Ukládání vícejazyčných záznamů
Článek vyšel na serveru Interval.cz.
Mějme tabulku výrobků v MySQL, do které chceme uložit název, cenu a popis výrobku spolu s identifikátorem skupiny, do které patří. Problém je ten, že PHP aplikace využívající tuto tabulku je vícejazyčná, takže název a popis výrobku musí být k dispozici ve více jazycích.
Nad touto tabulkou budeme chtít provádět následující operace:
- Vypsat výrobky z dané skupiny setříděné podle názvu, samozřejmě s dodržením pravidel třídění jednotlivých jazyků.
- Fulltextově vyhledávat v názvu a popisu.
- Musíme počítat se situací, že ne všechny texty budou vždy přeložené do všech jazyků.
- Čas od času budeme chtít přidat další jazyk.
Všechny dotazy, které budou s tabulkou pracovat, by měly být pokud možno jednoduché a hlavně by se měly vykonávat efektivně. To především znamená, že budou využívat indexy, protože např. vyhodnocení klauzule ORDER BY nazev LIMIT 10
s indexem zabere 10 operací, ale bez něj plných N * log N
, kde N je celkový počet záznamů ve výsledku dotazu.
Překlady v samostatné tabulce
K řešení problému se dá přistoupit několika způsoby. První možnost je vyčlenit název a popis výrobku do samostatné tabulky a přidat identifikátor jazyka. Tento návrh má především tu výhodu, že se snadno přidávají další jazyky, a na první pohled vypadá čistě. Skutečnost je ale taková, že návrh směšuje data různého typu do jednoho sloupce – název sice může být ve všech jazycích např.
varchar(50)
, pokaždé ale používá jiný způsob porovnávání (a případně i kódování). Kvůli tomu se data nedají efektivně třídit správně ve všech jazycích. Rozdělení dat do dvou tabulek navíc znemožňuje použití indexu pro setřídění záznamů vybraných z dané skupiny.
S efektivitou jsme na tom tedy špatně a o jednoduchosti dotazů se také nedá hovořit, obzvlášť pokud zohledníme, že všechny překlady nemusí být k dispozici. Neexistující překlady je přirozené do tabulky vůbec neukládat nebo pokud je k dispozici např. jen název, tak na místo popisu uložit NULL. Místo nich můžeme použít např. anglickou jazykovou verzi, která bude k dispozici vždy. Jednoduchý dotaz pro výpis výrobků z dané skupiny setříděný podle názvu se potom promění v následující monstrum:
"SELECT vyrobky.id, IFNULL(vyrobky_preklady.nazev, vyrobky_en.nazev) AS nazev, IFNULL(vyrobky_preklady.popis, vyrobky_en.popis) AS popis, vyrobky.cena FROM vyrobky LEFT JOIN vyrobky_preklady ON vyrobky.id = vyrobky_preklady.vyrobek AND vyrobky_preklady.jazyk = '$jazyk' INNER JOIN vyrobky_preklady vyrobky_en ON vyrobky.id = vyrobky_en.vyrobek AND vyrobky_en.jazyk = 'en' WHERE vyrobky.skupina = $skupina ORDER BY IFNULL(vyrobky_preklady.nazev, vyrobky_en.nazev) COLLATE $porovnavani"
Způsob porovnávání pro uživatelem zvolený jazyk si předem vybereme z tabulky jazyky
a uložíme do proměnné $porovnavani
.
Poslední problém je s fulltextovým vyhledáváním – MySQL při něm ignoruje slova, která jsou obsažena alespoň v polovině řádek (např. spojky a předložky). Tím, že jsou všechny jazyky smíchané dohromady, k tomu prakticky nedojde a zbytečně se budou prohledávat i tato slova. Kromě toho není možné kombinovat fulltextový index s obyčejným, takže v klauzuli WHERE jazyk = 'en' AND MATCH (nadpis, popis) AGAINST ('dotaz')
se nebude moci použít index nad sloupcem jazyk.
Kopie dat
Záznamy můžeme mít v tabulce zkopírované do jednotlivých jazyků včetně jazykově nezávislých sloupců. Dá se to automaticky zařídit např. triggerem, ale i tak zůstane nevýhoda duplicitního uložení dat. Z problémů předchozího řešení zůstane nemožnost efektivního třídění se zohledněním rozdílů mezi jazyky a neefektivita fulltextového vyhledávání. Navíc se zkomplikuje přidání dalšího jazyka – kromě uložení do tabulky jazyků musíme rozkopírovat všechna data v jazykově citlivých tabulkách nebo všechny dotazy zkomplikovat nouzovým použitím anglické verze:
"SELECT vyrobky_en.id, IFNULL(vyrobky.nazev, vyrobky_en.nazev) AS nazev, IFNULL(vyrobky.popis, vyrobky_en.popis) AS popis, vyrobky.cena FROM vyrobky vyrobky_en LEFT JOIN vyrobky ON vyrobky_en.id = vyrobky.id AND vyrobky.jazyk = '$jazyk' WHERE vyrobky_en.jazyk = 'en' AND vyrobky_en.skupina = $skupina ORDER BY IFNULL(vyrobky.nazev, vyrobky_en.nazev) COLLATE $porovnavani"
Překlad přímo v tabulce s daty
Další možností je připravit v tabulce pro každou přeložitelnou hodnotu tolik sloupců, kolik máme jazyků. Z návrhu je patrné, že přidání jazyka nebude právě jednoduché a bude při něm potřeba změnit strukturu tabulek. Nicméně pokud se s tím smíříme, získáme systém, ve kterém se dotazy pokládají elegantně a vyhodnocují efektivně. Např. dotaz pro výpis výrobků z dané skupiny je v podstatě stejně jednoduchý jako bez jazykových verzí:
"SELECT id, nazev_$jazyk, popis_$jazyk, cena FROM vyrobky WHERE skupina = $skupina ORDER BY nazev_$jazyk"
Tento dotaz je nejen elegantní, ale také efektivní – při správném nastavení způsobu porovnávání přímo v definici tabulky a existenci indexu (skupina, nazev_$jazyk)
databáze provede vše stejně rychle jako v jednojazykové verzi. Totéž platí pro fulltextové vyhledávání.
Nepřeložené texty je vhodné řešit zkopírováním výchozí jazykové verze, protože použití hodnoty NULL by dotazy zkomplikovalo a zneefektivnilo. Navíc by s hodnotou NULL neplnily svou roli unikátní indexy (příkladem překládaných dat, nad kterými by měl existovat unikátní index, je URL výrobku). Pokud bychom potřebovali evidovat seznam nepřeložených dat (aby překladatelé věděli, co mají překládat), je vhodné ho udržovat v samostatné tabulce.
Zbývá vyřešit problém, jakým lze do existující struktury přidat nový jazyk. Postarat se o to může samozřejmě skript:
<?php /** Přidání jazykových sloupců do všech tabulek * @param string identifikátor jazyka * @param string způsob řazení textů * @return null provedení příkazů ALTER TABLE a UPDATE * @copyright Jakub Vrána, https://php.vrana.cz/ */ function add_language($lang, $collation = "") { $result = mysql_query("SHOW TABLES"); while ($row = mysql_fetch_row($result)) { // přidání a zkopírování sloupců $alter = array(); $update = array(); $result1 = mysql_query("SHOW COLUMNS FROM $row[0]"); while ($row1 = mysql_fetch_assoc($result1)) { if (substr($row1["Field"], -3) == "_en") { $field = substr($row1["Field"], 0, -2) . $lang; $null = ($row1["Null"] == "NO" ? " NOT NULL" : ""); $alter[] = "ADD $field $row1[Type]" . ($collation ? " COLLATE $collation" : "") . "$null AFTER $row1[Field]"; $update[] = "$field = $row1[Field]"; } } mysql_free_result($result1); if ($alter) { mysql_query("ALTER TABLE $row[0] " . implode(", ", $alter)); mysql_query("UPDATE $row[0] SET " . implode(", ", $update)); // přidání indexů $indexes = array(); $result1 = mysql_query("SHOW INDEXES FROM $row[0]"); while ($row1 = mysql_fetch_assoc($result1)) { $type = ($row1["Index_type"] == "FULLTEXT" ? "FULLTEXT" : ($row1["Non_unique"] ? "INDEX" : "UNIQUE")); $indexes[$type][$row1["Key_name"]] .= "$row1[Column_name], "; } mysql_free_result($result1); foreach ($indexes as $type => $type_indexes) { foreach ($type_indexes as $index) { if (preg_match('~_en, ~', $index)) { mysql_query("ALTER TABLE $row[0] ADD $type (" . substr(preg_replace('~_en, ~', "_$lang, ", $index), 0, -2) . ")"); } } } } } mysql_free_result($result); } ?>
Skript se automaticky postará o vytvoření nových sloupců, zkopírování nepřeložených dat a přidání indexů. Jeho slabinou je potřeba oprávnění ALTER.
Závěr
Leckdo se domnívá, že na první pohled čistá struktura jazykových dat je pro aplikaci ta správná. Bohužel je to pouze teorie, protože v praxi je mnohem důležitější, aby se dotazy snadno pokládaly a rychle vyhodnocovaly a aby správně fungovalo řazení dat i fulltextové vyhledávání. To vše je samozřejmě mnohem důležitější než potřeba snadného přidání nového jazyka, ke kterému za dobu životnosti aplikace dojde obvykle jen párkrát. Při bližším ohledání se navíc ukazuje, že ani s tou čistotou návrhu to není tak slavné, protože se data různého datového typu ukládají do stejného sloupce.
Podrobnější anglická verze tohoto článku vyšla v časopise php|architect – duben 2009.
Přijďte si o tomto tématu popovídat na školení Návrh a používání MySQL databáze.
Diskuse
Jakub Hejda:
Dobrý článek. Jen vyšel na špatném serveru :-))
->
Microsoft OLE DB Provider for SQL Server error '80040e14'
Incorrect syntax near the keyword 'order'.
/__services/__include/functions_discussion.asp, line 254

