📰软件模块的几种复用形式研究

585 阅读22分钟

几种常见复用形式

配置

这是最“古老”的,也是最常见的软件复用形式。代表软件有 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 界面进行配置,很多非技术专业人员也可以得心应手设置某些特性。

image

缺点

  • 最明显的缺点是“不通用”,由于配置文件的内容、形式,也是一种知识,但这种知识付出了学习成本之后,能应用的范围往往很小——局限于某一个版本的软件上。一个对 Apache 配置文件非常熟悉的人,换到 Nginx 上,配置文件怎么写往往又要学习一段时间。一个软件的配置越复杂,需要学习的成本就越高,同时这些知识失效的风险就越大。因此,只有让配置文件尽量的简单,才能对冲这种风险;然而如果这样,软件的灵活性也被迫限制。

  • 另外一个缺点是配置文件的格式,往往表达能力不会很强。普通的 INI 对于比较复杂的软件来说已经远远不够,而类似 JSON/XML 这类格式,编写和阅读起来往往都很累人(YAML 相对要好一些)。由于配置文件没有开发工具提示和校验,写起来往往比编程还费劲。当然很多软件也提供 GUI 界面(往往是 web),这种配置相对好一点,但是如果对着大段的“不理解”的选项和空格,填起来也是很累人的。——所有的这些,往往是由于软件的开发者,在“如何提供一个完整的概念”来包装这个软件的“使用知识”没有仔细的设计。只有对软件的功能进行抽象,形成一个明确的“概念”,才能帮助使用者更快的学习这个软件的功能。而配置文件的格式,其表达能力往往也很受限制,难以对比较复杂的概念进行完整的表达。所以有些软件如 Nginx 可以使用某种脚本语言如 Lua 作为配置文件的格式。

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";
};
......
}

优点

  • 使用经过完整设计的脚本语言,最大的优点就是灵活性非常高。可以进行非常丰富的逻辑表达,从而扩展出相当多的能力。

  • 脚本语言是一种比较“通用”的知识,配到的开发工具也比较多,间接提高了使用者的工作效率。

image

缺点

  • 由于是一门编程语言,所以肯定是有学习门槛。虽然脚本语言相对学习成本较低,但还是不能简单的看一看例子就直接用起来。不过这也是为了掌握“强大”能力的一种代价吧。

  • 支持脚本的另外一个缺点,就是作为支持方的软件,必须要提供脚本能力的支持,这种支持不是简单的把功能实现就可以,而是要加上脚本的编程接口才行。这可能会增加了额外的工作量,除非这个软件本身就是用这种脚本语言开发的。

  • 脚本语言最后一个缺点可能就是性能问题。很多复杂并且性能要求高的功能,往往不能简单的使用脚本语言来开发。

软件模块以库的形式提供扩展,是最通用也是最传统的方式。所有程序员每天都会和很多的“库”打交道,不使用程序库,几乎无法开发出任何有价值的软件。以库形式提供功能的软件模块,最典型也最有用的,莫过于操作系统。Linux 的系统调用函数手册,甚至可以直接通过 man 指令查询。一个设计良好的库,是最有价值的软件资产,它能提供强大的能力,同时也能提供无以伦比的灵活性。有一些编程语言,正是以其完整而丰富的库闻名,如 Java/C#/Go。

image

优点

  • 最高的扩展性。整个软件世界就是建立在“库”这种结构的基础上的。大量的软件工程理论和工具,为这种复用形式提供了坚实的支持。

  • 知识非常通用。得益于开源运动,优秀的库往往很快能成为业界的标准,而且在不断使用中进化。所以学习一些库的用法,往往是能在最大范围内受益的。

缺点

  • 某些语言的库,在依赖管理和编译集成上有相当高的门槛。最典型的就是 C/C++ 这一类。在 Linux/Windows 上,支持的静态链接、动态链接工具和方法,都是需要专门学习的。因此也诞生了《Link Load Libaray》这种书。幸好比较新的语言,都比较关注这一部分了,譬如 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)

优点

  • 使用 RPC 和 SOA,对于一个集群提供的服务进行调用,是一个非常方便的手段。对比自己编写协议通信,以及学习使用某些特定的 API,使用某个 IDL(接口描述语言)生成的各种库,显然会更加轻松。

  • 由于微服务往往运行在某个集群上的,所以开发者使用这个功能,是不需要去关心如何编译、部署、启动这些服务提供的过程。所以在运维角度上,是有非常大的便利性的。

  • 对于那些具有专门数据的功能的调用,比如获取某个 IM 软件的关系链,读取某个网络游戏中的玩家账号数据等等,几乎只能通过进场间通信来实现,而微服务则大大简化了这类功能的实现。

