N-layer архитектура

Лекция 10

Architecture

Project structure

package.json


{
  "name": "sample",
  "version": "0.1.0",
  "dependencies": {
    "bluebird": "^3.5.1",
    "body-parser": "^1.18.2",
    "cookie-parser": "^1.4.3",
    "express": "^4.16.2",
    "moment": "^2.19.3",
    "mysql2": "^1.5.1",
    "sequelize": "^4.23.4"
  }
}
  

index.js


const Sequelize = require('sequelize');

const config = require('./config.json');

const db = require('./context')(Sequelize, config);
const server = require('./server')(db, config);

...
  

index.js


...

(async function () {
  await db.sequelize.sync();

  await db.roles.findOrCreate({
    where: { name: 'adminstrator' }
  });

  await db.roles.findOrCreate({
    where: { name: 'user' }
  });

  server.listen(3000, () => console.log('Running'));
})();
  

config.json


{
  "db": {
    "host": "127.0.0.1",
    "name": "my_db",
    "user": "myuser",
    "password": "123#qwe"
  },
  "cookie": {
    "key": "secret key",
    "auth": "__auth_id"
  }
}
  

server.js


const express = require('express');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');

const errors = require('./helpers/errors');
  

server.js


const PostsService = require('./services/posts');
const UsersService = require('./services/users');
const RolesService = require('./services/roles');
const CacheService = require('./services/cache');

const AuthenticationService
  = require('./services/authentication');

const AuthorizationService
  = require('./services/authorization');

module.exports = (db, config) => {
  ...
};
  

server.js


const app = express();

// Services
const postsService = new PostsService(
  db.posts,
  errors
);

const usersService = new UsersService(
  db.users,
  db.roles,
  errors
);
  

server.js


const rolesService = new RolesService(
  db.roles,
  errors
);

const authenticationService = new AuthenticationService(
  db.users,
  db.roles,
  errors
);

const authorizationService = new AuthorizationService(
  db.roles,
  errors
);

const cacheService = new CacheService();
  

server.js


// Controllers
const logger
  = require('./global-controllers/logger');

const authenticator
  = require('./global-controllers/authenticator')(
    usersService,
    config
  );

const authorizator
  = require('./global-controllers/authorizator')
      (authorizationService);
  

server.js


const cache
  = require('./global-controllers/cache')
      (cacheService);

const error
  = require('./global-controllers/error');

const apiController = require('./controllers/api')(
  postsService,
  usersService,
  rolesService,
  authenticationService,
  cacheService,
  config,
);
  

server.js


// Mounting
app.use(express.static('public'));
app.use(cookieParser(config.cookie.key));
app.use(bodyParser.json());

app.use('/api', logger);
app.use('/api', authenticator);
app.use('/api', authorizator);
app.use('/api', cache);
app.use('/api', apiController);
app.use('/api', error);

return app;
  

Helpers

./helpers/hash.js


// TODO: user hash algorithm e.g. bcrypt
module.exports = {
  get: plain => {
    return plain;
  },

  isValid: (plain, hash) => {
    return plain === hash;
  },
};
  

./helpers/wrap.js


module.exports =
  fn =>
    (req, res, next) =>
      fn(req, res, next).catch(next);
  

./utils/errors.js


const express = require('express');

express.response.error = function(error) {
  if (!error.code) {
    error = {
      message: error.toString(),
      code: 'server_error',
      status: 500
    };
  }

  this.status(error.status).json(error);
};
...
  

./utils/errors.js


...
module.exports = {
  invalidId: {
    message: 'Invalid id',
    code: 'invalid_id', status: 400 },
  notFound: {
    message: 'Entity not found',
    code: 'entity_not_found', status: 404 },
  wrongCredentials: {
    message: 'Email or password are wrong',
    code: 'wrong_credentials', status: 404 },
  accessDenied: {
    message: 'Access denied',
    code: 'access_denied', status: 403 }
};
  

Model

Модель

Модель

  • класс, структура, схема
  • отражает сущность предметной области
  • абстракция
  • таблица в БД - модель в коде

./models/post.js


module.exports = (Sequelize, sequelize) => {
  return sequelize.define('posts', {
    id: {
      type: Sequelize.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },

    title: { type: Sequelize.STRING },
    content: Sequelize.STRING,
    date: Sequelize.DATEONLY,
    rating: Sequelize.INTEGER,

    draft: Sequelize.BOOLEAN
};
  

./models/user.js


module.exports = (Sequelize, sequelize) => {
  return sequelize.define('users', {
    id: {
      type: Sequelize.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },

    email: Sequelize.STRING,
    password: Sequelize.STRING,

    firstname: Sequelize.STRING,
    lastname: Sequelize.STRING
  });
};
  

./models/role.js


module.exports = (Sequelize, sequelize) => {
  return sequelize.define('roles', {
    id: {
      type: Sequelize.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },

    name: Sequelize.STRING
  });
};
  

Context

Контекст / ORM

Контекст

  • связи между моделями
  • перевод вызова методов в запросы
  • маппинг ответов на объекты

./context/index.js


module.exports = (Sequelize, config) => {
  const options = {
    host: config.db.host,
    dialect: 'mysql',
    logging: false,
    define: {
      timestamps: true,
      paranoid: true,
      defaultScope: {
        where: { deletedAt: { $eq: null } }
      }
    }
  };
  ...
};
  

./context/db.js


module.exports = (Sequelize, config) => {
  ...
  const sequelize = new Sequelize(config.db.name,
    config.db.user, config.db.password, options);

  const User = require('../models/user')
    (Sequelize, sequelize);
  const Role = require('../models/role')
    (Sequelize, sequelize);
  const Post = require('../models/post')
    (Sequelize, sequelize);
  ...
};
  

./context/db.js


module.exports = (Sequelize, config) => {
  ...
  // User <-> Role
  User.belongsToMany(Role,
    { through: 'userRoles' });
  Role.belongsToMany(User,
    { through: 'userRoles' });

  // Post -> User
  Post.belongsTo(User);
  User.hasMany(Post);
  ...
};
  

./context/db.js


module.exports = (Sequelize, config) => {
  ...
  return {
    users: User,
    roles: Role,
    posts: Post,

    sequelize,
    Sequelize,
  };
};
  

Repository

Репозиторий

Репозиторий

  • каждой модели - репозиторий
  • CRUD

Service

Сервис

Сервис

  • потребляет 1 и более репозиториев
  • валидация
  • бизнес-логика
  • может возвращать DTO

./services/crud.js


class CrudService {
  constructor(repository, errors) { ... }

  async readChunk(options) { ... }

  async read(id) { ... }

  async create(data) { ... }

  async update(id, data) { ... }

  async delete(id) { ... }
}

module.exports = CrudService;
  

./services/crud.js => constructor


this.repository = repository;
this.errors = errors;

this.defaults = {
  readChunk: {
    limit: 10,
    page: 1,
    order: 'asc',
    orderField: 'id'
  }
};
  

./services/crud.js => readChunk(options)


options = Object.assign(
  {}, this.defaults.readChunk, options
);

let limit = options.limit;
let offset = (options.page - 1) * options.limit;

return await this.repository.findAll({
  limit: limit,
  offset: offset,
  order: [[
    options.orderField,
    options.order.toUpperCase()
  ]],
  raw: true
});
  

./services/crud.js => read(id)


id = parseInt(id);

if (isNaN(id)) {
  throw this.errors.invalidId;
}

const item = await this.repository.findById(
  id,
  { raw: true }
);

if (!item) {
  throw this.errors.notFound;
}

return item;
  

./services/crud.js => create(data)


const item = await this.repository.create(data);

return item.get({ plain: true });
  

./services/crud.js => update(id, data)


await this.repository.update(data, {
  where: { id: id },
  limit: 1
});

return this.read(id);
  

./services/crud.js => delete(id)


return this.repository.destroy({
  where: { id: id }
});
  

./services/posts.js


const CrudService = require('./crud');

class PostsService extends CrudService {
  async create(data) { ... }

  async update(data) { ... }

  async upvote(id) { ... }

  async downvote(id) { ... }
}

module.exports = PostsService;
  

./services/posts.js => create(data)


let post = {
  title: data.title,
  content: data.content,
  date: data.date,
  draft: data.draft,
  userId: data.userId,
  rating: 0
};

return super.create(post);
  

./services/posts.js => update(data)


let post = {
  title: data.title,
  content: data.content,
  date: data.date,
  draft: data.draft
};

return super.update(data.id, post);
  

./services/posts.js => upvote(id)


const post = await this.repository.findById(id);

if (!post) {
  throw this.errors.notFound;
}

return post.increment({ rating: 1 });
  

./services/posts.js => downvote(id)


const post = await this.repository.findById(id);

if (!post) {
  throw this.errors.notFound;
}

return post.increment({ rating: -1 });
  

./services/users.js


const Promise = require('bluebird');
const CrudService = require('./crud');
const hash = require('../helpers/hash');

class UsersService extends CrudService {
  constructor(usersRepository, rolesRepository,
  errors) { ... }

  async update(data) { ... }
  async grant(userId, roleId) { ... }
  async revoke(userId, roleId) { ... }
}

module.exports = UsersService;
  

./services/users.js => update(data)


let user = {
  password: data.password
  && hash.get(data.password),
  firstname: data.firstname,
  lastname: data.lastname
};

return super.update(data.id, user);
  

./services/users.js => grant(userId, roleId)


const [ user, role ] = await Promise.all([
  this.repository.findById(userId),
  this.rolesRepository.findById(roleId),
]);

if (!user || !role) {
  throw this.errors.invalidId;
}

await user.addRole(role);
  

./services/users.js => revoke(userId, roleId)


const [ user, role ] = await Promise.all([
  this.repository.findById(userId),
  this.rolesRepository.findById(roleId),
]);

if (!user || !role) {
  throw this.errors.invalidId;
}

await user.removeRole(role);
  

./services/roles.js


const CrudService = require('./crud');

class RolesService extends CrudService {
  async create(data) { ... }
}

module.exports = RolesService;
  

./services/roles.js => create(data)


let role = {
  name: data.name
};

return super.create(role);
  

./services/authentication.js


const Promise = require('bluebird');
const hash = require('../helpers/hash');

class AuthenticationService {
  constructor(usersRepository, rolesRepository,
    errors) { ... }

  async login(data) { ... }
  async register(data) { ... }
}

module.exports = AuthenticationService;
  

./services/authentication.js => constructor


this.usersRepository = usersRepository;
this.rolesRepository = rolesRepository;
this.errors = errors;
  

./services/authentication.js => login(data)


const user = await this.usersRepository.findOne({
  where: { email: data.email },
  attributes: ['id', 'password']
});

if (!user
  || !hash.isValid(data.password, user.password)) {
  throw this.errors.wrongCredentials;
}

return user;
  

./services/authentication.js => register(data)


const user = this.usersRepository.build({
  email: data.email,
  password: hash.get(data.password),
  firstname: data.firstname,
  lastname: data.lastname
});

const [, role] = await Promise.all([
  user.save(),
  this.rolesRepository.findOne({
    where: { name: 'user' }
  })
]);

await user.addRole(role);

return user;
  

./services/authorization.js


const permissions = {
  '/posts/create': 'user',
  '/posts/update': 'user',
  '/posts/delete': 'user',

  '/users': 'administrator',
  '/users/update': 'administrator',
  '/users/delete': 'administrator',
  '/users/grant': 'administrator',
  '/users/revoke': 'administrator',

  '/roles': 'administrator',
  '/roles/create': 'administrator',
  '/roles/delete': 'administrator'
};
  

./services/authorization.js


class AuthorizationService {
  constructor(rolesRepository, errors) { ... }

  async checkPermissions(user, route)  { ... }
}

module.exports = AuthorizationService;
  

./services/authorization.js => constructor


this.rolesRepository = rolesRepository;
this.errors = errors;
  

./services/authorization.js => checkPermissions(user, route)


if (!permissions[route]) return;

if(!user) throw this.errors.accessDenied;

const role = await this.rolesRepository.findOne({
  where: { name: permissions[route] }
});

const hasRole = await user.hasRole(role);

if (!hasRole) {
  throw this.errors.accessDenied;
}
  

./services/cache.js


// TODO: implement cache, in memory or external
class CacheService {
  async set(req, data) {}
  async get(req) {}
  async invalidate(req) {}
}

module.exports = CacheService;
  

Controller

Контроллер

Контроллер

  • потребляет 1 и более сервисов
  • запросы от клиента
  • логика приложения

./global-contollers/logger.js


const moment = require('moment');

module.exports = (req, res, next) => {
  res.locals.trace = { ... };

  console.log(moment().format('HH:mm:ss'));
  console.log(`${req.method} ${req.path}`);
  console.log(JSON.stringify(req.query));
  console.log(JSON.stringify(req.body));
  console.log();

  next();
};
  

./global-contollers/authenticator.js


const wrap = require('../helpers/wrap');

module.exports = (usersService, config) =>
  wrap(async (req, res, next) => {
    let userId = req.signedCookies[
      config.cookie.auth
    ];

    if (userId) {
      req.user = await usersService.get(userId);
    }

    next();
  });
  

./global-contollers/authorizator.js


const wrap = require('../helpers/wrap');

module.exports = (authorizationService) =>
  wrap(async (req, res, next) => {
    await authorizationService
      .checkPermissions(req.user, req.path);

    next();
  });
  

./global-contollers/cache.js


module.exports = (cacheService) => async (req, res, next) => {
  const cached = await cacheService.get(req);

  if (cached) {
    res.json(cached);
    return;
  }

  next();
};
  

./global-contollers/error.js


module.exports = (error, req, res, next) => {
  // TODO: log error + 'res.locals.trace'

  res.error(error);
};
  

./controllers/api.js


const express = require('express');

module.exports = (
  postsService,
  usersService,
  rolesService,
  authenticationService,
  cacheService,
  config,
) => {
  ...
};
  

./controllers/api.js


const router = express.Router();

const postsController = require('./posts')(
  postsService,
  cacheService
);

const usersController = require('./users')(
  usersService
);

const rolesController = require('./roles')(
  rolesService
);
  

./controllers/api.js


const authController = require('./auth')(
  authenticationService,
  config
);

router.use('/posts', postsController);
router.use('/users', usersController);
router.use('/roles', rolesController);
router.use('/auth', authController);

return router;
  

./controllers/crud.js


const express = require('express');
const wrap = require('../helpers/wrap');

class CrudController {
  constructor(service) {...}

  async readAll(req, res) {...}
  async read(req, res) {...}
  async create(req, res) {...}
  async update(req, res) {...}
  async delete(req, res) {...}

  registerRoutes() {...}
}

module.exports = CrudController;
  

ctrls/crud.js => constructor


this.service = service;

this.readAll = this.readAll.bind(this);
this.read = this.read.bind(this);
this.create = this.create.bind(this);
this.update = this.update.bind(this);
this.delete = this.delete.bind(this);

this.router = express.Router();
this.routes = {
  '/': [{ method: 'get', cb: this.readAll }],
  '/:id': [{ method: 'get', cb: this.read }],
  '/create': [{ method: 'post', cb: this.create }],
  '/update': [{ method: 'post', cb: this.update }],
  '/delete': [{ method: 'post', cb: this.delete }]
};
  

ctrls/crud.js => readAll(req, res)


res.json(
  await this.service.readChunk(req.params)
);
  

ctrls/crud.js => read(req, res)


res.json(
  await this.service.read(req.params.id)
);
  

ctrls/crud.js => create(req, res)


res.json(
  await this.service.create(req.body)
);
  

ctrls/crud.js => update(req, res)


res.json(
  await this.service.update(req.body)
);
  

ctrls/crud.js => delete(req, res)


res.json(
  await this.service.delete(req.body.id)
);
  

ctrls/crud.js => registerRoutes()


Object.keys(this.routes).forEach(route => {
  let handlers = this.routes[route];

  if (!handlers || !Array.isArray(handlers)) {
    return;
  }

  for (let handler of handlers) {
    this.router[handler.method](
      route,
      wrap(handler.cb)
    );
  }
});
  

./controllers/posts.js


const CrudController = require('./crud');

class PostsController extends CrudController {
  constructor(postsService, cahceService) {...}
  async readAll(req, res) {...}
  async upvote(req, res) {...}
  async downvote(req, res) {...}
}

module.exports = (postsService, cacheService) => {
  const controller = new PostsController(
    postsService,
    cacheService
  );

  return controller.router;
};
  

ctrls/posts.js => constructor


super(postsService);

this.cacheService = cahceService;

this.readAll = this.readAll.bind(this);
this.upvote = this.upvote.bind(this);
this.downvote = this.downvote.bind(this);

this.routes['/'] = [{ method: 'get',
  cb: this.readAll }];
this.routes['/upvote'] = [{ method: 'post',
  cb: this.upvote }];
this.routes['/downvote'] = [{ method: 'post',
  cb: this.downvote }];

this.registerRoutes();
  

ctrls/posts.js => readAll(req, res)


const posts = await this.service.readChunk(
  req.params
);

this.cacheService.set(req, posts);

res.json(posts);
  

ctrls/posts.js => upvote(req, res)


await this.service.upvote(req.body.id);

res.json({ success: true });
  

ctrls/posts.js => downvote(req, res)


await this.service.downvote(req.body.id);

res.json({ success: true });
  

./controllers/users.js


const CrudController = require('./crud');

class UsersController extends CrudController {
  constructor(usersService) {...}
  async grant(req, res) {...}
  async revoke(req, res) {...}
}

module.exports = (usersService) => {
  const controller = new UsersController(
    usersService
  );

  return controller.router;
};
  

ctrls/users.js => constructor


super(usersService);

this.grant = this.grant.bind(this);
this.revoke = this.revoke.bind(this);

this.routes['/create'] = undefined;

this.routes['/grant'] = [{ method: 'post',
  cb: this.grant
}];

this.routes['/revoke'] = [{ method: 'post',
  cb: this.revoke
}];

this.registerRoutes();
  

ctrls/users.js => grant(req, res)


await this.service.grant(
  req.body.userId,
  req.body.roleId
);

res.json({ success: true });
  

ctrls/users.js => revoke(req, res)


await this.service.revoke(
  req.body.userId,
  req.body.roleId
);

res.json({ success: true });
  

./controllers/roles.js


const CrudController = require('./crud');

class RolesController extends CrudController {
  constructor(rolesService) {...}
}

module.exports = (rolesService) => {
  const controller = new RolesController(
    rolesService
  );

  return controller.router;
};
  

ctrls/roles.js => constructor


super(rolesService);

this.routes['/update'] = undefined;

this.registerRoutes();
  

./controllers/auth.js


const CrudController = require('./crud');

class AuthController extends CrudController {
  constructor(authenticationService, config) {...}
  async login(req, res) {...}
  async register(req, res) {...}
  async logout(req, res) {...}
}

module.exports = (authenticationService, config) => {
  const controller = new AuthController(
    authenticationService,
    config
  );

  return controller.router;
};
  

ctrls/auth.js => constructor


super(authenticationService);

this.config = config;

this.login = this.login.bind(this);
this.register = this.register.bind(this);
this.logout = this.logout.bind(this);

this.routes = { ... };

this.registerRoutes();
  

ctrls/auth.js => constructor


this.routes = {
  '/login': [{
    method: 'post',
    cb: this.login
  }],

  '/register': [{
    method: 'post',
    cb: this.register
  }],

  '/logut': [{
    method: 'post',
    cb: this.logout
  }],
};
  

ctrls/auth.js => login


const user = await this.service.login(req.body);

res.cookie(
  this.config.cookie.auth,
  user.id,
  { signed: true }
);

res.json({ success: true });
  

ctrls/auth.js => register


const user = await this.service.register(req.body);

res.cookie(
  this.config.cookie.auth,
  user.id,
  { signed: true }
);

res.json({ success: true });
  

ctrls/auth.js => logout


res.cookie(this.config.cookie.auth, '');

res.json({ success: true });