一、为什么会写这篇博客文章
从学校毕业成为一名Coder也有几年的时间了,期间也接手过好几个项目,也新启个好几个项目。接手的项目能明显感觉到不同的维护人员都有着自己独特的编码风格;新启动的项目在稍微不关注几个月之后也变得面目全非。很少能有项目维护的久了也给人一种耳目一新、一脉传承的感觉。
每一个项目,都像是自己看大的孩子,也许比喻为”心爱的宠物”更好一点,毕竟一个项目工程的生命周期与人的生命比起来,还是太短了。总想着让它长得更强壮、更健硕、更美丽、更讨人喜欢。
二、约定
公司里以Spring Cloud全家桶技术栈为主,所以以下的讨论与描述中不会单独涉及到RPC调用的部分。其实也可以触类旁通,稍加变化就能用到PRC的框架项目中。
具体的内容也只是罗列以下,并简单的加以说明。按需自取、自由组合。用到的功能、包结构则用,用不到的直接忽略掉即可。
三、模块划分
1.common模块
此模块下不应有任何的main入口方法。 定义公共的工具方法、枚举、异常类。 此common仅仅能被被项目下的其他模块直接依赖,不应被除本项目之外的任务项目直接依赖。 若确实需要依赖,则应依赖方与被依赖方的关系,是否合适?是否可以倒置依赖?是否需要将公共的部分提取到单独的项目中,作为公共的二方或三方依赖?
2.consumer模块
此模块下有且只有一个main入口方法。
作为项目的消息消费者模块,根据需要可以依赖common、interface、model、sdk模块。依赖common模块是方便使用本项目下统一的工具方法、枚举、异常等;依赖interface模块与sdk模块是方便直接调用的service模块中的方法;依赖model模块是方便使用本项目下统一的模型、枚举等。
根据划分设计思想,consumer模块可直接调用service模块的服务接口,也可以直接实现业务逻辑。具体情况需要具体分析。原则思路:业务逻辑强相关的应由service模块统一提供实现,consumer仅调用service模块提供的方法接口。业务逻辑不强相关的由consumer模块直接实现,便于维护与分散运行资源的压力。
或者将consumer模块最为一个简单的包装层,将service模块作为consumer模块的依赖,将service模块的代码在consumer模块的实例上单独部署一份,consumer模块中的定时任务以纯本地方法调用的方式,不使用任何的RPC框架,直接调用service模块中的方法。换句话说:提供web服务的service单独部署一套实例,提供消息消费服务的service直接部署到consumer的实例上。这样即将主要逻辑代码统一到service模块中统一维护,又在实例级别隔离了consumer服务与web服务,防止consumer任务的执行侵占了web服务的资源,造成web服务相应不及时的问题。
3.interface模块
此模块下不应有任何的main入口方法。
统一管理项目提供的服务接口,不应依赖model模块将本项目内部的模型暴露出去,应维护自己的接口model模型,并对外暴露。
4.model模块
此模块下不应有任何的main入口方法。
统一管理项目下使用的模型等POJO对象。此处POJO对象不再是只有getter/setter的贫血简单对象,而是要具有一些业务无关的功能,例如:数据校验等。
对模型中属性字段值的合法性校验应在对应的POJO对象中提供校验方法,这样既可以在使用setter时快速失败,也方便在使用getter钱进行数据校验,而不需要编写业务无关的、冗余的代码。针对同一个模型在不同场景下的校验逻辑不同,应考虑是否在不同的场景下需要不同的POJO对象?若合适使用同一个POJO对象,则针对不同的场景对编写不同的校验方法,不同场景下的检验方法可以相互调用,即严格场景的检验在宽松场景校验下做增强。
依赖interface模块,并提供对应的bean转换方法,完成interface模块中的模型与model模块中模型的互相转换。
5.sdk模块
此模块下不应有任何的main入口方法。
对外提供服务调用客户端,仅依赖interface模块方便对其他服务或模块提供服务接口。
sdk不应向外暴露本项目内部使用的模型、枚举等,只能暴露在interface模块中定义的仅在接口中使用的模型、枚举。
6.service模块
此模块下有且只有一个main入口方法。
依赖interface模块实现服务接口的具体逻辑;依赖model、common模块,使用本项目下的模型、枚举、异常等。 具体且主要实现本项目的业务逻辑。
7.task模块
此模块下有且只有一个main入口方法。
作为项目的定时任务模块,根据需要可以依赖common、interface、model、sdk模块。依赖common是方便使用本项目下统一的工具方法、枚举、异常等;依赖interface与sdk是方便直接调用的service 中的方法;依赖model是方便使用本项目下统一的模型、枚举等。
根据划分设计思想,consumer模块可直接调用service的服务接口,也可以直接实现业务逻辑。具体情况需要具体分析。原则思路:业务逻辑强相关的应由service统一提供实现,consumer仅调用service提供的方法接口。业务逻辑不强相关的由consumer直接实现,便于维护与分散运行资源的压力。
或者将task模块最为一个简单的包装层,将service模块作为task模块的依赖,将service模块的代码在task模块的实例上单独部署一份,task模块中的定时任务以纯本地方法调用的方式,不使用任何的RPC框架,直接调用service模块中的方法。换句话说:提供web服务的service单独部署一套实例,提供定时任务的service直接部署到task的实例上。这样即将主要逻辑代码统一到service模块中统一维护,又在实例级别隔离了task服务与web服务,防止task任务的执行侵占了web服务的资源,造成web服务相应不及时的问题。
四、主要包层级划分
所有包层级均基于基础包,故基础包路径不再单独列出。
1.common模块
1.abstracts包
放置本项目下多个模块都会使用或未来有计划在多个项目中使用的抽象接口、类。此包下的类定义应只具有规范约束的作用,不应含有任何逻辑代码,即使与业务无关(告警、日志)的代码。
2.constant包
放置常量定义。
3.exception包
放置自定义的公共异常类与本项目下多个模块都会用到的公共异常类。
4.enums包
放置本项目下多个模块都会用到的公共的枚举类。
5.util包
放置本项目下多个模块都会用到的公共的工具类。
2.consumer模块
1.cache
缓存功能实现。具体是使用redis还是memcache,不体现在包命名上,技术实现细节全部隐藏在包下。
2.config
放置各种配置类,包含但不限于:spring配置、mybatis配置、自定义配置项等。
3.exception
放置仅在service模块中使用的自定义异常类,所有的自定义异常累都应该继承common模块下自定义的基础异常类。
4.enums
放置consumer模块中单独使用的枚举类。
5.factory
放置工厂方法。 在此包下针对不同的业务场景、类型,创建不同的子包,在子包中编写具体的工厂类文件。
6.instance
放置消费者实现类,可根据业务划分,再划分更细致的子包。
7.mapper
mybatis持久化框架的接口,接口中提供以数据库为视角的接口,接口命名也以数据库视角出发,即提供对数据操作的CURD操作。
8.handler
包含两个子包:async与sync。
sync包下的类可以依赖async包下的类,应该避免async包下的类依赖sync包下的类。尽量避免循环依赖,但是业务场景必须要循环依赖,则也可以使用。此包及其子包在实现业务逻辑时,都不直接调用mapper包下的接口方法,而是调用processor包下封装的具有业务逻辑语义的方法。
9.handler.async
放置同步使用的业务逻辑方法,是对业务逻辑的集中实现,可由多个service或其他handler包及其子包下的方法调用,但是所有对此包下类中方法的调用方式都应该是同步的,即:不要将此包下类中的方法作为异步逻辑的起始方法,若要异步使用,请对其进行包装。
10.handler.sync
放置异步使用的业务逻辑,此包下的类中的方法作为异步调用的起始方法,若异步的逻辑在同步场景中有需要,则将公用的业务逻辑抽象出来放置在兄弟包“async”包下,在此包下调用兄弟包下类中的方法。
11.processor
对mapper中接口方法的封装,方法的命名从业务逻辑视角出发,命名规则以符合业务描述元语为出发点,将对数据库的CURD操作封装为在业务中有实际意义的方法。
12.proxy
包含子包:client。
其他服务的接口代理类,负责接口的入参的组装和出参的解析,以及其他服务接口出参到本项目内部模型的转换逻辑。
13.proxy.client
包含子包:interface
其他服务的接口客户端,主要用于放置Feign框架的client实现。
14.proxy.client.interface
其他服务的接口定义类,放置由于位提供SDK或依赖的Feign版本差异造成的feign客户端无法直接使用的场景,有本项目重写一遍被依赖服务的接口定义。
15.util
本项目服务的consumer模块使用到的工具方法,若需要拓展到其他模块,将类文件或方法迁移到common模块下再提供给本项目的其他模块使用。
3.interface模块
1.interfaces
放置当前项目的所有服务接口的定义,根据具体的业务场景可再细分不同的子包。
2.model
含有两个子包:request、response。
3.model.request
放置接口中使用的入参model。
4.model.response
放置接口中使用的出参model。
4.model模块
1.cache
缓存映射实体对象,可针对不同的场景划分在细分为不同的子包。
2.db
数据库映射实体对象,可针对不同的场景划分在细分为不同的子包。
3.resource
对象模型,业务逻辑中使用的对象模型,可针对不同的场景划分在细分为不同的子包。
5.sdk模块
1.client
本项目的服务接口对应的feign客户端,可针对不同的场景划分在细分为不同的子包,但子包的划分应与interface模块下接口定义的子包划分保持一致。
6.service模块
1.cache
缓存功能实现。具体是使用redis还是memcache,不体现在包命名上,技术实现细节全部隐藏在包下。
2.controller
interface包下接口的直接实现,但方法中不包含对业务逻辑的修改,只有调用model模块下方法将接口模型与业务模型相互转换的逻辑。
3.config
放置各种配置类,包含但不限于:spring配置、mybatis配置、自定义配置项等。
4.exception
放置仅在service模块中使用的自定义异常类,所有的自定义异常累都应该继承common模块下自定义的基础异常类。
5.enums
放置service模块中使用的枚举类。
6.factory
放置工厂方法。 在此包下针对不同的业务场景、类型,创建不同的子包,在子包中编写具体的工厂类文件。
7.mapper
mybatis持久化框架的接口,接口中提供以数据库为视角的接口,接口命名也以数据库视角出发,即提供对数据操作的CURD操作。
8.handler
包含两个子包:async与sync。
async包下的类可以依赖sync包下的类,应该避免sync包下的类依赖async包下的类。尽量避免循环依赖,但是业务场景必须要循环依赖,则也可以使用。此包及其子包在实现业务逻辑时,都不直接调用mapper包下的接口方法,而是调用processor包下封装的具有业务逻辑语义的方法。
单独一个handler子包,用来将复杂的业务逻辑提取出来,防止service包下的类及方法过去庞大。再有就是Spring的注解基于类级别方法进行代理,类内部的方法调用会造成基于代理实现的注解(例:@Transaction声明式事务注解)逻辑失效。使用单独的子包,及能解决这类的问题,又能便捷的对事务等进行精细化的控制操作。
9.handler.sync
放置同步使用的业务逻辑方法,是对业务逻辑的集中实现,可由多个service或其他handler包及其子包下的方法调用,但是所有对此包下类中方法的调用方式都应该是同步的,即:不要将此包下类中的方法作为异步逻辑的起始方法,若要异步使用,请对其进行包装。
10.handler.async
放置异步使用的业务逻辑,此包下的类中的方法作为异步调用的起始方法,若异步的逻辑在同步场景中有需要,则将公用的业务逻辑抽象出来放置在兄弟包“sync”包下,在此包下调用兄弟包下类中的方法。
11.processor
对mapper中接口方法的封装,方法的命名从业务逻辑视角出发,命名规则以符合业务描述元语为出发点,将对数据库的CURD操作封装为在业务中有实际意义的方法。
12.proxy
包含子包:client。
其他服务的接口代理类,负责接口的入参的组装和出参的解析,以及其他服务接口出参到本项目内部模型的转换逻辑。
13.proxy.client
包含子包:interface
其他服务的接口客户端,主要用于放置Feign框架的client实现。
14.proxy.client.interface
其他服务的接口定义类,放置由于位提供SDK或依赖的Feign版本差异造成的feign客户端无法直接使用的场景,有本项目重写一遍被依赖服务的接口定义。
15.service
本项目的业务逻辑实现,分为两种:
- 较为简单的逻辑实现,直接将逻辑代码写在service包下的类文件中。
- 较为复杂的逻辑实现,由单独的类文件(如handler的两个子包的类文件)承载具体的业务逻辑实现,service包下的类文件仅仅起到串联作用,将具体业务逻辑串联起来。
无论那种场景下的实现,service包下的类文件都不直接调用mapper包下的接口方法,而是调用processor包下封装的具有业务逻辑语义的方法。
16.util
本项目服务的service模块使用到的工具方法,若需要拓展到其他模块,将类文件或方法迁移到common模块下再提供给本项目的其他模块使用。
7.task模块
1.cache
缓存功能实现。具体是使用redis还是memcache,不体现在包命名上,技术实现细节全部隐藏在包下。
2.config
放置各种配置类,包含但不限于:spring配置、mybatis配置、自定义配置项等。
3.exception
放置仅在task模块中使用的自定义异常类,所有的自定义异常累都应该继承common模块下自定义的基础异常类。
4.enums
放置service模块中使用的枚举类。
5.factory
放置工厂方法。 在此包下针对不同的业务场景、类型,创建不同的子包,在子包中编写具体的工厂类文件。
6.mapper
mybatis持久化框架的接口,接口中提供以数据库为视角的接口,接口命名也以数据库视角出发,即提供对数据操作的CURD操作。
7.handler
包含两个子包:async与sync。
async包下的类可以依赖sync包下的类,应该避免sync包下的类依赖async包下的类。尽量避免循环依赖,但是业务场景必须要循环依赖,则也可以使用。此包及其子包在实现业务逻辑时,都不直接调用mapper包下的接口方法,而是调用processor包下封装的具有业务逻辑语义的方法。
单独一个handler子包,用来将复杂的业务逻辑提取出来,防止service包下的类及方法过去庞大。再有就是Spring的注解基于类级别方法进行代理,类内部的方法调用会造成基于代理实现的注解(例:@Transaction声明式事务注解)逻辑失效。使用单独的子包,及能解决这类的问题,又能便捷的对事务等进行精细化的控制操作。
8.handler.sync
放置同步使用的业务逻辑方法,是对业务逻辑的集中实现,可由多个service或其他handler包及其子包下的方法调用,但是所有对此包下类中方法的调用方式都应该是同步的,即:不要将此包下类中的方法作为异步逻辑的起始方法,若要异步使用,请对其进行包装。
9.handler.async
放置异步使用的业务逻辑,此包下的类中的方法作为异步调用的起始方法,若异步的逻辑在同步场景中有需要,则将公用的业务逻辑抽象出来放置在兄弟包“sync”包下,在此包下调用兄弟包下类中的方法。
10.processor
对mapper中接口方法的封装,方法的命名从业务逻辑视角出发,命名规则以符合业务描述元语为出发点,将对数据库的CURD操作封装为在业务中有实际意义的方法。
11.proxy
包含子包:client。
其他服务的接口代理类,负责接口的入参的组装和出参的解析,以及其他服务接口出参到本项目内部模型的转换逻辑。
12.proxy.client
包含子包:interface
其他服务的接口客户端,主要用于放置Feign框架的client实现。
13.proxy.client.interface
其他服务的接口定义类,放置由于位提供SDK或依赖的Feign版本差异造成的feign客户端无法直接使用的场景,有本项目重写一遍被依赖服务的接口定义。
14.jobs
本项目的业务逻辑实现,分为两种:
- 较为简单的逻辑实现,直接将逻辑代码写在jobs包下的类文件中。
- 较为复杂的逻辑实现,由单独的类文件(如handler的两个子包的类文件)承载具体的业务逻辑实现,service包下的类文件仅仅起到串联作用,将具体业务逻辑串联起来。 无论那种场景下的实现,jobs包下的类文件都不直接调用mapper包下的接口方法,而是调用processor包下封装的具有业务逻辑语义的方法。
15.util
本项目服务的jobs模块使用到的工具方法,若需要拓展到其他模块,将类文件或方法迁移到common模块下再提供给本项目的其他模块使用。
五、关于单元测试
编写的单元测试应该是无干预、可自动、可重复的。当代码逻辑发生变动时,单元测试也应该一起修改;当发生只需要修改逻辑代码但不需要修改单元测试代码的情况时,应考虑被测试的方法是否足够的简单,是否违反了单一职责等原则。
- 无干预:在单元测试开始前、进行中、结束时都不应该有人为操作的干预。
- 可自动:单元测试在进行时或编排时,应可以自动进行,单元测试可由人为手动触发,也可由工具自动触发。
- 可重复:单元测试应该是可重复的,编写完成且通过了的单元测试,无论运行多少次、换多少个换进运行,结果都应该是一致,但不要要求每次测试使用到的数据都是一致的,反而推荐使用动态数据:例如根据时间生成数据、使用随机数方法生成数据等。
针对所编写的每个类都应该提供对应的单元测试,简单的getter、setter、常量可以不提供单元测试,但只要有逻辑实现(业务的或者非业务的,所有模块下的类文件)都要提供单元测试,且要将代码行的覆盖率无限接近于100%。
编写单元测试时,不需要针对私有访问级别的方法单独提供单元测试,应该在当前类文件的非私用方法的时候一并对私有方法进行测试。
若在编写单元测试时,某个方法的单元测试需要mock的对象、方法过多,则应该考虑当前被测试方法的方法体是否过长?当前被测试方法中的逻辑是否还有可以被抽象、提取的地方?当前被测试方法中的逻辑写在当前的位置是否时最合适的,是否还可以划分出更细致的方法抽象huozhe包层级?
针对processor中的方法进行单元测试时,应直接测试到SQL的执行,即insert要将测试数据写入对应的表中,同时验证数据字段对应的是否正确;update要将测试数据的修改反应到对应的表中;delete要将表中对应的测试数据删除掉。其他方法以此类推。当涉及到与持久化组建交互获得数据的情况时,可以在单元测试的开始阶段先将本次单元测试用到的数据写入到数据库等持久化组件中,而后在测试方法中再使用刚刚生成的数据。
当写单元测试时,发现需要针对方法中后续的逻辑分支要编写重复且具有不同参数因子的前置逻辑模拟代码的时候,就应该考虑将被测试方法进行拆分,将关联性较强的逻辑代码封装为单独的方法。
六、下线
1.接口下线流程
- 业务业务下线或调整时,首先发送接口下线确认邮件,尽可能多的通知到每一个已知的调用方。
- 接口打deprecated标签,别写明下线原因。
- 综合时间与接口重要、安全程度,设置预下线时间,在此时间内观察生产环境流量,并将观察结果记录下来。若期间发现还有流量,应查明调用方,并与之沟通限期切花其他接口或停用(此限期原则上不应晚于设置的“预下线时间”)。若无流量接入,在预下线时间到来时再次发送邮件通知:接口逻辑下线。但接口定义依旧保留,并在接口文档中做隐藏处理。同时接口响应返回统一的接口下线的自定义异常。根据接口的被调用频次,在不少于一天的等待期后将接口定义删除。设置预下线时间与返回自定义异常,是防止有也无妨私自对接接口且未通知到接口服务的维护团队,给兄弟部分以反应时间。
- 当前接口所依赖的方法,在确认只是为当前接口设计的情况下或只有当前接口使用且短期未来无其他地方使用的情况下,根据“六.2 类、方法、代码块废弃流程”进行下线处理。
2.类、方法、代码块废弃流程
- 确认类、方法、代码块在逻辑中无显示的调用/适用方。
- 类、方法、代码块打deprecated标签。
- 设置预下线时间,在此时间之前,观察生产环境日志,主要为info日志,若代码中未打印日志可现添加相应的逻辑代码。防止在代码中使用反射等其他机制对要下线的目标代码进行类调用。
- 到达预下线时间后,将代码删除。
handler下为什么还要划分async与sync两个子包
基于Spring框架的项目在使用异步线程池的时候,直接使用@Async注解,若在一个类文件中既有同步方法又有异步方法,且形成了循环依赖,spring bean容器在注入时会触发异常Bean with name ‘*’ has been injected into other beans [, **********, **********, ********] in its raw version as part of a circular reference,造成项目启动失败。为防止这种情况的发生,也为了项目逻辑的维护,分为两个子包分别维护。