Jakub Vrána
:
V jaké situaci k tomu došlo? Můžeš to prosím nahlásit přímo redakci Intervalu?


RoW:
Stačí kliknúť na možnosť zobraziť všetky príspevky z diskusie a ten error na teba vybafned hned :-))
Čuga:
Když otrocky vyklikate vsechny a date precist oznacene, tak vam to vypise :)

OndroNR:
to sa mi len zda alebo je aj tu v HTML? :)
Jakub Vrána
:
Je i tady, dobrý postřeh. Časem zde zveřejním i plnou verzi, ale nechci tříštit diskusi.


OndroNR:
+ dobra pamat :)vlkodlak:
nechci byt stoural .... uz prisel ten cas? zverijnis slibene?
Jakub Vrána
:
Vždyť tu článek je...


Čuga:
Clanek je, ale odkaz haze 404...

Jakub Vrána
:
Je to tak. Z Intervalu se stal blog a přechod na něj byl dost odfláknutý. Nepřevedené komentáře a nepřesměrované RSS zjevně nebyly jediné problémy. Redakci jsem informoval.


Čuga:
sice sem nemyslel interval ale ten php|architect, ale interval taky neni... tez dle meho ten prechod nepodchytili...

Jakub Vrána
:
Nojo, taky nepořádníci. Odkaz jsem opravil.


