软件设计与架构笔记(16): 架构风格——反应式架构

架构风格——反应式架构

在过去10年,多核、云计算、移动/IOT、用户体验等相关领域的发展使传统上以可维护性为核心的软件架构面临着新的挑战,这主要体现在软件系统的即时响应性(Responsiveness)、回弹性(Resilience)以及弹性(Elasticity)等架构质量属性[JDRM14]。

即时响应性旨在合理成本范围内提供低延迟的用户体验。显然这并非是全新的质量属性,传统上围绕它的解决方案包括算法优化、摩尔定律等。然而,随着系统复杂性不断上升,即时响应性不可避免地受到损害,业界发明了许多技术解决这一问题。从架构角度看,这些技术可以被划分为纵向(Scale up/down)和横向(Scale out/in)两种基本的扩展方案。其中,前者以多核技术为代表,后者则依赖分布式技术,两者在架构方面互为补充。由此衍生的细分领域包括但不限于并发编程、分布式通信、数据一致性、节点协调、错误处理、职责分离等,这些技术在实践中形成了一系列设计原则和模式。反应式架构(Reactive architecture)就是以这些原则和模式为基础、进而发展为一种面向现代高即时响应性的软件系统的架构风格。

多核与反应式编程

多核技术提供了纵向的单机扩展能力,但要利用这种底层能力离不开上层并发编程的支持。经典的并发编程框架如Java Concurrency[BG99],提供了最接近底层且功能强大的并发编程API,例如下列一个爬虫的代码片段(例子来源于互联网):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Crawler {
   private ConcurrentHashMap<String, Boolean> seen = new ConcurrentHashMap<String, Boolean>();
   private AtomicInteger pending = new AtomicInteger(0);
   
   public Crawler(String baseUrl, int numOfThreads) {
       this.client = HttpClientBuilder.create().build();
       this.baseUrl = baseUrl;
       this.executorService = Executors.newFixedThreadPool(numOfThreads, new ThreadFactory() {
          public Thread newThread(Runnable r) {
              return new Thread(r, "Crawler-Worker");
           }
       });
   }
   
   public void start() {
       handle(baseUrl);
   }
   
   private void handle(final String link) {        
      if (seen.containsKey(link))
          return;
      seen.put(link, true);
      pending.incrementAndGet();
      executorService.execute(new Runnable() {
          public void run() {
              List<String> links = getLinksFromUrl(link);
              for (String link : links) {
                  handle(link);
              }
              pending.decrementAndGet();
              if (pending.get() == 0) {
                  synchronized (lock) {
                      lock.notify();
                  }
              }
          }
      });
   }
}

客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main{
  public static void main(String[] args) throws InterruptedException {
          if (args.length != 2) {
              System.err.println("Invalid syntax: <baseUrl> <numOfThreads>");
              System.exit(1);
          }
          String baseUrl = args[0];
          int numOfThreads = Integer.parseInt(args[1]);
          Crawler crawler = new Crawler(baseUrl, numOfThreads);
          crawler.start();
          crawler.join();
          crawler.shutdown();
  }
}

该例中的爬虫实现基于Java Concurrency的ExecutorService API,一种共享状态并发式编程模型。为了保证线程安全,代码中采用了ConcurrentHashMap、AtomicInteger、Lock和Synchronized等Java特有的并发编程技术,存在复杂度较高、易理解性差、共享状态维护难度高、易出错等缺点。

反应式编程(Reactive programming)是一种具有异步编程风格的编程框架,其衍生自基于数据流的并发声明式编程模型,采用事件驱动和非阻塞线程技术,从而降低因为资源等待导致的并发性能瓶颈。首先来看一个基于RxJava[RXJ14]的爬虫代码片段(例子来源于互联网):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ObservableCrawler {
  private Subscriber<? super String> subscriber;
  
  public static Observable<String> create(Crawler crawler, String url, int numOfThreads) {
      ObservableCrawler o = new ObservableCrawler(crawler, numOfThreads);
      return Observable.create(subscriber -> {
          o.subscriber = subscriber;
          if (o.executorService == null) {
              o.process(url);
              subscriber.onCompleted();
          } else {
              o.processAsync(url);
          }
      });
  }
  private ObservableCrawler(Crawler crawler, int numOfThreads) {
      this.crawler = crawler;
      this.executorService = numOfThreads > 0 ?
              Executors.newFixedThreadPool(numOfThreads, r -> new Thread(r, "Crawler-" + threadIdGenerator.incrementAndGet()))
              : null;
  }
  
  private void processAsync(String url) {
         pendingTasks.incrementAndGet();
         executorService.submit(() -> {
             // If item is not unique, skip processing
             boolean isFirstTime =  results.add(url);
             if (isFirstTime) {
                 subscriber.onNext(url);
                 crawler.crawl(url, this::processAsync);
             }
             if (pendingTasks.decrementAndGet() == 0) {
                 subscriber.onCompleted();
                 executorService.shutdown();
             }
         });
  }
}

其客户端代码如下:

1
2
3
4
5
6
7
8
9
10
public class CrawlerClient {
  public CrawlerClient(Crawler crawler, String url, int numOfThreads) {
      this.observable = ObservableCrawler.create(crawler, url, numOfThreads);
      observable.subscribe(this::onNext, this::onError, this::onCompleted);
  }

  public void waitForCompletion() throws InterruptedException {
      completionLatch.await();
  }
}

与前面直接基于Java Concurrency的例子类似,该例同样采用了ExecutorService实现爬虫的并发执行,其中waitForCompletion也提供了阻塞客户端的能力。两者的区别在于,RxJava的版本采用观察者模式对爬虫类进行了封装,客户端不再需要等待代码执行结束后再执行后续指令。其中的一部分技术细节,例如线程安全代码被封装在API中,再通过回调函数接口提供给客户端,后者只需要关心onNext、onError和onCompleted的线程安全实现,这在一定程度上降低了Java并发编程的复杂度。

除了上例介绍中朴素的回调函数API之外,反应式编程通常还可能提供多种异步编程风格API,包括但不限于:

  • Futures/Promises,一种单赋值容器,支持针对单写/多读场景的、自上而下的异步编程风格,能够有效解决回调地狱(Callback hell)的问题。

  • 流(Streams),一种无界限的数据处理流,支持多个源点、汇点间的异步、非阻塞、背压(Backpressure)的数据变换管道。例如函数组合(Functional composition)提供的map、filter、fold等流式操作。这里的背压是指当异步执行管道中的消费端计算能力不足,上游仍然持续生产事件,从而导致系统过载的问题。反应式编程API一般通过配置不同策略以防止潜在的系统灾难。

  • 数据流变量(Dataflow variables),一种单赋值变量,能够基于输入、过程和其它变量实现自动更新。

前述例子中采用的RxJava遵循了ReactiveX标准——一套反应式编程的技术规范[REX],类似的反应式编程扩展(Rx)支持已被添加至许多编程语言中,如RxJS、Rx.NET、RxScala等。此外,RxJava还支持JVM平台的反应式流规范(Reactive Streams Specification)[RSS],从而具备与其它反应式编程API之间的互操作性。与传统编程模型相比,反应式编程在多核利用率、并发编程、系统模块性、工作流组装方面相较于传统编程模型具有优势。但其同样可能会损害代码的易理解性,并且通常只局限于单机计算,虽然有助于加强即时响应性,但并不能满足反应式架构要求的另外两个核心质量属性:

  • 回弹性,指系统在出错时能够自动恢复并保持正常的即时响应性目标。

  • 弹性,是指系统在异常工作负载下,能够自适应调整自身容量从而维持其正常的即时响应性目标。

这正是分布式系统成为云计算、移动/IOT领域核心技术的重要原因。

分布式与异步消息传递

分布式技术的目标是为系统提供横向扩展能力。通过负载监控和自动化伸缩技术,实现系统容量的自适应调节,从而满足弹性需求。另外,通过隔离组件控制系统错误/灾难的级联传递,再将错误提交至安全上下文中即时处理,从而满足回弹性需求。

反应式架构使用异步消息传递作为组件间的通信模式,通过在组件间建立临时边界,实现组件间的松耦合、隔离与地址透明化,达到时间、空间二重解耦的目的。值得注意的是,这里的空间解耦既可以指单机中的线程/进程,也可以指分布式系统的组件。“消息传递”意味着组件通常是长期存活且可被调用者直接定位、实现定向通信的方式,这与另一种流行的分布式系统通信模式——“事件传递”有着重要区别。因为“事件传递”是通过事件源的状态变化引发相应事件,再通过观察者模式通知“订阅”的组件,因此其关注点在于可定位事件源。而在“消息传递”中则相反,调用者需要明确知道被调用者的位置信息,即更加关注可定位接收者——这是反应式架构实现回弹性和弹性的重要基础。因为后者具有更强的控制力:负载管理、错误检测、消息丢弃/复制/排序、通过监控和调整消息队列实现流量控制以及背压等。

以应对回弹性为例,为了使系统在出错时仍然保持正常的即时响应性,需要实现调用者和被调用者的完全隔离,使后者发生的错误不被传递至前者,同时应支持消息被传递到多个复制组件中,即便错误发生时系统仍能正常提供服务。虽然容错性也是受到普遍重视的质量属性之一,但是传统上强耦合、深度嵌套的同步调用链代码缺少一致的容错方案。反应式架构明确要求把错误信息封装在消息中传递至其它组件,并使其在出错组件外部的一个安全上下文中得到有效处理(代理模式)。这里体现的基本思想是把错误处理从原有调用链中解耦,即移除客户端中针对服务端错误进行处理的职责。

再以弹性计算为例,系统被要求能够根据实际负载需求自动增加或减少所占用资源,从而动态调整吞吐量。这种自适应性意味着无介入实现系统伸缩、状态/行为冗余、负载均衡、失效备援和系统升级等能力。其基本思想是在编程抽象和语义层面实现组件空间位置的透明化,使系统更易于伸缩,且这种可伸缩性无需局限于CPU核甚至数据中心。

消息传递并发式模型及其应用

消息传递并发式模型是一种通过异步通信信道实现组件间通信的编程模型,目前被广泛应用于实现反应式架构中的“异步消息传递”模式,例如经典的Actor模型[CPR73]。

Actor是一种相比基于线程的并发编程更高级的抽象模型,其旨在解决如下问题:

  • 伸缩性,指包括单机和分布式环境下的系统扩容能力,即隐藏系统横向、纵向扩展的底层技术差异。

  • 透明性,指同时适应单机和分布式环境下的定位能力,例如在单机环境下采用并发编程语言,在分布式环境下采用网络通信,这些完全不同的资源定位方式导致系统难以从单机向分布式演化。

  • 不一致性,这里的不一致性是指在许多超大型系统中,面向人的信息系统交互存在不一致的问题,例如文档、标准等。

在Actor模型中,最基本的并发计算元素被称作actor。actor之间可以发送和接收消息,并且各自维护一个内部状态。当actor接收到消息时,可以并行执行下列响应方法:

  • 创建有限数量的新actor。

  • 发送有限数量的消息给其它actor。

  • 定义下一次接收消息时触发的行为。

Actor模型无论在计算理论还是在实际应用中都产生了重要影响,特别是可以被用于描述一些流行的并发编程框架,例如下面要介绍的Erlang/OTP Processes[EOP]和Akka Actors[AA]。

Erlang/OTP Processes

Erlang是一种声明式编程语言,除了基本的语法规则外,Erlang在其内核语言的基础上还提供了一套专有运行时系统和库——OTP(Open Telecom Platform),后者是Erlang实现分布式、软实时、高容错、高可用、热部署的基础[OTP]。其中,进程(Processes,注意这里不是操作系统进程)是Erlang并发编程的基本计算元素,类似actor。如下列代码所示(例子来源于互联网):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-module(counter).
-export([run/0, counter/1]).

run() ->
    S = spawn(counter, counter, [0]),
    send_msgs(S, 100000),
    S.

counter(Sum) ->
    receive
        value -> io:fwrite("Value is ~w~n", [Sum]);
        {inc, Amount} -> counter(Sum+Amount)
    end.

send_msgs(_, 0) -> true;
send_msgs(S, Count) ->
    S ! {inc, 1},
    send_msgs(S, Count-1).

% Usage:
%    1> c(counter).
%    2> S = counter:run().
%       ... Wait a bit until all children have run ...
%    3> S ! value.
%    Value is 100000

上例中实现了一个并发计数器counter,并向外部提供两个函数run和counter。其中run函数的作用是创建一个新的counter进程,然后向其发送倒计时时间消息。counter定义了消息接收行为,包括打印和增数。send_msgs通过!向S进程发送消息,然后通过递归实现倒数。与actor概念类似,Erlang的进程相互之间完全隔离,并通过消息传递相互通信。由于进程的创建和销毁十分轻量化,从而允许系统中容纳数量非常可观的进程(在普通PC中即可实现千万级进程数),这些进程可以在运行时系统中存在很长时间,如果没有消息接收或者运行了太长时间,进程就会被重新放入调度队列,避免影响其它正常运行进程。

Akka Actors

Akka是一个基于JVM的并发编程框架,其中的核心模块Akka Actors的Scala语法部分借鉴自Erlang,例如:

1
2
3
4
5
6
7
8
9
10
object HelloWorld {
  final case class Greet(whom: String, replyTo: ActorRef[Greeted])
  final case class Greeted(whom: String, from: ActorRef[Greet])

  def apply(): Behavior[Greet] = Behaviors.receive { (context, message) =>
    context.log.info("Hello {}!", message.whom)
    message.replyTo ! Greeted(message.whom, context.self)
    Behaviors.same
  }
}

与此相比Java版本就显得较为繁琐一些,但也很容易理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class HelloWorld extends AbstractBehavior<HelloWorld.Greet> {

  public static final class Greet {
    public final String whom;
    public final ActorRef<Greeted> replyTo;

    public Greet(String whom, ActorRef<Greeted> replyTo) {
      this.whom = whom;
      this.replyTo = replyTo;
    }
  }

  public static final class Greeted {
    public final String whom;
    public final ActorRef<Greet> from;

    public Greeted(String whom, ActorRef<Greet> from) {
      this.whom = whom;
      this.from = from;
    }
  }

  public static Behavior<Greet> create() {
    return Behaviors.setup(HelloWorld::new);
  }

  private HelloWorld(ActorContext<Greet> context) {
    super(context);
  }

  @Override
  public Receive<Greet> createReceive() {
    return newReceiveBuilder().onMessage(Greet.class, this::onGreet).build();
  }

  private Behavior<Greet> onGreet(Greet command) {
    getContext().getLog().info("Hello {}!", command.whom);
    command.replyTo.tell(new Greeted(command.whom, getContext().getSelf()));
    return this;
  }
}

从编程模型的角度看,Akka Actors与Erlang Processes本质上是一致的,然而其底层系统存在巨大差别——更多是JVM与OTP的差别。两者如今也都形成了各自庞大的生态系统,成为设计反应式架构的重要参考,并且在通信、数字金融、在线游戏、在线交易、统计、社交媒体、移动应用等领域得到了广泛应用。

结论

本文介绍了一种流行的架构风格——反应式架构,详细讨论了即时响应性、回弹性和弹性等质量属性以及异步消息传递模式。在具体实践层面,首先讨论了事件驱动模式的反应式编程及其在多核环境中的应用,进一步介绍了消息传递模式的Actor模型及相关的流行编程框架——Erlang Processes和Akka Actors。由此可见,参考架构风格的关键在于理解其要解决的核心问题,即要满足的特定的功能或非功能需求是否符合期望。一旦确定架构风格,其特定的设计原则就应尽量被作为软件开发的通用设计原则。而针对架构风格中包含的多种模式、框架和系统,就需要依据具体上下文灵活做出选择。

引用

JDRM14, https://www.reactivemanifesto.org/

BG99, Java Concurrency in Practice

RXJ14, https://github.com/ReactiveX/RxJava

REX, http://reactivex.io/

RSS, http://www.reactive-streams.org/

CPR73, A universal modular ACTOR formalism for artificial intelligence

EOP, https://erlang.org/doc/reference_manual/processes.html

AA, https://doc.akka.io/docs/akka/current/typed/index.html

OTP, https://erlang.org/doc/

Comments

软件设计与架构笔记(15)

软件架构说什么?

架构(Architecture)一词源自建筑领域,尽管看起来与软件设计毫无关联,但人们从上世纪60年代起就注意到两者的相似性[PHJ06],并从结构和工程等方面大量借鉴了这个古老的学科,软件设计也因此获益匪浅[GHJV95]。当然不仅是软件,这个词也被其它领域广泛借鉴,例如作为计算机基础的体系结构(Computer architecture),后者主要指计算机的物理结构或者CPU指令集。又比如企业架构(Enterprise architecture)、解决方案架构(Solution architecture)或者信息架构(Information architecture)等,则是表示针对各自问题域的专业性实践集合。

虽然架构与设计同属于本系列的主题之一,但迄今为止我们都很少提及。一方面是因为,作为诞生于上世纪90年代的buzz word,软件架构是在软件设计的基础上发展而来的,前者继承了后者的许多核心思想,例如模块化、原则、模式等,逐渐形成了更加庞大的体系。另一方面,架构一词如今具有极其丰富的含义,以至于可能达到阻碍交流的地步,因此确有必要首先对部分概念予以澄清。此外,除非特别说明,本系列文章中的架构均指软件架构。

定义和解释

架构是指一个系统在其所在环境中的基本概念和属性,这体现为系统的元素、关系及其设计和演进的原则。

虽然这是ISO/IEC 42010对架构的正式定义,另一种USP(Unique selling proposition)定义则更详细地解释了这一点:

架构是一系列重要的决策,涉及描述软件系统的组织、确定结构化元素及其接口、确定元素在协作中的特定行为、指导结构和行为元素通过组合逐渐形成较大子系统的风格(涉及元素、接口、协作和组织等)。此外还要考虑用途、功能、性能、适应力、可重用性、可理解性、经济性、技术限制及其权衡、美学等因素。

以上定义明确指出了架构的表示(Representation)、质量属性(Quality attribute)以及风格(Style)等核心内容。此外,针对已有的架构方案,有时需要进行专门的架构评估(Architecture evaluation),从而提前发现问题并控制潜在风险。本文剩余部分将作进一步讨论。

架构表示

由于架构本身的丰富性,采用适当方法描述架构就变得非常重要,一种基本的架构描述工具是架构视图(Architecture view),其被用于表示架构在解决特定问题时所体现的结构化信息。由于完整的架构一般会涉及众多干系人,在单一视图中无法清楚表示所有信息,因此为了进一步在视图中区分来自不同干系人的诉求,采用架构视点(Architecture viewpoint)聚焦于某一类架构决策,并采用特定的标记和建模技术建立对应的架构视图。常见的架构视点有功能、逻辑、数据、模块、组件-连接器、需求、实现、并发、性能、安全、部署、用户使用及反馈等,由此可见其对应的架构视图也就非常丰富。

以组件-连接器类型的架构视图为例,该架构视图定义了系统中的可计算组件及其交互方式,其中组件是指可独立运行、且支持交互或存储数据的软件单元,连接器则被用于描述组件之间的交互机制。在构建组件-连接器视图的过程中,组件可以根据承担功能、可重用性、硬件单元,甚至团队的技术背景、康威定律以及产品演化路径等方式定义。同时,组件还需要描述其对外提供交互的接口(API),包括访问端口、参数以及参数类型。然后根据组件间交互的需求,如同步、异步、延迟、吞吐量等确定连接器的类型和通信协议。连接器两端的组件分别被称为调用者和被调用者,组件与连接器之间通常需要相关配置以确定关联信息。

架构描述语言

通常,架构视图是采用架构描述语言(Architecture description languages,ADL)具体实现的,如AADL、Wright、ACME、xADL等专门面向软件架构的语言。同时也可以采用通用的建模语言,例如UML,实际上后者在工业界更加流行。架构视图和ADL共同组成了架构表示的基本方法,但仍不足以有效应对架构的复杂性。这是因为在真实场景中架构视点可能是非常多的,架构需要从核心视点出发逐步完善,因此需要进一步参考适当的架构框架(Architecture framework)。

架构框架

架构框架是指在特定应用领域或干系人社区中,创建、解释、分析和使用架构表示的通用实践集合。一种经典的架构框架是“4+1架构视图模型”[PK95],其基本思想是需要采用若干个相互平行、且具有不同架构视点的架构视图,具体来说就是逻辑视图进程视图开发视图物理视图等四种主要架构视图,以及相应的用例和场景说明,从而达到表示完整架构的目的。

  • 逻辑视图,即把系统按照功能、通信、行为等进行结构化分解的结果,描述系统的静态信息。具体可以采用UML中的类图或状态图实现。

  • 进程视图,即对系统中进程和线程的通信、执行过程进行描述,即系统的动态运行信息。具体可以采用UML中的部署图和活动图实现。

  • 开发视图,也称作实现视图,用于描述软件开发过程中的软件结构,例如组件、包、类、子系统、代码库、文件等。具体可采用UML的组件图或包图实现。

  • 物理视图,描述系统运行的硬件资源结构,及其与系统进程之间的映射关系。具体可采用UML中的部署图、时序图或协作图实现。

  • 用例和场景,也称作用例视图,即从少量核心用例出发,描述系统中对象间、进程间的交互顺序。该视图主要用于构建可验证的架构原型,从而对当前架构进行测试。

根据上述5种架构视图,4+1架构视图模型能够建立一个核心的软件架构表示。然而从架构对整个软件工程的影响角度来说,架构框架作为架构表示的核心,往往还需要更多架构视点的支持,这与具体上下文密切相关,特别是接下来要讨论的质量属性。

质量属性

除了满足功能需求,架构还需要考虑系统的非功能需求(二者相互正交),后者也被称作系统的质量属性,例如性能、可靠性、资源利用率、可用性、精确性等。与功能需求最显著的不同在于,质量属性往往是相对概念,一般表现为某种程度,且具备多种领域背景。正因为如此,质量属性大大提升了架构的复杂性,也是除了功能需求变化外另一个可能引起架构变化的重要原因。

ISO/IEC 25010对软件质量进行了明确定义,其中功能性表示系统功能的完整性、正确性、适当性与合规性,此外还包含7种非功能属性以及对应的子属性:

  • 可靠性,指系统在特定时间和条件下维持当前性能的能力,包括成熟度、容错性、可恢复下、可用性等指标。

  • 易用性,指个体或群体在使用系统时的难易程度。包括易理解性、易学习性、易操作性、界面美观性、操作错误保护以及可访问性。

  • 高效性,指系统在特定条件下,资源使用量与软件性能之间的关系。包括耗时、资源利用率、容量等指标。

  • 兼容性,指系统在特定的软、硬件环境中能够正常运行的能力。包括共存性、互操作性等指标。

  • 安全性,指系统保护数据和执行正当行为的能力。包括保密性、完整性、非拒绝性、可审计性以及可验证性。

  • 可维护性,指系统在需要做出特定修改时所花费的成本大小。包括可分析性、可改变性、稳定性、可测试性、模块性、可重用性以及可修改性。

  • 可移植性,指系统迁移到其它环境的能力。包括可适应性、可安装性、可替换性等。

