当前位置: 首页 > news >正文

C# .NET Core 源代码生成器(dotnet source generators)

介绍

    在这篇博文中,我们将介绍源生成器的基础知识、涉及的主要类型、您可能遇到的一些常见问题、如何正确记录这些问题以及如何修复这些问题。

    自 2020 年末首次推出 .NET 5 以来,源生成器就已经存在。自首次发布以来,它们已经有了许多改进,包括创建了更新的增量源生成器。

        TLDR:.NET 中的源生成器使您能够检查用户代码并基于该分析动态生成附加代码。本博文中的示例可能看起来有些冗余,但随着您使用更高级的模式,您可以生成数百行代码,从而帮助减少项目中的样板代码和重复代码。源生成器还非常适合降低运行时反射的使用,因为运行时反射可能会很昂贵并降低应用程序的运行速度。

更新:我更新了源生成器必须针对 .NET 标准的声明原因,并从示例中删除了一些冗余代码。

        在开发一个 C# 库来处理不同进程组件之间或进程间的消息传递时,我遇到了一个问题:使用这个新消息传递库的客户端程序需要添加所有“Messenger 类型”的列表。在遇到这个问题之前,我听说过源生成器,也做过一些小实验,所以我想我可以深入研究并设计一个可行的解决方案来解决这个问题,并自动将“Messenger 类型”列表作为属性注入。

        我也一直在学习各种编程范式和有趣的实践。垂直切片架构和面向方面编程 (AOP) 是与本博客相关的两个主题。垂直切片专注于将那些会一起更改的事物分组,通常根据它们所代表的功能进行分组,而不管它们属于哪个层。切片的目标是最小化切片之间的耦合,并最大化切片内部的耦合(即,一个功能中的事物相互依赖,同时尽量不依赖于其他切片的功能)。这使得代码库保持模块化,并且易于更新、删除或添加新的切片,因为更改不会直接影响现有切片。

        AOP 是一种编程范式,旨在通过分离横切关注点来提高模块化程度。在 C# 中,通常通过创建属性来实现,这些属性随后放置在类、方法等上,用于引入或修改被装饰的代码。因此,考虑到这些因素,我想尝试使用 AOP 创建一个支持垂直切片的功能。考虑到我新遇到的挑战:在构建时自动将对象列表注入到我的即时通讯应用中,我心中的目标就是将所有这些功能结合在一起。

        通过简要概述我为什么开始使用源生成器,让我们快速回顾一下源生成器的基本知识,它允许您做什么以及它不能做什么。

什么是源生成器?

    微软表示:“源生成器旨在实现编译时元编程,即可以在编译时创建代码并添加到编译中。源生成器能够在运行之前读取编译的内容,并访问任何其他文件,从而使生成器能够自检用户 C# 代码和特定于生成器的文件。生成器创建一个管道,从基本输入源开始,并将其映射到它们希望生成的输出。暴露的、完全相等的状态越多,编译器就越能尽早截断更改并重用相同的输出。 ”

        简而言之,.NET 中的源生成器是可以添加到解决方案或包含在现有 NuGet 包中的库项目。它们仅供在构建过程中使用,用于向项目添加新代码。[在此处阅读有关常见源生成器用例的更多信息]。

源生成器不应该做什么

    微软指出了两个主要概念,指出生成器不适用于以下领域。第一个领域是添加语言功能。微软指出:“源生成器并非旨在取代新的语言功能:例如,可以设想将记录实现为一个源生成器,该生成器将指定的语法转换为可编译的 C# 表示形式。我们明确地认为这是一种反模式;该语言将继续发展并添加新功能,我们并不期望源生成器能够实现这一点。这样做会创建与不带生成器的编译器不兼容的新的 C#‘方言’。 ”

    在这方面,我同意团队的观点,允许任何 .NET 开发人员开始向该语言添加新功能,可能会产生竞争功能、令人困惑的需求以及与 .NET 编译器不兼容的可能性;这只会让开发人员感到困惑,并让他们完全远离源生成器。

    第二个是代码修改;微软文档还指出:“如今,用户对其程序集执行了许多后处理任务,我们在此将其广泛定义为‘代码重写’。这些任务包括但不限于:

    • 优化

    • 日志注入

    • IL 编织

    • 调用站点重写

    虽然这些技术有很多有价值的用例,但它们并不符合源码生成的理念。根据定义,它们是代码修改操作,而这些操作已被源码生成器的提案明确排除。

    虽然从技术上来说确实如此,但对于不希望使用“生成器”执行替换操作的团队来说,这更像是一条语义上的界线,而不是一个破坏语言的功能。话虽如此,如果您使用源生成器的目标之一就是代码重写,我将向您展示我最近使用过的一种解决方法。

    源生成器也不是分析器。虽然它们经常一起使用,并且与在项目中使用源生成器有许多完全相同的需求,但生成器的作用是生成代码,而分析器的作用是根据各种规则(例如代码格式)生成警告或错误,或者,正如我们将在源生成器中看到的那样,阻止访问分析器作者认为不受欢迎的特定函数/代码库。

现代 .NET 中的主要源生成器类型

    在撰写本文时(2024 年 9 月),.NET 团队已决定弃用实现“ISourceGenerator”接口的源生成器,转而支持增量生成器。此更改将被强制执行,这意味着 Roslyn API 4.10.0/.NET 9 之后的版本将无法访问旧版“ISourceGenerator”API。(旧版生成器已弃用)。鉴于此,本系列博文将仅介绍“IncrementalGenerator”的使用。

什么是增量源生成器?

    增量生成器是一种源生成器,它仅在项目通过某些过滤要求后才对其进行评估和执行,从而显著提高性能。

