VB.NETでもコマンドライン引数を楽にパースしたい(またの名をVB.NETとF#で連携したい)
はじめに
前になんかいい感じのコマンドライン引数をパースするライブラリを見つけたいとか書いたのですが、DSL付きで見つけました。
なんとこちら、DSLなのに.NETの型システムと高い親和性を持っていたりマイクロソフトがサポートを改めて表明したりと至れり尽くせりなんです。F#って言うんですって
VB.NETとFSharp
とまぁ、やっぱりF#はこういう方面はめっぽう強いから好きです。
namespace CommandLine.Parser open System type Param = { Level:int; IsDryRun:bool; OutputName:string option; Files:string list } [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module Param = let rec parseImpl input param = match input with | "--level" :: x :: tail -> match Int32.TryParse(x) with | (true, x) when 1 <= x && x <= 5 -> parseImpl tail { param with Level = x } | _ -> parseImpl tail param | "--dry-run" :: tail -> parseImpl tail { param with IsDryRun = true } | "--output-name" :: name :: tail -> parseImpl tail { param with OutputName = Some(name) } | head :: tail -> parseImpl tail { param with Files = head :: param.Files } | [] -> param [<CompiledName("Parse")>] let parse input = parseImpl (Array.toList input) { Level = 1; IsDryRun = false; OutputName = None; Files = [] } [<CompiledName("Show")>] let show x = printfn "%A" x
これだけのコードで割と複雑なコマンドライン引数をパースできます。
Imports CommandLine.Parser Module Program Sub Main() Dim param1 = ParamModule.Parse({"--level", "2", "--dry-run", "c.txt"}) ParamModule.Show(param1) Console.WriteLine() Dim param2 = ParamModule.Parse({"--level", "2", "--output-name", "hoge.exe", "a.txt", "b.txt"}) ParamModule.Show(param2) End Sub End Module
{Level = 2; IsDryRun = true; OutputName = None; Files = ["c.txt"];} {Level = 2; IsDryRun = false; OutputName = Some "hoge.exe"; Files = ["b.txt"; "a.txt"];}
言語のちゃんぽんはあまりオススメ出来ませんが、異なるパラダイムをごった煮出来るのが.NET Frameworkの良さ*1なのだと思います。
おわりに
まぁ、例のごとくgithubから見つけてきた手法の紹介でアレなのですが、githubは宝の山ですね。
このライブラリはどうやってこの機能を実現しているのだろうと思ったときに調べればすぐに出てきますし、すごい人はやっぱり凄いなと感心するばかりです。
あと、本題とは違う形での紹介で申し訳ないのですが、このPersimmonと言うユニットテストフレームも結構いい感じなので、興味があろうとなかろうと一回使ってみるといいかもしれません。
おわり
VB.NETでもクラスとモジュールの違いを知りたい
はじめに
皆さんは新人くんの
VBのクラスとモジュールの違いは何ですか?
の質問にどのように答えているでしょうか。
こんな時の対応は大体以下の感じになると思います。
- よくわからないから「ググれ」で済ませて軽蔑される
- 機能面での話をさらっとして尊敬される
- 機能面での話をすっとばして内部実装の細かい話をしてドン引きされる
正直、2.の機能面の話は死ぬほどいろんなところで書かれているはず*1なので、ここでは3.の方面でまとめてみようと思います。
仕様上のモジュール
過去の記事でVBの仕様書が見つからないと言ったな。アレは嘘だ。
というわけでVisual Studio 2017をインストールするとx64環境だと
C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VB\Specifications\1041
あたりに付いてくるっぽいです。しかも日本語で
今回の件から弊社が得る教訓は"SDKをインストールした際についてくるドキュメントも存外馬鹿にはできない"ということだ。
さて、仕様書からモジュールについて引用すると
"標準モジュール" は、メンバーが暗黙的に
Shared
である型です。標準モジュールのスコープは、標準モジュール宣言自体だけではなく、標準モジュールが含まれる名前空間の宣言空間まで及びます。標準モジュールはインスタンス化できません。標準モジュール型の変数を宣言すると、エラーになります。標準モジュールのメンバーには 2 つの完全修飾名があります。1 つは標準モジュール名を持たず、もう 1 つは標準モジュール名を持ちます。名前空間内の複数の標準モジュールで、特定の名前の 1 つのメンバーを定義できます。そのため、いずれかのモジュールの外部でその名前を修飾せずに参照すると、あいまいになります。
~中略~
モジュールは名前空間内でのみ宣言でき、別の型の中で入れ子にすることはできません。標準モジュールはインターフェイスを実装できません。標準モジュールは暗黙的に
Object
から派生し、Shared
コンストラクターだけを持ちます。標準モジュールのメンバーは、メンバー宣言によって導入されたメンバーと、
Object
から継承されたメンバーです。標準モジュールは、インスタンス コンストラクター以外の任意の型のメンバーを持つことができます。標準モジュール型のすべてのメンバーは、暗黙的にShared
になります。通常、標準モジュールのメンバー宣言に指定できるのは、
Public
、Friend
、またはPrivate
のアクセスだけです。ただし、Object
を継承するメンバーをオーバーライドする場合は、Protected
およびProtected Friend
のアクセス修飾子も指定できます。標準モジュールのメンバー宣言にアクセス修飾子が含まれない場合は、既定でPublic
アクセスが宣言されます。ただし、変数の場合、既定はPrivate
アクセスです。前述のとおり、標準モジュール メンバーのスコープは、標準モジュール宣言が含まれる宣言です。
Object
を継承するメンバーはこの特殊なスコープに含まれません。これらのメンバーにはスコープがなく、常にモジュールの名前で修飾する必要があります。メンバーにFriend
アクセスが指定されている場合、そのスコープは同じプログラムまたはFriend
アクセスが指定されたアセンブリ内で宣言された名前空間メンバーだけに及びます。"拡張メソッド" を使用すると、型宣言の外部からメソッドを型に追加できます。拡張メソッドとは、
System.Runtime.CompilerServices.ExtensionAttribute
属性が適用されているメソッドのことです。拡張メソッドは、標準モジュール内でだけ宣言でき、メソッドによって拡張される型を指定する 1 つ以上のパラメーターを持つ必要があります。Microsoft(R)Visual Basic(R)言語仕様 - (C) 2012 Microsoft Corporation.All Rights Reserved.
だそうです。
実用上で注意しないといけないのは
- モジュール内のメンバーはすべて
Shared
(C#でいうstatic
)になる - モジュール型の変数は宣言できない
- 名前空間内でのみ宣言でき、入れ子で宣言できない
- モジュールのメンバー宣言に指定できるのは、
Public
、Friend
(C#のinternal
)、またはPrivate
- 拡張メソッドはモジュールにしか宣言できない
- 名前空間内で一意なメンバー名であればモジュール名を省略できる
といったところです。
ここまで説明出来て100点です。
モジュールの内部実装
表面上の使い方で満足しないのが我々エクストリームVBerです。
単純なコードのILを確認してみましょう。
なお、ここでは以下の環境で検証しています。
- Windows10 Pro x64 1703
- Visual Studio 2017 Community
Public Module Hello Public Sub Hoge() Console.WriteLine("Hello") End Sub End Module
.class public sealed auto ansi ConsoleApp1.Hello extends [mscorlib]System.Object { .custom instance void [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.StandardModuleAttribute::.ctor() = (01 00 00 00 ) .method public static void Hoge() cil managed { .maxstack 8 IL_0000: nop IL_0001: ldstr "Hello" IL_0006: call void [mscorlib]System.Console::WriteLine(string) IL_000b: nop IL_000c: ret } }
つまり、以下のコードとほぼ等価なことが分かります。
<Microsoft.VisualBasic.CompilerServices.StandardModule> Public NotInheritable Class World Private Sub New() ' Nothing to do. End Sub Public Shared Sub Hoge() Console.WriteLine("World") End Sub End Class
.class public sealed auto ansi ConsoleApp1.World extends [mscorlib]System.Object { .custom instance void [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.StandardModuleAttribute::.ctor() = (01 00 00 00 ) .method private specialname rtspecialname instance void .ctor() cil managed { .maxstack 8 IL_0000: nop IL_0001: ldarg.0 // this IL_0002: call instance void [mscorlib]System.Object::.ctor() IL_0007: nop IL_0008: ret } .method public static void Hoge() cil managed { .maxstack 8 IL_0000: nop IL_0001: ldstr "World" IL_0006: call void [mscorlib]System.Console::WriteLine(string) IL_000b: nop IL_000c: ret } }
デフォルトコンストラクタはさすがに封殺できないので、プライベートコンストラクタを明示的に宣言することでデフォルトコンストラクタを消し去っています。
StandardModuleAttribute
がキモのようで、VBコンパイラはこの属性が付与されたクラスをモジュールとして認識しているようです。
そのため、C#のクラスにStandardModuleAttribute
を付与するだけでVBから利用できないクラスを爆誕させることが出来ます。
余談ですが、同一ソリューション内であればVBコンパイラはStandardModuleAttribute
が付与されたクラスとモジュールを区別できるようです。
これがロスリンの力か・・・
おわりに
個人的な話でアレですが、弊社としては拡張メソッドを定義する以外ではモジュールは使いたくないですね。 オブジェクト指向に反するというのも若干ありますが、別に弊社はオブジェクト指向原理主義ではないのでそれについてはあまり気にしません。 やはり名前空間の汚染は正直気持ちのいいものではありませんし、レガシーVBの作法を持ち込まれてもねぇ・・・といった感じです。*2
おわり
*1:調べてはいない
*2:そもそもで言えば、VB.NETという言語自体がレガシーVBの作法を.NET Frameworkに持ち込むための言語なのですが
VB.NETとログについての伺か
はじめに
弊社はロギングライブラリとかの扱いについて割と長い間悩んでいたのですが、自分の中のとりあえずの答えが出たので今回はそんな感じです。
ログライブラリへの強依存
さて、弊社はログライブラリとしてNLogに慣れ親しんでいるのでサンプル中で使うライブラリとしてNLogを使用しますが、Serilogやlog4netを使用しても大して変わらないと思います。たぶん*1
さて、NLogでログを取得するコードを書くとすると以下のような感じになるのではないでしょうか。
Module Module1 Sub Main() Dim model = New SomeModel() Console.WriteLine($"{model.Add(1, 2)}") Console.WriteLine($"{model.Subtract(2, 1)}") Console.WriteLine($"{model.Subtract(1, 2)}") End Sub End Module Class SomeModel Private Shared ReadOnly Logger As NLog.ILogger = NLog.LogManager.GetCurrentClassLogger() Function Add(x As Integer, y As Integer) As Integer Dim result = x + y Logger.Info("{0} + {1} = {2}", x, y, result) Return result End Function Function Subtract(x As Integer, y As Integer) As Integer Dim result = x - y If result < 0 Then Logger.Warn("{0} - {1} is negative ({2})", x, y, result) Return 0 Else Logger.Info("{0} - {1} = {2}", x, y, result) Return result End If End Function End Class
はい、そうですね。べっとりNLogに依存しちゃってます。 仮に他のロギングライブラリに移行することになった場合、ロギングコードを修正する必要が出てきますし、細かくログを取得しようとしていた場合には修正箇所は相当な数になるはずです。
インタフェース化とIoCコンテナ
はいはいインターフェースインターフェース。 メソッドをインターフェースに切り出すことができればあとはIoCコンテナとかを使って外部からロガーを注入してみんなハッピーになれます。
と、ここで壁にぶち当たりました。
どのようなメソッドとしてインタフェースを切り出しましょう
NLogやSerilog、log4netではログの書き出しメソッドはログレベルとして実装されています。 まずはログレベルで検討してみましょう。
NLog | Serilog | log4net | 簡単な説明 |
---|---|---|---|
Trace | Verbose | 該当なし | めっちゃ細かい |
Debug | Debug | Debug | デバッグ情報 |
Info | Information | Info | 通常のイベント情報 |
Warn | Warning | Warn | クリティカルではない警告 |
Error | Error | Error | エラー |
Fatal | Fatal | Fatal | ちょー致命的 |
という感じで多少であるものの、各ロギングライブラリで使用できるログレベルには差異があります。
また、NLogとlog4netはメッセージを出力するタイプのログライブラリですが、Serilogは構造化ログライブラリなのでインターフェースでメソッドのシグネチャを統一しても幸せになれそうではありません。
ではどのようなインタフェースを用意すればいいのかと言いますと、ログイベントを意味論としてメソッドを用意してあげれば良いのではとの結論に至りました。
先ほどの例ですと、以下のような感じになります。
Interface IMyLogger Sub CalcAdd(x As Integer, y As Integer, result As Integer) Sub CalcSubtract(x As Integer, y As Integer, result As Integer) Sub SubtractResultIsNegative(x As Integer, y As Integer, result As Integer) End Interface
この方法ですとプロジェクトごとにインターフェースが異なりますが、そもそもログイベントもプロジェクトごとに異なるんだからそもそもプロジェクトにまたがって共通化する必要はありませんよね。
また、イベントの種類だけメソッドが増大するという問題もありますが、そちらは、まぁ、その、いい感じにお願いします。
そんな感じで、Simple Injectorを使いつつ書き直したのが以下になります。
Imports NLog Imports SimpleInjector Module Module1 Sub Main() Dim c = New Container() Dim logger = New MyLogger(LogManager.GetCurrentClassLogger()) c.RegisterSingleton(Of IMyLogger)(logger) c.Register(Of SomeModel)() c.Verify() Dim model = c.GetInstance(Of SomeModel)() Console.WriteLine($"{model.Add(1, 2)}") Console.WriteLine($"{model.Subtract(2, 1)}") Console.WriteLine($"{model.Subtract(1, 2)}") End Sub End Module Class SomeModel Private ReadOnly _logger As IMyLogger Sub New(logger As IMyLogger) _logger = logger End Sub Function Add(x As Integer, y As Integer) As Integer Dim result = x + y _logger.CalcAdd(x, y, result) Return result End Function Function Subtract(x As Integer, y As Integer) As Integer Dim result = x - y If result < 0 Then _logger.SubtractResultIsNegative(x, y, result) Return 0 Else _logger.CalcSubtract(x, y, result) Return result End If End Function End Class Interface IMyLogger Sub CalcAdd(x As Integer, y As Integer, result As Integer) Sub CalcSubtract(x As Integer, y As Integer, result As Integer) Sub SubtractResultIsNegative(x As Integer, y As Integer, result As Integer) End Interface Class MyLogger Implements IMyLogger Private ReadOnly _nlog As ILogger Public Sub New(logger As ILogger) _nlog = logger End Sub Public Sub CalcAdd(x As Integer, y As Integer, result As Integer) Implements IMyLogger.CalcAdd _nlog.Info("{0} + {1} = {2}", x, y, result) End Sub Public Sub CalcSubtract(x As Integer, y As Integer, result As Integer) Implements IMyLogger.CalcSubtract _nlog.Info("{0} - {1} = {2}", x, y, result) End Sub Public Sub SubtractResultIsNegative(x As Integer, y As Integer, result As Integer) Implements IMyLogger.SubtractResultIsNegative _nlog.Warn("{0} - {1} is negative ({2})", x, y, result) End Sub End Class
この辺のアイデアは弊社が独自で思いついたというよりはSLABの概念とかLightNodeのコードをパクった参考にしたところがあるのでオリジナルへのリンクを付け加えておきます。
余談(ってほど余談でもない)
NLogの${callsite}
とかのスタックフレームのスキップ
<target xsi:type="ColoredConsole" name="console" layout="${longdate} ${uppercase:${level}} ${message}${newline}${stacktrace}${newline}${callsite}" />
みたいなレイアウトを設定している場合、出力結果は
2017-05-06 01:26:54.5080 INFO 1 + 2 = 3 Module1.Main => SomeModel.Add => MyLogger.CalcAdd ConsoleApp2.MyLogger.CalcAdd 3 2017-05-06 01:26:54.5281 INFO 2 - 1 = 1 Module1.Main => SomeModel.Subtract => MyLogger.CalcSubtract ConsoleApp2.MyLogger.CalcSubtract 1 2017-05-06 01:26:54.5281 WARN 1 - 2 is negative (-1) Module1.Main => SomeModel.Subtract => MyLogger.SubtractResultIsNegative ConsoleApp2.MyLogger.SubtractResultIsNegative 0
みたいになって、ログの実装クラスがしゃしゃり出てきてもんにょりします。
そんな時はskipFrames=Integer
を設定してあげるといい感じになります。
<target xsi:type="ColoredConsole" name="console" layout="${longdate} ${uppercase:${level}} ${message}${newline}${stacktrace:skipFrames=1}${newline}${callsite:skipFrames=1}" />
2017-05-06 01:30:57.3469 INFO 1 + 2 = 3 Module1.Main => SomeModel.Add ConsoleApp2.SomeModel.Add 3 2017-05-06 01:30:57.3700 INFO 2 - 1 = 1 Module1.Main => SomeModel.Subtract ConsoleApp2.SomeModel.Subtract 1 2017-05-06 01:30:57.3700 WARN 1 - 2 is negative (-1) Module1.Main => SomeModel.Subtract ConsoleApp2.SomeModel.Subtract 0
IoCコンテナとサービスロケーター
IoCコンテナを使い始めると
コンテナをコンストラクタ引数で渡して、コンテナから必要なオブジェクトを取り出せばいいんだ
みたいに勘違いしやすいと思います。というよりは弊社は勘違いしていました。
今回ので例を示すとこんな感じですね。
Class SomeModel Private ReadOnly _logger As IMyLogger Sub New(container As Container) _logger = container.GetInstance(Of IMyLogger)() End Sub Function Add(x As Integer, y As Integer) As Integer '... End Function Function Subtract(x As Integer, y As Integer) As Integer '... End Function End Class
これには以下のデメリットが発生します。
- 本来
IMyLogger
だけに依存したいはずなのに、IoCコンテナ(Container
)にも依存してしまっている- 本来なら必要のない別のクラスに依存することになり、依存を減らしたいはずなのに逆に増えてしまっている
- そのクラスが依存しているインターフェース・クラスがコンストラクタのシグネチャから判別がつかない
- コンテナが保持するインターフェース・クラスをすべて利用することが出来てしまうため、不用意に不要なクラスへアクセス出来てしまうため、なんかやばい
なんで、まぁサービスロケーターとして使うのはやめましょうねって感じで。
おわりに
弊社は最近では自動化ツールを作ったりコンソールアプリケーションを作成する機会が多いです。
そんな中、どのみち必要になるから最初からNuGetでぶち込んでおけと思うのが以下の3つです。
今回のでロギングライブラリのもんにょりが消えたので、残るはコマンドラインオプションパーサーになるのですが、今のところしっくりくるのがありません。
なければ作るしかないっぽいのでアレですが、当面はいい感じのを探していこうと思っています。
おわり