Page MenuHomePhabricator

Hooks system should support MediaWiki services
Closed, DuplicatePublic

Description

Requirement

It should be possible to pass service name instead of class name in hook definition.

Why ?

MediaWiki hooks system is based on static calls which is difficult to test. To define a new hook listener have to edit extension.json file and pass a callback which is something like Namespace\ClassName::onHookDoSomething, which means we need a Namespace\ClassName class with a public static onHookDoSomething.
MediaWIki injection documentation explains how to migrate away from static hook handler functions. This approach requires couple extra steps:

  • create a static method newFromGlobalState just to instantiate a HooksListener as singleton
  • refactor old onSomething static function just to call self::newFromGlobalState()->doSomething()

This allows us to create a testable code, which is a good thing. This solution has couple minor problems

  • if HookClass handles 4 hooks ( => 4 methods), now the hook class will have 9 methods (4 on methods, 4 do methods and newFromGlobalState)
  • it's very difficult to get 100% code coverage as now we have 5 static methods to cover

Possible solution with @ annotation

Instead of writing new system use the existing hooks instrumentation. Pass an additional flag when passing a callback, Hooks::run() will detect this flag and or call the static method (old way) or use MediaWikiServices to retrieve the service (new way).

As a flag I specified @ character. When callable is passed without preceding @ threat it the old way. When @ is there use MediaWikiServices to retrieve service.

Example extension.json hook definition:

"Hooks": {
  "GetPreferences" : [
    "@ServiceName::hookMethod"
  ]

Possible solution - pass a [service, method] array

As @Tgr suggested - Pass an associative array containing two properties service which is service name and method which is method to call. When method is not passed hooks system will call on$event method.

Example extension.json hook definition:

"Hooks": {
  "GetPreferences" : [
    [
      "service" : "ServiceName",
      "method": "methodName"
    ]
  ]

Benefits of both solutions

With that approach we can easily introduce new system without interfering with old hooks.

Solution 1: https://gerrit.wikimedia.org/r/#/c/367421/3
Solution 2: https://gerrit.wikimedia.org/r/#/c/367421/4

See also

See also T154673: Refactor Hooks to no longer rely on global state and improve type safety

Event Timeline

Change 367421 had a related patch set uploaded (by Pmiazga; owner: Pmiazga):
[mediawiki/core@master] Allow Hooks::run() to use services.

https://gerrit.wikimedia.org/r/367421

  • the @ notation is too cryptic. I don't think Symfony is popular enough that it would make sense to freeride on its conventions. I'd rather see something like [ 'service' => 'ServiceName', 'method' => 'hookMethod' ] which is self-documenting even if it's uglier in config files.
  • there are quite a few places which implement some kind of object instantiation notation (ObjectFactory, ApiModuleFactory::addModules, ResourceLoader::getModule, ObjectCache::newFromParams...) which is a pretty similar problem. It would be nice to come up with a shared codebase for doing those things.

Other than that, seems like a good idea to me. IMO we should discard the hook system eventually and end up with something more like event listeners/dispatchers, but that's a long term thing and this looks like a reasonable short-term fix.

@Tgr - I updated the patch to respect new notation [ 'service' => 'ServiceName', 'method' => 'hookMethod' ]. Code became little bit more complex as it has to handle old arrays ( ['HookHandler::staticMethod']) and associative arrays with service/method definition.

pmiazga updated the task description. (Show Details)

Change 367421 had a related patch set uploaded (by Pmiazga; owner: Pmiazga):
[mediawiki/core@master] Allow Hooks::run() to use services.

https://gerrit.wikimedia.org/r/367421

last comments from gerrit patch:

@daniel:

The code seems fine. The approach is interesting, but I'm not quite convinced that this is a pattern we want to encourage, nor that using services as hook handlers is the best approach to solve the problem at hand. I'm not opposed to it either. Just not convinced.
My personal approach is using dedicated hook handler classes that use proper DI in the constructor. These classes have a static method that acts as the hook callback. The callback constructs a handler instance from global state, and calls the actual handler method. This allows the handler method to be properly unit tested.
A much more sever problem is in my opinion that code that runs hooks relies on global state. Allowing the hook manager to be injected would be very useful. But that's not for this patch.

@pmiazga:

When working on Popups code base I tried to stick with the new approach and create newFromGlobalState() function that creates one global static instance. Source: https://github.com/wikimedia/mediawiki-extensions-Popups/blob/master/includes/UserPreferencesChangeHandler.php
What I find disturbing is to get rid of one static function (onEvent handler) I had to move existing logic to doEvent() and introduce two static functions (newFromGlobalState() and onEvent(). This works pretty well but it adds lots of unnecessary code (just functions that call other functions). Also, I have a feeling that I'm reinventing the wheel as MediaWikiServiwces already give me the global state container and ServiceWirings the possibility to pass all dependencies as constructor arguments.
I created ServiceWirings for Popups extension for almost all classes (patch: https://gerrit.wikimedia.org/r/#/c/366874), including UserPreferencesChangeHandler which is temporary hook listener only for one hook. With that approach all logic how to construct classes is in one place, if I change some dependency I do not have to navigate to 10 all files and change how to construct something. Because of that UserPreferencesChangeHandler looks a bit cleaner but there are still two static functions. With a possibility to pass Service as a hook listener (this patch) we'll be able to get rid of IMHO unnecessary newFromGlobalState (as it is already defined in ServiceWirings) and a static listener. If you take a look at https://gerrit.wikimedia.org/r/#/c/366874/5/includes/PopupsHooks.php, this HookHandler has 6 eventListeners, if I change PopupsHooks to a service the only thing I have to do is

  • Create a constructor to pass PopupsContext as a dependency
  • Add this file to ServiceWirings
  • Remove static from hook listeners
  • Use $this->context instead of calling MediaWikiServices The number of functions/size of the code stays almost the same and it has no global state.

Now, if I switch to newFromGlobalState - the number of functions will double, (from 6 to 13 functions) plus it will still have a global state. Using PopupsHooks as a service looks much cleaner approach. Also, I'll be able to simplify PopupsContext class, to not have some dummy functions like PopupsContext::conflictsWithNavPopupsGadget() which only calls PopupsGadgetsItegration::conflictsWithNavPopupsGadget(). Currently, it's done this way as PopupsHooks is the boundary between MediaWiki and PopupsExtension. PopupsContext is the only exposed class to PopupsHooks to minimalize global state access between MediaWiki and Popups.

daniel added a subscriber: Anomie.

For the record, I find the idea appealing, I just learned to be careful about adding more options to an already bloaty interface. And while using MediaWiki services will work, it seems semantically odd to me...

@pmiazga it seems to me like it could benefit from going through the RFC process. It may look complicated, but you really don't have to do much more than tag this ticket with TechCom-RFC. What do you think? This means more eyes, and perhaps longer discussions, but a good chance to get something merged in the end.

I don' see anything odd in replacing static hook handlers with methods of a hook handler service. It's a step towards having proper per-extension event listener services. (Also it was sort of possible pre-extension registration, via the $wgHooks[] = [ $hookHandler, 'method' ]; notation; it'd be nice to have it back (and more performant).

Given that the patch only adds syntatic sugar, an RfC seems like an overkill to me. A mail to wikitech-l might be a more commensurate way of raising attention.

Should we worry about the possibility that something registers a hook for service "Foo" expecting that the implementation of that service will be BarFoo, but then it turns out that it's actually BazFoo that implements it and doesn't implement the hooked method? Or are these services not intended to be that reconfigurable?

IMO the whole point of services is that they are supposed to provide a stable interface with a well-defined contract describing what to expect (and when not to expect well-defined behavior) so that the implementation can be swapped out safely. Relying on that in hooks is not any more risky than relying on it in general. If it breaks, that's the problem with the service interface not being well-defined, or the caller not honoring the definition, not with the architecture.

Krinkle triaged this task as Medium priority.
Krinkle moved this task from Backlog to In progress on the TechCom board.
Krinkle subscribed.

@Krinkle I'm happy to help with this task, I still keep the https://gerrit.wikimedia.org/r/#/c/mediawiki/core/+/367421/ if we decide to follow that idea. Just let me know and I'll rebase/update this patch and make it work on current MW.

The larger topic of extension interfaces is a major focus in the Platform Evolution program. A first round of feedback was collected at TechConf 2018 which resulted in an RFC draft published at T212482: RFC: Evolve hook system to support "filters" and "actions" only.

The concerns raised in this task are in scope for that RFC and I believe it would be better to close this task in favour of centralising the discussion around the RFC. That is, if when the RFC is ready and does not address your concern, I would consider that a failure!

Please take a moment to read through the RFC and raise any questions or concerns in a comment below on T212482.

@Krinkle that sounds good, I'll check the RFC task.

Change 367421 abandoned by Pmiazga:
Allow Hooks::run() to use services.

https://gerrit.wikimedia.org/r/367421