通常,源生成器会尝试在设计时和编译时执行。虽然这很好,但每次项目发生更改(例如,删除一行代码、添加一行代码、创建新文件等)时,任何标记为源生成器的类都会被执行。可以想象,每次输入代码时都运行某些代码对性能来说并不理想;因此,微软创建了这些增量生成器来帮助解决这个性能问题。

向您的项目添加增量源生成器

    源生成器必须以 为目标.NET standard 2.0。这是因为,目前 .NET 编译器以 .NET 2.0 标准为目标,而源生成器是由编译器加载的程序集,必须以编译器能够理解的版本为目标。在本节结束时,我们将得到一个包含三个项目的解决方案。

A .NET standard 2.0 library (Source Generator)
A .NET standard 2.0 library (A shared library for the generator and consumers)
A .NET 8 web API project (Main project)

dotnet您可以在终端或您选择的 IDE 中使用该命令来创建这些项目。我将使用 dotnet 工具,因为它与 IDE/平台无关。以下命令将生成所需的项目。

dotnet new sln -n IncrementalSourceGenPractice
dotnet new webapi -n WebProject — project .\IncrementalSourceGenPractice.sln
dotnet new classlib -f netstandard2.0 — langVersion 12 -n SourceGenerator — project .\IncrementalSourceGenPractice.sln
dotnet new classlib -f netstandard2.0 — langVersion 12 -n SourceGenerator.SharedLibrary — project .\IncrementalSourceGenPractice.sln
dotnet sln .\IncrementalSourceGenPractice.sln add .\SourceGenerator.SharedLibrary\SourceGenerator.SharedLibrary.csproj .\SourceGenerator\SourceGenerator.csproj .\WebProject\WebProject.csproj

在创建源生成器之前,需要添加一些 Nuget 包并更改 .csproj 文件。打开SourceGenerator.csproj文件 并确保其与以下内容匹配。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="*">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="*" />
    <PackageReference Include="PolySharp" Version="*">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SourceGenerator.SharedLibrary\SourceGenerator.SharedLibrary.csproj" OutputItemType="Analyzer" />
  </ItemGroup>

</Project>

sourceGenerator.csproj

包引用的版本号可能会有所不同,只要它们对于.NET 标准 2.0 目标有效,就没有问题。

添加的三个配置设置如下

1.<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>

    •确保生成器使用.NET团队创建的推荐规则

2.<IsRoslynComponent>true</IsRoslynComponent>

    •使项目能够充当生成器并与 Roslyn 编译器配合使用,从而可以调试生成器

3.<IncludeBuildOutput>false</IncludeBuildOutput>

    •这可以防止项目构建被包含在输出中,这是理想的,因为生成器只是编译时

此.csproj文件中的另一个奇怪的配置是OutputItemType=”Analyzer”添加到共享库的项目引用。即使共享库不是分析器,这也是必需的,以便生成器可以在生成过程中访问它。

所需的最后配置是针对webproject.csproj文件。

将以下行添加到项目。

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>.\GeneratedFiles</CompilerGeneratedFilesOutputPath>

    •这两个选项允许将源生成器文件写入文件系统并设置自定义路径以将其写入,而不是使用默认路径。

最后,将以下项目组也添加到webproject.csproj文件中。

<ItemGroup>
<ProjectReference Include="..\SourceGenerator.SharedLibrary\SourceGenerator.SharedLibrary.csproj" />
<ProjectReference Include="..\SourceGenerator\SourceGenerator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
</ItemGroup>

    •当引用源生成器时,我们不需要再次输出程序集,因为编译后不需要它。

添加相关生成目标

在第一部分中,我们将生成一些相对简单的东西;然而,后面的文章将更深入地讨论使用源生成器,并且我们将创建一个小型 AOP 框架来实现本博客开头概述的目标。

打开WebProject并 添加一个名为的新类,Calculator.cs其源代码如下。

namespace WebProject;

public partial class Calculator
{
  public int num1 { get; set; }
  public int num2 { get; set; }
}

calculator.cs

然后,我们将为该类生成加、减、乘、除函数。为了保持只向现有类添加内容的预期功能,我们必须将该类标记为部分类。这意味着该类的更多源代码可能位于其他文件中。

启动增量源生成器

恭喜!我们终于完成了所需的设置。

最后,配置完成后,我们就可以开始编写源生成器了。在SourceGenerator项目中,添加一个名为 的类,CalculatorGenerator 其内容如下。

using System;
using Microsoft.CodeAnalysis;

namespace SourceGenerator;

[Generator]
public class CalculatorGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
 
  }
}

CalculatorGenerator.cs

这给出了最基本的起点。要成为一个有效的增量源生成器,该类必须继承自“IIncrementalGenerator”,并用[Generator]属性修饰。该接口要求我们的生成器仅实现“Initialize”函数。

提供者

IncrementalGeneratorInitializationContext它在方法中提供的参数将Initialize允许访问底层源。

上下文对象通过几个不同的“提供者”来实现这一点。

访问各种上下文的不同提供程序 

•CompilationProvider-> 可以访问与整个编译相关​​的数据(程序集、所有源文件、各种解决方案范围的选项和配置)

•SyntaxProvider-> 访问语法树来分析、转换和选择节点以供将来工作(最常访问)

•ParseOptionProvider-> 提供对正在解析的代码的各种信息的访问,例如语言、是否是常规代码文件、脚本文件、自定义预处理器名称等。

•AdditionalTextsProvider-> 附加文本是您可能想要访问的任何非源文件,例如具有各种用户定义属性的 JSON 文件

•MetadataReferencesProvider-> 允许获取对各种事物的引用,例如组件,而无需直接获取整个组件项

•AnalyzerConfigOptionsProvider-> 如果源文件应用了其他分析器规则,则可以访问它们

我们关心的是CompilationProvider和SyntaxProvider。

