安全高效跨平台的. NET 模板引擎 Fluid 使用文檔

Liquid 是一門開源的模板語言,由 Shopify 創造並用 Ruby 實現。它是 Shopify 主題的主要構成部分,並且被用於加載店鋪系統的動態內容。它是一種安全的模板語言,對於非程序員的受眾來說也非常容易理解。

Fluid 是一個基於 Liquid 模板語言的開源 .NET 模板引擎。由 Sébastien Ros 開發並發佈在 GitHub 上,NuGet 上的引用地址是: //www.nuget.org/packages/Fluid.Core 。

 

Liquid 模板語言

如果你對 Liquid 模板語言還不了解,可以先行查看筆者翻譯的 Liquid 模板語言中文文檔: //www.coderbusy.com/archives/1219.html 。Liquid 模板的文件擴展名為 .liquid ,假如我們有以下 Liquid 模板:

<ul id="products">
{% for product in products %}
<li>
<h2>{{product.name}}</h2>
Only {{product.price | price }}
 
{{product.description | prettyprint | paragraph }}
</li>
{% endfor %}
</ul>

 

該模板被渲染後將會產生以下輸出:

<ul id="products">
<li>
<h2>Apple</h2>
$329
 
Flat-out fun.
</li>
<li>
<h2>Orange</h2>
$25
 
Colorful.
</li>
<li>
<h2>Banana</h2>
$99
 
Peel it.
</li>
</ul>

 

在項目中使用 Fluid

你可以直接在項目中引用 NuGet 包

Hello World

C# 代碼:

var parser = new FluidParser();
 
var model = new { Firstname = "Bill", Lastname = "Gates" };
var source = "Hello {{ Firstname }} {{ Lastname }}";
 
if (parser.TryParse(source, out var template, out var error))
{
var context = new TemplateContext(model);
 
Console.WriteLine(template.Render(context));
}
else
{
Console.WriteLine($"Error: {error}");
}

 

運行結果:

Hello Bill Gates

 

線程安全

 FluidParser 類型是線程安全的,可以被整個應用程序共享。常規做法是將其定義為一個本地的靜態變量:

 private static readonly FluidParser _parser = new FluidParser();

IFluidTemplate 類型也是線程安全的,其實例可以被緩存起來,並被多個線程並發使用。

TemplateContext 不是線程安全的,每次使用時都應該新建一個實例。 

過濾器

過濾器 改變 Liquid 對象的輸出,通過一個 | 符號分隔。

{{ "/my/fancy/url" | append: ".html" }}
/my/fancy/url.html

多個過濾器可以共同作用於同一個輸出,並按照從左到右的順序執行。

{{ "adam!" | capitalize | prepend: "Hello " }}
Hello Adam!

Fluid 實現了 Liquid 所有的標準過濾器,同時支持自定義過濾器。

自定義的過濾器可以是同步的,也可以是異步的。過濾器被定義為一個委託,該委託接收一個輸入,一個參數集合和當前的渲染上下文。以下是一個實現文字轉小寫過濾器的代碼:

public static ValueTask<FluidValue> Downcase(FluidValue input, FilterArguments arguments, TemplateContext context)
{
return new StringValue(input.ToStringValue().ToLower());
}

過濾器需要註冊在 TemplateOptions 對象上,該 Options 對象可以被重用。 

var options = new TemplateOptions();
options.Filters.AddFilter('downcase', Downcase); 
var context = new TemplateContext(options);

 

成員屬性白名單

Liquid 是一種安全的模板語言,它只允許白名單中的成員屬性被訪問,並且成員屬性不能被改變。白名單成員需要被加入到 TemplateOptions.MemberAccessStrategy 中。 

另外,MemberAccessStrategy 可以被設置為 UnsafeMemberAccessStrategy ,這將允許模板語言訪問所有成員屬性。

將特定類型加入白名單

下面的代碼會將 Person 類型加入白名單,這意味着該類型下所有公開的字段和屬性都可以被模板讀取:

var options = new TemplateOptions();
options.MemberAccessStrategy.Register<Person>();

注意:當用 new TemplateContext(model) 傳遞一個模型時,模型對象會被自動加入白名單。該行為可以通過調用 new TemplateContext(model, false) 來禁用。 

將特定成員加入白名單

下面的代碼只允許模板讀取特定的成員:

var options = new TemplateOptions();
options.MemberAccessStrategy.Register<Person>("Firstname", "Lastname");

