Post

软件设计中的责任分离

在编码中存在一个职责分离的原则,是说将系统切分成不同的部分,每一个部分实现系统功能的一部分。这个方法在大的系统切分中很容易实现。因为一般而言一个大的系统流程都可以切分成不同的子系统,每一个子系统实现独立的功能,然后通过消息传递或者共享内存的方式连接协同工作。但是同样的原则,在具体的代码中就显得比较难处理。主要的难度在于一个细致的功能点,在代码实现的时候最简单的是将所有的功能都实现在一个类中。这个也是长期的编写业务代码而不是框架代码带来的训练,但是这往往也不是最佳的设计,如果长期编写类似偏向业务的代码而没有对代码进行设计,可能我们自己都没有发现有这样的问题。举一个最简单的例子:一个系统功能,客户端Client需要和Server创建一个连接,在遵循以往的经验逻辑,我们第一时间很容易设计出:

1
2
3
4
5
6
interface ClientService {
  void init();
  void connect();
  void doIO();
  void send();
}

为了描述的简单,我忽略了参数。其中init方法负责初始化和服务器的连接;connect发起连接;doIO则处理连接之后的数据处理;send负责发送。然后定义对应的实现类来对ClientService实现ClientServiceImpl。如果有其他的方法,比如监听和状态管理等,则这个接口会更加复杂,对于每一个需要实现这个接口的类来说这无疑是一个巨大的负担。在需要进行拓展的时候,都需要修改这个接口或者对应的实现类。这种设计可能在很多系统中都存在,特别是没有经过多次迭代和代码设计的项目。

我们来看一下这样设计代码的一些不足之处:

  1. 抽象:如果只有一个实现类,其实接口抽象是多余的,但是在面向Spring的编程中,因为一般都选择基于接口和实现的自动注入模式,接口又时常是必须的。解决的办法,对于业务代码,会涉及到很多水平参差不起的程序员一起工作,使用固定的工程模式进行约束是十分有必要的。否则很容易每个人使用不同的设计方式,而很难找到业务实现的位置,这种模式虽然不灵活,也不利于架构之美,但是却能够按部就班地工作。找到一个实现接口阅读接口的内容,就可以快速知道大体的业务逻辑,找到对应的实现类,就可以开始修改,新手理解和接受的成本非常低。但是在进行非业务工程,特别是一些基础软件框架的时候,应该跟多地考虑灵活拓展,便于阅读进行考虑。如果只是死板地使用这样的模式,带来的是代码大量堆积在一个扁平的结构中,每一次修改和调整都在这个层进行调整,而缺乏层次感,难以共用和拓展。这样做法,从本质上更多的是一种最初级的面向对象/面向接口的实践,更多的一种流程化的开发方式。
  2. 复用性:对于连接这样的操作而言,可能会需要多个实现的情况,比如使用不同的协议进行连接,在具体的协议中,会使用不同的编程方式处理连接、IO内字节流的读写。但是对于序列化和反序列化等公用的功能,实际上公用的部分。解决的办法,可以增加一个抽象的实现类,在Jdk8中,interface也可以实现方法,但是在此之前使用抽闲类将公共部分进行抽取是进行良好设计的一个开始。
  3. 责任的分离:通过上面的讨论,我们在接口和实现的中间增加了新的一层抽象,也就是公共部分。同时也引入了另外一个重要的问题,那就是职责的分离。回到开头中的讨论,也就是具体到一个类/功能方法应该怎么设计。

最近在阅读Zookeeper代码的时候,刚好看到ZookeeperServer对于客户端连接的处理,尝试理解一下zk的在这一块的设计思想。为了简化分析的流程,主要描述方法的设计,而不进行详细的逻辑分析。

zk client连接server的入口是ZooKeeper类,首先在其内部定义了提供给客户端访问的访问方法

ZooKeeper

但是ZooKeeper类里面却没有包含真正处理网络连接的方法,比如Socket的创建等,而是交给ClientCnxn类进行处理,这样做的好处显而易见,分离了接口和实现。在大多数情况下,用户会使用ZooKeeper作为Client的角色,因此保持其稳定的API是非常重要的。而对于内部的实现逻辑,我们可能会经常需要进行调整。如果把ZooKeeper定义为对外部的API,那么设计的关注点就会聚焦在1. 我有多少功能 2. 用户场景会有那些 3. 用户要怎么使用这些API,是调用一个还是需要多个组合。这一层的代码,也就是封装内部的API对外提供服务,具体未讲用户请求的参数,封装成为内部的Request请求,并发送给ClientCnxn

ClientCnxn

相比较ZooKeeper主要作为对外接口的职责,ClientCnxn则是作为处理主要的是对内的接口的定义,核心的方法为submitRequestqueuePacket,ZooKeeper通过这两个接口来实现了对服务器的访问。但是我们依然没有看到具体的类似Socket创建等,而是看到ClientCnxn的内部维护了两个独立的线程,分别用于处理发送消息SendThread和SERVER返回事件的处理EventThread,这是一种事件驱动架构的实践。在ClientCnxn接受到Request请求的时候,并不是直接在此处维护连接,发送请求,处理返回,而是将这两个功能独立起来,形成两个互不干扰的工作空间,而自己ClientCnxn本身则是承担协调和维护的责任,具体一点,就是Request再次封装成为传输单元Packet,并发送到SendThread的待发送队列中,真正对请求收发/事件处理等关键操作,则交给两个Thread进行处理。实际上这也是一种责任的分离,站在ClientCnxn的角度,收到请求将请求放入队列,等待从队列中取值,职责是很单一的。对于SendThread,则是从队列中取出数据,发送,并处理返回值,通知服务器已经返回值,如果有回调或者监听等则放入EventThread的处理队列中,进行类似的处理。整个流程通过事件贯穿,彼此相互独立又可以协调工作。实际上在大的系统之间也有类似这样的架构,只是在分布式环境下,一般无法直接使用内部的队列,而是使用消息中间件来扮演队列的角色,消息的提示也只能使用轮询的方式,而无法像单机一样使用监听的方式。

SendThread

SendThread的内部,维护了队列,并不断轮询本地队列并进行处理。那处理底层的连接呢?既然SendThread的主要任务是维护和处理消息队列,那么通信层的责任自然要移交给其他的对象来处理更符合单一职责的设计原则。在zk内部,承担这个责任的是ClientCnxnSocket,它本身是一个抽象类,定义了主要的连接和发送借口,并抽象出一些通用的状态管理方法,比如获取更新时间等不会因为不同的实现类而不同的方法。

ClientCnxnSocket

最后我们可以根据需要使用的不同通信架构或者通信协议,实现具体的连接和发送接受这些核心方法,例如ClientCnxnSocketNIO使用了NIO实现了通信,也可以使用Netty框架实现,甚至是其他的框架或者协议。

总结,在建筑设计中,一般都会讲空间划分成不同的区域,每一个区域完成特定的工作,比如厨房,卧室,客厅,书房,以便在每一个空间能够针对性地进行布置,也利于每一个空间能够更好地提供服务。在软件设计中,分层原则有一条是将相同属性的功能放在一起,成为内聚,这个属性可能是变化频率或者功能。一个构造精巧的软件按照其不同的属性,分布在不同的层中,每一层负责自己单一的职责,通过合适的通信方式写作工作,构成一个稳定的结构,在需要更新功能的时候只需要调整或者修改其中的某一部分,而对外又始终能够保持稳定。

This post is licensed under CC BY 4.0 by the author.