一、项目
1.1 结构
项目结构示例如下:
src/java/
|-- aop AOP拦截器
|-- annoations 注解
|-- check 检查工具
|-- config 项目的配置信息
|-- constant 公共常量
|-- enums 枚举
|-- exception 异常
|-- handler 全局处理器
|-- interceptor 全局拦截器
|-- listener 全局监听器
|-- model pojo包
|-- |-- dto
|-- |-- |-- user
|-- |-- bo
|-- |-- |-- user
|-- |-- vo
|-- |-- |-- user
|-- mybatis mybais相关
|-- |-- types 自定义类型和处理器
|-- service 业务模块
|-- |-- user 用户模块接口
|-- |-- |-- impl 用户模块实现
|-- third 三方服务,比如oss,微信sdk等
|-- redis redis相关
|-- entity 数据库对象
|-- |-- user user相关数据库对象
|-- mapper 数据库操作mapper
|-- |-- user user相关数据库操作mapper
|-- exception 异常
|-- util 工具类
|-- xxxApplication.java 启动类
src/resources/
|-- mapper
|-- |-- user user相关mapper.xml文件
|-- bootstrap.properties 启动配置
|-- application[-{profile}].properties 本地环境配置,如果是远程可忽略
其中user
包表示业务功能,通常有多个,如order
、product
,如果是模块化项目,对应user
模块下可省略该包。
1.2 POJO(Plain Ordinary Java Object)
- VO(视力层实体,View Object)
View Object, 接口入参实体,需要区分入参(ReqVo|InVo)和出参(ResVo|OutVo)。 - DTO(业务层单实体,Data Transfer Object)
业务层交互数据对象,不同模块、层级间交互。
DTO为什么能与Dao层打通:基本CRUD使用Entity
即可,但特定业务情况下查询条件与表字段不对应,查询部分或聚合的结果,这时候该查询通常与具体业务相关,可以使用DTO简化操作,但一定需要注意单一职能原责。 - BO(业务层聚合实体,Business Object)
Business Object,业务对象,把业务逻辑封装为一个对象,可以简单理解为把多个DTO聚合成一个BO,如下:public class UserOrderBo { private UserDto user; private OrderDto order; }
- Entity(数据库实体)
与数据库表一一对应,同时又叫PO(Persistent Object)、DO(Data Object),其定义规则如下:- 所有Entity继承
BaseEntity
- 包名:
com.xxx.xxx.common.entity.{业务模块}
- 类名:
{表名}+Entity
,例:UserInfoEntity
- 所有Entity继承
- DO(数据库实体,Data Object)
与Entity不同,DO不与数据库表一一对应,但我们需要取单表的部分字段或多表的聚合字段时,使用DO。
POJO层级交互图如下:
上图展示了各层级交互POJO使用定义,各个项目或架构可能有不同的意见,但最重要的对POJO层级交互的约束,防止POJO使用的混乱。
值得关注的是为什么不支持一个对象透传多个层,比如VO对象同时使用于业务层和视图层。
透传可见的好处是简单、性能高(减少POJO转换),但其缺点在于对项目的可读性、扩展性和维护性是灾难。
试想以下几种情况:
- 当页面展示性别为男|女,而数据库为1|0时:
该情况需要进行值的转换,应在视图层将DTO的枚举值转为VO的展示值,不应该在DTO增加了用于显示值的字段。 - 当查询用户订单信息时,后端需要额外限制订单所属用户id:
该情况,后端需要添加条件userId为当前登录用户,而页面对此条件是不可见且无需传值的。 - 当数据库某些字段为管理员账号时(密级字段),管理员账号对外业务不可见时:
需要在响应字段里删除该字段,即VO里删除。
诚然,不支持透传会导致项目中出现大量贫血POJO,且层级交互时几乎每次都需要进行POJO的转换,但为了项目架构的清晰、代码复用性和解耦,约束POJO是必要的。
1.3 url
- url method只支持
get
、post
,目的在于更广泛地兼容框架。 - url路径格式:
{应用}/{模块}[/{功能}]/{操作}{对象}
,只允许字符、数字和下划线,如:mgr/user/deleteUser
。
- 应用示例:
- 管理端:mgr
- 用户端:portal
- 定时任务:job
- 消息队列:mq
- 三方:third
- 操作示例:
- 删除:delete
- 新增:add
- 编辑:edit
- 列表:list
- 详情:detail
- url应全部定义在方法上,禁止部分在
controller
上部分在方法中。 - url最后一段应同方法名一致。
- 包名格式:
{应用}[.模块]
,如果controller
不多,可省略模块
,如:com.xxx.web.mgr.user
-
controller
类名格式:{模块}[功能]{应用}Controller
,其中功能起细化controller
作用,当接口较多时,需要在类名中增加功能。
示例:
package com.xxx.web.mgr.user;
@RestController
@RequestMapping
public class UserCrudMgrController {
/**
* 删除用户
*
* @param id 用户id
* @return void
* @date 20xx/01/01 10:00
* @author zhangSan
* @since 1 by zhangSan at 20xx/01/01
*/
@GetMapping("/mgr/user/deleteUser")
public BaseRes<Void> deleteUser(@RequestParam Long id) {
...
return BaseRes.ok();
}
}
1.4 逻辑划分
1.4.1 controller
controller
支持的逻辑如下:
- 入参转换
- 出参转换
- validator参数校验
- 登录用户获取并传递给service
1.4.2 service
service包含主要的业务逻辑代码,但要区分非业务代码的剥离。
1.4.3 mapper
mapper层处理所有的数据库操作,实现sql有多种方式(Provider、ServiceImpl、SQL注解、Wrapper、手写sql等),为保证代码的统一和项目的可读性,建议单个项目中仅支持其中一中,鉴于手写sql能保证支持所有功能且可读性可维护性最佳,建议使用手写sql方式。
比如,如果使用wrapper
和ServiceImpl
方式,为保证数据库操作的可维护性,需要独立一层repository
来封装所有的数据库操作,反而会增加工作量。
-
mapper
继承mybatis-plus
的BaseMapper
(应适当斟酌) - 禁止使用
wrapper
语句 - 禁止使用
ServiceImpl
- 方法命名前缀规则如下:
- get:获取单个对象的方法。
- list: 获取多个对象的方法
- count:获取统计值的方法
- save/insert:插入的方法
- remove/delete:删除的方法
- update:修改的方法
1.4.4 check
check主要汇总所有的校验逻辑,方便熟悉系统所有的校验内容。
如:
// 用户crud服务
public class UserCrudServiceImpl {
public void deleteUser(Long userId, Admin admin) {
User user = this.userMapper.getUserById(userId, true);
// 删除校验
UserCheck.checkForDeleteUser(user, admin);
...
this.userMapper.deleteUserById(userId);
}
}
// 用户check类
public class UserCheck {
public static void checkForDeleteUser(User user, Admin admin) {
if (!admin.getId().equals(user.getCreateBy()) {
// 非用户创建人
throw ErrUtil.throw(BaseCode.USER_DELETE_CREATE);
}
...
}
}
1.4.5 util
util
包含所有非核心逻辑且无属性的功能,比如导入导出数据的解析和转换、pojo对象的封装等,如果将这些代码放入service
层,会导致service
包含大量非核心逻辑,增加阅读和理解核心业务逻辑的难度。
二、 注释
2.1 IDEA注释配置
参考 IDEA注释配置
2.2 注释
注释提供的功能包括:梳理逻辑、说明原因、解释复杂代码、分离步骤等。
但同样,过多无意义的注释会增加开发工作,<font color=red>不要为了注释而注释</font>。
我们应该通过清晰的逻辑架构,好的变量命名来提高代码可读性;需要的时候,才辅以注释说明。注释是为了帮助阅读者快速读懂代码,所以要<font color=red>从读者的角度出发</font>,按需注释。注释内容要简洁、明了、无二义性,信息全面且不冗余。
使用名称来解释
方法、变量和类名要尽量通过名称自解释,不要写无用、信息冗余的注释。-
方法注释要全面
方法的注释,需要解释方法内部特殊的逻辑和注意事项。所有参数都应解释说明。返回值存在null
时,需要解释场景。/** * 解析转换时间字符串为 LocalDate 时间类 * <pre> * 调用前必须校验字符串格式 否则可能造成解析失败的错误异常 * </pre> * * @param dateStr 必须是 yyyy-MM-dd 格式的字符串 * @return LocalDate */ public static LocalDate parseYMD(String dateStr){}
梳理逻辑
对于复杂的方法或内容,需要解释说明内部逻辑,方便阅读者通过注释理解业务逻辑。-
说明原因
不要用注释来解释简单的代码行为,注释要说明代码实现的原因。
反例:public void registerUser(User user) { this.userMapper.insertUser(user); // 休眠5分钟 Thread.sleep(300_000); }
正例:
public void registerUser(User user) { this.userMapper.insertUser(user); // 系统使用了同步功能,而同步需要时间,这里休眠5分钟,等待同步完成 Thread.sleep(300_000); }
正例说明了休眠5分钟的原因,让阅读者能够清晰了解内部逻辑;反例仅简单翻译了代码(实际上代码足够简单,不需要说明也能理解),阅读者很难理解为什么这里需要休眠5分钟。
-
Key 和 Value值说明
在转换基本类型为map和set时,需要说明其key和value的含义:public void fillOrderList(List<Order> orderList) { if (CollUtil.isEmpty(orderList)) { log.info("order list empty"); return; } // 1. 填充用户信息,k: userId, v: userName Set<Long> userIdSet = orderList.stream().map(Order::getUserId).collect(Collectors.toSet()); Map<Long, String> userIdToNameMap = this.getUserNameMap(userIdSet); ... // 2. 填充商品信息,k: productId ... // 3. 填充店铺信息,k: merchantId ... // 4. 填充促销信息,k: promotionId } /** * 获取用户id - name的map * * @param userIds user id * @return k: userId, v: userName */ public Map<Long, String> getUserNameMap(Collection<Long> userIds) { // 分批获取用户 List<User> userList = BatchUtil.batch(userIds, it -> { return this.userMapper.listById(it); }); return userList.stream.collect(Collectors.toMap(User::getId, User::getUserName, (a, b) -> b)); }
上述代码说明了map包含基本类型时的k和v含义,使阅读者不需要去了解代码细节。
同时上述代码演示了使用注释来分离代码逻辑,使阅读者能够清晰了解大量代码其内部的逻辑性。
三、异常处理
3.1 spring全局异常处理器
系统自动注入GlobalExceptionHandler
全局异常处理器,该全局异常处理器提供如下功能:
- 按异常类处理异常
- 按异常信息设置http code
- 按异常级别输出异常日志
3.2 异常码
由BaseCode
记录异常码,若模块化项目,可视项目规模自行实现模块化的IApiCode
子类,记录模块内部的异常码。
异常码由6位数字组成,前2位为模块(在多模块项目中表示功能),后4位可按顺序递增。应避免随意新增前2模块码,应该尽量使用已有的。
3.3 抛异常
由ErrUtil
工具构建异常,该工具类实现多种异常构建方式方便开发。
if (null == user) {
log.info("user not found, userId:{}", userId);
throw ErrUtil.err(BaseCodeEnum.USER_NOT_FOUND);
}
3.4 透传
try..catch
时,需要透传原始异常信息,用于日志输出完整的异常栈信息:
try {
// 执行可能异常的业务代码
userMapper.updateUserById(user);
} catch(Throwable t) {
// 第一种方式:透传
log.info("update user error, userId:{}", user.getId());
throw ErrUtil.err(BaseCodeEnum.USER_NOT_FOUND, t);
// 第二种方式:手动输出异常栈
log.error("update user error, userId:{}", user.getId(), t);
throw ErrUtil.err(BaseCodeEnum.USER_NOT_FOUND);
}
注意:第一种方式用info
级日志输出了关键信息,将异常栈交由全局异常处理器处理;
第二种方式用error
级日志输出了关键信息和异常。
两种方式的区别:开发主观区分了该场景是正常的业务流程,还是需要告警错误。
反例:
try {
// 执行可能异常的业务代码
userMapper.updateUserById(user);
} catch(Throwable t) {
}
上述反例中,使用try...catch
捕获了异常,但没有任务处理,异常信息被屏蔽,导致无法排查和定位问题,即使不需要处理该异常也应当输出日志。
四、日志
4.1 框架
基于spring默认的日志框架logback
,代码中需要使用日志时请使用Lombok
的@Slf4j
注入log
属性。
4.2 结构
日志输出一般由4部分组成:
- 级别:根据业务场景决定问题输出的级别。
- 描述信息:描述业务场景,如下例中的
update file error
,表明该问题是上传文件时发生的问题 - 关键参数:排查问题的关键数据,如下例中的
fileId
,用于定位具体的数据。变量的输出在日志信息中使用{}
表示占位,后续参数按顺序替换。 - 异常栈:输出完整的异常栈,用于定位具体的异常代码。异常置于最后1个参数,无需
{}
占位。log.error("upload file error, fileId:{}", file.getId(), exception);
错误示例:
- 未输出完整异常栈
该情况导致无法定位具体代码和调用链路,不便于定位问题。log.error("update file error, fileId:{}, msg:{}", file.getId(), exception.getMessage());
- 未输出关键参数
该情况无法确定具体数据,无法结合数据定位问题,并且后续需要修复数据也无从着手。log.error("update file error", exception);
4.3 时机
4.3.1 分支
User user = ...
log.info("find user, userId:{}, user:{}", userId, user);
if (null == user) {
log.info("not found user, userId:{}", userId);
...
} else if (!user.isValid()) {
log.info("user invalid, userId:{}", userId);
...
}
上述示例中,在每个分支中都输出了相关日志和参数,方便排查问题时定位代码走向。
4.3.2 起止
public void handleTask(Long taskId) {
log.info("handle task start, taskId:{}", taskId);
long start = System.currentTimeMillis():
try {
... (复杂或耗时业务代码)
} finally {
long times = System.currentTimeMillis() - start;
log.info("handle task end, taskId:{}, times:{}", taskId, times);
}
}
复杂或耗时业务执行时,需要输出起止日志,方便关联日志和了解业务的执行情况。
上述示例中,业务代码的开始和结束分别输出了start
和end
日志,并输出了关键业务数据taskId
和耗时times
。
4.3.3 步骤
public boolean login(String email, String password) {
// 1. 查询用户
User user = this.queryUserByEmail(email);
log.info("find user, email:{}, userId:{}", email, user.getId());
// 2. 登录校验
UserCheck.checkForLogin(user, password);
log.info("check login, userId:{}", user.getId());
// 3. 增加登录记录
this.userLoginService.addLoginRecord(user);
log.info("add user log record, userId:{}", user.getId());
// 4. 生成 token
Token token = this.tokenService.createToken(user);
log.info("create login token, userId:{}, tokenId:{}", user.getId(), token.getId());
}
上述示例中,每步均输出对应步骤日志和关键数据,排查日志时可以很清晰了解代码执行的步骤、时间和数据。
4.4 敏感
避免输出敏感数据到日志中,比如password
、token
等...
4.5 性能
输出日志时需要关注最终执行时日志的性能问题,以下为错误示例:
- OOM
上述示例在读取文件内容(禁止将整个文件内容读取到内存)后,用日志输出了全部字节数据,该操作会导致内存溢出(OOM)。byte[] bs = IoUtil.read(in); log.info("read file, content:{}", bs);
- JSON
上述示例中用JSON序例化输出了用户集合,该操作性能较低,占用较多内存,建议仅输出关键数据,如用户id。List<User> list = ... log.info("find all user, list:{}", JSON.toJSONString(list));
4.6 其他
- 禁止使用
System.out|err
打印日志。 - 在功能提测前,检查日志输出情况,使日志信息符合预期。
- 非必要不输出
error
级日志,避免非必要的告警。
五、SQL规范
未特殊说明编码均使用utf8mb4
。
5.1 建表
表名、字段名必须使用小写字母,非分库分表时禁止出现数字,且数字只能出现在结尾。
表名格式:业务名称_表的作用,表名不使用复数名词,如:
sys_dict
、sys_config
。禁止使用关键字,<font color=red size=4>详情参阅《待补充》</font>。
小数类型必须使用 decimal。
boolean数据类型是 unsigned tinyint, 1是0否。
如果存储的字符串长度几乎相等,使用 char 定长字符串类型。
varchar 是可变长字符串,不预先分配存储空间,长度不要超过 5000,如果存储长度大于此值,定义字段类型为 text,独立出来一张表,用主键来对应,避免影响其它字段索引效率。
-
枚举值均使用'varchar'类型,值使用枚举类的
name
,字段备注需标明关联的枚举,如:create table user_info { ... user_status varchar(64) not null comment '用户状态,关联 UserStatusEnum', ... } ...
-
表必备下例字段,均不可为
null
:字段 类型 备注 id unsigned bigint 主键,自行选择是否自增 delete_status unsigned tinyint 删除状态,1是0否 create_by unsigned bigint 创建人 create_time datetime 创建时间 update_by unsigned bigint 修改人 update_time datetime 修改时间 create table user_info ( id unsigned bigint not null auto_increment, delete_status tinyint unsigned not null default '0', create_by unsigned bigint not null, create_time datetime not null default current_timestamp, update_by unsigned bigint not null, update_time datetime not null default current_timestamp on update current_timestamp, primary key (id) ) engine=innodb default charset=utf8mb4 collate=utf8mb4_0900_ai_ci comment='用户表';
5.2 索引
- 唯一优先:业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。
- 超过三个表禁止 join。需要 join 的字段,数据类型保持绝对一致;多表关联查询时,保证被关联的字段需要有索引。
- 建组合索引的时候,区分度最高的在最左边。
- 防止因字段类型不同造成的隐式转换,导致索引失效。
5.3 SQL语句
- 使用
count(*)
, 不要使用count(1|字段名)
。 - 不使用外键与级联,一切外键概念必须在应用层解决。
- 禁止使用存储过程,存储过程难以调试和扩展,更没有移植性。
-
left|right join
会生成多条数据,注意数据重复性。 -
left|right join
的on
条件只能加辅助表,会忽略主表条件,如:
上述sqlselect * from order_info o left join user_info u on o.user_id = u.id and o.order_status = 1
on
条件中的and o.order_status = 1
会被忽略。
5.4 其他
- 排序时,必须有id参与。
- 手写sql时,必须有
delete_status
。 - 关联时,辅助表的条件尽量加在
on
中以减少关联数据。 - 时间字段(datetime)只能到秒,若需要精确到毫秒,使用unsigned bigint,存时间戳。
- mybatis中使用
${}
要注意sql注入问题。 -
update
操作时,必须同步更新update_by
和update_time
字段。
六、版本管理
6.1 分支
七、工具
7.1 基础工具类
- 优先使用
Hutool
包,如StrUtil
、CollUtil
- 自定义
- 自定义工具类应使用
Util
结尾,并设于业务的util
包下。 - 工具类不应缓存属性,避免线程安全问题。
- 须定义
private
无参构造器,如:public class NumberUtil { private NumberUtil { // do nothing } }
- 自定义工具类应使用
7.2 Redis
7.2.1 RedissonOps
自行参考其方法
7.2.2 RedisKey
使用RedisKey
记录所有的key,当项目模块化时应按模块独立所有的IRedisKey
子类。
注意:
- 使用
:
分隔业务逻辑,如:user:info
、user:token
- 使用
.
分隔同一业务下的不同功能,如user.info.email
、user:info.address
- 使用
{}
限制节点,避免批量操作时,不同key位于不同节点导致异常,如:user:info:{10086}
、user:token:{10086}
- key可过期时应标明过期时间(秒),如果是预热或定时缓存应增加随机数防止缓存雪崩
7.2.3 RedisLock
<font color=red size=4>待补充</font>
7.3 自动分页
7.4 MyBatis类型转换
7.4.1 自动加解密
7.4.2 自定义
7.4 ContextInject
7.5 防重复
7.6 自动注入
7.7 金额序列化
系统中金额需要使用Long
,数据库存储bigint
,不使用BigDecimal
。使用最小精度单位,如CNY最小单位为分,则1代表1分,100代表1元。
使用Long
的优点是:
- 存储空间小
- 降低性能开销(如果使用
BigDecimal
每次运算都会生成新的对象) - 避免浮点精度问题
前后端交互时,在VO对象属性上使用注解进行自动转换:
@Data
public class OrderReqVo {
// 自动反序列化 amount,如为1,自动转为100
@JsonDeserialize(using = AmountLongDeserializer.class)
private Long amount;
}
@Data
public class OrderResVo {
// 自动序列化 amount,如为1,前端收到为0.01
@JsonSerialize(using = AmountLongSerializer.class)
private Long amount;
}
@JsonSerialize(using = AmountLongSerializer.class)
@JsonDeserialize(using = AmountLongDeserializer.class)
八、单元测试
十、良性开发
- TODO
- 合理使用@Transactional和顺序
- 循环依赖
- 基本类型参数
- Query参数不易过大,且不宜使用数组、集合和对象类型
- 时间类型交互
接口使用时间类型时,必须使用Long值的时间戳,如果后端是Spring,可在对象上直接使用Date或LocalDateTime类型,Spring依赖的Jackson会自动对其进行毫秒值的转换。
上传或导出文档中有时间(比如excel)的,需要根据业务需求制定时区转换的规则,通常是请求头中携带x-zone
标识用户的地域(避免夏令时问题),后端根据该地域值进行时间转换。或者根据规则配置用户所属的地域。 - 金额使用
Long
,数据库使用bigint
,使用最小单位存储,例如CNY最小单位为分,则1代表1分,100代表1元。这里做能减少运算时的性能,且
十一、IDEA插件
-
Alibaba Java Coding Guidelines
阿里巴巴编码规约,详情参考其说明。 -
YapiUpload
一键上传接口文档到yapi平台插件,详情参考其说明。 -
CamelCase
使用Shift + Alt + U
(⇧ + ⌥ + U
)切换各种文字格式样式,如大小写、驼峰等。 -
MavenHelperPro
可以通过pom.xml
文件来分析并排除冲突的依赖关系。 -
MyBatisX
提供mybatis的提示、跳转和高亮等功能。 -
POJO to JSON
提供多种方式,将pojo类生成json字符串。 -
Key Promoter X
快捷键提醒,当操作有对应的快捷键时,右下角会提示对应的快捷键。