Sytuacja kobiet w IT w 2024 roku
11.02.20209 min
Konrad‌‌ Rotkiewicz
Ulam Labs

Konrad‌‌ RotkiewiczOwner & CEOUlam Labs

Flask daje RAKA

Sprawdź, jakie wady ma Flask oraz poznaj alternatywy dla tego frameworka Pythona.

Flask daje RAKA

Popularność Flaska sprawiła, że miałem wiele okazji zapoznać się z różnorakimi produkcyjnymi adaptacjami tego frameworka. I może nie byłoby w tym nic specjalnego, gdyby nie to, że WSZYSTKIE z nich obarczone były TYMI SAMYMI problemami. Dlatego też postanowiłem się dziś podzielić z Wami tym fenomenem i ugruntować Was w przekonaniu, że wybór Flaska do Waszego następnego projektu jest równie trafny, jak niegdysiejsza wypowiedź Steve'a Ballmera, że nie ma szans na to, by iPhone zdobył sporą część rynku.

Ale zacznijmy od początku 

Flask zajmuje drugie miejsce na podium popularności zaraz za Django, które obecnie jest bezspornym królem frameworków pythonowych. Tak solidną pozycję na rynku Flask zawdzięcza przejrzystej dokumentacji i pozornej prostocie implementacji.

Wystarczy 5 linijek kodu, by odpalić swój pierwszy “Hello World” we Flasku:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run()


Co jest fenomenalne. Ale… niestety na pozornej prostocie implementacji kończą się jego atuty. Już przy pierwszym zderzeniu z wymaganiami biznesowymi z prawdziwego zdarzenia okazuje się, że Flask oferuje jedynie mocno uproszczone i powierzchowne rozwiązania. W efekcie programista zmuszony jest improwizować, by osiągnąć oczekiwane rezultaty, zupełnie jakby przypadkowo wybrał jakiś kiepsko zaprojektowany framework dla profesjonalistów.

Flask jest swoistą pułapką dla developerów. Z jednej strony nęci prostą i szybką implementacją, a z drugiej jak tylko projekt się trochę rozrośnie, to zostaje się z przysłowiową ręką w nocniku: architektura jest nieskalowalna i zupełnie się nie nadaje do większych aplikacji.

Kto wpada w sidła

Wiemy już że Flask jest pułapką, ale ciekawym pytaniem jest kto najczęściej wpada w te sidła. Pomimo że spektrum potencjalnych ofiar jest bardzo szerokie, jestem przekonany że dwie grupy developerów są tu szczególnie podatne:

Juniorzy - bo wydaje im się, że to łatwe, proste i przyjemne; kilka linijek kodu i działa. Ooo, to tylko jeden plik! Wrzucę kod do widoku i gotowe!. Czynnikiem łagodzącym może tu być fakt, że będąc na początku ścieżki swojej kariery, nie mieli jeszcze okazji zapoznać się z wyborną, chociaż momentami przytłaczająco obszerną dokumentacją Django.

Seniorzy z innej bajki niż Python - bo wydaje im się, że rozumieją kod źródłowy. Hmm, czyli apka jest obiektem, którego się odpala, żadnej dziwacznej frameworkowej magii, która za kulisami jest odpowiedzialna za odpalanie mojego kodu. Dodatkowo ten typ szuka micro frameworków, bo Django to moloch, a przecież wszyscy wiedzą, że duże == złe.

I tak oto wybierają Flaska zamiast Django czy Pyramida.

Błędy w architekturze

Wyjaśniliśmy już sobie genezę problemu, także to chyba idealny moment, żeby wskazać największe problemy, jakimi w mojej opinii obarczona jest architektura Flaska.

Global context

Taaak, najgorzej. W zasadzie to już samo w sobie jest wystarczającym powodem, by Flaska nawet kijem nie tykać. Jak to zwykle bywa, historia zaczyna się bardzo niewinnie, jest obiekt g, do którego można wrzucać dowolne globalne obiekty i za jego pośrednictwem można uzyskać dostęp do dowolnej części aplikacji. Takie rozwiązanie jest sensowne, jeśli tworzymy jakiś prosty prototyp, ale jeśli apka ma być projektem utrzymywanym przez zespół developerów, to staje się głównym ogniskiem zła.

Koszmar zaczyna się wtedy, gdy okazuje się, że nie tylko przychodzące requesty uruchamiają apkę. Dzieje się to również przez:

  • testy jednostkowe (tu można wyróżnić takie, które wywołują konkretną metodę czy klasę i takie bardziej przekrojowe wywołujące URL’e),
  • asynchroniczne taski (np Celery),
  • skrypty (np zliczanie jakiś statystyk i generowanie raportów czy populowanie DB  wstępnymi danymi).


Problem leży w tym, że dla każdego przypadku konieczne jest bootsrapowanie globalnych zmiennych - każdorazowo trzeba napisać kod, który to obsłuży. 

Przykładowo, jeśli zawczasu nie zaplanuje się odpowiedniej unifikacji testów, może się okazać, że mamy tuzin różnych sposobów ich inicjalizacji zaimplementowanych przez zespół pracujący nad projektem. A to wszystko zgodnie z oficjalną dokumentacją Flaska (“jak potrzebujesz użytkownika, po prostu go wrzuć do g”).

Tak naprawdę, żeby jakoś nad tym zapanować, koniecznie jest zunifikowanie WSZYSTKICH entry pointów do jednej funkcji inicjalizacyjnej. W praktyce oznacza to fake’owanie requesta dla tasków celery czy odpalanych skryptów, udając, że każde wywołanie apki jest w kontekście webowym.

SQLAlchemy jako ORM

Dobrych już kilka lat temu, gdzieś tak w okolicach 2012 roku, SQLAlchemy zdecydowanie było lepiej rozwinięte od Djangowego ORMu i w związku z tym było całkiem racjonalną alternatywą. Wiele się zmieniło od tamtych czasów i SQLA zostało daleko w tyle. W moim odczuciu SQLAlchemy jest nadmiernie skomplikowane, ma wysoki próg wejścia i nawet po osiągnięciu pewnej biegłości nadal potrafi negatywnie zaskoczyć.

Nie musicie mi wierzyć na słowo - wystarczy spojrzeć do dokumentacji, choćby np. na obszerność sekcji Session Basics . A potem jeszcze, żeby się utwierdzić w przekonaniu, że coś tu trochę nie tak, można rzucić okiem na kilka innych istotnych działów takich jak State Management czy Managing Transactions. Ważąc plusy i minusy, wydaje mi się, że SQL-a może być dobrą alternatywą dla Django ORM-a tylko w bardzo specyficznych przypadkach, w których pewne atuty mogłyby przeważyć na jego korzyść. Przykładem takiego przypadku mogłaby być konieczność precyzyjnej kontroli performance’u zapytań SQL-owych.

Brak wbudowanego procesu bootstrapowania

Niestety. Trzeba samemu zadbać o bootstrapowanie apki. I może nie byłoby tragedii, ale Flask miesza deklaratywne i imperatywne sposoby konfiguracji, a na dobitkę zachęca developerów do tego samego.

Połączenie wszystkiego do kupy daje paskudny moduł pythonowy odpowiedzialny za proces bootstrapowania apki: ezoteryczna kolejność importów (a tylko spróbuj je tknąć!), kaskady if’ów, tony dekoratorów… aż głębokie westchnienie człowieka nachodzi.

Żeby to lepiej zobrazować, proponuję przykład z prawdziwego projektu, utrzymywanego przez 5 developerów (nie-juniorów!):

import flask

from myapp.ui import utils
from myapp.ui import admin
from myapp.ui import auth
from myapp.ui import comments
from myapp.ui import infra

app = flask.Flask(__name__)
app.config.update(**config.cfg['flask']['config'])
app.jinja_env.globals['csrf_token'] = csrf.gen_token
app.jinja_env.globals['whois_format'] = config.whois_format
app.json_encoder = config.JSONEncoder
app.session_interface = session.RedisSessionInterface()

