Pull to refresh

PHP и OData: пересаживаемся с велосипедов на технологию от Microsoft

Reading time15 min
Views23K
Нынче модно делать API и многие из нас уже реализовывали какие-то API на PHP. Одна из задач REST API — отдавать наборы данных, чтобы их в конечном итоге отобразить в табличном виде. Для этого, помимо прочего, приходится решать такие задачи:

  • провалидировать запрос,
  • отфильтровать данные,
  • отсортировать данные,
  • запрашивать и отдавать не все колонки, а только некоторые,
  • реализовать пагинацию.

Не знаю как вы, но я вижу, что часто это делается велосипедными решениями. Задачи с виду не сложные, но чтобы их решить качественно, приходится потратить немало времени на разработку, документацию и разъяснения коллегам, как работает ваше изобретение. Я расскажу о том, как можно реализовать эти задачи весьма технологично с помощью OData.

image

Я понимаю, что многим, кто дружит с LAMP, чужды веяния вражеских фронтов всевозможных Microsoft и Windows. Но давайте сперва посмотрим, что такое OData.

Что такое OData


Как написано на официальном сайте,
OData — the best way to REST

Вот такое не отличающееся скромностью короткое определение. Еще там сказано, что это открытый протокол и что с его помощью можно делать RESTful API простым и стандартным способом. Дальше вы увидите, что часть из этого сущая правда и что в этом есть сила.

Примечательно то, что этот стандарт продвигает не только Microsoft. Стандарт одобрен организацией OASIS. Стандарт повсеместно используется в продуктах Microsoft и во многих больших системах других компаний.

Чем полезен OData


Если вы реализовали обмен данными между клиентской и серверной частью вашего приложения по протоколу OData, то тем, кто будет использовать протокол, достаточно дать ссылку на ваш сервис. Коллегам, которые будут использовать ваш сервис, достаточно ознакомиться со стандартным форматом URL для доступа к вашим коллекциям.

Кроме этого, у вас появляется возможность сэкономить время на разработке фронтэнда. Для этого можно взять готовые библиотеки, которые умеют работать с OData. Например, Kendo UI или бесплатный OpenUI5 от SAP.

Это позволяет в течении грубо говоря часа создать хороший технологичный каркас для приложения, куда останется добавить специфичную для вашей задачи бизнес логику.

Но почему тогда OData не используется в проектах на PHP


Или почти не используется. Действительно, я старательно погуглил на тему PHP+OData, но нашел не так уж и много. А то, что я нашел, заставило меня поломать голову — как добиться, чтобы это заработало.

С точки зрения реализации можно выделить две важные составляющие компоненты — клиент (запрашивает данные) и сервер (отдает данные). Далее речь пойдет о реализации серверной части.

Что удалось найти. Есть отличный проект от Microsoft, с открытыми исходниками, он выложен прямо на гитхабе: odataphpprod. По сути, это фреймворк для создания серверной части на PHP, которая работает по протоколу OData.

Я попытался его использовать и сразу столкнулся с двумя препятствиями. Во-первых, проект не заработал на Linux, хотя в readme проекта сказано, что поддерживаются обе системы — Windows и Linux. Чтобы проект заработал на Linux, пришлось в исходниках править места, где подключаются файлики — там путаница в путях и регистрах. Во вторых, чтобы реализовать простую отдачу списка, пришлось почитать не самую короткую инструкцию.

Кроме того, за последние 3 года в этом проекте не было ни одного коммита.

Я продолжил поиск, но не нашел других достойных реализаций, кроме форка вышеупомянутого проекта. Форк называется POData. В readme этого проекта сказано, что PHP разработчики — бедняжки, потому что у них нет хорошего инструментария для работы с OData, но библиотека берется нивелировать это недоразумение и привнести преимущества OData в массы. Я попробовал использовать этот форк, с ним дело пошло гораздо лучше. Проект заработал на Linux без дополнительных усилий. Там же есть и пошаговая простая дока, как использовать проект вместе с Zend Framework.

Я попробовал использовать POData для своих нужд, но и в этом случае столкнулся с мелкими неприятностями. В частности, не удавалось подружить POData с гридом от OpenUI5, пока не внес несколько мелких правок в POData. Да и много кода пришлось-таки писать самому. Причем большая часть этого кода вполне многоразовая и могла быть частью фреймворка.

В общем, я вижу две причины, почему PHP разработчики до сих пор не дружат с OData:

  • порог вхождения — много кода дописывать, документация не очень простая, мало пошаговых примеров,
  • сыроватые инструменты.

Моя попытка снизить порог вхождения и пример OData-сервиса


