Express 学习之 shoutbox 项目
Express 学习之 shoutbox 项目
这是《Node.js 实战》一书中介绍的 Express 示例项目,代码基本上和书中一致,只有访问 redis 的部分方法和书中有一些差异。
源码地址:https://cnb.cool/liujiajia-me/learning/shoutbox
另外,在本地安装 redis 服务(Windows 环境)时出了点问题,已经记录在 README 中了。
详细内容记录如下。
初始化项目
本次使用的 Node.js 版本是 24.1.0,操作系统是 Windows。
安装 express-generator
npm install -g express-generator
初始化项目
express -e shoutbox
-e 选项表示使用 EJS 模板引擎。
安装依赖
npm install
启动应用
npm run start
安装依赖
示例中还用到了 redis 和 bcrypt 依赖。
因为示例很简单,所以使用 redis 作为数据的存储。
bcrypt 则用来对用户密码进行加密。
安装 redis 依赖
npm i redis
安装 bcrypt 依赖
用来对用户密码进行加密。
npm i bcrypt
安装 basic-auth 依赖
用来实现基本的 HTTP 认证。
npm i basic-auth
安装 express-session 依赖
用来存储用户登录信息的 session。
npm i express-session
Redis
安装 redis(Windows)
这里使用 Chocolatey 来安装 redis。Chocolatey 的安装方法见 https://chocolatey.org/install。
choco install redis
豆包回答使用
choco install redis-64 -y命令安装,但是我安装时虽然窗口显示成功了,但是并没有redis-server命令,另外也没有 redis 服务。
貌似豆包是根据 CSDN 上的一篇文章回复的,但是不知道为什么我这边执行的效果不一样。
另外,安装 redis-64 时好像默认给我安装了一个 memurai 服务,貌似而且这个服务也是用的 6379 端口。
奇怪的是,memurai 服务启动时,我仍然可以正常启动 redis 服务,而且通过 RDM 连接时,也可以正常连接到 redis 服务。
此时,express 代码中的 redis 客户端连接的是 memurai 服务,而不是 redis 服务。
需要将 memurai 服务关闭,才会连接到 redis 服务。
查看 redis 版本
redis-server --version
启动 redis 服务
redis-server
本项目是根据 Node.js 实战的代码来实现的,其中 redis 访问的代码和书中的不太一样。
项目结构
完成后的项目整体结构如下:
SHOUTBOX
│ .gitignore
│ api.http
│ app.js
│ package-lock.json
│ package.json
│ README.md
│
├─bin
│ www
│
├─middleware
│ messages.js
│ page.js
│ user.js
│ validate.js
│
├─models
│ entry.js
│ user.js
│ userTest.js
│
├─public
│ └─stylesheets
│ style.css
│
├─routes
│ api.js
│ entries.js
│ index.js
│ login.js
│ register.js
│ users.js
│
└─views
│ entries.ejs
│ error.ejs
│ index.ejs
│ login.ejs
│ menu.ejs
│ message.ejs
│ post.ejs
│ register.ejs
│
└─entries
xml.ejs
项目中的重点内容如下:
路由
项目路由在 app.js 中配置,具体的实现则在 routes 目录下的文件中。
路由注册示例
var entries = require('./routes/entries');
app.get('/', entries.list);
对应的路由处理函数
const Entry = require('../models/entry');
exports.list = (req, res, next) => {
Entry.getRange(0, -1, (err, entries) => {
if (err) return next(err);
res.render('entries', { title: 'Entries', entries: entries });
});
};
路由处理函数说明
req:请求对象,包含请求信息。res:响应对象,用于发送响应。next:下一个中间件函数,用于处理错误或继续执行。Entry.getRange(0, -1, (err, entries) => { ... }):从数据库中获取所有 entry 记录。res.render('entries', { title: 'Entries', entries: entries }):渲染entries.ejs视图文件,传递 title 和 entries 参数。
视图
这里使用的 ejs(Embedded JavaScript)视图模板引擎,视图文件保存在 views 目录下。
视图示例
这里是上面 route 示例代码中用到的 entries 视图文件。
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<% include menu %>
<% entries.forEach((entry) => { %>
<div class="entry">
<h3><%= entry.title %></h3>
<p><%= entry.body %></p>
<p>Posted by <%= entry.username %></p>
</div>
<% }) %>
</body>
</html>
视图示例说明
<%= title %>:输出路由处理函数中传递的 title 参数。<% include menu %>:包含menu.ejs视图文件。<% entries.forEach((entry) => { %>:遍历 entries 参数,渲染每个 entry。<% }) %>:结束遍历。<%= entry.title %>、<%= entry.body %>、<%= entry.username %>:输出 entry 对象的属性。
模型
模型存储在 models 目录下。
这里的模型有些类似于 Spring Boot 项目中的 Repository 层。其写法感觉比较像 DDD 中的 Model 层,Java 中一般模型和操作是分开的。
const redis = require("redis");
const db = redis.createClient();
// 连接到 Redis
(async () => {
await db.connect();
})();
class Entry {
constructor(obj) {
for (let key in obj) {
this[key] = obj[key];
}
}
save(cb) {
const entryJSON = JSON.stringify(this);
db.lPush("entries", entryJSON)
.then(() => {
cb();
})
.catch((err) => {
console.error("保存 entry 失败:", err);
cb(err);
});
}
static getRange(from, to, cb) {
db.lRange("entries", from, to)
.then((items) => {
let entries = [];
items.forEach((item) => {
entries.push(JSON.parse(item));
});
cb(null, entries);
})
.catch((err) => {
console.error("获取 entries 范围失败:", err);
cb(err);
});
}
static count(cb) {
db.lLen("entries")
.then((total) => {
cb(null, total);
})
.catch((err) => {
console.error("获取 entries 总数失败:", err);
cb(err);
});
}
}
module.exports = Entry;
redis 的相关方法的调用方法和书中的不太一致,不确定是因为版本问题还是其他原因。
从上面的代码可以看出,这里使用了 redis 的 List 类型,来存储 entry 记录。这种用法还是很常见的,项目中经常会用到。
中间件
中间件存储在 middleware 目录下。
这个项目中用到了 4 个中间件,感觉都很有用,这里都分别介绍一下。
validate
这个中间件用来验证请求中的参数是否符合要求。如果不符合要求,就会返回错误信息并跳转到上一页。
function parseField(field) {
return field.split(/\[|\]/).filter((s) => s);
}
function getField(req, field) {
let val = req.body;
field.forEach(prop => {
val = val[prop];
});
return val;
}
exports.required = (field) => {
field = parseField(field);
return (req, res, next) => {
const val = getField(req, field);
if (val) {
next();
} else {
res.error(`${field.join('.')} 不能为空`);
res.redirect('back');
}
};
}
exports.lengthRange = (field, from, to) => {
field = parseField(field);
return (req, res, next) => {
const val = getField(req, field);
if (val.length >= from && val.length <= to) {
next();
} else {
res.error(`${field.join('.')} 长度必须在 ${from} 到 ${to} 个字符之间`);
res.redirect('back');
}
};
}
使用方法
app.post('/post', validate.required('entry[title]'), validate.lengthRange('entry[title]', 4, 100), entries.submit);
上面的代码表示:当用户提交表单时,会先验证 entry[title] 是否为空,然后验证其长度是否在 4 到 100 个字符之间。验证通过了,才会调用 entries.submit 方法来处理提交。
user
这个是用户信息中间件。从 session 中获取用户 id,然后读取用户信息到 req.user 和 res.locals.user 中,以供之后路由处理或视图渲染时使用。
const User = require("../models/user");
module.exports = (req, res, next) => {
if (req.remoteUser) {
res.locals.user = req.remoteUser;
}
const uid = req.session.uid;
if (!uid) return next();
User.get(uid, (err, user) => {
if (err) return next(err);
req.user = res.locals.user = user;
next();
});
};
page
这个中间件用来处理分页参数。
从请求参数中获取 page 参数,然后根据 perpage 参数计算出分页信息,存储到 req.page 和 res.locals.page 中,查询时直接使用即可。
module.exports = (cb, perpage) => {
perpage = perpage || 10;
return (req, res, next) => {
const page = Math.max(parseInt(req.params.page || '1', 10), 1) - 1;
cb((err, total) => {
if (err) return next(err);
req.page = res.locals.page = {
number: page,
perpage: perpage,
from: page * perpage,
to: page * perpage + perpage - 1,
total: total,
count: Math.ceil(total / perpage),
};
next();
});
};
};
messages
这个中间件用来处理消息。
其在 req 中提供了一个 error 方法以接受错误消息,同时提供了一个 removeMessages 方法以清除消息。
const express = require('express');
function message(req) {
return (msg, type) => {
type = type || 'info';
const sess = req.session;
sess.messages = sess.messages || [];
sess.messages.push({
string: msg,
type: type,
});
};
}
module.exports = (req, res, next) => {
res.message = message(req);
res.error = (msg) => res.message(msg, 'error');
res.locals.messages = req.session.messages || [];
res.locals.removeMessages = () => {
req.session.messages = [];
};
next();
}
添加消息的方法在上面的 validate 中间件中有使用到。
显示的处理在 message.ejs 视图里。
<% if (locals.messages) { %>
<% messages.forEach((message) => { %>
<p class="<%= message.type %>"><%= message.string %></p>
<% }) %>
<% removeMessages() %>
<% } %>
api
前面的路由中请求的响应都是页面,这里的路由则返回数据。返回的格式支持内容协商。
示例代码在 /routes/api.js 中。
const auth = require('basic-auth');
const express = require('express');
const User = require('../models/user');
const Entry = require('../models/entry');
exports.auth = (req, res, next) => {
const credentials = auth(req);
if (!credentials || !credentials.name || !credentials.pass) {
res.set('WWW-Authenticate', 'Basic realm="Shoutbox"');
return res.status(401).send('Authentication required');
}
User.authenticate(credentials.name, credentials.pass, (err, user) => {
if (err) return next(err);
if (user) {
req.remoteUser = user;
next();
} else {
res.set('WWW-Authenticate', 'Basic realm="Shoutbox"');
return res.status(401).send('Authentication failed');
}
});
}
exports.user = (req, res, next) => {
const id = req.params.id;
User.get(id, (err, user) => {
if (err) return next(err);
if (user) {
res.json(user);
} else {
res.status(404).send('User not found');
}
});
}
exports.entries = (req, res, next) => {
const page = req.page;
Entry.getRange(page.from, page.to, (err, entries) => {
if (err) return next(err);
res.format({
json: () => {
res.send(entries);
},
xml: () => {
res.render('entries/xml', { entries });
},
});
});
}
这里用户权限校验采用的方式是:所有 /api 开头的路由都需要进行权限校验,也就是执行上面的 auth 中间件。
中间件的注册在 /app.js 中。
var api = require('./routes/api');
app.use('/api', api.auth);
上面的 entries 方法中可以看到直接使用上面介绍的 page 中间件设置的分页参数。另外其返回内容的 res.format 方法中增加内容协商的支持,可以根据请求中的 Accept 头来返回不同的格式。
api.http
这个文件里添加了几个发送 api 请求的配置。
VS Code 中安装了 REST Client 插件的话,可以直接点击文档中出现的 Send Request 按钮发送请求。
以上就是关于 shoutbox 的介绍。因为是入门项目,理解错误的地方还请指正。