訪問攔截

Fluid 提供了一種可以在運行時攔截屬性訪問的方式,通過該方式你可以允許訪問成員並返回自定義值,或者阻止訪問。

下面的代碼演示了如何攔截對 JObject 的調用並返回相應的屬性:

 var options = new TemplateOptions();

options.MemberAccessStrategy.Register<JObject, object>((obj, name) => obj[name]);

繼承處理

當被註冊到白名單中的類型包含繼承關係時,情況將變得複雜:默認情況下被註冊類型的父類實例成員將不能被訪問,子類實例中的派生成員可以被訪問。

類型定義

public class Animal
{
public string Type { get; set; }
}
public class Human : Animal
{
public string Name { get; set; }
public Int32 Age { get; set; }
}
public class Boy : Human
{
public string Toys { get; set; }
}

測試代碼

var parser = new FluidParser();
 
var model = new { };
var source = @"
Animal=Type:{{Animal.Type}}
Human=Type:{{Human.Type}},Name:{{Human.Name}},Age:{{Human.Age}}
Boy=Type:{{Boy.Type}},Name:{{Boy.Name}},Age:{{Boy.Age}},Toys:{{Boy.Toys}}
";
 
var options = new Fluid.TemplateOptions { };
options.MemberAccessStrategy.Register(typeof(Human));
 
if (parser.TryParse(source, out var template, out var error))
{
var context = new TemplateContext(model, options);
context.SetValue("Animal", new Animal { Type = "Human" });
context.SetValue("Human", new Human { Type = "Human", Name = "碼農很忙", Age = 30 });
context.SetValue("Boy", new Boy { Type = "Human", Name = "小明", Age = 10, Toys = "小汽車" });
 
Console.WriteLine(template.Render(context));
}
else
{
Console.WriteLine($"Error: {error}");
}

輸出結果

Animal=Type:
Human=Type:Human,Name:碼農很忙,Age:30
Boy=Type:Human,Name:小明,Age:10,Toys:

成員名稱風格

默認情況下,註冊對象的屬性是區分大小寫的,並按照其源代碼中的內容進行註冊。例如,屬性 FirstName 將使用 {{ p.FirstName }} 標籤訪問。

 同時,也可以配置使用不同的名稱風格。比如小駝峰(firstName)或者蛇形(first_name)風格。

以下代碼可以配置為使用小駝峰風格:

var options = new TemplateOptions();
options.MemberAccessStrategy.MemberNameStrategy = MemberNameStrategies.CamelCase;

執行限制

限制模板遞歸

當調用 {% include ‘sub-template’ %} 語句時,有些模板可能會產生無限的遞歸,從而阻塞服務器。為了防止這種情況,TemplateOptions 類定義了一個默認的 MaxRecursion = 100 ,防止模板的深度超過100 。 

限制模板執行

模板可能會不經意地創建無限循環,這可能會使服務器無限期地運行而堵塞。為了防止這種情況,TemplateOptions 類定義了一個默認的 MaxSteps。默認情況下,這個值沒有被設置。 

轉換 CLR 類型

當一個對象在模板中被操作時,它會被轉換為一個特定的 FluidValue 實例。該機制與 JavaScript 中的動態類型系統有些類似。

在Liquid中,它們可以是數字、字符串、布爾值、數組或字典。Fluid會自動將CLR類型轉換為相應的Liquid類型,同時也提供專門的類型。

為了能夠定製這種轉換,你可以添加自定義的轉換器。

添加一個值轉換器

當轉換邏輯不能直接從一個對象的類型中推斷出來時,可以使用一個值轉換器。

值轉換器可以返回:

  • null 代表值不能被轉換。
  • 一個 FluidValue 實例,代表停止進一步的轉換,並使用這個值。
  • 其他對象實例,代表需要繼續使用自定義和內部類型映射進行轉換。

以下的代碼演示了如何將實現接口的任意實例轉換為自定義字符串值:

var options = new TemplateOptions();
options.ValueConverters.Add((value) => value is IUser user ? user.Name : null);

注意:類型映射的定義是全局的,對整個程序都生效。

在模型中使用 Json.NET 對象

Json.NET 中使用的類並不像類那樣有直接命名的屬性,這使得它們在 Liquid 模板中無法開箱使用。

為了彌補這一點,我們可以配置 Fluid,將名稱映射為 JObject 屬性,並將 JValue 對象轉換為 Fluid 所使用的對象。

var options = new TemplateOptions();
 
// When a property of a JObject value is accessed, try to look into its properties
options.MemberAccessStrategy.Register<JObject, object>((source, name) => source[name]);
 
// Convert JToken to FluidValue
options.ValueConverters.Add(x => x is JObject o ? new ObjectValue(o) : null);
options.ValueConverters.Add(x => x is JValue v ? v.Value : null);
 
var model = JObject.Parse("{\"Name\": \"Bill\"}");
 
var parser = new FluidParser();
 
parser.TryParse("His name is {{ Name }}", out var template);
var context = new TemplateContext(model, options);
 
Console.WriteLine(template.Render(context));

編碼

默認情況下,Fluid 不會對輸出進行編碼。在模板上調用 Render() 或 RenderAsync() 時可以指定編碼器。

HTML 編碼

可以使用 System.Text.Encodings.Web.HtmlEncoder.Default 實例來渲染 HTML 編碼的模板。 

該編碼被 MVC View engine 作為默認編碼使用。

在上下文中禁用編碼

當一個編碼器被定義後,你可以使用一個特殊的 raw 過濾器或 {% raw %} … {% endraw %} 標籤來阻止一個值被編碼。例如,如果你知道這個內容是 HTML 並且是安全的:

代碼

{% assign html = '<em>This is some html</em>' %}
 
Encoded: {{ html }}
Not encoded: {{ html | raw }

結果

&lt;em%gt;This is some html&lt;/em%gt;
<em>This is some html</em>

Capture 塊不會被二次編碼

當使用 capture 塊時,內部內容被標記為預編碼,如果在 {{ }} 標籤中使用,就不會被再次編碼。 

代碼

{% capture breaktag %}<br />{% endcapture %}
 
{{ breaktag }}

結果

<br />

本地化

默認情況下,模板使用不變的文化( Invariant culture ,對應 CultureInfo.InvariantCulture 。)進行渲染,這樣在不同的系統中可以得到一致的結果。這項設置在輸出日期、時間和數字時很重要。 

即便如此,也可以使用 TemplateContext.CultureInfo 屬性來定義渲染模板時使用的文化信息(你也可以稱之為多語言信息)。 

代碼

var options = new TemplateOptions();
options.CultureInfo = new CultureInfo("en-US");
var context = new TemplateContext(options);
var result = template.Render(context);

模板

{{ 1234.56 }}
{{ "now" | date: "%v" }}

結果

1234.56
Tuesday, August 1, 2017

時區

系統時區

TemplateOptions 和 TemplateContext 提供了一個定義默認時區的屬性,以便在解析日期和時間時使用。該屬性的默認值是當前系統的時區。當日期和時間被解析而沒有指定時區時,將會使用默認時區。設置一個自定義的時區可以防止在不同環境(數據中心)時產生不同的結果。

注意:date 過濾器符合 Ruby 的日期和時間格式: //ruby-doc.org/core-3.0.0/Time.html#method-i-strftime 。要使用 .NET 標準的日期格式,請使用 format_date 過濾器。 

代碼

var context = new TemplateContext { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time") } ;
var result = template.Render(context);

模板

{{ '1970-01-01 00:00:00' | date: '%c' }}

結果

Wed Dec 31 19:00:00 -08:00 1969

時區轉換

日期和時間可以使用 time_zone 標籤轉換為特定的時區,格式為:time_zone:<iana> 。 

代碼

var context = new TemplateContext();
context.SetValue("published", DateTime.UtcNow);

模板

{{ published | time_zone: 'America/New_York' | date: '%+' }}

結果

Tue Aug 1 17:04:36 -05:00 2017

自定義標籤和塊

Fluid 的語法可以被修改,以使其接受任何新的標記(tag)和帶有任何自定義參數的塊(block)。Fluid 使用了 Parlot 作為語法分析器,這使得 Fluid 完全可擴展。

與塊(block)不同,標記(tag)沒有結束元素(例如:循環,自增)。當把一個模板的某個部分作為一組語句來操作時,塊很有用。

Fluid 提供了用於註冊常見標籤和塊的幫助方法。所有的標籤和塊總是以他們的名稱作為標識符開始。

自定義標籤時需要提供一個委託(delegate),該委託會在標籤被匹配時執行。該委託可以使用使用以下三個屬性:

  1. writerTextWriter的實例,用於渲染文字。
  2. encodeTextEncoder的實例,例如 HtmlEncoder 或者 NullEncoder。由模板的調用者定義。
  3. contextTemplateContext 的實例。

註冊自定義標籤

自定義標籤可以分為三種類型:

  1. Empty:空白標籤,沒有任何參數,例如 {% renderbody %} 。
  2. Identifier:標識符。將標識符作為標籤參數,例如 {% increment my_variable %} 。
  3. Expression:表達式。以表達式作為參數,例如 {% layout ‘home’ | append: ‘.liquid’ %} 。

代碼

parser.RegisterIdentifierTag("hello", (identifier, writer, encoder, context) =>
{
writer.Write("Hello ");
writer.Write(identifier);
});

模板

{% hello you %}

結果

Hello you

註冊自定義塊

塊的創建方式與標記相同,可以在委託中訪問塊內的語句列表。

源碼

parser.RegisterExpressionBlock("repeat", (value, statements, writer, encoder, context) =>
{
for (var i = 0; i < value.ToNumber(); i++)
{
await return statements.RenderStatementsAsync(writer, encoder, context);
}
 
return Completion.Normal;
});

模板

{% repeat 1 | plus: 2 %}Hi! {% endrepeat %}

結果

Hi! Hi! Hi!

自定義模板解析

如果 identifier、 empty 和 expression 解析器不能滿足你的要求,RegisterParserBlock 和 RegisterParserTag 方法可以接受自定義的解析結構。這些結構可以是 FluidParser 中定義的標準解析器,例如 Primary 或者其他任意組合。

例如,RegisterParseTag(Primary.AndSkip(Comma).And(Primary), …) 將期望兩個 Primary 元素用逗號隔開。然後,該委託將被調用,使用 ValueTuple<Expression, Expression> 代表這兩個 Primary 表達式。

註冊自定義運算符

運算符是用來比較數值的,比如 > 或 contains 。如果需要提供特殊的比較,可以定義自定義運算符。

自定義 xor 運算符

下面的例子創建了一個自定義的 xor 運算符,如果左或右表達式被轉換為布爾時只有一個是真的,它將為真。

 

using Fluid.Ast;
using Fluid.Values;
using System.Threading.Tasks;
 
namespace Fluid.Tests.Extensibility
{
public class XorBinaryExpression : BinaryExpression
{
public XorBinaryExpression(Expression left, Expression right) : base(left, right)
{
}
 
public override async ValueTask<FluidValue> EvaluateAsync(TemplateContext context)
{
var leftValue = await Left.EvaluateAsync(context);
var rightValue = await Right.EvaluateAsync(context);
 
return BooleanValue.Create(leftValue.ToBooleanValue() ^ rightValue.ToBooleanValue());
}
}
}

配置解析器

parser.RegisteredOperators["xor"] = (a, b) => new XorBinaryExpression(a, b);

模板

{% if true xor false %}Hello{% endif %}

結果

Hello

空白控制

Liquid 在支持空白方面遵循嚴格的規則。默認情況下,所有的空格和新行都從模板中保留下來。Liquid 的語法和一些 Fluid 選項允許自定義這種行為。

通過連字符控制空白輸出

例如有以下模板:

{% assign name = "Bill" %}
{{ name }}

在 assign 標籤之後的換行將被保留下來。輸出如下:

Bill

標籤和值可以使用連字符來剝離空白。

{% assign name = "Bill" -%}
{{ name }}

這將輸出:

Bill

模板中的 -%} 將 assign 標籤右側的空白部分剝離。 

通過模板選項控制空白輸出

Fluid 提供了 TemplateOptions.Triming 屬性,可以用預定義的偏好來設置何時應該自動剝離空白,即使標籤和輸出值中不存在連字符。

貪婪模式

當 TemplateOptions.Greedy 中的貪婪模式被禁用時,只有第一個新行之前的空格被剝離。貪婪模式默認啟用,這是 Liquid 語言的標準行為。

自定義過濾器

Fliud 默認提供了一些非標準過濾器。

format_date

使用標準的 .NET 日期和時間格式來格式化日期和時間。它使用系統當前的多語言信息。

輸入

"now" | format_date: "G"

輸出

6/15/2009 1:45:30 PM

詳細的文檔可以看這裡: //docs.microsoft.com/zh-cn/dotnet/standard/base-types/standard-date-and-time-format-strings

format_number

使用 .NET 數字格式來格式化數字。

輸入

123 | format_number: "N"

輸出

123.00

詳細的文檔可以看這裡://docs.microsoft.com/zh-cn/dotnet/standard/base-types/standard-numeric-format-strings

format_string

格式化字符串

輸入

"hello {0} {1:C}" | format_string: "world" 123

輸出

hello world $123.00

詳細的文檔可以看這裡://docs.microsoft.com/zh-cn/dotnet/api/system.string.format?view=net-5.0

性能測試

緩存

如果你在渲染之前對解析過的模板進行緩存,你的應用程序可以獲得一些性能提升。解析是內存安全的,因為它不會引起任何編譯(意味着如果你決定解析大量的模板,所有的內存都可以被收集),你可以通過存儲和重用 FluidTemplate 實例來跳過解析步驟。

只要每次對 Render() 的調用使用一個獨立的 TemplateContext 實例,這些對象就是線程安全的。

基準測試

Fluid 項目的源代碼中提供了一個基準測試應用程序,用於比較 Fluid、Scriban、DotLiquid 和 Liquid.NET 。在本地運行該項目,分析執行特定模板所需的時間。

Results

Fluid 比所有其他知名的 .NET Liquid 模板分析器更快,分配的內存更少。對於解析,Fluid 比 Scriban快30%,分配的內存少 3 倍。對於渲染,Fluid 比 Scriban 快 3 倍,分配的內存少 5 倍。與 DotLiquid 相比,Fluid 的渲染速度快 10 倍,分配的內存少 40 倍。

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.201
[Host] : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT
ShortRun : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT
 
Job=ShortRun IterationCount=3 LaunchCount=1
WarmupCount=3
 
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------------- |--------------:|-------------:|------------:|-------:|--------:|----------:|---------:|--------:|------------:|
| Fluid_Parse | 7.056 us | 1.081 us | 0.0592 us | 1.00 | 0.00 | 0.6714 | - | - | 2.77 KB |
| Scriban_Parse | 9.209 us | 2.989 us | 0.1638 us | 1.31 | 0.03 | 1.8005 | - | - | 7.41 KB |
| DotLiquid_Parse | 38.978 us | 13.704 us | 0.7512 us | 5.52 | 0.14 | 2.6855 | - | - | 11.17 KB |
| LiquidNet_Parse | 73.198 us | 25.888 us | 1.4190 us | 10.37 | 0.29 | 15.1367 | 0.1221 | - | 62.08 KB |
| | | | | | | | | | |
| Fluid_ParseBig | 38.725 us | 11.771 us | 0.6452 us | 1.00 | 0.00 | 2.9907 | 0.1831 | - | 12.34 KB |
| Scriban_ParseBig | 49.139 us | 8.313 us | 0.4557 us | 1.27 | 0.02 | 7.8125 | 1.0986 | - | 32.05 KB |
| DotLiquid_ParseBig | 208.644 us | 45.839 us | 2.5126 us | 5.39 | 0.15 | 13.1836 | 0.2441 | - | 54.39 KB |
| LiquidNet_ParseBig | 24,211.719 us | 3,862.113 us | 211.6955 us | 625.30 | 8.32 | 6843.7500 | 375.0000 | - | 28557.49 KB |
| | | | | | | | | | |
| Fluid_Render | 414.462 us | 12.612 us | 0.6913 us | 1.00 | 0.00 | 22.9492 | 5.3711 | - | 95.75 KB |
| Scriban_Render | 1,141.302 us | 114.127 us | 6.2557 us | 2.75 | 0.02 | 99.6094 | 66.4063 | 66.4063 | 487.64 KB |
| DotLiquid_Render | 5,753.263 us | 7,420.054 us | 406.7182 us | 13.88 | 0.96 | 867.1875 | 125.0000 | 23.4375 | 3879.18 KB |
| LiquidNet_Render | 3,262.545 us | 1,245.387 us | 68.2639 us | 7.87 | 0.18 | 1000.0000 | 390.6250 | - | 5324.5 KB |

 

以上結果的測試時間是 2021年3月26 日,使用的組件詳情如下:

  • Scriban 3.6.0
  • DotLiquid 2.1.405
  • Liquid.NET 0.10.0

測試項目說明

Parse:解析一個包含過濾器和屬性的簡單 HTML 模板。
ParseBig:解析一個博客文章模板。
Render:使用 500 個產品渲染一個包含過濾器和屬性的簡單 HTML 模板。