Gin Web项目最佳实践
本文包含,Gin项目推荐布局,一些最佳实践等等。
Gin项目推荐布局
假设项目名称叫Hearth
- xxx、yyy代表大块的业务区分:如用户、订单、支付
- aaa、bbb代表小块的业务区分:如(用户的)登录、注册、查询
1 | example/ |
放弃的布局方式
此种布局比较适合独立的包,对api结构体的操作复用较差
1 | example/ |
本文包含,Gin项目推荐布局,一些最佳实践等等。
假设项目名称叫Hearth
1 | example/ |
1 | example/ |
前端调用SaaS服务一个全局级别的接口
sequenceDiagram
participant User as 用户
participant Frontend as 前端
participant Backend as 后端
User->>Frontend: 点击页面
Frontend->>Backend: 请求当前功能集
Backend->>Backend: 返回当前功能集
alt 用户有权限
Backend->>Frontend: 返回全局数据
Frontend->>User: 显示数据
else 用户无权限
Backend->>Frontend: 返回错误信息
Frontend->>User: 显示错误信息
end
前端调用SaaS服务一个用户权限的接口
sequenceDiagram
participant User as 用户
participant Frontend as 前端
participant Backend as 后端
User->>Frontend: 点击页面
Frontend->>Backend: 查看用户权限
Backend->>Backend: 验证用户权限
alt 用户有权限
Backend->>Frontend: 返回用户项目数据
Frontend->>User: 显示数据
else 用户无权限
Backend->>Frontend: 返回错误信息
Frontend->>User: 显示错误信息
end
Mysql8.0.X版本,且核心配置如下
1 | gtid_mode=ON |
数据不一致的根本原因在于MySQL在设计上不具备分布式系统的完整语义,这导致主从复制在面对网络分区和延迟时无法保持数据一致性。(又不可能采取全同步的模式,那就变成一个CP系统了)。根据数据冲突的内容,如果是**“不同主键,不触发唯一键约束的数据冲突”**,那么后续很容易可以同步到一致。如果触发了主键或者唯一键的冲突,无法互相同步,场景会变得复杂一些,简而言之,只有当后续的操作可以同时在主/备两个数据库中抹平这个差距,数据才能恢复,并且约束越多,抹平也就变得愈困难。举例
下文将分别以插入为例讨论这几个场景,用红色叉号代表同步延迟或者断开。
注:由于Mysql主备同步时会将upsert类的sql转换为实际执行的insert、update语句,也就是说upsert的语义在主备同步不稳定/切换时,容易丢失。
设想表结构,仅有一个name字段,且name为主键。比如我们先在MysqlA中插入了数据name=tt,假设发生了切换,又向MysqlB插入了数据name=wtt。

这就导致MysqlA与MysqlB里面的数据存在着不一致,但是一旦同步恢复,数据就会一致。

表结构,拥有两个字段,name为主键,age为字段。
同样,插入了两条数据,导致冲突。

即使MysqlA和MysqlB之间同步恢复,后续insert语句也会由于主键冲突同步失败。

这种不一致要等到后续对主键进行update操作后,才能恢复一致

主键为数据库自增主键,其中一个库为奇数,另一个库为偶数。同时还有唯一约束name

这时候插入数据,就会导致不一致,并且主键也不相同,由于业务不感知主键,使用不存在则更新的语法也会导致主键不一致。

可以预想到即使恢复同步,MysqlA和MysqlB数据也无法一致。

在这种场景下,任何针对id的SQL操作都无法在双方数据库中成功同步。例如,MysqlB数据库中不存在id为0的记录,而MysqlA中不存在id为1的记录,导致同步操作失败。
想要恢复一致,可以通过业务唯一约束来删除记录或者是根据业务约束把Mysql主键id也一并更新(不过这很困难,一般这种业务是不会直接操作id的)
那么可能会有人有疑问,为什么不像之前那样,用name作为唯一主键呢?
答:业务的需求多种多样,而且如果唯一约束由多个字段组成,使用Mysql自增主键是唯一的选择。
本文探讨了Mysql异步复制模式下的数据不一致问题,容易在什么时候产生,什么时候恢复。总的来说,业务如果只有一个唯一主键,出现不一致的概率更小。如果业务用数据库自增作为主键,同时伴有唯一约束的插入操作(如upsert等),更容易出现长期的不一致。
WebFlux是Spring 5引入的新的响应式编程框架,它提供了一种基于反应式流的编程模型,可以用于构建高性能、高吞吐量的Web应用程序。
由于WebFlux可以处理大量的请求,如果后端处理较慢(如写db较慢等),可能会导致大量的请求堆积,可以通过限制同一时间的并发处理个数来防止请求堆积。
1 | import org.springframework.http.HttpStatus; |
网络编程中,任何操作都应该有超时时间。WebFlux允许大量的请求进入,如果不设置超时时间,可能会导致大量的请求排队处理(可能客户端早已放弃),可以通过统一Filter来设置最大超时时间。
1 | import lombok.extern.slf4j.Slf4j; |
在一个使用Spring R2dbc与Mysql8.x的项目中,当创建 一个REST资源,进行创建,返回的毫秒精度时间戳,和下一瞬间查询的时间戳不一致。sql及代码大概如下
1 | CREATE TABLE person ( |
实体类定义
1 | @Entity |
这里使用了@CreatedDate、@LastModifiedDate注解,并在Application类上配置了@EnableR2dbcAuditing注解用于在Repo操作实体的时候,自动更新时间戳。
1 | public interface PersonRepo extends ReactiveCrudRepository<PersonEntity, Long> { |
创建代码类比如下,大概就是使用r2dbc操作数据,并将r2dbc返回的实体用于转换毫秒时间戳
1 | return createPersonReq |
然而创建的时候返回的时间戳和查询的时间戳不一致,现象举例:
创建的时候返回:2024-05-08T08:11:47.333Z,
查询的时候却返回:2024-05-08T08:11:47.334Z,
走读代码,发现代码基本上万无一失,那么问题出在哪里呢?
通过仔细观察时间戳的区别,发现时间戳的变化都在最后一位,且相差为一,醒悟到这估计是由于内存中纳秒时间戳精度在转化为数据库毫秒时间戳的时候,部分库的行为是截断,部分库的行为是四舍五入,导致了这个问题。
最终通过写demo,docker抓包复现了这个问题,如下图所示,mysql server会将接收的时间戳进行四舍五入,而java常见的format工具类都是截断,导致了这一不一致。同时,这也体现了,r2dbc返回的entity可能并不是实际存入数据的内容,而是”原始”的entity。

在这个问题里面,存在三个时间精度:
r2dbc返回的entity可能并不是实际存入数据的内容,而是经过r2dbc处理之后,发送到数据库之前的entity。问题的关键就在r2dbc并不根据列定义的精度处理数据,而是根据mysql server支持的最高精度处理数据。
解决问题的方式有几种:
在进入r2dbc之前,将时间戳截断到数据库表定义的精度,也有两种方式
@CreatedDate、@LastModifiedDate注解,而是在应用程序中手动设置时间戳@CreatedDate、@LastModifiedDate注解,通过拦截器统一进位通过拦截器的代码如下,定义基类,不然每个实体类都要书写拦截器。一般来说,一个项目里,时间戳的精度都应该统一,所以可以定义一个统一的拦截器。
1 |
|
1 | import org.reactivestreams.Publisher; |
出于好奇,我也做了jpa的尝试,jpa也是一样的行为

对于一个组件来说,日志打印常见的有三种选择:
java生态slf4j已经成为事实上的标准,像Apache Ignite在最开始的时候也将日志作为自己的Spi定义,是向着2来发展的,但在Ignite3版本也去掉。Go生态由于去没有这样的标准,很多library只能选择2,导致引入了一个go library,它的日志会怎么出来,对于使用者来说是一个未知数。
java生态的基本原则如下:
slf4j-api,不引入具体的实现,可以在单元测试里面引入某个实现,用于测试打印日志。如果在一个高度一致的团队内,可以无视上面两条

假设我们的错误信息返回如下
1 | HTTP/1.1 200 OK |
无模板变量的错误信息国际化,可以直接在前端对整体字符串根据错误码进行静态国际化。
1 | // catch the error code first |
假设我们的错误信息返回如下
1 | HTTP/1.1 200 OK |
包含模板变量的错误信息国际化,可以在前端通过正则表达式提取,并代入到中文字符串模板中实现。如示例代码
1 | // catch the error code first |
从Spring的新版本开始,推荐使用构造函数的注入方式,通过构造函数注入有很多优点,诸如不变性等等。同时在构造函数上,也不需要添加@Autowire
注解就可以完成注入
1 | // Before |
但是,这种注入方式会导致变动代码的时候,需要同时修改field以及构造函数,在项目早期发展时期,这种变动显得有一些枯燥,再加上已经不需要@Autowire
注解。这时,我们可以用Lombok的@RequiredArgsConstructor来简化这个流程。
Lombok的@RequiredArgsConstructor会包含这些参数:
对于那些被标记为 @NonNull
的字段,还会生成一个显式的空检查(不过在Spring框架里这个没什么作用)。通过应用@RequiredArgsConstructor
,代码可以简化为如下模样,同时添加新的字段也不需要修改多行。
1 |
|
对于maven项目来说,模块的划分和pom(Project)文件可谓是至关重要,但往往在商业代码中,maven模块和pom文件并不得到大家的重视,最终导致模块杂乱、pom文件复杂难度,进而导致团队维护成本高。随意地exclude,随意地指定版本号,只会将项目带入依赖管理的地狱。本文讲述maven项目模块布局以及pom文件书写原则。
常见的一个需要打包发布、发布到maven仓库、并提供本项目bom的maven模块划分,以项目名xxx为例,当然,实际的项目可能会更加复杂,拥有common、util模块等。


xxx-parent用来组织整个工程,常见的可以放一些诸如java版本、checkstyle、spotbugs等配置,xxx-parent也可以考虑以上层依赖/组织发布的parent作为parentxxx-parent会有很多的依赖、插件配置,xxx-bom仅给其他项目提供版本号,没必要引入过多的依赖。很多商业微服务,根本不涉及会将代码的一部分发布成library给其他人使用,那么可以进行简化,将bom去除

java基于jar包而不是源码的依赖方式是jar包版本冲突罪恶的源头,像Netty这样基于4.1,一直维护了100多个小版本的library何其之少。这导致了java应用程序依赖的jar包,需要一个微妙的关系才能搭配运行,比如springboot3.x才能对应hibernate 6.x版本等。对于一个springboot项目,我们没有必要也没有意义去定义hibernate的版本号,只需要将Spring的依赖指定为parent就可以了。
对于大部分项目来说,子模块使用的依赖版本号都是一致的。尽量将版本号都通过parent或通过<properties> 统一管理引用起来,比如
1 | <properties> |
对于一些bom文件较为简单的项目,比如netty、jackson等,引入没有问题。但引入多个大项目的dependency management,比如同时引入公司内的parent、springboot某个dependency parent、再比如同时引入两个springboot衍生项目作为dependency management,可能会导致依赖版本传递关系复杂,难以维护。
举个例子:
对于library依赖,不应该把log4j2作为自己的compile级别依赖,只能作为runtime和test级别的依赖。
大部分情况下,无需进行exclue、特殊指定某个组件的版本号,仅当版本冲突,或紧急漏洞修复。每一个exclude、指定版本号都应该有合理的原因。尽量不在某一个子模块里单独exclude、指定版本号。合理的exclude如
1 | <!-- use log4j2 instead of logback --> |
pom文件就和代码一样,优美的pom应该整洁、避免重复、在项目中拥有统一的风格。
某个节点下有大量的元素时,优先按照含义区分先后顺序,比如compile依赖在先,test依赖在后;比如按依赖顺序pulsar-api在先,pulsar-client在后,其次可以按照字母顺序排列。会大大提升整个pom文件的可读性。
依赖管理,可以按照compile、runtime、test,并按照依赖的重要程序排序。比如在opengemini-client-reactor中,将本项目的模块放在上面,三方依赖放在下面
1 | <dependencies> |
properties里面,可以按全局、依赖、插件分别归类,并在小类中按字母名称排序。示例如下:
1 | <properties> |
maven从3.5.0版本开始,支持revision。允许其他模块引用父pom里面的版本号。IDEA对这个特性的支持还不是特别好,比如父pom里面定义
1 | <groupId>com.shoothzj</groupId> |
主要的好处有两条:
如果您的项目不涉及这两条,那么大可不必使用这个特性