Перейти к содержимому

55. Конфигурация окружений

Привет! Яша здесь. Управление конфигурацией — это то, что отличает хорошее приложение от production-ready. Сегодня разберём всё: от environment.ts до секретов и feature flags 🔐


Angular CLI создаёт два файла по умолчанию:

// src/environments/environment.ts — для разработки
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
wsUrl: 'ws://localhost:3000',
analyticsKey: '',
featureFlags: {
newDashboard: true,
betaFeature: false,
},
// Никогда не храните секреты здесь! Это попадёт в бандл
// apiSecret: '...' ← НЕВЕРНО!
};
// src/environments/environment.prod.ts — для продакшена
export const environment = {
production: true,
apiUrl: 'https://api.example.com',
wsUrl: 'wss://api.example.com',
analyticsKey: 'GA-XXXXX',
featureFlags: {
newDashboard: false,
betaFeature: false,
},
};
// angular.json — file replacements при сборке
{
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"staging": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts"
}
]
}
}
}
Окно терминала
ng build --configuration=staging
ng serve --configuration=staging

src/environments/environment.model.ts
export interface Environment {
production: boolean;
apiUrl: string;
wsUrl: string;
analyticsKey: string;
featureFlags: FeatureFlags;
logging: LoggingConfig;
}
export interface FeatureFlags {
newDashboard: boolean;
betaCheckout: boolean;
aiAssistant: boolean;
maintenanceMode: boolean;
}
export interface LoggingConfig {
level: 'debug' | 'info' | 'warn' | 'error';
remote: boolean;
}
// environment.ts — реализует интерфейс
import { Environment } from './environment.model';
export const environment: Environment = {
production: false,
apiUrl: 'http://localhost:3000',
wsUrl: 'ws://localhost:3000',
analyticsKey: '',
featureFlags: {
newDashboard: true,
betaCheckout: true,
aiAssistant: true,
maintenanceMode: false,
},
logging: {
level: 'debug',
remote: false,
},
};

Проблема environment.ts — значения вшиты в бандл на этапе сборки. Для runtime конфигурации (разные значения для одного бандла в разных средах) используем APP_INITIALIZER:

config.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export interface AppConfig {
apiUrl: string;
wsUrl: string;
featureFlags: Record<string, boolean>;
version: string;
}
@Injectable({ providedIn: 'root' })
export class ConfigService {
private config: AppConfig | null = null;
constructor(private http: HttpClient) {}
// Вызывается ПЕРЕД запуском приложения
load(): Promise<void> {
return this.http
.get<AppConfig>('/assets/config.json')
.toPromise()
.then(config => {
this.config = config!;
console.log('Конфигурация загружена:', config?.version);
})
.catch(err => {
console.error('Ошибка загрузки конфигурации:', err);
// Используем fallback конфигурацию
this.config = this.getDefaultConfig();
});
}
get<K extends keyof AppConfig>(key: K): AppConfig[K] {
if (!this.config) {
throw new Error('ConfigService: конфигурация не загружена. Проверь APP_INITIALIZER');
}
return this.config[key];
}
isFeatureEnabled(flag: string): boolean {
return this.config?.featureFlags[flag] ?? false;
}
private getDefaultConfig(): AppConfig {
return {
apiUrl: '/api',
wsUrl: '/ws',
featureFlags: {},
version: 'unknown',
};
}
}
// app.module.ts — регистрация APP_INITIALIZER
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { ConfigService } from './config.service';
export function initializeApp(configService: ConfigService) {
// Возвращаем функцию, возвращающую Promise
return () => configService.load();
}
@NgModule({
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [ConfigService],
multi: true, // multi: true — можно иметь несколько инициализаторов!
}
]
})
export class AppModule {}
// assets/config.json — загружается в runtime
{
"apiUrl": "https://api.production.example.com",
"wsUrl": "wss://api.production.example.com",
"version": "2.1.0",
"featureFlags": {
"newDashboard": true,
"betaCheckout": false,
"aiAssistant": false,
"maintenanceMode": false
}
}

FROM node:20-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN ng build --configuration=production
FROM nginx:alpine
COPY --from=build /app/dist/my-app /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
# docker-entrypoint.sh — генерирует config.json из ENV переменных
#!/bin/sh
cat > /usr/share/nginx/html/assets/config.json << EOF
{
"apiUrl": "${API_URL:-/api}",
"wsUrl": "${WS_URL:-/ws}",
"version": "${APP_VERSION:-unknown}",
"featureFlags": {
"newDashboard": ${FEATURE_NEW_DASHBOARD:-false},
"aiAssistant": ${FEATURE_AI_ASSISTANT:-false}
}
}
EOF
exec nginx -g "daemon off;"
docker-compose.yml
services:
frontend:
image: my-angular-app:latest
environment:
- API_URL=https://api.example.com
- WS_URL=wss://api.example.com
- APP_VERSION=2.1.0
- FEATURE_NEW_DASHBOARD=true
- FEATURE_AI_ASSISTANT=false

feature-flag.service.ts
import { Injectable, inject } from '@angular/core';
import { ConfigService } from './config.service';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
private config = inject(ConfigService);
private overrides = new BehaviorSubject<Record<string, boolean>>({});
isEnabled(flag: string): boolean {
const override = this.overrides.value[flag];
if (override !== undefined) return override;
return this.config.isFeatureEnabled(flag);
}
isEnabled$(flag: string): Observable<boolean> {
return this.overrides.pipe(
map(overrides => {
if (overrides[flag] !== undefined) return overrides[flag];
return this.config.isFeatureEnabled(flag);
})
);
}
// Для A/B тестирования и дебага
override(flag: string, value: boolean): void {
this.overrides.next({ ...this.overrides.value, [flag]: value });
console.log(\`Feature flag "\${flag}" overridden to \${value}\`);
}
resetOverrides(): void {
this.overrides.next({});
}
}
// feature-flag.directive.ts — структурная директива
@Directive({
selector: '[appFeatureFlag]',
standalone: true,
})
export class FeatureFlagDirective implements OnInit {
@Input('appFeatureFlag') flag!: string;
constructor(
private vcr: ViewContainerRef,
private template: TemplateRef<unknown>,
private featureFlags: FeatureFlagService,
) {}
ngOnInit(): void {
if (this.featureFlags.isEnabled(this.flag)) {
this.vcr.createEmbeddedView(this.template);
}
}
}
// Использование
// <div *appFeatureFlag="'newDashboard'">Новый дашборд!</div>

// app.config.ts — типизированный токен
import { InjectionToken } from '@angular/core';
export interface ApiConfig {
baseUrl: string;
timeout: number;
retries: number;
}
export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');
// Провайдинг
@NgModule({
providers: [
{
provide: API_CONFIG,
useFactory: (config: ConfigService) => ({
baseUrl: config.get('apiUrl'),
timeout: 30000,
retries: 3,
}),
deps: [ConfigService],
}
]
})
// Использование
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(@Inject(API_CONFIG) private apiConfig: ApiConfig) {
console.log('API baseUrl:', this.apiConfig.baseUrl);
}
}

// ❌ НЕВЕРНО — секреты в environment.ts (попадут в бандл!)
export const environment = {
jwtSecret: 'my-super-secret-key', // видно в браузере!
databasePassword: 'password123', // видно в браузере!
stripeSecretKey: 'sk_live_...', // видно в браузере!
};
// ✅ ВЕРНО — секреты только на бэкенде
// Фронтенд знает только публичные ключи
export const environment = {
stripePublishableKey: 'pk_live_...', // публичный — можно
googleMapsApiKey: 'AIza...', // публичный — можно
sentryDsn: 'https://[email protected]/...', // публичный — можно
};
// ✅ Публичные ключи в runtime config (не в бандле)
// config.json:
// { "stripePublishableKey": "pk_live_..." }

Интерактивная система feature flags: