Koa+Mongodb 搭建商家店铺服务端项目总结

背景

自从完成了客户端管理后台项目后,一个完整的web应用前端方面的项目算是搭建完成了。最后还需要有一个提供API服务的后端项目服务前端应用运行,经过一个月的开发,现在基本上已经全部完成,经过部署后,完成了最后的上线运行。

项目使用技术栈

本项目是基于nodejs主要使用koa+mongodb为核心开发的轻量级服务端应用。接口是按照RESTful风格进行设计

使用主要中间件有
koa-compress,
koa-parameter,koa-connect-history-api-fallback,koa-static,koa-mount。具体使用方式请在koa官方仓库查看

数据库操作:mongoose

接口权限验证:jsonwebtoken

用户密码加密:bcryptjs

上传资源存储:koa-multer

路由分发:koa-router

接口参数解析: koa-bodyparser

接口使用文档: https://konglingwen94.github.io/elm-seller-server

开发过程

数据库设计

本项目选择使用mongodb作为数据存储的数据库,因为其对于前端开发者有着天然的有好性,使用容易上手。
由于本人涉猎服务端领域尚处于初级阶段,在数据库设计方面经验有限,在此分享出来仅供参考使用。

mongodb使用bson类型作为数据存储格式。由于跟前端js的json类型可以互相转换使用。所以这就减小了入门者设计数据库表字段的难度,我们可以参照前端页面需要展示的数据进行设计。
然后通过使用mongoose这个库作为快速操作数据库的模型,我们可以用代码的形式设计mongodb表字段的模型,经过mongoose的编译可以存储到真实的数据库表中。

拿本项目中的商品表来说,这是一个声明好的mongooseSchema

 {
    name: String,
    price: Number,
    oldPrice: Number,
    description: String,
    sellCount: Number,
    rating: Number,
    info: String,
    menuID: ObjectId,
    image: String,
    online: { type: Boolean, default: true },
  },

通过mongoose模型的编译方法后存储到数据库中的字段是这个样子

数据库存储字段

Field Type Description
menuID ObjectId 商品分类 ID
name String 商品标题
info String 商品信息
description String 商品简介
image String 商品封面
online Boolean 是否发布
oldPrice Number 商品原价
price Number 商品售价
sellCount Number 售卖个数

查看完整的模型文件点这里

接口搭建

一个完整的API接口从接收请求到响应数据完成,中间这个过程就是服务端处理各种代码逻辑的。这其中主要包括暴露接口地址,接口权限验证请求参数验证查询数据库返回响应信息这几个阶段。为了符合服务端业务逻辑分层设计的模式,每一个处理阶段都可以抽离到一个单独的模块,最后再把各种相关联的模块组装起来打包成一个完整的项目,这样的模块化设计可以很大的增强项目的维护性可读性。用目录结构的方式展现就是这个样子的

├── model  // 数据库模型
│   ├── administrator.js
│   ├── seller.js
│   ├── rating.js
│   ├── category.js
│   └── food.js
├── helper
│   ├── validatorRules.json  // 参数验证规则
│   ├── mongoose.js  // mongoose连接脚本
│   ├── middleware.js // 项目中间件
│   └── util.js  // 工具函数
├── controller  // 控制器
│   ├── administrator.js
│   ├── seller.js
│   ├── rating.js
│   ├── category.js
│   └── food.js
├── config
│   └── config.default.json  // 项目配置文件
├── router
│   └── index.js  // 路由配置

model文件夹用来放数据库表模型,数据库存储了哪些字段在这个文件目录查看一目了然。helper目录存放了一些辅助的项目文件和一些脚本,其中middleware.js这个文件存放了整个项目的所有中间件,按照模式分层的原则,我把服务端接口的一些处理逻辑都抽离到了中间件里,其中包括接口权限验证请求参数验证这两个主要的代码处理逻辑。controller目录则是存放接口业务逻辑的地方,我们也把他叫做控制器查询数据库返回响应信息也是在这个模块里面完成的。最后就是统一分发路由接口,router目录是项目所有接口分发的地方,在这里可以把不同的控制器分发到一个或多个路由接口地址上,这样可以实现控制器文件的复用,不需要写重复的业务代码。

权限验证和登录(包含注册功能)

面向多用户服务的后端项目,权限验证是不可或缺的。本项目使用了authorization请求头验证的方式判断每一个请求的权限。为了方便处理,我把这一块的代码逻辑抽离到了一个中间件里。这样对每一个接口是否验证权限也容易管理和阅读。本项目的权限验证使用jsonwebtoken这个第三方插件作为生成秘钥token的工具,用户在登录的时候服务端会生成一个token响应到前端,前端根据运行环境把它存储下来,之后的每一个请求根据业务需要携带这个token传递到服务端,服务端根据设置好的验证规则返回不同的验证结果,这就是本项目接口权限验证整体运行过程。

通过控制器中的用户登录接口分析其中的业务逻辑时怎么处理的

