最近需要在 Windows 环境下完成一些自动化操作,于是开始学习 PowerShell 脚本的编写。 本来对 PowerShell 比较无感,因为比较熟悉 Bash Script,觉得 PowerShell 语法似乎比较啰嗦, 而且好多命令还要重头学起,自然就有了一定的抵触情绪。然而我还是太天真了,一切都逃不过真香定律。
本文将从一个实际遇到的需求出发展示 PowerShell 的强大之处,以及它对于熟悉 C#的开发者来说有多么友好。
本文首发于本人博客0x1C.dev,欢迎访问以获得更好的阅读体验。
0x0 知识背景和开发环境
本文假设读者:
- 有基础的
git知识(知道git是干吗的); - 喜欢用命令行工具(CLI)来提高工作效率;
- 有基础的
Shell脚本编写或阅读经验; - 喜欢.NET 技术框架
本文尤其适合有.NET 开发背景的程序员食用。
PowerShell 和.NET 库能够完美配合,因此如果您有.NET (C#) 的开发背景, 那么对于脚本中的各种类型和函数会更容易理解,甚至都不用怎么去理解。
我的运行环境是PowerShell 7.0.2,经测试脚本在5.1版也能跑,更低的版本可能就悬了吧。 不过更低的版本估计也没人用,毕竟国内用的最多的Windows Server 2008 R2都可以装PowerShell 5.1了。
编辑器我用的是VS Code并安装了PowerShell插件, 该插件能够在编写 PowerShell 脚本时提供智能提示、格式化代码等功能。
0x1 需求说明
本文实现的其实是一个很简单的需求:
实现一个命令行脚本,调用方式为:
diff.ps1 [-verbose] [-baseDir BASE_DIR] [-out OUT_FILE] COMMIT_HASH要求能够:
- 获取提交
COMMIT_HASH之后的所有 Git 提交记录,记录中包括提交信息和变动的文件列表。 - 根据第 1 步得到的提交记录,生成:
- 提交信息列表,按提交时间倒序排列;
BASE_DIR目录下的变动文件的路径列表,按路径升序排列,其中文件路径是基于BASE_DIR的相对路径,且不能重复
3. 若通过-out参数指定了输出路径,则将上述信息写入OUT_FILE文件,否则在 stdout 中打印 JSON 数据。JSON 结构如下:
{
"commits": [...],
"filePaths": [...]
}4. 通过-verbose标记决定是否打印冗余信息,包括:
- 查询到的提交记录
- 被记录的变动文件路径
0x2 获取Git提交记录
首先要考虑如何获得便于解析的、带文件路径记录的 Git 提交记录。因为这个不是本文重点,在此我仅作简单说明。
假设要筛选的起始提交 Hash 为abc123,且不包含这个起始 Hash,则获取日志的命令如下:
git log --name-only --no-merges --dense --format="%n>>%h|%ai|%s" abc123..HEAD简要参数说明如下,更准确的详细说明请参见 git-log Documentation:
--name-only仅显示变动的文件名(路径)
--no-merges不显示合并产生的提交记录,即将父级提交数量限制为1
--dense仅显示选定的提交信息
--format提供提交信息的格式,参数值指定的格式为:(换行)>>短 Hash|类似 ISO-8601 提交时间|提交标题
<commit>..HEAD筛选出从<commit>到分支头部的提交记录,但不包含<commit>
用该命令打印出来的提交信息看起来是这样:
>>32aeee41|2020-06-16 02:04:14 +0800|commit 2
file1.txt
file2.bin
>>9a02956f|2020-06-16 01:44:26 +0800|commit 1
file0.exe
file1.txt由于每个信息和文件路径都各占一行,因此不难想到只需要按行处理输出便可以得到我们所需的全部信息。 而有了格式化的提交信息,我们便可以很方便的使用正则表达式来进行数据提取了。
0x3 编写PowerShell脚本
有了上面的git命令作基础,剩下工作的就是使用 PowerShell 脚本来进行数据的提取、清洗、组装和输出了。
我本来以为用 PowerShell 处理器来会非常麻烦,因为毕竟 Windows 环境下缺少 Linux 下完善的工具集, 比如sed、jq等数据处理工具。我甚至一度绝望地想到,难不成要我自己来手写 JSON 文件的解析和序列化, 或者上网找一些第三方工具作为依赖库?
但是我错了。
接下来你会看到 PowerShell 是如何利用强大的.NET 库来完成各种复杂工作, 甚至利用类、泛型和接口实现等类似 C# 的语言特性来更好的组织代码, 并在代码编写过程中使用IntelliSense来提高脚本编写效率。
在开始之前,新建一个脚本diff.ps1。后面的 PowerShell 代码都写在这个脚本里。
0x3 >> 1 定义和解析命令行参数
在Bash Script中,我们一般通过for+case指令块来解析命令行中的参数,比如差不多是这个意思:
for arg in $@
do
case $arg in
-verbose)
verbose=1
shift
;;
-baseDir)
shift
baseDir=$1
shift
;;
*)
-out)
shift
outFile=$1
shift
;;
afterCommit=$1
shift
;;
esac
done而在 PowerShell 中,则是通过param关键字来声明一个参数列表:
param(
[string]$baseDir = "content/blog/",
[string]$out,
[Parameter(Position = 0, Mandatory = $true)] [string] $afterCommit
)这段代码定义了:
- 一个名为
-baseDir的可选 命名参数,为字符串类型。这里提供了一个默认值,当没有指定-baseDir时,默认筛选出博客文章的变化文件路径。 - 一个名为
-out的可选 命名参数,为字符串类型。 - 一个必填的 位置参数,该参数名为
afterCommit。如果未在命令行中提供,则 PowerShell 会用这个名字来提示你需要输入的参数。
你可能已经注意到,我们在需求中有通过-verbose控制冗余信息是否打印的要求,但在这里并没有声明。这是因为-Verbose是一个 PowerShell 的保留参数,用来直接控制Write-Verbose是否打印到输出流。如果在这里定义了,反而会导致脚本运行错误(PowerShell 会提示定义了 多个名为Verbose的参数)。
这位问了,这代码每个变量还得声明类型吗?有点儿麻烦啊。
其实不声明类型的写法也是完全可以的,PowerShell 会在运行时进行类型推断;但我还是喜欢用强类型的写法,一则是多年来我自己形成的习惯, 更重要的是,在声明类型后才能充分享受编写 PowerShell 脚本时智能提示的便利。可以看一下下面这个例子:

