Wzorzec projektowy State (Stan) to jeden z behawioralnych wzorców projektowych opisanych przez Gang of Four. W tym artykule poznasz zasadę działania tego wzorca oraz jego przykładowe zastosowania. Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych. Przykłady kodu w artykule, przygotowane zostały w TypeScripcie.
Charakterystyka wzorca
Gang of Four w prosty i klarowny sposób przedstawił intencję wzorca State:
Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.
Wzorzec State ma zastosowanie w aplikacjach, gdzie zachowanie danego obiektu jest różne w zależności od jego stanu. Aby lepiej zrozumieć intencję wzorca, przygotowałem przykład. W sklepie internetowym zamówienie
może mieć kilka stanów:
- nowe;
- w przygotowaniu;
- wysłane;
- zrealizowane;
- oczekujące na zwrot;
- zwrócone;
- anulowane.
W zależności od stanu zamówienia powinny być możliwe do wykonania różne akcje. Przykładowo zamówienie w stanie nowe
lub w przygotowaniu
powinno móc zostać anulowane, ale nie zwrócone. Zamówienie w stanie innym niż zwrócone
lub anulowane
nie powinno mieć możliwości zwrotu środków. Zamówienie w stanie innym niż nowe
nie powinno mieć możliwości edycji. Takich warunków i ograniczeń może być znacznie więcej a ich obsługa wraz z rozwojem systemu będzie coraz trudniejsza.
Sytuacje w kodzie, gdzie zachowanie obiektu zależy od jego stanu, są bardzo częste. Przykłady można by mnożyć i mnożyć. Myślę, że w większości aplikacji, nawet prostych CRUD-ach, dałoby się znaleźć przykłady takich obiektów.
Struktura wzorca
Wzorzec State składa się z trzech komponentów:
Context
— obiekt, który może znajdować się w jakimś stanie. W podanym przykładzie byłoby to zamówienie;State
— interfejs/klasa abstrakcyjna do zaimplementowania przez klasy reprezentujące poszczególne stany;ConcreteState
— implementacje interfejsu/klasy State dla poszczególnych stanów.
Praktyczny przykład
W części praktycznej posłużę się już przytoczonym przykładem z zamówieniem. Do wdrożenia obiektu obsługującego zamówienia konieczna będzie klasa Order
. W klasie istnieją metody obsługujące omówione przypadki z edycją zamówienia, anulowaniem, zwróceniem zamówienia. Obiekty klasy Order
przechowywać będą również swój stan. Dla każdego stanu zamówienia powstanie dedykowana klasa, a sam stan będzie opisany interfejsem IState
. W interfejsie dodałem również pole name reprezentujące nazwę danego stanu. By móc odczytać stan zamówienia w klasie Order
wystawiłem dodatkową metodę getState()
.
enum OrderStatus {
New = 'New',
InPreparation = 'InPreparation',
Shipped = 'Shipped',
Completed = 'Completed',
PendingReturn = 'PendingReturn',
Returned = 'Returned',
Cancelled = 'Cancelled'
}
interface IState {
cancelOrder(): void;
refundOrder(): void;
editOrder(): void;
name: OrderStatus;
}
class Order {
private _state: IState;
constructor() {
this._state = new NewOrderState( this );
}
public setState( state: IState ): void {
this._state = state;
}
public getState(): OrderState {
return this._state.name;
}
public cancelOrder(): void {
this._state.cancelOrder();
}
public refundOrder(): void {
this._state.refundOrder();
}
public editOrder(): void {
this._state.editOrder();
}
}
Poniżej znajdziesz uproszczony diagram dla przykładu z tego artykułu. Na diagramie umieściłem jedynie kilka przykładowych stanów, ponieważ reprezentacja różnych stanów na diagramie wygląda niemal identycznie.
Ponieważ najistotniejsze w części praktycznej jest przedstawienie samego wzorca, pominąłem faktyczne implementacje logiki w klasie Order
. Zastąpiłem je console.log
-ami. Skupiłem się głównie na pokazaniu zależności wynikających z wykorzystania wzorca State. Cały przykład implementacji wzorca stan zaprezentowałem poniżej. Zachęcam, by poświęcić chwilę na samodzielne przeanalizowanie go i pobawienie się kodem w formie edytowalnej w TypeScript Playground.
enum OrderState {
New = 'New',
InPreparation = 'InPreparation',
Shipped = 'Shipped',
Completed = 'Completed',
PendingReturn = 'PendingReturn',
Returned = 'Returned',
Cancelled = 'Cancelled'
}
interface IState {
cancelOrder(): void;
refundOrder(): void;
editOrder(): void;
name: OrderState;
}
class Order {
private _state: IState;
constructor() {
this._state = new NewOrderState( this );
}
public setState( state: IState ): void {
this._state = state;
}
public getState(): OrderState {
return this._state.name;
}
public cancelOrder(): void {
this._state.cancelOrder();
}
public refundOrder(): void {
this._state.refundOrder();
}
public editOrder(): void {
this._state.editOrder();
}
}
class NewOrderState implements IState {
public readonly name = OrderState.New;
constructor( private readonly _order: Order ) {}
public cancelOrder(): void {
console.log( 'Order cancelled!' );
this._order.setState( new CancelledOrderState() );
}
public refundOrder(): void {
throw new Error( 'Cannot refund new order!' );
}
public editOrder(): void {
console.log( 'Order updated!' );
}
}
class InPreparationOrderState implements IState {
public readonly name = OrderState.InPreparation;
constructor( private readonly _order: Order ) {}
public cancelOrder(): void {
console.log( 'Order cancelled' );
this._order.setState( new CancelledOrderState() );
}
public refundOrder(): void {
throw new Error( 'Cannot refund order in preparation!' );
}
public editOrder(): void {
throw new Error( 'Cannot edit order in preparation!' );
}
}
class ShippedOrderState implements IState {
public readonly name = OrderState.Shipped;
constructor() {}
public cancelOrder(): void {
throw new Error( 'Cannot cancel shipped order!' );
}
public refundOrder(): void {
throw new Error( 'Cannot refund shipped order!' );
}
public editOrder(): void {
throw new Error( 'Cannot edit shipped order!' );
}
}
class CompletedOrderState implements IState {
public readonly name = OrderState.Completed;
constructor() {}
public cancelOrder(): void {
throw new Error( 'Cannot cancel completed order!' );
}
public refundOrder(): void {
throw new Error( 'Cannot refund completed order!' );
}
public editOrder(): void {
throw new Error( 'Cannot edit completed order!' );
}
}
class PendingReturnOrderState implements IState {
public readonly name = OrderState.PendingReturn;
constructor() {}
public cancelOrder(): void {
throw new Error( 'Cannot cancel order pending to return!' );
}
public refundOrder(): void {
throw new Error( 'Cannot refund order pending to return!' );
}
public editOrder(): void {
throw new Error( 'Cannot edit order pending to return!' );
}
}
class ReturnedOrderState implements IState {
public readonly name = OrderState.Returned;
constructor() {}
public cancelOrder(): void {
throw new Error( 'Cannot cancel returned order!' );
}
public refundOrder(): void {
console.log( 'Order refunded!' );
}
public editOrder(): void {
throw new Error( 'Cannot edit returned order!' );
}
}
class CancelledOrderState implements IState {
public readonly name = OrderState.Cancelled;
constructor() {}
public cancelOrder(): void {
throw new Error( 'Cannot cancel already canceled order!' );
}
public refundOrder(): void {
console.log( 'Order refunded!' );
}
public editOrder(): void {
throw new Error( 'Cannot edit cancelled order!' );
}
}
const order = new Order();
order.editOrder(); // Order updated!
order.cancelOrder(); // Order cancelled!
order.refundOrder(); // Order refunded!
order.editOrder(); // Error: Cannot edit cancelled order!
Problemem, jaki widzę w takim podejściu, jest podatność na „pchnięcie bokiem” niewłaściwego stanu. W proponowanym rozwiązaniu warunków zmiany stanu pilnują klasy implementujące IState
. Jednak tak zaprojektowany kod pozwala na bezpośrednie wywołanie metody setState()
na obiekcie klasy Order
spoza klas stanu. Wtedy można zmienić dowolny stan na dowolny inny z dowolnego miejsca z pominięciem logiki pilnującej stanu.
Jeśli widzisz sposób na to, by ograniczyć wywołanie metody setState()
jedynie do klas implementujących IState
, to zachęcam, by podzielić się nim w sekcji komentarzy 🙂
Podsumowanie
Wdrożenie wzorca State pozwala zwiększyć czytelność i utrzymywalność kodu. Moim zdaniem zdecydowanie łatwiej jest czytać i rozwijać taki kod, niż w przypadku, gdyby klasa Order
była wielkim if-owym spaghetti. Fani SOLID-a również powinni być zadowoleni. Odpowiedzialności w kodzie są fajnie podzielone, a obsługa nowych stanów jest prosta i nie wpływa na kod już obsłużonych.
Mimo łatwości znalezienia przypadku, gdzie można wykorzystać wzorzec State, warto każdorazowo zastanowić się, czy jest to konieczne. Przykładowo, jeśli do obsługi są dwa proste stany np. włączony/wyłączony, to wykorzystanie wzorca State może być armatą na muchę. Jeśli jednak czujesz, że jego wdrożenie faktycznie da wartość, to gorąco zachęcam.
Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić, klikając TEN link. Kupując z tego linku, wspierasz rozwój bloga.
Źródła i materiały dodatkowe
- Design Patterns: Elements of Reusable Object-Oriented Software – Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (1994) str. 338
- Koddlo – State (Stan)
- dofactory – C# State Design Pattern
- TypeScript Playground – edytowalny przykład z artykułu
- SourceMaking – State Design Pattern
- Refactoring Guru – State
- The State Design Pattern: Why State Management Doesn’t Have to be a Mess
Zapisz się na mailing i odbierz e-booka
Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.