前后端联调经验总结

2023-09-05 16:32 月霖 1008

在日常的业务开发中,前后端联调在整个开发周期中占据着相当大的比重,在我的工作中遇到过不少影响联调效率和质量的问题,导致一些前后端的实现问题没能及早暴露,堆积到测试阶段,项目质量全靠测试人员,如果测试人员经验不足,这些问题就会带到生产环境,引发严重问题;要不然就是测试人员发现大量bug,开发疲于修复,测试疲于回归,甚至是修复一个问题又引发一堆新的问题。在此,总结一下改善这些问题的方法。

一、接口设计阶段

一般来说,前端静态页面的开发总是比接口开发要快,因此后端需要优先完成接口定义,以便前端可以尽早进行对接,而且这个过程也有助于后端理解整体的需求,后续联调的时候,也能有效降低前后端的沟通成本。在定义接口时,有几个方面可以注意一下:

  • 明确入参字段是否必填
  • 标注字段含义
  • 写明枚举类型的取值
  • 相同含义的字段名保持一致

是不是很简单,看着也许会觉得“这不是理所当然的嘛”,但实际中确实有不少没能做到的情况。比如不同的模块是由不同的开发人员定义的,或者项目经过了多次需求变更,开发人员有变动之类的,都可能导致字段的命名不一致(我见过一个项目里,“创建日期”这个字段在有些接口里叫createTime,有些叫timeFrom)。

下面还有个例子:

这张图是个学生信息接口的一部分,里面的schoolName和stuSchool让人很疑惑,看上去都是学生所在学校,没有备注,不知

道是什么区别。如果后续又有需求变更,开发人员又换了,只能看代码,如果代码里还没注释,那还得花时间沟通、琢磨。

二、前后端开发阶段

1.后端充分自测

在开始联调之前,后端必须进行充分的自测:

  • 出入参字段、结构是否和接口定义一致
  • 对入参是否做了充分的校验
  • 针对不同的入参是否返回了正确的数据
  • 一项数据变更后,与之相关联的数据是否得到了处理
  • 异常情况是否妥善处理了
  • 我在联调的时候,碰到过各种500、400的报错,有空指针的、有SQL报错的(字段不存在、语法错等等),还有什么都没返回的、无响应的,而且大多还都是在入参正确的情况下报的,这些报错会导致流程走不下去,都应该在自测的时候发现。除了这类直接报错,更多的是数据类的错误:修改了数据但是没有生效的(比如用户禁用了照样能登录);涉及到权限方面的(比如公司列表上的设备数量返回了10条,点进去的设备列表没有做公司隔离,返回了所有的设备);涉及到关联数据的(比如一个字段从必填改为选填,如果后端仅仅是去掉一个NotNull注解,不去关注这个字段相关联的逻辑,那就会导致各种各样的问题)。
  • 这些报错有大部分对于后端来说只需要几分钟就能改好,但是对于前端或者测试来说,要花很多时间来造数据、验证、沟通,有时改了之后还有问题,甚至引发了更多问题,反反复复,挺恼人的(我曾因为一个注册功能的变更,联调了4、5个小时)。
  • 所以后端务必要进行自测,以杜绝一些显而易见的错误。我认为最好的方式就是写好单元测试,尤其是针对那些关键的、复杂的模块。单元测试的好处很多:
  • 有助于反思模块划分的合理性(如果一个单元测试写得逻辑非常复杂、或者说一个函数复杂到无法写单测,那就说明模块的抽象有问题)。
  • 可以及早发现bug,保证代码质量。
  • 可以为功能变更,或者代码重构提供快速的回归。
// 一个简单的单测示例,仅作示范说明
@Test
public void testUserEnable() {
    Integer userId = 1;
    List<Integer> inputs = Arrays.asList(CommonEnum.NO.getCode(), CommonEnum.YES.getCode());
    List<Integer> expects = Arrays.asList(CommonEnum.NO.getCode(), CommonEnum.YES.getCode());
    
    // 第一次禁用,第二次启用
    for (int i = 0; i < inputs.size(); i++) {
        UserEnableInVo ueInVo = new UserEnableInVo();
        ueInVo.setUserId(userId);
        ueInVo.setIsEnable(inputs.get(i));
        userService.userEnable(ueInVo);

        // 查询用户详情
        UserDetailInVo inVo = new UserDetailInVo();
        inVo.setUserId(userId);
        UserDetailOutVo outVo = userService.userDetail(inVo);
        Assert.assertNotNull(outVo); // 断言用户存在
        Assert.assertEquals(outVo.getIsEnable(), expects.get(i)); // 断言用户状态
    }
}

