Przejdź do treści

Dlaczego, wychowany na PHP, piszę dziś głównie w Pythonie

W pierwszej części cyklu pisałem o PHP2Shell, w drugiej o tym, że o bezpieczeństwie aplikacji częściej decyduje model wdrożenia niż język. Obiecałem, że trzecia część będzie osobista. Jest: to historia tego, jak ktoś, kto całe lata pisał aplikacje internetowe w PHP, zaczął nowe projekty pisać głównie w Pythonie – i dlaczego nie uważam tego za ucieczkę przed złym językiem.

Najpierw był Perl

Tytuł cyklu jest trochę na wyrost, bo zanim wychowało mnie PHP, pisałem w Perlu. Dynamiczna strona oznaczała wtedy skrypt CGI w katalogu cgi-bin, który sam składał nagłówki i sam drukował cały HTML.

PHP w wersji 3 (rok 1998) widziałem i próbowałem, ale przy Perlu zostałem. Przekonało mnie dopiero PHP 4, wydane w maju 2000 roku. Wtedy zrobiłem dużą przeprowadzkę: moje własne serwisy – z matematyka.net na czele – i kilka pomniejszych, a także projekty klientów przeniosłem z Perla na PHP 4.

Dlaczego? Z tego samego powodu, dla którego na PHP zaczynało potem całe pokolenie programistów. Kod PHP wkleja się w HTML, zamiast drukować HTML z kodu. Plik wrzucony przez FTP po prostu działa, bez chmod +x, bez ścieżki do interpretera w pierwszej linii i bez tajemniczego błędu 500, gdy coś pójdzie nie tak. Hosting z PHP był wszędzie i kosztował grosze.

Od czwórki do siódemki

Potem przyszło PHP 5 (lipiec 2004) z prawdziwym modelem obiektowym i wszystko, co pisałem, poszło za nim. Piątka była ze mną najdłużej, bo sama żyła bardzo długo – ostatnia z jej gałęzi, 5.6, straciła wsparcie dopiero pod koniec 2018 roku.

Na siódemce się zatrzymałem. PHP 7.0 wyszło w grudniu 2015 roku, a ostatnia „siódemka”, 7.4, straciła poprawki bezpieczeństwa w listopadzie 2022 roku. Po kilkunastu latach z piątką siedem lat siódemki minęło zaskakująco szybko. Po PHP 8 sięgam już tylko sporadycznie.

Ślady tamtego etapu zostały zresztą na blogu: cykl o Symfony 6, role użytkowników w Laravelu, a nawet konfiguracja Apache z PHP na FreeBSD, gdzie interpreter siedział w serwerze WWW jako mod_php – dokładnie w modelu, który w drugiej części opisałem jako najhojniejszy dla atakującego.

Matematyka.net przeszła zresztą całą tę drogę i jeszcze dalej. Od Perla, przez PHP i Joomlę, po statyczną stronę wygenerowaną w Astro, taką samą jak ten blog. Interpretera w odpowiedzi na żądanie nie ma tam już wcale.

Przesiadka nie przyszła przez język, tylko przez wzorzec

Nie było jednego dnia, w którym postanowiłem „od dziś Python”. Było wszystkiego po trochu. Jeśli jednak miałbym wskazać jedną rzecz, to byłby nią sposób myślenia o aplikacji, który przyniosły mi frameworki PHP.

W Laravelu, a potem w Symfony, nauczyłem się układać aplikację według wzorca Model-View-Controller: model trzyma dane, kontroler obsługuje żądanie, widok tylko wyświetla. Jeden punkt wejścia, trasy zdefiniowane w kodzie, zapytania do bazy przez ORM. Kiedy usiadłem do Pythona, okazało się, że ten wzorzec umiem odnaleźć wszędzie.

We Flasku MVC nie jest obowiązkowe, bo mikroframework nie narzuca struktury, ale nic nie stoi na przeszkodzie, żeby ułożyć projekt dokładnie tak, jak w Laravelu. Ciekawiej było z Django, które mówi o sobie MTV: Model-Template-View. Przez pierwsze dni zastanawiałem się, co to w ogóle jest. Potem okazało się, że to ten sam wzorzec z innymi nazwami: to, co w Symfony było kontrolerem, w Django nazywa się widokiem, a to, co było widokiem, nazywa się szablonem. Twórcy Django wyjaśniają ten wybór nazw w oficjalnym FAQ – i po przeczytaniu go trudno się z nimi nie zgodzić.

To właśnie dlatego przesiadka była łatwa. Nie uczyłem się od nowa, jak budować aplikację internetową, tylko jak nazywa się to samo w innym języku.

W międzyczasie były też projekty w Javie, w Spring Boot. Wzorzec ten sam, kontrolery i repozytoria znajome. Ale ostatecznie wygrał Python.

Co zdecydowało

Po latach widzę, że nie chodziło o składnię ani o wcięcia zamiast nawiasów klamrowych. Chodziło o rzeczy, które w moich projektach pythonowych są stanem domyślnym:

  • Django z adminem i migracjami. Użytkownicy, uprawnienia, ochrona CSRF, migracje bazy i panel administracyjny generowany z modeli – od pierwszego dnia projektu, bez dokładania pakietów.
  • Typowanie w FastAPI. Model danych opisany typami Pythona to jednocześnie walidacja wejścia i dokumentacja API. Szerzej pisałem o tym we wpisie Flask, FastAPI, Django – jak poznawałem trzy Pythony do weba.
  • Wersje przypięte w repozytorium. Wersja bibliotek w requirements.txt albo pyproject.toml, wersja interpretera w Dockerfile. Aktualizacja to commit, który przechodzi przez testy, a nie przełącznik w panelu hostingu, o którym po roku nikt nie pamięta.
  • Docker. To samo środowisko na moim komputerze i na serwerze, opisane w pliku, a nie w czyjejś pamięci.
  • Jeden język do backendu i skryptów. Aplikację, skrypt do przerobienia danych, automatyzację na serwerze i szybkie obliczenia piszę w tym samym języku. To ostatecznie przeważyło też nad Javą, w której nikt rozsądny nie pisze skryptu na pięć minut.

Zwróć uwagę, że trzy z pięciu punktów to nie są cechy języka, tylko sposobu wdrożenia – czyli dokładnie tego, o czym była druga część cyklu.

Gdzie nadal sięgam po PHP

Zdarza mi się to i dziś, w jednym bardzo konkretnym przypadku: klient ma najtańszy albo wręcz darmowy hosting, na którym nie ma nawet bazy MySQL. Python tam nie wejdzie, kontener tym bardziej. Wtedy piszę małą aplikację w PHP na własnym silniku: jeden plik index.php na froncie i SQLite jako baza.

Szkielet takiego silnika wygląda mniej więcej tak:

<?php
// index.php – jedyny punkt wejścia aplikacji
declare(strict_types=1);

// plik bazy SQLite poza katalogiem publicznym, jeśli hosting na to pozwala
$db = new PDO('sqlite:' . __DIR__ . '/../data/app.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// trasy w kodzie, a nie w układzie plików na dysku
$routes = [
    '/'     => 'showHome',
    '/news' => 'showNews',
];

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$handler = $routes[$path] ?? null;

if ($handler === null) {
    http_response_code(404);
    exit('Nie ma takiej strony');
}

$handler($db);

function showHome(PDO $db): void
{
    $items = $db->query('SELECT id, title FROM news ORDER BY id DESC LIMIT 10');
    foreach ($items as $item) {
        $url = '/news?id=' . (int) $item['id'];
        // każdy tekst z bazy przechodzi przez escapowanie
        echo '<a href="' . $url . '">' . htmlspecialchars($item['title']) . '</a><br>';
    }
}

function showNews(PDO $db): void
{
    // zapytanie parametryzowane – dane od użytkownika nigdy nie trafiają do SQL wprost
    $stmt = $db->prepare('SELECT title, body FROM news WHERE id = ?');
    $stmt->execute([(int) ($_GET['id'] ?? 0)]);
    $item = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($item === false) {
        http_response_code(404);
        exit('Nie ma takiej wiadomości');
    }

    echo '<h1>' . htmlspecialchars($item['title']) . '</h1>';
    echo '<p>' . nl2br(htmlspecialchars($item['body'])) . '</p>';
}

Do tego jedna reguła dla Apache, która każde żądanie do nieistniejącego pliku kieruje do index.php:

# .htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

To jest w miniaturze model 2 z poprzedniej części: jeden punkt wejścia, trasy w kodzie, zapytania parametryzowane (o tym, dlaczego to dziś prawie wystarcza na SQL Injection, pisałem osobno). Ale nie łudzę się – hosting za darmo to wciąż model 1. O wersji PHP decyduje panel hostingodawcy, a plik bazy SQLite, jeśli nie da się go wynieść poza katalog publiczny, trzeba zablokować regułą serwera, bo inaczej każdy pobierze go jednym kliknięciem. Taka aplikacja jest bezpieczna dokładnie na tyle, na ile jest mała. I dlatego taka zostaje.

Uczciwie: to nie jest wyrok na PHP

Wszystko, co wymieniłem w sekcji „Co zdecydowało”, da się mieć w PHP. Symfony czy Laravel z Composerem, wersją PHP przypiętą w Dockerfile i nginx przepuszczającym tylko index.php to praktycznie ten sam profil ryzyka co aplikacja w Django – model 3 z drugiej części. Laravel ma migracje, Symfony ma świetny system formularzy, oba mają porządne ORM-y. Zresztą to w nich nauczyłem się wzorca, który potem zabrałem do Pythona.

Różnica, którą widzę, nie leży w możliwościach języka, tylko w tym, co jest stanem domyślnym. W Pythonie aplikację internetową właściwie zawsze zaczyna się od frameworka, serwera aplikacji i zależności zapisanych w pliku. W PHP wciąż można zacząć od pliku wrzuconego przez FTP. To jest jednocześnie największa zaleta tego języka – sam z niej korzystam, gdy klient ma darmowy hosting – i powód, dla którego przy PHP2Shell tyle serwerów trzeba było wyłączać zamiast łatać.

Z Perla przesiadłem się na PHP, bo było łatwiejsze. Z PHP na Pythona – bo to, czego nauczyło mnie PHP, w Pythonie dostałem w pakiecie.

W następnej części cyklu przyjrzę się temu, czego o PHP wymaga dziś egzamin zawodowy INF.03 – i co z tego wynika dla bezpieczeństwa aplikacji, które za kilka lat napiszą jego absolwenci.