06 — Model 层:数据模型与操作

6.2 为什么用内存存储做示例

本教程用内存数组来演示 Model 层,而不是直接上数据库。原因是:

  1. 聚焦架构思想,不用纠结数据库语法
  2. 没有数据库也能跑完整项目
  3. 换成真实数据库时,只改 Model 层,其他层不动

第 14 篇会讲怎么换成真实数据库。


6.3 类型定义

先定义数据长什么样。

src/models/project/project.ts

typescript 复制代码
export interface Project {
  id: number;
  name: string;
  description: string;
  status: 'active' | 'inactive';
  created_at: number;  // Unix 时间戳(秒)
  updated_at: number;
}

用时间戳还是 Date 对象?

两种都常见,各有优劣:

方式 优点 缺点
Unix 时间戳(秒) 传输方便、数据库存数字就行 可读性差,前端要格式化
Date 对象 可读性好 序列化后是字符串,前后端要约定格式
ISO 字符串 人类可读 字符串比较大小不如数字方便

本教程用秒级时间戳,和大多数管理后台项目一致。


6.4 Model 类的实现

src/models/project/project-model.ts

typescript 复制代码
import type { Project } from './project.js';

export class ProjectModel {
  // 用私有字段存数据,外部不能直接访问
  #projects: Project[];

  constructor(initialData: readonly Project[] = []) {
    // 深拷贝一份,防止外部修改影响内部数据
    this.#projects = initialData.map((item) => structuredClone(item));
  }

  // ===== 查询 =====

  // 获取全部列表
  list(): Project[] {
    return structuredClone(this.#projects);
  }

  // 按 ID 查找
  findById(id: number): Project | undefined {
    const project = this.#projects.find((p) => p.id === id);
    return project ? structuredClone(project) : undefined;
  }

  // 按名称查找(用于查重)
  findByName(name: string): Project | undefined {
    const project = this.#projects.find((p) => p.name === name);
    return project ? structuredClone(project) : undefined;
  }

  // ===== 新增 =====

  create(input: Pick<Project, 'name' | 'description' | 'status'>): Project {
    const now = Math.floor(Date.now() / 1000);
    const project: Project = {
      ...input,
      id: this.#nextId(),
      created_at: now,
      updated_at: now,
    };
    this.#projects.push(structuredClone(project));
    return structuredClone(project);
  }

  // ===== 更新 =====

  update(
    id: number,
    input: Partial<Pick<Project, 'name' | 'description' | 'status'>>,
  ): Project | undefined {
    const project = this.#projects.find((p) => p.id === id);
    if (!project) return undefined;

    if (input.name !== undefined) project.name = input.name;
    if (input.description !== undefined) project.description = input.description;
    if (input.status !== undefined) project.status = input.status;
    project.updated_at = Math.floor(Date.now() / 1000);

    return structuredClone(project);
  }

  // ===== 删除 =====

  delete(id: number): boolean {
    const index = this.#projects.findIndex((p) => p.id === id);
    if (index < 0) return false;
    this.#projects.splice(index, 1);
    return true;
  }

  // ===== 内部方法 =====

  // 生成自增 ID
  #nextId(): number {
    if (this.#projects.length === 0) return 1;
    return Math.max(...this.#projects.map((p) => p.id)) + 1;
  }
}

6.5 几个关键设计

1. 私有字段(#projects)

# 而不是 private 关键字。

  • private 是 TypeScript 的,编译后就没了,运行时还是能访问
  • # 是 JavaScript 原生私有字段,运行时也访问不了

真正想保护数据,用 #

2. 每次返回都深拷贝

typescript 复制代码
list(): Project[] {
  return structuredClone(this.#projects);
}

为什么要深拷贝?因为如果直接返回内部数组的引用,外部修改了返回值,内部数据也会被改,绕过了 Model 的控制。

typescript 复制代码
// 如果不拷贝,外部可以直接改内部数据
const list = projectModel.list();
list[0].name = '被篡改了';  // 内部数据也变了!

深拷贝保证了外部只能通过 Model 的方法修改数据,数据一致性有保障。

3. 新增和更新时自动维护时间戳

created_atupdated_at 由 Model 层自动维护,调用方不用管。

4. 用 Pick / Partial 定义入参

typescript 复制代码
create(input: Pick<Project, 'name' | 'description' | 'status'>): Project
update(id: number, input: Partial<Pick<Project, 'name' | 'description' | 'status'>>)
  • Pick 表示从接口里挑出哪些字段
  • Partial 表示这些字段都是可选的(更新时不需要传全部)

这样定义入参,类型安全又不重复。


6.6 初始数据

src/data/project/index.ts

typescript 复制代码
import type { Project } from '@/models/project/project.js';

const CREATED_AT = 1_700_000_000;

export const projectData: readonly Project[] = [
  {
    id: 1,
    name: '示例项目A',
    description: '这是一个示例项目',
    status: 'active',
    created_at: CREATED_AT,
    updated_at: CREATED_AT,
  },
  {
    id: 2,
    name: '示例项目B',
    description: '这是另一个示例项目',
    status: 'inactive',
    created_at: CREATED_AT,
    updated_at: CREATED_AT,
  },
];

在 bootstrap 里把初始数据传给 Model:

typescript 复制代码
import { projectData } from './data/project/index.js';
import { ProjectModel } from './models/project/project-model.js';

const projectModel = new ProjectModel(projectData);

6.7 Model 层能有查询方法吗

可以。Model 层除了基础的 CRUD,还可以有各种查询方法,比如:

typescript 复制代码
// 按状态筛选
listByStatus(status: Project['status']): Project[] {
  return this.#projects
    .filter((p) => p.status === status)
    .map((p) => structuredClone(p));
}

// 模糊搜索名称
searchByName(keyword: string): Project[] {
  const lower = keyword.toLowerCase();
  return this.#projects
    .filter((p) => p.name.toLowerCase().includes(lower))
    .map((p) => structuredClone(p));
}

原则:Model 层只管「怎么查数据」,不管「为什么要查这些数据」。

比如"管理员能看到所有项目,普通用户只能看到自己的项目"------这是业务规则,应该在 Service 层做判断,然后调用 Model 的不同方法。


6.8 小结

  • Model 层只做数据 CRUD,不包含业务逻辑
  • 用私有字段保护数据,外部只能通过方法操作
  • 返回数据时深拷贝,防止外部直接修改内部状态
  • 时间戳、ID 自增等由 Model 层自动维护
  • 初始数据放在 data/ 目录,启动时注入 Model
相关推荐
TechLee1 小时前
一行代码防水平越权:用 PHP-Casbin 终结业务里的数据级 if-else
后端·php
步行cgn1 小时前
Spring 底层如何创建对象:反射机制与实例化策略
java·后端·spring
Lyra_Infra1 小时前
从 MySQL 到达梦:一次信创隔离环境里的数据库迁移踩坑实录
数据库·后端·mysql
程序员天天困1 小时前
Kafka 接入 AI 的三条路线:MCP 提案、会话记忆与实时上下文
大数据·后端·kafka
明月_清风1 小时前
用自然语言控制 Blender:开源项目 Blender MCP 深度介绍
人工智能·后端
JaguarJack2 小时前
Whim 是什么?一门把 PHP 被否决的设想真跑起来的实验语言
后端·php·服务端
明月_清风3 小时前
MHS:AI Agent 开始连接物理世界
人工智能·后端·网络协议
ssshooter3 小时前
Node.js 天生就是异步的,为什么还要消息队列?
javascript·后端·面试
福兮说3 小时前
Gin 项目里的错误处理:让 Controller 不必知道错误是怎么来的
后端·go·gin·架构设计