Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
9
Help us understand the problem. What is going on with this article?
@yagince

[Rust] warpを使ってみる

今までwebフレームワークは actix/actix-web にお世話になってきたんですが
最近は seanmonstar/warp をよく見かけるようになってきたので使ってみようと思いたってやってみたメモ。

環境

  • OS: Ubuntu 20.04
  • DockerCompose: 1.27.4
  • Rust: 1.49.0
Cargo.toml
[package]
name = "rust_warp_examples"
version = "0.1.0"
authors = ["yagince <xxxx@gmail.com>"]
edition = "2018"

[dependencies]
anyhow = "=1.0.36"
thiserror = "=1.0.23"
warp = "=0.3.0"
tokio = { version = "1", features = ["full"] }
env_logger = "=0.8.2"
serde_derive = "=1.0.118"
serde = "=1.0.118"
serde_json = "=1.0.61"

[dev-dependencies]
assert-json-diff = "1.1.0"

実行するようのdocker-compose.ymlを用意

FROM rust:1.49.0-slim-buster

ENV CARGO_TARGET_DIR=/tmp/target \
    DEBIAN_FRONTEND=noninteractive \
    LC_CTYPE=ja_JP.utf8 \
    LANG=ja_JP.utf8

RUN apt-get update \
  && apt-get install -y -q \
     ca-certificates \
     locales \
     apt-transport-https\
     libssl-dev \
     pkg-config \
     curl \
     build-essential \
  && echo "ja_JP UTF-8" > /etc/locale.gen \
  && locale-gen \
  \
  && echo "install rust tools" \
  && rustup component add rustfmt \
  && cargo install cargo-watch cargo-make

WORKDIR /app

CMD ["cargo", "run"]
docker-compose.yml
version: "3.7"

services:
  app:
    build:
      context: .
    container_name: app
    working_dir: /app
    command: cargo watch -x run
    tty: true
    volumes:
      - ./:/app
    ports:
      - 3000:3000

今回はまだDBとか使わないのでシンプル

まずはシンプルにhello worldしてみる

まずはREADMEに書いてあるExampleを書いてみる

main.rs
use warp::Filter;

#[tokio::main]
async fn main() {
    // GET /hello/warp => 200 OK with body "Hello, warp!"
    let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name));

    warp::serve(hello).run(([0, 0, 0, 0], 3000)).await;
}
docker-compose up -d

で起動

❯ curl -v http://localhost:3000/hello/warp
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /hello/warp HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.73.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Thu, 07 Jan 2021 13:12:23 GMT
<
* Connection #0 to host localhost left intact
Hello, warp!%

うん、とりあえず動いた

ログを出したい

アクセスログが出たほうが動作確認しやすいので、ログを出したいですね

この辺を真似てみます。

main.rs
use warp::Filter;

#[tokio::main]
async fn main() {
    std::env::set_var("RUST_LOG", "example");
    env_logger::init();

    // GET /hello/warp => 200 OK with body "Hello, warp!"
    let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name));

    let routes = hello.with(warp::log("example"));

    warp::serve(routes).run(([0, 0, 0, 0], 3000)).await;
}
[2021-01-07T13:22:19Z INFO  warp::filters::log] 192.168.16.1:58474 "GET /hello/warp HTTP/1.1" 200 "-" "curl/7.73.0" 84.539µs
[2021-01-07T13:22:20Z INFO  warp::filters::log] 192.168.16.1:58478 "GET /hello/warp HTTP/1.1" 200 "-" "curl/7.73.0" 81.013µs

こんな感じで出るようになりました。

warp::log で指定した名前が RUST_LOG で設定されていないと出ません。

filterとhandlerを真似てみる

このexampleが一番実際なにかアプリケーションを作った場合に近い気がするので、真似てみます。

main.rs
use warp::Filter;

pub mod filters;
pub mod handlers;

#[tokio::main]
async fn main() {
    std::env::set_var("RUST_LOG", "example");
    env_logger::init();

    let hello = filters::hello();

    let routes = hello.with(warp::log("example"));

    warp::serve(routes).run(([0, 0, 0, 0], 3000)).await;
}
handlers.rs
use std::convert::Infallible;

pub async fn hello(name: String) -> Result<impl warp::Reply, Infallible> {
    Ok(format!("Hello, {}!", name))
}
filters.rs
use super::handlers;
use warp::Filter;

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}
❯ curl -v http://localhost:3000/hello/warp
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /hello/warp HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.73.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 12
< date: Thu, 07 Jan 2021 13:42:14 GMT
<
* Connection #0 to host localhost left intact
Hello, warp!%

うん、動きました。

ちょっとハマったポイントとしては
use warp::Filter;
が必要だったってことですね。

filters.rs
use super::handlers;

pub fn hello() -> impl warp::Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

こんな風に書くと

app    | error[E0599]: no method named `and` found for struct `warp::filter::and::And<warp::filter::and::And<warp::filter::and::And<impl warp::Filter+std::marker::Copy, Exact<warp::path::internal::Opaque<__StaticPath>>>, impl warp::Filter+std::marker::Copy>, impl warp::Filter+std::marker::Copy>` in the current scope
app    |  --> src/filters.rs:5:10
app    |   |
app    | 5 |         .and(warp::get())
app    |   |          ^^^ method not found in `warp::filter::and::And<warp::filter::and::And<warp::filter::and::And<impl warp::Filter+std::marker::Copy, Exact<warp::path::internal::Opaque<__StaticPath>>>, impl warp::Filter+std::marker::Copy>, impl warp::Filter+std::marker::Copy>`
app    |   |
app    |   = help: items from traits can only be used if the trait is in scope
app    |   = note: the following trait is implemented but not in scope; perhaps add a `use` for it:
app    |           `use warp::Filter;`

こんな風に怒られます。
ちゃんと use warp::Filter; ってエラーに書いてあるのですぐ気づくっちゃ気づくんですが。
おそらくwarp::Filterにtraitがいろいろあるんですね。
なるほど。

テストを書いてみる

なるほど、filter毎に単体テストを書けるようなしくみがサポートされてるんですね。
良い( ・∀・)イイ!!

docker-compose run --rm app bash
cargo watch -x test

先程のページにかかれてるサンプル通り書いてみる

filters.rs
use super::handlers;
use warp::Filter;

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hello() {
        let filter = hello();

        let res = warp::test::request().path("/hello/warp").reply(&filter);

        assert_eq!(res.status(), 200);
    }
}
error[E0599]: no method named `status` found for opaque type `impl warp::Future` in the current scope
  --> src/filters.rs:20:24
   |
20 |         assert_eq!(res.status(), 200);
   |                        ^^^^^^ method not found in `impl warp::Future`
   |
help: consider `await`ing on the `Future` and calling the method on its `Output`
   |
20 |         assert_eq!(res.await.status(), 200);
   |                        ^^^^^^

おぉ、なるほど。Futureが返ってくるからawaitしないとイケナイらしい。

filters.rs
use super::handlers;
use warp::Filter;

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_hello() {
        let filter = hello();

        let res = warp::test::request()
            .path("/hello/warp")
            .reply(&filter)
            .await;

        assert_eq!(res.status(), 200);
    }
}
running 1 test
test filters::tests::test_hello ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

うん、通りましたね。

res.body も見てみます。

filters.rs
use super::handlers;
use warp::Filter;

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_hello() {
        let filter = hello();

        let res = warp::test::request()
            .path("/hello/warp")
            .reply(&filter)
            .await;

        assert_eq!(res.status(), 200);
        assert_eq!(res.body(), "Hello, warp");
    }
}
running 1 test
test filters::tests::test_hello ... FAILED

failures:

---- filters::tests::test_hello stdout ----
thread 'filters::tests::test_hello' panicked at 'assertion failed: `(left == right)`
  left: `b"Hello, warp!"`,
 right: `"Hello, warp"`', src/filters.rs:24:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    filters::tests::test_hello

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

あぁ、なるほど、バイナリか。

filters.rs
use super::handlers;
use warp::Filter;

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_hello() {
        let filter = hello();

        let res = warp::test::request()
            .path("/hello/warp")
            .reply(&filter)
            .await;

        assert_eq!(res.status(), 200);
        assert_eq!(res.body(), "Hello, warp!".as_bytes());
    }
}
running 1 test
test filters::tests::test_hello ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

パスしました。

JSONを返してみる

Handler

handlers.rs
use serde_derive::Serialize;
use std::convert::Infallible;

pub async fn hello(name: String) -> Result<impl warp::Reply, Infallible> {
    Ok(format!("Hello, {}!", name))
}

#[derive(Debug, Clone, PartialEq, Serialize)]
struct HelloJson {
    data: String,
}

pub async fn hello_json(name: String) -> Result<impl warp::Reply, Infallible> {
    Ok(warp::reply::json(&HelloJson {
        data: format!("Hello, {}!", name),
    }))
}

Filter

filters.rs
use super::handlers;
use warp::Filter;

pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    hello().or(hello_json())
}

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

pub fn hello_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / "json" / String)
        .and(warp::get())
        .and_then(handlers::hello_json)
}

#[cfg(test)]
mod tests {
    use super::*;
    use assert_json_diff::assert_json_eq;
    use serde_json::json;

    #[tokio::test]
    async fn test_hello() {
        let filter = api();

        let res = warp::test::request()
            .path("/hello/warp")
            .reply(&filter)
            .await;

        assert_eq!(res.status(), 200);
        assert_eq!(res.body(), "Hello, warp!".as_bytes());
    }

    #[tokio::test]
    async fn test_hello_json() -> anyhow::Result<()> {
        let filter = api();

        let res = warp::test::request()
            .path("/hello/json/warp")
            .reply(&filter)
            .await;

        assert_eq!(res.status(), 200);

        assert_json_eq!(
            serde_json::from_slice::<serde_json::Value>(res.body())?,
            json!({ "data": "Hello, warp!"})
        );

        Ok(())
    }
}
  • hello_json を追加
    • warp::reply::json で Serializeを実装した構造体を渡す
  • hello_json のテストを追加
    • hellohello_json を両方ハンドリングする api を追加
    • これをテスト対象にする
    • jsonの期待値は文字列で書くと辛いので、serde_json::jsonマクロにおまかせして、assert-json-diffを使ってassert

Test

running 2 tests
test filters::tests::test_hello ... ok
test filters::tests::test_hello_json ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

通りましたね。
なるほど。

Infalliable ってどんな型?

impl<T, U> TryFrom<U> for T where U: Into<T> {
    type Error = Infallible;

    fn try_from(value: U) -> Result<Self, Infallible> {
        Ok(U::into(value))  // Never returns `Err`
    }
}

なるほど
Result を返さなきゃいけないけど、絶対失敗しないので必ず Ok を返すような時の ResultのErrに使う型、ですかね?
将来的には ! という never 型になると?
ふむ、なるほど。

POSTでJSONデータを受け取ってみる

そろそろテスト駆動で行けそうなので、テストを先に書いてみる

Test

...
    #[tokio::test]
    async fn test_receive_json() -> anyhow::Result<()> {
        let body = json!({ "data": "Hello, warp!"});
        let res = warp::test::request()
            .method("POST")
            .path("/receive/json")
            .body(body.to_string())
            .reply(&api())
            .await;

        assert_eq!(res.status(), 200);

        assert_json_eq!(
            serde_json::from_slice::<serde_json::Value>(res.body())?,
            body,
        );

        Ok(())
    }
...
  • { "data": "..." } っていうJSONを投げる
  • 投げたJSONがそのまま返ってくる
...
running 3 tests
test filters::tests::test_hello ... ok
test filters::tests::test_receive_json ... FAILED
test filters::tests::test_hello_json ... ok

failures:

---- filters::tests::test_receive_json stdout ----
thread 'filters::tests::test_receive_json' panicked at 'assertion failed: `(left == right)`
  left: `404`,
 right: `200`', src/filters.rs:75:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    filters::tests::test_receive_json

test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
...

当然まだ404ですね

