LINQ 不念不忘少年蓝@ 2023-02-21 14:08 27阅读 0赞 # 简介 # LINQ(Language Integrated Query,语言集成查询)在C\#编程语言中集成了查询语法,可以用相同的语法访问不同的数据源。LINQ提供了不同数据源的抽象层,所以可以使用相同的语法。 from r in Formula1.GetChampions() where r.Country == "Brazil" orderby r.Wins descending select r; 子句from、where、orderby、descending和select都是这个查询中预定义的关键字。 查询表达式必须以from子句开头,以select或group子句结束。在这两个子句之间,可以使用where、orderby、join、let和其他from子句。 # 标准查询操作符 # <table> <thead> <tr> <th>标准查询操作符</th> <th>说明</th> </tr> </thead> <tbody> <tr> <td>Where OfType </td> <td>筛选操作符定义了返回元素的条件。在Where查询操作符中可以使用谓词,例如,lambda表达式定义的谓词,来返回布尔值。OfType 根据类型筛选元素,只返回TResult类型的元素 </td> </tr> <tr> <td>SelectSelectMany</td> <td>投射操作符用于把对象转换为另一个类型的新对象。Select和SelectMany定义了根据选择器函数选择结果值的投射</td> </tr> <tr> <td>OrderByThenByOrderByDescendingThenByDescendingReverse</td> <td>排序操作符改变所返回的元素的顺序。OrderBy按升序排序,OrderByDescending按降序排序。如果第一次排序的结果很类似,就可以使用ThenBy和ThenByDescending操作符进行第二次排序。Reverse反转集合中元素的顺序</td> </tr> <tr> <td>JoinGroupJoin</td> <td>连接操作符用于合并不直接相关的集合。使用Join操作符,可以根据键选择器函数连接两个集合,这类似于SQL中的JOIN。GroupJoin操作符连接两个集合,组合其结果</td> </tr> <tr> <td>GroupByToLookup</td> <td>组合操作符把数据放在组中。GroupBy操作符组合有公共键的元素。ToLookup通过创建一个一对多字典,来组合元素</td> </tr> <tr> <td>AnyAllContains</td> <td>如果元素序列满足指定的条件,限定符操作符就返回布尔值。Any、All和Contains都是限定符操作符。Any确定集合中是否有满足谓词函数的元素;All确定集合中的所有元素是否都满足谓词函数;Contains检查某个元素是否在集合中</td> </tr> <tr> <td>TakeSkipTakeWhileSkipWhile</td> <td>分区操作符返回集合的一个子集。Take、Skip、TakeWhile和SkipWhile都是分区操作符。使用它们可以得到部分结果。使用Take必须指定要从集合中提取的元素个数;Skip跳过指定的元素个数,提取其他元素;TakeWhile提取条件为真的元素,SkipWhile跳过条件为真的元素</td> </tr> <tr> <td>DistinctUnionIntersectExceptZip</td> <td>Set操作符返回一个集合。Distinct从集合中删除重复的元素。除了Distinct之外,其他Set操作符都需要两个集合。Union返回出现在其中一个集合中的唯一元素。Intersect返回两个集合中都有的元素。Except返回只出现在一个集合中的元素。Zip把两个集合合并为一个</td> </tr> <tr> <td>FirstFirstOrDefaultLastLastOrDefaultElementAtElementAtOrDefaultSingleSingleOrDefault</td> <td>这些元素操作符仅返回一个元素。First返回第一个满足条件的元素。FirstOrDefault类似于First,但如果没有找到满足条件的元素,就返回类型的默认值。Last返回最后一个满足条件的元素。ElementAt指定了要返回的元素的位置。Single只返回一个满足条件的元素。如果有多个元素都满足条件,就抛出一个异常。所有的XXOrDefault方法都类似于以相同前缀开头的方法,但如果没有找到该元素,它们就返回类型的默认值</td> </tr> <tr> <td>CountSumMinMaxAverageAggregate</td> <td>聚合操作符计算集合的一个值。利用这些聚合操作符,可以计算所有值的总和、所有元素的个数、值最大和最小的元素,以及平均值等</td> </tr> <tr> <td>ToArrayAsEnumerableToListToDictionaryCast </td> <td>这些转换操作符将集合转换为数组:IEnumerable、IList、IDictionary等。Cast方法把集合的每个元素类型转换为泛型参数类型</td> </tr> <tr> <td>EmptyRangeRepeat</td> <td>这些生成操作符返回一个新集合。使用Empty时集合是空的;Range返回一系列数字;Repeat返回一个始终重复一个值的集合</td> </tr> </tbody> </table> ## 筛选 ## 使用where子句,可以合并多个表达式。例如,找出赢得至少15场比赛的巴西和奥地利赛车手。传递给where子句的表达式的结果类型应是布尔类型: var racers = from r in Formula1.GetChampions() where r.Wins > 15 && (r.Country == "Brazil" || r.Country == "Austria") select r; foreach (var r in racers) { WriteLine($"{r:A}"); } ## 用索引筛选 ## 不能使用LINQ查询的一个例子是Where()方法的重载。在Where()方法的重载中,可以传递第二个参数——索引。索引是筛选器返回的每个结果的计数器。可以在表达式中使用这个索引,执行基于索引的计算。下面的代码由Where()扩展方法调用,它使用索引返回姓氏以A开头、索引为偶数的赛车手(代码文件EnumerableSample/Program.cs): var racers = Formula1.GetChampions(). Where((r, index) => r.LastName.StartsWith("A") && index % 2 ! = 0); foreach (var r in racers) { WriteLine($"{r:A}"); } 注:索引及下标,入abc\[2\]. ## 类型筛选 ## 为了进行基于类型的筛选,可以使用OfType()扩展方法。这里数组数据包含string和int对象。使用OfType()扩展方法,把string类传送给泛型参数,就从集合中仅返回字符串: object[] data = { "one", 2, 3, "four", "five", 6 }; var query = data.OfType<string>(); foreach (var s in query) { WriteLine(s); } ## 复合的from子句 ## 如果需要根据对象的一个成员进行筛选,而该成员本身是一个系列,就可以使用复合的from子句。Racer类定义了一个属性Cars,其中Cars是一个字符串数组。要筛选驾驶法拉利的所有冠军,可以使用如下所示的LINQ查询。第一个from子句访问从Formula1.Get Champions()方法返回的Racer对象,第二个from子句访问Racer类的Cars属性,以返回所有string类型的赛车。接着在where子句中使用这些赛车筛选驾驶法拉利的所有冠军(代码文件EnumerableSample/Program.cs)。 var ferrariDrivers = from r in Formula1.GetChampions() from c in r.Cars where c == "Ferrari" orderby r.LastName select r.FirstName + " " + r.LastName; ## 排序 ## 要对序列排序,前面使用了orderby子句。下面复习一下前面使用的例子,但这里使用orderby descending子句。其中赛车手按照赢得比赛的次数进行降序排序,赢得比赛的次数用关键字选择器指定: var racers = from r in Formula1.GetChampions() where r.Country == "Brazil" orderby r.Wins descending select r; OrderBy()和OrderByDescending()方法返回IOrderEnumerable 。这个接口派生自IEnumerable 接口,但包含一个额外的方法CreateOrderedEnumerable ()。这个方法用于进一步给序列排序。如果根据关键字选择器来排序,其中有两项相同,就可以使用ThenBy()和ThenByDescending ()方法继续排序。这两个方法需要IOrderEnumerable 接口才能工作,但也返回这个接口。所以,可以添加任意多个ThenBy()和ThenByDescending()方法,对集合排序。 使用LINQ查询时,只需要把所有用于排序的不同关键字(用逗号分隔开)添加到orderby子句中。在下例中,所有的赛车手先按照国家排序,再按照姓氏排序,最后按照名字排序。添加到LINQ查询结果中的Take()扩展方法用于返回前10个结果: var racers = (from r in Formula1.GetChampions() orderby r.Country, r.LastName, r.FirstName select r).Take(10); ## 分组 ## 要根据一个关键字值对查询结果分组,可以使用group子句。现在一级方程式冠军应按照国家分组,并列出一个国家的冠军数。子句group r by r.Country into g根据Country属性组合所有的赛车手,并定义一个新的标识符g,它以后用于访问分组的结果信息。group子句的结果根据应用到分组结果上的扩展方法Count()来排序,如果冠军数相同,就根据关键字来排序,该关键字是国家,因为这是分组所使用的关键字。where子句根据至少有两项的分组来筛选结果,select子句创建一个带Country和Count属性的匿名类型。 var countries = from r in Formula1.GetChampions() group r by r.Country into g orderby g.Count() descending, g.Key where g.Count() >= 2 select new { Country = g.Key, Count = g.Count() }; foreach (var item in countries) { WriteLine($"{item.Country, -10} {item.Count}"); } **例程:** using System; using System.Collections.Generic; using System.Linq; namespace CSharpTest { class progress { class Person { public string Name { set; get; } public int Age { set; get; } public string Gender { set; get; } public override string ToString() => Name; } public static int Main() { List<Person> personList = new List<Person> { new Person { Name = "P1", Age = 18, Gender = "Male" }, new Person { Name = "P2", Age = 19, Gender = "Male", }, new Person { Name = "P2", Age = 17,Gender = "Female", } }; //var groups = personList.GroupBy(p => p.Gender); var groups = from p in personList group p by p.Gender; foreach (var group in groups) { Console.WriteLine(group.Key); foreach (var person in group) { Console.WriteLine($"\t{person.Name},{person.Age}"); } } return 0; } } } Male P1,18 P2,19 Female P2,17 ## LINQ查询中的变量 ## 在为分组编写的LINQ查询中,Count方法调用了多次。使用let子句可以改变这种方式。let允许在LINQ查询中定义变量: var countries = from r in Formula1.GetChampions() group r by r.Country into g let count = g.Count() orderby count descending, g.Key where count >= 2 select new { Country = g.Key, Count = count }; ## 对嵌套的对象分组 ## 如果分组的对象应包含嵌套的序列,就可以改变select子句创建的匿名类型。在下面的例子中,所返回的国家不仅应包含国家名和赛车手数量这两个属性,还应包含赛车手的名序列。这个序列用一个赋予Racers属性的from/in内部子句指定,内部的from子句使用分组标识符g获得该分组中的所有赛车手,用姓氏对它们排序,再根据姓名创建一个新字符串: var countries = from r in Formula1.GetChampions() group r by r.Country into g let count = g.Count() orderby count descending, g.Key where count >= 2 select new { Country = g.Key, Count = count, Racers = from r1 in g orderby r1.LastName select r1.FirstName + " " + r1.LastName }; foreach (var item in countries) { WriteLine($"{item.Country, -10} {item.Count}"); foreach (var name in item.Racers) { Write($"{name}; "); } WriteLine(); } ## 内连接 ## 使用join子句可以根据特定的条件合并两个数据源,但之前要获得两个要连接的列表。在一级方程式比赛中,有赛车手冠军和车队冠军。赛车手从GetChampions()方法中返回,车队从GetConstructorChampions()方法中返回。现在要获得一个年份列表,列出每年的赛车手冠军和车队冠军。为此,先定义两个查询,用于查询赛车手和车队: var racers = from r in Formula1.GetChampions() from y in r.Years select new { Year = y, Name = r.FirstName + " " + r.LastName }; var teams = from t in Formula1.GetContructorChampions() from y in t.Years select new { Year = y, Name = t.Name }; 有了这两个查询,再通过join子句,根据赛车手获得冠军的年份和车队获得冠军的年份进行连接。select子句定义了一个新的匿名类型,它包含Year、Racer和Team属性。 var racersAndTeams = (from r in racers join t in teams on r.Year equals t.Year select new { r.Year, Champion = r.Name, Constructor = t.Name }).Take(10); WriteLine("Year World Champion\t Constructor Title"); foreach (var item in racersAndTeams) { WriteLine($"{item.Year}: {item.Champion, -20} {item.Constructor}"); } ## 左外连接 ## 上一个连接示例的输出从1958年开始,因为从这一年开始,才同时有了赛车手冠军和车队冠军。赛车手冠军出现得更早一些,是在1950年。使用内连接时,只有找到了匹配的记录才返回结果。为了在结果中包含所有的年份,可以使用左外连接。左外连接返回左边序列中的全部元素,即使它们在右边的序列中并没有匹配的元素。 下面修改前面的LINQ查询,使用左外连接。左外连接用join子句和DefaultIfEmpty方法定义。如果查询的左侧(赛车手)没有匹配的车队冠军,那么就使用DefaultIfEmpty方法定义其右侧的默认值: var racersAndTeams = (from r in racers join t in teams on r.Year equals t.Year into rt from t in rt.DefaultIfEmpty() orderby r.Year select new { Year = r.Year, Champion = r.Name, Constructor = t == null ? "no constructor championship" : t.Name }).Take(10); ## 组连接 ## 左外连接使用了组连接和into子句。它有一部分语法与组连接相同,只不过组连接不使用DefaultIfEmpty方法。 使用组连接时,可以连接两个独立的序列,对于其中一个序列中的某个元素,另一个序列中存在对应的一个项列表。 TODO ## 集合操作 ## TODO ## 合并 ## Zip()方法允许用一个谓词函数把两个相关的序列合并为一个。 首先,创建两个相关的序列,它们使用相同的筛选(国家意大利)和排序方法。对于合并,这很重要,因为第一个集合中的第一项会与第二个集合中的第一项合并,第一个集合中的第二项会与第二个集合中的第二项合并,依此类推。如果两个序列的项数不同,Zip()方法就在到达较小集合的末尾时停止。 第一个集合中的元素有一个Name属性,第二个集合中的元素有LastName和Starts两个属性。 在racerNames集合上使用Zip()方法,需要把第二个集合(racerNamesAndStarts)作为第一个参数。第二个参数的类型是Func<TFirst, TSecond, TResult>。这个参数实现为一个lambda表达式,它通过参数first接收第一个集合的元素,通过参数second接收第二个集合的元素。其实现代码创建并返回一个字符串,该字符串包含第一个集合中元素的Name属性和第二个集合中元素的Starts属性: var racerNames = from r in Formula1.GetChampions() where r.Country == "Italy" orderby r.Wins descending select new { Name = r.FirstName + " " + r.LastName }; var racerNamesAndStarts = from r in Formula1.GetChampions() where r.Country == "Italy" orderby r.Wins descending select new { LastName = r.LastName, Starts = r.Starts }; var racers = racerNames.Zip(racerNamesAndStarts, (first, second) => first.Name + ", starts: " + second.Starts); foreach (var r in racers) { WriteLine(r); } ## 分区 ## 扩展方法Take()和Skip()等的分区操作可用于分页,例如,在第一个页面上只显示5个赛车手,在下一个页面上显示接下来的5个赛车手等。 在下面的LINQ查询中,把扩展方法Skip()和Take()添加到查询的最后。Skip()方法先忽略根据页面大小和实际页数计算出的项数,再使用Take()方法根据页面大小提取一定数量的项: int pageSize = 5; int numberPages = (int)Math.Ceiling(Formula1.GetChampions().Count() / (double)pageSize); for (int page = 0; page < numberPages; page++) { WriteLine($"Page {page}"); var racers = (from r in Formula1.GetChampions() orderby r.LastName, r.FirstName select r.FirstName + " " + r.LastName). Skip(page * pageSize).Take(pageSize); foreach (var name in racers) { WriteLine(name); } WriteLine(); } 下面输出了前3页: Page 0 Fernando Alonso Mario Andretti Alberto Ascari Jack Brabham Jenson Button Page 1 Jim Clark Juan Manuel Fangio Nino Farina Emerson Fittipaldi Mika Hakkinen Page 2 Lewis Hamilton Mike Hawthorn Damon Hill Graham Hill Phil Hill ## 聚合操作符 ## 聚合操作符(如Count、Sum、Min、Max、Average和Aggregate操作符)不返回一个序列,而返回一个值。 Count()扩展方法返回集合中的项数。下面的Count()方法应用于Racer的Years属性,来筛选赛车手,只返回获得冠军次数超过3次的赛车手。因为同一个查询中需要使用同一个计数超过一次,所以使用let子句定义了一个变量numberYears: var query = from r in Formula1.GetChampions() let numberYears = r.Years.Count() where numberYears >= 3 orderby numberYears descending, r.LastName select new { Name = r.FirstName + " " + r.LastName, TimesChampion = numberYears }; foreach (var r in query) { WriteLine($"{r.Name} {r.TimesChampion}"); } Sum()方法汇总序列中的所有数字,返回这些数字的和。下面的Sum()方法用于计算一个国家赢得比赛的总次数。首先根据国家对赛车手分组,再在新创建的匿名类型中,把Wins属性赋予某个国家赢得比赛的总次数: var countries = (from c in from r in Formula1.GetChampions() group r by r.Country into c select new { Country = c.Key, Wins = (from r1 in c select r1.Wins).Sum() } orderby c.Wins descending, c.Country select c).Take(5); foreach (var country in countries) { WriteLine("{country.Country} {country.Wins}"); } 方法Min()、Max()、Average()和Aggregate()的使用方式与Count()和Sum()相同。Min()方法返回集合中的最小值,Max()方法返回集合中的最大值,Average()方法计算集合中的平均值。对于Aggregate()方法,可以传递一个lambda表达式,该表达式对所有的值进行聚合。 ## 转换操作符 ## 本章前面提到,查询可以推迟到访问数据项时再执行。在迭代中使用查询时,查询会执行。而使用转换操作符会立即执行查询,把查询结果放在数组、列表或字典中。 在下面的例子中,调用ToList()扩展方法,立即执行查询,得到的结果放在List 类中: List<Racer> racers = (from r in Formula1.GetChampions() where r.Starts > 150 orderby r.Starts descending select r).ToList(); foreach (var racer in racers) { WriteLine($"{racer} {racer:S}"); } ## 生成操作符 ## 生成操作符Range()、Empty()和Repeat()不是扩展方法,而是返回序列的正常静态方法。在LINQ to Objects中,这些方法可用于Enumerable类。 有时需要填充一个范围的数字,此时就应使用Range()方法。这个方法把第一个参数作为起始值,把第二个参数作为要填充的项数: var values = Enumerable.Range(1, 20); foreach (var item in values) { Write($"{item} ", item); } WriteLine(); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Empty()方法返回一个不返回值的迭代器,它可以用于需要一个集合的参数,其中可以给参数传递空集合。 Repeat()方法返回一个迭代器,该迭代器把同一个值重复特定的次数。 # 并行LINQ # System.Linq名称空间中包含的类ParallelEnumerable可以分解查询的工作,使其分布在多个线程上。尽管Enumerable类给IEnumerable 接口定义了扩展方法,但ParallelEnumerable类的大多数扩展方法是ParallelQuery 类的扩展。一个重要的异常是AsParallel()方法,它扩展了IEnumerable 接口,返回ParallelQuery 类,所以正常的集合类可以以并行方式查询。 ## 并行查询 ## 为了说明并行LINQ(Parallel LINQ, PLINQ),需要一个大型集合。对于可以放在CPU的缓存中的小集合,并行LINQ看不出效果。在下面的代码中,用随机值填充一个大型的int集合: static IEnumerable<int> SampleData() { const int arraySize = 50000000; var r = new Random(); return Enumerable.Range(0, arraySize).Select(x => r.Next(140)).ToList(); } 现在可以使用LINQ查询筛选数据,进行一些计算,获取所筛选数据的平均数。该查询用where子句定义了一个筛选器,仅汇总对应值小于20的项,接着调用聚合函数Sum()方法。与前面的LINQ查询的唯一区别是,这次调用了AsParallel()方法。 var res = (from x in data.AsParallel() where Math.Log(x) < 4 select x).Average(); 与前面的LINQ查询一样,编译器会修改语法,以调用AsParallel()、Where()、Select()和Average()方法。AsParallel()方法用ParallelEnumerable类定义,以扩展IEnumerable 接口,所以可以对简单的数组调用它。AsParallel()方法返回ParallelQuery 。因为返回的类型,所以编译器选择的Where()方法是ParallelEnumerable.Where(),而不是Enumerable.Where()。在下面的代码中,Select()和Average()方法也来自ParallelEnumerable类。与Enumerable类的实现代码相反,对于ParallelEnumerable类,查询是分区的,以便多个线程可以同时处理该查询。集合可以分为多个部分,其中每个部分由不同的线程处理,以筛选其余项。完成分区的工作后,就需要合并,获得所有部分的总和。 var res = data.AsParallel().Where(x => Math.Log(x) < 4). Select(x => x).Average(); 运行这行代码会启动任务管理器,这样就可以看出系统的所有CPU都在忙碌。如果删除AsParallel()方法,就不可能使用多个CPU。当然,如果系统上没有多个CPU,就不会看到并行版本带来的改进。 ## 分区器 ## AsParallel()方法不仅扩展了IEnumerable 接口,还扩展了Partitioner类。通过它,可以影响要创建的分区。 Partitioner类用System.Collection.Concurrent名称空间定义,并且有不同的变体。Create()方法接受实现了IList 类的数组或对象。根据这一点,以及Boolean类型的参数loadBalance和该方法的一些重载版本,会返回一个不同的Partitioner类型。对于数组,使用派生自抽象基类OrderablePartitioner 的DynamicPartitionerForArray 类和StaticPartitionerFor-Array 类。 手工创建一个分区器,而不是使用默认的分区器: var result = (from x in Partitioner.Create(data, true).AsParallel() where Math.Log(x) < 4 select x).Average(); 也可以调用WithExecutionMode()和WithDegreeOfParallelism()方法,来影响并行机制。对于WithExecutionMode()方法可以传递ParallelExecutionMode的一个Default值或者ForceParallelism值。默认情况下,并行LINQ避免使用系统开销很高的并行机制。对于WithDegreeOf Parallelism()方法,可以传递一个整数值,以指定应并行运行的最大任务数。查询不应使用全部CPU,这个方法会很有用。 ## 取消 ## .NET提供了一种标准方式,来取消长时间运行的任务,这也适用于并行LINQ。 要取消长时间运行的查询,可以给查询添加WithCancellation()方法,并传递一个CancellationToken令牌作为参数。CancellationToken令牌从CancellationTokenSource类中创建。该查询在单独的线程中运行,在该线程中,捕获一个OperationCanceledException类型的异常。如果取消了查询,就触发这个异常。在主线程中,调用CancellationTokenSource类的Cancel()方法可以取消任务。 var cts = new CancellationTokenSource(); Task.Run(() => { try { var res = (from x in data.AsParallel().WithCancellation(cts.Token) where Math.Log(x) < 4 select x).Average(); WriteLine($"query finished, sum: {res}"); } catch (OperationCanceledException ex) { WriteLine(ex.Message); } }); WriteLine("query started"); Write("cancel? "); string input = ReadLine(); if (input.ToLower().Equals("y")) { // cancel! cts.Cancel(); }
相关 Linq分组 Linq应用场景 linq的语法通过System.Linq下面的Enumerable类提供支持,也就是说,只要是实现了IEnumerable<T>的对象都可以使用Linq的语 水深无声/ 2024年03月29日 16:18/ 0 赞/ 96 阅读
相关 LINQ 简介 LINQ(Language Integrated Query,语言集成查询)在C\编程语言中集成了查询语法,可以用相同的语法访问不同的数据源。LINQ提供了不同数据 不念不忘少年蓝@/ 2023年02月21日 14:08/ 0 赞/ 28 阅读
相关 LINQ LINQ(语言集成查询)是对C\语言的扩展,允许C\代码以查询数据库的方式操作内存数据。 一些关键字: from:指定范围变量和数据源 where:根据条件从数据源 爱被打了一巴掌/ 2022年09月17日 07:29/ 0 赞/ 251 阅读
相关 Linq应用 在说LINQ之前必须先说说几个重要的C\语言特性 一:与LINQ有关的语言特性 1.隐式类型 (1)源 在隐式类型出现之前, 我们 ゞ 浴缸里的玫瑰/ 2022年05月24日 22:14/ 0 赞/ 215 阅读
相关 Linq-GroupBy 1.简单形式: var q = from p in db.Products group p by p.CategoryID into g ╰半橙微兮°/ 2022年05月19日 12:21/ 0 赞/ 281 阅读
相关 linq 什么是linq: Language Integrated Query 语言集成查询,,是进行数据访问的编程模式,可以通过延迟查询,降低访问数据的复杂度。 那么它是怎么 ╰+攻爆jí腚メ/ 2022年03月19日 07:54/ 0 赞/ 317 阅读
相关 linq(1)常见查询(linq表达式) 本文主要讲解的是linq的查询用法。 采用的数据 //定义数组man var man = new[] 绝地灬酷狼/ 2022年01月23日 07:55/ 0 赞/ 429 阅读
相关 LINQ之路(1):LINQ基础 本文将从什么是LINQ(What)、为什么使用LINQ(Why)以及如何使用LINQ(How)三个方面来进行说明。 1.什么是LINQ LINQ(Language In 我会带着你远行/ 2021年11月26日 07:40/ 0 赞/ 572 阅读
还没有评论,来说两句吧...