C++のWebフレームワーク oat++ を試す

120件のシェア(ちょっぴり話題の記事)

はじめに

福岡のyoshihitohです。先日 GitHubのTrending でC++のWebアプリケーションフレームワークの oat++ が紹介されていました。C++のフレームワークは珍しいなーと思い、どんな感じか試してみました。

oatpp-examples のリポジトリで色んなサンプルが紹介されています。今回は crud を参考にREST風のAPIサーバーを作ってみます。

検証環境

  • macOS: 10.13.6
  • Command Line Tools for Xcode: 10.0.0.0.1.1535735448
  • Premake: 5.0 alpha 13
  • CMake: 3.9.0

oat++?

特徴

公式サイトによると以下の特徴があるようです (※意訳です)

  • めっちゃ早い
  • 依存ライブラリなし (他のライブラリを導入せずに使える)
  • 非同期処理/マルチスレッド対応
  • RESTフレームワーク
  • シンプルなDI(dependency injection)機構あり
  • シンプルなテスト機構あり
  • HTTP1.1対応 (2.0も対応予定)

詳細は 公式サイト を確認ください。

構成

フレームワークは結構薄めの作りになってる印象です。主に以下のコンポーネントを実装していきます。

  • ApiController
    • APIのコントローラ
  • DtoObject
    • データ転送用のオブジェクト (DTO=Data Transfer Object)

HTTPルーティングやTCP接続処理はoat++ライブラリのものを利用できます。

試してみる

先述の通り crud を参考にREST風のAPIサーバーを作ってみます。crudのサンプルではユーザを扱っていますが、今回は本のデータに変えてみます。また、単純化のため同期APIで実装し、swaggerの機能は除外します。この2つはoat++でも特に重要な部分と思うので、別の記事で投稿したいと思います。

(2018/10/31 追記) Swaggerについての記事を追加しました。

プロジェクトを作る

まず、プロジェクトのフォルダ構成を作ります。crudと同様の構成にしてみます。

# 外部ライブラリ(oat++)の配置フォルダを作る
$ mkdir -p oatpp-book/external  

# ソース配置フォルダを作る
$ mkdir -p oatpp-book/src/controller
$ mkdir -p oatpp-book/src/db
$ mkdir -p oatpp-book/src/dto

次に、oatppをクローンしてビルドします。

# リポジトリをクローンする
$ cd oatpp-book/external
$ git clone https://github.com/oatpp/oatpp

# oat++をビルドする
$ mkdir -p oatpp/build
$ cd oatpp/build
$ cmake -G "Unix Makefiles" ..
$ make -j$(sysctl -n hw.ncpu)  # macOSの場合、CPU使用率を抑えたい場合は -jの指定を省略する

今回はデバッグしやすいように上記の指定でビルドしました。実際のサービスで利用する場合は、cmakeのオプションに -DCMAKE_BUILD_TYPE=Release を指定しましょう。

最後に、 oatpp-book のプロジェクトを作ります。今回は Premake を使って、GNU Makefileを作ります。Premakeについては下記の記事を確認ください。

workspace "oatpp-book"
    configurations {"Debug", "Release"}

    filter "configurations:Debug"
        defines { "DEBUG" }
        symbols "On"

    filter "configurations:Release"
        defines { "NDEBUG" }
        optimize "Full"

    filter { "action:gmake*" }
        location "./build-gmake"
        buildoptions {"-std=c++14"}

project "oatpp-book"
    kind "ConsoleApp"
    language "C++"

    includedirs {
        "external",
    }

    files {
        "src/**.cpp",
        "src/**.hpp",
    }

    libdirs {
        "external/oatpp/build"
    }

    links {
        "oatpp"
    }

premake5 コマンドでMakefile化しましょう。

$ premake5 gmake2

DTOを作る

本のデータをやりとりするためのクラスを作ります。

#pragma once

#include "oatpp/core/data/mapping/type/Object.hpp"
#include "oatpp/core/macro/codegen.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class BookDto : public oatpp::data::mapping::type::Object {

  DTO_INIT(BookDto, Object)

  DTO_FIELD(Int32, id);
  DTO_FIELD(String, title);
  DTO_FIELD(String, author);
  DTO_FIELD(String, isbn);
  DTO_FIELD(Int64, publish_at);
};

#include OATPP_CODEGEN_END(DTO)

サンプルコードを見てびっくりしました、マクロをフル活用して実装するみたいですね。DTOクラスを実装する場合は下記2点が重要なようです。

  • oatpp::data::mapping::type::Object をpublic継承すること
  • DTOクラスの宣言の前後を #include OATPP_CODEGEN_BEGIN(DTO)#include OATPP_CODEGEN_END(DTO) で囲む

コード生成マクロの実装を見た感じだと、コンストラクタ・ファクトリメソッド、型情報の取得メソッドなどのボイラープレートを自動で生成してくれるようです。

データベース操作クラスを作る

crud サンプルは具象クラスの Database をコンポーネントとして登録していますが、せっかくDI機能を利用するのでインタフェース化してみます。

まずインタフェースを定義します。

#pragma once

#include "../dto/book.hpp"

class IDatabase
{
public:
    using BookDtoList = oatpp::data::mapping::type::List<BookDto::ObjectWrapper>;

    virtual ~IDatabase() {}

    virtual BookDtoList::ObjectWrapper allBooks() = 0;
    virtual BookDto::ObjectWrapper getBook(v_int32 book_id) = 0;
    virtual BookDto::ObjectWrapper putBook(const BookDto::ObjectWrapper& book_dto) = 0;
};

次に具象クラスを定義・実装します。今回はサンプルと同じくメモリ上のマップで実装します。時間がとれれば後日DynamoDBにも対応させてみたいと思います。

#pragma once

#include <unordered_map>

#include "oatpp/core/concurrency/SpinLock.hpp"

#include "model/book.hpp"
#include "database.hpp"

class MemoryDatabase : public IDatabase
{
public:
    using BookMap = std::unordered_map<v_int32, Book>;

    MemoryDatabase();

    BookDtoList::ObjectWrapper allBooks() override;
    BookDto::ObjectWrapper getBook(v_int32 book_id) override;
    BookDto::ObjectWrapper putBook(const BookDto::ObjectWrapper& book_dto) override;

private:
    oatpp::concurrency::SpinLock::Atom m_atom;
    BookMap m_books;
};
#include <functional>

#include "memory_database.hpp"

using SpinLock = oatpp::concurrency::SpinLock;
using Atom = SpinLock::Atom;

static Book serialize(const BookDto::ObjectWrapper& book_dto)
{
    const int id = book_dto->id ? book_dto->id->getValue() : 0;
    const auto title = book_dto->title ? book_dto->title->std_str() : std::string();
    const auto author = book_dto->author ? book_dto->author->std_str() : std::string();
    const auto isbn = book_dto->isbn ? book_dto->isbn->std_str() : std::string();
    const auto publish_at = book_dto->publish_at ? book_dto->publish_at->getValue() : 0;
    return Book(id, title, author, isbn, publish_at);
}

static BookDto::ObjectWrapper deserialize(const Book& book)
{
    auto book_dto = BookDto::createShared();
    if (book.id() != 0)  book_dto->id = book.id();
    if (!book.title().empty()) book_dto->title = book.title().c_str();
    if (!book.author().empty()) book_dto->author = book.author().c_str();
    if (!book.isbn().empty()) book_dto->isbn = book.isbn().c_str();
    if (book.publishAt() != 0)  book_dto->publish_at = book.publishAt();

    return book_dto;
}

template <typename T>
static T withLock(Atom& atom, MemoryDatabase::BookMap& books, std::function<T(MemoryDatabase::BookMap&)> f)
{
    SpinLock lock(atom);
    return f(books);
}

MemoryDatabase::MemoryDatabase()
    : m_atom(false)
    , m_books()
{
}

MemoryDatabase::BookDtoList::ObjectWrapper MemoryDatabase::allBooks()
{
    return withLock<MemoryDatabase::BookDtoList::ObjectWrapper>(m_atom, m_books, [](BookMap& books) {
        auto result = oatpp::data::mapping::type::List<BookDto::ObjectWrapper>::createShared();
        for (const auto& pair : books) {
            result->pushBack(deserialize(pair.second));
        }

        return result;
    });
}

BookDto::ObjectWrapper MemoryDatabase::getBook(v_int32 book_id)
{
    return withLock<BookDto::ObjectWrapper>(m_atom, m_books, [book_id](BookMap& books) {
        auto it_book = books.find(book_id);
        return it_book != std::end(books)
            ? deserialize(it_book->second)
            : nullptr
            ;
    });
}

BookDto::ObjectWrapper MemoryDatabase::putBook(const BookDto::ObjectWrapper& book_dto)
{
    return withLock<BookDto::ObjectWrapper>(m_atom, m_books, [this, &book_dto](BookMap& books) {
        books.emplace(book_dto->id->getValue(), serialize(book_dto));
        return book_dto;
    });
}

コントローラを作る

コントローラも同様にサンプルを参考に実装します。

#pragma once

#include "oatpp/web/server/HttpError.hpp"
#include "oatpp/web/server/api/ApiController.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/macro/component.hpp"

#include "../db/database.hpp"

class BookController : public oatpp::web::server::api::ApiController {
public:
    BookController(const std::shared_ptr<ObjectMapper>& object_mapper)
        : oatpp::web::server::api::ApiController(object_mapper)
    {
    }

private:
    OATPP_COMPONENT(std::shared_ptr<IDatabase>, m_database); // インタフェースで受け取る

public:
    static std::shared_ptr<BookController> createShared(OATPP_COMPONENT(std::shared_ptr<ObjectMapper>, object_mapper))
    {
        return std::make_shared<BookController>(object_mapper);
    }

#include OATPP_CODEGEN_BEGIN(ApiController)

    ENDPOINT("GET", "api/books", allBooks) {
        return createDtoResponse(Status::CODE_200, m_database->allBooks());
    }

    ENDPOINT("GET", "api/books/{book_id}", getBook, PATH(Int32, book_id)) {
        const auto book = m_database->getBook(book_id);
        OATPP_ASSERT_HTTP(book, Status::CODE_404, "book not found");
        return createDtoResponse(Status::CODE_200, book);
    }

    ENDPOINT("PUT", "api/books/{book_id}", putBook,
             PATH(Int32, book_id),
             BODY_DTO(BookDto::ObjectWrapper, book_dto)) {
        book_dto->id = book_id;
        return createDtoResponse(Status::CODE_200, m_database->putBook(book_dto));
    }

#include OATPP_CODEGEN_END(ApiController)
};

コントローラーの場合は、 ENDPOINT の宣言箇所を #include OATPP_CODEGEN_BEGIN(ApiController)#include OATPP_CODEGEN_END(ApiController) で囲みます。URLのパスと本文は以下の宣言で変数として使用できるようになります。

  • URIのパスは PATH マクロで宣言する
  • リクエストの本文は BODY マクロで宣言する
    • DTOオブジェクトとして受け取るときは BODY_DTO マクロを使う
    • 文字列として受け取るときは BODY_STRING マクロを使う

URLのquery-stringは使えないのかな?と思ってGitHubで質問してみたらちゃんと対応してました。以下の手順で取得できます。

  • REQUEST マクロでリクエストオブジェクトを受け取る
  • getPathTail() でパスより後方の指定を取得する
  • oatpp::network::Url::Parser::parseQueryParams で解析する
  • queryParam->get("param_name", "default_value") で値を取得する

詳細な使い方はGitHubのIssueで確認ください

HTTPサーバを作る

まず、サーバーで使うコンポーネントをインスタンス化します。 crudのAppComponent とほぼ同様ですが、以下の点を変更しています。

  • Swaggerのコンポーネントを削除
  • ポート番号を 80008002 に変更 (私の環境だと8000使用中のため)
  • Database をインタフェース化
#pragma once

#include "oatpp/web/server/HttpConnectionHandler.hpp"
#include "oatpp/web/server/HttpRouter.hpp"
#include "oatpp/network/server/SimpleTCPConnectionProvider.hpp"
#include "oatpp/parser/json/mapping/Serializer.hpp"
#include "oatpp/parser/json/mapping/Deserializer.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "oatpp/core/macro/component.hpp"

#include "db/database.hpp"
#include "db/memory_database.hpp"

class AppComponent {
public:
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>, serverConnectionProvider)([] {
    return oatpp::network::server::SimpleTCPConnectionProvider::createShared(8002);
  }());

  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, httpRouter)([] {
    return oatpp::web::server::HttpRouter::createShared();
  }());

  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::server::ConnectionHandler>, serverConnectionHandler)([] {
    OATPP_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, router); // get Router component
    return oatpp::web::server::HttpConnectionHandler::createShared(router);
  }());

  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::data::mapping::ObjectMapper>, apiObjectMapper)([] {
    auto serializerConfig = oatpp::parser::json::mapping::Serializer::Config::createShared();
    auto deserializerConfig = oatpp::parser::json::mapping::Deserializer::Config::createShared();
    deserializerConfig->allowUnknownFields = false;
    auto objectMapper = oatpp::parser::json::mapping::ObjectMapper::createShared(serializerConfig, deserializerConfig);
    return objectMapper;
  }());

  OATPP_CREATE_COMPONENT(std::shared_ptr<IDatabase>, database)([] {
    return std::make_shared<MemoryDatabase>();
  }());
};

最後に main 関数を実装します。ここまでに実装したコントローラー・コンポーネントをHTTPサーバーに組み込みます。なお、Loggerについては crud サンプルの実装をそのまま使用します。

#include <iostream>

#include "controller/book_controller.hpp"
#include "app_component.hpp"
#include "logger.hpp"

using namespace std;

static void run()
{
    AppComponent components;

    auto router = components.httpRouter.getObject();

    auto book_controller = BookController::createShared();
    book_controller->addEndpointsToRouter(router);

    oatpp::network::server::Server server(components.serverConnectionProvider.getObject(),
                                          components.serverConnectionHandler.getObject());
    OATPP_LOGD("Server", "Running on port %u...", components.serverConnectionProvider.getObject()->getPort());
    server.run();
}

int main(int argc, char** argv)
{
    oatpp::base::Environment::setLogger(new Logger());
    oatpp::base::Environment::init();

    run();

    oatpp::base::Environment::setLogger(nullptr);
    oatpp::base::Environment::destroy();

    return 0;
}

ビルドして動かす

最後に、ビルドして動かしてみます。ソースを追加したのでMakefileを更新します。

$ premake5 gmake2

build-gmakeフォルダ内にMakefileが生成されます。cdコマンドで移動してビルドします。

$ cd build-gmake
$ make -j$(sysctl -n hw.ncpu)
==== Building oatpp-book (debug) ====
Creating obj/Debug
Creating bin/Debug
book.cpp
main.cpp
memory_database.cpp
Linking oatpp-book

ビルドが成功したらサーバーを起動してみます。

$ cd ./bin/Debug
$ ./oatpp-book
Server:Running on port 8002...

無事起動しましたね!別のターミナルからcurlコマンドで動かしてみましょう。

# 最初は登録データなし
$ curl http://localhost:8002/api/books
[]

# 本を追加
$ curl -XPUT http://localhost:8002/api/books/1 -d '{"title": "Developers.IO", "author": "Classmethod, Inc.", "publish_at": 1540725959}'
{"id": 1, "title": "Developers.IO", "author": "Classmethod, Inc.", "isbn": null, "publish_at": 1540725959}

$ curl -XPUT http://localhost:8002/api/books/2 -d '{"title": "Lorem ipsum", "author": "John Doe", "publish_at": 1540726193}'
{"id": 2, "title": "Lorem ipsum", "author": "John Doe", "isbn": null, "publish_at": 1540726193}

# 一覧を取得
$ curl http://localhost:8002/api/books
[{"id": 2, "title": "Lorem ipsum", "author": "John Doe", "isbn": null, "publish_at": 1540726193}, {"id": 1, "title": "Developers.IO", "author": "Classmethod, Inc.", "isbn": null, "publish_at": 1540725959}]

# 個別アクセス
$ curl http://localhost:8002/api/books/1
{"id": 1, "title": "Developers.IO", "author": "Classmethod, Inc.", "isbn": null, "publish_at": 1540725959}

$ curl http://localhost:8002/api/books/2
{"id": 2, "title": "Lorem ipsum", "author": "John Doe", "isbn": null, "publish_at": 1540726193}

ちゃんと動きましたね!

おわりに

oat++を試す前はC++でAPIサーバーは辛いんじゃないかなーと思っていたんですが、意外と簡単にAPIサーバーを実装することができました。外部ライブラリの依存関係が一切ないというのも導入しやすくてありがたいですね。

コントローラーのURLパスやリクエスト・レスポンスの扱いはoat++の流儀に従う必要がありますが、そこから先の処理は比較的自由な作りにできそうです。

今回は同期APIだけしか試しませんでしたが、非同期APIモードもあるようなのでいずれ試してみたいです。