软件模块在开发中,被复用的次数越多,价值越高。
这是最“古老”的,也是最常见的软件复用形式。代表软件有 Nginx,Apache 这类,通过修改他们的配置文件,可以让软件的行为有很多的不同。譬如在 Apache 上,通过对 cgi-bin 的配置,可以指定一个自定义的程序,通过 unix 的 stdin、stdout 接口组合出非常复杂的功能。
配置的形式随着时代发展,也有各种变化,从 ini 格式,到 xml,yaml 甚至某种脚本语言。除了文件形式,配置还可以通过环境变量、命令行参数的形式传入软件,甚至是以自带的 web 界面来输入。
# 访问时可以:http://xx.xx.xx/cgi-bin/,但是该目录下的CGI脚本文件要加可执行权限
ScriptAlias /cgi-bin/ "/mnt/software/apache2/cgi-bin/"
<Directory "/usr/local/apache2/cgi-bin"> #设置目录属性
AllowOverride None
Options None
Order allow,deny
Allow from all
</Directory>
配置文件在不太复杂的情况下,使用门槛是最低的。很多初学者都能通过几个小时的学习,就能配置 web 服务器这一类软件。现在很多家用路由器,也提供了 web 界面进行配置,很多非技术专业人员也可以得心应手设置某些特性。
server {
listen 80;
server_name localhost;
location /lua {
content_by_lua ‘
ngx.say("Hello, Lua!")
ngx.log(ngx.ERR, "###########################");
';
}
}
很多软件都支持脚本语言或者某种 DSL 语言。最为人熟知的就是 MySQL 数据库,支持的就是 SQL 这种 DSL 语言;SQL 除了一般的查询表达外,也可用来编写存储过程,其实也是相当完整的语言了。同样的,Redis 也支持 Lua 脚本语言进行功能扩展。
很多现代的游戏,都提供脚本语言作为其插件的开发接口,最出名的就是《魔兽世界》,提供了 Lua 语言作为其插件语言,全球玩法开发了成千上万的各种插件。另外,《ROBLOX》也提供 Lua 脚本功能,《我的世界》则支持 Python 脚本。另外,在设计软件领域,如《AUTOCAD》支持 C# 语言作为二次开发脚本,《Photoshop》支持 JavaScript 脚本。微软的 Office 软件,基本都支持 VBA 脚本,也就是 VisualBasic 语言。
[游戏《文明6》的脚本]
local data ={
BoostID_1={
CivicType="CIVIC_CRAFTSMANSHIP";
Boost=50;TriggerDescription=[=[Improve 3 tiles.]=];
TriggerLongDescription=[=[With the land around our first city developing nicely, we can fine tune our production techniques.]=];
Unit1Type="UNIT_BUILDER";
BoostClass="BOOST_TRIGGER_NUM_IMPROVED_TILES";
NumItems=3;
};
BoostID_2={
CivicType="CIVIC_FOREIGN_TRADE";
Boost=50;
TriggerDescription=[=[Discover a second continent.]=];
TriggerLongDescription=[=[Having discovered another continent we realize there is a wide world of trading opportunities.]=];
BoostClass="BOOST_TRIGGER_DISCOVER_CONTINENT";
};
......
}
软件模块以库的形式提供扩展,是最通用也是最传统的方式。所有程序员每天都会和很多的“库”打交道,不使用程序库,几乎无法开发出任何有价值的软件。以库形式提供功能的软件模块,最典型也最有用的,莫过于操作系统。Linux 的系统调用函数手册,甚至可以直接通过 man 指令查询。一个设计良好的库,是最有价值的软件资产,它能提供强大的能力,同时也能提供无以伦比的灵活性。有一些编程语言,正是以其完整而丰富的库闻名,如 Java/C#/Go。
微服务最早是以一种进场间通信的工具被发明出来的,它建立于 RPC 这种抽象概念,把一次网络通信封装成一次函数调用。除了网络通信,RPC 一般还要加上在 SOA 架构上的服务,才能成为微服务。这种技术对于需要提供进程内数据的服务来说,是非常流行的。可以对比一下传统 SQL 或者一些通信网关(如 CMPP 短信网关),那些传统的服务,为了提供“带数据”的服务,从而需要设计一系列的“通信协议”,然后由调用者编程去实现这些协议,才能进行通信。而微服务通过一些工具,让这个过程隐藏在已经写好的某些语言的函数“库”里。使用者只需要调用这些“库”里的函数即可。现在流行的 Restful API 也可以算成一种微服务,但这绝对不应该缩写为 API,因为 Application Programming Interface, 有更广泛的形式。
req := &api.Request{Message: "my request"}
resp, err := client.Echo(context.Background(), req)if err != nil {
log.Fatal(err)}
log.Println(resp)
time.Sleep(time.Second)
形式 | 灵活性 | 知识通用性 | 学习难度 |
---|---|---|---|
配置 | ⭐ | ⭐ | ⭐ |
脚本 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
库 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
微服务 | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
很多人觉得配置文件修改后,无需编译,甚至无需重启,是最灵活的一种形式。在运维的角度来看,修改配置确实是一种很方便的形式。但是配置文件的表达能力有限,特别是对于有类似 if...else... 的判断配置,或者是带有循环逻辑的配置来说,使用没有严格定义的编程语言,其实是作茧自缚。因为除了使用者使用诸如 XML/JSON 格式来表达 if/else 很不直观以外,开发者要自己在没有规范的语法分析器帮助下,解析如此复杂的配置文件,也是一件很容易出错的事情。
<!-- 创建方式1:空参构造创建 -->
<bean name="user" class="com.wisedu.springDemo.User"></bean>
<!-- 创建方式2:静态工厂创建
调用UserFactory的createUser方法来创建名为User2的对象放入容器
-->
<bean name="user2" class="com.wisedu.createObject.UserFactory" factory-method="createUser"></bean>
<!-- 创建方式3:实例工厂创建
首先将UserFactory作为普通的bean配置到Spring中,然后再去调用UserFactory对象的createUser2方法
-->
<bean name="user3" factory-bean="userFactory" factory-method="createUser2"></bean>
<bean name="userFactory" class="com.wisedu.createObject.UserFactory"></bean>
上面是 Spring 框架的配置文件,里面的配置项和配置值,实际上都是和程序源码密切相关甚至一模一样的内容,这份配置其实相当于用 XML 来写程序代码。但是这种配置并没有 IDE 能帮你做错误检查。
很多程序员都痴迷于集中使用一个文件,来管理或者登记大量的“常量”,譬如错误码或者代替 switch...case 的常量表(我也赞成不应该使用巨型 switch...case),但是这种文件最后会成为一个大家只想添加而不想维护的大泥团,里面会充斥着近似而又不知道能不能合并的数据,这最终会导致没人想要继续维护整个软件。——这种设计虽然在很多软件中都很常见,但是不见得就是对的。
虽然我们可能都为学习 apache 的 httpd.conf 而花过大量的时间,但是我认为这个时间花的并不是很值得,因为现在很多地方已经换成 nginx 了。而且这种软件功能越是强大,配置也就越是复杂,就越让人望而生畏。
对于提供了脚本引擎的软件模块,应该让所有的配置都以一个脚本形式来运行。如果一个微服务需要做什么配置,也应该让用户在微服务的调用参数中传入。那些既需要复杂的配置,又需要在调用函数中填入对应“正确”的参数的设计,是一种折磨使用者的最好方法。
在最早期的互联网上,就充斥着各种如何配置 web 服务器来支持用户名密码鉴权的问题和教程。这些配置需要填上合适的文件路径,而文件的权限也要配置正确,最后文件的内容还需要以正确的算法加密——这一切任何一个环节错误,功能都不生效,真的太困难了。
现代的云服务厂商,也同时提供了微服务(restful api)的接口,通常也是需要使用者进行一系列的配置,然后才能以配置平台上的参数,进行接口调用,最后才可能成功。
这一类问题最主要的原因,就是关于软件中的“概念”不够明确清晰。从而导致:
由于很多微服务,在“配置”无法满足需求变化的时候,没有去考虑支持一种脚本,让使用者自己来写扩展代码,也没有去重构自己的微服务 api ,(因为服务 API 一旦公布,就有很多其他程序在用,只能增加而无法修改了)而是选择了另外一条不归路:
定制开发——也就是新增一个专门为了这一个需求,开发一个新的微服务。
这种做法除了会让本服务的代码更加的臃肿和难以维护之外,对于使用者来说,也是一次艰难的接入之旅。原因如下:
不要迷信配置、脚本、库、微服务之中的任何一种,所有的特性,都有其代价。应该根据我们对软件模块的复用性的设计,来选择其支持的复用形式。如果模块的功能比较复杂,就不要希望仅靠配置文件来解决,需要用脚本的就上脚本。如果模块主要是对某些数据的访问,那么就不要尝试把太多功能整合到模块里,用微服务把数据提供出来就好。如果希望模块的灵活性高,最好的选择是做成“库”,特别是并不提供那些特有的数据的模块。我们可以总结一下几种复用形式的适用场景:
如果软件需要通过编程方式使用,不管是脚本、库、还是微服务,这个软件的配置都应该尽量简单。如果真的有需要“配置”的信息,应该以编程的方式,通过初始化函数或者类似的 API 接口进行输入。
如果软件模块是一个库,尽量使用一种语言解决问题,避免部分功能使用一种语言,另外一些功能使用第二种语言。这种情况很容易发生在支持一种脚本的库上。对于库来说,要封装多一种脚本语言,并且要符合这种脚本的特征,是需要很多额外工作的。特别是如果库里面的某些功能需要调整,可能要同时两种语言的封装。
有一些微服务,为了减少接口的修改,从而采用从服务接口输入大段脚本(或者配置)的设计。但是这种设计很容易让开发者在微服务的接口参数,和脚本里面的参数之间迷惑。虽然典型的 MySQL API 就是这种设计,但不可忽视的是使用者确实也花费了大量的时间来学习这种用法。如果这种学习获得的知识,适用范围比较广,还是比较有价值的;如果仅仅为了某个微服务就花这么大的成本,就显得比较不值得了。
虽然说使用编程的方法,如库、微服务的时候,我们完全可以设计成用一个特殊类型的参数,传入无穷复杂的数据——譬如说我们设计一个 map 类型的叫做 ExtendParam 的参数,或者设计一个 string 类型的 Options 参数。但是这种方法,无疑是混合了使用“配置”和“微服务/库”的方法。这需要调用者学习另外一种以参数传入的配置。对于服务提供者来说,也是一种没有抽象,仅仅针对功能做实现的“懒惰”方法,这种行为的代价,就是代码的可维护性降低。
一个软件复用能力的易用性,很大程度取决这些接口,是否具备某种层次的抽象。设计模型,进行抽象,是计算机科学最基本的方法。对于每种复用形式的设计,我们都应该让其符合某种概念完整的抽象,而不仅仅是因为需要某种功能,甚至实现,就添加到复用的接口中去。符合一种概念的抽象,除了能降低使用者的学习成本,还可以帮助开发者优化自己的代码结构,提高代码的可维护性。
对于配置来说,由于其表达能力有限,应该严格限制其内容的复杂程度。它是整个软件模块抽象的补充部分,应该要让人能以最直观的猜想,就能发现其含义。譬如连接数据库需要配置的地址、用户名、密码;web 服务器的文件地址目录等等。更进一步说,配置文件甚至不应该包含软件模块的“业务逻辑”相关的数据,而应该仅仅作为纯技术性参数的配置存在。因为一旦把业务逻辑放到配置里面,必然就会需要使用者理解复杂而多变的业务逻辑,才能正确使用配置,这显然是容易出问题的。
对于脚本来说,由于其有丰富的表达能力,同时也很方便把软件需要处理的“数据”,和处理代码结合起来。所以那些复杂躲避的业务逻辑是适合用脚本来扩展的。但是脚本本身也需要有,针对“业务模型”进行抽象的底层模块,否则脚本的使用者很容易写出让整个系统崩溃的代码。业务模型除了提高开发效率以外,对于系统稳定性甚至性能,也是一种保护。
对于库来说,本身要实现功能丰富和灵活性高,就必须依赖多层抽象的方法。软件工程中有大量相关的知识,如《设计模式》《面向对象编程》《领域驱动设计》都是这方面的。
对于微服务来说,应该保持接口尽量少,尽量简单。不要试图把所有功能都用微服务来实现,因为这显然会带来性能上的高昂代价(用网络带宽代替内存带宽),也会让大量的软件工具(如 IDE)帮不上忙。高内聚低耦合同样适用在微服务领域。