Ракеты-носители с несколькими провайдерами


Обзор

Расширения фреймворка Booster называются ракетами. Rocket — это не что иное, как пакет узла, который реализует публичные интерфейсы Booster Rocket для добавления новой функциональности в приложения Booster.

Мультипровайдерная ракета

Booster версии 0.24.0 способен создавать многопровайдерные ракеты. Мультипровайдерные ракеты могут включать реализации для разных поставщиков в одном пакете npm.

Наличие всех реализаций в одном пакете уменьшает необходимость повторять один и тот же код в разных реализациях Rocket. Теперь нам нужно опубликовать только один Rocket, и он может использоваться столькими провайдерами (AWS, Azure, Local, …), сколькими мы поддерживаем.

Когда мы создаем Multi Provider Rocket, мы можем извлечь общую логику в один пакет, а логику конкретного провайдера — в другой. В этом случае у нас будет как минимум столько пакетов, сколько провайдеров мы поддерживаем. Например, если мы создадим ракету, поддерживающую Azure, и локального провайдера, который развертывает инфраструктуру, то у нас будут следующие пакеты:

  • Core: Точка входа вашей ракеты. Здесь находится общая логика.
  • Azure: Логика, специфичная для Azure. Например, код для записи в таблицу Cosmos DB.
  • Инфраструктура Azure: Укажите Booster, как построить инфраструктуру Azure. Например, код Terrafom-cdktf для добавления новой таблицы CosmosDB.
  • Локальный: Локальная логика. Например, код для записи в таблицу Nedb.
  • Локальная инфраструктура: Как и в случае с инфраструктурой Azure, нам нужно указать Booster на создание новой таблицы Nedb или новой конечной точки.
  • Типы: Как вариант, мы можем иметь пакет с общими типами, которые будут использоваться различными пакетами.

Rocket Core — это пакет-оркестратор. Он отвечает за вызов нужного пакета провайдера, а в конце пакеты провайдера будут использовать пакет инфраструктуры по мере необходимости.

Пример Rocket

Распространенным требованием во многих проектах является возможность работы с файлами. Booster Core не поддерживает работу с файлами, поэтому Rocket может помочь нам в этом.

Scaffolding

Файлы Rocket должны обеспечивать базовый набор функциональных возможностей:

  1. Получение предварительно подписанного url для загрузки файла в папку.
  2. Получение предварительно подписанного url для загрузки файла в папку.
  3. Получение предварительно подписанного url для вывода списка всех файлов в папке.
  4. В соответствии с подходом, основанным на поиске событий, было бы полезно создавать событие при каждой загрузке файла.
  5. Позволить пользователям определять несколько папок.

Этот Rocket обеспечит реализацию для Azure и локального провайдера. Полный код можно найти на Github.

Если у вас есть вопросы о том, как начать работу с ракетами, следуйте официальному руководству.

Первым шагом будет создание лесов проекта.

Инфраструктура локального провайдера

Приступим к созданию инфраструктуры Local Provider. В файле index.ts экспортируйте константу, которая возвращает InfrastructureRocket. Этот объект будет содержать как минимум метод mountStack. Этот метод будет вызываться Booster в процессе развертывания:

const AzureRocketFiles = (params: RocketFilesParams): InfrastructureRocket => ({
 mountStack: Infra.mountStack.bind(Infra, params),
})

export default AzureRocketFiles
Войти в полноэкранный режим Выйти из полноэкранного режима

InfrastructureRocket должен содержать как минимум один метод для монтирования стека. Мы привязываем этот метод к нашему классу Infra с помощью RocketFilesParams.

Когда пользователь получает предварительно подписанный get, put или list url, он должен указывать на Express-маршрут, который ведет себя так же, как Azure File Storage.

Для локального провайдера мы публикуем наши конечные точки, создавая Express-маршруты. Поскольку мы хотим иметь разные конечные точки в зависимости от параметра каталога, мы перебираем наши параметры, чтобы создать необходимые маршруты.

export class Infra {
 public static mountStack(params: RocketFilesParams, config: BoosterConfig, router: Router): void {
   params.params.forEach((parameter: RocketFilesParam) => {
     router.use(`/${containerName}`, new FileController(parameter.directory).router)
     fsWatch(parameter.directory)
   })
 }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для каждой директории в списке параметров мы создаем новый маршрутизатор для пути /rocketFiles. Таким образом, мы можем иметь один Rocket для работы с несколькими директориями.

Контроллер будет иметь две конечные точки для каждой директории, одна для загрузки файлов, а другая для получения наших файлов. Это необходимо для имитации методов Azure Blob get/put:

constructor(readonly directory: string) {
 this.router.put(`/${directory}/:fileName`, this.uploadFile.bind(this))
 this.router.get(`/${directory}/:fileName`, this.getFile.bind(this))
 this._path = path.join(process.cwd(), containerName, this.directory)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Нам не нужно добавлять конечную точку для конечной точки list, так как если пользователь вызовет корневой url, Express просто вернет ожидаемый список файлов.

Теперь нам осталось определить методы для получения и размещения файлов. Получение файла — это просто запрос метода download метода express.Response:

public async getFile(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
 const fileName = req.params.fileName
 const filePath = path.join(this._path, fileName)
 res.download(filePath)
}
Войти в полноэкранный режим Выход из полноэкранного режима

Запись файла требует нескольких шагов. Сначала нам нужно создать путь назначения, а затем использовать _объект _express.Request для записи потока:

const fileName = req.params.fileName
const filePath = path.join(this._path, fileName)
const writeStream = fs.createWriteStream(filePath)
req.pipe(writeStream)
req.on('end', function () {
 const result = {
   status: 'success',
   result: {
     message: 'File uploaded',
     data: {
       name: fileName,
       mimeType: DEFAULT_MIME_TYPE,
       size: DEFAULT_FILE_SIZE,
     },
   },
 } as APIResult
 res.send(result)
})

writeStream.on('error', async function (e) {
 const err = e as Error
 await requestFailed(err, res)
 next(e)
})
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь, когда у нас есть основные методы, нам нужно реализовать наблюдателя за файловой системой. Он будет обнаруживать любые изменения в папке и выдавать событие Booster, которое мы хотим оповестить. Для этого мы реализуем функцию fsWatch, которая следит за папками и вызывает метод boosterRocketDispatcher.

export function fsWatch(directory: string): void {
 const _path = path.join(process.cwd(), containerName, directory)
 if (!fs.existsSync(_path)) {
   fs.mkdirSync(_path, { recursive: true })
 }
 fs.watch(_path, async (eventType: 'rename' | 'change', filename: string) => {
   await boosterRocketDispatcher({
     name: filename,
   })
 })
}
Вход в полноэкранный режим Выход из полноэкранного режима

BoosterRocketDispatcher — это метод, который Booster предоставляет для взаимодействия с функциональными возможностями Core. Этот метод будет отправлять полезную нагрузку запроса в функцию, определенную в переменной окружения BOOSTER_ROCKET_FUNCTION_ID. Мы определили эту переменную в методе mountStack для Local Provider, поэтому давайте добавим ее:

export class Infra {
 public static mountStack(params: RocketFilesParams, config: BoosterConfig, router: Router): void {
   process.env[rocketFunctionIDEnvVar] = functionID
   params.params.forEach((parameter: RocketFilesParam) => {
     router.use(`/${containerName}`, new FileController(parameter.directory).router)
     fsWatch(parameter.directory)
   })
 }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Функция boosterRocketDispatcher не только получит полезную нагрузку запроса, но и предоставит доступ к переменной Booster config.

С помощью этого вызова мы можем быть уверены, что каждый раз, когда файл будет сброшен в каталог, Booster будет отправлять запрос нашему обработчику. Этот обработчик будет определен в пакете Rocket Core.

Мы соединяем наш наблюдатель за файлами с логикой локального провайдера через диспетчер Booster Core. Все провайдеры будут использовать этот поток для реализации соединения:

Local Provider

Пакет Local Provider (rocket-files-local) реализует логику методов, которые мы будем открывать для нашего Express-сервера:

  1. Получить предварительно подписанный get url для получения файла
  2. Получить предварительно подписанный url put, чтобы **поместить **файл
  3. Вывести список директорий

Чтобы вернуть get и put url, нам нужно только объединить имеющуюся информацию:

export async function presignedGet(config: BoosterConfig, directory: string, fileName: string): Promise<string> {
 return `http://localhost:3000/${containerName}/${directory}/${fileName}`
}

export async function presignedPut(config: BoosterConfig, directory: string, fileName: string): Promise<string> {
 return `http://localhost:3000/${containerName}/${directory}/${fileName}`
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Эти методы будут вызываться из пакета Core.

Перечисление всех файлов в каталоге — это просто использование модуля fs:

export async function list(config: BoosterConfig, directory: string): Promise<Array<ListItem>> {
 const result = [] as Array<ListItem>
 const _path = path.join(process.cwd(), containerName, directory)
 const files = fs.readdirSync(_path)
 files.forEach((file) => {
   const stats = fs.statSync(path.join(_path, file))
   result.push({
     name: file,
     properties: {
       lastModified: stats.ctime,
     },
   })
 })

 return result
}
Войти в полноэкранный режим Выход из полноэкранного режима

Core

После того, как у нас есть реализация Local Provider, пришло время создать пакет Core. Этот пакет должен вызывать каждого провайдера в зависимости от конфигурации приложения. Первым шагом будет регистрация нашего Rocket:

export class BoosterRocketFiles {
 public constructor(readonly config: BoosterConfig, readonly params: RocketFilesParams) {
   config.registerRocketFunction(functionID, async (config: BoosterConfig, request: unknown) => {
     return fileUploaded(config, request, params)
   })
 }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Мы регистрируем наш Rocket, используя уникальный идентификатор functionID (см. rocket-files-params.ts), и функцию Rocket, которая будет вызываться Booster Core при вызове нашей функции. Мы настроим это в провайдере Azure позже. Для локального провайдера она была определена в методе fsWatch.

Нам также необходимо предоставить два метода настройки Rocket для нашего клиента.

public rocketForAzure(): RocketDescriptor {
 return {
   packageName: '@boostercloud/rocket-files-provider-azure-infrastructure',
   parameters: this.params,
 }
}

public rocketForLocal(): RocketDescriptor {
 return {
   packageName: '@boostercloud/rocket-files-provider-local-infrastructure',
   parameters: this.params,
 }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Эти методы будут использоваться клиентами позже.

Далее нам нужно реализовать публичные методы, которые будут использовать наши клиенты. С точки зрения Core, эти методы должны вызывать соответствующую логику на провайдере, настроенном в приложении. Теперь давайте определим класс с методами get, put и list:

public presignedGet(directory: string, fileName: string): Promise<string> {
 this.checkDirectory(directory)
 return this._provider.presignedGet(this.config, directory, fileName)
}

public presignedPut(directory: string, fileName: string): Promise<string> {
 this.checkDirectory(directory)
 return this._provider.presignedPut(this.config, directory, fileName)
}

public list(directory: string): Promise<Array<ListItem>> {
 this.checkDirectory(directory)
 return this._provider.list(this.config, directory)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Каждый метод вызывает метод провайдера, чтобы клиент, использующий наш пакет Rocket Core, мог совершать вызовы. Например, если клиент хочет получить url presignedPut, то он может вызвать наш метод presignedPut:

 @Returns(String)
  public static async handle(command: FileUploadPut, register: Register): Promise<string> {
    const boosterConfig = Booster.config
    const fileHandler = new FileHandler(boosterConfig)
    return await fileHandler.presignedPut(command.directory, command.fileName)
  }
Войти в полноэкранный режим Выйти из полноэкранного режима

Нам нужно только реализовать метод fileUploaded в нашем пакете Core, чтобы генерировать событие Booster для каждого загруженного файла. Это событие будет сохраняться с помощью объекта config. Объект config дает нам доступ к хранилищу событий провайдера:

async function processEvent(config: BoosterConfig, metadata: unknown): Promise<void> {
 try {
   const envelop = toEventEnvelop(metadata)
   await config.provider.events.store([envelop], config, console)
 } catch (e) {
   console.log('[ROCKET#files] An error occurred while performing a PutItem operation: ', e)
 }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, для каждого загруженного файла мы будем хранить событие:

const provider = require(params.rocketProviderPackage)
const metadata = provider.getMetadataFromRequest(request)
if (provider.validateMetadata(params, metadata)) {
 return processEvent(config, metadata)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Инфраструктура провайдера Azure

После того, как у нас есть локальная и основная функциональность, мы можем приступить к инфраструктуре провайдера Azure. Как и в случае с Local Provider, нам понадобится экспортировать константу с методом mountStack, который создает InfrastructureRocket:

const AzureRocketFiles = (params: RocketFilesParams): InfrastructureRocket => ({
 mountStack: Synth.mountStack.bind(Synth, params),
})
export default AzureRocketFiles
Вход в полноэкранный режим Выход из полноэкранного режима

Мы создаем необходимую инфраструктуру в Azure, которая состоит из:

  • Учетная запись хранилища
  • контейнера
  • функция

Используя cdktf, легко создать новые TerraformResources и добавить их в applicationSynthStack.rocketStack:

const rocketStack = applicationSynthStack.rocketStack ?? []
const rocketStorage = TerraformStorageAccount.build(terraformStack, resourceGroup, appPrefix, utils, config)
rocketStack.push(rocketStorage)
Вход в полноэкранный режим Выход из полноэкранного режима

Для FunctionApp нам нужно будет установить некоторые определенные значения, чтобы сообщить Azure, как подключиться к нашему blob-хранилищу и нашему functionID:

return new FunctionApp(terraformStack, id, {
 name: functionAppName,
 location: resourceGroup.location,
 resourceGroupName: resourceGroup.name,
 appServicePlanId: applicationServicePlan.id,
 appSettings: {
   FUNCTIONS_WORKER_RUNTIME: 'node',
   AzureWebJobsStorage: storageAccount.primaryConnectionString,
   WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: storageAccount.primaryConnectionString,
   WEBSITE_RUN_FROM_PACKAGE: '',
   WEBSITE_CONTENTSHARE: id,
   WEBSITE_NODE_DEFAULT_VERSION: '~14',
   ...config.env,
   BOOSTER_ENV: config.environmentName,
   BOOSTER_REST_API_URL: `https://${apiManagementServiceName}.azure-api.net/${config.environmentName}`,
   COSMOSDB_CONNECTION_STRING: `AccountEndpoint=https://${cosmosDatabaseName}.documents.azure.com:443/;AccountKey=${cosmosDbConnectionString};`,
   BOOSTER_ROCKET_FUNCTION_ID: functionID,
   ROCKET_FILES_BLOB_STORAGE: rocketStorageAccount.primaryConnectionString,
 },
 osType: 'linux',
 storageAccountName: storageAccount.name,
 storageAccountAccessKey: storageAccount.primaryAccessKey,
 version: '~3',
 dependsOn: [resourceGroup],
 lifecycle: {
   ignoreChanges: ['app_settings["WEBSITE_RUN_FROM_PACKAGE"]'],
 },
})
Вход в полноэкранный режим Выйти из полноэкранного режима

Далее нам нужно вернуть наш обновленный applicationSynthStack.

return applicationSynthStack
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, нам нужно реализовать код functionApp. Для этого нам нужно вернуть массив определений наших функций:

export class RocketFilesFileUploadedFunction {
 static getFunctionDefinition(config: BoosterConfig): BlobFunctionDefinition {
   return {
     name: 'fileupload',
     config: {
       bindings: [
         {
           type: 'blobTrigger',
           direction: 'in',
           name: 'blobUpload',
           path: `${containerName}/{name}`,
           connection: 'ROCKET_FILES_BLOB_STORAGE',
         },
       ],
       scriptFile: config.functionRelativePath,
       entryPoint: config.rocketDispatcherHandler.split('.')[1],
     },
   }
 }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы создаем определение функции, которая будет привязана к пути к блобу. Параметры entryPoint и scriptFile помогут Booster выполнить наш код. Booster предоставляет утилиты для соединения с Booster Core в объекте config.

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

const AzureRocketFiles = (params: RocketFilesParams): InfrastructureRocket => ({
 mountStack: Synth.mountStack.bind(Synth, params),
 mountFunctions: Functions.mountFunctions.bind(Synth, params),
 getFunctionAppName: Functions.getFunctionAppName.bind(Synth, params),
})

export default AzureRocketFiles
Вход в полноэкранный режим Выход из полноэкранного режима

Провайдер Azure

Реализация методов get, put, list и file uploaded в Azure будет выполнена в пакете Azure provider.

Давайте создадим класс для получения всей информации, которую предоставляет Azure, используя пакет @azure/storage-blob. Метод get будет таким:

public getBlobSasUrl(
 directory: string,
 fileName: string,
 permissions = this.DEFAULT_PERMISSIONS,
 expiresOnSeconds = this.DEFAULT_EXPIRES_ON_SECONDS
): string {
 const key = BlobService.getKey()
 const blobName = BlobService.getBlobName(directory, fileName)
 const credentials = this.getCredentials(key)
 const client = this.getClient(credentials)
 const blobSASQueryParameters = BlobService.getBlobSASQueryParameters(
   blobName,
   permissions,
   expiresOnSeconds,
   credentials
 )
 const containerClient = client.getContainerClient(containerName)
 const blobClient = containerClient.getBlobClient(blobName)
 return blobClient.url + '?' + blobSASQueryParameters
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Аналогичным образом нам нужно построить и другие методы. Важной частью здесь является то, что мы возвращаем предварительно подписанный url, используя ключ, который мы определили в нашей функции:

private static getKey(): string {
 return process.env['ROCKET_STORAGE_KEY'] ?? ''
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это значение конфигурации устанавливается в пакете инфраструктуры Azure:

applicationSynthStack.functionApp!.addOverride('app_settings', {
 ROCKET_STORAGE_KEY: `${rocketStorage.primaryAccessKey}`,
})
Ввести полноэкранный режим Выйти из полноэкранного режима

Когда у нас есть все необходимые функции, мы экспортируем их:

export async function presignedGet(config: BoosterConfig, directory: string, fileName: string): Promise<string> {
 const storageAccount = storageName(config.appName)
 return new BlobService(storageAccount).getBlobSasUrl(directory, fileName)
}

export async function presignedPut(config: BoosterConfig, directory: string, fileName: string): Promise<string> {
 const storageAccount = storageName(config.appName)
 return new BlobService(storageAccount).getBlobSasUrl(directory, fileName, WRITE_PERMISSION)
}

export async function list(config: BoosterConfig, directory: string): Promise<Array<ListItem>> {
 const storageAccount = storageName(config.appName)
 return new BlobService(storageAccount).listBlobFolder(directory)
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Клиентское приложение

Чтобы добавить эту ракету в наше приложение Booster, сначала нам нужно обновить наши зависимости:

npm i --save @boostercloud/rocket-files-core
npm i --save @boostercloud/rocket-files-types
npm i --save @boostercloud/rocket-files-provider-local 
npm i --save @boostercloud/rocket-files-provider-azure
Войти в полноэкранный режим Выход из полноэкранного режима

Затем мы добавим dev-зависимости для пакетов инфраструктуры:

npm i --save-dev @boostercloud/rocket-files-provider-azure-infrastructure
npm i --save-dev @boostercloud/rocket-files-provider-local-infrastructure
Войти в полноэкранный режим Выйти из полноэкранного режима

Далее мы настраиваем наш Rocket в файле config.ts. Для Azure:

Booster.configure('production', (config: BoosterConfig): void => {
 config.appName = 'test-rockets-files020'
 config.providerPackage = '@boostercloud/framework-provider-azure'
 config.rockets = [
   new BoosterRocketFiles(config, {
     rocketProviderPackage: '@boostercloud/rocket-files-provider-azure' as RocketProviderPackageType,
     params: [
       {
         directory: 'folder01',
       },
       {
         directory: 'folder02',
       },
     ],
   } as RocketFilesParams).rocketForAzure(),
 ]
})
Войти в полноэкранный режим Выйти из полноэкранного режима

И для Local:

Booster.configure('local', (config: BoosterConfig): void => {
 config.appName = 'test-rockets-files020'
 config.providerPackage = '@boostercloud/framework-provider-local'
 config.rockets = [
   new BoosterRocketFiles(config, {
     rocketProviderPackage: '@boostercloud/rocket-files-provider-local' as RocketProviderPackageType,
     params: [
       {
         directory: 'folder01',
       },
       {
         directory: 'folder02',
       },
     ],
   } as RocketFilesParams).rocketForLocal(),
 ]
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы использовать наши ракеты из нашего приложения, мы создадим команду, которая добавит мутацию GraphQL. Команда для получения предварительно подписанного url для загрузки файла будет выглядеть следующим образом:

export class FileUploadPut {
 public constructor(readonly directory: string, readonly fileName: string) {}

 @Returns(String)
 public static async handle(command: FileUploadPut, register: Register): Promise<string> {
   const boosterConfig = Booster.config
   const fileHandler = new FileHandler(boosterConfig)
   return await fileHandler.presignedPut(command.directory, command.fileName)
 }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

А для получения заранее подписанного url для** получения** файла и списка всех файлов:

export class FileUploadGet {
 public constructor(readonly directory: string, readonly fileName: string) {}

 @Returns(String)
 public static async handle(command: FileUploadGet, register: Register): Promise<string> {
   const boosterConfig = Booster.config
   const fileHandler = new FileHandler(boosterConfig)
   return await fileHandler.presignedGet(command.directory, command.fileName)
 }
}
Войти в полноэкранный режим Выйти из полноэкранного режима
export class FileUploadList {
 public constructor(readonly directory: string) {}

 @Returns(String)
 public static async handle(command: FileUploadList, register: Register): Promise<string> {
   const boosterConfig = Booster.config
   const fileHandler = new FileHandler(boosterConfig)
   const listItems = await fileHandler.list(command.directory)
   return '[' + listItems.map((item: ListItem) => JSON.stringify(item)).join(',') + ']'
 }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь, когда у нас есть все функциональные возможности для работы с файлами, давайте создадим ReadModel для проецирования всех событий загрузки файлов, чтобы мы знали, какие файлы были загружены. Эта ReadModel будет проецировать событие UploadedFileEntity, определенное как:

export class UploadedFileEntityReadModel {
 public constructor(public id: string, readonly metadata: unknown) {}

 @Projects(UploadedFileEntity, 'id')
 public static projectUploadedFileEntity(
   entity: UploadedFileEntity,
   currentUploadedFileEntityReadModel?: UploadedFileEntityReadModel
 ): ProjectionResult<UploadedFileEntity> {
   console.log(`ReadModel Projects UploadedFileEntityReadModel ${entity}`)
   return new UploadedFileEntityReadModel(entity.id, entity.metadata)
 }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вот и все. Запустите ваше приложение Booster, и вы увидите новые мутации:

mutation {
    FileUploadPut(input: {directory: "folder01", fileName: "3.txt"})
}
mutation {
    FileUploadGet(input: {directory: "folder01", fileName: "3.txt"})
}
mutation {
  FileUploadList(input: {directory: "folder02"}) 
}
Вход в полноэкранный режим Выход из полноэкранного режима

И запрос для получения загруженных файлов:

query{
    UploadedFileEntityReadModels(filter: {}){
        id
        metadata
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Оба запроса работают на Local так же, как и на провайдере Azure.

Выводы

На этом реальном примере мы рассмотрели, как расширить логику и инфраструктуру Booster с поддержкой любого провайдера.

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

И последнее, но не менее важное: если у вас есть вопросы по Booster или по любой другой теме, мы будем рады выслушать их на канале нашего сообщества.

Оцените статью
Procodings.ru
Добавить комментарий