nodejs从0到1搭建后台服务

  • nodejs从0到1搭建后台服务已关闭评论
  • 171 次浏览
  • A+
所属分类:Web前端
摘要

然后在config文件中将.env的配置暴露出去2.在最外层创建app.js文件这里可以先不引入websocket和Mperson,这是后续发布内容时才会用到。


项目主体搭建

  • 前端:vue3element-plustsaxiosvue-routerpinia
  • 后端:nodejskoakoa-routerkoa-bodyjsonwebtoken
  • 部署:nginxpm2xshell、腾讯云自带宝塔面板
  • 数据库:mysqlredis
  • 开发软件:vs codeAnother Redis Desktop ManagerNavicat Premium 15

后端主要使用的依赖包

  • dotenv: 将环境变量中的变量从 .env 文件加载到 process.env
  • jsonwebtoken: 颁发token,不会缓存到mysqlredis
  • koa: 快速搭建后台服务的框架
  • koa-body: 解析前端传来的参数,并将参数挂到ctx.request.body
  • koa-router: 路由中间件,处理不同url路径的请求
  • koa2-cors: 处理跨域请求的中间件
  • mysql2: 在nodejs中连接和操作mysql数据库,mysql也可以,不过要自己封装连接数据库
  • redis: 在nodejs中操作redis的库,通常用作持久化token、点赞等功能
  • sequelize: 基于promise的orm(对象关系映射)库,不用写sql语句,更方便的操作数据库
  • nodemon: 自动重启服务
  • sequelize-automete: 自动化为sequelize生成模型

文件划分

nodejs从0到1搭建后台服务

常量文件配置

  1. 创建.env.developmentconfig文件夹,配置如下
# 数据库ip地址 APP_HOST = 1.15.42.9 # 服务监听端口 APP_PORT = 40001 # 数据库名 APP_DATA_BASE = test # 用户名 APP_USERNAME = test # 密码 APP_PASSWORD = 123456 # redis地址 APP_REDIS_HOST = 1.15.42.9 # redis端口 APP_REDIS_PORT = 6379 # redis密码 APP_REDIS_PASSWORD = 123456 # redis仓库 APP_REDIS_DB = 15 # websocket后缀 APP_BASE_PATH = / # token标识 APP_JWT_SECRET = LOVE-TOKEN # 保存文件的绝对路径 APP_FILE_PATH = "" # 网络url地址 APP_NETWORK_PATH = blob:http://192.168.10.20:40001/ 

然后在config文件中将.env的配置暴露出去

const dotenv = require("dotenv"); const path = process.env.NODE_ENV   ? ".env.development"   : ".env.production.local"; dotenv.config({ path });  module.exports = process.env; 

2.在最外层创建app.js文件

const Koa = require("koa"); const http = require("http"); const cors = require("koa2-cors"); const WebSocket = require("ws"); const { koaBody } = require("koa-body"); const { APP_PORT, APP_BASE_PATH } = require("./src/config/index"); const router = require("./src/router/index"); const seq = require("./src/mysql/sequelize"); const PersonModel = require("./src/models/person"); const Mperson = PersonModel(seq);  // 创建一个Koa对象 const app = new Koa(); const server = http.createServer(app.callback()); // 同时需要在nginx配置/ws const wss = new WebSocket.Server({ server, path: APP_BASE_PATH }); // 同一端口监听不同的服务 // 使用了代理 app.proxy = true; // 处理跨域 app.use(cors()); // 解析请求体(也可以使用koa-body) app.use(   koaBody({     multipart: true,     // textLimit: "1mb",  // 限制text body的大小,默认56kb     formLimit: "10mb", // 限制表单请求体的大小,默认56kb,前端报错413     // encoding: "gzip",    // 前端报415     formidable: {       // uploadDir: path.join(__dirname, "./static/"), // 设置文件上传目录       keepExtensions: true, // 保持文件的后缀       // 最大文件上传大小为512MB(如果使用反向代理nginx,需要设置client_max_body_size)       maxFieldsSize: 512 * 1024 * 1024,     },   }) ); app.use(router.routes());  wss.on("connection", function (ws) {   let messageIndex = 0;   ws.on("message", async function (data, isBinary) {     console.log(data);     const message = isBinary ? data : data.toString();     if (JSON.parse(message).type !== "personData") {       return;     }     const result = await Mperson.findAll();     wss.clients.forEach((client) => {       messageIndex++;       client.send(JSON.stringify(result));     });   });   ws.onmessage = (msg) => {     ws.send(JSON.stringify({ isUpdate: false, message: "pong" }));   };   ws.onclose = () => {     console.log("服务连接关闭");   };   ws.send(JSON.stringify({ isUpdate: false, message: "首次建立连接" })); });  server.listen(APP_PORT, () => {   const host = process.env.APP_REDIS_HOST;   const port = process.env.APP_PORT;   console.log(     `环境:${       process.env.NODE_ENV ? "开发环境" : "生产环境"     },服务器地址:http://${host}:${port}/findExcerpt`   ); }); module.exports = server; 

