0x00 前言
在日常开发的过程中,由于项目的结构不合理,微服务之间的调用,需要将各个数据传输对象、业务码等写到统一的二方包中,尤其在多人协作时,容易出现二方包的覆盖、冲突、缺少依赖等问题,降低开发同学的工作效率。
本文以此为目的,讲述通过拆解,将不合理的公共依赖分散到各个业务模块中,从而在不同模块协同时,能够降低和减少协同问题带来的效率降低。
目标:通过调整项目结构,提高工作效率。
0x01 分析现阶段的问题状况
1.1 项目分包不合理
合理的分包应该是每个包的职责清晰明确,能够帮助开发同学快速定位到相应的代码。不合理的分包则会增加项目的理解成本。
以现有项目举例,下面展示的是项目结构层级,以三层为例。
可以很明显的看出,项目的分包是比较混乱的。很容易发现包的职责不明确,例如像 security 这种配置不在config 中,biz 包中包含大量与业务无关的目录(util、constant、cache),而且core 和 project 中都包含了一定程度的相同名称的目录,诸如biz、web、models等,会使得不熟悉的同学增加许多的学习和理解成本。
1.2 依赖管理混乱且不统一
当前每个项目的依赖管理都是完全独立的,对依赖的改动非常随意,容易导致依赖冲突(如下图),需要不断排除冲突的依赖内容。并且各个模块依赖版本都不相关,依赖的版本号没有统一管理,有可能每个项目依赖的版本都不尽相同。
不但有潜在的因为版本导致的潜在问题,倘若涉及到版本升级等工作则会相当麻烦,例如之前出现的Apache Log4j 2 远程代码执行漏洞(CVE-2021-44228) ,就需要升级版本号。
复杂且没有版本号管理的依赖,每一条红线都是一个依赖冲突。
1.3 微服务调用繁琐
在当前的微服务开发过程中,需要开发人员在通用的二方包中,编写对应的数据传输对象。以此解决不同微服务之间对象的传输问题。
🧑🏻 💻 👉 👉 📑
一般来说一个需求不只有一个开发同学进行开发,因此在多个同学开发的同时,也就如下所示。此时两位开发同学就需要约定一个版本号,以保证双方的内容一致,不出现二方包互相覆盖等问题。
🧑🏻 💻 👉 👉 📑 👈👈 💻 🧑🏻
在实际生产过程中,不同的项目有不同的需求,而且并行的需求也不可能只有一个,因此在多个项目、多个需求,很多同学同时进行开发的情况下,一个通用的二方包来解决后端所有的微服务对象传输问题就很容易出现,二方包频繁冲突,互相覆盖等问题。极大的降低协作效率。
project_01 🧑🏻💻 👉📑 👈 💻 🧑🏻 project_01
👇
依赖冲突❌😭😭
☝️
project_02 🧑🏻💻 👉📑👈 💻🧑🏻 project_02
另外一方面,当前业务模块并没有提供对应的 rpc 调用的二方包,而是由每个应用方自行编写重复的接口调用, 因此浪费了不少时间。
0x02 解决方案
2.1 问题总结
- 项目结构范围不清晰,理解成本高,新成员介入成本高。
- 依赖包管理混乱且不统一
- 所有的数据传输对象需要写在一个通用的二方包中,难以维护,增加了协作成本
- 需要重复编写rpc调用接口,增加了工作量
针对以上问题,优化现有项目结构。
- 建立一个父级依赖管理,来统一管理项目的依赖版本。
- 重新规划每个包相应的职能,每个项目提供自己的接口二方包。其中包括 rpc 接口和数据传输对象,每个项目维护自身的 api 二方包。
准备工作
以 IntelliJ IDEA 作为集成开发环境。 JDK 默认使用 OPEN JDK 1.8. 构建工具选择 Maven 3.5 +
2.2 项目结构设计
2.2.1 父子级pom依赖管理设计
父级pom 用于管理依赖包的版本,子模块使用具体的依赖包。因此需要在父级pom 中配置相关的依赖管理。
首先打开 Idea 选择新建一个项目。 File -> new -> project -> maven
按照要求依次输入各项,点击右下角的finish即可创建一个空白的新项目。
接下来我们创建两个子项目,File -> new -> module -> maven . 其他的和上图一样。我们这里规划api模块提供 rpc 接口,biz模块负责实现具体的业务逻辑。
如下图,我们就得到了类似的项目结构。
在父级pom 中 通过使用 dependencyManagement 标签即可对相应的依赖进行管理。在子模块中,只需要配置相应的坐标,即可自动标记到已经配置好的版本号了。
<?xml version="1.0" encoding="UTF-8"?>
<project...>
...
<groupId>cn.yizhoucp</groupId>
<artifactId>family-services</artifactId>
<packaging>pom</packaging>
<properties>
<fastjson.version>1.2.58</fastjson.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
父级pom 依赖示例
子模块中依赖引用,有左上角的标志说明版本号引用了父级别的依赖。
2.2.2 使用占位符解决maven版本管理
在 2.2.1 中,通过父级pom,能够实现子模块的依赖管理。但是如果子模块进行版本升级,则每次都需要升级相应的子模块和依赖。为此可以使用占位符对子模块的版本号进行统一管理。
parent 和 import 都能够通过 dependencyManagement 标签对依赖进行管理。但是 import 不会继承
pluginManagement,因此如果是import spring-boot-starter-parent,则需要额外编写 spring-boot-maven-plugin 插件的相关配置,否则打包之后会遇到找不到启动类的问题。
示例如下
父级pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.3.2.RELEASE</version>
</parent>
<groupId>cn.yizhoucp</groupId>
<artifactId>spring-template</artifactId>
<packaging>pom</packaging>
<version>${revision}</version>
<modules>
<module>${rootArtifactId}-api</module>
<module>${rootArtifactId}-biz</module>
</modules>
<properties>
<revision>1.0.0-SNAPSHOT</revision>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<fastjson.version>1.2.58</fastjson.version>
<maven_flatten_version>1.1.0</maven_flatten_version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring cloud alibaba 依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.0.0.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Greenwich.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 子模块依赖 -->
<dependency>
<groupId>${groupId}</groupId>
<artifactId>${rootArtifactId}-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${groupId}</groupId>
<artifactId>${rootArtifactId}-biz</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 第三方依赖管理 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<defaultGoal>spring-boot:run</defaultGoal>
<plugins>
<!-- 添加flatten-maven-plugin插件 -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>${maven_flatten_version}</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
</execution>
<execution>
<id>flatten.clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
子模块pom
Api 模块 pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-template</artifactId>
<groupId>cn.yizhoucp</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>${rootArtifactId}-api</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
</project>
Biz 模块pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-template</artifactId>
<groupId>cn.yizhoucp</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>${rootArtifactId}-biz</artifactId>
<dependencies>
<!-- 二方包 -->
<dependency>
<groupId>${groupId}</groupId>
<artifactId>${rootArtifactId}-api</artifactId>
</dependency>
<!-- Spring 相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 其他依赖-->
<dependency>
<groupId>org.jdom</groupId>
<artifactId>jdom2</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>ma.glasnost.orika</groupId>
<artifactId>orika-core</artifactId>
</dependency>
<!-- 单元测试相关依赖 -->
<dependency>
<!-- 单元测试,我们采用 H2 作为数据库 -->
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>uk.co.jemos.podam</groupId>
<artifactId>podam</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
通过使用 ${revision} 占位符,来统一进行父子模块的版本管理。而如果需要针对某一个特殊的子模块进行管理,则直接加一个 version 标签即可。
例如 :
<artifactId>${rootArtifactId}-api</artifactId>
<version>1.0.0.RELEASE</version>
使用占位符进行父子模块管理时候,发布出去的二方包会由于 parent 依赖中的占位符没有被替换,导致找不到 父级pom 的问题。因此在父级 pom 中 使用 flatten-maven-plugin 插件进行构建,即可解决问题。
2.2.3 项目分层包名和类名规范
最外层包名结构:cn/com.${公司简拼}.${项目名称}.${模块名称}.${子包名}
示例: cn.yizhoucp.family.api.client
-
控制器层包名:web
- 类名:Controller
-
业务层包名:manager
- 类名:Manager
-
通用业务处理层包名:serivce
- 类名:Serivce
-
配置层包名:config
- 类名:Config
-
DAO 层包名:mapper
- 类名:Mapper
- 数据库实体包名:entity
-
DTO 包名:dto
- 类名:DTO
-
视图模型对象包名:vo
- 类名:VO
- 枚举包名:enums
-
常量包名:constant
- 类名:Constants
-
Aspect 实现类包名:aop
- 类名:Aspect
-
异常类包名:exception
- 类名:Exception
-
Spring Filter 包名:filter
- 类名:Filter
- 安全相关类包名:security
-
工具类包名:util
- 类名:Utils
- 注解包名:annotation
项目结构示意图:
2.2.4 Api 模块设计
Api 模块主要暴露 RPC 的接口调用,以二方包的形式对外提供。其中需要包含相应的业务码、数据传输对象(DTO)、RPC的接口。因此api 模块的功能也相对简单清晰。
要使用 rpc 调用,需要将 api 模块打包到对应的 maven 仓库,引用api 模块的 maven 坐标和对应的版本号即可。
例如 :
<dependency>
<groupId>cn.yizhoucp</groupId>
<artifactId>spring-template-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
2.2.5 Biz 模块设计
Biz 模块也是项目的核心功能。虽然在一定程度上,能够将 biz 模块更加细分下去,诸如 公共内容子模块、配置子模块、数据持久层子模块、表现层子模块等等。
但是会在很大程度上降低工作效率,例如写一个简单的功能却需要多不少的步骤。最重要的是,与很多开发同学的开发习惯不符合,在一定程度上会降低开发的效率,这与提高工作效率的目标相悖,因此在现阶段做出了一定程度的妥协,也不失为一种明智的选择。
因此,Biz 的模块设计如下
2.3 使用示例
示例项目使用 Springboot 2.3.2+ 版本、rpc 使用Fegin 、Orm 使用 Mybatis-plus 、数据库使用mysql 5.6+
创建一个数据库和一个测试用的表 test_info
-- auto-generated definition
drop table if exists TEST_INFO;
create table if not exists TEST_INFO
(
ID BIGINT auto_increment,
NAME VARCHAR(255) not null,
CREATE_TIME DATETIME not null,
UPDATE_TIME DATETIME not null,
constraint TEST_INFO_PK
primary key (ID)
);
编写 dal 层代码
实体类
BaseDO 主要是封装了一些通用的字段
@Data
public class BaseDO implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "create_time",jdbcType = JdbcType.DATE, fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(value = "update_time",jdbcType = JdbcType.DATE, fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@Data
@EqualsAndHashCode
@TableName(value = "test_info")
public class TestInfoDO extends BaseDO{
private String name;
}
Mapper类
大部分 CRUD 的操作Mybatis-plus 都帮我们封装好了,继承一下即可。
@Mapper
public interface TestInfoMapper extends BaseMapper<TestInfoDO> {
}
编写 serivce 层代码
Service 层大部分接口也可以直接使用Mybatis-plus 帮我们封装的部分。示例中写了一个 findById 用于根据Id 查询内容。
public interface TestService extends IService<TestInfoDO> {
TestInfoDO findById(Long id);
}
@Service
public class TestServiceImpl extends ServiceImpl<TestInfoMapper, TestInfoDO> implements TestService {
@Override
public TestInfoDO findById(Long id) {
return baseMapper.selectOne(new QueryWrapper<TestInfoDO>()
.lambda().eq(BaseDO::getId,id)
);
}
}
编写 biz 层代码
biz层是编写主要的业务逻辑的地方。通过调用不同的 Service 层整理数据完成业务逻辑。
@Service
public class TestManager {
@Autowired
private TestService service;
public TestInfoDTO findById(Long id) {
return coverage(service.findById(id));
}
public boolean save(TestInfoDTO testInfoDTO) {
return service.save(coverage(testInfoDTO));
}
private TestInfoDTO coverage(TestInfoDO source){
TestInfoDTO target = new TestInfoDTO();
BeanUtils.copyProperties(source,target);
return target;
}
private TestInfoDO coverage(TestInfoDTO source){
TestInfoDO target = new TestInfoDO();
BeanUtils.copyProperties(source,target);
return target;
}
}
编写api 和 web层代码
Api 接口,即对外暴露的rpc 接口
@FeignClient(name = "spring-template", contextId = "test-info")
public interface TestFeignService {
@GetMapping("/test/findByid")
Result<TestInfoDTO> findById(@RequestParam("id") Long id);
@PostMapping("/test/save")
Result<Boolean> save(@RequestBody TestInfoDTO entity);
}
DTO 数据根据具体的业务需要进行封装。
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TestInfoDTO {
private Long id;
private String name;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
Controller 类主要是实现 api 层的接口。如果不需要api 的接口,也可单独在 web 层编写Controller 类
@RestController
public class TestController implements TestFeignService {
@Autowired
private TestManager testManager;
@Override
public Result<TestInfoDTO> findById(Long id) {
return RestBusinessTemplate.executeWithoutTransaction(() -> {
return testManager.findById(id);
});
}
@Override
public Result<Boolean> save(TestInfoDTO entity) {
return RestBusinessTemplate.executeWithoutTransaction(() -> {
return testManager.save(entity);
});
}
}
编写完成,项目运行成功。请求接口功能正常即可。
使用 CURL 进行 POST 请求,成功插入数据
curl -X POST -d '{"name": "name1"}' -H 'Content-Type: application/json' http://localhost:9132/test/save
{"code":"000000","message":"success","data":true,"serverTime":1649761604271,"extData":null}
使用 CURL 进行 GET 请求,成功查询数据
{"id":1,"name":"name","createTime":{"dayOfMonth":6,"dayOfWeek":"WEDNESDAY","dayOfYear":96,"monthValue":4,"hour":9,"minute":34,"second":30,"year":2022,"month":"APRIL","nano":0,"chronology":{"id":"ISO","calendarType":"iso8601"}},"updateTime":{"dayOfMonth":6,"dayOfWeek":"WEDNESDAY","dayOfYear":96,"monthValue":4,"hour":9,"minute":34,"second":30,"year":2022,"month":"APRIL","nano":0,"chronology":{"id":"ISO","calendarType":"iso8601"}}}
0x03 总结
心路历程
在早期的设计过程中,尝试将项目划分为七个子模块,聚焦业务内容,通过模块的强制性来约束和规范项目结构。
但是从某种程度上而言,强制性的约束和规范必然会导致一定程度上的效率的降低。这次的项目结构由优化的主要目标是提高工作的开发效率,也导致过分的设计偏差了原有的目标。
这也导致了大多数同学对此的一些批评,在重新思考以及和在与大部分同学详谈过后,一方面发现很多同学对于自己所管理的模块已经做过不少的重构和调整。
换而言之,可以通过一个约定俗成的规范在一定程度上来规范项目的结构。随即将原有的思路摒弃掉,而计划在已有的项目结构和基础上做出因地制宜的改变,提供一个相对建议而已比较标准化的模板,一方面能够去提供参考样例来规范结构,另一方面也能适应对不同项目情况的适当调整,从而可以尽量减少项目结构的变动,来解决已有的问题。
总结规律
在团队或组织带来一定程度的制度上的变化,必然是为了解决一个主要问题。解决问题固然是好事,但同时也要思考是否带来了新的问题,任何完备的理论在面对必然不完美的现实时,因地制宜的做出最符合当下情况的变化才是最好的做法。
模版代码: git.yizhoucp.cn/microservic…