真入门级Node.js + Redis构建事件管理服务,作为小前端都能秒懂,属实不容易。
最近看到一篇不错的文章,Node.js + Redis + React 构建一个事件管理应用,适合想要更好的了解关于后台那点事的我!文中讲解了
- 为什么要用
Redis? - 如何使用
Redis? - 如何构建用户认证?
- 如何
Redis结合Node做接口以及做事件管理服务?
............
原文中讲解的挺细的,整体流程循循渐进,跟着操作思路很清晰,有空的可以去看一看,是全英文的。下面记录一下我实践过的整体流程,更多知识点都有扩展性,很值得学习。
一. 前置准备
Node.jsVSCodenpmPostman- RedisInsight 可视化工具,直接安装就可以了
-
选择 New subscription -> Fixed plans
-
可以选择
30MB的免费云数据库玩一下,只是数据不会被持久化,不影响往下学习 -
接着创建一个数据库
-
最后一步用上面安装好的
RedisInsight可视化我们的数据库数据
okkk...然后点击连接,一切准备就绪,可以开始构建服务了
二. 开始构建事件管理服务
1. 初始化项目
-
cd desktop && mkdir event-manager && cd event-manager -
npm init -y -
引入需要的依赖
npm install express dotenv redis-om ulid jsonwebtoken bcrypt
npm install -D nodemon -
打开
package.json, 把type字段改为module -
创建
src文件夹,在下面创建app.js文件,初始化服务并启动import express from 'express'; import { config } from 'dotenv'; config(); const app = express(); app.use(express.json()); const port = process.env.PORT ?? 3000; app.get('/api/v1', (req, res) => { res.send('Welcome to the event management platform built on redis'); }); app.listen(port, () => { console.log(`Server running on: http://localhost:${port}`); }); -
package.json加入启动命令"start": "node src/app.js", "dev": "nodemon src/app.js" // 实时更新 -
启动服务
2. 连接数据库
-
在
src下面创建db文件夹,创建index.js文件import { config } from 'dotenv'; import { Client } from 'redis-om'; config(); const redisCloudConnectionString = `redis://${process.env.REDIS_DB_USER}:${process.env.REDIS_DB_PASS}@${process.env.REDIS_DB_URL}`; const redisClient = new Client(); await redisClient.open(redisCloudConnectionString); export { redisClient };
3. 创建用户认证
创建个人事件管理的应用是需要用户认证的
-
创建认证接口
在src下面创建routes, controllers, utils,middleware4个文件夹,在controllers文件夹下面创建auth.js文件,。import { ulid } from 'ulid'; import { hash, compare } from 'bcrypt'; import { redisClient } from '../db/index.js'; import { generateToken } from '../utils/jwtHelper.js'; const createAccount = async (req, res) => { try { const { firstName, lastName, email, password, displayName } = req.body; const userId = ulid(); // 为用户生成id用作键在RedisGenerate为用户id将被用作复述的关键 // 检查是否已经存在 const userEmail = await redisClient.hgetall(`user:${email}`); if (userEmail.email === email) { return res.status(409).send({ error: true, message: 'Account with that email already exists', data: '', }); } // 保存密码前进行加密 const hashedPassword = await hash(password, 10); // 创建用户账户 const createUser = await redisClient.execute([ 'HSET', `user:${email}`, 'id', `${userId}`, 'firstName', `${firstName}`, 'lastName', `${lastName}`, 'email', `${email}`, 'password', `${hashedPassword}`, 'displayName', `${displayName}`, ]); // 生成token const token = await generateToken({ userKey: `user:${email}`, userId }) if (createUser && typeof createUser === 'number') { return res.status(201).send({ error: false, message: 'Account succesfully created', data: { token }, }); } } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}` }); } }; const login = async (req, res) => { try { const { email, password } = req.body; // 获取用户详细复述 const user = await redisClient.hgetall(`user:${email}`); const validaPassword = await compare(password, user.password); if (!user.email || !validaPassword) { return res.status(401).send({ error: true, message: 'Invlaid email or password', }); } const token = await generateToken({ userKey: `user:${email}`, userId: user.id }) return res.status(200).send({ error: false, message: 'Login successfully', data: { token }, }); } catch (error) { return `Server error, please try again later. ${error}`; } }; export { createAccount, login };很明显,
createAccount函数是创建账户接口方法,我们保存用户注册的信息,用ulid包(是uuid很好的替代品)生成一个唯一的userId,用email作为唯一键,对密码进行加密,生成对应的token传给用户,然后通过redisClient与Redis云数据库进行交互。
登录的时候只需要用email拿到用户信息以及校验密码就可以拿到token。 -
jwt获取和验证token
在utils文件夹里创建jwtHelper.js文件import jwt from 'jsonwebtoken'; import { config } from 'dotenv'; config(); export const generateToken = async payload => { return await jwt.sign(payload, process.env.JWTSECRET, { expiresIn: parseInt(process.env.TOKENEXPIRATIONTIME, 10), }); }; export const jwtValidator = async token => { try { const decodedToken = await jwt.verify(token, process.env.JWTSECRET,); return decodedToken; } catch (error) { return false; } }; -
创建接口路由,应用接口
在routes文件夹里创建authRouter.js文件import { Router } from 'express'; import { createAccount, login } from '../controllers/authController.js'; const authRouter = Router(); authRouter.post('/signup', createAccount); authRouter.post('/login', login); export { authRouter }; -
在
app.js主应用里接入路由app.use('/api/v1/auth', authRouter);
到这里已经完成用户认证接口了
最后要设置一下前面定义了process.env.xxx的环境变量,是通过dotenv来实现的
在根目录创建 .env 文件
PORT=
REDIS_DB_URL=
REDIS_DB_USER=
REDIS_DB_PASS=
TOKENEXPIRATIONTIME=
JWTSECRET=
DB_URL是 Redis云数据库 的Public endpoint字段
TOKENEXPIRATIONTIME是 token的过期时间,例如: 60, "2 days", "10h", "7d"
JWTSECRET是自定义秘钥,随便写一串字符串就可以了
-
用
postman调试一下
4. 创建事件模块接口
The RediSearch module since its development has been a game changer in this regard. It’s now much easier to perform various searches e.g. fulltext search, aggregate search results, sort and so much more.
RediSearch模块对于搜索操作数据库非常容易,有各种内置api支持,不用写SQL也能很好的跟数据库做交互,事件模块是这个应用的核心,也是 Redis 数据库的全部功能发挥作用的地方。
这就是为什么用RediSearch模块应用数据库
-
建模数据并创建索引
在src文件夹下面创建repository文件夹,在该文件夹下面创建event.js文件import { Entity, Schema } from 'redis-om'; import { redisClient } from '../db/index.js'; class EventRepository extends Entity {} const eventSchema = new Schema(EventRepository, { title: { type: 'text' }, description: { type: 'text' }, category: { type: 'string' }, venue: { type: 'string' }, locationPoint: { type: 'point' }, startDate: { type: 'date', sortable: true }, endDate: { type: 'date', sortable: true }, imageUrl: { type: 'string' }, userId: { type: 'string' }, createdAt: { type: 'date', sortable: true }, updatedAt: { type: 'date', sortable: true }, }, { dataStructure: 'HASH' }); const eventRepository = redisClient.fetchRepository(eventSchema); await eventRepository.createIndex(); export { eventRepository };redisClient相当于连接数据库的网关,用redis-om定义协议,分配字段和数据类型。
具体协议以及类型看这里
对于这部分代码有兴趣的可以看看原文,搜索 Modeling the data and creating the index -
在
Redis上搭建事件管理平台
在controllers文件夹下面创建event.js文件export { eventRepository }; import { eventRepository } from '../repository/event.js'; import { preparePagination, getTotalPages } from '../utils/pagination.js'; const createEvent = async (req, res) => { try { const { title, description, category, venue, locationPoint, startDate, endDate, imageUrl, } = req.body; const event = await eventRepository.createAndSave({ title: title.toLowerCase(), description, category: category.toLowerCase(), venue, locationPoint, startDate, endDate, imageUrl, userId: req.validatedToken.userId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); if (event) { return res.status(201).send({ error: false, message: 'Event successfully created', data: event, }); } } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}`, }); } }; const getAllEvents = async (req, res) => { try { const { page, limit } = req.query; const { page: offset, limit: count } = preparePagination(page, limit); // 获取所有事件的创建日期排序,确保最新出现的第一个 const allEvents = await eventRepository .search() .sortDescending('createdAt') .return.page(offset, count); // 获取事件的总数 const totalEvents = await eventRepository.search().return.count(); const totalPages = getTotalPages(totalEvents, count); return res.status(200).send({ error: false, message: 'Events retrieved successfylly', data: { allEvents, totalEvents, totalPages, }, }); } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}`, }); } }; const getEventById = async (req, res) => { try { const { eventId } = req.params; // 通过id获取单个事件 const event = await eventRepository.fetch(eventId); if (!event) { return res.status(404).send({ error: true, message: 'Event not found.', data: event, }); } return res.status(200).send({ error: false, message: 'Event retrieved successfylly', data: event, }); } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}`, }); } }; const getEventsByUserId = async (req, res) => { try { const { userId } = req.validatedToken; const { page, limit } = req.query; const { page: offset, limit: count } = preparePagination(page, limit); // 获取按创建日期排序的所有事件,以确保最新的事件最先出现 const userEvents = await eventRepository .search() .where('userId') .equal(userId) .sortDescending('createdAt') .return.page(offset, count); // 获取当前用户的事件总数 const totalEvents = await eventRepository .search() .where('userId') .equal(userId) .return.count(); const totalPages = getTotalPages(totalEvents, count); return res.status(200).send({ error: false, message: 'Events retrieved successfylly', data: { userEvents, totalEvents, totalPages, }, }); } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}`, }); } }; const getEventsNearMe = async (req, res) => { try { const lon = Number(req.query.lon); const lat = Number(req.query.lat); const distanceInKm = Number(req.query.distanceInKm) ?? 10; const { page, limit } = req.query; const { page: offset, limit: count } = preparePagination(page, limit); // 获取所有事件公里半径的位置提供 const eventsNearMe = await eventRepository .search() .where('locationPoint') .inRadius( (circle) => circle.origin(lon, lat).radius(Number(distanceInKm)).km ) .sortDescending('createdAt') .return.page(offset, count); // 获取所有事件公里半径的位置提供的总数 const totalEvents = await eventRepository .search() .where('locationPoint') .inRadius( (circle) => circle.origin(lon, lat).radius(Number(distanceInKm)).km ) .return.count(); const totalPages = getTotalPages(totalEvents, count); return res.status(200).send({ error: false, message: 'Here are the events happening near you.', data: { eventsNearMe, totalEvents, totalPages, }, }); } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}`, }); } }; const searchEvents = async (req, res) => { try { const searchKey = Object.keys(req.query)[0]; const searchValue = Object.values(req.query)[0]; const { page, limit } = req.query; const { page: offset, limit: count } = preparePagination(page, limit); if (!searchKey) { return await getAllEvents(req, res); } // 确定搜索标准及相应的事件 let searchResult; if (searchKey && searchKey.toLowerCase() === 'category') { searchResult = await searchBycategory(searchValue.toLowerCase(), offset, count); } if (searchKey && searchKey.toLowerCase() === 'title') { searchResult = await searchBytitle(searchValue.toLowerCase(), offset, count); } return res.status(200).send({ error: false, message: 'Events based on your search criteria.', data: searchResult, }); } catch (error) { return res.status(500).send({ error: true, message: `Server error, please try again later. ${error}`, }); } }; // 通过category字段搜索 const searchBycategory = async (category, offset, count) => { const events = await eventRepository .search() .where('category') .eq(category) .sortDescending('createdAt') .return.page(offset, count); const totalEvents = await eventRepository .search() .where('category') .eq(category) .return.count(); const totalPages = getTotalPages(totalEvents, count); return { events, totalEvents, totalPages, }; }; // 通过title字段搜索 const searchBytitle = async (title, offset, count) => { const events = await eventRepository .search() .where('title') .match(title) .sortDescending('createdAt') .return.page(offset, count); const totalEvents = await eventRepository .search() .where('title') .match(title) .return.count(); const totalPages = getTotalPages(totalEvents, count); return { events, totalEvents, totalPages, }; }; export { createEvent, getAllEvents, getEventById, getEventsByUserId, getEventsNearMe, searchEvents, };可以看到上面代码里有大量的
eventRepository.xxx方法,只要调用对应的api就可以与数据库交互,实在方便。 类似SQL语句,api也是语义化的,很好理解
SELECT * FROM table_name ORDER BY field DESC LIMIT limit OFFSET offset;还需要创建上面
import的pagination,在utils文件夹下面新建pagination.js文件await eventRepository.createIndex(); export const preparePagination = (page, limit) => { page = Number(page); limit = Number(limit); return { page: page && page >= 0 ? (page - 1) * limit : 0, limit: limit && limit > 0 ? limit : 10, }; } export const getTotalPages = (totalRecords, limit) => { return Math.ceil(totalRecords / limit); } -
提供接口中间件
主要用来拦截用户认证
在middleware文件夹下面新建index.js文件import { jwtValidator } from '../utils/jwtHelper.js'; export const authGuard = async (req, res, next) => { const token = req.headers.authorization ? req.headers.authorization.split(' ')[1] : req.params.token; const validatedToken = await jwtValidator(token); if (!validatedToken) { return res.status(401).send({ error: true, message: 'Unauthorized user.', }); } req.validatedToken = validatedToken; next(); };如果
token验证失败就返回401,表示用户无权限 成功执行next()方法,调用下一个接口执行函数 -
加入路由
在routes文件夹下新建event.js文件import { Router } from 'express'; import { createEvent, getAllEvents, getEventById, getEventsByUserId, getEventsNearMe, searchEvents, } from '../controllers/event.js'; import { authGuard } from '../middleware/index.js'; const eventRouter = Router(); eventRouter.post('', authGuard, createEvent); eventRouter.get('', getAllEvents); eventRouter.get('/users', authGuard, getEventsByUserId); eventRouter.get('/locations', getEventsNearMe); eventRouter.get('/search', searchEvents); eventRouter.get('/:eventId', getEventById); export { eventRouter }; -
接入主应用
import express from 'express'; import { config } from 'dotenv'; import { authRouter } from './routes/authRouter.js'; import { eventRouter } from './routes/event.js'; config(); const app = express(); app.use(express.json()); const port = process.env.PORT ?? 3000; app.use('/api/v1/auth', authRouter); app.use('/api/v1/events', eventRouter); app.get('/api/v1', (req, res) => { res.send('Welcome to the event management platform built on redis'); }); app.listen(port, () => { console.log(`Server running on: http://localhost:${port}`); });
测试一下
-
现在接口已经开发完了,postman测试一下能用不
这样就没问题了,后面可以发挥聪明的大脑去设计一下前端页面,扩展一下其他功能接口,一个属于自己的管理页面就出来了。
写在最后
- 为什么要用
Redis做唯一数据库?
是因为Redis运行时存储数据在内存中有缓存,所以非常快,快速的数据库将直接影响应用程序的性能,进而影响应用程序用户的体验。对于简单的管理系统来说很合适。 - 数据类型
text和string有什么区别?
两种类型都是字符串,如果只是读取和写入,没什么区别,如果要搜索的话,text类型启用了全文搜索,只要有字段匹配上了就能搜出来。