REST API w C++ – biblioteka Casablanca cpprest

Czas czytania: 5 minut

Przed lekturą
Abyś zrozumiał treść tego wpisu byłoby dobrze, abyś wiedział co to jest REST API oraz do czego ono służy. W treści tego wpisu nie będę się rozwodził nad tym co to dokładnie jest REST API oraz do czego ono służy. Skupię się jedynie nad tym, jak wykonać prostą implementację.



Ostatnimi czasy tworzę dość duży projekt. Jego wielkość wymagała podziału na dwie części: backend i frontend. Backend to wszystko, co dzieje się pod spodem (komunikacja z bazą danych, wykonywanie obliczeń itp.) Natomiast Frontend zajmuje się głównie interfejsem aplikacji, czyli sposobem „komunikacji” z użytkownikiem. Aplikacja powstawała w C++ i miałem spory problem z wyprowadzeniem przetwarzanych danych do aplikacji tworzonej w React’ie. Próbowałem różnych sposobów – np.: komunikacji za pomocą socketów. Pewnego razu na uczelni usłyszałem o czymś takim jak REST API – które wykorzystuje m.in. format JSON oraz różne typy zapytań HTTP do udostępnienia frontendowi różnych informacji. W wykonaniu REST API w języku C++ może nam pomóc biblioteka firmy Microsoft o nazwie Casablanca (cpprest). Za chwilę zobaczymy, jak ją zainstalować oraz jak zrobić proste API.

Instalacja Cablanca cpprest

Instalacja biblioteki cpprest została dobrze opisana na jej stronie w Githubie. W Linuksie możemy ją bezproblemowo zainstalować za pomocą polecenia:

sudo apt-get install libcpprest-dev

Nasz projekt nie będzie miał jakiegoś ogromnego rozmiaru. Toteż użyjemy prostego środowiska Code::Blocks. Utwórz nowy projekt konsolowy w języku C++. Gdy to zrobisz, kliknij PPM na nazwie projektu→Build Options→na liście po lewej stronie zaznacz nazwę swojego projektu. Po prawej stronie przejdź na zakładkę Linker Settings. Musimy dodać następujące opcje:

-lcpprest
-lpthread
-lboost_system
-lssl
-lcrypto

Poniżej znajduje się screen prezentujący jak to powinno wyglądać:


To tyle. Biblioteka została dołączona a projekt został skonfigurowany. Pora na napisanie prostego kodu.

Piszemy proste Rest API w C++

Nasz projekt będzie składał się z kilku klas. Utworzymy sobie klasę BasicController, która będzie ustawiała wszystkie podstawowe parametry serwera. Następnie utworzymy sobie klasę usługi, która będzie implementowała metody GET/PUT/POST i inne.

BasicController

Zaczynamy od klasy BasicController. Dodajemy do projektu nowy plik o nazwie BasicController.h. Wewnątrz znajduje się następujący kod:

Omówimy go linijka po linijce.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef BASICCONTROLLER_H_INCLUDED
#define BASICCONTROLLER_H_INCLUDED
#include <cpprest/http_listener.h>
#include <pplx/pplxtasks.h>
#include <string>
#include <cpprest/http_msg.h>
using namespace web;
using namespace http;
using namespace http::experimental::listener;

class BasicController {
    public:
    BasicController(const std::string& address,const std::string& port);
    ~BasicController();
    void setEndpoint(const std::string& mount_point);
    std::string endpoint() const;
    pplx::task<void> accept();
    pplx::task<void> shutdown();

    virtual void initRestOpHandlers() = 0;

    std::vector<utility::string_t> requestPath(const http_request & message);
    protected:
    http_listener _listener;
    private:
    uri_builder endpointBuilder;
};

#endif // BASICCONTROLLER_H_INCLUDED

#include’y – to jest to, co musi się pojawić. Aby program prawidłowo się skompilował, musimy dołączyć pliki nagłówkowe zawierające funkcje z nowo dodanej biblioteki, z której będziemy korzystali.

