Kompresja obrazu w javie

Jest wiele metod kompresji, wiele różnych algorytmów, które to wykonują. Oczywiście, można samemu napisać kompresję obrazu ale czy warto, skoro można skorzystać z gotowych rozwiązań. W javie z pomocą idą nam klasy z pakietu javax.imageio i przykładowy kod może wyglądać następująco.

private void compress(BufferedImage image, File file, float compressionLevel) {
   ImageWriter writer = null;
   Iterator iterator = ImageIO.getImageWritersByFormatName("jpg");
   if (iterator.hasNext()) {
      writer = (ImageWriter) iterator.next();
   }
   ImageOutputStream ios = null;

   ios = ImageIO.createImageOutputStream(file);
   writer.setOutput(ios);

   ImageWriteParam imageWriteParam = writer.getDefaultWriteParam();
   imageWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
   imageWriteParam.setCompressionQuality(compressionLevel);

   writer.write(null, new IIOImage(image, null, null), imageWriteParam);

   ios.flush();
   writer.dispose();
   ios.close();

} 

Ta metoda za parametry przyjmuje:

  • obraz, kt6ry będzie kompresowany
  • plik, do którego będzie zapisany skompresowany wynik
  • oraz stopień kompresji. Współczynnik ten przyjmuje wartości od 0.0 do 1.0. Im niższy tym rozmiar pliku jest mniejszy ale jednocześnie gorsza jakość.

A kod początkowo pobiera writera dla odpowiedniego typu – jpg i ustawia odpowiedni outputStream, ustawia jego parametry i dokonuje kompresji. Oczywiście nie należy zapominać o obsłudze wyjątków, które tutaj są pominięte.

Reklamy

Iterowanie między przedziałem czasu

Dosyć często w aplikacjach wykorzystujemy czas aby ograniczyć dane pomiędzy czasem początkowym i końcowym. W takich sytuacjach czas ma znaczenie drugorzędne. Jednak czasem zdarza się, że konkretna data ma znaczenie pierwszorzędne a to co jest przypisane do czasu jest dodatkiem. W takich sytuacjach, trzeba nieraz wykonywać operacje na kolekcjach dat.

Przy zakresie dat, gdzie zazwyczaj mamy datę początku i końca, przechodzenie po kolejnych elementach trochę się różni od zwykłych iteracji. I co mnie zdziwiło ale nie udało mi się znaleźć gotowej klasy, metody która to wykonuje. Jeśli istnieje to byłbym wdzięczny za informacje.

Ale samodzielne napisanie takiego iteratora nie jest wielkim problemem. Poniższy przykład wykorzystuje bibliotekę joda-time ale nic nie stoi na przeszkodzie aby użyć dowolnych klas odpowiadających za operacje na czasie.

import org.joda.time.*;
import java.util.*;

public class LocalDateRange implements Iterable<LocalDate>
{
    private final LocalDate start;
    private final LocalDate end;

    public LocalDateRange(LocalDate start,
                          LocalDate end)
    {
        this.start = start;
        this.end = end;
    }

    public Iterator<LocalDate> iterator()
    {
        return new LocalDateRangeIterator(start, end);
    }

    private static class LocalDateRangeIterator implements Iterator<LocalDate>
    {
        private LocalDate current;
        private final LocalDate end;

        private LocalDateRangeIterator(LocalDate start,
                                       LocalDate end)
        {
            this.current = start;
            this.end = end;
        }

        public boolean hasNext()
        {
            return current != null;
        }

        public LocalDate next()
        {
            if (current == null)
            {
                throw new NoSuchElementException();
            }
            LocalDate ret = current;
            current = current.plusDays(1);
            if (current.compareTo(end) > 0)
            {
                current = null;
            }
            return ret;
        }

        public void remove()
        {
            throw new UnsupportedOperationException();
        }
    }
}

W powyższym kodzie nie ma żadnej magii, zaimplementowanie interejsów Iterable oraz Iterator. Kod pochodzi z tej odpowiedzi.

Konfiguracja timezone dla JVM

Krótki post ku pamięci, bo chwilę musiałem poszukać jak ustawić odpowiedni timezone dla maszyny wirtualnej javy. A dokładniej mówiąc dla uruchomionego jbossa. W run.conf do parametrów JAVA_OPTS należy dodać parametr:

-Duser.timezone=Europe/Warsaw

Gdzie Europe/Warsaw zależy od strefy czasowej jaka będzie dla nas poprawna.

Walidacja schematu XSD

Było już tutaj o parsowaniu xml o tworzeniu wizualizacja xml poprzez transformatę xsl ale po co nam to wszystko jeśli nie posiadamy poprawnego xml. Jeśli jest on niezgodny ze schemą xsd. Oczywiście istnieje wiele walidatorów online ale nie zawsze mamy do nich dostęp. Jednym z rozwiązań jest napisanie własnego walidatora. Przy wykorzystaniu javovego api dotyczącego walidacji, jest to prostsze niż się wydaje na pierwszy rzut oka.

import java.io.*;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.*;
import org.xml.sax.SAXException;

public class MyValidator {

    public static void main(String[] args) throws SAXException, IOException {

        // 1
        SchemaFactory factory =
            SchemaFactory.newInstance("http://www.w3.org/2001/XMLSchema");

        // 2
        File schemaLocation = new File("/path/to/my/schema.xsd");
        Schema schema = factory.newSchema(schemaLocation);

        // 3
        Validator validator = schema.newValidator();

        // 4
        Source source = new StreamSource(args[0]);

        // 5
        try {
            validator.validate(source);
            System.out.println(args[0] + " is valid.");
        }
        catch (SAXException ex) {
            System.out.println(args[0] + " is not valid because ");
            System.out.println(ex.getMessage());
        }

    }

}

Mniej niż 10 linijek głównego kodu robi wrażenie ale co tu się dzieje kolejno.

  1. Utworzenie fabryki dla konkretnej schemy, domyślnie dostarczone są:
  2. Załadowanie konkretnej schemy xsd i jej kompilacja.
  3. Utworzenie walidatora ze skompilowanej schemy.
  4. Załadowanie xml, który ma zostać sprawdzony.
  5. Przeprowadzenie walidacji. Jeśli xml jest niezgodny ze schema, to zostanie rzucony wyjątek gdzie będzie można się dowiedzieć dlaczego plik jest nie poprawny.

Oparte na tym wpisie

Service w grails

Nie długi rozdział książki Definitive guide to grails, tym razem podejmuje tematykę serwisów.

Często stosowana w aplikacjach jest warstwa serwisów (service layer), która zawiera w sobie operacje biznesowe. Dzięki nim można wprowadzić warstwę abstrakcji oraz ograniczyć zależności pomiędzy warstwami mvc. Serwisów można używać dla:

  • potrzeby scentralizowania logiki biznesowej w API
  • przypadki użycia aplikacji operują na wielu klasach domenowych i operacji lepiej nie mieszać w jednym kontrolerze
  • pewne procesy powinny być zhermetyzowane poza klasami domenowymi

Serwisy zazwyczaj mają wiele zależności(jdbc, orm).

Serwisy w grails nie rozszerzają żadnej klasy i znajdują się w grails-app/services/. Serwis tworzy się poleceniem create-service name. Do nazwy pliku zostaje dodana końcówka Service.

Serwisy domyślnie są singletonami. Dzięki korzystaniu ze springa mogą zostać wstrzyknięte(injected) do kontrolera (poprzez autowiring). Następuje to poprzez utworzenie pola o takiej nazwie jak serwis

class StoreController {
  def storeService

}

Aby wstrzyknąć serwis do innego należy postąpić tak samo. Nie należy tworzyć samemu instancji serwisów, tylko pozostawić to grails (traci się wtedy transakcyjność).
W serwisach są transakcje i są one zgodne z ACID:

  • atomicity – cząsteczkowe
  • consistency – spójne
  • isolation – ilozacyjne, odseparowane
  • durability – trwałe

Serwisy posiadają statyczną zmienną transactional, która ustawiona na true zapewnia transakcyjność. Można włączyć transakcyjność dla wybranych metod poprzez ustawienie transactional na false i wywołanie klasy domenowej z metodą withTransaction.

// turn off automatic transaction management
static transactional = false
void someServiceMethod() {
  Album.withTransaction {
    // everything in this closure is happening within a transaction
    // which will be committed when the closure completes
  }
}

Jeśli w przekazanym domknięciu wystąpi błąd nastąpi rollback transakcji.

Zakres serwisów

  • prototype – nowy serwis jest tworzony zawsze jeśli jest wstrzyknięty do innej klasy
  • request – nowy serwis na każdy request
  • flash – nowy dla obecnego i następnego
  • flow, conversation tak jak w grails web flow
  • session – dla całej sesji dla danego użytkownika
  • singleton – domyślny tylko jeden