熟悉 C# 的朋友一定感到非常亲切:这不就是String类型的成员变量么!没有错,写 PowerShell 竟然让我找到了写 C#的快感,这是我始料未及的。
至此为止,我们仅用了三行代码就完成了命令行参数解析和默认值、限制条件等额外功能。真的有点儿爽。下面来处理业务逻辑。
0x3 >> 2 遍历提交记录
在前文中已经得到了用于获取提交记录的git命令,现在是调用它的时候了。我们首先来遍历这条git命令打印的每一行输出信息, 并从中区分提交信息和文件路径。
为此,我们首先定义一个GetDiffList的函数,该函数接受两个参数:$startHash和$relTo。$startHash限制了 提交记录的查询起点,而$relTo则会用来筛选文件路径。
$startHash和$relTo分别对应$afterCommit和$baseDir这两个命令行参数的值。这里将其定义为函数参数, 是为了避免直接调用全局变量导致代码混乱,且便于对函数进行单元测试。
我们先来简单实现一下最基础的逻辑:
function GetDiffList ([string] $startHash, [string] $relTo) {
# 调用 git 命令
[string[]]$lines = git log --name-only --no-merges --dense --format="%n>>%h|%ai|%s" "$startHash..HEAD"
foreach ($line in $lines) {
$line = $line.Trim()
if ([String]::IsNullOrEmpty($line)) {
continue
}
elseif ($line -match ">>([a-z0-9]+?)|(.+?)|(.+)") {
# 正则匹配结果自动保存到内置的 $Matches 变量中
$hash = $Matches[1]
$datetime = $Matches[2]
$subject = $Matches[3]
Write-Verbose "$hash / $datetime / $subject"
}
elseif ($line.StartsWith($baseDir)) {
$relPath = $line.Substring($baseDir.Length)
Write-Verbose $relPath
}
}
}
# 调用函数并传参
GetDiffList $afterCommit $baseDir该函数进行了如下操作:
- 执行
git log命令,并将打印输出到一个名为$logs的字符串数组中; - 遍历
$lines的每一行,裁剪掉每行两头的空白字符,并忽略空行; - 使用正则表达式匹配提交信息的输出行,并从中提取 Hash 值、提交时间和提交说明;
- 若文件路径是相对于
$baseDir的,则截取出相对路径并打印。
值得一提的是,PowerShell 中调用静态方法的方式是[类型]::方法(),比如这里的
[String]:IsNullOrEmpty($line)到目前为止,脚本执行效果如下:

因为代码中是用Write-Verbose来输出的,因此在执行脚本时注意需要添加-Verbose标记。
0x3 >> 3 用类和.NET内置类型组织数据
上面获取到的数据是有嵌套结构的。如果能够在数据操作的过程中保留结构并结构化输出到 JSON,岂不美哉?

上文说过 PowerShell 可以和.NET 库配合得天衣无缝,各种类啊泛型啊什么的更是不在话下。 下面来看看如何配合 PowerShell 强大的类型系统改造我们的脚本,来让数据结构更加合理。
首先来分析一下,最终生成的 JSON 文件所对应的数据结构应该如下:

上图中可以看到,diff.json和Commit都是带内嵌字段的复合结构,而commits和filePaths是两个类似列表/数组的结构。按照这种设计, diff.json和Commit应采用类或哈西表一类的数据结构,而commits和filePaths应使用数组类的可遍历类型。
针对不同的数据结构,我们来用 PowerShell 分别实现一下看看。
### 列表类数据结构
我们需要commits中的元素按提交时间倒序排列,这一点git已经帮忙做好了,可以不用再处理,在此我们直接使用System.Collections.Generic.List<T>类型。
文件路径的添加顺序则不一定按名称升序排列,因此肯定要对该数组进行排序。 另外,由于文件路径不能重复,所以在向filePaths中添加路径时必须要验证新增路径是否已经存在于集合中。
.NET 中有一个数据类型完美契合上述两个需求:System.Collections.Generic.SortedSet<T>,即排序集合。顾名思义,它能够保证集合中的元素不会重复, 且能够根据某种规则将元素排序。
为了让SortedSet可以排序,我们需要在它的构造函数中提供一个比较器对象,比较器的类必须实现System.Collections.Generic.IComparer<T>接口。
相关代码如下:
# 你甚至可以用 using namespace 来简化代码
using namespace System.Collections
using namespace System.Collections.Generic
...
# 这里是我们的比较器,按路径字符串升序排序,且不区分大小写
class FilePathComparer:IComparer[string] {
[CaseInsensitiveComparer]$caseiComp = [CaseInsensitiveComparer]::new()
[int] Compare([string]$a, [string]$b) {
return $this.caseiComp.Compare($a, $b)
}
}
function GetDiffList (...) {
...
# 创建 SortedSet 和 List ,注意这里展示了两种不同的 new 对象的方式
$filePaths = [SortedSet[string]]::new([FilePathComparer]::new())
$commits = New-Object List[Commit]
foreach ($line in $lines) {
$line = $line.Trim()
if ([String]::IsNullOrEmpty($line)) {
continue
}
elseif ($line -match ">>([a-z0-9]+?)|(.+?)|(.+)") {
...
# $commit 对象会在下一节中创建
$commits.Add($commit)
}
elseif ($line.StartsWith($baseDir)) {
$relPath = $line.Substring($baseDir.Length)
Write-Verbose $relPath
# 要用管道操作将命令输出传递给空目标,否则会影响 return 的值
$filePaths.Add($relPath) | Out-Null
}
}
}
...要注意的是,在 PowerShell 脚本中,泛型类型都是用[T]定义的(而非<T>),这一点可能需要习惯一下。
另外,在编写函数的过程中,如果内部有未使用的其他函数的返回值(比如本例中的SortedSet.Add()的返回值), 应将其通过管道操作符 | 传递给 Out-Null 指令,否则会被当作本函数的返回值传递出去。
复合类数据结构
下面用两种不同的方法来实现diff.json和Commit,来展示一下 PowerShell 的简洁与强大。
首先我们用类来实现Commit数据结构。代码非常直白非常简单,和 C# 的类声明没什么大区别。
...
class Commit {
[string] $hash; # Commit Hash
[string] $datetime; # 提交的日期时间,简便起见这里不再转化成专用的数据类型了
[string] $subject; # 提交说明的主题
# Override ToString() 函数,让打印输出更漂亮
[string] ToString() {
return [String]::Format(
# PowerShell中,n, t等写作 `n, `t
"[{0}] {1}`n`t @ {2}",
$this.hash,
$this.subject,
$this.datetime)
}
}
function GetDiffList(...) {
...
}
...要初始化一个Commit类的实例,有两种方法:通过构造函数或直接用Hash 表转换。 在这里我觉得构造函数有点多余了,所以采用第二种方法,即构建一个 Hash 表然后进行类型转换。
通过 PowerShell 的@{}语法,我们能够很轻松的创建一个 Hash 表。看看,是不是有点 TypeScript 内味儿了?
function GetDiffList(...){
...
$commit = [Commit] @{
hash = $Matches[1];
datetime = $Matches[2];
subject = $Matches[3];
}
Write-Verbose $commit
$commits.Add($commit)
...
}接下来我们用 Hash 表来实现GetDiffList函数的返回值,即diff.json的顶层结构。
function GetDiffList(...){
...
return @{
commits = $commits;
filePaths = $filePaths;
}
}到此为止我们已经收集了所有需要的数据,是时候疯狂虚区了!

