tech.guitarrapc.cóm

Technical updates

PowerShellでマルチプラットフォームに動くモジュールの作成と継続的なテスト

この記事は、PowerShell Advent Calendar 2018 の 22日目です。

qiita.com

今年は、PowerShell Coreについて本を書いたのですが、その中で書ききれなかった.NET Core と .NET Framework の両方で動くPowerShellモジュールの実用的なサンプルです。

目次

目的

このサンプルを作った目的は、単純に自分が書いていて自分のために用意した環境が汎用的に使えるものだったのでオープンにしただけです。(正直)

ただ、その背景にはいくつか動機があります。

  • 最近.NET は .NET Coreでしか作らないこと
  • PowerShell モジュールを作ろうという機会があった
  • CIで継続的に実行可否をテストしたい
  • ビルド環境につかれたのでDockerでビルドしたい

これらの例は本で提供するには紙面の見せ方も難しく、あの本でやるにはスコープを外していました。 今回の記事はそういう意味では本の補足記事になります。

Utf8BomHeader

非常にこじんまりとしたモジュールを作りました。実装は Utf8BomHeader.psm1 ファイル1つ、PowerShell Gallery で公開するためにUtf8BomHeader.psd1 を一つ追加しただけです。

github.com

このモジュールはファイルのUtf8Bomを扱うだけのもので、Windows 10、MacOS (mojave), ubuntu上で動作を確認しています。PowerShell 5.1以上、PowerShell Core 6.0 以上で動作します。

動機

Windows と Linux の相互対応、あるいはWindowsでクロスプラットフォームで動くものを動かす時、そのどちらでも課題になるのがファイルのエンコーディングです。 特に、Utf8 の BOMはWindowsでBOMが意図せずつくことがあったりして厄介度が上がっているように思います。

皆さんも Docker for Windows でDockerにファイルをmountで渡したときにファイルエンコーディングで怒られた経験をお持ちなのではないでしょうか? あるいは、GoやPythonでクロスプラットフォームに動く前提で作られたものが求めるファイルエンコーディングも通常は Utf8noBomです。

モジュールができること

このモジュールが提供するのは4機能だけです。

  • Test : utf8bom の有無をテストします。BOMがあれば trueです
  • Get : ファイルを先頭から読んでバイナリを表示します。デフォルトは3バイトです(BOM分)
  • Remove : ファイルのBOMを削除します
  • Add : ファイルにBOMを追加します
  • Coimpare : 2ファイル間でそれぞれのBOM結果を出します

実装について

少し実装について触れましょう。 当初PowerShell のみで書いていたのですが、ファイルをストリームで読むためのDisposeのために関数用意するのも冗長になるのも嫌です。 サンプルとして、Test-Utf8BomHeade のPowerShell 版は次のようにFileStream を開いてヘッダを読むことになります。

gist.github.com

本でも書きましたが、リソースの自動破棄は簡単な Using-Dispose 当りの糖衣関数を書いてもいいのですが、あまりスクリプトブロック内部で例外を起こしたくもありません。

そのため、今回のモジュールはインラインで定義した C# で実装を書いて、PowerShell はAdd-Typeでオンザフライにコンパイルしています。

https://github.com/guitarrapc/Utf8BomHeader/blob/94af0c001495a9a49a6bce53f207f5df09cf54a5/src/Utf8BomHeader.psm1#L1-L104

C# はNET Coreで動作することだけ気を付ければたいがい問題ありません。読み込んだC# クラスをPowreShell から呼び出すようにすると先ほどの実装が次のようになります。

gist.github.com

やりたいことだけ、という感じでシンプルになります。 さて、インラインでC#コードを文字列で書くのはよみにくいですね? 私も同意です、PowerShell の外に C# コードを定義したファイルはGitHub上でコードハイライトが利いたり、VS Codeで単品で動かせるメリットがあります。しかしビルド、展開、利用を考えるとファイルは少ない方が取り扱いが圧倒的に楽なため、インラインに定義できるならそうするのをオススメします。

今回はC# で実装を書きましたが、もちろんPowerShell で書くのほうが楽なことも多いです。気を付けるといいのはPowerShell Core と Windows PowerShellで両方動くようにするための抽象化は余り頑張らないようにした方が「書くのが苦しくない」ということです。いちいち互換性を気を付けることも、互換性の抽象化レイヤーを書くのもどちらもやりたいことではありません。私は苦しいのが嫌なので楽をするためにもC# で書きました。C# なら言語レベルで .NET Framework / .NET Core で気を付けることはかなり少ないので選択したということです。

CI