Dla zasięgów flow, flash i conversation należy zaimplementować Serializable i mogą one być używane tylko z web flow. Deklaracja zasięgu odbywa się poprzez:

static scope = 'request'

Testowanie serwisów odbywa się poprzez testy integracyjne (dependency injection). Jeśli ma być test jednostkowy to trzeba utworzyć instancję (brak dependency injection).

Wystawianie serwisów odbywa się poprzez zmienną expose która jest listą

static expose = ['jmx', 'xfire']

Poprzez pluginy są dostępne różne możliwości jak np jmx, rmi, axis2 itd.

Parę słów o GORM

Jak do tej pory w książce Definitive guide to grails temat gorm pojawiał się chyba przez wszystkie rozdziały i teraz najwyższy czas o szczegółowe przyglądnięcie się temu zagadnieniu.

Każda klasa domenowa automatycznie jest rozszerzona o pewne metody, wspierające zapytania. Na przykład metoda get(id), zwraca daną encję lub null jeśli nie zostanie znaleziona.
getAll(id1, id2) – zwraca listę obiektów, może jako parametr przyjąć listę.
Te dwie metody sprawiają, że zwracany obiekty można modyfikować (persisted). Jeśli jest potrzebny obiekt tylko do odczytu (read-only) z pomocą przychodzi metoda read(id).

Zazwyczaj z bazy pobiera się jakąś listę elementów i służy do tego metoda list(), która zwraca wszystkie encje. Można wprowadzić dodatkowe parametry

  • max – ilość zwracanych rekordów
  • offset – wykorzystywane przy stronicowaniu
  • sort – właściwość po której jest sortowanie
  • order – kolejność sortowania

Często z metodą list() jest używana metoda count(), która zwraca ilość elementów.
Przykład użycia tych metod i parametrów:

// get all the albums; careful, there might be many!
def allAlbums = Album.list()
// get the ten most recently created albums
def topTen = Album.list(max:10, sort:'dateCreated', order:'desc')
// get the total number of albums
def totalAlbums = Album.count()

Dodatkowo są metody listOrderBy*, gdzie w miejsce * wstawia się nazwę pola, np

def allByDate = Album.listOrderByDateCreated()

Zapisywanie nowego i aktualizacja istniejącego rekordu odbywa się poprzez metodę save(). Hibernate, który znajduje się w GORM automatycznie rozpoznaje czy ma wykonać aktualizację czy zapis. Przy starszych silnikach bazodanowych, mogą się pojawić błędy ale można to obejść poprzez wymuszenie:

object.save(insert:true)

Usuwanie to metoda delete()

Zazwyczaj relacja jeden do wielu (one-to-many) to jest java.util.Set. Jeśli jest ważna kolejność to można użyć SortedSet. Trzeba podać wtedy regułę sortowania i może to być poprzez implementację Comparable.
Ewentualnie można dodać w właściwości mapping sortowanie (i wtedy będzie użyte dla każdego zapytania)

static mapping = {
sort "trackNumber"
}

Takie sortowanie może nie być potrzebne dla każdego zapytania a potrzebne dla zapytań jeśli jest powiązanie z innymi elementami.

static mapping = {
songs sort: "trackNumber"
}

Klasa jest w relacji 1-n z klasą Song, którą reprezentuje zbiór songs.

Zamiast setów, można używać listy i odwoływać się przez indeksy

List songs
println album.songs[0]

Także mapy są dozwolone, gdzie kluczem(indeksem) jest jakiś string.
Jako, że GORM zapewnia asocjacje to także posiada metody do zarządzania nimi addTo* i removeFrom*, które zwracają obiekt na którym zostały wywołane i dzięki temu wywołania można łączyć w łańcuchy

new Album(title:"Odelay",
artist:beck
year:1996)
.addToSongs(title:"Devil's Haircut", artist:beck, duration:342343)
.addToSongs(mySong)
...
.save()

Domyślna kaskadowość w GORMie zależy od właściwości belongsTo. Jeśli ta właściwość istnieje to operacje zapisu, updatu i usunięcia są kaskadowe. Jeśli jej nie ma to tylko zapis i aktualizacja są a usuwanie nie jest kaskadowe. Można nad tym mieć większa kontrolę poprzez

static mapping = {
songs cascade:'save-udpate'
}

Możliwe opcje takie jak w hibernate.

