tech.guitarrapc.cóm

Technical updates

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

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

https://qiita.com/advent-calendar/2018/powershell

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

目的

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

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

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

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

Utf8BomHeader

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

https://github.com/guitarrapc/Utf8BomHeader

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

動機

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

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

モジュールができること

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

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

実装について

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

https://gist.github.com/guitarrapc/3ee289271e9a1f1590fef36dd70c2efe

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

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

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

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

https://gist.github.com/guitarrapc/cfc411ebe2447363b4be10ba1e449edc

やりたいことだけ、という感じでシンプルになります。 さて、インラインで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はビルド環境イメージが定義されています。

https://www.appveyor.com/docs/build-environment/

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

https://www.appveyor.com/docs/linux-images-software/

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

https://gist.github.com/guitarrapc/893fde0ab1f97f7ee39ff0ee579fd41c

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

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

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

https://gist.github.com/guitarrapc/c0101ab7f301b27ed3f8916a1f3a8992

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

https://gist.github.com/guitarrapc/96cd1bafd9a6e6a5b9fe4b686153b498

先ほどの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:あたりでします。この実行タイミングはドキュメントになくてここで説明されています。

https://help.appveyor.com/discussions/problems/18093-build-failed-cannot-connect-to-the-docker-daemon-at-unixvarrundockersock-is-the-docker-daemon-running

テスト

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

https://github.com/guitarrapc/Utf8BomHeader/blob/master/tests/Utf8BomHeader.Tests.ps1

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

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

https://gist.github.com/guitarrapc/06358b0b0ee5f67214fb8ed78024eff5

あとは適当に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で大半を占めるようになることを祈ります。本当はAWS Lambda RuntimeでPowerShell動かして見せる。というのを書くかと思ったのですが、「毎年アドベントカレンダーを書いた後にもっとアドベントカレンダーは軽いネタをやるものだ」と言われたので、今年はそのことを思い出して軽くしました。

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

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

https://gist.github.com/guitarrapc/4a474a105e304648adc7df749f8d0a10

.NET CoreのMicrosoft Docページにもある通りlibicu60が必要なのですが .NET Coreのインストールページの説明にはありません。apt-get install libicu60でも入らないですし罠感はあります。