Day 09 「世事难预料」单元测试与例外处理

世事难预料,写程序总会遇到例外。例外该怎麽处理,逻辑该怎麽验测,本篇将进行讨论。


图片撷取自网路

「例外处理有什麽难的。不过就是 try-catch 吗?」

嗯,其实对也不对。例外处理基本起手式的确就是 try-catch,但是同样是 try-catch,处理得好不好,结果差很多。根据笔者过往经验,就算例外处理做得再差,还是能把功能做出来是没错,但是後续维护上就会遇到很多麻烦。包括可读性、易修改性等。Kent Beck 曾在「Implementation Patterns」一书中提到,现代的软件开发,花在维护与修改上的成本,比起开发本身,还要高出许多。因此,做出难以维护的功能,不是长久下来会吃亏,而是很快就会吃亏。

例外处理简介

说到例外处理,我们就先来简单看一下例外本人。

在程序中,会遇到的例外有两种情形:

  1. 可修复:例如网路突然断了一下,或是档案不存在
  2. 不可修复:例如应该要有值的地方竟然是 null,也就是出 bug 了

简单来说,可修复的例外称为 Checked Exception,可以试试 retry,采取替代方案,或是 log 下来。不可修复的意外称为 Unchecked Exception,代表程序有 bug,这时程序本身已经帮不上什麽忙,应该要让 RD 来排查,把 bug 解掉。

然而,光靠例外的 1) 类型与 2) 可恢复性,也还不足以决定怎麽处理,程序设计者应该要再追加考虑三个条件:

  1. Application Context:这个错误在系统中代表什麽意义?
  2. Robustness Level:你想要做到回报错误就好,还是要确保使命必达,还是介於两者之间?
  3. Exception Handling Policy (Strategy):你想要怎麽处理,是要重试,还是要回报,还是其他?

以上五个因素综合考虑,便可以帮助程序开发者在遇到例外时,依照一定的原则,做出比较妥善的处理,如此一来一旦上线真的遇到问题,就不会这麽难以排查了。

当然,本文没有要详细介绍例外的意思,主要是因为这其实是门专门的学问,而且本系列文章也不是以例外为中心。以上内容节录自部落格「搞笑谈软工」作者 Teddy Chen 的着作:「笑谈软件工程:例外处理设计的逆袭」。建议读者可以参考一下,会很有帮助的。书买不到没关系,订阅部落格也有相同功效。

我们准备要跳回测试了。在本文中,我们假设各位抓到一个例外,并且依据上述的五大因素,准备要采取行动了。以下介绍两种较常见的处理方式,以及对应的测试方法。

拦下来处理

任何时刻底层出错,你觉得在这一层「应该处理,也有能力处理」时,就可以将特定 Exception 拦下来,并在 catch block 里面做对应的处理。譬如,在网站後端的 Controller 层,当收到了 Service 层抛出来的例外,他应该要拦下来处理,因为他如果不处理,这个 Query 就会整个出错。以 Java 的 Spring Boot 来说,这会使得前端网页收到一个很诡异的「500 - Internal Server Error」讯息,如下:


图片截自网路

当看到这个画面,用户只知道「有地方出错了」,但其他讯息一无所知(或是看不懂)。这会大大影响用户的感受,因此这件事不能发生。

而且,假设我们的处理,是要回传前端认识的错误代码,那麽 Controller 层也有能力处理,因为他可以从来源的 Query、自己身上的资料、及 Exception 里面传递的讯息,自行组成前後端讲好的资料格式并回传。此回传可能是代表错在前端的 400,也可能还是代表错在後端的 500,但无论如何,它都应该还要在回传数据里塞入足够让前端分辨原委的资讯,才不会搞得用户一头雾水,不知道哪里出问题。

举例来说,假设我们要做一个「学生注册」的网页与 API,Repository 处理到一半发现学校根本没有这个学生的资料,这时应该怎麽办?这时我们应该要把前後端开发者叫来,两边讲好一个沟通方式,让相同情况发生时,前端有足够讯息显示正确画面给用户看。譬如:

@PostMapping("/register")
public ResponseEntity<ApiResponse> createTeam(@RequestBody RegisterRequest request) {
    try {
        service.execute(request);

        // ApiResponse 是前後端共同讲好的回传资料格式
        // 这里与前端说好,如果成功就直接回 200 就好,内容可以「空白」
        return ResponseEntity.ok(ApiResponse.empty());
    } catch (StudentNotExistException e) {
        // 我们不认为这是系统出错,所以写 info 就好,以免过多 error 在 log 里干扰阅读
        log.info("Student not found. " + e.getMessage());

        // 用 Http 400 告诉前端这是用户有问题,
        // 并在 body 里带一个特殊代码「987」,代表「用户不存在」
        return ResponseEntity.status(400).body(ApiResponse.bad(987));
    }
}

程序虽短,但我还是在程序码中加了一些注解,以帮助不熟悉 Java 与 Spring Boot 的读者更容易了解意图。

这里对於底层吐出来的例外,经判断决定要「处理」,并且处理的方法为,先记下 log,再回传 Http 400 给前端,并透过沟通好的格式,告知详细原因。因为逻辑分支有两条,於是,单元测试就得包含两种情况:

  1. 一切正常,回传 Http 200
  2. 找不到学生,回传 Http 400,且 body 内容包含 987 这个特殊代码

事不宜迟,我们就赶紧藉由测项来检查程序有没有把这两个逻辑分支都写对吧:

@SpringBootTest
@AutoConfigureMockMvc
class RegisterControllerTest {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private RegisterService service;


    @Test
    void all_ok() throws Exception {

        MockHttpServletRequestBuilder postRequest = MockMvcRequestBuilders
                .post("/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(new RegisterRequest(35L)));

        mockMvc.perform(postRequest)
                .andExpect(status().is(HttpStatus.OK.value()));

    }

    @Test
    void student_not_found() throws Exception {

        Mockito.doThrow(new StudentNotExistException("ANY_MESSAGE"))
                .when(service)
                .execute(any(RegisterRequest.class));

        MockHttpServletRequestBuilder postRequest = MockMvcRequestBuilders
                .post("/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(new RegisterRequest(35L)));

        mockMvc.perform(postRequest)
                .andExpect(status().is(HttpStatus.BAD_REQUEST.value()))
                .andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.bad(987))));

    }


}

至於先前提到的「重构以隐藏细节」,读者可以自行试看看,如果懒得试,也可以下载 GitHub Repository 里的档案来参考看看。连结在下方 Reference 处。

有些读者或许已经发现,「写 log」这个逻辑没被测到。这不是笔者的疏漏,我故意不测的。有写过单元测试的读者也许有这个经验:改了程序里一点点无关紧要的细节,却坏了很多测试。这种情形很普遍,坊间称之为「过度测试」。其实测试跟程序一样,写的粗跟细都各有优缺点,程序的每个细节行为都测得钜细弥遗固然安全,但也使得付出的成本提高。

其实很多技术细节都是取舍。坏掉或行为不一致会造成巨大损失的东西,那就一定要测,像在这里写的这一行 log,如果把内容稍做修改,应该也不会造成什麽损失,那我们就选择不测,让测试里面只保留最重要的逻辑,同时阅读时也可以避免干扰,一举两得。

拦下来转抛

例外处理的另一种方式,就是「拦下来转抛」。譬如说,底层也许会抛个 IOException,而你判断这一层并没有能力处理,或是在架构上由上一层处理比较适合,这时你就可以拦下来以後,在 catch block 里面,转包一个跟上层讲好的 Exception 出去,至於上层要怎麽处理,你就不用管了。

一样是举刚刚「学生注册的例子」,假设在第二层的 Service,收到来自 Repository 层的 Exception,并且从各个方讯息判断出这个情况应该是「学生不存在」,这时我们就要来看看他是否应该处理。

由於「学生不存在」这件事应该要回报给前端显示,所以讯息的发送应该要让 Controller 进行。事实上,也只有 Controller 应该要知道前端想收什麽 Error Code。因此,这时就适合「拦下来转抛」这个做法。程序大概会长这个样子:

public void execute(RegisterRequest request) throws StudentNotExistException {
    try {
        repository.register(request);
    } catch (DataNotFoundException e) {
        throw new StudentNotExistException("Student not exists", e);
    }
}

这里因为会转抛一个例外,所以测试就不能让他顺利跑完,而是要让在测项里把例外抓下来,并检查这个例外长得是否符合预期:

class RegisterServiceTest {

    private final StudentRepository repository = Mockito.mock(StudentRepository.class);

    @Test
    void when_student_not_exists() throws DataNotFoundException {

        given_student_NOT_exists(35L);

        try {
            create_register_service().execute(request(35L));
            fail("should throw exception");
        } catch (StudentNotExistException e) {
            assertThat(e).hasMessageContaining("not exists");
        }

    }

    // ... 以下细节省略
}

这里读者可以发现两件事:

首先,我虽然有验 Exception 的 message 内容,但是我没有规定他整句话的样貌。这跟前面讨论 log 时很像,我不太想要在这里验太细,只要 message 里有提到「not exists」,我认为就足够了,其他细节後面的人可以随意调整,我不认为有太大影响。

第二,我这里用了一个初登场的语法:fail。这是因为我希望制造出一个「转抛 Exception」的场景,所以如果在这场景下,程序竟然能够顺利跑完,我就认为这其中肯定哪里有误会,於是就强制把整个测试都 fail 掉。

其实坊间主流的测试框架有提供蛮多方法可以验例外的,譬如,写成这样,各位觉得如何?

    @Test
    void when_student_not_exists_alternative() throws DataNotFoundException {

        given_student_NOT_exists(35L);
        
        StudentNotExistException actualException = Assertions.assertThrows(
                StudentNotExistException.class,
                () -> create_register_service().execute(request(35L))
        );
        assertThat(actualException).hasMessageContaining("not exists");
        
    }

其实这里只是提供一两种做法给各位参考而已。测试方法会随者各位使用的框架与套件而有所不同,各位读者或是你们团队可以选择自己喜欢的工具与方法来使用就好。

这里留个思考题:「大家觉得这两种写法有什麽不同,哪种你比较喜欢,为什麽?」大家可以拿自己手上的专案来套一下,感受一下,也欢迎文章下方留言分享 :)

为什麽要转抛?为什麽不直接往外丢就好?

「迪米特法则」,又称为最少知识原则(Least Knowledge Principle),包含三大原则,其中一项就是「只与你直接认识的人交谈」。你说这跟例外有什麽关系?有啊!举个例子,当存取资料时, Repository 会进行 DB 操作,而当出错时会抛出 SQLException,这时 Service 收到 SQLException 就知道底层的 DB 操作出了问题,这样的介面很合理,对吧?

「不对喔!」

图片截自网路

谁告诉你 Repositry 一定要操作 DB 的?它不能操作 API 吗?它不能跟 Redis 拿吗?它可不可以操作本机上的档案?可以吧!既然可以,那凭什麽 Service 非得认识 SQLException 不可?

说到底,Repository 应该要是 Service 定义好的介面,根据 OOP 的依赖反转原则,Service 不应该直接依赖於 Repository 的实作,而应该「共同依赖一个介面」才对。

换句话说,既然实作可以多变,那收到 Exception 的那一个实作,就不应该直接转抛出去,而是转化成它与上层共同依赖的介面所定义好的 Exception 才对,否则他就会强迫呼叫方去认识他原本不该认识的人,就违反迪米特法则了。

为什麽不用 Runtime Exception?

先讲结论:「很简单,因为我不喜欢。」我声明,不是他不好,只是我不喜欢而已。

我知道,我知道大家都有读过 Clean Code,我知道大家都记得,Uncle Bob 在书中建议大家尽量多用 Runtime Exception。但你知道为什麽他会这麽建议吗?也许你一时忘了,我们一起来复习一下:Uncle Bob 认为,有很多人会把丢例外跟处理例外的逻辑,放在好几层远的地方,而语言与框架也允许我们这麽做。然而万一底层有需要修改,沿途的所有类别都得修改签名,违反了「开放封闭原则」,非常麻烦。

此时,如果我们丢的是 Runtime Exception,因为不用在签名上宣告,所以错误从最低层往上走,一路都不用宣告,他自然会被传递到最高层,也就是你的「系统边界」,这时再抓下来处理就好,非常方便,而且底层丢的例外就算有换,沿路的类别也不用再修改,满足了「开放封闭原则」。非常多 Java 开发者(譬如 MyBatis 那群人)也喜欢这种处理方式,因为很方便。

