├── sock-app ├── .gitignore ├── .dockerignore ├── nodemon.json ├── Dockerfile ├── tsconfig.json ├── package.json ├── src │ ├── TestClient.ts │ └── App.ts └── package-lock.json ├── article ├── images │ ├── 1549048945334.png │ ├── 1549139768940.png │ ├── 1549139882747.png │ ├── 1549140491881.png │ ├── 1549140775997.png │ ├── 1549231953897.png │ ├── 1549232309776.png │ ├── 1549233023980.png │ ├── 1549237952673.png │ ├── 1549239361416.png │ ├── 1549239818663.png │ ├── 1550174092066.png │ ├── 1550174595491.png │ ├── 1550174943313.png │ └── 1550233610561.png ├── sources │ ├── 2.pu │ ├── 4.pu │ ├── 1.pu │ └── 3.pu ├── includes │ └── style.iuml └── article.md ├── docker-compose.yml └── README.md /sock-app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /sock-app/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /article/images/1549048945334.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549048945334.png -------------------------------------------------------------------------------- /article/images/1549139768940.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549139768940.png -------------------------------------------------------------------------------- /article/images/1549139882747.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549139882747.png -------------------------------------------------------------------------------- /article/images/1549140491881.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549140491881.png -------------------------------------------------------------------------------- /article/images/1549140775997.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549140775997.png -------------------------------------------------------------------------------- /article/images/1549231953897.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549231953897.png -------------------------------------------------------------------------------- /article/images/1549232309776.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549232309776.png -------------------------------------------------------------------------------- /article/images/1549233023980.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549233023980.png -------------------------------------------------------------------------------- /article/images/1549237952673.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549237952673.png -------------------------------------------------------------------------------- /article/images/1549239361416.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549239361416.png -------------------------------------------------------------------------------- /article/images/1549239818663.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1549239818663.png -------------------------------------------------------------------------------- /article/images/1550174092066.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1550174092066.png -------------------------------------------------------------------------------- /article/images/1550174595491.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1550174595491.png -------------------------------------------------------------------------------- /article/images/1550174943313.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1550174943313.png -------------------------------------------------------------------------------- /article/images/1550233610561.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Alster/distributed-nodejs-chat-with-redis/HEAD/article/images/1550233610561.png -------------------------------------------------------------------------------- /sock-app/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node ./src/App.ts" 6 | } -------------------------------------------------------------------------------- /sock-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.12-alpine 2 | WORKDIR /usr/src/app 3 | # COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"] 4 | RUN npm i nodemon@1.18.9 typescript@3.2.2 ts-node@7.0.1 -g 5 | # RUN npm install 6 | # COPY . . 7 | EXPOSE 3000 8 | CMD nodemon -------------------------------------------------------------------------------- /article/sources/2.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include ./article/includes/style.iuml 3 | 4 | hide footbox 5 | actor UserA 6 | participant Node1 7 | participant Node2 8 | actor UserB 9 | 10 | UserA -> Node1: Шлет сообщение UserB 11 | Node1 -> Node2: Пересылка нужной ноде 12 | Node2 -> UserB: Отправка конечному пользователю 13 | 14 | 15 | @enduml -------------------------------------------------------------------------------- /article/sources/4.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include ./article/includes/style.iuml 3 | 4 | hide footbox 5 | actor UserA 6 | participant Node1 7 | collections Redis 8 | participant Node2 9 | actor UserB 10 | 11 | UserA -> Node1: Шлет сообщение UserB 12 | Node1 -> Redis: Пересылка в редис 13 | Redis -> Node2: Пересылка нужной ноде 14 | Node2 -> UserB: Отправка конечному пользователю 15 | 16 | 17 | @enduml -------------------------------------------------------------------------------- /article/sources/1.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include ./article/includes/style.iuml 3 | 4 | hide footbox 5 | actor UserA 6 | participant Node1 7 | collections Master 8 | participant Node2 9 | actor UserB 10 | 11 | UserA -> Node1: Шлет сообщение UserB 12 | Node1 -> Master: Опрашивание мастера 13 | Master -> Node1: Ответ 14 | Node1 -> Node2: Пересылка нужной ноде 15 | Node2 -> UserB: Отправка конечному пользователю 16 | 17 | 18 | @enduml -------------------------------------------------------------------------------- /article/sources/3.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include ./article/includes/style.iuml 3 | 4 | hide footbox 5 | actor UserA 6 | participant Node1 7 | collections Master 8 | collections Redis 9 | participant Node2 10 | actor UserB 11 | 12 | UserA -> Node1: Шлет сообщение UserB 13 | Node1 -> Master: Опрашивание мастера 14 | Master -> Node1: Ответ 15 | Node1 -> Redis: Пересылка нужному редису 16 | Redis -> Node2: Пересылка нужной ноде 17 | Node2 -> UserB: Отправка конечному пользователю 18 | 19 | 20 | @enduml -------------------------------------------------------------------------------- /sock-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "removeComments": true, 5 | "preserveConstEnums": true, 6 | "rootDirs": [ 7 | "./src", 8 | ], 9 | "experimentalDecorators": true, 10 | "strictNullChecks": true, 11 | "noImplicitThis": true, 12 | "noImplicitReturns": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | 16 | "outDir": "./build", 17 | "sourceMap": true, 18 | "typeRoots": [ 19 | "node_modules/@types" 20 | ], 21 | "target": "ES2017", 22 | }, 23 | } -------------------------------------------------------------------------------- /sock-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sock-app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "cli-color": "^1.4.0", 13 | "notepack.io": "^2.2.0", 14 | "redis": "^2.8.0", 15 | "socket.io": "^2.2.0", 16 | "xxhash": "^0.2.4" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^10.12.19", 20 | "@types/redis": "^2.8.10", 21 | "@types/socket.io": "^2.1.2", 22 | "@types/socket.io-client": "^1.4.32", 23 | "socket.io-client": "^2.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /article/includes/style.iuml: -------------------------------------------------------------------------------- 1 | @startuml 2 | skinparam shadowing false 3 | skinparam default{ 4 | FontName Roboto 5 | } 6 | skinparam sequence { 7 | ArrowColor #58a2e0 8 | ActorBorderColor Black 9 | LifeLineBorderColor blue 10 | LifeLineBackgroundColor #89c5c8 11 | 12 | ParticipantBorderColor #58a2e0 13 | ParticipantBackgroundColor White 14 | 15 | ActorBackgroundColor #89c5c8 16 | } 17 | skinparam activity { 18 | StartColor #89c5c8 19 | EndColor #89c5c8 20 | BackgroundColor white 21 | BorderColor #58a2e0 22 | FontSize 14 23 | Arrow { 24 | Color #89c5c8 25 | } 26 | } 27 | skinparam note { 28 | BackgroundColor #58a2e0 29 | BorderColor white 30 | FontColor white 31 | } 32 | 33 | 34 | skinparam package { 35 | BorderColor #89c5c8 36 | FontColor #58a2e0 37 | } 38 | skinparam collections { 39 | BorderColor #89c5c8 40 | FontColor #58a2e0 41 | BackgroundColor #58a2e0 42 | } 43 | skinparam interface { 44 | backgroundColor white 45 | borderColor #89c5c8 46 | } 47 | skinparam component { 48 | FontSize 13 49 | BackgroundColor<> white 50 | BorderColor<> #89c5c8 51 | BorderColor black 52 | BackgroundColor white 53 | } 54 | @enduml -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | 3 | services: 4 | msg-bus-0: 5 | image: "redis:5.0-alpine" 6 | # ports: 7 | # - 6379:6379 8 | networks: 9 | - esnet 10 | 11 | msg-bus-1: 12 | image: "redis:5.0-alpine" 13 | # ports: 14 | # - 6379:6379 15 | networks: 16 | - esnet 17 | 18 | dialogs-cache: 19 | image: "redis:5.0-alpine" 20 | # ports: 21 | # - 6379:6379 22 | networks: 23 | - esnet 24 | 25 | sock-app-0: 26 | image: "sock-app:latest" 27 | ports: 28 | - 3001:3000 29 | networks: 30 | - esnet 31 | volumes: 32 | - ./sock-app:/usr/src/app 33 | depends_on: 34 | - dialogs-cache 35 | - msg-bus-0 36 | - msg-bus-1 37 | 38 | sock-app-1: 39 | image: "sock-app:latest" 40 | ports: 41 | - 3002:3000 42 | networks: 43 | - esnet 44 | volumes: 45 | - ./sock-app:/usr/src/app 46 | depends_on: 47 | - dialogs-cache 48 | - msg-bus-0 49 | - msg-bus-1 50 | 51 | # sock-nginx: 52 | # image: "sock-nginx:latest" 53 | # ports: 54 | # - 3333:3333 55 | # depends_on: 56 | # - sock-app-0 57 | # - sock-app-1 58 | # networks: 59 | # - esnet 60 | # volumes: 61 | # - ./sock-nginx:/etc/nginx 62 | 63 | networks: 64 | esnet: -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Распределенный чат на Node.JS и Redis 2 | 3 | - [Распределенный чат на Node.JS и Redis](#%D1%80%D0%B0%D1%81%D0%BF%D1%80%D0%B5%D0%B4%D0%B5%D0%BB%D0%B5%D0%BD%D0%BD%D1%8B%D0%B9-%D1%87%D0%B0%D1%82-%D0%BD%D0%B0-nodejs-%D0%B8-redis) 4 | - [Установка и запуск](#%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D0%B8-%D0%B7%D0%B0%D0%BF%D1%83%D1%81%D0%BA) 5 | - [Команды](#%D0%BA%D0%BE%D0%BC%D0%B0%D0%BD%D0%B4%D1%8B) 6 | - [Скринкасты](#%D1%81%D0%BA%D1%80%D0%B8%D0%BD%D0%BA%D0%B0%D1%81%D1%82%D1%8B) 7 | 8 | ## Установка и запуск 9 | 10 | Установка 11 | 12 | ```bash 13 | npm i typescript ts-node -g 14 | docker-compose -f "docker-compose.yml" up -d --build 15 | ``` 16 | 17 | Серверы доступны на портах **3001** и **3002**, их логи можно посмотреть: 18 | 19 | ```bash 20 | docker logs distributed-nodejs-chat-with-redis_sock-app-0_1 --tail 50 -f 21 | ``` 22 | 23 | ..и: 24 | 25 | ```bash 26 | docker logs distributed-nodejs-chat-with-redis_sock-app-1_1 --tail 50 -f 27 | ``` 28 | 29 | Чтобы поднять клиента заходим в папку `sock-app` и выполняем: 30 | 31 | ```bash 32 | ts-node src/TestClient.ts %PORT% 33 | ``` 34 | 35 | ## Команды 36 | 37 | Клиент являет собой интерактивную консоль с заданными командами 38 | 39 | | Description | Call | Full name | 40 | | -- |:--| --:| 41 | | Авторизоваться | a(`user_name`) | **A**uth | 42 | | Разлогиниться | lo() | **L**og **O**ut | 43 | | Обновить список подписок | subs([`user_to_unsubscribe`], [`user_to_subscribe`]) | **Subs**cribtions | 44 | | Узнать кто из списка пользователей онлайн | o([`user_name`]) | **O**nline | 45 | | Написать сообщение в комнату | w(`room`, `message`) | **W**rite | 46 | | Создать комнату | cc(`room`) | **C**reate **C**hat | 47 | | Добавить участника с комнаты | am(`room`, `member`) | **A**dd **M**ember | 48 | | Удалить участника в комнату | rm(`room`, `member`) | **R**emove **M**ember | 49 | 50 | ## Скринкасты 51 | 52 | Чат с двумя участниками 53 | 54 | [![asciicast](https://asciinema.org/a/227099.svg)](https://asciinema.org/a/227099) 55 | 56 | Онлайн статус 57 | 58 | [![asciicast](https://asciinema.org/a/227104.svg)](https://asciinema.org/a/227104) 59 | 60 | Множество участников 61 | 62 | [![asciicast](https://asciinema.org/a/227110.svg)](https://asciinema.org/a/227110) -------------------------------------------------------------------------------- /sock-app/src/TestClient.ts: -------------------------------------------------------------------------------- 1 | const clc = require('cli-color') 2 | import * as IOClient from 'socket.io-client' 3 | 4 | const connStr = `http://localhost:${process.argv[2] || '3333'}` 5 | console.log(`Connecting to ${connStr}`) 6 | const sock = IOClient(connStr, { 7 | transports: ['websocket'] 8 | }) 9 | 10 | sock.on('connect', (err)=> { 11 | console.log(clc.blue('Connected')) 12 | 13 | sock.on('write', data=> 14 | console.log(`${clc.green('MSG')} from ${data.dialog}: ${data.msg}`)) 15 | 16 | sock.on('memberAdded', data=> 17 | console.log(`Member ${data.member} ${clc.green('added')} to ${data.dialog}`)) 18 | 19 | sock.on('memberRemoved', data=> 20 | console.log(`Member ${data.member} ${clc.red('removed')} from ${data.dialog}`)) 21 | 22 | sock.on('statusChange', data=> 23 | console.log(`User ${data.user} goes ${data.online ? clc.yellow("online") : clc.red("offline")}`)) 24 | 25 | const Repl = require('repl') 26 | const repl = Repl.start({ 27 | prompt: '> ', 28 | useColors: true, 29 | replMode: Repl.REPL_MODE_STRICT, 30 | ignoreUndefined: true 31 | }) 32 | 33 | repl.context.a = (ruid)=> { 34 | sock.emit('auth', ruid.toString(), uid=> { 35 | console.log(`${clc.green('Authorized')} as ${uid}`) 36 | }) 37 | } 38 | 39 | repl.context.lo = ()=> { 40 | sock.emit('logout') 41 | } 42 | 43 | repl.context.subs = (unsub, sub)=> { 44 | sock.emit('updStatSubs', {unsub, sub}) 45 | } 46 | 47 | repl.context.o = (users)=> { 48 | sock.emit('whoIsOnline', users, (res)=> console.log(`${clc.blue('Online users')}: ${res.join(', ')}`)) 49 | } 50 | 51 | repl.context.w = (dialog, msg)=> { 52 | sock.emit('write', { 53 | dialog: dialog.toString(), msg: msg.toString() 54 | }) 55 | } 56 | 57 | repl.context.cc = (dialog)=> { 58 | sock.emit('createChat', { 59 | dialog: dialog.toString() 60 | }) 61 | } 62 | 63 | repl.context.am = (dialog, member)=> { 64 | sock.emit('addMember', { 65 | dialog: dialog.toString(), 66 | member: member.toString() 67 | }) 68 | } 69 | 70 | repl.context.rm = (dialog, member)=> { 71 | sock.emit('removeMember', { 72 | dialog: dialog.toString(), 73 | member: member.toString() 74 | }) 75 | } 76 | }) -------------------------------------------------------------------------------- /sock-app/src/App.ts: -------------------------------------------------------------------------------- 1 | const clc = require('cli-color') 2 | import * as msgpack from 'notepack.io' 3 | 4 | //region shard indexing 5 | import * as xxhash from 'xxhash' 6 | 7 | /** 8 | * Интерфейс отвечающий за разбивку редисов по хешу, 9 | * именно через него мы получаем доступ к нужным клиентам 10 | */ 11 | class HashPartitoning{ 12 | //Список подключений к редису 13 | private readonly servers:RedisClient[] = [] 14 | 15 | constructor(opts: ClientOpts[]){ 16 | opts.forEach(o=> this.servers.push(getRedis(o))) 17 | } 18 | 19 | //Получить клиент по ключу 20 | serverOf(key:string):RedisClient{ 21 | return this.servers[this.indexOf(key)] 22 | } 23 | 24 | //Получить индекс клиента по ключу 25 | indexOf(key:string|number):number{ 26 | if (typeof key == 'number') key = key.toString() 27 | return Math.floor( 28 | xxhash.hash64( 29 | Buffer.from(key, 'utf8'), 0x2B0352DF, 'buffer' 30 | ).readUInt32BE() 31 | % this.servers.length 32 | ) 33 | } 34 | 35 | //Получить клиент по индексу 36 | getServer(index:number):RedisClient{ 37 | return this.servers[index] 38 | } 39 | 40 | //Количество клиентов 41 | get length():number{ 42 | return this.servers.length 43 | } 44 | } 45 | 46 | //endregion 47 | 48 | //region connect to redis 49 | import { RedisClient, ClientOpts, Multi } from 'redis' 50 | 51 | //Что-то типа фабрики клиентов 52 | function getRedis(opts: ClientOpts): RedisClient{ 53 | const c = new RedisClient(opts) 54 | c.on('error', err=> console.error(err)) 55 | return c; 56 | } 57 | ///endregion 58 | 59 | type EventCallback = (data:unknown, channel?:string)=> void; 60 | 61 | /** 62 | * Интерфейс общения между клиентами, 63 | * именно тут мы подписываемся на события и пушим свои 64 | */ 65 | class MessagesBus{ 66 | 67 | //Хранилище подписок 68 | private readonly evt: {[index:string]:EventCallback[]}[] = []; 69 | 70 | //Клиенты для подписок 71 | private readonly sub: HashPartitoning; 72 | 73 | //Клиенты для пуша 74 | private readonly pub: HashPartitoning; 75 | 76 | constructor(opts:ClientOpts[]){ 77 | this.sub = new HashPartitoning(opts) 78 | this.pub = new HashPartitoning(opts) 79 | 80 | //Навешиваем обработчик для входящих сообщений 81 | for (let i = 0; i < opts.length; i++){ 82 | const e = {} 83 | this.evt.push(e) 84 | this.sub.getServer(i).on("messageBuffer", (chBuff:Buffer, data:Buffer)=> { 85 | const channel = chBuff.toString() 86 | if (!e[channel]) return; 87 | console.log(`${clc.bold(clc.green("REC "+i))} ${clc.yellow(channel)} ${JSON.stringify(msgpack.decode(data))}`) 88 | e[channel].forEach(c=> c(msgpack.decode(data), channel)) 89 | }) 90 | } 91 | } 92 | 93 | /** 94 | * Подписка на канал 95 | * Можно сколько угодно подписываться на один и тот же канал, 96 | * но реальная подписка в клиенте редиса на указанный канал 97 | * производится только один раз, если до этого не было локальных подписчиков 98 | */ 99 | join(channel:string, cb:EventCallback):void{ 100 | const i = this.sub.indexOf(channel) 101 | const e = this.evt[i] 102 | if (!e[channel]) e[channel] = [] 103 | const c = e[channel] 104 | 105 | //Подписываемся на клиенте 106 | if (c.length == 0) { 107 | this.sub.getServer(i).subscribe(channel) 108 | console.log(`${clc.bold(clc.yellow("SUB "+i))} ${clc.yellow(channel)}`) 109 | } 110 | 111 | //Подписываемся локально 112 | if (!!~c.indexOf(cb)) throw new Error(`Duplicate callback for "${channel}"`) 113 | c.push(cb) 114 | console.log(`${clc.bold(clc.yellow("JOIN "+i))} ${clc.yellow(channel)}`) 115 | } 116 | 117 | /** 118 | * Отписка от канала 119 | * Можно сколько угодно отписываться от одного и того же канала, 120 | * но реальная отписка в клиенте редиса на указанный канал 121 | * производится только один раз, если уже действительно нет локальных подписчиков 122 | */ 123 | leave(channel:string, cb:EventCallback):void{ 124 | const i = this.sub.indexOf(channel) 125 | const e = this.evt[i] 126 | if (!e[channel]) throw new Error(`Channel "${channel}" does not have any listeners`) 127 | const c = e[channel] 128 | const cb_index = c.indexOf(cb) 129 | if (!~cb_index) throw new Error(`Callback for "${channel}" does not exists`) 130 | 131 | //Удаляем подписку локально 132 | c.splice(cb_index, 1) 133 | console.log(`${clc.bold(clc.yellow("LEAVE"+i))} ${clc.yellow(channel)}`) 134 | if (c.length > 0) return; 135 | 136 | //Удаляем подписку полностью 137 | this.sub.getServer(i).unsubscribe(channel) 138 | delete e[channel] 139 | console.log(`${clc.bold(clc.yellow("UNSUB"+i))} ${clc.yellow(channel)}`) 140 | } 141 | 142 | /** 143 | * Пушим сообщение в канал 144 | */ 145 | publish(channel:string, data:unknown):void{ 146 | const i = this.sub.indexOf(channel) 147 | console.log(`${clc.bold(clc.blue("PUB "+i))} ${clc.yellow(channel)} ${JSON.stringify(data)}`) 148 | this.pub.getServer(i).publish(channel, msgpack.encode(data)) 149 | } 150 | 151 | /** 152 | * Получить количество подписчиков на указанные каналы 153 | */ 154 | async numSubs(channels:string[]):Promise<{[index:string]:number}>{ 155 | const sep:{[index:number]:string[]} = channels.reduce((p, c)=> { 156 | const i = this.sub.indexOf(c) 157 | p[i] = p[i] || [] 158 | p[i].push(c) 159 | return p 160 | }, {}) 161 | return (await Promise.all<{[index:string]:number}>(Object.keys(sep) 162 | .map(i=> new Promise((resolve, reject)=> { 163 | this.pub.getServer(parseInt(i)).pubsub("NUMSUB", sep[i], (err, whyIAmANumber)=> { 164 | if (err) reject(err) 165 | const s = whyIAmANumber 166 | const co = {} 167 | while(s.length > 0){ 168 | const k = s.shift() 169 | const v = s.shift() 170 | if (!k || !v) continue; 171 | co[k] = v 172 | } 173 | resolve(co) 174 | }) 175 | })))).reduce((p, c)=> { 176 | return {...p, ...c} 177 | }, {}) 178 | } 179 | } 180 | 181 | const bus = new MessagesBus([{ 182 | host: 'msg-bus-0', 183 | port: 6379 184 | }, { 185 | host: 'msg-bus-1', 186 | port: 6379 187 | }]) 188 | 189 | //endregion 190 | 191 | //region dialogs 192 | 193 | /** 194 | * Интерфейс отвечающий за работу с комнатами (диалогами) 195 | */ 196 | class DialogsConrtoller{ 197 | 198 | private readonly dcache:HashPartitoning; 199 | 200 | constructor(){ 201 | this.dcache = new HashPartitoning([{ 202 | host: 'dialogs-cache', 203 | port: 6379 204 | }]) 205 | } 206 | 207 | /** 208 | * Получить диалог по идентификатору 209 | */ 210 | async get(id:string):Promise{ 211 | if (!id) throw new Error(`No dialog id passed`) 212 | return await new Promise((resolve, reject)=> { 213 | this.dcache.serverOf(id).smembers(id, (err, item)=> err ? reject(err) : resolve(item)) 214 | }) 215 | } 216 | 217 | /** 218 | * Тоже получение диалога по идентификатору, но с проврекой 219 | * является ли указанный пользователь его участником 220 | */ 221 | async getFor(uid:string, did:string):Promise{ 222 | if (!uid) throw new Error(`No uid passed`) 223 | if (!did) throw new Error(`No dialog id passed`) 224 | const members = await this.get(did) 225 | if (!~members.indexOf(uid)) throw new Error(`User "${uid}" doesn't have access to dialog "${did}"`) 226 | return members; 227 | } 228 | 229 | /** 230 | * Добавить участника в диалог 231 | */ 232 | add(id, member):void{ 233 | this.dcache.serverOf(id).sadd(id, member) 234 | } 235 | 236 | /** 237 | * Удалить участника с диалога 238 | */ 239 | remove(id, member):void{ 240 | this.dcache.serverOf(id).srem(id, member) 241 | } 242 | } 243 | 244 | const dc = new DialogsConrtoller() 245 | 246 | //endregion 247 | 248 | interface Message{ 249 | method: string, 250 | data: string 251 | } 252 | 253 | import * as SocketIO from 'socket.io' 254 | const io = SocketIO(3000, { 255 | // transports: ['websocket'] 256 | }) 257 | 258 | /** 259 | * Оборачивание идентификатора пользователя в имя канала пользователя 260 | */ 261 | function toUserChan(uid:string):string{ 262 | return `u:${uid}` 263 | } 264 | 265 | /** 266 | * Оборачивание идентификатора пользователя в имя канала изменения состояния статуса пользователя 267 | */ 268 | function toOnlineStatChan(uid:string):string{ 269 | return `s:${uid}` 270 | } 271 | 272 | /** 273 | * Извлечение идентификатора пользователя из имени канала 274 | */ 275 | function fromUserChan(channel:string):string{ 276 | return channel.slice(2) 277 | } 278 | 279 | io.on('connection', sock=>{ 280 | //Идентификатор текущего пользователя 281 | let UID = '' 282 | 283 | //Метод отлавивающий входящие сообщения пользователю от редиса 284 | function receiveMessage(data:unknown):void{ 285 | const msg:Message = data 286 | sock.emit(msg.method, msg.data) 287 | } 288 | 289 | sock.on('auth', async (uid, resp)=> { 290 | console.log(`User ${uid} authorized`) 291 | const uchan = toUserChan(uid) 292 | const subs = await bus.numSubs([uchan]) 293 | if (!subs[uchan]) bus.publish(toOnlineStatChan(uid), true) 294 | bus.join(uchan, receiveMessage) 295 | resp(uid) 296 | UID = uid 297 | }) 298 | 299 | /** 300 | * Отписка от канала пользователя, 301 | * уведомление об уходе оффлайн 302 | * если этот человек больше нигде не залогинен 303 | */ 304 | async function logout(){ 305 | if (!UID) return; 306 | const uchan = toUserChan(UID) 307 | bus.leave(uchan, receiveMessage) 308 | const subs = await bus.numSubs([uchan]) 309 | if (!subs[uchan]) bus.publish(toOnlineStatChan(UID), false) 310 | UID = '' 311 | } 312 | 313 | sock.on('logout', async ()=> { 314 | await logout() 315 | }) 316 | 317 | //Список пользователей от которых мы хотим получать обновления об изменении онлайн статуса 318 | const statSubs = new Set() 319 | 320 | //Метод отлавливающий сообщения об изменении онлайн статусов 321 | function receiveStatus(data, channel):void{ 322 | sock.emit('statusChange', { 323 | user: fromUserChan(channel), 324 | online: data 325 | }) 326 | } 327 | 328 | sock.on('updStatSubs', async changes=> { 329 | if (changes.unsub) changes.unsub.forEach(uid=> { 330 | const uchan = toOnlineStatChan(uid) 331 | if (!statSubs.has(uchan)) throw new Error(`You already unsubscribed from ${uid}`) 332 | statSubs.delete(uchan) 333 | bus.leave(uchan, receiveStatus) 334 | }) 335 | if (changes.sub) changes.sub.forEach(uid=> { 336 | const uchan = toOnlineStatChan(uid) 337 | if (statSubs.has(uchan)) throw new Error(`You already subscribed to ${uid}`) 338 | statSubs.add(uchan) 339 | bus.join(uchan, receiveStatus) 340 | }) 341 | }) 342 | 343 | sock.on('createChat', async data=> { 344 | dc.add(data.dialog, UID) 345 | bus.publish(toUserChan(UID), { 346 | method: 'memberAdded', data: { 347 | dialog: data.dialog, 348 | member: UID 349 | } 350 | }) 351 | }) 352 | 353 | sock.on('write', async data=> { 354 | const members = await dc.getFor(UID, data.dialog) 355 | members.forEach(u=> { 356 | bus.publish(toUserChan(u), { 357 | method: 'write', data: data 358 | }) 359 | }) 360 | }) 361 | 362 | sock.on('addMember', async data=> { 363 | const members = await dc.getFor(UID, data.dialog) 364 | dc.add(data.dialog, data.member) 365 | ;[...members, data.member].forEach(u=> { 366 | bus.publish(toUserChan(u), { 367 | method: 'memberAdded', data: data 368 | }) 369 | }) 370 | }) 371 | 372 | sock.on('removeMember', async data=> { 373 | const members = await dc.getFor(UID, data.dialog) 374 | dc.remove(data.dialog, data.member) 375 | members.forEach(u=> { 376 | bus.publish(toUserChan(u), { 377 | method: 'memberRemoved', data: data 378 | }) 379 | }) 380 | }) 381 | 382 | sock.on('whoIsOnline', async (users:string[], resp)=> { 383 | const subs = await bus.numSubs(users.map(u=> toUserChan(u))) 384 | resp(Object.keys(subs).map(uc=> fromUserChan(uc))) 385 | }) 386 | 387 | /** 388 | * Метод очистки от подписок, должен обязательно сррабатывать при отключении пользователя 389 | */ 390 | async function cleanup():Promise{ 391 | statSubs.forEach(s=> bus.leave(s, receiveStatus)) 392 | await logout() 393 | } 394 | 395 | sock.on('disconnect', async (err)=> { 396 | console.log(`Disconnecting ${err}`) 397 | await cleanup() 398 | }) 399 | }) 400 | 401 | process.on('unhandledRejection', (err)=> { 402 | console.error(clc.red(`ERR: ${err.message}`)) 403 | }) -------------------------------------------------------------------------------- /article/article.md: -------------------------------------------------------------------------------- 1 | # Распределенный чат на Node.JS и Redis 2 | 3 | ![Результат пошуку зображень за запитом "голубиная почта"](https://cs4.pikabu.ru/post_img/2015/10/04/5/1443945163_2102700146.jpg) 4 | 5 | Небольшой вопрос/ответ: 6 | 7 | *Для кого это?* Людям, которые мало или вообще не сталкивались с распределенными системами, и которым интересно увидеть как они могут строится, какие существуют паттерны и решения. 8 | 9 | *Зачем это?* Самому стало интересно что и как. Черпал информацию с разных источников, решил выложить в концентрированном виде, ибо в свое время сам хотел бы увидеть подобную работу. По сути это текстовое изложение моих личных метаний и раздумий. Также, наверняка будет много исправлений в комментариях от знающих людей, отчасти это и есть целью написания всего этого именно в виде статьи. 10 | 11 | ## Постановка задачи 12 | 13 | Как сделать чат? Это должно быть тривиальной задачей, наверное каждый второй бекендер пилил свой собственный, так же как игровые разработчики делают свои тетрисы/змейки и т. п. Я взялся за такой, но чтоб было интереснее он должен быть готов к захвату мира, чтоб мог выдерживать сотниллиарды активных пользователей и вообще был неимоверно крут. Из этого исходит ясная потребность в распределенной архитектуре, потому что вместить все воображаемое количество клиентов на одной машине - пока нереально с нынешними мощностями. Заместо того чтоб просто сидеть и ждать на появление квантовых компьютеров я решительно взялся за изучение темы распределенных систем. 14 | 15 | Стоит отметить что быстрый отклик очень важен, пресловутый realtime, ведь это же **чат**! а не доставка почты голубями. 16 | 17 | %*рандомная шутка про почту россии*% 18 | 19 | Использовать будем Node.JS, он идеален для прототипирования. Для сокетов возьмем Socket.IO. Писать на TypeScript. 20 | 21 | И так, что вообще мы хотим: 22 | 23 | 1. Чтоб пользователи могли слать друг-другу сообщения 24 | 2. Знать кто онлайн/оффлайн 25 | 26 | Как мы это хотим: 27 | 28 | ## Сингл сервер 29 | 30 | Тут нечего говорить особо, сразу к коду. Объявим интерфейс сообщения: 31 | 32 | ```typescript 33 | interface Message{ 34 | roomId: string,//В какую комнату пишем 35 | message: string,//Что мы туда пишем 36 | } 37 | ``` 38 | 39 | На сервере: 40 | 41 | ```typescript 42 | io.on('connection', sock=>{ 43 | 44 | //Присоеденяемся в указанную комнату 45 | sock.on('join', (roomId:number)=> 46 | sock.join(roomId)) 47 | 48 | //Пишем в указанную комнату 49 | //Все кто к ней присоеденился ранее получат это сообщение 50 | sock.on('message', (data:Message)=> 51 | io.to(data.roomId).emit('message', data)) 52 | }) 53 | ``` 54 | 55 | На клиенте что-то типа: 56 | 57 | ```typescript 58 | sock.on('connect', ()=> { 59 | const roomId = 'some room' 60 | 61 | //Подписываемся на сообщения из любых комнат 62 | sock.on('message', (data:Message)=> 63 | console.log(`Message ${data.message} from ${data.roomId}`)) 64 | 65 | //Присоеденяемся к одной 66 | sock.emit('join', roomId) 67 | 68 | //И пишем в нее 69 | sock.emit('message', {roomId: roomId, message: 'Halo!'}) 70 | }) 71 | ``` 72 | 73 | С онлайн статусом можно работать так: 74 | 75 | ```typescript 76 | io.on('connection', sock=>{ 77 | 78 | //При авторизации присоеденяем сокет в комнату с идентификатором пользователя 79 | //В будущем, если нужно будет послать сообщение конкретному пользователю - 80 | //можно его скинуть прямо в нее 81 | sock.on('auth', (uid:string)=> 82 | sock.join(uid)) 83 | 84 | //Теперь, чтоб узнать онлайн ли пользователь, 85 | //просто смотрим есть ли кто в комнате с его айдишником 86 | //и отправляем результат 87 | sock.on('isOnline', (uid:string, resp)=> 88 | resp(io.sockets.clients(uid).length > 0)) 89 | }) 90 | ``` 91 | 92 | И на клиенте: 93 | 94 | ```typescript 95 | sock.on('connect', ()=> { 96 | const uid = 'im uid, rly' 97 | 98 | //Типо авторизуемся 99 | sock.emit('auth', uid) 100 | 101 | //Смотрим в онлайне ли мы 102 | sock.emit('isOnline', uid, (isOnline:boolean)=> 103 | console.log(`User online status is ${isOnline}`)) 104 | }) 105 | ``` 106 | 107 | > Примечание: код не запускал, пишу по памяти просто для примера 108 | 109 | Просто как дрова, докручиваем сюды реальную авторизацию, менеджмент комнат (история сообщений, добавление/удаление участников) и профит. 110 | 111 | НО! Мы же собрались захватывать мир во всем мире, а значит не время останавливаться, стремительно движемся дальше: 112 | 113 | ## Node.JS кластер 114 | 115 | Примеры использования Socket.IO на множестве нод есть прямо на офф.сайте https://socket.io/docs/using-multiple-nodes/. В том числе там есть и про родной Node.JS кластер, который мне показался неприменимым к моей задаче: он позволяет расширить наше приложение по всей машине, НО не за ее рамки, поэтому решительно пропускаем мимо. Нам нужно наконец-то выйти за границы одной железки! 116 | 117 | ## Распределяй и велосипедь 118 | 119 | Как это сделать? Очевидно, нужно как-то связать наши инстансы, запущенные не только дома в подвале, но и в соседском подвале тоже. Что первое приходит в голову: делаем некое промежуточное звено, которое послужит шиной между всеми нашими нодами: 120 | 121 | ![1549140775997](./images/1549140775997.png) 122 | 123 | Когда нода хочет послать сообщение другой, она делает запрос к Bus, и уже он в свою очередь пересылает его куда надо, все просто. Наша сеть готова! 124 | 125 | **FIN.** 126 | 127 | ..но ведь не все так просто?) 128 | 129 | При таком подходе мы упираемся в производительность этого промежуточного звена, да и вообще хотелось бы напрямую обращаться к нужным нодам, ведь что может быть быстрее общения на прямую? Так давайте двинемся именно в эту сторону! 130 | 131 | Что нужно в первую очередь? Собственно, законнектить один инстанс к другому. Но как первому узнать об существовании второго? Мы же хотим иметь их бесконечное количество, произвольно поднимать/убирать! Нужен мастер-сервер, адрес которого заведомо известен, к нему все коннектятся, за счет чего он знает все существующие ноды в сети и этой информацией добросердечно делиться с всеми желающими. 132 | 133 | ![1549048945334](./images/1549048945334.png) 134 | 135 | Т е нода поднимается, говорит мастеру о своем пробуждении, он дает список других активных нод, мы к ним подключаемся и все, сеть готова. В качестве мастера может выступать Consul или что-то подобное, но поскольку мы велосипедим то и мастер должен быть самопальный. 136 | 137 | Отлично, теперь мы располагаем собственным скайнетом! Но текущая реализация чата в нем уже не пригодна. Давайте, собственно, придумаем требования: 138 | 139 | 1. Когда пользователь шлет сообщение, мы должны знать КОМУ он его шлет, т е иметь доступ к участникам комнаты. 140 | 2. Когда мы получили участников мы должны доставить им сообщения. 141 | 3. Мы должны знать какой пользователь в онлайне сейчас 142 | 4. Для удобства - дать пользователям возможность подписываться на онлайн статус других пользователей, чтоб в реальном времени узнавать об его изменении 143 | 144 | Давайте разберемся с пользователями. Например, можно сделать чтоб мастер знал к какой ноде какой юзерь подключен. Ситуация следующая: 145 | 146 | ![1549237952673](./images/1549237952673.png) 147 | 148 | Два пользователя подсоединены к разным нодам. Мастер знает об этом, ноды знают что мастер знает. Когда UserB авторизуется, Node2 уведомляет об этом Master, который "запоминает" что UserB присоединен к Node2. Когда UserA захочет послать сообщение UserB то получится следующая картина: 149 | 150 | ![1549140491881](./images/1549140491881.png) 151 | 152 | В принципе все работает, но хотелось бы избежать лишнего раунд трипа в виде опрашивания мастера, экономнее было бы сразу обращаться напрямую к нужной ноде, ведь именно ради этого все и затеивалось. Это можно сделать, если они будут сообщать всем окружающим какие пользователи к ним подключены, каждая из них становится самодостаточным аналогом мастера, и сам мастер становится не нужным, т к список соотношения "User=>Node" дублируется у всех. При старте ноде достаточно подключиться к любой уже запущенной, стянуть ее список себе и вуаля, она также готова к бою. 153 | 154 | ![1549139768940](./images/1549139768940.png) 155 | 156 | ![1549139882747](./images/1549139882747.png) 157 | 158 | Но качестве trade off мы получаем дублирование списка, который хоть и являет собой соотношение "user id -> [host connections]", но все же при достаточном количестве пользователей получится довольно большим по памяти. Да и вообще пилить такое самому - это явно попахивает велосипедостроительной промышленностью. Чем больше кода - тем больше потенциальных ошибок. Пожалуй, заморозим этот вариант и глянем что уже есть из готового: 159 | 160 | ## Брокеры сообщений 161 | 162 | Сущность реализующая тот самый "Bus", "промежуточное звено" упомянутое выше. Его задача - получение и доставка сообщений. Мы, как пользователи - можем подписаться на них и отсылать свои. Все просто. 163 | 164 | Есть зарекомендовавшие себя RabbitMQ и Kafka: они только и делают что доставляют сообщения - таково их предназначение, напичканы всем необходимым функционалом по горло. В их мире сообщение должно быть доставлено, несмотря ни на что. 165 | 166 | В то же время есть Redis и его pub/sub - тоже что и вышеупомянутые ребята но более дубово: он просто тупо принимает сообщение и доставляет подписчику, без всяких очередей и остальных оверхедов. Ему абсолютно плевать на сами сообщения, пропадут они, подвис ли подписчик - он выбросит его и возьмется за новое, будто ему в руки кидают раскаленную кочергу от которой хочется избавиться по быстрее. Также, если он вдруг упадет - все сообщения также канут вместе с ним. Иными словами - ни о какой гарантии доставки речи не идет. 167 | 168 | ... и это то что нужно! 169 | 170 | Ну действительно же, мы делаем просто чат. Не какой-то критический сервис по работе с деньгами или центр управления космическими полетами, а.. просто чат. Риск что условному Пете раз в год не придет одно сообщение из тысячи - им можно пренебречь, если в замен получаем рост производительности а в месте с ним количества пользователей за те же деньки, trade off во всей красе. Тем более что в то же время можно вести историю сообщений в каком нибудь персистентном хранилище, а значит Петя таки увидит то самое пропущенное сообщение перезагрузив страницу/приложение. Именно по этому остановимся на Redis pub/sub, а точнее: посмотрим на существующий адаптер для SocketIO который упоминается в статье на оф сайте https://socket.io/docs/using-multiple-nodes/ 171 | 172 | Так что это? 173 | 174 | ## Redis adapter 175 | 176 | https://github.com/socketio/socket.io-redis 177 | 178 | С его помощью обычное приложение посредством нескольких строчек и минимальным количеством телодвижений превращается в настоящий распределенный чат! Но как? Если посмотреть внутрь - оказывается там всего один файл на пол сотни строк. 179 | 180 | https://github.com/socketio/socket.io-redis/blob/master/index.js 181 | 182 | В случай когда мы эмитим сообщение 183 | 184 | ```typescript 185 | io.emit("everyone", "hello") 186 | ``` 187 | 188 | оно пушится в редис, передается всем другим инстансам нашего чата, которые в свою очередь эмитят его у себя уже локально по сокетам 189 | 190 | ![1549232309776](./images/1549232309776.png) 191 | 192 | Сообщение разойдется по всех нодах даже если мы эмитим конкретному пользователю. Т е каждая нода принимает все сообщения и уже сама разбирается нужно ли оно ей. 193 | 194 | Также, там реализован простенький rpc (вызов удаленных процедур), позволяющий не только отсылать но и получать ответы. Например можно управлять сокетами удаленно, типо "кто находится в указанной комнате", "приказать сокету присоединиться к комнате", и т д. 195 | 196 | Что с этим можно сделать? Например, использовать идентификатор пользователя в качестве имени комнаты (user id == room id). При авторизации джоинить сокет к ней, а когда мы захотим послать пользователю сообщение - просто шлем в нее. Еще, мы можем узнать онлайн ли юзер, элементарно глянув есть ли сокеты в указанной комнате. 197 | 198 | В принципе на этом можно остановиться, но нам как всегда мало: 199 | 200 | 1. Бутылочное горлышко в виде одного инстанса редиса 201 | 2. Избыточность, хотелось бы чтоб ноды получали только нужные им сообщения 202 | 203 | На счет пункта первого, глянем на такую штуку как: 204 | 205 | ## Redis cluster 206 | 207 | Связывает между собой несколько инстансов редиса, после чего работают как единое целое. Но как он это делает? Да вот так: 208 | 209 | ![1549233023980](./images/1549233023980.png) 210 | 211 | ..и видим что сообщение дублируется всем участникам кластера. Т е он предназначен не для прироста производительности, а для повышения надежности, что конечно хорошо и нужно, но для нашего кейса не имеет ценности и никак не спасает ситуацию с бутылочным горлышком, плюс в сумме это еще больше расходования ресурсов. 212 | 213 | ![1549231953897](./images/1549231953897.png) 214 | 215 | Я новичек, многого не знаю, иногда приходится возвращаться к вилесопедостроению, чем мы и займемся. Нет, редис оставим дабы совсем не скатываться, но нужно что-то придумать с архитектурой ибо текущая никуда не годится. 216 | 217 | ## Поворот не туда 218 | 219 | Что нам нужно? Повысить общую пропускную способность. Например попробуем тупо заспавнить еще один инстанс. Представим что socket.io-redis умеет подключаться к нескольким, при пуше сообщения он выбирает рандомный, а подписывается на все. Получится вот так: 220 | 221 | ![1549239818663](./images/1549239818663.png) 222 | 223 | Вуаля! В общем и целом, проблема решена, редис теперь не узкое место, можно спавнить сколь угодно экземпляров! Зато им стали ноды. Да-да, инстансы нашего чатика по прежнему переваривают ВСЕ сообщения, кому бы они не предназначались. 224 | 225 | Можно наоборот: подписываться на один рандомный, что уменьшит нагрузку на ноды, а пушить в все: 226 | 227 | ![1549239361416](./images/1549239361416.png) 228 | 229 | Видим что стало наоборот: ноды чувствуют себя спокойнее, но на инстанс редиса нагрузка выросла. Это тоже никуда не годится. Нужно чутка повелосипедить. 230 | 231 | Дабы прокачать нашу систему оставим пакет socket.io-redis в покое, он хоть и классный но нам нужно больше свободы. И так, подключаем редис: 232 | 233 | ```typescript 234 | //Отдельные каналы для: 235 | const pub = new RedisClient({host: 'localhost', port: 6379})//Пуша сообщений 236 | const sub = new RedisClient({host: 'localhost', port: 6379})//Подписок на них 237 | 238 | //Также вспоминаем этот интерфейс 239 | interface Message{ 240 | roomId: string,//В какую комнату пишем 241 | message: string,//Что мы туда пишем 242 | } 243 | ``` 244 | 245 | Настраиваем нашу систему сообщений: 246 | 247 | ```typescript 248 | //Отлавливаем все приходящие сообщения тут 249 | sub.on('message', (channel:string, dataRaw:string)=> { 250 | const data = JSON.parse(dataRaw) 251 | io.to(data.roomId).emit('message', data)) 252 | }) 253 | 254 | //Подписываемся на канал 255 | sub.subscribe("messagesChannel") 256 | 257 | //Присоеденяемся в указанную комнату 258 | sock.on('join', (roomId:number)=> 259 | sock.join(roomId)) 260 | 261 | //Пишем в комнату 262 | sock.on('message', (data:Message)=> { 263 | 264 | //Публикуем в канал 265 | pub.publish("messagesChannel", JSON.stringify(data)) 266 | }) 267 | ``` 268 | 269 | На данный момент получается как в socket.io-redis: мы слушаем все сообщения. Сейчас мы это исправим. 270 | 271 | Организуем подписки следующим образом: вспоминаем концепцию с "user id == room id", и при появлении пользователя подписываемся на одноименный канал в редисе. Таким образом наши ноды будут получать только предназначенные им сообщения, а не слушать "весь эфир". 272 | 273 | ```typescript 274 | //Отлавливаем все приходящие сообщения тут 275 | sub.on('message', (channel:string, message:string)=> { 276 | io.to(channel).emit('message', message)) 277 | }) 278 | 279 | let UID:string|null = null; 280 | sock.on('auth', (uid:string)=> { 281 | UID = uid 282 | 283 | //Когда пользователь авторизируется - подписываемся на 284 | //одноименный нашему UID канал 285 | sub.subscribe(UID) 286 | 287 | //И соответствующую комнату 288 | sock.join(UID) 289 | }) 290 | sock.on('writeYourself', (message:string)=> { 291 | 292 | //Пишем сами себе, т е публикуем сообщение в канал одноименный UID 293 | if (UID) pub.publish(UID, message) 294 | }) 295 | ``` 296 | 297 | Офигенно, теперь мы уверены что ноды получают только предназначенные им сообщения, ничего лишнего! Следует заметить, однако, что самих подписок теперь намного, намного больше, а значит будет кушать память ой йой йой, + больше операций подписки/отписки, которые сравнительно дорогие. Но в любом случае это придает нам некоторой гибкости, даже можно на этом моменте остановиться и пересмотреть заново все предыдущие варианты, уже с учетом нашего нового свойства нод в виде более выборочного, целомудренного получения сообщений. Например, ноды могут подписываться на один из нескольких инстансов редиса, а при пуше - отсылать сообщение в все инстансы: 298 | 299 | ![1550174595491](./images/1550174595491.png) 300 | 301 | 302 | 303 | ..но, как ни крути, они все равно не дают бесконечной расширяемости при разумных накладных расходах, нужно рожать иные варианты. В один момент в голову пришла следующая схема: а что если инстансы редисов поделить на группы, скажем А и В по два инстанса в каждой. Ноды при подписке подписываются по одному инстансу от каждой группы, а при пуше отсылают сообщение в все инстансы какой-то одной рандомной группы. 304 | 305 | ![1550174092066](./images/1550174092066.png) 306 | 307 | ![1550174943313](./images/1550174943313.png) 308 | 309 | Таким образом мы получаем действующую структуру с бесконечным потенциалом расширяемости в реальном времени, нагрузка на отдельный узел в любой точке не зависит от размера системы, ибо: 310 | 311 | 1. Общая пропускная способность делится между группами, т е при увеличении пользователей/активности просто спавним дополнительные группы 312 | 2. Менеджмент пользователями (подписками) делится внутри самих групп, т е при увеличении пользователей/подписок просто наращиваем количество инстансов внутри групп 313 | 314 | ...и как всегда есть одно "НО": чем больше оно все становится, тем больше ресурсов нужно для следующего прироста, это мне кажется непомерным trade off. 315 | 316 | Вообще, если подумать - вышеупомянутые затыки исходят из незнания на какой ноде какой пользователь. Ну ведь действительно, имея мы эту информацию могли б пушить сообщения сразу куда надо, без лишнего дублирования. Что мы пытались сделать все это время? Пытались сделать систему бесконечно масштабируемой, при этом не имея механизма четкой адресации, из за чего неизбежно упарывались либо в тупик, либо в неоправданную избыточность. Например можно вспомнить об мастере, выполняющий роль "адресной книги": 317 | 318 | ![1550233610561](./images/1550233610561.png) 319 | 320 | > Что-то похожее рассказывает этот чувак: 321 | > 322 | > https://youtu.be/6G22a5Iooqk 323 | 324 | Чтобы получить местонахождение пользователя мы проделываем дополнительный раундтрип, что в принципе О'К, но не в нашем случае. Кажется мы копаем в не совсем правильную сторону, нужно что-то иное... 325 | 326 | ## Сила хэша 327 | 328 | Есть такая штука как хэш. У него есть некоторый конечный диапазон значений. Получить его можно из любых данных. А что если разделить этот диапазон между инстансами редиса? Ну вот берем мы идентификатор пользователя, производим хэш, и в зависимости от диапазона в котором он оказался подписываемся/пушим в один определенный инстанс. Т е мы заранее не знаем где какой юзерь существует, но получив его айди можем с уверенностью сказать что он именно в n инстансе, инфа 100. Теперь то же самое, но с кодом: 329 | 330 | ```typescript 331 | function hash(val:string):number{/**/}//Наша хэш-функция, возвращающая число 332 | const clients:RedisClient[] = []//Массив клиентов редиса 333 | const uid = "some uid"//Идентификатор пользователя 334 | 335 | //Теперь, такой не хитрой манипуляцией мы получаем всегда один и тот же 336 | //клиент из множества для данного пользователя 337 | const selectedClient = clients[hash(uid) % clients.length] 338 | ``` 339 | 340 | Вуаля! Теперь мы не зависим от количества инстансов от слова вообще, можем скалироваться сколь угодно без оверхедов! Ну серьезно, это же гениальный вариант, единственный минус которого: нужда в полном перезапуске системы при обновлении количества редис-инстансов. Есть такая штука как Стандартное кольцо и Партиционное кольцо (https://4gophers.ru/articles/standartnoe-kolco-basic-hash-ring/) позволяющие побороть это, но они не применимы в условиях системы обмена сообщениями. Ну т е сделать логику миграции подписок между инстансами можно, но это стоит еще дополнительный кусок кода непонятного размера, а как мы знаем - чем больше кода, тем больше багов, нам этого не надо, спасибо. Да и в нашем случае downtime вполне допустимый tradeoff. 341 | 342 | Еще можно посмотреть на RabbitMQ с его плагином https://github.com/rabbitmq/rabbitmq-sharding позволяющий вытворять то же что и мы, и + обеспечивает миграцию подписок (как я говорил выше - он обвязан функционалом с ног до головы). В принципе можно взять его и спать спокойно но, если кто шарит в его тюнинге дабы вывести в realtime режим оставив только лишь фичу с хэш рингом. 343 | 344 | Залил репозиторий на гитхаб 345 | 346 | https://github.com/Alster/distributed-nodejs-chat-with-redis 347 | 348 | В нем реализован тот финальный вариант к которому мы пришли. Помимо всего, там есть дополнительная логика по работе с комнатами (диалогами). 349 | 350 | В общем, я доволен и можно закругляться. 351 | 352 | ## Итого 353 | 354 | Можно сделать что угодно, но есть такая штука как ресурсы, а они конечны, поэтому нужно извиваться. 355 | 356 | Мы начали с полного незнания как могут работать распределенные системы к более менее осязаемым конкретным паттернам, и это хорошо. -------------------------------------------------------------------------------- /sock-app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sock-app", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/node": { 8 | "version": "10.12.19", 9 | "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.19.tgz", 10 | "integrity": "sha512-2NVovndCjJQj6fUUn9jCgpP4WSqr+u1SoUZMZyJkhGeBFsm6dE46l31S7lPUYt9uQ28XI+ibrJA1f5XyH5HNtA==", 11 | "dev": true 12 | }, 13 | "@types/redis": { 14 | "version": "2.8.10", 15 | "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.10.tgz", 16 | "integrity": "sha512-X0NeV3ivoif2SPsmuPhwTkKfcr1fDJlaJIOyxZ9/TCIEbvzMzmZlstqCZ5r7AOolbOJtAfvuGArNXMexYYH3ng==", 17 | "dev": true, 18 | "requires": { 19 | "@types/node": "*" 20 | } 21 | }, 22 | "@types/socket.io": { 23 | "version": "2.1.2", 24 | "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-2.1.2.tgz", 25 | "integrity": "sha512-Ind+4qMNfQ62llyB4IMs1D8znMEBsMKohZBPqfBUIXqLQ9bdtWIbNTBWwtdcBWJKnokMZGcmWOOKslatni5vtA==", 26 | "dev": true, 27 | "requires": { 28 | "@types/node": "*" 29 | } 30 | }, 31 | "@types/socket.io-client": { 32 | "version": "1.4.32", 33 | "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.32.tgz", 34 | "integrity": "sha512-Vs55Kq8F+OWvy1RLA31rT+cAyemzgm0EWNeax6BWF8H7QiiOYMJIdcwSDdm5LVgfEkoepsWkS+40+WNb7BUMbg==", 35 | "dev": true 36 | }, 37 | "accepts": { 38 | "version": "1.3.5", 39 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", 40 | "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", 41 | "requires": { 42 | "mime-types": "~2.1.18", 43 | "negotiator": "0.6.1" 44 | } 45 | }, 46 | "after": { 47 | "version": "0.8.2", 48 | "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", 49 | "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" 50 | }, 51 | "ansi-regex": { 52 | "version": "2.1.1", 53 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", 54 | "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" 55 | }, 56 | "arraybuffer.slice": { 57 | "version": "0.0.7", 58 | "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", 59 | "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==" 60 | }, 61 | "async-limiter": { 62 | "version": "1.0.0", 63 | "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", 64 | "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" 65 | }, 66 | "backo2": { 67 | "version": "1.0.2", 68 | "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", 69 | "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" 70 | }, 71 | "base64-arraybuffer": { 72 | "version": "0.1.5", 73 | "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", 74 | "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" 75 | }, 76 | "base64id": { 77 | "version": "1.0.0", 78 | "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", 79 | "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=" 80 | }, 81 | "better-assert": { 82 | "version": "1.0.2", 83 | "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", 84 | "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", 85 | "requires": { 86 | "callsite": "1.0.0" 87 | } 88 | }, 89 | "blob": { 90 | "version": "0.0.5", 91 | "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", 92 | "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" 93 | }, 94 | "callsite": { 95 | "version": "1.0.0", 96 | "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", 97 | "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" 98 | }, 99 | "cli-color": { 100 | "version": "1.4.0", 101 | "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-1.4.0.tgz", 102 | "integrity": "sha512-xu6RvQqqrWEo6MPR1eixqGPywhYBHRs653F9jfXB2Hx4jdM/3WxiNE1vppRmxtMIfl16SFYTpYlrnqH/HsK/2w==", 103 | "requires": { 104 | "ansi-regex": "^2.1.1", 105 | "d": "1", 106 | "es5-ext": "^0.10.46", 107 | "es6-iterator": "^2.0.3", 108 | "memoizee": "^0.4.14", 109 | "timers-ext": "^0.1.5" 110 | } 111 | }, 112 | "component-bind": { 113 | "version": "1.0.0", 114 | "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", 115 | "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" 116 | }, 117 | "component-emitter": { 118 | "version": "1.2.1", 119 | "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", 120 | "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" 121 | }, 122 | "component-inherit": { 123 | "version": "0.0.3", 124 | "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", 125 | "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" 126 | }, 127 | "cookie": { 128 | "version": "0.3.1", 129 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", 130 | "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" 131 | }, 132 | "d": { 133 | "version": "1.0.0", 134 | "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", 135 | "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", 136 | "requires": { 137 | "es5-ext": "^0.10.9" 138 | } 139 | }, 140 | "debug": { 141 | "version": "4.1.1", 142 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", 143 | "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", 144 | "requires": { 145 | "ms": "^2.1.1" 146 | } 147 | }, 148 | "double-ended-queue": { 149 | "version": "2.1.0-0", 150 | "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz", 151 | "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=" 152 | }, 153 | "engine.io": { 154 | "version": "3.3.2", 155 | "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.3.2.tgz", 156 | "integrity": "sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==", 157 | "requires": { 158 | "accepts": "~1.3.4", 159 | "base64id": "1.0.0", 160 | "cookie": "0.3.1", 161 | "debug": "~3.1.0", 162 | "engine.io-parser": "~2.1.0", 163 | "ws": "~6.1.0" 164 | }, 165 | "dependencies": { 166 | "debug": { 167 | "version": "3.1.0", 168 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 169 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 170 | "requires": { 171 | "ms": "2.0.0" 172 | } 173 | }, 174 | "ms": { 175 | "version": "2.0.0", 176 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 177 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 178 | } 179 | } 180 | }, 181 | "engine.io-client": { 182 | "version": "3.3.2", 183 | "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.3.2.tgz", 184 | "integrity": "sha512-y0CPINnhMvPuwtqXfsGuWE8BB66+B6wTtCofQDRecMQPYX3MYUZXFNKDhdrSe3EVjgOu4V3rxdeqN/Tr91IgbQ==", 185 | "requires": { 186 | "component-emitter": "1.2.1", 187 | "component-inherit": "0.0.3", 188 | "debug": "~3.1.0", 189 | "engine.io-parser": "~2.1.1", 190 | "has-cors": "1.1.0", 191 | "indexof": "0.0.1", 192 | "parseqs": "0.0.5", 193 | "parseuri": "0.0.5", 194 | "ws": "~6.1.0", 195 | "xmlhttprequest-ssl": "~1.5.4", 196 | "yeast": "0.1.2" 197 | }, 198 | "dependencies": { 199 | "debug": { 200 | "version": "3.1.0", 201 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 202 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 203 | "requires": { 204 | "ms": "2.0.0" 205 | } 206 | }, 207 | "ms": { 208 | "version": "2.0.0", 209 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 210 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 211 | } 212 | } 213 | }, 214 | "engine.io-parser": { 215 | "version": "2.1.3", 216 | "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.1.3.tgz", 217 | "integrity": "sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==", 218 | "requires": { 219 | "after": "0.8.2", 220 | "arraybuffer.slice": "~0.0.7", 221 | "base64-arraybuffer": "0.1.5", 222 | "blob": "0.0.5", 223 | "has-binary2": "~1.0.2" 224 | } 225 | }, 226 | "es5-ext": { 227 | "version": "0.10.47", 228 | "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.47.tgz", 229 | "integrity": "sha512-/1TItLfj+TTfWoeRcDn/0FbGV6SNo4R+On2GGVucPU/j3BWnXE2Co8h8CTo4Tu34gFJtnmwS9xiScKs4EjZhdw==", 230 | "requires": { 231 | "es6-iterator": "~2.0.3", 232 | "es6-symbol": "~3.1.1", 233 | "next-tick": "1" 234 | } 235 | }, 236 | "es6-iterator": { 237 | "version": "2.0.3", 238 | "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", 239 | "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", 240 | "requires": { 241 | "d": "1", 242 | "es5-ext": "^0.10.35", 243 | "es6-symbol": "^3.1.1" 244 | } 245 | }, 246 | "es6-symbol": { 247 | "version": "3.1.1", 248 | "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", 249 | "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", 250 | "requires": { 251 | "d": "1", 252 | "es5-ext": "~0.10.14" 253 | } 254 | }, 255 | "es6-weak-map": { 256 | "version": "2.0.2", 257 | "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", 258 | "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", 259 | "requires": { 260 | "d": "1", 261 | "es5-ext": "^0.10.14", 262 | "es6-iterator": "^2.0.1", 263 | "es6-symbol": "^3.1.1" 264 | } 265 | }, 266 | "event-emitter": { 267 | "version": "0.3.5", 268 | "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", 269 | "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", 270 | "requires": { 271 | "d": "1", 272 | "es5-ext": "~0.10.14" 273 | } 274 | }, 275 | "has-binary2": { 276 | "version": "1.0.3", 277 | "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", 278 | "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", 279 | "requires": { 280 | "isarray": "2.0.1" 281 | } 282 | }, 283 | "has-cors": { 284 | "version": "1.1.0", 285 | "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", 286 | "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" 287 | }, 288 | "indexof": { 289 | "version": "0.0.1", 290 | "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", 291 | "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" 292 | }, 293 | "is-promise": { 294 | "version": "2.1.0", 295 | "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", 296 | "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" 297 | }, 298 | "isarray": { 299 | "version": "2.0.1", 300 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", 301 | "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=" 302 | }, 303 | "lru-queue": { 304 | "version": "0.1.0", 305 | "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", 306 | "integrity": "sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=", 307 | "requires": { 308 | "es5-ext": "~0.10.2" 309 | } 310 | }, 311 | "memoizee": { 312 | "version": "0.4.14", 313 | "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.14.tgz", 314 | "integrity": "sha512-/SWFvWegAIYAO4NQMpcX+gcra0yEZu4OntmUdrBaWrJncxOqAziGFlHxc7yjKVK2uu3lpPW27P27wkR82wA8mg==", 315 | "requires": { 316 | "d": "1", 317 | "es5-ext": "^0.10.45", 318 | "es6-weak-map": "^2.0.2", 319 | "event-emitter": "^0.3.5", 320 | "is-promise": "^2.1", 321 | "lru-queue": "0.1", 322 | "next-tick": "1", 323 | "timers-ext": "^0.1.5" 324 | } 325 | }, 326 | "mime-db": { 327 | "version": "1.37.0", 328 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz", 329 | "integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==" 330 | }, 331 | "mime-types": { 332 | "version": "2.1.21", 333 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.21.tgz", 334 | "integrity": "sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==", 335 | "requires": { 336 | "mime-db": "~1.37.0" 337 | } 338 | }, 339 | "ms": { 340 | "version": "2.1.1", 341 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", 342 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" 343 | }, 344 | "nan": { 345 | "version": "2.12.1", 346 | "resolved": "https://registry.npmjs.org/nan/-/nan-2.12.1.tgz", 347 | "integrity": "sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==" 348 | }, 349 | "negotiator": { 350 | "version": "0.6.1", 351 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", 352 | "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" 353 | }, 354 | "next-tick": { 355 | "version": "1.0.0", 356 | "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", 357 | "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" 358 | }, 359 | "notepack.io": { 360 | "version": "2.2.0", 361 | "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", 362 | "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==" 363 | }, 364 | "object-component": { 365 | "version": "0.0.3", 366 | "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", 367 | "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=" 368 | }, 369 | "parseqs": { 370 | "version": "0.0.5", 371 | "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", 372 | "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", 373 | "requires": { 374 | "better-assert": "~1.0.0" 375 | } 376 | }, 377 | "parseuri": { 378 | "version": "0.0.5", 379 | "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", 380 | "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", 381 | "requires": { 382 | "better-assert": "~1.0.0" 383 | } 384 | }, 385 | "redis": { 386 | "version": "2.8.0", 387 | "resolved": "https://registry.npmjs.org/redis/-/redis-2.8.0.tgz", 388 | "integrity": "sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==", 389 | "requires": { 390 | "double-ended-queue": "^2.1.0-0", 391 | "redis-commands": "^1.2.0", 392 | "redis-parser": "^2.6.0" 393 | } 394 | }, 395 | "redis-commands": { 396 | "version": "1.4.0", 397 | "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.4.0.tgz", 398 | "integrity": "sha512-cu8EF+MtkwI4DLIT0x9P8qNTLFhQD4jLfxLR0cCNkeGzs87FN6879JOJwNQR/1zD7aSYNbU0hgsV9zGY71Itvw==" 399 | }, 400 | "redis-parser": { 401 | "version": "2.6.0", 402 | "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", 403 | "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=" 404 | }, 405 | "socket.io": { 406 | "version": "2.2.0", 407 | "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.2.0.tgz", 408 | "integrity": "sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==", 409 | "requires": { 410 | "debug": "~4.1.0", 411 | "engine.io": "~3.3.1", 412 | "has-binary2": "~1.0.2", 413 | "socket.io-adapter": "~1.1.0", 414 | "socket.io-client": "2.2.0", 415 | "socket.io-parser": "~3.3.0" 416 | } 417 | }, 418 | "socket.io-adapter": { 419 | "version": "1.1.1", 420 | "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz", 421 | "integrity": "sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=" 422 | }, 423 | "socket.io-client": { 424 | "version": "2.2.0", 425 | "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.2.0.tgz", 426 | "integrity": "sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==", 427 | "requires": { 428 | "backo2": "1.0.2", 429 | "base64-arraybuffer": "0.1.5", 430 | "component-bind": "1.0.0", 431 | "component-emitter": "1.2.1", 432 | "debug": "~3.1.0", 433 | "engine.io-client": "~3.3.1", 434 | "has-binary2": "~1.0.2", 435 | "has-cors": "1.1.0", 436 | "indexof": "0.0.1", 437 | "object-component": "0.0.3", 438 | "parseqs": "0.0.5", 439 | "parseuri": "0.0.5", 440 | "socket.io-parser": "~3.3.0", 441 | "to-array": "0.1.4" 442 | }, 443 | "dependencies": { 444 | "debug": { 445 | "version": "3.1.0", 446 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 447 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 448 | "requires": { 449 | "ms": "2.0.0" 450 | } 451 | }, 452 | "ms": { 453 | "version": "2.0.0", 454 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 455 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 456 | } 457 | } 458 | }, 459 | "socket.io-parser": { 460 | "version": "3.3.0", 461 | "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz", 462 | "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==", 463 | "requires": { 464 | "component-emitter": "1.2.1", 465 | "debug": "~3.1.0", 466 | "isarray": "2.0.1" 467 | }, 468 | "dependencies": { 469 | "debug": { 470 | "version": "3.1.0", 471 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 472 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 473 | "requires": { 474 | "ms": "2.0.0" 475 | } 476 | }, 477 | "ms": { 478 | "version": "2.0.0", 479 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 480 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 481 | } 482 | } 483 | }, 484 | "timers-ext": { 485 | "version": "0.1.7", 486 | "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", 487 | "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", 488 | "requires": { 489 | "es5-ext": "~0.10.46", 490 | "next-tick": "1" 491 | } 492 | }, 493 | "to-array": { 494 | "version": "0.1.4", 495 | "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", 496 | "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=" 497 | }, 498 | "ws": { 499 | "version": "6.1.3", 500 | "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.3.tgz", 501 | "integrity": "sha512-tbSxiT+qJI223AP4iLfQbkbxkwdFcneYinM2+x46Gx2wgvbaOMO36czfdfVUBRTHvzAMRhDd98sA5d/BuWbQdg==", 502 | "requires": { 503 | "async-limiter": "~1.0.0" 504 | } 505 | }, 506 | "xmlhttprequest-ssl": { 507 | "version": "1.5.5", 508 | "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", 509 | "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" 510 | }, 511 | "xxhash": { 512 | "version": "0.2.4", 513 | "resolved": "https://registry.npmjs.org/xxhash/-/xxhash-0.2.4.tgz", 514 | "integrity": "sha1-i4pIFiz8zCG5IPpQAmEYfUAhbDk=", 515 | "requires": { 516 | "nan": "^2.4.0" 517 | } 518 | }, 519 | "yeast": { 520 | "version": "0.1.2", 521 | "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", 522 | "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" 523 | } 524 | } 525 | } 526 | --------------------------------------------------------------------------------