C#基础与进阶扩展合集-进阶篇(持续更新)
(名称空间System.Buffers)使用数组池,可减少垃圾收集器的工作,ArrayPool管理一个数组池,数组可以从这租借,并返回池中,内存在ArrayPool中管理。可理解成转换器,适配器适配的是不同类间相同的名称,不论字段或属性(必须为值类型或字符串类型),只要名字相同,都适配给目的对象;:变量直接保存其数据,作为类的字段(成员变量)时,跟随其所属的实例存储,也就是存储在堆中;,不会复制数
目录
17、Dictionary与ConcurrentDictionary
本文分两篇,基础篇点击:C#基础与进阶扩展合集-基础篇
实用进阶合集如下:
C#-了解ORM框架SqlSugar并巧妙使用(附相关数据库工具)
一、进阶
1、Predicate
拥有一个或多个泛型参数并返回一个 bool 值,常用于对 collection 进行一组条件检索,类似于Func。
举例:Predicate pre=m=>m.Id==2;
2、设置C#语言版本
工程文件 x.csproj中修改
PropertyGroup节点内添加子节点:
<LangVersion>latest</LangVersion>
3、ListCollectionView过滤集合
使用ListCollectionView类构造函数注入列表
通过该类的 Filter属性过滤集合
List<Animal> animals = new List<Animal>() { new Animal(1,"ani1"),new Animal(2,"动物2") };
List<Bear> bears = new List<Bear>();
var tmp = animals.Adapt<List<Bear>>();
tmp.ForEach(m => m.Description = "Animal adapt bear...");
ListCollectionView view=new ListCollectionView(tmp);
view.Filter = i => ((Bear)i).ID == 2;
foreach (var animal in view)
MessageBox.Show(((Bear)animal).Name);
4、值类型与引用类型
值类型:变量直接保存其数据,作为类的字段(成员变量)时,跟随其所属的实例存储,也就是存储在堆中;作为方法中的局部变量时,存储在栈上;
引用类型:变量保存其数据的引用(地址)分配在栈中,具体数据(实例)部署在托管堆中;
值类型:结构体(数值类型,bool型,用户定义的结构体),枚举,可空类型
引用类型:数组,用户定义的类、接口、委托,object,字符串
引用类型string:
string a = "A";
string b = a;
Console.WriteLine($"a:{a}\tb:{b}");
a= "B";
Console.WriteLine($"a:{a}\tb:{b}");
string为引用类型,上面示例看出string像值类型:
实际上,是由于运算符的重构所导致的结果。当a被重新赋值时,.NET为a在托管堆上重新分配了一块内存。这样做的目的是,使字符串类型与通俗意义上讲的字符串更接地气。
引用类型数组:
数组元素为值类型时,在托管堆中一次性分配全部值类型空间(堆中栈),并自动初始化;
元素为 引用类型时,先在托管堆分配一次空间,此时不会自动初始化任何元素(均为null)。等到有代码初始化某个元素的时,这个引用类型元素的存储空间才会被分配在托管堆上;
5、程序设置当前项目工作目录
Directory.SetCurrentDirectory(Path.GetDirectoryName(typeof(Test).Assembly.Location));
Environment.CurrentDirectory=Path.Combine(Directory.GetCurrentDirectory(),"..");
6、获取和更新App.config配置文件中的值
获取appSettings节点值:
ConfigurationManager.AppSettings[key];
获取或设置connectionStrings节点值:
string connectStr= ConfigurationManager.ConnectionStrings["ConTest"].ConnectionString; //var list= ConfigurationManager.ConnectionStrings; //string str=""; //foreach (ConnectionStringSettings item in list) //{ // if(item.Name=="ConTest") // str = item.ConnectionString; //}
更新App配置文件值
①将当前应用程序的配置文件作为Configuration对象打开:
Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
②添加键值对:
config.AppSettings.Settings.Add("language", SelectLanguage.Name);
③修改对应键的值:
config.AppSettings.Settings["language"].Value = SelectLanguage.Name;
④保存并刷新:
config.Save(ConfigurationSaveMode.Modified); ConfigurationManager.RefreshSection("appSettings");
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<appSettings>
<add key="Auston" value="Test"/>
<add key="language" value="zh-CN"/>
</appSettings>
<connectionStrings>
<add name="Auston" connectionString="TestConnect"/>
</connectionStrings>
</configuration>
7、Linq常用语句
定义LINQ扩展方法的一个类是System.Linq名称空间中的Enumerable;
Linq常用语句,详细讲解点击:C#-关于LINQ
select:以指定形式返回
Where查询特点条件(方式1:from in;方式2:Lambda表达式)
Order排序:1、descending 降序;2、ascending 升序
OfType查询特定类型
Join合并两集合通过指定键,返回指定结构类型集合
GroupJoin:俩集合通过指定键分组
Reverse反转集合元素顺序
GroupBy按指定键对自身分组
Any / All 判断是否(任意一个/全部)满足条件
Skip跳过指定数量元素
Take拿取指定数量元素
Count获取元素个数
Sum、Average、Max、Min获取集合总值、平均值、最大值、最小值
Concat连接集合
Distinct去重(去重类中某个字段需实现IEqualityComparer接口)
ElementAt获取指定索引元素(与[ ]类似)
First/Single、Last:获取集合中第一个、最后一个元素(如果集合中包含多个元素,使用Single会报错);
ToDictionary:将集合转换为字典;
ToList: 将集合转换为List;
SequenceEqual:判断两个集合是否相等;
8、并行LINQ
System.Linq名称空间中包含的类ParallelEnumerable可将查询的工作拆分到多个处理器上同时运行的多个线程中;
通常可使用AsParallel()方法让集合类以并行方式查询,该方法扩展了IEnumerable<TSource>接口,返回ParallelQuery<TSource>类;
示例如下,示例中并行LINQ所用时间约90ms,普通LINQ所用时间约为420ms,可以看出并行LINQ加快了代码运行速度
var list=Enumerable.Range(0, 5000_0000).Select(x => Random.Shared.Next(100)).ToList();
Stopwatch sw = Stopwatch.StartNew();
var avera= list.AsParallel().Where(m=>m<50).Select(m=>m).Average();
sw.Stop();
Stopwatch sw2 = Stopwatch.StartNew();
var avera2= list.Where(m => m < 50).Select(m => m).Average();
sw2.Stop();
Console.WriteLine(sw.Elapsed.TotalMilliseconds);//约90ms
Console.WriteLine(sw2.Elapsed.TotalMilliseconds);//约420ms
9、强引用与弱引用
1、在应用程序中实例化一个类或结构时,只要有代码引用它,就会形成强引用;
2、GC不能收集仍在引用的对象的内存,也就是强引用的内存,但它可以收集不在根表中直接或间接引用的托管内存;
3、弱引用允许创建和使用对象,但如果垃圾收集器碰巧运行,就会收集对象并释放内存,弱引用开销比小对象大,用于小对象没有意义;
4、弱引用使用WeakReference类创建的,使用构造函数传递强引用,其Target属性的值若不为null,则该对象仍可使用,若赋值给传递类型对象,则会再次创建该对象的强引用,不能被GC收集(注意:在访问Target时可能被GC收集,所以通常赋值后需对其进行null判断);
WeakReference weakReference = new WeakReference(new Pig());
Pig pig = weakReference.Target as Pig;
if (pig != null)
{
//use pig
}
else
{
//reference not available
}
10、using处理非托管资源
方式一:声明一个析构函数(或终结器finalizer)作为类的成员;
C#中,析构函数在底层.NET体系结构中为终结器,编译器会隐式的把析构函数编译成等价于重写Finalize()方法的代码,如下:
protected override void Finalize()
{
try
{
//Finalizer implementation
}
finally
{
base.Finalize();
}
}
方式二:实现IDisposable或IAsyncDisposable接口;
C#中,推荐使用该方式替代析构函数,这些接口定义了一种模具(具有语言级的支持),该模式为释放非托管的资源提供了确定的机制,并避免产生析构函数固有的与GC相关的问题;
注意:若处理过程中出现异常则不会释放,通常在finally块中释放,如下:
People people = null;
try
{
people = new();
//other process
}
finally
{
people.Dispose();
}
class People : IDisposable
{
public void Dispose()
{
//implementation
}
}
方式三:using语句和using声明(推荐),实现了对方式二的封装;
用于实现IDisposable接口的对象,当对象的引用超出作用域时,自动调用该对象的Dispose()或DisposeAsync()方法,如下示例,会生成与上面try块等价的IL代码;
using (People people = new())
{
//other process
}
11、模块初始化器
若需要在使用一个库的任何类型之前调用该库的初始化代码,可使用C#的一个新特性,模块初始化器[ModuleInitializer];
在使用该类的任何类型之前,会自动调用该特性标记的初始化方法,该方法必须是静态、无参,返回void,使用public或internal访问修饰符;
[ModuleInitializer]
public static void Initializer()
{
Console.WriteLine("*******Module Initializer********");
}
12、序列化
序列化相关详解:C#-序列化与反序列化(xml、json)
13、并行编程
关于并行编程详解:C#-关于并行编程
14、单元测试
运用NUnit单元测试框架:C#-单元测试NUnit框架的安装及使用
15、GUID全局唯一标识符
全局唯一标识符(GUID,Globally Unique Identifier)是一种由算法生成的二进制长度为128位的数字标识符,常用于标识如注册表项、类及接口标识、数据库、系统目录等对象。
- var uuid = Guid.NewGuid().ToString();
- var uuidN = Guid.NewGuid().ToString(“N”);
- var uuidD = Guid.NewGuid().ToString(“D”);
- var uuidB = Guid.NewGuid().ToString(“B”);
- var uuidP = Guid.NewGuid().ToString(“P”);
- var uuidX = Guid.NewGuid().ToString(“X”);
16、Queue与ConcurrentQueue
Queue<T> 和ConcurrentQueue<T> 都是先进先出的队列,其区别主要是关于线程安全性和并发访问的支持。
Queue<T>:
- 线程安全性:
Queue<T>
不是线程安全的,因此在多线程环境中使用时必须采取额外的同步措施,如使用lock
或Monitor
。 - 方法:主要的方法包括
Enqueue(T item)
(向队列末尾添加一个元素)和Dequeue()
(从队列头部移除并返回一个元素)。还有Peek()
方法可以查看队列头部的元素而不移除它。 - 用途:适用于单线程环境或已经进行了适当同步的多线程场景。
ConcurrentQueue<T>(性能开销增大):
- 线程安全性:
ConcurrentQueue<T>
在内部实现了必要的同步机制,使其能够安全地被多个线程访问。 - 方法:主要的方法包括
TryEnqueue(T item)
(尝试向队列末尾添加一个元素,成功返回 true)和TryDequeue(out T result)
(尝试从队列头部移除并返回一个元素,成功返回 true 并将元素赋值给 out 参数)。同样也有Peek()
方法查看队列头部元素。 - 用途:适用于多线程环境,特别是当多个生产者和消费者共享一个队列时。
17、Dictionary与ConcurrentDictionary
Dictionary<TKey, TValue>为非线程安全的键值对集合。提供了高效的方法来存储和检索数据,但在多线程环境中使用时需要特别注意同步问题,关键点如下:
- 线程安全性:
Dictionary<TKey, TValue>
不是线程安全的,这意味着在多线程环境中直接使用Dictionary<TKey, TValue>
可能会导致数据竞争(race conditions),从而引发异常或数据不一致。 - 性能:由于没有额外的同步开销,
Dictionary<TKey, TValue>
在单线程环境中通常比ConcurrentDictionary<TKey, TValue>
性能更好。 - 方法:主要的方法包括
Add
,Remove
,ContainsKey
,TryGetValue
,this[]
访问器等。 - 用途:适用于单线程环境或已进行适当同步的多线程场景。
ConcurrentDictionary<TKey, TValue> 为线程安全的键值对集合,允许多个线程安全地访问和修改字典,而不需要额外的外部同步机制,关键点如下:
- 线程安全性:
ConcurrentDictionary<TKey, TValue>
内部实现了必要的同步机制,使其能够在多线程环境中安全地使用。 - 性能:虽然
ConcurrentDictionary<TKey, TValue>
在多线程环境下具有更好的表现,但在单线程环境中可能会因为内置的同步机制而导致性能不如Dictionary<TKey, TValue>
。 - 方法:除了类似
Dictionary<TKey, TValue>
的基本方法外,还提供了TryAdd
,GetOrAdd
,AddOrUpdate
,GetOrAdd
等方法,这些方法可以在没有键的情况下执行原子操作。 - 用途:适用于多线程环境,特别是在需要多个生产者和消费者共享数据时。
二、进阶扩展
1、Adapt适配器
安装NutGet包:Mapster
可理解成转换器,适配器适配的是不同类间相同的名称,不论字段或属性(必须为值类型或字符串类型),只要名字相同,都适配给目的对象;
注意:即使名称相同,属性或字段也不能适配成方法
Animal animal = new Animal(18);
Bear bear = animal.Adapt<Bear>();
Console.WriteLine(bear.Age.ToString());
Console.WriteLine(bear.Description.ToString());
Console.WriteLine("************************");
Bear bear1=new Bear();
Console.WriteLine(bear1.Age.ToString());
Console.WriteLine(bear1.Description.ToString());
Console.WriteLine("*************************");
Banana banana = animal.Adapt(new Banana());
Console.WriteLine(banana.Description);
2、Mutex互斥及防止App多开
1、继承自WaitHandle类:抽象基类,用于等待一个信号的设置(有静态方法WaitOne()、WaitAll()、WaitAny());
2、Mutex互斥锁可定义互斥名称,所以可用于跨进程的同步操作(因为操作系统可识别有名称的互斥,在不同进程间共享);
3、Mutex构造函数中,可指定互斥是否最初应由主调线程拥有、定义互斥名称、获取互斥是否已存在的信息;
用法1:跨进程互斥实现进程间同步(未命名互斥只能用于跨线程)
Mutex mutext = new Mutex(false,"MyConsole");
mutext.WaitOne();
Console.WriteLine($"{Process.GetCurrentProcess().ProcessName}:\tStart......");
Console.ReadLine();
mutext.ReleaseMutex();
Console.WriteLine($"{Process.GetCurrentProcess().ProcessName}:\tEnd.......");
用法2:防止App重复开启
Mutex mutext = new Mutex(false,"MyConsole",out bool createNew);
if (!createNew)
return;
3、Monitor设置等待资源时间
lock关键字是由Monitor类实现(抛出异常也会解锁)如下:
Monitor.Enter(_obj);
try{Count--;}
finally { Monitor.Exit(_obj); }
Monitor相对于lock的优点在于,使用Monitor的TryEnter()方法,其中可传递一个超时值,用于指定等待被锁定的最长时间,若_obj被锁定,TryEnter()方法将布尔型的引用参数设置为true,并同步的访问_obj锁定状态,若另一个线程锁定_obj时间超过指定时间,TryEnter()将bool引用参数置为false,线程将不再等待,而是去执行其它操作,如下:
Monitor.TryEnter(_obj, 2000, ref _lockTaken);
if (_lockTaken)
{
try
{
Console.WriteLine(Thread.CurrentThread.Name + ":\t obj lock.....");
Thread.Sleep(5000);
Console.WriteLine(Thread.CurrentThread.Name + ":\t obj release.....");
}
finally
{
Monitor.Exit(_obj);
}
}
else
Console.WriteLine("Timeout,Run other.....");
4、ReaderWriterLockSlim读写锁
它支持多个线程同时读取数据,但只允许一个线程写入数据,并且在写入时不允许其他线程读取或写入,相对于ReaderWriterLock读写锁来说更加安全。
ReaderWriterLockSlim lockSlim = new ReaderWriterLockSlim();
private int testLock = 0;
public int TestLock
{
get
{
try
{
lockSlim.EnterReadLock();
return testLock;
}
finally
{
lockSlim.ExitReadLock();
}
}
set
{
try
{
lockSlim.EnterWriteLock();
testLock = value;
}
finally
{
lockSlim.ExitWriteLock();
}
}
}
5、扩展方法实现解构
了解扩展方法点击:扩展方法定义与使用
创建Deconstruct()方法(也称解构器),将分离部分放入out参数中,这里使用扩展方法实现解构,示例如下:
Stu stu = new Stu(98, "Auston");
stu.Deconstruct(out int score, out string name);
Console.WriteLine($"{name}:{score}");
static class StuExtension
{
public static void Deconstruct(this Stu stu, out int score, out string name)
{
score = stu.Score;
name = stu.Name;
}
}
6、Span<T>实现切片
1、Span<T>,可快速访问托管与非托管的连续内存,如数组、长字符串;
2、可实现对数组部分进行访问或切片,不会复制数组元素,是从span中直接访问的,切片的两种方式①构造函数传递数组的开头与结尾;②Slice方法传递开头索引,提取到数组末尾;
3、可使用Span改变值,除了索引访问更改,还提供方法有:Clear()、填充Fill()、复制CopyTo()(不推荐,目标span不够大会抛异常)、复制推荐TryCopyTo()(span不够大不抛异常,而是返回false);
4、若只需对数组片段进行读访问,可使用ReadOnlySpan<T>;
int[] c = { 1, 3, 5, 8 };
Span<int> span = new Span<int>(c);
Span<int> span1= new Span<int>();
span[1] = 11;
span.Clear();
span.Fill(11);
Span<int> span2 = new Span<int>(c,0,3);
Span<int> span3 = span.Slice(0,3); //切片
ReadOnlySpan<int> span4 = new(c); //只读变量
if (!span.TryCopyTo(span3))
Console.WriteLine("Argument");
7、数组池减少GC工作
通过ArrayPool类(名称空间System.Buffers)使用数组池,可减少垃圾收集器的工作,ArrayPool管理一个数组池,数组可以从这租借,并返回池中,内存在ArrayPool中管理。
创建ArrayPool<T>,调用静态Create()方法;
使用预定义共享池,通过访问Shared属性;
从池中租用内存,可调用Rent()方法,(池中数组元素数量最低16,且都是成倍增加);
内存(数组)返回到池中,调用Return()方法,可指定返回池之前是否清除该数组(false,下次租用数组的人可读取数据);
ArrayPool<int> arrayPool = ArrayPool<int>.Create(maxArrayLength: 100, maxArraysPerBucket: 10);
int[] arr = ArrayPool<int>.Shared.Rent(10);
arr[15] = 15;
Console.WriteLine($"Len={arr.Length}\tarr[15]={arr[15]}");//输出Len=16 arr[15]=15
ArrayPool<int>.Shared.Return(arr,true);
Console.WriteLine(arr[15]);//输出0
8、深度解析await关键字
await通常与async一同使用来实现异步编程,async没有await搭配使用将毫无意义;
使用Task任务的GetAwaiter()方法,返回一个TaskAwaiter<T>类型对象,该对象的OnCompleted()方法实现了INotifyCompletion接口,在任务完成时调用;
await实际就是编译器把await关键字后的所有代码放进了OnCompleted()方法的代码块中。
public static void Main(string[] args)
{
TestAsync();
Console.ReadLine();
}
public static async void TestAsync()
{
var awaiter = MyAsync().GetAwaiter();
awaiter.OnCompleted(() =>
{
Console.WriteLine($"MyAsync ended....");
});
//await MyAsync();
//Console.WriteLine("MyAsync ended.....");
}
public static async Task<string> MyAsync()
{
Thread.Sleep(100);
Console.WriteLine(nameof(MyAsync));
return nameof(MyAsync);
}
9、Task
GetAwaiter()方法,用于await关键字的实现,详细如上;
ContinueWith()方法,用于延续任务;
RunSynchronously()方法,同步任务;
WaitAll()静态方法,阻塞调用任务,直到所有任务完成;
WhenAll()静态方法,返回一个任务,从而允许使用async关键字等待结果,因此不会阻塞等待的任务;
WhenAny()静态方法,用于等待任意一个任务结束;
注意:任务不一定使用线程池中的线程,也可以使用其他线程,调用RunSynchronously()任务以同步方式运行,以相同的线程作为主调线程,如示例中的 t4;
开始一个新任务方式有如下几种:
TaskMethod();
Task t1 = Task.Run(TaskMethod);
Thread.Sleep(100);
Task t2 = Task.Factory.StartNew(TaskMethod);
Thread.Sleep(100);
Task t3 = new TaskFactory().StartNew(TaskMethod);
Thread.Sleep(100);
Task t4 = new(TaskMethod);
//t4.Start();
t4.RunSynchronously();
static void TaskMethod()
{
Console.WriteLine($"Task ID:{Task.CurrentId?.ToString() ?? "no task"}");
Console.WriteLine($"thread ID:{Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine($"Is background:{Thread.CurrentThread.IsBackground}");
Console.WriteLine($"Is pool thread:{Thread.CurrentThread.IsThreadPoolThread}");
Console.WriteLine("*****************Auston****************");
}
使用泛型类Task<TResult>获取Task结果,示例如下
Task<(int result1, int result2)> t4 = new (TaskWithResult,(2,5));
t4.Start();
Console.WriteLine(t4.Result.Item1+$"\t{t4.Result.Item2}");
static (int, int) TaskWithResult(object obj)
{
(int a, int b) = ((int a, int b))obj;
return (a * 10, b * 100);
}
10、ValueTask
C#7新增可用作await的新类型,ValueTask是一个结构,其在堆上没有对象,相对于Task具有性能上的优势(当不能忽略任务开销的时候可使用);
11、async异步编程
async、await异步编程详解:C#-异步编程
12、CancellationTokenSource
CancellationTokenSource控制Task详解:C#-控制Task结束
13、异步方法的异常处理
若调用异步方法没有等待,try/catch不会捕获异常,因为在抛出异常前,就已经执行完毕了,若要捕获异常,需await等待异步方法。
static void PutError()
{
try
{
await ThrowExcp();
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
static async Task ThrowExcp()
{
await Task.Delay(1000);
throw new Exception("Exception.....");
}
14、获取当前程序集实现指定接口的类型
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(e => e.GetTypes()
.Where(i => i.GetInterfaces()
.Contains(typeof(IControlbase))))
.ToArray();
15、程序运行计时
①Stopwatch
Stopwatch sw = Stopwatch.StartNew(); Thread.Sleep(1000); sw.Stop(); var time = sw.ElapsedMilliseconds;
②DateTime.Now记录开始和结束时间,取差值
var start=DateTime.Now; Thread.Sleep(1000); var stop=DateTime.Now; var time=(stop-start).TotalMilliseconds;
③ValueStopwatch(.NET Core新增,Stopwatch扩展,为结构体,减少了Stopwatch的内存消耗从而提高性能)
16、异常的全局处理
主线程未处理的异常捕获事件:
Application.Current.DispatcherUnhandledException += Current_DispatcherUnhandledException;
子线程未处理的异常捕获事件(不包括Task):
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
17、Lazy延迟初始化
构造方式:①默认构造方式:Lazy<T> lazyObj=new Lazy<T>()
②委托方式构造:Lazy<T> lazyObj=new Lazy<T>(()=>{return new T;})
优:使用Lazy的对象,只会在第一次使用时初始化,省去了不必要的开销,提升了效率。
Lazy创建的对象(Lazy.Value),属性为只读
18、配置私有目录
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="Reference;plugins"/>
</assemblyBinding>
</runtime>
三、版本新增
C#9新增顶级语句;
字符串的范围除SubString方法,C#8新增hat(^)、范围运算符([..]);
1、范围运算符
string rangstr ="hello,auston!" ;
Console.WriteLine(rangstr[..5]);//范围运算符
Console.WriteLine(rangstr[7^2]);//hat^运算符,从索引7往前数第2个字符
2、字符串格式控制
DateTime t = DateTime.Now;
Console.WriteLine($"{t:D}");//字符串格式控制
3、数字分隔符
int a = 2_2_2;//使用数字分隔符,提高代码可读性(编译器会忽略下划线)
Console.WriteLine($"{a:c}");
4、小数点前后保留格式
double d = 22.336_6;
Console.WriteLine($"{d:###.##}");//小数点后四舍五入保留2位
Console.WriteLine($"{d:000.00}");//小数点前保留3位,后保留2位
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)