Dynamiczne findery są jednym z najpoteżniejszych konceptów GORM, trochę zbliżone do wcześniejszych listOrderBy*. Mogą zawierać w sobie logiczne zapytania jak And, Or, Not i wyglądają findBy*, gdzie w miejsce gwiazdki wchodzi pole z klasy z odpowiednim łączeniem np

findByTitleAndTime("title", 30)

W tych zapytaniach są dozwolone także inne operatory(operator, ilość parametrów, przykład):

  • Between 2 Album.findByDateCreatedBetween(today-10,today)
  • Equals 1 Album.findByTitleEquals(‚Aha Shake Heartbreak’)
  • GreaterThan 1 Album.findByDateCreatedGreaterThan(lastMonth)
  • GreaterThanOrEqual 1 Album.findByDateCreatedGreaterThanOrEqual(lastMonth)
  • InList 1 Album.findByTitleInList([‚Aha Shake Heartbreak’, ‚Odelay’])
  • IsNull 0 Album.findByGenreIsNull()
  • IsNotNull 0 Album.findByGenreIsNotNull()
  • LessThan 1 Album.findByDateCreatedLessThan(lastMonth)
  • LessThanOrEqual 1 Album.findByDateCreatedLessThanOrEqual(lastMonth)
  • Like 1 Album.findByTitleLike(‚Shake’)
  • NotEqual 1 Album.findByTitleNotEqual(‚Odelay”)

Podobnymi metodami dla findBy* są findByAll* (zwraca listę) i countBy*(ilość).
Dzięki dynamicznym finderom, można w aplikacji zrezygnować z warstwy DAO.

Kryteria są również potężnym narzędziem wykorzystującym buildery w groovy. Kryteria w GORM są bardziej rozbudowane niż w hibernate.
Przed użyciem kryteriów należy stworzyć instancję kryteriów dla danej klasy poprzez statyczną metodę createCriteria(). Po utworzeniu instancji można wywołać jedną z czterech metod z domknięciami:

  • get – odnajduje unikalny element
  • list – zwraca listę
  • scroll – zwraca ScrollableResults dla zapytania
  • count – ilość elementów

Najpowszechniejsze jest list

def c = Album.createCriteria()
def results = c.list {
  eq('genre', 'Alternative')
  between('dateCreated', new Date()-30, new Date())
}

Dostępne kryteria oparte są na klasie Restricions.
Jeśli jest asocjacja w klasie to również można użyć jej w kryteriach. Tworzy się to poprzez zbudowanie zagnieżdżonego kryterium gdzie nazwą jest właściwość w klasie

def criteria = Album.withCriteria {
  songs {
    ilike('title', '%Shake%')
  }
}

Istnieją zapytania przez przykład (query by example) gdzie jako parametr do finderów jest przekazywany obiekt:

def album = Album.find( new Album(title:'Odelay') )

GORM wspiera także zapytania w hql. Występują tutaj metody find, findAll i executeQuery. Dozwolone są kolejne parametry (positional parameters), paramery nazwane (named parameters)

// query for all albums
def allAlbums = Album.findAll('from com.g2one.gtunes.Album')
// query for an Album by title
def album = Album.find(
'from Album as a where a.title = ?',
['Odelay'])
// query for an Album by title
def album = Album.find(
'from Album as a where a.title = :theTitle',
[theTitle:'Odelay'])
// get all the songs
def songs = Album.executeQuery('select elements(b.songs) from Album as a')

Do stronicowania wykorzystuje się parametry offset i max. Można je przekazać w ostatnim argumencie dynamicznych finderów jako mapę:

def results = Album.findAllByGenre("Alternative", [max:10, offset:20])

W widoku służy do tego tag g:paginate, którego obowiązkowym atrybutem jest total, określający liczbę wszystkich encji. Domyślnie jest wywoływana aktualna akcja danego kontrolera, można to zmienić poprzez atrybuty contoller i action (tak jak w g:link). Również można określić nazwy przycisków dalej i wstecz poprzez atrybuty prev i next.

Wszystkie opcje konfiguracyjne w hibernate są dostępne w GORM. Wykorzystywane pliki to grails-app/conf/Config.groovy dla loggerów i grails-app/conf/DataSource.groovy. W tym pliku można zadeklarować blok hibernate i konfiguracja wygląda wtedy tak jak w plikach hibernate

hibernate {
  cache.use_second_level_cache=true
  cache.use_query_cache=true
  cache.provider_class='com.opensymphony.oscache.hibernate.OSCacheProvider'
}

