RazorService

更新:2023/7/11 建立: 2023/2/20

(github) https://github.com/mirrortom/razorservice

参考项目

RazorEngineCore https://github.com/adoconnection/RazorEngineCore

参考项目RazorEngineCore,是一个.netcore版本的实现.基于新版本.NET 6.0,使用的基础库是:

  • Microsoft.AspNetCore.Razor.Language Razor语言处理,生成Razor C#代码
  • Microsoft.CodeAnalysis.CSharp Roslyn编译工具,编译c#代码

概述

razor是asp.net提供的一项功能,可以在html中写入c#程序,成为一个扩展名是cshtml或者razor的文件,在浏览时,服务端解析razor文件并发回最终的html文档.

在asp.net项目以外,例如控制台程序中,也可以使用这种功能,使用第3方实现的库.比较早出现的,功能也比较全面的库是 https://github.com/Antaris/RazorEngine ,在.net framework时代就已经有了.Asp.net的Razor基本功能,这个库都实现了.

Razor执行过程

通过学习RazorEngineCore项目,了解到Razor功能的实现过程,可以分为4个阶段:

  • Razor源码 : 一般是含有c#代码的html文本,也可以是其它格式的源码文件,只要其中的c#代码是合法的Razor语法.
  • C#源码 : Microsoft.AspNetCore.Razor.Language库的相关API将读取Razor源码,将其转化为C#代码,一个Razor源文件会处理成为一个C#类源文件.
  • Dll程序集 : Microsoft.CodeAnalysis.CSharp库的相关API将读取C#源码,编译成一个Dll文件,得到的是一个含有该C#类的程序集.
  • html文本 : 调用这个程序集的方法就得到了最终的html文本.
生成cs源码相关的类
  • RazorSourceDocument
  • RazorProjectEngine
  • RazorCodeDocument
  • RazorCSharpDocument
// Step1,将razor源代码包装为RazorSourceDocument对象.
// 使用RazorSourceDocument.Create(content,fileName)静态方法
// content是razor源代码,文件名字可以随机
RazorSourceDocument document = RazorSourceDocument.Create(content, Path.GetRandomFileName());

// Step2,建立RazorProjectEngine对象
RazorProjectEngine engine = RazorProjectEngine.Create
(
    RazorConfiguration configuration,
    RazorProjectFileSystem fileSystem,
    Action<RazorProjectEngineBuilder> configure
)

// Step3,调用Engine对象的Process()方法得到 RazorCodeDocument 对象
// 第一个参数是RazorSourceDocument对象
RazorCodeDocument codeDocument= engine.Process
(
    RazorSourceDocument,
    null,
    new List<RazorSourceDocument>(),
    new List<TagHelperDescriptor>()
)

// Step4,调用RazorCodeDocument的GetCSharpDocument()方法,得到cs源码对象
RazorCSharpDocument razorCSharpDocument = codeDocument.GetCSharpDocument();

// 最后,获取GeneratedCode属性得到cs源码文本
// razorCSharpDocument.GeneratedCode

到这里,Razor语言从源码到CS源码的处理部分完成.

编译cs源码相关的类
  • CSharpSyntaxTree
  • CSharpCompilation
// Step1,分析cs源码,得到语法树对象CSharpSyntaxTree
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(CsSource);

// Step2,建立编译对象CSharpCompilation
// assemblyName: 程序集名字,最后编译出来的程序集名字
// SyntaxTree: 被编译的cs语法树对象
// MetadataReference: 编译时需要引用的程序集
CSharpCompilation compilation = CSharpCompilation.Create
(
    string? assemblyName,
    IEnumerable<SyntaxTree>? syntaxTrees = null,
    IEnumerable<MetadataReference>? references = null,
    CSharpCompilationOptions? options = null
)

// 第3步,调用Emit()进行编译
// EmitResult对象包含编译结果信息.其中Success属性指示编译是否成功,Diagnostics属性包含编译过程中产生的信息
// 如果编译成功,那么内存流中就有程序集文件字节数组
MemoryStream memoryStream = new();
EmitResult emitResult = compilation.Emit(memoryStream);

执行

得到程序集后,用反射获取类型和方法执行.

var assembly = Assembly.Load(assemblyBytes);
var type = assembly.GetType("RazorService.Template");
var instance = Activator.CreateInstance(type) as TemplateBase;
instance.ExecuteAsync();

程序集里的类和方法是Template.ExecuteAsync() 这和生成的CS代码相关.

Razor生成的CS代码

Razor源代码生成为CS代码示例如下

// razor语法源代码
string razortxt="<div>@DateTime.Now.ToString()</div>";

// 生成的cs代码
namespace RazorService
{
    public class Template : TemplateBase
    {
        public async override global::System.Threading.Tasks.Task ExecuteAsync()
        {
            WriteLiteral("<div>");
            Write(DateTime.Now.ToString());
            WriteLiteral("</div>");
        }
    }
}

razor源码转化成了Template类,只有一个ExecuteAsync方法.但这个方法里还包含WriteLiteral()和Write()两个方法,WriteLiteral的参数就是源代码中的html文本,而Write的参数是源代码中的c#代码.

但这个类本身并没有实现具体功能,看到override就表明,需要写一个基类让Template类继承它,那么,Template类使用的其实是基类的方法,除了ExecuteAsync()外.这样,基类里面就要定义并且实现WriteLiteral()和Write().

基类实现

那么,RazorApi功能的实现做法就比较清楚了.一个razor文件生成一个c#类:Template,有一个方法ExecuteAsync(),执行这个方法就得到输出文本.

这个方法是override的,那么Template需要继承一个类.示例中的TemplateBase类还有类所在的命名空间RazorService,是作为参数传入的,可自定义.

模板基类访问限制要public,方法也需要public,否则继承类在使用基类方法时就会报错,即使命名空间相同.

// TemplateBase基类,抽象类,用于被Template继承
public abstract class TemplateBase
{
    // 输出内容缓存容器
    public readonly StringBuilder buffer = new();

    // 具体由Template类实现
    public abstract Task ExecuteAsync();

    public void WriteLiteral(string literal)
    {
        buffer.Append(literal);
    }

    public void Write(object obj)
    {
        buffer.Append(obj);
    }
    ...
}

Razor功能

Asp.NET里的Razor提供的主要功能,是编译使用Razor语法编写的文本文件,生成html文档.

Razor语法支持的指令并不完全一样,这和使用Razor语法的场景有关.早期的Asp.Net MVC和现在的ASP.NET Core Blazor支持的Razor指令大不相同.这个也就是说,可以根据需求自定义需要的功能.

使用最多的Razor功能,母版页@Layout="layout.cshtml",节@section{},引用页@include(),数据实体@model 如果要实现这些基本功能,那么基类中要实现对应的方法和属性.

// TemplateBase基类,抽象类用于被Template继承
public abstract class TemplateBase
{
    // 母版
    public string Layout { get; set; } = string.Empty;
    // 主页
    public string Body { get; set; } = string.Empty;
    // 字典数据
    public dynamic ViewBag { get; set; } = new ExpandoObject();
    // 数据实体类 使用dynamic可以支持强类型和ExpandoObject动态类型
    public dynamic Model { get; set; } = null;
    // 节定义
    public void DefineSection(string key, Action action)
    // 节载入
    public string RenderSection(string key, bool isRequire = false)
    // 页引用
    public string Include(string path)
    ...
}

目前RazorServicer项目实现了这几个基本功能,用于基本的html文本生成和其它使用razor语法的文本生成.

RazorService程序

类设计
  • RazorServe 主要的运行处理方法,Run() , RunTxt()
  • RazorCfg 配置类,所有配置从这里设定和执行
  • RazorCompile 源文件解析和编译
  • RazorCache 缓存服务
  • TemplateBase Template基类
  • Helper 给TemplateBase扩展辅助方法,比如@Html.Raw()

运行过程

1. 读取Razor源码

从调用RazorServe.Run(path)开始,读取Razor源文件.重载RunTxt(content),读取Razor文本.

对于文件路径参数,默认cshtml扩展名,可以只写文件名字,将从程序运行目录和指定的razor目录搜索文件.也可以是全路径名字,没找到文件将报错.

// 设置cshtml文件搜索目录
RazorCfg.AddSearchDirs("razorFiles");
// 开启定时器,这个方法需要调用一次,会建立缓存目录
RazorCfg.StartTimer();
// razor文件路径
string main = "main";
// 实体类
PersonEntity model = new()
{
    Id = 1,
    Name = "mirror",
    Description = "software worker",
    Money = 10
};
// 实体类2
dynamic model2=new ExpandoObject();
model2.Id = 1001;
model2.Name = "anne";
model2.Description = "data engineer";
model2.Money = 50;
// 结果
string html = RazorServe.Run(main, model);
2. 生成cs源码

将从内存缓存查找,再从文件缓存目录查找,否则生成新的cs源码.首次生成的cs源码会加入缓存.

3. 生成dll

同上,从缓存查找,否则编译新的dll程序集.编译是最费时间的步骤,所以要建立缓存.

4. 执行得到结果

使用反射取得类和方法执行,得到文本结果.如果模板是单一的,没有使用layout和include或者section,那么只需要执行一次就得到结果了.这是最简单的情况,一个razor页生成一个Template类.

如果主模板使用了layout/Include和section,那么,layout和include引用的页面就会再次生成,这些模板其实也是一个独立的razor文件,所以会生成对应的Template类.最后,主模板引用到的所有其它页都生成后,将结果拼装到最终的输出页面,就是layout页面.

所谓的layout/include的实现,其实就是多个Template类的执行结果的组合而已.

更多方法

@Html.Raw(),这个在Asp.Net MVC中经常用到,用于在页面上原样输出html标记.

使用了简单的实现办法,添加一个类HtmlExt,包含Raw()方法,然后在模板基类里加入这个类的引用,这样就实现了.

public abstract class TemplateBase
{
    public readonly HtmlExt Html = new();
}

public class HtmlExt
{
    public string Raw(string htmlTxt)
    {
        return HttpUtility.HtmlEncode(htmlTxt);
    }
}

主要配置

// 设置razor文件的查询目录. 默认程序运行目录
RazorCfg.AddSearchDirs("razorFilesDir1","razorFilesDir2"...);

// 设置缓存文件目录. 默认运行目录下的RazorTemplateCache目录
RazorCfg.CacheDir = "RazorTemplateCache";

// 内存缓存过期分钟数. 默认120分钟
RazorCfg.DeadMinutes= 120;

// 文件缓存过期天数. 默认10天,超时不访问将删除
RazorCfg.DeadDays = 10;

// 清理文件缓存的定时器.开启/关闭.默认没开启,需要调用
RazorCfg.StartTimer();
RazorCfg.StopTimer();

排错更新

1.标记属性方法

发现症状: 如果razor文档中,有包含空属性值的标记,那么编译出的razor源码,不会将这情况视为普通HTML标记直接输出,而是编译成如下代码.

// 例如html标记有一个属性class,但值是空字符串""
<br class="" />
// 编译如下
BeginWriteAttribute("class", " class=\"", 132, "\"", 137, 0);
EndWriteAttribute();

这个问题折腾了2天,还发了issue,最后发现编译成这样没有问题.这两个方法也是razor工具集里的正常方法.https://learn.microsoft.com/zh-cn/dotnet/api/microsoft.aspnetcore.mvc.razor.razorpagebase.beginwriteattribute?view=aspnetcore-7.0

那么,和WriteLiteral()还有Write()方法一样,这两个方法也是razor的TemplateBase基类需要提供的方法.这个问题的原因就在于没有实现这2个方法,而以前也没有测试过razor对空属性值的这种编译规则,所以想当然的以为是语法编译错了^_^.

属性标记的相关方法:
// 开始写出属性
public virtual void BeginWriteAttribute (string name, string prefix, int prefixOffset, string suffix, int suffixOffset, int attributeValuesCount);

// 结束写入属性
public virtual void EndWriteAttribute ();

// 写出属性值
public void WriteAttributeValue (string prefix, int prefixOffset, object? value, int valueOffset, int valueLength, bool isLiteral);

根本原因还是对razor编译法则的基础了解不够,只是生搬硬套了参考项目的代码.相信这种情况只是一个开始,随着使用的增多,应该还会有类似的没被测试到的编译规则.

2.匿名模型类型

可以使用匿名类做数据模型传给页面.实现上是通过转换匿名类为动态类.但转换的动态类还是有问题,对于简单的匿名类有效,复杂的依然不行.但RazorEngine项目就可以使用匿名类型,多复杂的都能,可能因为它的转换方法更好.

// 这种简单属性值类型的可以
var model=new {name="mirror",money=200}
// 复杂的不行了,比如属性值是列表或者字典或者其它引用类型
var model= new {value1=List,value2=Dict,}