mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
mod
This commit is contained in:
214
极客时间专栏/软件设计之美/巩固篇/30 | 程序库的设计:Moco是如何解决集成问题的?.md
Normal file
214
极客时间专栏/软件设计之美/巩固篇/30 | 程序库的设计:Moco是如何解决集成问题的?.md
Normal file
@@ -0,0 +1,214 @@
|
||||
<audio id="audio" title="30 | 程序库的设计:Moco是如何解决集成问题的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/06/d8cc875aeb02c81be064e4c7907b1806.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
经过前面内容的讲解,我终于把软件设计的基础知识交付给你了,如果你有一定的经验,相信有很多东西你已经可以借鉴到日常工作中了。
|
||||
|
||||
但是对于一些同学来说,这些知识恐怕还是有些抽象。那在接下来的几讲中,我会给你讲几个例子,让你看看如何在日常的工作中,运用学到的这些知识,巩固一下前面所学。
|
||||
|
||||
我在[第9讲](https://time.geekbang.org/column/article/245878)说过,学习软件设计,可以从写程序库开始。所以,我们的巩固篇就从一个程序库开讲。这是我自己维护的一个开源项目 [Moco](https://github.com/dreamhead/moco),它曾经获得 2013 年的 Oracle Duke 选择奖。
|
||||
|
||||
Moco 是用来做模拟服务器的,你既可以把它当作一个程序库用在自动化测试里,也可以把它单独部署,做一个独立的服务器。我们先来看一个用 Moco 写的测试,感受一下它的简单吧!
|
||||
|
||||
```
|
||||
public void should_return_expected_response() {
|
||||
// 设置模拟服务器的信息
|
||||
// 设置服务器访问的端口
|
||||
HttpServer server = httpServer(12306);
|
||||
// 访问/foo 这个 URI 时,返回 bar
|
||||
server.request(by(uri("/foo"))).response("bar");
|
||||
|
||||
// 开始执行测试
|
||||
running(server, new Runnable() {
|
||||
// 这里用了 Apache HTTP库访问模拟服务器,实际上,可以使用你的真实项目
|
||||
Content content = Request.Get("http://localhost:12306/foo")
|
||||
.execute()
|
||||
.returnContent();
|
||||
|
||||
// 对结果进行断言
|
||||
assertThat(content.asString(), is("bar"));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这一讲,我就来说说它的设计过程,让你看看一个程序库是如何诞生以及成长的。
|
||||
|
||||
## 集成的问题
|
||||
|
||||
不知道你有没有发现,阻碍一个人写出一个程序库的,往往是第一步,也就是**要实现一个什么样的程序库**。因为对于很多人来说,能想到的程序库,别人都写了,再造一个轮子意义并不大。
|
||||
|
||||
但是,这种思路往往是站在理解结果的角度。其实,**程序库和所有的应用一样,都是从一个要解决的问题出发。**所以,在日常的繁忙工作中,我们需要偶尔抬头,想想哪些问题正困扰着我们,也许这就是一个程序库或者一个工具的出发点。
|
||||
|
||||
曾经有一个问题困扰了我好久,就是**集成**。还记得在我初入职场时,有一次,我们开发的系统要与第三方厂商的系统进行集成。可是,怎样才能知道我们与第三方集成的效果呢?我们想到的办法就是模拟一个第三方服务。
|
||||
|
||||
于是,作为当时的新人,我就承担起编写这个模拟服务的任务。那个时候还真是年少无知,居然自己写了一个 HTTP 服务器,然后又继续在上面写了应用协议。那时候的我完全没有编写程序库的意识,只是有人要求我返回什么样的应答,我就改代码,返回一个什么应答。
|
||||
|
||||
在我的职业生涯中,集成并不少见,只是后来我的经验多了,这种编写模拟服务的事就交到了别人的手上,我就成了那个让别人改来改去的人。
|
||||
|
||||
2012 年,我加入到一个海外合作的项目中,这个项目也有一个模拟的 HTTP 服务。开发人员根据自己的需要去改动代码,让这个模拟服务返回不同的应答。之后,他们再打出一个包,部署到一个 Web 服务器上。显然,这比我当年一个人维护模拟服务器要进步很多了,至少它不用考虑 HTTP 协议层面的问题了。
|
||||
|
||||
不过,依旧要自己部署模拟服务这一点,让我突然想起当年开发模拟服务时的景象。这么多年过去了,模拟服务却依然如此麻烦,没有得到任何好转,也许我可以做点什么。比起当年做软件开发的懵懂的我,工作了十多年的我,显然已经有了更多的知识储备。
|
||||
|
||||
## 从问题到需求,再到解决方案
|
||||
|
||||
那问题有了,我要怎么解决这个问题呢?我需要先把它变成一个可以下手解决的需求。首先,我要考虑的是,我希望这个模拟服务做成什么样子呢?
|
||||
|
||||
- 它可以支持配置,这样的话,我就不用每次都调整代码了;
|
||||
- 它可以独立部署,因为部署到应用服务器上的方式实在不够轻量级;
|
||||
- 它可以是一个通用的解决方案,因为我已经在多个不同的场景下遇到类似的问题。
|
||||
|
||||
除了这些正常的需求之外,我还有一个额外的小需求,就是希望它**有一个有表达性的 DSL**。因为我当时刚刚翻译完《领域特定语言》,特别想找个机会练练手。
|
||||
|
||||
以我当时的知识水平来看,配置肯定不是问题,这是任何一个程序员都可以做到的。独立部署,应该也可行,虽然当时还不流行嵌入式的 Web 服务器,但我还知道有 Netty 这样的网络编程框架,我稍微做了一点调研就发现,用它实现一个简单的 Web 服务器并不难。
|
||||
|
||||
问题就是,我怎样能把它做成一个通用的方案?
|
||||
|
||||
在设计中,其实最难的部分就在这里。一个特定的问题总有一个快速的解决方案,而要**想做成一个通用方案,它就必须是一个通用的模式。这就需要我们把问题抽丝剥茧,把无关的信息都拿掉,才可能看到最核心的部分。**而进行这种分析的的根基,同样是我们在前面说过的分离关注点。
|
||||
|
||||
我找到的核心问题就是,模拟服务到底是做什么的呢?其实,它就是按照我预期返回相应的应答。对,一方面,我要表达出预期;另一方面,它要给出返回的结果。
|
||||
|
||||
当我想明白这一点之后,一段代码浮现在我的脑海中:
|
||||
|
||||
```
|
||||
server.request("foo").response("bar");
|
||||
|
||||
```
|
||||
|
||||
对,这就是这个模拟服务器最简单的样子。当请求是“foo”的时候,它就给出对应的应答“bar”,这个结构非常适用于 HTTP 这种请求应答的结构。这段代码简直太合我的胃口了,因为它还是一段内部 DSL,声明出这个模拟服务器的行为,我的额外需求也得到了满足。
|
||||
|
||||
如果代码真的可以做成这个样子,那它应该就可以写在单元测试里了。和现在一比,动辄需要启动整个应用,做人工的集成测试,这简直是一个巨大的飞跃。而且,从开发效率上看,这简直就是数量级的提升。
|
||||
|
||||
不过,上面只是给出了设置服务器的样子,如果我们要把它写到单元测试里,还要考虑到如何去启动和关闭服务器。于是,一段单元测试的代码就浮现了出来:
|
||||
|
||||
```
|
||||
public void should_return_expected_response() {
|
||||
HttpServer server = httpServer(12306);
|
||||
server.request("foo").response("bar");
|
||||
running(server, new Runnable() {
|
||||
Content content = Request.Post("http://localhost:12306")
|
||||
.bodyString("foo", ContentType.TEXT_PLAIN)
|
||||
.execute()
|
||||
.returnContent();
|
||||
assertThat(content.asString(), is("foo"));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就是 Moco 的第一个测试了。有了测试,我就该考虑如何让测试通过了。同时,测试帮我锁定了具体的目标,我还知道了可用的技术,剩下的就是把它实现出来了。
|
||||
|
||||
对于程序员而言,实现反而是最简单的。就这样,我花了一个周末的时间,翻着各种文档,让第一个测试通过了。如此一来,Moco 在实现上的技术难度就被突破了。
|
||||
|
||||
## 基础设计的诞生
|
||||
|
||||
接下来,我就要考虑 Moco 可以提供怎样的功能了。Moco 首先是一个 HTTP 的模拟服务器,所以,它需要对各种 HTTP 的元素进行支持。HTTP 的元素有哪些呢?其实,无非就是 HTTP 协议中可以看到的HTTP 协议版本、 URI、HTTP 方法、HTTP 头和HTTP 内容等等这些东西。
|
||||
|
||||
问题来了,如果我们要把 Moco 实现成一个通用的解决方案,我们就需要任意地组合这些元素,我们该如何设计呢?
|
||||
|
||||
你可能已经想到了,在前面我们讲函数式编程的组合性时,已经提到了要设计可以组合的接口。是的,Moco 就是这么做的。下面是一个例子,如果我们请求 /foo 这个 URI,请求的内容是 foo,那就返回一个 bar,我们还要把这个应答的状态码设置成 200。
|
||||
|
||||
```
|
||||
server
|
||||
.request(and(by("foo"), by(uri("/foo"))))
|
||||
.response(and(with(text("bar")), status(200)));
|
||||
|
||||
```
|
||||
|
||||
在这里,传给 request 和 response 的就不再是一个简简单单的文本,而是一个元素的组合。
|
||||
|
||||
所以,传给 request 的,我称之为 RequestMatcher,也就是对请求进行匹配,匹配成功则返回 true,反之返回 false。而传给 response 的,我称之为 ResponseHandler,也就是对应答进行处理,在这里面设置应答中的各种元素。
|
||||
|
||||
这就是 Moco 最核心的两个模型。从 Moco 的第一个版本形成开始,一直没有变过。
|
||||
|
||||
```
|
||||
interface RequestMatcher {
|
||||
boolean match(Request request);
|
||||
}
|
||||
|
||||
interface ResponseHandler {
|
||||
void writeToResponse(Response response);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这段代码上,你还可以看到用来组合各个元素的and。学过前面函数式编程的内容,想必你也知道了该如何实现它。除了 and,我还提供了 or 和 not 这样的元素,方便你更好地进行表达。
|
||||
|
||||
## 扩展设计
|
||||
|
||||
有了基础设计之后,其实 Moco 已经是一个可用的程序库了。从理论上来说,它已经能够完成HTTP 模拟服务器所有的需求了。事实上,当我拿出了 Moco 的第一个版本,就有同事在实际的项目中用了起来。
|
||||
|
||||
如同所有开源项目一样,只要有人用,就会有人给出反馈,你就需要去解决它。Moco 就这样,不经意间开启了自己的生命周期。
|
||||
|
||||
我在开篇词就说过,软件设计是一门关注长期变化的学问。长期意味着会有需求源源不断地扑面而来。每当有新问题的到来,软件就要去应对这个新的变化,这也是考验软件设计的时候。
|
||||
|
||||
第一个变化就是,有人提出要有一个外部的配置文件。Moco 所要做的调整,就是增加一个配置文件,然后要在配置文件和核心模型之间做一个映射。这个变化其实在核心模型上没有任何的改变。学了前面的课程,你也知道,这就相当于给 Moco 增加了一种外部 DSL,只不过,这个 DSL 的语法我采用了 JSON。
|
||||
|
||||
正是因为 JSON 配置文件的出现,Moco 有了一个全新的用法,就是把 Moco 当作了一个独立的模拟服务器。后来的很多人其实更熟悉的反而是这种用法,而把 Moco 用在单元测试的这种场景比例就要低一些。也是因为这个独立模拟服务器的用法,Moco 也不再局限于 Java,不同的程序设计语言编写的应用都可以与之进行交互,Moco 的使用范围得到了扩展。
|
||||
|
||||
随后,还有人提出了更多功能性上的需求,让 Moco 的能力也得到了极大的提升:
|
||||
|
||||
- 有些被模拟的服务不稳定,Moco 支持了一个 proxy 功能,将请求转发给被模拟服务。如果这个服务失效了,就使用本地缓存的信息;
|
||||
- 有些应答里的字段是根据请求的内容来的,Moco 支持了 template 功能,让使用者自己决定怎样使用哪个信息;
|
||||
- 有时还要对请求的内容,进行各种匹配。比如,URI 在同一个根目录下,就进行一样的处理,Moco 支持了 match 功能,让使用者自己可以写正则表达式,对请求进行匹配;
|
||||
- 有人为了方便管理,希望把所有的应答内容放到一个目录下,Moco 支持了 mount 功能,把一个目录挂载在一个 URI ;
|
||||
- 现在的 REST 开发是主流,Moco 支持了 REST 能力,能够定义资源,更方便地将同一资源的内容定义在一起;
|
||||
- ……
|
||||
|
||||
所有这些内容都是在基础的模型上扩展出来的,基本上都不需要去改动基础模型。不过,有一个功能的拓展影响了基础模型,就是 template。因为它需要根据请求的内容来决定应答的内容,这让原本各自独立的 request 和 response 开始有了关联。
|
||||
|
||||
为了适应 template 的需求,我在 ResponseHandler 的接口上增加了 Request,把请求信息带了进来:
|
||||
|
||||
```
|
||||
class SessionContext {
|
||||
private final Request request;
|
||||
private final Response response;
|
||||
...
|
||||
}
|
||||
|
||||
interface ResponseHandler {
|
||||
void writeToResponse(SessionContext context);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也是由于这个调整,让 Moco 后来有了可以支持录制回放的能力:
|
||||
|
||||
```
|
||||
server
|
||||
.request(by(uri("/record")))
|
||||
.response(record(group("foo")));
|
||||
|
||||
server
|
||||
.request(by(uri("/replay")))
|
||||
.response(replay(group("foo")));
|
||||
|
||||
```
|
||||
|
||||
在这个设置中,我们发给 /record 这个地址的内容就可以记录下来,然后,访问 /replay 这个地址的时候,我们就可以得到刚才记录的内容。由此,Moco 由原来只提供静态设置的模拟服务器,变成了一个能够动态配置的模拟服务器,能力得到了进一步提升。
|
||||
|
||||
至此,你已经看到了 Moco 是怎么一点一点长大的。与 2012 年刚刚起步时相比,今天的 Moco 的能力已经强大了许多,但它的内核依然很小,代码量也不大。如果你希望研究一个有设计的代码,不妨从 Moco 入手,这个专栏讲到的不少内容都可以在 Moco 中看到影子。
|
||||
|
||||
**Moco 就是根据请求给出应答,只要理解了这么一个简单的逻辑,你就完全可以理解 Moco 在做的事情**,其他的东西都是在这个基础上生长出来的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了 Moco 的设计过程。一个好的软件也好,程序库也罢,都是从实际的问题出发的。阻碍一个程序员写出好的程序库的原因,往往是没有找到一个好问题去解决。**程序员不能只当一个问题的解决者,还应该经常抬头看路,做一个问题的发现者。**
|
||||
|
||||
有了问题之后,**需要把问题拆解成可以下手解决的需求**,让自己有一个更明确的目标。然后,我们才是根据这个需求找到一个适当的解决方案。**一个通用的解决方案需要不断地抽丝剥茧,抛开无关的部分,找到核心的部分**,这同样根植于分离关注点。
|
||||
|
||||
如果最后的解决方案是一个程序库,那么,我们用测试把程序库要表达的内容写出来,就是最直接的。有了测试,就锁定了目标,剩下的就是让测试通过。
|
||||
|
||||
一个好的设计,应该找到一个最小的核心模型,所有其他的内容都是在这个核心模型上生长出来的,越小的模型越容易理解,相对地,也越容易保持稳定。
|
||||
|
||||
这一讲,我讲了一个程序库的设计。下一讲,我们再来看看如何设计一个应用。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**注意发现身边的小问题,用一个程序库或工具解决它。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/8f/ce398c521cbc2793c0a8522b468f7a8f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你抬头看一下路,看看你在开发的过程中,发现过哪些阻碍研发过程的问题呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
136
极客时间专栏/软件设计之美/巩固篇/31 | 应用的设计:如何设计一个数据采集平台?.md
Normal file
136
极客时间专栏/软件设计之美/巩固篇/31 | 应用的设计:如何设计一个数据采集平台?.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<audio id="audio" title="31 | 应用的设计:如何设计一个数据采集平台?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/bc/3fee6fdf9f72eb00275f8df78b0fe2bc.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
上一讲,我给你讲了 Moco 的设计,这是一个程序库级别的设计。除了开发一个程序库,在日常工作中,还有一种工作是程序员们非常熟悉的,就是开发一个应用。与之对应的设计就是应用设计。
|
||||
|
||||
也许你会说,应用设计不就是按照之前讲的 DDD 的方法,先通过事件风暴建立通用语言,然后,再找出子域和划分出限界上下文,最后,再按照模板找出各种对象吗?
|
||||
|
||||
是的,设计的基本过程确实是这样的。不过,DDD 的方法只能保证我们设计出一个可以接受的方案。**如果你想有一个更有扩展性的设计方案,就需要多花一点时间去构建一个更好的模型**。
|
||||
|
||||
这一讲,我就以一个金融指数系统为例,给你讲一下如何更好地设计一个应用。
|
||||
|
||||
## 一个指数系统
|
||||
|
||||
在金融系统中,有一个概念叫指数,用来表示金融市场的活动,比如有股票指数、期货指数等等。比较著名的指数有道琼斯指数、标准普尔指数。这个世界上的指数多得数不胜数,每个金融机构都会有自己的指数,而且,它们还会不断推出新的指数。
|
||||
|
||||
那指数是怎么算出来的呢?如果以股票为例,就是获取一堆股票的价格,然后根据一个公式算出一个结果。比如,我们有一个公式,A**0.2+B**0.3+C*0.5,我们把公式里的数据部分称为指标,也就是公式中的 A、B、C,这个公式表示这三种指标分别占比20%、30%和50%。
|
||||
|
||||
这个公式就是三个不同的指标按照不同的占比进行求和。假设A指标的价格是5元、B指标是2元、C指标是1元,按照公式可以算出5**20%+2**30%+1*50%=2.1,这个算出来的2.1就是指数的值。
|
||||
|
||||
价格是实时变化的,而公式是固定的。指数在问世之初,我们需要不断调整这个公式里面各个指标的参数,以便能更好地反映市场的变化。问题来了,我们要怎样设计一个这样的指数系统呢?
|
||||
|
||||
一个不假思索的设计就是,针对一个具体的指数进行开发。我们就要把指数计算中涉及的各种数据实时取过来,然后根据设置的公式去做计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/28/37dyy618b2c70cc30b32cc93ff0f3d28.jpg" alt="">
|
||||
|
||||
如果我们只有一个指数,这么做也许是可以接受的。但我们要开发的是一个指数系统,这意味着我们会有很多个指数。两个不同的指数可能会用到同样的指标,如果我们按照开发一个指数的方法,不同的指标数据要获取好多遍,从某种意义上来说,这就是一种重复。
|
||||
|
||||
所以,一个好的做法就是,**先做职责划分,把不同职责的部分划分出来**。正如我在这个专栏中一直说的,我们不能把各种不同的关注点混在一起,这是很多系统出问题的根源所在。
|
||||
|
||||
那从上面的需求描述中,我们可以把指数的计算过程分成两个部分:
|
||||
|
||||
- 一部分是需要实时获取的数据,比如,前面说到的各种价格;
|
||||
- 一部分是根据公式进行计算出最终的结果,也就是指数最终的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/8b/73224c9763993abc6665d6980d75418b.jpg" alt="">
|
||||
|
||||
这种拆分解决了前面设计中存在的问题,使得指标数据获取和公式计算分开了,同样的数据就可以用在多个公式中,数据的获取和公式的计算就不用同步进行了。
|
||||
|
||||
而且,把计算过程拆成了两个部分之后,我们就可以针对这两个部分,分别进行细化了:
|
||||
|
||||
- 对于指标数据获取的部分,我们要解决数据获取可能出现的问题,比如,不同的数据来源如何管理、不同数据源的数据格式是怎样的、如果数据源不可用,我们该怎么办等等;
|
||||
- 对于公式计算的部分,我们关心的问题则是计算要用到哪些指标、每个指标当前可用的值是多少、如果公式中有不可用的指标数据时,系统该怎么处理等等。
|
||||
|
||||
既然我们把系统拆分成了两个部分,还有一个问题就是,如何把这两个部分连接起来。其实,指标数据获取部分的输出,就是公式计算部分的输入,那指标数据获取部分的输出是什么呢?
|
||||
|
||||
我们在前面分析过,指标数据获取要实时获取,无论采用轮询的方式,还是采用数据上报的方式。这种数据的特点就是,它有一个值,还有一个时间。正是因为这种特点,数据会形成一个序列,所以,我们将这种数据称为**时序数据**。
|
||||
|
||||
指标数据获取部分的输出其实就是这种时序数据,只不过,针对每一种指标都会产生一个时序数据序列,而这些不同的时序数据也正是公式计算部分的输入。
|
||||
|
||||
既然是时序数据,也就有了时间的信息,我们的公式计算部分就可以根据时序数据的时间做一些处理了。比如,怎么判定一个指标不可用呢?如果判断一个指标最新的数据与当前时间的差值过大,我们就可以判断在这次计算中,该指标的数据不可用。
|
||||
|
||||
有了对于时序数据的认识,结合前面所说的数据获取和公式计算不再是同步进行的这一点,指标数据获取和公式计算两个部分就完全解耦了,二者之间可以只通过时序数据进行交互。
|
||||
|
||||
## 更上一层楼
|
||||
|
||||
现在,我们已经把数据获取和公式计算分成了两个部分,这应该是常规设计中都可以想到的。很多设计者做设计也可能就此打住,开始动手写代码了。但是,有时候我们还可以更进一步。
|
||||
|
||||
我们可以继续分析一下,看看还有什么可以进一步改进的地方。
|
||||
|
||||
我先问你一个问题,公式计算你打算怎么做?你可能会想,这难道不是业务人员给我什么样的公式,我就用写代码的方式把它实现出来吗?
|
||||
|
||||
这么做肯定是可以把公式实现出来,这一点是毋庸置疑的。但是,正如我前面所说,指数往往要经过一个调整的过程。因为业务人员自己也常常不确定设置的参数是否合理。
|
||||
|
||||
用写代码的方式实现公式,也就意味着,每次业务人员要调整一个参数,你都需要去改代码。在可以预见的未来,你的工作基本上都会与调整参数相关,而这件事一点技术含量都没有。
|
||||
|
||||
对我们程序员而言,**一件事是不是有技术含量往往不取决于事情本身,而取决于我们怎么做它**。换言之,问题是一样的,但不同的解决方案却会带来不同的效果。**业务人员提出的是问题,解决方案是由技术人员给出的,千万别混淆问题和解决方案**。
|
||||
|
||||
当你可以预见一件事将来会很繁琐、会不断重复,而且会持续相当长的时间,这时候我们就需要重新审视我们的解决方案了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/7a/1eed9cb9d3595170473316b07fe3677a.jpg" alt="">
|
||||
|
||||
最原始的解决方案是没有自动化的方案,对于任何一个系统而言,我们最好要知道没有自动化的时候,这个问题是如何解决的。这和我们前面说到的,理解一个模型来龙去脉的思路是一致的。
|
||||
|
||||
当然,我们现在大多数情况下接触的都是一个已经自动化的方案,但方案之间还是存在着差别。在自动化方案中,最原始的做法是开发人员自己修改代码的方案,这种做法会导致开发人员大量的时间投入,属于严重消耗时间的做法。
|
||||
|
||||
其次是开发人员修改配置,虽然这种做法只修改配置,但通常还会涉及到重新打包发布的过程,只能说它比修改代码要强一点。
|
||||
|
||||
比较好的做法是,业务人员修改配置,开发人员完全不参与其中。一方面,业务人员自己最知道自己想要什么;另一方面,没有开发人员的参与,反馈周期就缩短了。
|
||||
|
||||
虽然这几种方法在业务的角度是越来越好的,但在设计上,却是要求越来越高的。比起没有自动化的方案,自动化的方案需要投入一些力量去做设计。相比于修改代码,修改配置就意味着要留下扩展的接口。而能够做到让业务人员而不是开发人员修改配置,配置的接口就应该是一个业务的接口,比如,要有一个配置界面。
|
||||
|
||||
如果我们用这几个标准评估一下我们现在的方案,显然,我们现在的方案还处于开发人员修改代码的阶段,这说明我们还有向上努力的空间。不过,我们给出的只是一个衡量标准,并不意味着这个台阶要一步一步上,因为我们可以一步就提升到最高标准,一步到位给业务人员提供一个配置的接口。
|
||||
|
||||
问题来了,我们要给业务人员提供一个配置接口,它应该长啥样呢?
|
||||
|
||||
我们知道,这个指数设计的关键就是这个指数的公式。在前面的那个例子里面,它的公式是 A**0.2+B**0.3+C*0.5。如果我们能够让业务人员在配置接口上这样配置,问题就解决了。
|
||||
|
||||
在这里,A、B、C 分别代表一个指标,也就是说,我们只要能够让业务人员指定指标以及指定计算公式,剩下的问题就简单了,就是根据公式计算出相应的结果就好了。
|
||||
|
||||
说起来很简单,但怎么把A**0.2+B**0.3+C*0.5变成一个可执行的公式,对一些程序员来说,还是有一定难度的。解析文本执行这件事是编译原理的基本功,不过只要你能理解这里需要一点编译原理的知识就很可以了,如果欠缺知识,就去学习相关知识好了。
|
||||
|
||||
实际上,公式的解析是编译原理入门的知识,难度系数比设计一门程序设计语言要小多了。而且,现在有编译器前端的工具,比如,Java 世界的 [Antlr](https://www.antlr.org/),它可以直接生成对应的语法树结构,我们只要负责去编写对应的执行部分就好了。
|
||||
|
||||
也许你发现了,我们实际上已经构建出了一门 DSL,一门属于指数计算这个特定领域的外部 DSL。前面讲 DSL 的时候,我们就说过,把设计做到极致就可以构建出一门 DSL。在这里,我们也看到了,了解 DSL,实际上也给我们增添了一个可以前进的方向。
|
||||
|
||||
把公式构建出来之后,我们仔细分析,还会有一个有趣的发现。你可以想一下,公式计算的结果是什么?因为我们说,它是在利用多个指标的时序数据做计算,所以它得到的结果,其实也是一个时序数据。
|
||||
|
||||
这样,我们发现了另一个有趣的事,公式计算的得到其实也是一个指标。如此一来,公式计算的结果也可以作为另外一个公式的输入,形成更为复杂的复合公式。显然,由于复合公式的出现,这个系统的处理能力又上了一个台阶。
|
||||
|
||||
不知道你是否想起了什么,没错,它和设计模式中的组合模式如出一辙。你看,我们学习到的基础知识在这里都用上了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/70/16fbf23d1663442f46c54c275dc51070.jpg" alt="">
|
||||
|
||||
虽然我们这里讨论的是一个金融中会用到的指数系统,但当我们把模型经过一番整理之后,你会发现它不仅仅局限于指数系统中。比如,如果你在开发的是一个物联网系统,上报上来的数据,往往也要经过一些计算和聚合,那这个模型显然也是适用的。
|
||||
|
||||
再比如,你开发了一个 APM(Application Performance Management,应用性能管理)类的应用,采集上来的数据往往也要经过一番计算再展示出来,这个模型同样适用。
|
||||
|
||||
所以,当我们可以构建出一个好的模型时,它本身就有着更大的适用范围。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我通过一个指数系统的应用给你讲了一个应用的设计过程。在这里,你知道了想要做好设计,目标就不能局限于只把功能实现出来,而是我们要去不断发现可能存在的各种问题。
|
||||
|
||||
简言之,只要你认为会出现重复,它就是一个值得我们去思考解决的问题。
|
||||
|
||||
我还给你讲了如何衡量应用的设计水平,就是看它符合下面哪个标准:
|
||||
|
||||
- 没有自动化;
|
||||
- 开发员修改代码实现;
|
||||
- 开发员修改配置实现;
|
||||
- 业务员修改配置实现。
|
||||
|
||||
程序员常常给人写代码,实现自动化,却常常忽略了自己工作中可以自动化的部分。作为[一个懒惰的程序员](https://time.geekbang.org/column/article/86210),我们需要发现日常工作中繁琐的地方,让自己从低水平的重复中解脱出来。**一件事是不是有技术含量往往不取决于事情本身,而取决于我们怎么做它**。
|
||||
|
||||
这两讲我们讲的都是怎么去设计一个新东西,但在实际工作中,有时候,我们还会面对一个既有的系统,这样的系统该如何改进呢?我们下一讲来谈。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**一个更好的设计从拒绝低水平重复开始,把工作做成有技术含量的事情**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/5f/a1493ea6b7afba5e9827c4caef5fb45f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你回想一下,参照今天的内容,在你现在的工作中,有哪些可以从设计上改进的内容呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
111
极客时间专栏/软件设计之美/巩固篇/32 | 应用的改进:如何改进我们的软件设计?.md
Normal file
111
极客时间专栏/软件设计之美/巩固篇/32 | 应用的改进:如何改进我们的软件设计?.md
Normal file
@@ -0,0 +1,111 @@
|
||||
<audio id="audio" title="32 | 应用的改进:如何改进我们的软件设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0d/cd/0d2289759462ae8a78db371d43ddf3cd.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
前面两讲,我们分别讲了如何从头开始设计一个程序库和应用。但是在实际工作中,有很多时候,我们的工作并不是从头设计一个应用,而是改进一个既有项目的代码。既有项目的代码意味着什么呢?意味着各种问题。
|
||||
|
||||
我们一直在说,软件设计是一门关注长期变化的学问。越是在商业上成功的软件,存续的时间往往越长。存续的时间越长,往往就会有更多的麻烦。
|
||||
|
||||
我们先不说有些项目一开始就没有设计,一路混乱向前。即便是一个最初有着还算不错设计的项目,随着时间的积累、人员的更替、把前人的做法当作惯例等等事情的发生,项目的设计就会逐渐变得不堪重负。
|
||||
|
||||
我在前面的课程中也举过一些例子,虽然每人只改了一点点,最后却是积重难返。这就是一个项目缺乏设计守护的结果。好的守护可以使设计更持久,遗憾的是,大多数项目做得并不好。
|
||||
|
||||
除了上面这几点,还有一点就是,新的技术和框架会不断涌现,旧代码往往是不能有效使用这些新东西的。比如,Java 世界今天开发的主流是 Spring Boot,然而十年前,它还不存在。
|
||||
|
||||
虽然那时候已经有了 Spring,但那时候的主流开发方式还是打出一个 WAR 包,再部署到 Tomcat 上。所以,新出现的很多技术会提供更简单的做法,替换掉旧代码中笨拙的部分。
|
||||
|
||||
所以,到底怎么才能让自己的项目在设计上不断地演进,跟上时代发展的步伐,不断焕发新的活力呢?对于任何一个开发团队而言,这都是一个值得考虑的问题。
|
||||
|
||||
那么,这一讲,我们就来谈谈如何改进既有项目的设计。
|
||||
|
||||
## 从目标开始
|
||||
|
||||
在我的另外一个专栏《[10x 程序员工作法](https://time.geekbang.org/column/intro/148)》中,我讲过一个类似的主题,[如何面对遗留系统](https://time.geekbang.org/column/article/90231)。那里面的主要观点就是我们应该找到一个目标,然后小步改进,逐步向这个目标接近。
|
||||
|
||||
在那一讲中,我讲的重点主要在于改进的过程,而在这里,我打算从设计的角度再来审视一下这个问题。既然都是我的专栏,所以二者在解决问题上的思路一致的,都要先从找到目标开始。
|
||||
|
||||
大多数团队一说起改进,一般想的都是功能性方面的目标。比如,我原来的系统能支持100万的用户,现在要支持 1000 万的用户。这种改进固然是我们需要考虑的,甚至是迫不得已的。
|
||||
|
||||
但这种改进解决的是实现,因为[不同量级的系统根本就不是一个系统](https://time.geekbang.org/column/article/88764),承载的用户量发生了变化,其实是一种需求的变化。但是,这种改变并不会让你的设计变好。
|
||||
|
||||
既然我们已经决定要改进了,就应该好好地把设计改进一下,而不只是把功能重新实现一遍。因为功能实现是你无论如何都必须做的,都是为别人做的,而设计的改进才是你为了自己做的,因为在未来的一段日子里维护这些代码的人是你。如果我们要做设计的改进,设定好改进设计的目标就显得尤为重要。
|
||||
|
||||
那设计改进的目标应该是什么呢?你可以先问一下自己这样一个问题,**如果有机会从头设计这个系统,它应该是什么样子呢?**
|
||||
|
||||
这个问题可能会让很多程序员一下子愣住,因为他们每天都陷于忙碌的工作中,做的工作都是各种微调、各种打补丁,眼中只有一个具体微观的世界,却不曾有一个整体的思考。
|
||||
|
||||
是的,从头来过,它应该是什么样子。这是一个简单的问题,也是一个困难的问题。简单在于,它的字面意思很好理解。困难却在于,很多人一听到这个问题,直觉就要回避:
|
||||
|
||||
- 我的系统已经这么沉重了,怎么可能重来?
|
||||
- 我有那么多的需求要做,哪有时间重做一遍?
|
||||
- 我的系统那么复杂,重做一遍,出了问题谁来负责?
|
||||
|
||||
我承认,这些都是很现实的问题。但是,我的意思并不是让你真的一上来就动手,从零开始把系统重写一遍。这里的重点在于,**我们要找到改进的目标,也就是一个系统本来应有的面貌**。
|
||||
|
||||
这就是为什么我们前面要学习那么多设计一个系统的知识,否则,我们没有一个设计知识的沉淀,所谓的“重新设计”,弄不好我们就会回到原来的老路上去。
|
||||
|
||||
这时候,或许你突然想到一个严重的问题了,开启一次系统改进,如何处理人们的共识好像也是一件困难的事情,但这根本不是一个设计问题。想要真正地开启一次改进,就要让人们意识到,**设计一个系统和实施一次系统改进是两个完全不同的问题,可以分阶段地进行**。
|
||||
|
||||
我们只有把系统设计成它应有的样子,才算是确定了我们的目标。有了目标之后,接下来,我们才能制定改进路径,而把现有的系统一点一点从旧有的样子改动成新的样子,这是实施的过程。
|
||||
|
||||
好!我假设你已经搞定了周边人的共识,准备着手进行改进了。
|
||||
|
||||
## 改进的过程
|
||||
|
||||
现在你要重新设计这个系统了,或许你会想,这有什么难的?不就是照着原来的需求,重新来一遍吗?如果你真的还有原来的需求,能让你照着设计一遍。我真的只能说,你太幸运了。
|
||||
|
||||
在大部分真实的项目中,一个既有系统的情况是,没有人能够说出它到底承载了哪些需求。当然,主干部分是人人都知道的,但主干常常是九牛一毛,而更多的细节隐藏在代码中了。
|
||||
|
||||
一个长期存在的系统,开发者可能已经换了好几拨。了解当年那些需求的人可能早已不知所踪了,导致的结果就是,每一个工作在这个项目上的人都是只见树木不见森林。
|
||||
|
||||
在这种情况下,我们该怎么办呢?我给你**一个入手的起点,就是接口**。
|
||||
|
||||
在[第4讲](https://time.geekbang.org/column/article/241114)学习怎样理解一个系统的设计时,我们曾经说过,想要理解一个系统的设计,可以按照模型、接口和实现的这个框架去理解,其中,接口是模型能力的体现。
|
||||
|
||||
对于一个系统而言,接口也是使系统内部状态发生改变的原因,系统中的所有变化必然都是从某个接口开始的。既然没有人能够清楚地说明系统的现状,那么,我们从接口入手,了解系统的现状是一个非常现实的做法。毕竟,接口是不会骗人的。
|
||||
|
||||
不过,这里的接口不仅包括我们传统意义上的接口,也包括各种后台服务。前面我们讲了很多构建模型的内容,有了这个基础,我们再看后台服务,就会发现,后台服务只不过是按照某种规则触发模型的接口。比如,定时服务,就是定时地去调用模型的接口。所以,我们也要把这种接口梳理出来。
|
||||
|
||||
有了对于这些接口的了解,我们就对这个系统呈现哪些能力有一个认识了,就相当于获得了一份需求描述。基于这个认识, 我们来构建我们新的设计。
|
||||
|
||||
接下来,我们就要重新设计了,**这个改进设计的难点就是不要回到老路上**。我们需要按照一个正常设计的思路去走,该分离关注点的分离关注点,该重新组合的要重新组合。
|
||||
|
||||
之所以我要提示这一点,就是因为思维的惯性实在是太大了。比如说,在原有的系统内有一个叫订单的概念,我们就会习惯性地使用订单,而不是把商品订单、支付订单等概念分开。
|
||||
|
||||
一般而言,既有项目的设计有一个很大的问题就是各种信息混在一起,而能够把不同的信息拆分开来,对于设计而言,就是一个巨大的进步。
|
||||
|
||||
做好了新的设计,也就为我们后续的行动找到了新的方向。接下来,我们要做的是,对比新旧设计,找到一条改进路径。
|
||||
|
||||
**永远不要指望一个真实的项目停下来,一步到位地进行改进。**我们能够做的,唯有小心翼翼,一步一步向着目标前进。
|
||||
|
||||
对于不同的项目,选择的路径可能是不同的,有人会选择关键路径上的关键模块进行改进,也有人会选择影响较小的模块先进行探索,无论是哪种方案都是可以的。一个关键点就在于,动作要小。
|
||||
|
||||
学习过我的两个专栏的同学可能已经充分理解了我对小步前行的喜爱了。任何一个大动作,往往都意味着很长时间无法完成。在这个过程中,所有人都会提心吊胆。如果不能看到成果,很多人的信心都会随时间流失。所以,**在软件设计的改进过程中,积小胜为大胜才是一个合理的选项**。
|
||||
|
||||
还有一个关键点,要让所有相关利益人有一个共识。我又一次说到了共识,软件开发虽然是一个技术活,但归根结底还是一项团队活动,是一项人的活动。
|
||||
|
||||
既然涉及到诸多参与者,就一定要让大家形成一个共识。所以,系统改进,尤其是一个规模比较大的系统改进,一定要让所有人有共识。无论是开会也好,宣讲也罢,让大家对于改进的原因和改进的计划有个共同的预期是至关重要的。
|
||||
|
||||
更加具体的改进过程,我在《10x 程序员工作法》中有更细节的讨论,有兴趣的话,可以去参考一下。
|
||||
|
||||
虽然我在这里讲的是一个系统的改进过程,其实,同样的思路也可以运用在更小的模块中。只不过,更小模块意味着更少的接口、更低的复杂度以及更少的相关利益人。事实上,我反而鼓励你从小模块入手,一步到位去改进整个系统,难度系数是更大的,而小模块可以帮助你积累更多改进的经验,无论是设计,还是与人打交道。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了如何改进一个既有软件的设计。一个软件放在时间长河中会有很多东西发生改变,即便是当初还算不错的设计,随着时间的累积,也可能积重难返。
|
||||
|
||||
改进一个软件的设计,首先,要确定改进的目标。改进的目标就是,重新设计这个软件,它应该设计成什么样子,让设计还原到它应有的本来面貌。寻找改进的起点,一部分可以从需求入手,还有一部分要从梳理接口入手。
|
||||
|
||||
设计改进的难点在于不要回到老路上,要做正常的设计,尤其是要把分解做好。
|
||||
|
||||
有了改进目标之后,接下来就是要找到一条改进路径,选择怎样的路径都是有道理的,但有两个关键点是非常重要的,一个是每步改进的动作要小;一个是要让相关利益人达成共识。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**改进既有设计,从做一个正常的设计开始,小步向前**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/2f/9df0d3f24dcbebafc3fce6d26628eb2f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你回想一下,你的系统在设计上存在着哪些问题,你打算怎么改进它呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user