🎨

自分のお気に入りのエディタテーマを見つけられるサービスを作った

2022/02/14に公開

何を作ったか

エディタやシンタックスハイライトのテーマは星の数ほどあり、自分が本当に好きなものが何かを答えることは難しいと思っています。そこで、2つのテーマを並べどちらが好きかに回答し続けることで、どのテーマが一番好きかを調べられる syntax highlight battle というサービスを作りました。

FYI: https://syntax-highlight-battle.ojisan.dev/

top page

元々、最強の syntax highlight を知りたくてバトルするサイトを作った話 というものを作っていたのですが、これは自分がどのテーマを使うか悩んだときに知人にとったアンケートサイトとして作っており、そのときに「自分が選んだ一番気に入っているテーマを知りたい」という声を頂いていたことを思い出したので、作りました。

自分にあうテーマを見つけたい人は是非とも使ってみてください。
改善もしていきたいのでリクエストなどございましたらコメントを頂けると嬉しいです。

技術的な挑戦

元々の実装は svelte + Rust + MySQL(Docker on GCE Container-Optimized OS) で作りましたが、今回は vanillaJS + Rust + Firestore という構成です。

前の実装も Rust だったのですが、そのときはきちんとした勉強もせずコピペで書いていたようなもので自分が何を書いていたのか理解できていませんでした。今回はちゃんと一冊本を読んでから書いてみて少しは解像度高く書けた気がします。

Firestore と Rust の接続

Firestore には REST や gRPC ベースのインターフェースがあり、SDK の存在していない任意の言語からでも利用できます。

そのため tonic_build などを使って gRPC client を生やせば Rust と Firestore を繋げられます。ただ今回は時短のためその辺りをしてくれている googleapi というクレートを使いました。このクレートに関しては Rust で Cloud Firestore で先行して調査してくれている人がいて、始めやすかったです。ただ、"ぶっちゃけ型定義が厳密でとてもめんどうなので Golang などのほうが楽ですね。" とある通り、実際とても大変な思いをするので、気合は必要です。型を合わせるためにひたすらライブラリの定義を読んでそれと合わせる作業がかなり多いです。ドキュメントも存在しません。気合がいります。そこで必要な気合を減らすためにも断片的にメモを残しておきます。

認証

SDK を使わない場合の認証についてですが、ヘッダにトークンをつけることでできます。そのトークンを作る方法はいろいろあるのですが、Rust の場合は gouth というクレートに任せるのが良いと思います。

use gouth::Builder;

let token = Builder::new().file("src/auth.json").build().unwrap()

このようにトークンを作れます。

ここではまったこととしてはこのサービスは CloudRun で実行しているのですが、gouth::Token::new().unwrap() で生成できるはずのデフォルト認証がうまく通りませんでした。GCP 側でデフォルトの IAM にオーナー権限を割り振ったりもしたのですができませんでした。そのため GCP 環境では、サービスアカウントの認証 json を Secret Manager に入れた上で環境変数に書きこみ、let json = env::var("AUTH_JSON") で認証をしています。

クライアントの作成

いろいろなところで使い回すことを念頭に置いて、Firestore クライアントを生成する関数を作りました。

async fn get_client() -> Result<
    FirestoreClient<
        InterceptedService<
            Channel,
            Box<dyn FnMut(tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status>>,
        >,
    >,
    Error,
> {
    let hosting_env = env::var("HOSTING_ENV")?;

    let token = if hosting_env == "cloudrun" {
        let json = env::var("AUTH_JSON")?;
        Builder::new().json(json).build().unwrap()
    } else {
        Builder::new().file("src/auth.json").build().unwrap()
    };

    let tls_config = ClientTlsConfig::new()
        .ca_certificate(Certificate::from_pem(CERTIFICATES))
        .domain_name("firestore.googleapis.com");

    let channel = Channel::from_static("https://firestore.googleapis.com")
        .tls_config(tls_config)
        .map_err(|e| anyhow!(e))?
        .connect()
        .await?;

    let f: Box<dyn FnMut(Request<()>) -> Result<Request<()>, tonic::Status>> =
        Box::new(move |mut req: Request<()>| {
            let token = &*token.header_value().unwrap();
            let meta = MetadataValue::from_str(token).unwrap();
            req.metadata_mut().insert("authorization", meta);
            Ok(req)
        });
    let service = FirestoreClient::with_interceptor(channel, f);
    Ok(service)
}

channel の生成までは儀式です。

この関数を作るときは関数の戻り値の長い型定義がやっかいです。
with_interceptor はクロージャが入るのですが、クロージャの型はコンパイル時には定まらないので型を書くことができません。
そこで Box に入れたうえで クロージャ f の型を明記します。
f に何を書くかは tonic の with_interceptor の型定義を見に行くとわかるのでそれをコピペすれば動くでしょう。

Firestore への接続

基本的には service.${操作名}(${リクエスト情報})というインターフェースで Firestore と通信ができます。

ちなみにここで登場するインターフェースは Rust のライブラリ特有のものではなく、Firestore の gRPC インターフェース共通のものです。

FYI: https://firebase.google.com/docs/firestore/reference/rpc/google.firestore.v1

一覧取得

let prjname_env = env::var("PROJECT_NAME")?;
let response = service
        .list_documents(Request::new(ListDocumentsRequest {
            parent: String::from(format!(
                "projects/{}/databases/(default)/documents",
                prjname_env
            )),
            collection_id: String::from("syntax_highlight"),
            page_size: 100,
            ..Default::default()
        }))
        .await?;

更新

let _ = service
        .update_document(Request::new(UpdateDocumentRequest {
            document: Some(dto),
            ..Default::default()
        }))
        .await?;

作成

service
.create_document(Request::new(CreateDocumentRequest {
    parent: String::from(format!(
        "projects/{}/databases/(default)/documents",
        prjname_env
    )),
    collection_id: String::from("syntax_highlight"),
    document_id: record.name.clone(),
    document: Some(dto),
    ..Default::default()
}))
.await?;

とできます。

このとき Firstore の Document オブジェクトが要求されるインターフェースもあります。
そこで Entity と Document を相互変換する道具も用意しておきましょう。

Document を作る

REST も gRPC インターフェースもなかなか癖の強いインターフェースをしているので、それを一つずつ対応させていく必要があります。

FYI: https://firebase.google.com/docs/firestore/reference/rpc/google.firestore.v1?hl=ja#google.firestore.v1.Document

pub fn to_document(&self) -> Result<Document> {
        let mut fields = HashMap::new();
        let name_value = Value {
            value_type: Some(value::ValueType::StringValue(self.name.clone())),
        };
        let url_value = Value {
            value_type: Some(value::ValueType::StringValue(self.url.clone())),
        };
        let count_value = Value {
            value_type: Some(value::ValueType::IntegerValue(self.count)),
        };
        fields.insert(String::from("name"), name_value);
        fields.insert(String::from("url"), url_value);
        fields.insert(String::from("count"), count_value);

        let prjname_env = env::var("PROJECT_NAME")?;
        Ok(Document {
            fields: fields,
            name: String::from(format!(
                "projects/{}/databases/(default)/documents/syntax_highlight/{}",
                prjname_env, self.name
            )),
            ..Default::default()
        })
    }

適宜必要な Enum に包む必要があるので、googleapi ライブラリからその enum を持ってきましょう。Firestore の TimeStamp に関しては prost_types の型定義が必要になるので別途インストールしておきましょう。これは Protocol Buffers に関する型定義をまとめたライブラリです。

use googapis::google::firestore::v1::{value, Document, Value};

vanillajs

複雑なフォームを作らない限り、バックエンドがしっかりしていればフロントエンドは型が要らないのではないかと思い、テンプレートエンジンに直接 JS を埋め込んだだけのコードを書きました。

結論としては TS が無いのは困らなかったのですが、宣言的 UI はほしいと思いました。

dataset.forEach((d) => {
  const tbody = tableWrapperEl.getElementsByTagName("tbody")[0];
  const insertTr = document.createElement("tr");
  const insertTdName = document.createElement("td");
  const link = document.createElement("a");
  link.href = `/assets/html/${d.name}.html`;
  link.innerText = d.name;
  insertTdName.appendChild(link);
  const insertTdCount = document.createElement("td");
  insertTdCount.innerText = Number(d.count) || 0;
  insertTr.appendChild(insertTdName);
  insertTr.appendChild(insertTdCount);
  tbody.appendChild(insertTr);
});

また、SPA っぽく操作できるように、data は JSON 文字列として HTML に埋め込んでいます。これを JSON.parse() することで JS 世界に持ち込み、VanillaJS で UI を組み立てています。

<script type="application/json" id="highlights">
  {{ highlights }}
</script>
const el = document.getElementById("highlights");
const data = JSON.parse(el.textContent);

後から気づいたのですが何のためにテンプレートエンジンを使っているのか分からなくなりました。

CDN 経由で React なりを入れるとよかったです。

syntax highlight

highlight.js を埋め込んだ HTML ページを作りそれを iframe で表示しています。
HTML も actix_web から配信しています。

FYI: https://syntax-highlight-battle.ojisan.dev/assets/html/a11y-dark.html

おわりに

Rust と Firestore の接続ができてよかったです。
RDB は繋ぐ例はよく見るしライブラリも充実しているのですが、どんなに小さいサービスでも運用のためには最低でも数千円が飛んでいくのであまり使いたくはなかったです。
それが Firestore に繋がるとなればとても安く始められるので、個人開発のバックエンドスタックを Rust にするというのもアリだなと思いました。
頑張らないといけないところは非常に頑張らないといけないので開発効率は落ちるのですが、actix_web なんかは自動でいろいろしてくれる機能も多いので、慣れたら開発効率よく作れそうだなという感想です。

もっともっと改善していきたいので改善や追加希望のテーマなどのリクエストがございましたらコメントお願いします。

URL: https://syntax-highlight-battle.ojisan.dev/

Discussion