在日常的业务开发中,前后端联调在整个开发周期中占据着相当大的比重,在我的工作中遇到过不少影响联调效率和质量的问题,导致一些前后端的实现问题没能及早暴露,堆积到测试阶段,项目质量全靠测试人员,如果测试人员经验不足,这些问题就会带到生产环境,引发严重问题;要不然就是测试人员发现大量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越来越少~