后端工程圣殿形象的崩塌以及重建

哈德韦

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



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


浏览 37
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报