Это мог быть очередной JavaScript-фреймворк

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прошлым летом, в процессе подготовки статьи для Хабра, я не поленился упаковать свой шаблон для бэкэнд-приложений на Node.js в npm-пакет, сделав из него cli-утилиту для быстрого старта.


Никаких надежд на то, что этим пакетом будет пользоваться кто-то, кроме меня, не было изначально. Однако, когда я решил обновить шаблон, внедрив в него нужные мне фичи, я обратил внимание на то, что у npm-пакета есть несколько десятков скачиваний в неделю, а у проекта на гитхабе 12 звёзд. Поставленные по доброте хорошими людьми, наверняка, чтобы поддержать меня, а не проект. Всего 12 звёзд, но мне этого хватило, чтобы решить, что karcass я буду развивать так, как будто он нужен не только мне.


Несмотря на то, что изначально я решил сделать легковесный фреймворк для бэкэнд-приложений, в процессе разработки мне удалось себя убедить в том, что этот велосипед не нужен. И что karcass должен стать не фреймворком, а универсальным инструментом для создания приложений из шаблонов.


image


В первой версии логика работы cli-скрипта была примитивной.


  1. Пользователь указывает настройки.
  2. karcass копирует содержимое своей же директории template в созданную под новый проект директорию.
  3. В процессе копирования каждый файл проходит через обработчик, который может изменить содержимое файла (заменить какой-то текст, удалить строки) или блокировать копирование (файл не попадёт в результирующую директорию).
  4. После копирования шаблона, установщик выполняет npm install.

Для пользователя этот процесс выглядел так:


image


Обработка исходников шаблона в соответствии с настройками была реализована набором условий и реплэйсов. Поддерживать это было невозможно, посмотрите сами.


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


Application.ts
import Express from 'express'
import { AbstractConsoleCommand } from './Base/Console/AbstractConsoleCommand'
import { DbService } from './Database/Service/DbService'
import { HelpCommand } from './Base/Console/HelpCommand'
import { LoggerService } from './Logger/Service/LoggerService'
import { IssueService } from './Project/Service/IssueService'
import { GitlabService } from './Gitlab/Service/GitlabService'
import { LocalCacheService } from './Base/Service/LocalCacheService'
import { ProjectService } from './Project/Service/ProjectService'
import { GroupService } from './Project/Service/GroupService'
import { UserService } from './User/Service/UserService'
import { UpdateProjectsCommand } from './Gitlab/Console/UpdateProjectsCommand'
import { CreateMigrationCommand } from './Database/Console/CreateMigrationCommand'
import { MigrateCommand } from './Database/Console/MigrateCommand'
import { MigrateUndoCommand } from './Database/Console/MigrateUndoCommand'
import IssueController from './Project/Controller/IssueController'
import fs from 'fs'

export class Application {
    public http!: Express.Express

    // Services
    public localCacheService!: LocalCacheService
    public loggerService!: LoggerService
    public dbService!: DbService
    public gitlabService!: GitlabService
    public issueService!: IssueService
    public projectService!: ProjectService
    public groupService!: GroupService
    public userService!: UserService

    // Commands
    public helpCommand!: HelpCommand
    public createMigrationCommand!: CreateMigrationCommand
    public migrateCommand!: MigrateCommand
    public migrateUndoCommand!: MigrateUndoCommand
    public updateProjectsCommand!: UpdateProjectsCommand

    // Controllers
    public issueController!: IssueController

    public constructor(public readonly config: IConfig) {
        if (config.columns.length < 2) {
            throw new Error('There are too few columns :-(')
        }
    }

    public async run() {
        this.initializeServices()
        if (process.argv[2]) {
            this.initializeCommands()
            for (const command of Object.values(this)
                .filter((c: any) => c instanceof AbstractConsoleCommand) as AbstractConsoleCommand[]
            ) {
                if (command.name === process.argv[2]) {
                    await command.execute()
                    process.exit()
                }
            }
            await this.helpCommand.execute()
            process.exit()
        } else {
            this.runWebServer()
        }
    }

    protected runWebServer() {
        this.initCron()
        this.http = Express()
        this.http.use('/', Express.static('vue/dist'))
        this.http.use((req, res, next) => {
            if (req.url.indexOf('/api') === -1) {
                res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate')
                res.header('Expires', '-1')
                res.header('Pragma', 'no-cache')
                return res.send(fs.readFileSync('vue/dist/index.html').toString())
            }
            next()
        })
        this.http.use(Express.urlencoded())
        this.http.use(Express.json())
        this.http.listen(this.config.listen, () => console.log(`Listening on port ${this.config.listen}`))

        this.initializeControllers()
    }

    protected initCron() {
        if (this.config.gitlab.updateInterval) {
            setInterval(async () => {
                if (!this.updateProjectsCommand) {
                    this.updateProjectsCommand = new UpdateProjectsCommand(this)
                }
                await this.updateProjectsCommand.execute()
            }, this.config.gitlab.updateInterval * 1000)
        }
    }

    protected initializeServices() {
        this.localCacheService = new LocalCacheService(this)
        this.gitlabService = new GitlabService(this)
        this.loggerService = new LoggerService(this)
        this.dbService = new DbService(this)
        this.issueService = new IssueService(this)
        this.projectService = new ProjectService(this)
        this.groupService = new GroupService(this)
        this.userService = new UserService(this)
    }

    protected initializeCommands() {
        this.helpCommand = new HelpCommand(this)
        this.createMigrationCommand = new CreateMigrationCommand(this)
        this.migrateCommand = new MigrateCommand(this)
        this.migrateUndoCommand = new MigrateUndoCommand(this)
        this.updateProjectsCommand = new UpdateProjectsCommand(this)
    }

    protected initializeControllers() {
        this.issueController = new IssueController(this)
    }

}

ProjectService.ts
import { AbstractService } from '../../Base/Service/AbstractService'
import { Project } from '../Entity/Project'

export class ProjectService extends AbstractService {

    public get projectRepository() {
        return this.app.dbService.connection.getRepository(Project)
    }

    public async updateProjects(allTime = false) {
        await this.app.groupService.updateGroups()
        for (const data of await this.app.gitlabService.getProjects()) {
            let project = await this.getProject(data.id)

            if (!project) {
                project = this.projectRepository.create({ id: data.id })
            }
            project.name = data.name
            project.url = data.web_url
            project.updatedTimestamp = Math.round(new Date(data.last_activity_at).getTime() / 1000)
            project.groupId = data.namespace && data.namespace.kind === 'group' ? data.namespace.id : null
            await this.projectRepository.save(project)
            await this.app.issueService.updateProjectIssues(project, allTime)
        }
    }

    public async getProject(id: number): Promise<Project|undefined> {
        return id ? this.app.localCacheService.get(`project.${id}`, () => this.projectRepository.findOne(id)) : undefined
    }

}

Разумеется, в новой версии шаблона, который предназначался бы уже не только для меня, предлагать такую архитектуру было бы просто неприлично. Поэтому я посвятил некоторое время разработке простенького, но функционального DI-контейнера, а на его основе сделал класс для работы с cli.


Теперь в Application.ts появился контейнер, который может инициализировать зависимости по запросу или «на месте». При этом контейнер может создавать экземпляр зависимости сам или получать его из коллбэка.


Application.ts
import CreateExpress, { Express } from 'express';
import { TwingEnvironment, TwingLoaderFilesystem } from 'twing';
import { Container } from '@karcass/container';
import { Cli } from '@karcass/cli';
import { Connection, createConnection } from 'typeorm';
import { CreateMigrationCommand, MigrateCommand, MigrateUndoCommand } from '@karcass/migration-commands';
import { createLogger } from './routines/createLogger';
import { Logger } from 'winston';
import { FrontPageController } from './SampleBundle/Controller/FrontPageController';
import { Message } from './SampleBundle/Entity/Message';
import { MessagesService } from './SampleBundle/Service/MessagesService';

export class Application {
    private container = new Container();
    private console = new Cli();
    private controllers: object[] = [];
    private http!: Express;

    public constructor(public readonly config: IConfig) { }

    public async run() {
        await this.initializeServices();

        if (process.argv[2]) {
            this.initializeCommands();
            await this.console.run();
        } else {
            this.runWebServer();
        }
    }

    protected runWebServer() {
        this.http = CreateExpress();
        this.http.use('/public', CreateExpress.static('public'));
        this.http.use(CreateExpress.urlencoded());
        this.http.listen(this.config.listen, () => console.log(`Listening on port ${this.config.listen}`));

        this.container.add<Express>('express', () => this.http);
        this.container.add(TwingEnvironment, () => new TwingEnvironment(new TwingLoaderFilesystem('src')));

        this.initializeControllers();
    }

    protected async initializeServices() {
        await this.container.addInplace<Logger>('logger', () => createLogger(this.config.logdir));
        const typeorm = await this.container.addInplace(Connection, () => createConnection({
            type: 'sqlite',
            database: 'db/sample.sqlite',
            entities: ['build/**/Entity/*.js'],
            migrations: ['build/**/Migrations/*.js'],
            logging: ['error', 'warn', 'migration'],
        }));
        this.container.add('Repository<Message>', () => typeorm.getRepository(Message));
        this.container.add(MessagesService);
    }

    protected initializeCommands() {
        this.console.add(CreateMigrationCommand, () => new CreateMigrationCommand());
        this.console.add(MigrateCommand, async () => new MigrateCommand(await this.container.get(Connection)));
        this.console.add(MigrateUndoCommand, async () => new MigrateUndoCommand(await this.container.get(Connection)));
    }

    protected async initializeControllers() {
        this.controllers.push(
            await this.container.inject(FrontPageController),
        );
    }

}

Использование TypeScript позволяет указывать зависимости с помощью декоратора:


FrontPageController.ts
import { Express } from 'express';
import { Dependency } from '@karcass/container';
import { TwingEnvironment } from 'twing';
import { AbstractController, QueryData } from './AbstractController';
import { MessagesService } from '../Service/MessagesService';

export class FrontPageController extends AbstractController {

    public constructor(
        @Dependency('express') protected express: Express,
        @Dependency(TwingEnvironment) protected twing: TwingEnvironment,
        @Dependency(MessagesService) protected messagesService: MessagesService,
    ) {
        super(express);

        this.onQuery('/', 'get', this.frontPageAction);
        this.onQuery('/', 'post', this.sendMessageAction);
    }

    public async sendMessageAction(data: QueryData) {
        await this.messagesService.addMessage(data.params.text);
        data.res.redirect('/');
    }

    public async frontPageAction() {
        if (await this.messagesService.isEmpty()) {
            await this.messagesService.createSampleMessages();
        }
        return this.twing.render('SampleBundle/Views/front.twig', {
            messages: await this.messagesService.getMessages(),
        });
    }

}

В случае с JavaScript, экземпляр придётся создавать «вручную»:


protected async initializeControllers() {
    this.controllers.push(
        new FrontPageController(
            await this.container.get('express'),
            await this.container.get(TwingEnvironment),
            await this.container.get(MessagesService),
        ),
    );
}

Если раньше шаблон располагался в директории template самого karcass, то теперь я решил вынести шаблон в отдельный проект: так разработка и отладка становится проще. Соответственно, появилась необходимость в интерфейсе общения между установщиком и самим шаблоном.


После нескольких недель вечерних изысканий я пришёл к следующему варианту реализации: в корне любого шаблона должен быть файл TemplateReducer.ts или TemplateReducer.js, который должен экспортировать класс TemplateReducer, реализующий такой интерфейс:


interface TemplateReducerInterface {
    getConfigParameters(): Promise<ConfigParametersResult>
    getConfig(): Record<string, any>
    setConfig(config: Record<string, any>): void
    getDirectoriesForRemove(): Promise<string[]>
    getFilesForRemove(): Promise<string[]>
    getDependenciesForRemove(): Promise<string[]>
    getFilesContentReplacers(): Promise<ReplaceFileContentItem[]>
    finish(): Promise<void>
    getTestConfigSet(): Promise<Record<string, any>[]>
}

На этом этапе меня, наконец, осенило, что я работаю не над реализацией какого-то скелета приложения, что karcass не должен стать очередным ненужным никому фреймворком, но он может стать хорошим инструментом для работы с шаблонами приложений на JavaScript/TypeScript. Это могут быть шаблоны бэкэнд-приложений. Кто-то сможет его использовать для создания приложений на основе своей выстраданной конфигурации webpack. Возможно, какой-то шаблон может стать хорошей альтернативой create-react-app с блекдж... с настройками на этапе установки, как у vue create.


Не буду нудить описанием каждого метода TemplateReducerInterface, тем более, что пример реализации можно посмотреть на гитхабе. Вместо этого предлагаю небольшой туториал по созданию простейшего шаблона для karcass:


В пустую директорию hello поместим файл index.js незатейливого содержания:


console.log('Hello, [replacethisname]!')

Рядом с ним кладём файл TemplateReducer.js, который расскажет karcass'у как настроить шаблон и выполнит соответствующие изменения в процессе установки:


const reducer = require('@karcass/template-reducer')
const Type = reducer.ConfigParameterType

class TemplateReducer extends reducer.AbstractTemplateReducer {
    getConfigParameters() {
        return [
            { name: 'name', description: 'Please enter your name', type: Type.string },
        ]
    }
    async getFilesContentReplacers() {
        return [
            { filename: 'index.js', replacer: (content) => {
                return content.replace('[replacethisname]', this.config.name)
            } },
        ]
    }
    async finish() {
        console.log(`Application installed, to launch it execute\n  cd ${this.directoryName} && node index.js`)it.`)
    }
}
module.exports = { TemplateReducer }

Как можно заметить, у любого шаблона есть, как минимум, одна зависимость — @karcass/template-reducer, нужно не забыть её установить, создав перед этим package.json:


npm init && npm install @karcass/template-reducer

Не нужно беспокоиться о том, чтобы убрать эту зависимость в методе getDependenciesForRemove, её karcass уберёт сам.


Теперь можно посмотреть как karcass справится с созданием приложения из нашего шаблона. Делать это нужно не в директории шаблона, иначе неизбежна рекурсия при копировании директории шаблона саму в себя.


image


Всё это было бы бессмысленно, если бы кроме установки из локальной директории karcass не мог устанавливать приложения из общедоступных источников. Пока поддерживается только github.com, можете попробовать:


npx karcass create helloworld https://github.com/karcass-ts/hello-world

Ненавязчиво предложу попробовать и мой дефолтный шаблон, который устанавливается по-умолчанию, если не указать путь до шаблона:


npx karcass create ooohhhh-ok-show-it

Тестирование? У шаблона есть возможность задавать пресеты конфигурации для тестирования, в TemplateReducer нашего helloworld-шаблона можно было бы добавить такой метод:


    getTestConfigSet() {
        return [
            { name: 'testname1' },
            { name: 'testname2' },
        ]
    }

Само тестирование запускается так:


npx karcass test www/karcass/hello

Процесс тестирования — это просто поочерёдная установка шаблона с заданной конфигурацией. Примитивно, но ничего лучше я не придумал, по крайней мере, пока:


image


Я решил не делать лонгрид с описанием всех особенностей karcass, а сделать небольшую ознакомительную заметку с целью презентации и сбора фидбека: любой отзыв будет мне полезен, чтобы понять, куда двигаться дальше и стоит ли. Пока же в приоритете написание документации.


Репозиторий karcass на github;

Источник: https://habr.com/ru/post/487648/


Интересные статьи

Интересные статьи

Так какими же всё таки должны быть идеальные шахматы?Как уже принято на хабре, увидел статью «Какими могут быть идеальные шахматы», хотел написать комментарий, но понял, что многовато пол...
В обновлении «Сидней» Битрикс выпустил новый продукт в составе Битрикс24: магазины. Теперь в любом портале можно создать не только лендинг или многостраничный сайт, но даже интернет-магазин. С корзино...
Весь современный дизайн — веб, типографический, продуктовый, моушн-дизайн — интересен тем, что объединяет в себе классические понятия о цвете и композиции с заботой об удобстве пользователе...
Блокировать рекламу или нет? Ваш выбор имеет значение! Отказ от поддержки webRequest API, лежащего в основе почти всех существующих блокировщиков рекламы и скрытых трекеров, нарушит работу ...
И сотворил Гвидо строки по образу C, по образу массивов символов сотворил их. И увидел Гвидо, что это хорошо. Или нет? Представьте, что вы пишете совершенно идиоматичный код по обходу неких да...