张赐荣,视障者,信息无障碍专家
深耕Web/PC/移动端可访问性研究与实践工作多年,对跨平台无障碍解决方案拥有深刻的独特理论和丰富的实战经验。
精通视障用户软件交互设计,致力于用专业的能力改善、提升产品可及性体验。

張賜榮

张赐荣的技术博客

博客园 首页 新随笔 联系 订阅 管理

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 交互的底层细节,让我们在业务代码中可以一行调用,轻松完成渲染。

我们的目标是:

  1. 在创建渲染器时,指定主模板文件的路径。
  2. 渲染器能自动处理子模板的加载(include 功能)。
  3. 渲染器默认使用 C# 的命名风格,无需我们在模板里写小写 snake_case
  4. 提供一个异步的 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);
		}
	}
}

讲解

  1. SimpleFileLoader: 这是实现 include 功能的关键。Scriban 需要一个“文件加载器”(ITemplateLoader)来告诉它去哪里寻找子模板。我们实现的 SimpleFileLoader 非常简单,它会在主模板所在的目录下查找其他模板文件。

  2. ScribanRenderer 构造函数:

    • 它接收主模板的完整路径。
    • 创建了我们的 SimpleFileLoader,为 include 做好准备。
    • 提前解析主模板Template.Parse() 是一个相对耗时的操作,我们在构造函数里只做一次,然后将解析好的 Template 对象缓存起来,这样后续的 RenderAsync 调用就会非常快。
    • 快速失败:如果在解析时就发现模板有语法错误,它会立刻抛出异常,避免在运行时才发现问题。
  3. 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) 等等强大的脚本化功能。如果你想深入挖掘它的全部潜力,强烈建议阅读它的官方文档。

从此,告别字符串拼接,拥抱模板引擎吧!

posted on 2025-08-20 14:03  张赐荣  阅读(133)  评论(0)    收藏  举报

感谢您访问张赐荣的技术分享博客!
博客地址:https://cnblogs.com/netlog/
知乎主页:https://www.zhihu.com/people/tzujung-chang
个人网站:https://prc.cx/