用Angular構建一個真正的CRUD應用

關於如何開發CRUD應用的文章已然不計其數,我有意再添一篇,試圖將重點落在“真正”兩字上。 我發現網上很多示例通常會把開發CRUD應用說得很容易,而我想說這種程序的開發其實比大多數人想象的要難。我更不覺得開發CRUD很低端,相反我覺得是那些失真的示例,通過有意無意地隱藏複雜度,向世人傳遞了一種不實的映像。

我將使用一個常用的系統模塊作爲示例,即用戶管理。“用戶”是指被授予訪問系統資源的人,我們需要一個UI針對用戶對象進行增刪改查的操作。這個示例能讓我們真正看清一個CRUD應用從設計到開發的整個過程。您同時也能瞭解一些相關的思考、模式以及困難。

我選擇Angular作爲UI技術,因爲我喜歡它的雙向綁定和組件設計,這和我一直以來的開發理念是相符的。當然,開發CRUD應用還涉及許多其他技術,有些太重要了,以至於我們忘記了它們的存在。撇開操作系統和數據庫不表,我選擇在NodeJS上構建我的應用。您可以先看一下演示,再考慮是否值得花30分鐘來閱讀我這篇絮絮叨叨的博客。您還可以直接從Github上查閱完整的代碼。

先建模

第一個問題,先從哪裏入手? 有些人喜歡先繪製UI樣例,而另外一些人則選擇先設計數據庫表。 無論採用哪種方法,您實際上都在進行建模思考。這個階段,您唯一要考慮的是“用戶”應該具有哪些屬性。我這裏選擇用時下流行的JSON去建模。

{"r_user": {"USER_ID": "DH001", "USER_NAME": "VINCEZK", "PASSWORD": "Dark1234", "PWD_STATE": 1, "LOCK": null,
             "DISPLAY_NAME": "Vincent Zhang", "FAMILY_NAME": "Zhang", "GIVEN_NAME": "Vincent", "MIDDLE_NAME": null},
"r_employee": {"USER_ID": "DH001", "COMPANY_ID": "Darkhouse", "DEPARTMENT_ID": "Development", "TITLE": "Developer", "GENDER": "Male"},
"r_email": [
    {"EMAIL": "[email protected]", "TYPE": "private", "PRIMARY": 1},
    {"EMAIL": "[email protected]", "TYPE": "work", "PRIMARY": 0}
],
"r_address": [
    {"ADDRESS_ID": 527, "COUNTRY": "China", "CITY": "Shanghai", "POSTCODE": 201202, 
     "ADDRESS_VALUE": "Room #999, Building #99, XXX Road #999", "TYPE": "Current Live", "PRIMARY": 1 },
    {"ADDRESS_ID": 528, "COUNTRY": "China", "CITY": "Haimen", "POSTCODE": 226126,
     "ADDRESS_VALUE": "Village LeeZhoo", "TYPE": "Born Place", "PRIMARY": 0}
],
"r_personalization": {"USER_ID": "DH001", "DATE_FORMAT": null, "DECIMAL_FORMAT": null, "TIMEZONE": "UTC+8", "LANGUAGE": "ZH" },
"relationshipWithRole": [
    {"NAME": "administrator"},
    {"NAME": "tester"}
]
}

JSON建模的好處是直接方便。您需要的只是一個文本編輯器。自然,你還得有一個模式規劃。如上示例中,“ r_user”是將一些相關屬性歸在一起,我將之稱爲“relation”。如果某個“relation”具有多個元組,則用數組表示,例如“r_email”和“r_address”。如果實體與實體間存在某種關係,則參考“relationshipWithRole”。

以此JSON模型爲基礎,我們可以選擇向上或向下擴展。向上通UI,向下連數據庫。
到底先做哪個呢? 如果您是收錢辦事,那麼應先做UI部分,以便能儘早覈實需求。就我而言,我沒有這種壓力,因此我會首先處理數據庫,這樣會節約一些開發時間。

接下來,我們將會面對一個老問題:對象關係映射。我目前的JSON模型是面向對象的,是否應使用相同的對象模式去存儲它呢? 我的回答永遠是“否”。我們存儲數據的最終目的是方便以後查閱分析。而那個時候,我們將主要以“集合”的形式,而非“對象”形式去訪問它們。如果我爲了開始的便利,以對象形式存儲,那麼我將在查閱分析時付出更多的代價。所以我的選擇一定是關係型數據庫,並使用
JSON-On-Relations(簡稱JOR) 作爲對象關係映射框架。

由於“用戶”會對應到某個“人”,因此我創建了兩個角色“system_user”和“employee”,並將它們分配給個實體“person”。
Entity: Person
在角色“system_user”中,我分配了4個relation,分別是:“r_address”,“r_email”,“r_personalization”,以及“r_user”。每個relation都有對應的基數設置。例如:“r_user”的“[1…1]”表示每個實例在“r_user”中必須具有1個數據項。
Role: system_user
使用JOR的圖形建模工具,我可以輕鬆地創建數據庫表並將它們組成“用戶”實體。如您所見,它遵循實體關係模型這個聽起來有點過時的概念,但實際上,它遠比那些試圖隱藏數據庫的ORM深刻。此外,JOR提供了開箱即用的RESTful API,以滿足對實體主要的CRUD操作。

然而就數據建模本身而言,絕非易事。 工具只能幫助您實現它,而無法幫助您設計它。困難點在於如何設計一個兼具可適用性、可擴展性和可重用性的數據模型。有人倡導“漸進式架構(Growing Architecture)”,認爲架構一開始可以不用那麼好,慢慢會變好。我認爲這個理念並不適用於數據建模。精心設計的數據模型對於軟件的生命週期至關重要;而粗製濫造的數據模型是不會漸進的。

在我設計“用戶”模型時,我開始認爲用戶必須是一個人。然而這並不確切。 在A2A集成方案中使用的通信用戶就不是一個自然人。它只是一種具有訪問某些系統資源的憑據。因此,我將“system_user”定義爲角色,而不是實體。當將其分配給某個自然人時,該人就具有系統用戶這個角色,並以此能訪問某些系統資源。如您所見,建模實際上是哲學性很強的思考,特別形而上學。

繪製UI

從架構上看,CRUD應用通常具有3層:數據庫、應用服務器和用戶界面(UI)。業務邏輯散佈在這三層中,您很難消除其中任何一層。應用服務器處於數據庫和UI之間,用於處理從UI發來的請求,並將之轉換爲對數據庫的訪問請求。沒有這個中間層,將會極大加重UI與數據庫的通訊成本。與單用戶應用相比(例如Office Word),CRUD應用是必須要支持多用戶併發訪問的

接下來,我將繪製用戶界面。取決於您對UI技術的熟悉程度,您既可以用鉛筆和紙來繪製UI,也可以利用一些UI建模工具,或直接使用正式的UI開發工具。由於我在數據建模的同時想象了UI,因此我就直接用HTML和CSS來繪製UI,這樣我可以節省很多時間。 我的UI共有2個頁面:“搜索和列表”頁面,以及“詳細”頁面。
Search&List Page
“搜索和列表”頁面除了允許您搜索和列示用戶外,您還可以創建一個新用戶,或刪除一個現有用戶。單擊User ID鏈接,或者點擊顯示/更改操作按鈕,將導航到“詳細”頁面。
Detail Page
“詳細”頁面顯示用戶的詳細信息。它具有一個固定的擡頭,以及5個自由選項卡,用於對信息進行分組。右上角有“編輯/顯示”和“保存”按鈕。

這兩個頁面目前還是靜態的。 數據是固定的,按鈕是禁用的,鏈接是假的。我只是想先儘快把它畫出來,看看是否符合我的預期。在實際項目中,您可能需要將這樣的頁面給產品經理過目,以檢查是否滿足需求。

也許有人會問:“爲什麼你還要人工繪製UI?不是有很多工具可以根據數據模型自動生成UI嗎?”我的回答是:就我所知的產品和經歷過的項目,我從未見過這種方式真正成功過。也許,許多技術佈道師正在宣講這樣的開發方式,例如低代碼或無代碼,但我不相信這個能成功。

正如我前面所說的三層架構,您無法消除其中的任何一層。每個層都有自己的建模語言來描述相同的實體對象。數據庫使用關係代數語言以實現對物理存儲的全路徑訪問。應用服務器層使用面向對象的語言來操作內存中的數據。UI嘗試用便於人類理解的語言以使其更加用戶友好。由於每個層各有其不同的側重,我們幾乎無法用一個固定的規則使得其他層能基於某層自動產生。我們可以做的是翻譯和映射這3種不同層面的建模語言。從數據庫到應用服務器,我們使用對象關係映射;從應用服務器到UI,我們使用UI對象映射。

對象到UI的映射

實際上,我還是比較喜歡繪製UI的。尤其是當你有趁手的工具能讓你實時觀察調整效果。在這裏,我使用Bootstrap進行排版,用Angular Server進行實時渲染。當UI看起來不錯後,就該做數據綁定和界面邏輯了。使用Angular的Reactive Form讓這件事情變得很簡單。

無論UI的外觀如何,其幕後都對應一個數據對象。這裏所說的“數據對象”可以用一種嵌套結構表示。比如擡頭下面包含行項目,行項目下面再包含子項目。我的“用戶對象”用Angular的FormGroup可表示如下:

this.userForm = this.fb.group({
  USER_ID: ['DH001', [Validators.required]], LOCK: ['Unlocked'], PWD_STATUS: [''],
  userBasic: this.fb.group({
    names: this.fb.group({
      USER_NAME: ['VINCEZK', [Validators.required]],
      DISPLAY_NAME: ['Vincent Zhang', [Validators.required]],
      GIVEN_NAME: ['Vincent'], MIDDLE_NAME: [''], FAMILY_NAME: ['Zhang']
    }),
    employee: this.fb.group({
      TITLE: ['Developer'], DEPARTMENT_ID: ['Development'], 
      COMPANY_ID: ['Darkhouse', [Validators.required]], GENDER: ['Male']
    })
  }),
  emails:  this.fb.array([
    this.fb.group({
      EMAIL: ['[email protected]'], TYPE: ['private'], PRIMARY: ['1']
    });
    this.fb.group({
      EMAIL: ['[email protected]'], TYPE: ['work'], PRIMARY: ['0']
    });
  ]),
  addresses: this.fb.array([
    this.fb.group({
      ADDRESS_ID: [''], TYPE: ['Current Live', [Validators.required]],
      ADDRESS_VALUE: ['Room #999, Building #99, XXX Road #999', [Validators.required]],
      POSTCODE: ['201202'], CITY: ['Shanghai'], COUNTRY: ['China'], PRIMARY: ['1']
    })
  ]),
  userPersonalization: this.fb.group({
    USER_ID: ['DH001'], LANGUAGE: ['ZH'], TIMEZONE: ['UTC+8'], DECIMAL_FORMAT: [''], DATE_FORMAT: ['']
  }),
  userRole: this.fb.array([
    this.fb.group({
      NAME: ['administrator'], DESCRIPTION: ['Administrator'],
      system_role_INSTANCE_GUID: ['391E75B02A1811E981F3C33C6FB0A7C1'],
      RELATIONSHIP_INSTANCE_GUID: ['06FEB4702A1B11E981F3C33C6FB0A7C1']
    })
  ])
}); 

Angular引入了FormGroup及其構建器(this.fb)來構建UI數據對象。它不僅可以用來定義對象結構和數值,還可以添加驗證器(Validator)。例如,我在屬性“USER_ID”上添加了“Validators.required”,以聲明它不允許爲空。此外,FormGroup還實現了UI(HTML)和對象(JS)之間的雙向數值綁定。這意味着在UI上進行的任何數據更改可實時同步到背後的數據對象上,反之亦然。

<div class="col-lg-4 form-group" [formGroup]="userForm">
  <label for="user_id" class="col-form-label dk-form-label">User ID:</label>
  <input id="user_id" name="user_id" formControlName="USER_ID" type="text" class="form-control">
</div>
<div class="col-lg-4 form-group" [formGroup]="userForm">
  <label for="lockStatus" class="col-form-label">Lock Status:</label>
  <div id="lockStatus" class="form-control">
    <span *ngIf="userForm.get('LOCK').value" class="fas fa-lock" > Locked</span>
    <span *ngIf="!userForm.get('LOCK').value" class="fas fa-lock-open"> Unlocked</span>
  </div>
</div>
<div class="col-lg-4 form-group" [formGroup]="userForm">
  <label for="passwordStatus" class="col-form-label">Password Status:</label>
  <div id="passwordStatus" class="form-control" [ngSwitch]="userForm.get('PWD_STATUS').value">
    <div *ngSwitchCase="">
      <span class="badge badge-primary">Initial</span>
    </div>
    <div *ngSwitchCase="1">
      <span class="badge badge-success">Active</span>
    </div>
    <div *ngSwitchCase="2">
      <span class="badge badge-warning">Renew</span>
    </div>
  </div>
</div>

從上面的代碼片段中,您可以發現FormGroup對象如何通過HTML屬性“[formGroup]”和“formControlName”綁定到HTML。有時候,您不想直接顯示數值,而是希望做一些轉換。就如“lockStatus”和“passwordStatus”,我把其數值替換成易讀的描述和圖標,以便更直觀的向使用者展示。

我的有些屬性是多元組的,例如“電子郵件”,“地址”和“用戶角色”。它們可以用Angular FormArray來構造。但是在UI中,它們又可以以多種形式展示。您可以選擇LIST或TABLE控件,每個控件還有更多的細分。根據這些屬性的性質,結合考慮如何“添加”和“刪除”等操作,您可能會發現有時艱難決定到底選擇那種展示形式。
Email Representation
我爲“電子郵件”和“地址”選擇LIST控件,主要是因爲用戶可能有多個電子郵件和地址,但也不會多到哪裏去,不會超過10個。因此,以表單而不是表格樣式來展示它們顯得更爲自然。當您點擊“添加”按鈕時,將會添加一個新的空白表單,以允許用戶輸入一個新的電子郵件。單擊右上角的“X”會刪除一個已有的電子郵件。
Role Assignment Representation
但是,對於“用戶角色”,我則選擇TABLE控件。不僅是由於用戶可能會被分配許多角色,更是因爲這是一種指派性的屬性。也就是說,這裏描述的是“用戶”實體和“角色”實體之間的關係,而這種關係信息以密集的形式展示會比較好。

請注意,我在“Action”列中只有一個“刪除”按鈕,並沒有“添加”按鈕。當用戶在最後一行中輸入一個角色時,程序自動會執行追加一個空行的操作。這種設計會使UI更加乾淨自然。但是,它假定用戶角色分配動作是無序的,並總是一個一個地去添加。若非這樣,這並不是一種推薦的模式。

現在,我的用戶界面具有了動態性。至少,頁面上的數據是由背後的數據對象提供的,而不是在HTML中硬編碼的。但是,FormGroup數據對象和HTML都運行在瀏覽器中。到目前爲止,我們還是處於UI層,並未與應用服務器發生聯繫。不過您可能已經發現FormGroup對象與我們一開始的JSON數據模型很相似。這是因爲它們都是對象模型。回憶一下,我們首先設計了JSON數據模型,然後在繪製UI中構建了FormGroup對象。你會發現,只要都是對象模型,我們就可以把它們輕鬆地對應起來。

依靠JOR,我只需在瀏覽器端編寫了一個服務調用,就能獲取一個“用戶”的JSON數據對象。

getUserDetail(userID: string): Observable<Entity | Message[]> {
  const pieceObject = {
    ID: { RELATION_ID: 'r_user', USER_ID: userID},
    piece: {RELATIONS: ['r_user', 'r_employee', 'r_email', 'r_address', 'r_personalization'],
            RELATIONSHIPS: [
              {
                RELATIONSHIP_ID: 'rs_user_role',
                PARTNER_ENTITY_PIECES: { RELATIONS: ['r_role'] }
              }]
    }
  };
  return this.http.post<Entity | Message[]>(
    this.originalHost + `/api/entity/instance/piece`, pieceObject, httpOptions).pipe(
    catchError(this.handleError<any>('getUserDetail')));
}

上面的服務調用是通過用戶ID獲取該用戶的詳細信息。它提交了一個請求,要求提供該用戶實體的部分信息。檢查“pieceObject”的定義,您不難理解它請求讀取以下信息片段,分別是Relaion: “r_user”、“r_employee”、“r_email”、“r_address”、“r_personalization”以及Relationship: “rs_user_role”。服務調用返回的是一個JSON對象,結構大致和我們開始定義的JSON模型一致。

接下來我們要將返回的JSON對象(data)映射到FromGroup對象(userForm)上。這部分編碼很簡單,唯一需要注意的是數組對象。觀察“r_email”、“ r_address”、和“userRole”,我針對它們使用了循環操作將單個FormGroup對象壓到對應的FormArray中。

this.userForm = this.fb.group({
  USER_ID: [data['r_user'][0]['USER_ID'], [Validators.required]],
  LOCK: [data['r_user'][0]['LOCK']],
  PWD_STATUS: [data['r_user'][0]['PWD_STATUS']],
  userBasic: this.fb.group({
    names: this.fb.group({
      USER_NAME: [data['r_user'][0]['USER_NAME'], [Validators.required]],
      DISPLAY_NAME: [data['r_user'][0]['DISPLAY_NAME'], [Validators.required]],
      GIVEN_NAME: [data['r_user'][0]['GIVEN_NAME']],
      MIDDLE_NAME: [data['r_user'][0]['MIDDLE_NAME']],
      FAMILY_NAME: [data['r_user'][0]['FAMILY_NAME']]
    }),
    employee: this.fb.group({
      TITLE: [data['r_employee'][0]['TITLE']],
      DEPARTMENT_ID: [data['r_employee'][0]['DEPARTMENT_ID']],
      COMPANY_ID: [data['r_employee'][0]['COMPANY_ID'], [Validators.required]],
      GENDER: [data['r_employee'][0]['GENDER']]
    })
  }),
  emails:  this.fb.array([]),
  addresses: this.fb.array([]),
  userPersonalization: this.fb.group({
    USER_ID: [data['r_personalization'] ? data['r_personalization'][0]['USER_ID'] : ''],
    LANGUAGE: [data['r_personalization'] ? data['r_personalization'][0]['LANGUAGE'] : ''],
    TIMEZONE: [data['r_personalization'] ? data['r_personalization'][0]['TIMEZONE'] : ''],
    DECIMAL_FORMAT: [data['r_personalization'] ? data['r_personalization'][0]['DECIMAL_FORMAT'] : ''],
    DATE_FORMAT: [data['r_personalization'] ? data['r_personalization'][0]['DATE_FORMAT'] : '']
  }),
  userRole: this.fb.array([])
});

const emailArray = this.userForm.get('emails') as FormArray;
data['r_email'].forEach( email => {
  emailArray.push(
    this.fb.group({
      EMAIL: [email['EMAIL'], [Validators.required]],
      TYPE: [email['TYPE'], [Validators.required]],
      PRIMARY: [email['PRIMARY']]
    })
  );
});

const addressArray = this.userForm.get('addresses') as FormArray;
if (data['r_address']) {
  data['r_address'].forEach( address => {
    addressArray.push(
      this.fb.group({
        ADDRESS_ID: [address['ADDRESS_ID']],
        TYPE: [address['TYPE'], [Validators.required]],
        ADDRESS_VALUE: [address['ADDRESS_VALUE'], [Validators.required]],
        POSTCODE: [address['POSTCODE']],
        CITY: [address['CITY']],
        COUNTRY: [address['COUNTRY']],
        PRIMARY: [address['PRIMARY']]
      })
    );
  });
}

const roleArray = this.userForm.get('userRole') as FormArray;
const userRoleRelationship = data['relationships'][0];
if (userRoleRelationship) {
  userRoleRelationship.values.forEach( value => {
    const roleInstance = value.PARTNER_INSTANCES[0];
    roleArray.push(
      this.fb.group({
        NAME: [roleInstance['r_role'][0]['NAME']],
        DESCRIPTION: [roleInstance['r_role'][0]['DESCRIPTION']],
        system_role_INSTANCE_GUID: [roleInstance['INSTANCE_GUID']],
        RELATIONSHIP_INSTANCE_GUID: [value['RELATIONSHIP_INSTANCE_GUID']]
      })
    );
  });
}  

到目前爲止,我僅完成了從數據庫到UI的完整數據流,這僅僅是CRUD中的字母“R”。具體數據流可描述如下:

DB(relations) →JSON-On-Relations(server-side JS) →FormGroup(client-side JS) →UI(HTML).

依靠JOR,我省去了很多數據庫和應用服務器上的工作。它使得我將精力集中在建模和UI上。接下來,我將完成字母“U”的操作,即更新用戶對象。

UI to Object Mapping

這是一個反向的數據流:

UI(HTML) →FormGroup(client-side JS) →JSON-On-Relations(server-side JS) →DB(relations).

在數學中,我們有很多例子從一個方向上計算很容易,但其逆運算就變得非常困難,例如平方計算和開方計算。“讀取(R)”和“更新(U)”也是這樣一種關係。與“R”相比,“U”得花更多精力去實現。這些額外的精力主要花在數據校驗,錯誤處理,併發控制,工作保護等等。

但是在着手開發“更新”之前,我們還得先完成編輯模式和顯示模式之間的切換。在一些簡單的CRUD演示中,您未必會看到有編輯模式與顯示模式之分。這就是我前面所說的那些技術佈道者所掩蓋的複雜度之一。他們只想告訴您使用某些工具開發會有多麼的容易,卻會常常忽略實際應用中一些非常重要的特性。就像我前面說的那樣,CRUD應用是設計給多個用戶併發使用的。因此,區分編輯模式和顯示模式是非常重要的。這有助於檢查否通過必要的權限檢查以及併發控制。

有些應用甚至使用不同的UI控件和設計來區分編輯模式和顯示模式。通常,這是爲了獲得更好的用戶體驗。例如,在顯示模式下,下拉框將被替換成普通輸入框,這樣讓UI顯得更爲簡潔。在此示例中,爲避免一些複雜度,我會使用相同的UI控件和設計。爲此,我需要將“readonly”屬性添加到所有可被編輯的UI控件上,並將其值綁定到一個變量上。棘手的是,對於某些控件(如複選框和單選框),它們是不支持“readonly”屬性的。因此,我必須對它們特殊照顧。還有一部分是我自找的麻煩,例如,我對LIST和TABLE控件也有一些特殊處理,自動刪除無效行以及自動添加新空行。取決於您的UI複雜度,在實現這兩個UI狀態切換上所耗費的精力可能會有很大的不同。

除了在UI層面上的工作外,還有一些服務端邏輯也需要被關注。從顯示模式切換到編輯模式時,將按序執行以下邏輯:

  1. 檢查用戶是否有權限修改該實例;
  2. 檢查是否另一個用戶在同時編輯該實例;
  3. 將UI切換成編輯模式。

從編輯模式切換到顯示模式時,將按序執行以下邏輯:

  1. 檢查該實例是否已經被修改過,如果是,彈出對話框詢問是保存修改還是放棄修改;
  2. 釋放併發鎖,以便其他用戶能修改;
  3. 將UI切換成顯示模式。

做完兩種模式的切換,接下來可着手數據校驗相關的開發。校驗是無止境的。你可以對單個字段的值進行校驗,也可以對多個字段組合後進行校驗。爲了數據的質量,您可以輕鬆地想出很多校驗規則,但是我們還是需注意投入產出比。過多的數據校驗除了增加您的開發成本外,還會破壞性能和用戶友好度。這裏,我還簡單給出一個結論:大多數校驗都是關於數據值域的,您可以在客戶端去實現,也可以在服務器端實現。

客戶端實現校驗的成本較低。因此,儘可能使用客戶端校驗。Angular提供了一些現成的校驗函數,例如:required,maxLength,minLength,email等。但是,客戶端校驗只能涵蓋一小部分。大多數情況下,數據校驗需根據上下文。而上下文只能在應用服務器端,因此服務端校驗是不可避免的。

我的第一個校驗邏輯寫在“USER_NAME”字段上。USER_NAME必須是唯一的,因此我必須確保用戶輸入的NAME或者ID在系統範圍內是唯一的。還要考慮何時觸發校驗? 是輸入值後馬上校驗,還是在點保存按鈕後? 答案總是越早越好。爲此,我需要實現了一個異步校驗功能,並將其分配給“USER_NAME”這個FormControl。

const userNameCtrl = this.userForm.get('userBasic.names.USER_NAME') as FormControl;
userNameCtrl.setAsyncValidators(
      existingUserNameValidator(this.identityService, this.messageService, this.userForm.get('USER_ID').value));
...

export function existingUserNameValidator(identityService: IdentityService,
                                          messageService: MessageService,
                                          userID: string): AsyncValidatorFn {
  return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {
    return timer(500).pipe(
      switchMap( () => identityService.getUserByUserName(control.value).pipe(
        map(data => {
          if (data['r_user'] && data['r_user'][0]['USER_ID'] !== userID) {
            return {message: messageService.generateMessage('USER', 'USER_NAME_EXISTS', 'E', control.value).msgShortText};
          } else {
            return null;
          }
        })
      )));
  };
}

現在效果看起來不錯。當用戶在“USER_NAME”字段中輸入一些字母后,它立即與服務端進行通訊,以檢查已輸入值是否存作爲用戶名已存在。您會發現我在校驗函數中設置了“ timer(500)”。這意味着當用戶思考時間超過半秒(500毫秒),校驗纔會觸發。作爲用戶,他可以馬上獲得反饋,無需執行額外操作。

用戶現在是高興了,唯一的問題是成本。爲了實現這樣的校驗,您需要服務端提供專門的服務調用點。幸運的是,在這個示例中,我可以直接調用JOR提供的服務,而無需任何服務端編碼。但是我也可以預見,很多情況下服務端編碼是不可避免的。並且這類服務可能僅用於UI校驗。這裏,我想提兩個問題:

問題1:是否所有UI層面上的校驗都應在服務端重複實現? 我的回答是:“是”。因爲這些校驗試圖儘早的將錯誤反饋給用戶,但它們無法取代服務端的校驗。 它們只負責UI的輸入,並不能保證數據從其他渠道(例如API)輸入的準確性。無論如何,你都得在服務端再次實施同樣的校驗。

問題2:是否應將服務端校驗儘可能嵌入到數據模型中?我的答案是:“否”。我知道這一次的回答更具爭議。衆所周知,數據庫提供了一些數據一致性檢查的功能,例如主鍵檢查和外鍵檢查。爲什麼不利用這些功能呢? 我的經驗告訴我,數據模型在誕生時並不完美,它們在生命週期內經常被調整。這就意味着數據模型中添加的業務邏輯越多,調整它們的成本將會越高。想象一下,您要調整一個外鍵所要付出的數據轉換成本。而如果將這種校驗邏輯與數據模型分開,則可以在調整模型時獲得更大的靈活性。我並不是建議您完全不用它們,只是建議要三思。
Validation Error
除了在字段旁邊顯示錯誤提示外,還有一些實例級別的校驗消息需要被顯示。並不僅僅是錯誤消息,還有警告消息,告知消息和成功消息。此外,消息既要有簡明扼要的短文本,還得有詳盡的原因解釋和解決方案的長文本。它得支持多種語言。可能有的消息是需要在客戶端維護,有的要在服務端維護。最後,您會發現您需要一個消息框架來滿足所有這些需求。因此,我創建了UI-Message,並將之應用於這個示例中。

當所有校驗通過後,就可以將數據保存到數據庫中了。我需要調用JOR的RESTful API去更改實例。該API要求我們提供一個與建模對象類似的JSON對象。在每個Relation元組上需要添加一個“action”屬性,以指示對該元組所要執行的操作。它的值可以是“add”,“delete”, 或者“update”。

Angular FormGroup做得不錯,它通過“dirty”屬性可以知道哪些字段被修改過,這讓我用起來很舒服。這樣我們就可以從更改後的FormGroup對象來構建JOR所需要的JSON對象了。從UI對象到服務端對象,再到數據庫,這樣的映射編碼通常既無聊又容易出錯。這就解釋了爲什麼ORM總有其生存空間。但我不喜歡的是,它們總愛幹一些畫蛇添足的事情。實際上,我們需要的只是映射。

_composeChangesToUser() {
  this.changedUser['ENTITY_ID'] = 'person';
  this.changedUser['INSTANCE_GUID'] = this.instanceGUID;

  const userBasicFormGroup = this.userForm.get('userBasic');
  const userID = this.userForm.get('USER_ID').value;
  if (userBasicFormGroup.dirty) {
    const userBasicNamesFormGroup = userBasicFormGroup.get('names') as FormGroup;
    this.changedUser['r_user'] = this.uiMapperService.composeChangedRelation(
      userBasicNamesFormGroup, {USER_ID: userID}, this.isNewMode);

    const userBasicEmployeeFormGroup = userBasicFormGroup.get('employee') as FormGroup;
    this.changedUser['r_employee'] = this.uiMapperService.composeChangedRelation(
      userBasicEmployeeFormGroup, {USER_ID: userID}, this.isNewMode);
  }

  const userEmailFormArray = this.userForm.get('emails') as FormArray;
  this.changedUser['r_email'] = this.uiMapperService.composeChangedRelationArray(
    userEmailFormArray, this.originalUserValue['emails'], {EMAIL: null});

  const userAddressFormArray = this.userForm.get('addresses') as FormArray;
  this.changedUser['r_address'] = this.uiMapperService.composeChangedRelationArray(
    userAddressFormArray, this.originalUserValue['addresses'], {ADDRESS_ID: null});

  const userPersonalizationFormGroup = this.userForm.get('userPersonalization') as FormGroup;
  this.changedUser['r_personalization'] = this.uiMapperService.composeChangedRelation(
    userPersonalizationFormGroup, {USER_ID: userID}, !userPersonalizationFormGroup.get('USER_ID').value);

  const userRoleFormArray = this.userForm.get('userRole') as FormArray;
  const relationship = this.uiMapperService.composeChangedRelationship(
    'rs_user_role',
    [{ENTITY_ID: 'permission', ROLE_ID: 'system_role'}],
    userRoleFormArray, this.originalUserValue['userRole'], ['NAME', 'DESCRIPTION']);
  if (relationship) {this.changedUser['relationships'] = [relationship]; }
}

