Szkolenia dla programistów - BNS IT
Szkolenia AgileKonferencje Publikacje Klienci Zespół
 
  Książki    Artykuły     
 
Mariusz Sieraczkiewicz
Rozwarstwienie

Dlaczego o warstwach?

O warstwach, architekturze dwuwarstwowej, trójwarstwowej, wielowarstwowej słyszał prawie każdy programista. Jednak w wielu rozmowach na temat programowania odnoszę wrażenie, że jest to zagadnienie traktowane marginalnie, jak coś co ma niewielki wpływ na codzienną pracę programisty. Mimo że temat jest związany z  architekturą systemu, to bez względu na rolę, jaką pełnisz w projekcie wpływa on lub może wpływać na to, co robisz. Ten artykuł mówi o tym, jak w praktyce wykorzystać ten koncept, jak jego zrozumienie może wpłynąć na polepszenia Twojego kodu i jak jednocześnie być pragmatycznym w tej kwestii.

O co właściwie chodzi?

Koncept warstw powstał, tak jak wiele różnych idei, po to by ułatwiać życie. W tym przypadku chodzi o ułatwienie tworzenia systemów informatycznych. Aby zorganizować strukturę systemu, warto wydzielić pewne logiczne części powiązanych ze sobą klas, które mają wspólną odpowiedzialność. I tak w dużej części systemów możemy wyróżnić m.in.:

  • interfejs użytkownika - odpowiadający za interakcję z użytkownikiem, najczęściej poprzez odpowiednie widoki lub okienka,
  • dziedzinę - główne dane systemu, przetwarzanie aplikacji, algorytmy, obliczenia, cykl życia systemu,
  • komunikację ze światem zewnętrznym - dostęp do danych, sposób zapisu i odczytu danych w sposób trwały i komunikacja z systemami zewnętrznymi.

To tylko przykładowy podział warstwowy. Warstw może być więcej i mogą być inaczej zdefiniowane.

Pierwszym wyróżnikiem wynikającym ze stosowania warstw jest podzielenie systemu na logiczne części z jasno wydzieloną odpowiedzialnością. Części te są ortogonalne do funkcji systemu.

Drugim wyróżnikiem są jednoznacznie zdefiniowane relacje między warstwami. Tak jak cebule mają warstwy, tak i systemy informatyczne mają warstwy. Im bardziej zewnętrzne, tym bliższe końcowemu klientowi systemu. Warstwy mają zatem ustaloną kolejność i pełnią dla siebie funkcje usługowe. Zależność tę przedstawia poniższy rysunek.

Z wymienionych powyżej warstw, najbardziej zewnętrzną warstwą jest interfejs użytkownika, który organizuje interakcje z użytkownikiem – odpowiada za pobieranie i wyświetlanie danych oraz za logiczną organizację widoków. Konkretne przetwarzanie w systemie jest delegowane do klas z warstwy dziedziny, gdyż to ona odpowiada za główne funkcje aplikacji (w oderwaniu od interfejsu). Zaś ostatecznie wszelkie operacje zapisu, odczytu danych lub komunikacji z systemami zewnętrznymi w warstwie dziedziny są delegowane do warstwy dostępu do danych. Tylko warstwy następujące po sobie bezpośrednio mogą się ze sobą komunikować, przy czym warstwa wyższa korzysta z warstwy niższej.

Jakie są główne korzyści wynikające z korzystania z warstw?

  • Warstwy są sposobem na podzielenie systemu na wysokopoziomowe logiczne części – łatwiej nimi zarządzać i łatwiej je zrozumieć, gdyż każda z nich ma wyraźnie wydzieloną odpowiedzialność.
  • Każda warstwa ma charakterystyczną dla siebie budowę i zestaw interfejsów, które należy zaimplementować.
  • Warstwy można traktować jako niezależne całości w dużym stopniu niezależne od pozostałych.
  • Komponenty z danej warstwy mogą być ponownie używane w innych aplikacjach o tej samej strukturze warstwowej, co sprzyja tworzeniu szkieletów aplikacyjnych.
  • Niezależne zespoły mogą pracować nad rozwojem danej warstwy systemu.
  • Komponenty z różnych warstw mogą być niezależnie tworzone, wdrażane, utrzymywane i aktualizowane.

