在一个 MoonBit 项目中开发全栈应用#
本教程展示如何在一个 MoonBit 模块中构建一个小型全栈应用。
你将实现一套共享校验规则,并在以下两处复用:
frontend/:展示本地告警并调用后端backend/:再次校验并返回 JSON 响应
关键在于 supported-targets:
frontend/为jsbackend/为nativeshared/与目标平台无关
前置条件#
已安装 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"
}
步骤 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 allmoon test --deny-warn --target allHurl API 测试
现在你已经有了一个可执行的项目:前后端共享同一套校验契约与错误模型。