Скажу сразу, я окончил суммарно 8 классов, не пошел в университет и прочёл всего 5 книг за всю свою жизнь, я не претендую на звание "топ архитектор" и не утверждаю что материалы описанные ниже являться стандартом и обязательны к реализации на любом проекте, это лишь мой опыт, который позволил мне решать более широкий спектр задач.
Данные руководство подразумевает что вы как минимум знакомы с основами программирования и изучали объектно ориентированный подход ( ООП )
В данном уроке используется сервис draw.io и базовые элементы UML.
Мотивацией написать это руководство служит безграничное количество оупен-сорс/слитых проектов с процедурным, нечитаемым, ужасным кодом, по этому и начнём мы с этого.
Ниже я привёл несколько популярных вопросов и заблуждений связанных с темой.
Я думаю примерно такие мысли посещают умы людей которые пишут ресурсы/проекты процедурно, используя "модульный подход".Зачем проектировать? Я разбиваю свой проект на модули и этого достаточно!
К сожалению модульный подход не решает все проблемы, он лишь обеспечивает читабельность кода и структурирует файлы проекта.
В первую очередь проектирование поможет найти дыры в логике, потенциальные проблемы с реализацией ещё до того как вы откроете вашу любимую среду разработки.В чем конкретно поможет мне проектирование?
Вы сможете до мелочей продумать определённую систему, разбить все на конкретные классы и исключить повторение кода, что уже добавить вас +100 к эффективности разработки.
Так же это даст вам полный контроль над системой, упростится её поддержка и расширение, при условии что вы все сделали правильно.
Я слышал это утверждение сразу от нескольких моих знакомых, "Проектировать на моём пет проекте/маленьком проекте не имеет смысла, я потрачу время зря".Проектирование занимает слишком много времени
Забавно но все работает в точности да наоборот, как раз таки проектирование поможет сохранить ваше время в будущем, потратив час-второй на то что бы расписать классы и их взаимодействие на draw.io сэкономит вам уйму времени в будущем.
Теперь же, после того как мы выяснили зачем нам это нужно, мы можем приступить к более подробному алгоритму действий, а так-же к наглядным примерам.
Важная ремарка, что бы закрепить все дальнейшие действия на практике, рекомендую повторить это с любой уже написанной вами ранее системой.
Что бы понять как все это работает мы сделаем все как обычно, без проектирования, просто сядем и напишем какую-то процедурную систему, которая будет выполнять одну простенькую задачу, а затем сядем и спроектируем эту же задачу, после чего снова реализуем её в коде, писать мы будем систему админ-авто, её описание ( ТЗ ) будет ниже.
Важно отметить что данный код не будет тестироваться в игре, это ни к чему, моя задача показать обычный код, который написал бы работяга, а затем спроектировать эту же систему и реализовать её, дабы наглядно показать разницу в эффективности.
Техническое задание:
Для начала сделаем базовую настройку проекта, я буду использовать NodeJS + TypeScript ( Server ) / JavaScript + TypeScript ( Client ) / Esbuild в качестве сборщика.Реализовать систему "админ-транспорта", добавить команду которая будет спавнить авто для администратора с указанной моделью авто, при выходе администратора из авто, оно удаляется спустя N времени, реализовать возможность на ходу заменить транспорт на другой, предусмотреть возможность починки транспорта и смены его цвета. Так-же не забываем про ручное удаление авто.
Разделим проект на Server/Client, сделаем npm i в каждой папочке, настроим Esbuild так, что бы он собирал все в один файлик в папку дист как для сервера так и для клиента.
Я не буду показывать как, все это вы сможете найти в github репозитории.
Далее я сделаю один костыль, я немного расширю базовый тип PlayerMp, добавив туда пару своих полей.
( не рекомендую это делать, рано или поздно вы запутаетесь в этом, но для примера - сойдёт. )
Посмотрим на структуру файлов:
Основной файл нашего модуля adminCar.ts, с него и начнём.
JavaScript:
// Время после выхода из авто, для его удаления
const TIMEOUT_TIME = 10000;
// Функция создающая админ-машину
export function createCar(player: PlayerMp, model: number): VehicleMp {
const { x, y, z } = player.position;
// Создаём машину рядом с игроком
const vehicle = mp.vehicles.new(model, new mp.Vector3(x + 2, y, z), {
dimension: player.dimension,
heading: player.heading,
numberPlate: 'JhonnyGay(ts)'
});
// Таймер - это костыль, что бы подождать пока машина появится
setTimeout(() => {
// Засовываем игрока на место водителя
player.putIntoVehicle(vehicle, 0);
}, 100);
// Возвращаем авто
return vehicle;
}
// Функция для уничтожения админ-машины
export function destroyCar(vehicle: VehicleMp, reason?: string) {
// Проверяем есть ли такая машина на сервере
if(!mp.vehicles.exists(vehicle)) {
return
}
// Выкидываем всех игроков с машины
removeOccupants(vehicle);
// Удаляем Entity машины
vehicle.destroy();
// Дополнительно, выводим по какой причине была удалена машина
if(reason) {
console.log(`Машина удалена по причине: ${reason}`);
}
}
// Функция для обработки выхода админа из машины
export function resetTimeout(player: PlayerMp, vehicle: VehicleMp) {
// Проверяем что машина и игрок на сервере
if(!mp.players.exists(player) || !mp.vehicles.exists(vehicle)) {
return
}
// Запускаем таймер, и в хендлере удаляем авто
const timeout = setTimeout(() => {
destroyCar(vehicle, 'timeout');
player.adminCar = null;
}, TIMEOUT_TIME);
// Возвращаем таймер
return timeout;
}
// Функция для замены авто на другое
export function swapCar(player: PlayerMp, currentVehicle: VehicleMp, newVehicleModel: number): VehicleMp {
if(mp.vehicles.exists(currentVehicle)) {
removeOccupants(currentVehicle);
destroyCar(currentVehicle, 'swapCar')
}
return createCar(player, newVehicleModel);
}
// Функция для починки авто
export function repairCar(vehicle: VehicleMp) {
if(!mp.vehicles.exists(vehicle)) {
return;
}
vehicle.repair();
}
// Функция для смены цвета авто
export function changeColor(vehicle: VehicleMp, color: RGB) {
if(!mp.vehicles.exists(vehicle)) {
return
}
vehicle.setColorRGB(color[0], color[1], color[2], color[0], color[1], color[2]);
}
// Утилити функция для высадки всех из машины
function removeOccupants(vehicle: VehicleMp) {
if(vehicle.getOccupants().length > 0) {
vehicle.getOccupants().forEach((player) => {
if(mp.players.exists(player)) {
player.removeFromVehicle();
}
});
}
}
Далее нам нужно обрабатывать ивенты такие как игрок вышел с машину, что бы удалить машину, игрок вышел из игры, что бы опять же удалить машину, игрок зашел в игру, что бы подготовить его окружение к системе и игрок вошел обратно в транспорт, что бы сбросить таймер, все это мы обрабатываем в events.ts
JavaScript:
import { destroyCar, resetTimeout } from "./adminCar";
// Игрок зашел в игру
mp.events.add('playerJoin', (player: PlayerMp) => {
// Подготовим игрока к системе
player.isAdmin = true;
player.adminCar = null;
player.adminCarTimer = null;
});
// Игрок покинул транспорт
mp.events.add('playerExitVehicle', (player: PlayerMp, vehicle: VehicleMp) => {
// Куча проверок, что бы не получить ошибки при исполнении
if(!player.adminCar || player.adminCar.id !== vehicle.id) {
return;
}
if(player.adminCarTimer !== null) {
clearTimeout(player.adminCarTimer);
player.adminCarTimer = null;
}
// Ресетим таймер модулем
player.adminCarTimer = resetTimeout(player, vehicle);
});
// Игрок зашел в машину
mp.events.add('playerEnterVehicle', (player: PlayerMp, vehicle: VehicleMp) => {
// Опять же, миллион проверок.
if(!player.adminCar || player.adminCar.id !== vehicle.id) {
return;
}
if(player.adminCarTimer === null) {
return;
}
// Чистим таймер
clearTimeout(player.adminCarTimer);
player.adminCarTimer = null;
});
// Игрок вышел
mp.events.add('playerQuit', (player: PlayerMp) => {
// Убираем машину
if(player.adminCar !== null) {
destroyCar(player.adminCar);
}
// Чистим таймер
if(player.adminCarTimer !== null) {
clearTimeout(player.adminCarTimer);
}
});
Последний файл, commands.ts, задача файла принимать команды игрока, обрабатывать доступ и параметры, после чего делегировать логику на основной модуль ( adminCar.ts )
JavaScript:
import { changeColor, createCar, destroyCar, repairCar, swapCar } from "./adminCar";
mp.events.addCommand('admincar', (player: PlayerMp, fullText: string, model: string) => {
// Куча проверок
if(!player.isAdmin || player.adminCar !== null) {
return;
}
// Получаем модель
const modelHash = model ? mp.joaat(model) : null;
if(modelHash === null) {
return;
}
// Создаём машину с помощью основного модуля
player.adminCar = createCar(player, modelHash);
});
// Удаляем машину, все аналогично
mp.events.addCommand('delcar', (player: PlayerMp, fullText: string) => {
if(!player.isAdmin || player.adminCar === null) {
return;
}
destroyCar(player.adminCar, 'delcar');
if(player.adminCarTimer !== null) {
clearTimeout(player.adminCarTimer);
player.adminCarTimer = null;
}
player.adminCar = null;
});
// Меняем цвет машины
mp.events.addCommand('colorcar', (player: PlayerMp, fullText: string, red: string, green: string, blue: string) => {
if(!player.isAdmin || player.adminCar === null) {
return;
}
if(!red || !green || !blue) {
return;
}
const rgb: RGB = [parseInt(red), parseInt(green), parseInt(blue)];
changeColor(player.adminCar, rgb);
});
// Меняем машину
mp.events.addCommand('swapcar', (player: PlayerMp, fullText: string, newModel: string) => {
if(!player.isAdmin || player.adminCar === null) {
return;
}
const modelHash = newModel ? mp.joaat(newModel) : null;
if(modelHash === null) {
return;
}
player.adminCar = swapCar(player, player.adminCar, modelHash);
});
// Чиним машину
mp.events.addCommand('repair', (player: PlayerMp, fullText: string) => {
if(!player.isAdmin || player.adminCar === null) {
return;
}
repairCar(player.adminCar);
});
Подведём итоги нашей реализации.
Эта реализация не худшая из тех что я видел, но все же процедурный подход даёт о себе знать.
Ниже я постарался расписать плюсы и минусы такой реализации.
Минусы:
- Низкая читабельность кода, причём чем больше сюда будет добавляться методов, тем менее читабельнее будет код.
- Трудно расширять, что бы что то добавить нужно писать ещё одну функцию, причем чем больше мы добавляем, тем сложнее добавлять, а что бы к примеру добавить уровни к админке и и сделать все эти команды с определённого уровня нужно будет залезть в каждую команду и поменять условия доступа, а что если нужно будет ввести авторизацию для администратора? Теперь нам нужно опять идти по всем командам связанным с системой и добавлять ещё одну проверку.
- Низкая эффективность, исходя из пунктов выше вытекает этот, что бы расширять систему, её нужно читать, а у нас проблемы и с тем и с другим.
- Контроль над кодом, у нас есть плавающие значения по типу player.adminCar и player.adminCarTimer, которые нужно постоянно перепроверять.
Плюсы:
- Модульная структура файлов
- Быстрая реализация
- TypeScript
А теперь попробуем спроектировать эту систему, используя объектно ориентированный подход
Для начала немного объясню что мы будем использовать внутри сервиса, для удобства я создаю белый прямоугольник на фоне и блокирую его с помощью контекстного меню, почему то меня раздражает базовая сетка. Далее обратим внимание на блок слева, там нам в основном понадобится раздел UML.Без лишних слов перейдём к более серьёзному подходу к задаче и начнём мы на этот раз не с открытия Visual Studio Code, а с перехода на сайт draw.io.
Для более глубокого понимания происходящего мне не так давно посоветовали прочитать эту книгу, я её конечно не прочитал, просто пробежался по главам, но уверен, кому то информация отсюда будет восприниматься куда легче.
UML - это по сути язык разметки, которым мы будем описывать сущности в нашем случае системы, связи между этими сущностями и так далее
Начать стоит с разделение нашей системы на сущности в нашем примере будет логично создать три сущности: Admin, AdminHandler и AdminVehicle, причем в нашем варианте реализации мы будем использовать агрегацию.
Создадим две сущности, в Admin добавим поля: id: number, player: PlayerMp, vehicle: AdminVehicle, как видим вот и агрегация, наш Admin в определённый момент времени будет содержать ссылку на объект AdminVehicle, но при это AdminVehicle остаётся отдельной сущностью, далее опишем некоторые методы, так-как наш класс Admin в данном случае выступает классом-ацессором, который будет выполнять всего две функции, проверять доступ к командам и предоставлять доступ к AdminVehicle, тут будет всего четыре метода:Агрегация - это концепция которая говорит нам о том, что один объект содержит в себе в себе ссылку, или ссылки на другие объекты.
JavaScript:
hasAccess(), createVehicle(mode: Hash), onPlayerLeave() и onVehicleDestroy()
В AdminVehicle добавим следующие поля:
JavaScript:
vehicleEntity: VehicleMp, destroyTimer: ReturnType<typeof setTimeout> model: Hash, color: RGB
Так же добавим методы для обработки некоторых действий, а именно:
JavaScript:
init(player: PlayerMp), destroy(), changeColor(color: RGB), swapModel(newModel: Hash), onDriverExit(), onDriverEnter(), removeOccupants(), checkVehicle(), resetTimer(), repair()
Мы создадим два процедурных файл - controller.ts, который будет отвечать за обработку необходимых нам ивентов, таких как выход из авто, вход в авто, выход и вход игрока на сервер, так же если бы у нас было клиент-серверное взаимодействие, там были бы и другие ивенты которые мы бы вызывали с клиента и commands.ts, который будет обрабатывать команды игрока.
А теперь подумаем о том, как же нам получить доступ к классу Admin? Откуда мы будем у какого игрока есть сущность Admin, а у какого нет?
Тут на самом деле есть два варианта развития событий, первый как и в предыдущей реализации мы добавим в типизацию PlayerMp поле содержащее ссылку на объект Admin, и будем присваивать ссылку например при входе игрока на сервер, но у такого варианта есть несколько недостатков, самый главный из которых - сложность в читабельности. Мы никогда точно не знаем есть ли там объект, или нет, что является проблемой.
По этому мы пойдём по второму пути, мы создадим статический класс AdminHandler, который будет создавать, хранить, удалять и отдавать экземпляры.
Давайте посмотрим какие у него будут поля и методы, начнём с поля, тут есть одна очень распространённая ошибка делать storage статическим полем класса, таким образом мы сможем изменять этот сторедж с любого места программы, что нам не подходит, почему? Думаю объяснять не нужно. Тут есть много всяких вариантов и рекомендаций, но проще всего - создать переменную чуть выше класса, и не экспортировать её, что позволит нам иметь доступ к ней только внутри файла, чего нам будет достаточно.
JavaScript:
const storage = new Map<number, Admin>();
let idGenerator = 0;
Это - хранилище наших администраторов, сюда они будут попадать после создания их экземпляра и удаляться при удалении экземпляра.
Так же добавим utility переменную idGenerator, в коде будет ясно зачем она нужна.
Методы нашего статического класса:
JavaScript:
create(player: PlayerMp), getByPlayer(player: PlayerMp), remove(id: number).
В целом это все что нужно, давайте посмотрим на результат.
Получилась довольно простенькая диаграмма, три сущности, AdminHandler, Admin и AdminVehicle, AdminVehicle связан с Admin агрегацией, а AdminHandler создаёт экземпляры Admin, так же AdminHandler использует две внешние переменные, storage и idGenerator.
Выглядит неплохо, я потратил на это от силы минут 10, теперь же давайте напишем код
Реализация в отличии от предыдущей будет опираясь на парадигму ООП, с использованием принципов SOLID, DRY и так далее.
Рассмотрим файлы, структура получилась следующая:Важная ремарка, я сделаю один костыль, что бы не писать хуки, метод AdminVehicle destroy будет оповещать класс AdminHandler о том, что машина уничтожена, в свою очередь AdminHandler будет находить кому эта машина принадлежит и присваивать полю adminVehicle = null, так делать не нужно, обычно для таких целей использую хуки, либо ивенты.
Admin.ts
JavaScript:
import { AdminVehicle } from "./AdminVehicle";
export class Admin {
public readonly id: number;
private readonly _player: PlayerMp;
private _adminVehicle: AdminVehicle;
public get player() : PlayerMp {
return this._player;
}
public get adminVehicle() : AdminVehicle {
return this._adminVehicle;
}
constructor(id: number, player: PlayerMp) {
this.id = id;
this._player = player;
this._adminVehicle = null;
}
hasAccess(): boolean {
if(this._adminVehicle === null) {
return false;
}
// Сколько угодно различных проверок
return true;
}
createVehicle(model: number) {
if(this._adminVehicle !== null) {
return;
}
this._adminVehicle = new AdminVehicle(model, [0, 0, 0]);
this._adminVehicle.init(this.player);
}
onPlayerLeave() {
this._adminVehicle.destroy();
}
onVehicleDestroy() {
this._adminVehicle = null;
}
}
AdminHandler.ts
JavaScript:
import { Admin } from "./Admin";
import { AdminVehicle } from "./AdminVehicle";
const storage = new Map<number, Admin>();
let idGenerator = 0;
export class AdminHandler {
static create(player: PlayerMp) {
if(this.getByPlayer(player)) {
return;
}
const id = idGenerator++;
const admin = new Admin(id, player);
storage.set(id, admin);
}
static getByPlayer(player: PlayerMp) {
return [...storage.values()].find((item) => item.player.id === player.id);
}
static getByAdminVehicle(adminVehicle: AdminVehicle) {
return [...storage.values()].find((item) => item.adminVehicle.vehicleEntity.id === adminVehicle.vehicleEntity.id);
}
static remove(id: number) {
if(storage.has(id)) {
storage.delete(id);
}
}
}
AdminVehicle.ts
JavaScript:
import { AdminHandler } from "./AdminHandler";
const DESTROY_TIMER = 10000;
export class AdminVehicle {
private _vehicleEntity: VehicleMp | null;
private destroyTimer: ReturnType<typeof setTimeout> | null;
private model: number;
private color: RGB;
public get vehicleEntity(): VehicleMp {
return this._vehicleEntity;
}
constructor(model: number, color: RGB) {
this.model = model;
this.color = color;
this.destroyTimer = null;
this._vehicleEntity = null;
}
init(player: PlayerMp) {
const { x, y, z } = player.position;
this._vehicleEntity = mp.vehicles.new(this.model, new mp.Vector3(x + 2, y, z), {
dimension: player.dimension,
color: [this.color, this.color]
});
setTimeout(() => {
player.putIntoVehicle(this._vehicleEntity, 0);
}, 100);
}
changeColor(newColor: RGB) {
if(!this.checkVehicle()) {
return;
}
this.color = newColor;
this._vehicleEntity.setColorRGB(this.color[0], this.color[1], this.color[2], this.color[0], this.color[1], this.color[2]);
}
swapModel(player: PlayerMp, newModel: number) {
if(!this.checkVehicle()) {
return
}
this.model = newModel;
this.destroy();
this.init(player);
}
onDriverExit() {
if(!this.checkVehicle()) {
return;
}
this.resetTimer();
this.destroyTimer = setTimeout(() => {
this.destroy();
}, DESTROY_TIMER);
}
onDriverEnter() {
if(!this.checkVehicle) {
return;
}
this.resetTimer();
}
repair() {
if(!this.checkVehicle()) {
return;
}
this._vehicleEntity.repair();
}
private removeOccupants() {
if(!this.checkVehicle()) {
return;
}
const occupants = this._vehicleEntity.getOccupants();
if(occupants.length > 0) {
occupants.forEach((player) => {
player.removeFromVehicle();
});
}
}
private checkVehicle() {
if(this._vehicleEntity === null) {
return false;
}
if(!mp.vehicles.exists(this._vehicleEntity)) {
return false
}
return true;
}
private resetTimer() {
if(this.destroyTimer !== null) {
clearTimeout(this.destroyTimer);
this.destroyTimer = null;
}
}
destroy() {
this.resetTimer();
if(mp.vehicles.exists(this._vehicleEntity)) {
this.removeOccupants();
this._vehicleEntity.destroy();
}
const admin = AdminHandler.getByAdminVehicle(this);
admin.onVehicleDestroy();
}
}
commands.ts
JavaScript:
import { AdminHandler } from "./AdminHandler";
mp.events.addCommand('admincar', (player: PlayerMp, fullText: string, model: string) => {
const admin = AdminHandler.getByPlayer(player);
if(!admin || !admin.hasAccess()) {
return;
}
const modelHash = model ? mp.joaat(model) : null;
if(modelHash === null) {
return;
}
admin.createVehicle(modelHash);
});
// Удаляем машину, все аналогично
mp.events.addCommand('delcar', (player: PlayerMp, fullText: string) => {
const admin = AdminHandler.getByPlayer(player);
if(!admin || !admin.hasAccess()) {
return;
}
admin.adminVehicle.destroy();
});
// Меняем цвет машины
mp.events.addCommand('colorcar', (player: PlayerMp, fullText: string, red: string, green: string, blue: string) => {
const admin = AdminHandler.getByPlayer(player);
if(!admin || !admin.hasAccess()) {
return;
}
const rgb: RGB = [parseInt(red), parseInt(green), parseInt(blue)];
admin.adminVehicle.changeColor(rgb);
});
// Меняем машину
mp.events.addCommand('swapcar', (player: PlayerMp, fullText: string, newModel: string) => {
const admin = AdminHandler.getByPlayer(player);
if(!admin || !admin.hasAccess()) {
return;
}
const modelHash = newModel ? mp.joaat(newModel) : null;
if(modelHash === null) {
return;
}
admin.adminVehicle.swapModel(player, modelHash);
});
// Чиним машину
mp.events.addCommand('repair', (player: PlayerMp, fullText: string) => {
const admin = AdminHandler.getByPlayer(player);
if(!admin || !admin.hasAccess()) {
return;
}
admin.adminVehicle.repair();
});
controller.ts
JavaScript:
import { AdminHandler } from "./AdminHandler";
mp.events.add('playerJoin', (player: PlayerMp) => {
AdminHandler.create(player);
});
mp.events.add('playerQuit', (player: PlayerMp) => {
const admin = AdminHandler.getByPlayer(player);
if(admin) {
admin.onPlayerLeave();
AdminHandler.remove(admin.id);
}
});
mp.events.add('onPlayerExitVehicle', (player: PlayerMp, vehicle: VehicleMp) => {
const admin = AdminHandler.getByPlayer(player);
if(admin || admin.hasAccess()) {
if(vehicle.id === admin.adminVehicle.vehicleEntity.id) {
admin.adminVehicle.onDriverExit();
}
}
});
mp.events.add('onPlayerEnterVehicle', (player: PlayerMp, vehicle: VehicleMp) => {
const admin = AdminHandler.getByPlayer(player);
if(admin || admin.hasAccess()) {
if(vehicle.id === admin.adminVehicle.vehicleEntity.id) {
admin.adminVehicle.onDriverEnter();
}
}
});
Подведём итоги этой реализации.
Хоть кода стало и больше, но писать его по ощущениям было намного проще, это заняло у меня куда меньше времени чем реализация с процедурным подходом, код стал в разы читабельнее, а самое главное - код стал легко расширяемым, мы заменили все проверки на вызов одного метода, и теперь, что бы добавить какую-то проверку - мне достаточно просто добавить её в одном месте кода, что бы добавить какой то функционал мне больше не нужно трогать то что я уже написал, мне нужно будет просто добавить новый метод и обработать команду в контроллере.
Минусы:
- Нужно потратить время на проектирование
- Сложное взаимодействие классов в месте, где происходит удаление/создание авто
Плюсы:
- Высокая читабельность кода, все разбито на сущности
- Объектно ориентированный подход
- Легко расширять систему
- Относительно просто дебажить
Делюсь всеми ресурсами:
GitHub репозиторий: ( будет добавлен если попросите )
Draw.io диаграмма: Тык
Последнее редактирование: