Введение

В последнее время все больше компаний сталкиваются с необходимостью создания нескольких брендов на одном сайте или приложении. Такая задача может быть связана с различными целевыми аудиториями пользователей, регионами или продуктами. Реализация мультибрендинга может быть непростой задачей, особенно если у вас уже есть готовый проект. 

В этой статье я расскажу, как на проекте с Angular добавить несколько брендов, как добавить разную для них логику, о преимуществах NX для этой задачи и важности архитектуры. 

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

Добавление мультибренда

Способ добавления мультибрендинга сильно зависит от текущей конфигурации вашего проекта. 

К примеру, если у вас классическое Angular приложение, то вы можете для второго бренда создать новый репозиторий и развивать его там независимо. Это позволит легко добавлять уникальные компоненты и стили, настроить независимый деплой. А общий код можно вынести в отдельный репозиторий и использовать в проектах как библиотеку. Однако этот подход имеет и недостатки: в зависимости от чистоты кода и продуманности архитектуры создание общих библиотек может быть непростой задачей. 

При использовании монорепозитория NX переиспользование общего кода становится более простым и существенно упрощает реализацию общего функционала, хотя требует более структурированного подхода к ведению кода и немного усложняет CICD.

Для проекта над которым я работаю вместе со своей командой мы построили мультибрендинг на NX. К моменту, когда появилась эта задача мы уже перешли с классического приложения на NX и проделали серьёзную работу по реструктуризации кода на библиотеки с горизонтальным и вертикальным делением по слоям.

Если коротко, то горизонтальное и вертикальное деление по слоям - это два подхода к организации кода в приложении. Первое это ….

Реализация монорепозитория позволила решить нам следующие задачи:

  1. Раздельные релизы 

  2. Независимые конфигурации приложений брендов

  3. Уникальные стили и брендинг приложений

  4. Переиспользование общих компонентов и логики

  5. Использование уникальных для брендов компонентов и логики

  6. Настройка независимой конфигурации роутинга приложений

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

Добавление монорепоризитория

Если у вас классическое Angular приложение, то перейти на nx можно по инструкции

После этого надо создать приложения для брендов и перенести туда ваше приложение. Об этом ниже.

Приложения для брендов

Для каждого бренда создаётся отдельное приложение. Это даёт большую гибкость при настройке независимых релизов, уникальные конфигурации приложений и брендинг.

NX предлагает удобный CLI для добавления приложения https://nx.dev/packages/angular/generators/application

При добавлении приложения в папке apps создаётся папка с проектом и e2e тестами. В конечном итоге файловая структура будет выглядеть так:

/apps/brand1 – приложение бренда 1

/apps/brand1-e2e – интеграционные тесты приложения бренда 1

/apps/brand2 – приложение бренда 2

/apps/brand2-e2e – интеграционные тесты приложения бренда 2

/libs/… – общие библиотеки

Добавление библиотек

После создания приложений необходимо расположить общий для них код в библиотеках NX. 

Перед созданием библиотек я рекомендую заранее продумать горизонтальное и вертикальное деление вашего проекта. Вертикальное деление это деление по доменам, а горизонтальное – по слоям. NX позволяет для библиотек указать принадлежность к слоям и в ESLint настроить правила их импортов. Затем мы можем в хуке прекоммита и в пайпах GitLab настроить проверку импортов и циклических зависимостей. Эта тема довольно интересная и об этом я расскажу в отдельной статье.

Создать библиотеку можно также через NX CLI https://nx.dev/packages/angular/generators/library

Настройка правил зависимостей библиотек

Если в проекте определены правила импорта библиотек, то NX предоставляет инструмент, который будет следить за этим. 

Для этого в файле project.json в свойстве tags необходимо описать теги приложения, которые отражают его принадлежность к бренду или слою, к примеру, добавим библиотеку слоя feature и правило, по которому слой page может импортировать feature:

{
  "tags": ["layer:feature"]
}

Затем в .eslint.json добавьте правила для enforce-module-boundaries:

"@nrwl/nx/enforce-module-boundaries": [
  "error",
  {
    "depConstraints": [
      {
        "sourceTag": "layer:page",
        "onlyDependOnLibsWithTags": [
          "layer:feature",
        ]
      },

Настройка project.json

Приложения в NX можно настроить в файле конфигурации, они расположены по адресу apps/(brand1/brand2)/project.json. 

При настройке уникальными значениями являются такие параметры как имена проектов, префикс, путь к проекту и сборке, другие значения могут быть переиспользованы. К примеру у проектов может быть общий index.html (хотя на практике удобнее иметь разные).

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

Параметр

Описание

Значение для брендов

name

уникальное имя проекта

brand1/brand2

sourceRoot

путь к проекту

apps/(brand1/brand2)/src

prefix

Префикс проекта (мы используем сокращённое имя бренда)

brnd1/brnd2

targets/build/options/outputPath

путь к сборке проекта

dist/apps/(brand1/brand2)/browser

targets/build/options/index

путь к index.html

apps/(brand1/brand2)/src/index.html

targets/build/options/main

Путь к main.ts

apps/(brand1/brand2)/src/main.ts

targets/build/options/polyfills

Путь к polyfills.ts

apps/(brand1/brand2)/src/polyfills.ts

targets/build/options/tsConfig

Путь к tsconfig.app.json

apps/(brand1/brand2)/tsconfig.app.json

targets/build/options/assets

Статика

{

   "glob": "**/*",

   "input": "libs/assets/src/lib/assets-ng",

   "output": "./assets-ng/"

}, – общая статика для обоих брендов

{

   "glob": "**/*",

   "input": "apps/(brand1/brand2)/src/assets-( brnd1/brnd2)",

   "output": "./assets-( brnd1/brnd2)/"

} – уникальная статика бренда

targets/build/options/styles

Стили проекта

apps/(brand1/brand2)/src/styles.scss 

targets/build/options/stylePreprocessorOptions/includePaths

Глобальные переменные стилей

apps/(brand1/brand2)/src/styles/core/color

Cкрипты для запуска

Все скрипты для обоих приложений размещены в package.json, они дублируются с учётом специфики проекта. Вот пример скриптов для разработки: 

"brand1:start": "nx serve brand1 -o",

"brand2:start": "nx serve brand2 -o",

Работа с брендами в проекте

Определение бренда внутри общих библиотек и работа с уникальными для бренда параметрами может быть достигнуто разными способами и зависит от задачи и архитектуры приложения. Мы используем такие подходы, как файлы конфигурации, токены, сервисы, роутинг, if/else (но в меру.) и другие подходы. Дальше я опишу их все.

Файл конфигурации брендов

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

В такую конфигурацию мы вынесли GTM ключ для аналитики, путь к assets, конфиг CDN и другие важные и повторяющиеся для каждого бренда параметры.

Для настройки используются 3 файла конфигурации brand.config.ts. Один общий и 2 для брендов - в папках приложений: apps/(brand1/brand2)/src/brand.config.ts.

Далее в project.json настроена замену общего файла конфигурации на нужный для бренда в параметре targets/build/configurations/${имя конфига}/fileReplacements:. Логика такая же как и с обычным файлом переменных окружения environment.ts.

{
  "replace": "libs/core/src/lib/environments/brand.config.ts",
  "with": "apps/(brand1/brand2)/src/environments/brand.config.ts"
}

Общие компоненты

В мультибрендинге общие компоненты составляют бОльшую часть всех компонентов, они используются во всех брендах и являются ключевыми в приложении, так как позволяют существенно сократить время разработки. 

Чтобы обеспечить повторное использование, общие компоненты должны быть максимально абстрагированы от особенностей брендов. Такими компонентами могут быть атомарные UI элементы или наоборот общие шаблоны страниц, они могут встраивать в себя уникальные для бренда компоненты и зависимости и для этого требуется особый подход, и об этом ниже.

Уникальные компоненты

Наряду с общими компонентами часто требуется создавать уникальные для брендов компоненты. Они могут отличаться внешним видом, функционалом и другими специфичными для бренда параметрами. Важно понимать, что создание уникальных компонентов увеличивает время и сложность проекта. 

Для их отображения чаще всего можно использовать общие компоненты или роутинг. Далее мы рассмотрим эти способы.

Получение уникального компонента

Выбор подхода в получении компонента бренда влияет на читаемость кода, его переиспользование и масштабирование. Можно в каждом общем компоненте реализовать свою логику получения компонента и иногда это может быть оправдано, например, вот так:

@Component({
  selector: 'app-common-component',
  template: ``
})
export class CommonComponent implements OnInit {
  @Input() brand: BrandEnum;

  brandComponent;

  ngOnInit() {
    if (this.brand === BrandEnum.A) {
      this.brandComponent = BrandAComponent;
    }
    
    // Динамическое создание компонента для Brand B
    if (this.brand === BrandEnum.B) {
      this.brandComponent = BrandBComponent;
    }
  }
}

Но этот способ неудобен, такая реализация будет дублировать код из компонента в компонент, а это значит, что неизбежны ошибки и вообще DRY.

Способ лучше это получить компонент через DI.

Dependency Injection

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

Грамотная настройка DI в мультибрендинге позволяет избежать ошибок, связанных с неправильным управлением зависимостями и упрощает поддержку кода в дальнейшем.

Давайте перепишем пример выше и получим компонент бренда через DI. Для этого определяем базовый интерфейс для компонента бренда, где описываем общие методы или свойства, которые должны быть реализованы:

interface BrandComponent {
  // Общие методы или свойства для компонента бренда
  // ...
}

Создаём компоненты броендов и имплементируем общий интерфейс:

@Component({
  selector: 'app-brand-component-a',
  // ...
})
export class BrandComponentA implements BrandComponent {
  // Реализация методов и свойств для компонента бренда A
  // ...
}


@Component({
  selector: 'app-brand-component-b',
  // ...
})
export class BrandComponentB implements BrandComponent {
  // Реализация методов и свойств для компонента бренда B
  // ...
}

Создаём сервис BrandService, который будет предоставлять информацию о текущем бренде:

import { Injectable } from '@angular/core';


@Injectable()
export class BrandService {
  getBrand(): string {
    // Верните текущий бренд
    return 'brand1';
  }
}

Делаем фабрику BrandComponentFactory и в неё внедряем BrandService, в результате она возвращает нужный компонент:

import { BrandService } from 'путь_к_сервису_бренда';
import { Component, Type } from '@angular/core';


import { BrandComponent1 } from 'путь_к_компоненту_бренда_1';
import { BrandComponent2 } from 'путь_к_компоненту_бренда_2';

export function brandComponentFactory(brandService: BrandService): Type<any> {
  const brand = brandService.getBrand();

  if (brand === 'brand1') {
    return BrandComponent1;
  } else if (brand === 'brand2') {
    return BrandComponent2;
  }

  // По умолчанию вернуть общий компонент
  return CommonBrandComponent;
}

В модуле, где определены компоненты бренда, указываем провайдер с использованием useFactory и внедряем BrandService:

import { NgModule } from '@angular/core';
import { BrandComponent1 } from 'путь_к_компоненту_бренда_1';
import { BrandComponent2 } from 'путь_к_компоненту_бренда_2';
import { BrandService } from 'путь_к_сервису_бренда';
import { brandComponentFactory } from 'путь_к_фабрике';

@NgModule({
  declarations: [BrandComponent1, BrandComponent2],
  providers: [
    BrandService,
    {
      provide: BrandComponent,
      useFactory: brandComponentFactory,
      deps: [BrandService]
    }
  ]
})
export class BrandModule {}

И в общем компоненте, через DI получаем компонент бренда:

@Component({
  selector: 'app-common-component',
  template: ``,
})
export class CommonComponent {
  constructor(public brandComponent: BrandComponent) {}
}

Далее в примерах я буду использовать этот способ получения компонента через DI.

Также мы можем передать настройки специфичные для бренда. Для этого мы создадим токен:

export const BRAND_CONFIG = new InjectionToken<BrandConfig>('brandConfig');

создадим конфиги для каждого бренда и далее запровайдим в app.module для каждого application:

{
 provide: BRAND_CONFIG,
 useValue: BrandsConfig,
}

После этого мы сможет через токен получить нужное значение в общих или уникальных для брендов компонентах:

constructor(
  @Inject(BRAND_CONFIG) private brandConfig: BrandConfig
) {}

Таким образом компоненту не придётся заботиться о проверке бренда, он получит конфиг в зависимости от сборки проекта.

Сервис

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

export const BRAND_SERVICE = new InjectionToken<FeatureService>('brand service');

export function brandServiceFactory(brandConfig: BrandConfig, brand1Service: Brand1Service, brand2Service: Brand2Service): any {
  switch (brandConfig.brand) {
    case BRANDS.BRAND1:
      return brand1Service;
    case BRANDS.BRAND2:
      return brand2Service;
    default:
      throw new Error(`Brand ${brandConfig.brand} is not supported`);
  }
}

Далее регистрируем функцию в провайдере сервиса, указав её в useFactory, а в deps передаём нужные фабрике зависимости:

export const BRAND_SERVICE_PROVIDER = {
  provide: BRAND_SERVICE,
  useFactory: brandServiceFactory,
  deps: [brand.config, Brand1Service, Brand2Service]
};

И затем добавляем провайдер в модуль или компонент:

@Component({
  …
  providers: [BRAND_SERVICE_PROVIDER],

И инжектим в конструктор токен:

constructor(
  @Inject(BRAND_SERVICE) private brandService: BrandService
) {}

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

Передача компонента через роутинг

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

Также в Angular 15 появился routerGuard – CanMatch, он позволяет для одного маршрута в зависимости от условия загрузить нужный компонент, в нашем примере нужный для бренда:

{
  path: 'routPath',
  component: LandingOffersComponent,
  canMatch: [() => brandConfig.BRAND === Brands.Brand1
},
{
  path: 'routPath',
  component: LandingOffersComponent,
  canMatch: [() => brandConfig.BRAND === Brands.Brand2],
}

Ещё у роутинга есть возможность передавать компонент в нужный селектор, указанный в outlet:

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('@layout).then((mod) => mod.LayoutComponent),
    children: [
      {
        path: '',
        loadComponent: () =>
          import('@header-brand-a').then((mod) => mod.HeaderAComponent),
        outlet: 'header',
      },
    ],
  },
]

Затем в шаблоне компонента LayoutCompoenent указываем router-outlet, где в атрибуте name задаём «header»:

<router-outlet name="header"></router-outlet>

Так в общем компоненте через роутинг по селектору «header» для каждого бренда мы можем указать специфичный компонент хедера, где в свою очередь через ng-content мы можем передать уникальное для бренда содержимое. Подробнее об этом ниже.

ng-content

ng-content позволяет передавать содержимое внутрь компонента, что делает его более гибким и настраиваемым. В контексте мультибрендинга, мы используем это для настройки отображения содержимого компонентов в зависимости от конкретного бренда.

Вот пример как настроить уникальные хедеры для брендов, используя общий компонент:

Создаём HeaderComponent, который будет использовать ng-content: 

@Component({
  selector: 'app-header',
  template: `
    <div class="header">
      <ng-content></ng-content>
    </div>
  `,
  styles: [`
    .header {
      /* стили компонента */
    }
  `]
})
export class HeaderComponent {}

В разметке шаблона родительского компонента HeaderComponent используем <ng-content></ng-content>, чтобы указать место, куда будет вставлено содержимое.

Создаём компоненты для каждого бренда, которые будут использовать BrandComponent и предоставлять свое содержимое:

@Component({
  selector: 'app-header-brand-a',
  template: `
    <app-header>
      <app-logo-brand-a />
      <app-auth />
    </app-header>
  `,
  styles: [`
    /* стили компонента Brand A */
  `]
})
export class HeaderAComponent {}

@Component({
  selector: 'app-header-brand-b',
  template: `
    <app-header>
      <app-logo-brand-a />
      <app-search />
      <app-auth />
    </app-header>
  `,
  styles: [`
    /* стили компонента Brand B */
  `]
})
export class HeaderBComponent {}

Затем мы можем передать компоненты HeaderAComponent и HeaderBComponent через роутинг как было описано выше.

В результате мы получаем хедер для бренда А:

И для бренда Б:

Динамическая загрузка и создание компонентов

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

Вот пример как динамически загрузить нужный компонент:

@Component({
  selector: 'app-dynamic-component-container',
  template: `
    <div #container></div>
  `
})
export class DynamicComponentContainerComponent implements AfterViewInit {
  @ViewChild('container', { read: ViewContainerRef }) container: ViewContainerRef;

  constructor(private componentFactoryResolver: ComponentFactoryResolver, private brandComponent: BrandComponent) {}

  ngAfterViewInit() {
    // После инициализации представления компонента мы можем создавать компоненты 
   this.createComponent(this.brandComponent);
  }

  createComponent(component: Type<any>) {
    const componentRef = this.container.createComponent(component);
    // Через ссылку на компонент можно передать Input и подписаться на Output  
  }
}

Чтобы оптимизировать размер проекта можно лениво загружать компоненты, таким образом при сборке компонент попадает в отдельный бандл. Вот как выглядит метод ленивой загрузки:

export const brandDynamicComponentLoader = async (): Promise<typeof BrandDynamicComponent> => {
  const { BrandDynamicComponent } = await import('./brand-dynamic.component');
  return BrandDynamicComponent;
};

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

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

Директивы *ngComponentOutlet

Директива *ngComponentOutlet позволяет динамически создавать компоненты в Angular. В контексте мультибрендинга она позволяет в общем компоненте отобразить уникальный компонент бренда, а также передать в него инжекты и контент, это делает её использование ещё более функциональным.

К примеру, можно сделать общий компонент согласования, в нём мы будем показывать компонент нужного бренда, куда передадим 2 темплейта и инжект с необходимыми данными:

@Component({
  selector: 'app-approval-component',
  template: `
    <ng-template #accept>Accept</ng-template>
    <ng-template #reject>Reject</ng-template>
    <ng-container *ngComponentOutlet="brandComponent;
                                      injector: myInjector;
                                      content: myContent"></ng-container>`
})
export class BrandSpecificComponent {
  @ViewChild('accept', {static: true}) acceptTemplateRef!: TemplateRef<any>;
  @ViewChild('reject', {static: true}) rejectTemplateRef!: TemplateRef<any>;

  myInjector: Injector;
  myContent?: any[][];

  constructor(
    private vcr: ViewContainerRef,
    private brandComponent: BrandComponent,
  ) {
    this.myInjector =
        Injector.create({providers: [{ provide: COMPONENT_DATA useValue: ComponentData,  }]});
  }

  ngOnInit() {
    // Create the projectable content from the templates
    this.myContent = [
      this.vcr.createEmbeddedView(this.acceptTemplateRef).rootNodes,
      this.vcr.createEmbeddedView(this.rejectTemplateRef).rootNodes
    ];
  }
}

Логика внутри общего компонента, структурные директивы: *ngIf, *ngSwitch, *ngTemplateOutlet

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

Стурктурная директива *ngIf на основании результата тернарного выражения позволяет указать один из шаблонов:

@Component({
  selector: 'app-brand-specific-component',
  template: `
    <div *ngIf="brand === 'A'">
      <app-brand-a-component></app-brand-a-component>
    </div>
    <div *ngIf="brand === 'B'">
      <app-brand-b-component></app-brand-b-component>
    </div>
    <div *ngIf="brand !== 'A' && brand !== 'B'">
      <app-default-component></app-default-component>
    </div>
  `
})
export class BrandSpecificComponent {
  @Input() brand: string;
}

<app-brand-specific-component [brand]="'A'"></app-brand-specific-component>

*ngSwith на основнании сравнения со значением переменной может вернуть один из нескольких вариантов шаблона:

@Component({
  selector: 'app-brand-specific-component',
  template: `
    <div [ngSwitch]="brand">
      <ng-container *ngSwitchCase="'A'">
        <app-brand-a-component></app-brand-a-component>
      </ng-container>
      <ng-container *ngSwitchCase="'B'">
        <app-brand-b-component></app-brand-b-component>
      </ng-container>
      <ng-container *ngSwitchDefault>
        <app-default-component></app-default-component>
      </ng-container>
    </div>
  `
})
export class BrandSpecificComponent {
  @Input() brand: string;
}
<app-brand-specific-component [brand]="'A'"/>

*ngTemplateOutlet позволяет отрендерить один из шаблонов:

@Component({
  selector: 'app-brand-specific-component',
  template: `
    <!-- Шаблон для бренда A -->
    <ng-template #brandATemplate>
      <h1>Brand A</h1>
      <p>Welcome to Brand A!</p>
    </ng-template>

    <!-- Шаблон для бренда B -->
    <ng-template #brandBTemplate>
      <h1>Brand B</h1>
      <p>Welcome to Brand B!</p>
    </ng-template>

    <ng-container [ngTemplateOutlet]="selectedTemplate"></ng-container>
  `
})
export class BrandSpecificComponent implements AfterViewInit {
  @ViewChild('brandATemplate') brandATemplate: TemplateRef<any>;
  @ViewChild('brandBTemplate') brandBTemplate: TemplateRef<any>;

  public selectedTemplate!: TemplateRef<any>;

  constructor(@Inject(BRAND_CONFIG) private brandConfig: BrandConfig) {}

  public ngAfterViewInit(): void {
    this.selectedTemplate = this.brandConfig.brand === Brands.A ?       this.brandATemplate : this.brandBTemplate;
  }
}

<app-brand-specific-component/>

Но надо помнить, что эти подходы усложняют логику компонента, снижают его возможность масштабирования и читабельность.

Уникальная статика и брендинг

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

Далее мы рассмотрим способы их настройки.

Настройка assets

Бренды используют как общую статику, например, в виде иконок кнопок, так и уникальную, в виде брендовых изображений.

Общую статику мы разместили в библиотеке libs/assets.

Брендовую статику разместили в каждом приложении бренда, в своей assets по адресу apps/(brand1/brand2)/src/assets-(brnd1/brnd2). 

Ссылка на оба assets задаётся в project.json в параметре targets/build/options/assets:

{
 "glob": "**/*",
 "input": "libs/assets/src/lib/assets-ng",
 "output": "./assets-ng/"
},    
{
 "glob": "**/*",
 "input": "apps/brnd1/src/assets ",
 "output": "./assets "
}

Далее доступ к общей статике указываем ссылкой “assets-ng/”, к брендовой через файл конфигурации бренда brand.config.assets, где для каждого бренда мы указываем через enum нужный путь:

export enum BrandsAssets {
  BRAND_1 = 'assets-brnd1/',
  BRAND_2 = 'assets-brnd2/',
}

Настройка scss переменных

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

К примеру, для общего компонента в стилях мы может задать background-image, передав адрес к брендовой картинке: background-image: url($backgroundImageUrl + $assetsUrl + 'icon-arrow-primary.svg');

Здесь:

$backgroundImageUrl – задаёт относительную ссылку для среды разработки или ссылку на CDN в проде

$assetsUrl – путь к assets бренда

В общих деталях настройка $assetsUrl и $backgroundImageUrl выглядит похоже, но они разделены по разным файлам, тк у них разная причина изменения: $assetsUrl – зависит от бренда, а $backgroundImageUrl – от среды.

Настройка $backgroundImageUrl:

В проекте находятся 3 файла конфигурации: один для среды разработки и два - для прода.

Для разработки:

«libs/lu/core/shared/src/lib/environments/scss/_env-vars.scss»

$backgroundImageUrl: '/';

Для прода: 

«apps/(brand1/brand2)/src/environments/scss-prod/_global-vars.scss»

$backgroundImageUrl: 'https://cdn.(brand1/brand2).com/';

Настройка $assetsUrl:

В каждом приложении бренда есть файл apps/(brand1/brand2)/src/environments/scss/_brand-vars.scss

Внутри файла определены scss переменные:

$assetsUrl: 'assets-(brand1/brand2)/';

Все файлы с переменными SCSS задаются в project.json в параметре targets/build/options/stylePreprocessorOptions/includePaths:

"includePaths": [
   "apps/(brand1/brand2)/src/environments/scss",
]

Далее оба файла надо прописать в mixins.scss

@import '_global-vars';
@import '_brand-vars';

В свою очередь mixins.scss добавляется в styles.scss бренда.

SCSS функция

SCSS функция может быть полезна, что бы задать уникальный для бренда стиль. Можно создать функцию для определённого стиля. Мы сделали общую функцию, которая на основании переменной scss переменной задаёт переданное ей свойство: 

@function brandSwitch($brand1Arg: '', $brand2Arg: '', $important: '') {
  $var: $brand1Arg;
  @if $assetsUrl == 'assets-brnd2/' {
    $var: $brand2Arg;
  }
  @return unquote($var + ' ' + $important);
}

Используется она так:

background-color: brandSwitch($main-secondary-color, $main-primary-color);

Уникальные цвета

Для каждого бренда мы создали файл с конфигом своей цветовой палитры. Эти файлы находятся в папках приложений: apps/(brand1/brand2)/src/styles/color/colors.scss

В файле определены переменные для цветов бренда. Названия переменных одинаковы для брендов.

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

//buttons
$primary-button: (
  background-color: $main-primary-color,
  color: $white-color,
  icon-color: $white-color,
  hover-background-color: $hover-primary-color,
  hover-color: $white-color,
  focus-background-color: $hover-primary-color,
  focus-color: $white-color,
  disabled-background-color: $main-disable-color,
  disabled-color: $button-text-disable-color,
);

Это позволяет гибко настраивать цветовую схему для каждого из брендов.

Конфиги добавлены в project.json в параметре targets/build/options/stylePreprocessorOptions/includePaths:

"includePaths": [
    "apps/(brand1/brand2)/src/styles/core/color",
]

CICD

Независимые релизы брендов позволяют обеспечить собственные циклы разработки и релизов, обеспечить масштабируемость за счёт независимости ресурсов.

Мы настроили деплой через GitLab CI, в файле .gitlab-ci.yml описали шаги для развёртывания. Бренды имеют общие шаги по запуску unit-test’ов, lint’ов, сборке и независимые релизы на окружения: тестовые и на прод. Это позволяет убедиться, что новый функционал одинаково хорошо работает во всех сборках и при этом мы можем релизить только нужный бренд. 

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

Мы используем Angular Universal и для кеширования статики используем Redis и здесь мы пошли по аналогичному пути – у каждого свой отдельный сервер со своими настройками по ресурсам. 

Мониторинг

Для мониторинга приложений мы используем Sentry и OpenTelemetry. Наличие нескольких приложений позволяет легко передать нужные настройки для подключения и настроить сбор логов для брендов отдельно, это упрощает мониторинг и выявление ошибок у клиентов.

Заключение

На выбор технической реализации мультибрендинга влияет архитектура вашего проекта и потребности компании. На примере нашего проекта я показал, что Angular и Nx являются эффективными инструментами для реализации мультибрендинга. Angular позволяет строить модульную и масштабируемую систему, а NX предоставляет инструменты для работы с несколькими приложениями в монорепозитории. 

Помимо этого, есть важные темы для мультибрендинга - это SEO и архитектура приложения. 

SEO затрагивает вопросы оптимизации брендов для поисковых систем, как правильно развести бренды в поисковой выдаче, выбор стратегии позиционирования брендов, разделение контента, фасилитацию.  

Тема архитектуры описывает вопросы изоляции и переиспользование кода, гибкости и масштабируемости, управление зависимостями, иначе говорят, всё что позволяет успешно развивать приложение сейчас и в будущем.

Обе эти темы очень большие и о них я расскажу в следующих статьях.

Все приведённые в статье подходы мы успешно используем в нашем проекте. Напишите, что из примеров вы используете у себя на проекте или поделитесь своими интересными решениями.

Комментарии (4)


  1. dopusteam
    15.07.2023 13:34
    +1

    И в общем компоненте, через DI получаем компонент бренда

    А зачем нам компонент получать как зависимость, что с ним дальше делать?


    1. druzhkov_dv Автор
      15.07.2023 13:34
      +1

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

      К примеру, в общем компоненте можно создать экземпляр компонента бренда, отобразить его в шаблоне, при этом первый компонент будет абстрагирован об логики брендов и легко переиспользован.


      1. dopusteam
        15.07.2023 13:34

        Имхо, не хватает сравнения с обычным ngIf, который будет отрисовывать либо компонентА, либо компонентБ.

        Я вот вижу плюсы, что не будет размазывания логики по сервисам и резолверам, не будет работы с компонентами в компоненте (а только в шаблоне). Можно будет сделать разные инпуты в компоненты разных брендов.

        Сейчас это выглядит очень шатко и применимо в каком то ограниченном количестве кейсов


        1. druzhkov_dv Автор
          15.07.2023 13:34

          Плюсы такого подхода через DI перечислены верно, в проекте он используется регулярно.
          Что касается сравнения с ngIf, то в статье есть пример подхода с ngIf, ngSwith и ngTemplateOutlet, в целом это рабочие решения, но тогда логика определения бренда переходит в общий компонент, что конечно его усложняет и затрудняет масштабирование.