缺点

  • 微服务第一个缺点,是由它的优点带来的:你无法灵活的部署自己的微服务集群。因为这些集群往往是别人提供的,所以如果你想部署一些用于开发、测试的环境,而你的微服务提供者并没有这些环境,你只能自己 mock(模拟)一些替代品,这对于开发和测试来说都会增加成本。同样的,一般这些集群往往还有权限控制、网络安全限制、SDK 版本等大量“运行时”问题,让微服务的调用需要跨过很多困难,这些困难往往不是常见的技术问题,IDE 或者其他一些工具都无能为力。一个“充满各种规定的”的微服务集群,绝对会使用者感到身心俱疲。

  • 微服务也是一种“库”,而这个“库”却又多了一些约束局限:微服务一般都是“请求-响应”式的,你比较难用来传入你自定义的回调函数,你也很难通过继承或者其他封装来扩展这个库。

  • 微服务有一个相当严重,但又很隐藏的错误,就是“引诱”了很多程序员滥用微服务。由于开发一个提供服务的函数非常简单,所以一些开发者不再去认真设计自己的代码结构,而是把每个新发现的需求,都重新写一个微服务函数。很快系统中就会充满了各种类似而又有一点点差异的 RPC 函数;而另外一些开发者则尝试在同一个 RPC 中塞入大量的参数,让这个 RPC 本身变得非常“通用”,从而让整个程序难以维护。

总结表格

形式灵活性知识通用性学习难度
配置
脚本⭐⭐⭐⭐⭐⭐⭐
⭐⭐⭐⭐⭐⭐⭐⭐⭐
微服务⭐⭐⭐⭐⭐⭐⭐

几种典型不良设计

配置一切

很多人觉得配置文件修改后,无需编译,甚至无需重启,是最灵活的一种形式。在运维的角度来看,修改配置确实是一种很方便的形式。但是配置文件的表达能力有限,特别是对于有类似 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)的接口,通常也是需要使用者进行一系列的配置,然后才能以配置平台上的参数,进行接口调用,最后才可能成功。

image.png

这一类问题最主要的原因,就是关于软件中的“概念”不够明确清晰。从而导致:

  1. 很多配置和参数在中文名和英文名上的混淆,让人摸不着头脑

  2. 对于调用方传入的脚本,在整个处理流程中的调用时机,以及调用结果的处理方法,没有很明确的说明

  3. 提供服务的软件模块,在内存空间上,没有很明确的说明,这导致了编写脚本的时候,不知道各种数据的生命周期和可访问性如何

复杂配置+二次开发微服务

由于很多微服务,在“配置”无法满足需求变化的时候,没有去考虑支持一种脚本,让使用者自己来写扩展代码,也没有去重构自己的微服务 api ,(因为服务 API 一旦公布,就有很多其他程序在用,只能增加而无法修改了)而是选择了另外一条不归路:

定制开发——也就是新增一个专门为了这一个需求,开发一个新的微服务。

image.png 这种做法除了会让本服务的代码更加的臃肿和难以维护之外,对于使用者来说,也是一次艰难的接入之旅。原因如下:

  • 需求理解的过程,导致接口的变化。如果我们并不太在意微服务接口的通用性,而希望尽量多的包含当前所需的功能,就很容易造成微服务接口的频繁变化。原因是我们往往在开发、联调中才会真正的认识和确定需求——这是认识规律决定的,而不以个人意志转移。微服务想做的越多,其根据需求变化而变化的频率就越高。这种变化带来的直接就是调用方和被调用方的频繁联调。我们都知道,软件模块开发的良好实践应该是“对接口编程”,所以这种接口频繁变化,显然是不良的实践。

  • 微服务的配置和接口参数的歧义。如果我们并不把微服务视为一种非常内聚的,尽量少修改的模块,那么我们会发现,如果需要增加一些“功能更多”的微服务的时候,就会需要调用其他的一些微服务。一条长长的微服务调用链,肯定有一些参数是需要最终调用者来负责配置、填写参数的。这些配置可能被分散在各个微服务的配置界面/平台上,而需要调用者一一去配置,然后填入最末端的微服务接口中。这很容易造成微服务的参数非常多,也容易造成不好理解的问题。

  • 由于需要开发联调环境而新增的运维问题。微服务作为一种运行时系统,是无法简单的跟随调用者部署到任意开发环境的。这就导致了调用者的运行环境,和其需要调用的微服务的运行环境之间的联通问题。最常见的问题就是网络隔离问题:可能双方的服务发现系统不互通,也可能服务器之间的网络有防火墙。更加不容易调试的是,同时运行的多个微服务环境,可能运行着不同的配置,或者代码版本,如果调用者对于调用“哪个环境”有所误解,可能会需要调用者和被调用者双方一起查找问题。如果这是一条比较长的调用链,那排除因为环境导致的故障,就需要更多人参与,这无疑会大大增加解决故障的难度。

提高复用能力的解决思路

针对需求选择复用形式

不要迷信配置、脚本、库、微服务之中的任何一种,所有的特性,都有其代价。应该根据我们对软件模块的复用性的设计,来选择其支持的复用形式。如果模块的功能比较复杂,就不要希望仅靠配置文件来解决,需要用脚本的就上脚本。如果模块主要是对某些数据的访问,那么就不要尝试把太多功能整合到模块里,用微服务把数据提供出来就好。如果希望模块的灵活性高,最好的选择是做成“库”,特别是并不提供那些特有的数据的模块。我们可以总结一下几种复用形式的适用场景:

  • 软件模块需要提供给非编程人员使用,或者软件功能比较单一,采用配置文件形式。如果有很多需要在“判断”甚至“循环”逻辑下进行配置的情况,就不适合仅仅用配置文件了,可能需要用脚本进行控制。

  • 软件模块希望能解决很多未知场景的问题,对灵活性要求高的,首选库的形式。在很多需要封装业务逻辑处理的地方,应该首选使用库的方式。这对于维护者和开发者来说,都是成本最低的方案。

  • 软件模块保有重要的,需要保密的数据,或者这些功能是依赖某些动态数据的,如果功能不复杂,使用微服务。然而,我们现实中往往碰到,很多需求都会要求我们的微服务,除了提供原始的数据以外,还有很多处理。在这种情况下,就要坚持高内聚的设计思想,要让我们提供的微服务,能紧密的符合某种抽象模型,而不是通过“来一个需求就修改一次,加一个参数或者接口”,去满足需求。

  • 软件模块依赖动态数据的,譬如缓存系统或者数据库系统,基本上就是需要脚本形式的复用。这方面诸如 MySQL 和 Redis 就提供了很典型的实现方案。在很多和数据有关的业务上,这种方式是很多开发者都忽视的,因为,比起要提供一个功能完整,而且稳定性有保障的“编程运行环境”,多做几个接口要简单的多。但是脚本提供的灵活性,以及脚本环境和数据在同一个环境上所提供的高性能,收益也是非常的大。

尽量只使用一种形式

如果软件需要通过编程方式使用,不管是脚本、库、还是微服务,这个软件的配置都应该尽量简单。如果真的有需要“配置”的信息,应该以编程的方式,通过初始化函数或者类似的 API 接口进行输入。

如果软件模块是一个库,尽量使用一种语言解决问题,避免部分功能使用一种语言,另外一些功能使用第二种语言。这种情况很容易发生在支持一种脚本的库上。对于库来说,要封装多一种脚本语言,并且要符合这种脚本的特征,是需要很多额外工作的。特别是如果库里面的某些功能需要调整,可能要同时两种语言的封装。

有一些微服务,为了减少接口的修改,从而采用从服务接口输入大段脚本(或者配置)的设计。但是这种设计很容易让开发者在微服务的接口参数,和脚本里面的参数之间迷惑。虽然典型的 MySQL API 就是这种设计,但不可忽视的是使用者确实也花费了大量的时间来学习这种用法。如果这种学习获得的知识,适用范围比较广,还是比较有价值的;如果仅仅为了某个微服务就花这么大的成本,就显得比较不值得了。

虽然说使用编程的方法,如库、微服务的时候,我们完全可以设计成用一个特殊类型的参数,传入无穷复杂的数据——譬如说我们设计一个 map 类型的叫做 ExtendParam 的参数,或者设计一个 string 类型的 Options 参数。但是这种方法,无疑是混合了使用“配置”和“微服务/库”的方法。这需要调用者学习另外一种以参数传入的配置。对于服务提供者来说,也是一种没有抽象,仅仅针对功能做实现的“懒惰”方法,这种行为的代价,就是代码的可维护性降低。

对问题进行不同层次的抽象

一个软件复用能力的易用性,很大程度取决这些接口,是否具备某种层次的抽象。设计模型,进行抽象,是计算机科学最基本的方法。对于每种复用形式的设计,我们都应该让其符合某种概念完整的抽象,而不仅仅是因为需要某种功能,甚至实现,就添加到复用的接口中去。符合一种概念的抽象,除了能降低使用者的学习成本,还可以帮助开发者优化自己的代码结构,提高代码的可维护性。

image.png

对于配置来说,由于其表达能力有限,应该严格限制其内容的复杂程度。它是整个软件模块抽象的补充部分,应该要让人能以最直观的猜想,就能发现其含义。譬如连接数据库需要配置的地址、用户名、密码;web 服务器的文件地址目录等等。更进一步说,配置文件甚至不应该包含软件模块的“业务逻辑”相关的数据,而应该仅仅作为纯技术性参数的配置存在。因为一旦把业务逻辑放到配置里面,必然就会需要使用者理解复杂而多变的业务逻辑,才能正确使用配置,这显然是容易出问题的。

对于脚本来说,由于其有丰富的表达能力,同时也很方便把软件需要处理的“数据”,和处理代码结合起来。所以那些复杂躲避的业务逻辑是适合用脚本来扩展的。但是脚本本身也需要有,针对“业务模型”进行抽象的底层模块,否则脚本的使用者很容易写出让整个系统崩溃的代码。业务模型除了提高开发效率以外,对于系统稳定性甚至性能,也是一种保护。

对于库来说,本身要实现功能丰富和灵活性高,就必须依赖多层抽象的方法。软件工程中有大量相关的知识,如《设计模式》《面向对象编程》《领域驱动设计》都是这方面的。

对于微服务来说,应该保持接口尽量少,尽量简单。不要试图把所有功能都用微服务来实现,因为这显然会带来性能上的高昂代价(用网络带宽代替内存带宽),也会让大量的软件工具(如 IDE)帮不上忙。高内聚低耦合同样适用在微服务领域。