Jak wypada porównanie Express i hapi?


134

Z punktu widzenia projektowania i programowania aplikacji internetowych, jak wypada porównanie Express i Hapi? W przypadku podstawowych przykładów wydają się one podobne, jednak chciałbym dowiedzieć się więcej o kluczowych różnicach w ogólnej strukturze aplikacji.

Na przykład, o ile się dowiedziałem, Hapi używa innego mechanizmu routingu, który nie bierze pod uwagę kolejności rejestracji, może wykonywać szybsze wyszukiwania, ale jest ograniczony w porównaniu do Express. Czy są inne ważne różnice?

Jest też artykuł o wyborze Hapi (over Express) do tworzenia nowej strony npmjs.com, w tym artykule stwierdzono, że „system wtyczek Hapi oznacza, że ​​możemy izolować różne aspekty i usługi aplikacji w sposób, który pozwoliłby na mikrousługi w przyszłość. Express, z drugiej strony, wymaga nieco więcej konfiguracji, aby uzyskać tę samą funkcjonalność ”, co to dokładnie oznacza?

Odpowiedzi:


232

To duże pytanie, które wymaga długiej odpowiedzi, aby było kompletne, dlatego omówię tylko podzbiór najważniejszych różnic. Przepraszamy, że to wciąż długa odpowiedź.

Jak są podobne?

Masz całkowitą rację, mówiąc:

W przypadku podstawowych przykładów wydają się podobne

Obie struktury rozwiązują ten sam podstawowy problem: zapewnienie wygodnego interfejsu API do budowania serwerów HTTP w węźle. Oznacza to, że jest to wygodniejsze niż używanie httpsamego modułu natywnego niższego poziomu . httpModuł może zrobić wszystko, co chcemy, ale jest to uciążliwe do zastosowań pisać.

Aby to osiągnąć, obaj używają koncepcji, które od dawna są obecne w strukturach internetowych wysokiego poziomu: routing, moduły obsługi, wtyczki, moduły uwierzytelniające. Może nie zawsze miały te same nazwy, ale są z grubsza równoważne.

Większość podstawowych przykładów wygląda mniej więcej tak:

  • Utwórz trasę
  • Uruchom funkcję, gdy żądana jest trasa, przygotowując odpowiedź
  • Odpowiedz na żądanie

Wyrazić:

app.get('/', function (req, res) {

    getSomeValue(function (obj) {

        res.json({an: 'object'});
    });
});

hapi:

server.route({
    method: 'GET',
    path: '/',
    handler: function (request, reply) {

        getSomeValue(function (obj) {

            reply(obj);
        });
    }
});

Różnica nie jest tutaj przełomowa, prawda? Po co więc wybierać jedną z nich?

Czym się różnią?

Prosta odpowiedź brzmi: hapi to o wiele więcej i robi o wiele więcej po wyjęciu z pudełka. To może nie być jasne, jeśli spojrzysz na prosty przykład z góry. W rzeczywistości jest to zamierzone. Proste przypadki są proste. Przyjrzyjmy się więc niektórym z dużych różnic:

Filozofia

Express ma być bardzo minimalny. Dając ci małe API z niewielkim odkurzaniem http, nadal jesteś sam, jeśli chodzi o dodawanie dodatkowych funkcji. Jeśli chcesz odczytać treść przychodzącego żądania (dość powszechne zadanie), musisz zainstalować osobny moduł . Jeśli spodziewasz się wysłania różnych typów zawartości do tej trasy, musisz również sprawdzić Content-typenagłówek, aby sprawdzić, który to jest i odpowiednio go przeanalizować (na przykład dane formularza, JSON lub wiele części), często używając oddzielnych modułów .

hapi ma bogaty zestaw funkcji, często ujawnianych za pomocą opcji konfiguracyjnych, zamiast wymagać pisania kodu. Na przykład, jeśli chcemy się upewnić, że treść żądania (ładunek) jest w pełni wczytywana do pamięci i odpowiednio analizowana (automatycznie na podstawie typu zawartości) przed uruchomieniem procedury obsługi, jest to tylko prosta opcja :

server.route({
    config: {
        payload: {
            output: 'data',
            parse: true
        }
    },
    method: 'GET',
    path: '/',
    handler: function (request, reply) {

        reply(request.payload);
    }
});

cechy

Wystarczy porównać dokumentację API w obu projektach, aby przekonać się, że hapi oferuje większy zestaw funkcji.

hapi zawiera niektóre z następujących wbudowanych funkcji, których Express nie ma (o ile wiem):

Rozszerzalność i modułowość

hapi i Express podchodzą do rozszerzalności w zupełnie inny sposób. Dzięki Express masz funkcje oprogramowania pośredniego . Funkcje oprogramowania pośredniego są podobne do filtrów, które układasz w stos i wszystkie żądania przechodzą przez nie przed trafieniem do programu obsługi.

hapi ma cykl życia żądania i oferuje punkty rozszerzeń , które są porównywalne z funkcjami oprogramowania pośredniego, ale istnieją kilka zdefiniowanych punktów w cyklu życia żądania.

Jednym z powodów, dla których Walmart stworzył hapi i przestał używać Express, była frustracja z powodu tego, jak trudno było podzielić aplikację Express na oddzielne części i pozwolić, aby różni członkowie zespołu bezpiecznie pracowali nad swoim fragmentem. Z tego powodu stworzyli system wtyczek w hapi.

Wtyczka jest jak podaplikacja, możesz zrobić wszystko, co możesz w aplikacji hapi, dodawać trasy, punkty rozszerzeń itp. We wtyczce masz pewność, że nie psujesz innej części aplikacji, ponieważ kolejność rejestracje tras nie mają znaczenia i nie można tworzyć tras kolidujących. Następnie możesz połączyć te wtyczki na serwerze i wdrożyć go.

Ekosystem

Ponieważ Express daje tak niewiele po wyjęciu z pudełka, musisz wyglądać na zewnątrz, gdy chcesz coś dodać do swojego projektu. Często podczas pracy z hapi funkcja, której potrzebujesz, jest albo wbudowana, albo istnieje moduł utworzony przez podstawowy zespół.

Minimalne brzmi świetnie. Ale jeśli tworzysz poważną aplikację produkcyjną, prawdopodobnie będziesz potrzebować wszystkich tych rzeczy.

Bezpieczeństwo

hapi został zaprojektowany przez zespół Walmart do obsługi ruchu w Czarny piątek, więc bezpieczeństwo i stabilność zawsze były głównym problemem. Z tego powodu framework robi wiele dodatkowych rzeczy, takich jak ograniczanie rozmiaru przychodzącego ładunku, aby zapobiec wyczerpaniu pamięci procesu. Ma również opcje dotyczące takich rzeczy, jak maksymalne opóźnienie pętli zdarzeń, maksymalna używana pamięć RSS i maksymalny rozmiar sterty v8, po przekroczeniu którego serwer odpowie z limitem czasu 503 zamiast po prostu się zawiesić.

Podsumowanie

Oceń ich obu samodzielnie. Pomyśl o swoich potrzebach i o tym, która z nich jest odpowiedzią na Twoje największe obawy. Zanurz się w dwóch społecznościach (IRC, Gitter, Github), zobacz, które wolisz. Nie wierz mi na słowo. I miłego hakowania!


ZRZECZENIE SIĘ: Jestem stronniczy jako autor książki o hapi, a powyższe jest w dużej mierze moją osobistą opinią.


7
Matt, dziękuję za obszerny post, sekcje „rozszerzalność i modułowość” oraz „bezpieczeństwo” były dla mnie najbardziej pomocnymi sekcjami. Chyba warto wspomnieć, że nowy system routingu w Express 4 zapewnia lepszą modułowość dla podaplikacji.
Ali Shakiba

1
Świetna odpowiedź Matt. Jesteśmy również zdezorientowani, jeśli chodzi o Hapi i Express, jedną wadą, którą widzimy w przypadku Hapi, jest to, że nie ma tak szerokiego wsparcia społeczności jak Express i może być poważnym problemem, jeśli gdzieś utkniemy. Potrzebujesz twojej opinii na ten sam temat.
Aman Gupta

1
Express jest generyczny, podczas gdy hapi jest nieco bardziej korporacyjnym.
windmaomao

1
@MattHarrison świetna odpowiedź, w tej chwili czytam twoją książkę o Hapi, jest po prostu świetna. Mam zamiar stworzyć nowy rynek dla książek używających Hapi na zapleczu i vue.js na frontendzie, po przyzwyczajeniu się do Hapi chciałbym aktywnie uczestniczyć w projekcie Hapi.
Humoyun Ahmad

1
@Humoyun Great! Należy jednak pamiętać, że pojawiła się nowa główna wersja hapi z kilkoma znaczącymi zmianami od <= v16.0.0. Obecnie tworzę serię screencastów zaprojektowaną dla ludzi do nauki w wersji 17: youtube.com/playlist?list=PLi303AVTbxaxqjaSWPg94nccYIfqNoCHz
Matt Harrison,

54

Moja organizacja korzysta z Hapi. Dlatego to lubimy.

Hapi to:

  • Wspierany przez główny korpus. Oznacza to, że wsparcie społeczności będzie silne i będzie dostępne dla Ciebie w przyszłych wydaniach. Łatwo jest znaleźć pasjonatów Hapi i są tam dobre samouczki (choć nie tak liczne i obszerne jak samouczki ExpressJs). Od tej daty postu npm i Walmart używają Hapi.
  • Może ułatwić pracę rozproszonym zespołom pracującym nad różnymi częściami usług backendu bez konieczności posiadania kompleksowej wiedzy o pozostałej części powierzchni API (architektura wtyczek Hapi jest uosobieniem tej jakości).
  • Niech framework zrobi to, co powinien: skonfigurować rzeczy. Następnie ramy powinny być niewidoczne i pozwolić programistom skupić swoją prawdziwą energię twórczą na budowaniu logiki biznesowej. Po roku używania Hapi, zdecydowanie czuję, że Hapi to osiąga. Czuje się szczęśliwy!

Jeśli chcesz usłyszeć bezpośrednio od Erana Hammera (prowadzącego Hapi)

W ciągu ostatnich czterech lat hapi stało się ramą wyboru dla wielu projektów, dużych i małych. To, co wyróżnia hapi, to możliwość skalowania do dużych wdrożeń i dużych zespołów. Wraz ze wzrostem projektu rośnie jego złożoność - inżynieryjna i procesowa. Architektura i filozofia hapi radzi sobie ze zwiększoną złożonością bez konieczności ciągłego refaktoryzacji kodu [czytaj więcej]

Rozpoczęcie pracy z Hapi nie będzie tak łatwe, jak ExpressJs, ponieważ Hapi nie ma takiej samej „mocy gwiazdowej”… ale kiedy poczujesz się komfortowo, będziesz mieć DUŻO kilometrów. Zajęło mi to około 2 miesiące jako nowy haker, który nieodpowiedzialnie używał ExpressJs przez kilka lat. Jeśli jesteś doświadczonym programistą backendu, będziesz wiedział, jak czytać dokumenty i prawdopodobnie nawet tego nie zauważysz.

Obszary, w których dokumentacja Hapi może zostać ulepszona:

  1. jak uwierzytelniać użytkowników i tworzyć sesje
  2. obsługa żądań między źródłami (CORS)
  3. przesyłanie plików (wieloczęściowe, fragmentaryczne)

Myślę, że uwierzytelnianie byłoby najtrudniejszą częścią tego procesu, ponieważ musisz zdecydować, jakiej strategii uwierzytelniania użyć (uwierzytelnianie podstawowe, pliki cookie, tokeny JWT, OAuth). Chociaż technicznie nie jest to problem Hapi, że krajobraz sesji / uwierzytelniania jest tak podzielony ... ale chciałbym, aby zapewnili do tego trochę ręki. To znacznie zwiększyłoby zadowolenie deweloperów.

Pozostałe dwa nie są w rzeczywistości takie trudne, dokumenty mogłyby być napisane trochę lepiej.


3

Szybkie fakty o Hapi lub dlaczego Hapi JS?

Hapi jest zorientowany na konfigurację Ma wbudowane uwierzytelnianie i autoryzację Został wydany w atmosferze testowanej w boju i naprawdę udowodnił swoją wartość Wszystkie moduły mają 100% pokrycie testowe Rejestruje najwyższy poziom abstrakcji z dala od rdzenia HTTP Łatwo porównywalny poprzez architekturę wtyczek

Hapi to lepszy wybór, jeśli chodzi o wydajność. Hapi używa innego mechanizmu routingu, który może wykonywać szybsze wyszukiwania i uwzględniać kolejność rejestracji. Niemniej jednak jest dość ograniczony w porównaniu z Expressem. Dzięki systemowi wtyczek Hapi możliwe jest wyodrębnienie różnych aspektów i usług, które w przyszłości na wiele sposobów pomogłyby aplikacji.