// 这里仅展示业务逻辑代码
  async login(ctx) {
    const { username, password } = ctx.request.body;

    let result = await AdministratorModel.findOne({ username });
    //如果没有结果则 创建新用户
    if (!result) {
      // 加密密码
      const hashPass = await bcrypt.hash(password, 10);

      const newUser = await AdministratorModel.create({ password: hashPass, username });

      const token = jwt.sign({ username, role: newUser.role, level: newUser.level }, secretKey, {
        expiresIn,
      });

      return (ctx.body = { admin: omit(newUser.toObject(), ["password"]), token });
    }

    if (!bcrypt.compareSync(password, result.password)) {
      ctx.status = 400;

      return (ctx.body = { message: "密码错误" });
    }
    const user = result.toObject();
    const token = jwt.sign(user, secretKey, { expiresIn });
     
    ctx.body = { admin: omit(user, ["password"]), token };
  },

为了支持管理后天首次登陆即注册的功能,本登录代码接口也包含了用户注册的业务逻辑。经过参数解析和校验的过程后(代码部分以中间件处理的方式在其他模块),通过解构即取到了前端传递的有效参数。根据数据库查询的结果处理不同的业务逻辑,在取到创建后的用户信息后需通过jsonwebtoken的签名生成一个token,此token也是其他接口在验证用户登录状态时唯一的验证信息。

通过登录接口生成token后我们就可以对其他需要添加访问权限的接口进行鉴权验证了。下面是通过验证token是否有效判断用户登录状态的逻辑代码中间件

// 统一抽离到一个中间件中,这里省略了引入其他模块的过程
module.exports={
 adminRequired() {
    return async (ctx, next) => {
      let token = ctx.headers["authorization"];

      if (!token) {
        ctx.status = 400;
        return (ctx.body = { message: "没有传递token" });
      }
      token = token.split(" ")[1];

      try {
        var decodeToken = jwt.verify(token, secretKey, { expiresIn });
      } catch (error) {
        ctx.status = 403;
        if (error.name === "TokenExpiredError") {
          return (ctx.body = { message: "过期的token" });
        }
        return (ctx.body = { message: "无效的token" });
      }

      ctx.state.adminInfo = decodeToken;
      await next();
    };
  },
 }

请求进入到这里后,通过认证请求头先提取到token变量,当token取到具体值后,再使用jsonwebtoken内部提供的验证函数校验,根据不同的验证结果响应不同的状态码和错误信息。具体的验证结果错误类型自行到插件仓库查看,这里不做详细介绍。当验证通过后会解析出token的签名内容,如果鉴权接口其他地方的业务逻辑需要用到此信息的话,我们可以把它挂载到koa提供的特定命名空间字段上,这样方便局部的逻辑代码获取。

备注:token使用的认证类型需要根据前后端开发人员的约定使用,本项目使用Bearer ${token}的格式作为令牌访问头

为了符合koa中间件导出格式的设计原则,这个文件的中间件是以闭包的形式导出的,实际应用到接口上的是这个闭包函数,这样设计的好处是我们在调用中间件函数的时可以传递参数进去,内部实际生效的中间件可以根据外部传递的参数做逻辑上的处理。在路由配置表里面统一使用这个中间件的方式是这个样子的

//部分代码省略
const Router = require("koa-router");

const router = new Router({ prefix: "/api" });
const middleware = require("../helper/middleware");

router.post("/admin/foods", middleware.adminRequired(),FoodController.createOne);

数据库分页查询功能

对于大多数前端项目,分页显示数据在一个非常常见的功能,对应到服务端的代码逻辑就是数据库的过滤查询。使用mongoose提供的过滤查询操作API可以很容易完成这个需求,当我们用到的地方比较多的时候,问题就出现了。对于前端请求的接口路径一般是这个样子的/api/foods?page=1&size=20,我们需要对传递的querystirng做进一步的判断和解析才能应用到数据库参数的查询上。问题是很多个接口都需要这个功能,使用起来比较繁琐,那不如我们把这个解析查询参数的过程抽离成一个模块,这样更方便我们使用和维护。现在让我们看一下封装好的全部代码吧!

module.exports = {
  resolvePagination(pagination = {}) {
    const defaults = { page: 1, size: 10 };

    pagination.page = parseInt(pagination.page, 10);
    pagination.size = parseInt(pagination.size, 10);

    if (Number.isNaN(pagination.page) || pagination.page <= 0) {
      pagination.page = defaults.page;
    }
    if (Number.isNaN(pagination.size) || pagination.size <= 0) {
      pagination.size = defaults.size;
    }

    const { page, size } = pagination;
    return {
      page,
      size,
    };
  },
  resolveFilterOptions(filter = {}) {
    let sort = {
      createdAt: -1,
    };
    sort = defaults({}, filter.sort, sort);

    const { page, pageSize } = resolvePagination({
      page: filter.page,
      size: filter.size,
    });
    return {
      limit: size,
      skip: (page - 1) * size,
      sort,
    };
  },
};

