Skip to content

软件架构

软件架构设计是从宏观角度思考软件设计的方式。软件架构的优劣决定了一个软件系统的上限。

单进程程序的结构

从程序的宏观角度看,常见程序的结构可以分成两种:one-shot 和 deamon;one-shot 程序读取命令行参数后,一下子从前执行到尾,处理完数据后,直接退出程序;而 deamon 程序会在解析完命令行参数和配置文件后进入死循环,并通过阻塞式系统调用,监听和等待它感兴趣的下层响应,并在响应发生时被操作系统唤醒,然后执行业务逻辑,除非触发关闭命令或者异常,否则将会按照期望一直运行下去。

c
// deamon 程序
int main(int argc, char *argv[]) {
   // 读取命令行参数,形成启动参数
  Args *args = parseArgs(argc, argv);
  // 读取配置文件,形成程序配置
  Config *config = read(file);

  while(true) {
  // 查找配置中的监听任务
    void *event = findTask(args, config);
    if (event == NULL) {
      break;
    }

    // 阻塞式调用,根据配置信息按照要求监听客户端的请求
    void* incoming = listen(args, config);

    // 业务层寻找处理该请求的函数
    void* handler = findHandler(args, config, incoming);

    // 分发该请求
    void* result = distribute(args, config, incoming, handler);

    // 响应请求
    response(incoming, result);
  }

  // 释放程序的所有资源
  cleanAll(args, config);
  return 0;
}

// 清除监听任务的请求处理器
void handleShutdown(Args *args, Config *config, ...) {
   cleanTask(args, config);
}

前者,较为简单,略;后者是我们平时编写多数程序的结构,包括前端的 GUI 客户端程序和后端的 server 程序。

deamon 程序

从纵向来看,程序需要在开始时解析配置和参数,然后进入循环,从配置和参数中寻找需要监听和处理的任务,如果找到了,则根据任务的类型,将具体任务分发给相应的处理函数,得到处理结果,然后把结果返回给客户端,往复循坏。何时停止?当需要监听的事件和任务被处理完了,或者触发了清理监听任务的处理器的时候,由于没有任务,则程序 break 出循环,程序结束。同时,在处理任务的过程中,可能由于监听到了新的事件发生,从而向任务队列中添加了新的任务,也就是任务是可增可减,动态变化的,形如一个状态机的结构。

从横向看,deamon 程序的结构可以被概括为一个“前中后”三层结构——前端视图层、中间业务层、后端持久层。视图层面向自己的客户端,监听客户端的请求和操作,解析和翻译操作,并将请求的任务分发给中间业务层;中间层负责进行主体复杂逻辑的实现和繁重工作的处理,它在处理业务的过程中可能需要依赖外界的持久化数据,或者将数据进行持久化,它会选择性地调用后端持久层,进行数据持久化的操作;后端持久层通过系统 IO 调用,向进程外发送和获取数据,或是作为数据的直接管理者,代替进程中的其他部分管理数据。在横向的三层中,必须要保持单向依赖,以简化程序的结构,让层间的功能保持独立。

子程序和函数

进一步向更小的粒度分析,我们发现,一个程序往往由一个个子程序构成,或者直白点,在多数的编程语言中,它叫做函数。先从更底层的 C 语言和汇编的角度去分析函数是什么?函数是进程的内存空间中的一段数据,它记录的是一段 CPU 可执行的机器指令序列,我们可以告知 CPU 这段内存中的数据是指令,并应当读取并执行它;同时,在执行之前,可以携带一定的指针,指向一些数据,并要求令这些数据作为函数执行时的一部分上下文,函数在执行期间,可以访问他们,最后函数应当将处理数据的结果,保留到一个指定的 Return 指针中进行返回,让函数的调用者能够通过访问该指针拿到自己想要的数据和结果。这个过程可以被抽象成一个管道,它从进口端接受一些数据,并使用这些数据进行计算,产生一个返回值,并从管道的出口端返回。

函数有如下几个重要特点:

  • 函数嵌套调用,函数内部可以调用另一个函数,从而形成嵌套,大大减少重复代码的书写;为了实现这个特性,大多数的编译器采用调用栈来实现;

  • 系统调用,系统调用不是用户自己编写的,它存在于进程中,在进程创建的时候就被操作系统一同加载到了进程的内存空间,用户可以直接调用这些函数实现逻辑复用和受限资源的访问;这些函数区别于用户自行实现的函数,他们是系统层提供的 API 接口;

    系统调用有一个特点那就是外界交互。程序期望和外界发生交互,或者说 IO 操作。IO 操作有一个非常大的特点就是,可能是失败的,它可能返回一个程序无法处理的结果或者没有结果,那么程序将会就此崩溃,或者是主动退出;健壮的程序可以通过设置失败 fallback 机制来在 IO 操作失败之后继续运行。

  • 函数复用,函数的目的就是为了复用程序逻辑,避免重复书写相同的代码。

作用域

