Cloudflare Workers(Rust)からKVを使うチュートリアルをやってみた #Cloudflare

2021.12.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

どうも!オペ部西村祐二です。

この記事は「Cloudflare のアドベントカレンダー」の4日目の記事です。

以前、下記のようなブログを書きました。そこからCloudflareのサービスに興味がでてきましたので今回、Cloudflare Workers(Rust)からCloudflare Workers KVを使うチュートリアルをやってみました。

今回、やってみたチュートリアルは下記になります。

Use Workers KV directly from Rust · Cloudflare Workers docs

Cloudflare Workers KVとは

Cloudflareのアプリケーション向けサーバーレス Key-Value ストレージです。

Cloudflare Workers KVは、Cloudflareのグローバルネットワーク内にあるすべてのデータセンターで、セキュアな低レイテンシーKey-Value Storeへのアクセスを提供。

Cloudflare Workers KV|サーバーレスコンピューティング | Cloudflare

やってみる

環境

  • MacOS: 10.15.7

  • Rust: v1.58.0-nightly

  • Wrangler: 1.19.5

事前準備

  • warnglerをインストール

wranglerというCLIをインストールしてCloudflareの環境を操作できるようにしておいてください。

詳細は下記参考サイトを確認ください。

cloudflare/wrangler: 🤠 wrangle your Cloudflare Workers

Get started guide · Cloudflare Workers docs

雛形プロジェクトを作成

wranglerを使って雛形プロジェクトを作成します。

worker-rsを使用したテンプレートを指定します。

worker-rsはWebAssembly経由でRustで記述したプログラムをCloudflare Workersで動かすためのクレート(ライブラリ)です。

$ wrangler generate workers-kv-from-rust https://github.com/cloudflare/rustwasm-worker-template/
$ cd workers-kv-from-rust
$ git add -A
$ git commit -m 'Initial commit'

KV namespaceを作成

wranglerをつかって「KV_FROM_RUST」を指定し、namespaceを作成します。

プロジェクト名と指定した値を連結した「workers-kv-from-rust-KV_FROM_RUST」というnamespaceが作成されます。

❯ wrangler kv:namespace create "KV_FROM_RUST"
🌀  Creating namespace with title "workers-kv-from-rust-KV_FROM_RUST"
✨  Success!
Add the following to your configuration file:
kv_namespaces = [
	 { binding = "KV_FROM_RUST", id = "xxxxxxxxxxxxxxxxxxx" }

また、--previewをつけるとローカルから実行するときに参照されるKV namespaceを作成してくれます。

❯ wrangler kv:namespace create "KV_FROM_RUST" --preview
🌀  Creating namespace with title "workers-kv-from-rust-KV_FROM_RUST_preview"
✨  Success!
Add the following to your configuration file:
kv_namespaces = [
	 { binding = "KV_FROM_RUST", preview_id = "xxxxxxxxxxxxxx" }
]

webコンソールを確認するとちゃんと作成されていることがわかります。

KV namespaceをバインドさせる

KV namespace作成時に生成されたidをwrangler.tomlファイルに追加します。

wrangler.toml

.
.
.
kv_namespaces = [
  { binding = "KV_FROM_RUST", preview_id = "xxxxxxxxxxxxx", id = "xxxxxxxxxx" }
]
.
.
.

KV namespaceにアクセスするための下処理追加

JS側では変数KV_FROM_RUSTとして、作成したKV namespaceにアクセスします。Rustの名前空間から読み取りまたは書き込みを行うには、オブジェクト全体をRustハンドラー関数に渡す必要があります。

$ mkdir worker
$ cd worker
$ touch worker.js

worker/worker.js

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

const { handle } = wasm_bindgen;
const instance =  wasm_bindgen(wasm);
/**
 * Fetch and log a request
 * @param {Request} request
 */
async function handleRequest(request) {
  await instance;

  return await handle(KV_FROM_RUST, request);
}

このチュートリアルでは、Rust側で可能な限り多くの処理を実行し、リクエストを直接wasmハンドラーに渡して、wasmハンドラーが応答を作成して返します。

リクエストとレスポンスの処理をJavaScript側で維持するテンプレートとは異なることに注意してください。

Rust側でなるべく処理を寄せるために、web-sysをRust依存関係の1つとして宣言し、Request、Response、およびResponseInitを明示的に有効にします。(UrlおよびUrlSearchParams機能はこのチュートリアルの後半で使用されます)。

.
.
.
[dependencies.web-sys]
version = "0.3"
features = [
    'Request',
    'Response',
    'ResponseInit',
    'Url',
    'UrlSearchParams',
]

下記のように記述することでRustのRequest、Responseを使用して、常に200OKステータスで応答するシンプルなハンドラーを作成できます。

src/lib.rs

extern crate cfg_if;
extern crate wasm_bindgen;

mod utils;

use cfg_if::cfg_if;
use wasm_bindgen::{JsCast, prelude::*};
use web_sys::{Request, Response, ResponseInit};

cfg_if! {
    // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
    // allocator.
    if #[cfg(feature = "wee_alloc")] {
        extern crate wee_alloc;
        #[global_allocator]
        static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
    }
}

#[wasm_bindgen]
pub fn handle(kv: JsValue, req: JsValue) -> Result<Response, JsValue> {
    let req: Request = req.dyn_into()?;
    let mut init = ResponseInit::new();
    init.status(200);
    Response::new_with_opt_str_and_init(None, &init)
}

wasm_bindgenを使用してKVにバインドする

先程のsrc/lib.rsをベースに発展させていきます。

KV APIはJavaScriptのpromiseを返すため、依存関係としてwasm-bindgen-futuresjs-sysを追加する必要があります。

Cargo.toml highlight=2-4

[dependencies]
cfg-if = "0.1.2"
wasm-bindgen = "=0.2.73"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"

wasm_bindgenを使用して型バインディングを作成してKVオブジェクトにアクセスするようにしたコードが下記になります。

src/lib.rs

.
.
.
#[wasm_bindgen]
pub fn handle(kv: WorkersKvJs, req: JsValue) -> Result<Response, JsValue> {
    let req: Request = req.dyn_into()?;
    let mut init = ResponseInit::new();
    init.status(200);
    Response::new_with_opt_str_and_init(None, &init)
}

#[wasm_bindgen]
extern "C" {
    pub type WorkersKvJs;

    #[wasm_bindgen(structural, method, catch)]
    pub async fn put(
        this: &WorkersKvJs,
        k: JsValue,
        v: JsValue,
        options: JsValue,
    ) -> Result<JsValue, JsValue>;

    #[wasm_bindgen(structural, method, catch)]
    pub async fn get(
        this: &WorkersKvJs,
        key: JsValue,
        options: JsValue,
    ) -> Result<JsValue, JsValue>;
}

KVのラッパーを作成

KVパラメーターをそのまま使用し始めることもできますが、wasm_bindgenによって生成された関数シグネチャはRust内で機能するのが難しい場合があります。より簡単に扱うために、WorkersKvJs型の周りに単純な構造体を作成して、よりRustに適したAPIでラップします。

src/lib.rs

.
.
.
use js_sys::{ArrayBuffer, Object, Reflect, Uint8Array};

struct WorkersKv {
    kv: WorkersKvJs,
}

impl WorkersKv {
    async fn put_text(&self, key: &str, value: &str, expiration_ttl: u64) -> Result<(), JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"expirationTtl".into(), &(expiration_ttl as f64).into())?;
        self.kv
            .put(JsValue::from_str(key), value.into(), options.into())
            .await?;
        Ok(())
    }

    async fn put_vec(&self, key: &str, value: &[u8], expiration_ttl: u64) -> Result<(), JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"expirationTtl".into(), &(expiration_ttl as f64).into())?;
        let typed_array = Uint8Array::new_with_length(value.len() as u32);
        typed_array.copy_from(value);
        self.kv
            .put(
                JsValue::from_str(key),
                typed_array.buffer().into(),
                options.into(),
            )
            .await?;
        Ok(())
    }

    async fn get_text(&self, key: &str) -> Result<Option<String>, JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"type".into(), &"text".into())?;
        Ok(self
            .kv
            .get(JsValue::from_str(key), options.into())
            .await?
            .as_string())
    }

    async fn get_vec(&self, key: &str) -> Result<Option<Vec<u8>>, JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"type".into(), &"arrayBuffer".into())?;
        let value = self.kv.get(JsValue::from_str(key), options.into()).await?;
        if value.is_null() {
            Ok(None)
        } else {
            let buffer = ArrayBuffer::from(value);
            let typed_array = Uint8Array::new_with_byte_offset(&buffer, 0);
            let mut v = vec![0; typed_array.length() as usize];
            typed_array.copy_to(v.as_mut_slice());
            Ok(Some(v))
        }
    }
}

上記のラッパーは、KV APIでサポートされているオプションのサブセットのみを利用可能にします。たとえば、putのexpirationTtlの代わりにexpirationや、getのtextやarrayBuffer以外のタイプなど、他のオプションも同様の方法でラップできます。概念的には、ラッパーメソッドはすべてReflect::setを使用して手動でJSオブジェクトを作成し、必要に応じて戻り値を標準のRust型に変換します。

ラッパーを利用する処理を追加

ラッパーを使用してKV namespaceとの間で値を読み書きする準備が整いました。

次の関数は、URLセグメントを使用してKVドキュメントのキー名と値を決定し、PUTリクエストでKVに書き込むハンドラーの例です。たとえば、PUTリクエストを/foo?value=barに送信すると、「bar」値がfooキーに書き込まれます。

さらに、サンプルハンドラーは、GETリクエスト時に、URLパス名をキー名として使用してKVから読み取ります。たとえば、GET /fooリクエストは、fooキーの値があればそれを返します。

src/lib.rs

.
.
.
#[wasm_bindgen]
pub async fn handle(kv: WorkersKvJs, req: JsValue) -> Result<Response, JsValue> {
    let req: Request = req.dyn_into()?;
    let url = web_sys::Url::new(&req.url())?;
    let pathname = url.pathname();
    let query_params = url.search_params();
    let kv = WorkersKv { kv };
    match req.method().as_str() {
        "GET" => {
            let value = kv.get_text(&pathname).await?.unwrap_or_default();
            let mut init = ResponseInit::new();
            init.status(200);
            Response::new_with_opt_str_and_init(Some(&format!("\"{}\"\n", value)), &init)
        },
        "PUT" => {
            let value = query_params.get("value").unwrap_or_default();
            // set a TTL of 60 seconds:
            kv.put_text(&pathname, &value, 60).await?;
            let mut init = ResponseInit::new();
            init.status(200);
            Response::new_with_opt_str_and_init(None, &init)
        },
        _ => {
            let mut init = ResponseInit::new();
            init.status(400);
            Response::new_with_opt_str_and_init(None, &init)
        }
    }
}

src/lib.rsのコード全体

チュートリアルで紹介されたlib.rsのコードは下記のようになります。

KVにデータをPUTするときにTTL60秒が設定されて保存されるようになっているのでご注意ください。

src/lib.rs

extern crate cfg_if;
extern crate wasm_bindgen;

mod utils;

use cfg_if::cfg_if;
use js_sys::{ArrayBuffer, Object, Reflect, Uint8Array};
use wasm_bindgen::{prelude::*, JsCast};
use web_sys::{Request, Response, ResponseInit};

cfg_if! {
    // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
    // allocator.
    if #[cfg(feature = "wee_alloc")] {
        extern crate wee_alloc;
        #[global_allocator]
        static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
    }
}

#[wasm_bindgen]
pub async fn handle(kv: WorkersKvJs, req: JsValue) -> Result<Response, JsValue> {
    let req: Request = req.dyn_into()?;
    let url = web_sys::Url::new(&req.url())?;
    let pathname = url.pathname();
    let query_params = url.search_params();
    let kv = WorkersKv { kv };
    match req.method().as_str() {
        "GET" => {
            let value = kv.get_text(&pathname).await?.unwrap_or_default();
            let mut init = ResponseInit::new();
            init.status(200);
            Response::new_with_opt_str_and_init(Some(&format!("\"{}\"\n", value)), &init)
        }
        "PUT" => {
            let value = query_params.get("value").unwrap_or_default();
            // set a TTL of 60 seconds:
            kv.put_text(&pathname, &value, 60).await?;
            let mut init = ResponseInit::new();
            init.status(200);
            Response::new_with_opt_str_and_init(None, &init)
        }
        _ => {
            let mut init = ResponseInit::new();
            init.status(400);
            Response::new_with_opt_str_and_init(None, &init)
        }
    }
}

#[wasm_bindgen]
extern "C" {
    pub type WorkersKvJs;

    #[wasm_bindgen(structural, method, catch)]
    pub async fn put(
        this: &WorkersKvJs,
        k: JsValue,
        v: JsValue,
        options: JsValue,
    ) -> Result<JsValue, JsValue>;

    #[wasm_bindgen(structural, method, catch)]
    pub async fn get(
        this: &WorkersKvJs,
        key: JsValue,
        options: JsValue,
    ) -> Result<JsValue, JsValue>;
}

struct WorkersKv {
    kv: WorkersKvJs,
}

impl WorkersKv {
    async fn put_text(&self, key: &str, value: &str, expiration_ttl: u64) -> Result<(), JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"expirationTtl".into(), &(expiration_ttl as f64).into())?;
        self.kv
            .put(JsValue::from_str(key), value.into(), options.into())
            .await?;
        Ok(())
    }

    async fn put_vec(&self, key: &str, value: &[u8], expiration_ttl: u64) -> Result<(), JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"expirationTtl".into(), &(expiration_ttl as f64).into())?;
        let typed_array = Uint8Array::new_with_length(value.len() as u32);
        typed_array.copy_from(value);
        self.kv
            .put(
                JsValue::from_str(key),
                typed_array.buffer().into(),
                options.into(),
            )
            .await?;
        Ok(())
    }

    async fn get_text(&self, key: &str) -> Result<Option<String>, JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"type".into(), &"text".into())?;
        Ok(self
            .kv
            .get(JsValue::from_str(key), options.into())
            .await?
            .as_string())
    }

    async fn get_vec(&self, key: &str) -> Result<Option<Vec<u8>>, JsValue> {
        let options = Object::new();
        Reflect::set(&options, &"type".into(), &"arrayBuffer".into())?;
        let value = self.kv.get(JsValue::from_str(key), options.into()).await?;
        if value.is_null() {
            Ok(None)
        } else {
            let buffer = ArrayBuffer::from(value);
            let typed_array = Uint8Array::new_with_byte_offset(&buffer, 0);
            let mut v = vec![0; typed_array.length() as usize];
            typed_array.copy_to(v.as_mut_slice());
            Ok(Some(v))
        }
    }
}

ローカルで動作確認

CLIを使ってローカルサーバを実行してみます。

するとエラーとなってしまいました。

$ wrangler dev
.
.
.
[INFO]: ⬇️  Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨   Done in 1.31s
[INFO]: 📦   Your wasm pkg is ready to publish at /Users/nishimura.yuji/study/cloudflare/kv/workers-kv-from-rust/build.
Error: Something went wrong with the request to Cloudflare...
Uncaught SyntaxError: The requested module './index_bg.mjs' does not provide an export named 'fetch'
  at line 2
 [API code: 10021]

エラー修正

wrangler.tomlの2行がjavascriptになっていたので、rustに修正します。

wrangler.toml

name = "workers-kv-from-rust"
type = "rust"
workers_dev = true
compatibility_date = "2021-12-03"
kv_namespaces = [
.
.
.

再度、ローカルで動作確認

CLIを使ってローカルサーバを実行してみます。

一応エラーが出ずにロールサーバがたちあがりました。いくつか警告がでていますがチュートリアルということで今回は無視して進めます。

$ wrangler dev
.
.
.
[INFO]: ⬇️  Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨   Done in 0.53s
[INFO]: 📦   Your wasm pkg is ready to publish at /Users/nishimura.yuji/study/cloudflare/kv/workers-kv-from-rust/pkg.
[WARN]: ⚠️   There's a newer version of wasm-pack available, the new version is: 0.10.1, you are using: 0.10.0. To update, navigate to: https://rustwasm.github.io/wasm-pack/installer/
💁  watching "./"
👂  Listening on http://127.0.0.1:8787

curlを使って動作確認してみます。

$ curl 'localhost:8787/foo'
""
$ curl -X PUT 'localhost:8787/foo?value=bar'
$ curl 'localhost:8787/foo'
"bar"

問題なく動作しているようです。

また、TTLを60秒で設定しているので、時間が経過するとKVからデータが消えていることも確認できました。

webコンソールから確認してみます。

末尾に「preview」のKV namespaceの中にデータがあることが確認できました。

デプロイ

チュートリアルでは記載はないですが、実際にデプロイして動作確認もしてみます。

下記コマンドで簡単にデプロイできます。

❯ wrangler publish
🌀  Compiling your project to WebAssembly...
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...
[INFO]: ⬇️  Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
.
.
.

✨  Build succeeded
✨  Successfully published your script to
 https://<your url>

curlで動作確認してみます。

今度はJSON形式のデータの{"name":"foo","selected":[1,2,3],"flags":{"a":true,"b":false}}を保存します。

jsonをurlにパスに変換する必要があります。

$ curl -X PUT 'https://<your url>/foo2?value=%7B%22name%22%3A%22foo%22%2C%22selected%22%3A%5B1%2C2%2C3%5D%2C%22flags%22%3A%7B%22a%22%3Atrue%2C%22b%22%3Afalse%7D%7D'

$ curl 'https://<your url>/foo2'
"{"name":"foo","selected":[1,2,3],"flags":{"a":true,"b":false}}"

webコンソールからもきちんとデータが保存されていることが確認できます。

また KV namespaceはpreviewがついていないnamespaceに保存されています。

さいごに

Cloudflare Workers(Rust)からCloudflare Workers KVを使うチュートリアルをやってみました。

Cloudflare WorkersがV8を直接実行できる環境ということもあって、RustとWebAssemblyの知識、wasm-bindgenの使い方などの知識が必要となり、なかなかハードルを感じましたがとても楽しく、勉強になるチュートリアルでした。

誰かの参考になれば幸いです。

参考サイト

Rust から WebAssembly にコンパイルする - WebAssembly | MDN

wasm-pack で JS の Promise を await できる非同期 Rust を書いて node.js で動かす - Qiita

fkettelhoit/workers-kv-from-rust: Example Cloudflare Worker that calls Workers KV directly from Rust