阅读视图

发现新文章,点击刷新页面。
🔲 ☆

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

目前大部分团队是使用的阿里巴巴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实践

🔲 ☆

错误码设计思考

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

本文中涉及的源码: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线程池进阶

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

一、线程池工作流程

以下是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的架构演进

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研发

☑️ ⭐

平台化建设思路浅谈

随着业务的不断发展,软件系统不可避免的走向熵增:复杂度越来越高、研发效率越来越差、稳定性逐渐降低等。这时抽象核心能力,走向平台化的道路成为很多系统的首要选择。笔者结合自己的经验,总结了平台化建设的几种思路,希望对大家建设平台化有所帮助。

平台化有以下优点

  • 复用性强:复用核心逻辑,业务功能只在平台之上的业务层建设,降低建设成本;
  • 研发效率高:平台服务作为通用能力基建,业务只需要关注需求,不用关心平台底层复杂能力实现;
  • 降低复杂性:平台都有合理的职责边界和模块划分,对外开发的接口也都直观简洁;
  • 稳定性:平台服务的稳定性是重中之重,一般有专门的团队维护,稳定性比一般的业务系统强;

平台化建设几种方式

1、嵌入式

平台提供类似容器的功能,业务方以Jar包形式嵌入到平台当中,类似于传统的多个war包部署在tomcat中。这种实现方式平台提供通用能力接口和业务扩展点,业务方实现业务扩展点来实现业务逻辑。一般有统一的入口(比如tomcat提供的域名+端口),根据租户标识来区分业务方(比如tomcat的serverPath),平台底层的存储及模型中也都有租户ID标识。

image

优势:

  • 运维: 平台统一运维,业务方工作量降低;
  • 对外接口:对外统一接口,调用者的工作量会降低;

劣势:

  • 业务方功能受限:一般不能做重量级任务,平台以扩展点方式提供给业务方扩展,除此之外的能力都应该被限制;
  • jar包冲突、类冲突问题:平台本身包含了很多依赖,业务方jar包也会有很多依赖,如果有冲突会导致整个平台不可用,下文会介绍几种规避方法;
  • 业务隔离性差:不同业务方之间可能相互影响;

处理业务隔离的常用方案:

  1. 每个业务方提供一个集群;
  2. 使用类加载器隔离jar包,但可能依然解决不了jar包冲突的问题;
  3. 业务方提供fatjar,更改所有依赖包的package路径,比如MavenShadePlugin插件;

2、接口依赖式

平台也可以通过远程依赖的形式来整合业务的功能。这样能避免jar包冲突、业务功能受限等问题。此方案也会有一些限制,比如原jar包依赖的方式都是本地调用,现在都是远程调用,对性能、事务保证等都提出了新的挑战;需要保证接口的兼容性;平台与业务的交互由原来对象交互变成RPC接口,设计到编解码等;

这种方案适合平台与业务层交互较少、扩展点比较固定的场景,比如API渲染服务,平台提供渲染模板接口,业务方实现接口填充字段。

image

优势:

  • 隔离性:平台和业务完全隔离;
  • 业务方方便整合其他业务:平台扩展点只是作为业务方的一种能力,可以在已有的服务上提供;

劣势:

  • 接口变更复杂:如果要变更接口,所有业务方都需要迭代;
  • 交互复杂:都是通过RPC交互,一些扩展字段需要编解码成String传输;
  • 平台方兜底:如果业务方服务异常,平台方需要提供限流、降级、兜底的能力;

3、中台式

上面讲到两种模式都是以平台为主,对上层来说都是感知的平台,适合交互接口比较固定的场景,对交互差异性大的业务不是很适合。中台式的思路是提供业务通用能力,业务方基于中台能力快速开发自己的业务,并独立提供服务或页面。

image

中台和平台的区别:

  • 视角不同:平台关注的是去重、整合;中台关注的是复用;
  • 价值体现:平台直接对外提供服务,是一个功能大集合;中台是其他产品的一部分,为了其他产品更好的提供服务;

优势:

  • 能力聚焦:只需要提供核心能力支撑,不关心和用户交互;
  • 复用性更强:平台不依赖业务的扩展点,而只是业务方到平台的单向依赖;

劣势:

  • 个性化能力弱:因为没有扩展点,只提供通用能力;

平台化建设常用模式

1、DSL领域特性语言

DSL(Domain Specific Language)是针对某一领域,具有受限表达性的一种计算机程序设计语言。 DSL 具备强大的表现力,常用于聚焦指定的领域或问题。

在平台化建设中,DSL一般用来屏蔽平台复杂的业务逻辑,以DSL的形式对业务方暴露简洁能力接口。

比如非常有名的Gradle,就是一种DSL表达,具有比Maven更灵活的特性,关于如何构建DSL,请参考作者博客:使用Groovy构建DSL

2、Specification规约模式

Specification 模式用于解决「业务规则」相关的复杂性。

什么是业务规则呢?比如电商业务场景中需要判断:账户有效状态、是否是VIP、活动价有效期、账户余额等。在常规的代码开发中,有三种处理方式:

  • 在业务流程代码中case by case的编写;缺点是会导致能力复用性、可维护性越来越差;
  • 新建静态类,比如OrderValidator、TimeValidator等,缺点是处理组合逻辑(and、or)力不从心;
  • 在模型类中校验,缺点是类中会掺杂越来越多的非领域逻辑,这种逻辑太多会掩盖业务核心的业务规则;

Specification模式认为校验逻辑都是“动作”,需要单独建模,且模型都是值对象,接口通用模式如下:

public interface Specification<T> {    boolean isMatch(T domainObject);}

通过实现 Specification 接口,我们可以对不同的领域对象扩展不同的校验逻辑,而这些类都是可以复用的。

同时这些 Specification 可以作为基础元素进行任意的组合,组合更为复杂的校验规则与筛选逻辑。

当然Specification 不仅仅适用于过滤数据,它的核心是组装业务规则。例如 Spring Data JPA 提供了基于 JPA 的 Specification 模式的查询功能,使用起来非常方便,以下是一个示例:

 public List<Student> getStudent(String studentNumber, String name) {        Specification<Student> specification = new Specification<Student>(){            @Override            public Predicate toPredicate(Root<Student> root, CriteriaQuery<?> query, CriteriaBuilder cb) {                //用于暂时存放查询条件的集合                List<Predicate> predicatesList = new ArrayList<>();                //equal                if (!StringUtils.isEmpty(name)){                    Predicate namePredicate = cb.equal(root.get("name"), name);                    predicatesList.add(namePredicate);                }                //like                if (!StringUtils.isEmpty(nickName)){                    Predicate nickNamePredicate = cb.like(root.get("nickName"), '%'+nickName+'%');                    predicatesList.add(nickNamePredicate);                }                //最终将查询条件拼好然后return                Predicate[] predicates = new Predicate[predicatesList.size()];                return cb.and(predicatesList.toArray(predicates));            }        };        return repository.findAll(specification);    }

3、异构

平台提供的通用能力如果不能直接满足业务的需求,需要提供扩展能力以适配业务模型来达到异构的目的。支持业务扩展模型一般有以下几种方式:

  • String ext
  • Map<String,String> ext
  • Class:一般用于嵌入jar式

还有另外一个问题需要解决,平台作为通用能力,有平台自身的模型,如何将平台模型转换为业务模型?简单的做法是作为扩展点开放给业务方实现,不过作为业务方,应该关注的是业务模型,平台模型有自己的规则且平台为了通用化,模型都会非常复杂。

一个更完善的平台应该支持更灵活的异构模型支持,常用是方案是字段配置化:

  1. 在平台申请一个模型的定义,一般包括类型,长度,限制规则等(比如必须是正整数);
  2. 业务方配置字段和平台模型的映射关系,如果需要动态能力,可提供Groovy或Aviator等脚本支持;
  3. 转换为业务方模型:根据用户配置,自动转换并给用户返回业务模型;
  4. 转化为平台模型:参数中需要传入元数据类名,平台按照配置规则进行有效性校验;如果需要自动转化,则需要在配置服务支持双向映射

4、统一存储

平台除了平台通用模型的存储支持,还需要支持不能转换为平台模型业务模型存储。有以下几种方案:

  1. 宽表:采用列式存储引擎,可方便的创建、修改列,缺点是常用的列式存储引擎一般不能提供的很好的事务支持;
  2. 列转行:数据表只有三列:id、key、value,查询及存储时在repository层进行转换,缺点是不能join、不支持修改列;适用于不太复杂的业务场景;
  3. 元数据:完全接管数据库操作,根据不同字段格式自动存储到不同列,完善的元数据平台还支持分库分表、扩缩容、数据迁移等能力,建设成本最高;

总结

平台化建设是一个非常复杂的工程,涉及的业务方、方案选择比较多,难点和投入成本也都差异较大,没有一套完美的方案能覆盖所有业务场景,本文提供了几种参考方案和设计模式,具体的方案还需要读者结合自己的业务场景来挑选最适合自己的方案。

作者简介:木小丰,美团Java技术专家,专注分享软件研发实践、架构思考。欢迎关注公共号:Java研发
本文链接:平台化建设思路浅谈

🔲 ☆

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

构建可回滚的应用及上线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依赖冲突问题排查经验

一、背景

在日常的开发中,排查问题是一个合格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研发

☑️ ☆

升级Java17问题记录

最新的长期支持版Java17于2021年9月14日如期发布,按照发布规划,JDK/Java 17 属于长期支持版本 (LTS),将会获得 8 年的技术支持,直至 2029 年 9 月。值得一提的是,根据 Oracle 最新推出的**「Free Java License」**,Oracle JDK 可免费用于生产环境。

Java各版本新特性请查看以下系列文章:Java17的新特性

原文链接,转载请注明出处

1、Lombok报错

错误日志:

class lombok.javac.apt.LombokProcessor (in unnamed module @0x3b968111) cannot access class com.sun.tools.javac.processing.JavacProcessingEnvironment (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.processing to unnamed module @0x3b968111

错误原因没有深入分析,直接升级到最新版本解决:

<dependency>    <groupId>org.projectlombok</groupId>    <artifactId>lombok</artifactId>    <version>1.18.20</version>    <scope>provided</scope></dependency>

2、模块化(Jigsaw)报错

错误日志:

module java.base does not "opens java.lang" to unnamed module @1941a8ff

错误原因是在Java9中引入了模块化功能:The State of the Module System,常见的库比如(Spring、Hibernate、JAXB)大量用到包扫描和反射,所以常出现此错误。一个粗暴的解决办法是将没开放的module强制对外开放,即保持和Java9之前的版本一致。

开放模块有以下两种模式:

  • --add-exports 该包将被导出,这意味着在编译和运行时可以访问其中的所有公共类型和成员。
  • --add-opens 包被打开,这意味着所有类型和成员(不仅仅是公共成员!)都可以在运行时访问。

如果使用的代码中使用了诸如setAccessible(true)的方法,就选择 --add-exports。想了解更多请参考:What's the difference between --add-exports and --add-opens in Java 9?

针对这个错误,Java命令行添加以下命令:

--add-opens java.base/java.lang=ALL-UNNAMED

改完后的效果类似这样:

java --add-opens java.base/java.lang=ALL-UNNAMED -jar demo.jar

3、zookeeper连接报错

错误日志:

org.apache.zookeeper.ClientCnxn - Session 0x0 for server 10.0.*.*/<unresolved>:2181, unexpected error, closing socket connection and attempting reconnectjava.nio.channels.UnresolvedAddressException: nullat sun.nio.ch.Net.checkAddress(Net.java:149) ~[?:?]at sun.nio.ch.Net.checkAddress(Net.java:157) ~[?:?]at sun.nio.ch.SocketChannelImpl.checkRemote(SocketChannelImpl.java:816) ~[?:?]at sun.nio.ch.SocketChannelImpl.connect(SocketChannelImpl.java:839) ~[?:?]at org.apache.zookeeper.ClientCnxnSocketNIO.registerAndConnect(ClientCnxnSocketNIO.java:277) ~[zookeeper-3.4.13.jar:3.4.13-2d71af4dbe22557fda74f9a9b4309b15a7487f03]at org.apache.zookeeper.ClientCnxnSocketNIO.connect(ClientCnxnSocketNIO.java:287) ~[zookeeper-3.4.13.jar:3.4.13-2d71af4dbe22557fda74f9a9b4309b15a7487f03]at org.apache.zookeeper.ClientCnxn$SendThread.startConnect(ClientCnxn.java:1021) ~[zookeeper-3.4.13.jar:3.4.13-2d71af4dbe22557fda74f9a9b4309b15a7487f03]at org.apache.zookeeper.ClientCnxn$SendThread.run(ClientCnxn.java:1064) [zookeeper-3.4.13.jar:3.4.13-2d71af4dbe22557fda74f9a9b4309b15a7487f03]

原因分析:

Java15中InetSocketAddressHolder的toString方法有重构:

image-20210918202119549

下面是之前的版本:

image-20210918202301690

而在zookeeper的3.4的版本中直接使用 InetSocketAddress的toString()方法,因为Java新版本的hostname中加入/后缀导致创建连接失败。

修复方案是升级zookeeper的jar包为大于等于3.5的版本

<dependency>    <groupId>org.apache.zookeeper</groupId>    <artifactId>zookeeper</artifactId>    <version>3.5.9</version>    <exclusions>        <exclusion>            <groupId>org.slf4j</groupId>            <artifactId>*</artifactId>        </exclusion>        <exclusion>            <groupId>log4j</groupId>            <artifactId>*</artifactId>        </exclusion>    </exclusions></dependency>

4、总结

本博客总结了Java11升级到Java17过程中遇到的问题,如果读者现在使用的是Java8,可以参考作者文章:JDK8升级JDK11过程记录,你在升级Java新版本过程中还遇到过什么问题? 欢迎留言讨论。

附录:

Oracle JDK下载链接:https://www.oracle.com/java/technologies/downloads/

OpenJDK下载链接:https://jdk.java.net/17/


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

☑️ ⭐

使用Groovy构建DSL

DSL(Domain Specific Language)是针对某一领域,具有受限表达性的一种计算机程序设计语言

常用于聚焦指定的领域或问题,这就要求 DSL 具备强大的表现力,同时在使用起来要简单。由于其使用简单的特性,DSL 通常不会像 Java,C++等语言将其应用于一般性的编程任务。

对于 Groovy 来说,一个伟大的 DSL 产物就是新一代构建工具——Gradle,接下来让我们看下有哪些特性来支撑Groovy方便的编写DSL:

一、原理

1、闭包

官方定义是“Groovy中的闭包是一个开放,匿名的代码块,可以接受参数,返回值并分配给变量

简而言之,他说一个匿名的代码块,可以接受参数,有返回值。在DSL中,一个DSL脚本就是一个闭包。

比如:

//执行一句话  { printf 'Hello World' }                                       //闭包有默认参数it,且不用申明      { println it }                   //闭包有默认参数it,申明了也无所谓                { it -> println it }          // name是自定义的参数名  { name -> println name }                  //多个参数的闭包{ String x, int y ->                                    println "hey ${x} the value is ${y}"    }

每定义的闭包是一个Closure对象,我们可以把一个闭包赋值给一个变量,然后调用变量执行

//闭包赋值def closure = {    printf("hello")}//调用closure()

2、括号语法

当调用的方法需要参数时,Groovy 不要求使用括号,若有多个参数,那么参数之间依然使用逗号分隔;如果不需要参数,那么方法的调用必须显示的使用括号。

def add(number) { 1 + number }//DSL调用def res = add 1println res

也支持级联调用方式,举例来说,a b c d 实际上就等同于 a(b).c(d)

//定义total = 0def a(number) {    total += number    return this}def b(number) {    total *= number    return this}//dsla 2 b 3println total

3、无参方法调用

我们结合 Groovy 中对属性的访问就是对 getXXX 的访问,将无参数的方法名改成 getXXX 的形式,即可实现“调用无参数的方法不需要括号”的语法!比如:

def getTotal() { println "Total" }//DSL调用total

4、MOP

MOP:元对象协议。由 Groovy 语言中的一种协议。该协议的出现为元编程提供了优雅的解决方案。而 MOP 机制的核心就是 MetaClass。

有点类似于 Java 中的反射,但是在使用上却比 Java 中的反射简单的多。

常用的方法有:

  • invokeMethod()
  • setProperty()
  • hasProperty()
  • methodMissing()

以下是一个methodMissing的例子:

detailInfo = [:]def methodMissing(String name, args) {    detailInfo[name] = args}def introduce(closure) {    closure.delegate = this    closure()    detailInfo.each {        key, value ->            println "My $key is $value"    }}introduce {    name "zx"    age 18}

5、定义和脚本分离

@BaseScript 需要在注释在自定义的脚本类型变量上,来指定当前脚本属于哪个Delegate,从而执行相应的脚本命令,也使IDE有自动提示的功能:

脚本定义abstract class DslDelegate extends Script {def setName(String name){        println name    }}

脚本:

import dsl.groovy.SetNameDelegateimport groovy.transform.BaseScript@BaseScript DslDelegate _setName("name")

6、闭包委托

使用以上介绍的方法,只能在脚本里执行单个命令,如果想在脚本里执行复杂的嵌套关系,比如Gradle中的dependencies,就需要@DelegatesTo支持了,@DelegatesTo执行了脚本里定义的闭包用那个类来解析。

上面提到一个DSL脚本就是一个闭包,这里的DelegatesTo其实定义的是闭包里面的二级闭包的格式,当然如果你乐意,可以无限嵌套定义。

//定义二级闭包格式class Conf{    String name    int age    Conf name(String name) {        this.name = name        return this    }    Conf age(int age) {        this.age = age        return this    }}//定义一级闭包格式,即脚本的格式String user(@DelegatesTo(Conf.class) Closure<Conf> closure) {    Conf conf = new Conf()    DefaultGroovyMethods.with(conf, closure)    println "my name is ${conf.name} my age is ${conf.age}"}//dsl脚本user{    name "tom"    age 12}

7、加载并执行脚本

脚本可以在IDE里直接执行,大多数情况下DSL脚本都是以文本的形式存在数据库或配置中,这时候就需要先加载脚本再执行,加载脚本可以通过以下方式:

 CompilerConfiguration compilerConfiguration = new CompilerConfiguration(); compilerConfiguration.setScriptBaseClass(DslDelegate.class.getName()); GroovyShell shell = new GroovyShell(GroovyScriptRunner.class.getClassLoader()); Script script = shell.parse(file);

给脚本传参数,并得到返回结果:

Binding binding = new Binding();binding.setProperty("key", anyValue);Object res = InvokerHelper.createScript(script.getClass(), binding).run()

二、总结

通过以上的原理,你应该能设计出自己的DSL了,通多DSL可以设计出非常简洁的API给用户,在执行的时候调用DSL内部的复杂功能,这些功能的背后逻辑隐藏在了自己编写的Delegate中。

为了加深印象,我写了个小的开源项目,把上面知识点串起来,构建了一个较完整的流程,如果还有什么不懂的地方,欢迎留言交流。

项目地址:https://github.com/sofn/dsl-groovy

三、参考

官方MOP:https://groovy-lang.org/metaprogramming.html

领域专属语言:https://wiki.jikexueyuan.com/project/groovy-introduction/domain-specific-languages.html

实战Groovy系列:https://wizardforcel.gitbooks.io/ibm-j-pg/content/index.html


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

博客:https://lesofn.com

公共号:Java研发

🔲 ⭐

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

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的操作

一、背景

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最佳实践

一、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后端模板引擎对比

一、什么是模板引擎

模板引擎是为了解决用户界面(显示)与业务数据(内容)分离而产生的。他可以生成特定格式的文档,常用的如格式如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

☑️ ☆

Git Commit Log规范推荐

一、背景

Git每次提交代码都需要写commit message,一般来说,commit message应该清晰明了,说明本次提交的目的,具体做了什么操作等。但是在日常开发中,开发者提交的的commit message千奇百怪,中英文混合使用,这就导致后续代码维护成本特别大,有时自己都不知道自己的fix bug修改的是什么问题。基于以上这些问题,我们希望通过某种方式来监控用户的git commit message,让规范更好的服务于质量,提高大家的研发效率。

二、约定

所有项目的Commit Log的格式精确控制,增加可读性,便于查看变更历史,形成良好的git使用习惯。规范作为git hook的commit-msg和pre-receive执行,不合法无法提交。全面执行后可自动化执行以下操作:

  • 平台工具包可根据commit log直接生成每次版本的changelog。
  • 上线申请系统自动附带本次上线的commit log。
  • 要求每次提交认真思考,保持commit log的整洁性,每次commit的局部完整性。

三、Commit Log Format

Commit Log包含三部分header、body、footer,其中header是必须的,格式固定,body在变更有必要详细解释时使用。

commit log 格式

Plain Text

<types>(<scopes>): <subject><空行><body><空行><footer>

注意:冒号后面必须有一个小写空格,types和scopes可为多个,中间用逗号分隔。

举例

  1. 仅header:
fix(service,dao): 修改产品类型时不过滤产品Type
  1. 仅header,涉及模块较多用*代替
refactor(*): 修改DTO模型前缀
  1. 有header和body
fix(language-service): Improve signature selection for pipes with argsPipes with arguments like `slice:0` or `slice:0:1` should not producediagnostic errors.
  1. 有header、body、footer
func(core,logic): 添加礼包审核添加商品编辑审核状态和回调,blablablablaPRD:https://km.sankuai.com/page/194127085

1、Type

英文,小写。必须为下列中一个或多个:

  • func: function,小功能。注意:feat改成func了,避免大家按feature这个大粒度来提交,期望是按小功能点分批提交,另外避免跟feature分支规范混淆。
  • fix: bug修复,包括编码过程中的逻辑修复,不特指线上bug修复
  • refactor: 重构代码,非bug修复和性能优化,包括编码过程中的代码结构调整,不特指重构项目
  • impr: improvement,小的代码设计改进
  • perf: 性能优化
  • apm: 仅监控打点、异常日志处理相关
  • chore: 无关紧要的改动,例如删除用不到的注解、调整日志内容等
  • jvm: 仅JVM参数变更
  • pom: 仅依赖和版本变化
  • conf: 仅配置变化,Spring配置、properties文件
  • docs: 仅文档变更
  • style: 代码格式调整,如import清理,代码格式化
  • test: 单测和自动化case相关
  • typo: 修复小的拼写错误
  • wip: work in progress,少用,用于开发中的不完整提交,新工程开始时偶尔使用

2、Scope

英文,小写。表示变更的包或模块范围,可多个组合,若涉及范围较大,可用 * 代替。各服务可以自行定义,组内同学可轻易理解。通用scope列表如下:

  • dto: dto结构变化
  • core: core包
  • service: service层代码
  • dao: dao层代码
  • sql: sql代码变更

除上述通用字段外,Scope中各方向可自行定义关键字。例如以下为商品平台中所定义字段:

  • price: 价格相关
  • stock: 库存相关
  • product: 商品相关
  • idl: IDL文件变化

3、Subject

中文。标题简述修改,结尾不要有句号。

4、Body

中文。修改的背景(为什么做这次修改),说明修改逻辑。

5、Footer

中文。可以放置需求wiki或task链接,对以后其他同学blame很有用。

四、规范校验

1、commit log正则表达式(持续集成工具会用到):

Java代码块

(^(\w+)\(([\w+,.\-_*]+?)\): .+(.|\n)*)|(^Automatic merge(.|\n)*)|(^Merge (.|\n)*)

2、本地卡控

  • 本地hook:可自行加一个git hook,确保不合法commit log格式无法提交,在自己的工程里执行:
#!/usr/bin/env pythonimport sys, os, refrom subprocess import check_outputcommit_msg_filepath = sys.argv[1]commit_type = sys.argv[2] if len(sys.argv) > 2 else ''branch = check_output(['git', 'symbolic-ref', '--short', 'HEAD']).strip()print "commit_type:", commit_typewith open(commit_msg_filepath, 'r+') as f:    content = f.read()    # ignore merge    if content.startswith('Merge'):        sys.exit(0)    result = re.match('(\w+)\(([\w+,.\-_*]+?)\): .+(.|\n)*)', content)    if result is None:        print "ERROR: commit msg not match pattern '<type>(<scope>): <subject>'\n\t%s" % content        sys.exit(1)    sys.exit(0)

然后在git仓库一级目录下执行:

mv commit-msg.txt .git/hooks/commit-msgchmod +x .git/hooks/commit-msg

五、声明

本博客内容均为原创,授权公共号:肉眼品世界 首发
原文链接:https://lesofn.com/archives/mei-tuan-c-o-m-m-i-t--l-o-g-gui-fan

作者简介

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

公共号:Java研发

☑️ ☆

系统设计之降低复杂性

人活着就是在对抗熵增定律,生命以负熵为生。——薛定谔

一、熵增定律

1、熵增定律

熵的概念最早起源于物理学,用于度量一个热力学系统的无序程度。热力学第二定律,又称“熵增定律”,表明了在自然过程中,一个孤立的系统总是从最初的集中、有序的排列状态,趋向于分散、混乱和无序;当熵达到最大时,系统就会处于一种静寂状态。

通俗的讲:系统的熵增过程,就是由原始到死亡的过程。“熵”是“活跃”的反义词,代表负能量。

非生命,比如物质总是向着熵增演化,屋子不收拾会变乱,手机会越来越卡,耳机线会凌乱,热水会慢慢变凉,太阳会不断燃烧衰变……直到宇宙的尽头——热寂。

2、软件系统的熵增

在软件开发、维护过程中。软件的生命力总是从最初的理想状态,逐步趋向于复杂、混乱和无序状态发展,直到软件不可维护而被迫下线或重构。这种损坏软件质量的因素的逐步增长,叫做软件的熵增现象,也即本文讨论的软件复杂性。

二、系统复杂性的表现

1、表象

  1. 代码混乱、新人不易上手
  2. 代码高度冗余,复用性低,开发效率低
  3. 扩展和修改困难,牵一发动全身
  4. 业务数据错乱
  5. 程序性能低下
  6. 系统难以移置
  7. BUG率居高不下
  8. 其它……

2、深层原因

(1)变更放大

复杂性的第一个征兆是,看似简单的变更需要在许多不同地方进行代码修改

(2)认知负荷

复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。较高的认知负担意味着开发人员必须花更多的时间来学习所需的信息,并且由于错过了重要的东西而导致错误的风险也更大。

(3)未知的未知

复杂性的第三个症状是,必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。

3、小结

复杂性的三种表现形式中,未知的未知是最糟糕的。一个未知的未知意味着你需要知道一些事情,但是你没有办法找到它是什么,甚至是否有一个问题。你不会发现它,直到错误出现后,你做了一个改变。更改放大是令人恼火的,但是只要清楚哪些代码需要修改,一旦更改完成,系统就会工作。同样,高的认知负荷会增加改变的成本,但如果明确要阅读哪些信息,改变仍然可能是正确的。对于未知的未知,不清楚该做什么,或者提出的解决方案是否有效。唯一确定的方法是读取系统中的每一行代码,这对于任何大小的系统都是不可能的。甚至这可能还不够,因为更改可能依赖于一个从未记录的细微设计决策。

三、复杂性的原因

复杂性是由两件事引起的:依赖性和模糊性。

1、依赖关系

依赖关系是软件的基本组成部分,不能完全消除。实际上,我们在软件设计过程中有意引入了依赖性。每次编写新类时,都会围绕该类的 API 创建依赖关系。但是,软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。

2、模糊性

当重要的信息不明显时,就会发生模糊。

一个简单的例子是一个变量名,它是如此的通用,以至于它没有携带太多有用的信息(例如,时间)。或者,一个变量的文档可能没有指定它的单位,所以找到它的惟一方法是扫描代码,查找使用该变量的位置。

晦涩常常与依赖项相关联,在这种情况下,依赖项的存在并不明显。例如,如果向系统添加了一个新的错误状态,可能需要向一个包含每个状态的字符串消息的表添加一个条目,但是对于查看状态声明的程序员来说,消息表的存在可能并不明显。

不一致性也是造成不透明性的一个主要原因:如果同一个变量名用于两个不同的目的,那么开发人员就无法清楚地知道某个特定变量的目的是什么。

3、依赖性和模糊性的积累

复杂性不是由单个灾难性错误引起的;它堆积成许多小块。单个依赖项或模糊性本身不太可能显着影响软件系统的可维护性。之所以会出现复杂性,是因为随着时间的流逝,成千上万的小依赖性和模糊性逐渐形成。最终,这些小问题太多了,以至于对系统的每次可能更改都会受到其中几个问题的影响。

四、降低复杂性的方法

1、日常开发留出一点战略规划时间

大多数程序员日常以战术编程的心态来进行软件开发。例如新功能或错误修复。乍一看,这似乎是完全合理的:还有什么比编写有效的代码更重要的呢?但是战术编程几乎不可能产生出良好的系统设计。

与之相对应的是战略规划,成为一名优秀的软件设计师的第一步是要意识到仅工作代码是不够的。尽管代码当然必须工作,但不应将“能跑通的代码”视为主要目标。战略设计的主要目标必须是制作出出色的设计,考虑后续的可维护性及扩展性。

战略性编程需要一种投资心态。尽管前提投入会比战术编程花费更多的时间,但随着系统的迭代,战略编程的优势就开始逐渐显现。

当然既然是投资,就要考虑投入产出比,不应该吹毛求疵,只要发现一点不合理的地方就整体大重构。推荐的方式小步快跑的方式,在日常开发中留出5%-10%的时间来做战略设计。

2、模块的设计

开发一个新模块,如果有不可避免的复杂性。两种设计思路哪个更好:1、应该让模块用户处理复杂性,2、应该在模块内部处理复杂性?如果复杂度与模块提供的功能有关,则第二个答案通常是正确的答案。

作为开发人员,很容易以相反的方式行事:解决简单的问题,然后将困难的问题推给其他人。如果出现不确定如何处理的条件,最简单的方法是引发异常并让调用方处理它。这样的方法短期内会使您的生活更轻松,但它们会加剧复杂性。大多数模块拥有的用户多于开发人员,因此此模块还会有许多人来维护。作为模块开发人员,您应该努力使模块用户的生活尽可能轻松,即使这对您来说意味着额外的工作。另一种更好的方法是,模块具有简单的接口比简单的实现更为重要。

模块是设计应该是深的,最好的模块是那些其接口比其实现简单得多的模块。这样的模块具有两个优点。1、一个简单的接口可以将模块强加于系统其余部分的复杂性降至最低。2、如果以不更改其接口的方式修改了一个模块,则该修改不会影响其他模块。如果模块的接口比其实现简单得多,则可以在不影响其他模块的情况下更改模块的许多方面。

3、如何编写注释

编写注释的原因是,使用编程语言编写的语句无法捕获编写代码时开发人员想到的所有重要信息。注释记录了这些信息,以便后来的开发人员可以轻松地理解和修改代码。注释的指导原则是,注释应描述代码中不明显的内容。

注释的最重要原因之一是抽象,其中包括许多从代码中看不到的信息。抽象的思想是提供一种思考问题的简单方法,但是代码是如此详细,以至于仅通过阅读代码就很难看到抽象。注释可以提供一个更简单,更高级的视图(“调用此方法后,网络流量将被限制为每秒 maxBandwidth 字节”)。即使可以通过阅读代码推断出此信息,我们也不想强迫模块用户这样做:阅读代码很耗时,并且迫使他们考虑很多不需要使用的信息模块。开发人员应该能够理解模块提供的抽象,而无需阅读其外部可见声明以外的任何代码。

4、重视命名

名称是一种抽象形式:名称提供了一种简化的方式来考虑更复杂的基础实体。良好的名字是一种文档形式:它们使代码更易于理解。它们减少了对其他文档的需求,并使检测错误更加容易。相反,名称选择不当会增加代码的复杂性,并造成可能导致错误的歧义和误解。

命名需满足以下几个要求:

(1)准确性

名称最常见的问题是名称太笼统或含糊不清。结果,读者很难说出这个名字指的是什么。读者可能会认为该名称所指的是与现实不符的事物,如上面的代码错误所示。考虑以下方法声明:

/** * Returns the total number of indexlets this object is managing. */int IndexletManager::getCount() {...}

术语“计数”太笼统了:计数什么?如果有人看到此方法的调用,除非他们阅读了它的文档,否则他们不太可能知道它的作用。像 getActiveIndexlets 或 numIndexlets 这样的更精确的名称会更好:因为使用这些名称,读者可能无需查看其文档就能猜测该方法返回的内容。

(2)一致性

在任何程序中,都会反复使用某些变量。例如,文件系统反复操作块号。对于每种常见用法,请选择一个用于该目的的名称,并在各处使用相同的名称。例如,文件系统可能总是使用 fileBlock 来保存文件中块的索引。一致的命名方式与重用普通类的方式一样,可以减轻认知负担:一旦读者在一个上下文中看到了该名称,他们就可以重用其知识并在不同上下文中看到该名称时立即做出假设。

一致性具有三个要求:

  1. 始终将通用名称用于给定目的;
  2. 除了给定目的外,切勿使用通用名称;
  3. 确保目的足够狭窄,以使所有具有名称的变量都具有相同的行为。

(3)通过命名能构建起系统功能图

选择名称的目标是在读者的脑海中创建一幅关于被命名事物的性质的图像。

一个好名字传达了很多有关底层实体是什么,以及同样重要的是,不是什么的信息。在考虑特定名称时,请问自己:“如果有人孤立地看到该名称,而没有看到其声明,文档或使用该名称的任何代码,他们将能够猜到该名称指的是什么?还有其他名称可以使画面更清晰吗?” 当然,一个名字可以输入多少信息是有限制的。更泛化一些,能根据几个名字构建起一个模块的视图,根据模块的名称构建起单个系统的视图,根据单个系统命名构建起整个业务的视图。

5、辅助工具

(1)敏捷开发

敏捷开发是一种软件开发方法,它是在 1990 年代末期出现的,其思想涉及如何使软件开发更加轻量,灵活和增量。

敏捷开发中最重要的元素之一是开发应该是渐进的和迭代的概念。在敏捷方法中,软件系统是通过一系列迭代开发的,每个迭代都添加并评估了一些新功能。

现代的敏捷开发思想已经融合进了devops、持续集成等工具当中,在企业中可以使用现成的工具来实现软件开发的快速验证及迭代,开源工具也有很多。比如Jenkins。

持续集成工具中集成了很多有用的工具,比如静态代码检查、单元测试、自动化测试、预合并、自动部署等功能。

(2)Coca

Coca是一个用于系统重构、系统迁移和系统分析的瑞士军刀。它可以分析代码中的 badsmell,行数统计,分析调用与依赖,进行 Git 分析,以及自动化重构等。

借助coca工具,可以实现快速掌握系统整体的情况,比如重点方法调用图、类之间关系、系统命名健康情况、代码质量评估等。

示例一:类依赖关系

android-gradle-elements

示例二:方法调用图

call_demo

示例三:变量命名出现次数统计

+------------------+--------+|      WORDS       | COUNTS |+------------------+--------+| context          |    590 || resolve          |    531 || path             |    501 || content          |    423 || code             |    416 || resource         |    373 || property         |    372 || session          |    364 || attribute        |    349 || properties       |    343 || headers          |    330 |+------------------+--------+

示例四:代码质量评估

+--------------------------------+-------+-----------------------+-------+-----------+|              TYPE              | COUNT |         LEVEL         | TOTAL |   RATE    |+--------------------------------+-------+-----------------------+-------+-----------+| Nullable / Return Null         |     0 | Method                |  1615 | 0.00%     || Utils                          |     7 | Class                 |   252 | 2.78%     || Static Method                  |     0 | Method                |  1615 | 0.43%     || Average Method Num.            |  1615 | Method/Class          |   252 |  6.408730 || Method Num. Std Dev / 标准差   |  1615 | Class                 | -     |  7.344917 || Average Method Length          | 13654 | Without Getter/Setter |  1100 | 12.412727 || Method Length Std Dev / 标准差 |  1615 | Method                | -     | 20.047092 |+--------------------------------+-------+-----------------------+-------+-----------+

五、参考资料

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

作者简介

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

公共号:Java研发

🔲 ⭐

漫谈分层架构

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不同版本编译器踩坑

升级一个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),并不保证能正确运行.

☑️ ☆

JDK8升级JDK11过程记录

1、引言

最新版本Java15都出来了,很多小伙伴还在使用Java8,当然JDK15不是长期支持版本,最新的长期支持版本是Java11,而下一个长期支持版本要等到2021年9月发布的Java17。最近把内部几个系统从Java8升级到了Java11,升级过程还是比较简单的。

img

Java11的新特性如下,最兴奋的功能是ZGC,相关资料请其他文档

img

注:以下教程基于Maven配置

2、准备工作

下载openjdk,这里推荐使用华为镜像:https://mirrors.huaweicloud.com/java/jdk/11.0.2+9/

然后导入到Idea中:

打开Project Structure,以此点击SDKs--> 加号 --> Add JDK 选择目录添加,当然也可以选第一个Download JDK直接添加,不过笔者网络不好没下下来。

image-20201114205403927

3、编译器支持

Maven支持Java11的的最低版本是3.5.4(该版本以后可以不用升级)

编译插件支持,设置完成后刷新Idea,会自动将当前项目设置成JDK11

 <plugin>    <groupId>org.apache.maven.plugins</groupId>    <artifactId>maven-compiler-plugin</artifactId>    <version>3.8.1</version>     <configuration>         <source>11</source>         <target>11</target>     </configuration> </plugin>

4、依赖升级

(1)额外依赖的的jar包

Java11中将一些包从标准JDK中移除,不引用可能会导致项目报错,

@Resource 注解支持:

<dependency>   <groupId>javax.annotation</groupId>   <artifactId>javax.annotation-api</artifactId>   <version>1.3.1</version></dependency>

jaxb支持:

JDK9以后的版本,模块化的概念去除了JAXB(默认没有加载),需做接入声明。

<!--  jdk11 jaxb模块引用 start  -->        <dependency>            <groupId>org.glassfish.jaxb</groupId>            <artifactId>jaxb-runtime</artifactId>            <version>2.3.2</version>        </dependency>        <dependency>            <groupId>javax.xml.bind</groupId>            <artifactId>jaxb-api</artifactId>        </dependency>        <dependency>            <groupId>com.sun.xml.bind</groupId>            <artifactId>jaxb-impl</artifactId>            <version>2.3.0</version>        </dependency>        <dependency>            <groupId>org.glassfish.jaxb</groupId>            <artifactId>jaxb-runtime</artifactId>            <version>2.3.0</version>        </dependency>        <dependency>            <groupId>javax.activation</groupId>            <artifactId>activation</artifactId>            <version>1.1.1</version>        </dependency>        <!--  jdk11 jaxb模块引用 end  -->

(2)项目中可能用到的jar包

Lombok:

首先把现在项目中所有的lombok依赖排除掉,通过Idea Maven Helper插件搜索:

473527222

然后引入lombok最新版本:

<dependency>    <groupId>org.projectlombok</groupId>    <artifactId>lombok</artifactId>    <version>1.18.14</version>    <scope>provided</scope></dependency>

Jacoco支持:

升级到最新版本,最低要求0.8.0

如果是用mavan plugin方式引入,修改pom.xml文件:

<plugin>    <dependency>        <groupId>org.jacoco</groupId>        <artifactId>jacoco-maven-plugin</artifactId>        <version>0.8.6</version>    </dependency></plugin>

如果是javaagent方式引入,从官网下载最新的包:https://www.eclemma.org/jacoco/

这里下载的是0.8.6.zip,解压开使用里面的/lib/jacocoagent.jar文件即可,JVM参数:

-javaagent:${WORK_PATH}/jacocoagent.jar=output=tcpserver,port=6300,address=*"

5、JVM日志参数

java11中将很多日志参数去掉了,比如以下日志参数失效:

GC_LOG="-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintAdaptiveSizePolicy \            -XX:+PrintGCApplicationStoppedTime -XX:+PrintHeapAtGC -XX:+PrintStringTableStatistics -XX:+PrintTenuringDistribution -Xloggc:$LOG_PATH/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=30 -XX:GCLogFileSize=50M"

新的jvm参数:

GC_LOG="-Xlog:gc:$LOG_PATH/gc.log"

6、IDEA可能会遇到的问题排查

常见的问题是引入了JDK11,但是编译器不支持Java11新语法,解决方案如下:

打开Preferences,配置Java Compiler中的Java版本号

image-20201114211447417

如果还不行,则打开Project Structure配置project和module的版本号,module的版本号理论上配置了maven compiler插件,会自动刷新

image-20201114211706680

image-20201114211810739

6、结语

至此,JDK8升级JDK11就完成了,你还遇到过什么问题,欢迎留言讨论

Enjoy~

关注作者二维码,第一时间获取最新文章

❌