Next-js - Rust 革新全棧開發,Rust 沒那麼難

作者 | Josh Mo

譯者 | 核子可樂

策劃 | 丁曉昀

最近,shuttle 發佈了新的 Node.js CLI 包,允許用戶快速引導由 Next.js 前端加 Axum 後端(一種流行的 Rust Web 框架,以易於上手、語法簡單著稱)開發的應用程序。

本文打算構建的示例,是一個帶有登錄門戶的記事本應用程序,提供用戶註冊、用戶登錄、密碼重置等功能。用戶在登錄之後可以查看、創建、更新和刪除筆記內容。本文將主要關注 Rust 後端方面,對於 React.js/Next.js 前端不會過多着墨。

完整代碼倉庫請參閱此處(https://github.com/joshua-mo-143/nodeshuttle-example)。

馬上開始

運行以下命令,即可快速開始本次示例:

npx create-shuttle-app --ts

在按下回車鍵後,系統會提示我們輸入名稱——您可以隨意起名,之後系統會自動安裝 Rust 並引導一個使用 Next.js 的應用程序(由於這裏我們添加了 ts 標誌,所以使用的是 TypeScript);後端部分使用 Rust,再加上相應的 npm 命令,我們可以快速着手後端和前端的開發工作。這裏我們使用的後端框架爲 Axum,這是一套靈活的高性能框架,語法簡單而且與 tower_http(用於創建中間件的強大庫)高度兼容。

shuttle 是一個雲開發平臺,能夠簡化應用程序的部署流程。它最突出的優點就是 “基礎設施即代碼”,允許大家直接通過代碼定義基礎設施,無需藉助複雜的控制檯或外部 yaml.config 文件。這種方式不僅提高了代碼的清晰度,同時也能更好地保證編譯時的輸出質量。需要 Postgres 實例?只需添加相應註釋即可。shuttle 還支持 secrets(作爲環境變量)、靜態文件夾和狀態持久性。

接下來,我們需要安裝 sqlx-cli,這款命令行工具能幫助我們管理數據庫遷移。只須運行以下簡單命令,即可完成安裝:

cargo install sqlx-cli

這樣,只要前往項目文件夾內的後端目錄,我們就能使用 sqlx migrate add schema 創建數據庫遷移。此命令會添加一個遷移文件夾(如果之前不存在)和一個以_schema.sql 形式命名的新 SQL 文件,其中的 “schema” 部分代表我們的遷移名稱。

這個 SQL 文件包含以下內容:

-- backend/migrations/<timestamp>_schema.sql
DROP TABLE IF EXISTS sessions;
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    username VARCHAR UNIQUE NOT NULL,
    email VARCHAR UNIQUE NOT NULL,
    password VARCHAR NOT NULL,
    createdAt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 
);
CREATE TABLE IF NOT EXISTS notes (
    id SERIAL PRIMARY KEY,
    message VARCHAR NOT NULL,
    owner VARCHAR NOT NULL,
    createdAt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 
);
INSERT INTO notes (message, owner) VALUES ('Hello world!', 'user');
CREATE TABLE IF NOT EXISTS sessions (
    id SERIAL PRIMARY KEY,
    session_id VARCHAR NOT NULL UNIQUE,
    user_id INT NOT NULL UNIQUE
);

遷移會自動運行。但如果大家想要手動操作,也可以使用 sqlx migrate run --database-url。這種操作之所以可行,是因爲我們已經將 SQL 文件設置爲冪等,就是說只要已經存在該表、則不再重複創建。這裏我們刪除會話表,這樣當應用程序重新上傳之後,由於原先的 cookie 已經失效,用戶就必須重新登錄。

現在設置已經完成,馬上進入正式開發!

前端

在這款應用中,我們需要以下幾個頁面:

大家可以通過以下方式克隆本文中的前端示例:

git clone https://github.com/joshua-mo-143/nodeshuttle-example-frontend

克隆的代碼倉庫包含一個預先設置好的 src 目錄,如下圖所示:

其中 components 文件夾中包含兩個佈局組件,我們需要將頁面組件嵌套在其中;另外還有一個用於在儀表板索引頁面中編輯記錄的 modal。Pages 文件夾則包含我們將在應用中使用的各相關頁面組件(文件名代表相應路徑)。

