はじめに

絵文字の仕様は非常に多様かつ複雑であるため、絵文字のバリデーションは簡単ではありません。

本記事では、絵文字の入力バリデーションをする方法を解説します。バックエンドはSpring Boot、フロントエンドはReact Nativeを使用して実装します。

今回、私が関わった案件ではブラックリスト方式(禁止したい文字をリストアップする方式)のバリデーションを採用しましたが、本記事ではホワイトリスト方式(許可したい文字・文字種のみ通す方式)についても取り上げ、それぞれの方式のメリットとデメリットについても説明します。

絵文字の仕様

絵文字は非常に多くの種類が存在しており、基本的な絵文字だけでも約1,900種類、さらに肌の色や性別などのバリエーションを含めると、約3,700種類にもなります。

絵文字の中には、複数のUnicodeコードポイントを組み合わせて1つの絵文字として表現されているものが多くあります。たとえば、大人2人が手をつないでいる絵文字「🧑‍🤝‍🧑」は、人の絵文字「🧑」と握手の絵文字「🤝」、それを結合するための特殊な文字(結合文字)を組み合わせて表現されます。

さらに、スキントーン修飾子という特殊なコードポイントを付与することで、肌の色の異なる絵文字を表現することもできます。サムズアップの絵文字「👍」にスキントーン修飾子を付けることで、「👍🏻」や「👍🏾」のようなバリエーションを表現できます。

加えて、異体字セレクタという特殊なコードポイントを使うことで、同じ絵文字でも表示方法を指定できます。たとえば、ハートの絵文字に異体字セレクタを付けることで、テキスト風の表示「」にするか、カラフルな絵文字「❤️」として表示するかを切り替えることができます。数字と結合文字や異体字セレクタを組み合わせることで、半角数字を四角で囲む「0️⃣」、「1️⃣」なども絵文字として扱われます。

また、Unicodeのアップデートによって新しい絵文字が次々と追加されています。

絵文字には以上のような複雑な仕様があるため、バリデーション処理は一筋縄ではいきません。

ホワイトリスト方式とブラックリスト方式

絵文字のバリデーションのやり方には主に2つの方式があります。それぞれの特徴と、選択のポイントを解説します。

ホワイトリスト方式

許可したい文字や文字種のみを通す方式です。

たとえば許可したい文字種として「アルファベット、数字、ホワイトスペース、漢字、ひらがな、カタカナ、記号」を指定すると、それ以外の文字はすべて除外されるため、絵文字も許可されません。

メリット
  • 入力で使用可能となる文字や文字種を明確に設定できるので、想定外の文字を確実に除外することができる
  • 新しく絵文字が追加された場合でも、追加の設定は不要で、自動で除外することができる
  • スキントーン修飾子、異体字セレクタが付与されたものなど、絵文字特有の複雑なバリエーションも一括で排除できる
デメリット
  • 許可する文字種が多い場合や、範囲が不明確な場合には、すべてをリストアップするのが大変
    (たとえば世界中の言語の入力に対応したい場合、許可する全ての文字種を網羅的に指定するのは非現実的)

ブラックリスト方式

禁止したい文字の一覧(ブラックリスト)を作成し、それらの文字の使用を制限する方式です。

メリット
  • 許可範囲が広く、多様な文字種の入力に対応できる
  • 特定の文字のみ禁止する、といった要件に対応しやすい
デメリット
  • 除外対象の絵文字やバリエーション(スキントーン修飾子や異体字セレクタ付きなど)を全て網羅するのが大変
  • Unicodeで新規に絵文字が追加された場合など、リストから漏れたものが許可されてしまうリスクがある

方式選択のポイント

ホワイトリスト方式
  • 許可したい文字や文字種が明確なとき
    (例:日本語、英数字、特定の記号や一部の絵文字のみ許可したい場合)
