-
-
Notifications
You must be signed in to change notification settings - Fork 408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Explicit Service Injection #502
base: master
Are you sure you want to change the base?
Explicit Service Injection #502
Conversation
@NullVoxPopuli Where does the class name come from? Is it just the path camel cased with the type tacked on? Would it be something you import? Is it the class name you define in the individual file that would get pulled out? |
hi @NullVoxPopuli , this.store.findAll('post', '...') we have: @service(StoreService) store;
// i prefer the pluralized form, `posts` not `post` for model name
@model(PostsModel) posts;
// ...
doSomething() {
this.store.findAll(this.posts, '...');
// or
this.posts.somePostsModelMethod('...');
// maybe
this.posts.findAll('...');
// a class cased form for model name `Posts` or `PostsModel`
this.PostsModel.findAll('...');
} |
A few tangents regarding TypeScript. Currently I (we?) do this for TypeScript: import Service, { inject as service } from '@ember/service';
import BarService from './bar';
class FooService extends Service {
@service bar!: BarService;
} Alternatively you can get around the extra import of import Service, { inject as service } from '@ember/service';
class FooService extends Service.extends({
bar: service('bar')
}) {
} This works because of the clever registry pattern. The reason you have to use the Ember Object Model is that decorators still can't yet change the type signature: microsoft/TypeScript#4881 Once they could, the following should type-check and infer the service class automatically: import Service, { inject as service } from '@ember/service';
class FooService extends Service {
@service bar;
} For the time being, if you don't want to or can't use the Ember Object Model, but dislike explicitly importing the injtectee class, you could use the trick that we discussed in machty/ember-concurrency-decorators#50: Use a Babel transform to convert class property assignments to decorated properties. import Service, { inject as service } from '@ember/service';
class FooService extends Service {
bar = service('bar');
}
// gets transformed into
class FooService extends Service {
@service('bar') bar;
} I have a transform for it ready, but I need to battle-test and optimize it further: |
if I understand this RFC correctly, it is about explicitely importing the respective class and exchange the string lookup for the class definition. Second is: As much as we love typescript, this RFC must work for JS and as such the registry pattern from e-c-ts isn't available in pure js land. So with this RFC TS code will look like this: import Service form '@ember/serivce';
import NotificationsService from 'my-project/services/notifications';
class Foo extends Service {
@service(NotificationsService) notifications: NotificationsService;
} at least until decorators can mutate the type definition for properties (then we can get rid of the doubled written class name). |
@gossi This is exactly why I raised these points. While Ember is committed to not pushing TypeScript onto anybody and offering first-class support for JS, we are equally committed to offering first-class support for TypeScript as well. I find it important that, as long as decorators cannot change types in TS or using a Babel transform is not a community-accepted and agreed upon opinion, we do not deprecate string lookups, as this could worsen ergonomics for some TypeScript users. Edit: I actually believe we should never deprecate string lookups, as this would break the automatic inferral, when decorators can change signatures, i.e.: import Service, { inject as service } from '@ember/service';
class FooService extends Service {
@service bar;
} |
One further observation: The Ember Resolver / Container system is a bit of magic in the background. You don't always know where the backing injectee class is actually located or it might not be accessible to the code you are authoring. There's also nothing preventing you from registering the same class with multiple names or even generating the names at runtime. Admittedly these are uncommon edge cases. |
Also, by requiring explicit class name use, we're coupling the class to a specific service, but this shouldn't be a concern of the class:
I mean, if we're going to import the So whilst I like the look of this as purely a nicer way to write TS classes in Ember right now - it's not really DI any more since the explicit class name is required (no more IoC). So it seems that if the goal is to improve typing in TS, proper TS support for decorators will give us that. For newbies that get confused by strings, maybe we need to better teach the newbies about DI and why a string name is about as explicit & accurate a DI lookup name should be to maintain proper separation. |
@lougreenwood I totally agree with the point you're making regarding isolation of concerns. However, I think @NullVoxPopuli was not aiming at better TypeScript support, but instead better "cmd+clickability" support for generic JS IDEs / users that don't use TypeScript. Please correct me, if I am wrong. |
yeah - you're right, I was getting ahead of myself - sorry :D But IMO, we also shouldn't start hacking apart established patterns for nicer IDE support - our solution should "work with the patterns" as well as "using the platform". So if I understand JS & IDEs correctly... Since JS is not typed, and by it's nature DI de-couples - then there's no "platform" mechanism which is true to DI other than typing (and decorators changing function signature) which allows nice IDE integration? |
@webark the class would be an import of the service itself :) import Component from '@glimmer/components';
import { inject as service } from '@ember/services';
import MyService from 'appmame/services/my-service';
export default class extends Component {
@service(MyService) myService;
} :) |
@buschtoens the registry pattern could be used with classes as keys, couldn't it? |
If only we had interfaces we could use instead :) |
Renamed RFC due to technicality in DI definition |
@NullVoxPopuli Yes, of course it could. But that's not the point I was trying to make. 🙂 Switching to a class-based registry / decorator and deprecating the string-based one, means that the future TypeScript ergonomics will be much worse, because you can't any more use the property name to infer the injection, as show in #502 (comment). |
Gotchya, so string lookup stays :) |
Thanks for clearing that up! So there’s a pattern where you extend a dependencies service, to either overwrite or extend the dependent service. When this is done, all of your existing references are updated. With direct imports, would you always import a dependents service from the addons merged “app” space, even if it doesn’t exist? Or would you need to go and update all of your imports where you had imported them from the depended addon? |
it should resolve to the same thing in both scenarios, afaik |
An addon that has a service re-export in its I don't want to explode the scope here, but this segways into another interesting problem: What is the "official" way (pre and post this RFC) to override / clobber an addon's service (from another addon possibly)? One way is using One major hazard of this approach is emberjs/ember-cli-babel#240: If you use a file extension other than |
maybe this RFC should be held off until embroider ships ;) but, I don't think we should continue to have clobbering of services. It's hard to debug. |
this just goes against the main motivation of “enable "go to definition" support from service definitions so developers can more easily discover the where and how their service is defined.” For using services in addons, it seems just as magical also, and more confusing around “is this a singleton” especially if you could import the same service from the invisible “app” space, and where it lives in the addon’s “addon” space in the same app. |
This RFC should also probably provide some text about how to register/inject services in component rendering tests. |
That’s correct; they only make their DI system work this way by emitting runtime metadata from TS (including decorators in particular). That’s obviously a non-starter for Ember. |
Something we have discussed is in the future, if decorators land and eventually, parameter decorators are proposed and land (could take a while), we could do something like: import MyService from '../services/my-service';
import { inject as service } from '@ember/service';
class MyComponent extends Component {
constructor(
@service(MyService) myService
) {}
} Like I said, this probably won't happen for years to come, so I definitely wouldn't want to include this type of thing in this RFC, but I'm mentioning it because it's definitely part of the (far future) design space 😄 |
Over the weekend I hacked out an addon that implements this RFC using Ember's public APIs, After more thought and playing around with the API, I think the biggest concern I have so far is that we don't have a simple, declarative way to override a service at the application level. Today, if you need to override a service I've been toying around with the idea of a @override({ key: Store })
class MyCustomStore extends Store {} It could even have conditionals: @override({ key: CookieService, if: !isFastboot() })
class BrowserCookieService extends CookieService {} The duplication of the key in these cases wouldn't be ideal, since you're both extending and registering it, but the declarative nature of a class decorator would be nice because it actually is statically analyzable (unlike initializers), and would also get us away from relying implicitly on the ordering of the build system. I do think it gets tricky if you try to allow addons to use a decorator like this as well - if both the app and an addon (or two addons) attempt to override the same service, we have an ordering problem once again. This could also come later on, or in parallel to this RFC - it would work just as well with string based service keys, and would have the same benefits of getting us off using the build system for resolution if we can nail down the semantics. |
FWIW, on this point:
I very much understand why people reach for this, but every time I've seen it, it's an anti-pattern—the amped up version of the failure which "prefer composition over inheritance" is meant to address. It may be a thing we want to support as an escape hatch, because sometimes addon's services are badly designed, but even there, simply injecting it into one's own service and providing a wrapper API around it seems to me to be a much better pattern than the override-and-it-happens-to-have-the-sam-ename pattens we currently use. The trick, of course, is for the scenario where you want it to be resolved back in the original addon's code or similar, but that also seems like a very, very smelly smell to me. I don't have more fully-formed thoughts than that, but I figured I'd raise it as a point against the importance of that kind of "overridability". Semi-related: perhaps the design of a new system here should not assume it, so as to be backwards compatible, but it seems to me that accounting for a future where addons' exports are no longer merged into the consuming app's namespace may illuminate a better design here. If you don't have namespace merging, you also don't have the ability to override in the same way, and new patterns will need to emerge to account for the kind of escape-hatch work referenced here. Again, we may not want to defer this work until that time, but leaving it as an intentional hole in the design—acknowledged and unanswered, designed to be filled in later when we have more of an idea how that system will play out—may be a reasonable option. |
I have some concerns with this, mostly along the lines of what @lougreenwood and @lupestro said before. While the RFC is mostly concerned about basically what the key for a lookup operation is (a string or a class), this small-ish change can only properly be assessed when we are clear about what the scope of our DI system is, and where it is eventually heading to: 1. Singleton container with test mocking This is probably how we use Ember's DI in most cases, but effectively that is a very limited form of DI. And given the dynamic nature of JS, we could mock services in tests even without any dynamic registering/lookup. 2. True DI The obvious problem we have here is that our platform does not know the concept of an interface (even TS has that only at build-time). So IMHO our string-based lookup actually comes closer to that than any injection based on a concrete implementing class. Even an abstract class seems wrong to me, as to my understanding that is still concerned on an (incomplete, sharable in a class hierarchy) implementation rather than defining an interface. Let's look at an example: We want to have a central
Now we have an Having an abstract class for that was a suggestion, but as said before, I don't see an abstract class as a valid replacement for some other way to declare a dependency on an interface. And I also see some practical problems with such an (abstract-class based) approach. Ideally services wouldn't need to extend from TL;DR While our current DI is commonly used in a way as described under 1., maybe that is because it is IMO quite limited still. At least I would love to see it evolve to be more flexible in a "true DI" sense. And in that regard the proposed changes here seem counter-productive, as they increase coupling while IoC is supposed to decrease it. And as such it would make teaching the benefits of IoC and loose coupling more difficult. |
Kinda throwing this out there, but we could use decorators to fake a runtime interface -- as long as that object that declared the interface was only defined once, I think the "references" / "usages" aspect of today's tooling would still work. However, this potentially adds runtime overhead, and makes the overall D.I. system more complicated both in implementation and usage. :-\ |
Much of this discussion has me wondering if it's not worth taking a step back and reconsidering the shape of this entirely. It's worth remembering that dependency injection and particularly DI containers are a specific way of implementing the much more general pattern of inversion of control. However, a commitment to this specific kind of DI container is not a hard necessity. Functional languages with currying and partial application as native language constructs (F♯, Elm, etc.) tend to solve this simply by passing arguments which must conform to a given interface. And there are similar patterns available even in more traditional "OO" languages where the arguments are passed to the constructor. That's not to say that we should switch to an FP style, per se—that would be a fairly radical shift in the programming model, and while I'd like it personally, it may or may not be the best migration path here! My point is simpler: if we're evaluating what the future of our inversion-of-control pattern should look like at this level, perhaps it's worth taking a more fundamental look at the options on the table for idiomatic JavaScript and thinking about how we might make a great developer experience there. A good migration path is not the same as an API that is effectively just a superset of today's API—that's just one possible path (if often a good one)! |
I had a long conversation with @runspired about this RFC and the future of Ember Data last week, and his concerns mirrored @simonihmig's concerns. We also came to the conclusion that what this RFC is after, ultimately, is to formalize services around interfaces. This is also how most major DI frameworks work in typed languages, so it makes sense that everyone kind of wants to be able to do this. Since we don't have interfaces in JS, classes are the next best thing, and abstract classes are the closest real pattern that we could use to emulate interfaces. Another important point is that Ember Data is internally going to be moving a direction where True DI (@simonihmig's use case 2) is much more common, even required. Data will define interfaces rather than concrete services in the future, and various other addons will implement those interfaces in their own services. The ability to swap out implementations like this for a particular service is very valuable for an ecosystem, since it means that other addons and libraries can rely on the existence of a particular service for a particular key, declaratively. The biggest concern we ended up having with using a class as the key is, even if it were an abstract class, tooling would link users to that class even if a different class were the selected implementation, and that could be very confusing. The Ember language server might be able to solve this with some clever redirects, but we also have to consider environments that won't be able to run the language server, such as Github (since it recently implemented go-to-definition). We did land on a couple of deficiencies of the current system though, which explicit service injections would solve:
Both of these problems could be addressed, even if we stuck with string based keys. Our strawman solution was to:
If we made these changes, then I think the main difference between strings and classes as keys would be DX around and support for go-to-definition. Strings would be supported via the language server, and simple to understand, but would not work in any environment by default (no Github support). Classes would work in most places, but could be confusing in cases where they're used to emulate interfaces. I still like the idea of being able to inject classes directly in the common case, but I can also see how it could add more complexity (or percieved complexity). I think ultimately, we should try to address these two concerns separately in different RFCs. One last thought here: JavaScript doesn't have interfaces, but TypeScript does. It may be possible for @service foo: MyServiceInterface; And transforms it into: @service('my-service') foo; Making the ability to "inject interfaces" a progressive enhancement of TS that doesn't impact JS APIs directly. How we would associate a string name with an interface would be tricky, but maybe this is a way we could get the best of both worlds. |
@simonihmig and @pzuraq collectively have done a very good job summarizing my positions. I fully agree with @pzuraq that there are things we should do to tighten up the existing expectations such that knowing where a service in use was declared is quickly discoverable, and that doing so is separate from changing the nature of the DI system (which this RFC would do largely by accident) or the syntax of using the DI system. One thing I'd note on the value of strings-v-classes as keys that I think got glossed over is that were we to cleanup the expectations around where services can originate from then when you don't have a language server it is still quick to find the source (look in the services directory for the service of that name, if it's a re-export you can go-to-source from there). E.g. if we need a language server for either pattern (which we would) I feel strings come with the additional benefit of conveying the location to go look for the concrete implementation when that language server is not present. |
I mean, really all I want is control+click. With strings, we need to make up our own tooling, which sounds like more work. :-\ |
I had this discussion with @NullVoxPopuli out of band, but wish to summarize it here.
Moving to using classes as keys would not prevent us from using our own tooling nor save us any work. In either case we end up needing a language-server to ensure that control+click works. For strings, we have to map strings to a class. This remapping from class A to class B is likely to cause users a large amount of confusion. (Just imagine a new user being told that to "use service B" you must "import service A"). Why would this be? Let's run through an example of some pitfalls with classes as keys. Imagine the addon export default class CentralHubService {
dispatch() {}
subscribe() {}
} Let's say that import CentralHubService from 'good-communicator/services/central-hub';
export default MyComponent extends Component {
@service(CentralHubService) centralHub;
} Still another addon import CentralHubService from 'good-communicator/services/central-hub';
export default AdvancedCentralHubService extends CentralHubService {
dispatch() {
if (...) {
// ....
} else {
return super.dispatch(...arguments);
}
}
} And it (or another addon) uses this version of the service class: import AdvancedCentralHubService from 'good-communicator-plus/services/central-hub';
export default MyComponent extends Component {
@service(AdvancedCentralHubService) centralHub;
} Now finally, a consuming application consumes import CentralHubService from 'good-communicator-plus/services/central-hub';
import config from './hub-config';
export default ConfiguredCentralHubService extends CentralHubService {
config = config;
} The situation we are now in is that we have 3 distinct classes that all are serving as keys to what is intended to be the same service. If the consuming application does nothing, then we would instantiate three separate services, two of which would be missing the desired configuration. If however the consuming application wanted to ensure that we continued to only have a single service, it would need to remap the keys. Something like: import CentralHubService from 'good-communicator/services/central-hub';
import AdvancedCentralHubService from 'good-communicator-plus/services/central-hub';
import ConfiguredHubService from '../services/central-hub';
export function initialize(application) {
application.unregister(CentralHubService);
application.unregister(AdvancedCentralHubService);
application.register(CentralHubService, ConfiguredHubService);
application.register(AdvancedCentralHubService, ConfiguredHubService);
};
export default {
initialize
}; With this remapping complete, we would now be back to a single "central-hub" service. But there remains a problem: the class we've used as a key in so many places is not the class that we've instantiated. Any "go-to-definition" features we take advantage of out of the box are going to send us to the wrong class for 2 of the 3 potential keys in this example. And remember, there's nothing preventing an application from using all 3 key variants (the two from addons and it's own). In parting, a final concern: This sort of keying by class is also problematic if someone desires multiple instances of a given service. By using the class as the key, we lose the ability to re-use the same class multiple times if desired. |
@runspired I think your examples are exaggerating the issue a bit, as they don't use the API the way it is described by the RFC. If we were to move forward with this RFC, in order for any addon to override a service, it would have to manually reregister that service, so the addon that provides the import CentralHubService from 'good-communicator/services/central-hub';
import AdvancedCentralHubService from 'good-communicator-plus/services/central-hub';
export function initialize(application) {
application.register(CentralHubService, AdvancedCentralHubService);
};
export default {
initialize
}; And the addon itself would inject the // good-communicator-plus/components/my-component.js
import CentralHubService from 'good-communicator/services/central-hub';
export default MyComponent extends Component {
@service(CentralHubService) centralHub;
} I'm not particularly worried about this pattern being difficult or smelly, because IMO it should be. Attempting to override a service from another addon like this should require some manual steps, because it is generally an antipattern - the functionality should be contributed up stream, ideally, or contained in a separate service that the downstream addon has full control over. Now, your second example is a bit more where my concerns come from as well, because it isn't uncommon to have a service that should be configured, or an interface for a service that should be provided by the consuming application, and Ember Data is moving this direction so it'll become even more common. In these cases, it's unavoidable to have a different implementation, and this alternate implementation would need to be registered, and then tooling would have to know about that registration so it could Control+Click to the proper definition. While we could absolutely do this, we no longer get it for "free", like this RFC was hoping for. I think if we were providing interfaces, this would be a more logical leap. Users would go-to the definition, see it was an interface, and then know they had to find the proper implementation. I agree that taking them to a class, even an "abstract class", probably isn't enough of a hint that there could be a subclass being used, and could result in a lot of confusion. |
Disclaimer: I haven't read this yet, But I did have a related yet independent idea. What ii all service classes inherited a static import MyService from 'my-app/services/my-service';
…
export default class MyComponent extends Component {
@MyService.inject myService;
} |
Where does this stand? |
@chancancode has a user-land addon that explores this space. 🤔 and @wycats has some thoughts, too. There is also: emberjs/ember.js#20095
|
This is being moved to the Exploring state. |
While reading https://netbasal.com/lazy-load-services-in-angular-bcf8eae406c8 I think we could have something like this https://gist.github.com/lifeart/fbcc7bd8747562aa85d79b42ca991493 In short, all properties on lazy service may be thanks to @NullVoxPopuli navigating me here. Some version of provided gist: class Bar {
doSomething() {
console.log('do something');
}
name: string;
}
type PromisifyProps<T> = {
[P in keyof T]: T[P] extends (...args: infer A) => infer R ? (...args: A) => Promise<R> : Promise<T[P]>;
};
// a function to accept service load using import
// and it should return same type as service but all it's methods and properties should be promises
// we can use this function to make lazy loading of services
// under the hood we use proxy to make all methods and properties to be promises
function lazyService<T extends object>(service: () => Promise<T>): PromisifyProps<T> & {
toSync(): Promise<T>;
} {
let loadedService: T;
const proxy = new Proxy({}, {
get(_, prop) {
return new Promise(async (resolve, reject) => {
if (!loadedService) {
try {
loadedService = await service();
} catch(e) {
reject(e);
}
}
if (prop === 'toSync') {
return resolve(loadedService);
}
const value = Reflect.get(loadedService, prop);
if (typeof value === 'function') {
return resolve((...args: any[]) => Promise.resolve(value.apply(loadedService, args)));
} else resolve(value);
});
},
});
return proxy as PromisifyProps<T> & {
toSync(): Promise<T>;
}
}
class Foo {
bar = lazyService<Bar>(() => import('./bar'));
async onClick() {
// auto-load and invoke service method
await this.bar.doSomething();
// get service property
const name = await this.bar.name;
// convert async service to sync (auto-load)
const sync = await this.bar.toSync();
const secondName = sync.name;
}
} |
While there's nothing that would preclude that kind of auto-lazy service for someone who wanted it, I don't think it makes much sense as a default or encouraged pattern. There are almost always better places to do the lazy loading. For example: if the service will only gets used based on certain URLs, it would get taken care of automatically by route-based splitting, with no need to juggle promises in the component. For another example: if a service is only needed when the user clicks on a particular button, it's actually not great to do the lazy loading after they click. That puts the loading into their perceptible critical path. It's better if the service is already loading the background before they click. It costs almost nothing as long as you do it after rendering the critical path. For that you'd probably want the service itself to have a small shim that's part of the initial payload that is responsible for lazy loading the rest, preferable using idle timing (which would be nice to do given something like #957). |
This was discussed at the spec meeting this week, the main open point of discussion was wether we want to keep the decorator syntax despite typescript still not inferring it, or use a field assignment syntax instead that would infer correctly (with an explicit cookie = service(this, CookieService); |
Expanding on @ef4's comment above: Today, service lookups are keyed on the tuple
In Polaris, we plan to solve both of these problems by changing the lookup key to
In terms of practical day-to-day usages, it will probably look something like this: // app/services/session.js
import { setOwner } from "@ember/owner";
import { tracked } from "@glimmer/tracking";
export default class SessionService {
// Alternatively, we can still provide a superclass in the framework
// to deal with this boilerplate, just have to be a different import
// and not subclassing from `Ember.Object`
constructor(owner) {
setOwner(this, owner);
}
@tracked currentUser = null;
} // app/components/menu.js
import { service } from "@ember/service";
import Component from "@glimmer/component";
import Session from "my-app/services/session";
export default class Menu extends Component {
@service(Session) accessor session;
} ...or... // app/components/menu.js
import { service } from "@ember/service";
import Component from "@glimmer/component";
import Session from "my-app/services/session";
export default class Menu extends Component {
// About the same number of characters but possibly work better with TypeScript today
session = service(Session, this);
} For the primitives, we will need: // The definition of "Owner" in this context is different/more relaxed than the traditional one
// We only need it to be a WeakMap-key-able, that should probably be rectified across the framework
export type Owner = object;
export type ServiceDefinition<T> = /* ...defined later... */;
export type ServiceInstanceType<S extends ServiceDefinition<any>> = /* ...defined later... */;
type InstantiatedServices = WeakMap<ServiceDefinition<any>, ServiceInstanceType<ServiceDefinition<any>>>;
const Services = new WeakMap<Owner, InstantiatedServices>;
export function lookupService<S extends ServiceDefinition<any>>(owner: Owner, definition: S): ServiceInstanceType<S> {
let services = servicesFor(owner);
let service: ServiceInstanceType<S> | undefined = services.get(definition);
if (service === undefined) {
service = instantiate(owner, definition);
services.set(definition, service);
}
return service!;
}
type OverriddenServices = WeakMap<ServiceDefinition<any>, ServiceDefinition<any>>;
const Overrides = new WeakMap<Owner, OverriddenServices >;
export function overrideService<S1 extends ServiceDefinition<any>, S2 extends ServiceDefinition<any>>(owner: Owner, definition: S1, override: S2) {
if (DEBUG && servicesFor(owner).has(definition)) {
throw new Error(`Cannot override service ${inspect(definition)} after it has already been instantiated`);
}
}
let services = servicesFor(owner);
let service: ServiceInstanceType<S> | undefined = services.get(definition);
if (service === undefined) {
service = instantiate(owner, definition);
services.set(definition, service);
}
return service!;
}
function servicesFor(owner: Owner): InstantiatedServices {
let map = Services.get(owner);
if (map === undefined) {
map = new WeakMap();
Services.set(owner, map);
}
return map;
}
function instantiate<S extends ServiceDefinition<any>>(owner: Owner, definition: S): ServiceInstanceType<S> {
/* ...defined later... */
} On top of which we can build the convenience API. From the primitive's perspective, we can accept any WeakMap-key-able ( |
Linking to @chancancode's addon which explores this design and has some discussion issues around open questions: https://github.com/chancancode/ember-polaris-service |
Important design issues uncovered by experimentation work: |
rendered