Гравюра М. Эшера «Относительность», 1953 В предыдущей статье на примере доменной сущности товара мы рассмотрели собственные типы данных для многоязычных приложений. Мы научились описывать и использовать атрибуты сущностей, имеющие значения на различных языках. Но вопросы хранения и обработки в реляционной СУБД, а также проблемы эффективной работы в коде приложения до сих пор актуальны. IT-сообщество использует различные способы хранения многоязычных данных. Способы эти кардинально различаются эффективностью запросов, устойчивостью к добавлению новых локализаций, объемом данных, удобством для приложения-потребителя.  Однако в индустрии все еще нет решения Database Internationalization for Dummies. Вместе с вами мы попробуем немного заполнить этот пробел: опишем возможные способы, оценим их преимущества и недостатки, выберем эффективные. Мы не собираемся изобретать серебряную пулю, но сценарий, который будем рассматривать, довольно типичен для корпоративных приложений. Надеемся, многим он окажется полезен. Приведенные в статье фрагменты кода — на языке C#. На GitHub можно найти примеры реализации механизмов интернационализации с использованием двух различных связок ORM и СУБД: NHibernate + Oracle Database и Entity Framework Core + SQL Server. Разработчикам, использующим упомянутые ORM, будет интересно узнать конкретные приемы и трудности работы с многоязычными данными, а также блокирующие дефекты фреймворков и перспективы их устранения. Изложенные ниже принципы и примеры работы с многоязычными данными легко перенести и на другие языки и технологии. Наше приложение должно работать сразу с несколькими языками. На любом из них в зависимости от окружения пользователя будут отображаться и вводиться оперативные и справочные данные. При этом существуют сценарии, требующие умения в рамках одной сессии работать с данными сразу на всех языках.  Для примера рассмотрим уже знакомую нам доменную сущность товара, имеющую наименование на различных языках.  Сформулируем требования к хранению и обработке данных сущности с многоязычными атрибутами: Напомним, что алгоритм обработки альтернативных ресурсов IEnumerable<CultureInfo> IResourceFallbackProcess.GetFallbackChain(CultureInfo initial)  может возвращать различные порядки поиска локализаций для различных начальных локалей initial:  Какие же фичи есть в крупнейших современных СУБД для интернационализации? Это: Эти фичи поддерживают многие крупные вендоры. Приведем ссылки на некоторые статьи: Однако никаких «стандартных» схем хранения многоязычных данных не существует. Нет их и в самом свежем стандарте SQL:2016. Из любопытных академических публикаций можно отметить кандидатскую диссертацию экс-главы Исследовательской группы многоязычных систем Microsoft Multilingual Information Processing on Relational Database Architectures, 2005. В работе рассматриваются проблемы кросс-языковых запросов, многоязычные операторы соединения (multilingual join operators), алгебра запросов для нового типа хранения многоязычных данных и упомянутых операторов. Документация по NHibernate порадовала присутствием статьи Localization techniques. В ней рассматриваются два способа хранения (с вариациями): Примечательно, что в этой статье даже не упоминается достаточно очевидный и популярный способ хранения — многоколоночный (колонка на локаль).  По Entity Framework значимых материалов найти не удалось. Для начала сформируем критерии сравнения. Для этого описанные выше требования переформулируем в более технические. Как мы убедимся далее, все они довольно жесткие. Локализуемость. Изначально требование означает отсутствие изменений в коде, когда новая локализация добавляется в приложение. Применительно к приложениям с локализованными данными мы, например, можем говорить об отсутствии изменений в схеме данных и маппингов ORM. Поиск и сортировка для заданной локали. В терминах многоязычной строки это означает возможность использовать индексы по результатам функции String MultiCulturalString.GetString(IResourceFallbackProcess resourceFallbackProcess, CultureInfo culture, bool useFallback) для всех осмысленных в данном приложении комбинаций значений ее параметров. Экземпляр IResourceFallbackProcess в приложении, вероятно, единственный, при useFallback = false значение resourceFallbackProcess роли не играет, поэтому число необходимых индексов по локализованным данным не более 2N, где N — число различных локалей, используемых во всех записях в многоязычном атрибуте. Поиск среди всех локалей. Требуется поддержка функции вида MultiCulturalString.FindLocalizedStringCulture(String localizedString) с типом возвращаемого значения CultureInfo. Поиск подходящей локализации. Локаль пользователя всегда специфичная, то есть определяет не только язык, но и региональные параметры. А для хранения в большинстве случаев достаточно использовать нейтральные локали (не задающие специфику региона), «ближайшие» к выбранным специфичным. Поэтому из приведенных выше десяти локалей нам достаточно предусмотреть хранение только для четырех нейтральных: ru, en, kz, zh-Hans. А в ORM можно поддержать функционал приведения специфичной локали к нейтральной при присваивании атрибута и (или) сохранении сущности. Пригодность для ORM. У выбранного ORM должно быть достаточно точек расширения, чтобы мы смогли претворить в жизнь все наши фантазии о многоязычном хранении. Иначе придется писать новый фреймворк. Этот вариант требует заранее знать список необходимых локализаций. А для каждой новой используемой  локали нам понадобится добавлять новую колонку и пару индексов. И так для каждого многоязычного атрибута во всех таблицах локализуемых сущностей. Вероятно, придется изменять маппинги ORM в коде и (или) конфигурации клиента БД, особенно для статических моделей. Но не стоит сразу расстраиваться. Изменения в коде и конфигурации, скорее всего, не станут большой проблемой для приложения, в котором заранее известен и редко меняется список используемых локалей и (или) используются динамические сущности. Кроме того, как увидим далее, требование локализуемости так или иначе придется ослаблять для всех рассматриваемых вариантов.  Рассмотрим, например, такой запрос: Думаю, вы согласитесь, что он вполне может быть реализован следующим SQL-запросом: Чтобы получать «честный» null вместо пустой строки, когда запрашиваемое значение отсутствует, с точки зрения многоязычной строки следует использовать одну из перегрузок MultiCulturalString.GetString. Тогда выражение фильтрации в SQL немного упростится: Получаем, что еще хотя бы по одному индексу на локаль нам необходимо строить по выражению с isnull, которое является SQL-отображением цепочки поиска альтернативных ресурсов. Такие индексы обычно называют функциональными, в SQL Server это индексы над вычислимыми колонками. В приведенном выше описании таблицы t_product эти индексы имеют суффикс _stdfallback. О виде запроса в случае поиска строки среди всех локализаций мы предоставим читателю возможность пофантазировать. Такой способ хранения и доступа реализован для одного из клиентов нашей компании. В этом варианте многоязычный атрибут занимает только одну колонку. Многоязычная строка может быть сериализована как в бинарном, так и в человекочитаемом виде. Или, например, в Oracle колонка может иметь объектный тип. Учитывая современные тенденции развития стандарта SQL, стоит обратить внимание на XML- или JSON-хранение. Oracle Database, SQL Server, DB2, PostgreSQL, MySQL имеют довольно серьезную поддержку XML и (или) JSON. Принимая такое решение, нам необходимо позаботиться о возможности получения (а кому-то — и записи) локализованных значений средствами СУБД. Универсальным и хорошо инкапсулирующим конкретный вариант сериализации способом будет создание пользовательской скалярной функции, которая принимает на вход сериализованное значение, локаль и цепочку для поиска подходящей локализации, а возвращает локализованную строку. При появлении новой локали нам все же придется добавлять новые функциональные индексы по результатам функции McsGetString. Добавление только индексов — более безопасное действие, чем добавление новых колонок. В отличие от многоколоночного хранения маппинги ORM, вероятно, изменять не придется.  Здесь мы заводим отдельную таблицу для хранения локализованных значений. Вариаций такого хранения множество. Рассмотрим только один из них.  SQL-запроc с учетом поиска подходящих локализаций уже более громоздкий: Такой запрос преподносит нам целый букет сюрпризов.  Во-первых, стоимость запроса уже заметно выше, чем в предыдущих вариантах.  Во-вторых, при помощи такого запроса проблематично инстанцировать сущность с полностью инициализированным многоязычным атрибутом. Либо мы должны добавить LEFT JOIN для всех локалей, список которых нужно знать заранее, либо отказаться от использования атрибута типа MultiCulturalString, заменив на String и уменьшив число покрываемых сценариев. Еще один вариант  — поддержать ленивую загрузку значений для различных локалей внутри многоязычной строки. Каждая из альтернатив имеет право на жизнь с учетом конкретных требований к продукту. В-третьих, мы собираемся поддержать работу с многоязычными атрибутами в существующих ORM. Но нам вряд ли удастся найти такую точку расширения, чтобы добавить JOIN в SQL-запрос, при этом оставаясь в рамках простого LINQ-запроса: Соединения с таблицей t_product_localizable можно заменить на подзапросы, но это принципиально ничего не меняет. Весьма любопытный результат, не правда ли? Зато требование локализуемости выполняется. Как мы и ожидали, найти серебряную пулю среди рассмотренных вариантов не удалось. В каждом из них в большей или меньшей степени нарушается одно или несколько требований. Однако это не повод ничего не делать. Нам необходимо принять компромиссное решение, выбрать золотую середину. Наиболее взвешенным и перспективным мы посчитали вариант одноколоночного сериализованного хранения. Поэтому в следующем разделе предлагаем рассмотреть особенности реализаций этого варианта для связок NHibernate + Oracle Database и Entity Framework Core + SQL Server. Сериализацию многоязычных атрибутов будем делать в XML, а для доступа к локализованным значениям из БД — использовать средства СУБД. Итак, мы предпримем попытку поддержать работу с сущностями, содержащими атрибут типа MultiCulturalString, а также запрос поиска таких сущностей по локализованному значению атрибута. Вопрос поиска многоязычного атрибута по всем локалям мы рассматривать не будем, предоставив это заинтересованному читателю. В реализации мы исходим из следующих принципов: Приведем диаграмму зависимостей с мелким делением на сборки.

  Сериализовывать значения многоязычной строки мы будем в XML. В .NET удачно разделены ответственности за содержание сериализованных данных (ISerializable, ISerializationSurrogate) и конечное сериализованное представление (IFormatter). И если первую ответственность вполне логично возложить на сам сериализуемый объект, то вторая сильно зависит от использования. Поэтому для форматирования применим найденный на просторах интернета XmlFormatter: IFormatter, использующий ISerializable-возможности объектов. NHibernate, пожалуй, уже давно наиболее функциональный ORM под .NET. Вместе с тем он содержит противоречивые наслоения, возникшие на разных этапах своего развития. Сейчас версии выходят крайне редко, контрибьюторов осталось мало. Некоторые давно ожидаемые исправления дефектов, видимо, не выйдут никогда. Чтобы загрузить и сохранить значения многоязычной строки, нам необходимо реализовать IUserType, в котором и использовать упомянутый XmlFormatter.  Чтобы использовать эту функцию в HQL, достаточно добавить реализацию ISQLFunction в конфигурацию NHibernate. Но кроме того, мы хотим, чтобы обращение в LINQ-to-Database к перегрузкам MultiCulturalString.ToString() превращалось в вызов функции McsGetString. Впрочем, и для этого в NHibernate есть точка расширения: достаточно реализовать IHqlGeneratorForMethod и также зарегистрировать реализацию в конфигурации. Сама реализация IHqlGeneratorForMethod ожидаемо преобразует одно дерево выражений в другое. Вот, собственно говоря, и все. Неужели мы молодцы, а с NHibernate все так беспроблемно? Увы, нет!  Для обоих запросов будет сгенерирован одинаковый SQL: Все дело в дефекте NH-2500: LINQ-запросы даже разных сессий кэшируются на уровне фабрики сессий и переиспользуются, несмотря на различные значения параметров запроса. Хотя баг уже шесть лет как критичный, исправление войдет только в будущую версию 5.1. А пока можно выпустить fork NHibernate. Если вам понадобится править какие-то другие дефекты NHibernate, низкая динамика продукта делает риски «протухания» ваших правок невысокими. Не так давно вышел EF Core 2.0, и дальнейшее развитие именно за этой кроссплатформенной ветвью. Для наших примеров мы выбрали его, а не EF 6, так как надеемся на скорое решение описанных ниже проблем. К сожалению, в EF Core мы не можем реализовать поддержку пользовательского типа, в том числе MultiCulturalString. Но уже в версии фреймворка 2.1 появится такая возможность (см. issue #242). Именно прокси-класс будет участвовать в нашем DbContext. Как вы догадались, атрибут Name в маппинге участвовать не будет вовсе. Ну и конечно же, читателю хотелось бы используемый в маппинге атрибут RawName сделать protected, благо ORM-у это не мешает. Но не торопитесь! Уже знакомый нам запрос  заработает в EF без дополнительных усилий с нашей стороны. Но только на клиенте! Ведь атрибут Name никак не маппится на БД.  Большое подспорье, что EF позволяет получать предупреждения о клиентском выполнении части запроса либо вовсе запрещать его при помощи DbContextOptionsBuilder.ConfigureWarnings.  И даже после того, как будет сделан issue #242, мы можем использовать только статические функции для маппинга на функции БД. Использовать экземплярную функцию ToString не получится, см. issue #9213. Возможным (пусть и временным) выходом для нас видится объявление методов-расширений DbUserDefinedMethods.McsGetString(this String mcs, ...), по сигнатурам совпадающих с многочисленными перегрузками MultiCulturalString.ToString, без учета первого параметра this. Запросы будут иметь несколько искусственный вид: Такие методы-расширения регистрируются в построителе модели, и задается преобразование из одного дерева выражений в другое, реализация которого практически не отличается от реализации IHqlGeneratorForMethod для NHibernate. Вдобавок в EF вычислимость выражения анализируется на серверной стороне без учета преобразования зарегистрированной функции. Поэтому перегрузки DbUserDefinedMethods.McsGetString, содержащие неизвестные ORM типы CultureInfo и IResourceFallbackProcess, будут всегда вычисляться на клиенте. Альтернатива всем перечисленным «костылям» в EF есть — это написание собственного провайдера. Тогда можно поддержать и любые типы, и необходимые экземплярные функции. Но с точки зрения поддержки, синхронизации с провайдером-оригиналом, который активно развивается, такое архитектурное решение выглядит слабым. Поэтому будем надеяться на хорошую динамику новой ветви развития EF. Мы рассмотрели возможный сценарий использования и реализации поддержки многоязычных атрибутов. В то же время для некоторых простых сценариев использования многоязычных атрибутов достаточно в сущности декларировать только строковый атрибут, а для переключения между локализациями — вводить специальные методы вида static String InLocale(this String mcs, CultureInfo culture, ...). Недостаток рассмотренного варианта одноколоночного сериализованного хранения — в частности, больший трафик между клиентом и сервером и более высокое потребление памяти.  Тем не менее поддержка многоязычных атрибутов и запросов с ними со стороны ORM позволяет получить полное, однородное и прозрачное для разработчиков решение. С нашей точки зрения, это неоспоримое преимущество описанного подхода. Software Architect