Практическое руководство по Unicode'изации
Мы, наконец, это сделали! Долгое время позорное наследие CP1251 раздражало разработчиков, наводило на мысли о том, что, как же так? Эпоха Unicode уже давно наступила, а мы все еще используем однобайтовую кодировку и расставляем в разных местах костыли для совместимости с внешними системами. Но причина тому была достаточно рациональная: перевести на Unicode большой проект, в который развился Мой Мир, очень трудоемко. Мы оценивали это в полгода и не были готовы тратить столько ресурсов на фичу, которая не принесет русскоязычной аудитории существенной пользы.
Но история вносит свои коррективы, зачастую весьма неожиданные. Не секрет, что в Казахстане весьма популярен проект Мой Мир, который является самой популярной социальной сетью в этой стране. И нам всегда хотелось, чтобы у наших казахских пользователей появилась возможность использовать символы казахского алфавита из расширенного кириллического набора, которым, к сожалению, не нашлось места в CP1251. И дополнительным стимулом для нас, позволившим, наконец, оправдать длительную разработку, стал дальнейший рост популярности нашего проекта за пределами нашей страны. Мы поняли, что пора делать шаг навстречу нашим зарубежным пользователям.
Разумеется, первое, что было необходимо для интернационализации проекта, это начать принимать, передавать, обрабатывать и хранить данные в UTF-8. Процедура эта для большого проекта непростая и длительная, по пути нам пришлось решить несколько достаточно интересных задач, про которые мы постараемся рассказать.
Первый выбор, с которым мы столкнулись, был достаточно стандартным — с какого конца начинать: от отображения страницы или от хранилищ данных. Решили начать с хранилищ по причине того, что это самый затяжной и трудоемкий процесс, требующий слаженных действий разработчиков и администраторов.Ситуацию осложняло еще и то, что в нашей социальной сети, из соображений быстродействия, используется большое количество разнообразных специализированных хранилищ. Разумеется, не все они содержали текстовые поля, подлежащие интернационализации, но все же их оказалось немало. И первое, что пришлось сделать — провести полную инвентаризацию всех наших хранилищ на предмет содержания в них текстовых строк. Стоит признать, что мы узнали много нового.
Конвертацию хранилищ в UTF-8 мы начали с MySQL. Причиной этому послужило то, что, в общем-то, изменение кодировки этой базой поддерживается нативно. Но на практике все оказалось не так просто.Во-первых, необходимо было осуществить конвертацию базы без даунтайма на время конвертации.
Во-вторых, выяснилось, что выполнить для всех таблиц alter table `my_table` convert to character set utf8; не рационально и, более того, невозможно. Не рационально потому, что индекс для UTF-8-поля всегда занимает 3 * length_in_characters байт, даже если поле содержит только ASCII-символы. А таких полей у нас оказалось немало, в том числе индексных, особенно таких, которые содержали hex-строки. Невозможно по причине того, что максимальная длина ключа индекса в MySQL 767 байт, и индексы (особенно многоколоночные) перестают помещаться. Помимо этого обнаружилось, что в текстовых полях кое-где по недосмотру хранятся бинарные данные и наоборот, и надо внимательно проверять каждое поле.
После того, как мы собрали с наших баз данных информацию об имеющихся там таблицах, появилось понимание, что львиная доля их, скорее всего, не используется. Так оно в результате и оказалось, мы удалили из баз примерно половину всех имевшихся в них таблиц. Для того чтобы найти неиспользуемые таблицы мы применили следующую технику: при помощи tcpdump собрали все запросы к нашим базам за сутки, затем пересекли список таблиц из этого дампа с текущей схемой баз и на всякий случай поискали неиспользуемые таблицы по коду (заодно подчистили код). Tcpdump применили потому, что он, в отличие от записи всех запросов в лог средствами MySQL, не требует перезапуска базы и не оказывает влияния на скорость обработки запросов. Разумеется, сразу удалять таблицы было страшно, поэтому сначала просто переименовали таблицы со специальным суффиксом, выждали несколько недель и потом удалили (кстати, не зря перестраховались, парочку лишних малоиспользуемых зацепили по недосмотру, пришлось вернуть).
Затем приступили собственно к написанию DDL для конвертации баз. Для этого использовалось несколько стандартных паттернов:
если в таблице не было текстовых полей, то (на всякий случай, вдруг когда-нибудь добавим) просто выполняли запрос: alter table `my_table` default character set utf8; если в таблице были только varchar текстовые поля требующие интернационализации, то: alter table `my_table` convert to character set utf8; поля, содержащие только ASCII-символы, конвертили в ASCII: alter table `my_table` modify `my_column` varchar (n) character set ascii…; поля, требующие интернационализации, стандартно: alter table `my_table` modify `my_column` varchar (n) character set utf8…; но, для некоторых полей с уникальным индексом, из-за равенства в collation utf8_general_ci (в отличии от cp1251_general_ci) букв е и ё, пришлось костылить: alter table `my_table` modify `my_column` varchar (n) charater set utf8 collate utf8_bin…; для индексных полей, которые после конвертации перестали влезать в индекс, тоже пришлось костылить: alter table `my_table` drop index `my_index`, modify `my_column` varchar (n) character set utf8…, add index `my_index`(`my_column`(m)); (где m < n, а индекс, как правило, по нескольким полям); текстовые поля, содержащие двоичные данные, переводили в binary и varbinary; двоичные поля, содержащие текстовые строки в CP1251, конвертировали в два приема: alter table `my_table` modify `my_column` varchar(n) character set cp1251; alter table `my_table` modify `my_column` varchar(n) character set utf8; Это необходимо, чтобы первым запросом MySQL понял, что данные в кодировке cp1251, а вторым сконвертировал в utf8. текстовые блобы пришлось обрабатывать отдельно, так как при convert to character set utf8 MySQL расширяет блоб до минимально необходимого, чтобы уместить текст максимальной длины, все символы которого трехбайтные. То есть text автоматически расширяется до mediumtext. Это не совсем то, чего мы хотели в ряде случаев, поэтому приводили явно: alter table `my_table` alter `my_column` text character set utf8; и, разумеется, на будущее финальный аккорд: alter database `my_database` default character set utf8; Задачу конвертации базы в UTF-8 без даунтайма на время конвертации решили привычным для нас способом: через реплику. Но не обошлось без особенностей. Во-первых, для того чтобы строки автоматически конвертировались при догоне реплики из мастера необходимо, чтобы репликация обязательно была в режиме statement, в режиме raw конвертация не осуществляется. Во-вторых, чтобы перейти на statement репликацию также нужно поменять transaction isolation level с дефолтного repeatable read на read commited.Собственно конвертировали следующим образом:
Переключаем мастер в режим statement-репликации. Поднимаем временную копию базы для конвертации, запускаем на ней конвертацию. По окончанию конвертации переводим копию в режим реплики от основной базы, данные догоняются, строки на лету также конвертируются. Для каждой реплики базы: — переводим нагрузку с реплики на временную реплику в UTF-8; — переливаем все реплики с нуля из временной базы, включаем репликацию с нее; — возвращаем нагрузку обратно на реплику. Переводим временную базу в режим мастера, перебрасываем запросы со старого мастера на временный при помощи NAT. Старый мастер переливаем из временной базы, догоняем репликацией. Переключаем мастер обратно, убираем NAT, возвращаем репликацию обратно в mixed. Отключаем временную базу. В итоге за три месяца кропотливой работы нам удалось сконвертировать все 98 мастеров (плюс куча реплик) с пятнадцатью разнообразными схемами баз (одна особенно большая база на 750Гб конвертировалась почти две недели машинного времени). Админы плакали, не спали по ночам (иногда не давали спать и разработчикам), но процесс, не так быстро как нам хотелось, все же шел. Изначально хотели как лучше и проводили конвертацию по вышеприведенной схеме, для ускорения процесса использовали машины с SSD-дисками. Но под конец третьего месяца, осознавая, что при таком раскладе понадобится еще месяца два работы, не выдержали, перекинули всю нагрузку с реплик на мастеры и стали конвертировать прямо на старых репликах. К счастью, никаких нештатных ситуаций за это время на мастерах не возникло, и за неделю (в основном, потому что реплики крутились на довольно-таки слабеньких старых тачках) конвертация завершилась.Помимо конвертации собственно баз, понадобилось также впилить поддержку UTF-8 в коде, а также обеспечить плавный и незаметный переход. С MySQL тут все, правда, просто. Дело в том, что у него есть отдельно кодировка, в которой он хранит данные, и отдельно кодировка, в которой он данные отдает клиенту. Исторически на серверах у нас было прописано, что character_set_* = cp1251. Для параметров character_set_client, character_set_connection, character_set_results мы ничего менять не стали, чтобы не ломать старые клиенты, и оставили cp1251. Остальные заменили на utf8. В итоге старые клиенты, работающие в cp1251, по-прежнему получают данные в cp1251, независимо от того, отконвертирована база или нет, а новые, работающие в UTF-8, после установления соединения сразу же выполняют команду set names utf8; и начинают пользоваться всеми благами этой кодировки.
Что такое тарантул, думаю, уже можно не рассказывать. Это детище Моего Мира уже обрело достаточную известность и выросло в хороший open source проект.Мы за годы его использования ухитрились накопить в нем огромное количество информации, и когда обнаружилось, что инстансов тарантула у нас под 400 штук, честно говоря, стало страшно, что конвертация затянется надолго. Но, к счастью для нас, выяснилось, что текстовые поля есть только в 60 из них (в основном это пользовательские профили).
Стоит признать, что перекодировать тарантулы оказалось действительно интересной задачей. И решение получилось достаточно элегантное. Но, разумеется, не совсем из коробки. Сразу же оговорюсь, что исторически так получилось, что после того, как tarantool стал развиваться как open source проект, выяснилось, что потребности сообщества и наши немного не совпадают. Сообществу нужен понятный продукт, key-value хранилище, которое работает из коробки, нам же нужен продукт с модульной архитектурой (фрейморк для написания хранилищ), дополнительными узкоспециализированными возможностями, оптимизациями производительности. Поэтому где-то мы продолжали использовать tarantool, а где-то стали использовать его форк octopus, который развивается силами автора самого первого тарантула. И это нам сильно упростило процесс конвертации. Дело в том, что в октопусе есть возможность писать репликационные фильтры на lua, то есть передавать не оригинальные команды из снапшота и xlog’а мастера, а прошедшие модификацию при помощи lua-функции. Эту возможность когда-то давно добавляли для того, чтобы была возможность поднимать частичные реплики, содержащие не все данные из мастера, а только определенные поля tuple’а. И у нас возникла мысль, что аналогичным образом мы можем в процессе репликации на лету перекодировать тексты.
И все же октопус для этой задачи пришлось чуть допилить: хотя feeder (процесс мастера, скармливающий xlog’и реплике) уже долгое время был реализован в виде отдельного модуля октопуса mod_feeder, он все же не мог запускаться отдельно без хранилища (в данном случае key-value, реализуемого модулем mod_box), а это было необходимо, чтобы изменения в механизме репликации не требовали перезапуска мастера. Ну и, разумеется, пришлось написать репликационные фильтры на lua, которые для каждого namespace’а конвертировали нужные поля из CP1251 в UTF-8.
Помимо, собственно, конвертации данных в тарантулах и октопусах, необходимо было обеспечить прозрачную работу кода с их шардами, которые уже отконвертированы и еще нет, а также обеспечить атомарное переключение с работы в CP1251 на работу в UTF-8. Поэтому было решено поставить перед хранилищами специальную перекодирующую прокси, которая, в зависимости от флажка в запросе клиента, конвертировала данные из кодировки базы в кодировку клиента. Тут нам на помощь опять пришел октопус, а вернее его модуль mod_colander, который позволяет писать быстрые прокси-серверы, в том числе на lua (так как octopus использует luajit и ffi, то получается действительно производительно).
Итого, схема конвертации tarantool/octopus в UTF-8 получилась следующая:
Настраиваем utf8proxy на мастере и репликах. Поднимаем его на том порту, который до этого слушал тарантул, сам тарантул перевешиваем на другой порт. Начиная с этого момента, клиенты могут выполнять запросы как в CP1251, так и в UTF-8. На сервере с мастером запускаем переконвертирующий utf8feeder, настраиваем его читать snapshot’ы и xlog’и из тех же директорий, куда их пишет мастер. На другом сервере в сторонке поднимаем временную реплику мастера, настраиваем ее реплицироваться с переконвертирующего фидера. Во временную реплику данные уже будут приезжать в кодировке UTF-8. utf8proxy реплики настраиваем реплицироваться из временной реплики, старую реплику переливаем из временной, затем возвращаем нагрузку обратно. Файерволим порт на utf8proxy мастера (чтобы не было коллизий на апдейтах), utf8proxy перенастраиваем на временную реплику, временную реплику делаем временным мастером, старый мастер тушим, расфайерволиваем порт на utf8proxy. Переливаем новый мастер из временного, переключаем реплики на репликацию из него. Делаем новый мастер мастером при помощи utf8proxy, временный мастер выключаем. На этом шаге все инстансы содержат данные в UTF-8, можно начинать писать с клиентов некириллические тексты. После перехода всех клиентов на UTF-8 вынимаем utf8proxy.
Весь процесс перекодирования тарантулов/октопусов занял примерно месяц. К сожалению, не обошлось без накладок: так как конвертировали по несколько шардов параллельно, ухитрились при переключении мастеров обратно перепутать два шарда местами. К тому времени, как проблема обнаружилось, уже произошло существенное количество изменений данных. Пришлось анализировать xlog’и с обоих шардов и восстанавливать справедливость.
На первый взгляд, кажется (нам, во всяком случае, так сначала казалось), что с конвертацией кэшей будет проще всего: либо пишем UTF-8 в ключи с другим именем, либо в другие инстансы. Но на практике так не получается. Причины этому две: во-первых, потребуется в два раза больше кэшей, во-вторых, при переключении кодировки кэши будут непрогретые. Если со второй проблемой можно бороться плавным переключением по несколько серверов, то с первой, учитывая большое количество кэшей, — гораздо сложнее.Поэтому мы пошли по пути помечания каждого ключа флагом о том, в какой кодировке он хранится. Тем более что перловый клиент к мемкешу Cache: Memcached: Fast уже имеет эту возможность: при сохранении строки в мемкеше в одном из флагов ключа (F_UTF8 = 0×4) он записывает внутренний перловый флаг строки SVf_UTF8, который установлен, если строка содержит мультибайтовые символы. Таким образом, если флаг стоит, то строка однозначно в UTF-8, если не стоит, то все чуть сложнее: это строка либо текстовая в CP1251, либо бинарная. Текстовые строки, понятное дело, мы переконвертируем при необходимости, а вот с бинарными возникла сложность: чтобы не ломать их ненужной конвертацией, пришлось разделить методы set/get (и пр.) для текстовых строк и бинарных, найти все сохранения бинарных строк в мемкэшед и их получения, и заменить соответствующими методами без автоматического перекодирования. В сишном коде поступили аналогично и добавили поддержку флага F_UTF8.
Помимо вышеупомянутых стандартных хранилищ, у нас используется огромное количество самописных хранилищ, используемых для хранения ленты «что нового», комментариев, очередей сообщений, диалогов, поиска и прочего. Подробнее останавливаться на каждом из них не будем, отметим лишь основные случаи и способы их решения.Хранилище сложно переконвертировать без даунтайма, либо скоро планируем перелить данные в новое хранилище, либо данные с коротким сроком жизни. В таких случаях данные не конвертировали, а помечали новые записи признаком кодировки одним из двух способов: либо при помощи флага, указывающего в какой кодировке находится вся запись, либо при помощи BOM-маркера в начале каждого строкового поля, если оно в UTF-8. В хранилище хранятся не сами строки, а хэш-сумма от них. Используется у нас для поиска. Тут просто прошли по всему хранилищу скриптом, который перегенирировал хэш-суммы от оригинальных строк, переконвертированных в UTF-8. На момент переконвертации пришлось на каждый поисковый запрос выполнять два запроса к базе: один в CP1251, другой в UTF-8. Перед хранилищем уже установлена прокси, и все запросы в хранилище идут через нее. В этом случае реализовывали переконвертацию в прокси, аналогично тому, как это было сделано для тарантула, с той лишь разницей, что если для тарантула это временный функционал, то в данном случае он останется до тех пор, пока будут актуальны данные, хранящиеся в базе. Параллельно с тем, как наши администраторы конвертировали базы данных, разработчики адаптировали код к работе с кодировкой UTF-8. Вся наша кодовая база условно делится на три части: Perl, C и шаблоны.При проектировании процедуры переключения проекта на кодировку UTF-8 одним из ключевых требований для нас была возможность переключения по одному серверу. Нужно было это в первую очередь для того, чтобы обеспечить возможность тестирования проекта в UTF-8 с использованием боевых баз сначала силами наших тестировщиков, а затем несколькими процентами наших пользователей.
Для адаптации перлового кода для работы с UTF-8 потребовалось решить несколько основных задач: переконвертировать кириллические строки, разбросанные по коду; учитывать кодировку сервера при установке соединения ко всем хранилищам и сервисам; учитывать, что параметры HTTP-запросов могут приходить не в той кодировке, в которой работает сервер; необходимо отдавать контент в кодировке сервера и использовать правильные шаблоны; необходимо однозначно логически разделять байтовые строки и символьные, декодировать UTF-8 (из байтов в символы) на входе и кодировать в него на выходе. Задачу конвертации перлового кода из CP1251 в UTF-8 мы решили несколько нетривиальным способом: начали с конвертации модулей на лету при компиляции с применением фильтров (см. perlfilter и Filter: Util: Call, перл позволяет модифицировать исходные коды в промежутке между чтением с диска и компиляцией). Это понадобилось для того, чтобы избежать множественных конфликтов при мердже веток репозитория, которые возникли бы в том случае, если бы мы попытались сконвертировать репо в одной отдельно взятой ветке и держать ее в сторонке во время процесса разработки и тестирования. Весь процесс тестирования и первую неделю после запуска исходные коды продолжали оставаться в CP1251 и конвертировались прямо на боевых серверах при запуске демонов, если сервер был сконфигурирован как UTF-8. Через неделю после запуска мы сконвертировали репозиторий и сразу же вмерджили результат в master. В итоге, конфликты при мердже возникали всего лишь для тех веток, которые были в разработке в этот момент времени.Самым рутинным был процесс добавления во все необходимые места автоматической конвертации строк для хранилищ, которые мы не стали конвертировать в UTF-8 целиком. Но даже и в тех случаях, когда конвертация строк в перле была не нужна, все же приходилось учитывать то, что в перле есть разница (и существенная) между байтовыми и символьными строками. Разумеется, нам хотелось, чтобы все текстовые строки автоматически становились символьными после прочтения из базы, что потребовало анализа всего ввода/вывода на предмет того двоичные данные передаются или текстовые, пришлось пройтись по всем вызовам pack/unpack, чтобы после распаковки пометить все нужные строки как символьные (либо перед упаковкой наоборот сделать строку байтовой, чтобы длина считалась в байтах, а не символах).
Проблему того, что параметры HTTP-запроса могут прийти либо в CP1251, либо в UTF-8 (в зависимости от того, в какой кодировке была загружена referer-страница) сначала хотели решить при помощи передачи дополнительного параметра в запросе. Но потом, проанализировав то, как кодируется CP1251 и UTF-8 пришли к выводу, что всегда можем однозначно отличать кириллицу в CP1251 от кириллицы в UTF-8 путем проверки строки на то, является ли она валидным UTF-8 (только из русских букв в CP1251 практически невозможно составить валидный UTF-8).
В целом же, то, как организована работа с UTF-8 в перле хотя и достаточно удобно, но все-таки зачастую магично, и следует учитывать, что надо:
забыть про то, что у строк есть флаг SVf_UTF8 (он полезен только при отладке), вместо этого лучше относиться к строкам как к байтовым и символьным, забыть про то, что внутреннее представление перловой строки с флагом SVf_UTF8 — UTF-8; забыть про функции Encode::_utf8_on (), Encode::_utf8_off (), utf8:: upgrade (), utf8:: downgrade (), utf8:: is_utf8(), utf8:: valid (); использовать utf8:: encode () при конвертации символьной Unicode-строки в UTF-8; учитывать, что для перла кодировка UTF-8 и utf8 — это слегка разные кодировки: для первой валидны только code point <= 0x10FFFF (как и определено стандартом Unicode), а для второй — любые IV (int32 или int64 в зависимости от архитектуры), закодированные по алгоритму кодирования UTF-8; в соответствии с этим, utf8::decode() можно применять только для декодирования из доверенных источников (свои БД), в которых не может быть невалидного UTF-8, а при декодировании внешнего ввода всегда применять Encode::decode('UTF-8', $_), чтобы защититься от невалидных, с точки зрения Unicode, code point'ов; не забывать, что результат функции utf8::decode() иногда полезно проверять, чтобы понять была ли байтовая строка валидным utf8, для аналогичных целей проверки на валидный UTF-8 можно использовать третий параметр в Encode::decode(); учитывать, что верхняя половина таблицы latin1 содержит те же самые символы, что и Unicode code point'ы с теми же номерами, но при этом в UTF-8 они будет кодироваться по-другому. Это сказывается на результате ошибочного двойного вызова utf8::decode(): для строк содержащих только code point из таблицы ASCII или содержащих хотя бы один символ с code point > 0xff все будет в порядке, а вот если строка содержит только символы с code point из верхней половины таблицы latin1 и ascii, то символы из latin1 побьются. использовать перл последних версий. На perl 5.8.8 мы наступили на замечательный баг: сочетание use locale и некоторых регулярных выражений при правильных входных данных приводит к бесконечному зацикливанию регулярки. Пришлось максимально ограничить scope применения use locale только для строго необходимого набора функций: sort, cmp, lt, le, gt, ge, lc, uc, lcfirst, ucfirst. В нашем коде на C, к счастью, оказалось не так много строк, как в перле, поэтому пошли по классическому пути: вынесли все кириллические строки в отдельный файл. Это позволило ограничить потенциальные конфликты при мерджах в рамках одного файла, а также упростило последующую локализацию. В процессе конвертации репо в UTF-8 обнаружили забавное — русскоязычные комментарии в коде были во всех 4 кириллических кодировках: cp1251, cp866, koi8-r и iso8859–5. Пришлось при конвертации использовать автоопределение кодировки каждой конкретной строки.Помимо конвертации репо, в C также нужна была поддержка базовых строковых функций: определение длины в символах, приведение регистров, обрезание строки по длине, и пр. Для работы с Unicode в C есть замечательная библиотечка libicu, но у нее есть определенное неудобство: она в качестве внутреннего представления использует UTF-16. Разумеется, нам хотелось избежать накладных расходов на перекодирование между UTF-8 и UTF-16, поэтому для наиболее часто используемых несложных функций пришлось реализовать аналоги, работающие непосредственно с UTF-8 без перекодирования.
С шаблонами, к счастью, все оказалось достаточно просто. На production они раскладываются rpm-пакетами, поэтому логичным решением было впилить перекодирование в процесс сборки rpm. Мы добавили еще один пакет с шаблонами в UTF-8, которые устанавливались в соседнюю директорию, а код (и перловый и сишный) после этого просто выбирал шаблон из соответствующей директории.С javascript же из коробки не получилось. Большинство браузеров при загрузке javascript учитывают его Content-Type, но все же есть отдельные старые экземпляры, которые этого не делают, а ориентируются на кодировку страницы. Поэтому поставили костыль: при сборке пакета с javascript мы заменяли все не-ASCII-символы на их escape-последовательности в виде номеров code point’ов. При таком подходе размер js увеличивается, но зато любой браузер загружает его корректно.
В итоге, по прошествии шести месяцев пасьянс сошелся. Админы как раз закончили перекодировать пару сотен баз, разработчики допилили код, процесс тестирования тоже завершился. Мы постепенно переключили ручки на панели управления Миром: сначала в UTF-8 перевели все аккаунты наших коллег, затем одного процента пользователей, после чего стали по 10 серверов переключать backend-сервера и, в конце, frontend’ы. Визуально ничего не менялось, ни страницы проекта, ни графики нагрузки, что не могла не радовать. Единственное внешние изменение, по которому было понятно, что полгода прошли недаром, — это изменение в Content-Type строчки charset=windows-1251 на charset=UTF-8.С тех пор прошло еще три месяца, наши русскоязычные пользователи уже оценили возможность вставлять в текст emoji и прочие рюшечки, а казахские начали переписываться на родном языке и, с недавнего времени, у них появилась возможность пользоваться web-интерфейсом и мобильными приложениями на родном языке. Интересных задач в последовавшем за unicode’изацией процессе интернационализации и локализации проекта тоже хватало, мы постараемся посвятить этому отдельную статью.