2.前端接口对接

在后端接口开发完之前,前端可以先根据接口定义来对接接口,在这个过程中,前端可以及时发现一些接口定义上的问题,比如字段缺失、拼写错误、结构与页面不符等等,还要注意接口设计是否合理,比如一个功能如果要连续调好几个接口才能拿到想要的数据,那肯定有点问题。

对接时一定要mock数据,而且尽量mock一些符合业务的数据,这么做有几个好处:

  • 有助于完善页面交互。可以mock一些正常数据、异常数据,用来检验页面有没有报错,数据展示、各类事件、跳转是否都正常。
  • 有助于理解业务。无意义的数据想怎么造就怎么造,无法引导你进一步思考,只有当你想要造些合理的数据时,你才会开始考虑这个数据是从哪里来的,它应该是什么样的,它如果发生了改变会不会影响其他地方等等。
  • 可以对后端数据有所预期,在后续联调时,可以更容易地发现后端返回数据的问题。
  • 一般来说,后端接口的开发总是比前端慢一些,所以前端应该在对接的时候尽可能地完善页面展示和交互,多思考业务逻辑,不要等到联调才开始做交互。可能会有人这么想:“接口都没好呢,我现在对接,还要花时间mock数据,等接口好了,说不定还有变更,我还要改,太麻烦了,还不如等接口好了再开始做”,我非常不认同这种观点,这个阶段的交互、对接做好了,后续联调只需要切换个接口地址,然后就可以把注意力放在数据和业务流程上,否则还要花大量时间做交互,数据都顾不上了。

三、联调阶段

联调阶段最需要关注的就是数据和业务流程(接口五花八门的报错、前端的交互问题都应该在各自的开发阶段就处理得差不多了),前端决不能抱着“数据都是后端处理的,跟我没什么关系”、“接口不报错就行了,反正后面测试也会测”这样的想法。联调的目的就是前后端一起把整个流程走通,并且确保数据流转正确无误,我始终认为问题越早暴露越好,为此,前端也必须对业务很清楚。联调时,前端需要注意一些问题:

  • 接口返回空数据。这种情况必须和后端确认,如果数据来自三方并且还没有提供,也应该由后端配合造些数据(其实我认为,如果后端进行了自测,那一定会有测试数据返给前端,所以空数据大概率会有问题。根据我的联调经验来看,事实也确实如此)。
  • 重点关注有关联的数据。前端必须清楚页面上的每一项操作会带来什么样的数据变化,举个例子,有一个申请注册的功能,用户注册后,管理员会在通知中心收到一条申请通知,并且可以看到对这条通知的处理情况;另外有一个专门的审核页面供管理员进行审核(通过或拒绝),通知中心和审核页面是两个不同的页面,它们调用不同的接口,但它们的数据是有关联的(对这条申请的处理结果),所以联调的时候不仅仅只是看一下通知有没有收到,审核页面有没有这条数据,还需要看看审核完成后,通知中心的这条记录的数据是不是正确。
  • 前端在向后端报接口问题时,需要给出具体的入参,描述清楚具体的问题,如果有报错,就把具体的报错信息贴给后端,协助后端定位问题。
  • 此外,如果接口有变动,后端应该及时更新接口文档,并且将变更项告知前端。

四、适时进行代码重构

常常会有这样的情况:项目一开始还是写的挺好的,但是随着一次又一次的需求变更以及新功能的加入,项目渐渐往糟糕的方向发展。举些我碰到过的例子:

  • 随着字段和各类判断的增加,方法里的 if else 越来越多,甚至一些不很相关的判断也放在一起,越来越臃肿。
  • 后端为了方便处理,在接口定义里加上了奇怪的入参让前端来传。或者不恰当地复用接口,比如一个消息接口,原本只有一类消息,于是接口定义完全是按这一类消息来设计的,字段名也完全特定于这类消息,后来又加了一类完全不同类型的消息,后端直接复用了原来的接口,导致这个接口在这两个不同的类型下,相同字段的含义是不同的。
  • 前端没有及时拆分组件,随着功能的增加,导致页面逻辑越来越凌乱。
  • 从表面上来看,功能快速开发完,页面功能正常,很好没问题。实际上处处都是隐患,不断积累,最终导致“稍微改一改就一堆问题”。所以我认为不要为了一时的省事而把问题积累下去,应该花些时间审视自己的设计和代码,及时进行重构。
  • 贴一段夸张的代码,一个解析excel的方法(字段少的话也就算了,这儿足足43个,位置还都硬编码写死了,其中某些字段还涉及计算,excel格式有变更,那可折腾了,一个个对过去,还特别容易出错):
