From 003fe46cb1a819a2cc0172d3d29ea136238518df Mon Sep 17 00:00:00 2001 From: Nicolo Davis Date: Tue, 27 Feb 2018 22:27:53 +0800 Subject: [PATCH] MongoDB connector --- package-lock.json | 106 +++++++++++++++++++++++++- package.json | 3 + src/client/multiplayer/multiplayer.js | 2 +- src/core/reducer.js | 6 +- src/core/reducer.test.js | 6 +- src/server/db.js | 97 ++++++++++++++++++++++- src/server/db.test.js | 66 ++++++++++++++-- src/server/index.js | 3 +- src/server/index.test.js | 9 ++- 9 files changed, 277 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 792f584c0..ef90a0b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3053,6 +3053,17 @@ "node-int64": "0.4.0" } }, + "bson": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.0.5.tgz", + "integrity": "sha512-D4SCtud6mlEb48kXdTHU31DRU0bsgOJ+4St1Dcx30uYNnf/aGc+hC9gHB/z0Eth8HYYs/hr0SFdyZViht19SwA==" + }, + "bson-objectid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bson-objectid/-/bson-objectid-1.2.2.tgz", + "integrity": "sha512-GyjZ1yqTDXaK5HlcDe5NXwRlURZERSF2q0p4sQCQ0Cns2aXzc/5F6mgLPBnlAWOvq9awl6NNHZ8bqvNWvZkcMg==", + "dev": true + }, "buffer": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", @@ -10415,6 +10426,11 @@ "yallist": "2.1.2" } }, + "lru-native": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/lru-native/-/lru-native-0.4.0.tgz", + "integrity": "sha1-vBPDLfvRcZwC8aA5KlTn5cYRRlQ=" + }, "macaddress": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.2.8.tgz", @@ -10668,12 +10684,77 @@ "minimist": "0.0.8" } }, + "modifyjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/modifyjs/-/modifyjs-0.3.1.tgz", + "integrity": "sha1-ckaUd/tMRwlx1hemO6G73pqqx88=", + "dev": true, + "requires": { + "clone": "2.1.1", + "deep-equal": "1.0.1" + }, + "dependencies": { + "clone": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.1.tgz", + "integrity": "sha1-0hfR6WERjjrJpLi7oyhVU79kfNs=", + "dev": true + } + } + }, "moment": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==", "dev": true }, + "mongo-mock": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mongo-mock/-/mongo-mock-3.1.0.tgz", + "integrity": "sha512-I1aXGpyflbjdy2HlwoIvFzAEFPTb87Pc1IgALb/yFBYbz4pJ2evw0IVmeGtVRhyly7OSnnx0F5dS2xempCNGeA==", + "dev": true, + "requires": { + "bson-objectid": "1.2.2", + "debug": "2.6.9", + "lodash": "3.10.1", + "modifyjs": "0.3.1", + "sift": "3.3.12" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + } + } + }, + "mongodb": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.0.3.tgz", + "integrity": "sha512-BuYbPwjrIS/Ik/AUegjnYb8ncOa4dj8tzP4eSCsaqjP9yjmIWzzKrAXBY+s8xy6xkTJxgvbuTFub6cIwpmHRXQ==", + "requires": { + "mongodb-core": "3.0.3" + } + }, + "mongodb-core": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.0.3.tgz", + "integrity": "sha512-AkEiYeq4PZrgoKPZ32q2nL2xFe9iswOgefMipS2YHJHX8DCFAXmYr1aFxefAWisinxI/nd57nBMSe4mrm3yV1g==", + "requires": { + "bson": "1.0.5", + "require_optional": "1.0.1" + } + }, "mousetrap": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.1.tgz", @@ -13293,6 +13374,22 @@ "resolve-from": "1.0.1" } }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "2.0.0", + "semver": "5.4.1" + }, + "dependencies": { + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + } + } + }, "reselect": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", @@ -13734,8 +13831,7 @@ "semver": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", - "dev": true + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" }, "semver-diff": { "version": "2.1.0", @@ -13909,6 +14005,12 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "sift": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/sift/-/sift-3.3.12.tgz", + "integrity": "sha1-T1zfFq89syr6BKslKXsOIK2YKUo=", + "dev": true + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", diff --git a/package.json b/package.json index efc4ace80..0940cb0ab 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "koa-helmet": "^3.2.0", "koa-webpack": "^1.0.0", "lint-staged": "^6.0.1", + "mongo-mock": "^3.1.0", "open-browser-webpack-plugin": "0.0.5", "prettier": "^1.10.2", "react": "^16.0.0", @@ -113,6 +114,8 @@ "koa-router": "^7.2.1", "koa-socket": "^4.4.0", "koa-static": "^4.0.1", + "lru-native": "^0.4.0", + "mongodb": "^3.0.3", "mousetrap": "^1.6.1", "prop-types": "^15.5.10", "react-json-view": "^1.13.0", diff --git a/src/client/multiplayer/multiplayer.js b/src/client/multiplayer/multiplayer.js index c0e0a04ad..9c7d44395 100644 --- a/src/client/multiplayer/multiplayer.js +++ b/src/client/multiplayer/multiplayer.js @@ -58,7 +58,7 @@ export class Multiplayer { this.socket.emit( 'action', action, - state._id, + state._stateID, this.gameID, this.playerID ); diff --git a/src/core/reducer.js b/src/core/reducer.js index 7d945e299..513731547 100644 --- a/src/core/reducer.js +++ b/src/core/reducer.js @@ -36,7 +36,7 @@ export function createGameReducer({ game, numPlayers, multiplayer }) { // A monotonically non-decreasing ID to ensure that // state updates are only allowed from clients that // are at the same version that the server. - _id: 0, + _stateID: 0, // A snapshot of this object so that actions can be // replayed over it to view old snapshots. @@ -84,7 +84,7 @@ export function createGameReducer({ game, numPlayers, multiplayer }) { ctx = { ...ctx, _random: PRNGState.get() }; const log = [...state.log, action]; - return { ...state, G, ctx, log, _id: state._id + 1 }; + return { ...state, G, ctx, log, _stateID: state._stateID + 1 }; } case Actions.MAKE_MOVE: { @@ -110,7 +110,7 @@ export function createGameReducer({ game, numPlayers, multiplayer }) { } const log = [...state.log, action]; - state = { ...state, G, ctx, log, _id: state._id + 1 }; + state = { ...state, G, ctx, log, _stateID: state._stateID + 1 }; // If we're on the client, just process the move // and no triggers in multiplayer mode. diff --git a/src/core/reducer.test.js b/src/core/reducer.test.js index fa6d8e434..3453560e0 100644 --- a/src/core/reducer.test.js +++ b/src/core/reducer.test.js @@ -23,15 +23,15 @@ const game = Game({ const endTurn = () => gameEvent('endTurn'); -test('_id is incremented', () => { +test('_stateID is incremented', () => { const reducer = createGameReducer({ game }); let state = undefined; state = reducer(state, makeMove('unknown')); - expect(state._id).toBe(1); + expect(state._stateID).toBe(1); state = reducer(state, endTurn()); - expect(state._id).toBe(2); + expect(state._stateID).toBe(2); }); test('makeMove', () => { diff --git a/src/server/db.js b/src/server/db.js index ed4894169..4a31a9297 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -6,6 +6,9 @@ * https://opensource.org/licenses/MIT. */ +const MongoClient = require('mongodb').MongoClient; +const LRUCache = require('lru-native'); + /** * InMemory data storage. */ @@ -17,6 +20,14 @@ export class InMemory { this.games = new Map(); } + /** + * Connect. + * No-op for the InMemory instance. + */ + async connect() { + return; + } + /** * Write the game state to the in-memory object. * @param {string} id - The game id. @@ -35,8 +46,9 @@ export class InMemory { async get(id) { return await this.games.get(id); } + /** - * Read the game state from the in-memory object. + * Check if a particular game id exists. * @param {string} id - The game id. * @returns {boolean} - True if a game with this id exists. */ @@ -44,3 +56,86 @@ export class InMemory { return await this.games.has(id); } } + +/** + * MongoDB connector. + */ +export class Mongo { + /** + * Creates a new Mongo connector object. + */ + constructor({ url, dbname, cacheSize, mockClient }) { + if (cacheSize === undefined) cacheSize = 1000; + + this.client = mockClient || MongoClient; + this.url = url; + this.dbname = dbname; + this.cache = new LRUCache({ maxElements: cacheSize }); + } + + /** + * Connect to the instance. + */ + async connect() { + const c = await this.client.connect(this.url); + this.db = c.db(this.dbname); + return; + } + + /** + * Write the game state. + * @param {string} id - The game id. + * @param {object} store - A game state to persist. + */ + async set(id, state) { + this.cache.set(id, state); + + const col = this.db.collection(id); + delete state._id; + await col.insert(state); + + return; + } + + /** + * Read the game state. + * @param {string} id - The game id. + * @returns {object} - A game state, or undefined + * if no game is found with this id. + */ + async get(id) { + const item = this.cache.get(id); + if (item !== undefined) { + return item; + } + + const col = this.db.collection(id); + const docs = await col + .find() + .sort({ _id: -1 }) + .limit(1) + .toArray(); + + this.cache.set(id, docs[0]); + return docs[0]; + } + + /** + * Check if a particular game exists. + * @param {string} id - The game id. + * @returns {boolean} - True if a game with this id exists. + */ + async has(id) { + const item = this.cache.get(id); + if (item !== undefined) { + return true; + } + + const col = this.db.collection(id); + const docs = await col + .find() + .limit(1) + .toArray(); + return docs.length > 0; + } +} diff --git a/src/server/db.test.js b/src/server/db.test.js index 923239126..d3b844c1d 100644 --- a/src/server/db.test.js +++ b/src/server/db.test.js @@ -6,25 +6,77 @@ * https://opensource.org/licenses/MIT. */ -import { InMemory } from './db'; -import * as Redux from 'redux'; +import { InMemory, Mongo } from './db'; +import * as MongoDB from 'mongo-mock'; test('basic', async () => { const db = new InMemory(); - const reducer = () => {}; - const store = Redux.createStore(reducer); + await db.connect(); // Must return undefined when no game exists. let state = await db.get('gameID'); expect(state).toEqual(undefined); // Create game. - await db.set('gameID', store.getState()); + await db.set('gameID', { a: 1 }); // Must return created game. state = await db.get('gameID'); - expect(state).toEqual(store.getState()); + expect(state).toEqual({ a: 1 }); // Must return true if game exists - let has = await db.has('gameID'); + const has = await db.has('gameID'); expect(has).toEqual(true); }); + +test('Mongo', async () => { + const mockClient = MongoDB.MongoClient; + const db = new Mongo({ mockClient, url: 'a' }); + await db.connect(); + + // Must return undefined when no game exists. + let state = await db.get('gameID'); + expect(state).toEqual(undefined); + + // Create game. + await db.set('gameID', { a: 1 }); + + // Cache hits. + { + // Must return created game. + state = await db.get('gameID'); + expect(state).toMatchObject({ a: 1 }); + + // Must return true if game exists + const has = await db.has('gameID'); + expect(has).toBe(true); + } + + // Cache misses. + { + // Must return created game. + db.cache.clear(); + state = await db.get('gameID'); + expect(state).toMatchObject({ a: 1 }); + + // Must return true if game exists + db.cache.clear(); + const has = await db.has('gameID'); + expect(has).toBe(true); + } + + // Cache size. + { + const db = new Mongo({ mockClient, cacheSize: 1, url: 'a' }); + await db.connect(); + await db.set('gameID', { a: 1 }); + await db.set('another', { b: 1 }); + state = await db.get('gameID'); + // Check that it came from Mongo and not the cache. + expect(state._id).toBeDefined(); + } + + { + const db = new Mongo({ url: 'a' }); + expect(db.client).toBeDefined(); + } +}); diff --git a/src/server/index.js b/src/server/index.js index 21c197e23..ad8551dc1 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -28,6 +28,7 @@ function Server({ games, db, _clientInfo, _roomInfo }) { if (db === undefined) { db = new InMemory(); } + db.connect(); const clientInfo = _clientInfo || new Map(); const roomInfo = _roomInfo || new Map(); @@ -63,7 +64,7 @@ function Server({ games, db, _clientInfo, _roomInfo }) { return; } - if (state._id == stateID) { + if (state._stateID == stateID) { // Update server's version of the store. store.dispatch(action); state = store.getState(); diff --git a/src/server/index.test.js b/src/server/index.test.js index ff6ce9d67..13175aea2 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -160,10 +160,10 @@ test('action', async () => { await io.socket.receive('action', action, 0, 'gameID', '0'); expect(io.socket.emit).lastCalledWith('sync', 'gameID', { G: {}, - _id: 1, + _stateID: 1, _initial: { G: {}, - _id: 0, + _stateID: 0, _initial: {}, ctx: { currentPlayer: '0', @@ -196,7 +196,7 @@ test('action', async () => { await io.socket.receive('action', action, 1, 'unknown', '1'); expect(io.socket.emit).toHaveBeenCalledTimes(0); - // ... and not if the _id doesn't match the internal state. + // ... and not if the _stateID doesn't match the internal state. await io.socket.receive('action', action, 100, 'gameID', '1'); expect(io.socket.emit).toHaveBeenCalledTimes(0); @@ -258,6 +258,9 @@ test('custom db implementation', async () => { constructor() { this.games = new Map(); } + async connect() { + return; + } async get(id) { getId = id; return await this.games.get(id);