MOBILE-3320 DX: Magic Singletons 🧙

main
Noel De Martin 2021-03-02 10:51:23 +01:00
parent 25a592e0b1
commit 2ab9d37f48
5 changed files with 212 additions and 33 deletions

View File

@ -56,23 +56,32 @@ import { Zip as ZipService } from '@ionic-native/zip/ngx';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
const OBJECT_PROTOTYPE = Object.getPrototypeOf(Object);
/** /**
* Injector instance used to resolve singletons. * Injector instance used to resolve singletons.
*/ */
let singletonsInjector: Injector | null = null; let singletonsInjector: Injector | null = null;
/** /**
* Stub class used to type anonymous classes created in the makeSingleton method. * Helper to get service class methods.
*/ */
class CoreSingleton {} type GetMethods<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never;
}[keyof T];
/** /**
* Singleton class created using the factory. * Singleton proxy created using the factory method.
*
* @see makeSingleton
*/ */
export type CoreSingletonClass<Service> = typeof CoreSingleton & { export type CoreSingletonProxy<Service, Getters extends keyof Service = never> =
instance: Service; Pick<Service, GetMethods<Service>> &
setInstance(instance: Service): void; Pick<Service, Getters> &
}; {
instance: Service;
setInstance(instance: Service): void;
};
/** /**
* Set the injector that will be used to resolve instances in the singletons of this module. * Set the injector that will be used to resolve instances in the singletons of this module.
@ -84,34 +93,101 @@ export function setSingletonsInjector(injector: Injector): void {
} }
/** /**
* Make a singleton for the given injection token. * Make a singleton proxy for the given injection token.
* *
* @param injectionToken Injection token used to resolve the singleton instance. This is usually the service class if the * This method will return an object that will proxy method calls to an underlying service instance. Getters will also be proxied,
* provider was defined using a class or the string used in the `provide` key if it was defined using an object. * but these need to be configured manually using the `getters` argument. Most of the time, this proxy can be used directly like
* you would use a service instance. If you need to get the real service instance, it can be accessed through the `instance`
* property and it can be set with the `setInstance` method.
*
* @param injectionToken Injection token used to resolve the service. This is usually the service class if the provider was
* defined using a class or the string used in the `provide` key if it was defined using an object.
* @param getters Getter names to proxy.
* @return Singleton proxy.
*/ */
export function makeSingleton<Service>(injectionToken: Type<Service> | Type<unknown> | string): CoreSingletonClass<Service> { export function makeSingleton<Service>(injectionToken: Type<Service> | Type<unknown> | string): CoreSingletonProxy<Service, never>;
return class { export function makeSingleton<Service, Getters extends keyof Service>(
injectionToken: Type<Service> | Type<unknown> | string,
getters: Getters[],
): CoreSingletonProxy<Service, Getters>;
export function makeSingleton<Service, Getters extends keyof Service>(
injectionToken: Type<Service> | Type<unknown> | string,
getters: Getters[] = [],
): CoreSingletonProxy<Service, Getters> {
// Define instance manipulation affordances.
const proxy = {
setInstance(instance: Service) {
Object.defineProperty(proxy, 'instance', {
value: instance,
configurable: true,
});
},
} as CoreSingletonProxy<Service, Getters>;
private static serviceInstance: Service; Object.defineProperty(proxy, 'instance', {
get: () => {
static get instance(): Service { if (!singletonsInjector) {
// Initialize instances lazily. throw new Error('Can\'t resolve a singleton instance without an injector');
if (!this.serviceInstance) {
if (!singletonsInjector) {
throw new Error('Can\'t resolve a singleton instance without an injector');
}
this.serviceInstance = singletonsInjector.get(injectionToken);
} }
return this.serviceInstance; const instance = singletonsInjector.get(injectionToken);
proxy.setInstance(instance);
return instance;
},
configurable: true,
});
// Define method and getter proxies.
if (isServiceClass(injectionToken)) {
// Get property descriptors, going all the way up the prototype chain (for services extending other classes).
let parentPrototype = injectionToken;
let descriptors: Record<string, PropertyDescriptor> = {};
do {
descriptors = {
...Object.getOwnPropertyDescriptors(parentPrototype.prototype),
...descriptors,
};
parentPrototype = Object.getPrototypeOf(parentPrototype);
} while (parentPrototype !== OBJECT_PROTOTYPE);
// Don't proxy constructor calls.
delete descriptors['constructor'];
// Define method proxies.
for (const [property, descriptor] of Object.entries(descriptors)) {
// Skip getters and setters.
if (descriptor.get || descriptor.set) {
continue;
}
// Define method proxy.
Object.defineProperty(proxy, property, {
value: (...args) => proxy.instance[property].call(proxy.instance, ...args),
configurable: true,
});
} }
static setInstance(instance: Service): void { // Define getter proxies.
this.serviceInstance = instance; for (const getter of getters) {
Object.defineProperty(proxy, getter, { get: () => proxy.instance[getter] });
} }
}
}; return proxy;
}
/**
* Type guard to check if an injection token is a service class.
*
* @param injectionToken Injection token.
* @return Whether the token is a class.
*/
function isServiceClass(injectionToken: Type<unknown> | string): injectionToken is Type<unknown> {
return typeof injectionToken !== 'string';
} }
// Convert ionic-native services to singleton. // Convert ionic-native services to singleton.

View File

@ -38,7 +38,7 @@ export class CoreObject {
* @param keys Keys to remove from the new object. * @param keys Keys to remove from the new object.
* @return New object without the specified keys. * @return New object without the specified keys.
*/ */
static without<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, keyof { [k in K]: unknown }> { static without<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const newObject: T = { ...obj }; const newObject: T = { ...obj };
for (const key of keys) { for (const key of keys) {

View File

@ -0,0 +1,53 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { mock } from '@/testing/utils';
import { CoreSingletonProxy, makeSingleton, setSingletonsInjector } from '@singletons';
import { MilkyWayService } from './stubs';
describe('Singletons', () => {
let MilkyWay: CoreSingletonProxy<MilkyWayService, 'MEANING_OF_LIFE'>;
beforeEach(() => {
setSingletonsInjector(mock({ get: serviceClass => new serviceClass() }));
MilkyWay = makeSingleton(MilkyWayService, ['MEANING_OF_LIFE']);
});
it('works using the service instance', () => {
expect(MilkyWay.instance.getTheMeaningOfLife()).toBe(42);
});
it('works using magic methods', () => {
expect(MilkyWay.getTheMeaningOfLife()).toBe(42);
});
it('works using magic getters', () => {
expect(MilkyWay.MEANING_OF_LIFE).toBe(42);
});
it('magic getters use the same instance', () => {
expect(MilkyWay.addYears(1)).toBe(1);
expect(MilkyWay.instance.addYears(1)).toBe(2);
expect(MilkyWay.addYears(1)).toBe(3);
expect(MilkyWay.instance.addYears(2)).toBe(5);
});
it('magic methods respect inheritance', () => {
expect(MilkyWay.isGalaxy()).toBe(true);
});
});

View File

@ -0,0 +1,39 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export class Galaxy {
isGalaxy(): boolean {
return true;
}
}
export class MilkyWayService extends Galaxy {
readonly MEANING_OF_LIFE = 42;
private years = 0;
getTheMeaningOfLife(): number {
return this.MEANING_OF_LIFE;
}
addYears(years: number): number {
this.years += years;
return this.years;
}
}

View File

@ -15,7 +15,7 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, Type, ViewChild } from '@angular/core'; import { Component, CUSTOM_ELEMENTS_SCHEMA, Type, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CoreSingletonClass } from '@singletons'; import { CoreSingletonProxy } from '@singletons';
abstract class WrapperComponent<U> { abstract class WrapperComponent<U> {
@ -47,15 +47,15 @@ export function mock<T>(
return instance as T; return instance as T;
} }
export function mockSingleton<T>(singletonClass: CoreSingletonClass<T>, instance: T): T; export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
export function mockSingleton<T>(singletonClass: CoreSingletonClass<unknown>, instance?: Record<string, unknown>): T; export function mockSingleton<T>(singletonClass: CoreSingletonProxy<unknown>, instance?: Record<string, unknown>): T;
export function mockSingleton<T>( export function mockSingleton<T>(
singletonClass: CoreSingletonClass<unknown>, singletonClass: CoreSingletonProxy<unknown>,
methods: string[], methods: string[],
instance?: Record<string, unknown>, instance?: Record<string, unknown>,
): T; ): T;
export function mockSingleton<T>( export function mockSingleton<T>(
singletonClass: CoreSingletonClass<T>, singleton: CoreSingletonProxy<T>,
methodsOrInstance: string[] | Record<string, unknown> = [], methodsOrInstance: string[] | Record<string, unknown> = [],
instance: Record<string, unknown> = {}, instance: Record<string, unknown> = {},
): T { ): T {
@ -64,7 +64,18 @@ export function mockSingleton<T>(
const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : []; const methods = Array.isArray(methodsOrInstance) ? methodsOrInstance : [];
const mockInstance = mock<T>(methods, instance); const mockInstance = mock<T>(methods, instance);
singletonClass.setInstance(mockInstance); singleton.setInstance(mockInstance);
for (const [property, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(singleton))) {
if (typeof descriptor.value !== 'function' || property === 'setInstance') {
continue;
}
Object.defineProperty(singleton, property, {
value: jest.fn(descriptor.value),
configurable: true,
});
}
return mockInstance; return mockInstance;
} }