Posts Tagged Controller
Fat model, skinny controller – przykład
Niedawno przy projekcie trafiliśmy na ciekawy problem. Chciałbym się podzielić z Wami tym czego się nauczyliśmy.
Problem przedstawię w bardzo uproszczonej formie, bo trudno bez machania rękoma przy zabazgranej tablicy wyjaśnić go w całości.
Problem
Mamy formularz dodawania kosztów, który jest dość specyficzny. Można dodać “łysy koszt” i wtedy pojawia się formularz z wyborem firmy i innymi atrybutami. Można jednak z widoku konkretnej firmy wybrać opcję “dodaj koszt” – w takim wypadku lista wyboru firm się nie pojawia.
Dość naturalne jest załatwić to kontrolerem. Są różne możliwości, jedną z nich jest takie zdefiniowanie akcji:
function add($entryId=null) {
if(!empty($this->data)){
$this->Cost->save($this->data);
// redirect w jakieś przyjemne okolice
}
if(is_null($entryId)){
$this->set("entry", $this->Cost->Entry->read(null, $entryId);
}else{
$this->set("entries", $this->Cost->Entry->find("list");
}
}
W widoku wiadomo – jeśli jest $entry, wyświetlimy $entry["Entry"]["name"] i w jakimś ukrytym inpucie wrzucimy id, jeśli istnieje $entries – wyświetlimy selecta.
Na razie w kontrolerze wygląda to wystarczająco zgrabnie, żeby tego nie ruszać. Ale jeśli znajdziesz się w sytuacji, gdy ilość przypadków jest coraz większa – możesz się zorientować, że dodajesz kolejne argumenty do metody add i rozbudowujesz tą metodę. W takiej sytuacji może Ci pomóc takie podejście.
Propozycja rozwiązania
Po pierwsze olej zwykłe parametry funkcji, a zacznij używać parametrów “named”. Czyli zamiast:
costs/add/1/3/45/23
costs/add/2///22
będziesz miał
costs/add/cost_id:1/entry_id:3/param3:45/param4:23
costs/add/cost_id:2/param4:22
Po drugie – przenieś logikę działania do modelu. np:
//model Cost
function giveMeProperData($params){
if(isset($params["cost_id")){
// do sth
return $some_array;
}
if(isset($params["entry_id")){
// do sth
return $other_array;
}
return array();
}
Kontroler uprość do granic możliwości:
function add($entryId=null) {
if(!empty($this->data)){
$this->Cost->save($this->data);
// redirect w jakieś przyjemne okolice
}
$this->data = $this->Cost->giveMeProperData($this->params["named"];
}
A w widoku w zależności od tego w jaki sposób wygląda $this->data renderuj widok.
Jeśli w tablicy jest “entry_id” to wyświetl zawartość $this->data["Entry"]["name"], a wartość $this->data["Entry"]["id"] przypisz do jakiegoś ukrytego pola. Itd.
Co zyskujesz?
- Kontroler robi to co do niego należy. Przestaje go interesować przypadek właśnie rozpatrywany. Jego interesuje tylko czy został przesłany formularz. Jeśli tak – spróbuj zapisać i przekierować, jeśli nie – pobierz dla niego dane i wyświetl (przekaż do widoku)
- Testowanie modeli, choć nie tak proste, jest o niebo łatwiejsze od testowania kontrolerów i widoków – możesz pokryć ważną część aplikacji testami. Możesz testować, czy metoda giveMeProperData() zwraca to, czego się spodziewasz dla konkretnych argumentów.
- Jeśli pokryjesz tą metodę testami będziesz mógł w komfortowych warunkach refaktoryzować ten kod – jak się pojawią powtórzenia wyodrębnisz wspólne części itd. Gdyby to siedziała w kontrolerze – nikt nie miałby ochoty tam zajrzeć, a testowanie polegało by głównie na odświeżaniu widoków w przeglądarce dla każdego z przypadków.
- Teraz widok jest klasą, która “ma wiedzę” na temat tego jak się zachować w zależności od otrzymanych danych (w prostych przypadkach to już się dzieje – chociażby w akcjach “edit”). Wcześniej część tej wiedzy znajdowała się w kontrolerze.
- Przyjemny side-effect wynikający z używania parametrów “named”: Teraz komponując link w widoku zamiast zastanawiać się czy formularz dodawania kosztów dla danej firmy jest pod /costs/add//[id_firmy]/ czy pod /costs/add///[id_firmy] – wiem, że jest pod /costs/add/entry_id:[id_firmy]
Jest oczywiście brak w tym rozwiązaniu – dość sporo kodu wędruje do widoku (który, jak wspomniałem, trudno testować). Jednak pozostawienie architektury w pierwotnym stanie tej kwestii nie rozwiązuje. Na początku masz problem z
- dużą ilością kodu w kontrolerze
- kodem kontrolera, który nie jest pokryty testami więc nie będzie refaktoryzowany
- kodem w widoku, który nie jest pokryty testami
Po zastosowanej zmianie zostanie Ci tylko ostatni punkt. Samodzielnie możesz zdecydować, czy to się opłaca.
Zcalenie edit() i add() w kontrolerze
Gdy przyjrzysz się bliżej kontrolerom stworzonym przy pomocy narzędzia ‘bake’ (lub takim, jakie powstają po wykonaniu tutoriala) możesz stwierdzić, że łamią one koncepcję DRY. Jesteś w stanie powiedzieć, które z dwóch metod są niemal identyczne? Jeśli nie – sprawdź, które dwa widoki są niemal identyczne. Ok, może spaliłem swoją zagadkę, bo w tytule tego postu jest wyraźna sugestia o czym będę mówił. Zgadza się.
Najpierw rzućmy okiem na widoki (tu w przykładzie dla Modelu Thing, który ma jedno pole – name):
// app/views/things/add.ctp
<div class="things form">
<?php echo $this->Form->create('Thing');?>
<fieldset>
<legend>
<?php printf(__('Add %s', true), __('Thing', true)); ?>
</legend>
<?php
echo $this->Form->input('name');
?>
</fieldset>
<?php echo $this->Form->end(__('Submit', true));?>
</div>
<div class="actions">
<h3><?php __('Actions'); ?></h3>
<ul>
<li>
<?php echo $this->Html->link(
sprintf(
__('List %s', true),
__('Things', true)
),
array('action' => 'index')
);?>
</li>
</ul>
</div>
// app/views/things/edit.ctp
<div class="things form">
<?php echo $this->Form->create('Thing');?>
<fieldset>
<legend>
<?php printf(__('Edit %s', true), __('Thing', true)); ?>
</legend>
<?php
echo $this->Form->input('id');
echo $this->Form->input('name');
?>
</fieldset>
<?php echo $this->Form->end(__('Submit', true));?>
</div>
<div class="actions">
<h3><?php __('Actions'); ?></h3>
<ul>
<li>
<?php echo $this->Html->link(
__('Delete', true),
array('action' => 'delete', $this->Form->value('Thing.id')),
null,
sprintf(
__('Are you sure you want to delete # %s?', true),
$this->Form->value('Thing.id')
)
); ?>
</li>
<li>
<?php echo $this->Html->link(
sprintf(__('List %s', true), __('Things', true)),
array('action' => 'index')
);?>
</li>
</ul>
</div>
Różnią się dokładnie w trzech linijkach (na 16 łącznie). Jeśli zmienisz coś w jednym (na przykład dodasz pole) to będziesz musiał poprawić formularz w dwóch plikach. Czysta głupota.
Przyjrzyjmy się jeszcze metodom:
// app/controllers/things_controller.ctp
function add() {
if (!empty($this->data)) {
$this->Thing->create();
if ($this->Thing->save($this->data)) {
$this->Session->setFlash(__('The thing has been saved', true));
$this->redirect(array('action' => 'index'));
} else {
$this->Session->setFlash(
__('The thing could not be saved. Please, try again.', true)
);
}
}
}
function edit($id = null) {
if (!$id && empty($this->data)) {
$this->Session->setFlash(__('Invalid thing', true));
$this->redirect(array('action' => 'index'));
}
if (!empty($this->data)) {
if ($this->Thing->save($this->data)) {
$this->Session->setFlash(__('The thing has been saved', true));
$this->redirect(array('action' => 'index'));
} else {
$this->Session->setFlash(
__('The thing could not be saved. Please, try again.', true)
);
}
}
if (empty($this->data)) {
$this->data = $this->Thing->read(null, $id);
}
}
Praktycznie całe ciało metody add() można by wkleić w miejsce drugiego if’a w metodzie edit() i niewiele by się zmieniło, prawda?
Kilka tricków pozwoli nam naprawić to marnotrawstwo. W zasadzie najważniejsza kwestia jest następująca – jak działa metoda $this->Thing->create()? Wymusi dodanie nowego elementu (dlatego, że metoda save() działa jako create lub update w zależności od stanu modelu). Ale nie przekazując żadnych parametrów do tej metody tak naprawdę sprowadzi się do wyczyszczenia $this->data i $this->id (this oznacza w tym przypadku model). W przypadku metody add() nie ma możliwości, żeby jakieś śmieci były w $this->Thing->data, więc możemy spokojnie usunąć tą linijkę – dalej będzie działać tak samo (czy na pewno? – spoiler).
Teraz całkowicie ciało metody add() mogłoby zawrzeć się w metodzie edit(). Dlatego trzeba ją usunąć (razem z jej widokiem). A wszystkie linki, które prowadziły do /things/add niech teraz prowadzą do /things/edit.
Warto sprawdzić, co się teraz dzieje?
W zależności od wersji – 1.2 się wywali, 1.3 pokaże informacje “Invalid thing”. Teraz skupię się na wersji 1.3 – bo na niej sprawdzam na bieżąco efekty pisząc ten wpis. (dla 1.2 jeśli dobrze pamiętam wystarczy wstępnie zmienić sygnaturę metody edit($id) na edit($id=null), dalsze kroki będą analogiczne, choć kod w szczegółach może się różnić)
Od teraz możemy założyć, że jeśli w url’u nie ma podanego id – to znaczy, że chcemy dodać nowy element, zatem tego if’a możemy usunąć:
// app/controllers/things_controller.php
if (!$id && empty($this->data)) {
$this->Session->setFlash(__('Invalid thing', true));
$this->redirect(array('action' => 'index'));
}
Możemy spokojnie teraz dodawać nowe elementy przez formularz- wszystko działa.
Warto jeszcze dopieścić widok – bez sensu jest nagłówek “edit” przy dodawaniu elementu, oraz przycisk “delete”,
fragment z legend poprawiamy na taki (dodałem wcięcia dla przejrzystości):
// app/views/things/edit.ctp
<legend>
<?php printf(
(!is_null($this->data["Thing"]["id"])?__('Edit %s', true): __('Add %s', true)),
__('Thing', true));
?>
</legend>
a pierwszy element li otaczamy if’ami tak:
// app/views/things/edit.ctp
<?php if(!is_null($this->data["Thing"]["id"])): ?>
<li>
<?php echo $this->Html->link(
__('Delete', true),
array('action' => 'delete', $this->Form->value('Thing.id')),
null,
sprintf(
__('Are you sure you want to delete # %s?', true),
$this->Form->value('Thing.id')
)
); ?>
</li>
<?php endif; ?>
I tyle.
Usunięcie $this->Thing->create() nie takie bezpieczne?
Napisałem, że po usunięciu tego elementu nic się złego nie stanie. Oczywiście jest to prawdą pod warunkiem, że wykonasz wszystkie czynności opisane w tym poscie. Jeśli tylko usuniesz $this->Thing->create() narażasz się na niebezpieczeństwo, że ktoś wyśle spreparowanego post’a pod ten adres. Jeśli poda w nim pole o nazwie data[Thing][id] – będzie mógł zmienić dane dla elementu o danym id. Jeśli masz sytuację taką, że jedna grupa może dodawać elementy, a inna je edytować i polegasz tylko na acl’ach (dana grupa ma dostęp do edit(), a kolejna do add()) – to w ten sposób możesz stworzyć lukę. Jednak ta sytuacja jest dość rzadka i to jest raczej temat na inny wpis, więc jeśli interesuje Cię rowiązanie tego problemu – zachęcam do komentowania.