使用JOR提供的“ UiMapperService”,我們可以輕鬆地從FormGroup對象構建RESTful API所要的JSON對象。“UiMapperService”提供了3種方法:

  1. composeChangedRelation:將FormGroup轉換爲單元組Relation;
  2. composeChangedRelationArray:將FormArray轉換爲多元組Relation;
  3. composeChangedRelationship:將FormArray轉換爲Relationship。

還有一種簡單粗暴的更新方法,即先刪除原有的,再重新插入。這樣開發人員無需費心跟蹤哪些字段被改了,每次提交更新請求,執行的是完全覆蓋。 這非常適用於簡單的實體。除了缺少一些優雅性以及性能損耗外,這種全覆蓋的方式還有一些侷限性。例如,一個實體的主碼可能用了UUID或者流水號,全覆蓋的操作可能會導致其主碼的變更。

最後的步驟很簡單,只需調用RESTful API即可,讓JOR幫助您完成與數據庫的映射工作。如果保存成功,它會返回一個更新後的對象;若保存失敗,它則返回具體的錯誤消息。在下面的代碼片段中,“saveUser”方法通過檢查JSON對象是否具有“INSTANCE_GUID”來區分是“更新”操作,還是“新建”操作。分別對應RESTful方法“put”和“post”。

saveUser(user: Entity): Observable<Entity | Message[]> {
  if (user['INSTANCE_GUID']) {
    return this.http.put<Entity | Message[]>(
      this.originalHost + `/api/entity`, user, httpOptions).pipe(
      catchError(this.handleError<any>('saveUser')));
  } else {
    return this.http.post<Entity | Message[]>(
      this.originalHost + `/api/entity`, user, httpOptions).pipe(
      catchError(this.handleError<any>('saveUser')));
  }
}

我們似乎已經攻克了最困難的一條路徑,即CRUD中的“U”。然而我們還遺留了一個尾巴,即工作保護。想象一下,當您正在編輯某個對象時,不小心點了瀏覽器的“返回”按鈕,你會期待什麼?您希望應用程序通過彈框的方式詢問是否真的要退出當前編輯狀態。這使我們進入下一個大問題:導航。

導航

導航是很難做的。幸運的是,Angular提供了很大的幫助。我們這個示例只有2頁,即使這樣,我仍在導航上花費了一番精力。

在着手實現導航之前,我需要先完成“搜索和列表”頁面。這個頁面沒有“更新”的需求,比起製作“詳細”頁面來,開發它要容易得多。更幸運的是,我還是不需要任何服務端編碼,因爲JOR已經爲我提供了generic query API

searchUsers(userID: string, userName: string): Observable<UserList[] | Message[]> {
  const queryObject = new QueryObject();
  queryObject.ENTITY_ID = 'person';
  queryObject.RELATION_ID = 'r_user';
  queryObject.PROJECTION = ['USER_ID', 'USER_NAME', 'DISPLAY_NAME', 'LOCK', 'PWD_STATE'];
  queryObject.FILTER = [];
  if (userID) {
    if (userID.includes('*')) {
      userID = userID.replace(/\*/gi, '%');
      queryObject.FILTER.push({FIELD_NAME: 'USER_ID', OPERATOR: 'CN', LOW: userID});
    } else {
      queryObject.FILTER.push({FIELD_NAME: 'USER_ID', OPERATOR: 'EQ', LOW: userID});
    }
  }
  if (userName) {
    if (userName.includes('*')) {
      userName = userName.replace(/\*/gi, '%');
      queryObject.FILTER.push({FIELD_NAME: 'USER_NAME', OPERATOR: 'CN', LOW: userName});
    } else {
      queryObject.FILTER.push({FIELD_NAME: 'USER_NAME', OPERATOR: 'EQ', LOW: userName});
    }
  }
  queryObject.SORT = ['USER_ID'];
  return this.http.post<any>(this.originalHost + `/api/query`, queryObject, httpOptions).pipe(
    catchError(this.handleError<any>('searchObjects')));
}

我只需要編寫一個“queryObject”。它由一個目標entity,一個主relation,列表字段,過濾條件,和排序字段構成。由於我只允許在“USER_ID”和“USER_NAME”這兩個字段上加過濾條件,因此我爲它們編寫了一些特殊邏輯。如果用戶要進行通配符“*”搜索,則將“*”替換爲“%”,因爲我的數據庫(mysql)僅將“%”識別爲通配符。
Search&List Page with Navigation
“搜索和列表”頁面具有3個導航路徑:
1.單擊“用戶ID”鏈接或“顯示”按鈕將以顯示模式導航到詳細頁面;
2.單擊“更改”按鈕將以編輯模式導航到詳細頁面;
3.單擊“新建”按鈕將以新建模式導航到詳細頁面。

在網頁應用中,導航由URL驅動。我實際上花費了一番心思來設計我的URL。我將“/users”路由到“搜索與列表”頁面,將“/users/:userID”路由到“詳細”頁面。再加一個參數“action”以表示不同的模式。例如:“/users/DH001;action=change”將在編輯模式下導航到用戶“DH001”。利用Angular的路由模塊,我設計瞭如下的路由表:

const routes: Routes = [
  { path: 'users', component: UserListComponent},
  { path: 'users/:userID', component: UserDetailComponent, canDeactivate: [WorkProtectionGuard]},
  { path: 'errors', component: ErrorPageComponent },
  { path: 'pageNotFound', component: NotFoundComponent },
  { path: '**', component: NotFoundComponent }
];

除了前兩個路徑外,我還有兩個用於錯誤顯示和找不到頁面的路由。如果在瀏覽器的地址欄中輸入了無效的路徑,則會路由到最後一個路徑:“pageNotFound”。

注意看第二條路徑有一個附加屬性“canDeactivate”。我爲其定義了工作保護邏輯。當導航離開“詳細”頁面時,這段邏輯會檢查對象是否已經修改過,然後彈出對話框詢問:“是否放棄更改?”。

還有一個問題,從“詳細”頁面返回“搜索和列表”頁面時。默認情況下,Angular會觸發重新加載,而這並不是我想要的。爲了避免這種情況,我實現了自己的RouteReuseStrategy。

// In app.module.ts
providers: [
  {provide: RouteReuseStrategy, useClass: CustomReuseStrategy}
]

// In custom.reuse.strategy.ts
import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';

export class CustomReuseStrategy implements RouteReuseStrategy {
  routesToCache: string[] = ['users'];
  storedRouteHandles = new Map<string, DetachedRouteHandle>();

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return this.routesToCache.indexOf(route.routeConfig.path) > -1;
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    this.storedRouteHandles.set(route.routeConfig.path, handle);
  }

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return this.storedRouteHandles.has(route.routeConfig.path);
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
    return this.storedRouteHandles.get(route.routeConfig.path);
  }

  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return future.routeConfig === curr.routeConfig;
  }
}

該自定義路由策略的主要思想是緩存“搜索和列表”頁面(/users)。但是,背後的真正問題是:導航回前一個頁面時是否應該重新加載頁面?好吧,答案肯定是取決於你的需要。 也正因爲有這麼多的變數,使得導航實現起來異常的困難。

以這個示例來說,如果我想讓“詳細”頁面上的變動(更改用戶名)同時影響到“搜索與列表”頁面,那麼我就應該在返回時重新加載頁面。 基於這種需求,我要做的不是緩存整個頁面,而是隻緩存搜索條件,並重新執行搜索。 然而,如果用戶名的變化會導致搜索結果的變化,由此產生的副作用是修改後的條目可能不出現在列表中了,這樣對用戶來說並不自然。相反如果不執行搜索,而只是調整列表的顯示值,這在邏輯上又說不過去。如果考慮更復雜的情況,不僅是搜索條件,還包括列寬,位置,排序字段等。所有這些考慮加起來會讓您發瘋。

現在該是處理最後兩個字母“C”和“D”了。我只需利用導航來實現新用戶的創建。當單擊“新建”按鈕時,它將導航到路徑“/users/;action=new”。在“詳細”頁面中,我會檢查參數“action”的值,如果是“new”,則創建一個空的實例對象。這樣,我就可以重用編輯模式的邏輯。

ngOnInit() {
  this.route.paramMap.pipe(
    switchMap((params: ParamMap) => {
      this.action = params.get('action');
      if (this.action === 'new') {
        this.isNewMode = true;
        return this._createNewUser();
      } else {
        this.isNewMode = false;
        return this.identityService.getUserDetail(params.get('userID'));
      }
    })
  ).subscribe( data => {
    if ('ENTITY_ID' in data) {
      this.instanceGUID = data['INSTANCE_GUID'];
      this._generateUserForm(<Entity>data);
      if (this.isNewMode || this.action === 'change') {
        this._switch2EditMode();
      } else {
        this._switch2DisplayMode();
      }
    } else {
      const errorMessages = <Message[]>data;
      errorMessages.forEach( msg => this.messageService.add(msg));
    }
  });
}

...

_createNewUser(): Observable<Entity> {
  const userDetail = new Entity();
  userDetail['ENTITY_ID'] = 'person';
  userDetail['r_user'] = [
    { USER_ID: '', LOCK: 0, PWD_STATUS: '', USER_NAME: '', DISPLAY_NAME: '',
      GIVEN_NAME: '', MIDDLE_NAME: '', FAMILY_NAME: ''}
  ];
  userDetail['r_employee'] = [
    {TITLE: '', DEPARTMENT_ID: '', COMPANY_ID: '', GENDER: ''}
  ];
  userDetail['r_email'] = [];
  userDetail['r_personalization'] = [
    {USER_ID: '', LANGUAGE: '', TIMEZONE: '', DECIMAL_FORMAT: '', DATE_FORMAT: ''}
  ];
  userDetail['relationships'] = [];
  return of(userDetail);
}

要刪除一個用戶,我需要在“搜索和列表”頁面上實現一個確認對話框。當用戶單擊刪除按鈕時,該確認框會彈出,點擊“確認”會執行真正的刪除操作。再度依靠JOR,我無需編寫任何服務端代碼就實現了刪除操作。
Delete User
至此,CRUD已全部完成。但是我能自信地說:這個應用可以投入生產使用了嗎? 答案是:“否”。因爲我們還沒有仔細測試過。 儘管在開發過程中,我進行了一些碎片化的測試,但這是遠遠不夠的。 只要想想那麼多的按鈕、字段、導航路徑,以及以不同的順序組合它們,你就無法自信的說這個應用沒有Bug。 您也無法預期真正的用戶將如何使用您的應用程序。因此,我們必須仔細有效地對其進行測試。

測試

測試的重要性不言而喻。 但是,如何有效地進行測試已經變成一個有爭議的話題。儘管如此,我還是按照自己的方式去做測試。 唯一重要的是提高我對這個應用的自信。

回想一下整個過程,我從沒有在服務端寫過任何東西,看來我只需要關注UI層。
基於Angular的測試手冊,我嘗試了它提供的幾個測試工具。

首先,我認爲我無須編寫任何Service tests。我所有的Service都非常簡單。在它們被第一次成功調用之後,再無必要花費時間去測試它們了。

接着,我寫了一些Component Class tests,並發現這無助於我獲得自信。儘管我的大部分代碼都位於Component Class中,但首先,我認爲沒有必要爲其編寫測試代碼以證明這些數據映射和轉換邏輯是正確的。我的意思是看看那些簡單的“if else”和“loop”語句,我真的需要花費這些力氣嗎?其次,就算寫了這些測試代碼並完成了100%的覆蓋率,這又說明不了任何問題。我真正關心的是整條數據鏈路上的邏輯結合在一起是否運行正確。

