🚀

Rust vs. Go: Implementing a REST API in SQLite

2023/11/12に公開

Backend の開発言語選定について、現職では Go を推す声が強い。

なぜなら、現状の技術選定に、規律がないからだ。Java、Python、Ruby、PHP、Node.js、Go、Kotlin and more.

Web 業界では、Go が popular になっているし、現職の 2B・2C 向けサービスも Go を多く使っている。

僕個人として、Go を選択することに異論はない。

一方で、次を見据えて考えておく必要もある。

何が言いたいかというと、より最高な選択肢は何かを常に考えていきたい。思考停止は退化。

Rust について、

  • tutorial
  • gRPC
  • Rust + Wasm + Cloudflare Workers
  • REST

と続けてきて、syntax は身について来た。

より実践的な課題を解けるように実践を重ねていく。そして、AtCoder の algorithm 問題についても解いていく。

本題

今回も、REST + Database の API を Rust で実装し、それを Go で rewrite した。

database は、扱いやすさの観点から SQLite にした。

Rust、Go のコードを全部消して、ゼロから実装できるところまで完了した。

次は、Advent Calendar に向けて、何かお題を考えていく。Rust で Otel の tracer を作るなど。

コードはこちら。

https://github.com/danny-yamamoto/rust-api-samples/tree/main/users

https://github.com/danny-yamamoto/go-api-samples/tree/main/cmd/users

Rust Web Server: /users

実装の手順

  1. Response 用の struct User を書く
  2. Request 用の struct UserQuery を書く
  3. Handler users_handler を書く。Query と IntoResponse 以外。
  4. main を書く
  5. Query と IntoResponse を書く
  6. db との整合性をチェック cargo sqlx prepare --database-url "sqlite:./local.db"
  7. rust-analyzer の警告に対応する
Cargo.toml
[package]
name = "users"
version = "0.1.0"
edition = "2021"

[dependencies]
dotenv = "0.15.0"
serde = { version = "1.0.192", features = ["derive"] }
serde_json = "1.0.108"
sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio-native-tls", "chrono", "runtime-tokio", "macros"] }
tokio = { version = "1.33.0", features = ["full"] }
axum = "0.6.20"
main.rs
use std::{env, sync::Arc, net::{SocketAddr, IpAddr, Ipv4Addr}};

use axum::{extract::Query, Router, routing::get, Extension, http::StatusCode, Json, response::IntoResponse};
use serde::{Serialize, Deserialize};
use dotenv::dotenv;
use sqlx::SqlitePool;

#[derive(Serialize)]
struct User {
    user_id: i64,
    email_address: Option<String>,
    created_at: Option<i64>,
    deleted: Option<i64>,
    settings: Option<String>,
}

#[derive(Deserialize)]
struct UserQuery {
    user_id: i64,
}

async fn users_handler(Query(query):Query<UserQuery>, Extension(pool):Extension<Arc<SqlitePool>>) -> impl IntoResponse {
    let selected_user_id = query.user_id;
    match sqlx::query_as!(User, "select user_id, email_address, created_at, deleted, settings from users where user_id = ?", selected_user_id).fetch_optional(&*pool).await {
        Ok(user) => (StatusCode::OK, Json(user)),
        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(None::<User>)),
    }
}

#[tokio::main]
async fn main() {
    dotenv().ok();
    let key = "DATABASE_URL";
    let db_url = env::var(key).expect("key not found.");
    let pool = SqlitePool::connect(&db_url).await.expect("cannot connect.");
    let shared_pool = Arc::new(pool);
    let app = Router::new()
        .route("/users", get(users_handler))
        .layer(Extension(shared_pool));
    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

GPT-4 の解説

このRustコードは、Webサーバとして機能し、SQLiteデータベースを使用してユーザー情報を提供するAPIを実装しています。コードの主要な要素を分析し、その機能を説明します。

  1. ライブラリのインポート:
    std、axum、serde、dotenv、sqlxといった複数のライブラリがインポートされています。これらは標準ライブラリ、HTTPサーバー機能、シリアライズ/デシリアライズ、環境変数の読み込み、データベース操作に必要です。
  2. ユーザー構造体の定義:
    UserとUserQueryという2つの構造体が定義されています。Userはユーザー情報を表し、Serializeトレイトを使用してJSON形式で出力可能です。UserQueryはクエリパラメータを表し、DeserializeトレイトでHTTPリクエストからデータを取得します。
  3. ユーザー情報取得ハンドラ:
    users_handler関数は、指定されたユーザーIDに基づいてデータベースからユーザー情報を取得します。QueryとExtensionは、それぞれクエリパラメータとデータベース接続プールを取得するために使用されます。
  4. メイン関数:
    tokio::mainアトリビュートは、非同期ランタイムを表します。環境変数からデータベースURLを取得し、SqlitePoolを介してSQLiteデータベースに接続します。Routerを使用してエンドポイント(/users)を定義し、users_handlerを関連付けます。アドレス127.0.0.1:8080でサーバーを起動し、HTTPリクエストを待機します。このコードは、指定されたユーザーIDをクエリパラメータとして受け取り、そのIDに関連するユーザー情報をデータベースから検索してJSON形式で返す簡易的なREST APIを提供します。エラーハンドリングやセキュリティ機能は基本的ですが、これはサーバーサイドのWebアプリケーションの典型的な構造を示しています。

sqlx により、実行前に Query の検証を行う。

vscode ➜ /workspaces/rust-api-samples/users (main) $ cargo sqlx prepare --database-url "sqlite:./local.db"
    Checking users v0.1.0 (/workspaces/rust-api-samples/users)
    Finished dev [unoptimized + debuginfo] target(s) in 5.14s
query data written to .sqlx in the current directory; please check this into version control
  • 実行
vscode ➜ /workspaces/rust-api-samples/users (main) $ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 2.83s
     Running `target/debug/users`
  • テスト
vscode ➜ /workspaces/rust-api-samples (main) $ curl "localhost:8080/users?user_id=1" -i
HTTP/1.1 200 OK
content-type: application/json
content-length: 90
date: Sun, 12 Nov 2023 03:23:25 GMT

{"user_id":1,"email_address":"maria@example.com","created_at":0,"deleted":0,"settings":""}
vscode ➜ /workspaces/rust-api-samples (main) $ 

Rust to Go Conversion

実装の手順

  1. User 構造体を書く。Rust と違い colon が不要。
  2. Handler を書く
  3. Handler のインスタンスを書く
  4. Response 用の関数 respondWithJSON を書く
  5. UserHandler を書く
  6. main を書く
  7. blank import を忘れずに書く
main.go
package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"

	_ "github.com/mattn/go-sqlite3"
)

type User struct {
	UserId       int64  `json:"user_id"`
	EmailAddress string `json:"email_address"`
	CreatedAt    int64  `json:"created_at"`
	Deleted      int64  `json:"deleted"`
	Settings     string `json:"settings"`
}

type Handler struct {
	db *sql.DB
}

func NewHandler(db *sql.DB) *Handler {
	return &Handler{db: db}
}

func respondWithJSON(w http.ResponseWriter, statusCode int, payload interface{}) {
	response, _ := json.Marshal(payload)
	w.Header().Add("Content-Type", "application/json")
	w.WriteHeader(statusCode)
	w.Write(response)
}

func (h Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
	userId := r.URL.Query().Get("user_id")
	var user User
	err := h.db.QueryRow("select * from users where user_id = ?", userId).Scan(&user.UserId, &user.EmailAddress, &user.CreatedAt, &user.Deleted, &user.Settings)
	if err != nil {
		fmt.Println(err)
		respondWithJSON(w, http.StatusInternalServerError, err)
		return
	}
	respondWithJSON(w, http.StatusOK, user)
}

func main() {
	key := "DATABASE_URL"
	dbUrl := os.Getenv(key)
	client, err := sql.Open("sqlite3", dbUrl)
	if err != nil {
		fmt.Printf("Failed to create connection: %s", err)
		return
	}
	defer client.Close()
	handler := NewHandler(client)
	http.HandleFunc("/users", handler.UserHandler)
	port := "8080"
	addr := fmt.Sprintf("0.0.0.0:%s", port)
	fmt.Printf("Listening on http://%s\n", addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}

Discussion