Posts Tagged DRY

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?

  1. 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)
  2. 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.
  3. 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.
  4. 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.
  5. 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.

, ,

No Comments

Dług techniczny – przykład z życia wzięty

Można się spierać co jest długiem technicznym, a co nie jest. Chciałbym Wam pokazać przykład kodu, który świetnie nadaje się do zobrazowania problemu.

Problem:
Na jeden ze stron jest wyszukiwarka- formularz, w którym po wpisaniu danych w inputach i selectach przesyła te dane jako parametry “named”, np.:
example.com/controller/action/field1:value1/field2:value2
Potrzebny jest kawałek kodu javascript, który wygeneruje odpowiedni link i wywoła ten adres

Rozwiązanie 1:

function submitform()
{
    form = document.getElementById('car_form');
    name = form.elements["CarName"].value;
    registration_number = form.elements["CarRegistrationNumber"].value;
    entry = document.getElementById('CarEntry').value
    driver = document.getElementById('CarDriver').value
    linkForm = 'url('/cars/find/')?>';
    location = linkForm+'name:'+name+'/registration_number:'+registration_number+'/entry:'+entry+'/driver:'+driver;
    document.forms["car_form"].action = location;
}

Jest ono poprawne. Warto się zastanowić, czy został w tym przypadku zaciągnięty dług techniczny? Na pewno będą tacy, którzy od razu będą wiedzieć co z tym kodem jest nie tak. Inni będą to czuli w kościach. Resztę pocieszę mówiąc, że ta umiejętność przychodzi z czasem pod warunkiem, że chcesz się uczyć jak pisać dobry kod.

Ten formularz wisi sobie gdzieś w jakimś panelu admina w listingu samochodów. Jednak taka wyszukiwarka jest potrzebna też przy listingu kierowców. Oczywiście kopiujesz rozwiązanie i poprawiasz je do nowych warunków. Nie ma w tym nic złego pod warunkiem, że robisz to z odpowiednim nastawieniem: “kopiuję kod, żeby móc zauważyć części wspólne i przeprowadzić refactoring” (czasem łatwiej mieć dwa dublujące się elementy i na ich podstawie tworzyć uniwersalny element/funkcje niż wymyślać ją od zera).

No i zabierając się za refactoring – w tym wypadku chciałbym zbudować wspólny element (dla tych co raczej siedzą w RoR – partial), który wywołam sobie tam, gdzie potrzebuję – zaczynam widzieć, że teraz oto przyjdzie mi spłacić ów mistyczny dług techniczny. Na czym on polega? Otóż w moim przykładzie funkcja budująca url jest strasznie “sztywna”. Działa w tym i tylko w tym przypadku. Nie ma najmniejszych znamion uniwersalności. Dlatego teraz, zanim zacznę myśleć o elemencie, muszę tą funkcję przebudować na bardziej uniwersalną. Co to znaczy?

Powinna być w stanie złapać wszystkie inputy i selecty, które są istotne przy budowaniu url’a i iterując po nich sprytnie go zbudować. Mogła by wyglądać na przykład tak:

Rowziązanie 2:

function submitform(){
    var params = "";

    $("#car_form input[type!=submit][name!=_method], #car_form select").each(
    	    function(index,element) {
        	    params +=
        	    		  element.name.substr(
        	    				  element.name.lastIndexOf("[")
        	    		  ).slice(1, -1)+
        	    		  ":"+
        	    		  element.value+
        	    		  "/";
    	    }
    );
    window.location.href = 'url('/cars/find/')?>' + params;
    return false;
}

Teraz inputy i selecty są zbierane bardziej automatycznie ("#car_form input[type!=submit][name!=_method], #car_form select"), a nazwy parametrów są brane z parametru name tych inputów i selectów. Dzięki temu powinna działać dla każdego formularza. Mniejsza o szczegóły.

Jednak rozwiązanie 1 nie było złe. Było wręcz idealne (bo proste i zrozumiałe) tak długo, jak było potrzebne w jednym miejscu. Rozwiązanie 2 mogło zająć przynajmniej 4 razy tyle czasu co pierwsze. Dlatego dopóki nie było powtórzeń kodu - było dobre. Dług techniczny nie istniał. Jednak pojawił się w momencie, kiedy zacząłem refaktoring kodu.

Jak to możliwe, że dług techniczny pojawił się w momencie, kiedy zacząłem go "spłacać"?
Czy istniał cały czas i był ukryty, a ja go nagle odkryłem?

Odpowiem na to pytanie nie wprost: Dług techniczny jest tylko pojęciem umożliwiającym zrozumienie ludziom, kŧórzy nie są programistami, dlaczego nie wolno im zachęcać programistów do chodzenia na skróty. Uzmysławia też (mam nadzieję) niedoświadczonym programistom jakie są niebezpieczeństwa związane z chodzeniem na skróty.

Leniwy Dobry, doświadczony programista w ogóle nie musi zagłębiać się w szczegóły czy dług techniczny istnieje, czy nie. Po prostu tworzy dobry kod.

, , , ,

1 Comment

Tutorial: dashboard web 2.0 dla leniwych…


czyli dobrych programistów ;)

W moim pierwszym tutorialu napiszę jak wydajnie korzystać z tego co daje Ci cakePHP i jQuery. Aktualnie cake opiera się na współpracy z innym frameworkiem javascript – prototype. Ale już w wersji 1.3 core cake’a ma współpracować z jquery. Już nie mogę się doczekać. Ten tutorial oparłem na wersji 1.3.0-beta dlatego, że przy okazji jego pisania chciałem rzucić okiem na kolejną wersję tego świetnego frameworka php. Jednak nie będę korzystał z żadnych helperów jquery, zatem większość z tych rad powinna pasować do wersji 1.2 (w razie problemów – chętnie służę pomocą).
Jest to mój pierwszy tutorial, więc proszę o wskazówki, komentarze i odrobinę wyrozumiałości.

Efekt jaki chcemy osiągnąć można obejrzeć jako demo oraz źródła na świetnym hostingu, który gorąco wszystkim webdeveloperom i ich klientom polecam: vipserv.org.

W tym tekście zakładam, ze znasz podstawy cakePHP i javascript. Czyli przynajmniej zrobiłeś sławny blog tutorial , wiesz jak mniej więcej używać narzędzia bake, oraz potrafisz przynajmniej zniknąć element na stronie i zmienić jego kolor przy pomocy czystego javascript’u.

Dashboard na zakładkach

Zacznijmy od przygotowania przykładowej aplikacji. Wypiekamy (cake bake) aplikację złożoną z dwóch modeli Thing i Item (każda tabela składa się z id i pola tekstowego name).

Następnie tworzymy dla niej zbiorczy widok (tzw. dashboard, metoda all())

// /app/controllers/items_controller.php
class ItemsController extends AppController {
 //...
 function all() {}
}
// /app/views/items/all.ctp
<?php echo $this->requestAction(
            array(
               "controller"=> "items",
               "action"=> "index"),
            array("return")
      ); ?>
<?php echo $this->requestAction(
            array(
               "controller"=> "things",
               "action"=> "index"),
            array("return")
      ); ?>

Na razie nie przejmujcie się kwestią requestAction i wydajności. Zazwyczaj efekty requestAction należy umieszczać w cache’owanych elementach, ale w naszym przypadku już niedługo zrezygnujemy z samego requestAction.

widok zbiorczy - dashboard

tak to wygląda teraz

Zróbmy z tego widoku zakładki modyfikując widok all.ctp w następujący sposób:

<script type="text/javascript">
   $(document).ready(function() {
      $("#tabs").tabs();
   });
</script>

<div id="tabs">
   <ul>
      <li><a href="#tabs-1">Items</a></li>
      <li><a href="#tabs-2">Things</a></li>
   </ul>
   <div id="tabs-1">
      <?php echo $this->requestAction(
                  array(
                     "controller"=> "items",
                     "action"=> "index"),
                  array("return")
            ); ?>
   </div>
   <div id="tabs-2">
      <?php echo $this->requestAction(
                  array(
                     "controller"=> "things",
                     "action"=> "index"),
                  array("return")
            ); ?>
   </div>
</div>

Żeby wypieczone widoki były “otoczone” ramką z zakładki – trzeba w nich dodać taki fragment na samym końcu plików index.ctp:

<div style="float: none; clear: both;">&nbsp;</div>
Widok zakładek

tak wyglądają nasze zakładki

Dodajmy więcej elementów, żeby zobaczyć jak zachowuje się pagination…

insert into items(`name`) values ('Item 4'), ('Item 5'), ('Item 6'), ('Item 7')

i zmieńmy parametry stronicowania, żeby efekty stronicowania zobaczyć szybko ;)

class ItemsController extends AppController {
//...
	var $paginate = array(
	  'limit'=> 5
	);
//...

Widzimy, że w naszym zbiorczym widoku link ciągle prowadzi do items/index. Wolelibyśmy, żeby stronicowanie odbyło się w zakładkach.

Można oczywiście próbować rozwiązania via php. Oprogramować obsługę parametrów w metodzie all(), zmienić cel linków stronicowania i sortowania w widokach. I ogólnie dużo napracować się nad tym, żeby nasz kod wyglądał źle ;) . Już za chwilę moc javascript i w szczególności jQuery przyjdzie nam z pomocą. Zanim to nastąpi – zamieńmy nasze zakładki na Ajaxowe zmieniając widok all.ctp:

// views/items/all.ctp
<script type="text/javascript">
   $(document).ready(function() {
      $("#tabs").tabs();
   });
</script>

<div id="tabs">
   <ul>
      <li>
         <?php echo $html->link(
                  "Items",
                  array(
                     "controller"=> "items",
                     "action"=> "index"
                  )
               );
         ?>
      </li>
      <li>
         <?php echo $html->link(
                  "Things",
                  array(
                     "controller"=> "things",
                     "action"=> "index"
                  )
               );
         ?>
      </li>
   </ul>
</div>

…prościzna, prawda? Kwestię requestAction też już mamy z głowy. Jednak pojawił się problem – wnętrze zakładek pojawiło się w powtórzonym layoucie.
Szybko naprawimy to w app_controller.php przy pomocy RequestHanlder:

// /app/app_controller.php
var $components = array("RequestHandler");
function beforeFilter(){
    if($this->RequestHandler->isAjax()){
      $this->layout = "ajax";
    }
}

Wrócmy do kwestii stronicowania (i sortowania przy okazji). Chcemy, aby kolejne strony ładowały się w danej zakładce. Wykorzystajmy do tego potęgę jQuery.
Dodajemy taki prosty skrypt

// views/items/all.ctp
   $('#tabs a[href*=/sort:],#tabs a[href*=/page:]').live('click', function(){
       $('#tabs>div:visible').load($(this).attr('href'));
          return false;
   });

Jeśli interesuje Cię co się w tym fragmencie dzieje, zapraszam na do innego wpisu. W dużym skrócie: łapiemy linki, które mają w adresie “sort:” i “page:” i zmuszamy je aby przeładowały zawartość zakładki, zamiast całej strony.

, , ,

3 Comments