从前端到全栈:编写一个NodeJS后端服务

840 阅读10分钟

1. 引言

  • 背景介绍:为了拓宽技术视野,了解从Web项目从开发到部署到全流程,准备从头开始编写一个全栈练习项目。
  • 项目目标:编写一个简易CMS 后端服务。之所以选择CMS(内容管理系统)是因为它业务足够简单,满足我们练习的目的。
  • 技术栈选择:选择Node.js作为项目主要技术,因为作为前端开发,最快上手的后端技术是Node.js。同时Node.js在我们写一些轻量服务也有用处,值得学习。

我选择了以下技术栈来构建后端服务:

  • Koa:Koa 是一个由 Express 团队开发的下一代 Node.js 框架,以其简洁和现代化的设计著称。它通过中间件机制提供了高度的灵活性和可扩展性,非常适合构建轻量级的 HTTP 服务器和 API。
  • MySQL2:MySQL2 是一个用于 Node.js 的 MySQL 客户端库,提供了对 MySQL 数据库的高效访问。它支持 Promise 和异步操作,确保数据库操作的高性能和可靠性。
  • Nodemon:Nodemon 是一个开发工具,用于监视 Node.js 应用程序中的文件更改,并自动重启服务器。这提高了开发效率,使开发者能够快速测试和迭代代码。
  • Joi:Joi 是一个强大的数据验证库,允许开发者声明式地定义数据结构和验证规则。它帮助确保 API 请求和用户输入的数据符合预期的格式和约束。
  • Mock.js:Mock.js 是一个模拟数据生成库,常用于开发和测试阶段。它可以生成随机的、符合格式的假数据,帮助开发者在没有真实数据的情况下进行测试。
  • Day.js:Day.js 是一个轻量级的日期处理库,提供了与 Moment.js 类似的 API,但体积更小。它用于处理和格式化日期和时间,适合需要高性能和小体积的应用。

2. 项目架构

  • 整体架构图:提供一个简单的架构图,展示系统的组成部分。 image.png

  • 模块介绍:一个新闻内容管理,一个用户管理。

    • 新闻内容管理:包含新闻、新闻分类、新闻标签的增删改查。
    • 用户管理:包含用户信息,权限信息的相关逻辑,以及与其相关的登录逻辑。

3. 技术实现

  • 项目结构
/cms-server-node
│
├── /db
│   ├── backup.sql
│
├── /config
│   ├── index.js
│
├── /controller
│   ├── articleController.js
│   ├── authController.js
│   └── other...
│
├── /model
│   ├── articleModel.js
│   ├── authModel.js
│   └── other...
│
├── /router
│   ├── index.js
│
├── /middleware
│   ├── 404.js
│   ├── authenticate.js
│   └── responseFormatter.js
│
├── /utils
│   ├── db.js
│   ├── dotenv.js
│   └── index.js
│
├── /validators
│   ├── articleValidator.js
│   ├── categoryValidator.js
│   └── other...
│
├── /mock
│   ├── articleMock.js
│   ├── authMock.js
│   └── other...
│
├── /node_modules
│
├── app.js
├── package.json
└── README.md

说明:

  • controller:包含应用的业务逻辑。每个控制器文件通常对应一个模块(如内容管理、用户管理)。
  • model:包含数据模型和数据库交互逻辑。每个模型文件通常对应数据库中的一个表或集合。
  • route:定义应用的路由,将请求映射到相应的控制器。
  • middleware:包含中间件函数,用于处理请求和响应的不同阶段。
  • config:包含配置文件,如是否使用mock数据。
  • db:存放数据库初始化文件backup.sql,执行它可以初始化数据库及数据库表以及一些初始数据。
  • utils:存放辅助功能模块,如数据库连接、环境变量加载。
  • mock:包含mock文件,用于开发调试过程中模拟数据。
  • app.js:应用的入口文件,设置服务器和中间件。
  • package.json:项目的依赖和脚本配置。
  • README.md:项目的文档说明。
  • 核心功能实现
    • 入口配置:设置静态文件服务、请求体解析、跨域资源共享、格式化响应数据等多种中间件的配置和路由器的集成。
function start({ port, host } = {}) {
  const app = new Koa();
  const router = getRouter();

  app
    .use(serve(rootDir)) // 提供静态文件服务
    .use(bodyParser())
    .use(
      cors({
        credentials: true,
      })
    )
    .use(notFound)
    .use(responseFormatter)
    .use(router)
    .use(compress);

  app.listen(port, host);
  console.log(`serve start at:  http://localhost:${port}\n\n`);
}
  • 路由配置:设置各模块路由路径及授权中间件、关联对应的Controller方法。
// 权限相关
  router.get("/menu/list", authenticate, authController.getMenuList);
  router.get("/auth/buttons", authenticate, authController.getAuthButtons);
// 用户相关
// 文章相关
// 文章分类
// 文章标签
// 文件相关
  • Controller方法:展示一个添加文章方法,使用Joi封装validateCreateArticle方法验证参数合法性,再调用Model层方法执行数据库操作。

    async addArticle(ctx) {
        const params = ctx.request.body;
        
        const { error } = validateCreateArticle(params);
        
        if (error) {
          ctx.throw(400, { message: error.details[0].message });
          return;
        }
        
        const result = await articleModel.addArticle(params);
        if (result > 0) {
          ctx.body = true;
        } else {
          ctx.throw(500, { message: '创建文章失败' });
        }
    }
  • 数据校验:使用Joi进行校验数据,配置简单明了。

    // 定义基础的文章验证规则
    const baseArticleSchema = Joi.object({
      title: Joi.string().required().messages({
        'string.empty': '标题不能为空',
        'any.required': '标题是必填项',
      }),
      content: Joi.string().required().messages({
        'string.empty': '内容不能为空',
        'any.required': '内容是必填项',
      }),
      summary: Joi.string().required().messages({
        'string.empty': '摘要不能为空',
        'any.required': '摘要是必填项',
      }),
      categoryId: Joi.string().required().messages({
        'string.empty': '分类ID不能为空',
        'any.required': '分类ID是必填项',
      }),
      tagIds: Joi.array().items(Joi.string()).required().messages({
        'array.base': '标签ID必须是一个数组',
        'any.required': '标签ID是必填项',
      }),
      thumbnail: Joi.string().uri().optional().allow('').messages({
        'string.uri': '缩略图URL格式不正确',
      }),
      isPublish: Joi.boolean().required().messages({
        'boolean.base': '发布状态必须是布尔值',
        'any.required': '发布状态是必填项',
      }),
      createTime: Joi.optional(),
      updateTime: Joi.optional(),
    });

    // 新增文章的验证规则(直接使用基础规则)
    const createArticleSchema = baseArticleSchema;
    const validateCreateArticle = (data) => {
      return createArticleSchema.validate(data);
    };
  • Model方法:展示一个文章添加方法,设置Sql语句和参数,执行查询。

    async function addArticle(reqParams) {
      if (config.useMock) {
        return articleMock.addArticle(reqParams);
      }
      const { title, content, summary, categoryId, tagIds, thumbnail, isPublish } =
        reqParams;

      const id = uuidv4();
      const createTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
      const updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");

      const sql = `
            INSERT INTO Article (
                id, 
                title, 
                content, 
                summary, 
                categoryId, 
                tagIds, 
                thumbnail, 
                isPublish, 
                createTime, 
                updateTime
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
        `;
      const params = [
        id,
        title,
        content,
        summary,
        categoryId,
        JSON.stringify(tagIds),
        thumbnail,
        isPublish,
        createTime,
        updateTime,
      ];
      const result = await db.query(sql, params);
      return result.affectedRows;
    }
  • 用户认证:如何实现用户注册、登录和身份验证(使用 JWT)。

    // 登录,用户名密码正常则生成token返回给客户端 
    async login(ctx) {
        const params = ctx.request.body;
        const result = await userModel.validateUser(
          params.username,
          params.password
        );
        if (result) {
          const token = jwt.sign(
            { username: result.username, roleId: result.roleId },
            config.SECRET_KEY,
            {
              expiresIn: '90d',
            }
          ); // 设置有效期为 3 个月

          ctx.body = { access_token: token };
        } else {
          ctx.throw(401, '用户名或密码无效');
        }
     }

    // 校验中间件方法,用户需要校验的接口都需要带着token,该方法校验token是否合法。
    async function authenticate(ctx, next) {
      if (config.useMock) {
        await next();
        return;
      }

      const token = ctx.headers['x-access-token'];

      if (token) {
        const user = jwt.verify(token, config.SECRET_KEY);
        ctx.state.user = user;
        await next();
      } else {
        ctx.status = 401;
        ctx.body = { message: 'Unauthorized' };
      }
    }

