• 从零到一搭建基础架构(7)-管理好你的工程门面


    你是否在遭受以下的困扰:

    • 明明是写过的代码为什么得不到复用?

    • Controller怎么要处理这么多的业务逻辑?

    • 全局性配置与模块级配置我们该怎么处理?

    • ...

    本文将为大家介绍如何使用基础架构搭建起的你的系统门面,让别人一眼望去就知道你的系统正在提供什么的业务功能与配置。

    一、什么是门面?

    目前市面上除了比较少数的大厂使用DDD架构进行业务开发,大多数的公司还是使用MVC进行业务开发。

    DDD与MVC对比文章,可以参考我的小册试读内容:DDD是什么?为什么我们用DDD?

    为什么MVC是大多数公司的选择?简单,易上手,新手友好。

    M(模型),V(视图),C(控制器)三者在实现增删改查上有一套非常固定的模板。

    三者的串联逻辑:控制器从模型层获取到的数据映射成视图展示给用户。

    Spring中常见的操作,我们把M定为DAO,V定为Controller,C定为Service。

    但是经历过历史项目的同学都会有这种感觉,Controller跟Service的边界总是模糊不清的,在Controller里面会写好多业务逻辑,夸张的一点的Controller直接调用DAO来处理业务逻辑。

    M与V之间的映射关系跳过了C的流程,导致M与V之间的处理变成了一次性买卖,碰到类似的逻辑的时候我们无法进行复用。

    我们以关闭订单这个case为例,假设我们在业务上关闭订单可以用户主动关闭被动关闭(超时未支付)

    主动关闭这个case比较好理解,Controller接收到请求,调用Service处理逻辑,Service调用DAO修改模型。如果我们模糊了M与V之间的边界,就会导致大量的逻辑存在于Controller中。这时我们被动关闭订单时,总不能直接去调用Contoller的逻辑吧(如果你这么做了,那我只能说牛逼👍🏻)。同样的逻辑在被动关闭订单中要再写一遍(比如实现方式是定时任务、时间轮等)。

    想想就觉得非常的麻瓜。这还是只是一种被动关闭的场景,后面如果增加一个MQ监听关闭订单,是不是还要再加一段一模一样的代码?

    所以为了逻辑具备一定的通用性、可复用性,我们应该把逻辑收缩到控制层(Service)来处理。Controller层作为系统功能的门面,只有接受请求、校验参数、参数转换、映射Service的结果的代码(比如Service返回男性为1,Controller将1映射成男,其实就是DTO与VO的转换)。

    我们可以认为门面只是一层壳,壳内填充物是Service。

    能够作为门面的有哪些定义呢?

    定义描述
    controller用户web请求处理。
    apiImpl(第六篇的RPC接口实现类)它的定位其实与Controller类似,只不过它的作用域是内部服务。
    MqConsumerMQ可以看做一种特殊的RPC,异步的处理内部服务的消息。
    定时任务系统本身作为逻辑触发入口,核心逻辑还是由Service触发。
    系统启动后的Runner类似于@PostConstruct是启动过程中的逻辑,而Runner是启动后的处理,类似于定时任务,只不过它仅在启动完成后触发一次。

    上述的门面定义组成了Maven模块interaction(用户交互层), 你能够基于上述五种类型快速知道系统正在提供什么样的功能。

    对照common-frame中interaction的maven层级,在应用服务的interaction模块下应该存在上述的几个门面的package。

    二、门面的系统配置

    配置这个东西吧,是一个非常神奇的东西。因为它只提供一些组件级或者系统级的功能配置与属性,一般不参与逻辑处理。所以在多模块工程中定义配置的类的时候通常会把他们放到Service的模块中。

    那么在common-frame中我们也这么处理,把所有配置都放在common-frame-service中可以吗?

    肯定不行,为什么?

    common-frame为什么要拆成多模块?除了后续做业务应用脚手架与分层建议指导之外,更多的是想给业务服务有足够多的选择可以自由决定来引用所需要的模块。

    比如我现在只需要common-frame-service的依赖即可,但是在common-frame-service的依赖里面却包含了很多interaction层面或者启动类层面的配置类与maven引用。

    比如用户服务现在想要引用common-frame-service,但是common-frame-service中增加swagger-ui的maven引用与配置。用户服务只是想要使用common-frame-service中所包含的那些组件级的配置,但是你却给我引入了web层接口的组件配置,这显然非常的不合理。

    因此从maven引用与配置角度来说是非常有必须要存在interaction这样一个模块来独立放置这些门面级别的配置。

    那么可以放在门面的配置定义是什么样的呢?

    2.1.国际化配置

    国际化配置存在的意义是让你的数据响应符合用户所在的region,它是属于用户交互级别的。

    2.2.出入参序列化配置

    我们在进行日期格式序列化的时候,经常会有把日期、时间映射成yyyy-MM-dd HH:mm:ssyyyy-MM-ddHH:mm:ss格式的字符串返回给前端。同样的,前端也会将yyyy-MM-dd HH:mm:ssyyyy-MM-ddHH:mm:ss格式的字符串请求给业务服务,业务又需要映射成Date相关的java类。

    Spring在日期格式的出入参序列化提供了 @DateTimeFormat、@JsonFormat注解。

    对于有日期序列化需求的属性只要标上这两个注解就能实现2022-10-24 10:10:10与LocalDateTime互相转换的需求。

    但是弊端是,每个属性都需要标。有没有什么方法统一实现这个序列化需求?

    Spring默认是Jackson来进行序列化,所以我们只需要修改Jackson的序列化配置即可。

    1. @Configuration
    2. @ConditionalOnProperty(value = "baiyan.config.jackson.enable", havingValue = "true")
    3. public class CommonJacksonConfig {
    4.   public static final String timeFormat = "HH:mm:ss";
    5.   public static final String dateFormat = "yyyy-MM-dd";
    6.   public static final String dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
    7.   /**
    8.     * 全局时间格式化
    9.     */
    10.   @Bean
    11.   public Jackson2ObjectMapperBuilderCustomizer customizer() {
    12.       return builder -> {
    13.           builder.simpleDateFormat(dateTimeFormat);
    14.           //日期序列化
    15.           builder.serializers(new LocalTimeSerializer(DateTimeFormatter.ofPattern(timeFormat)));
    16.           builder.serializers(new LocalDateSerializer(DateTimeFormatter.ofPattern(dateFormat)));
    17.           builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(dateTimeFormat)));
    18.           //日期反序列化
    19.           builder.deserializers(new LocalTimeDeserializer(DateTimeFormatter.ofPattern(timeFormat)));
    20.           builder.deserializers(new LocalDateDeserializer(DateTimeFormatter.ofPattern(dateFormat)));
    21.           builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(dateTimeFormat)));
    22.       };
    23.   }
    24. }
    25. 复制代码

    只要你的出参的字段是LocalDateTime的字段,会自动序列化成yyyy-MM-dd HH:mm:ss,入参格式为yyyy-MM-dd HH:mm:ss,如果接受参数类型为LocalDateTime,也会自动映射,不需要再添加 @DateTimeFormat、@JsonFormat注解。

    同样的,如果你有用户交互层的序列化策略你也应该将序列化配置添加在interaction层。

    2.3.全局异常拦截

    全局异常拦截属于业务处理级别的兜底异常处理方案,发生异常时它将作为兜底的异常响应报文返回给用户。

    在common-frame中已经定义了一个GlobalExceptionHandler,它的代码比较简单,它的基础思路我在Spring中优雅的处理全局异常也介绍过。

    这里我着重说一下在common-frame中定义GlobalExceptionHandler与在业务应用中定义GlobalExceptionHandler有什么区别。

    common-frame中GlobalExceptionHandler仅对common-frame框架级的异常做处理,业务应用的GlobalExceptionHandler对本服务内的特定业务异常做处理。

    这两者应该是相辅相成的,业务应用的GlobalExceptionHandler应该继承common-frame的GlobalExceptionHandler。

    结合第五篇:从零到一搭建基础架构(5)-让你的RPC原地起飞,你觉得业务应用继承common-frame的GlobalExceptionHandler后会有什么问题?

    Controller接口与ApiImpl(RPC接口实现)本质上都是符合Spring Web接口定义的,都是标注了@Controller或者@RestController的。对于apiImpl来说,接口如果发生了异常,我期望通过具体的异常来告知给调用方。但是由于GlobalExceptionHandler的存在,rpc的异常将会被处理包装成标准结构返回,而导致Jackson序列化失败。

    比如调用如下的rpc接口发生异常

    1. @RequestMapping(VersionConfig.COMMON_RPC_VERSION_URL+"user")
    2. public interface UserApi {
    3.   @PostMapping("/by_id")
    4.   UserDTO getUserDetail(@RequestParam("String") String id);
    5. }
    6. 复制代码

    它将被GlobalExceptionHandler拦截,响应给调用方报文是

    1. {
    2. "code": 500,
    3. "errorCode": null,
    4. "message": "请求失败",
    5. "traceId": null,
    6. "data": null
    7. }
    8. 复制代码

    而UserDTO的结构为

    1. @Data
    2. public class UserDTO {
    3.   /**
    4.     * 用户id
    5.     */
    6.   private Long id;
    7.   /**
    8.     * 用户名
    9.     */
    10.   private String userName;
    11.   /**
    12.     * 用户展示名称
    13.     */
    14.   private String realName;
    15. }
    16. 复制代码

    Spring使用Jackson将rpc报文映射成UserDTO会报错,而调用rpc接口真正的异常将被掩盖。

    因此在common-frame中定义了几个注解

    假设业务应用的GlobalExceptionHandler仅对web controller请求生效,不作用与rpc 请求。

    那么我们可以在Controller上标识@Web的注解,在RPC接口上标识@Rpc注解。定义UserGlobalExceptionHandler为

    1. @ControllerAdvice(annotations = Web.class)
    2. public class UserGlobalExceptionHandler extends GlobalExceptionHandler{
    3. }
    4. 复制代码

    这样controller的请求发生异常将会包装为统一响应的报文,rpc的请求发生异常将会直接传递异常。

    2.4.链路信息赋值

    这个操作比较好理解,在分布式应用下,我们使用全局的链路id来跟踪请求的调用链。因此我们在请求报文中需要把当前请求上下文的链路id返回出去,我们才能根据链路id来进行定位

    1. @ControllerAdvice
    2. public class AddTraceIdResponseBodyAdvice implements ResponseBodyAdvice<BaseResult> {
    3.   @Autowired
    4.   private Tracer tracer;
    5.   @Override
    6.   public boolean supports(MethodParameter returnType, Classextends HttpMessageConverter> converterType) {
    7.       return BaseResult.class.isAssignableFrom(returnType.getParameterType());
    8.   }
    9.   @Override
    10.   public BaseResult beforeBodyWrite(BaseResult body, MethodParameter returnType, MediaType selectedContentType, Classextends HttpMessageConverter> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
    11.       body.setTraceId(tracer.currentSpan().context().traceIdString());
    12.       return body;
    13.   }
    14. }
    15. 复制代码

    三、总结

    本文从MVC分层职责出发,知道了工程门面的定义:接受请求、校验参数、参数转换、映射Service的结果。

    并介绍了可以作为门面的五种类型:rpc接口实现、MQ消费者、定时任务、启动后任务、controller

    最后为大家介绍了门面层的配置与service/component的配置独立开的必要性,并讲解了common-frame中所提供的的几个公共配置。

  • 相关阅读:
    Git 常用
    分类预测 | MATLAB实现GRU门控循环单元多特征分类预测
    计算机毕业设计springboot+vue基本微信小程序的汽车租赁公司小程序
    RK3566 AI开发 Docker 环境搭建
    Redis 八种常用数据类型详解
    Linux查询服务器配置(CPU、内存RAM等)命令
    杀掉进程但是fastapi程序还在运行
    用 nodejs 实现 http 服务版本的 hello world
    四种bean拷贝工具对比
    [Android]问题解决-Device must be bootloader unlocked
  • 原文地址:https://blog.csdn.net/BASK2311/article/details/127784674