Slimme generics in PHP

Gepost in PHP, Development, 1 jaar geleden Leestijd: 4 minuten
image

Je hebt soms van die dingen die heel klein beginnen maar dan heel snel uitlopen op een enorm project. Ik had een simpel idee voor een Mastodon-app. Alleen bleken de bestaande PHP implementaties incompleet, verouderd, of anderszins kwalitatief ondermaats. Dus besloot ik er zelf een te maken.

Nu kan een API client heel simpel en basic zijn, maar dat is niet wat ik wilde. Ik wilde er een maken die een optimale developer's experience zou bieden. In het ideale geval zo goed dat je de API documentatie helemaal niet meer nodig hebt, doordat alle methodes, entities en argumenten in de code gedocumenteerd zijn. Ook is het natuurlijk heel erg fijn als je IDE altijd weet wat voor exact type er uit de verschillende methodes komt.

Met de standaard type hinting die in PHP8 zit kom je wat dat betreft behoorlijk ver, maar die heeft ook flinke beperkingen. Neem bijvoorbeeld de volgende opzet:

class GetAccountRequest implements RequestInterface
{
	//
}

class GetListsRequest implements RequestInterface
{
	//
}

class ApiClient
{
	public function send(RequestInterface $request): ResultInterface
	{
		// ...
		return (new ResultFactory())->build( /* ... */ );
	}
}

// instance of AccountResult, but your IDE does not know
$accountResult = $client->send(new GetAccountRequest('123'));

// instance of ListResult, but your IDE does not know
$listsResult = $client->send(new GetListsRequest());

Je IDE begrijpt alleen dat $accountResult en $listsResult implementaties van ResultInterface zijn, maar weet helemaal niks van de specifiekere implementaties die in de praktijk uit de factory kunnen komen. Dat is namelijk run time info en die heeft je IDE helemaal niet.

Kun je nou toch je IDE op de een of andere manier vertellen wat er uit gaat komen?

Je zou natuurlijk voor elke request class een aparte functie in de client kunnen definiëren, met de correcte type hint. Maar dat betekent wel heel veel duplicatie van code en dat wil je liever niet.

Je kan ook een 'verborgen' stub maken met alle signatures van de ApiClient::send() methode inclusief type hint, die door je IDE geanalyseerd wordt en ie alles beter kan begrijpen. Een dergelijke aanpak wordt ook gebruikt door laravel-ide-helper package om alle nare Laravel "magie" van o.a. Facades en Macro's en magic properties begrijpelijk te maken voor je IDE (en jou).

Maar ook dat is niet ideaal. Is er geen elegantere oplossing?

Jazeker:

Generics is iets waar door de PHP wereld al heel lang over wordt gediscussieerd. Het zou zo veel dingen makkelijker en beter maken, als je bijvoorbeeld kan specificeren dat een variabele niet zomaar een array is, maar ook kan aangeven wat het type van de elementen van die array zijn. Heel veel andere talen (zo'n beetje alle gecompileerde talen zoals Java, Rust maar ook Typescript) hebben native ondersteuning, maar omdat PHP geen gecompileerde taal is, is het een stuk lastiger. Het zou namelijk voor een enorme performance hit zorgen.

Maar als het niet in de taal zelf kan, kan het wel op andere manieren erin gefietst worden. Jaren geleden begon PHPStorm met het ondersteunen van een eerste versie van generics achtige dingen in docblocks. Dus in commentaar, dat niet uitgevoerd wordt door PHP, maar alleen dient om je IDE meer meta-info te geven. Best een slim idee, en het vond snel navolging. Code analyzers zoals PHPStan gingen het ook in allerlei vormen ondersteunen en andere Language Server implementaties zoals intelephense volgden. Uiteindelijk ontstond er een soort van officieuze standaard.

/**
 * @return array<array-key, ModelInterface>
 */
function getModels(): array
{
	// ...
	
	return $models;
}

foreach (getModels() as $model) {
	// your IDE knows that $model is a ModelInterface implementation
}

Er is behalve dit simpele voorbeeld meer mogelijk met generics. Zo kun je twee verschillende classes aan elkaar relateren, en daar naar refereren op andere plekken.

/**
 * @template TDerivative of ResponseInterface
 */
interface RequestInterface
{
}

interface ResponseInterface
{
}

/**
 * @implements RequestInterface<FooResponse>
 */
class FooRequest implements RequestInterface
{
}

/**
 * @implements RequestInterface<BarResponse>
 */
class BarRequest implements RequestInterface
{
}

class Client
{
    /**
     * @template T of ResponseInterface
     * @param RequestInterface<T> $request
     * @return T
     */
    public function send(RequestInterface $request): ResponseInterface
    {
        // ...
        return (new ResultFactory())->build( /* ... */ );
    }
}

$client = new Client;

// your IDE knows $response is a FooResponse
$response = $client->send(new FooRequest);

// your IDE knows $response is a BarResponse
$response = $client->send(new BarRequest);

In de RequestInterface maak je de interface generic door de @template te definieren, die een implementatie van ResponseInterface is. In de implementaties maak je die template vervolgens met de @implements tag concreet. En in de Client::send() functie kun je daar weer aan refereren om het juiste return type te kunnen hinten.

Bovenstaande constructie gebruik ik in mijn Mastodon API client, en dit werkt mooi. Het zorgt voor een veel betere en makkelijkere developer ervaring, omdat het opzoekwerk door je IDE voor je gedaan wordt.

Daarnaast kun je met dit soort constructies je code dieper analyseren (bijvoorbeeld door static analysers zoals PHPStan) waardoor deze robuuster wordt en uiteindelijk minder bugs zal bevatten. Hierover later meer!

Meer info:

  • https://phpstan.org/blog/generics-in-php-using-phpdocs
  • https://phpstan.org/blog/generics-by-examples
  • https://stitcher.io/blog/generics-in-php-1
  • https://github.com/vazaha-nl/mastodon-api-client

Gerelateerde posts

image
Nextcloud

Nextcloud wordt ook wel een "google killer" genoemd. Met dit veelzijdige open source pakket kun je veel google (en andere) cloud services vervangen en hiermee je persoonlijke data in eigen hand houden.

Lees meer →

image
Dertig jaar Debian!

Vandaag, 16 augustus 2023, is de dertigste verjaardag van de Debian GNU/Linux distributie. Het was de alleerste linux-versie die installeerde nadat ik Windows voorgoed achter me liet. Vandaag is Debian nog steeds relevant.

Lees meer →

image
Een complete Mastodon API client bouwen

Mastodon heeft een behoorlijk uitgebreide API, maar geen openapi spec. Het was best een uitdaging om een complete client hiervoor te maken.

Lees meer →

image
Een transparante proxy met ssh en sshuttle

Een van de meest krachtige tools die op elk unix of linux systeem beschikbaar zijn is ssh, de "secure shell". In dit artikel laat ik zien hoe je met ssh een transparant, systeembreed en secure vpn opzet.

Lees meer →