2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python風に書けてLowまで降りられる。Rust製自作バックエンド言語「Nagi」の設計と実装

2
Posted at

前回、バックエンド向けの自作プログラミング言語「Nagi」を公開しました。

当時は「実際に動く試作ができた」という段階でしたが、その後も仕様と実装をかなり詰めました。

現在は、Python風の構文を持つ High Language、人間が読んで編集できる Low Language、所有権とview、HTTP / JSON / SQLite、async / await、Rust連携などが実際に動きます。

今回は機能一覧を並べるのではなく、Nagiのコードがどうネイティブバイナリになるのかを、実際のソースと測定結果を見ながら紹介します。

この記事は2026年10月1日時点のNagi 0.1.3を基準にしています。

Nagiはまだ0.1系のプロトタイプです。仕様変更は普通に入ります。また、後半で紹介する30分のHTTP負荷試験はNagi 0.1.2で測定した結果です。

リポジトリはこちらです。

ドキュメントはこちらです。

まず、今のNagiで何が書けるのか

最初に実際のコードを見たほうが早いです。

例えば、SQLiteからユーザーを取得するHTTP APIはこう書けます。

examples/crud.nagi
class User:
    id: i64
    name: str
    age: i32

@get("/users/{id}")
async def get_user(db: Db, id: i64) -> Result[User?, Error]:
    return await db_query[User](
        db,
        "SELECT id, name, age FROM users WHERE id = ?1",
        id
    )

POSTも同じように書けます。

examples/crud.nagi
class CreateUser:
    name: str
    age: i32

@post("/users")
async def create_user(db: Db, req: CreateUser) -> Result[User, Error]:
    return await db_insert[User](
        db,
        "INSERT INTO users(name, age) VALUES (?1, ?2) RETURNING id, name, age",
        req.name,
        req.age
    )

見た目はPythonにかなり近いですが、Python互換言語ではありません。

req.ageはi32、idはi64として扱われ、Nagiのコンパイラで型検査されたあとネイティブバイナリになります。

現在のCRUDサンプルでは、実際に次の経路が動きます。

「Pythonのように書きやすくしたい」というのはNagiの目標の一つですが、内部までPythonのオブジェクトモデルに寄せるつもりはありません。

バックエンドで頻繁に通るHTTP・JSON・DBの境界を、なるべく型を保ったまま流せる言語にしたいと考えています。

HighをそのままRustに変換しない

Nagiで特にこだわっているのが、HighとLowの二層構造です。

現在のコンパイル経路はこうなっています。

普通にアプリケーションを書くときはHighを使います。

例えば、

hello.nagi
class Point:
    x: f64
    y: f64

def main():
    p = Point(x=1.5, y=2.5)
    print(p.x + p.y)

これをlowerすると、次のようなLowになります。

generated.low
record Point {
    x: f64;
    y: f64;
}

fn main() -> unit {
    let p: Point = Point(x=1.5, y=2.5);
    print(p.x + p.y);
}

Lowは単なるデバッグ表示ではありません。

独立したparserとcheckerを持っていて、.lowを直接書いて実行することもできます。

nagic run examples/hello.low

生成したLowを、本当にもう一度parseする

ここは「そういう設計にする予定」ではなく、現在のコンパイラに入っています。

compiler/src/emit.rsを見ると、HighからLowを生成してgenerated.lowへ書き出したあと、その文字列を独立したparserへ通しています。

compiler/src/emit.rs
let low_source = low_with_lines(&p);

fs::write(
    out.join("generated.low"),
    &low_source.text
)?;

p = crate::parser::parse(&low_source.text, false)?;
low_source.restore_lines(&mut p)?;

つまり、HighのASTをそのままRust codegenへ渡してはいません。

High AST
   ↓
Low text
   ↓
Low AST
   ↓
Rust

という経路になっています。

この一手間を入れた理由は、コンパイラ内部だけが理解できる中間表現にしたくなかったからです。

生成結果を人間が読めて、必要ならそこまで降りて触れる余地を残したかった。

Nagiの二層構造は、そのために入れています。

Lowまで降りると、生成関数を差し替えられる

Highに次の関数があるとします。

examples/override.nagi
def score(x: i64) -> i64:
    return x + 1

def main():
    print(score(7))

このscoreだけを、手書きLowで置き換えられます。

examples/native/override.low
@replace generated::score
fn optimized_score(x: i64) -> i64 {
    return x * 6;
}

実行時は、生成Lowと手書きLowを統合します。

nagic run examples/override.nagi --native examples/native/override.low

@replaceなら何でも置き換えられるわけではありません。

対象関数が存在するか、引数と戻り値が一致しているか、asyncが一致しているか、同じ関数を複数回置換していないかをcheckerで確認します。

生成されたgenerated.lowを直接編集して変更を維持する方式にもしていません。

再生成で消える変更は手書きLowへ移し、差し替える関数を明示する形です。

個人的には、ここが今のNagiでかなり気に入っている部分です。

Highだけで完結するコードはそのまま書ける。一方、特定の処理だけ低い層まで降りたい場合にも逃げ道を残せます。

所有権は入れた。でもRustの構文をそのまま持ち込まない

バックエンドでは文字列、JSON、バイト列、DBの結果など、大きなデータを何度も扱います。

そこでNagiにも所有権を入れています。

例えばstrを自作関数へそのまま渡すとmoveします。

def consume(name: str):
    print(name)

def main():
    name = "Nagi"
    consume(name)
    print(name)  # move後なのでエラー

読むだけならviewを使います。

def name_size(name: view[str]) -> i64:
    return len(name)

def main():
    name = "Nagi"

    print(name_size(view(name)))
    print(name_size(view(name)))
    print(name)

必要なときだけ明示的にコピーできます。

copied = copy(view(name))

現在のRust backendでは、おおまかに次のような型へ落とします。

Nagi Rust backend
str String
view[str] &str
bytes Vec<u8>
view[bytes] &[u8]
List[T] Vec<T>
shared[T] Arc<T>

ただし、Nagi単体でRustと同等のborrow checkerが完成した、という話ではありません。

High側でmoveやviewの基本的な検査を行い、最終的にはsafe Rustを生成します。そこでRust側の型・borrow・Send検査にも通ったものをネイティブバイナリにしています。

HTTP bodyにもviewを使う

例えばCRUDサンプルにある/echoはこうなっています。

examples/crud.nagi
@post("/echo")
async def echo(body: view[bytes]) -> Result[CreateUser, Error]:
    return json_decode[CreateUser](body)

HTTPで受け取ったbodyそのものはview[bytes]として借り、JSON decoderへ渡します。

ここで「Nagiは完全zero-copy HTTPです」と言うつもりはありません。

HTTPライブラリ内部、kernel、JSONの文字列所有化など、別の場所では当然コピーやallocationが発生します。

Nagiでは「zero-copy」という言葉で経路全体をまとめるのではなく、どの境界で何を借用できたかを分けて測るようにしています。

Resultとtryで失敗を伝える

失敗する処理はResultを返します。

def double_nonnegative(text: view[str]) -> Result[i64, Error]:
    value = try parse_i64(text)

    if value < 0:
        return error("0以上の整数を入力してください")

    return ok(value * 2)

NagiのtryはPythonのtry / exceptとは別物です。

成功なら値を取り出し、失敗ならその場で呼び出し元へ返します。

成功と失敗をその場で分けたい場合はmatchを使います。

def number_or(text: view[str], fallback: i64) -> i64:
    match parse_i64(text):
        case Ok(number):
            return number
        case Err(_):
            return fallback

Lowでは波括弧を使った形へ変換されます。

match parse_i64(text) {
    case Ok(number) {
        return number;
    }
    case Err(_) {
        return fallback;
    }
}

「ネイティブなら速い」で終わらせない

Nagiは最終的にRust経由でネイティブコードになります。

CPUだけを見ると、100,000要素を使ったいくつかのkernelでRustに近い値が出ています。

例えばinteger_sumでは、今回の測定でNagiが14.667 µs/op、手書きRustが13.895 µs/opでした。

ただ、これを見て「Nagiは高速なWeb言語」と結論づけるのは早すぎます。

HTTPではrouter、JSON、allocation、scheduler、socketなど別のコストが大量に入ります。

実際、小さいPOSTの比較では、

実装 Requests/sec
Nagi 39,860
手書きAxum 60,842

となり、Nagiのほうがかなり遅い結果でした。

一方、4KiBのPOSTではNagiが36,641 req/s、Axumが36,366 req/sで同じ程度でした。

CPU kernelの結果を、そのままHTTPの性能として扱えないことがよく分かります。

ベンチマークは単一環境での探索的な測定です。

「NagiはRustと同じ速度」「Pythonより何倍速い」と一般化するための結果ではありません。測定条件と生ログはリポジトリのPERFORMANCE.mdとbenchmarks/results/に保存しています。

30分、2700万リクエストを流したら別の問題が見えた

短時間のRequests/secだけでは分からないので、HTTPサーバーへ長時間負荷も掛けました。

4KiBの文字列を受け取りJSONを返すAPIへ、15,000 requests/secを30分間送っています。

結果は27,000,270リクエスト。

HTTP 200以外と通信エラーは0件でした。

ここだけ見ると綺麗です。

ただ、リソースを見ると別の挙動がありました。

条件 30分試験 128接続固定の10分試験
リクエスト数 27,000,270 9,000,090
HTTP 200以外・通信エラー 0 0
全体p99 34.11ms 11.07ms
最大RSS 180.74MiB 8.29MiB
最大FD 6,315 138
終了後 FD 1,049 FD 10

30分試験では接続数とメモリが増え、負荷終了後30秒でもFDが1,049個残りました。

ではNagiにFDリークがあるのか。

