PowerShell 第1回 Get-ContentとAdd-Contentの処理速度調査

#はじめに
今回は、PowerShellについてまとめようと思います。
仕事で、Microsoft Officeを使用する場合、効率化や品質向上を目的として、VBAをよく使います。
そのため、Microsoft Office以外で効率化や品質向上においては、同じVisual Basic系のVB Scriptを使用することが多いです。
VB Scriptは、Windowsにおいて、特に新たに何かをインストールする必要がなく、非常に便利です。
しかしVB Scriptはサポート外になり、PowerShellになるとのことを以前知り、やはりインストール不要のPowerShellも実施してみたのですが、結局やりたいことはVB Scriptで十分で、PowerShellだとSecurity設定などを考えると、面倒で周りに配布するのもしづらく（それがMicrosoftの思惑ですが）、またコマンドレットがやたら長くて、新たに覚える気にもならず、やめてしまったのですが、とあるきっかけがあり、再度手をつけてみようかと思い立ちました。
手を付けたのもこの記事を記載する1週間前です。したがって、PowerShellのことは本当によくわかっていないのですが、始めてみて気になったことをまとめていこうと思います。

今回は、テキストファイルの分割・結合のスクリプトを作成してみたところ、処理が非常に遅く、調べたところ、記載方法を変えると早くなることがわかりました。
ということで、その辺の速度調査結果をまとめようと思います。

#はじめに2
コメントをいただきましたので、内容を更新します。
新しい言語をすると言語が違うのだから当たり前なのでしょうが、PowerShellの動作のくせが、まだ私にはなかなか難しいです。

#今回実施する内容
ファイルの読み込みと書き込みの実装の仕方による速度比較を実施します。速度比較のため、なるべくシンプルな記載にします。
ものすごく基本的なことだったり、記載している内容が正確でないこともあると思いますが、ご容赦ください。今のところの私の理解をきさいしている状況です。

なお、私の使用するPCは、５，６年前のPCですし、CPUもミドルレンジのため、そんなに速くはありませんが、今回実施しておきたいのは、書き方でどのくらい変わるかというところの把握なので、本記事をみて、そもそも遅いなどと思われないようにお願いします。


■読み込みは、以下を試します。

- Get-Contentによる読み込み
- .NET FrameworkのStreamReaderによる読み込み

■書き込みは、以下を試します。

- リダイレクト
- Add-Contentによる書き込み
- .NET FrameworkのStreamWriterによる書き込み

ついでにパイプライン処理を利用するか否かでも比較します。

結論から言えば、Add-Contentが圧倒的に遅く、StreamReaderで読み込んで、StreamWriterで書き込むのがよいという結果でした。

#ソースコード(Git Hub)
[PowerShell_01_Get-Content](https://github.com/Build-Mount/PowerShell_01_Get-Content)

#環境
OS: Windows 10 JP (64bit)
PowerShell version: 5.1.19041.1

#参考
[Get-Content](https://docs.microsoft.com/ja-jp/powershell/module/microsoft.powershell.management/get-content?view=powershell-7)
MicrosoftのPowershellのAPIリファレンスの記載です。

[Add-Content](https://docs.microsoft.com/en-us/powershell/module/Microsoft.PowerShell.Management/Add-Content?view=powershell-7)
MicrosoftのPowershellのAPIリファレンスの記載です。

#用語


#エイリアス
基本的にエイリアスを使用してプログラムします。
最初にPowerShellを実施したときは、このコマンドレットの長さのせいで、入力のわずらわしかったのを覚えており、新たに始めた今回も極力エイリアスで記載しようかなと考えています。
ひとつのコマンドレットに対して、複数のエイリアスが利用可能な場合があるようですが、ここでは以下を使用します。

- Get-Content：cat
- Add-Content：ac
- For-Each：%

#処理速度調査
##調査に使用するデータ

- data1.txt

一行に「1234567890」と記載されたものが10,000行連続したテキストファイルです。これを読み込んで、別のファイルを書き込みます。

- Test1.txt

以下を含みます。

```
001
002
003
004
005
006
007
008
009
010
```
 

##読み込み速度調査
###cat -ReadCount 1
catで、-ReadCountは1にし、一行ずつ読み込んで、その出力を"output.txt"にリダイレクトで出力します。
処理の開始、終了後の時間を取得して、その差分を処理時間とします。

```powershell:readCatReadCount1.ps1
#ファイル読み込みするScript

function readByCat($inFile) {
	cat $inFile -ReadCount 1 >> ".\output.txt"
}

#Main Proc
$startTime = Get-Date
readByCat ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**297.9122ms**でした。
何度か実施すると値は違いますが、だいたい**300ms**くらいでした。

###cat -ReadCount 10000
catで、-ReadCountは10,000にし、10,000行ずつ読み込んで、その出力を"output.txt"にリダイレクトで出力します。
今回のファイルは、10,000行のため、1回ですべての行を読み込むということになるかと思います。
処理の開始、終了後の時間を取得して、その差分を処理時間とします。

```powershell:readCatReadCount10000.ps1
#ファイル読み込みするScript

function readByCat($inFile) {
	cat $inFile -ReadCount 10000 >> ".\output.txt"
}

#Main Proc
$startTime = Get-Date
readByCat ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**92.0396ms**でした。
何度か実施すると値は違いますが、だいたい**90ms**くらいでした。
ReadCount 1よりもずいぶん速くなりました。
でもおそらくメモリ使用量は増えるかもしれませんが、今回のファイルサイズ116KBしかないため、大したことはありません。もっと大きなファイルを使うとメモリ不足になるかもしれませんが、このくらいなら大丈夫といったところでしょうか？
ファイル結合とかするだけならば、ReadCountは大きめのほうが速度は速くなりそうですが、メモリも使用しそうではあるため、バランスの取れた値があるかもしれませんが、よくわかりません。

###StreamReaderとStreamWriter
System.IO.StreamReaderを使用してファイルを読み込み、Stream.IO.StreamWriterを使用してファイルに書き込みます。

```powershell:readerWriter.ps1
#ファイル読み込みするScript

function readByReader($inFile) {
	$enc = [Text.Encoding]::GetEncoding("Shift_JIS")
	$outpath = $PSScriptRoot + "\output.txt"
	$sr = New-Object System.IO.StreamReader($inFile, $enc)
	$sw = New-Object System.IO.StreamWriter($outpath , $true, $enc)
	while(($line = $sr.ReadLine()) -ne $null) {
		$sw.WriteLine($line)
	}
	$sr.Close()
	$sw.Close()
}

#Main Proc
$startTime = Get-Date
$path = Split-Path -Parent $MyInvocation.MyCommand.Path
$i = $path + "\data1.txt"

readByReader $i
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**28.1489ms**でした。
何度か実施すると値は違いますが、だいたい**30ms**くらいでした。
３つの中では一番早い結果でした。
StreamReaderとStreamWriterは優秀ですね。構文は長くなってしまいますけどね。

###-ReadCountの動作の違いについて
ReadCountについて、MicrosoftのAPIリファレンス[Get-Content](https://docs.microsoft.com/ja-jp/powershell/module/microsoft.powershell.management/get-content?view=powershell-7)によれば、以下の記載ああります。

> -ReadCount
Specifies how many lines of content are sent through the pipeline at a time. The default value is 1. A value of 0 (zero) sends all of the content at one time.
>
>This parameter does not change the content displayed, but it does affect the time it takes to display the content. As the value of ReadCount increases, the time it takes to return the first line increases, but the total time for the operation decreases. This can make a perceptible difference in large items.

ざくっと訳すと、
一度に複数のコンテンツのラインをパイプラインに送信する。デフォルトは1で、0にするとすべてのラインを送信する。
コンテンツ表示を変えるものではなく、表示にかかる時間に影響があり、ReadCountを大きくすると、最初の行を応答する時間はかかるが、トータルの時間は減少する。大きいアイテムほど効果はわかる。
ということだと思います。

パイプラインのことは、まだよく理解できていませんが、ReadCountを2以上に設定すると、その行数単位で、パイプラインを通して、パイプライン先で処理を実行し、また、次の行数単位をパイプラインに通してといった処理なのかなと思いました。
まだほとんどMicrosoftのリファレンスページを読んでおらず、ネット上のパイプラインはいくつか読んでみたものの、ReadCountに関する記載はほとんどなく、あっているかどうかはわかりませんが、理解したイメージは図の通りです。
![pipelineイメージ.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/3fe2549c-1a93-5cf1-7b61-ed007271d8c4.jpeg)

上記を確認したいと思い、Get-Memberを使用して、-ReadCount 1、および3の場合の型をみてみました。

```powershell:readCountText.ps1
#catの-Readcountの動作確認

cat ".\data1.txt" -ReadCount 3 | Get-Member
cat ".\data1.txt" -ReadCount 1 | Get-Member
```

すると、

```
   TypeName: System.Object[]   <=ReadCount 3の場合

Name           MemberType            Definition
----           ----------            ----------
Count          AliasProperty         Count = Length


   TypeName: System.String    #<=ReadCount 1の場合

Name             MemberType            Definition
----             ----------            ----------
Clone            Method                System.Object Clone(), System.Object ICloneable.Clone()
...

```

のようになり、3の場合Object[]型で、ReadCount 1の場合String型と分かりました。
イメージ通りなのかなと思いました。

###パイプラインの動きについて
イメージは間違ってなさそうだとは思ったものの、もう少し実験してみました。
パイプラインでObject送信は何度発生しているのかを確認したいと思い、以下のようなものを作りました。
test1.txtは10行からなるテキストで、想定では、iの値は、0, 1, 2, 3とインクリされて、Lengthは、3, 3 ,3, 1のようになると思います。

```powershell:readCountTest2.ps1
#catの-Readcountの動作確認2

$i=0; cat ".\test1.txt" -ReadCount 3 | % {
	Write-Host $i
	Write-Host "Length:"$_.Length
	foreach ($val in $_) {
		Write-Host $val
	}
	$i++
}
```

結果は、

```
0
Length: 3
001
002
003
1
Length: 3
004
005
006
2
Length: 3
007
008
009
3
Length: 1
010
```

やはり、イメージとあっているようです。3行分のデータが一度に送信されて、それが3回実施された後、最後は1行分のデータが送信されるということですね。
ソースコードで、`$i=0; cat ".\test1.txt" -Readcount 3 | % {`の部分で、%を付けたのですが、これが適切なのかどうか疑問だったので、少しコードを変えてみました。

```powershell:readCountTest3.ps1
#catの-Readcountの動作確認3

$global:i=0

function f ([Parameter(ValueFromPipeline=$true)] $param) {
	Process {
		Write-Host "count:"$i
		$i++
		return $param
	}
}

cat ".\test1.txt" -ReadCount 3 | f | % {Write-Host "Length"$_.Length":"$_}

```

結果は、

```
count: 0
Length 3 : 001
Length 3 : 002
Length 3 : 003
count: 1
Length 3 : 004
Length 3 : 005
Length 3 : 006
count: 2
Length 3 : 007
Length 3 : 008
Length 3 : 009
count: 3
Length 3 : 010
```

なるほど、やはり、4回ループしていることを確認できました。
動作は、cat -> f -> % -> catといったように繰り返すと理解しました。
シンプルなソースですが、ここにたどり着くのに、かなり時間を要しました。
パイプラインを通す関数に記載できる内容に何かルールがあるようなので、それがよくわかっていませんが、それはおいおい勉強しようと思います。

###読み込みのまとめ
３つ試した結果では、以下の通りです。
読み込みだけでなく、書き込みもあって、StreamReaderとStreamWriterでは、書き込み方法が違うことは注意が必要です。
StreamReader, StreamWriterが速いということがわかりました。

|手法|スクリプト名|速度(ms)|
|:------|:-------|:-----|
|cat -ReadCount 1|readCatReadCount1.ps1|300|
|cat -ReadCount 10000|readCatReadCount10000.ps1|90|
|StreamReaderとStreamWriter|readerWriter.ps1|30|


##書き込み速度調査
###リダイレクト
これは、読み込み速度調査のcat -ReadCount 1の結果を参照し、処理の時間は、**297.9122ms**とします。

###ac (cat -ReadCount 1)
catで読み込んだファイルを、acで保存します。

```powershell:writeAdd-Content1.ps1
#ファイル書き込みするScript

function writedByAc($inFile) {
	cat $inFile -ReadCount 1 | %{ac ".\output.txt" -value $_}
}

#Main Proc
$startTime = Get-Date
writedByAc ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**85295.9329ms**でした。**1分25秒**ということです。
ものすごく時間がかかりました。

###ac (cat -ReadCount 1 w/o For-Each-Object)
これは、コメントをいただきました内容にもとづいて見直しましたので、記載します。
**ac (cat -ReadCount 1)**では、catの後のパイプライン「|」の後に、% (For-Each-Object)を記載した後、その処理内でacを記載していました。
これをした理由は、acをそのまま記載するとエラーが発生していたためです。
コメントをいただいたところ、%をとり、-value $_も記載をなくすと速くなるとのことでしたので試しました。

```powershell:writeAdd-Content1woForEach.ps1
#ファイル書き込みするScript

function writedByAc($inFile) {
	cat $inFile -ReadCount 1 | ac ".\output.txt"
}

#Main Proc
$startTime = Get-Date
writedByAc ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**174.1575ms**です。嘘のように速くなりました。
なぜこれほどという感じです。
確かにCatの出力をパイプライン処理するときに、For-Each-Objectをして、そのなかでAdd-Content-Objectが流れているわけなので、これで２つのコマンドレットが流れるということなので、その分遅くなるとは思うのですが、前の記載方法でも、2倍くらいの速度（例えば、340msとか）程度でいいのではないかとは思いました。

For-Each-Objectのの削除とAdd-Content-Objectの-Valueパラメータの両方を取ったので、どちらが要因なのかを調べるために、以下のようなScriptを作りました。

```powershell:writeAdd-Content1woValue.ps1
#ファイル書き込みするScript

function writedByAc($inFile) {
	cat $inFile -ReadCount 1 | %{ac ".\output.txt"}
}

#Main Proc
$startTime = Get-Date
writedByAc ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

これを実行すると、

```
コマンド パイプライン位置 1 のコマンドレット Add-Content
次のパラメーターに値を指定してください:
Value[0]:
```
と表示されて、Valueに何か入力しないと進まませんでした。

MicrosoftのAPIリファレンスで、Valueを確認すると、

>-Value
Specifies the content to be added. Type a quoted string, such as This data is for internal use only, or specify an object that contains content, such as the DateTime object that Get-Date generates.
>
>You cannot specify the contents of a file by typing its path, because the path is just a string. You can use a Get-Content command to get the content and pass it to the Value parameter.

ざくっと訳すと、追加する内容を規定する。内部で使用するためだけのThis dataのような引用stringをタイプする、もしくはGet-Dateが生成するDteTimeオブジェクトのような内容を含むオブジェクトを規定する。
内容を含むPathは規定できず、それはただのStringだから、といっています。
文字列か、内容を含むObjectを設定すると理解しました。

Valueを記載しない場合については記載がないのですが、例を今回の場合だと、ForEach-ObjectからStringのオブジェクトを受けているのでは？と思ったのですが、違うのでしょうか？
[Add-Content](https://docs.microsoft.com/en-us/powershell/module/Microsoft.PowerShell.Management/Add-Content?view=powershell-7)のExample 4: Add the contents of a specified file to another file using the pipelineでは、Get-Contentからのパイプラインの場合だけ、-Valueがありませんでした。この場合だけValueを記載しなくてもいいのかもしれません。

続いて、試したのは、%だけ取るパターンです。

```powershell:writeAdd-Content1woForEach2.ps1
#ファイル書き込みするScript

function writedByAc($inFile) {
	cat $inFile -ReadCount 1 | ac ".\output.txt" -Value $_
}

#Main Proc
$startTime = Get-Date
writedByAc ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

これを実行すると、

```
ac : 入力オブジェクトをコマンドのパラメーターにバインドできません。コマンドがパイプライン入力を受け入れないか、または入力とそのプロパティが、パイプライン入力を受け入れるいずれのパラメーターにも一致しません。
発生場所 ...\writeAdd-Content1woForEach.ps1:4 文字:29
+     cat $inFile -ReadCount 1 | ac ".\output.txt" -Value $_
+                                ~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (1234567890:PSObject) [Add-Content]、ParameterBindingException
    + FullyQualifiedErrorId : InputObjectNotBound,Microsoft.PowerShell.Commands.AddContentCommand
```
とエラーが発生してしまいました。
なかなか難しいですね。
エラーでは、1234567890:PSObjectと記載されており、文字列は受けているようにみえるのですが、うまくいきません。

本当にくせを感じました。

###ac (cat -ReadCount 10000)
catで読み込んだファイルを、acで保存します。
今回は、catの-ReadCountを10000にします。
読み込み速度調査でのパイプライン処理のイメージ通りであれば、今回の10,000行のファイルであれば、acの実施回数は1回で済むため、速度は上がるはずです。

```powershell:writeAdd-Content10000.ps1
#ファイル書き込みするScript2

function writedByAc($inFile) {
	cat $inFile -ReadCount 10000 | %{ac ".\output.txt" -value $_}
}

#Main Proc
$startTime = Get-Date
writedByAc ".\data1.txt"
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**76.0691ms**でした。
先ほどとReadCountを変えただけなのに、圧倒的に速くなりました。

###StreamReaderとStreamWriter
System.IO.StreamReaderを使用してファイルを読み込み、Stream.IO.StreamWriterを使用してファイルに書き込みます。
これは読み込みの結果を参照すると、処理時間は、**28.1489ms**でした。

#StreamWriter(cat -ReadCount 1)
これは少しトリッキーです。読み込みは、catで行って、それをパイプラインに流して、書き込みは、StreamWriterを使用した関数で書き込みます。

```powershell:writeStreamWriter1.ps1
#ファイル書き込みするScript4

$global:enc = [Text.Encoding]::GetEncoding("Shift_JIS")
$global:outpath = $PSScriptRoot + "\output.txt"
$global:sw = New-Object System.IO.StreamWriter($outpath,$true, $enc)

function writedByWriter([Parameter(ValueFromPipeline=$true)] $param) {
	process {
		$sw.WriteLine($param.tostring())
	}
	end {
		$sw.Close()
	}
}

#Main Proc
$startTime = Get-Date
cat ".\test1.txt" -ReadCount 1 | writedByWriter
$endTime = Get-Date
$exeTime = $endTime - $startTime
$exeTime
```

処理時間は、**141.8494ms**でした。
どちらかというと読み込み部分とパイプライン処理の速度確認よりの試験のような気がしました。

###書き込みのまとめ
６つ試した結果では、以下の通りです。
StreamReader, StreamWriterが速いということがわかりました。
私が「はじめに」に記載したすごく時間がかかったという処理が、ac (cat -ReadCount 1)を使用した処理でした。
この処理は、VB Scriptでファイルのコピーなどの処理をするときに、StreamReader、StreamWriterを使用しており、一行単位で読み込み、書き込み処理をしていましたので、それと同じつもりで、catとacを使用して、パイプライン処理を実施しました。
しかし、それだと1分25秒もかかってしまい、なんだ？と思いました。ネットを調べてみると、パイプライン処理は遅いというような記載はいくつかありました。
理由は正直わかりませんが、感触としては、
`ac ".\output.txt" -value S_`の場合、毎回ファイルを開いて最終行まで検索して、追記しているのかなと思います。したがって時間がかかる。
catのReadCountを大きくすると、書き込み回数が減るため、速くなる。
そのうえ、ForEach-Objectも回っていた。

StreamWriterは、ファイルを開き、その処理は1回で、その後は、追記していくという動作なので、速い。
ということで、基本的に、StreamWriterを使えば問題ないのかなと思いました。
どうしてもパイプライン処理が必要で、acを使うのであれば極力ループする回数を減らすように、今回のようにcatのReadCountを調整することなのかなとは思いました。
でも、ReadCountの値はどのくらいが適切なのか？メモリ量も消費が大きくなると思いますので、難しいなと思いました。
経験でなんとなく見えてくるのかな。

|手法|スクリプト名|速度(ms)|
|:------|:-------|:-----|
|リダイレクト|readCatReadCount1.ps1|300|
|ac (cat -ReadCount 1)|writeAdd-Content1.ps1|85,295(1分25秒)|
|ac (cat -ReadCount 1 w/o For-Each-Object)|writeAdd-Content1woForEach.ps1|170|
|ac (cat -ReadCount 10000)|writeAdd-Content10000.ps1|80|
|StreamReaderとStreamWriter|readerWriter.ps1|30|
|StreamWriter(cat -ReadCount 1)|writeStreamWriter1.ps1|140|

#おわりに
今回は、Get-Content、Add-Contentを使った処理の速度を調査しました。
パイプライン処理が簡単に記載できてよいと思いましたが、ファイル読み込み、書き込み処理は、StreamReader、StreamWriterを使用したほうがよいということがわかりました。

#更新

|日付|更新箇所|内容|
|:---|:---|:---|
|2020/08/10|環境|PowerShellのバージョンを追記。|
|2020/08/06|はじめに2|追加。|
|2020/08/06|ac (cat -ReadCount 1 w/o For-Each-Object)|追加。|
|2020/08/06|書き込み調査まとめ|ac (cat -ReadCount 1 w/o For-Each-Object)に従い修正。|