1+1=10

扬长避短 vs 取长补短

在WinForm程序中显示Git版本信息的一种方式

对于一个开发桌面程序的小团队来说,如果 一没有专门人员负责软件打包发布,二没有CI/CD系统进行自动发布,且需要频繁迭代的话,开发工程师最好保证每次代码提交后,生成的产物自动生成附带不同的版本号。不然在迭代过程中,和测试人员或用户交流会有一定困难。

对于使用qmake或cmake等构建工具的工程,可以直接:

  • 在工程文件中通过git获取仓库信息,而后通过宏定义传递到源码;
  • 或者构建过程中生成一个临时文件,作为源码一部分使用。

那么对于一个C#的WinForm程序,如何在Visual Studio下每次构建时自动调用git来获取仓库信息呢?

其实也简单,只需要一个.tt文件进行了。接下来,我们使用Visual Studio 2019自带 T4 功能,一步一步实现如下效果:

c-sharp-about-dialog

即:在C#程序 About对话框 以及在程序 属性对话框 中显示版本信息。那么:

如何确定版本号?

其实,版本号定义本身比较随意的,比如大佬 高德纳 使用圆周率 π和自然底数e作为他软件作品的版本号。截至目前Tex的版本号是3.141592653。

语义化版本(SemVer)在开源社区用的比较广,文档中各字段定义也很详细。简单说,版本号一般由三部分构成:

MAJOR.MINOR.PATCH

而微软的定义是这样的(一直不清楚它的Build number是怎么用的):

Major.Minor.Build.Revision

在本文中,我们使用4个数字来表示版本号,比如 1.0.0.2。

其中,前三位是代码中或配置文件中写死的(有重大改动时可手动升版),最后一位是自动生成的,只需要保证最后一位单调增加就行(当然,不同人在不同时间编译同一个版本,最后一位也要保持一致)。

老的SVN干这个事情很简单,反而Git作为分布式源码控制系统,做这个事情还真不擅长,它只保证每次提交生成一个长长的SHA1值,但是这个值又对用户没什么用。简单起见,我们可以使用git仓库的提交数或者距离上一个tag的提交数作为版本号的最后一位。

尽管代码每次提交生成的长长的SHA1值对客户没有用,但是对偷懒的程序员来说,实在太有用了,依靠它,随时可以找到这个版本对应的源码。所以这个东西我们此次也显示出来了,不过SHA1也不用全显示,一般显示开头的几个数字就够了。如果仓库中有tag,使用describe获取描述信息也是不错选择。

信息放什么地方?

在C#中,版本信息一般都存放在一个名为 AssemblyInfo.cs的文件中。文件内容大致如下:

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

