动手造轮子 -- 给模板渲染添加 pipe 支持
Intro
在之前的介绍中 动手造轮子 - 实现简单的模板渲染,我们实现了一个简单的模板引擎,实现基本的模板渲染, 有时可能需要对值的转换,比如转大小写、格式化等 。
在 angular 应用中有一个 pipe 的概念,可以对模板表达式渲染的时候进行一些转换,可以使用 |
来实现更多的控制.
import { Component } from '@angular/core';
import { CurrencyPipe, DatePipe, TitleCasePipe } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [CurrencyPipe, DatePipe, TitleCasePipe],
template: `
<main>
<!-- Transform the company name to title-case and
transform the purchasedOn date to a locale-formatted string -->
<h1>Purchases from {{ company | titlecase }} on {{ purchasedOn | date }}</h1>
<!-- Transform the amount to a currency-formatted string -->
<p>Total: {{ amount | currency }}</p>
</main>
`,
})
export class ShoppingCartComponent {
amount = 123.45;
company = 'acme corporation';
purchasedOn = '2024-07-08';
}
我们也想为之前的模板引擎引入这一功能,于是我们尝试开始改动模板引擎以支持 template pipe
Samples
来看几个支持之后的示例:
var engine = TemplateEngine.CreateDefault();
var result = await engine.RenderAsync("Hello {{Name}}", new { Name = ".NET" });
Console.WriteLine(result);
Console.WriteLine(await engine.RenderAsync("Hello {{Name | toTitle }}", new { Name = "mike" }));
Console.WriteLine(await engine.RenderAsync("Today is {{ date | format:yyyy-MM-dd }}", new { date = DateTime.Today }));
输出结果如下:
自定义 Pipe 示例:
file sealed class SubstringTemplatePipe : TemplatePipeBase
{
protected override int? ParameterCount => null;
public override string Name => "substr";
protected override string? ConvertInternal(object? value, params ReadOnlySpan<string> args)
{
if (args.Length is not 1 or 2)
{
throw new InvalidOperationException("Arguments count must be 1 or 2");
}
var str = value as string ?? value?.ToString() ?? string.Empty;
var start = int.Parse(args[0]);
if (args.Length is 1)
{
return str[start..];
}
var len = int.Parse(args[1]);
return str[start..len];
}
}
[Fact]
public async Task CustomPipeRenderTest()
{
var name = "mike";
var text = "Hello {{ Name | substr:2 | toTitle }}";
var renderedText = await _templateEngine.RenderAsync(text, new { Name = name });
Assert.Equal($"Hello {name[2..].ToTitleCase()}", renderedText);
}
Implement
首先我们需要一个 template pipe 抽象,定义了一个如下的接口:
public interface ITemplatePipe
{
string Name { get; }
object? Convert(object? value, params ReadOnlySpan<string> args);
}
原来我们直接从模板中提取到变量名称,再去做替换,要支持 template pipe 就不能只匹配名称了,需要加入更多的信息,于是改造 TemplateRenderContext
如下:
public sealed class TemplateRenderContext(string text, IReadOnlyCollection<TemplateInput> inputs)
: IProperties
{
public string Text { get; } = text;
public Dictionary<TemplateInput, object?> Inputs { get; } =
inputs.ToDictionary(x => x, _ => (object?)null);
public string RenderedText { get; set; } = text;
public IDictionary<string, object?> Properties { get; } = new Dictionary<string, object?>();
}
[DebuggerDisplay("{Input,nq}")]
public sealed class TemplateInput : IEquatable<TemplateInput>
{
public required string Input { get; init; }
public required string? Prefix { get; init; }
public required string VariableName { get; init; }
public required TemplatePipeInput[] Pipes { get; init; }
public bool Equals(TemplateInput? other) => other is not null && other.Input == Input;
public override bool Equals(object? obj) => obj is TemplateInput input && Equals(input);
public override int GetHashCode() => Input.GetHashCode();
}
[DebuggerDisplay("{PipeName,nq}")]
public sealed class TemplatePipeInput
{
public required string PipeName { get; init; }
public required string[] Arguments { get; init; }
}
既然需要更多信息,解析模板的时候也就需要做一些变化来提取这些更多的信息,更新之后如下:
internal sealed class DefaultTemplateParser : ITemplateParser
{
private const string VariableGroupRegexExp = @"\{\{(?<Variable>[\w\$\s:\.]+)(?<Pipe>|[^\{\}]*)\}\}";
private static readonly Regex VariableRegex = new(VariableGroupRegexExp, RegexOptions.Compiled);
public Task<TemplateRenderContext> ParseAsync(string text)
{
List<TemplateInput> inputs = [];
var match = VariableRegex.Match(text);
while (match.Success)
{
var pipes = Array.Empty<TemplatePipeInput>();
var variableInput = match.Groups["Variable"].Value;
var variableName = variableInput.Trim();
string? prefix = null;
var prefixIndex = variableName.IndexOf('$'); // prefix start
if (prefixIndex >= 0)
{
var nameIndex = variableName.IndexOf(' ', prefixIndex); // name start
prefix = variableName[..nameIndex].Trim();
variableName = variableName[nameIndex..].Trim();
}
var pipeValue = match.Groups["Pipe"]?.Value.Trim();
if (!string.IsNullOrEmpty(pipeValue))
{
var pipeIndex = pipeValue.IndexOf('|');
if (pipeIndex < 0)
{
match = match.NextMatch();
continue;
}
// exact pipes
pipeValue = pipeValue[pipeIndex..].Trim();
var pipeInputs = pipeValue!.Split(['|'], StringSplitOptions.RemoveEmptyEntries);
pipes = pipeInputs.Select(p =>
{
var pipeName = p.Trim();
var arguments = Array.Empty<string>();
var sep = pipeName.IndexOf(':');
if (sep >= 0)
{
if (sep + 1 < pipeName.Length)
{
var argumentsText = pipeName[(sep + 1)..].Trim();
arguments =
#if NET
argumentsText.Split([':'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
#else
argumentsText.Split([':'], StringSplitOptions.RemoveEmptyEntries)
.Select(x=> x.Trim()).ToArray();
#endif
}
pipeName = pipeName[..sep].Trim();
}
return new TemplatePipeInput() { PipeName = pipeName, Arguments = arguments };
}).ToArray();
}
var input = new TemplateInput
{
Input = match.Value,
Prefix = prefix,
VariableName = variableName,
Pipes = pipes
};
inputs.Add(input);
match = match.NextMatch();
}
var context = new TemplateRenderContext(text, inputs);
return Task.FromResult(context);
}
}
这样我们就解析了每个模板表达式的 input,variable name,pipe 等信息,接着就该考虑怎么去渲染模板了,
渲染中间件和之前大体差不多,但是因为结构的变化需要稍微调整下,如下:
internal sealed class EnvRenderMiddleware : IRenderMiddleware
{
private const string Prefix = "$env";
public Task InvokeAsync(TemplateRenderContext context, Func<TemplateRenderContext, Task> next)
{
foreach (var pair in context.Inputs
.Where(x => x.Key.Prefix is Prefix
&& x.Value is null)
)
{
var variable = pair.Key.VariableName;
context.Inputs[pair.Key] = Environment.GetEnvironmentVariable(variable);
}
return next(context);
}
}
默认的中间件逻辑也有一些调整,在其他的中间件执行之后,需要执行 pipe 来对值的转换,最后更新渲染后的结果
internal sealed class DefaultRenderMiddleware(Dictionary<string, ITemplatePipe> pipes) : IRenderMiddleware
{
public async Task InvokeAsync(TemplateRenderContext context, Func<TemplateRenderContext, Task> next)
{
await next(context);
foreach (var input in context.Inputs.Keys)
{
var value = context.Inputs[input];
if (input.Pipes is { Length: > 0 })
{
foreach (var pipeInput in input.Pipes)
{
if (pipes.TryGetValue(pipeInput.PipeName, out var pipe))
{
value = pipe.Convert(value, pipeInput.Arguments);
}
}
}
// replace input with value
context.RenderedText = context.RenderedText.Replace(input.Input, value as string ?? value?.ToString());
}
}
}
render 逻辑更新后如下:
internal sealed class DefaultTemplateRenderer(Func<TemplateRenderContext, Task> renderFunc)
: ITemplateRenderer
{
public async Task<string> RenderAsync(TemplateRenderContext context, object? globals)
{
if (context.Text.IsNullOrWhiteSpace() || context.Inputs.IsNullOrEmpty())
return context.Text;
var parameters = globals.ParseParamDictionary();
if (parameters is { Count: > 0 })
{
foreach (var input in context.Inputs.Keys.Where(x => x.Prefix is null))
{
if (parameters.TryGetValue(input.VariableName, out var value))
{
context.Inputs[input] = value;
}
}
}
await renderFunc.Invoke(context).ConfigureAwait(false);
return context.RenderedText;
}
}
从输入的 value 信息中获取到一些变量的值先进行初步配置以便于后面 pipe 的转换,这样我们大体上就可以支持 template pipe 了,更多细节可以参考源码实现
References
https://github.com/WeihanLi/WeihanLi.Common/pull/220 https://github.com/WeihanLi/WeihanLi.Common/blob/dev/samples/DotNetCoreSample/TemplatingSample.cs https://github.com/WeihanLi/WeihanLi.Common/blob/dev/test/WeihanLi.Common.Test/TemplateTest/TemplateParserTest.cs https://github.com/WeihanLi/WeihanLi.Common/blob/dev/test/WeihanLi.Common.Test/TemplateTest/TemplateRendererTest.cs https://angular.dev/guide/templates/pipes/ 动手造轮子 - 实现简单的模板渲染
点击下方卡片关注DotNet NB
一起交流学习
▲ 点击上方卡片关注DotNet NB,一起交流学习
请在公众号后台