Im Rahmen meines Systems Engineering Projektes habe ich die shuto-api entwickelt – eine in Go geschriebene Open-Source-Bildoptimierungslösung. Mein Ziel war es, eine flexible, self-hostable und erweiterbare API zu erstellen, welche ohne viele Probleme in bereits bestehende Systeme integriert werden kann.
Der Service ermöglicht es, Bilder zu komprimieren, zu skalieren sowie weiter zu bearbeiten. Diese sind häufig hauptsächlich relevant, um ihre Ladezeiten zu verbessern. Deshalb werden solche Services sehr häufig für Websites/ Webanwendungen, aber auch Apps und Content-Delivery-Plattformen verwendet, um die Performance zu steigern, Bandbreite zu reduzieren und eine bessere User-Experience zu gewährleisten.
Neben den Features zur Optimierung der Bilder wollte ich sicherstellen, dass so gut wie möglich jeder diesen Service nutzen kann. Deshalb war es mir auch wichtig, so viele Storage-Lösungen (also Orte, wo die Bilder, welche optimiert werden sollen, gespeichert sind) zu unterstützen, damit sie von vielen auch in schon bestehende Systeme mit Storage-Lösungen eingebunden werden können.
Meine Motivation für dieses Projekt war es, eine einfach zu verwendende sowie leistungsstarke Lösung zu entwickeln, mit der Bilder effizient verarbeitet werden können. Der Fokus lag dabei, bewährte Standards und Prinzipien der Softwareentwicklung anzuwenden, um eine gut funktionierende, skalierbare Architektur zu schaffen, welche langfristig angepasst und weiterentwickelt werden kann.
Wichtig dabei waren unter anderem Punkte wie die Automatisierung von Prozessen bei der Entwicklung oder Bereitstellung durch den Einsatz von CI/CD-Pipelines, sowie eine automatisierte API-Dokumentation und eine gut dokumentierte Codebasis. Zudem wurden für eine bessere Integration in JavaScript und React Projekte noch zwei NPM-Pakete entwickelt, welche dafür gedacht sind, die Developer-Experience auf der Nutzerseite zu verbessern.
Dieser Blogpost soll technische Herausforderungen, getroffene Designentscheidungen sowie wichtige Erkenntnisse aus der Entwicklung dieses für Open-Source gedachten, anpassbaren Services vorstellen.
Entwickeln für Open-Source
Einer der zentralen Aspekte war, das Ganze so anzugehen, dass daraus potenziell ein Open-Source-Projekt entstehen kann. Wichtige Punkte für Open-Source sind unter anderem eine klare und dokumentierte Architektur. Ich habe von Anfang an versucht, die Codebasis modular aufzubauen und zu schauen, dass sie einfach erweitert werden kann. Dabei habe ich zum Beispiel probiert, so gut wie möglich auf Standards auch im Umfeld von Go zu setzen. Ich habe dafür zum Beispiel entschieden, keine third-party router in Go wie mux oder chi zu verwenden, sondern auf den Go-Standard (net/http) zu setzen.
Für einen transparenten Entwicklungsprozess wurde GitHub als zentrale Plattform gewählt, welche dann für Versionskontrolle, öffentliche Dokumentation, die Möglichkeit für Pull Requests von anderen Entwicklern sowie Automatisierungen mittels der GitHub eigenen CI/CD-Pipelines genutzt wurde. Die Integration der CI/CD-Pipelines, speziell zu Beginn des Projektes, ermöglichte es während der Entwicklung dauerhaft automatisierte Tests durchzuführen sowie die komplette Anwendung zu bauen. Dies ist im Sinne von Open-Source vorwiegend für externe Änderungen wichtig, um die Stabilität der Software sicherzustellen sowie es für neue Contributors zu erleichtern, sich sicherer zu fühlen.
Ein weiterer eingesetzter Standard ist Semantic Versioning, welches eine klare Versionierung der Software ermöglicht. Dadurch sind Änderungen und neue Features einfach nachvollziehbar und potenzielle Probleme durch Breaking Changes können klar kommuniziert werden. In diesem Falle geschieht die Versionierung über Git-Commits, durch klar definierte Commit-Messages wird strukturiert festgehalten, um was für eine Art Änderung (Feature, Fix oder Breaking Change) es sich handelt. Diese wird durch Semantic-Release ermöglicht, welches den Release steuert. Der Release wird über eine CI-Pipeline ausgeführt, Semantic-Release schaut sich die Commits seit dem letzten Release an und bestimmt dadurch die nächste Version. Sind zum Beispiel seit dem letzten Release nur Commits mit “fix(…)” getätigt worden, würde die Version von 1.4.3 auf 1.4.4 erhöht werden. Beinhaltet eine Commit-Nachricht den Inhalt BREAKING CHANGE wird von 1.4.3 auf 2.0.0 erhöht. Neben der Bestimmung der Versionsnummer kümmert sich Semantic-Release auch noch zum Beispiel um das korrekte Setzen eines Git-Tags sowie die automatische Generierung von Release Notes. Die Release Notes können dann etwa direkt in GitHub hinterlegt werden. Hier können zum Beispiel alle Releases inkl. Release Notes der shuto-api eingesehen werden.

