Maciej Sikora: Programowanie ofensywne

Maciej Sikora: Programowanie ofensywne
Podobno najlepszą obroną jest atak. Czy programowanie ofensywne jest więc lepszym wyborem niż defensywne? Czym różnią się oba podejścia? W artykule postaram się porównać i wyjaśnić obie definicje.

Kod to twoja twierdza. Obroń ją

Programowanie defensywne polega na swoistym barykadowaniu i zabezpieczaniu funkcjonalności przed wszelkimi nieprzewidzianymi lub przewidzianymi błędami. Inaczej mówiąc, wszystko, co może wywołać problem powinno być zabezpieczone i przechwycone. Jeśli mam funkcję która przyjmuje parametr muszę sprawdzić, czy parametr został przekazany, czy jest odpowiednio wypełniony, czy nie spowoduje wywołania wyjątku. Dla przykładu stworzę funkcję, która zwraca pierwszą literę zadanego na wejściu ciągu znaków:

	
		String getFirstChar(String message) {
		    if (message != null && !message.isEmpty()) {
		            return Character.toString(message.charAt(0));
		    } else {
		        return “”;
		  	}
		}
	

W funkcji na początek sprawdzane jest, czy parametr wejściowy ma przypisaną wartość oraz, czy nie jest pusty. Jeśli warunki są spełnione zwracany jest pierwszy znak, jeśli nie, wartość domyślna (pusty ciąg znaków). Widać tutaj wiele cech typowych dla defensywnego programowania - sprawdzanie wejścia, czy też wartość domyślną.

Kolejną cechą programowania defensywnego jest “zjadanie” wyjątków. Celem programisty jest zabezpieczenie przed wywołaniem jakiegokolwiek zdarzenia mogącego spowodować błąd w aplikacji. Poniżej pseudo-kod odczytu z pliku:

	
		String readFile(String fileName, String fileDirectory) {
			if (fileName == null || fileDirectory == null) {
				return “”;
			}
			if (!System.fileExists(fileDirectory + fileName) {
				return “”;
			}
			try {
				return System.readFile(fileDirectory + fileName);
			} catch (Exception e) {
				return “”;
			}
		}
	

W przykładzie starałem się zabezpieczyć przed Null-em, przed nieistniejącym plikiem oraz złapałem wyjątek z funkcji System.readFile. We wszystkich możliwych sytuacjach wyjątkowych zwracany jest pusty ciąg znaków (oczywiście jest to tylko przykład). W rezultacie metoda nie wyrzuci wyjątku, natomiast jej wynik może znacznie zaburzyć działanie programu, a dodatkowo rezultat może sugerować, że wszystko jest w porządku i tylko odczytywany plik jest pusty.

Tak defensywna część kodu obroni się przed błędami, ale nie zabezpieczy lub wręcz ułatwi błąd logiczny w trakcie wykonywania programu. Wracając do przykładu wcześniej, założę, że użytkownik odczytuje plik i zamiast wyjątku lub informacji o błędzie dostaje pusty plik (wartość domyślną). Takie zachowanie programu może powodować dużo poważniejsze implikacje, otóż jeśli użytkownik miał tam ważne dane może sądzić, że program je usunął.

Jeśli natomiast funkcja tak przetwarzająca błędy jest częścią większego procesu, to w konsekwencji wszystkie operacje występujące po niej będą operować na błędnych danych wejściowych bez świadomości wystąpienia “zjedzonego” błędu. Konsekwencje tego działania są często nieprzewidywalne.

Kilka cech programowania defensywnego:

  • brak zaufania do parametrów wejściowych
  • konsumowanie wyjątków
  • zwracanie domyślnych wartości
  • dodatkowy kod (z reguły instrukcje warunkowe)

Najlepszą obroną jest atak

Wyjaśniłem czym jest i czym cechuje się programowanie defensywne, przejdę więc do tytułowego programowania ofensywnego, które jest dokładnym przeciwieństwem poprzednio opisywanej koncepcji.

Głównym założeniem ofensywnego podejścia jest zaufanie, zaufanie do faktu, że inny programista wykorzysta napisany kod zgodnie z założeniami i wymaganiami. Tak więc, jeśli funkcja przyjmuje parametr, to nieprzekazanie tego parametru lub przekazanie Null-a bezkompromisowo powinno wyrzucić odpowiedni wyjątek.

Programowanie ofensywne pozwala błędom i wyjątkom wybrzmieć. Takie podejście od razu budzi pytanie - ale jak to, świadomie mam pozwalać na wyjątki w aplikacji? Tak, oczywiście, z wielu powodów:

  • błędy w łatwy sposób mogą być przechwycone i poprawione przed wydaniem aplikacji użytkownikom
  • wyrzucone błędy są łatwe do zauważenia, odnotowania i reprodukcji
  • dalsza część aplikacji nie pracuje z zaburzonym stanem spowodowanym przez nieprzewidzianą sytuację
  • usuwamy zbędny kod

Wrócę do przykładów zapisanych w podejściu defensywnym. Jak mogłoby to wyglądać w stylu ofensywnym:

	
		String getFirstChar(String message) {
			return Character.toString(message.charAt(0));
		}
	

Funkcja skrócona została do jednej linii. W przypadku Null-a funkcja wyrzuci Exception, ale to właśnie jest wyjątek dla tej funkcji, jest to sytuacja nieprzewidziana, oznacza to, że została źle użyta.

Ofensywny przykład funkcji czytającej plik:

	
		String readFile(String fileName, String fileDirectory) {
			return System.readFile(fileDirectory + fileName);
		}
	

Ponownie jedna linia kodu, wszystkie sprawdzanie wejścia, istnienia pliku czy łapanie wyjątku usunięte. W momencie podania złej ścieżki funkcja wygeneruje wyjątek stworzony w metodzie System.readFile.

Czy oznacza to, że kod ofensywny nigdy nie konsumuje wyjątków? Otóż nie, kod ofensywny konsumuje wszystkie rodzaje błędów które są możliwe do obsłużenia na jego poziomie, nie konsumuje wyjątków których nie jest w stanie przetworzyć i zrozumieć ich przyczyny.

Świetnie, mniej kodu, większa przejrzystość, wreszcie można skupić się na logice biznesowej, a nie na barykadowaniu się przed nieprzewidzianym użyciem!

Postaw linię obrony

Wszystko wygląda pięknie, lecz po zastanowieniu się widać miejsce w którym zaufania nie ma. Jest to miejsce gdzie panem i władcą jest użytkownik. Dla front-endu będzie to warstwa UI z wszelkimi kontrolkami, które użytkownik uzupełnia, dla back-endu będą to dane przychodzące w żądaniach http. W tych sytuacjach nie można zakładać odpowiednich danych, należy wręcz szykować się na najgorsze, czyli postawić odpowiednią linię obrony. 

Linia obrony (Line of defence) powinna być ustawiona w warstwie aplikacji najbliżej interakcji z użytkownikiem. Są to wszelkiego rodzaju nasłuchy na interfejs użytkownika, walidacja wejścia, walidacja odpowiedniego wypełnienia formularzy etc. Po zrealizowaniu strategii defensywnej dalsze przekazywanie danych niżej ma być obsługiwane przez komponenty posiadające kod ofensywny.

Dzięki zaprezentowanemu podziałowi, kod aplikacji posiada dużo mniej instrukcji warunkowych oraz instrukcji try catch. Wszystko co przechodzi przez linię obrony uważane jest za bezpieczne.

Poniższy diagram przepływu obrazuje konkretny przypadek użycia.

Jeszcze jedno słowo wyjaśnienia - nie można uważać użytkownika za jedyny element, który powinien być obłożony warstwą kodu defensywnego. Tak naprawdę każdy styk systemu ze światem zewnętrznym powinien być broniony, dotyczy to również integracji z podmiotami trzecimi, są to wszystkie miejsca gdzie do systemu przychodzą zewnętrzne informacje lub bodźce, które należy starannie weryfikować

Mieszane sztuki walki

Dobrze napisane oprogramowanie powinno umiejętnie łączyć dwie formy programowania. Niepewność programisty w zakresie sprawdzania i przechwytywania wyjątków powinna być wyeliminowana przez odpowiednią architekturę. Ustawienie odpowiedniej linii obrony przed którą znajduje się kod defensywny, a za którą ofensywny, jest najlepszym sposobem do osiągnięcia czytelności, zmniejszenia nadmiarowego kodu z jednoczesnym zachowaniem bezpieczeństwa.