Jakub Vrána
:
Překlady statických textů lze ukládat do tabulky se strukturou (
id int NOT NULL AUTO_INCREMENT,
preklad_en text NOT NULL,
preklad_cs text,
UNIQUE (preklad_en(100)),
PRIMARY KEY (id)
).


Martin:
A co rikas na pouziti oddelenych celych databazi? Neboli co mutace to databaze ... pri prepnuti na jiny jazyk se pripoji na jinou db.
Jakub Vrána
:
Je tady opět problém se synchronizací dat, které mají být ve všech jazycích stejná. Tato data se jednak ukládají opakovaně a jednak může dojít k jejich nekonzistenci.


pr0gr4mm3r:
A jak se potom dá jednoduše vyřešit vyhledávání (FULLTEXT) ve všech jazykových verzích? Budu muset pokaždé vypsat do dotazu všechny sloupce s jazykem, že? V tomto je výhoda možnosti 1 (lokalizace umístěné ve speciální tabulce).mmm111:
Zdravím, mám prosím dotaz na řešení, které v článku doporučujete a to "Překlad přímo v tabulce s daty". Lámu si hlavu nad tím, co bych měl pro větší multijazyčný projekt použít, protože jsem někde četl, že rapidně klesá výkonnost operace s tabulkou, pokud má tabulka hodně sloupců. Máte nějakou zkušenost s klesajícím výkonem?Petr Hamtil:
Jak velký vliv bude mít 10 nebo 100 sloupců na pokles výkonu netuším, asi nějaký logicky bude.Chce si to udělat pár testů.
Ale přemýšlím nad možností místo duplikace sloupců rozdělit jazykové mutace do více tabulek a při selectu udělat left join jen na tu kterou potřebuji:
vyrobky (obecně)
vyrobky_cs
vyrobky_en
vyrobky_fr
SELECT *
FROM vyrobky v
LEFT JOIN vyrobky_$lang vl ON bl.id = v.id
ORDER BY vl.nazev
V doporučené variantně "Překlad přímo v tabulce s daty" se stejně doporučuje zkopírovat výchozí jazykovou mutaci do ostatních.
Prosím o feedback, zda toto řešení má mouchy a jaké?
Děkuji.
Petr Hamtil:
Nalezl jsem odpověď na prakticky stejný dotaz v příspěvku u anglické verze článku:Jakub Vrána: Ano, je to kompromis mezi druhým a třetím přístupem. Odstraňuje problémy
- slow or inaccurate sorting
- slow fulltext search
a naopak přidává
- variable database structure
- complex language addition (i ve variantě Fallback to default language)
Diskuse je zrušena z důvodu spamu.