Ein letzter kleiner Fokus war es direkt im GitHub-Readme sicherzustellen, dass die wichtigsten Informationen, um zum Projekt beizusteuern oder es selbst zu nutzen, direkt einsehbar und verständlich sind.
Eine flexible und erweiterbare API bauen
Ein Fokus war ja, einen flexiblen und erweiterbaren Service zu schaffen, welcher sich einfach in verschiedene Umgebungen integrieren lässt. Ziel war es also, bewährte API-Prinzipien zu gewährleisten, um eine konsistente und verständliche Schnittstelle bereitzustellen. Dies stellte sich an vielen Punkten als kompliziert und herausfordernd dar. Eine erste Version, API Entwurf, war meist schnell gefunden, beim Testen oder eigenen Ausprobieren fallen dann doch immer wieder Ungereimtheiten auf, welche für andere Benutzer, welche nicht den gleichen Kenntnisstand haben, unklar sein könnten. Ein zentraler Punkt des Services ist es, einfach über Query Parameter die gewünschten Komprimierungen, Größen etc. des Bildes anzupassen. Es ist also möglich, wenn das Bild mit einer Breite und Höhe von 500px gewünscht ist dieses folgendermaßen anzufragen /v2/images/example-image.jpeg?w=500&h=500
Das Auswählen und Festlegen der Bezeichnungen und Funktionen der Query Parameter schien auf den ersten Blick leicht, ich ertappte mich aber selbst bei eigenen Testen zwischen width
und w
zu wechseln sowie die genaue Bezeichnung anderer Parameter zu vergessen. Schließlich legte ich mich fest, mich an der API von imgix (API Dokumentation), einer weitverbreiteten ähnlichen Lösung (wird zum Beispiel von Unsplash verwendet) zu orientieren. Generell nahm ich während der Entwicklung und auch bei späteren Versionen immer wieder Änderungen an Bezeichnungen und API-Strukturen vor. Diese Entscheidungen traf ich stets mit dem Gedanken im Hinterkopf, dass es sich um ein Open-Source-Projekt handelt und andere es möglichst einfach nutzen und verstehen sollten.
Ein anderer Fokus bestand in der modularen Auswahlmöglichkeit der Storage-Lösung, um damit möglichst viele Speicheranbieter wie AWS S3, Google Cloud Storage, Nextcloud, aber auch FTP und viele weitere zu ermöglichen. Dies wurde nach viel trial-and-error und verschiedenen Ansätzen schließlich durch die Integration eines weiteren Open-Source-Tools rclone gelöst. Dieses ermöglicht es, über 70 verschiedene Storage-Lösungen zu integrieren und für den Service zu nutzen, ohne dass individuelle Implementierungen für jeden Anbieter notwendig waren und unterstützt zudem den Open-Source-Charakter.
Ein weiterer wichtiger Aspekt bei der Entwicklung des Services war die Sicherheit der Bilder. Eine Implementierung, bei der alle Bilder ohne Zugriffskontrollen verfügbar waren, stellte ein erhebliches Sicherheitsrisiko dar, besonders für sensible oder private Inhalte. Da die Pfade zu den Bildern Teil der URL sind, bestand die Gefahr, dass Unbefugte systematisch nach vorhandenen Bildern scannen könnten. Gleichzeitig wollte ich keine komplexen Authentifizierungsmechanismen wie User-Sessions erzwingen, da dies die einfache Einbindung von Bildern, beispielsweise in Nachrichtenartikeln, erschweren würde. Nach Abwägung verschiedener Sicherheitsoptionen – Client-seitige Authentifizierung (mit Sessions), API-Keys oder pre-signed URLs – entschied ich mich für einen flexiblen Ansatz mit optionalen pre-signed URLs. Diese Lösung ermöglicht es, URLs mit allen Parametern kryptografisch zu signieren, wodurch das systematische Scannen nach Bildpfaden verhindert wird. Zusätzlich können die Signaturen mit einem Zeitstempel versehen werden, um den Zugriff zeitlich zu begrenzen, oder ohne Zeitstempel für dauerhafte Verfügbarkeit konfiguriert werden. Diese Implementierung bietet einen guten Kompromiss zwischen Sicherheit und Benutzerfreundlichkeit, während sie gleichzeitig die Flexibilität bewahrt, die für verschiedene Anwendungsfälle erforderlich ist.
Technische Herausforderungen meistern
Bei der Entwicklung stieß ich auf verschiedene technische Herausforderungen, die kreative Lösungsansätze erforderten. Das Überwinden dieser Hürden war nicht nur für die Funktionalität der API entscheidend, sondern auch eine wertvolle Lernerfahrung.
Eine der größten Herausforderungen war die Latenz zwischen dem Service und den verschiedenen Storage-Lösungen, insbesondere beim Auflisten von Bildern. Die Anfragen mittels rclone an die Storage-Lösungen verursachten spürbare Verzögerungen, die die Benutzererfahrung beeinträchtigten. Um dieses Problem zu lösen, implementierte ich schlussendlich einen LRU-Cache (Least Recently Used) mit dem “Stale-While-Revalidate”-Muster. Dieser Ansatz ermöglicht es, zunächst zwischengespeicherte (möglicherweise veraltete) Daten sofort zurückzugeben, während im Hintergrund eine Aktualisierung der Daten stattfindet. Dadurch werden Antwortzeiten drastisch reduziert, ohne auf aktuelle Daten verzichten zu müssen. Dies stellt den klassischen Kompromiss zwischen Geschwindigkeit und Aktualität dar. Ich setzte am Ende auf den LRU-Cache welcher in-Memory gespeichert wird, da er die einfachste und effektivste Methode war, die keinerlei externe Abhängigkeiten erzeugte. Klassisch hätte ich für solch einen Einsatzzweck eine In-Memory-Datenbank als Cache (wie z.B: Redis) oder einen externen Key-Value-Store herangezogen. Diese Komponenten hätten aber die Komplexität des Aufbaus für Nutzer, die es selbst hosten möchten, erhöht. Es wäre aber denkbar, solche Möglichkeiten in der Zukunft als optionale Komponente zu ermöglichen.
Eine weitere Herausforderung bestand darin, die Zuverlässigkeit des Services durch umfassende Tests zu gewährleisten. Da der Service stark von externen Libraries / Kommandozeilen Tools wie rclone und libvips abhängt, war es entscheidend, diese Komponenten in Testumgebungen zu simulieren. Ich setzte auf das „Mocking“ der Libraries, um isolierte und reproduzierbare Tests zu ermöglichen, ohne tatsächliche Verbindungen zu externen Diensten herstellen zu müssen. Zusätzlich verwendete ich feste (pinned) Versionen aller Abhängigkeiten, um sicherzustellen, dass unerwartete Änderungen in externen Bibliotheken nicht zu Problemen führen. Diese Teststrategien waren besonders wertvoll, da sie frühzeitig Probleme aufdeckten und die Stabilität des Services über verschiedene Umgebungen während des ganzen Entwicklungsprozesses immer wieder sicherzustellen
Developer-Experience und Integration
Auch wichtig bei der Entwicklung war es für mich, nicht nur einen leistungsfähigen API-Service, sondern auch eine gute Integration für Entwickler zu schaffen, da dies meiner Meinung nach genauso wichtig für den Erfolg eines Projektes, insbesondere eines Open-Source-Projekts, ist.
NPM-Pakete für einfache Integration
Um dies zu ermöglichen, habe ich für JavaScript- und React-basierte Projekte jeweils zwei dedizierte NPM-Pakete erstellt, <a href="https://www.npmjs.com/package/@shuto-img/api" title="">@shuto-img/api</a>
und @shuto-img/react
.
Das API-Paket bietet eine schlanke Abstraktionsschicht über die API, welche die Komplexitäten der HTTP-Anfragen sowie der URL-Generierung kapselt. Besonders die Funktionalität der pre-signed URLs, welche in ihrem Aufbau der Signatur identisch zu der erwarteten Signatur der API sein muss, ist durch das Paket deutlich leichter in der Integration. Hier ein kleiner Ausschnitt, wie mithilfe des API-Pakets die API integriert werden kann:
Für React-Entwickler bietet das @shuto-img/react
-Paket eine nahtlose Integration durch spezialisierte Komponenten und Hooks welche eine einfache Integration in das React-Ecosystem ermöglichen.
Um die Funktionalität dieser Pakete sowie der API zu demonstrieren, habe ich eine Demoseite erstellt, die auch als Referenzimplementierung dient. Diese kann unter shuto-demo.lg-apps.tech eingesehen werden. Die Entwicklung dieser Pakete begann ich erst spät im Entwicklungsprozess, was ich im Nachhinein früher angegangen wäre, dennoch erwiesen sie sich als entscheidend für die Benutzerfreundlichkeit des gesamten Projekts.

