Windows 中Mongodb 多台服务器实现副本集,集群部署_一起记录GIS学习-CSDN博客

mikel阅读(581)

来源: (1条消息) Windows 中Mongodb 多台服务器实现副本集,集群部署_一起记录GIS学习-CSDN博客

目前,在CSDN包含个人博客中,关于mongodb集群部署是非常多的,这篇主要是针对于多台机器,部署mongodb副本集,并通过Java代码进行验证,其中,Java代码使用主流的SpringBoot架构。

首先,我们进行mongodb的下载,下载地址为:

https://download.csdn.net/download/weixin_41986096/11539922

可以将mongodb下载后放在任意一个文件夹中,进行解压。我们是放在了

 

本文主要采用mongodb在windows中实现副本集集群,由三个节点组成的 副本集 为网络故障或是其他的系统故障提供了足够的冗余。该副本集也有足够的分布式读操作的能力。副本集应该保持奇数个节点,这也就保证了 选举 可以正常的进行

在生产环境的部署中,我们应该尽可能将副本集中得节点置于不同的机器上。当使用虚拟机的时候,我们应该将 mongod实例置于拥有冗余电源和冗余网络的机器上。我们应该将每个节点部署在独立的机器上,并使用标准的MongoDB端口 27017 。使用 bind_ip 参数来限制访问MongoDB的应用程序的地址。

副本集的容错性是指可以变为不可用,并且在该集中仍保留足够的成员以选择主要成员的成员数。换言之,这是一个群体中成员的数量与选举初选所需的多数投票成员之间的差异。没有主副本集,副本集就不能接受写操作。容错是副本集大小的一个影响,但这种关系不是直接的。见下表:

 

目录结构的准备:

准备三台机器为windows服务器。首先将mongodb拷贝到每一个服务器中,我们是拷贝到了E:\mongodb\ 下进行解压文件

启动mongodb看是否能正常启动,可使用mongodb 工具进行连接测试

 

工具下载地址:

https://download.csdn.net/download/weixin_41986096/11540214

这里将三台服务器分别定义为A B C 服务器,方便阐述。

在A B C 服务器的E:\mongodb\mongodb-win32-x86_64-2008plus-2.6.0\bin中分别编写mongodb的启动脚本,如果已经有启动脚本,建议删除。

脚本如下所示:

mongod –port 27017 –dbpath ..\data\agri\ –logpath ..\logs\mongo.log –logappend –replSet mySet
port

Mongodb的监听端口

dbpath

数据文件存储路径

logpath

系统日志存放路径

replSet

副本集名称,我用的是mySet,其他的节点
必须使用这个名字做副本集名称

logappend

日志的写入模式是追加,不是默认的覆盖模式

分别在E:\mongodb\mongodb-win32-x86_64-2008plus-2.6.0\data\下建立文件夹agri,名称可以随便起,只要和脚本配置文件路径保持一致就行。

注意:dbpath 在配置前,必须保证是空文件夹

以上脚本配置可以通过bat文件来完成,也可以建立配置文件来完成,具体配置如下:

 

建立mongo.conf文件,进行如下配置:

#数据库地址

dbpath = E:\mongodb\mongodb-win32-x86_64-2008plus-2.6.0\data\agri

#数据库端口号

port = 27017

#数据库所在服务器

bind_ip = 192.168.191.228

#日志地址

logpath = E:\mongodb\mongodb-win32-x86_64-2008plus-2.6.0\logs\mongo.log

#日志追加

logappend = true

#副本集名称

replSet = mySet
启动文件mongo_start.bat文件配置如下:

mongod –config mongo.conf

如上步骤,在A B C 三台服务器上分别建立启动脚本和配置文件

 

连接Mongod实例:

分别启动A B C 三台服务器,并在A 服务器中找到E:\mongodb\mongodb-win32-x86_64-2008plus-2.6.0\bin位置,开启cmd窗口,执行mongod命令设置对应的副本集配置:

首先输入mongo -port 27017 连接mongodb库

连接成功输入副本集配置信息:

config_set={“_id”:”mySet”,members:[{_id:0,host:”192.168.191.228:27017″},{_id:1,host:”192.168.191.243:27017″},{_id:2,host:”192.168.191.241:27017″}]}
然后执行rs.initiate(config_set)

 

 

测试连接A B C 服务器三个库:

 

能正常连接

 

SpringBoot 进行代码测试:
首先我们建立一个SpringBoot的项目,在pom中引入需要的jar包:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
在application.properties中加入相关配置信息

#mongodb

# 单机模式 mongodb://name:pass@ip:port/database

# 集群模式 mongodb://user:pwd@ip1:port1,ip2:port2/database

#spring.data.mongodb.uri=mongodb://127.0.0.1:27017/agri

spring.data.mongodb.uri=mongodb://192.168.191.228:27017,192.168.191.243:27017,192.168.191.241:27017/agri
建立需要存储文本信息的实体

package com.herbert.accident.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.io.Serializable;
import java.util.Date;

@Document(collection=”data”)//集合名

@Data

@Builder

@NoArgsConstructor

@AllArgsConstructor

public class NotifyMsg implements Serializable {

private static final long serialVersionUID = -8985545025018238754L;

@Id
String id;

/**
* 消息类型
*/
@Indexed
String notifyType;

/**
* 消息单号
*/
@Indexed
String notifyNo;

/**
* 消息通知日期
*/
String notifyDate;

/**
* 消息体
*/

@Field(“data”)//可指定存储时的字段名
String notifyMsg;

/**
* 创建时间
*/
@CreatedDate
Date gmtCreate;

}
建立需要存储文本信息的Server接口

package com.herbert.accident.service;

import com.herbert.accident.entity.NotifyMsg;

import java.util.List;

/**
* @author :
* @date :Created in 2019/8/7 15:54
* @description:
* @modified By:
* @version: $
*/
public interface NotifyMsgService {

/**
* 保存数据
*
*/
NotifyMsg saveNotifyMsg(NotifyMsg msg);

/**
* 根据消息号查找
*
*/
NotifyMsg findNotifyMsgByNo(String notifyNo);

/**
* 根据消息日期查找
*
*/
List<NotifyMsg> findNotifyMsgByDate(String notifyDate);

/**
* 根据id进行删除 返回删除的对象
*
*/
NotifyMsg delNotifyMsgById(String id);

}
建立接口的实现类

package com.herbert.accident.service.impl;

import com. herbert.accident.entity.NotifyMsg;

import com. herbert.accident.service.NotifyMsgService;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.data.mongodb.core.MongoTemplate;

import org.springframework.data.mongodb.core.query.Criteria;

import org.springframework.data.mongodb.core.query.Query;

import org.springframework.stereotype.Service;

import java.util.List;

/**
* @author :
* @date :Created in 2019/8/7 15:55
* @description:
* @modified By:
* @version: $
*/

@Service

public class NotifyMsgServiceImpl implements NotifyMsgService {

@Autowired

MongoTemplate mongoTemplate;

@Override

public NotifyMsg saveNotifyMsg(NotifyMsg msg) {

//使用 save和insert都可以进行插入

//区别:当存在”_id”时

//insert 插入已经存在的id时 会异常

//save 则会进行更新

//简单来说 save 就是不存在插入 存在更新

mongoTemplate.insert(msg);

mongoTemplate.save(msg);

return msg;

}

@Override

public NotifyMsg findNotifyMsgByNo(String notifyNo) {

//根据Criteria 改造查询条件

Query query = new Query(Criteria.where(“notifyNo”).is(notifyNo));

return mongoTemplate.findOne(query, NotifyMsg.class);

}

@Override

public List<NotifyMsg> findNotifyMsgByDate(String notifyDate) {

//查找 notifyDate 根据Criteria 改造查询条件

Query query = new Query(Criteria.where(“notifyDate”).is(notifyDate));

return mongoTemplate.find(query, NotifyMsg.class);

}

@Override

public NotifyMsg delNotifyMsgById(String id) {

//查找 id 根据Criteria 改造查询条件

Query query = new Query(Criteria.where(“id”).is(id));

return mongoTemplate.findAndRemove(query, NotifyMsg.class);

}

}
建立请求访问的Controller

package com.herbert.accident.controller;/**
* Created by Herbert on 2019/8/7.
*/

import com.herbert.accident.entity.NotifyMsg;

import com.herbert.accident.service.NotifyMsgService;

import lombok.extern.slf4j.Slf4j;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* @author :
* @date :Created in 2019/8/7 15:56
* @description:
* @modified By:
* @version: $
*/

@RestController

@RequestMapping(“/template”)

@Slf4j

public class MongoTemplateController {

@Autowired

NotifyMsgService notifyMsgService;

@PostMapping(“/add”)

public NotifyMsg add(NotifyMsg msg) {

log.info(“mongoTemplate方式新增:{}”, msg);

return notifyMsgService.saveNotifyMsg(msg);

}

@PostMapping(“del/{id}”)

public NotifyMsg del(@PathVariable String id) {

log.info(“mongoTemplate方式删除:{}”, id);

return notifyMsgService.delNotifyMsgById(id);

}

@GetMapping(“/find/{no}”)

public NotifyMsg findNotifyMsgByNo(@PathVariable String no){

log.info(“mongoTemplate方式查找:notifyNo-{}”, no);

return notifyMsgService.findNotifyMsgByNo(no);

}

@GetMapping(“/find/list/{date}”)

public List<NotifyMsg> findNotifyMsgByDate(@PathVariable String date){

log.info(“mongoTemplate方式查找:notifyDate-{}”, date);

return notifyMsgService.findNotifyMsgByDate(date);

}

}
我们进行请求测试,测试工具这边使用的是postman测试

启动服务后:

 

上面启动已经标明主节点和子节点

启动成功标志:

 

下来我们进行访问接口

访问接口保存数据:

接口类型为POST请求

http://127.0.0.1:6660/template/add?notifyType=2&notifyNo=3&notifyMsg=789456123

请求成功:

 

访问接口查询上次保存的数据:

接口类型为GET请求

 

现在我们随意关闭A B C中的一个Mongodb,继续访问刚才保存的数据:

接口类型为GET请求

 

 

副本集 为网络故障或是其他的系统故障依然可以访问到原始保存的数据。

 

需要注意问题:

1:在建立dbpath数据文件存储路径的时候,该路径下必须为空文件夹

2:创建副本集脚本执行的时候,确保和副本集名称保持一致

3:尽量使用默认标准的MongoDB端口 27017
————————————————
版权声明:本文为CSDN博主「GIS码农」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_41986096/article/details/99465898

Lambda 表达式查询条件拼接 - 鞍山老菜鸟 - 博客园

mikel阅读(671)

来源: Lambda 表达式查询条件拼接 – 鞍山老菜鸟 – 博客园

public static class PredicateBuilder
    {    
      /// <summary>              /// 可以理解为创建一个初始化为True的Predicate。              /// 注意:如果你是要创建一个OR组成的Predicate就不能把它初始化为True因为这样这个表达试永远为True了。              /// </summary>              /// <typeparam name="T"></typeparam>              /// <returns></returns>
     public static Expression<Func<T, bool>> True<T>() { return f => true; }  
      /// <summary>              /// 可以理解为创建一个初始化为False的Predicate。              ///注意:如果你是要创建一个AND组成的Predicate就不能把它初始化为False因为这样这个表达试永远为False了。              /// </summary>              /// <typeparam name="T"></typeparam>              /// <returns></returns>
public static Expression<Func<T, bool>> False<T>() { return f => false; }
        public static Expression<T> Compose<T>(this Expression<T> first, Expression<T> second, Func<Expression, Expression, Expression> merge)
        {
            // build parameter map (from parameters of second to parameters of first)
            var map = first.Parameters.Select((f, i) => new { f, s = second.Parameters[i] }).ToDictionary(p => p.s, p => p.f);

            // replace parameters in the second lambda expression with parameters from the first
            var secondBody = ParameterRebinder.ReplaceParameters(map, second.Body);

            // apply composition of lambda expression bodies to parameters from the first expression
            return Expression.Lambda<T>(merge(first.Body, secondBody), first.Parameters);
        }

        public static Expression<Func<T, bool>> And<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
        {
            return first.Compose(second, Expression.And);
        }

        public static Expression<Func<T, bool>> Or<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
        {
            return first.Compose(second, Expression.Or);
        }

        class ParameterRebinder : ExpressionVisitor
        {
            private readonly Dictionary<ParameterExpression, ParameterExpression> map;

            public ParameterRebinder(Dictionary<ParameterExpression, ParameterExpression> map)
            {
                this.map = map ?? new Dictionary<ParameterExpression, ParameterExpression>();
            }

            public static Expression ReplaceParameters(Dictionary<ParameterExpression, ParameterExpression> map, Expression exp)
            {
                return new ParameterRebinder(map).Visit(exp);
            }

            protected override Expression VisitParameter(ParameterExpression p)
            {
                ParameterExpression replacement;
                if (map.TryGetValue(p, out replacement))
                {
                    p = replacement;
                }
                return base.VisitParameter(p);
            }
        }
    }

调用方法

/*
       Lambda 表达式查询条件拼接

       调用实例

        var predicate = PredicateBuilder.True<Orders>();
        if (!string.IsNullOrWhiteSpace(select.so_text))
        {
            predicate = predicate.And(d => d.price > 10);
            predicate = predicate.And(d => d.price < 12);
        }


        var list = db.Orders.Where(predicate).ToList();
      全And:            var where = PredicateBuilder.True<int>();            where = where.And(x => x >= 50);            where = where.And(x => x <= 70);            var res = list.Where(where.Compile());

       全Or:            var list  = Enumerable.Range(1, 100);            var where = PredicateBuilder.False<int>();            where = where.Or(x => x == 50);            where = where.Or(x => x == 70);            var res = list.Where(where.Compile());

       var list = Enumerable.Range(1, 100);            var where = PredicateBuilder.True<int>();            where = where.And(x => x >= 50);            where = where.And(x => x <= 70);            var subwhere = PredicateBuilder.False<int>();            subwhere = subwhere.Or(x => x == 60);            subwhere = subwhere.Or(x => x == 61);            where = where.And(subwhere);            var res = list.Where(where.Compile());
*/

[原创]C#的MongoDB的大于0的条件查询不到记录

mikel阅读(777)

原查询条件语句按大于0的条件查询不到记录,只有查询出等于0的记录

        String sql = squery.Select().Where(n => n.Root_Class=="H型钢"&&n.RealyiNum>0).GetQuery();

查询条件语句只能改为大于等于1的查询才能查询除不等于0的结果

String sql = squery.Select().Where(n => n.Root_Class==”H型钢”&&n.RealyiNum>=1).GetQuery();

.NET平台系列31:.NET团队送给.NET开发人员的云原生学习资源汇总 - 张传宁 - 博客园

mikel阅读(671)

来源: .NET平台系列31:.NET团队送给.NET开发人员的云原生学习资源汇总 – 张传宁 – 博客园

.NET Core 启动于2016年,跟K8S同年诞生,既拥有着悠久的历史积累,又集成了当下最新的设计理念,加上.NET团队持续对容器技术的官方支持和适配改进,这两年各种性能测试榜单中,.NET Core都是最受欢迎和期待的!在云原生 Cloud Native 的大潮中,.NET Core/.NET5/.NET6也是最具优势的,因为他们具有足够的轻巧和高效的先天优势。

如今.NET Core/.NET5/.NET6 在世界各大企业广泛,用户对应用程序功能、性能以及运维的要求不断提高,如:应用程序需要具有快速响应能力、最新和最强大的功能以及零停机时间等。企业正迅速采用云的能力来满足用户需求,提高应用程序的可伸缩性和可用性。然而,要完全拥抱云并优化成本节约,应用程序的设计需要考虑到云。这意味着不仅要改变构建应用程序的方式,还要改变组织中的开发实践以采用这种云本地架构风格。

.NET团队已经收集了一系列免费资源,帮助您加快云本地应用程序的开发进程。无论您是在更新应用程序还是创建新的应用程序,我们都会为您的决策提供指导。这些指南是最新的,包括.NET平台中最新和最强大的云就绪功能。

Microsoft Doc 是微软官方提供的功能最全、最详细的产品级文档管理系统。它提供给广大微软系开发者从入门、进阶到高级的全套技术指南以及视频、开源项目等资源。是.NETer不可或缺的资源宝库。https://docs.microsoft.com/zh-cn/

入门教程

如果是初学者,请开始使用 ASP.NET Web API、Docker 构建简单的微服务终结点,并将它们部署到 Azure Kubernetes 服务 (AKS)。

Microsoft Learns 学习板块

Microsoft 有一个免费的在线培训平台,称为 Microsoft Learn。其中构建了一系列模块来帮助您学习构建 .NET 微服务和云原生技术,如 Docker、Container Registry、Kubernetes、Helm 等等。

在 Learns 板块中提供了大约83个.NET相关内容。具体请参考链接:https://docs.microsoft.com/zh-cn/learn/browse/?products=dotnet

其中专门提供了 .NET Microservices 资源集合,每个主题都是一个完整的系列教程,妥妥的狂甩 Java 几条街。

电子书
  • 《Dapr for .NET Developers》面向 .NET 开发人员的 Dapr

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/dapr-for-net-developers/?WT.mc_id=dotnet-17847-nanil

该书指导.NET开发人员理解并充分利用Microsoft开源分布式应用程序运行时的全部功能。Dapr帮助您解决构建微服务带来的挑战。

  • 《Cloud-Native .NET Apps for Azure》为 Azure 构建云原生 .NET 应用程序

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/cloud-native/?WT.mc_id=dotnet-17847-nanil

本指南定义了云原生应用程序开发,介绍了使用云原生原则构建的示例应用程序,并涵盖了大多数云原生应用程序的常见主题。本指南的读者主要是决策者、开发负责人和架构师,以及对学习如何构建为Azure云设计应用程序感兴趣的开发人员。

  • 《.NET Microservices》.NET 微服务:适用于容器化 .NET 应用程序的体系结构

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/microservices/?WT.mc_id=dotnet-17847-nanil

我们为刚接触基于Docker的应用程序开发和基于微服务的体系结构的开发人员和解决方案架构师编写了本指南。技术决策者(如企业架构师)也会发现本指南对于决定为新的和现代的分布式应用程序选择何种方法非常有用。这本书涵盖了诸如域驱动设计(DDD)、命令查询责任分离(CQRS)、每个服务的数据库、API组合等模式。

  • 《Serverless apps》 无服务器应用:体系结构、模式和 Azure 实现

在线阅读地址:https://docs.microsoft.com/dotnet/architecture/serverless?WT.mc_id=dotnet-17847-nanil

本指南重点介绍使用无服务器的应用程序的云原生开发。这本书强调了开发无服务器应用程序的好处和潜在的缺点,并提供了一个无服务器架构的调查。

  • 《Docker app lifecycle》 使用 Microsoft 平台和工具的容器化 Docker 应用程序的生命周期

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/containerized-lifecycle/?WT.mc_id=dotnet-17847-nanil

本指南从高层次介绍了用于实现CI/CD管道的Azure DevOps,包括用于部署的Azure容器注册(ACR)和Azure Kubernetes服务(AKS)。

  • 《gRPC for WCF developers》适用于 WCF 开发人员的 ASP.NET Core gRPC

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/grpc-for-wcf-developers/?WT.mc_id=dotnet-17847-nanil

我们为在.NET Framework或.NET Core中工作的开发人员编写了本指南,这些开发人员以前使用过WCF并试图将其应用程序迁移到.NET5的现代RPC环境中。如果您正在升级或考虑升级到.NET 5,并且希望使用内置的gRPC工具,则本指南将有所帮助。

  • 《Migrate .NET apps to Azure》 使用 Azure 云和 Windows 容器现代化现有 .NET 应用程序

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/modernize-with-azure-containers/?WT.mc_id=dotnet-17847-nanil

本指南主要关注现有Microsoft.NET Framework web或面向服务应用程序的初始现代化。这意味着在不显著改变应用程序代码和基本架构的情况下,将工作负载移动到更新或更现代的环境中。它还强调了将应用程序移动到云端以及使用一组特定的新技术和方法(如Azure中的Windows容器和相关计算平台)对应用程序进行部分现代化的好处。另外,请在“将.NET应用迁移到Azure”上查看其他迁移资源。

  • 《Porting existing ASP.NET Apps to .NET Core》将现有ASP.NET应用程序移植到.NET Core

在线阅读地址:https://docs.microsoft.com/zh-cn/dotnet/architecture/porting-existing-aspnet-apps/?WT.mc_id=dotnet-17847-nanil

本指南提供了将为ASP.NETMVC和Web API(.NET Framework 4.x)编写的现有应用程序迁移到.NET核心的高级策略。它还包括了通过一个示例项目迁移大型解决方案的策略。

开源项目

eShopOnContainers是我们开发的流行的微服务参考示例项目之一。它是一个基于跨平台容器的应用程序,由.NET 5提供支持。请查看此示例,了解一些微服务模式的详细实现,如CQRS、DDD、每个服务的数据库、API组合等。不要忘记查看其他示例,包括在github.com上更新.NET应用程序。

 


参考文献:

DDD实战课(实战篇)--学习笔记 - MingsonZheng - 博客园

mikel阅读(657)

来源: DDD实战课(实战篇)–学习笔记 – MingsonZheng – 博客园

目录

  • DDD实践:如何用DDD重构中台业务模型?
  • 领域建模:如何用事件风暴构建领域模型?
  • 代码模型(上):如何使用DDD设计微服务代码模型?
  • 代码模型(下):如何保证领域模型与代码模型的一致性?
  • 边界:微服务的各种边界在架构演进中的作用?
  • 视图:如何实现服务和数据在微服务各层的协作?
  • 从后端到前端:微服务后,前端如何设计?
  • 知识点串讲:基于DDD的微服务设计实例
  • 基于DDD的微服务设计实例代码详解
  • 总结(一):微服务设计和拆分要坚持哪些原则?
  • 总结(二):分布式架构关键设计10问

DDD实践:如何用DDD重构中台业务模型?

传统企业应用分析

互联网电商平台和传统核心应用,两者面向的渠道和客户不一样,但销售的产品却很相似,它们之间的业务模型既有相同的地方,又有不同的地方。

image

  • 核心能力的重复建设。由于销售同质保险产品,二者在核心业务流程和功能上必然相似,因此在核心业务能力上存在功能重叠是不可避免的。
  • 通用能力的重复建设。传统核心应用的通用平台大而全,通常会比较重。而互联网电商平台离不开这些通用能力的支撑,但为了保持敏捷性,一般会自己建设缩小版的通用功能,比如用户、客户等。
  • 业务职能的分离建设。有一类业务功能,在互联网电商平台中建设了一部分,在传统核心应用中也建设了一部分,二者功能不重叠而且还互补,组合在一起是一个完整的业务职能。
  • 互联网电商平台和传统核心功能前后完全独立建设。

如何避免重复造轮子?

你需要站在企业高度,将重复的需要共享的通用能力、核心能力沉淀到中台,将分离的业务能力重组为完整的业务板块,构建可复用的中台业务模型。前端个性能力归前端,后端管理能力归后台。建立前、中、后台边界清晰,融合协作的企业级可复用的业务模型。

如何构建中台业务模型?

1. 自顶向下的策略

这种策略是先做顶层设计,从最高领域逐级分解为中台,分别建立领域模型,根据业务属性分为通用中台或核心中台。领域建模过程主要基于业务现状,暂时不考虑系统现状。自顶向下的策略适用于全新的应用系统建设,或旧系统推倒重建的情况。

image

2. 自底向上的策略

这种策略是基于业务和系统现状完成领域建模。首先分别完成系统所在业务域的领域建模;然后对齐业务域,找出具有同类或相似业务功能的领域模型,对比分析领域模型的差异,重组领域对象,重构领域模型。这个过程会沉淀公共和复用的业务能力,会将分散的业务模型整合。自底向上策略适用于遗留系统业务模型的演进式重构。

具体如何采用自底向上的策略来构建中台业务模型,主要分为这样三个步骤。

第一步:锁定系统所在业务域,构建领域模型。

image

在这些领域模型的清单里,我们可以看到二者之间有很多名称相似的领域模型。深入分析后你会发现,这些名称相似的领域模型存在业务能力重复,或者业务职能分散(比如移动支付和传统支付)的问题。那在构建中台业务模型时,你就需要重点关注它们,将这些不同领域模型中重复的业务能力沉淀到中台业务模型中,将分散的领域模型整合到统一的中台业务模型中,对外提供统一的共享的中台服务。

第二步:对齐业务域,构建中台业务模型。

image

首先我们可以将传统核心的领域模型作为主领域模型,将互联网电商领域模型作为辅助模型来构建中台业务模型。然后再将互联网电商中重复的能力沉淀到传统核心的领域模型中,只保留自己的个性能力,比如订单。中台业务建模时,既要关注领域模型的完备性,也要关注不同渠道敏捷响应市场的要求。

客户中台业务模型的构建过程

互联网电商客户主要面向个人客户,除了有个人客户信息管理功能外,基于营销目的它还有客户积分功能,因此它的领域模型有个人和积分两个聚合。

而传统核心客户除了支持个人客户外,还有单位和组织机构等团体客户,它有个人和团体两个领域模型。其中个人领域模型中除了个人客户信息管理功能外,还有个人客户的评级、重复客户的归并和客户的统一视图等功能,因此它的领域模型有个人、视图、评级和归并四个聚合。

构建多业务域的中台业务模型的过程,就是找出同一业务域内所有同类业务的领域模型,对比分析域内领域模型和聚合的差异和共同点,打破原有的模型,完成新的中台业务模型重组或归并的过程。

我们将互联网电商和传统核心的领域模型分解后,我们找到了五个与个人客户领域相关的聚合,包括:个人、积分、评级、归并和视图。这五个聚合原来分别分散在互联网电商和传统核心的领域模型中,我们需要打破原有的领域模型,进行功能沉淀和聚合的重组,重新找出这些聚合的限界上下文,重构领域模型。

最终个人客户的领域模型重构为:个人、归并和视图三个聚合重构为个人领域模型(客户信息管理),评级和积分两个聚合重构为评级积分领域模型(面向个人客户)。到这里我们就完成了个人客户领域模型的构建了。

总结成一句话就是:“分域建模型,找准基准域,划定上下文,聚合重归类。”

其它业务域重构后的中台业务模型

image

第三步:中台归类,根据领域模型设计微服务。

完成中台业务建模后,我们就有了下面这张图。根据中台下的领域模型就可以设计微服务了。

image

重构过程中的领域对象

传统核心客户领域模型重构之前,包含个人、团体和评级三个聚合,每个聚合内部都有自己的聚合根、实体、方法和领域服务等。

image

互联网电商客户领域模型重构前包含个人和积分两个聚合,每个聚合包含了自己的领域对象、方法和领域服务等。

image

传统核心和互联网电商客户领域模型重构成客户中台后,建立了个人、团体和评级积分三个领域模型。

部分领域对象可能会根据新的业务要求,从原来的聚合中分离,重组到其它聚合。新领域模型的领域对象,比如实体、领域服务等,在重组后可能还会根据新的业务场景和需求进行代码重构。

image

领域建模:如何用事件风暴构建领域模型?

事件风暴是一项团队活动,领域专家与项目团队通过头脑风暴的形式,罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对每一个事件,标注出导致该事件的命令,再为每一个事件标注出命令发起方的角色。命令可以是用户发起,也可以是第三方系统调用或者定时器触发等,最后对事件进行分类,整理出实体、聚合、聚合根以及限界上下文。而事件风暴正是 DDD 战略设计中经常使用的一种方法,它可以快速分析和分解复杂的业务领域,完成领域建模。

事件风暴需要准备些什么?

1. 事件风暴的参与者

事件风暴采用工作坊的方式,将项目团队和领域专家聚集在一起,通过可视化、高互动的方式一步一步将领域模型设计出来。

领域专家就是对业务或问题域有深刻见解的主题专家,他们非常了解业务和系统是怎么做的,同时也深刻理解为什么要这样设计。

除了领域专家,事件风暴的其他参与者可以是 DDD 专家、架构师、产品经理、项目经理、开发人员和测试人员等项目团队成员。

2. 事件风暴要准备的材料

事件风暴参与者会将自己的想法和意见写在即时贴上,并将贴纸贴在墙上的合适位置,我们戏称这个过程是“刷墙”。所以即时贴和水笔是必备材料,另外,你还可以准备一些胶带或者磁扣,以便贴纸随时能更换位置。

值得提醒一下的是,在这个过程中,我们要用不同颜色的贴纸区分领域行为。

image

3. 事件风暴的场地

你只需要一堵足够长的墙和足够大的空间就可以了。墙是用来贴纸的,大空间可以让人四处走动,方便合作。撤掉会议桌和椅子的事件风暴,你会发现参与者们的效率更高。

4. 事件风暴分析的关注点

在领域建模的过程中,我们需要重点关注这类业务的语言和行为。比如某些业务动作或行为(事件)是否会触发下一个业务动作,这个动作(事件)的输入和输出是什么?是谁(实体)发出的什么动作(命令),触发了这个动作(事件)…我们可以从这些暗藏的词汇中,分析出领域模型中的事件、命令和实体等领域对象。

如何用事件风暴构建领域模型?

领域建模的过程主要包括产品愿景、业务场景分析、领域建模和微服务拆分与设计这几个重要阶段。

1. 产品愿景

产品愿景的主要目的是对产品顶层价值的设计,使产品目标用户、核心价值、差异化竞争点等信息达成一致,避免产品偏离方向。

image

2. 业务场景分析

场景分析是从用户视角出发的,根据业务流程或用户旅程,采用用例和场景分析,探索领域中的典型场景,找出领域事件、实体和命令等领域对象,支撑领域建模。事件风暴参与者要尽可能地遍历所有业务细节,充分发表意见,不要遗漏业务要点。

用户中台有这样三个典型的业务场景:

  • 第一个是系统和岗位设置,设置系统中岗位的菜单权限;
  • 第二个是用户权限配置,为用户建立账户和密码,设置用户岗位;
  • 第三个是用户登录系统和权限校验,生成用户登录和操作日志。

我们可以按照业务流程,一步一步搜寻用户业务流程中的关键领域事件,比如岗位已创建,用户已创建等事件。再找出什么行为会引起这些领域事件,这些行为可能是一个或若干个命令组合在一起产生的,比如创建用户时,第一个命令是从公司 HR 系统中获取用户信息,第二个命令是根据 HR 的员工信息在用户中台创建用户,创建完用户后就会产生用户已创建的领域事件。当然这个领域事件可能会触发下一步的操作,比如发布到邮件系统通知用户已创建,但也可能到此就结束了,你需要根据具体情况来分析是否还有下一步的操作。

image

3. 领域建模

领域建模时,我们会根据场景分析过程中产生的领域对象,比如命令、事件等之间关系,找出产生命令的实体,分析实体之间的依赖关系组成聚合,为聚合划定限界上下文,建立领域模型以及模型之间的依赖。领域模型利用限界上下文向上可以指导微服务设计,通过聚合向下可以指导聚合根、实体和值对象的设计。

具体可以分为这样三步。

  • 第一步:从命令和事件中提取产生这些行为的实体
  • 第二步:根据聚合根的管理性质从七个实体中找出聚合根
  • 第三步:划定限界上下文,根据上下文语义将聚合归类

image

image

到这里我们就完成了用户中台领域模型的构建了。那由于领域建模的过程中产生的领域对象实在太多了,我们可以借助表格来记录。

image

4. 微服务拆分与设计

原则上一个领域模型就可以设计为一个微服务,但由于领域建模时只考虑了业务因素,没有考虑微服务落地时的技术、团队以及运行环境等非业务因素,因此在微服务拆分与设计时,我们不能简单地将领域模型作为拆分微服务的唯一标准,它只能作为微服务拆分的一个重要依据。

用户中台微服务设计如果不考虑非业务因素,我们完全可以按照领域模型与微服务一对一的关系来设计,将用户中台设计为:用户、认证和权限三个微服务。但如果用户日志数据量巨大,大到需要采用大数据技术来实现,这时用户信息聚合与用户日志聚合就会有技术异构。虽然在领域建模时,我们将他们放在一个了领域模型内,但如果考虑技术异构,这两个聚合就不适合放到同一个微服务里了。我们可以以聚合作为拆分单位,将用户基本信息管理和用户日志管理拆分为两个技术异构的微服务,分别用不同的技术来实现它们。

代码模型(上):如何使用DDD设计微服务代码模型?

DDD 分层架构与微服务代码模型

image

业务逻辑从领域层、应用层到用户接口层逐层封装和协作,对外提供灵活的服务,既实现了各层的分工,又实现了各层的协作。因此,毋庸置疑,DDD 分层架构模型就是设计微服务代码模型的最佳依据。

微服务代码模型

微服务一级目录结构

image

各层目录结构
1. 用户接口层

image

  • Assembler:实现 DTO 与领域对象之间的相互转换和数据交换。一般来说 Assembler 与 DTO 总是一同出现。
  • Dto:它是数据传输的载体,内部不存在任何业务逻辑,我们可以通过 DTO 把内部的领域对象与外界隔离。
  • Facade:提供较粗粒度的调用接口,将用户请求委派给一个或多个应用服务进行处理。
2. 应用层

image

虽然应用层和领域层都可以进行事件的发布和处理,但为了实现事件的统一管理,我建议你将微服务内所有事件的发布和订阅的处理都统一放到应用层,事件相关的核心业务逻辑实现放在领域层。

3. 领域层

image

按照 DDD 分层架构,仓储实现本应该属于基础层代码,但为了在微服务架构演进时,保证代码拆分和重组的便利性,我是把聚合仓储实现的代码放到了聚合包内。这样,如果需求或者设计发生变化导致聚合需要拆分或重组时,我们就可以将包括核心业务逻辑和仓储代码的聚合包整体迁移,轻松实现微服务架构演进。

4. 基础层

image

Util:主要存放平台、开发框架、消息、数据库、缓存、文件、总线、网关、第三方类库、通用算法等基础代码,你可以为不同的资源类别建立不同的子目录。

代码模型总目录结构

image

总结

那关于代码模型我还需要强调两点内容。

第一点:聚合之间的代码边界一定要清晰。聚合之间的服务调用和数据关联应该是尽可能的松耦合和低关联,聚合之间的服务调用应该通过上层的应用层组合实现调用,原则上不允许聚合之间直接调用领域服务。这种松耦合的代码关联,在以后业务发展和需求变更时,可以很方便地实现业务功能和聚合代码的重组,在微服务架构演进中将会起到非常重要的作用。

第二点:你一定要有代码分层的概念。写代码时一定要搞清楚代码的职责,将它放在职责对应的代码目录内。应用层代码主要完成服务组合和编排,以及聚合之间的协作,它是很薄的一层,不应该有核心领域逻辑代码。领域层是业务的核心,领域模型的核心逻辑代码一定要在领域层实现。如果将核心领域逻辑代码放到应用层,你的基于 DDD 分层架构模型的微服务慢慢就会演变成传统的三层架构模型了。

代码模型(下):如何保证领域模型与代码模型的一致性?

DDD 强调先构建领域模型然后设计微服务,以保证领域模型和微服务的一体性,因此我们不能脱离领域模型来谈微服务的设计和落地。但在构建领域模型时,我们往往是站在业务视角的,并且有些领域对象还带着业务语言。我们还需要将领域模型作为微服务设计的输入,对领域对象进行设计和转换,让领域对象与代码对象建立映射关系。

领域对象的整理

我们第一个重要的工作就是,整理事件风暴过程中产生的各个领域对象,比如:聚合、实体、命令和领域事件等内容,将这些领域对象和业务行为记录到下面的表格中。

image

从领域模型到微服务的设计

从领域模型到微服务落地,我们还需要做进一步的设计和分析。事件风暴中提取的领域对象,还需要经过用户故事或领域故事分析,以及微服务设计,才能用于微服务系统开发。

领域层的领域对象

下面我们就来看一下这些领域对象是怎么得来的?

1. 设计实体

大多数情况下,领域模型的业务实体与微服务的数据库实体是一一对应的。但某些领域模型的实体在微服务设计时,可能会被设计为多个数据实体,或者实体的某些属性被设计为值对象。

在分层架构里,实体采用充血模型,在实体类内实现实体的全部业务逻辑。这些不同的实体都有自己的方法和业务行为,比如地址实体有新增和修改地址的方法,银行账号实体有新增和修改银行账号的方法。

2. 找出聚合根

聚合根来源于领域模型,在个人客户聚合里,个人客户这个实体是聚合根,它负责管理地址、电话以及银行账号的生命周期。个人客户聚合根通过工厂和仓储模式,实现聚合内地址、银行账号等实体和值对象数据的初始化和持久化。

3. 设计值对象

根据需要将某些实体的某些属性或属性集设计为值对象。值对象类放在代码模型的 Entity 目录结构下。在个人客户聚合中,客户拥有客户证件类型,它是以枚举值的形式存在,所以将它设计为值对象。

4. 设计领域事件

如果领域模型中领域事件会触发下一步的业务操作,我们就需要设计领域事件。首先确定领域事件发生在微服务内还是微服务之间。然后设计事件实体对象,事件的发布和订阅机制,以及事件的处理机制。判断是否需要引入事件总线或消息中间件。

5. 设计领域服务

如果一个业务动作或行为跨多个实体,我们就需要设计领域服务。领域服务通过对多个实体和实体方法进行组合,完成核心业务逻辑。你可以认为领域服务是位于实体方法之上和应用服务之下的一层业务逻辑。

按照严格分层架构层的依赖关系,如果实体的方法需要暴露给应用层,它需要封装成领域服务后才可以被应用服务调用。所以如果有的实体方法需要被前端应用调用,我们会将它封装成领域服务,然后再封装为应用服务。

6. 设计仓储

每一个聚合都有一个仓储,仓储主要用来完成数据查询和持久化操作。仓储包括仓储的接口和仓储实现,通过依赖倒置实现应用业务逻辑与数据库资源逻辑的解耦。

应用层的领域对象

应用层的主要领域对象是应用服务和事件的发布以及订阅。

在严格分层架构模式下,不允许服务的跨层调用,每个服务只能调用它的下一层服务。服务从下到上依次为:实体方法、领域服务和应用服务。

image

1. 实体方法的封装

实体方法是最底层的原子业务逻辑。如果单一实体的方法需要被跨层调用,你可以将它封装成领域服务,这样封装的领域服务就可以被应用服务调用和编排了。如果它还需要被用户接口层调用,你还需要将这个领域服务封装成应用服务。经过逐层服务封装,实体方法就可以暴露给上面不同的层,实现跨层调用。

2. 领域服务的组合和封装

领域服务会对多个实体和实体方法进行组合和编排,供应用服务调用。如果它需要暴露给用户接口层,领域服务就需要封装成应用服务。

3. 应用服务的组合和编排

应用服务会对多个领域服务进行组合和编排,暴露给用户接口层,供前端应用调用。

领域对象与微服务代码对象的映射

在完成上面的分析和设计后,我们就可以建立像下图一样的,领域对象与微服务代码对象的映射关系了。

典型的领域模型

我们看一下下面这个图,我们对个人客户聚合做了进一步的分析。提取了个人客户表单这个聚合根,形成了客户类型值对象,以及电话、地址、银行账号等实体,为实体方法和服务做了封装和分层,建立了领域对象的关联和依赖关系,还有仓储等设计。关键是这个过程,我们建立了领域对象与微服务代码对象的映射关系。

image

在建立这种映射关系后,我们就可以得到如下图的微服务代码结构了。

image

非典型领域模型

那对于这类非典型模型,我们怎么办?

我们还是可以借鉴聚合的思想,仍然用聚合来定义这部分功能,并采用与典型领域模型同样的分析方法,建立实体的属性和方法,对方法和服务进行封装和分层设计,设计仓储,建立领域对象之间的依赖关系。唯一可惜的就是我们依然找不到聚合根,不过也没关系,除了聚合根管理功能外,我们还可以用 DDD 的其它设计方法。

边界:微服务的各种边界在架构演进中的作用?

微服务的设计要涉及到逻辑边界、物理边界和代码边界等等。

演进式架构

演进式架构就是以支持增量的、非破坏的变更作为第一原则,同时支持在应用程序结构层面的多维度变化。

随着业务的发展或需求的变更,在不断重新拆分或者组合成新的微服务的过程中,不会大幅增加软件开发和维护的成本,并且这个架构演进的过程是非常轻松、简单的。

这也是微服务设计的重点,就是看微服务设计是否能够支持架构长期、轻松的演进。

微服务还是小单体?

image

这种单体式微服务只定义了一个维度的边界,也就是微服务之间的物理边界,本质上还是单体架构模式。微服务设计时要考虑的不仅仅只有这一个边界,别忘了还要定义好微服务内的逻辑边界和代码边界,这样才能得到你想要的结果。

微服务边界的作用

在事件风暴中,我们会梳理出业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出实体等领域对象。根据实体对象之间的业务关联性,将业务紧密相关的多个实体进行组合形成聚合,聚合之间是第一层边界。根据业务及语义边界等因素将一个或者多个聚合划定在一个限界上下文内,形成领域模型,限界上下文之间的边界是第二层边界。

逻辑边界主要定义同一业务领域或应用内紧密关联的对象所组成的不同聚类的组合之间的边界。事件风暴对不同实体对象进行关联和聚类分析后,会产生多个聚合和限界上下文,它们一起组成这个领域的领域模型。微服务内聚合之间的边界就是逻辑边界。一般来说微服务会有一个以上的聚合,在开发过程中不同聚合的代码隔离在不同的聚合代码目录中。

随着业务的快速发展,如果某一个微服务遇到了高性能挑战,需要将部分业务能力独立出去,我们就可以以聚合为单位,将聚合代码拆分独立为一个新的微服务,这样就可以很容易地实现微服务的拆分。

image

另外,我们也可以对多个微服务内有相似功能的聚合进行功能和代码重组,组合为新的聚合和微服务,独立为通用微服务。

物理边界主要从部署和运行的视角来定义微服务之间的边界。不同微服务部署位置和运行环境是相互物理隔离的,分别运行在不同的进程中。这种边界就是微服务之间的物理边界。

代码边界主要用于微服务内的不同职能代码之间的隔离。微服务开发过程中会根据代码模型建立相应的代码目录,实现不同功能代码的隔离。由于领域模型与代码模型的映射关系,代码边界直接体现出业务边界。代码边界可以控制代码重组的影响范围,避免业务和服务之间的相互影响。微服务如果需要进行功能重组,只需要以聚合代码为单位进行重组就可以了。

正确理解微服务的边界

微服务的拆分可以参考领域模型,也可以参考聚合,因为聚合是可以拆分为微服务的最小单位的。但实施过程是否一定要做到逻辑边界与物理边界一致性呢?也就是说聚合是否也一定要设计成微服务呢?答案是不一定的,这里就涉及到微服务过度拆分的问题了。

微服务的过度拆分会使软件维护成本上升,比如:集成成本、发布成本、运维成本以及监控和定位问题的成本等。在项目建设初期,如果你不具备较强的微服务管理能力,那就不宜将微服务拆分过细。当我们具备一定的能力以后,且微服务内部的逻辑和代码边界也很清晰,你就可以随时根据需要,拆分出新的微服务,实现微服务的架构演进了。

视图:如何实现服务和数据在微服务各层的协作?

服务的协作

1. 服务的类型

分层架构中的服务。按照分层架构设计出来的微服务,其内部有 Facade 服务、应用服务、领域服务和基础服务。

  • Facade 服务:位于用户接口层,包括接口和实现两部分。用于处理用户发送的 Restful 请求和解析用户输入的配置文件等,并将数据传递给应用层。或者在获取到应用层数据后,将 DO 组装成 DTO,将数据传输到前端应用。
  • 应用服务:位于应用层。用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果拼装,对外提供粗粒度的服务。
  • 领域服务:位于领域层。领域服务封装核心的业务逻辑,实现需要多个实体协作的核心领域逻辑。它对多个实体或方法的业务逻辑进行组合或编排,或者在严格分层架构中对实体方法进行封装,以领域服务的方式供应用层调用。
  • 基础服务:位于基础层。提供基础资源服务(比如数据库、缓存等),实现各层的解耦,降低外部资源变化对业务应用逻辑的影响。基础服务主要为仓储服务,通过依赖倒置提供基础资源服务。领域服务和应用服务都可以调用仓储服务接口,通过仓储服务实现数据持久化。2. 服务的调用

2. 服务的调用

微服务的服务调用包括三类主要场景:微服务内跨层服务调用,微服务之间服务调用和领域事件驱动。

image

微服务内跨层服务调用

微服务架构下往往采用前后端分离的设计模式,前端应用独立部署。前端应用调用发布在 API 网关上的 Facade 服务,Facade 定向到应用服务。应用服务作为服务组织和编排者,它的服务调用有这样两种路径:

  • 第一种是应用服务调用并组装领域服务。此时领域服务会组装实体和实体方法,实现核心领域逻辑。领域服务通过仓储服务获取持久化数据对象,完成实体数据初始化。
  • 第二种是应用服务直接调用仓储服务。这种方式主要针对像缓存、文件等类型的基础层数据访问。这类数据主要是查询操作,没有太多的领域逻辑,不经过领域层,不涉及数据库持久化对象。
微服务之间的服务调用

微服务之间的应用服务可以直接访问,也可以通过 API 网关访问。由于跨微服务操作,在进行数据新增和修改操作时,你需关注分布式事务,保证数据的一致性。

领域事件驱动

领域事件驱动包括微服务内和微服务之间的事件。微服务内通过事件总线(EventBus)完成聚合之间的异步处理。微服务之间通过消息中间件完成。异步化的领域事件驱动机制是一种间接的服务访问方式。

当应用服务业务逻辑处理完成后,如果发生领域事件,可调用事件发布服务,完成事件发布。当接收到订阅的主题数据时,事件订阅服务会调用事件处理领域服务,完成进一步的业务操作。

3. 服务的封装与组合

微服务的服务是从领域层逐级向上封装、组合和暴露的。

image

领域层

领域层实现核心业务逻辑,负责表达领域模型业务概念、业务状态和业务规则。主要的服务形态有实体方法和领域服务。

DDD 提倡富领域模型,尽量将业务逻辑归属到实体对象上,实在无法归属的部分则设计成领域服务。领域服务会对多个实体或实体方法进行组装和编排,实现跨多个实体的复杂核心业务逻辑。

应用层

应用层的主要服务形态有:应用服务、事件发布和订阅服务。

为了实现微服务内聚合之间的解耦,聚合之间的服务调用和数据交互应通过应用服务来完成。原则上我们应该禁止聚合之间的领域服务直接调用和聚合之间的数据表关联。

4. 两种分层架构的服务依赖关系

松散分层架构的服务依赖

在松散分层架构中,领域层的实体方法和领域服务可以直接暴露给应用层和用户接口层。松散分层架构的服务依赖关系,无需逐级封装,可以快速暴露给上层。

image

严格分层架构的服务依赖

在严格分层架构中,每一层服务只能向紧邻的上一层提供服务。虽然实体、实体方法和领域服务都在领域层,但实体和实体方法只能暴露给领域服务,领域服务只能暴露给应用服务。

image

数据对象视图

我们先来看一下微服务内有哪些类型的数据对象?它们是如何协作和转换的?

  • 数据持久化对象 PO(Persistent Object),与数据库结构一一映射,是数据持久化过程中的数据载体。
  • 领域对象 DO(Domain Object),微服务运行时的实体,是核心业务的载体。
  • 数据传输对象 DTO(Data Transfer Object),用于前端与应用层或者微服务之间的数据组装和传输,是应用之间数据传输的载体。
  • 视图对象 VO(View Object),用于封装展示层指定页面或组件的数据。

image

从后端到前端:微服务后,前端如何设计?

微服务架构通常采用前后端分离的设计方式。作为企业级的中台,在完成单体应用拆分和微服务建设后,前端项目团队会同时面对多个中台微服务项目团队,这时候的前端人员就犹如维修电工一样了。

要从一定程度上解决上述问题,我们是不是可以考虑先有效降低前端集成的复杂度呢?先做到前端聚合,后端解耦

从单体前端到微前端

在前端设计时我们需要遵循单一职责和复用原则,按照领域模型和微服务边界,将前端页面进行拆分。同时构建多个可以独立部署、完全自治、松耦合的页面组合,其中每个组合只负责特定业务单元的 UI 元素和功能,这些页面组合就是微前端。

微前端与微服务一样,都是希望将单体应用,按照规则拆分,并重组为多个可以独立开发、独立测试、独立部署和独立运维,松耦合的微前端或者微服务。以适应业务快速变化及分布式多团队并行开发的要求。

业务单元的组合形态

我们可以参照领域模型和微服务边界,建立与微服务对应的前端操作界面,将它与微服务组成业务单元,以业务组件的方式对外提供服务。业务单元包括微前端和微服务,可以独立开发、测试、部署和运维,可以自包含地完成领域模型中部分或全部的业务功能。

我们看一下下面这个图。一个虚框就是一个业务单元,微前端和微服务独立部署,业务单元内的微前端和微服务已完成前后端集成。你可以将这个业务单元理解为一个特定业务领域的组件。业务单元可以有多种组合方式,以实现不同的业务目标。

image

记住一点:微前端不宜与过多的微服务组合,否则容易变成单体前端。

微前端的集成方式

我们看一下下面这个图,微前端位于前端主页面和微服务之间,它需要与两者完成集成。

image

1. 微前端与前端主页面的集成

前端主页面是企业级的前端页面,微前端是业务单元的前端页面。微前端通过主页面的微前端加载器,利用页面路由和动态加载等技术,将特定业务单元的微前端页面动态加载到前端主页面,实现前端主页面与微前端页面的“拼图式”集成。

微前端完成开发、集成和部署后,在前端主页面完成微前端注册以及页面路由配置,即可实现动态加载微前端页面。

2. 微前端与微服务的集成

微前端与微服务独立开发,独立部署。在微前端注册到前端主页面前,微前端需要与微服务完成集成。它的集成方式与传统前后端分离的集成方式没有差异。微服务将服务发布到 API 网关,微前端调用发布在 API 网关中的服务,即完成业务单元内的前后端集成。

团队职责边界

前端项目团队专注于前端集成主页面与微前端的集成,完成前端主页面的企业级主流程的页面和流程编排以及微前端页面的动态加载,确保主流程业务逻辑和流程正确。前端项目除了要负责企业内页面风格的整体风格设计、业务流程的流转和控制外,还需要负责微前端页面动态加载、微前端注册、页面路由和页面数据共享等前端技术的实现。

中台项目团队完成业务单元组件的开发、测试和集成,确保业务单元内的业务逻辑、页面和流程正确,向外提供包含页面逻辑和业务逻辑的业务单元组件。

这样,前端项目团队只需要完成企业级前端主页面与业务单元的融合,前端只关注前端主页面与微前端页面之间的集成。

中台项目团队关注业务单元功能的完整性和自包含能力,完成业务单元内微服务和微前端开发、集成和部署,提供业务单元组件。

一个有关保险微前端设计的案例

保险集团为了统一运营,会实现寿险、财险等集团化的全险种销售。这样前端项目团队就需要用一个前端应用,集成非常多的不同产品的核心中台微服务,前端应用与中台微服务之间的集成将会更复杂。

如果仍然采用传统的单体前端模式,将会面临比较大的困难。

  • 第一是前端页面开发和设计的复杂性。
  • 第二是前端与微服务集成的复杂性。
  • 第三是前后端软件版本的协同发布。

那如何用一个前端应用实现全险种产品销售呢?怎样设计才能降低集成的复杂度,实现前端界面融合,后端中台解耦呢?

我们看一下下面这个图。我们借鉴了电商的订单模式实现保险产品的全险种订单化销售,在一个前端主页面可以将所有业务流程和业务操作无缝串联起来。虽然后端有很多业务单元(包含微服务和微前端),但用户始终感觉是在一个前端应用中操作。

image

微前端

每个微服务都有自己的微前端页面,实现领域模型的微服务前端页面操作。

业务单元

微服务与微前端组合为一个业务单元。由一个中台团队完成业务单元的开发、集成、测试和部署,确保业务单元内页面操作和业务逻辑正确。

前端主页面

前端主页面类似门户,包括页面导航以及部分通用的常驻主页面的共享页面,比如购物车。前端主页面和所有微前端应统一界面风格,符合统一的前端集成规范。按照正确的业务逻辑和规则,动态加载不同业务单元的微前端页面。前端主页面作为一个整体,协调核心和通用业务单元的微前端页面,完成业务操作和业务流程,提供全险种销售接触界面,包括商品目录、录单、购物车、订单、支付等操作。

虽然后端有很多业务单元在支持,但用户所有的页面操作和流转是在一个前端主页面完成的。在进行全险种的订单化销售时,用户始终感觉是在操作一个系统。这种设计方式很好地体现了前端的融合和中台的解耦。

微前端和业务单元化的设计模式可以减轻企业级中台,前后端应用开发和集成的复杂度,真正实现前端融合和中台解耦。它的主要价值和意义如下:

  • 前端集成简单
  • 项目职责专一
  • 隔离和依赖性
  • 降低沟通和测试成本
  • 更敏捷地发布
  • 降低技术敏感性
  • 高度复用性

知识点串讲:基于DDD的微服务设计实例

为了更好地理解 DDD 的设计流程,今天我会用一个项目来带你了解 DDD 的战略设计和战术设计,走一遍从领域建模到微服务设计的全过程,一起掌握 DDD 的主要设计流程和关键点。

项目基本信息

项目的目标是实现在线请假和考勤管理。功能描述如下:

1、请假人填写请假单提交审批,根据请假人身份、请假类型和请假天数进行校验,根据审批规则逐级递交上级审批,逐级核批通过则完成审批,否则审批不通过退回申请人。

2、根据考勤规则,核销请假数据后,对考勤数据进行校验,输出考勤统计。

战略设计

战略设计是根据用户旅程分析,找出领域对象和聚合根,对实体和值对象进行聚类组成聚合,划分限界上下文,建立领域模型的过程。

1. 产品愿景

产品愿景是对产品顶层价值设计,对产品目标用户、核心价值、差异化竞争点等信息达成一致,避免产品偏离方向。

事件风暴时,所有参与者针对每一个要点,在贴纸上写出自己的意见,贴到白板上。事件风暴主持者会对每个贴纸,讨论并对发散的意见进行收敛和统一,形成下面的产品愿景图。

image

我们把这个产品愿景图整理成一段文字就是:为了满足内外部人员,他们的在线请假、自动考勤统计和外部人员管理的需求,我们建设这个在线请假考勤系统,它是一个在线请假平台,可以自动考勤统计。它可以同时支持内外网请假,同时管理内外部人员请假和定期考勤分析,而不像 HR 系统,只管理内部人员,且只能内网使用。我们的产品内外网皆可使用,可实现内外部人员无差异管理。

通过产品愿景分析,项目团队统一了系统名称——在线请假考勤系统,明确了项目目标和关键功能,与竞品(HR)的关键差异以及自己的优势和核心竞争力等。

2. 场景分析

场景分析是从用户视角出发,探索业务领域中的典型场景,产出领域中需要支撑的场景分类、用例操作以及不同子域之间的依赖关系,用以支撑领域建模。

下面我就以请假和人员两个场景作为示例。

第一个场景:请假

用户:请假人

  • 请假人登录系统:从权限微服务获取请假人信息和权限数据,完成登录认证。
  • 创建请假单:打开请假页面,选择请假类型和起始时间,录入请假信息。保存并创建请假单,提交请假审批。
  • 修改请假单:查询请假单,打开请假页面,修改请假单,提交请假审批。
  • 提交审批:获取审批规则,根据审批规则,从人员组织关系中获取审批人,给请假单分配审批人。

第二个场景:审批

用户:审批人

  • 审批人登录系统:从权限微服务获取审批人信息和权限数据,完成登录认证。
  • 获取请假单:获取审批人名下请假单,选择请假单。
  • 审批:填写审批意见。逐级审批:如果还需要上级审批,根据审批规则,从人员组织关系中获取审批人,给请假单分配审批人。
  • 重复以上 4 步。最后审批人完成审批。

完成审批后,产生请假审批已通过领域事件。后续有两个进一步的业务操作:发送请假审批已通过的通知,通知邮件系统告知请假人;将请假数据发送到考勤以便核销。

image

下面这个图是人员组织关系场景分析结果图

image

3. 领域建模

领域建模是通过对业务和问题域进行分析,建立领域模型。向上通过限界上下文指导微服务边界设计,向下通过聚合指导实体对象设计。

领域建模是一个收敛的过程,分三步:

  • 第一步找出领域实体和值对象等领域对象;
  • 第二步找出聚合根,根据实体、值对象与聚合根的依赖关系,建立聚合;
  • 第三步根据业务及语义边界等因素,定义限界上下文。

下面我们就逐步详细讲解一下。

第一步:找出实体和值对象等领域对象

根据场景分析,分析并找出发起或产生这些命令或领域事件的实体和值对象,将与实体或值对象有关的命令和事件聚集到实体。

image

第二步:定义聚合

定义聚合前,先找出聚合根。从上面的实体中,我们可以找出“请假单”和“人员”两个聚合根。然后找出与聚合根紧密依赖的实体和值对象。我们发现审批意见、审批规则和请假单紧密关联,组织关系和人员紧密关联。

找出这些实体的关系后,我们发现还有刷卡明细、考勤明细和考勤统计,这几个实体没有聚合根。这种情形在领域建模时你会经常遇到,对于这类场景我们需要分情况特殊处理。

刷卡明细、考勤明细和考勤统计这几个实体,它们之间相互独立,找不出聚合根,不是富领域模型,但它们一起完成考勤业务逻辑,具有很高的业务内聚性。我们将这几个业务关联紧密的实体,放在一个考勤聚合内。在微服务设计时,我们依然采用 DDD 的设计和分析方法。由于没有聚合根来管理聚合内的实体,我们可以用传统的方法来管理实体。

经过分析,我们建立了请假、人员组织关系和考勤三个聚合。其中请假聚合有请假单、审批意见实体和审批规则等值对象。人员组织关系聚合有人员和组织关系等实体。考勤聚合有刷卡明细、考勤明细和考勤统计等实体。

image

第三步:定义限界上下文

由于人员组织关系聚合与请假聚合,共同完成请假的业务功能,两者在请假的限界上下文内。考勤聚合则单独构成考勤统计限界上下文。因此我们为业务划分请假和考勤统计两个限界上下文,建立请假和考勤两个领域模型。

4. 微服务的拆分

理论上一个限界上下文就可以设计为一个微服务,但还需要综合考虑多种外部因素,比如:职责单一性、敏态与稳态业务分离、非功能性需求(如弹性伸缩、版本发布频率和安全等要求)、软件包大小、团队沟通效率和技术异构等非业务要素。

在这个项目,我们划分微服务主要考虑职责单一性原则。因此根据限界上下文就可以拆分为请假和考勤两个微服务。其中请假微服务包含人员组织关系和请假两个聚合,考勤微服务包含考勤聚合。

到这里,战略设计就结束了。通过战略设计,我们建立了领域模型,划分了微服务边界。下一步就是战术设计了,也就是微服务设计。下面我们以请假微服务为例,讲解其设计过程。

战术设计

战术设计是根据领域模型进行微服务设计的过程。这个阶段主要梳理微服务内的领域对象,梳理领域对象之间的关系,确定它们在代码模型和分层架构中的位置,建立领域模型与微服务模型的映射关系,以及服务之间的依赖关系。

战术设计包括以下两个阶段:分析微服务领域对象和设计微服务代码结构。

1. 分析微服务领域对象

服务的识别和设计

事件风暴的命令是外部的一些操作和业务行为,也是微服务对外提供的能力。它往往与微服务的应用服务或者领域服务对应。我们可以将命令作为服务识别和设计的起点。具体步骤如下:

  • 根据命令设计应用服务,确定应用服务的功能,服务集合,组合和编排方式。服务集合中的服务包括领域服务或其它微服务的应用服务。
  • 根据应用服务功能要求设计领域服务,定义领域服务。这里需要注意:应用服务可能是由多个聚合的领域服务组合而成的。
  • 根据领域服务的功能,确定领域服务内的实体以及功能。
  • 设计实体基本属性和方法。

另外,我们还要考虑领域事件的异步化处理。

我以提交审批这个动作为例,来说明服务的识别和设计。提交审批的大体流程是:

  • 根据请假类型和时长,查询请假审批规则,获取下一步审批人的角色。
  • 根据审批角色从人员组织关系中查询下一审批人。
  • 为请假单分配审批人,并将审批规则保存至请假单。
  • 通过分析,我们需要在应用层和领域层设计以下服务和方法。

应用层:提交审批应用服务。

领域层:领域服务有查询审批规则、修改请假流程信息服务以及根据审批规则查询审批人服务,分别位于请假和人员组织关系聚合。请假单实体有修改请假流程信息方法,审批规则值对象有查询审批规则方法。人员实体有根据审批规则查询审批人方法。下图是我们分析出来的服务以及它们之间的依赖关系。

image

服务的识别和设计过程就是这样了,我们再来设计一下聚合内的对象。

聚合中的对象

在请假单聚合中,聚合根是请假单。

请假单经多级审核后,会产生多条审批意见,为了方便查询,我们可以将审批意见设计为实体。请假审批通过后,会产生请假审批通过的领域事件,因此还会有请假事件实体。请假聚合有以下实体:审批意见(记录审批人、审批状态和审批意见)和请假事件实体。

我们再来分析一下请假单聚合的值对象。请假人和下一审批人数据来源于人员组织关系聚合中的人员实体,可设计为值对象。人员类型、请假类型和审批状态是枚举值类型,可设计为值对象。确定请假审批规则后,审批规则也可作为请假单的值对象。请假单聚合将包含以下值对象:请假人、人员类型、请假类型、下一审批人、审批状态和审批规则。

综上,我们就可以画出请假聚合对象关系图了。

image

在人员组织关系聚合中,我们可以建立人员之间的组织关系,通过组织关系类型找到上级审批领导。它的聚合根是人员,实体有组织关系(包括组织关系类型和上级审批领导),其中组织关系类型(如项目经理、处长、总经理等)是值对象。上级审批领导来源于人员聚合根,可设计为值对象。人员组织关系聚合将包含以下值对象:组织关系类型、上级审批领导。

综上,我们又可以画出人员组织关系聚合对象关系图了。

image

微服务内的对象清单

在确定各领域对象的属性后,我们就可以设计各领域对象在代码模型中的代码对象(包括代码对象的包名、类名和方法名),建立领域对象与代码对象的一一映射关系了。根据这种映射关系,相关人员可快速定位到业务逻辑所在的代码位置。在经过以上分析后,我们在微服务内就可以分析出如下图的对象清单。

image

2. 设计微服务代码结构

应用层代码结构

应用层包括:应用服务、DTO 以及事件发布相关代码。在 LeaveApplicationService 类内实现与聚合相关的应用服务,在 LoginApplicationService 封装外部微服务认证和权限的应用服务。

image

领域层代码结构

领域层包括一个或多个聚合的实体类、事件实体类、领域服务以及工厂、仓储相关代码。一个聚合对应一个聚合代码目录,聚合之间在代码上完全隔离,聚合之间通过应用层协调。

请假微服务领域层包含请假和人员两个聚合。人员和请假代码都放在各自的聚合所在目录结构的代码包中。如果随着业务发展,人员相关功能需要从请假微服务中拆分出来,我们只需将人员聚合代码包稍加改造,独立部署,即可快速发布为人员微服务。到这里,微服务内的领域对象,分层以及依赖关系就梳理清晰了。微服务的总体架构和代码模型也基本搭建完成了。

image

后续的工作

1. 详细设计

在完成领域模型和微服务设计后,我们还需要对微服务进行详细的设计。主要设计以下内容:实体属性、数据库表和字段、实体与数据库表映射、服务参数规约及功能实现等。

2. 代码开发和测试

开发人员只需要按照详细的设计文档和功能要求,找到业务功能对应的代码位置,完成代码开发就可以了。代码开发完成后,开发人员要编写单元测试用例,基于挡板模拟依赖对象完成服务测试。

基于DDD的微服务设计实例代码详解

上一节用事件风暴完成的“在线请假考勤”项目的领域建模和微服务设计,接下来我们在这个项目的基础上看看,用 DDD 方法设计和开发出来的微服务代码。点击 https://github.com/ouchuangxin/leave-sample 获取完整代码

项目回顾

“在线请假考勤”项目中,请假的核心业务流程是:请假人填写请假单提交审批;根据请假人身份、请假类型和请假天数进行校验并确定审批规则;根据审批规则确定审批人,逐级提交上级审批,逐级核批通过则完成审批,否则审批不通过则退回申请人。

请假微服务采用的 DDD 设计思想

image

聚合中的对象

image

请假微服务包含请假(leave)、人员(person)和审批规则(rule)三个聚合。leave 聚合完成请假申请和审核核心逻辑;person 聚合管理人员信息和上下级关系;rule 是一个单实体聚合,提供请假审批规则查询。

Leave 是请假微服务的核心聚合,它有请假单聚合根 leave、审批意见实体 ApprovalInfo、请假申请人 Applicant 和审批人 Approver 值对象(它们的数据来源于 person 聚合),还有部分枚举类型,如请假类型 LeaveType,请假单状态 Status 和审批状态类型 ApprovalType 等值对象。

下面我们通过代码来了解一下聚合根、实体以及值对象之间的关系。

1. 聚合根

聚合根 leave 中有属性、值对象、关联实体和自身的业务行为。Leave 实体采用充血模型,有自己的业务行为,具体就是聚合根实体类的方法,如代码中的 getDuration 和 addHistoryApprovalInfo 等方法。

聚合根引用实体和值对象,它可以组合聚合内的多个实体,在聚合根实体类方法中完成复杂的业务行为,这种复杂的业务行为也可以在聚合领域服务里实现。但为了职责和边界清晰,我建议聚合要根据自身的业务行为在实体类方法中实现,而涉及多个实体组合才能实现的业务能力由领域服务完成。

下面是聚合根 leave 的实体类方法,它包含属性、对实体和值对象的引用以及自己的业务行为和方法。

public class Leave {
    String id;
    Applicant applicant;
    Approver approver;
    LeaveType type;
    Status status;
    Date startTime;
    Date endTime;
    long duration;
    int leaderMaxLevel; //审批领导的最高级别
    ApprovalInfo currentApprovalInfo;
    List<ApprovalInfo> historyApprovalInfos; 

    public long getDuration() {
        return endTime.getTime() - startTime.getTime();
    }

    public Leave addHistoryApprovalInfo(ApprovalInfo approvalInfo) {
        if (null == historyApprovalInfos)
            historyApprovalInfos = new ArrayList<>();
        this.historyApprovalInfos.add(approvalInfo);
        return this;
    } 

    public Leave create(){
        this.setStatus(Status.APPROVING);
        this.setStartTime(new Date());
        return this;
}

//其它方法
}

2. 实体

审批意见实体 ApprovalInfo 被 leave 聚合根引用,用于记录审批意见,它有自己的属性和值对象,如 approver 等,业务逻辑相对简单。

public class ApprovalInfo {
    String approvalInfoId;
    Approver approver;
    ApprovalType approvalType;
    String msg;
    long time;
}

3. 值对象

审批人值对象 Approver。这类值对象除了属性集之外,还可以有简单的数据查询和转换服务。Approver 数据来源于 person 聚合,从 person 聚合获取审批人返回后,从 person 实体获取 personID、personName 和 level 等属性,重新组合为 approver 值对象,因此需要数据转换和重新赋值。

Approver 值对象同时被聚合根 leave 和实体 approvalInfo 引用。这类值对象的数据来源于其它聚合,不可修改,可重复使用。将这种对象设计为值对象而不是实体,可以提高系统性能,降低数据库实体关联的复杂度,所以我一般建议优先设计为值对象。

public class Approver {
    String personId;
    String personName;
    int level; //管理级别

    public static Approver fromPerson(Person person){
        Approver approver = new Approver();
        approver.setPersonId(person.getPersonId());
        approver.setPersonName(person.getPersonName());
        approver.setLevel(person.getRoleLevel());
        return approver;
    }
}

枚举类型的值对象 Status 的代码

public enum Status {
    APPROVING, APPROVED, REJECTED
}

由于值对象只做整体替换、不可修改的特性,在值对象中基本不会有修改或新增的方法

4. 领域服务

如果一个业务行为由多个实体对象参与完成,我们就将这部分业务逻辑放在领域服务中实现。领域服务与实体方法的区别是:实体方法完成单一实体自身的业务逻辑,是相对简单的原子业务逻辑,而领域服务则是多个实体组合出的相对复杂的业务逻辑。两者都在领域层,实现领域模型的核心业务能力。

一个聚合可以设计一个领域服务类,管理聚合内所有的领域服务。

请假聚合的领域服务类是 LeaveDomainService。领域服务中会用到很多的 DDD 设计模式,比如:用工厂模式实现复杂聚合的实体数据初始化,用仓储模式实现领域层与基础层的依赖倒置和用领域事件实现数据的最终一致性等。

public class LeaveDomainService {

    @Autowired
    EventPublisher eventPublisher;
    @Autowired
    LeaveRepositoryInterface leaveRepositoryInterface;
    @Autowired
    LeaveFactory leaveFactory;

    @Transactional
    public void createLeave(Leave leave, int leaderMaxLevel, Approver approver) {
            leave.setLeaderMaxLevel(leaderMaxLevel);
            leave.setApprover(approver);
            leave.create();
    leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
    LeaveEvent event = LeaveEvent.create(LeaveEventType.CREATE_EVENT, leave);
    leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event));
    eventPublisher.publish(event);
    }

    @Transactional
    public void updateLeaveInfo(Leave leave) {
    LeavePO po = leaveRepositoryInterface.findById(leave.getId());
        if (null == po) {
                throw new RuntimeException("leave does not exist");
         }
     leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
    }

    @Transactional
    public void submitApproval(Leave leave, Approver approver) {
       LeaveEvent event;
       if (ApprovalType.REJECT == leave.getCurrentApprovalInfo().getApprovalType()) {
       leave.reject(approver);
       event = LeaveEvent.create(LeaveEventType.REJECT_EVENT, leave);
       } else {
             if (approver != null) {
                 leave.agree(approver);
                 event = LeaveEvent.create(LeaveEventType.AGREE_EVENT, leave); } else {
                    leave.finish();
                    event = LeaveEvent.create(LeaveEventType.APPROVED_EVENT, leave);
                    }
           }
      leave.addHistoryApprovalInfo(leave.getCurrentApprovalInfo());
      leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
      leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event));
      eventPublisher.publish(event);
    }

    public Leave getLeaveInfo(String leaveId) {
    LeavePO leavePO = leaveRepositoryInterface.findById(leaveId);
    return leaveFactory.getLeave(leavePO);
    }

    public List<Leave> queryLeaveInfosByApplicant(String applicantId) {
        List<LeavePO> leavePOList = leaveRepositoryInterface.queryByApplicantId(applicantId);
    return leavePOList.stream().map(leavePO -> leaveFactory.getLeave(leavePO)).collect(Collectors.toList());
    }

    public List<Leave> queryLeaveInfosByApprover(String approverId) {
    List<LeavePO> leavePOList = leaveRepositoryInterface.queryByApproverId(approverId);
    return leavePOList.stream().map(leavePO -> leaveFactory.getLeave(leavePO)).collect(Collectors.toList());
    }
}
领域服务开发时的注意事项:

在领域服务或实体方法中,我们应尽量避免调用其它聚合的领域服务或引用其它聚合的实体或值对象,这种操作会增加聚合的耦合度。在微服务架构演进时,如果出现聚合拆分和重组,这种跨聚合的服务调用和对象引用,会变成跨微服务的操作,导致这种跨聚合的领域服务调用和对象引用失效,在聚合分拆时会增加你代码解耦和重构的工作量。

以下是一段不建议使用的代码。在这段代码里 Approver 是 leave 聚合的值对象,它作为对象参数被传到 person 聚合的 findNextApprover 领域服务。如果在同一个微服务内,这种方式是没有问题的。但在架构演进时,如果 person 和 leave 两个聚合被分拆到不同的微服务中,那么 leave 中的 Approver 对象以及它的 getPersonId() 和 fromPersonPO 方法在 person 聚合中就会失效,这时你就需要进行代码重构了。

public class PersonDomainService {

   public Approver findNextApprover(Approver currentApprover, int leaderMaxLevel) {
   PersonPO leaderPO = personRepository.findLeaderByPersonId(currentApprover.getPersonId());
        if (leaderPO.getRoleLevel() > leaderMaxLevel) {
            return null;
        } else {
            return Approver.fromPersonPO(leaderPO);
        }
   }
}

那正确的方式是什么样的呢?在应用服务组合不同聚合的领域服务时,我们可以通过 ID 或者参数来传数,如单一参数 currentApproverId。这样聚合之间就解耦了,下面是修改后的代码,它可以不依赖其它聚合的实体,独立完成业务逻辑。

public class PersonDomainService {
   
   public Person findNextApprover(String currentApproverId, int leaderMaxLevel) {
   PersonPO leaderPO = personRepository.findLeaderByPersonId(currentApproverId);
   if (leaderPO.getRoleLevel() > leaderMaxLevel) {
       return null;
    } else {
            return personFactory.createPerson(leaderPO);
      }
  }
}

领域事件

在创建请假单和请假审批过程中会产生领域事件。为了方便管理,我们将聚合内的领域事件相关的代码放在聚合的 event 目录中。领域事件实体在聚合仓储内完成持久化,但是事件实体的生命周期不受聚合根管理。

1. 领域事件基类 DomainEvent

你可以建立统一的领域事件基类 DomainEvent。基类包含:事件 ID、时间戳、事件源以及事件相关的业务数据。

public class DomainEvent {
    String id;
    Date timestamp;
    String source;
    String data;
}

2. 领域事件实体

请假领域事件实体 LeaveEvent 继承基类 DomainEvent。可根据需要扩展属性和方法,如 leaveEventType。data 字段中存储领域事件相关的业务数据,可以是 XML 或 Json 等格式。

public class LeaveEvent extends DomainEvent {
    LeaveEventType leaveEventType;
    public static LeaveEvent create(LeaveEventType eventType, Leave leave){
       LeaveEvent event = new LeaveEvent();
       event.setId(IdGenerator.nextId());
       event.setLeaveEventType(eventType);
       event.setTimestamp(new Date());
       event.setData(JSON.toJSONString(leave));
       return event;
    }
}

3. 领域事件的执行逻辑

一般来说,领域事件的执行逻辑如下:

第一步:执行业务逻辑,产生领域事件。

第二步:完成业务数据持久化。

leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));

第三步:完成事件数据持久化。

leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event));

第四步:完成领域事件发布。

eventPublisher.publish(event); 

以上领域事件处理逻辑代码详见 LeaveDomainService 中 submitApproval 领域服务,里面有请假提交审批事件的完整处理逻辑。

4. 领域事件数据持久化

为了保证事件发布方与事件订阅方数据的最终一致性和数据审计,有些业务场景需要建立数据对账机制。数据对账主要通过对源端和目的端的持久化数据比对,从而发现异常数据并进一步处理,保证数据最终一致性。

对于需要对账的事件数据,我们需设计领域事件对象的持久化对象 PO,完成领域事件数据的持久化,如 LeaveEvent 事件实体的持久化对象 LeaveEventPO。再通过聚合的仓储完成数据持久化:

leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event))。

事件数据持久化对象 LeaveEventPO 格式如下:

public class LeaveEventPO {
    @Id
    @GenericGenerator(name = "idGenerator", strategy = "uuid") 
    @GeneratedValue(generator = "idGenerator")
    int id;
    @Enumerated(EnumType.STRING)
    LeaveEventType leaveEventType;
    Date timestamp;
    String source;
    String data;
}

仓储模式

领域模型中 DO 实体的数据持久化是必不可少的,DDD 采用仓储模式实现数据持久化,使得业务逻辑与基础资源逻辑解耦,实现依赖倒置。持久化时先完成 DO 与 PO 对象的转换,然后在仓储服务中完成 PO 对象的持久化。

1. DO 与 PO 对象的转换

Leave 聚合根的 DO 实体除了自身的属性外,还会根据领域模型引用多个值对象,如 Applicant 和 Approver 等,它们包含多个属性,如:personId、personName 和 personType 等属性。

在持久化对象 PO 设计时,你可以将这些值对象属性嵌入 PO 属性中,或设计一个组合属性字段,以 Json 串的方式存储在 PO 中。

以下是 leave 的 DO 的属性定义:

public class Leave {
    String id;
    Applicant applicant;
    Approver approver;
    LeaveType type;
    Status status;
    Date startTime;
    Date endTime;
    long duration;
    int leaderMaxLevel;
    ApprovalInfo currentApprovalInfo;
    List<ApprovalInfo> historyApprovalInfos;
}

public class Applicant {
    String personId;
    String personName;
    String personType;
}

public class Approver {
    String personId;
    String personName;
    int level;
}

为了减少数据库表数量以及表与表的复杂关联关系,我们将 leave 实体和多个值对象放在一个 LeavePO 中。如果以属性嵌入的方式,Applicant 值对象在 LeavePO 中会展开为:applicantId、applicantName 和 applicantType 三个属性。

以下为采用属性嵌入方式的持久化对象 LeavePO 的结构。

public class LeavePO {
    @Id
    @GenericGenerator(name="idGenerator", strategy="uuid") 
    @GeneratedValue(generator="idGenerator")
    String id;
    String applicantId;
    String applicantName;
    @Enumerated(EnumType.STRING)
    PersonType applicantType;
    String approverId;
    String approverName;
    @Enumerated(EnumType.STRING)
    LeaveType leaveType;
    @Enumerated(EnumType.STRING)
    Status status;
    Date startTime;
    Date endTime;
    long duration;
    @Transient
    List<ApprovalInfoPO> historyApprovalInfoPOList;
}

2. 仓储模式

为了解耦业务逻辑和基础资源,我们可以在基础层和领域层之间增加一层仓储服务,实现依赖倒置。通过这一层可以实现业务逻辑和基础层资源的依赖分离。在变更基础层数据库的时候,你只要替换仓储实现就可以了,上层核心业务逻辑不会受基础资源变更的影响,从而实现依赖倒置。

一个聚合一个仓储,实现聚合数据的持久化。领域服务通过仓储接口来访问基础资源,由仓储实现完成数据持久化和初始化。仓储一般包含:仓储接口和仓储实现。

2.1 仓储接口

仓储接口面向领域服务提供接口。

public interface LeaveRepositoryInterface {
   void save(LeavePO leavePO);
   void saveEvent(LeaveEventPO leaveEventPO);
   LeavePO findById(String id);
   List<LeavePO> queryByApplicantId(String applicantId);
   List<LeavePO> queryByApproverId(String approverId);
}
2.2 仓储实现

仓储实现完成数据持久化和数据库查询。

@Repository
public class LeaveRepositoryImpl implements LeaveRepositoryInterface {

    @Autowired
    LeaveDao leaveDao;
    @Autowired
    ApprovalInfoDao approvalInfoDao;
    @Autowired
    LeaveEventDao leaveEventDao;

    public void save(LeavePO leavePO) { 
        leaveDao.save(leavePO);
        approvalInfoDao.saveAll(leavePO.getHistoryApprovalInfoPOList());
    }

    public void saveEvent(LeaveEventPO leaveEventPO){
        leaveEventDao.save(leaveEventPO);
    }

    @Override
    public LeavePO findById(String id) {
        return leaveDao.findById(id)
                .orElseThrow(() -> new RuntimeException("leave not found"));
    }

    @Override
    public List<LeavePO> queryByApplicantId(String applicantId) {
        List<LeavePO> leavePOList = leaveDao.queryByApplicantId(applicantId);
        leavePOList.stream()
                .forEach(leavePO -> {
                    List<ApprovalInfoPO> approvalInfoPOList = approvalInfoDao.queryByLeaveId(leavePO.getId());
                    leavePO.setHistoryApprovalInfoPOList(approvalInfoPOList);
                });
        return leavePOList;
    }

    @Override
    public List<LeavePO> queryByApproverId(String approverId) {
        List<LeavePO> leavePOList = leaveDao.queryByApproverId(approverId);
        leavePOList.stream()
                .forEach(leavePO -> {
                    List<ApprovalInfoPO> approvalInfoPOList = approvalInfoDao.queryByLeaveId(leavePO.getId());
                    leavePO.setHistoryApprovalInfoPOList(approvalInfoPOList);
                });
        return leavePOList;
    }
}

这里持久化组件采用了 Jpa。

public interface LeaveDao extends JpaRepository<LeavePO, String> {
    List<LeavePO> queryByApplicantId(String applicantId);
    List<LeavePO> queryByApproverId(String approverId);
}
2.3 仓储执行逻辑

以创建请假单为例,仓储的执行步骤如下。

第一步:仓储执行之前将聚合内 DO 会转换为 PO,这种转换在工厂服务中完成:

leaveFactory.createLeavePO(leave)。

第二步:完成对象转换后,领域服务调用仓储接口:

leaveRepositoryInterface.save

第三步:由仓储实现完成 PO 对象持久化。

代码执行步骤如下:

public void createLeave(Leave leave, int leaderMaxLevel, Approver approver) {
  leave.setLeaderMaxLevel(leaderMaxLevel);
  leave.setApprover(approver);
  leave.create();
  leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
}

工厂模式

对于大型的复杂领域模型,聚合内的聚合根、实体和值对象之间的依赖关系比较复杂,这种过于复杂的依赖关系,不适合通过根实体构造器来创建。为了协调这种复杂的领域对象的创建和生命周期管理,在 DDD 里引入了工厂模式(Factory),在工厂里封装复杂的对象创建过程。

当聚合根被创建时,聚合内所有依赖的对象将会被同时创建。

工厂与仓储模式往往结对出现,应用于数据的初始化和持久化两类场景。

  • DO 对象的初始化:获取持久化对象 PO,通过工厂一次构建出聚合根所有依赖的 DO 对象,完数据初始化。
  • DO 的对象持久化:将所有依赖的 DO 对象一次转换为 PO 对象,完成数据持久化。

下面代码是 leave 聚合的工厂类 LeaveFactory。其中 createLeavePO(leave)方法组织 leave 聚合的 DO 对象和值对象完成 leavePO 对象的构建。getLeave(leave)通过持久化对象 PO 构建聚合的 DO 对象和值对象,完成 leave 聚合 DO 实体的初始化。

public class LeaveFactory {
   
   public LeavePO createLeavePO(Leave leave) {
   LeavePO leavePO = new LeavePO();
   leavePO.setId(UUID.randomUUID().toString());
   leavePO.setApplicantId(leave.getApplicant().getPersonId());
   leavePO.setApplicantName(leave.getApplicant().getPersonName());
   leavePO.setApproverId(leave.getApprover().getPersonId());
   leavePO.setApproverName(leave.getApprover().getPersonName());
   leavePO.setStartTime(leave.getStartTime());
   leavePO.setStatus(leave.getStatus());
   List<ApprovalInfoPO> historyApprovalInfoPOList = approvalInfoPOListFromDO(leave);
   leavePO.setHistoryApprovalInfoPOList(historyApprovalInfoPOList);
   return leavePO;
}


   public Leave getLeave(LeavePO leavePO) {
   Leave leave = new Leave();
   Applicant applicant = Applicant.builder()
       .personId(leavePO.getApplicantId())
       .personName(leavePO.getApplicantName())
       .build();
   leave.setApplicant(applicant);
   Approver approver = Approver.builder()
       .personId(leavePO.getApproverId())
       .personName(leavePO.getApproverName())
       .build();
   leave.setApprover(approver);
   leave.setStartTime(leavePO.getStartTime());
   leave.setStatus(leavePO.getStatus());
   List<ApprovalInfo> approvalInfos = getApprovalInfos(leavePO.getHistoryApprovalInfoPOList());
   leave.setHistoryApprovalInfos(approvalInfos);
   return leave;
   }


//其它方法
}

服务的组合与编排

应用层的应用服务完成领域服务的组合与编排。一个聚合的应用服务可以建立一个应用服务类,管理聚合所有的应用服务。比如 leave 聚合有 LeaveApplicationService,person 聚合有 PersonApplicationService。

在请假微服务中,有三个聚合:leave、person 和 rule。我们来看一下应用服务是如何跨聚合来进行服务的组合和编排的。以创建请假单 createLeaveInfo 应用服务为例,分为这样三个步骤。

第一步:根据请假单定义的人员类型、请假类型和请假时长从 rule 聚合中获取请假审批规则。这一步通过 approvalRuleDomainService 类的 getLeaderMaxLevel 领域服务来实现。

第二步:根据请假审批规则,从 person 聚合中获取请假审批人。这一步通过 personDomainService 类的 findFirstApprover 领域服务来实现。

第三步:根据请假数据和从 rule 和 person 聚合获取的数据,创建请假单。这一步通过 leaveDomainService 类的 createLeave 领域服务来实现。

由于领域核心逻辑已经很好地沉淀到了领域层中,领域层的这些核心逻辑可以高度复用。应用服务只需要灵活地组合和编排这些不同聚合的领域服务,就可以很容易地适配前端业务的变化。因此应用层不会积累太多的业务逻辑代码,所以会变得很薄,代码维护起来也会容易得多。

以下是 leave 聚合的应用服务类。

public class LeaveApplicationService{

    @Autowired
    LeaveDomainService leaveDomainService;
    @Autowired
    PersonDomainService personDomainService;
    @Autowired
    ApprovalRuleDomainService approvalRuleDomainService;
    
    public void createLeaveInfo(Leave leave){
    //get approval leader max level by rule
    int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
    //find next approver
    Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
    leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
    }

    public void updateLeaveInfo(Leave leave){
    leaveDomainService.updateLeaveInfo(leave);
    }

    public void submitApproval(Leave leave){
    //find next approver
    Person approver = personDomainService.findNextApprover(leave.getApprover().getPersonId(), leave.getLeaderMaxLevel());
    leaveDomainService.submitApproval(leave, Approver.fromPerson(approver));
    }
    
    public Leave getLeaveInfo(String leaveId){
        return leaveDomainService.getLeaveInfo(leaveId);
    }

    public List<Leave> queryLeaveInfosByApplicant(String applicantId){
    return leaveDomainService.queryLeaveInfosByApplicant(applicantId);
    }

    public List<Leave> queryLeaveInfosByApprover(String approverId){
    return leaveDomainService.queryLeaveInfosByApprover(approverId);
    }
}

应用服务开发注意事项:

为了聚合解耦和微服务架构演进,应用服务在对不同聚合领域服务进行编排时,应避免不同聚合的实体对象,在不同聚合的领域服务中引用,这是因为一旦聚合拆分和重组,这些跨聚合的对象将会失效。

在 LeaveApplicationService 中,leave 实体和 Applicant 值对象分别作为参数被 rule 聚合和 person 聚合的领域服务引用,这样会增加聚合的耦合度。下面是不推荐使用的代码。

public class LeaveApplicationService{

  public void createLeaveInfo(Leave leave){
  //get approval leader max level by rule
  ApprovalRule rule = approvalRuleDomainService.getLeaveApprovalRule(leave);
  int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(rule);
  leave.setLeaderMaxLevel(leaderMaxLevel);
  //find next approver
  Approver approver = personDomainService.findFirstApprover(leave.getApplicant(), leaderMaxLevel);
  leave.setApprover(approver);
  leaveDomainService.createLeave(leave);
  }
}

那如何实现聚合的解耦呢?我们可以将跨聚合调用时的对象传值调整为参数传值。一起来看一下调整后的代码,getLeaderMaxLevel 由 leave 对象传值调整为 personType,leaveType 和 duration 参数传值。findFirstApprover 中 Applicant 值对象调整为 personId 参数传值。

public class LeaveApplicationService{

  public void createLeaveInfo(Leave leave){
  //get approval leader max level by rule
  int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
  //find next approver
  Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
  leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
  }
}

在微服务演进和聚合重组时,就不需要进行聚合解耦和代码重构了。

微服务聚合拆分时的代码演进

如果请假微服务未来需要演进为人员和请假两个微服务,我们可以基于请假 leave 和人员 person 两个聚合来进行拆分。由于两个聚合已经完全解耦,领域逻辑非常稳定,在微服务聚合代码拆分时,聚合领域层的代码基本不需要调整。调整主要集中在微服务的应用服务中。

我们以应用服务 createLeaveInfo 为例,当一个微服务拆分为两个微服务时,看看代码需要做什么样的调整?

1. 微服务拆分前

createLeaveInfo 应用服务的代码如下:

public void createLeaveInfo(Leave leave){
    
    //get approval leader max level by rule
    int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
    //find next approver
    Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
    leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
}

2. 微服务拆分后

leave 和 person 两个聚合随微服务拆分后,createLeaveInfo 应用服务中下面的代码将会变成跨微服务调用。

Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);

由于跨微服务的调用是在应用层完成的,我们只需要调整 createLeaveInfo 应用服务代码,将原来微服务内的服务调用 personDomainService.findFirstApprover 修改为跨微服务的服务调用:personFeignService. findFirstApprover。

同时新增 ApproverAssembler 组装器和 PersonResponse 的 DTO 对象,以便将 person 微服务返回的 person DTO 对象转换为 approver 值对象。

// PersonResponse为调用微服务返回结果的封装
//通过personFeignService调用Person微服务用户接口层的findFirstApprover facade接口
PersonResponse approverResponse = personFeignService. findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
Approver approver = ApproverAssembler.toDO(approverResponse);

在原来的 person 聚合中,由于 findFirstApprover 领域服务已经逐层封装为用户接口层的 Facade 接口,所以 person 微服务不需要做任何代码调整,只需将 PersonApi 的 findFirstApprover Facade 服务,发布到 API 网关即可。

如果拆分前 person 聚合的 findFirstApprover 领域服务,没有被封装为 Facade 接口,我们只需要在 person 微服务中按照以下步骤调整即可。

第一步:将 person 聚合 PersonDomainService 类中的领域服务 findFirstApprover 封装为应用服务 findFirstApprover。

@Service
public class PersonApplicationService {

  @Autowired
  PersonDomainService personDomainService;
  
  public Person findFirstApprover(String applicantId, int leaderMaxLevel) {
  return personDomainService.findFirstApprover(applicantId, leaderMaxLevel);
  }
}

第二步:将应用服务封装为 Facade 服务,并发布到 API 网关。

@RestController
@RequestMapping("/person")
@Slf4j
public class PersonApi {
  
  @Autowired
  @GetMapping("/findFirstApprover")
  public Response findFirstApprover(@RequestParam String applicantId, @RequestParam int leaderMaxLevel) {
  Person person = personApplicationService.findFirstApprover(applicantId, leaderMaxLevel);
          return Response.ok(PersonAssembler.toDTO(person));
  }
}

服务接口的提供

用户接口层是前端应用与微服务应用层的桥梁,通过 Facade 接口封装应用服务,适配前端并提供灵活的服务,完成 DO 和 DTO 相互转换。

当应用服务接收到前端请求数据时,组装器会将 DTO 转换为 DO。当应用服务向前端返回数据时,组装器会将 DO 转换为 DTO。

1. facade 接口

facade 接口可以是一个门面接口实现类,也可以是门面接口加一个门面接口实现类。项目可以根据前端的复杂度进行选择,由于请假微服务前端功能相对简单,我们就直接用一个门面接口实现类来实现就可以了。

public class LeaveApi {   
  @PostMapping
  public Response createLeaveInfo(LeaveDTO leaveDTO){
          Leave leave = LeaveAssembler.toDO(leaveDTO);
          leaveApplicationService.createLeaveInfo(leave);
          return Response.ok();
  }
  
  @PostMapping("/query/applicant/{applicantId}")
  public Response queryByApplicant(@PathVariable String applicantId){
  List<Leave> leaveList = leaveApplicationService.queryLeaveInfosByApplicant(applicantId);
  List<LeaveDTO> leaveDTOList = leaveList.stream().map(leave -> LeaveAssembler.toDTO(leave)).collect(Collectors.toList());
          return Response.ok(leaveDTOList);
  }

//其它方法
}

2. DTO 数据组装

组装类(Assembler):负责将应用服务返回的多个 DO 对象组装为前端 DTO 对象,或将前端请求的 DTO 对象转换为多个 DO 对象,供应用服务作为参数使用。组装类中不应有业务逻辑,主要负责格式转换、字段映射等。Assembler 往往与 DTO 同时存在。LeaveAssembler 完成请假 DO 和 DTO 数据相互转换。

public class LeaveAssembler {

    public static LeaveDTO toDTO(Leave leave){
        LeaveDTO dto = new LeaveDTO();
        dto.setLeaveId(leave.getId());
        dto.setLeaveType(leave.getType().toString());
        dto.setStatus(leave.getStatus().toString());
        dto.setStartTime(DateUtil.formatDateTime(leave.getStartTime()));
        dto.setEndTime(DateUtil.formatDateTime(leave.getEndTime()));
        dto.setCurrentApprovalInfoDTO(ApprovalInfoAssembler.toDTO(leave.getCurrentApprovalInfo()));
        List<ApprovalInfoDTO> historyApprovalInfoDTOList = leave.getHistoryApprovalInfos()
                .stream()
                .map(historyApprovalInfo -> ApprovalInfoAssembler.toDTO(leave.getCurrentApprovalInfo()))
                .collect(Collectors.toList());
        dto.setHistoryApprovalInfoDTOList(historyApprovalInfoDTOList);
        dto.setDuration(leave.getDuration());
        return dto;
    }

    public static Leave toDO(LeaveDTO dto){
        Leave leave = new Leave();
        leave.setId(dto.getLeaveId());
        leave.setApplicant(ApplicantAssembler.toDO(dto.getApplicantDTO()));
        leave.setApprover(ApproverAssembler.toDO(dto.getApproverDTO()));
        leave.setCurrentApprovalInfo(ApprovalInfoAssembler.toDO(dto.getCurrentApprovalInfoDTO()));
        List<ApprovalInfo> historyApprovalInfoDTOList = dto.getHistoryApprovalInfoDTOList()
                .stream()
                .map(historyApprovalInfoDTO -> ApprovalInfoAssembler.toDO(historyApprovalInfoDTO))
                .collect(Collectors.toList());
        leave.setHistoryApprovalInfos(historyApprovalInfoDTOList);
        return leave;
    }
}

DTO 类:包括 requestDTO 和 responseDTO 两部分。

DTO 应尽量根据前端展示数据的需求来定义,避免过多地暴露后端业务逻辑。尤其对于多渠道场景,可以根据渠道属性和要求,为每个渠道前端应用定义个性化的 DTO。由于请假微服务相对简单,我们可以用 leaveDTO 代码做个示例。

@Data
public class LeaveDTO {
    String leaveId;
    ApplicantDTO applicantDTO;
    ApproverDTO approverDTO;
    String leaveType;
    ApprovalInfoDTO currentApprovalInfoDTO;
    List<ApprovalInfoDTO> historyApprovalInfoDTOList;
    String startTime;
    String endTime;
    long duration;
    String status;
}

总结

聚合与聚合的解耦:当多个聚合在同一个微服务时,很多传统架构开发人员会下意识地引用其他聚合的实体和值对象,或者调用其它聚合的领域服务。因为这些聚合的代码在同一个微服务内,运行时不会有问题,开发效率似乎也更高,但这样会不自觉地增加聚合之间的耦合。在微服务架构演进时,如果聚合被分别拆分到不同的微服务中,原来微服务内的关系就会变成跨微服务的关系,原来微服务内的对象引用或服务调用将会失效。最终你还是免不了要花大量的精力去做聚合解耦。虽然前期领域建模和边界划分得很好,但可能会因为开发稍不注意,而导致解耦工作前功尽弃。

微服务内各层的解耦:微服务内有四层,在应用层和领域层组成核心业务领域的两端,有两个缓冲区或数据转换区。前端与应用层通过组装器实现 DTO 和 DO 的转换,这种适配方式可以更容易地响应前端需求的变化,隐藏核心业务逻辑的实现,保证核心业务逻辑的稳定,实现核心业务逻辑与前端应用的解耦。而领域层与基础层通过仓储和工厂模式实现 DO 和 PO 的转换,实现应用逻辑与基础资源逻辑的解耦。

总结(一):微服务设计和拆分要坚持哪些原则?

微服务的演进策略

在从单体向微服务演进时,演进策略大体分为两种:绞杀者策略和修缮者策略。

1. 绞杀者策略

绞杀者策略是一种逐步剥离业务能力,用微服务逐步替代原有单体系统的策略。它对单体系统进行领域建模,根据领域边界,在单体系统之外,将新功能和部分业务能力独立出来,建设独立的微服务。新微服务与单体系统保持松耦合关系。

随着时间的推移,大部分单体系统的功能将被独立为微服务,这样就慢慢绞杀掉了原来的单体系统。绞杀者策略类似建筑拆迁,完成部分新建筑物后,然后拆除部分旧建筑物。

2. 修缮者策略

修缮者策略是一种维持原有系统整体能力不变,逐步优化系统整体能力的策略。它是在现有系统的基础上,剥离影响整体业务的部分功能,独立为微服务,比如高性能要求的功能,代码质量不高或者版本发布频率不一致的功能等。

通过这些功能的剥离,我们就可以兼顾整体和局部,解决系统整体不协调的问题。修缮者策略类似古建筑修复,将存在问题的部分功能重建或者修复后,重新加入到原有的建筑中,保持建筑原貌和功能不变。一般人从外表感觉不到这个变化,但是建筑物质量却得到了很大的提升。

其实还有第三种策略,就是另起炉灶,顾名思义就是将原有的系统推倒重做。建设期间,原有单体系统照常运行,一般会停止开发新需求。而新系统则会组织新的项目团队,按照原有系统的功能域,重新做领域建模,开发新的微服务。在完成数据迁移后,进行新旧系统切换。

不同场景下的领域建模策略

1. 新建系统

新建系统又分为简单和复杂领域建模两种场景。

简单领域建模

简单的业务领域,一个领域就是一个小的子域。在这个小的问题域内,领域建模过程相对简单,直接采用事件风暴的方法构建领域模型就可以了。

复杂领域建模

对于复杂的业务领域,领域可能需要多级拆分后才能开始领域建模。领域拆分为子域,甚至子域还需要进一步拆分。

对于复杂领域,我们可以分三步来完成领域建模和微服务设计。

第一步,拆分子域建立领域模型

第二步,领域模型微调

第三步,微服务的设计和拆分

2. 单体遗留系统

如果我们面对的是一个单体遗留系统,只需要将部分功能独立为微服务,而其余仍为单体,整体保持不变,比如将面临性能瓶颈的模块拆分为微服务。我们只需要将这一特定功能,理解为一个简单子领域,参考简单领域建模的方式就可以了。在微服务设计中,我们还要考虑新老系统之间服务和业务的兼容,必要时可引入防腐层。

DDD 使用的误区

很多人在接触微服务后,但凡是系统,一概都想设计成微服务架构。其实有些业务场景,单体架构的开发成本会更低,开发效率更高,采用单体架构也不失为好的选择。同样,虽然 DDD 很好,但有些传统设计方法在微服务设计时依然有它的用武之地。下面我们就来聊聊 DDD 使用的几个误区。

1. 所有的领域都用 DDD

很多人在学会 DDD 后,可能会将其用在所有业务域,即全部使用 DDD 来设计。DDD 从战略设计到战术设计,是一个相对复杂的过程,首先企业内要培养 DDD 的文化,其次对团队成员的设计和技术能力要求相对比较高。在资源有限的情况下,应聚焦核心域,建议你先从富领域模型的核心域开始,而不必一下就在全业务域推开。

2. 全部采用 DDD 战术设计方法

不同的设计方法有它的适用环境,我们应选择它最擅长的场景。DDD 有很多的概念和战术设计方法,比如聚合根和值对象等。聚合根利用仓储管理聚合内实体数据之间的一致性,这种方法对于管理新建和修改数据非常有效,比如在修改订单数据时,它可以保证订单总金额与所有商品明细金额的一致,但它并不擅长较大数据量的查询处理,甚至有延迟加载进而影响效率的问题。

而传统的设计方法,可能一条简单的 SQL 语句就可以很快地解决问题。而很多贫领域模型的业务,比如数据统计和分析,DDD 很多方法可能都用不上,或用得并不顺手,而传统的方法很容易就解决了。

因此,在遵守领域边界和微服务分层等大原则下,在进行战术层面设计时,我们应该选择最适合的方法,不只是 DDD 设计方法,当然还应该包括传统的设计方法。这里要以快速、高效解决实际问题为最佳,不要为做 DDD 而做 DDD。

3. 重战术设计而轻战略设计

战略设计时构建的领域模型,是微服务设计和开发的输入,它确定了微服务的边界、聚合、代码对象以及服务等关键领域对象。领域模型边界划分得清不清晰,领域对象定义得明不明确,会决定微服务的设计和开发质量。没有领域模型的输入,基于 DDD 的微服务的设计和开发将无从谈起。因此我们不仅要重视战术设计,更要重视战略设计。

4. DDD 只适用于微服务

DDD 是在微服务出现后才真正火爆起来的,很多人会认为 DDD 只适用于微服务。在 DDD 沉默的二十多年里,其实它一直也被应用在单体应用的设计中。

具体项目实施时,要吸取 DDD 的核心设计思想和理念,结合具体的业务场景和团队技术特点,多种方法组合,灵活运用,用正确的方式解决实际问题。

微服务设计原则

第一条:要领域驱动设计,而不是数据驱动设计,也不是界面驱动设计。

微服务设计首先应建立领域模型,确定逻辑和物理边界以及领域对象后,然后才开始微服务的拆分和设计。而不是先定义数据模型和库表结构,也不是前端界面需要什么,就去调整核心领域逻辑代码。在设计时应该将外部需求从外到内逐级消化,尽量降低对核心领域层逻辑的影响。

第二条:要边界清晰的微服务,而不是泥球小单体。

微服务上线后其功能和代码也不是一成不变的。随着需求或设计变化,领域模型会迭代,微服务的代码也会分分合合。边界清晰的微服务,可快速实现微服务代码的重组。微服务内聚合之间的领域服务和数据库实体原则上应杜绝相互依赖。你可通过应用服务编排或者事件驱动,实现聚合之间的解耦,以便微服务的架构演进。

第三条:要职能清晰的分层,而不是什么都放的大箩筐。

分层架构中各层职能定位清晰,且都只能与其下方的层发生依赖,也就是说只能从外层调用内层服务,内层通过封装、组合或编排对外逐层暴露,服务粒度也由细到粗。应用层负责服务的组合和编排,不应有太多的核心业务逻辑,领域层负责核心领域业务逻辑的实现。各层应各司其职,职责边界不要混乱。在服务演进时,应尽量将可复用的能力向下层沉淀。

第四条:要做自己能 hold 住的微服务,而不是过度拆分的微服务。

微服务过度拆分必然会带来软件维护成本的上升,比如:集成成本、运维成本、监控和定位问题的成本。企业在微服务转型过程中还需要有云计算、DevOps、自动化监控等能力,而一般企业很难在短时间内提升这些能力,如果项目团队没有这些能力,将很难 hold 住这些微服务。

微服务拆分需要考虑哪些因素?

1. 基于领域模型

基于领域模型进行拆分,围绕业务领域按职责单一性、功能完整性拆分。

2. 基于业务需求变化频率

识别领域模型中的业务需求变动频繁的功能,考虑业务变更频率与相关度,将业务需求变动较高和功能相对稳定的业务进行分离。这是因为需求的经常性变动必然会导致代码的频繁修改和版本发布,这种分离可以有效降低频繁变动的敏态业务对稳态业务的影响。

3. 基于应用性能

识别领域模型中性能压力较大的功能。因为性能要求高的功能可能会拖累其它功能,在资源要求上也会有区别,为了避免对整体性能和资源的影响,我们可以把在性能方面有较高要求的功能拆分出去。

4. 基于组织架构和团队规模

除非有意识地优化组织架构,否则微服务的拆分应尽量避免带来团队和组织架构的调整,避免由于功能的重新划分,而增加大量且不必要的团队之间的沟通成本。拆分后的微服务项目团队规模保持在 10~12 人左右为宜。

5. 基于安全边界

有特殊安全要求的功能,应从领域模型中拆分独立,避免相互影响。

6. 基于技术异构等因素

领域模型中有些功能虽然在同一个业务域内,但在技术实现时可能会存在较大的差异,也就是说领域模型内部不同的功能存在技术异构的问题。由于业务场景或者技术条件的限制,有的可能用.NET,有的则是 Java,有的甚至大数据架构。对于这些存在技术异构的功能,可以考虑按照技术边界进行拆分。

总结(二):分布式架构关键设计10问

前面我们重点讲述了领域建模、微服务设计和前端设计方法,它们组合在一起就可以形成中台建设的整体解决方案。而中台大多基于分布式微服务架构,这种企业级的数字化转型有很多地方值得我们关注和思考。

我们不仅要关注企业商业模式、业务边界以及前中台的融合,还要关注数据技术体系、微服务设计、多活等多领域的设计和协同。结合实施经验和思考,今天我们就来聊聊分布式架构下的几个关键问题。

一、选择什么样的分布式数据库?

分布式架构下的数据应用场景远比集中式架构复杂,会产生很多数据相关的问题。谈到数据,首先就是要选择合适的分布式数据库。分布式数据库大多采用数据多副本的方式,实现数据访问的高性能、多活和容灾。

目前主要有三种不同的分布式数据库解决方案。它们的主要差异是数据多副本的处理方式和数据库中间件。

1. 一体化分布式数据库方案

它支持数据多副本、高可用。多采用 Paxos 协议,一次写入多数据副本,多数副本写入成功即算成功。代表产品是 OceanBase 和高斯数据库。

2. 集中式数据库 + 数据库中间件方案

它是集中式数据库与数据库中间件结合的方案,通过数据库中间件实现数据路由和全局数据管理。数据库中间件和数据库独立部署,采用数据库自身的同步机制实现主副本数据的一致性。集中式数据库主要有 MySQL 和 PostgreSQL 数据库,基于这两种数据库衍生出了很多的解决方案,比如开源数据库中间件 MyCat+MySQL 方案,TBase(基于 PostgreSQL,但做了比较大的封装和改动)等方案。

3. 集中式数据库 + 分库类库方案

它是一种轻量级的数据库中间件方案,分库类库实际上是一个基础 JAR 包,与应用软件部署在一起,实现数据路由和数据归集。它适合比较简单的读写交易场景,在强一致性和聚合分析查询方面相对较弱。典型分库基础组件有 ShardingSphere。

小结:这三种方案实施成本不一样,业务支持能力差异也比较大。一体化分布式数据库主要由互联网大厂开发,具有超强的数据处理能力,大多需要云计算底座,实施成本和技术能力要求比较高。集中式数据库 + 数据库中间件方案,实施成本和技术能力要求适中,可满足中大型企业业务要求。第三种分库类库的方案可处理简单的业务场景,成本和技能要求相对较低。在选择数据库的时候,我们要考虑自身能力、成本以及业务需要,从而选择合适的方案。

二、如何设计数据库分库主键?

与客户接触的关键业务,我建议你以客户 ID 作为分库主键。这样可以确保同一个客户的数据分布在同一个数据单元内,避免出现跨数据单元的频繁数据访问。跨数据中心的频繁服务调用或跨数据单元的查询,会对系统性能造成致命的影响。

三、数据库的数据同步和复制

在微服务架构中,数据被进一步分割。为了实现数据的整合,数据库之间批量数据同步与复制是必不可少的。数据同步与复制主要用于数据库之间的数据同步,实现业务数据迁移、数据备份、不同渠道核心业务数据向数据平台或数据中台的数据复制、以及不同主题数据的整合等。

传统的数据传输方式有 ETL 工具和定时提数程序,但数据在时效性方面存在短板。分布式架构一般采用基于数据库逻辑日志增量数据捕获(CDC)技术,它可以实现准实时的数据复制和传输,实现数据处理与应用逻辑解耦,使用起来更加简单便捷。

四、跨库关联查询如何处理?

跨库关联查询是分布式数据库的一个短板,会影响查询性能。在领域建模时,很多实体会分散到不同的微服务中,但很多时候会因为业务需求,它们之间需要关联查询。

关联查询的业务场景包括两类:第一类是基于某一维度或某一主题域的数据查询,比如基于客户全业务视图的数据查询,这种查询会跨多个业务线的微服务;第二类是表与表之间的关联查询,比如机构表与业务表的联表查询,但机构表和业务表分散在不同的微服务。

如何解决这两类关联查询呢?

对于第一类场景,由于数据分散在不同微服务里,我们无法跨多个微服务来统计这些数据。你可以建立面向主题的分布式数据库,它的数据来源于不同业务的微服务。采用数据库日志捕获技术,从各业务端微服务将数据准实时汇集到主题数据库。在数据汇集时,提前做好数据关联(如将多表数据合并为一个宽表)或者建立数据模型。面向主题数据库建设查询微服务。这样一次查询你就可以获取客户所有维度的业务数据了。你还可以根据主题或场景设计合适的分库主键,提高查询效率。

对于第二类场景,对于不在同一个数据库的表与表之间的关联查询场景,你可以采用小表广播,在业务库中增加一张冗余的代码副表。当主表数据发生变化时,你可以通过消息发布和订阅的领域事件驱动模式,异步刷新所有副表数据。这样既可以解决表与表的关联查询,还可以提高数据的查询效率。

五、如何处理高频热点数据?

常见的做法是将这些高频热点数据,从数据库加载到如 Redis 等缓存中,通过缓存提供数据访问服务。这样既可以降低数据库的压力,还可以提高数据的访问性能。

另外,对需要模糊查询的高频数据,你也可以选用 ElasticSearch 等搜索引擎。

六、前后序业务数据的处理

在微服务设计时你会经常发现,某些数据需要关联前序微服务的数据。比如:在保险业务中,投保微服务生成投保单后,保单会关联前序投保单数据等。在电商业务中,货物运输单会关联前序订单数据。由于关联的数据分散在业务的前序微服务中,你无法通过不同微服务的数据库来给它们建立数据关联。

如何解决这种前后序的实体关联呢?

一般来说,前后序的数据都跟领域事件有关。你可以通过领域事件处理机制,按需将前序数据通过领域事件实体,传输并冗余到当前的微服务数据库中。

你可以将前序数据设计为实体或者值对象,并被当前实体引用。在设计时你需要关注以下内容:如果前序数据在当前微服务只可整体修改,并且不会对它做查询和统计分析,你可以将它设计为值对象;当前序数据是多条,并且需要做查询和统计分析,你可以将它设计为实体。

这样,你可以在货物运输微服务,一次获取前序订单的清单数据和货物运输单数据,将所有数据一次反馈给前端应用,降低跨微服务的调用。如果前序数据被设计为实体,你还可以将前序数据作为查询条件,在本地微服务完成多维度的综合数据查询。只有必要时才从前序微服务,获取前序实体的明细数据。这样,既可以保证数据的完整性,还可以降低微服务的依赖,减少跨微服务调用,提升系统性能。

七、数据中台与企业级数据集成

分布式微服务架构虽然提升了应用弹性和高可用能力,但原来集中的数据会随着微服务拆分而形成很多数据孤岛,增加数据集成和企业级数据使用的难度。你可以通过数据中台来实现数据融合,解决分布式架构下的数据应用和集成问题。

你可以分三步来建设数据中台。

第一,按照统一数据标准,完成不同微服务和渠道业务数据的汇集和存储,解决数据孤岛和初级数据共享的问题。

第二,建立主题数据模型,按照不同主题和场景对数据进行加工处理,建立面向不同主题的数据视图,比如客户统一视图、代理人视图和渠道视图等。

第三,建立业务需求驱动的数据体系,支持业务和商业模式创新。

数据中台不仅限于分析场景,也适用于交易型场景。你可以建立在数据仓库和数据平台上,将数据平台化之后提供给前台业务使用,为交易场景提供支持。

八、BFF 与企业级业务编排和协同

企业级业务流程往往是多个微服务一起协作完成的,每个单一职责的微服务就像积木块,它们只完成自己特定的功能。那如何组织这些微服务,完成企业级业务编排和协同呢?

你可以在微服务和前端应用之间,增加一层 BFF 微服务(Backend for Frontends)。BFF 主要职责是处理微服务之间的服务组合和编排,微服务内的应用服务也是处理服务的组合和编排,那这二者有什么差异呢?

BFF 位于中台微服务之上,主要职责是微服务之间的服务协调;应用服务主要处理微服务内的服务组合和编排。在设计时我们应尽可能地将可复用的服务能力往下层沉淀,在实现能力复用的同时,还可以避免跨中心的服务调用。

BFF 像齿轮一样,来适配前端应用与微服务之间的步调。它通过 Façade 服务适配不同的前端,通过服务组合和编排,组织和协调微服务。BFF 微服务可根据需求和流程变化,与前端应用版本协同发布,避免中台微服务为适配前端需求的变化,而频繁地修改和发布版本,从而保证微服务核心领域逻辑的稳定。

九、分布式事务还是事件驱动机制?

分布式架构下,原来单体的内部调用,会变成分布式调用。如果一个操作涉及多个微服务的数据修改,就会产生数据一致性的问题。数据一致性有强一致性和最终一致性两种,它们实现方案不一样,实施代价也不一样。

对于实时性要求高的强一致性业务场景,你可以采用分布式事务,但分布式事务有性能代价,在设计时我们需平衡考虑业务拆分、数据一致性、性能和实现的复杂度,尽量避免分布式事务的产生。

领域事件驱动的异步方式是分布式架构常用的设计方法,它可以解决非实时场景的数据最终一致性问题。基于消息中间件的领域事件发布和订阅,可以很好地解耦微服务。通过削峰填谷,可以减轻数据库实时访问压力,提高业务吞吐量和处理能力。你还可以通过事件驱动实现读写分离,提高数据库访问性能。对最终一致性的场景,我建议你采用领域事件驱动的设计方法。

十、多中心多活的设计

分布式架构的高可用主要通过多活设计来实现,多中心多活是一个非常复杂的工程,下面我主要列出以下几个关键的设计。

  • 选择合适的分布式数据库。数据库应该支持多数据中心部署,满足数据多副本以及数据底层复制和同步技术要求,以及数据恢复的时效性要求。
  • 单元化架构设计。将若干个应用组成的业务单元作为部署的基本单位,实现同城和异地多活部署,以及跨中心弹性扩容。各单元业务功能自包含,所有业务流程都可在本单元完成;任意单元的数据在多个数据中心有副本,不会因故障而造成数据丢失;任何单元故障不影响其它同类单元的正常运行。单元化设计时我们要尽量避免跨数据中心和单元的调用。
  • 访问路由。访问路由包括接入层、应用层和数据层的路由,确保前端访问能够按照路由准确到达数据中心和业务单元,准确写入或获取业务数据所在的数据库。
  • 全局配置数据管理。实现各数据中心全局配置数据的统一管理,每个数据中心全局配置数据实时同步,保证数据的一致性。

课程链接:

http://gk.link/a/10sUJ

手语识别_使用深度学习进行手语识别_weixin_26752765的博客-CSDN博客

mikel阅读(715)

来源: 手语识别_使用深度学习进行手语识别_weixin_26752765的博客-CSDN博客

手语识别

TL;DR It is presented a dual-cam first-vision translation system using convolutional neural networks. A prototype was developed to recognize 24 gestures. The vision system is composed of a head-mounted camera and a chest-mounted camera and the machine learning model is composed of two convolutional neural networks, one for each camera.

TL; DR 提出了一种使用卷积神经网络的双摄像头第一视觉翻译系统。 开发了一个原型来识别24个手势。 视觉系统由头戴式摄像头和胸部安装式摄像头组成,机器学习模型由两个卷积神经网络组成,每个卷积神经网络一个。

介绍 (Introduction)

Sign language recognition is a problem that has been addressed in research for years. However, we are still far from finding a complete solution available in our society.

手语识别是多年来研究中已经解决的问题。 但是,我们离我们的社会还没有找到一个完整的解决方案。

Among the works developed to address this problem, the majority of them have been based on basically two approaches: contact-based systems, such as sensor gloves; or vision-based systems, using only cameras. The latter is way cheaper and the boom of deep learning makes it more appealing.

在为解决这个问题而开发的作品中,大部分都基于两种方法:基于接触的系统,例如传感器手套; 或基于视觉的系统,仅使用摄像头。 后者更便宜,而深度学习的兴起使其更具吸引力。

This post presents a prototype of a dual-cam first-person vision translation system for sign language using convolutional neural networks. The post is divided into three main parts: the system design, the dataset, and the deep learning model training and evaluation.

这篇文章介绍了使用卷积神经网络的双摄像头第一人称视觉翻译系统的原型。 该职位分为三个主要部分:系统设计,数据集以及深度学习模型的训练和评估。

视觉系统 (VISION SYSTEM)

Vision is a key factor in sign language, and every sign language is intended to be understood by one person located in front of the other, from this perspective, a gesture can be completely observable. Viewing a gesture from another perspective makes it difficult or almost impossible to be understood since every finger position and movement will not be observable.

视觉是手语中的一个关键因素,每种手语都应由位于另一人面前的一个人理解,从这个角度来看,手势可以完全观察到。 由于无法观察到每个手指的位置和移动,因此从另一个角度查看手势很难或几乎无法理解。

Trying to understand sign language from a first-vision perspective has the same limitations, some gestures will end up looking the same way. But, this ambiguity can be solved by locating more cameras in different positions. In this way, what a camera can’t see, can be perfectly observable by another camera.

试图从第一视觉的角度理解手语具有相同的局限性,某些手势最终将以相同的方式出现。 但是,可以通过在不同位置放置更多摄像机来解决这种歧义。 这样,一台摄像机看不到的东西可以被另一台摄像机完全观察到。

The vision system is composed of two cameras: a head-mounted camera and a chest-mounted camera. With these two cameras we obtain two different views of a sign, a top-view, and a bottom-view, that works together to identify signs.

视觉系统由两个摄像头组成:头戴式摄像头和胸部安装式摄像头。 使用这两个摄像头,我们可以获得标牌的两种不同视图,即顶视图和底视图,它们可以一起识别标牌。

Image for post
Sign corresponding to letter Q in the Panamanian Sign Language from a top view and a bottom view perspective (image by author)
从顶视图和底视图的角度来看,对应于巴拿马手语中字母Q的符号(作者提供的图像)

Another benefit of this design is that the user will gain autonomy. Something that is not achieved in classical approaches, in which the user is not the person with disability but a third person that needs to take out a system with a camera and focus a signer while the signer is performing a sign.

这种设计的另一个好处是用户将获得自主权。 传统方法无法实现的某些功能,在该方法中,用户不是残疾人,而是需要在签名人执行签名时需要带照相机的系统并对准签名人的第三方。

数据集 (DATASET)

To develop the first prototype of this system is was used a dataset of 24 static signs from the Panamanian Manual Alphabet.

为了开发该系统的第一个原型,使用了来自巴拿马手册字母的24个静态符号的数据集。

Image for post
SENADIS, Lengua de Señas PanameñasSENADIS,Lengua deSeñasPanameñas )

To model this problem as an image recognition problem, dynamic gestures such as letter J, Z, RR, and Ñ were discarded because of the extra complexity they add to the solution.

为了将此问题建模为图像识别问题,由于字母J,Z,RR和Ñ之类的动态手势会增加解决方案的复杂性,因此将其丢弃。

数据收集和预处理 (Data collection and preprocessing)

To collect the dataset it was asked to four users to wear the vision system and perform every gesture for 10 seconds while both cameras were recording in a 640×480 pixel resolution.

为了收集数据集,要求两个用户佩戴视觉系统并在每个相机都以640×480像素分辨率记录的同时执行每个手势10秒钟。

It was requested to the users to perform this process in three different scenarios: indoors, outdoors, and in a green background scenario. For the indoors and outdoors scenarios the users were requested to move around while performing the gestures in order to obtain images with different backgrounds, light sources, and positions. The green background scenario was intended for a data augmentation process, we’ll describe later.

要求用户在三种不同的情况下执行此过程:室内,室外和绿色背景。 对于室内和室外场景,要求用户在执行手势时四处走动,以获取具有不同背景,光源和位置的图像。 绿色背景方案是用于数据增强过程的,我们将在后面介绍。

After obtaining the videos, the frames were extracted and reduced to a 125×125 pixel resolution.

获得视频后,将帧提取并降低到125×125像素分辨率。

Image for post
From left to right: green background scenario, indoors and outdoors (image by author)
从左到右:室内和室外的绿色背景场景(作者提供)

资料扩充 (Data augmentation)

Since the preprocessing before going to the convolutional neural networks was simplified to just rescaling, the background will always get passed to the model. In this case, the model needs to be able to recognize a sign despite the different backgrounds it can have.

由于将卷积神经网络之前的预处理简化为仅缩放,因此背景将始终传递给模型。 在这种情况下,尽管模型可能具有不同的背景,但它仍需要能够识别符号。

To improve the generalization capability of the model it was artificially added more images with different backgrounds replacing the green backgrounds. This way it is obtained more data without investing too much time.

为了提高模型的泛化能力,它人为地添加了更多具有不同背景的图像来代替绿色背景。 这样,无需花费太多时间即可获取更多数据。

Image for post
Images with new backgrounds (image by author)
具有新背景的图像(作者提供的图像)

During the training, it was also added another data augmentation process consisting of performing some transformations, such as some rotations, changes in light intensity, and rescaling.

在训练期间,它还添加了另一个数据增强过程,该过程包括执行一些转换(例如某些旋转,光强度的更改和缩放)。

Image for post
Variations in rotation, light intensity and rescaling (image by author)
旋转,光强度和缩放比例的变化(作者提供的图片)

These two data augmentation process were chosen to help improve the generalization capability of the model.

选择这两个数据增强过程可帮助提高模型的泛化能力。

顶视图和底视图数据集 (Top view and bottom view datasets)

This problem was model as a multiclass classification problem with 24 classes, and the problem itself was divided into two smaller multi-class classification problems.

该问题被建模为具有24个类的多类分类问题,并且该问题本身被分为两个较小的多类分类问题。

The approach to decide which gestures would be classified whit the top view model and which ones with the bottom view model was to select all the gestures that were too similar from the bottom view perspective as gestures to be classified from the top view model and the rest of gestures were going to be classified by the bottom view model. So basically, the top view model was used to solved ambiguities.

决定将哪些手势归类为顶视图模型以及哪些手势与底视图模型相结合的方法是选择从底视图角度来看太相似的所有手势作为要从顶视图模型中归类的手势,其余选择为手势将由仰视图模型分类。 因此,基本上,顶视图模型用于解决歧义。

As a result, the dataset was divided into two parts, one for each model as shown in the following table.

结果,数据集分为两部分,每个模型一个,如下表所示。

Dataset distribution, classes per model

深度学习模型 (DEEP LEARNING MODEL)

As state-of-the-art technology, convolutional neural networks was the option chosen for facing this problem. It was trained two models: one model for the top view and one for the bottom view.

作为最先进的技术,卷积神经网络是解决这一问题的选择。 它训练了两种模型:一种用于顶视图的模型,一种用于底视图的模型。

建筑 (Architecture)

The same convolutional neural network architecture was used for both, the top view and the bottom view models, the only difference is the number of output units.

顶视图模型和底视图模型都使用了相同的卷积神经网络体系结构,唯一的区别是输出单元的数量。

The architecture of the convolutional neural networks is shown in the following figure.

下图显示了卷积神经网络的体系结构。

Convolutional Neural Network architecture
Convolutional neural network architecture
卷积神经网络架构

To improve the generalization capability of the models it was used dropout techniques between layers in the fully connected layer to improve model performance.

为了提高模型的泛化能力,在完全连接的层中的层之间使用了丢弃技术来提高模型性能。

评价 (Evaluation)

The models were evaluated in a test set with data corresponding to a normal use of the system in indoors, in other words, in the background it appears a person acting as the observer, similar to the input image in the figure above (Convolutional neural networks architecture). The results are shown below.

在测试集中对模型进行了评估,并使用了与室内正常使用系统相对应的数据,换句话说,在背景中看起来像是人在充当观察者,类似于上图中的输入图像( 卷积神经网络)建筑 )。 结果如下所示。

Results — precision and recall

Although the model learned to classify some signs, such as Q, R, H; in general, the results are kind of discouraging. It seems that the generalization capability of the models wasn’t too good. However, the model was also tested with real-time data showing the potential of the system.

尽管模型学会了对一些符号进行分类,例如Q,R,H; 一般来说,结果令人沮丧。 这些模型的泛化能力似乎不太好。 但是,该模型还通过显示系统潜力的实时数据进行了测试。

The bottom view model was tested with real-time video with a green uniform background. I wore the chest-mounted camera capturing video at 5 frames per second while I was running the bottom view model in my laptop and try to fingerspell the word fútbol (my favorite sport in Spanish). The entries for every letter were emulated by a click. The results are shown in the following video.

底视图模型已通过具有绿色统一背景的实时视频进行了测试。 当我在笔记本电脑中运行底视图模型时,我戴着胸部摄像头以每秒5帧的速度捕获视频,并尝试拼写fútbol(我最喜欢的西班牙语运动)一词。 通过单击可以模拟每个字母的条目。 结果显示在以下视频中。

Demo video of the bottom view model running with real-time video
与实时视频一起运行的底视图模型的演示视频

Note: Due to the model performance, I had to repeat it several times until I ended up with a good demo video.

注意:由于模型的性能,我不得不重复几次,直到获得了不错的演示视频。

结论 (Conclusions)

Sign language recognition is a hard problem if we consider all the possible combinations of gestures that a system of this kind needs to understand and translate. That being said, probably the best way to solve this problem is to divide it into simpler problems, and the system presented here would correspond to a possible solution to one of them.

如果我们考虑这类系统需要理解和翻译的手势的所有可能组合,则手语识别将是一个难题。 话虽这么说,解决这个问题的最好方法可能是将其分为更简单的问题,此处介绍的系统将对应于其中一个的可能解决方案。

The system didn’t perform too well but it was demonstrated that it can be built a first-person sign language translation system using only cameras and convolutional neural networks.

该系统的性能不是很好,但是证明了可以仅使用相机和卷积神经网络构建第一人称手语翻译系统。

It was observed that the model tends to confuse several signs with each other, such as U and W. But thinking a bit about it, maybe it doesn’t need to have a perfect performance since using an orthography corrector or a word predictor would increase the translation accuracy.

据观察,该模型倾向于使多个符号相互混淆,例如U和W。但是,仔细考虑一下,也许它不需要具有完美的性能,因为使用正交拼写校正器或单词预测器会增加翻译准确性。

The next step is to analyze the solution and study ways to improve the system. Some improvements could be carrying by collecting more quality data, trying more convolutional neural network architectures, or redesigning the vision system.

下一步是分析解决方案,并研究改进系统的方法。 通过收集更多质量的数据,尝试更多的卷积神经网络体系结构或重新设计视觉系统,可以带来一些改进。

结束语 (End words)

I developed this project as part of my thesis work in university and I was motivated by the feeling of working in something new. Although the results weren’t too great, I think it can be a good starting point to make a better and biggest system.

作为大学论文工作的一部分,我开发了这个项目,并且受到在新事物中工作的感觉的激励。 尽管效果不是很好,但我认为这可能是构建更好,最大的系统的良好起点。

If you are interested in this work, here is the link to my thesis (it is written in Spanish)

如果您对此工作感兴趣,请点击此处链接至我的论文 (用西班牙语撰写)

Thanks for reading!

谢谢阅读!

翻译自: https://towardsdatascience.com/sign-language-recognition-using-deep-learning-6549268c60bd

Web打印插件实现思路(C#/Winform) - 畅饮无绪 - 博客园

mikel阅读(616)

来源: Web打印插件实现思路(C#/Winform) – 畅饮无绪 – 博客园

最近,客户有个需求过来,Web端无预览打印,美其名曰:快捷打印。

当时第一反应就是找插件,拿来主义永远不过时。找了一圈发现,免费的有限制,没限制的需要收费(LODOP真的好用)。说来就是一个简单的无预览打印,收费的诸多功能都无用武之地,总的来说性价比很低,所以就打算自己琢磨着写一个算了。刚开始总是纠结在Web端去实现直接打印,打印是实现了,结果是服务端的,客户端只能一脸懵。

后来(准备妥协了),安装了一些收费的插件发现,都需要先安装一个客户端程序,灵光乍现,解决方案这不就出来了。

思路:Web端调用客户端程序,让客户端程序去实现打印。

实现:Web端通过WebSocket将Html发送给客户端,客户端(Winform)监听消息,接收处理后再打印。

客户端(Winform)打印实现方式:

1、Html转PDF,然后打印PDF(暂时需要用到三方包)

2、Html转图片,然后通过PrintDocument打印图片

3、通过WebBrowser实现打印

 

按照上面的思路再去写代码,就会简单很多。

Web端的代码就不多说了,一个简单的WebSocket通信。

客户端程序(Winform)

1、监听Web端的WebSocket消息,这里有用到三方包:Fleck,开箱即用,非常方便。

复制代码
 1         /// <summary>
 2         /// ConnAndListen
 3         /// </summary>
 4         public static void ConnAndListen()
 5         { 7             // 监听本地 45000端口
 8             var server = new WebSocketServer($"ws://127.0.0.1:45000");
 9             server.Start(socket =>
10             {
11                 socket.OnOpen = () =>
12                 {
13                     // 连接成功
14                     socket.Send("socket通讯已连接");
15                 };
16                 socket.OnClose = () =>
17                 {
18                     // 关闭连接
19                     socket.Send("socket通讯已关闭连接");
20                     socket.Close();
21                 };
22                 socket.OnMessage = message =>
23                 {
24                     // TODO...
25                 };
26             });
27         }
复制代码

2、处理Html,将Html转换为图片:

复制代码
 1         /// <summary>
 2         /// GetBitmap
 3         /// </summary>
 4         /// <returns></returns>
 5         public Bitmap GetBitmap()
 6         {
 7             WebPageBitmap Shot = new WebPageBitmap("html", "页面宽度", "页面高度");
 8             Shot.GetIt();
 9             Bitmap Pic = Shot.DrawBitmap("图片高度", "图片宽度");
10             // 设置图片分辨率
11             Pic.SetResolution(203.0F, 203.0F);
12             return Pic;
13         }
复制代码

3、打印图片

复制代码
  1     /// <summary>
  2     /// 图片打印
  3     /// </summary>
  4     public class PrintDirectClass
  5     {
  6         private PaperSize paperSize = null;
  7         // 多页打印
  8         private int printNum = 0;
  9         // 单个图片文件
 10         private string imageFile = ".....";
 11         // 多个图片文件
 12         private ArrayList imageList = new ArrayList();
 13 
 14         /// <summary>
 15         /// 打印预览
 16         /// </summary>
 17         public void PrintPreview()
 18         {
 19             // 打印机对象
 20             PrintDocument imgToPrint = new PrintDocument();
 21 
 22             #region 打印机相关设置
 23             var pageSize = imgToPrint.PrinterSettings.PaperSizes;
 24             paperSize = pageSize[pageSize.Count - 1];
 25 
 26             // 打印方向设置
 27             //imgToPrint.DefaultPageSettings.Landscape = false;
 28             // 打印纸张大小设置
 29             //imgToPrint.DefaultPageSettings.PaperSize = paperSize;
 30             // 打印分辨率设置
 31             //imgToPrint.DefaultPageSettings.PrinterResolution.Kind = PrinterResolutionKind.High;
 32             // 打印边距设置
 33             //imgToPrint.DefaultPageSettings.Margins = new Margins(40, 40, 40, 40);
 34 
 35             // 打印开始事件
 36             imgToPrint.BeginPrint += new PrintEventHandler(this.imgToPrint_BeginPrint);
 37             // 打印结束事件
 38             imgToPrint.EndPrint += new PrintEventHandler(this.imgToPrint_EndPrint);
 39             // 打印内容设置
 40             imgToPrint.PrintPage += new PrintPageEventHandler(this.imgToPrint_PrintPage);
 41             #endregion
 42 
 43             // 直接打印
 44             //imgToPrint.Print();
 45 
 46             // 打印弹框确认
 47             //PrintDialog printDialog = new PrintDialog();
 48             //printDialog.AllowSomePages = true;
 49             //printDialog.ShowHelp = true;
 50             //printDialog.Document = imgToPrint;
 51             //if (printDialog.ShowDialog() == DialogResult.OK)
 52             //{
 53             //    imgToPrint.Print();
 54             //}
 55 
 56             // 预览打印
 57             //PrintPreviewDialog pvDialog = new PrintPreviewDialog();
 58             //pvDialog.Document = imgToPrint;
 59             //pvDialog.ShowDialog();
 60         }
 61 
 62         /// <summary>
 63         /// 打印开始事件
 64         /// </summary>
 65         /// <param name="sender"></param>
 66         /// <param name="e"></param>
 67         private void imgToPrint_BeginPrint(object sender, PrintEventArgs e)
 68         {
 69             if (imageList.Count == 0)
 70                 imageList.Add(imageFile);
 71         }
 72 
 73         /// <summary>
 74         /// 打印结束事件
 75         /// </summary>
 76         /// <param name="sender"></param>
 77         /// <param name="e"></param>
 78         private void imgToPrint_EndPrint(object sender, PrintEventArgs e)
 79         {
 80 
 81         }
 82 
 83         /// <summary>
 84         /// 设置打印内容
 85         /// </summary>
 86         /// <param name="sender"></param>
 87         /// <param name="e"></param>
 88         private void imgToPrint_PrintPage(object sender, PrintPageEventArgs e)
 89         {
 90             // 图片文本质量
 91             e.Graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
 92             // 图片插值质量
 93             e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
 94             // 图片合成质量
 95             e.Graphics.CompositingQuality = CompositingQuality.HighQuality;
 96             // 图片抗锯齿
 97             e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
 98             // 设置缩放比例
 99             e.Graphics.PageScale = 0.3F;
100             using (Stream fs = new FileStream(imageList[printNum].ToString().Trim(), FileMode.Open, FileAccess.Read))
101             {
102                 System.Drawing.Image image = System.Drawing.Image.FromStream(fs);
103                 int w = image.Width;
104                 int h = image.Height;
105                 // 绘制Image
106                 e.Graphics.DrawImage(image, 40, 40, 410, 600);
107                 if (printNum < imageList.Count - 1)
108                 {
109                     printNum++;
110                     // HasMorePages为true则再次运行PrintPage事件
111                     e.HasMorePages = true;
112                     return;
113                 }
114                 e.HasMorePages = false;
115             }
116             // 设置打印内容的边距
117             //e.PageSettings.Margins = new Margins(40, 40, 40, 40);
118             // 设置是否横向打印
119             e.PageSettings.Landscape = false;
120             // 设置纸张大小
121             e.PageSettings.PaperSize = paperSize;
122             // 设置打印分辨率
123             e.PageSettings.PrinterResolution.Kind = PrinterResolutionKind.High;
124         }
125     }
复制代码

以上就是 Html转图片,然后通过PrintDocument打印图片 的实现代码了。其它两种方式大家有兴趣可以去试试。

WebBrowser 比较简单,但是对打印机控制不是特别友好(也可能是我没有发现,研究不深)

复制代码
1             WebBrowser wb = new WebBrowser();
2             // 直接打印
3             wb.Print();
4             // 确认打印
5             wb.ShowPrintDialog();
6             // 预览打印
7             wb.ShowPrintPreviewDialog();
复制代码

Html转PDF,然后打印PDF原理于Html转图片,然后通过PrintDocument打印图片一样,不同的是转PDF可能需要用到第三方的包,目前这方便的包还是比较齐全的,NUGET搜索pdf几乎全是。

需求总是来得特别突然,下次再有新特别需求时再来和大家分享。

以上就是这篇文章的全部内容了,有帮助的点个赞,有错误的欢迎大家指出来,有更好的解决方案也希望大家不吝赐教。

.Net Core with 微服务 - Ocelot 网关 - Agile.Zhou - 博客园

mikel阅读(523)

来源: .Net Core with 微服务 – Ocelot 网关 – Agile.Zhou – 博客园

上一次我们通过一张架构图(.Net Core with 微服务 – 架构图)来讲述了微服务的结构,分层等内容。从现在开始我们开始慢慢搭建一个最简单的微服务架构。这次我们先用几个简单的 web api 项目以及 ocelot 网关项目来演示下网关是如何配置,如何工作的。

Ocelot 网关

Ocelot 是使用 ASP.NET core 开发的一个 api 网关项目。它功能丰富,集成了路由、限流、缓存、聚合等功能。它使用 .net 编写,本质上就是一堆 ASP.NET core 的中间件,所以它天生对 .net 友好。这些中间件拦截外部的请求,根据路由配置转发到对应的内部服务上,再把内部的返回结果对外暴露。

搭建项目结构


新建一个解决方案,新建几个项目。

  • api_gateway API网关
  • hotel_base 酒店基本信息服务
  • member_center 会员中心服务
  • ordering 订单服务

安装 Ocelot

在API网关项目上使用nuget安装Ocelot的类库。Ocelot本质上就是一堆 asp.net Core 的 middleware。所以我们需要在USEOcelot扩展方法在注册这些中间件。

Install-Package Ocelot
        public static void Main(string[] args)
        {
            new WebHostBuilder()
              .UseKestrel()
              .UseContentRoot(Directory.GetCurrentDirectory())
              .ConfigureAppConfiguration((hostingContext, config) =>
              {
                  config
                      .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath)
                      .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", true, true)
                      .AddJsonFile("routes.json")
                      .AddEnvironmentVariables();
              })
              .ConfigureServices(s => {
                  s.AddOcelot();
              })
              .ConfigureLogging((hostingContext, logging) =>
              {
                  logging.AddConsole();
               })
              .UseIISIntegration()
              .Configure(app =>
              {
                  app.UseOcelot().Wait();
              })
              .Build()
              .Run();
        }
    
    }

在 main 函数内注册Ocelot的中间件,服务,使用AddJsonFile指定路由的配置文件。

路由

Ocelot最基本的功能就是反向代理。代理的配置通过一个json文件来配置。下面让我们来简单的演示下如何配置。
以下是通过网关代理访问酒店服务的酒店列表的配置示例。

 {
      //获取酒店列表
      "UpstreamPathTemplate": "/api/hotel",
      "UpstreamHttpMethod": [ "Get" ],
      "DownstreamPathTemplate": "/hotel",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          //hotel service
          "Host": "localhost",
          "Port": 6003
        }
      ]
}

配置主要是分为Upstream跟Downstream两部分。Upstream其实就是指代ocelot网关本身。Downstream代表真正的服务。

  • UpstreamPathTemplate 网关匹配的路径
  • UpstreamHttpMethod 网关匹配的请求方法
  • DownstreamPathTemplate 服务匹配的路径
  • DownstreamScheme 服务的Scheme,http、https
  • DownstreamHostAndPorts 服务的主机地址跟端口

上面的配置描述的意思是:把对网关的/api/hotel的GET请求转发到主机http://localhost:6003/hotel接口上。

路由参数

Ocelot的path模板可以使用{param}模式来匹配参数,然后传递到下游服务器上。

    {
      //获取单个酒店
      "UpstreamPathTemplate": "/api/hotel/{hotel_id}",
      "UpstreamHttpMethod": [ "Get" ],
      "DownstreamPathTemplate": "/hotel/{hotel_id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          //hotel service
          "Host": "localhost",
          "Port": 6003
        }
      ],
      "Key": "hotel_base_info",
    }

使用{hotel_id}匹配hotelId参数。

    {
      //获取酒店房间列表
      "UpstreamPathTemplate": "/api/hotel_rooms/{hotel_id}",
      "UpstreamHttpMethod": [ "Get" ],
      "DownstreamPathTemplate": "/room/hotel_rooms/{hotel_id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          //hotel service
          "Host": "localhost",
          "Port": 6003
        }
      ],
      "Key": "hotel_rooms"
    }

使用{hotel_id}匹配hotelId参数。

    {
      //获取查询订单
      "UpstreamPathTemplate": "/api/order/query?day={day}",
      "UpstreamHttpMethod": [ "Get" ],
      "DownstreamPathTemplate": "/order/get_orders?day={day}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          //order service
          "Host": "localhost",
          "Port": 6001
        }
      ]
    }

在QueryString上使用{day}匹配参数。

限流

Ocelot支持对请求的限流操作。

 "RateLimitOptions": {
        "EnableRateLimiting": true,
        "Period": "1s",
        "PeriodTimespan": 1,
        "Limit": 1
      }

在路由配置节点添加RateLimitOptions节点。

  • EnableRateLimiting = true 开启限流
  • Period = 1s 限流的时间区间为1s
  • PeriodTimespan = 1 限流后重置时间
  • Limit = 1 限制请求的数量

上面的配置的意思是1秒内限制一次请求,1秒后重置这个限制。

缓存

Ocelot可以对请求的响应值提供缓存服务。

//缓存5s
      "FileCacheOptions": { "TtlSeconds": 5 }

在路由配置节点上配置FileCacheOptions字段,TtlSeconds代表需要缓存的时间,单位是秒。

聚合

上一回我们讲微服务架构的时候说到“聚合服务层”,我们说这一层的主要功能是对请求进行聚合适配跟裁剪。其实ocelot已经提供了简单的api聚合功能。如果聚合的需求比较简单,那么可以使用ocelot直接实现。

简单聚合

简单聚合可以通过配置把几个请求的聚合成一个请求,一次性返回几个请求的响应。响应通过json格式被包装返回。

  "Aggregates": [
    {
      //聚合 查询酒店信息跟酒店房间列表
      "RouteKeys": [
        "hotel_base_info",
        "hotel_rooms"
      ],
      "UpstreamPathTemplate": "/api/hotel_detail/{hotel_id}"
    },
  ]

RouteKeys 代表需要聚合的请求的键值。

使用代码聚合

上面我们直接通过配置实现了api之间聚合请求。这种聚合比较简单,会把聚合的几个请求的响应值原封不动的返回回来。有的时候我们需要对返回值做一些转换或者裁剪,比如同一个api我们对移动端的响应可能需要裁剪掉部分字段。这种需求在ocelot内我们可以使用代码来完成。
这里不太推荐这种聚合方式,这会造成网关跟下游服务的强耦合关系。

这里我们演示下如何把获取酒店信息跟酒店房间列表的返回值进行裁剪,并返回一个新的响应。

    public class HotelDetailInfoForMobileAggregator : IDefinedAggregator
    {
        public async Task<DownstreamResponse> Aggregate(List<HttpContext> responses)
        {
            dynamic hotelInfo = new ExpandoObject();
            List<dynamic> rooms = new List<dynamic>();
            foreach (var context in responses)
            {
                if ((context.Items["DownstreamRoute"] as dynamic).Key == "hotel_base_info")
                {
                    var respContent = await context.Items.DownstreamResponse().Content.ReadAsStringAsync();
                    hotelInfo = JsonConvert.DeserializeObject<dynamic>(respContent);
                }
                if ((context.Items["DownstreamRoute"] as dynamic).Key == "hotel_rooms")
                {
                    var respContent = await context.Items.DownstreamResponse().Content.ReadAsStringAsync();
                    rooms = JsonConvert.DeserializeObject<List<dynamic>>(respContent);
                }
            }

            dynamic newResponse = new ExpandoObject();
            newResponse.hotel = new { 
                hotelInfo.id,
                hotelInfo.name
            };
            newResponse.rooms = rooms.Select(x => new { 
                x.id,
                x.no
            });

            var stringContent = new StringContent(JsonConvert.SerializeObject(newResponse));

            return new DownstreamResponse(
                stringContent, 
                System.Net.HttpStatusCode.OK, 
                responses.SelectMany(x => x.Items.DownstreamResponse().Headers).ToList(),
                "OK");
        }
    }

每一个聚合都需要继承IDefinedAggregator这个接口然后实现Aggregate方法。在这个方法内对每个请求的响应值进行裁剪,然后重新组合。

    {
      //聚合 查询酒店信息跟酒店房间列表 移动端 裁剪
      "RouteKeys": [
        "hotel_base_info",
        "hotel_rooms"
      ],
      "UpstreamPathTemplate": "/api/m/hotel_detail/{hotel_id}",
      "Aggregator": "HotelDetailInfoForMobileAggregator"
    }

在配置文件的Aggregates内添加一个配置节点在“Aggregator”字段上指定Aggregator的类名。

  .ConfigureServices(s => {
                  s.AddOcelot()
                  .AddTransientDefinedAggregator<HotelDetailInfoForMobileAggregator>();
              })

同时在ConfigureServices方法内配置HotelDetailInfoForMobileAggregator的依赖注入。

总结

本次我们通过几个最简单的web api项目,演示了如何使用 ocelot 网关进行反向代理,限流,聚合等常用功能。可以看到 ocelot 的配置使用还是比较简单的。因为是 .net 代码编写,所以对.net 开发者比较友好,我们可以直接使用 .net 代码来编写一些功能,比如直接使用代码来聚合请求的结果。

相关文章

NET Core with 微服务 – 什么是微服务
.Net Core with 微服务 – 架构图

演示代码

https://github.com/kklldog/myhotel_microservice

.Net Core with 微服务 - 架构图 - Agile.Zhou - 博客园

mikel阅读(566)

来源: .Net Core with 微服务 – 架构图 – Agile.Zhou – 博客园

上一次我们简单介绍了什么是微服务(.NET Core with 微服务 – 什么是微服务
)。介绍了微服务的来龙去脉,一些基础性的概念。有大佬在评论区指出说这根本不是微服务。由于本人的能力有限,大概也只能理解到这个层次。先不管它到底是不是微服务吧,既然开篇了,那就硬着头皮把这个系列写完。我想不管是对自己对看官多少还是有点帮助的。

架构图

这篇文章将从一张架构图开始说起(开局一张图,内容全靠凑🤣)。

很多介绍微服务架构的文章画的架构图比这张图复杂的多。我根据自己的理解与实践修改跟精简了一下。
上次评论区说.Net只在标题上出现了一次,那么这次,大概也只会在标题上出现一次🤣。大概从下一篇开始就会正式介绍如何使用 .net core 一步步实现一个最简微服务系统。
下面就开始对照这张架构图进行讲解吧。

基础服务层

基础服务层是一个抽象的概念。我们把提供基础业务处理能力的服务归类到这一层。我们按照模块\领域等概念把服务划分好,最后建成了一个个独立部署的服务。它们提供一些基础的服务功能,对外提供一些api接口。每个服务都有自己独立的数据库,独立的运行时。每个服务都可以根据压力进行伸缩。这一层可以说是微服务架构里最核心的一层。

比如一个酒店管理系统,我们一般可以划分成:“酒店基本信息服务”、“订单服务”、“会员服务”、“支付服务”等等基础服务,每个服务都提供一些api,比如订单服务提供查询下单等服务,支付服务提供微信支付的支付能力等等。

当然如何划分都是似情况而定的,这里只是举个例子。

聚合服务层

我们已经有了基础服务,为什么还会有聚合服务这一层呢。假设现在用户根据订单号查询订单明细的功能。这个功能可能需要涉及到订单基本信息、用户基本信息、会员信息、支付信息、房型信息等多个api。如果有前端直接调用基础服务层,那么可能要发送多次http请求。所以为了效率往往还需要有一个服务来聚合跟适配,合并成一次请求再对前端提供服务,这样对于前端来说效率相对会高一些,开发起来也简单很多。

再比如我们现在的系统往往会接入很多终端,有PC,有APP,有小程序。由于设备的不同,我们对外需要展示的内容也会有差异,我们可以在聚合层进行api的适配跟裁剪,根据不同的设备返回不同的响应。

网关

微服务网关在这个微服务架构中起着至关重要的的地位。从上面的图上可以看到,网关在架构的顶端,是流量的入口。它对每个一个请求进行监控,路由。使每一个合法的请求进入到对应的服务。

因为所有请求都会过网关,所以网关可以做一些全局的工作或者说类似AOP思想里切面的工作。比如认证、授权、限流、过滤等等。一旦网关服务崩溃往往会影响到整个微服务的工作,尽管它内部服务全部正常,但是它可能无法对外提供服务。
正因为网关所在的位置在架构中所承担的功能,所以我们需要网关组件的性能非常高,稳定性非常高。
常用的网关组件有:kong,zuul,Ocelot 等。在 .Net 领域用的比较多的是Ocelot。

微服务相关组件

很多网上的架构图都把微服务相关的这些组件写到业务服务层下面,叫做支撑服务。其实个人是不太认同的。所谓支撑的话可以说是桌子的腿,少了一条桌子就会翻了。事实上我觉得一个完整的微服务不一定非要上全了所有组件。这种都是视情况而定的,没有绝对的说法。比如说不上监控服务,微服务就不能跑了吗?显然不是的。不是说上了这些组件就叫微服务,不上就不是微服务。有了日志聚合、服务发现等等组件是为了更好的实施微服务,但不是必须的,所以我并没有把他们叫做支撑服务。

服务注册发现

在实施微服务之后,我们的调用都变成了服务间的调用。服务间调用需要知道IP、端口等信息。再没有微服务之前,我们的调用信息一般都是写死在调用方的配置文件里(当然这话不绝对,有些公司会把这些信息写到数据库等公共的地方,以方便维护)。又由于业务的复杂,每个服务可能依赖N个其他服务,如果某个服务的IP,端口等信息发生变更,那么所有依赖该服务的服务的配置文件都要去修改,这样显然太麻烦了。有些服务为了负载是有个多个实例的,而且可能是随时会调整实例的数量。如果每次调整实例数量都要去修改其他服务的配置并重启那太麻烦了。
为了解决这个问题,业界就有了服务注册发现组件。
假设我们有服务A需要调用服务B,并且有服务注册发现组件R。整个大致流程将变成3步:

  1. 服务B启动向服务R注册自己的信息
  2. 服务A从服务R拉取服务B的信息
  3. 服务A调用服务B

有了服务注册发现组件之后,当修改A服务信息的时候再也不用去修改其他相关服务了。


常用的服务注册发现组件有:Eureka,Consul 等等。

配置中心

看了上面的服务发现注册,也许你也想到了。其实配置中心跟服务发现注册解决的是同一类问题。那就是分布式系统对于修改配置这种事情实在是太麻烦。如果实例是部署在容器内的那一个个实例去修改配置简直是一场噩梦。
为了解决这个问题,就有了配置中心。配置中心把所有服务的配置都集中管理。并且提供配置热更新、权限管理、版本管理、灰度发布等高级功能。当服务启动的时候不再从本地配置文件读取配置,而是远程从配置中心读取配置。

常用配置中心组件有:Nacos 、Apollo 、AgileConfig 。
🌟🌟🌟打个广告:AgileConfig 是本人开源的一个轻量级配置中心。https://github.com/kklldog/AgileConfig 求🌟🌟🌟!

日志聚合

日志是我们写程序离不开的一个东西。在我们排查问题的时候日志就是我们的救命稻草。我们的每个服务都在不停的生产日志。但是实施微服务后,如果按照传统的写本地文件的日志方案,显然会面临跟修改配置一样麻烦的境地。不同的日志分散在各个服务器、容器内,这种情况下查日志简直是生不如死。
日志聚合组件为我们解决了这个问题。所有的服务通过接口发送日志到聚合服务,再由聚合服务进行统一存储,并且提供统一的查询、分析的能力。
日志聚合组件业界有比较重型的 ELK ,.Net 这边也有常用的Exceptionless 。

监控服务

由于微服务架构带来系统的复杂性,出了问题往往无法快速定位问题。那么这个时候就需要监控系统出场了。监控系统能够在故障发生之前防范于未然,在故障发生之后快速回溯问题,定位问题。
微服务监控一般分以下几个维度的监控:

  1. 硬件资源类监控
    硬件资源是我们实施微服务的基石。CPU、内存、储存等指标在日常生产中是需要时刻关注的,不然很容易因为资源耗尽出现故障。
  2. 应用类监控
    这一类监控围绕对应用、服务、容器的健康监控,对接口的调用链、性能进行监控。这里着重提一下调用链监控。在我们实施微服务后,由于复杂的业务逻辑,服务之间的调用会像蜘蛛网一样复杂。有了调用链监控后服务之间的调用可以用图像的方式展示出来,每个请求的性能,响应等都会记录下来。对于提前防范问题,以及排查问题有非常大的意义。
    这一类监控跟我们研发同学比较近,常用的组件有重量级的 SkyWalking APM ,轻量级的有 HttpReports 。
  3. 运营类监控
    这一类监控主要跟业务相关。运营的同学比较关注。比如监控每一天的流水,每天注册的会员人数,发放的优惠券等等。

微服务的组件还有很多,这里也就介绍几个常用的组件,其他不再多说。还是那句话这些组件是为了更好的实施微服务,用不用看情况。当你实施微服务的过程中发现了痛点,自然就会去找对应的方案、组件去解决它。

总结

以上通过一张微服务架构图,大概讲解了微服务架构常用的分层方案,每一层的意义,为什么要这么分。介绍了常用的微服务组件的作用功能等等。至此我们对微服务架构应该有一个比较全面的了解。但是记住一句话,架构没有固定的模板没有定式,你可以根据自己的情况来划分层次,自己的情况来决定使用哪些组件。
那么从下一篇开始我们就要正式开始使用.Net Core来一步步实现一个最简微服务项目啦,敬请期待。

.NET Core with 微服务 - 什么是微服务 - Agile.Zhou - 博客园

mikel阅读(599)

来源: .NET Core with 微服务 – 什么是微服务 – Agile.Zhou – 博客园

微服务是这几年最流行的架构,说起架构不提微服务都不好意思跟人家打招呼。最近想要再梳理一下关于微服务的知识,并且结合本人的一些实践经验来做一些总结与分享。前面会分享一些概念性的东西,后面也会使用.net来实践,一步步完成一个简单的微服务架构的小demo。

什么是微服务

其实微服务并没有统一的标准定义。微服务是一种软件架构的风格。它首先由大神martin fowler提出,2014年3月25号在他的博客上发表了一篇博客来描述了这种微服务的架构。原文地址(https://www.martinfowler.com/articles/microservices.html)。
相对于传统的单体(Monolithic)架构应用,微服务把单个进程的应用拆分为多个单独部署的服务。每个服务对外提供一些接口来进行服务间的通讯或者对第三方提供功能。每个独立的服务甚至使用自己独立的存储技术,独立的语言技术栈。说到底微服务架构还是贯彻了软件开发中:单一职责、分而治之、解耦等基本理念,只是它把这种理念从类、类库级别提升到了进程级别。

图片引用自https://www.redhat.com/zh/topics/microservices/what-are-microservices

微服务与SOA

微服务架构看起来跟SOA架构非常相似。事实上微服务架构就是SOA的一种现代化的实现方式,一次进化。虽然不能在两者之间画等号,但是他们的思想确实是一致的。

图片引用自https://zato.io/docs/intro/esb-soa-cn.html

微服务与SOA之间的区别网上有很多,在此不再大段的复制黏贴网上现成的文字,简单谈谈自己的一些理解。
首先SOA大多数情况下是作用于企业内部,它通过ESB等总线技术把企业内的服务(或者称之为应用)串联起来。SOA虽然是在解耦、去中心化,但是它通常跟某种ESB技术强耦合起来,以至于ESB会成为那个最大的中心。微服务的作用范围是应用而不是庞大的企业。微服务不在依赖ESB等总线技术,服务间的通讯通过无状态、轻量级的接口实现。协议采用http、json等通用协议无关开发语言,谁都可以调用。所以相比SOA有更好的去中心化意义。

优点

上面说了这么多关于微服务的知识,那么实施微服务到底为我们带来了哪些好处?网上有很多复制黏贴的话其实我不太苟同,比如:部署简单,如果没有强大的运维团队微服务的部署显然是比传统单体应用部署难度更大了。 比如快速开发快速迭代:事实上单体应用也不用等到完全开发完才能上线。下面说下我认为的微服务的几个优点:

  1. 技术异构
    采用微服务架构可以很方便的在每个服务中使用不同的技术栈。每个团队可以根据自身的业务情况,人员情况安排使用最合适的技术。如果我们服务业务是AI那就考虑pyhton,如果我们的人员比较熟悉JavaScript,那么可以选nodejs。当然技术的多样性也是要权衡的,不能说每个服务都撸一种语言每个都试验一把,这样未必就是好事情了。
  2. 扩展性
    当我们的业务做的越来越大,流量越来越大的时候,需要对计算资源进行扩展。相对于单体应用,微服务可以更好的进行扩展。传统单体应用水平扩展的时候可能需要把整个应用都扩展多个实例。事实上我们的业务越来越大的时候,往往只是某个模块压力巨大。而采用微服务架构我们只需要对某压力大的服务进行水平扩展。配合现在的容器化技术能够更好的利用技术资源。
  3. 可靠性
    由于每个服务都是独立部署,当某个服务故障的时候通常不会导致其它服务同时故障,只是丧失了部分能力。再配合服务降级、熔断等技术可以比单体应用提供更好的可靠性。
  4. 强模块化边界
    这个概念在网上很少出现。我是在B站上杨波老师的一个关于微服务视频上看到的,对这个观点比较认同。模块化是我们软件开发常用的模式。原来我们按类、按类库进行模块化,现在通过微服务架构直接把模块服务化了,并且能独立部署运行。其它模块不在需要直接引用相关类库就可以使用它。而且实施微服务架构后会强制团队进行应用的模块化,对模块的边界进行明确的划分。当然模块的边界划分是个技术活,如果划分的不够好那就是场灾难。

缺点

这个世界上的事情都是具有两面性的。微服务除了有其优点,自然也有缺点。我们在做架构的时候要尽量处理好这些缺点,避免踩到巨坑。下面谈谈我对微服务缺点的一些看法。

  1. 运维难度增加
    本来只需要部署一个IIS站点或者Tomcat服务、维护一个数据库,现在变成了需要部署N个不同的服务,N个不同类型的数据库。不同的服务甚至可能分散在不同的服务器上。要使这些服务正常的工作,正常的通讯,还要对其进行监控显然比单体架构时代对运维的考验提高了一个维度。没有强大的运维团队、自动化的运维工具的话微服务实施起来出故障的概率显然会大大增加。
  2. 分布式的挑战
    微服务架构天然就是分布式的。但是分布式系统会带来很多单体架构没有的问题。比如分布式事务,数据一致性问题。本来在进程内一个锁或者在数据库开一个事务就能解决的事情,现在不得不借助分布式锁、分布式事务、数据最终一致性来处理。这些问题对开发人员写代码的时候也是很大的挑战。除了一致性的问题,微服务架构中服务之间的通信也会有很高的成本。本来进程内的方法调用变成了跨进程、跨服务的通讯。我们知道网络是不可靠的,出现故障的概率远远超过进程内调用。
  3. 调试,测试难度增加
    由于服务之间互相依赖,在做集成测试或者调试的时候需要把所有依赖的服务、数据库等全部都跑起来。出现问题很难一次性定位到确切位置。由于服务器之间网络带宽的原因多次测试结果可能会有变动,测试的结果不稳定。
  4. 沟通成本提高
    在采用微服务架构开发之后,团队的组织架构都可能跟着变动,团队免不了被拆分成多个小团队甚至不同部门。在公司呆过的都知道,跨团队跨部门之间沟通的成本有多大。本来一天就能修复的bug,很可能变成一周。
  5. 模块划分困难
    我们前面说微服务把每个模块进行独立部署,采用独立的数据库。这么轻描淡写的一句话,事实上实施起来并没有那么容易。如果模块划分的不好,那么会出现非常多的跨库查询,非常多的跨库事务。本来单体架构上很简单的事情变得无比复杂。本来一句Transaction就你搞定的事情,现在可能需要先团队之间进行沟通,然后互相开接口,再使用分布式事务来完成。模块划分的一个好的方案就是采用DDD的思想进划分,但是事实上能把DDD玩好落地也不是一件容易的事。

微服务不是银弹

微服务这几年火热的很。很多公司、架构师言架构必微服务,好像微服务是包治百病的良药。不管项目大小,项目周期,人员配置,技术实力,一股脑的上微服务。见过3,5人小团队一个月就能开发上线的说要进行微服务改造。这么做怕不是微服务真的香,而是为了充实自己的简历。
微服务不是银弹,正如上面所述,微服务在享受它带来的好处的时候也是有巨大的成本开销的。它会带来组织架构上的变动,人员的变动。它大大的提高了系统的复杂性,给运维、开发、测试、调试都带来巨大的挑战。
在采用微服务架构之前最好先思考一下,真的需要微服务吗?权衡一下微服务带来的利弊再下决定。以我个人的经验来看,市面上绝大多数系统更适合单体架构,或者说没必要一上来就采用微服务架构。真正好的架构是在满足当前需求的前提下快速稳定的上线,并对后面的扩展、改造留好余地,以应对后面业务发展带来的需求进行架构的升级改造。

总结

通过以上这些铺垫我们讲了微服务的概念、微服务有哪些优点、微服务又有哪些缺点给我们带来了哪些方面的挑战。以上是我个人的一些浅薄的理解有可能有遗漏或者有错误,大家可以一起讨论一下。
下一篇将会对微服务架构、微服务使用的常用组件进行详细介绍,敬请期待。
谢谢阅读,帮忙点赞。