抽象与隔离原则

抽象与隔离

低耦合,高内聚

抽象

通常,抽象是通过通常称为应用程序编程接口(API)的方式实现的。API 是一个有点含糊的术语,在各种编程工作中都意味着不同的事物。从根本上讲,程序员设计一组功能并记录其接口和功能,其原理是提供 API 的实际实现是不透明的。例如,许多大型 Web 应用程序都提供了可通过 HTTP 访问的 API。通过这种方法访问数据肯定会触发许多复杂的远程过程调用,数据库查询和数据传输,所有这些对于仅接收合同数据的最终用户都是不透明的。

那些熟悉 Java,Python 或 C++ 等面向对象语言的人会熟悉类提供的抽象。方法提供了类的接口,但是抽象了实现。对于像 C 这样缺乏内建面向对象支持的语言,我们可以使用函数指针来实现抽象:

#include <stdio.h>

/* The API to implement */
struct greet_api
{
	int (*say_hello)(char *name);
	int (*say_goodbye)(void);
};

/* Our implementation of the hello function */
int say_hello_fn(char *name)
{
	printf("Hello %s\n", name);
	return 0;
}

/* Our implementation of the goodbye function */
int say_goodbye_fn(void)
{
	printf("Goodbye\n");
	return 0;
}

/* A struct implementing the API */
struct greet_api greet_api =
{
	.say_hello = say_hello_fn,
	.say_goodbye = say_goodbye_fn
};

/* main() doesn't need to know anything about how the
 * say_hello/goodbye works, it just knows that it does */
int main(int argc, char *argv[])
{
	greet_api.say_hello(argv[1]);
	greet_api.say_goodbye();

	printf("%p, %p, %p\n", greet_api.say_hello, say_hello_fn, &say_hello_fn);

	exit(0);
}

我们首先通过 greet_api 结构体来定义接口,函数指针描述了它必须指向的函数原型;将其指向没有正确返回类型或参数的函数将至少产生一个编译器警告,如果保留在代码中,则很可能导致错误的操作或崩溃。然后我们定义 API 的具体实现,在实际情况下接口的定义与接口的实现可以由不同的工程师完成。

抽象的概念也贯穿了几乎所有的软件开发,譬如在 Linux、BSD 这样的类 Unix 系统中,万物皆文件(Everything is a file),文件的概念是数据接收器或数据源的良好抽象。

低耦合,高内聚

从软件设计上讲存在三种不同层级的耦合度,即技术耦合、空间耦合和时间耦合。技术耦合度表现在服务提供者与服务消费者之间需要使用同一种技术实现方式。如下图 a 中服务提供者与服务消费者都使用 RMI(Remote Method Invocation)作为通信的基本技术,而 RMI 是 Java 领域特有的技术,也就意味着其他服务消费者想要使用该服务也只能采用 Java 作为它的基本开发语言;空间耦合度指的是服务提供者与服务消费者都需要使用统一的方法签名才能相互协作,下图 b 中的 getUserById(id)方法名称和参数的定义就是这种耦合的具体体现;而时间耦合度则表现在服务提供者与服务消费者只有同时在线才能完成一个完整的服务调用过程,如果出现下图 c 中所示的服务提供者不可用的情况,显然,服务消费者调用该服务就会发生失败。

抽象

抽象的使用是计算机科学中最为重要的概念之一。例如,为一组函数规定一个简单的应用程序接口(API)就是一个很好的编程习惯,程序员无需了解它内部的工作便可以使用这些代码。不同的编程语言提供不同形式和等级的抽象支持,例如 Java 类的声明和 C 语言的函数原型。操作系统中也存在着很多的抽象:

在处理器里,指令集结构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像它是运行在一个一次只执行一条指令的处理器上。底层的硬件比抽象描述的要复杂精细得多,它并行地执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一样,不同的处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。

image

文件是对 IO 的抽象,虚拟存储器是对程序存储器的抽象,而进程是对一个正在运行的程序的抽象。我们再增加一个新的抽象:虚拟机,它提供对整个计算机(包括操作系统、处理器和程序)的抽象。虚拟机的思想是 IBM 在 20 世纪 60 年代提出来的,但是最近才显示出其管理计算机方式上的优势,因为一些计算机必须能够运行为不同操作系统(例如,Microsoft Windows、MacOS 和 Linux)或同一操作系统的不同版本而设计的程序。

节选自《Growth: 全栈增长工程师指南》

为了将我们的应用部署到服务器上,我们需要为其配置一个运行环境。从底层到顶层有这样的运行环境及容器:

  1. 隔离硬件:虚拟机
  2. 隔离操作系统:容器虚拟化
  3. 隔离底层:Servlet 容器
  4. 隔离依赖版本:虚拟环境
  5. 隔离运行环境:语言虚拟机
  6. 隔离语言:DSL

