Приключения с Unicode в SMS

Автор: | 20.07.2026

Помню одну из своих первых недель работы инженером в Twilio, когда я попытался отправить на свой телефон символы шахматных фигур в формате Unicode. Я был разочарован, увидев, что символы не отображаются на устройстве. Возможно, не менее плохо было то, что при отправке шахматных фигур с моего телефона в Twilio они приходили на мой TwiML-эндпоинт с неверной кодировкой.

Одним из моих первых крупных проектов в команде Messaging в Twilio было выведение Международных SMS из бета-версии. Одним из требований для отправки сообщений на международном уровне является поддержка символов языков этих стран. Изучение кодировки символов и протоколов сетей SMS оказалось чрезвычайно познавательной и интересной задачей.

Исторически наш API SMS заявлял поддержку 160 символов ASCII. Это оказалось упрощением в двух аспектах: ограничение по размеру на самом деле сложнее, и ASCII — не та кодировка, которая нас интересует. На самом деле в одно SMS (по крайней мере, для сетей GSM) можно поместить **140 байт**.

Максимум в 160 символов возникает из-за того, что в 140 байт можно закодировать 160 символов по 7 бит. Но даже если все ваши символы находятся в ASCII, вы не гарантированно поместитесь в 160 символов. Это связано с тем, что кодировка, используемая в SMS, — это не ASCII, а GSM 03.38. Следовательно,

— Некоторые символы в GSM 03.38 требуют экранирующего символа. Это означает, что для их кодирования требуется 2 символа (14 бит). К таким символам относятся:

`|`, `^`, `{`, `}`, `€`, `[`, `~`, `]` и `\`.

Короче говоря, SMS, состоящее только из экранирующих символов, может содержать только 80 символов вместо 160. Возможно, более разрушительным является то, что сообщение, обрезанное ровно на 160 символах, не поместится в одно SMS, если в нём будет хотя бы один из этих символов.

— Не каждый символ ASCII можно закодировать в GSM. Например, отсутствуют символы «` и табуляция.

Протоколы SMS-шлюзов обычно поддерживают больше кодировок, чем просто GSM 03.38. Но плохая новость заключается в том, что очень немногие из этих кодировок действительно имеют значение. Это зависит от сети оператора устройства, но большинство телефонов (особенно на международном уровне) будут получать сообщения либо в кодировке GSM, либо в `UCS-2`.

Это означает, что даже если, например, SMPP 3.4 поддерживает такие кодировки, как `ISO 8859-1`, `ISO 8859-8`, `ISO 8859-5`, `IA5` и загадочную неопределённую кодировку `Pictogram`, сообщение, скорее всего, всё равно будет преобразовано в GSM 03.38, так что любые символы из этих кодировок, отсутствующие в GSM, будут потеряны при преобразовании.

Таким образом, для любого сообщения, которое не может быть закодировано в GSM, самым надёжным вариантом является его кодирование с использованием `UCS-2`. `UCS-2` — это устаревшая кодировка символов, которая была заменена на `UTF-16`. Есть два основных различия между этими кодировками:

`UCS-2` не использует метку порядка байтов (Byte Order Mark), поэтому всегда имеет порядок big endian. `UCS-2` не поддерживает «суррогатные пары», то есть всегда ограничена 2 байтами на символ, тогда как `UTF-16` использует 2 или 4 байта на символ. Это означает, что вы не можете закодировать символы за пределами Основной многоязычной плоскости (не-BMP символы).

На практике эти различия не имеют значения, потому что из-за отсутствия поддержки кодировки `UCS-2` современные языки программирования и смартфоны обычно просто декодируют сообщения `UCS-2` как `UTF-16` Big Endian. Это хорошая новость, так как на практике мы можем отправлять не-BMP символы, такие как эмодзи, через SMS, несмотря на то, что спецификация этого строго не позволяет.

Недостатком сообщений в `UTF-16` является то, что поскольку каждый символ занимает 2 или 4 байта, в сообщение размером 140 байт может поместиться максимум 70 символов. (Это верно даже если только один символ не входит в GSM, так как кодировка применяется ко всему сообщению). Обратите внимание, что если мы отправляем только эмодзи или другие не-BMP символы, то ограничение фактически составляет 35. Это не учитывает комбинированные символы (например, диакритические знаки), которые могут ещё больше уменьшить количество символов в зависимости от того, как вы считаете длину текста.

Когда мы поняли, что возможно, стало ясно, что наш API имеет некоторые ограничения, поэтому настало время начать исправлять эти проблемы. Большинство ошибок относились к нескольким категориям. Чтобы избежать наших ошибок, важно помнить следующие концепции:

— Убедитесь, что ваш язык или фреймворк интерпретирует процентно-кодированные HTTP-параметры как UTF-8, а не как Latin-1.

— Убедитесь, что соединение с базой данных установлено в UTF-8.

— Если вы используете MySQL 5.5 или новее, убедитесь, что ваши столбцы UTF-8 имеют тип `utf8mb4`, а не `utf8`. Последний не поддерживает не-BMP символы, так как имеет максимум 3 байта на символ. Если вы используете MySQL 5.1 и не можете обновиться, вам придётся найти собственное решение для не-BMP символов. Некоторые варианты включают использование типа `BLOB` и самостоятельную обработку кодирования/декодирования или экранирование не-BMP символов (любая схема, которую вы придумаете, должна производить корректный UTF-8).

— В целом, не путайте «строки» и «байты».

**Если у вас есть набор байтов, вы должны знать кодировку, чтобы интерпретировать его как строку текста.**

С API SMS с надёжной поддержкой кодировки символов у нас появилась возможность полностью реализовать идею шахматной игры, проводимой исключительно через SMS! Используя символы Unicode, мы можем моделировать фигуры на шахматной доске и создать игру «Шахматы через SMS» (ChesSMS). Ходы делаются с использованием длинной алгебраической нотации (например, e2e4), и в настоящее время противник ограничен шахматным движком.

Чтобы отправить шахматную доску на телефон, есть две основные задачи: выровнять фигуры и уместить всю доску в одно SMS-сообщение.

Есть несколько тонкостей в отображении шахматных досок с использованием только текста. Когда я искал кодовые точки Unicode для пустых чёрных и белых клеток, я надеялся найти символы, максимально близкие по ширине к символам шахматных фигур. В целом телефоны не используют моноширинные шрифты. Хуже того, поскольку телефоны используют разные шрифты, может оказаться сложным или невозможным найти символы чёрных и белых клеток, которые обеспечат красивое выравнивание доски на всех устройствах. На самом деле, во многих шрифтах даже шахматные фигуры имеют разную ширину!

Конечно, всё это предполагает, что шрифт телефона вообще содержит символы для шахматных фигур. Если их нет, вы, скорее всего, увидите замещающий символ, например, блок Unicode (▯). К счастью, iPhone, Android и Windows Phone используют шрифты с шахматными фигурами. Для других телефонов нам придётся вернуться к алфавитным символам.

После нескольких проб и ошибок я нашёл затенённые символы Unicode и символы фиксированной ширины, которые неплохо работали на шрифте Helvetica Neue моего iPhone. Поскольку мы не можем знать, на какой телефон отправляется SMS, пользователю придётся самому изменить «настройки» для выбора символов, используемых для отображения доски. Но в целом из-за всех этих проблем рендеринг доски — это «лучшее усилие».

Вот как это выглядит на iOS:

Поскольку я использовал пробельные символы для белых клеток, я столкнулся с проблемой: если верхняя левая клетка была пустой (верхняя левая клетка — белая, когда вы играете белыми), первый символ в сообщении был пробелом. Оказалось, что наш API на самом деле удаляет начальные пробелы, и поэтому он удалял мою клетку! Возможно, это самый буквальный «краевой случай» в истории программного обеспечения. Я изменил API так, чтобы он удалял только «стандартные» типы пробелов, исходя из предположения, что любой, кто использует специальные пробельные символы, вероятно, делает это намеренно.

Как вы помните из раздела о кодировке символов, нам разрешено 140 байт в SMS-сообщении. К счастью, шахматные фигуры находятся в Основной многоязычной плоскости (BMP), поэтому каждая из них занимает всего 2 байта. Таким образом, в каждом сообщении мы можем отправить 70 символов.

Шахматная доска имеет размер 8 на 8, поэтому 64 символа используются самой доской. Нам нужно разделить ряды, поэтому требуется 7 символов новой строки (один после каждого ряда, кроме последнего).

64 + 7 = 71. Нам нужно на один символ больше, так близко! У меня есть кадры того самого момента, когда гроссмейстер Гарри Каспаров читал спецификации SMS:

Решение для размещения доски — опустить последний пробел в рядах, где на конце находится пустая белая клетка. Это работает до тех пор, пока на этих 4 рядах не окажется фигура. Для таких позиций я пока не придумал обходного пути, и доска будет отправляться в двух сообщениях.

Для реализации ChesSMS я выбрал Erlang/OTP. Я знал, что требования ChesSMS позволят мне познакомиться с несколькими функциями Erlang, с которыми я ещё не работал, и я знал, что для такой критически важной службы, как Шахматы через SMS, ни одна другая система не сможет обеспечить такую надёжность и масштабируемость, которые мне были нужны. Ну, на самом деле у меня не было веских причин выбирать Erlang, но в итоге это оказалось отличным и увлекательным выбором, как я объясню, не вдаваясь в подробности архитектуры.

Разработка шахматного движка не входила в рамки этого проекта. К счастью, существует множество отличных шахматных движков с открытым исходным кодом и два основных протокола для связи между движками и интерфейсами. Есть протокол XBoard, который постепенно развивался вместе с интерфейсами XBoard и Winboard, и есть UCI (Universal Chess Interface), который был разработан с нуля авторами движков. UCI — это гораздо более простой интерфейс, но он возлагает больше сложностей на сторону UI. Я выбрал реализацию UCI из-за его бессостоятельного дизайна и более простого списка команд.

Шахматный движок и UI общаются через `stdin`/`stdout`, что отлично сочетается с функцией Port в Erlang. Port — это исполняемая программа, которую ваша программа запускает и может отправлять и получать сообщения так, как если бы это был другой актор Erlang. Это сделало чрезвычайно простым управление шахматными движками из Erlang. Поскольку я хочу дать движку несколько секунд на обдумывание хода, я запускаю 8 движков (по умолчанию, конечно, это настраивается) и добавляю их в общий пул ресурсов, который управляет моим пулом движков. В настоящее время я использую шахматный движок Stockfish, но теоретически любой движок UCI должен подключаться и работать.

Реализация состояния шахматной доски и обновление доски при ходах оказались удивительно подходящими для Erlang. В частности, сопоставление шаблонов в Erlang сделало некоторые части кода очень читабельными, которые в противном случае были бы сложными.

«`

special_kind({_, pawn}, _From, To, undefined, EnPassantSquare)

when To == EnPassantSquare -> enpassant;

special_kind({_, pawn}, _, _, Promoted, _)

when Promoted =/= undefined -> promotion;

special_kind({white, king}, 5, 7, undefined, _) -> castle;

special_kind({white, king}, 5, 3, undefined, _) -> castle;

special_kind({black, king}, 61, 63, undefined, _) -> castle;

special_kind({black, king}, 61, 59, undefined, _) -> castle;

special_kind(_, _, _, undefined, _) -> normal.

«`

Приведённый выше код определяет функцию `special_kind`, которая определяет, является ли ход особым. Она принимает кортеж `{Color, PieceType}`, квадрат `From`, квадрат `To`, квадрат `Promotion` и квадрат `enpassant` и возвращает либо `castle`, `normal`, `enpassant`, либо `promotion`.

Каждая шахматная партия работает как «процесс» Erlang (не процесс операционной системы) внутри виртуальной машины Erlang. Я использую базу данных mnesia для хранения соответствия игроков и Pid (идентификаторов процессов). Когда веб-интерфейс (реализованный в HTTP-фреймворке Cowboy) получает SMS-команду от Twilio, он ищет, в каком игровом процессе участвует номер телефона отправителя. Затем он отправляет ход как сообщение процессу (поскольку мы в Erlang, процесс может работать локально или на другом узле Erlang без изменений в коде). Если ход допустим, процесс вносит изменения в своё локальное состояние, запрашивает у пула шахматных движков ответный ход, сериализует шахматную доску и отправляет её обратно актору, который возвращает TwiML.

ChesSMS стал хорошим рабочим побочным проектом с точки зрения изучения Erlang/OTP, тестирования нашего API и демонстрации того, что можно сделать с поддержкой Unicode, помимо отправки международных символов. Я считаю, что Erlang — отличный выбор для создания SMS-приложений на Twilio.

Вы можете сыграть в ChesSMS, отправив сообщение «PLAY» на номер +1 (415) 494-8454.

Или вы можете создать форк исходного кода на Github.