概要
本标准是本人在某Saas微服务系统的开发过程中积累而来的最佳实践,此系统及其相关项目遵守本标准。 亦可作为一般性独立项目的参考。
框架
Java 框架
Java语言每半年更新一个新的版本,2024年已经更新到了 JDK 22 ,虽然 Oracle 是收费的,但是仍有不少兼容的开源版本。比如OpenJDK,此后新项目使用 OpenJDK 17, 这是在各种操作系统中支持比较广泛的一个版本。
Spring 框架
目前 Spring 版本已经升到 3.x , 其对JPA中 对于 部分列对象的支持和 Json 字段的支持有较大变化,如果要使用 2.x 或者更早的方法,是不管用的。 这里推荐使用 3.2.1 ,因其对各种非标准操作,如Json列,自定义DTO对象,地理数据格式的支持更简单了。如果你要使用 Map 来搞定一切操作,那任何版本都没区别。
Javax与 Jakarta
从 2020年,JDK 9 开始,Javax 不再存在。由于某些历史和商业原因,Oracle 将 Javax 命名空间 托管给 Eclipse Foundation 来维护,而后者 将其重命名为 Jakarta,因此在所有相关的如 persistance(JPA)及 validation 相关的类,都应转移到 Jakarta 下。对于那些引用了 javax 的类库,基本都有其对应的 Jakarta 版本。 # 安全 对于一些临时性的中小型项目,不区分角色的登录,使用 Spring自带的 Security 组件就可以满足需要。 如果要求给不同角色分配菜单路径,并且控制其访问权限,那就适合使用 Shiro 框架。 Shiro能够控制每个接口的角色要求,如果用户角色不符合预期要求,则可以将其拦截在业务代码之外。
数据查询
基于 Spring 3.2.1(3.x) 的 JPA 与 2.5.6 (2.x) 有很大的区别,具体表达在: 1 Json列不需要在Entity上配置TypeDef注解,直接配置在属性上就能识别:
@JdbcTypeCode(SqlTypes.JSON )@Column(name = platform_roles, columnDefinition = json)private PlatformRoles platformRoles;
2 DTO 不再需要注解和复杂的配置,可以直接使用 HSQL(HyperSQL ,超级SQL语法) 支持:
@Query(value = SELECT new ShopApplyDto(u.id, u.name, u.shopName) FROM ShopApply u )
Page<shopapplydto> AllShopNames(Pageable pageable);
注意这里没有 nativeQuery = true
这样的声明,说明这是一条 HSQL 语法,由 JPA 框架解析,它可以识别 ShopApplyDto 类,前提是 ShopApplyDto 需要一个 有两个参数的构造函数。
3 复杂条件查询
复杂查询经常使用 and if(:id is not null or :id is not empty, id=:id, 1=1) 这样的查询,请注意,这是 nativeQuery = true
这样的声明,说明这不是一条 HSQL 语法,也就是它并不会被翻译成简化的 sql 语句,而是直接传输给数据库了。如果 条件过多,比如有十几二十几个and if 这样的条件,在慢查询诊断的时候是个非常复杂的问题,就连 explain 也无法给出结论。
因此,推荐使用动态拼接的方法:
if( id is not null) { sql = sql + and id = {id} }
动态拼接的结果是确定的 sql 语句,某个字段如果不参与查询,就不会出现在 sql 中,这有助于迅速定位慢查询的原因。因为查询条件虽然有很多种可能性,但使用拼接SQL,可以直接定义慢查询的原因可以是确定的某个条件,然后有针对性地处理就可以了。
注意:动态拼接SQL可能引起安全问题,一定要提交做好参数校验,并在防火墙中开启防SQL注入。如果无法使用防火墙,就一定要在参数校验中过滤掉像 and, select, insert 这样的关键字。
配置
1 禁止依赖项目根路径之外的文件。
当服务在机器之间迁移时,特别是当服务以容器方式运行时,会使用自动化脚本进行迁移操作。请记住,迁移操作将会变得非常普遍。并且每次重新部署,都将视为一次全新的安装,如果项目依赖根路径之外的文件,在迁移和部署时会出问题。当多实例部署时,位于绝对路径的依赖文件会相互冲突。
2 禁止使用直接字符串,而应使用常量中心代替。
常量中心是一个 类,其提供了所有字符串的常量型表达,如 const String MSG_OK = OK,使用常量表达的一个原因是,避免在代码中使用直接字符串的形式,因为字符串是无法使用工具混淆的,这很容易被反编译人员用作代码特征点来定位一段特定功能的代码。
相应地,将所有字符串集中在一起,并封装其内部实现,有利于灵活地从配置文件,或者从数据库中加载,降低代码泄漏的后果,并且如果有需要调整字符串的表达内容,只要更改一处,而不必到处搜索与替换。
接口
禁止返回Entity
禁止贪图方便,将Entity整个返回给用户。即便前端需要的数据包含了Entity的全部字段,也应将 Entity 转为 Vo 再返回给用户,因为有时对 entity 的操作,会直接同步到数据库,造成误修改。而使用 Vo 则可以放心地任意设置其值,而不必担心其与数据库的关系。
在设计返回数据内容时,应只返回目前事实上需要的数据,而不是”可能需要的数据“,要遵守”最小授权”的原则,而不是 ”为了以后方便“ 的原则。
接口形式
使用 RESTFULL 风格,GET:获取数据 , POST:创建数据 PUT:修改数据 DELETE:删除数据。RESTFULL 的特点有两个:1 充分利用HTTP谓词 ,来消减方法名 2 充分利用 url 路径参数,来消减参数名。结果是 RESTFULL 的 url 外观简洁优雅。
例如 User 对象, 获取数据,使用GET 方法,url 定义为: /user/{page}/{size} ,比如 /user/1/10,代表请求第1页,每页10条数据; 而不是 /user/get?page=1&size=10 如果要根据ID获取单个用户,应使用 /user/{id} ,比如 /user/3 ,这表示请求ID为3的数据, 而不是 /user/get?id=3
此表列出了推荐的和不推荐的常用接口url:
--- | --- | |
接口说明 | 推荐的方法 | 不推荐的方法 |
请求列表 | GET /user/{page}/{size}如:GET /user/1/10 | GET /user/get?page=1&size=10 |
请求单个数据 | GET /user/{id}如: GET /user/3 | GET /user/get?id=3 |
提交数据 | POST /user/ | POST /user/add |
修改数据 | PUT /user/{id}如:PUT /user/3 | POST /user/modify?id=3 |
删除数据 | DELETE /user/{id}如:DELETE /user/3 | GET /user/delete?id=3POST /user/delete?id=3 |
推荐写法:
@RequestMapping(user)
public class UserController {
//GET列表
@RequestMapping(value = {page}/{pageSize}, method = {RequestMethod.GET})
public ApiPageResult<uservo> GetList(@PathVariable int page, @PathVariable int pageSize){
//方法体
}
//GET单个对象
@RequestMapping(value = {id}, method = {RequestMethod.GET})
public ApiDataResult<uservo> Get(@PathVariable int id){
//方法体
}
//新建对象
@RequestMapping(value = /, method = {RequestMethod.POST})
public ApiResult add(@Valid @RequestBody AddUserVo para){
//方法体
}
//修改对象
@RequestMapping(value = /, method = {RequestMethod.PUT})
public ApiResult modify(@Valid @RequestBody ModifyUserVo para){
//方法体
}
//删除对象
@RequestMapping(value = /{id}, method = {RequestMethod.DELETE})
public ApiResult delete(@PathVariable int id){
//方法体
}
}
参数验证
在以上的代码示例中,对于 POST或PUT 的参数,都有参数注解,@Valid 和 @RequestBody ,其中 @Valid 指示此参数需要进行验证,而 @RequestBody 指示此参数来源是请求体。
public class AddUserVo {
private String loginName;
private String password;
@JsonProperty(name)
@NotBlank(message = 请输入姓名)
private String nick;
@NotBlank(message = 必须指定角色)
private String role;
@NotBlank(message = 手机号不能为空)
private String mobile;
private String platform;
private List<string> platforms;
}
以 AddUserVo 为例,@NotBlank 指示字符串不能为空,@JsonProperty 指示,其取自 json 的字段与代码中定义的定义不同。其他验证约束:@NotNull 指示此字段必传 @Max @Min 指示数字的最大最小值。
如果使用 get 参数,也可以使用 @Valid + @NotXXX 组合的方式,对单个get参数验证:
//GET业务方法
@RequestMapping(value = dosomething, method = {RequestMethod.GET})
public ApiBaseResult<vo> DoSomething(@Valid @NotEmpty String para1, @@Valid @NotEmpty String para2){
//方法体
}
参数验证的生效条件是使用 GlobalExceptionHandler ,全局异常处理,对于 @Valid,抛出的异常是 ConstraintViolationException, 对于 @Valided 抛出的异常是 MethodArgumentNotValidException;如未在全局范围捕获这两个异常,验证不管用。
验证示例如下:
@RestControllerAdvice@Priority(1)public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<apiresultbase> handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
log.error(ThrowableUtil.getStackTrace(e)); // 打印堆栈 StringBuffer msg = new StringBuffer();
msg.append(e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
ApiResultBase apiError = new ApiResultBase().fail(msg.toString());
return buildResponseEntity(apiError, OK.value());
}
private ResponseEntity<apiresultbase> buildResponseEntity(ApiResultBase apiError, int responseStatus) {
return new ResponseEntity(apiError, valueOf(responseStatus));
}
}
GlobalExceptionHandler 说明
GlobalExceptionHandler 是参数验证实现的重要环节,其原理是处理在系统中未被捕获的异常,返回自定义消息,提示调用者发生了什么问题,让调用者去排查问题。如果不使用全局异常自定义的话,就会报 500 错误,后端去查服务器日志。
因此,将所有可以预期发生的异常写入 GlobalExceptionHandler 中处理,一是避免在业务中处理,破坏业务逻辑,也可以减轻一些业务开发工作量。
返回格式
数据的返回格式一般有三种:
1 什么也不返回 2 返回一个实体 3 返回一个列表(table) 4 返回一个对象和列表
针对第1种情况,对应的类型是 ApiResultBase,返回内容应是:
{ status: 0, message: OK}
如果返回一个对象, 对应的返回类型是 ApiDataResult ,返回内容是:
{ status: 0, message: OK, data: {对象内容}}
返回列表, 对应的返回类型是 ApiListResult ,返回内容是:
{ status: 0, message: OK, list: []}
返回对象和列表, 对应的返回类型是 ApiDataListResult ,返回内容是:
{ status: 0, message: OK, data: {}, list: []}
ApiDataListResult 的情形非常少见,基本不会用到,这是为了兼容旧版本而保留的一个类。因为旧版本并不区分 data 与 list ,直接调用 setData(xx) 来设置,如果参数是 object 则设到 data 上,如果 是 list 则设置到 list 上,这种方法相当地随心所欲。
返回的数据类型一定要明确,要保证可预期性。如果只需要返回 Data 或 List 中的一种,不可使用 ApiDataListResult 作为默认类型,因为这样会让调用者搞不清返回的数据格式。
ApiPageResult 在形式上是 ApiDataResutl的一个实现,而不是 ApiDataListResult 。因为 其 Data 是一个 Page 对象,因为这些属性: total, page, pageSize, list 是平级的。
{
status: 0,
message: OK,
data: {
total: 100,
page: 1,
pageSize: 10,
list: [{}, {}] }}