在 .Select lambda 中使用 async/await

34

我正在使用Asp.Net Core Identity,并尝试简化一些代码,以将用户列表及其角色投影到ViewModel中。 这段代码可以正常工作,但是当我尝试简化它时,我进入了一个疯狂的错误和好奇心的旋涡。

以下是我的工作代码:

        var allUsers = _userManager.Users.OrderBy(x => x.FirstName);
        var usersViewModel = new List<UsersViewModel>();

        foreach (var user in allUsers)
        {
            var tempVm = new UsersViewModel()
            {
                Id = user.Id,
                UserName = user.UserName,
                FirstName = user.FirstName,
                LastName = user.LastName,
                DisplayName = user.DisplayName,
                Email = user.Email,
                Enabled = user.Enabled,
                Roles = String.Join(", ", await _userManager.GetRolesAsync(user))
            };

            usersViewModel.Add(tempVm);
        }

为了简化代码,我想可以像这样做(破损的代码)

        var usersViewModel = allUsers.Select(user => new UsersViewModel
        {
            Id = user.Id,
            UserName = user.UserName,
            FirstName = user.FirstName,
            LastName = user.LastName,
            DisplayName = user.DisplayName,
            Email = user.Email,
            Enabled = user.Enabled,
            Roles = string.Join(", ", await _userManager.GetRolesAsync(user))
        }).ToList();

由于在user之前的 lambda 表达式中没有使用 async 关键字,因此代码会出现错误。但是,当我在user之前添加 async 关键字时,又会出现另一个错误,显示“异步 lambda 表达式无法转换为表达式树”。

我猜测 GetRolesAsync() 方法返回的是 Task,而不是该任务的实际结果并将其分配给 Roles。至于如何使其正常工作,我好像无论如何都弄不明白。

过去一天里,我研究并尝试了许多方法,但都没有成功。以下是我查看的一些示例:

Is it possible to call an awaitable method in a non async method?

https://blogs.msdn.microsoft.com/pfxteam/2012/04/12/asyncawait-faq/

Calling async method in IEnumerable.Select

How to await a list of tasks asynchronously using LINQ?

how to user async/await inside a lambda

How to use async within a lambda which returns a collection

诚然,我并不完全理解 async / await 的工作原理,这可能是问题的一部分。我的 foreach 代码可以工作,但我想要理解如何实现我试图做到的方式。由于我已经花了这么多时间,所以觉得这是一个很好的第一个问题。

谢谢!

编辑

我猜我必须解释一下我在每个参考文章中所做的事情,这样这篇文章才不会被标记为重复问题 - 我真的很努力地避免这种情况 :-/。虽然问题听起来相似,但结果却不同。在被标记为答案的文章中,我尝试了以下代码:

    public async Task<ActionResult> Users()
    {
        var allUsers = _userManager.Users.OrderBy(x => x.FirstName);
        var tasks = allUsers.Select(GetUserViewModelAsync).ToList();
        return View(await Task.WhenAll(tasks));
    }

    public async Task<UsersViewModel> GetUserViewModelAsync(ApplicationUser user)
    {
        return new UsersViewModel
        {
            Id = user.Id,
            UserName = user.UserName,
            FirstName = user.FirstName,
            LastName = user.LastName,
            DisplayName = user.DisplayName,
            Email = user.Email,
            Enabled = user.Enabled,
            Roles = String.Join(", ", await _userManager.GetRolesAsync(user))
        };
    }

我也尝试使用 AsEnumerable,像这样:

    var usersViewModel = allUsers.AsEnumerable().Select(async user => new UsersViewModel
        {
            Id = user.Id,
            UserName = user.UserName,
            FirstName = user.FirstName,
            LastName = user.LastName,
            DisplayName = user.DisplayName,
            Email = user.Email,
            Enabled = user.Enabled,
            Roles = string.Join(", ", await _userManager.GetRolesAsync(user))
        }).ToList();

这两者都会产生错误消息:"InvalidOperationException: A second operation started on this context before a previous operation completed. Any instance members are not guaranteed to be thread safe."

此时,似乎我的原始 ForEach 方法可能是最佳选择,但如果我使用异步方法进行操作,应该怎么做才是正确的仍然让我感到疑惑。

