在一个 MoonBit 项目中开发全栈应用#

本教程展示如何在一个 MoonBit 模块中构建一个小型全栈应用。

你将实现一套共享校验规则,并在以下两处复用:

  • frontend/:展示本地告警并调用后端

  • backend/:再次校验并返回 JSON 响应

关键在于 supported-targets

  • frontend/js

  • backend/native

  • shared/ 与目标平台无关

前置条件#

  • 已安装 MoonBit 工具链

  • 已安装用于 API 测试的 hurl

步骤 1:创建模块#

moon new fullstack_one_project
cd fullstack_one_project
moon add moonbitlang/async
moon add moonbit-community/rabbita

项目结构:

fullstack_one_project
├── Makefile
├── moon.mod.json
├── backend
│   ├── api.hurl
│   ├── index.html
│   ├── main.mbt
│   └── moon.pkg
├── frontend
│   ├── main.mbt
│   └── moon.pkg
└── shared
    ├── moon.pkg
    ├── shared_test.mbt
    └── task.mbt

模块配置:

{
  "name": "moonbit-community/fullstack-one-project-doc",
  "version": "0.1.0",
  "deps": {
    "moonbitlang/async": "0.16.6",
    "moonbit-community/rabbita": "0.11.5"
  },
  "preferred-target": "native",
  "supported-targets": "+wasm+wasm-gc+js+native"
}

步骤 2:实现共享领域校验#

shared/ 中定义带 derive(ToJson, FromJson) 的请求/响应类型,以及一个基于 suberror 的校验器。前后端都导入这个包。

import {
  "moonbitlang/core/json" @json,
}
pub(all) struct SubmitTitleRequest {
  title : String
} derive(Eq, ToJson, FromJson)

///|
pub(all) suberror TitleValidationError {
  EmptyTitle
  TooLong(Int)
  ForbiddenHash
} derive(Eq, ToJson, FromJson)

///|
pub(all) enum SubmitTitleResponse {
  Accepted(String)
  ValidationError(TitleValidationError)
  InvalidJson
} derive(Eq, ToJson, FromJson)

///|
pub fn validate_request(
  request : SubmitTitleRequest,
) -> Unit raise TitleValidationError {
  let title = request.title.trim().to_string()
  if title.length() == 0 {
    raise EmptyTitle
  } else if title.length() > 24 {
    raise TooLong(title.length())
  } else if title.rev_find("#") is Some(_) {
    raise ForbiddenHash
  }
}

///|
pub fn warning_text(err : TitleValidationError) -> String {
  match err {
    EmptyTitle => "title cannot be empty"
    TooLong(length) => "title is too long (\{length}), max is 24"
    ForbiddenHash => "title cannot contain '#'"
  }
}

///|
pub impl Show for SubmitTitleResponse with output(self, logger) {
  let text = match self {
    Accepted(title) => "accepted: \{title}"
    ValidationError(err) => "validation_error: \{warning_text(err)}"
    InvalidJson => "invalid_json: invalid request json"
  }
  logger.write_string(text)
}

步骤 3:实现前端(js#

前端行为:

  • 用共享规则在本地校验标题

  • 若通过校验,则向后端 /submit 发送 POST

  • 展示后端响应文本

import {
  "moonbit-community/fullstack-one-project-doc/shared" @shared,
  "moonbitlang/core/json" @json,
  "moonbit-community/rabbita" @rabbita,
  "moonbit-community/rabbita/html" @html,
  "moonbit-community/rabbita/http" @rhttp,
}

supported_targets = "js"

options(
  "is-main": true,
)
fn main {
  let app = @rabbita.cell(
    model={ title: "", warning: None, server_message: None },
    update=(dispatch, msg, model) => {
      match msg {
        Edit(title) => {
          let warning = local_warning(title)
          (@rabbita.none, { title, warning, server_message: None })
        }
        Submit =>
          match model.warning {
            Some(message) =>
              (
                @rabbita.none,
                { ..model, server_message: Some("not sent: \{message}") },
              )
            None => {
              let request = @shared.SubmitTitleRequest::{ title: model.title }
              let request_json = request.to_json().stringify()
              let expect : @rhttp.Expecting[@rabbita.Cmd, Unit] = @rhttp.Expecting::Text(result => {
                  dispatch(ServerReplied(result))
                },
              )
              let cmd = @rhttp.post(
                "http://127.0.0.1:8080/submit",
                @rhttp.Body::Text(request_json),
                expect~,
              )
              (
                cmd,
                { ..model, server_message: Some("sending json request...") },
              )
            }
          }
        ServerReplied(result) => {
          let server_message = match result {
            Ok(raw_json) =>
              try {
                let response : @shared.SubmitTitleResponse = @json.from_json(
                  @json.parse(raw_json),
                )
                Some("\{response}")
              } catch {
                _ => Some("invalid backend response json")
              }
            Err(err) => Some("request failed: \{err}")
          }
          (@rabbita.none, { ..model, server_message, })
        }
      }
    },
    view=(dispatch, model) => {
      let warning_line = match model.warning {
        Some(message) => p("warning: \{message}")
        None => p("local validation passed")
      }
      let server_line = match model.server_message {
        Some(response) => p("backend response: \{response}")
        None => p("backend response: (none yet)")
      }
      let value = model.title
      div([
        h2("Shared Validation Demo"),
        input(
          input_type=Text,
          value~,
          on_input=text => dispatch(Edit(text)),
          nothing,
        ),
        button(on_click=dispatch(Submit), "Submit as JSON"),
        warning_line,
        server_line,
      ])
    },
  )
  @rabbita.new(app).mount("app")
}

步骤 4:实现后端(native#

后端行为:

  • GET / 映射到静态文件 backend/index.html

  • GET /frontend.js 映射到前端构建产物

  • 用共享校验处理 POST /submit 并返回 JSON 响应

import {
  "moonbit-community/fullstack-one-project-doc/shared" @shared,
  "moonbitlang/core/json" @json,
  "moonbitlang/async",
  "moonbitlang/async/fs" @fs,
  "moonbitlang/async/http" @http,
  "moonbitlang/async/socket" @socket,
  "moonbitlang/async/stdio",
}

supported_targets = "native"

options(
  "is-main": true,
)
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Shared Validation Demo</title>
  </head>
  <body>
    <h1>Shared Validation Demo</h1>
    <p>Backend serves this page and the built frontend bundle.</p>
    <div id="app"></div>
    <script src="/frontend.js"></script>
  </body>
</html>
async fn main {
  @stdio.stdout.write("starting backend on http://127.0.0.1:8080\n")
  let server = @http.Server::new(@socket.Addr::parse("127.0.0.1:8080")) catch {
    err => {
      @stdio.stdout.write("failed to start backend: \{err}\n")
      return
    }
  }

  server.run_forever((request, body, conn) => {
    match (request.meth, request.path) {
      (Get, "/") =>
        send_file(
          conn, index_html_path, html_headers, "missing backend/index.html",
        )
      (Get, "/frontend.js") =>
        send_file(
          conn, frontend_js_path, js_headers, "missing frontend bundle; run `moon build frontend --target js`",
        )
      (Post, "/submit") => {
        let raw_body = body.read_all().text() catch { _ => "" }
        let response : @shared.SubmitTitleResponse = try {
          let request : @shared.SubmitTitleRequest = @json.from_json(
            @json.parse(raw_body),
          )
          try {
            @shared.validate_request(request)
            @shared.SubmitTitleResponse::Accepted(
              request.title.trim().to_string(),
            )
          } catch {
            err => @shared.SubmitTitleResponse::ValidationError(err)
          }
        } catch {
          _ => @shared.SubmitTitleResponse::InvalidJson
        }
        let code = match response {
          @shared.SubmitTitleResponse::Accepted(_) => 200
          _ => 400
        }
        let reason = if code == 200 { "OK" } else { "BadRequest" }
        conn
        ..send_response(code, reason, extra_headers=json_headers)
        ..write(response.to_json().stringify())
        .end_response()
      }
      _ =>
        conn
        ..send_response(404, "NotFound", extra_headers=text_headers)
        ..write("Not Found")
        .end_response()
    }
  })
}

步骤 5:使用 Makefile 快捷命令#

.PHONY: help build-frontend run-backend check test api-test verify verify-all clean

help:
	@echo "Targets:"
	@echo "  make build-frontend  Build frontend JS bundle"
	@echo "  make run-backend     Run backend server on 127.0.0.1:8080"
	@echo "  make check           Run moon check for all targets"
	@echo "  make test            Run moon test for all targets"
	@echo "  make api-test        Run Hurl API tests against local backend"
	@echo "  make verify          Run check + test"
	@echo "  make verify-all      Run verify + api-test"
	@echo "  make clean           Remove build artifacts"

build-frontend:
	moon build frontend --target js

run-backend:
	moon run backend --target native

check:
	moon check --deny-warn --target all

test:
	moon test --deny-warn --target all

api-test: build-frontend
	@command -v hurl >/dev/null 2>&1 || { echo "hurl is required for api-test"; exit 1; }
	@set -eu; \
		moon run backend --target native >/tmp/fullstack-one-project-backend.log 2>&1 & \
		pid=$$!; \
		trap 'kill $$pid >/dev/null 2>&1 || true' EXIT INT TERM; \
		sleep 1; \
		hurl --test backend/api.hurl

verify: check test

verify-all: verify api-test

clean:
	rm -rf _build

常用工作流:

make build-frontend
make run-backend

然后在浏览器中打开 http://127.0.0.1:8080/

步骤 6:用 Hurl 测试 API#

Hurl 测试套件:

GET http://127.0.0.1:8080/
HTTP 200
[Asserts]
body contains "<div id=\"app\"></div>"

GET http://127.0.0.1:8080/frontend.js
HTTP 200
[Asserts]
body contains "function"

POST http://127.0.0.1:8080/submit
Content-Type: application/json
{
  "title": "Write docs"
}
HTTP 200
[Asserts]
jsonpath "$[0]" == "Accepted"
jsonpath "$[1]" == "Write docs"

POST http://127.0.0.1:8080/submit
Content-Type: application/json
{
  "title": "bad #title"
}
HTTP 400
[Asserts]
jsonpath "$[0]" == "ValidationError"
jsonpath "$[1]" == "ForbiddenHash"

POST http://127.0.0.1:8080/submit
Content-Type: application/json
{
  "title": "01234567890123456789012345"
}
HTTP 400
[Asserts]
jsonpath "$[0]" == "ValidationError"
jsonpath "$[1][0]" == "TooLong"
jsonpath "$[1][1]" == 26

POST http://127.0.0.1:8080/submit
Content-Type: application/json
```
{"title":
```
HTTP 400
[Asserts]
jsonpath "$" == "InvalidJson"

运行:

make api-test

这会验证:

  • 静态 GET /GET /frontend.js

  • 合法提交(200

  • 标题非法时被拒绝提交(400

  • JSON 输入格式错误时被拒绝提交(400

步骤 7:验证全部内容#

make verify-all

会执行:

  • moon check --deny-warn --target all

  • moon test --deny-warn --target all

  • Hurl API 测试

现在你已经有了一个可执行的项目:前后端共享同一套校验契约与错误模型。