API-Dokumentation
Für Entwickler, welche die NPM-Pakete nicht nutzen wollen oder können, sich aber trotzdem intensiv mit der API-Dokumentation beschäftigen möchten, aber auch generell ist eine gute API-Dokumentation unerlässlich. Nachdem ich diese Dokumentation am Anfang manuell gepflegt hatte, was sich schnell als kompliziert und aufwendig sowie fehleranfällig erwies, stellte ich die API-Dokumentation von manueller OpenAPI/Swagger-Dokumentation auf eine automatisch generierbare Dokumentation im selben Format um. Mithilfe des Tools swag wird jetzt die Dokumentation in der OpenAPI-Spezifikation automatisch mithilfe von Code-Annotationen generiert. Diese Dokumentation ist dann über die interaktive Swagger-UI (hier am Beispiel von shuto) zugänglich. Um dies zu ermöglichen, werden die Annotationen im Code an passenden Stellen, in diesem Fall direkt im Zusammenhang mit den jeweiligen HTTP-Handlern hinzugefügt und können somit bei Änderungen direkt aktualisiert werden.
Die Dokumentation bleibt dann im Idealfall immer synchron mit dem Code, die Entwickler können zusätzlich direkt mit der Dokumentation testen sowie die OpenAPI-Spezifikation mit Client-Generatoren verwenden.
In diesem Fall hat die Automatisierung nicht nur den Wartungsaufwand, sondern auch die Qualität und Aktualität der Dokumentation verbessert.
Key Learnings
Die Entwicklung hat mir vor allem verdeutlicht, wie stark sich die Anforderungen an öffentliche APIs von internen Lösungen unterscheiden. Die Balance zwischen Flexibilität und Einfachheit war eine ständige Herausforderung. Ich habe gelernt, dass die richtigen Abstraktionsebenen wichtig für die Erweiterbarkeit und die Wartbarkeit sind. So reduzierte die Integration von rclone als Abstraktionsschicht den Implementierungsaufwand im Vergleich zu anderen getesteten Lösungen erheblich.
Ein weiteres Learning war generell so früh wie möglich, die Software im Komplettzustand zu testen. Damit meine ich alle Komponenten, von der Verbindung und damit bestehenden Latenzen zu Storage-Lösungen bis zu den geplanten Frontend-Paketen, wäre es sinnvoll gewesen, alles so früh wie möglich zusammen zu testen. Damit hätten einige Punkte früher durchdacht und geplant werden können. Bei der Implementierung und vor allem Nutzung der Frontendpakete sind mir wieder mögliche Verbesserungen für die API eingefallen. Hätte man früher die Verbindung zu mehreren verschiedenen Storage-Lösungen getestet, hätte der Cache früher integriert werden können. Generell macht ein End-to-End Testing in allen Fällen so früh wie möglich Sinn und bringt wahrscheinlich sinnvolle Erkenntnisse.
Von der Theorie zur Praxis
Im Rahmen des Projektes wurde mein Verständnis von Themen wie modularer Architektur, CI/CD sowie API-Design vertieft, aber auch von Aspekten, die in diesem Blogpost nicht beschrieben worden sind, wie Infrastructure-as-Code (mit Terraform), verschiedene Formen des Loggens. Die Herausforderung, eine Applikation, die von Anfang an für Open Source gedacht ist und auch anderen Entwicklern zugänglich sein soll, zwang mich des Öfteren erneut und anders über Themen nachzudenken.
Ich habe erneut gemerkt, wie wichtig eine Sammlung an verstandenen und einsetzbaren Techniken ist. Gleichzeitig darf man das Gesamtbild der Anwendung sowie weitere Aspekte wie die Entwickler und die zugrundeliegende Architektur nicht aus den Augen verlieren. Die Balance zwischen Vision und Implementierung und die Einschränkungen dadurch nehme ich für die Weiterentwicklung von shuto (was fest geplant ist) sowie andere Projekte mit.
Leave a Reply
You must be logged in to post a comment.