Conversation
|
I like the direction, especially keeping League an implementation detail. Right now
All code below is a sketch against League Container 4.2.5 (the version in 1. Constructor injection instead of a dependency listA namespace SMF\Actions;
use SMF\ActionInterface;
use SMF\ActionTrait;
use SMF\Services\MemberReader;
use SMF\Services\TopicReader;
final class Unread implements ActionInterface
{
use ActionTrait;
public function __construct(
private readonly TopicReader $topics,
private readonly MemberReader $members,
) {}
public function execute(): void
{
$topics = $this->topics->unreadFor($this->members->current());
// ...
}
}The dispatcher takes the new route only for actions that ask for it. Everything that still has the protected, argument-less constructor from namespace SMF;
final class ActionResolver
{
public function __construct(
private readonly Services $services,
) {}
public function resolve(string $class): ActionInterface
{
$constructor = (new \ReflectionClass($class))->getConstructor();
// Actions with no dependencies keep the existing singleton route.
if ($constructor === null || !$constructor->isPublic() || $constructor->getNumberOfParameters() === 0) {
return $class::load();
}
$args = [];
foreach ($constructor->getParameters() as $param) {
$type = $param->getType();
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
throw new \LogicException($class . ' can only ask for services in its constructor.');
}
$args[] = $this->services->get($type->getName());
}
return new $class(...$args);
}
}
This keeps goal 2: only 2. Services as permissionsThis is where the small a) Narrow services instead of broad ones. Rather than a namespace SMF\Services;
interface MemberReader
{
public function current(): MemberData;
public function findById(int $id): ?MemberData;
}
interface PostWriter
{
public function create(NewPost $post): int;
}b) A mod declares what it needs in <install for="3.0 - 3.0.99">
<services>
<service id="SMF\Services\MemberReader" />
<service id="SMF\Services\MailSender" />
</services>
<hook hook="integrate_services" function="MyMod\Integration::services" file="$sourcedir/MyMod/Integration.php" />
</install>c) The Package Manager shows it before install. That screen already lists file operations and hooks, so this fits in naturally. Each service carries a label from
d) Each package gets its own namespace SMF;
use Psr\Container\ContainerInterface;
final class Services
{
/**
* @param array<string, true> $granted Service IDs this consumer may resolve.
*/
private function __construct(
private readonly ContainerInterface $container,
private readonly string $consumer,
private readonly array $granted,
) {}
public static function forCore(ContainerInterface $container): self
{
return new self($container, 'core', ['*' => true]);
}
public static function forPackage(ContainerInterface $container, string $package_id, array $service_ids): self
{
return new self($container, $package_id, array_fill_keys($service_ids, true));
}
public function has(string $id): bool
{
return $this->isGranted($id) && $this->container->has($id);
}
public function get(string $id): object
{
if (!$this->isGranted($id)) {
throw new ServiceNotGrantedException(sprintf(
'%s did not declare %s in its package-info.xml.',
$this->consumer,
$id,
));
}
return $this->container->get($id);
}
private function isGranted(string $id): bool
{
return isset($this->granted['*']) || isset($this->granted[$id]);
}
}e) $container = new \League\Container\Container();
$container->delegate(new \League\Container\ReflectionContainer(true));
$container->addServiceProvider(new Infrastructure\ServiceProvider());
foreach (PackageServices::installed() as $package_id => $package) {
$services = Services::forPackage($container, $package_id, $package['granted']);
$factories = [];
\call_user_func_array($package['hook'], [&$factories]);
foreach ($factories as $factory) {
$method = ($factory['shared'] ?? false) ? 'addShared' : 'add';
$container->$method($factory['name'], \Closure::bind($factory['callback'], $services, null));
}
}A mod action resolved through An admin page such as Packages > Installed > Service access can then list every package and what it was granted. The limit, stated plainlyPHP can't isolate code running in the same process. A mod that declares only It is still worth having:
Two small things on the current sketch
The obvious precondition for all of this is that SMF actually grows narrow services beyond |
|
Thanks for the suggestions. I'm going to keep to the initial scope. That I don't know if actions can override the protected empty constructor in |
|
@tyrsson do you have any thoughts on this plan? |
|
This latest commit implements my vision for our new container integration. I have also added a test to prove that it works. Even the integration hook has a test. Only thing left now is to unwire the previous implementation and migrate the error handling service. Switching to another container could be a separate PR. The League Container looks complicated. |
|
Good to see it working end to end, and the hook test is a nice touch. I tried the head of the branch and have a few findings, worst first. The branch currently fatals on every page
I realise this falls under "unwire the previous implementation", so this is only a note that the branch can't be run in its present state. The unit suite stays green because it never loads The red What the registration loop does with mod inputI ran your loop from // 1. true, for a class whose constructor needs an argument
$factories[NeedsDb::class] = true;
// ArgumentCountError: Too few arguments to function NeedsDb::__construct()
// 2. a factory written as a static closure
$factories[Db::class] = static fn (): Db => new Db();
// Warning: Cannot bind an instance to a static closure
// Closure::bind() returns null, the factory is dropped, and League builds
// the class from its own name instead. No error, wrong object.
// 3. two registrations of the same id
// The first one wins. DefinitionAggregate::getDefinition() returns the first
// match, so a mod that registers an id before core does keeps it, silently.
// 4. an action asking for a service nobody registered
// League\Container\Exception\NotFoundException, uncaught, so the page dies.Suggestions, roughly in order of value:
Where the block sitsThree things follow from it being a local variable inside
Since only actions consume services today, the whole block could also be skipped unless On the interfaceYou've settled this, so just one small thing rather than reopening it: Test hygiene
|
|
I'm waiting on @Sesquipedalian to provide feedback on #9699 before I fix the hook removal. He mentioned that he fixed unreported bugs when refactoring the entire codebase to be object-oriented. I don't want to accidentally undo one. |
I finished migrating the error handler service. Now I just need to run this on my test install. Cheers. |
Caution
These code sketches are not documentation.
The code shown in these sketches is intended to illustrate an idea, approach, or possible implementation. It is not production-ready code and should not be treated as documentation for how to write code in the project.
Our PSR container implementation feels a bit lackluster to me, and I think the global static access probably shouldn't exist. It is simply too tempting to use it as a service locator from services, entities, value objects, etc.
I've mentioned several times in the past that I dislike static objects because they're essentially global variables wearing a gold chain and trying to look cool.
I'd like to redesign the implementation so that the choice of container becomes an implementation detail. Ideally, only
SMF\Forumwould know that we're using a container at all. The rest of SMF should depend on an SMF abstraction rather than directly on the container implementation.For third-party modifications, I'm thinking about adding an integration hook that allows them to provide an array of callables. SMF would process those callables as service factories, passing them a small
SMF\Servicesobject.Something along these lines:
The
Servicesobject would deliberately have a very small API, perhaps just:Both methods would simply delegate to the underlying container.
The important part is that the container itself would never become part of the public extension API. If we eventually replace League Container with something else, third-party modifications shouldn't need to care.
Injecting services into actions
The end goal is to make these services available to actions through dependency injection, while retaining compatibility with the existing action hooks.
I'm not completely settled on the API here. One possibility is a new method on actions, exposed through a new interface, which tells the action system that the action accepts injectable services.
For example:
The action dispatcher could then resolve those services and pass them as arguments when invoking the action.
I'm not particularly attached to that interface or method name yet, though.
InjectableActionInterface,ServiceAwareActionInterface,DependencyAwareActionInterface, etc. all have slightly different implications.EDIT: When reviewing the spec again, it occurred to me that we could use a setter method instead, either via a factory/callable or by having it make the call itself, although the latter probably isn't portable.
https://github.com/thephpleague/container/blob/6.x/docs/5.x/dependency-injection.md#setter-injection
The main goals are:
I'd be interested in thoughts on both the architecture and, particularly, the naming/API for the action injection part.