浅色圆锥曲线爱好者PostsNotesAbout

Graphql Rust + Vue3 ~gcpにDeployするまで~

2020 年最後の学期で Web 演習の授業を取りました。授業概要として、5 人くらいでチームを組み、役割分担しつつクライエント(指導教員)から新規サイト作成の要望を聞き、デザインからデプロイまでを行う授業です。

普段やらないことをやってみたいという考えのもと、チームメンバーの都合で、私はバックエンド・インフラ・イベント画面・管理画面を担当して、偉大なる yama 先生はトップページのコーディングをやってくれました。

Repository => https://github.com/yue4u/tsukiyo

サイトイメージ
サイトイメージ

技術選定のプロセス

技術スタックやインフラの選択も提案のうち自由ということなので、最近試したかったけど試せなかったものを積み合わせました。

いろんなものを試すことが目的なので、最終的な技術スタックはかなりマニアックの結果になりました。

フロントエンド

yama 先生Vue3+viteの構成でやりたかったらしいので、フロントエンドの framework としてそれらを採用しました。css の pre processor としてscssを入れましたが、途中でtailwindも試したくなったので、実際プロジェクト内でscssはほとんど使われていません。また、フロントエンド開発のベースラインとして TypeScriptを使用しました。

バックエンド

私は最初からrustで書きたいのと、sqlデータベース上でgraphqlレイヤーを作るという縛りでやりたかったので、ORMとしてのdieselと graphql ライブラリーのjuniperを採用しました。全体の web framework はactixに載せました。

開発環境

複数人同時開発なので、docker-composeで構築しました。

インフラ

インフラ選択の初期にVPSを借りてやるか、cloud vendorを利用するのかについて結構悩みました。クライアントから記事の画像を貰ってアップロードし反映したいのと、できればコストを抑えたいという要望がありました。その上大量のアクセスが来ないという想定なので、Pay as you gocloud vendorなら無料枠で済ませるという算段でした。

私達のチームはrustという相対的にマイナーな言語を選択したので、デプロイするにはCloud Runが一番手軽です。さらに、CI/CDの構成や自動 revision はVPSより楽です。以上を踏まえ、インフラはGCPを選択しました。

また、Cloud RunのためにContainer Registryも合わせて使用しました。管理画面から backend を経由せず直接アプロードできるという利点があるので、Identity Platformを authentication の手段として採用しました。

CI/CDの pipeline はgithub actionsを採用しました。最初にCloud Buildを使ってみましたが、一番安いランタイムのパフォーマンスが非常に悪く、当時の build 用の Dockerfile に問題もあったけれど、10 分のビルドタイムですらアウトしたので、github actionsに移行しました。

gcp cloud build timeout
gcp cloud build timeout

遭遇した問題と振り返り

フロントエンド

フロントエンドは計 11 ページで、サイトマップは以下のような感じです。

pathページ実装した機能
1/LPデザイン通りの coding
2/contactContactcontact を作成する
3/contact/successContactSuccesscontact 送信成功の表示
4/eventEvent公開している event 一覧
5/event/:id_or_slugEventItemid か slug からイベントを表示する
6/adminAdminadmin トップページ
7/admin/loginLoginlogin 機能
8/admin/contact-listContactList送られた contact 一覧
9/admin/event-editorEventEditor- 新規イベントの作成
- 既存イベントの更新
10/admin/event-listEventListすべての event 一覧
11/*404 

イベント作成画面
イベント作成画面

vue3<script setup/>TypeScript

今回の制作にあたって、まだRFCであるscript setupを採用しました。svelteと似たような感じで<script/>の内容を直接 expose してくれるため、boilerplateが減り、見通しを良くすることができます。

toolingがまだ追いついてないため、veturの設定などに最初は少し苦労しました。その後、Volar(johnsoncodehk/volar)という vscode-extension の存在を知りました。これはscript setupをサポートしている上、<template/>の中でも型チェック出来るようになっていて自動補完も効くので、開発効率が爆速になりました。

typecheck in template with volar
typecheck in template with volar

urqlgraphql-codegen

Backend の graphql API と通信するため、@urql/vueを入れました。使ってみた印象は良く、もし次があればまた使いたいと思いました。<script setup/>と合わせて、非常に clean なコンポーネントが書けます。

vue
<template>
<div v-if="!data?.events?.length">There is no events</div>
<div v-else>There is {{ data.events.length }} events</div>
<div v-if="fetching">Loading...</div>
<div v-else-if="error">Oh no... {{ error }}</div>
<div v-else>
<ul v-if="data">
<li
v-for="event in data.events"
:key="event.id"
class="event"
@click="deleteEvent({ id: event.id })"
>
{{ event.id }} ) {{ event.title }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { useQuery, useMutation } from "@urql/vue"
import { Event } from "@/type/gql"
const { fetching, data, error } = useQuery<{
events: Pick<Event, "id" | "title">[]
}>({
query: `
{
events {
id
title
}
}
`,
requestPolicy: "network-only",
})
const { executeMutation: deleteEvent } = useMutation(`
mutation ($id: Int!) {
deleteEvent(id: $id) {
id
}
}
`)
</script>
<style lang="scss" scoped>
.event {
font-size: 2rem;
cursor: pointer;
&:hover {
&::after {
margin-left: 1rem;
content: "x";
color: red;
}
}
}
</style>

型生成にはgraphql-codegenを使いました。urql用の pluginもありますが、ざっくり見た感じそれらはreact向けです。@urql/vueとうまくいくかわからなかったのでそれらは使いませんでした。graphql-codegenも buggy でうまく行かないことが多かったため、いい印象はないです。今回はchange-caseを手動で入れてtypeNames: change-case#pascalCaseを指定することで回避しました。

overwrite: true schema: "http://localhost:4000/graphql" generates: type/gql.ts: plugins: - "typescript" config: skipTypename: true useTypeImports: true namingConvention: typeNames: "change-case#pascalCase"

viteesm

vitewebpackと違ってcommonjsesm(node と browser)の export の区別に対して厳しいため、firebaseなどの一部esmにサポートしていないようです。そのため package の export が怪しいモジュールに対しては特殊の処理が必要です。例えば以下のようにvite.config.tsoptimizeDepsに入れる必要があります。

ts
export default defineConfig({
plugins: [vue()],
optimizeDeps: {
include: ["firebase/app", "firebase/auth", "firebase/storage", "uuid"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
})

state 管理

今回 state 管理が必要なのは event の作成と更新ページだけなので、vuexなどの state 管理ライブラリーを使わずにcomposition apiで全部やりました。

画像の最適化

当初から Pull Request のテストとしてpreactjs/compressed-size-actionを入れていたので、予期せぬサイズ変更をすぐに検知できました。普通ならカメラで撮った画像そのままサイトに載せることはないですが、誤って commit した画像に対してわかりやすく warning を出すことができました。もちろん SSG にする時は画像の最適化を組み込むのが一番ですが、raw 画像はそもそも commit するべきではないため検知する意味はあったと思います。

+22046%
+22046%

ちゃんとリサイズと圧縮すれば、14MB -> 155KBまで変わるので、無視できないプロセスだと再認識しました。

圧縮��したあと
圧縮したあと

バックエンド

今回のactix+diesel+juniper構成でやるのは初めてなので探りながらやる感じでした。

anyhow

anyhowは rust の error handling するためのライブラリーです。Try trait と?operator を最大限利用して、理想的な処理 flow が書けました。例えば、contact を DB から取得する時はこんな感じになります。

rust
pub fn get(ctx: &Context, contact_id: i32) -> anyhow::Result<Contact> {
let conn = ctx.pool.get()?;
Ok(contacts.filter(id.eq(contact_id)).get_result(&conn)?)
}

しかし、?operator は同じ関数の中で、Option<T>Result<T, E>に対して混用してはいけないため、Option<T>Result<T, E>に変換する必要があります。.ok_or(MyError::...)でもいいですが、短くするためにMessageErrorを作りました。

rust
#[derive(Debug)]
pub struct MessageError {
message: String,
}
impl MessageError {
pub fn new(message: impl ToString) -> Self {
Self {
message: message.to_string(),
}
}
}
impl std::error::Error for MessageError {}
pub trait OrMessageError<T> {
fn or_error(self, message: &str) -> Result<T, MessageError>;
}
impl<T> OrMessageError<T> for Option<T> {
fn or_error(self, message: &str) -> Result<T, MessageError> {
match self {
Some(data) => Ok(data),
_ => Err(MessageError::new(message).into()),
}
}
}

例えば、request の header から max-age の20349を数値として取り出したい時に、

json
{
...
"Cache-Control": "public, max-age=20349, must-revalidate, no-transform"
...
}

こんな感じでかなりスムーズの処理ができます。

rust
let max_age = res
.headers()
.get("Cache-Control")
.or_error("Cache-Control is not provided")?
.to_str()?
.split(',')
.find_map(|field| field.trim().strip_prefix("max-age="))
.or_error("max age is not found")?
.parse::<i64>()?;

actix自体はanyhowでサポートしていませんが、使ってみたところ特に問題はなかったです。

juniper

今回使ってみた感想として、juniperは非常に良くできています。query と data acess の処理を bind するだけで、graphql レイヤーができます。過去に一度 node のexpress-graphqlで resolver 書いたことがありましたが、これより大変だった記憶があります。

rust
pub struct QueryPublic;
#[graphql_object(
context = Context,
)]
impl QueryPublic {
fn api_version() -> &str {
API_VERSION
}
fn event(ctx: &Context, query: EventQueryPublic) -> FieldResult<EventPublic> {
let event = events::service::get_public(ctx, query)?;
Ok(event)
}
fn events(ctx: &Context, by: Option<EventListQuery>) -> FieldResult<Vec<EventPublic>> {
let events = events::service::list_public(ctx, by)?;
Ok(events)
}
}
pub struct MutationPublic;
#[graphql_object(
context = Context,
)]
impl MutationPublic {
async fn create_contact(ctx: &Context, contact: ContactInput) -> FieldResult<Contact> {
let contact = contacts::create(ctx, contact)?;
Ok(contact)
}
}
pub type SchemaPublic = RootNode<'static, QueryPublic, MutationPublic, EmptySubscription<Context>>;

graphiqlもデフォルトでサポートしていて、とても便利です。

tsukiyo admin graphiql

actixレイヤーのweb::Json<T>が schema に合わないデータが post されるた時自動で弾くように、juniper も問題のある request を処理してくれるので、これはnodejssuperstructajvout of the boxになったような開発体験です。

rust
pub async fn handler(
req: HttpRequest,
pool: web::Data<Pool>,
schema: web::Data<Schema>,
data: web::Json<GraphQLRequest>,
) -> impl Responder {
...
}

バリデーション

さらに post されたデータの中身をバリデーションするためにKeats/validatorを使いました。以下の code のように#[validate(xxx)]をつけることで、model.validate()?ようにバリデーションができます。

rust
use serde::Deserialize;
use validator::{Validate, ValidationError};
#[derive(Debug, Validate, Deserialize)]
struct Model {
#[validate(email)]
mail: String,
#[validate(phone)]
phone: String,
}
fn dummy(model: Model) -> anyhow::Result<()> {
model.validate()?
...
}

こんな感じで、(min=1)の処理と同じですが、カスタムの validator も作成できます

rust
pub fn not_empty(input: &str) -> Result<(), ValidationError> {
if input.is_empty() {
return Err(ValidationError::new("should not be empty"));
}
Ok(())
}

diesel

今回初見ですが、触ったことがある ORM の中でdieselは使いやすい部類に入ると思います。ドキュメントもそれなりにあって、やりたいことがハマることなく直ぐできます。query するたびに接続しないため、r2d2を使って connection pool を作ります。

rust
use diesel::pg::PgConnection;
use diesel::r2d2::{self, ConnectionManager};
use dotenv::dotenv;
use std::env;
pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;
pub fn create_pool() -> Pool {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let manager = ConnectionManager::<PgConnection>::new(database_url);
r2d2::Pool::builder()
.build(manager)
.expect("Failed to create pool.")
}

Cloud SQLへの接続はunix socketになっているためDATABE_URLの構成も少し試しました。

postgresql:///development?user=<DB-USER>&password=<DB-PASSWORD>&host=/cloudsql/<PROJECT-NAME>:<INSTANCE-REGION>:<INSTANCE-NAME>

dieselQueryableInsertableといった trait を提供しているので、model と input をderiveします。junipervalidatorと合わせると、こんな感じになります。

Contact model
rust
#[derive(Queryable, Serialize, Deserialize, GraphQLObject)]
#[graphql(description = "A new contact")]
pub struct Contact {
pub id: i32,
pub title: String,
pub name: String,
pub email: String,
pub phone: Option<String>,
pub body: String,
pub created_at: NaiveDateTime,
pub checked: bool,
}
Contact Input
rust
#[derive(
Debug, Default, Clone, Validate, Insertable, Serialize, Deserialize, GraphQLInputObject,
)]
#[graphql(description = "A new contact input")]
#[table_name = "contacts"]
pub struct ContactInput {
#[validate(length(max = 100), custom = "not_empty")]
pub title: String,
#[validate(length(max = 50), custom = "not_empty")]
pub name: String,
#[validate(email)]
pub email: String,
pub phone: Option<String>,
#[validate(custom = "not_empty")]
pub body: String,
}

diesel直接 struct から select はできないため、取得したい field を一個つづ list しないといけないですが、代わりに型の安全は保証されます。例えば、event の公開データを取得し page view を+1 する操作はこんな感じになります。

rust
pub fn get_public(ctx: &Context, query_input: EventQueryPublic) -> anyhow::Result<EventPublic> {
let conn = ctx.pool.get()?;
let updates = page_view.eq(page_view + 1);
let returns = (
id, slug, title, body, genre, tag, fee, ogp_img, start_at, end_at, publish_at, updated_at,
page_view,
);
Ok(
diesel::update(events.filter(id.eq(query_input.id.or_error("not found")?)))
.set(updates)
.returning(returns)
.get_result::<EventPublic>(&conn)?,
)
}

jsonwebtoken と openssl

今回Identity Platformも使うことになったため、rust 側で firebase のJWTtoken を検証する必要がありました。オフィシャルのライブラリーがないために、verify_id_tokens_using_a_third-party_jwt_libraryの手順を見て実装しました。Rust と Firebase Authentication でユーザー認証を導入という記事を参考にしましたが、実際の処理の部分はほとんどリファクタリングしました 😨。

その中で、jsonwebtokenの crate を利用しました。crate は現在x509の証明書をサポートしていませんが、issuecomment-753403072で書かれている通り、opensslそのものを使うことで、public keyを抽出できます。

rust
let certificate = openssl::x509::X509::from_pem(v.as_bytes())?;
let pem_bytes = certificate.public_key()?.rsa()?.public_key_to_pem()?;

詳細の実装はauth/mod.rsauth/certs.rsにあります。

graphqlendpoint の authentication 手段もいくつかあります。例えば、fieldobjectmutationなどいくつかのレイヤーで権限を決めることができます。

今回は一番最初の connection をもらう前に、context で user をチェックしましたが、

rust
let conn = context.check_user()?.get()?;

juniperで query している field を見る方法がすぐ見つからなかったため、ログインの有無でschemaごとに分けることにしました。

rust
let res = if ctx.user.is_some() {
data.execute(&schema.admin, &ctx).await
} else {
data.execute(&schema.public, &ctx).await
};

Dockerfile for Production Rust

Cloud runにデプロイするためには production ビルドの Docker Image が必要です。まずdieselcrate とJWTの検証のためopensslが必要なので、muti-stage build にしようとします。そうするとdynamic linkingが問題になります。そこで、完全static linkingmuslimage が選択肢として上がります。musl は最終 image がかなり小さい(~10MB after compression)上、using-dieselの example もあるので、muslに決めました。

ただし、why-musl-extremely-slowという記事に書かれているように、muslイメージにはパフォーマンス問題が存在しています。今回は benchmark を取っていませんが、できれば避けたほうがいいかもしれません。

tsukiyo docker image

さらに、rust のビルドスピードを上げるために少し工夫をしました。

一番最初はこんな感じでした。

FROM ekidd/rust-musl-builder:latest AS builder ADD --chown=rust:rust . ./ RUN cargo build --release

ADD --chown=rust:rust . ./は source code を全部 copy して、cargo build --releaseすると、依存している crates を全部 download して compile しますす。しかし、source code に変更があった場合 docker の layer cache が無効になり、dependencies を最初から download して再 compile することになるので、遅くなります。

そこで、修正してこうなりました。

FROM ekidd/rust-musl-builder:latest AS builder ADD --chown=rust:rust Cargo.toml Cargo.lock ./ # compile dependencies RUN mkdir -p src \ && echo "fn main() {}" > src/main.rs \ && cargo build --release ADD --chown=rust:rust . ./ RUN cargo build --release

docker の layer cache をちゃんと利用するため、まずCargo.tomlCargo.lockだけ copy して、dummy の main 関数をつくってコンパイルします。そうすれば、あとで source code に変更があったとしても dependencies のコンパイル cache が効いた状態になります。

github actionsのなかで--cache-fromを使うことで、9分くらい掛かったビルドを2分半まで短縮できました。

sh
docker pull ${{ env.IMAGE }}-cache || true
docker build . -t ${{ env.IMAGE }}:${{ github.sha }} -f Dockerfile.prod --cache-from=${{ env.IMAGE }}-cache

最終的な Dockerfile はここです

インフラ

google-github-actions/deploy-cloudrunの不具合

Cloud runにデプロイする workflow でgoogle-github-actions/deploy-cloudrunを使いましたが、プレゼント日の2日前から突然動かなくなりました。流石に心当たりがないので、deploy-cloudrun/issues/26を立ててみたら、本当にバッグでした。

Backend はCloud Runのみの場合、Cloud SQLと一緒に使うべきではない

Cloud Runと合わせたデータベースとして、Cloud SQLの紹介が一番多かったのでデータベースはpostgresqlの instance にしましたが、ここが一番大きなミスでした。なぜならCloud SQLPay as you goではなく、一つのVM常に起動する状態になるので、Cloud Runの特性と合わない上値段が高くなります。一方、Cloud FirestoreRust ライブラリーがないですが起動時間で課金されるわけではないので、向いているかもしれません。

プロジェクトマネージメント

最初に紹介した通り、今回の授業は5人で協力するはずでした。しかし、授業外で yama 先生以外のほか三名のメンバー(チームリーダー含め)と連絡が取れない状態が続いて、デザインの完成もかなり遅れた上、それは一部しかありませんでした。よって残念ながら、デザインの改善について話す機会はありませんでした x。

デザインが遅れることで制作期間がテスト期間とかぶりました。yama 先生も忙しくなり、最終的にコードを書く時間は二週間くらいしかありませんでした。

プロジェクトマネージメント的に一番失敗した点は、進捗管理ができなかったことだと思います。一応プロジェクトマネージメントツールとして、Backlogを使いましたが、リーダー担当の人が不在だったせいで、うまく利用できませんでした。

細かく項目を洗い出さなくても、最初からトップページのデザインのようなクリティカルとなるタスクの締め切りと、遅れた時の対策を考えたほうが良かったかもしれません。

積み残し

  • スケジュール的に、テストを書く余裕はありませんでした。
  • vue3 で jest + cypress で setup しましたが、うまく動かないので、まだ調査が必要です。@testing-library/vueは良さそうです。
  • tailwind使うことになったので、sassをやめて@apply使うほうが綺麗に収まると思いました。
  • tailwind最初のビルドタイムが長いですが、windicss(vite-plugin-windicss)を使用すれば 20~100x 速くなります
  • イベント editor ページのコードも綺麗とは言えないので、リファクタリングが必要です。
  • 今回 backend でお主に event と contact 2つ独立したテーブルしか操作してなかったのでN+1などgraphqlで良くある問題について検証できませんでした。
  • 提出とほぼ同じタイミングで、Cloud Runwebsocket サポートするようになりましたjunipersubscriptionと組み合わせて使用できそうです。
  • もともと psql で管理画面のtext searchを実装して予定でしたができませんでした。
  • Cloud Runinvokerに対して権限確認できて、未認可のアクセスに対してそもそも起動しないので、もしかすると公開の API と管理画面の API を別々に deploy したほういいかもしれません。
  • 内容少ないですが、cloud インフラの新規作成と管理はやはりそれなり大変です。管理画面で UI を探してクリックするのはつらいので、もし機会があれば次回はterraformとかで構築を自動化したいです。そもそも今回のプロジェクトの複雑度的にVPSを借りてやると比べて同じかそれ以上大変と感じたので、VPSで完結するという選択肢もあります。

まとめ

今回のプロジェクトを通して、tailwindurqljuniperdiesel及びCloud Runなどについて調査、実装しました。最終的な成果物はプロダクションレベルとは言いがたいものの、検証の意味では充分だと思います。tailwindに対してやはり不信感を感じてはいますが、urqljuniperCloud Runはこれからも使って行きたいです。

© 2023