如何构建现代 Web 应用?如何确保它能长期保持可维护性?在本指南中,我们将使用六边形架构和强大的表单验证创建一个 Next.js 应用。为了让学习更有趣,我们将创建一个绝地学院注册系统!
源代码可在 GitHub 上获取。
目录
- 六边形架构简介
- 安装和结构
- 领域层
- 基础设施层
- 用户界面
- 进一步探索
1. 六边形架构简介
1.1 什么是六边形架构?
六边形架构,也称为"端口和适配器"模式,是一种架构模式,它允许创建具有以下特点的业务组件:
该架构分为三个主要层次:
- 领域层:包含纯业务逻辑
- 端口层:定义与领域通信的接口
- 适配器层:实现端口以与外部世界交互
1.2 为什么使用六边形架构?
优势
- 关注点分离清晰
- 业务逻辑受保护且集中
- 便于测试
- 技术更换灵活
- 简化维护
理想使用场景
- 具有复杂业务规则的应用
- 需要随时间演进的项目
- 需要高测试性的系统
- 需要支持多个接口的应用
[内容继续...]
[以下内容按照原文格式继续翻译...]
2. 安装和结构
2.1 完整安装
1# 创建项目
2npx create-next-app@latest jedi-academy
创建项目时,回答以下问题:
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? Yes
Using Next.js version 15.0.0+ with Turbopack
项目创建后,安装依赖:
1cd jedi-academy
2
3# 表单管理和验证的主要依赖
4npm install @hookform/resolvers zod react-hook-form
5
6# Prisma 数据库
7npm install @prisma/client
8npm install -D prisma
9
10# shadcn/ui 安装
11npx shadcn@latest init
初始化 shadcn/ui 时,回答以下问题:
Would you like to use TypeScript (recommended)? Yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › src/app/globals.css
Would you like to use CSS variables for colors? › Yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › Yes
# 安装必要的组件
npx shadcn@latest add form input select textarea button card toast table
1# 日期工具
2npm install date-fns
3
4# 数据库
5npx prisma init --datasource-provider sqlite
2.2 项目结构
src/
├── app/
│ ├── actions/
│ │ └── register-padawan.ts
│ ├── components/
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── jedi-form.tsx
│ │ └── padawan-list.tsx
│ ├── lib/
│ │ └── utils.ts # shadcn/ui 工具
│ └── page.tsx
├── core/
│ ├── domain/
│ │ ├── models/
│ │ │ └── padawan.ts
│ │ ├── ports/
│ │ │ └── padawan-repository.ts
│ │ ├── services/
│ │ │ └── padawan-eligibility.ts
│ │ ├── validation/
│ │ │ └── padawan-schema.ts
│ │ └── events/
│ │ └── padawan-registered.ts
│ └── usecases/
│ └── register-padawan.ts
└── infrastructure/
└── db/
├── prisma/
│ ├── migrations/
│ └── schema.prisma
├── prisma.ts
└── repositories/
└── prisma-padawan-repository.ts
此结构遵循清晰的六边形架构:
app/ - Next.js 用户界面层
actions/ - 表单处理的服务器操作
components/ - UI/业务分离的 React 组件
lib/ - 共享工具
core/ - 纯业务逻辑
domain/ - 模型、端口和业务服务
usecases/ - 应用用例
infrastructure/ - 技术实现
3. 领域层:应用的核心
3.1 理解领域
领域是我们应用的核心。这里是我们将业务规则转换为代码的地方,独立于任何技术考虑。在我们的案例中,领域代表了管理绝地学院注册的所有规则。
关键领域原则
- 技术独立性
- 集中的业务规则
- 所有业务逻辑都在领域中
- 规则明确且有文档
- 业务验证与技术验证分离
- 丰富的建模
- 使用精确的类型和接口
- 重要业务概念的值对象
- 具有清晰身份的实体
3.2 领域组件
3.2.1 模型
模型代表基本的业务概念。
1// src/core/domain/models/padawan.ts
2export type PrimarySkill = "combat" | "healing" | "meditation" | "telepathy";
3
4export interface Padawan {
5 id?: string;
6 name: string;
7 age: number;
8 midichlorianCount: number;
9 homeworld: string;
10 primarySkill: PrimarySkill;
11 background: string;
12 createdAt?: Date;
13}
14
15export class Result<T> {
16 private constructor(
17 private readonly _isSuccess: boolean,
18 private readonly value?: T,
19 private readonly error?: string
20 ) {}
21
22 static success<T>(value: T): Result<T> {
23 return new Result<T>(true, value);
24 }
25
26 static failure<T>(error: string): Result<T> {
27 return new Result<T>(false, undefined, error);
28 }
29
30 getError(): string | undefined {
31 return this.error;
32 }
33
34 getValue(): T | undefined {
35 return this.value;
36 }
37
38 isSuccess(): boolean {
39 return this._isSuccess;
40 }
41}
3.2.2 领域事件
事件代表领域中发生的重要事实。
1// src/core/domain/events/padawan-registered.ts
2export interface DomainEvent {
3 occurredOn: Date;
4}
5
6export class PadawanRegistered implements DomainEvent {
7 occurredOn: Date = new Date();
8
9 constructor(
10 public readonly padawanId: string,
11 public readonly name: string,
12 public readonly midichlorianCount: number
13 ) {}
14}
3.2.3 端口(接口)
端口定义了领域如何与外部世界交互。
1// src/core/domain/ports/padawan-repository.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3
4export interface PadawanRepository {
5 save(padawan: Omit<Padawan, "id" | "createdAt">): Promise<Result<Padawan>>;
6 findById(id: string): Promise<Result<Padawan | null>>;
7 findAll(): Promise<Result<Padawan[]>>;
8}
3.2.4 领域服务
领域服务封装了不自然适合实体的业务逻辑。
1// src/core/domain/services/padawan-eligibility.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3
4export class PadawanEligibilityService {
5 static check(padawan: Omit<Padawan, "id" | "createdAt">): Result<void> {
6 if (padawan.age < 4) {
7 return Result.failure("太年轻,无法开始训练");
8 }
9 if (padawan.age > 30) {
10 return Result.failure("超过开始训练的年龄限制");
11 }
12
13 if (padawan.midichlorianCount < 7000) {
14 return Result.failure("原力原体数量太低");
15 }
16 if (padawan.midichlorianCount > 20000) {
17 return Result.failure("可疑的原力原体数量");
18 }
19
20 if (padawan.background.length < 20) {
21 return Result.failure("个人故事需要更多细节");
22 }
23
24 return Result.success(void 0);
25 }
26}
3.2.5 用例
用例协调不同领域元素之间的交互以实现业务功能。
1// src/core/usecases/register-padawan.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3import { PadawanRepository } from "@/core/domain/ports/padawan-repository";
4import { PadawanEligibilityService } from "@/core/domain/services/padawan-eligibility";
5import { PadawanRegistered } from "@/core/domain/events/padawan-registered";
6import { EventDispatcher } from "@/core/domain/ports/event-dispatcher";
7
8export class RegisterPadawanUseCase {
9 constructor(
10 private readonly padawanRepository: PadawanRepository,
11 private readonly eventDispatcher: EventDispatcher
12 ) {}
13
14 async execute(
15 padawan: Omit<Padawan, "id" | "createdAt">
16 ): Promise<Result<Padawan>> {
17 const eligibilityResult = PadawanEligibilityService.check(padawan);
18 if (!eligibilityResult.isSuccess()) {
19 return Result.failure(eligibilityResult.getError()!);
20 }
21
22 const saveResult = await this.padawanRepository.save(padawan);
23 if (!saveResult.isSuccess()) {
24 return Result.failure(saveResult.getError()!);
25 }
26
27 const savedPadawan = saveResult.getValue()!;
28
29 await this.eventDispatcher.dispatch(
30 new PadawanRegistered(
31 savedPadawan.id!,
32 savedPadawan.name,
33 savedPadawan.midichlorianCount
34 )
35 );
36
37 return Result.success(savedPadawan);
38 }
39}
4. 基础设施层
基础设施层实现了领域中定义的端口以与外部世界交互。
4.1 Prisma 配置
1// prisma/schema.prisma
2generator client {
3 provider = "prisma-client-js"
4}
5
6datasource db {
7 provider = "sqlite"
8 url = env("DATABASE_URL")
9}
10
11model Padawan {
12 id String @id @default(uuid())
13 name String
14 age Int
15 midichlorianCount Int
16 homeworld String
17 primarySkill String
18 background String
19 createdAt DateTime @default(now())
20
21 @@map("padawans")
22}
4.2 适配器实现
1// src/infrastructure/db/repositories/prisma-padawan-repository.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3import { PadawanRepository } from "@/core/domain/ports/padawan-repository";
4import { prisma } from "@/infrastructure/db/prisma";
5
6export class PrismaPadawanRepository implements PadawanRepository {
7 async save(
8 padawan: Omit<Padawan, "id" | "createdAt">
9 ): Promise<Result<Padawan>> {
10 try {
11 const saved = await prisma.padawan.create({
12 data: padawan,
13 });
14
15 return Result.success(this.mapPrismaPadawanToDomain(saved));
16 } catch (error: unknown) {
17 if (error instanceof Error) {
18 return Result.failure(`保存时出错:${error.message}`);
19 }
20 return Result.failure('保存过程中发生未知错误');
21 }
22 }
23
24 async findById(id: string): Promise<Result<Padawan | null>> {
25 try {
26 const padawan = await prisma.padawan.findUnique({
27 where: { id },
28 });
29
30 return Result.success(
31 padawan ? this.mapPrismaPadawanToDomain(padawan) : null
32 );
33 } catch (error: unknown) {
34 if (error instanceof Error) {
35 return Result.failure(`搜索时出错:${error.message}`);
36 }
37 return Result.failure('搜索过程中发生未知错误');
38 }
39 }
40
41 async findAll(): Promise<Result<Padawan[]>> {
42 try {
43 const padawans = await prisma.padawan.findMany({
44 orderBy: { createdAt: "desc" },
45 });
46
47 return Result.success(padawans.map(this.mapPrismaPadawanToDomain));
48 } catch (error: unknown) {
49 if (error instanceof Error) {
50 return Result.failure(`检索时出错:${error.message}`);
51 }
52 return Result.failure('检索过程中发生未知错误');
53 }
54 }
55
56 private mapPrismaPadawanToDomain(padawan: any): Padawan {
57 return {
58 id: padawan.id,
59 name: padawan.name,
60 age: padawan.age,
61 midichlorianCount: padawan.midichlorianCount,
62 homeworld: padawan.homeworld,
63 primarySkill: padawan.primarySkill,
64 background: padawan.background,
65 createdAt: padawan.createdAt,
66 };
67 }
68}
5. 用户界面
5.1 使用 Zod 进行验证
输入数据验证使用 Zod 来确保与我们的领域保持一致。
1// src/core/domain/validation/padawan-schema.ts
2import { z } from "zod";
3
4export const padawanSchema = z.object({
5 name: z
6 .string()
7 .min(2, "学徒名字必须超过2个字符")
8 .max(50, "就连魁刚·金也没有这么长的名字"),
9 age: z.coerce
10 .number()
11 .min(4, "连格鲁古都是4岁开始训练的")
12 .max(30, "议会认为这是开始训练的年龄上限"),
13 midichlorianCount: z.coerce
14 .number()
15 .min(7000, "原力原体数量太低,无法进行绝地训练")
16 .max(20000, "就连安纳金的原力原体数量也只有20000"),
17 homeworld: z
18 .string()
19 .min(2, "必须填写你的母星名称"),
20 primarySkill: z.enum(["combat", "healing", "meditation", "telepathy"], {
21 errorMap: () => ({
22 message: "这个技能不被绝地武士团认可",
23 }),
24 }),
25 background: z
26 .string()
27 .min(20, "告诉我们更多关于你的经历"),
28});
29
30export type PadawanFormData = z.infer<typeof padawanSchema>;
现在我们可以生成数据库。
为了进一步探索,Prisma 提供了一个客户端来可视化数据库。
5.2 注册的服务器操作
1// src/app/actions/register-padawan.ts
2'use server'
3
4import { PrismaPadawanRepository } from '@/infrastructure/db/repositories/prisma-padawan-repository';
5import { ConsoleEventDispatcher } from '@/infrastructure/services/event-dispatcher';
6import { RegisterPadawanUseCase } from '@/core/usecases/register-padawan';
7import { padawanSchema } from '@/core/domain/validation/padawan-schema';
8import { revalidatePath } from 'next/cache';
9
10const padawanRepository = new PrismaPadawanRepository();
11const eventDispatcher = new ConsoleEventDispatcher();
12const registerPadawan = new RegisterPadawanUseCase(
13 padawanRepository,
14 eventDispatcher
15);
16
17export async function handlePadawanRegistration(formData: FormData) {
18 try {
19 const validatedData = padawanSchema.parse(Object.fromEntries(formData));
20 const result = await registerPadawan.execute(validatedData);
21
22 if (!result.isSuccess()) {
23 return {
24 success: false,
25 message: result.getError()
26 };
27 }
28
29 revalidatePath('/');
30 return {
31 success: true,
32 message: "愿原力与你同在,年轻的学徒!🌟"
33 };
34 } catch (error) {
35 return {
36 success: false,
37 message: "原力中出现了扰动... 🌀"
38 };
39 }
40}
5.3 表单组件
1// src/app/components/jedi-form.tsx
2"use client";
3
4import { useForm } from "react-hook-form";
5import { zodResolver } from "@hookform/resolvers/zod";
6import { useToast } from "@/hooks/use-toast";
7
8import {
9 Form,
10 FormControl,
11 FormField,
12 FormItem,
13 FormLabel,
14 FormMessage,
15} from "@/components/ui/form";
16import {
17 Card,
18 CardHeader,
19 CardTitle,
20 CardDescription,
21 CardContent,
22} from "@/components/ui/card";
23import { Input } from "@/components/ui/input";
24import { Button } from "@/components/ui/button";
25import { Textarea } from "@/components/ui/textarea";
26import {
27 Select,
28 SelectContent,
29 SelectItem,
30 SelectTrigger,
31 SelectValue,
32} from "@/components/ui/select";
33import {
34 padawanSchema,
35 type PadawanFormData,
36} from "@/core/domain/validation/padawan-schema";
37import { handlePadawanRegistration } from "../actions/register-padawan";
38
39export function JediRegistrationForm() {
40 const { toast } = useToast();
41 const form = useForm<PadawanFormData>({
42 resolver: zodResolver(padawanSchema),
43 defaultValues: {
44 name: "",
45 age: 0,
46 midichlorianCount: 7000,
47 homeworld: "",
48 primarySkill: "combat",
49 background: "",
50 },
51 });
52
53 async function onSubmit(data: PadawanFormData) {
54 const formData = new FormData();
55 Object.entries(data).forEach(([key, value]) => {
56 formData.append(key, value.toString());
57 });
58
59 const result = await handlePadawanRegistration(formData);
60
61 toast({
62 title: result.success ? "注册成功" : "错误",
63 description: result.message,
64 variant: result.success ? "default" : "destructive",
65 });
66
67 if (result.success) {
68 form.reset();
69 }
70 }
71
72 return (
73 <Card className="w-full max-w-md mx-auto">
74 <CardHeader>
75 <CardTitle>绝地学院</CardTitle>
76 <CardDescription>
77 提交你的申请加入武士团
78 </CardDescription>
79 </CardHeader>
80 <CardContent>
81 <Form {...form}>
82 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
83 <FormField
84 control={form.control}
85 name="name"
86 render={({ field }) => (
87 <FormItem>
88 <FormLabel>学徒姓名</FormLabel>
89 <FormControl>
90 <Input placeholder="卢克·天行者" {...field} />
91 </FormControl>
92 <FormMessage />
93 </FormItem>
94 )}
95 />
96
97 <FormField
98 control={form.control}
99 name="age"
100 render={({ field }) => (
101 <FormItem>
102 <FormLabel>年龄</FormLabel>
103 <FormControl>
104 <Input
105 type="number"
106 min="4"
107 max="30"
108 placeholder="19"
109 {...field}
110 onChange={(e) => field.onChange(parseInt(e.target.value))}
111 />
112 </FormControl>
113 <FormMessage />
114 </FormItem>
115 )}
116 />
117
118 <FormField
119 control={form.control}
120 name="midichlorianCount"
121 render={({ field }) => (
122 <FormItem>
123 <FormLabel>原力原体数量</FormLabel>
124 <FormControl>
125 <Input
126 type="number"
127 min="7000"
128 max="20000"
129 step="100"
130 {...field}
131 onChange={(e) => field.onChange(parseInt(e.target.value))}
132 />
133 </FormControl>
134 <FormMessage />
135 </FormItem>
136 )}
137 />
138
139 <FormField
140 control={form.control}
141 name="homeworld"
142 render={({ field }) => (
143 <FormItem>
144 <FormLabel>母星</FormLabel>
145 <FormControl>
146 <Input placeholder="塔图因" {...field} />
147 </FormControl>
148 <FormMessage />
149 </FormItem>
150 )}
151 />
152
153 <FormField
154 control={form.control}
155 name="primarySkill"
156 render={({ field }) => (
157 <FormItem>
158 <FormLabel>主要技能</FormLabel>
159 <Select
160 onValueChange={field.onChange}
161 defaultValue={field.value}
162 >
163 <FormControl>
164 <SelectTrigger>
165 <SelectValue placeholder="选择一项技能" />
166 </SelectTrigger>
167 </FormControl>
168 <SelectContent>
169 <SelectItem value="combat">
170 光剑格斗 ⚔️
171 </SelectItem>
172 <SelectItem value="healing">
173 原力治疗 💚
174 </SelectItem>
175 <SelectItem value="meditation">
176 深度冥想 🧘
177 </SelectItem>
178 <SelectItem value="telepathy">心灵感应 🧠</SelectItem>
179 </SelectContent>
180 </Select>
181 <FormMessage />
182 </FormItem>
183 )}
184 />
185
186 <FormField
187 control={form.control}
188 name="background"
189 render={({ field }) => (
190 <FormItem>
191 <FormLabel>你的故事</FormLabel>
192 <FormControl>
193 <Textarea
194 placeholder="告诉我们你的经历..."
195 className="resize-none"
196 rows={4}
197 {...field}
198 />
199 </FormControl>
200 <FormMessage />
201 </FormItem>
202 )}
203 />
204
205 <Button type="submit" className="w-full">
206 提交申请 🌟
207 </Button>
208 </form>
209 </Form>
210 </CardContent>
211 </Card>
212 );
213}
5.4 学徒列表
1// src/app/components/padawan-list.tsx
2"use client";
3
4import {
5 Table,
6 TableBody,
7 TableCaption,
8 TableCell,
9 TableHead,
10 TableHeader,
11 TableRow,
12} from "@/components/ui/table";
13import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
14import { format } from "date-fns";
15import { enUS } from "date-fns/locale";
16import type { Padawan } from "@/core/domain/models/padawan";
17
18type PadawanListProps = {
19 padawans: Padawan[];
20};
21
22const skillEmoji = {
23 combat: "⚔️ 光剑格斗",
24 healing: "💚 原力治疗",
25 meditation: "🧘 深度冥想",
26 telepathy: "🧠 心灵感应",
27} as const;
28
29export function PadawanList({ padawans }: PadawanListProps) {
30 if (padawans.length === 0) {
31 return (
32 <Card>
33 <CardContent className="pt-6">
34 <p className="text-center text-muted-foreground">
35 目前还没有注册的学徒...
36 </p>
37 </CardContent>
38 </Card>
39 );
40 }
41
42 return (
43 <Card>
44 <CardHeader>
45 <CardTitle>候选人列表</CardTitle>
46 </CardHeader>
47 <CardContent>
48 <Table>
49 <TableHeader>
50 <TableRow>
51 <TableHead>姓名</TableHead>
52 <TableHead>年龄</TableHead>
53 <TableHead>母星</TableHead>
54 <TableHead>专长</TableHead>
55 <TableHead>原力原体</TableHead>
56 <TableHead>注册时间</TableHead>
57 </TableRow>
58 </TableHeader>
59 <TableBody>
60 {padawans.map((padawan) => (
61 <TableRow key={padawan.id}>
62 <TableCell className="font-medium">{padawan.name}</TableCell>
63 <TableCell>{padawan.age} 岁</TableCell>
64 <TableCell>{padawan.homeworld}</TableCell>
65 <TableCell>{skillEmoji[padawan.primarySkill]}</TableCell>
66 <TableCell>
67 {padawan.midichlorianCount.toLocaleString()}
68 </TableCell>
69 <TableCell>
70 {format(new Date(padawan.createdAt!), "Pp", { locale: enUS })}
71 </TableCell>
72 </TableRow>
73 ))}
74 </TableBody>
75 </Table>
76 </CardContent>
77 </Card>
78 );
79}
这些组件使用 shadcn/ui 创建了现代且无障碍的用户界面,具有:
- JediRegistrationForm:
- 使用 react-hook-form 和 Zod 的完整验证
- 带有上下文消息的错误处理
- 使用 toasts 的用户反馈
- 提交成功后的表单重置
- PadawanList:
- 表格数据显示
- 空状态处理
- 英文日期格式化
- 技能图标
- 与表单一致的样式
所有内容都完美地集成到我们的六边形架构中,同时保持在表现层,不包含业务逻辑。
6. 进一步探索
6.1 技术改进
- 缓存和性能
1// Redis 缓存实现示例
2export class RedisPadawanCache {
3 async cacheResults(key: string, data: any): Promise<void> {
4 await redis.set(key, JSON.stringify(data), "EX", 3600);
5 }
6}
- 监控和日志
1// 监控服务
2export class ApplicationMonitoring {
3 trackDomainEvent(event: DomainEvent): void {
4 // 发送到监控服务
5 }
6}
6.2 功能演进
- 审批工作流
- 评分系统
- 晋升管理
- 学徒通讯
结论
六边形架构使我们能够创建一个:
- 可维护的
- 可扩展的
- 易于添加新功能
- 能够更改基础设施
- 支持新的输入/输出渠道
要进一步发展,你可以:
- 添加身份验证
- 实现缓存系统
- 设置监控
- 设置自动化测试
- 添加 webhooks
记住:好的架构就像原力一样 - 必须保持平衡!🌟
[翻译结束]