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

适合新手或前端学习 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 这样的为前端接口调试页面

user.json 存放 jwt-token 数据的临时表

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

src

api 各个接口模块,也是路由目录

modules 一些 class 类功能目录

utils 工具目录

项目构建和配置

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": {
    "start": "tsc && node dist/index.js",
    "serve": "nodemon --watch src/**/* -e ts,tsx --exec ts-node ./src/index.ts"
},
复制代码

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

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

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

npm install koa-body
复制代码

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

npm install mysql
复制代码
npm install --save-dev @types/mysql
复制代码

9. 最后配置代码参数

modules/config.ts 项目设置

class ModuleConfig {

    /** 端口号 */
    readonly port = 1995;

    /** 数据库配置 */
    readonly db = {
        host: "localhost",
        user: "root",
        password: "root",
        /** 数据库名 */
        database: "node_ts", // 待会创建数据库的时候就是这个名字
        /** 链接上限次数 */
        connection_limit: 10
    }

    /** 接口前缀 */
    readonly api_prefix = "/api/v1/";

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

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

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

    /** 用户临时表 */
    readonly user_file = "public/user.json";

    /** token 长度 */
    readonly token_size = 28;

    /** token 格式错误提示文字 */
    readonly token_tip = "无效的token";
}

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

export default config;
复制代码

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

index.ts 文件下,部分接口是依赖 token 的,所以这里我把 token 判断的代码也写在了这里面

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 config from "./modules/Config";
import router from "./api/main";
import "./api/apiUser";                         // 用户模块
import "./api/apiUpload";                       // 上传文件模块
import "./api/apiTest";                         // 基础测试模块
import "./api/apiTodo";                         // 用户列表模块
import { TheContext } from "./utils/interfaces";

const App = new Koa();

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

    console.log("--------------------------");
    console.count("request count");
    
    ctx.set({
        "Access-Control-Allow-Origin": "*", // 指定请求域,* 就是所有域名都可访问,即跨域打开
        // "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`引入自身域名的地址
    });

    // 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>";
    // }

    // 如果前端设置了 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;
    }
    
    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.error("server error !!!!!!!!!!!!!", err, ctx);
})

App.listen(config.port, () => {
    console.log(`server is running at http://localhost:${ config.port }`);
})

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

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

npm run watch-update
复制代码

接口编写和传参处理

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

api/main.ts 文件下

import * as Router from 'koa-router';       
/** api路由模块 */
const router = new Router();
export default router;
复制代码

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

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

/** 资源路径 */
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) => {
    ctx.response.type = "text/html; charset=utf-8";

    const data = {
        pageTitle: "serve-root",
        jsLabel: "",
        content: `<h1 style="text-align: center; line-height: 40px; font-size: 24px; color: #007fff">Welcome to home</h1>`
    }

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

// get 请求
router.get("/getData", (ctx, next) => {
    /** 接收参数 */
    const params: object | string = 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: object = ctx.request.body || ctx.params;

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

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

    ctx.body = apiSuccess(result, "post success")
})
复制代码

上传图片

上传至服务端

modules/api/apiUpload.ts 文件下,

import * as fs from "fs";
import * as path from "path";
import router from "./main";
import config from "../modules/Config";
import { UploadFile } from "../utils/interfaces";
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);
    
    const result = {
        image: `http://${ctx.headers.host}/${config.uploadPath}${fileName}`,
        file: `${config.uploadPath}${fileName}`
    }

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

上传至阿里云

首先安装对应的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 的使用(这里我用的是自己写的一个模块)

实现思路:利用js内存进行读写用户的token信息,只在对内存写的时候(异步写入防止阻塞)把信息以json格式写入到user.json文件中,然后实例化的时候读取上次纪录的token信息并把过期的剔除掉。举一反三,在写入或者读取的时候可以采用Redis的方式代替读写本地user.json表,原理是一样的。

实现过程:

  1. public/目录下新建一个user.json的文件作为一张临时的 token 纪录表

  2. 然后定义一个 ModuleJWT 的模块,userRecord 这个私有的属实是个以tokenkey的对象去存储用户信息,这个信息里面带有一个参数 online 意思是在线的时间,之后取值做判断的时候会用到

  3. ModuleJWT 的模块中,对外暴露的只有3个方法:

    • setRecord 首次设置纪录并返回 token 登录用,并且纪录在 userRecord 里面,再写入到临时表 user.json

    • updateRecord 这个是每次请求的时候都会先执行的方法,用来判断前端传过来的 token 是否在 userRecord 里面,如在存在再判断 userRecord[token].online 及其他操作,全部通过判断就说明当前 token 没问题并且更新 userRecord[token].online 为当前时间,最后再写入到临时表去。这个过程可能比较复杂,还是看代码比较好理解点。

    • removeRecord 这个逻辑就简单了,直接从 userRecord 中删除当前token,退出登录用

  4. ModuleJWT 在实例化时,先从 user.json 临时表里面读取上次写入的信息,然后剔除过时的token,保证数据同步。

  5. 最后就是在代码运行的过程中,定时去剔除过时的token,减少读写和内存大小的性能,具体还是看代码吧。

modules/Jwt.ts 文件下

import * as fs from "fs";
import config from "./Config";
import { apiSuccess } from "../utils/apiResult";
import { 
    UserRecordType, 
    UserInfoType, 
    JwtResultType, 
    TheContext,
    ApiResult
} from "../utils/interfaces";

/**
 * 自定义`jwt-token`验证模块,区别于[koa-jwt](https://www.npmjs.com/package/koa-jwt)
 * @author [Hjs](https://github.com/Hansen-hjs)
 */
class ModuleJWT {
    constructor() {
        this.init();
    }

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

    /** 更新 & 检测时间间隔(10分钟) */
    private interval = 600000;

    /** 用户`token`纪录 */
    private userRecord: UserRecordType = {};

    /**
     * 写入文件
     * @param obj 要写入的对象
     */
    private write(obj?: UserRecordType) {
        const data = obj || this.userRecord;
        // 同步写入(貌似没必要)
        // fs.writeFileSync(config.userFile, JSON.stringify(data), { encoding: "utf8" });
        // 异步写入
        fs.writeFile(config.userFile, JSON.stringify(data), { encoding: "utf8" }, err => {
            if (err) {
                console.log(`\x1B[41m jwt-token 写入失败 \x1B[0m`, err);
            } else {
                console.log(`\x1B[42m jwt-token 写入成功 \x1B[0m`);
            }
        })
    }

    /** 从本地临时表里面初始化用户状态 */
    private init() {
        // fs.accessSync(config.userFile)
        if (!fs.existsSync(config.userFile)) {
            console.log(`\x1B[42m ${config.userFile} 不存在,开始创建该文件 \x1B[0m`);
            fs.writeFileSync(config.userFile, "{}", { encoding: "utf8" });
        }
        const userFrom = fs.readFileSync(config.userFile).toString();
        this.userRecord = userFrom ? JSON.parse(userFrom) : {};
        this.checkRecord();
        // console.log("token临时表", userFrom, this.userRecord);
    }

    /** 生成`token` */
    private getToken() {
        const getCode = (n: number): string => {
            let codes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789";
            let code = "";
            for (let i = 0; i < n; i++) {
                code += codes.charAt(Math.floor(Math.random() * codes.length));
            }
            if (this.userRecord[code]) {
                return getCode(n);
            }
            return code;
        }
        const code = getCode(config.tokenSize);
        return code;
    }
    
    /** 定时检测过期的`token`并清理 */
    private checkRecord() {
        const check = () => {
            const now = Date.now();
            let isChange = false;
            for (const key in this.userRecord) {
                if (this.userRecord.hasOwnProperty(key)) {
                    const item = this.userRecord[key];
                    if (now - item.online > this.maxAge * 3600000) {
                        isChange = true;
                        delete this.userRecord[key];
                    }
                }
            }
            if (isChange) {
                this.write();
            }
        }
        // 定时检测
        setInterval(check, this.interval);
        check();
    }

    /**
     * 设置纪录并返回`token`
     * @param data 用户信息
     */
    setRecord(data: UserInfoType) {
        const token = this.getToken();
        data.online = Date.now();
        this.userRecord[token] = data;
        this.write();
        return token;
    }

    /**
     * 更新并检测`token`
     * @param token 
     * @description 这里可以做单点登录的处理,自行修改一下规则判断即可
     */
    updateRecord(token: string) {
        const result: JwtResultType = {
            message: "",
            success: false,
            info: null
        }

        if (!this.userRecord.hasOwnProperty(token)) {
            result.message = "token 已过期或不存在";
            return result;
        } 
        
        const userInfo = this.userRecord[token];

        const now = Date.now();

        if (now - userInfo.online > this.maxAge * 3600000) {
            result.message = "token 已过期";
            return result;
        }

        result.message = "token 通过验证";
        result.success = true;
        result.info = userInfo;

        // 更新在线时间并写入临时表
        // 这里优化一下,写入和更新的时间间隔为10分钟,避免频繁写入
        if (now - userInfo.online > this.interval) {
            this.userRecord[token].online = now;
            this.write();
        }
        
        return result;
    }

    /**
     * 从纪录中删除`token`纪录
     * @param token 
     * @description 主要是退出登录时用
     */
    removeRecord(token: string) {
        if (this.userRecord.hasOwnProperty(token)) {
            delete this.userRecord[token];
            this.write();
            return true;
        } else {
            return false;
        }
    }

    /**
     * 检测需要`token`的接口状态
     * @param context 
     */
    checkToken(context: TheContext) {
        const token: string = context.header.authorization;
        let fail = false;
        let info: ApiResult;

        if (!token) {
            fail = true;
            info = apiSuccess({}, "缺少token", 400);
        }

        if (token && token.length != config.tokenSize) {
            fail = true;
            info = apiSuccess({}, config.tokenTip, 400);
        }
        
        const state = this.updateRecord(token);

        if (!state.success) {
            fail = true;
            info = apiSuccess({}, state.message, 401);
        }

        // 设置 token 信息到上下文中给接口模块里面调用
        if (!fail) {
            context["theState"] = state;
        }

        return {
            fail,
            info
        }
    }

}

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

export default jwt;
复制代码

连接数据库和接口操作

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

upupw下载地址

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

  1. 这里用upupw命令窗口获取数据的账号密码后在navicat新建一个数据库的连接,照着账号密码填就行,注意不要改默认的端口,不然会连接失败。

  2. 连接好后在左侧栏新建一个数据库,叫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";

interface queryResult {
    /** `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 增删改查语句
 * @param value 对应的值
 */
export default function query(command: string, value?: Array<any>) {
    const result: queryResult = {
        state: 0,
        results: null,
        fields: null,
        error: null,
        msg: ""
    }
    return new Promise<queryResult>((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);
                }
            }
        });
    });
}

// learn: https://blog.csdn.net/gymaisyl/article/details/84777139	
复制代码

登录注册用户模块

mysql查询语句对应user_form表格式的字段

src/api/apiUser.ts 文件下

import router from "./main";
import query from "../utils/mysql";
import jwt from "../modules/Jwt";
import config from "../modules/Config";
import {
    UserInfoType,
    TheContext,
    ApiResult
} from "../utils/interfaces";
import { apiSuccess, apiFail } from "../utils/apiResult";

// 注册
router.post("/register", async (ctx) => {
    /** 接收参数 */
    const params: UserInfoType = 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_form 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 res = await query("insert into user_form(account, password, username) values(?,?,?)", [params.account, params.password, params.name])
        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: UserInfoType = 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_form where account = "${ params.account }"`)
    
    console.log("登录查询", res);

    if (res.state === 1) {
        // 再判断账号是否可用
        if (res.results.length > 0) {
            const data: UserInfoType = res.results[0];
            // 最后判断密码是否正确
            if (data.password == params.password) {
                data.token = jwt.setRecord({
                    id: data.id,
                    account: data.account,
                    password: data.password
                });
                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", async (ctx: TheContext) => {
    const checkInfo = jwt.checkToken(ctx);

    if (checkInfo.fail) {
        return ctx.body = checkInfo.info;
    }
    
    const state = ctx["theState"];
    // /** 接收参数 */
    // const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult: ApiResult;

    // console.log("getUserInfo", params, state);

    const res = await query(`select * from user_form where account = "${ state.info.account }"`)
    
    if (res.state === 1) {
        // 判断账号是否可用
        if (res.results.length > 0) {
            const data: UserInfoType = res.results[0];
            bodyResult = apiSuccess(data);
        } else {
            bodyResult = apiSuccess({}, "该账号不存在,可能已经从数据库中删除", 400);
        }
    } else {
        ctx.response.status = 500;
        bodyResult = apiFail(res.msg, 500, res.error);
    }

    ctx.body = bodyResult;
})

// 退出登录
router.get("/logout", ctx => {
    const checkInfo = jwt.checkToken(ctx);

    if (checkInfo.fail) {
        return ctx.body = checkInfo.info;
    }
    
    const token: string = ctx.header.authorization;
    /** 接收参数 */
    const params = ctx.request.body;

    console.log("logout", params, token);

    if (token.length != config.tokenSize) {
        return ctx.body = apiSuccess({}, config.tokenTip);
    }

    const state = jwt.removeRecord(token);

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

增删改查功能

然后再建一个叫todo_form的表

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

import router from './main';
import query from '../modules/mysql';
import stateInfo from '../modules/state';
import { mysqlQueryType, mysqlErrorType, JwtResultType } from '../modules/interfaces';

// 获取所有列表
router.get('/getList', async (ctx) => {
    const checkInfo = jwt.checkToken(ctx);

    if (checkInfo.fail) {
        return ctx.body = checkInfo.info;
    }
    
    const state: JwtResultType = ctx['theState'];
    /** 返回结果 */
    let bodyResult = null;
    
    // console.log('getList');

    // 这里要开始连表查询
    await query(`select * from todo_form where user_id = '${ state.info.id }'`).then((res: mysqlQueryType) => {
        // console.log('/getList 查询', res.results);
        bodyResult = stateInfo.getSuccessData({
            list: res.results.length > 0 ? res.results : [] 
        });
    }).catch((err: mysqlErrorType) => {
        bodyResult = stateInfo.getFailData(err.message);
    })

    ctx.body = bodyResult;
})

// 添加列表
router.post('/addList', async (ctx) => {
    const checkInfo = jwt.checkToken(ctx);

    if (checkInfo.fail) {
        return ctx.body = checkInfo.info;
    }
    
    const state: JwtResultType = ctx['theState'];
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;

    if (!params.content) {
        return ctx.body = stateInfo.getFailData('添加的列表内容不能为空!');
    }

    // 写入列表
    await query('insert into todo_form(content, time, user_id) values(?,?,?)', [params.content, new Date().toLocaleDateString(), state.info.id]).then((res: mysqlQueryType) => {
        // console.log('写入列表', res.results.insertId);
        bodyResult = stateInfo.getSuccessData({
            id: res.results.insertId
        }, '添加成功');
    }).catch((err: mysqlErrorType) => {
        // console.log('注册写入错误', err);
        bodyResult = stateInfo.getFailData(err.message);
    })
    
    ctx.body = bodyResult;
})

// 修改列表
router.post('/modifyList', async (ctx) => {
    const checkInfo = jwt.checkToken(ctx);

    if (checkInfo.fail) {
        return ctx.body = checkInfo.info;
    }
    
    const state: JwtResultType = ctx['theState'];
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;

    if (!params.id) {
        return ctx.body = stateInfo.getFailData('列表id不能为空');
    }

    if (!params.content) {
        return ctx.body = stateInfo.getFailData('列表内容不能为空');
    }

    // 修改列表
    await query(`update todo_form set content='${params.content}', time='${new Date().toLocaleDateString()}' where list_id='${params.id}'`).then((res: mysqlQueryType) => {
        console.log('修改列表', res);
        if (res.results.affectedRows > 0) {
            bodyResult = stateInfo.getSuccessData({}, '修改成功');
        } else {
            bodyResult = stateInfo.getFailData('列表id不存在');
        }
    }).catch((err: mysqlErrorType) => {
        // console.log('注册写入错误', err);
        bodyResult = stateInfo.getFailData(err.message);
    })

    ctx.body = bodyResult;
})

// 删除列表
router.post('/deleteList', async (ctx) => {
    const checkInfo = jwt.checkToken(ctx);

    if (checkInfo.fail) {
        return ctx.body = checkInfo.info;
    }
    
    const state: JwtResultType = ctx['theState'];
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;
    
    // 从数据库中删除
    await query(`delete from todo_form where list_id=${params.id} and user_id = ${state.info.id}`).then((res: mysqlQueryType) => {
        console.log('从数据库中删除', res);
        if (res.results.affectedRows > 0) {
            bodyResult = stateInfo.getSuccessData({}, '删除成功');
        } else {
            bodyResult = stateInfo.getFailData('当前列表id不存在或已删除');
        }
    }).catch((err: mysqlErrorType) => {
        console.log('从数据库中删除失败', err);
        bodyResult = stateInfo.getFailData(err.message);
    })

    ctx.body = bodyResult;
})
复制代码

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

  • 这个是node.js核心功能之一,原则上和Koa没有任何关系;因为网上能搜到的相关资料比较少,所以这里顺便也带上
  • 这个功能主要是某些时候我们需要获取第三方的一些数据,然后返回给前端的,类似微信小程序的后端接口就会用到,这里不举例了

utils目录下新建一个request.ts

import * as http from "http";
import * as querystring from "querystring"
import * as zlib from "zlib"
import { ServeRequestResult } from "./interfaces";

/**
 * 服务端请求
 * - [基础请求参考](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: object = {}): Promise<ServeRequestResult> {
    /** 返回结果 */
    const info: ServeRequestResult = {
        msg: "",
        result: "",
        state: -1
    }

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

    if (data && options.method == "GET") {
        options.path += `?${data}`;
    }
    
    return new Promise((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 >>");
                // info.result += chunk;
                // info.result = chunk;
                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模拟即可

import { apiSuccess, apiFail } from "../utils/apiResult";
import request from "../utils/request";

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

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

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

    // 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 start就可以了,但是发现退出之后就关闭服务了,所以还要再安装一个进程管理 pm2 来代替我们手动去npm run start;
  4. 安装完pm2之后还需要在当前项目目录下写一个pm2.json的配置运行文件,类似package.json一样,代码片段如下:
{
    "name": "node-serve", // 进程的名字
    "script": "npm run start" // 运行代码的命令
}
复制代码

最后执行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下,因为是后端代码的分享,所以这里不作前端代码演示。