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. 项目架构
-
整体架构图:提供一个简单的架构图,展示系统的组成部分。
-
模块介绍:一个新闻内容管理,一个用户管理。
-
- 新闻内容管理:包含新闻、新闻分类、新闻标签的增删改查。
- 用户管理:包含用户信息,权限信息的相关逻辑,以及与其相关的登录逻辑。
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 列表:
权限相关
- 获取菜单列表
- 方法: GET
- 路径:
/menu/list
- 描述: 获取用户的菜单列表
- 身份验证: 是
- 获取授权按钮
- 方法: GET
- 路径:
/auth/buttons
- 描述: 获取用户可用的按钮权限
- 身份验证: 是
用户相关
- 用户登录
- 方法: POST
- 路径:
/login
- 描述: 用户登录
- 身份验证: 否
- 用户登出
- 方法: POST
- 路径:
/logout
- 描述: 用户登出
- 身份验证: 否
- 获取用户性别信息
- 方法: GET
- 路径:
/user/gender
- 描述: 获取用户的性别信息
- 身份验证: 是
- 获取用户状态信息
- 方法: GET
- 路径:
/user/status
- 描述: 获取用户的状态信息
- 身份验证: 是
- 获取用户部门信息
- 方法: GET
- 路径:
/user/department
- 描述: 获取用户的部门信息
- 身份验证: 是
- 获取用户列表
- 方法: POST
- 路径:
/user/list
- 描述: 获取用户列表
- 身份验证: 是
- 重置用户密码
- 方法: POST
- 路径:
/user/rest_password
- 描述: 重置用户密码
- 身份验证: 是
- 删除用户
- 方法: POST
- 路径:
/user/delete
- 描述: 删除用户
- 身份验证: 是
- 添加用户
- 方法: POST
- 路径:
/user/add
- 描述: 添加新用户
- 身份验证: 是
- 编辑用户
- 方法: POST
- 路径:
/user/edit
- 描述: 编辑用户信息
- 身份验证: 是
- 导出用户
- 方法: POST
- 路径:
/user/export
- 描述: 导出用户数据
- 身份验证: 是
- 导入用户
- 方法: POST
- 路径:
/user/import
- 描述: 导入用户数据
- 身份验证: 是
- 获取角色列表
- 方法: GET
- 路径:
/user/role
- 描述: 获取角色列表
- 身份验证: 是
- 获取用户树形列表
- 方法: GET
- 路径:
/user/tree/list
- 描述: 获取用户的树形结构列表
- 身份验证: 是
- 改变用户信息
- 方法: POST
- 路径:
/user/change
- 描述: 改变用户信息
- 身份验证: 是
文章相关
- 获取文章列表
- 方法: POST
- 路径:
/article/list
- 描述: 获取文章列表
- 身份验证: 否
- 获取文章详情
- 方法: GET
- 路径:
/article/detail
- 描述: 获取文章的详细信息
- 身份验证: 否
- 添加文章
- 方法: POST
- 路径:
/article/add
- 描述: 添加新文章
- 身份验证: 是
- 编辑文章
- 方法: POST
- 路径:
/article/edit
- 描述: 编辑文章信息
- 身份验证: 是
- 删除文章
- 方法: POST
- 路径:
/article/delete
- 描述: 删除文章
- 身份验证: 是
文章分类
- 获取分类列表
- 方法: POST
- 路径:
/category/list
- 描述: 获取文章分类列表
- 身份验证: 否
- 获取分类详情
- 方法: GET
- 路径:
/category/detail
- 描述: 获取分类的详细信息
- 身份验证: 是
- 添加分类
- 方法: POST
- 路径:
/category/add
- 描述: 添加新分类
- 身份验证: 是
- 编辑分类
- 方法: POST
- 路径:
/category/edit
- 描述: 编辑分类信息
- 身份验证: 是
- 删除分类
- 方法: POST
- 路径:
/category/delete
- 描述: 删除分类
- 身份验证: 是
文章标签
- 获取标签列表
- 方法: POST
- 路径:
/tag/list
- 描述: 获取文章标签列表
- 身份验证: 否
- 获取标签详情
- 方法: GET
- 路径:
/tag/detail
- 描述: 获取标签的详细信息
- 身份验证: 是
- 添加标签
- 方法: POST
- 路径:
/tag/add
- 描述: 添加新标签
- 身份验证: 是
- 编辑标签
- 方法: POST
- 路径:
/tag/edit
- 描述: 编辑标签信息
- 身份验证: 是
- 删除标签
- 方法: POST
- 路径:
/tag/delete
- 描述: 删除标签
- 身份验证: 是
文件相关
- 上传图片
- 方法: POST
- 路径:
/file/upload/img
- 描述: 上传图片文件
- 身份验证: 否
- 上传视频
- 方法: POST
- 路径:
/file/upload/video
- 描述: 上传视频文件
- 身份验证: 否
- 上传文件
- 方法: 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服务,我还开发了配套的后台管理和新闻阅读各端,只是为了练习,仓库地址放在下边。
- 后端node服务: gitee.com/DaBuChen/cm…
- 前端后台管理: gitee.com/DaBuChen/cm…
- 新闻阅读web端: gitee.com/DaBuChen/cm…
- 新闻阅读app端: gitee.com/DaBuChen/cm…
- 新闻阅读小程序端: gitee.com/DaBuChen/cm…
- 新闻阅读桌面端: gitee.com/DaBuChen/cm…
- 新闻阅读web端(nextjs版) : gitee.com/DaBuChen/cm…