Принципы и приемы написания эффективного кода

Александр Завьялов, Евгений Мокеев

Сервис «Заметки»

app/
└── controllers/
   └── notes.js
   └── pages.js
└── models/
   └── note.js
└── views/
└── index.js
└── routes.js
GET  /
    → GET  /notes
    → POST /notes
    → GET  /notes/:name

Задача

Аутентификация пользователя

  • Страница с формой
  • Проверка логина/пароля
  • Редирект на главную при успешной проверке
  • Вывод ошибки при неудачной попытке

Как это работает

Страница аутентификации

controllers/
   └── user.js


exports.login = (req, res) => {
    res.render('login');
};
 routes.js


const user = require('./controllers/user');

module.exports = function (app) {
    app.get('/login', user.login);
}
GET /login
views/
    └── login.hbs


Модель пользователя

models/
   └── user.js


const users = [
    {id: 1, username: 'admin', password: 'admin'}
];

class User {
    static find(username, password) {}

    static findById(id) {}
}

module.exports = User;

Сессии

Способность сохранять и восстанавливать окружение посетителя между запросами к сайту.

Сессии

package.json


"dependencies": {
    "express-session": "^1.13.0",
}
index.js


app.use(require('express-session')({}));

req.session

Библиотека passport

lib/
    └── passport/
        └── index.js


const User = require('../../models/user');

exports.authenticate = (req, res) => {};

exports.onlyAuth = (req, res) => {};

exports.authenticate = (req, res) => {
    const user = User.find(
        req.body.username,
        req.body.password
    );

    if (!user) {
        req.session.authFailMessage =
            'Пользователь не найден';
        res.redirect('/login');
        return;
    }

    req.session.auth = { id: user.id };

    res.redirect('/');
};

exports.onlyAuth = (req, res, next) => {
    const auth = req.session.auth;

    if (auth && auth.id) {
        if (User.findById(auth.id)) {
            return next();
        }
    }

    res.sendStatus(401);
};
controllers/
    └── user.js


exports.login = (req, res) => {
    res.render('login');
};

exports.login = (req, res) => {
    const data = {};

    if (req.session.authFailMessage) {
        data.error = req.session.authFailMessage;
    }

    res.render('login', data);
};

exports.profile = (req, res) => {
    res.send('Страница профиля');
};
routes.js


app.post('/login', passport.authenticate);
POST /login


app.get('/profile', passport.onlyAuth, user.profile);
GET  /profile

Задание выполнено

Новые фичи

  • Данные пользователя на странице профиля
  • Увеличить надежность проверки пароля
  • Редиректить на другую страницу
  • Показать другое сообщение об ошибке
  • Аутентифицировать через социальные сети
  • Прикрутить к трем других проектам

Переписывать весь код?

Почему?

Проблемы нашей бибиотеки

  • Много обязанностей
  • Много знаний о внешних объектах
  • Нет точек расширения/эволюции

Ароматы плохого модуля

  • Жесткий дизайн
  • Хрупкость
  • Монолитность
  • Сложность

SOLID

S - Single responsibility principle

O - Open/closed principle

L - Liskov substitution principle

I - Interface segregation principle

D - Dependency inversion principle

Зачем?

На каждую сущность должна быть возложена одна единственная ответственность.

God Object

Результат

  • Четкая структура приложения
  • Маленькие модули
  • Простое тестирование
  • Реиспользование модулей
  • Меньше проблем при доработках

Middleware

routes.js


app.get(
    '/profile',
    passport.onlyAuth,
    user.profile
);


Chain of Responsibility

Делит ответственность за обработку между несколькими обработчиками
lib/
└── passport
    └── index.js


    exports.onlyAuth = (req, res, next) => {
    const auth = req.session.auth;

    if (auth && auth.id) {
        if (User.findById(auth.id)) {
            return next();
        }
    }

    res.sendStatus(401);
};
lib/
└── passport
    └── index.js


exports.initUser = (req, res, next) => {
    const auth = req.session.auth;

    if (auth && auth.id) {
        req.user = User.findById(auth.id);
    }

    next();
};

exports.onlyAuth = (req, res, next) => {
    if (!req.user) {
        res.sendStatus(401);
        return;
    }

    next();
};
index.js


const passport = require('./lib/passport');

app.use(passport.initUser);
controllers/
└── user.js


exports.profile = (req, res) => {
    res.send('Страница профиля');
};

exports.profile = (req, res) => {
    res.send(`Привет, ${req.user.username}!`);
};
Сущности (классы, модули, функции) должны быть открыты к раширению, но закрыты от модификаций.

Результат

  • Гибкие сущности
  • Простое тестирование
  • Меньше сильных связей
  • Нет правок в базовых сущностях
lib/
└── passport
    └── index.js


