アルパカ三銃士

〜アルパカに酔いしれる獣たちへ捧げる〜

Go で実装する Grapheme Cluster を基に文字を数えるバリデーション

Unicodeテキストを1文字ずつ分割するアルゴリズムUnicodeの仕様として定められており、grapheme cluster (書記素クラスタ)と呼ばれる。

hydrocul.github.io

最近 grapheme cluster というものを知った。(こういう概念は知ってたけど名前が知らなかった)

簡単に説明すると例えば という文字は人間にとって 1 文字と認識するが、コンピューターの世界ではコードポイント 2 つ(U+0061 U+0301)で構成される。

この令和の時代ではスマホが普及しているので何かしらのサービスを開発するとき、よくある例として Twitter のようにウェブアプリとモバイルアプリが存在する場合あり、例として次のようなシチュエーションが考えられる。

  1. モバイルアプリでメッセージの入力。140文字の制限があるので入力するたびにそれが分かるようにカウンターが動く。
  2. モバイルアプリから入力したメッセージをウェブアプリへ送信する。受け取ったパラーメータが正しいか validation などで検証する。(140 文字以内か)
  3. もし検証に成功すれば DB へ保存、失敗すればモバイルアプリへ失敗した趣旨を返す。

f:id:codehex:20190929182753p:plain

それで今回はアプリ側の文字数カウントの方式とウェブアプリ側でのカウント方式を統一させるために考えたことを忘れないうちにまとめておくことにした。

タイトルに Go を入れたので言語は Go で書く。

モバイルアプリ上での文字数カウント

例えば iOS 向けのアプリを開発する言語として有名な Swift がある。Swift では String というクラスが最初から扱える。このクラスは count というプロパティを提供している。

これは冒頭で説明した という文字を 1 文字としてカウントする。

  1> "á́"
$R0: String = ""
  2> let a = ""
a: String = ""
  3> a.count
$R1: Int = 1

結果から分かるように Swift 上で扱われる文字列のカウントは grapheme cluster に基づいて行われる。詳しい説明は Swift のドキュメントの Counting Characters にて説明されている。

またこれを utf8 や utf16 としてカウントした場合はどうなるのか

  4> a.utf8.count
$R2: Int = 3
  5> a.utf16.count
$R3: Int = 2

といったようにもちろん文字コードによってカウントが変化する。

Go で grapheme clusters count をする

Go でよく使われる文字カウントとして utf8.RuneCountInString が使われると思う。これのロジックを簡素に説明すると string -> []rune へ変換して rune が幾つあるかカウントする。

しかし という文字をカウントしてみると結果として 2 が返ってくる。これは utf8.RuneCountInString がコードポイントをベースに区切ってカウントしてるためである。

そこで grapheme cluster に基づいてカウントしてくれるパッケージを探したところ次のものを見つけた。

github.com

これを用いてそれぞれでカウントするコードを用意したので playground で試してほしい

ウェブアプリケーションへ組み込む

ここまで簡単に見えるが、実はタイトルのようなバリデーションを用意する場合、頭を悩ませる問題も存在する。例えば U+25FB というコードポイントは ◻︎ を表現する。しかしこれを表示する端末によって絵文字に見えたり、そのまま四角の記号が表示されるように見える。また、絵文字は表示する端末によって絵が異なるのでこれを嫌がる人がいる。

この問題を回避して各端末間で同じ記号として表示するために U+25FB U+FE0E という組み合わせが存在する。こうすること端末では白黒だけで表現された、シンプルな四角の記号が表示される。

U+FE0E だけを表示すると人の目には見えない文字として存在する。次のコードはそれを変数の中に入れてカウントしている。確かに存在することが分かるはず。

https://play.golang.org/p/gj8sWRWYPZL

そしてもし grapheme cluster count のみを採用してバリデーションを行った場合、危険になり得る事例として次の playground にコードを記述した。

https://play.golang.org/p/S8obvTiuxSq

実際に U+FE0E を 36 回記入しているが grapheme cluster に基づいてカウントすると常に 1 になる。もし grapheme cluster に基づいて 100 文字の制限を確認する validation を行っていたとして、この文字が 5000 兆文字数記述されたリクエストが来たとき、この validation の結果は正しいものとして扱われ値を DB など何かしらのストレージへ保存しようとする。しかし実際の容量は期待する 100 文字分ではなく、5000 兆文字*1数分の容量になるため、その環境のディスク容量を無駄に占領する攻撃?を受けてしまうことになる。

これを回避するために grapheme cluster count だけでなく byte の長さも一緒に見るのがベストという考えに至った。下記に示すのは grapheme cluster count をした後に、もし満たされていれば byte 列の長さが許容値の範囲であるかといった検証を行うコードである。

https://play.golang.org/p/NdjLPxmwUMn

最後のケースだけ grapheme cluster count の部分ではなく、byte 列の部分で弾かれてることが確認できる。

refs: https://rentafounder.com/how-to-count-unicode-string-characters/