Podejście typu „rób tak zawsze i koniec” jest moim zdaniem słabe. Tym bardziej, jeżeli nie padają żadne argumenty. Bo jak pojawiają się argumenty, to jest również dyskusja. Tak jest w kwestii podejścia „klasa powinna być finalna by default”, a ja przychodzę z argumentami, dlaczego nie 🙂
Final, final wszędzie…
Jednym z podejść programowania obiektowego jest to, aby domyślnie wrzucać klasy jako finalne. Bo w sumie jeżeli piszemy kod, gdzie wiemy, że nie będziemy rozszerzali tej klasy, to chyba tak powinno być. W IDE pojawia nam się ładna kłódeczka bądź inny element, który nam się podoba i jest fajnie. No i na Code Review możemy pochwalić się, że wyłapaliśmy brak finala. W zasadzie, to przeciwko takiemu finalowi samemu w sobie nie mam nic przeciwko. W sumie, to sam przez długi czas propagowałem to podejście, dopóki nie przekalkulowałem sobie tego w moim przypadku.
PHPSpec i test doubles
Ja testy jednostkowe najczęściej piszę w PHPSpecu. Ma to swoje zalety i wady, o których w kontekście testów jednostkowych pisałem we wpisie Testy jednostkowe a PHPSpec. Dzisiaj podejdziemy nieco bardziej technicznie do tego, w jaki sposób PHPSpec generuje stuby / mocki i dlaczego to nie pracuje ze słowem kluczowym final.
Zacznijmy od tego, że mamy dwie klasy finalne, gdzie obiekt jednej jest wstrzykiwany do drugiej:
<?php
namespace App;
final class MyService
{
public function __construct(
private readonly DependencyClass $dependency
) {
}
public function doSomething(): void
{
$this->dependency->doSomethingElse();
}
}
final class DependencyClass
{
public function doSomethingElse(): void
{
// ...
}
}
No i standardowo piszemy dla klasy MyService
speckę:
<?php
namespace spec\App;
use PhpSpec\ObjectBehavior;
final class MyService extends ObjectBehaviour
{
public function let(DependencyClass $dependency): void
{
$this->beConstructedWith($dependency);
}
public function it_throws_exception_when_value_is_not_valid(
DependencyClass $dependency
): void {
// ...
}
}
Odpalając tą speckę dostaniemy następujący następujący błąd:
App\MyService
14 - it throws exception when value is not valid
could not reflect class App\DependencyClass as it is marked final.
Problem zniknie, jeżeli do klasy DependencyClass
(z perspektywy PHPSpec jest to klasa będąca kolaboratorem klasy MyService
) utworzymy interfejs i użyjemy jego zamiast klasy.
Ponieważ design testów w PHPSpec polega na odwzorowaniu drzewa naszych klas, to powoduje, że każda zależność, z której korzystamy w serwisach będzie kolaboratorem specki (test double). Stąd, jeżeli stosujemy podejście final-by-default, to każdy serwis aplikacyjny siłą rzeczy będzie musiał posiadać swój interfejs, bo inaczej nie będziemy mogli go wykorzystać w specce. Interfejsu nie będą miały za to klasy, które są entrypointem aplikacji w Symfony, czyli np. handlery, komendy konsolowe, kontrolery oraz event listenery.
No to teraz fajnie by było dowiedzieć się, dlaczego PHPSpec zachowuje się tak, a nie inaczej w przypadku klas finalnych. Aby odpowiedzieć sobie na to pytanie, musimy przeprowadzić śledztwo, zaczynając od źródła tego komunikatu:
<?php
// https://github.com/phpspec/prophecy/blob/v1.18.0/src/Prophecy/Doubler/Generator/ClassMirror.php#L98
namespace Prophecy\Doubler\Generator;
// ...
class ClassMirror
{
private function reflectClassToNode(ReflectionClass $class, Node\ClassNode $node): void
{
if (true === $class->isFinal()) {
throw new ClassMirrorException(sprintf(
'Could not reflect class %s as it is marked final.', $class->getName()
), $class);
}
// ...
}
}
Z powyższego jasno wychodzi, że mowa o klasie finalnej. PHPSpec gdzieś tam pod spodem bada klasę kolaboratora przez refleksję i weryfikuje wiele rzeczy na jej temat pod spodem. Jeżeli poszperamy nieco więcej, to dojdziemy do klasy Doubler
, która wykorzystuje wyżej wspomnianą klasę Mirror
:
<?php
// https://github.com/phpspec/prophecy/blob/v1.18.0/src/Prophecy/Doubler/Doubler.php
namespace Prophecy\Doubler;
// ...
class Doubler
{
// ...
protected function createDoubleClass(ReflectionClass $class = null, array $interfaces)
{
$name = $this->namer->name($class, $interfaces);
$node = $this->mirror->reflect($class, $interfaces);
foreach ($this->patches as $patch) {
if ($patch->supports($node)) {
$patch->apply($node);
}
}
$node->addInterface(DoubleInterface::class);
$this->creator->create($name, $node);
\assert(class_exists($name, false));
return $name;
}
}
Przechodząc przez obiekt klasy ClassCreator
do klasy ClassCodeGenerator
zauważymy, że jest tam dynamicznie generowany kod klasy. Nazwa tej klasy pochodzi z obiektu namer
z powyższego listingu, a jej generowanie wygląda następująco:
<?php
// https://github.com/phpspec/prophecy/blob/v1.18.0/src/Prophecy/Doubler/NameGenerator.php
namespace Prophecy\Doubler;
class NameGenerator
{
public function name(ReflectionClass $class = null, array $interfaces)
{
$parts = array();
if (null !== $class) {
$parts[] = $class->getName();
} else {
foreach ($interfaces as $interface) {
$parts[] = $interface->getShortName();
}
}
if (!count($parts)) {
$parts[] = 'stdClass';
}
return sprintf('Double\%s\P%d', implode('\\', $parts), self::$counter++);
}
}
Na sam koniec przechodzimy do klasy ClassCreator
, która korzysta z wygenerowanej nazwy klasy, generuje kod klasy za pomocą wspomnianej klasy ClassCodeGenerator
, a następnie uruchamia ten kod w locie za pomocą funkcji eval(...)
:
<?php
// https://github.com/phpspec/prophecy/blob/v1.18.0/src/Prophecy/Doubler/Generator/ClassCreator.php
namespace Prophecy\Doubler\Generator;
use Prophecy\Exception\Doubler\ClassCreatorException;
// ...
class ClassCreator
{
// ...
public function create($classname, Node\ClassNode $class)
{
$code = $this->generator->generate($classname, $class);
$return = eval($code);
if (!class_exists($classname, false)) {
if (count($class->getInterfaces())) {
throw new ClassCreatorException(sprintf(
'Could not double `%s` and implement interfaces: [%s].',
$class->getParentClass(), implode(', ', $class->getInterfaces())
), $class);
}
throw new ClassCreatorException(
sprintf('Could not double `%s`.', $class->getParentClass()),
$class
);
}
return $return;
}
}
Z wszystkich powyższych listingów wynika, że PHPSpec pod spodem generuje klasę Double dla każdego naszego kolaboratora. Klasa Double dziedziczy po klasach naszego serwisu, aby mógł on „udawać” go. Dlatego nie może być on finalny. Ale z drugiej strony – mechanizmy PHPSpeca działają w ten sposób, że jeżeli posługujemy się interfejsami, to jest gitez. Bo interfejsy nie są finalne, a PHPSpec może bez problemu wygenerować sobie klasę Double, która dziedziczy po każdym naszym interfejsie.
Koniec końców wychodzi na to, że jeżeli trzymamy się podejścia final-by-default i piszemy specki, to cała logika biznesowa będzie musiała mieć interfejsy. Moim zdaniem jest to rzecz zupełnie niepotrzebna, zaciemniająca kod / listing plików w sidebarze. Do tego prawdopodobnie gubimy informację na temat tego, jakie faktycznie zalety ma stosowanie interfejsów tam, gdzie się powinno ich stosować.
Dla szerszego kontekstu, podobna (technicznie) kwestia dotyczy encji Doctrine – tutaj również są automagicznie tworzone klasy je rozszerzające, nazywane klasami Proxy. Jeżeli chcecie o nich coś więcej się dowiedzieć, to zachęcam do przeczytania innego mojego wpisu: Czym są i kiedy powstają klasy Proxy w Doctrine 2?
Idea, która stoi za interfejsami
W programowaniu obiektowym pracujemy na obiektach konkretnych klas. Mamy klasy bardziej bądź mniej podobne do siebie. Budujemy zależności między klasami, przekazując ich egzemplarze do propertiesów oraz metod. Zawsze jest to konkretna implementacja; załączając egzemplarz klasy wiemy, co dokładnie będzie robiła każda z metod, która jest zaimplementowana.
Programowanie obiektowe przynosi ze sobą nie tylko klasy, ale również interfejsy. Przekazując egzemplarz klasy AwsStorageClient
uzależniamy się konkretnie od implementacji AWSa:
<?php
class AwsStorage
{
public function store(Image $image): void {
// ...
}
}
class ImageProcessor
{
public function __construct(
private readonly AwsStorageClient $storage,
) {
}
public function processImage(Image $image): void {
// processing the image...
$this->storage->store($image);
}
}
Klasa ImageProcessor
niekoniecznie musi być zainteresowana informacją, gdzie zapisywany jest obrazek. Ją bardziej interesuje, aby podpięty był „jakiś” storage. Jakikolwiek, który obsługuje tzw. kontrakt, czyli w tym przypadku posiada metodę store(Image $image): void
. Docelowo do tego właśnie powstały interfejsy:
<?php
interface ImageStorageInterface
{
public function store(Image $image): void;
}
class ImageProcessor
{
public function __construct(
private readonly ImageStorageInterface $storage,
) {
}
public function processImage(Image $image): void {
// processing the image...
$this->storage->store($image);
}
}
Jak widzimy, serwis ImageProcessor nie musi wiedzieć, gdzie co jest zapisywane. Informację „co gdzie jak i dlaczego” będzie posiadał kontener dependency injection. Bo tam prawdopodobnie dopniemy konkretną implementację tego interfejsu.
Więcej niż jedna implementacja
Z powyższego wychodzi, że możemy mieć więcej, niż jedną implementację konkretnego interfejsu. I w rzeczywistości w taki sposób się działa. Nasz storage do obrazków może mieć kilka różnych wersji:
<?php
interface ImageStorageInterface
{
public function store(Image $image): void;
}
class FileSystemImageStorage implements ImageStorageInterface
{
public function store(Image $image): void {
// ...
}
}
class AwsImageStorage implements ImageStorageInterface
{
public function store(Image $image): void {
// ...
}
}
class GoogleCloudImageStorage implements ImageStorageInterface
{
public function store(Image $image): void {
// ...
}
}
O ile w projektach typu WebApp niezbyt często zdarza się tego typu sytuacja, to jeżeli robimy już jakiegoś publicznego bundle, z którego ktoś może skorzystać – sytuacja jest zupełnie inna. Ale istnieją wzorce projektowe, które w bardzo podobny sposób pracują na interfejsach, z których już znacznie częściej będziemy korzystali nawet w aplikacjach końcowych. Mowa o takich wzorcach jak: Strategia, Kompozyt, Dekorator czy nawet Fabryka. Są to wzorce, które w szczególny sposób realizują jedną z zasad SOLIDu. Więcej na ich temat możecie przeczytać w innym moim wpisie: Wzorce projektowe przyjazne Open-Close Principle cz.1.
Nadawanie klasom cech
Wzorce projektowe, o których wspomniałem wyżej w dużej mierze odnoszą się do serwisów aplikacyjnych, które zawierają logikę biznesową opartą o model naszej domeny. Do samej domeny, czyli encji, obiektów wartości i podobnych tego typu wzorce mogą się nie przydać. Ale istnieje inna bardzo przydatna opcja na wykorzystanie interfejsów w warstwie domeny. Jeżeli jednak chcielibyście przeczytać coś więcej o warstwie domeny, to zachęcam do przeczytania mojego wpisu: Co składa się na Warstwę Domeny?.
Domena jako model posiada swoje właściwości oraz zachowania. W naszym języku są to propertiesy oraz metody klasy. Okazuje się, że oprócz tego nasz model może mieć również cechy. Część naszych encji może mieć opcję na włączenie / wyłączenie. Część z nich może mieć funkcje wyliczające jakiś total. Część z nich może być traktowana jako kolekcja. I to są cechy, które mogą być oparte o interfejsy:
<?php
interface Togglable
{
public function enable(): void;
public function disable(): void;
public function toggleEnabled(): void;
}
class Product implements Togglable
{
public function enable(): void {
// ...
}
public function disable(): void {
// ...
}
public function toggleEnabled(): void {
// ...
}
}
class Customer implements Togglable {
public function enable(): void {
// ...
}
public function disable(): void {
// ...
}
public function toggleEnabled(): void {
// ...
}
}
Powyżej mamy dwa modele, które pomimo różnic domenowych mają tą samą cechę. Ich implementacja może być różna: encja Product
może mieć odpowiedzialne za to pole, a encja Customer
może zapisywać tą informację w jakimś polu konfiguracji, które jest wewnętrznie zserializowaną tablicą, która zawiera w sobie wiele innych informacji.
Ktoś zapyta, po co tak komplikować, skoro i tak musimy implementować każdą z metod osobno. Tak się składa, że dzięki temu zabiegowi możemy w naszej aplikacji napisać reużywalny serwis (np. kontroler) do disablowania, którego nie interesuje to, co disabluje. Dla niego ważne jest to, aby disablowany element miał zaimplementowane konkretne metody, czyli implementował konkretny interfejs Togglable
:
<?php
// ...
class DisableController
{
public function __construct(
EntityRepositoryInterface $repository,
) {
}
public function __invoke(Request $request): Response {
$id = (int) $request->query->get('id');
$entity = $this->repository->findById($id);
Assert::isInstanceOf(TogglableInterface::class);
$entity->disable();
return new Response('OK', Response::HTTP_OK);
}
}
Taką klasę kontrolera możemy wykorzystać w naszej aplikacji wielokrotnie, przekazując przy tym inne repozytorium za pomocą kontenera dependency injection. Oczywiście, to jest najprostszy przykład, który ma posłużyć tylko zobrazowaniu konceptu nadawania cech modelowi / domenie.
Różnice między Symfony WebApp oraz Bundle
Jako programiści Symfony pracujemy zazwyczaj w dwóch trybach: WebApp oraz Bundle. Aplikacje typu WebApp są typem kodu, na który mamy wpływ w 100%. Możemy rozszerzyć, co chcemy. Mamy w pełni wpływ na kontener dependency injection. Możemy dosłownie wszystko. Stąd, jeżeli mamy pojedynczy serwis aplikacyjny, który realizuje jakąś logikę biznesową, to może okazać się, że nie ma potrzeby, aby posiadał on interfejs. Zatem nie ma też potrzeby, aby wchodzić na opcję final-by-default.
Nieco inaczej jest w aplikacjach typu Bundle. Tutaj możemy chcieć, aby ktoś nie rozszerzył naszej klasy. Bo przykładowo, mamy w bundlu statyczną konfigurację, która opiera się o jej nazwę. Albo na podstawie nazwy klasy mamy gdzieś switcha i nie chcemy, aby ktoś w niego ingerował. Możemy mieć mnóstwo powodów do tego, aby nie pozwolić komuś na rozszerzenie klasy.
Z drugiej strony, Bundle to rodzaj aplikacji, która powinna być przyjazna programistom, którzy z niej korzystają. Ktoś może chcieć napisać adapter pasujący bardziej do jego case. Ktoś może chcieć wstrzyknąć do jakiegoś serwisu z bundla swoją logikę. Dlatego pomimo pojedynczej implementacji danego serwisu, jeżeli nie mamy żadnych przeciw, to fajnie jest wrzucić interfejs. Aby wszystkim żyło się lepiej 🙂
Ostatecznie o słowie kluczowym final
Reasumując: z jakiegoś powodu klasa nie jest domyślnie finalna w PHP – musimy użyć słowa kluczowego. Z jakiegoś powodu nie jest wymagane w PHPie, aby każda klasa miała swój interfejs. Robienie czegoś, bo tak się robi, raczej dobrym podejściem nie jest. Każdy powinien mieć swój rozum i przemyśleć, co mu da to lub tamto. W przypadku, który dziś omówiłem, klasy finalne by default są /dla mnie/ słabą opcją.
Aha, i to nie jest tak, że jeżeli ten case z finalem i interfejsami macie u siebie w projekcie, to że trzeba to nagle przepisać. Dobrze by było przegadać ten temat z resztą zespołu. Może okazać się, że spam interfejsów nie jest u Was aż tak dużym problemem, jakim jest dla mnie. Sam osobiście niedawno spotkałem się również ze zdaniem, że „interfejs per klasa jest fajną opcją, bo jak wejdę sobie w taki interfejs, to widzę wszystkie metody naraz”. A jak już stwierdzicie w zespole, że może faktycznie dobrze by było to zrefaktoryzować, to polecam zrobić to raz, a porządnie. A na to trzeba zarezerwować sobie czas w sprincie 🙂
PS. Tak tylko wspomnę, że ten blog powstał szczególnie dzięki temu, że sam popełniłem ten problem podczas prac nad projektem TheGame. I prawdopodobnie niebawem będę to zmieniał. Konkluzja tego jest taka, że warto jest popełniać błędy. Warto, kiedy krok dalej jest refleksja na ich temat.
Comments are closed.