设计模式解决什么问题
设计模式一直被认为是一门难以学习的课程。究其原因是因为我们不清楚设计模式在解决哪些问题方面发挥作用。简言之,设计是为了实现特定的目标,基于不断更新的局部知识来获得解决方案的过程。我们通常熟悉的范式是在几乎掌握所有知识的情况下解决问题,例如解数学题、物理题等。然而,在软件编程过程中,我们掌握的知识往往不充分,而且会不断更新,因此需要我们关注有哪些知识,没有哪些知识,可以获取哪些知识,如何获取知识等问题。当我们面对这些问题时,往往会感到困惑,而这种困惑稍不留神就可能带来很多漏洞,这对我们系统的稳定性构成了极大的挑战。
一些例子
怎么做错误处理
在我们的编码过程中,经常需要对于错误进⾏处理,例如Java中的Try-Catch模式,Golang中的强制错误捕捉模式。这两种模式基于两种关于“错误”的认知假设:
-
Try-Catch:我们知道错误的范围,根据错误的影响范围,抛弃错误范围内的相应操作。
-
强制错误捕捉模式;我们知道错误的特性,根据错误的分类与特性,进⾏相应的恢复操作。
在这两种模式中,需要根据我们的知识来处理错误才能够使得系统稳定地运行。如果我们的错误处理不匹配我们的知识,会使得错误处理并不能cover产生的错误,此时BUG就产生了。
对于Try-Catch模式来说,合理的错误处理应该有如下特性:Try和Catch中的内容是等价的。因为我们不知道Try中的代码和Catch中的代码哪⼀部分会被执⾏,⾃然我们就应该要求Try和Catch中的内容是是等价的(⾄少是同等可接受的)。例如:
//错误
Resource r1 = new Resource();
Resource r2 = new Resource();
try {
r1.init();
r2.init();
} catch (Exception e) {
r2 = defaultResourceForR2;
}
//正确
Resource r1 = new Resource();
Resource r2 = new Resource();
try {
r1.init();
r2.init();
} catch (Exception e) {
r1 = defaultResourceForR1;
r2 = defaultResourceForR2;
}
对于错误捕捉模式,我们一定要搞清楚我们的代码遇到的问题是什么,并确保我们的错误处理能够解决掉相应的问题。当我们不清楚会发生何种错误时,应当采取兜底方案,彻底放弃对报错代码的执行结果的预期。也就是说:使⽤特定处理⼿段时,判断条件应该为包含判断如ErrorType.contains(err);当使⽤了err!=nil时,必须使⽤使⽤兜底⽅案,例如取消执行,继续向上报错等。⼀个典型的反例:
//错误代码
err := sendData(data)
if err != nil {
reSendData(data)
}
//正确代码
err := sendData(data)
//已知错误为⽹络波动时,重试
if NetworkFluctuations.contains(err) {
reSendData(data)
}else if err != nil {
//不知道错误类型时,采⽤兜底⽅案报错并返回
record(err)
return
}
这是一段关于ETL软件处理错误的示例,采用了三段式回滚设计,以确保数据能够传输到下游。对于网络波动,使用重试处理是可行的。为了确保数据不丢失,不设置重试上限也是正确的。然而,问题在于err不仅仅是网络故障这一种情况。例如,当err本身是由于下游拒绝接收数据时,这个错误处理逻辑会形成一个死循环。
什么时候应该记录⽇志:
我们关于系统的知识并不是完备的,这⼀点在软件编码中体现地很明显。处理这种不完备的重要⼿段之一是获取新知识。我们在代码的Debug过程中,经常会碰到⼀整个调⽤链路上,不知道哪⼀点出现了问题,导致Debug的⼈效极低。那么,如何留日志才能够高效的解决故障?
⽇志本⾝是为了将代码运⾏过程中的未知点变成已知部分的内容。当我们不知道代码运⾏会发⽣什么时,就应该留⽇志。特别是在进⾏与他人进行交互,例如⽤户上传数据或调⽤其他部门的SDK等情况时,更应该留下日志。因为我们难以完全预期他⼈的⾏为,如果没有⽇志,我们就无法了解系统发⽣了什么。
通过⽇志,我们应该能够获取处理未知情况,特别是异常状况的相关知识。例如,为了弄清访问下游网络时未收到正确回复数据的问题,我们应该编写日志。但是,如果我们只记录了我们访问下游的日志,仅能确定我们发送了相关请求,并无法处理未收到正确回复数据的问题。只有记录了下游的响应时间,我们才能知道上下游之间的网络通信是否畅通;只有记录了下游返回的响应编码,我们才能知道我们的请求是否符合格式;只有记录了下游返回的响应内容,我们才能知道下游回复的内容是否符合我们的预期。
为什么要先写单元测试,再去进行具体的编程
这是因为单元测试是一种描述编码需求的知识,是关于目标的知识,而不是关于路径的知识。当然,确定目标后才能完成路径的实现。研发人员经常抱怨需求不清晰,那么是否能反问一个问题:什么是明确的需求?我认为明确的需求就是给出一个函数F,对于我们的代码x,如果F(x)=1则完成了需求;如果F(x)=0则未完成需求。因此,一个明确的需求自然包含单元测试。
举个例子,同学A分配给同学B和C两个子任务。B同学需要编写一个函数,能够把服务器上指定位置的临时文件传递到永久存储中,并返回永久存储中的地址。C同学需要根据永久存储地址将内容同步到一个指定的Redis中。如果我们不写单元测试,直接进行编码开发,就会面临以下问题:
-
永久存储是什么,是对象存储还是MySQL?
-
服务器上指定位置是否需要特殊权限?Redis中是否需要特殊权限?
-
Redis中的存储格式是什么?选择什么作为RowKey?
这些问题不一定能在编码之前想清楚。如果出现不对齐的情况,可能会导致代码功能失效,需要重新编写。而如果我们先写单元测试,就能在写单元测试的过程中搞清楚很大一部分之前没有注意到的问题。
展望:
虽然本文在工程实践中解决了一些问题,但远远没有完善。还有很多现实和理论上需要处理的问题。例如,运用设计模式本身就会带来成本,如何平衡成本与带来的收益?我们从经验中总结出的设计模式只是一种“博物馆”式的解决方案,能否建立一种规则化的方案,对所有知识,而不仅仅是代码知识进行形式化建模?这些问题仍有待解决。