跳到正文

诊所预约:从 0 到 1 ​

这是一条从 0 到 1 的引导项目路径,通过真实业务不变量(无论请求从哪里来都必须保持的规则)学习 go-like,而不是再做一个泛化 Todo list。页面描述目标结构和可执行 checkpoint,不声称目标目录已经作为可复制运行的完整应用提交。项目是一个诊所预约服务,包含进程内 policy service(负责校验预约规则的内部服务)、作为权威来源的预约 repository、可丢弃的 availability cache、health endpoints,以及一个明确的 application lifecycle。

仓库里已经有 examples/healthcare-appointments,本指南以它为起点。它的 policy service 使用 defineService("appointment-policy.v1") 的 check、withEndpoint("memory://appointment-policy.v1"),以及 serviceError(..., 409)。下面的片段与该示例一致。

业务不变量 ​

服务必须保持五条规则:

  1. 同一位医生不能有时间重叠的 active appointments。
  2. 取消预约后,时间段重新可用。
  3. 使用同一个 appointment ID 重复提交同一预约请求时,操作必须幂等。
  4. 复用 appointment ID 但提交不同预约内容时,必须拒绝。
  5. Availability 只作为加速手段缓存;repository 仍然是权威来源。

当前仓库示例用内存 repository 实现了前四条规则,并通过内部 policy service 校验预约最大时长。它没有声称提供数据库、分布式锁、持久化 cache、authentication 或生产级预约流程。

你会构建什么 ​

