RazorLight 與 Razor.Templating.Core 差異
模板引擎 RazorLight 與 Razor.Templating.Core 差異
前言
用 Razor 模板(.cshtml)在後端產生 HTML(例如啟用信、密碼重設信、驗證碼信)時,
常見的兩個引擎是 RazorLight 與 Razor.Templating.Core。
模板檔案本身可以完全一樣,連 @model、@if、@Model.Xxx 都不用改,
但專案設定和驅動程式的實作差很多——而且差的點,剛好都源自同一件事:
RazorLight 是執行期(runtime)才編譯模板,Razor.Templating.Core 是建置期(build time)就預編譯好。
這一個差異,連帶解釋了三個「為什麼」:
- 為什麼 RazorLight 要寫一支 warmup 預熱 程式,Razor.Templating.Core 不用。
- 為什麼 RazorLight 的模板要設成 嵌入資源(EmbeddedResource),Razor.Templating.Core 不用。
- 為什麼用 Razor.Templating.Core 時,多開一個專案會比直接改 Infrastructure 好。
這篇就把這三件事講清楚,並附上兩邊的程式碼對照。
一句話先講核心差異
| RazorLight | Razor.Templating.Core | |
|---|---|---|
| 模板編譯時機 | 執行期,第一次 render 時用 Roslyn 即時編譯 | 建置期,由 Razor SDK 先編成 view 類別 |
| 模板怎麼帶進程式 | .cshtml 原始碼以嵌入資源塞進 DLL | .cshtml 是建置輸入,編完就不需要原始碼 |
| 冷啟動 | 有,第一次 render 慢 → 需要預熱 | 無,啟動即可用 → 不需要預熱 |
| 取得引擎 | 自己 RazorLightEngineBuilder 建 | DI 注入 IRazorTemplateEngine |
| 模板 key | 嵌入資源名(如 "ActivationEmail") | view 路徑(如 "/Emails/ActivationEmail.cshtml") |
| 專案 SDK | 一般 Microsoft.NET.Sdk 即可 | 放模板的專案要 Microsoft.NET.Sdk.Razor |
補充:Roslyn 是什麼? Roslyn 是 .NET 官方的 C# 編譯器(正式名稱 .NET Compiler Platform)。
你平常dotnet build把 C# 編成 DLL 用的就是它;特別的是它把「編譯」開放成 API,
程式可以在執行期呼叫它,把一段 C# 即時編譯成可執行的組件。
兩個引擎的差別不在「有沒有用到 Roslyn」,而在 RazorLight 是執行期才呼叫它、
Razor.Templating.Core 則是在建置期就用它編好。
下面逐一展開。
差異一:專案設定(csproj)
RazorLight
Infrastructure 專案本身就是一般的 class library,模板放在專案裡,設成嵌入資源:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RazorLight" Version="2.3.1" />
<!-- 其他套件略 -->
</ItemGroup>
<!-- 模板必須塞進組件,RazorLight 執行期才讀得到 -->
<ItemGroup>
<EmbeddedResource Include="EmailTemplates\**\*.cshtml" />
</ItemGroup>
</Project>重點是那段 <EmbeddedResource>。RazorLight 執行期需要拿到 .cshtml 的原始文字去編譯,
所以一定要把模板原始碼包進 DLL,它才找得到。
Razor.Templating.Core
建議把架構拆成兩個專案(原因見後面「最佳實踐」一節):
EsignGateway.RazorTemplates —— 專門放模板,用 Razor SDK:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- @model 用到的型別在 Domain,要參考它 -->
<ProjectReference Include="..\EsignGateway.Domain\EsignGateway.Domain.csproj" />
</ItemGroup>
</Project>注意這裡:
- SDK 是
Microsoft.NET.Sdk.Razor,不是一般的Microsoft.NET.Sdk。 <AddRazorSupportForMvc>true</AddRazorSupportForMvc>讓.cshtml在 build 時被編成 MVC view 類別。FrameworkReference Microsoft.AspNetCore.App提供 Razor / MVC 那些型別。- 沒有
<EmbeddedResource>,因為模板已經被編進組件,不需要再帶原始碼。
EsignGateway.Infrastructure —— 維持一般 class library,只是多參考模板專案 + 套件:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Razor.Templating.Core" Version="3.1.0" />
<!-- 其他套件略 -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EsignGateway.Domain\EsignGateway.Domain.csproj" />
<ProjectReference Include="..\EsignGateway.RazorTemplates\EsignGateway.RazorTemplates.csproj" />
<!-- 其他參考略 -->
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>為什麼要這樣拆?這是一個值得特別強調的最佳實踐,後面有一段專門講。
差異二:驅動程式實作(Driver)
RazorLight:自己建引擎 + Lazy 單例
using Microsoft.Extensions.Logging;
using RazorLight;
using Riyar.Domain.DomainModels.Base;
using Riyar.Domain.DriverContracts;
namespace Riyar.Infrastructure.Drivers;
public class EmailTemplateDriver : IEmailTemplateDriver
{
private readonly IRazorLightEngine _razorEngine;
private readonly ILogger<EmailTemplateDriver> _logger;
// 引擎建構成本高,用 Lazy 確保整個 App 只建一次
private static readonly Lazy<IRazorLightEngine> _lazyEngine = new(() =>
{
return new RazorLightEngineBuilder()
.UseEmbeddedResourcesProject(typeof(EmailTemplateDriver).Assembly, "Riyar.Infrastructure.EmailTemplates")
.UseMemoryCachingProvider()
.Build();
});
public EmailTemplateDriver(ILogger<EmailTemplateDriver> logger)
{
_logger = logger;
_razorEngine = _lazyEngine.Value;
}
public async Task<string> RenderActivationEmailAsync(ActivationEmailTemplateModel model)
{
// ...stopwatch / logging 省略
const string templateKey = "ActivationEmail"; // 嵌入資源名
var result = await _razorEngine.CompileRenderAsync(templateKey, model);
return result;
}
// PasswordReset / VerificationToken 同理
}幾個重點:
- 要自己
RazorLightEngineBuilder建引擎,還得用UseEmbeddedResourcesProject指定組件與根命名空間,它才知道去哪裡撈嵌入的.cshtml。 - 引擎建構不便宜,所以包成
static Lazy<>單例,避免每次注入都重建。 - 模板 key 是嵌入資源名(設過根命名空間後可只給檔名
"ActivationEmail")。 CompileRenderAsync顧名思義:Compile + Render,第一次會真的去編譯。
Razor.Templating.Core:注入引擎、給路徑
using Microsoft.Extensions.Logging;
using Razor.Templating.Core;
using EsignGateway.Domain.DomainModels.Base;
using EsignGateway.Domain.DriverContracts;
namespace EsignGateway.Infrastructure.Drivers;
public class EmailTemplateDriver : IEmailTemplateDriver
{
private readonly IRazorTemplateEngine _razorTemplateEngine;
private readonly ILogger<EmailTemplateDriver> _logger;
public EmailTemplateDriver(IRazorTemplateEngine razorTemplateEngine, ILogger<EmailTemplateDriver> logger)
{
_razorTemplateEngine = razorTemplateEngine;
_logger = logger;
}
public async Task<string> RenderActivationEmailAsync(ActivationEmailTemplateModel model)
{
// ...stopwatch / logging 省略
var result = await _razorTemplateEngine.RenderAsync("/Emails/ActivationEmail.cshtml", model);
return result;
}
// PasswordReset / VerificationToken 同理
}對照之下整個清爽很多:
- 不用自己建引擎,直接 DI 注入
IRazorTemplateEngine(套件也有提供靜態RazorTemplateEngine.RenderAsync(...),但要在模板裡注入服務、或想吃 DI 的話,注入介面版比較正規)。 - 沒有
Lazy<>、沒有 builder、沒有嵌入資源命名空間那些雜訊。 - 模板 key 改成 view 路徑:
"/Emails/ActivationEmail.cshtml"(前面有斜線、要帶副檔名,對應的就是.cshtml在模板專案裡的相對路徑)。
模板檔案本身幾乎不用動,@model、@if、@Model.Xxx 語法兩邊相容,差別只在 @model 後面的命名空間是各自專案的型別而已。
差異三:DI 註冊
RazorLight 那邊沒什麼特別的,照常註冊 IEmailTemplateDriver、再把 warmup 掛成 IHostedService(下一段會講)。
Razor.Templating.Core 要多呼叫一個 AddRazorTemplating(),而且順序有講究:
public static IServiceCollection AddEmailTemplateModule(this IServiceCollection services)
{
services.AddScoped<IEmailTemplateDriver, EmailTemplateDriver>();
// AddRazorTemplating() 必須在其他服務都註冊完之後才呼叫,
// 否則模板裡若有 @inject 服務會解析不到。
services.AddRazorTemplating();
return services;
}AddRazorTemplating() 會把 IRazorTemplateEngine 與背後那套 MVC view engine 接進 DI。
如果你的模板裡會 @inject 自己的服務,記得讓這行排在那些服務註冊之後,不然引擎建立時抓不到。
為什麼 RazorLight 要「預熱」,Razor.Templating.Core 不用?
這是最值得講清楚的一點。
RazorLight 的冷啟動是真的痛
RazorLight 走的是執行期編譯:第一次 render 某個模板時,它會
讀取 .cshtml 原始碼 → 產生 C# 程式碼 → 丟給 Roslyn 即時編譯成一個記憶體裡的組件 → 快取起來。
初次 Roslyn 編譯動輒幾百毫秒到一兩秒。對寄信來說,使用者剛好踩到「第一次寄某種信」就會明顯卡頓。
所以才需要一支**預熱(warmup)**的 IHostedService,在 App 啟動時就先用假資料 render 過一輪,把編譯成本提早付掉:
/// <summary> 在 App 啟動時預先 render 一次,觸發 RazorLight 編譯,避免第一個使用者踩到冷啟動 </summary>
public class EmailTemplateWarmupDriver : IHostedService
{
// ...
private async Task PerformWarmupAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var driver = scope.ServiceProvider.GetRequiredService<IEmailTemplateDriver>();
// 用假資料 render,純粹為了觸發編譯
await driver.RenderActivationEmailAsync(new ActivationEmailTemplateModel
{
FullName = "預熱測試用戶",
Account = "warmup_test",
ActivationUrl = "https://example.com/activate/warmup",
ExpirationMinutes = 30,
SystemName = "預熱系統",
CompanyName = "預熱測試公司"
});
await driver.RenderPasswordResetEmailAsync(/* 假資料 */);
// ...
}
}這支東西沒有業務價值,純粹是為了繞過引擎本身的冷啟動,是 RazorLight 架構逼出來的成本。
Razor.Templating.Core 把編譯搬到 build time
Razor.Templating.Core 配合 Microsoft.NET.Sdk.Razor + <AddRazorSupportForMvc>,
在建置階段就把每個 .cshtml 編成一個編譯好的 Razor view 類別,直接塞進組件。
執行期要 render 時,它做的事只是「找到那個已經編好的 view 類別、實例化、把 model 灌進去跑」——
完全沒有 Roslyn、沒有即時編譯,自然也就沒有冷啟動。
所以用 Razor.Templating.Core 時,根本不需要 EmailTemplateWarmupDriver 這種東西。
編譯成本在 dotnet build 時就付掉了,線上不會有「第一次特別慢」這回事。
一句話:RazorLight 把編譯成本放到 runtime,所以要 warmup 把它提早;
Razor.Templating.Core 把編譯成本放到 build time,runtime 直接零成本,warmup 沒有存在意義。
為什麼 RazorLight 要嵌入資源,Razor.Templating.Core 不用?
道理其實跟上面同一條:誰需要在執行期拿到 .cshtml 原始碼?
RazorLight 需要。它執行期才編譯,所以一定要能在執行期讀到模板的原始文字。
把.cshtml設成<EmbeddedResource>就是把原始碼打包進 DLL,
再用UseEmbeddedResourcesProject(assembly, "根命名空間")告訴它去那個組件撈。
少了嵌入資源這段,RazorLight 執行期會找不到模板。Razor.Templating.Core 不需要。模板在 build time 已經被編成 view 類別,
執行期要的是「編好的類別」,不是「原始.cshtml」。
所以模板專案裡不用、也不該再加<EmbeddedResource>,.cshtml純粹是建置輸入,編完它的「文字」就功成身退了。
這也是為什麼在 Razor.Templating.Core 這邊,那段
<EmbeddedResource Include="EmailTemplates\**\*.cshtml" />不該出現。加了的話模板會被「當成嵌入資源」又「被 Razor SDK 編譯」,重複處理只會添亂。
最佳實踐:用獨立 Razor 專案,別污染 Infrastructure
Razor.Templating.Core 的文件要求:放 .cshtml 的專案 SDK 要是 Microsoft.NET.Sdk.Razor。
最直覺的做法是直接把 Infrastructure 的 SDK 從
<Project Sdk="Microsoft.NET.Sdk">改成
<Project Sdk="Microsoft.NET.Sdk.Razor">然後在裡面塞 <AddRazorSupportForMvc>、FrameworkReference。這樣能動,但不建議。
原因:Infrastructure 是基礎建設層,裡面是 EF Core、S3、Redis、MailKit 這些東西,
它應該是一個乾淨、單純的 class library。
一旦把它改成 Razor SDK,這個專案的性質就變了——它開始帶有「web/MVC」味道、預設的檔案 glob 行為也不同,
只為了「裡面剛好放了幾個寄信模板」就讓整層基礎建設背上 Razor SDK 的包袱,分層上很不乾淨。
較好的做法:開一個專門的 Razor 模板專案(例如 EsignGateway.RazorTemplates),
- 它用
Microsoft.NET.Sdk.Razor,符合文件要求; - 模板
.cshtml通通放這裡,職責單一; - 參考
Domain(@model要用到它的型別); - 然後 Infrastructure 維持
Microsoft.NET.Sdk不變,只是多一個ProjectReference指向它。
Razor.Templating.Core 會跨參考組件去找已編譯的 view,所以模板放在獨立專案裡,
Infrastructure 一樣 render 得到。
換句話說:讓「需要 Razor SDK」這件事被隔離在一個小專案裡,而不是傳染給整個基礎建設層。
分層乾淨、職責清楚,之後誰來看都一眼明白「模板住在哪、為什麼那個專案 SDK 不一樣」。
那到底該選哪個?
兩個都能把 Razor 模板 render 成 HTML 字串,純功能上沒有誰做不到的事,差別在「編譯成本付在哪、模板從哪裡來」:
多數情況選 Razor.Templating.Core。 只要你的模板是固定的、跟著程式一起出貨(寄信模板幾乎都是這種),
它就是比較省事的一邊:build 時編好,沒有冷啟動、不必寫預熱、不必設嵌入資源,Driver 也只是注入 IRazorTemplateEngine 給路徑而已。
唯一要付的代價是放模板的專案得用 Microsoft.NET.Sdk.Razor——把它獨立成一個小專案就能解決(見上一節)。
這些情況才考慮 RazorLight:
- 模板要執行期動態載入——例如從資料庫、檔案系統,甚至執行期才組出字串當模板。
RazorLight 本來就是 runtime 編譯,吃「build 時還不存在」的模板是它的強項,這點 Razor.Templating.Core 做不到。 - 你完全不想引入 Razor SDK /
Microsoft.AspNetCore.App,希望純 class library 就解決。
一句話總結:
模板固定、跟程式一起出貨 → Razor.Templating.Core;
模板要能執行期動態載入 → RazorLight。
小結
兩者最核心的差別只有一句:編譯時機是 runtime 還是 build time。這一點往下展開就是整篇的內容:
- RazorLight 在 runtime 編譯 → 有冷啟動(要寫預熱)、執行期需要原始
.cshtml(要設嵌入資源)、能吃動態模板。 - Razor.Templating.Core 在 build time 編譯 → 沒冷啟動(免預熱)、執行期不需要原始碼(免嵌入資源)、但模板得跟著編譯、放模板的專案要用 Razor SDK。
把這條軸抓住,前面那些設定與寫法上的差異,其實都只是同一個選擇的延伸而已。