はじめに
ここまでで、
- Linux から情報を拾う
list / show ioctlで NVMe command を打つ入口IdentifySMART LogError Information Log
まで実装してきました。
機能が増えてくると、次に気になってくるのが「どこに何を書けばいいのか」です。
特に責務の分割について、途中で何度も考え直す羽目になりました。
今回は、v0 を作りながら最終的にどう分けたかを整理します。
最初は「とりあえず動けばいい」で始まる
実装の最初の段階では、
- command を呼ぶ
- device を探す
- 必要な情報を取る
- そのまま表示する
という流れを 1 箇所にまとめがちでした。
これは最初の 1 本を動かすだけなら自然です。
ただ、Identify の次に SMART、さらに Error Log へ進んでいくと、同じような処理が何度も出てきます。
例えば、
- serial で対象 device を探す
- mock / real を切り替える
- access 権限を確認する
IdentifyやGet Log Pageを呼ぶ- 結果を表示する
のような流れです。
この段階になると、実装側が増えてきます。そのため、なるべく各動作を共通化したくなりますし、その処理はどこでまとめるか、を考えたくなってきます。
v0での最終的な構成
v0では以下の構成になりました。
classDiagram
class main_cpp {
+main(argc, argv)
+DescribeError(err)
+ToExitCode(err)
}
class cli_cpp {
+run_app(argc, argv)
-ParseOptions(argc, argv)
-ParseSourceValue(...)
-ParseCountvalue(...)
-PrintNoValueError(cmd, name)
-PrintInvalidArgument(cmd, ex_args)
-PrintCommandUsage(cmd)
}
class identify_cpp {
+RunIdentify(serial, src)
}
class get_log_page_cpp {
+RunGetErrorLog(serial, src, err_count)
+RunGetSmart(serial, src)
}
class NvmeDevice {
<<interface>>
+Info() const
+IdentifyController(out) const
+IdentifyNamespace(out) const
+GetErrorLog(...)
+GetSmart(...)
}
class MockNvmeDevice {
+Info() const
+IdentifyController(out) const
+IdentifyNamespace(out) const
+GetErrorLog(...)
+GetSmart(...)
}
class LinuxNvmeDevice {
+Info() const
+IdentifyController(out) const
+IdentifyNamespace(out) const
+GetErrorLog(...)
+GetSmart(...)
}
class DeviceInfo {
+serial
+is_allowed
+block_dev_path
+char_dev_path
}
class IdentifyControllerInfo
class IdentifyNamespaceInfo
class LogErrorInfo
class LogSmart
main_cpp --> cli_cpp : call
cli_cpp --> identify_cpp : identify
cli_cpp --> get_log_page_cpp : errinfo / smart
identify_cpp --> NvmeDevice : use
get_log_page_cpp --> NvmeDevice : use
NvmeDevice <|-- MockNvmeDevice
NvmeDevice <|-- LinuxNvmeDevice
NvmeDevice --> DeviceInfo : returns
identify_cpp --> IdentifyControllerInfo : output
identify_cpp --> IdentifyNamespaceInfo : output
get_log_page_cpp --> LogErrorInfo : output
get_log_page_cpp --> LogSmart : output
それぞれのクラスの概要について、概要だけですが解説していきます。
NvmeDevice は device access interface に寄せた
最終的に、NvmeDevice は NVMe device に対する操作 interface として扱う方針にしました。
つまり、NvmeDevice に持たせるのは、
IdentifyController(...)IdentifyNamespace(...)GetLogPage_Smart(...)GetLogPage_ErrorInfo(...)
のような、device に対する操作そのものです。
逆に、ここへは入れないようにしたものもあります。
- command line 引数の解釈
- serial で対象を探す処理
- どの情報を画面にどう表示するか
このようにしておくと、NvmeDevice は「device に触る人」であり、command の都合までは持たない形になります。
RunXXX() は orchestration に寄せた
では、serial で対象 device を探したり、表示したりする処理はどこに置くかというと、RunXXX() 側です。
RunIdentify() や RunGetSmart() のような関数には、
LoadNvmeDevices()で device 一覧を取る- serial で対象を探す
- access 可能かを確認する
NvmeDeviceのメソッドを呼ぶ- 取得結果を表示する
という流れを持たせています。
ここでは device access そのものではなく、command を実行する流れ全体を組み立てること を責務としています。
この分け方にすると、
NvmeDevice
device accessRunXXX()
command orchestration
という役割の違いがかなりはっきりします。
mock / real の差分は LoadNvmeDevices() の下に閉じ込めた
もうひとつ大きかったのが、mock と real をどこで吸収するかです。
ここは最終的に、
LoadNvmeDevices(source)
が std::unique_ptr<NvmeDevice> の配列を返す形にしました。
つまり、command 側から見ると、
- 返ってくるのは
NvmeDevice - それが mock なのか real なのかは知らなくていい
という状態です。
これにより、RunXXX() 側は
- source に応じて読み込み方法を切り替える
のではなく、
- source に応じて作られた
NvmeDeviceを使う
だけで済むようになりました。
結果データは *_info.h に寄せた
途中でかなり迷ったのが、取得結果の置き場です。
最終的には、
IdentifyControllerInfoIdentifyNamespaceInfoLogSmartLogErrorInfo
のような結果型を *_info.h に寄せました。
これは、「device access interface」と「結果データ」に責務自体を分けたかったからです。
NvmeDevice 自体が大量の field を持つのではなく、
NvmeDeviceは取ってくるInfoは結果を保持する
という形にした方が、後で SMART や Error Log を増やした時にも整理しやすくなりました。
また、Identify や SMART では raw bytes の decode も Info::LoadFromBytes(...) に寄せています。
これは、
- spec の byte layout を解釈する責務
を 1 箇所に寄せるためです。
表示は PrintXxxInfo(...) 側に寄せた
かなり悩んで、結局これだという解が見いだせなかったのが、結果型自身に表示メソッドを持たせるかどうかでした。
最終的には、
PrintIdentifyControllerInfo(...)PrintIdentifyNamespaceInfo(...)PrintSmart(...)PrintErrorLog(...)
のような free function に寄せています。
今回は、表示は device access でも decode でもなく、command 側の責務だと考えて、この形になりました。
例えば SMART では、
- 温度を
KとCで出す %を付けるData Unitsに補助表示を付けるError Count == 0の entry を見せない
のように、表示の判断がかなり多く入ります。
これを data 型の中に入れるより、表示関数として外に出した方が後で直しやすい形になりました。
振り返り: クラス図を書くと次の分割候補も見えてくる
今回クラス図を書いてみて、v0 としては十分整理できた一方で、次に分けたくなる責務も見えてきました。
特に気になったのは次の 2 点です。
DeviceInfoはNvmeDeviceinterface からさらに独立してよいのではないか- serial で対象 device を引く処理と、
RunXXX()で command を実行する処理は分けてもよいのではないか
今の v0 では、NvmeDevice::Info() を通して serial や path を返しています。
ただ、NvmeDevice 自体は本来 device access interface なので、
IdentifyController(...)IdentifyNamespace(...)GetLogPage_Smart(...)GetLogPage_ErrorInfo(...)
のような操作だけを持っていても成立しそうです。
同じように、RunXXX() も今は
- device 一覧の取得
- serial での対象探索
- access check
- command 実行
- 表示
までまとめて持っていますが、今後さらに command が増えるなら「対象 device を解決する層」を別に置いた方がすっきりしそうです。
v0 ではここまで踏み込まず、まずは動く構成を整理するところまでに留めました。
ただ、v1 に向けて設計を見直すなら、こうした分割は最初に考えておきたい論点だと感じています。
これに関しては、表示メソッドもデータ型の責務として持たせたほうがいいのか、いまだに解を見いだせないです。
open -> ioctl -> close は executor に切り出した
さらに実装が進んでから整理したのが、admin command を投げる骨格です。
Identify も SMART も Error Log も、
- char device を開く
nvme_admin_cmdを埋めるioctlを呼ぶ- 閉じる
という骨格自体は同じです。
そこで、この共通部分は NvmeAdminExecutor に切り出しました。
一方で、
opcodensidcdw10-15buffer size
のような command 固有の意味は、それぞれの実装側に残しています。
つまり、
- executor は「どう送るか」
- feature 側は「何を送るか」
という分け方です。
v0 ではここまでに留めた
ここまで整理した一方で、v0 の段階ではあえて踏み込まなかったこともあります。
- CLI error の全面整理
- error code の細分化
- 全 command を
Identify前提にする初期シーケンス - lower layer の異常系テスト
このあたりは、必要性は見えていても、v0 でやると影響範囲が大きすぎると判断しました。
そのため、v0 では
- 実際に困った責務分割だけ解く
- 大きな再設計は
Followups.mdに逃がす
という進め方にしています。
まとめ
今回の v0 で落ち着いた責務分割をまとめると、次のようになります。
NvmeDevice
device access interfaceRunXXX()
command orchestrationLoadNvmeDevices()
mock / real の差分吸収*_info.h
取得結果の保持と decodePrintXxxInfo(...)
表示NvmeAdminExecutor
open -> ioctl -> closeの共通骨格
最初からこの形を決めていたわけではありません。
実際には Identify、SMART、Error Log を増やしながら、何度も責務を見直してこの形に落ち着きました。
個人的には、設計は最初に全部決め切るものというより、機能を増やした時に同じつらさが何度も出る場所を切り出していくものだと実感しています。