As far as developer tools go, Microsoft gets a bad rep. There's a long list of reasons tools built by the big M get clowned on but it's hard to deny obvious success stories like VSCode among others - however few.
One tool I think is actually good but not talked about enough is powershell. If you use a windows machine you've
probably come across it - yep, it's that shell that shows up in your VSCode integrated terminal before you adjust
your settings to use bash instead - well, maybe you shouldn't.
This isn't a dump bash and pick up powershell exposition, I use both (you will too) - this is a give powershell
a chance appeal.
What exactly is powershell? Well, it's a command-line shell and it's also a scripting language - just like bash is. While both have a lot of similarities, the fundamental difference between both technologies lie in their approach to handling data. In bash, the output of a command is a text stream; with powershell however, the output of a command is a stream of .NET objects.
What I intend to do with this article is share one of the ways I use powershell in my local development process.
Everyone who has done any work with javascript is familiar with nodemon. It is a dev tool that watches files and
restarts your program when a change is detected. When I started writing golang, I needed golang's nodemon. I didn't find air (go's nodemon alternative) in time so I wrote a simple powershell script I still use till this day.
Here's how I did it:
In your powershell terminal, create a directory.
# create a directory called watcher
mkdir watcher
Fun fact, "mkdir" is a bash command. The underlying cmdlet (as they are called in powershell) for creating directories is New-Item. New-Item is a cmdlet that takes a few arguments. In true powershell fashion the proper way to create a directory from your terminal is:
New-Item -Path watcher -Item-Type directory
But that's too verbose and so the wonderful folks that gave us powershell decided to provide bash-like aliases to quite a number of powershell cmdlet. When you see what looks like a bash command in powershell it really is an alias for a powershell cmdlet.
Now that we have a directory to work with, "cd" into it and create few files:
# go into watcher
cd watcher
# create a go file
ni main.go
# create a powershell script
ni watcher.ps1
Before we continue, you might need to adjust the execution policy option in your terminal. This is important because the wrong option can stop your script from executing.
# check execution policy
Get-ExecutionPolicy
# if response is RemoteSigned you're fine
# if it isn't set execution policy
Set-ExecutionPolicy RemoteSigned
In your go file write some code - that prints something to the terminal. This isn't an article about go, you can replicate the same process for any other language.
package main
import "fmt"
func main() {
// prints "Hello World to the terminal"
fmt.Println("Hello World")
}
Before we start writing our script, let's go over what we are trying to build. We want to write a script that continuously watches go files and restarts our program whenever a changed is made to any of the files being watched. We will do this
by tracking the hash of each file. In simple terms, if a file changes, so will its hash; when the hash change, we re-run the program. It's that simple.
# go file path
$path = "main.go"
# write welcome message
Write-Host 'GoWatcher *Press CTRL+C to quit*'
# current directory
$dirname = "."
# recursively get all files in the current directory as well as nested directories
$files = Get-ChildItem $dirname -Filter "*.go" -Recurse
# run go program - initial run
go run $path
# get file hash, sort them and parse the result as string
$signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
# start a while loop to continuously track file changes
while($true) {
# get file hash, sort them and parse the result as string again - for comparison (will be different if there's been a change)
$current_signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
# check for a change
if ($signature -ne $current_signature) {
Write-Host 'File change detected: Restarting...'
# re-run go program
go run $path
# swap new signature for old one
$signature = $current_signature
}
# sleep for 300 milliseconds
Start-Sleep -Milliseconds 300
}
Here we have a powershell script that tracks all go files in a directory starts the program and re-runs it when a change is made to any go file.
A small adjustment we can make to the script is - pass the path value via the terminal as a flag. That's quite easy to do - just add the following to the top the file.
[CmdletBinding()]
Param(
[Parameter(Mandatory)]
$path
)
# go file path
# $path = "main.go"
In your terminal you can run the script file by typing the script name ("./watcher.ps1") followed by the path argument
./watcher.ps1 -path 'main.go'
We aren't quite done. If the go app performs a blocking/long-running operation, our watcher script will freeze. An example of a blocking operation is a web server.
Now let's adjust out script accordingly.
Just before we begin, I'd like us to take a detour and talk a little bit about jobs in powershell. They are a way to run scripts or commands asynchronously. They are perfect for allowing you perform long running processes in the background of your current session. They are quite easy to start:
# start a background job
Start-Job -Name GoServer -ScriptBlock {
# perform long running task here
}
# stop a background job
Stop-Job -Name GoServer
# remove a background job
Remove-Job -Name GoServer
# receive response from a background job
Receive-Job -Name GoServer
# get a job
Get-Job -Name GoServer
Now that we've gone over some powershell job specific commands, let's write a script that will fix the issue we have with the first script.
[CmdletBinding()]
Param(
[Parameter(Mandatory)]
$path
)
# write welcome message
Write-Host 'GoWatcher *Press CTRL+C to quit*'
# current directory
$dirname = "."
# job name
$jobname = "GoServer"
# binary name
$binary = "GoApp"
# create a function that starts a job
function Start-ServerJob {
# quietly stop previously running job
# important because our watcher restarts when a file changes
Get-Job -Name $jobname -ErrorAction SilentlyContinue | Remove-Job -Force
# start new job
# use the argument list parameter to pass values to the job
$job = Start-Job -Name $jobname -ScriptBlock {
param($path, $dirname)
# set location
Set-Location $dirname
# build go script to avoid that annoying popup that shows up any time we run a go server
go run build -o "bin/$binary.exe" $path
# run binary
Invoke-Expression "./bin/$binary.exe"
} -ArgumentList $path, $dirname
return $job
}
# recursively get all files in the current directory as well as nested directories
$files = Get-ChildItem $dirname -Filter "*.go" -Recurse
# start job - initial run
$job = Start-ServerJob
# get file hash, sort them and parse the result as string
$signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
# run a while loop in a try-finally block because we want to perform clean-up when the watcher is terminated
try {
while($true) {
# get file hash, sort them and parse the result as string again - for comparison (will be different if there's been a change)
$current_signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
# check for a change
if ($signature -ne $current_signature) {
Write-Host 'File change detected: Restarting...'
# re-run the job
$job = Start-ServerJob
# swap new signature for old one
$signature = $current_signature
}
}
}
# clean-up - release resources locked up by job
finally {
# quietly stop previously running job
# important because our watcher restarts when a file changes
Get-Job -Name $jobname -ErrorAction SilentlyContinue | Remove-Job -Force
# stop process if running
if (Get-Process -Name $binary -ErrorAction SilentlyContinue) {
Stop-Process -Name $binary -Force
}
}
Just like that, we have a script that can watch a long running process. We can run a go web server and our script
will watch for changes.
There is a pesky little issue though, since we have the go server running as a background job, we do not get any logs.
We can fix this easily with the Receive-Job cmdlet
# in the while loop, after the signature check, add the following if statement
if ($job -and $job.HasMoreData) {
# collect job data
$logs = Receive-Job -Job $job
# loop over job data and log to the console
foreach($log in $logs) {
Write-Host $log
}
}
Problem fixed. Now let's combine the logic from both scripts into one:
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Path,
[Parameter()]
[ValidateSet('Basic', 'Server')]
$Mode = 'Basic'
)
$Jobname = "GoApp"
$BinaryName = "goapp"
$Dirname = "."
Write-Host 'GoWatcher ' -Foregroundcolor green -NoNewLine
Write-Host '*Press CTRL+C to terminate*' -Foregroundcolor red
Write-Host '**************************************' -Foregroundcolor green
function Run-BasicMode {
go run $Path
$files = Get-ChildItem -Path "." -Filter "*.go" -Recurse
$signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
while ($true) {
$current_signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
if ($signature -ne $current_signature) {
Write-Host 'File change detected: Restarting...' -Foregroundcolor yellow
go run $Path
$signature = $current_signature
}
Start-Sleep -Milliseconds 100
}
}
function Start-ServerJob {
Get-Job -Name $Jobname -ErrorAction SilentlyContinue | Remove-Job -Force
$job = Start-Job -Name $Jobname -ScriptBlock {
param($dirname, $path)
Set-Location $dirname
go build -o "bin/$BinaryName.exe" $path
Invoke-Expression "./bin/goapp.exe"
} -ArgumentList $Dirname, $Path
return $job
}
function Run-ServerMode {
$job = Start-ServerJob
$files = Get-ChildItem . -Filter '*.go' -Recurse
$signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
try {
while($true) {
$current_signature = $files | Get-FileHash -ErrorAction SilentlyContinue | Sort-Object Path | Out-String
if ($signature -ne $current_signature) {
Write-Host 'File change detected: Restarting...' -Foregroundcolor yellow
$job = Start-ServerJob
$signature = $current_signature
}
if ($job -and $job.HasMoreData) {
$logs = Receive-Job -Job $job
foreach ($log in $logs) {
Write-Host $log
}
}
Start-Sleep -Milliseconds 300
}
}
finally {
Get-Job -Name $Jobname -ErrorAction SilentlyContinue | Remove-Job -Force
if (Get-Process -Name $BinaryName -ErrorAction SilentlyContinue) {
Stop-Process -Name $BinaryName -Force
}
}
}
switch ($Mode) {
'Basic' { Run-BasicMode }
'Server' { Run-ServerMode }
}
With the logic for both operations combined, we can now run the script with the appropriate flags depending on your need
# run the script in basic mode
./watcher.ps1 -Path main.go -Mode Basic
#run the script in server mode
./watcher.ps1 -Path main.go -Mode Server
To avoid the need to copy the script into every project we can add an alias to $PROFILE. On a windows machine,
$PROFILE is the path to the current user's powershell profile script. It loads every time powershell is opened.
# Set-Alias **add name** **add path to watcher script**
Set-Alias watcher 'C:/Users/Documents/scripts/watcher.ps1'
Now you can run your go programs with this script from any directory in powershell by running the command:
# for basic mode
watcher -Path main.go -Mode Basic
# for server mode
watcher -Path main.go -Mode Server
Top comments (0)