Архитектура агрегаторов: паттерны веб-сервисов (Часть 1)

Сегодня создано много веб приложений и сервисов, у которых одинаковая цель, но различный подход к исполнению. Так как информация разбросана по сети, пользователям приходится посещать множество аналогичных сервисов для того, чтобы увеличить эффект работы. К примеру, заказчик хочет разместить задачу на тендерной площадке. Для того, чтобы увеличить количество поданных заявок, он тратит время на повторяющуюся работу: создание офера и заполнение данных о проекте — на различных фриланс-биржах. Появляются сайты агрегаторы, которые пытаются решить эту проблему, но их поддержка становится все более затруднительной с появлением новых сервисов тематики агрегатора. Необходимо интегрировать все новые функции, и структуры данных, которые отличаются от сервиса к сервису. К счастью, мы не первые, кто создает и поддерживает подобные вещи: уже существуют паттерны, которые упрощают поддержку таких приложений и позволяют создавать гибкую архитектуру. В этой статье я хотел бы привести пример архитектуры агрегатора, который позволяет объединить тендерные площадки для фрилансеров — такие как Odesk, Freelancer, Elance и другие.

Основные проблемы с которыми сталкиваются разработчики:

  1. Разные принципы работы с сервисами: некоторые из них предоставляют API, а с другими приходится работать посредством бота — имитации действий человека.
  2. Неоднородность ответов от сервисов — одни возвращают json, другие — xml, третьи — веб страницу.
  3. Вытекает из 2 — неоднородность структур данных — например, структура объекта Project
    c freelancer выглядит так: 

    <?xml version="1.0" encoding="UTF-8"?>
    <xml-result xmlns="http://api.freelancer.com/schemas/xml-0.1">
        <id>588582</id>
        <name>sign design web software</name>
        <url>http://www.sandbox.freelancer.com/projects/PHP-ASP/sign-design-web-software.html</url>
        <buyer>
            <url>http://www.sandbox.freelancer.com/users/1353095.html</url>
            <id>1353095</id>
            <username>billk89</username>
        </buyer>
        <short_descr>Create and upload sign design web software for our client nlsigndesign.com Public internet users must be able to create there own signs online, then save and submit as a file. 
        </short_descr>
        <jobs>
            <job>PHP</job>
        </jobs>
        ...
    </xml-result>

     

    в то время как Odesk отвечает следующим образом:

    <response>
        ....
        <profile>
              ....
              <buyer>
                <cnt_assignments>0</cnt_assignments>
                <op_contract_date>December 18, 2011</op_contract_date>
                <timezone>Russia (UTC+06)</timezone>
              </buyer>
              <op_title>A social network client app for iPhone/iPad</op_title>
              <ciphertext>~~05d405f2d5b8eb27</ciphertext>
               ....
              <op_required_skills>
                <op_required_skill>
                  <skill>ipad,ui-design,iphone-development</skill>
                </op_required_skill>
              </op_required_skills>
                ....
              <op_desc_digest>
            Hello, We need a social network client app for iPhone/iPad to be developed. It should support Facebook, Twitter and Linked-In.
              </op_desc_digest>
                ....
        </profile>
    </response>

     

Для простоты понимания, я решил описать решение каждой из проблем в отдельной статье. В данной статье я покажу, как унифицировать работу с различными сервисами на примере двух популярных фриланс бирж Odesk и Freelancer. Исходный код написан на языке PHP5 с использованием фреймворка Yii.

Создаем интерфейс

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

interface ServiceInterface
{
    public function authorize();

    public function getServiceName();

    public function setCredential(ModelCredential $credential);

    public function searchProjects();
}

 

Создаем сервис адаптеры

Далее, для каждого сервиса мы создаем сервис адаптеры (вариация паттерна «Adapter» применительно к веб сервисам), которые будут реализовывать этот интерфейс. Оба класса работают с сервисами через API, но скрывают разницу в реализации вызовов.

Freelancer Service Adapter:

class FreelancerService implements ServiceInterface
{
    private $_serviceName = 'freelancer';

    private $_service = null;

    public function __construct(ModelCredential $credential = null) {
        Yii::import('ext.freelancer-api-wrapper.*');
        $this->_service = new Freelancer(Yii::app()->params['freelancer']['token'],Yii::app()->params['freelancer']['secret'], 'http://geeks-board.local/authorizeService/freelancer/?');
        if($credential !== null) {
            $this->setCredential($credential);
        }
    }

    public function authorize() {
        if(!isset($_GET['oauth_token'])) {
            $requestToken = $this->_service->requestRequestToken();
            $redirectUrl = $this->_service->getRedirectUrl($requestToken);
            header('Location: ' . $redirectUrl);
        } else {
            $oauth_verifier = $this->_service->getRequestTokenVerifier($_GET['oauth_token']);
            $auth = $this->_service->requestAccessToken($oauth_verifier,$_GET['oauth_token']);
            $this->_service->oauth->setToken($auth['oauth_token'],$auth['oauth_token_secret']);
            $this->_service->auth = $auth;
            return $auth;
        }
        return false;
    }

    public function getServiceName() {
       ....
    }
    public function setCredential(ModelCredential $credential) {
       ....
    }
    public function searchProjects() {
       ....
    }
}

 

Odesk Service Adapter:

class OdeskService implements ServiceInterface
{
    private $_serviceName = 'odesk';

    private $_service = null;

    public function __construct(ModelCredential $credential = null) {
        Yii::import('ext.odesk-api.*');
        Yii::import('classes.mapper.service.RequestMapper');
        $this->_service = new oDeskAPI(Yii::app()->params['odesk']['secret'], Yii::app()->params['odesk']['token']);
        if($credential !== null) {
            $this->setCredential($credential);
        }
    }

    public function authorize() {
        return $this->_service->auth();
    }
    public function getServiceName() {
       ....
    }
    public function setCredential(ModelCredential $credential) {
       ....
    }
    public function searchProjects() {
       ....
    }
}

 

Обратите внимание, что оба класса имплементируют интерфейс ServiceInterface, но реализация метода authorize различна для каждого. Тут необходимо упомянуть также модель ModelCredential, которая хранит данные для авторизации. При Oauth авторизации это token и secret.

Создаем фабрику

В будущем нам необходимо легко добавлять новые сервисы, не изменяя кода существующих классов (Следуем OCP принципу). Для этого воспользуемся паттерном «Factory Method».

class ServiceFactory
{
    public static function create($serviceName, $credentials = null)  {
        $className = ucfirst(strtolower($serviceName)).'Service';
        Yii::import('classes.service.'.$className);
        $serviceObject = (is_null($credentials)) ? new $className() : new $className($credentials);
        if(!($serviceObject instanceof ServiceInterface)) {
            throw new Exception('Not an instance of Service');
        }
        return $serviceObject;
    }
}

 

Мы также добавили проверку на то, что данный класс реализует ServiceInterface. Для чего здесь используется интерфейс и проверка на его реализацию классами сервисов? В случае, если разработчик ошибется и забудет имплементировать какой либо метод интерфейса, php не даст возможности запустить код в принципе. Это дает нам уверенность в том, что метод реализован. Также это дает понимание, какие конкретно методы необходимы будут системе для работы. По этому поводу поделюсь своей историей.
На одном из проектов, над которым я работал, была поставлена задача имплементировать сервис адаптер для Google+. В проект уже были интегрированы Facebook и Twitter адаптеры. Когда я открыл класс одного из них, я ужаснулся от количества кода внутри. Я не понимал, какие из методов мне необходимо реализовать, для того чтобы сервис заработал, а какие были вторичными. Пришлось сравнивать несколько классов, уточнять у тех разработчиков, которые писали этот код. Это заняло время. Если бы у нас был один интерфейс для таких сервис адаптеров — было бы сразу понятно какие из методов нужно было создать.

Собираем все вместе

Итак, мы подготовили классы сервис-адаптеры и фабрику, которая будет создавать их. Давайте посмотрим как эти части работают вместе:

Yii::import('application.models.ModelCredential');
        //Для того чтобы работать с общей информацией - такой как проекты, нам необходимы публичные Credentials - не принадлежащие 
        // ни одному пользователю в системе.
        $credentialsRecords = ModelCredential::model()->findAllByAttributes(array(
            'type' => 'public'
        ));

        if(!empty($credentialsRecords)) {

            Yii::import('classes.service.ServiceFactory');

            $aggregatedProjects = array();
            foreach($credentialsRecords as $serviceCredential) {
                try {
            // Используя нашу фабрику создаем сервис адаптер соответствующий указанному в credentials.
                    $service = ServiceFactory::create($serviceCredential->service, $serviceCredential);
                    $foundProjects =  $service->searchProjects(); // унифицированный вызов метода для всех сервисов

                    if(is_array($foundProjects))
                        $aggregatedProjects = array_merge($aggregatedProjects,$foundProjects);

                } catch(Exception $e) {
                    print_r($e);
                }
            }
        } else {
            echo 'empty '. "\n";
        }

 

Итог

Данная архитектура позволяет интегрировать новые сервисы более прозрачно и разработчику нет необходимости помнить различия в названиях методов или их вызовах.

В следующей статье я расскажу, что делать с неоднородностью возвращаемых ответов и структур данных.