这里可以先不引入websocketMperson,这是后续发布内容时才会用到。

注:app.use(cors())必须在app.use(router.routes())之前,不然访问路由时会显示跨域。

3.在package.json中添加命令"dev": "set NODE_ENV=development && nodemon app.js",
然后就可以直接运行npm run dev启动服务了

mysql创建数据库建表

在服务器上开放mysql端口3306,还有接下来使用到的redis端口6379
使用root连接数据库,可以看到所有的数据库。

注:sequelize6版本最低支持mysql5.7,虽然不会报错,但是有提示

nodejs从0到1搭建后台服务

mysql默认情况下不允许root直接连接,需要手动放开

  • 进入mysql:mysql -uroot -p
  • 使用mysql:use mysql;
  • 授权给所有ip:GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456(密码)' WITH GRANT OPTION;
  • 刷新权限:FLUSH PRIVILEGES;

redis使用密码远程连接后,就不需要在本地安装redis

redis默认也是不允许远程连接,需要手动放开。

  1. 使用find / -name redis.conf先找到redis配置文件

    • bind 127.0.0.1修改为bind 0.0.0.0;
    • 设置protected-mode no;
    • 设置密码requirepass 123456密码123456替换为自己的。
  2. 进入src使用./redis-server ../redis.conf重新启动

使用sequelize-automate自动生成表模型

创建文件sequelize-automate.config.js,然后在package.json增加一条命令"automate": "sequelize-automate -c './sequelize-automate.config.js'",使用npm run automate自动生成表模型。

注:建议自己维护createdAtupdatedAt,因为在Navicat Premium 15上创建表后,自动生成的模型虽然会自动增加createdAtupdatedAt,但是不会同步到数据库上,需要删除数据库中的表,然后再重新启动服务才会同步,但是当id为主键且不为null时,模板会生成defaultValuenull,在个别表中会报错

const Automate = require("sequelize-automate"); const dbOptions = {   database: "test",   username: "test",   password: "123456",   dialect: "mysql",   host: "1.15.42.9",   port: 3306,   logging: false,   define: {     underscored: false,     freezeTableName: false,     charset: "utf8mb4",     timezone: "+00:00",     dialectOptions: {       collate: "utf8_general_ci",     },     timestamps: true, // 自动创建createAt和updateAt   }, }; const options = {   type: "js",   dir: "./src/models",   camelCase: true, }; const automate = new Automate(dbOptions, options); (async function main() {   const code = await automate.run();   console.log(code); })(); 

连接mysql和redis

mysql连接,timezone默认是世界时区, "+08:00"指标准时间加8个小时,也就是北京时间

const { Sequelize } = require("sequelize"); const { APP_DATA_BASE, APP_USERNAME, APP_PASSWORD, APP_DATA_HOST } = require("../config/index"); const seq = new Sequelize(APP_DATA_BASE, APP_USERNAME, APP_PASSWORD, {   host: APP_DATA_HOST,   dialect: "mysql",   define: {     timestamps: true,   },   timezone: "+08:00" }); seq   .authenticate()   .then(() => {     console.log("数据库连接成功");   })   .catch((err) => {     console.log("数据库连接失败", err);   });  seq.sync(); // 强制同步数据库,会先删除表,然后创建 seq.sync({ force: true }); module.exports = seq; 

redis连接,database不同环境使用不同的分片

const Redis = require("redis"); const {   APP_REDIS_HOST,   APP_REDIS_PORT,   APP_REDIS_PASSWORD,   APP_REDIS_DB } = require("../config");  const client = Redis.createClient({   url: "redis://" + APP_REDIS_HOST + ":" + APP_REDIS_PORT,   host: APP_REDIS_HOST,   port: APP_REDIS_PORT,   password: APP_REDIS_PASSWORD,   database: APP_REDIS_DB, }); client.connect(); client.on("connect", () => {   console.log("Redis连接成功!"); }); // 连接错误处理 client.on("error", (err) => {   console.error(err); });  // client.set("age", 1);  module.exports = client; 

一切都没问题后,现在开始编写路由代码,也就是一个个接口

编写登录注册

controllers新建user.js模块,具体逻辑:登录和注册是同一个接口,前端提交时,会先判断这个用户是否存在,不存在会将传来的密码进行加密,然后将用户信息存入到数据库,同时使jsonwebtoken颁发token,token本身是无状态的,也就是说,在时间到期之前不会销毁!这里可以在服务端维护一个令牌黑名单,用于退出登录。

const response = require("../utils/resData"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const { APP_JWT_SECRET } = require("../config/index"); const seq = require("../mysql/sequelize"); const UserModel = require("../models/user"); const Muser = UserModel(seq); // 类定义 class User {   constructor() {}   // 注册用户   async register(ctx, next) {     try {       const { userName: user_name, password: pass_word } = ctx.request.body;       if (!user_name || !pass_word) {         ctx.body = response.ERROR("userNotNull");         return;       }       // 判断用户是否存在       const isExist = await Muser.findOne({         where: {           user_name: user_name,         },       });       if (isExist) {         const res = await Muser.findOne({           where: {             user_name: user_name,           },         });         // 密码是否正确         if (bcrypt.compareSync(pass_word, res.dataValues.password)) {           // 登录成功           const { password, ...data } = res.dataValues;           ctx.body = response.SUCCESS("userLogin", {             token: jwt.sign(data, APP_JWT_SECRET, { expiresIn: "30d" }),             userInfo: res.dataValues,           });         } else {           ctx.body = response.ERROR("userAlreadyExist");         }       } else {         // 加密         const salt = bcrypt.genSaltSync(10);         // hash保存的是 密文         const hash = bcrypt.hashSync(pass_word, salt);         const userInfo = await Muser.create({ user_name, password: hash });         const { password, ...data } = userInfo.dataValues;         ctx.body = response.SUCCESS("userRegister", {           token: jwt.sign(data, APP_JWT_SECRET, { expiresIn: "30d" }),           userInfo,         });       }     } catch (error) {       console.error(error);       ctx.body = response.SERVER_ERROR();     }   } }  module.exports = new User(); 

增加校验用户是否登录的中间件

middleware创建index.js,将用户信息挂载到ctx.state.user上,方便后续别的地方需要用到用户信息

const jwt = require("jsonwebtoken"); const { APP_JWT_SECRET } = require("../config/index"); const response = require("../utils/resData");  // 上传文件时,如果存在token,将token添加到state,方便后面使用,没有或者失效,则返回null const auth = async (ctx, next) => {   // 会返回小写secret   const token = ctx.request.header["love-token"];   if (token) {     try {       const user = jwt.verify(token, APP_JWT_SECRET);       // 在已经颁发token接口上面添加user对象,便于使用       ctx.state.user = user;     } catch (error) {       ctx.state.user = null;       console.log(error.name);       if (error.name === "TokenExpiredError") {         ctx.body = response.ERROR("tokenExpired");       } else if (error.name === "JsonWebTokenError") {         ctx.body = response.ERROR("tokenInvaild");       } else {         ctx.body = response.ERROR("unknownError");       }       return;     }   }   await next(); };  module.exports = {   auth, }; 

增加路由页面

controllers所有文件都导入到index.js中,然后再暴露出去,这样在router文件就只需要引入controllers/index.js即可

const hotword = require("./hotword"); const issue = require("./issue"); const person = require("./person"); const user = require("./user"); const common = require("./common"); const wallpaper = require("./wallpaper"); const fileList = require("./fileList"); const ips = require("./ips"); module.exports = {   hotword,   issue,   person,   user,   common,   wallpaper,   fileList,   ips, }; 
const router = require("koa-router")(); const {   hotword,   person,   issue,   common,   user,   wallpaper,   fileList,   ips, } = require("../controllers/index"); const { auth } = require("../middleware/index");  // router.get("/", async (ctx) => { //   ctx.body = "欢迎访问该接口"; // }); router.get("/wy/find", hotword.findHotword); router.get("/wy/pageQuery", hotword.findPageHotword);  // 登录才能删除,修改评论(协商缓存) router.get("/findExcerpt", person.findExcerpt); router.get("/addExcerpt", person.addExcerpt); router.get("/updateExcerpt", auth, person.updateExcerpt); router.get("/delExcerpt", auth, person.delExcerpt);  // 不走缓存 router.post("/findIssue", issue.findIssue); router.post("/addIssue", issue.addIssue); router.post("/delIssue", issue.delIssue); router.post("/editIssue", issue.editIssue);  router.post("/register/user", user.register); router.post("/upload/file", common.uploadFile); router.post("/paste/upload/file", common.pasteUploadFile); // router.get("/download/file/:name", common.downloadFile); // 强缓存 router.get("/wallpaper/findList", wallpaper.findPageWallpaper);  // 文件列表 router.post("/file/list", auth, fileList.findFileLsit); router.post("/save/list", auth, fileList.saveFileInfo); router.post("/delete/file", auth, fileList.delFile);  // ip router.post("/find/ipList", (ctx, next) => ips.findIpsList(ctx, next)); module.exports = router; 

注:需要用户登录的接口在路由前加auth中间件即可

获取ip、上传、下载

获取ip

获取ip可以单独提取出来,当作中间件,这里当用户访问我的ip地址页面时,会自动在数据库中添加记录,同时使用redis存储当前ip,10分钟内再次访问不会再增加。

注:当服务在线上使用nginx反向代理时,需要在app.js添加app.proxy = true,同时需要配置nginx来获取用户真实ip

const response = require("../utils/resData"); const { getClientIP } = require("../utils/common"); const seq = require("../mysql/sequelize"); const IpsModel = require("../models/ips"); const MIps = IpsModel(seq); const client = require("../utils/redis"); const { reqIp } = require("../api/ip");  class Ips {   constructor() {}   async findIpsList(ctx, next) {     try {       if (!process.env.NODE_ENV) {         await this.addIps(ctx, next);       }       const { size, page } = ctx.request.body;       const total = await MIps.findAndCountAll();       const data = await MIps.findAll({         order: [["id", "DESC"]],         limit: parseInt(size),         offset: parseInt(size) * (page - 1),       });       ctx.body = response.SUCCESS("common", { total: total.count, data });     } catch (error) {       console.error(error);       ctx.body = response.SERVER_ERROR();     }   }   async addIps(ctx, next) {     try {       const ip = getClientIP(ctx);       const res = await client.get(ip);       // 没有才在redis中设置       if (!res) {         // 需要将值转为字符串         await client.set(ip, new Date().toString(), {           EX: 10 * 60, // 以秒为单位存储10分钟           NX: true, // 键不存在时才进行set操作         });         if (ip.length > 6) {           const obj = {             id: Date.now(),             ip,           };           const info = await reqIp({ ip });           if (info.code === 200) {             obj.operator = info.ipdata.isp;             obj.address = info.adcode.o;             await MIps.create(obj);           } else {             console.log("ip接口请求失败");           }         }       }     } catch (error) {       console.error(error);     }     await next();   } }  module.exports = new Ips(); 
location / {   proxy_set_header Host $http_host;   proxy_set_header X-Real-IP $remote_addr;   proxy_set_header REMOTE-HOST $remote_addr;   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;   proxy_set_header Public-Network-URL http://$http_host$request_uri;   proxy_pass http://127.0.0.1:40001; } 

上传

配置文件.env需要配置好绝对路径:APP_FILE_PATH = /任意位置

const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const response = require("../utils/resData"); const { APP_NETWORK_PATH, APP_FILE_PATH } = require("../config/index");  class Common {   constructor() {}   async uploadFile(ctx, next) {     try {       const { file } = ctx.request.files;       // 检查文件夹是否存在,不存在则创建文件夹       if (!fs.existsSync(APP_FILE_PATH)) {         APP_FILE_PATH && fs.mkdirSync(APP_FILE_PATH);       }       // 上传的文件具体地址       let filePath = path.join(         APP_FILE_PATH || __dirname,         `${APP_FILE_PATH ? "./" : "../static/"}${file.originalFilename}`       );       // 创建可读流(默认一次读64kb)       const reader = fs.createReadStream(file.filepath);       // 创建可写流       const upStream = fs.createWriteStream(filePath);       // 可读流通过管道写入可写流       reader.pipe(upStream);       const fileInfo = {         id: Date.now(),         url: APP_NETWORK_PATH + file.originalFilename,         name: file.originalFilename,         size: file.size,         type: file.originalFilename.match(/[^.]+$/)[0],       };       ctx.body = response.SUCCESS("common", fileInfo);     } catch (error) {       console.error(error);       ctx.body = response.ERROR("upload");     }   }   async pasteUploadFile(ctx, next) {     try {       const { file } = ctx.request.body;       const dataBuffer = Buffer.from(file, "base64");       // 生成随机40个字符的hash       const hash = crypto.randomBytes(20).toString("hex");       // 文件名       const filename = hash + ".png";       let filePath = path.join(         APP_FILE_PATH || __dirname,         `${APP_FILE_PATH ? "./" : "../static/"}${filename}`       );       // 以写入模式打开文件,文件不存在则创建       const fd = fs.openSync(filePath, "w");       // 写入       fs.writeSync(fd, dataBuffer);       // 关闭       fs.closeSync(fd);       const fileInfo = {         id: Date.now(),         url: APP_NETWORK_PATH + filename,         name: filename,         size: file.size || "",         type: 'png',       };       ctx.body = response.SUCCESS("common", fileInfo);     } catch (error) {       console.error(error);       ctx.body = response.ERROR("upload");     }   } }  module.exports = new Common(); 

下载

直接配置nginx即可,当客户端请求路径以 /static 开头的静态资源时,nginx 会从指定的文件系统路径 /www/wwwroot/note.loveverse.top/static 中获取相应的文件。用于将 /static 路径下的静态资源标记为下载类型,用户访问这些资源时告诉浏览器下载文件

location /static {   add_header Content-Type application/x-download;   alias   /www/wwwroot/note.loveverse.top/static; }