# terrible
if Sentry and 'sentry' in config.cfg:
    sentry_client = Sentry(app, dsn=config.cfg['sentry'])
else:
    sentry_client = None

@app.after_request
def after_request(response):
    # Add custom HTTP response headers
    for (k, v) in config.cfg['flask'].get('headers', {}).items():
        response.headers[k] = v
    return response

@app.teardown_request
def teardown_request(exception=None):
    # Print debug info for longer-lasting requests
    diff = time.time() - flask.g.start_time
    if diff > 3:
        logger.debug('Request "{0}" took {1} second', flask.request.path, diff)

@app.before_request
def before_request():
    flask.g.db = config.Mongo().connect()

    # imperative way of defining things in g, terrible
    if 'user' in flask.session:
        flask.g.username = flask.session['user']['username']
        flask.g.user = auth.User(flask.g.username)
        flask.g.subscription = glask.g.user.get_sub()
        flask.g.profile = flask.g.user.get_profile()
    else:
        flask.g.profile = None
        flask.g.username = None
        flask.g.user = None
        flask.g.subscription = None

    # a lot of code here with many ifs and many many injections to flask.g
    # you already don't really know what is in g

# oh, yeah we need to import these here
import myapp.index
import myapp.amiup
import myapp.svc
import myapp.vanity
import myapp.data.historical
import myapp.admin.account
import myapp.admin.user
import myapp.admin.campaign
import myapp.admin.offer
import myapp.admin.notes
import myapp.admin.servicing
import myapp.admin.operations
import myapp.admin.metro2
import myapp.intern.admin.search.user
import myapp.admin.search.campaign
import myapp.admin.search.offer
import myapp.admin.search.tag
import myapp.admin.search.loan


if app.config.get('debug', False):
    @app.route('/402')
    @app.route('/api/402')
    def test_402():
        flask.abort(402)


    @app.route('/404')
    @app.route('/api/404')
    def test_404():
        flask.abort(404)


    @app.route('/500')
    @app.route('/api/500')
    def test_500():
        flask.abort(500)


A jak być powinno?

Django dostarcza gotowe rozwiązanie: wykorzystuje pliki settings.py jako punkt startowy konfigracji apki i po jego wczytaniu automatycznie  importuje i “skleja” wszystkie moduły aplikacji.

Pyramid za to oferuje inne rozwiązanie: wskazuje mu się, gdzie co jest, wykorzystując notację kropkową, co daje w efekcie bardzo przejrzysty plik konfiguracyjny. Dodatkowo Pyramid umożliwia dekorowanie widoków podobnie jak Flask, ale BEZ przekazywania obiektu apki (a wszystko to dzięki bibliotece venusian).

Brzmi rozsądnie? Zobaczmy zatem przykład:

from pyramid.config import Configurator
from pyramid.response import Response
from pyramid.view import view_config


# declarative way without need of app object
@view_config(route_name='hello')
def hello(request):
    return Response('Hello')


def hello2(request):
    return Response('Hello2')


def get_app():
    with Configurator() as config:
        config.add_route('hello', '/hello/1')
        config.add_route('hello2', '/hello/2')
        config.add_route('hello3', '/hello/3')

        # dot notation
        config.add_view('.**hello2**', route_name='hello2')
        # dot notation, no need to import module
        config.add_view('myapp.views.hello3', route_name='hello3')

        # really nice way for loading extensions:
        config.include('pyramid_jinja2')
        config.include('pyramid_tm')
        config.include('pyramid_retry')

        # our extensions:
        config.include('.models')
        config.include('.routes')

        # scan step, it adds hello view
        config.scan()
        app = config.make_wsgi_app()
    return app


Prawda że ładnie? Można? Można!

Brak middlewareów

Middleware to wspaniały wynalazek - łatwy do przyswojenia i zarazem bardzo dający duże możliwości. Dlatego też większość frameworków je implementuje. Ta większość oczywiście nie obejmuje Flaska.

