Pull to refresh

HOWTO: Одна из возможных реализация Модели (MVC) в Zend Framework

Reading time 15 min
Views 16K
Написание статьи навеяно habrahabr.ru/qa/34735 и habrahabr.ru/qa/32135 вопросами, в качестве ответов на которые не смог найти полной и подробной информации, чего очень не хватало. Я надеюсь, что она будет полезна и другим.

Проект, на чью долю пал выбор в виде ZF в качестве основного фреймворка, представлял из себя мобильную версию сервиса (адаптивный дизайн с некоторыми ньюансами) + АПИ для мобильных приложений.
Коллегиально было принято политико-техническое решение делать единое АПИ, посредством которого будет общаться и сайт, и приложения.

На этом, думаю, прелюдию можно закончить и перейти к самому интересному.

Статью для удобства разделил на 2 части. В первой части содержится немного теории, мыслей и отсылок к различным источникам. Во второй части я постарался подробно (с примерами кода) показать, как я реализовал свой вариант архитектуры.

Немного теории


Начало

Zend Framework — не самый простой с точки зрения порога входа. Я потратил достаточно времени, чтобы вникнуть в его идеологию, но после этого, каждый следующий шаг ожидаем, предсказуем и логичен.

Несмотря на достаточное количество официальной документации, в некоторых местах она достаточно ужата (коллеги даже деликатно прозвали ее «документация в стиле Тви»), и очень многое приходится брать из изучения исходников.

Сразу хочу обратить внимание, тех, кто говорит о монструозности сего чуда — да, зенд достаточно большая, масштабная пушка, из которых на первый взгляд… по воробьям то и не к чему… Но присмотревшись и изучив его особенности даже поверхностно, я могу добавить, что калибр этой пушки очень даже настраивается. Есть достаточно неплохо работающий autoloader, который позволяет подключать минимальный набор классов.

Собрав каркас тестового приложения (quick start) начал процесс проектирования архитектуры при активном изучении возможностей, рекомендаций и best practice разработки на ZF (очень понравилась презентация, почерпнул из нее много мыслей, по тексту на нее еще будут ссылки).

Модель в MVC

Многие воспринимают и описывают модель как способ доступа к данным на уровне базы данных, но это не совсем верно. Модель — это не реляционная база данных, и даже не таблица. Данные могут приходить из разных источников.
Я рассмотрел модель как многоуровневую прослойку и выделил для себя 3 слоя:
  • Domain Model
  • Data Mapper
  • Data Access Layer (DAL)

Domain Model — описание метаданных объекта, включая геттеры, сеттеры, валидацию данных и описание поведения объекта (behavior). Бытует мнение, что описание поведение также можно вынести в прослойку DomainEvents, и это есть нечто иное как Table Data Gateway pattern.
Этот уровень в моей реализации ничего не знает о способах (и местах) хранения данных.

Data Mapper представляет собой некоторую прослойку, предназначенную для непосредственного трансфера данных от уровня абстрактного описания к низкому уровню.

DAL содержит прямые запросы к источнику хранения данных. Там можно встретить SQL код и другие прелести жизни. В ZF роль этого уровня выполняет Zend_Db_Table и его производные.

Если использовать внешние ORM, например Doctrine, то она вполне заменяет последние уровни и облегчает жизнь разработчика. Так как я себе поставил цель скорее «изучать с погружением», я не стал использовать сторонние ORM и решил сделать свой велосипед свою реализацию.

HowTo


Структура проекта

Реально получившаяся картина соответствует следующей организации файловой структуры:

application/
	controllers/
		IndexController.php
		FooController.php
	models/
		Abstract/
			AbstractMapper.php
			AbstractModel.php
		DBTable/
			FooTable.php
			DeviceTable.php
		Mapper/
			FooMapper.php
			DeviceMapper.php
		Foo.php
		Device.php
	services/
		DeviceService.php
		FooService.php
	views/

Немного кода в примерах

Я сторонник подхода, когда реализуется тонкий контроллер, а весь бизнес выносится в сервисы и модели. Такой подход позволяет минимизировать повторяемость кода, упростить тестирование и внесение изменений в логику.
Приведу пример «нейтрального и стандартного» контроллера, который отвечает за авторизацию, регистрацию и связанные с этими процессами действия.

Пример контроллера
class DeviceapiController extends Zend_Controller_Action
{
	public function init()
	{
		$this->_helper->viewRenderer->setNoRender(true);
	}

	/**
	 * Login user from API Request
	 * @method POST
	 * @param json rawBody {"data":{"login": "admin", "password": "password"}}
	 * @param string login in JSON
	 * @param string password in JSON
	 *
	 * @return string SecretKey
	 * @return HTTP STATUS 200 if ok 
	 * @return HTTP STATUS 400 if fields doesn't valid  
	 * @return HTTP STATUS 409 if user already exist
	 */
	public function loginAction()
	{
		$request    = $this->getRequest();
		$data	    = $request->getRawBody();
		
		if ($data) {
			// decode from json params
			$params = Zend_Json::decode($data);
			
			$result = Application_Service_DeviceService::login($params);
			if (!is_null($result['secretKey'])) {
				$this->getResponse()
					->setHttpResponseCode(200)
					->setHeader('Content-type', 'application/json', true)
					->setBody(Zend_Json::encode($result));
				
				$this->_setSecretKeyToCookies($result['secretKey']);
				return;
			}
			$this->getResponse()
				->setHttpResponseCode(401);
			return;
		}
		
		$this->getResponse()
			->setHttpResponseCode(405);
		return;
	}

	/**
	 * Profile from API Request
	 *
	 * @method GET
	 * @param Request Header Cookie secretKey
	 *
	 * @return json string {"id":"","email":"","realName":""}
	 * @return HTTP STATUS 200 OK
	 */
	public function profileAction()
	{
		$cookies = $this->getRequest()->getCookie();
		if (!isset($cookies['secretKey']) || (!Application_Service_DeviceService::isAuthenticated($cookies['secretKey']))) {
			$this->getResponse()
				->setHttpResponseCode(401)
				->setHeader('Content-Type', 'application/json')
				->setBody(Zend_Json::encode(array("message" => "Unauthorized")));
			return;
		}
		
		$result = Application_Service_DeviceService::getProfile($cookies['secretKey'])->toArray();
		
		unset($result['password']);
		unset($result['passwordSalt']);
		
		$this->getResponse()
			->setHttpResponseCode(200)
			->setHeader('Content-type', 'application/json', true)
			->setBody(Zend_Json::encode($result));
		return;
	}
	
	/**
	 * Logout from API Request
	 * @method POST
	 * @param Request Header Cookie secretKey
	 * 
	 * @return HTTP STATUS 200 OK
	 */
	public function logoutAction()
	{
		$cookies = $this->getRequest()->getCookie();

		if ($cookies['secretKey']) {
			$device = new Application_Model_Device();
			$device->deleteByKey($cookies['secretKey']);
			$this->_setSecretKeyToCookies($cookies['secretKey'], -1);

			if(Zend_Auth::getInstance()->hasIdentity()) {
				Zend_Auth::getInstance()->clearIdentity();
			}
		}
	        $this->getResponse()
			->setHttpResponseCode(200);
		return;
	}
	
	/**
	 * Signup user from API Request
	 * @method POST
	 * @param  json string {"email": "", "password": "", “realName”: “”}
	 *
	 * @return string SecretKey
	 * @return HTTP STATUS 201 Created
	 * @return HTTP STATUS 400 Bad request
	 * @return HTTP STATUS 409 Conflict - user already exist
	 */
	public function signupAction()
	{
		$request    = $this->getRequest();
		$data	    = $request->getRawBody();

		// decode from json params
		$params = Zend_Json::decode($data);

		$email = $params['email'];
		$name = $params['realName'];
		$password = $params['password'];

		$err = array();
		if (!isset($email) || !isset($name) || !isset($password) || (filter_var($email, FILTER_VALIDATE_EMAIL)==FALSE))
		{
			if (!isset($email)) {
				$err['email'] = "Email is missing";
			}
			if (!isset($name)) {
				$err['name'] = "Name is missing";
			}
			if (!isset($password)) {
				$err['password'] = "Password are missing";
			}
			if (filter_var($email, FILTER_VALIDATE_EMAIL)==FALSE) {
				$err['valid_email'] = "Email is not valid";
			}
		}
		
		if (!empty($err)) {
			$this->getResponse()
				->setHttpResponseCode(400)
				->setBody(Zend_Json::encode(array ("invalid" => $err)));
			return;
		}
		
		$service = new Application_Service_DeviceService();
		$params = array("email" => $email, "username" => $name, "password" => $password);
		
		try {
			$result = $service->signup($params);
		} catch (Zend_Exception_UserAlreadyExist $e) {
			$this->getResponse()
				->setHttpResponseCode(409)
				->setBody(Zend_Json::encode(array("message" => "User already exist")));
			return;
		}
		
		$this->getResponse()
			->setHttpResponseCode(201)
			->setHeader('Content-type', 'application/json', true)
			->setBody(Zend_Json::encode($result));

		$this->_setSecretKeyToCookies($result['secretKey']);
		return;
	}

	/**
	 * Protected local method to set Secretkey to Cookies
	 * @param string $secretKey
	 * @param int | null $timeFlg
	 */
	protected function _setSecretKeyToCookies($secretKey,$timeFlg = 1) {
			$cookie = new Zend_Http_Header_SetCookie();
			$cookie->setName('secretKey')
				->setValue($secretKey)
				->setPath('/')
				->setExpires(time() + (1* 365 * 24 * 60 * 60)*$timeFlg);
			$this->getResponse()->setRawHeader($cookie);
			return;
	}
}


Таким образом, контроллер в данном примере выполняет роль предварительного валидатора входных данных, роутера на бизнес (вызов определенных сервисов) и формирование ответов. В моем примере мне было необходимо возвращать данные только посредством АПИ. В более сложных случаях, когда нужно отработать одну и ту же логику, только в зависимости от типа запроса или других параметров, выдать ответ в разном формате, удобно использовать content switcher. Например, это может быть полезно, когда один и тот же запрос используется для простого взаимодействия с сайтом, для отработки аякс вызовов, или когда нужно одни и те же данные отдать в различных форматах (либо в JSON, либо в XML, например) в зависимости от Content-Type запроса.
На мой взгляд, это наиболее эффективное использование контроллеров, которое позволяет достаточно легко расширять функционал.
Такие контроллеры достаточно легко тестировать. При этом, тесты действительно помогают понять, как работает функционал, как он должен работать. В процессе разработки я не применял такие методики, как TDD, поэтому тесты писал уже по готовым контроллерам. Это помогло выявить пару узких мест и потенциальных багов.
В подтверждение моих слов о легкой тестируемости таких контроллеров ниже приведу пример тестов.