Konstruktor naszej klasy przyjmuje dwa parametry: adres oraz port pod którym nasz serwer będzie nasłuchiwał. Jako adres przekażemy adres IP naszej karty sieciowej. W przyszłości możemy sami odczytywać ten adres, ale pominąłem ten fragment dla uproszczenia przykładu.

Destruktor jest, ale nic nie robi.

setEndpoint – funkcja ta przyjmuje tylko jeden argument. mount_point, czyli „fragment adresu” pod jakim będzie widoczna nasza usługa. np.: Skonstruujmy kontroler, przekazując mu jako adres IP i port 192.168.1.1 i port 8080. setEndpoint uruchommy z argumentem „/api/calculator”. Wtedy nasze API będzie widoczne pod adresem http://192.168.1.1:8080/api/calculator. Proste, nie?

Kolejne dwie funkcje – accept i shutdown – służą do rozpoczęcia i zakończenia pracy serwera. Czysto wirtualną metodą initRestOpHandlers zajmiemy się później.

requestPath wyciąga nam z zapytania część adresu, którą dopisał użytkownik. Wróćmy do poprzedniego przykładu. Załóżmy, że użytkownik wysłał zapytanie pod adres:

http://192.168.1.1:8080/api/calculator/adder. Wtedy requestPath zwróci tablicę, której pierwszym elementem będzie słowo „adder”.

http_listener _listener – jest to zmienna, która służyć nam będzie po podłączenia odpowiednich funkcji do danych zapytań. Szczegółowo zajmiemy się nią później.

uri_builder endpointBuilder – służy do zbudowania adresu, pod którym będzie widoczna nasza usługa. Zauważysz i zrozumiesz jej zastosowanie, kiedy będziemy patrzyli na implementację powyższych metod.

No dobra. Pierwsze koty za płoty. Przechodzimy do implementacji klasy BasicController.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "BasicController.h"

BasicController::BasicController(const std::string& naddress,const std::string& nport) {
   this->endpointBuilder.set_host(naddress);
   this->endpointBuilder.set_port(nport);
   this->endpointBuilder.set_scheme("http");
}
BasicController::~BasicController() {
}
void BasicController::setEndpoint(const std::string& mount_point)
{
    endpointBuilder.set_path(mount_point);
    _listener = http_listener(endpointBuilder.to_uri());
}

W konstruktorze za pomocą endpointBuildera ustawiamy adres, port oraz protokół, pod którym będziemy nasłuchiwać. W setEndpoint do endPointBuildera dodajemy ścieżkę i to wszystko przekazujemy jako wynik do _listenera, który od tego momentu wie, pod jakim adresem ma nasłuchiwać przychodzących zapytań.

20
21
22
23
24
25
26
pplx::task<void> BasicController::accept() {
    initRestOpHandlers();
    return _listener.open();
}
pplx::task<void> BasicController::shutdown() {
    return _listener.close();
}

W metodzie accept wywołujemy metodę initRestOpHandlers(), którą niedługo nadpiszemy w klasie pochodnej. Poza tym, otwieramy _listenera. Od momentu wywołania tej funkcji nasz serwer zaczyna pracować. Implementacja metody shutdown jest analogiczna.

27
28
29
30
31
std::vector<utility::string_t> BasicController::requestPath(const http_request& message)
{
    auto relativePath = uri::decode(message.relative_uri().path());
    return uri::split_path(relativePath);
}

Jak działa metoda requestPath? Jako argument przyjmujemy zapytanie http. Dekodujemy je za pomocą specjalnej metody uri::decode. Korzystamy ze specjalnego typu zmiennej auto, której typ, wzorem Javascriptu i innych nowoczesnych języków, automatycznie dopasowuje się do danej sytuacji. Metoda split_path dzieli ścieżkę na kawałki i każdy z tych kawałków wkłada do oddzielnego elementu klasy vector. To wszystko zwracamy za pomocą słowa kluczowego return.

Service

Klasa Service dziedziczy po BasicController. Umieść ją w oddzielnych plikach: Service.cpp oraz Service.h.

Nasza usługa będzie pełniła bardzo prostą rolę. Zapytanie GET będzie zwracało nam zawartość kontenera vector przechowującego liczby int. Jeśli przy zapytaniu GET adres URI będzie kończył się słówkiem sum, to dostaniemy sumę liczb, a jeśli słówkiem avg to średnią. Zapytanie PUT będzie natomiast dodawało kolejne liczby do tego kontenera. To wystarczy, aby pokazać ci ogólną wizję posługiwania się biblioteką cpprest. Pozostałe zapytania dasz radę zaimplementować we własnym zakresie.

Service.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef SERVICE_H_INCLUDED
#define SERVICE_H_INCLUDED
#include "BasicController.h"
#include <vector>
using std::vector;

class Service : public BasicController {
    public:
    Service(const std::string& address,const std::string& port) : BasicController(address,port) {}
    ~Service() {}
    void handleGet(http_request message);
    void handlePut(http_request message);
    void initRestOpHandlers() override;
    private:
    vector<int> numbers;
    float calculateSum();
};
#endif // SERVICE_H_INCLUDED

Oto nasz plik Service.h. Zdefiniowaliśmy w nim klasę Service. To ona będzie zajmowała się analizą zapytań i wysłaniem odpowiedniej odpowiedzi do użytkownika. Prywatnie zdefiniowaliśmy sobie vector liczb typu int. To tam będziemy przechowywali liczby dodane przez użytkownika za pomocą zapytania PUT. Zdefiniowaliśmy sobie także pomocniczą funkcję calculateSum() (w linijce 16). Funkcja calculateSum będzie liczyła sumę wszystkich elementów znajdującej się w kontenerze numbers.

A co zdefiniowaliśmy jako publiczne elementy? Konstruktor, który przyjmuje takie same parametry jak konstruktor klasy, po której dziedziczymy (BasicController). Poza tym konstruktor nic nie musi robić. Dalej mamy definicję metod, z których będziemy korzystali: handleGet i handlePut. Obydwie jako argument przyjmują zmienną typu http_request. http_request zawiera wszystkie informacje o zapytaniu HTTP, jakie zostało wysłane przez użytkownika. InitrRestOpHandlers() jest metodą odziedziczoną po BasicController, którą w tym miejscu zaimplementujemy.

Service.cpp
Plik Service.cpp jest dosyć długi. Abyś wszystko dobrze zrozumiał, będę omawiał po kolei poszczególne metody tego pliku.

9
10
11
12
13
14
15
#include "Service.h"
#include <string>
using std::string;
void Service::initRestOpHandlers() {
    _listener.support(methods::GET,std::bind(&Service::handleGet,this,std::placeholders::_1));
    _listener.support(methods::PUT,std::bind(&Service::handlePut,this,std::placeholders::_1));
}

Zaczynamy od funkcji initRestOpHandlers. Domyślasz się pewnie, jakie ona ma zadanie :). Mówimy serwerowi, jakie metody obsługuje nasze API. Pierwszy argument to odpowiednia metoda. Drugi argument wygląda nieco bardziej tajemniczo. Za pomocą std::bind „mówimy” listenerowi, żeby użył danej metody obsługi jeśli serwer otrzyma dane zapytanie. Np.: w linijce piątej mówimy, że do obsługi zapytania GET powinna zostać użyta metoda handleGet. Natomiast w kolejnej linijce uświadamiamy serwerowi, żeby użył metody handlePut do obsługi zapytania PUT. Analogicznie postępowalibyśmy z innymi zapytaniami HTTP, które chcielibyśmy obsłużyć a nie ma ich w tym przykładzie.

9
10
11
12
13
14
15
float Service::calculateSum() {
    float sum = 0;
    for(unsigned int i = 0;i<numbers.size();i++) {
        sum+=numbers[i];
    }
    return sum;
}

Implementacja metody CalculateSum nie pozostawia żadnych wątpliwości. Po prostu w pętli for sumujemy wszystkie elementy kontenera numbers i sumę tę przechowujemy w zmiennej typu float o nazwie sum. Na samym końcu, gdy pętla skończy swoją pracę, zwracamy zmienną sum jako rezultat wykonania tej metody. Nie ma tu nic niezwykłego, nad czym warto byłoby dłużej się rozwodzić.

16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
void Service::handleGet(http_request message) {
    vector<string> path = requestPath(message);
    if(path.empty()) {
        vector <json::value> jsonNumbers;
        for(unsigned int i = 0;i<numbers.size();i++) {
            json::value number;
            number["number"] = json::value::number(numbers[i]);
            jsonNumbers.push_back(number);
        }
        json::value generatedNumberList;
        generatedNumberList["numbers"] = json::value::array(jsonNumbers);
        message.reply(status_codes::OK,generatedNumberList);
    }
    else {
        if(path[0]=="avg") {
            float avg = calculateSum()/numbers.size();
            json::value number;
            number["avg"] = json::value::number(avg);
            message.reply(status_codes::OK,number);
        }
        else if(path[0]=="sum") {
            float sum = calculateSum();
            json::value number;
            number["sum"] = json::value::number(sum);
            message.reply(status_codes::OK,number);
        }
        else {
            message.reply(status_codes::BadRequest);
        }
    }
}

Ciekawe rzeczy zaczynają się od metody handleGet, która ma obsłużyć żądanie GET do naszego API. Przyjęliśmy, że wysłanie zapytania GET pod adres bez żadnych argumentów zwróci nam listę liczb zapisanych w kontenerze numbers. Jeśli dodamy do adresu avg, to zostanie zwrócona średnia, a jeśli sum – to suma. Aby rozpoznać każdą z tych sytuacji, wykorzystujemy metodę requestPath, której implementację omawiałem wcześniej. W kolejnej linijce sprawdzamy, jaką wersję zapytania wybrał użytkownik. Jeśli nic nie dopisał do adresu naszego API, to znaczy że powinniśmy mu zwrócić liczby zapisane w kontenerze.

REST API zwykle zwraca wyniki swojej pracy w formie JSON. Musimy takiego JSON’a stworzyć. Kreujemy sobie kontener przechowujący jakieś wartości (json::value) o nazwie jsonNumbers. Potem w pętli przechodzimy przez wszystkie elementy kontenera przechowującego nasze liczby, konstruujemy zawartość „wnętrza” tablicy, w której znajduje się tylko liczba z danego obiegu pętli. Powstały obiekt json::value dopisujemy do wcześniej utworzonej kolekcji number.

Gdy pętla skończy swoją pracę, musimy jeszcze konwertować kontener vector na tablicę JSON. Robią to linijki 26 i 27. W linijce 28 wysyłamy odpowiedź do użytkownika.

A co, jeśli użytkownik chciałby poznać średnią przechowywanych przez serwer liczb? Ten przypadek jest o wiele prostszy. Zwracamy tylko jedną wartość, więc nie musimy bawić się w żadne pętle czy tablice. Po prostu obliczamy średnią. Tworzymy obiekt json::value number, który będzie przechowywał liczbę, którą zwrócimy. Pod nazwą avg zapisujemy w nim obliczoną średnią. Ostatecznie zwracamy wynik naszych działań za pomocą message.reply. Analogicznie wygląda obsługa sumy.

Co, jeśli użytkownik rozszerzy zapytanie o coś innego niż avg czy sum? Odpowiadamy mu, że zrobił coś źle – linijka 43.

47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
void Service::handlePut(http_request message) {
    message.extract_json().then([=](pplx::task<json::value> task)
    {
        try
        {
            json::value val = task.get();
            int number = val[U("number")].as_number().to_int32();
            numbers.push_back(number);
            message.reply(status_codes::OK);
        }
        catch(std::exception& e) {
            message.reply(status_codes::BadRequest);
        }
    });
}

Zajmijmy się teraz dodawaniem nowych liczb – metoda handlePut. W linijce 45 widzimy tajemniczy zapis: message.extract_json().then([=](pplx::task<json::value> task). Korzystamy tu z mechanizmu „asynchroniczności” wprowadzonego w jednym z najnowszych standardów C++. To, co znajduje się poniżej w nawiasach klamrowych jest wykonywane „tak jakby” w oddzielnym wątku.

Widzisz blok try,catch. Do czego on może służyć? Oczywiście, do obsługi sytuacji wyjątkowych. Nie mogliśmy go pominąć, gdyż użytkownik może w ciele zapytania wpisać jakieś nieprawidłowe dane. Może się też zwyczajnie pomylić. Mimo przyjęcia nieprawidłowego zapytania, nasz serwer powinien kontynuować pracę. Właśnie dlatego zastosowaliśmy obsługę wyjątków. Jeśli ciało zapytania będzie posiadało błędy, rzucimy użytkownikowi BadRequestem. Z pewnością to mu uświadomi, że zrobił coś źle.

Jeśli wszystkie dane zostały prawidłowo wprowadzone, obsługujemy zapytanie. Nie mamy wiele roboty. Odczytujemy zmienną number z otrzymanego „ciała” JSON i zapisujemy ją do zmiennej number w naszym programie. Tę liczbę dodajemy do kontenera i odpowiadamy użytkownikowi, że operacja się powiodła.

Main.cpp

Omówiliśmy już budowę całego serwera, a jeszcze nie powiedzieliśmy nic o najważniejszej rzeczy – jak go uruchomić. No cóż, zostawiłem ten element na sam koniec gdyż nie ma tu specjalnej filozofii. Spójrz na poniższy kawałek kodu.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include "Service.h"
using namespace std;

int main()
{
    Service serv("127.0.0.1","8080");
    serv.setEndpoint("/api");
    serv.accept().wait();
    while(1==1)
    {
        sleep(1000);
    }
    return 0;
}

Dosłownie kilka linijek. Includujemy sobie plik „Service.h” który zawiera kod naszej usługi. Tworzymy sobie obiekt tej klasy, podając mu jako argument adres i port. Ustawiamy następnie za pomocą omawianej wcześniej funkcji setEndpoint pod jakim adresem ma być widoczna ta usługa. W końcu uruchamiamy serwer za pomocą metody accept().
Jedynym elementem, który mógłby wzbudzić wątpliwości jest ta pętla, która znajduje się na samym końcu. Po co ona? serv.accept().wait() jest wywołaniem nieblokującym. Oznacza to, że program dalej kontynuowałby swoje działanie, gdyż serwer uruchamia się w oddzielnym wątku. Gdyby pętli nie było, program natychmiast zakończyłby swoje działanie. Aby nie marnować czasu procesora, umieściłem w pętli wywołanie funkcji sleep na dosyć długi okres czasu.

Nasze API działa!

To tyle. Nasze API jest funkcjonalne i prawidłowo reaguje na prośby użytkownika. Nie zawiesi się też, gdy wprowadzone zostaną nieprawidłowe dane.

Dodawanie nowych liczb
Pobieranie sumy

Z pewnością chciałbyś przetestować, jak ono pracuje. Służy do tego specjalna aplikacja Postman, którą możesz pobrać ze sklepu z aplikacjami dla Google Chrome. Obsługa jest intuicyjna. Niemniej, w niedalekiej przyszłości zajmę się dokładniejszym opisaniem tego wspaniałego programu.

W swoim zakresie spróbuj rozszerzyć powyższy program o obsługę usuwania liczb. Możesz to zrobić na dwa sposoby: albo odczytywać z liczbę/indeks liczby do usunięcia z ciała metody JSON, albo przekazywać żądanie przez URL.

Kod omawiany w tym artykule znajduje się w moim repozytorium na Githubie. (o właśnie tutaj!)

Na dzisiaj to tyle. Do zobaczenia 🙂 Zapraszam do czytania moich kolejnych artykułów.

Opublikowany w C++

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.