By now, every Drupal developer should know that it's a bad idea to overwrite core and contributed code. Especially when using Composer correctly and, therefore, only store the composer.lock
file to the version control system. It should be imminent that changing files containing core code is a no-brainer (don't hack core!). But what should/can you do when you want to extend or overwrite existing code and still use the rest of the functionality?
An option is to write patches, and when using Composer in combination with cweagans/composer-patches it becomes pretty simple to apply it automatically when building an environment (development, staging, production). However, this shouldn't be an acceptable solution either. Patches should be used to fix code that is incorrect in a generic manner, not to improve functionality for your project's specific use case. As a general rule of thumb, you can only consider a patch an acceptable solution when you have attached it to an issue on drupal.org.
Another solution is to replace a service with a different object. Similar to RouteSubscriber: serve a different page on an existing URL, where the original controller has been removed and used a form instead. In that specific example, it makes perfect sense to use a completely different class, however, what would be the neatest solution when only a small part of the code (of a service) needs to be altered? Extending the original class, implementing the custom code change in it and use this one instead, not only feels counter-intuitive it also limits flexibility in extending functionality (though the latter is mostly theoretical).
The correct solution for this is to provide a service decorator. In your module's .sevices.yml
file, you have to register the class and tell which service it decorates. In the example below, the VendorStreamWrapperAssetOptimizer
decorates the asset.css.optimizer
service that is provided by Drupal core, gets an appropriate decoration_priority
, and is marked as a private service (i.e., public: false
, no need for this service to be instantiated itself). Important here is that the original service is injected as an argument to the decorating class: @vendor_stream_wrapper.asset.css.optimizer_decorator.inner
. The name of this inner-service is automatically based on the new decorator service, you can provide an alternative by setting the decoration_inner_name
option.
…
vendor_stream_wrapper.asset.css.optimizer_decorator:
class: 'Drupal\vendor_stream_wrapper\Asset\VendorStreamWrapperAssetOptimizer'
decorates: asset.css.optimizer
decoration_priority: 1
public: false
arguments: ['@vendor_stream_wrapper.asset.css.optimizer_decorator.inner']
…
The example used here is based on the contributed Vendor Stream Wrapper module, which provides a vendor://
stream wrapper to include CSS or JavaScript files that are offered by libraries in the vendor
directory. Typically, the vendor
directory is located outside the web-root, and, therefore not publicly accessible. To allow assets to be still available easily/loaded by browsers, the vendor://
stream wrapper comes in handy and works similar to the private://
and temporary://
stream wrappers. The problem here is that the virtual paths introduced by this module cannot be used correctly by Drupal's asset optimization service to load the contents of the files using the virtual paths. Therefore, the solution is to introduce a service decorator for both the asset.css.optimizer
and asset.js.optimizer
services (the latter is not shown here, but similar to the service registration depicted above), which in short transforms the (virtual) paths for assets starting with vendor_files/
to the correct file location in the vendor
directory.
The VendorStreamWrapperAssetOptimizer
class is provided below and implements the same interface as the original class for the asset.css.optimizer
(and asset.js.optimizer
) service (i.e., AssetOptimizerInterface
). The original asset optimization service is provided as the $inner_service
argument to the constructor and allows it to ensure that the existing functionality can be used correctly (even when other service decorators would change it). The only thing we have done is to transform the virtual paths to the correct file location in the optimize()
method.
<?php
namespace Drupal\vendor_stream_wrapper\Asset;
use Drupal\Core\Asset\AssetOptimizerInterface;
use Drupal\vendor_stream_wrapper\StreamWrapper\VendorStreamWrapper;
/**
* Decorates the CSS and JS optimization services.
*
* The optimization service loads the CSS and JS files based on the provided
* path, however, the path that is generated is virtual/non-existing (which
* prevents the whole vendor folder from being publicly available). Therefore,
* we need to decorate the optimization service, used when aggregating CSS and
* JS files, to set the correct file path for the CSS and JS assets in the
* vendor folder.
*/
class VendorStreamWrapperAssetOptimizer implements AssetOptimizerInterface {
/**
* The original asset optimization service instance that is being decorated.
*
* @var \Drupal\Core\Asset\AssetOptimizerInterface
*/
protected $innerService;
/**
* Creates a new VendorStreamWrapperCssOptimizer instance.
*
* @param \Drupal\Core\Asset\AssetOptimizerInterface $inner_service
* The original asset optimization service instance that is being decorated.
*/
public function __construct(AssetOptimizerInterface $inner_service) {
$this->innerService = $inner_service;
}
/**
* {@inheritdoc}
*/
public function optimize(array $asset) {
// Translate the virtual 'vendor_files' paths into the correct path for the
// vendor directory, so that the actual file can loaded by the optimization
// service.
if (!empty($asset['data']) && strpos($asset['data'], 'vendor_files/') === 0) {
$asset['data'] = str_replace('vendor_files', VendorStreamWrapper::basePath(), $asset['data']);
}
return $this->innerService->optimize($asset);
}
/**
* {@inheritdoc}
*/
public function clean($content) {
return $this->innerService->clean($content);
}
}
Note that you shouldn't extend the original's service class, but instead implement each method and invoke its functionality using $this->innerService
. In different sources on the internet, this is provided as an example, but it prevents correct working when multiple decorators are applied to one service.
When a lot of methods have to be overloaded without introducing/changing functionality, like the clean()
method in the example above, you can instead use the magic __call()
method as shown below:
...
/**
* Direct all non-overloaded method invocations to the original/inner service.
*
* @param $name
* Name of the method being called.
* @param $arguments
* Enumerated array containing the parameters passed to the method.
*
* @return mixed
* Result of the original services that is being decorated.
*/
public function __call($name, $arguments) {
return call_user_func_array(array($this->innerService, $name), $arguments);
}
...
Comments