title: “客户管理系统微服务化实战-PartI” lang: cn ref: crm-part-I permalink: /cn/docs/crm-part-I/ excerpt: “客户管理系统微服务化实战-PartI” last_modified_at: 2018-08-28T10:00:00+08:00 author: Yangyong Zheng tags: [CRM,Scaffold,DDD,JPA,JWT,Edge] redirect_from:
在今年的LC3大会上,ServiceComb展台所展示的demo视频“30分钟开发雏形CRM应用”
引起了参会者的广泛关注,大家纷纷对其背后的技术表现出浓厚的兴趣。本文将从房地产企业的客户管理管理场景入手,使用领域驱动设计,深入技术细节,详解如何快速开发落地一个微服务化的客户管理系统。
打开浏览器,输入地址http://start.servicecomb.io/
打开SERVICECOMB SPRING INITIALIZR,修改Project Metadata中的Group
,Artifact
和ServiceComb Parameters中的ServiceCenter Address
,Governance
等,点击Generate Project,解压生成下载的demo.zip。
运行它也很简单,使用IDE打开项目,DEBUG -> Application.java,或在命令行:
稍等微服务启动就绪,打开浏览器输入http://localhost:9080/hello
验证一下:
是不是非常轻松呢?
在建筑领域,脚手架是施工现场为方便工人操作并解决垂直和水平运输而搭设的各种支架以及平台。
在软件开发领域,它引申为预提供一些基础框架代码加速开发过程,避免从零开始构建项目。用户只需要依据需求场景选择合适的脚手架,然后填充定制的业务逻辑即可,不必再去处理一些基础功能,例如数据库连接、日志实现、RPC传输等。
微服务框架一般都会提供脚手架功能,例如Spring,提供了SPRING INITIALIZR;ServiceComb基于SPRING INITIALIZR,提供了更具优势的特性:
那么什么叫一个完整的微服务系统呢?我们可以拿一个具体的场景做例子,会更有感觉:
您经营着一家房地产开发商,销售房产,迫切需要一套销售系统,考虑到微服务的优势,您决定使用微服务的方式构建系统;主要的业务流程也非常简单:用户前来购买购买产品(房产),首先需要登记用户信息,并缴纳一定数量的定金,待交易当日,挑选心仪的产品(房产),支付尾款,完成交易。
微服务系统的设计方面,领域驱动设计(Domain-Driven Design,DDD)是一个从业务出发的好选择,它由Eric Evans提出,是一种全新的系统设计和建模方法,这里的模型指的就是领域模型(Domain Model)。领域模型通过聚合(Aggregate)组织在一起,聚合间有明显的业务边界,这些边界将领域划分为一个个限界上下文(Bounded Context)。Martin Fowler对它们都有详细的解读。
理论概念都搞清楚了,那么怎么来找模型和聚合呢?一个非常流行的方法就是Event Storming,它是由Alberto Brandolini发明,经历了DDD社区和很多团队的实践,也是一种非常有参与感的团队活动:
上图就是我们对地产CRM这个场景使用Event Storming探索的结果,现在我们能够将限界上下文清晰的梳理出来:
提示:Event Storming是一项非常有创造性的活动,也是一个持续讨论和反复改进的过程,不同的团队关注的核心域(Core Domain)不同,得到的最终结果也会有差异。我们的目的是为了演示完整的微服务系统构建的过程,并不涉及商业核心竞争力方面的探讨,因此没有Core Domain和Sub Domain之类的偏重。
当我们完成所有的限界上下文的识别后,可以直接将它们落地为微服务:
由于完成一笔交易是一个复杂的流程,与这三个微服务都有关联,因此我们引入了一个复合服务——交易服务:
定金支付
Step1:通过用户服务验证用户身份;
Step2:通过支付服务请求银行扣款,增加定金账号内的定金;
购房交易
Step1:通过用户服务验证用户身份;
Step2:通过资源服务确定用户希望购买的资源(房产)尚未售出;
Step3:通过资源服务标记目标资源(房产)已售出;
Step4:通过支付服务请求扣减定金账号内的定金,以及银行扣剩下的尾款;
最后两个步骤需要保证事务一致性,其中Step4包含两个扣款操作。
之后,我们引入Edge服务提供统一入口:
提示:Edge服务需要依赖服务注册-发现机制,因此同时导入了ServiceCenter。
最后还需要提供UI:
至此,DDD设计地产CRM的工作就结束了。
用户微服务是所有系统中不可或缺的部分,它承载了认证和授权等核心功能——无论是登录一个网站、还是打开一个APP,当涉及到需要身份识别后才能够执行的操作,都需要用户微服务把关。例如观看视频网站上的视频,匿名用户会插播广告,如果希望屏蔽广告,则需要登录并购买VIP会员,登录即是身份认证的过程,而VIP屏蔽广告即是授权的过程。
认证不仅仅是一次性验证用户名和密码的过程,还需要能反复使用认证的结果,确保后继所有操作都是合法的,这就涉及到“有状态”,但HTTP是一个无状态协议,如何能够将登录成功后的认证信息与后继的请求关联起来呢?
我们非常熟悉的做法是使用Session或Cookie:
那么,如何兼顾这两方面的需求呢?Token就是一个比较好的解决方案。
Token中文翻译为令牌,它将登录认证后的信息签名后返回,服务端不保存,客户端请求的时候将认证的完整信息附带上提供给服务端验签,签名可以保证信息不被篡改。了解了了解Token的原理,自然要关注Token的格式,JWT就是这样一个基于JSON的开放标准RFC-7519。
简而言之JWT规范由三部分构成:
{ "typ": "JWT", "alg": "HS256" }
{ "sub": "1234567890", "name": "YangYong Zheng", "iat": 1516239022 }
提示:JWT IO提供了在线编码和解码工具。
授权的本意是指将完成某项工作所必须的权力授给下属人员,在软件系统中往往引申为使人或角色具备访问特定资源或更改行为的许可。例如之前提到的VIP屏蔽广告,即是视频网站允许播放终端在特定的帐号登录后跳过广告播放环节(行为)的许可。
授权系统比较常见的做法有ACL和RBAC:
由于微服务系统的权限控制主要是接口访问控制上,并且多采用用户组方式组织用户,因此RBAC是比较流行的做法。
使用SERVICECOMB SPRING INITIALIZR创建用户微服务,创建完毕后使用IDEA或Eclipse打开项目,我们删掉HelloImpl和HelloConsumer,之后添加自己的实现。
用户微服务需要持久化用户信息,我们使用MySQL数据库,ORM使用Spring Data JPA:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
@Entity @Table(name = "T_User") public class UserEntity { @Id private String name; private String password; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public UserEntity() { } public UserEntity(String name, String password) { this.name = name; this.password = password; } }
在CodeFist模式下,Spring Data JPA会在数据库中自动创建T_User表与此实体映射。
我们继承JPA的PagingAndSortingRepository来实现ORM操作
@Repository public interface UserRepository extends PagingAndSortingRepository<UserEntity, Long> { UserEntity findByName(String name); }
在项目的resources
目录下新增application.properties
文件,写入数据库连接信息:
spring.datasource.url=jdbc:mysql://localhost:3306/user_db?useSSL=false spring.datasource.username=root spring.datasource.password=pwd spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
提示:关于Spring Data JPA的更多资料请参见这篇文档,为了能够简化依赖的引入我们实际上使用的是Spring Boot JPA Starter,详细的例子请参见这篇文档。
public interface TokenStore { String generate(String userName); boolean validate(String token); }
generate用于生成Token,validate用于验证Token是否正确。
我们使用jjwt提供的JWT实现,创建JwtTokenStore类,继承TokenStore接口,并重写方法:
@Component @Component public class JwtTokenStore implements TokenStore { private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenStore.class); private final String secretKey; private final int secondsToExpire; public JwtTokenStore() { this.secretKey = "someSecretKeyForAuthentication"; this.secondsToExpire = 60 * 60 * 24; } public JwtTokenStore(String secretKey, int secondsToExpire) { this.secretKey = secretKey; this.secondsToExpire = secondsToExpire; } @Override public String generate(String userName) { return Jwts.builder().setSubject(userName) .setExpiration(Date.from(ZonedDateTime.now().plusSeconds(secondsToExpire).toInstant())) .signWith(HS512, secretKey).compact(); } @Override public boolean validate(String token) { try { return StringUtils.isNotEmpty(Jwts.parser() .setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject()); } catch (JwtException | IllegalArgumentException e) { LOGGER.info("validateToken token : " + token + " failed", e); } return false; } }
public interface UserService { ResponseEntity<Boolean> logon(UserDTO user); ResponseEntity<Boolean> login(UserDTO user); }
logon用于新用户注册,login用于用户登录验证,UserDTO用于参数传递:
public class UserDTO { private String name; private String password; public String getName() { return name; } public String getPassword() { return password; } public UserDTO() { } public UserDTO(String name, String password) { this.name = name; this.password = password; } }
创建UserServiceImpl,继承UserService
接口:
@RestSchema(schemaId = "user") @RequestMapping(path = "/") public class UserServiceImpl implements UserService { private final UserRepository repository; private final TokenStore tokenStore; @Autowired public UserServiceImpl(UserRepository repository, TokenStore tokenStore) { this.repository = repository; this.tokenStore = tokenStore; } @Override @PostMapping(path = "logon") public ResponseEntity<Boolean> logon(@RequestBody UserDTO user) { if (validateUser(user)) { UserEntity dbUser = repository.findByName(user.getName()); if (dbUser == null) { UserEntity entity = new UserEntity(user.getName(), user.getPassword()); repository.save(entity); return new ResponseEntity<>(true, HttpStatus.OK); } throw new InvocationException(BAD_REQUEST, "user name had exist"); } throw new InvocationException(BAD_REQUEST, "incorrect user"); } @Override @PostMapping(path = "login") public ResponseEntity<Boolean> login(@RequestBody UserDTO user) { if (validateUser(user)) { UserEntity dbUser = repository.findByName(user.getName()); if (dbUser != null) { if (dbUser.getPassword().equals(user.getPassword())) { String token = tokenStore.generate(user.getName()); HttpHeaders headers = generateAuthenticationHeaders(token); //add authentication header return new ResponseEntity<>(true, headers, HttpStatus.OK); } throw new InvocationException(BAD_REQUEST, "wrong password"); } throw new InvocationException(BAD_REQUEST, "user name not exist"); } throw new InvocationException(BAD_REQUEST, "incorrect user"); } private boolean validateUser(UserDTO user) { return user != null && StringUtils.isNotEmpty(user.getName()) && StringUtils.isNotEmpty(user.getPassword()); } private HttpHeaders generateAuthenticationHeaders(String token) { HttpHeaders headers = new HttpHeaders(); headers.add(AUTHORIZATION, token); return headers; } }
登录成功后,会从TokenStore生成Token,并将其写入Key为AUTHORIZATION
的Header。
由于我们允许任何用户注册和登录,所以目前还没有授权的需求,经过上面四步,具有基本注册和登录功能的用户微服务就构建好了。
启动用户微服务,我们先注册一个账号:
显示注册成功,现在我们使用这个账号登录:
返回登录成功,Response中已经包含了AUTHORIZATION
Header,后继的所有请求都需要使用这个Token值进行合法认证。
至此,实现客户关系管理系统的用户服务工作就结束了,现在我们会将目光转移到Edge服务,通过Edge服务作为微服务调用的统一入口,在它之上构建统一认证,应对海量级调用的挑战。
边缘服务也是一个微服务,微服务化系统通常使用边缘服务(Edge Service)作为所有其它微服务的统一入口,因此它也常常会被称为API Gateway,使用边缘服务的好处有如下几点:
我们先来看不使用边缘服务,UI直接调用用户服务的场景:
可以看出这种调用方式,UI缺乏一定的灵活性,体现在:
我们再看引入边缘服务后,UI如何通过边缘服务调用用户服务:
Edge服务将在9090
端口上接受http rest调用,我们设计了下面的转发规则:
http://{edge-host-name}:9090/{ServiceComb微服务Name}/{服务路径&参数}
用户微服务名(service_description.name
)是user-service
,因此login调用URL:cse://user-service/login可以通过:http://{edge-host-name}:9090/user-service/login 访问。
如此一来,微服务名成为了路径的一部分,http协议的hostname
和port
将固定指向Edge服务保持不变,灵活性大大增加了。
到此我们还可以再做一点点改进,引入一个自定义配置edge.routing-short-path.{简称}
,映射微服务名:
edge: routing-short-path: user: user-service
上面的配置代表:http://{edge-host-name}:9090/user/login 等效于:http://{edge-host-name}:9090/user-service/login ,如此一来:
<dependency> <groupId>org.apache.servicecomb</groupId> <artifactId>edge-core</artifactId> </dependency>
Edge服务的核心就是调度器Dispatcher,ServiceComb Edge Core中的Dispatcher基于高性能的Vertx Reactive,轻松应对百万量级API请求的挑战;只需要继承AbstractEdgeDispatcher抽象类,添加对应的逻辑即可:
public class EdgeDispatcher extends AbstractEdgeDispatcher { private static final Logger LOGGER = LoggerFactory.getLogger(EdgeDispatcher.class); //此Dispatcher的优先级,Order级越小,路由策略优先级越高 public int getOrder() { return 10000; } //初始化Dispatcher的路由策略 public void init(Router router) { ///捕获 {ServiceComb微服务Name}/{服务路径&参数} 的URL String regex = "/([^\\\\/]+)/(.*)"; router.routeWithRegex(regex).handler(CookieHandler.create()); router.routeWithRegex(regex).handler(createBodyHandler()); router.routeWithRegex(regex).failureHandler(this::onFailure).handler(this::onRequest); } //处理请求,请注意 private void onRequest(RoutingContext context) { Map<String, String> pathParams = context.pathParams(); //从匹配的param0拿到{ServiceComb微服务Name} final String service = pathParams.get("param0"); //从匹配的param1拿到{服务路径&参数} String path = "/" + pathParams.get("param1"); //还记得我们之前说的做出一点点改进吗?引入一个自定义配置edge.routing-short-path.{简称},映射微服务名;如果简称没有配置,那么就认为直接是微服务的名 final String serviceName = DynamicPropertyFactory.getInstance() .getStringProperty("edge.routing-short-path." + service, service).get(); //创建一个Edge转发 EdgeInvocation edgeInvocation = new EdgeInvocation(); //允许接受任意版本的微服务实例作为Provider,未来我们会使用此(设置版本)能力实现灰度发布 edgeInvocation.setVersionRule(DefinitionConst.VERSION_RULE_ALL); edgeInvocation.init(serviceName, context, path, httpServerFilters); edgeInvocation.edgeInvoke(); } }
ServiceComb Edge使用SPI(Service Provider Interface)的方式加载已经编写好的调度器Dispatcher,在resources目录下创建META-INF.services/org.apache.servicecomb.transport.rest.vertx.VertxHttpDispatcher
配置文件,写入上一步EdgeDispatcher的类全名:
{EdgeDispatcher的包名}.EdgeDispatcher
边缘服务本身也是一个微服务,同样需要配置microservice.yaml:
APPLICATION_ID: scaffold service_description: name: edge-service version: 0.0.1 servicecomb: service: registry: #配置ServiceCenter使得Edge能够发现其他微服务 address: http://127.0.0.1:30100 #配置Rest Endpoint rest: address: 0.0.0.0:9090 #自定义的简称机制配置(这是我们自行扩展实现的) edge: routing-short-path: user: user-service
提示:
- 除了配置Rest Endpoint,我们也支持配置Highway Endpoint,但Highway Endpoint只支持ServiceComb开发的微服务调用;
- microservice.yaml中没有配置Handler,Edge支持所有Consumer端Handler,不支持Producer端Handler,调用链原理如下:
启动用户微服务和Edge服务,使用Postman注册一个用户:
成功,现在我们使用新注册的用户名ldg
登录:
同样成功,并在Response中已经包含了正确的AUTHORIZATION
Header。
ServiceComb Java Chassis也支持集成Netflix Zuul作为网关服务,我们做了一次性能比较,使用ServiceComb Edge作为网关吞吐能力大幅优于Netflix Zuul,性能测试项目源代码在这里。
正如前面提到的,统一认证的目的是在Edge入口处进行访问认证,避免需要在所有的微服务中都承载重复的认证机制,因此:
AuthenticationService
,放在用户服务中;功能 | 描述 |
---|---|
login | 登录验证,通过后为用户生成Token |
logon | 新用户注册 |
除此之外其他业务请求都需要做Token认证;
HttpServerFilter
扩展这个能力:统一认证流程时序图为:
public interface AuthenticationService { String validate(String token); }
@RestSchema(schemaId = "authentication") @RequestMapping(path = "/") public class AuthenticationServiceImpl implements AuthenticationService { private final TokenStore tokenStore; @Autowired public AuthenticationServiceImpl(TokenStore tokenStore) { this.tokenStore = tokenStore; } @Override @GetMapping(path = "validate") public String validate(String token) { String userName = tokenStore.validate(token); if (userName == null) { throw new InvocationException(BAD_REQUEST, "incorrect token"); } return userName; } }
public class AuthenticationFilter implements HttpServerFilter { private final RestTemplate template = RestTemplateBuilder.create(); private static final String USER_SERVICE_NAME = "user-service"; public static final String EDGE_AUTHENTICATION_NAME = "edge-authentication-name"; private static final Set<String> NOT_REQUIRED_VERIFICATION_USER_SERVICE_METHODS = new HashSet<>( Arrays.asList("login", "logon", "validate")); @Override public int getOrder() { return 0; } @Override public Response afterReceiveRequest(Invocation invocation, HttpServletRequestEx httpServletRequestEx) { if (isInvocationNeedValidate(invocation.getMicroserviceName(), invocation.getOperationName())) { String token = httpServletRequestEx.getHeader(AUTHORIZATION); if (StringUtils.isNotEmpty(token)) { String userName = template .getForObject("cse://" + USER_SERVICE_NAME + "/validate?token={token}", String.class, token); if (StringUtils.isNotEmpty(userName)) { //Add header invocation.getContext().put(EDGE_AUTHENTICATION_NAME, userName); } else { return Response .failResp(new InvocationException(Status.UNAUTHORIZED, "authentication failed, invalid token")); } } else { return Response.failResp( new InvocationException(Status.UNAUTHORIZED, "authentication failed, missing AUTHORIZATION header")); } } return null; } private boolean isInvocationNeedValidate(String serviceName, String operationPath) { if (USER_SERVICE_NAME.equals(serviceName)) { for (String method : NOT_REQUIRED_VERIFICATION_USER_SERVICE_METHODS) { if (operationPath.startsWith(method)) { return false; } } } return true; } }
别忘了通过SPI机制加载它,在resources\META-INF\services
目录中创建org.apache.servicecomb.common.rest.filter.HttpServerFilter
文件:
org.apache.servicecomb.scaffold.edge.filter.AuthenticationFilter
现有的login
和logon
都无需认证,因此我们在用户微服务中增加需要认证的修改密码的功能用于验证统一认证。
public interface UserService { ResponseEntity<Boolean> logon(UserDTO user); ResponseEntity<Boolean> login(UserDTO user); //需要认证的修改密码功能 ResponseEntity<Boolean> changePassword(UserUpdateDTO userUpdate); }
@Override @PostMapping(path = "changePassword") public ResponseEntity<Boolean> changePassword(@RequestBody UserUpdateDTO userUpdate) { if (validateUserUpdate(userUpdate)) { UserEntity dbUser = repository.findByName(userUpdate.getName()); if (dbUser != null) { if (dbUser.getPassword().equals(userUpdate.getOldPassword())) { dbUser.setPassword(userUpdate.getNewPassword()); repository.save(dbUser); return new ResponseEntity<>(true, HttpStatus.OK); } throw new InvocationException(BAD_REQUEST, "wrong password"); } throw new InvocationException(BAD_REQUEST, "user name not exist"); } throw new InvocationException(BAD_REQUEST, "incorrect user"); }
在Edge服务的启动日志中能够找到:
2018-07-13 14:38:48,756 [INFO] 1. org.apache.servicecomb.scaffold.edge.filter.AuthenticationFilter. org.apache.servicecomb.foundation.common.utils.SPIServiceUtils.loadSortedService(SPIServiceUtils.java:79)
使用zhengyangyong登录:
拿到的Token值为:eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ6aGVuZ3lhbmd5b25nIiwiZXhwIjoxNTMwNjA4OTczfQ.90teWUNbypPZvds_SD7Kus_y7wLc4b6VzC_aIVg8sLItKxwQ0g4V9BDU665PlqQY5KM-mnk8y0R6ENL1T8YVFg
返回的失败信息是:authentication failed, missing AUTHORIZATION header
返回的失败信息是:authentication failed : InvocationException: code=400;msg=CommonExceptionData [message=incorrect token]
修改密码成功。
这里可能有疑问,使用zhengyangyong登录后,是可以通过这个Token修改其他用户例如lidagang的密码的,这是因为我们目前构建的validate仅检查Token的有效性,而不做权限检查,基于RBAC的角色权限管理系统将会在未来构建。
提示:
本文详细介绍了如何使用http://start.servicecomb.io脚手架快速构建微服务项目、使用领域驱动设计(Domain-Driven Design,DDD)设计地产CRM系统、使用Edge Service构建统一认证边缘服务等内容。至此,一个地产客户关系管理系统的骨架已经初步搭建起来,剩下的模块,我们将在接下来的文章里详细介绍。