didix16 / php-apidatamapper
一个可扩展的DTO库,允许使用简单的字段映射语言、过滤器以及函数将传入的API数据映射到任何你的实体/模型。
Requires
- php: >=7.4
- didix16/php-apidataobject: >= 1.0.3
- didix16/php-grammar: ^1.0
- didix16/php-hydrator: ^1.0
- didix16/php-interpreter: ^1.0
README
一个可扩展的DTO库,允许使用简单的字段映射语言、过滤器以及函数将传入的API数据映射到任何你的实体/模型。
内容
什么是API DataMapper
API DataMapper是一个类,它允许在不费太多力气的情况下将传入数据映射到你拥有的模型或实体类。你只需指示哪些传入的字段应映射到你的实体类字段,以及如何映射。换句话说,允许你使用DTO模式来处理你拥有的每个类。
它能够在将最终数据设置为实体字段之前预处理数据并转换它。
什么是模型映射
因此,模型映射是一个处理将传入数据映射到你的应用程序中的模型的所有复杂操作的类。它具有配置哪些字段应映射到哪些字段以及如何映射的配置。所有DTO魔法都发生在这里。它使用FieldInterpreter来完成这项任务。
安装
composer require didix16/php-apidatamapper
用法
在下面的列表中,你将看到如何使用此包的每个重要部分。
字段语言
这是模型映射器使用的语言。其语法非常易于理解:它允许选择您想要从APIDataObject中提取和处理的字段。
该语法允许从对象中选择单个字段,从列表(数组、向量等)中选择字段,并转换传入的数据(例如,字符串到PHP DateTime)。还允许使用列表项的聚合函数。例如,获取列表中某个金额的最大值。
语法
假设我们有一个这样的JSON $data
{ "warrior": { "name": "Lancelot", "active": "no", "weapon": "Spear", "comes_from": "Camelot" } }
选择第一级字段:warrior
use didix16\Api\ApiDataObject\ApiDataObject; class MyApiDataObject extends ApiDataObject {} $data = MyApiDataObject::fromJson($data); $input = "warrior"; $fi = new FieldInterpreter($parser, $data); $lexer = new FieldLexer($input); $parser = new FieldParser($lexer); $res = $fi->run(); var_dump($res); /** * array(1) { * ["warrior"]=> stdClass(warrior...) * * } * */
从对象中选择特定字段并应用过滤器(在这种情况下为BooleanFilter)
use didix16\Api\ApiDataObject\ApiDataObject; class MyApiDataObject extends ApiDataObject {} /** * Example of how to parse a field from api data and turn into a boolean */ $input = "warrior.active:boolean"; $lexer = new FieldLexer($input); $parser = new FieldParser($lexer); $data = MyApiDataObject::fromJson($data); $fi = new FieldInterpreter($parser, $data); $res = $fi->run(); var_dump($res); /** * array(1) { * ["warrior.active:boolean"]=> * bool(false) * } * */
选择最深层属性的深度没有限制。
warrior.place.country.address.name ...
我们可以堆叠尽可能多的过滤器。
例如
warrior.comes_from:capitalize,snakecase,kebab,...
注意:这些过滤器不存在,你必须注册它们。在下面的部分中,你将看到如何做。
转换管道的顺序遵循语法中指定的,从左到右。在上面的例子中,capitalize过滤器将首先执行,然后是snakecase,依此类推...
现在,假设我们有一个这样的JSON $data
{ "comes_from": "Camelot", "warrior_list": [ { "name": "Lancelot", "active": "no", "weapon": "Spear", "kills": 90 }, { "name": "Arthur", "active": "yes", "weapon": "Sword", "kills": 50 }, ... }
从列表中选择一个字段
warrior_list[].name
$data = MyApiDataObject::fromJson($data); $input = "warrior_list[].name"; $lexer = new FieldLexer($input); $parser = new FieldParser($lexer); $fi = new FieldInterpreter($parser, $data); $res = $fi->run(); var_dump($res); /** * array(1) { * ["warrior_list[].name"]=> * array(2) { * [0]=> * string(8) "Lancelot" * [1]=> * string(6) "Arthur" * } *} * */
在列表中使用聚合函数
MAX(warrior_list[].kills)
$input = "MAX(warrior_list[].kills)"; $lexer = new FieldLexer($input); $parser = new FieldParser($lexer); $fi = new FieldInterpreter($parser, $data); $res = $fi->run(); var_dump($res); /** * array(1) { * ["MAX(warrior_list[].kills)"]=> * int(90) *} */
注意:目前只有MAX和MIN可用。然而,你可以通过添加所需的函数(如SUM、AVG、MEAN等)来扩展字段语言。
顺便说一下:你只能在与列表字段一起使用聚合函数!即:MAX(name)不会起作用,但MAX(warrior_list[].name)会。
字段解释器
如上所述,为了使FieldInterpreter执行其工作,我们需要一个FieldLexer和一个FieldParser。
FieldLexer接收字段语言输入语法。FieldParser接收FieldLexer作为唯一参数。
最后,你需要将FieldParser传递给FieldInterpreter,并传递一个APIDataObject实例来执行其魔法 :).
如果由于某种原因语法无效,您将收到一个异常。
最后,您需要从解释器调用#run方法。结果将是一个关联数组,键是处理后的输入,值是最终处理后的数据。
$input = "MAX(warrior_list[].kills)"; $lexer = new FieldLexer($input); $parser = new FieldParser($lexer); $fi = new FieldInterpreter($parser, $data); $res = $fi->run(); // returns an array
注意:如果您指定了一个不在传入数据中的字段,则该字段的值将是
class didix16\Api\ApiDataObject\UndefinedField {}
因此,您必须使用ApiDataObject::isUndefined($res[$field])来检查结果是否正确或是否是未定义的字段。
字段过滤器
正如我们所看到的,过滤器允许管道数据并转换数据。我们可以添加任意数量的过滤器。
默认情况下有两个过滤器:DateFilter和BooleanFilter。
BooleanFilter将潜在的值转换为布尔值。例如:"yes",1,"1","true",true,"True","tRUE"等将被转换为PHP true值。然而,"no",0,"1","false",false,"FALSE","fAlse"等将被转换为PHP false值。
您可以使用一个关联数组实例化一个新的BooleanFilter,该数组告诉过滤器哪些值应被视为true,哪些应被视为false。
// The second parameter is $forceFalse. // If is true then if the value founded is not in the specified list nor is a php boolean value the value will be set to false as default. // By default is false and thus will leave the value as is if is "non-booleable" $filter = new BooleanFilter( [ "true" => [ "done", "completed", ... ], "false" => [ "pending", "not_finished", ... ] ], true);
DateFilter允许将任何标准日期格式解析为PHP DateTime类。
首先,它将尝试通过测试以下格式来转换
DateTimeInterface::ATOM, DateTimeInterface::COOKIE, DateTimeInterface::ISO8601, DateTimeInterface::RFC822, DateTimeInterface::RFC850, DateTimeInterface::RFC1036, DateTimeInterface::RFC1123, DateTimeInterface::RFC2822, DateTimeInterface::RFC3339, DateTimeInterface::RFC3339_EXTENDED, DateTimeInterface::RSS, DateTimeInterface::W3C
如果这些格式中没有找到任何一个,则将使用$fromFormat='Y-m-d'构造函数选项作为最后的手段。
您还可以传递一个时区作为第二个可选参数。
// $fromFormat $toTimezone $filter = new DateFilter('d-m-Y', 'Europe/London');
如果需要,请随意扩展。
要创建自己的过滤器,您需要从以下内容扩展
class didix16\Api\ApiDataMapper\FieldInterpreter\Filters\FieldFilter;
最后,要注册一个过滤器,您需要在调用#run方法之前从FieldInterpreter调用#loadFilter(FieldFilter $filter)方法
/** * Custom FieldField: allows capitilize strings */ class CapitalizeFilter extends FieldFilter { protected function transform(&$value) { if($this->assertString($value)) $value = strtoupper($value); } } /** * Custom FieldFilter: adds '_thisIsASuffix' as a string suffix */ class SuffixerFilter extends FieldFilter { protected function transform(&$value) { if($this->assertString($value)) $value = $value . '_thisIsASuffix'; } } $input = "warrior.name:capitalize,suffixer"; ... $fi = new FieldInterpreter($parser, $data); $fi ->loadFilter(new CapitalizeFilter('capitalize')) ->loadFilter(new SuffixerFilter('suffixer'));
重要:过滤器名称必须在字段语言语法中相同。如果您将过滤器命名为"capitalize",则在$input语法中,过滤器也应为"capitalize"。
FieldFilter作为必需参数接收其名称。如果您查看BooleanFilter和DateFilter,您将在它们的构造函数中看到以下内容
parent::__construct("boolean")
和
parent::__construct("date")
行分别在其构造函数中
字段函数
类似于FieldFilter,但用于AggregateFunctions。正如我之前写的,聚合函数仅适用于列表,因此请小心。
有MAX和MIN函数(自我解释)。
如果您需要添加自己的函数,您必须扩展
class didix16\Api\ApiDataMapper\FieldInterpreter\Functions\AggregateFunction;
最后,要注册一个函数,您需要在调用#run方法之前从FieldInterpreter调用#loadFunction(InterpreterFunction $function)方法
class AvgFunction extends AggregateFunction { public function __construct() { parent::__construct("AVG"); } /** * Returns the average value within iterable $data * If $data is empty, then return null * @return mixed */ protected function avg(){ if (empty($this->iterable)) return null; if (!$this->field) return array_sum($this->iterable)/ count($this->iterable); else { $values = array_map(function($obj){ return $obj->{$this->field} ?? null; }, $this->iterable ); return array_sum($values) / count($values); } } /** * Given an interable, returns the avergage interpreted value * @param $args * @return mixed */ public function run(...$args) { parent::run(...$args); return $this->avg(); } } $input = "AVG(warrior_list[].kills)"; $lexer = new FieldLexer($input); $parser = new FieldParser($lexer); $fi = new FieldInterpreter($parser, $data); $fi ->loadFunction(new AvgFunction()); $res = $fi->run(); var_dump($res); /** * array(1) { * ["AVG(warrior_list[].kills)"]=> * float(48.333333333333) *} */
重要:函数名称必须在字段语言语法中相同。如果您将函数命名为"AVG",则在$input语法中,函数也应为"AVG"。
模型映射
正如我之前解释的,模型映射是API数据映射器的关键。它处理所有解析和管理数据的丑陋任务。
幸运的是,我们唯一要做的就是告诉模型映射如何映射字段以及是否需要预处理和后处理它们。
例如,想象我们有这些实体类
/** * An other ORM class or system class that is being used by another class as a property */ class Color { protected string $name; public function __construct(string $color) { $this->name = $color; } public static function fromName(string $color): Color { return new static($color); } public function getName(): string { return $this->name; } public function __toString() { return '<Color(' .$this->getName(). ')>'; } } /** * A potential ORM entity. */ class Monster { protected string $name; protected Color $color; protected bool $eatHumans; protected int $numLegs; public function setName($name): Monster { $this->name = $name; return $this; } public function getName(): string { return $this->name; } public function setColor($color): Monster { $this->color = $color; return $this; } public function getColor(): Color { return $this->color; } public function setEatHumans($flag): Monster { $this->eatHumans = $flag; return $this; } public function eatsHumans(): bool { return $this->eatHumans; } public function setNumLegs($legs): Monster { $this->numLegs = $legs; return $this; } public function getNumLegs(): int { return $this->numLegs; } }
想象我们有这个数据源
// https://a-monster-api.com/api/monster/Blob $jsonIncomingFromMonsterAPI = <<<JSON { "monster": { "name": "Blob", "eat_humans": 0, "color": "green", "num_legs": 0 } } JSON;
我们应该如何将数据映射到我们的Monster实体中?
没有数据映射器,我们可能会设计一个特定的DTO或类似的东西。也许有人硬编码了转换(是的,我看到了很多这样的东西),不管怎样。
但如果你告诉我,你只需要一个映射配置(也许还有一个模型映射器工厂)呢?
use didix16\Api\ApiDataMapper\ModelMapFactoryInterface; use didix16\Api\ApiDataMapper\ModelMapInterface; class ModelMapFactory implements ModelMapFactoryInterface { public static function build($modelClass): ModelMapInterface { switch($modelClass){ case Warrior::class: return new WarriorModelMap(); case Monster::class: return new MonsterModelMap(); default: throw new \Exception(sprintf('There are not factory for class %s', $modelClass)); } } }
use didix16\Api\ApiDataMapper\ModelMap; class MonsterModelMap extends ModelMap { public function __construct() { parent::__construct(); $this // configured single fields ->mapFields([ 'monster.name' => 'name', 'monster.color' => 'color:getColor', 'monster.eat_humans:boolean'=> 'eatHumans', 'monster.num_legs' => 'numLegs' ]); } }
$apiData = MonsterApiDataObject::fromJson($jsonIncomingFromMonsterAPI); /** * @var Monster $monster */ $monster = $apiDataMapper ->configure(Monster::class) ->use([ new GetColorMapFunction() ]) ->mapper ->mapToModel(Monster::class, $apiData); echo "\n"; echo "\n"; echo 'Name: ' . $monster->getName() . "\n"; echo 'Eat Humans: ' . ($monster->eatsHumans() ? 'yes' : 'no') . "\n"; echo 'Color: ' . $monster->getColor() . "\n"; echo 'Number of legs: ' . $monster->getNumLegs() . "\n"; echo '========================'. "\n";
哇,这是很多信息!是的,我知道。但到目前为止,请注意MonsterModelMap和mapFields方法。这听起来熟悉吗?正确,字段语言!正如我们所看到的,模型映射器使用字段解释器为我们映射字段,并且能够在将最终值设置到我们的实体之前执行一个“后解析”函数。这意味着什么?好吧,看看我们的怪物中的颜色属性。
它不仅仅是一个“普通”的值,比如字符串或数字,而是一个类!虽然我们可以创建一些将值转换为类的过滤器,但过滤器只能访问单个值。如果我们需要更多信息怎么办呢?答案是ModelMapFunction。这类函数允许我们在将数据设置到我们的实体之前进行最后的转换,并且还可以访问APIDataObject。
还记得颜色属性吗?是的,颜色是一个类,因此,我们可以添加一个模型映射函数,解析值并将其转换为我们的Color类。如果我们有需要从API获取更多数据的复杂类,我们可以访问这些数据,还可以调用我们应用中的任何服务,比如DDBB存储或类似的服务,并对其进行相应的处理 :)
模型映射函数
模型映射函数很容易实现:只需要实现run(...$args)方法。
$args 0包含来自解释器的值(在过滤器管道之后)
$args 1包含整个APIDataObject。
注意:数据可能不是原始数据,因为解释器可能会通过应用您的过滤器来更改值。
use didix16\Api\ApiDataMapper\ModelMapFunction; class GetColorMapFunction extends ModelMapFunction { // parameters and its default values protected $parameterName = null; public function run(...$args) { $colorName = $args[0]; $apiDataObject = $args[1]; $fieldName = $args[2]; /** * At v1.0.5+ also you can pass external parameters to be used inside run method at construction time * * Remember that the given parameters should exists in your ModelMapFunction * * You can build ModelMapFunction using: * * new YourModelMapFunction("", ['param1' => 'value1', ...]) * YourModelMapFunction::withParameters(['param1' => 'value1', ...]) <== this is an alias of constructor above * * Example: * * GetColorMapFunction::withParameters(['parameterName' => '#FF0000']) * */ $colorString = $this->parameterName; // #FF0000 return new Color($colorName); } }
最后,我们如何告诉模型映射使用这个函数呢?
很简单。还记得这一行吗?
'monster.color' => 'color:getColor',
完美!正如你所推测的,mapFields数组的关键是字段语言的输入,值必须是我们的实体的字段,但也可以可选地以冒号和函数名后缀。通常,函数名是类名的驼峰命名法,不包括MapFunction后缀,因此每个MapFunction的类名应该是
<YourFunctionName>MapFunction
但这还不算完,我们必须将函数注册到我们的模型映射中。
有一个#use()方法,允许我们注册多种类型的对象
- 模型映射函数
- AggregateFunction
- 字段过滤器
这样,我们可以通过模型映射而不是直接与字段解析器通信来扩展我们的语言字段映射。
模型映射工厂
为什么我们需要一个工厂来实例化模型映射?因为这是ApiDataMapper的神奇之处的一部分,它允许传递任何类型的类(我们有的ModelMapFactory中的那些),并在运行时配置我们的模型映射,同时将繁重的工作留给ApiDataMapper。
记住,我们的目标只是配置映射,其余的留给APIDataMapper :)
如我们所见,这是一个可以生成Warrior和Monster类映射的工厂示例。
将来我会改掉这个,因为没有人想为每个类都有一个巨大的switch语句 :)
与此同时,你可以生成不同的模型映射工厂,拥有不同的api数据映射器。
use didix16\Api\ApiDataMapper\ModelMapFactoryInterface; use didix16\Api\ApiDataMapper\ModelMapInterface; class ModelMapFactory implements ModelMapFactoryInterface { public static function build($modelClass): ModelMapInterface { switch($modelClass){ case Warrior::class: return new WarriorModelMap(); case Monster::class: return new MonsterModelMap(); default: throw new \Exception(sprintf('There are not factory for class %s', $modelClass)); } } }
ApiDataObject
- 请参阅didix16/php-apidataobject - 一个简单的库,允许轻松处理来自任何来源的(特别是API来源)数据
ApiDataMapper
到这里,我们就看到了这个奇妙包的API功能使用方法 :)
基本上,从这个包中,我们需要
-
GlobalApiDataMapper(或者如果你需要其他东西,使用ApiDataMapper)
-
/** * Given A model class and an ApiDataObject, attempt to generate an instance of $modelClass with data given * @param $modelClass - Should be any kind of ORM entity or object class representing a model in DDBB * @param ApiDataObjectInterface $data * @return object * @throws ApiDataMapperException */ public function mapToModel($modelClass, ApiDataObjectInterface $data): object
-
/** * Given a model class and ApiDataObjectInterface, attempts to generate an interable of * $modelClass with data given * @param $modelClass - Should be any kind of ORM entity or object class representing a model in DDBB * @param ApiDataObjectInterface $data * @return iterable * @throws ApiDataMapperException */ public function mapToModelList($modelClass, ApiDataObjectInterface $data): iterable
-
/** * Given an instance of a model and an ApiDataObjectInterface, attempt to refresh the model with data given * @param object $instance * @param ApiDataObjectInterface $data * @throws ApiDataMapperException */ public function refreshModel(object $instance, ApiDataObjectInterface $data): void
-
/** * Refreshes instance $to using instance $from * If $strict is true and the instances are not the same class then an exception is thrown * @param object $to, * @param object $from * @param bool $strict * @throws ApiDataMapperException */ public function refreshModelFromOtherModel(object $to, object $from, bool $strict = false): void
-
-
模型映射
-
/** * Tell to this model map that should generate multiple instances by using $arrayField as field list * @param string $arrayField * @return ModelMap */ public function setMultiple(string $arrayField): self
-
/** * Tell to this model map that don't generate multiple instances. * This methods unset the arrayField if was stablished using #setMultiple method */ public function unsetMultiple(): self
-
/** * Check if this model map is configured to process and return a multiple instances */ public function isMultiple(): bool
-
/** * Given an associative array with key as externalField and a value as modelField, * tries to make the association for this model map * @param array $fieldMap * @return $this * @throws Exception */ public function mapFields(array $fieldMap): self
-
/** * Same as mapFields but for list fields */ public function mapListFields(array $listFieldMap): self
-
/** * Given a model field names, ignores the fields on field interpreting process if model instance * field has value different from null or empty * @param iterable|string $fields * @return $this */ public function ignoreFieldsIfSet($fields): ModelMap
-
/** * Given model fields, remove from ignore field list, the specified model fields * @param $fields * @return $this */ public function unignoreFieldsIfSet($fields): ModelMap
-
/** * Allows to register external components to extends the functionality of the model map language * The components allowed are: FieldFilter, AggregateFunction and ModelMapFunction * @param FieldFilter|FieldFilter[]|AggregateFunction|AggregateFunction[]|ModelMapFunction|ModelMapFunction[] $components */ public function use($components): self
-
/** * * ACCESSIBLE ONLY FROM GlobalApiDataMapper. It uses HasModelMapFactory Trait. * * Allows to access the model map for specified $modelClass * Returns a HighOrderModelMapConfiguration to allow chain access * between model map and api data mapper * @param string $modelClass * @return HighOrderModelMapConfiguration */ public function configure($modelClass): HighOrderModelMapConfiguration
基本上,你会从这个类中创建每个实体的类。
例如,假设我们有一个Warrior实体。这可以是一个模型映射配置
use didix16\Api\ApiDataMapper\ModelMap; class WarriorModelMap extends ModelMap { public function __construct() { parent::__construct(); $this // configured single fields ->mapFields([ 'warrior.name' => 'name', 'warrior.is_active:boolean' => 'active', 'warrior.weapon' => 'weapon', 'warrior.comes_from' => 'place' ]) // configuring map list fields when coming from a list but not using #setMultiple() method here ->mapListFields([ 'name' => 'name', 'is_active:boolean' => 'active', 'weapon' => 'weapon' ]); } }
-
-
模型映射工厂
-
/** * Given a $modelClass, returns a new instance of a ModelMapInterface * that is related to the $modelClass */ public static function build($modelClass): ModelMapInterface
-
-
字段过滤器
-
# EXTENDS THIS CLASS /** * The name this filter has * Must be the same on syntax field language */ public function __construct($name);
-
/** * Gets a $value and transform it into something else */ protected function transform(&$value);
-
-
AggregateFunction
-
# EXTENDS THIS CLASS /** * The list you want to iterate */ protected $iterable = []; /** * The field name from every object inside the list. If null means we only * iterate over list elements, * else each elem shuld be an object */ protected $field = null; /** * Do this allways! */ protected function run(...$args){ parent::run($args); // do whatever you want from here }
-
-
ApiDataObject
用示例更好地解释
class ApiPlatformDataObject extends ApiDataObject {} $json = <<<JSON { "property1": "value1", "property2": "value2", ... } JSON; $apiData = ApiPlatformDataObject::fromJson($json); /** * Different accessors */ $apiData['property1']; $apiData->property1; $apiData->property1(); /** * Different setters */ $apiData['property1'] = 'value5'; $apiData->property1 = 'value5'; // chainable setter properties $apiData ->property1('value5') ->property2('value6') ...
$data = [ 'property1' => 'value1', 'property2' => 'value2', ... ]; /** * Instantiate from an array */ $apiData = new ApiPlatformDataObject($data); $data = (object)[ 'property1' => (object)[ 'objProp1' => 1, 'objProp2' => 2, ... ], 'property2' => 'value2', ... ]; /** * Instantiate from an object */ $apiData = new ApiPlatformDataObject($data);
请随意从任何来源实例化你的API数据对象,并记住将其转换为有效的PHP数据。
例如,你可以从XML中读取并将其转换为数组或对象。这可以在从XML()的静态函数中完成
class MyXMLApiDataObject extends ApiDataObject { public static fromXML($xml): MyXMLApiDataObject { // parse your XML // ... or whatever ... $list = $xmlParsed; return new static($list); } }
你知道我的意思,让你的想象力飞起来 ;)
示例
你可以在examples文件夹中找到一些示例
你应该看看
- index.php
- FieldInterpreter.php
它们已经准备好了,所以如果你在安装后打开终端
php vendor/didix16/php-apidatamapper/examples/index.php
或者
php vendor/didix16/php-apidatamapper/examples/FieldInterpreter.php
你应该能看到一些示例的结果
致谢
请随意使用这个库,但不要忘记提到我是所有者 :).
对不起我的英语很差。我会在未来尝试纠正任何语法错误。
也请随意向我发送问题、报告错误、建议等。