使用Source Generators将SQL脚本生成C#实体类

时间:09/18/2024 19:31:25   作者:ChenReal    阅读:8

我们做业务系统的开发,很多时候往往离不开代码生成器。项目使用代码生成器的好处不言而喻,生成出来的代码标准规范,代码质量有保障,并且还能大幅提高开发效率,何乐不为呢?

来自go-zero得到的灵感

最近,我在朋友推荐下研究了go-zero,一个基于golang的快速开发框架。我翻阅了一下它的文档和demo代码,发现其的设计思想跟我用的C#开发框架,功能上基本大同小异。

其中有一项功我是比较有用:根据SQL脚本来生成数据表实体类。下面简单描述一下go-zero实现的过程:

  • 首先,定一个创建MySQL数据表的SQL脚本users.sql

    CREATE TABLE `users` (  
      `id` int PRIMARY KEY NOT NULL AUTO_INCREMENT COMMENT 'ID',  
      `name` varchar(32) NOT NULL COMMENT '用户名',  
      `password` varchar(32) NOT NULL COMMENT '密码',  
      `gender` int NOT NULL COMMENT '性别(0-未知;1-男;2-女)',  
      `age` int NOT NULL COMMENT '年龄'  
    )ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户资料';
    
  • 然后,使用go-zero的命令行工具goctl,执行以下命令:

    goctl model mysql ddl -src="users.sql" -dir="./model" -c
    
  • 最后,在所生成的usersmodel.go代码中,得到一个名为Users结构体。

Users struct {  
    Id         int       `db:"id"`  
    Name       string    `db:"name"`     // 用户名  
    Password   string    `db:"password"` // 密码  
    Gender     uint64    `db:"gender"`   // 性别(0-未知;1-男;2-女)  
    age        int       `db:"age"`      // 年龄  
}

用C#的Source Generators来实现

说完go-zero,回到我们熟悉的C#。想要实现这个功能,其实不困难,而且实现的方法可以有很多。如果让我选我会首选用Source Generators来实现,因为我个人认为是最最优雅的。

1、什么是Source Generators?

说起,SourceGenerator其实很多.NET开发者会感到既熟悉又陌生,总觉得有在哪里看到过或者听到过,但是自己开发的代码却很少用到过。

如果大家对 Source Generators 没有概念,请先阅读一下微软官方文档,我这里不做详细介绍的展开。

https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview

总结成一句话:它是编译器级别的源码生成器,在编译阶段生成代码,并即时参与编译。 这就决定了它与go-zero提供的传统代码生成工具,有本质上的区别。早在两年前,已经将开始逐步将Source Generators应用到我的项目开发中,感觉非常棒。

  • 支持动态代码生成:修改应用程序的上下文代码或者资源文件配置,Source Generators立刻就能帮你生成出相应的代码,没有半点滞后和延迟。不像供传统代码生成器,还需要另外执行脚本。
  • 代码模板维护非常简单:源代码生成器可以自动创建样板,确保一致性并减少手动工作。
  • 性能优化:通过在编译时生成代码的能力,开发人员可以引入针对应用程序需求量身定制的特定优化,通常会产生更高性能和更高效的代码。
  • 增强代码可维护性:代码编译时自动生成,从而大大减少源码的代码量,从而变得更容易维护管理。
  • 需要调试的手动代码更少,生成的代码遵循一致的模式,使其更易于理解和管理。
  • 一致性和标准化:当代码自动生成时,它遵循设定的模式或标准。这确保了团队成员始终在同一页面上,从而减少差异和冲突。

2、开始使用

下面请跟随我一起,用Source Generators来实现这个目标。

首先,创建一个.NET Standard 2.0的项目。

source-gen-1.png

接着,添加依赖Microsoft.CodeAnalysis.CSharpMicrosoft.CodeAnalysis.Analyzers。其中,需要将Microsoft.CodeAnalysis.CSharp的属性设置为PrivateAssets="all"

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>  
    <TargetFramework>netstandard2.0</TargetFramework>  
      <Version>1.0.0</Version>  
      <LangVersion>latest</LangVersion>
  </PropertyGroup>
    <ItemGroup>  
       <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />  
       <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />  
    </ItemGroup>  
</Project>

请注意:Microsoft.CodeAnalysis.CSharp的版本不要选太高的,4.8以上的会有额外的依赖对 运行Source Generator不太友好。

创建好项目,接着开始编写代码:

[Generator]  
public class SqlCodeGenerator : IIncrementalGenerator  
{  
    public void Initialize(IncrementalGeneratorInitializationContext context)  
    {
        // 仅读取.sql后缀的文件
        var provider = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith(".sql"));  
        context.RegisterSourceOutput(provider, Execute);  
    }  

    const string TABLE_PTN = @"CREATE TABLE `(\w+)`";  
    const string COLUMN_PTN = @"`(\w+)` (.*?) COMMENT '(.*?)'";  
    const string LAST_PTN = @"(.*?) COMMENT='(.*?)';";  

    private void Execute(SourceProductionContext context, AdditionalText file)  
    {  
        var sqlText = file.GetText().ToString();  
        var lines = sqlText.Split("\r\n".ToCharArray(), StringSplitOptions.RemoveEmptyEntries);  
        var tableName = "";  
        var tableComment = "";  
        var columns = new List<DataFieldInfo>();  
        // 正则表达式逐行解析SQL脚本,提取表和字段的关键信息
        foreach (var line in lines)  
        {  
            var text = line.Trim();  
            if (string.IsNullOrEmpty(tableName))  
            {  
                var m = Regex.Match(line, TABLE_PTN);  
                if (m.Success)  
                {  
                    tableName = m.Groups[1].Value;  
                    continue;  
                }  
            }  
            var match = Regex.Match(line, COLUMN_PTN);  
            if (match.Success)  
            {  
                var prop = new DataFieldInfo()  
                {  
                    FieldName = match.Groups[1].Value,  
                    Comment = match.Groups[3].Value  
                };  
                var others = match.Groups[2].Value.Split(" ".ToCharArray(), StringSplitOptions.RemoveEmptyEntries);  
                prop.DbType= others[0].ToUpper();  
                prop.CodeType = MapToCodeType(prop.DbType);  
                prop.NotNull = (others.Contains("NOT") && others.Contains("NULL"));  
                prop.IsPrimary = (others.Contains("PRIMARY") && others.Contains("KEY"));  
                prop.AutoIncrement = others.Contains("AUTO_INCREMENT");  
                prop.PropertyName = ConvertDBNameToPascalCase(prop.FieldName);  
                columns.Add(prop);  

                continue;  
            }  
            match = Regex.Match(line, LAST_PTN);  
            if (match.Success)  
            {  
                tableComment = match.Groups[2].Value;  
            }  
        }  

        var table = new DataTableInfo  
        {  
            TableName = tableName,  
            Comment = string.IsNullOrEmpty(tableComment) ? tableName : tableComment,  
            ClassName = ConvertDBNameToPascalCase(tableName)  
        };  

        // 调用模板输出代码
        RenderModelCode(context, table, columns);
    }  


    /// <summary>  
    /// 生成Model类模板代码  
    /// </summary>  
    /// <param name="context"></param>
    /// <param name="table"></param>
    /// <param name="columns"></param>
    private void RenderModelCode(SourceProductionContext context,DataTableInfo table,List<DataFieldInfo> columns)  
    {  
        const string nameSpace = "GeneratorApp.Models";  
        var sb = new StringBuilder(); 
        sb.AppendLine($"namespace {nameSpace};").AppendLine();  
        sb.AppendLine("/// <summary>")  
            .AppendLine($"/// Model for Table :{table.Comment}")  
            .AppendLine("/// <para>此代码由SourceGenerator生成</para>")  
            .AppendLine($"/// <para>生成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}</para>")  
            .AppendLine("/// </summary>");  
        sb.AppendLine($"public partial class {table.ClassName} {{").AppendLine();  

        foreach(var props in columns)  
        {  
            if (string.IsNullOrWhiteSpace(props.FieldName)|| string.IsNullOrWhiteSpace(props.CodeType)) continue;  
            var comment = props.Comment;  
            if (string.IsNullOrWhiteSpace(comment)) comment = "Field:" + props.FieldName;  
            sb.Append("\t").AppendLine("/// <summary>")  
                .Append("\t").AppendLine($"/// {comment}")  
                .Append("\t").AppendLine("/// </summary>");  
            sb.Append("\t").Append($"public {props.CodeType} {props.PropertyName} {{ get; set; }}");  
            sb.AppendLine();  
        }  

        sb.AppendLine();  
        sb.Append("\t").Append($"public {table.ClassName}() {{ }}").AppendLine().AppendLine();  
        sb.AppendLine("}");  

        context.AddSource($"{table.ClassName}_{nameSpace}.g.cs", sb.ToString());  
    }
}

3、项目调用

新建一个名为GeneratorApp的控制台项目,然后引用刚才的SqlGenerator项目。

<Project Sdk="Microsoft.NET.Sdk">  
  <PropertyGroup>  
    <OutputType>Exe</OutputType>  
    <TargetFramework>net8.0</TargetFramework>  
    <ImplicitUsings>enable</ImplicitUsings>  
    <Nullable>enable</Nullable>  
  </PropertyGroup> 
    <ItemGroup>  
       <ProjectReference Include="..\SqlGenerator\SqlGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />  
    </ItemGroup>  
</Project>

把刚才的users.sqlCopy到项目里,并且将其编译动作设为AdditionalFiles

source-gen-2.png

接下来启动编译项目,在GeneratorApp的依赖项的分析器中会出现一个名为Users_GeneratorApp.Models.g.cs的文件。

source-gen-3.png

双击打开可以看到生成的代码。并且会提示该文件是自动生成的,无法编辑。
可以看到,文件中的代码便是我们通过SQL脚本,生成的Model类。

namespace GeneratorApp.Models;  

/// <summary>  
/// Model for Table :用户资料  
/// <para>此由SourceGenerator生成</para>  
/// <para>生成时间:2024-09-16 12:49:01</para>  
/// </summary>  
public partial class Users {  

    /// <summary>  
    /// ID
    /// </summary>
    public int Id { get; set; }  
    /// <summary>  
    /// 用户名  
    /// </summary>  
    public string Name { get; set; }  
    /// <summary>  
    /// 密码  
    /// </summary>  
    public string Password { get; set; }  
    /// <summary>  
    /// 性别(0-未知;1-男;2-女)  
    /// </summary>  
    public int Gender { get; set; }  
    /// <summary>  
    /// 年龄  
    /// </summary>  
    public int Age { get; set; }  

    public Users() { }  
}

最后,打开Program.cs写几行代码来实现Users类的调用。接着,直接编译运行。

internal class Program  
{  
    static void Main(string[] args)  
    {   
        var user = new Models.Users()  
        {  
            Id = 1,   
            Age = 30,  
            Gender = 1,  
            Name = "Sam",  
            Password = "111"  
        };  

        Console.WriteLine(user.Name);
    }
}

总结

我从go-zero中发现了用SQL脚本来生成model代码的功能,感觉非常棒。于是,我使用C# Source Generators来实现了一个类似的功能。

对.NET开发者而言,Source Generators绝对是宝藏级的开发工具。强烈推荐给大家!

 

评论
0/200