ブラックリスト方式
  • 許可したい文字種が幅広い・曖昧なとき
    (例:全世界の言語を許可したい場合)
  • 除外したい文字が明確なとき
    (例:特定の絵文字のみ除外したい場合)

 

今回の案件では、日本語・英語だけでなく、ハングルや繁体字・簡体字など、世界中のさまざまな言語の入力を許可する必要がありました。

そのため、ホワイトリスト方式で許可したいすべての文字種をリストアップするのは現実的ではありませんでした。

一方で、除外したい文字種は絵文字のみと明確だったため、ブラックリスト方式を採用しました。Unicodeの絵文字リストを参照して除外対象の一覧を作成し、該当する絵文字を除外するロジックを実装しました。

さらに、新しい絵文字の多くは既存絵文字のコードポイントの組み合わせで構成されています。既存のブラックリストと部分一致で照合することで、頻繁なリスト更新を避けることができます。

ホワイトリスト方式の実装

バックエンドの文字種バリデーション(Spring Boot)

ホワイトリスト方式用のアノテーションの作成

まず自作のアノテーションを作成します。

このアノテーション(@AllowedCharacters)をメソッドやフィールドに適用することで、バリデーションをかけることができます。

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 文字種バリデーション用アノテーション
 */
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AllowedCharacterValidator.class)
public @interface AllowedCharacters {

    String message() default "許可されていない文字が含まれています";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

 

ホワイトリスト方式用のバリデータの作成

次に、バリデータを作成します。

実際にバリデーションを行うAllowedCharacterValidatorクラスを実装します。このクラスは、許可した文字種のみで入力文字列が構成されているかをチェックします。

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.apache.commons.lang3.StringUtils;

import java.util.regex.Pattern;

/**
 * 指定した文字種以外が入力された場合にエラーとする。
 */
public class AllowedCharacterValidator implements ConstraintValidator<AllowedCharacters, String> {

    private String message;

    // ホワイトリスト正規表現
    private static final Pattern ALLOWED_CHARACTERS_PATTERN = Pattern.compile("^[\\p{IsAlphabetic}\\p{IsDigit}\\p{IsWhiteSpace}\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}\\p{Punct}]+$");

    @Override
    public void initialize(AllowedCharacters constraintAnnotation) {
        this.message = constraintAnnotation.message();
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext context) {
        if (StringUtils.isNotEmpty(s)) {
            if (!isAllowedCharacters(s)) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
                return false;
            }
        }
        return true;
    }

    /**
     * ホワイトリストの文字のみを含むかチェックする。
     *
     * @param input 検証文字列
     * @return 検証結果(true : 許可される文字のみ含む, false : 不許可文字を含む)
     */
    private boolean isAllowedCharacters(String input) {
        return ALLOWED_CHARACTERS_PATTERN.matcher(input).matches();
    }
}

ALLOWED_CHARACTERS_PATTERNは特定の文字種を許可するように定義された正規表現パターンです。このパターンにより、文字列が許可された文字種のみで構成されているかをチェックし、それ以外の文字が含まれている場合にはその文字列を弾くことができます。
また、コードポイントで指定することで、特定の文字のみ許可することも可能です。

上記のコードでは、アルファベット、数字、ホワイトスペース、漢字、ひらがな、カタカナ、記号を許可するように定義しているため、絵文字を弾くことができます。この例では絵文字だけでなく、アラビア文字やハングルも許可していないので、要件に合わせて許可する文字種を指定してください。

フロントエンドの文字種バリデーション(React Native)

Yupを用いてバリデーションを設定します。
Yupは、バリデーションルール(スキーマ)を簡単に定義できるライブラリで、入力フォームのバリデーションによく使われます。
たとえば、「必須入力」や「文字数制限」、「メールアドレスの形式チェック」など、さまざまな条件を柔軟に設定できます。
今回は許可した文字種のみでnameフィールドの入力文字列が構成されているかをチェックします。

import * as Yup from 'yup';

const allowedCharactersRegex =
    /^[\p{Alphabetic}\p{Number}\p{White_Space}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Punctuation}]*$/u;

const informationValidationSchema = Yup.object().shape({
    name: Yup.string()
      .matches(allowedCharactersRegex, '許可されていない文字が含まれています')
});

allowedCharactersRegexは特定の文字種を許可するように定義された正規表現パターンです。このパターンにより、文字列が許可された文字種のみで構成されているかをチェックし、それ以外の文字が含まれている場合にはその文字列を弾くことができます。

上記のコードでは、アルファベット、数字、ホワイトスペース、漢字、ひらがな、カタカナ、記号を許可するように定義しているため、絵文字を弾くことができます。こちらも要件に合わせて許可する文字種を指定してください。

ブラックリスト方式の実装

バックエンドの絵文字バリデーション(Spring Boot)

ブラックリスト方式用のアノテーションの作成

まず自作のアノテーションを作成します。

このアノテーション(@ValidEmoji)をメソッドやフィールドに適用することで、バリデーションをかけることができます。

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 絵文字のバリデーション用アノテーション
 */
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmojiValidator.class)
public @interface ValidEmoji{

    String message() default "許可されていない文字が含まれています";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

除外対象とする絵文字の定義(YAML)

除外対象の絵文字はsrc/main/resources配下のYAMLファイル(disallow-emoji.yaml)に以下のような形式で定義します。

UnicodeのU+表記(例:U+1F600)ではなく、16進数(例:0x1F600)で指定します。

disallow:
  emojis:
    - 0x1F600
    - 0x1F603
    - 0x1F604
    - 0x1F601
    - 0x1F62E, 0x200D, 0x1F4A8
    - 0x1F925
    - 0x1F9D1, 0x200D, 0x1F91D, 0x200D, 0x1F9D1
    - 0x1F60C

たとえば、

0x1F9D1, 0x200D, 0x1F91D, 0x200D, 0x1F9D1

は🧑(U+1F9D1)と🤝(U+1F91D)と🧑(U+1F9D1)をゼロ幅接合子(U+200D)で繋げた、大人2人が手をつないでいる絵文字🧑‍🤝‍🧑です。

このように、複数のコードポイントを組み合わせて特定の絵文字を表現する場合もあります。

YAMLの読み込み用プロパティクラスの作成と読み込み設定

YAMLファイルの内容を取り込むために、EmojiPropertiesを作成します。このクラスはSpring Bootの@ConfigurationPropertiesアノテーションを使用し、YAMLファイルからデータを読み込んでSet<List<Integer>>として保持します。

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * disallow-emoji.yamlで定義する除外対象の絵文字の設定値を保持する。
 */
@Component
@ConfigurationProperties("disallow")
public class EmojiProperties {

    /**
     * 除外対象の絵文字コードを保持するSet。
     */
    private Set<List<integer>> emojis = Set.of();

    /**
     * イミュータブルなSet/Listで返却するgetter。
     */
    public Set<List<integer>> getEmojis() {
        return emojis;
    }

    /**
     * イミュータブルなSet/Listに変換して保持するsetter。
     */
    public void setEmojis(Set<List<integer>> newEmojis) {
        Set<List<integer>> immutableSet = newEmojis.stream()
                .map(List::copyOf)
                .collect(Collectors.collectingAndThen(
                        Collectors.toSet(),
                        Set::copyOf 
                ));
        this.emojis = immutableSet;
    }
}

 

また、YAMLファイルを読み込むためにapplication.propertiesに以下を記載します。

spring.config.import=disallow-emoji.yaml

ブラックリスト方式用のバリデータの作成

実際にバリデーションを行うEmojiValidatorクラスを実装します。このクラスは、入力文字列に除外対象の絵文字が含まれているかをチェックします。

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import com.example.config.EmojiProperties;
import org.apache.commons.lang3.StringUtils;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 絵文字が含まれる場合にエラーとする
 */
public class EmojiValidator implements ConstraintValidator<ValidEmoji, String> {

    // Unicode拡張書記素クラスタ(ユーザが認識できる1文字を表す正規表現)
    private static final Pattern GRAPHEME_CLUSTER_PATTERN = Pattern.compile("\\X");

    private final EmojiProperties emojiProperties;
    private String message;

    public EmojiValidator(EmojiProperties emojiProperties) {
        this.emojiProperties = emojiProperties;
    }

    @Override
    public void initialize(ValidEmoji constraintAnnotation) {
        this.message = constraintAnnotation.message();
    }

    @Override
    public boolean isValid(String s, ConstraintValidatorContext context) {

        if (StringUtils.isNotEmpty(s)) {
            if (containsEmoji(s)) {
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
                return false;
            }
        }
        return true;
    }

    /**
     * 絵文字を含むかチェックする。
     *
     * @param input 検証文字列
     * @return 検証結果(true : 含む, false : 含まない)
     */
    private boolean containsEmoji(String input) {

        // 入力文字列を「拡張書記素クラスタ」の単位(ユーザーが認識できる1文字ごと)で分割するためのMatcherを生成
        Matcher matcher = GRAPHEME_CLUSTER_PATTERN.matcher(input);

        // 除外する絵文字のリストを取得
        Set<List<Integer>> emojis = emojiProperties.getEmojis();
        while (matcher.find()) {
            // 1文字を取得
            String data = matcher.group();
            // コードポイントを取得
            List<Integer> inputCodePoint = data.codePoints().boxed().toList();
            HashSet<Integer> inputCodePointSet = new HashSet<>(inputCodePoint);

            // 除外する絵文字のリストを全てチェック
            for (List<Integer> emoji : emojis) {
                // この1文字が、リスト中のどれか1つの禁止絵文字の全てのコードポイントを含んでいたらtrue
                if (inputCodePointSet.containsAll(emoji)) {
                    return true;
                }
            }
        }
        return false;
    }
}

containsEmojiでは、入力文字列をUnicodeの拡張書記素クラスタ(ユーザーが認識できる1文字単位)で分割し、それぞれのクラスタのコードポイント集合と、ブラックリスト内の各絵文字のコードポイント集合を比較しています。

※今回の案件ではブラックリストに登録する絵文字の数が少なかったため、for文による線形探索でもパフォーマンスへの影響は軽微と判断しています。

inputCodePointSet.containsAll(emoji) は、「そのクラスタに除外対象絵文字のすべてのコードポイントが含まれているか」を判定しています。ただし、順序や完全な一致ではなく、部分集合として含まれていればtrueになります。

このアプローチは、スキントーン修飾子や異体字セレクタが追加されたバリエーションにも対応できます。
ブラックリストに存在する絵文字のコードポイントと「順序や連続性まで含めて完全に一致しているかどうか」を判定する場合、修飾子がついていると一致しないため除外できません。
一方、HashSetとcontainsAllを使うことで、基本となる絵文字がブラックリストに入っていれば、そのバリエーションもまとめて除外することができます。

また、「0️⃣」「1️⃣」のような、半角数字を四角で囲むような絵文字を除外したい場合にもこのアプローチは有効です。半角数字自体は許可されますが、囲まれた数字はブラックリストに含まれている特定のコードポイントの組み合わせとして弾くことができます。

※今回は、すべての絵文字を漏れなく除外することを目的としているため、紹介した実装でも要件を満たせます。仮に順序や連続性を無視して一致した場合でも、何らかの絵文字が検出されたことに変わりはありません。また、新しい絵文字が追加された際にも、ブラックリストを更新せずとも対応することができる可能性が高いです。ただし、例えばブラックリストに [0x1F44D, 0x1F3FB]のような並びを登録し、「入力が[0x1F44D, 0x1F3FB]であれば弾きたいが、順番が異なる[0x1F3FB, 0x1F44D]は許可したい」という要件の場合、紹介した方法では正しく判定できません。

 

たとえば、ブラックリストに以下の絵文字のコードポイントが定義されているとします。

disallow:
  emojis:
    - 0x0030, 0xFE0F, 0x20E3 // 半角数字0を四角で囲んだ絵文字(0️⃣)
    - 0x1F44D                // サムズアップの絵文字(👍)

この設定に基づくバリデーション結果は以下のようになります。

  1. 0x0030, 0xFE0F, 0x20E3(0️⃣) / 0x1F44D(👍)
    除外対象絵文字のコードポイントが全て含まれているので弾かれます。
  2. 0x0030(0)
    コードポイントの一部(0x0030=半角数字の0)のみでは弾かれません。
  3.  0x1F44D, 0x1F3FB(👍🏻)
    スキントーン修飾子が付与された「👍🏻」であっても、基本の「👍」のコードポイントが含まれているため弾かれます。

 

フロントエンドの絵文字バリデーション(React Native)

除外対象とする絵文字リストの作成

まずは除外したい絵文字をdisallow-emoji.tsファイルにリストとして定義します。

絵文字はUnicodeのU+表記(例:U+1F600)ではなく、エスケープシーケンス(例:\u{1F600})で記述します。

export const disallow_emoji = [
  '\u{1F600}',
  '\u{1F603}',
  '\u{1F604}',
  '\u{1FAE5}',
  '\u{1F636}\u{200D}\u{1F32B}\u{FE0F}',
  '\u{1F60F}',
  '\u{1FAE8}',
  '\u{1F642}\u{200D}\u{2194}\u{FE0F}',
  '\u{1F642}\u{200D}\u{2195}\u{FE0F}',
];

Yupによるバリデーション設定

次に、Yupを用いてバリデーションを設定します。

import { disallow_emoji } from 'disallow-emoji';
import * as Yup from 'yup';

const informationValidationSchema = Yup.object().shape({
  name: Yup.string()
    .test('emoji', '許可されていない文字が含まれています', value => {
      // 除外対象の絵文字が含まれているかチェック
      return !disallow_emoji.some(emoji => value.includes(emoji));
    }),
});

value.includes(emoji)の部分では、入力文字列の中に、ブラックリスト内のいずれかの絵文字が「そのままの順序・構成のコードポイントで含まれているかどうか」を判定しています。ブラックリストに含まれる絵文字を構成する一部分のコードポイントだけが入力値に含まれている場合は、弾かれることはありません。

 

たとえば、ブラックリストに以下の絵文字のコードポイントが定義されているとします。

export const disallow_emoji = [
    '\u{0030}\u{FE0F}\u{20E3}', // 半角数字0を四角で囲んだ絵文字 0️⃣
    '\u{1F44D}',                 // サムズアップの絵文字 👍
];

この設定に基づくバリデーション結果は以下のようになります。

  1. \u{0030}\u{FE0F}\u{20E3}(0️⃣) / \u{1F44D}(👍)
    ブラックリストに定義された絵文字のコードポイントがそのままの順序・構成で含まれているので弾かれます。
  2. \u{0030}(0)
    除外対象絵文字(0️⃣)の一部のコードポイントのみでは弾かれません。
  3. \u{1F44D}\u{1F3FB}(👍🏻)
    スキントーン修飾子が付与された「👍🏻」であっても、基本の「👍」のコードポイントが部分文字列として含まれているため弾かれます。

おわりに

本記事では、バックエンドとフロントエンドの両方における、ホワイトリスト方式およびブラックリスト方式による絵文字バリデーションの実装方法について解説しました。それぞれの方式にはメリットとデメリットがあるため、システムの具体的な要件や運用方針に合わせて適切な方法を選択することが重要です。

本記事が、絵文字の入力バリデーションに悩んでいる方の助けになれば幸いです。