node + koa + ts 构建服务端应用

11913

适合新手或前端学习 node.js 做后台的起步教程

内容:

  1. 项目构建和配置
  2. 前后端请求header基本配置说明
  3. 接口编写和传参处理
  4. 上传图片
  5. 链接数据库和接口操作
  6. 登录注册用户模块
  7. jwt-token认证的使用(这里我用的是自己写的一个模块)
  8. 增删改查功能
  9. 项目构建推送到线上

这里我使用 typescript 去编写的理由是因为非常好用的类型提示和代码追踪,所以在纯 javascript 编程的项目中,typescript 是最好维护和阅读的,这里使用vscode这个代码编辑器

代码地址:node-koa

先来看下目录结构

public

template 存放静态页面 api-xxx.html 这样的为前端接口调试页面

upload 存放上传文件的临时目录

src

routes 接口路由目录

middleware 接口中间件目录

modules 一些功能目录,当前目录功能均是单例调用

utils 工具目录

types typescript类型目录

index.ts 入口文件

项目构建和配置

1. cd project 并创建 src 目录

mkdir src

2. 初始化 package.json,之后的所有配置和命令都会写在里面

npm init

3. 安装 koa 和对应的路由 koa-router

npm install koa koa-router 

4. 安装 TypeScript 对应的类型检测提示

npm install --save-dev @types/koa @types/koa-router 

5. 然后就是 TypeScript 热更新编译

npm install --save-dev typescript ts-node nodemon

这里会有个坑(这里使用的是window环境下),如果安装失败或者安装之后执行不了,就是 ts-nodenodemon 这两个需要全局安装才能执行热更新的命令:像这样

npm install -g -force ts-node nodemon

6. 再配置一下 package.json 设置

"scripts": {
    "build": "tsc && node dist/index.js",
    "dev": "nodemon --watch src/**/* -e ts,tsx --exec ts-node ./src/index.ts"
},

如果执行不了 npm run dev 那就手动复制执行 nodemon --watch src/**/* -e ts,tsx --exec ts-node ./src/index.ts

不确定是否 window 环境下的问题还是 npm 的问题,项目首次创建并执行的时候,所有依赖都可以本地安装并且 npm run dev 也可以完美执行但是再次打开项目的时候就出错了,目前还没找到原因,不过以上方法可以解决

后面最新版本弃用nodemon,改用ts-node-dev了,理由是后者热重载更快,具体看代码仓库。

7. 选装的中间件 koa-body 中间件作为解析POST传参和上传图片用

npm install koa-body

8. 安装数据库模块和对应类型依赖

npm install mysql
npm install --save-dev @types/mysql

9. 最后配置代码参数

src/modules/Config.ts 项目设置

import * as os from "os";

function getIPAdress() {
  const interfaces = os.networkInterfaces();
  for (const key in interfaces) {
    const iface = interfaces[key];
    for (let i = 0; i < iface.length; i++) {
      const alias = iface[i];
      if (alias.family === "IPv4" && alias.address !== "127.0.0.1" && !alias.internal) {
        return alias.address;
      }
    }
  }
}

class ModuleConfig {
  constructor() {
    this._ip = getIPAdress();
    const devDomain = `http://${this._ip}`;
    this.origins.push(devDomain);
  }

  private _ip = "";

  /** 当前服务`ip`地址 */
  get ip() {
    return this._ip;
  }

  /** 服务器公网`ip` */
  readonly publicIp = "123.123.123";

  /** 服务器内网`ip` */
  readonly privateIp = "17.17.17.17";

  /** 是否开发模式 */
  get isDev() {
    return this.ip != this.privateIp;
  }

  /** 端口号 */
  get port() {
    return this.isDev ? 1995 : 80;
  }

  /** 数据库配置 */
  readonly db = {
    host: "localhost",
    user: "root",
    password: "root",
    /** 数据库名 */
    database: "node_ts",
    /** 链接上限次数 */
    maxLimit: 10
  }

  /** 允许访问的域名源 */
  readonly origins = [
    `http://${this.publicIp}`,
    "http://huangjingsheng.gitee.io"
  ]

  /** 接口前缀 */
  readonly apiPrefix = ""; // "/api";

  /** 上传图片存放目录 */
  readonly uploadPath = "public/upload/images/";

  /** 上传图片大小限制 */
  readonly uploadImgSize = 5 * 1024 * 1024;

  /**
   * 前端上传图片时约定的字段
   * @example 
   * const formData = new FormData()
   * formData.append("img", file)
   * XHR.send(formData)
   */
  readonly uploadImgName = "img";

}

/** 项目配置 */
const config = new ModuleConfig();

export default config;

前后端请求 header 基本配置说明

这里我直接复制 src/index.ts 代码作为讲解示例,不截取部分了

jwt-token模块我放在了后面再说明,先写一些不需要带token的接口处理

import * as Koa from "koa";                     // learn: https://www.npmjs.com/package/koa
import * as koaBody from "koa-body";            // learn: http://www.ptbird.cn/koa-body.html
import * as staticFiles from "koa-static";      // 静态文件处理模块
import * as path from "path";
import config from "./modules/Config";
import router from "./routes/main";
import utils from "./utils";
import "./routes/test";                         // 基础测试模块
import "./routes/user";                         // 用户模块
import "./routes/upload";                       // 上传文件模块
import "./routes/todo";                         // 用户列表模块
import { TheContext } from "./types/base";

const App = new Koa();

// 指定 public目录为静态资源目录,用来存放 js css images 等
// 注意:这里`template`目录下如果有`index.html`的话,会默认使用`index.html`代`router.get("/")`监听的
App.use(staticFiles(path.resolve(__dirname, "../public/template")))
// 上传文件读取图片的目录也需要设置为静态目录
App.use(staticFiles(path.resolve(__dirname, "../public/upload")))

// 先统一设置请求配置 => 跨域,请求头信息...
App.use(async (ctx: TheContext, next) => {
  /** 请求路径 */
  // const path = ctx.request.path;

  console.log("--------------------------");
  console.count("request count");

  const { origin, referer } = ctx.headers;

  const domain = utils.getDomain(referer || "");
  // console.log("referer domain >>", domain);
  // 如果是 允许访问的域名源 ,则给它设置跨域访问和正常的请求头配置
  if (domain && config.origins.includes(domain)) {
    ctx.set({
      "Access-Control-Allow-Origin": domain,
      // "Access-Control-Allow-Origin": "*", // 开启跨域,一般用于调试环境,正式环境设置指定 ip 或者指定域名
      // "Content-Type": "application/json",
      // "Access-Control-Allow-Credentials": "true",
      // "Access-Control-Allow-Methods": "OPTIONS, GET, PUT, POST, DELETE",
      "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization",
      // "X-Powered-By": "3.2.1",
      // "Content-Security-Policy": `script-src "self"` // 只允许页面`script`引入自身域名的地址
    });
  }

  // 如果前端设置了 XHR.setRequestHeader("Content-Type", "application/json")
  // ctx.set 就必须携带 "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization" 
  // 如果前端设置了 XHR.setRequestHeader("Authorization", "xxxx") 那对应的字段就是 Authorization
  // 并且这里要转换一下状态码
  // console.log(ctx.request.method);
  if (ctx.request.method === "OPTIONS") {
    ctx.response.status = 200;
  }

  // const hasPath = router.stack.some(item => item.path == path);
  // // 判断是否 404
  // if (path != "/" && !hasPath) {
  //     return ctx.body = "<h1 style="text-align: center; line-height: 40px; font-size: 24px; color: tomato">404:访问的页面(路径)不存在</h1>";
  // }

  try {
    await next();
  } catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.body = {
      message: err.message
    }
  }
});

// 使用中间件处理 post 传参 和上传图片
App.use(koaBody({
  multipart: true,
  formidable: {
    maxFileSize: config.uploadImgSize
  }
}));

// 开始使用路由
App.use(router.routes())

// 默认无路由模式
// App.use((ctx, next) => {
//     ctx.body = html;
//     // console.log(ctx.response);
// });

App.on("error", (err, ctx) => {
  console.log(`\x1B[91m server error !!!!!!!!!!!!! \x1B[0m`, err, ctx);
})

App.listen(config.port, () => {
  // for (let i = 0; i < 100; i++) {
  //     console.log(`\x1B[${i}m 颜色 \x1B[0m`, i);
  // }
  console.log("服务器启动完成:");
  console.log(` - Local:   \x1B[36m http://localhost:\x1B[0m\x1B[96m${config.port} \x1B[0m`);
  console.log(` - Network: \x1B[36m http://${config.ip}:\x1B[0m\x1B[96m${config.port} \x1B[0m`);
})

// 参考项目配置连接: https://juejin.im/post/5ce25993f265da1baa1e464f
// mysql learn: https://www.jianshu.com/p/d54e055db5e0

完事之后运行项目 (代码热更新)

npm run dev

接口编写和传参处理

先来定义一个路由然后导出来使用,后面可能会有多个模块的接口,所以全部都是基于这个去使用,index.ts 也是

src/routes/main.ts 文件下

import * as Router from "koa-router";       // learn: https://www.npmjs.com/package/koa-router
import config from "../modules/Config";

/**
 * api路由模块
 */
const router = new Router({
  prefix: config.apiPrefix // 这里统一设置了接口前缀,默认是空
});

export default router;

src/routes/test.ts 文件下,来写个不用连接数据库的 GETPOST 请求作为测试用,并且接收参数,写好之后在前端请求,前端的代码我就不做说明了,看注释就懂。写完之后再到前端页面请求一下是否正确跑通即可。

request()请求函数是用原生封装的的方法,放到后面再讲;

import * as fs from "fs";
import * as path from "path";
import router from "./main";
import utils from "../utils";
import { apiSuccess, apiFail } from "../utils/apiResult";
import config from "../modules/Config";
import request from "../utils/request";
import { BaseObj } from "../types/base";

/** 资源路径 */
const resourcePath = path.resolve(__dirname, "../../public/template");

const template = fs.readFileSync(resourcePath + "/page.html", "utf-8");

// "/*" 监听全部
router.get("/", (ctx, next) => {
  // 指定返回类型
  // ctx.response.type = "html";
  // ctx.response.type = "text/html; charset=utf-8";

  // const data = {
  //   pageTitle: "serve-root",
  //   jsLabel: "",
  //   content: `<button class="button button_green"><a href="/home">go to home<a></button>`
  // }

  // ctx.body = utils.replaceText(template, data);
  // console.log("根目录");

  // 路由重定向
  ctx.redirect("/home");

  // 302 重定向到其他网站
  // ctx.status = 302;
  // ctx.redirect("https://www.baidu.com");
})

router.get("/home", (ctx, next) => {
  const userAgent = ctx.header["user-agent"];

  ctx.response.type = "text/html; charset=utf-8";

  const data = {
    pageTitle: "serve-root",
    jsLabel: `<script src="https://res.wx.qq.com/open/js/jweixin-1.0.0.js"></script>`,
    content: `
      <div style="font-size: 24px; margin-bottom: 8px; font-weight: bold;">当前环境信息:</div>
      <p style="font-size: 15px; margin-bottom: 10px; font-weight: 500;">${userAgent}</p>
      <button class="button button_purple"><a href="./api-index.html">open test</></button>
    `
  }

  ctx.body = utils.replaceText(template, data);
  // console.log("/home");
})

// get 请求
router.get("/getData", (ctx, next) => {
  /** 接收参数 */
  const params = ctx.query || ctx.querystring;

  console.log("/getData", params);

  ctx.body = apiSuccess({
    method: "get",
    port: config.port,
    date: utils.formatDate()
  });
})

// post 请求
router.post("/postData", (ctx, next) => {
  /** 接收参数 */
  const params: BaseObj = ctx.request.body || ctx.params;

  console.log("/postData", params);

  const result = {
    data: "请求成功"
  }

  ctx.body = apiSuccess(result, "post success")
})

上传图片

上传至服务端

src/routes/upload.ts 文件下,

import * as fs from "fs";
import * as path from "path";
import router from "./main";
import config from "../modules/Config";
import { UploadFile } from "../types/base";
import { apiSuccess } from "../utils/apiResult";

// 上传图片
// learn: https://www.cnblogs.com/nicederen/p/10758000.html
// learn: https://blog.csdn.net/qq_24134853/article/details/81745104
router.post("/uploadImg", async (ctx, next) => {

  const file: UploadFile = ctx.request.files[config.uploadImgName] as any;

  let fileName: string = ctx.request.body.name || `img_${Date.now()}`;

  fileName = `${fileName}.${file.name.split(".")[1]}`;

  // 创建可读流
  const render = fs.createReadStream(file.path);
  const filePath = path.join(config.uploadPath, fileName);
  const fileDir = path.join(config.uploadPath);

  if (!fs.existsSync(fileDir)) {
    fs.mkdirSync(fileDir);
  }

  // 创建写入流
  const upStream = fs.createWriteStream(filePath);

  render.pipe(upStream);

  // console.log(fileName, file);

  /** 模拟上传到`oss`云存储 */
  function uploadToCloud() {
    const result = {
      image: ""
    }
    return new Promise<{ image: string }>(function (resolve) {
      const delay = Math.floor(Math.random() * 5) * 100 + 500;
      setTimeout(() => {
        result.image = `http://${config.ip}:${config.port}/images/${fileName}`;
        resolve(result);
      }, delay);
    });
  }

  const res = await uploadToCloud();

  ctx.body = apiSuccess(res, "上传成功");
})

上传至阿里云

首先安装对应的SDK,这里看文档操作就可以了,代码十分简单

# 模块依赖
npm install ali-oss -S
# 类型包
npm install @types/ali-oss -D

代码片段

import * as path from "path";
import * as OSS from "ali-oss";

import router from "./main";
import config from "../modules/Config";
import { TheContext, UploadFile } from "../utils/interfaces";
import { apiSuccess } from "../utils/apiResult";

/**
 * - [阿里云 OSS-API 文档](https://help.aliyun.com/document_detail/32068.html?spm=a2c4g.11186623.6.1074.612626fdu6LBB7)
 * - [用户管理](https://ram.console.aliyun.com/users)
 */
const client = new OSS({
  // yourregion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
  region: "oss-cn-guangzhou",
  accessKeyId: "阿里云平台生成的key",
  accessKeySecret: "阿里云平后台生成的secret",
  bucket: "指定上传的 bucket 名",
});

// 上传图片
// learn: https://www.cnblogs.com/nicederen/p/10758000.html
// learn: https://blog.csdn.net/qq_24134853/article/details/81745104
router.post("/uploadImg", async ctx => {

  const file: UploadFile = ctx.request.files[config.uploadImgName] as any;
  // console.log("file >>", file);
    
  let fileName: string = `${Math.random().toString(36).slice(2)}-${Date.now()}-${file.name}`;

  const result = await client.put(`images/${fileName}`, path.normalize(file.path));
  // console.log("上传文件结果 >>", result);

  ctx.body = apiSuccess(result, "上传成功");
})

jwt-token 的使用(这里我用的是自己设计的程序方式实现)

思路:定义一个持久化的单例对象,然后项目运行时从数据库里读取所有用户,并以id作为,用户数据字段作为的形式写入到对象值中;通过中间件的方式在需要用到用户token验证的接口中使用;中间件在每次请求的过程中都会解析token的字段,通过id去读取该单例对象,最后验证当前用户是否存在或者过期并做出对应响应即可。

src/modules/TableUser先来看一下这个持久化单例对象的代码,下面的用户信息字段和数据库用户表里的一致,数据库建表之后再说;

import utils from "../utils";
import query from "../utils/mysql";
import { TableUserInfo } from "../types/user";
import { BaseObj } from "../types/base";

class ModuleUser {
  constructor() {
    this.update();
  }

  /** 缓存表格数据 */
  private _table: { [id: number]: TableUserInfo } = {};

  /** 用户计数 */
  private _total = 0;

  /** 用户表数据 */
  get table() {
    return this._table;
  }

  /** 用户总数 */
  get total() {
    return this._total;
  }

