Následující článek bude skriptování pohledů v NoSQL databázi CouchDB pomocí jazyka C++. Pokusím se představit nástroj, který jsem k tomuto účelu vytvořil včetně popisu implementace.
Při vymýšlení titulku jsem si představil šokované oči odmítačů jazyka C++ při představě, že by se v něm dalo „skriptovat“. Ale proč ne? Motivací bylo více. Vedle příslibu vyšší rychlosti při zpracování pohledů také i skutečnost, že jazyku C++ nejvíce rozumím, je mi příjemné v něm pracovat a také mohu sdílet některé části kódu mezi klientem a skripty nahrané na serveru (serverem myslím zejména databázi CouchDB). Výhodou je také syntaktická kontrola „skriptu“ před nahráním na serveru což díky silnému typování odhalí většinu chyb a překlepů ve skriptech ještě během vývoje.
Abych některé nedráždil, budu nadále používat slovo „fragment“ nebo „fragment kódu“
Na představení CouchDB nemám prostor, proto bych zájemce odkázal na pěkný serial na Zdrojáku. NoSQL databáze CouchDB je autory prezentována jako databáze, ve které se indexace záznamů řídí JavaScriptem. Propojení CouchDB a JavaScriptu je tak zažité, že drtivá většina projektů dostupných z vyhledávače jsou též napsané v JavaScriptu. To celé nakonec dovršuje projekt PouchDB, který je vlastně implementací celé CouchDB v JavaScript (nejde jen o klienta, opravdu dělá vše co CouchDB) a je určen hlavně pro prohlížeče.
Samotná CouchDB je naprogramovaná v jazyce Erlang. Méně známa je skutečnost, že Javascript je sice vestavěný interpretr, ale nikoliv nativní. Indexaci v CouchDB lze řídit samotným Erlangem. Takto psané skripty přitom dosahují maximálního výkonu. Mají však bezpečnostní rizika daná právě tím, že Erlangový skript je úzce spjat s databází a skripty, kterou jsou součástí dat se v něm vykonávají, aniž by byly odděleny v nějakém sandboxu. Z tohoto důvodu je ve výchozím stavu Erlang, jako skriptovací jazyk, zakázán.
Zůstaňme u vestavěného Javascriptu. To poodhalí mechanismus, jakým lze do CouchDB integrovat další jazyky. CouchDB nabízí otevřené rozhraní, které se nazývá „query server protocol“ a jeho popis lze snadno dohledat v dokumentaci. Komponentě, která pak představuje protistranu k databázi, se říká „query server“. V rámci query serveru se realizuje mapování dokumentů (funkce map), operace reduce a rereduce, a samozřejmě neméně známé funkce show, list, update, validate a filter (více zde). Databáze komunikuje se serverem prostřednictvím obyčejné roury, samotný query server nachází požadavky na standardním vstupu a výsledky odesílá na standardní výstup. Základem komunikačního protokolu je formát JSON.
Vyvinout query server postavený na tomto jednoduchém schématu – pouhé čtení stdin a zápis do stdout, žádné vlákna, žádné porty, žádná synchronizace, žádné hlavičkové soubory a knihovny – zvládne každý průměrný programátor za pár hodin. Může si zvolit libovolný jazyk, může to psát třeba Perlu, nebo v Pythonu, v Php, v Javě nebo i v C++. Jediným požadavkem je, aby query server byl samostatně spustitelný proces, který pracuje se standardním vstupem a výstupem. Před vznikem tohoto projektu jsem si několik takových serverů napsal, zpravidla však byly jednoúčelné, určené pro konkrétní aplikace, a všechny operace uvnitř byly psané v C++. Kompletní sadu nástrojů pro práci s CouchDB z C++, včetně možností vyvinout si vlastní query server jsem pak zahrnul do knihovny couchit (stále ve vývoji)
U těchto jednoúčelových query serverů však nebyl kód v design dokumentu, jak je obvyklé, ale byl „zapečen“ přímo v query serveru a v design dokumentech se nacházel pouze odkaz na patřičný „zapečený“ fragment kódu. Mnohem větší výzvou tedy bylo, pokud by šlo do design dokumentu umístit C++ kód přímo. Mělo by to řadu výhod. Asi největším problémem jednoúčelových query serverů byla nutnost aktualizace binárek na všech uzlech kam se databáze replikovala a to při každé změně některého pohledu. Obecný query server by se aktualizovat nemusel, řízen by byl přímo design dokumenty, které se do každého uzlu replikují samy, tedy tak, jak se přirozeně děje u jiných skriptovacích jazyků
Projekt couchcpp je plnohodnotná implementace query serveru přijímající fragmenty psané v jazyce C++, kterými se pak zpracovávají data, jež serverem protečou. Podporuje všechny známé funkce, které design dokumenty nabízí až do verze 1.6, tedy nepodporuje nově zavedenou funkci „rewrite“ od verze 2.0 (ale to je jen otázkou času). Kódové fragmenty se píšou jako funkce s daným jménem a parametry, ale mohou volitelně obsahovat vložené hlavičkové soubory a v rámci fragmentu lze i ovlivnit, jaké knihovny mají být při spuštění fragmentu k dispozici.
#include <cstring> //standardní knihovna
#include <moje/custom/knihovna.h> //jiná knihovna
//direktiva pro linker
//!link -lmylib
using namespace std; //import namespace
void mapdoc(Document doc) {
emit(doc[“_id”],doc[“_rev”]);
}
Příklad ukazuje obecný formát fragmentu, přičemž vložené hlavičky a importy namespace jsou volitelné. Povinná je vlastní funkce. Příklad realizuje operaci „map“ (a aby nebyla v kolizi se std::map, je záměrně pojmenovaná jako „mapdoc“). Jako parametr přijímá typ Document, což je předdefinovaný typ vhodný pro přístup k datům dokumentu.
Další funkce mají tyto prototypy
void mapdoc(Document doc);
Value reduce(RowSet rows);
Value rereduce(Value values);
void show(Document doc, Value request);
void list(Value queryhead, Value request);
void update(Document &doc, Value request);
bool filter(Document doc, Value request);
ValidateResult validate(Document doc, Context context);
Mnoho hodnot je předáváno jako JSON objekt a to včetně typu Document, což je jen trochu vylepšený JSON objekt. JSON objekty jsou reprezentovány typem Value, který je importován z knihovny imtjson, kterou jsem představil v minulém příspěvku.
Uvnitř kódového fragmentu jsou pak k dispozici následující funkce (podle kontextu):
void emit(Value key, Value value); //funkce mapdoc() ListRow getRow(); //funkce list() void start(Value heades, int status); //funkce show(), list(), update() void send(StrViewA text); //funkce show(), list(), update() void log(StrViewA text); //všude
Význam těchto funkcí je shodný s tím, co nabízí rozhraní Javascriptu. Předsto má rozhraní ale jisté odlišnosti
void update(Document &doc, Value request) { doc = doc.replace(„counter“,doc[„counter“].getUInt()+1); }
Výše uvedený příklad zvýší o jedničku pole „counter“. Funkce doc.replace nahradí hodnotu v patřičném poli a vrací nový dokument. Zápisem do proměnné doc se zajistí změna dokumentu v databázi.
V Javascriptu lze využít klíč „lib“ k importu knihoven CommonJS modulů. Ty se pak dají volat ve všech funkcí, kromě „reduce“. V couchcpp lze klíč „lib“ velice podobně použít k importu sdíleného kódu.
{ “views”: { “lib”: { “vendors”:{ “test”:{ “hello”:”std::string hello() {return \”Hello world!\”;}”, "magic":"const int response = 42;" } } } } }
Tento sdílený kód lze pak do ostatních fragmentů vložit takto: (vyjma reduce)
#include “vendors/test/hello”
#include “vendors/test/magic”
(Kvůli chybě nelze sdílený kód používat ve verzi CouchDB 2.0, protože v jednom okamžiku tam autoři zapomínají sdílený kód připojit k fragmentu a query server následně odmítne fragment přeložit, protože sdílený kód nenajde. Chyba už byla nahlášena, případný pull request už je na cestě).
Určitě někoho bude zajímat, jakým způsobem se C++ fragmenty kompilují a spouští. Není to nic složitého. Když se podíváte do projektu couchcpp, zjistíte, že se skládá z dvou *.cpp souborů. V jednom (couchcpp.cpp) je funkce main() a mnoho funkcí implementující jednotlivé části query server protocolu. V druhém souboru (module.cpp) je realizováno přeložení a načtení fragmentu do paměti.
Překlad fragmentu se provádí pomocí „g++“. Nicméně, Couchcpp má i konfigurační soubor, kde lze vybrat i jíný překladač a také upravit příkazovou řádku. Výsledkem překladu je dynamická knihovna *.so. Tato knihovna je následně načtena do paměti. Třída Module poskytuje unifikované rozhraní k takto přeloženému fragmenu a interface IProc pak poskytuje přímý přístup k jednotlivých funkcím daného fragmentu (podle typu). Kromě toho umožňuje inicializovat funkce log, emit, start, send a getRow, podle aktuální situace.
CouchDB nijak fragmenty neoznačuje, prostě posílá plain-text daného kódu. Couchcpp z každého fragmentu spočítá hash přes funkci FNV-1a, a prohleda cache již přeložených modulů. Pokud najde modul se stejným hashem, rovnou jej načte do paměti. V případě, že modul neexistuje, je spuštěn překlad fragmentu a výsledný modul je uložen do cache. Protože i samotné načítání fragmentů stojí nějaký výkon, podobná cache ještě spravuje načtené moduly v paměti a jejich uvolnění při delší době nečinnosti.
Jednou z motivací bylo dosažení rychlejší indexace pohledů než třeba u javascriptu. Tady je potřeba dodat, že rychlosti zrovna nepřidá nutnost serializace a deserializace JSONů předávaných rourou mezi CouchDB a query serverem. Touto nevýhodou však trpí jak javaskriptový query server tak Couchcpp. Jak bylo zmíněno výše, Erlangové pohledy toto dělat nemusí, proto mohou být výkonější.
Provedl jsem několik srovnání a vždy byla indexace v C++ rychlejší než u JS. U složitější skriptů i o 100%. Na obrázku je ukázkový skript, který vytváří pohled typů a indexů, který se sestavuje po importu dat z MySQL, kdy je třeba data hledat podle původního automatické číslování. Výsledkem je tedy pohled dvojsloupcových klíčů, kde v prvním sloupci je typ záznamu (v jaké tabulce byl záznam uložen) a v druhém pak jeho tamní ID. V databázi je něco přes 6000 záznamů a skripty jsou puštěny na plný re-index (což je speciální, nejpomalejší případ, kdy se sestavuje celý index projitím všech záznamů)
Javascriptová verze (vlevo) trvala zhruba 2.5 sekundy, zatímco C++verze jen tři čtvrtě druhé sekundy. JS verze je zhruba o 40% pomalejší.
Je třeba říct, že do času C++ verze není započítán čas kompilace fragmentu. I s kompilací fragmentu trvá celý reindex 3.358 sekundy, čili pomaleji, než JS. Nicméně, kompilace není závislá na počtu záznamů a s vyšším počtem záznamů se snižuje její význam. U inkrementální indexace, která je v CouchDB nejčastější se už pracuje se zkopmilovaným fragmentem, takže i tady bude C++ verze rychlejší.
Přítomnost fragmentů C++ v databázi mezi uživatelskými daty moc pocitu bezpečí nepřidá. Jiné skriptovací jazyky se dají omezit tím, že nenabízí funkce, které by mohly poškodit systém. Couchcpp však umožňuje fragmentu použít cokoliv, co je dostupné v systému, přitom již samotná stdlib nabízí funkce pro přístup na filesystem a spouštění procesů.
Dobrou zprávou je, že fragmenty běží v odděleném procesu, takže nemohou přímo ovlivňovat běh databáze. Nicméně ve výchozím nastavení se proces spouští v kontextu stejného uživatele, jako sama databáze, takže process má teoreticky možnost číst i zapisovat do souborů patřící CouchDB. Což je ošklivé. Naštěstí to má snadné řešení. V systému založit uživatele, který má maximálně omezená práva. Tomuto uživateli věnovat process couchcpp a nastavit mu SUID příznak. CouchDB nebude protestovat, že se couchcpp spouští s právy jiného uživatele. Je třeba akorát zajistit, aby takový process měl přístup i do své cache, a víc už nepotřebuje.
Pokud někoho napadne ještě lepší izolace, chroot, fakechroot, LD_PRELOAD napište do komentářů.
Hlavní uplatnění najde couchcpp tam, kde se vyvíjí v C++. Umožní tak sdílet kód mezi klientskou aplikací a fragmenty a není třeba některé algoritmy psát ve dvou jazycích. Při psaní fragmentů používám couchapp, která drží fragmenty v souborech. Couchcpp také nabízí možnost nechat před sestavením design dokumentu fragmenty cvičně zkompilovat a vypsat případné chyby. Při implementaci jsem také počítal se všelijakými IDE pro vývoj. Couchcpp například správně nahlásí místo chyby, tedy přesně soubor a řádek, přestože se při kompilaci fragment vkládá do jiného souboru a kompiluje se na jiném místě.
I API počítá s IDE. Pokud se do fragmentu vloží #include <couchcpp/api.h>, budou správně fungovat kontextoví asistenti a obarvování syntaxe, protože v tomto souboru jsou všechny potřebné definice (byť se pak fragment překládá v jiném kontextu)
Projekt Couchcpp je v zásadě ve fázi bety, je zdarma pod licencí MIT a pro programátory C++ píšící pro CouchDB plně k dispozici. Pokud používáte jiné databáze, doporučuji rozhodně CouchDB dát šanci a pořádně si projít její možnosti. Programovat pohledy v C++ se vám jinde nepoštěstí.
Stránky projektu: https://github.com/ondra-novak/couchcpp
Máte nějaký zdroj k (ne)bezpečnosti erlangovských pohledů? Každý design dokument se indexuje v odděleném procesu.
No píšou to přímo v dokumentaci
http://docs.couchdb.org/en/2.0.0/config/query-servers.html#config-native-query-servers
"""
Due to security restrictions, the Erlang query server is disabled by default.
Unlike the JavaScript query server, the Erlang one does not runs in a sandbox mode. This means that Erlang code has full access to your OS, filesystem and network, which may lead to security issues. While Erlang functions are faster than JavaScript ones, you need to be careful about running them, especially if they were written by someone else.
"""
Nezapočítání času kompilace mi připadá jako zásadní problém. Nevím jaký JS engine couchdb používá, ale spousta js enginů optimalizují také, takže rychlost běhu programu se bude taky zlepšovat pro více záznamů (provede se JIT kompilace a pak už se nic neinterpretuje). Já bych k tomu byl hodně skeptický, dokud neuvidím nějaké komplexnější testy. Zatím mi to spíš připadá jako předčasná optimalizace. Navíc nechápu, proč nepoužít rovnou erlang, když už to autor zmiňuje jako nejlepší možnost z hlediska výkonu.
Dekuji za nazor a za otazky.
Nejprve proc nepouzit Erlang. Tak proc ne. Vyhodou CouchDB je svobodna volba skriptovaciho jazyka. Treba ja Erlang neumim. Veskery kod mam v C++, mohu treba sdilet hlavickove soublory s definicemi struktur. Vykon neni jedinym kriteriem. Pokud mam na vyber mezi pomalejsim JS, bez moznosti sdilet definice a rychlejsi C++ s touto moznosti, pak volim C++. Pokud budu potrebovat surovy vykon, mohu se prekonat a neco v tom erlangu tam spichnout.
Co se tyce zapocitani casu kompilace. CouchDB pouziva spidermonkey. Nevim jak dobry ma JIT, ale rozhodne kompilaty nikam neuklada, takze kazda dalsi instance kompiluje a optimalizuje znovat. Couchcpp uklada kompilaty do cache, kompiluje se jen jednou pro novy skript a pripadne ze nekdo smaze cache nebo vyjde nova verze couchcpp (ktera automaticky cache smaze), jinak se nekompiluje, proto cas kompilace C++ fragmentu nezapocitavam. Vzhledem k tomu, ze pri zmene skriptu se provadi fullindex, je cas kompilace zanedbatelny vuci tomu, jak dlouho muze v realne db trvat reindex.
Pri testech jsem opakovane provadel reindex bez toho abych nejak ovlivnoval oba skriptovaci engine. Zvysovani vykonu u JS casem jsem nepozoroval. Ty cisla byla stale stejna.
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjim vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvýjim serverové komponenty a informační systémy v C++
Přečteno 43 797×
Přečteno 21 382×
Přečteno 15 167×
Přečteno 13 510×
Přečteno 13 288×