作为一个前端,似乎一直有点抬不起头。总是在圈子里被鄙视,什么:前端也是程序员?什么: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
    sql CREATE DATABASE IF NOT EXISTS dbname; USE dbname;

docker-compose.yaml yaml version: 3.3 services: adminer: image: adminer:4.8.0 restart: always ports: - 7777:8080 db: image: mysql:5.7 restart: always environment: MYSQLDATABASE: dbname MYSQLUSER: root MYSQLROOTPASSWORD: 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文件,配置如下: yaml 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 文件,配置如下: yaml 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: xml ... local <package.environment>local</package.environment> true ... ...
    • 增加 application-local.properties 文件: xml env=local server.port=8080 server.http2.enabled=true
  • 新建一个 start.sh 文件,无非就是启动相关依赖,再利用 **mvn bootRun** 启动项目。总之封装成一个脚本文件,方便后续本地一键启动项目。 shell mvn clean install docker-compose up -d mvn bootRun


完全没有任何测试代码

这真是日了狗了。

我所见过的前端工程,测试占比并不多,由于前端 UI 测试代价比较大,变化多,所以很少会去写详细的自动化测试,但是会做好 file watch,一旦代码变化,界面也随之更新,所以也还好。但是对于公共逻辑,或者重要的组件,一般都是一个实现文件对应着一个测试文件,每个文件用来干什么,只要看下对应的测试文件就一目了然:
image.png
但是我打开的 java 工程,看似很专业,分层分得很细,遵照了《领域驱动设计》的层次结构:image.png
image.png
但是《领域驱动设计》中引入这个分层架构是为了方便自动化测试,并且让程序本身更加简单明了。显然这个项目工程,只是生硬照搬了分层结构,并没有实现《领域驱动设计》中引入这个结构的目的。

部分重建方案

对于新加的代码,必须添加测试。这对我来说很艰难,不太会用 java 写测试,但最终把测试加入了,每次把改动推到远程仓库前,会自动运行项目中的测试。对于这个我还没有形成一个通用的方案,只能记录一些零碎的新得。

@MockBean 可以解决测试实例的替换
对于一些依赖,在运行测试时需要控制住,这时候可以采用 @MockBean 将实际依赖替换成一个假的对象实例。前提是实现代码做到了依赖一个抽象,而不是具体的实例。

对于没有依赖接口,而是依赖特定实例实现的代码,可以增加一些 setter 以方便测试
比如这样的代码:
image.png
显然它对 HttpClient 的依赖没有使用依赖注入。应该有办法改造一下,但我还不会,于是给这个类增加了一个 setter 方法: java public void setHttpClient(HttpClient client) { this.httpClient = client; }

然后在测试代码中,通过 @BeforeEach 对其内部 httpClient 进行替换: java @BeforeEach void initialize() { mpService.setHttpClient(new MockHttpClient()); }

当然,还可以在测试中不更改 HttpClient,而是更改对应的 HttpClient 要调用的远程端点,使用 MockServer 的方式,防止在测试中调用真正的远程端点。 java @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.None) public class XXXTest { public static MockWebServer mockBackEnd;

@BeforeAll
static void setUp() throws IOException {
    mockBackEnd = new MockWebServer();
    mockBackEnd.start();
}

@AfterAll
static void 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:gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmmn +
            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 界面不会弹出日期时间选择框,而是一个数字输入框……)。

更加讽刺的是,这个设计号称能避免时区问题,结果偏偏带来了不应该有的时区问题:比如,我在调用后端接口创建某些资源时,由于业务逻辑限制开始时间和结束时间必须在同一天,我的前端明明传了同一天的时间给到后端,但后端就是一直报开始时间和结束时间不在同一天的错误!
image.png
最后查看后端代码,是这样写的: java 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 跟踪。

由于现在增加时区信息让前端传递不太合适(而且后期如果改成日期时间格式,并不需要前端单独传递时区信息),以及考虑到产品只面向中国用户,因此暂时在代码中将时区从默认改成了东八区, 不过,任何代码的改动,都添加了测试代码,以保证重构的安全。 diff

  • 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 场次开始时间和结束时间需要在同一天!; }

java 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;

import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*;

class CampaignEventValidationUseCaseImplTest { 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 的分支,使用提前退出,并在单独的小函数中进行自动化测试并修复:

java @Override public Boolean queryCampaignHasReservableEvent(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,以方便快速定位这些问题(扫描结果不堪入目🙈,这还只是新建没多久的项目啊……):
image.png
image.png

可以看出,重建很艰难,也是个长期工程,今天就记录到这里。