进程从操作系统那里申请一个块巨大的内存空间,它是一个简洁的字节数组,但是却也非常的简陋,我们需要对内存进行划分使用和管理,而不是随意使用,其中一个重要的手段便是作用域。作用域是为了划分和隔离子程序,拆解问题,提供资源的隔离。

我们在一个内存区域中划分一小块进行使用,在区域中存放了一些数据,接着进行了一些计算,然后释放和关闭了这个作用域,不同的作用域在不同的时期被展开和闭合,并且彼此之间的数据资源存在一定的隔离性。作用域需要程序设计时被声明,在运行时依照声明被展开;展开时,可以将状态传入一个域,在域展开的过程当中,状态可以被改变,在域使用完毕时,可以将闭合,释放资源,同时传出一些状态。

当我们在一个作用域中声明和创建了一个变量的时候,该数据的大小可能会发生变化,我们会对其进行重新赋值。

从一定角度看,程序就是状态 + 作用域的管理。不同的编程语言中,大都提供了相似的概念和 API 来让开发这创建作用域。其中,最典型的就是函数。函数在执行的时候,可以展开一个作用域,在作用域中可以声明和创建变量,并且这些变量对函数外部来说不可见,命名可以在函数作用域范围内命名,不必担心与外部变量重名。

父子作用域

我们划分并隔离不同的子程序,但是最后却又要让子程序之间发生交互和数据交换,发生父子嵌套和兄弟并列,这体现了封装和接口的思想。父作用域只需调用一个接口,便可以完成一个复杂动作,而无需关系其底层细节。

在不同的语言中,提供了不同层级的作用域抽象,层级之间可以相互嵌套,包括模块作用域类作用域函数作用域块作用域。但是不管如何,我们需要着重关注的是父作用域和子作用域之间的关系——父作用域依赖于子作用域

作用域可以被分为,全局作用域和局部作用域。全局作用域中的内容被整个程序可见,并且早于所有局部作用域被展开,晚于所有局部作用域被闭合。全局作用域只有一个。全局作用域是所有作用域的父代。函数调用展开一个局部作用域,局部作用域中可以声明自己的数据和状态,在一些高级语言中,局部作用域中甚至可以声明临时的匿名函数。

管理规则

  • 当一个作用域依赖于其他作用域的时候,它的功能就受到了别人的影响,为了让这个作用域正常工作,就需要保证它依赖的所有作用域正常工作,或者干脆不依赖别人;
  • 作用域可以选择对外暴露一些数据和状态作为接口,作用域之间的接口应该保持稳定,当接口更变时,同时需要改变依赖者的逻辑;
  • 作用域之间应该避免循环依赖,因为循环依赖往往是程序划分不正确的外在体现,或者从哲理的角度讲,循环依赖其实是在解决“蛋生鸡”的问题,但是这是没有办法解决的;解决循环依赖的方法就是,拆分数据结构和函数,将其中的冲突内容,拆分到一个新的独立作用域中
  • 作用域之间需要通信,特别是对于兄弟组件或者是没有父子关系的组件之间进行通信,通信的基本方式是:基于父组件进行共享,然而这会造成一些额外的问题——变量的状态管理,后续将会展开讨论。

生命周期

作用域和状态都有生命周期。生命周期管理是作用域和状态管理的重要内容。

作用域的生命周期是指其代码从被展开到关闭的这个时间段区间。对于函数就是从函数进入开始,到函数返回这个时间区间;对于一个变量就是从创建,到读取/修改,再到销毁,的整个时间段。

变量的创建的标志往往就是变量的声明语句,修改的标志就是变量的赋值语句,销毁往往对应着变量的 free 调用。对于外部的 IO 资源,创建对应着 open 操作,修改对应着的写入操作,销毁对应着 close 操作。

在一些语言中,不同的语法和特性会产生不同的生命周期,管理生命周期是管理状态和作用域的一部分,使用各自的方式和语法,但是通用的规则普遍适用。

管理规则

  1. 状态和作用域之间具有所属关系,称之为所有权,一个变量在哪里创建,其最近一级的作用域就是其所有者。访问状态资源应当在适当的作用域中,在一个作用域中不应该访问不属于自己的状态资源;

  2. 局部作用域中声明的状态,其生命周期应当小于该局部作用域的生命周期。在关闭该作用域的时候,必须一起关闭其所属的资源,除非该作用域在关闭时将该变量的所有权转移出去,此时该作用域不再拥有对该资源的资源访问权限,状态的销毁责任也被转移给了外部作用域;

    在具有垃圾回收的语言中,程序员往往会忘记回收需要手动关闭的变量和资源,当一个变量需要手动清除,例如全局状态 API,外部 IO 资源等,在资源还在使用期间,必须始终持有其引用,以保证资源正确关闭。

  3. 全局作用域在一次程序运行期间往往只能被展开一次,重新展开需要重启程序,不应该在全局作用域中声明一个需要重复创建和销毁的资源。局部作用域在一次程序运行期间往往可以被展开多次,意味着其中的数据也可以被重复的创建和销毁,但是多次展开的作用域实例之间被视作不同的作用域