0x3 >> 4 将数据对象输出为JSON文件
终于到最后一步临门一脚了。
不过这一步实在没有什么可说的。我们既不用做任何结构化解析,也不用调用外部工具,只需要通过 PowerShell 自带的ConvertTo-Json命令以及一系列管道操作符,即可完成数据转换和输出。
...
# 先转成 JSON
$json = GetDiffList $afterCommit $baseDir | ConvertTo-Json
if ([string]::IsNullOrEmpty($out)) {
# 如果是 Dry-Run 或没有指定输出文件,则直接打印在控制台中
Write-Host $json
}
else {
# 否则写到文件里
$json | Out-File $out
}除了 ConvertTo-Json,PowerShell 还有ConvertTo-HTML、ConvertTo-Csv、ConvertTo-Xml等一些列常用转换指令, 以及对应的ConvertFrom-版本。说实话,这一点我是服微软的。
比如我在我的博客仓库中按如下方式执行脚本:
.diff.ps1 -out diff.json 8534dfe55可以得到如下的文件:

0x4 后记
说实话,这篇文章写完以后我都觉得我有点给微软捧臭脚的意思,谄媚之情充溢于字里行间。
但是作为从毕业第一份工作就是 C# ,后来又做了 N 年 Unity 的老刀奶特Old .NET, PowerShell 脚本编程带给我的体验如黑丝般顺滑,以至于我现在就是非常后悔没有早点用 PowerShell 来写 各种脚本。
在写脚本的过程中,我的学习成本几乎为 0,偶尔需要去查一下微软的文档库,剩下的多是类似这样的惊喜时刻:
“哎?这里是不是跟 C#一样也能这么做?我试试……”
“我去还真行?!”
不过咱有一说一,PowerShell 面临的最大问题并不在于提高易用性,抑或如何发展背后强大的技术, 而是如何打破与后端开发者及运维工程师之间的壁垒,去掉脑袋上Windows专属 + 难用 + 丑的标签, 让开发者愿意共同构建 PowerShell 生态。
我很高兴看到 PowerShell 已经可以跨平台了,也在自己的 WSL Ubuntu 环境下安装了 PowerShell 来尝鲜, 但依然无法说服自己在使用 Linux 时选择PowerShell作为 shell 而非oh-my-zsh。这其中各种细节总会多多少少泼点儿冷水, 从自动补全到历史回溯,从工具集成到花花绿绿,PowerShell 真的还有不少路要走。
不过我还是很期待。
我说知乎你好歹也是个创作社区,就不能提供个正常点的编辑器吗?好好做一个支持完整Markdown语法的编辑器有这么难吗?每次发文章我都想死。鉴于知乎的编辑器太屎了,diff1.ps的完整脚本代码我就不贴在这了,感兴趣的朋友可以在我的博客页面查看,也希望能多多指教。
本文介绍如何用PowerShell编写脚本,获取Git提交记录并生成文件Diff列表。脚本解析Git命令输出,使用.NET库处理数据,支持命令行参数,输出结构化的JSON文件。适合有.NET背景的程序员,能在Windows和WSL环境下运行。

被折叠的 条评论
为什么被折叠?



