TalentX Tech Blog

Tech Blog

SMSで ⚽ は送れて 🏀 が送れなかった理由を調べた話

はじめに

はじめまして、TalentX バックエンドエンジニアの伊東です。 採用MAサービス MyTalent CRM の開発を担当しています。

MyTalent CRM ではタレントへSMSを送信する機能があり、外部のSMS送信サービスを利用しています。そのサービスには仕様として「UTF-8基準で1文字あたり4バイトになる文字は送信不可」という制約があります。

開発当初、私は絵文字はすべて4バイト以上だと誤解していました。しかし開発環境でのテスト中に「⚽」や「✨」を含むメッセージが正常に届き、絵文字=すべてエラーという理解が誤っていたことが分かりました。

この記事では、文字コードの仕様をもとにその理由を整理します。


基礎知識:UnicodeとUTF-8の違い

まずUnicodeとUTF-8の違いを確認しておきます。

Unicode

世界中のすべての文字(英数字、絵文字、漢字など)に、重複しない一意の番号(コードポイント)を割り当てた文字セットの標準規格です。コードポイントは U+0041A)や U+1F3C0(🏀)のように、プレフィックス U+ と16進数で表記されます。範囲は U+0000U+10FFFF で、約110万のコードポイントを割り当て可能な空間です。

UTF-8

Unicodeで定義された番号を、コンピュータが扱えるバイトデータに変換する符号化方式の1つです。8ビット(1バイト)を1つの単位(コード単位)として扱い、これを1〜4個組み合わせて1つのコードポイントを表現する可変長方式です。


Unicode の「面」の概念と UTF-8 のバイト数

絵文字によってUTF-8のバイト数が変わるのは、Unicodeのデータ空間が17個のエリア(面)に分割されているからです。

各「面」はコードポイントの上位ビットで区切られています。U+FFFF(10進数で65,535)は16ビットで表現できる最大値で、U+10000(10進数で65,536)は17ビット目が立つ最初の値です。つまり「16ビットに収まるかどうか」が2つの面を分ける境界になっています。

基本多言語面(BMP:Basic Multilingual Plane)

U+0000U+FFFF のエリアで、主要な言語の文字や古くからある記号由来の絵文字(⚽ や ✨ など)が属しています。BMP内の文字はUTF-8でエンコードすると最大3バイトに収まるためSMSで送信できました。

追加面(Supplementary Planes)

U+10000以上のエリアで、カラー絵文字(🏀 や 😀 など)や特殊な人名漢字(「𠮷(つちよし)」など)が属しています。追加面の文字はUTF-8でエンコードすると必ず4バイトになるため、制限に引っかかり送信不可となります。


結合絵文字とは

少し話を広げますが、結合絵文字というものがあります。肌の色が違う顔文字や、職業を表す絵文字(例:エンジニアの男性 👨🏼‍💻)がこれに当たります。

見た目は1文字ですが、実際には複数のコードポイントを組み合わせて1つの絵文字を表現する仕様(UTS #51)が定義されています。👨🏼‍💻 を分解すると、以下の4パーツ(計15バイト)で構成されています。

  • 👨 U+1F468 / 4バイト :ベースとなる男性の絵文字
  • 🏼 U+1F3FC / 4バイト :肌色を指定する修飾子(Emoji Modifier)
  • ZWJ U+200D / 3バイト :文字同士を接着するゼロ幅接合子(Zero Width Joiner)
  • 💻 U+1F4BB / 4バイト :パソコンの絵文字

見た目は1文字でも、データ上は15バイトです。

結合絵文字の多くには4バイト文字が含まれる

肌色つきやZWJ職業系、国旗などの結合絵文字は、ベースのカラー絵文字・肌色修飾子・地域コードがすべて追加面に配置されているため、構成要素に4バイト文字が含まれます。

※ すべての結合絵文字に4バイト文字が含まれるわけではなく、例外もあります。例えばキーキャップ絵文字の 1️⃣ を構成する3パーツはすべてBMPであるため4バイト文字を含みません。


4バイト文字チェックの実装例

実際にアプリケーション側でこのチェックを実装してみます。

前提:Goにおける string と rune の違い

実装に入る前に、Goのstringruneの違いを押さえておきます。

  • string :UTF-8でエンコードされたバイト列。len(s) はバイト数を返し、s[i] でアクセスすると byte 型の1バイトが取得できる
  • runeint32 のエイリアスで、Unicodeコードポイント1つを表す整数型

コードで確認していきます。

s := "🏀"

fmt.Println(len(s))                    // → 4(UTF-8 で 4 バイト)
fmt.Println(utf8.RuneCountInString(s)) // → 1(コードポイントは 1 つ)

// バイト単位のループ
for i := 0; i < len(s); i++ {
    fmt.Printf("%x ", s[i]) // → f0 9f 8f 80
}

// rune 単位のループ
for _, r := range s {
    fmt.Printf("%U", r) // → U+1F3C0
}

for _, r := range s は文字列をUTF-8として解釈し、コードポイント単位で rune を取り出します。今回のバリデーションではこの挙動を利用します。

バリデーション関数の実装

import (
    "fmt"
    "unicode/utf8"
)

// メッセージ内に4バイトの文字が含まれていないかチェックする
// 注:入力が有効なUTF-8である前提(不正なバイト列はチェックをすり抜ける)
func validateFourByteChar(message string) error {
    for _, r := range message {
        // utf8.RuneLen は対象のrune(コードポイント)のUTF-8でのバイト数を返す
        if utf8.RuneLen(r) == 4 {
            return fmt.Errorf("4-byte character not allowed: %c", r)
        }
    }
    return nil
}

結合絵文字を入力したときの挙動

15バイトある 👨🏼‍💻 をvalidateFourByteCharに渡すと、1回目のループで先頭の 👨(追加面・4バイト)がrに入り、utf8.RuneLen(r)4となりエラーを返します。

15バイト全体を評価しているわけではなく、rune単位で分解した最初の構成要素を処理した時点でエラーが返ります。👨🏼‍💻 のように追加面の文字を含む結合絵文字であれば、ループのどこかで引っかかります。


まとめ

絵文字はすべて4バイト以上という思い込みを持っていましたが、今回の調査で、⚽ が送れた理由も、Unicodeの「面」の概念とUTF-8のバイト数の関係を理解して説明できるようになりました。

今回は外部サービスの仕様がきっかけでしたが、今後も何か分からないことやエラーなどに遭遇したときに、表面だけで終わらず裏側の技術背景まで踏み込む習慣を大事にしていきたいと思います。

最後に

最後までお読みいただきありがとうございました。

TalentXでは一緒に働く仲間を募集しております。 ご興味ある方は下記リンクの求人をご覧ください。 talentx.brandmedia.i-myrefer.jp

また、カジュアル面談も行っていますのでぜひご応募ください。 i-myrefer.jp