エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

人間にUnicode正規化は難しい

【AI・機械学習チーム ブログリレー2日目】

AI・機械学習チームの池嶋 (@mski_iksm) です。

私達のチームでは、機械学習バッチの実行方法やインターンを含む新配属者のPC初期セットアップ手順など多くのドキュメントがGitLab上で管理されています。Gitでドキュメントを管理するのは、Wiki等と比較して更新時のピアレビューがしやすかったり、CIによる自動チェックがやりやすかったりなどのメリットから採用されています。

CIの自動チェックの1つとしてリンクチェッカーがあります。これは切れているリンクがないかを更新時にチェックするものです。

ある日、ファイルはあるように「見える」のに、なぜかリンクチェッカーのCIが落ちているという事象が発生しました。

タイトルでネタバレしているのですが、原因はUnicodeの正規化でした。 この記事では、何が起きていたのか?どのようなケースで起こりうるのか?どう回避すればいいのか?に加え、macのファイルシステムや各アプリケーションのUnicodeの対応を紹介します。

問題:リポジトリ内のファイル間でリンク切れが発生

今回、ドキュメントに記載されているリポジトリ内の他ファイルへの一部リンクが切れていました。 ファイル名は「XXXデータのXXXX」となっており、一見したところ参照ファイルは存在していそうでしたが、リンクを踏んでみるとたしかに404 Not Foundでした。

原因はこの「デ」の文字のコードポイントが異なっていることです。 実ファイル名は「デ(\u30c7)」という1つのコードポイントで表現されている一方、ドキュメントに記載されているリンクは「デ」1文字を表現するのに\u30c6\u3099という2つのコードポイントを使用していました。 実ファイル名は「デ」という1文字で表現されているが、リンクは「テ」+「゛」という2文字で表現されており、後者を指し示すファイルは存在しないので、リンクが切れていたという訳です。

前者を合成済み文字、後者を結合文字列と呼びます。

原因:FinderとGitのUnicodeの扱いの違い

さて、なんでこんなことが発生していたのでしょうか? 手順は以下のとおりでした。

  1. Finderで合成済み文字「デ(\u30c7)」を利用してファイルを作成。
  2. ファイル名をドキュメントにコピー&ペーストしてリンクを張る。合成済み文字でリンクを張っているはず。
  3. ファイルとドキュメントをGitにコミット

この結果、ファイルもドキュメント内リンクも合成済み文字のファイル名になっている想定でした。

しかし実際には、

  1. Finderで合成済み文字「デ(\u30c7)」を利用してファイルを作成したが、Finderが合成済み文字を分解して結合文字列「デ(\u30c6\u3099)」に自動変換した。
  2. ファイル名をドキュメントにコピー&ペーストしてリンクを張る。Finderが作成した結合文字列「デ(\u30c6\u3099)」のファイル名でリンクを張った。
  3. ファイルとドキュメントをGitにコミットするが、Gitが結合文字列「デ(\u30c6\u3099)」のファイル名を合成済み文字「デ(\u30c7)」に自動変換した。

この結果、Git管理されている実ファイル名は合成済み文字「デ(\u30c7)」、ドキュメントに書かれたリンクは結合文字列「デ(\u30c6\u3099)」になるというねじれが発生しました。

Unicode正規化

見た目が同じ文字なのに実はコードポイントが違う…というのは扱う人間を混乱させる元となります。この問題を軽減させるためにあるのがUnicode正規化です。次の4つのパターンがあります。

  • NFD (Normalization Form Canonical Decomposition):視覚的・意味的に等価な文字列に分解し、結合文字列にする方法
  • NFC (Normalization Form Canonical Composition):NFDした上で、視覚的・意味的に等価な文字列を合成し、合成済み文字にする方法
  • NFKD (Normalization Form Compatibility Decomposition):NFDよりも緩い基準で文字列に分解(同一文字と判定する基準がゆるい。例えば1と①も同じ1とみなす)し、結合文字列にする方法
  • NFKC (Normalization Form Compatibility Composition):NFKDした上で、視覚的・意味的に等価な文字列を合成し、合成済み文字にする方法

macのFinderではこのうち、NFDを採用しています。そのため、合成済み文字「デ(\u30c7)」でファイルを作ろうとしても、結合文字列「デ(\u30c6\u3099)」の名前のファイルになってしまいます。

一方で、GitではNFCでファイル名を正規化しています。そのため、結合文字列「デ(\u30c6\u3099)」を合成済み文字「デ(\u30c7)」の名前のファイルに変換します。

macのtouchコマンドでは同名に見えるファイルが複数できてしまう?

mac(※APFSの場合)において、コマンドでファイルを作成する場合は正規化は行われません。例えば、touchコマンドでは正規化は行われず、合成済み文字「デ(\u30c7)」でも、結合文字列「デ(\u30c6\u3099)」でもファイルを作成できます。

ということは、名前が同じに見えるけど実はコードポイントが違うファイルを同時に存在させることができてしまうのでしょうか?これは「一時期できていたけど、最近のmac OS(macOS 10.12.6以降)ではできない」ようになりました。 macOS 10.12.6において、ランタイム正規化という仕組みが導入されました。これは、読み込み時に指定された正規化手法のファイルがない場合、他手法を試してファイルを探す仕組みです。例えば、ユーザーが結合文字列「デ(\u30c6\u3099)」のファイルを参照しようとしている場合、ファイルシステムが別の正規化手法(合成済み文字「デ(\u30c7)」)のファイルがないかも探してくれることで、実ファイル名がどちらになっていても参照できるという仕組みです。

touchコマンドでは確かに合成済み文字「デ(\u30c7)」でも、結合文字列「デ(\u30c6\u3099)」でもファイルを作成できるのですが、作成前にランタイム正規化を使ったファイルの存在チェックが走ります。ファイルの存在チェックにより、すでに別の正規化手法の同名に見えるファイルがあった場合はファイル作成がキャンセルされます。こうして同名に見えるファイルが複数できることを回避しています。

mv、cpコマンドでファイルを移動・コピーしてきた場合も同様です。移動・コピー先に対してランタイム正規化を使ってファイルの存在を事前チェックすることで、同名に見えるファイルがあれば操作をキャンセルします。ただし、移動・コピー先に新たに作成するファイル名は正規化されないので、好きな正規化手法のファイル名を指定できます。

mkdirコマンドも同様で、ランタイム正規化を使ってディレクトリの存在を事前チェックすることで、同名に見えるディレクトリがあれば操作をキャンセルします。

かつてのmacOSで採用されていたファイルシステムHFS+(10.12まで標準)では、NFDでファイル名が正規化されていました。後継となるAPFS(Apple File System)ではUnicodeの正規化機能を廃止しています。その移行期では上記ランタイム正規化のしくみが導入されるまで、なんと同名に見えるファイルを共存できていたようです。

eclecticlight.co

VSCode, PythonのUnicode対応

VSCode自身はUnicode正規化を行いません。そのため「デ(\u30c7)」も「デ(\u30c6\u3099)」も書き分けが可能です。ただし、Unicode Normalizerというプラグインを使うことで正規化も可能です。

Pythonでは、字句解析の際にNFKCでUnicode正規化が行われます。そのため、(やらないとは思うが)変数名を「デ(\u30c7)」と「デ(\u30c6\u3099)」で書き分けることはできず、どちらも同じ変数として扱われます。 また、import文に結合文字列「デ(\u30c6\u3099)」を書いてもNFKCで合成済み文字として読み込まれてしまいます。なので、例えばNFDであるFinderで作成したファイル「デ(\u30c6\u3099).py」をimportはできない点に注意が必要です。

ちなみに、はてなブログはテキスト中のUnicode正規化を行って無いようで、このブログ本文も「デ(\u30c7)」と「デ(\u30c6\u3099)」は書き分けられています。

対策とまとめ

今回、「Gitでcommitしたファイル名と別ファイルに記載しているリンク名のUnicode正規化手法がずれると、リンクが辿れなくなって困る」問題が発生していました。

この対策としては、最初に考えるべきはやはり「日本語など文字合成のややこしい言語は使わない」という点かと考えています。 しかし、ドキュメントなど日本語を含めたい場合もあり得ると思います。こういうケースでは「CIでドキュメント中の文字列を自動で再正規化する」や「VSCodeのプラグインを活用し、手元でも正規化し直しておく」といった対策が有効かと考えています。

We are hiring

バッチの実行方法などのドキュメントを整備することは他メンバーのためだけでなく、将来の自分のためにも有効です。 AIチームではこれを重視し、ML開発と平行してドキュメントもちゃんと書くことで生産性を高めていこうという文化ができています。 興味のある方は以下のURLからカジュアル面談をお待ちしています!

jobs.m3.com