exports.authenticate = (req, res) => {
    const user = User.find(
        req.body.username,
        req.body.password
    );

    if (!user) {
        req.session.authFailMessage =
            'Пользователь не найден';
        res.redirect('/login');
        return;
    }

    req.session.auth = { id: user.id };
    res.redirect('/');
};
routes.js


app.post('/login', passport.authenticate);


app.post('/login', passport.authenticate({
    successRedirect: '/',
    failureRedirect: '/login',
    failureMessage: 'Неправильный логин или пароль'
}));
lib/
└── passport
    └── index.js


exports.authenticate = (req, res) => {};


exports.authenticate = options => {
    return (req, res) => {};
};

exports.authenticate = options => {
    return (req, res) => {
        const user = User.find(
            req.body.username,
            req.body.password
        );

        if (!user) {
            req.session.authFailMessage =
                options.failureMessage'Пользователь не найден';
            res.redirect(options.failureRedirect'/login');
            return;
        }

        req.session.auth = { id: user.id };

        res.redirect(options.successRedirect'/');
    }
};


Factory

Создает однотипные объекты

    exports.authenticate = options => {
    return (req, res) => {
        const user = User.find(
            req.body.username,
            req.body.password
        );

        if (!user) {
            req.session.authFailMessage =
                options.failureMessage ;
            res.redirect( options.failureRedirect );
            return;
        }

        req.session.auth = { id: user.id };

        res.redirect(options.successRedirect);
    }
};
lib/
└── passport
    └── index.js


exports.initUser = (req, res, next) => {
    const auth = req.session.auth;

    if (auth && auth.id) {
        req.user = User.findById(auth.id);
    }

    next();
};

Сильные связи

Умные вещи


// Сериализация пользователя
auth = { id: user.id }


// Десериализация пользователя
user = User.findById(auth.id)
lib/
└── passport
    └── index.js


let serialize = () => {};
let deserialize = () => {};

exports.authenticate = options => {
    return (req, res) => {
        const user = User.find(
            req.body.username,
            req.body.password
        );

        if (!user) {
            req.session.authFailMessage =
                options.failureMessage ;
            res.redirect( options.failureRedirect );
            return;
        }

        req.session.auth = { id: serialize(user)user.id }

        res.redirect(options.successRedirect);
    }
};
lib/
└── passport
    └── index.js


exports.initUser = (req, res, next) => {
    const auth = req.session.auth;

    if (auth && auth.id) {
        req.user = deserialize(auth.id)User.findById(auth.id)
    }

    next();
};
lib/
└── passport
    └── index.js


exports.registerSerializer = fn => {
    serialize = fn;
};

exports.registerDeserializer = fn => {
    deserialize = fn;
};

Кто знает о пользователе?

models/
└── user.js


class User {
    static getSerializator() {
        return user => user.id;
    }

    static getDeserializator() {
        return id => User.findById(id);
    }
}

Что осталось?

index.js


const User = require('./models/user');
const passport = require('./lib/passport/index');

passport.registerSerializer(User.getSerializator());
passport.registerDeserializer(User.getDeserializator());

Нужно помнить об инициализации!

lib/
└── passport/
    └── index.js
    passport.js


const User = require('../models/user');
const passport = require('./passport/index');

passport.registerSerializer(User.getSerializator());
passport.registerDeserializer(User.getDeserializator());

module.exports = passport;


Decorator

Расширение базовой функциональности без наследования

Будут ли повторные инициализации?

require кеширует результат


function Universe() {
    // имеется экземпляр, созданный ранее?
    if (typeof Universe.instance === 'object') {
        return Universe.instance;
    }

    // создать новый экземпляр
    this.bang = "Big";

    // сохранить его
    Universe.instance = this;
}

// проверка
const uni = new Universe();
const uni2 = new Universe();
uni === uni2;   // true


Singleton

Создание уникальных объектов, существующих только в одном экземпляре
Сущности, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа не зная об этом.
Подкласс не должен требовать от вызывающего кода больше, чем базовый класс, и не должен предоставлять вызывающему коду меньше, чем базовый класс.

Результат

  • Предсказуемое поведение потомков

exports.authenticate = options => {
    return (req, res) => {
        const user = User.find(
            req.body.username,
            req.body.password
        );

        if (!user) {
            req.session.authFailMessage =
                options.failureMessage ;
            res.redirect( options.failureRedirect );
            return;
        }

        req.session.auth = { id: serialize(user) }

        res.redirect(options.successRedirect);
    }
};
lib/
└── passport/
    └── index.js


const strategies = {};

exports.registerStrategy = (name, strategy) => {
    strategies[name] = strategy;
};

Что делает стратегия?