Тесты для такого контроллера выглядят так
class LoginControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    /*
     * Fixtures:
     * User with `email@example.com` and `password`
    */
    public function setUp()
    {
        $this->bootstrap = new Zend_Application(APPLICATION_ENV, APPLICATION_PATH . '/configs/application.ini');
        parent::setUp();
    }

    public function testSuccessfulLoginAction()
    {
    	$request = $this->getRequest();

    	$email = 'email@example.com';
    	
    	$request->
    		setMethod('POST')->
    		setHeader('Content-Type', 'application/json')->
    		setRawBody(Zend_Json::encode(array(
    			'email' => $email,
    			'password' => 'password',
    		)));
    	$this->dispatch('/login');
    	$this->assertResponseCode(200); 
    	$this->assertNotRedirect();
    	$this->assertHeaderContains('Content-Type', 'application/json');
    	$data = $this->getResponse()->getBody();
    	$data = Zend_Json::decode($data, true);
    	
    	$this->assertArrayHasKey('secretKey', $data);
    	$this->resetRequest()
    		->resetResponse();  

    	// Test logout
    	$request->
	    	setMethod('POST')->
	    	setHeader('Content-Type', 'application/json')->
	    	setCookie('secretKey', $data['secretKey']);
    	$this->dispatch('/logout');
    	$this->assertResponseCode(200);
    	
    	$this->resetRequest()
    		->resetResponse();
    }
    
    public function testLoginWithEmptyParamsAction()
    {
    	$request = $this->getRequest();
    
    	$request->
    		setMethod('POST')->
    		setHeader('Content-Type', 'application/json')->
    		setRawBody(Zend_Json::encode(array(
    			'email' => '',
    			'password' => '',
    	)));
    	$this->dispatch('/login');
    	$this->assertResponseCode(401); 
    	
    	$this->resetRequest()
    		->resetResponse();
    }
    
    public function testLoginWithoutParamsAction()
    {
    	$request = $this->getRequest();
    
    	$request->
    		setMethod('POST')->
    		setHeader('Content-Type', 'application/json');
    	
    	$this->dispatch('/login');
    	$this->assertResponseCode(405);
    	
    	$this->resetRequest()
    		->resetResponse();
    }
    
    public function testSignupAction()
    {
        $request = $this->getRequest();
        
        $email = "newemail_".substr(MD5(uniqid(rand(), true)), 0, 12)."@".substr(MD5(uniqid(rand(), true)), 0, 5).".com";
        
        $request->
            setMethod('POST')->
            setHeader('Content-Type', 'application/json')->
            setRawBody(Zend_Json::encode(array(
                'email' => $email,
                'password' => 'password',
                'realName' => 'John Dow',
            )));
        $this->dispatch('/signup');
        $this->assertResponseCode(201);
        $this->assertHeaderContains('Content-Type', 'application/json');
        $data = json_decode($this->getResponse()->outputBody(), true);
        $this->assertArrayHasKey('secretKey', $data);
        $secretKey = $data['secretKey'];
        $this->assertArrayHasKey('user', $data);

        $this->resetRequest()
             ->resetResponse();

        $request->
            setMethod('POST')->
            setHeader('Content-Type', 'application/json')->
            setRawBody(json_encode(array(
                'email' => '2',
                'password' => '11',
                'realName' => '23s',
            )));
        $this->dispatch('/signup');
        $this->assertResponseCode(400);
        $data = json_decode($this->getResponse()->outputBody(), true);
        $this->assertArrayHasKey('invalid', $data);
        $invalid = $data['invalid'];
        $this->assertArrayHasKey('email', $invalid);
        $this->assertArrayHasKey('password', $invalid);
        
        $this->resetRequest()
             ->resetResponse();
    }
    
    public function testAlreadyExistUserSignup() 
    {
    	$request = $this->getRequest();
    	
    	$request->
	    	setMethod('POST')->
	    	setHeader('Content-Type', 'application/json')->
	    	setRawBody(Zend_Json::encode(array(
	    			'email' => 'email@example.com',
	    			'password' => 'password',
	    			'realName' => 'John Dow',
	    	)));
    	$this->dispatch('/signup');
    	$this->assertResponseCode(409);
    	
    	$this->resetRequest()
    		->resetResponse();
    }
}


В сервисах реализован сам бизнес. Методы сервисов я старался делать статическими. Такой подход позволял мне не создавать лишний раз объект сервиса и минимизировать зависимости сервисов от контекста и друг от друга, что также облегчает их тестирование, рефактор, внесение изменений, расширение функциональности.
Также стоит обратить внимание, что сервисы возвращают данные в контекстно-независимом формате (например, массивы), а их упаковкой в конкретный формат, уже занимается контроллер. Поэтому, если завтра нам потребуется изменить формат передачи данных, мы это сможем изменить легким движением руки «без лишней крови».

Пример сервиса
class Application_Service_DeviceService
{
	public static function login (array $params) 
	{
		if (!empty($params) && !empty($params['email']) && !empty($params['password']))
		{	
			$user = new Application_Model_User();
			$device = new Application_Model_Device();
			$adapter = new Zend_Auth_Adapter_DbTable(
					Zend_Controller_Front::getInstance()->getParam('bootstrap')->getPluginResource("db")->getDbAdapter(),
					'user',
					'email',
					'password',
					'MD5(CONCAT(?, passwordSalt,"' //MD5(пароль + уникальный хэш + общий хэш)
					. Zend_Controller_Front::getInstance()->getParam('bootstrap')->getOption('salt') . '"))'
			);
			//идентификатор пользователя
			$adapter->setIdentity($params["email"]);
			//параметр для проверки через функцию из Zend_Registry::get('authQuery')
			$adapter->setCredential($params["password"]);

			$auth = Zend_Auth::getInstance();
			if ($auth->authenticate($adapter)->isValid()) //успешная авторизация
			{
				$id = $user->getCurrentUserId();
				$secretKey = $user->generateSecretKey();
				
				try {
					$device->userId = $id;
					$device->secretKey = $secretKey;
					$device->lastUsage = time();
					$device->save();
				} catch (Exception $e) {
					throw new Exception("Couldn't save with error ".$e);
				}
				
				$user->loadById($id);
				
				return array("secretKey" => $secretKey, "user" => array("email" => $user->{Application_Model_User::ATTRIBUTE_EMAIL}, "realName" => $user->{Application_Model_User::ATTRIBUTE_REALNAME}, "id" => $user->{Application_Model_User::ATTRIBUTE_ID}));
			}
		}
		return NULL;
	}
	