Oczywiście są również i wady.

  • Wiele warstw powoduje, że poważniejsze modyfikacje funkcji systemu wymuszają kaskadowe zmiany w wielu warstwach.
  • Warstwy mogą spowodować spadek wydajności systemu.

Jeśli tworzysz jednoosobowo pewną aplikację lub masz wpływ na architekturę systemu, wtedy warstwy pomagają Ci łatwiej opanować projekt i uprościć jego tworzenie - odpowiedzialności w systemie są wyraźnie wydzielone. Jeśli napotykasz sytuacje, kiedy po kilku dniach rozwoju pewnej aplikacji staje się ona niespójna – jest wiele powtórzeń, nie wiesz, jak rozdzielić kod odpowiedzialny za zapytania do bazy danych od reszty systemu, wtedy z pomocą może przyjść podział warstwowy w systemie.

Jeśli jesteś członkiem większego zespołu, prawdopodobnie architektura jest już góry narzucona. Już wcześniej ktoś zdecydował, że w systemie, który tworzysz, obowiązuje architektura warstwowa. W różnych technologiach  może być  ona inaczej zdefiniowana, ale podstawowa idea pozostaje taka sama. Znajomość warstw pozwala Ci łatwiej odnaleźć się w tworzonym systemie, łatwiej go zrozumieć i wpasować się do niego. Wiesz za co powinny odpowiadać Twoje klasy, a czym nie powinny się zajmować. Warstwy są jak kontynenty na mapie świata – wiesz co i gdzie możesz znaleźć.

Prosty przykład bezwarstwowy

Przyjrzyjmy się prostemu przykładowi opartemu o konsolę. Już w takiej aplikacji można bez większego wysiłku wyodrębniać warstwy.  Oczywiście ważnym pytaniem, które należy sobie postawić, to pytanie „Czy warto stosować warstwy”. Na potrzeby tego artykuły dla prostoty użyjemy przykładu konsolowego.

Zajmiemy się prostą aplikacją, służącą do zarządzania tłumaczeniami słów z języka angielskiego na polski. W aplikacji możemy:

  • dodawać nowe słowa i ich tłumaczenia;
  • usuwać zadane słowo razem z tłumaczeniem;
  • znaleźć słowo z jego tłumaczeniem;
  • wyświetlić wszystkie słowa z tłumaczeniami:
  • bez sortowania,
  • sortowane alfabetycznie,
  • sortowane według daty dodania słowa;
  • aplikacja ma przechowywać dane w sposób trwały pomiędzy uruchomieniami.

Jedna z prostych implementacji takiego systemu mogłaby wyglądać następująco:



package bnsit.layers.wordbook;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Scanner;

public class Wordbook {

	private static String FILENAME = "wordbook.dat";

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

		List words = loadData();
		
		boolean ok = true;
		Scanner s = new Scanner(System.in);

		System.out.println("Welcome to Wordbook.");

		while (ok) {
			System.out.print("dictionary> ");
			String line = s.nextLine();
			String [] arguments = line.split(" ");

			if ( line.startsWith( "search" ) ) {
				if ( arguments.length != 2 ) {
					System.out.println( "Command syntax: search " );
				} else {
					String englishWord = arguments[1];
					for (DictionaryWord word : words) {
						if ( word.getEnglishWord().equals(englishWord) ) {
							System.out.println( word );
						}
					}
				}
			} else if ( line.startsWith( "add" ) ) {
				if ( arguments.length != 3 ) {
					System.out.println(
					"Command syntax: add  " );
				} else {
					String englishWord = arguments[1];
					String polishWord = arguments[2];
					DictionaryWord dictionaryWord
						= new DictionaryWord(
							englishWord, polishWord, new Date());
					words.add( dictionaryWord );
					System.out.println( "Word added: " + dictionaryWord );
					writeData(words);
				}
			} else if ( line.startsWith( "delete" ) ) {
				if ( arguments.length != 2 ) {
					System.out.println(
						"Command syntax:delete ");
				} else {
					int wordNumber = Integer.valueOf( arguments[1] );
					words.remove( wordNumber - 1 );
					writeData(words);
				}
			} else if ( line.equals( "show" ) ) {
				showList(words);
			} else if ( line.equals( "show sorted by name" ) ) {
				showList(words, new Comparator() {
					@Override
					public int compare(DictionaryWord o1, DictionaryWord o2) {
						return o1.getEnglishWord()
							.compareTo(o2.getEnglishWord());
					}
				});
			} else if ( line.equals( "show sorted by date" ) ) {
				showList(words, new Comparator() {
					@Override
					public int compare(DictionaryWord o1, DictionaryWord o2) {
						return o1.getDate().compareTo(o2.getDate());
					}
				});
			} else if ( line.equals( "exit" ) ) {
				ok = false;
			} else {
				System.out.println( "Command not found: '" + line + "'" );
			}
		  }
		  s.close();
	}

	private static void writeData(List words)
			throws IOException, FileNotFoundException {
			ObjectOutputStream objectOutputStream
				= new ObjectOutputStream( new FileOutputStream( FILENAME ) );
			objectOutputStream.writeObject( words );
	}

	private static List loadData()
		throws FileNotFoundException, IOException, ClassNotFoundException {
		
			List result = new ArrayList();
			File file = new File( FILENAME );
			if ( file.exists() ) {
				ObjectInputStream objectInputStream
					= new ObjectInputStream( new FileInputStream( FILENAME ) );
				result = (List) objectInputStream.readObject();
			}		
			return result;
	}

	private static void showList(List words) {
			int counter = 0;
			for (DictionaryWord word : words) {
				System.out.println( ++counter + " " + word );
			}
	}
	
	private static void showList(List words,
			Comparator comparator) {
		
			List wordsCopy = new ArrayList(words);
			Collections.sort(wordsCopy, comparator);
			showList(wordsCopy);
	}
}

 

Jest to typowy przykład aplikacji o płaskiej architekturze. Oczywiście w tak prostym systemie tego typu rozwiązanie ma same zalety – jest proste, zwięzłe i dość łatwo poruszać się po kodzie. Jednak gdy tylko system będzie się rozwijał, tego typu rozwiązanie będzie coraz trudniejsze w utrzymaniu. Będzie występować coraz więcej powtórzeń, konstrukcje programistyczne będą coraz bardziej skomplikowane oraz elementy interfejsu użytkownika, dostępu do danych będą ze sobą wymieszane.

Wprowadzamy warstwy

Czy na podstawie tak prostego systemu możemy wyodrębnić warstwy? Oczywiście! Przyglądając się aplikacji możemy wydzielić elementy odpowiedzialne za interfejs użytkownika (pobieranie danych od użytkownika i wyświetlanie informacji na ekranie) – klasa Wordbook, za przetwarzanie w systemie (np. sortowanie, dodawanie nowych słów) – klasa WordbookService i dostęp do danych (zapis i odczyt z pliku) – klasa WordbookDao.

Przyjrzyjmy się przykładom klasom, po to aby wyodrębnić główne cechy klas z danej warstwy. Na początek zajmiemy się klasą interfejsu użytkownika – Wordbook (kod źródłowy poniżej). Klasa ta, w porównaniu z kodem z poprzedniej wersji, ma konkretnie wydzieloną odpowiedzialność – interakcję z użytkownikiem. Pozostały w niej tylko instrukcje związane ze współpracą z konsolą oraz delegowanie konkretnych zadań do klasy WordbookService, która reprezentuję w tym przypadku warstwę dziedziny. Klasa Wordbook:

  • pobiera dane z konsoli;
  • waliduje i analizuje dane wpisywane przez użytkownika;
  • wyświetla stosowne komunikaty;
  • deleguje operacje konkretne.

Zauważmy, że nie ma tutaj żadnego konkretnego przetwarzania związanego z wewnętrzną logiką działania systemu. Tylko i wyłącznie interfejs użytkownika. Zatem odchudziliśmy klasę tak, aby pełniła jedną konkretną rolę w systemie.