Как я уже сообщил выше, много кода, который приходится реализовывать для работы с POData, вполне многоразовый и с большой вероятностью будет переноситься из проекта в проект без изменений. Поэтому я вынес этот код в отдельную библиотеку SimplePOData. Это реализация IQueryProvider (для реляционных SQL баз типа MySQL) и IService.

Далее — пример построения серверной части на чистом PHP без использования каких-либо фреймворков, кроме разве что самого POData.

Шаг 0. Устанавливаем необходимые библиотеки

Создайте каталог www/odata-example для первого проекта с OData. Создайте файл composer.json со следующим содержимым:

{
    "require": {
        "qeti/simple-podata": ">=0.9.1"
    }
}

Небольшое лирическое отступление. Проект POData/POData на гитхабе — по большому счету хороший рабочий проект. Но, судя по данным гитхаба, активная работа над ним закончилась два года назад. Чтобы использовать проект под свои нужды, я внес небольшие доработки, отправил пулл реквесты, но автор проекта не ответил до сих пор, даже когда я попытался связаться с ним через социальную сеть. Я надеюсь, что он выйдет на связь. Но пока что я прописал в SimplePOData свой форк Qeti/POData, куда закомитил свои изменения. Для удобства все добавил в Packagist. Если автор POData выйдет на связь и будет активно принимать изменения, то смысла в еще одном форке не будет и я переключусь на его форк.

Итак, чтобы установить необходимые пакеты, выполняем из консоли:

composer install

Шаг 1. Работа с URL

Описание метаданных будет доступно по адресу localhost/odata-example/odata.svc$metadata.
Значит, нам надо сделать, чтобы все запросы к нашему OData сервису (localhost/odata-example/odata.svc) шли на index.php. Для этого в корне проекта разместите файл .htaccess со следующим содержимым:

<IfModule mod_rewrite.c>
    RewriteEngine on
    RewriteRule    (\.svc.*) index.php
</IfModule>

Всю остальную работу по разбору URL возьмет на себя POData.

Шаг 2. Реализация IHTTPRequest

Мы должны реализовать интерфейс IHTTPRequest. Этот класс будет использовать POData для того, чтобы получить параметры URL. Реализация может различаться в зависимости от используемого фреймворка. Мы пойдем простым путем и будем передавать в конструктор $_GET. Создайте файл RequestAdapter.php со следующим содержимым:

<?php

use POData\Common\ODataConstants;
use POData\OperationContext\HTTPRequestMethod;
use POData\OperationContext\IHTTPRequest;

class RequestAdapter implements IHTTPRequest
{
    protected $request;

    public function __construct($request)
    {
        $this->request = $request;
    }

    /**
     * get the raw incoming url
     *
     * @return string RequestURI called by User with the value of QueryString
     */
    public function getRawUrl()
    {
        return $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'] . '/' . $_SERVER['REQUEST_URI'];
    }

    /**
     * get the specific request headers
     *
     * @param string $key The header name
     *
     * @return string|null value of the header, NULL if header is absent.
     */
    public function getRequestHeader($key)
    {
        if (isset($this->request[$key])) {
            return $headers = $this->request[$key];
        }
        return null;
    }

    /**
     * Returns the Query String Parameters (QSPs) as an array of KEY-VALUE pairs.  If a QSP appears twice
     * it will have two entries in this array
     *
     * @return array[]
     */
    public function getQueryParameters()
    {
        $data = [];
        if (is_array($this->request)) {
            foreach ($this->request as $key => $value) {
                $data[] = [$key => $value];
            }
        }
        return $data;
    }

    /**
     * Get the HTTP method/verb of the HTTP Request
     *
     * @return HTTPRequestMethod
     */
    public function getMethod()
    {
        return new HTTPRequestMethod('GET');
    }
}

Шаг 3. Реализация IOperationContext

Этот класс еще проще, он должен реализовать всего два метода — возвращать объекты запроса и ответа. Объект запроса — это экземпляр вышеописанного класса. Объект ответа — экземпляр OutgoingResponse, который уже реализован в POData так что с этим классом можно не разбираться на данном этапе. Создайте файл OperationContextAdapter.php:

<?php

use POData\OperationContext\IHTTPRequest;
use POData\OperationContext\IOperationContext;
use POData\OperationContext\Web\OutgoingResponse;

class OperationContextAdapter implements IOperationContext
{
    /**
     * @var RequestAdapter;
     */
    protected $request;

    protected $response;

    /**
     * @param yii\base\Request $request
     */
    public function __construct($request)
    {
        $this->request = new RequestAdapter($request);
        $this->response = new OutgoingResponse();
    }

