Использование границ модулей, гексагональной архитектуры и современного NgRx

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

Прочитайте исходную статью на ng-journal.com (включая репозиторий GitHub)



Почему и когда следует использовать NgRx?

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

Короткий ответ: это зависит от обстоятельств.

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

Еще более длинный ответ:

Большинству приложений на самом деле не нужно полноценное хранилище Redux, такое как NgRx, — пока оно не понадобится!.
Обычно сначала достаточно начать с простого управления состояниями на основе служб, и вы можете быть удачливым и никогда не нуждаться ни в чем другом. На самом деле многим приложениям больше ничего и не нужно.

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

Один из способов избежать таких циклических зависимостей — дублировать состояние и, возможно, логику, связанную с этим состоянием. С одной стороны, это решит проблему циклической зависимости, но, с другой стороны, приведет к множеству дублирований кода и несоответствий в вашем состоянии. Кроме того, будет сложно отслеживать все места, где используется и обновляется состояние. Это также является очевидным нарушением принципа DRY (не повторяйтесь).
Если вы попытаетесь синхронизировать состояние между различными службами, вы либо снова получите круговые зависимости, либо вам придется ввести ваша собственная система событий, что тоже не очень хорошая идея.

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

Если вы не знакомы с NgRx и Redux, рекомендую сначала прочитать официальную документацию.

Архитектура Nx для NgRx

Границы модуля

Если вы не знакомы с шаблоном Enterprise Monorepo Pattern, я бы порекомендовал сначала прочитать о нем. Причина этого в том, что архитектура рабочей области Nx основана на шаблоне Enterprise Monorepo, но со специфическими изменениями NgRx.
Вместо традиционной библиотеки доступа к данным, содержащей логику, состояние, сервисы и сущности, мы разделим его на библиотеки состояния и доступа к данным. В то время как библиотека state будет содержать только особенности NgRx, такие как действия, редюсеры, эффекты и селекторы, библиотека доступа к данным будет содержать только службы данных и сущности.

Кроме того, мы представим библиотеку DTOs, которая будет содержать все объекты передачи данных, которые используются для связи с серверной частью. Это особенно полезно, потому что мы можем использовать правило линтинга @nrwl/nx/enforce-module-boundaries, чтобы убедиться, что DTO используются только в библиотеке доступа к данным и больше нигде. Чтобы реализация бэкенда не просачивалась во фронтенд.

"rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "enforceBuildableLibDependency": true,
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "type:app",
            "onlyDependOnLibsWithTags": ["type:feature", "type:routes", "type:ui", "type:state", "type:util"]
          },
          {
            "sourceTag": "type:routes",
            "onlyDependOnLibsWithTags": ["type:feature", "type:util", "type:state"]
          },
          {
            "sourceTag": "type:feature",
            "onlyDependOnLibsWithTags": ["type:ui", "type:state", "type:util"]
          },
          {
            "sourceTag": "type:ui",
            "onlyDependOnLibsWithTags": ["type:util"]
          },
          {
            "sourceTag": "type:util",
            "onlyDependOnLibsWithTags": []
          },
          {
            "sourceTag": "type:state",
            "onlyDependOnLibsWithTags": ["type:util", "type:data-access"]
          },
          {
            "sourceTag": "type:data-access",
            "onlyDependOnLibsWithTags": ["type:util", "type:dtos"]
          },
          {
            "sourceTag": "type:dtos",
            "onlyDependOnLibsWithTags": []
          }
        ]
      }
    ]
  }

Создание функционального среза для домена

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

Чтобы настроить глобальное хранилище для приложения Angular с использованием автономных API-интерфейсов, нам нужно добавить метод ProvideEffects() и ProvideStore() в массив поставщиков в файле appConfig.

export const appConfig: ApplicationConfig = {
  providers: [
    provideEffects(),
    provideStore(),
    provideRouter(appRoutes),
    provideHttpClient(),
    !environment.production ? provideStoreDevtools() : []
  ],
};

Теперь мы можем создать фрагмент функции для домена, создав новую библиотеку с тегом type:state внутри домена. Эта библиотека состояний содержит весь ваш специфичный код NgRx, такой как действия, редукторы, эффекты и селекторы. Вы также должны убедиться, что вы экспортируете действия, редукторы, эффекты и селекторы из библиотеки, экспортируя их в index.ts библиотеки состояний.

Вы можете использовать генератор Nx ngrx-feature-store для создания части кода NgRx. После запуска генератора вы сможете увидеть множество файлов в вашей государственной библиотеке.

Прежде всего, давайте взглянем на файл действий. В настоящее время мы используем функцию createActionGroup для создания группы действий, потому что она экономит некоторый шаблонный код и помогает со структурой. Например, мы можем создать группу действий для вызовов API фрагмента функции, а другую группу — только для определенных действий на странице. Крайне важно не использовать действия повторно, чтобы обеспечить хорошую гигиену действий, чтобы вы могли отслеживать поток действий в своем приложении. Структурирование ваших действий в группах помогает в этом.

export const initCustomers = createAction('[Customers Page] Init');

export const customerApiActions = createActionGroup({
  source: 'Customers API',
  events: {
    loadCustomers: emptyProps(),
    loadCustomersSuccess: props<{ customers: CustomersEntity[] }>(),
    loadCustomersFailure: props<{ error: unknown }>(),
  },
});

Внутри файла редуктора вы должны увидеть редукторы и начальное состояние фрагмента функции.

export interface CustomersState extends EntityState<CustomersEntity> {
  selectedId: string | null;
  loaded: boolean;
  error: unknown | null;
}

export const customersAdapter: EntityAdapter<CustomersEntity> =
  createEntityAdapter<CustomersEntity>();
export const initialCustomersState: CustomersState =
  customersAdapter.getInitialState({
    selectedId: null,
    loaded: false,
    error: null,
  });

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

Обратите внимание, что функция createFeature требует, чтобы вы не использовали необязательные свойства в состоянии. Это означает, что использование в состоянии load?: boolean не допускается. Чтобы предотвратить это, вы все же можете создать объединение типов, например, нагруженное: boolean | не определено или загружено: логическое значение | нулевой.

const { selectAll } = customersAdapter.getSelectors();

/**
 * 👧🏻 Modern NgRx with out-of-the-box selectors
 */
export const customersFeature = createFeature({
  name: 'customers',
  reducer: createReducer(
    initialCustomersState,
    on(CustomersActions.initCustomers, (state) => ({
      ...state,
      loaded: false,
      error: null,
    })),
    on(customerApiActions.loadCustomersSuccess, (state, { customers }) =>
      customersAdapter.setAll(customers, { ...state, loaded: true })
    ),
    on(customerApiActions.loadCustomersFailure, (state, { error }) => ({
      ...state,
      error,
    }))
  ),
  extraSelectors: ({
    selectCustomersState,
  }) => ({
    selectAllCustomers: createSelector(
      selectCustomersState,
      (state: CustomersState) => selectAll(state)
    ),
  }),
});

Прежде чем мы сможем использовать фрагмент функции, мы должны предоставить эффекты и саму функцию в глобальное хранилище. Используя автономные API, это можно сделать, добавив эффекты и функцию в конфигурацию Routes и добавив функции ProvideState() и ProvideEffects() в массив поставщиков.

export const routes: Routes = [
  {
    path: '',
    providers: [
      provideState(customersFeature),
      provideEffects(customersEffects),
    ],
    children: [
      {
        path: '',
        pathMatch: 'full',
        redirectTo: 'list',
      },
      {
        path: 'list',
        loadComponent: async () =>
          (await import('@ngrx-leaky-backends/customer/feature-list'))
            .FeatureListComponent,
      },
    ],
  },
];

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

@Component({
  selector: 'ngrx-leaky-backends-feature-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './feature-list.component.html',
  styleUrls: ['./feature-list.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeatureListComponent implements OnInit {
  private readonly store = inject(Store);
  readonly customersSignal = this.store.selectSignal(customersFeature.selectAllCustomers);
  readonly customersLoadedSignal = this.store.selectSignal(
    customersFeature.selectLoaded
  );

ngOnInit() {
    this.store.dispatch(customerApiActions.loadCustomers());
  }
}

Шестиугольная архитектура

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

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

Но… Концепция портов и адаптеров может быть полезна, если вы действительно хотите добиться независимости от серверной части. В этом случае вы можете использовать общую службу порта, которая отвечает за связь с серверной частью. Фактическая реализация предоставляется в конкретном адаптере. Этот адаптер может быть адаптером REST, адаптером GraphQL, фиктивным адаптером или адаптером Firebase. Потребитель службы порта не знает, какой адаптер используется, и поэтому ему не нужно знать, как реализована серверная часть.

Служба порта должна быть абстрактной службой, реализуемой адаптерами. Абстрактные методы должны быть действительно общими. Также важно использовать ключ поставщика useFactory или useClass для предоставления службы адаптера.

@Injectable({
  providedIn: 'root',
  useFactory: useCustomerAdapter,
})
export abstract class CustomerPortService {
  abstract loadOne(id: string): Observable<Customer>;
  abstract loadAll(): Observable<Customer[]>;
  abstract create(customer: Customer): Observable<Customer>;
  abstract update(id: string, customer: Customer): Observable<Customer>;
}

Служба адаптера должна быть конкретной службой, реализующей абстрактную службу порта. Реализация должна быть максимально конкретной. Например, если вы используете адаптер REST, вы должны использовать HttpClient для связи с серверной частью. Если вы используете адаптер GraphQL, вам следует использовать Apollo для связи с серверной частью. Если вы используете адаптер Firebase, вам следует использовать AngularFire для связи с серверной частью. Если вы используете фиктивный адаптер, вы должны использовать фиктивную службу для связи с серверной частью. Здесь вы можете увидеть фиктивный адаптер, который используется для имитации бэкенда:

@Injectable({
  providedIn: 'root',
})
export class CustomerMockAdapterService implements CustomerPortService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/assets/customers.json';

loadOne(id: string): Observable<Customer> {
    return this.http.get<CustomerDto[]>(this.baseUrl).pipe(
      map((customersDto) =>
        customersDto.map(
          (customerDto) =>
            ({
              id: customerDto.id,
              firstName: customerDto.first_name,
              lastName: customerDto.last_name,
              dateOfBirth: new Date(customerDto.date_of_birth * 1000),
              email: customerDto.email,
              phone: customerDto.phone,
            } satisfies Customer)
        )
      ),
      map((customers) => customers.find((customer) => customer.id === id)),
      filter((customer): customer is NonNullable<typeof customer> => !!customer)
    );
  }
  loadAll(): Observable<Customer[]> {
    // ...
  }
  create(customer: Customer): Observable<Customer> {
    return of(customer);
  }
  update(id: string, customer: Customer): Observable<Customer> {
    return of(customer);
  }
}

Мы выбираем, какой адаптер следует использовать, используя фабричную функцию useCustomerAdapter. В моей реализации я использую среду, чтобы определить, какой адаптер следует использовать. Если я нахожусь в режиме разработки, я использую фиктивный адаптер. В противном случае я использую адаптер REST.

export const useCustomerAdapter = () =>
  inject(ENVIRONMENT).mockBackend
    ? inject(CustomerMockAdapterService)
    : inject(CustomerAdapterService);

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

export const loadCustomers = createEffect(
    (actions$ = inject(Actions), customerService = inject(CustomerPortService)) =>
      actions$.pipe(
        ofType(customerApiActions.loadCustomers),
        switchMap(() =>
          customerService.loadAll().pipe(
            switchMap((customers) =>
              of(customerApiActions.loadCustomersSuccess({ customers }))
            ),
            catchError((error) => {
              console.error('Error', error);
              return of(customerApiActions.loadCustomersFailure({ error }));
            })
          )
        )
      ),
    { functional: true }
  );

Обратите внимание, что при использовании функциональных эффектов без классов важно добавить к эффекту параметр functions: true. В противном случае функция ProvideEffect() не сработала бы. Это необходимо для сохранения обратной совместимости со старыми эффектами на основе классов.

Заключение

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

В целом, сам NgRx является очень обсуждаемой темой в сообществе Angular — тем не менее, на самом деле 1 из 6 проектов Angular использует NgRx (показано статистикой npm), и многие корпоративные проекты получают от этого пользу. Я надеюсь, что эта статья поможет вам начать работу с современными NgRx и Nx и использовать их в своем следующем проекте.

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

Прочитайте исходную статью на ng-journal.com (включая репозиторий GitHub)