Są dostępne zaawansowane mechanizmy zarządzania sesjami. Warto pamiętać o czyszczeniu sesji (mniejsze zużycie pamięci)

def index = {
  Album.withSession { session ->
    def allAlbums = Album.list()
    for(album in allAlbums) {
      def songs = Song.findAllByAlbum(album)
      // do something with the songs
      ...
      session.clear()
     }
  }
}

Występuje automatyczny zmiany (automatic flush) (Można zmienić na manual, commit i domyślny auto):

  • kiedy zapytanie jest uruchomione
  • bezpośrednio po skończeniu akcji kontrolera, jeśli nie został rzucony wyjątek
  • przed skomitowaniem transakcji

Dlatego warto używać metody save(), ponieważ zapewnia ona walidacje i sprawia że obiekt jest tylko do odczytu jeśli pojawi się jakiś błąd. Jeśli nie jest naszym zamiarem aby zmieniać obiekt lepiej korzystać z metody read.

Można tworzyć bloki z transakcjami, rollbackami i zapisami po części operacji

def save = {
  Album.withTransaction { status ->
    def album = Album.get(params.id)
    album.title = "Changed Title"
    album.save(flush:true)
    def savepoint = status.createSavepoint()
...
// something goes wrong
    if(hasSomethingGoneWrong()) {
      status.rollbackToSavepoint(savepoint)
// do something else
...
  }
}

Domyślnie asocjacje są lazy. Można to zmienić poprzez

static mapping = {
artist fetch:'join'
}[/ourcecode]
lub w zapytaniu

def albums = Album.list(fetch:[artist:'join'])
for(album in albums) {
println album.artist.name
}

Także w kryteriach, hql i dynamicznych finderach można tego użyć.
Dostępne są 4 strategie cacheowania:

  • read-only – tylko do odczytu
  • nonstrict-read-write – malo i rzadkie operacje zapisu
  • read-write – dużo i często, istnieje szansa zmian w tym samym czasie
  • transactional – pełne zachowanie transakcyjne
class Album {
...
 static mapping {
  cache true
  songs cache:'read-only'
  }
}

Również można dać cacheowanie do zapytań poprzez parametr cache:true
Możliwa jest tabela dla hierarchii i tabela dla podklasy
Automatyczne wspierane są dateCreated i lastUpdate, wyłączenie tego to

class Album {
...
  static mapping = {
    autoTimestamp false
  }
}

Web flows w grails

Znowu lektura książki Definitive guide to grails przyśpieszyła. Tym razem bardzo długi rozdział, który przeczytałem bardzo szybko, o web flows w grails. Nie miałem wcześniej praktycznego do czynienia z przepływami (obieg i przepływ będę używał jako tłumaczenia flow). Wiedza jaką wyniosłem z tego rozdziału jest ogromna i żałuję, że wcześniej nie wziąłem się za grails. Jak Jacek Laskowski wielokrotnie wspominał nauka grails to także nauka wielu innych narzędzi i technologi. Ale do rzeczy.

Grails zapewnia wsparcie dla web flows opartego na spring web flow. Przepływy w grails są to serie stanów od rozpoczynające się od stanu początkowego a kończące na ostatnim, kolejno po sobie następujące. Niemożliwe jest wywołanie stanów nie po kolei. Spring Web Flow jest zaawansowaną maszyną stanów, flowExecutionKey i Id zdarzenia są przekazywane pomiędzy klientem a serwerem jako parametry w requescie, umożliwiając przejścia pomiędzy kolejnymi stanami.

W przeciwieństwie do Spring Web Flow, w grails nie jest wymagana żadna konfiguracja xml. Aby utworzyć obieg należy w kontrolerze utworzyć metodę, która kończy się na Flow (konwencja)

def shoppingCartFlow = {
...
}

Każdy przepływ ma swoje własne id, które ma nazwę taką jak ta metoda tylko, że bez słowa Flow (konwencja ponownie), czyli dla powyższego to będzie shoppingCart.

W przeciwieństwie do zwykłych akcji, akcja przepływu w ciele domknięcia nie definiują logiki a sekwencję stanów. Początkowym stanem, jest zawsze pierwszy stan zdefiniowany w przepływie.

def shoppingCartFlow = {
  showCart {
    on("checkout").to "enterPersonalDetails"
    on("continueShopping").to "displayCatalogue"
  }
...
}

Jeśli nastąpi zdarzenie checkout to wtedy przejdzie się do stanu enterPersonalDetails

Widok dla danego stanu znajduje się w grails-app/views/controllerName/flowId/state.gsp np
grails-app/views/store/shoppingCart/showCart.gsp

Stan końcowy to stan który nie przyjmuje parametrów lub jeden (w którym następuje przekierowanie do akcji lub innego obiegu).

Można zmienić nazwę widoku poprzez metodę render w stanie

showCart {
  render(view:"basket")
...
}

Istnieją stany akcji i stany widoku.
Stan widoku wstrzymuje wykonywanie obiegu w celu wyrenderowaniu widoku i interakcji z użytkownikiem. (np showCart wyżej ten z render jak i z on).
Stan akcji wykonuje blok kodu po, którym następuje przejście dalej. np

listAlbums {
  action {
    [ albumList:Album.list(max:10,sort:'dateCreated', order:'desc') ]
  }
  on("success").to "showCatalogue"
  on(Exception).to "handleError"
}

Obiegi posiadają swoje własne zasięgi i obiekty w nich muszą implementować java.io.Serializable:

  • flash – przechowuje obiekty dla obecnego i następnego żądania.
  • flow – przez cały czas przepływu, usuwając obieg kiedy zostanie osiągnięty ostatni stan.
  • conversation – dla danego obiegu i jego podobiegów.

Istnieją dwie opcje wywołania stanu z linku i z wysłania formy (submitt).

Z linka, gdzie action to id przepływu

<g:link controller="store" action="shoppingCart">My Cart</g:link>

tutaj zawsze zostanie utworzony nowy przepływ, a jeśli chcemy wywołać jakieś zdarzenie to zostaje dodany atrybut event

<g:link controller="store" action="shoppingCart" event="checkout">Checkout</g:link>

Użycie zdarzenia w formie wygląda trochę inaczej, zdarzenie jest w atrybucie name submitButton

<g:form name="shoppingForm" url="[controller:'store', action:'shoppingCart']">
...
  <g:submitButton name="checkout" value="Checkout" />
  <g:submitButton name="continueShopping" value="Continue Shopping" />
</g:form>

Aby zwalidować formularz w przepływie, to można przekazać go do stanu akcji. Jednak zalecane jest wywołanie akcji przejścia (transition action). Jeśli transition action się nie powiedzie to przejście jest zatrzymywane a stan przywracany.

enterPersonalDetails {
  on("submit") {
    flow.person = new Person(params)
    flow.person.validate() ? success() : error()
  }.to "enterShipping"
  on("return").to "showCart"
}

Aby utworzyć podobieg należy użyć subflow(nazwaObieguFlow)

wrappingOptions {
  subflow(chooseGiftWrapFlow)
  on('giftWrapChosen') {
    flow.giftWrap = conversation.giftWrap
  }
  on('cancelGiftWrap'). to 'enterShippingAddress'
}

Zdarzeniami tutaj są wszystkie końcowe stany przepływu chooseGiftWrapFlow.

Istnieje w requescie właściwość xhr która określa czy żądanie jest ajaxowe.

W przepływach można zastosować CommandObject.

W obiegu można dynamicznie tworzyć przejścia pomiędzy stanami:

on('back').to {
  def view
  if(flow.genreRecommendations || flow.userRecomendations)
    view = "showRecommendations"
  else if(flow.lastAlbum.shippingAddress) {
    view = 'enterShipping'
  }
  else {
    view = 'requireHardCopy'
  }
return view
}

W różny sposób można zapisać przejścia między stanami

on('back').to 'enterShipping' // static String name
on('back').to { 'enterShipping' } // Groovy optional return
on('back').to { return 'enterShipping' } // Groovy explicit return

wszystkie powyżej są równoważne.

Poprzez assert można sprawdzić czy stan jest w odpowiednim stanie (masło maślane – ale chodzi o to czy nie pojawił się jakiś błąd np przy zapisie do bazy, pola są zwalidowane itp).

Testowanie przepływów jest wykonywane za pomocą klasy grails.test.WebFlowTestCase i są to testy integracyjne. Przy wygenerowaniu testu należy pamiętać o podmianie klasy z której nasza klasa dziedziczy (z GroovyTestCase na WebFlowTestCase).
Następnie należy zaimplementować abstrakcyjną metodę getFlow, która zwraca domknięcie, które reprezentuje web flow np

def controller = new StoreController()
def getFlow() {
  controller.buyFlow
}
%d blogerów lubi to: