RustのExcelライブラリをWasmに移植!wasm-xlsxwriterの紹介

こんにちは、estieの業務委託のtamaronです。普段は大学でコンピュータサイエンスを勉強しています。estieでは、estie レジリサーチの開発に参加したり、全社横断的なツールを作ったりと色々なことをさせてもらっています。本記事では、kenkooooさんのサポートのもとで、私が作成したExcelライブラリのwasm-xlsxwriterを紹介します。

wasm-xlsxwriterはブラウザでExcelファイルを作成するためのライブラリで、ユーザーはサーバーに負荷をかけずに高速にExcelファイルをダウンロードすることができるのが特徴です。このライブラリは、Rustで書かれたrust_xlsxwriterのラッパーになっていて、Web向けに一部を使いやすくしています。例えば、JavaScriptには、null、undefined、Dateなどの型や値があり、これらを直接関数に渡せるようにしています。wasm-xlsxwriterは以下のGitHubリポジトリでオープンソースとして公開しています!

github.com

背景

estieでは住宅の賃料を分析するツールとしてestie レジリサーチを提供しています。estie レジリサーチでは、特定のエリアや設備条件の競合情報をまとめて、Excelシートとしてダウンロードすることができます。

これまで、この機能には人気のExcelライブラリであるexceljsを使っていましたが、セルへの画像の埋め込みには対応していないという問題がありました。さらに開発がほとんど停止しているということもあり、別のライブラリの利用を検討することになりましたが、セルへの画像埋め込みに対応したオープンソースのライブラリはありませんでした。

そこで、kenkooooさんと相談した結果、さっと新しくライブラリを作れそうなら作ってみようということになりました。estieではプロダクトで積極的にRustを採用していることもあり、Rustのライブラリを再利用してWeb用のライブラリを作成することになりました。

ここからは、実装にあたって得られた技術的な知見や難しかった部分を紹介します。

Rustからnpmパッケージを作る

Rustは一般的なネイティブアプリケーションだけでなく、WebAssemblyにコンパイルすることができます。WebAssemblyは、2017年に登場したJavaScriptよりも速いプログラム形式で、ブラウザ上で実行することができます。WebAssemblyはW3Cが仕様を策定して、今後は利用が広がっていくと期待されています。

RustからWebAssemblyにコンパイルするのにはwasm-packを使いました。このツールを使うことで、npmパッケージを自動的に作成したり、作成したパッケージを簡単に公開することができます。

ラッパーの作成

wasm-bindgenを使うことで、Rustの関数や構造体をJavaScriptから使うことができるようになります。JavaScriptにエクスポートするには、アイテムの定義の前に#[wasm_bindgen]を付ければよいです。

例えば、以下のように構造体とメソッドをJavaScriptにエクスポートしてみます。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Person {
  name: String
}

#[wasm_bindgen]
impl Person {
  #[wasm_bindgen(constructor)]
  pub fn new(name: &str) -> Self {
    Person { name: name.to_owned() }
  }
  
  #[wasm_bindgen(js_name = "sayHello")]
  pub fn say_hello(&self) {
    println!("Hi, I'm {}!", self.name);
  }
}

すると、以下のように、JS側から構造体を使うことができます。JSのstring型とRustの&str型など、基本的な型については、wasm-bindgenが自動的に変換を行ってくれます。

import { Person } from "library";

const person = new Person("Alice");
person.sayHello(); // Hi, I'm Alice!

これらを踏まえると、rust_xlsxwriterの構造体や関数を新たな構造体や関数でラップして、#[wasm_bindgen]をつけるだけで、RustのライブラリをJSで使えるようになります。例えば、以下の例では、JS側でExcelのワークブックを作り、Uint8Arrayのバッファに保存することができます。

use rust_xlsxwriter as xlsx;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Workbook {
  inner: xlsx::Workbook
}

#[wasm_bindgen]
impl Person {
  #[wasm_bindgen(constructor)]
  pub fn new() -> Self {
    Workbook { inner: Workbook::new() }
  }
  
  #[wasm_bindgen(js_name = "saveToBufferSync")]
  pub fn save_to_buffer_sync(&self) -> Vec<u8> {
    workbook.save_to_buffer().unwrap()
  }
}

簡単なケースはこれでうまくいきますが、実は多くの場合は実はこれだけでは不十分です。

というのもwasm-bindgenには以下のような制限があるからです。

  • 制限1:Rustの所有権をJavaScript側で破ることができてしまう
  • 制限2:関数の戻り値として参照&Tを返せない

これらの制限はラッパの型を工夫することで解決することが出来ます。以下で、各制限の解決策を説明します。

制限1 「Rustの所有権をJavaScript側で破ることができてしまう」とその解決策

1つ目の例は、rust_xlsxwriter側で以下のように実装されているケースです。

struct Format {
  // ...
}

impl Format {
    // ...
    
    pub fn set_bold(mut self) -> Format {
        self.font.bold = true;
        self
    }
}

set_boldというメソッドは、自身の所有権(Self)を奪って自身を返す関数の型になっています。

このようなメソッドを、そのままラップして、JSにエクスポートすると、JS側で面白い動作を引き起こすことができます。以下のようにsetBold関数を2回呼び出してみます。

import { Format } from "library";

const oldFormat = new Format();
const newFormat = oldFormat.setBold();

oldFormat.setBold(); // Error!

すると、ブラウザのコンソール画面に以下のようなエラーが出てきます。

Uncaught (in promise) Error: null pointer passed to rust

このエラーメッセージは2回目のsetBoldの呼び出し時にオブジェクトが不正な値であることを表しています。最初のsetBoldの呼び出しで、オブジェクトの所有権がoldFormatからnewFormatに移り、oldFormatにnullポインタが設定されるからです。

このポインタのクリアは、wasm-bindgenが生成するJSのグルーコードに起因するもので、関数呼び出し時に引数の所有権が移動する場合には、Rustのソースコードでオブジェクトを使い終わったタイミングでポインタをnullに設定するようなJSのスラブが生成されます。

そのため、このような場合は、引数をmut selfから&selfに変更して、self.clone()を呼び出すことで対処することができます。

制限2 「関数の戻り値として参照を返せない」とその解決策

wasm-bindgenでは、参照を受け取る関数をエクスポートすることはできますが、&T&mut Tなどの参照を返す関数をエクスポートすることはできません。

例えば、以下のようなワークブックを表す構造体の、ワークシートを追加するメソッドが例として挙げられます。このメソッドを#[wasm_bindgen]をつけて単純にラップすると、「参照の戻り値がサポートされていない」という旨のコンパイルエラーが発生します。

impl Workbook {
  fn add_worksheet(&mut self) -> &mut Worksheet {
    self.sheets.push(Workbook::new());
    self.sheets.last_mut().unwrap()
  }
}

そのため、以下のようにラップの方法を工夫します。つまり、ワークブック構造体のラッパでワークブックへの参照を持つようにします。こうすることで、ワークシートのラッパでも、ワークブックへの参照を持つことができます。ワークシートのメソッドのラッパでは、ワークブックの参照とシートのインデックスから、シートの実体にアクセスすることができます。

#[wasm_bindgen]
pub struct Workbook {
    inner: Arc<Mutex<xlsx::Workbook>>,
    next_sheet_index: usize,
}

#[wasm_bindgen]
impl Workbook {
    #[wasm_bindgen(js_name = "addWorksheet")]
    pub fn add_worksheet(&mut self) -> Worksheet {
        let index = self.next_sheet_index;
        self.next_sheet_index += 1;
        let mut workbook = self.inner.lock().unwrap();
        workbook.add_worksheet();
        Worksheet {
            workbook: Arc::clone(&self.inner),
            index,
        }
    }
}

#[wasm_bindgen]
pub struct Worksheet {
    workbook: Arc<Mutex<xlsx::Workbook>>,
    index: usize,
}

このようにメソッドを&mut TではなくTを返すように変更することで無事にコンパイルすることができます。

最後に

RustのライブラリをWasmに移植することで、既存のRust資産をブラウザやNode.jsで活用することが出来ます。また、元のライブラリをフォークする必要もないため、一から自分で書く場合と比べて、メンテナンスも楽です。皆さんもここまで紹介してきた方法を使って、RustのライブラリからWeb用のライブラリを作ってみてはかがでしょうか?

謝辞

公開に際し、レビューやCIの整備などを行っていただいたkenkooooさんありがとうございました!

estieでRustを書いているエンジニアが書籍を書きました。Rustに興味がある方はこちらも読んでみてください。

www.estie.jp

© 2019- estie, inc.