Stosowanie

Hapi jest najbardziej preferowanym frameworkiem w porównaniu do Express. Hapi jest używany głównie w dużych aplikacjach korporacyjnych.

Oto kilka powodów, dla których programiści nie wybierają Express podczas tworzenia aplikacji dla przedsiębiorstw:

Trasy są trudniejsze do utworzenia w Express

Oprogramowanie pośredniczące często przeszkadza; za każdym razem, gdy definiujesz trasy, musisz wpisać tyle numerów kodów.

Hapi byłby najlepszym wyborem dla programisty, który chce zbudować RESTful API. Hapi ma architekturę mikroserwisów i możliwe jest również przeniesienie kontroli z jednej obsługi na drugą w oparciu o określone parametry. Dzięki wtyczce Hapi możesz cieszyć się wyższym poziomem abstrakcji wokół HTTP, ponieważ możesz podzielić logikę biznesową na części, które można łatwo zarządzać.

Kolejną ogromną zaletą Hapi jest to, że wyświetla szczegółowe komunikaty o błędach w przypadku błędnej konfiguracji. Hapi pozwala również domyślnie skonfigurować rozmiar wysyłanych plików. Jeśli maksymalny rozmiar wysyłania jest ograniczony, możesz wysłać do użytkownika komunikat o błędzie informujący, że rozmiar pliku jest zbyt duży. To ochroniłoby twój serwer przed awarią, ponieważ przesyłane pliki nie będą już próbować buforować całego pliku.

  1. Cokolwiek możesz osiągnąć używając ekspresu, możesz też łatwo osiągnąć używając hapi.js.

  2. Hapi.js jest bardzo stylowy i bardzo dobrze organizuje kod. Jeśli zobaczysz, jak działa routing i umieszcza podstawową logikę w kontrolerach, z pewnością to pokochasz.

  3. Hapi.js oficjalnie zapewnia kilka wtyczek wyłącznie dla hapi.js, od uwierzytelniania opartego na tokenach po zarządzanie sesjami i wiele innych, czyli reklamy. Nie oznacza to, że nie można użyć tradycyjnego npm, wszystkie są obsługiwane przez hapi.js

  4. Jeśli tworzysz kod w hapi.js, kod byłby bardzo łatwy w utrzymaniu.


„Jeśli zobaczysz, jak działa routing i umieści podstawową logikę w kontrolerach…”. W dokumentacji nie widzę żadnego przykładu pokazującego użycie kontrolerów. Wszystkie przykłady routingu używają właściwości handler, która jest funkcją. Porównuję ten sposób z tym, co Laravel (framework PHP) i AdonisJs (framework Node.js) robią dla routingu, w którym możemy używać kontrolerów do routingu. Prawdopodobnie przegapiłem fragmenty dokumentu HAPI, które pokazują użycie kontrolerów do routingu. Więc jeśli ta funkcja istnieje, będzie mi dobrze, ponieważ jestem przyzwyczajony do używania kontrolerów do routingu w Laravel.
Lex Soft

1

Niedawno zacząłem używać Hapi i jestem z niego całkiem zadowolony. Moje powody są

  1. Łatwiejsze do przetestowania. Na przykład:

    • server.inject umożliwia uruchomienie aplikacji i uzyskanie odpowiedzi bez jej uruchamiania i nasłuchiwania.
    • server.info podaje aktualny URI, port itp.
    • server.settingsuzyskuje dostęp do konfiguracji, np. server.settings.cachepobiera aktualnego dostawcę pamięci podręcznej
    • w razie wątpliwości spójrz na /testfoldery dowolnej części aplikacji lub obsługiwanych wtyczek, aby zobaczyć sugestie, jak mock / test / stub itp.
    • mam wrażenie, że model architektoniczny hapi pozwala zaufać, ale zweryfikować np. Czy moje wtyczki są zarejestrowane ? Jak mogę zadeklarować zależność modułu ?
  2. Działa po wyjęciu z pudełka, np. Przesyłanie plików , strumienie zwrotne z punktów końcowych itp.

  3. Podstawowe wtyczki są utrzymywane wraz z podstawową biblioteką. np. parsowanie szablonów , buforowanie itp. Dodatkową korzyścią jest to, że te same standardy kodowania są stosowane w najważniejszych rzeczach.

  4. Rozsądne błędy i obsługa błędów. Hapi sprawdza poprawność opcji konfiguracyjnych i przechowuje wewnętrzną tabelę tras, aby zapobiec powielaniu tras. Jest to bardzo przydatne podczas nauki, ponieważ błędy są generowane wcześnie, zamiast nieoczekiwanych zachowań, które wymagają debugowania.


-1

Jeszcze jedna kwestia do dodania, Hapi zaczął obsługiwać wywołania „http2” począwszy od wersji 16 (jeśli się nie mylę). Jednak express nie obsługuje jeszcze modułu „http2” bezpośrednio do express 4. Chociaż wydali tę funkcję w wersji alfa programu express 5.


-2
'use strict';
const Hapi = require('hapi');
const Basic = require('hapi-auth-basic');
const server = new Hapi.Server();
server.connection({
    port: 2090,
    host: 'localhost'
});


var vorpal = require('vorpal')();
const chalk = vorpal.chalk;
var fs = require("fs");

var utenti = [{
        name: 'a',
        pass: 'b'
    },
    {
        name: 'c',
        pass: 'd'
    }
];

const users = {
    john: {
        username: 'john',
        password: 'secret',
        name: 'John Doe',
        id: '2133d32a'
    },
    paul: {
        username: 'paul',
        password: 'password',
        name: 'Paul Newman',
        id: '2133d32b'
    }
};

var messaggi = [{
        destinazione: 'a',
        sorgente: 'c',
        messsaggio: 'ciao'
    },
    {
        destinazione: 'a',
        sorgente: 'c',
        messsaggio: 'addio'
    },
    {
        destinazione: 'c',
        sorgente: 'a',
        messsaggio: 'arrivederci'
    }
];

var login = '';
var loggato = false;

vorpal
    .command('login <name> <pass>')
    .description('Effettua il login al sistema')
    .action(function (args, callback) {
        loggato = false;
        utenti.forEach(element => {
            if ((element.name == args.name) && (element.pass == args.pass)) {
                loggato = true;
                login = args.name;
                console.log("Accesso effettuato");
            }
        });
        if (!loggato)
            console.log("Login e Password errati");
        callback();
    });

vorpal
    .command('leggi')
    .description('Leggi i messaggi ricevuti')
    .action(function (args, callback) {
        if (loggato) {
            var estratti = messaggi.filter(function (element) {
                return element.destinazione == login;
            });

            estratti.forEach(element => {
                console.log("mittente : " + element.sorgente);
                console.log(chalk.red(element.messsaggio));
            });
        } else {
            console.log("Devi prima loggarti");
        }
        callback();
    });

vorpal
    .command('invia <dest> "<messaggio>"')
    .description('Invia un messaggio ad un altro utente')
    .action(function (args, callback) {
        if (loggato) {
            var trovato = utenti.find(function (element) {
                return element.name == args.dest;
            });
            if (trovato != undefined) {
                messaggi.push({
                    destinazione: args.dest,
                    sorgente: login,
                    messsaggio: args.messaggio
                });
                console.log(messaggi);
            }
        } else {
            console.log("Devi prima loggarti");
        }
        callback();
    });

vorpal
    .command('crea <login> <pass>')
    .description('Crea un nuovo utente')
    .action(function (args, callback) {
        var trovato = utenti.find(function (element) {
            return element.name == args.login;
        });
        if (trovato == undefined) {
            utenti.push({
                name: args.login,
                pass: args.pass
            });
            console.log(utenti);
        }
        callback();
    });

vorpal
    .command('file leggi utenti')
    .description('Legge il file utenti')
    .action(function (args, callback) {
        var contents = fs.readFileSync("utenti.json");
        utenti = JSON.parse(contents);
        callback();
    });

vorpal
    .command('file scrivi utenti')
    .description('Scrive il file utenti')
    .action(function (args, callback) {
        var jsontostring = JSON.stringify(utenti);
        fs.writeFile('utenti.json', jsontostring, function (err) {
            if (err) {
                return console.error(err);
            }
        });
        callback();
    });

