.NET 雲原生架構師訓練營(基於 OP Storming 和 Actor 的大型分散式架構二)--學習筆記

鄭子銘發表於2022-12-25

目錄

  • 為什麼我們用 Orleans
  • Dapr VS Orleans
  • Actor 模型
  • Orleans 的核心概念
  • 結合 OP Storming 的實踐

結合 OP Storming 的實踐

  • 業務模型
  • 設計模型
  • 程式碼實現

業務模型

我們可以把關鍵物件(職位、客戶行為記錄、線索)參考為 actor

獵頭顧問一邊尋找職位,一邊尋找候選人,撮合之後匹配成線索,然後推薦候選人到客戶公司,進行面試,發放 offer,候選人入職

設計模型

我們新建職位的時候需要一個引數物件 CreateJobArgument,相當於錄入資料

建立了 Job 之後,它有三個行為:瀏覽、點贊、投遞

投遞之後會直接產生一個意向的 Thread,可以繼續去推進它的狀態:推薦 -> 面試 -> offer -> 入職

針對瀏覽和點贊會產生兩種不同的活動記錄:ViewActivity 和 StarActivity

程式碼實現

  • HelloOrleans.Host

HelloOrleans.Host

新建一個空白解決方案 HelloOrleans

建立一個 ASP .NET Core 空專案 HelloOrleans.Host

分別建立 BaseEntity、Job、Thread、Activity 實體

namespace HelloOrleans.Host.Contract.Entity
{
    public class BaseEntity
    {
        public string Identity { get; set; }
    }
}


namespace HelloOrleans.Host.Contract.Entity
{
    public class Job : BaseEntity
    {
        public string Title { get; set; }
        public string Description { get; set; }
        public string Location { get; set; }
    }
}

namespace HelloOrleans.Host.Contract.Entity
{
    public class Thread : BaseEntity
    {
        public string JobId { get; set; }
        public string ContactId { get; set; }
        public EnumThreadStatus Status { get; set; }
    }
}

namespace HelloOrleans.Host.Contract
{
    public enum EnumThreadStatus : int
    {
        Recommend,
        Interview,
        Offer,
        Onboard,
    }
}

namespace HelloOrleans.Host.Contract.Entity
{
    public class Activity : BaseEntity
    {
        public string JobId { get; set; }
        public string ContactId { get; set; }
        public EnumActivityType Type { get; set; }
    }
}

namespace HelloOrleans.Host.Contract
{
    public enum EnumActivityType : int
    {
        View = 1,
        Star = 2,
    }
}

給 Job 新增 View 和 Star 的行為

public async Task View(string contactId)
{

}

public async Task Star(string contactId)
{

}

這裡就只差 Grain 的 identity,我們新增 Orleans 的 nuget 包

<PackageReference Include="Microsoft.Orleans.Core" Version="3.6.5" />
<PackageReference Include="Microsoft.Orleans.Server" Version="3.6.5" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.6.5" />
<PackageReference Include="Microsoft.Orleans.OrleansTelemetryConsumers.Linux" Version="3.6.5" />
  • Microsoft.Orleans.Core 是核心
  • Microsoft.Orleans.Server 做 Host 就需要用到它
  • Microsoft.Orleans.CodeGenerator.MSBuild 會在編譯的時候幫我們生成客戶端或者訪問程式碼
  • Microsoft.Orleans.OrleansTelemetryConsumers.Linux 是監控

安裝完後我們就可以繼承 Grain 的基類了

using Orleans;

namespace HelloOrleans.Host.Contract.Entity
{
    public class Job : Grain
    {
        public string Title { get; set; }
        public string Description { get; set; }
        public string Location { get; set; }

        public async Task View(string contactId)
        {

        }

        public async Task Star(string contactId)
        {

        }
    }
}

如果我們需要用它來做持久化是有問題的,因為持久化的時候它會序列化我們所有的公有屬性,然而在 Grain 裡面會有一些公有屬性你沒有辦法給它序列化,所以持久化的時候會遇到一些問題,除非我們把持久化的東西重新寫一遍

public abstract class Grain : IAddressable, ILifecycleParticipant<IGrainLifecycle>
{
    public GrainReference GrainReference { get { return Data.GrainReference; } }
    
    /// <summary>
    /// String representation of grain's SiloIdentity including type and primary key.
    /// </summary>
    public string IdentityString
    {
        get { return Identity?.IdentityString ?? string.Empty; }
    }
    
    ...
}

理論上你的狀態和行為是可以封裝在一起的,這樣更符合 OO 的邏輯

我們現在需要分開狀態和行為

定義一個 IJobGrain 介面,繼承 IGrainWithStringKey,用 string 作為它的 identity 的型別

using Orleans;

namespace HelloOrleans.Host.Contract.Grain
{
    public interface IJobGrain : IGrainWithStringKey
    {
        Task View(string contactId);
    }
}

定義 JobGrain 繼承 Grain,實現 IJobGrain 介面

using HelloOrleans.Host.Contract.Entity;
using HelloOrleans.Host.Contract.Grain;
using Orleans;

namespace HelloOrleans.Host.Grain
{
    public class JobGrain : Grain<Job>, IJobGrain
    {
        public Task View(string contactId)
        {
            throw new NotImplementedException();
        }
    }
}

這是使用 DDD 來做的區分開狀態和行為,變成貧血模型,是不得已而為之,因為持久化的問題

在 Orleans 的角度而言,它的 Actor 繫結了一個外部的狀態,但是實際上我們更希望它們兩在一起

它的實體就變成這樣

namespace HelloOrleans.Host.Contract.Entity
{
    public class Job
    {
        public string Title { get; set; }
        public string Description { get; set; }
        public string Location { get; set; }
    }
}

Job 不是 Actor 例項,JobGrain 才是 Actor 例項

接下來我們需要做一個 Host 讓它跑起來

新增 nuget 包

<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />

在 Program 中需要透過 WebApplication 的 Builder 配置 Orleans

builder.Host.UseOrleans(silo =>
{
    silo.UseLocalhostClustering();
    silo.AddMemoryGrainStorage("hello-orleans");
});

在 JobGrain 中使用 hello-orleans 這個 Storage 標識一下

[StorageProvider(ProviderName = "hello-orleans")]
public class JobGrain : Grain<Job>, IJobGrain

新增 JobController,這屬於前面講的 silo 內模式,可以直接使用 IGrainFactory,因為這是在同一個專案裡

using Microsoft.AspNetCore.Mvc;
using Orleans;

namespace HelloOrleans.Host.Controllers
{
    [Route("job")]
    public class JobController : Controller
    {
        private IGrainFactory _factory;

        public JobController(IGrainFactory grainFactory)
        {
            _factory = grainFactory;
        }
    }
}

新增一個建立方法 CreateAsync,它的入參叫做 CreateJobViewModel,包含我們需要的 Job 的資料

[Route("")]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateJobViewModel model)
{
    var jobId = Guid.NewGuid().ToString();
    var jobGrain = _factory.GetGrain<IJobGrain>(jobId);
}

建立的時候 Grain 是不存在的,必須有 identity,不然 Actor 獲取不到,所以需要先 new 一個 identity,就是 jobId

透過 IGrainFactory 獲取到 jobGrain 之後我們是無法獲取到它的 state,只能看到它的行為,所以我們需要在 Grain 裡面新增一個 Create 的方法方便我們呼叫

using HelloOrleans.Host.Contract.Entity;
using Orleans;

namespace HelloOrleans.Host.Contract.Grain
{
    public interface IJobGrain : IGrainWithStringKey
    {
        Task<Job> Create(Job job);
        Task View(string contactId);
    }
}

所以這個 Create 方法並不是真正的 Create,只是用來設定 state 的物件,再透過 WriteStateAsync 方法儲存

using HelloOrleans.Host.Contract.Entity;
using HelloOrleans.Host.Contract.Grain;
using Orleans;
using Orleans.Providers;

namespace HelloOrleans.Host.Grain
{
    [StorageProvider(ProviderName = "hello-orleans")]
    public class JobGrain : Grain<Job>, IJobGrain
    {
        public async Task<Job> Create(Job job)
        {
            job.Identity = this.GetPrimaryKeyString();
            this.State = job;
            await this.WriteStateAsync();
            return this.State;
        }

        public Task View(string contactId)
        {
            throw new NotImplementedException();
        }
    }
}

new 一個 job,呼叫 Create 方法設定 State,得到一個帶 identity 的 job,然後返回 OK

[Route("")]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateJobViewModel model)
{
    var jobId = Guid.NewGuid().ToString();
    var jobGrain = _factory.GetGrain<IJobGrain>(jobId);

    var job = new Job()
    {
        Title = model.Title,
        Description = model.Description,
        Location = model.Location,
    };
    job = await jobGrain.Create(job);
    return Ok(job);
}

因為我們現在採用的是記憶體級別的 GrainStorage,所以我們沒有辦法去檢視它

我們再加一個 Get 的方法去查詢它

[Route("{jobId}")]
[HttpGet]
public async Task<IActionResult> GetAsync(string jobId)
{
    var jobGrain = _factory.GetGrain<IJobGrain>(jobId);
}

這個時候我們需要去 Grain 的介面裡面加一個 Get 方法

using HelloOrleans.Host.Contract.Entity;
using Orleans;

namespace HelloOrleans.Host.Contract.Grain
{
    public interface IJobGrain : IGrainWithStringKey
    {
        Task Create(Job job);
        Task<Job> Get();
        Task View(string contactId);
    }
}

Get 方法是不需要傳 id 的,因為這個 id 就是 Grain 的 id,你啟用的時候就已經有了,直接返回 this.State

using HelloOrleans.Host.Contract.Entity;
using HelloOrleans.Host.Contract.Grain;
using Orleans;
using Orleans.Providers;

namespace HelloOrleans.Host.Grain
{
    [StorageProvider(ProviderName = "hello-orleans")]
    public class JobGrain : Grain<Job>, IJobGrain
    {
        public async Task Create(Job job)
        {
            this.State = job;
            await this.WriteStateAsync();
        }

        public Task<Job> Get()
        {
            return Task.FromResult(this.State);
        }

        public Task View(string contactId)
        {
            throw new NotImplementedException();
        }
    }
}

這個地方所有你的行為都不是直接去查資料庫,而是利用這個 State,它不需要你自己去讀取,跟 DDD 的 repository 不同

直接透過 Grain 的 Get 方法獲取 Job 返回 OK

[Route("{jobId}")]
[HttpGet]
public async Task<IActionResult> GetAsync(string jobId)
{
    var jobGrain = _factory.GetGrain<IJobGrain>(jobId);
    return Ok(await jobGrain.Get());
}

這裡我們可以再加點校驗邏輯

[Route("{jobId}")]
[HttpGet]
public async Task<IActionResult> GetAsync(string jobId)
{
    if (string.IsNullOrEmpty(jobId))
    {
        throw new ArgumentNullException(nameof(jobId));
    }

    var jobGrain = _factory.GetGrain<IJobGrain>(jobId);
    return Ok(await jobGrain.Get());
}

要注意如果你傳入的 jobId 是不存在的,因為不管你傳什麼,只要是一個合法的字串,並且不重複,它都會幫你去啟用,只不過在於它是否做持久化而已,如果你隨便傳了一個 jobId,這個時候不是調了 Get 方法,它可能也會返回給你一個空的 state,所以這個 jobId 沒有這種很強的合法性的約束,在調 Get 的時候要特別的注意,不管是 Create 還是 Get,其實都是呼叫了 GetGrain,傳了一個 identity 進去,這樣的一個行為

在 Program 中新增 Controller 的配置

using Orleans.Hosting;

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseOrleans(silo =>
{
    silo.UseLocalhostClustering();
    silo.AddMemoryGrainStorage("hello-orleans");
});
builder.Services.AddControllers();

var app = builder.Build();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});

app.MapGet("/", () => "Hello World!");

app.Run();

我們啟動專案測試一下

Create 方法入參

{
	"title": "第一個職位",
	"description": "第一個職位"
}

可以看到方法呼叫成功,返回的 job 裡面包含了 identity

接著我們使用 Create 方法返回的 identity 作為入參呼叫 Get 方法

可以看到方法呼叫成功,返回同一個 job

這種基於記憶體的儲存就很適合用來做單元測試

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com) 。

相關文章