4. 数据库设计

  • 选择数据库: MongoDB、MySQL都可以作为我们的选择,关系型数据库还是更通用一些所以选择MySQL。
  • 数据模型
+----------------+       +----------------+       +----------------+
|   Category     |       |    Article     |       |      Tag       |
+----------------+       +----------------+       +----------------+
| id (PK)        |<------| id (PK)        |       | id (PK)        |
| parentId       |       | title          |       | name           |
| name           |       | content        |       | createTime     |
| createTime     |       | summary        |       | updateTime     |
| updateTime     |       | categoryId (FK)|       +----------------+
+----------------+       | tagIds (JSON)  |
                         | thumbnail      |
                         | isPublish      |
                         | createTime     |
                         | updateTime     |
                         +----------------+

+----------------+       +----------------+
|    Users       |       |    Roles       |
+----------------+       +----------------+
| id (PK)        |       | roleId (PK)    |
| username       |       | roleName       |
| gender         |       | createTime     |
| age            |       | updateTime     |
| idCard (UK)    |       +----------------+
| email (UK)     |
| address        |
| createTime     |
| status         |
| avatar         |
| roleId (FK)    |
| password       |
+----------------+

说明

  • Article 与 Category 的关系
    • Article表中的categoryId是一个外键,指向Category表的id字段。
  • Article 与 Tag 的关系
    • tagIds字段是JSON格式,用于存储多个标签ID。通常在关系型数据库中,这种多对多关系会通过一个中间表(如ArticleTag)来实现。
  • Users 与 Roles 的关系
    • Users表中的roleId是一个外键,指向Roles表的roleId字段。

5. API 设计

  • API 列表

权限相关

  1. 获取菜单列表
    • 方法: GET
    • 路径:/menu/list
    • 描述: 获取用户的菜单列表
    • 身份验证: 是
  2. 获取授权按钮
    • 方法: GET
    • 路径:/auth/buttons
    • 描述: 获取用户可用的按钮权限
    • 身份验证: 是

用户相关

  1. 用户登录
    • 方法: POST
    • 路径:/login
    • 描述: 用户登录
    • 身份验证: 否
  2. 用户登出
    • 方法: POST
    • 路径:/logout
    • 描述: 用户登出
    • 身份验证: 否
  3. 获取用户性别信息
    • 方法: GET
    • 路径:/user/gender
    • 描述: 获取用户的性别信息
    • 身份验证: 是
  4. 获取用户状态信息
    • 方法: GET
    • 路径:/user/status
    • 描述: 获取用户的状态信息
    • 身份验证: 是
  5. 获取用户部门信息
    • 方法: GET
    • 路径:/user/department
    • 描述: 获取用户的部门信息
    • 身份验证: 是
  6. 获取用户列表
    • 方法: POST
    • 路径:/user/list
    • 描述: 获取用户列表
    • 身份验证: 是
  7. 重置用户密码
    • 方法: POST
    • 路径:/user/rest_password
    • 描述: 重置用户密码
    • 身份验证: 是
  8. 删除用户
    • 方法: POST
    • 路径:/user/delete
    • 描述: 删除用户
    • 身份验证: 是
  9. 添加用户
    • 方法: POST
    • 路径:/user/add
    • 描述: 添加新用户
    • 身份验证: 是
  10. 编辑用户
    • 方法: POST
    • 路径:/user/edit
    • 描述: 编辑用户信息
    • 身份验证: 是
  11. 导出用户
    • 方法: POST
    • 路径:/user/export
    • 描述: 导出用户数据
    • 身份验证: 是
  12. 导入用户
    • 方法: POST
    • 路径:/user/import
    • 描述: 导入用户数据
    • 身份验证: 是
  13. 获取角色列表
    • 方法: GET
    • 路径:/user/role
    • 描述: 获取角色列表
    • 身份验证: 是
  14. 获取用户树形列表
    • 方法: GET
    • 路径:/user/tree/list
    • 描述: 获取用户的树形结构列表
    • 身份验证: 是
  15. 改变用户信息
    • 方法: POST
    • 路径:/user/change
    • 描述: 改变用户信息
    • 身份验证: 是

