Angular/Spring Boot Feature Activation by Spring Profile
Switching on features with Spring Profiles enables the save separation of different requirements in an application. Let's look at the architecture example here.
Join the DZone community and get the full member experience.
Join For FreeUse Case Description
Sometimes there is the use-case for a system that has the same basic requirements but needs some different logic in the core for different customers. The question is: Do the differences warrant a new microservice or do you put the logic in the same? Basically, the question is: A monolith or a zoo of microservices?
The answer depends on the requirements and both extremes are probably wrong.
For requirements that are very similar, the Spring feature to filter classes can be used with a condition based on the selected profile. The Profiles are a comma-separated list that can be used to decide what classes to filter out. For Spring the classes are then not available and only the endpoints and services that are needed can be injected.
The Angular frontend can get a ConfigService that returns the profiles that are used to switch on features based on them. The routing can be based on the profiles too and use Angulars lazy loaded modules.
That enables keeping the common parts and adding some different features for each customer. But there needs to be a warning:
If you do not have good information on how many different requirements will come over time think of more small microservices. Then you do not have to explain why after a certain point in time the application has grown too big and you need to split it now. This is more of a cultural than a technical consideration.
Implementing the Backend
The project that is used as an example is the AngularPortfolioMgr. The Profiles ‘dev’ and ‘prod’ are used in this example but other Profiles could be added and used. The separate classes are in packages that are prefixed ‘dev’ or ‘prod’. The filter is implemented in the ComponentScanCustomFilter class:
public class ComponentScanCustomFilter implements TypeFilter, EnvironmentAware {
private Environment environment;
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
String searchString = List.of(this.environment.getActiveProfiles())
.stream().anyMatch(profileStr -> profileStr.contains("prod")) ?
"ch.xxx.manager.dev." : "ch.xxx.manager.prod.";
ClassMetadata classMetadata = metadataReader.getClassMetadata();
return classMetadata.getClassName().contains(searchString);
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
The setter at the bottom implements EnvironmentAware and gets the environment injected at that early stage of the application startup. The match method implements the Typefilter and sets the class filter according to the Profile in the environment. True is returned for the classes that are excluded.
The class filter is used in the ManagerApplication class:
@SpringBootApplication
@ComponentScan(basePackages = "ch.xxx.manager", excludeFilters =
@Filter(type = FilterType.CUSTOM, classes = ComponentScanCustomFilter.class))
public class ManagerApplication {
public static void main(String[] args) {
SpringApplication.run(ManagerApplication.class, args);
}
}
The ‘@ComponentScan’ annotation sets the basePackages where the filer works. The property ‘excludeFilters’ adds the ComponentScanCustomFilter. That excludes either the packages with ‘dev.*’ or ‘prod.*’. That makes it impossible to use classes in that packages.
To get an http return code 401 for the excluded ‘/rest/dev’ or ‘/rest/prod’ endpoints the Spring Security config in the WebSecurityConfig class needs to be updated:
...
private static final String DEVPATH="/rest/dev/**";
private static final String PRODPATH="/rest/prod/**";
@Value("${spring.profiles.active:}")
private String activeProfile;
@Override
protected void configure(HttpSecurity http) throws Exception {
final String blockedPath = this.activeProfile.toLowerCase()
.contains("prod") ? DEVPATH : PRODPATH;
http.httpBasic().and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().authorizeRequests()
.antMatchers("/rest/config/**").permitAll()
.antMatchers("/rest/auth/**").permitAll()
.antMatchers("/rest/**").authenticated()
.antMatchers(blockedPath).denyAll()
.antMatchers("/**").permitAll()
.anyRequest().authenticated()
.and().csrf().disable()
.headers().frameOptions().sameOrigin()
.and().apply(new JwtTokenFilterConfigurer(jwtTokenProvider));
}
...
The ‘blockedPath’ is set according to the activeProfile. It then sets the excluded path to ‘denyAll()’ with an AntMatcher. The endpoint for the ConfigController(‘/rest/config/profiles’) is set to ‘permitAll()’ to enable early access to the value. The added paths inherit the ‘authenticated()’ value.
The ConfigController provides the endpoint that the frontend uses to get the profiles.
The different requirements can be implemented in classes like the ProdAppInfoService in the ‘prod’ packages and DevAppInfoService in the ‘dev’ packages. The different endpoints can be implemented in classes like the ProdAppInfoController in the ‘prod’ packages and the DevAppInfoController in the ‘dev’ packages.
ArchUnit can be used to check the package dependencies during build time. A setup can be found in the MyArchitectureTests class.
Implementing the Frontend
The Angular frontend has a ConfigService that retrieves and then caches the Profiles:
@Injectable({
providedIn: 'root'
})
export class ConfigService {
private profiles: string = null;
constructor(private http: HttpClient) { }
getProfiles(): Observable<string> {
if(!this.profiles) {
return this.http.get(`/rest/config/profiles`,
{responseType: 'text'})
.pipe(tap(value => this.profiles = value));
} else {
return of(this.profiles);
}
}
}
The ConfigService retrieves the Profiles once and then caches them forever.
The ConfigService is used in the OverviewComponent. The ‘ngOnInit’ method initializes the ‘profiles’ property:
...
private profiles: string = null;
...
ngOnInit() {
...
this.configService.getProfiles()
.subscribe(value => this.profiles = !value ? 'dev' : value);
}
...
The injected configService is used to get the profiles. If the result is empty the ‘dev’ value is used otherwise the returned value.
The ‘showConfig()’ method of the OverviewComponent opens a dialog according to the ‘profiles’ property:
showConfig(): void { if (!this.dialogRef && this.profiles) { if(!!this.dialogSubscription) { this.dialogSubscription.unsubscribe(); } const myOptions = { width: '700px' }; this.dialogRef = this.profiles.toLowerCase().includes('prod') ? this.dialog.open(ProdConfigComponent, myOptions) : this.dialog.open(DevConfigComponent, myOptions); this.dialogSubscription = this.dialogRef.afterClosed().subscribe(() => this.dialogRef = null); } }
The ‘showConfig()’ method first checks that no dialog is open and the ‘profiles’ property is set. Then is checked if the dialogSubscription is checked and unsubscribed if it is set. Then it opens the dialog with the ProdConfigComponent or the DevConfigComponent according to the ‘profiles’ property.
The ProdConfigComponent injects the ProdAppInfoService to retrieve the classname from the ‘/rest/prod/app-info/class-name’ endpoint and displays it.
The DevConfigComponent injects the DevAppInfoService to retrieve the classname from the ‘/rest/dev/app-info/class-name’ endpoint and displays it.
The differences in the frontend can be implemented like in this example. It is possible to route according to the Profiles of the ConfigService and to use lazy loaded modules in Angular. In the AppRoutingModule lazy loaded modules are used. Then the user does only loads the needed Angular Modules for the configuration.
Conclusion
Switching on features with Spring Profiles enables the save separation of different requirements in an application. Only the required features are available and the frontend features can be switched on according to the requirements too. Lazy loaded Angular Modules would enable the users to load only the needed parts for their configuration. This design is useful if the differences in the requirements are small(and will stay small) and there should have a clean separation.
Opinions expressed by DZone contributors are their own.
Comments