這裏的 CSS 使用 TailwindCSS,並選擇 Zustand 保證在不涉及太多模板的情況下實現簡單的基本狀態管理。

當用戶登錄之後,已有消息將顯示爲以下形式:

在後端構建完成之後,用戶就能通過前端註冊和登錄(使用基於 cookie 會話的身份驗證機制),並查看、創建、編輯和刪除自己的消息。如果用戶忘記了密碼,還可以通過輸入電子郵件來重置密碼內容。

如果大家對示例中的前端不滿意,也可以參考 GitHub 代碼倉庫(https://github.com/joshua-mo-143/nodeshuttle-example)來了解 API 調用和狀態管理的設置方式。

現在前端部分已經完成,接下來就是後端環節了!

後端

前往 backend 文件夾,我們會看到一個名爲 main.rs 的文件。其中包含一個函數,此函數會創建一個基礎路由程序並返回 “Hello,world!” 我們將使用此文件作爲應用程序的入口點,然後創建我們在 main 函數中調用的其他文件。

請確保您的 Cargo.toml 文件中包含以下內容:

# Cargo.toml
[package]
name = "static-next-server"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
# the rust framework we will be using - https://github.com/tokio-rs/axum/
axum = "0.6.1"
# extra functionality for Axum https://github.com/tokio-rs/axum/
axum-extra = { version = "0.4.2", features = ["spa", "cookie-private"] }
# encryption hashing for passwords - https://github.com/Keats/rust-bcrypt
bcrypt = "0.13.0"
# used for writing the CORS layer - https://github.com/hyperium/http
http = "0.2.9"
# send emails over SMTP - https://github.com/lettre/lettre
lettre = "0.10.3"
# random number generator (for creating a session id) - https://github.com/rust-random/rand
rand = "0.8.5"
# used to be able to deserialize structs from JSON - https://github.com/serde-rs/serde
serde = { version = "1.0.152", features = ["derive"] }
# environment variables on shuttle
shuttle-secrets = "0.12.0"
# the service wrapper for shuttle
shuttle-runtime = "0.12.0"
# allow us to use axum with shuttle
shuttle-axum = "0.12.0"
# this is what we use to get a shuttle-provisioned database
shuttle-shared-db = { version = "0.12.0", features = ["postgres"] }
# shuttle static folder support
shuttle-static-folder = "0.12.0"
# we use this to query and connect to a database - https://github.com/launchbadge/sqlx/
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres"] }
# middleware for axum router - https://github.com/tower-rs/tower-http
tower-http = { version = "0.4.0", features = ["cors"] }
# pre-req for using shuttle runtime   
tokio = "1.26.0"
# get a time variable for setting cookie max age
time = "0.3.20"

完成之後,接下來就是設置主函數,這樣就能使用 shuttle_shared_db 和 shuttle_secrets 來獲取 shuttle 免費配置的數據庫並使用 secrets,具體方式如下(包括基於 cookie 的會話存儲功能,爲簡潔起見較爲粗糙):

// main.rs
 #[derive(Clone)]
pub struct AppState {
    postgres: PgPool,
    key: Key
}
impl FromRef<AppState> for Key {
    fn from_ref(state: &AppState) -> Self {
        state.key.clone()
    }
}
#[shuttle_runtime::main]
async fn axum(
    #[shuttle_static_folder::StaticFolder] static_folder: PathBuf,
    #[shuttle_shared_db::Postgres] postgres: PgPool,
    #[shuttle_secrets::Secrets] secrets: SecretStore,
) -> shuttle_axum::ShuttleAxum {
    sqlx::migrate!().run(&postgres).await;
    let state = AppState {
        postgres,
        key: Key::generate()
    };
    let router = create_router(static_folder, state);
    Ok(router.into())
}

現在就可以創建路由程序了!我們首先要在 backend 目錄的 src 文件夾中創建一個 router.rs 文件。我們的大部分路由程序代碼都將存放在這裏,並在準備好之後將最終版路由程序的函數導入到主文件當中。

現在打開 router.rs 文件並創建一個函數,該函數將返回一個能夠路由至註冊和登錄的路由程序:

// router.rs
// typed request body for logging in - Deserialize is enabled via serde so it can be extracted from JSON responses in axum
#[derive(Deserialize)]
pub struct LoginDetails {
    username: String,
    password: String,
}
pub fn create_router(state: AppState, folder: PathBuf) -> Router {
// create a router that will host both of our new routes once we create them
    let api_router = Router::new()
           .route("/register", post(register))
           .route("/login, post(login))
           .with_state(state);
// return a router that nests our API router in an "/api" route and merges it with our static files
   Router::new()
       .nest("/api", api_router)
       .merge(SpaRouter::new("/", static_folder).index_file("index.html"))
}

可以看到,接下來要做的就是編寫路由程序中使用的函數。另外,我們也可以簡單將多個方法串連起來,藉此在同一路由內使用多個請求方法(後文將具體介紹)。

// backend/src/router.rs
pub async fn register(
// this is the struct we implement and use in our router - we will need to import this from our main file by adding "use crate::AppState;" at the top of our app
    State(state): State<AppState>,
// this is the typed request body that we receive from a request - this comes from the axum::Json type
    Json(newuser): Json<LoginDetails>,
) -> impl IntoResponse { 
// avoid storing plaintext passwords - when a user logs in, we will simply verify the hashed password against the request. This is safe to unwrap as this will basically never fail
     let hashed_password = bcrypt::hash(newuser.password, 10).unwrap();
    let query = sqlx::query("INSERT INTO users (username, , email, password) values ($1, $2, $3)")
// the $1/$2 denotes dynamic variables in a query which will be compiled at runtime - we can bind our own variables to them like so:
        .bind(newuser.username)
        .bind(newuser.email)
        .bind(hashed_password)
        .execute(&state.postgres);
// if the request completes successfully, return CREATED status code - if not, return BAD_REQUEST
    match query.await {
        Ok(_) => (StatusCode::CREATED, "Account created!".to_string()).into_response(),
        Err(e) => (
            StatusCode::BAD_REQUEST,
            format!("Something went wrong: {e}"),
        )
            .into_response(),
    }
}

我們在這裏對密碼做散列處理,通過 SQLx 設置查詢以創建新用戶。如果成功,則返回 402 Created 狀態碼;如果不成功,則返回 400 Bad Request 狀態碼以指示錯誤。

模式匹配是 Rust 中一種非常強大的錯誤處理機制,而且提供多種使用方式:我們可以使用 if let else 和 let else,二者都涉及模式匹配,後文將具體介紹。

// backend/src/router.rs
pub async fn login(
    State(mut state): State<AppState>,
    jar: PrivateCookieJar,
    Json(login): Json<LoginDetails>,
) -> Result<(PrivateCookieJar, StatusCode), StatusCode> {
    let query = sqlx::query("SELECT * FROM users WHERE username = $1")
        .bind(&login.username)
        .fetch_optional(&state.postgres);
    match query.await {
        Ok(res) => {
// if bcrypt cannot verify the hash, return early with a BAD_REQUEST error
            if bcrypt::verify(login.password, res.unwrap().get("password")).is_err() {
                return Err(StatusCode::BAD_REQUEST);
            }
// generate a random session ID and add the entry to the hashmap 
                let session_id = rand::random::<u64>().to_string();
                sqlx::query("INSERT INTO sessions (session_id, user_id) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET session_id = EXCLUDED.session_id")
                .bind(&session_id)
                .bind(res.get::<i32, _>("id"))
                .execute(&state.postgres)
                .await
                .expect("Couldn't insert session :(");
            let cookie = Cookie::build("foo", session_id)
                .secure(true)
                .same_site(SameSite::Strict)
                .http_only(true)
                .path("/")
                .finish();
// propogate cookies by sending the cookie as a return type along with a status code 200
            Ok((jar.add(cookie), StatusCode::OK))
        }
// if the query fails, return status code 400
        Err(_) => Err(StatusCode::BAD_REQUEST),
    }
}

可以看到,請求僅採用各類 JSON 請求主體(因爲我們將請求主體設定爲 axum::Json 類型,所以它只會接受帶有「username」和「password」JSON 請求主體的請求)。這樣的 struct 必須實現 serde::Deserialize ,因爲我們需要從 JSON 中提取數據,而且 JSON 請求參數本身將作爲我們傳遞給路由函數的最後一個參數。

我們在登錄請求中使用了名爲 PrivateCookieJar 的 struct。通過這種方式,我們既可以自動處理 HTTP cookie,又不需要爲其顯式設置標題頭(爲了傳播其中的變更,我們需要將其設置爲返回類型並返回變更)。當用戶想要訪問受保護的路由時,需要從 cookie jar 當中獲取值,再根據保存在數據庫內的會話 ID 對其進行驗證。因爲使用的是私有 cookie jar,所以保存在客戶端的任何 cookie 都將使用我們在初始 struct 內創建的密鑰進行加密,且每次應用啓動時都會生成一個新密鑰。

現在我們已經添加了用於登錄的路由,接下來看看如何添加用於註銷的路由和用於驗證會話的中間件:

// backend/src/router.rs
pub async fn logout(State(state): State<AppState>, jar: PrivateCookieJar) -> Result<PrivateCookieJar, StatusCode> {
    let Some(cookie) = jar.get("foo").map(|cookie| cookie.value().to_owned()) else {
        return Ok(jar)
    };
    let query = sqlx::query("DELETE FROM sessions WHERE session_id = $1")
        .bind(cookie)
        .execute(&state.postgres);
        match query.await {
        Ok(_) => Ok(jar.remove(Cookie::named("foo"))),
        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR)       
    }
}
pub async fn validate_session<B>(
    jar: PrivateCookieJar,
    State(state): State<AppState>,
// Request<B> and Next<B> are required types for middleware from a function in axum
    request: Request<B>,
    next: Next<B>,
) -> (PrivateCookieJar, Response) {
// attempt to get the cookie - if it can't find a cookie, return 403
    let Some(cookie) = jar.get("foo").map(|cookie| cookie.value().to_owned()) else {
        println!("Couldn't find a cookie in the jar");
        return (jar,(StatusCode::FORBIDDEN, "Forbidden!".to_string()).into_response())
    };
// attempt to find the created session
    let find_session = sqlx::query("SELECT * FROM sessions WHERE session_id = $1")
                .bind(cookie)
                .execute(&state.postgres)
                .await;
// if the created session is OK, carry on as normal and run the route - else, return 403
    match find_session {
        Ok(res) => (jar, next.run(request).await),
        Err(_) => (jar, (StatusCode::FORBIDDEN, "Forbidden!".to_string()).into_response())
    }
}

可以看到,在註銷路由這部分,我們會嘗試銷燬會話、返回 cookie 刪除;至於驗證路由,我們嘗試獲取會話 cookie,並保證 cookie 會話在數據庫內有效。

下面來看如何爲數據庫內的各項記錄創建最基本的 CRUD 功能。這裏我們創建一個使用 sqlx::FromRow 的 struct,這樣就能輕鬆從數據庫中提取記錄,具體代碼如下所示:

// src/backend/router.rs
#[derive(sqlx::FromRow, Deserialize, Serialize)]
pub struct Note {
    id: i32,
    message: String,
    owner: String,
}

之後,我們就可以直接使用 sqlx::query_as 並將該變量分類爲 struct 的向量,藉此實現預期功能,如下所示:

// src/backend/router.rs
pub async fn view_records(State(state): State<AppState>) -> Json<Vec<Note>> {
    let notes: Vec<Note> = sqlx::query_as("SELECT * FROM notes ")
        .fetch_all(&state.postgres)
        .await.unwrap();
    Json(notes)
}

很明顯,我們要做的就是通過連接查詢數據庫,並確保我們分類後的返回 struct 上有 sqlx::FromRow 派生宏。通過同樣的方式,我們也可以輕鬆編寫出其他路由:

// backend/src/router.rs
#[derive(Deserialize)]
pub struct RecordRequest {
    message: String,
    owner: String
}
pub async fn create_record(
    State(state): State<AppState>,
    Json(request): Json<RecordRequest>,
) -> Response {
    let query = sqlx::query("INSERT INTO notes (message, owner) VALUES ($1, $2)")
        .bind(request.message)
        .bind(request.owner)
        .execute(&state.postgres);
    match query.await {
        Ok(_) => (StatusCode::CREATED, "Record created!".to_string()).into_response(),
        Err(err) => (
            StatusCode::BAD_REQUEST,
            format!("Unable to create record: {err}"),
        )
            .into_response(),
    }
}
// note here: the "path" is simply the id URL slug, which we will define later
pub async fn edit_record(
    State(state): State<AppState>,
    Path(id): Path<i32>,
    Json(request): Json<RecordRequest>,
) -> Response {
    let query = sqlx::query("UPDATE notes SET message = $1 WHERE id = $2 AND owner = $3")
        .bind(request.message)
        .bind(id)
        .bind(request.owner)
        .execute(&state.postgres);
    match query.await {
        Ok(_) => (StatusCode::OK, format!("Record {id} edited ")).into_response(),
        Err(err) => (
            StatusCode::BAD_REQUEST,
            format!("Unable to edit message: {err}"),
        )
            .into_response(),
    }
}
pub async fn destroy_record(State(state): State<AppState>, Path(id): Path<i32>) -> Response {
    let query = sqlx::query("DELETE FROM notes WHERE id = $1")
        .bind(id)
        .execute(&state.postgres);
    match query.await {
        Ok(_) => (StatusCode::OK, "Record deleted".to_string()).into_response(),
        Err(err) => (
            StatusCode::BAD_REQUEST,
            format!("Unable to edit message: {err}"),
        )
            .into_response(),
    }
}

現在,我們已經爲這款 Web 應用創建了所有基本功能!但在合併全部路由之前,我們還有最後一項工作。如果用戶想要重置密碼,應當如何操作?我們當然應該再提供一條自助式的密碼重置路由,下面馬上開始。

// backend/src/router.rs
pub async fn forgot_password(
    State(state): State<AppState>,
    Json(email_recipient): Json<String>,
) -> Response {
    let new_password = Alphanumeric.sample_string(&mut rand::thread_rng(), 16);
let hashed_password = bcrypt::hash(&new_password, 10).unwrap();
    sqlx::query("UPDATE users SET password = $1 WHERE email = $2")
            .bind(hashed_password)
            .bind(email_recipient)
            .execute(&state.postgres)
            .await;
    let credentials = Credentials::new(state.smtp_email, state.smtp_password);
    let message = format!("Hello!\n\n Your new password is: {new_password} \n\n Don't share this with anyone else. \n\n Kind regards, \nZest");
    let email = Message::builder()
        .from("noreply <your-gmail-address-here>".parse().unwrap())
        .to(format!("<{email_recipient}>").parse().unwrap())
        .subject("Forgot Password")
        .header(ContentType::TEXT_PLAIN)
        .body(message)
        .unwrap();
// build the SMTP relay with our credentials - in this case we'll be using gmail's SMTP because it's free
    let mailer = SmtpTransport::relay("smtp.gmail.com")
        .unwrap()
        .credentials(credentials)
        .build();
// this part x`doesn't really matter since we don't want the user to explicitly know if they've actually received an email or not for security purposes, but if we do then we can create an output based on what we return to the client
    match mailer.send(&email) {
        Ok(_) => (StatusCode::OK, "Sent".to_string()).into_response(),
        Err(e) => (StatusCode::BAD_REQUEST, format!("Error: {e}")).into_response(),
    }
}

我們還需要在 Cargo.toml 層級上使用 Secrets.toml 和 Secrets.dev.toml 文件來添加必要的 secrets。爲此,我們需要使用以下格式:

# Secrets.toml
SMTP_EMAIL="your-email-goes-here"
SMTP_PASSWORD="your-email-password-goes-here"
DOMAIN="<your-project-name-from-shuttle-toml>.shuttleapp.rs"
# You can create a Secrets.dev.toml to use secrets in a development environment - in this case, you can set domain to "127.0.0.1" and copy the other two variables as required.

現在應用已經開發完成,接下來就是要爲應用整體建立出口路由程序了。我們可以簡單進行路由嵌套,並把中間件附加到受保護的路由上,如下所示:

// backend/src/router.rs
pub fn api_router(state: AppState) -> Router {
// CORS is required for our app to work
    let cors = CorsLayer::new()
        .allow_credentials(true)
        .allow_methods(vec![Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers(vec![ORIGIN, AUTHORIZATION, ACCEPT])
        .allow_origin(state.domain.parse::<HeaderValue>().unwrap());
// declare the records router
    let notes_router = Router::new()
        .route("/", get(view_records))
        .route("/create", post(create_record))
        .route(
// you can add multiple request methods to a route like this
            "/:id",       get(view_one_record).put(edit_record).delete(destroy_record),
        )
        .route_layer(middleware::from_fn_with_state(
            state.clone(),
            validate_session,
        ));
// the routes in this router should be public, so no middleware is required
    let auth_router = Router::new()
        .route("/register", post(register))
        .route("/login", post(login))
        .route("/forgot", post(forgot_password))
        .route("/logout", get(logout));
// return router that uses all routes from both individual routers, but add the CORS layer as well as AppState which is defined in our entrypoint function
    Router::new()
        .route("/health", get(health_check))
        .nest("/notes", notes_router)
        .nest("/auth", auth_router)
        .with_state(state)
        .layer(cors)
}

我們可以簡單定義兩個路由程序來創建一個 API 路由程序,每個路由程序對應自己的路由路徑(路由程序受到保護,只有會話通過驗證時纔會運行相應路由),之後直接返回一個帶有健康檢查的路由,嵌套我們之前的兩個路由,最後爲路由程序添加 CORS 和應用狀態。

我們的最終路由函數大致如下:

// backend/src/router.rs
pub fn create_router(static_folder: PathBuf, state: AppState) -> Router {
    let api_router = api_router(state);
// merge our static file assets
    Router::new()
        .nest("/api", api_router)
        .merge(SpaRouter::new("/", static_folder).index_file("index.html"))
}

我們接下來要在主函數(main.rs 當中)的初始入口點函數中使用此函數來生成路由程序,如下所示:

#[derive(Clone)]
pub struct AppState {
    postgres: PgPool,
    key: Key,
    smtp_email: String,
    smtp_password: String,
    domain: String,
}
impl FromRef<AppState> for Key {
    fn from_ref(state: &AppState) -> Self {
        state.key.clone()
    }
}
#[shuttle_runtime::main]
async fn axum(
    #[shuttle_static_folder::StaticFolder] static_folder: PathBuf,
    #[shuttle_shared_db::Postgres] postgres: PgPool,
    #[shuttle_secrets::Secrets] secrets: SecretStore,
) -> shuttle_axum::ShuttleAxum {
    sqlx::migrate!()
        .run(&postgres)
        .await
        .expect("Something went wrong with migrating :(");
    let smtp_email = secrets
        .get("SMTP_EMAIL")
        .expect("You need to set your SMTP_EMAIL secret!");
    let smtp_password = secrets
        .get("SMTP_PASSWORD")
        .expect("You need to set your SMTP_PASSWORD secret!");
// we need to set this so we can put it in our CorsLayer
    let domain = secrets
        .get("DOMAIN")
        .expect("You need to set your DOMAIN secret!");
    let state = AppState {
        postgres,
        key: Key::generate(),
        smtp_email,
        smtp_password,
        domain,
    };
    let router = create_router(static_folder, state);
    Ok(router.into())   
}

請注意,對於從文件導入的函數,如果其位於前面提到的同一文件目錄當中(use router),則需要在 lib.rs 文件中對其做定義;如果大家需要將函數從一個文件導入至另一個非主入口點文件中,也得進行同樣的操作。

現在編程部分全部結束,大家可以試試實際部署效果了!

部署

感謝 shuttle,整個部署流程非常簡單,只需在項目的根目錄中運行 npm run deploy 即可。如果沒有錯誤,shuttle 會啓動我們的應用並返回部署信息列表和由 shuttle 配置的數據庫連接字符串。如果需要再次查找此數據庫字符串,可以在項目的 backend 目錄下運行 cargo shuttle status 命令。

在實際部署之前,大家可能還需要提前運行 cargo fmt 和 cargo clippy,因爲 Web 服務的構建過程中可能出現警告或錯誤。如果沒有這些組件,大家也可以分別用 rustup component add rustfmt 和 rustup component add clippy 來替代——這裏向各位 Rust 開發者強烈推薦這兩款工具,絕對是您工具箱中的必備選項。

總結

感謝大家閱讀本文!希望這篇文章能帶您深入瞭解如何輕鬆構建 Rust Web 服務。過去幾年間,Rust 實現了顯著發展,也降低了新手學習的准入門檻。如果大家還停留在 Rust“生人勿近” 的舊觀念中,那實在是大可不必,現在正是上手體驗的好時機。相信 Rust 強大的功能和愈發完善的用戶友好度會給您留下深刻印象。

原文鏈接:

https://joshmo.hashnode.dev/nextjs-and-rust-an-innovative-approach-to-full-stack-development

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/x5SHfqoW26XrEchEpWzzfg