  /** 从数据库中更新缓存用户表 */
  async update() {
    const res = await query("select * from user_table")
    if (res.state === 1) {
      const list: Array<TableUserInfo> = res.results || [];
      this._total = list.length;
      this._table = {};
      if (this._total > 0) {
        for (let i = 0; i < list.length; i++) {
          const item = utils.objectToHump(list[i]) as TableUserInfo;
          item.createTime = utils.formatDate(item.createTime);
          this._table[item.id] = item;
        }
      }
      console.log("\x1B[42m 更新用户表缓存 \x1B[0m", this._total, "条数据");
    } else {
      console.log("用户表更新失败 >>", res.msg, res.error);
    }
  }

  /**
   * 新增用户
   * @param id 
   * @param value 用户信息
   */
  add(id: number, value: TableUserInfo) {
    this._table[id] = value;
    this._total++;
    console.log("\x1B[42m 新增用户 \x1B[0m", value);
  }

  /**
   * 通过`id`删除用户记录
   * @param id 
   */
  remove(id: number) {
    // delete _table[id];
    this._table[id] = undefined;
  }

  /**
   * 通过`id`更新指定用户信息
   * @param id 
   * @param value 用户信息
   */
  updateById(id: number, value: Partial<TableUserInfo>) {
    utils.modifyData(this._table[id], value);
  }

  /**
   * 【单个对象】匹配用户名,包括:创建用户名、编辑用户名
   * - 并返回新的驼峰数据对象
   * @param item 
   */
  matchName(item: BaseObj) {
    const createId = item["create_user_id"] as number;
    const updateId = item["update_user_id"] as number;
    item["create_user_name"] = this.table[createId].name;
    if (updateId) {
      item["update_user_name"] = this.table[updateId].name || "";
    }
    return utils.objectToHump(item);
  }

  /**
   * 【数组】匹配用户名,包括:创建用户名、编辑用户名
   * - 并返回驼峰数据对象
   * @param list 
   * @returns 
   */
  matchNameArray<T extends BaseObj>(list: Array<T>) {
    const result = [];
    for (let i = 0; i < list.length; i++) {
      const item = this.matchName(list[i]);
      result.push(item);
    }
    return result;
  }
}

/**
 * 用户表数据
 * - 缓存一份到内存里面,方便读取使用
 */
const tableUser = new ModuleUser();

export default tableUser;

数据库用户缓存表写好之后就到最后一步,设计token字段的响应和拦截、读取、解析操作;我这里把一些用户主要字段组装成一个对象,然后转成base64返回给前端,读取的时候再安装组装的规则解析即可,详细看下面代码。

src/modules/Jwt.ts 文件下

import tableUser from "./TableUser";
import { apiSuccess } from "../utils/apiResult";
import { TheContext, ApiResult } from "../types/base";
import { UserInfoToken, UserInfo } from "../types/user";

class ModuleJWT {
  constructor() {
    // tableUser.update();
  }

  /** 效期(小时) */
  private maxAge = 24 * 7;

  /** 前缀长度 */
  private prefixSize = 8;

  /**
   * 通过用户信息创建`token`
   * @param info 用户信息 
   */
  createToken(info: Omit<UserInfo, "name">) {
    const decode = JSON.stringify({
      i: info.id,
      a: info.account,
      p: info.password,
      t: info.type,
      g: info.groupId,
      o: Date.now()
    } as UserInfoToken);
    // const decode = encodeURI(JSON.stringify(info))
    const base64 = Buffer.from(decode, "utf-8").toString("base64");
    const secret = Math.random().toString(36).slice(2).slice(0, this.prefixSize);
    return secret + base64;
  }

  /**
   * 检测需要`token`的接口状态
   * @param ctx `http`上下文
   */
  checkToken(ctx: TheContext) {
    const token: string = ctx.header.authorization;
    /** 是否失败的`token` */
    let fail = false;
    /** 检测结果信息 */
    let info: ApiResult<{}>;
    /**
     * 设置失败信息
     * @param msg 
     */
    function setFail(msg: string) {
      fail = true;
      ctx.response.status = 401;
      info = apiSuccess({}, msg, -1);
    }

    if (!token) {
      setFail("缺少 token");
    }

    if (token && token.length < this.prefixSize * 2) {
      setFail("token 错误");
    }

    if (!fail) {
      /** 准备解析的字符串 */
      const str = Buffer.from(token.slice(this.prefixSize), "base64").toString("utf-8");

      /** 解析出来的结果 */
      let result: UserInfoToken;

      try {
        result = JSON.parse(str);
      } catch (error) {
        console.log("错误的 token 解析", error);
        setFail("token 错误");
      }

      if (!fail) {
        if (result.o && Date.now() - result.o < this.maxAge * 3600000) {
          const info = tableUser.table[result.i];
          // console.log("userInfo >>", info);
          // console.log("token 解析 >>", result);
          if (info) {
            // 设置`token`信息到上下文中给接口模块里面调用
            // 1. 严格判断账号、密码、用户权限等是否相同
            // 2. 后台管理修改个人信息之后需要重新返回`token`
            if (info.password == result.p && info.groupId == result.g && info.type == result.t) {
              ctx["theToken"] = info;
            } else {
              setFail("token 已失效");
            }
          } else {
            setFail("token 不存在");
          }
        } else {
          setFail("token 过期");
        }
      }
    }

    return {
      fail,
      info
    }
  }

}

/** `jwt-token`模块 */
const jwt = new ModuleJWT();

export default jwt;

最后再来看下src/middleware/index.ts中间件函数,这样用户权限基本完成,等建好数据库表再来运行验证一下即可。