    /**
     * Gets the Web request context for the request being sent.
     *
     * @return OutgoingResponse reference of OutgoingResponse object
     */
    public function outgoingResponse()
    {
        return $this->response;
    }

    /**
     * Gets the Web request context for the request being received.
     *
     * @return IHTTPRequest reference of IncomingRequest object
     */
    public function incomingRequest()
    {
        return $this->request;
    }
}

Шаг 4. Реализация IQueryProvider

Задача этого класса — выбрать данные из вашего источника. Насколько я понял из документации к фреймворку, разработчики предлагают реализовать в этом классе не только функциональность для извлечения данных, но мэппинг источника данных с названиями таблиц и колонок в базе. В принципе, в большинстве случаев названия таблиц будут явно соответствовать названию сервисов в URL-ах. Поэтому в SimplePOData реализованы необходимые методы, но наложено ограничение. Название сервиса в URL DocumentHasProduct будет преобразовано в название таблицы document_has_product. Если вас это не устраивает, то можете переопределить метод getTableName().

Все, что вам остается сделать — реализовать метод получения множества строк и метод получения одного значения из вашего источника данных. В нашем примере мы будем работать с PDO. Создайте файл QueryProvider.php:

<?php

use qeti\SimplePOData\BaseQueryProvider;

class QueryProvider extends BaseQueryProvider
{
    public function __construct(\PDO $db){
        parent::__construct($db);
    }

    /**
     * Get associated array with rows
     * @param string $sql SQL query
     * @param array $parameters Parameters for SQL query
     * @return mixed[]|null
     */
    protected function queryAll($sql, $parameters = null)
    {
        $statement = $this->db->prepare($sql);
        $statement->execute($parameters);
        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Get one value
     * @param string $sql SQL query
     * @param array $parameters Parameters for SQL query
     * @return mixed|null
     */
    protected function queryScalar($sql, $parameters = null)
    {
        $statement = $this->db->prepare($sql);
        $statement->execute($parameters);
        $data = $statement->fetchAll(PDO::FETCH_COLUMN);
        if ($data) {
            return $data[0];
        }
        return null;
    }

}

Шаг 5. Классы, описывающие данные

С базовыми вещами мы разобрались, приступаем к описанию конкретных данных. Для примера создайте в базе таблицу product:

CREATE TABLE product (
  id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  added_at TIMESTAMP DEFAULT NOW(),
  name VARCHAR(250),
  weight DECIMAL(10, 4),
  code VARCHAR(45)
);

Добавьте в нее тестовых данных
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (6,'2013-05-07 00:00:00','Kedi',2.9200,'Ked-25');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (9,'2009-08-05 00:00:00','Kedi',10.9100,'Ked-51');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (13,'2003-02-27 00:00:00','Kedi',11.7300,'Ked-17');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (29,'2014-12-19 00:00:00','Kedi',7.6100,'Ked-29');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (33,'2003-07-05 00:00:00','Kedi',11.8700,'Ked-99');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (36,'2015-09-15 00:00:00','Kedi',11.0000,'Ked-89');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (40,'2004-01-25 00:00:00','Kedi',14.8800,'Ked-83');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (47,'2006-04-23 00:00:00','Kedi',1.2100,'Ked-62');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (51,'2012-12-08 00:00:00','Kedi',12.4000,'Ked-86');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (54,'2010-06-09 00:00:00','Kedi',6.3800,'Ked-61');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (58,'2010-04-25 00:00:00','Kedi',8.8900,'Ked-74');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (106,'2004-04-11 00:00:00','Kedi',6.7100,'Ked-44');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (134,'2001-02-07 00:00:00','Kedi',2.3200,'Ked-29');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (153,'2002-01-13 00:00:00','Kedi',7.3300,'Ked-80');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (156,'2014-03-20 00:00:00','Kedi',10.9600,'Ked-30');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (165,'2003-07-11 00:00:00','Kedi',2.5300,'Ked-90');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (176,'2010-09-26 00:00:00','Kedi',7.0100,'Ked-38');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (182,'2007-05-07 00:00:00','Kedi',3.8900,'Ked-6');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (194,'2004-03-21 00:00:00','Kedi',3.1000,'Ked-20');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (205,'2000-06-02 00:00:00','Kedi',12.9500,'Ked-20');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (212,'2002-02-20 00:00:00','Kedi',2.5300,'Ked-62');
INSERT INTO `product` (`id`,`added_at`,`name`,`weight`,`code`) VALUES (220,'2000-10-19 00:00:00','Kedi',8.4000,'Ked-31');


И опишите класс для этой таблицы, создайте файл models/Product.php:

<?php

namespace models;

use qeti\SimplePOData\EntityTrait;

class Product {