Filter

filters.rs
use super::handlers;
use warp::Filter;

pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    hello().or(hello_json()).or(receive_json()) // 追加
}

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / String)
        .and(warp::get())
        .and_then(handlers::hello)
}

pub fn hello_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("hello" / "json" / String)
        .and(warp::get())
        .and_then(handlers::hello_json)
}

// 追加
pub fn receive_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("receive" / "json")
        .and(warp::post())
        .and(warp::body::json())
        .and_then(handlers::receive_json)
}
...
  • receive_json を追加
    • methodは POST
    • bodyは JSON

Handler

handlers.rs
use serde_derive::{Deserialize, Serialize};
use std::convert::Infallible;

pub async fn hello(name: String) -> Result<impl warp::Reply, Infallible> {
    Ok(format!("Hello, {}!", name))
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HelloJson {
    data: String,
}

pub async fn hello_json(name: String) -> Result<impl warp::Reply, Infallible> {
    Ok(warp::reply::json(&HelloJson {
        data: format!("Hello, {}!", name),
    }))
}

pub async fn receive_json(body: HelloJson) -> Result<impl warp::Reply, Infallible> {
    Ok(warp::reply::json(&body))
}
  • HelloJson をそのまま使う
    • 受け取ったbodyをマッピングする為に Deserialize を deriveに追加
    • 引数で受け取るのでpubに変更

Test

running 3 tests
test filters::tests::test_hello ... ok
test filters::tests::test_hello_json ... ok
test filters::tests::test_receive_json ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

OK

helloをDRYにしたい

  • hello/{param}
  • hello/json/{param}

hello で始まるルーティングとしてまとめたい

filters.rs
use super::handlers;
use warp::Filter;

pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    hello().or(receive_json())
}

pub fn hello() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path("hello").and(hello_string().or(hello_json()))
}

pub fn hello_string() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!(String)
        .and(warp::get())
        .and_then(handlers::hello)
}

pub fn hello_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path!("json" / String)
        .and(warp::get())
        .and_then(handlers::hello_json)
}
  • warp::path("hello") の後につなげればいい
    • マクロではなく関数の方を使う

bodyのDeserializeができない場合のレスポンスはどうなるのか

    #[tokio::test]
    async fn test_receive_json_invalid() -> anyhow::Result<()> {
        let body = json!({ "hoge": "foo"});

        let res = warp::test::request()
            .method("POST")
            .path("/receive/json")
            .body(body.to_string())
            .reply(&api())
            .await;

        assert_eq!(res.status(), 400);
        assert_eq!(
            res.body(),
            "Request body deserialize error: missing field `data` at line 1 column 14".as_bytes()
        );

        Ok(())
    }

こんな感じになるようです
serdeのエラーメッセージがBodyに入って返ってくるんですね。

認証Filter入れてみたい

Authorizationヘッダを見て認証してみたい

Test

    #[tokio::test]
    async fn test_with_auth() -> anyhow::Result<()> {
        let body = json!({ "data": "foo"});

        let res = warp::test::request()
            .method("POST")
            .header("Authorization", "Bearer hogehoge")
            .path("/with_auth/json")
            .body(body.to_string())
            .reply(&api())
            .await;

        dbg!(res.body());
        assert_eq!(res.status(), 200);
        assert_eq!(
            serde_json::from_slice::<serde_json::Value>(res.body())?,
            json!({
                "user": { "id": 0, "name": "hoge" },
                "body": body,
            }),
        );

        Ok(())
    }

こんな感じを目指します。
Authorization ヘッダに Bearerトークンを入れてみる。

https://github.com/seanmonstar/warp/blob/6cc14a44ba/examples/rejections.rs#L80-L122
Rejectionをハンドリングしてレスポンスをカスタマイズできそう。
(今回はやらない)

Filter

https://blog.logrocket.com/jwt-authentication-in-rust/
これを参考にやってみます。
ログインは一旦すっとばして、トークンを受け取って認証するFilterを入れてみたい。

filters.rs
pub fn api() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    hello().or(receive_json()).or(with_auth_json())
}

...

pub fn with_auth_json() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone
{
    warp::path!("with_auth" / "json")
        .and(with_authenticate())
        .and(warp::post())
        .and(warp::body::json())
        .and_then(handlers::with_auth)
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct User {
    pub id: u64,
    pub name: String,
}

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("no auth header")]
    NoAuthHeaderError,
    #[error("invalid auth header")]
    InvalidAuthHeaderError,
}
impl warp::reject::Reject for Error {}

pub fn with_authenticate() -> impl Filter<Extract = (User,), Error = warp::Rejection> + Clone {
    warp::header::headers_cloned().and_then(authorize)
}

async fn authorize(
    headers: warp::http::HeaderMap<warp::http::HeaderValue>,
) -> Result<User, warp::Rejection> {
    match token_from_header(&headers) {
        Ok(token) => {
            if token != "hogehoge" {
                return Err(warp::reject::custom(Error::InvalidAuthHeaderError));
            }
            Ok(User {
                id: 0,
                name: "hoge".to_owned(),
            })
        }
        Err(e) => Err(warp::reject::custom(e)),
    }
}

fn token_from_header(
    headers: &warp::http::HeaderMap<warp::http::HeaderValue>,
) -> Result<String, Error> {
    let header = headers
        .get(warp::http::header::AUTHORIZATION)
        .ok_or_else(|| Error::NoAuthHeaderError)?;

    let value = std::str::from_utf8(header.as_bytes()).map_err(|_| Error::NoAuthHeaderError)?;

    if !value.starts_with("Bearer ") {
        return Err(Error::InvalidAuthHeaderError);
    }

    Ok(value.trim_start_matches("Bearer ").to_owned())
}
  • thiserrorでError型を定義
    • ヘッダーの処理で失敗した時に使う
    • ここはとりあえずanyhow::Errorで雑にやってもよかったかも
  • Authorization ヘッダーからトークンを取得
    • ヘッダーが無い場合や、Bearerトークンじゃない場合はエラー
  • トークンが hogehoge だったら User を返す
    • トークンが違ったらエラー
  • 後のhandlerにUserを渡す

Handler

handlers.rs
...
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WithAuthJson {
    user: User,
    body: HelloJson,
}
pub async fn with_auth(user: User, body: HelloJson) -> Result<impl warp::Reply, Infallible> {
    Ok(warp::reply::json(&WithAuthJson { user, body }))
}
  • filtersで with_authenticate がセットした User を第一引数で受け取る
    • filtersで定義したandの順番になるっぽい
    • なので、user -> bodyの順番
  • 受け取ったuserとbodyをそのままJSONとして返す
running 5 tests
test filters::tests::test_hello ... ok
test filters::tests::test_hello_json ... ok
test filters::tests::test_receive_json_invalid ... ok
test filters::tests::test_with_auth ... ok
test filters::tests::test_receive_json ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

テストは通りましたね。
なるほど。

まとめ

  • 一旦今回はWebAPIを実装する前提で最低限試してみたい事をちょろっとやってみました
  • Warpは基本的にFilterを実装して、つなげていく
    • わかりやすいといえばわかりやすい
  • こういうのどうやってやるの?があまり探しやすくはない
    • warpがpub useしている外部ライブラリ(例えばwarp::http)はそれがwarp外のcrateである事を知っていないとドキュメントまでたどり着くのが大変
    • exampleの種類は多いがあくまでexample
      • なんというかもう少し実際のWebアプリケーションなexampleがあると嬉しい
      • 例えばdieselと組み合わせてるサンプルみたいなのとか
  • テストは書きやすい
  • warpくらいシンプルなフレームワークもアリだなと思った
    • actix-webがRailsだとしたらwarpはSinatraみたいな雰囲気?
    • actix-webはRailsほどフルスタックではないけど…
  • どうでもいいことだけど、どうしても wrap ってtypoする。。。
  • actix-webとパフォーマンスを比較してみたい
  • 次回は sqlx と組み合わせて使ってみたい
9
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
yagince

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
9
Help us understand the problem. What is going on with this article?