public class Wordbook {
	private WordbookService wordbookService = new WordbookService();
	
	public static void main(String[] args) {
		Wordbook wordbook = new Wordbook();
		wordbook.run();
	}

	public void run() {
		
		boolean ok = true;
		Scanner s = new Scanner(System.in);

		System.out.println("Welcome to Wordbook.");

		while (ok) {
System.out.print("dictionary> "); String line = s.nextLine(); String [] arguments = line.split(" "); if ( line.startsWith( "search" ) ) { if ( arguments.length != 2 ) { System.out.println( "Command syntax: search " ); } else { String englishWord = arguments[1]; List words = wordbookService.find( englishWord ); for (DictionaryWord word : words) { System.out.println( word ); } } } else if ( line.startsWith( "add" ) ) { if ( arguments.length != 3 ) { System.out.println( "Command syntax: " + "add " ); } else { String englishWord = arguments[1]; String polishWord = arguments[2]; DictionaryWord dictionaryWord = wordbookService.createNewWord( englishWord, polishWord); System.out.println( "Word added: " + dictionaryWord ); } } else if ( line.startsWith( "delete" ) ) { if ( arguments.length != 2 ) { System.out.println( "Command syntax: " + "delete " ); } else { int wordNumber = Integer.valueOf( arguments[1] ); wordbookService.remove( wordNumber ); } } else if ( line.equals( "show" ) ) { List words = wordbookService.findAll(); showList(words); } else if ( line.equals( "show sorted by name" ) ) { List words = wordbookService.findAllSortedByName(); showList(words); } else if ( line.equals( "show sorted by date" ) ) { List words = wordbookService.findAllSortedByDate(); showList(words); } else if ( line.equals( "exit" ) ) { ok = false; } else { System.out.println( "Command not found: '" + line + "'" ); } } s.close(); } private void showList(List words) { int counter = 0; for (DictionaryWord word : words) { System.out.println( ++counter + " " + word ); } } }

Operacje konkretne są delegowane do klasy WordbookService, która zajmuje się głównymi związanymi z funkcjami systemu. Jednak operacje trwałego zapisu lub wyszukiwania danych są delegowane do innego obiektu WordbookDao.

Przyjrzyjmy się klasie WordbookService. Co warto zauważyć?

  1. Metody w tej klasie odpowiadają funkcjom systemu np. znajdź, usuń, znajdź wszystkie.
  2. Metody są dość krótkie i czytelne.
  3. Metody nie zależą w żaden sposób od interfejsu użytkownika, a więc można ich użyć z dowolnym interfejsem użytkownika!
  4. Operacje zależne od źródła danych są delegowane do klasy WordbookDao.


public class WordbookService {

	private WordbookDao wordbookDao = new WordbookDao();

	public List find(String englishWord) {
		return wordbookDao.find(englishWord);
	}

	public DictionaryWord createNewWord(String englishWord, String polishWord) {
		DictionaryWord dictionaryWord
			= new DictionaryWord( englishWord, polishWord, new Date());
		wordbookDao.save(dictionaryWord);
		return dictionaryWord;
	}

	public void remove(int wordNumber) {
		DictionaryWord dictionaryWord
			= wordbookDao.findByIndex( wordNumber - 1  );
		wordbookDao.remove(dictionaryWord);
	}

	public List findAll() {
		return wordbookDao.findAll();
	}

	public List findAllSortedByName() {
		List words = wordbookDao.findAll();
		Collections.sort(words, new Comparator() {
			@Override
			public int compare(DictionaryWord o1, DictionaryWord o2) {
				return o1.getEnglishWord().compareTo(o2.getEnglishWord());
			}
		});
		return words;
	}

	public List findAllSortedByDate() {
		List words = wordbookDao.findAll();
		Collections.sort(words, new Comparator() {
			@Override
			public int compare(DictionaryWord o1, DictionaryWord o2) {
				return o1.getDate().compareTo(o2.getDate());
			}
		});
		return words;
	}
}

Przyjrzyjmy się na końcu klasie WordbookDao. Kilka elementów, na które warto zwrócić uwagę:

  1. Odpowiedzialność tej klasy to współpraca z danymi i źródłem danych (w tym przypadku jest to plik z zserializowanymi danymi).
  2. Metody w tej klasie reprezentują podstawowe operacje związane z pracą na danych.
  3. Metody są krótkie, czytelne i mają jednoznacznie zdefiniowaną odpowiedzialność.
  4. Ze względu na enkapsulację dostępu do danych, można bez większych konsekwencji dla reszty aplikacji zmienić sposób zapisu danych (np. do pliku XML).

 



public class WordbookDao {

	final private String FILENAME = "wordbook.dat";
	private List words = null;

	public WordbookDao() {
		words = loadData();
	}

	public List find(String englishWord) {
		List result = new ArrayList();
		for (DictionaryWord word : words) {
			if ( englishWord.equals(word.getEnglishWord()) ) {
				result.add(word);
			}
		}
		return result;
	}

	public DictionaryWord findByIndex(int i) {
		return words.get( i );
	}

	public List findAll() {
		return new ArrayList(words);
	}
	
	public void save(DictionaryWord dictionaryWord) {
		words.add(dictionaryWord);
		writeData(words);
	}
	
	public void remove(DictionaryWord dictionaryWord) {
		words.remove( dictionaryWord );
		writeData(words);
	}
	
	private void writeData(List words) {
		ObjectOutputStream objectOutputStream;
		try {
			objectOutputStream = new ObjectOutputStream(
					new FileOutputStream(FILENAME));
			objectOutputStream.writeObject(words);
		} catch (Exception e) {
			throw new WordbookDaoException(e);
		}
	}

	private List loadData() {
		List result = new ArrayList();
		File file = new File(FILENAME);
		if (file.exists()) {
			ObjectInputStream objectInputStream;
			try {
				objectInputStream = new ObjectInputStream(
						new FileInputStream(FILENAME));
				result = (List) objectInputStream.readObject();
			} catch (Exception e) {
				throw new WordbookDaoException(e);
			}
		}
		return result;
	}

W ten sposób udało nam się rozwarstwić aplikację. Jakie wynikają z tego konsekwencje? Jasno wydzielona odpowiedzialność, przygotowanie aplikacji na zmiany, łatwiejsze zarządzanie i organizacja kodu – wiadomo, gdzie szukać poszczególnych elementów. Z drugiej strony bardziej skomplikowana struktura – zamiast jednej klasy mamy trzy. Potencjalnie nasza aplikacja może również stracić na wydajności. Cóż, nie ma róży bez kolców. W prostych systemach – składających się z kilku, kilkunastu klas, takie podejście będzie zbyt pracochłonne. W większych systemach konkretyzuje strukturę i ułatwia nawigację.

Podmiana warstw

Jedną z głównych cech podziału warstwowego jest możliwość podmiany warstw i zmiany zastosowanych rozwiązań w danej warstwie przy względnie niewielkim wpływie na resztę systemu. To prawdziwa magia tego rozwiązania.

W tym celu powinniśmy uelastycznić budowę systemu. W chwili obecnej klasy systemu, są ze sobą ściśle powiązane. Zastosujemy dwie techniki, aby rozluźnić nieco projekt – zastosujemy interfejsy dla klas danej warstwy oraz zastosujemy wzorzec Dependency Injection, co umożliwi nam łatwe podmienianie zależności w aplikacji. System będzie miał taką postać:

 

Dzięki zastosowaniu interfejsów elementy systemu są ze sobą luźno powiązane i możemy podmieniać ich konkretne implementacje.

Klasa Wordbook korzysta z interfejsu WordbookService, co oznacza, że w jego miejsce możemy podstawić dowolną implementację (np. opartą o POJO  lub EJB). Aby umożliwić wstrzykiwanie zależności dodaliśmy akcesory (metody set/get), zaś w metodzie main umieściliśmy budowanie powiązanych ze sobą klas.



public class Wordbook {
	private WordbookService wordbookService = null;
	
