C# 模板引擎 Scriban 入门:从零开始构建你的动态文本生成器
一、 为什么我们需要模板引擎?
作为开发者,我们经常会遇到这样的场景:需要生成一封欢迎邮件,内容大部分固定,但用户名和激活链接是动态的;或者需要渲染一个产品列表页面,页面的骨架不变,但产品信息需要从数据库中动态填充。
面对这种需求,最原始的办法莫过于字符串拼接。
string html = "<h1>欢迎, " + user.Name + "!</h1><p>您的订单号是: " + order.Id + "</p>";
这种方式在简单场景下尚可应付,一旦逻辑变得复杂,代码就会迅速变成一场难以维护的“意大利面条式”灾难。逻辑与表现形式紧紧耦合,设计师无法修改样式,程序员也难以理清业务逻辑。
为了解决这个问题,模板引擎 (Template Engine) 应运而生。它的核心思想极其简单却又有巨大威力:将“表现(Presentation)”与“逻辑(Logic)”完全分离。
比如说这样,我们准备一份带有“占位符”的模板(例如一个 HTML 文件),再准备一份纯粹的数据(比如一个 C# 对象)。模板引擎的工作,就像一个自动化的“装配工”,它读取模板,然后把数据精确地填充到对应的占位符里,最终生成一份完整的文本。
纵观 Web 开发的历史,模板技术也经历了从早期的 PHP/ASP(代码与 HTML 混杂),到后来严格的 MVC 框架(如 ASP.NET MVC 中的 Razor)的演进。每一步,都是在追求更彻底的“关注点分离”。
二、 主角登场:认识轻量级强大的 Scriban
.NET 虽然有 Razor 这样强大的视图引擎,但它与 ASP.NET Core 框架深度绑定,并不适合所有场景(比如生成邮件、配置文件或在控制台应用中使用)。这时候,我们需要一个更轻量、通用、独立的模板引擎。
Scriban 正是这方面的佼佼者。
Scriban 是什么?
Scriban 是一个为 .NET 设计的高性能、功能强大的文本模板引擎和脚本语言。它的语法与 Shopify 开创的 Liquid 模板语言高度兼容。
Scriban 的特点
- 轻量且快速:它没有外部依赖,并且经过高度优化,内存占用低,执行速度快。
- 安全:默认运行在沙箱环境中,模板无法访问不被允许的 .NET 类型,非常适合让非开发者编辑。
- 功能强大:它不仅仅是个“文本填充”工具。Scriban 支持变量、循环、条件判断、函数定义、三元表达式等,几乎是一门完备的轻量级脚本语言。
- 高度可配置:它可以被配置成严格遵循 C# 的命名规范(大小写敏感、PascalCase),对 .NET 开发者极其友好。
三、 实战演练:封装一个通用的 ScribanRenderer
理论说再多,不如动手写代码。为了在项目中优雅地使用 Scriban,我们先来封装一个可复用的帮助类 ScribanRenderer。这个类会处理所有与 Scriban 交互的底层细节,让我们在业务代码中可以一行调用,轻松完成渲染。
我们的目标是:
- 在创建渲染器时,指定主模板文件的路径。
- 渲染器能自动处理子模板的加载(
include功能)。 - 渲染器默认使用 C# 的命名风格,无需我们在模板里写小写
snake_case。 - 提供一个异步的
RenderAsync方法,接收一个匿名对象作为数据源。
下面是完整的封装代码,我们来一步步讲解它的构成。
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
namespace ConsoleApplication
{
/// <summary>
/// 一个封装了 Scriban 渲染逻辑的帮助类。
/// </summary>
public class ScribanRenderer
{
private readonly Template _template;
private readonly ITemplateLoader _templateLoader;
/// <summary>
/// 构造函数,接受主模板的完整路径。
/// </summary>
/// <param name="mainTemplatePath">主模板文件的完整路径。</param>
public ScribanRenderer(string mainTemplatePath)
{
if (string.IsNullOrWhiteSpace(mainTemplatePath) || !File.Exists(mainTemplatePath))
{
throw new FileNotFoundException($"The main template file \"{mainTemplatePath}\" was not found.", mainTemplatePath);
}
// 从主模板路径中提取其所在的目录,作为子模板的根目录
var templateRootDirectory = Path.GetDirectoryName(mainTemplatePath);
_templateLoader = new SimpleFileLoader(templateRootDirectory);
// 异步读取文件内容的封装
var mainTemplateContent = ReadFileAsync(mainTemplatePath).GetAwaiter().GetResult();
// 解析主模板,后续可复用
_template = Template.Parse(mainTemplateContent);
if (_template.HasErrors)
{
// 在构造时就抛出异常,实现快速失败
throw new InvalidOperationException(FormatErrors(_template.Messages));
}
}
/// <summary>
/// 异步渲染模板。
/// </summary>
/// <param name="model">一个包含根数据的匿名对象。</param>
/// <returns>渲染后的文本。</returns>
public ValueTask<string> RenderAsync(object model)
{
var context = new TemplateContext
{
// 关键配置: 支持 include 子模板
TemplateLoader = _templateLoader,
// 关键配置: 支持 C# 命名风格 (PascalCase)
MemberRenamer = member => member.Name
};
var scriptObject = new ScriptObject();
if (model != null)
{
scriptObject.Import(model);
}
context.PushGlobal(scriptObject);
// 使用 Task.Run 来将同步的 Render 方法包装成异步的
return _template.RenderAsync(context);
}
// 辅助方法: 异步读取文件,兼容 .NET Framework 4.8
private Task<string> ReadFileAsync(string path)
{
return Task.Run(() => File.ReadAllText(path));
}
// 辅助方法: 格式化错误信息
private string FormatErrors(IEnumerable<LogMessage> messages)
{
return "Template parsing failed:\n" + string.Join("\n", messages);
}
}
/// <summary>
/// 用于加载子模板的简单文件加载器。
/// </summary>
public class SimpleFileLoader : ITemplateLoader
{
private readonly string _rootPath;
public SimpleFileLoader(string rootPath)
{
_rootPath = rootPath ?? string.Empty;
}
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
{
return Path.Combine(_rootPath, templateName);
}
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
if (!File.Exists(templatePath))
{
// 当找不到子模板时,抛出一个更明确的异常
throw new FileNotFoundException($"Sub template file not found: {templatePath}", templatePath);
}
return File.ReadAllText(templatePath);
}
public async ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
if (!File.Exists(templatePath))
{
throw new FileNotFoundException($"Sub template file not found: {templatePath}", templatePath);
}
// 使用 Task.Run 包装同步方法以实现异步接口
return await Task.Run(() => File.ReadAllText(templatePath)).ConfigureAwait(false);
}
}
}
讲解
-
SimpleFileLoader类: 这是实现include功能的关键。Scriban 需要一个“文件加载器”(ITemplateLoader)来告诉它去哪里寻找子模板。我们实现的SimpleFileLoader非常简单,它会在主模板所在的目录下查找其他模板文件。 -
ScribanRenderer构造函数:- 它接收主模板的完整路径。
- 创建了我们的
SimpleFileLoader,为include做好准备。 - 提前解析主模板:
Template.Parse()是一个相对耗时的操作,我们在构造函数里只做一次,然后将解析好的Template对象缓存起来,这样后续的RenderAsync调用就会非常快。 - 快速失败:如果在解析时就发现模板有语法错误,它会立刻抛出异常,避免在运行时才发现问题。
-
RenderAsync方法:- 这里创建了一个
TemplateContext,这是 Scriban 渲染时的“上下文环境”。 TemplateLoader = _templateLoader:将文件加载器配置进去。MemberRenamer = member => member.Name:这是让 Scriban 遵循 C# 命名规范的关键! 它的意思是说:“不要把我的UserName属性转换成user_name,就用UserName。”scriptObject.Import(model):使用Import方法,将传入的匿名对象的所有属性,都导入到 Scriban 的作用域中。_template.RenderAsync(context):最后,调用渲染方法,传入配置好的上下文,得到最终的文本。
- 这里创建了一个
四、 小试牛刀:渲染一个学生列表
现在我们已经有了强大的 ScribanRenderer,使用它就变成了一件轻松愉快的事情。让我们来完成一个简单的任务:打印一份班级学生名单。
1. 准备数据模型
首先,我们定义一个简单的 Student 类。
public class Student
{
public string Name { get; set; }
public int Age { get; set; }
public char Gender { get; set; }
public string ClassName { get; set; }
public Student(string name, int age, char gender, string className)
{
Name = name; Age = age; Gender = gender; ClassName = className;
}
}
2. 编写模板文件
编写两个模板文件,一个主模板和一个用于渲染单条学生信息的子模板。
templates/student_report.tpl (主模板)
# {{ report_title }}
**班级:** {{ class_name }}
**学生总数:** {{ students.size }}
---
{{ for student_item in students }}
{{# 引入子模板来渲染每个学生 #}}
{{ include 'student_details.tpl' item:student_item }}
{{ end }}
templates/student_details.tpl (子模板)
· **姓名**: {{ item.Name }}
· **年龄**: {{ item.Age }}
· **性别**: {{ item.Gender == 'm' ? '男' : '女' }}
3. 调用渲染器
最后,在我们的 Main 方法中,创建数据,实例化渲染器,然后调用 RenderAsync。
class Program
{
static async Task Main(string[] args)
{
try
{
// 1. 准备数据
var studentList = new List<Student>
{
new Student("Arno", 9, 'm', "三(1)班"),
new Student("Alice", 8, 'f', "三(1)班"),
new Student("David", 9, 'm', "三(1)班")
};
var model = new
{
students = studentList,
report_title = "学生信息报告",
class_name = "三(1)班"
};
// 2. 创建渲染器实例,传入主模板的完整路径
var renderer = new ScribanRenderer(Path.GetFullPath(@"templates\student_report.tpl"));
// 3. 调用渲染方法
var result = await renderer.RenderAsync(model);
// 4. 输出结果
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"程序发生错误: {ex.Message}");
}
}
}
运行结果:
# 学生信息报告
*班级:** 三(1)班
**学生总数:** 3
---
· **姓名**: Arno
· **年龄**: 9
· **性别**: 男
· **姓名**: Alice
· **年龄**: 8
· **性别**: 女
· **姓名**: David
· **年龄**: 9
· **性别**: 男
用几行c#代码,就将复杂的数据对象渲染成了一份格式清晰的报告。
五、 小结
通过本文,你应该了解了模板引擎的基本思想,认识了 Scriban 这款优秀的 .NET 库,并通过封装一个 ScribanRenderer 掌握了它的核心用法。
本文仅仅是 Scriban 功能的冰山一角。实际上它还支持自定义函数、复杂的表达式、管道操作、模板继承 (wrap) 等等强大的脚本化功能。如果你想深入挖掘它的全部潜力,强烈建议阅读它的官方文档。
- Scriban 官方 GitHub 文档: https://github.com/scriban/scriban
从此,告别字符串拼接,拥抱模板引擎吧!
知乎: @张赐荣
赐荣博客: www.prc.cx
浙公网安备 33010602011771号