vorpal
    .command('file leggi messaggi')
    .description('Legge il file messaggi')
    .action(function (args, callback) {
        var contents = fs.readFileSync("messaggi.json");
        messaggi = JSON.parse(contents);
        callback();
    });

vorpal
    .command('file scrivi messaggi')
    .description('Scrive il file messaggi')
    .action(function (args, callback) {
        var jsontostring = JSON.stringify(messaggi);
        fs.writeFile('messaggi.json', jsontostring, function (err) {
            if (err) {
                return console.error(err);
            }
        });
        callback();
    });

// leggi file , scrivi file

vorpal
    .delimiter(chalk.yellow('messaggi$'))
    .show();




const validate = function (request, username, password, callback) {
    loggato = false;


    utenti.forEach(element => {
        if ((element.name == username) && (element.pass == password)) {
            loggato = true;
            console.log("Accesso effettuato");
            return callback(null, true, {
                name: username
            })
        }
    });
    if (!loggato)
        return callback(null, false);
};

server.register(Basic, function (err) {
    if (err) {
        throw err;
    }
});

server.auth.strategy('simple', 'basic', {
    validateFunc: validate
});



server.route({
    method: 'GET',
    path: '/',
    config: {
        auth: 'simple',
        handler: function (request, reply) {
            reply('hello, ' + request.auth.credentials.name);
        }
    }
});

//route scrivere
server.route({
    method: 'POST',
    path: '/invia',
    config: {
        auth: 'simple',
        handler: function (request, reply) {
            //console.log("Received POST from " + request.payload.name + "; id=" + (request.payload.id || 'anon'));
            var payload = encodeURIComponent(request.payload)
            console.log(request.payload);
            console.log(request.payload.dest);
            console.log(request.payload.messaggio);
            messaggi.push({
                destinazione: request.payload.dest,
                sorgente: request.auth.credentials.name,
                messsaggio: request.payload.messaggio
            });
            var jsontostring = JSON.stringify(messaggi);
            fs.writeFile('messaggi.json', jsontostring, function (err) {
                if (err) {
                    return console.error(err);
                }
            });
            console.log(messaggi);
            reply(messaggi[messaggi.length - 1]);

        }
    }
});


//route leggere (json)
server.route({
    method: 'GET',
    path: '/messaggi',
    config: {
        auth: 'simple',
        handler: function (request, reply) {
            messaggi = fs.readFileSync("messaggi.json");
            var estratti = messaggi.filter(function (element) {
                return element.destinazione == request.auth.credentials.name;
            });
            var s = [];

            console.log(request.auth.credentials.name);
            console.log(estratti.length);
            estratti.forEach(element => {

                s.push(element);

                //fare l'array con stringify
                //s+="mittente : "+element.sorgente+": "+element.messsaggio+"\n";

            });
            var a = JSON.stringify(s);
            console.log(a);
            console.log(s);
            reply(a);
        }
    }
});



server.start(function () {
    console.log('Hapi is listening to ' + server.info.uri);
});

function EseguiSql(connection, sql, reply) {
    var rows = [];
    request = new Request(sql, function (err, rowCount) {
        if (err) {
            console.log(err);
        } else {
            console.log(rowCount + ' rows');
            console.log("Invio Reply")
            reply(rows);
        }
    });

    request.on('row', function (columns) {
        var row = {};
        columns.forEach(function (column) {
            row[column.metadata.colName] = column.value;
        });
        rows.push(row);
    });

    connection.execSql(request);
}

server.route({
    method: 'POST',
    path: '/query',
    handler: function (request, reply) {
        // Qui dovrebbe cercare i dati nel body e rispondere con la query eseguita
        var connection = new Connection(config);

        // Attempt to connect and execute queries if connection goes through
        connection.on('connect', function (err) {
            if (err) {
                console.log(err);
            } else {

                console.log('Connected');
                console.log(request.payload.sql);
                EseguiSql(connection, request.payload.sql, reply);
            }
        });

    }
});

server.connection({
    host: process.env.HOST || 'localhost',
    port: process.env.PORT || 8080
});

var config = {
    userName: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    server: process.env.DB_SERVER,
    options: {
        database: process.env.DB_NAME,
        encrypt: true
    }
}

Witamy w StackOverflow. Czy mógłbyś bardziej szczegółowo opisać swoją odpowiedź i jej związek z pytaniem zadanym przez OP?
Szymon Maszke
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.