Последнее, что вам нужно, это еще один шаблон, о котором нужно беспокоиться…
Почему мы не можем просто написать код простым и понятным способом?
Ну… если вы спросите меня, шаблоны играют очень важную роль в разработке программного обеспечения.
Шаблоны проектирования VS Простой код
Нет такой вещи, как простой простой код.
Даже если вы не знаете никаких шаблонов, вы все равно используете их каждый раз, когда пишете код. Это называется «паттерн кода спагетти» 😊
Да, это может звучать восхитительно после долгих часов написания кода посреди ночи, но поверьте мне, это худший кошмар разработчика.
Шаблоны — ваши лучшие друзья, потому что они помогают организовать ваш код таким образом, чтобы он был понятным для чтения, гибким для расширений и простым для понимания.
Шаблоны могут значительно облегчить вашу жизнь и повысить скорость, с которой вы сможете добавлять новые функции в свой проект в будущем.
- Так что же должен делать шаблон базового репозитория?
- Как это выглядит?
- И в чем его основные преимущества?
Шаблон базового репозитория вводит уровень абстракции, реализованный между вашими моделями (логика предметной области) и уровнем сохраняемости (базой данных).
Это поможет вам отделить модели от уровня постоянства, чтобы в будущем вы могли легко изменять таблицы базы данных, не затрагивая логику вашего приложения.
Вы даже можете изменить всю свою реализацию базы данных, ваши модели все равно не должны заботиться об этом.
Говоря о некоторых неблагодарных моделях предметной области, верно? 😊
Давайте посмотрим на код, чтобы вы могли лучше понять концепцию.
Я буду использовать Typescript для этого, в основном потому, что он предоставляет сильные типы и интерфейсы для javascript. А также потому, что я использую его каждый день в своей работе. 😊
Если вы мало что знаете о Typescript, я предлагаю вам сначала прочитать это: Typescript — это Javascript со сверхспособностями.
Теперь давайте оглянемся назад, на переулок памяти…
Вот как я сохранял модели в базе данных:
import { User } from './../models' let user = new User('Bob', 'Smith', 29, 'front end developer') user.persiste()
И внутри модели пользователя:
import { myslqConnection } from './mysql-connection' export default class User { private _firstName : string private _lastName : string private _age : number private _job : string constructor( firstName : string, lastName : string, age : number, job : string ){ this._firstName = firstName this._lastName = lastName this._age = age this._job = job } persist() { // Somehow you need to pass in the configs for connecting to the database return myslqConnection.query(` INSERT INTO users (first_name, last_name, age, job) VALUES (?)`, [ this.firstName, this.lastName, this.age, this.job ]) } }
Это выглядит неправильно.
Вот несколько причин, по которым это абсолютная катастрофа:
- Я смешивал модель, как в бизнес-логике, со слоем сохраняемости. Наблюдения. Модель пользователя не должна знать, как она сохраняется в базе данных, поскольку ее это не волнует. Эти неблагодарные юзеры моделируют, им плевать на всё… 😊
- Я реализовал подключение к базе данных в реальной модели, что плохо, если вы когда-нибудь захотите изменить учетные данные.
Есть много других причин, по которым эта реализация плоха, но я не буду утомлять вас подробностями…
И последнее, что я хочу отметить
Представьте, что в понедельник утром к вам на стол подходит руководитель проекта и говорит:
«Мы решили изменить нашу текущую базу данных Mysql на Mongo DB. Мы думаем, что это будет намного быстрее. Ваша задача — перенести наш текущий стек, чтобы он работал с новой БД…»
- Это означает, что вам придется обновить все ваши модели.
- Убедитесь, что ваша основная логика не нарушена
- Повторите каждый автоматический тест, который у вас есть на этих моделях.
Представьте УЖАС…
Пока не выбрасывайте свой ноутбук в окно, потому что есть решение.
Репозитории в помощь
Теперь давайте посмотрим, как мы можем реализовать этот слой между моделями и базой данных.
- Мы можем поместить логику постоянства в класс BaseModel, а затем расширить его во всех наших моделях. Хм, это ничего не решит, на самом деле, это еще немного усложнит ситуацию.
- Мы можем использовать пакет ORM, который абстрагирует все биты и бобы. Да, это может немного помочь, но тогда мы по-прежнему будем совмещать анемичные модели с логикой ORM. Если вам нужно изменить ORM, у вас будет та же проблема, что и раньше.
- Мы можем использовать другой класс, который может обрабатывать логику постоянства, а затем передавать модели для сохранения в базу данных. Хм, звучит куда лучше…
Давай попробуем.
Но прежде чем приступить к написанию этого «специального» класса для управления сохранением и извлечением из базы данных, давайте подумаем нестандартно и начнем с того, как наша реализация должна выглядеть снаружи.
- Нам нужен чистый способ обработки моделей
- Мы не хотим передавать учетные данные базы данных в конструкторе этого класса каждый раз, когда нам нужно его использовать.
- Мы хотим использовать тот же интерфейс на случай, если нам когда-нибудь понадобится изменить тип базы данных.
Мы хотим что-то вроде этого…
import { connexion } from './connexion' let repo = new UserRepo(connexion) // this is the connexion to the database // Create the user model let user = new User('Bob', 'Smith', 29, 'front end developer') // Save the user model to the database repo.create(user) // Get a user from the database let otherUser = repo.findOne({ id: 1 }) // DONE, simple as that
Понимаете…
Мы абстрагировали всю логику, которая обрабатывает вставку и выборку, внутри этого класса UserRepo.
Если нам когда-нибудь понадобится изменить базу данных, мы сможем сделать это, не затрагивая бизнес-логику нашего приложения.
Я покажу вам, как реализовать этот шаблон с нуля, а затем давайте начнем с создания класса BaseRepository.
Это будет абстрактный класс, который может содержать методы, которыми мы собираемся поделиться для всех наших репозиториев.
Как создать абстрактный класс BaseRepository в Typescript
import Context from './Context' export default abstract class BaseRepository<T> { protected _context : Context constructor(context : Context) { this._context = context } create(model : T) : Promise<null> { throw new Error('Method not implemented.') } update(id : number, model : T) : Promise<null> { throw new Error('Method not implemented.') } delete(id : number) : Promise<null> { throw new Error('Method not implemented.') } all() : Promise<T[]> { throw new Error('Method not implemented.') } findOne(id : number) : Promise<T> { throw new Error('Method not implemented.') } }
Как видите, у нас есть абстрактный класс, который содержит методы для создания, обновления, удаления сущностей, а также для получения всех и поиска одной по id.
Это все методы, которыми мы будем делиться между конкретными реализациями.
Если вам интересно, какой класс Context передается в конструктор, то это способ абстрагировать соединение с базой данных.
Это просто хороший способ хранить все, что нужно изменить по одной и той же причине, в одном месте.
Внутри класс Context выглядит так:
import { Connection, createConnection } from 'mysql' // npm install mysql export default class Context { private _conn? : Connection private _host : string private _database : string private _username : string private _password : string private _port : number constructor( host : string, database : string, username : string, password : string, port? : number) { this._host = host this._database = database this._username = username this._password = password this._port = port || 3306 //optional and default 3306 } connect() { this._conn = createConnection({ host: this._host, user: this._username, database: this._database, password: this._password, port: this._port }) } private _assertConnectionInitialized() { if(!this._conn) { throw Error('Connection is not initialized') } } // Wrapping the callback in a Promise async query(query : string, params? : any[]) : Promise<any> { // Check if the connection is initialized this._assertConnectionInitialized() // Return a Promise and handle the callback inside it return new Promise((resolve, reject)=> { this._conn!.query(query, params, (err, result)=> { if(err) { return reject(error) } return resolve(result) }) }) } // You shouldn't need this, but it's always good to have get connection() { return this._connection } // This is going to be useful for transactions rollback() { this._assertConnectionInitialized() this._conn!.rollback() this._conn!.end() } // Use this to close the connection or commit the transaction complete() { this._assertConnectionInitialized() this._conn!.commit() this._conn!.end() } }
Вы можете пропустить класс Context и просто инициализировать соединение с базой данных внутри UserRepository или BaseRepository, но я думаю, что это более чистый способ сделать это.
Только представьте, что вы передаете конфиденциальные учетные данные каждый раз, когда вызываете класс репозитория. Это значительно усложнит изменение конфигурации подключения, когда вам это нужно.
Теперь давайте создадим реализацию этого класса в виде UserRepository.
Наш UserRepository расширит BaseRepository и будет использовать объект Context для подключения к базе данных и выполнения всевозможных запросов.
import Context from './Context' import BaseRepository from './BaseRepository' import { User, IUserRepository } from './../../Domain' import UserTransformer from './UserTransformer' // will explain later export default class UserRepository extends BaseRepository<User> implements IUserRepository { constructor(context : Context) { super(context) } async findOne(id : number) { this._context.connect() let rows = await this._context.query( `SELECT * FROM clients WHERE id = ?`, [ id ] this._context.complete() return ClientTransformer.toModel(rows)[0] } async all() { this._context.connect() let rows = await this._context.query( `SELECT * FROM clients`) this._context.complete() return ClientTransformer.toModel(rows) } async create(model : Client) { let raw = ClientTransformer.toRaw(model) let values = Object.values(raw) let columns = Object.keys(raw).join(',') this._context.connect() await this._context.query( `INSERT INTO clients (${ columns }) VALUES (?)`, [ values ]) this._context.complete() return null } async update(id : number, model : Client) { let raw = ClientTransformer.toRaw(model) let pairs = Object.keys(raw).map((key)=> { return `${ key } = ?` }).join(',') let values = Object.values(raw) this._context.connect() await this._context.query( `UPDATE clients SET ${ pairs } WHERE id = ?`, [ ...values, id ]) this._context.complete() return null } async delete(id : number) { this._context.connect() await this._context.query( `DELETE FROM clients WHERE id = ?`, [ id ]) this._context.complete() return null } }
Вам может быть интересно, почему я предпочитаю возвращать null из методов создания, удаления и обновления.
Это связано с принципом, который сформулировал создатель CQS (Command Query Separation)Бертран Мейер:
Задавание вопроса не должно менять ответ
Подробнее о Командно-запросном разделении можно прочитать здесь.
Но если коротко, мы хотим разделить INSERT, DELETE, UPDATE как команды, которые ничего не возвращают, и SELECT как запрос, который возвращает объект или объекты, которые вы ожидаете.
Давайте подробнее рассмотрим UserTransformer.
Нет, это не часть десептиконов 😊.
Это просто способ сопоставить свойства модели предметной области со столбцами таблицы и из столбцов таблицы базы данных с моделью предметной области.
Помните, что мы не хотим пачкать руки логикой персистентности, мы хотим получать обратно из наших репозиториев уже построенные модели.
Так как же выглядит UserTransformer?
import { User } from './../../Domain' export default abstract class UserTransformer { static toModel(rows : { [key : string] : any }[]) { return rows.map((row)=> { return new User( row.first_name, row.last_name, row.age, row.job) }) } static toRaw(model : User) { return { first_name: model.firstName, last_name: model.lastName, age: model.age, } } }
Все, что он делает, — это преобразует модель в данные строки и преобразует необработанные данные в пользовательскую модель.
Я надеюсь, что это поможет вам лучше понять, как работает шаблон базового репозитория и почему это хорошая идея — реализовать какой-то слой между логикой предметной области и уровнем постоянства.
Но есть пара вещей, которые я бы все же улучшил в этом…
Как вы можете улучшить свои репозитории помимо реализации этой статьи?
- Вы можете не хранить ссылку на соединение с базой данных в классе Context.
- Вы можете использовать библиотеку, которая уже упаковывает запросы mysql в промис. Проверьте это: https://www.npmjs.com/package/promise-mysql
- Вы можете разделить свой UserRepository на WriteUserRepository и ReadUserRepository. Таким образом, у вас будет лучшее разделение интересов.
- Это открытый список, поэтому не стесняйтесь добавлять дополнительные улучшения в разделе комментариев ниже. Я хотел бы увидеть, что вы придумали.
Одна из серьезных проблем с использованием шаблона базового репозитория заключается в том, что вы получаете один миллион зависимостей, о которых вам нужно позаботиться.
Не беспокойтесь об этом, потому что вы всегда можете использовать контейнер зависимостей для обработки всех ваших… ну… зависимостей.
Если вы уже используете один, это прекрасно.
Но если нет, взгляните на этот, который я создал и использую во всех своих проектах.
Используйте внедрение зависимостей Typescript: https://www.npmjs.com/package/typescript-dependency-injection
Это проект с открытым исходным кодом, так что не стесняйтесь вносить свой вклад.
Это поможет НЕ писать это каждый раз, когда вам нужно выбрать модель пользователя из базы данных…
import { Context, UserRepository } from './somewhere-far-far-away' let context = new Context('host', 'databse_name', 'username', 'password') let repository = new UserRepository(context) let user = repository.findOne({ id: 23 })
Вместо этого вы можете сделать это только один раз:
import { Container } from 'typescript-dependency-injection' import { Context, UserRepository } from './somewhere-far-far-away' let container = new Container() container.register(Context) .dependsOnString('host') .dependsOnString('databse_name') .dependsOnString('username') .dependsOnString('password') container.register(UserRepository) .dependsOnClass('Context') export default container
И затем, каждый раз, когда вам нужен UserRepository, вы можете делать это:
import container from './where-ever-the-container-is' let repo = container.get('UserRepository') // DONE
Не забудьте похлопать меня, если вам понравилась статья, или написать мне комментарий в разделе ниже.