项目开发规范

一、项目

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包表示业务功能,通常有多个,如orderproduct,如果是模块化项目,对应user模块下可省略该包。

1.2 POJO(Plain Ordinary Java Object)

  1. VO(视力层实体,View Object)
    View Object, 接口入参实体,需要区分入参(ReqVo|InVo)和出参(ResVo|OutVo)。
  2. DTO(业务层单实体,Data Transfer Object)
    业务层交互数据对象,不同模块、层级间交互。
    DTO为什么能与Dao层打通:基本CRUD使用Entity即可,但特定业务情况下查询条件与表字段不对应,查询部分或聚合的结果,这时候该查询通常与具体业务相关,可以使用DTO简化操作,但一定需要注意单一职能原责。
  3. BO(业务层聚合实体,Business Object)
    Business Object,业务对象,把业务逻辑封装为一个对象,可以简单理解为把多个DTO聚合成一个BO,如下:
    public class UserOrderBo {
      private UserDto user;
      private OrderDto order;
    }
    
  4. Entity(数据库实体)
    与数据库表一一对应,同时又叫PO(Persistent Object)、DO(Data Object),其定义规则如下:
    • 所有Entity继承BaseEntity
    • 包名:com.xxx.xxx.common.entity.{业务模块}
    • 类名:{表名}+Entity,例:UserInfoEntity
  5. DO(数据库实体,Data Object)
    与Entity不同,DO不与数据库表一一对应,但我们需要取单表的部分字段或多表的聚合字段时,使用DO。

POJO层级交互图如下:


图1-1 POJO分层交互图

上图展示了各层级交互POJO使用定义,各个项目或架构可能有不同的意见,但最重要的对POJO层级交互的约束,防止POJO使用的混乱。
值得关注的是为什么不支持一个对象透传多个层,比如VO对象同时使用于业务层和视图层。
透传可见的好处是简单、性能高(减少POJO转换),但其缺点在于对项目的可读性、扩展性和维护性是灾难。
试想以下几种情况:

  1. 当页面展示性别为男|女,而数据库为1|0时:
    该情况需要进行值的转换,应在视图层将DTO的枚举值转为VO的展示值,不应该在DTO增加了用于显示值的字段。
  2. 当查询用户订单信息时,后端需要额外限制订单所属用户id:
    该情况,后端需要添加条件userId为当前登录用户,而页面对此条件是不可见且无需传值的。
  3. 当数据库某些字段为管理员账号时(密级字段),管理员账号对外业务不可见时:
    需要在响应字段里删除该字段,即VO里删除。

诚然,不支持透传会导致项目中出现大量贫血POJO,且层级交互时几乎每次都需要进行POJO的转换,但为了项目架构的清晰、代码复用性和解耦,约束POJO是必要的。

1.3 url

  1. url method只支持getpost,目的在于更广泛地兼容框架。
  2. url路径格式:{应用}/{模块}[/{功能}]/{操作}{对象},只允许字符、数字和下划线,如:mgr/user/deleteUser
  • 应用示例:
    • 管理端:mgr
    • 用户端:portal
    • 定时任务:job
    • 消息队列:mq
    • 三方:third
  • 操作示例:
    • 删除:delete
    • 新增:add
    • 编辑:edit
    • 列表:list
    • 详情:detail
  1. url应全部定义在方法上,禁止部分在controller上部分在方法中。
  2. url最后一段应同方法名一致。
  3. 包名格式:{应用}[.模块],如果controller不多,可省略模块,如:com.xxx.web.mgr.user
  4. 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方式。
比如,如果使用wrapperServiceImpl方式,为保证数据库操作的可维护性,需要独立一层repository来封装所有的数据库操作,反而会增加工作量。

  • mapper继承mybatis-plusBaseMapper(应适当斟酌)
  • 禁止使用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>,按需注释。注释内容要简洁、明了、无二义性,信息全面且不冗余。

  1. 使用名称来解释
    方法、变量和类名要尽量通过名称自解释,不要写无用、信息冗余的注释。

  2. 方法注释要全面
    方法的注释,需要解释方法内部特殊的逻辑和注意事项。所有参数都应解释说明。返回值存在null时,需要解释场景。

    /**
     * 解析转换时间字符串为 LocalDate 时间类
     * <pre>
     * 调用前必须校验字符串格式 否则可能造成解析失败的错误异常
     * </pre>
     *
     * @param dateStr 必须是 yyyy-MM-dd 格式的字符串
     * @return LocalDate
     */
    public static LocalDate parseYMD(String dateStr){}
    
  3. 梳理逻辑
    对于复杂的方法或内容,需要解释说明内部逻辑,方便阅读者通过注释理解业务逻辑。

  4. 说明原因
    不要用注释来解释简单的代码行为,注释要说明代码实现的原因。
    反例:

    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分钟。

  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);
  }
}

复杂或耗时业务执行时,需要输出起止日志,方便关联日志和了解业务的执行情况。
上述示例中,业务代码的开始和结束分别输出了startend日志,并输出了关键业务数据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 敏感

避免输出敏感数据到日志中,比如passwordtoken等...

4.5 性能

输出日志时需要关注最终执行时日志的性能问题,以下为错误示例:

  • OOM
    byte[] bs = IoUtil.read(in);
    log.info("read file, content:{}", bs);
    
    上述示例在读取文件内容(禁止将整个文件内容读取到内存)后,用日志输出了全部字节数据,该操作会导致内存溢出(OOM)。
  • JSON
    List<User> list = ...
    log.info("find all user, list:{}", JSON.toJSONString(list));
    
    上述示例中用JSON序例化输出了用户集合,该操作性能较低,占用较多内存,建议仅输出关键数据,如用户id。

4.6 其他

  • 禁止使用System.out|err打印日志。
  • 在功能提测前,检查日志输出情况,使日志信息符合预期。
  • 非必要不输出error级日志,避免非必要的告警。

五、SQL规范

未特殊说明编码均使用utf8mb4

5.1 建表

  1. 表名、字段名必须使用小写字母,非分库分表时禁止出现数字,且数字只能出现在结尾。

  2. 表名格式:业务名称_表的作用,表名不使用复数名词,如:sys_dictsys_config

  3. 禁止使用关键字,<font color=red size=4>详情参阅《待补充》</font>。

  4. 小数类型必须使用 decimal。

  5. boolean数据类型是 unsigned tinyint, 1是0否。

  6. 如果存储的字符串长度几乎相等,使用 char 定长字符串类型。

  7. varchar 是可变长字符串,不预先分配存储空间,长度不要超过 5000,如果存储长度大于此值,定义字段类型为 text,独立出来一张表,用主键来对应,避免影响其它字段索引效率。

  8. 枚举值均使用'varchar'类型,值使用枚举类的name,字段备注需标明关联的枚举,如:

    create table user_info {
      ...
      user_status varchar(64) not null comment '用户状态,关联 UserStatusEnum',
      ...
     } ...
    
  9. 表必备下例字段,均不可为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 索引

  1. 唯一优先:业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。
  2. 超过三个表禁止 join。需要 join 的字段,数据类型保持绝对一致;多表关联查询时,保证被关联的字段需要有索引。
  3. 建组合索引的时候,区分度最高的在最左边。
  4. 防止因字段类型不同造成的隐式转换,导致索引失效。

5.3 SQL语句

  1. 使用count(*), 不要使用count(1|字段名)
  2. 不使用外键与级联,一切外键概念必须在应用层解决。
  3. 禁止使用存储过程,存储过程难以调试和扩展,更没有移植性。
  4. left|right join会生成多条数据,注意数据重复性。
  5. left|right joinon条件只能加辅助表,会忽略主表条件,如:
    select * from order_info o 
    left join user_info u 
    on o.user_id = u.id and o.order_status = 1
    
    上述sqlon条件中的and o.order_status = 1会被忽略。

5.4 其他

  1. 排序时,必须有id参与。
  2. 手写sql时,必须有delete_status
  3. 关联时,辅助表的条件尽量加在on中以减少关联数据。
  4. 时间字段(datetime)只能到秒,若需要精确到毫秒,使用unsigned bigint,存时间戳。
  5. mybatis中使用${}要注意sql注入问题。
  6. update操作时,必须同步更新update_byupdate_time字段。

六、版本管理

6.1 分支

七、工具

7.1 基础工具类

  • 优先使用Hutool包,如StrUtilCollUtil
  • 自定义
    • 自定义工具类应使用 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:infouser:token
  • 使用.分隔同一业务下的不同功能,如user.info.emailuser: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)

八、单元测试

十、良性开发

  1. TODO
  2. 合理使用@Transactional和顺序
  3. 循环依赖
  4. 基本类型参数
  5. Query参数不易过大,且不宜使用数组、集合和对象类型
  6. 时间类型交互
    接口使用时间类型时,必须使用Long值的时间戳,如果后端是Spring,可在对象上直接使用Date或LocalDateTime类型,Spring依赖的Jackson会自动对其进行毫秒值的转换。
    上传或导出文档中有时间(比如excel)的,需要根据业务需求制定时区转换的规则,通常是请求头中携带x-zone标识用户的地域(避免夏令时问题),后端根据该地域值进行时间转换。或者根据规则配置用户所属的地域。
  7. 金额使用Long,数据库使用bigint,使用最小单位存储,例如CNY最小单位为分,则1代表1分,100代表1元。这里做能减少运算时的性能,且

十一、IDEA插件

  1. Alibaba Java Coding Guidelines
    阿里巴巴编码规约,详情参考其说明。
  2. YapiUpload
    一键上传接口文档到yapi平台插件,详情参考其说明。
  3. CamelCase
    使用Shift + Alt + U⇧ + ⌥ + U)切换各种文字格式样式,如大小写、驼峰等。
  4. MavenHelperPro
    可以通过pom.xml文件来分析并排除冲突的依赖关系。
  5. MyBatisX
    提供mybatis的提示、跳转和高亮等功能。
  6. POJO to JSON
    提供多种方式,将pojo类生成json字符串。
  7. Key Promoter X
    快捷键提醒,当操作有对应的快捷键时,右下角会提示对应的快捷键。

参考

  1. 阿里巴巴Java开发规范(嵩山版)
  2. Java中的VO,BO,PO,DO,DTO
  3. 「 Java开发规范 」10人小团队Java开发规范参考这篇就够了!
  4. POJO分层领域模型
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 前言 本开发规范基于《阿里巴巴Java开发手册终极版》修改,并集成我们自己的项目开发规范,整合而成。 为表示对阿里...
    4ea0af17fd67阅读 10,939评论 0 5
  • 一. 说明 以下内容大部分引用Laravel China社区的文章 - 分享下团队的开发规范 ——《Laravel...
    knghlp508阅读 12,357评论 0 28
  • 项目规范 扩展包 严禁在项目中新增任何生产环境扩展包 所需要用到的扩展在项目规划初期就已经包含,如果真没有,请沟通...
    fourn熊能阅读 4,059评论 0 2
  • 前言 关于规范 目的 提供更加高质量的软件交付。 实现团体智慧的延续和精进(工作总结,开发效率、程序执行效率、扩展...
    零一间阅读 1,495评论 0 0
  • 工程组 - iOS项目开发规范 语言OC 工具编辑器:XCode 11.4(保持最新)托管平台:Git(Sourc...
    ZhangMeng_阅读 3,192评论 0 1