微服务
打算写两篇文章,今天主要介绍微服务的请求流程以及流程处理过程中涉及到的代码,第二篇结合简单业务逻辑,详细介绍代码实现以及数据一致性的问题。微服务这个名词,我在2014年就接触了,当时是在一家医疗公司。这里简单介绍下医疗行业的技术背景,医疗行业我所接触到的,技术更新还是比较滞后的,尤其是HIS,当然这并不是医疗行业受制于技术,我觉得还是业务。本人工作快10年,接触过最复杂的业务系统应该当属HIS。当时是由王总牵头,准备把HIS升级到3.0,并且走服务化 & 微服务,由长沙和武汉团队部分人员协作,那时也是我第一次接触微服务这个概念和一些技术栈。继续我们的目标。
一些概念性的东西我就不写了,啥优点缺点啊啥的。个人一点见解,微服务架构能不用尽量不用,如果要用必须要满足两个条件,其一开发产品团队对微服务的技术栈有一定的沉淀,其二devops团队必须要牛逼,纯属个人的一点看法。
架构图,我们先看下简单架构图(参考微软开源框架eshop)。
一图胜千言,通过这张图,这里简单介绍下整个请求流程:
1.客户端通过浏览器或者app访问gateway网关envoy服务网格;
2.如果该请求需要聚合器聚合,envoy路由请求到agg聚合器,聚合器通过grpc访问services,返回聚合结果到client;
3.service处理请求入库,如果需要其他service协作,需要写入eventlog到mssql,并publish到mq。
4.如果该请求不需要聚合,由gateway直接路由到service。
5.idp是统一认证服务,基于ids4实现,遵循oauth2和openidconect1协议规范,通过accesstoken授权访问。
图片解说就到这吧。
技术栈,下面简单介绍下整个练手微服务涉及到的所有技术栈,1.webapicore 2.eventbus 3.envoy 4.rabbitmq 5.efcore 6.identityserver4 7.DDD领域驱动 8.docker & docker-compose 9.mediatR 10.protobuffer、CQRS等等。
解决方案,下面我们看下代码结构。
今天这篇文章主要介绍微服务的一个完整请求,代码不错详细介绍,下面简单介绍下所有工程:
EventBus:事件总线,里面有三个工程,分别为总线接口、rabbitmq实现、efcore实现;
Gateway:网关,里面包含两个工程,聚合器和服务网格,服务网格很简单就一个yaml配置文件;
Cart:购物车服务,标准的DDD分层架构;
Order:订单服务,标准的DDD分层架构;
Product:产品服务,标准的DDD分层架构。
解决方案结构就到这吧,下面我们就从入口开始一起分析下代码的实现,web端还未实现,正在实现中,用的是vue框架(前后端完全分离),对这个不熟正打算系统的学一下前端技术vue webpark等,非常怀念只要有jQuery在手,打遍天下无敌手。呵呵,感觉现在的前端跟后端完全是一个模式了,以前的前端有点打游击的感觉,现在的前端工程化了,题外话。我们从gateway网关开始吧。
envoy由c++实现,个人觉得这家伙要火有点难度,即便它站在巨人(service mesh)肩膀上。为什么?就因为它是c++实现,不是因为c++不行,还是因为现在网关产品是golang的天下,一个网关多少会牵扯中后台的一些技术平台,个人愚见,不要当真。使用envoy非常简单,就一份配置文件,随后通过docker-comose up就可启动,看代码
1 admin: # 后台 2 access_log_path: "/dev/null" # log路径 3 address: 4 socket_address: 5 address: 0.0.0.0 # ip 6 port_value: 8001 # 端口 7 static_resources: # 静态资源,提供前端请求 8 listeners: 9 - address: 10 socket_address: 11 address: 0.0.0.0 # web请求地址 12 port_value: 80 # 监听80端口 13 filter_chains: # 筛选器 14 - filters: 15 - name: envoy.http_connection_manager 16 config: 17 codec_type: auto 18 stat_prefix: ingress_http 19 route_config: 20 name: backend_route 21 virtual_hosts: 22 - name: backend 23 domains: 24 - "*" 25 routes: # 路由 26 - name: "product" 27 match: 28 prefix: "/product/" # 前缀匹配 29 route: 30 auto_host_rewrite: true 31 prefix_rewrite: "/product-api/" 32 cluster: product # 服务名称 逻辑主机配置 33 - name: "agg" 34 match: 35 prefix: "/" 36 route: 37 auto_host_rewrite: true 38 prefix_rewrite: "/" 39 cluster: webagg 40 http_filters: 41 - name: envoy.router 42 access_log: 43 - name: envoy.file_access_log 44 filter: 45 not_health_check_filter: {} 46 config: 47 json_format: 48 time: "%START_TIME%" 49 protocol: "%PROTOCOL%" 50 duration: "%DURATION%" 51 request_method: "%REQ(:METHOD)%" 52 path: "/tmp/access.log" 53 clusters: 54 - name: webagg 55 connect_timeout: 0.25s 56 type: strict_dns 57 lb_policy: round_robin 58 hosts: 59 - socket_address: 60 address: webagg 61 port_value: 80 62 - name: product # 对应上面cluster 63 connect_timeout: 0.25s # 超时 64 type: strict_dns # envoy 监听 DNS,A记录域名对应的IP地址 65 lb_policy: round_robin # 负载均衡 轮询主机 66 hosts: 67 - socket_address: 68 address: product-api # service域名,配置ip也可,线上环境肯定用域名,同时可以做服务发现等 69 port_value: 80 # 端口
代码比较长删减了一部分,以上做了相关注释,配置也比较简单,其他配置可以参考官网:
https://www.servicemesher.com/envoy/ 。接下来配置docker-compose.yml.
1 webshoppingapigw: # service名称 2 image: envoyproxy/envoy # envoy镜像 3 volumes: # 数据卷 4 - ./ApiGateways/Envoy/config/webagg:/etc/envoy # 宿主和容器地址映射 5 ports: 6 - "6000:80" # 前台 7 - "6001:8001" # 后台
envoy配置和启动就是这么简单,下面我们就可以通过ip:port访问网关。网关介绍就到这,下面看下聚合器,题外话,网关都有聚合能力,包括netcore平台的ocelot网关等产品,但是我相信没几个人会用网关的聚合,个人感觉太弱了。
聚合器基于webapicore开发,跟后台service通信主要采用grpc(远程过程调用),其实就是一种rpc通信框架,类似thrift,底层原理就是通过本地代理实现,默认通过protobuf语言描述及做为底层通信格式。protobuf是一种轻便高效的结构化数据存储格式,个人理解二进制的性能,面向对象的效率。
聚合器工程比较简单,普通的webapicore项目。我们有这么一个操作,添加商品到购物车,相信大家都在京东、淘宝等电商买过商品吧。如果是单体应用是不是直接在购物车的application层或者api层里面直接聚合产品和购物车信息。但是在微服务架构里面,我们需要聚合器完成这个操作。看代码。
1 public async Task<ActionResult> AddCartItemAsync([FromBody] AddCartItemRequest data) 2 { 3 // 其他代码... 4 var item = await _product.GetProductItemAsync(data.ProductItemId); // 通过grpc 从product微服务 获取产品信息 5 var currentCart = (await _cart.GetById(data.CartId)) ?? new CartData(data.CartId);// 通过grpc从cart微服务 获取购物车信息 6 7 var product = currentCart.Items.SingleOrDefault(i => i.ProductId == item.Id); // 检索当前购物车是否有该产品信息 8 if (product != null) 9 { 10 product.Quantity += data.Quantity; // 如果有数量+1 11 } 12 else 13 { // 添加新商品到购物车 14 currentCart.Items.Add(new CartDataItem() 15 { 16 ProductId = item.Id, 17 ProductName = item.Name, 18 Quantity = data.Quantity, 19 Id = Guid.NewGuid().ToString() 20 }); 21 } 22 // 通过grpc更新cart微服务信息 23 await _cart.UpdateAsync(currentCart); 24 return Ok(); 25 }
我们继续看看,聚合器通过grpc调用产品微服务的代码:
1 public async Task<ProductItem> GetProductItemAsync(int id) 2 { 3 return await GrpcCallerService.CallService(_urls.GrpcProduct, async channel => 4 { 5 // 其他代码... 6 var client = new ProcuctClient(channel); // 通过proto描述文件生成 7 var request = new ProductItemRequest { Id = id }; // proto请求消息 8 var response = await client.GetItemByIdAsync(request); // 通过本地代理调用 9 return MapToProductItemResponse(response); 10 }); 11 }
最后docker部署代码 1
// dockerfile
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS base 2 WORKDIR /app 3 EXPOSE 80 4 5 FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build 6 WORKDIR /src 7 8 9 COPY "Gateways/Web.Aggregator/Web.Aggregator.csproj" "Gateways/Web.Aggregator/" 10 11 COPY "NuGet.config" "NuGet.config" 12 13 RUN dotnet restore "Gateways/Web.Aggregator/Web.Aggregator.csproj" 14 15 COPY . . 16 WORKDIR /src/Gateways/Web.Aggregator 17 RUN dotnet publish --no-restore -c Release -o /app 18 19 FROM build AS publish 20 21 FROM base AS final 22 WORKDIR /app 23 COPY --from=publish /app . 24 ENTRYPOINT ["dotnet", "Web.Aggregator.dll"]
docker-compose
1 webshoppingagg: 2 image: centos 3 build: 4 context: . 5 dockerfile: Gateways/Web.Aggregator/Dockerfile 6 depends_on: 8 - sqldata 9 - identity-api 10 - rabbitmq 11 - order-api13 - product-api 14 - cart-api 15 ports: 16 - "7000:80"
以上就是聚合器通过grpc调用后端服务,并完成聚合,这里还有个小问题需要注意,客户端的本地代理对象,我们需要copy一份后端的proto文件。我们继续看下后台服务,产品服务。
ProductService 产品服务,我们继续上面grpc。先看下productService工程。
product、order、cart三个微服务工程都是基于DDD领域驱动设计,标准的4层结构(一个小插曲啊,记得以前刚接触DDD,有一天因为下午请假,比较急当时对DDD又不熟,为了赋值方便直接把domain里面的private set的private改成public,哈哈)。我们继续看下加入产品到购物车,产品微服务这边的处理,看代码
1 // proto代码 2 syntax = "proto3"; 3 package ProductApi; 4 message ProductItemRequest { 5 int32 id = 1; 6 } 7 8 // 其他成员... 9 10 message ProductItemResponse { 11 int32 id = 1; 12 string name = 2; 13 string description=3; 14 int32 available_stock=4; 15 ProductBrand product_brand=5; 16 ... 17 } 18 message ProductBrand { 19 int32 id = 1; 20 string name = 2; 21 } 22 service ProductGrpc { 23 rpc GetItemById (ProductItemRequest) returns (ProductItemResponse) { 24 } 25 } 26 27 28 c#代码 29 public class ProductGrpcService : ProductApi.ProductGrpc.ProductGrpcBase // 编译器编译proto文件生成的基类 30 { 31 // 其他成员... 32 private readonly ProductContext _productContext; 33 private readonly ProductSettings _settings; 34 private readonly ILogger _logger; 35 public ProductGrpcService(ProductContext dbContext, IOptions<ProductSettings> settings, ILogger<ProductGrpcService> logger) 36 { 37 // 其他代码... 38 } 39 public override async Task<ProductItemResponse> GetItemById(ProductItemRequest request, ServerCallContext context) 40 { 41 // 其他代码... 42 var item = await _productContext.ProductItems.SingleOrDefaultAsync(ci => ci.Id == request.Id); 43 44 if (item != null) 45 { 46 return new ProductItemResponse() 47 { 48 Description = item.Description, 49 Id = item.Id, 50 Name = item.Name, 51 Price = (double)item.Price 52 }; 53 } 54 return null; 55 } 56 }
以上就是grpc服务端处理聚合器grpc请求的流程。代码很简单。到此微服务流程应该走通了一半多了,接下来再介绍微服务处理的后半部分,我们还是以productService微服务为例吧。产品有这么一个操作,那就是修改产品价格,假如我们在京东买商品,商品A添加到了购物车,添加之后,商品A的价格发生了改变,这个时候是不是需要通知购物车服务,修改信息?直接看代码
1 // controller 2 public class ProductController : ControllerBase 3 { 4 // 其他成员... 5 private readonly IMediator _mediator; 6 private readonly ProductSettings _settings; 7 public ProductController(IMediator mediator, IOptionsSnapshot<ProductSettings> settings) 8 { 9 _mediator = mediator; 10 _settings = settings.Value; 11 } 12 public async Task<ActionResult> UpdateProductPriceAsync([FromBody]UpdateProductCommand updateProductCommand) 13 { 14 // command验证统一处理 15 bool commandResult = await _mediator.Send(updateProductCommand); 16 if (!commandResult) 17 { 18 return BadRequest(); 19 } 20 return Ok(); 21 } 22 } 23 24 // command 25 [DataContract] 26 public class UpdateProductCommand : IRequest<bool> 27 { 28 [DataMember] 29 public int ProductId { get;private set; } 30 [DataMember] 31 public decimal NewPrice { get; private set; } 32 public UpdateProductCommand(int productId, decimal newPrice) 33 { 34 this.ProductId = productId; 35 this.NewPrice = NewPrice; 36 } 37 }
productService基于mediator实现CQRS读写分离,在controller里面直接就调用mediator.send方法,mediator如果不熟可以先百度一下,很简单。我们继续看下UpdateProductCommandHandler处理器的Hanlder实现。
1 public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, bool> 2 { 3 private readonly IProductRepository _productRepository; 4 public UpdateProductCommandHandler(IProductRepository productRepository) 5 { 6 _productRepository = productRepository; 7 } 8 public async Task<bool> Handle(UpdateProductCommand request, CancellationToken cancellationToken) 9 { 10 var product = await _productRepository.FindByIdAsync(request.ProductId.ToString()); 11 if (product == null) 12 { 13 return false; 14 } 15 var oldPrice = product.Price; 16 17 product.SetProductPrice(request.NewPrice, oldPrice); 18 return await _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); 19 } 20 }
代码很简单,直接通过domain模型修改价格,看代码。
1 public void SetProductPrice(decimal newPrice, decimal oldPrice) 2 { 3 this.Price = newPrice; 4 AddDomainEvent(new ProductPriceChangedDomainEvent(this, oldPrice)); 5 }
修改价格,并跟踪domain状态。接着我们继续看 _productRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);方法的实现。
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default(CancellationToken)) { // 其他代码... await _mediator.DispatchDomainEventsAsync(this); var result = await base.SaveChangesAsync(cancellationToken); return true; }
通过mediator.Publish发布domain事件。我们继续看看具体domaineventhandler处理器。
1 public class ProductPriceChangedDomainEventHandler : INotificationHandler<ProductPriceChangedDomainEvent> 2 { 3 private readonly IProductIntegrationEventService _productIntegrationEventService; 4 private readonly ILoggerFactory _logger; 5 public ProductPriceChangedDomainEventHandler(IProductIntegrationEventService productIntegrationEventService, 6 ILoggerFactory logger) 7 { 8 _logger = logger; 9 _productIntegrationEventService = productIntegrationEventService; 10 } 11 public async Task Handle(ProductPriceChangedDomainEvent notification, CancellationToken cancellationToken) 12 { 13 // 其他代码... 14 var productPriceChangedDomainEvent = new ProductPriceChangedIntegrationEvent(notification.ProductItem.Id, notification.ProductItem.Price, notification.OldPrice); 15 await _productIntegrationEventService.SaveEventAndProductContextChangesAsync(productPriceChangedDomainEvent); 16 } 17 18 // SaveEventAndProductContextChangesAsync 保存数据库 19 public async Task SaveEventAndProductContextChangesAsync(IntegrationEvent evt) 20 { 21 22 await ResilientTransaction.New(_productContext).ExecuteAsync(async () => 23 { 24 await _productContext.SaveChangesAsync(); 25 await _eventLogService.SaveEventAsync(evt, _productContext.Database.CurrentTransaction); 26 }); 27 }
创建event,并保存event和修改price价格到数据库,注意这是在一个本地事务中完成的,借助mssql事务实现。这样就完了?是不是没通知cartservice服务啊?不要急,在application层,我实现了mediatR的IPipelineBehavior<TRequest, TResponse>管道服务,我们看下代码
1 public class TransactionBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> 2 { 3 private readonly ILogger<TransactionBehaviour<TRequest, TResponse>> _logger; 4 private readonly ProductContext _dbContext; 5 private readonly IProductIntegrationEventService _productIntegrationEventService; 6 public TransactionBehaviour(ProductContext dbContext, 7 IProductIntegrationEventService productIntegrationEventService, 8 ILogger<TransactionBehaviour<TRequest, TResponse>> logger) 9 { 10 // .... 11 } 12 public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next) 13 { 14 var response = default(TResponse); 15 var typeName = request.GetGenericTypeName(); 16 try 17 { 18 if (_dbContext.HasActiveTransaction) 19 { 20 return await next(); 21 } 22 var strategy = _dbContext.Database.CreateExecutionStrategy(); 23 await strategy.ExecuteAsync(async () => 24 { 25 Guid transactionId; 26 using (var transaction = await _dbContext.BeginTransactionAsync()) 27 using (LogContext.PushProperty("TransactionContext", transaction.TransactionId)) 28 { 29 30 response = await next(); // 执行mediatR 处理逻辑 31 await _dbContext.CommitTransactionAsync(transaction); 32 transactionId = transaction.TransactionId; 33 } 34 await _productIntegrationEventService.PublishPublishEventsThroughEventBusAsync(transactionId); // eventbus publish事件到mq 35 }); 36 return response; 37 } 38 catch (Exception ex) 39 { 40 throw; 41 } 42 } 43 } 44 45 // PublishPublishEventsThroughEventBusAsync 46 public async Task PublishPublishEventsThroughEventBusAsync(Guid transactionId) 47 { 48 var pendingLogEvents = await _eventLogService.RetrieveEventLogsPendingToPublishAsync(transactionId); // 从mssql获取事件数据 49 foreach (var logEvt in pendingLogEvents) 50 { 51 try 52 { 53 await _eventLogService.MarkEventAsInProgressAsync(logEvt.EventId); 修改状态 54 _eventBus.Publish(logEvt.IntegrationEvent); // 发布到mq 55 await _eventLogService.MarkEventAsPublishedAsync(logEvt.EventId); // 修改状态 56 } 57 catch (Exception ex) 58 { 59 await _eventLogService.MarkEventAsFailedAsync(logEvt.EventId); 60 } 61 } 62 }
在controller层里面mediatR.send方法执行完成之后,执行了IPipelineBehavior 里面的post操作,其实你可以理解为aop或者中介者,我们看下publish里面的实现。
1 public void Publish(IntegrationEvent @event) 2 { // 其他代码... 3 using (var channel = _persistentConnection.CreateModel()) 4 { 5 6 policy.Execute(() => 7 { 8 var properties = channel.CreateBasicProperties(); 9 properties.DeliveryMode = 2; 10 channel.BasicPublish( 11 exchange: BROKER_NAME, 12 routingKey: eventName, 13 mandatory: true, 14 basicProperties: properties, 15 body: body); 16 }); 17 } 18 }
通过重试熔断策略发布消息到rabbitmq,到此,productservice服务边界的处理已完成,好困要睡觉了,留着第二篇说吧。简单总结一下,微服务架构个人觉得最难处理的就是数据一致性的问题,如果是强一致性的需求,根本不适合微服务架构。上面代码在修改价格时开启了事务,并把事件日志纳入了该事务,这样在源头就确保了事件和数据的强一致性,并持久化事件,接下来再发布到mq,当然这里如果此时mq挂了,至少事件被持久化了,最终还可以补偿实现最终一致性,好了,就到这吧。