实现上这是一个请求的处理过程,一个 HTTP 请求会先到达你的主机。如果你的主机上运行着多个虚拟机实例,那么请求就会来到这个虚拟机上。又或者是如果你是在 Docker 这一类容器里运行你的程序的话,那么也会先到达 Docker。随后这个请求就会交由 HTTP 服务器来处理,如 Apache、Nginx,这些 HTTP 服务器再将这些请求交由对应的应用或脚本来处理。随后将交由语言底层的指令来处理。

不同的环境有不同的选择,当然也可以结合在一起。不过,从理论上来说在最外层还是应该有一个真机的,但是我想大家都有这个明确的概念,就不多解释了。

1

隔离硬件

虚拟机

在虚拟机技术出现之前,为了运行不同用户的应用程序,人们需要不同的物理机才能实现这样的需求。对于 Web 应用程序来说,有的用户的网站访问量少消耗的系统资源也少,有的用户的网站访问量大消耗的系统资源也多。虽然有不同的服务器类型可以选择,然而对于多数的访问少的用户来说他们需要支付同样的费用。这听上去相当的不合理,并且也浪费了大量的资源。并且对于系统管理员来说,管理这些系统也不是一件容易的事。在过去硬件技术革新特别快,让操作系统运行在不同的机器上也不是一件容易的事。

虚拟机(Virtual Machine )指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。

这是一个很有意思的技术,它可以让我们在一个主机上同时运行几个不同的操作系统。我们可以为这几个操作系统使用不同的硬件,在这之上的应用可以使用不同的技术栈来运行,并且从理论上互相不影响。其架构如下图所示:

借助于虚拟机技术,当我们需要更多的资源的时候,创建一个新的虚拟机就行了。同时,由于这些虚拟机上运行的是同样的操作系统,并且可以使用相同的配置,我们只需要编写一些脚本就可以实现其自动化。当我们的物联机发生问题时,我们也可以很快将虚拟机迁移或恢复到另外的宿主机。

2

隔离操作系统

容器虚拟化

对于大部分的开发团队来说,直接开发基于虚拟机的自动化工具不是一件容易的事,并且他从使用成本上来说比较高。这时候我们就需要一些更轻量级的工具容器:它可以提供轻量级的虚拟化,以便隔离进程和资源,而且不需要提供指令解释机制以及全虚拟化的其他复杂性。并且,它从启动速度上来说更快。

LXC

在介绍 Docker 之前,我们还是稍微提一下 LXC。因为在过去我有一些使用 LXC 的经历,让我觉得 LXC 很赞。

LXC,其名称来自 Linux 软件容器(Linux Containers )的缩写,一种操作系统层虚拟化(Operating system–level virtualization )技术,为 Linux 内核容器功能的一个用户空间接口。它将应用软件系统打包成一个软件容器(Container ),内含应用软件本身的代码,以及所需要的操作系统核心和库。通过统一的名字空间和共用 API 来分配不同软件容器的可用硬件资源,创造出应用程序的独立沙箱运行环境,使得 Linux 用户可以容易的创建和管理系统或应用容器。

我们可以将之以上面说到的虚拟机作一个简单的对比,其架构图如下所示:

我们会发现虚拟机中多了一层 Hypervisor—— 运行在物理服务器和操作系统之间,它可以让多个操作系统和应用共享一套基础物理硬件。这一层级可以协调访问服务器上的所有物理设备和虚拟机,然而由于这一层级的存在,它也将消耗更多的能量。据爱立信研究院和阿尔托大学发表的论文表示:Docker、LXC 与 Xen、KVM 在完成相同的工作时要少消耗 10% 的能耗。

LXC 主要是利用 cgroups 与 namespace 的功能,来向提供应用软件一个独立的操作系统运行环境。cgroups (即 Control Groups)j Linux 内核提供的一种可以限制、记录、隔离进程组所使用的物理资源的机制。而由 namespace 来责任隔离控制。

与虚拟机相比,LXC 隔离性方面有所不足,这就意味着在实现可移植部署会遇到一些困难。这时候,我们就需要 Docker 来提供一个抽象层,并提供一个管理机制。

Docker

Docker 是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。Docker 可以自动化打包和部署任何应用、创建一个轻量级私有 PaaS 云、搭建开发测试环境、部署可扩展的 Web 应用等。

构建出 Docker 的 Container 是一个很有意思的过程。在这一个过程中,首先我们需要一个 base images,这个基础镜像不仅包含了一个基础系统,如 Ubuntu、Debian。他还包含了一系列的模块,如初始化进程、SSH 服务、syslog-ng 等等的一些工具。由上面原内容构建了一个基础镜像,随后的修改都将于这个镜像,我们可以用它生成新的镜像,一层层的往上叠加。而用户的进程运行在 writeable 的 layer 中。

从上图中我们还可以发现一点: Docker 容器是建立在 Aufs 基础上的。AUFS 是一种 Union File System,它可以不同的目录挂载到同一个虚拟文件系统下。它的目的就是为了实现上图的增量递增的过程,同时又不会影响原有的目录。即如下的流程如下:

其增量的过程和我们使用 Git 的过程中有点像,除了在最开始的时候会有一个镜像层。随后我们的修改都可以保存下来,并且当下次我们提交修改的时候,我们也可以在旧有的提交上运行。