首先通过resolvePagination这个函数我们可以解析出有效的query参数,在通过resolveFilterOptions这个函数解析出来符合mongoose数据筛选操作的查询选项。通过模块化引入的操作方式,应用到实际的数据库查询过程中如下

// 代码片段来自项目`controller`目录
const {  resolveFilterOptions, resolvePagination } = require("../helper/utils");

module.exports={
 async queryListByOpts(ctx) {
    const { page, size } = resolvePagination({ page: ctx.query.page, size: ctx.query.size });

    const { skip, limit, sort } = resolveFilterOptions({ page, size });

    const total = await FoodModel.countDocuments();

    var results = await FoodModel.find().populate("category").sort(sort).skip(skip).limit(limit);

    ctx.body = {
      data: results,
      total,
      pagination: {
        page,
        size,
      },
    };
  },
}

从代码中可以看到以获取到前端传递的query类型参数为解析值,resolvePagination函数负责解析有效的数据分页查询选项,resolveFilterOptions函数解析出来了mongoose特定查询语句格式的参数,我们通过分离业务代码和逻辑代码的方式有效增强了代码的模块化结构,也增加了代码的复用性,提高了项目的开发效率。

应用的部署和运行

本项目使用github-actions的持续集成功能自动部署到云服务器,有了持续集成的服务,就省去了项目手动构建,测试,发布这一系列流程,而且降低了手动操作程序出错的风险,具体的配置文件如下

name: Deploy files
on: [push]
jobs:

  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
    - name: copy file via ssh key
      uses: appleboy/scp-action@master
      with:
        host: ${{ secrets.SERVER_HOST }}
        username: ${{ secrets.SERVER_USERNAME }}
        key: ${{ secrets.SERVER_SSH_KEY }}
        port: ${{ secrets.SERVER_PORT }}
        source: "*"
        target: "/var/www/elm-seller-server"

    - name: executing remote ssh commands using ssh key
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.SERVER_HOST }}
        username: ${{ secrets.SERVER_USERNAME }}
        key: ${{ secrets.SERVER_SSH_KEY }}
        port: "22"
        script: |
          cd /var/www/elm-seller-server
          npm install
          
          npm start
          

从配置文件中看出,服务器发布的环境变量都采用了加密的方式传递,比如${{secrets.SERVER_HOST}}这个环境变量,真实的存储值需要我们在github仓库的设置面板里的secret选项配置的,当本地使用git管理的仓库推送到远程仓库的时候就会触发github-actions的自动部署操作,同时我们还可以在workflows文件夹下面配置多个以.yml结尾的配置文件,一个配置文件对应一个actions部署任务,本项目我就使用了两个持续集成的任务,因为项目对应的说明文档也需要及时的更新发布。至于部署文件模板怎么选择需要根据个人的需求自己选择设置,github-actions官方市场提供了常用的集成任务模板供我们选择

发布到云服务器的应用我选择使用PM2管理应用,应用启动的配置文件点这里。pm2是一个面对node应用的管理工具,我们可以方便的查看,重启,删除,停止,启动应用

API文档编写

文档的撰写是一个后端项目不可或缺的一部分内容,学会写文档可以回顾项目从设计到开发的过程,发现有问题的地方可以第一时间发现,及时的修复bug。项目文档是使用markdown语法编写的REAEME文件,所有文件均在项目的docs目录内。文档使用vuepress作为构建工具预览和发布。具体使用方式自行查看官方文档,不做详细介绍

文档发布地址:https://konglingwen94.github.io/elm-seller-server

工具和环境

vscode mac node mongodb git github postman ssh

总结心得

从项目的需求规划,到数据库表设计,api接口逻辑关注点的分离,最后成功的部署运行以及文档的撰写完成,自己初步掌握了服务端项目完整的开发流程,并积累了一些开发经验可以在这分享。

作为编程开发人员,在项目开发过程中遇到困难是很正常的,尤其是在调试代码的时候各种各样的错误信息看的"眼花缭乱",尤其是服务端node的环境没有浏览器客户端调试方便。遇到代码出错不要怕,我们需要一步步排查出错的原因,如果错误信息看起来不直观我们可以借助第三方工具调试,本项目我使用的是nodemon这个工具,他可以热加载应用,也可以开启debug的命令打开一个类似浏览器开发者工具的调试面板,我们可以在控制台面板查看程序抛出的错误信息,在source面板查看出错的代码堆栈,借助这些工具的分析,只要有耐心,一点点思考出现错误的问题,最终我们一定可以解决它。

支持

感谢所有点赞和关注的小伙伴们,对本项目有兴趣的同学可以一块和我交流,欢迎在下面留言!

如果您对本项目由好的建议或者发现bug可以到项目仓库提issues,也欢迎您的收藏和关注,谢谢!

仓库地址:https://github.com/konglingwen94/elm-seller-server

文档地址:https://konglingwen94.github.io/elm-seller-server

w

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272

推荐阅读更多精彩内容