使用 C# 进行常量预订和 Git Hooks





5.00/5 (11投票s)
在本文中,我将解释如何使用 C# 构建一个健壮的 Git hooks 系统。此外,我还会展示如何使用它来解决开发协作中的一些棘手问题。
引言
让我给你讲个故事。很久以前,有两个开发者:Sam 和 Bob。他们在一个有数据库的项目上工作。当开发者想要对数据库结构进行更改时,她/他必须创建一个步骤文件stepNNN.sql,其中NNN
是某个数字。为了避免不同开发者之间的数字冲突,他们有一个简单的 Web 应用程序。每个开发者在开始编写 SQL 文件之前,都必须访问该应用程序并预订一个新数字以供其修改使用。
那是 Sam 和 Bob 进行数据库更改的时候。Sam 听话地去 Web 应用程序预订了数字 333。但 Bob 忘了这样做。他直接将 333 用于他的新步骤文件。碰巧 Bob 是第一个将更改提交到版本控制系统的人。当 Sam 准备提交时,发现step333.sql已经存在。他联系了 Bob,解释说 333 号已经被预订了,并要求 Bob 修复冲突。但 Bob 回答说:
- 嘿,哥们。你知道,我的代码已经在“master”分支上了,很多开发者已经取用了。而且,它已经上线了。你能否自己修改你的代码?
你注意到了吗?遵守所有规则的人反而是受惩罚的人。Sam 不得不更改他的文件,修改他的本地数据库等等。我个人很讨厌这种情况。让我们看看如何避免它。
总体思路
我们如何才能防止此类事情发生?如果 Bob 没有在 Web 应用程序上预订相应的数字就无法提交更改,那会怎么样?
这是可以实现的。我们可以使用 Git hooks 在每次提交之前执行自定义代码。这段代码将检查开发者想要提交的所有更改。如果这些更改包含新的步骤文件,代码将联系 Web 应用程序并检查该步骤文件的编号是否已被当前开发者预订。如果该编号未被预订,代码将阻止提交。
这就是主要思想。现在让我们深入细节。
C# 中的 Git Hooks
Git 不限制你使用哪种语言来编写 hooks。作为一名 C# 开发者,我希望为此目的使用熟悉的 C#。我可以做到吗?
是的,我可以。我从 Max Hamulyák 的这篇文章中得到了主要想法。这需要我们使用dotnet-script
全局工具。此工具需要在开发者的机器上安装 .NET Core 2.1+ SDK。我认为如果您正在进行 .NET 开发,安装它并不算过分。dotnet-script
的安装非常直接。
> dotnet tool install -g dotnet-script
现在我们可以使用 C# 编写 Git hooks 了。要做到这一点,在你的项目文件夹中,转到.git\hooks目录并创建一个pre-commit
文件(没有任何扩展名)。
#!/usr/bin/env dotnet-script
Console.WriteLine("Git hook");
从此刻起,每次运行git commit
命令时,你都会在控制台中看到Git hook
消息。
一个 Hook 的多个处理器
嗯,这只是一个开始。现在我们可以在pre-commit
文件中编写任何内容。但我不太喜欢这个想法。
首先,编写脚本文件不是很方便。我宁愿使用我最喜欢的 IDE 及其所有功能。并且我想将复杂代码分成多个文件。
但还有另一件事我不喜欢。考虑以下情况。你创建了一个pre-commit
文件并进行了一些检查。但后来,你决定添加更多检查。你必须打开文件,决定在哪里插入新代码,决定如何与旧代码交互等等。我个人更喜欢编写新代码,而不是修改现有代码。
让我们一个一个地解决这些问题。
调用外部代码
这是我们将要做的。我们将创建一个文件夹(例如,gitHookAssemblies)。在这个文件夹中,我将放置一个 .NET Core 程序集(例如 GitHooks
)。我pre-commit
文件中的脚本将仅调用此程序集中的某个方法。
public class RunHooks
{
public static void RunPreCommitHook()
{
Console.WriteLine("Git hook from assembly");
}
}
我可以使用我喜欢的 IDE 和任何我想要的工具来创建程序集。
现在在pre-commit
文件中,我可以这样写:
#!/usr/bin/env dotnet-script
#r "../../gitHookAssemblies/GitHooks.dll"
GitHooks.RunHooks.RunPreCommitHook();
看这有多酷!现在我只需要在GitHooks
程序集中进行更改。pre-commit
文件的代码将永远不变。任何时候我需要新的检查,我都会更改RunPreCommitHook
方法的代码,重新编译程序集并将其放置在gitHookAssemblies文件夹中。就是这样!
嗯,也不是完全如此。
对抗缓存
让我们尝试遵循这个过程。让我们将Console.WriteLine
的消息更改为不同的内容,重新编译程序集并将其放入gitHookAssemblies文件夹。之后,再次调用git commit
。我们会看到什么?旧的消息。我们的更改未被找到。为什么会这样?
假设你的项目在c:\project文件夹中。这意味着 Git hooks 存储在c:\project\.git\hooks文件夹中。现在,如果你使用的是 Windows 10,请转到c:\Users\<UserName>\AppData\Local\Temp\scripts\c\project\.git\hooks\文件夹。这里的<UserName>
应该是你当前用户的名字。这里有什么?当我们运行pre-commit
脚本时,该文件夹中的脚本的编译版本将被创建。在这里,你还可以找到所有引用的程序集(包括我们的GitHooks.dll)。在execution-cache
子文件夹中,你可以找到一个 SHA256 文件。我可以推测这个文件包含了我们pre-commit
文件的 SHA256 哈希值。每次我们运行脚本时,运行时会将当前文件的哈希值与存储的哈希值进行比较。如果它们相等,将使用存储的已编译脚本版本。
这意味着,由于我们从不更改pre-commit
文件,GitHooks.dll中的更改将永远不会进入缓存,也不会被使用。
我们能对此做些什么?嗯,反射会提供帮助。我将重写我的脚本文件,使用反射而不是直接引用GitHooks
程序集。我们的pre-commit
文件将如下所示:
#!/usr/bin/env dotnet-script
#r "nuget: System.Runtime.Loader, 4.3.0"
using System.IO;
using System.Runtime.Loader;
var hooksDirectory = Path.Combine(Environment.CurrentDirectory, "gitHookAssemblies");
var assemblyPath = Path.Combine(hooksDirectory, "GitHooks.dll");
var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
if(assembly == null)
{
Console.WriteLine($"Can't load assembly from '{assemblyPath}'.");
}
var collectorsType = assembly.GetType("GitHooks.RunHooks");
if(collectorsType == null)
{
Console.WriteLine("Can't find entry type.");
}
var method = collectorsType.GetMethod("RunPreCommitHook",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if(method == null)
{
Console.WriteLine("Can't find method for pre-commit hooks.");
}
method.Invoke(null, new object[0]);
现在我们可以随时更新我们gitHookAssemblies文件夹中的GitHook.dll,并且所有更改都将由同一个脚本执行。无需更改它。
这听起来不错,但我们还有另一个问题需要解决才能继续。我指的是引用。
引用程序集
当我们的RunHooks.RunPreCommitHook
所做的唯一事情是向控制台写入某些字符串时,一切看起来都很好。但坦率地说,我们通常对编写字符串不感兴趣。我们需要做更复杂的事情。要做到这一点,我们需要引用其他程序集和 NuGet 包。让我们看看如何做到。
我将修改我的RunHooks.RunPreCommitHook
以使用一些LibGit2Sharp
包。
public static void RunPreCommitHook()
{
using var repo = new Repository(Environment.CurrentDirectory);
Console.WriteLine(repo.Info.WorkingDirectory);
}
现在,如果我尝试运行git commit
,我会收到以下错误消息:
System.Reflection.TargetInvocationException: Exception has been thrown by
the target of an invocation.
---> System.IO.FileLoadException: Could not load file or assembly
'LibGit2Sharp, Version=0.26.0.0, Culture=neutral, PublicKeyToken=7cbde695407f0333'.
General Exception (0x80131500)
因此,我们需要一种方法来提供所有引用的程序集。这里的主要思想如下。我将所有执行所需的程序集与GitHooks.dll一起放置在同一个gitHookAssemblies文件夹中。要获取 .NET Core 项目中的所有引用的程序集,可以使用dotnet publish
命令。在我们的例子中,我们需要将LibGit2Sharp.dll和git2-7ce88e6.dll放置在此文件夹中。
此外,我们还必须修改我们的pre-commit
脚本。我们将添加以下代码:
#!/usr/bin/env dotnet-script
#r "nuget: System.Runtime.Loader, 4.3.0"
using System.IO;
using System.Runtime.Loader;
var hooksDirectory = Path.Combine(Environment.CurrentDirectory, "gitHookAssemblies");
var assemblyPath = Path.Combine(hooksDirectory, "GitHooks.dll");
AssemblyLoadContext.Default.Resolving += (context, assemblyName) => {
var assemblyPath = Path.Combine(hooksDirectory, $"{assemblyName.Name}.dll");
if(File.Exists(assemblyPath))
{
return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
}
return null;
};
...
这段代码将尝试在gitHookAssemblies文件夹中查找所有未知的程序集。
现在我们可以运行git commit
,它将顺利执行。
提高可扩展性
现在我们的pre-commit
已经完成了。我们不再需要修改它了。但在任何更改的情况下,我们都需要修改RunHooks.RunPreCommitHook
方法。我们只是将这个问题移到了另一个层面。我个人更希望有一种插件系统。每次我想添加一个必须在提交前执行的操作时,我只需编写另一个插件而不修改任何内容。这很难实现吗?
一点也不难。让我们使用MEF。它的工作原理如下。
首先,我们为所有 hook 处理程序定义一个接口:
public interface IPreCommitHook
{
bool Process(IList<string> args);
}
每个 Git hook 都可以通过 Git 传递一些string
参数。这些参数将在args
参数中。如果Process
方法允许提交更改,则返回true
,否则返回false
。
我们绝对可以为其他 hooks 定义类似的接口,但在本文中,我们将重点关注 pre-commit hook。
现在我们来实现这个接口:
[Export(typeof(IPreCommitHook))]
public class MessageHook : IPreCommitHook
{
public bool Process(IList<string> args)
{
Console.WriteLine("Message hook...");
if(args != null)
{
Console.WriteLine("Arguments are:");
foreach(var arg in args)
{
Console.WriteLine(arg);
}
}
return true;
}
}
如果需要,可以在不同的程序集中定义此类。实际上,没有任何限制。Export
属性必须从System.ComponentModel.Composition
NuGet 包中获取。
我们将定义一个辅助方法,它将收集所有标记了Export
属性的IPreCommitHook
接口的实现,运行所有这些实现,并返回是否有一个不允许继续提交。我将这段代码放在一个单独的GitHooksCollector
程序集中,但这并不重要。
public class Collectors
{
private class PreCommitHooks
{
[ImportMany(typeof(IPreCommitHook))]
public IPreCommitHook[] Hooks { get; set; }
}
public static int RunPreCommitHooks(IList<string> args, string directory)
{
var catalog = new DirectoryCatalog(directory, "*Hooks.dll");
var container = new CompositionContainer(catalog);
var obj = new PreCommitHooks();
container.ComposeParts(obj);
bool success = true;
foreach(var hook in obj.Hooks)
{
success &= hook.Process(args);
}
return success ? 0 : 1;
}
}
这段代码还使用System.ComponentModel.Composition
NuGet 包。首先,我们指定我们将查找与*Hooks.dll模式匹配的所有程序集,在directory文件夹中。你可以使用任何你想要的模式。然后,我们将所有导出的IPreCommitHook
接口实现收集到PreCommitHooks
对象中。最后,我们运行所有处理程序并计算聚合执行结果。
最后一件事是稍微修改pre-commit
文件:
#!/usr/bin/env dotnet-script
#r "nuget: System.Runtime.Loader, 4.3.0"
using System.IO;
using System.Runtime.Loader;
var hooksDirectory = Path.Combine(Environment.CurrentDirectory, "gitHookAssemblies");
var assemblyPath = Path.Combine(hooksDirectory, "GitHooksCollector.dll");
AssemblyLoadContext.Default.Resolving += (context, assemblyName) => {
var assemblyPath = Path.Combine(hooksDirectory, $"{assemblyName.Name}.dll");
if(File.Exists(assemblyPath))
{
return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
}
return null;
};
var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
if(assembly == null)
{
Console.WriteLine($"Can't load assembly from '{assemblyPath}'.");
}
var collectorsType = assembly.GetType("GitHooksCollector.Collectors");
if(collectorsType == null)
{
Console.WriteLine("Can't find collector's type.");
}
var method = collectorsType.GetMethod("RunPreCommitHooks",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if(method == null)
{
Console.WriteLine("Can't find collector's method for pre-commit hooks.");
}
int exitCode = (int) method.Invoke(null, new object[] { Args, hooksDirectory });
Environment.Exit(exitCode);
不要忘记将所有参与的程序集放置在gitHookAssemblies文件夹中。
哇,这真是一个漫长的介绍。但现在我们有了一个相当健壮的用 C# 编写 Git hooks 的解决方案。我们只需要修改gitHookAssemblies文件夹的内容。此文件夹的内容可以放置在版本控制系统中,从而分发给所有开发人员。
无论如何,是时候解决我们最初的问题了。
用于常量预订的 Web 服务
我们想确保开发人员在忘记在 Web 服务上注册相应常量之前无法提交更改。让我们为我们的需求创建一个简单的 Web 服务。我将使用 ASP.NET Core Web 服务和 Windows 身份验证。但实际上,有许多变体可供使用。
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ListsService.Controllers
{
public sealed class ListItem<T>
{
public ListItem(T value, string owner)
{
Value = value;
Owner = owner;
}
public T Value { get; }
public string Owner { get; }
}
public static class Lists
{
public static List<ListItem<int>> SqlVersions = new List<ListItem<int>>
{
new ListItem<int>(1, @"DOMAIN\Iakimov")
};
public static Dictionary<int,
List<ListItem<int>>> AllLists = new Dictionary<int, List<ListItem<int>>>
{
{1, SqlVersions}
};
}
[Authorize]
public class ListsController : Controller
{
[Route("/api/lists/{listId}/ownerOf/{itemId}")]
[HttpGet]
public IActionResult GetOwner(int listId, int itemId)
{
if (!Lists.AllLists.ContainsKey(listId))
return NotFound();
var item = Lists.AllLists[listId].FirstOrDefault(li => li.Value == itemId);
if(item == null)
return NotFound();
return Json(item.Owner);
}
}
}
在这里,我仅出于测试目的将static
类Lists
用作存储机制。每个列表将有一个整数标识符。每个列表将包含整数项,其中包含有关已注册它们的个人的信息。ListController
类的方法GetOwner
允许获取注册相应列表项的人的某个标识符。
检查 SQL 步骤文件
现在我们准备好检查是否可以提交新的 SQL 步骤文件了。假设我们以以下方式存储 SQL 步骤文件。在项目的主文件夹中,我们有一个sql子文件夹。在此文件夹中,每个开发人员都可以创建verXXX
文件夹,其中XXX
是必须在 Web 服务中注册的某个数字。并且在verXXX
文件夹内应该有一个或多个.sql文件,它们提供对数据库的修改。我们在这里不讨论这些.sql文件的执行顺序问题。这与我们的讨论无关。我们只想做到这一点。如果开发人员想要提交sql/verXXX文件夹内的任何新文件,我们必须检查常量XXX
是否已被该开发人员注册。
这是相应 Git hook 的代码:
[Export(typeof(IPreCommitHook))]
public class SqlStepsHook : IPreCommitHook
{
private static readonly Regex _expr = new Regex("\\bver(\\d+)\\b");
public bool Process(IList<string> args)
{
using var repo = new Repository(Environment.CurrentDirectory);
var items = repo.RetrieveStatus()
.Where(i => !i.State.HasFlag(FileStatus.Ignored))
.Where(i => i.State.HasFlag(FileStatus.NewInIndex))
.Where(i => i.FilePath.StartsWith(@"sql"));
var versions = new HashSet<int>(
items
.Select(i => _expr.Match(i.FilePath))
.Where(m => m.Success)
.Select(m => m.Groups[1].Value)
.Select(d => int.Parse(d))
);
foreach(var version in versions)
{
if (!ListItemOwnerChecker.DoesCurrentUserOwnListItem(1, version))
return false;
}
return true;
}
}
在这里,我们使用LibGit2Sharp
NuGet 包中的Repository
类。items
变量将包含 Git 索引中位于sql文件夹内的所有新文件。如果你愿意,可以改进查找此类文件的过程。在versions
变量中,我们收集所有不同的XXX
常量来自verXXX
文件夹。最后,方法ListItemOwnerChecker.DoesCurrentUserOwnListItem
检查版本是否由当前用户在 Web 服务上的列表 1 中注册。
ListItemOwnerChecker.DoesCurrentUserOwnListItem
的实现非常简单:
class ListItemOwnerChecker
{
public static string GetListItemOwner(int listId, int itemId)
{
var handler = new HttpClientHandler
{
UseDefaultCredentials = true
};
var client = new HttpClient(handler);
var response = client.GetAsync
($"https://:44389/api/lists/{listId}/ownerOf/{itemId}")
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
var owner = response.Content
.ReadAsStringAsync()
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
return JsonConvert.DeserializeObject<string>(owner);
}
public static bool DoesCurrentUserOwnListItem(int listId, int itemId)
{
var owner = GetListItemOwner(listId, itemId);
if (owner == null)
{
Console.WriteLine($"There is no item '{itemId}' in the list '{listId}'
registered on the lists service.");
return false;
}
if (owner != WindowsIdentity.GetCurrent().Name)
{
Console.WriteLine($"Item '{itemId}' in the list '{listId}'
registered by '{owner}' and you are '{WindowsIdentity.GetCurrent().Name}'.");
return false;
}
return true;
}
}
在这里,我们向 Web 服务询问注册所需常量(GetListItemOwner
方法)的用户的标识符。然后将其与当前 Windows 用户的名字进行比较。这只是实现此功能的一种可能方法。例如,你可以使用 Git 配置中的用户名或电子邮件。
就是这样。只需构建相应的程序集并将其与所有引用的程序集一起放置在gitHookAssemblies文件夹中。一切都会自动工作。
检查枚举值
嗯,这很棒。现在没有人可以在 Web 服务中注册相应常量之前提交新的 SQL 数据库更改。但我们可以在其他地方使用此方法,其中一些常量需要预订。
例如,在代码的某个地方,可能有一个enum
。每个开发人员都可以向enum
添加一个成员并为该成员分配一个整数值:
enum Constants
{
Val1 = 1,
Val2 = 2,
Val3 = 3
}
我们想避免此enum
成员值的冲突。这就是为什么我们要求先在 Web 服务中注册相应的整数常量。实现此类常量的注册检查有多难?
这是新的 Git hook 的代码:
[Export(typeof(IPreCommitHook))]
public class ConstantValuesHook : IPreCommitHook
{
public bool Process(IList<string> args)
{
using var repo = new Repository(Environment.CurrentDirectory);
var constantsItem = repo.RetrieveStatus()
.Staged
.FirstOrDefault(i => i.FilePath == @"src/GitInteraction/Constants.cs");
if (constantsItem == null)
return true;
if (!constantsItem.State.HasFlag(FileStatus.NewInIndex)
&& !constantsItem.State.HasFlag(FileStatus.ModifiedInIndex))
return true;
var initialContent = GetInitialContent(repo, constantsItem);
var indexContent = GetIndexContent(repo, constantsItem);
var initialConstantValues = GetConstantValues(initialContent);
var indexConstantValues = GetConstantValues(indexContent);
indexConstantValues.ExceptWith(initialConstantValues);
if (indexConstantValues.Count == 0)
return true;
foreach (var version in indexConstantValues)
{
if (!ListItemOwnerChecker.DoesCurrentUserOwnListItem(2, version))
return false;
}
return true;
}
...
}
首先,我们检查我们enum
的相应文件是否已被修改。然后我们使用GetInitialContent
和GetIndexContent
方法从 Git 存储(先前提交的版本)和 Git 索引中提取该文件的内容。以下是它们的实现:
private string GetInitialContent(Repository repo, StatusEntry item)
{
var blob = repo.Head.Tip[item.FilePath]?.Target as Blob;
if (blob == null)
return null;
using var content = new StreamReader(blob.GetContentStream(), Encoding.UTF8);
return content.ReadToEnd();
}
private string GetIndexContent(Repository repo, StatusEntry item)
{
var id = repo.Index[item.FilePath]?.Id;
if (id == null)
return null;
var itemBlob = repo.Lookup<Blob>(id);
if (itemBlob == null)
return null;
using var content = new StreamReader(itemBlob.GetContentStream(), Encoding.UTF8);
return content.ReadToEnd();
}
然后我们从enum
的两个版本中提取enum
成员的整数值。这是在GetConstantValues
方法中完成的。我使用了Roslyn来实现此功能。你可以从Microsoft.CodeAnalysis.CSharp
NuGet 包中获取。
private ISet<int> GetConstantValues(string fileContent)
{
if (string.IsNullOrWhiteSpace(fileContent))
return new HashSet<int>();
var tree = CSharpSyntaxTree.ParseText(fileContent);
var root = tree.GetCompilationUnitRoot();
var enumDeclaration = root
.DescendantNodes()
.OfType<EnumDeclarationSyntax>()
.FirstOrDefault(e => e.Identifier.Text == "Constants");
if(enumDeclaration == null)
return new HashSet<int>();
var result = new HashSet<int>();
foreach (var member in enumDeclaration.Members)
{
if(int.TryParse(member.EqualsValue.Value.ToString(), out var value))
{
result.Add(value);
}
}
return result;
}
在使用Roslyn
时,我遇到了以下问题。当我编写代码时,Microsoft.CodeAnalysis.CSharp
NuGet 包的最新版本是 3.4.0。我将程序集放在gitHookAssemblies文件夹中,但代码说找不到相应的程序集版本。原因如下。你看,dotnet-script
也使用Roslyn
来工作。这意味着,某个版本的Microsoft.CodeAnalysis.CSharp
程序集已被加载到域中。对我来说,那是 3.3.1 版本。当我开始使用此版本的 NuGet 包时,问题就消失了。
最后,在我们 hook 处理程序的Process
方法中,我们选择所有新值并在我们的 Web 服务上检查它们的拥有者。
关注点
好了。我们的常量预订检查系统已经构建完成。最后,我想谈谈一些我们应该考虑的问题。
- 我们创建了一个
pre-commit
hook 文件,但我们还没有讨论如何在所有开发者的计算机上将其放置到.git\hooks文件夹中。我们可以使用git init
命令的--template
参数。或者类似的东西:git config init.templatedir git_template_dir git init
或者,如果你有 Git 2.9 或更高版本,可以使用
core.hooksPath
Git 配置选项:git config core.hooksPath git_template_dir
或者我们可以将其作为我们项目构建过程的一部分。
dotnet-script
的安装也存在同样的问题。我们可以预先在所有开发者的机器上安装它,并指定某个版本的 .NET Core,或者我们可以将其作为构建过程的一部分进行安装。- 我个人认为最大的问题在于引用的程序集的位置。我们同意将所有程序集都放在gitHookAssemblies文件夹中,但我不能确定这在所有情况下都有帮助。例如,
LibGit2Sharp
包附带了许多适用于不同操作系统的原生库。在这里,我使用了适合 Win-x64 的git2-7ce88e6.dll。但是,如果不同的开发人员使用不同的操作系统,我们可能会遇到一些问题。 - 我们几乎没有谈论 Web 服务的实现。在这里,我们使用了 Windows 身份验证,但还有许多可能的选项。此外,Web 服务应该提供一些 UI 来预订新常量和创建新列表。
- 也许你已经注意到,在我们的 Git hook 处理程序中使用异步操作很麻烦。我认为应该实现对这些操作更好的支持。
结论
在本文中,我们学习了如何使用 .NET 语言构建一个健壮的 Git hooks 系统。在此基础上,我们编写了几个 hook 处理程序,可以检查不同常量的预订情况,并在违反规定时阻止提交。
我希望这些信息对你有所帮助。祝你好运!
你可以在我的博客上阅读更多我的文章。
附注:你可以在GitHub上找到本文的代码。
历史
- 2020年1月23日:初始版本