当前位置:   article > 正文

一个前端渣渣的node开发体验

ts-node --project

前言

因为最近打算自己搭建一个自己的博客系统,用来记录日常的学习和提升一下写作水平,所以能就打算自己搭建一下前后端项目。在网上找了下,也没有找到合适(现成)的项目,所以就打算自己动手来搭建一下。这篇文章主要描述如何搭建一个node的API接口服务。

技术栈简述

网上的node框架也挺多的,用的较多的有egg,express,koa等框架,框架间各有利弊,最后均衡下来,还是决定使用可拓展性比较强的koa2来搭建项目,加上最近在学习typescript,最后决定使用的技术栈就是 koa+typescript+mysql+mongodb来搭建项目。

为什么要用node

最主要的一点是其他语言咱也不会啊。。。

0a8957a743f26b095e71ad10ee7dc1a6.jpeg

言归正传,Node.js是一个运行在服务端的框架,它底层使用的是V8引擎,它的速度非常快,并且作为一个前端的后端服务语言,还有其他吸引人的地方:

  1. 异步I/O

  2. 事件驱动

  3. 单线程

  4. 跨平台

而且,最最最最重要的一点就是,node是由JavaScript开发的,极大的降低了前端同学的学习成本。

Koa

koa是Express的原班人马打造的一个新的框架。相对于express来说koa更小,更有表现力更加健壮。当然,前面说的都是虚的,其实真正吸引我的是koa通过es6的写法,利用async函数,解决了express.js中地狱回调的问题,并且koa不像express一样自带那么多中间件,对于一个私有项目来说,无疑是极好的,还有一个特点就是koa独特的中间件流程控制,也就是大家津津乐道的koa洋葱模型。

关于洋葱模型,大概归纳起来就是两点

  1. context的保存和传递

  2. 中间件的管理和next的实现

64e9e73b6fc7b3cedbe55754956a3c57.png

(图片来源于网络)

cea321b015ad22fe55a31ce5d7ea8679.png

上面两张图很清晰的展示了洋葱模型的工作流程,当然,具体的原理实现的话与本篇无关,就不在深入描述了,有兴趣的同学可以自己到网上搜一下哈。

Typescript

网上特别多关于“为什么要用Typescript开发”,“Typescript开发的好处和坏处”,“为什么不用Typescript开发”等等的争论和文章,有兴趣的同学也可以去说道说道哈。

本次项目用ts主要是出于以下几点考虑:

  • 本人在持续的学习ts中,“纸上得来终觉浅,绝知此事要躬行”,需要更多的ts实战才能加深对ts的了解

  • 自己的项目,想用什么就用什么

  • 写起来逼格会相对高一些

  • Ts有诸多js中没有的东西,譬如泛型接口抽象等等

  • 良好的模块管理

  • 强类型语音,个人感觉比js开发服务端项目更合适

  • 有良好的错误提示机制,可以避免很多开发阶段的低级错误

  • 约束开发习惯,使得代码更优雅规范

最后记住一点,适合自己的才是最好的

Mysql

MySQL 是最流行的关系型数据库管理系统,在 WEB 应用方面 MySQL 是最好的 RDBMS(Relational Database Management System:关系数据库管理系统)应用软件之一

Mongodb

为什么用了mysql还要用mongodb呢?其实主要是因为使用的是jwt来做一个身份认证,由于用到中间件没有提供刷新过期时间的API,而又想要实现一个自动续命的功能,所以使用mongodb来辅助完成自动续命的功能。并且,一些用户身份信息或埋点信息可以存在mongo中

PM2

PM2是node进程管理工具,可以利用它来简化很多node应用管理的繁琐任务,如性能监控、自动重启、负载均衡等,而且使用非常简单

项目搭建

我主要把项目分为:框架,日志,配置,路由,请求逻辑处理,数据模型化这几个模块

以下是一个项目的目录结构:

  1. ├── app                         编译后项目文件
  2.   ├── node_modules                依赖包
  3.   ├── static                      静态资源文件
  4.   ├── logs                       服务日志
  5.   ├── src                         源码
  6.   │   ├── abstract                    抽象类
  7.   │   ├── config                      配置项
  8.   │   ├── controller                  控制器
  9.   │   ├── database                    数据库模块
  10.   │   ├── middleware                  中间件模块
  11.   │   ├── models                    数据库表模型
  12.   │   ├── router                      路由模块 - 接口
  13.   │   ├── utils                       工具
  14.   │   ├── app.ts                  koa2入口
  15.   ├── .eslintrc.js                eslint 配置
  16.   ├── .gitignore                  忽略提交到git目录文件
  17.   ├── .prettierrc                 代码美化
  18.   ├── ecosystem.config.js         pm2 配置
  19.   ├── nodemon.json                nodemon 配置
  20.   ├── package.json                依赖包及配置信息文件
  21.   ├── tsconfig.json               typescript 配置
  22.   ├── README.md                   描述文件

话不多说,接下来跟着代码来看项目

创建一个koa应用

俗话说的好:人无头不走。项目中也会有个牵着项目走的头,这就是入口app.ts,接下来咱就结合代码看看它是怎么做这个头的

  1. import Koa, { ParameterizedContext } from 'koa'
  2. import logger from 'koa-logger'
  3. // 实例化koa
  4. const app = new Koa()
  5. app.use(logger())
  6. // 答应一下响应信息
  7. app.use(async (ctx, next) => {
  8.   const start = (new Date()).getDate();
  9.   let timer: number
  10.   try {
  11.     await next()
  12.     timer = (new Date()).getDate()
  13.     const ms = timer - start
  14.     console.log(`method: ${ctx.method}, url:${ctx.url} - ${ms}ms`)
  15.   } catch (e) {
  16.     timer = (new Date()).getDate()
  17.     const ms = timer - start
  18.     console.log(`method: ${ctx.method}, url:${ctx.url} - ${ms}ms`)
  19.   }
  20. })
  21. // 监听端口并启动
  22. app.listen(config.PORT, () => {
  23.   console.log(`Server running on http://localhost:${config.PORT || 3000}`)
  24. })
  25. app.on('error', (error: Error, ctx: ParameterizedContext) => {
  26.   // 项目启动错误
  27.   ctx.body = error;
  28. })
  29. export default app

到了这一步,我们就已经可以启动一个简单的项目了

  1. npm run tsc 编译ts文件

  2. node app.js 启动项目

接下来在浏览器输入http://localhost:3000就能在控制台看到访问日志了。当然,做到这一步还是不够的,因为我们开发过程中总是伴随着调试,所以需要更方便的开发环境。

本地开发环境

本地开发使用nodemon来实现自动重启,因为node不能直接识别ts,所以需要用ts-node来运行ts文件。

  1. // nodemon.json
  2. {
  3.   "ext""ts",
  4.   "watch": [ // 需要监听变化的文件
  5.     "src/**/*.ts",
  6.     "config/**/*.ts",
  7.     "router/**/*.ts",
  8.     "public/**/*",
  9.     "view/**/*"
  10.   ],
  11.   "exec""ts-node --project tsconfig.json src/app.ts" // 使用ts-node来执行ts文件
  12. }
  13. // package.json
  14. "scripts": {
  15.   "start""cross-env NODE_ENV=development nodemon -x"
  16. }
本地调试

因为有的时候需要看到请求的信息,那我们又不能在代码中添加console.log(日志)这样效率低又不方便,所以我们需要借助编辑器来帮我们实现debug的功能。这边简单描述一下怎么用vscode来实现debug的。

  • tsconfig.json中开启sourceMap

  • 为ts-node注册一个vsc的debug任务,修改项目的launch.json文件,添加一个新的启动方式

  • launch.json

  1. {
  2.   "name""Current TS File",
  3.   "type""node",
  4.   "request""launch",
  5.   "args": [
  6.     "${workspaceRoot}/src/app.ts" // 入口文件
  7.   ],
  8.   "runtimeArgs": [
  9.     "--nolazy",
  10.     "-r",
  11.     "ts-node/register"
  12.   ],
  13.   "sourceMaps"true,
  14.   "cwd""${workspaceRoot}",
  15.   "protocol""inspector",
  16.   "console""integratedTerminal",
  17.   "internalConsoleOptions""neverOpen"
  18. }
  • F9 代码中断点

  • F5 开始调试代码

引入接口路由

上面我们已经创建了一个koa应用了,接下来就使用需要引入路由了:

  1. // app.ts
  2. import router from './router'
  3. import requestMiddleware from './middleware/request'
  4. app
  5.   .use(requestMiddleware) // 使用路由中间件处理路由,一些处理接口的公用方法
  6.   .use(router.routes())
  7.   .use(router.allowedMethods())
  8. // router/index.ts
  9. import { ParameterizedContext } from 'koa'
  10. import Router from 'koa-router'
  11. const router = new Router()
  12. // 接口文档 - 这边使用分模块实现路由的方式
  13. router.use(路由模块.routes())
  14. ...
  15. router.use(路由模块.routes())
  16. // 测试路由连接
  17. router.get('/test-connect', async (ctx: ParameterizedContext) => {
  18.   await ctx.body = 'Hello Frivolous'
  19. })
  20. // 匹配其他未定义路由
  21. router.get('*', async (ctx: ParameterizedContext) => {
  22.   await ctx.render('error')
  23. })
  24. export default router

定义数据库模型

  1. 使用sequlize作为mysql的中间件

  1. // 实例化sequelize
  2. import { Sequelize } from 'sequelize'
  3. const sequelizeManager = new Sequelize(db, user, pwd, Utils.mergeDefaults({
  4.     dialect: 'mysql',
  5.     host: host,
  6.     port: port,
  7.     define: {
  8.       underscored: true,
  9.       charset: 'utf8',
  10.       collate: 'utf8_general_ci',
  11.       freezeTableName: true,
  12.       timestamps: true,
  13.     },
  14.     logging: false,
  15.   }, options));
  16. }
  17. // 定义表结构
  18. import { Model, ModelAttributes, DataTypes } from 'sequelize'
  19. // 定义用户表模型中的字段属性
  20. const UserModel: ModelAttributes = {
  21.   id: {
  22.     type: DataTypes.INTEGER,
  23.     allowNull: false,
  24.     primaryKey: true,
  25.     unique: true,
  26.     autoIncrement: true,
  27.     comment: 'id'
  28.   },
  29.   avatar: {
  30.     type: DataTypes.INTEGER,
  31.     allowNull: true
  32.   },
  33.   nick_name: {
  34.     type: DataTypes.STRING(50),
  35.     allowNull: true
  36.   },
  37.   email: {
  38.     type: DataTypes.STRING(50),
  39.     allowNull: true
  40.   },
  41.   mobile: {
  42.     type: DataTypes.INTEGER,
  43.     allowNull: false
  44.   },
  45.   gender: {
  46.     type: DataTypes.STRING(35),
  47.     allowNull: true
  48.   },
  49.   age: {
  50.     type: DataTypes.INTEGER,
  51.     allowNull: true
  52.   },
  53.   password: {
  54.     type: DataTypes.STRING(255),
  55.     allowNull: false
  56.   }
  57. }
  58. // 定义表模型
  59. sequelizeManager.define(modelName, UserModel, {
  60.   freezeTableName: true// model对应的表名将与model名相同
  61.   tableName: modelName,
  62.   timestamps: true,
  63.   underscored: true,
  64.   paranoid: true,
  65.   charset: "utf8",
  66.   collate: "utf8_general_ci",
  67. })

根据上面的代码,我们就已经定义好一个user表了,其他表也可以按照这个来定义。不过这个项目除了使用mysql,也还有用到mongo,接下来看看mongodb怎么用

  1. 使用mongoose作为mongodb的中间件

  1. // mongoose入口
  2. import mongoose from 'mongoose'
  3. const uri = `mongodb://${DB.host}:${DB.port}`
  4. mongoose.connect('mongodb://' + DB_STR)
  5. mongoose.connection.on('connected', () => {
  6.   log('Mongoose connection success')
  7. })
  8. mongoose.connection.on('error', (err: Error) => {
  9.   log('Mongoose connection error: ' + err.message)
  10. })
  11. mongoose.connection.on('disconnected', () => {
  12.   log('Mongoose connection disconnected')
  13. })
  14. export default mongoose
  15. // 定义表模型
  16. import mongoose from '../database/mongoose'
  17. const { Schema } = mongoose
  18. const AccSchema = new Schema({}, {
  19.   strict: false// 允许传入未定义字段
  20.   timestamps: true// 默认会带上createTime/updateTime
  21.   versionKey: false // 默认不带版本号
  22. })
  23. export default AccSchema
  24. // 定义模型
  25. mongoose.model('AccLog', AccSchema)

实现接口

好了,上面我们已经定义好表模型了,接下来就是激动人心的接口实现了。我们通过一个简单的埋点接口来实现一下,首先需要分析埋点工具实现的逻辑:

  1. 因为埋点信息都是非关系型的,所以使用mongodb来存储埋点信息

  2. 因为这个就是一个单纯的记录接口,所以需要设计的比较通用 - 即除了关键几个字段,调用方传什么就保存什么

  3. 埋点行为对用户来说是无感知的,所以不设计反馈信息,如果埋点出错也是由内部处理

好了,了解这个埋点的功能之后,就开始来实现这个简单的接口了:

  1. // route.ts 定义一个addAccLog的接口
  2. router.post('/addAccLog', AccLogController.addAccLog)
  3. // AccLogController.ts 实现addAccLog接口
  4. class AccLogRoute extends RequestControllerAbstract {
  5.   constructor() {
  6.     super()
  7.   }
  8.   // AccLogController.ts
  9.   public async addAccLog(ctx: ParameterizedContext): Promise<void> {
  10.     try {
  11.       const data = ctx.request.body
  12.       const store = Mongoose.model(tableName, AccSchema, tableName)
  13.       // disposeAccInsertData 方法用来处理日志信息,有些字段嵌套太要扁平化深或者去除空值冗余字段
  14.       const info = super.disposeAccInsertData(data.logInfo)
  15.       // 添加日志
  16.       const res = await store.create(info)
  17.       // 不需要反馈
  18.       // super.handleResponse('success', ctx, res)
  19.     } catch (e) {
  20.       // 错误处理 - 比如说打个点,记录埋点出错的信息,看看是什么原因导致出错的(根据实际的需求来做)
  21.       // ...
  22.     }
  23.   }
  24.   // ...
  25. }
  26. export default new AccLogRoute()

说到这边,不得不提一句哈,就是路由可以引入装饰器写法,这样能减少重复工作和提高效率,有兴趣的同学可以看我上一篇博客哈。这边贴一下装饰器写法的代码:

  1. @Controller('/AccLogController')
  2. class AccLogRoute {
  3.   @post('/addAccLog')
  4.   @RequestBody({}) 
  5.   async addAccLog(ctx: ParameterizedContext, next: Function) {
  6.     const res = await store.create(info)
  7.     handleResponse('success', ctx, res)
  8.   }
  9. }

这一对比,是不是看出装饰器的好处了呢。

jwt身份验证

这边使用jsonwebtoken来做jwt校验

  1. import { sign, decode, verify } from 'jsonwebtoken'
  2. import { ParameterizedContext } from 'koa'
  3. import { sign, decode, verify } from 'jsonwebtoken'
  4. import uuid from 'node-uuid'
  5. import IController from '../interface/controller'
  6. import config from '../config'
  7. import rsaUtil from '../util/rsaUtil'
  8. import cacheUtil from '../util/cacheUtil'
  9. interface ICode {
  10.   success: string,
  11.   unknown: string,
  12.   errorstring,
  13.   authorization: string,
  14. }
  15. interface IPayload {
  16.   iss: number | string// 用户id
  17.   login_id: number | string// 登录日志id
  18.   sub?: string;
  19.   aud?: string;
  20.   nbf?: string;
  21.   jti?: string;
  22.   [key: string]: any;
  23. }
  24. abstract class AController implements IController {
  25.   // 服务器响应状态
  26.   // code 状态码参考 https://www.cnblogs.com/zqsb/p/11212362.html
  27.   static STATE = {
  28.     success: { code: 200, message: '操作成功!' },
  29.     unknown: { code: -100, message: '未知错误!' },
  30.     error: { code: 400, message: '操作失败!' },
  31.     authorization: { code: 401, message: '身份认证失败!' },
  32.   }
  33.   /**
  34.    * @description 响应事件
  35.    * @param {keyof ICode} type
  36.    * @param {ParameterizedContext} [ctx]
  37.    * @param {*} [data]
  38.    * @param {string} [message]
  39.    * @returns {object}
  40.    */
  41.   public handleResponse(
  42.     type: keyof ICode,
  43.     ctx?: ParameterizedContext,
  44.     data?: any,
  45.     message?: string
  46.   ): object {
  47.     const res = AController.STATE[type]
  48.     const result = {
  49.       message: message || res.message,
  50.       code: res.code,
  51.       data: data || null,
  52.     }
  53.     if (ctx) ctx.body = result
  54.     return result
  55.   }
  56.   /**
  57.    * @description 注册token
  58.    * @param {IPayload} payload
  59.    * @returns {string}
  60.    */
  61.   public jwtSign(payload: IPayload): string {
  62.     const { TOKENEXPIRESTIME, JWTSECRET, RSA_PUBLIC_KEY } = config.JWT_CONFIG
  63.     const noncestr = uuid.v1()
  64.     const iss = payload.iss
  65.     // jwt创建Token
  66.     const token = sign({
  67.       ...payload,
  68.       noncestr
  69.     }, JWTSECRET, { expiresIn: TOKENEXPIRESTIME, algorithm: "HS256" })
  70.     // 加密Token
  71.     const result = rsaUtil.pubEncrypt(RSA_PUBLIC_KEY, token)
  72.     const isSave = cacheUtil.set(`${iss}`, noncestr, TOKENEXPIRESTIME * 1000)
  73.     if (!isSave) {
  74.       throw new Error('Save authorization noncestr error')
  75.     }
  76.     return `Bearer ${result}`
  77.   }
  78.   /**
  79.    * @description 验证Token有效性,中间件
  80.    * 
  81.    */
  82.   public async verifyAuthMiddleware(ctx: ParameterizedContext, next: Function): Promise<any> {
  83.     // 校验token
  84.     const { JWTSECRET, RSA_PRIVATE_KEY, IS_AUTH, IS_NONCESTR } = config.JWT_CONFIG
  85.     if (!IS_AUTH && process.env.NODE_ENV === 'development') {
  86.       await next()
  87.     } else {
  88.       // 如果header中没有身份认证字段,则认为校验失败
  89.       if (!ctx.header || !ctx.header.authorization) {
  90.         ctx.response.status = 401
  91.         return
  92.       }
  93.       // 获取token并且解析,判断token是否一致
  94.       const authorization: string = ctx.header.authorization;
  95.       const scheme = authorization.substr(06)
  96.       const credentials = authorization.substring(7)
  97.       if (scheme !== 'Bearer') {
  98.         ctx.response.status = 401;
  99.         this.handleResponse('authorization', ctx, null, 'Wrong authorization prefix')
  100.         return;
  101.       }
  102.       if (!credentials) {
  103.         ctx.response.status = 401;
  104.         this.handleResponse('authorization', ctx, null, 'Request header authorization cannot be empty')
  105.         return;
  106.       }
  107.       const token = rsaUtil.priDecrypt(RSA_PRIVATE_KEY, credentials)
  108.       if (typeof token === 'object') {
  109.         ctx.response.status = 401;
  110.         this.handleResponse('authorization', ctx, null, 'authorization is not an object')
  111.         return;
  112.       }
  113.       const isAuth = verify(token, JWTSECRET)
  114.       if (!isAuth) {
  115.         ctx.response.status = 401;
  116.         this.handleResponse('authorization', ctx, null, 'authorization token expired')
  117.         return;
  118.       }
  119.       const decoded: string | { [key: string]: any } | null = decode(token)
  120.       if (typeof decoded !== 'object' || !decoded) {
  121.         ctx.response.status = 401;
  122.         this.handleResponse('authorization', ctx, null, 'authorization parsing failed')
  123.         return;
  124.       }
  125.       const noncestr = decoded.noncestr
  126.       const exp = decoded.exp
  127.       const iss = decoded.iss
  128.       const cacheNoncestr = cacheUtil.get(`${iss}`)
  129.       if (IS_NONCESTR && noncestr !== cacheNoncestr) {
  130.         ctx.response.status = 401;
  131.         this.handleResponse('authorization', ctx, null, 'authorization signature "noncestr" error')
  132.         return;
  133.       }
  134.       if (Date.now() / 1000 - exp < 60) {
  135.         const options = { ...decoded };
  136.         Reflect.deleteProperty(options, 'exp')
  137.         Reflect.deleteProperty(options, 'iat')
  138.         Reflect.deleteProperty(options, 'nbf')
  139.         const newToken = AController.prototype.jwtSign(options as IPayload)
  140.         ctx.append('token', newToken)
  141.       }
  142.       ctx.jwtData = decoded
  143.       await next()
  144.     }
  145.   }
  146. }
  147. export default AController
  148. //  授权装饰器代码
  149. public auth() {
  150.   return (target: any, name?: string, descriptor?: IDescriptor) => {
  151.     if (typeof target === 'function' && name === undefined && descriptor === undefined) {
  152.       target.prototype.baseAuthMidws = super.verifyAuthMiddleware;
  153.     } else if (typeof target === 'object' && name && descriptor) {
  154.       descriptor.value.prototype.baseAuthMidws = super.verifyAuthMiddleware;
  155.     }
  156.   }
  157. }

这样,我们就完成了一个jwt授权的模块了,我们用也很简单,以addAccLog接口为例

  1. class AccLogRoute {
  2.   @auth() // 只要➕这一行代码就可以
  3.   @post('/addAccLog')
  4.   ...
  5. }

接口文档

既然我们已经写好接口了,那总要有一份可参阅的文档输出,这时候就想到了swagger,接下来咱们就把swagger引入到我们的项目中吧。

  • 入口

  1. // swagger入口
  2. import swaggerJSDoc from 'swagger-jsdoc'
  3. import config from '../config'
  4. const { OPEN_API_DOC } = config
  5. // swagger definition
  6. const swaggerDefinition = {
  7.   // ...
  8. }
  9. const createDOC = (): object => {
  10.   const options = {
  11.     swaggerDefinition: swaggerDefinition,
  12.     apis: ['./src/controller/*.ts']
  13.   }
  14.   return OPEN_API_DOC ? swaggerJSDoc(options) : null
  15. }
  16. export default createDOC
  17. // 怎么
  • 配置示例 - 这边一定要注意格式

  1. @swagger Tips: 必须要声明,不然代码不会把此处生成为文档
  2.  definitions:
  3.    Login: // 接口名
  4.      required: // 必填参数
  5.        - username
  6.        - password
  7.      properties: // 可选参数
  8.        username:
  9.          typestring
  10.        password:
  11.          typestring
  12.        path:
  13.          typestring
  • swagger官方配置工具

  • 推荐一个vscode插件 - facility插件,用来快速生成注释

Mock数据

使用mock来生成测试数据

日志

日志模块本来打算是用log4.js来做的,后来感觉做的日志模块还没达到预期,所以就决定先暂时用pm2的日志系统来代替log4。这边就先不贴log4相关的代码了

部署

使用pm2来部署项目,这边展示一下配置文件

Tips

  • error_file 错误日志输出

  • out_file 正常日志输出

  • script 入口文件 - 以打包过后的js文件作为入口

  1. // pm2.json
  2. {
  3.   "apps": {
  4.     "name""xxx",
  5.     "script""./app/server.js",
  6.     "cwd""./",
  7.     "args""",
  8.     "interpreter_args""",
  9.     "watch"true,
  10.     "ignore_watch": [
  11.       "node_modules",
  12.       "logs",
  13.       "app/lib"
  14.     ],
  15.     "exec_mode""fork_mode",
  16.     "instances"1,
  17.     "max_memory_restart"8,
  18.     "error_file""./logs/pm2-err.log",
  19.     "out_file""./logs/pm2-out.log",
  20.     "merge_logs"true,
  21.     "log_date_format""YYYY-MM-DD HH:mm:ss",
  22.     "max_restarts"30,
  23.     "autorestart"true,
  24.     "cron_restart""",
  25.     "restart_delay"60,
  26.     "env": {
  27.       "NODE_ENV""production"
  28.     }
  29.   }
  30. }
  31. // package.json
  32. "scripts": {
  33.   // 生产环
  34.   "prod""pm2 start pm2.json"
  35. }

配置好pm2之后,我们只要在package.json中配置pm2 start pm2.json就可以实现启动pm2进程了

结束语

虽然是一个简单的接口服务器,但是需要考虑的东西也是很多,而且因为很多插件都是第一次接触,所以整个项目实现的过程还是蛮坎坷的,基本上是那种摸石头过河。不过痛并快乐着吧,虽然困难很多,但是过程中也学到了不少新的知识点,大概了解了一个简单的后端服务项目所承载的重量。

最后有两件小事

  1. 有想入群的学习前端进阶的加我微信 luoxue2479 回复加群即可

  2. 喜欢这篇文章的话,点个 在看,让更多的人看到

  3. 有写错的地方和更好的建议可以在下面 留言,一起讨论

原文地址:https://juejin.im/post/5eb3e1b4e51d45244e7c2d09

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/69050
推荐阅读
相关标签
  

闽ICP备14008679号