AppleのヘルスケアのXMLをCSV変換するGo製のCLIツールを作った
はじめに
iOSなどで体重やApple Watchから得られる心拍数を記録する「ヘルスケア」アプリは下記の画像のように収集されたデータをエクスポートできる。
ヘルスケアでデータをエクスポートする
このデータはZIP圧縮されたXMLとなっており、これを体重や心拍数などの項目ごとCSVなどにできればExcelといったツールでグラフ化できて便利そうである。そうしたツールとしてすでにJavaScript(JS)で書かれたahcd
があり、下記の記事で使い方などが紹介されている。
この記事では、なぜすでに使えるツールがあるにも関わらず新しくGo製のツールを作ったのか理由を説明する。なお、作ったツールは下記のGitHubリポジトリーで公開している。
ahcd
の問題
既存の筆者のヘルスケアのデータをエクスポートしたデータであるexport.xml
は約2.9GBあるが、これを既存のahcd
で読み込むと下記のように文字列の最大サイズを超過したというような例外が発生してしまう。
$ ahcd export.xml
Read export.xml
node:fs:441
return binding.readFileUtf8(path, stringToFlags(options.flag));
^
Error: Cannot create a string longer than 0x1fffffe8 characters
at Object.readFileSync (node:fs:441:20)
at Object.<anonymous> (/opt/homebrew/lib/node_modules/ahcd/bin/ahcd.js:27:41)
at Module._compile (node:internal/modules/cjs/loader:1546:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1691:10)
at Module.load (node:internal/modules/cjs/loader:1317:32)
at Module._load (node:internal/modules/cjs/loader:1127:12)
at TracingChannel.traceSync (node:diagnostics_channel:315:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:217:24)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:166:5)
at node:internal/main/run_main_module:30:49 {
code: 'ERR_STRING_TOO_LONG'
}
Node.js v22.7.0
これは次のように引数で渡されたexport.xml
を全てメモリ上に文字列として読み込み、それをXMLとしてパーズするというコーディングに起因していると考えられる。
最初はすでに動くものがあるということで、このJSのプログラムを下記の方針で改造することを考えた。
-
fs.readFileSync
ではなくてfs.createReadStream
でストリームでXMLファイルを読み込む - XMLのパージングを、文字列で入力を取らざるを得ない
elementtree
から、ストリームで処理できるsax
へ変更する
これらの変更を行った差分が下記のようになる[1]。
このバージョンで再度、筆者のexport.xml
を読み込ませたところ次のようになった。
$ node ahcd.js ~/Downloads/apple_health_export/export.xml
Read /Users/yyu/Downloads/apple_health_export/export.xml
Analyze /Users/yyu/Downloads/apple_health_export/export.xml
(node:3351) [MODULE_TYPELESS_PACKAGE_JSON] Warning: file:///Users/yyu/Desktop/ahcd/bin/ahcd.js parsed as an ES module because module syntax was detected; to avoid the performance penalty of syntax detection, add "type": "module" to /Users/yyu/Desktop/ahcd/package.json
(Use `node --trace-warnings ...` to show where the warning was created)
<--- Last few GCs --->
[3351:0x148008000] 31332 ms: Scavenge (interleaved) 4049.9 (4128.2) -> 4046.7 (4130.7) MB, pooled: 0 MB, 12.88 / 0.00 ms (average mu = 0.422, current mu = 0.225) allocation failure;
[3351:0x148008000] 31399 ms: Scavenge (interleaved) 4052.2 (4130.7) -> 4048.9 (4148.9) MB, pooled: 0 MB, 61.54 / 0.00 ms (average mu = 0.422, current mu = 0.225) allocation failure;
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
1: 0x1004d69e4 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [/opt/homebrew/Cellar/node/22.7.0/bin/node]
2: 0x10067d864 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [/opt/homebrew/Cellar/node/22.7.0/bin/node]
(省略)
このようにOOMとなってプロセスが死亡してしまう。下記のように既存のahcd
はXMLの解析を行いながら、解析結果をthis.results
といったメンバー変数に保存しておき、あとでこれを元にCSVを作るという実装になっている。
恐らくはこのthis.results
が多すぎる状況になってしまったものと思わる。このようなコーディングは、恐らくJSのオブジェクトとしていったん保持すれば単体テストでJSのデータ構造を比較すればよいとなって簡単になるからだと思われる。
単体テストをどうするのかはとりあえず放置するとして、OOMエラー解決のためにはXMLから見つけた情報を(ある程度)直ちにCSVファイルに書きだしてメモリを開放できるようにする必要があると考えられる[2]。このあたりで筆者が動的型付け言語を書けるキャパシティーを突破してしまった(?)ので、ここまで改造するならもはや既存のJSコードを使わなくてもいいのでは🤔となってGoで開発することにした。
ahcd-go
Go製の前節で既存のahcd
には大きく次の2つの問題があって筆者のXMLファイルを変換できないことが分かった。
- XMLファイルを全て読み込もうとして文字列の長さの限界を越える
- XMLから得たデータを全て連想配列に乗せようとしてOOMとなる
よって下記の方針で実装した。
- ファイルからXMLのトークン[3]単位でパーズする
- 書き出すべきデータがあれば、それを直ちにCSVファイルへ書き出す
XMLのパーズ
まず(1)についてはGoの標準ライブラリーであるencoding/xml
には文字列ではなくて入力ストリームでパーズし、トークンごと処理する機能があったため、それを使っただけで実装できた。これを用いて狙ったXMLに遭遇した場合、そのデータを体重などの種別ごとに作成したチャネルに送信するような実装とした。
CSVへの書き出しとテスタビリティー
続いて(2)についてだが、前述のとおりメモリにいったん乗せるほうが単体テストがやりやすくなるもののOOMエラーとなる危険性がある。そこで次のようなインターフェースWriteCSV
を導入した。
type WriteCSV interface {
Execute(name string, ch chan RecordValue) error
}
このインターフェースはname
として例えば体重などのデータの種目と、80kgといったデータRecordValue
を受信できるチャネルch
を受け取り、場合によってはエラーを返すようになっている。
本番用の実装では、次のように実際にCSVファイルを開いたうえでチャネルからデータを受信次第直ちに書き込むようになっている。このようにすることでJS実装で問題となっていたOOMエラー対策になると考えられる。
type WriteCSVImpl struct{}
func (_ WriteCSVImpl) Execute(name string, ch chan RecordValue) error {
fp, err := os.Create(name)
if err != nil {
return err
}
defer fp.Close()
writer := csv.NewWriter(fp)
err = writer.Write(
[]string{
"Unit",
"Value",
"SourceName",
"SourceVersion",
"Device",
"CreationDate",
"StartDate",
"EndDate",
},
)
for value := range ch {
err = writer.Write(
[]string{
value.Unit,
value.Value,
value.SourceName,
value.SourceVersion,
value.Device,
value.StartDate,
value.EndDate,
},
)
if err != nil {
return err
}
writer.Flush()
}
return nil
}
一方で、これとは別に次のようなテスト用の実装であるWriteMapInsteadOfCSV
を作っておく。こちらはWriteCSV
インターフェースに適合するものの、チャネルからデータを受信したとしてもファイル出力はせず、代わりにフィールドに持つマップResults
に受信したデータを配列に追加する。
type WriteMapInsteadOfCSV struct {
Results map[string][]RecordValue
}
func (this *WriteMapInsteadOfCSV) Execute(name string, ch chan RecordValue) error {
for value := range ch {
this.Results[name] = append(this.Results[name], value)
}
return nil
}
このようにすれば、入力されるXMLが小さくメモリに収まることが明確なテストにおいてはGoのデータ構造を使ったテストを次のように用意できる[4]。
func TestParseXML(t *testing.T) {
var actual = WriteMapInsteadOfCSV{map[string][]RecordValue{}}
var rootCmd = rootCmdConstructor(&actual)
output := new(bytes.Buffer)
rootCmd.SetOut(output)
rootCmd.SetErr(output)
rootCmd.SetArgs([]string{"test_data/export.xml"})
err := rootCmd.Execute()
assert.Nil(t, err)
assert.Equal(t, 1, len(actual.Results["HeartRate.csv"]))
assert.Equal(t, 2, len(actual.Results["BodyMassIndex.csv"]))
assert.Equal(t, 7, len(actual.Results["BloodPressureSystolic.csv"]))
}
最後に、出力先ディレクトリーの設定オプションなどは既存のahcd
のオプション体系をそのまま利用させていただいた。
動作例
GoReleaserを使って自動リリースするようしてあるため、GitHubのリリースページから好きな実行ファイルを持ってきて動作させることができる。
このGo版のahcd-go
で筆者のXMLを変換すると次のようになる。
$ ahcd-go -d tmp ~/Downloads/apple_health_export/export.xml
Done
約2.9GBある筆者のexport.xml
の場合、手元のApple M2 MacBook Airで約79秒でCSVへ変換できた。変換が完了すると下記のように項目ごとのCSVファイルが作成される。
$ ls tmp
.rw-r--r--@ 570M yyu 9 10 01:09 ActiveEnergyBurned.csv
.rw-r--r--@ 24M yyu 9 10 01:09 AppleExerciseTime.csv
.rw-r--r--@ 90k yyu 9 10 01:09 AppleSleepingWristTemperature.csv
.rw-r--r--@ 12M yyu 9 10 01:09 AppleStandHour.csv
.rw-r--r--@ 9.4M yyu 9 10 01:09 AppleStandTime.csv
.rw-r--r--@ 37k yyu 9 10 01:09 AppleWalkingSteadiness.csv
.rw-r--r--@ 70k yyu 9 10 01:09 AudioExposureEvent.csv
.rw-r--r--@ 222M yyu 9 10 01:09 BasalEnergyBurned.csv
.rw-r--r--@ 86k yyu 9 10 01:08 BodyFatPercentage.csv
.rw-r--r--@ 152k yyu 9 10 01:08 BodyMass.csv
.rw-r--r--@ 86k yyu 9 10 01:08 BodyMassIndex.csv
(省略)
そして、このCSVをExcelやGoogle Sheetsなどで読み込めばグラフにすることができる。
BodyMass.csv
から生成した体重グラフ
まとめ
はじめてGoのCLIツールを作成したが、自分にとって必要なツールを割と簡単に作ることができてよかった。GoはCLIを作るための周辺ツールや情報がそろっていて始めやすいのもよかった。個人的な話として動的型付き言語は苦手なので、今後も使うツールを静的型付き言語であるGoで書いておくことで将来の機能追加もしやすくなったと思う。
-
ついでにwebpackのバージョンが古く、最新版の設定ファイルと互換性を失っていたのでマイグレーションした。 ↩︎
-
あるいはJVMの
-Xmx
のようにNodeのヒープサイズを調整するという手もあるか🤔 ↩︎ -
筆者も実は「XMLのトークン」の定義を正確に理解しているわけではないが、Goの
enconding/xml
のToken
ではStartElement
やEndElement
などのの種別が定義されているので、XMLのノードのうちの一部みたいなイメージだと思われる。 ↩︎ -
本当はXMLを読み込むところも
WriteCSV
のようにインターフェースに切りだして、テスト時は文字列を直接XMLを入力できるようにしたほうがストレージやファイルシステムに依存しなくなってよりよくなるかもしれない。 ↩︎
Discussion