[X ] Bug
[X ] Enhancement / Suggestion
Not sure which one it is so will leave it to you guys :)
I have uploaded my entire code for this issue here: https://github.com/vman/ServiceScope-WIP/tree/master/src/webparts/spRequestor
Also added comments in the code explaining the problem. Let me know if I am making a mistake somewhere.
I have created a ServiceScope and registered my service mappings using the following code:
const serviceScope: ServiceScope = ServiceScope.startNewRoot();
const listServiceKey: ServiceKey<IListService> = ServiceKey.create<IListService>("listservicekey", ListService);
const loggingServiceKey: ServiceKey<ILoggingService> = ServiceKey.create<ILoggingService>("loggingservicekey", LoggingService);
serviceScope.finish();
Now to instantiate my services: Expected behavior is that I would be able to consume my services from the ServiceScope by using the uniquename string used in the mapping.
const listServiceInstance: IListService = serviceScope.consume("listservicekey");
But the ServiceScope.consume method only accepts a ServiceKey<T> object as the parameter. So we have to consume it using the following code:
const listServiceInstance: IListService = serviceScope.consume(listServiceKey);
This is possible if you have your ServiceKey object ready in the same class but this will not be the case in most scenarios.
In most cases, you would be doing the consuming from where the ServiceKeyLoggingService class from the constructor of my ListService class.
So the only option in such cases is to create a new ServiceKey
constructor(serviceScope: ServiceScope) {
this.httpClient = new HttpClient(serviceScope);
//It would be nice to use the LogginService here by consuming it from the serviceScope object passed in from the contructor.
//It will have to be instantiated by consuming it from the ServiceScope;
serviceScope.whenFinished(() => {
//It is not possible to consume a dependency by just using the unique name of the ServiceKey.
//this.loggingService = serviceScope.consume("loggingservicekey");
//So we have to resort to creating a ServiceKey<ILoggingService> object and using it to consume the ILoggingService dependency.
//This also means that we have to use the LoggingService class here which means there is tight coupling between ListService and LoggingService classes.
this.loggingServiceKey = ServiceKey.create<ILoggingService>("loggingservicekey", LoggingService);
this.loggingService = serviceScope.consume(this.loggingServiceKey);
});
}
I think I have figured it out. I will need to import the ServiceKeys in order to access them. I have updated my code, if someone can have a look and check if that is the expected way to register and consume services from the ServiceScope:
I have created a class "ServiceLocator":
https://github.com/vman/ServiceScope-WIP/blob/master/src/webparts/spRequestor/ServiceLocator.ts
and then use that to register and consume the services from the webpart class:
https://github.com/vman/ServiceScope-WIP/blob/master/src/webparts/spRequestor/SpRequestorWebPart.ts
and from custom classes:
https://github.com/vman/ServiceScope-WIP/blob/master/src/webparts/spRequestor/services/ListService.ts
The ServiceScope is a somewhat advanced plumbing feature that isn't intended to the everyday API for working with web parts. This is sketched out in the API documentation, however I will write up a wiki page that gives some more detail.
Here are some additional comments regarding your specific code/questions:
1. Request for string keys
In most cases, you would be doing the consuming from where the ServiceKey object is not available e.g. the constructor of a class. In the following code, I want to instantiate my LoggingService class from the constructor of my ListService class.
So the only option in such cases is to create a new ServiceKey object which means there is tight coupling between classes.
The ServiceScope uses object keys instead of string keys by design, for a couple reasons:
2. ListService approach
export class ListService implements IListService {
private httpClient: HttpClient;
private loggingService: ILoggingService;
constructor(serviceScope: ServiceScope) {
this.httpClient = new HttpClient(serviceScope); <<<=== INCORRECT
The HttpClient is a service, so you would consume it via serviceScope.consume(httpClientServiceKey); otherwise, you are constructing a new instance of the object which is inefficient.
3. Logging Service
export class LoggingService implements ILoggingService {
public log(message: any){
console.log(message);
}
public warn(message: any)
. . .
}
Were you aware that there is already a Log class in _sp-client-base_?
4. ServiceLocator design
export class ServiceLocator {
public static serviceScope: ServiceScope;
public static ListServiceKey: ServiceKey<IListService>;
public static LoggingServiceKey: ServiceKey<ILoggingService>;
public static Init(){
this.serviceScope = ServiceScope.startNewRoot();
this.ListServiceKey = ServiceKey.create<IListService>("listservicekey", ListService);
this.LoggingServiceKey = ServiceKey.create<ILoggingService>("loggingservicekey", LoggingService);
this.serviceScope.finish();
}
public static getServiceInstance<T>(serviceKey: ServiceKey<T>): T {
return this.serviceScope.consume(serviceKey);
}
}
In general, there should only be one call to ServiceScope.startNewRoot() for the entire application. Web parts receive their scope via BaseClientSideWebPart.context.serviceScope. If your intent is to add your own services to the existing serviceScope, you can simply call ServiceScope.consume() which will automatically construct the default implementation. If you want to set up a secondary implementation of your interface (i.e. not the default implementation), then you would create a child scope by calling this.context.serviceScope.startNewChild.
Theoretically, the ServiceScope API is already a service locator pattern, so it doesn't really make sense to introduce your own "ServiceLocator" class. Rather, you should simply define whatever services/keys you want, and then consume them from your code. If you need to register non-default instances, you would do this by creating a child scope as part of the regular initialization for your web part and/or unit test.
5. When to use ServiceScope
However, as I will explain in the wiki note, the reason for factoring your code in this way is to pass dependencies around in a decoupled code base, which is a somewhat advanced scenario. If you are coding a simple web part and don't have that need, it would be simpler to just pass your dependencies explicitly to your class constructors. All the interesting system API's are already accessible via BaseClientSideWebPart.context.
Thanks @pgonzal! Appreciate the detailed answer. Agree that this is an advanced feature and should only be used if the code base is going to be large enough.
1. Request for string keys
It ensures that there is always a default implementation.
It ensures uniqueness.
This makes sense. When I created the issue, it was't clear to me how to consume the services with the ServiceKey objects. Thanks for this.
2. ListService approach
The HttpClient is a service, so you would consume it via serviceScope.consume(httpClientServiceKey);
Nice one! The only addition I had to do was to consume it inside the whenFinished callback
constructor(serviceScope: ServiceScope) {
serviceScope.whenFinished(() => {
this.httpClient = serviceScope.consume(httpClientServiceKey);
});
}
Otherwise, I got the following error:
Unable to load web part script resources due to: Error: Cannot consume services during autocreation.
3. Logging Service
Were you aware that there is already a Log class in sp-client-base?
I wasn't aware of this so thanks for that! The reason I had created a custom logging services was just for illustration purposes.
4. ServiceLocator design
It makes sense not to re-create your own "ServiceLocator" given that ServiceScope is a service locator already. It would make sense though to have all your service/key registrations defined in a single location.
Thanks again for the in depth response :)
No problem. Here's my wiki writeup giving more detail about the intended usage:
Thanks @pgonzal just a quick question about creating a child scope to register the MockService, is this so that the root ServiceScope is not polluted with default implementations as well as mocks for all services?
Also, I have followed your guidance on the OrgChart webpart sample I have submitted to the sp-dev-fx-webpart repo: https://github.com/vman/sp-dev-fx-webparts/tree/master/samples/OrganisationChart
If there is anything in that you would like me to correct, please let me know.
Thanks!
The primary reason for having child scopes is to create a nested context with different service objects. For example, suppose your LoggingService collects error logs for diagnostic purposes and uploads them to a telemetry server, and there are various configuration settings (e.g. name of component that is doing the logging, where to send the error information, whether to include verbose logs, etc). If your application is organized into big components (e.g. the authored pages, the admin panel, the background tasks) then you could create a child scope for each area. Each scope would have its own LoggingService instance with a distinct configuration, e.g. maybe background tasks get a lower priority than the admin panel, etc. Code that uses the LoggingService would behave differently depending on which scope it's running from.
There is also a second, more pragmatic reason for creating new scopes, which is that that you cannot add more services into a scope after ServiceScope.finish() has been called. This is part of the "two-stage initialization" that I described in the technical note. This restriction requires system architects to put a little bit more thought into how the services get registered, but it makes life much simpler for consumers and eliminates a bunch of counterintuitive bugs.
Thanks @pgonzal just a quick question about creating a child scope to register the MockService, is this so that the root ServiceScope
is not polluted with default implementations as well as mocks for all services?
That was just an example. In a unit test, we usually create a new isolated root scope for each test, and it's fine to add your services directly to that root. In a real application however, there should only be one root scope. By the time a web part is loaded, the root scope is already finished, so you need to create child scope if you want to register an alternative service implementation there.
Also, I have followed your guidance on the OrgChart webpart sample I have submitted to
the sp-dev-fx-webpart repo: https://github.com/vman/sp-dev-fx-webparts/tree/master/samples/OrganisationChart
This code looked a little odd to me:
private getPropertiesForUsers(userLoginNames: string[]): Promise<IPerson[]> {
return new Promise<IPerson[]>((resolve, reject) => {
const arrayOfPersons: IPerson[] = [];
const batchOpts: IODataBatchOptions = {};
const odataBatch: ODataBatch = new ODataBatch(this.serviceScope, batchOpts);
I would just call this.httpClient.beginBatch(), and then you can avoid persisting the serviceScope beyond your constructor.
I'm going to go ahead and close this issue. Feel free to reopen it if you have more questions.
Issues that have been closed & had no follow-up activity for at least 7 days are automatically locked. Please refer to our wiki for more details, including how to remediate this action if you feel this was done prematurely or in error: Issue List: Our approach to locked issues
Most helpful comment
No problem. Here's my wiki writeup giving more detail about the intended usage:
Tech Note: ServiceScope API