Servování souborů z MongoDB GridFS

V mnohých projektech používáme bezschémovou databázi MongoDB. Tato databáze poskytuje onu flexibilitu, kterou jsme v těchto projektech potřebovali, neboť mnoho záležitostí ohledně reprezentace dat v databázi se rýsovalo až v průběhu projektů. Kromě toho, že MongoDB umožňuje elegantně mapovat JSON (resp. BSON) objekty do Python dictionary pomocí bindingu pymongo, umožňuje také pomocí modulu gridfs přistupovat k objektům uloženým v databázi pomocí GridFS (specifikace, jak do MongoDB ukládat velká, binární data). V tomto zápisku se podíváme na to, jak tyto soubory z databáze odbavit pomocí HTTP serveru napsaného v Pythonu.

GridFS

GridFS samotné je specifikace, jak do MongoDB ukládat binární soubory. Tyto souboru jsou rozdělené do chunků, které jsou uloženy samostatně. Díky tomu je možné při čtení v souborech přistupovat pouze k jejich částem, což se u opravdu velkých souborů velice hodí. Chunky mají defaultní velikost 255kB. Pokud budeme předpokládat, že chceme přistupovat k souborům uloženým v DB v bucketu fs, GridFS vytvoří dvě MongoDB kolekce: fs.filesfs.chunksZatímco ta první udržuje metadata souborů, druhá sdružuje samostatné chunky. Výhodou je, že kromě standardních metadat, jako je délka, datum a čas uložení nebo MIME typ je možné použít i libovolná vlastní metadata uložená v BSON dokumentu a dále nad těmito metadaty (a potažmo souboru) provádět databázové dotazy.

Ke GridFS se v Pythonu přistupuje pomocí modulu gridfs, který poskytuje file-like interface nad objekty v databázi. Pro většinu standardní knihovny Pythonu se pak GridFS soubory tváří jako standardní soubory ve filesystému, mají např. metody read(), close(), seek() apod.

Cílem dnešního povídání bude ukázat jak na to, když budeme chtít GridFS soubory servovat pomocí HTTP klientské aplikaci.

Ne-python řešení

Kromě Python řešení je možné použít i řadu ne-python obdob, např. zde, nebo zde. V principu se buď jedná o mapování GridFS do filesystému pomocí FUSE, nebo o modul do webového serveru (ať již Apache nebo nginx).

Bohužel, žádný z těchto modulů do webového serveru není v Debianu. Proto rozšíříme existující HTTP server napsaný v Pythonu o obsluhu GridFS souborů.

Python řešení — web2py

Jak na streamování GridFS souborů ve web2py jsem se již zmiňoval v tomto zápisku na abclinuxu.cz:

Protože GridFS soubor je v Pythonu file-like objekt, je možné jej jednoduše odstreamovat přímo z web2py. V modelu si udělám připojení k databázi a instanci GridFS:

db = MongoClient(settings.db_uri).stitky123
fs = gridfs.GridFS(db, 'files')

V controlleru pak:

def download():
    id = request.args[0]
 
    id = uuid.UUID(id)
    db_fr = fs.get(id)
 
    response.headers['Content-Type'] = db_fr.content_type
 
    return response.stream(db_fr, 1024)

Python řešení — Tornado

Projekt, pro který jsem potřeboval servovat GridFS soubory je však postaven na webovém serveru Tornadu. Tornado pracuje v asynchronním non-blocking I/O módu, díky čemuž je velice rychlé.

Propojení GridFS s Tornadem je pak jednoduché, v principu stačí naimplementovat vlastní GET handler, který na základě cesty získá GridFS soubor a ten postupně odservuje. Nicméně kvůli servování audio a video obsahu — a tedy kvůli podpoře Accept-Ranges requestů — jsem šel jinou cestou. Tornado již obsahuje StaticFileHandler s podporou těchto požadavků, kdy se neposílá celý soubor, ale pouze jeho část specifikovaná rozsahem bytů.

Celá práce tedy spočívala v oddědění nového handleru od StaticFileHandleru a reimplementace metod tak, že pomocí gridfs simulujeme přístup k souborům ve filesystému.

Implementace

Nejprve nějaké importy:

from tornado.ioloop import IOLoop
from tornado.httpserver import HTTPServer
from tornado.web import StaticFileHandler, HTTPError, Application
from pymongo import MongoClient
import gridfs
from bson.objectid import ObjectId, InvalidId

Nyní oddědíme nový handler a předefinuje chování:

class GridFSHandler(StaticFileHandler):
    def initialize(self, fs):
        self.root = ''
        self.fs = fs
        self.fr = None

    def get_version(self, url_path):
        return None

    def get_content(self, abspath, start=None, end=None):
        if start is not None:
            self.fr.seek(start)
        if end is not None:
            remaining = end - (start or 0)
        else:
            remaining = None
        while True:
            chunk_size = self.fr.chunk_size
            if remaining is not None and remaining < chunk_size:
                chunk_size = remaining
            chunk = self.fr.read(chunk_size)
            if chunk:
                if remaining is not None:
                    remaining -= len(chunk)
                yield chunk
            else:
                if remaining is not None:
                    assert remaining == 0
                return

    def get_content_type(self):
        return self.fr.content_type

    def get_content_size(self):
        return self.fr.length

    def get_modified_time(self):
        return self.fr.upload_date

    def get_absolute_path(self, root, path):
        try:
            return ObjectId(path)
        except InvalidId:
            raise HTTPError(404)

    def validate_absolute_path(self, root, absolute_path):
        #print self.request.get_ssl_certificate()['subject']
        try:
            self.fr = self.fs.get(absolute_path)
        except gridfs.NoFile:
            raise HTTPError(404)
        return absolute_path
    
    def get_content_version(self, abspath):
        return abspath

    def compute_etag(self):
        return self.fr.md5

Poznámky k implementaci:

  • Při inicializaci se předá jako jediný parametr GridFS objekt, který ukazuje na bucket, ve kterém jsou uloženy soubory.
  • Metadata content_type, content_size a modified_time jsou získána přímo z MongoDB objektu v kolekci files.
  • V metodě get_absolute_path() se namísto vrácení absolutní cesty vrátí ObjectId, pod kterým se pak v GridFS hledá soubor.
  • V metodě validate_absolute_path() se GridFS soubor otevře (na základě ObjectId), pokud neexistuje vrátí se 404.
  • Jako ETag se pro soubor použije jeho MD5 součet uložený v databázi.
  • Metoda get_content() je pouze modifikací původní metody ve StaticFileHandleru.
  • GridFS soubor otevřený pro čtení není nutné uzavírat, implementace GridOut.close() v modulu gridfs je prázdná.

Zbytek kódu pro spuštění HTTP serveru:

if __name__ == "__main__":
    db = MongoClient('mongodb://localhost/db').get_default_database()
    fs = gridfs.GridFS(db, 'data')

    application = Application([
        (r"/data/(.*)", GridFSHandler, {'fs': fs}),
    ])

    server = HTTPServer(application)
    server.listen(8888)
    IOLoop.instance().start()

Závěr

Nejprve do MongoDB GridFS bucketu s názvem data nahrajeme nějaký soubor (jak na to čtěte zde). Nyní můžeme tento soubor stáhnout z URI http://localhost:8888/data/53f64dba1608c0780e7dcaad — 8888 je port, na kterém poslouchá vytvoření server a 53f64dba1608c0780e7dcaad je ObjectId() GridFS souboru.

Výše uvedený kód, přestože je velice jednoduchý, umožňuje přímočaré servování GridFS souborů z MongoDB. Tuto implementaci lze dále rozšiřovat, například doplnit ACL pro jednotlivé uživatel apod. Fantazii se meze nekladou.

Napsat komentář