24. Локалізація в БД. Підходи до збереження локалізованих даних у базі даних. Кешування таблиць БД

Відео версія
(YouTube - для зворотнього зв'язку)

Окрема колонка для кожної мови

Для кожного локалізованого поля створюються окремі колонки в таблиці, наприклад, title_en, title_ua. Це дуже зрозумілий підхід і він працює.

Проте має низку недоліків:

  • При додаванні нової мови доведеться створювати численні міграції для кожної сутності та кожної локалізованої колонки.
  • Керувати таблицею з великою кількістю додаткових колонок для різних мов незручно.

Рекомендую використовувати цей підхід у дуже обмежених випадках, коли у вас мало сутностей і ви не плануєте масштабувати проект.

Окрема таблиця з текстами локалізації

Створюється окрема поліморфна таблиця з полем, яке визначає унікальний тип локалізації (title, description тощо), та полем, що вказує на локаль.

Далі, або за допомогою join, або with, отримуємо необхідні нам поля.

Цей варіант є практичнішим, проте:

  • Необхідно оптимізувати запити, як для звичайної вибірки, так і для пошуку за полями. Або ж використовувати кешування.
  • Таблиця з локалізацією повинна бути доступною у всіх моделях і сервісах, де може бути локалізоване поле. Або ж її можна винести в окремий сервіс, що ще більше підвищує необхідність оптимізації.
  • Є варіант створення окремої таблиці або таблиць для кожної сутності, що потребує перекладу. Це ізолює дані в модулі, але сортування та пошук все одно будуть здійснюватися за допомогою join, і питання оптимізації залишається актуальним.
  • Колонка з текстом зазвичай матиме тип Text, що покриє будь-які варіанти перекладу, але це впливає на оптимізацію.

Цей варіант виглядає набагато краще, ніж окремі колонки, як мінімум, він легше масштабується.

Будьте обережні з готовими рішеннями, особливо в проєктах з високим навантаженням.

JSON

Тут усе просто — зберігаємо локалізоване поле у вигляді JSON (jsonb для Postgres).

example
{
  "en": "Title in English",
  "ua": "Заголовок українською"
}

Це мій вибір. Він дозволяє зберігати будь-яку кількість мов, реалізовувати унікальну fallback логіку і, головне, ізолювати всі дані всередині сутності. Звісно, є й мінуси: оптимізація при сортуванні та пошуку за локалізованим полем.

Дуже раджу звертати увагу на функціонал готових рішень. Часто вони для кожної дії дістають усі варіанти локалізації, що не є оптимальним, якщо у вас багато мов.

Для надійної оптимізації я реалізував власний варіант для Laravel Eloquent.

Доступні локалі оформив у вигляді Enum. Це дозволяє легко маніпулювати мовами, а також зберігати обрану мову або мови у БД.

Моє рішення виглядає так:

Locale middleware у Laravel

Тут усе просто: або беремо локаль із заголовка, або встановлюємо локаль за замовчуванням.

app/Http/Middleware/Localize.php
<?php

namespace App\Http\Middleware;

use App\Enums\Locale;
use Closure;
use Illuminate\Http\Request;

class Localize
{
    public function handle(Request $request, Closure $next): mixed
    {
        $locale = $request->header('X-Locale') ?? Locale::default();
        $locale = Locale::tryFrom($locale)?->value;
        if ($locale) {
            app()->setLocale($locale);
        }

        return $next($request);
    }
}

І обовязково викликайте його на кожен api запит

app/Http/Kernel.php
  protected $middlewareGroups = [
        'api' => [
            \App\Http\Middleware\Localize::class,
        ],
    ];

Кешування всієї таблиці (словників)

Усі списки сутностей, що змінюються рідко (категорії, теги, групи тощо), можна назвати "словниками". Словники доцільно кешувати, це дає суттєвий приріст в оптимізації. Головне — не забувайте чистити кеш при оновленні, видаленні або додаванні записів до словника.

Що стосується eager loading, повноцінно використовувати кеш тут не вийде, запит до БД все одно доведеться робити, щоб отримати дані. Query builder не працює з кешем, і в цьому немає великого сенсу, якщо запит вже є.

Some dictionary get list method
 return Cache::rememberForever(Dictionary::LIST_CACHE_KEY, function () {
            return Dictionary::select([
                'id', 
                'name'
            ])
                ->get();
        });
    }

Some dictionary model
public const string LIST_CACHE_KEY = 'genre_list';

public static function boot(): void
{
    parent::boot();

    static::created(function () {
        Cache::forget(self::LIST_CACHE_KEY);
    });

    static::updated(function () {
        Cache::forget(self::LIST_CACHE_KEY);
    });

    static::deleted(function () {
        Cache::forget(self::LIST_CACHE_KEY);
    });
}

Будьте обережні з пакетами, що кешують кожен запит. Пам'ятайте, що кеш зазвичай зберігається в оперативній пам'яті. Однак це може бути доцільним у деяких ситуаціях.

Масив Enums

Laravel має готовий каст для масиву переліків. Проте, на жаль, він не працює з Postgres. Ось приклад касту для Postgres: