📜 ⬆️ ⬇️

HOWTO: One of the possible implementation of the Model (MVC) in the Zend Framework

Writing an article inspired by habrahabr.ru/qa/34735 and habrahabr.ru/qa/32135 with questions, as answers to which I could not find complete and detailed information, which was much lacking. I hope it will be useful to others.

The project, whose share was chosen as ZF as the main framework, was the mobile version of the service (adaptive design with some nuances) + API for mobile applications.
It was collectively made a political and technical decision to make a single API, through which both the site and applications will communicate.

On this, I think, the prelude can be finished and go to the most interesting.

Article for convenience divided into 2 parts. The first part contains a bit of theory, thoughts and references to various sources. In the second part, I tried in detail (with code examples) to show how I implemented my version of the architecture.

Some theory


Start

Zend Framework is not the easiest in terms of entry threshold. I spent enough time to understand his ideology, but after that, each next step is expected, predictable and logical.
')
Despite a sufficient amount of official documentation , in some places it is pretty hard (colleagues even delicately called it “Twi-style documentation”), and a lot has to be taken from studying the source code.

I just want to draw attention to those who talk about the monstrosity of this miracle - yes, the Zend is quite a large, large-scale cannon, of which at first glance ... on sparrows there is nothing to it ... But after looking and studying its features even superficially, I can add that the caliber of this gun is very customizable. There is a fairly good working autoloader, which allows you to connect a minimal set of classes.

Having assembled the test application framework (quick start), I began the process of designing the architecture while actively exploring the possibilities, recommendations and best practices developed at ZF (I really liked the presentation , got a lot of thoughts from it, there will be more links to the text).

Model in MVC

Many people perceive and describe the model as a way to access data at the database level, but this is not entirely true. A model is not a relational database, or even a table. Data may come from different sources.
I considered the model as a multilevel layer and selected 3 layers for myself:

Domain Model - description of object metadata, including getters, setters, data validation and object behavior description (behavior). Argued that the description of the behavior can also be made in the layer DomainEvents, and this is something different from the Table Data Gateway pattern .
This level in my implementation knows nothing about the methods (and locations) of data storage.

Data Mapper is a layer designed to directly transfer data from the abstract description level to the low level.

DAL contains direct requests to the storage source. There you can find SQL code and other delights of life. In ZF, the role of this level is performed by Zend_Db_Table and its derivatives.

If you use an external ORM, for example Doctrine, then it completely replaces the last levels and makes the developer's life easier. Since I set myself the goal of “learning with immersion,” I did not use third-party ORMs and decided to make my own implementation of my bike .

Howto


Project structure

The real picture is the following organization of the file structure:

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/ 

Some code in the examples.

I am a supporter of the approach when a thin controller is implemented, and the whole business is put into services and models. This approach allows you to minimize the repeatability of the code, simplify testing and changes to the logic.
I will give an example of a “neutral and standard” controller, which is responsible for authorization, registration, and actions related to these processes.

Controller example
 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; } } 


Thus, the controller in this example performs the role of a preliminary validator of input data, a business router (calling certain services) and generating responses. In my example, I needed to return data only through the API. In more complex cases, when you need to work out the same logic, only depending on the type of request or other parameters, to give an answer in a different format, it is convenient to use the content switcher. For example, this can be useful when the same request is used for simple interaction with the site, for processing Ajax calls, or when you need to give the same data in different formats (either in JSON or in XML, for example) depending from Content-Type request.
In my opinion, this is the most efficient use of controllers, which makes it quite easy to expand the functionality.
Such controllers are fairly easy to test. At the same time, the tests really help to understand how the functionality works, how it should work. In the development process, I did not use such techniques as TDD, so I wrote tests on ready-made controllers. This helped to identify a couple of bottlenecks and potential bugs.
In confirmation of my words about the easy testability of such controllers below I give an example of tests.

Tests for such a controller look like this
 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(); } } 


The services implemented the business itself. I tried to do methods of services static. This approach allowed me not to create the service object once more and to minimize the dependencies of services on the context and on each other, which also facilitates their testing, refactor, making changes, extending functionality.
It is also worth noting that services return data in a context-independent format (for example, arrays), and the controller is already engaged in their packaging in a specific format. Therefore, if tomorrow we need to change the format of the data transfer, we can change it with a flick of the wrist “without excess blood”.

Service example
 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); } 


As can be seen from the code, in the service, if necessary, the next level of data validation is performed, model objects are created, work is being done with their properties and methods.
Next, consider an example of the model itself, which would describe our objects and their behavior.

Model class example
 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); } } 


A more complex example of the model method
  /** * 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); } 


Thus, our immediate model includes the definition of metadata (object properties) and describes their behavior. At the same time, the object's behavior is described at a rather abstract level and ends with a call to a specific method from the mapper, which is already responsible for interacting with the repository. If you need to connect an additional data source, for example, tomorrow we decide to use an additional NoSQL database, or start using a cache, then it will be enough for us to decorate. Once again I want to refer to the presentation , where all the advantages of this approach are very clearly demonstrated.
Dive deeper.
The next level in my implementation is mapper. Its main purpose is to forward data or request from the model to the DAL level. In other words, at this level we implement the Table Data Gateway pattern.

Mapper example
 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; } } 


As part of my task, I implemented only one mapper - working with MySql database, but already there is a task and a cache connection, and a potential thought to transfer a number of objects to NoSQL. For me, this will only mean the need to decorate and write the minimum amount of code. At the same time, tests will not have to be rewritten (except for writing new ones :))
As can be seen from the code, this mapper refers to the table class - DAL.
For this layer, I did not invent anything new and used the standard classes that Zend provides.
The class itself does not look very intricate:

Data Access Class (DAL Level)
 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(); } } 


If you look into the Zend Framework manuals, it is easy to see that this (and only this level) is offered as an implementation model (see manuals + Quick Start).
Additionally, I use abstract mapper methods and models, but their purpose, I hope, is obvious.
In addition, I want to say that Zend_Db_Table returns values ​​either in arrays or as an object of a corresponding type that does not correspond to the type of the object we need, from the context of which we call these methods.
To bring data from data warehouses, as well as to validate them, we can use the methods specified at the model level (ORM).

Summary


With this article with enough code, I'm not trying to impose this approach on everyone, or say that you need to continue to use ZF1, and leave the second branch of the framework, more modern, for a brighter future. Yes, we are evolving, growing and developing, but the general principles, including architectural ones, are always relevant, regardless of the tool used.

At first glance, the solution I described above is more complex than that described in the manuals. Inevitably, more objects are created and a deeper data is thrown inside.
This, of course, cons.
Now about the pros.

Each of us on a particular project decides for itself what architecture should be implemented. I just described another option that works successfully and the number of advantages that far outweighs the number of minuses (I speak in the context of myself and my specific project). Similar opinion

Hope this article with code examples was helpful to you.
I will also be grateful for the criticism, advice and thanks.

PS

I understand that there is no perfect code and, almost always, you can do better.
I also understand that third-party solutions can be used.
And yes, I understand that there is ZF2, and it is better to start doing new projects on it.
I also realize that there are other frameworks / programming languages ​​in which some things work faster / optimal / higher / stronger / look prettier and more.

Source: https://habr.com/ru/post/169537/


All Articles