text
clinic-appointments/
|-- package.json
|-- tsconfig.json
|-- README.md
|-- src/
|   |-- contract.ts       # typed policy Endpoint and Structs
|   |-- service.ts        # domain invariant and canonical repository
|   |-- transport.ts      # policy Server and Client over Memory Transport
|   |-- cache.ts          # availability cache and invalidation policy
|   |-- http.ts           # Fetch routes and health delegation
|   `-- main.ts           # one composition root and one Core App
`-- test/
    |-- main.test.ts      # domain, typed call, HTTP, cache, health, cancellation
    `-- node-e2e.ts       # real bind, request, stop, and port release

现有 workspace example 当前的目录树更小:

text
examples/healthcare-appointments/
|-- package.json
|-- tsconfig.json
|-- README.md
|-- src/
|   |-- service.ts
|   |-- transport.ts      # defineService appointment-policy.v1
|   |-- http.ts
|   `-- main.ts
`-- test/main.test.ts

第二棵树才是当前 checkout 里已经存在的真实结构。第一棵树是本教程各个里程碑的目标形态。

前置条件与命令 ​

在 repository 根目录执行:

sh
bun install --frozen-lockfile

此 checkout 使用版本为 0.0.1 的 workspace:* 包。Manifest 版本不能证明 npm 可用;在 workspace 外安装前,应独立核实发布状态。

运行已有的 baseline example:

sh
HOST=127.0.0.1 PORT=3000 bun run --cwd examples/healthcare-appointments start

start script 会构建 root packages、创建 prepared Node bundle,再运行它。发送请求前,先等到出现 GO_LIKE_EXAMPLE_READY 行。在另一个终端执行:

sh
NOW=$(($(date +%s) * 1000))
curl -i -sS http://127.0.0.1:3000/v1/appointments \
  -H 'content-type: application/json' \
  -d "{\"appointmentId\":\"appointment-1\",\"doctorId\":\"doctor-1\",\"patientId\":\"patient-1\",\"startsAt\":$((NOW + 3600000)),\"endsAt\":$((NOW + 5400000))}"

curl -i -sS -X DELETE \
  http://127.0.0.1:3000/v1/appointments/appointment-1

用 Ctrl-C 停掉前台进程。不要为 policy service 再启动一个隐藏的 App;当前示例把 policy Server 和 Web Server 放进同一个 Core App。

当前示例的 focused checks 是:

sh
bun run --cwd examples/healthcare-appointments typecheck
bun run --cwd examples/healthcare-appointments test:unit

示例还声明了一个 E2E wrapper:

sh
bun run --cwd examples/healthcare-appointments test:e2e

该命令会构建并运行 example E2E task。它是一个待执行的命令,不表示当前 checkout 已经通过。

M0:先写领域规则 ​

领域模块即使使用的内存 repository 关键区段是同步的,也应该采用 Context-first 形式。这样取消能力和未来替换 provider 的边界都可见:

ts
import type { Context } from "@go-like/context"

export interface BookAppointmentCommand {
  readonly appointmentId: string
  readonly doctorId: string
  readonly patientId: string
  readonly startsAt: number
  readonly endsAt: number
}

export type AppointmentStatus = "booked" | "cancelled"

export interface Appointment extends BookAppointmentCommand {
  readonly status: AppointmentStatus
}

export interface AppointmentRepository {
  book(ctx: Context, command: BookAppointmentCommand): Appointment
  cancel(ctx: Context, appointmentId: string): Appointment
  get(ctx: Context, appointmentId: string): Appointment | undefined
}

Repository 在修改状态前应该检查 ctx.err()。当前示例的 newMemoryAppointmentRepository() 会这样做,并为每个 appointment 保存 fingerprint。它使用下面的 overlap predicate:

ts
function overlaps(
  leftStartsAt: number,
  leftEndsAt: number,
  rightStartsAt: number,
  rightEndsAt: number
): boolean {
  return leftStartsAt < rightEndsAt && rightStartsAt < leftEndsAt
}

这个 predicate 允许相邻预约,但会让同一位医生的 active appointments 在重叠时失败。取消会把保存的 status 改成 cancelled;再次取消会返回同一条已取消记录。

M0 测试 ​

在加入 HTTP 或 transport 前,先写这些测试:

ts
import { background } from "@go-like/context"
import { expect, test } from "bun:test"
import { newBookAppointment, newMemoryAppointmentRepository } from "../src/service"
test("rejects an overlapping active slot", () => {
  const repository = newMemoryAppointmentRepository()
  const book = newBookAppointment(repository, () => 1_000)
  book(background(), {
    appointmentId: "a-1",
    doctorId: "doctor-1",
    patientId: "patient-1",
    startsAt: 2_000,
    endsAt: 3_000
  })

  expect(() =>
    book(background(), {
      appointmentId: "a-2",
      doctorId: "doctor-1",
      patientId: "patient-2",
      startsAt: 2_500,
      endsAt: 3_500
    })
  ).toThrow("doctor time conflict")
})

当前 test/main.test.ts 已经包含这个用例,以及取消复用、幂等取消和 HTTP handler 检查。在你的环境中执行上面的命令前,这些测试只是已经检查过的 repository evidence。

M1:一个 typed internal policy service ​

typed internal contract 使用 @go-like/struct 和 @go-like/transport 的 defineService。这是 JSON Fetch body 上的 runtime Struct validation,不是 IDL 或 generated Protobuf service。

src/contract.ts ​

ts
import { struct } from "@go-like/struct"
import { defineService } from "@go-like/transport"

const appointmentPolicyCommand = struct.object({
  appointmentId: struct.string(),
  doctorId: struct.string(),
  patientId: struct.string(),
  startsAt: struct.number(),
  endsAt: struct.number()
})

const appointmentPolicyDecision = struct.object({
  allowed: struct.literal(true)
})

export const appointmentPolicy = defineService("appointment-policy.v1", {
  check: {
    request: appointmentPolicyCommand,
    response: appointmentPolicyDecision
  }
})

路由 token 匹配 ^[A-Za-z0-9._~-]+$(URL unreserved),且不能恰好是 . 或 ..。defineService 的服务名和每个 endpoint 键组成 URL 路径 /<service>/<endpoint>。它们不是网络地址。节点地址由 withEndpoint 提供。

src/transport.ts ​

ts
import { newClient, withEndpoint, withTransport } from "@go-like/client"
import type { Context } from "@go-like/context"
import { address, newServer, transport as serverTransport, type Server } from "@go-like/server"
import { serviceError } from "@go-like/transport"
import { newMemoryTransport } from "@go-like/transport-memory"

import { appointmentPolicy } from "./contract"

const policyAddress = "memory://appointment-policy.v1"

export interface AppointmentPolicy {
  readonly server: Server
  validate(
    ctx: Context,
    command: {
      readonly appointmentId: string
      readonly doctorId: string
      readonly patientId: string
      readonly startsAt: number
      readonly endsAt: number
    }
  ): Promise<void>
  close(ctx: Context): Promise<void>
}

export function newAppointmentPolicy(maximumDurationMs = 7_200_000): AppointmentPolicy {
  const transport = newMemoryTransport()
  const server = newServer(serverTransport(transport), address(policyAddress))
  appointmentPolicy.registerHandler(server, {
    check(_ctx, command) {
      if (command.endsAt - command.startsAt > maximumDurationMs) {
        throw serviceError(
          "appointment_policy_rejected",
          "appointment duration exceeds policy",
          409
        )
      }
      return { allowed: true }
    }
  })
  const client = newClient(withTransport(transport), withEndpoint(policyAddress))
  const caller = appointmentPolicy.newClient(client)
  const policy: AppointmentPolicy = {
    server,
    async validate(ctx, command) {
      await caller.check(ctx, command)
    },
    close(ctx) {
      return client.close(ctx)
    }
  }
  return Object.freeze(policy)
}

已提交的示例用 appointmentPolicy.registerHandler 登记 check,超时预约以 serviceError(..., 409) 拒绝。所有权不变:一个 Memory Transport 实例、一个内部 Server、一个 Client,以及对该 Client 的显式 close。

继续传递 Context ​

预约 use case 应该把同一个 request Context 传给 policy Client 和 repository:

ts
import type { Context } from "@go-like/context"

interface BookCommand {
  readonly appointmentId: string
  readonly doctorId: string
  readonly patientId: string
  readonly startsAt: number
  readonly endsAt: number
}

interface Appointment {
  readonly id: string
}

declare const policy: {
  validate(ctx: Context, command: BookCommand): Promise<void>
}
declare const repository: {
  book(ctx: Context, command: BookCommand): Promise<Appointment>
}

async function validatedBook(ctx: Context, command: BookCommand): Promise<Appointment> {
  await policy.validate(ctx, command)
  return repository.book(ctx, command)
}

用 background() 替换 ctx 会丢掉 request deadline、取消信号和 Context ancestry。这是 correctness regression,不是无害的简化。

M1 测试 ​

至少测试以下内容:

测试预期结果
valid typed requestallowed: true,并创建 booked appointment
overlong request在 repository mutation 之前失败
invalid field typetyped request decode failure
invalid response shapeServer boundary 处的 typed response encode failure
canceled Contextpolicy 和 repository 观察到同一个 cancellation
client closeresident Transport Client 的 cleanup 是显式的

当前示例的 policy test 已经验证了在 repository mutation 前拒绝,以及通过 Client -> Memory Transport -> Server 成功调用。typed test 是建议增加的扩展。

M2:availability Cache ​

Cache 适合做读取投影,不适合做预约权威来源。Cache package 提供 Context-first 的 get、put 和 delete;@go-like/cache-memory 提供 newMemoryCache(),@go-like/cache 提供 expiresIn(...):

ts
import type { Context } from "@go-like/context"
import { expiresIn } from "@go-like/cache"
import { newMemoryCache } from "@go-like/cache-memory"
import type { AppointmentRepository } from "./service"

interface Availability {
  readonly doctorId: string
  readonly slots: readonly { readonly startsAt: number; readonly endsAt: number }[]
}

interface AvailabilityRepository extends AppointmentRepository {
  readAvailability(ctx: Context, doctorId: string): Availability
}

const availabilityCache = newMemoryCache()
declare const repository: AvailabilityRepository

async function readAvailability(ctx: Context, doctorId: string) {
  const key = `availability/${doctorId}`
  const cached = await availabilityCache.get(ctx, key)
  if (cached !== null) {
    return JSON.parse(new TextDecoder().decode(cached)) as Availability
  }

  const authoritative = repository.readAvailability(ctx, doctorId)
  await availabilityCache.put(
    ctx,
    key,
    new TextEncoder().encode(JSON.stringify(authoritative)),
    expiresIn(30_000)
  )
  return authoritative
}

async function invalidateAvailability(ctx: Context, doctorId: string): Promise<void> {
  await availabilityCache.delete(ctx, `availability/${doctorId}`)
}

repository.readAvailability(...) 是本教程中由应用拥有的方法,不是 go-like export。Booking 和 cancellation 都必须在权威 mutation 后删除这个 key。如果失效操作失败,应该报告失败并选择明确的一致性策略;不要静默地把 cache 当成预约事实来源。

M2 测试 ​

  • miss 会读取 repository 并填充 cache;
  • hit 不会再次读取 repository;
  • booking 或 cancellation 会删除 projection;
  • 过期值会回退到 repository;
  • cache failure 不会把正确的 authoritative read 变成错误的 booking result;
  • 进程重启会按设计丢失 Memory Cache 状态。

M3:liveness 与 readiness ​

在 composition root 创建 registry,并把两个路径委托给 createHealthHandler(...):

ts
import type { Context } from "@go-like/context"
import { newProbeRegistry } from "@go-like/health"
import { createHealthHandler } from "@go-like/web/health"
import type { Handler } from "@go-like/web"

import type { Appointment, BookAppointmentCommand } from "./service"
import { newBookAppointment, newCancelAppointment, newMemoryAppointmentRepository } from "./service"
import { newAppointmentPolicy } from "./transport"
import { newAppointmentHandler } from "./http"

const repository = newMemoryAppointmentRepository()
const policy = newAppointmentPolicy()
const book = async (ctx: Context, command: BookAppointmentCommand): Promise<Appointment> => {
  const decision = await policy.validate(ctx, command)
  if (!decision.allowed) throw new Error("appointment policy rejected request")
  return newBookAppointment(repository)(ctx, command)
}
const cancel = newCancelAppointment(repository)
const probes = newProbeRegistry()
probes.register("ready", "policy", async (ctx) => {
  await policy.server.endpoint(ctx)
})

const healthHandler = createHealthHandler(probes)
const appointmentHandler: Handler = newAppointmentHandler(book, cancel)

const webHandler: Handler = (request) => {
  const path = new URL(request.url).pathname
  if (path === "/livez" || path === "/readyz") return healthHandler(request)
  return appointmentHandler(request)
}

默认路由是 /livez 和 /readyz。空的 liveness 是 healthy;空的 readiness 则 fail closed。上面的 policy probe 让 readiness 依赖内部 listener admission,但不会假装外部数据库总是等同于进程 liveness。

生产服务只应该把真正的流量前置依赖加入 readiness。Probe name 是 public identifier,health payload 会刻意做脱敏处理。

M4:一个生命周期所有者 ​

Composition root 应该只构造一次资源,并把它们放进同一个 App:

ts
import process from "node:process"
import { afterStart, afterStop, name, newApp, server } from "@go-like/core"
import { signal } from "@go-like/core/node"
import { hostname, newNodeServer, port } from "@go-like/web/node"

const httpServer = newNodeServer(webHandler, hostname("127.0.0.1"), port(3000))
const app = newApp(
  signal(),
  name("healthcare-appointments"),
  server(policy.server, httpServer),
  afterStart(async (ctx) => {
    await httpServer.endpoint(ctx)
    process.stdout.write("GO_LIKE_EXAMPLE_READY=healthcare-appointments\n")
  }),
  afterStop((ctx) => policy.close(ctx))
)

await app.run()

afterStop hook 是 policy Client 的一个显式顺序边界。Core 本身会并发停止 sibling Servers。如果依赖更复杂,需要严格顺序,就把相关资源组合进一个 Server 或显式 hook,不要依赖声明顺序。

signal() 是 Node/Bun process adapter。领域代码、typed contract、Memory Transport 和 health modules 可以保持可移植;导入 @go-like/core/node 是一个有意的 runtime 选择。

M5:测试计划与证据 ​

层次测试证据目标
Domainoverlap、cancellation reuse、idempotency、conflicting IDsrc/service.ts 行为和 unit test 结果
Contextcanceled booking 不会修改 repository,也不会调用 policyfocused Context test
Typed callStruct decode/encode、policy rejection、response validation@go-like/client 和 @go-like/server boundary
Cachemiss、hit、TTL、invalidation、failure fallbacknewMemoryCache() tests
Healthempty liveness、empty readiness、failing probe、405/404newProbeRegistry() 和 createHealthHandler()
HTTPPOST、DELETE、invalid JSON、conflict statusstandard Fetch Handler test
Lifecyclepolicy 和 Web Server 在同一个 App 下被 admitted;显式 close ClientCore App 和 Server terminal behavior
Node E2Ereal bind、request、signal、stop、port releaseexample E2E wrapper 和 residual checks

当前 repository example 的 focused commands 是:

sh
bun run --cwd examples/healthcare-appointments typecheck
bun run --cwd examples/healthcare-appointments test:unit
bun run --cwd examples/healthcare-appointments test:e2e

整个 examples lane:

sh
bun run test:e2e:examples

完整 E2E lane 会构建 packages 并使用 repository runner。Docker providers 和 cross-runtime consumers 属于不同范围。请记录 candidate commit、runtime versions、exit status、summary 以及残留进程或 container;脚本存在不等于执行通过。

里程碑 ​

里程碑交付物何时进入下一步
M0Domain repository 和 invariant testsoverlap 与 cancellation 行为是确定的
M1通过 Memory Transport 的 typed policy Endpoint调用确实经过 Client/Server/Transport,而不是直接函数调用
M2带 invalidation 的 cache projectioncache failure 不会取代 authority
M3/livez 和 /readyz已理解空 readiness 与 failing probes
M4一个 App、signal、显式 Client cleanup每个 admitted resource 都有一个 owner
M5Unit 和 Node E2E evidence结果已带 command 和 exit status 记录

在这些里程碑清楚之前,不要加入 Registry、Redis、Vault、真实 Broker、authentication 或 retries。每一项都会增加新的所有权或故障模型,应该有意识地引入。

故障排查 ​

Cannot find package "@go-like/..." ​

你可能在 workspace 外运行,或者正在依赖尚未发布的 package。请从 repository 根目录执行 bun install --frozen-lockfile,并运行 workspace script,例如 bun run --cwd examples/healthcare-appointments start。

请求返回 404 ​

当前示例只暴露 POST /v1/appointments 和 DELETE /v1/appointments/{appointmentId}。检查 method、path 和 GO_LIKE_EXAMPLE_READY 行。Health routes 属于 M3 tutorial extension,不在当前已提交示例中。

请求返回 400 ​

示例要求 ID 是字符串,startsAt/endsAt 是数字。相对于注入的 clock,startsAt 必须在未来,并且 endsAt 必须大于 startsAt。确认 shell arithmetic 生成的是数字,而不是带引号的字符串。

请求返回 409 ​

可能是医生时间段与 active appointment 重叠、appointment ID 被不同内容复用,或者 policy service 拒绝了预约时长。policy 在 repository mutation 前调用,因此 policy rejection 不应该创建记录。

Endpoint typed call 报告 invalid request 或 response body ​

检查 client 和 server 是否使用同一个 Endpoint Structs,并确认 request Content-Type 正好是 application/json。server.registerHandler(contract, fn) 会在 Server boundary 做 JSON 和 Struct validation。

Memory Client 无法连到 Server ​

newMemoryTransport() 创建的是实例私有的 address map。Client 和 Server 必须共享同一个 Transport 实例,并且绑定的 memory: address 完全一致。在两个分别构造的 Memory Transport 实例中使用相同 URL,并不会连通。

app.run() 看起来卡住了 ​

Server.start(ctx) 或 afterStart 本身不代表 readiness。应在 hook 内等待 endpoint(ctx) 或资源自身的接纳信号,再宣布就绪。Core 并发停止兄弟 Server;需要严格顺序的资源应组合在同一 owner 内。

Stop 返回 timeout 或 aggregate error ​

timeout 限制的是调用方等待 cleanup 的时间,不代表原生资源已经停止;sibling Servers 会并发停止。在判断 shutdown 是否干净前,请检查 primary error、adapter terminal barrier 以及残留进程或 socket 证据。

Cache 数据消失了 ​

@go-like/cache-memory 是进程内且可丢弃的。权威记录应使用明确的 Store provider,并记录其实际 durability 和 ownership;不要把 Cache 当成数据库。

边界回顾 ​

这个项目用一条真实但保持小巧的路径教你使用 go-like:

text
Request
  -> standard Fetch Handler
  -> Context-first appointment use case
  -> typed Client call
  -> Memory Transport
  -> unary Server policy handler
  -> canonical appointment repository
  -> disposable availability Cache
  -> Response

App.stop()
  -> deregistration if configured
  -> concurrent Server stop
  -> explicit Client / provider cleanup
  -> terminal result

它不涉及 gRPC、Protobuf、IDL generation、内部全双工 streams、分布式锁、持久化消息或生产级 authentication。这些属于小项目之外的独立设计决策。