好处还不只如此。根据 Teddy Chen 在「例外处理设计的逆袭」书中的论述,例外的处理,有几种强健度等级,最差就是「放给它烂」,稍好一点就是做到「通知」,一路到最高等级就是「使命必达」(亦即容错),而成本也随着强健度等级越高而越贵。使用 Runtime Exception 後,啥都不干,就可以自动达到「通知」的强健度等级。是不是也很方便?

方便是方便,但是这种做法,使得最上层的人「跨层」跑去认识他不该认识的 Exception。如前所述,每个人都应该要像迪米特女神一样,只认识「直接的朋友」。譬如,处在系统边界的 Controller,表定的工作应该是要担任框架与 Service 的桥梁,转化物件与资料,让两边能顺利沟通。结果到头来整天在处理一些表示 DB 连线异常、档案不存在...等等错误,说老实话,这些错误关它什麽事?它应该要处理语意上更高阶的错误才是,譬如「学生不存在」、「成绩未达标」等等。因为它是负责把这些语意再转成前端看得懂的讯息回出去的人啊!

再者,Java 表现 Unchecked Exception 的类就是 Runtime Exception,而 Unchecked Exception 本应该代表 bug,如果拿来代表所有错误,那判断上又稍微模糊难辨了一点,这点我也不太喜欢

我们回到 Uncle Bob 描述的场景,不难发现,使程序违反开放封闭原则的元凶,并不是 Checked Exception 本身,而是「不良的设计习惯」。收到底层丢出的例外後,不假思索地沿路往外抛,才会造成违反开放封闭的问题。此时如果「全面改用 Runtime Exception」,虽能解决问题,但副作用却是又违反了迪米特法则。因此我认为这不是一个非常好的解法。

参考「例外处理的逆袭」,比较乾净的解法,应该是要每当遇到 Checked Exception 时,要使用「转包或处理」模式。亦即,如果能处理就处理,万一不能处理,也不要原封不动丢出去,要拦下来转包成上层应该认识的样貌,这样就可以同时解决「开放封闭」的问题,也不会造成「迪米特」的新问题。这时如在系统边界还是遇到了 Unchecked Exception,就只剩下「bug」了,那事情就好办多了,有 bug 修理就是了。然而,不可否认像这样的解法,每层都要考虑到底是要转包还是处理,真的很麻烦,要一直想事情、一直 try-catch 很讨厌。

「说这麽多,就是每个解法都有问题罗!」欸对啊,本来世上就不存在完美的解法,所以我一开始针对「使用 Runtime Exception」这件事,只说了「我自己不喜欢」,我可没说各位不能用,更没说 Uncle Bob 不对哦!请千万别误会啊各位,小弟只是读过两年书,尘世中一个迷途小 RD 而已啊...


图片截自网路

谜之声:「没有完美的解法,端看自己想要承担哪一种副作用而已。」

Reference

  1. Kent Beck, Implementation Patterns, 2007
  2. 陈建村,笑谈软件工程:例外处理设计的逆袭,2014
  3. 搞笑谈软工部落格:https://teddy-chen-tw.blogspot.com/
  4. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
  5. 迪米特法则:https://en.wikipedia.org/wiki/Law_of_Demeter
  6. 迪米特女神:https://zh.wikipedia.org/wiki/%E5%BE%97%E5%A2%A8%E5%BF%92%E8%80%B3
  7. 依赖反转原则:https://en.wikipedia.org/wiki/Dependency_inversion_principle
  8. 开放封闭原则:https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle#:~:text=In%20object%2Doriented%20programming%2C%20the,without%20modifying%20its%20source%20code.
  9. Martin, Robert C. Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  10. A quick discussion with Joey Chen.
tags: ithelp2021

<<:  30天轻松学会unity自制游戏-制作敌人

>>:  .NET Core第9天_MVC_Model的引入

Day 5:浅谈警报 (alert) 的设计

前天使用 updown.io 架设了 status page,并且让它可以在服务无法连上的时候,自动...

简报版-第五章-从手机安全更新认识安全更新年限、回收资料安全与定位追踪

其实原本最初规画想要做Index方式的纪录,然後多增加一些没写到的面向 不过,总是计画赶不上变化 ...

Day 14 关键字品质分数

当你设置完关键字等广告,接着可以使用品质分数这个工具去观察,好让你可以适度做些调整,透过这些品质分数...

DAY 8 『 CollectionView 』Part1

CollectionView:Storyboard、Xib + Collection View + ...