Online Chat-Egg+Webpack+Sockett.IO

102363 ワード

Egg+Webpack+Socket.io Notes
See project PorYoung/all Chat、an online chat web appplication based on egg.js and sockt.io.
Directory Structure
- app
  - controller
  - extend
  - middleware
  - model
  - service
  - public
    - dist  /* webpack output directory */
  - io  /* socket.io */
    - controller
    - middleware
  - view
  - router.js
- config
  - config.default.js
  - plugin.js
- build /* webpack */    
  - src
  - webpack.config.js
- ...
Egg
Quick Usage
npm i egg-init -g
egg-init egg-example --type=simple
cd egg-example
npm i
npm run dev
The server listens on 7001.
See egg for more detail.
Configconfig/config.default.js default content
'use strict';
const path = require('path');
module.exports = appInfo => {
  const config = exports = {};

  // use for cookie sign key, should change to your own and keep security
  config.keys = appInfo.name + 'your_keys';

  // add your config here

  return config;
}
middleware
// add your middleware
config.middleware = ['permission','middleware2'];
// your middleware config, which will be the param 'options' you can access later
config.permission = {
  excludeUrl: {
    'ALL': ['/', '/login', '/register'],
    'POST': [],
    'GET': ['/register/checkUserid'],
  },
}
plugin
  • ejs
  • // egg-view-view plugin
    config.view = {
      mapping: {
        '.html': 'ejs',
      },
      defaultViewEngine: 'ejs'
    };
    
  • モングース
  • // egg-mongoose plugin, [What is egg-mongoose](https://www.npmjs.com/package/egg-mongoose)
    config.mongoose = {
      client: {
        url: 'mongodb://127.0.0.1/chat',
        options: {},
      },
    };
    
  • egg security and session
  • // egg security solutions, see [egg Security](https://eggjs.org/en/core/security.html) for detail
    // you have to send csrftoken before your request
    config.security = {
      csrf: {
        headerName: 'x-csrf-token', //    header    CSRF token        x-csrf-token
      },
    };
    
    config.session = {
      key: 'EGG_SESS',
      maxAge: 24 * 3600 * 1000, // 1  
      httpOnly: true,
      encrypt: true,
      renew: true,
    };
    
    use csrfAjax.js to bind before Send event to ajax.
    import Cookies from 'js-cookie'
    const csrftoken = Cookies.get('csrfToken');
    
    function csrfSafeMethod(method) {
      // these HTTP methods do not require CSRF protection
      return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }
    $.ajaxSetup({
      beforeSend: function (xhr, settings) {
        if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
          xhr.setRequestHeader('x-csrf-token', csrftoken);
        }
      },
    });
    
  • sockete.io
  • // see [egg Socket.IO](https://eggjs.org/en/tutorials/socketio.html) for detail
    config.io = {
      namespace: {
        '/allChat': {
          connectionMiddleware: ['auth'],
          packetMiddleware: [],
        }
      }
    };
    
  • global config
  • // define global configuration and variabels yourself
    config.appConfig = {
      defaultAvatarArr: ['/public/image/default_avatar.jpg', '/public/image/default_avatar_1.jpg', '/public/image/default_avatar_2.jpg',],
      imagePublicPath: '/public/image',
      defaultChatRoom: 'default',
      defaultChatRoomMax: 999,
      messageSplitLimit: 8,
      allowReconnectionTime: 10 * 1000,
    };
    
    Upload File and Form
    use Formidable in egg
    // app/controller/xxx.js
    const formidable = require('formidable');
    const path = require('path');
    
    // It's ok use formidable to wait the `form` end event to send response, but wrong in egg.
    // You have to return a promise.
    
    // handle function
    async formParse(req, filename, config) {
      const form = new formidable.IncomingForm();
      return new Promise((resolve, reject) => {
        form.uploadDir = path.join(process.cwd(), 'app', config.appConfig.imagePublicPath);
        form.parse(req);
        form.on('fileBegin', (name, file) => {
          file.name = filename
          file.path = path.join(process.cwd(), 'app', config.appConfig.imagePublicPath, filename)
        })
        form.on('end', () => {
          resolve(path.join(config.appConfig.imagePublicPath, filename));
        })
        form.on('error', (err) => {
          reject('-1');
        })
      });
    }
    
    // usage
    // const result = await this.formParse(ctx.req, filename, config);
    
    モングース
    use mongoose in egg
    // take login for example
    
    // app/model/user.js
    module.exports = app => {
    const mongoose = app.mongoose;
    const Schema = mongoose.Schema;
    
    const UserSchema = new Schema({
      _id: {
        type: String,
        unique: true,
        // mongoose aliases
        alias: 'userid',
      },
      username: String,
      password: String,
    };
    };
    
    // app/service/user.js
    const Service = require('egg').Service;
    class UserService extends Service {
      async findOneByUserid(userid) {
        let docs = await this.ctx.model.User.findOne({
          _id: userid,
        });
        if (docs) {
          // mongoose virtuals
          return docs.toObject({
            virtuals: true
          });
        }
        return docs;
      }
    }
    
    // app/controller/user.js
    const Controller = require('egg').Controller;
    class UserController extends Controller {
      async login() {
        const {
          ctx,
          service
        } = this;
        let {
          userid,
          password,
          rememberMe
        } = ctx.request.body;
        let userinfo = await service.user.findOneByUserid(userid);
        if (userinfo && userinfo.password == password) {
          ctx.session.user = Object.assign(userinfo, {
            ipAddress: ctx.request.ip
          });
          if (rememberMe) ctx.session.maxAge = ms('30d');
          return ctx.body = '1';
        }
        return ctx.body = '-1';
      }
    }
    module.exports = UserController;
    
    $or,$and
    model.find({
      $or:[{
        criterion_1: 1
      },{
        $and: [{
          criterion_2: 2
        },{
          criterion_3: 3
        }]
      }]
    });
    
    alias and virtualsAliaes and Virtual for more detail.
    const UserSchema = new Schema({
        _id: {
          type: String,
          unique: true,
          alias: 'userid',
        },
    });
    // you can use _id or userid in getter and setter
    // assume `doc` is the query doc
    console.log(doc.toObject({ virtuals: true })); // { _id: 'xxx', userid: 'xxx' }
    console.log(doc.userid); // "xxx"
    
    ポプラテ
    Poputlate for more detail.You must set ref to _id、other fields are not avalible;
    Note:ObjectId,Number,String,and Buffer are valid for use.However,you shound use Object Id unless you are and have a good reason for dong so.
    // take `User` model and `Message` model for example,
    // which user have more than one messages,
    // `Message` have attributes that ref to `_id` alias `userid` here.
    
    // app/model/message.js
    module.exports = app => {
      const mongoose = app.mongoose;
      const Schema = mongoose.Schema;
    
      const MessageSchema = new Schema({
        from: {
          type: String,
          ref: 'User',
        },
        to: {
          type: String,
          ref: 'User',
        },
      });
      return mongoose.model('Message', MessageSchema);
    
      // now you can access user infomation while query message
      // Message.find(citerion).populate('from','userid username').populate('to','userid username');
    }
    
    Find by pagination
    // in app/service/message.js
    async findByPagination(criterion, limit, page) {
      const total = await this.ctx.model.Message.count(criterion);
      const totalPageNum = parseInt(total / limit);
      if (page > totalPageNum) {
        return null;
      }
      const start = limit * page;
      const queryArr = await this.ctx.model.Message
        .where(criterion)
        // sort by date desc
        .sort({
          date: -1
        })
        .limit(limit)
        .skip(start)
        .populate('from','userid username avatar')
        .populate('to','userid username avatar');
      let resArr = [];
      queryArr.forEach((doc)=>{
        resArr.push(doc.toObject({virtuals: true}));
      });
      return resArr;
    }
    
    ヘルパー
    get remote IP
    // ctx.request.ip
    
    // use socket.io to get ip address
    // socket.handshake.address
    // maybe you need to parse the IP address
    parseIPAddress(ip) {
      if (ip.indexOf('::ffff:') !== -1) {
        ip = ip.substring(7);
      }
      return ip;
    }
    
    Sockett.IO
    use sockete.io in egg
  • controller extens app.controller
  • middleware
  • // app/io/middleware/auth.js
    module.exports = () => {
      return async (ctx, next) => {
        const { app, socket, logger, helper, service } = ctx;
        const sid = socket.id;
        const nsp = app.io.of('/allChat');
        const query = socket.handshake.query;
      }
    }
    
    get socketid
    // by the socket object, get the connector id
    const sid = socket.id;
    
    // get online user socketid in room by the namespace adapter, except current connector id
    nsp.adapter.clients(rooms, (err, clients) => {
      //clients is an socketid arrs
      logger.info('#online', clients);
    }
    
    disconnect or refreshイベント
    When user refresh current page in browser,it will trigger disconnect and leave event,the n the join event.Try to use a timer(setTimeout function)to solive is proble,but might not be good solution.
    See auth.js.
    send message to users in room
    socket.to(room).emit('room_message', message);
    
    send prvate message to user
    // sender
    let userinfo = await service.user.findOneByUserid(ctx.session.user.userid);
    message.from = {
      userid: userinfo.userid,
      username: userinfo.username,
    };
    // receiver
    let toUserinfo = await service.user.findOneByUserid(message.to);
    if (!toUserinfo) {
      socket.emit(socket.id, helper.parseMsg('warning', {
        type: 'warning',
        content: '      =_=!',
      }));
    } else {
      message.to = {
        userid: toUserinfo.userid,
        username: toUserinfo.username,
        socketid: toUserinfo.socketid,
      };
      let messageParsed = helper.parseMsg('private_message', message);
      socket.to(message.to.socketid).emit(message.to.socketid, messageParsed);
    }
    
    use sockete.io in front end
    instead of
    const socketClient = require('socket.io-client');
    const allChat = socketClient(config.host.concat('/allChat'), {
      query: {
        room: config.socket.room,
      },
      transports: ['websocket'],
    });
    
    allChat.on("connect", () => {
      const sid = allChat.id;
      console.log('#connected', sid, allChat);
      //      id     p2p   
      allChat.on(sid, msg => {
        console.log('#receive,', msg);
        switch (msg.data.action) {
          case 'deny':
          case 'welcome':
          case 'warning':
          case 'private_message':
        }
      });
    });
    
    //     
    allChat.on('disconnect', msg => {
      console.log('#disconnect', msg);
    });
    
    allChat.on('disconnecting', () => {
      console.log('#disconnecting');
    });
    
    allChat.on('error', () => {
      console.log('#error');
    });
    
    Tips
  • the socktid get from Namespace.adapter.clients contain the room #room at the head.
  • get room the current connector jined、Object.keys(socket.rooms)[0];
  • Webpack 4.0
    you can use egg-webpack in egg or use webpack-cli.
    see webpack.co.nfig.js.
    spilt Chunks
    optimization: {
      splitChunks: {
        cacheGroups: {
          common: {
            name: "common",
            chunks: "all",
            minSize: 1,
            priority: 0,
            minChunks: 2,
          },
          vendors: {
            name: "vendors",
            test: /[\\/]node_modules[\\/]/,
            chunks: "all",
            priority: 10
          }
        }
      },
    },
    
    extract-text-webpack-plugin
    トsupport webpack 4.0,install extract-text-webpack-plugin@next
    //module rules
    {
      test: /\.css/,
      use: ExtractTextPlugin.extract({
        fallback: 'style-loader',
        use: ['css-loader']
      }),
    },
    {
      test: /\.(less)/,
      use: ExtractTextPlugin.extract({
        fallback: 'style-loader',
        use: [{
            loader: 'css-loader',
            options: {
              alias: {
                '@': path.resolve(staticPath, 'image') // '~@/logo.png'     ,   src/img/logo.png    
              }
            },
          },
          'postcss-loader',
          {
            loader: 'less-loader',
            options: {
              lessPlugins: [
                new LessPluginCleanCSS({
                  advanced: true,
                }),
              ],
            },
          }
        ]
      }),
    },
    
    new ExtractTextPlugin({
      filename: 'css/[name].bundle-[hash].css',
    }),
    
    import jquery
    new webpack.ProvidePlugin({
        "$": "jquery",
        "jQuery": "jquery",
        "window.jQuery": "jquery",
        // underscore
        "_": 'underscore'
      }),
    
    Utils
    タッチイベント
    var myTouchEvent = function () {
      var swip_time = 300,
        swip_dis = 30,
        point_start,
        point_end,
        time_start,
        time_end,
        //1   2   3   4 
        result;
      if ("ontouchstart" in window) {
        var startEvt = "touchstart",
          moveEvt = "touchmove",
          endEvt = "touchend";
      } else {
        var startEvt = "mousedown",
          moveEvt = "mousemove",
          endEvt = "mouseup";
      }
      var getPos = function (e) {
        var touches = e.touches;
        if (touches && touches[0]) {
          return {
            x: touches[0].clientX,
            y: touches[0].clientY
          };
        }
        return {
          x: e.clientX,
          y: e.clientY
        };
      }
      var getDistance = function (p1, p2) {
        return parseInt(Math.sqrt(Math.pow(Math.abs(p1.x - p2.x), 2) + Math.pow(Math.abs(p1.y - p2.y), 2)));
      }
      var getDirection = function (p1, p2) {
        var angle = Math.atan2(p1.y - p2.y, p2.x - p1.x) * 180 / Math.PI;
        if (angle <= 45 && angle >= -45) return "right";
        if (angle >= 45 && angle <= 135) return "up";
        if (angle >= 135 || angle <= -135) return "left";
        if (angle <= -45 && angle >= -135) return "down";
      }
      var startEvtHandle = function (e) {
        var pos = getPos(e);
        var touches = e.touches;
        if (!touches || touches.length == 1) {
          point_start = getPos(e);
          time_start = new Date().getTime();
        }
        //      
        $("#notification").css({
          height: 0,
          overflow: "hidden"
        }).html("      ").show();
        point_end = pos;
      }
      var transformYPre = 0;
      var moveEvtHandle = function (e) {
        point_end = getPos(e);
        var y = point_end.y - point_start.y;
        if (y > 0 && y > 80) {
          y = 80;
        } else if (y < 0) {
          y = 0;
        }
        transformYPre += y - transformYPre;
        $("#listPanel").css({
          transition: "all 1s",
          transform: "translate3d(0," + transformYPre + "px,0)"
        })
        $("#notification").css({
          transition: "all 1s",
          height: transformYPre + "px",
          lineHeight: transformYPre + "px"
        })
        e.preventDefault();
      }
      var endEvtHandle = function (e) {
        time_end = new Date().getTime();
        var dis = getDistance(point_start, point_end);
        var time = time_end - time_start;
        //      
        if (dis >= swip_dis && time >= swip_time) {
          var dir = getDirection(point_start, point_end),
            disY = point_end.y - point_start.y,
            disX = point_end.x - point_start.x;
          if (disY >= 80 && dir == "down") {
            result = 3;
            //      
            loadMessage(++page);
            console.log('   ');
            //          30s
            var timer = setInterval(function () {
              if (loadMessageFlag) {
                $("#listPanel").css({
                  transition: "all 1s",
                  transform: "translate3d(0,0,0)"
                })
                //      
                if (loadMessageFlag == 1) $("#notification").html("Success");
                else if (loadMessageFlag == 2) $("#notification").html("       =_=");
                loadMessageFlag = 0;
                setTimeout(function () {
                  $("#notification").css({
                    height: "30px",
                    lineHeight: "30px"
                  }).html("").hide();
                  clearInterval(timer);
                }, 300);
              }
            });
            //30s   
            setTimeout(function () {
              clearInterval(timer);
              //      
              $("#notification").html("Failed");
              loadMessageFlag = false;
              setTimeout(function () {
                $("#notification").css({
                  height: "30px",
                  lineHeight: "30px"
                }).html("").hide();
              }, 300);
            }, 31000);
          } else if (disX >= 80 && dir == "right") {
            result = 2;
          } else if (disX < -30 && dir == "left") {
            result = 4;
          } else if (disY < -30 && dir == "up") {
            $("#listPanel").scrollTop(parseInt(Math.abs(point_end.y - point_start.y)));
            result = 1;
          }
        } else {
          $("#listPanel").css({
            transition: "all 1s",
            transform: "translate3d(0,0,0)"
          }).animate({
            scrollTop: '30px'
          }, 300);
          $("#notification").css({
            height: "30px",
            lineHeight: "30px"
          }).html("").hide();
        }
      }
    
      $("#listPanel").on(startEvt, function (e) {
        if ($(this).scrollTop() <= 0) {
          startEvtHandle(e);
          $(this).on(moveEvt, moveEvtHandle);
          $(this).on(endEvt, function (e) {
            endEvtHandle(e);
            $(this).off(moveEvt).off(endEvt);
          });
        }
      })
    }
    
    Sroll To Bottom
    const scrollToBottom = () => {
      let scrollHeight = $("#listPanel")[0].scrollHeight - $("#listPanel")[0].clientHeight;
      $("#listPanel").animate({
        scrollTop: scrollHeight
      }, 300);
    };