技术架构:Springboot+SpringMVC+SpringCloud+MyBatis+MybatisPlus
负责:
管理员端的登录实现,以及网关校验jwt;
频道模块的增删改查功能实现及测试;
项目的通用异常处理;
app端用户认证列表查询与认证后审核;
在user微服务远程调用自媒体接口和作者接口时实现事务控制;
图片上传模块编写及测试。
技术亮点:
1.搭建网关微服务,实现全局过滤器实现jwt校验;
2.微服务开启远程调用,使用feign调用接口;
3.基于Seata实现分布式事务;
4.使用FastDFS完成文件上传。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jJURXuT0-1619110083800)(项目经验.assets/image-20210406113531745.png)]
用户管理:系统后台用来维护用户信息,可以对用户进行增删改查操作,对于违规用户可以进行冻结操
用户审核:管理员审核用户信息页面,用户审核分为身份审核和实名审核,身份审核是对用户的身份信息进行审核,包括但不限于工作信息、资质信息、经历信息等;实名认证是对用户实名身份进行认证
内容管理:管理员查询现有文章,并对文章进行新增、删除、修改、置顶等操作
内容审核:管理员审核自媒体人发布的内容,包括但不限于文章文字、图片、敏感信息等
频道管理:管理频道分类界面,可以新增频道,查看频道,新增或修改频道关联的标签
网站统计:统计内容包括:日活用户、访问量、新增用户、访问量趋势、热门搜索、用户地区分布等数据
内容统计:统计内容包括:文章采集量、发布量、阅读量、阅读时间、评论量、转发量、图片量等数据
权限管理:超级管理员对后台管理员账号进行新增或删除角色操作
Spring-Cloud-Gateway : 微服务之前架设的网关服务,实现服务注册中的API请求路由,以及控制流速控制和熔断处理都是常用的架构手段,而这些功能Gateway天然支持
黑马头条项目全部采用逻辑关联,没有采用主外键约束。也是方便数据源冗余,尽可能少的使用多表关联查询。冗余是为了效率,减少join。单表查询比关联查询速度要快。某个访问频繁的字段可以冗余存放在两张表里,不用关联了。
如查询一个订单表需要查询该条订单的用户名称,就必须join另外用户表,如果业务表很大,那么就会查询的很慢,这个时候我们就可以使用冗余来解决这个问题,在新建订单的同时不仅仅需要把用户ID存储,同时也需要存储用户的名称,这样我们在查询订单表的时候就不需要去join另外用户表,也能查询出该条订单的用户名称。这样的冗余可以直接的提高查询效率,单表更快。
后端工程基于Spring-boot 2.1.5.RELEASE 版本构建,工程父项目为heima-leadnews,并通过继承方式集成Spring-boot。
【父项目下分4个公共子项目】:
【多个微服务】:
在访问具体的接口方法的url映射的时候也应该加上版本说明,如下:
java
@RequestMapping("/api/v1/article")
在每一个微服务的工程中的根目录下创建三个文件,方便各个环境的切换
(1)maven_dev.properties
定义开发环境的配置
(2)maven_prod.properties
定义生产环境的配置
(3)maven_test.properties
定义测试环境的配置,开发阶段使用这个测试环境
默认加载的环境为test,在打包的过程中也可以指定参数打包 package -P test/prod/dev
具体配置,请查看父工程下的maven插件的profiles配置
dev
maven_dev.properties
test
true
maven_test.properties
prod
maven_prod.properties
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K6lk3p5w-1619110083804)(项目经验.assets/image-20210406121521124.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mIRoHKcS-1619110083806)(项目经验.assets/image-20210406121532713.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FPCLOg5S-1619110083808)(项目经验.assets/image-20210406121546388.png)]
@Service
public class AdChannelServiceImpl extends ServiceImpl implements AdChannelService {
@Override
public ResponseResult findByNameAndPage(ChannelDto dto) {
//1.参数检测
if(dto==null){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//分页参数检查
dto.checkParam();
//2.安装名称模糊分页查询
Page page = new Page(dto.getPage(),dto.getSize());
LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper();
if(StringUtils.isNotBlank(dto.getName())){
lambdaQueryWrapper.like(AdChannel::getName,dto.getName());
}
IPage result = page(page, lambdaQueryWrapper);
//3.结果封装
ResponseResult responseResult = new PageResponseResult(dto.getPage(),dto.getSize(),(int)result.getTotal());
responseResult.setData(result.getRecords());
return responseResult;
}
}
@RestController
@RequestMapping("/api/v1/channel")
public class AdChannelController implements AdChannelControllerApi {
@Autowired
private AdChannelService channelService;
@PostMapping("/list")
@Override
public ResponseResult findByNameAndPage(@RequestBody ChannelDto dto){
return channelService.findByNameAndPage(dto);
}
}
(1)简介
Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务(https://swagger.io/)。 它的主要作用是:
Spring已经将Swagger纳入自身的标准,建立了Spring-swagger项目,现在叫Springfox。通过在项目中引入Springfox ,即可非常简单快捷的使用Swagger。
(2)SpringBoot集成Swagger
xml
io.springfox
springfox-swagger2
io.springfox
springfox-swagger-ui
只需要在heima-leadnews-common中进行配置即可,因为其他微服务工程都直接或间接依赖即可。
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration
@EnableSwagger2
public class SwaggerConfiguration {
@Bean
public Docket buildDocket() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(buildApiInfo())
.select()
// 要扫描的API(Controller)基础包
.apis(RequestHandlerSelectors.basePackage("com.heima"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo buildApiInfo() {
Contact contact = new Contact("黑马程序员","","");
return new ApiInfoBuilder()
.title("黑马头条-平台管理API文档")
.description("平台管理服务api")
.contact(contact)
.version("1.0.0").build();
}
}
(3)Swagger常用注解
在Java类中添加Swagger的注解即可生成Swagger接口文档,常用Swagger注解如下:
@Api:修饰整个类,描述Controller的作用 @ApiOperation:描述一个类的一个方法,或者说一个接口 @ApiParam:单个参数的描述信息
@ApiModel:用对象来接收参数
@ApiModelProperty:用对象接收参数时,描述对象的一个字段
@ApiResponse:HTTP响应其中1个描述
@ApiResponses:HTTP响应整体描述
@ApiIgnore:使用该注解忽略这个API
@ApiError :发生错误返回的信息
@ApiImplicitParam:一个请求参数
@ApiImplicitParams:多个请求参数的描述信息
@ApiImplicitParam属性:
我们在AdChannelControllerApi中添加Swagger注解,代码如下所示:
@Api(value = "频道管理", tags = "channel", description = "频道管理API")
public interface AdChannelControllerApi {
/**
* 根据名称分页查询频道列表
* @param dto
* @return
*/
@ApiOperation("频道分页列表查询")
public ResponseResult findByNameAndPage(ChannelDto dto);
}
ChannelDto
@Data
public class ChannelDto extends PageRequestDto {
/**
* 频道名称
*/
@ApiModelProperty("频道名称")
private String name;
}
PageRequestDto
@Data
@Slf4j
public class PageRequestDto {
@ApiModelProperty(value="当前页",required = true)
protected Integer size;
@ApiModelProperty(value="每页显示条数",required = true)
protected Integer page;
public void checkParam() {
if (this.page == null || this.page < 0) {
setPage(1);
}
if (this.size == null || this.size < 0 || this.size > 100) {
setSize(10);
}
}
启动admin微服务,访问地址:http://localhost:9001/swagger-ui.html
(1)简介
knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui,取名kni4j是希望它能像一把匕首一样小巧,轻量,并且功能强悍!
gitee地址:https://gitee.com/xiaoym/knife4j
官方文档:https://doc.xiaominfo.com/
效果演示:http://knife4j.xiaominfo.com/doc.html
(2)核心功能
该UI增强包主要包括两大核心功能:文档说明 和 在线调试
(3)快速集成
pom.xml
文件中引入knife4j
的依赖,如下:1
2
3
4
com.github.xiaoymin
knife4j-spring-boot-starter
在heima-leadnews-common模块中新建配置类
新建Swagger的配置文件SwaggerConfiguration.java
文件,创建springfox提供的Docket分组对象,代码如下:
package com.heima.common.knife4j;
import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import springfox.bean.validators.configuration.BeanValidatorPluginsConfiguration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration
@EnableSwagger2
@EnableKnife4j
@Import(BeanValidatorPluginsConfiguration.class)
public class Swagger2Configuration {
@Bean(value = "defaultApi2")
public Docket defaultApi2() {
Docket docket=new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
//分组名称
.groupName("1.0")
.select()
//这里指定Controller扫描包路径
.apis(RequestHandlerSelectors.basePackage("com.heima"))
.paths(PathSelectors.any())
.build();
return docket;
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("黑马头条API文档")
.description("黑马头条API文档")
.version("1.0")
.build();
}
}
以上有两个注解需要特别说明,如下表:
注解 | 说明 |
---|---|
@EnableSwagger2 |
该注解是Springfox-swagger框架提供的使用Swagger注解,该注解必须加 |
@EnableKnife4j |
该注解是knife4j 提供的增强注解,Ui提供了例如动态参数、参数过滤、接口排序等增强功能,如果你想使用这些增强功能就必须加该注解,否则可以不用加 |
在heima-leadnews-admin中开启配置
在config包下新建类KnifeConfig
java
@Configuration
@ComponentScan("com.heima.common.knife4j")
public class KnifeConfig {
}
在浏览器输入地址:http://host:port/doc.html
以上有两个注解需要特别说明,如下表:
注解 | 说明 |
---|---|
@EnableSwagger2 |
该注解是Springfox-swagger框架提供的使用Swagger注解,该注解必须加 |
@EnableKnife4j |
该注解是knife4j 提供的增强注解,Ui提供了例如动态参数、参数过滤、接口排序等增强功能,如果你想使用这些增强功能就必须加该注解,否则可以不用加 |
在heima-leadnews-admin中开启配置
在config包下新建类KnifeConfig
1
2
3
4
5
java
@Configuration
@ComponentScan("com.heima.common.knife4j")
public class KnifeConfig {
}
在浏览器输入地址:http://host:port/doc.html
频道删除
@Override
public ResponseResult deleteById(Integer id) {
//1.检查参数
if(id == null){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//2.判断当前频道是否存在 和 是否有效
AdChannel adChannel = getById(id);
if(adChannel==null){
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST);
}
if(adChannel.getStatus()){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID,"频道有效不能删除");
}
// int i = 10/0;
//3.删除频道
removeById(id);
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
项目开发中肯定会设置全局异常处理,不管系统发生了任何不可知的异常信息,都应该给用户返回友好提示信息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uJBqS2JB-1619110083811)(项目经验.assets/image-20210406165536411.png)]
在heima-leadnews-common模块中新建类ExceptionCatch
package com.heima.common.exception;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
@ControllerAdvice//控制器增强
@Log4j2
public class ExceptionCatch {
//捕获Exception此类异常
@ExceptionHandler(Exception.class)
@ResponseBody
public ResponseResult exception(Exception exception) {
exception.printStackTrace();
//记录日志
log.error("catch exception:{}", exception.getMessage());
//返回通用异常
return ResponseResult.errorResult(AppHttpCodeEnum.SERVER_ERROR);
}
}
@ControllerAdvice
控制器增强注解
@ExceptionHandler
异常处理器 与上面注解一起使用,可以拦截指定的异常信息
在heima-leadnews-admin模块中新增类ExceptionCatchConfig
package com.heima.admin.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan("com.heima.common.exception")
public class ExceptionCatchConfig {
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rGBNugOZ-1619110083812)(项目经验.assets/image-20210406165937931.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6t11Kxzf-1619110083814)(项目经验.assets/image-20210406165946724.png)]
分别要完成敏感词管理的如下功能
在heima-leadnews-apis模块中新建接口com.heima.api.admin.SensitiveControllerApi
分别定义查询,新增,修改,删除方法
解释: 加密后, 密文可以反向解密得到密码原文.
【文件加密和解密使用相同的密钥,即加密密钥也可以用作解密密钥】
常见的对称加密算法有: AES、DES、3DES、Blowfish、IDEA、RC4、RC5、RC6、HS256
【两个密钥:公开密钥(publickey)和私有密钥,公有密钥加密,私有密钥解密】
加密与解密:
常见的非对称加密算法有: RSA、DSA(数字签名用)、ECC(移动设备用)、RS256 (采用SHA-256 的 RSA 签名)
解释: 一旦加密就不能反向解密得到密码原文.
种类: Hash加密算法, 散列算法, 摘要算法等
**用途:**一般用于效验下载文件正确性,一般在网站上下载文件都能见到;存储用户敏感信息,如密码、 卡号等不可解密的信息。
常见的不可逆加密算法有: MD5、SHA、HMAC
Base64是网络上最常见的用于传输8Bit字节代码的编码方式之一。Base64编码可用于在HTTP环境下传递较长的标识信息。采用Base64Base64编码解码具有不可读性,即所编码的数据不会被人用肉眼所直接看到。*注意:Base64只是一种编码方式,不算加密方法。
//md5加密 DegestUtils:spring框架提供的工具类
String md5Str = DigestUtils.md5DigestAsHex("abc".getBytes());
System.out.println(md5Str);//900150983cd24fb0d6963f7d28e17f72
手动加密(md5+随机字符串)
在md5的基础上手动加盐(salt)处理
//uername:zhangsan password:123 salt:随时字符串
String salt = RandomStringUtils.randomAlphanumeric(10);//获取一个10位的随机字符串
System.out.println(salt);
String pswd = "123"+salt;
String saltPswd = DigestUtils.md5DigestAsHex(pswd.getBytes());
System.out.println(saltPswd);
这样同样的密码,加密多次值是不相同的,因为加入了随机字符串
BCrypt密码加密
在用户模块,对于用户密码的保护,通常都会进行加密。我们通常对密码进行加密,然后存放在数据库中,在用户进行登录的时候,将其输入的密码进行加密然后与数据库中存放的密文进行比较,以验证用户密码是否正确。 目前,MD5和BCrypt比较流行。相对来说,BCrypt比MD5更安全。
BCrypt 官网http://www.mindrot.org/projects/jBCrypt/
(1)我们从官网下载源码
(2)新建工程,将源码类BCrypt拷贝到工程
(3)新建测试类,main方法中编写代码,实现对密码的加密
String gensalt = BCrypt.gensalt();//这个是盐 29个字符,随机生成
System.out.println(gensalt);
String password = BCrypt.hashpw("123456", gensalt); //根据盐对密码进行加密
System.out.println(password);//加密后的字符串前29位就是盐
(4)新建测试类,main方法中编写代码,实现对密码的校验。BCrypt不支持反运算,只支持密码校验。
boolean checkpw = BCrypt.checkpw("123456", "$2a$10$61ogZY7EXsMDWeVGQpDq3OBF1.phaUu7.xrwLyWFTOu8woE08zMIW");
System.out.println(checkpw);
token认证
随着 Restful API、微服务的兴起,基于 Token 的认证现在已经越来越普遍。基于token的用户认证是一种服务端无状态的认证方式,所谓服务端无状态指的token本身包含登录用户所有的相关数据,而客户端在认证后的每次请求都会携带token,因此服务器端无需存放token数据。
当用户认证后,服务端生成一个token发给客户端,客户端可以放到 cookie 或 localStorage 等存储中,每次请求时带上 token,服务端收到token通过验证后即可确认用户身份。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2FdTuXeN-1619110083815)(项目经验.assets/image-20210406170736520.png)]
我们现在了解了基于token认证的交互机制,但令牌里面究竟是什么内容?什么格式呢?市面上基于token的认证方式大都采用的是JWT(Json Web Token)。
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。
JWT令牌结构:
JWT令牌由Header、Payload、Signature三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC、SHA256或RSA)。
工具类
package com.heima.utils.common;
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.*;
public class AppJwtUtil {
// TOKEN的有效期一天(S)
private static final int TOKEN_TIME_OUT = 3_600;
// 加密KEY
private static final String TOKEN_ENCRY_KEY = "MDk4ZjZiY2Q0NjIxZDM3M2NhZGU0ZTgzMjYyN2I0ZjY";
// 最小刷新间隔(S)
private static final int REFRESH_TIME = 300;
// 生产ID
public static String getToken(Long id){
Map claimMaps = new HashMap<>();
claimMaps.put("id",id);
long currentTime = System.currentTimeMillis();
return Jwts.builder()
.setId(UUID.randomUUID().toString())
.setIssuedAt(new Date(currentTime)) //签发时间
.setSubject("system") //说明
.setIssuer("heima") //签发者信息
.setAudience("app") //接收用户
.compressWith(CompressionCodecs.GZIP) //数据压缩方式
.signWith(SignatureAlgorithm.HS512, generalKey()) //加密方式
.setExpiration(new Date(currentTime + TOKEN_TIME_OUT * 1000)) //过期时间戳
.addClaims(claimMaps) //cla信息
.compact();
}
/**
* 获取token中的claims信息
*
* @param token
* @return
*/
private static Jws getJws(String token) {
return Jwts.parser()
.setSigningKey(generalKey())
.parseClaimsJws(token);
}
/**
* 获取payload body信息
*
* @param token
* @return
*/
public static Claims getClaimsBody(String token) {
try {
return getJws(token).getBody();
}catch (ExpiredJwtException e){
return null;
}
}
/**
* 获取hearder body信息
*
* @param token
* @return
*/
public static JwsHeader getHeaderBody(String token) {
return getJws(token).getHeader();
}
/**
* 是否过期
*
* @param claims
* @return -1:有效,0:有效,1:过期,2:过期
*/
public static int verifyToken(Claims claims) {
if(claims==null){
return 1;
}
try {
claims.getExpiration()
.before(new Date());
// 需要自动刷新TOKEN
if((claims.getExpiration().getTime()-System.currentTimeMillis())>REFRESH_TIME*1000){
return -1;
}else {
return 0;
}
} catch (ExpiredJwtException ex) {
return 1;
}catch (Exception e){
return 2;
}
}
/**
* 由字符串生成加密key
*
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getEncoder().encode(TOKEN_ENCRY_KEY.getBytes());
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
public static void main(String[] args) {
/* Map map = new HashMap();
map.put("id","11");*/
System.out.println(AppJwtUtil.getToken(1102L));
Jws jws = AppJwtUtil.getJws("eyJhbGciOiJIUzUxMiIsInppcCI6IkdaSVAifQ.H4sIAAAAAAAAADWLQQqEMAwA_5KzhURNt_qb1KZYQSi0wi6Lf9942NsMw3zh6AVW2DYmDGl2WabkZgreCaM6VXzhFBfJMcMARTqsxIG9Z888QLui3e3Tup5Pb81013KKmVzJTGo11nf9n8v4nMUaEY73DzTabjmDAAAA.4SuqQ42IGqCgBai6qd4RaVpVxTlZIWC826QA9kLvt9d-yVUw82gU47HDaSfOzgAcloZedYNNpUcd18Ne8vvjQA");
Claims claims = jws.getBody();
System.out.println(claims.get("id"));
}
}
在heima-leadnews-apis中新建:com.heima.api.admin.LoginControllerApi
package com.heima.api.admin;
import com.heima.model.admin.dtos.AdUserDto;
import com.heima.model.admin.pojos.AdUser;
import com.heima.model.common.dtos.ResponseResult;
import org.springframework.web.bind.annotation.RequestBody;
public interface LoginControllerApi {
/**
* admin登录功能
* @param dto
* @return
*/
public ResponseResult login(@RequestBody AdUserDto dto);
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U2ZEJrgl-1619110083816)(项目经验.assets/image-20210406171215724.png)]
@Service
@Transactional
public class UserLoginServiceImpl extends ServiceImpl implements UserLoginService {
@Override
public ResponseResult login(AdUserDto dto) {
//1.参数校验
if (StringUtils.isEmpty(dto.getName()) || StringUtils.isEmpty(dto.getPassword())) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_REQUIRE, "用户名或密码不能为空");
}
Wrapper wrapper = new QueryWrapper();
((QueryWrapper) wrapper).eq("name", dto.getName());
List list = list(wrapper);
if (list != null && list.size() == 1) {
AdUser adUser = list.get(0);
String pswd = DigestUtils.md5DigestAsHex((dto.getPassword() + adUser.getSalt()).getBytes());
if (adUser.getPassword().equals(pswd)) {
Map map = Maps.newHashMap();
adUser.setPassword("");
adUser.setSalt("");
map.put("token", AppJwtUtil.getToken(adUser.getId().longValue()));
map.put("user", adUser);
return ResponseResult.okResult(map);
} else {
return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_PASSWORD_ERROR);
}
} else {
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST, "用户不存在");
}
}
}
nacos是阿里系的,不仅可以cp还可以ap
Nacos主要提供以下四大功能:
在liunx下安装nacos必须先安装jdk8+才能运行
unzip nacos‐server‐$version.zip
或者
tar ‐xvf nacos‐server‐$version.tar.gz
在admin微服务中加入依赖
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
在admin微服务中的application.yml文件中加入配置
spring:
cloud:
nacos:
discovery:
server-addr: 192.168.200.130:8848
引导类中加上注解@EnableDiscoveryClient
可以让该服务注册到nacos注册中心上去
启动admin微服务,启动nacos,可以查看到admin服务已经在服务列表中了
不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口才能完成一个业务需求,如果让客户端直接与各个微服务通信,会有以下的问题:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bDiKm4tq-1619110083817)(项目经验.assets/image-20210406172134677.png)]
优点如下:
总结:微服务网关就是一个系统,通过暴露该微服务网关系统,方便我们进行相关的鉴权,安全控制,日志统一处理,易于监控的相关功能。
实现微服务网关的技术有很多,
(1)创建heima-leadnews-admin-gateway微服务
pom文件
org.springframework.cloud
spring-cloud-starter-gateway
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
io.jsonwebtoken
jjwt
引导类:
package com.heima.admin.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient //开启注册中心
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class,args);
}
}
application.yml
server:
port: 6001
spring:
application:
name: leadnews-admin-gateway
cloud:
nacos:
discovery:
server-addr: 192.168.200.130:8848
gateway:
globalcors:
cors-configurations:
'[/**]': # 匹配所有请求
allowedOrigins: "*" #跨域处理 允许所有的域
allowedMethods: # 支持的方法
- GET
- POST
- PUT
- DELETE
routes:
# 平台管理
- id: admin
uri: lb://leadnews-admin
predicates:
- Path=/admin/**
filters:
- StripPrefix= 1
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4QVbB2y9-1619110083818)(项目经验.assets/image-20210406172503264.png)]
思路分析:
第二步,编写全局过滤器
package com.heima.admin.gateway.filter;
import com.heima.admin.gateway.utils.AppJwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
@Log4j2
public class AuthorizeFilter implements GlobalFilter, Ordered {
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1.获取请求对象和响应对象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//2.判断当前的请求是否为登录,如果是,直接放行
if(request.getURI().getPath().contains("/login/in")){
//放行
return chain.filter(exchange);
}
//3.获取当前用户的请求头jwt信息
HttpHeaders headers = request.getHeaders();
String jwtToken = headers.getFirst("token");
//4.判断当前令牌是否存在
if(StringUtils.isEmpty(jwtToken)){
//如果不存在,向客户端返回错误提示信息
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
try {
//5.如果令牌存在,解析jwt令牌,判断该令牌是否合法,如果不合法,则向客户端返回错误信息
Claims claims = AppJwtUtil.getClaimsBody(jwtToken);
int result = AppJwtUtil.verifyToken(claims);
if(result == 0 || result == -1){
//5.1 合法,则向header中重新设置userId
Integer id = (Integer) claims.get("id");
log.info("find userid:{} from uri:{}",id,request.getURI());
//重新设置token到header中
ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders -> {
httpHeaders.add("userId", id + "");
}).build();
exchange.mutate().request(serverHttpRequest).build();
}
}catch (Exception e){
e.printStackTrace();
//想客户端返回错误提示信息
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//6.放行
return chain.filter(exchange);
}
/**
* 优先级设置
* 值越小,优先级越高
* @return
*/
@Override
public int getOrder() {
return 0;
}
}
测试:
启动admin服务,继续访问其他微服务,会提示需要认证才能访问,这个时候需要在heads中设置设置token才能正常访问。
![当用户在app前端进行了认证请求会自动往ap_user_realname表中加入数据,目前所查询的就是用户认证列表
默认查询待审核的信息,也可以根据状态进行过滤
ap_user_realname
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o5KqEFaQ-1619110083819)(项目经验.assets/image-20210406172907508.png)]
(1)新建模块:heima-leadnews-user
@Service
public class ApUserRealnameServiceImpl extends ServiceImpl implements ApUserRealnameService {
@Override
public ResponseResult loadListByStatus(AuthDto dto) {
//1.检查参数
if(dto == null ){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//分页检查
dto.checkParam();
//2.根据状态分页查询
LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper();
if(dto.getStatus() != null){
lambdaQueryWrapper.eq(ApUserRealname::getStatus,dto.getStatus());
}
//分页条件构建
IPage pageParam = new Page(dto.getPage(),dto.getSize());
IPage page = page(pageParam, lambdaQueryWrapper);
//3.返回结果
PageResponseResult responseResult = new PageResponseResult(dto.getPage(),dto.getSize(),(int)page.getTotal());
responseResult.setData(page.getRecords());
return responseResult;
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6CHDSHGi-1619110083820)(项目经验.assets/image-20210406173437635.png)]
(1)新建heima-leadnews-wemedia模块,引导类和pom配置参考其他微服务
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ONx1cNLR-1619110083820)(项目经验.assets/image-20210406173618311.png)]
@RestController
@RequestMapping("/api/v1/user")
public class WmUserController implements WmUserControllerApi {
@Autowired
private WmUserService userService;
@PostMapping("/save")
@Override
public ResponseResult save(@RequestBody WmUser wmUser){
userService.save(wmUser);
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
@GetMapping("/findByName/{name}")
@Override
public WmUser findByName(@PathVariable("name") String name){
List list = userService.list(Wrappers.lambdaQuery().eq(WmUser::getName, name));
if(list!=null && !list.isEmpty()){
return list.get(0);
}
return null;
}
}
(1)新建模块heima-leadnews-article,其中引导类和pom文件依赖参考其他微服务
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fR03fnnw-1619110083821)(项目经验.assets/image-20210406173809527.png)]
package com.heima.article.controller.v1;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.heima.api.article.AuthorControllerApi;
import com.heima.article.service.AuthorService;
import com.heima.model.article.pojos.ApAuthor;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/author")
public class AuthorController implements AuthorControllerApi {
@Autowired
private AuthorService authorService;
@GetMapping("/findByUserId/{id}")
@Override
public ApAuthor findByUserId(@PathVariable("id") Integer id){
List list = authorService.list(Wrappers.lambdaQuery().eq(ApAuthor::getUserId, id));
if(list!=null &&!list.isEmpty()){
return list.get(0);
}
return null;
}
@PostMapping("/save")
@Override
public ResponseResult save(@RequestBody ApAuthor apAuthor){
apAuthor.setCreatedTime(new Date());
authorService.save(apAuthor);
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
}
在新建自媒体账户时需要把apuser信息赋值给自媒体用户
app端用户信息表
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aytmZNOx-1619110083822)(项目经验.assets/image-20210406174527372.png)]
修改pom文件,加入依赖
org.springframework.cloud
spring-cloud-starter-openfeign
修改引导类,添加注解@EnableFeignClients
开启远程调用
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan("com.heima.user.mapper")
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class,args);
}
/**
* mybatis-plus分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
return new PaginationInterceptor();
}
}
新建接口com.heima.user.feign.ApAuthorFeign
@FeignClient("leadnews-article")
public interface ArticleFeign {
@GetMapping("/api/v1/author/findByUserId/{id}")
public ApAuthor findByUserId(@PathVariable("id") Integer id);
@PostMapping("/api/v1/author/save")
public ResponseResult save(@RequestBody ApAuthor apAuthor);
新建接口:com.heima.user.feign.WmUserFeign
@FeignClient("leadnews-wemedia")
public interface WemediaFeign {
@PostMapping("/api/v1/user/save")
public ResponseResult save(@RequestBody WmUser wmUser);
@GetMapping("/api/v1/user/findByName/{name}")
public WmUser findByName(@PathVariable("name") String name);
在user模块
实现类:
@Autowired
private ArticleFeign articleFeign;
@Autowired
private WemediaFeign wemediaFeign;
@Autowired
private ApUserMapper apUserMapper;
@Override
public ResponseResult updateStatusById(AuthDto dto, Short status) {
//1.检查参数
if(dto == null || dto.getId()==null){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//检查状态
if(checkStatus(status)){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//2.修改状态
ApUserRealname apUserRealname = new ApUserRealname();
apUserRealname.setId(dto.getId());
apUserRealname.setStatus(status);
if(dto.getMsg() != null){
apUserRealname.setReason(dto.getMsg());
}
updateById(apUserRealname);
//3.如果审核状态是通过,创建自媒体账户,创建作者信息
if(status.equals(UserConstants.PASS_AUTH)){
//创建自媒体账户,创建作者信息
ResponseResult result = createWmUserAndAuthor(dto);
if(result != null){
return result;
}
}
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
@Autowired
private ApUserMapper apUserMapper;
@Autowired
private WemediaFeign wemediaFeign;
/**
* 创建自媒体账户,创建作者信息
* @param dto
*/
private ResponseResult createWmUserAndAuthor(AuthDto dto) {
//获取ap_user信息
Integer apUserRealnameId = dto.getId();
ApUserRealname apUserRealname = getById(apUserRealnameId);
ApUser apUser = apUserMapper.selectById(apUserRealname.getUserId());
if(apUser == null){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
WmUser wmUser = wemediaFeign.findByName(apUser.getName());
//创建自媒体账户
if(wmUser == null){
wmUser = new WmUser();
wmUser.setApUserId(apUser.getId());
wmUser.setCreatedTime(new Date());
wmUser.setName(apUser.getName());
wmUser.setPassword(apUser.getPassword());
wmUser.setSalt(apUser.getSalt());
wmUser.setPhone(apUser.getPhone());
wmUser.setStatus(9);
wemediaFeign.save(wmUser);
}
//创建作者
createAuthor(wmUser);
apUser.setFlag((short)1);
apUserMapper.updateById(apUser);
return null;
}
@Autowired
private ArticleFeign articleFeign;
/**
* 创建作者
* @param wmUser
*/
private void createAuthor(WmUser wmUser) {
Integer apUserId = wmUser.getApUserId();
ApAuthor apAuthor = articleFeign.findByUserId(apUserId);
if(apAuthor == null){
apAuthor = new ApAuthor();
apAuthor.setName(wmUser.getName());
apAuthor.setCreatedTime(new Date());
apAuthor.setUserId(apUserId);
apAuthor.setType(UserConstants.AUTH_TYPE);
articleFeign.save(apAuthor);
}
}
/**
* 检查状态
* @param status
* @return
*/
private boolean checkStatus(Short status) {
if(status == null || (!status.equals(UserConstants.FAIL_AUTH) && !status.equals(UserConstants.PASS_AUTH))){
return true;
}
return false;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ipMzwr4C-1619110083823)(项目经验.assets/image-20210406175341342.png)]
FastDFS 架构包括 Tracker server 和 Storage server。客户端请求 Tracker server 进行文件上传、下载,通过Tracker server 调度最终由 Storage server 完成文件上传和下载。
Tracker server 作用是负载均衡和调度,通过 Tracker server 在文件上传时可以根据一些策略找到Storage server 提供文件上传服务。可以将 tracker 称为追踪服务器或调度服务器。Storage server 作用是文件存储,客户端上传的文件最终存储在 Storage 服务器上,Storageserver 没有实现自己的文件系统而是利用操作系统的文件系统来管理文件。可以将storage称为存储服务器。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OeeDzowS-1619110083823)(项目经验.assets/image-20210406180333610.png)]
客户端上传文件后存储服务器将文件 ID 返回给客户端,此文件 ID 用于以后访问该文件的索引信息。文件索引信息包括:组名,虚拟磁盘路径,数据两级目录,文件名。
关于fasfdfs图片服务器后面需要在项目中多个地方应用,所以把fastdfs封装到通用的模块中,方便后期各个模块引用。
(1)heima-leadnews-common模块中加入依赖
com.github.tobato
fastdfs-client
(2)heima-leadnews-common中的resources新建文件fast_dfs.properties
#socket连接超时时长
fdfs.soTimeout=1500
#连接tracker服务器超时时长
fdfs.connectTimeout=600
fdfs.trackerList=192.168.200.130:22122
(3)heima-leadnews-common中新建配置类:com.heima.common.fastdfs.FdfsConfiguration
@Configuration
@Import(FdfsClientConfig.class) // 导入FastDFS-Client组件
@PropertySource("fast_dfs.properties")
public class FdfsConfiguration {
}
(4)新建fastdfs客户端:com.heima.common.fastdfs.FastDFSClient
@Component
public class FastDFSClient {
@Autowired
private FastFileStorageClient storageClient;
public String uploadFile(MultipartFile file) throws IOException {
StorePath storePath = storageClient.uploadFile((InputStream) file.getInputStream(), file.getSize(), FilenameUtils.getExtension(file.getOriginalFilename()), null);
return storePath.getFullPath();
}
public void delFile(String filePath) {
storageClient.deleteFile(filePath);
}
/**
* 下载
* @param groupName
* @param path
* @return
*/
public byte[] download(String groupName, String path) throws IOException {
InputStream ins = storageClient.downloadFile(groupName, path, new DownloadCallback() {
@Override
public InputStream recv(InputStream ins) throws IOException {
// 将此ins返回给上面的ins
return ins;
}
});
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buff = new byte[100];
int rc = 0;
while ((rc = ins.read(buff, 0, 100)) > 0) {
byteArrayOutputStream.write(buff, 0, rc);
}
return byteArrayOutputStream.toByteArray();
}
}
(5)在heima-leadnews-wemedia微服务中添加配置
①添加配置类,引用fastdfs
package com.heima.wemedia.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan("com.heima.common.fastdfs")
public class FastDfsConfiguration {
②修改application.yml文件,添加自定义的图片访问ip
#图片访问ip
fdfs.url: http://192.168.200.130
AT模式机制:
基于两阶段提交协议的演变。
一阶段:
业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
二阶段:
提交异步化,非常快速地完成。
回滚通过一阶段的回滚日志进行反向补偿。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tLhQ6kmI-1619110083824)(项目经验.assets/image-20210406200722624.png)]
Transaction Coordinator (TC): 事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
Transaction Manager ™: 控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
Resource Manager (RM): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
一个典型的分布式事务过程:
注意此处seata版本是0.7.0+ 增加字段 context
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
(1)从官网上下载seata server端的程序包
(2)修改配置
我们是基于file的方式启动注册和承载配置的
打开conf/file.conf文件
修改service 节点目录内容如下:
说明:需要修改default.grouplist = “127.0.0.1:8091”,将该值设置为seata server向外提供服务ip及端口(或域名+端口)
(4)启动server
到bin目录下执行脚本启动seata server端,注:windows下执行seata-server.bat
启动;linux下执行seata-server.sh
启动
分别在leadnews_article、leadnews_user、leadnews_wemedia三个库中都创建undo_log表
因为有多个工程都需要引入seata,所以新建一个工程heima-leadnews-seata专门来处理分布式事务
(1)因为多个工程都需要依赖与seata,所以在heima-leadnews-seata模块下创建seata的配置类
(2)分别在heima-leadnews-article、heima-leadnews-user、heima-leadnews-wemedia引入heima-leadnews-seata工程,并且添加一下配置类:
@Configuration
@ComponentScan("com.heima.seata.config")
public class SeataConfig {
}
修改注册中心配置,在每个项目中必须按照下方要求来
将配置文件file.conf和配置文件register.conf放到每个需要参与分布式事务项目的resources中。
特别注意:#{spring.application.name}
是一个变量,指的是该项目的名称
如自媒体微服务名称的项目名称如下:
分别在heima-leadnews-article、heima-leadnews-user、heima-leadnews-wemedia微服务的application.yml文件中添加如下配置:
spring:
cloud:
alibaba:
seata:
tx-service-group: ${spring.application.name}_tx_group
在ApUserRealnameServiceImpl类的updateStatusById方法上加上@GlobalTransactional
注解
运行:/seata/bin/seata-server.bat
BASE:全称:Basically Available(基本可用),Soft state(软状态),和 Eventually consistent(最终一致性)三个短语的缩写,来自 ebay 的架构师提出。BASE 理论是对 CAP 中一致性和可用性权衡的结果,其来源于对大型互联网分布式实践的总结,是基于 CAP 定理逐步演化而来的。其核心思想是:
既是无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。
软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问最终都能够获取到最新的值。
首先我们来简要看下分布式事务处理的XA规范 :
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CU4Gt2e2-1619110083825)(项目经验.assets/image-20210406195827317.png)]
可知XA规范中分布式事务有AP,RM,TM组成:
其中应用程序(Application Program ,简称AP):AP定义事务边界(定义事务开始和结束)并访问事务边界内的资源。
资源管理器(Resource Manager,简称RM):Rm管理计算机共享的资源,许多软件都可以去访问这些资源,资源包含比如数据库、文件系统、打印机服务器等。
事务管理器(Transaction Manager ,简称TM):负责管理全局事务,分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚、失败恢复等。
二阶段协议:
第一阶段TM要求所有的RM准备提交对应的事务分支,询问RM是否有能力保证成功的提交事务分支,RM根据自己的情况,如果判断自己进行的工作可以被提交,那就就对工作内容进行持久化,并给TM回执OK;否者给TM的回执NO。RM在发送了否定答复并回滚了已经的工作后,就可以丢弃这个事务分支信息了。
第二阶段TM根据阶段1各个RM prepare的结果,决定是提交还是回滚事务。如果所有的RM都prepare成功,那么TM通知所有的RM进行提交;如果有RM prepare回执NO的话,则TM通知所有RM回滚自己的事务分支。
也就是TM与RM之间是通过两阶段提交协议进行交互的.
优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。(其实也不能100%保证强一致)
缺点: 实现复杂,牺牲了可用性,对性能影响较大,不适合高并发高性能场景。
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
消息最终一致性其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于ebay。我们可以从下面的流程图中看出其中的一些细节:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lE8CVxkJ-1619110083826)(项目经验.assets/image-20210406200233317.png)]
基本思路就是:
消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。