「.net core」电商平台升级之微服务架构应用实战
connygpt 2024-12-19 10:37 3 浏览
升级之前
升级之后:
然而升级之后问题又来了,由于之前增加了代理商业务并且把授权中心和支付网关单独拆出来了,这使得公司的业务订单量翻了几十倍,这个时候整个电商系统达到了瓶颈,如果再不找解决方案系统又得宕机了。
2.1 问题及解决方案
经过技术的调研及问题分析,导致这个瓶颈的问题主要有以下几个原因,只需要把下面问题解决就可以得到很大的性能提升
- 每天的订单量暴增,导致订单数据太大,然而整个电商系统数据存储在一个数据库中,并且是单表、单数据库(未进行读写分离),以致于订单数据持续暴增。
- 相关业务需要依赖订单查询,订单数据查询慢以至于拖垮数据库
- 整个电商系统连接数达到瓶颈(已经分布式部署,在多加服务器会损耗更多的经费而达不到最佳性价比)
为了一劳永逸的解决以上问题,经过技术的调研,决定对订单业务做如下升级改造:
- 拆分独立的订单微服务(本章节着重分享)
- 使用ES进行数据迁移(按年进行划分,并且进行读写分离,这里就不着重讲,下次来跟大家一起学习和分享)
- 增加分布式缓存 (也不是本次的重点,后续再来跟大家学习和分享)
经过升级后的架构图如下:
架构图说明:
- 右边同一颜色的代表还是原先电商系统的单体式架构,为拆分的单体架构业务,其中在业务处理上夹杂了一层分布式缓存的处理
- 左边的是微服务的架构,是这次升级拆分后的架构,其中数据库也已经从原有的数据库拆分并且数据迁移到了ES集群中,并进行了读写分离。
- 订单服务可以随意扩容成分布式服务,通过一些工具动态扩展服务及服务器的支持。
- 右边的业务后续也可以进行拆分,拆分成不同的业务服务。
- 后续升级还可以考虑消息队列等相关方面,架构图中未构思(后续再来分享升级用到的相关技术,这里还是回归到本文的核心微服务)
三、微服务概述
微服务的相关概念我就不多说了,以下就先简单概况下微服务带来的利和弊。
3.1 微服务的优势
- 使大型的复杂应用程序可以持续交付和持续部署:持续交付和持续部署是DevOps的一部分,DevOps是一套快速、频繁、可靠的软件交付实践。高效的DevOps组织通常将软件部署到生产环境时面临更少的问题和故障。DevOps工具有Docker、Kubernets、Jenkins、Git等。
- 每个服务相对较小并容易维护:微服务架构相比单体应用要小的多,开发者理解服务中的逻辑代码更容易。代码库小,打包,启动服务速度也快。
- 服务可以独立部署:每个服务都可以独立于其他服务进行部署
- 服务可以独立扩展:服务可以独立扩展,不论是采用X轴扩展的实例克隆,还是Z轴的流量分区方式。此外每个服务都可以部署到适合它们需求的硬件之上
- 微服务架构可以实现团队的自治:可以根据服务来把开发团队拆分。每个团队都有自己负责的微服务,而不用关心不属于他们负责的服务。
- 更容易实验和采纳新的技术:最后,微服务可以消除对某个技术栈的长期依赖。因为服务更小,使用更换的编程语言和技术来重写一项服务变得有可能,这也意味着,对一项新技术尝试失败后,可以直接丢弃这部分工作而不至于给整个应用带来失败的风险。
- 更好的容错性:微服务架构也可以实现更换的故障隔离。例如,某个服务引发的致命错误,不会影响其他服务。其他服务仍然正常运行。
- 服务可以独立扩容:对于整个架构来说,可以随意选择相关业务进行扩容和负载,通过相关技术工具动态进行随意扩容
3.2 微服务的劣势
- 服务拆分和定义是一项挑战:采用微服务架构首当其冲的问题,就是根本没有一个具体的、良好定义的算法可以完成服务的拆分工作。与软件开发一样,服务的拆分和定义更像一门艺术。更糟糕的是,如果对系统的服务拆分出现了偏差,很有可能会构建出一个分布式的单体应用;一个包含了一大堆互相之间紧耦合的服务,却又必须部署在一起的所谓分布式系统。这将会把单体架构和微服务架构两者的弊端集于一身。
- 分布式系统带来的各种复杂性、使开发、测试和部署变得更困难:使用微服务架构的另一个问题是开发人员必须处理创建分布式系统的额外复杂性。服务必须是进程间通信。这比简单的方法调用要复杂的多。
- 当部署跨越多个服务的功能时需要谨慎地协调更多的开发团队:使用微服务架构的另外一项挑战在于当部署跨越多个服务的功能时需要谨慎地协调更多开发团队。必须制定一个发布计划,把服务按照依赖关系进行排序。这跟单体架构下部署多个组件的方式截然不同。
- 开发者需要思考到底应该在应用的什么阶段使用微服务架构:使用微服务架构的另一个问题是决定在应用程序生命周期的哪个阶段开始使用这种架构。
- 跨服务数据的问题:在单体应用中,所有的数据都在一个数据库中,而在微服务架构中,每个服务都有自己的数据库,想要获取,操作其他服务的数据,只能通过该服务提供API进行调用,这样就带来一个问题,进程通信的问题,如果涉及到事务,那么还需要使用Saga来管理事务,增加了开发的难度。
3.3 微服务拆分原则
说到单体架构拆分,那也不是随意拆分,是要有一定的原则,拆分的好是优势,拆分的不好是混乱。以下是我查阅资料以及我的经验总结出来的拆分原则
- 1、单一职责、高内聚低耦合
- 2、微服务力度适中
- 3、考虑团队结构
- 4、以业务模型切入
- 5、演进式拆分
- 6、避免环形依赖与双向依赖
- 7、DDD(可以考虑使用领域驱动设计去进行底层服务的设计,后续会单独分析该设计的相关文章)
四、微服务实战
好了,到这里大家已经对微服务有了一定的理解,就不继续详细概述相关理念的东西,下面来直接撸代码,让大家熟悉微服务的应用。这里我使用 莫堇蕈 在github 上开源的微服务框架,框架源代码地址 :https://github.com/overtly/core-grpc (我这里强烈推荐该框架,目前已经比较成熟的用于公司生产环境)
4.1core-grpc微服务框架的优势:
- 集成Consul 实现服务发现和注册以及健康检查等机制
- 实时监听服务状态
- 多节点 轮询机制
- 故障转移,拉入黑名单
- 支持.Net Core 和Framework 两种框架
- 实现基于Grpc的微服务
- 部署支持环境变量
4.2 实战
创建Jlion.NetCore.OrderService订单微服务
我们用vs2019 创建控制台应用程序 选择框架.Net Core 3.1 命名为Jlion.NetCore.OrderService 后面简称订单服务,创建完后我们通过nuget包引入 core-grpc微服务框架,如下图:
目前core-grpc微服务框架,最新正式发布版本是 1.0.3
引用了core-grpc 后我们还需要安装一个工具VS RPC Menu,这个工具也是大神免费提供的,图片如下:
由于微软官方下载比较慢,我这里共享到 百度网盘,百度网盘下载地址如下:
链接: https://pan.baidu.com/s/1twpmA4_aErrsg-m0ICmOPw 提取码: cshs
如果通过下载后安装不是vs 集成安装方式,下载完成后需要关闭vs 2019相关才能正常安装。
VS RPC Menu 工具说明如下:
- 用于客户端代码生成 支持Grpc 和Thrift 我们再在 订单服务项目 中创建OrderRequest.proto文件,这个是Grpc 的语法,不了解该语法的同学可以 点击 gRPC 官方文档中文版_V1.0 进行学习,地址:http://doc.oschina.net/grpc?t=56831
OrderRequest.proto代码如下:
syntax = "proto3";
package Jlion.NetCore.OrderService.Service.Grpc;
//定义订单查找参数实体
message OrderSearchRequest{
string OrderId = 1; //定义订单ID
string Name = 2;
}
//定义订单实体
message OrderRepsonse{
string OrderId = 1;
string Name = 2;
double Amount = 3;
int32 Count = 4;
string Time = 5;
}
//定义订单查找列表
message OrderSearchResponse{
bool Success = 1;
string ErrorMsg = 2;
repeated OrderRepsonse Data = 3;
}
上面主要是定义了几个消息实体, 我们再创建JlionOrderService.proto,代码如下:
syntax = "proto3";
package Jlion.NetCore.OrderService.Service.Grpc;
import "OrderRequest.proto";
service JlionOrderService{
rpc Order_Search(OrderSearchRequest) returns (OrderSearchResponse){}
}
上面的代码中都可以看到最上面有 package Jlion.NetCore.OrderService.Service.Grpc 代码,这是声明包名也就是后面生成代码后的命名空间,这个很重要。同时定义了JlionOrderService服务入口,并且定义了一个订单搜索的方法Order_Search,到这里我们已经完成了一小部分了。
生成客户端代码
再在JlionOrderService.proto文件里面右键 》选择Grpc代码生成》Grpc 代码 会自动生存微服务客户端代码 。生存工具中具有如下功能:
- 生存Grpc客户端代码
- Grpc 编译(不常用)
- Grpc 打包(常用,用来把客户端dll发布到nuget服务器上)
- 还可以对Thrift 代码进行生成和打包
创建Jlion.NetCore.OrderService.Grpc类库
把刚刚通过工具生成的Grpc客户端代码直接copy到 Jlion.NetCore.OrderService.Grpc这个类库中(必须和上面Grpc 的代码声明的package 一致)以下简称订单服务客户端,并且需要通过Nuget包添加Overt.Core.Grpc 的依赖,代码结构如下:
Jlion.NetCore.OrderService.Grpc类库已经构建完成,现在让 Jlion.NetCore.OrderService 服务引用Jlion.NetCore.OrderService.Grpc 类库
订单服务中 实现自己的IHostedService
创建HostService类,继承IHostedService代码如下:
public class HostedService : IHostedService
{
readonly ILogger _logger;
readonly JlionOrderServiceBase _grpcServImpl;
public HostedService(
ILogger<HostedService> logger,
JlionOrderServiceBase grpcService)
{
_logger = logger;
_grpcServImpl = grpcService;
}
//服务的启动机相关配置
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.Factory.StartNew(() =>
{
var channelOptions = new List<ChannelOption>()
{
new ChannelOption(ChannelOptions.MaxReceiveMessageLength, int.MaxValue),
new ChannelOption(ChannelOptions.MaxSendMessageLength, int.MaxValue),
};
GrpcServiceManager.Start(BindService(_grpcServImpl), channelOptions: channelOptions, whenException: (ex) =>
{
_logger.LogError(ex, #34;{typeof(HostedService).Namespace.Replace(".", "")}开启失败");
throw ex;
});
System.Console.WriteLine("服务已经启动");
_logger.LogInformation(#34;{nameof(Jlion.NetCore.OrderService.Service).Replace(".", "")}开启成功");
}, cancellationToken);
}
//服务的停止
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.Factory.StartNew(() =>
{
GrpcServiceManager.Stop();
_logger.LogInformation(#34;{typeof(HostedService).Namespace.Replace(".", "")}停止成功");
}, cancellationToken);
}
}
上面代码主要是创建宿主机并且实现了StartAsync 服务启动及StopAsync 服务停止方法。我们创建完HostedServicce代码再来创建之前定义的Grpc服务的方法实现类JlionOrderServiceImpl,代码如下:
public partial class JlionOrderServiceImpl : JlionOrderServiceBase
{
private readonly ILogger _logger;
private readonly IServiceProvider _serviceProvider;
public JlionOrderServiceImpl(ILogger<JlionOrderServiceImpl> logger, IServiceProvider provider)
{
_logger = logger;
_serviceProvider = provider;
}
public override async Task<OrderSearchResponse> Order_Search(OrderSearchRequest request, ServerCallContext context)
{
//TODO 从底层ES中查找订单数据,
//可以设计成DDD 方式来进行ES的操作,这里我就为了演示直接硬编码了
var response = new OrderSearchResponse();
try
{
response.Data.Add(new OrderRepsonse()
{
Amount = 100.00,
Count = 10,
Name = "订单名称测试",
OrderId = DateTime.Now.ToString("yyyyMMddHHmmss"),
Time = DateTime.Now.ToString()
});
response.Data.Add(new OrderRepsonse()
{
Amount = 200.00,
Count = 10,
Name = "订单名称测试2",
OrderId = DateTime.Now.ToString("yyyyMMddHHmmss"),
Time = DateTime.Now.ToString()
});
response.Data.Add(new OrderRepsonse()
{
Amount = 300.00,
Count = 10,
Name = "订单名称测试2",
OrderId = DateTime.Now.ToString("yyyyMMddHHmmss"),
Time = DateTime.Now.ToString()
});
response.Success = true;
}
catch (Exception ex)
{
response.ErrorMsg = ex.Message;
_logger.LogWarning("异常");
}
return response;
}
}
再修改Program代码,并把HostedService和JlionOrderServiceImpl 注入到容器中,代码如下:
class Program
{
static void Main(string[] args)
{
var host = new HostBuilder()
.UseConsoleLifetime() //使用控制台生命周期
.ConfigureAppConfiguration((context, configuration) =>
{
configuration
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables();
})
.ConfigureLogging(logger =>
{
logger.AddFilter("Microsoft", LogLevel.Critical)
.AddFilter("System", LogLevel.Critical);
})
.ConfigureServices(ConfigureServices)
.Build();
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
var logFactory = host.Services.GetService<ILoggerFactory>();
var logger = logFactory.CreateLogger<Program>();
logger.LogError(e.ExceptionObject as Exception, #34;UnhandledException");
};
host.Run();
}
/// <summary>
/// 通用DI注入
/// </summary>
/// <param name="context"></param>
/// <param name="services"></param>
private static void ConfigureServices(HostBuilderContext context, IServiceCollection services)
{
//HostedService 单例注入到DI 中
services.AddSingleton<IHostedService, HostedService>();
services.AddTransient<JlionOrderServiceBase, JlionOrderServiceImpl>();
}
}
到了这里简单的微服务已经编码完成,但是还缺少两个配置文件,我们创建appsettings.json配置文件和consulsettings.json 服务注册发现的配置文件consulsettings.json配置文件如下:
{
"ConsulServer": {
"Service": {
"Address": "127.0.0.1:8500"// 你的Consul 服务注册及发现配置地址
}
}
}
上面的地址配置只是简单的例子,我这里假定我的Consul服务地址是 127.0.0.1:8500 等下服务启动会通过这个地址进行注册。
appsettings.json配置文件如下:
{
"GrpcServer": {
"Service": {
"Name": "JlionOrderService",
"Port": 10001,
"HostEnv": "serviceaddress",
"Consul": {
"Path": "dllconfigs/consulsettings.json"
}
}
}
}
我这里服务监听了10001 端口,后面注册到Consul中也会看到该端口 官方完整的配置文件如下:
{
"GrpcServer": {
"Service": {
"Name": "OvertGrpcServiceApp", // 服务名称使用服务名称去除点:OvertGrpcServiceApp
"Host": "service.g.lan", // 专用注册的域名 (可选)格式:ip[:port=default]
"HostEnv": "serviceaddress", // 获取注册地址的环境变量名字(可选,优先)环境变量值格式:ip[:port=default]
"Port": 10001, // 端口自定义
"Consul": {
"Path": "dllconfigs/consulsettings.json" // Consul路径
}
}
}
}
好了,订单服务已经全部完成了,订单服务服务整体结构图如下:
好了,我们这里通过命令行启动下JlionOrderService服务,生产环境你们可以搭建在Docker 容器里面
我们可以来看下我之前搭建好的Consul服务 ,打开管理界面,如图:
图片中可以发现刚刚启动的服务已经注册进去了,但是里面有一个健康检查未通过,主要是由于服务端不能访问我本地的订单服务,所有健康检查不能通过。你可以在你本地搭建 Consul服务用于测试。
我本地再来开启一个服务,配置中的的端口号由10001 改成10002,再查看下Consul的管理界面,如下图:
发现已经注册了两个服务,端口号分别是10001 和10002,这样可以通过自定化工具自动添加服务及下架服务,分布式服务也即完成。到这里订单服务的启动已经完全成功了,我们接下来是需要客户端也就是上面架构图中的电商业务网关或者支付网关等等要跟订单服务进行通讯了。
创建订单网关(跟订单服务进行通信)
创建订单网关之前我先把上面的 订单服务客户端 类库发布到我的nuget包上,这里就不演示了。我发布的测试包名称JlionOrderServiceDemo nuget官方可以搜索找到。你们也可以直接搜索添加到你们的Demo中进行测试。我通过VS 2019 创建Asp.Net Core 3.1 框架的WebApi 取名为Jlion.NetCore.OrderApiService 下面简称订单网关服务现在我把前面发布的微服务客户端依赖包 JlionOrderServiceDemo 添加到订单网关服务中,如下图:
现在在订单网关服务中添加OrderController api控制器,代码如下:
namespace Jlion.NetCore.OrderApiService.Controllers
{
[Route("[controller]")]
[ApiController]
public class OrderController : ControllerBase
{
private readonly IGrpcClient<OrderService.Service.Grpc.JlionOrderService.JlionOrderServiceClient> _orderService;
public OrderController (IGrpcClient<OrderService.Service.Grpc.JlionOrderService.JlionOrderServiceClient> orderService)
{
_orderService = orderService;
}
[HttpGet("getlist")]
public async Task<List<OrderRepsonse>> GetList()
{
var respData =await _orderService.Client.Order_SearchAsync(new OrderService.Service.Grpc.OrderSearchRequest()
{
Name = "test",
OrderId = "",
});
if ((respData?.Data?.Count ?? 0) <= 0)
{
return new List<OrderRepsonse>();
}
return respData.Data.ToList();
}
}
}
代码中通过构造函数注入 OrderService 并且提供了一个GetList的接口方法。接下来我们还需要把OrderService.Service.Grpc.JlionOrderService注入到容器中,代码如下:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
//注册Grpc 客户端,具体可以查看源代码
services.AddGrpcClient();
}
现在整个订单网关服务项目结构如下图:
项目中有两个最重要的配置dllconfig//Jlion.NetCore.OrderService.Grpc.dll.json 和consulsettings.json 他们分别是干什么的呢?我们先分别来看我本地这两个配置的内容Jlion.NetCore.OrderService.Grpc.dll.json 配置如下:
{
"GrpcClient": {
"Service": {
"Name": "JlionOrderService", // 服务名称与服务端保持一致
"MaxRetry": 0, // 最大可重试次数,默认不重试
"Discovery": {
"Consul": { // Consul集群,集群优先原则
"Path": "dllconfigs/consulsettings.json"
},
"EndPoints": [ // 单点模式
{
"Host": "127.0.0.1",
"Port": 10001
}]
}
}
}
}
Jlion.NetCore.OrderService.Grpc.dll.json 配置主要是告诉订单网关服务和订单服务应该怎样进行通信,以及通信当中的一些参数配置。我为了测试,本地使用单点模式,不使用Consul模式consulsettings.json 配置如下:
{
"ConsulServer": {
"Service": {
"Address": "127.0.0.1:8500"
}
}
}
有没有发现这个配置和之前服务端的配置一样,主要是告诉订单网关服务(客户端调用者)和订单服务服务端服务发现的集群地址,如果上面的配置是单点模式则这个配置不会起作用。
到这里订单网关服务 (客户调用端)编码完成,我们开始启动它:
我这里固定5003端口,现在完美的启动了,我们访问下订单接口,看下是否成功。访问结果如下图:
微服务完美的运行成功。
微服务框架开源项目地址:https://github.com/overtly/core-grpc
相关推荐
- 3分钟让你的项目支持AI问答模块,完全开源!
-
hello,大家好,我是徐小夕。之前和大家分享了很多可视化,零代码和前端工程化的最佳实践,今天继续分享一下最近开源的Next-Admin的最新更新。最近对这个项目做了一些优化,并集成了大家比较关注...
- 干货|程序员的副业挂,12个平台分享
-
1、D2adminD2Admin是一个完全开源免费的企业中后台产品前端集成方案,使用最新的前端技术栈,小于60kb的本地首屏js加载,已经做好大部分项目前期准备工作,并且带有大量示例代码,助...
- Github标星超200K,这10个可视化面板你知道几个
-
在Github上有很多开源免费的后台控制面板可以选择,但是哪些才是最好、最受欢迎的可视化控制面板呢?今天就和大家推荐Github上10个好看又流行的可视化面板:1.AdminLTEAdminLTE是...
- 开箱即用的炫酷中后台前端开源框架第二篇
-
#头条创作挑战赛#1、SoybeanAdmin(1)介绍:SoybeanAdmin是一个基于Vue3、Vite3、TypeScript、NaiveUI、Pinia和UnoCSS的清新优...
- 搭建React+AntDeign的开发环境和框架
-
搭建React+AntDeign的开发环境和框架随着前端技术的不断发展,React和AntDesign已经成为越来越多Web应用程序的首选开发框架。React是一个用于构建用户界面的JavaScrip...
- 基于.NET 5实现的开源通用权限管理平台
-
??大家好,我是为广大程序员兄弟操碎了心的小编,每天推荐一个小工具/源码,装满你的收藏夹,每天分享一个小技巧,让你轻松节省开发效率,实现不加班不熬夜不掉头发,是我的目标!??今天小编推荐一款基于.NE...
- StreamPark - 大数据流计算引擎
-
使用Docker完成StreamPark的部署??1.基于h2和docker-compose进行StreamPark部署wgethttps://raw.githubusercontent.com/a...
- 教你使用UmiJS框架开发React
-
1、什么是Umi.js?umi,中文可发音为乌米,是一个可插拔的企业级react应用框架。你可以将它简单地理解为一个专注性能的类next.js前端框架,并通过约定、自动生成和解析代码等方式来辅助...
- 简单在线流程图工具在用例设计中的运用
-
敏捷模式下,测试团队的用例逐渐简化以适应快速的发版节奏,大家很早就开始运用思维导图工具比如xmind来编写测试方法、测试点。如今不少已经不少利用开源的思维导图组件(如百度脑图...)来构建测试测试...
- 【开源分享】神奇的大数据实时平台框架,让Flink&Spark开发更简单
-
这是一个神奇的框架,让Flink|Spark开发更简单,一站式大数据实时平台!他就是StreamX!什么是StreamX大数据技术如今发展的如火如荼,已经呈现百花齐放欣欣向荣的景象,实时处理流域...
- 聊聊规则引擎的调研及实现全过程
-
摘要本期主要以规则引擎业务实现为例,陈述在陌生业务前如何进行业务深入、调研、技术选型、设计及实现全过程分析,如果你对规则引擎不感冒、也可以从中了解一些抽象实现过程。诉求从硬件采集到的数据提供的形式多种...
- 【开源推荐】Diboot 2.0.5 发布,自动化开发助理
-
一、前言Diboot2.0.5版本已于近日发布,在此次发布中,我们新增了file-starter组件,完善了iam-starter组件,对core核心进行了相关优化,让devtools也支持对IAM...
- 微软推出Copilot Actions,使用人工智能自动执行重复性任务
-
IT之家11月19日消息,微软在今天举办的Ignite大会上宣布了一系列新功能,旨在进一步提升Microsoft365Copilot的智能化水平。其中最引人注目的是Copilot...
- Electron 使用Selenium和WebDriver
-
本节我们来学习如何在Electron下使用Selenium和WebDriver。SeleniumSelenium是ThoughtWorks提供的一个强大的基于浏览器的开源自动化测试工具...
- Quick 'n Easy Web Builder 11.1.0设计和构建功能齐全的网页的工具
-
一个实用而有效的应用程序,能够让您轻松构建、创建和设计个人的HTML网站。Quick'nEasyWebBuilder是一款全面且轻巧的软件,为用户提供了一种简单的方式来创建、编辑...
- 一周热门
- 最近发表
- 标签列表
-
- kubectlsetimage (56)
- mysqlinsertoverwrite (53)
- addcolumn (54)
- helmpackage (54)
- varchar最长多少 (61)
- 类型断言 (53)
- protoc安装 (56)
- jdk20安装教程 (60)
- rpm2cpio (52)
- 控制台打印 (63)
- 401unauthorized (51)
- vuexstore (68)
- druiddatasource (60)
- 企业微信开发文档 (51)
- rendertexture (51)
- speedphp (52)
- gitcommit-am (68)
- bashecho (64)
- str_to_date函数 (58)
- yum下载包及依赖到本地 (72)
- jstree中文api文档 (59)
- mvnw文件 (58)
- rancher安装 (63)
- nginx开机自启 (53)
- .netcore教程 (53)