文章相关

  1. 获取文章列表
    • 方法: POST
    • 路径:/article/list
    • 描述: 获取文章列表
    • 身份验证: 否
  2. 获取文章详情
    • 方法: GET
    • 路径:/article/detail
    • 描述: 获取文章的详细信息
    • 身份验证: 否
  3. 添加文章
    • 方法: POST
    • 路径:/article/add
    • 描述: 添加新文章
    • 身份验证: 是
  4. 编辑文章
    • 方法: POST
    • 路径:/article/edit
    • 描述: 编辑文章信息
    • 身份验证: 是
  5. 删除文章
    • 方法: POST
    • 路径:/article/delete
    • 描述: 删除文章
    • 身份验证: 是

文章分类

  1. 获取分类列表
    • 方法: POST
    • 路径:/category/list
    • 描述: 获取文章分类列表
    • 身份验证: 否
  2. 获取分类详情
    • 方法: GET
    • 路径:/category/detail
    • 描述: 获取分类的详细信息
    • 身份验证: 是
  3. 添加分类
    • 方法: POST
    • 路径:/category/add
    • 描述: 添加新分类
    • 身份验证: 是
  4. 编辑分类
    • 方法: POST
    • 路径:/category/edit
    • 描述: 编辑分类信息
    • 身份验证: 是
  5. 删除分类
    • 方法: POST
    • 路径:/category/delete
    • 描述: 删除分类
    • 身份验证: 是

文章标签

  1. 获取标签列表
    • 方法: POST
    • 路径:/tag/list
    • 描述: 获取文章标签列表
    • 身份验证: 否
  2. 获取标签详情
    • 方法: GET
    • 路径:/tag/detail
    • 描述: 获取标签的详细信息
    • 身份验证: 是
  3. 添加标签
    • 方法: POST
    • 路径:/tag/add
    • 描述: 添加新标签
    • 身份验证: 是
  4. 编辑标签
    • 方法: POST
    • 路径:/tag/edit
    • 描述: 编辑标签信息
    • 身份验证: 是
  5. 删除标签
    • 方法: POST
    • 路径:/tag/delete
    • 描述: 删除标签
    • 身份验证: 是

文件相关

  1. 上传图片
    • 方法: POST
    • 路径:/file/upload/img
    • 描述: 上传图片文件
    • 身份验证: 否
  2. 上传视频
    • 方法: POST
    • 路径:/file/upload/video
    • 描述: 上传视频文件
    • 身份验证: 否
  3. 上传文件
    • 方法: POST
    • 路径:/file/uploadFile
    • 描述: 上传文件并执行回调
    • 身份验证: 否
  • 响应格式

    // 成功
    {
      "code": 200,
      "msg": "Success",
      "data": <实际的响应数据>
    }
    // 失败
    {
      "code": <错误状态码>,
      "msg": <错误消息>,
      "data": null
    }

6. Mock数据及调试

  • Mock策略:在编写业务逻辑过程中,我先使用MockJs模拟要返回的数据,用Controller层调用Mock层,调试完毕后,再写Model层调用数据库的逻辑,再将Controller层切换到调用Model层,完成整体的连调。
  • 调试工具:这里我使用Apifox来模拟接口的调用。

7. 结论

  • 项目总结:通过这个小项目,熟悉NodeJS开发后端服务的技术栈及开发调试流程。
  • 未来展望:全栈除了开发,还应该熟悉部署流程,接下来我们该学习Docker、CI/CD部署。

8. 附录

源码仓库:除了nodejs服务,我还开发了配套的后台管理和新闻阅读各端,只是为了练习,仓库地址放在下边。