强大的代码生成技术





5.00/5 (9投票s)
通过一些基础的代码模板技术,让您的项目功能更加强大
本文没有下载,但有多个工具链接在此。
引言
我注意到开发者们对代码生成似乎有一种爱恨交加的关系。每个人都有自己的看法,但希望我能在这里赢得一些批评者的支持,同时提供一些工具来简化这个过程。
进入预构建步骤
为什么不用 Visual Studio 集成?
我不再为大多数工具使用 Visual Studio 集成。原因在于它不如预构建步骤强大和灵活。它还花费更长的时间来设置和调试,并使您的开发环境变得特殊且难以在工作站之间复制(安装半打 vsix 并不好玩)。更糟糕的是,您无法轻松地将项目部署到 GitHub 等平台,并期望其他用户能够克隆仓库即可使用。最后这一点对于团队开发至关重要,甚至对您在两年后将项目下载到另一台机器上并忘记了安装了哪些工具时也很重要。它们也比简单的命令行工具更难构建,不适用于 VS Code(以及 JetBrains 的产品?),等等。您不能将它们放入 make
文件、批处理文件,等等。
为项目添加构建步骤
通过预构建步骤,您可以将工具与项目一起部署。我通常将它们放在根解决方案文件夹中,以便在预构建步骤中轻松进行路径设置,但这取决于您。这样,当有人克隆您的仓库时,他们就可以立即使用。
虽然这因项目类型而异,但通常可以在 Visual Studio 中通过转到项目属性页并选择构建事件来访问预构建步骤。在 Visual Basic 中,它在某个“高级”设置下。我不使用 Visual Studio 进行 C++ 开发,但在 VS Code 中,您可以编辑相关的 .json 文件,或者更好的是,添加一个 make
文件。
知道如何使用命令行。您是开发者。现在 CLI 应该足够让您感到舒适了。如果不是,您很快就会擅长它。幸运的是,设置此项是一次性完成的,因此您不必每次构建时都去处理它。您的 IDE/开发环境会为您完成。
设置构建步骤时,请记住为可能包含空格的文件名或参数加上引号。您可以用两个引号来转义引号。
Visual Studio 提供了有用的宏,可用于定位二进制文件、解决方案文件夹、项目文件夹等。在创建构建步骤时请使用这些宏,以确保其健壮性。在 Visual Studio 中,只需用换行符分隔多个命令行。
以下是我为即将更新的 Reggie 所做的实际应用构建中的预构建事件示例。
"$(SolutionDir)csppg.exe" "$(ProjectDir)Export\Common.cs.template" /output
"$(ProjectDir)Export.CommonGenerator.cs" /class CommonGenerator /namespace Reggie /internal
"$(SolutionDir)csppg.exe" "$(ProjectDir)Export\TableMatcher.cs.template" /output
"$(ProjectDir)Export.TableMatcherGenerator.cs" /class TableMatcherGenerator
/namespace Reggie /internal
"$(SolutionDir)csppg.exe" "$(ProjectDir)Export\TableTokenizer.cs.template" /output
"$(ProjectDir)Export.TableTokenizerGenerator.cs" /class TableTokenizerGenerator
/namespace Reggie /internal
"$(SolutionDir)csbrick.exe" "$(SolutionDir)LexContext\LexContext.csproj" /output
"$(ProjectDir)LexContext.brick.cs"
"$(SolutionDir)csbrick.exe" "$(SolutionDir)FastFA\FastFA.csproj"
/output "$(ProjectDir)FastFA.brick.cs"
我所有的工具都使用相同的基本语法,以便于协同工作。稍后在介绍快速制作自己的工具时,我会提供一些样板代码。
这些命令将在每次构建项目时执行。如果您不希望它们每次都运行,例如,如果您的过程需要很长时间才能执行,您可以向可执行文件添加 /ifstale
开关,该开关会跳过该过程,除非输入文件比输出文件新。
给猫咪“梳毛”
在 .NET 领域,有几种不同的代码生成方法。您可以使用 CodeDOM,或者使用某种文本模板系统,具体取决于您想如何进行。
CodeDOM 方法
CodeDOM 的优点
- 生成的代码与语言无关。您创建一个抽象语法树,然后使用 CodeDOM 提供程序将其呈现为目标语言。Microsoft 为 C# 和 Visual Basic 提供了提供程序,但第三方也可以提供自己的。通常,如果您可以在 ASP.NET 页面中用于服务器端代码,则可以使用 CodeDOM 进行呈现。当然,这些仅限于 .NET 语言。例如,您不能使用此方法呈现 SQL/DDL。
- 由于代码以内存树的形式表示,因此您可以对其进行“宏化”和分析。这可以实现强大的代码转换。**
CodeDOM 的缺点
- 实际上,“语言无关”是相对的,通常需要在树上进行一些“调整”,然后才能与给定的目标语言一起工作。例如,为了让 Visual Basic 代码提供程序在对象也继承自接口时正确呈现它们,您可能需要显式地从
System.Object
派生类。 - 您仅限于拥有 CodeDOM 提供程序的 .NET 语言,并且并非所有提供程序都一样。例如,F# 的提供程序可能无法与大多数代码生成器一起使用,因为它们不支持或支持得不好。
- 代码树非常稀疏。没有什么花哨的,比如没有
yield
关键字。甚至一些常用运算符也缺失。** - 树的语法非常冗长,名称如
CodePrimitiveExpression
和CodeIterationStatement
,并且涉及大量对象嵌套,因此在代码中构建它们需要大量的代码。** - 没有解析器。** 您必须在代码中显式构造对象。
** Deslang 是一个强大的工具,它采用 Slang - C# 的一个与 CodeDOM 兼容的子集,它可以解析为 CodeDOM 树并在代码中创建图,您可以将其包含在代码生成项目中。使用 Deslang,您可以编写 C# 子集语言的代码,并将其呈现为 Visual Basic,例如。您还可以执行复杂的操作,例如按顺序访问 CodeDOM 树的每个元素 以进行分析和转换。此外,您还可以对内存中的代码模型执行反射,包括类型和方法解析,以及常量表达式求值(折叠)。使用 Deslang 作为预构建步骤,您可以非常轻松地以语言无关的方式生成非常复杂If you choose the CodeDOM route, I highly recommend using Deslang. It is orders of magnitude more powerful and efficient than using the CodeDOM by hand.
文本模板方法
文本模板方法的优点
- 您可以生成所需的任何基于文本的输出,无论是代码还是其他内容,并且可以利用您喜欢的语言的最新功能。
- 您可以通过使用多个模板生成到多种目标语言,这为您提供了很大的灵活性。
- 它比 CodeDOM 更易于理解。
- 它通常比使用 CodeDOM 方法生成得更快,特别是与使用 Slang 和 Deslang 并对复杂代码或大量代码进行分析相比。
文本模板方法的缺点
- 您必须为要呈现的每个目标编写一个模板。
- 您无法对生成的代码进行分析。必须在代码生成之前进行分析,这会严重限制功能。
- 您通常会在模板中丢失 IntelliSense。
- 可能很难正确格式化您的输出。
使用 csppg,您可以使用类似 ASP 或 T4 的语法创建基于文本的模板。模板本身被生成为 C# 文件。然后,您可以将 C# 文件包含在代码生成项目中,以便在需要时随时获取输出。
两者结合
您可以使用文本模板呈现为 Slang,然后使用 Deslang 将其解析为代码以构建代码表示的 CodeDOM 树。然后,可以在呈现为任何所需语言之前对该树进行分析和反射。唯一的真正缺点是它比其他方法更复杂,并且包含使用 CodeDOM 的一些缺点。
我的偏好,基于经验
我编写了大量的代码生成工具。您可能会说我是一个代码生成爱好者。我父亲是一名工具制造商,所以从某种意义上说,我认为我继承了他的一些驱动力。随之而来的是一些来之不易的经验。
我过去曾因其优点而使用 CodeDOM 方法,但现在我更倾向于简化。我对针对 C++ 甚至 SQL/DDL 等目标更感兴趣。
我仍然会转向 CodeDOM 模型,特别是如果我想在运行时使用 Slang,就像 Parsley 所做的那样,但现在我倾向于主要避免它,因为它增加了复杂性,即使在使用 Deslang 等工具时也是如此。
制作工具
很多时候,您会找不到完全满足您需求的手册。发生这种情况时,您需要自己制作一个,而上述工具可以提供帮助。在选择好生成器的方向(CodeDOM、文本模板或两者兼而有之)后,您需要整理一个文件来处理命令行参数
using System;
using System.IO;
using System.Reflection;
namespace Tool
{
static class Program
{
static readonly string CodeBase = _GetCodeBase();
static readonly string Filename = Path.GetFileName(CodeBase);
static readonly string Name = _GetName();
static readonly Version Version = _GetVersion();
static readonly string Description = _GetDescription();
static int Main(string[] args) => Run(args, Console.In, Console.Out, Console.Error);
/// <summary>
/// Runs the process
/// </summary>
/// <param name="args">The arguments</param>
/// <param name="stdin">The input stream</param>
/// <param name="stdout">The output stream</param>
/// <param name="stderr">The error stream</param>
/// <returns>The error code</returns>
public static int Run(string[] args, TextReader stdin,
TextWriter stdout, TextWriter stderr)
{
string inputfile = null;
string outputfile = null;
bool ifstale = false;
// our working variables
var result = 0;
TextReader input = null;
TextWriter output = null;
try
{
if (0 == args.Length)
{
result = -1;
_PrintUsage(stderr);
}
else if (args[0].StartsWith("/"))
{
throw new ArgumentException("Missing input file.");
}
else
{
// process the command line args
inputfile = args[0];
for (var i = 1; i < args.Length; ++i)
{
switch (args[i].ToLowerInvariant())
{
case "/output":
if (args.Length - 1 == i) // check if we're at the end
throw new ArgumentException(string.Format
("The parameter \"{0}\" is missing an argument",
args[i].Substring(1)));
++i; // advance
outputfile = args[i];
break;
case "/ifstale":
ifstale = true;
break;
default:
throw new ArgumentException(string.Format
("Unknown switch {0}", args[i]));
}
}
if (string.IsNullOrWhiteSpace(inputfile))
throw new ArgumentException("inputfile");
var cwd = Environment.CurrentDirectory;
if (!ifstale || _IsStale(inputfile, outputfile))
{
if (null != outputfile)
{
stderr.WriteLine("{0} is building file: {1}", Name, outputfile);
cwd = Path.GetDirectoryName(outputfile);
output = new StreamWriter(outputfile);
}
else
{
stderr.WriteLine("{0} is building preprocessor.", Name);
output = stdout;
}
input = new StreamReader(inputfile);
//
// TODO: Do work here
//
}
else
{
stderr.WriteLine("{0} skipped building of {1}
because it was not stale.", Name, outputfile);
}
}
}
// we don't like to catch in debug mode
#if !DEBUG
catch (Exception ex)
{
result = _ReportError(ex, stderr);
}
#endif
finally
{
// close the input file if necessary
if (null != input)
input.Close();
// close the output file if necessary
if (null != outputfile && null != output)
output.Close();
}
return result;
}
static void _PrintUsage(TextWriter w)
{
w.Write("Usage: " + Filename + " ");
w.WriteLine("<inputfile> [/output <outputfile>] [/ifstale]");
w.WriteLine();
w.Write(Name);
w.Write(" ");
w.Write(Version.ToString());
if (!string.IsNullOrWhiteSpace(Description))
{
w.Write(" - ");
w.WriteLine(Description);
}
else
{
w.WriteLine(" - <No description>");
}
w.WriteLine();
w.WriteLine(" <inputfile> The input template");
w.WriteLine(" <outputfile> The generated file - defaults to STDOUT");
w.WriteLine(" <ifstale> Only generate if the input is newer than the output");
w.WriteLine();
}
static bool _IsStale(string inputfile, string outputfile)
{
if (string.IsNullOrEmpty(outputfile) || string.IsNullOrEmpty(inputfile))
return true;
var result = true;
// File.Exists doesn't always work right
try
{
if (File.GetLastWriteTimeUtc(outputfile) >= File.GetLastWriteTimeUtc(inputfile))
result = false;
}
catch { }
return result;
}
static string _GetCodeBase()
{
try { return Assembly.GetExecutingAssembly().GetModules()[0].FullyQualifiedName; }
catch { return Path.Combine(Environment.CurrentDirectory,
typeof(Program).Namespace + ".exe"); }
}
static string _GetName()
{
try
{
foreach (var attr in Assembly.GetExecutingAssembly().CustomAttributes)
{
if (typeof(AssemblyTitleAttribute) == attr.AttributeType)
{
return attr.ConstructorArguments[0].Value as string;
}
}
}
catch { }
return Path.GetFileNameWithoutExtension(Filename);
}
static Version _GetVersion()
{
return Assembly.GetExecutingAssembly().GetName().Version;
}
static string _GetDescription()
{
string result = null;
foreach (Attribute ca in Assembly.GetExecutingAssembly().GetCustomAttributes())
{
var ada = ca as AssemblyDescriptionAttribute;
if (null != ada && !string.IsNullOrWhiteSpace(ada.Description))
{
result = ada.Description;
break;
}
}
return result;
}
// do our error handling here (release builds)
static int _ReportError(Exception ex, TextWriter stderr)
{
_PrintUsage(stderr);
stderr.WriteLine("Error: {0}", ex.Message);
return -1;
}
}
}
您可以将上面的代码粘贴到您的命令行项目中的Program.cs中。这是一个健壮的 CLI 应用程序的样板代码,易于添加新的解析参数,具有使用屏幕,可以检测输出是否过时,并且可以作为库从其他工具(如 Visual Studio 自定义文件生成器)中引用。
您只需将主要代码放在 TODO: Do work here 注释所在的位置。
当您构建此类工具时,您需要避免使用外部库,除非绝对必要。原因是,作为预构建步骤,为工具要求额外的依赖项会使使用更加复杂。我知道在 .NET 中,即使是最小的项目,通常也有半打.dll文件,但在这里您将需要偏离这一点,因为您的构建树中有一堆.dll文件就是一团糟。
您可以使用 csbrick 将依赖项目的所有源文件压缩成一个“代码砖”文件,该文件可以包含在您的项目中,而不是使用.dll。只需从您的引用中删除依赖项,然后添加一个运行 csbrick 的预构建步骤,并包含来自项目的您所有的源文件。然后将生成的输出添加到您的项目中。
希望本文能帮助您改进和简化您的开发。
历史
- 2021 年 10 月 28 日 - 初始提交