访问context.SyntaxProvider.CreateSyntaxProvider()方法调用。

此方法接受两个参数。第一个参数称为predicate,这是一个超轻量级过滤器,它将代码库中的所有内容精简为仅包含我们关心的项目。第二个参数称为transform,它从过滤器中提取我们关心的内容,并根据需要进行任何其他更改、属性访问、附加过滤等,然后返回我们稍后需要使用的对象。

使用此syntaxProvider方法的一个例子如下。

参数的名称(谓词、变换)不必提供。我添加它们是为了更容易理解。

更新:本博文的先前版本包含一个 `.Where()` 过滤器调用,用于移除可能为空的结果。然而,在本例中,`ClassDeclarationSyntax` 对象永远不会为空,因此我删除了该行代码。

还要注意的是,像这样返回语法节点并不理想,因为如果我们指定一个类似记录类型的值来返回,Roslyn 可以利用缓存。更直接的做法是将之前返回的节点与将来可能返回的节点进行比较。目前,我们将使用语法节点,在本系列的下一篇中,我们将介绍一些优化方法。

IncrementalValuesProvider<ClassDeclarationSyntax> calculatorClassesProvider = context.SyntaxProvider.CreateSyntaxProvider(
  predicate: (SyntaxNode node, CancellationToken cancelToken) =>
  {
    //the predicate should be super lightweight to filter out items that are not of interest quickly
    return node is ClassDeclarationSyntax classDeclaration && classDeclaration.Identifier.ToString() == "Calculator";
  },
  transform: (GeneratorSyntaxContext ctx, CancellationToken cancelToken) =>
  {
    //the transform is called only when the predicate returns true, so it can do a bit more heavyweight work but should mainly be about getting the data we want to work with later
    var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
    return classDeclaration;
  });

IncrementalValuesProvider.cs

谓词(The Predicate)

谓词代码的第一个参数是 a SyntaxNode,第二个参数也是 a CancellationToken。令牌允许在需要时优雅地停止此方法中执行的任何异步任务。在本例中,它没有必要,因此我们将重点关注 SyntaxNode 。

(SyntaxNode node, _) =>
{
  return node is ClassDeclarationSyntax classDeclaration && 
  classDeclaration.Identifier.ToString() == “Calculator”;
}

上面的代码乍一看可能让人望而生畏,因为你很快就会被 C# 开发人员不常见的术语(例如,SyntaxNode、ClassDeclerationSyntax、标识符等等)所淹没。如果你和我一样,你肯定想知道它们的含义、用途以及为什么需要使用它们。

源代码生成器与 Roslyn 编译器协同工作/利用 Roslyn 编译器。Roslyn 是一组编译器和代码分析 API。Roslyn 通过将几乎所有内容分解为SyntaxNodes和来理解您的代码和项目SyntaxTokens。

语法标记的一些示例包括访问修饰符(如public或private),修饰符(如static、abstract和partial)。项目名称(如类名、命名空间、方法名等)。

标记还包括语言中的语法项目,如分号、括号等。 

SyntaxToken 项目示例

语法节点的示例包括 class declarations、method declarations、bodies of methods 和individual lines of code,其中包括赋值、表达式和 using 语句。

语法节点的示例(在本例中为类声明语法节点)

然后,这种程序化的代码分解可用于分析代码、编写新类、修改方法等。虽然这可能令人望而生畏,但需要记住的是,语法最终仍然是文本,并且语法对象可以根据需要转换为字符串。这正是我们在谓词中所做的,SyntaxToken使用方法将其转换为字符串,.ToString()并将其与目标名称进行比较。

有各种语法节点和标记类型,我计划在本系列的后续文章中对其进行分解并提供示例。

总而言之,谓词表示,如果这段代码表示声明一个像 这样的类public partial class Calculator,则检查其标识符(即类名)是否为“Calculator”,如果是,则将其传递给变换。这样,当生成器看到像 这样的节点时public static void Main(),它就知道要跳过它。

转型(The Transform)

transform: (GeneratorSyntaxContext ctx, _) =>
{
   var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
   return classDeclaration;
}

转换过程接收通过过滤器的项,并再次传入一个取消标记,以便在需要时取消它。该项GeneratorSyntaxContext本质上是一个节点和一些额外的元数据。然后,我们将上下文节点项强制转换为ClassDeclarationSyntax。这是必需的,因为即使过滤器只向我们传递了该类型的节点, 也SyntaxContext无法识别它;然而,我们可以强制转换它,并且确信我们得到的是预期的结果。

我们可以借助转换来提取类的成员、方法体等等,无论我们想处理什么。在这个例子中,我们想处理一个类,因此我们将该项作为 来获取ClassDeclarationSyntax。

最后,我们添加一个 where 语句来过滤掉可能通过的空项。这是可选的,但确保我们不会得到一些奇怪的无效项总是好的。

返回CreateSyntaxProvider的IncrementalValuesProvider<T>T 是我们尝试从方法调用返回的任何项目类型。

“IncrementalValuesProvider” 是一个用来存放我们返回值的对象。还有一个IncrementalValueProvider<T>与之类似,但只包含一个对象。

例如,我们的代码ValuesProvider包含来自类型的类声明ClassDeclarationSyntax。

这给我们留下了一个像这样的初始化方法:

public void Initialize(IncrementalGeneratorInitializationContext context)
{
  IncrementalValuesProvider<ClassDeclarationSyntax> calculatorClassesProvider = context.SyntaxProvider.CreateSyntaxProvider(
    predicate: (SyntaxNode node, CancellationToken cancelToken) =>
    {
      //the predicate should be super lightweight so it can quickly filter out nodes that are not of interest
      //it is basically called all of the time so it should be a quick filter
      return node is ClassDeclarationSyntax classDeclaration && 
      classDeclaration.Identifier.ToString() == "Calculator";
    },
    transform: (GeneratorSyntaxContext ctx, CancellationToken cancelToken) =>
    {
      //the transform is called only when the predicate returns true
      //so for example if we have one class named Calculator
      //this will only be called once, regardless of how many other classes exist
      var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
      return classDeclaration;
    }
 );
 
  //next, we register the Source Output to call the Execute method so we can do something with these filtered items
  context.RegisterSourceOutput(calculatorClassesProvider, (sourceProductionContext, calculatorClass) 
    => Execute(calculatorClass, sourceProductionContext));
}

CalculatorGenerator.cs

使用源生成器的最后一个核心部分是告诉它如何处理我们返回的项目。继续将以下代码添加context. RegisterSourceOutput()到您的项目中。这将告诉生成器如何处理返回的项目。接下来,我们将介绍此 Execute 方法的内容。

Execute 方法

好的,我们有了目标类型;我们正在过滤掉所有我们不关心的内容,所以让我们将它发送到执行方法并生成我们的源代码。

执行方法通常定义如下:

public void Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context)
{
  //Code to perform work on the calculator class
}

第一个参数会根据您尝试执行的工作而有所不同,并且可以根据需要修改该方法以接受额外的参数。该SourceProductionContext对象为我们提供了有关项目/解决方案的基本信息,并使我们能够将代码添加到编译中,以将其包含在最终构建中。

由于我们的目标是生成一些简单的计算器函数,因此我们将首先检查正在处理的类的所有成员,看看它们是否已经包含同名方法,以免意外覆盖现有版本。接下来,我们将收集一些元数据,例如命名空间、所有修饰符以及所有 using 语句,以确保代码能够正确编译。最后,我们将所需的代码插入到源文件中,并将其保存到编译目录中。

public void Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context)
{
  var calculatorClassMembers = calculatorClass.Members;
  
  //check if the methods we want to add exist already 
  var addMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Add");
  var subtractMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Subtract");
  var multiplyMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Multiply");
  var divideMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Divide");

  //this string builder will hold our source code for the methods we want to add
  StringBuilder calcGeneratedClassBuilder = new StringBuilder();
  //This has been updated to now correctly get the Root of the tree and add any Using statements from that to the file
  foreach (var usingStatement in calculatorClass.SyntaxTree.GetCompilationUnitRoot().Usings)
  {
    calcGeneratedClassBuilder.AppendLine(usingStatement.ToString());
  }
  calcGeneratedClassBuilder.AppendLine();
  SyntaxNode calcClassNamespace = calculatorClass.Parent;
  while (calcClassNamespace is not NamespaceDeclarationSyntax)
  {
    calcClassNamespace = calcClassNamespace.Parent;
  }
  
  //Insert the Namespace
  calcGeneratedClassBuilder.AppendLine($"namespace {((NamespaceDeclarationSyntax)calcClassNamespace).Name};");
  
  //insert the class declaration line
  calcGeneratedClassBuilder.AppendLine($"public {calculatorClass.Modifiers} class {calculatorClass.Identifier}");
  calcGeneratedClassBuilder.AppendLine("{");

  //if the methods do not exist, we will add them
  if (addMethod is null)
  {
    //when using a raw string, the first " is the far left margin in the file, 
    //if you want the proper indentation on the methods, you will want to tab the string content at least once
    calcGeneratedClassBuilder.AppendLine(
    """
    public int Add(int a, int b)
    {
      var result = a + b;
      Console.WriteLine($"The result of adding {a} and {b} is {result}");
      return result;
    }
    """);
  }
  
  if (subtractMethod is null)
  {
    calcGeneratedClassBuilder.AppendLine(
    """
    public int Subtract(int a, int b)
    {
      var result = a - b;
      if(result < 0)
      {
        Console.WriteLine("Result of subtraction is negative");
      }
      return result; 
    }
    """);
  }
  
  if (multiplyMethod is null)
  {
    calcGeneratedClassBuilder.AppendLine(
    """
    public int Multiply(int a, int b)
    {
      return a * b;
    }
    """);
  }
  if (divideMethod is null)
  {
    calcGeneratedClassBuilder.AppendLine(
    """
    public int Divide(int a, int b)
    {
      if(b == 0)
      {
        throw new DivideByZeroException();
      }
      return a / b;
    }
    """);
  }
  //append a final bracket to close the class
  calcGeneratedClassBuilder.AppendLine("}");
  
  //while a bit crude, it is a simple way to add the methods to the class

  //to write our source file we can use the context object that was passed in
  //this will automatically use the path we provided in the target projects csproj file
  context.AddSource("Calculator.Generated.cs", calcGeneratedClassBuilder.ToString());
}

CalculatorGenerator.Execute.cs

注意:此代码块尝试通过类的子节点获取命名空间,但这永远不会成功,并且始终返回 null。我们利用这一点来展示日志记录,并在最终的工作副本中修复了这个问题。
因此,“最终”的生成器代码应如下所示:

[Generator]
public class CalculatorGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    IncrementalValuesProvider<ClassDeclarationSyntax> calculatorClassesProvider = context.SyntaxProvider.CreateSyntaxProvider(
      predicate: (node, cancelToken) =>
      {
       //the predicate should be super lightweight so it can quickly filter out nodes that are not of interest
       //it is called all of the time, so it should be a quick filter
       return node is ClassDeclarationSyntax classDeclaration && classDeclaration.Identifier.ToString() == "Calculator";
      },
      transform: (ctx, cancelToken) =>
      {
       //the transform is called only when the predicate returns true, so it can do a bit more heavyweight work but should mainly be about getting the data we want to work with later
       var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
       return classDeclaration;
      }
    );

    context.RegisterSourceOutput(calculatorClassesProvider, (sourceProductionContext, calculatorClass) 
      => Execute(calculatorClass, sourceProductionContext));
  }

  /// <summary>
  /// This method is where the real work of the generator is done
  /// This ensures optimal performance by only executing the generator when needed
  /// The method can be named whatever you want, but Execute seems to be the standard 
  /// </summary>
  /// <param name="calculatorClasses"></param>
  /// <param name="context"></param>
  public void Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context)
  {
    var calculatorClassMembers = calculatorClass.Members;
    
    //check if the methods we want to add exist already 
    var addMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Add");
    var subtractMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Subtract");
    var multiplyMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Multiply");
    var divideMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Divide");

    //this string builder will hold our source code for the methods we want to add
    StringBuilder calcGeneratedClassBuilder = new StringBuilder();
    //This will now correctly parse the Root of the tree for any using statements to add
    foreach (var usingStatement in calculatorClass.SyntaxTree.GetCompilationUnitRoot().Usings)
    {
      calcGeneratedClassBuilder.AppendLine(usingStatement.ToString());
    }
    calcGeneratedClassBuilder.AppendLine();
    //NOTE: This is not the correct way to do this and is used to help produce an error while logging
    SyntaxNode calcClassNamespace = calculatorClass.Parent;
    while (calcClassNamespace is not NamespaceDeclarationSyntax)
    {
      calcClassNamespace = calcClassNamespace.Parent;
    }
    
    calcGeneratedClassBuilder.AppendLine($"namespace {((NamespaceDeclarationSyntax)calcClassNamespace).Name};");
    calcGeneratedClassBuilder.AppendLine($"public {calculatorClass.Modifiers} class {calculatorClass.Identifier}");
    calcGeneratedClassBuilder.AppendLine("{");

    //if the methods do not exist, we will add them
    if (addMethod is null)
    {
      //when using a raw string, the first " is the far left margin in the file, so if you want the proper indentation on the methods, you will want to tab the string content at least once
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Add(int a, int b)
      {
      var result = a + b;
      Console.WriteLine($"The result of adding {a} and {b} is {result}");
      return result;
      }
      """);
    }
    if (subtractMethod is null)
    {
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Subtract(int a, int b)
      {
      var result = a - b;
      if(result < 0)
      {
      Console.WriteLine("Result of subtraction is negative");
      }
      return result; 
      }
      """);
    }
    if (multiplyMethod is null)
    {
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Multiply(int a, int b)
      {
      return a * b;
      }
      """);
    }
    if (divideMethod is null)
    {
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Divide(int a, int b)
      {
      if(b == 0)
      {
      throw new DivideByZeroException();
      }
      return a / b;
      }
      """);
    }
    calcGeneratedClassBuilder.AppendLine("}");
    
    //while a bit crude, it is a simple way to add the methods to the class

    //to write our source file we can use the context object that was passed in
    //this will automatically use the path we provided in the target projects csproj file
    context.AddSource("Calculator.Generated.cs", calcGeneratedClassBuilder.ToString());
  }
}

calculatorGenerator.cs

好了,一切就绪。让我们构建解决方案并检查生成的代码。

源生成失败时的示例错误消息

嗯,这并非我们所期望的;然而,正如许多开发项目一样,错误难免会发生。先别慌,这是为了展示使用源代码生成器时的一些重要事项。首先,源代码生成器只有在生成代码失败时才会显示警告,因此在编译代码时请留意类似的警告信息。

Warning CS8785 : Generator ‘CalculatorGenerator’ failed to generate source. It will not contribute to the output and compilation errors may occur as a result. Exception was of type ‘NullReferenceException’ with message ‘Object reference not set to an instance of an object.’.

其次,源生成器在编译时执行,这使得从异常中捕获额外的上下文变得很有挑战性,就像您通常使用 try-catch 那样,您可以在其中将信息打印到控制台。

如果您尝试以下操作,您会注意到没有其他信息发送到控制台。

public void Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context) 
{
  Try
  {
     // code from before
  }
  catch(Exception ex)
  {
     Console.WriteLine(ex);
  }
}

好的,没问题。相反,让我们在 catch 语句中将消息保存到文件中。

源生成器中阻止的文件 API

好吧,也许不行。如果我们无法登录到生成器中的文件,也无法登录到控制台,我们如何获取所需的详细信息来找出问题所在?

登录源生成器

这使我们开始登录源生成器,我想将其包含在第一部分中,因为它是迄今为止使用源生成器时解决问题最方便的方法。

要在源生成器中启用日志记录功能,请打开我们一开始创建的共享库。它应该只有一个名为 的类class1。将其重命名为GeneratorLogging。虽然文件 API 在源生成器内部被阻止,但可以将该功能添加到辅助库中,并让其将内容写入文件。

一个简单的日志类如下所示

using System;
using System.Collections.Generic;
using System.IO;

namespace SourceGenerator.SharedLibrary;

public class GeneratorLogging
{
    private static readonly List<string> _logMessages = new List<string>();
    private static string? logFilePath = null;
    private static readonly object _lock = new();

    private static string logInitMessage = "[+] Generated Log File this file contains log messages from the source generator\n\n";
    private static LoggingLevel _loggingLevel = LoggingLevel.Info;
    
    public static void SetLoggingLevel(LoggingLevel level)
    {
        _loggingLevel = level;
    }
    
    public static void SetLogFilePath(string path)
    {
        logFilePath = path;
    }
    
    public static LoggingLevel GetLoggingLevel()
    {
        return _loggingLevel;
    }

    public static void LogMessage(string message, LoggingLevel messageLogLevel = LoggingLevel.Info)
    {
        lock (_lock)
        {
            try
            { 
                if (logFilePath is null)
                {
                    return;
                }
                if (File.Exists(logFilePath) is false)
                {
                    File.WriteAllText(logFilePath, logInitMessage);
                    File.AppendAllText(logFilePath, $"Logging started at {GetDateTimeUtc()}\n\n");
                }
                if (messageLogLevel < _loggingLevel)
                {
                    return;
                }
                string _logMessage = message + "\n";
                if (messageLogLevel > LoggingLevel.Info)
                {
                    _logMessage = $"[{messageLogLevel} start]\n" + _logMessage + $"[{messageLogLevel} end]\n\n";
                }
                if (!_logMessages.Contains(_logMessage))
                {
                    File.AppendAllText(logFilePath, _logMessage);
                    _logMessages.Add(_logMessage);
                }
            }
            catch (Exception ex)
            {
                if (logFilePath is null)
                {
                    return;
                }
                File.AppendAllText(logFilePath, $"[-] Exception occurred in logging: {ex.Message} \n");
            }
        }
    }
    
    public static void EndLogging()
    {
        if (logFilePath is null)
        {
            return;
        }
        if (File.Exists(logFilePath))
        {
            File.AppendAllText(logFilePath, $"[+] Logging ended at {GetDateTimeUtc()}\n");
        }
    }
    
    public static string GetDateTimeUtc()
    {
        return DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
    }
}

public enum LoggingLevel
{
    Trace,
    Debug,
    Info,
    Warning,
    Error,
    Fatal
}

GeneratorLogging.cs

我将快速解释几个关键部分。

    •The lock object-> 这确保只有一个日志消息调用实例同时运行。这样,源生成器在尝试访问同一个文件时就不会互相干扰。即使只有一个源生成器,这种情况仍然可能发生,因为它会同时检查多个类。

    •The log message method-> 如果文件存在,此方法将在指定的路径下创建文件。然后,只要日志级别等于或高于设置的级别,它就会将消息记录到文件中。它还会为高于 info 级别的消息添加一个小的页眉和页脚,以便更好地显示错误。

修复示例代码

使用日志类非常简单;如果您还没有这样做,请确保将共享库添加为生成器项目的依赖项,以便生成器项目可以访问它。那么,让我们在当前代码中添加一些日志调用,看看日志会显示什么。

public void Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context)
{
  Try 
  {
    //set the location we want to save the log file
    GeneratorLogging.SetLogFilePath("C:\\BlogPostContent\\SourceGeneratorLogs\\CalculatorGenLog.txt");
    var calculatorClassMembers = calculatorClass.Members;
    GeneratorLogging.LogMessage($"[+] Found {calculatorClassMembers.Count} members in the Calculator class");

    //check if the methods we want to add exist already 
    var addMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Add");
    var subtractMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Subtract");
    var multiplyMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Multiply");
    var divideMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Divide");
    GeneratorLogging.LogMessage("[+] Checked if methods exist in Calculator class");
    
    //this string builder will hold our source code for the methods we want to add
    StringBuilder calcGeneratedClassBuilder = new StringBuilder();
    foreach (var usingStatement in calculatorClass.SyntaxTree.GetCompilationUnitRoot().Usings)
    {
      calcGeneratedClassBuilder.AppendLine(usingStatement.ToString());
    }
    
    GeneratorLogging.LogMessage("[+] Added using statements to generated class");
    calcGeneratedClassBuilder.AppendLine();
    //NOTE: This is the incorrect way to perform this check and is meant to produce an error to help showcase logging
    SyntaxNode calcClassNamespace = calculatorClass.Parent;
    while (calcClassNamespace is not NamespaceDeclarationSyntax)
    {
      calcClassNamespace = calcClassNamespace.Parent;
    }
    
    GeneratorLogging.LogMessage($"[+] Found namespace for Calculator class {calcClassNamespace?.Name}", LoggingLevel.Info);
    calcGeneratedClassBuilder.AppendLine($"namespace {((NamespaceDeclarationSyntax)calcClassNamespace).Name};");
    calcGeneratedClassBuilder.AppendLine($"public {calculatorClass.Modifiers} class {calculatorClass.Identifier}");
    calcGeneratedClassBuilder.AppendLine("{");

    //if the methods do not exist, we will add them
    if (addMethod is null)
    {
      //when using a raw string, the first " is the far left margin in the file, so if you want the proper indentation on the methods, you will want to tab the string content at least once
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Add(int a, int b)
      {
      var result = a + b;
      Console.WriteLine($"The result of adding {a} and {b} is {result}");
      return result;
      }
      """);
    }
    if (subtractMethod is null)
    {
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Subtract(int a, int b)
      {
      var result = a - b;
      if(result < 0)
      {
      Console.WriteLine("Result of subtraction is negative");
      }
      return result; 
      }
      """);
    }
    if (multiplyMethod is null)
    {
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Multiply(int a, int b)
      {
      return a * b;
      }
      """);
    }
    if (divideMethod is null)
    {
      calcGeneratedClassBuilder.AppendLine(
      """
      public int Divide(int a, int b)
      {
      if(b == 0)
      {
      throw new DivideByZeroException();
      }
      return a / b;
      }
      """);
    }
    calcGeneratedClassBuilder.AppendLine("}");

    //while a bit crude, it is a simple way to add the methods to the class
    GeneratorLogging.LogMessage("[+] Added methods to generated class");

    //to write our source file we can use the context object that was passed in
    //this will automatically use the path we provided in the target projects csproj file
    context.AddSource("Calculator.Generated.cs", calcGeneratedClassBuilder.ToString());
    }
  }
  catch(Exception ex)
  {
    GeneratorLogging.LogMessage($"[-] Exception occurred in generator: {e}", LoggingLevel.Error);
  }
}

CalculatorGenerator-faultyExec.cs

如果需要,请执行dotnet clean清理任何以前的日志或生成的文件的操作。然后,构建解决方案并检查日志文件。

日志将包含如下输出:

[+] Generated Log File
[+] This file contains log messages from the source generator
Logging started at 2024–09–14 19:42:12.287
[+] Found 2 members in the Calculator class
[+] Checked if methods exist in Calculator class
[+] Added using statements to generated class

[Error start]
[-] Exception occurred in generator: System.NullReferenceException: Object reference not set to an instance of an object.
 at SourceGenerator.CalculatorGenerator.Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context)
[Error end]

从此日志输出中,我们可以看到生成器在使用语句代码之后立即遇到了此空引用异常,因此让我们更深入地了解一下。

GeneratorLogging.LogMessage(“[+] Added using statements to generated class”);
calcGeneratedClassBuilder.AppendLine();

SyntaxNode calcClassNamespace = calculatorClass.Parent;

while (calcClassNamespace is not NamespaceDeclarationSyntax)
{
  calcClassNamespace = calcClassNamespace.Parent;
}
GeneratorLogging.LogMessage($"[+] Found namespace for Calculator class {calcClassNamespace?.Name}", LoggingLevel.Info);

在这里,我们遍历calcClassNamespace类对象的父级,直到找到某个对象。但是,我们没有添加任何空值检查,以确保在继续操作之前拥有命名空间。让我们修改这部分代码,以处理空值,并对节点的祖先执行检查。

GeneratorLogging.LogMessage(“[+] Added using statements to generated class”);
 
 calcGeneratedClassBuilder.AppendLine();
 
 BaseNamespaceDeclarationSyntax? calcClassNamespace = calculatorClass.DescendantNodes().OfType<NamespaceDeclarationSyntax>().FirstOrDefault() ?? 
 (BaseNamespaceDeclarationSyntax?)calculatorClass.DescendantNodes().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
 
 calcClassNamespace ??= calculatorClass.Ancestors().OfType<NamespaceDeclarationSyntax>().FirstOrDefault();
 calcClassNamespace ??= calculatorClass.Ancestors().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
 
 if(calcClassNamespace is null)
 {
 GeneratorLogging.LogMessage(“[-] Could not find namespace for Calculator class”, LoggingLevel.Error);
 }
 GeneratorLogging.LogMessage($”[+] Found namespace for Calculator class {calcClassNamespace?.Name}”);

更新后的版本将搜索所有祖先节点,检查之前的检查是否为空,并根据需要进行更新。如果检查结果仍然为空,我们应该记录日志,以便持续排查问题。

这为我们提供了最终的工作源生成器:

using System;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using SourceGenerator.SharedLibrary;

namespace SourceGenerator;

[Generator]
public class CalculatorGenerator : IIncrementalGenerator
{

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        IncrementalValuesProvider<ClassDeclarationSyntax> calculatorClassesProvider = context.SyntaxProvider.CreateSyntaxProvider(
        predicate: (SyntaxNode node, CancellationToken cancelToken) =>
        {
            //the predicate should be super lightweight so it can quickly filter out nodes that are not of interest
            //it is basically called all of the time so it should be a quick filter
            return node is ClassDeclarationSyntax classDeclaration && classDeclaration.Identifier.ToString() == "Calculator";
        },
        transform: (GeneratorSyntaxContext ctx, CancellationToken cancelToken) =>
        {
            //the transform is called only when the predicate returns true
            //so for example if we have one class named Calculator
            //this will only be called once regardless of how many other classes exist
            var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
            return classDeclaration;
        }
        );


        context.RegisterSourceOutput(calculatorClassesProvider, (sourceProductionContext, calculatorClass) => Execute(calculatorClass, sourceProductionContext));
    }
    
    /// <summary>
    /// This method is where the real work of the generator is done
    /// This ensures optimal performance by only executing the generator when needed
    /// The method can be named whatever you want but Execute seems to be the standard 
    /// </summary>
    /// <param name="calculatorClass"></param>
    /// <param name="context"></param>
    public void Execute(ClassDeclarationSyntax calculatorClass, SourceProductionContext context)
    {
        GeneratorLogging.SetLogFilePath("C:\\BlogPostContent\\SourceGeneratorLogs\\CalculatorGenLog.txt");
        try
        {
            var calculatorClassMembers = calculatorClass.Members;
            GeneratorLogging.LogMessage($"[+] Found {calculatorClassMembers.Count} members in the Calculator class");
            //check if the methods we want to add exist already 
            var addMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Add");
            var subtractMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Subtract");
            var multiplyMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Multiply");
            var divideMethod = calculatorClassMembers.FirstOrDefault(member => member is MethodDeclarationSyntax method && method.Identifier.Text == "Divide");
            
            GeneratorLogging.LogMessage("[+] Checked if methods exist in Calculator class");
            
            //this string builder will hold our source code for the methods we want to add
            StringBuilder calcGeneratedClassBuilder = new StringBuilder();
            foreach (var usingStatement in calculatorClass.SyntaxTree.GetCompilationUnitRoot().Usings)
            {
                calcGeneratedClassBuilder.AppendLine(usingStatement.ToString());
            }
            GeneratorLogging.LogMessage("[+] Added using statements to generated class");
            
            calcGeneratedClassBuilder.AppendLine();
            
            //The previous Descendent Node check has been removed as it was only intended to help produce the error seen in logging
            BaseNamespaceDeclarationSyntax? calcClassNamespace = calculatorClass.Ancestors().OfType<NamespaceDeclarationSyntax>().FirstOrDefault();
            calcClassNamespace ??= calculatorClass.Ancestors().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
            
            if(calcClassNamespace is null)
            {
                GeneratorLogging.LogMessage("[-] Could not find namespace for Calculator class", LoggingLevel.Error);
            }
            GeneratorLogging.LogMessage($"[+] Found namespace for Calculator class {calcClassNamespace?.Name}");
            calcGeneratedClassBuilder.AppendLine($"namespace {calcClassNamespace?.Name};");
            calcGeneratedClassBuilder.AppendLine($"{calculatorClass.Modifiers} class {calculatorClass.Identifier}");
            calcGeneratedClassBuilder.AppendLine("{");
            
            //if the methods do not exist, we will add them
            if (addMethod is null)
            {
                //when using a raw string the first " is the far left margin in the file
                //if you want the proper indention on the methods you will want to tab the string content at least once
                calcGeneratedClassBuilder.AppendLine(
                """
                    public int Add(int a, int b)
                    {
                        var result = a + b;
                        Console.WriteLine($"The result of adding {a} and {b} is {result}");
                        return result;
                    }
                """);
            }
            if (subtractMethod is null)
            {
                calcGeneratedClassBuilder.AppendLine(
                """
                
                    public int Subtract(int a, int b)
                    {
                        var result = a - b;
                        if(result < 0)
                        {
                            Console.WriteLine("Result of subtraction is negative");
                        }
                        return result; 
                    }
                """);
            }
            if (multiplyMethod is null)
            {
                calcGeneratedClassBuilder.AppendLine(
                """
                
                    public int Multiply(int a, int b)
                    {
                        return a * b;
                    }
                """);
            }
            if (divideMethod is null)
            {
                calcGeneratedClassBuilder.AppendLine(
                """
                
                    public int Divide(int a, int b)
                    {
                        if(b == 0)
                        {
                            throw new DivideByZeroException();
                        }
                        return a / b;
                    }
                """);
            }
            calcGeneratedClassBuilder.AppendLine("}");
            //while a bit crude it is a simple way to add the methods to the class
            
            GeneratorLogging.LogMessage("[+] Added methods to generated class");
            
            //to write our source file we can use the context object that was passed in
            //this will automatically use the path we provided in the target projects csproj file
            context.AddSource("Calculator.Generated.cs", calcGeneratedClassBuilder.ToString());
            GeneratorLogging.LogMessage("[+] Added source to context");
        }
        catch (Exception e)
        {
            GeneratorLogging.LogMessage($"[-] Exception occurred in generator: {e}", LoggingLevel.Error);
        }
    }
}

CalculatorGenerator.cs

我们可以通过修改WebApi project一开始创建的来测试这一点。

打开WebApi program.cs文件,修改成如下的样子:

namespace WebProject;

public class Project
{
    public static async Task Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Add services to the container.
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();

        var app = builder.Build();

        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }
        app.UseHttpsRedirection();
        
        app.MapGet("/",() =>
        {
                Calculator myCalc = new Calculator();
                myCalc.num1 = 5;
                myCalc.num2 = 10;

               int additionResult = myCalc.Add(myCalc.num1, myCalc.num2);
               int subtractionResult =  myCalc.Subtract(myCalc.num1, myCalc.num2);
               int multiplyResult =  myCalc.Multiply(myCalc.num1, myCalc.num2);
               int divideResult =  myCalc.Divide(myCalc.num1, myCalc.num2);

               string response = $"""
                              Successfully executed source generated methods.
                              Results:
                                  Add: {additionResult}
                                  Subtract: {subtractionResult}
                                  Multiply: {multiplyResult}
                                  Divide: {divideResult}
                              """;

               return response;
        });
        
       await app.RunAsync();
    }
}

WebProject.Program.cs

当我们运行这个项目并向/URL 发送获取请求时,我们将收到一条包含源生成方法的结果的消息。

API 调用显示生成的代码已正确编译和执行

结论

希望在未来的文章中介绍源代码生成器的更多功能,以展现其背后的真正威力。所以,如果您喜欢这第一部分,请继续阅读,深入了解 C# 源代码生成器。

如果您喜欢此文章,请收藏、点赞、评论,谢谢,祝您快乐每一天。 

http://www.xdnf.cn/news/1020169.html

相关文章:

  • ROS2编译的理解,与GPT对话
  • 浏览器播放监控画面
  • 【谷歌登录SDK集成】
  • torch 高维矩阵乘法分析,一文说透
  • 信号(瞬时)频率求解与仿真实践(2)
  • 数据库中的Schema是什么?不同数据库中Schema的含义
  • 使用HashMap或者List模拟数据库插入和查询数据
  • 橡胶厂生产线的“协议翻译官”:DeviceNet转Modbus RTU网关实战记
  • PCB 层压板的 Dk 和 Df 表征方法 – 第一部分
  • Linux(Centos 7.6)命令详解:w
  • 从0开始学习R语言--Day22--km曲线
  • 可视化图解算法51:寻找第K大(数组中的第K个最大的元素)
  • 第32节 Node.js 全局对象
  • Nginx 负载均衡、高可用及动静分离
  • CRM管理软件如何实现客户成功管理?
  • Unity3D仿星露谷物语开发62之添加NPC角色
  • 第六章 进阶21 奶茶周会没了奶茶
  • 如何用4 种可靠的方法更换 iPhone(2025 年指南)
  • Vuex相关知识点
  • Flutter项目编译到鸿蒙模拟器报错
  • Vue3 Element Plus 表格默认显示一行
  • Linux爬虫系统从开始到部署成功全流程
  • 国产智能体“双子星”:实在Agent vs Manus(核心架构与技术实现路径对比)
  • EFK架构日志采集系统
  • (nice!!!)(LeetCode 每日一题) 2616. 最小化数对的最大差值 (二分查找)
  • 基于C#+SQLServer2016实现(控制台)小型机票订票系统
  • 力扣面试150题--实现Trie(前缀树)
  • Git:现代开发的版本控制基石
  • Linux系统中自签名HTTPS证书
  • windows使用命令行查看进程信息