exports.authenticate = options => {
    return (req, res) => {
        /* поиск пользователя */const user = User.find(
            req.body.username,
            req.body.password
        );

        if (!user) { /* проверка пользователя */
            /* обработка ошибки */req.session.authFailMessage =
                options.failureMessage;
            res.redirect( options.failureRedirect );
            return;
        }

        /* обработка успешного результата */req.session.auth = { id: serialize(user) }

        res.redirect(options.successRedirect);
    }
};

exports.authenticate = (name, options) => {
    return (req, res) => {
        const strategy = strategies[name];

        /* проверка пользователя */
        strategy.authenticate(req, (err, user) => {
            if (err) {
                /* обработка ошибки */
            }

            /* обработка успешного результата */
        });
    }
};
lib/
└── passport
    └── strategies
        └── strategy.js


class Strategy {
    constructor(verify) {
        this._verify = verify;
    }

    authenticate(req, done) {
        const username = req.body.username;
        const password = req.body.password;

        this._verify(username, password, done);
    }
}

module.exports = Strategy;
lib/
└── passport.js


const Strategy = require('./passport/strategies/strategy');

const verify = (username, password, done) => {
    const user = User.find(username, password);

    const err = user ? null : new Error('...');

    done(err, user);
}

passport.registerStrategy('local', new Strategy(verify));

Задаем стратегию при подключении аутентификации

routes.js


app.post('/login', passport.authenticate('local', {
    successRedirect: '/',
    failureRedirect: '/login'
}));


Strategy

Выбор алгоритма поведения независимо от клиентов, которые его используют

Нужна еще одна стратегия?

lib/
└── passport
    └── strategies
        └── strategy.js
        └── anotherStrategy.js


var Strategy = require('./strategy');

class AnotherStrategy extends Strategy  {
    constructor(verify) {}

    authenticate(req, done) {}
}

Passport зависит от Strategy

AnotherStrategy зависит от Strategy

Плохо

lib/
└── passport
    └── strategies
        └── BaseStrategy.js


class BaseStrategy {
    constructor(verify) {
        this._verify = verify;
    }

    authenticate(req, done) {
        done(new Error('Такого пользователя нет'));
    }
}

module.exports = BaseStrategy;
lib/
└── passport
    └── strategies
        └── BaseStrategy.js
        └── LocalStrategy.js


var BaseStrategy = require('./BaseStrategy');

class LocalStrategy extends BaseStrategy {
    authenticate(req, done) {
        const username = req.body.username;
        const password = req.body.password;

        this._verify(username, password, done);
    }
}

module.exports = LocalStrategy;
lib/
└── passport
    └── strategies
        └── BaseStrategy.js
        └── CookieStrategy.js


var BaseStrategy = require('./BaseStrategy');

class CookieStrategy extends BaseStrategy {
    authenticate(req, done) {
        userId = req.cookies.userId;

        this._verify(userId, done);
    }
}

module.exports = CookieStrategy;
lib/
└── passport
    └── strategies
        └── BaseStrategy.js
        └── BadStrategy.js


    var BaseStrategy = require('./BaseStrategy');

class BadStrategy extends BaseStrategy {
    authenticate(req, done) {
        userId = req.cookies.userId;

        if (userId === 9) {
            throw new Error('...');
        }

        this._verify(userId, done);
    }
}
Маленькие интерфесы лучше больших

Интерфейсы и JS

Результат

  • Маленькие интерфейсы
  • Один интерфейс = одна роль
models
└── user.js


class User {
    static findByName(username) {}

    static checkPassword(password){}static find(username, password) {}
}
lib
└── passport.js


passport.registerStrategy('local',
    new Strategy((username, password, done) => {
        const user = User.findByName(username);

        if (!user) {
            done(new Error('Пользователя не существует'));
            return;
        }

        if (!user.checkPassword(password)) {
            done(new Error('Неправильный пароль'));
            return;
        }

        done(null, user);
    })
);
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракции.

Результат

  • Модули не имеют жестких связей
  • Изменения локализованны
  • Изменения не ломают систему
lib/
└── passport.js


// Инъекция через метод
passport.registerSerializer(...);
passport.registerDeserializer(...);
passport.registerStrategy(...);

// Инъекция через конструктор
passport.registerStrategy('local', new LocalStartegy(...));

// Инъекция через свойство
passport.strategy = ...;

Что мы получили?

  • Данные пользователя на странице профиля
  • Аутентифицировать через социальные сети
  • Увеличить надежность проверки пароля
  • Редиректить на другую страницу
  • Показать другое сообщение об ошибке
  • Прикрутить к трем других проектам

Без фанатизма!

Авторизация в «Заметках»

Книги

Паттерны проектирования

Node.js Design Patterns

Learning JavaScript Design Patterns

JavaScript. Шаблоны

Приемы объектно-ориентированного
проектирования