private List<QuotationDetail> parseFromCAD(String pathInVo){
    // ...省略

    List<QuotationDetail> quotationDetails = new ArrayList<>();
    Sheet sheetAt = workbook.getSheetAt(0);

    int rowIndex = 0;
    for (Iterator<?> rowIterator = sheetAt.iterator(); rowIterator.hasNext();){
        Row row = (Row) rowIterator.next();
        if (rowIndex <= 1){
            rowIndex += 1;
            continue;
        } else if ( "".equals(row.getCell(1).toString()) || "".equals(row.getCell(3).toString())){
            break;
        }

        QuotationDetail quotationDetail = new QuotationDetail();
        if (null != row.getCell(0) && !"".equals(row.getCell(0).toString())){
            quotationDetail.setBarCode(row.getCell(0).toString());
        }

        if (null != row.getCell(1) && !"".equals(row.getCell(1).toString())){
            quotationDetail.setRegionalPosition(row.getCell(1).toString());
        }

        if (null != row.getCell(3) && !"".equals(row.getCell(3).toString())){
            quotationDetail.setRegionalIdentity(row.getCell(3).toString());
        }

        if (null != row.getCell(4) && !"".equals(row.getCell(4).toString())){
            quotationDetail.setFigureNumber(row.getCell(4).toString());
        }

        if (null != row.getCell(6) && !"".equals(row.getCell(6).toString())){
            quotationDetail.setArticleNumber(row.getCell(6).toString());
        }

        if (null != row.getCell(7) && !"".equals(row.getCell(7).toString())){
            quotationDetail.setTradeName(row.getCell(7).toString());
        }

        if (null != row.getCell(8) && !"".equals(row.getCell(8).toString())){
            quotationDetail.setTexture(row.getCell(8).toString());
        }

        // ...省略 9 ~ 41(实在太长了)
        
         if (null != row.getCell(42) && !"".equals(row.getCell(42).toString())){
            ScriptEngine js = new ScriptEngineManager().getEngineByName("JavaScript");
            Double distributionUnitPrice = Double.valueOf(row.getCell(19).toString());
            Double additionalFee = Double.valueOf(row.getCell(25).toString());
            Double sizeAbnormal = Double.valueOf(row.getCell(26).toString());
            Object eval = null;
            try {
                eval = js.eval(distributionUnitPrice + row.getCell(40).toString() + "*" +
                        (1 + additionalFee + sizeAbnormal));
            } catch (ScriptException e) {
            
            }
            Double AP3 = Double.valueOf(eval.toString());
            Double quantityAmount = Double.valueOf(row.getCell(18).toString());
            Double quantity = Double.valueOf(row.getCell(16).toString());

            quotationDetail.setCostSubtotal(AP3 * quantityAmount * quantity);
        }

        if (null != row.getCell(43) && !"".equals(row.getCell(43).toString())){
            quotationDetail.setDescription(row.getCell(43).toString());
        }

        quotationDetail.setCreateTime(LocalDateTime.now());

        quotationDetails.add(quotationDetail);
        rowIndex += 1;
    }
    return quotationDetails;
}

重构后的代码(将解析方法进行了封装,用提取字段的方式取代了原来的硬编码,然后再进行解析)

private Map<Integer, String> createColumnFieldMap(Sheet sheet, Map<String, QuotationFunc> quotationFields) {
    Map<Integer, String> columnFieldMap = new LinkedHashMap<>();
    Row row = sheet.getRow(quotationTitleRowIndex);
    if (row != null) {
        for (int i = 0; i < row.getLastCellNum(); i++) {
            Cell cell = row.getCell(i);
            if (cell != null && quotationFields.get(cell.getStringCellValue()) != null) {
                columnFieldMap.put(i, cell.getStringCellValue());
            }
        }
    }
    return columnFieldMap;
}