因此,Docker 与 LXC 的差距就如下如图所示:

LXC 时每个虚拟机只能是一个虚拟机,而 Docker 则是一系列的虚拟机。

3

隔离底层

Servlet 容器

在上面的例子里我们已经隔离开了操作系统的因素,接着我们还需要解决操作系统、开发环境引起的差异。早期开发 Web 应用时,人们使用 CGI 技术,它可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。并且 CGI 程序可以用任何脚本语言或者是完全独立编程语言实现,只要这个语言可以在这个系统上运行。而这样的脚本语言在多数情况下是依赖于系统环境的,特别是针对于 C++ 这一类的编译语言来说,在不同的操作系统中都需要重新编译。

而 Java 的 Servlet 则是另外一种有趣的存在,它是一种独立于平台和协议的服务器端的 Java 应用程序,可以生成动态的 Web 页面。

Tomcat

在开发 Java Web 应用的过程中,我们在开始环境使用 Jetty 来运行我们的服务,而在生产环境使用 Tomcat 来运行。他们都是 Servlet 容器,可以在其上面运行着同一个 Servlet 应用。Servlet 是指由 Java 编写的服务器端程序,它们是为响应 Web 应用程序上下文中的 HTTP 请求而设计的。它是应用服务器中位于组件和平台之间的接口集合。

Tomcat 服务器是一个免费的开放源代码的 Web 应用服务器。它运行时占用的系统资源小,扩展性好,支持负载平衡与邮件服务等开发应用系统常用的功能。除此,它还是一个 Servlet 和 JSP 容器,独立的 Servlet 容器是 Tomcat 的默认模式。其架构如下图所示:

Servlet 被部署在应用服务器中,并由容器来控制其生命周期。在运行时由 Web 服务器软件处理一般请求,并把 Servlet 调用传递给 “ 容器 ” 来处理。并且 Tomcat 也会负责对一些静态资源的处理。

4

隔离依赖版本

虚拟环境

对于 Java 这一类的编译语言来说,不存在太多语言运行带来的问题。而对于动态语言来说就存在这样的问题,如 Ruby、Python、Node.js 等等,这一个问题主要集中于开发环境。当然如果你在一个服务器上运行着几个不同的应用来说,也会存在这样的问题。这一类的工具在 Python 里有 VirtualEnv,在 Ruby 里有 RVM、Rbenv,在 Node.js 里有 NVM。

下图是使用 VirtualEnv 时的不同几个应用的架构图:

如下所示,在不同的虚拟环境里,我们可以使用不同的依赖库。在这上面构建不同的应用,也可以使用不同的 Python 版本来构建系统。通常来说,这一类的工具主要用于本地的开发环境。

5

隔离运行环境

语言虚拟机

最后一个要介绍的可能就是更加抽象的,但是也是更加实用的一个,JVM 就是这方面的一个代表。在我们的编程生涯里,我们很容易就会遇到跨平台问题:即我们在我们的开发机器上开发的软件,在我们的产品环境的机器上就没有办法运行。特别是当我们使用 Mac OS 或者 Windows 机器上开发了我们的应用,然后我们需要在 Linux 系统上运行,就会遇到各种问题。并且当我们使用了一个需要重新编译的库时,这种问题就更加麻烦。

如下图所示的是 JVM 的架构示意图

JVM 是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。它可以实现 “ 编写一次,到处运行 ”。

换句话来说,它在底层实现了环境隔离,它屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

基于此,只要其他编程语言的编译器能生成正确 Java bytecode 文件,这个语言也能实现在 JVM 上运行。如下图所示的是基于 JVM 的 Jython 语言的架构图:

其底层是基于 JVM,而编写时则是用 Python 语言,并且他可以使用 Java 的模块来编程。

常见拥有同样架构的工具,还有 MySQL,如下图是所示的是 MySQL 的架构图:

MySQL 在最顶层提供了一个名为 SQL 的查询语言,这个查询语言只能用于查询数据库,然而它却是一种更高级的用法。它不像通用目的语言那样目标范围涵盖一切软件问题,而是专门针对某一特定问题的计算机语言,即领域特定语言。

6

隔离语言

DSL

这是一门特别有意思也特别值得期待的技术,但是实现它并不是一件容易的事。

作为讨论隔离环境的一部分,我们只看外部 DSL。内部 DSL 与外部 DSL 最大的区别在于:外部 DSL 近似于创建了一种新的语法和语义的全新语言。如下图所示是两中 DSL 的一种对比:

在这样的外部 DSL 里,我们有自己的语法、自己的解析器、类型检测器等等。最简单且最常用的 DSL 就是 Markdown,如下图所示:

如果我们可以将我们的业务逻辑写成 DSL,那么我们就不需要担心底层语言的变动过多地影响原有的业务逻辑。换句话说,这相当于创建了我们自己的语言隔离环境,我们不需要思考用何种语言来实用我们的业务。

抽象的案例

容器与虚拟化

事务

下一页