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
  

访问:http://localhost:3000

安装依赖

示例中还用到了 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.userres.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.pageres.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 的介绍。因为是入门项目,理解错误的地方还请指正。