Preis: 9,80 €
Erhältlich ab: Mai 2022
Umfang: 100
Java-Software-Artefakte waren schon immer – by Design – recht portabel, die zugehörige Infrastruktur indes nicht. So blieb zwar das stets proklamierte „Write once, run everywhere“ in der Java-Community als Phrase lebendig, in der Praxis hatte es wenig Bedeutung. Mit Docker entstand im Jahre 2014 die Möglichkeit, Software als Ganzes inklusive der Ablaufumgebung in Container verpackt und mit Hilfe der Docker Registry bereitzustellen.
Später setzte sich ergänzend Kubernetes durch, um die neue, mit Docker containerisierte Cloud-native Microservices-Welt zu organisieren. Dass aber der Kubernetes-Standard allein auch noch keine Lösung darstellt, verdeutlicht ein Blick auf die Cloud-Native-Landkarte der Cloud Native Computing Foundation: https://landscape.cncf.io. Es bleibt kompliziert.
Um leistungsfähige Systeme robust, flexibel, skalierbar und kosteneffizient in der Cloud zu deployen und zu betreiben, bedarf es vieler Stellschrauben, die sorgfältig zu justieren sind. Das scheint dem Versprechen von DevOps, die Verwendung von Infrastruktur durch Automatisierung zu vereinfachen, Hohn zu sprechen. Das schöne neue „Infrastructure as Code“-Universum scheint, mal wieder, Java-Entwickler:innen von ihrer Kernaufgabe abzulenken, nämlich: guten Applikationscode zu schreiben, der den Businessanforderungen dient.
Hinzu gesellt sich ein weiteres Problem: Java wurde in seiner über 25-jährigen Geschichte nicht wirklich für diese Art von Anforderungen designt, wie Lars Röwekamp, Thilo Frotscher und Frederieke Scheper in ihren Leitartikeln erläutern.
Auftritt Quarkus: „A Kubernetes Native Java stack […] crafted from the best of breed Java libraries and standards.“ Das klingt nach entschlossenem und ehrlichem Anpacken, was uns die Leute von Red Hat da auf der Quarkus-Website versprechen; ein Fullstack-Framework, das Java-Entwickler:innen einen Weg durch den Cloud-nativen Dschungel weisen und ihnen die Freude beim Entwickeln von Applikationen (und nicht der Infrastruktur) zurückgeben will.
Wenn diese Rechnung aufgeht – und unser Titelthema legt diesen Schluss nahe – dann hat die Java-Welt wieder einmal bewiesen, dass Totgesagte einfach länger leben, denn Quarkus hat das Zeug dazu, Java aus der alten Welt direkt in die Cloud-Gegenwart zu katapultieren!
Dass dies unter Vermeidung von Technologiebrüchen gelingen soll, das heißt durch Verwendung klassischer APIs und Libraries der Java-Welt, macht Quarkus zu einem Anwärter für die Hall of Fame der Java-Innovationen, denen es immer wieder gelungen ist, Innovation mit Investitionsschutz zu versöhnen.
Eine inspirierende Lektüre wünscht
Vernünftige Software zu schreiben ist schwer. Vernünftige Software in verteilten Systemen zu schreiben ist noch schwerer. Wie können wir uns also die Arbeit erleichtern, wenn es viele bewegliche Ziele gibt, die Dienste mal da sind und mal nicht und das ganze System am besten auch noch immer verfügbar und performant sein soll wie in der Cloud?
Die Cloud und Cloud-native Anwendungen rücken in den Alltag von immer mehr Entwicklern. Hinter dem nebulösen Konzept „Cloud“ verbirgt sich regelmäßig ein verteiltes System, das aus vielen kleinen Anwendungen und Ressourcen zusammengesetzt ist. Dieser Aufbau ermöglicht es der Cloud, Eigenschaften wie Nebenläufigkeit, Fehlertoleranz und Skalierbarkeit zu bieten. Gleichzeitig birgt der Einsatz verteilter Systeme ein großes Fehlerpotenzial, insbesondere im Hinblick auf Konsistenz und Netzwerkprobleme.
Die Eigenarten verteilter Systeme erfordern von uns Entwicklern, dass wir die Anwendungen entsprechend gestalten, zusammenstellen und auch mit potenziellen Fehlerfällen angemessen umgehen können. Ein mögliches Paradigma, das die oben genannten Eigenschaften seit Jahrzehnten erfüllt und damit für den Einsatz in der Cloud wie gemacht zu sein scheint, ist das Actor Model.
Wir wollen uns heute anschauen, welche besonderen Anforderungen Cloud-native Anwendungen erfüllen müssen, was das Actor Model ist und inwiefern es für den Einsatz in der Cloud geeignet ist. Dabei soll uns ein vereinfachtes Beispiel zur Veranschaulichung begleiten: Eine Komponente nimmt eine Liste von URLs entgegen, ruft jeden URL via HTTP auf, wertet die erhaltenen Antworten aus und gibt sie gebündelt zurück.
Wenn wir gegenwärtig von der Cloud sprechen, meinen wir meistens ein verteiltes System aus einer Vielzahl von Anwendungen und Ressourcen, die in Containern gekapselt und durch Orchestrierungsdienste verwaltet werden. Diese Anwendungen, gerne auch als Dienste bezeichnet, kommunizieren über definierte Schnittstellen direkt oder indirekt, synchron oder asynchron und erwirken im Zusammenspiel miteinander die gewünschte Funktionalität.
Durch diesen Aufbau erlangen wir viele positive (und auch teilweise negative) Eigenschaften, die wir sonst nicht hätten. Zu den wichtigsten dieser Eigenschaften zählen sicherlich die Ausfallsicherheit und die Skalierbarkeit.
Die Ausfallsicherheit benennt die Eigenschaft des Systems, seine Funktionalität auch dann zur Verfügung stellen zu können, wenn Teile des Systems ausgefallen sind. Der Ausfall kann dabei einzelne Komponenten bis hin zu ganzen Rechenzentren betreffen. Ausgelöst durch kleine Bugs bis hin zum großflächigen Stromausfall. Für diese Fälle sind die Dienste und Ressourcen in der Cloud redundant ausgelegt und auf verschiedene Knoten verteilt. Spezielle Hard- und Software wie etwa Load Balancer erkennen den Ausfall einzelner Teile, unterbinden die Kommunikation mit ihnen und leiten eingehende Nachrichten auf alternative Routen um.
Die zweite wichtige Eigenschaft ist die Skalierbarkeit, die Fähigkeit des Systems, auf veränderte Lastbedingungen einzugehen und ohne Verlust von Leistung und ohne Erhöhung der Verwaltungskomplexität auf das Hinzufügen von Benutzern und Ressourcen zu reagieren.
Die Skalierbarkeit wird auf zwei Achsen betrachtet: auf der Vertikalen und auf der Horizontalen. Die Skalierbarkeit in der Vertikalen wird auch als Scaling Up bezeichnet und ist charakterisiert durch den Einsatz immer stärkerer Hardware, um den wachsenden Leistungsanforderungen gerecht zu werden. Diese anfangs simpel umzusetzende Maßnahme hat sehr schnell mehrere Nachteile. Zum einen ist der Einsatz immer besserer Hardware nicht mehr wirtschaftlich, da stärkere Hardware oder – noch schlimmer – speziell hierfür entwickelte Hardware sehr teuer ist. Zum anderen gelangt auch die beste Hardware irgendwann an die Grenzen ihrer Leistungsfähigkeit und kann den wachsenden Anforderungen nicht gerecht werden. Außerdem stellt der Einsatz eines einzelnen Servers einen Single Point of Failure dar, dessen Ausfall auf einen Schlag das gesamte System lahmlegt.
Die Probleme des Single Point of Failure und der steigenden Kosten behebt die Skalierbarkeit in der Horizontalen, auch als Scaling Out bekannt. Scaling Out bezeichnet den Zusammenschluss mehrerer Computer/Server zum gemeinschaftlichen Erreichen der Funktionalität. Die Last wird dabei durch geeignete Verfahren der Lastverteilung auf die jeweiligen Server verteilt. Das ermöglicht den Einsatz sog. Commodity-Hardware, also regulärer, bezahlbarer Computer, die in ihrem Zusammenschluss die benötigte Leistung bereitstellen.
Die Dienste, Daten und Ressourcen werden im Rahmen der horizontalen Skalierung je nach Anwendungsfall entweder dedizierten Servern zugewiesen (Partition) oder auf mehrere Server verteilt (Replikation). Die Replikation ermöglicht es, Redundanz herzustellen und so bei einem Ausfall eines Teils des Systems die Funktionalität im Ganzen aufrecht zu erhalten. Die beiden genannten Varianten kommen im Regelfall gemeinsam vor: Dienste werden dedizierten Partitionen des Clusters zugewiesen, werden aber aus Gründen der Redundanz repliziert vorgehalten.
Die Replikation birgt jedoch auch Risiken, insbesondere in der Konsistenz der Daten, die z. B. in „Distributed Systems“ von Tanenbaum [1] und in „Designing Data-Intensive Applications“ von Kleppmann [2] ausführlich beschrieben und daher hier nicht weiter betrachtet werden.
Unser einführendes Beispiel kann z. B. so implementiert sein, dass die genannte Komponente ein Dienst ist, der von anderen Diensten aufgerufen wird. Die aufrufenden Dienste werden dann so lange auf die Antwort warten, bis unsere Komponente alle Aufrufe erledigt und ausgewertet hat. Die benötigte Zeit für das sequenzielle Abarbeiten des übergebenen URL wird so aus der Summe aller einzelnen Aufrufe ermittelt. Von möglichen Fehlerfällen wollen wir hier gar nicht erst ausgehen.
Ein anderer Ansatz für die Verarbeitung der einzelnen Aufrufe ist die parallele Ausführung. Aber wie stellt man das sinnvollerweise an? Schauen wir hierzu auf das Actor Model.
Das Actor Model (zu Deutsch Aktorenmodell) ist ein ursprünglich 1973 von Carl Hewitt [3] vorgestelltes mathematisches Modell zur Betrachtung von nebenläufigen Berechnungen. In diesem Modell ist der Aktor die elementare Einheit der Berechnung, die allein über Message Passing, also den Austausch von Nachrichten mit anderen Aktoren kommuniziert und sonst nichts mit anderen Aktoren teilt, insbesondere keine Zustände.
Jeder Aktor wird durch drei wesentliche Eigenschaften definiert: Logik, Speicher und Kommunikation. Logik meint die Implementierung, Speicher die Möglichkeit, Zustände zwischen einzelnen Nachrichten behalten zu können, und Kommunikation die Adressierbarkeit.
Ein Aktor verfügt über einen Posteingang und kann über eine eindeutige Adresse angesprochen werden. Erhält ein Aktor eine Nachricht, kann er auf drei Arten reagieren:
Nachrichten an andere Aktoren senden, deren Adressen er hat (oder an sich selbst),
weitere Aktoren erzeugen oder
bestimmen, wie mit der nächsten eingehenden Nachricht verfahren werden soll, also den eigenen Zustand für die Behandlung der nächsten Nachricht bestimmen.
Das bedeutet, dass sich der Zustand nicht ändert, sondern ein neuer Zustand für die nächste Operation gegeben ist.
Die Verarbeitung der eingehenden Nachrichten erfolgt sequenziell in der Reihenfolge des Eingangs (FIFO-Prinzip). Die Reihenfolge des Empfangs muss nicht zwingend der Reihenfolge entsprechen, in der die Nachrichten gesendet wurden. Die Kommunikation mit Aktoren erfolgt grundsätzlich asynchron, ein synchrones Verhalten kann aber durch eine ausgehende Nachricht als Antwort auf eine eingehende Nachricht abgebildet werden.
Eine wesentliche Eigenschaft eines Aktors ist, dass der interne Speicherbereich isoliert ist. Das bedeutet, dass kein anderer nebenläufiger Aktor den Speicherbereich direkt verändern kann. Zustandsänderungen können allein über den Austausch von Nachrichten erfolgen, die Entscheidung hierüber trifft aber der jeweilige empfangende Aktor über das für ihn definierte Verhalten.
Diese Eigenschaft wird idealerweise mit dem Einsatz von nicht-veränderlichen (immutable) Datenstrukturen verbunden. Hierdurch kann die Verwendung von Mechanismen zur Synchronisierung des Zugriffs, also z. B. Locks und Mutexe, vermieden werden, da nicht mehrere Aktoren gleichzeitig schreibend auf die Datenstruktur zugreifen können. Der Verzicht auf diese Mechanismen erlaubt es dann auch, die Verarbeitung einfacher zu parallelisieren, weil nun der gleichzeitige Zugriff auf Daten unterbunden ist.
Zu den derzeit verbreiteten Lösungen, die sich am Actor Model orientieren, zählen die Erlang-Plattform mit der gleichnamigen Sprache [4], die ebenfalls hierauf aufbauende Sprache Elixir [5] und diverse Bibliotheken für verschiedene Sprachen wie Akka [6] und actor4j [7]. Aktoren werden hier durch Prozesse (Erlang) respektive Objekte (Akka) repräsentiert, die in Isolation zu anderen Einheiten stehen und nur durch den Austausch von Nachrichten kommunizieren.
Ein Konzept, das in Zusammenhang mit Aktoren besonders sinnvoll ist, ist der Supervisor. Ein Supervisor ist ein Aktor, dessen einzige Aufgabe es ist, andere Aktoren zu erzeugen, zu überwachen und ggf. zu beenden oder neu zu starten. Beenden und Neustarten können dabei nach verschiedenen Strategien erfolgen und z. B. nur einen Aktor betreffen oder alle von diesem Supervisor überwachten Aktoren. Hierdurch ist es möglich, im Falle eines unerwarteten Fehlers eine ganze Reihe von Aktoren in deterministischen Zuständen neu zu starten, ohne dass die gesamte Anwendung inkonsistent wird.
Das ermöglicht weiter die Anwendung der sog. Let-it-crash-Mentalität: Bei der Entwicklung wird nicht versucht, jeden einzelnen Fehlerfall abzufangen, sondern akzeptiert, dass unerwartete Fehler vorkommen werden. Daher wird darauf geachtet, dass ein Fehler und seine Auswirkungen auf den Rest des Systems weitestgehend isoliert sind. Eine mögliche Implementierung unseres Beispiels mit Aktoren ist in Abbildung 1 dargestellt.
Der Aktor A erhält eine Nachricht, die mehrere URLs enthält. Für jeden erhaltenen URL kann A nun weitere Aktoren erzeugen und jeweils einen URL übergeben. Die erzeugten Aktoren führen den Aufruf via HTTP aus, werten die Antwort aus und schicken sie per Nachricht an A zurück. Danach werden die erzeugten Aktoren wieder beendet. Die Ausführung der einzelnen Aufrufe erfolgt nun parallel und dauert maximal so lange, wie der langsamste Aufruf dauert. Für Fehlerfälle wie etwa fehlschlagende Aufrufe kann ein Supervisor eingesetzt werden, der beispielsweise die betroffenen Aktoren für weitere Versuche neustartet.
Die Einfachheit in der Entwicklung, die durch den Einsatz des Actor Models erreicht werden kann, eignet sich hervorragend für den Einsatz in Cloud-Umgebungen. Jeder Aktor wird durch eine konkrete, dedizierte Aufgabe definiert. Durch das Zusammenspiel der einzelnen Aktoren können dann die positiven Effekte einer Cloud-Anwendung erreicht werden.
Im Kontext von Software für Cloud-Umgebungen können Aktoren für verschiedene Rollen eingesetzt werden, etwa als Microservices, Event Handler und Publisher, Broker oder verteilte Logger. Die jeweilige Ausprägung des Verhaltens ist hierbei unterschiedlich, die zugrunde liegende Arbeitsweise jedoch immer die Gleiche.
Da die Kommunikation auf Message Passing basiert, ist die konkrete Implementierung der Übertragung in der Regel verborgen. Plattformen wie Erlang setzen auf eine transparente Kommunikation zwischen Prozessen und vereinfachen so den Austausch von Nachrichten. Transparent bedeutet, dass es im Rahmen der Adressierung unerheblich ist, ob die Prozesse innerhalb desselben Knotens laufen oder über verschiedene Knoten im Cluster verteilt sind. Hierdurch ist es möglich, Anwendungen bei Bedarf von einem auf mehrere Knoten zu verteilen, ohne die Struktur wesentlich anpassen zu müssen.
Der Einsatz von Message Passing ist weiter eine bewährte Grundlage, um die Eigenschaften Ausfallsicherheit und Skalierbarkeit zu erhalten. Nachrichten werden an eine Adresse gesendet. Hinter dieser Adresse kann sich ein Aktor verbergen, aber auch eine ganze Reihe von Aktoren, die parallel und isoliert von den anderen Aktoren ihrer Funktion nachgehen. Verbunden mit dem geeigneten Einsatz eines Supervisors, der auf den unerwarteten Ausfall von Aktoren reagieren kann, lässt sich eine hohe Verfügbarkeit der Anwendung erreichen.
Der oben genannte Supervisor kann in Cloudumgebungen, die häufig durch eine Form von Orchestrierung wie Kubernetes betrieben werden, entweder ersetzt oder damit verbunden werden. Wenn ein Service im Cluster ausfällt, wird die Orchestrierung diesen normalerweise auch neustarten, wie ein Supervisor das erledigen würde.
Ein wichtiger Aspekt, der bei der Kommunikation zwischen den Diensten beachtet werden muss, ist, dass die einzelnen Nachrichten verschlüsselt und signiert sind. So kann sichergestellt werden, dass nur legitimierte Nachrichten verarbeitet werden und das System nicht durch bösartige Nachrichten beeinträchtigt wird.
Andreas Willems ist Enterprise Developer bei der OPEN KNOWLEDGE GmbH in Oldenburg. Als Fullstack-Entwickler liegt sein Fokus auf den Eigenarten verteilter Systeme. Außerhalb der Java-Welt beschäftigt er sich vor allem mit der funktionalen Programmierung in Elixir und der Erlang-Plattform.
[1] https://www.distributed-systems.net/index.php/books/ds3/
[3] https://dl.acm.org/doi/10.5555/1624775.1624804
[6] https://akka.io
Cloud-Services lassen sich schnell und flexibel einrichten, mit jeder neuen Anwendung steigt aber auch die Komplexität. So kann der Überblick über die Anzahl und Preismodelle der verschiedenen Ressourcen schnell verlorengehen. Wie können Anwender schnell über steigende Kosten informiert werden? Welche Dienste und Projekte sind dafür verantwortlich? Wer sind die entsprechenden Ansprechpartner? Der Artikel bietet einen Überblick darüber, wie sich diese Fragen am Beispiel der Amazon Web Services (AWS) einfach beantworten lassen und welche Maßnahmen zur Kostenkontrolle möglich sind.
Cloud-Dienste sind zwar leicht erstellt, die zu erwartenden Ausgaben sind zu Beginn aber nicht immer klar. Die Sorge vor plötzlichen Kostensprüngen führt häufig zum Griff zu konventionellen, dedizierten Serverarchitekturen – doch prinzipiell benötigt es nur drei Planungsschritte, um das zu negieren:
Vorabschätzung von Kosten anhand der geplanten Infrastruktur und ihrer Eigenschaften
Überwachung und Kommunikation dynamischer Kostenänderungen
Identifikation von Kostenstellen
Die Identifizierung von benötigten Diensten und eine initiale Schätzung von voraussichtlichen Kosten sind wichtige Schritte für die Auslieferung von Infrastruktur in der Cloud. Mit dem AWS-Kostenrechner [1] wird eine leicht zu verwendende Weboberfläche bereitgestellt, die für jeden Dienst schnell und einfach die Aufwendungen anhand der benötigten Eigenschaften berechnet. Durch Anforderungsminimierung lässt sich hier schnell eine hohe Kosteneffektivität erreichen, die Möglichkeit zur Gruppierung einzelner Kosteninstanzen machen Hotspots der geplanten Architektur ersichtlich.
Initiale Abschätzungen sind wichtig und notwendig, doch durch die dynamische Natur von Cloud-Auslieferungen werden die Kosten variieren – besonders dann, wenn mit den Diensten experimentiert wird. Im AWS-Kostenmanagementdienst bieten Budgets [2] die erste und einfachste Instanz gegen unverhoffte Ausgaben. Mehrere Budgets können angelegt werden, um verschiedene Zeiträume zu überwachen und bei Überschreitungen von festgelegten Beträgen Warnungen auszulösen – im einfachsten Fall per E-Mail. Um schnell und automatisiert zu reagieren, können erfahrenere Nutzer zusätzlich Aktionen definieren, um über IAM-Regeln die Rechte von Identitäten einzuschränken oder virtuelle Maschinen und verwaltete Datenbankinstanzen zu stoppen. Wie in Abbildung 1 zu sehen ist, bietet AWS für Zeiträume größer als täglich zusätzlich prognostizierte Kosten an, wodurch frühzeitig auf kommende Ausgaben reagiert werden kann.
Wurden plötzliche Kostensprünge oder hohe Prognosen durch Budgets erkannt, gilt es, den Verursacher unter der Vielzahl verwendeter Dienste zu finden. Der AWS Cost Explorer [3] bietet, wie in Abbildung 2 ersichtlich ist, neben einem Vergleich von mehreren Zeitabschnitten die Möglichkeit, Kosten nach Diensten, Regionen und anderen Eigenschaften zu gruppieren. So lassen sich Verantwortlichkeiten schnell eingrenzen und gewonnene Erkenntnisse zur Präsentation einfach als Bericht exportieren.
Oftmals lässt sich die genutzte Infrastruktur zusätzlich kennzeichnen, sei es nach Teilabschnitten von Anwendungen, ausgelieferten Umgebungen oder ganzen Projekten. Hierfür empfiehlt es sich wie in Abbildung 3 jede Ressource mit Tags zu versehen – das hilft nicht nur bei der Zuordnung von Ressourcen, sondern kann auch als Gruppierung im Cost Explorer dienen. Hierfür müssen Tagschlüssel aber vorher im AWS-Billing-Bereich unter Kostenzuordnungs-Tags [4] aktiviert werden. Das ist allerdings nur auf Zahlerkonto-Ebene möglich und kann bis zu 24 Stunden dauern.
AWS bietet trotz der hohen Dienstvielfalt und der dynamischen Natur von Cloud-Infrastruktur verschiedenste Möglichkeiten, Kosten zu planen, zu überwachen und einzuschränken. Mit wenig Know-how lassen sich so die Vorteile um die Kostensicherheit erweitern, die Anwender von festen Serverinstanzen gewohnt sind.
Justin Kromlinger arbeitet als DevOps Engineer bei der Exxeta AG. Seine Domänen sind CI/CD, Infrastructure as Code und die Planung von Cloud-Architekturen.
„jQAssistant? Schon wieder ein neues Tool?“, könnte man fragen. Was für Viele vielleicht noch unbekannt ist, jedoch schon in vielen Projekten erfolgreich eingesetzt wird, ist ein sehr flexibles Werkzeug, mit dem Wissen über Softwaresysteme unkompliziert aufgebaut und dokumentiert werden kann. Architektur und Umsetzung lassen sich einander so wieder näherbringen. In unserer dreiteiligen Artikelserie (Kasten: „Über diese Artikelserie“) gibt Stephan Pirnbaum einen Einstieg in die Verwendung von jQAssistant und zeigt interessante Use Cases.
Werkzeuge, die Entwicklerteams bei der täglichen Arbeit unterstützen, gibt es heutzutage wie Sand am Meer: Angefangen bei IDEs mit Code Completion und Debuggern über Tools zur Qualitätssicherung bis hin zu Mitteln zur kollaborativen Arbeit. Da dürften eigentlich keine Wünsche mehr offenbleiben, oder? Nicht ganz!
Je größer die Abstraktion weg vom Code ist, desto schwieriger wird es, unterstützende Tools zu finden. Ein paar Beispiele? Sprachkonzepte können heute einfach mit jeder IDE identifiziert und analysiert werden. Für Architekturkonzepte ist das leider schwierig und aufwendig. Auch der Abgleich von Sollarchitektur und der Implementierung ist nicht so einfach wie die Überprüfung von Coding-Standards. Ebenso ist die Dokumentation von Implementierungsdetails, Stichwort Javadoc, häufig fest in den Entwicklungs-Flow integriert. Bei der Architekturdokumentation schaut das schon ganz anders aus.
Dieser Artikel ist der erste Teil einer dreiteiligen Artikelserie. Er soll die Funktionsweise und die Integration von jQAssistant in das eigene Projekt erklären. Dabei sollen exemplarisch die ersten Schritte zur Exploration von Softwaresystemen aufgezeigt werden, um jQAssistant im eigenen Projekt erfolgreich einsetzen zu können. Im zweiten Teil dreht sich alles um Software-Analytics. Es soll auf Best Practices eingegangen werden, um Software-Analytics bestmöglich einsetzen zu können. Daneben werden mögliche Fragestellungen und deren Lösung mit Software-Analytics besprochen und es wird gezeigt, welche zentrale Rolle jQAssistant dabei spielt und wie Ergebnisse interaktiv für die verschiedensten Zielgruppen aufgearbeitet werden können. Im dritten und letzten Teil soll schließlich gezeigt werden, welche Mittel und Wege jQAssistant bietet, um Architektur und Implementierung wieder zusammenzuführen. Dabei soll auch darauf eingegangen werden, wie Architekturdokumentation richtig, nachhaltig und dennoch mit wenig Aufwand umgesetzt werden kann, um schlussendlich einen Mehrwert für das gesamte Team zu liefern.
Das Open-Source-Tool jQAssistant schließt die Lücke zwischen Architektur und Implementierung und stellt dafür einen variablen Baukasten mit flexiblen Einsatzmöglichkeiten bereit. Das Ziel hinter jQAssistant ist es, die Komplexität von Softwaresystemen für den Menschen greifbar zu machen, indem es von Implementierungsdetails abstrahiert und Strukturinformationen hervorhebt. Dabei beschränkt sich jQAssistant nicht auf die Sprachbestandteile von Java, sondern ermöglicht es, auch Architekturmodelle, Informationen über das Build-System oder die Entwicklungshistorie zugänglich zu machen und mit dem Code zu verknüpfen. Durch diese Abstraktion auf das Relevante ist es Entwicklern, Architekten und Entscheidern möglich, den Fokus auf ihr konkretes Problem zu legen. Was diese Abstraktion genau ist, sehen wir im nächsten Abschnitt. Zu den Use Cases gehören unter anderem:
Software-Analytics
Beantwortung alltäglicher Entwicklerfragen, beispielsweise Impact-Analysen für Änderungen
Identifikation potenziell problematischer Codestellen, beispielsweise häufig geänderter Code
Planung von Refaktorisierungs- und Modernisierungsprojekten
Aufwands- und Risikoabschätzung für bevorstehende Änderungen
Bewertung von Softwaresystemen
Automatisierte Erhebung von Metriken und Aggregation zur Systemzustandsbewertung
Dokumentation und Abgleich von Architektur und Implementierung
Dokumentation von Architekturkonzepten und deren Identifikation im Source Code
Definition von Sollarchitekturen und Abgleich mit dem Istzustand zur Vermeidung von Architekturerosion
Generierung von Diagrammen auf Basis der tatsächlichen Implementierung, z. B. Klassendiagramme, Komponentendiagramme, Context Maps oder C4-Diagramme
Typische Use Cases sind zusätzlich in Abbildung 1 dargestellt.
Um die skizzierten Use Cases zu ermöglichen, liest jQAssistant Strukturinformationen von Softwaresystemen ein. Dieser Vorgang wird als Scan bezeichnet. Dabei können unterschiedliche Quellenformate eingelesen werden. Dazu gehören neben Javas Class-Dateien (Bytecode) auch Informationen über Build- und Abhängigkeitsinformationen in Form von pom.xml-Dateien. Je nach Konfiguration können zusätzlich auch die Git-Historie, C4- und Context-Mapper-Modelle, Enterprise-Architect-Dateien und vieles mehr gescannt werden. All diese Informationen landen in einer Neo4j-Graphdatenbank, die standardmäßig als Embedded-Version in jQAssistant enthalten ist. Die Verwendung einer Neo4j-DB stellt einen großen Vorteil dar, da dadurch zur Abfrage der Strukturen auch die dazugehörige Abfragesprache Cypher verwendet werden kann. So steht die gesamte offizielle Neo4j-Dokumentation als Grundlage zur Verfügung. Es kann leicht Unterstützung im Internet gefunden und etwaige Erfahrungen in der Verwendung der Datenbank können aus dem Projektalltag direkt übertragen werden. Das ist natürlich auch in die andere Richtung interessant. Dank jQAssistant können erste Erfahrungen mit Neo4j gemacht werden, die vielleicht später in einem Projekt vorteilhaft sind.
Auf den gescannten Informationen werden anschließend Abfragen in der Abfragesprache Cypher ausgeführt, um beispielsweise herauszufinden, welche Codebestandteile besonders häufig verändert werden. Diese Analysen können entweder manuell in der bereitstehenden Oberfläche oder automatisiert bei der Ausführung von jQAssistant durchgeführt werden. Daneben ist es auch möglich, die eingelesenen Strukturen gegen dokumentierte Sollarchitekturen abzugleichen oder die tatsächliche Implementierung zur Generierung von Dokumentationsartefakten, beispielsweise Diagrammen, zu nutzen. Abbildung 2 zeigt den groben Ablauf.
jQAssistant ist modular aufgebaut und kann durch Plug-ins erweitert werden. Damit sind der Datenbasis für die spätere Analyse nahezu keine Grenzen gesetzt (Abb. 3). So ist die Funktionalität, um Java-Code scannen zu können, selbst ein Scan-Plug-in. Das Gleiche gilt für alle anderen Dateiformate, die aktuell von Haus aus unterstützt und über den offiziellen Contrib-Bereich [1] oder sonstige Quellen durch die Community bereitgestellt werden. Neben den in jQAssistant standardmäßig enthaltenen Plug-ins werden häufig folgende Scan-Plug-ins ergänzt:
de.kontext-e.jqassistant.plugin:jqassistant.plugin.git [2]: ein Scan-Plug-in, um Git-Historien einlesen zu können
org.jqassistant.contrib.plugin:jqassistant-context-mapper-plugin [3]:ein Scan-Plug-in, um DDD Context Maps einlesen zu können
org.jqassistant.contrib.plugin:jqassistant-c4-plugin [4]: ein Scan-Plug-in, um Architekturdiagramme des C4-Modells einlesen zu können
Falls doch mal etwas fehlen sollte, ist es einfach möglich, ein eigenes Scan-Plug-in zu entwickeln [5]. Dieser Plug-in-Ansatz setzt sich ebenfalls in der Analyse fort, beispielsweise für die Prüfung von Architekturverstößen. Regeln, die einmal definiert wurden, können so in verschiedenen Projekten wiederverwendet werden. Ein Beispiel stellt hier das jqassistant-jmolecules-plugin [6] dar, das Prüfungen für Architekturkonzepte wie Domain-driven Design oder Schichtenarchitekturen bereitstellt. Aber auch unternehmensspezifische Regeln, z. B. Standardisierungen in Microservices-Landschaften, können auf diese Art wiederverwendbar umgesetzt werden. Im Bereich des Reportings stehen ebenso Plug-ins bereit, beispielsweise für AsciiDoc oder PlantUML.
Abbildung 3 zeigt auch, wie jQAssistant verwendet werden kann. Die häufigste Möglichkeit ist, jQAssistant direkt als Maven-Build-Plug-in einzusetzen. Sollte Maven nicht eingesetzt werden, existiert eine Command-Line-Distribution, die unabhängig vom Build-System verwendet werden kann. Die Ausführung von jQAssistant innerhalb von Maven unterteilt sich in einzelne Goals, u. a.:
jqassistant:scan: Einlesen von verschiedenen Dateiformaten und Aufbau der Datenstrukturen in Neo4j
jqassistant:analyze: Ausführen von Regeln zur Prüfung der Strukturen gegen Entwicklungs- und Architekturregeln
jqassistant:report: Erstellen von zusätzlichen Reports, beispielsweise einer HTML-Übersicht ausgeführter Regeln inklusive Regelverletzungen
jqassistant:server: Starten der eingebetteten Neo4j-Datenbank, um manuell Abfragen gegen diese ausführen und die Strukturen explorieren zu können
Bis hierhin wurde erläutert, welche Use Cases mit jQAssistant umsetzbar sind und wie jQAssistant intern aufgebaut ist. Um mit einem der zuvor genannten Use Cases starten zu können, ist es immer notwendig, jQAssistant in das eigene Projekt zu integrieren, schließlich müssen die Daten zunächst ihren Weg in die Datenbank finden. Für diesen ersten Teil soll die Integration von jQAssistant anhand des in [7] bereitgestellten, Java- und Maven-basierten Beispielprojekts erläutert werden. Dieses Beispielprojekt hat die Besonderheit, dass es intern in einzelne Packages zerfällt, die fachliche Teilbereiche darstellen. Um erstes Wissen über das System aufzubauen, soll herausgefunden werden, wie diese Teilbereiche miteinander interagieren und ob es gegebenenfalls sogar zyklische Abhängigkeiten, ein klares Anti-Pattern und einen guten Indikator für eine falsche Strukturierung gibt. Dazu soll natürlich jQAssistant verwendet werden.
Zur Integration in ein Projekt ist die Nutzung des Maven-Build-Plug-ins zu empfehlen. Dadurch bindet sich jQAssistant direkt in den Build-Prozess ein, kann abhängig von Maven-Profilen (de-)aktiviert werden sowie umgebungsspezifische Schritte und Prüfungen ausführen. Die in Listing 1 dargestellte Integration basiert auf dem Beispielprojekt, enthält aber nur die notwendige Minimalkonfiguration zur Beantwortung der im Rahmen dieses Artikels gestellten Frage.
Listing 1
<build> <plugins> <plugin>
<groupId>com.buschmais.jqassistant</groupId>
<artifactId>jqassistant-maven-plugin</artifactId>
<version>1.11.1</version>
<executions>
<execution>
<id>default-cli</id>
<goals>
<goal>scan</goal>
</goals>
</execution>
</executions>
</plugin> </plugins> </build>
Neben der Spezifikation der Maven-Koordinaten ist auch das jQAssistant Goal scan definiert, das, wie der Name vermuten lässt, zum Scannen des Projekts führt. Mit diesem Set-up kann jQAssistant nun während des Maven-Builds ausgeführt und anschließend das System exploriert werden, um die eingangs gestellte Frage zu fachlichen Zusammenhängen zu beantworten.
jQAssistant läuft standardmäßig in der Verify-Phase von Maven mit. Ein einfacher Aufruf von mvn verify würde entsprechend das spezifizierte jQAssistant Goal scan ausführen. Möchte man einzelne Goals abseits von mvn verify ausführen, wäre das auch direkt über mvn jqassistant:scan möglich.
Während des scan Goals wurden die Quellen des Projekts, u. a. Java-Klassen und Maven-pom.xml-Dateien, in die Datenbank eingelesen und das der Neo4j zugrunde liegende Property-Graph-Modell aufgebaut. Um herauszufinden, wie die fachlichen Teilbereiche (also Packages) des Beispielprojekts zusammenhängen, ist es hilfreich, sich die aufgebauten Strukturen anzuschauen. Nehmen wir als Beispiel die nachfolgend dargestellte Java-Klasse aus dem Beispielprojekt:
package com.buschmais.gymmanagement.user;
public class UserService { }
Mit dem Scan dieser Klasse entsteht das in Abbildung 4 dargestellte Modell. Für jedes Element wird ein Knoten in der Datenbank angelegt, hier also ein Knoten für die Klasse und ein Knoten für das Package, in dem sich diese Klasse befindet. Diese Knoten werden durch Labels beschrieben, wobei jeder Knoten auch mehrere Labels aufweisen kann. An jedem dieser Knoten können zusätzlich Properties in Form von Key-Value-Paaren hängen. In diesem Beispiel sind das der Name der Klasse, ihre Sichtbarkeit und auch der Name des Packages. Als drittes Element existieren noch Beziehungen zwischen den Knoten, beispielsweise eine :CONTAINS-Beziehung zwischen Package-Knoten und Type-Knoten, an der wiederum auch Properties hängen können.
Um nun herauszufinden, wie die fachlichen Packages zusammenhängen und ob Zyklen zwischen ihnen existieren, werden zwei Dinge benötigt. Zum einen natürlich eine passende Abfrage und zum anderen eine Möglichkeit, diese auch auszuführen. Letzteres ist nutzerfreundlich über die Weboberfläche der Neo4j-Datenbank möglich. Dazu muss auf der Kommandozeile die von jQAssistant ausgelieferte Embedded-Version via mvn jqassistant:server gestartet werden. Anschließend kann die Oberfläche im Browser unter http://localhost:7474 erreicht werden.
Nach dem Öffnen der Seite können in dem oberen Textfeld Abfragen eingegeben und über den blauen Pfeil oder über STRG + ENTER ausgeführt werden. Anschließend erscheint wie in Abbildung 5 dargestellt das Ergebnis der Abfrage. Hier ist eine Cypher-Abfrage zu sehen, die die Struktur aus Abbildung 4 abfragt. Diese besteht primär aus drei Teilen:
MATCH: sucht nach dem spezifizierten Graph-Pattern
WHERE: grenzt die Suchergebnisse auf die jeweils spezifizierten Bedingungen ein
RETURN: gibt die Ergebnisse zurück
Anzumerken ist hier die beabsichtigte Ähnlichkeit des Match-Patterns zu ASCII-Art. Knoten bilden sich in der Abfrage mit (bezeichner:Label) ab, während sich Beziehungen zwischen diesen mit -[bezeichner:RelationType]-> umsetzen lassen.
Ausgehend von dieser Query kann nun auch die Query zum Abfragen der Package-Abhängigkeiten umgesetzt werden. Dafür ist es notwendig, sich zu überlegen, wie Abhängigkeiten zwischen Packages gefunden werden können. Zwischen Package-Knoten existieren standardmäßig keine Beziehungen, die derartige Rückschlüsse zulassen. Jedoch sind auf Type-Knoten-Ebene diese Informationen in Form von :DEPENDS_ON-Beziehungen vorhanden. Damit kann wie nachfolgend dargestellt eben diese Information gefunden werden:
MATCH (p1:Package)-[:CONTAINS]->(t1:Type),
(p2:Package)-[:CONTAINS]->(t2:Type),
(t1)-[:DEPENDS_ON]->(t2)
RETURN DISTINCT p1.fqn AS Source, p2.fqn AS Target
Nun ist diese Abfrage bereits relativ umfangreich. Es wäre schön, wenn beispielsweise die Abhängigkeiten zwischen Packages direkt in der Datenbank verfügbar wären. Listing 2 bewerkstelligt eben dies. Anzumerken sind hier zwei Aspekte:
Listing 2
MATCH (p1:Package)-[:CONTAINS]->(t1:Type),
(p2:Package)-[:CONTAINS]->(t2:Type),
(t1)-[:DEPENDS_ON]->(t2)
WHERE p1.fqn STARTS WITH "com.buschmais.gymmanagement" AND
p2.fqn STARTS WITH "com.buschmais.gymmanagement"
MERGE (p1)-[d:DEPENDS_ON]->(p2)
RETURN p1, d, p2
Es wird eine Einschränkung der Package-Namen auf das Basis-Package vorgenommen, da es die erste für die Analyse interessante Ebene darstellt.
Es findet der Befehl MERGE Verwendung. Er fügt einen Knoten oder eine Beziehung hinzu, wenn diese(r) noch nicht existiert.
Nun ist es leicht, Zyklen zwischen Packages zu identifizieren. Alles, was dafür benötigt wird, ist nachfolgend dargestellt.
MATCH (p1:Package)-[d1:DEPENDS_ON]->(p2:Package),
(p2)-[d2:DEPENDS_ON]->(p1)
RETURN *
Das Ergebnis dieser Abfrage liefert für unser Beispielsystem glücklicherweise keine Ergebnisse. Wir haben also keine zyklischen Abhängigkeiten zwischen fachlichen Aspekten.
Natürlich sind noch viel mehr Möglichkeiten durch Cypher gegeben, die aber den Rahmen dieses Artikels sprengen würden. In den folgenden Artikeln dieser Serie wird das Schreiben von Abfragen jedoch immer wieder aufgegriffen, um einen tieferen Einblick zu gewähren. In der Zwischenzeit sei das Cypher Cheat Sheet [8] empfohlen.
Wenn es darum geht, Softwaresysteme zu analysieren und zu bewerten, Wissen darüber (wieder) zu gewinnen und zu dokumentieren sowie Architektur und Implementierung einander näherzubringen, führt kein Weg an jQAssistant vorbei. Zugegebenermaßen mag die Einstiegshürde zu Beginn hoch sein, da man im Zweifel eine neue Abfragesprache lernen muss. Allerdings können in der Graphdatenbank beliebige Daten aggregiert werden, was unzählige Strukturanalysen ermöglicht. Das liegt auch an dem flexiblen, plug-in-basierten Ansatz, der es einem gestattet, selbst neue Funktionalitäten zu entwickeln oder neue Programmiersprachen zu unterstützen.
Dieser Artikel ist nur ein kurzer Einstieg, der das Potenzial von jQAssistant verdeutlichen und die Einbindung in Maven-basierte Projekte erklären soll. In den nächsten beiden Artikeln dieser Serie soll dieses Wissen aufgegriffen werden, um zum einen in das Thema Software-Analytics und zum anderen in das Thema Architekturdokumentation und -validierung einzutauchen. In der Zwischenzeit kann alles Gelernte im Beispielprojekt nachvollzogen oder direkt im eigenen Projekt ausprobiert werden. Ich bin gespannt, wie ihr es findet und freue mich auf Feedback!
Stephan Pirnbaum ist Consultant bei der BUSCHMAIS GbR. Er beschäftigt sich leidenschaftlich gern mit der Analyse und strukturellen Verbesserung von Softwaresystemen im Java-Umfeld. In Vorträgen und Workshops präsentiert er seine gesammelten Erfahrungen und genutzten Methodiken.
[1] https://github.com/jqassistant-contrib
[2] https://github.com/kontext-e/jqassistant-plugins/blob/master/git/src/main/asciidoc/git.adoc
[3] https://github.com/jqassistant-contrib/jqassistant-context-mapper-plugin
[4] https://github.com/jqassistant-contrib/jqassistant-c4-plugin
[5] https://101.jqassistant.org/implementation-of-a-scanner-plugin/index.html
[6] https://github.com/jqassistant-contrib/jqassistant-jmolecules-plugin
Die Entwicklung von Bluetooth ist ein guter Indikator für die Alterung der lesenden Wetware. Der Autor dieser Zeilen kann sich noch gut daran erinnern, als er einst bei seinem damals schweineteuren Palm Tungsten|T erstmals das Bluetooth-Radio aktivierte. Traurigerweise fand er damals in seinem gesamten Umkreis keine Gegenstelle vor. Eine Wegbegleiterin kaufte dann für ihren Palm m515 eine Bluetooth-Karte, um .pcr-Dateien auszutauschen. Dass all der Affentanz über die Infrarotschnittstelle und/oder den SD-Slot genauso bequem von der Stange gegangen wäre, wollen wir an dieser Stelle verschweigen.
In den gut zwanzig Jahren seit dem Erscheinen von Tungsten|T und Co. hat sich die Bluetooth-Technologie radikal weiterentwickelt, und trotz der für kleine Hardwarehersteller lästigen Lizenzbedingungen auch Marktdurchdringung erreicht. In den folgenden Heften wollen wir uns Bluetooth näher ansehen – und zwar nach Maßgabe der Möglichkeiten sowohl unter Android als auch unter anderen Java-Plattformen.
In der Anfangszeit realisierte Bluetooth (denken Sie an Palm m515 und Tungsten|T) eine Punkt-zu-Punkt-Verbindung, wo an jeder Gegenstelle nur ein Gerät zu finden war. Diese Technologie wird von der Bluetooth Special Interest Group (SIG) auch heute noch unterstützt: Man spricht in diesem Zusammenhang vom Namen Bluetooth BR/EDR (Basic Rate/Enhanced Data Rate).
Hintergedanke dieses Standards war der Ersatz von Kabeln – ein Kabel hat normalerweise nur zwei Gegenstellen. Bluetooth LE (LE: Low Energy) wurde einst als niedrige Energieverbrauchsvariante von Bluetooth konzipiert, die sich für Embedded-Systeme eignen sollte.
Im Rahmen von Spezifikation und Standardisierung beseitigte die Bluetooth SIG nicht nur einige Probleme der Basisversion, sondern führte auch neue Betriebsmodi ein. Neben dem nach wie vor möglichen Point-to-Point-Betrieb erlaubt Bluetooth LE sowohl Broadcast- als auch Mesh-Szenarien. Im Fall eines Broadcast-Szenarios sendet ein meist als Beacon bezeichnetes Gerät „Statusmeldungen“ in den Äther, die dann von beliebig vielen Gegenstellen entgegengenommen werden können. Im Mesh-Betrieb „vernetzen“ sich die einzelnen Geräte dynamisch, was unter anderem für höhere Reichweiten des resultierenden Netzes sorgt.
Gemein ist all diesen Systemen vor allem das zugrunde liegende Radio-System. Bluetooth funkt im 2,4 GHz-Band und nutzt eine als Frequency Hopping bezeichnete und in Abbildung 1 illustrierte Technologie. Dahinter steht der Gedanke, dass Störungen im geteilten Übertragungsband dadurch überwunden werden, dass die nächste Übertragung in einem anderen Bandteil stattfindet.
Ganz analog zur historischen Entwicklung des Bluetooth-Standards wollen wir auch in diesem Tutorial mit einem gewöhnlichen, auf Bluetooth EDR basierenden Programm beginnen. Der eigentliche Bluetooth-Stack ist dabei – ganz analog zum OSI-Modell – in einer Schichtenstruktur aufgebaut.
LMP und L2CAP kümmern sich dabei um die „logische“ Aufrechterhaltung des Datenverkehrs – also darum, dass Pakete von A nach B wandern. Sofern Sie nicht einen Bluetooth-Stack von Hand realisieren (eine Sisyphus-Aufgabe), spielen sie in der täglichen Arbeit allerdings keine große Rolle. Wichtiger bei der Arbeit mit Bluetooth BER/EDR ist das Service Discovery Protocol (SDP).
Die erste Generation der Bluetooth-Spezifikation war (siehe oben) vor allem als Ersatz für verkabelte Verbindungen vorgesehen und war eine Affäre für finanzstarke Unternehmen. Daraus folgte, dass die Bluetooth SIG die vom Bluetooth-Standard zu erfüllenden Anforderungen im Allgemeinen gut zentralisiert verwalten konnte – die verschiedenen Kommunikationsarten bzw. an Bluetooth gestellten Anforderungen wurden deshalb in Form von Profilen zusammengefasst.
Die im Allgemeinen aufnahmefreundliche Wikipedia listet unter dem URL [2] die in Abbildung 2 gezeigten insgesamt 37 bekannten Profile – einige davon sind mittlerweile übrigens deprecated.
In der Praxis spielte die Mehrzahl dieser Protokolle nur eine untergeordnete Rolle, die meisten Unternehmen griffen auf das Serial-Port-Profil zurück, das (analog zu RS232) das Austauschen von mehr oder weniger beliebigen Payloads erlaubte. Das Obex-Protokoll war in der Praxis ebenfalls nicht unwichtig, ermöglichte es doch das Übertragen diverser kleiner Dateien wie die in der Einleitung genannten .pcr-Dateien. Manchmal kam auch das FTP-Protokoll zum Einsatz, das Bluetooth-Geräten das Exponieren von an FTP-Servern erinnernden Speichern erlaubte. Analog zu dem in der Frühphase manchmal verwendeten LAN-Protokoll (der Belkin Bluetooth Access Point ist unvergessen) galt allerdings auch hier, dass die vergleichsweise geringe Datenrate diese Protokolle in der praktischen Nutzung bald durch WLAN und Co. ersetzte. Bluetooth LE umging die Probleme mit den rigide definierten Profilen übrigens durch Einführung eines KV-Speichers – ein Thema, dem wir uns allerdings erst in einem der nächsten Artikel zuwenden wollen.
Komplexe Systeme lassen sich oft am leichtesten durch Betrachtung ihrer Bausteine verstehen. Google (das Unternehmen ist logischerweise Mitglied der Bluetooth SIG) bietet unter [3] ein schlüsselfertiges Chat-Sample an, das die Chatkommunikation zwischen zwei per Bluetooth verbundenen Geräten realisiert. Interessanterweise bietet Google dieses Beispiel in der in Android Studio integrierten Beispielsammlung nicht mehr an – offensichtlich sieht man im Hause Big G Bluetooth LE als wichtigeren Kandidaten.
Das Nichterscheinen des grünen Codeknopfs im Browser-GUI von GitHub informiert uns darüber, dass dieses Beispiel auch nicht direkt herunterladbar ist. Öffnen Sie stattdessen [4], um das Archiv herunterzuladen – extrahieren Sie die komplette Connectivity-Beispielsammlung (etwa 5 MB) danach in einen bequem zugänglichen Ordner im Dateisystem und importieren Sie das Chatbeispiel wie jedes andere Projekt in Android Studio.
Von den Systemanforderungen her ist das Beispiel durchaus fußgeherisch – der Autor wird in den folgenden Schritten eine Canary-Version von Android Studio verwenden, ältere Versionen der Android-Studio-IDE sollten allerdings ebenso problemlos funktionieren.
Die erste interessante Eigenschaft des Projektskeletts findet sich in der Manifestdatei, wo Google zwei Permissions deklariert:
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH" />
Im Interesse feingranularer Berechtigungsverwaltung spezifiziert Android ein gutes halbes Dutzend Permissions, die Google unter [5] in Detail auflistet. Die grundlegende Permission Bluetooth erlaubt dabei den Verbindungsaufbau zu Geräten, die den als Pairing bezeichneten Vertrauensbeziehungsaufbau durchlaufen haben. Durch Bluetooth_Admin darf die Applikation ebendiesen Prozess anstoßen, der eine permanente Verbindung zwischen zwei Bluetooth-Endstellen herstellt.
Beachten Sie, dass Android 12 im Bereich der Bluetooth Permissions massive Änderungen durchführt. Diese betreffen unsere Beispielapplikation nicht, weil ihre Target-SDK-Version 28 lautet. Google bietet unter [6] allerdings umfangreiche weitere Informationen zum Thema an.
Nach der Klärung der sicherheitstechnischen Aspekte bietet es sich an, die Applikation im ersten Schritt auf ein Gerät zu deployen. Das Anklicken des oben rechts eingeblendeten Bluetooth-Dialogs führt dann zum Aufscheinen des in Abbildung 3 gezeigten Fensters.
Da der Autor dieser Zeilen das Programm auf seinem (gut benutzten) BlackBerry PRIV zur Ausführung bringt, sehen wir ein gutes halbes Dutzend schon gepaarte Geräte. Das Anklicken von Scan führt dann zu einem neuen Scanlauf, der immer etwa 15 Sekunden dauert und noch nicht gepaarte Geräte, die als „sichtbar“ gekennzeichnet sind, erkennt.
Die hier teilweise zensiert abgedruckten Strings erinnern dabei an MACs: Jeder Bluetooth-Chip bekommt im Allgemeinen eine weltweit einzigartige ID eingeschrieben, die den jeweiligen Chip identifiziert. Die im Rahmen der Impfkampagne lancierten Meldungen über „MACs von geimpften Personen“ lassen sich übrigens anhand der Webseite [7] „resolvieren“ – haben Sie einen MAC, so lässt sich einiges über das zugrunde liegende Bluetooth-Gerät herausfinden.
Beachten Sie in diesem Zusammenhang außerdem, dass MACs auf Hardwareebene leben, Pairing-Beziehungen aber eine reine Softwaregeschichte sind. Haben Sie beispielsweise eine Dual Boot-Workstation mit einem Bluetooth-Modul, kann dies zu Kollisionen führen, wenn Sie einen Partitionswechsel durchführen.
Wenn Sie unser Programmbeispiel (dies ist immer empfehlenswert) mit ausgeschaltetem Bluetooth-Transmitter starten, sehen Sie im ersten Schritt eine Message, die Sie zur Einschaltung des Bluetooth-Zentimeters auffordert. Zum Verständnis dessen wollen wir uns zuerst den Programmstart ansehen. Google setzt in ChatSample übrigens konsequent auf Fragmente.
War zu Zeiten von Palm OS und Co. der direkte Zugriff auf die Energieversorgungseinstellungen des Bluetooth-Transmitters gang und gäbe, so muss der Android-Entwickler einen weniger direkten Weg gehen. Systemglobale Einstellungen wie die Anpassung bzw. das Einschalten des Bluetooth-Transmitters darf nur das Betriebssystem erledigen, das zu dieser Handlung durch Abschicken eines Intents aufgefordert werden möchte. Der dazu notwendige Code sieht aus wie in Listing 1 dargestellt.
Listing 1
public class BluetoothChatFragment extends Fragment {
private static final String TAG = "BluetoothChatFragment";
@Override
public void onStart() {
. . .
if (!mBluetoothAdapter.isEnabled()) {
Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
startActivityForResult(enableIntent, REQUEST_ENABLE_BT);
}
. . .
}
Die Klasse BluetoothAdapter dient dabei als Schnittstelle zwischen Ihrer Programmlogik und den verschiedenen Funktionen des Bluetooth-Stacks. Besonders wichtig ist die im Rahmen von onCreate erfolgende Bevölkerung durch Aufruf der Methode BluetoothAdapter.getDefaultAdapter() (Listing 2).
Listing 2
@Override
public void onCreate(Bundle savedInstanceState) {
mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
if (mBluetoothAdapter == null && activity != null) {
Toast.makeText(activity, "Bluetooth is not available", Toast.LENGTH_LONG).show();
}
}
Obwohl nach Erfahrung des Autors keine Android-Geräte mit mehr als einem Bluetooth-Transmitter im Markt verfügbar sind, wählt Google (übrigens analog zu Microsofts Vorgehensweise im Kinect-API) den Vollausbau, der in der Theorie auch Geräte mit mehreren Adaptern erlaubt. Der Aufruf von BluetoothAdapter.getDefaultAdapter() liefert jedenfalls eine BluetoothAdapter-Instanz zurück, die mit dem Standardtransmitter der zugrunde liegenden Hardware verbunden ist.
Obwohl der Gutteil der im Markt verfügbaren Android-Hardware heute einen Bluetooth-Transmitter mitbringt, ist es trotzdem empfehlenswert, den zurückgegebenen Wert auf null zu prüfen und eventuelle Sondergeräte schon an dieser Stelle abzuweisen.
Angemerkt sei in diesem Zusammenhang, dass der Play Store auf Wunsch auch zur automatischen Abweisung inkompatibler Hardware animiert werden darf. Erlaubt ist dabei die genaue Selektierung von Hardware, die entweder Bluetooth oder Bluetooth LE unterstützen muss:
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
Nach diesem kleinen Exkurs können wir wieder zur eigentlichen Scanning-Logik zurückkehren, die in der Datei DeviceListActivity.java unterkommt. Für die Kommunikation zwischen Entwicklerapplikation und vom Bluetooth-Stack generierten Informationen setzt Google dabei konsequent auf die Technik des BroadcastReceiver. Im Beispiel in Listing 3 hört der betreffende Kandidat auf den Namen mReceiver und ist am Ende der Datei als Inline-Variable angelegt.
Listing 3
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (BluetoothDevice.ACTION_FOUND.equals(action)) {
Wann immer der Bluetooth-Stack in der Umgebung ein sichtbares Gerät findet, sendet er einen Intent vom Typ BluetoothDevice.ACTION_FOUND. In unserem vorliegenden Beispiel beschaffen wir im ersten Schritt ein BluetoothDevice-Objekt, das uns weitere Informationen über das Zielgerät anliefert. Durch Aufrufen der Methode getBondState überprüfen wir dann, ob das Gerät schon eine Pairing-Beziehung zum vorliegenden Telefon unterhält. Ist das der Fall, müssen wir es hier nicht mehr aufnehmen:
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
if (device != null && device.getBondState() != BluetoothDevice.BOND_BONDED) {
mNewDevicesArrayAdapter.add(device.getName() + "\n" + device.getAddress());
}
Wichtig ist in diesem Zusammenhang außerdem noch das Ereignis BluetoothAdapter.ACTION_DISCOVERY_FINISHED, mit dem der Bluetooth-Stack unsere Applikation über den erfolgreichen Abschluss des kompletten Suchlaufs informiert (Listing 4).
Listing 4
} else if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) {
setProgressBarIndeterminateVisibility(false);
setTitle(R.string.select_device);
if (mNewDevicesArrayAdapter.getCount() == 0) {
String noDevices = getResources().getText(R.string.none_found).toString();
mNewDevicesArrayAdapter.add(noDevices);
}
}
}
};
Das eigentliche Anstoßen des Scanprozesses erfolgt dann über die von weiter oben bekannte BluetoothAdapter-Klasse, die über die Methode startDiscovery das direkte Aktivieren erlaubt (Listing 5).
Listing 5
private void doDiscovery() {
. . .
if (mBtAdapter.isDiscovering()) {
mBtAdapter.cancelDiscovery();
}
mBtAdapter.startDiscovery();
}
Damit können wir uns der Initialisierung des für den Suchprozess zuständigen Benutzerinterface zuwenden. Neben diversen GUI-Konfigurationshandlungen, die uns an dieser Stelle nicht weiter interessieren, finden wir hier auch die Erzeugung von zwei Intentfiltern. Diese ermöglichen Google bzw. der Applikation das Verbinden des als „innere Variable“ angelegten Broadcast Receivers mit den diversen vom Bluetooth-Stack emittierten Informationen (Listing 6).
Listing 6
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
. . .
IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND);
this.registerReceiver(mReceiver, filter);
filter = new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
this.registerReceiver(mReceiver, filter);
Nächste Amtshandlung der vorliegenden Routine ist abermals das Aufrufen von getDefaultAdapter, um die lokal vorliegende BluetoothAdapter-Membervariable zu bevölkern. Mehrfache Aufrufe von getDefaultAdapter führen in 99,9 % der Fälle dazu, dass auf dasselbe Endgerät verweisende BluetoothAdapter-Instanzen entstehen. Es ist also nicht notwendig, die Instanzen auf komplizierten bzw. verschlungenen Wegen zwischen den verschiedenen Teilen Ihrer Android-Applikation hin- und herzuschieben:
mBtAdapter = BluetoothAdapter.getDefaultAdapter();
Interessant ist im nächsten Schritt die Bevölkerung der in Abbildung 3 gezeigten oberen Liste. Sie nimmt nur die bereits gepaarten Geräte auf – Informationen, die der Bluetooth-Stack ohne Interaktion mit dem Radio bereitstellen kann. Dies erfolgt durch den in Listing 7 gezeigten Code, der über den von mBtAdapter.getBondedDevices() zurückgelieferten Speicher iteriert.
Listing 7
Set<BluetoothDevice> pairedDevices = mBtAdapter.getBondedDevices();
if (pairedDevices.size() > 0) {
findViewById(R.id.title_paired_devices).setVisibility(View.VISIBLE);
for (BluetoothDevice device : pairedDevices) {
pairedDevicesArrayAdapter.add(device.getName() + "\n" + device.getAddress());
}
} else {
String noDevices = getResources().getText(R.string.none_paired).toString();
pairedDevicesArrayAdapter.add(noDevices);
}
}
An dieser Stelle können wir in die Datei BluetoothChatFragment.java wechseln, die für die eigentliche Kommunikationsinfrastruktur, das Einsammeln der zu sendenden Daten und das Anzeigen von eingehenden Chatnachrichten verantwortlich ist. Die Klasse BluetoothChatFragment enthält dabei einige Dutzend Member-Variablen – wirklich relevant sind für uns die beiden folgenden:
public class BluetoothChatFragment extends Fragment {
private BluetoothAdapter mBluetoothAdapter = null;
private BluetoothChatService mChatService = null;
Neben der mittlerweile hinreichend bekannten Geräteinstanz BluetoothAdapter finden wir hier auch eine Instanz der Klasse BluetoothChatService. Google realisiert das Chatbeispiel unter konsequenter Nutzung der Service-Architektur, die Klasse BluetoothChatService ist ein System-Service, der die eigentliche Kommunikation und Datenübertragung enkapsuliert. Interessant ist an der vorliegenden Datei die Methode ensureDiscoverable. Sie hat die Aufgabe, den Bluetooth-Transmitter des Telefons sichtbar zu machen (Listing 8).
Listing 8
private void ensureDiscoverable() {
if (mBluetoothAdapter.getScanMode() !=
BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300);
startActivity(discoverableIntent);
}
}
Analog zur weiter oben besprochenen Einschaltung des Transmitters erfolgt auch diese Handlung durch das Abfeuern eines Intents, der danach vom Betriebssystem und dem in ihm enthaltenen GUI-Stack verarbeitet wird. Interessant ist in diesem Zusammenhang der Aufruf von putExtra, die die Flag BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION und den Wert 300 entgegennimmt.
Discoverability von Bluetooth-Endgeräten ist ein aus Stromverbrauchssicht durchaus teurer Prozess, der sich insbesondere auf die Stand-by-Laufzeit von Telefon und/oder Tablet auswirken kann. Das Freischalten der Sichtbarkeit von Bluetooth erfolgt in Android deshalb im Allgemeinen mit einem Ablaufdatum, nach dem das Gerät wieder in den normalen „Ansprechbar“-Modus zurückfällt. Die Erfahrung mit älteren Betriebssystemen lehrt nämlich, dass insbesondere technisch herausgeforderte Benutzer nur allzu häufig auf das Ausschalten dieser für sie im Allgemeinen unnützen oder sogar aus sicherheitstechnischer Sicht kritischen Funktion vergessen.
Beachten Sie in diesem Zusammenhang, dass die Sichtbarkeit in vielen Fällen keine Vorteile bringt. Ein bereits gepaartes Bluetooth-Endgerät kann sich nämlich auch dann verbinden, wenn der Transmitter Zielsystem nur eingeschaltet, nicht aber discoverable ist.
Der letzte für die Kommunikationsinfrastruktur wichtige Programmteil ist dann der folgende Handler, der sich um die Verarbeitung der vom Service eingehenden Nachrichten kümmert (Listing 9).
Listing 9
private final Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
FragmentActivity activity = getActivity();
switch (msg.what) {
case Constants.MESSAGE_STATE_CHANGE:
switch (msg.arg1) {
Im Prinzip handelt es sich dabei um gewöhnlichen GUI-Code. Im Fall der Konstante Constants.MESSAGE_STATE_CHANGE meldet der Service dem Benutzerinterface beispielsweise eine Änderung des Programmzustands, deren Auswertung nach dem Schema in Listing 10 erfolgt.
Listing 10
case Constants.MESSAGE_WRITE:
byte[] writeBuf = (byte[]) msg.obj;
String writeMessage = new String(writeBuf);
mConversationArrayAdapter.add("Me: " + writeMessage);
break;
case Constants.MESSAGE_READ:
byte[] readBuf = (byte[]) msg.obj;
String readMessage = new String(readBuf, 0, msg.arg1);
mConversationArrayAdapter.add(mConnectedDeviceName + ": " + readMessage);
break;
Wichtig ist, dass die Übertragung von Daten über die Bluetooth-Schnittstelle ausschließlich auf Basis von Byteströmen erfolgt – Encodierung und Co. ist hier ob der Nutzung von byte[] die alleinige Verantwortung Ihrer Applikation.
Nach diesem ersten Artikel haben wir eine grundlegende Working Proficiency im Bereich Bluetooth erreicht – wir verstehen nun, was die verschiedenen Arten von Bluetooth sind und wie man unter Android eine Geräte-Discovery auslöst.
Im nächsten Artikel dieser Serie werden wir unsere Experimente mit Bluetooth insofern vertiefen, als wir uns nun dem eigentlichen Datenaustausch zuwenden – und zwar sowohl unter Android als auch mit anderen Zielsystemen. Bleiben Sie also bei uns, denn die Arbeit mit Bluetooth-Funk bleibt spannend.
Tam Hanna befasst sich seit der Zeit des Palm IIIc mit Programmierung und Anwendung von Handcomputern. Er entwickelt Programme für diverse Plattformen, betreibt Onlinenewsdienste zum Thema und steht für Fragen, Trainings und Vorträge gern zur Verfügung.
[1] https://microchipdeveloper.com/wireless:ble-link-layer-channels
[2] https://en.wikipedia.org/wiki/List_of_Bluetooth_profiles
[3] https://github.com/android/connectivity-samples/tree/master/BluetoothChat
[4] https://github.com/android/connectivity-samples/tree/master
[5] https://developer.android.com/reference/android/Manifest.permission
[6] https://developer.android.com/guide/topics/connectivity/bluetooth/permissions