	public static void main(String[] args) {
		Wordbook wordbook = new Wordbook();
		
		PlainWordbookService plainWordbookService = new PlainWordbookService();
		plainWordbookService.setWordbookDao(new SerializationWordbookDao());
		wordbook.setWordbookService(plainWordbookService);
		
		wordbook.run();
	}
	// ...
	public WordbookService getWordbookService() {
		return wordbookService;
	}

	public void setWordbookService(WordbookService wordbookService) {
		this.wordbookService = wordbookService;
	}
}

Analogiczne zmiany wprowadziliśmy  w klasie PlainWordbookService, która implementuje interfejs WordbookService.




public interface WordbookService {

	public abstract List find(String englishWord);

	public abstract DictionaryWord createNewWord(String englishWord,
			String polishWord);

	public abstract void remove(int wordNumber);

	public abstract List findAll();

	public abstract List findAllSortedByName();

	public abstract List findAllSortedByDate();

}

 


public class PlainWordbookService implements WordbookService {

	private WordbookDao wordbookDao = null;

	// ...

	public WordbookDao getWordbookDao() {
		return wordbookDao;
	}

	public void setWordbookDao(WordbookDao wordbookDao) {
		this.wordbookDao = wordbookDao;
	}

}

 



public interface WordbookDao {

	public abstract List find(String englishWord);

	public abstract DictionaryWord findByIndex(int i);

	public abstract List findAll();

	public abstract void save(DictionaryWord dictionaryWord);

	public abstract void remove(DictionaryWord dictionaryWord);

}

public class SerializationWordbookDao implements WordbookDao {

	final private String FILENAME = "wordbook.dat";
	private List words = null;

	public SerializationWordbookDao() {
		words = loadData();
	}

	public List find(String englishWord) {
		List result = new ArrayList();
		for (DictionaryWord word : words) {
			if ( englishWord.equals(word.getEnglishWord()) ) {
				result.add(word);
			}
		}
		return result;
	}

	// ...

	private List loadData() {
		List result = new ArrayList();
		File file = new File(FILENAME);
		if (file.exists()) {
			ObjectInputStream objectInputStream;
			try {
				objectInputStream = new ObjectInputStream(
						new FileInputStream(FILENAME));
				result = (List) objectInputStream.readObject();
			} catch (Exception e) {
				throw new WordbookDaoException(e);
			}
		}
		return result;
	}
}

Obecnie aplikacja w warstwie dostępu do danych jest oparta o plik z zserializowanymi danymi. Równie dobrze możemy stworzyć inną implementację interfejsu WordbookDao, na przykład   opartą o JDBC. Wtedy ta sama aplikacja, bez większych zmian w warstwie interfejsu użytkownika oraz dziedzinie, będzie działać z bazą danych! Potraktuj to jako ćwiczenie, a rozwiązanie znajdziesz na stronie http://www.bnsit.pl/rozwarstwienie/

Powyżej opisany sposób myślenia można przeskalować na bardziej złożone systemy.

Testowanie

Kolejna korzyść ze stosowania warstw ujawnia się w chwili, gdy testujemy system. Wystarczy porównać początkową i końcową wersję przykładowej aplikacji. Którą łatwiej przetestować? Czy monolityczny kod, który zawiera wiele alternatywnych ścieżek i przemieszany kod interfejsu użytkownika z kodem dziedziny i dostępem do danych. Czy może klasy z niewielkimi metodami, o dobrze określonej odpowiedzialności? Testowanie jednostkowe staje się przyjemnością.

Podsumowanie

Warstwy nie są złotym środkiem, który rozwiążę wszystkie kwestie architektoniczne. W złożonych systemach są nieodzowne, aby móc je efektywnie rozwijać. Jednak każda warstwa to dodatkowy poziom złożoności. W mniejszych aplikacjach jest to indywidualna decyzja, którą warto podjąć, jeśli w ustrukturyzowany sposób chcesz rozwijać swoją aplikację. Wtedy pozostaje pytanie, ile i jakie warstwy chcesz zastosować. Życzę powodzenia podczas eksperymentowania.



 
 
Touching the Void
Radość tworzenia
oprogramowania
Pawel.Wrzesz.cz
BNS IT
Plac Przymierza 6/26
03-944 Warszawa