使用DDD和Spring HATEOAS構建一個MRP的API例項和原始碼 - elca

banq發表於2022-01-23

透過一個具體的例子告訴你我們如何在 Java 中實現一個只允許根據業務規則定義良好的狀態轉換的域模型,然後使用 Spring 在一個REST-API 中釋出它。看看我們如何構建一個完全由該 API 驅動的簡單 Web 應用程式。該實現使用來自領域驅動設計(DDD) 的概念,這是一種軟體工件試圖與業務模型一致的方法,例如透過使用領域語言的術語。
 

CRUD 應用程式的問題
create-read-update-delete 或簡短的 CRUD 方法非常容易實現,它是有狀態 Web 應用程式中將圖形使用者介面 (GUI) 與後端整合的主要方法。因此,後端的實體僅對需要持久化的領域邏輯的狀態進行建模,而不是對適用於產生有效狀態更改的業務操作和業務規則進行建模。
在最好的情況下,服務操作會為這些操作命名幷包含實現所需業務規則的邏輯。這些規則很容易從整個程式碼庫中洩漏出來,並且也經常出現在圖形使用者介面中,現在通常實現為在瀏覽器中執行的單頁 Web 應用程式。
結果,我們有一個應用程式,它帶有 Martin Fowler 所說的貧血域模型,以及一個難以理解和維護的相互糾纏的泥球,因為職責沒有明確分離。基於啟發式,GUI 對實體可以做什麼做出假設,並實現與後端分離的導航邏輯。
 

HATEOAS 和 Richardson 成熟度模型
查閱文獻以尋找解決此問題的解決方案,您可能會遇到 HTTP 應用程式 API 的Richardson 成熟度模型。它從普通的舊 XML 開始,這意味著 XML 內容被髮布到 Web 服務端點。

  • 在 1級API中 引入了資源的概念,允許單獨操作後端實體,從而將大型服務端點分解為多個資源。
  • 2級API進一步使用特定的 HTTP 動詞,如PUT、DELETE或PATCH來細化操作的含義。Martin Fowler 稱它提供了一組標準的動詞,以便我們以相同的方式處理類似的情況,消除不必要的變化。
  • 在第 3級,透過向每個響應新增特定於上下文的超連結,將可發現性融入 API,讓客戶瞭解接下來可以使用給定資源執行哪些操作,或連結相關資源。在這個級別,與 API 交換的“超文字”充當應用程式狀態的引擎,植根於後端並透過超連結向客戶端(在我們的例子中為 Web 前端)顯示。

REST-ful API 和 HAL 標準如何幫助我們解決將業務邏輯封裝在後端的初始問題?讓我們看一個具體的例子。
 

示例領域:製造資源計劃
假設我們正在構建一個製造資源計劃系統( MRP ),產品經理可以在其中準備和提交生產訂單。然後製造商可以接受訂單,指明預計的交貨日期,並在產品生產後完成訂單。
此外,以下業務規則適用:

  • 產品經理在提交後無法更改生產訂單。
  • 當製造商接受生產訂單時,他必須指明未來可以完成訂單的日期。

領域故事:

使用DDD和Spring HATEOAS構建一個MRP的API例項和原始碼 - elca
 

使用 Spring 和 Angular 設定專案
正如 Josh Long 一直告訴我們的,每個專案都應該從https://start.spring.io開始。事實上,該頁面非常方便,讓我們可以輕鬆地引導一個包含所需技術的新應用程式。對於我們的案例,我們選擇以下依賴項:

  • Spring Data JDBC:基於 java 資料庫連線 (JDBC) 的直接 OR 對映器,使我們免於 JPA 的開銷,非常適合持久化 DDD 風格的聚合
  • H2 資料庫:用 Java 編寫的關聯式資料庫,可以開箱即用地在記憶體中執行
  • Rest Repositories:一個 Spring 庫,允許將我們的聚合釋出為 REST 資源
  • Lombok:一個位元組碼生成器,它極大地減少了樣板程式碼的數量,併為 Java 提供了一些現代語言,如 Kotlin 或 TypeScript
  • Spring Boot DevTools:一個開發依賴,它會在程式碼庫的每次更改時自動重新啟動應用程式


聚合:狀態符合業務規則的地方
域驅動應用程式的核心是域模型。它不受技術和整合方面的影響,並儘可能地遵循商業模式和術語。因此,應用程式的狀態在所謂的實體中被捕獲,其中相關的實體可以被分組以形成一個聚合體。每個聚合都定義了應用程式內部的一致性邊界,這意味著只有明確定義的狀態更改才會在聚合內以事務方式發生。
為了保持專注,讓我們從一個非常簡單的生產訂單模型開始,沒有子實體,只有四個欄位:

  • id:區分不同生產訂單的識別符號。為簡單起見,我們將其建模為 aLong並讓資料庫對其進行初始化。註釋告訴 Spring Data JDBC這@Id是主鍵。
  • name:生產訂單的名稱。提交後,名稱不得再更改。
  • expectedCompletionDate:製造商在接受生產訂單時提供的日期,表明製造過程的計劃完成
  • state:根據領域模型的生產訂單狀態。它可以假定值DRAFT, SUBMITTED, ACCEPTED, COMPLETED, 建模為列舉。

我們使用 Lombok 的註解對類進行@Getter註解,它生成位元組碼以使用 getter 來檢測我們的(普通)聚合,以便從外部訪問這些欄位的值。
我們現在如何確保域模型只允許定義良好的狀態轉換,而不是透過 setter 展示所有欄位?答案是:透過執行各自的業務操作。
當然,我們可以使用建構函式來建立我們的實體。然而,我更喜歡提供一個靜態工廠方法,它允許我們使用適當的業務術語作為名稱,而不是相當技術性的new語句。creat- 方法將產品訂單的名稱作為單個引數並將狀態初始化為DRAFT。當聚合被持久化到資料庫時,該id欄位稍後將由框架初始化。
初始的create方法:

package com.example.demo.productionorders;

import java.time.LocalDate;

import org.springframework.data.annotation.Id;

import lombok.Getter;
import lombok.val;

@Getter
public class ProductionOrder {

    @Id
    private Long id;
    private String name;
    private LocalDate expectedCompletionDate;
    private ProductionOrderState state;

    public static ProductionOrder create(String name) {
        val result = new ProductionOrder();
        result.name = name;
        result.state = ProductionOrderState.DRAFT;
        return result;
    }

    public enum ProductionOrderState { DRAFT, SUBMITTED, ACCEPTED; }

}

 

Repository庫和基本 REST API
為了將我們的聚合持久化到資料庫並從那裡檢索它,我們定義了一個介面,為簡單起見擴充套件了CrudRepositorySpring Data 的介面。我們沒有在其名稱中使用“儲存庫”,而是將其命名ProductionOrders為持久儲存我們領域無處不在的語言。
為了將我們的聚合持久化到資料庫並從那裡檢索它,我們定義了一個介面,為簡單起見擴充套件了CrudRepositorySpring Data 的介面。我們沒有在其名稱中使用“儲存庫”,而是將其命名ProductionOrders為堅持我們領域無處不在的語言。

package com.example.demo.productionorders;

import org.springframework.data.repository.CrudRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

@RepositoryRestResource
public interface ProductionOrders extends CrudRepository<ProductionOrder, Long> {

}


啟動應用程式並透過 curl 命令列工具查詢其 API,我們得到以下響應:

$ curl http://localhost:8080/api
 {   
  "_links" : { 
    "productionOrders" : { 
      "href" : "http://localhost:8080/api/productionOrders" 
    }, 
    "profile" : { 
      "href" : “http://localhost:8080/api/profile” 
    } 
  } 
}

你注意到欄位_links了嗎?是的,Spring Data REST預設產生HAL格式的響應。它向我們表明,API提供了一個集合資源 "productionOrders",包括一個如何導航的連結。如果每個資源都提供了客戶需要的所有連結,以導航到相關的資源和呼叫動作,那麼我們就可以得出以下結論。
客戶端只需要知道一個URL,那就是"/api"。在一個真正的REST-ful API中,所有其他的URL都可以從API的響應中檢索出來。
為了進一步說明這個概念,我們在DemoApplication類中建立並持久化一些生產訂單,然後關注productionOrders資源的href-Property。

