重構模組相依性

隨著程式碼的規模越來越大,將其分割成模組非常重要,這樣一來,您就不需要了解所有程式碼才能進行小幅修改。通常,這些模組可以由不同的團隊提供並動態結合。在這篇重構文章中,我使用呈現-網域-資料分層分割了一個小型程式。然後,我重構這些模組之間的相依性,以引入服務定位器和相依性注入模式。這些模式適用於不同的語言,但看起來不同,因此我在 Java 和無類別 JavaScript 樣式中展示這些重構。

2015 年 10 月 13 日



當程式碼超過幾百行時,您需要思考如何將其分割成模組。至少,擁有較小的檔案有助於更好地管理您的編輯。但更重要的是,您希望分割程式,這樣一來,您就不必將所有內容都記在腦中才能進行變更。

設計良好的模組化結構應讓您在需要對大型程式進行小幅變更時,只需了解其中一小部分即可。有時,小幅變更會跨越模組,但大部分時間您只需要了解單一模組及其鄰近模組即可。

將程式拆分成模組最困難的部分,就是決定模組的界線在哪裡。這方面沒有簡單的準則可遵循,事實上,我畢生工作的重大主題之一,就是試著了解良好的模組界線會是什麼樣子。畫出良好模組界線最重要的部分,或許就是注意您所做的變更,並重構您的程式碼,讓一起變更的程式碼位於同一個或鄰近的模組中。

除此之外,還有讓不同部分如何彼此關聯的機制。最簡單的情況是,您有呼叫供應商的客戶端模組。但這些客戶端和供應商的組態通常會變得錯綜複雜,因為您並不總是希望客戶端程式過於了解其供應商如何彼此配合。

我將透過一個範例來探討這個問題,我會取用一段程式碼,看看如何將其拆分成多個部分。事實上,我將使用兩種不同的語言來執行這項工作:Java 和 JavaScript,儘管它們名稱相似,但它們在模組化能力方面卻有很大的不同。

起點

我們從一家新創公司開始,這家公司對銷售資料進行精密的資料分析。他們有一個有價值的指標,稱為 Gondorff 數字,這是銷售產品時極有用的預測指標。他們的網路應用程式會取得公司的銷售資料,將其輸入他們的精密的演算法,然後印出一個簡單的產品表格及其 Gondorff 數字。

初始狀態的程式碼全部都在一個檔案中,我將分段說明。首先是會在 HTML 中發出表格的程式碼。

app.js

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

我沒有使用多行字串,因為輸出的縮排需求與原始碼的縮排不一致。

這不是世界上最精密的 UI,在這個單頁應用和響應式設計的世界中,它絕對是平庸的。對於這個範例來說,唯一重要的是 UI 需要在不同的地方呼叫 gondorffNumber 函式。

接下來,我將轉到 Gondorff 數字的計算。