值得一提的是,经验研究表明并非所有的非功能需求都有同等机会引发架构变化[JAD16],尽管它们可能拥有相同的重要性。但在进行架构相关决策时,依然不可避免地要考虑功能以及多种质量属性,这就导致从零开始设计架构具有极高的成本和风险。因此绝大多数架构设计活动实际上是遵循着经受实践检验的经验,即下面要讨论的架构风格

架构风格

架构风格是指一系列满足功能和特定质量属性的设计决策与约束子集[RNE09],其意义在于:

  • 提供可重用的领域和工程知识,特别是相同领域或产品族中与应用无关的设计规则和决策,避免重新发明轮子。

  • 阻止架构腐化和偏离,帮助未来开发人员在不损害基本架构原则的基础上扩展系统。

  • 根据质量需求指导设计。

以经典的管道-过滤器架构为例,在该架构风格中,所有的过滤器都通过两个字节流“输入”和“输出”进行通信,这样就保证了任何过滤器都能够互相连接——即满足兼容性。另外,过滤器之间可以一次只传递部分数据,这样就能够尽可能提高过滤器之间并行计算的能力,从而提高系统效率。除此之外,应用中常见的架构风格还包括但不限于:

  • 客户端-服务器(C/S)架构。

  • 分层(三层或N层)架构。

  • 点对点(Peer-to-peer)架构。

  • 事件驱动(Event-driven)架构,也称隐式调用架构。

  • 表述性状态转移(REST)架构。

  • 面向服务架构(SOA)。

  • 领域驱动设计(DDD)。

限于篇幅本文无法详细讨论每种架构风格。而事实上,在实际软件开发过程中架构风格往往是在最初就确定的,因此可被视为架构设计的设计规则。另外,大部分情况下整个系统会拥有多种架构风格,从而满足各种质量属性需求。

架构评估

由于架构的重要性,团队通常需要对已有的架构方案进行评估。架构权衡分析(Architecture tradeoff analysis method)是一种架构评估方法,采用该方法首先需要建立一个专门的架构评审小组,该小组应至少包含所有的干系人。启动评估后,首先应确保所有参与者熟悉评估流程以及业务背景。向所有评估者展示更高层次的系统架构,包括所采用的架构风格。然后通过质量属性树描述系统所要特别关注的质量属性,并且为每个所要满足的属性提供一个具体场景。一个质量属性树的例子如下图所示:

Quality attribute tree

把所有场景按照优先级进行排序,然后逐一分析当前架构对该场景的适用性,根据相关反馈进行调整。最后在更大范围的干系人组织中分享当前架构知识。

结论

软件架构包含三个核心问题,分别是架构表示、质量属性和架构风格。架构表示是架构得以沟通并完善的重要途径,在架构设计的过程中不仅要考虑功能需求,还要考虑非功能需求(质量属性),不同的架构风格在应对特定质量属性方面具有优势,因此真实场景中需要组合架构风格以满足来自不同干系人的需求。

引用

PHJ06, The Past, Present, and Future of Software Architecture

GHJV95, Design Patterns: Elements of Reusable Object-Oriented Software

PK95, Architectural Blueprints—The “4+1” View Model of Software Architecture

RNE09, Software Architecture: Foundations, Theory, and Practice

JAD16, Are “Non-functional” Requirements really Non-functional?

Comments

软件设计与架构笔记(14)

敏捷软件设计

早期的软件开发方法源自传统制造业和建筑业,即按照需求、分析、设计、开发、测试、运营等阶段顺序执行。这种线性的软件开发过程被称作瀑布模型(Waterfall)。瀑布模型在上世纪70年代逐渐发展成熟,成为软件开发方法的事实标准。随着互联网的出现,软件工业迈入飞速发展,频繁变更的需求和快速更替的技术使瀑布模型遭遇了空前挑战。于是,通过从先进制造业汲取经验,行业一线的职业程序员们开始调整原有方法,90年代先后诞生了统一过程(Unified process)、Scrum极限编程(Extreme programming)等轻量级软件开发方法。2001年,程序员们从这些方法的核心思想中提炼出了著名的敏捷宣言,由此敏捷成为前述一系列软件开发方法的代名词。时至今日,对于需求明确并且依赖成熟技术的软件开发活动来说,严谨且可靠的瀑布模型仍然占有一席之地。敏捷思想则在自互联网时代开启的一系列新兴领域中更受欢迎,也更具备发展空间。

敏捷对软件设计产生了重大影响,正如Martin Fowler所指出的,极限编程不仅宣告了Big Design Up Front的终结,还严重影响了一批热门的技术实践例如UML、框架构建、设计模式[MFL00]。然而软件设计并未因敏捷而消失,敏捷也不意味着无设计或设计灾难,为了与传统瀑布模型的计划设计(Planned design)进行区别,敏捷设计被描述为演进式设计(Evolutionary design)、持续设计(Continuous design)或浮现式设计(Emergent design),或许这些名词有时夹带了浓厚的宣传意味,但是不可否认分析与设计、原则与模式依然是敏捷软件设计的核心,后者的主要特点在于更加强调轻量化的敏捷设计实践。这往往意味着:

  • 强调价值交付,交付价值是推动整个软件工业发展的重要经济基础,因此价值应当始终是软件开发的优先选项。

  • 强调团队责任,而不是把职责局限于分析师、设计师、XX师等不同工种,从而减少单点失败(Single point failure)。

  • 强调快速反馈,无论是测试驱动开发还是持续集成,通过尽可能的自动化实现软件设计质量的实时监控,且应保证快速响应。

具备代表性的实践有面向设计一致性的代码味道重构、面向功能一致性的测试驱动开发(Test driven development)以及面向团队一致性的结对编程(Pair programming)、代码评审(Code review)和持续集成(Continuous integration)等。敏捷正是通过前述一系列实践,从而避免从BDUF走向另一个设计熵增的极端。本文剩余部分将进一步讨论价值交付、团队责任和快速反馈在敏捷软件设计活动中的具体体现。

价值交付:扩展—收缩模式(Expand-Contract Pattern)

应对变化是敏捷软件设计的永恒主题。当现有设计发生变化时,这种变化可能通过接口向模块外传递,从而影响更多其它模块。特别对于公共接口来说,变更现有设计会产生较高成本,进而影响交付的价值。[MFL14]讨论了一种扩展—收缩模式,其核心思想是在变更设计的同时保持向后兼容,当新设计产生的价值得到验证后再移除旧设计。例如下列代码:

1
2
3
4
5
6
class WindowFactory {

    public Window createWindow(String title, int x, int y, int width, int height) {
        ...
    }
}

该例中的抽象工厂类WindowFactory能够创建不同类型的Window,参数列表接受窗口名、位置和尺寸等信息,其中位置和尺寸能够使用Rect对象代替,从而有:

1
2
3
4
5
6
class WindowFactory {

    public Window createWindow(String title, Rect rect) {
        ...
    }
}

如果该接口属于公共接口,那么所有客户端组件都必须被动修改,否则将无法正常工作。更加合理的做法是首先保留原接口(扩展):

1
2
3
4
5
6
7
8
9
10
class WindowFactory {

    public Window createWindow(String title, int x, int y, int width, int height) {
        ...
    }

    public Window createWindow(String title, Rect rect) {
        ...
    }
}

当客户端组件迁移完成后,再移除失效的接口(收缩)。通过采用扩展—收缩模式,能够有效控制设计变更对交付价值的影响,这也是敏捷软件设计的核心目标之一。

同样的模式还被应用于演进式数据库设计[MFL16],特别是当数据模式发生破坏性修改时(修改表名、列名等操作),需要保证在迁移阶段同时支持新旧两种数据访问模式。例如当修改表名时,可以通过创建与旧表名相同的视图提供向后支持。当设计需要修改列名时,可以先创建新的列,然后通过触发器实现新旧两列的同步,直至迁移阶段结束再清理旧模式。在真实场景中应根据数据库类型、应用类型等相关上下文决定具体实现,但设计思想仍然遵循扩展—收缩模式。

团队责任:模型风暴(Model Storming)

模型风暴是一种即时建模活动,其目的是把设计责任赋予团队而非个人。理论上说模型风暴可以发生在敏捷软件开发过程的任何时间,但通常是由一名用户故事的所有者(Story owner)在进入开发阶段前发起。首先由所有者确保理解所要解决的问题,然后集合若干团队成员(通常是2~3人)进行站立会议(Stand session)。在所有者介绍完背景并确保所有人理解上下文后,团队开始在一个共享建模工具上探索设计方案,直至大家充分理解并达成一致,会议结束(通常是5~10分钟)。

模型风暴有两种应用场景——分析和设计。分析模型风暴主要是帮助团队理解需求,这时应尽可能集合相关干系人(产品负责人、业务分析师、设计师、质量分析师和开发等),然后通过绘制草图帮助所有人理解原始需求,并澄清相关问题。这一阶段的关键在于鼓励各种干系人参与建模过程,于是应尽量采用包容性建模(Inclusive modeling)及相关工具,避免过度专业和复杂的工具应用,从而促进沟通。常见的包容性工具有白板、索引卡、便利贴、白板纸等。

设计模型风暴是在编写代码前由若干开发人员共同完成的设计活动。根据开发人员的技术背景,建模过程可以采用UML类职责协作卡(CRC)、数据流程图或一般流程图等包容性工具。具体过程可以参考前文介绍的CRC及其在协作式OOD中的应用。

模型风暴能够促进设计知识在团队中进行传递,从而有效控制软件设计的单点失败风险。

快速反馈:设计监测(Design Monitoring)

快速反馈主要是指能够快速验证当前设计的完整性,并在发现设计缺陷时提供警报,这往往需要依赖专业面向软件设计的静态代码分析工具来完成。通常的做法是把相关工具集成进现有的持续集成过程,并作为某种质量检测报告输出,从而实现设计监测。设计监测工具主要通过分析软件结构中的依赖热点(Hotspot)进行,一般有两种途径分析这些热点——度量模式

度量是通过对软件结构中的实体及其依赖关系进行量化分析,从而反映软件模块化的程度。一种可量化的设计原则是包依赖原则,在此基础上的经典Java开源实现即JDepend(现基本停止维护),该工具以Java语言的包为单位,分别计算每个包的类数量(TC)、具体类数量(CC)、抽象类数量(AC)、传入耦合(Ca)、传出耦合(Ce)、抽象系数(A)、不稳定性(I)、偏离距离(D),每种度量的具体定义本文不再赘述。

模式主要是指检测依赖中的反模式,后者主要是违反设计原则的实际情况,例如违反包间无环依赖,接口隔离、Liskov替换、依赖倒置等。具体做法是把软件设计中的依赖关系用图表示,然后检测图中存在的违反设计原则的特定模式(Motif),经典开源实现即Google的GUERY(停止维护)。该工具能够根据图中的顶点、关系及其路径长度等条件识别特定模式,并且根据Tarjan算法计算强联通子图进而生成凝聚图。

免费工具除前述外,还有针对Java程序的依赖抽取和可视化工具Class Dependency Analyzer(CDA),CDA能够把相关依赖以UML的形式进行可视化,帮助用户理解并管理复杂软件结构。专业用于依赖分析的商业工具有LattixSonarGraphStructure 101JArchitect/NDepend/CppDepend等,本文不再赘述。

值得一提的是,面向软件设计领域无论是免费还是商业工具,尽管其内置的设计规则具有普遍性,同时也支持自定义规则,但通常都存在较高的学习、维护和实施成本,实际上并不能真正达到快速反馈的目的。对处于一线的中小型敏捷团队来说过重,更适用于一些已经具备较高价值的商业软件开发和大型软件组织的架构看护活动。

ArchUnit是一个基于Java语言的开源依赖检查框架,用户通过编写测试断言的形式约束软件结构依赖,并且通过现有单元测试框架如Junit实现自动运行。与前面提到的主流第三方工具相比,该工具也定义了一些具有普遍意义的依赖规则,同时还具有如下优势:

  • 直接采用原生语言并作为宿主的测试实现,支持包、类、注解、分层、分片等多种概念实体,使定义复杂的Java代码依赖规则更加容易。

  • 采用单元测试的思路,使依赖规则能够更快响应软件结构变化,降低规则维护的成本,真正实现快速反馈。

  • 允许开发人员结合价值交付、团队责任等灵活定制依赖规则,特别适用于敏捷软件设计的场景。

该工具的缺点是无法向多数GUI工具那样支持依赖分析,内置规则也不如成熟商业工具丰富,因此要求开发人员深入理解设计原则,并能够结合上下文定制恰当的规则。

结论

在一般的敏捷宣传语言中,诸如大道至简(You Aren’t Gonna Need It,YAGNI)和恰如其分(Just enough)等词汇往往被使用且被轻易误解。原因在于脱离了具体的实践,敏捷就只剩下一个以人为本的空壳,并不能反映出源自核心的根本经济动力。因此无论是从软件匠艺(Craftsmanship)还是专业主义(Professionalism)来看,敏捷对开发人员的要求都要更高。反映在软件设计领域,具体就是除了基本的分析和设计方法、原则和模式等知识外,进一步注重软件设计中的价值交付、团队责任以及快速反馈等实践。

引用

MFL00, Is Design Dead?

MFL14, Parallel Change

MFL16, Evolutionary Database Design

Comments

软件设计与架构笔记(13)

设计诊断

设计诊断(Design diagnosis)是指获取、分析、检测软件设计及其质量的活动。经济利益是驱动软件设计诊断的重要原因之一,特别是对于具有重要价值的软件设施来说,尽早发现并解决设计中存在的问题是十分必要的。然而一直以来软件设计都被认为是难以度量和评价。一方面是因为设计中包含的决策通常是涉及对领域、技术或其它上下文因素的权衡,这是任何客观标准都难以做到完全覆盖的,因此始终无法100%消除对主观参与的依赖,导致设计诊断的权威性受到限制。另一方面,已有的设计验证方法普遍落后于软件开发技术的创造和更替。尽管设计原则与代码味道具有一定的普适性,但是大部分原则本身缺少明确的定量或定性规则(即使存在这类规则一般也很难适用于新的开发技术),少量有明确的规则通常被归为静态代码分析和风格检查,尚不足以达到设计诊断的目的。

因此为了实现设计诊断,一方面需要对软件设计进行统一的形式化表示,避免对具体的软件开发技术产生依赖。在前者的基础上进一步分析当前软件设计,帮助分析人员理解现有设计、发现设计中的潜在缺陷、甚至模拟历史和未来的设计演化,从而为工程进度和技术债管理提供依据。本文的剩余部分将详细讨论这些主题。

设计分析(Design analysis)

设计分析的目标是研究设计本身,后者通常表示解决问题的方案,也可以指构建解决方案的过程。[BC00]认为,理想的解决方案可被视作具有一系列特性的集合,其中的每种特性都可以被归纳为某个维度,即设计参数(Design parameters)。不同的设计参数之间存在一定的依赖关系,即设计结构(Design structure)。设计参数的所有可能值的集合被称作设计空间(Design space)。在整个设计过程中,每个设计参数由对应的设计任务(Design tasks)决定,后者相互之间的依赖关系被称为任务结构(Task structure)。例如要设计一个马克杯,“是否包含杯盖”是一个设计参数,而“杯盖直径”则是依赖于前者的另一个设计参数,而对于“容器直径”来说,“杯盖直径”与其存在相互依赖的关系。相应地,分别负责杯盖和容器的设计任务之间也就存在依赖,因此设计结构和任务结构具有一致性。前述这些结构可以用一种邻接方阵进行表示,即接下来要讨论的设计/任务结构矩阵(Design/Task structure matrix,DSM/TSM)。

设计结构矩阵

在DSM(TSM)中,每个结点表示设计参数(或设计任务),两个设计参数之间的依赖用符号x进行表示,一个马克杯的设计结构例子如下图所示:

Full DSM

虽然该DSM只有10x10,但每个结点间依赖都可能包含了丰富的物理和工程属性,真实场景中也许会非常复杂。根据设计参数之间的依赖关系类型,在DSM中可以进一步发掘出一些微结构,例如:

Micro DSM

上图描述了两种微观的设计结构,(a)表示层次结构,(b)表示无层次的相互依赖结构,这两种设计结构实质上体现了不同的关系强度,显然(b)体现了更强的相互关系。

另外,真实案例中的矩阵规模通常要比马克杯大得多,例如一个设计笔记本电脑的TSM如下图所示:

A laptop computer TSM

上图中的TSM相比于马克杯的例子有几个新的元素。首先矩阵中的设计任务呈现出特定排序(或呈现为下三角矩阵),即相互之间存在强关联的设计任务被放置的更近,越接近对角线的依赖密度就越高。其次整个矩阵上产生了若干相互关联的区块,这些区块直接反映了系统中的独立组件,例如驱动系统、主板等,被称作原型模块(Protomodules)。原型模块通常是由领域知识或者组织结构等上下文决定,但是并非真正意义上的模块,因为其本身不具有接下来要讨论的模块性(Modularity)。

模块性与设计规则

一般而言,高复杂度的问题会导致同样高复杂度的设计,而一个“好”的设计能够有效地管理其自身复杂性。[BC00]认为,模块化(Modularization)是系统管理自身复杂性的核心,也是二十世纪以来计算机乃至更多其它领域得以飞速发展的重要原因。系统的模块化程度体现为模块性(Modularity),其中包含两个重要概念:

模块,即内部元素间的关系比与外部元素间更强的系统单元,这些关系的相对强弱决定了模块的粒度。

抽象信息隐藏接口,即当一个系统达到一定复杂度时,需要将其拆分成不同部分,抽象的目的在于隐藏其内部复杂性,且通过接口与系统的其它部分进行交互。

在设计笔记本电脑的TSM中,我们知道主板和显示屏之间的设计任务多存在循环依赖,一个例子如下图所示:

Cycling in a laptop computer TSM

该例中的多个设计任务因为相应设计参数而存在互相依赖,例如主板要决定CPU的规格和所采用的中断协议,而显示屏需要确定详细规格。当主板中具有独立的图形控制器时,显示屏的规格就会发生改变。否则CPU就要根据显示屏的规格提供更多的计算能力,并且采用不同的中断协议。由此可见,“主板是否包含独立的图形控制器”就成为其它设计参数的关键依赖参数。从系统的角度看,其整体复杂性通常是由一系列关键依赖参数决定的,一旦其中某个设计参数确定,则许多依赖的设计参数也就相应确定。这些关键依赖参数被称作设计规则(Design rule)。一个完整的系统设计规则集合应至少包含如下信息:

  • 模块及其在系统中扮演的角色。

  • 模块间通信的接口。

  • 系统集成协议以及测试某个模块是否遵循设计规则。

通过抽取设计规则可以消除原型模块间的相互依赖,从而形成真正意义的模块。其中,设计规则被称为显性模块(Explicit modules),而其他相互独立的部分被称为隐性模块(Implicit modules)。一个模块化后拥有完整设计规则集合的DSM/TSM所下图所示:

Modularization

DSM/TSM对于计划设计过程同样具有意义,在上例中,首先进行的是设计规则阶段,然后进入可并行进行的隐性模块设计阶段,最后是系统集成和测试阶段。其中,设计规则作为所有阶段的输入,隐性模块则作为集成和测试阶段的输入。

模块演化及其模拟

模块性反映了系统的结构状况。如果一个系统具有嵌套层级结构,每个结构单元对内强关联,对外则相互独立,并且具有良好的功能角色定义——那么该系统就被称作模块化系统。值得注意的是,系统结构并非一成不变,一方面是因为某些设计参数间的依赖并不容易在初期就显现出来,另一方面,由于复杂的结构往往导致更高的经济成本,因此在真实场景中更加倾向于寻求结构和经济之间的平衡。为了描述模块的动态特征,可以采用模块操作符(Modular operators),[BC00]提出了六种最基本的模块操作符,后者能够用于表示动态结构的所有可能演化路径:

  • 分解(Splitting),把现有设计或任务划分成多个模块,在层次结构中这往往意味着产生了新层,例如以下模块化层级设计:

Two-level modular design hierarchy

上例中描述了A~D四个隐性模块以及一个集成和测试阶段,从模块化的角度来看它们都属于相同层级。当更多设计参数及其依赖显现,并且上下文满足模块化设计需求时,新的设计规则以及相应的接口、测试就会出现,于是就诞生了新的层级,如下图所示:

Three-level modular design hierarchy

在进行分解操作后,新的层级应当只对其所依赖的设计规则负责,而对全局设计规则以及上层的集成和测试部分保持透明,这对设计任务和阶段执行具有重要意义。

  • 替换(Substituting),指替换现有模块设计。替换通常是因为多种设计路径之间存在竞争关系,于是更多受到经济系统因素的驱动。模块的可替换性通常是由分解所决定的,因此分解在此扮演了非常重要的角色。

  • 增强(Augmenting)和排除(Excluding),即添加或删除模块,与分解与替换不同的是,增强和排除是针对已经模块化的系统来说的。排除体现了模块化设计的可配置性,也就是说用户可以按需选择模块,这与替换的特性是相当的。增强通常是由于系统中需要引入新特性,为了保证可增强性,需要在设计规则阶段就要考虑这种能力。

  • 反转(Inverting),指创建新的设计规则。我们知道设计规则来自于设计参数,后者广泛存在于隐性模块中。因此有时需要把隐性模块从当前的设计层级中“拉取”上来,使其对更多模块保持可见。

  • 移植(Porting),即把当前模块移植到新系统。某些隐性模块支持从当前系统移植到新系统,那么该模块至少应满足以下条件之一:

    • 所依赖的设计规则在新系统中存在且不变。

    • 模块本身不受设计规则的影响。

采用上述模块操作符可以模拟任何过去、现在和未来所发生的设计变化。例如可以抽取设计演化历史中的连续片段,然后用模块操作符描述每一步的变化。对于进行中乃至未来的设计来说,模块演化则是非确定的,采用公式(j6 X 2) - 1即可计算模块演化的所有可能路径,例如当系统中包含6个模块时,就有93311种演化可能。

应用DSM分析软件设计

[NEVD05]首次把DSM用于管理复杂软件系统的依赖模型(Dependency model),具体方法是通过静态分析提取代码的依赖关系,然后在DSM中进行层次结构展示,支持人工选取设计规则,并且检测出违反相关规则的依赖关系。

通过静态分析提取到的大多属于语法依赖,即字面引用所体现的依赖关系。不同编程语言的语法依赖类型存在一定区别,并且语言自身的模块化特性也不尽相同,因此存在多种表示软件依赖模型的方式。一种简单的做法是忽略依赖类型间的差异,选择统一的模块化元素作为DSM的设计参数,例如Java中的类,并且按照元素间存在的引用数量定义依赖强度。下例展示了jEdit v4.2的DSM:

DSM for jEdit v4.2

当DSM规模较大时,需要支持进一步显示矩阵中的层次结构。尽管许多现代编程语言都在语法上提供了层次化结构的特性(例如包、类、方法等),这些信息可被直接用于DSM分层。但是,多数情况下软件的层次结构无法满足[BC00]的模块性标准,这种在实际中十分普遍的情况被称作软件结构的技术债。为了方便理解和改进现有系统的模块性,业界开发了许多针对DSM的聚类算法,即从DSM中的元素及其依赖出发,通过重新排列元素顺序实现自动聚类,其中有代表性的方法有:

  • [JNW73]采用矩阵分区算法把初始矩阵划分成若干子矩阵,使后者满足下三角矩阵的特征,从而消除循环依赖。

    该算法的基本思路是针对每个元素,首先构建可达性(Reachability)集合R(s)与先导(Antecedent)集合A(s),前者指从该元素出发能到达的所有元素集合,后者指从非当前元素出发能到达或经过该元素的路径的所有元素集合,以及两者交集R(s)A(s)。算法每次迭代选择满足R(s)A(s) = R(s)的元素集合作为当前矩阵的top-levels,然后将其从剩余元素的集合中删除并重复这一过程,直到剩余元素个数为0。矩阵分区算法的优点是实现简单,能够快速筛选出不存在循环依赖的子矩阵,对DSM分层具有一定意义。但是该方法无法满足更多的模块化特性,例如[BC00]中指出的隐性模块间的相互独立性。

  • 聚类分析中常用的启发式算法同样被用于构建DSM的元素聚类。如果某个系统内存在一系列规模合理且相互独立的子模块,那么这些子模块内的依赖关系一定趋近于DSM对角线,以此推论为基础设计距离惩罚函数作为启发式算法的目标函数[TS94]。与分区算法相比,启发式算法能够实现模块间独立条件下的更优结果,而且实现也比较简单,例如聚类部分采用现有的遗传算法框架[RAC08]。但是,设计软件模块性的目标函数是一项挑战。另外,软件的模块性往往还体现在层次结构方面,这是一般的聚类方法难以同时考虑的。

  • 由于DSM本质上是有向图,因此可以采用图算法进行DSM分层。[SYG09]是一种基于图算法的DSM层次聚类方法,首先计算DSM的凝聚图(Condensation graph),然后找出所有出度为0的结点的所有依赖关系路径,再从拥有最长路径的结点出发构建DSM的层次结构。该方法构建出的层次结构一定满足下三角矩阵,同一层的模块间保持相互独立且允许并行开发。其优点在于使用DSM实际反映出软件的层次结构,从而能够进一步诊断软件的设计问题[RYR15]。

    如果要根据DSM中依赖关系的强弱寻找更优化的层次结构,则可以采用图聚类方法[SS07],特别是针对有向图聚类[FM13]。[SA14]采用谱聚类方法对DSM进行重新聚类,该方法建立在DSM中具有较大特征值的特征向量、特征值、模块层次数以及每层模块数等数量之间的相关性基础上,通过对原始DSM进行奇异值分解、分析和降维,计算每个结点在k维空间的线性表示,最后以结点在k维空间中的距离进行聚类。尽管该方法需要指定k值,但是聚类结果依然能正确反映DSM的层次结构。例如:

Spectral clustering

其中(a)是原始DSM,(b)©(d)分别表示k=2,k=4,k=8时的谱聚类结果,可以看到随着k值的变化,聚类结果始终能表现出实际DSM的层次结构。

设计度量(Design metrics)

设计度量涉及一系列面向软件设计的度量指标,包括针对整体模块性的度量、接口强度和优先级、扇入/扇出、联通度以及可见度等等。值得注意的是,设计的度量结果并不能直接等价于设计质量,通常可以作为支持设计分析结果的辅助证据,帮助定位具体问题并结合具体上下文制定改进计划。

模块度是一种度量整体模块性的指标。[MAC06]认为可以通过计算DSM中元素间的依赖成本,例如依赖的数量和分布模式等,从而实现模块度的间接计算,并且其中存在两种可能的应用场景:

  • 比较软件A和软件B的模块性。

  • 比较软件A在T时刻和T + N时刻的模块性。

假设DSM的元素数量为n,其中传播成本(Propagation cost,Pc)忽略元素所在的位置,假设直接依赖和间接依赖具有同等成本,然后计算所有元素的扇入或扇出数M,则Pc = M / n2。对于整个系统而言,扇入和扇出数是相等的,因此M可以任选其中一种进行计算。聚集成本(Clustered cost)把模块内和模块间的依赖进行区别计算,首先指定一个依赖阈值(通常是10%~100%间的数),并将DSM中被依赖次数超过该阈值的元素计入主控元素,然后根据以下条件计算每项依赖所包含的成本:

  • DependencyCost(i -> j | j is a vertical bus) = d
  • DependencyCost(i -> j | in same cluster) = d * n^λ
  • DependencyCost(i -> j | not in same cluster) = d * N^λ 其中d是表示是否存在i -> j依赖的二进制值,n指模块规模,N指DSM规模。λ是自定义参数。

除了通过依赖成本计算模块度,另一类方法是直接计算模块度。根据模块从内及外且依赖由强变弱的定义,[GG04]提出了一种通用的模块度计算方法,该方法的前提是DSM中已经包含了精确的模块化信息。当DSM中不包含模块化信息,或者需要直接计算系统的实际依赖复杂度时,可采用奇异值模块度指数(Singular Value Modularity Index,SMI)[KO11]。该方法通过对DSM进行奇异值分解,然后计算奇异值的下降率从而表示系统模块度。以下面三种典型的结构模式为例:

Typical structural patterns

从模块性来看,单块(Integral)系统的模块性较差,总线(Bus-modular)系统也比较差,模块化系统则相对较好。对这些模式对应的DSM进行奇异值分解,从而得到上面三种结构的奇异值下降模式:

Singular value decay pattern

可以看出,单块系统的下降趋势非常陡峭,总线型系统比较陡峭,而模块化系统的下降趋势则相对平滑。基于上述关联关系,可以认为当系统的模块性较差时,奇异值会出现迅速下降的情况(SMI较低),而模块性较好的系统,奇异值下降则通常比较缓慢(SMI较高),这种相关性也是上文讨论的谱聚类方法的基本假设。

结论

设计诊断包括分析和度量两个方面,其中设计分析主要负责设计的形式化表示,例如本文讨论的DSM工具。在DSM的基础上可以进一步分析和模拟设计演化过程,发现设计缺陷以及优化系统模块性。DSM同样可以用于设计度量,设计度量指标不直接等价于设计质量,但可以指导设计及其改进。除了基本的度量指标外,模块度是度量系统整体模块性的核心,可以用于不同软件之间和相同软件的不同版本之间的模块性评价。

引用

BC00, Design Rules, Vol. 1: The Power of Modularity

NEVD05, Using Dependency Models to Manage Complex Software Architecture

JNW73, Binary Matrices in System Modeling

TS94, Integration analysis of product decompositions

RAC08, Systematic module and interface definition using component design structure matrix

SS07, Graph clustering

FM13, Clustering and Community Detection in Directed Networks: A Survey

SYG09, Design Rule Hierarchies and Parallelism in Software Development Tasks

RYR15, Hotspot Patterns: The Formal Definition and Automatic Detection of Architecture Smells

SA14, A Spectral Analysis Software to Detect Modules in a DSM

MAC06, Exploring the Structure of Complex Software Designs: An Empirical Study of Open Source and Proprietary Code

GG04, A Comparison of Modular Product Design Methods on Improvement and Iteration

KO11, Degree of Modularity in Engineering Systems and Products with Technical and Business Constraints

Comments

软件设计与架构笔记(12)

设计原则与代码味道

在此之前我们已经讨论了面向对象分析与设计领域分析及其模式以及设计模式。其中朴素的分析和设计方法具有通用性,但需要长期的实际经验积累,在这一过程中不可避免会付出一定代价。虽然模式提供了可复用的设计元素,但基本都存在特定上下文的限制。尽管仍有新的模式不断被提出,但并不意味着其适用性和局限性已被明确认识。同样是来源于经验,有一些设计知识能适用于绝大多数场景,从而具备更广泛的适用性,这就是本文首先要讨论的设计原则。设计原则是来源于实际经验且能够指导一般软件设计的法则,其根本动机是发现并解决软件设计问题。

一般而言,发现软件设计问题的终极方法是以真实需求为基础构建软件,然后收集并分析该软件的开发和运行反馈——显然这种方式的代价过于昂贵。一种退而求其次的办法是构建原型系统,在原型阶段只考虑待验证的核心功能,尽早交付给用户使用并收集相关反馈,该方法使软件设计能够更快响应变化。但是我们知道设计复杂度与问题的复杂度是正相关的,而易变性又是软件设计的一个重要特征,因此从原型获得一次反馈的效用会随着时间推移和问题复杂度的增加而逐步降低,于是需要缩短反馈周期以实现频繁反馈。高反馈频率意味着更高的交付效率,然而交付效率的提升又有赖于恰当的工程方法和可扩展的设计。因此,“黑盒”式的问题反馈方法虽然为设计问题发现提供了事实依据,但其效率受软件自身设计问题所制约。另一方面,从最初采集得到反馈到定位具体设计问题,对问题根因可能存在不同解读方式,导致最终结论的有效性也可能面临挑战。

幸运的是软件开发并非孤立问题,软件设计实践中遇到的问题及其解决方案往往具有普遍性。在这些知识的基础上诞生了一系列被普遍认可的、“白盒”式的设计原则,使软件设计中的潜在问题能够被更早发现和解决。应注意,某些设计原则是针对特定上下文,例如Liscov替换原则之于OO,更多则适用于广泛的上下文。本文剩余部分首先介绍设计原则背后的核心设计属性,然后按所适应的场景分组并讨论经典的设计原则,最后讨论相比于设计原则更轻量、更贴近日常编程活动、且涵盖更广泛的经验知识——代码味道及其与设计原则之间由表及里的内在联系。

设计属性和通用设计原则

每提起设计原则就会出现许多经典的名字和概念,但诸多原则都表现了相对稳定的设计属性,这些属性往往也是软件设计领域中的核心概念,且在前文大多已经讨论过:

  • 耦合性(Coupling),即模块间依赖的程度,耦合越高则意味着该模块将难以被维护,详见结构化设计方法

  • 内聚性(Cohesion),即模块具有单一目的性的程度,内聚越高则意味着更好的可理解性和可重用性,详见结构化设计方法

  • 正交性(Orthogonality),即模块能够独立发生变化的程度,具有正交性的模块意味着更容易应对变化。正交性最初被用于描述一种针对关系数据库的设计原则[DC93],即对于任意两个相互独立的表,其无损分解后的子集不存在相互重叠的情况,该原则能够帮助发现关系数据库设计存在的数据冗余问题。[AD00]详细解释了正交性在更广泛的软件设计问题中的意义,特别是其在模块化、组件化、分层设计等不同设计方法中的一致性体现。

  • 信息隐藏性(Information hiding),即模块尽力隐藏其实现细节的程度,具有信息隐藏的模块通常意味着更低的耦合性,详见模块化编程

设计属性为评估设计质量建立了基础,但由于更加强调概念完整性,使其在形式上很难直接与具体的设计问题相关联,于是就出现了数量更多且更具实践意义的设计原则。对于早期提出的、通用的设计原则来说,其可能借鉴自其它领域,例如:

  • 关注点分离(Separation of concerns, SoC),即把注意力集中在某个方面,而非与其它无关方面相混淆。该原则最初来源于Dijkstra对计算领域中科学性思维属性的探索[EWD74],后来被引入软件设计领域,用于强调软件模块之间应具有尽可能少的特性重叠。

  • 一次且仅一次(Once and only once),也称Don’t repeat yourself,DRY。指任何知识都应在系统中有唯一、清晰和权威的表示。该原则适用于许多软件设计领域。例如单一数据源(Single source of truth, SSOT),指系统中的任何数据元素都只有一份,任何其它具有相同定义的数据都是该唯一元素的引用,目的是保证数据的完整性和规范性。

  • 保持简洁(Keep it simple stupid, KISS),简洁意味着易于理解、维护和扩展。KISS旨在强调简洁性对于系统设计的重要性。实际上简洁性还普遍适用于设计、建筑和哲学等其它领域,例如Simplicity is the ultimate sophistication,Brevity is the soul of wit,Less is more,Make simple tasks simple以及Simplify, then add lightness等。

实体设计原则

实体通常指软件中表示模块的单位,例如存在于许多编程范式中的类、模块等元素。针对实体的代表性设计原则如下:

  • 单一职责(Single responsibility),指任意实体应只有一个使其产生变更的原因。这里“产生变更的原因”等价于实体的职责,即要求实体具有尽可能少的变化维度。单一职责原则是表述最简单的设计原则之一,也是最难被遵循的原则。这是因为职责的定位和分离会随着上下文变化而不同,这需要一定的实践经验和分析过程,且缺少直观的量化手段。尽管如此,单一职责原则仍有可能通过遵循其它设计原则而间接实现。

  • 开放-封闭(Open closed),指任意实体应对扩展开放,对修改封闭[BM88]。当程序需要发生变更时,应尽可能通过添加新的代码而非修改已有代码来完成,即增量扩展。频繁发生修改的实体通常是难以被预测和重用的。值得注意的是,对大多数软件设计来说,保证100%对修改封闭是难以实现的。因此实际中通常采用一些策略性封闭方法,例如:

    • 采用抽象加强显式封闭。如果新的需求导致无法满足对修改封闭,首先应考虑当前的抽象设计,是否需要调整或引入新的抽象从而加强显式封闭特性。

    • 采用数据驱动实现封闭。当修改可能影响同一抽象层级下的许多实体时,可以考虑采用配置数据驱动代码的方式限制修改的影响范围。该方法能够避免引入额外依赖,同时把修改封闭在尽可能小的范围。

    无论是采用抽象还是数据驱动方法,都有可能引入新的封闭性问题。因此实践中往往需要不断考虑并扩展实体对修改的封闭性。

  • Liskov替换(Liskov substitution),如果S是T的子类型,那么对T的任意对象的引用可以被直接替换为S的对象,且毋须修改已有代码。Liskov原则中的“替换”不仅是指语法上父子类型相互兼容,进一步子类型应当保留父类型中的不变量(见设计契约),从而实现在语义层面的兼容。在类型系统中,前述这种更趋严格的子类型定义被称作行为子类型(Behavior subtyping)[LB87]。

  • 接口隔离(Interface segregation),指客户端不应被强迫依赖于它们不用的接口。当实体中需要引入一个新的公共方法时,一般会在其接口中声明具有相同签名的方法。如果该抽象层级下对应了多个子类型,但并非所有子类型都需要新声明的方法时,就意味着发生了接口污染,这种接口也被称作”胖接口”。依赖于胖接口的客户端代码被迫依赖于许多对它们来说无意义的接口,从而大幅增加了级联变更发生的概率。

实体依赖原则

软件的不同实体之间通常存在着依赖关系,针对实体间依赖的代表性设计原则如下:

  • 依赖倒置(Dependency inversion),指高层实体不应依赖低层实体,两者都应该依赖于抽象;抽象不应依赖细节,细节应依赖抽象。当发生直接依赖的实体之间同时存在层级关系时,应当使其依赖共同的抽象。

  • 控制反转(Inversion of control),指通过框架实现程序的控制流,从而操作客户端代码以实现自定义扩展[MFR05]。传统上软件由客户端代码和所依赖的代码库组成,其中客户端代码扮演了负责控制流的角色。为了实现可扩展性,需要首先建立抽象,框架就是集合了众多抽象设计的代码骨架,其中提供了客户端代码的接口,但控制流就从客户端移交到框架端。该原则也被称作好莱坞原则(Hollywood principle),即Don’t call us, we’ll call you

  • 最少知识(Least knowledge),也称迪米特法则(Law of demeter)[LHR88]。对于任何类C以及C中的方法M,M中发生直接调用的对象的类应符合以下两种情形之一:

    • 方法M的参数对象所属的类(参数对象可以是M中创建的对象、M中发生的函数调用所创建的对象、或者是M中引用的全局变量对象,包括C)。

    • 类C中任何实例变量对象所属的类。

    应用最少知识原则能够降低系统本身和对其修改的复杂度。该原则的另一个名字“迪米特”是最初应用该项原则所设计的OO系统[LHR88]。

包设计原则

与Java的package和C++的namespace等关键字不同,包在设计原则的上下文中是指独立的可交付物(有时也被称作组件),例如jar包和dll文件。包是常见于大规模的软件系统中的概念,这里的“规模”没有具体的量化指标,可能是指代码行数、团队大小以及系统复杂度等。针对包的代表性设计原则如下:

  • 重用-发布等价(Reuse-Release equivalency),指可重用代码的粒度不应小于代码的可发布粒度。我们知道可重用性是OOD的一个重要属性,可重用的代码应遵循如下原则:

    • 可以被独立开发、维护、测试、分发。

    • 具体实现对外部隐藏,只通过发布接口(Published Interface)对外公开[MFR02]。

    违反上述原则的代码重用通常都具有副作用,例如代码复制(Code clone)、破坏代码封装导致的强耦合等。针对这些问题,在可发布粒度上实现代码重用是一种有效的解决办法,该方法通过封装和可追踪使代码具有更好的可重用性,包就是实现这种可发布粒度的有效途径。

  • 共同封闭(Common closure),指包中的不同实体应当封闭于相似的修改原因。根据前文对开放-封闭原则的讨论,实际中始终存在无法令实体对其封闭的修改。而如果不同实体具有共同的修改,那么应使它们属于同一个包。也就是说,同类型的修改应尽可能被限制于最少数量的包中。

  • 共同重用(Common reuse),指包中的不同实体应当具有被共同使用的倾向。一般情况下,如果某些实体之间存在抽象层级的协作关系,那么它们应属于同一个包。否则,仅针对个别实体的修改可能引起跨包修改,从而存在较高风险。与共同封闭原则类似,该原则有利于加强包的可维护性,这在大多数上下文中比可重用性更加重要。

包依赖原则

与实体间存在依赖关系类似,包之间也存在依赖关系,针对包之间依赖的代表性设计原则如下:

  • 无环依赖(Acyclic dependencies),指包之间的依赖关系图应是一个有向无环图(DAG)。作为可发布的软件单元,不同包之间不可避免着存在着依赖关系。如果软件系统中存在包的循环依赖关系,即环形依赖,则可能导致以下问题:

    • 依赖环中的所有包存在共同修改的可能,破坏了可独立发布的属性。

    • 包可能间接依赖于大量其它包,从而降低可维护性。

    一种解决循环依赖的方法是应用依赖倒置原则,提取依赖的共同抽象。另一种方法是把现有包中被依赖的部分抽取出来组成新的包。

  • 稳定依赖(Stable dependencies),指包之间应遵循更加稳定的依赖方向。如果一个模块是易变的,那么对该模块的依赖在很大程度上也是易变的,这种易变性(Volatility)会沿着依赖的方向传递,从而影响整个系统的可维护性。然而由于软件的易变性可能存在许多影响因子,不同影响因子所导致的后果也不尽相同。其中稳定性(Stability)被用于描述模块修改的难易程度,即当模块越难以被修改即越稳定。Uncle Bob提出了一种稳定性的度量指标,可以用如下形式计算:

    Ca: 传入耦合(Afferent coupling),依赖于当前包内实体的外部实体数量。

    Ce: 传出耦合(Efferent coupling),依赖于外部实体的包内实体数量。

    I: 不稳定系数(Instability),且I = Ce ÷ (Ca + Ce),则I范围是[0,1],当I=0时当前包最稳定,I=1时则最不稳定。

    因此,被依赖的包应具有比依赖包更大的I值,即满足稳定依赖原则。

  • 稳定抽象(Stable abstractions),指稳定性越高的包也应越抽象,反之则越具体。由于高度稳定的包往往难以被修改,因此其应尽可能抽象,从而使系统的易变部分始终保持在不稳定包的具体实现中。包的抽象程度被称作抽象性,与稳定性相同,Uncle Bob提出了抽象性A的计算方式:

    A = 抽象实体数 ÷ 实体总数。

根据稳定性和抽象性的定义,理想情况下的软件系统应呈现类似如下线性关系:

Abstraction-Instability graph

也就是说,当已知某个包的稳定性和抽象性度量时,我们就可以进一步计算它们在上述坐标中偏离理想值的程度,用距离D表示这种程度,从而有:

D = |(A + I - 1) ÷ √2|

在实际中能够通过计算包的D值,从而决定包的设计合理性,D值越大的包应优先被关注和改进。

代码味道

相比于设计原则,代码味道是一种更加轻量的、被用于识别设计问题的方法。如果把具有良好设计的代码视作是干净无味的话,那么代码味道则可被用于发现那些“显而易见”的“异常”代码。“味道”一词并不是一个正式的概念,Martin Fowler认为代码味道应具备三个基本特征[MFR06]:

  • 代码味道应是易于被察觉的。

  • 代码味道不一定表示代码中存在设计问题,即使存在“问题”,也有可能是因为设计权衡的结果。

  • 代码味道非常易于被程序员理解和掌握,基于代码异味的重构使新手程序员也有机会持续改进设计,即使在缺少对深层次设计原则理解和相关经验的情况下。

与设计原则类似,按照作用层级可以把代码异味划分为方法、实体(类和模块)以及通用三个类别。

  • 具有代表性的方法级代码味道包括:

    • 长方法(Long function)和长参数列表(Long parameter list),不仅导致代码难于理解,还可能违反单一职责原则。
    • 重复switch(Repeated switches),尽管switch语句具有易于理解的结构,但可能违反开放-封闭原则,特别是当相似结构的switch语句重复出现时,会相应存在多处需要同时被修改的代码。
    • 循环(Loops),随着内循环和管道式编程的普及,常规的外循环语句由于相对复杂的结构已经成为循环计算的备选方案。
    • 特性依恋(Feature envy),指实体中的某个方法过度依赖了其它实体中的数据或方法,进而可能违反了关注点分离原则。
  • 具有代表性的实体级代码味道包括:

    • 临时值域(Temporary field),指实体中仅在部分情况下有效的属性,这可能违反单一职责原则和关注点分离原则。
    • 消息链(Message chains),如果存在对象的方法的链式调用,且每个阶段的调用都作用于不同对象,这种消息链可能违反了最少知识原则。
    • 中间人(Middle man),指缺少实际意义的代理方法。尽管封装被作为是OO的重要特征,但某些时候存在不合理的封装,例如一些实体中存在的代理方法,其作用仅是分离了真正的调用对象和被调用对象,这可能违反了单一职责原则和关注点分离原则。
    • 内幕交易(Insider trading),指实体间发生数据处理和相互传递的现象。数据交易可能会引起过度耦合,但有时很难完全避免,因此需要尽可能减少此类现象出现的频率。
    • 过大的类(Large class),指一些包含了大量属性的类。这可能违反了单一职责原则和关注点分离原则。
    • 异曲同工的类(Alternative classes with different interfaces),指某些具有相同类型特性的类,因其具有不同接口而无法利用OO的多态性。
    • 数据类(Data class),指只包含可被外部读写的属性的类,导致有关该类的操作散布在不同的类中,这可能违反单一职责原则。
    • 被拒绝的馈赠(Refused bequest),指在继承关系中,子类拒绝或忽略了父类中的某些方法或数据,这可能违反接口隔离原则。一种解决办法是创建父类的兄弟类,使方法和数据相分离——这只在具有良好抽象意义的情况下有效,否则可能会引入更多复杂度,反而得不偿失。如果现有抽象更加稳定,而“被拒绝”的元素又足以影响可维护性,更有效的办法是抽取新的类,并采用对象代理关系替换原有继承关系。
  • 具有代表性的通用代码味道包括:

    • 数据泥团(Data clumps),指某些经常同时出现的数据组合,其出现场景可能包括不同类中的属性、许多方法签名的参数等。通常可以采用创建新的类表示这些数据组合。
    • 重复代码(Duplicated code),指相同或相似的代码结构在程序中多次出现的现象。最易被识别的重复代码通常发生在同一个类或拥有继承关系的多个类中,也比较容易被消除。除此之外,对重复代码的识别和解决都可能需要进一步的设计权衡。
    • 全局数据(Global data),特别是可变的全局数据,即全局变量。我们已经多次强调了这种共享可变状态的代码可能导致潜在的质量和可维护性问题。实际上,即使是非共享状态的可变数据(Mutable data),依然可能导致代码质量问题。
    • 发散式变化(Divergent change),即某个模块可能会由于不同原因而导致不同方式的修改,违反了单一职责原则。如果修改原因只有一种,但引起了其它模块发生多次级联修改,则称作霰弹式修改(Shotgun surgery)。
    • 基本类型偏执(Primitive obsession),指采用基本类型表示某些复杂数据类型,而非创建独立的类,这可能导致大量的重复代码。
    • 懒元素(Lazy element),指某些设计元素(例如类、实体)只有非常简单的功能,甚至使用一、两行代码就能清楚表现该元素的特性,那么他们就没有单独存在的必要。与之表现形式相反,但具有统一思想的夸夸其谈未来性(Speculative generality),或者称作大设计先行(Big design up front, BDUF),则表示发生了过度设计。
    • 过高的圈复杂度(High cyclomatic complexity),指代码中存在过度复杂的控制流图。圈复杂度通常用代码中的线性逻辑路径数进行表示。假设用N表示代码中的基本区块(指不包含任何控制分支的连续代码片段)数,E表示连接基本区块的边数,P表示连通子图数,那么圈复杂度M可用公式M = E - N + 2P进行计算。过高的圈复杂度可能意味着过度复杂的逻辑或缺少结构性的代码。
    • 神秘命名(Mysterious name)和注释(Comments),这两种代码味道经常同时出现,因为合理的命名更加表意,也就降低了额外注释的必要性。当然在某些时候再合理的命名也无法表达某些上下文时,注释则是必要的补充。

尽管前述大部分的代码味道都有“程度”的概念,使其具体的应用仍然依赖实际经验,但仍然有一些可以遵循的规则。例如,针对重复代码的事不过三规则(Rule of Three),这里虽然中文成语中的数量是虚指,但具体应用时可以作为实际阈值,也就是说当重复代码出现三次时,就应考虑采取相应解决方案了。当然这种规则只能作为初步判断条件,进一步仍然需要结合设计原则进行恰当分析。

结论

为了发现和定位软件设计中存在的问题,人们在实践中总结出了一系列具有普遍意义的设计原则。看似纷繁复杂的设计原则其实体现了一致的设计属性,并在长期的设计分析和验证过程中不断得到认可。为了进一步缩短软件设计的反馈周期,在代码编写活动中就可以通过识别代码味道尽早发现潜在的设计问题,并通过持续重构保证软件的设计质量。

引用

DC93, The Principle of Orthogonal Design

AD00, The Pragmatic Programmer: From Journeyman to Master

EWD74, On the role of scientific thought

BM88, Object Oriented Software Construction

LB87, Data Abstraction and Hierarchy

LHR88, Object-Oriented Programming: An Objective Sense of Style

MFR00, Refactoring: Improving the Design of Existing Code

MFR02, Published Interface

MFR05, Inversion Of Control

MFR06, CodeSmell

Comments

软件设计与架构笔记(11)

设计模式——动机与陷阱

作为软件设计领域在过去三十多年里最重要的议题之一,时至今日,新的设计模式仍不断被提出和采用。软件设计模式的根本目的是为特定上下文提供经受验证的、可复用的元素,从而提高软件工业的生产效率。该领域在早期是伴随着OO的流行逐渐发展起来的[GHJV95],前文所讨论的领域分析模式就是OOA相关的模式,此类模式侧重分析和描述问题域本身。OOD/OOP等活动中存在的模式被称为设计模式,后者用于描述通用代码设计过程中经常重现的组件结构。

为了便于交流和传播,每种设计模式最为人所知的部分就是名字和典型结构。实际上这是一把双刃剑。一方面它确实促进了模式在业界的普及,起到了良好的教育作用;另一方面,对设计模式的描述往往只表现出其中一面,背后其实隐藏了的许多问题,例如:

  • 根本动机,即模式要解决的原始问题,这对于理解模式的适用性非常重要。在不适用场景中应用模式实际上破坏了模式原本的经济效益。

  • 复杂性,例如具有较高的实现复杂性,带缺陷的模式实现会引入更加隐蔽的问题。有时候这种复杂性与具体语言相关,因此语言特定的惯用法(idioms)也成为一种较底层的模式[BMRSS96]。

  • 非适用场景,例如缺少明确的非适用场景的描述,而这部分信息有助于快速排除候选模式。

  • 替代方案,例如缺少对已知的非适用场景的替代解决方案。

上述问题的存在使应用设计模式面临许多挑战。对于这些经典设计模式的“动机与陷阱”,本文将在剩余部分逐一展开讨论。

单例模式(Singleton Pattern)

在OOD中经常会遇到整个系统要求某个类只产生唯一实例的情况,例如Printer spooler、A/D converter等。单例模式通过限制访问构造方法,并向全局提供统一的实例获取接口,从而保证所生产实例的唯一性,如下图所示:

Singleton pattern

系统也可能允许某个类创建特定数量的实例,此时可以用Map结构的instances存储对应多组实例,即多例(Multiton)模式。虽然单例模式利用OO语言的特性实现了对任意创建实例的限制,但实际上可能引入更多问题。以基于Java语言的单例模式实现为例:

1
2
3
4
5
6
7
8
9
public final class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

与上述采用普通类的实现相比,Java的单元素枚举模式具有更加简洁的实现,例如:

1
2
3
4
5
public enum Singleton {
    INSTANCE;

    Singleton();
}

依据JVM规范中的类加载机制,作为静态常量的INSTANCE初始化会在Singleton类初始化过程中进行,而后者的发生需要满足且不限于以下条件之一:通过new运算符初始化实例;对类的非常静态变量进行读写操作;调用类的静态方法;通过反射调用类;类包含main函数。可见,由于类初始化条件的复杂性,INSTANCE初始化时机是无法得到有效控制的。一种结合懒求值模式的实现能够把INSTANCE初始化从类初始化的过程中分离出来:

1
2
3
4
5
6
7
8
9
10
11
12
public final class Singleton {
    private static Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

在多线程场景下,上述实现无法保证Singleton只初始化一次。一种解决办法是把getInstance方法声明为synchronized,但会显著影响程序运行效率。另一种办法是采用双重检查锁(Double-checked locking)模式,这时需要把instance变量声明为volatile以保证可见性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final class Singleton {
    private static volatile Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

事实上,如果充分利用Java的类初始化的原理,则可以借助按需初始化持有者(Initialization-on-demand holder)模式实现更高效的懒求值单例模式:

1
2
3
4
5
6
7
8
9
10
11
public final class Singleton {
    private Singleton() {}

    private static class LazyHolder {
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

上述结合多种模式的实现,看似解决了单例模式在Java并发程序中存在的问题,但因其较复杂的代码实现而影响了程序的可理解性。

单例模式的另一个复杂性在于其向系统中引入了全局状态特性,后者被公认为是影响软件质量和可维护性的重要反模式之一。因此,具有可变特性的单例模式实现应受到特别关注。而如果单例模式具有不可变特性,那么可以考虑采用无状态的工具类(Utility class)模式,即其携带的所有方法均为静态方法,且无法被用于创建任何实例,例如经典的工具类java.lang.Math。与单例类模式通常会引入额外复杂性相反,工具类模式有时会存在过度简化的问题,因为后者消除了抽象层级存在的可能,使得每次对工具类的引用都造成强依赖关系,即“不够OO”。因此,在采用工具类模式前,应充分理解所在领域与系统核心领域之间的关系及其作用。此外,无论是单例模式还是工具类模式,都可能会影响具体实现代码的可测试性,这点对于Java语言尤为如此。

虽然单例模式使OO在行为上更加接近真实世界,但其可能会引入一系列负面影响。尽管有针对性的模式试图消除这些影响,但始终难以避免引发新的问题,最终仍有可能得不偿失。因此,采用普通类始终是解决前述问题的首选替代方案。

工厂模式(Factory Patterns)

工厂模式是指一系列模式,这些模式被用于把实例的创建过程从现有应用逻辑中分离出来,从而更好地管理复杂性。例如,一个Color类可能包含如下构造函数:

1
2
3
Color(String rgb) {//...}
Color(int red, int green, int blue) {//...}
Color(int hex) {//...}

注意上述构造函数的参数其实相当于Color的不同表示形式,这种构造过程更像是在进行类型转换,采用静态工厂方法(Static factory method)可以令其更加表意:

1
2
3
static from(String rgb) {//...}
static from(int red, int green, int blue) {//...}
static from(int hex) {//...}

上述类型转换也可以支持聚合类型,例如java.util.EnumSet的of方法:

1
static <E extends Enum<E>> EnumSet<E> of(E first, E... rest) {//...}

静态工厂方法也支持创建不属于当前类的实例,例如java.util.Arrays和java.nio.Files等工具类:

1
2
static <T> List<T> asList(T... a) {//...}
static BufferedReader newBufferedReader(Path path) {//...}

除了上述语法糖特性外,静态工厂方法还允许对实例进行缓存,从而控制新实例创建,例如单例模式中的静态方法getInstance。另外,还可以通过设置输入参数返回不同子类型的实例,例如java.util.EnumSet,该抽象类并不提供公共构造函数,而是通过静态工厂方法对输入数据进行分类,按照输入的元素数量自动选择RegularEnumSet或是JumboEnumSet的子类实现。

静态工厂方法是对Java语言中new运算符的替代方案,由于其更好的表意性,该方法通常被用于为编程框架提供统一界面。但是如果某个类只提供静态工厂方法,也就意味着该类无法被正确继承,从而限制OO的抽象类型特性。Java语言中采用静态方法也会影响代码的可测试性。

基于OO的代码框架设计通常采用抽象类或接口表示对象及其相互关系,有时需要负责提供对象创建的功能,但在抽象层级无法确定具体类型。因此先定义抽象的对象创建方法,然后在子类中进行具体实现,该模式被称作工厂方法(Factory method):

Factory method pattern

虽然工厂方法通过采用抽象类型延后了对象的具体类型确定。但是这也意味着始终要配合ConcreteProduct衍生出对应的ConcreteCreator,这种平行类层级结构能起到分离职责的作用,但也可能会引入过多的复杂性。

当相关的对象属性或行为随上下文变化时,Client需要根据条件创建不同的对象,于是就产生了对所有相关对象所属类的依赖。这时Client的复杂性会同时受到两个维度的影响:

  • 对象创建过程的复杂性。

  • 对象所属的抽象类型数量。

通过独立的工厂类对相关对象的创建过程进行封装,并从中抽取统一的抽象接口,这就是抽象工厂(Abstract factory)模式。其基本结构如下图所示:

Abstract factory pattern

借助抽象工厂模式,Client只需要依赖于Product和Factory的抽象类型,从而具有更好的可扩展性。当面临更复杂的场景时,抽象工厂模式就显得不够灵活,这里所说的复杂性具体存在两种情况:

  • 创建Product依赖于复杂的初始化参数列表,且具有复杂的实现过程。

  • Product具有很多类别且经常发生变化,那么AbstractFactory及其子类也会相应增多且面临频繁修改。

一种能够避免创建工厂类且同样能够分离对象创建过程的途径是采用原型(Prototype)模式,后者允许通过复制一个已存在的对象从而快速创建新对象。原型模式的基本结构如下图所示:

Prototype pattern

当已经存在一个原型对象时,Client就能通过调用原型对象的clone方法快速创建新对象。这里的原型对象可以采用任意方式创建,例如new运算符或者其它工厂模式。原型模式主要解决以下两个问题:

  • 要创建的对象具有复杂的初始化参数列表。

  • 在运行时才能确定要创建的对象所属类型。

Java语言中的Object类提供了默认的clone方法(浅拷贝),但对象所属的类需要包含Cloneable标记接口才能调用和覆盖Object中的clone方法(例如实现深拷贝)。然而无论是实现浅拷贝还是深拷贝,clone方法都会为对象实现引入额外的复杂度,特别是当对象拥有较深的类层级结构或嵌套引用时,如何保证clone方法的正确性和一致性会遭遇挑战。

原型模式可以看作是对其它工厂模式的补充,其有效性依赖于已经存在的原型对象,因此存在较严格的适用场景。相比之下,建造者(Builder)模式则是一种通用性更强、更加灵活的对象创建模式。与抽象工厂通过实例方法直接创建对象不同,建造者模式把复杂对象的创建过程划分为相互独立的子过程,最后通过调用build方法生成所需的完整对象。该模式具有如下结构:

Builder pattern

建造者模式允许更加可控的对象创建过程,而非传统上通过构造函数或setter方法准备对象所需的输入,因此具有更加表意、灵活且一致的优点。然而与抽象工厂类似,当具有许多不同类型的Product时,就需要创建对应的ConcreteBuilder,从而可能引入额外的复杂性。

依赖注入模式(Dependency Injection Pattern)

借助工厂模式,Client能够方便地创建所需的对象。但无论是工厂方法还是抽象工厂,目标对象始终是由Client通过直接或间接方法调用而创建的,因此两者之间依然存在较强的依赖关系。而在多数情况下,Client只关心依赖对象所提供的相关特性,而非对象的创建过程。另一方面,由于语言自身条件的制约(例如Java),单元测试难以利用stub/mock技术隔离目标代码内部动态创建的对象,导致单元测试的高实现成本和低运行效率。为了解决前述问题,可以把创建对象的职责从Client彻底分离出来,即在外部完成对象创建,然后依照Client的实际需求“注入”相关依赖,从而使Client能够更聚焦于自身功能特性,这种模式被称作依赖注入(DI)。下图进一步解释了该模式的结构和原理:

Dependency injection pattern

构建依赖对象的过程被称作装配(Assembling),通常是由注入器(Injector)负责装配过程,其中包括向Client注入依赖。一种注入器的实现模式是服务定位器(Service locator),在该模式中Client获取任何外部依赖都需要通过服务定位器进行查询,后者相当于保存相关依赖的注册表服务。其基本结构如下图所示:

Service locator pattern

虽然采用服务定位器的Client不再关心依赖对象的创建,但前两者之间仍存在强依赖关系,同时由于服务定位器的实现通常是基于单例和静态工厂方法,因此可能成为并发条件下的系统瓶颈,也欠缺可测试性。彻底实现Client与依赖获取分离的方式是依赖注入容器模式,该方法通过Client无关的代码或配置文件实现对象装配,然后将其与Client中声明的依赖相互关联以便注入。Client声明所需依赖和实现注入点的方式有三种:

  • 构造函数注入,即在Client进行初始化的过程中注入依赖。该方法有助于维护Client内部状态的一致性,也让Client的依赖项更易于被理解,这是最常见的一种DI实现方式。

  • setter注入,即当Client完成初始化后,通过其提供的setter方法注入依赖。当Client存在很多依赖项时,单纯依赖构造函数注入会面临长参数列表的问题,同时如果Client存在多种依赖组合状态,就需要相应数量的构造函数,相比之下setter注入就更加灵活。

  • 接口注入,与setter注入类似,区别在于setter方法来自于独立的接口,并由Client实现相关接口的方法。该方法的优势在于外部的注入代码可以完全忽略Client的具体类型,仅通过相关接口类型的引用向其注入所需依赖。

DI能够最大化分离依赖的创建和实际应用,从而实现显著的模块解耦。由于对象装配完全由注入器提供的机制负责,因此这里往往是DI最核心和最复杂的部分。许多基于动态特性的DI实现也使静态对象追踪更加困难,进而可能增加软件的维护成本。

组件依赖模式(Component Dependency Patterns)

软件开发的过程中经常存在独立的软件单元,例如被独立开发的类(如类库),或是包含一组类的子系统,亦或是相互独立的对象。组件通常具有独立的设计和演化规则,因此不同组件对外提供的接口也不尽相同。实现组件间依赖的一般做法是引用目标组件的原始接口,但这会造成组件间的强耦合,当需要替换某些组件、或者对依赖过程实现扩展时会产生较高代价。组件依赖模式就是为了解决此类问题的一组设计模式。

一种解决组件间依赖的模式是适配器(Adapter),也称包装(Wrapper)。该模式会额外创建一个Wrapper类包装目标组件,同时遵循面向Client的一致性接口。具体的包装手段有类适配器和对象适配器两种,其基本结构分别如下图所示:

Adapter pattern

类适配器具有更加简洁和OO的外观,但也存在一些潜在的限制。首先类适配器只能包装具体类而非接口,因为后者没有可供复用的具体实现。其次继承通常具有侵入性,仅以代码复用为目的的继承可能会破坏原有类的封装能力,从而损害了OO的内核。对象适配器则与之相反,虽然可能需要额外的对象创建和连接操作,但不存在类适配器的问题,因此更容易应对复杂性。

适配器模式主要解决依赖已有类的问题,但类似的结构也可被用于正在开发的类,有时因为希望保持接口和实现相对独立。传统上接口是作为与Client间的契约先于实现而确定的,但是当接口本身也具有一定复杂性时,就可能需要接口和实现分别独立演化,以便必要时还能切换其它替代实现,这种模式被称作桥接(Bridge)。其基本结构如下图所示:

Bridge pattern

桥接模式具有和对象适配器相似的结构,但要注意其根本动机的不同。桥接模式支持从原有具体类中分离出承担接口职责的部分,从而应对接口和实现同时可能发生快速变更的情况。而适配器是在目标类稳定的情况下利用额外的接口包装以实现代码快速复用。

当目标类的接口满足Client需求,但在具体应用时需要引入额外的面向切面(Aspect oriented)功能时(例如访问控制、日志记录等),可以创建与其拥有相同接口的代理类,通过类似对象适配器的结构对目标类的对象进行包装,这就是代理(Proxy)模式。该模式的基本结构如下:

Proxy pattern

如果一个已有的子系统由若干接口和类组成,且其内部具有复杂的结构关系。为了提高子系统的易用性,可以额外维护一个统一的对外接口类,将系统特性通过简单的接口定义向外部发布,即外观(Facade)模式。该模式的基本结构如下:

Facade pattern

与其它模式关注类之间的依赖问题不同,外观模式主要解决子系统间的依赖问题,但其解决问题的基本思路是一致的。有时对象间的依赖也会存在问题,例如可能会生成庞大数量的目标对象,使系统资源过度消耗。当大量目标对象中包含许多重复信息时,一种解决方法是通过创建共享对象以减少资源总消耗,这种模式称作享元(Flyweight)。享元模式具有如下基本结构:

Flyweight pattern

为了实现目标对象共享,可以采用抽象工厂或工厂方法生产被Client依赖的享元对象,当已有的享元对象满足共享条件时,系统会直接返回该对象而非重新创建。享元对象相当于对目标对象的重新包装,但这并不意味着享元对象是一定可以被复用的,例如当目标对象包含特定的外部状态信息时,就需要专门再创建一个非共享享元对象保存这些外部状态信息,并且在对应的共享享元对象中保存对其引用。

增量扩展模式(Incremental Extension Patterns)

OO的重要特性之一是通过继承实现增量式扩展,这种特性能帮助我们有效管理系统复杂性。但是正如面向对象——概念与建模一文中提到的,继承除了提供模块能力外还兼有类型概念,这种概念兼有利弊,特别是令继承的适用场景受到一定限制,不合适的继承非但不能有效管理复杂性,反而会带来更多问题,此时就需要考虑用组合替代继承实现模块扩展,这就是组合优于继承(Composition over inheritance)长期占据主流观点的原因。接下来要讨论的设计模式或采用继承或采用组合的方法来解决增量扩展的问题。

当系统需要同时维护很多对象时,维护增量式扩展的核心问题之一是如何管理每个对象的定义及其相互间的关系。当所有对象间关系呈现为树形或层级结构、并且Client与这些对象交互存在趋向一致的外部接口时,可以采用组合(Composite)模式。该模式为系统中的对象提供了统一的外部接口,作为非叶节点的对象可以包含并将同类型的对象作为其子节点。组合模式的基本结构如下图所示:

Composite pattern

组合模式所提供的增量扩展能力在于可以通过实现Component接口任意添加新的节点类型,虽然是等同于继承的扩展方式,但除了具有树形或层级结构这种特殊的对象间关系外,多数时候组合模式并不能适用。相比之下,聚合是一种更加普遍的对象间关系,其中聚合对象可以包含若干个不同类型的局部对象,在实践中聚合关系往往是通过对象间的组合实现的,这被称作整体和局部(Whole-Part)模式。下图给出了该模式的基本结构:

Whole-part pattern

与组合模式最大的区别在于,该模式并不要求“整体”和“局部”对象具有统一的访问接口,通常只有整体对象对外提供服务,而局部对象具有多种类型且一般不会向外界暴露。类似的结构也存在于主从(Master-slave)模式,该模式描述的对象间关系一般只有两层——主对象和从对象,其基本结构如下图所示:

Master-slave pattern

其中主对象可以按需把接收到的工作分解并指派给若干从对象,最终再汇总从对象的计算结果并产生最终结果,主从模式有助于提高系统容错性、并行性和计算准确性。

有时需要实现对象的动态扩展,为了保持扩展对Client透明,需要同时借助继承的抽象类型特性保证扩展结果具有与原对象一致的接口,这就是装饰器(Decorator)模式。该模式的基本结构如下:

Decorator pattern

为了描述具体的扩展内容,首先需要创建与目标对象相同接口的装饰器对象,后者只需关心各自要扩展的功能;在对象创建阶段,用装饰器对象包装目标对象,如果存在多个装饰器对象,则继续用其包装装饰后的对象;最终生成的装饰后对象具有与目标对象完全一致的对外接口,因此装饰器模式能够最大限度地保持Client不变。

如果一个上下文需要在不同状态中表现出不同行为,可以把这些状态对应的行为单独抽取出来,利用组合关系使其行为能够被动态替换,这就是状态(State)模式。与装饰器模式类似,状态模式也是一种同时结合了继承和组合的增量扩展模式,该模式的基本结构如下:

State pattern

除了内部状态发生改变之外,上下文行为还可能会因为其它因素产生变化,例如需要选择不同的算法策略,此时可以采用与状态模式具有几乎相同结构的策略(Strategy)模式:

Strategy pattern

状态模式和策略模式都属于纵向增量扩展模式,即每次扩展相当于对原有功能进行完整替换,这种扩展方式具有较高的灵活度。在某些场景中,上下文行为存在相对固定的套路,延续这种套路虽然可能会引入某些限制,但使后续扩展的关注点更加明确,从而提高增量式扩展的效率。模板方法(Template method)模式就是这类横向增量扩展模式的典型代表,其基本结构如下:

Template method pattern

该模式中提供的模板方法就是一个预置的算法框架,任何扩展只需替换其中预设的子步骤。另一种预置了算法框架的模式是迭代器(Iterator),该模式封装了数据结构的基本操作,从而令Client更加关注在具体元素的操作。其基本结构如下:

Iterator pattern

在前述模式的基础上,可以进一步抽象出元素操作,从而满足数据结构及其算法、数据结构中的元素、元素对应的操作的独立扩展,这就是访问者(Visitor)模式。该模式的基本结构如下:

Visitor pattern

由于实现了最大限度的抽象化,访问者模式同时在上述三个维度上提供了增量扩展能力,但同时也引入了较高的复杂度,特别是当元素及其操作分别属于不同的类层级结构,但其相互之间仍然存在着强耦合关系,导致代码的可维护性受到损害。

消息模式(Messaging Patterns)

OO中的对象间通信主要通过消息传递进行,最直接的消息传递方式就是调用目标对象的方法。消息模式主要用于解决下述几个方面的复杂性:

  • 消息的发送者和接收者。

  • 消息的表示和传递。

  • 消息的存储和管理。

当接收者的消息处理过程比较复杂,例如具有层次式的处理结构时(类似多层嵌套的条件分支),可以把其中每一层的处理逻辑抽取出来构建独立对象,然后将原始的消息接收者替换为一组对象链,即责任链(Chain of responsibility)模式。该模式的基本结构如下:

Chain of responsibility pattern

在责任链模式中,原来的消息接收者被划分成多个职责独立的对象,消息由一次处理变为沿着责任链进行传递并被多个对象分别处理。这种变化降低了原始代码的圈复杂度,而且方便复用现有对象组建新的责任链。

对于作为消息发送者的对象,把待发送的消息和接收者从中抽取出来构建独立对象,一方面可以降低发送者的复杂度,满足消息类型的增量扩展;另一方面可以灵活控制消息的发送时机。这种模式被称作命令(Command)模式。其基本结构如下所示:

Command pattern

上图中的Invoker扮演消息发送者的角色,Receiver则是消息的接收者,Client负责创建命令对象并将其和接收者关联。命令模式中的命令对象是不可变的,这种特性使命令对象能够被重复使用,但无法保存状态。另一种相似的模式——命令处理器(Command processor)允许创建具有可变状态的命令对象,其代价是扮演Invoker的命令处理器需要每次从Client获取新建的命令对象,其基本结构如下所示:

Command processor pattern

如果一次消息发送对应了多个接收者,并且接收者还可能会发生动态增加或减少,那么在每个接收者上实现观察者接口,然后将其动态注册至消息的发送者上,当消息发送时会依次发送至每个已注册的接收者。这就是观察者(Observer)模式。该模式的基本结构如下:

Observer pattern

观察者是一种消息传递模式,特别是当存在一对多的消息传递关系时,应用观察者模式能够实现消息发送者和接收者的解耦,并且支持动态的接收者增加和减少特性。观察者模式有时也被称作发布者-订阅者(Publisher-subscriber)模式,但后者有时存在更多变种。例如传统的观察者模式主要依赖消息推送(Push),所有观察者都被动接收消息。但是发布者-订阅者模式中还支持消息拉取(Pull),这时发布者只会发送很简单的变更通知,由订阅者决定是否读取该消息。消息拉取是一种更加灵活的消息传递方式,特别是当消息量可能超出了接收者的承受能力时,拉取实际上对接收者起到了保护作用。如果不存在消息过载的情况,那么采用推送则更加简单且实时。

如果系统中存在许多对象,并且这些对象间大都存在着某种消息传递关系时,可以创建一个中介对象负责接收并转发对象发送的消息,即中介者(Mediator)模式。该模式的基本结构如下:

Mediator pattern

与观察者模式类似,中介者模式能够解决多对多的对象间消息传递和动态增减问题。但是该模式会增加获取消息传递路径和参与双方信息的难度,使某些重要的领域逻辑无法在代码中得到清晰呈现,进而间接提高维护成本。因此当消息传递包含关键领域逻辑时,应避免采用中介者模式,当然最终的设计和实现可能会更加复杂,以视图处理器(View handler)模式为例:

View handler pattern

该模式用于解决多文档窗口管理的问题。多文档窗口通常存在于Word等文本编辑工具,其特点是系统可以同时打开多个窗口,而且每个窗口可以指向任意文档的内容。该问题的复杂性在于每个窗口中的操作可能会影响其它窗口的显示结果,同时当用户点击退出时系统需要针对每个打开的文档向其询问是否要保存已修改的内容。视图处理器模式定义了系统中存在的三种对象:

  • 视图处理器,负责管理所有视图,以及对外提供针对视图的操作。

  • 视图,负责保存当前视图的内部状态,向视图处理器提供基本操作。

  • 供应者,负责保存数据,并且向其观察者发送数据更新消息。

该模式的对象间的消息传递存在两种情况:

  • 当视图内的数据发生修改,该修改被反馈给供应者,然后把更新后的状态通过观察者模式发送给视图处理器和其它相关视图。

  • 外部触发系统调用视图控制器提供的接口,然后把相关更新依次传递到每个视图。

当多个对象间消息传递发生在跨进程的对等网络中时,消息传递需要先后经过序列化和反序列化,这时可以采用转发者-接收者(Forwarder-receiver)模式。

Forwarder-receiver pattern

在该模式下,消息发送者需要首先把消息传递给转发者;后者进行序列化和寻址,然后把消息传递至接收者;接收者接到消息后先进行反序列化,然后把消息返回给接收对象。有时需要保持网络地址对消息双方透明,从而实现信道自动调度,此时可以采用客户端-调度器-服务器模式。该模式的基本结构如下:

Client dispatcher server pattern

在处理相关消息时,有时会导致接收者的内部状态发生改变,但消息的发送者可能要求撤销某些消息处理,其实质是恢复消息接收者的状态到历史的某个时刻。一种简单的做法是由消息接收者返回当前内部状态,以备忘录对象的形式在外部进行保存。当回退需求发生时,发送者向接收者发送备忘录对象并要求恢复至指定状态。这种模式被称为备忘录(Memento),其基本结构如下:

Memento pattern

结论

作为软件设计的最佳实践,设计模式从诞生之初就受到了工业界和学术界的热捧,并把OO进一步推向了金字塔顶端。近年来随着软件开发框架和工具链的日益完善,设计模式在日常应用开发中逐渐退居幕后,成为新技术背后的驱动力。但是,作为软件工业化的必经之路,新模式的发现及传播不可能由此中断。对于任何一种模式,了解其场景和结构固然重要,但深入理解模式的动机和陷阱才意味着做到了正确理解,才能真正将其作为软件设计的最佳实践。

引用

GHJV95, Design Patterns: Elements of Reusable Object-Oriented Software

BMRSS96, Pattern-Oriented Software Architecture Volume 1: A System of Patterns

Comments

软件设计与架构笔记(10)

面向模式的领域分析与建模

前文讨论了OOA/OOD的基本概念、工具和方法。我们已经知道,当面临稍复杂的问题域或特殊的上下文时,朴素方法会面临许多挑战,使软件开发风险难以得到有效控制。模式的发现和应用为工业界带来了高效的解决方案,也因此成为甚至比朴素方法更易流行的方法论,即面向模式(Pattern oriented)。本文是对前文OOA部分的进一步补充和扩展,首先讨论领域分析(Domain analysis),及其与数据建模和OOA的关系,然后深入讨论面向对象分析模式,这些模式大多采用领域特定的概念模型(也称分析模型)进行描述,与纯粹抽象的表达方式相比更易理解,然而其模型结构和分析过程可被应用至更广泛的领域。

领域分析

领域分析是指为了满足跨组件的可复用性,对软件开发信息进行识别、收集、组织的过程[RPD90]。当相似的问题域重现时,期待所构建的软件能够尽可能被复用,从而提高软件的经济效益,这是领域分析的根本动机。因此,相对于可复用的代码,领域分析主要关注于分析和设计的可复用性,这通常反映为组件级的可复用性[JMN80]。

在软件工程的语境下,领域是指所构建软件系统的实际应用场景。具体来说,一个领域既可以大至银行业务,也能小至算术运算。一个领域还能被分解成多个子领域,因此复杂的领域可被看成是一种层级的网状结构,层级越高则意味着领域的复杂度也就越高。领域边界用于描述领域的范围,定义了每个领域中的对象、操作以及相互之间的关系。组件是指一个独立的软件单位,这种独立性可以指独立开发、独立部署或独立运维等特性,并且组件之间还能通过某种协议进行通信。领域是业务单位(例如对应不同的业务部门),组件则是工程单位(例如对应不同的项目或开发团队),但二者最终存在一定的映射关系。虽然领域分析有比较明确的目标,但具体的实现方法属于非平凡问题。例如如何确定领域层级、如何确定领域边界以及如何建立与组件之间的映射关系等。

领域分析的框架模型

[RPD90]描述了领域分析的结构化上下文视图(SADT Context View),如下图所示:

Domain analysis

在上述模型看来,领域分析实际上是一种依赖多种输入且高度参数化的框架,其最终产出也可根据参数进行定制。例如,当核心是某个产品而非技术时,领域分析主要关注战略一致性、市场策略、产品定位、风险分析、日常外观等一系列产品特性,这时需要用到通用术语调研、逻辑架构、可靠性标准等方法——这种领域分析活动被称为产品定义领域分析(Product definition domain analysis)。当有明确的证据支持当前产品流时,即可进一步就具体产品展开分析,这时往往是概念性领域分析,该活动被称为需求领域分析(Requirement domain analysis)。当表示产品线的领域趋于稳定时,就可以考虑构建特定领域的生成器,从而满足高效构建新产品的需求,此过程被称作生成器领域分析(Generator domain analysis)。

领域分析、数据建模、OOA

我们在数据模型与数据建模一文曾讨论过数据建模。从上世纪80年代初期开始,由于关系模型和关系数据库系统的流行,信息系统的构建往往都强依赖于数据库系统。为了保证概念模型能直接用于后续设计和实现,数据建模、特别是概念数据建模成为需求领域分析活动的主要内容之一。其中,实体-关系建模(ER modeling)是数据建模中最具代表性的方法,其分析结果即ER图,后者往往被直接用于数据库设计甚至应用构建[DCH96]。虽然数据建模也重视对真实世界的反映,但其更侧重于表现状态而非过程,而事实上两者对于软件开发同样重要,这也是兼具二者的OOA后来居上的原因。而OO天然具有的抽象层级抽象边界等概念,与前文提到的领域和领域边界的概念能够一一对应,且其最终得到的领域概念和对象,亦即需求领域分析的目标。

因此,数据建模和OOA都是领域分析采用相应建模技术的具体实现。在领域分析方法的发展过程中,二者都扮演了重要角色。虽然至今领域分析方法都在不断演进,但从已有经验总结得出的分析模式,能够为常规的领域分析活动提供可靠的候选参考。

面向对象分析模式

概念模型是理解和模拟真实世界的基础,但由于后者的复杂性通常都超乎想象,导致概念模型基本不存在正确与否的问题。对此Martin Fowler用模拟斯诺克的例子解释:当需要对球的运动进行建模时,经典牛顿力学和爱因斯坦的狭义相对论都可以作为候选模型,前者非常直观且易于实现,后者实现复杂但具备更好的适应性和精确度。显然,最终生成软件的灵活性和可重用性强烈依赖于所采用的概念模型。但除了个别质量属性外,软件工程中还存在更多的权衡因子,一个典型的例子是可维护性和成本之间的矛盾。一种平衡前述因子的有效办法是构建极简概念模型(Simplest conceptual model)——这并非一个容易达成的目标,因为“极简”不是指问题域中最简单的一部分,而是指在更高抽象层级构建概念模型,其内核虽然是“极简”的,却因为良好的伸缩性得以适应更复杂的问题域。在[MFR96]中,Martin用分析模式表示用于构建概念模型的模式,随之用支持模式表示将概念模型转化为OOD的模式。前者是下面要讨论的重点,后者属于OOD的范畴,本文不做专门讨论。

  • 职责模式(Accountability),该模式用于表示个人或者组织之间的职责。在一些问题域中经常需要表示相关职责,例如组织结构、合约、雇佣等,这些都无一例外体现为某种真实对象之间的关系。这里我们把个人、组织这种具体领域的词汇统称为更加抽象的词——实体。当不同的实体表现出趋同的职责时,应考虑构建一个更高抽象层级的实体。如下图所示:

Generalized party

当不同实体之间存在灵活的组织层级关系时,可以从中提取抽象实体,通过递归引用构建这种层级关系,从而易于应对层级变化。如下图所示:

Organization hierarchy

当实体的子类型间的层级关系存在某种约束时,可以把其添加至相应的子类型内部,从而避免改变模型结构。此外,实体间还可能存在多个层级关系,可以把上述显式引用的关系替换为关系类型(Typed relationship),后者被用于表示实体间组织结构的对象类型,实体通过关联的多个结构对象实现不同的层级关系。同样,实体的结构类型也可以关联相应的约束规则,如下图所示:

Multiple organization hierarchies

上述方法的优势是如果实体的关系类型比较复杂,约束规则可以对应至每种关系类型进行管理。问题在于,如果实体的子类型变化比关系类型更多,那么任何子类型的增删改都会影响现有规则,这时可以把规则关联到每个实体的子类型,而非关系类型。

显然此时模型的复杂度已经有了明显增加,为了方便分析这种复杂性,把当前模型划分成运行等级(Operational level)和知识等级(Knowledge level)两个部分,如下图所示:

Knowledge and operational levels of accountability

其中运行等级包含职责、实体和两者的关系,知识等级包含职责类型、实体类型和两者的关系。前者用于记录领域中发生的日常事件,后者记录用于监管前者的规则。

另外,实体类型也可能具有抽象层级,这样就能描述更加复杂的场景。例如对于全科医生和专科医生来说,两种类型既具有共性也具有特殊性,因此可以创建一个更加抽象的层级——医生来管理其共性。

职责类型也具有抽象层级,随着职责类型不断增加,相应规则数量也会随之增加。但是,如果职责类型表现为固定且简单的等级关系,例如常见的办公室、部门、区域等,那么可以采用分级职责类型,即直接在职责类型对象内存储静态等级列表和相应规则。后者比传统按抽象层级创建新的职责类型相比更加简单,但缺少灵活性。

职责类型描述职责的种类,但一个具体的职责通常包含更多细节信息,例如地点、数量、内容等,这里称为运行范围(Operating scope)。运行范围也可能具有复杂的抽象层级和关联关系,如下图所示:

Operating scope

运行范围的一个典型用例是职位描述(Job description),也就意味着职位描述是与工作职责相互关联的,而非具体的员工实体,这也更加符合真实世界的场景。假设具有某项职责的员工离职,那么其承担的职责应被移交给接替者,可以在此基础上创建一个新的职位(Post)实体,于是就避免了职责与员工的直接关联,而是让两者通过职位间接产生关联,从而实现更好的灵活性。

  • 观察与测量模式(Observations and measurements),该模式用于表示真实世界对象的信息。以数值类属性为例,体重是人的基本属性之一,一般用数值类型存储。然而如果领域要求更精确一些,数值类属性还需要额外的单位信息,这时就需要构建抽象数据类型,这里称为Quantity。几乎一定会用到Quantity类型的领域包括财务(货币单位)、医疗(剂量单位)等。

Quantity通常意味着单位转换的问题。一个简单的单位转换率模型如下图所示:

Unit conversion

如果问题域中包含大量的转换率,可以把单位用量纲表示,这样就可以动态计算单位之间的转换率。对于更复杂的单位转换需求,例如摄氏和华氏这两个温度单位,就需要引入携带计算行为的单位转换模型。在真实世界中,一种物理量的单位经常需要用一些基本单位组合表示,例如平方英尺、米每秒等,这时就需要引入复合单位,即引用其它单位的单位。

测量是一种表示实体中数值属性的抽象数据类型。当一个实体具有大量属性、且这些属性分别用于不同操作时,实体中就可能存在大量针对这些属性的操作。测量的目的就是抽出这些操作,并在此基础上分离出位于知识等级层面的现象类型(Phenomenon type)。如下图所示:

Measurement

实体还可能包含非数值属性,与测量类似,可以采用独立的抽象数据类型表示非数值属性,二者可以共同提取出更高的抽象层级——观察。观察中所包含的规则、解释和创建方式等可以进一步提取出来,构成协议。观察往往具有一定的时效性,即观察值仅当满足特定时间段时才有效,可以通过关联时间对象解决。在一些问题域中,观察值可能被发现是无效的,但基于可追溯性无法删除已有的观察值,因此通常是给原有观察关联一个新的不合格观察。例如在医疗领域中,患者的检测报告有可能因为各种原因被识别为无效,但仍需要保留历史记录以便追踪治疗历史。医生会根据检测结果诊断病因,有时会因为某种测量结果而判断病因,也可能因后续测量结果调整治疗方案。为了表示观察值所带来的影响,可以将其划分为三种子类型:假设、投影和活动观察。其中活动观察指当前最被信任的观察,假设指需要进一步测试才能确定的观察,投影指医生认为未来可能出现的观察。

观察是可以相互关联并产生影响的,这种情况被称为关联观察。如下图所示:

Associated observation

  • 引用对象(Referring to objects),这是一种表示对象间引用关系的模式。真实世界的对象大都存在交互,OO首先要解决的问题就是引用对象,这通常是基于一个显式的身份标识。最简单的对象引用方式就是按名字引用其它对象,具体可以是对象名,也可以是对象id。

当对象可能在不同场景中具有多种的标识时,简单的名字引用就难以应对各类场景中的特定对象。例如一个学生可能同时有身份证号、学号、甚至准考证号等标识,此时可以采用标识对象表示引用关系,描述场景的对象被称作标识模式,这是一种具有上下文信息的人工标识方法。如下图所示:

Identification scheme

  • 库存与会计(Inventory and accounting),一种跟踪企业资金流动的模式,主要用于财务会计、库存和资源管理等领域。通常用账户表示具有实际价值及其历史记录的实体,例如银行账户、库存账户等。对于账户中的每两条历史记录(指包含存和取的“两腿交易”),都能够对应一个交易,后者用于记录价值流动的细节。如果问题域存在多腿交易,例如一次交易超过了两个参与方,则允许一个交易引用多条历史记录。从模型的角度看,两腿交易属于多腿交易的一种特殊情况,因此后者的灵活性更好。如下图所示:

Multilegged transaction

如果同时拥有多个账户,那么通常会需要额外创建一种总结账户,后者被用于汇总其它细节账户,因此这里可以抽取出账户的一个更高抽象层级表示。在税务领域中还存在另一种账户类型——备忘录账户,这种账户并非记录当前的实际价值,而是额外存放一份“应税价值”,优点是能实时监测某种价值的累积和变更情况,从而为领域添加更好的可预测性,例如方便进行年度报税。为了保证备忘录账户能够被及时更新,通常在账户中会创建一个触发器,每当一个新的记录被创建,则根据触发规则(Posting rule)创建另一条记录,后者即应税价值。如果创建新记录需要比较复杂的计算,则需要给触发规则关联一个额外的计算对象。值得注意的是,触发规则通常是可逆的,同时由于规则本身包含了交易细节,备忘录账户中的历史记录也就无须保存相应的交易记录。

不同的账户可能具有不同的触发规则,一种方法是把触发规则与账户类型相关联,如下图所示:

Posting rule

该模型体现了良好的概念表示,实则兼有利弊。因为其基本假设是账户类型与触发规则应存在某种强关联性,否则反而会使情况更加复杂。一个替代做法是直接把触发规则关联至某个总结账户,这样灵活性就更好,但在概念表现上则比较隐晦。

会计的实质就是在触发规则的基础上,构建一个多账户网络。这就需要能随时获取指定类型的账户和指定类型的触发规则记录。一般情况下,如果实体具有多个账户,那么该实体会附带一个相应的会计操作。如果问题域中具有极其复杂的会计操作,例如不同类型的触发规则对应不同的会计操作,那么可以通过添加会计操作类型的概念对逻辑进行合理分配。如下图所示:

Accounting practice

基于上述模型的历史记录具有更强的追溯性,例如每条记录可以查找对应的源——交易,然后根据交易查询其触发规则。同时还能根据问题域创建对应的资产负债表(Balance sheet)账户和损益表(Income statement)账户,前者记录当前实际价值,例如资产总额;后者则记录每笔交易的具体流向。这两种账户虽然具有不完全相同的历史记录,但实际是一个概念的不同方面,被称作相关账户。相关性可能存在于多个账户之间,且具有对称性和传递性的特征。

针对一条记录可能存在于多个账户的场景,还需要注意账户间关联应遵循有向无环图(Directed acyclic graph, DAG),从而保证正确性。如果问题域要求更多的记录类型,可以采用前文讨论的备忘录账户,也可以创建子类型账户。前者具有简单的模型,适合满足简单的报表类需求,对于更加复杂的场景,就需要采用后者负责针对特定记录类型的操作。

  • 计划(Planning),该模式用于表示计划,包括制定和追踪两部分内容。任何计划都可以被看作由若干基本动作(Action)组成,与计划强调整体性不同的是,动作可以拥有不同的粒度,一个简单的动作可以包含人物、时间和地点等信息。

动作通常是状态化的,也可能因为不同状态表现出不同的行为,其中最重要的两个状态是建议实施,前者是指动作已经被创建,并且可能已经被赋予了某些资源;后者指动作已经开始执行。虽然同时指向一个真实世界的动作,但是建议和实施实际上可能具有很大的区别,因此,通常不使用关联的动作值/对象表示这两种状态,而是通过创建动作的两个子类型做彻底区分,同时同一个动作的两个子类型之间保持关联,如下图所示:

Proposed and implemented action

类似地,如果某个动作处于完成状态,但由于完成的动作可能仍需要追溯性,通常可以将其作为实施动作的一个特例。在某些问题域中需要丢弃已经完成的动作,于是需要丢弃状态,丢弃也可能是在实施之前发生,因此丢弃动作应当是独立于建议和实施的动作。如果问题域允许暂停一个动作,那么就需要给动作关联一个暂停对象,该对象携带一个有效期信息,当时间位于该有效期内时,动作将被挂起直到暂停有效期结束。

最基本的计划即一组建议动作的有序集合,这种顺序需要给动作添加依赖保证。当具有多个计划时,一个动作也可能同时位于多个计划中。如果动作允许嵌套,那么计划也可以被看作是一种特殊的动作。

通常企业的运行过程可以用通用的动作进行表示,其中动作和计划位于运行等级,我们把位于知识等级的部分称作协议,其结构如下图所示:

Structure for protocol

动作通常是基于协议创建的,最简单的方法是根据协议创建关联的建议动作,也可以在更高层级创建等同于计划的建议动作。如下图所示:

Relationship of plan, action and protocol

我们知道动作在实施前需要分配资源。资源可以被划分为两种基本类型,例如药品、针头、源材料等属于消费型资源,又如设备、房间、人等属于资产型资源。每种资源类型都具备对应的分配方式,这些分配方式具有统一的资源分配接口。

计划是通过观察启动的,后者是基于假设和投影的结果。因此计划的输出结果也应该是观察的形式。与计划类似,观察也是动作的一种子类型,并且可以作为计划的一部分。前述两种观察在知识等级可以采用启动函数和输出函数表示,前者用于触发协议,后者用于把输入的协议和观察组合进行转换并以两个观察概念集合输出:一个表示协议的使用目标,另一个记录边际效应。

  • 交易(Trading),指在变化的市场条件下销售和购买商品的模式。合约是最基本的交易形式,具体的实现手段包括股票、商品、外汇等。例如大多数市场都以货币为交易手段,于是交易价格就以货币方式表示。交易的两种类型做多(Long)和做空(Short)分别代表买入和卖出,一个基本的交易模型如下图所示:

Contract

银行的风控系统通常以交易组合(Portfolio)为单位监控异常交易,交易组合一般拥有采用合约构建其自身的方法,该方法通过合约选择器筛选出合约,并保存在自身记录中。交易组合可以是短期或长期的,短期交易组合是按需创建,使用完成后丢弃;而长期交易组合能存在较长一段时间,因此当新的交易产生时,系统会将其与所有的长期交易组合进行匹配,并添加至满足条件的长期交易组合。

任何金融市场的交易都有报价(Quote),多数情况下“报价”其实包含两个值——买入价和卖出价,这被称为双向价格(Two way pricing)。相应地,在某些问题域中也存在单向价格(One way pricing),后者可以被看作是双向价格的一种特殊情况。为了同时支持这两种报价类型,需要构建一个更高抽象层次的报价类型,如下图所示:

Quote

如果问题域中单向价格和双向价格的差别非常大,那么抽象类型反而会造成很多局限性,此时就需要对两种价格类型分别建模。

市场中的报价是瞬息万变的,因此报价通常会携带时刻信息。如果想查询市场上所有股票的价格,需要抽取每只股票截至某个时刻的最后一次报价,然后把这些报价添加至一个集合——场景(Scenario),即表示市场在某一时刻的状态。从满足需求的角度来说,直接从全部报价中根据时刻抽取相应报价,即可方便获得市场上的公开价格。但场景是对某个时刻市场价格的抽象表示,可以方便后续的价格查询、分析和比较等。问题域在外汇市场变得更加复杂,由于汇率不再是由单一来源发布,因此需要额外添加一个实体作为价格发布者,如下图所示:

Party and scenario

  • 交易包(Trading package),一种把较大的领域模型拆分为更多小型领域模型的模式。通常指一个独立且具有一定规模的软件模块,例如在OO中包可以是一些类的集合,其内部可见性也表明了包的内聚性。包之间一般存在多重访问等级,例如在交易领域中,交易组合会把市场指标作为其描述,场景则为市场指标提供价格计算,交易组合与合约通过场景对自身进行定价,但反过来场景并不需要依赖交易组合或合约。这种包之间的可见性如下图所示:

Package visibility

进一步,交易组合需要从市场指标中获取价格,但并不需要了解场景是如何被创建的。因此虽然之前我们讨论了包之间存在可见性的必要性,但这种可见性必须是可控的。一种解决办法是通过额外的接口实现包之间的依赖,然后针对这些接口创建新的包,从而实现更细粒度的包可见性依赖。这种方法的缺点是包的概念和结构变得更加复杂,进而引发可维护性问题。另一种办法是在现有概念模型的基础上引入表示应用逻辑和表现层的组件,例如创建一个新的风险管理应用包和场景管理应用包,前者负责沟通交易组合与场景,后者专门负责创建场景,如下图所示:

Application package

注意在上图中还有一个新的包“场景结构”,其目的是为了保证场景包对外提供统一的接口,从而将部分职责从原始场景包中进行了分离。

如果一个包是另一个包的子类型,那么不应出现后者依赖前者的情况,否则会损害子类型的可扩展性。包有时还会出现互相依赖的问题,进而导致过高的耦合性,这时需要考虑把这种双向依赖转变为单向依赖,因为后者的耦合度更低,但也可能会增加使用上的复杂度。例如对于实体和合约来说,实体有时需要知道与之相关的合约,反之合约也需要知道参与的实体。对此如果要消除双向依赖,就必须消除某个方向的依赖关系。但是无论是消除任一种关系,都会造成某些客户端应用的复杂度增加的问题。除此之外还可以选择把两个包合并,但也可能引入不恰当的可见性问题。因此对这种互相依赖的解决需要考虑更多上下文进行综合判断。

结论

模式是对实践经验的积累和发散,真实世界的复杂性导致了模式只能被发现而不能被发明出来,也因此很难避免其在面临复杂问题域时的局限性。然而,与后续会讨论的其它类型的模式相比,分析模式其实具备更高的灵活性和适用性,特别是比形式化的模式表述更加接近真实的思维方式。

引用

RPD90, Domain analysis: introduction

JMN80, Software Construction Using Components

DCH96, Data model patterns: conventions of thought

MFR96, Analysis patterns

Comments

软件设计与架构笔记(9)

面向对象——分析与设计

分析(Analysis)与设计(Design)是软件开发过程中的两种重要活动。分析研究需求和问题本身,决定“做正确的事”;设计则侧重于提出概念性解决方案,决定“正确地做事”[CLA01]。在软件工程实践中,分析主要围绕需求进行,这里既包括功能需求,也包括非功能需求;设计则围绕具体实现进行,例如计划设计、技术设计、用户体验设计、测试设计、运维设计等。面向对象建模技术在上世纪90年代中期的兴起,使其被广泛应用于软件的分析和设计过程,逐渐成为企业级开发领域的主宰范式。

面向对象分析(OOA)

OOA的目的是识别和描述问题域中的概念和对象。问题域通常以软件需求为主要的呈现形式,其中包括了功能需求和非功能需求。前者描述面向用户的软件行为,后者则包括了其它类型的需求,例如可用性、可靠性、性能和可支持性(可维护性)等,此类软件属性也被称作软件的质量属性(Quality attributes)。功能需求可通过用例模型描绘出更多细节,非功能需求则作为前者的补充性规格说明进行呈现。如何恰当地描述用例可以进一步参考[ACB99],本文后续部分假设用例和非功能需求已经被有效地描述,因为这是进行面向对象分析的重要前提。

用例建模(Use case modeling)

当具有初步的用例描述时,可以采用用例模型对问题域进一步分析,从而整理出系统事件(System event)及其相应的输入和输出,为之后的逻辑设计建立基础。构建用例模型的过程被称为用例建模,可用的工具和方法包括用例图系统时序图系统契约等。

  • 用例图实现了对基于文本描述用例的可视化表示,使其在概念级别更易于理解和沟通,通常采用UML用例图作为工具。

  • 系统时序图与UML的时序图类似,不同之处在于前者把整个系统作为黑盒,主要描述系统的外部交互过程。这些交互可以是位于用户与系统间,也可以位于不同系统之间。在系统时序图中,系统事件是系统与外部交互的唯一方式,而为了描述系统事件则需首先确定系统边界(System boundary),即系统的功能集合。系统时序图着重于描述系统事件类型、参数、发生顺序和其它关键特性。

  • 系统契约能够进一步描述系统事件所引起的领域模型中对象的状态变化规则,相当于系统级别的设计契约,针对后者的讨论可参考下文面向对象设计一节的设计契约部分。

用例模型是需求分析的重要交付物,其实质是针对领域的过程化描述,但这种形式不足以启发后续的面向对象设计,因此需要进一步建立接近面向对象概念的领域模型,针对后者的构建方法就是接下来要讨论的领域建模

领域建模(Domain modeling)

领域模型描述了问题域中的概念类(Conceptual class),概念类与面向对象中的类有一定相似性,但与设计和实现阶段创建的类没有必然的对应关系。领域模型有时也被称作概念模型、领域对象模型或分析对象模型,其通常应包含如下信息:

  • 领域对象或概念类。

  • 概念类之间的关联关系。

  • 概念类的属性。

一个采用UML类图描述的领域模型例子如下图所示:

Domain model

领域建模首先需要识别概念类。[MO95]提出了概念类的三要素:1.符号(Symbol),用于表示概念类的文字或图像信息;2.含义(Intension),概念类的定义;3.扩展(Extension),描述所有同属于该概念类的案例集合。例如,概念类Sale应包含以下信息:1.表示概念类的符号“Sale”;2.其含义是一次购买交易的事件,包括了date和time两个附加属性;3.扩展即是所有销售的案例集合。对概念类Sale的三要素进行可视化的例子如下图所示:

Conceptual classes

在复杂的问题域中,为了识别出所有概念类,需要对领域进行实体分解。一般方法是从用例描述中的名词短语直接提取概念类,但是受限于自然语言的随意性,把任何提取出的词汇都映射为概念类极有可能出错。除了进一步澄清所提取的词汇外,还可以借助概念类分类表(Conceptual class category list),后者是对特定领域中常用概念类的一种分类形式,可用于对照用例描述中的词汇获取预设的概念类。例如在商场中,一次交易可以包含Sale或者Payment等概念,而在机场可能还涉及Reservation等概念。通过上述方法可以获取候选的概念类列表,例如在表示交易的领域模型中,我们可以提取出Store、Register和Sale等作为候选概念类列表。需知候选概念类列表并非是唯一确定的,其具体的覆盖范围应结合上下文综合考虑。

下面讨论领域建模的一般步骤。

  1. 结合上下文列出候选概念类列表。

  2. 采用领域模型对概念类进行可视化表示,即UML类图的最基本形式。

  3. 添加概念类之间的关系记录。作为领域模型的重要信息之一,关联关系有助于深入理解领域模型,下文会做进一步讨论。

  4. 添加概念类内部的属性记录。领域模型中的属性表示概念类中需要被记录的信息,其既可以是由用例描述中显式给出的,也可以是间接获取的。属性通常包含属性名和类型两个部分,其中属性类型可以是原始类型(Primitive type)或值对象(Value object)。此外,领域模型的属性应当排除外键属性的情况,后者应在设计阶段再予以考虑。

关联是指概念类之间的、持续一定时长的关系,这里主要是为了强调关联的时间概念,用于和瞬间性关系进行区别。领域模型中的关系主要包含以下两类:1.须知型关系,即用例中明确指出的关联关系,属于领域模型的核心内容;2.理解型关系,即用例未显式说明但实际有助于理解领域模型的关联关系。

与概念类的三要素类似,关联也具有三个重要组成部分,分别是关联名字(Name)、多重性(Multiplicity)和导航性(Navigability)。其中关系命名可以遵照TypeName-VerbPhrase-TypeName的规则,在UML类图中体现为由上至下、由左至右的顺序;多重性与UML类图的概念基本一致;导航性是指关联关系在其两端概念类各自的可见性,例如可以是单向关联或是双向关联。

与提取概念类相似,关联关系可以通过从用例描述中提取动词短语发现,也可以对照通用关联关系表(Common Associations List)——一种领域特定的常见关联关系分类表。虽然在真实场景中可能存在非常多的概念类间关系,领域模型中仅需要表示重要关系,具体应依用例描述和上下文而定。

一个完整的领域模型例子如下图所示:

A complete domain model

基于彩色UML的通用领域建模模式

当面对复杂问题域或特殊上下文时,朴素的领域建模方法会面临挑战。为了控制风险,常见解决办法是在分析过程中应用模式(Pattern)。模式是一种经过验证的经验性方法,且能够满足大多数的软件开发需求。领域建模的模式可以是基于特定领域的,也可以不限领域——即所谓的通用模式。本文不会就模式本身或其它类型的领域建模模式展开,下面仅讨论一种较为通用的领域建模模式。

[CDL99]描述了一种基于彩色UML的通用领域建模模式。该模式的作者从数百种领域模型中总结出了一种领域中立组件(Domain-neutral component)模型,该模型可以被应用至大部分的领域建模过程,从而得到理想的领域模型。领域中立组件模型由下面四种最基本的原型(Archetype)组成:

  • 时刻或时段(Moment-interval)原型,表示在某个时刻或时段内出于业务或合法性原因需要追踪的事物。例如一次销售在某个时刻发生,那么这次销售就有日期和时间;一次租赁发生在某个时间段,于是有起始时间和终止时间;一次销售甚至可以发生在某个时间段,从而允许对整个销售过程的效率进行评估。采用UML类图表示如下:

Moment-interval

  • 角色(Role)原型,表示参与者、地点或事物(统称实体或Player)的参与方式。同一个实体可能扮演了多个角色,同一个角色也可能由多个实体扮演。实体自身拥有与角色无关的核心属性和行为,但有时也会具有跨角色的接口方法,用UML表示如下:

Role

  • 参与者、地点或事物(Party, place or thing)原型,表示问题域中的人、组织、地点或者事物,即实体。

  • 描述(Description)原型,这种原型与目录项类似(Catalog-entry-like),表示反复出现的值的集合以及这些集合项所共有的行为。例如一辆汽车拥有序列号、购买日期、颜色、里程表等多种信息,其中与目录项类似的描述主要是指车辆描述,例如制造商、型号、生产日期以及可选颜色等。

为了在UML中描述前述原型,一般可以采用UML中的构造型(Stereotype)工具,但是当复杂性进一步增加时这种方式就不够直观,进而妨碍理解和后续建模。[CDL99]给UML添加了一个新的视觉维度,即采用粉-黄-绿-蓝四种颜色分别代表上述四种原型,颜色体现了原型的重要性程度,下图描述了原型、颜色及其相互关联:

Archetypes and their colors

每个原型在属性、链接、方法、插入点和交互等方面具有相应特征:

时刻或时段原型是领域模型的核心,该原型的对象通常用于封装最有价值的内容,包含序号、日期、时刻或时段、优先级、总数和状态等属性。该对象方法包括创建支持业务流程的对象、添加细节、计算总数、完成或取消时刻或时段对象、以及访问其它同类对象,例如获取同类对象列表、计算平均时刻或时段等。当某些业务流程较复杂时,需要引入插入点使其更加适应变化。

角色原型是第二重要的部分,该原型的对象包含序号、状态等属性。角色原型对象通常会链接到对应的时刻或时段对象。该对象方法包括确定相应实体的可用性、提供业务值或性能估算,且都需要和对应的时刻或时段对象交互。

接下来是实体原型,该原型的对象通常作为角色对象的容器,包含序号、地址和自定义值等属性。实体对象通常会链接到角色对象。该对象方法包括查询可用性(自身状态或角色对象上的状态)、获取自定义值(当其不可用时则从对应的描述对象获取默认值)以及提供业务值或性能估算(与对应的角色对象交互)。

最后是描述原型,该原型的对象包含类型、描述、编号和默认值等属性。描述对象通常会链接到实体对象,但某些时候也会直接链接到时刻或时段对象。该对象方法包括查询可用的实体、计算可用实体的数量,这些方法都需要和对应的实体对象交互。当对象具备复杂算法的行为时,需要引入一个支持提供替代行为的插入点。

在领域建模中,根据所属原型的特征定义概念类的职责,从而使原本偏向静态表示的UML类图进一步表达了类似时序图或协作图的动态交互信息,这也是彩色UML工具的一大优势。此外,[CDL99]借助领域中立组件模式构建了12种领域特定组件(Domain-specific component),经验表明这些领域特定组件能够直接应用于大多数领域建模过程。在面临复杂系统时,可以通过恰当的领域特定模型描述系统中不同的子概念,然后基于是否需要可扩展性,采用直接连接或插入点连接实现组件间联通,从而组装成最终的领域模型。

面向对象设计(OOD)

OOA通常要求领域专家、需求分析和技术专家共同参与,体现的是团队对问题域的一致性描述,也就是本文最开始提到的“做正确的软件”。为了“正确的做软件”,OOD更多需要技术团队的更多参与,进一步描述实现中所需对象及其协作的定义。与OOA不同的是,OOD更关注实现细节,例如创建与实际编码中对应的对象、定义对象行为以及对象间的交互等。在过去数十年中,一系列工具、方法、原则、模式等被提出和应用至OOD,限于篇幅本文不可能覆盖前述所有主题,下面仅讨论具有代表性的工具和方法。

GRASP:基于通用职责分配软件模式的面向对象设计

[CLA01]提出了一种基于通用职责分配软件模式(General Responsibility Assignment Software Patterns, GRASP)的OOD方法,该方法被用于生成可供参考实现的设计模型(Design Model)。其基本思路是从用例模型和领域模型出发创建交互图,采用GRASP模式对交互图进行不断优化,并随时补全所需的类图,最终得到的交互图和类图就是设计模型的主要内容。前文已经介绍过时序图和通信图,这里不再赘述。下面讨论GRASP模式及其在设计中的应用。

在UML中,职责是指契约或者义务,这些通常是通过对象的行为体现出来的。职责包含以下两种类型:

  • 行为型(Doing)责任,例如自身行为、驱动其它对象的行为以及在其它对象中起到控制和协调作用的行为。

  • 知识型(Knowing)责任,例如自身封装的数据和功能以及与自身相关的其它对象。

在领域模型中,概念类的职责通常是显式的。但在实际设计中,来自领域模型的职责需要被分配至不同粒度的类或方法。例如“创建一个销售”这类职责可能只需要一两个方法,但是“提供关系数据库的连接”这类职责就可能需要更多类和方法了。为了完成某项职责,对象可以仅通过自身方法完成,也可能依赖其它对象的方法,因此可以认为OOD的本质就是把职责分配到不同的对象中,这也是GRASP的核心思想。

由职责的定义可知,最能完整体现职责的UML工具是交互图,因为后者同时包含了对象、方法和交互信息,这也是从交互图出发进行OOD的原因。GRASP为职责分配提供了一系列指南,以下用其中5个代表性条目为例:

  • 信息专家(Information Expert),指如果某个对象具有满足职责的必要信息,那么职责就分配给该对象。同时满足该职责所需的信息可能保存在不同对象中,那么就需要把职责分配至不同的对象,并通过对象协作完成整个职责。

  • 创造者(Creator),指如果对象A的职责是创建对象B,那么需要满足A对B的紧密包含关系,例如组合、聚合、强使用关联、强数据关联等场景。

  • 高内聚(High Cohesion),指职责分配应保证高内聚,低内聚意味着对象包含了许多无关或者过多的职责,从而影响可理解性、可复用性、可维护性等方面,同时极易被外部影响而发生改动。

  • 低耦合(Low Coupling),指职责分配应保证低耦合,强耦合意味着对象更易受到外部变的影响,低可理解性和低可复用性。

  • 控制器(Controller),指接收和处理系统消息的职责应分配给具备以下条件的对象:1.代表整个子系统;2.代表某个用例场景。前者相当于系统的外观控制器(Facade Controller),后者则只表示一个事件处理器(Handler)或协调器(Coordinator)。

基于GRASP的设计过程相当于把职责从领域模型转移到设计模型,优先从设计模型中选择已有的类,如果没有该类再从领域模型中抽取概念类,直到满足全部用例。

类职责协作卡(Class-responsibility-collaboration card, CRC)

目前为止,UML在OOA和OOD中都占据了主流地位,但是我们仅采用了其中不到5%的内容。同时,UML及相关的计算机辅助软件工程(Computer-aided software engineering, CASE)工具,使OOD在纸面上花费了大量时间,却难以应付软件工程中出现的各种变化。强调团队协作是应对变化的重要手段之一,因此OOD需要与之相适应的轻量级工具,其中有代表性的就是本节将讨论的类职责协作卡(CRC)。

CRC是一种专注于描述类的职责分配和对象协作的轻量级OOD工具,其最初被用于针对团队的OOD培训并取得了良好效果[BC89],后来因其面向团队协作的特点,被广泛应用至协作式的OOD活动。下图展示了CRC的基本格式:

CRC

CRC的形式很容易理解,也比UML的交互图更加轻便,但其可表达的信息类型数和精确性不如后者,其优势在于团队协作设计。基于CRC的团队协作设计流程大致如下:

  1. 集合团队,首先解释当前用例及其具体场景描述。

  2. 从场景中提取相应职责,并将其分配给合适的CRC卡(即类)。

  3. 确定该职责是否需要协作类,并且移动卡片使相互关联的类挨在一起。

  4. 如果存在替代方案,可以快速创建新的卡片,并将其和原方案放在一起比较,经过团队讨论做出进一步设计决定。

应当注意,上述活动中除了步骤1之外,都离不开OOD原则和模式的指导。经过准确解释和高效协作,CRC能够帮助团队快速理解上下文并就OOD达成一致的方案。当然,CRC的成功应用离不开一系列敏捷实践的共同作用,否则很容易导致“欲速则不达”的后果。

设计契约(Design by contract)

随着OOD/OOP在软件设计和开发中流行,如何保证对象间协作的可靠性逐渐受到重视。软件的可靠性包含了正确性和鲁棒性(或者可直观表达为bug的数量)。我们知道,检验软件本身及其设计的正确性是非常困难的。因此,在设计和开发阶段采取措施是加强可靠性的重要手段。例如采用防御式编程(Defensive programming),即意味着在对象方法的执行过程中做大量检查,例如参数合法性、状态合法性、返回结果合法性等。但是,防御式编程会引入大量冗余且复杂的代码,进而降低软件的可维护性,也不利于维持可靠性。[BM92]提出了设计契约的概念,通过在面向对象的类和方法级别定义合适的契约,从而在设计阶段就引入可靠性保障,该思想同时被引入到实现过程中,成为软件开发的重要技巧之一。

断言(Assertion)是设计契约的核心,断言要求对应的布尔表达式返回值必须为True,否则即意味着异常和潜在bug并且立即报错。同时,断言应当仅被用于表示OOD的契约,而非具体用例场景,因此其只能被用于debug和测试场景。OOD的设计契约包含三种类型的断言:

  • 前提条件(Pre-condition),指某个方法执行前的期望,例如检查输入参数是否合法。采用断言作为前置条件,即意味着输入合法性应实际由方法的调用者保证。而断言的引入则确保在debug和测试阶段验证调用者是否实现了正确的参数检查。

  • 后置条件(Post-condition),指某个方法执行后的期望。与方法体本身不同的是,后置条件语句侧重于描述执行结果,而非前者的过程。

  • 不变量(Invariant),指某个类本身的断言。不变量应当对类的所有对象都始终为True,也就是说,不变量实际上是所有公共方法的默认前提条件和后置条件。

面向对象中的继承给断言带来了一定的复杂性。对于不变量和后置条件来说,子类只能选择强化父类中的断言或维持不变;但是对前提条件而言,子类只能选择弱化父类断言或维持不变。前述约束是由继承引入的动态绑定特性所带来的[MFR03]。

结论

本文所讨论的OOA和OOD分别对应了不同的动机、方法论和评价方式。工具和方法离不开原则和模式的指导,原则和模式在工具和方法的帮助下实现了解释和落地,这些设计元素已然形成了有机整体。前述这些方法论极大促进了软件分析和设计朝着“两个正确”的方向前进。另一方面,以80年代末SEI发起的CMM(Capability Maturity Model)和90年代中期UML的标准化为标志,软件设计乃至整个软件工程开始迈向标准化时代。但是,看似科学的设计和过程随即受到互联网时代巨大变化的冲击,敏捷的思想就是在这种背景下诞生和发展起来的。例如,敏捷强调良好的沟通和协作胜于功能强大的工具,在OOD中正如UML这类大杀器,但也有更轻量的CRC,因此后者被更多用于敏捷软件设计活动。无论如何,在可以预见的未来,业界在“两个正确”的方向上仍有很长一段路要走。

引用

CLA01, Applying UML and Patterns: An Introduction to Object-Oriented Analysis and Design and the Unified Process

ACB99, Writing Effective Use Cases

MO95, Object-oriented methods: a foundation

CDL99, Java Modeling in Color with UML

BC89, A Laboratory For Teaching Object-Oriented Thinking

BM92, Applying “Design by Contract”

MFR03, UML Distilled

Comments

软件设计与架构笔记(8)

面向对象——概念与建模

由前文编程范式我们知道,对象作为最基础的编程概念广泛存在于各类编程范式中,采用这种范式的语言被称为基于对象语言(Object based language)。本文讨论的面向对象(Object oriented)的概念则首次出现在1967年诞生的Simula 67,其中除了对象概念本身外,还进一步提出了继承多态这两个重要特性,成为此后长期影响学术界的语言之一。而面向对象在工业界的兴起则始于上世纪80年代,以Smalltalk和C++的相继发明为标志,深刻影响了此后近四十年的软件工程。

基本概念

继承是区分面向对象和基于对象的重要特征,大部分面向对象语言的继承是采用(Class)实现的(注意“类”和“对象”是完全不同的编程概念)。我们知道类可被看作是能够创建对象的工厂对象,继承则允许增量式地进行对象扩展。因此,类作为数据抽象的核心,在其基础上实现继承就自然地支持增量式的数据抽象。除了类之外,面向对象还支持一种基于原型(Prototype)的特殊实现,后者通常并不包含类定义,任何对象都能够唯一地指定另一个对象作为其原型,其中对象的属性和事件沿原型链传递查找,从而实现继承。从支持增量式对象扩展的角度看,类继承和原型继承没有本质区别。下面讨论在面向对象语境下的一些重要概念。

  • 类。类是一个可能同时包含部分具体实现的抽象数据类型[BMR97],其被用于描述一组存在于内存中且可直接被用于计算的实例。类的特点在于其同时扮演了模块类型两种角色。其中模块作为一种语法概念,通常被用于表示软件分解单元,而类型则被用于动态对象的静态描述,相当于一种语义概念。在非面向对象模式中,上述两种概念通常是被分开表示的。

在某些面向对象系统中(例如Smalltalk和Ruby),类自身也可能是通过对象实现的,这种实例依然是其本身的类被称作元类(Metaclass),面向对象语言或编程环境的作者可以利用元类方便地实现某些动态扩展特性,例如Ruby的单例类

  • 对象。对象是指某个类的运行时实例[BMR97]。

虽然“对象”一词最初来源于对真实世界物体的描述,但在实际编程场景中对象已经不仅仅被用于描述真实物体,例如用于描述一组配置属性等因技术需求而诞生的对象。对象是通过引用(Reference)进行表示的,并且可以被自身或其它对象引用。一个对象引用唯一地指向了该对象唯一且不变的标识(Identity)。对象标识是用于区分不同对象的唯一凭证。

对象的创建过程通常是在面向对象系统中默认定义的,一般包括分配内存空间和初始化两个步骤,前者由系统自身负责,后者允许在程序中自定义初始化过程。以Java的面向对象系统为例,类支持以构造函数(Constructor)定义初始化过程,如果某个类没有包含显式的构造函数,编译器会自动加入一个默认的无参构造函数并在其中调用父类的无参构造函数。

  • 组合与聚合。如果采用引用表示对象,那么对象之间就可以通过引用产生关联(Association)。但是仅使用引用不足以描述对象间真实的关系特征,从而无法满足忠实建模(Faithful modeling)。组合(Composition)关系是指一个对象包含了另一个对象的值,这种关系超过了一般引用的定义,特别是指被包含对象的生命周期被限制在其父对象内。聚合(Aggregation)关系指一个对象由另外多个对象组成,其组成关系通过引用实现。与组合的区别在于,聚合中被包含对象的生命周期不受父对象限制。

从面向对象中对关系分类的角度看,组合与聚合可以统一被看作客户(Client)关系,其实质是对对象间关系的描述。

  • 继承。继承描述了一种类之间的扩展关系。在面向对象中,类本身具备良好的模块化特性,从而能够满足信息隐藏的原则,但模块化并不直接提供增量式设计和开发的途径。继承支持了类之间的扩展、特化和组成关系,显著增强了面向对象的可重用性和可扩展性。其中包括四个重要的衍生概念:

    • 重定义(Redefinition),指子类能够重新实现父类中定义的过程,有时也称作覆盖(Override)。
    • 多态(Polymorphism),指一个变量实体或数据结构元素,在具备可控静态声明的前提下能够在运行时阶段绑定至不同类型的对象。例如当类A继承类B,那么类A的对象a,可以被赋于类型为B的变量b,且并不违反类型检查。
    • 静态类型(Static typing),前面提到多态的前提是具备“可控静态声明”,具体是指对于一个变量的声明类型(也称变量的静态类型),尽管允许其被赋予不同类型(也称变量的动态类型),但其动态类型必须是静态类型的后代(后代定义包含其自身)。
    • 动态绑定(Dynamic binding),是指变量所指向对象的动态类型决定被调用操作的具体位置。

本质上,继承同时包含了两个角色且互有重叠:模块和类型。从模块的角度看,继承提供了一种有效的可重用特性。这里提到的有效是指当类A继承类B时,A就立即拥有了B的全部特性,且无须进行任何改动。从类型的角度看,继承同时增强了可重用性和可扩展性,这主要体现在: 1.对于类Rectangle继承类Polygon,即Rectangle的全部实例同时也是Polygon全部实例的子集。2.对于类A继承类B,那么B的任意实例所具有的操作也同时存在于A。在许多文献中,继承也被称作is-a关系。

  • 多重继承。在真实世界中,对象可能同时包含了不同领域的抽象,对应在面向对象中就是多重继承,即一个类可以同时拥有多个父类。例如,类Teacher和类Student都继承了类UniversityPerson,这时需要一个类TeachingAssistant且同时继承自类Teacher和类Student,也就是说通过两个父类间接继承了类UniversityPerson,即重复继承。重复继承可能会造成一定的函数访问冲突,特别是当调用类TeachingAssistant的对象的name函数,而其实际上位于类UniversityPerson时,系统就面临多重函数查找路径的问题。而由此引发的复杂性使得多重继承在许多现代面向对象实践中被认为弊大于利,且不被推荐使用。但不可否认,多重继承实际上体现了真实世界原本的复杂性。

在支持多重继承的面向对象系统中,通常采用复制(Replication)和共享(Sharing)两个策略解决前述重复继承问题。复制是指当遇到重复继承时,子类实际上包含了所有继承路径上属性和函数的副本,程序这时需要具体指定被调用副本的名称。共享是指程序可以指定在子类中只保留一份来自祖先的副本,这样就避免了名字冲突的问题,例如C++的虚继承(Virtual inheritance)。

许多现代面向对象语言不支持多重继承,但同时为了保留一定的设计能力大多采用了折衷方案,例如Java的接口(Interface)、Ruby的混入(Mixin)等。

  • 泛型。继承本质上体现了一种纵向的层级扩展关系,由上至下可以被看作是面向对象从抽象化到特化。而泛型(Genericity)则支持横向的同级扩展关系,即类型参数化。

泛型最经典的案例就是编程语言标准库中常见的容器类,例如Set、List、Map等,这些容器类实际上是参数化的抽象数据类型,其本身包含了抽象数据类型中的具体操作,并藉由客户代码通过指定参数决定具体的元素类型。由于历史的原因,许多现代编程语言中虽然提供了泛型特性,但其实现原理差别巨大。例如C++的模板能完整地重新编译目标代码,最终根据模板参数生成不同的函数和类;而Java的泛型实际上是一种为了增强代码类型安全的语法特性,编译器对泛型语法进行类型检查,并最终通过类型擦除(Type erasure)生成无类型参数的目标代码;C#的泛型实现则介于C++的灵活和Java的简易之间,通过运行时实化(Reification)进行类型检查和具体操作,从而避免了类型擦除的缺点(例如无法实现泛型数组),同时把类型参数保留在运行时,从而满足在泛型条件下支持反射。

面向对象建模(Object Oriented Modeling)

从上世纪80年代起,随着工业界对面向对象语言从逐渐认识到深入实践,面向对象开始代替传统的结构化方法成为主宰范式。但是,基于面向对象的软件开发很快就面临了更多数量的对象以及更加复杂的关系,实现系统设计也变得空前复杂。作为OO的早期布道者之一,Grady Booch等分别在面向对象的基本概念基础上发展出了一系列全新的建模方法,被统称为面向对象建模

统一建模语言(Unified Modeling Language, UML)

1997年,Three amigos在Grady Booch的Booch method、James Rumbaugh的Object modeling technique(OMT)以及Ivar Jacobson的Object oriented software engineering(OOSE)的基础上,正式发布了UML 1.1。UML是一种编程语言无关的通用建模技术,旨在采用统一语言对复杂问题的不同关注点进行建模。

UML由图形标记(Graphical notations)和元模型(Meta-model)组成。其中图形标记定义了相关概念的图形表示法,也是UML建模的主要工具。元模型是一种对UML模型的形式化表示方法,用于对UML规格说明的精确表达。后者常被用于构建基于UML的计算机辅助软件工程(Computer Aided Software Engineering, CASE)系统。这里只讨论UML的图形标记方法。

下图展示了UML 2.5.1的图形标记分类[UML17]。其中结构图(Structure diagrams)用于代表系统中对象的静态结构,通常能够表示系统的核心概念及行为定义,但并不包括行为的动态细节。行为图(Behavior diagrams)表示系统中的动态行为,包括方法、协作、活动和状态历史记录等。

The taxonomy of UML diagrams

随着UML被纳入国际标准,其复杂度不断增加,即使由对象管理组织(Object Management Group, OMG)定义的狭义UML规范也已经十分臃肿,使其逐渐脱离了日常的软件设计实践。但不可否认,UML的核心内容依然在面向对象建模中占据权威地位。为了保证UML的实用性,本文接下来只讨论核心的图形标记[MFR03]。

  • 类图(Class diagrams),表示系统中的对象类型及其相互之间的静态关系。如下图所示:

Class diagram

在上图中,每个方框表示类的属性和操作,方框间的实线表示类的相互关系,在每种关系上还标记了两个类之间的多重关系(Multiplicity)。关系是UML中最复杂的概念之一,甚至允许通过构造型(Stereotype)对关系进一步扩充。UML的基本关系种类包括:1. 关联(Association),指类之间的持续性关系,例如类的属性类型,关联采用实线表示,并且可以是无向、单向和双向,带方向的关联进一步揭示了源类中包含以目标类作为类型的属性;2. 依赖(Dependency),指一个元素(Supplier)的变更可能引起另一个元素(Client)的变更,依赖采用单向的虚线表示;3. 泛化(Generalization),表示类之间的类型层级关系,例如继承采用带三角箭头的实线表示,如果目标类是接口类,那么采用带三角箭头的虚线表示,抽象类需要额外使用斜体表示。

从上述关系的定义来看,依赖是含义最广泛的关系,也是面向对象建模中需要仔细考虑的问题。过多的依赖路径会导致修改时发生涟漪效应(Ripple effect),从而降低系统的可维护性。关联进一步包含了前文讨论的组合和聚合关系,其中组合使用一端实心菱形和单向箭头的实线表示,聚合采用一端空心菱形和单向箭头的实线表示,分别如下图所示:

Aggregation

Composition

UML中的大部分工具实质上都是为了设置约束,但仅通过图形标记无法表示所有类型的约束,因此UML支持使用{}表示自定义的约束。自定义约束的具体形式没有严格限定,可以是自然语言、伪代码,也可以采用对象约束语言(Object constraint language, OCL)。

  • 对象图(Object diagrams),是指系统在某个时间点的对象快照,也被称作实例图。虽然类图能够完整表达对象的结构信息,但有时候并不容易理解,对象图能够以某个真实案例对前者进行补充。如下图所示:

Object diagram

  • 包图(Package diagrams),指一组类或嵌套包的集合。包也被称作命名空间(Namespace),其作用是定义比类层次更高的系统结构。包图在UML中使用带标签名的方框表示,包之间也可以定义类似类图中关系。如下图所示:

Package diagrams

  • 部署图(Deployment diagrams),指系统的物理结构,特别是软件及其所运行的硬件框架。如下图所示:

Deployment diagram

  • 组合结构图(Composite diagrams),指对一个类的内部结构进行层级表示,从而使其更容易被理解。以类TV Viewer为例,下面是TV Viewer的类图:

Class diagram of TV Viewer

如果用组合结构图进一步描述TV Viewer,则如下图所示:

Composite structure diagram of TV Viewer

  • 组件图(Component diagrams)。在UML中,组件是一种从功能角度上看可以独立分发和升级的模块。组件图用于表示组件之间的交互关系,如下图所示:

Component diagram

  • 时序图(Sequence diagrams),表示一组对象及其之间的协作关系。具体来说,时序图通常限定在一个单独的场景下,包含了一组对象以及依据用例而发生的对象间消息传递,特别是展示了消息发生的顺序信息。如下图所示:

Sequence diagram

在时序图中,由于第一个消息通常不在参与者(Participants)中,因此也被称作创始消息(Found message)。另外,时序图中的参与者是可以被动态创建和销毁的。同时消息传递过程也支持循环、分支以及异步等特性。

  • 状态机图(State machine diagrams),也称作状态图,表示单个对象的整个生命周期行为。在面向对象中状态具体包括了对象中所有属性值的集合,而状态图则侧重于抽象的状态定义,即提供不同的事件响应方式。一个简单的状态图例子如下:

State machine diagram

该状态图表示了一座城堡中的安全机关。图中方框表示一个状态,除了状态名之外,还可以填入状态的内部活动,包括状态事件(Event)、看守(Guard)和活动(Activity),当某个事件发生时,可根据当前状态选择迁移(Transition)或保持状态不变。更进一步,状态既可以是静止的也可以是活动的,例如当前对象某个正在发生的动作。状态也可以被分组,其作用是表示组内所有子状态的同一个向外部某个超状态(Superstate)迁移的路径。

在并发场景中,单个状态可以被分割成几个正交的子状态图,一个闹钟的例子如下图所示:

Concurrent state diagram

该图中用一个历史伪状态标记代替了初始状态标记,意味着当开关打开时,初始状态应为上一次开关关闭时所处的状态。

值得注意的是,状态图对应着两种可能的实现,一种是采用控制流代码或面向对象实现,另一种是基于状态表的解析和查询。前者具有更加复杂的代码结构,且需要持续维护相关代码;后者需要在初期实现一个较复杂的状态表解析和查询特性,后期则主要集中于状态表的数据维护。无论采用哪一种实现,其最终代码都具有一定的样板特征,因此结合代码生成技术都是更好的选择。

  • 活动图(Activity diagrams),表示过程逻辑的业务流程的行为,且通常是跨多个用例或线程的行为。先看一个活动图的例子:

Activity diagram

从上例可以看出,该活动图与传统的流程图十分类似,最主要的区别在于前者支持并发活动,例如分叉(fork)操作可以产生并发的子活动,所有子活动的同步操作通过结合(join)进行。活动图中的动作(Action)之间使用流(Flow)或者边(Edge)进行连接,且可以被进一步分解成更多子活动,如下图所示:

Action decomposition

一般而言,活动图更多聚焦于描述业务过程而非实现,但通过分区(Partition)可以进一步表示不同动作的负责对象,如下图所示:

Partition

活动图支持基于信号的动作触发场景,信号可以被直接触发并接收,也可以通过定时器触发,如下图所示:

Signal

活动图还进一步支持采用动作方框下放置别针(Pin)表示该动作的输入和输出参数,如果某个动作的输出与下一个动作的输入参数不同,还需要使用参数变换(Transformation)使其一致。当一个动作会导致下一个动作触发多次时,可以采用扩展区域(Expansion regions)标记需要响应多次的动作集合,如下图所示:

Expansion regions

在该例中,扩展区域中的动作流程可能会部分导致终止,因此采用终止流(Flow final)进行标记。

  • 通信图(Communication diagrams),在UML 1.x中被称作协作图(Collaboration diagram),用于表示对象交互过程中的数据连接,如下图所示:

Communication diagram

通信图所表达的信息与时序图类似,形式相对灵活但不如后者规整,因此其受欢迎程度也不如后者。

  • 交互概述图(Interaction overview diagrams),指结合了活动图和时序图的概述表示。如下图所示:

Interaction overview diagram

交互概述图实际上是一种针对对象交互行为的整体表现方案的总结。

  • 时间图(Timing diagrams),表示单个或一组对象交互的时间约束。特别是当对象的某个状态存在一定时间约束时,如下图所示:

Timing diagram

  • 用例图(Use case diagrams),表示系统的功能需求。用例(Use case)是一种对用户与系统或系统自身交互的描述。用例更注重用户的一般目标,与用户场景(User scenario)不同,后者详细描述了用户与系统的每一步交互(这里的用户不仅指人类),如果交互过程中发生分支则产生新的场景。也就是说,一个用例包含了许多用户场景。下图是一个用例图的例子:

Use case diagram

用例是对系统功能的更高级描述,与极限编程中的用户故事(User stories)相比,后者侧重于表示系统特性,且可以用于安排迭代计划,而用例则是单纯的系统功能性描述。在具体实践中,一个特性可能直接对应用例,或者用例中的一个步骤,也可能对应于其中一个场景。而大多数情况下用例要比特性具有更粗的粒度。

结论

本文首先讨论了面向对象的核心概念;随着面向对象编程的流行,开发中面临的问题复杂度不断提升,由此催生了面向对象建模技术。UML旨在实现语言无关的通用建模语言,被广泛用于面向对象分析(OOA)和设计(OOD)等活动。另一方面,随着相关国际标准的建立,UML也逐渐变得更加臃肿,因此实践中,视实际情况选择性地对工具进行裁剪就变得尤为重要。

引用

BMR97, Object-Oriented Software Construction

UML17, The unified modeling language specification

MFR03, UML Distilled

Comments

软件设计与架构笔记(7)

编程范式(Programming Paradigms)

作为专业程序员,对方法论的持续抽象绝对是一项明智的长期投资。

Robert W. Floyd在1978年图灵奖颁奖礼上如是说[RWF79],这里所说的“方法论”即编程范式(Programming Paradigms)。范式一词源自Thomas S. Kuhn的《科学革命的结构》,Kuhn认为过去数个世纪的科学变革,实质上是主宰范式的更替,而这些范式却都曾在一段时期内常自认为能够独立揭示科学的内涵[TSK62]。具体到程序设计领域,范式表示为编程活动中存在的公共风格或方法论。例如,结构化编程可看作是上世纪70年代的主宰范式,但并非唯一。即使是忠实的拥趸也必须承认,结构化编程在解决某些问题时并不理想,于是持续有诸如分支限定(branch-and-bound)、分治(divide-and-conquer)、状态机(state machine)等其他更高层级范式的提出。或许有人认为使用较为底层的编程范式照样可以完成绝大部分任务,但却低估了软件的运行效率和经济效益等重要因素。因此Floyd认为,编程技术得以持续进步的重要前提即是新范式的发明、阐释和交流。

编程范式的概念组成(Conceptual Composition of Programming Paradigms)

任何一种编程范式都可以被看作是由一组编程概念(Programming concepts),通过组装内核语言(Kernel language)而形成[PVR04]。从数量上看,编程语言要比编程范式种类更多,编程概念则较少,假设存在n种概念,则理论上可以有2n种范式。下面以一些重要的编程概念为例:

  • 变量(Variables),通常由标识符(Identifier)和存储变量(Store variable)组成,前者相当于变量的名字,后者则是变量在内存中的实际位置。一个变量需要使用声明语句加以创建,例如:
1
2
declare
V=9999*9999

这里declare语句的作用相当于执行创建标识符和存储变量两个任务。

  • 函数(Functions)。函数由标识符、参数列表和函数体组成,标识符的作用与变量一致,参数列表规定函数的输入,而函数体用于容纳一段程序代码,例如:
1
2
3
4
declare
fun {Fact N}
if N==0 then 1 else N*{Fact N-1} end
end

在该例中,Fact函数接受参数N,并且计算并返回N的阶乘。值得注意的是,函数体中的代码包含了条件表达式,以及对应于阶乘数学定义的递归表达式。递归使函数具有相对复杂的数学表达能力,例如求解组合数函数:

1
2
3
4
declare
fun {Comb N K}
{Fact N} div ({Fact K}*{Fact N-K})
end

尽管该函数体只有一条语句,但精确反映了组合数的数学定义。但需要注意,Comb函数本身需要依赖之前定义的Fact函数,这种使用已有函数组成新函数的形式,被称作函数抽象(Functional abstraction)。

  • (Lists)。当参与计算的数达到一定数量,就需要一个方便的方式表示数的集合了。例如计算杨辉三角( Pascal’s triangle),其实质是对组合数在自然数序列上的枚举,即“从n中取k个数”,其中n为自然数序列,k则是从0到n范围内的自然数,在杨辉三角中n表示三角形的行数,而k表示为列数。那么为了保存该问题中的数列,就需要引出表的定义:
1
T=[5, 6, 7, 8]

表实际上是一条由连接构成的链,其中每个连接由两部分组成:表元素和剩余链部分的引用。Lisp语言使用cons函数动态地在表中创建新的连接,类似地这里用H|T表示cons,例如:

1
2
3
H=4
T=[5, 6, 7, 8]
M=H|T

该例中M的值为[4, 5, 6, 7, 8]。反过来,也可以在一个表中实现逆cons操作,例如:

1
2
3
L=[5 6 7 8]
{Browse L.1}
{Browse L.2}

这里L.1输出5,L.2输出为6, 7, 8。

表通常支持模式匹配(Pattern matching)操作,目的是更方便对表进行分解,例如该例中的case指令:

1
2
3
declare
L=[5 6 7 8]
case L of H|T then {Browse H} {Browse T} end

这里case通过指定一种cons模式对表L进行分解,并使用H和T两个局部变量保存分解后的值,该局部变量的作用域仅限于case语句的then..end代码块内。

  • 基于表的函数应用(Functions over lists)

现在设计函数计算杨辉三角,计算原理是,对于第n行数列,分别将其左移一位和右移一位生成两个新的数列(末端补0),然后将两列相加,即得到第n+1行数列。下面用自上而下法编程解决该问题。

1
2
3
4
5
6
7
declare Pascal AddList ShiftLeft ShiftRight
fun {Pascal N}
  if N==1 then [1]
  else
      {AddList {ShiftLeft {Pascal N-1}} {ShiftRight {Pascal N-1}}}
  end
end

该函数在最顶端模拟了前述文字描述的算法,其余函数可分别表示为:

1
2
3
4
5
fun {ShiftLeft L}
  case L of H|T then
  H|{ShiftLeft T}
  else [0] end
end
1
fun {ShiftRight L} 0|L end
1
2
3
4
5
6
7
fun {AddList L1 L2}
  case L1 of H1|T1 then
      case L2 of H2|T2 then
          H1+H2|{AddList T1 T2}
      end
  else nil end
end

可以看出,当程序引入了函数和表操作时,其复杂度也相应增加。那么应如何判别该程序的正确性呢?

  • 正确性(Correctness)。程序的正确性验证是一个非常复杂的问题,因为它不仅涉及编写的程序本身,还依赖对编译器、运行时系统、操作系统、硬件环境乃至其它物理因素的正确性验证。因此对程序的正确性验证首先要确定一个合理范围,并假设范围之外的部分是可信的,例如要验证前面计算阶乘的代码片段,通常需要经过以下步骤:1. 建立对应编程语言中各种操作的数学模型,称作语义模型。2. 定义程序的行为,通常是对程序输入和输出的数学定义,称作程序的规格说明。3. 基于1的语义模型,借助数学方法推导程序的运行结果,从而证明程序符合2定义的规格说明。

  • 复杂度(Complexity)。这里主要指时间复杂度。观察前面给出的Pascal函数,{Pascal N-1}在函数体中出现了两次,由于其递归的特性,最终计算量将正比于2n,从而当输入稍大时就会导致很长的运行时间。为了提高程序运行效率,可以消除一次{Pascal N-1}的计算,所以有:

1
2
3
4
5
6
7
fun {FastPascal N}
  if N==1 then [1]
  else L in
      L={FastPascal N-1}
      {AddList {ShiftLeft L} {ShiftRight L}}
  end
end

改进后的程序时间复杂度达到了n2的多项式时间,从而远好于之前的指数时间。理想状态的时间复杂度应尽量满足低阶多项式时间。

  • 懒求值(Lazy evaluation)。一般而言被直接调用的函数会立即被计算,这种模式被称作及早求值(Eager evaluation),与之相反则被称作懒求值。在懒求值下,计算只会在其结果被需要时发生。懒求值对于程序代码的优化有一定意义,如下例:
1
2
3
fun lazy {Ints N}
  N|{Ints N+1}
end

如果Ints函数是及早求值,那么调用该函数会直接进入死循环,直到调用栈溢出,但懒求值则不会。例如:

1
2
L={Ints 0}
case L of A|B|C|_ then {Browse A+B+C} end

该例只会导致Ints被执行三次,模式匹配中抽出的A、B、C将等于列表中的前三个数。与及早求值相比,懒求值其实意味了对程序的更多控制权,避免像普通的递归函数一样过早进行了大量计算。

  • 高阶编程(Higher-order programming)。如果需要计算一个杨辉三角的变种,例如每行数列的获取不是通过对上一行的数做加法,而是改用减法、异或等算术表达式,那么最直观的方法就是对Pascal程序进行改造,特别是替换FastPascal函数中的AddList为新的函数调用。这就导致FastPascal函数可能需要频繁修改,甚至重复才能满足不同类型的计算需求。高阶编程允许将函数作为另一个函数调用的参数,从而满足统一的代码实现:
1
2
3
4
5
6
7
fun {GenericPascal Op N}
  if N==1 then [1]
  else L in
      L={GenericPascal Op N-1}
      {OpList Op {ShiftLeft L} {ShiftRight L}}
  end
end

GenericPascal就是理想的统一代码实现,允许通过Op传递所需的计算函数,避免了计算功能需要扩展时再次发生修改的可能。

  • 并发(Concurrency)。真实世界中存在大量相互独立、且根据自身情况决定执行节奏的活动,这被称为“并发”。除非程序建立了通信机制,否则并发的活动相互间不会发生干涉。程序中的并发通常借助线程实现,如下例所示:
1
2
3
4
thread P in
  P={Pascal 30}
  {Browse P}
end

当线程代码开始运行时,尽管Pascal函数的执行需要较长时间,但程序本身依然会继续向下执行。

  • 数据流(Dataflow)。如果某个操作引用了一个尚无法被绑定的变量,例如在并发编程中,该变量正在被另一个线程所绑定,那么理想的行为是请求绑定的一方陷入阻塞,直到获得该变量的绑定,这被称为数据流。例如:
1
2
3
declare X in
thread {Delay 10000} X=99 end
{Browse start} {Browse X*X}

上例中X*X会发生阻塞,直到X被主线程绑定。

  • 显式状态(Explicit state)。与并发类似,真实世界中也会存在某种行为依赖于历史记录的情况,这就需要程序语言的函数具有维持内部状态的能力,大多数情况下我们把这种状态保存在变量中,这里使用内存单元(Memory cell)表示,以便和前文同样提到的变量概念进行区分。下例显示了如何在FastPascal中引入显式状态:
1
2
3
4
5
6
declare
C={NewCell 0}
fun {FastPascal N}
C:=@C+1
{GenericPascal Add N}
end
  • 对象(Objects)。对象即带有内部状态的函数,一个包含多个函数的对象例子如下:
1
2
3
4
5
6
7
8
9
10
11
declare
  local C in
  C={NewCell 0}
  fun {Bump}
    C:=@C+1
    @C
  end
  fun {Read}
    @C
  end
end

本例中local..end定义了变量C的作用域范围,也就是说C对local..end定义的代码范围之外的部分不可见,即封装(Encapsulation)。封装意味着隔离了对象状态与程序的其它部分,从而具有了信息隐藏的特性。此外,该对象还包含两个方法:Bump和Read实现对其状态的操作,即对象的接口(Interface)。一旦对象的接口能维持一致,那么客户程序就无需了解对象具体的方法实现,并能够直接调用任何具有相同接口的对象方法,这种特性即多态(Polymorphism)。在封装和多态背后,针对接口与其实现的分离即数据抽象(Data abstraction)的实质。

  • (Classes)。对象极大提升了程序的可复用性和可维护性,那么如果程序中需要超过一个对象呢?一种解决办法就是创建一个“工厂对象”,将其用于生产更多的对象,这个工厂对象即(Class)。下例演示了如何利用函数创建类:
1
2
3
4
5
6
7
8
9
10
11
12
13
declare
fun {NewCounter}
C Bump Read in
  C={NewCell 0}
  fun {Bump}
    C:=@C+1
    @C
  end
  fun {Read}
      @C
  end
  counter(bump:Bump read:Read)
end

该例中NewCounter函数每次调用都能返回一个具有独立内部状态以及Bump和Read函数的新函数(对象),即利用了前文提到的高阶编程的概念。对类的使用如下所示:

1
2
3
declare
Ctr1={NewCounter}
Ctr2={NewCounter}

其中Ctr1和Ctr2相当于独立的对象,程序进而可以通过.操作符调用其中的方法,例如:

1
{Browse {Ctr1.bump}}

值得注意的是,截至目前我们介绍了类和对象的概念,但这并非面向对象编程(Object oriented programming)的全部,也不意味着使用类和对象的编程概念就可以被称为“面向对象语言”。

  • 非确定性和时间(Nondeterminism and time)。当程序具有并发和状态等概念时,问题就会变得更加复杂,这是因为并发所引起的线程时序是非确定的,而状态的改变也会因此变得不稳定。这里需要强调,非确定性本身不会带来问题,只有当程序中的非确定性具有可观测性时,进而引发的竞争条件才会导致潜在问题。
1
2
3
4
5
6
7
8
declare
C={NewCell 0}
thread
  C:=1
end
thread
  C:=2
end

在本例中,从字面上无法判断出在某个时刻变量C的值,即所谓的可观测非确定性。在这种情况下,程序的线程之间会因为交叉存取(Interleaving)问题而变得极不稳定,因此避免和限制交叉存取是保证高质量程序的重要经验之一。

  • 原子性(Atomicity)。解决交叉存取问题的途径之一就是引入原子操作(Atomic operation)。原子性意味着任何中间状态都无法被观测,即从初始态直接跳跃至最终态。原子操作的实现方法之一即引入锁(Lock)对象,锁保证了在任何时刻只有一个线程在其中执行,此时其它线程只能在锁外等待。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
declare
C={NewCell 0}
L={NewLock}
thread
  lock L then I in
    I=@C
    C:=I+1
  end
end
thread
  lock L then J in
    J=@C
    C:=J+1
  end
end

锁的使用一般包含两步操作:1.创建锁对象;2.使用锁对象加锁并执行目标代码。代码运行结束后锁对象被立即释放,后续线程可以继续对该对象加锁。

编程范式的分类(Taxonomy of Programming Paradigms)

由于概念——范式——语言之间的组合构成关系,理论上说出于更上层次范式和语言的数量相比较于编程概念而言是十分巨大的。然而在多数情况下,实用的范式应当是图灵完备的。例如函数式编程,基于头等函数(First-class function)或闭包(Clusure)的概念,因此其相当于和λ算子等价,从而可以被证明图灵完备。下面讨论由基本编程概念组合而成的编程范式类别,这种分类法覆盖了绝大多数的实用编程范式(在[PVR04]中也被称作计算模型)。

声明式模型(Declarative model)

作为最早出现也是最简单的编程范式类别,声明式是指通过定义数学函数实现编程,从而使其最易被推导和测试,也是所有其它类别的编程范式的基础。

声明式编程首先定义了语法(Syntax)和语义(Semantics)的概念,其中语法用于规定合法的语言形式,由于编程不可能像自然语言完全一样灵活自由,因此通常具有极为限定的语法形式和约束。一种常用的语法标记即扩展巴科斯范式(Extended Backus-Naur Form, EBNF),其基本形式是从非终结符开始,由左向右列出记号(Token)序列,其中任何遇到的终结符可以被直接加入序列,而非终结符则需要被它的展开式替换,并在选择项(Choice)前任选一个作为替换。上述这种语法定义形式被称为上下文无关文法(Context-free grammars),因为其非终结符在任何情况下的展开都是唯一确定的。须知上下文无关文法是可能会存在歧义的,例如:

1
2
<exp> ::= <int>|<exp> <op> <exp>
<op> ::= + | *

对于表达式2 * 3 + 4来说,其解析树存在两种可能,一种的叶结点是2 * 3和4,另一种的叶结点是2和3 + 4。为了消除这种歧义性,编程语言层面会定义更多约束以保证确定性:例如确定运算符优先级或者定义计算表达式的默认方向。

在语义方面,无论现代编程语言被设计得多复杂,其底层一定是基于一个纯数学的、易于推导的模型,这种模型被称作内核语言。实际上,本文所讨论的编程范式,就是通过定义内核语言形成对编程语言的语义化翻译,进而更容易被机器或操作系统所识别。

声明式编程有时也被称为无状态式编程,也即以下两种编程范式的核心思想——函数式编程(Functional programming,例如Scheme和Standard ML)和逻辑式编程(Logic programming,例如Prolog)。以该范式为基础为编程语言构建了庞大的特性集合,例如大部分常用的语法规则、编译技术、内存管理技术和异常管理技术等,都超出了本文的主题。

并发声明式模型(Concurrent declarative model)

该范式在声明式编程的基础上引入并发的概念。在本文的编程概念部分就已经讨论过,并发本身并不显著提高复杂度,只有并发和状态同时存在时问题才可能会出现,即前文提到的可观测非确定性问题。并发声明式的主要特点在于数据流概念的引入,既保留了声明式编程的基本特征,也允许更加灵活的增量执行属性,且避免引入可观测非确定性。

一般的声明式编程都是按照语句的出现顺序、由左至右依次执行,这种执行方式即所谓的及早求值或数据驱动求值(Data driven evaluation)。在某些应用场景中,及早求值并非最佳方案。例如在同时包含生产者和消费者的程序中,传统的及早求值要求生产者确定是否已经发送了完整的数据,而如果由消费者来负责就能进一步保证处理后的数据完整性,后者就采用了懒求值的思想,也被称作需求驱动求值(Demand driven evaluation)。在声明式编程中引入懒求值的特性,即懒声明式模型(Lazy declarative model)。该范式允许在某些潜在的无限制数据结构基础上实现编程,更有利于资源管理和程序结构的模块化。

懒求值最早是在函数式编程中被发现,最初仅被视为声明式编程的一种执行策略,可用于帮助设计具有良好平摊性或最坏时间上界的算法;后来被进一步应用于包含声明式子集且更具表达性的范式中,强化其模块化特性。采用懒求值的例子包括Haskell和Miranda。

消息传递并发式模型(Message-passing concurrent model)

由于前文所描述的并发声明式编程不具备可观测非确定性,使其在描述能力上有所限制。例如经典的C/S系统,任何时刻服务器都无法预知客户端发来的下一条消息,而这在并发声明式编程中就无法实现。而消息传递并发式编程则在前者的基础上引入了一个异步通信信道,任何程序中的实体都能从该信道中写入和读取消息,从而满足了可观测非确定性编程的需求。该范式创建了一个具有关联流的信道——端口(Port)。任何实体可以向该端口发送消息,一个具体的作法是创建一个流对象并将其和对应端口相关联,这里称其为端口对象。于是,实体就可以通过该端口对象对其它端口对象发送和接收消息。一个端口对象通常是一个声明式的递归过程,从而使其拥有声明式编程的一部分特性。

采用消息传递并发式的例子包括Actor模型和Erlang。

状态式模型(Stateful model)

状态式编程,也称命令式编程(Imperative programming)。本质上状态式相当于声明式+显式状态的组合,这里的显式状态是指某个过程调用依赖于超出该过程生命周期的状态,且该状态没有出现在过程的调用参数列表中。状态式编程增强了范式的抽象能力,这种能力被视为构建复杂系统的关键。以传统的无状态编程为例,尽管程序中的一个过程可以根据外界传递的参数做出对应的行为,但这始终是针对特定输入而产生确定性结果。而对于状态式编程而言,其自身拥有了更多能力从而变得相对较“智能”,这也更接近对真实世界活动的模拟。

范式的抽象能力可以通过以下特性衡量:1.封装性;2.组合性;3.可实例化和可调用性。其中,封装性的意义在于,我们知道程序的可推导性能够保证其正确性,但显式状态的引入会使得程序推导变得十分困难,一个例子是带有边际效应(Side effect)的函数。而封装性的提高可以降低状态带来的不利影响,特别是维持不变量(Invariant),这在一定程度上提高可推导性。

状态式编程能够描述出行为依据状态而发生变化的程序,从而进一步有利于模块化程序,且如果封装和不变量使用得当,则其会拥有与声明式编程相当的可推导能力。

在状态式编程的基础上,采用一组交互式数据抽象的集合描述最终程序,即面向对象式模型(Object oriented model)。这里的数据抽象具有状态化和对象化二元特性。状态化意味着模块化能力,而对象化则进一步启发了多态和继承——这就是面向对象编程的基本原理。多态允许更细粒度的模块化,在合理的职责划分下依旧能保证统一接口;而继承则开辟了增量式的抽象构建,使程序模块易于复用,从而降低潜在的开发成本。

在最近40年里,面向对象编程在工业界和学术界都得到了深入研究和广泛应用,并且在绝大多数现代编程语言中都得到了支持。

共享状态并发式模型(Shared-state concurrent model)

与消息传递并发式类似,共享状态并发式也提供了可观测非确定性编程的能力,区别在于后者借助了共享且可变的显式状态(这里称作单元)而非异步通信信道来实现。尽管实质上都是状态化,但共享状态并发式编程较前者具有更加复杂的实现。

前文已经提到,并发声明式编程不具备可观测非确定性,这点其实兼有利弊,特别是无法实现完全独立的并发线程,或是超过两条线程以上的通信,这也是状态化并发编程的主要目的。但是为了应对随之而来的即交叉存取问题,除了异步消息信道外,还可以通过引入锁、监控和事务等实现针对共享状态单元的原子操作,这些解决方案适用于不同的问题。

事实上,共享状态并发式编程在绝大多数语言中都得到了支持,这主要得益于状态式编程(特别是面向对象编程)的广泛应用。颇为讽刺的是,尽管这种范式可能受到了更加彻底的研究,但建立在其基础上的应用程序至今仍面临复杂且严峻的挑战。

关系式模型(Relational model)

声明式编程的基本特性源于数学计算,包括过程、输入参数、输出参数等概念。当一个给定输入参数集合仅有一组输出参数集合时,同样可以用关系式编程实现。后者比声明式具有更高的灵活性:1.允许有0至多个结果;2.输入参数和输入参数可以在每次调用时都不同。从而令关系式编程在数据库、歧义性语法解析和复杂组合问题的枚举实现等领域具有一定优势。

具体实现上,关系式在声明式基础上引入了选择(Choice)语句,这种选择语句能够通过搜索自由抽取出一个结果集,虽然算法是确定的,但最终结果仍然是非确定。Prolog的search特性就是基于这种范式的逻辑式编程语言。

编程范式的比较(Comparison of Programming Paradigms)

编程范式对软件工程的意义在于满足天然性(Natural)和高效性(Efficient)。天然性意味着相关程序使用了尽可能少的、与问题本身不相干的代码,例如某些纯技术原因导致的代码。一般采用模块化非确定性对接真实世界来衡量范式的天然性。高效性则意味着程序与解决同一问题的嵌入式编程只存在常数级差别。由于通常无法同时兼顾这两种属性,于是它们就成为衡量编程范式的重要工具。

声明式编程的简洁和可推导性,使得程序较易于保证正确性,尽管多数时候由于不可变(Immutable)数据类型而需要为计算结果开辟新空间,但这引来的性能损耗在真实场景中几乎可以忽略不计,因此总体上说声明式编程具有高效性。但在满足天然性方面,朴素的声明式编程首先并不具备模块化特性,除非向其注入显式状态;其次,虽然声明式编程支持并发,但由于本身不支持状态,从而不具备可观测非确定性;由于前两者不满足,声明式编程也就不具备对接真实世界的特性。因此可以认为声明式编程并不具备良好的天然性。

状态式编程一般要求程序采用顺序执行,但真实世界的实体通常既是状态的也是平行的,这就需要引入并发来解决这一问题,即增加可观测非确定性。另一方面,在分布式环境中,状态的存储也面临一致性和效率问题,于是导致了一套复杂的一致性等级和协调算法解决方案,极大提升了状态式编程在解决分布式问题中的复杂度。这些问题对面向对象编程同样适用,而对后者而言,其相较于其它范式显然更符合天然性要求,这也是其流行至今的原因之一。

在并发编程方面,并发声明式作为基于数据流的、最简单的并发编程范式,无疑是实现确定性并发编程的最佳工具;真实世界中更多时候面临着可观测非确定性问题的挑战,于是推动了消息传递和共享状态两种应对方案的出现。对于消息传递并发式来说,程序描述了一批相互协调的活动实体,更加适用于多代理场景,例如通信;而对于共享状态并发式来说,程序描述了一批实现一致性修改的数据仓库,适用于以数据为中心的计算场景。而事实上,这两种范式在真实的软件工程实践中是可以并存使用的。

结论

编程范式用于描述编程活动中的风格和方法论,该问题是软件设计和实现的共同基础。本文首先介绍了编程范式的基本组成和重要的编程概念,并在此基础上进一步介绍了一种编程范式分类法[PVR04],并在此基础上对不同类型的编程范式进行了比较。了解这种分类法能够便于理解编程语言的动机和设计原理,并且掌握语言发展的历史、现状和趋势,从而为进一步构建得以实用的软件设计提供更好的理论和技术储备。

引用

[RWF79], The Paradigms of Programming

[TSK62], The Structure of Scientific Revolutions

[PVR04], Concepts, Techniques, and Models of Computer Programming

Comments