Rust から Go で書かれた関数を呼ぶ


こんにちは!スタッフエンジニアの @kenkoooo です!Rust から Go で書かれたコードを呼び出す方法を紹介します。

日々ソフトウェアを開発するにあたって、我々開発者は多種多様なツールを利用しています。それらのツールはそれぞれ様々なプログラミング言語で作られていて、その中には Go 言語で書かれたものも多くあります。例えば、自分の AWS アカウントに紐づく権限情報を使って、ローカルからクラウド上で動いているリソースにアクセスすることができるツールとして session-manager-plugin があります。plugin の名の通り、単体で使うことは想定されておらず、AWS CLI で Systems Manager の機能を利用可能にするものです。AWS CLI は内部的にこのコマンドを呼び出して使っています。estie でも内部的に session-manager-plugin を呼び出している Rust 製の社内ツールが存在します。

上記のようなユースケースでは、社内ツールを使ってもらうにあたって、ユーザーに session-manager-plugin を事前にインストールしてもらう必要があり、ツール自体も含めて2つのバイナリのインストールをお願いしなければなりません。Homebrew などのパッケージマネージャを導入して依存関係を解決してもらうこともできますが、1つのバイナリに固めてしまうことはできないのでしょうか?

C 言語から Go 言語で書かれた関数を呼び出す

Go 言語には cgo というツールが存在し、C 言語から Go 言語で書かれた関数を呼び出せるようにできます。「Rust はどこいった?」と思われるかもしれませんが、いったん気にしないでください。

次のような Go のコードを考えます。

package main

import "fmt"

func Println(p string) {
    fmt.Println(p)
}

ここに以下のように追記することで、Println という関数を C 言語から呼び出せるようになります。

package main

import "C"

import "fmt"

//export Println
func Println(p string) {
    fmt.Println(p)
}

func main() {}

このコードを main.go という名前で保存し、次のコマンドでビルドすると、C 言語から呼び出し可能な静的ライブラリを生成できます。

go build -buildmode=c-archive -o out/libppsys.a main.go

./out/libppsys.a という静的ライブラリが生成されたことを確認できます。

Rust から C 言語で書かれた関数を呼び出す

先ほどビルドされた libppsys.a は C 言語にすると以下のようなインターフェイスをもっています。

extern void Println(GoString p);

これを Rust から呼び出すコードは次のようになります。

use std::ffi::CString;
use std::os::raw::c_char;

extern "C" {
    fn Println(args: GoString);
}

#[repr(C)]
struct GoString {
    a: *const c_char,
    b: i64,
}

pub fn println(s: &str) {
    let args = CString::new(s).expect("failed to convert args to CString");
    unsafe {
        Println(GoString {
            a: args.as_ptr(),
            b: args.as_bytes().len() as i64,
        });
    }
}

このコードだけでは、cargo が libppsys.a をビルド時にリンクしてくれません。cargo に読み出すべき静的ライブラリと、それが ./out/にあるということを伝えるために、次のような build.rs を書きます。

use std::{env, path::Path, process::Command};

enum Target {
    Linux,
    Macos,
}

fn main() {
    // main.go や ./out に変化があった時、再度ビルドを走らせる
    println!("cargo::rerun-if-changed=main.go");
    println!("cargo::rerun-if-changed=out");

    // ビルドする環境を設定する
    let target = env::var("TARGET").expect("TARGET env var not found");
    let target = match target.as_str() {
        "x86_64-unknown-linux-gnu" => Target::Linux,
        "aarch64-apple-darwin" => Target::Macos,
        target => panic!("Unsupported target: {}", target),
    };

    // 環境に合わせてビルドするコマンドを組み立てる
    let mut cmd = Command::new("go");
    cmd.arg("build")
        .arg("-buildmode=c-archive")
        .arg("-o")
        .arg("out/libppsys.a")
        .arg("main.go");

    match target {
        Target::Linux => cmd.envs([("GOOS", "linux"), ("GOARCH", "amd64")]),
        Target::Macos => cmd.envs([("GOOS", "darwin"), ("GOARCH", "arm64")]),
    };

    // コマンドを実行する
    cmd.status().expect("Failed to build");

    // ビルドしたライブラリをリンクするための情報を出力する
    let dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR env var not found");
    println!(
        "cargo::rustc-link-search=native={}",
        Path::new(&dir).join("out").display()
    );
    println!("cargo::rustc-link-lib=static=ppsys");
}

ついでに先ほどの go build コマンドも組み込みました。Apple シリコンの Mac と x86_64 Linux の両対応になっています。これで Rust から C で書かれたライブラリ(実態は Go で書かれている)の関数を呼び出すことができます。

まとめ

  • Go 言語で書かれた関数を、C 言語から呼び出せるように C 言語のインターフェイスをつけた上で、静的ライブラリにビルドすることができます。
  • C 言語で書かれた静的ライブラリを Rust から呼び出すことができます。

これらを2つを組み合わせることで、Rust から Go で書かれた関数を呼び出せるようになり、静的ライブラリなので最終的に1つのバイナリに固めることができます。


おわかりいただけただろうか…
誰も C 言語を使っていないのにプロトコルとして利用されていて、面白いですね。


最後に

estie では複数のプロダクトを同時並行で開発していますが、お客様にご期待いただいていることもあり、それぞれの開発がどんどん加速しています。技術的な課題も同時並行で湧き出していて、その内容も様々で、大変解きがいがあります。

ぜひ来て、力を貸していただければと思います。

hrmos.co

© 2019- estie, inc.