app.js

  function gondorffNumber(product) {
    return salesDataFor(product, gondorffEpoch(product), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

  function baselineRange(product){
    // redacted
  }
  function deriveEpoch(countingBase) {
    // redacted
  }
  function hookerExpiry() {
    // redacted
  }

這對我們來說可能看起來不像百萬美元的演算法,但幸運的是這不是這段程式碼的重要部分。重要的部分是這個關於計算 gondorff 數字的邏輯需要兩個函式(salesDataForrecordCounts),它們僅從某種銷售資料來源傳回基本資料。這些資料來源函式並非特別精緻,它們僅過濾從 CSV 檔案取得的資料。

app.js

  function salesDataFor(product, start, end) {
    return salesData()
      .filter(r =>
        (r.product === product)
        && (new Date(r.date) >= start)
        && (new Date(r.date) < end)
      );
  }
  function recordCounts(start) {
    return salesData()
      .filter(r => new Date(r.date) >= start)
      .length
  }
  function salesData() {
    const data = readFileSync('sales.csv', {encoding: 'utf8'});
    return data
      .split('\n')
      .slice(1)
      .map(makeRecord)
      ;
  }
  function makeRecord(line) {
    const [product,date,quantityString,location] = line.split(/\s*,\s*/);
    const quantity =  parseInt(quantityString, 10);
    return { product, date, quantity, location };
  }

就這項討論而言,這些函式完全無聊 - 我只展示它們以求完整性。它們的重要之處在於它們從某些資料來源取得資料,將其整理成簡單的物件,並以兩種不同的形式提供給核心演算法程式碼。

在這個時間點,java 版本看起來非常類似,首先是 HTML 產生。

類別 App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format("  <tr><td>%s</td><td>%4.2f</td></tr>", p, gondorffNumber(p)));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

gondorff 演算法

類別 App...

  public double gondorffNumber(String product) {
    return salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

  private LocalDate baselineRange(String product) {
    //redacted
  }
  private LocalDate deriveEpoch(long base) {
    //redacted
  }
  private LocalDate hookerExpiry() {
    // yup, redacted too
  }

由於資料來源程式碼的主體並不重要,所以我只會顯示方法宣告

類別 App

  private Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) {
    // unimportant details
  }
  private long recordCounts(LocalDate start) {
    // unimportant details
  }

呈現-網域-資料分層

我之前說過,設定模組邊界是一門微妙且細緻的藝術,但許多人遵循的一個準則為 呈現-網域-資料分層 - 將呈現程式碼(UI)、商業邏輯和資料存取分開。遵循這種分割有很好的理由。這三個類別中的每個類別都涉及思考不同的問題,而且經常使用不同的架構來協助執行任務。此外,還需要替換 - 多個呈現使用相同的核心商業邏輯,或商業邏輯在不同的環境中使用不同的資料來源。

因此,對於這個範例,我將遵循這個常見的分割,而且我也會強調替換的理由。畢竟,這個 gondorff 數字是一個如此有價值的指標,許多人會想要使用它 - 鼓勵我將它封裝成一個單元,可以輕鬆地被多個應用程式重複使用。此外,並非所有應用程式都會將其銷售資料保留在 csv 檔案中,有些會使用資料庫或遠端微服務。我們希望應用程式開發人員能夠取得 gondorff 程式碼,並將其插入她的特定資料來源,她可以自己撰寫或從另一位開發人員取得。

但在我們開始進行重構以啟用所有這些功能之前,我確實需要強調呈現-網域-資料分層確實有其限制。模組化的普遍規則是,如果可以,我們希望將變更的後果限制在一個模組中。但分開的呈現-網域-資料模組通常確實必須一起變更。新增資料欄位的簡單動作通常會導致所有三個模組更新。因此,我贊成在較小的範圍內使用這種方法,但較大的應用程式需要沿著不同的路線開發高層級模組。特別是,您不應該使用呈現-網域-資料層作為團隊邊界的基礎。

執行分割

我將從分離簡報開始,將其分割成模組。對於 JavaScript 案例,這幾乎只是將程式碼剪下並貼到新的檔案中。

gondorff.es6

  export default function gondorffNumber …
  function gondorffEpoch(product) {…
  function baselineRange(product){…
  function deriveEpoch(countingBase) { …
  function hookerExpiry() { …
  function salesDataFor(product, start, end) { …
  function recordCounts(start) { …
  function salesData() { …
  function makeRecord(line) { …

透過使用 export default,我可以匯入對 gondorffNumber 的參照,而且我只需要新增一個匯入陳述式。

app.es6

  import gondorffNumber from './gondorff.es6'

在 Java 方面,這幾乎一樣簡單。我再次將除了 emitGondorff 以外的所有內容複製到新的類別中。

class Gondorff…

  public double gondorffNumber(String product) { …
  private LocalDate gondorffEpoch(String product) { …
  private LocalDate baselineRange(String product) { …
  private LocalDate deriveEpoch(long base) { …
  private LocalDate hookerExpiry() { …

  Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) { …
  long recordCounts(LocalDate start) {…
  Stream<SalesRecord> salesData() { …
  private SalesRecord makeSalesRecord(String line) { …

對於原始的 App 類別,除非我將新的類別放入新的套件中,否則我不需要匯入,但我確實需要實例化新的類別。

類別 App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format("  <tr><td>%s</td><td>%4.2f</td></tr>", p, new Gondorff().gondorffNumber(p)));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

現在我想在計算邏輯和提供資料記錄的程式碼之間進行第二次分離。

dataSource.es6…

  export function salesDataFor(product, start, end) {

  export function recordCounts(start) {

  function salesData() { …
  function makeRecord(line) { …

這個動作與前一個動作之間的差異在於 gondorff 檔案需要匯入兩個函式,而不仅仅是一個函式。它可以使用這個匯入來執行此動作,不需要變更其他任何內容。

Gondorff.es6…

  import {salesDataFor, recordCounts} from './dataSource.es6'

Java 版本與前一個案例非常類似,移至新的類別,並針對新的物件實例化類別。

class DataSource…

  public Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) { …
  public long recordCounts(LocalDate start) {…
  Stream<SalesRecord> salesData() { …
  private SalesRecord makeSalesRecord(String line) { …

class Gondorff...

  public double gondorffNumber(String product) {
    return new DataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = new DataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

這種檔案分離是一種機械化程序,並沒有那麼有趣。但在我們進行有趣的重構之前,這是必要的步驟。

連結器替換

將程式碼分為多個模組很有幫助,但所有這些中最困難的挑戰是希望將 Gondorff 計算分發為一個獨立的元件。目前 Gondorff 計算假設銷售資料來自具有特定路徑的 csv 檔案。分離資料來源邏輯讓我能夠變更這一點,但我目前的機制很尷尬,而且還有其他選項可以探索。

那麼目前的機制是什麼?基本上這就是我所謂的連結器替換。術語「連結器」有點像是 C 等編譯程式語言的回溯,其中連結階段會解析不同編譯單元中的符號。在 JavaScript 中,我可以透過操作匯入命令的檔案查詢路徑來達成這個目的。

假設我想在環境中安裝這個應用程式,而他們不會將銷售記錄保存在 CSV 檔案中,而是對 SQL 資料庫執行查詢。為了讓這項工作順利進行,我首先需要建立一個 CorporateDatabaseDataSource 檔案,其中包含 salesDataForrecordCounts 的已匯出函式,這些函式會以 Gondorff 檔案預期的格式傳回資料。然後我用這個新的檔案取代 DataSource 檔案。然後,當我執行應用程式時,它會「連結」到已取代的 DataSource 檔案。

對於許多依賴某種路徑查詢機制進行連結的動態語言,連結器替換是一種用於簡單元件替換的相當不錯的技術。我無需對我的程式碼做任何事即可使其運作,除了我剛剛執行的簡單檔案分離。如果我有一個建置指令碼,我可以透過將不同的檔案複製到路徑中的適當點,為不同的資料來源環境建置程式碼。這說明了將程式分解成小片段的優點 - 它允許替換這些片段,即使原始撰寫者沒有任何替換的想法。它能實現無法預見的客製化。

在 Java 中執行連結器替換基本上是相同的任務。我需要將 DataSource 封裝在與 Gondorff 分開的 jar 檔案中,然後指示 Gondorff 的使用者建立一個名為 DataSource 的類別,並使用適當的方法將其放入類別路徑中。

不過,使用 Java 時,我會執行一個額外的步驟,對資料來源套用 Extract Interface

public interface DataSource {
  Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end);
  long recordCounts(LocalDate start);
}
public class CsvDataSource implements DataSource {

使用 Required Interface 很有幫助,因為它明確說明了 gondorff 從其資料來源預期的功能。

動態語言的缺點之一是它們缺乏這種明確性,當組合已個別開發的元件時,這可能會造成問題。JavaScript 的模組系統在此運作良好,因為它靜態定義模組相依性,因此它們是明確的,並且可以在靜態中檢查。靜態宣告有成本和好處,最近語言設計中一個不錯的發展是嘗試對靜態宣告採取更細緻的方法,而不是僅將語言視為純靜態或動態。

連結器替換的優點是元件作者需要做的工作很少,因此符合無法預見的客製化。但它有其缺點。在某些環境中,例如 Java,使用它可能會很麻煩。程式碼不會揭示替換如何運作,因此沒有機制可以在程式碼庫中控制替換。

此程式碼中缺乏存在性的重要後果是,替換無法動態進行 - 也就是說,一旦程式組譯並執行後,我無法變更資料來源。這通常在製作上並非大問題,有時候熱交換資料來源會很有用,但這只是少數情況。但動態替換的價值在於測試。通常會希望使用測試替身提供罐頭資料進行測試,這通常表示我需要針對不同的測試案例加入不同的替身。

程式碼庫中需要更明確的要求和動態替換進行測試,通常會讓我們探索其他替代方案,這些替代方案允許我們明確指定元件的連接方式,而不用只依賴路徑查詢。

每個呼叫都將資料來源作為參數

如果我們想要支援使用不同的資料來源呼叫 gondorff,一個顯而易見的方法是每次呼叫時將其傳遞為參數。

讓我們先看看 Java 版本中的樣貌,從萃取 DataSource 介面後的 Java 版本目前狀態開始

類別 App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

class Gondorff...

  public double gondorffNumber(String product) {
    return new CsvDataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = new CsvDataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

若要將資料來源傳遞為參數,產生的程式碼如下。

類別 App...

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p, new CsvDataSource())
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

class Gondorff...

  public double gondorffNumber(String product, DataSource dataSource) {
    return dataSource.salesDataFor(product, gondorffEpoch(product, dataSource), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product, DataSource dataSource) {
    final long countingBase = dataSource.recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

我可以透過幾個小步驟進行此重構。

  • gondorffEpoch 使用新增參數以新增 dataSource
  • 取代對 new CsvDataSource() 的呼叫,使用剛剛新增的 dataSource 參數
  • 編譯並測試
  • gondorffNumber 重複執行

現在轉到 JavaScript 版本,以下是目前狀態。

app.es6…

  import gondorffNumber from './gondorff.es6'

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

Gondorff.es6…

  import {salesDataFor, recordCounts} from './dataSource.es6'

  export default function gondorffNumber(product) {
    return salesDataFor(product, gondorffEpoch(product), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

在這個案例中,我可以將兩個函式都傳遞為參數

app.es6…

  import gondorffNumber from './gondorff.es6'
  import * as dataSource from './dataSource.es6'

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product, dataSource.salesDataFor, dataSource.recordCounts).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

Gondorff.es6…

  import {salesDataFor, recordCounts} from './dataSource.es6'
  
  export default function gondorffNumber(product, salesDataFor, recordCounts) {
    return salesDataFor(product, gondorffEpoch(product, recordCounts), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product, recordCounts) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

與 Java 範例一樣,我可以先對 gondorffEpoch 套用新增參數,編譯並測試,然後對每個函式對 gondoffNumber 執行相同的動作。

在這種情況下,我會傾向將 salesDataForrecordCounts 函式都放入單一資料來源物件,然後傳遞該物件 - 基本上使用引入參數物件。我不會在本文中執行此動作,主要是因為如果我不這麼做,將能更清楚展示如何處理一級函式。但如果 gondorff 需要使用更多資料來源函式,我會這麼做。

將資料來源檔案名稱參數化

作為進一步的步驟,我可以將資料來源的檔案名稱參數化。對於 Java 版本,我透過將檔案名稱欄位新增至資料來源,並使用 新增參數 至其建構函式來執行此動作。

class CsvDataSource…

  private String filename;
  public CsvDataSource(String filename) {
    this.filename = filename;
  }

class App…

  public String emitGondorff(List<String> products) {
    DataSource dataSource = new CsvDataSource("sales.csv");
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p, dataSource)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

對於 JavaScript 版本,我需要在資料來源上需要它的函式中使用 新增參數

dataSource.es6…

  export function salesDataFor(product, start, end, filename) {
    return salesData(filename)
      .filter(r =>
      (r.product === product)
      && (new Date(r.date) >= start)
      && (new Date(r.date) < end)
    );
  }
  export function recordCounts(start, filename) {
    return salesData(filename)
      .filter(r => new Date(r.date) >= start)
      .length
  }

如果維持現狀,這將迫使我將檔案名稱參數放入 gondorff 函式中,但實際上它們不應該需要知道任何相關資訊。我可以透過建立一個簡單的轉接器來修復此問題。

dataSourceAdapter.es6…

  import * as ds from './dataSource.es6'
  
  export default function(filename) {
    return {
      salesDataFor(product, start, end) {return ds.salesDataFor(product, start, end, filename)},
      recordCounts(start) {return ds.recordCounts(start, filename)}
    }
  }

應用程式程式碼在將資料來源傳遞至 gondorff 函式時會使用這個轉接器。

app.es6…

  import gondorffNumber from './gondorff.es6'
  import * as dataSource from './dataSource.es6'
  import createDataSource from './dataSourceAdapter.es6'

  function emitGondorff(products) {
    function line(product) {
      const dataSource = createDataSource('sales.csv');
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product, dataSource.salesDataFor, dataSource.recordCounts).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

參數化的取捨

在每次呼叫 gondorff 時傳入資料來源會提供我正在尋找的動態替換。身為應用程式開發人員,我可以使用我喜歡的任何資料來源,我也可以在需要時透過傳入 stub 資料來源輕鬆進行測試。

但是,每次呼叫都使用參數也有缺點。首先,我必須將資料來源 (或其函式) 作為參數傳遞給 gondorff 中的每個函式,無論是需要它的函式,還是呼叫需要它的另一個函式。這可能會導致資料來源成為到處遊走的垃圾資料。

更嚴重的問題是,現在每次我有一個使用 gondorff 的應用程式模組時,我都必須確保我也能建立和設定資料來源。如果我的設定比較複雜,其中包含需要幾個必要元件的通用元件,而每個元件都有自己的一組必要元件,這很容易變得混亂。每次使用 gondorff 時,我都必須將如何設定 gondorff 物件的知識嵌入其中。這是一個會使程式碼複雜化,並使其更難理解和使用的重複。

我可以透過查看相依性來視覺化這一點。在將資料來源作為參數引入之前,相依性如下所示

當我將資料來源作為參數傳遞時,它看起來像這樣。

在這些圖表中,我區分了使用相依性和建立相依性。使用相依性表示客戶端模組呼叫在供應商上定義的函式。gondorff 和資料來源之間將永遠存在使用相依性。建立相依性是一種更親密的相依性,因為您通常需要進一步了解供應商模組才能設定和建立它。(建立相依性暗示了使用相依性。)在每次呼叫中使用參數會將 gondorff 的相依性從建立降低為使用,但會從任何應用程式中引入建立相依性。

除了建立依賴問題之外,由於我實際上不想在生產程式碼中變更資料來源,因此還有另一個問題。在每次呼叫 gondorff 時傳遞參數表示我在呼叫之間變更參數,但在此,每當我呼叫 gondorffNumber 時,我總是傳遞完全相同的資料來源。這種不協調可能會在六個月後讓我感到困惑。

如果我始終對資料來源使用相同的組態,那麼在每次使用時設定一次並參考它是有道理的。但如果我這樣做,我可能也會設定 gondorff 一次,並在每次想要使用它時使用完全組態的 gondorff。

因此,在探討每次使用參數的樣子之後,我將使用我的版本控制系統,並對本節開頭的位置進行硬重置,以便我可以探索另一條路徑。

單一服務

gondorff 和 dataSource 的一個重要屬性是它們都可以充當單一服務物件。服務物件是 Evans 分類 的一部分,指的是以活動為導向的物件,而不是以資料為中心的實體或值。我經常將服務物件稱為「服務」,但它們與 SOA 中的服務不同,因為它們不是網路可存取的元件。在函式世界中,服務通常只是函式,但有時您確實會發現想要將一組函式視為單一事物的情況。我們在資料來源中看到這一點,其中我們有兩個函式,我可以將它們視為單一資料來源的一部分。

我也說了「單一」,我的意思是,對於整個執行內容來說,只有一個這樣的概念是有道理的。由於服務通常是無狀態的,因此只有一個是有道理的。如果某個東西在執行內容中是單一的,則表示我們可以在程式中全局參考它。我們甚至可能想要強制它成為單例,可能是因為設定成本很高,或者對它所操作的資源有並行限制。在我們執行的整個過程中可能只有一個,或者可能有多個,例如每個執行緒使用執行緒特定儲存體。但無論如何,就我們的程式碼而言,只有一個。

如果我們選擇讓 gondorff 計算器和資料來源成為單一服務,那麼在應用程式啟動期間設定它們一次,然後在稍後使用時參照它們是有意義的。

這引入了服務處理方式的分離:設定和使用的分離。有幾種方法可以重構此程式碼以進行此分離:引入服務定位器模式或依賴性注入模式。我將從服務定位器開始。

引入服務定位器

服務定位器模式背後的想法是有一個單一點讓組件定位服務。定位器是服務的註冊表。在使用中,客戶端使用全域查詢註冊表,然後向註冊表要求特定服務。設定會使用所需的所有服務設定定位器。

引入它的重構的第一步是建立定位器。它是一個相當簡單的結構,只比全域記錄多一點,所以我的 JavaScript 版本只有一些變數和一個簡單的初始化器。

serviceLocator.es6…

  export let salesDataFor;
  export let recordCounts;
  export let gondorffNumber;
  
  export function initialize(arg) {
    salesDataFor: arg.salesDataFor;
    recordCounts: arg.recordCounts;
    gondorffNumber = arg.gondorffNumber;
  }

export let 將變數作為唯讀檢視匯出到其他模組。[1]

當然,Java 的版本會稍微冗長一點。

class ServiceLocator…

  private static ServiceLocator soleInstance;
  private DataSource dataSource;
  private Gondorff gondorff;

  public static DataSource dataSource() {
     return soleInstance.dataSource;
  }

  public static Gondorff gondorff() {
    return soleInstance.gondorff;
  }

  public static void initialize(ServiceLocator arg) {
    soleInstance = arg;
  }

  public ServiceLocator(DataSource dataSource, Gondorff gondorff) {
    this.dataSource = dataSource;
    this.gondorff = gondorff;
  }

在這種情況下,我比較喜歡提供靜態方法的介面,這樣定位器的客戶端就不需要知道資料儲存在哪裡。但我喜歡使用單一執行個體的資料,因為這樣可以更輕鬆地替換進行測試。

在兩種情況下,服務定位器都是一組屬性。

重構 JavaScript 以使用定位器

定義定位器後,下一步是開始將服務移到它上面,我從 gondorff 開始。若要設定服務定位器,我將撰寫一個小模組來設定服務定位器。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  
  export default function() {
    locator.initialize({gondorffNumber: gondorffImpl});
  }

我需要確保在應用程式啟動時匯入並呼叫此函式。

某些啟動檔案…

  import initializeServices from './configureServices.es6';

  initializeServices();

為了喚醒我們的記憶,以下是目前的應用程式程式碼(在先前還原後)。

app.es6…

  import gondorffNumber from './gondorff.es6'

  function emitGondorff(products) {
    function line(product) {
      return [
        `  <tr>`,
        `    <td>${product}</td>`,
        `    <td>${gondorffNumber(product).toFixed(2)}</td>`,
        `  </tr>`].join('\n');
    }
    return encodeForHtml(`<table>\n${products.map(line).join('\n')}\n</table>`);
  }

若要改用服務定位器,我只需要調整匯入陳述式即可。

app.es6…

  import gondorffNumber from './gondorff.es6'
  import {gondorffNumber} from './serviceLocator.es6';

只要進行這個變更,就能執行測試,確保我沒有搞砸(聽起來比「找出我搞砸的地方」好)。變更完成後,我對資料來源進行類似的變更。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  import * as dataSource from './dataSource.es6' ;
  
  
  export default function() {
    locator.initialize({
      salesDataFor: dataSource.salesDataFor,
      recordCounts: dataSource.recordCounts,
      gondorffNumber: gondorffImpl
    });
  }

Gondorff.es6…

  import {salesDataFor, recordCounts} from './serviceLocator.es6'

我可以使用與先前相同的重構,將檔案名稱參數化,這次變更只會影響服務組態函數。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  import * as dataSource from './dataSource.es6' ;
  import createDataSource from './dataSourceAdapter.es6'
  
  
  export default function() {
    const dataSource = createDataSource('sales.csv');
    locator.initialize({
      salesDataFor: dataSource.salesDataFor,
      recordCounts: dataSource.recordCounts,
      gondorffNumber: gondorffImpl
    });
  }

dataSourceAdapter.es6…

  import * as ds from './dataSource.es6'
  
  export default function(filename) {
    return {
      salesDataFor(product, start, end) {return ds.salesDataFor(product, start, end, filename)},
      recordCounts(start) {return ds.recordCounts(start, filename)}
    }
  }

Java

Java 案例看起來很類似。我建立一個組態類別來填入服務定位器。

類別 ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(null, new Gondorff());
      ServiceLocator.initialize(locator);
    }
  }

並確保在應用程式啟動的某個地方呼叫它。

目前的應用程式程式碼如下所示

class App…

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              new Gondorff().gondorffNumber(p)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

現在我使用定位器來取得 gondorff 物件。

class App…

  public String emitGondorff(List<String> products) {
    List<String> result = new ArrayList<>();
    result.add("\n<table>");
    for (String p : products)
      result.add(String.format(
              "  <tr><td>%s</td><td>%4.2f</td></tr>",
              p,
              ServiceLocator.gondorff().gondorffNumber(p)
      ));
    result.add("</table>");
    return HtmlUtils.encode(result.stream().collect(Collectors.joining("\n")));
  }

若要將資料來源物件加入組合中,我從將它加入定位器開始。

類別 ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(new CsvDataSource(), new Gondorff());
      ServiceLocator.initialize(locator);
    }
  }

目前 gondorff 物件如下所示

class Gondorff…

  public double gondorffNumber(String product) {
    return new CsvDataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = new CsvDataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

使用服務定位器會將它變更為

class Gondorff…

  public double gondorffNumber(String product) {
    return ServiceLocator.dataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = ServiceLocator.dataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

與 JavaScript 案例一樣,將檔案名稱參數化只會變更服務組態程式碼。

類別 ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(new CsvDataSource("sales.csv"), new Gondorff());
      ServiceLocator.initialize(locator);
    }
  }

使用服務定位器的後果

使用服務定位器的直接影響是改變我們三個元件之間的相依性。在元件進行簡單的區分後,我們可以看到相依性如下所示。

引入服務定位器會移除主要模組之間的所有建立相依性。 [2]。當然,這是忽略了組態服務模組,其中包含所有建立相依性。

我確定你們有些人可能已經注意到,應用程式自訂是由服務組態函數完成的,這表示任何自訂都是由我先前說過我們需要擺脫的相同連結器替換機制完成的。在某種程度上來說是正確的,但服務組態模組明顯獨立的事實讓我更有彈性。程式庫提供者可以提供一系列資料來源實作,而客戶端可以撰寫一個服務組態模組,它會根據組態參數(例如組態檔案、環境變數或命令列變數)在執行階段選取一個。這裡有一個潛在的重構,可以從組態檔案中引入參數,但我會留待以後再處理。

但是,使用服務定位器的一個特定結果是,現在我可以輕鬆地替換服務以進行測試。我可以為 gondorff 的資料來源放入一個測試 stub,如下所示

測試…

  it('can stub a data source', function() {
    const data = [
      {product: "p", date: "2015-07-01", quantity: 175}
    ];
    const newLocator = {
      recordCounts: () => 500,
      salesDataFor: () => data,
      gondorffNumber: serviceLocator.gondorffNumber
    };
    serviceLocator.initialize(newLocator);
    assert.closeTo(549.7787, serviceLocator.gondorffNumber("p"), 0.001);
  });

類別 Tester...

  @Test
  public void can_stub_data_source() throws Exception {
    ServiceLocator.initialize(new ServiceLocator(new DataSourceStub(), ServiceLocator.gondorff()));
    assertEquals(549.7787, ServiceLocator.gondorff().gondorffNumber("p"), 0.001);
  }
  private class DataSourceStub implements DataSource {
    @Override
    public Stream<SalesRecord> salesDataFor(String product, LocalDate start, LocalDate end) {
      return Collections.singletonList(new SalesRecord("p", LocalDate.of(2015, 7, 1), 175)).stream();
    }
    @Override
    public long recordCounts(LocalDate start) {
      return 500;
    }
  }

分割階段

在我撰寫這篇文章時,我拜訪了 Kent Beck。在他餵我吃他自製的起司後,我們的對話轉向了重構主題,他告訴我一個重要的重構,他在十年前就已經發現,但從未寫成一篇像樣的文章。這個重構涉及將複雜的運算分成兩個階段,第一個階段會將結果傳遞給第二個階段,並附帶一些中間結果資料結構。這個模式的一個大規模範例是編譯器使用的模式,它將工作分成許多階段:將符號化、解析、產生程式碼,並使用符號串流和解析樹等資料結構作為中間結果。

當我回到家並再次開始撰寫這篇文章時,我很快地發現,引入這樣的服務定位器就是分割階段重構的一個範例。我已使用服務定位器將服務物件的組態抽取到自己的階段中,作為中間結果,將組態服務階段的結果傳遞給程式中的其他部分。

將計算拆分為獨立的階段是一種有用的重構,因為它讓我們可以分別思考每個階段的不同需求,每個階段的結果都有明確的指示(在中間結果中),而且每個階段都可以透過檢查或提供中間結果來獨立測試。當我們將中間結果視為不可變資料結構時,這種重構特別有效,讓我們享有使用後續階段程式碼的好處,而無需考慮早期階段所產生資料的變異行為。

當我寫下這些文字時,距離與 Kent 的那次對話才過了一個多月,但我覺得階段拆分這個概念是一個強大的重構工具。就像許多偉大的模式一樣,它有種顯而易見的感覺 - 我覺得它只是為我數十年來一直在做的事情命名。但這樣的名稱並非小事,一旦你為這種常用的技術命名,就能更容易與其他人討論,並改變自己的思考方式:賦予它更核心的角色,並比無意識地進行時更審慎地使用它。

相依性注入

使用服務定位器有一個缺點,那就是組件物件需要知道服務定位器如何運作。如果 gondorff 計算器只在使用相同服務定位器機制的眾所周知應用程式範圍內使用,這就不是問題,但如果我想賣掉它來賺取財富,這種耦合就會成為問題。即使所有渴望購買的人都在使用服務定位器,他們也不太可能都使用相同的 API。我需要的是一種方式,讓 gondorff 可以使用資料來源進行組態,而不需要使用語言本身內建的任何機制。

這就是導致產生稱為依賴性注入的另一種組態形式的需求。依賴性注入被廣為宣傳,特別是在 Java 世界中,有各種各樣的架構來實作它。雖然這些架構可能很有用,但基本概念其實非常簡單,我將透過將此範例重構成一個簡單的實作來說明它。

Java 範例

這個概念的核心是,你應該能夠撰寫像 gondorff 物件這樣的組件,而不需要知道任何特殊慣例或工具來組態依賴組件。在 Java 中,這樣做的自然方式是讓 gondorff 物件有一個欄位來儲存資料來源。這個欄位可以使用服務組態以你用來填入任何欄位的方式填入 - 使用設定器或在建構期間。由於 gondorff 物件需要資料來源才能執行任何有用的操作,我通常的做法是將它放入建構函式中。

class Gondorff…

  private DataSource dataSource;

  public Gondorff(DataSource dataSource) {
    this.dataSource = dataSource;
  }
  private DataSource getDataSource() {
    return (dataSource != null) ? dataSource : ServiceLocator.dataSource();
  }
  public double gondorffNumber(String product) {
    return getDataSource().salesDataFor(product, gondorffEpoch(product), hookerExpiry())
            .filter(r -> r.getDate().toString().matches(".*01$"))
            .findFirst()
            .get()
            .getQuantity() * Math.PI
            ;
  }
  private LocalDate gondorffEpoch(String product) {
    final long countingBase = getDataSource().recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

類別 ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      ServiceLocator locator = new ServiceLocator(new CsvDataSource("sales.csv"), new Gondorff(null));
      ServiceLocator.initialize(locator);
    }
  }

透過放入存取器 getDataSource,我可以以較小的步驟進行重構。這段程式碼與使用服務定位器所做的組態搭配使用時運作良好,我可以逐漸用使用這個新的依賴性注入機制的測試來替換設定定位器的測試。第一次重構只會新增欄位並套用 新增參數。呼叫者最初可以使用具有 null 引數的建構函式,而我可以一次處理一個,提供資料來源,並在每次變更後進行測試。(當然,由於我們在組態階段進行所有服務組態,因此通常沒有太多呼叫者。我們在測試中會得到更多呼叫者。)

類別 ServiceConfigurator…

  public class ServiceConfigurator {
    public static void run() {
      DataSource dataSource = new CsvDataSource("sales.csv");
      ServiceLocator locator = new ServiceLocator(dataSource, new Gondorff(dataSource));
      ServiceLocator.initialize(locator);
    }
  }

完成所有這些步驟後,我可以移除 gondorff 物件中所有對服務定位器的參照。

class Gondorff…

  private DataSource getDataSource() {
    return (dataSource != null) ? dataSource : ServiceLocator.dataSource();
    return dataSource;
  }

如果願意,我也可以內嵌 getDataSource

JavaScript 範例

由於我在 JavaScript 範例中避開類別,因此確保 gondorff 計算器取得資料來源函數而不使用額外架構的方法,就是透過每次呼叫將它們傳遞為參數。

Gondorff.es6…

  import {recordCounts} from './serviceLocator.es6'
  
  export default function gondorffNumber(product, salesDataFor, recordCounts) {
    return salesDataFor(product, gondorffEpoch(product, recordCounts), hookerExpiry())
        .find(r => r.date.match(/01$/))
        .quantity * Math.PI
      ;
  }
  function gondorffEpoch(product, recordCounts) {
    const countingBase = recordCounts(baselineRange(product));
    return deriveEpoch(countingBase);
  }

我當然在之前就使用過這種方法,但這次需要確保客戶端不需要在每次呼叫時執行任何設定。我可以透過提供部分套用的 gondorff 函數給客戶端來執行此動作。

configureServices.es6…

  import * as locator from './serviceLocator.es6';
  import gondorffImpl from './gondorff.es6';
  import createDataSource from './dataSourceAdapter.es6'
  
  
  export default function() {
    const dataSource = createDataSource('sales.csv');
    locator.initialize({
      salesDataFor: dataSource.salesDataFor,
      recordCounts: dataSource.recordCounts,
      gondorffNumber: (product) => gondorffImpl(product, dataSource.salesDataFor, dataSource.recordCounts)
    });
  }

後果

如果我們在使用階段檢視相依性,圖示如下。

這與先前使用服務定位器的唯一差異,在於 gondorff 與服務定位器之間不再有任何相依性,而這就是使用相依性注入的重點。(組態階段的相依性與建立相依性為同一組。)

一旦我移除 gondorff 對服務定位器的相依性,如果沒有其他類別需要從服務定位器取得資料來源,我也可以完全移除服務定位器中的資料來源欄位。我也可以使用相依性注入來提供 gondorff 物件給應用程式類別,儘管這樣做的價值較低,因為應用程式類別並非共用,因此不會因使用定位器而處於劣勢。通常會看到服務定位器與相依性注入模式像這樣一起使用,其中服務定位器用於取得已透過相依性注入完成進一步組態的初始服務。相依性注入容器通常會用於服務定位器,藉由提供查詢服務的機制。

最後的想法

此重構情節的關鍵訊息,在於將服務組態階段與服務使用階段分開。如何使用服務定位器與相依性注入來執行此動作並非重點,這取決於您所處的特定情況。這些情況很可能會引導您使用封裝架構來管理這些相依性,或者如果您的案例很簡單,自行開發也無妨。


腳註

1: 我使用 Babel 開發這些範例。目前 Babel 有 一個錯誤,允許您重新指派已匯出的變數。ES6 規格指出匯出的變數 匯出為唯讀檢視

2: 有人可能會爭論,服務定位器的 Java 版本依賴於 gondorff 和資料來源,因為它們在型別簽章中被提及。我這裡不考慮這一點,因為定位器實際上不會呼叫那些類別上的任何方法。我也可以透過一些型別體操來移除那些靜態型別依賴項,儘管我懷疑治標會比治本更糟。

致謝

Pete Hodgson 和 Axel Rauschmayer 在改善我的 JavaScript 方面給了我寶貴的幫助。Ben Wu(伍斌)建議了一個有用的說明。Jean-Noël Rouvignac 糾正了幾個錯別字。

重大修訂

2015 年 10 月 13 日:首次發布