入门级Node.js + Redis构建事件管理服务

302 阅读4分钟

入门级Node.js + Redis构建事件管理服务,作为小前端都能秒懂,属实不容易。
最近看到一篇不错的文章,Node.js + Redis + React 构建一个事件管理应用,适合想要更好的了解关于后台那点事的我!文中讲解了

  1. 为什么要用Redis
  2. 如何使用Redis
  3. 如何构建用户认证
  4. 如何Redis结合Node做接口以及做事件管理服务
    ............

原文中讲解的挺细的,整体流程循循渐进,跟着操作思路很清晰,有空的可以去看一看,是全英文的。下面记录一下我实践过的整体流程,更多知识点都有扩展性,很值得学习。

一. 前置准备

  1. Node.js
  2. VSCode
  3. npm
  4. Postman
  5. RedisInsight 可视化工具,直接安装就可以了

最后需要创建一个免费Redis云数据库账户

  • 登录账户

  • 选择 New subscription -> Fixed plans

  • 可以选择30MB的免费云数据库玩一下,只是数据不会被持久化,不影响往下学习

    image.png

  • 接着创建一个数据库

    image.png

  • 最后一步用上面安装好的RedisInsight可视化我们的数据库数据

    image.png

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" // 实时更新
    
  • 启动服务

    image.png

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,middleware 4个文件夹,在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传给用户,然后通过redisClientRedis云数据库进行交互。
    登录的时候只需要用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_URLRedis云数据库Public endpoint字段
TOKENEXPIRATIONTIMEtoken的过期时间,例如: 60, "2 days", "10h", "7d"
JWTSECRET自定义秘钥,随便写一串字符串就可以了

  • postman调试一下

    image.png

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;

    还需要创建上面importpagination,在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测试一下能用不

    image.png

    image.png
    这样就没问题了,后面可以发挥聪明的大脑去设计一下前端页面,扩展一下其他功能接口,一个属于自己的管理页面就出来了。

写在最后

  • 为什么要用Redis做唯一数据库?
    是因为Redis运行时存储数据在内存中有缓存,所以非常快,快速的数据库将直接影响应用程序的性能,进而影响应用程序用户的体验。对于简单的管理系统来说很合适。
  • 数据类型textstring有什么区别?
    两种类型都是字符串,如果只是读取和写入,没什么区别,如果要搜索的话,text类型启用了全文搜索,只要有字段匹配上了就能搜出来。