Given that title, I don't think I need to explain. So let's get on with it.
anchorFetching Configuration from server
Environment variables pollute the compiled source code, which does not allow for multiple server deployments. External configuration, allows multiple custom configuration for the same source code. The downside is, you have to maintain them manually.
Let's begin by creating the config json file with some keys:
The full project is on StackBlitz
{
"API": {
"apiRoot": "http://localhost:8888/.netlify/functions"
},
"MyKey": "MyValue"
}
The end result in some component, is to be able to get the configuration as a property of a service, or as a static member.
// Component
constructor(private configService: ConfigService) {
}
ngOnInit(): void {
const myValue = this.configService.Config.MyKey;
// or
const myStaticValue = ConfigService.Config.MyKey;
}
anchorAPP_INITIALZER token
In AppModule: (refer to mysterious three tokens post).
@NgModule({
imports: [BrowserModule, HttpClientModule, CommonModule],
declarations: [AppComponent, HelloComponent],
bootstrap: [AppComponent],
providers: [
{
// TODO: create ConfigService and configFactory
provide: APP_INITIALIZER,
useFactory: configFactory,
multi: true,
deps: [ConfigService]
},
],
})
export class AppModule {}
In a service file for ConfigService:
export const configFactory = (config: ConfigService): (() => Observable<boolean>) => {
return () => config.loadAppConfig();
};
@Injectable({
providedIn: 'root',
})
export class ConfigService {
constructor(private http: HttpClient) {
}
// retursn observable, right now just http.get
loadAppConfig(): Observable<boolean> {
return this.http.get(environment.configUrl).pipe(
map((response) => {
// do something to reflect into local model
this.CreateConfig(response);
return true;
}),
catchError((error) => {
// if in error, set default fall back from environment
this.CreateConfig(defaultConfig);
return of(false);
})
);
}
}
The evironment.configUrl
in development would be the local file, ore remote server. Later will be elaborating more on strategy of how to handle the config file and location.
The Config
model:
export interface IConfig {
API: {
apiRoot: string;
};
MyKey: string;
}
The private method to cast configuration, should also return default configuration in case of failure. The extra configuration though does not have to match IConfig
.
The default fallback config:
import { environment } from '../enviornments/dev.env';
export const Config = {
API: {
apiRoot: environment.apiRoot,
},
MyKey: 'default value',
ExtraKeys: 'wont harm',
};
Back to the service, the CreateConfig
should only try to cast, then set to a public property. This, later, is going to fail. But let's go on.
export class ConfigService {
constructor(private http: HttpClient) {}
private _createConfig(config: any): IConfig {
// cast all keys as are
const _config = { ...(<IConfig>config) };
return _config;
}
// public property
public Config: IConfig;
loadAppConfig(): Observable<boolean> {
return this.http.get(environment.configUrl).pipe(
map((response) => {
// set to public property
this.Config = this._createConfig(response);
return true;
}),
catchError((error) => {
// if in error, return set fall back from Config
this.Config = Config;
return of(false);
})
);
}
}
anchorThe curious case of Router Initialization
The Router Module uses APP_INITIALIZER as referenced in master branch of Angular 13, and initialization functions are run in parallel according to source code. Without digging deeper into navigation options, it is already an open wound that needs to be patched. The sequence of events cannot be guaranteed in a module that uses both configuration and Route modules. One is going to happen before the other.
Route guards and resolves are one example of routing happening sooner than initialization response. The extreme case I reached after multiple trials:
- The external configuration is remote, thus a bit slower than local
- Routing option
InitialNavigation
is set toenabledBlocking
, according to Angular docs, this is required for SSR.
A word of caution, leaving theInitialNavigation
to its default "enabledNonBlocking
" will produce unexpected results in the resolve service. Filtering out unready configuration to avoid "fallback" values, the benefit of "non blocking" is nullified. Read the code comments as you go along.
So let's create an app routing module and add a router resolve with these extreme conditions.
// the routing module
const routes: Routes = [
{
path: 'project',
component: ProjectComponent,
resolve: {
// add a project resolve
ready: ProjectResolve,
},
},
// ...
];
@NgModule({
imports: [
RouterModule.forRoot(routes, {
// enabledBlocking for SSR, but also enabledNonBlocking is not as good as it sounds in this setup
initialNavigation: 'enabledBlocking',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}
Import the AppRoutingModule
into root AppModule
, add a project component, and let's create the project resolve, that returns a boolean.
@Injectable({ providedIn: 'root' })
export class ProjectResolve implements Resolve<boolean> {
// inject the service
constructor(private configService: ConfigService) {}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
// log the value of the configuration here
// if this is too soon, the result is undefined
console.log('on resolve', this.configService.Config);
return of(true);
}
}
Running this on stackblitz and loading the app on /project
, consoles "undefined." Which means, the initial Route Resolve was faster than getting http config result. The solution to that, if we see it backwards, should be like this:
wait till this.configService.Config is ready
That translates to RxJS observable. So let me head to ConfigService
and create an observable of an internal subject (much like RxJS state management).
// config service
export class ConfigService {
constructor(private http: HttpClient) {}
// keep track of config, initialize with fall back Config
private config = new BehaviorSubject<IConfig>(Config as IConfig);
config$: Observable<IConfig> = this.config.asObservable();
private _createConfig(config: any): IConfig {
// cast all keys as are
const _config = { ...(<IConfig>config) };
return _config;
}
loadAppConfig(): Observable<boolean> {
return this.http.get(environment.configUrl).pipe(
map((response) => {
const config = this._createConfig(response);
// here next
this.config.next(config);
return true;
}),
catchError((error) => {
// if in error, return set fall back from Config
this.config.next(Config);
console.log(error);
return of(false);
})
);
}
}
In the resolve service, watching updates is not good enough, we need to signal end of stream, to return and move on. RxJS take(1)
is usually recommended, but before we take 1, we need to filter out configuration that is not ready yet, otherwise, that "1" would be the fallback one. This, is why enabledNonBlocking
is useless in this setup (I hope I'm clear, if not, let me know in the comments and I will try to clear that out).
// in resolve, need to take 1 and return
// This is he first attempt
return this.configService.config$.pipe(
take(1),
map(n => {
if (n.MyKey === 'default') {
// the first one will actually be the fallback
return false;
}
return true;
}));
// attempt two: filter before you take
return this.configService.config$.pipe(
filter(n => n['somevalue to distinguish remote config'])
take(1),
map(n => {
if (n.MyKey === 'default') {
return false;
}
// it will be true for sure
return true;
}));
// last attempt, two in one:
return this.configService.config$.pipe(
first(n => n['somevalue to distinguish remote config']
map(n => {
// always same value
return true;
}));
isServed is my new configuration property to "distinguish remote configuration" from fallback one. It's just a Boolean set to true in remote config.
// config json
{
"isServed": true,
"API": {
"apiRoot": "http://localhost:8888/server/app"
},
"MyKey": "MyValue"
}
Add it to the config model, and to the default Config.
// config model:
export interface IConfig {
isServed: boolean;
API: {
apiRoot: string;
};
MyKey: string;
}
// the default Config with isServed: false
export const Config = {
isServed: false,
API: {
apiRoot: environment.apiRoot,
},
MyKey: 'default value',
ExtraKeys: 'wont harm',
};
The project resolve is ready
@Injectable({ providedIn: 'root' })
export class ProjectResolve implements Resolve<boolean> {
constructor(private configService: ConfigService) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
// watch it until it's served
return this.configService.config$.pipe(
first((n) => n.isServed),
map((n) => true)
);
}
}
The observable in the current setup shall produce two values, the first is isServed
set to false. To read the configuration in a component:
@Component({
template: `Project page with resolve
<p>
{{ config$ | async | json}}
</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProjectComponent implements OnInit {
config$: Observable<IConfig>;
constructor(private configService: ConfigService) {
}
ngOnInit(): void {
this.config$ = this.configService.config$;
}
}
A final touch to garnish, for off the track usage, we add static getter, that returns the value of the configuration:
// config service
// make a static member
private static _config: IConfig;
// and a static getter with fallback
static get Config(): IConfig {
return this._config || Config;
}
private _createConfig(config: any): IConfig {
const _config = { ...(<IConfig>config) };
// set static member
ConfigService._config = _config;
return _config;
}
// ...
// This can be used directly, for example in template
{{ ConfigService.Config.isServed }}
anchorPitfalls
1. If the remote configuration does not have all keys expected, they will be overwritten to "null". To overcome, extend the configuration, via shallow cloning.
private _createConfig(config: any): IConfig {
// shallow extension of fallback
const _config = {...Config, ...(<IConfig>config) };
ConfigService._config = _config;
return _config;
}
2. The default Config may be mistaken for ConfigService.Config, if ever used, the default fallback value is in place. To fix that, a separation between the general Config, and remote Config fallback may be needed, or a little bit of attention. You can also make it a habit to use ConfigService, or make the default Config a private element. Treat it per project needs.
3. If the config file needed in Route Resolve or Guard fails to be served, we're blocked. Placing the config file on the same server, or a combination of RxJS operators, are possible solutions. A second property that identifies failure in Config is also a solution.
4. The url of the config file, cannot be part of the configuration keys!
5. Remember to filter out config url in your HTTP interceptor, if you prefix urls with a value fed by configuration.
anchorWhere to place the config file
The benefit aspired for is to have a production-specific configurations for every deployed version, ready to be adjusted for whatever prompt reason. As much as you would like to believe that touching production is taboo, there shall be times when the kitchen is on fire.
The question is, where to place configuration during development.
1. Remote server. Can be an inhouse local server, or a staging server.
2. Mock server, a nodejs local server that you run before starting Angular.
3. On a root folder, e.g. "configs", served via angular.json assets
UPDATE: this will also copy the file into production, and used with the same url, but if that is not your intention, remove entry from production assets in angular.json
.
// add this to assets in angular.json
"assets": [
{
"glob": "*.json",
"input": "configs",
"output": "/localdata"
}
]
// now, every ./configs/*.json will be accessed in dev env as /localdata/*.json
Wherever you decide to place your configuration, remember to update respective environments.
anchorInline, SSR, and other issues
There is another way to load configuration without HTTP, and that is to inject the JS file in the header of the index file. To accomplish that ...come back next week. đŸ˜´
Thank you for tuning in. Let me know in the comments if I pressed any wrong buttons.
This configuration setup is part of the Cricket Seed.