我在写go服务程序,而且运行在Windows上,为了方便启停服务,我决定写一个脚本来管理服务。由于运行在Windows上,所以选择bat批处理脚本。
首先我们需要确定一下脚本的结构。我们需要4个参数,用来启动、重启、停止应用以及查看应用状态,目标如下:
run.bat start
启动应用run.bat restart
重启应用run.bat stop
停止应用run.bat status
查看应用状态
OK,非常简单,我们通过 if
来实现这些命令:
@echo offif "%1" == "start" (echo starting server....
) ^
else if "%1" == "restart" (echo restarting server...
) ^
else if "%1" == "stop" (echo stopping server...
) ^
else if "%1" == "status" (echo server status...
) ^
else (echo "USAGE: run.bat [start|restart|stop|status]"
)
这就是我们的第一个版本,只是打印出一下信息,并无实际功能,不用担心,我们稍后会一步步把所有功能都添加进来。
现在先让我们来看看上面的脚本,首先第一行通过 @echo off
关闭命令回显,常规操作。注意在 if
的条件那里我们给等号两边的表达式都加上了双引号,这是必须的,如果你写成 if %1 == start
将不会有任何一个分支能匹配上。当然,除了双引号,你也可以使用方括号,写成 if [%1] == [start]
,这样也能正确匹配。不过我更喜欢双引号。至于 ^ 只是为了能够换行书写 else 分支,让脚本更好看一点而已。
在最后一个 else
分支我们通过 echo
打印命令用法,这里的双引号也是必须的,因为据我尝试,在bat脚本中,方括号似乎有着某种神奇魔力。
这四个功能并不复杂,相信你已经迫不及待想要去实现他们了。但是,请等一等,我们希望这个脚本能更“好”。
举几个例子,比如我们希望在启动应用之前能先判断应用是否正在运行,而不是直接运行程序。再比如再重启时,先判断应用是否正在运行,是的话先停止它,然后再启动应用。停止应用时也可以做类似的判断。所有我们要先解决一个问题:如何判断程序的运行状态?
我的服务在启动后会将进程ID写入一个叫 pid
的文件中,所以我就直接用这个文件了。当然,获取进程PID并写入文件也可以通过脚本来实现,只是我的服务已经写入PID了,所以我的脚本就直接读取它了,感兴趣的朋友可以尝试自己实现通过脚本维护PID。
好了,废话讲完了,接下来要动真格了。
首先我需要读取PID文件中记录的进程ID,Windows里面有 type
命令可以用来读取文件内容,但是我们不能直接写 type pid
,因为它会直接将结果输出到控制台,我们无法获取到命令的结果。在bat脚本中要获取命令的结果只能用 for
指令,虽然有点离谱,但几经搜索也只有这一个办法。
获取PID的脚本如下:
for /F %%i in ('type pid') do (set pid=%%i)
我们稍微解释一下上面的命令。 in
后面的括号中的是要执行的命令,多条命名通过逗号分隔。这里是 type pid
,也就是读取pid文件。 %%i
是迭代变量,也就是每条命令的执行结果。 do
后面的括号是每次迭代所要执行的命令,这里我们将迭代结果赋值给 pid
变量。要格外注意的是 for
后面的 /F
指令,它也是必须的,在 /F
的加持下, in
后面的括号中的内容才会被当做命令执行,否则的话,括号中的内容会被拆分为token去迭代。比如,这里如果不加 /F
,那么上面的脚本会循环两次,第一次 %%i
的值是 'type
,第二次 %%i
的值是 pid'
。
OK,有了pid,接下来我们就可以通过 tasklist
命令来查询进程了。命令如下:
tasklist /fi "PID eq 12345"
/fi
是过滤选项,过滤出指定PID的进程,有关该命令的详细参数可以参考微软官方文档。 tasklist
输出如下:
映像名称 PID 会话名 会话# 内存使用
========================= ======== ================ =========== ============
platform.exe 4684 Console 1 17,092 K
毫无疑问我们任然需要用到 for
指令来提取关键信息进行比较, /F
选项不仅可以用来执行指令,还可以用来对结果进行过滤,这里我们希望提取第4行(注意第一行有空行),第二列的内容,也就是进程ID,和我们之前从pid文件中获取的进程id进行比较,来判断进程是否存在。
for /F "skip=3 tokens=2" %%i in ('tasklist /fi "PID eq %pid%"') do (if %%i==%pid% set isrunning=yes
)
注意 /F
后面多了这样一段内容 "skip=3 tokens=2"
, skip
表示跳过前多少行, tokens
表示取第几列。因为我们的目标是第4行,所在跳过前3行。这里我们只取第2列,如果你想把进程名称也提取出来做一个比较,可以写成 tokens=1-2
或者 tokens=1,2
,但是注意,这样的话 do
后面的 %%i
就变成了进程名,而进程id需要用 %%j
来访问,这是两列的情况,如果还有第三列,那么就是 %%k
,依次类推。关于bat中 for
指令的详细语法,可以自行搜索学习,这里就不再深入了。
很好,现在我们可以通过 %isrunning%
来判断进程是否存在了,让我们将他们添加到脚本中。
@echo off:: read server pid
for /F %%i in ('type pid') do (set pid=%%i)
:: check process status
for /F "skip=3 tokens=2" %%i in ('tasklist /fi "PID eq %pid%"') do (if %%i==%pid% set isrunning=yes
)if "%1" == "start" (if "%isrunning%" == "yes" (echo server is running) else (echo starting server...echo do-starting)exit /b 0
) ^
else if "%1" == "restart" (if "%isrunning%" == "yes" (echo stopping server...taskkill /f /pid %pid%)echo restarting server...echo do-startingexit /b 0
) ^
else if "%1" == "stop" (if "%isrunning%" == "yes" (echo stopping server...taskkill /f /pid %pid%) else (echo server isnot running)exit /b 0
) ^
else if "%1" == "status" (echo server status...if "%isrunning%" == "yes" (echo running) else (echo gone)exit /b 0
) ^
else (echo "USAGE: run.bat [start|restart|stop|status]"exit /b 0
)
增加了不少内容,停止应用和查看状态已经完成了,但是启动应用还没做,只是用 echo do-starting
替代了。你可能会疑惑,启动应用难道不是最简单的功能吗?只需要调用下程序就可以了。
但问题是我们希望程序能在独立的进程中运行,并且不带命令行窗口,不能随着命令行的关闭而结束,因为我的程序是一个网络服务,它需要一直运行。这在Linux中的确很容易,但是在Windows中却不那么直接。
要在Windows中实现这一点我么需要使用powershell脚本的 Start-Process
函数。详细说明可参考官方文档。命令如下:
powershell.exe -command "& {Start-Process -WindowStyle Hidden -WorkingDirectory '%~dp0' -FilePath 'platform.exe' -RedirectStandardOutput logs\stdout.log}"
-WindowStyle Hidden
选项让我们可以在不显示命令行窗口的情况下启动进程,这正是我们想要的。 -FilePath
是指定需要运行的程序。其他参数也都是字面含义。需要注意的是 -RedirectStandardError
和 -RedirectStandardOutput
不能指向同一个文件,这里我只重定向了标准输出,如果你需要同时重定向标准输出和标准错误,需要注意这一点。
因为启动和重启应用都需要用到这段代码,并且应用启动后我们还需要再次检查进程状态,因为程序不一定能启动成功。所以我将真正启动程序以及检查状态的代码提取到了脚本的最后,而在 if
那里通过 goto
指令跳转到启动程序的地方即可。还有一点就是程序名称可以放到一个变量里,这样方便我们修改。
在脚本的最后添加下面的代码:
:do-starting
powershell.exe -command "& {Start-Process -WindowStyle Hidden -WorkingDirectory '%~dp0' -FilePath '%server%' -RedirectStandardOutput logs\stdout.log}"
timeout /T 1 /NOBREAK > nul
for /F %%i in ('type pid') do (set pid=%%i)
for /F "skip=3 tokens=2" %%i in ('tasklist /fi "PID eq %pid%"') do (if %%i==%pid% set isrunning=yes
)
if "%isrunning%" == "yes" (echo success) else (echo failed)
exit /b 0
注意在启动程序之后,我使用 timeout
命令延时了1秒钟才去检查进程状态,目的是留出足够的时间让程序启动并更新pid文件,如果你的程序启动很慢,可以适当延长这里的延时。最后别忘了将所有 echo do-starting
替换成 goto do-starting
。
大功告成,最终我们的脚本长这样:
@echo offset server=platform.exe
:: read server pid
for /F %%i in ('type pid') do (set pid=%%i)
:: check process status
for /F "skip=3 tokens=2" %%i in ('tasklist /fi "PID eq %pid%"') do (if %%i==%pid% set isrunning=yes
)if "%1" == "start" (if "%isrunning%" == "yes" (echo server is running) else (echo starting server...goto do-starting)exit /b 0
) ^
else if "%1" == "restart" (if "%isrunning%" == "yes" (echo stopping server...taskkill /f /pid %pid%)echo restarting server...goto do-startingexit /b 0
) ^
else if "%1" == "stop" (if "%isrunning%" == "yes" (echo stopping server...taskkill /f /pid %pid%) else (echo server isnot running)exit /b 0
) ^
else if "%1" == "status" (echo server status...if "%isrunning%" == "yes" (echo running) else (echo gone)exit /b 0
) ^
else (echo "USAGE: run.bat [start|restart|stop|status]"exit /b 0
)
exit:do-starting
powershell.exe -command "& {Start-Process -WindowStyle Hidden -WorkingDirectory '%~dp0' -FilePath '%server%' -RedirectStandardOutput logs\stdout.log}"
timeout /T 1 /NOBREAK > nul
for /F %%i in ('type pid') do (set pid=%%i)
for /F "skip=3 tokens=2" %%i in ('tasklist /fi "PID eq %pid%"') do (if %%i==%pid% set isrunning=yes
)
if "%isrunning%" == "yes" (echo success) else (echo failed)
exit /b 0
好了,预期功能都完成了,来唠点闲嗑。
首先是关于注释的,在bat中,有两种注释 ::
和 @REM
,我发现在 if
的括号内部,不能使用 ::
这种注释,只能使用 @REM
注释。这个问题在调试的时候困扰了我好久。
其次是关于如何在Windows中不带窗口的在后台启动进程的。上面用powershell的 Start-Process
函数只是其中一种方案,因为逻辑比较直接,所以选择了这个方案。
另一种方案是使用vbs脚本来启动进程。有一种是写两个脚本,用vbs脚本去运行bat脚本。写两个脚本毕竟是不方便的,当然也有写道一个脚本里的方法。这里我也给出这个版本的写法:
@echo offif "%2" == "" goto begin:hide
if "%2" == "h" goto %1
mshta vbscript:CreateObject("WScript.Shell").Run("%~0 starting h",0)(window.close)&&exit
:beginset server=platform.exe
:: read server pid
for /F %%i in ('type pid') do (set pid=%%i)
:: check process status
for /F "skip=3 tokens=2" %%i in ('tasklist /fi "PID eq %pid%"') do (if %%i==%pid% set isrunning=yes
)if "%1" == "start" (if "%isrunning%" == "yes" (echo server is runningexit /b 0)echo starting server...goto hideexit /b 0
) ^
else if "%1" == "restart" (if "%isrunning%" == "yes" (echo stopping server...taskkill /f /pid %pid%)echo restarting server...goto hideexit /b 0
) ^
else if "%1" == "stop" (if "%isrunning%" == "yes" (echo stopping server...taskkill /f /pid %pid%) else (echo server isnot running)exit /b 0
) ^
else if "%1" == "status" (echo server status...if "%isrunning%" == "yes" (echo running) else (echo gone)exit /b 0
) ^
else (echo "USAGE: run.bat [start|restart|stop|status]"exit /b 0
)
exit:starting
"%server%"
大体架构差不多,但是逻辑却有点绕,这里稍作解释。
首先是正常调用,只有一个参数,此时会跳过vbs脚本直接到达 :begin
开始执行,对于停止和查看状态来说并无区别。
如果 start
需要启动程序,那么就会跳转到 :hide
开始执行,此时 if
条件并不满足,于是开始执行vbscript脚本。创建一个 WScript.Shell
对象并运行一个命令,关键就在于它运行的这个命令。
%~0
表示扩展命令的第一个参数,实际上就是 run.bat
,也就是vbscript实际上运行的是 run.bat starting h
,这不就是bat脚本本身吗?
你猜的没错,我们的bat脚本调用vsscript又运行了它自己,就是这么神奇,只不过这次的参数被vbscript改写了,于是进入到紧跟 :hide
的那个 if
分支,跳转到了最后的 :starting
标签,运行我们的程序。注意 Run
函数的第二个参数 0
,表示不显示窗口。
最后还有一点忠告是不要使用 start
命令来启动程序,否则一定会有一个窗口出现,这绝对是一个坑。