VSIX 中自定义命令的异步实现

Async implementation of Custom Commands in VSIX

当您在 VSIX 项目中添加模板自定义命令时,Visual Studio 生成的脚手架代码包括以下通用结构:

    /// <summary>
    /// Initializes a new instance of the <see cref="GenerateConfigSetterCommand"/> class.
    /// Adds our command handlers for menu (commands must exist in the command table file)
    /// </summary>
    /// <param name="package">Owner package, not null.</param>
    /// <param name="commandService">Command service to add command to, not null.</param>
    private GenerateConfigSetterCommand(AsyncPackage package, OleMenuCommandService commandService)
    {
        this.package = package ?? throw new ArgumentNullException(nameof(package));
        commandService = commandService ?? throw new ArgumentNullException(nameof(commandService));

        var menuCommandID = new CommandID(CommandSet, CommandId);
        var menuItem = new MenuCommand(this.Execute, menuCommandID);
        commandService.AddCommand(menuItem);
    }

    /// <summary>
    /// This function is the callback used to execute the command when the menu item is clicked.
    /// See the constructor to see how the menu item is associated with this function using
    /// OleMenuCommandService service and MenuCommand class.
    /// </summary>
    /// <param name="sender">Event sender.</param>
    /// <param name="e">Event args.</param>
    private void Execute(object sender, EventArgs e)
    {
        ThreadHelper.ThrowIfNotOnUIThread();
        
        // TODO: Command implementation goes here
    }

    /// <summary>
    /// Initializes the singleton instance of the command.
    /// </summary>
    /// <param name="package">Owner package, not null.</param>
    public static async Task InitializeAsync(AsyncPackage package)
    {
        // Switch to the main thread - the call to AddCommand in GenerateConfigSetterCommand's constructor requires
        // the UI thread.
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);

        OleMenuCommandService commandService = await package.GetServiceAsync((typeof(IMenuCommandService))) as OleMenuCommandService;
        Instance = new GenerateConfigSetterCommand(package, commandService);
    }

请注意,框架提供的 MenuCommand class 采用带有签名 void Execute(object sender, EventArgs e) 的标准同步事件处理委托。此外,从 ThreadHelper.ThrowIfNotOnUIThread() 的存在来看,很明显 Execute 方法的主体确实是 UI 线程上的 运行,这意味着它将是在我的自定义命令正文中包含任何阻塞同步操作 运行 是个坏主意。或者在 Execute() 处理程序的主体中做 任何事情 很长 运行。

所以我想使用 async/await 将自定义命令实现中的任何长 运行 操作与 UI 线程分离,但我不确定如何正确地将其放入 VSIX MPF 框架脚手架中。

如果我将 Execute 方法的签名更改为 async void Execute(...),VS 会告诉我 ThreadHelper.ThrowIfNotOnUIThread() 调用有问题:

我不确定如何“改为切换到所需的线程”。 InitializeAsync 方法中的 await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken) 代码就是这样做的吗?我应该复制那个吗?

异常处理呢?如果我允许同步 void Execute() 处理程序抛出异常,VS 将捕获它并显示一个通用错误消息框。但是,如果我将其更改为 async void Execute(),那么未捕获的异常 将不会在调用 Execute 的线程上引发 ,并且可能会在其他地方引起更严重的问题。在这里正确的做法是什么?同步访问 Task.Result 以在正确的上下文中重新抛出异常似乎是 well-known deadlock 的典型示例。我是否应该只捕获我的实现中的所有异常并显示我自己的通用消息框来处理无法更优雅地处理的任何事情?

编辑以提出更具体的问题

这是一个伪造的同步自定义命令实现:

internal sealed class GenerateConfigSetterCommand
{
    [...snip the rest of the class...]

    /// <summary>
    /// This function is the callback used to execute the command when the menu item is clicked.
    /// See the constructor to see how the menu item is associated with this function using
    /// OleMenuCommandService service and MenuCommand class.
    /// </summary>
    /// <param name="sender">Event sender.</param>
    /// <param name="e">Event args.</param>
    private void Execute(object sender, EventArgs e)
    {
        ThreadHelper.ThrowIfNotOnUIThread();

        // Command implementation goes here
        WidgetFrobulator.DoIt();
    }
}

class WidgetFrobulator
{
    public static void DoIt()
    {
        Thread.Sleep(1000);
        throw new NotImplementedException("Synchronous exception");
    }


    public static async Task DoItAsync()
    {
        await Task.Delay(1000);
        throw new NotImplementedException("Asynchronous exception");
    }
}

单击自定义命令按钮时,VS 有一些基本的错误处理,显示一个简单的消息框:

单击确定关闭消息框,VS 继续工作,不受“错误”自定义命令的干扰。

现在假设我将自定义命令的执行事件处理程序更改为简单的异步实现:

    private async void Execute(object sender, EventArgs e)
    {
        // Cargo cult attempt to ensure that the continuation runs on the correct thread, copied from the scaffolding code's InitializeAsync() method.
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);

        // Command implementation goes here
        await WidgetFrobulator.DoItAsync();
    }

现在,当我单击命令按钮时,Visual Studio 由于未处理的异常而终止。

我的问题是: 处理由异步 VSIX 自定义命令实现引起的异常的最佳实践方法是什么,这导致 VS 处理 async 代码中未处理的异常的方式与处理 [=57= 中未处理的异常的方式相同]同步 代码,没有主线程死锁的风险?

描述正确用法的文档 ThreadHelper.JoinableTaskFactory API 是 here

最后,我做了以下事情:

private async void Execute(object sender, EventArgs e)
{
    try
    {
         await CommandBody();
    }
    catch (Exception ex)
    {
        // Generic last-chance MessageBox display 
        // to ensure the async exception can't kill Visual Studio.
        // Note that software for end-users (as opposed to internal tools)
        // should usually log these details instead of displaying them directly to the user.
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

        VsShellUtilities.ShowMessageBox(
            this._package,
            ex.ToString(),
            "Command failed",
            OLEMSGICON.OLEMSGICON_CRITICAL,
            OLEMSGBUTTON.OLEMSGBUTTON_OK,
            OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
    }
}

private async Task CommandBody()
{
    // Actual implementation logic in here
}

已接受的答案会生成编译器警告 VSTHRD100 'Avoid async void methods',这表明它可能不完全正确。事实上 Microsoft threading documentation has a rule to never define async void methods.

我认为这里的正确答案是使用 JoinableTaskFactory 的 RunAsync 方法。这看起来像下面的代码。 Andrew Arnott of Microsoft says '这比 async void 更可取,因为异常不会使应用程序崩溃,而且(更具体地说)应用程序不会在异步事件处理程序(可能正在保存文件,例如)。'

有几点需要注意。尽管异常不会使应用程序崩溃,但它们只会被吞噬,因此如果您想显示一个消息框,您仍然需要在 RunAsync 中使用 try..catch 块。此代码也是可重入的。我在下面的代码中展示了这一点:如果你快速点击菜单项两次,5 秒后你会收到两个消息框,都声称它们来自第二次调用。

    // Click the menu item twice quickly to show reentrancy
    private int callCounter = 0;
    private void Execute(object sender, EventArgs e)
    {
        ThreadHelper.ThrowIfNotOnUIThread();
        package.JoinableTaskFactory.RunAsync(async () =>
        {
            callCounter++;
            await Task.Delay(5000);
            string message = $"This message is from call number {callCounter}";
            VsShellUtilities.ShowMessageBox(package, message, "", 
                OLEMSGICON.OLEMSGICON_INFO, OLEMSGBUTTON.OLEMSGBUTTON_OK, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
        });
    }