脚本文件里的 Hybrid Script(混合式脚本)

因为日常需要,经常写一些脚本命令,比如 Windows CMD / PowerShell / bash 之类的。最近学习了一种新的脚本类型:Hybrid script,即混合式脚本,是在一个脚本文件内,同时使用多个语言的语法和对应功能。比如以下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<# : hybrid.bat
@ECHO OFF
if "%~1" == "" goto SELECT
bin\youtube-dl -a "%~1"
goto :EOF

:SELECT
setlocal
for /f "delims=" %%I in ('powershell -noprofile "iex (${%~f0} | out-string)"') do (
bin\youtube-dl -a "%%~I"
)
goto :EOF
:: end Batch portion / begin PowerShell hybrid chimera #>

Add-Type -AssemblyName System.Windows.Forms
$f = new-object Windows.Forms.OpenFileDialog
$f.InitialDirectory = pwd
$f.Filter = "Text Files (*.txt)|*.txt|All Files (*.*)|*.*"
$f.ShowHelp = $true
$f.Multiselect = $true
[void]$f.ShowDialog()
if ($f.Multiselect) { $f.FileNames } else { $f.FileName }

这段代码的功能是调用 youtube-dl.exe,按预先准备的待下载视频列表指示,下载所有视频。有趣的是,如果你通过拖拽的方式,把列表放到脚本文件图标上,则脚本直接开始按列表下载。

如果没有拖拽,而是双击打开脚本文件,则会跳出一个选择文件的 Windows 对话框,让用户选择一个或者多个列表,然后再开始下载。

这是一个用户体验相对比较好的方法。虽然这脚本只是自用,但我自己也是比较烦命令行的。至于任务是下载视频,或者处理音频,亦或删除目录什么的,只是中段执行不同,和本文主旨无关。

这脚本的核心难点,便是『如果没有拖拽列表文件,则打开窗口让用户选』这个需求点。CMD 是没有 Windows 图形界面下的对话框功能的,但 PowerShell 有。因此便有了这个 Hybrid Script 脚本。

脚本前半段是 Batch 代码,输入下载列表文件路径,执行下载。这个『下载列表文件路径』,或者是用户拖拽获得,或者是用户在选择窗口中操作获得。后半段则是 .ps1 代码,用来绘制选择窗口并把列表路径返回 CMD。

但问题在于,两种代码并不兼容。CMD 并不能识别 PowerShell 代码,而 PS 也无法识别 Batch 代码。如果普通的执行对方的代码,一定会报错的。

于是我们看到,上面这段代码里似乎有几行奇怪的代码:

1
2
3
4
5
<# : hybrid.bat
......
goto :EOF
......
:: end Batch portion / begin PowerShell hybrid chimera #>

没错,就是这几行特殊代码,以及另一个非常重要的变量 %~f0,决定了 Hybrid Script 的可行性。

事实上,<# ... #> 是 PowerShell 的注释代码,PS1 执行器遇到它时,会直接忽略两者中间的所有内容,执行后面的代码。而 <# :hybrid.bat 对 CMD 而言是个没有指定目标的左向重定向命令。因此 CMD 确实执行了第一行,但没有任何效果。

对于 PS 的结束注释符号 #>,在 CMD 看来确实有意义。但我们提前用 goto :EOF 直接跳到脚本最末(End-Of-File)的办法,把这一行以及后面的所有代码都跳过了。于是这些代码在 CMD 下的对错就无关紧要了。

也就是说,在 CMD 眼睛里,这段代码其实是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
<# : hybrid.bat
@ECHO OFF
if "%~1" == "" goto SELECT
bin\youtube-dl -a "%~1"
goto :EOF

:SELECT
setlocal
for /f "delims=" %%I in ('powershell -noprofile "iex (${%~f0} | out-string)"') do (
bin\youtube-dl -a "%%~I"
)
goto :EOF

而在 PowerShell 眼里,这段代码是这样的:

1
2
3
4
5
6
7
8
Add-Type -AssemblyName System.Windows.Forms
$f = new-object Windows.Forms.OpenFileDialog
$f.InitialDirectory = pwd
$f.Filter = "Text Files (*.txt)|*.txt|All Files (*.*)|*.*"
$f.ShowHelp = $true
$f.Multiselect = $true
[void]$f.ShowDialog()
if ($f.Multiselect) { $f.FileNames } else { $f.FileName }

以上都明确以后,中间那句

1
powershell -noprofile "iex (${%~f0} | out-string)"

的作用也就容易理解了:Batch 脚本调用了 PowerShell 的执行器,并把这个 hybrid.bat 自己(在 Batch 代码中,%~f0 这个变量就是指脚本文件自己)传递给了 PowerShell。

PowerShell 接到消息以后,又执行了一遍这个 Hybrid.bat。这次是 PS 视角,因此上来就忽略了前面整大段的“注释”,直接从 Add-Type -AssemblyName System.Windows.Forms 这段开始,绘制窗口,等待用户选择,获得列表文件路径,然后再返回给 CMD。CMD 最后再执行 for /f "delims=" %%I in ( ...... ) do ( ...... ) 的部分,并根据 goto :EOF 的指示,跳过剩余代码,避免了在 CMD 环境下的报错。

因此,为了正确执行功能,Hybrid Script 的核心思想是:

1. 利用两种语言的注释符号的不同,隐藏非执行环境下的代码。
2. 灵活应用两种语言的特性,确保任一语言下的注释符号本身,对于另一种语言没有负面效果。
3. 首先执行的语言 A,需要把脚本文件自身的路径,传递给另一种语言 B 的执行器。
4. B 语言的执行器,忽略掉被 B 语言注释符号包裹起来的 A 语言代码,执行 B 的代码,如果需要的话,把执行结果返回给 A。
5. A 继续执行剩下的部分,忽略掉被 A 语言的注释符号包裹起来的 B 语言代码。
6. Hybrid Script 至少会执行两遍,可能更多。

———————————————

充分理解 Hybrid Script 思想以后,我们就知道这并不仅仅限于 Batch 和 PowerShell 脚本的混合了。常用的几种脚本语言都可以实现混合代码。实践中:

1. Windows Batch & Windows PowerShell
2. Windows Batch & Linux Bash
3. Windows Batch & Python
4. Windows Batch & Javascript
5. Linux Bash & Python
6. Linux Bash & Javascript
7. NodeJS & Python
8. …

等等组合都可以写出相应的混合代码脚本。

Hybrid Script 能同时利用两种甚至多种语言的方便特性。并且对于各语言组合,相互注释的『套路』是固定的,几部分代码实际功能如何变化并不影响套路。但其实整体来说适用性不广,一来机器上需要同时有两种语言的运行环境,二来 Python JS C# 等几种主流语言都有完备的功能集和函数库,不需要跨语言写作。三来,即使真的需要两种语言,大部分情况下也可以写成两个脚本互相调 用执行。

只有很少的几种情况下需要考虑 Hybrid Script,包括:

1. 因为传播渠道问题,不适合拆分成多个脚本的。比如互联网上常见的『复制粘贴代码到记事本改后缀为 .bat 然后双击运行』。
2. 代码票友,对 A 语言和 B 语言都不甚精通,或者从 A 转 B 的学习过程中,B 还不甚了解,需要用 A 语言的代码补足。
3. A 语言确实功能有限,但胜在编码方便历史普及率高。而产品虽需要却也只需要 B 的极小功能。比如本文示例。
4. 写着玩。


5. 解决 PowerShell 无法正确处理文件路径带空格的 BUG,参见:

Hybrid Script 附一则:解决 PowerShell 无法双击打开路径含空格文件的 Bug