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

5

在 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类使用带有签名void Execute(object sender, EventArgs e)的标准同步事件处理委托。此外,由于存在ThreadHelper.ThrowIfNotOnUIThread(),很明显Execute方法体将在UI线程上运行,这意味着在自定义命令的方法体中运行任何阻塞同步操作都是不好的。或者说,在那个 Execute() 处理程序的主体中不要执行任何非常长的操作。
因此,我想使用async/await将自定义命令实现中的任何长时间运行的操作与UI线程解耦,但我不确定如何正确地将其放入VSIX MPF框架脚手架中。
如果我将Execute方法的签名更改为async void Execute(...),则VS会告诉我ThreadHelper.ThrowIfNotOnUIThread()调用存在问题: "Avoid throwing when not on the main thread while in an async or Task-returning method. Switch to the thread required instead." 我不确定如何“转换到所需的线程”。这就是InitializeAsync方法中的await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken)代码所做的吗?我应该只是复制它吗?
异常处理呢?如果我允许同步的void Execute()处理程序引发异常,VS将捕获它并显示通用错误消息框。但是,如果我将其更改为async void Execute(),则未捕获的异常不会在调用Execute的线程上引发,并可能在其他地方引起更严重的问题。这里应该做什么正确的事情?通过同步访问Task.Result以在正确的上下文中重新抛出异常似乎是众所周知的死锁的典型示例。我应该在实现中捕获所有异常,并为任何无法更优雅地处理的内容显示自己的通用消息框吗?
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会进行基本的错误处理并显示一个简单的消息框: basic error message box for synchronously-thrown exception 点击“确定”按钮关闭消息框后,VS将继续工作,不受“有缺陷”的自定义命令的影响。
现在假设我将自定义命令的Execute事件处理程序更改为天真的异步实现:
    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将异步代码中的未处理异常与同步代码中的未处理异常视为相同,而又不会冒着主线程死锁的风险?

2
这里有很多问题。你看过这个例子了吗?https://learn.microsoft.com/en-us/visualstudio/extensibility/how-to-provide-an-asynchronous-visual-studio-service?view=vs-2019 - TheGeneral
只是为了提供背景信息,我将在自定义命令中使用MySqlConnector包。作者强烈建议使用它的异步方法而不是同步方法。(https://mysqlconnector.net/tutorials/best-practices/) 这让我很想用async/await编写自定义命令,而不是同步方式。 - Hydrargyrum
1
啊,我没有看到文章中“在命令处理程序中使用异步服务”的小标题。我浏览了一下文章,因为我认为我不需要编写服务。我会去看看的。 - Hydrargyrum
我已经阅读了它,但它非常令人困惑!逐步说明谈到将同步包适应为异步包,但在VS2017 15.9.25中,同步包模板似乎不再存在;只有 AsyncPackage。而将自定义命令调用异步服务的适配说明涉及到一些现代自定义命令模板中不存在的方法,例如 MenuItemCallback() ,我认为这在当前模板中被 Execute() 取代了。 - Hydrargyrum
@TheGeneral链接的文章中有示例代码,其中命令的MenuItemCallback()方法(相当于Execute())对异步服务进行了fire-and-forget调用。它调用返回Task的服务而没有等待结果,并且没有处理异常。VS可以在不终止的情况下处理异常,但在这种情况下似乎完全未被观察到。我不认为这是最佳实践。 - Hydrargyrum
显示剩余5条评论
2个回答

5
之前被接受的答案会产生编译警告,VSTHRD100 '避免使用异步 void 方法',这表明它可能不完全正确。实际上,Microsoft 线程文档有一个规则,绝不定义异步 void 方法
我认为在这里使用 JoinableTaskFactory 的 RunAsync 方法才是正确的答案。代码如下所示。Microsoft 的 Andrew Arnott 说:“这比使用异步 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);
        });
    }

0

描述正确使用ThreadHelper.JoinableTaskFactory API的文档在这里

最终,我做了以下操作:

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
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接