|
| 1 | +# DTO Management proposal |
| 2 | + |
| 3 | +The current proposal is working and implemented in a WIP branch: https://github.com/magento/magento2/pull/23265 . |
| 4 | + |
| 5 | +## Current situation |
| 6 | +DTOs in Magento2 must be manually created as PHP classes and immutables are not currently supported by `\Magento\Framework\Api\DataObjectHelper::populateWithArray` method which is widely used in the core. |
| 7 | + |
| 8 | +Most of the time, DTOs are simply used to transport immutable information, such as an API request or an API resonse. In this case and because of implementation limitatsion, each DTO must also declare setter methods even if it supposed to not change. This requirement can lead to unexpected conditions and can introduce mutations where they should not be allowed. |
| 9 | + |
| 10 | +Another problem with the manual DTO creation is represented by the possibility for a non experienced developer to add side effects to getter methods. |
| 11 | + |
| 12 | +## Proposal |
| 13 | +The aim of this proposal is to switch from a manual DTO creation to an XML declarative way and an autogeneration of DTO PHP classes. |
| 14 | + |
| 15 | +Other than speeding up the developing process of new features, this approach could avoid most of the human errors such as typos or side effects in DTOs. |
| 16 | + |
| 17 | +### XML Syntax |
| 18 | + |
| 19 | +The XML declarative DTO definition should be base on the following model: |
| 20 | + |
| 21 | +``` |
| 22 | +<?xml version="1.0"?> |
| 23 | +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| 24 | + xsi:noNamespaceSchemaLocation="urn:magento:framework:Dto:etc/dto.xsd"> |
| 25 | +
|
| 26 | + <dto |
| 27 | + id="test-dto" |
| 28 | + mutable="false" |
| 29 | + > |
| 30 | + <class>Test\DtoGenerator\Dto\Test</class> |
| 31 | + <interface>Test\DtoGenerator\Api\TestInterface</interface> |
| 32 | + <property name="test1" type="string" nullable="true" /> |
| 33 | + <property name="test2" type="string" optional="true" /> |
| 34 | + <property name="test3" type="string" /> |
| 35 | + <property name="test_abc" type="string" /> |
| 36 | + </dto> |
| 37 | +</config> |
| 38 | +
|
| 39 | +``` |
| 40 | + |
| 41 | +The class name and the interface name should be declaread as fully qualified class names to allow a valid code generation. |
| 42 | + |
| 43 | +### DTO mutability |
| 44 | + |
| 45 | +A DTO can be specified with a `mutable` attribute. Depending on its value, setters methods will be created or not. |
| 46 | +In case of `mutable` = `false`, a PSR-7 like interface will be applied by creating a `with` method for each property. BY calling a `with` method, a new instance of the object will be returned with the updated information. |
| 47 | + |
| 48 | +Example: |
| 49 | +``` |
| 50 | +$dto = $dto |
| 51 | + ->withTest1('another value') |
| 52 | + ->withTest2('something different'); |
| 53 | +``` |
| 54 | + |
| 55 | +#### DTO mutators |
| 56 | +Since the PSR-7 like approach can lead to an high memory consumption in case of several fields update, an autogenerated `Mutator` class is created to help such process. |
| 57 | +Mutator classes follow the builder model to store all the changed properties and return an updated object only at the end of the mutation process. |
| 58 | + |
| 59 | +Example: |
| 60 | +``` |
| 61 | +/** @var \Test\DtoGenerator\Dto\TestMutator $mutator */ |
| 62 | +$dto = $mutator |
| 63 | + ->withTest1('another value') |
| 64 | + ->withTest2('something different') |
| 65 | + ->mutate($dto); |
| 66 | +
|
| 67 | +``` |
| 68 | + |
| 69 | +In this last case, **only a new instance** is created instead of 2. |
| 70 | + |
| 71 | +**NOTE:** |
| 72 | +Developers shold be aware that mutating several properties of an immutable can mean that the object should be considered as a mutable. Maybe a warning in static testing should be considered. |
| 73 | + |
| 74 | +### Implementation consequences introdcuing immutables |
| 75 | +The current core implementation deeply relies on `\Magento\Framework\Api\DataObjectHelper::populateWithArray` that requires an empty object to be created before hydrating it. |
| 76 | + |
| 77 | +Example: |
| 78 | +``` |
| 79 | +$address = $this->addressFactory->create(); |
| 80 | +$this->dataObjectHelper->populateWithArray($address, $data, AddressInterface::class); |
| 81 | +``` |
| 82 | + |
| 83 | +This approach is of course not compatible with an immutable DTO since the DTO information should be vailable at the moment of its creation. |
| 84 | + |
| 85 | +The solution proposed is to add a new method to `\Magento\Framework\Api\DataObjectHelper::createFromArray` and deprecate `populateWithArray`. |
| 86 | + |
| 87 | +**\Magento\Framework\Api\DataObjectHelper::createFromArray:** |
| 88 | +``` |
| 89 | +public function createFromArray(array $data, string $type) |
| 90 | +{ |
| 91 | + if ($this->dtoConfig->isDto($type)) { |
| 92 | + return $this->dtoProcessor->createFromArray($data, $type); |
| 93 | + } |
| 94 | +
|
| 95 | + // Compatibility mode |
| 96 | + $dataObject = $this->objectFactory->create($type, []); |
| 97 | + $this->populateWithArray($dataObject, $data, $type); |
| 98 | +
|
| 99 | + return $dataObject; |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +This new method should be used by `ServiceInputProcessor` and used in the recursive operation of `\Magento\Framework\Api\DataObjectHelper::_setDataValues` allowing a full backward compatibility. |
| 104 | + |
| 105 | +#### The hydration strategy |
| 106 | + |
| 107 | +The `createFromArray` method should be supporting both mutable and immutable DTOs, so a values injection srategy should be defined to allow the compatibility. |
| 108 | + |
| 109 | +The proposal is to create a class `\Magento\Framework\Dto\DtoProcessor\GetHydrationStrategy` able to define the attributes injection strategy depending on code reflection. In this way we know whenever an attribute should be injected via constructor or setter. |
| 110 | + |
| 111 | +### The extension attributes problem in immutables |
| 112 | + |
| 113 | +One of the most logical effects of introducing immutable DTOs is related to **Extension Attributes**. Since Extension Attributes are `setter` based by designed, they are not supposed to be immutable, but an immutable DTO cannot have a mutable part. |
| 114 | + |
| 115 | +The proposal is to define a new interface `\Magento\Framework\Api\ImmutableExtensibleDataInterface` not defining setter methods while code generating the extensible data class. |
| 116 | + |
| 117 | +The open point now is how to inject values inside the extension classes if no setters are provided and extension must be hydrated only after the DTO creation. |
| 118 | + |
| 119 | +#### The extension attributes injectors |
| 120 | + |
| 121 | +The proposal is to modify the `extension_attributes.xsd` by adding the following specifications: |
| 122 | + |
| 123 | +``` |
| 124 | +<xs:complexType name="injectorType"> |
| 125 | + <xs:attribute type="xs:string" name="code" use="required"/> |
| 126 | + <xs:attribute type="xs:string" name="type" use="required"/> |
| 127 | +</xs:complexType> |
| 128 | +<xs:complexType name="extension_attributesType"> |
| 129 | + <xs:sequence> |
| 130 | + <xs:element type="attributeType" name="attribute" minOccurs="0" maxOccurs="unbounded"/> |
| 131 | + <xs:element type="injectorType" name="injector" minOccurs="0" maxOccurs="unbounded"/> |
| 132 | + </xs:sequence> |
| 133 | + <xs:attribute type="xs:string" name="for" use="required"/> |
| 134 | +</xs:complexType> |
| 135 | +``` |
| 136 | + |
| 137 | +In this way, an `extension_attributes.xml` file may look like: |
| 138 | + |
| 139 | +``` |
| 140 | +<?xml version="1.0"?> |
| 141 | +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| 142 | + xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd"> |
| 143 | + <extension_attributes for="Test\DtoGenerator\Api\TestInterface"> |
| 144 | + <attribute code="test_attr" type="string" /> |
| 145 | + <injector code="test_attr_injector" type="Test\DtoGenerator\Model\MyInjector" /> |
| 146 | + </extension_attributes> |
| 147 | +</config> |
| 148 | +
|
| 149 | +``` |
| 150 | + |
| 151 | +The class `Test\DtoGenerator\Model\MyInjector` must implement `\Magento\Framework\Api\ExtensionAttribute\InjectorProcessorInterface` and declare an `execute` method with the DTO data as array and return an array with the extension attributes values. |
| 152 | + |
| 153 | +Unfortunately, at this stage, only an associative array can be used to manupulate the information, since the object should be created only after its dataset is defined. |
| 154 | + |
| 155 | +Example: |
| 156 | +``` |
| 157 | +<?php |
| 158 | +declare(strict_types=1); |
| 159 | +
|
| 160 | +namespace Test\DtoGenerator\Model; |
| 161 | +
|
| 162 | +use Magento\Framework\Api\ExtensionAttribute\InjectorProcessorInterface; |
| 163 | +
|
| 164 | +class MyInjector implements InjectorProcessorInterface |
| 165 | +{ |
| 166 | + /** |
| 167 | + * Process object for injections |
| 168 | + * |
| 169 | + * @param string $type |
| 170 | + * @param array $objectData |
| 171 | + * @return array |
| 172 | + */ |
| 173 | + public function execute(string $type, array $objectData): array |
| 174 | + { |
| 175 | + return ['test_attr' => 'Hello world']; |
| 176 | + } |
| 177 | +} |
| 178 | +
|
| 179 | +``` |
| 180 | + |
| 181 | +The attributes extension injector mechanism could also be used in mutable DTOs or existing classes. |
| 182 | + |
| 183 | +## Generated code examples |
| 184 | + |
| 185 | +``` |
| 186 | +<?php |
| 187 | +declare(strict_types=1); |
| 188 | +
|
| 189 | +namespace Test\DtoGenerator\Dto; |
| 190 | +
|
| 191 | +class Test implements \Test\DtoGenerator\Api\TestInterface |
| 192 | +{ |
| 193 | + /** |
| 194 | + * @var string |
| 195 | + */ |
| 196 | + private $test1 = null; |
| 197 | +
|
| 198 | + /** |
| 199 | + * @var string |
| 200 | + */ |
| 201 | + private $test2 = null; |
| 202 | +
|
| 203 | + /** |
| 204 | + * @var string |
| 205 | + */ |
| 206 | + private $test3 = null; |
| 207 | +
|
| 208 | + /** |
| 209 | + * @var string |
| 210 | + */ |
| 211 | + private $testAbc = null; |
| 212 | +
|
| 213 | + /** |
| 214 | + * @var \Test\DtoGenerator\Api\TestExtensionInterface |
| 215 | + */ |
| 216 | + private $extensionAttributes = null; |
| 217 | +
|
| 218 | + /** |
| 219 | + * @param string|null $test1 |
| 220 | + * @param string $test2 |
| 221 | + * @param string $test3 |
| 222 | + * @param string $testAbc |
| 223 | + * @param \Test\DtoGenerator\Api\TestExtensionInterface|null $extensionAttributes |
| 224 | + */ |
| 225 | + public function __construct(?string $test1, string $test3, string $testAbc, ?string $test2 = null, ?\Test\DtoGenerator\Api\TestExtensionInterface $extensionAttributes = null) |
| 226 | + { |
| 227 | + $this->test1 = $test1; |
| 228 | + $this->test2 = $test2; |
| 229 | + $this->test3 = $test3; |
| 230 | + $this->testAbc = $testAbc; |
| 231 | + $this->extensionAttributes = $extensionAttributes; |
| 232 | + } |
| 233 | +
|
| 234 | + /** |
| 235 | + * @return string|null |
| 236 | + */ |
| 237 | + public function getTest1() : ?string |
| 238 | + { |
| 239 | + return $this->test1; |
| 240 | + } |
| 241 | +
|
| 242 | + /** |
| 243 | + * @return string|null |
| 244 | + */ |
| 245 | + public function getTest2() : ?string |
| 246 | + { |
| 247 | + return $this->test2; |
| 248 | + } |
| 249 | +
|
| 250 | + /** |
| 251 | + * @return string |
| 252 | + */ |
| 253 | + public function getTest3() : string |
| 254 | + { |
| 255 | + return $this->test3; |
| 256 | + } |
| 257 | +
|
| 258 | + /** |
| 259 | + * @return string |
| 260 | + */ |
| 261 | + public function getTestAbc() : string |
| 262 | + { |
| 263 | + return $this->testAbc; |
| 264 | + } |
| 265 | +
|
| 266 | + /** |
| 267 | + * @return \Test\DtoGenerator\Api\TestExtensionInterface|null |
| 268 | + */ |
| 269 | + public function getExtensionAttributes() : ?\Test\DtoGenerator\Api\TestExtensionInterface |
| 270 | + { |
| 271 | + return $this->extensionAttributes; |
| 272 | + } |
| 273 | +
|
| 274 | + /** |
| 275 | + * @param string|null $value |
| 276 | + * @return Test\DtoGenerator\Api\TestInterface |
| 277 | + */ |
| 278 | + public function withTest1(?string $value) : \Test\DtoGenerator\Api\TestInterface |
| 279 | + { |
| 280 | + $dtoProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\Dto\DtoProcessor::class); |
| 281 | + return $dtoProcessor->createUpdatedObjectFromArray($this, ['test1' => $value]); |
| 282 | + } |
| 283 | +
|
| 284 | + /** |
| 285 | + * @param string $value |
| 286 | + * @return Test\DtoGenerator\Api\TestInterface |
| 287 | + */ |
| 288 | + public function withTest2(string $value) : \Test\DtoGenerator\Api\TestInterface |
| 289 | + { |
| 290 | + $dtoProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\Dto\DtoProcessor::class); |
| 291 | + return $dtoProcessor->createUpdatedObjectFromArray($this, ['test2' => $value]); |
| 292 | + } |
| 293 | +
|
| 294 | + /** |
| 295 | + * @param string $value |
| 296 | + * @return Test\DtoGenerator\Api\TestInterface |
| 297 | + */ |
| 298 | + public function withTest3(string $value) : \Test\DtoGenerator\Api\TestInterface |
| 299 | + { |
| 300 | + $dtoProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\Dto\DtoProcessor::class); |
| 301 | + return $dtoProcessor->createUpdatedObjectFromArray($this, ['test3' => $value]); |
| 302 | + } |
| 303 | +
|
| 304 | + /** |
| 305 | + * @param string $value |
| 306 | + * @return Test\DtoGenerator\Api\TestInterface |
| 307 | + */ |
| 308 | + public function withTestAbc(string $value) : \Test\DtoGenerator\Api\TestInterface |
| 309 | + { |
| 310 | + $dtoProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\Dto\DtoProcessor::class); |
| 311 | + return $dtoProcessor->createUpdatedObjectFromArray($this, ['testAbc' => $value]); |
| 312 | + } |
| 313 | +
|
| 314 | + /** |
| 315 | + * @param \Test\DtoGenerator\Api\TestExtensionInterface|null $value |
| 316 | + * @return Test\DtoGenerator\Api\TestInterface |
| 317 | + */ |
| 318 | + public function withExtensionAttributes(?\Test\DtoGenerator\Api\TestExtensionInterface $value) : \Test\DtoGenerator\Api\TestInterface |
| 319 | + { |
| 320 | + $dtoProcessor = \Magento\Framework\App\ObjectManager::getInstance()->get(\Magento\Framework\Dto\DtoProcessor::class); |
| 321 | + return $dtoProcessor->createUpdatedObjectFromArray($this, ['extensionAttributes' => $value]); |
| 322 | + } |
| 323 | +} |
| 324 | +``` |
0 commit comments