编辑 2 - 包含答案 在 Tseng 的评论(和其他一些研究)的帮助下,我能够使用以下代码使事情正常运行:

        var userViewModels = allUsers.Result.Select(async user => new UsersViewModel
        {
            Id = user.Id,
            UserName = user.UserName,
            FirstName = user.FirstName,
            LastName = user.LastName,
            DisplayName = user.DisplayName,
            Email = user.Email,
            Enabled = user.Enabled,
            Roles = string.Join(", ", await _userManager.GetRolesAsync(user))
        });
        var vms = await Task.WhenAll(userViewModels);
        return View(vms.ToList());

尽管我已经考虑了每个人的评论,但我还是开始更仔细地查看SQL Profiler,以了解实际上有多少次访问数据库-正如Matt Johnson提到的那样,这很多(N+1)。

因此,虽然这确实回答了我的问题,但现在我正在重新考虑如何运行查询,可能只需在选择每个用户时删除主视图中的角色并仅拉取它们。 通过这个问题,我肯定学到了很多东西(也学到了更多我不知道的东西),所以谢谢大家。


我的猜测是GetRolesAsync()方法返回的是一个Task,而将其分配给Roles,而不是该任务的实际结果。但很可能不是这样,因为await会获取任务的结果。 - mason
2
尝试在Select之前加上AsEnumerable,这样它就会在LInq to Objects中运行,而不是尝试将其转换为EF或其他提供程序的表达式树。 - juharr
1
var usersViewModels = (await Task.WhenAll(allUsers.AsEnumerable().Select(async user => new UsersViewModel { Id = user.Id, UserName = user.UserName, FirstName = user.FirstName, LastName = user.LastName, DisplayName = user.DisplayName, Email = user.Email, Enabled = user.Enabled, Roles = string.Join(", ", await _userManager.GetRolesAsync(user)) }))).ToList(); - Matthew Whited
可能是重复问题:Entity Framework 6中的多异步? - poke
基本上,由于GetRolesAsync显然也在数据库上下文中运行某些内容,因此您不能同时进行多个并行的GetRolesAsync调用。您需要按顺序运行它们。根据Matt Johnson的评论,如果您确实需要它们,应考虑一种同时查询多个(所有)用户角色的方法。 - 顺便说一句。您最初的问题完全省略了与这些链接问题相关的部分,现在您已经编辑了它,我不清楚为什么您没有搜索错误消息。 - poke
显示剩余3条评论
3个回答

18
我认为你在混淆两件事情。表达式树和委托。Lambda 可以用于表达它们两个,但它取决于方法接受的参数类型将转换成哪一个。
传递给 Action<T>Func<T, TResult> 方法的 lambda 将被转换为委托(基本上是匿名函数/方法)。
当你将 lambda 表达式传递给接受 Expression<T> 的方法时,你从 lambda 创建了一个表达式树。表达式树只是描述代码的代码,但它们本身不是代码。
话虽如此,表达式树不能被执行,因为它被转换为可执行代码。你可以在运行时编译表达式树,然后像委托一样执行它。
ORM 框架使用表达式树允许你编写“代码”,该代码可以被翻译成不同的东西(例如数据库查询),或者在运行时动态生成代码。
因此,在接受 Expression<T> 的方法中不能使用async。当你将其转换为 AsEnumerable() 时它可能会起作用的原因是因为它返回一个 IEnumerable<T>,并且它上面的 LINQ 方法接受 Func<T, TResult>。但它本质上是在内存中获取整个查询并执行整个操作,因此你不能使用投影(或者你必须在使用表达式和投影之前获取数据),将过滤后的结果转换为列表,然后再对其进行过滤。
你可以尝试这样做:
// Filter, sort, project it into the view model type and get the data as a list
var users = await allUsers.OrderBy(user => user.FirstName)
                             .Select(user => new UsersViewModel
    {
        Id = user.Id,
        UserName = user.UserName,
        FirstName = user.FirstName,
        LastName = user.LastName,
        DisplayName = user.DisplayName,
        Email = user.Email,
        Enabled = user.Enabled
    }).ToListAsync();