    // This trait contains method for fields mapping (between database table and this class)
    use EntityTrait;

    public $id;
    public $added_at;
    public $name;
    public $weight;
    public $code;
}

Как видно, все, что есть в этом классе — перечисление полей таблицы product и подключение трейта EntityTrait, в котором реализован мэппинг названий свойств класса и полей базы. В этой реализации названия полей БД точно соответствует названию свойств класса. Те, кого это по каким-то причинам не устраивает, могут просто сделать другую реализацию статического метода fromRecord().

Шаг 6. Метаданные

Теперь нам надо описать наши данные, чтобы сервис понимал, какого типа эти данные и как они связаны друг с другом.

Создайте файл models/MetadataProvider.php:

<?php

namespace models;

use POData\Providers\Metadata\Type\EdmPrimitiveType;
use POData\Providers\Metadata\SimpleMetadataProvider;

class MetadataProvider
{
    const MetaNamespace = "Data";

    /**
     * Description of service
     *
     * @return IMetadataProvider
     */
    public static function create()
    {
        $metadata = new SimpleMetadataProvider('Data', self::MetaNamespace);
        $metadata->addResourceSet('Products', self::createProductEntityType($metadata));
        return $metadata;
    }

    /**
     * Describtion of Products
     */
    private static function createProductEntityType(SimpleMetadataProvider $metadata)
    {
        $et = $metadata->addEntityType(new \ReflectionClass('\models\Product'), 'Products', self::MetaNamespace);

        $metadata->addKeyProperty($et, 'id', EdmPrimitiveType::INT32); 
        $metadata->addPrimitiveProperty($et, 'added_at', EdmPrimitiveType::DATETIME);
        $metadata->addPrimitiveProperty($et, 'name', EdmPrimitiveType::STRING);
        $metadata->addPrimitiveProperty($et, 'weight', EdmPrimitiveType::DECIMAL);
        $metadata->addPrimitiveProperty($et, 'code', EdmPrimitiveType::STRING);

        return $et;
    }

}

Тут мы описали, что у нас есть коллекция Products (localhost/odata-example/odata.svc/Products) и какие в ней есть поля.
addKeyProperty() определяет ключевое поле. Это поле используется для фильтрации, когда вы выбираете конкретную запись, запрашивая localhost/odata-example/odata.svc/Products(1). addPrimitiveProperty() определяет обычное поле.

Шаг 7. index.php

Ну вот и все. Остается создать index.php, в котором надо подключить созданные классы, создать соединение с базой и попросить POData обработать запрос.

<?php

use POData\OperationContext\ServiceHost;
use qeti\SimplePOData\DataService;

require(__DIR__ . '/vendor/autoload.php');

require(__DIR__ . '/OperationContextAdapter.php');
require(__DIR__ . '/RequestAdapter.php');
require(__DIR__ . '/QueryProvider.php');
require(__DIR__ . '/models/MetadataProvider.php');
require(__DIR__ . '/models/Product.php');

// DB Connection
$dsn = 'mysql:dbname=yourdbname;host=127.0.0.1';
$user = 'username';
$password = 'password;
$db = new \PDO($dsn, $user, $password);

// Realisation of QueryProvider
$db->queryProviderClassName = '\\QueryProvider';

// Controller
$op = new OperationContextAdapter($_GET);
$host = new ServiceHost($op);
$host->setServiceUri("/odata.svc/");
$service = new DataService($db, \models\MetadataProvider::create());
$service->setHost($host);
$service->handleRequest();
$odataResponse = $op->outgoingResponse();

// Headers for response
foreach ($odataResponse->getHeaders() as $headerName => $headerValue) {
    if (!is_null($headerValue)) {
        header($headerName . ': ' . $headerValue);
    }
}

// Body of response
echo $odataResponse->getStream();

Что получили в результе


В результате проделанного выше получаем сервис, который умеет обрабатывать такие запросы.

odata.svc
Возвращает список коллекций

<service xmlns:atom="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app" xmlns="http://www.w3.org/2007/app"xml:base="http://localhost:80/OData-base-example/odata.svc">
<workspace>
<atom:title>Default</atom:title>
<collection href="Products">
<atom:title>Products</atom:title>
</collection>
</workspace>
</service>

odata.svc/$metadata возвращает описание сущностей.

<edmx:Edmx xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx" Version="1.0">
<edmx:DataServices xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx"m:DataServiceVersion="1.0">
<Schema xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"xmlns="http://schemas.microsoft.com/ado/2007/05/edm" Namespace="Data">
<EntityType Name="Products">
<Key>
<PropertyRef Name="id"/>
</Key>
<Property Name="id" Type="Edm.Int32" Nullable="false"/>
<Property Name="added_at" Type="Edm.DateTime" Nullable="true"/>
<Property Name="name" Type="Edm.String" Nullable="true"/>
<Property Name="weight" Type="Edm.Decimal" Nullable="true"/>
<Property Name="code" Type="Edm.String" Nullable="true"/>
</EntityType>
<EntityContainer Name="Data" m:IsDefaultEntityContainer="true">
<EntitySet Name="Products" EntityType="Data.Products"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>

odata.svc/Products возвращает все записи из коллекции Products. Если это большая коллекция, то так вызывать не стоит — лучше ограничивать выборку. Наример, если вызвать odata.svc/Products?&$format=json&$filter=id le 5&$orderby=id desc,
то произойдет следующее. Будут выбраны записи с id не более 5, данные отсортируются по id в обратном порядке. Результат будет отдан в формате json:

{"odata.metadata":"http://localhost:80/odata-example/odata.svc/$metadata#Products","value":
[{"id":5,"added_at":"2006-07-14T00:00:00","name":"Kon","weight":"14.1700","code":"Kon-59"},
{"id":4,"added_at":"2014-03-16T00:00:00","name":"Kon","weight":"2.4100","code":"Kon-89"},
{"id":3,"added_at":"2009-07-23T00:00:00","name":"Bicycle","weight":"4.3100","code":"Bic-18"},
{"id":2,"added_at":"2000-03-25T00:00:00","name":"Samokat","weight":"8.0200","code":"Sam-96"},
{"id":1,"added_at":"2006-10-22T00:00:00","name":"Kolyaska","weight":"10.1300","code":"Kol-97"}]}

Насчет $filter, тут стоит обратить отдельное внимание, что условия можно накручивать непростые, использовать скобки, операторы and, or и многое другое. При разборе условия выполняются необходимые проверки и исключается возможность внедрения SQL-инъекций.

Пример организации пагинации и выбора только указанных колонок: odata.svc/Products?$skip=10&$top=5&$format=json&$select=id,name&$inlinecount=allpages

{"odata.metadata":"http://localhost:80/odata-example/odata.svc/$metadata#Products", "odata.count":"1002", "value":
[{"id":11,"name":"Motoroller"},
{"id":12,"name":"Kolyaska"},
{"id":13,"name":"Kedi"},
{"id":14,"name":"Roliki"},
{"id":15,"name":"Doska"}]}

При указании $inlinecount=allpages вы получаете в ответе в поле odata.count количество записей в выборке, как если бы не применялся оператор LIMIT в SQL запросе.

odata.svc/Products(5)?$format=json
Возвращает данные о продукте с id=5
{
  "odata.metadata":"http://localhost:80/odata-example/odata.svc/$metadata#Products/@Element",
  "id":1, "added_at":"2006-10-22T00:00:00", "name":"Kolyaska", "weight":"10.1300", "code":"Kol-97"
}

odata.svc/Products/$count — количество записей.

Тем, кто не хочет делать копи-паст


Если вы заинтересовались использованием OData в своем проекте на PHP, хотите сделать примерчик, но не хотите копипастить, можете поступить еще проще. Вышеописанный пример есть на гитхабе — следуйте инструкциям из readme.

Бонус


А если вы хотите живого подтверждения тому, что теперь можно ничего не программируя получить готовую реализацию фронтэнда, то загляните в этот пример. Можете сортировать по колонкам, использовать пагинацию — все работает. Вот как это будет выглядеть:

image

Резюме


Что хорошего

  • Оказывается, рабочая реализация серверной части OData есть и на PHP.
  • Позволяет сэкономить немало сил при разработке фронтэнда.
  • Готовая реализация первичной валидации, фильтрации и сортировки данных, пагинация.
  • Защита от SQL инъекций.

Какие есть минусы

  • Пока что в POData есть поддержка только GET запросов.
  • На данный момент маловато примеров использования.

Лично я, взвесив плюсы и минусы, склоняюсь к тому, что технологию стоит использовать в своих проектах. Если в POData чего-то не хватает (например, INSERT, UPDATE, DELETE операций), то туда несложно добавить недостающее. Надеюсь, что и вам удастся извлечь для себя пользу из материала.

Мне хотелось бы получить побольше обратной связи. Будете ли работать с OData в своих проектах? Что думаете о вышесказанном?
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 12: ↑8 and ↓4+4
Comments31

Articles