Skip to content

4. [TD]:分层架构

关键词:多层架构、Spring、依赖注入。

4.1. 引言

让我们回顾一下我们所做的工作:

  • 在“ELECTIONS”练习的第一部分中,我们没有使用任何类。我们构建了一个解决方案,就像用 C 语言编写一样。
  • 在练习的第 2 部分中,引入了两个类:
    • [VoterList],用于表示候选人列表的属性(id、name、votes、seats、eliminated)
    • [ElectionsException],一个用于未处理异常的类。当选举应用程序中发生致命错误时,会抛出此类异常。它是未处理的,这意味着开发者无需使用 try-catch 代码块来处理它。

迄今为止,选举结果的计算一直由 [MainElections] 类的 [main] 方法负责

package istia.st.elections;

import java.io.*;

public class MainElections {

   // some data
  private static final double barre = 0.05;

  // ----------------------------------------------------------------------
   // the main procedure
  public static void main(String[] arguments) throws IOException {

     // prepare keyboard input stream
    BufferedReader clavier = new BufferedReader(new InputStreamReader(System.in));

     // data entry for seat calculations
...
     // calculation of seats obtained by the various lists
....
     // display of results
...
  } // hand
} // class

前面的解决方案包含三个标准阶段:

  • 数据采集,第17-18行
  • 计算解,第19–20行
  • 显示和/或保存结果,第21–22行

只有第2阶段是真正固定的。第1阶段可能有所不同:数据可以来自键盘(如所研究的示例中那样),也可以来自文本文件、图形界面、数据库、网络等。同样,第3阶段也有多种方式输出结果:如所研究的示例中那样在屏幕上显示,保存到文件或数据库中,通过网络发送等。

更普遍地说,应用程序通常可以建模为三个层,每层都有明确定义的角色:

这种架构也被称为“三层架构”。“三层”一词通常指各层位于不同机器上的架构。当各层位于同一台机器上时,该架构便成为“三层”架构。

  • [业务]层包含应用程序的业务规则。对于我们的选举应用程序而言,这些规则用于在各候选名单获得的票数确定后,计算其赢得的席位。该层需要数据才能运行。例如,在选举应用程序中:
  • 各候选名单,包括其名称及得票数
  • 待填补的席位数
  • 候选名单的淘汰门槛(低于该门槛的名单将被淘汰)

在上图中,数据可来自两个来源:

  • 数据访问层或 [DAO](DAO = 数据访问对象),用于已存储在文件或数据库中的数据。此处候选名单名称、待填补席位数及选举门槛即属于此类。事实上,这些信息在选举开始前已知晓。
  • 用户界面层或 [ui](UI = 用户界面),用于用户输入或显示给用户的数据。本例中,各名单的得票数(仅在最后时刻才知晓)以及选举结果的显示即属于此类。
  • 一般而言,[DAO] 层负责处理对持久化数据(文件、数据库)或非持久化数据(网络、传感器等)的访问。
  • 另一方面,[UI] 层负责处理与用户的交互(如果有的话)。
  • 通过使用 Java 接口,这三个层被设计为相互独立。
  • 将这些层集成到应用程序中有多种方法。我们将使用一个名为“Spring”的工具。在图中,它横跨其他各层。

我们将重新审视之前开发的 [Elections] 应用程序,使其采用三层架构。为此,我们将依次考察 [UI、业务、DAO] 层,首先从处理持久化数据的 [DAO] 层开始。

首先,我们需要为 [Elections] 应用程序的不同层定义接口。

4.2. [Elections] 应用程序的接口

请记住,接口定义了一组方法签名。实现该接口的类则为这些方法提供具体实现。

让我们回到应用程序的三层架构:

在此类架构中,通常由用户发起操作。用户在 [1] 处发起请求,并在 [8] 处收到响应。这被称为请求-响应循环。以选举之夜计算获胜席位为例,这需要经过几个步骤:

  1. [ui]层需要向用户询问各候选名单所获的票数。为此,它需要向用户显示各竞争名单的名称。随后,用户只需在每个名单旁输入票数并请求计算席位。
  2. [ui]层并不包含候选名单的名称。这些名称存储在图示右侧的数据源中。它将通过路径[2, 3, 4, 5, 6, 7]检索这些信息。操作[2]是获取候选名单的请求,操作[7]则是对此请求的响应。完成这些操作后,[ui]层即可通过[8]将名单呈现给用户。
  3. 用户将向 [ui] 层提交各候选名单获得的票数。即上文中的操作 [1]。在此步骤中,用户仅与 [ui] 层进行交互。该层将负责验证输入数据的有效性。验证完成后,用户将请求各候选名单所获席位列表。
  4. [ui]层将请求业务层计算席位。为此,它会将从用户处接收到的数据发送给业务层。这就是操作[2]。
  5. [业务]层需要某些信息来执行其任务。它已经拥有操作(b)中的候选名单。此外,它还需要待分配的席位数量和选举门槛值。它将通过路径[3, 4, 5, 6]向[DAO]层请求这些信息。[3]是初始请求,[6]是对此请求的响应。
  6. 获取所需全部数据后,[业务]层计算各候选名单赢得的席位数。
  7. [业务]层现在可以响应[UI]层在(d)中发出的请求。这是路径[7]。
  8. [UI]层将对这些结果进行格式化,以便以适当的形式呈现给用户,然后进行显示。这就是路径[8]。
  9. 可以设想,这些结果需要存储在文件或数据库中。这可以自动完成。在这种情况下,在操作 (f) 之后,[业务] 层将请求 [DAO] 层保存结果。这将走路径 [3, 4, 5, 6]。这也可以仅在用户请求时进行。路径 [1-8] 将被请求-响应循环所使用。

从上述描述中可以看出,每一层仅使用其右侧层的资源,绝不使用其左侧层的资源。考虑两个相邻的层:

层 [A] 向层 [B] 发起请求。在最简单的情况下,一个层由单个类实现。应用程序会随着时间推移而演变。因此,层 [B] 可能拥有不同的实现类 [B1, B2, ...]。如果层 [B] 是 [DAO] 层,它可能有一个初始实现 [B1],用于从文件中检索数据。 几年后,我们可能希望将数据存储在数据库中。届时我们将构建第二个实现类 [B2]。如果在最初的应用程序中,层 [A] 是直接与类 [B1] 交互的,那么我们不得不部分重写层 [A] 的代码。例如,假设我们在层 [A] 中编写了类似以下的内容:

1
2
3
B1 b1=new B1(...);
..
b1.getData(...);
  • 第 1 行:创建 [B1] 类的实例
  • 第 3 行:向该实例请求数据

如果我们假设新的实现类 [B2] 使用的方法与类 [B1] 的方法具有相同的签名,那么我们将不得不把所有对 [B1] 的引用都改为 [B2]。这是一种非常理想的情况,如果我们之前没有注意这些方法签名,这种情况发生的概率非常低。 实际上,类 [B1] 和 [B2] 通常具有不同的方法签名,这意味着 [A] 层的大部分代码必须完全重写。

我们可以通过在层 [A] 和 [B] 之间引入一个接口来改善这种情况。这意味着,我们在接口中定义了层 [B] 向层 [A] 展示的方法签名。那么,之前的图示就会变成如下所示:

层 [A] 不再直接与层 [B] 通信,而是通过其接口 [IB] 进行通信。因此,在层 [A] 的代码中,层 [B] 的实现类 [Bi] 仅在实现接口 [IB] 时出现一次。一旦完成这一操作,代码中使用的是接口 [IB],而非其实现类。原代码变为如下所示:

1
2
3
IB ib=new B1(...);
..
ib.getData(...);
  • 第 1 行:通过实例化类 [B1] 创建了一个实现接口 [IB] 的实例 [ib]
  • 第 3 行:从实例 [ib] 请求数据

现在,如果我们将第 [B] 层的实现 [B1] 替换为实现 [B2],且这两个实现都遵循相同的接口 [IB],那么只需修改第 [A] 层的第 1 行,其他行无需修改。这正是系统化地在两层之间使用接口的主要优势,仅此一点就足以证明其合理性。

我们甚至可以更进一步,使层 [A] 完全独立于层 [B]。在上面的代码中,第 1 行存在一个问题,因为它硬编码了对类 [B1] 的引用。理想情况下,层 [A] 应该能够使用接口 [IB] 的某个实现,而无需指定类名。这将与我们上面的图示保持一致。 我们可以看到,层 [A] 与接口 [IB] 进行交互,而它没有理由需要知道实现该接口的类名。这一细节对层 [A] 而言毫无用处。

Spring 框架(http://www.springframework.org)使我们能够实现这一目标。之前的架构演变如下:

横切层 [Spring] 将允许一个层通过配置获取其右侧层的引用,而无需知道实现该层的类名。该类名将位于配置文件中,而非 Java 代码中。因此,层 [A] 的 Java 代码将采用以下形式:

1
2
3
IB ib; // initialisé par Spring
..
ib.getData(...);
  • 第 1 行:一个实现层 [B] 的 [IB] 接口的实例 [ib]。该实例由 Spring 根据配置文件中的信息创建。Spring 将负责创建:
    • 实现层 [B] 的实例 [b]
    • 实现层 [A] 的 [a] 实例。该实例将被初始化。上方的 [ib] 字段将被赋值为实现层 [B] 的对象 [b] 的引用
  • 第 3 行:从 [ib] 实例请求数据

现在我们可以看到,层 B 的实现类 [B1] 在层 [A] 的代码中并未出现。当实现 [B1] 被新的实现 [B2] 替换时,类 [A] 的代码不会发生任何变化。我们只需修改 Spring 配置文件,使其实例化 [B2] 而不是 [B1]。

SpringJava 接口的结合,通过使应用程序的各层紧密耦合,为应用程序的维护带来了决定性的改进。这正是我们将用于 [Elections] 应用程序的解决方案。

让我们回到应用程序的三层架构:

在简单的情况下,我们可以从[业务]层开始,以发现应用程序的接口。为了正常运行,它需要数据:

  • 这些数据已存在于文件、数据库中,或可通过网络获取。该数据由[DAO]层提供。
  • 尚未可用。此时由[UI]层提供,该层通过应用程序用户获取数据。

[DAO]层应向[业务]层提供哪些接口?这两个层之间可能进行哪些交互?[DAO]层必须向[业务]层提供以下数据:

  • 待填补的席位数
  • 导致候选名单被淘汰的选举门槛
  • 候选名单的名称

这些信息在选举前已知,因此可以进行存储。在 [业务] -> [DAO] 的方向上,[业务] 层可以请求 [DAO] 层记录选举结果,包括各候选名单所获得的席位数。

基于这些信息,我们可以尝试对 [DAO] 层接口进行初步定义:


public interface IElectionsDao {
 
  public double getSeuilElectoral();
 
  public int getNbSiegesAPourvoir();
 
  public ListeElectorale[] getListesElectorales();
 
  public void setListesElectorales(ListeElectorale[] listesElectorales);
}
  • 第 1 行:该接口名为 [IElectionsDao]。它定义了四个方法:
    • 三个用于从数据源读取数据的方法:[getVotingThreshold, getNumberOfSeatsToBeFilled, getVoterLists]。这三个方法将允许[业务]层获取描述当前选举特征的数据。
    • 一个用于向数据源写入数据的方法:[setVoterLists]。该方法将允许[业务]层请求记录其计算出的结果。

让我们回到应用程序的三层架构:

[业务]层应该向[UI]层提供什么样的接口?让我们来探讨这两层之间可能的交互。

  1. [UI]层将负责向用户征集对各竞争名单的投票。为此,它必须知道名单的数量。它可以向[业务]层请求此信息,而[业务]层则可以向[DAO]层请求竞争名单的列表。如果[业务]层拥有该列表,不妨直接将其传递给[UI]层。 此时,[UI]层将掌握各名单的名称,并能据此优化向用户显示的信息,例如询问“名单A的票数是多少”。
  2. 一旦 [UI] 层获取了所有候选名单的票数,它将向 [business] 层请求席位计算。 [business] 层可以执行此计算并将结果返回给 [UI] 层。
  3. [UI]层随后可将这些结果呈现给用户。用户还可以要求将结果保存下来。
  4. [UI]层还可能希望向用户展示其他信息,例如选举门槛或待填补的席位数量。

基于这些信息,我们可以尝试初步定义 [ 业务] 层的接口:


public interface IElectionsMetier {
 
    public ListeElectorale[] getListesElectorales();

    public int getNbSiegesAPourvoir();
 
    public double getSeuilElectoral();
 
    public void recordResultats(ListeElectorale[] listesElectorales);
 
    public ListeElectorale[] calculerSieges(ListeElectorale[] listesElectorales);
 
}
  • 第 1 行:该接口名为 [IElectionsMetier]。它定义了以下方法:
    • 第 3 行:一个名为 [getVoterLists] 的方法,该方法将允许 [ui] 层获取候选名单数组;
    • 第 5 行:[getNbSiegesAPourvoir] 方法用于获取待填补的席位数量;
    • 第 7 行:方法 [getElectoralThreshold] 用于获取选举门槛;
    • 第 11 行:方法 [calculateSeats](第 36 行),该方法允许 [ui] 层在各候选名单的得票数确定后请求计算席位。其参数是候选名单数组,该数组不包含席位信息,也不包含 "eliminated" 布尔值。返回结果是该数组本身,但此时 [seats, eliminated] 字段已被初始化;
    • 第 9 行:方法 [recordResults],允许 [ui] 层请求记录结果。

:鉴于其位置,[业务]层复用了[DAO]层中的部分方法,以便向[UI]层提供这些方法。由于这种冗余,人们可能会倾向于将所有内容整合到一个单一层中,该层将同时包含业务逻辑和数据访问。这个单一层有时被称为模型即MVC(模型-视图-控制器)缩写中的“M”。 MVC 是一种在 Web 应用程序中广泛使用的设计模式。

让我们来分析一下 [calculateSeats] 方法的签名:


public ListeElectorale[] calculerSieges(ListeElectorale[] listesElectorales);

此前曾提到:“该参数是候选名单数组,不包含其席位信息,也不包含‘被淘汰’布尔值。返回结果是相同的数组,但这次包含 [席位, 被淘汰] 字段。”该方法的签名也可以如下所示:


public void calculerSieges(ListeElectorale[] listesElectorales);

参数 [voterLists] 是一个对象引用,在此情况下为一个数组。该数组的每个元素又是一个对象引用,在此情况下类型为 [VoterList]。方法 [calculateSeats] 将修改这些对象各自的 [seats, eliminated] 字段。调用该方法的方法持有指针 [voterLists],该指针:

  • 在调用之前,是 [VoterList] 对象数组的引用,其 [seats, eliminated] 字段尚未初始化;
  • 调用后,是(同一个)指向 [VoterList] 对象数组的引用,且这些对象的 [seats, eliminated] 字段已初始化;

那么,为什么要使用以下签名:


public ListeElectorale[] calculerSieges(ListeElectorale[] listesElectorales);

编写接口时,必须记住它可在两种不同场景下使用:本地远程 。在本地场景中,调用方法和被调用方法在同一台 JVM(Java 虚拟机)中执行:

如果 [ui] 层调用 [DAO] 层的 calculateSeats 方法,它确实持有传递给该方法的 [VoterList[] voterLists] 参数的引用。

远程调用场景中,调用方法和被调用方法是在不同的 JVM 中执行的:

在上例中,[ui] 层运行在 JVM 1 上,[business] 层运行在 JVM 2 上,且位于两台不同的机器上。这两个层之间不直接通信。它们之间存在一个我们称之为通信层 [1] 的中间层。该层由传输层 [2] 和接收层 [3] 组成。开发人员通常无需编写这些通信层,它们由软件工具自动生成。 [business] 层的编写方式,就如同它与 [DAO] 层运行在同一 JVM 中一样。因此,无需修改任何代码。

[ui]层与[business]层之间的通信机制如下:

  • [ui]层调用[business]层的calculateSeats方法,并向其传递参数[VoterList[] voterLists1];
  • 该参数实际上会被传递给传输层 [2]。该层将通过网络传输 `listesElectorales1` 参数的,而非其引用。该值的具体形式取决于所使用的通信协议;
  • 接收层 [3] 将检索该值,并利用其重建一个与 [business] 层初始发送的参数完全一致的 [VoterList[] voterLists2] 对象。此时,我们在两个不同的 JVM 中拥有了两个内容完全相同的对象:voterLists1 voterLists2
  • 接收层将把 `listesElectorales2` 对象传递给 [business] 层的 `calculerSieges` 方法,该方法会将其持久化到数据库中。此操作完成后,`listesElectorales2` 的引用指向一个 [VoterList] 对象数组,其中 [seats, eliminated] 字段已初始化。而 [ui] 层引用的 `listesElectorales1` 对象则并非如此。 如果希望 [ui] 层持有 listesElectorales2 对象的引用,就必须将其传递给 [ui] 层。因此,我们为 [calculerSieges] 方法采用以下签名:

public ListeElectorale[] calculerSieges(ListeElectorale[] listesElectorales);
  • 根据此签名,`calculateSeats` 方法将返回 `electoralLists2` 的引用作为其结果。该结果将返回给调用 [业务] 层的接收层 [3]。[业务] 层将把 `electoralLists2` (而非引用)返回给发送层 [2];
  • 发送层 [2] 将获取该值,并利用它重建一个对象 [VoterList[] voterLists3],该对象反映了 [业务] 层 `calculateSeats` 方法返回的结果。
  • 该对象 [VoterList[] voterLists3] 将返回给 [UI] 层中的方法,正是该方法对 [DAO] 层 `calculateSeats` 方法的调用触发了整个机制;

在此过程中,[VoterList] 类型的对象将在第 [2] 层和第 [3] 层之间传递:

  • 当第 [2] 层将 [VoterList] 对象的值传输至第 [3] 层时,该对象被称为被序列化。这种序列化的具体形式取决于所使用的通信协议;
  • 当 [3] 层检索 [VoterList] 对象的值以创建新的 [VoterList] 对象时,该对象被称为反序列化

为了使对象能够进行这种序列化/反序列化,某些协议要求该对象实现 [Serializable] 接口。该接口仅作为标记,无需实现任何方法。因此,[VoterList] 类将按如下方式声明:


public abstract class ListeElectorale implements Serializable {
    private static final long serialVersionUID = 1L;
  • 第 2 行中的字段是必需的。可以保留该字段,并将其用于任何 [Serializable] 类型的类。

4.3. 异常类

让我们回到 [DAO] 层的接口:


public interface IElectionsDao {
 
  public double getSeuilElectoral();
 
  public int getNbSiegesAPourvoir();
 
  public ListeElectorale[] getListesElectorales();
 
  public void setListesElectorales(ListeElectorale[] listesElectorales);
}

这些方法会与数据库交互,可能会遇到各种错误,例如数据库不可用。编写方法时,必须始终处理错误情况。这些错误通常通过异常来报。我们在第3.3节中已经遇到过[ElectionsException]类。我们将继续使用它,并对其进行如下增强:


package ...;
 
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
 
// exception class for the Elections application
// the exception is uncontrolled
 
public class ElectionsException extends RuntimeException implements Serializable {
 
    // serial ID
    private static final long serialVersionUID = 1L;
 
    // local fields
    private int code;
    private List<String> erreurs;
 
    // manufacturers
    public ElectionsException() {
        super();
    }
 
    public ElectionsException(int code, Throwable e) {
        // parent
        super(e);
        // local
        this.code = code;
        this.erreurs = getErreursForException(e);
    }
 
    public ElectionsException(int code, String message, Throwable e) {
        // parent
        super(message,e);
        // local
        this.code = code;
        this.erreurs = getErreursForException(e);
    }
 
    public ElectionsException(int code, String message) {
        // parent
        super(message);
        // local
        this.code = code;
        List<String> erreurs = new ArrayList<>();
        erreurs.add(message);
        this.erreurs = erreurs;
    }
 
    public ElectionsException(int code, List<String> erreurs) {
        // parent
        super();
        // local
        this.code = code;
        this.erreurs = erreurs;
    }
 
    // list of exception error messages
    private List<String> getErreursForException(Throwable th) {
        // retrieve the list of exception error messages
        Throwable cause = th;
        List<String> erreurs = new ArrayList<>();
        while (cause != null) {
            // the message is retrieved only if it is !=null and not blank
            String message = cause.getMessage();
            if (message != null) {
                message = message.trim();
                if (message.length() != 0) {
                    erreurs.add(message);
                }
            }
            // next cause
            cause = cause.getCause();
        }
        return erreurs;
    }
 
    // getters and setters
...
}
  • 第 16-17 行:[ElectionsException] 类型封装了:
    • 一个错误代码,第 16 行;
    • 一组错误消息,第 17 行;

该类支持五个构造函数:

  • 第 20 行:ElectionsException()
  • 第 24 行:ElectionsException(int code, Throwable e):第二个参数的类型为 [Throwable],它是 [Exception] 类的超类。此构造函数允许将异常 e 与错误代码一起封装。 [Throwable] 类型(因此也包括 Exception 类型)允许您封装一个或多个异常。其设计思想是:
    • 捕获发生的异常;
    • 通过将其封装到一个新异常中,为其添加消息;
    • 抛出该新异常;
try{
...
}catch (Exception1 e1){
   throw new Exception2(«un message»,e1);
}

第 34 行通过 [super(message, e)] 语句实现了封装。该封装过程可以重复进行,初始异常可以被赋予不同的消息。这被称为异常堆栈。方法 [private List<String> getErrorsForException(Throwable th)] 允许您检索与封装的异常相关的各种消息:

  • (待续)
    • (续)
      • 可通过 Throwable 类的 [Throwable].getCause() 方法获取封装后的异常;
      • 通过方法 String [Throwable].getMessage() 获取与异常关联的消息;
  • 第 28–29 行:构造 [code, errors] 字段;
  • 第 32 行:public ElectionsException(int code, String message, Throwable e):此构造函数与前一个类似,不同之处在于它会为其将要封装的异常添加代码和消息;
  • 第 40 行:public ElectionsException(int code, String message):不封装异常的构造函数;
  • 第 50 行:public ElectionsException(int code, List<String> errors):构造函数未对异常进行封装,也未提供异常信息;

[ElectionsException] 类可按以下方式使用:

try{
...
}catch (Exception1 e1){
   throw new ElectionsException(un_code,un_message,e1);
}

其中消息可能存在也可能不存在。一旦创建,[ElectionsException] 异常并不旨在封装新的异常。在上例中,它封装了异常 e1 以及 e1 所封装的异常。除此之外,不再有进一步的封装。

[ElectionsException] 类还可以按以下方式使用:

// code susceptible de rencontrer un cas d'erreur (mais pas sous la forme d'une exception)
...
if(erreur){
    throw new ElectionsException(un_code,un_message);
}