2012年12月5日水曜日

再考! PowerShell で LINQ - Terrific! LINQ to PowerShell -

PowerShell Advent Calendar 2012、5 日目です!

はじめましての人ははじめまして!PowerShell Advent Calendar 2012 の 5 日目を担当させていただきます、杉浦と申します。
自分は、本業的には、.NET の静的型付けな言語で構築されたシステムに関わることがほとんどで、PowerShell を扱うのは素人なのですが、業務効率改善や不具合調査等、色んなところで助けられたこともあり、今回少しでも PowerShell 遣いな方々と情報共有できればと、こちらに参加させていただいた次第です。よろしくお願いいたします!

今回扱うテーマは、「PowerShell で LINQ」。もうこれまで散々議論されて来た感がありますが、.NET 開発者の 99.9% の人が知らないあの機能と同様、まだ見落とされてきた何かがあるのでは・・・!?と、私の記事では、これまで Web 上で発表された成果を振り返りつつ、別の手法を改めて考えてみたいと思います。では、早速行ってみましょー!

※今回の記事で扱うのは、PowerShell v2 の情報となります。PowerShell v3 での情報は、また別の機会に検証させていただければと思います <(_ _)>


こちらの情報を参考にさせていただきました。この場を借りてお礼申し上げます。この世界、ホント奥が深いとです・・・...( = =)
統合開発環境「PowerShell ISE」を使ってみよう:CodeZine
add-types.ps1 - poor man's using for PowerShell - BUGBUG poor title - Site Home - MSDN Blogs
PowerShell で LINQ - NyaRuRuが地球にいたころ
Powershell 2.0でチャーチ数の夢を見た - めらんこーど地階
Hey! Don’t break my pipe! IT Pro PowerShell experience
Best Practices for Windows PowerShell
LINQ for PowerShell
Tellingmachine PSUnit PowerShell Unit Testing Framework – Getting Started Guide – Installation - Version 2 Beta 1
Further Down the Rabbit Hole PowerShell Modules and Encapsulation
Pester - BDD style testing framework for PowerShell - @fsugiyamaの技術日誌
if($array -eq $null) には要注意! - PowerShell Scripting Weblog
PowerShell 2.0の新機能(2) ――リモート処理編 :CodeZine
HOW change Microsoft Windows PowerShell"! Ui Culture the time of a PSSession





目次

まずは成果を眺めてみる
作成途中ではあるのですが、もしよろしければ、どんな感じか触ってみていただくのが良いかもしれません。Pester みたく、PsGet できるとかっこいいのですが、とりあえずは手動です (^_^;)
  1. urasandesu / PSAnonym - GitHub からリポジトリをダウンロード(もしくは git clone)。
  2. 必要に応じて、管理者権限で PowerShell を起動し、Set-ExecutionPolicy を使って、スクリプト実行可能に。
  3. ダウンロードしたリポジトリの中にある、Urasandesu.PSAnonym ディレクトリを $env:PSModulePath にコピー。
  4. Import-Module 'Urasandesu.PSAnonym' でモジュールをインポート。

早速、単純な例として、MSDN にもある「1 から 10 個の整数シーケンスを生成し、それぞれを 2 乗する」をやってみましょう。
PS C:\> QRange 1 10 | QSelect { $_ * $_ } | QToArray
1
4
9
16
25
36
49
64
81
100
 

select や where は既に予約されていますので、Prefix として "Q" を付けてみました(linQ の Q です!)。また、ラムダ式に当たる部分は、ScriptBlock を使って表現しています。引数は、1 つだけの時は、$_ が、複数ある場合は、$1, $2, ・・・がプレースホルダとして使えます。もちろん ScriptBlock ですので、明示的に param () を使って別の名前で受け取ることもできます。集計操作の時などは、意味のある名前を付けたほうがわかりやすいかもしれませんね。
こちらも MSDN にある例で恐縮ですが、「" "(半角スペース)で区切られた文字列を逆順にする」を書いてみました。
PS C:\> $sentence = 'the quick brown fox jumps over the lazy dog'
PS C:\> $words = $sentence -split ' '
PS C:\> $reversed = { $words } | 
>>                      QAggregate { 
>>                          param ($workingSentence, $next) 
>>                          $next + ' ' + $workingSentence 
>>                      }
>>
PS C:\> $reversed
dog lazy the over jumps fox brown quick the
 

プレースホルダを使った版はこんな感じになります。C++ の Boost とかをよく使われているのであれば、こちらのほうが親しみがあるかもしれません。
PS C:\> $sentence = 'the quick brown fox jumps over the lazy dog'
PS C:\> $words = $sentence -split ' '
PS C:\> $reversed = { $words } | QAggregate { $2 + ' ' + $1 }
PS C:\> $reversed
dog lazy the over jumps fox brown quick the
 

これと同じですね。
#include <boost/algorithm/string.hpp>
#include <boost/lambda/lambda.hpp>
#include <boost/range/numeric.hpp>
#include <iostream>
#include <string>
#include <vector>

int main(int argc, char* argv[])
{
    using namespace boost;
    using namespace boost::algorithm;
    using namespace boost::lambda;
    using namespace std;

    string sentence = "the quick brown fox jumps over the lazy dog";

    vector<string> words;
    split(words, sentence, is_any_of(" "));

    string reversed = accumulate(words, string(), _2 + " " + _1);

    cout << reversed << endl;
    // This code produces the following output:
    // dog lazy the over jumps fox brown quick the

    return 0;
}
 

最後の例は、良く知られた FizzBuzz 問題。「無限リストを生成し、最初の 36 個の数字について Fizz Buzz する」、こんな感じになるでしょうか。
PS C:\> $query = QRange 1 ([int]::MaxValue) |
>>                 QSelect {
>>                     switch ($_) {
>>                         { $_ % 15 -eq 0 } { 'Fizz Buzz'; break }
>>                         { $_ % 3 -eq 0 } { 'Fizz'; break }
>>                         { $_ % 5 -eq 0 } { 'Buzz'; break }
>>                         default { $_ }
>>                     }
>>                 } |
>>                 QTake 36
>>
PS C:\> ($query | QToArray) -join ', '
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, Fizz Buzz, 16, 17, Fizz, 19, Buzz, F
izz, 22, 23, Fizz, Buzz, 26, Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz
 

最終的に用意するものは、以下の表のようなものを考えています。
PowerShell 関数名 エイリアス 機能 元の LINQ クエリ 実装
状況
Get-Aggregated QAggregate シーケンスにアキュムレータ関数を適用する。 Aggregate 100%
Get-AllSatisfied QAll シーケンスのすべての要素が条件を満たしているかどうかを判断する。 All<TSource> 100%
Get-AnySatisfied QAny シーケンスの要素が存在するか、または条件を満たすかどうかを判断する。 Any 100%
Get-Average QAverage 数値のシーケンスの平均値を計算する。 Average 100%
ConvertTo-Casted
Select-Casted
QCast 列挙子の要素を、指定した型に変換する。 Cast<TResult> 100%
Join-Concatenated QConcat 2 つのシーケンスを連結する。 Concat<TSource> 100%
Get-Contained QContains 指定した要素がシーケンスに格納されているかどうかを判断する。 Contains 100%
Get-Count QCount シーケンス内の要素数を返する。 Count 100%
Get-DefaultIfEmpty
Select-DefaultIfEmpty
QDefaultIfEmpty 列挙子の要素を返する。シーケンスが空の場合は既定値を持つシングルトン コレクションを返する。 DefaultIfEmpty 100%
ConvertTo-Distinct QDistinct シーケンスから一意の要素を返する。 Distinct 100%
Get-ElementAt QElementAt シーケンス内の指定されたインデックス位置にある要素を返する。 ElementAt<TSource> 0%
Get-ElementAtOrDefault QElementAtOrDefault シーケンス内の指定されたインデックス位置にある要素を返する。インデックスが範囲外の場合は既定値を返する。 ElementAtOrDefault<TSource> 0%
New-Empty QEmpty 指定した型引数を持つ空の 列挙子を返する。 Empty<TResult> 0%
Join-Except QExcept 2 つのシーケンスの差集合を生成する。 Except 0%
Get-First QFirst シーケンスの最初の要素を返する。 First 0%
Get-FirstOrDefault QFirstOrDefault シーケンスの最初の要素を返する。要素が見つからない場合は既定値を返する。 FirstOrDefault 0%
Group-SequenceBy QGroupBy シーケンスの要素をグループ化する。 GroupBy 100%
Join-GroupedBy QGroupJoin キーが等しいかどうかに基づいて 2 つのシーケンスの要素を相互に関連付け、その結果をグループ化する。 GroupJoin 0%
Join-Intersect QIntersect 2 つのシーケンスの積集合を生成する。 Intersect 0%
Join-Sequence QJoin 一致するキーに基づいて 2 つのシーケンスの要素を相互に関連付ける。 Join 0%
Get-Last QLast シーケンスの最後の要素を返する。 Last 0%
Get-LastOrDefault QLastOrDefault シーケンスの最後の要素を返する。要素が見つからない場合は既定値を返する。 LastOrDefault 0%
Get-LongCount QLongCount シーケンス内の要素数を表す Int64 を返する。 LongCount 0%
Get-Max QMax 値のシーケンスの最大値を返する。 Max 0%
Get-Min QMin 値のシーケンスの最小値を返する。 Min 0%
ConvertTo-OfType QOfType 指定された型に基づいて 列挙子の要素をフィルタ処理する。 OfType<TResult> 0%
Select-OrderBy QOrderBy シーケンスの要素を昇順に並べ替える。 OrderBy 100%
Select-OrderByDescending QOrderByDescending シーケンスの要素を降順に並べ替える。 OrderByDescending 100%
New-Range QRange 指定した範囲内の整数のシーケンスを生成する。 Range 100%
New-Repeat QRepeat 繰り返される 1 つの値を含むシーケンスを生成する。 Repeat<TResult> 100%
ConvertTo-Reversed QReverse シーケンスの要素の順序を反転させる。 Reverse<TSource> 0%
Select-Sequence QSelect シーケンスの各要素を新しいフォームに射影する。 Select 100%
Select-ManySequence QSelectMany シーケンスの各要素を 列挙子に射影し、結果のシーケンスを 1 つのシーケンスに平坦化する。 SelectMany 100%
Get-SequenceEquality QSequenceEqual 等値比較演算子に従って 2 つのシーケンスが等しいかどうかを判断する。 SequenceEqual 0%
Get-Single QSingle 値のシーケンスの 1 つの特定の要素を返する。 Single 0%
Get-SingleOrDefault QSingleOrDefault 値のシーケンスの 1 つの特定の要素を返する。そのような要素が見つからない場合は既定値を返する。 SingleOrDefault 0%
Skip-Sequence QSkip シーケンス内の指定された数の要素をバイパスし、残りの要素を返する。 Skip<TSource> 0%
Skip-SequenceWhile QSkipWhile 指定された条件が満たされる限り、シーケンスの要素をバイパスした後、残りの要素を返する。 SkipWhile 0%
Get-Sum QSum 数値のシーケンスの合計を計算する。 Sum 0%
Find-CountOf QTake シーケンスの先頭から、指定された数の連続する要素を返する。 Take<TSource> 100%
Find-While QTakeWhile 指定された条件を満たされる限り、シーケンスから要素を返した後、残りの要素をスキップする。 TakeWhile 100%
Select-ThenBy QThenBy シーケンス内の後続の要素を昇順で配置する。 ThenBy 100%
Select-ThenByDescending QThenByDescending シーケンス内の後続の要素を降順で配置する。 ThenByDescending 100%
ConvertTo-Array QToArray 列挙子から配列を作成する。 ToArray<TSource> 100%
ConvertTo-Dictionary QToDictionary 列挙子から Hashtable を作成する。 ToDictionary 0%
ConvertTo-List QToList 列挙子から ArrayList を作成する。 ToList<TSource> 0%
ConvertTo-Lookup QToLookup 列挙子から Lookup を作成する。 ToLookup 0%
Join-Union QUnion 2 つのシーケンスの和集合を生成する。 Union 0%
Find-Sequence QWhere 述語に基づいて値のシーケンスをフィルタ処理する。 Where 100%
Invoke-Linq QRun クエリを単純に起動する。 - 100%
2012/12/05 現在、進捗率は 30%。なるべく今月中に一通り仕上げたいところですが、師走でもあり何かとありまして・・・ぼちぼち進められればと思いますです (^_^;)
2012/12/24 現在、進捗率は 48%。思ったより多い…。途中、今後に向けた仕込みもしていたためか、進捗は芳しくありません。ちょいちょい進めますです (^^ゞ

さてさて、実は最後に挙げた例ですが、PowerShell に LINQ するような小品を作りたいと思った場合、障害となるいくつかの特徴を持っています。ここから先は、ちょっとお堅い話になりますので、興味があればご覧ください~。




先人の成果を検証させていただく
2012/12/05 現在、Bing で、キーワード "PowerShell LINQ" を使った検索をしてみると、先人の方々が試行錯誤された結果がずらずらと見つかります。その結果、44 万件以上・・・。それは、PowerShell が動的型付け言語であるにも関わらず、元の静的型付けで Generics な I/F を使いやすくするような Wrapper であったり、Dynamic Query を使った統一的な表記で DB へのアクセスを可能にした、1 つの言語内 DSL であったり、と本当に多種多様です。

これらの中から、今回は検索結果の 1 ページ目に現れる、ある程度のクエリが定義された以下の 2 つについて検証させていただくことにしました。

LINQ for PowerShell
  • 2010/02 ごろに公開された Josh Einstein 氏作成のライブラリです。
  • *.psm1 形式になっていますので、ダウンロード後、Import-Module … し、インストールを行います。
  • 20 以上の LINQ 処理が定義されており、一通りの処理はこれだけでできるようになっています。
参考サンプルコードを引用します:
Import-Module LINQ

function Assert-AreEqual($Expected, $Actual) {
    if (@(Compare-Object $Expected $Actual -SyncWindow 0).Length) {
        $OFS = ','
        Write-Error "Assert-AreEqual Failed: Expected=($Expected), Actual=($Actual)"
    }
}

# Take
Assert-AreEqual -Expected @(1..3)   -Actual @(1..5 | Linq-Take 3)

# TakeWhile
Assert-AreEqual -Expected @(1..2)   -Actual @(1..5 | Linq-TakeWhile { $_ -lt 3})

# Repeat
Assert-AreEqual -Expected @(1..5)   -Actual @(1..5 | Linq-Repeat 1)
 

PowerShell で LINQ - NyaRuRuが地球にいたころ
  • NyaRuRu 氏解説の日本語による記事です。2008/12 ごろに公開ということは、なんと今から 4 年も前!
  • ダウンロードファイル等はないようですので、適当に *.psm1 や *.ps1 としてコピペして使うことになります。
  • 定義されている LINQ 処理は 5 つ程ですが、元々ある機能も十分強力ですので、組み合わせて使うことを想定されているのでしょう。
参考サンプルコードを引用します:
# win.ini のダンプ
xrepeat {new-object IO.StreamReader 'c:\windows\win.ini'} `
  | xselect { $_.ReadLine() } `
  | xtakewhile { $_ -ne $null } `
  | Out-Default 

#1 桁の乱数を 100 個得る
xrepeat {new-object Random} `
  | xselect { $_.Next(0,10) } `
  | xtake 100 `
  | Out-Default 
 




ん?何が違う?
サンプルが異なると、パッと見同じ雰囲気に見えますね。ですので、先ほどの FizzBuzz 問題をどのライブラリでも扱えるよう変形して、処理を揃えてみましょうか。
PS C:\> function NewCounter {
>>         New-Object psobject |
>>             Add-Member NoteProperty m_counter 0 -PassThru |
>>             Add-Member ScriptMethod Increment `
>>             {
>>                 $this.m_counter++
>>                 $this.m_counter
>>             } -PassThru
>>      }
>>
 
無限リストのためのカウンターを生成する補助関数です。どの例からでも参照できるように最初に定義しておきましょう。
これを使った私のものの例はこうなります。
PS C:\> $query = QRepeat { NewCounter } |
>>                 QSelect { $_.Increment() } |
>>                 QSelect {
>>                     switch ($_) {
>>                         { $_ % 15 -eq 0 } { 'Fizz Buzz'; break }
>>                         { $_ % 3 -eq 0 } { 'Fizz'; break }
>>                         { $_ % 5 -eq 0 } { 'Buzz'; break }
>>                         default { $_ }
>>                     }
>>                 } |
>>                 QTake 36
>>
PS C:\> ($query | QToArray) -join ', '
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, Fizz Buzz, 16, 17, Fizz, 19, Buzz, F
izz, 22, 23, Fizz, Buzz, 26, Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz
 

Einstein 氏のライブラリを使うと、こんな感じの表現になります。
PS C:\> $query = NewCounter |
>>                 Linq-Repeat (40) |
>>                 Linq-Select { $_.Increment() } |
>>                 Linq-Select {
>>                     switch ($_) {
>>                         { $_ % 15 -eq 0 } { 'Fizz Buzz'; break }
>>                         { $_ % 3 -eq 0 } { 'Fizz'; break }
>>                         { $_ % 5 -eq 0 } { 'Buzz'; break }
>>                         default { $_ }
>>                     }
>>                 } |
>>                 Linq-Take 36
>>
PS C:\> $query -join ', '
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, Fizz Buzz, 16, 17, Fizz, 19, Buzz, F
izz, 22, 23, Fizz, Buzz, 26, Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz
 
2 行目の Linq-Repeat、上限が 40 に制限されていますが、これを元の問題であったような無限リストに近づけようと、[int]::MaxValue にすることはできません。
スクリプトが終わらなくなってしまうからです。
PowerShell v2 でよく問題として挙げられる挙動に、Select-Object コマンドレットの -First パラメータを指定しても、パイプラインが止まらないというものがあると思いますが、これも同様の問題を抱えているようです。

NyaRuRu 氏のライブラリの場合は、こんな感じでしょうか。
PS C:\> $query = $(do {
>>                 xrepeat { NewCounter } |
>>                 xselect { $args[0].Increment() } |
>>                 xselect {
>>                     switch ($args[0]) {
>>                         { $_ % 15 -eq 0 } { 'Fizz Buzz'; break }
>>                         { $_ % 3 -eq 0 } { 'Fizz'; break }
>>                         { $_ % 5 -eq 0 } { 'Buzz'; break }
>>                         default { $_ }
>>                     }
>>                 } |
>>                 xtake 36
>>      } until ($true))
>>
PS C:\> $query -join ', '
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, Fizz Buzz, 16, 17, Fizz, 19, Buzz, F
izz, 22, 23, Fizz, Buzz, 26, Fizz, 28, 29, Fizz Buzz, 31, 32, Fizz, 34, Buzz, Fizz
 
Einstein 氏のライブラリと比べると、無限リストであることは良いのですが、全体が do { } until (...) で囲まれるようになりました。残念ながら、これを外すことはできません。
スクリプトが途中で終了してしまうからです。
元の xtake の中に break 文を見つけることができますが、本来これは foreach/for/while/do/switch 文をただちに終了するためのものです。break の外側にこれらの囲いが無い場合、容赦なくスクリプトを止めてしまうのですね。

あとは共通した問題として、1 行目の $query が遅延評価されていない、というものがあります(どちらの例でも、Take に与える数を増やした分だけ、最初の式の実行に時間がかかるようになります)。元々の LINQ では、foreach に引き渡したり、スカラー値を戻すメソッド(All や Cout、ToArray など)を呼ばない限りは、実際の計算を先延ばしにすることができました。
しかしながら、PowerShell の動きとして、IEnumerable や IEnumerator を実装したオブジェクトは、ありとあらゆる場所で先行評価が試みられます。そのポイントは、-eq 演算子の左辺への指定や、ValidateNotNull 検証属性が付与された関数への引き渡し、変数への格納のタイミングなど、本当にいたるところでその対象になってしまいます。よほど気を付けていないと回避はできません。LINQ で連鎖させることになっている IEnumerable オブジェクトは、扱うのが困難という結論になってしまうのです。




どうやって解決を?
「間接法をもう一段増やせば解けない問題はない.―Butler Lampson」ですね。先行評価されないものを一枚被せれば良いのです。
構文的・ライブラリのサポートが充実しているということから、私は ScriptBlock を被せてみました。さらに、これなら最後の評価文を do { } until (...) で囲むことで、中で break しても安全に実行できるといううれしいオマケも付いてきます。最初の例で使った 3 つのクエリの中身を覗いてみましょう。
function New-Range {
    [CmdletBinding()]
    [OutputType([scriptblock])]
    param (
        [Parameter(Position = 0, Mandatory = $true)]
        [int]
        $Start,

        [Parameter(Position = 1, Mandatory = $true)]
        [int]
        $Count
    )
    
    if (($Count -lt 0) -or ([int]::MaxValue -lt ($Start + $Count - 1))) {
        $exParamName = '$Count'
        $exName = 'ArgumentOutOfRangeException'
        $ex = New-Object $exName -ArgumentList $exParamName
        throw $ex
    }

    {
        [CmdletBinding()]
        param (
            [switch]
            $WithSpecialInvoker = $(throw New-Object Urasandesu.PSAnonym.Linq.InvalidInvocationException)
        )
        
        $Start = $Start
        $Count = $Count
        
        for ($index = $Start; $index -lt $Start + $Count; $index++) {
            ,$index
        }
        
    }.GetNewClosure()
}

New-Alias QRange New-Range
 
function Select-Sequence {
    [CmdletBinding()]
    [OutputType([scriptblock])]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [scriptblock]
        $InputObject,

        [Parameter(Position = 0, Mandatory = $true)]
        [scriptblock]
        $Selector
    )

    {
        [CmdletBinding()]
        param (
            [switch]
            $WithSpecialInvoker = $(throw New-Object Urasandesu.PSAnonym.Linq.InvalidInvocationException)
        )
        
        $InputObject = $InputObject
        $Selector = $Selector
        
        & $InputObject -WithSpecialInvoker | 
            ForEach-Object {
                , (& $Selector.GetNewClosure() $_)
            }

    }.GetNewClosure()

}

New-Alias QSelect Select-Sequence
 
function ConvertTo-Array {
    [CmdletBinding()]
    [OutputType([scriptblock])]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [scriptblock]
        $InputObject
    )
    
    @(do {
        & $InputObject -WithSpecialInvoker | 
            ForEach-Object {
                $_
            }
    } until ($true))

}

New-Alias QToArray ConvertTo-Array
 
QRange/QSelect が返す ScriptBlock 内では、関数内の変数を参照する必要がありますので、GetNewClosure で環境を保存しておきます。また、決まった実行方法で実行しないとスクリプトが途中で終了してしまうことがありますので、それを防ぐために返した ScriptBlock の呼び出し方法を若干面倒にしてみました。QToArray では、実行する ScriptBlock を do { } until (...) で囲み、配列に変換しているという寸法です。
これで、基本的に同様の idiom で、これまでの問題は解決できることになります。つまり、全ての LINQ クエリを定義することができるようになったのです・・・はい、後は力業ですね (`・ω・ )ゝ




終わりに
PowerShell Advent Calendar 2012 の 5 日目は、PowerShell に LINQ するライブラリを再考してみました。いかがでしたか?たまには振り返るのも良いかな、と。Windows 8 の新基盤 WinRT で、また COM の知識が見直されたりしていますしね。

PowerShell、個人的には、Windows 7 以降、標準で搭載されている強力な統合開発環境(PowerShell ISE)や、.NET Framework との親和性の高さなどもあり、Windows 環境においては、最強クラスのスクリプト言語の 1 つだろうと感じています。
この記事が、少しでも PowerShell 遣いの方々のお役に立てば、また、他の .NET 系言語で LINQ を知った方が、PowerShell をちょっとでも触ってみようかと思っていただければ幸いに思いますです。

さあ、引き続き PowerShell Advent Calendar 2012 楽しみたいと思います。明日の担当は @twit_ahf さんです。
よろしくどうぞ~(´ー`~)


1 件のコメント:

  1. LINQ クエリを追加しました。詳細はリリースノートをご参照くださいませ~。

    - urasandesu/PSAnonym at f082fe7 - GitHub
    https://github.com/urasandesu/PSAnonym/tree/f082fe79da30f12702077f5bc7daaa127b3752fc

    返信削除