|
Персональные инструменты |
|||
|
Подвалы Вавилонской башни, или Об интернационализации баз данных с доступом через ORMМатериал из CustisWikiВладислав Иофе, наш архитектор приложений, опубликовал на портале «Хабрахабр» пост, посвященный хранению и обработке многоязычных данных. Какие способы хранения существуют? Какими преимуществами и недостатками они обладают и есть ли идеальный вариант? Об этом — в материале «Подвалы Вавилонской башни, или Об интернационализации баз данных с доступом через ORM». СодержаниеВведениеВ предыдущей статье на примере доменной сущности товара мы рассмотрели собственные типы данных для многоязычных приложений. Мы научились описывать и использовать атрибуты сущностей, имеющие значения на различных языках. Но вопросы хранения и обработки в реляционной СУБД, а также проблемы эффективной работы в коде приложения до сих пор актуальны. IT-сообщество использует различные способы хранения многоязычных данных. Способы эти кардинально различаются эффективностью запросов, устойчивостью к добавлению новых локализаций, объемом данных, удобством для приложения-потребителя. Однако в индустрии все еще нет решения Database Internationalization for Dummies. Вместе с вами мы попробуем немного заполнить этот пробел: опишем возможные способы, оценим их преимущества и недостатки, выберем эффективные. Мы не собираемся изобретать серебряную пулю, но сценарий, который будем рассматривать, довольно типичен для корпоративных приложений. Надеемся, многим он окажется полезен. Приведенные в статье фрагменты кода — на языке C#. На GitHub можно найти примеры реализации механизмов интернационализации с использованием двух различных связок ORM и СУБД: NHibernate + Oracle Database и Entity Framework Core + SQL Server. Разработчикам, использующим упомянутые ORM, будет интересно узнать конкретные приемы и трудности работы с многоязычными данными, а также блокирующие дефекты фреймворков и перспективы их устранения. Изложенные ниже принципы и примеры работы с многоязычными данными легко перенести и на другие языки и технологии. Условия задачиНаше приложение должно работать сразу с несколькими языками. На любом из них в зависимости от окружения пользователя будут отображаться и вводиться оперативные и справочные данные. При этом существуют сценарии, требующие умения в рамках одной сессии работать с данными сразу на всех языках. Для примера рассмотрим уже знакомую нам доменную сущность товара, имеющую наименование на различных языках. public class Product { public long Id {get; set; } public String Code { get; set; } public MultiCulturalString Name { get; set; } } Сформулируем требования к хранению и обработке данных сущности с многоязычными атрибутами:
Напомним, что алгоритм обработки альтернативных ресурсов
Обзор существующих возможностейСУБДКакие же фичи есть в крупнейших современных СУБД для интернационализации? Это:
Эти фичи поддерживают многие крупные вендоры. Приведем ссылки на некоторые статьи: Однако никаких «стандартных» схем хранения многоязычных данных не существует. Нет их и в самом свежем стандарте SQL:2016. Из любопытных академических публикаций можно отметить кандидатскую диссертацию экс-главы Исследовательской группы многоязычных систем Microsoft Multilingual Information Processing on Relational Database Architectures, 2005. В работе рассматриваются проблемы кросс-языковых запросов, многоязычные операторы соединения (multilingual join operators), алгебра запросов для нового типа хранения многоязычных данных и упомянутых операторов. ORMДокументация по NHibernate порадовала присутствием статьи Localization techniques. В ней рассматриваются два способа хранения (с вариациями):
Примечательно, что в этой статье даже не упоминается достаточно очевидный и популярный способ хранения — многоколоночный (колонка на локаль). По Entity Framework значимых материалов найти не удалось. Сравнение храненийДля начала сформируем критерии сравнения. Для этого описанные выше требования переформулируем в более технические. Как мы убедимся далее, все они довольно жесткие. Локализуемость. Изначально требование означает отсутствие изменений в коде, когда новая локализация добавляется в приложение. Применительно к приложениям с локализованными данными мы, например, можем говорить об отсутствии изменений в схеме данных и маппингов ORM. Поиск и сортировка для заданной локали. В терминах многоязычной строки это означает возможность использовать индексы по результатам функции Поиск среди всех локалей. Требуется поддержка функции вида Поиск подходящей локализации. Локаль пользователя всегда специфичная, то есть определяет не только язык, но и региональные параметры. А для хранения в большинстве случаев достаточно использовать нейтральные локали (не задающие специфику региона), «ближайшие» к выбранным специфичным. Поэтому из приведенных выше десяти локалей нам достаточно предусмотреть хранение только для четырех нейтральных: Пригодность для ORM. У выбранного ORM должно быть достаточно точек расширения, чтобы мы смогли претворить в жизнь все наши фантазии о многоязычном хранении. Иначе придется писать новый фреймворк. Многоколоночное хранениеЭтот вариант требует заранее знать список необходимых локализаций. А для каждой новой используемой локали нам понадобится добавлять новую колонку и пару индексов. И так для каждого многоязычного атрибута во всех таблицах локализуемых сущностей. Вероятно, придется изменять маппинги ORM в коде и (или) конфигурации клиента БД, особенно для статических моделей. Но не стоит сразу расстраиваться. Изменения в коде и конфигурации, скорее всего, не станут большой проблемой для приложения, в котором заранее известен и редко меняется список используемых локалей и (или) используются динамические сущности. Кроме того, как увидим далее, требование локализуемости так или иначе придется ослаблять для всех рассматриваемых вариантов. С созданием индексов для каждой из Рассмотрим, например, такой запрос: var enUS = CultureInfo.GetCultureInfo("en-US"); var productName = "..."; var result = GetRepository<Product>() .Where(p => p.Name.ToString(enUS) == productName) .SingleOrDefault(); Думаю, вы согласитесь, что он вполне может быть реализован следующим SQL-запросом: SELECT pr.id_product, pr.code, pr.name_ru, pr.name_en, pr.name_kz, pr.name_zh_hans FROM t_product pr WHERE isnull(pr.name_en, isnull(pr.name_ru, '')) LIKE @p1 ORDER BY isnull(pr.name_en, isnull(pr.name_ru, '')), pr.id_product Чтобы получать «честный» SELECT pr.id_product, pr.code, pr.name_ru, pr.name_en, pr.name_kz, pr.name_zh_hans FROM t_product pr WHERE isnull(pr.name_en, pr.name_ru) LIKE @p1 ORDER BY isnull(pr.name_en, pr.name_ru), pr.id_product Получаем, что еще хотя бы по одному индексу на локаль нам необходимо строить по выражению с О виде запроса в случае поиска строки среди всех локализаций мы предоставим читателю возможность пофантазировать. Такой способ хранения и доступа реализован для одного из клиентов нашей компании. Одноколоночное сериализованное хранениеВ этом варианте многоязычный атрибут занимает только одну колонку. Многоязычная строка может быть сериализована как в бинарном, так и в человекочитаемом виде. Или, например, в Oracle колонка может иметь объектный тип. Учитывая современные тенденции развития стандарта SQL, стоит обратить внимание на XML- или JSON-хранение. Oracle Database, SQL Server, DB2, PostgreSQL, MySQL имеют довольно серьезную поддержку XML и (или) JSON. Принимая такое решение, нам необходимо позаботиться о возможности получения (а кому-то — и записи) локализованных значений средствами СУБД. Универсальным и хорошо инкапсулирующим конкретный вариант сериализации способом будет создание пользовательской скалярной функции, которая принимает на вход сериализованное значение, локаль и цепочку для поиска подходящей локализации, а возвращает локализованную строку. SQL-запрос для поиска будет выглядеть примерно так: SELECT pr.id_product, pr.code, pr.name FROM t_product pr WHERE McsGetString(pr.name, 'en', 'en,ru') LIKE @p1 ORDER BY McsGetString(pr.name, 'en', 'en,ru'), pr.id_product При появлении новой локали нам все же придется добавлять новые функциональные индексы по результатам функции Реляционное хранениеЗдесь мы заводим отдельную таблицу для хранения локализованных значений. Вариаций такого хранения множество. Рассмотрим только один из них. SQL-запроc для поиска только по одной локали выглядит довольно просто: SELECT pr.id_product, pr.code, pl.name FROM t_product pr LEFT JOIN t_product_localizable pl ON pr.id_product = pl.id_product AND pl.locale = 'en' WHERE pl.name LIKE @p1 ORDER BY pl.name, pr.id_product SQL-запроc с учетом поиска подходящих локализаций уже более громоздкий: SELECT pr.id_product, pr.code, pl_en.name, pl_ru.name FROM t_product pr LEFT JOIN t_product_localizable pl_ru ON pr.id_product = pl_ru.id_product AND pl_ru.locale = 'ru' LEFT JOIN t_product_localizable pl_en ON pr.id_product = pl_en.id_product AND pl_en.locale = 'en' WHERE isnull(pl_en.name, isnull(pl_ru.name, '')) LIKE @p1 ORDER BY isnull(pl_en.name, isnull(pl_ru.name, '')), pr.id_product Такой запрос преподносит нам целый букет сюрпризов. Во-первых, стоимость запроса уже заметно выше, чем в предыдущих вариантах. Во-вторых, при помощи такого запроса проблематично инстанцировать сущность с полностью инициализированным многоязычным атрибутом. Либо мы должны добавить В-третьих, мы собираемся поддержать работу с многоязычными атрибутами в существующих ORM. Но нам вряд ли удастся найти такую точку расширения, чтобы добавить var result = GetRepository<Product>() .Where(p => p.Name.ToString(enUS) == productName) .SingleOrDefault(); Соединения с таблицей Весьма любопытный результат, не правда ли? Зато требование локализуемости выполняется. ВыводыКак мы и ожидали, найти серебряную пулю среди рассмотренных вариантов не удалось. В каждом из них в большей или меньшей степени нарушается одно или несколько требований. Однако это не повод ничего не делать. Нам необходимо принять компромиссное решение, выбрать золотую середину. Наиболее взвешенным и перспективным мы посчитали вариант одноколоночного сериализованного хранения. Поэтому в следующем разделе предлагаем рассмотреть особенности реализаций этого варианта для связок NHibernate + Oracle Database и Entity Framework Core + SQL Server. Сериализацию многоязычных атрибутов будем делать в XML, а для доступа к локализованным значениям из БД — использовать средства СУБД. Расширяем ORMИтак, мы предпримем попытку поддержать работу с сущностями, содержащими атрибут типа Вопрос поиска многоязычного атрибута по всем локалям мы рассматривать не будем, предоставив это заинтересованному читателю. В реализации мы исходим из следующих принципов:
Приведем диаграмму зависимостей с мелким делением на сборки. Сериализовывать значения многоязычной строки мы будем в XML. В .NET удачно разделены ответственности за содержание сериализованных данных ( NHibernate + Oracle DatabaseNHibernate, пожалуй, уже давно наиболее функциональный ORM под .NET. Вместе с тем он содержит противоречивые наслоения, возникшие на разных этапах его развития. Сейчас версии выходят крайне редко, контрибьюторов осталось мало. Некоторые давно ожидаемые исправления дефектов, видимо, не выйдут никогда. Чтобы загрузить и сохранить значения многоязычной строки, нам необходимо реализовать Многоязычная строка в XML выглядит довольно очевидно. <?xml version="1.0" encoding="UTF-8"?> <MultiCulturalString xsi:type="MultiCulturalString" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://custis.ru/i18n"> <ru>Шоколад Алина</ru> <en>Chocolate Alina</en> </MultiCulturalString> «Сердце» функции доступа к локализованным значениям McsGetString использует XQuery. SELECT XMLCast( XMLQuery('declare namespace i18n="http://custis.ru/i18n"; for $mcs_locale in $mcs/i18n:MultiCulturalString/* where $mcs_locale/name() = $locale return $mcs_locale/text()' PASSING a_mcs AS "mcs", a_locale AS "locale" RETURNING CONTENT) AS VARCHAR2(4000 CHAR)) INTO l_value FROM dual;
Вот, собственно говоря, и все. Неужели мы молодцы, а с NHibernate все так беспроблемно? Увы, нет! Вот такой простой тест не пройдет. [Test] public void TestNh2500() { using (var session = SessionFactory.OpenSession()) { var product = new Product { Code = ProductCode, Name = new MultiCulturalString(ru, ProductNameRu) .SetLocalizedString(en, ProductNameEn) }; session.Save(product); } using (var session = SessionFactory.OpenSession()) { var product = session.AsQueryable<Product>() .SingleOrDefault(p => p.Name.ToString(zhCHS)) == ProductNameEn); Assert.IsNotNull(product); Assert.AreEqual(ProductCode, product.Code); } using (var session = SessionFactory.OpenSession()) { var product = session.AsQueryable<Product>() .SingleOrDefault(p => p.Name.ToString(ruRU)) == ProductNameEn); // The next line throws AssertionException Assert.IsNull(product); } } Для обоих запросов будет сгенерирован одинаковый SQL: SELECT product0_.id_product AS id1_0_, product0_.code AS code0_, product0_.name AS name0_ FROM t_product product0_ WHERE McsGetString(product0_.name, 'zh-CHS', 'zh-CHS,zh-Hans,zh,en')=:p0; Все дело в дефекте NH-2500: LINQ-запросы даже разных сессий кэшируются на уровне фабрики сессий и переиспользуются, несмотря на различные значения параметров запроса. Хотя баг уже шесть лет как критичный, исправление войдет только в будущую версию 5.1. А пока можно выпустить fork NHibernate. Если вам понадобится править какие-то другие дефекты NHibernate, низкая динамика продукта делает риски «протухания» ваших правок невысокими. Entity Framework Core + SQL ServerНе так давно вышел EF Core 2.0, и дальнейшее развитие именно за этой кроссплатформенной ветвью. Для наших примеров мы выбрали его, а не EF 6, так как надеемся на скорое решение описанных ниже проблем. К сожалению, в EF Core мы не можем реализовать поддержку пользовательского типа, в том числе А пока что создадим прокси-класс для нашего товара со строковым атрибутом RawName. public class Product { public virtual long Id { get; protected set; } public virtual String Code { get; set; } public virtual MultiCulturalString Name { get; set; } } public class ProductProxy : Product { public override MultiCulturalString Name { get => base.Name; set { _rawName = ConvertToStoredValue(value); base.Name = value; } } public virtual String RawName { get => _rawName; set { base.Name = ParseStoredValue(value); _rawName = value; } } private String _rawName; ... } Именно прокси-класс будет участвовать в нашем Уже знакомый нам запрос var result = GetRepository<ProductProxy>() .Where(p => p.Name.ToString(enUS) == productName) .SingleOrDefault(); заработает в EF без дополнительных усилий с нашей стороны. Но только на клиенте! Ведь атрибут Большое подспорье, что EF позволяет получать предупреждения о клиентском выполнении части запроса либо запрещать его при помощи И даже после того, как будет сделан issue #242, мы можем использовать только статические функции для маппинга на функции БД. Использовать экземплярную функцию Возможным (пусть и временным) выходом для нас видится объявление методов-расширений var result = GetRepository<ProductProxy>() .Where(p => p.RawName.McsGetString(enUS) == productName) .SingleOrDefault(); Такие методы-расширения регистрируются в построителе модели, и задается преобразование из одного дерева выражений в другое, реализация которого практически не отличается от реализации Вдобавок в EF вычислимость выражения анализируется на серверной стороне без учета преобразования зарегистрированной функции. Поэтому перегрузки Альтернатива всем перечисленным «костылям» в EF есть — это написание собственного провайдера. Тогда можно поддержать и любые типы, и необходимые экземплярные функции. Но с точки зрения поддержки, синхронизации с провайдером-оригиналом, который активно развивается, такое архитектурное решение выглядит слабым. Поэтому будем надеяться на хорошую динамику новой ветви развития EF. ЗаключениеМы рассмотрели возможный сценарий использования и реализации поддержки многоязычных атрибутов. В то же время для некоторых простых сценариев использования многоязычных атрибутов достаточно в сущности декларировать только строковый атрибут, а для переключения между локализациями — вводить специальные методы вида Недостаток рассмотренного варианта одноколоночного сериализованного хранения — в частности, больший трафик между клиентом и сервером и более высокое потребление памяти. Тем не менее поддержка многоязычных атрибутов и запросов с ними со стороны ORM позволяет получить полное, однородное и прозрачное для разработчиков решение. С нашей точки зрения, это неоспоримое преимущество описанного подхода. |
||