今回はCIとして Appveyor を用いました。 2つのパターンでビルドを用意しました。

  • appveyor 提供の ubuntu18.04 での .NET Coreを使ったビルド
  • Microsoft/PowerShell 提供のDockerを使ったビルド

自前のDockerイメージを使うとどこのCIという縛りはありません。

PowrShell でコンテナを使ったビルド? 大げさでしょうか?ビルド、テストにコンテナを使うことで、普通のプログラミング言語のビルドと同様にビルド環境を選ばなくなります。自分のコードの実行を各種環境で検証することも容易になり、ビルドしたらもうその環境はクリーンなDisposableが担保されます。いろんな環境への可搬性を考慮するとコンテナを用いないという選択はないでしょう。

Appveyor 提供のビルドイメージを使ったビルド

さて、AppVeyor はビルド環境イメージが定義されています。

www.appveyor.com

このイメージにはあらかじめ開発ツールがインストールされていおり、PowerShell Core も ubuntuイメージに含まれています。

www.appveyor.com

仮にappveyor提供のubuntuイメージでやる場合、これぐらいのカジュアルな内容でいけます。 今回は、cd を使っているあたりなんというかそういう感じって感じです。

gist.github.com

では独自のdockerイメージではどうなるでしょうか?

独自Dockerイメージを用いた場合のビルド

Dockerイメージは、Microsoft/PowerShell 提供のmcr.microsoft.com/powershell:6.1.0-ubuntu-18.04を使います。Pesterだけ入れて、プロジェクトを突っ込みましょう。

gist.github.com

appveyor.yml は次のようになります。

gist.github.com

先ほどのAppveyor提供のubuntuイメージくらべると、各種コマンド部分がそのままDockerコンテナでの実行に代わっただけです。こういった簡便な違いで済む割に、コンテナ内部の依存性はDockerfileで統一されるので敷居は低いでしょう。

ただし、mcr.microsoft.com/powershell:6.1.0-ubuntu-18.04 で1つ気になるのが、dotnet が叩けず、Publish-Moduleが実行できないことです。 Artifactなので外に出せばいいので別にいいというわけにもいかない場合があるのでちょっとどうしようかという感じです。

Appveyor のdockerを用いる場合の注意

AppveyorでDocker Imageをビルドする時は、services定義とbefore_build を使うのがプラクティスになっています。dockerは、linux image でdockerサービスの起動をかけることで利用できるので、servicesセクションを appveyor.yml 冒頭に定義しておきます。

services:
- docker

install: よりも後でしかservice startさればいため、docker buildbeffore_build:あたりでします。この実行タイミングはドキュメントになくてここで説明されています。

Build failed: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? / Problems / Discussion Area - AppVeyor Support

テスト

テストは、PowerShell の標準的なテストツールである Pester を用いたユニットテストとしました。 ここまで小さい機能だと、ユニットテストが使うシーンそのままなのでシナリオテストにもなります。

Utf8BomHeader/Utf8BomHeader.Tests.ps1 at master · guitarrapc/Utf8BomHeader · GitHub

重要なのはテストを書くことではなく、継続的にテストが回ることです。 テストはCIと一緒にやることで初めてスタート地点に立つのはPowerShell モジュールでも変わりません。

なお、テストカバレッジは Peseterで出せるので、適当にこんなコードでREADME.md を書き換えたりもできます。

gist.github.com

あとは適当に remote push しましょう。

environment:
  access_token:
    secure: zYCOwcOlgTzvbD0CjJRDNQ==

on_success:
  - git config --global credential.helper store
  - ps: Add-Content "$HOME\.git-credentials" "https://$($env:access_token):x-oauth-basic@github.com`n"
  - git config --global user.email "Your email"
  - git config --global user.name "Your Name"
  - git commit ...
  - git push ...

成果物の取得

ビルドして生成された成果物は Artifacts でアップロードしてあげればいいでしょう。

https://ci.appveyor.com/project/guitarrapc/utf8bomheader/branch/master/artifacts

ビルド結果

ログがすぐに吹き飛ぶのでキャプチャで。

Build started
git clone -q --branch=master https://github.com/guitarrapc/Utf8BomHeader.git /home/appveyor/projects/utf8bomheader
git checkout -qf ecf403c5fa0975688503c2c74a15a4837ed62e93
Running "install" scripts
pwsh --version
PowerShell 6.1.1
Starting 'services'
Starting Docker
Running "before_build" scripts
docker build -t utf8bomheader_build:latest .
Sending build context to Docker daemon  175.6kB
Step 1/5 : FROM mcr.microsoft.com/powershell:6.1.0-ubuntu-18.04
6.1.0-ubuntu-18.04: Pulling from powershell
124c757242f8: Pulling fs layer 
Status: Downloaded newer image for mcr.microsoft.com/powershell:6.1.0-ubuntu-18.04
 ---> bcea40fb0698
Step 2/5 : WORKDIR /app
 ---> Running in e03532abc50f
Removing intermediate container e03532abc50f
 ---> 40e5cd66d690
Step 3/5 : COPY . .
 ---> a391219f8b2e
Step 4/5 : RUN pwsh -c Install-Module Pester -Scope CurrentUser -Force
 ---> Running in 20dab6bbeaf2
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 Installing package 'Pester'                                                        Downloaded 0.00 MB out of 0.69 MB.                                              [                                                                    ]                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Removing intermediate container 20dab6bbeaf2
 ---> 8bee724ae30b
Step 5/5 : ENTRYPOINT [ "pwsh", "-c" ]
 ---> Running in a365d19ff2e5
Removing intermediate container a365d19ff2e5
 ---> 138aa32dca7b
Successfully built 138aa32dca7b
Successfully tagged utf8bomheader_build:latest
docker image ls
REPOSITORY                     TAG                  IMAGE ID            CREATED                  SIZE
utf8bomheader_build            latest               138aa32dca7b        Less than a second ago   369MB
mcr.microsoft.com/powershell   6.1.0-ubuntu-18.04   bcea40fb0698        2 months ago             367MB
Running "build_script" scripts
docker run --rm -v "${APPVEYOR_BUILD_FOLDER}/publish:/app/publish" utf8bomheader_build:latest "./build_psd1.ps1 -Version ${APPVEYOR_BUILD_VERSION}"
    Directory: /app/publish
Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----         12/22/18   7:16 PM                Utf8BomHeader
7z a "${APPVEYOR_BUILD_FOLDER}/publish/Utf8BomHeader_${APPVEYOR_BUILD_VERSION}.zip" "${APPVEYOR_BUILD_FOLDER}/publish/Utf8BomHeader/"
7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=C.UTF-8,Utf16=on,HugeFiles=on,64 bits,2 CPUs Intel(R) Xeon(R) CPU @ 2.30GHz (306F0),ASM,AES-NI)
Scanning the drive:
  0M Scan  /home/appveyor/projects/utf8bomheader/publish/                                                         1 folder, 4 files, 17125 bytes (17 KiB)
Creating archive: /home/appveyor/projects/utf8bomheader/publish/Utf8BomHeader_1.0.69.zip
Items to compress: 5
  0%    
Files read from disk: 4
Archive size: 5053 bytes (5 KiB)
Everything is Ok
Running "test_script" scripts
docker run --rm utf8bomheader_build:latest "Invoke-Pester -CodeCoverage src/Utf8BomHeader.psm1"
Executing all tests in '.'
Executing script /app/tests/Utf8BomHeader.Tests.ps1
  Describing Utf8BomHeader
    [+] Test should be pass for bom 1.2s
    [+] Test should be fail for bomless 75ms
    [+] Add should append BOM 45ms
    [+] Add should not operate when BOM already exists 44ms
    [+] Add -Force should append BOM even already exists 26ms
    [+] Remove should not contains BOM 33ms
    [+] Remove should not operate when BOM not exists 20ms
    [+] Remove -Force should remove BOM even already not exists 38ms
    [+] Compare should return == when both contains BOM 25ms
    [+] Compare should return <= when reference not contains BOM 28ms
    [+] Compare should return => when difference not contains BOM 16ms
    [+] Compare should return <> when reference & difference not contains BOM 17ms
Tests completed in 1.58s
Tests Passed: 12, Failed: 0, Skipped: 0, Pending: 0, Inconclusive: 0 
Code coverage report:
Covered 44.62% of 65 analyzed Commands in 1 File.
Missed commands:
File               Function             Line Command
----               --------             ---- -------
Utf8BomHeader.psm1 Add-Utf8BomHeader     130 if (!$Force) {...
Utf8BomHeader.psm1 Add-Utf8BomHeader     131 $header = [FileHeader]::Read($F...
Utf8BomHeader.psm1 Add-Utf8BomHeader     132 if ($header -eq $bomHex) {...
Utf8BomHeader.psm1 Add-Utf8BomHeader     133 Write-Verbose "Bom already exis...
Utf8BomHeader.psm1 Add-Utf8BomHeader     137 Write-Verbose "-Force paramter ...
Utf8BomHeader.psm1 Add-Utf8BomHeader     139 [FileHeader]::Write($File, $Out...
Utf8BomHeader.psm1 Remove-Utf8BomHeader  175 if (!$Force) {...
Utf8BomHeader.psm1 Remove-Utf8BomHeader  176 $header = [FileHeader]::Read($F...
Utf8BomHeader.psm1 Remove-Utf8BomHeader  177 if ($header -ne $bomHex) {...
Utf8BomHeader.psm1 Remove-Utf8BomHeader  178 Write-Verbose "Bom already miss...
Utf8BomHeader.psm1 Remove-Utf8BomHeader  182 Write-Verbose "-Force paramter ...
Utf8BomHeader.psm1 Remove-Utf8BomHeader  184 [FileHeader]::Remove($File, $Ou...
Utf8BomHeader.psm1 Get-Utf8BomHeader     218 $header = [FileHeader]::Read($F...
Utf8BomHeader.psm1 Get-Utf8BomHeader     219 Write-Output $header
Utf8BomHeader.psm1 Test-Utf8BomHeader    258 $header = [FileHeader]::Read($F...
Utf8BomHeader.psm1 Test-Utf8BomHeader    259 Write-Output ($header -eq $bomHex)
Utf8BomHeader.psm1 Test-Utf8BomHeader    259 $header -eq $bomHex
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  281 if ($PSBoundParameters.Contains...
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  282 [System.IO.FileStream]$stream
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  284 $stream = $File.OpenRead()
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  285 $header = (1..3 | ForEach-Objec...
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  285 1..3
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  285 ForEach-Object {$stream.ReadByt...
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  285 $stream.ReadByte().ToString("X2")
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  286 Write-Output ($header -eq $bomHex)
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  286 $header -eq $bomHex
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  289 $stream.Dispose()
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  293 [System.IO.FileStream]$stream
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  295 $stream = [System.IO.FileStream...
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  296 $header = (1..3 | ForEach-Objec...
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  296 1..3
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  296 ForEach-Object {$stream.ReadByt...
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  296 $stream.ReadByte().ToString("X2")
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  297 Write-Output ($header -eq $bomHex)
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  297 $header -eq $bomHex
Utf8BomHeader.psm1 Test-Utf8BomHeaderPS  300 $stream.Dispose()
Collecting artifacts...
Found artifact 'publish/Utf8BomHeader_1.0.69.zip' matching 'publish/Utf8BomHeader_1.0.69.zip' path
Uploading artifacts...
[1/1] publish/Utf8BomHeader_1.0.69.zip (5053 bytes)...100%
Running "deploy_script" scripts
pwsh -file ./deploy_pagallery.ps1 -NuGetApiKey ${PS_GELLERY_RELEASE_API} -BuildBranch master -ModuleName Utf8BomHeader
"Appveyor" deployment has been skipped as "APPVEYOR_REPO_TAG_NAME" environment variable is blank
Build completed

まとめ

PowerShell Core 使っていくといいです。本にも書きましたが、PowerShell Coreは十分実用に到達して、すでにWindows PowerShell が及ばないレベルまでまともに動けるようになっています。

特に私の場合は、クロスプラットフォームで動作することを仕事でも私生活でも扱っているので、Windows PowerShell はたまに触るたびに本当につらいです。モジュールを作った動機もBOMごるぁ、だったのです。特にWindows で、PowerShell で書いていた、書きたい時にマルチプラットフォーム向けのファイルを吐き出したい時は抜群に使いやすいでしょう。

そして、CIは回しましょう。 PowerShell にはビルドがないのでCIはいらない? そんなことを言うのは3年前まではスルーでしたが、今は最低限でも担保したいテストを書いて、CIで継続的に回す方が自分にとって楽になることが多くなっています。

今後PowerShell のネタが PowerShell Core が大半を占めるようになることを祈ります。(個人の勝手なお願い

PS. 本当はAWS Lambda Runtime で PowerShell 動かして見せる。というのを書くかと思ったのですが、「毎年アドベントカレンダーを書いた後にもっとアドベントカレンダーは軽いネタをやるものだ。」と言われたので、今年はそのことを思い出して軽くしました。

オフトピック : もしも.NET Core + PowerShell Core を独自イメージにいれるなら

もし自分でインストールする場合に備えて、.NET Core / PowerShell Core のインストールステップも公開しておきます。.NET Core がubuntu で公開されているやり方にひと手間必要だったのでどぞ。

gist.github.com

.NET Core のMicrosoft Docページにもありますが、libicu60が必要なのですが .NET Coreのインストールページの説明にはなくてほげ、って感じです。apt-get install libicu60 でも入らないですし罠感はあります。