[assembly: AssemblyTitle("Debao's C# Demo")]
[assembly: AssemblyDescription("All right reserved.")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Demo")]
[assembly: AssemblyCopyright("Copyright © 2023")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: AssemblyInformationalVersion("")]
[assembly: AssemblyVersion("1.0.0.2")]
[assembly: AssemblyFileVersion("1.0.0.2")]
[assembly: AssemblyMetadata("BuildDate", "11/21/2023 12:50:42")]
[assembly: AssemblyMetadata("GitSHA1", "4f3935e")]

如果只想在About对话框显示版本信息,用不用 AssemblyInfo.cs 是无所谓的,随便创建一个文件,写入需要的信息,让About对话框调用就可以了。

但使用这个文件可以同时解决版本显示的两个问题:

  • 在属性对话框中显示(在程序图标上点击右键)
  • 在About 对话框中显示

代码每次变化,都需要这个文件信息发生变化,该怎么办...

如何自动更新AssemblyInfo.cs?

既然需要自动更新,那么这个文件就不适合放进代码仓库。

创建一个 AssemblyInfo.tt 文件,在构建过程中,使用T4 自动将其转成对应的 .cs文件。

准备

首先,删除AsemblyInfo.cs这个文件

其次,保证 git 不管控这个文件,在 .gitignore中添加:

Properties/AssemblyInfo.cs

主角 T4

T4(Text Template Transformation Toolkit)文本模板转换工具,是一个开源的基于模板的文本生成框架。T4 源文件的后缀通常使用 .tt。

T4 文本模板是可生成文本文件的文字块和控制逻辑的混合。控制逻辑可以用Visual C# 或 Visual Basic的程序代码段书写。生成的文件可以是文本,资源文件,或程序源文件。

.tt文件 本身和 .cs 很像,如下所示,只有最后几行不同。在这几行中,我们调用自定义的函数,用来获取源码仓库信息:

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Debao's Demo")]
[assembly: AssemblyDescription("All right reserved.")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Demo")]
[assembly: AssemblyCopyright("Copyright © 2023")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: AssemblyInformationalVersion("")]
[assembly: AssemblyVersion("1.0.0.<#= GetCommitsCount() #>")]
[assembly: AssemblyFileVersion("1.0.0.<#= GetCommitsCount() #>")]
[assembly: AssemblyMetadata("BuildDate", "<#= DateTime.Now #>")]
[assembly: AssemblyMetadata("GitSHA1", "<#= GetSha1().Substring(0,7) #>")]

这两个函数的意图很明显,只需要将其也定义在这个文件中就行了

<#+
        public int GetCommitsCount()
        {
            int count = 0;
            using (Process process = new Process())
            {
                process.StartInfo.FileName = "git";
                process.StartInfo.Arguments = "rev-list --count HEAD";
                process.StartInfo.UseShellExecute = false;
                process.StartInfo.WorkingDirectory = Path.GetDirectoryName(this.Host.TemplateFile);
                process.StartInfo.RedirectStandardOutput = true;
                try
                {
                    process.Start();
                    StreamReader reader = process.StandardOutput;
                    string output = reader.ReadToEnd();
                    count = int.Parse(output);
                }
                catch(Exception ex)
                {
                    // fail to run the git command.
                }
            }
            return count;
        }


        public string GetSha1()
        {
            string sha1="";
            using (Process process = new Process())
            {
                process.StartInfo.FileName = "git";
                process.StartInfo.Arguments = "rev-parse HEAD";
                process.StartInfo.UseShellExecute = false;
                process.StartInfo.WorkingDirectory = Path.GetDirectoryName(this.Host.TemplateFile);
                process.StartInfo.RedirectStandardOutput = true;
                try
                {
                    process.Start();
                    StreamReader reader = process.StandardOutput;
                    sha1 = reader.ReadToEnd();
                }
                catch(Exception ex)
                {
                    // fail to run the git command.
                }
            }
            return sha1;
        }
#>

通过直接调用git获取所需的信息,代码有些简单粗暴,就不解释了。

只不过,这几个函数,使用了.net一些标准库,所以使用之前需要通过 import 引入进来,如下:

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Collections.Generic" #>

这三部分合起来,就是完整的所需要的文件...

Assembly.tt 文件

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Collections.Generic" #>
//---------------------------------------------------------------------------
//  This file is auto-generated by T4 from AssemblyInfo.tt
//
//  Changes to this file may cause incorrect behavior and will be lost if
//  the code is regenerated.
//
//---------------------------------------------------------------------------

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Debao's Demo")]
[assembly: AssemblyDescription("All right reserved.")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Demo")]
[assembly: AssemblyCopyright("Copyright © 2023")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: AssemblyInformationalVersion("")]
[assembly: AssemblyVersion("1.0.0.<#= GetCommitsCount() #>")]
[assembly: AssemblyFileVersion("1.0.0.<#= GetCommitsCount() #>")]
[assembly: AssemblyMetadata("BuildDate", "<#= DateTime.Now #>")]
[assembly: AssemblyMetadata("GitSHA1", "<#= GetSha1().Substring(0,7) #>")]

[assembly: ComVisible(false)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("14b8063a-65bf-4794-84d0-e6b61ee99ebb")]


<#+
        public int GetCommitsCount()
        {
            int count = 0;
            using (Process process = new Process())
            {
                process.StartInfo.FileName = "git";
                process.StartInfo.Arguments = "rev-list --count HEAD";
                process.StartInfo.UseShellExecute = false;
                process.StartInfo.WorkingDirectory = Path.GetDirectoryName(this.Host.TemplateFile);
                process.StartInfo.RedirectStandardOutput = true;
                try
                {
                    process.Start();
                    StreamReader reader = process.StandardOutput;
                    string output = reader.ReadToEnd();
                    count = int.Parse(output);
                }
                catch(Exception ex)
                {
                    // fail to run the git command.
                }
            }
            return count;
        }


        public string GetSha1()
        {
            string sha1="";
            using (Process process = new Process())
            {
                process.StartInfo.FileName = "git";
                process.StartInfo.Arguments = "rev-parse HEAD";
                process.StartInfo.UseShellExecute = false;
                process.StartInfo.WorkingDirectory = Path.GetDirectoryName(this.Host.TemplateFile);
                process.StartInfo.RedirectStandardOutput = true;
                try
                {
                    process.Start();
                    StreamReader reader = process.StandardOutput;
                    sha1 = reader.ReadToEnd();
                }
                catch(Exception ex)
                {
                    // fail to run the git command.
                }
            }
            return sha1;
        }
#>

确保文件属性中,Custom Tool 选择:TextTemplatingFileGenerator c-sharp-assembly-tt-property

至此,任务完成。每次重新构建项目时,AssemblyInfo.cs 都会自动生成出来,如果不重新构建项目,也可以在AssemblyInfo.tt上点击右键,选择"Run Custom Tool"。

题外

如果要在没有git的机器上进行构建,或者不想直接调用git。可以搜索到 .git 文件夹,直接解析它。比如,上面用到的GetSha1()函数,也可以像下面这样写:

        public  string GetHeadSha1(DirectoryInfo gitDirectory)
        {
            if (gitDirectory != null)
            {
                var head = File.ReadAllText(gitDirectory.GetFiles("HEAD").First().FullName);
                head = head.Substring(4).Trim();
                var headRef = System.IO.Path.Combine(gitDirectory.FullName, head);
                return  File.ReadAllText(headRef).Trim();
            }
            return null;
        }

        public  DirectoryInfo SearchForGitDirectory(string path)
        {
            DirectoryInfo di = new DirectoryInfo(path);
            var directories = di.GetDirectories(".git");
            var gitDirectory = directories.FirstOrDefault(x => x.Name == ".git");
            if (gitDirectory == null)
            {
                if (di.Parent != null)
                {
                    return SearchForGitDirectory(di.Parent.FullName);
                }

                return null;
            }
            return gitDirectory;
        }

        public string GetSha1()
        {
            var gitDirectory = SearchForGitDirectory(Path.GetDirectoryName(this.Host.TemplateFile));
            var headSha1 = GetHeadSha1(gitDirectory);
            return headSha1;
        }

当然,如果真要在没有安装git的机器上使用git仓库,就真有点自虐了...

参考

Tools git