We often need to add logs to our applications. Some of them are for debugging on the client and some needs to be sent to a server to track bugs remotely.
For this purpose we will build a Logger class which will log our events to the console, then we will upgrade it to be able to log messages to the server and all of this by using strategy pattern!
The most straightforward way is to create a class called Logger with logging methods. Let’s create one with just one method called log
for brevity.
@Injectable({
providedIn: 'root'
})
export class Logger {
public log(message: string): void {
console.log(message);
}
}
Now within the app whenever we need to log a message we would use not console.log
, but instead this.logger.log()
. For now it just uses console.log under the hood, nevertheless this is an abstraction in the first place which gives us some space for scaling.
Implementing strategy pattern
First let’s figure out what the strategy pattern is. To put simply the strategy pattern tells us to have one interface which can be implemented by N classes, so that we could replace the implementations on the fly and still get the application working. This way we can achieve different behaviors that have similar handles in common and do same job differently.
Let’s add some logging to a remote server, so we could keep track of user interactions with our app. For this we create another class called ServerLogger
with the same methods implemented as in Logger
.
@Injectable({
providedIn: 'root'
})
export class ServerLogger {
constructor(private http: HttpClient) {} public log(message: string): void {
this.http.post('/log', {
level: 'log',
message,
}).subscribe();
}
}
Good. Now we can use the ServerLogger
in our classes to post messages to the server. However, it’s not convenient to use multiple loggers separately and furthermore we would have more cases when we need to log messages both to the server and the console.
To handle that we will need to follow next steps:
- Create a
BaseLogger
which is an abstract class which will be implemented by all loggers and will also work as an injection token - Move logic from
Logger
toConsoleLogger
for console logging implementation - Make Logger work with implementations of
BaseLogger
export abstract class BaseLogger {
public abstract log(message: string): void;
}
Here we only declare methods and declare the class as abstract so we don’t create an instance of it by accident.
Now, let’s update our ServerLogger
by imlementing the class:
export class ServerLogger implements BaseLogger
After this we create a ConsoleLogger
and move the LoggerLogic
there:
@Injectable({
providedIn: 'root'
})
export class ConsoleLogger {
public log(message: string): void {
console.log(message);
}
}
As for the Logger
we will do the following:
@Injectable({
providedIn: 'root'
})
export class Logger {
private loggerProviders: BaseLogger[];
public log(message: string): void {
this.loggerProviders.forEach(provider => provider.log(message));
}
}
Now all we have left is to fill loggerProviders
array with actual providers we created.
The first thing that comes into mind is to simply instantiate them with the new
keyword, however this isn’t the best approach as we would need to come to the class and modify it each time we need a new logger, perhaps add a new dependency to instantiate the logger provider. Also, this would break the I in SOLID as we would create instances manually in the consuming class, meanwhile it has to be outside of it.
Remember, when used Angular’s injection tokens like APP_INITIALIZER
we also pass multi: true
? Ever wondered what this is for and how it works?
This is exactly what we will use to make our Logger
even more scalable and robust.
For this we need to replace Injectable
object configuration in ServerLogger
and ConsoleLogger
with Injectable()
as we don’t want those classes to be available as separate providers in DI system.
Then, we update the Logger
to use the BaseLogger
as a dependency
@Injectable({
providedIn: 'root'
})
export class Logger {
constructor(
@Inject(BaseLogger)
private loggerProviders: BaseLogger[]
) {}
public log(message: string): void {
this.loggerProviders.forEach(provider => provider.log(message));
}
}
We declare the type as BaseLogger[]
, however this is not a valid declaration for Angular, therefore we need to add @Inject(BaseLogger)
to tell the DI that we want to inject this class. DI does see that we provided multi: true
and therefore will give us an array instead of an object.
Let’s update the app.module.ts
file
const loggerProviders: Provider[] = [
{ provide: BaseLogger, useClass: ConsoleLogger, multi: true },
{ provide: BaseLogger, useClass: ServerLogger, multi: true },
];
@NgModule({
...
imports: [
...,
HttpClient,
],
providers: [
...
loggerProviders
]
})
export class AppModule {
}
From now on, whenever we need a new provider we just need create another class which implements the BaseLogger
and update providers array. The Logger is safe as we don’t touch it to add new providers.
If we wanted even more flexibility we could make BaseLogger
instances to accept a configuration using deps
array, there we could pass logLevel
for each provider separately. This way we could log everything to the console and only errors to the server.
Resulting code can be found here: https://stackblitz.com/edit/angular-ivy-pqspk7?embed=1&file=src/app/app.module.ts
Top comments (1)
Great article, keep the good work! Liked and followed! 🚀