まだ、そうは断定できません。

接続を128本に固定した対照試験では、9,000,090件を処理したあとFDは元の10個まで戻っています。

さらに別の2分試験ではFDが6,719まで増えたものの、終了後には10まで戻りました。

30分試験で残ったFDの種類を採取できていなかったため、現時点では原因を確定できていません。

2700万件全部成功したことより、この怪しい挙動が見つかったことのほうが今は重要です。

「何req/s出たか」だけではなく、接続の寿命、回収、RSSの推移まで見ないとバックエンドとしての安定性は判断できませんでした。

詳しい条件と生データはこちらに置いています。

いまのNagiは、独自ランタイムではない

ここも誤解されやすいので明記しておきます。

現在のランタイムは主にTokio、Axum、Serde、serde_json、rusqliteを利用しています。

つまり現在検証している中心は、

「完全独自の超高速ランタイム」ではなく、Nagiという言語からバックエンド処理をどう表現し、どう型付きで流すか

です。

actor、Supervisor、queueのランタイム試験もありますが、Elixirのような汎用actor宣言構文が完成しているわけではありません。

独自schedulerもまだありません。

この辺を混ぜて「全部Nagiが速い」としてしまうと、何を実装して何を既存ライブラリに任せているのか分からなくなるので、意識して分けています。

0.1.3時点で動くもの / まだないもの

動くもの

  • primitive
  • 値型class / record
  • List
  • nullable
  • Result
  • 関数、分岐、ループ
  • async / await
  • scope
  • HTTP / JSON
  • SQLite
  • High → Low
  • 手書きLowの呼び出し・関数置換
  • 相対ファイルimport
  • 型付きRust関数の呼び出し
  • actor / Supervisor / queueの試験用ランタイムAPI
  • VS Code拡張

まだないもの

  • 汎用generic
  • trait
  • class method / inheritance
  • named module / alias
  • PostgreSQL
  • compile-time SQL / schema検証
  • Lowの生pointer / unsafe / C ABI
  • 汎用actor / Supervisor構文
  • 独自scheduler
  • self-hosting

実装を見てみる

コンパイラ本体はRustで書いています。

compiler/src/
├── ast.rs
├── check.rs
├── diagnostics.rs
├── emit.rs
├── lexer.rs
├── parser.rs
├── project.rs
├── source.rs
└── symbols.rs

ランタイム側は現在こうなっています。

runtime/src/
├── concurrent.rs
├── database.rs
├── lib.rs
└── metrics.rs

構文だけPython風にしてRustコードを文字列置換しているわけではなく、

という流れを持っています。

テストも、parser/checkerのunit testだけではなく、実HTTP通信、SQLite、ownershipのnegative test、seed固定mutation、同時task、TCP接続、worker再起動などを入れています。

まだsoundnessを証明できる段階ではありませんが、「Hello Worldが動いたから完成」にはしないようにしています。

実際に動かす

GitHub Releasesでは現在、Windows x64、Linux x86_64、macOS Apple Silicon、macOS Intel向けの配布物を公開しています。

リポジトリから試す場合は、Rust / CargoとCビルド環境が必要です。

git clone https://github.com/disnana/Nagi.git
cd Nagi

cargo build --release --locked
./target/release/nagic run examples/hello.nagi

Windowsなら次のように実行できます。

.\target\release\nagic.exe run examples\hello.nagi

CRUD APIもそのまま起動できます。

./target/release/nagic run examples/crud.nagi

別ターミナルからPOSTしてみます。

curl \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","age":18}' \
  http://127.0.0.1:8080/users

Highから生成されたLowを読みたい場合は、

./target/release/nagic lower examples/crud.nagi

でbuild/crud/generated.lowを確認できます。

今後やりたいこと

最初は「Pythonのように読みやすくて、バックエンドで強い言語が欲しい」というところから始めました。

実際に作り始めると、構文よりも、

  • 所有権をどこまで表に出すか
  • HTTPとDBの境界で何をコピーするか
  • 非同期処理と障害をどう扱うか
  • Highからどこまで安全にLowへ降ろせるか
  • 生成コードをどこまで人間に見せるか

のほうが難しいことが分かってきました。

今後は言語本体だけでなく、package management、LSP、formatter、testなども含めて、Nagiだけでバックエンド開発を完結できるエコシステムまで持っていきたいです。

ただ、その前にまだ固めるべき部分があります。

特に所有権解析、module / generic、typed DB、actorのlowering、HTTP接続管理あたりは優先して進めます。

おわりに

Nagiで今残したい考え方は、

Highでは穏やかに書ける。必要ならLowまで降りられる。そして、その間で何が起きたかを隠しすぎない。

というものです。

まだ0.1.xですし、今後かなり変わるはずです。

だからこそ、今の段階で「この設計は変じゃないか」「こういうバックエンドならどう書くのか」といった意見をもらえたら嬉しいです。

ソースコード

ドキュメント

性能・検証結果

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?