import { Next } from "koa";
import jwt from "../modules/Jwt";
import { TheContext } from "../types/base";

/**
 * 中间件-处理`token`验证
 * @param ctx 
 * @param next 
 * @description 需要`token`验证的接口时使用
 */
export async function handleToken(ctx: TheContext, next: Next) {

  const checkInfo = jwt.checkToken(ctx);

  if (checkInfo.fail) {
    ctx.body = checkInfo.info;
  } else {
    await next();
  }
}

连接数据库和接口操作

这里我用到的本地服务是用(upupw)搭建,超简单的操作,数据库表工具是navicat

upupw下载地址

navicat下载地址 网上破解的也很多,自行下载即可

  1. 这里用upupw命令窗口获取数据的账号密码、或者在下图所示目录文件获取账号密码后,在navicat工具中新建一个数据库的连接,照着账号密码填就行,注意不要改默认的端口,不然会连接失败。

Screenshot 2022-10-06 210435.png

  1. 连接好后在左侧栏新建一个数据库,叫node_ts,代码中config.db.database就是这个,待会建表也是在这个栏目下面建表。

  1. 开始建一个叫user_form的表

不想建表的可以直接将项目mysql/node_ts.sql文件导入node_ts数据库即可

回到项目中src/utils/mysql.ts 文件下,这里封装了一个数据库增删改查的方法,之后所有的数据库操作都是通过这个方法去完成。

import * as mysql from "mysql";         // learn: https://www.npmjs.com/package/mysql
import config from "../modules/Config";

/** `mysql`查询结果 */
interface MsqlResult {
  /** `state === 1`时为成功 */
  state: number
  /** 结果数组 或 对象 */
  results: any
  /** 状态 */
  fields: Array<mysql.FieldInfo>
  /** 错误信息 */
  error: mysql.MysqlError
  /** 描述信息 */
  msg: string
}

/** 数据库链接池 */
const pool = mysql.createPool({
  host: config.db.host,
  user: config.db.user,
  password: config.db.password,
  database: config.db.database
});

/**
 * 数据库增删改查
 * @param command 增删改查语句 [mysql语句参考](https://blog.csdn.net/gymaisyl/article/details/84777139)
 * @param value 对应的值
 */
export default function query(command: string, value?: Array<any>) {
  const result: MsqlResult = {
    state: 0,
    results: null,
    fields: null,
    error: null,
    msg: ""
  }
  return new Promise<MsqlResult>((resolve, reject) => {
    pool.getConnection((error: any, connection) => {
      if (error) {
        result.error = error;
        result.msg = "数据库连接出错";
        resolve(result);
      } else {
        const callback: mysql.queryCallback = (error: any, results, fields) => {
          // pool.end();
          connection.release();
          if (error) {
            result.error = error;
            result.msg = "数据库增删改查出错";
            resolve(result);
          } else {
            result.state = 1;
            result.msg = "ok";
            result.results = results;
            result.fields = fields;
            resolve(result);
          }
        }

        if (value) {
          pool.query(command, value, callback);
        } else {
          pool.query(command, callback);
        }
      }
    });
  });
}
	

用户模块(包含登录注册)

下面utils.mysqlFormatParams()等一些工具函数均是自定义用来组装mysql语句用的,mysql查询语句对应user_form表格式的字段;

src/routes/user.ts 文件下

注意:用户接口的 接口要同步去修改tableUser中的缓存数据,这样才能保持token验证的数据实时同步,同时其他接口模块也可以通过tableUser模块去匹配对应的用户信息字段,不需要去连表查询也可以;例如:获取用户列表的时候,通过create_user_idupdate_user_id就可以匹配拿到创建人名称和修改人名称了,之后操作再举一反三,当前不做太细代码讲解。

import router from "./main";
import query from "../utils/mysql";
import jwt from "../modules/Jwt";
import { handleToken } from "../middleware";
import { apiSuccess, apiFail } from "../utils/apiResult";
import utils from "../utils";
import tableUser from "../modules/TableUser";
import { TheContext, ApiResult } from "../types/base";
import { UserInfo } from "../types/user";

// 注册
router.post("/register", async (ctx) => {
  /** 接收参数 */
  const params: UserInfo = ctx.request.body;
  /** 返回结果 */
  let bodyResult: ApiResult;
  /** 账号是否可用 */
  let validAccount = false;
  // console.log("注册传参", params);

  if (!/^[A-Za-z0-9]+$/.test(params.account)) {
    return ctx.body = apiSuccess({}, "注册失败!账号必须由英文或数字组成", 400);
  }

  if (!/^[A-Za-z0-9]+$/.test(params.password)) {
    return ctx.body = apiSuccess({}, "注册失败!密码必须由英文或数字组成", 400);
  }

  if (!params.name.trim()) {
    params.name = "用户未设置昵称";
  }

  // 先查询是否有重复账号
  const res = await query(`select account from user_table where account='${params.account}'`)

  // console.log("注册查询", res);

  if (res.state === 1) {
    if (res.results.length > 0) {
      bodyResult = apiSuccess({}, "该账号已被注册", 400);
    } else {
      validAccount = true;
    }
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  // 再写入表格
  if (validAccount) {
    const mysqlInfo = utils.mysqlFormatParams({
      "account": params.account,
      "password": params.password,
      "name": params.name,
      "create_time": utils.formatDate()
    })

    // const res = await query(`insert into user_table(${mysqlInfo.keys}) values(${mysqlInfo.values})`) 这样也可以,不过 mysqlInfo.values 每个值都必须用单引号括起来,下面的方式就不用
    const res = await query(`insert into user_table(${mysqlInfo.keys}) values(${mysqlInfo.symbols})`, mysqlInfo.values)

    if (res.state === 1) {
      bodyResult = apiSuccess(params, "注册成功");
    } else {
      ctx.response.status = 500;
      bodyResult = apiFail(res.msg, 500, res.error);
    }
  }

  ctx.body = bodyResult;
})

// 登录
router.post("/login", async (ctx) => {
  /** 接收参数 */
  const params: UserInfo = ctx.request.body;
  /** 返回结果 */
  let bodyResult: ApiResult;
  // console.log("登录", params);
  if (!params.account || params.account.trim() === "") {
    return ctx.body = apiSuccess({}, "登录失败!账号不能为空", 400);
  }

  if (!params.password || params.password.trim() === "") {
    return ctx.body = apiSuccess({}, "登录失败!密码不能为空", 400);
  }

  // 先查询是否有当前账号
  const res = await query(`select * from user_table where account='${params.account}'`)

  // console.log("登录查询", res);

  if (res.state === 1) {
    // 再判断账号是否可用
    if (res.results.length > 0) {
      const data: UserInfo = res.results[0];
      // 最后判断密码是否正确
      if (data.password == params.password) {
        data.token = jwt.createToken({
          id: data.id,
          account: data.account,
          password: data.password,
          type: data.type,
          groupId: data.groupId,
        })
        bodyResult = apiSuccess(data, "登录成功");
      } else {
        bodyResult = apiSuccess({}, "密码不正确", 400);
      }
    } else {
      bodyResult = apiSuccess({}, "该账号不存在,请先注册", 400);
    }
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

// 获取用户信息
router.get("/getUserInfo", handleToken, async (ctx: TheContext) => {

  const tokenInfo = ctx["theToken"];
  // /** 接收参数 */
  // const params = ctx.request.body;
  /** 返回结果 */
  let bodyResult: ApiResult;

  // console.log("getUserInfo >>", tokenInfo);

  const res = await query(`select * from user_table where account='${tokenInfo.account}'`)

  // console.log("获取用户信息 >>", res);

  if (res.state === 1) {
    // 判断账号是否可用
    if (res.results.length > 0) {
      const data: UserInfo = res.results[0];
      bodyResult = apiSuccess(utils.objectToHump(data));
    } else {
      bodyResult = apiSuccess({}, "该账号不存在,可能已经从数据库中删除", 400);
    }
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

// 编辑用户信息
router.post("/editUserInfo", handleToken, async (ctx: TheContext) => {
  const tokenInfo = ctx["theToken"];
  /** 接收参数 */
  const params: UserInfo = ctx.request.body;
  /** 返回结果 */
  let bodyResult: ApiResult;
  /** 账号是否可用 */
  let validAccount = false;
  // console.log("注册传参", params);

  if (!params.id) {
    ctx.response.status = 400;
    return ctx.body = apiSuccess({}, "编辑失败!用户id不正确", 400);
  }

  if (!params.account || !/^[A-Za-z0-9]+$/.test(params.account)) {
    ctx.response.status = 400;
    return ctx.body = apiSuccess({}, "编辑失败!账号必须由英文或数字组成", 400);
  }

  if (!params.password || !/^[A-Za-z0-9]+$/.test(params.password)) {
    ctx.response.status = 400;
    return ctx.body = apiSuccess({}, "编辑失败!密码必须由英文或数字组成", 400);
  }

  if (utils.checkType(params.groupId) !== "number") {
    ctx.response.status = 400;
    return ctx.body = apiSuccess({}, "编辑失败!分组不正确", 400);
  }

  if (!params.name.trim()) {
    params.name = "用户-" + utils.formatDate(Date.now(), "YMDhms");
  }

  if (tableUser.table[params.id]) {
    validAccount = true;
    for (const key in tableUser.table) {
      if (Object.prototype.hasOwnProperty.call(tableUser.table, key)) {
        const item = tableUser.table[key];
        if (item.account == params.account && item.id != params.id) {
          validAccount = false;
          bodyResult = apiSuccess({}, "当前账户已存在", -1);
          break;
        }
      }
    }
  } else {
    bodyResult = apiSuccess({}, "当前用户 id 不存在", -1);
  }

  // 再写入表格
  if (validAccount) {
    const setData = utils.mysqlSetParams({
      "account": params.account,
      "password": params.password,
      "name": params.name,
      "type": params.type,
      "group_id": params.groupId
    })

    // console.log("修改用户信息语句 >>", `update user_table ${setData} where id = '${params.id}'`);
    const res = await query(`update user_table ${setData} where id = '${params.id}'`)
    // console.log("再写入表格 >>", res);

    if (res.state === 1) {
      const data: { token?: string } = {};
      tableUser.updateById(params.id, {
        password: params.password,
        name: params.name,
        type: params.type,
        groupId: params.groupId,
      })
      // 判断是否修改自己信息
      if (params.id == tokenInfo.id) {
        data.token = jwt.createToken({
          id: params.id,
          account: params.account,
          password: params.password,
          type: params.type,
          groupId: params.groupId,
        })
      }
      bodyResult = apiSuccess(data, "编辑成功");
    } else {
      ctx.response.status = 500;
      bodyResult = apiFail(res.msg, 500, res.error);
    }
  }

  ctx.body = bodyResult;
})

// 删除用户
router.post("/deleteUser", handleToken, async (ctx: TheContext) => {
  const tokenInfo = ctx["theToken"];

  /** 接收参数 */
  const params = ctx.request.body;
  // console.log(params);

  if (tokenInfo && tokenInfo.type != 0) {
    return ctx.body = apiSuccess({}, "当前账号没有权限删除用户", -1);
  }

  if (!params.id) {
    ctx.response.status = 400;
    return ctx.body = apiSuccess({}, "编辑失败!用户id不正确", 400);
  }

  /** 返回结果 */
  let bodyResult: ApiResult;

  // 从数据库中删除
  const res = await query(`delete from user_table where id = '${params.id}'`)
  // console.log("获取用户列表 >>", res);

  if (res.state === 1) {
    if (res.results.affectedRows > 0) {
      bodyResult = apiSuccess({}, "删除成功");
      tableUser.remove(params.id);
      // 异步删除所有关联到的表单数据即可,不需要等待响应
      // query(`delete from street_shop_table where user_id='${params.id}'`)
    } else {
      bodyResult = apiSuccess({}, "当前列表id不存在或已删除", 400);
    }
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

// 退出登录
router.get("/logout", handleToken, ctx => {

  const token: string = ctx.header.authorization;

  if (token) {
    return ctx.body = apiSuccess({}, "退出登录成功");
  } else {
    return ctx.body = apiSuccess({}, "token 不存在", 400);
  }
})

增删改查功能

然后再建一个叫todo_form的表

最后新建一个 src/routes/todo.ts 作为用户列表的增删改查接口模块

import router from "./main";
import query from "../utils/mysql";
import utils from "../utils";
import { ApiResult, TheContext } from "../types/base";
import { apiSuccess, apiFail } from "../utils/apiResult";
import { handleToken } from "../middleware";

// 获取所有列表
router.get("/getList", handleToken, async (ctx: TheContext) => {

  const tokenInfo = ctx["theToken"];
  /** 返回结果 */
  let bodyResult: ApiResult;

  // console.log("getList >>", tokenInfo);

  // 这里要开始连表查询
  const res = await query(`select * from todo_table where user_id = '${tokenInfo.id}'`)

  if (res.state === 1) {
    // console.log("/getList 查询", res.results);
    bodyResult = apiSuccess({
      list: res.results.length > 0 ? utils.arrayItemToHump(res.results) : []
    });
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

// 添加列表
router.post("/addList", handleToken, async (ctx: TheContext) => {

  const tokenInfo = ctx["theToken"];
  /** 接收参数 */
  const params = ctx.request.body;
  /** 返回结果 */
  let bodyResult = null;

  if (!params.content) {
    return ctx.body = apiSuccess({}, "添加的列表内容不能为空!", 400);
  }

  const mysqlInfo = utils.mysqlFormatParams({
    "content": params.content,
    "user_id": tokenInfo.id,
    "update_time": utils.formatDate(),
    "create_time": utils.formatDate()
  })

  // 写入列表
  const res = await query(`insert into todo_table(${mysqlInfo.keys}) values(${mysqlInfo.symbols})`, mysqlInfo.values)

  // console.log("写入列表", res);

  if (res.state === 1) {
    bodyResult = apiSuccess({
      id: res.results.insertId
    }, "添加成功");
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

// 修改列表
router.post("/modifyList", handleToken, async (ctx) => {

  /** 接收参数 */
  const params = ctx.request.body;
  /** 返回结果 */
  let bodyResult = null;

  if (!params.id) {
    return ctx.body = apiSuccess({}, "列表id不能为空", 400);
  }

  if (!params.content) {
    return ctx.body = apiSuccess({}, "列表内容不能为空", 400);
  }

  const setData = utils.mysqlSetParams({
    "content": params.content,
    "update_time": utils.formatDate()
  })

  // 修改列表
  const res = await query(`update todo_table ${setData} where id = '${params.id}'`)

  // console.log("修改列表", res);

  if (res.state === 1) {
    if (res.results.affectedRows > 0) {
      bodyResult = apiSuccess({}, "修改成功");
    } else {
      bodyResult = apiSuccess({}, "列表id不存在", 400);
    }
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

// 删除列表
router.post("/deleteList", handleToken, async (ctx: TheContext) => {

  // const state = ctx["theToken"];
  /** 接收参数 */
  const params = ctx.request.body;
  /** 返回结果 */
  let bodyResult = null;

  // 从数据库中删除
  // const res = await query(`delete from todo_table where id='${params.id}' and user_id='${state.info.id}'`)
  const res = await query(`delete from todo_table where id = '${params.id}'`)
  // const res = await query(`delete from todo_table where id in(${params.ids.toString()})`) // 批量删除

  // console.log("从数据库中删除", res);

  if (res.state === 1) {
    if (res.results.affectedRows > 0) {
      bodyResult = apiSuccess({}, "删除成功");
    } else {
      bodyResult = apiSuccess({}, "当前列表id不存在或已删除", 400);
    }
  } else {
    ctx.response.status = 500;
    bodyResult = apiFail(res.msg, 500, res.error);
  }

  ctx.body = bodyResult;
})

后端请求第三方接口(进阶)

对应上面用到的request()函数

  • 这个是node.js核心功能之一,类似前端的ajax请求一样道理,本质上和Koa没有任何关系;因为网上能搜到的相关资料比较少,所以这里顺便也带上
  • 这个功能主要是某些时候我们需要获取第三方的一些数据,然后返回给前端的,类似微信小程序的后端接口就会用到,这里不举例了
  • 需要注意的是,服务端请求是没有httpshttp的,所以请求路径的话直接写host地址就行

src/utils目录下新建一个request.ts

import * as http from "http";
import * as querystring from "querystring"
import * as zlib from "zlib"
import { BaseObj, ServeRequestResult } from "../types/base";

/**
 * 服务端请求
 * - [基础请求参考](https://www.cnblogs.com/liAnran/p/9799296.html)
 * - [响应结果乱码参考](https://blog.csdn.net/fengxiaoxiao_1/article/details/72629577)
 * - [html乱码参考](https://www.microanswer.cn/blog/51)
 * - [node-http文档](http://nodejs.cn/api/http.html#http_class_http_incomingmessage)
 * @param options 请求配置
 * @param params 请求传参数据
 */
export default function request(options: http.RequestOptions, params: BaseObj<any> = {}) {
  /** 返回结果 */
  const info: ServeRequestResult = {
    msg: "",
    result: "",
    state: -1
  }

  /** 传参字段 */
  const data = querystring.stringify(params);

  if (data && options.method == "GET") {
    options.path += `?${data}`;
  }

  return new Promise<ServeRequestResult>((resolve, reject) => {
    const clientRequest = http.request(options, res => {
      // console.log("http.get >>", res);
      // console.log(`http.request.statusCode: ${res.statusCode}`);
      // console.log(`http.request.headers: ${JSON.stringify(res.headers)}`);

      // 因为现在自己解码,所以就不设置编码了。
      // res.setEncoding("utf-8");

      if (res.statusCode !== 200) {
        info.msg = "请求失败";
        info.result = {
          statusCode: res.statusCode,
          headers: res.headers
        }
        return resolve(info);
      }

      let output: http.IncomingMessage | zlib.Gunzip

      if (res.headers["content-encoding"] == "gzip") {
        const gzip = zlib.createGunzip();
        res.pipe(gzip);
        output = gzip;
      } else {
        output = res;
      }

      output.on("data", function (chunk) {
        // console.log("----------> chunk >>", chunk);
        // info.result += chunk;
        // info.result = chunk;
        // info.result += chunk.toString("utf-8");
        info.result += chunk.toString();
      });

      output.on("error", function (error) {
        console.log("----------> 服务端请求错误 >>", error);
        info.msg = error.message;
        info.result = error;
      })

      output.on("end", function () {
        // console.log("---------- end ----------");
        if (res.complete) {
          info.msg = "ok";
          info.state = 1;
          resolve(info);
        } else {
          info.msg = "连接中断"
          resolve(info);
        }
      });

    })

    if (data && options.method != "GET") {
      clientRequest.write(data)
    }

    clientRequest.end()
  })
}

使用示例 这里用的是天气预报接口作为演示,要请求一些独特的接口,类如扒数据这种的话自己设置headers模拟即可,还是在src/routes/test.ts中:

// 请求第三方接口并把数据返回到前端
router.get("/getWeather", async (ctx, next) => {
  // console.log("ctx.query >>", ctx.query);
  const city = ctx.query.city as string;

  if (!city) {
    ctx.body = apiSuccess({}, "缺少传参字段 city", 400);
    return;
  }

  const res = await request({
    method: "GET",
    hostname: "wthrcdn.etouch.cn",
    path: "/weather_mini?city=" + encodeURIComponent(city)
  })

  // console.log("获取天气信息 >>", res);

  if (res.state === 1) {
    if (utils.checkType(res.result) === "string") {
      res.result = JSON.parse(res.result);
    }
    ctx.body = apiSuccess(res.result)
  } else {
    ctx.body = apiFail(res.msg, 500, res.result)
  }

})

项目构建推送到线上

  1. 购买云服务器 ECS腾讯阿里随意;
  2. 安装对应的node.jsmyqlgit等工具,实质跟新电脑装工具一样,只不过服务器上是Linux系统,操作起来需要借助MobaXterm这个工具来远程连接,然后安装和进行其他操作,这里不展开说了,都是不用写代码的...
  3. 创建目录然后用git把代码拉下来,npm install再运行npm run build就可以了,但是发现退出之后就关闭服务了,所以还要再安装一个进程管理 pm2 来代替我们手动去npm run build;
  4. 安装完pm2之后还需要在当前项目根目录下写一个pm2.json的配置运行文件,类似package.json一样,代码片段如下:
{
  // 进程的名字,进程的名字要对应`package.json`中的`name`,
  // 不然会报错,虽然不影响我们程序运行,但是最好还是避免出现不必要的报错  
  "name": "node-koa-service",
  // 运行代码的命令,对应`package.json`里面的即可
  "script": "npm run build"
}

最后执行pm2 start pm2.json命令就启动项目了,要想查看和更多操作则参考下面命令

# 启动任务
pm2 start pm2.json

# 强制停止所有的服务
pm2 kill

# 查看服务
pm2 list

# 查看指定进程日志,0是任务列表索引值
pm2 log 0

# 重启指定进程,0是任务列表索引值
pm2 restart 0

# 停止指定进程,0是任务列表索引值
pm2 stop 0

最后说明

  • 项目的所有前端调试都是写好的,前端接口代码存放在public/template下,因为是后端代码的分享,所以这里不作前端代码演示。
  • 后面项目数据库字段可能有调整,当前文章代码片段只作为演示示例,所以还是以仓库代码为最新标准,并且项目目录中src/mysql/node_ts.sql保持和代码使用的字段相同,直接导入到数据库即可。