private List<QuotationDetail> getQuotationDetailsFromSheet(Sheet sheet, String quotationType, Map<String, QuotationFunc> quotationFields) throws QuotationException {
    List<QuotationDetail> quotationDetails = new ArrayList<>();

    Map<Integer, String> columnFieldMap = createColumnFieldMap(sheet, quotationFields);
    for (int i = quotationContentRowIndex; i <= sheet.getLastRowNum(); i++) {
        QuotationDetail quotationDetail = new QuotationDetail();
        quotationDetail.setQuotationType(quotationType);
        Row row = sheet.getRow(i);
        if (row == null) {
            break;
        }
        for (int j = 0; j < row.getLastCellNum(); j++) {
            String field = columnFieldMap.get(j);
            if (field == null) {
                continue;
            }
            // 解析单元格
            Cell cell = row.getCell(j);
            QuotationFunc func = quotationFields.get(field);
            if (func != null && func.getFuncSet() != null) {
                String value = "";
                if (cell != null) {
                    CellType cellType = cell.getCellTypeEnum();
                    if (cellType == CellType.STRING) {
                        value = cell.getStringCellValue();
                    } else if (cellType == CellType.NUMERIC) {
                        value = String.valueOf(cell.getNumericCellValue());
                    }
                }
                // 字段检验
                if (func.getFuncValidate() != null) {
                    if (!func.getFuncValidate().apply(value)) {
                        throw new QuotationException(String.format("[%s]%d行%d列"%s"%s",
                                sheet.getSheetName(), i + 1, j + 1, field, StringUtil.isBlank(value) ? "为空" : String.format("格式非法[%s]", value)));
                    }
                }
                // 字段转换
                String transferedValue = null;
                if (func.getFuncTransfer() != null) {
                    transferedValue = func.getFuncTransfer().apply(value);
                }

                func.getFuncSet().accept(quotationDetail, transferedValue == null ? value : transferedValue);
            }
        }

        quotationDetail.setStandard(QuotationProductStandardEnum.BIG_NON_STANDARD.getTitle());
        quotationDetail.setProductPrice();
        quotationDetails.add(quotationDetail);
    }

    return quotationDetails;
}

代码看起来比重构前的更复杂,但是后期修改就轻松多了。

再举一个前端拆分组件的例子,一个项目里有个步骤条,组件库提供的步骤条不太适用,需要自己写一个,我想着反正整个项目也就这一个,我也不单独封装了,直接写在了页面上。后来新加了个需求,又有了个一个步骤条,我开始纠结,是要把原来的拆个组件出来复用,还是直接把这段代码复制过去改一改。拆么还得改原来的代码,复制过去改一改是挺容易的,但万一后面再来一个,再复制一遍就不好了,最后还是要拆,还得多改一处代码。于是决定拆出来,代码看上去也更简洁了~


<div class="competition-update" style="display: flex; flex-direction: column;">
    <page-header :title="title" :has-back-btn="true" />
    <section style="margin-bottom: 10px; padding: 15px 10px 0 10px;">
      <!-- 重构前步骤条 -->
      <div class="steps">
        <div
          v-for="(item, index) of steps"
          :key="item.type"
          :style="{width: 'calc(100% / ' + steps.length + ')'}"
          :class="[ curStep === index ? 'is-active' : '', item.isFinished || index < curStep ? 'is-finished' : '' ]"
          class="step"
          @click="changeStep(index)"
        >
          <div class="step-line" />
          <div class="step-icon" :class="item.type" />
          <div class="title">{{ item.title }}</div>
          <div class="desc">
            {{ item.finishTime ? item.finishTime.split(' ')[0] : '' }}
            <br>
            {{ item.finishTime ? item.finishTime.split(' ')[1] : '' }}
          </div>
        </div>
      </div>
    </section>

    <!-- 省略 -->
  </div>

重构后单独封装了这个步骤条,所有跟它相关的逻辑、样式都可以从页面中去掉了:

<div class="competition-update" style="display: flex; flex-direction: column;">
  <page-header :title="title" :has-back-btn="true" />
  <section style="margin-bottom: 10px; padding: 15px 10px 0 10px;">
    <!-- 封装的步骤条 -->
    <m-steps :steps="steps" :cur-step="curStep" @change-step="changeStep" />
  </section>
  
  <!-- 省略 -->
</div>

因此,为了避免代码变得越来越臃肿,越来越难以维护,务必花点时间对有问题的代码进行重构。

五、总结

如果前后端都能做好上述每个阶段的工作,对自己的代码负责,最终提测的版本肯定不会差,当然,不可能一个bug都没有,但是肯定不会有严重到影响流程的问题,而且后期项目也会更容易维护。

愿项目质量越来越高,bug越来越少~