Rust対Go:実践的な比較

 
0
このエントリーをはてなブックマークに追加
Daichi Takayama
Daichi Takayama (高山 大地)

以下の文章はCorode Rust Consultingからの許可を得て翻訳したものです。

https://www.shuttle.rs/blog/2023/09/27/rust-vs-go-comparison

「また、Rust対Goの比較記事か…」と思っていませんか?確かに、この2つの比較記事は数多く存在します。それでも、本稿では異なる視点からの比較を行いたいと思います。

これまでの比較記事の多くでは、GoとRustの構文の違いや初期段階の学習の違いに焦点が当てられています。しかし、私たちが強調したいのは「実際のプロジェクトでの実用性」です。

私たちはPaaSとして、実際にGoとRustを使用しながら、どのようにウェブサービス構築していくのか、実演しながらの比較を行うことができます。両言語で同じタスクに取り組み、主要なライブラリを使用しながら結果を比較すれば、どちらのエコシステムで作業した方が効率的か、判断していただけるかと思います。

これまでと同じような比較だとは思わずに、ぜひこの記事をご一読ください。これまでの議論で触れられなかった新しい視点や情報が含まれているでしょう。

従来の「Rust対Go」議論

Rust対Goは何度も取り上げられているテーマで、すでに多くの情報が飛び交ってます。なぜかというと、開発者たちが次のウェブプロジェクトにどの言語を使用すべきかの情報を求める際、この2つの言語が議題に上がることが多いからです。しかし、我々が調査したところ、このテーマを深く掘り下げられている記事はあまり存在しません。現状として、開発者たちは自分たちでこの問題を解決しなければならず、誤解したまま、片方の選択肢を早急に排除してしまうリスクがあります。

両コミュニティとも、誤解や偏見に直面しています。Rustを主にシステムプログラミング言語として見ている人もいれば、Goは過度に単純化されているものだとレッテルを貼り、複雑なウェブアプリケーションの取り扱い能力を疑う人もいます。しかし、このような見方は非常に表面的です。

実際のところ、両言語とも迅速で信頼性の高いウェブサービスを書くのに適しています。しかし、アプローチが大きく異なるため、両言語に公平であろうとする質の高い比較を見つけることが難しくなるのです。

本稿では両言語を使いながら実際にアプリケーションを構築することにより、ウェブ開発に焦点を当てたまま、GoとRustの違いを概観します。構文を超えて、ルーティング、ミドルウェア、テンプレート化、データベースアクセスなどの典型的なウェブタスクの取り扱い方法について詳しく見ていきましょう。

この記事を読み終える頃にはどちらの言語があなたに適しているか、検討が着くはずです。

もちろん、私たちも自分たちの偏見や好みがあることを認識しています。しかし、可能な限り客観的に、両方の言語の強みと弱みを強調させていただきます。

小さなWebサービスの構築

以下のトピックをカバーします:

  • ルーティング
  • テンプレーティング
  • データベースアクセス
  • デプロイメント

クライアントサイドのレンダリングやマイグレーションのようなトピックは除外し、サーバーサイドに焦点を当てます。

タスク

ウェブ開発を代表するタスクを選ぶのは簡単ではありません。両方の言語の機能やライブラリに焦点を当てるためにはシンプルなタスクを選ぶべきです。しかし、可能な限り現実に近い状況下で両言語の機能やライブラリを取り上げたいので、タスクがシンプルになりすぎないよう注意しなければなりません。

そこで、今回は天気予報サービスを構築することにしました。ユーザーが都市を入力すれば、その土地の現在の天気予報を取得できるものです。「最近検索された都市」のリストも表示できるものにしましょう。

サービスを拡張するにつれて、以下の機能も追加します:

  • 天気予報を表示するためのシンプルなUI
  • 最近検索された都市を保存するためのデータベース

天気API

天気予報には、Open-Meteo APIを使用します。これはオープンソースで、使いやすく、1日最大10,000リクエストまでの非営利利用が可能だという、非常に寛大な無料枠を提供しています。

次の2つのAPIエンドポイントを使用します:

実際にサービスを構築するのであれば、Go(omgo)とRust(openmeteo)の両方で使用可能なライブラリがありますが、今回は比較のために、両言語で「生」のHTTPリクエストを行い、レスポンスを独自のデータ構造に変換する方法を見ていきます。

GoのWebサービス

Webフレームワークの選択

もともとウェブサービスの構築を簡素化するために作られたGoには、ウェブ関連の素晴らしいパッケージがたくさんあります。標準ライブラリがあなたのニーズを満たしていない場合、GinEchoChiなど、多くの人気のあるサードパーティー製のWebフレームワークが選択肢としてあります。

どれを選ぶかは好みの問題です。経験豊富なGo開発者の中には、標準ライブラリを使用し、その上でChiのようなルーティングライブラリを追加することを好む人もいれば、GinやEchoのような全機能を備えたフレームワークを使用する、バッテリー同梱型のアプローチを好む人もいます。

どちらの選択肢も良いですが、この記事では比較を行いやすいGinを選ぶことにしました。Ginは、もっとも人気のあるフレームワークの一つであり、今回の天気予報サービスに必要なすべての機能をサポートしているからです。

HTTPリクエストの実行

Open Meteo APIに対するHTTPリクエストを行い、レスポンスボディを文字列として返すシンプルな関数から始めてみましょう:

func getLatLong(city string) (*LatLong, error) {
    endpoint := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=en&format=json", url.QueryEscape(city))
    resp, err := http.Get(endpoint)
    if err != nil {
   	 return nil, fmt.Errorf("error making request to Geo API: %w", err)
    }
    defer resp.Body.Close()

    var response GeoResponse
    if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
   	 return nil, fmt.Errorf("error decoding response: %w", err)
    }

    if len(response.Results) < 1 {
   	 return nil, errors.New("no results found")
    }

    return &response.Results[0], nil
}

この関数は都市の名前を引数として受け取り、都市の座標をLatLong構造体として返します。

各ステップ後のエラー処理方法に注目してください。HTTPリクエストが成功したか、レスポンスボディがデコードできたか、またレスポンスに結果が含まれているかどうかを確認しています。これらのいずれかのステップが失敗した場合、エラーを返して中断します。ここまでは標準ライブラリのみの使用で済みました。

defer文は、関数が返る後でレスポンスボディが閉じることを保証します。これはGoにおける非常に一般的なリソースリークを防ぐ手段です。これを忘れてしまうと、コンパイラからの警告はありませんので注意が必要です。

エラー処理はコードの大部分を占めます。単純ではありますが、書くのに手間がかかり、読むときにも複雑に感じるかもしれません。しかし、理解するのは簡単で、エラーが発生した場合の動作も明確です。

APIは結果のリストを持つJSONオブジェクトを返すため、そのレスポンスに合わせた構造体を定義する必要があります:

type GeoResponse struct {
    // コメント:結果のリスト。最初のものだけが必要です。
    Results []LatLong `json:"results"`
}

type LatLong struct {
    Latitude  float64 `json:"latitude"`
    Longitude float64 `json:"longitude"`
}

jsonタグは、JSONのフィールドを構造体のフィールドにマッピングする方法をJSONデコーダに伝えます。JSONレスポンスの余分なフィールドはデフォルトで無視されます。

LatLong構造体を取り、その場所の天気予報を返す別の関数を定義しましょう:

func getWeather(latLong LatLong) (string, error) {
    endpoint := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.6f&longitude=%.6f&hourly=temperature_2m", latLong.Latitude, latLong.Longitude)
    resp, err := http.Get(endpoint)
    if err != nil {
   	 return "", fmt.Errorf("error making request to Weather API: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
   	 return "", fmt.Errorf("error reading response body: %w", err)
    }

    return string(body), nil
}

まず、これらの2つの関数を順番に呼び出し、結果を表示しましょう:

func main() {
    latlong, err := getLatLong("London") // コメント:ロンドンだと「雨」の予報しかないはずですが...
    if err != nil {
   	 log.Fatalf("Failed to get latitude and longitude: %s", err)
    }
    fmt.Printf("Latitude: %f, Longitude: %f\n", latlong.Latitude, latlong.Longitude)

    weather, err := getWeather(*latlong)
    if err != nil {
   	 log.Fatalf("Failed to get weather: %s", err)
    }
    fmt.Printf("Weather: %s\n", weather)
}

次の出力が表示されます:

Latitude: 51.508530, Longitude: -0.125740
Weather: {"latitude":51.5,"longitude":-0.120000124, ... }

良いですね!ロンドンの天気予報を取得できました。
では、これをWebサービスとして利用できるようにしましょう。

ルーティング

ルーティングは、Webフレームワークの最も基本的なタスクの1つです。
まず、プロジェクトにginを追加しましょう。

go mod init github.com/user/goforecast
go get -u github.com/gin-gonic/gin

次に、サーバーと、都市の名前をパラメーターとして取り、その都市の天気予報を返すルートでmain()関数を置き換えましょう。

Ginはパスパラメータとクエリパラメータの両方をサポートしています。

// コメント:パスパラメータ
r.GET("/weather/:city", func(c *gin.Context) {
   	 city := c.Param("city")
   	 // ...
})

// コメント:クエリパラメータ
r.GET("/weather", func(c *gin.Context) {
    city := c.Query("city")
    // ...
})

どちらを使用するかは、使用ケースによって異なります。
最終的にはフォームから都市名を送信したいので、クエリパラメータを使用します。

func main() {
    r := gin.Default()

    r.GET("/weather", func(c *gin.Context) {
   	 city := c.Query("city")
   	 latlong, err := getLatLong(city)
   	 if err != nil {
   		 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
   		 return
   	 }

   	 weather, err := getWeather(*latlong)
   	 if err != nil {
   		 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
   		 return
   	 }

   	 c.JSON(http.StatusOK, gin.H{"weather": weather})
    })

    r.Run()
}

別のターミナルで、サーバーをgo run .で起動し、リクエストを行いましょう:

curl "localhost:8080/weather?city=Hamburg"

天気予報が取得できます:

{"weather":"{\"latitude\":53.550000,\"longitude\":10.000000, ... }

ログの出力も良く、速度もかなり速いです!

[GIN] 2023/09/09 - 19:27:20 | 200 |   190.75625ms |   	127.0.0.1 | GET  	"/weather?city=Hamburg"
[GIN] 2023/09/09 - 19:28:22 | 200 |   46.597791ms |   	127.0.0.1 | GET  	"/wea

テンプレート

エンドポイントは出来ましたが、生のJSONは通常のユーザーにとってあまり有用ではありません。実際のアプリケーションでは、JSONレスポンスをAPIエンドポイント(例:/api/v1/weather/:city)で提供し、HTMLページを返す別のエンドポイントを追加する必要があるでしょう。今回は簡潔に留めたいので、HTMLページを直接返すことにします。

与えられた都市の天気予報をテーブルとして表示するシンプルなHTMLページも追加しましょう。標準ライブラリのhtml/templateパッケージを使用してHTMLページをレンダリングします。

まずはビューのための構造体をいくつか追加しましょう:

type WeatherData struct
type WeatherResponse struct {
    Latitude  float64 `json:"latitude"`
    Longitude float64 `json:"longitude"`
    Timezone  string  `json:"timezone"`
    Hourly	struct {
   	 Time      	[]string  `json:"time"`
   	 Temperature2m []float64 `json:"temperature_2m"`
    } `json:"hourly"`
}

type WeatherDisplay struct {
    City  	string
    Forecasts []Forecast
}

type Forecast struct {
    Date    	string
    Temperature string
}

これはJSONレスポンス内の関連性のあるフィールドを構造体に直接マッピングするものです。transformのように、JSONからGoの構造体への変換を容易にするツールもありますので、ぜひチェックしてみてください!

次に、天気APIからの生のJSONレスポンスを新しいWeatherDisplay構造体に変換する関数を定義します:

func extractWeatherData(city string, rawWeather string) (WeatherDisplay, error) {
    var weatherResponse WeatherResponse
    if err := json.Unmarshal([]byte(rawWeather), &weatherResponse); err != nil {
   	 return WeatherDisplay{}, fmt.Errorf("error decoding weather response: %w", err)
    }

    var forecasts []Forecast
    for i, t := range weatherResponse.Hourly.Time {
   	 date, err := time.Parse(time.RFC3339, t)
   	 if err != nil {
   		 return WeatherDisplay{}, err
   	 }
   	 forecast := Forecast{
   		 Date:    	date.Format("Mon 15:04"),
   		 Temperature: fmt.Sprintf("%.1f°C", weatherResponse.Hourly.Temperature2m[i]),
   	 }
   	 forecasts = append(forecasts, forecast)
    }
    return WeatherDisplay{
   	 City:  	city,
   	 Forecasts: forecasts,
    }, nil
}

日付の処理は組み込みのtimeパッケージで行います。
Goの日付処理についての詳細は、こちらの記事をご参照ください。

次に、HTMLページをレンダリングするようにルートハンドラを拡張します:

r.GET("/weather", func(c *gin.Context) {
    city := c.Query("city")
    latlong, err := getLatLong(city)
    if err != nil {
   	 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
   	 return
    }

    weather, err := getWeather(*latlong)
    if err != nil {
   	 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
   	 return
    }

    //////// コメント:ここから新しいコード ////////
    weatherDisplay, err := extractWeatherData(city, weather)
    if err != nil {
   	 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
   	 return
    }
    c.HTML(http.StatusOK, "weather.html", weatherDisplay)
    //////////////////////////////////////
})

次にテンプレートを作成しましょう。
viewsという名前のテンプレートディレクトリを作成し、Ginにそれを知らせます:

r := gin.Default()
r.LoadHTMLGlob("views/*")

そして、viewsディレクトリにweather.htmlというテンプレートファイルを作成します:

<!DOCTYPE html><html><head><title>Weather Forecast</title></head><body><h1>Weather for {{ .City }}</h1><table border="1"><tr><th>Date</th><th>Temperature</th></tr>
			{{ range .Forecasts }}
			<tr><td>{{ .Date }}</td><td>{{ .Temperature }}</td></tr>
			{{ end }}
		</table></body></html>

(テンプレート使用方法の詳細については、こちらをご参照ください。)

これで、与えられた都市の天気予報をHTMLページとして返す動作をするWebサービスができました!

そうですね… 都市名を入力することでその都市の天気予報を表示する入力フィールドを持つ、インデックスページも作っておいた方が良いかもしれません。

インデックスページの新しいルートハンドラを追加しましょう:

r.GET("/", func(c *gin.Context) {
    c.HTML(http.StatusOK, "index.html", nil)
})

新しいテンプレートファイルindex.htmlも作成します:

<!DOCTYPE html><html><head><title>Weather Forecast</title></head><body><h1>Weather Forecast</h1><form action="/weather" method="get"><label for="city">City:</label><input type="text" id="city" name="city" /><input type="submit" value="Submit" /></form></body></html>

これでWebサービスを開始し、ブラウザでhttp://localhost:8080を開くことが可能になります。

rust-vs-go-index

ロンドンの天気予報は以下のように表示されます。見た目はあまり良くありませんが、しっかりと機能しています。(JavaScriptなしでも、ターミナルブラウザでも動作します!)

rust-vs-go-forecast

練習のためにHTMLページにスタイルを追加することもできますが、ここではバックエンドに焦点を当てたいので、この辺りで終わりにしましょう。

データベースへのアクセス

このサービスは、各リクエストごとに外部APIから都市の緯度と経度を取得します。最初はそれで問題ないかもしれませんが、最終的には不要なAPI呼び出しを避けるために結果をデータベースにキャッシュした方が良いでしょう。

そこで、Webサービスにデータベースを追加することにします。
データベースとしてPostgreSQLを、データベースドライバとしてsqlxを使用します。

まず、データベースを初期化するためにinit.sqlというファイルを作成します:

CREATE TABLE IF NOT EXISTS cities (
	id SERIAL PRIMARY KEY,
	name TEXT NOT NULL,
	lat NUMERIC NOT NULL,
	long NUMERIC NOT NULL
);

CREATE INDEX IF NOT EXISTS cities_name_idx ON cities (name);

指定された都市の緯度と経度を保存します。
SERIALタイプは、PostgreSQLの自動インクリメント整数です。
高速化のために、name列にもインデックスを追加します。

Dockerなど、何らかのクラウドプロバイダーを使用するのが最も簡単でしょう。結局のところ、環境変数としてWebサービスに渡すことのできるデータベースURLさえあれば良いのです。

ここではデータベースのセットアップの詳細には触れませんが、ローカルでPostgreSQLデータベースを動作させる簡単な方法は次のとおりです:

docker run -p 5432:5432 -e POSTGRES_USER=forecast -e POSTGRES_PASSWORD=forecast -e POSTGRES_DB=forecast -v `pwd`/init.sql:/docker-entrypoint-initdb.d/index.sql -d postgres
export DATABASE_URL="postgres://forecast:forecast@localhost:5432/forecast?sslmode=disable"

データベースを手に入れたら、go.modファイルにsqlxの依存関係を追加する必要があります。

go get github.com/jmoiron/sqlx

これで、DATABASE_URL環境変数からの接続文字列を使うことで、sqlxパッケージを使用しながらデータベースに接続することができます。

_ = sqlx.MustConnect("postgres", os.Getenv("DATABASE_URL"))

これでデータベース接続は完成しました!

では、データベースに都市を挿入する関数を追加しましょう。
先ほどのLatLong構造体を使用します。

func insertCity(db *sqlx.DB, name string, latLong LatLong) error {
    _, err := db.Exec("INSERT INTO cities (name, lat, long) VALUES ($1, $2, $3)", name, latLong.Latitude, latLong.Longitude)
    return err
}

getLatLong関数の名前をfetchLatLongに変更し、外部APIの代わりにデータベースを使用する新しいgetLatLong関数を追加しましょう:

func getLatLong(db *sqlx.DB, name string) (*LatLong, error) {
    var latLong *LatLong
    err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
    if err == nil {
   	 return latLong, nil
    }

    latLong, err = fetchLatLong(name)
    if err != nil {
   	 return nil, err
    }

    err = insertCity(db, name, *latLong)
    if err != nil {
   	 return nil, err
    }

    return latLong, nil
}

ここでは、getLatLong関数に直接db接続を渡しますが、実際のプロジェクトであれば、データベースアクセスをAPIロジックから分離して、テストを可能にする必要があります。
おそらく、不要なデータベース呼び出しを避けるためにインメモリキャッシュも使用することになるでしょう。今回は比較のみです。

ハンドラを更新する必要があります:

r.GET("/weather", func(c *gin.Context) {
    city := c.Query("city")
    // コメント:dbをここに
    latlong, err := getLatLong(db, city)
    // ...
})

これで、指定された都市の緯度と経度をデータベースに保存し、新しいリクエストの際にはそこからデータを取得できるWebサービスが完成しました。

ミドルウェア

最後のステップは、Webサービスにミドルウェアを追加することです。
すでにGinから素晴らしいロギングが無料で提供されています。

Basic認証ミドルウェアを追加し、最後の検索クエリを表示するために使用する/statsエンドポイントを保護しましょう。

r.GET("/stats", gin.BasicAuth(gin.Accounts{
   	 "forecast": "forecast",
    }), func(c *gin.Context) {
   	 // コメント:ハンドラの残り
    }
)

以上!

ヒント:複数のルートに一度で認証を適用するために、ルートをまとめてグループ化することも可能です。

データベースから最後の検索クエリを取得するロジックは以下のとおりです:

func getLastCities(db *sqlx.DB) ([]string, error) {
    var cities []string
    err := db.Select(&cities, "SELECT name FROM cities ORDER BY id DESC LIMIT 10")
    if err != nil {
   	 return nil, err
    }
    return cities, nil
}

さて、最後の検索クエリを表示するために /stats エンドポイントを設定しましょう。

r.GET("/stats", gin.BasicAuth(gin.Accounts{
   	 "forecast": "forecast",
    }), func(c *gin.Context) {
   	 cities, err := getLastCities(db)
   	 if err != nil {
   		 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
   		 return
   	 }
   	 c.HTML(http.StatusOK, "stats.html", cities)
})

stats.html テンプレートも十分シンプルです:

<!DOCTYPE html><html><head><title>Latest Queries</title></head><body><h1>Latest Lat/Long Lookups</h1><table border="1"><tr><th>Cities</th></tr>
			{{ range . }}
			<tr><td>{{ . }}</td></tr>
			{{ end }}
		</table></body></html>

しっかりと動作するWebサービスが完成しました!

以下の機能を実現しました:

  • 外部APIから指定された都市の緯度と経度を取得するWebサービス
  • データベースに緯度と経度を保存
  • その後のリクエストでデータベースから緯度と経度を取得
  • /stats エンドポイントで最後の検索クエリを表示
  • /stats エンドポイントを保護するためのBasic認証
  • リクエストをログとして残すミドルウェアの使用
  • HTMLテンプレートでのレンダリング

たった数行のコードにしては、多くの機能を持っていると言えるでしょう!
Rustはこれにどのように対抗できるか、見ていきましょう!

RustでのWebサービス開発

RustがWebサービスの開発言語として注目されるようになるまでには少し時間がかかりました。初期のフレームワークは比較的低レベルのものが主流でしたが、async/awaitの導入により、ウェブ開発のエコシステムが急速に進化しました。これにより、ガベージコレクターを持たない言語でありながら、高性能で安全な同時処理を実現するウェブサービスを効率的に構築することが可能になりました。

GoとRustを比較すると、特に使いやすさ、パフォーマンス、安全性の点で興味深い違いがあります。では、どのウェブフレームワークを選ぶか決めましょう。

どのウェブフレームワークを選ぶか?

Rustのウェブフレームワーク全般について、また、それぞれの長所と短所について詳しく知りたい方は、こちらのRustのウェブフレームワーク深掘り記事をご参照ください。

この記事では、ActixAxumの2つのウェブフレームワークに焦点を当てます。

Actixは、Rustコミュニティで非常に人気のあるウェブフレームワークです。アクターモデルに基づいており、内部ではasync/awaitを利用しています。ベンチマークによると、世界で最も高速なウェブフレームワークの1つとして頻繁に名前が挙がります

一方、Axumはtower、非同期サービスを構築するためのライブラリを基盤とした新しいウェブフレームワークです。急速に人気を集めており、同じくasync/awaitを基盤としています。

両方とも、使いやすさやパフォーマンスの面で非常に似ています。ミドルウェアやルーティングのサポートもあります。どちらとも本稿で構築するウェブサービスに適していますが、今回はAxumを選択します。というのも、Axumは最近非常に注目を浴びており、Rustのエコシステム全体とも非常によく統合されているためです。

ルーティング

まず、cargo new forecastでプロジェクトを開始し、Cargo.tomlに以下の依存関係を追加しましょう。
(他に必要なものは後ほど追加します。)

[dependencies]
# web framework
axum = "0.6.20"
# async HTTP client
reqwest = { version = "0.11.20", features = ["json"] }
# serialization/deserialization  for JSON
serde = "1.0.188"
# database access
sqlx = "0.7.1"
# async runtime
tokio = { version = "1.32.0", features = ["full"] }

次に、特定の動作をするわけではないウェブサービスの基本的な構造を作成します。

use std::net::SocketAddr;

use axum::{routing::get, Router};

// コメント:静的な文字列を応答するハンドラー
async fn index() -> &'static str {
	"Index"
}

async fn weather() -> &'static str {
	"Weather"
}

async fn stats() -> &'static str {
	"Stats"
}

#[tokio::main]
async fn main() {
	let app = Router::new()
    	.route("/", get(index))
    	.route("/weather", get(weather))
    	.route("/stats", get(stats));

	let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
	axum::Server::bind(&addr)
    	.serve(app.into_make_service())
    	.await
    	.unwrap();
}

main関数はわかりやすいかと思います。ルータを作成し、それをソケットアドレスに関連付けます。indexweatherstatsはハンドラー関数です。これらは文字列を返す非同期関数として定義されていますが、後ほど具体的なロジックに置き換えます。

一度cargo runで確認してみましょう。

$ curl localhost:3000
Index
$ curl localhost:3000/weather
Weather
$ curl localhost:3000/stats
Stats

これで基本的な動作を確認できました。続いて、ハンドラーに実際の処理を実装していきます。

Axumのマクロについて

その前に、Axumにはいくつかの難点があることをお伝えしておきたいです。
たとえば、ハンドラー関数を非同期に設定し忘れると、エラーメッセージが表示されます。
もしHandler<_, _> is not implementedというエラーに遭遇したら、axum-macrosクレートを追加し、ハンドラーに#[axum_macros::debug_handler]アノテーションを付けてください。これにより、はるかにわかりやすいエラーメッセージが表示されるようになります。

緯度と経度の取得

指定された都市の緯度と経度を外部APIから取得する関数を書いていきましょう。

以下はAPIからの応答を表す構造体です:

use serde::Deserialize;

pub struct GeoResponse {
	pub results: Vec<LatLong>,
}

#[derive(Deserialize, Debug, Clone)]
pub struct LatLong {
	pub latitude: f64,
	pub longitude: f64,
}

Goと比較して、フィールド名を指定するためのタグは使用しません。代わりに、serdeからの#[derive(Deserialize)]属性を使用して、構造体のDeserializeトレイトを自動的に導出します。これらの導出マクロは非常に強力であり、型のパースエラーを含む、非常に少ないコードで多くのことを行うことができます。これはRustでの非常に一般的なパターンです。

新しい型を使用して、指定された都市の緯度と経度を取得しましょう:

async fn fetch_lat_long(city: &str) -> Result<LatLong, Box<dyn std::error::Error>> {
	let endpoint = format!(
    	"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1&language=en&format=json",
    	city
	);
	let response = reqwest::get(&endpoint).await?.json::<GeoResponse>().await?;
	response
    	.results
    	.get(0)
    	.cloned()
    	.ok_or("No results found".into())
}

Goと比べると少し簡潔なコードになります。if err != nil のような構造を書く必要がないため、エラーを伝播するために[?
operator](https://doc.rust-lang.org/rust-by-example/std/result/question_mark.html)が使用できます。これは各ステップが[Result](https://doc.rust-lang.org/std/result/) 型を返すためにも必須です。エラーを処理しない場合、値にアクセスできなくなります。

最後の部分は少し見慣れないかもしれません:

response
	.results
	.get(0)
	.cloned()
	.ok_or("No results found".into())

ここではいくつかのことが行われています:

  • response.results.get(0)Option<&LatLong> を返します。ベクタが空の場合、get関数はNoneを返す可能性があるため、Optionです。
  • cloned()Option内の値をクローンし、Option<&LatLong>Option<LatLong>に変換します。これは、LatLongを返す必要があり、参照ではないため必要です。そうでなければ、関数のシグネチャにライフタイム指定子を追加する必要があり、コードの可読性が低下するからです。
  • ok_or("No results found".into())は、Option<LatLong>Result<LatLong, Box<dyn std::error::Error>>に変換します。OptionNoneの場合、エラーメッセージを返します。into()関数は、文字列をBox<dyn std::error::Error>に変換します。

このコードを別の方法で書くと以下のようになります:

match response.results.get(0) {
	Some(lat_long) => Ok(lat_long.clone()),
	None => Err("No results found".into()),
}

どちらを好むかは本当に好みの問題です。

Rustは式ベースの言語であり、関数から値を返すためにreturnを使用する必要はありません。代わりに、関数の最後の値が返されます。

fetch_lat_longを使用してweather関数を更新できるようになりました。

最初の試みは以下のようになります:

async fn weather(city: String) -> String {
	println!("city: {}", city);
	let lat_long = fetch_lat_long(&city).await.unwrap();
	format!("{}: {}, {}", city, lat_long.latitude, lat_long.longitude)
}

最初にコンソールに都市を印刷し、緯度と経度をフェッチして結果をアンラップ(つまり「展開」)します。結果がエラーになってしまった場合、プログラムがパニックに陥ってしまいます。それは理想的ではないため、後で修正しましょう。

続いて、緯度と経度を使用して文字列を作成し、それを返します。

プログラムを実行し、何が起こるか見てみましょう:

curl -v "localhost:3000/weather?city=Berlin"
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /weather?city=Berlin HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.1.2
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server

さらに、この出力が得られます:

city:

cityパラメータは空です。何が起こったのでしょうか?

問題は、cityパラメータにString型を使用していることです。この型は有効なextractorではありません。

代わりにQueryエクストラクタを使用してみましょう:

async fn weather(Query(params): Query<HashMap<String, String>>) -> String {
	let city = params.get("city").unwrap();
	let lat_long = fetch_lat_long(&city).await.unwrap();
	format!("{}: {}, {}", *city, lat_long.latitude, lat_long.longitude)
}

動作はしますが、実用的ではありませんね。都市を取得するためにOptionunwrapする必要があります。また、参照ではなく値を取得するために、format!マクロに*cityを渡す必要があります。(Rustの用語で「デリファレンシング」と呼ばれます。)

クエリパラメータを表すstructを作成しましょう:

#[derive(Deserialize)]
pub struct WeatherQuery {
	pub city: String,
}

このstructをエクストラクタとして使用し、unwrapを回避します:

async fn weather(Query(params): Query<WeatherQuery>) -> String {
	let lat_long = fetch_lat_long(&params.city).await.unwrap();
	format!("{}: {}, {}", params.city, lat_long.latitude, lat_long.longitude)
}

綺麗になりました。Goに比べて、やや複雑ですが、より型安全でもあります。structに制約を追加してバリデーションを追加することもできそうです。例えば、都市の名前は最低3文字以上であることを要求することなどです。

weather関数内のunwrapについて考えてみましょう。
理想は、都市が見つからない場合にエラーを返すことです。これは、返り値の型を変更することで行うことができます。

axumにおいて、ハンドラから返されるものは[IntoResponse](https://docs.rs/axum/latest/axum/response/trait.IntoResponse.html) を実装するものであれば何でも良いのですが、具体的な型を返すことが望ましいです。なぜなら、impl IntoResponseを返す場合にはいくつかの注意点があるからです。

私たちの場合、Result型を返すことができます:

async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
	match fetch_lat_long(&params.city).await {
    	Ok(lat_long) => Ok(format!(
        	"{}: {}, {}",
        	params.city, lat_long.latitude, lat_long.longitude
    	)),
    	Err(_) => Err(StatusCode::NOT_FOUND),
	}
}

この方法で、都市が見つからない場合は404のステータスコードを返します。
fetch_lat_longの結果にマッチするためにmatchを使用します。それがOkの場合、天気をStringとして返します。それがErrの場合、StatusCode::NOT_FOUNDを返します。

また、エラーをStatusCodeに変換するためにmap_err関数を使用することもできます:

async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
	let lat_long = fetch_lat_long(&params.city)
    	.await
    	.map_err(|_| StatusCode::NOT_FOUND)?;
	Ok(format!(
    	"{}: {}, {}",
    	params.city, lat_long.latitude, lat_long.longitude
	))
}

