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

article Writing inspired by habrahabr.ru/qa/34735 and habrahabr.ru/qa/32135 questions, answers to which I could not find complete and detailed information, which is not enough. I hope it will be useful to others.

The project, whose share was chosen in the form of ZF as the main framework consists of a mobile version (responsive design with some nuances) + API for mobile applications.
Collectively adopted political and technical solution to make a single API through which to communicate both the site and app.

This, I think, prelude to finish and move on to the fun part.

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

a Little theory



Zend Framework — not the easiest from the point of view of the entry threshold. I spent enough time to understand his ideology, but after that, the next step is expected, predictable and logical.

Despite a sufficient number of official documentation in some places it is quite Ogata (colleagues even delicately called it "a documentation style Twi"), and very much should be taken from the study of sources.

I just want to draw attention to those who talk about monstrous this miracle — Yes, Zend is large enough, large cannon, of which at first glance... on the wheel and there is nothing... But looking closely and examining its features, even superficially, I can add that the caliber of this gun is very customizable. There are enough good working autoloader, which allows you to connect a minimum set of classes.

After collecting the frame of the test application (quick start) began the process of designing architecture with active learning opportunities, recommendations and best practice development at ZF (very much prezentacijalearned from it a lot of thought, the text on it will still be referenced).

Model in MVC

Many perceive and describe the model as a method of data access at the database level, but this is not true. The model is not a relational database, or even table. Data can come from different sources.
I considered the model as a multi-level layer and separated 3 layers:
the
    the
  • Domain Model
  • the
  • Data Mapper
  • the
  • Data Access Layer (DAL)

Domain Model — description of object metadata, including getters, setters, validation data and a description of the behavior of the object (behavior). Argued that the description of the behavior you can also make a layer DomainEvents, and this is something else Table Data Gateway pattern.
This level in my implementation knows nothing about the ways (and places) of data storage.

Data Mapper represents a certain layer intended for direct data transfer from level abstract descriptions to low level.

DAL contains direct requests to the source storage. There you can find the SQL code and other pleasures of life. In ZF the role of this level performs Zend_Db_Table and its derivatives.

If you use an external ORM, for example Doctrine, it completely replaces the last level and makes life easier for the developer. As I set myself the goal is more "to learn by immersion", I did not use third-party ORM and decided to make his Bicycle its implementation.

HowTo


project Structure

Really the resulting picture corresponds to the following file structure:

the
application/
controllers/
IndexController.php
FooController.php
models/
Abstract/
AbstractMapper.php
AbstractModel.php

FooTable.php
DeviceTable.php
Mapper/
FooMapper.php
DeviceMapper.php
Foo.php
Device.php
services/
DeviceService.php
FooService.php
views/

code examples

I am a supporter of the approach when the controller is implemented subtle, and the whole business moved to a services model. This approach allows us to minimize the repetition of code, to simplify testing and modification in logic.
Give an example of a "neutral standard" controller, which is responsible for the authorisation, registration and related processes, actions.

Example controller
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 the 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['paste']);

$this->getResponse()
->setHttpResponseCode(200)
->setHeader('Content-type', 'application/json', true)
->setBody(Zend_Json::encode($result));
return;
}