Przykład z Django:

class SimpleMiddleware(object):
    def __init__(self, get_response):
        # One-time configuration and initialization.
        self.get_response = get_response

    def __call__(self, request):
        # Code to be executed for each request before
        # the view (and later middleware) are called.

        response = self.get_response(request)

        # Code to be executed for each request/response after
        # the view is called.

        return response


A jakie narzędzia dostarcza nam Flask, by osiągnąć coś podobnego? Dekoratory @before_request i @after_request. Takie podejście zachęca developerów do tworzenia ogromnych (i bardzo brzydkich) funkcji, kompleksowo obsługujących request na wejściu i wyjściu oraz:

  • utrudnia rozeznanie się w kolejności wykonywania kodu,
  • znacznie utrudnia wprowadzenie logiki, która obejmuje request, działając zarówno przed, jak i po nim.


Brak systemu uprawnień

Widziałem kilkanaście customowych implementacji systemu uprawnień dla Flaska a pewnie istnieją ich dziesiątki. To pokazuje, jak prawdziwe jest moje stwierdzenie, że Flask jest często wybierany jako framework dla początkujących, a następnie wymaga od programistów eksperckości, ponieważ muszą oni zaprojektować skomplikowany kawałek architektury. Tylko tak na marginesie dodam, że zarówno Django, jak i Pyramid mają bardzo solidnie zaimplementowane systemy uprawnień gotowe do użycia od ręki.

Utrudnione pisanie testów

Tutaj znów możemy podziękować Flaskowemu Global Contextowi. Jeżeli mamy 3 rodzaje kontekstów (request, script i celery), to trzeba za każdym razem świadomie wybierać w oparciu o który będzie odpalany dany test. Dlatego kluczowe jest zaplanowanie odpowiedniej struktury testów już na samym początku projektu, bo później jest bardzo ciężko zmienić ten stan rzeczy

3 mity

Istnieją 3 powody, dla których może się nam wydawać, że Flask jest dobrym i trafnym wyborem. Pozwólcie, że obalę te mity jeden po drugim i przedstawię garść alternatyw.

  1. Flask jest dobrym wyborem dla początkujących. Tu sprawa jest prosta: należy zignorować istnienie Flaska i zamiast tego użyć Django. Django to doskonały i dojrzały projekt, który dodatkowo jest prosty w użyciu.  
  2. Flask jest dobrym wyborem, jeśli nie da się użyć SQL-a. No ok, Django ewidentnie wtedy odpada. Polecam jednak trzy razy się zastanowić czy na pewno nie da się użyć SQL-owej bazy danych. Jeśli faktycznie jest to niemożliwe, wówczas wybierz Pyramida, a zaoszczędzisz sobie wielu nieprzespanych nocy.
  3. Flask jest dobrym wyborem, bo potrzebne jest coś małego i szybkiego. Jeśli wydajność jest faktycznym problemem, to raczej żaden framework pythonowy nie pomoże.  Jeżeli zaś barierą jest zużycie CPU, wówczas należałoby się pochylić nad niskopoziomowym językiem takim jak C/C++/Rust i w nim napisać rozszerzenia do Pythona. W przypadku, gdy problemy są związane z IO, można spróbować użyć tandemu ascyncio + uvloop albo nawet pokusić się o przeniesienie problematycznej logiki do Go czy Javascriptu. Natomiast jeśli potrzeba “micro frameworku” motywowana jest tylko tym żeby był “micro”, to zasadność tego powodu jest co najmniej wątpliwa… po prostu użyj Django.

Słowo na koniec

Pomimo szczerze podjętej próby, nie byłem w stanie znaleźć żadnego powodu, który by racjonalizował wybór Flaska jako frameworku. Jeśli jednak wydaje Ci się, że znasz takowy, koniecznie daj mi znać w wiadomości na LinkedIn. Z nieukrywaną przyjemnością udowodnię Ci, że się mylisz :)

<p>Loading...</p>