后端工程圣殿形象的崩塌以及重建
共 12938字,需浏览 26分钟
·
2021-06-02 11:59
作为一个前端,似乎一直有点抬不起头。总是在圈子里被鄙视,什么:前端也是程序员?什么:JavaScript 工程太乱等等等等。我一直对后端工程心存敬畏,总之后端在我眼中一直是高大上的形象,但是最近我近距离深入接触了某公司的后端 Java 工程,三观彻底被毁了:一直鄙视我们前端的后端工程师们,搭出来的后端工程就这?心中一万匹马奔腾而过。在我原来的观念里,后端工程一直就像是圣殿般的存在,如今真正一看,却如同废墟。
自己一直想往全栈方向发展,于是决定在废墟中重建,虽然不能立即达到圣殿的级别,但是力求达到常见的前端工程水准。
缘起
后端接口一直调不通,后端同学总是说他那里是好的。我说开发环境调不通,他发来一堆 SQL 给我:你得在本地环境建数据呀。为了节省时间,我干脆在本地克隆了后端工程,开始近距离接触。一看,三观尽毁:
项目不能在本地一键启动
我接触过的所有前端项目,都会有一个 README,写清楚如何本地运行,尽管都是差不多的,尽管很简单,但都会注明。一般都会支持 yarn dev 或者 yarn start 等等一键运行命令。但是我打开的 java 工程,什么都没有。当然,能看出是基于 SpringBoot 的一个 maven 工程,于是 Googl 了一下 maven 工程的启动方式,试了一下 mvn bootRun 果然是各种报错。
重建方案
于是我通过各种错误提示,了解到了项目的依赖:mysql、redis 等等,就补充了一个 docker-compose.yaml 文件,在执行 mvn bootRun 之前,docker-compose up -d 一下,启动相关的依赖项。当然没有那么顺利,仍然有很多错误,详细的就不说了,心里窝火。最后的解决办法:
写一个初始数据库的脚本,并配置在 docker-compose.yaml 文件中,使得在 docker-compose up -d 时自动创建好需要的数据库
init.sql
CREATE DATABASE IF NOT EXISTS dbname;
USE dbname;
docker-compose.yaml
version: '3.3'
services:
adminer:
image: adminer:4.8.0
restart: always
ports:
- 7777:8080
db:
image: mysql:5.7
restart: always
environment:
MYSQL_DATABASE: 'dbname'
MYSQL_USER: 'root'
MYSQL_ROOT_PASSWORD: 'password'
ports:
- '3306:3306'
command: --init-file /data/application/init.sql
expose:
- '3306'
volumes:
- my-db:/var/lib/mysql
- ./init.sql:/data/application/init.sql
redis:
image: redis
command: redis-server --requirepass 123456
restart: always
ports:
- '6379:6379'
# Names our volume
volumes:
my-db:
新建一个 local profile 文件,并配置好相关的本地环境变量
对于本地数据库连接配置,新建src/main/resources/db/datasource-local.yml文件,配置如下:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
max-lifetime: 1800000
maximum-pool-size: 10
type: com.zaxxer.hikari.HikariDataSource
url: jdbc:mysql://localhost:3306/dbname?useUnicode=true&rewriteBatchedStatements=true&autoReconnect=true&failOverReadOnly=false&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: password
对于本地 Redis 配置,新建 src/main/resources/redis/redis-local.yml 文件,配置如下:
spring:
redis:
host: localhost
lettuce:
pool:
max-active: 100
max-idle: 100
max-wait: 6000
min-idle: 50
lock:
host: localhost
password: 123456
port: 6379
password: 123456
port: 6379
timeout: 5000
在 pom.xml 里新增一个 local profile:
...
<profiles>
<profile>
<id>local</id>
<properties>
<package.environment>local</package.environment>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
...
</profiles>
...
增加 application-local.properties 文件:
env=local
server.port=8080
server.http2.enabled=true
新建一个 start.sh 文件,无非就是启动相关依赖,再利用 mvn bootRun 启动项目。总之封装成一个脚本文件,方便后续本地一键启动项目。
mvn clean install
docker-compose up -d
mvn bootRun
完全没有任何测试代码
这真是日了狗了。
我所见过的前端工程,测试占比并不多,由于前端 UI 测试代价比较大,变化多,所以很少会去写详细的自动化测试,但是会做好 file watch,一旦代码变化,界面也随之更新,所以也还好。但是对于公共逻辑,或者重要的组件,一般都是一个实现文件对应着一个测试文件,每个文件用来干什么,只要看下对应的测试文件就一目了然,比如:
但是我打开的 java 工程,看似很专业,分层分得很细,遵照了《领域驱动设计》的层次结构:
但是《领域驱动设计》中引入这个分层架构是为了方便自动化测试,并且让程序本身更加简单明了。显然这个项目工程,只是生硬照搬了分层结构,并没有实现《领域驱动设计》中引入这个结构的目的。
部分重建方案
对于新加的代码,必须添加测试。这对我来说很艰难,不太会用 java 写测试,但最终把测试加入了,每次把改动推到远程仓库前,会自动运行项目中的测试。对于这个我还没有形成一个通用的方案,只能记录一些零碎的心得。
@MockBean 可以解决测试实例的替换
对于一些依赖,在运行测试时需要控制住,这时候可以采用 @MockBean 将实际依赖替换成一个假的对象实例。前提是实现代码做到了依赖一个抽象,而不是具体的实例。
对于没有依赖接口,而是依赖特定实例实现的代码,可以增加一些 setter 以方便测试
比如这样的代码:
显然它对 HttpClient 的依赖没有使用依赖注入。应该有办法改造一下,但我还不会,于是给这个类增加了一个 setter 方法:
publicvoidsetHttpClient(HttpClient client) {
this.httpClient = client;
}
然后在测试代码中,通过 @BeforeEach 对其内部 httpClient 进行替换:
@BeforeEach
voidinitialize() {
mpService.setHttpClient(new MockHttpClient());
}
当然,还可以在测试中不更改 HttpClient,而是更改对应的 HttpClient 要调用的远程端点,使用 MockServer 的方式,防止在测试中调用真正的远程端点。
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.None)
publicclassXXXTest {
publicstatic MockWebServer mockBackEnd;
@BeforeAll
staticvoid setUp() throws IOException {
mockBackEnd =new MockWebServer();
mockBackEnd.start();
}
@AfterAll
staticvoid tearDown() throws IOException {
mockBackEnd.shutdown();
}
@BeforeEach
void initialize() {
String baseUrl =String.format("http://localhost:%s",
mockBackEnd.getPort());
mpService.setQrCodeCreateUrl(baseUrl +"/test");
}
@Autowired
private MpService mpService;
@Test
void XXXTest() {
MockResponse mockResponse =new MockResponse();
mockResponse.setBody("{\"ticket\":\"gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm\n"+
"3sUw==\",\"expire_seconds\":60,\"url\":\"http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI\"}");
mockResponse.addHeader("Content-Type", "application/json");
mockBackEnd.enqueue(mockResponse);
...
assertThat(...);
}
总之,不要让测试产生真正的远程调用,要么 mock 自己使用的 HttpClient,对 sendRequest 方法进行打桩;要么 mock 这个远程 server,固定返回期待的 Response。
设计瑕疵
后端系统中所有的日期字段类型,全部设置成为了 Long 型,存储一个时间的秒数。这个让我非常不解,问下来的原因是时间的时区会带来问题……
我想不出日期时间格式会带来什么时区问题,就算有,采用 UTC 时间会怎样呢?总之目前的设计,导致编码不便,数据库查询不便,以及造测试数据时非常不方便(比如 Adminer 界面不会弹出日期时间选择框,而是一个数字输入框……)。
更加讽刺的是,这个设计号称能避免时区问题,结果偏偏带来了不应该有的时区问题:比如,我在调用后端接口创建某些资源时,由于业务逻辑限制开始时间和结束时间必须在同一天,我的前端明明传了同一天的时间给到后端,但后端就是一直报开始时间和结束时间不在同一天的错误!
最后查看后端代码,是这样写的:
LocalDate eventStartDate = eventStartTime ==null?null : Instant.ofEpochSecond(eventStartTime).atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate eventEndDate = eventEndTime ==null?null : Instant.ofEpochSecond(eventEndTime).atZone(ZoneId.systemDefault()).toLocalDate();
显然当前的 BUG 证明了系统的默认时区和用户真正使用的时区并不相同。为什么我倾向于直接采用日期时间类型,是因为日期时间类型在序列化时可以带上时区信息,比如 2021-05-31 00:00:00+8 代表了在东八区下 5 月 31 日的最开始时刻,但是它在 UTC 时间下,就是 5 月 30 日。
暂时的解决方案
由于直接将整个系统的时间设计从长整型改成日期时间型,工作量较大,我只能先修复了这个问题。修复可以通过把服务器的时区设置改成和用户的时区(东八区)相同,但是由于服务器的配置并没有代码化,我没有采用修改服务器设置。理想情况是一切基础设施也代码化,将一切系统知识采用文本记录并由 git 跟踪。
由于现在增加时区信息让前端传递不太合适(而且后期如果改成日期时间格式,并不需要前端单独传递时区信息),以及考虑到产品只面向中国用户,因此暂时在代码中将时区从默认改成了东八区, 不过,任何代码的改动,都添加了测试代码,以保证重构的安全。
- LocalDate eventStartDate = eventStartTime == null ? null : Instant.ofEpochSecond(eventStartTime).atZone(ZoneId.systemDefault()).toLocalDate();
- LocalDate eventEndDate = eventEndTime == null ? null : Instant.ofEpochSecond(eventEndTime).atZone(ZoneId.systemDefault()).toLocalDate();
+ LocalDate eventStartDate = eventStartTime == null ? null : Instant.ofEpochSecond(eventStartTime).atZone(ZoneId.of("Asia/Shanghai")).toLocalDate();
+ LocalDate eventEndDate = eventEndTime == null ? null : Instant.ofEpochSecond(eventEndTime).atZone(ZoneId.of("Asia/Shanghai")).toLocalDate();
if (eventEndDate != null && eventStartDate != null && !eventStartDate.equals(eventEndDate)) {
return "场次开始时间和结束时间需要在同一天!";
}
// 测试文件
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.ZoneId;
import java.util.TimeZone;
importstatic org.assertj.core.api.Assertions.assertThat;
importstatic org.junit.jupiter.api.Assertions.*;
classCampaignEventValidationUseCaseImplTest {
private CampaignEventValidationUseCaseImpl sut;
@BeforeEach
void setUp() {
sut =new CampaignEventValidationUseCaseImpl();
}
/**
* BUG:开始时间选择 2021-05-20 00:05,结束时间选择 2021-05-20 23:55,校验报错说:场次开始时间和结束时间需要在同一天
*/
@Test
void validate场次开始时间和结束时间需要在同一天() {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Kiev")); // 模拟服务器时区没有设置在东八区
sut =new CampaignEventValidationUseCaseImpl();
EventTime4Check event =new EventTime4Check();
event.setEventEndTime(1621526100L);
event.setEventStartTime(1621440300L);
CampaignDto campaignDto =new CampaignDto();
campaignDto.setCampaignId("12345");
campaignDto.setStartTime(1621483200L);
campaignDto.setEndTime(1622188000L);
assertNotNull(sut);
assertThat(sut.validate(event, campaignDto)).isNotEqualTo("场次开始时间和结束时间需要在同一天!");
}
}
长篇没有测试又满是 BUG 的函数
这个工程里充斥着长篇函数,状态变量特别多,而且往往在函数开头就定义一堆。如前所述,有没有测试,而且实际联调下来又有 BUG。
部分重建方案
内联只被用到一次的变量,对于被引用多次的变量,将其定义挪到第一次引用之前;
对于已知有 BUG 的分支,使用提前退出,并在单独的小函数中进行自动化测试并修复:
@Override
publicBooleanqueryCampaignHasReservableEvent(String campaignId, CampaignType campaignType) throws Exception {
// 新增代码
if (campaignType == CampaignType.CAMPAIGN) {
// 联调发现这个分支有 BUG,提前返回新的函数,并对其进行充分的自动化测试
return queryCampaignHasReservableEventForCampaign(campaignId);
}
// 原有代码
EventQueryCondition queryCondition =new EventQueryCondition();
queryCondition.setCampaignId(campaignId);
// 此处省略原有的几十行漏洞代码
...
PageInfo <EventDto> pageInfo = getOnlyEvents(queryCondition);
return pageInfo.getTotal() !=0;
}
其他重复代码等等问题
项目中居然有很多整段整段的代码重复,以及格式不规整的问题,这些问题都可以被自动扫描出来,于是搭建一个 SonarQube,以方便快速定位这些问题(扫描结果不堪入目🙈,这还只是新建没多久的项目啊……):
可以看出,重建很艰难,也是个长期工程,今天就记录到这里。