目錄
- 為什麼我們用 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
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) 。