これだとコントロールフローがより線形であるという利点があります。つまり、エラーをすぐに処理し、その後に正常なパスを続行することができるということです。一方、これらのコンビネータパターンに慣れるまでには時間がかかることがあります。

Rustでは、通常、物事を行うための方法が複数あります。
どちらのバージョンを好むかは好みの問題です。
基本的には考えすぎず、シンプルにしておくことが大切です。

さて、プログラムをテストしてみましょう。

curl "localhost:3000/weather?city=Berlin"
Berlin: 52.52437, 13.41053

そして、

curl -I "localhost:3000/weather?city=abcdedfg" HTTP/1.1 404 Not Found

次に、指定された緯度と経度の天気を返す二つ目の関数を書いてみましょう。

async fn fetch_weather(lat_long: LatLong) -> Result<String, Box<dyn std::error::Error>> {
	let endpoint = format!(
    	"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m",
    	lat_long.latitude, lat_long.longitude
	);
	let response = reqwest::get(&endpoint).await?.text().await?;
	Ok(response)
}

ここでAPIリクエストを行い、生のレスポンスボディをStringとして返します。

ハンドラを拡張し、2つの呼び出しを連続して行うこともできます。

async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
	let lat_long = fetch_lat_long(&params.city)
    	.await
    	.map_err(|_| StatusCode::NOT_FOUND)?;
	let weather = fetch_weather(lat_long)
    	.await
    	.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
	Ok(weather)
}

これは動作しますが、Open Meteo APIからの生のレスポンスボディを返します。Goと同様、レスポンスを解析してデータを返しましょう。

復習です。Goの定義は以下のとおりでした。

type WeatherResponse struct {
    Latitude  float64 `json:"latitude"`
    Longitude float64 `json:"longitude"`
    Timezone  string  `json:"timezone"`
    Hourly	struct {
   	 Time      	[]string  `json:"time"`
   	 Temperature2m []float64 `json:"temperature_2m"`
    } `json:"hourly"`
}

こちらがRustになります。

#[derive(Deserialize, Debug)]
pub struct WeatherResponse {
	pub latitude: f64,
	pub longitude: f64,
	pub timezone: String,
	pub hourly: Hourly,
}

#[derive(Deserialize, Debug)]
pub struct Hourly {
	pub time: Vec<String>,
	pub temperature_2m: Vec<f64>,
}

それでは、他にも必要な構造体を定義しておきましょう。

#[derive(Deserialize, Debug)]
pub struct WeatherDisplay {
	pub city: String,
	pub forecasts: Vec<Forecast>,
}

#[derive(Deserialize, Debug)]
pub struct Forecast {
	pub date: String,
	pub temperature: String,
}

これでレスポンスボディを構造体にて解析することができます。

async fn fetch_weather(lat_long: LatLong) -> Result<WeatherResponse, Box<dyn std::error::Error>> {
	let endpoint = format!(
    	"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m",
    	lat_long.latitude, lat_long.longitude
	);
	let response = reqwest::get(&endpoint).await?.json::<WeatherResponse>().await?;
	Ok(response)
}

さて、ハンドラを調整しましょう。コンパイルする最も簡単な方法は、Stringを返すことです。

async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
	let lat_long = fetch_lat_long(&params.city)
    	.await
    	.map_err(|_| StatusCode::NOT_FOUND)?;
	let weather = fetch_weather(lat_long)
    	.await
    	.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
	let display = WeatherDisplay {
    	city: params.city,
    	forecasts: weather
        	.hourly
        	.time
        	.iter()
        	.zip(weather.hourly.temperature_2m.iter())
        	.map(|(date, temperature)| Forecast {
            	date: date.to_string(),
            	temperature: temperature.to_string(),
        	})
        	.collect(),
	};
	Ok(format!("{:?}", display))
}

パースのロジックとハンドラーのロジックが混ざっていることに注目してください。これを少し整理するために、パースのロジックをコンストラクタ関数に移動させましょう。

impl WeatherDisplay {
	/// コメント:`WeatherResponse`から新しい`WeatherDisplay`を作成
	fn new(city: String, response: WeatherResponse) -> Self {
    	let display = WeatherDisplay {
        	city,
        	forecasts: response
            	.hourly
            	.time
            	.iter()
            	.zip(response.hourly.temperature_2m.iter())
            	.map(|(date, temperature)| Forecast {
                	date: date.to_string(),
                	temperature: temperature.to_string(),
            	})
            	.collect(),
    	};
    	display
	}
}

良いでしょう。ハンドラは次のようになります。

async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
	let lat_long = fetch_lat_long(&params.city)
    	.await
    	.map_err(|_| StatusCode::NOT_FOUND)?;
	let weather = fetch_weather(lat_long)
    	.await
    	.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
	let display = WeatherDisplay::new(params.city, weather);
	Ok(format!("{:?}", display))
}

少し良くなってきました。
気になるのはmap_errの定型文です。これを削除するために、カスタムエラータイプを導入しましょう。例として、[axumのリポジトリ](https://github.com/tokio-rs/axum/blob/main/examples/anyhow-error-response/src/main.rs)の例を参考にし、エラーハンドリングのための人気のあるクレートanyhowを使用します。

cargo add anyhow

例のコードをプロジェクトにコピーしましょう。

anyhow::Errorをラップする独自のエラーを作成します。

struct AppError(anyhow::Error);

// コメント:axumに`AppError`をレスポンスに変換する方法を示します
impl IntoResponse for AppError {
	fn into_response(self) -> Response {
    	(
        	StatusCode::INTERNAL_SERVER_ERROR,
        	format!("Something went wrong: {}", self.0),
    	)
        	.into_response()
	}
}

axumAppErrorをレスポンスに変換する方法を伝える。

impl IntoResponse for AppError {
	fn into_response(self) -> Response {
    	(
        	StatusCode::INTERNAL_SERVER_ERROR,
        	format!("Something went wrong: {}", self.0),
    	)
        	.into_response()
	}
}

Result<_, anyhow::Error>を返す関数で?を使用し、Result<_, AppError>に変換します。これにより、手動でそれを行う必要がなくなります。

impl<E> From<E> for AppError
where
	E: Into<anyhow::Error>,
{
	fn from(err: E) -> Self {
    	Self(err.into())
	}
}

このコードの全てを理解する必要はありません。
肝心なのは、この設定によりアプリケーションのエラー処理を整理し、ハンドラ内での取り扱いを不要にしていることです。

fetch_lat_longfetch_weather 関数を調整して、anyhow::Errorを持つResultを返すように変更する必要があります。

async fn fetch_lat_long(city: &str) -> Result<LatLong, anyhow::Error> {
	let endpoint = format!(
    	"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1&language=en&format=json",
    	city
	);
	let response = reqwest::get(&endpoint).await?.json::<GeoResponse>().await?;
	response.results.get(0).cloned().context("No results found")
}

そして

async fn fetch_weather(lat_long: LatLong) -> Result<WeatherResponse, anyhow::Error> {
  // コメント:コードはそのまま
}

依存関係を追加し、エラー処理のための追加の定型文を導入することで、ハンドラをかなり単純化することができました:

async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, AppError> {
	let lat_long = fetch_lat_long(&params.city).await?;
	let weather = fetch_weather(lat_long).await?;
	let display = WeatherDisplay::new(params.city, weather);
	Ok(format!("{:?}", display))
}

テンプレート

axumはテンプレートエンジンを備えていませんので、自分たちで選択する必要があります。
私は通常、teraまたはaskamaを使用しますが、コンパイル時の構文チェックをサポートしているという理由で、ややaskamaを好むことが多いです。これにより、誤って打ち間違えた文字がテンプレートに導入されることはなくなります。テンプレートで使用するすべての変数はコードで定義される必要があります。
axumのサポートを有効にする:

cargo add askama --features=with-axum

コンパイルさせるためにこれも追加する必要がありました:

cargo add askama_axum

templates ディレクトリを作成し、以前に作成したGoのテーブルテンプレートと同様のweather.htmlテンプレートを追加しましょう:

<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><title>Weather</title></head><body><h1>Weather for {{ city }}</h1><table><thead><tr><th>Date</th><th>Temperature</th></tr></thead><tbody>
				{% for forecast in forecasts %}
				<tr><td>{{ forecast.date }}</td><td>{{ forecast.temperature }}</td></tr>
				{% endfor %}
			</tbody></table></body></html>

WeatherDisplay 構造体をTemplateに変換しましょう:

#[derive(Template, Deserialize, Debug)] #[template(path = "weather.html")] struct WeatherDisplay { city: String, forecasts: Vec<Forecast>, }

ハンドラは次のようになります:

async fn weather(Query(params): Query<WeatherQuery>) -> Result<WeatherDisplay, AppError> {
	let lat_long = fetch_lat_long(&params.city).await?;
	let weather = fetch_weather(lat_long).await?;
	Ok(WeatherDisplay::new(params.city, weather))
}

ここに来るまで少し手間がかかりましたが、冗長なボイラープレートコードなしで、関心の分離ができました。

ブラウザhttp://localhost:3000/weather?city=Berlin を開くと、天気のテーブルが表示されるはずです。

入力マスクの追加は簡単です。
Goで使用したのとまったく同じHTMLを使用することができます:

<form action="/weather" method="get"><!DOCTYPE html><html><head><title>Weather Forecast</title></head><body><h1>Weather Forecast</h1><form action="/weather" method="get"><label for="city">City:</label><input type="text" id="city" name="city" /><input type="submit" value="Submit" /></form></body></html></form>

そして、ハンドラは以下のとおりです:

#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate;

async fn index() -> IndexTemplate {
	IndexTemplate
}

続いて、緯度と経度をデータベースに保存する作業に移りましょう。

データベースへのアクセス

データベースへのアクセスには、sqlx を使用します。
複数のデータベースをサポートする非常に人気のあるクレートです。今回は、Goで作成したWebサービスと同様にPostgresを使用します。

Cargo.tomlに以下を追加してください:

sqlx = { version = "0.7", features = [
	"runtime-tokio-rustls",
	"macros",
	"any",
	"postgres",
] }

.env ファイルに DATABASE_URL 環境変数を追加する必要があります:

export DATABASE_URL="postgres://forecast:forecast@localhost:5432/forecast?sslmode=disable"

まだPostgresが実行されていない場合、Goで紹介したDockerスニペットと同じもので起動できます。

それでは、データベースを使用するようにコードを調整しましょう。
まず、main関数から:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
	let db_connection_str = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
	let pool = sqlx::PgPool::connect(&db_connection_str)
    	.await
    	.context("can't connect to database")?;

	let app = Router::new()
    	.route("/", get(index))
    	.route("/weather", get(weather))
    	.route("/stats", get(stats))
    	.with_state(pool);

	let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
	axum::Server::bind(&addr)
    	.serve(app.into_make_service())
    	.await?;

	Ok(())
}

変更点は以下の通りです:

  • DATABASE_URL 環境変数を追加し、mainで読み取りました。
  • sqlx::PgPool::connect を使ってデータベース接続プールを作成しています。
  • その後、すべてのハンドラで使用可能にするために with_state にプールを渡します。

各ルートでは、以下のようにデータベースプールにアクセスすることができます(必須ではありません):

async fn weather(
	Query(params): Query<WeatherQuery>,
	State(pool): State<PgPool>,
) -> Result<WeatherDisplay, AppError> {
	let lat_long = fetch_lat_long(&params.city).await?;
	let weather = fetch_weather(lat_long).await?;
	Ok(WeatherDisplay::new(params.city, weather))
}

Stateの詳細については、こちらをご覧ください。

データベースからのデータの取得を可能にするために、FromRow トレイトを構造体に追加する必要があります:

#[derive(sqlx::FromRow, Deserialize, Debug, Clone)]
pub struct LatLong {
	pub latitude: f64,
	pub longitude: f64,
}

データベースから緯度と経度を取得する関数を追加しましょう:

async fn get_lat_long(pool: &PgPool, name: &str) -> Result<LatLong, anyhow::Error> {
	let lat_long = sqlx::query_as::<_, LatLong>(
    	"SELECT lat AS latitude, long AS longitude FROM cities WHERE name = $1",
	)
	.bind(name)
	.fetch_optional(pool)
	.await?;

	if let Some(lat_long) = lat_long {
    	return Ok(lat_long);
	}

	let lat_long = fetch_lat_long(name).await?;
	sqlx::query("INSERT INTO cities (name, lat, long) VALUES ($1, $2, $3)")
    	.bind(name)
    	.bind(lat_long.latitude)
    	.bind(lat_long.longitude)
    	.execute(pool)
    	.await?;

	Ok(lat_long)
}

そして最後に、新しい関数を使用してweatherルートを更新しましょう:

async fn weather(
	Query(params): Query<WeatherQuery>,
	State(pool): State<PgPool>,
) -> Result<WeatherDisplay, AppError> {
	let lat_long = fetch_lat_long(&params.city).await?;
	let weather = fetch_weather(lat_long).await?;
	Ok(WeatherDisplay::new(params.city, weather))
}

できました!これで、データベースのバックエンドを持ち、しっかりと動作するウェブサービスの完成です。動作は先程と同じですが、緯度と経度をキャッシュしています。

ミドルウェア

最後の機能は/statsエンドポイントです。

最近のクエリを示し、Basic認証の後ろにあるものです。

まずはBasic認証から始めましょう。

私もここを突破するのにはいくらか時間がかかりました。
axum用の認証ライブラリは多数ありますが、Basic認証の方法に関する情報はほとんどありませんでした。

結果的にカスタムミドルウェアを書くことになりました。動作としては以下のとおりです。

  • リクエストがAuthorizationヘッダーを持っているかどうかを確認する
  • そうであれば、ヘッダーが有効なユーザー名とパスワードを含んでいるかどうかを確認する
  • そうであれば、「認証されていない」レスポンスとWWW-Authenticateヘッダーを返し、ブラウザにログインダイアログを表示するように指示する

以下がそのコードです:

/// コメント:statsエンドポイントにアクセスするための認証されたユーザー。
///
/// フィールドは必要ありません、ユーザーが認証されていることを知るだけで十分です。
/// 本番のアプリケーションでは、ここで何らかのユーザーIDや類似のものを持つことが望ましいでしょう。struct User;

#[async_trait]
impl<S> FromRequestParts<S> for User
where
	S: Send + Sync,
{
	type Rejection = axum::http::Response<axum::body::Body>;

	async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
    	let auth_header = parts
        	.headers
        	.get("Authorization")
        	.and_then(|header| header.to_str().ok());

    	if let Some(auth_header) = auth_header {
        	if auth_header.starts_with("Basic ") {
            	let credentials = auth_header.trim_start_matches("Basic ");
            	let decoded = base64::decode(credentials).unwrap_or_default();
            	let credential_str = from_utf8(&decoded).unwrap_or("");

            	if credential_str == "forecast:forecast" {
                	return Ok(User);
            	}
        	}
    	}

    	let reject_response = axum::http::Response::builder()
        	.status(StatusCode::UNAUTHORIZED)
        	.header(
            	"WWW-Authenticate",
            	"Basic realm=\"Please enter your credentials\"",
        	)
        	.body(axum::body::Body::from("Unauthorized"))
        	.unwrap();

    	Err(reject_response)
	}
}

FromRequestParts は、リクエストからデータを抽出することを可能にするトレイトです。
また、FromRequestも存在し、これはリクエストボディ全体を消費し、そのためハンドラーで一度だけ実行できます。今回のケースでは、Authorizationヘッダーを読むだけなので、FromRequestPartsで十分です。

素晴らしいことに、User型をハンドラーに簡単に追加するだけで、リクエストからユーザーを抽出できます。

async fn stats(user: User) -> &'static str {
	"We're authorized!"
}

次に、/statsエンドポイントの実際のロジックです。

#[derive(Template)]
#[template(path = "stats.html")]
struct StatsTemplate {
	pub cities: Vec<City>,
}

async fn get_last_cities(pool: &PgPool) -> Result<Vec<City>, AppError> {
    let cities = sqlx::query_as::<_, City>("SELECT name FROM cities ORDER BY id DESC LIMIT 10")
        .fetch_all(pool)
        .await?;
    Ok(cities)
}

async fn stats(_user: User, State(pool): State<PgPool>) -> Result<StatsTemplate, AppError> {
	let cities = get_last_cities(&pool).await?;
	Ok(StatsTemplate { cities })
}

デプロイ

それでは最後にデプロイについて語りましょう。

RustやGoはどちらも静的にリンクされたバイナリをコンパイルできるため、どんな仮想マシン(VM)や仮想プライベートサーバ(VPS)でもホスティングできます。これはすばらしく、物理的なマシン上でアプリケーションをネイティブで実行できることを意味します。

別の選択肢は、コンテナを使用することです。コンテナはアプリケーションを隔離された環境で実行するため、非常に人気があり、簡単に使用でき、ほとんどどこでもデプロイできます。

Go言語に関しては、静的なバイナリやコンテナを実行するサポートがある任意のクラウドプロバイダを使用できます。人気のある選択肢の一つとして、Google Cloud Runがあります。

もちろん、Rustを出荷するためにコンテナを使用することもできますが、他の選択肢もあります。その中の一つがShuttleです。このサービスの動作は他のサービスとは異なり、Dockerイメージをビルドしてレジストリにプッシュする必要はありません。代わりに、コードをGitリポジトリにプッシュするだけで、Shuttleがバイナリをビルドして実行してくれます。

Rustの手続き的マクロのおかげで、追加の機能をすばやくコードに組み込むことができます。

メイン関数に[#[shuttle_runtime::main]](https://docs.shuttle.rs/examples/axum) を追加するだけで始めることができます:

#[shuttle_runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	// ここに残りのコードを書きます
}

はじめに、ShuttleのCLIとその依存関係をインストールしてください。

cargo binstall、つまりcrates.ioからのバイナリのインストールのために設計されたCargoのプラグインを使用することができます。まず、プラグインがインストールされていることを確認してください。そうすれば、ShuttleのCLIをインストールすることができます:

cargo binstall cargo-shuttle
cargo add shuttle-axum shuttle-runtime

main関数がShuttleを使うように変更しましょう。
ポートのバインディングが不要になったことにも注目してください。Shuttleがこなしてくれるからです。ルータを渡すだけで、残りはShuttleが面倒を見てくれます。

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
	let db_connection_str = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
	let pool = sqlx::PgPool::connect(&db_connection_str)
    	.await
    	.context("can't connect to database")?;

	let router = Router::new()
    	.route("/", get(index))
    	.route("/weather", get(weather))
    	.route("/stats", get(stats))
    	.with_state(pool);

	Ok(router.into())
}

次に、本番用のPostgresデータベースを設定しましょう。
そのためのマクロがあります。

cargo add shuttle-shared-db --features=postgres

そして、

#[shuttle_runtime::main]
async fn main(#[shuttle_shared_db::Postgres] pool: PgPool) -> shuttle_axum::ShuttleAxum {
	pool.execute(include_str!("../schema.sql"))
    	.await
    	.context("Failed to initialize DB")?;

	let router = Router::new()
    	.route("/", get(index))
    	.route("/weather", get(weather))
    	.route("/stats", get(stats))
    	.with_state(pool);

	Ok(router.into())
}

スキーマに関する部分をご覧ください。これは、既存のテーブル定義でデータベースを初期化する方法です。sqlxを通じてマイグレーションもサポートされています、さらにsqlx-cliを使用しています。

多くのボイラープレートコードを排除し、簡単にアプリをデプロイできるようになりました。

これは一度だけ実行すれば良いです。

cargo shuttle project start

これは好きなだけ実行してください。

cargo shuttle deploy`

完了すると、サービスのURLが表示されます。
以前と同じように動作するはずですが、今回はクラウドのサーバー上で実行されています。

Go と Rust の比較

2つの比較をまとめます。

Go

Goでの作業は非常にシンプルでわかりやすかったです。追加する必要がある依存関係は2つだけでした:Gin(Webフレームワーク)とsqlx(データベースドライバ)。それ以外のすべては標準ライブラリによって提供されました:テンプレートエンジン、JSONパーサ、日時の処理など。

私自身、Goのテンプレートエンジンやエラー処理のメカニズムの大ファンというわけではありませんが、開発プロセス全体を通じて生産的、効率的だと感じることができました。
また、外部のテンプレートライブラリを使用することも可能でしたが、今回は組み込みのもので十分だったため、必要はありませんでした。

Rust

Rustのコードは少し複雑で、Goでの機能と同じことを実現するために、たくさんのライブラリやツールを追加しないといけませんでした。テンプレート作成やJSONの解析、日付関連の機能、データベースへの接続、ウェブ関連の機能などを追加しました。

これはRustの特性です。Rustの基本的なライブラリはシンプルで、基本の部品しか入っていません。これは、必要な機能だけを選んで追加する、という考え方から来ています。新しい機能をすぐに試すことができ、言語自体は安定しています。

始めるのに少し時間がかかりましたが、段々と高度なコードを書く楽しさを感じました。不完全なところで妥協する必要はありませんでした。特に、?FromRequestのようなツールを使うことで、煩わしい繰り返しや複雑なエラー処理をせずに、スッキリとしたコードを書けました。

まとめ

  • Go:
    • 学びやすく、高速で、ウェブサービスに適している。
    • 必要なものは全て揃っている。標準ライブラリだけで多くのことができ、テンプレートエンジンや別の認証ライブラリを追加する必要はなかった。
    • 外部からの依存はGinsqlxだけ。
  • Rust:
    • 高速で、安全、ウェブサービス向けの進化するエコシステム。
    • 必要なツールは入っていない。Goと同じ機能を得るためにたくさんの依存関係を追加し、自分でミドルウェアを書く必要があった。
    • 最後のハンドラコードはエラー処理が邪魔にならないようになっている。独自のエラータイプと?オペレータを使って、読みやすいコードになった。ただ、追加のアダプタロジックを書くのに手間がかかった。ハンドラは短く、関心の分離が自然にできている。

そこで疑問が浮かびます…

RustはGoよりも優れているのか?RustはGoを置き換えるのか?

個人的に、私はRustの大ファンであり、ウェブサービスの構築における素晴らしい言語だと思っています。しかし、エコシステムにはまだ少し粗削りな部分や欠けている部分があります。

特に、初心者にとって、axumを使用したときのエラーメッセージは時々難解になることがあります。例として、このようなエラーメッセージがあります。これは、型の不一致のためにハンドラトレイトを実装していないルートで発生します:

error[E0277]: the trait bound `(): Handler<_, _>` is not satisfied
   --> src\router.rs:22:50
    |
22  |         router = router.route("/", get(handler));
    |                                    --- ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Handler<_, _>` is not implemented for `()`
    |                                    |
    |                                    required by a bound introduced by this call
    |
note: required by a bound in `axum::routing::get`

この場合、エラーメッセージをかなり簡略化するaxumのdebug_handlerをお勧めします。詳細については、公式ドキュメントで詳しく読むことができます。

また、Goと比較すると、認証部分も手間がかかりました。Goでは、ミドルウェアを使うだけで済みましたが、Rustでは独自のミドルウェアとエラータイプを書く必要がありました。これは必ずしも悪いことではありませんが、適切な解決策を見つけるためにaxumのドキュメントを調査する必要があります。もちろん、Basic認証は実際のプロジェクトでの一般的なユースケースではありませんが、高度な認証ライブラリの選択肢はたくさんあります。

上述の問題は、取引の中断要因ではなく、特定のクレートに関連する細かい問題がほとんどです。Core Rustは安定性と成熟度を達成しており、本番環境での使用に適しています。エコシステムはまだ進化していますが、すでに良い状態にあります

一方、最終的なGoのコードは少し冗長だと感じました。エラーハンドリングは非常に明確ですが、実際のビジネスロジックから注意が逸れることもあります。Goでは、より高レベルの抽象を求めることが多かったです(RustバージョンのFromRequestトレイトのような)。最終的な仕上がりはRustのコードの方が簡潔に感じました。Rustのコンパイラは、プロセス全体を通してより良い設計に導いてくれるように感じました。Rustを使う場合、初期段階のコストが高くなりますが、そこを超えた後のエルゴノミクスは素晴らしいです。

一方が他方より優れているとは思いません。それは好みの問題です。しかし、二つの言語の哲学がこれだけ異なっているのにも関わらず、どちらも高速で信頼性のあるウェブサービスを構築することができます。

2023年現在、どちらを使用すべきか

新しいプロジェクトを始めるときや、自由に言語を選ぶことができる場合、どちらを選ぶべきか悩むかもしれません。

それはプロジェクトの期間やチームの経験によります。迅速に始めたい場合、Goの方が良いかもしれません。Goは開発環境に必要なものが全て揃っているので、ウェブアプリケーションの開発に非常に適しています。

しかし、Rustの長期的な利点を過小評価しないでいただきたいです。豊富な型システムと、素晴らしいエラーハンドリング機構、コンパイル時のチェックを併せ持つRustは、高速であるだけでなく、頑丈で拡張性のあるアプリケーションを構築するのに役立ちます。

開発者の作業速度に関しては、Shuttleは本番環境でのRustコードの運用負担を大幅に軽減することができます。本稿で取り上げたように、Dockerfileを書く必要はなく、コードはクラウドでネイティブにビルドされるため、非常に迅速なデプロイメントやイテレーションが可能です。

したがって、長期的なソリューションを探していて、学習に時間を投資する意欲がある場合、Rustは素晴らしい選択肢だと言えるでしょう。

両方のソリューションを比較し、どちらが自分にとってより良いか、ご自身でで決めることをお勧めします。

いずれにせよ、同じプロジェクトを2つの異なる言語で構築するのは楽しい経験になりました。最終的な結果は同じでも、そこに至るまでの過程はかなり異なっていました。

この記事を通じて、RustとGoの違いやそれぞれの長所・短所についての洞察を得られたことを願っています。最終的には、どの言語があなたのプロジェクトやチームに最も適しているかを見極めることが大切です。選択する道がどちらであれ、成功を祈っています。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。