Hanteringen och kopplingen till datakällorna går ofta igenom en mognadsprocess ju mer en utvecklare lär sig. Det är inte ovanligt att man ser kod i stil med
1 function getUsername($userId) {
2 $query = "
3 SELECT * FROM users
4 WHERE id=%s
5 LIMIT 1
6 ";
7 $result = mysql_queryf($query, $userId);
8 while($row = mysql_fetch_array($result))
9 {
10 $output = $row[username];
11
12 }
13 return $output;
14 }
Många som jobbat lite med databasdrivna applikationer börjar förr eller senare att abstrahera bort databaslogiken. I ett första steg kan detta te sig som så att man skapar objekt som har fint namngivna funktioner, exempelvis ett User objekt med funktioner så som getName() osv. I dessa funktioner sker sedan databasanrop annat. Men det publika interfacet av objektet blir mycket trevligare.
1 class User
2 {
3 private $_userId;
4
5 ...
6
7 function getUsername() {
8 $query = "
9 SELECT * FROM users
10 WHERE id=%s
11 LIMIT 1
12 ";
13 $result = mysql_queryf($query, $this->_userId);
14 while($row = mysql_fetch_array($result))
15 {
16 $output = $row[username];
17
18 }
19 return $output;
20 }
21 }
Nästa steg är ofta att hämta ut all relevant data i konstruktorn av User och således inte hämta den varje gång du anropar getUsername(). Sedan bygger man vidare med cachning av datan osv, osv.
Jag satt själv och funderade på detta för ett tag sedan och tänkte att man egentligen bör abstrahera det så pass att User objektet i detta fallet inte har någonting med databasen att göra. Data bör kunna sparas på olika ställen utan att vår User egentligen skall behöva förändras. Så all logik kopplad till lagring av data bör således inte ligga i klassen som definierar vår User.
Istället bestämde jag mig för att göra som så att min modell, User i detta fallet, endast innehöll data, samt funktioner för att manipulera denna data. Att sedan hämta och lagra datan hanteras istället av en annan klass. Jag valde att kalla dessa för Gateways. På detta sätt kan man skapa Gateways som jobbar emot olika datakällor utan att behöva pilla på våra modell klasser. Detta lämpar sig tex om man sitter och utvecklar ny funktionalitet och vill ha testdata lokalt i en XML fil istället för att jobba mot databasen. När det sedan är dags att jobba emot databasen så använder man sig bara av en annan Gateway och så är det löst.
För att uppnå detta skapar vi en basklass för våra modeller. I denna basklass har vi funktioner för att fylla på data, samt att hämta ut data. Vi inför en standard som säger att data i våra datakällor sparas i formatet ‘foo_id’ och att vår modell i så fall kommer innehålla funktioner som heter ‘setFooId()’ och ‘getFooId()’.
1 <?php
2 abstract class Core_Model_Abstract
3 {
4 protected $_isSaved = false;
5
6 public function __construct(array $options = null){
7 if(is_array($options)){
8 $this->setOptions($options);
9 }
10 }
11
12 public function __set($name, $value){
13 $methodName = 'set';
14 $nameParts = explode('_', $name);
15
16 foreach($nameParts as $namePart) {
17 $methodName .= ucfirst($namePart);
18 }
19
20 if((!method_exists($this, $methodName)){
21 throw new Exception('Invalid property ' .$methodName . ' for ' . get_called_class());
22 }
23 $this->$methodName($value);
24 }
25
26 public function __get($name){
27 $methodName = 'get';
28 $nameParts = explode('_', $name);
29
30 foreach($nameParts as $namePart) {
31 $methodName .= ucfirst($namePart);
32 }
33
34 if(('gateway' == $name) || !method_exists($this, $methodName)){
35 throw new Exception('Invalid property ' .$methodName . ' for ' . get_called_class());
36 }
37 return $this->$methodName();
38 }
39
40 public function setOptions(array $options){
41 $methods = get_class_methods($this);
42 foreach($options as $name => $value){
43 $methodName = 'set';
44 $nameParts = explode('_', $name);
45
46 foreach($nameParts as $namePart) {
47 $methodName .= ucfirst($namePart);
48 }
49
50 if(in_array($methodName, $methods)){
51 $this->$methodName($value);
52 }
53 else {
54 Core_Log::log('Method ' . $methodName . ' does not exist for ' . get_called_class(), Core_Log::INFO);
55 }
56 }
57 return $this;
58 }
59
60 public function setIsSaved($isSaved) {
61 $this->_isSaved = $isSaved;
62 }
63
64 public function getIsSaved() {
65 return $this->_isSaved;
66 }
67 }
En klass för en User hade då sett ut i stil med.
1 <?php
2 class Core_Model_User extends Core_Model_Abstract
3 {
4 private $_userId;
5 private $_username;
6
7 ...
8
9 public function getUserId() {
10 return $this->_userId;
11 }
12
13 public function setUserId($id) {
14 $this->_userId = $id;
15 }
16
17 public function getUsername() {
18 return $this->_username;
19 }
20
21 public function setUsername($_username) {
22 $this->_username = $_username;
23 }
24 }
Denna modell kan nu innehålla data, men hur skall vi då sköta lagring av våra modeller? Här kommer våra Gateways in. Även här börjar vi med en basklass med det som är gemensamt för alla våra Gateways. En specialanpassad gateway för att spara till DB kan sedan se ut enligt följande.
1 <?php
2
3 /**
4 * Gateway class between Models and Database
5 * @author danlil
6 *
7 */
8 abstract class Core_Model_Gateway_Db extends Core_Model_Gateway_Abstract
9 {
10 protected $_dbTableClass;
11 protected $_modelClass;
12 protected $_dbTable;
13
14 public function setDbTable($dbTable){
15 if(is_string($dbTable)){
16 $dbTable = new $dbTable();
17 }
18 if(!$dbTable instanceof Core_Db_Table_Abstract){
19 throw new Exception('Invalid table data gateway provided');
20 }
21 $this->_dbTable = $dbTable;
22 return $this;
23 }
24
25 public function getDbTable(){
26 if(null === $this->_dbTable){
27 $this->setDbTable($this->_dbTableClass);
28 }
29 return $this->_dbTable;
30 }
31
32 public function save(Core_Model_Abstract $model) {
33 $tableInfo = $this->getDbTable()->info();
34 $pk = $this->getDbTable()->getPrimaryKey();
35 $setPkFunction = 'set';
36 $getPkFunction = 'get';
37
38 if(is_array($pk)) {
39 throw new Exception("Support for multicolumn primary keys not yet implemented. Prehaps it's time!");
40 }
41
42 /**
43 * As a standard we take column names like foo_bar and try to get
44 * the correct data to put in it by calling getFooBar() from the model
45 * object.
46 */
47 // echo "<pre>";
48 // die(var_dump($tableInfo['metadata']));
49 foreach($tableInfo['metadata'] as $columnName => $columnMetadata) {
50 $methodName = 'get';
51 $columnNameParts = explode('_', $columnName);
52
53 foreach($columnNameParts as $columnNamePart) {
54 $methodName .= ucfirst($columnNamePart);
55
56 if($columnName == $pk) {
57 $getPkFunction .= ucfirst($columnNamePart);
58 $setPkFunction .= ucfirst($columnNamePart);
59 }
60 }
61
62 if(!method_exists($model, $methodName)) {
63 $className = get_class($model);
64 Core_Log::log("Function \"$methodName\" does not exist in \"$className\". Either implement the function or override the save() function.", Core_Log::CRIT);
65 throw new Exception("Function \"$methodName\" does not exist in \"$className\". Either implement the function or override the save() function.");
66 }
67
68 $data[$columnName] = $model->$methodName();
69 }
70
71 $id = $model->$getPkFunction();
72
73 if($model->$getPkFunction() != null && $model->getIsSaved() == false) {
74 $lastInsertId = $this->getDbTable()->insert($data);
75 $model->$setPkFunction($lastInsertId);
76 }
77 else if(null === $id){
78 unset($data[$pk]);
79 $lastInsertId = $this->getDbTable()->insert($data);
80 $model->$setPkFunction($lastInsertId);
81 }
82 else{
83 unset($data[$pk]);
84 //$where = $this->getDbTable()->getAdapter()->quoteInto('bug_id = ?', 1234);
85 $this->getDbTable()->update($data, array("$pk = ?" => $id));
86 }
87 }
88
89 public function delete(Core_Model_Abstract $model) {
90 $tableInfo = $this->getDbTable()->info();
91 $pk = $this->getDbTable()->getPrimaryKey();
92 $setPkFunction = 'set';
93 $getPkFunction = 'get';
94
95 if(is_array($pk)) {
96 throw new Exception("Support for multicolumn primary keys not yet implemented. Prehaps it's time!");
97 }
98
99 /**
100 * As a standard we take column names like foo_bar and try to get
101 * the correct data to put in it by calling getFooBar() from the model
102 * object.
103 */
104 foreach($tableInfo['metadata'] as $columnName => $columnMetadata) {
105 $methodName = 'get';
106 $columnNameParts = explode('_', $columnName);
107
108 foreach($columnNameParts as $columnNamePart) {
109 $methodName .= ucfirst($columnNamePart);
110
111 if($columnName == $pk) {
112 $getPkFunction .= ucfirst($columnNamePart);
113 $setPkFunction .= ucfirst($columnNamePart);
114 }
115 }
116
117 if(!method_exists($model, $methodName)) {
118 $className = get_class($model);
119 Core_Log::log("Function \"$methodName\" does not exist in \"$className\". Either implement the function or override the save() function.", Core_Log::ERR);
120 throw new Exception("Function \"$methodName\" does not exist in \"$className\". Either implement the function or override the save() function.");
121 }
122
123 $data[$columnName] = $model->$methodName();
124 }
125
126 if(null === ($id = $model->$getPkFunction())){
127 unset($data[$pk]);
128 Core_Log::log($className . " returned null as primary key and could not be deleted", Core_Log::ERR);
129 }
130 else{
131 unset($data[$pk]);
132 $where = $this->getDbTable()->getAdapter()->quoteInto("$pk = ?", $model->$getPkFunction());
133 $this->getDbTable()->delete($where);
134 }
135 unset($model);
136 }
137
138 public function find($id, Core_Model_Abstract $model = null){
139 $result = $this->getDbTable()->find($id);
140 if(0 == count($result)){
141 return;
142 }
143 $row = $result->current();
144 if($model === null) {
145 $model = $this->_createModel($row);
146 return $model;
147 }
148 $this->_populate($model, $row);
149 }
150
151 public function fetchAll() {
152 return $this->_fetchAll();
153 }
154
155 protected function _fetchAll($selectStatement = null){
156 $result = $this->getDbTable()->fetchAll($selectStatement);
157
158 if(0 == count($result)){
159 return null;
160 }
161
162 $models = array();
163 foreach($result as $row){
164 $model = $this->_createModel($row);
165 array_push($models, $model);
166 }
167
168 return $models;
169 }
170
171 protected function _fetchRow($selectStatement) {
172 $result = $this->getDbTable()->fetchRow($selectStatement);
173
174 if(0 == count($result)){
175 return;
176 }
177
178 $model = $this->_createModel($result);
179
180 return $model;
181 }
182
183 /**
184 *
185 * Creates the model and if supplied sets the options
186 * @param array $options
187 * @throws Exception
188 */
189 protected function _createModel($options = null) {
190 if(is_string($this->_modelClass) && class_exists($this->_modelClass)){
191 if($options) {
192 $options = $options->toArray();
193 }
194 $model = new $this->_modelClass($options);
195 $model->setIsSaved(true);
196 return $model;
197 }
198 else {
199 throw new Exception('Invalid model class provided ' . $this->_modelClass);
200 }
201 }
202
203 protected function _beginTransaction() {
204 $this->getDbTable()->getDefaultAdapter()->beginTransaction();
205 }
206
207 protected function _commit() {
208 $this->getDbTable()->getDefaultAdapter()->commit();
209 }
210
211 protected function _rollBack() {
212 $this->getDbTable()->getDefaultAdapter()->rollBack();
213 }
214 }
Som synas så jobbar vår Gateway emot Core_Db_Table_Abstract objekt. Dessa är egentligen bara en specialisering av Zend_Db_Table_Abstract. Det hade även gått att ha SQL kod direkt i vår Gateway om man så önskar. Core_Db_Table_Abstract ser ut som följer.
1 <?php
2 abstract class Core_Db_Table_Abstract extends Zend_Db_Table_Abstract
3 {
4 static function getPrimaryKey() {
5 if(!empty($this)) {
6 if(count($this->_primary) > 0) {
7 $pk = $this->_primary;
8 } else {
9 $tableInfo = $this->info();
10 $pk = $tableInfo['primary'];
11 }
12 }
13 else {
14 $calledClassName = get_called_class();
15 $table = new $calledClassName();
16 if(count($table->_primary) > 0) {
17 $pk = $table->_primary;
18 } else {
19 $tableInfo = $table->info();
20 $pk = $tableInfo['primary'];
21 }
22 unset($table);
23 }
24 return $pk;
25 }
26
27 static function getName() {
28 if(!empty($this)) {
29 $name = $this->_name;
30 }
31 else {
32 $calledClassName = get_called_class();
33 $table = new $calledClassName();
34 $name = $table->_name;
35 unset($table);
36 }
37 return $name;
38 }
39 }
40
41
Vad allt detta resulterar i är att du nu kommer jobba med dina modeller utan att egentligen behöva tänka på den bakomliggande lagringen.
1 // Skapa en ny user och sätt data
2 $user = new Core_Model_User();
3 $user->setName('Foo Bar');
4
5 // Låt vår gateway, som kan jobba mot DB
6 // eller någon annan lagring spara den åt oss.
7 // Vår gateway sköter också om detta är en
8 // 'insert' eller 'update'
9 Core_Model_UsersGateway::getInstance()->save($user);
Vår Core_Model_UsersGateway använder vi för att spara och hämta Users enligt olika kriterier. Exempelvis
1 <?php
2 class Core_Model_UsersGateway extends Core_Model_Gateway_Db
3 {
4 protected $_dbTableClass = 'Core_Model_DbTable_Users';
5 protected $_modelClass = 'Core_Model_User';
6
7 public function fetchCurrent() {
8 if(!Zend_Auth::getInstance()->hasIdentity()) {
9 return null;
10 }
11
12 $userInfo = Zend_Auth::getInstance()->getStorage()->read();
13 $currentUser = $this->fetchWhereId($userInfo->user_id);
14
15 return $currentUser;
16 }
17
18 public function fetchWhereId($userId) {
19 return $this->_fetchRow($this->getDbTable()->select()
20 ->where('user_id = ?', $userId));
21 }
22 }
Jag har valt att döpa funktionerna med namn hämtade inspirerade ifrån queryn de kör. Men det är en smaksak.
Funktioner i User kan självklart innehålla all möjlig logik och inte bara sätta och returnera data. Säg att vi hade haft en funktion som hade varit getUnreadMessages() på vår User. Det hade också löst sig utan att bry sig om var/hur våra Messages var lagrade då även dessa använder sig av en Gateway. I User hade vi då tex kunna haft något i stil med.
1 class User extends Core_Model_Abstract
2 {
3 ...
4
5 public function getUnreadMessages() {
6 return Messages_Model_Gateway::getInstance()->fetchAllUnreadForUserId($this->_userId);
7 }
8
9 ...
10 }
11
12
13 // Nu kan du hämta ut den aktiva användaren,
14 // ändra dennes namn och spara ner ändringen.
15 // Allt utan att behöva bry dig alls om vad som
16 // händer i bakgrunden.
17 $currentUser = Core_Model_UsersGateway::getInstance()->fetchCurrent();
18 $currentUser->setName('Bar Foo');
19 Core_Model_UsersGateway::getInstance()->save($currentUser);
20
21 // Och så printar vi ut alla headers ifrån olästa
22 // meddelanden också.
23 $unreadMessages = $currentUser->getUnreadMessages();
24
25 foreach($unreadMessages as $unreadMessage) {
26 echo $unreadMessage->getHeader();
27 }
Det är naturligtvis lite jobb att få detta på plats och jag optimerar och finslipar detta kontinuerligt. Men fördelarna tycker jag är betydande. Jag jobbar mot Zend Framework, därav formaten på namnen på klasser etc, men det går lika bra att implementera detta i “ren” PHP eller mot något annat ramverk.
