动手造轮子 -- 给模板渲染添加 pipe 支持

科技   2024-11-10 08:00   广东  

动手造轮子 -- 给模板渲染添加 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 }));

输出结果如下:

output

自定义 Pipe 示例:


file sealed class SubstringTemplatePipe : TemplatePipeBase
{
    protected override int? ParameterCount => null;
    public override string Name => "substr";
    protected override string? ConvertInternal(objectvalueparams 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(objectvalueparams 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 { getset; } = text;
    public IDictionary<stringobject?> Properties { get; } = new Dictionary<stringobject?>();
}

[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<stringRenderAsync(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/
  • 动手造轮子 - 实现简单的模板渲染


推荐阅读:
在 .NET 中编写更好的配置文件
.NET Conf China 2024(智能 创新 开放)-- 启动智能时代新引擎
基于.NET8 + Vue/UniApp前后端分离的快速开发框架,开箱即用!
Avalonia开源控件库强力推荐-Semi.Avalonia
.NET 9 预览:C#13 带来的新功能抢先看
在 .NET 中使用强类型 ID 处理实体标识的更好方法

点击下方卡片关注DotNet NB

一起交流学习

▲ 点击上方卡片关注DotNet NB,一起交流学习

请在公众号后台

回复 【路线图】获取.NET 2024开发者路线
回复 【原创内容】获取公众号原创内容
回复 【峰会视频】获取.NET Conf大会视频
回复 【个人简介】获取作者个人简介
回复 【年终总结】获取作者年终回顾
回复 加群加入DotNet NB 交流学习群

长按识别下方二维码,或点击阅读原文。和我一起,交流学习,分享心得。

DotNet NB
.NET 技术学习分享,社区热点分享,专注为 .NET 社区做贡献,愿我们互相交流学习,共同推动社区发展
 最新文章