El verano pasado, en el proceso de preparación de un artículo para Habr, no fui demasiado flojo para empacar mi plantilla para aplicaciones de back-end en Node.js en un paquete npm, lo que la convirtió en una utilidad cli para un inicio rápido.
No había esperanza de que alguien más que yo usara este paquete inicialmente. Sin embargo, cuando decidí actualizar la plantilla introduciendo las características que necesitaba, noté que el paquete npm tiene varias docenas de descargas por semana, y el proyecto en el github tiene 12 estrellas. Entregado con amabilidad por buena gente, seguramente para apoyarme, no el proyecto. Solo 12 estrellas, pero fue suficiente para mí decidir que desarrollaré karcass como si fuera necesario no solo para mí.
A pesar de que inicialmente decidí crear un marco ligero para aplicaciones de back-end, durante el proceso de desarrollo logré convencerme de que esta bicicleta no era necesaria. Y ese karcass no debería ser un marco, sino una herramienta universal para crear aplicaciones a partir de plantillas.

En la primera versión, la lógica del cli-script era primitiva.
- .
- karcass template .
- , ( - , ) ( ).
- ,
npm install
.
:

. , .
, : Application , , . , :
Application.tsimport 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
public localCacheService!: LocalCacheService
public loggerService!: LoggerService
public dbService!: DbService
public gitlabService!: GitlabService
public issueService!: IssueService
public projectService!: ProjectService
public groupService!: GroupService
public userService!: UserService
public helpCommand!: HelpCommand
public createMigrationCommand!: CreateMigrationCommand
public migrateCommand!: MigrateCommand
public migrateUndoCommand!: MigrateUndoCommand
public updateProjectsCommand!: UpdateProjectsCommand
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.tsimport { 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.tsimport 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.tsimport { 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 . , .

, 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
— . , , , :

Decidí no hacer un recorrido largo con una descripción de todas las características del karcass, sino hacer una pequeña nota de investigación con el propósito de presentar y recopilar comentarios: cualquier comentario me será útil para comprender a dónde ir a continuación y si vale la pena. Mientras tanto, escribir documentación es una prioridad.
Repositorio de Karcass en github ;