	public function signup (array $params) {
		//добавляем пользователя в базу данных
		$user = new Application_Model_User();
		
		if ($user->findExistUserByEmail($params['email'])) 
		{
			throw new Zend_Exception_UserAlreadyExist();
		} 
		
		$user->email = $params['email'];
		$user->realName = $params['username'];
		$user->passwordSalt = $user->generatePwdSalt();
		$user->password = $user->generatePwd($params['password']);
		$user->save();
		return $this->login($params);
	}


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

Пример класса модели
class Application_Model_Device extends Application_Model_Abstract_AbstractModel
{
	const ATTRIBUTE_ID 			= "id";
	const ATTRIBUTE_USER_ID 	        = "userId";
	const ATTRIBUTE_SECRET_KEY 	= "secretKey";
	const ATTRIBUTE_LAST_USAGE 	= "lastUsage";

	protected 	$_id,
				$_userId,
				$_secretKey,
				$_lastUsage;

	public function __construct(array $options = null, $mapper = null)
	{
		// for future decorate
		if (is_null($mapper)) $this->_mapper = new Application_Model_DeviceMapper();
		else $this->_mapper = $mapper;

		if (is_array($options)) {
			$this->setOptions($options);
		}
	}

	/**
	 * Wrapper block
	 */
	public function fromProps() {
		return $data = array(
				self::ATTRIBUTE_USER_ID => $this->userId,
				self::ATTRIBUTE_SECRET_KEY => $this->secretKey,
				self::ATTRIBUTE_LAST_USAGE => $this->lastUsage,
		);
	}

	/*
	 * Start describe behaivors of object
	 */
	public function getDeviceByKey ($key) {
		return $this->_mapper->findByKey($key);
	}
	
	public function deleteByKey($key) {
		return $this->_mapper->deleteByCriteria('secretKey', $key);
	}
}


Более сложный пример метода модели
        /**
	 * Delete File in DB and unlink physical file
	 *
	 */
	public function deleteFile()
	{
		$id = $this->id;
		if (empty($id)) {
			throw new Exception('Invalid id');
			return false;
		}
		$imageFile = UPLOAD_PATH.'/'.$this->{self::ATTRIBUTE_REAL_NAME};
		$thImageFile = THUMB_PATH.'/'.$this->{self::ATTRIBUTE_TH_NAME};
		// Удаляем эту запись из БД
		
		$this->_mapper->deleteById($id);
		// Удаляем физический файл
		unlink($imageFile);
		unlink($thImageFile);
	}


Таким образом, наша непосредственная модель включает в себя определение метаданных (свойств объекта) и описывает их поведение. При этом, поведение объекта описано на достаточно абстрактном уровне и заканчивается вызовом определенного метода из маппера, который уже отвечает за взаимодействие с хранилищем. При необходимости подключить дополнительный источник данных, например, завтра мы решим использовать дополнительно NoSQL базу, или начать использовать кэш, то нам будет достаточно декорировать. Еще раз хочу сослаться на презентацию, где очень наглядно продемонстрированы все преимущества такого подхода.
Погружаемся глубже.
Следующим уровнем в моей реализации является маппер. Его основное назначение — пробросить данные или запрос от модели до уровня DAL. Другими словами, на этом уровне мы реализуем Table Data Gateway pattern.

Пример маппера
class Application_Model_DeviceMapper extends Application_Model_Abstract_AbstractMapper
{
	const MODEL_TABLE = "Application_Model_DBTable_DeviceTable";
	const MODEL_CLASS = "Application_Model_Device";

	/**
	 * Get DBTable
	 *
	 * @return string $dbTable return current dbTable object
	 */
	public function getDbTable()
	{
		if (null === $this->_dbTable) {
			$this->setDbTable(self::MODEL_TABLE);
		}
		return $this->_dbTable;
	}

	public function _getModel() {
		return new Application_Model_Device();
	}

	public function update(array $data, $where)
	{
		// add a timestamp
		if (empty($data['updated'])) {
			$data['updated'] = time();
		}
		return parent::update($data, $where);
	}

	/**
	 * @param string $key
	 * @throws Zend_Exception_Unauthtorize
	 */
	public function findByKey($key)
	{
		$result = $this->getDbTable()->fetchRow($this->getDbTable()->select()->where("secretKey = ?", $key));
		if (0 == count($result)) {
			throw  new Zend_Exception_Unauthtorize();
		}
		return $result;
	}
}


В рамках своей задачи я реализовал только один маппер — работа с MySql базой, но уже есть задача и подключение работы с кэшем, и потенциальная мысль перевести ряд объектов на NoSQL. Для меня это будет означать лишь необходимость декорирования и написания минимального количества кода. Тесты при этом переписывать не придется (за исключением написания новых :) )
Как видно из кода, данный маппер обращается к классу таблицы — DAL.
Для этой прослойки я не придумывал ничего нового и использовал стандартные классы, которые предоставляет Zend.
Сам класс выглядит весьма не замысловато:

Класс доступа к данным (уровень DAL)
class Application_Model_DBTable_DeviceTable extends Zend_Db_Table_Abstract
{
	protected $_name = 'deviceKey';
	protected $_primary = 'id';
	
	protected $_referenceMap    = array(
			'Token' => array(
					'columns'           => 'userId',
					'refTableClass'     => 'Application_Model_DBTable_UserTable',
					'refColumns'        => 'id',
					'onDelete'      	=> self::CASCADE,
					'onUpdate'      	=> self::CASCADE,
			));
	
	public function __construct($config = array()) {
		$this->_setAdapter(Zend_Db_Table::getDefaultAdapter());
		parent::__construct();
	}
}


Если заглянуть в мануалы Zend Framework, то легко заметить, что именно этот (и только этот уровень) предлагается в качестве реализации модели (см. мануалы + Quick Start).
Дополнительно я использую абстрактные методы маппера и модели, но их назначение, надеюсь, очевидно.
В дополнение хочу сказать, что Zend_Db_Table возвращает значения либо в массивах, либо в виде объекта соответствующего типа, который не соответствует типу нужного нам объекта, из контекста которого мы вызываем эти методы.
Для приведения данных, получаемых из хранилищ данных, а также для их валидации, мы можем использовать методы, заданные на уровне модели(ORM).

Резюме


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

На первый взгляд, решение, описанное мною выше, более сложное относительно описанного в мануалах. Также неизбежно создается больше объектов и идет более глубокая проброска данных внутрь.
Это, бесспорно, минусы.
Теперь о плюсах.
  • Мы получаем более атомарный код, который удобнее тестировать, а значит он будет проще для чтения, качественнее и вероятность ошибок в нем будет значительно меньше.
  • Гибкость, расширяемость. Для расширения функционала необходимо лишь декорировать существующий код.
  • Разделение «зон ответственности» между уровнями.

Каждый из нас на конкретном проекте сам принимает решение, какую архитектуру следует реализовать. Я просто описал еще один вариант, который успешно работает и количество плюсов которого сильно перевешивает количество минусов (говорю в контексте себя и моего конкретного проекта). Аналогичное мнение

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

PS

Понимаю, что совершенного кода не бывает и, практически всегда, можно сделать лучше.
Также понимаю, что можно использовать сторонние решения.
И да, я понимаю, что есть ZF2, и лучше новые проекты начинать делать на нем.
Также я осознаю, что есть другие фреймворки / языки программирования, на которых некоторые вещи работают быстрее / оптимальные / выше / сильнее / выглядят красивее и тп.
Tags:
Hubs:
+25
Comments 46
Comments Comments 46

Articles