/**
* Logout from the 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 validator of the preliminary input data, the router on the business (certain services) and generate responses. In my example I needed to return data only through the API. In more complex cases when you need to work on the same logic, only depending on the request type or other parameters, to give the answer in different format, it is convenient to use content switcher. For example, it may be useful when the same query is used for simple interactions with the site, for testing the Ajax calls, or when you need the same data can give in different formats (either JSON or XML, for example) depending on the Content-Type of the request.
Such controllers are easy enough to test. In this case, the tests really help to understand how the functionality of how it should work. During the development process, I did not use such techniques as TDD, so the test was written using a pre-made controllers. This helped to identify a couple of bottlenecks and potential bugs.
In confirmation of my words about easy testability of such controllers below are examples of tests.

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


In the services implemented by the business itself. Service methods I've tried to do static. This approach allowed me to create once again the object of service and minimize the dependencies of the services from the context and from each other, which also facilitates their testing, refaktor, changes, feature enhancements.
It is also worth to note that the services return data in a context-independent format (e.g., arrays), and they are packaged in a specific format, already engaged in the controller. So if tomorrow we will need to change the transmission format, we can change it flick of the wrist "without 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();

$adapter = new Zend_Auth_Adapter_DbTable(
Zend_Controller_Front::getInstance()->getParam('bootstrap')->getPluginResource("db")->getDbAdapter(),
'user',
'email',
'password',
'MD5(CONCAT(?, paste the,"' //MD5(password + hash + hash total)
. Zend_Controller_Front::getInstance()->getParam('bootstrap')->getOption('salt') . '"))'
);
//user ID
$adapter- > setIdentity($params["email"]);
//parameter to check through the function of Zend_Registry::get('authQuery')
$adapter- > setCredential($params["password"]);

$auth = Zend_Auth::getInstance();
if ($auth- > authenticate($adapter)->isValid()) //successful authorization
{
$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) {
//add user to the database
$user = new Application_Model_User();

if ($user->findExistUserByEmail($params['email'])) 
{
throw new Zend_Exception_UserAlreadyExist();
} 

$user->email = $params['email'];
$user- > realName = $params['username'];
$user->paste = $user->generatePwdSalt();
$user->password = $user->generatePwd($params['password']);
$user->save();
return $this->login($params);
}


As you can see from the code in the service, if necessary, is the next level of data validation, create object models, work with their properties and methods.
Next, consider the example of the model that would describe our objects and their behavior.

Sample model class
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 a method of the model
 /**
* 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};
// Delete the record from DB

$this- > _mapper- > deleteById($id);
// Delete the physical file
unlink($imageFile);
unlink($thImageFile);
}


Thus, our direct model includes the definition of metadata (object properties) and describes their behavior. Thus, object behavior is described at a rather abstract level and ends with a call to a particular method of the mapper, which is already responsible for interaction with storage. When you need to connect an additional data source, for example, tomorrow, we decide to use an additional NoSQL database, or you can start to use the cache, then it will be enough to decorate. Once again I want to refer to presentaciowhere it is clearly demonstrated all the advantages of this approach.
To go deeper.
The next level in my implementation is the mapper. Its main purpose is to traverse the data or request from the model to the level of the DAL. In other words, at this level we implement Table Data Gateway pattern.

Example mapper
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 $return the current dbTable object dbTable
*/
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;
}
}


In the framework of its tasks, I implemented only one mapper — work with the MySql database, but already have a task and cache, and the potential for the idea to translate a number of objects in NoSQL. For me it will only mean the need for decorating and writing minimal amounts of code. Tests at the same recode (with the exception of writing new ones :) )
As you can see from the code, the mapper accesses the table class — DAL.
For this layer I didn't invent anything new and used the standard classes that Zend offers.
The class itself looks 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 at the guide on the Zend Framework, it is easy to see that this (and only this level) is proposed as an implementation of the model (see manuals + Quick Start).
Additionally, I use the abstract methods of the mapper and the model, but their purpose, I hope, obvious.
In addition, I want to say that Zend_Db_Table returns the values, either in arrays or in the form of an object of the appropriate type that does not match the type of the object from the context we call these methods.
To bring data from data warehouses, as well as for their validation, we can use the methods defined on the model layer(ORM).

Summary


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

At first glance, the solution I have described above, more complex than in the described in the manuals. Also inevitably creates more objects and goes deeper propose data inside.
This, no doubt, cons.
Now for the pros.
the
    the
  • We get more atomic code that is easier to test, so it should be easier to read, better the likelihood of errors will be much smaller.
  • the
  • Flexibility and extensibility. To extend the functionality, you only need to decorate existing code.
  • the
  • the Division of "responsibility zones" between levels.

Each of us on a particular project to decide, which architecture should be implemented. I just described another option, which works successfully and the number of advantages which greatly outweigh the cons (say in the context of myself and my particular project). Similar opinion

I hope that this article with sample code was helpful to you.
I will also be grateful for criticism, tips and gratitude.

PS

Understand that the perfect code does not happen and, almost always, can do better.
Also understand that you can use third-party solutions.
And Yes, I understand that there are ZF2, and best new projects to start doing it.
I also realize that there are other frameworks / programming languages on which some things work faster / optimal / above / stronger / look more beautiful, etc.
Article based on information from habrahabr.ru

Комментарии

Популярные сообщения из этого блога

ODBC Firebird, Postgresql, executing queries in Powershell

Installation LivestreetCMS on MODX Revolution package 10 clicks

The Ministry of communications wants to ban phones without GLONASS