🍎

AppleのヘルスケアのXMLをCSV変換するGo製のCLIツールを作った

2024/10/10に公開

はじめに

iOSなどで体重やApple Watchから得られる心拍数を記録する「ヘルスケア」アプリは下記の画像のように収集されたデータをエクスポートできる。


ヘルスケアでデータをエクスポートする

このデータはZIP圧縮されたXMLとなっており、これを体重や心拍数などの項目ごとCSVなどにできればExcelといったツールでグラフ化できて便利そうである。そうしたツールとしてすでにJavaScript(JS)で書かれたahcdがあり、下記の記事で使い方などが紹介されている。

https://qiita.com/freddiefujiwara/items/9c6cbd9fb8c959ec4ae4

この記事では、なぜすでに使えるツールがあるにも関わらず新しくGo製のツールを作ったのか理由を説明する。なお、作ったツールは下記のGitHubリポジトリーで公開している。

https://github.com/y-yu/ahcd-go

既存の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としてパーズするというコーディングに起因していると考えられる。

https://github.com/freddiefujiwara/ahcd/blob/29b7a08e20512e48497d4977bbb843cc6c3ab507/bin/ahcd.js#L27
https://github.com/freddiefujiwara/ahcd/blob/29b7a08e20512e48497d4977bbb843cc6c3ab507/src/AppleHealthCareData.js#L19-L20

最初はすでに動くものがあるということで、このJSのプログラムを下記の方針で改造することを考えた。

  1. fs.readFileSyncではなくてfs.createReadStreamでストリームでXMLファイルを読み込む
  2. XMLのパージングを、文字列で入力を取らざるを得ないelementtreeから、ストリームで処理できるsaxへ変更する

これらの変更を行った差分が下記のようになる[1]

https://github.com/freddiefujiwara/ahcd/compare/master...y-yu:ahcd:use-sax-parser

このバージョンで再度、筆者の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を作るという実装になっている。

https://github.com/freddiefujiwara/ahcd/blob/29b7a08e20512e48497d4977bbb843cc6c3ab507/src/AppleHealthCareData.js#L40-L49

恐らくはこのthis.resultsが多すぎる状況になってしまったものと思わる。このようなコーディングは、恐らくJSのオブジェクトとしていったん保持すれば単体テストでJSのデータ構造を比較すればよいとなって簡単になるからだと思われる。

https://github.com/freddiefujiwara/ahcd/blob/29b7a08e20512e48497d4977bbb843cc6c3ab507/__test__/AppleHealthCareData.spec.js#L18-L29

単体テストをどうするのかはとりあえず放置するとして、OOMエラー解決のためにはXMLから見つけた情報を(ある程度)直ちにCSVファイルに書きだしてメモリを開放できるようにする必要があると考えられる[2]。このあたりで筆者が動的型付け言語を書けるキャパシティーを突破してしまった(?)ので、ここまで改造するならもはや既存のJSコードを使わなくてもいいのでは🤔となってGoで開発することにした。

Go製のahcd-go

前節で既存のahcdには大きく次の2つの問題があって筆者のXMLファイルを変換できないことが分かった。

  1. XMLファイルを全て読み込もうとして文字列の長さの限界を越える
  2. XMLから得たデータを全て連想配列に乗せようとしてOOMとなる

よって下記の方針で実装した。

  1. ファイルからXMLのトークン[3]単位でパーズする
  2. 書き出すべきデータがあれば、それを直ちにCSVファイルへ書き出す

XMLのパーズ

まず(1)についてはGoの標準ライブラリーであるencoding/xmlには文字列ではなくて入力ストリームでパーズし、トークンごと処理する機能があったため、それを使っただけで実装できた。これを用いて狙ったXMLに遭遇した場合、そのデータを体重などの種別ごとに作成したチャネルに送信するような実装とした。

https://github.com/y-yu/ahcd-go/blob/b8e3c31e62c9a2b12174666d10c8e913ea4a3319/main.go#L49-L65

CSVへの書き出しとテスタビリティー

続いて(2)についてだが、前述のとおりメモリにいったん乗せるほうが単体テストがやりやすくなるもののOOMエラーとなる危険性がある。そこで次のようなインターフェースWriteCSVを導入した。

main.go
type WriteCSV interface {
	Execute(name string, ch chan RecordValue) error
}

このインターフェースはnameとして例えば体重などのデータの種目と、80kgといったデータRecordValueを受信できるチャネルchを受け取り、場合によってはエラーを返すようになっている。
本番用の実装では、次のように実際にCSVファイルを開いたうえでチャネルからデータを受信次第直ちに書き込むようになっている。このようにすることでJS実装で問題となっていたOOMエラー対策になると考えられる。

main.go
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に受信したデータを配列に追加する。

main_test.go
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]

main_test.go
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で書いておくことで将来の機能追加もしやすくなったと思う。

脚注
  1. ついでにwebpackのバージョンが古く、最新版の設定ファイルと互換性を失っていたのでマイグレーションした。 ↩︎

  2. あるいはJVMの-XmxのようにNodeのヒープサイズを調整するという手もあるか🤔 ↩︎

  3. 筆者も実は「XMLのトークン」の定義を正確に理解しているわけではないが、Goのenconding/xmlTokenではStartElementEndElementなどのの種別が定義されているので、XMLのノードのうちの一部みたいなイメージだと思われる。 ↩︎

  4. 本当はXMLを読み込むところもWriteCSVのようにインターフェースに切りだして、テスト時は文字列を直接XMLを入力できるようにしたほうがストレージやファイルシステムに依存しなくなってよりよくなるかもしれない。 ↩︎

Discussion