我還嘗試了Component DOM testing。乍一看,我似乎應該投資它。但是,經過一番嘗試,我發現這個工具也不值得投入。不僅僅是由於陡峭的學習曲線,更是因爲那種花巨大成本去模擬一個虛擬運行時的思想讓我覺得匪夷所思。對於我這樣的CRUD應用來說,浪費時間去模擬一些技術性很強的東西(例如:HTTP服務,數據庫服務等)顯得非常不划算。有這個時間去投資一個不可信的虛擬環境,我更願意搭建一套可信的真實測試環境。我無法臆斷是否其他類型的軟件項目適用這種mock-up測試理念,但我相信它絕對不適於CRUD類應用軟件項目。

最終,我發現適合我的最佳測試工具是E2E測試。儘管Angular團隊似乎更推薦Component DOM testing,我對基於Protractor的E2E測試框架感到非常滿意。

我在“e2e”文件夾中放置了兩個文件:頁面對象“user.po.ts”和e2e腳本“user.e2e-spec.ts”。在“user.po.ts”中,我模擬了“搜索和列表”頁面中執行的各種操作,例如:導航、單擊按鈕、輸入值,並獲取返回結果。

  navigateToSearch() {
    return browser.get('/users');
  }
  
  fillUserID(userID: string = 'anonymous') {
    element(by.css('[name="user_id"]')).sendKeys(userID);
  }
  
  clickSearchButton() {
    element(by.id('search')).click();
  }
  
  getSearchResultList() {
    return element.all(by.tagName('tr'));
  }

在“user.e2e-spec.ts”中,我構造了“詳細”頁面上的操作以形成對應的e2e場景。

describe('Search&List Page', () => {
    beforeAll(() => {
      page.navigateToSearch();
    });

    it('should list all users when clicking button Search', () => {
      page.clickSearchButton();
      page.getSearchResultList()
        .then((list) => expect(list.length).toBeGreaterThan(2));
    });

    it('should list a user filtered by userID', () => {
      page.fillUserID(); // anonymous
      page.clickSearchButton();
      expect(page.getFirstHitUserID()).toEqual('anonymous');
    });
 }

有人認爲E2E測試既困難又昂貴,我的感覺恰恰相反。您可能也發現它實際上既直觀又易於維護。還有人會擔心E2E腳本很脆弱。 但就我而言,它實際上很穩健。我在開發過程中經常使用E2E腳本生成測試數據,幾乎不需要調整它們。我認爲可能有以下幾個原因:

  1. 我使用“by.id”來定位HTML元素;
  2. 頁面對象和E2E腳本的分離;
  3. 穩定的數據模型和後端服務;
  4. 我一個人完成了從數據模型到UI的開發。

根據不同的軟件類型,您應謹慎地選擇測試方法和工具。對於CRUD類應用來說,我認爲E2E測試是最合適的。這與構建框架、庫、算法等不一樣,您帶來的價值主要是數據的映射和UI外觀。有人可能會說UI開發人員和後端開發人員通常是兩個不同的人。這也並非總是如此,同樣取決於您所開發的軟件類型。如果您開發的是企業級數據庫應用,則讓一個開發人員完成從後端到前端的開發會帶來更好的結果。

我對實現高覆蓋率並不感興趣,我仍然會進行了大量的手工測試。例如,當我想測試從一個用戶中刪除一個角色時,我會先用E2E腳本創建一個用戶,然後以手工測試的方式進行角色的刪減操作。這會節省我大量的時間。等刪除角色的功能穩定後,我會將該操作添置到E2E腳本中。

最重要的是要提升你對軟件的信心。

結語

在這篇絮絮叨叨的長文中,我回顧了開發一個CRUD應用的整個過程。正如開始所說,這並不是一個容易的過程。 儘管我利用了很多框架,但如果不想偷工減料的話,仍然需要付出很多精力。每當我開發一個新的CRUD應用(即使在同一堆棧上)時,我總是會產生一些不一樣的想法。

在早期,UI是通過服務端呈現的,數據對象也在服務端。因此,我們需要一個強大的會話管理框架來容納它們。如今,藉助Web技術,數據對象可存於客戶端,我們開始弱化服務端的會話管理。一直不變的是對象模型與關係模型之間的映射。

儘管在本示例中,我利用JOR避免了服務端程序的開發,我也可以預料在很多情況下它提供的現成RESTful API是無法滿足的。現實情況是我們幾乎不可避免地要編寫服務端邏輯。這是因爲一個業務對象是無法獨立存在的,它必須與其他對象建立起關係才能使得整個業務系統變得有機。

我的小應用程序還遠沒到完成的程度。如果您想讓它成爲一個企業級CRUD應用產品,它還需要擁有以下特性:

  1. 搜索幫助。 例如,“COMPANY”字段最好是一個下拉框。“DEPARTMENT”字段也應該是一個下拉框,其值取決於“COMPANY”的值。“ROLE”字段應提供搜索幫助框,以方便用戶進行查詢和選擇。
  2. 權限檢查。 應就實例對象的CRUD操作進行權限劃分。
  3. 併發控制。 當一個用戶正在編輯某實例時,另一個用戶需被告知無法編輯,只能查看該實例。 也可使用ETAG等樂觀鎖機制。
  4. 多語言支持。 標籤、標題、按鈕文本等應支持多語言。 並以用戶的登錄語言來顯示它們。

以上僅僅是隨便舉了幾個,還有很多尚未提及,例如響應式網頁設計等。不過,我認爲好的框架確實會提供很大幫助。 就像我在此例中使用到的框架有:Angular,Bootstrap,JSON-On-Relations和UI-Message。它們都是開源的,很容易獲取。

另一樣可以提供很大幫助的是模式樣例。對於一些成熟的專有平臺,除了現成的框架,服務和庫之外,它還提供了許多模式樣例供參考。有了這些模式樣例,您可以既快又好地構建CRUD應用。我相信當我在相同的技術堆棧上構建第二個CRUD應用時,會更快更好。

這也是我寫這篇博客的主要目的。 我希望它也可以爲您提供一種真正的模式樣例,包括一些標準和成本上的參考。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章