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

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

source-gen.jpg

来自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的项目。

image.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

image.png

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

image.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绝对是宝藏级的开发工具。强烈推荐给大家!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,113评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,644评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,340评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,449评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,445评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,166评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,442评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,105评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,601评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,066评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,161评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,792评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,351评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,352评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,584评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,618评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,916评论 2 344

推荐阅读更多精彩内容