// now that we have the data, we iterate though it and 
// fetch the roles
var userViewModels = users.Select(async user => 
{
    user.Roles = string.Join(", ", await _userManager.GetRolesAsync(user))
});

第一部分将完全在数据库上执行,您将保留所有优势(即订单在数据库上进行,因此在获取结果后不必执行内存排序,而限制调用则限制从数据库中提取的数据等)。
第二部分在内存中迭代结果,并提取每个临时模型的数据,最终将其映射到视图模型。

0

扩展方法:

public static async Task<IEnumerable<TDest>> SelectSerialAsync<TSource, TDest>(this IEnumerable<TSource> sourceElements, Func<TSource, Task<TDest>> func)
{
    List<TDest> destElements = new List<TDest>();

    foreach (TSource sourceElement in sourceElements)
    {
        TDest destElement = await func(sourceElement);
        destElements.Add(destElement);
    }

    return destElements;
}

用法:

DestType[] array = (await sourceElements.SelectSerialAsync<SourceType, DestType>(
    async (sourceElement) => { return await SomeAsyncMethodCall(sourceElement); }
)).ToArray();

“carried deferred execution semantics”是什么意思?我返回IEnumerable的原因是因为Linq库的其余部分也这样做。我只想保持一致。我在调用者上使用ToList()或ToArray(),根据需要选择。由于此方法适用于源类型和目标类型,我更喜欢使用TSource和TDest。不过,感谢Linq.Async的提示。 - Jay
当你说“deferral”时,你可能指的是“yield”。我从不使用它。在我的用例中,我没有需要它,据我所知。因此,它也不会出现在我的扩展方法中。但是你知道吗,我会研究一下“yield”。在我了解更多信息之后,我可能会找到它的用途。 - Jay
这就是为什么我将它添加到我的扩展方法集合中的原因。它完全符合我的需求,也符合 OP 的需求。所以我想分享一下。 - Jay
起初,它关于我的类型的命名。 - Jay
这并没有什么误导性。在IEnumerable中使用yield并不是必须的。我不明白将类型重命名如何能让一个三行的代码片段变得更好。但是,既然你提供了,我会进行一些小的编辑。 - Jay

0

这里有一个解决方案,可以让你返回一个List

var userViewModels = (await allUsers).Select(async user => new UsersViewModel
        {
            Id = user.Id,
            UserName = user.UserName,
            FirstName = user.FirstName,
            LastName = user.LastName,
            DisplayName = user.DisplayName,
            Email = user.Email,
            Enabled = user.Enabled,
            Roles = string.Join(", ", await _userManager.GetRolesAsync(user))
        }).Select(q => q.Result);

更新1:

感谢@TheodorZoulias的提醒,我意识到.Select(q => q.Result)会导致线程阻塞。 因此,我认为最好使用这个解决方案,直到有人找到更好的方法。它也可能会打乱项目的顺序。

List<UsersViewModel> userViewModels = new();
(await allUsers)
.Select(async user => new UsersViewModel()
{
    //(...)
    Roles = string.Join(", ", await _userManager.GetRolesAsync(user))
})
.ToList()
.ForEach(async q => userViewModels.Add(await q));

1
allUsers.Result?这不会阻塞当前线程吗? - Theodor Zoulias
@TheodorZoulias: 是的,你是对的。非常感谢您的评论。我曾经使用过这种方式,但现在这不是一个好主意。您知道返回List<T>而不是Task<List<T>>的正确解决方案吗? - Arash Ghasemi Rad
不好意思,我不知道。据我所知,唯一正确的解决方案是全程异步 - Theodor Zoulias
@TheodorZoulias 在 API 控制器中使用 Async All the Way 不是很有用,最好不要返回 Task<>。我更新了我的答案,提供了一些更好的解决方案。我们来讨论一下吧。 - Arash Ghasemi Rad
如果你要在外部作用域中使用 await,那么为什么不在 allUsers 上也使用它呢?= (await allUsers).Select(...。在我看来,使用 .Result 是一种失败的表现。 - Theodor Zoulias
1
你又说对了,我的注意点在于返回类型,我又更新了答案,现在看起来更加简洁。 - Arash Ghasemi Rad

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