普通视图

发现新文章,点击刷新页面。
昨天以前木小丰的博客

高效能团队的Java研发规范(进阶版)

作者 木小丰
2022年8月12日 13:24

目前大部分团队是使用的阿里巴巴Java开发规范,不过在日常开发中难免遇到覆盖不到的场景,本文在阿里巴巴Java开发规范基础上,补充一些常用的规范,用于提升代码质量及增强代码可读性。

编程规约

1、基础类型及操作

(1)转换

基本类型转换

String类型转数字:使用apache common-lang3包中的工具类NumberUtils,优势:可设置默认值,转换出错时返回默认值

NumberUtils.toInt("1");

拆箱:包装类转化为基本类型的时候,需要判定null,比如:

Integer numObject = param.get(0);int num = numObject != null ? numObject : 0;
对象类型转换

使用MapStruct工具,转换类后缀Convertor,所有转换操作都在转换类中操作,禁止在业务代码中编写大量set代码。

(2)判断

枚举判定

使用枚举判等,而非枚举对应的数字。因为枚举更直观,方便查看代码及调试,数字容易出错。

判空

各种对象的判空:

//对象判空&非空Objects.isNull()Objects.nonNull()//String判空&非空StringUtils.isEmpty()   //可匹配null和空字符串StringUtils.isNotEmpty()StringUtils.isBlank()   //可匹配null、空字符串、多个空白字符StringUtils.isNotBlank()//集合判空&非空CollectionUtils.isEmpty()CollectionUtils.isNotEmpty()//Map判空&非空MapUtils.isEmpty()MapUtils.isNotEmpty()
断言

使用Guava里的Preconditions工具类,比如:

//如果是空则抛异常Preconditions.checkNotNull()//通用判断Preconditions.checkArgument()

2、集合处理

(1)Map快捷操作

推荐:

//如果值不存在则计算map.computeIfAbsent("key",k-> execValue(k));//默认值map.getOrDefault("key", DEFAULT_VALUE)

反例:

//如果值不存在则计算String v = map.get("key");if(v == null){    v = execValue("key");    map.put("key", v);}//默认值map.containsKey("key") ? map.get("key") : DEFAULT_VALUE

(2)创建对象

构造方法或Builder模式,超过3个参数对象创建使用Builder模式

//Java11+:List.of(1, 2, 3)  Set.of(1, 2, 3)Map.of("a", 1)//Java8中不可变集合(需引入Guava)ImmutableList.of(1,2,3)ImmutableSet.of(1,2,3)ImmutableMap.of("key","value")//多值情况ImmutableMap.builder()    .put("key", "value")    .put("key2", "value2")    .build()//Java8中可变集合(需引入Guava)Lists.newArrayList(1, 2, 3)Sets.newHashSet(1, 2, 3)Maps.newHashMap("key", "value")

反例:

new ArrayList<>(){{   add(1);   add(2);}};

(3)集合嵌套

集合里的值如果是基础类型必须加上注释,说明集合里存的是什么,比如:

//返回值: Map(key: 姓名, value: List(商品))Map<String, List<String>> res;

超过2层集合对象封装必须封装成自定义类:

//推荐Map<String, List<Node>> res;@Valuepublic static class Node {    /**    * 备注说明字段    */    String name;    /**    * 备注说明字段2    */    List<Integer> subjectIds;}//反例Map<String, List<Pair<String, List<Integer>>>> res;

异常及日志

1、异常

关于异常及错误码的思考,请参考笔者的另一篇文章:错误码设计思考

异常除了抛异常还有一种场景,即:上层发起多个必要调用,某些可能失败,需要上层自行决定处理策略,推荐使用vavr中的Either类,Either使用建议:通常我们使用左值表示异常,而右值表示正常调用后的返回结果,即: Either<Throwable, Data>

2、日志

(1)日志文件

根据日志等级一般分为4个日志文件即可:debug.log、info.log、warn.log、error.log;

如有特殊需求可根据场景单独建文件,比如请求日志:request.log、gc日志:gc.log等。

(2)所有用户日志都要有追踪字段

追踪字段包括:traceId、userId等,推荐使用MDC,常用的日志框架:Log4j、Logback都支持。

(3)日志清理及持久化

本地日志根据磁盘大小,必须设置日志保存天数,否则有硬盘满风险;

分布式环境为了方便查询,需要将日志采集到ES中查询;

重要日志:比如审计日志、B端操作日志需要持久保存,一般是保存到Hive中;

工具篇

1、JSON

推荐:使用Gson或Jackson;

不推荐:Fastjson。Fastjson爆出的漏洞多。

2、对象转换

推荐:MapStruct,根据注解编译成Java代码,没有反射,速度快;行为可预测,可查看编译后的Java代码查看转换逻辑;

不推荐:BeanUtils、Dozer等。需要反射,行为不可预测,需要测试;

不推荐:超过3个字段手动转换;

3、模板代码

推荐:Lombok,减少代码行数,提升开发效率,自动生成Java代码,没有性能损耗;

不推荐:手动生成大量set、get方法;

4、参数校验

推荐:hibernate Validation、spring-boot-starter-validation,可通过注解自动实现参数拦截;

不推荐:每个入口(比如Controller)都copy大量重复的校验逻辑;

5、缓存

推荐:Spring Cache,通过注解控制缓存逻辑,适合常用的加缓存场景。

设计篇

1、正向语义

正向语义的好处在于使代码容易理解。 比如:if(judge()){…},很容易理解,即:判定成功则执行代码块。

相反,如果是负向语义,思维还要转换一下,一般用于方法前置的参数校验。

正向语义的应用场景有:

  • 方法定义:方法名推荐:canPass、checkParam,返回true代表成功。 不推荐:比如isInvalidParam返回true代表失败,增加理解成本;
  • Lambda表达式:filter 操作符中返回true是可以通过的元素;
  • if和三目运算符:condition ? doSomething() : doSomething2() , 条件判定后紧跟的是判定成功后执行的操作。

反例:

if (!judge()) {   doSomething2()} else {   doSomething()}

2、防御式编程

(1)外部数据校验

外部传过来数据都需要校验,一般分为两类:

  • 数据流入:用户Http请求、RPC请求、MQ消费者等
  • 数据依赖:依赖的第三方RPC、数据库等

如果是数据流入,一定要首先校验数据合法性再往下执行,推荐hibernate Validation这类工具,可以很方便的做数据校验

数据是数据依赖,一定要考虑各种网络、限流、背压等场景,做好熔断、降级保障。推荐建立防腐层,将第三方的限界上下文语义转换为当前上下文语义,避免理解上的歧义;

(2)Null处理

  • 对于强依赖,没有返回值不行(比如查询数据库):直接抛异常;

  • 需要反馈给上层处理:

    (1)可能返回null的场景:使用Optional;

    (2)上层需要感知信息异常信息:使用vavr中的Either;

  • 可降级:

    (1)返回值是默认值:集合类返回,数字返回0或-1,字符串返回空字符串,其他场景自定义

    集合默认值:

Collections.emptyList()  //空ListCollections.emptySet()   //空SetCollections.emptyMap()   //空Map

总结

本文总结了Java开发常用的高级规范,暂时想到这么多,对文章中观点感兴趣,欢迎留言或加微信交流。

作者博客链接:Java研发规范(进阶版)

作者简介:李少锋,美团Java技术专家,专注分享软件研发实践、架构思考。欢迎关注公共号:Java研发

更多精彩文章:

错误码设计思考

Java线程池进阶

从MVC到DDD的架构演进

平台化建设思路浅谈

构建可回滚的应用及上线checklist实践

错误码设计思考

作者 木小丰
2022年3月24日 16:52

在微服务化的今天,服务间的交互越来越复杂,统一异常处理规范作为框架的基础,一旦上线后很难再更改,如果设计不好,会导致后期的维护成本越来越来大。 对于错误码的设计,不同的开发团队有不同的风格习惯。本文分享作者从实践中总结的经验及对应的思考,期望对读者有所启发。

本文中涉及的源码:https://github.com/sofn/app-engine/tree/master/common-error

什么是错误码

引自阿里巴巴《Java 开发手册》- 异常日志-错误码

错误码的制定原则:快速溯源、简单易记、沟通标准化。

说明:错误码想得过于完美和复杂,就像康熙字典中的生僻字一样,用词似乎精准,但是字典不容易随身携带并且简单易懂。
正例:错误码回答的问题是谁的错?错在哪?
1)错误码必须能够快速知晓错误来源,可快速判断是谁的问题。
2)错误码易于记忆和比对(代码中容易 equals)。
3)错误码能够脱离文档和系统平台达到线下轻量化地自由沟通的目的。

那么用Java异常能表示出来吗?答案显然是否定的

  • 必须能够快速知晓错误来源:异常类因为复用性不能很快的定位,异常类和代码行数也不是一个稳定的值
  • 必须易于记忆和对比:异常类不具有可比性,且不利于前后端交互
  • 能够脱离代码沟通:异常类只能存在于Java代码中

错误码设计

错误码的设计是比较简单的,一般只需要定义一个数字和描述信息即可。不过想设计一套完善错误码系统还有很多需要考虑的场景。

1、错误码的分层

大部分项目错误码设计分为3级能满足业务场景,即项目、模块、错误编码。比如错误码是6位,前两位是项目码、中间两位是模块码,最后两位是异常编号。以下是错误码10203的对应说明:

2、错误的表示方法:枚举or 类

推荐使用枚举,因为枚举具有不可变性,且所有值都在一个文件里描述。

3、多模块错误码定义及接口定义

最原始的错误定义方法是项目中所有的错误码都定义在一个类里,但是这样会随着业务的发展错误码越来越多,最终导致难以维护,推荐的做法是按照项目+模块粒度定义成多个错误码枚举类。有两个问题需要考虑:

(1)项目编码、模块编码的维护:推荐另建一个枚举类统一维护

(2)异常类的统一引用:定义接口,枚举类实现接口

示例:

//异常接口定义public interface ErrorCode {}//模块定义public enum UserProjectCodes {    LOGIN(1, 1, "登录模块"),    USER(1, 2, "用户模块")}//登录模块异常码定义public enum LoginErrorCodes implements ErrorCode {    USER_NOT_EXIST(0, "用户名不存在"), //错误码: 10100    PASSWORD_ERROR(1, "密码错误");    //错误码: 10101        private final int nodeNum;    private final String msg;    UserLoginErrorCodes(int nodeNum, String msg) {        this.nodeNum = nodeNum;        this.msg = msg;        ErrorManager.register(UserProjectCodes.LOGIN, this);    }}

4、防重设计

错误码本质上就是一个数字,且每一个都需要由RD编码定义,在错误码多的项目很容易重复。最佳实践是在枚举的构造方法里调用Helper类,Helper类统一维护所有的异常码,如有重复则枚举初始化失败。

5、错误扩展信息

只有错误码是不够的,还需要反馈给调用方详细的错误信息已方便修正。固定的错误信息字符串在某些场景写也是不够的,这里推荐使用slf4j打日志时使用的动态参数,这种方式相比于String.format格式的好处是不需要关心参数的类型以及记忆%s、%d等的区别,且打印日志时经常使用,降低了团队成员的学习成本。

示例:

//错误码定义PARAM_ERROR(17, "参数非法,期望得到:{},实际得到:{}")//错误码使用ErrorCodes.PARAM_ERROR.format(arg1, arg2);

实现方式:

org.slf4j.helpers.MessageFormatter.arrayFormat(this.message, args).getMessage()  

错误码和异常

在日常业务开发中,对于异常使用最多的还是抛出Java异常(Exception),异常又分为受检查异常(Exception)和不受检查异常(RuntimeException):

  • 受检查的异常:这种在编译时被强制检查的异常称为"受检查的异常"。即在方法的声明中声明的异常。
  • 不受检查的异常:在方法的声明中没有声明,但在方法的运行过程中发生的各种异常被称为"不被检查的异常"。这种异常是错误,会被自动捕获。

1、异常绑定错误码

定义两个父类,分别用于首检查异常和非受检查异常。可支持传入错误码,同时需要支持原始的异常传参,这种场景会赋予一个默认的错误码,比如:500服务器内部异常

//父类定义public abstract class BaseException extends Exception {    protected BaseException(String message) {...}    protected BaseException(String message, Throwable cause) {...}    protected BaseException(Throwable cause) {...}    protected BaseException(ErrorInfo errorInfo) {...}    protected BaseException(ErrorCode errorCode) {...}    protected BaseException(ErrorCode errorCode, Object... args) {...}}

2、部分异常

使用异常能适用于大部分场景,不过对于多条目的场景不是很适合,比如需要批量保存10条记录,某些成功、某些失败,这种场景就不适合直接抛出异常。

在Node.js和Go语言中异常处理采用多返回值方式处理,第一个值是异常,如果为null则表示无异常。在Java里建议采用vavr库中的Either来实现,通常使用左值表示异常,而右值表示正常调用后的返回结果,即: Either<ErrorCode, T>

注意不推荐Pair、Tuple来实现,因为Either只能设置一个左值或右值,而Pair、Tuple无此限制。

错误码和统一返回值

在前后端的交互中,后端一般使用JSON方式返回结果,整合前面说的错误码,可定义以下格式:

{   "code": number,   "msg": string,   "data": object}

在SpringMVC中实现方式是自定义ResponseBodyAdvice和异常拦截,具体实现方式直接查看:源码

实现了以上步骤之后就可以在SpringMVC框架中愉快的使用了,会自动处理异常及封装成统一返回格式

    @GetMapping("/order")    public Order getOrder(Long orderId) {        return service.findById(orderId);    }

总结

本文总结了设计错误码需要考虑的各种因素,并给出了参考示例,基本能满足一般中大型项目。规范有了最重要的还是落地,让团队成员遵守规范才能让项目健康的迭代。

源码地址:https://github.com/sofn/app-engine/tree/master/common-error

本文链接:错误码设计思考

作者简介:木小丰,美团Java技术专家,专注分享软件研发实践、架构思考。欢迎关注公共号:Java研发

更多精彩文章:
Java线程池进阶
从MVC到DDD的架构演进
平台化建设思路浅谈
构建可回滚的应用及上线checklist实践
Maven依赖冲突问题排查经验

Java线程池进阶

作者 木小丰
2022年2月26日 17:12

线程池是日常开发中常用的技术,使用也非常简单,不过想使用好线程池也不是件容易的事,开发者需要不断探索底层的实现原理,才能在不同的场景中选择合适的策略,最大程度发挥线程池的作用以及避免踩坑。

一、线程池工作流程

以下是Java线程池的工作流程,涉及创建线程的参数及拒绝策略,如果读者对这部分内容不太了解,可参考其他的文档,本文不在赘述。

image-20220226153333763

二、线程池进阶

1、线程池的创建

需要手动通过ThreadPoolExecutor创建,使用者要非常明确业务场景并定制线程池,避免误用可能导致的问题。

以下是阿里巴巴Java开发手册中的描述:

image-20220226153449939

ThreadFactory:推荐使用guava中的ThreadFactoryBuilder创建:

new ThreadFactoryBuilder().setNameFormat("name-%d").build();

2、阻塞队列在线程池中的使用

很多同学一看到阻塞队列就自然的认为出入队列都是阻塞的,使用的阻塞队列也就没必要关心拒绝策略了,其实不然,阻塞队列在任务提交和任务获取阶段使用了不同的策略。

任务提交阶段:调用的阻塞队列的offer方法,这个方法是非阻塞的,如果插入队列失败会直接返回false,并触发拒绝策略;

获取任务阶段:使用的是take方法,此方法是阻塞的;

3、保证提交阶段任务不丢失

有三种方法:使用CallerRunsPolicy拒绝策略、自定义拒绝策略、使用MQ系统保证任务不丢失。

(1)CallerRunsPolicy拒绝策略

ThreadPoolExecutor.CallerRunsPolicy:由提交任务的线程处理

这种是最简单的策略,但需要注意的是如果任务耗时较长,会阻塞提交任务的线程,可能会成为系统瓶颈。

(2)自定义拒绝策略

既然Java线程默认使用的是offer提交任务,那我们可以自定义拒绝策略在任务提交失败时改为put阻塞提交。

缺点也是会阻塞提交线程,不过相比CallerRunsPolicy策略更能发挥多线程的优势。

 RejectedExecutionHandler executionHandler = (r, executor) -> {   try {​     executor.getQueue().put(r);   } catch (InterruptedException e) {​     Thread.currentThread().interrupt();​     throw new RejectedExecutionException("Producer thread interrupted", e);   } };

(3)配合MQ保证任务不丢失

使用默认的ThreadPoolExecutor.AbortPolicy策略,如果抛出RejectedExecutionException异常则返回给MQ消费失败,MQ会保证自动重试。

4、保证队列、未执行完成的任务不丢失

当服务停止的时候,线程池中队列和活跃线程中未执行完成的任务可能会造成数据丢失,首先说下结论:无论采取任何策略,在Java层都不能100%保证不丢,比如机器突然断电的情况。我们还是可以采取一定的措施尽量避免任务丢失。

(1)线程池关闭

线程池关闭有两个方法:

shutdownNow方法:线程池拒绝接收新提交的任务,同时立马关闭线程池,线程池里的任务不再执行,并抛出InterruptedException异常。

shutdown方法:线程池拒绝接收新提交的任务,同时等待线程池里的任务执行完毕后关闭线程池。

(2)注册关闭钩子

使用以下方法注册JVM进程关闭钩子,在钩子方法中执行线程池关闭、未处理完成的任务持久化保存等。

Runtime.getRuntime().addShutdownHook()

需要注意的是:钩子方法在使用kill -9杀死进程时不会执行,一般的杀进程的方式是先执行kill,等待一段时间,如果进程还没杀死,再执行kill -9。

要保证队列中的任务不丢失,需要消费队列中的数据,发送到外部MQ中;

保证未执行完成的任务不丢失,需要在抛出InterruptedException异常后,将任务参数保证到MQ中;

需要注意的是:1)尽量不要把未完成的任务保存到本地磁盘,尤其是在经常扩缩容的弹性集群里;2)捕获InterruptedException异常后,不要做重试等耗时操作;3)需要监控任务都发送到MQ中的时间,以便调整kill -9强制执行前的等待时间。

(3)使用MQ保证任务必须执行完成

通过上面介绍的两种方式,可以处理大部分正常停止服务丢数据的任务。不过对于极端情况下,比如断电、断网等,需要严格保证任务不丢失的场景还是不能满足业务需要,这种情况下就需要依赖MQ。

方案是使用线程池的submit方法提交任务,通过future获取到任务执行完成再返回给MQ消费完成。在MQ中如何保证数据不丢失是另外一个复杂的话题了,这里不再深入探讨。

需要注意的是,如果采用这种方案,需要保证处理任务的幂等性,在操作步骤比较多的时候,复杂性也会很高。

5、ThreadLocal变量

ThreadLocal中变量的作用域是当前线程,使用线程池后会因跨线程导致数据不能传递,如果业务中使用了ThreadLocal,需要额外处理这种场景。

(1)InheritableThreadLocal

InheritableThreadLocal是在父子线程中自动传递参数,在线程池场景中不适用。

(2)手动处理

在提交任务前把ThreadLocal中的值取出来,在线程池执行时再set到线程池中线程的ThreadLocal中,并且在finally中清理数据。

缺点是每个线程池都要处理一遍,如果对上下文不熟悉,有漏传的风险。

(3)TransmittableThreadLocal

阿里开源地址:TransmittableThreadLocal

原理是通过javaagent自动处理ThreadLocal跨线程池传参,对业务开发者无感知,也是推荐的方案。

6、异常处理

(1)异常感知

execute方法:抛异常会被提交任务线程感知;

submit方法:抛异常不会被提交任务线程感知,在Future.get()执行时会被感知;

(2)统一处理方案1:异步任务里统一catch

在线程池的执行逻辑最外层,包装try、catch,处理所有异常。

缺点是: 1)所有的不同任务都要trycatch,增加了代码量。2)不存在checkedexception的地方也需要都trycatch起来,代码丑陋。

(3)统一处理方案2:覆写统一异常处理方法

此方案有两种常用实现:1)自定义线程池,继承ThreadPoolExecutor并覆写其afterExecute方法;2)创建线程池时自定义ThreadFactory,在实现里手动创建线程池,并调用Thread.setUncaughtExceptionHandler注册统一异常处理器。

(4)统一处理方案3:Future

任务提交都使用submit,并在Future.get()时捕获所有异常。

三、总结

本文从创建线程池、队列注意事项、如何保证任务不丢失、ThreadLocal、异常等方面总结了笔者的一些思考,各位读者可以对照下自己的使用场景,看本文提到的问题是否都考虑到了呢,或者你还有什么线程池方面的使用经验,欢迎交流分享。

本文链接:Java线程池进阶

作者简介:木小丰,美团Java技术专家,专注分享软件研发实践、架构思考。欢迎关注公共号:Java研发

更多精彩文章:
从MVC到DDD的架构演进
平台化建设思路浅谈
构建可回滚的应用及上线checklist实践
Maven依赖冲突问题排查经验

从MVC到DDD的架构演进

作者 木小丰
2022年1月13日 16:03

DDD这几年越来越火,资料也很多,大部分的资料都偏向于理论介绍,有给出的代码与传统MVC的三层架构差异较大,再加上大量的新概念很容易让初学者望而却步。本文从MVC架构角度来讲解如何演进到DDD架构。

从DDD的角度看MVC架构的问题

代码角度:

  • 瘦实体模型:只起到数据类的作用,业务逻辑散落到service,可维护性越来越差;
  • 面向数据库表编程,而非模型编程;
  • 实体类之间的关系是复杂的网状结构,成为大泥球,牵一发而动全身,导致不敢轻易改代码;
  • service类承接的所有的业务逻辑,越来越臃肿,很容易出现几千行的service类;
  • 对外接口直接暴露实体模型,导致不必要开放内部逻辑对外暴露,就算有DTO类一般也是实体类的直接copy;
  • 外部依赖层直接从service层调用,字段转换、异常处理大量充斥在service方法中;

项目管理角度:

  • 交付效率:越来越低;
  • 稳定性差:不好测试,代码改动的影响范围不好预估;
  • 理解成本高:新成员介入成本高,长期会导致模块只有一个人最熟悉,离职成本很大;

第一层:初出茅庐

以上的问题越来越严重,很多人开始把眼光转向DDD,于是埋头啃了几本大部头的书,对以下概念有了基本的了解:

  • 统一语言
  • 限界上下文
  • 领域、子域、支撑域
  • 聚合、实体、值对象
  • 分层:用户接口层、应用层、领域层、基础层

于是把MVC架构进行了改造,演进成DDD的分层架构。

DDD分层架构:

image

MVC架构到DDD分层架构的映射:

image

至此,算了基本入门了DDD架构,扩展性也得到了一定的提升。不过随着业务的发展,不断冒出新的问题:

  • 一段业务逻辑代码,到底应该放到应用层还是领域层?
  • 领域服务当成原来的MVC中的service层,随着业务不断发展,类也在不断膨胀,好像还是老样子啊?
  • 聚合包含多个实体类,这个接口用不到这么多实体,为了性能还是直接写个SQL返回必要的操作吧,不过这样貌似又回到了MVC模式
  • 既然实体类可以包含业务逻辑、领域服务也可以放业务逻辑,那到底放哪里?
  • 资料上说领域层不能有外部依赖,要做到100%单测覆盖,可是我的领域服务中需要用到外部接口、中央缓存等等,那这不就有了外部依赖了吗?

第二层:草船借箭(战术设计)

带着问题不断学习他人经验,并不断的尝试,逐渐get到以下技能:

1、领域层

领域(domain)是个模块,包含以下组成部分,传统的service按功能可能拆分到任何一个地方,各司其职。

  • 1个聚合
  • 1到多个实体
  • 若干值对象
  • 多个DomainService
  • 1个Factory:新建聚合
  • 1个Repository:聚合仓储服务
聚合根(AggregateRoot)

聚合本身也是一个实体,聚合可以包含其他实体,其他实体不能脱离聚合而单独提供服务,比如一篇文章下的评论,评论必须从属于文章,没有文章也就没有评论。仓库层(repository)也必须是以聚合为核心提供服务的;

实体:可以理解为一张数据库表,必须有主键;

值对象:没有主键,依附于实体而存在,比如用户实体下住址对象,一般在数据库中已json字符串的形式存在;最常见的值对象是枚举;

仓库服务(repository)

资源库是聚合的仓储机制,外部世界通过资源库,而且只能通过资源库来完成对聚合的访问。资源库以聚合的整体管理对象。因此,一个聚合只能有一个资源库对象,那就是以聚合根命名的资源库。除此之外的其他对象,都不应该提供资源库对象。仓储服务的实现一般有Spring Data JPA、Mybatis两种方式。

如果是用Spring Data JPA实现,直接使用JPA注解@OneToOne、@OneToMany,配合fetch配置,即可一个方法查询出所有的关联实体。

如果是用Mybatis实现,那么repository需要加入多个mapper的引用,再手动做拼装。

这里有一个经典的Hibernate笛卡尔积问题,答案是在聚合根中,一般不会加在大量的关联实体对象。如果确实需要查询关联对象而关联对象又比较多怎么办呢?在DDD中有一个CQRS(Command-Query Responsibility Segregation)模式,是一种读写分离模式,在此场景中需要将查询操作放到查询命令中分页查询。

当然CQRS也是一个很复杂模式,不应照搬他人方案,而是根据自己的业务场景选择适合自己的方案,以下列举了CQRS的几种应用模式:

image

工厂服务(factory)

作用是创建聚合,只传入必要的参数,工厂服务内部隐藏复杂的创建逻辑。简单的聚合可以直接通过new、静态方法等创建,不是必须由factory创建。

领域服务

单个实体对象能处理的逻辑放到实体里,多个实体或有交互的场景放到领域服务里。

领域服务可不可以调用仓储层或外部接口? 可以,但不能直接和领域服务代码放一起,领域服务模块存放API,实现放基础层(infrastructure)。

领域服务对象不建议直接以聚合名+DomainService命名,而要以操作命令关联,比如用户保存服务命名为:UserSaveService, 审核服务:UserAuditSerivce。

2、应用层

应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。

比如下订单服务的方法:

public void submitOrder(Long orderId) {    Order order = OrderFetchService.fetchById(orderId);   //获取订单对象    OrderCheckSerivce.check(order);    //验证订单是否有效    OrderSubmitSerivce.submit(order);  //提交订单    ShoppingCartClearService.clear(order);  //移除购物车中已购商品    NotifySerivce.emailNotify(order.getUser());  //发送邮件通知买家}

对于复杂的业务来说,应用层也有几种模式:

  • 编排服务:最典型比如Drools;
  • Command、Query命令模式;
  • 业务按Rhase、Step逐层拆分模式;

image

3、Maven模块划分

基础层是比较简单一层,不过这里还有个比较疑惑的问题:按照DDD的四层架构图去划分Maven模块,基础层是最上的一层,但是基础层也要包含基础组件供其他层使用,这时基础层应该是放到最下层,直接按照这样构建Maven模块会造成循环依赖。

image

相比来说,另一个架构图更准确一些,不过依然没有直观体现Maven模块如何划分。

image

我的最佳实践是将基础层拆分两部分,一部分是基础的组件+仓储API,一部分是实现,maven模块划分图如下所示:

image

第三层:运筹帷幄(战略设计)

经过以上的两层的磨炼,恭喜你把DDD战术都学习完了,应付日常的代码开发也够了,不过作为架构师来说,探索的道路还不能止步于此,接下来会DDD战略部分。战略部分关注点有3个:

  • 统一语言
  • 领域
  • 限界上下文
1、统一语言

统一语言的重要性可以根据Jeff Patton 在《用户故事地图》中给出的一副漫画来直观的描述:

image

统一语言是提炼领域知识的输出结果,也是进行后续需求迭代及重构的基础,统一语言的建立有以下几个要点:

  • 统一语言必须以文档的形式提供出来,并且在整个项目组的各团队达成共识;
  • 统一语言必须每个中文名有对应的英文名,并且在整个技术栈保持一致;
  • 统一语言必须是完整的,包含以下要素:
    1. 领域模型的概念与逻辑;
    2. 界限上下文(Bounded Context);
    3. 系统隐喻;
    4. 职责的分层;
    5. 模式(patterns)与惯用法。
2、领域划分

以事件风暴的形式(Event Storming),列出所有的用户故事(Use Story),用户故事可通过6W模型来构建,即描写场景的 Who、What、Why、Where、When 与 hoW 六个要素。然后圈选功能相近的部分,就形成了领域,领域又根据职能不同划分为:核心域、支撑域、通用域,

具体的过程有很多参考资料,这里不再细讲,最终的输出是领域划分图,以下是一个保险业务示例:

image

3、限界上下文

限界上下文包含两部分:上下文(Context)是业务目标,限界(Bounded)则是保护和隔离上下文的边界。

比如上图中的实现部分即是限界上下文的边界,虚线部分代表了领域的边界。限界上下文没有统一的划分标准,需要的读者根据自己的业务场景来甄别如何划分。

一个上下文中包含了相同的领域知识,角色在上下文中完成动作目标;

边界体现在以下几方面:

  • 领域逻辑层:确定了领域模型的业务边界,维护了模型的完整性与一致性,从而降低系统的业务复杂度;
  • 团队合作层:限界上下文一般也是用户换分团队的依据;
  • 技术实现层:限界上下文可当成是微服务的划分边界;

DDD的不足

DDD架构作为一套先进的方法论,在很多场景能发挥很大价值,但是DDD也不是银弹。高级的架构师把DDD架构当成一种工具,结合其他架构经验一起为业务服务。

DDD的不足有几个方面:

  1. 性能:DDD是基于聚合来组织代码,对于高性能场景下,加载聚合中大量的无用字段会严重影响性能,比如报表场景中,直接写SQL会更简单直接;
  2. 事务:DDD中的事务被限定在限界上下文中,跨多个限界上下文的场景需要开发者额外考虑分布式事务问题;
  3. 难度系数高,推广成本大:DDD项目需要领域专家专家,且需要特别熟悉业务、建模、OOP,对于管理者来说评估一个人是否真的能胜任也是一件困难的事情;

总结

本文从MVC架构开始讲述了如何从演进到DDD架构,限于篇幅很多DDD的知识点没有讲到,希望大家在实践过程中能灵活运用,尽享DDD给业务带来的价值。本文如有不足之处敬请反馈。

本文链接:从MVC到DDD的架构演进

作者简介:木小丰,美团Java技术专家,专注分享软件研发实践、架构思考。欢迎关注公共号:Java研发

构建可回滚的应用及上线checklist实践

作者 木小丰
2021年11月20日 22:48

构建可回滚的应用及上线checklist实践

一、背景

在互联网分布式应用中,如果上线的新版本有bug又不能回滚止损,带来的后果将是灾难性的。因此做到上线可回滚以及上线前的checklist是保证服务稳定性的基本要求。

在简单的场景里直接回滚到上一版个版本即可,但是如果涉及多个上下游和组件、考虑多版本兼容,就需要有好好设计下如何构建可回滚的代码,充分验证后还需要仔细检查上线checklist,最大程度保证线上服务的稳定性。

二、构建向前兼容的代码

回滚指的是程序或数据处理错误,将程序或数据恢复到上一次正确状态的行为。在回滚之后,程序依然能够正常处理,称为可回滚。

不可回滚原因大多是旧程序不能处理新数据,同时新数据又不能丢弃,代码回滚导致旧业务逻辑出错。

保证向前兼容的手段:

1、数据库变更

新加字段:设置默认值,默认值要保证新旧代码逻辑的语义一致性。比如用户表添加了用户状态,默认值要设置为默认有效。

删除字段:新版本全量发布后,最后迭代2-3个版本后,再删除无用字段,同时要做好数据库备份。

新加唯一约束:首先要确保原有的数据值没有重复的,再添加唯一索引,可使用以下SQL验证:

select field,count(1) from table group by field having count(1) > 1

复杂数据库字段变更:

  • 通常做法是:双写读旧 -> 新字段离线验证 -> 旧字段全量拷贝至新字段 -> 双读旧为主diff -> 双读新为主diff -> 写新读新 -> 移除旧字段和逻辑;
  • 出问题之后,只回滚程序,不回滚数据。

2、对外提供服务API变动设计

(1)对外提供RPC服务

入参新加字段:设置为可选的,如果没设置值,要有老业务逻辑兼容代码

返回值字段:设置为可选的,如果是新版本肯定有值字段,需要在字段描述文档里做好备注

如果入参和返回值结果差异较大,建议新建一个RPC方法,逐渐把业务方调用迁移到新方法

(2)对外提供HTTP服务

HTTP接口和RPC接口的不同是没有强制的约束,数据交换大多采用Json形式,虽然灵活性强,但是约束力低给管理带来很大的成本。因此HTTP接口文档必须给出类似RPC一样的规范,比如Swagger等工具,比如给指定字段名、类型、是否必填、不填写的默认值等。

3、多版本发布

对于客户端API服务或者基础服务来说,用户升级会有很大的延迟,对于服务提供方来说则需要尽量保证多版本同时可用。最好在最开始设计接口的时候就添加version字段,保证以后的扩展。

4、静态资源发布

因为静态资源一般放在CDN上缓存时间设置的比较长,比如1个月。这样假设发布的版本有问题,需要清理CDN缓存,并且也需要清理浏览器缓存,而且因为存在版本覆盖的问题,即使覆盖了也不一定保证是操作正确了。

现在的工具默认都会把md5设置到js、css文件名中,可有效的避免以上问题。

image-20211120215947571

三、上线checklist

尽管准备工作做的再好,难免也会出现疏漏,因此指定上线前checklist,组内同学交叉评审,保证上线前的最后一步,是很有必要的。

发布策略一般是从下游逐步向上部署。而回滚方案相反,一般是从上游往下回滚。

通用的checklist需要考虑以下几方面:

1、依赖

(1)存储(数据库、分布式缓存)

是否有数据库变更需要代码上线前先提交,并且验证执行成功。

(2)动态配置

首先需要确认代码中的动态配置有无默认值,没有默认值需要先在动态配置系统上添加相关的配置。

(3)下游

确认下游jar包版本是否正确,线上最好使用release包。

确认下游的业务方是否已经上线。

(4)鉴权

美团内部的服务调用都有鉴权,检查鉴权是否已经添加并且已生效

2、上游

(1)Nginx层

确认路由策略,在发布过程中会不会造成用户不断刷新页面命中不同代码逻辑,给用户带来用户体验上的问题和困扰。

(2)上游业务方

确保和上游业务方沟通好,等当前服务全量发布后且观察、验证没问题再通知上游发布。

3、业务内部

通过代码review,QA测试等方式保证上线对现有业务逻辑没有影响。

​ 如有必要可建设业务的checklist规范

  • 代码review:重点查看代码规范、业务逻辑的正确性、对相关功能的影响

  • 测试用例:测试相关代码的正常逻辑、边界条件、其他功能不受影响,如果写好测试用例请参考其他专业的文档

  • 自动化测试:有条件的团队建议添加自动化测试,来保证核心业务流程的正确性

四、可回滚发布

1、回滚方案

需考虑以下几方面:

  • 是否有动态开关、流量控制可以一键恢复到旧版本逻辑
  • 回滚代码会不会导致上游调用失败
  • 回滚代码:根据docker镜像、Git commitId、Git tag回滚

2、可回滚验证

方案设计要考虑可回滚性;通常要在test或预上线环境验证(演练)可回滚性。同时QA同学要把可回滚作为质量验收的一部分。

3、平滑升级

(1)线上验收

通过staging环境或线上灰度链路, 进行线上验收。

(2)蓝绿发布

线上部署两个集群,每个集群都能抗住所有的流量,发布的时候一个集群接受用户请求,等当前集群全部发布成功后,再把流量全部迁移到新版本集群。

优点:可保证新版本特性同一时间对所有用户生效;保证系统高可用,一个集群出问题,可快速切换到备用集群;

缺点:日常使用的机器只有一半,造成浪费;

蓝绿发布的高配方案:部署两套集群,通过Nginx层动态切换集群。

蓝绿发布的低配方案:用动态开关控制新旧逻辑,等新版本全量上线后,再切换到新代码逻辑。

image-20211120220311570

(3)灰度发布(金丝雀发布)

采用金丝雀部署,可以在生产环境的基础设施中小范围的部署新的应用代码。一旦应用新发布,只有少数用户被路由到它。最大限度的降低影响。检测新版本没问题,再逐步扩大发布范围直至全量。

实现灰度发布,需要部署系统设置分批发布,同时必须有监控、报警等相关的系统的配合。确定没问题后再扩量。

还有一点需要注意的就是灰度路由策略,一般服务中用的多是的随机策略,这样可能导致用户在发布过程中不断刷新页面,可能会来回命中新老版本逻辑,影响用户体验。避免方式有以下几种:

  • 采用用户ID做路由因子,问题是新增、删除机器时,算法匹配的机器会变,就需要引入一致性Hash算法,复杂性较高
  • RPC路由策略一般不能执行路由规则,就需要程序自己判断。常用做法是采用动态配置设置百分比值,算法如下:
userId % 100 < $dynamicTrafficPercent ? oldVersion : newVersion
(4)AB测试

AB测试虽然也有流量控制的功能,但一般是用于验证不同版本之间的性能、用户体验、效果数据等的手段。是上线后由运营控制的。

AB测试和灰度发布最大的不同是AB测试是通过桶的方式分配流量。

4、监控报警

常用的发布相关的监控报警包括:

  • 新、旧版本的流量及百分比
  • 对外提供的接口的性能指标、下游的性能指标(TP50、TP90、TP99、TP999)
  • 异常指标及报警
  • 业务指标

5、发布完成周知

  • PM: 周知PM关注业务指标、客诉等
  • 上游:如果上游依赖此发布,周知上游开始发布
  • 下游:周知下周注意流量增量及耗时等情况

五、上线步骤及规范

1、不在高峰期上线,如必须上线,要发邮件申请,同时降低并发度

2、上线前周知

3、保证能回滚

4、发布过程采用分组发布(强制)

5、上线过程中观察系统指标、业务指标、异常指标等;如有异常立即禁用已发布机器

6、上线后周知PM、上下游等

7、有异常第一时间回滚

六、总结

从本文可以看出,保证线上稳定性是一个复杂且系统的工程,需要从技术规范、流程规范、周知、检查、工具支持等各个方面来保证。

作者简介:木小丰,美团Java高级工程师,公共号只发原创精品文章,专注软件研发过程中的实践、思考。欢迎关注公共号:Java研发

img

历史文章推荐
Maven依赖冲突问题排查经验
升级Java17问题记录
使用Groovy构建DSL
Gradle最佳实践

Maven依赖冲突问题排查经验

作者 木小丰
2021年11月13日 20:50

一、背景

在日常的开发中,排查问题是一个合格Java开发者的的基本能力。对于常见的NullPointerException,NoClassDefFoundError等问题一般通过google直接就能找到答案。

不过还有一些异常情况不是那么直观,google一般搜不到有效的信息,就需要深入研究排查。新人遇到这类问题,往往一脸懵逼,不知如何下手,请教高手,高手如果只是简单指导一个方向,新人踩过几个坑没解决后会更加沮丧。甚至怀疑自己遇到一个神秘的无法解决的bug。有经验的开发往往也不能一眼就看出问题的修复方案,但是排查问题多了会有大概的思路,需要沉下心来不断的踩坑、爬坑,最终才能定位解决问题。

本文以Maven构建工具为例,从原理、思路、工具、实践几方面分享Java中复杂jar包依赖问题排查经验。

二、Maven依赖问题的表现形式

1、老项目

  • 一般是新引入了一个jar包,导致项目启动不起来
  • 原来的服务好好的,新的改动不涉及这块,但是新改动代码后突然报错了

2、新项目

  • 日志问题:众所周知,jar生态的日志包及其混乱,同一套日志组件,不同版本之间也可能会带来问题
  • 组件整合问题:各组件底层依赖jar包版本不一致,导致问题。最麻烦的是不兼容的依赖。

三、问题排查策略

1、基础知识:Maven依赖传递策略

compile (编译范围):默认策略;编译,测试,运行,打包都能用

provided (已提供范围):编译,测试可用,不会打包到package包中。大部分情况下需要底层环境提供,比如tomcat服务中已经内置了servlet包,代码写代码时需要调用servlet的类

runtime (运行时范围):只有运行时才可用,但在编译的时候不需要。比如,你可能在编译的时候只需要JDBC API JAR,而只有在运行的时候才需要JDBC驱动实现。

test (测试范围):test范围依赖在编译和运行时都不需要,它们只有在测试编译和测试运行阶段可用。

system (系统范围):system范围依赖与provided 类似,但是你必须显式的提供一个对于本地系统中JAR 文件的路径。适合maven中央仓库中不存在jar包引用。

2、基础知识:Maven依赖树的解析规则

(1)深入优先原则

深度优先遍历依赖,并缓存节点剪枝。比如下图:

  • A→B→D→E/F
  • A→C→D

在第二步A→C→D时,由于节点D已经被缓存,所以会立即返回,不必再次遍历E/F,避免重复搜索。

image-20211113200514057
(2)短路径优先原则

比如下图 A 通过 B 和 D 引入了 1.0 版本的 E,同时 A 通过 C 引入了 2.0 版本的 E。针对这种多个版本构建依赖时,Maven 采用短路径优先原则,即 A 会依赖 2.0 版本的 E。如果想引入 1.0 版本的 E,需要直接在 A 的 pom 中声明 E 的版本。

image-20211113200742469
(3)依赖循环

maven中禁止有循环依赖。比如A 依赖了 B,同时 B 又依赖了 A。这种循环依赖可能不会直接显现,但是会在一个很长的调用关系显现出来

(4)Maven多模块问题

B模块依赖A模块,A模块的依赖修改了,需要install一下,B模块才能感知到最新的依赖。

3、工具

(1)日志

开发环境,建议默认日志级别设置为info,对需要关注的模块,建议设置为debug模式,比如当前工程目录,正在联调的依赖jar包。

如果异常无法定位,第一反应应该是添加日志。

(2)maven命令行

添加以下有空的命令行参数:

  • -e 出错日志

  • -X 打印debug日志

  • -q 只打印错误

(3)IDEA插件:Maven Helper

安装插件后左下角会出现个新标签页,点击后搜所有此模块依赖的jar包及冲突情况

image-20211113201357394image-20211113201425368

4、新项目

新项目碰到错误,复杂的问题,先不要深入研究,先看下是否有以下情况:

  • 新项目推荐Log4j2,代码里统一用slf4j打日志
  • 新项目推荐spring boot,它们内部把依赖问题都修复了,可以避免很多问题

5、从下往上递归根因

java的错误栈是从报错的最底层逐步往上打,这样能很方便的定位,一般的问题通过此手段定位问题后即可很快修复。

复杂点的异常,比如jar包冲突异常,最底层的异常一般都是基础组件,比如类加载器、日志组件等,这种情况就要多看几层。

6、从上往下查看变动

大部分问题都是新引入依赖导致,从上往下查看变动,配合从下往上定位信息,定位出问题的组件。

四、案例分享

1、Tomcat项目启动报错

背景:新需求接入舆情SDK,测试环境测试通过,线上发布有大量机器tomcat启动失败。

(1)查找错误日志

异常日志:

image-20211113201801746

(2)尝试修复:第一次

通过module-info.class,和 tag in constant pool: 19分析,直观感受是tomcat不支持java9中的模块策略

机器上使用的是tomcat6, 还不支持java9的新模块策略,遂升级tomcat6到tomcat8,启动错误还有

(3)尝试修复:第二次

此异常是lombok-1.18.10引起的,这个版本为了支持java9添加了module-info.class,而lombok包是java源代码增强的一个工具,运行时,并不需要,遂把依赖的scope改成provided。

重新发布,又报了以下异常:

image-20211113201859921

(4)尝试修复:第三次

报错Not running on Jetty,而我们的服务是运行在tomcat上,配合从上到下排查的思路,在新引入的maven依赖项打开pom.xml,查看里面的依赖项还有parent里的依赖项,找到了以下元凶:parent里会依赖***-boot-starter-web,而这里默认使用jetty作为引擎,遂排除掉,并验证业务逻辑是否正常。

image-20211113202044303

重新发布,问题解决。

(5)经验总结

  • 排查jar包也有风险,很可能导致业务功能受影响,排除后要经过充分验证

2、新加依赖后服务启动失败

背景:新需求接入用户个性化api jar包,使用MDP的thrift注解注册了 thrift client bean,引入后项目启动失败。

(1)异常日志

image-20211113202156042

(2)定位问题

从堆栈初步看是以下包不兼容导致的:swift-codec或guava,还有mtthrift。刚开始重点排查了swift-codec或guava的兼容问题,将其降级和新引入jar包里的版本一致,启动错误还在

刚开始没往mtthrift方面想,因为mtthrift是美团的基础组件,一般情况下不会出现兼容的问题,查看官方wiki也没说有兼容问题

(3)从上到下排查

查看新引入的依赖,打开pom.xml,发现里面引入了MDP 1.5.5版本,而我们的项目的是最新的MDP 1.6.6.1, 初步判断是MDP包冲突,所以就把新包的MDP包排除掉

image-20211113202304318

重新启动,错误依旧

(4)排查版本不一致的依赖

到现在基本可以认为是mtthrift升级导致的不兼容问题。我们引入的jar包只需要其中了api定义,并不需要依赖的依赖,所以把所有的依赖都排除掉。重新引入和依赖包一致的mtthrift版本。重新启动,问题解决。

image-20211113202412654

(5)总结

这个问题解决过程中走了很多弯路,一般我们的认为基础组件升级会兼容老版本,还有一点加深了这个认识:项目里引入的其他的thrift-client包,并没有出现问题。

但不幸的是,这个正好遇到了此类问题。从最终的解决方案看,应该是新引入的jar包编译的问题。

五、对外发布jar包规范

  • 对外提供的SDK或API包,不要包含不必要的额外依赖
  • 严禁包含有依赖的parent的jar包发布
  • 必须要依赖的,可以依赖通用包比如common-langs、guava包等,最好设置provided。

六、总结

Maven包依赖问题是开发中的常见问题,如果不熟悉排查方案,会浪费大量时间。本文从工具、方法论、实践方面做了一些思考,希望对大家日常开发有帮助。

原文链接https://lesofn.com/archives/maven-yi-lai-chong-tu-wen-ti-pai-cha-jing-yan

作者简介:美团Java高级工程师,关注软件架构及职业成长,不定期分享各种技术、资源,对文章中涉及的技术感兴趣或有任何问题请关注微信交流。

公共号:Java研发

React + TypeScript + Router + Mobx + Antd + 多页面开发模板(免eject)

作者 木小丰
2021年3月1日 12:58

1、基础模板:create-react-app

2、开发者模式

src/setupProxy.js配置代理

执行

npm run start 或 yarn run start

打开:http://localhost:3000/ 默认index页面 打开:http://localhost:3000/admin.html 打开admin页面

3、构建线上文件

执行

npm run build 或 yarn run build

build目录下将生成两个html文件: index.html和admin.html 分别对应连个entry入口

4、构建过程

多页面开发参考文章:https://segmentfault.com/a/1190000017858725

需要改动的地方: require.resolve('./node_modules/react-scripts/config/polyfills.js')

改成

require.resolve('react-app-polyfill/stable')

Mobx支持及静态类型支持参考:src/index/components/header/Header.tsx

Router支持参考:src/index/components/header/Header.tsx

5、项目地址

https://github.com/sofn/react-ts-multientry

使用Reactor完成类似Flink的操作

作者 木小丰
2021年2月26日 22:47

一、背景

Flink在处理流式任务的时候有很大的优势,其中windows等操作符可以很方便的完成聚合任务,但是Flink是一套独立的服务,业务流程中如果想使用需要将数据发到kafka,用Flink处理完再发到kafka,然后再做业务处理,流程很繁琐。

比如在业务代码中想要实现类似Flink的window按时间批量聚合功能,如果纯手动写代码比较繁琐,使用Flink又太重,这种场景下使用响应式编程RxJava、Reactor等的window、buffer操作符可以很方便的实现。

响应式编程框架也早已有了背压以及丰富的操作符支持,能不能用响应式编程框架处理类似Flink的操作呢,答案是肯定的。

本文使用Reactor来实现Flink的window功能来举例,其他操作符理论上相同。文中涉及的代码:github

二、实现过程

Flink对流式处理做的很好的封装,使用Flink的时候几乎不用关心线程池、积压、数据丢失等问题,但是使用Reactor实现类似的功能就必须对Reactor运行原理比较了解,并且经过不同场景下测试,否则很容易出问题。

下面列举出实现过程中的核心点:

1、创建Flux和发送数据分离

入门Reactor的时候给的示例都是创建Flux的时候同时就把数据赋值了,比如:Flux.just、Flux.range等,从3.4.0版本后先创建Flux,再发送数据可使用Sinks完成。有两个比较容易混淆的方法:

  • Sinks.many().multicast() 支持多订阅者,如果没有订阅者,那么接收的消息直接丢弃
  • Sinks.many().unicast() 只支持一个订阅者,如果没有订阅者,那么保存接收的消息直到第一个订阅者订阅
  • Sinks.many().replay() 不管有多少订阅者,都保存所有消息

在此示例场景中,选择的是Sinks.many().unicast()

官方文档:https://projectreactor.io/docs/core/release/reference/#processors

2、背压支持

上面方法的对象背压策略支持两种:BackpressureBuffer、BackpressureError,在此场景肯定是选择BackpressureBuffer,需要指定缓存队列,初始化方法如下:Queues.get(queueSize).get()

数据提交有两个方法:

  • emitNext 指定提交失败策略同步提交
  • tryEmitNext 异步提交,返回提交成功、失败状态

在此场景我们不希望丢数据,可自定义失败策略,提交失败无限重试,当然也可以调用异步方法自己重试。

 Sinks.EmitFailureHandler ALWAYS_RETRY_HANDLER = (signalType, emitResult) -> emitResult.isFailure();

在此之后就就可以调用Sinks.asFlux开心的使用各种操作符了。

3、窗口函数

Reactor支持两类窗口聚合函数:

  • window类:返回Mono(Flux)
  • buffer类:返回List

在此场景中,使用buffer即可满足需求,bufferTimeout(int maxSize, Duration maxTime)支持最大个数,最大等待时间操作,Flink中的keys操作可以用groupBy、collectMap来实现。

4、消费者处理

Reactor经过buffer后是一个一个的发送数据,如果使用publishOn或subscribeOn处理的话,只等待下游的subscribe处理完成才会重新request新的数据,buffer操作符才会重新发送数据。如果此时subscribe消费者耗时较长,数据流会在buffer流程阻塞,显然并不是我们想要的。

理想的操作是消费者在一个线程池里操作,可多线程并行处理,如果线程池满,再阻塞buffer操作符。解决方案是自定义一个线程池,并且当然线程池如果任务满submit支持阻塞,可以用自定义RejectedExecutionHandler来实现:

 RejectedExecutionHandler executionHandler = (r, executor) -> {     try {         executor.getQueue().put(r);     } catch (InterruptedException e) {         Thread.currentThread().interrupt();         throw new RejectedExecutionException("Producer thread interrupted", e);     } };  new ThreadPoolExecutor(poolSize, poolSize,         0L, TimeUnit.MILLISECONDS,         new SynchronousQueue<>(),         executionHandler);

三、总结

1、总结一下整体的执行流程

  1. 提交任务:提交数据支持同步异步两种方式,支持多线程提交,正常情况下响应很快,同步的方法如果队列满则阻塞。
  2. 丰富的操作符处理流式数据。
  3. buffer操作符产生的数据多线程处理:同步提交到单独的消费者线程池,线程池任务满则阻塞。
  4. 消费者线程池:支持阻塞提交,保证不丢消息,同时队列长度设置成0,因为前面已经有队列了。
  5. 背压:消费者线程池阻塞后,会背压到buffer操作符,并背压到缓冲队列,缓存队列满背压到数据提交者。

2、和Flink的对比

实现的Flink的功能:

  • 不输Flink的丰富操作符
  • 支持背压,不丢数据

优势:轻量级,可直接在业务代码中使用

劣势:

  • 内部执行流程复杂,容易踩坑,不如Flink傻瓜化
  • 没有watermark功能,也就意味着只支持无序数据处理
  • 没有savepoint功能,虽然我们用背压解决了部分问题,但是宕机后开始会丢失缓存队列和消费者线程池里的数据,补救措施是添加Java Hook功能
  • 只支持单机,意味着你的缓存队列不能设置无限大,要考虑线程池的大小,且没有flink globalWindow等功能
  • 需考虑对上游数据源的影响,Flink的上游一般是mq,数据量大时可自动堆积,如果本文的方案上游是http、rpc调用,产生的阻塞影响就不能忽略。补偿方案是每次提交数据都使用异步方法,如果失败则提交到mq中缓冲并消费该mq无限重试。

四、附录

本文源码地址:https://github.com/sofn/reactor-window-like-flink

Reactor官方文档:https://projectreactor.io/docs/core/release/reference/

Flink文档:https://ci.apache.org/projects/flink/flink-docs-stable/

Reactive操作符:http://reactivex.io/documentation/operators.html


本文作者:木小丰,美团Java高级工程师,关注架构、软件工程、全栈等,不定期分享软件研发过程中的实践、思考。

作者博客:https://lesofn.com

公共号:Java研发

Gradle最佳实践

作者 木小丰
2021年1月13日 10:07

一、Gradle相比Maven的优势

  1. 配置简洁

    Maven是用pom.xml管理,引入一个jar包至少5行代码,Gradle只需要一行。

  2. 构建速度快

    Gradle支持daemon方式运行,启动速度快,同时有基于daemon的增量构建,充分利用JVM的运行时优化,和缓存数据构建速度相比Maven快很多。

  3. 更好的灵活性、扩展性

    Gradle 相对于 Maven 等构建工具, 其提供了一系列的 API 让我们有能力去修改或定制项目的构建过程。

二、基本配置

  1. 设置本地仓库地址

    默认本地仓库地址是:~/.gradle,Windows下会占用大量C盘空间。

    设置环境变量,GRADLE_USER_HOME=/your/path

  2. 复用Maven本地仓库

    在repositories配置mavenLocal()即可,如果是init.gradle全局配置,参考以下init.gradle文件

    Maven本地仓库查找路径:

    (1)USER_HOME/.m2/settings.xml

    (2)M2_HOME/conf/settings.xml

    (3)USER_HOME/.m2/repository

  3. 国内镜像加速

    国内访问国外仓库地址很慢,第一种方法是在每个项目中设置repositories

    repositories { maven { url 'https://maven.aliyun.com/repository/public/' } mavenLocal() mavenCentral()}

    更推荐的方式是类似的Maven的settings.xml全局的配置,在上一步配置的GRADLE_USER_HOME路径下,添加init.gradle文件,以下配置文件中使用了阿里云的Gradle代理,支持jcenter、google、maven仓库。

    gradle.projectsLoaded {    rootProject.allprojects {        buildscript {            repositories {                def JCENTER_URL = 'https://maven.aliyun.com/repository/jcenter'                def GOOGLE_URL = 'https://maven.aliyun.com/repository/google'                def NEXUS_URL = 'http://maven.aliyun.com/nexus/content/repositories/jcenter'                all { ArtifactRepository repo ->                    if (repo instanceof MavenArtifactRepository) {                        def url = repo.url.toString()                        if (url.startsWith('https://jcenter.bintray.com/')) {                            project.logger.lifecycle "Repository ${repo.url} replaced by $JCENTER_URL."                            println("buildscript ${repo.url} replaced by $JCENTER_URL.")                            remove repo                        }                        else if (url.startsWith('https://dl.google.com/dl/android/maven2/')) {                            project.logger.lifecycle "Repository ${repo.url} replaced by $GOOGLE_URL."                            println("buildscript ${repo.url} replaced by $GOOGLE_URL.")                            remove repo                        }                        else if (url.startsWith('https://repo1.maven.org/maven2')) {                            project.logger.lifecycle "Repository ${repo.url} replaced by $REPOSITORY_URL."                            println("buildscript ${repo.url} replaced by $REPOSITORY_URL.")                            remove repo                        }                    }                }                jcenter {                    url JCENTER_URL                }                google {                    url GOOGLE_URL                }                maven {                    url NEXUS_URL                }            }        }        repositories {            def JCENTER_URL = 'https://maven.aliyun.com/repository/jcenter'            def GOOGLE_URL = 'https://maven.aliyun.com/repository/google'            def NEXUS_URL = 'http://maven.aliyun.com/nexus/content/repositories/jcenter'            all { ArtifactRepository repo ->                if (repo instanceof MavenArtifactRepository) {                    def url = repo.url.toString()                    if (url.startsWith('https://jcenter.bintray.com/')) {                        project.logger.lifecycle "Repository ${repo.url} replaced by $JCENTER_URL."                        println("buildscript ${repo.url} replaced by $JCENTER_URL.")                        remove repo                    }                    else if (url.startsWith('https://dl.google.com/dl/android/maven2/')) {                        project.logger.lifecycle "Repository ${repo.url} replaced by $GOOGLE_URL."                        println("buildscript ${repo.url} replaced by $GOOGLE_URL.")                        remove repo                    }                    else if (url.startsWith('https://repo1.maven.org/maven2')) {                        project.logger.lifecycle "Repository ${repo.url} replaced by $REPOSITORY_URL."                        println("buildscript ${repo.url} replaced by $REPOSITORY_URL.")                        remove repo                    }                }            }            jcenter {                url JCENTER_URL            }            google {                url GOOGLE_URL            }            maven {                url NEXUS_URL            }        }    }}

三、最佳实践

  1. 多模块配置

    稍微大点的项目都会分模块开发,Gradle相比Maven的一个优势是用IDEA执行一个项目的代码时,会自动编译其依赖的其他模块。

    在项目一级目录下添加settings.gradle配置文件:

    rootProject.name = 'my-roject-name'include 'module1'include 'module2'

    其中module1、module2就是子模块的文件夹名,在子模块里需要有一个配置子模块的build.gradle

    模块内依赖,比如module2依赖module1,在module2的build.gradle配置文件里添加:

    dependencies {    implementation project(":module1")}
  2. profile支持

    profile用来加载不同环境的的配置文件,在笔者所在公司,推荐dev、test、staging、prod四套环境。

    添加gradle.properties配置文件指定默认profile

    profile=dev

    启动profile,加载不同路径下的配置,在build.gradle里添加配置:

    ext {    profile = project['profile']}sourceSets {    main {        resources {            srcDirs = ["src/main/resources/", "src/main/profiles/${profile}"]        }    }}

    命令行参数指定profile:

    gradle build -Pprofile=prod
  3. 初始化gradle项目

    安装gradle,并在项目顶级目录下执行:

    gradle init

    如果当前目录有pom.xml,接下来会提示是否从Maven项目初始化Gradle项目,选择yes回车执行。

    参数文档:https://docs.gradle.org/current/userguide/build_init_plugin.html

  4. 引入bom文件

    pom文件在Maven中是一个很有用的功能,方便多个项目统一版本号,在Maven中配置方式如下:

    <dependencyManagement>        <dependencies>            <dependency>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-dependencies</artifactId>                <version>${spring-boot.version}</version>                <type>pom</type>                <scope>import</scope>            </dependency>        </dependencies></dependencyManagement>

    Gradle有插件支持类似操作:

    //引入插件plugins {    id "io.spring.dependency-management" version "1.0.10.RELEASE"}//引入bomdependencyManagement {    imports {        mavenBom 'org.springframework.boot:spring-boot-dependencies:${spring-boot.version}'    }}//使用bom,不需要执行版本号dependencies {    implementation "org.springframework.boot:spring-boot-starter-web"}
  5. 引入parent文件

    在maven中执行parent是管理多个项目常用的方式,parent指定了项目的基本配置,配置方式如下:

    <parent>  <groupId>com.lesofn.blog</groupId>  <artifactId>test-parent</artifactId>  <version>0.0.1</version></parent>

    Maven中的parent有两个作用,一个是类似bom的统一管理版本,即parent中的:dependencyManagement,另一个是基础jar包的引用,即父pom中的:dependencies

    在Gradle可通过上面的io.spring.dependency-management插件实现类似的效果:

    //引入插件plugins {    id "io.spring.dependency-management" version "1.0.10.RELEASE"}//引入parent,实现maven中dependencyManagement的功能,不用指定版本号dependencyManagement {    imports {        mavenBom 'com.lesofn.blog:test-parent:0.0.1'    }}//再次引入parent,实现maven中dependencies的功能dependencies {    implementation 'com.lesofn.blog:test-parent:0.0.1'}

四、总结

经过以上配置,基本覆盖了Gradle开发过程中的大部分问题,如果还有什么问题,欢迎留言讨论。

本文作者:木小丰,美团Java高级工程师,关注架构、软件工程、全栈等,不定期分享软件研发过程中的实践、思考。

博客地址:https://lesofn.com/archives/gradle-zui-jia-shi-jian

公共号:Java研发

Java后端模板引擎对比

作者 木小丰
2020年12月14日 14:28

一、什么是模板引擎

模板引擎是为了解决用户界面(显示)与业务数据(内容)分离而产生的。他可以生成特定格式的文档,常用的如格式如HTML、xml以及其他格式的文本格式。其工作模式如下:

image-20201214142214449

二、java常用的模板引擎有哪些

jsp:是一种动态网页开发技术。它使用JSP标签在HTML网页中插入Java代码。

Thymeleaf : 主要渲染xml,HTML,HTML5而且与springboot整合。

Velocity:不仅可以用于界面展示(HTML.xml等)还可以生成输入java代码,SQL语句等文本格式。

FreeMarker:功能与Velocity差不多,但是语法更加强大,使用方便。

三、常用模板引擎对比

由于jsp与thymeleaf主要偏向于网页展示,而我们的需求是生成java代码与mybatis配置文件xml。顾这里只对Velocity与FreeMarker进行对比。

示例:1万次调用动态生成大小为25kb左右的mybatisxml文件

1、Velocity 模板文件

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="${mapperName}">    #foreach($map in $methodList)        #if(${map.sqlType} == "select")            <select id="${map.methodName}" resultType="${map.type}">                ${map.desc}            </select>            #elseif(${map.sqlType} == "insert")                <insert id="${map.methodName}" resultType="${map.type}">                    ${map.desc}                </insert>            #else        #end    #end</mapper>

2、Velocity java执行代码

public class VelocityTest {    public static void main(String[] args) {        //得到VelocityEngine        VelocityEngine ve = new VelocityEngine();        //得到模板文件        ve.setProperty(Velocity.FILE_RESOURCE_LOADER_PATH, "/Users/huhaiquan/project/database-proxy/database-proxy-server/src/test/resources");        ve.init();        Template template = ve.getTemplate("velocity.vm", "UTF-8");        VelocityContext data = new VelocityContext();        data.put("mapperName", "com.xxx.mapperName");        List<Map> methodList = DataUtils.createData(200);        data.put("methodList", methodList);        //        try {            //生成xml            //调用merge方法传入context            int num = 1;            int total=10000;            for (int i=0;i<num;i++){                StringWriter stringWriter = new StringWriter();                long curr = System.currentTimeMillis();                template.merge(data, stringWriter);                long end = System.currentTimeMillis();                total+=(end-curr);            }            System.out.println("total="+total+",vag="+(total*1f/num));        } catch (Exception e) {            e.printStackTrace();        }    }}

3、FreeMarker 模板文件

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="${mapperName}"><#list methodList as method>    <#if "${method.sqlType}" =="select">     <select id="${method.methodName}"  resultType="${method.type}">         ${method.desc}     </select>    <#elseif "${method.sqlType}" == "insert">      <insert id="${method.methodName}" resultType="${method.type}">          ${method.desc}      </insert>    </#if></#list></mapper>

4、FreeMarker 执行代码

public class FreeMTest {    public static Template getDefinedTemplate(String templateName) throws Exception{        //配置类        Configuration cfg = new Configuration(Configuration.VERSION_2_3_22);        cfg.setDirectoryForTemplateLoading(new File("/Users/huhaiquan/project/database-proxy/database-proxy-server/src/test/resources/"));        cfg.setDefaultEncoding("UTF-8");        cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);        return cfg.getTemplate(templateName);    }    public static void main(String[] args){        Map<String,Object> data = new HashMap<>();        data.put("mapperName", "com.xxx.mapperName");        List<Map> methodList =DataUtils.createData(200);        data.put("methodList", methodList);        try {            Template template = getDefinedTemplate("freemarker.ftl");            long total = 0;            int num = 10000;            for (int i=0;i<num;i++){                StringWriter stringWriter = new StringWriter();                long curr = System.currentTimeMillis();                template.process(data,stringWriter);                long end = System.currentTimeMillis();                total+=(end-curr);            }            System.out.println("total="+total+",vag="+(total/num));        } catch (Exception e) {            e.printStackTrace();        }    }}

四、特性对比

项目名称版本10000次执行耗时社区支持文件语法功能Velocity2.112833ms较差较少简单,接近java一般FreeMarker2.3.284599ms较好较多简单强大,在日期、数字,国际化方面有健全的处理机制。

结果:虽然网上对比结果一致为Velocity的性能高于FreeMarker,但是我的测试结果却完全相反,可能跟版本有关。语法方面,Velocity更接近java语法,学习成本低,FreeMarker本身提供的语法也相对简单。FreeMarker在社区支持,功能方面要比Velocity强大的多。

五、参考:

https://www.runoob.com/jsp/jsp-tutorial.html
https://www.thymeleaf.org/
https://blog.csdn.net/xiang__liu/article/details/81160766
http://freemarker.foofun.cn/
https://www.iteye.com/blog/lishumingwm163-com-933365

漫谈分层架构

作者 木小丰
2020年12月12日 22:34

1、为什么要分层

  • 高内聚:分层的设计可以简化系统设计,让不同的层专注做某一模块的事

  • 低耦合:层与层之间通过接口或API来交互,依赖方不用知道被依赖方的细节

  • 复用:分层之后可以做到很高的复用

  • 扩展性:分层架构可以让我们更容易做横向扩展

如果系统没有分层,当业务规模增加或流量增大时我们只能针对整体系统来做扩展。分层之后可以很方便的把一些模块抽离出来,独立成一个系统。

2、传统MVC架构

优点:关注前后端分离

缺点:模型层分层太粗,融合了数据处理、业务处理等所有的功能。核心的复杂业务逻辑都放到模型层,导致模型层很乱

适应场景:后端业务逻辑简单的服务,比如接口直接提供对数据库增删改查

3、后端三层架构

定义:

  1. 表现层:controller
  2. 逻辑层:service
  3. 数据访问层:dao

优点:逻辑与数据层分离

缺点:模型层分层比较粗,核心的复杂业务逻辑都放到模型层,导致模型层很乱

适应场景:后端业务逻辑简单的服务,比如接口直接提供对数据库增删改查

242694795

4、阿里分层架构

架构来源:参照参照阿里发布的《阿里巴巴 Java 开发手册 v1.4.0(详尽版)》,将原先的三层架构细化而来

特点:添加了Manager 通用业务处理层。

这一层有两个作用,一、可以将原先 Service 层的一些通用能力下沉到这一层,比如与缓存和存储交互策略,中间件的接入;二、也可以在这一层封装对第三方接口的调用,比如调用支付服务,调用审核服务等RPC接口。

优点:相比于三层方式,添加了通用处理层对接外部平台。 上下游对接划分的比较清晰

缺点:核心业务逻辑层没有划分

适应场景:业务逻辑不复杂的常用业务

242694789

5、DDD分层架构

(1)特点

  1. 数据、缓存等都视为基础层, 可以被所有层调用
  2. 抽离了领域层,负责核心业务逻辑处理,领域层调用外部依赖全部通过接口,以保证领域层的100%单测覆盖率
  3. 应用层聚合多个领域层的能力,只做功能的组合、转发,不负责具体业务逻辑

优点:相比于三层方式,更关注领域服务,即业务核心逻辑的划分、收敛

缺点:分层复杂, 如果业务逻辑简单没有必要

适应场景:业务复杂的业务

501368404

(2)和传统三层架构的对比

DDD四层架构也基于传统三层架构的,不同点有以下几方面:

  1. 关注点不一样:三层架构关注请求调用顺序;DDD架构关注领域服务。
  2. 横向划分方式不一样:三层架构主要关注纵向划分,对横向划分没有约定;DDD架构更关注纵向,即:多个领域层之间划分及交互方式。
  3. 对资源的定位不一样:三层架构把所有依赖的数据都放到数据访问层;DDD架构只将领域强关联的数据放到Repository中,其他比如API层缓存、文件等都当成基础服务来处理。

501502253

6、整洁架构和六边形架构

整洁架构和六边形架构都是DDD架构的一种方式,只不过是视角不同。

(1)整洁架构

特点:整洁架构的层就像洋葱片一样,它体现了分层的设计思想

整洁架构最主要的原则是依赖原则,它定义了各层的依赖关系,越往里依赖越低,代码级别越高,越是核心能力。外圆代码依赖只能指向内圆,内圆不需要知道外圆的任何情况。

501581545

(2)六边形架构

六边形架构又名“端口适配器架构”。追溯微服务架构的渊源,一般都会涉及到六边形架构。

六边形架构的核心理念是:应用是通过端口与外部进行交互的。我想这也是微服务架构下API网关盛行的主要原因吧。

也就是说,在下图的六边形架构中,红圈内的核心业务逻辑(应用程序和领域模型)与外部资源(包括APP、Web应用以及数据库资源等)完全隔离,仅通过适配器进行交互。它解决了业务逻辑与用户界面的代码交错问题,很好地实现了前后端分离。六边形架构各层的依赖关系与整洁架构一样,都是由外向内依赖。

501502464

7、汇总

本文汇总了传统MVC架构、后端三层架构、阿里分层架构、DDD架构以及基于DDD架构的整洁架构和六边形架构。从前往后越来越复杂,其他也对应着软件工程的越来越复杂,架构模式也变的越来越复杂。软件架构领域没有一招鲜吃遍天的功法,针对的不同的业务场景采用不同的架构,并且随着业务的发展,不断调整架构以适应业务的发展,以变(架构、技术组件、重构等)应不变(业务发展、用户体验、稳定性等)才是一个合格的软件工程师应追求的境界。

声明
本博客内容均为原创,授权公共号:肉眼品世界 首发。博客链接

作者简介

美团Java高级工程师,关注软件架构及职业成长,不定期分享各种技术、资源,对文章中涉及的技术感兴趣或有任何问题请关注微信交流。

公共号:Java研发

Java不同版本编译器踩坑

作者 木小丰
2020年12月1日 10:20

升级一个jar包新版本时, 启动tomcat发现一个很奇怪的错误,

java.lang.NoSuchMethodError: java.util.concurrent.ConcurrentHashMap.keySet() Ljava/util/concurrent/ConcurrentHashMap$KeySetView;

NoSuchMethodError这种错误基本属于jar包版本问题, 我本机使用的是jdk1.7.
查看jar包的class文件.

major版本是50, 对应jdk1.6.

可以看到该class文件中keySet()方法的返回值确实是ConcurrentHashMap$KeySetView.

问题产生的原意基本可以确定是作者用jdk1.8编译了class文件, 但是把source, target参数指定成了1.6. 此处要注意javac -target参数只保证制定版本的jdk可以加载class文件(major version会被编译成指定版本的jdk, 此处1.6对应50),并不保证能正确运行.

❌
❌