container-interop / service-provider

[EXPERIMENTAL] Promoting container/framework interoperability through standard service providers
71 stars 10 forks source link

Expose Scalar Cofiguration #48

Closed XedinUnknown closed 9 months ago

XedinUnknown commented 5 years ago

A service provider (basically, a module) is IMHO a way to expose configuration. Services are configuration. They are requested by key, and this is great, because it provides abstraction and overridability. However, services can also be scalar. In that case, they are more of what we understand by "traditional" config. But specifying this kind of configuration requires all of the values to be wrapped in a callable. This is very inconvenient, and adds an overhead that returns a static scalar.

What I propose is to allow getFactories() to return non-callables. Container implementations then do a simple check, and if the value is not a callable, then it is returned verbatim, without resolution. Of course, this would then invalidate the name getFactories(), in which case it would need to be changed, perhaps back to getServices(). Not sure why it was changed anyhow.

Thoughts?

pmall commented 5 years ago

Hi,

A service provider (basically, a module) is IMHO a way to expose configuration.

It was very confusing for me at first but here's what I think now: service providers are not a way to configure your application container. It's an interoperable way for packages/libraries/modules to expose factories and extensions. What the point for a library/module to expose configuration in an interoperable way? The essence of configuration is to be specific. On the other hand they may receive configuration to configure the service they provide (and there is no mechanism for this right now).

Basic example:

I'm more and more skeptical about this proposal. The more I play with containers the less I see real use cases for service providers. It is often easier to configure your container by yourself than loading service providers, configure it and to articulate them together.

XedinUnknown commented 5 years ago

I used to be skeptical about service providers too. But that was back in the days when they had just the getServices() method: you could just simply provide an array instead. Now that it is split into getFactories() and getExtension(), it makes total sense to me. I use containers and providers mostly in a modular architecture. In that kind of environment, modules are loaded one after another, and then run in that same order. This results in a very intuitive process, where factories that are loaded later override factories that were loaded before; and extensions that are loaded later complement those that were loaded before. Without a service provider standard, there would be no way to do something like this, without having modules return configured containers. That is a way that I used to use before this splitting of provider methods, but it is more complicated because each module would have to ship its own container implementation.

Another reason for using providers VS containers is that provided services are enumerable, whereas container services are not. In fact, values retrieved from the container are not guaranteed to be in memory or on disk (they can be remote, i.e. require an HTTP request), or even to exist prior to their retrieval (they can be generated by the container on demand). Therefore, having just the containers without providers makes it impossible to have compiled containers. With providers, on the other hand, it is possible to compile them by creating a definitive map of service names to their eventual definitions, and store them separately to avoid repetitive and expensive lookup - which can be a problem if you have many containers.

With regard to configuring services themselves, there is a way to achieve that, because the service definitions receive a container instance as parameter. That instance contains all of the configuration. Given that a service provider is a way to configure a container's services (I don't see why this is not the case), there should also be a way for providers to provide scalar configuration with ease, as it is often used for service configuration. It's all configuration, whether objects, scalars, or compounds. So why am I required to create and then resolve a closure just to retrieve a "hello!" value?

pmall commented 5 years ago

So why am I required to create and then resolve a closure just to retrieve a "hello!" value?

Why would a shared module/library/package ever provide a string value?

I think providing configuration values is out of the scope of interoperable service providers (= usable in any application understanding them) because configuration is application specific. A database parameter array for instance, I can see modules consuming a database parameter array (through the container the factories receive, yes) but providing it through a shared module makes no sense.

I think you are restricting yourself by trying to configure all your services with interoperable service providers. Your app could configure a container with configuration values and everything in any way you want then make usage of service providers for factories/extensions which can be shared by many applications. This is the goal of writing a specification: specifying what shared code looks like.

XedinUnknown commented 5 years ago

Why would a shared module/library/package ever provide a string value?

I don't think that this is a valid question in this case. What I believe to be important is:

  1. Services are configuration.
  2. Configuration may be scalar.
  3. No easy way to provide scalar values.

The "why" is not so important, because it is about principle: nobody can possibly know all the potential use-cases, but if it is done conceptually correctly, all natural use-cases will be supported automatically. My reasons for having shared modules provide scalars include prototyping, and meaningful defaults. But I imagine there may be other cases. Also, modules don't have to be shared, i.e. nothing about "modules" describes them as something that muse be re-used, e.g. by multiple other modules. I can have a module that simply provides some very application-specific functionality, or even without functionality at all but only providing services - or overriding other services, which may include configuration values.

I think you are restricting yourself by trying to configure all your services with interoperable service providers

Where exactly is the restriction? My app could have a container with configuration values, but those configuration values must come from somewhere, and ideally they come from modules.

pmall commented 5 years ago

modules don't have to be shared

That's the point of a PSR, specifying interfaces so other library can consume them regardless the implementation.

I mean, your modules can have a mechanism to provide scalar services, but it does not have to be in the PSR. Other libraries do not care how you set up your container.

XedinUnknown commented 5 years ago

That's the point of a PSR, specifying interfaces so other library can consume them regardless the implementation.

Yes, but nothing says that they have to be shared. If I have a compliant module that is only consumed by a module-loading mechanism only in my one concrete application, and that module adds a very application-specific thing, then the module is not shared; and yet it is a module. Modularity is not defined by the ability to be "shared", but by the ability to be plugged in and out without knowledge of internals.

I mean, your modules can have a mechanism to provide scalar services, but it does not have to be in the PSR. Other libraries do not care how you set up your container.

Yes, sure. However, I would need to add yet another method, like getConfig() to my module interface. And that method will do the same thing as getFactories(), except it will also allow scalars.

pmall commented 5 years ago

Yes, but nothing says that they have to be shared.

If they are not meant to be usable by multiple projects then they do not need to share a common interface. Like a container does not need to implement PSR-11 if it is not meant to be used by any project. My point is configuration is not shared by multiple projects because being specific is the essence of configuration, so it does not belongs to the interface. Configuration could be defined in any kind of modules which may or may not implement ServiceProviderInterface, it is up to you.

Do you have an example of a module exposing configuration?

mindplay-dk commented 9 months ago

To the original topic:

A service provider (basically, a module) is IMHO a way to expose configuration. Services are configuration. They are requested by key, and this is great, because it provides abstraction and overridability. However, services can also be scalar. In that case, they are more of what we understand by "traditional" config.

Agreed so far.

I am definitely putting all of my configuration in my DI container - for example, I have a service provider that will read the system environment and import all system variables, another service provider that will read an INI file and register every section/name/value as "services".

I want dependency injection for all dependencies - there is nothing special about configuration values, these are just dependencies, too.

(I've actually never liked the term "services", and prefer the term "dependencies" - it definitely helps in conversations with junior developers when trying to teach them dependency injection and the understanding that anything and everything can be a dependency; not just services. However, I am not going to push for a terminology change, as aligning the language with PSR-11 is more important.)

But specifying this kind of configuration requires all of the values to be wrapped in a callable. This is very inconvenient, and adds an overhead that returns a static scalar.

This argument doesn't make sense to me though.

If only some values are wrapped in a callable, this might give you some superficial convenience when implementing a service provider - but now the consumer needs to do type-checking, so you're essentially just trading one inconvenience for a different inconvenience.

In addition, this kind of approach can lead to some confusion when a service is a callable - it invites a common bug, where you forget to wrap your callable service in a callable factory, and this of course ends up getting called, which in some cases might no even error, and instead quietly succeeds and causes confusing bugs somewhere else in your application.

Of course, this would then invalidate the name getFactories() in which case it would need to be changed, perhaps back to getServices()

But with this change, they are also not services - so getFactoriesOrServices, if you wanted to be accurate.

I believe we should favor consistency over convenience: better the factory collection is consistently one type, than potentially confusing things by introducing a type union.

If you think this issue warrants further discussion, please open a thread in Discussions.