$ curl http://localhost:8080/api/productionOrders
{
  "_embedded" : {
    "productionOrders" : [ {
      "name" : "Order 1",
      "expectedCompletionDate" : null,
      "state" : "DRAFT",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/productionOrders/1"
        },
        "productionOrder" : {
          "href" : "http://localhost:8080/api/productionOrders/1"
        }
      }
    }, {
      "name" : "Order 2",
      "expectedCompletionDate" : null,
      "state" : "DRAFT",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/productionOrders/2"
        },
        "productionOrder" : {
          "href" : "http://localhost:8080/api/productionOrders/2"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/productionOrders"
    },
    "profile" : {
      "href" : "http://localhost:8080/api/profile/productionOrders"
    }
  }
}

我們看到,兩個生產訂單被返回,包含在HAL規範定義的特殊欄位_embedded的一個屬性中。如果與一個資源的互動需要額外的資訊,例如下拉選單的值,以過濾特定狀態下的生產訂單,這些資料可以被新增到響應的_embedded屬性下的另一個欄位。
每個生產訂單資源都提供一組連結,預設情況下是相當瑣碎的:一個自我連結和一個生產訂單連結,都指向資源本身。作為下一步,我們現在將把業務操作新增到生產訂單類中,並把呼叫它們的端點作為附加連結公開。
 

增加業務行為
如果我們回顧一下業務需求的大綱,我們的聚合體需要支援以下操作。

  • 當處於DRAFT狀態時允許重新命名
  • 一個向製造商提交訂單的操作
  • 一個接受訂單的操作,提供預期交貨日期

該實現遵循我們在建立方法中已經使用的方法。我們沒有提供getters和setters,而是用符合我們領域語言的名字來實現方法:renameTo、submit、accept。
如上所述,聚合被看作是一致性的邊界。由於額外的方法不再是靜態的,我們可以直接利用類的欄位來執行所需的業務規則,並允許只執行定義明確的狀態轉換。例如,我們可以完全確定永遠不會遇到沒有完成日期的已接受的生產訂單,這是我們第二個業務規則的要求。與基於setter的方法相比,這是一個多麼大的區別啊!
在生產訂單總量中實施三種業務操作,只允許有明確的狀態轉換。

package com.example.demo.productionorders;

import java.time.LocalDate;
import java.util.Objects;

import org.springframework.data.annotation.Id;

import lombok.Getter;
import lombok.val;

@Getter
public class ProductionOrder {

    @Id
    private Long id;
    private String name;
    private LocalDate expectedCompletionDate;
    private ProductionOrderState state;

    public static ProductionOrder create(String name) {
        val result = new ProductionOrder();
        result.name = name;
        result.state = ProductionOrderState.DRAFT;
        return result;
    }
    
    public ProductionOrder renameTo(String newName) {
        if (state != ProductionOrderState.DRAFT) {
            throw new IllegalStateException("Cannot rename production order in state " + state);
        }
        name = newName;
        return this;
    }
        
    public ProductionOrder submit() {
        if (state != ProductionOrderState.DRAFT) {
            throw new IllegalStateException("Cannot submit production order in state " + state);
        }
        state = ProductionOrderState.SUBMITTED;
        return this;
    }

    public ProductionOrder accept(LocalDate expectedCompletionDate) {
        if (state != ProductionOrderState.SUBMITTED) {
            throw new IllegalStateException("Cannot accept production order in state " + state);
        }
        Objects.requireNonNull(expectedCompletionDate, "expectedCompletionDate is required to submit a production order");
        if (expectedCompletionDate.isBefore(LocalDate.now())) {
            throw new IllegalArgumentException("Expected completion date must be in the future, but was " + expectedCompletionDate);
        }
        state = ProductionOrderState.ACCEPTED;
        this.expectedCompletionDate = expectedCompletionDate;
        return this;
    }

    public enum ProductionOrderState { DRAFT, SUBMITTED, ACCEPTED; }

}
 

在REST API中公開業務能力
作為下一步,我們現在要在REST API中公開業務操作。我們需要這樣做的成分是。

  • 為每個行動提供新的端點,其形式為/api/productionOrders/{id}/{action}。
  • ProductionOrder資源的HAL表示中的連結

仔細想想,如果我們只暴露一個指向端點的連結,如果相應的動作實際上是允許的,這不是很好嗎,這取決於特定生產訂單的狀態?這可以透過以下方式輕鬆實現。
我們首先實現一個ProductionOrderController類,並在類的層面上將其對映到/api/productionOrders端點(如果你在對映中遇到麻煩,請參見bug https://github.com/spring-projects/spring-data-rest/issues/1342)。
這使得我們可以透過額外的方法來擴充套件Spring Data REST提供的標準API:rename, submit, accept。這些方法從路徑中獲取生產訂單的ID,並從請求體中獲取任何額外的必要引數。由於與網路技術的整合是應用層的問題,我們把它放在子包web中,以便將其與領域邏輯明確分開。
在聚合上應用動作的模式總是相同的:從永續性儲存中載入聚合,呼叫業務操作,並將其儲存到儲存中。這同樣適用於我們案例中的關係型持久化,但也適用於事件源模型。為了簡單起見,我們在控制器中做了所有的事情,而在一個更大的或更純粹的應用中,控制器將委託給一個域服務。
關於這篇博文的主題,更有趣的部分是透過實現Spring HATEOAS的RepresentationModelProcessor介面。在這個方法過程中,它把生產訂單包裝成一個實體模型。這個實體模型允許向生產訂單資源新增額外的連結。因為該模型也提供了生產訂單本身,我們可以很容易地檢查它的狀態,然後決定是否生成一個特定的連結。
Spring提供了靜態的輔助方法linkTo和methodOn來動態地匯出引用的控制器方法的URL。

package com.example.demo.productionorders.web;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

import java.time.LocalDate;

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import com.example.demo.productionorders.ProductionOrder;
import com.example.demo.productionorders.ProductionOrder.ProductionOrderState;
import com.example.demo.productionorders.ProductionOrders;

import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.val;

@RestController
@RequestMapping("/api/productionOrders")
@RequiredArgsConstructor
public class ProductionOrderController implements RepresentationModelProcessor<EntityModel<ProductionOrder>> {

    public static final String REL_RENAME = "rename";
    public static final String REL_SUBMIT = "submit";
    public static final String REL_ACCEPT = "accept";
    
    private final ProductionOrders productionOrders;
    
    @PostMapping("/{id}/rename")
    public ResponseEntity<?> rename(@PathVariable Long id, @RequestBody RenameRequest request) {
        return productionOrders.findById(id)
            .map(po -> productionOrders.save(po.renameTo(request.newName)))
            .map(po -> ResponseEntity.ok().body(EntityModel.of(po)))
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping("/{id}/submit")
    public ResponseEntity<?> submit(@PathVariable Long id) {
        return productionOrders.findById(id)
            .map(po -> productionOrders.save(po.submit()))
            .map(po -> ResponseEntity.ok().body(EntityModel.of(po)))
            .orElse(ResponseEntity.notFound().build());
    }
    
    @PostMapping("/{id}/accept")
    public ResponseEntity<?> accept(@PathVariable Long id, @RequestBody CompleteRequest request) {
        return productionOrders.findById(id)
            .map(po -> productionOrders.save(po.accept(request.expectedCompletionDate)))
            .map(po -> ResponseEntity.ok().body(EntityModel.of(po)))
            .orElse(ResponseEntity.notFound().build());
    }
    
    @Override
    public EntityModel<ProductionOrder> process(EntityModel<ProductionOrder> model) {        
        val order = model.getContent();
        if (order.getState() == ProductionOrderState.DRAFT) {
            model.add(linkTo(methodOn(getClass()).rename(order.getId(), null)).withRel(REL_RENAME));
            model.add(linkTo(methodOn(getClass()).submit(order.getId())).withRel(REL_SUBMIT));
        }
        if (order.getState() == ProductionOrderState.SUBMITTED) {
            model.add(linkTo(methodOn(getClass()).accept(order.getId(), null)).withRel(REL_ACCEPT));
        }                
        return model;
    }
    
    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    void handleValidationException(Exception exception) {
        throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, exception.getMessage());
    }
    
    @Value
    static class RenameRequest {
        @NonNull String newName;
    }

    @Value
    static class CompleteRequest {
        @NonNull LocalDate expectedCompletionDate;
    }
    
}

再次查詢productionOrders資源為我們提供了每個生產訂單資源上的新連結。

$ curl http://localhost:8080/api/productionOrders
{
  "_embedded" : {
    "productionOrders" : [ {
      "name" : "Order 1",
      "expectedCompletionDate" : null,
      "state" : "DRAFT",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/productionOrders/1"
        },
        "productionOrder" : {
          "href" : "http://localhost:8080/api/productionOrders/1"
        },
        "rename" : {
          "href" : "http://localhost:8080/api/productionOrder/1/rename"
        },
        "submit" : {
          "href" : "http://localhost:8080/api/productionOrder/1/submit"
        }
      }
    }, {
      "name" : "Order 2",
      "expectedCompletionDate" : null,
      "state" : "DRAFT",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/productionOrders/2"
        },
        "productionOrder" : {
          "href" : "http://localhost:8080/api/productionOrders/2"
        },
        "rename" : {
          "href" : "http://localhost:8080/api/productionOrder/2/rename"
        },
        "submit" : {
          "href" : "http://localhost:8080/api/productionOrder/2/submit"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/productionOrders"
    },
    "profile" : {
      "href" : "http://localhost:8080/api/profile/productionOrders"
    }
  }
}

你看到Spring並沒有公開聚合的ID屬性。我們稍後會看到,我們不需要在客戶端知道它,因為它包含在連結中。
還請注意,每個連結都有一個關係屬性,簡稱 "rel"。這個屬性非常重要,因為它定義了與API客戶端的契約,即一個特定資源存在哪些連結。我們很快就會看到;我們的後端現在已經完成了,我們可以繼續在前端利用它了。
 

在前端消費HAL模型
正如我之前所說,一個真正的REST-ful API的客戶端應該只知道一個URL。/api。客戶端呼叫的任何其他URL都應該從響應中的連結中獲得。
從我們Angular應用程式的頂級元件(即AppComponent)中發出對基本URL的請求,並將其作為輸入傳遞給子元件,這將是一個自然的選擇。為了保持簡單,我們在ProductionOrderListComponent中做了所有事情,作為onInit方法的一部分獲取API,並將響應儲存在欄位根中。見下面程式碼
為了顯示生產訂單,我們在類頂部的@Component-decorator中新增一個HTML模板,並透過跟蹤根資源中與 "productionOrders "有關的連結,從後臺載入productionOrders。正如我們在上面看到的,這個連結的url屬性是http://localhost:8080/api/productionOrders,但前端對此是不知道的。事實上,後端可以在一個完全不同的URL下提供生產訂單,而我們的前端仍然可以工作。只有 "productionOrders "這個關係,也就是後端和前端之間的契約,必須保持穩定。
 
最初的生產訂單列表元件,首先載入API資源,然後透過各自關係下提供的URL載入生產訂單。

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { ProductionOrderResource } from '../model';

const API = "/api";
const REL = "productionOrders"; 

@Component({
  selector: 'app-production-order-list',
  template: `
  <ul>
    <li *ngFor="let order of productionOrders">{{order.name}} 
      <span *ngIf="order.expectedCompletionDate">
        Expected for {{order.expectedCompletionDate|date}}
      </span> ({{order.state}})
    </li>
  </ul>
  `,
  styleUrls: ['./production-order-list.component.css']
})
export class ProductionOrderListComponent implements OnInit {

  root: any;
  productionOrders?: ProductionOrderResource[];

  constructor(private http: HttpClient) { }

  ngOnInit(): void {
    this.http.get(API).subscribe(
      response => {
        this.root = response;
        this.reload();
      },
      error => alert(error)
    );
  }

  private reload(): void {
    if (this.root) {
      this.http.get<any>(this.root._links[REL].href).subscribe(
        response => this.productionOrders = response._embedded[REL],
        error => alert(error)
      )
    }
  }
}


接下來,我們需要新增一種方式,讓使用者可以在模型上執行相應的業務操作。最簡單的方法是為當前允許的每個動作在生產訂單旁邊新增一個按鈕。
為每個生產訂單新增按鈕,根據生產訂單資源中相關連結關係的存在與否,顯示或隱藏這些按鈕。

<ul>
  <li *ngFor="let order of productionOrders">{{order.name}}
    <span *ngIf="order.expectedCompletionDate">
      Expected for {{order.expectedCompletionDate|date}}
    </span> ({{order.state}})
    <button *ngIf="can('rename', order)" (click)="do('rename', order)">rename</button>
    <button *ngIf="can('submit', order)" (click)="do('submit', order)">submit</button>
    <button *ngIf="can('accept', order)" (click)="do('accept', order)">accept</button>
  </li>
</ul>

為了決定一個給定的動作是否被允許,以及相應的按鈕是否應該被顯示,我們查詢底層資源,以獲取與給定關係的連結。事實上,我們只是實現了一個功能切換:如果該關係被提供為一個連結,則該動作被啟用,否則它在GUI中被隱藏。
請注意,示例程式碼(你可以在這篇文章的末尾找到連結)新增了一些介面,以允許對生產訂單資源的_links-property進行型別安全的訪問。
最後,我們只需要為那些需要提交額外資料的動作新增特殊處理。在這裡,我們再次使用最簡單的解決方案,使用本地的提示控制,在重新命名動作的情況下接受新的名稱,在接受動作的情況下接受預期完成日期。
一個非常簡單的 "能 "和 "做 "方法的實現,利用關係和聯絡或生產秩序資源。

can(action: string, order: any): boolean {
    return !!order._links[action];
  }

  do(action: string, order: ProductionOrderResource): void {
    var body = {};
    if (action === 'rename') {
      const newName = prompt("Please enter the new name:");
      if (!newName) {
        return;
      }
      body = {
        newName: newName
      };
    } else if (action === 'accept') {
      const expectedCompletionDate = prompt("Expected completion date (yyyy-MM-dd):");
      if (!expectedCompletionDate) {
        return;
      }
      body = {
        expectedCompletionDate: expectedCompletionDate
      }
    }
    const url = order._links[action].href;
    this.http.post(url, body).subscribe(
      _ => this.reload(), 
      response => alert([response.error.error, response.error.message].join("\n")));
  }

因為我們在can-method中檢查了一個連結的存在,所以我們可以透過提取該連結的href-property輕鬆地確定要釋出正文的URL。這樣一來,我們的小演示程式的前端也就完成了。
 

總結
儘管許多開發者知道REST-ful APIs的Richardson成熟度模型,但只有少數人使用嵌入式連結來驅動應用狀態。基於Open API(以前稱為Swagger)的API文件將重點放在絕對URL上,而不是關係上。因此,前端程式碼經常被固定的URL所束縛,並複製了許多最好在後端進行的邏輯。
Spring HATEOAS為利用REST的全部潛力提供了所有必要的工具。因此,HAL是一個簡單而強大的連結關係標準,可以很容易地被Angular應用程式消費,很適合選擇性地釋出遵循領域驅動設計原則的領域模型的業務操作。本文展示的方法可以幫助大大降低Web應用的複雜性,使業務邏輯遠離前端,並使前端的行為基於後臺的狀態而 "恰到好處"。
你可以在這裡找到原始碼:https://github.com/sth77/spring-angular-ddd-hateoas

 

相關文章