V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
scf10cent
V2EX  ›  Serverless

Serverless + Egg.js 后台管理系统实战(上)

  •  
  •   scf10cent · 2020-02-27 11:48:05 +08:00 · 6173 次点击
    这是一个创建于 1765 天前的主题,其中的信息可能已经有所发展或是发生改变。

    ▎本文将介绍如何基于 Egg.js 和 Serverless 实现一个后台管理系统

    作为一名前端开发者,在选择 Nodejs 后端服务框架时,第一时间会想到 Egg.js,不得不说 Egg.js 是一个非常优秀的企业级框架,它的高扩展性和丰富的插件,极大的提高了开发效率。开发者只需要关注业务就好,比如要使用 redis,引入 egg-redis 插件,然后简单配置就可以了。正因为如此,第一次接触它,我便喜欢上了它,之后也用它开发过不少应用。

    有了如此优秀的框架,那么如何将一个 Egg.js 的服务迁移到 Serverless 架构上呢?

    背景

    我在文章 基于 Serverless Component 的全栈解决方案 中讲述了,如何将一个基于 Vue.js 的前端应用和基于 Express 的后端服务,快速部署到腾讯云上。虽然受到不少开发者的喜爱,但是很多开发者私信问我,这还是一个 Demo 性质的项目而已,有没有更加实用性的解决方案。而且他们实际开发中,很多使用的正是 Egg.js 框架,能不能提供一个 Egg.js 的解决方案?

    本文将手把手教你结合 Egg.jsServerless 实现一个后台管理系统。

    读完此文你将学到:

    1. Egg.js 基本使用
    2. 如何使用 Sequelize ORM 模块进行 Mysql 操作
    3. 如何使用 Redis
    4. 如何使用 JWT 进行用户登录验证
    5. Serverless Framework 的基本使用
    6. 如何将本地开发好的 Egg.js 应用部署到腾讯云云函数上
    7. 如何基于云端对象存储快速部署静态网站

    Egg.js 入门

    初始化 Egg.js 项目:

    $ mkdir egg-example && cd egg-example
    $ npm init egg --type=simple
    $ npm i
    

    启动项目:

    $ npm run dev
    

    然后浏览器访问 http://localhost:7001,就可以看到亲切的 hi, egg 了。

    关于 Egg.js 的框架更多知识,建议阅读 官方文档

    准备

    对 Egg.js 有了简单了解,接下来我们来初始化我们的后台管理系统,新建一个项目目录 admin-system:

    $ mkdir admin-system
    

    将上面创建的 Egg.js 项目复制到 admin-system 目录下,重命名为 backend。然后将前端模板项目复制到 frontend 文件夹中:

    $ git clone https://github.com/PanJiaChen/vue-admin-template.git frontend
    

    说明: vue-admin-template 是基于 Vue2.0 的管理系统模板,是一个非常优秀的项目,建议对 Vue.js 感兴趣的开发者可以去学习下,当然如果你对 Vue.js 还不是太了解,这里有个基础入门学习教程 Vuejs 从入门到精通系列文章

    之后你的项目目录结构如下:

    .
    ├── README.md
    ├── backend     // 创建的 Egg.js 项目
    └── frontend    // 克隆的 Vue.js 前端项目模板
    

    启动前端项目熟悉下界面:

    $ cd frontend
    $ npm install
    $ npm run dev
    

    然后访问 http://localhost:9528 就可以看到登录界面了。

    开发后端服务

    对于一个后台管理系统服务,我们这里只实现登录鉴权和文章管理功能,剩下的其他功能大同小异,读者可以之后自由补充扩展。

    1. 添加 Sequelize 插件

    在正式开发之前,我们需要引入数据库插件,这里本人偏向于使用 Sequelize ORM 工具进行数据库操作,正好 Egg.js 提供了 egg-sequelize 插件,于是直接拿来用,需要先安装:

    $ cd frontend
    # 因为需要通过 sequelize 链接 mysql 所以这也同时安装 mysql2 模块
    $ npm install egg-sequelize mysql2 --save
    

    然后在 backend/config/plugin.js 中引入该插件:

    module.exports = {
      // ....
      sequelize: {
        enable: true,
        package: "egg-sequelize"
      }
      // ....
    };
    

    backend/config/config.default.js 中配置数据库连接参数:

    // ...
    const userConfig = {
      // ...
      sequelize: {
        dialect: "mysql",
    
        // 这里也可以通过 .env 文件注入环境变量,然后通过 process.env 获取
        host: "xxx",
        port: "xxx",
        database: "xxx",
        username: "xxx",
        password: "xxx"
      }
      // ...
    };
    // ...
    

    2. 添加 JWT 插件

    系统将使用 JWT token 方式进行登录鉴权,安装配置参考官方文档,egg-jwt

    3. 添加 Redis 插件

    系统将使用 redis 来存储和管理用户 token,安装配置参考官方文档,egg-redis

    4. 角色 API

    定义用户模型,创建 backend/app/model/role.js 文件如下:

    module.exports = app => {
      const { STRING, INTEGER, DATE } = app.Sequelize;
    
      const Role = app.model.define("role", {
        id: { type: INTEGER, primaryKey: true, autoIncrement: true },
        name: STRING(30),
        created_at: DATE,
        updated_at: DATE
      });
    
      // 这里定义与 users 表的关系,一个角色可以含有多个用户,外键相关
      Role.associate = () => {
        app.model.Role.hasMany(app.model.User, { as: "users" });
      };
    
      return Role;
    };
    

    实现 Role 相关服务,创建 backend/app/service/role.js 文件如下:

    const { Service } = require("egg");
    
    class RoleService extends Service {
      // 获取角色列表
      async list(options) {
        const {
          ctx: { model }
        } = this;
        return model.Role.findAndCountAll({
          ...options,
          order: [
            ["created_at", "desc"],
            ["id", "desc"]
          ]
        });
      }
    
      // 通过 id 获取角色
      async find(id) {
        const {
          ctx: { model }
        } = this;
        const role = await model.Role.findByPk(id);
        if (!role) {
          this.ctx.throw(404, "role not found");
        }
        return role;
      }
    
      // 创建角色
      async create(role) {
        const {
          ctx: { model }
        } = this;
        return model.Role.create(role);
      }
    
      // 更新角色
      async update({ id, updates }) {
        const role = await this.ctx.model.Role.findByPk(id);
        if (!role) {
          this.ctx.throw(404, "role not found");
        }
        return role.update(updates);
      }
    
      // 删除角色
      async destroy(id) {
        const role = await this.ctx.model.Role.findByPk(id);
        if (!role) {
          this.ctx.throw(404, "role not found");
        }
        return role.destroy();
      }
    }
    
    module.exports = RoleService;
    

    一个完整的 RESTful API 就该包括以上五个方法,然后实现 RoleController, 创建 backend/app/controller/role.js:

    const { Controller } = require("egg");
    
    class RoleController extends Controller {
      async index() {
        const { ctx } = this;
        const { query, service, helper } = ctx;
        const options = {
          limit: helper.parseInt(query.limit),
          offset: helper.parseInt(query.offset)
        };
        const data = await service.role.list(options);
        ctx.body = {
          code: 0,
          data: {
            count: data.count,
            items: data.rows
          }
        };
      }
    
      async show() {
        const { ctx } = this;
        const { params, service, helper } = ctx;
        const id = helper.parseInt(params.id);
        ctx.body = await service.role.find(id);
      }
    
      async create() {
        const { ctx } = this;
        const { service } = ctx;
        const body = ctx.request.body;
        const role = await service.role.create(body);
        ctx.status = 201;
        ctx.body = role;
      }
    
      async update() {
        const { ctx } = this;
        const { params, service, helper } = ctx;
        const body = ctx.request.body;
        const id = helper.parseInt(params.id);
        ctx.body = await service.role.update({
          id,
          updates: body
        });
      }
    
      async destroy() {
        const { ctx } = this;
        const { params, service, helper } = ctx;
        const id = helper.parseInt(params.id);
        await service.role.destroy(id);
        ctx.status = 200;
      }
    }
    
    module.exports = RoleController;
    

    之后在 backend/app/route.js 路由配置文件中定义 role 的 RESTful API:

    router.resources("roles", "/roles", controller.role);
    

    通过 router.resources 方法,我们将 roles 这个资源的增删改查接口映射到了 app/controller/roles.js 文件。详细说明参考 官方文档

    5. 用户 API

    同 Role 一样定义我们的用户 API,这里就不复制粘贴了,可以参考项目实例源码 admin-system

    6. 同步数据库表格

    上面只是定义好了 RoleUser 两个 Schema,那么如何同步到数据库呢?这里先借助 Egg.js 启动的 hooks 来实现,Egg.js 框架提供了统一的入口文件( app.js )进行启动过程自定义,这个文件返回一个 Boot 类,我们可以通过定义 Boot 类中的生命周期方法来执行启动应用过程中的初始化工作。

    我们在 backend 目录中创建 app.js 文件,如下:

    "use strict";
    
    class AppBootHook {
      constructor(app) {
        this.app = app;
      }
    
      async willReady() {
        // 这里只能在开发模式下同步数据库表格
        const isDev = process.env.NODE_ENV === "development";
        if (isDev) {
          try {
            console.log("Start syncing database models...");
            await this.app.model.sync({ logging: console.log, force: isDev });
            console.log("Start init database data...");
            await this.app.model.query(
              "INSERT INTO roles (id, name, created_at, updated_at) VALUES (1, 'admin', '2020-02-04 09:54:25', '2020-02-04 09:54:25'),(2, 'editor', '2020-02-04 09:54:30', '2020-02-04 09:54:30');"
            );
            await this.app.model.query(
              "INSERT INTO users (id, name, password, age, avatar, introduction, created_at, updated_at, role_id) VALUES (1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', 20, 'https://yugasun.com/static/avatar.jpg', 'Fullstack Engineer', '2020-02-04 09:55:23', '2020-02-04 09:55:23', 1);"
            );
            await this.app.model.query(
              "INSERT INTO posts (id, title, content, created_at, updated_at, user_id) VALUES (2, 'Awesome Egg.js', 'Egg.js is a awesome framework', '2020-02-04 09:57:24', '2020-02-04 09:57:24', 1),(3, 'Awesome Serverless', 'Build web, mobile and IoT applications using Tencent Cloud and API Gateway, Tencent Cloud Functions, and more.', '2020-02-04 10:00:23', '2020-02-04 10:00:23', 1);"
            );
            console.log("Successfully init database data.");
            console.log("Successfully sync database models.");
          } catch (e) {
            console.log(e);
            throw new Error("Database migration failed.");
          }
        }
      }
    }
    
    module.exports = AppBootHook;
    

    通过 willReady 生命周期函数,我们可以执行 this.app.model.sync() 函数来同步数据表,当然这里同时初始化了角色和用户数据记录,用来做为演示用。

    注意:这的数据库同步只是本地调试用,如果想要腾讯云的 Mysql 数据库,建议开启远程连接,通过 sequelize db:migrate 实现,而不是每次启动 Egg 应用时同步,示例代码已经完成此功能,参考 Egg Sequelize 文档。 这里本人为了省事,直接开启腾讯云 Mysql 公网连接,然后修改 config.default.js 中的 sequelize 配置,运行 npm run dev 进行开发模式同步。

    到这里,我们的用户和角色的 API 都已经定义好了,启动服务 npm run dev,访问 https://127.0.0.1:7001/users 可以获取所有用户列表了。

    7. 用户登录 /注销 API

    这里登录逻辑比较简单,客户端发送 用户名密码/login 路由,后端通过 login 函数接受,然后从数据库中查询该用户名,同时比对密码是否正确。如果正确则调用 app.jwt.sign() 函数生成 token,并将 token 存入到 redis 中,同时返回该 token,之后客户端需要鉴权的请求都会携带 token,进行鉴权验证。思路很简单,我们就开始实现了。

    流程图如下:

    <center> Login Process </center>

    首先,在 backend/app/controller/home.js 中新增登录处理 login 方法:

    class HomeController extends Controller {
      // ...
      async login() {
        const { ctx, app, config } = this;
        const { service, helper } = ctx;
        const { username, password } = ctx.request.body;
        const user = await service.user.findByName(username);
        if (!user) {
          ctx.status = 403;
          ctx.body = {
            code: 403,
            message: "Username or password wrong"
          };
        } else {
          if (user.password === helper.encryptPwd(password)) {
            ctx.status = 200;
            const token = app.jwt.sign(
              {
                id: user.id,
                name: user.name,
                role: user.role.name,
                avatar: user.avatar
              },
              config.jwt.secret,
              {
                expiresIn: "1h"
              }
            );
            try {
              await app.redis.set(`token_${user.id}`, token);
              ctx.body = {
                code: 0,
                message: "Get token success",
                token
              };
            } catch (e) {
              console.error(e);
              ctx.body = {
                code: 500,
                message: "Server busy, please try again"
              };
            }
          } else {
            ctx.status = 403;
            ctx.body = {
              code: 403,
              message: "Username or password wrong"
            };
          }
        }
      }
    }
    

    注释:这里有个密码存储逻辑,用户在注册时,密码都是通过 helper 函数 encryptPwd() 进行加密的(这里用到最简单的 md5 加密方式,实际开发中建议使用更加高级加密方式),所以在校验密码正确性时,也需要先加密一次。至于如何在 Egg.js 框架中新增 helper 函数,只需要在 backend/app/extend 文件夹中新增 helper.js 文件,然后 modole.exports 一个包含该函数的对象就行,参考 Egg 框架扩展文档

    然后,在 backend/app/controller/home.js 中新增 userInfo 方法,获取用户信息:

    async userInfo() {
      const { ctx } = this;
      const { user } = ctx.state;
      ctx.status = 200;
      ctx.body = {
        code: 0,
        data: user,
      };
    }
    

    egg-jwt 插件,在鉴权通过的路由对应 controller 函数中,会将 app.jwt.sign(user, secrete) 加密的用户信息,添加到 ctx.state.user 中,所以 userInfo 函数只需要将它返回就行。

    之后,在 backend/app/controller/home.js 中新增 logout 方法:

    async logout() {
      const { ctx } = this;
      ctx.status = 200;
      ctx.body = {
        code: 0,
        message: 'Logout success',
      };
    }
    

    userInfologout 函数非常简单,重点是路由中间件如何处理。

    接下来,我们来定义登录相关路由,修改 backend/app/router.js 文件,新增 /login, /user-info, /logout 三个路由:

    const koajwt = require("koa-jwt2");
    
    module.exports = app => {
      const { router, controller, jwt } = app;
      router.get("/", controller.home.index);
    
      router.post("/login", controller.home.login);
      router.get("/user-info", jwt, controller.home.userInfo);
      const isRevokedAsync = function(req, payload) {
        return new Promise(resolve => {
          try {
            const userId = payload.id;
            const tokenKey = `token_${userId}`;
            const token = app.redis.get(tokenKey);
            if (token) {
              app.redis.del(tokenKey);
            }
            resolve(false);
          } catch (e) {
            resolve(true);
          }
        });
      };
      router.post(
        "/logout",
        koajwt({
          secret: app.config.jwt.secret,
          credentialsRequired: false,
          isRevoked: isRevokedAsync
        }),
        controller.home.logout
      );
    
      router.resources("roles", "/roles", controller.role);
      router.resources("users", "/users", controller.user);
      router.resources("posts", "/posts", controller.post);
    };
    

    Egg.js 框架定义路由时,router.post() 函数可以接受中间件函数,用来处理一些路由相关的特殊逻辑。

    比如 /user-info,路由添加了 app.jwt 作为 JWT 鉴权中间件函数,至于为什么这么用,egg-jwt 插件有明确说明。

    这里稍微复杂的是 /logout 路由,因为我们在注销登录时,需要将用户的 tokenredis 中移除,所以这里借助了 koa-jwt2isRevokded 参数,来进行 token 删除。


    限于篇幅,后端服务部署和前端开发将在下集中介绍,感兴趣的也可以自行了解 Serverless 相关内容:

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5203 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 31ms · UTC 09:34 · PVG 17:34 · LAX 01:34 · JFK 04:34
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.