Rust für Java-Entwickler: ein praktischer Leitfaden
Wenn du jahrelang mit Java gearbeitet hast, bringst du fast alles mit, was du zum Rust-Lernen brauchst. Du denkst in Typen. Du kennst Generics, Interfaces, Lambdas und Stream-Pipelines. Rust hat all das: Traits sehen Interfaces mit Default-Methoden sehr ähnlich, Iteratoren lesen sich wie Streams, und Cargo erledigt die Aufgaben von Maven oder Gradle, nur mit viel weniger Konfiguration.
Neu ist alles, was bisher die JVM für dich erledigt hat. Rust hat keinen Garbage Collector und keine virtuelle Maschine. Dein Code wird zu einer nativen Binärdatei kompiliert, und der Compiler entscheidet, wann Speicher freigegeben wird, indem er Ownership (Eigentum) und Borrowing (Ausleihen) prüft, bevor das Programm läuft. Außerdem hat Rust kein null (du nutzt Option), keine Exceptions (du nutzt Result) und keine Klassenvererbung. In diese vier Änderungen fließt der Großteil deiner Lernzeit.
Warum greifen Java-Entwickler zu Rust? Meist aus einem dieser Gründe: Dienste, die wenig und gleichmäßig Speicher brauchen und keine GC-Pausen haben dürfen. Kommandozeilen-Tools und Serverless-Funktionen, die ohne JVM sofort starten sollen. Ein heißer Pfad in einer Java-Anwendung, den du in nativen Code verlagern willst (Rust lässt sich aus Java über JNI oder die neuere Foreign Function & Memory API aufrufen). Oder einfach Neugier, warum Rust in Umfragen immer wieder als „beliebteste Sprache“ ganz oben landet. Diese Seite übersetzt das, was du aus Java kennst, in Rust. So kannst du dich auf die Teile konzentrieren, die wirklich anders sind.
Einen allgemeinen Vergleich mit C++, Go und Python findest du unter Rust vs. C++, Go und Python. Wenn du auch .NET kennst, gibt es eine eigene Seite: Rust für C#-Entwickler.
Sprachkonzepte: von Java zu Rust
Abschnitt betitelt „Sprachkonzepte: von Java zu Rust“Nutze diese Tabelle wie einen Sprachführer. Die letzte Spalte verlinkt die Lektion auf dieser Seite, die die Rust-Seite erklärt.
| Java | Rust | Mehr dazu |
|---|---|---|
class mit Feldern und Methoden |
struct für die Daten + ein impl-Block für die Methoden |
Structs |
Konstruktoren und new |
Eine ganz normale Funktion, die per Konvention new heißt und Self zurückgibt |
Structs |
interface |
trait |
Traits |
default-Methoden in einem Interface |
Standardmethoden in einem Trait (sie funktionieren genauso) | Traits |
abstract class, extends, super |
Keine Vererbung. Nutze Traits mit Standardmethoden für gemeinsames Verhalten und Komposition (ein Struct in einem Struct) für gemeinsame Daten | Traits, Entwurfsmuster |
List<Shape> mit verschiedenen Klassen |
Vec<Box<dyn Shape>> (ein Trait-Objekt) |
Trait-Objekte |
sealed interface + records + Pattern-switch (Java 17/21) |
Ein enum, dessen Varianten Daten tragen, plus match |
Enums, Match |
enum mit Feldern und Konstruktoren |
enum, bei dem jede Variante andere Daten enthalten kann |
Enums |
record Point(int x, int y) |
#[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } |
Structs, Wichtige Traits |
toString(), equals(), hashCode(), compareTo() |
Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (meist per derive) |
Wichtige Traits |
== (dasselbe Objekt) vs. .equals() (derselbe Wert) |
== vergleicht immer Werte. Die Falle „Referenzgleichheit“ gibt es nicht |
Operatoren |
null und Optional<T> |
Option<T>: entweder Some(value) oder None |
Option |
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent |
unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) |
Option, If Let / While Let |
Checked Exceptions und throws |
Result<T, E> im Rückgabetyp, plus der Operator ? |
Result, Eigene Fehlertypen |
Unchecked Exceptions für echte Bugs (IllegalStateException) |
panic! (beendet den Thread) |
Panic |
Exception Chaining (new ConfigException(msg, cause)) |
map_err, oder #[source] aus thiserror und .context(...) aus anyhow |
anyhow und thiserror |
try-with-resources und AutoCloseable |
Drop, das automatisch läuft, wenn der Besitzer seinen Gültigkeitsbereich verlässt |
Gültigkeitsbereich, Smart Pointer |
| Garbage Collector | Ownership und Borrowing, geprüft beim Kompilieren | Ownership, Borrowing |
final lokale Variablen |
Einfaches let (standardmäßig unveränderbar); mit let mut erlaubst du Änderungen |
Variablen |
static final-Konstanten |
const |
Variablen |
Veränderbare static-Felder, Singletons |
static mit LazyLock oder OnceLock, in einem Mutex, wenn sich der Wert ändern muss |
Geteilter Zustand |
Generics mit <T extends Comparable<T>> |
Generics mit Trait Bounds: T: Ord oder where T: Ord |
Generics |
Type Erasure, List<Integer> statt List<int> |
Monomorphisierung: Generischer Code wird für jeden Typ einzeln kompiliert, und ein Vec<i32> speichert einfache Zahlen |
Generics, Fortgeschrittene Generics |
Lambdas, Function<T, R>, Supplier<T>, Consumer<T> |
Closures, beschrieben durch die Traits Fn, FnMut, FnOnce |
Closures, Fn, FnMut und FnOnce |
Streams: filter, map, reduce, collect |
Iterator-Adapter: filter, map, fold, sum, collect |
Iteratoren |
Iterable<T> und Iterator<T> |
IntoIterator und Iterator (eine Methode: next, die eine Option zurückgibt) |
Eigene Iteratoren |
parallelStream() |
par_iter() aus dem Crate rayon |
Nebenläufigkeit |
Thread, ExecutorService |
std::thread::spawn und std::thread::scope; rayon oder tokio für Pools |
Nebenläufigkeit |
synchronized, ReentrantLock |
Mutex<T>, der die Daten umschließt, die er schützt |
Geteilter Zustand |
AtomicInteger, AtomicLong |
AtomicI32, AtomicI64, AtomicUsize |
Geteilter Zustand |
BlockingQueue zwischen Threads |
Channels (std::sync::mpsc) |
Channels |
CompletableFuture, virtuelle Threads |
async fn und .await, ausgeführt von einer Runtime wie tokio |
Async/Await |
String (unveränderbar) und StringBuilder |
&str (eine geliehene Sicht auf Text) und String (besessen, wachsend) |
Strings |
char (eine UTF-16-Einheit) |
char (ein beliebiges Unicode-Zeichen, 4 Bytes); Strings sind UTF-8 |
Strings |
int, long, double und geboxtes Integer |
i32, i64, f64, dazu vorzeichenlose Typen von u8 bis u128. Nie geboxt |
Datentypen |
| Stiller Integer-Überlauf | Eine Panic in Debug-Builds; mit checked_add, wrapping_add, saturating_add wählst du das Verhalten |
Integer-Überlauf |
Casts wie (int) x |
as für Zahlen, From / TryFrom für sichere Umwandlungen |
Typumwandlung, Konvertierungen |
BigDecimal, BigInteger |
Die Crates rust_decimal und num-bigint |
Datentypen |
Packages und import |
Module (mod) und use |
Module |
public, private, protected, package-private |
Standardmäßig privat; pub und pub(crate). Ein protected gibt es nicht |
Module |
| Ein JAR / Maven-Artefakt | Ein Crate (eine Bibliothek oder ein Programm) | Cargo und Crates |
Annotationen (@Override, @Data aus Lombok) |
Attribute wie #[derive(Debug)] und #[test] |
Makros |
| Reflection, Verarbeitung von Annotationen zur Laufzeit | Zur Laufzeit praktisch keine. Stattdessen erzeugen Makros Code beim Kompilieren | Prozedurale Makros |
| Methodenüberladung | Keine Überladung. Nutze verschiedene Namen oder einen generischen Parameter | Funktionen |
instanceof-Prüfungen und Downcasts |
match auf ein Enum (Downcasting mit Any gibt es, aber selten) |
Pattern Matching |
| JNI, Foreign Function & Memory API | extern "C"-Funktionen |
FFI |
Collections
Abschnitt betitelt „Collections“Das Java Collections Framework passt sauber auf std::collections. Der größte Unterschied: Collections in Rust geben nie null zurück. Ein Nachschlagen liefert dir eine Option.
| Java | Rust | Mehr dazu |
|---|---|---|
ArrayList<T> |
Vec<T> |
Vektoren |
int[], String[] |
[T; N], wenn die Länge beim Kompilieren bekannt ist, sonst Vec<T>. Beides übergibst du als Slice &[T] |
Arrays, Slices |
List.of(...) (unveränderbar) |
Ein Array oder ein Vec, den du nur als &Vec<T> / &[T] herausgibst (über eine geteilte Referenz kann ihn niemand ändern) |
Borrowing |
HashMap<K, V> |
HashMap<K, V> (get gibt Option<&V> zurück) |
HashMap |
map.merge(word, 1, Integer::sum) |
*map.entry(word).or_insert(0) += 1 |
HashMap |
TreeMap<K, V> |
BTreeMap<K, V> (Schlüssel bleiben sortiert) |
HashMap |
LinkedHashMap<K, V> (Einfügereihenfolge) |
IndexMap aus dem Crate indexmap |
|
HashSet<T>, TreeSet<T> |
HashSet<T>, BTreeSet<T> |
HashSet |
ArrayDeque<T> |
VecDeque<T> |
VecDeque und BinaryHeap |
Stack<T> oder Deque als Stack |
Vec<T> mit push / pop |
Vektoren |
PriorityQueue<T> (kleinstes Element zuerst) |
BinaryHeap<T> (größtes Element zuerst; mit Reverse umschlossen das kleinste zuerst) |
VecDeque und BinaryHeap |
LinkedList<T> |
LinkedList<T> gibt es, aber Vec oder VecDeque ist fast immer schneller |
VecDeque und BinaryHeap |
ConcurrentHashMap<K, V> |
Mutex<HashMap<K, V>> oder das Crate dashmap |
Geteilter Zustand |
list.sort(Comparator.comparing(...)) |
v.sort_by_key(...) oder v.sort_by(...) |
Sortieren und Suchen |
list.removeIf(...) |
v.retain(...) (behält die Elemente, die passen) |
Vektoren |
Wie die HashMap in Java hat auch eine HashMap in Rust keine feste Reihenfolge, und die Reihenfolge kann sich zwischen zwei Läufen ändern. Wenn du sortierte Ausgabe brauchst, nimm eine BTreeMap oder sortiere vorher die Schlüssel.
Werkzeuge: von Maven und Gradle zu Cargo
Abschnitt betitelt „Werkzeuge: von Maven und Gradle zu Cargo“Die meisten Java-Entwickler verlieben sich schnell in Cargo. Es gibt ein Tool, ein Konfigurationsformat und kein Plugin-XML. Builds, Abhängigkeiten, Tests, Doku, Formatierung und Lints kommen alle aus derselben Quelle.
| Java | Rust | Hinweise |
|---|---|---|
| Maven / Gradle | cargo |
Baut, startet, testet, fügt Abhängigkeiten hinzu, veröffentlicht. Siehe Cargo und Crates |
pom.xml / build.gradle.kts |
Cargo.toml |
Kurz, und für normale Projekte sind keine Build-Skripte nötig |
| Dependency Locking in Gradle | Cargo.lock |
Wird für dich erzeugt und aktualisiert |
| Multi-Modul-Build | Ein Cargo-Workspace | Eine Cargo.toml ganz oben listet die zugehörigen Crates auf |
| Maven Central | crates.io | Die API-Doku jedes veröffentlichten Crates steht auf docs.rs |
Eine <dependency> hinzufügen |
cargo add serde |
|
mvn archetype:generate / gradle init |
cargo new my_app (cargo new --lib für eine Bibliothek) |
|
mvn compile / mvn test / mvn package |
cargo build / cargo test / cargo build --release |
Debug-Builds laufen deutlich langsamer; Performance immer im Release-Modus messen |
java -jar app.jar |
./target/release/app |
Eine einzige native Binärdatei. Keine JVM auf dem Server nötig |
| JDK-Versionen, SDKMAN!, Toolchains | rustup und eine Datei rust-toolchain.toml |
Siehe Rust-Werkzeuge und Einrichtung |
javac |
rustc |
Cargo ruft es für dich auf; selbst startest du es selten |
| JShell | Der Rust Playground im Browser oder evcxr_repl |
|
| Checkstyle, SpotBugs, PMD, Error Prone | cargo clippy |
Hunderte offizielle Lints |
| google-java-format, Spotless | cargo fmt |
Ein einheitlicher Stil; niemand streitet über Klammern |
| IntelliJ IDEA, Eclipse | JetBrains RustRover oder VS Code + rust-analyzer | rust-analyzer bietet Vervollständigung, Sprung zur Definition und Fehler direkt im Code |
| Javadoc | ///-Doc-Kommentare in Markdown + cargo doc |
Code-Beispiele in der Doku laufen als Tests. Siehe Dokumentation |
| JUnit 5 | Eingebautes #[test] und cargo test (cargo nextest ist ein beliebter, schnellerer Runner) |
Siehe Testen |
@ParameterizedTest |
rstest |
|
| Mockito | mockall oder handgeschriebene Fakes, die deinen Trait implementieren |
Siehe Testen für Fortgeschrittene |
| AssertJ / Hamcrest | assert!, assert_eq! und pretty_assertions für gut lesbare Diffs |
|
| WireMock, Testcontainers | wiremock, testcontainers |
Dieselben Ideen als Rust-Versionen |
| JaCoCo | cargo llvm-cov oder cargo tarpaulin |
|
| JMH | criterion |
Siehe Performance |
| JFR, async-profiler, VisualVM | cargo flamegraph (installiert mit cargo install flamegraph), perf |
Kein GC und kein JIT, die man tunen müsste |
| GraalVM native-image | Nicht nötig | Jeder Rust-Build ist schon nativer Code |
Hier ist eine kleine Cargo.toml. Vergleich sie mit der pom.xml, die du für dieselben drei Abhängigkeiten bräuchtest:
[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }Bibliotheken und Frameworks: „Was nehmen wir statt …?“
Abschnitt betitelt „Bibliotheken und Frameworks: „Was nehmen wir statt …?““Das ist die Frage, die jedes Java-Team zuerst stellt: „Wir nutzen Spring Boot und Hibernate. Was gibt es dafür in Rust?“ Rust hat kein Spring, also kein einzelnes Framework, das alles mitbringt. Stattdessen kombinierst du ein paar spezialisierte Crates. Hier sind die nächsten Entsprechungen.
Web und Netzwerk
Abschnitt betitelt „Web und Netzwerk“| Java | Rust | Hinweise |
|---|---|---|
| Spring Boot (Spring MVC / WebFlux) | axum (wir bauen eine API in Projekt: Web-API mit Axum), actix-web, Rocket | axum und actix-web sind in Produktion weit verbreitet |
@RestController + @GetMapping |
axum-Routen, die auf einfache async fn-Handler zeigen |
Keine Annotationen und kein Classpath-Scanning; du verdrahtest die Routen im Code |
| Servlet-Filter, Spring-Interceptors | tower-Layer und das Crate tower-http (CORS, Kompression, Tracing, Timeouts) |
axum nutzt tower direkt |
Dependency Injection in Spring (@Autowired) |
Geteilter State in axum, einfache Konstruktoren und Traits |
Siehe die DI-Zeile weiter unten |
| springdoc-openapi | utoipa |
|
| Thymeleaf | askama (Templates, die beim Kompilieren geprüft werden) oder tera |
|
| Spring WebSocket | WebSockets in axum (das Feature ws) oder tokio-tungstenite |
|
| Spring for GraphQL | async-graphql |
|
| gRPC Java | tonic (mit prost für Protobuf) |
|
java.net.http.HttpClient, OkHttp, WebClient |
reqwest |
Standardmäßig async, mit optionalem blockierendem Client |
| Netty | tokio + hyper |
|
| Spring Security (JWT, Passwort-Hashing) | jsonwebtoken, argon2 |
Kein Security-Framework, das alles abdeckt |
Daten und Messaging
Abschnitt betitelt „Daten und Messaging“| Java | Rust | Hinweise |
|---|---|---|
| Hibernate / JPA / Spring Data JPA | SeaORM: asynchrones ORM mit Entities, Relationen und Migrationen | Fühlt sich am ehesten wie JPA an; baut auf sqlx auf |
| Hibernate, aber mit Fokus auf strenge Typen | Diesel: ORM und Query Builder; deine Abfragen werden beim Kompilieren typgeprüft | Ausgereift (2.x), standardmäßig synchron (diesel-async gibt es) |
JDBC, JdbcTemplate, jOOQ, MyBatis |
sqlx: einfaches SQL, und das Makro query! prüft es beim Kompilieren gegen deine echte Datenbank |
Async, sehr beliebt, kein ORM |
| Flyway / Liquibase | sqlx migrate (sqlx-cli), diesel migration oder refinery |
|
| Jedis / Lettuce (Redis) | redis |
|
| Kafka-Clients, Spring Kafka | rdkafka (baut auf librdkafka auf) |
|
| RabbitMQ Java Client | lapin |
|
| AWS SDK for Java | Das offizielle AWS SDK for Rust: aws-config, aws-sdk-s3, … |
Wenn du JPA-Entities magst, probier zuerst SeaORM. Wenn du lieber SQL schreibst (die jOOQ- und MyBatis-Fraktion), probier sqlx. Wenn du die stärksten Garantien beim Kompilieren willst, probier Diesel. Datenbanken in Rust zeigt alle drei mit funktionierendem Code.
Bausteine für den Alltag
Abschnitt betitelt „Bausteine für den Alltag“| Java | Rust | Hinweise |
|---|---|---|
| Jackson / Gson | serde + serde_json |
Der Standard; kann auch TOML, YAML und mehr. Siehe JSON mit Serde |
| JAXB, Jackson XML | quick-xml (mit dem Feature serialize für serde) |
|
| SLF4J + Logback / Log4j 2 | tracing (+ tracing-subscriber) oder log + env_logger |
Siehe Logging |
| Lombok | Eingebaute Derives (Debug, Clone, PartialEq, Default, …); bon oder derive_builder für Builder |
Keine Bytecode-Tricks; alles ganz normale Makros |
| MapStruct | From- / Into-Implementierungen |
Von Hand geschrieben, vom Compiler geprüft. Siehe Konvertierungen |
| IoC-Container von Spring | Meist gar nichts: Abhängigkeiten an Konstruktoren übergeben, Traits für alles, was du in Tests austauschen willst | Rust-Projekte nutzen selten einen DI-Container |
Bean Validation (@NotNull, @Size) |
validator oder garde (Validierung per derive) |
@NotNull brauchst du nicht: Nutze Option nur dort, wo ein Wert fehlen darf |
| Resilience4j | backon oder die Retry- und Timeout-Middleware von tower |
|
| Caffeine (Caching) | moka |
|
| Guava, Apache Commons | Meist die Standardbibliothek, dazu itertools |
|
java.time |
chrono oder jiff |
Siehe Datum und Uhrzeit |
| picocli | clap |
Siehe Projekt: CLI-Tool mit clap |
@Scheduled, Quartz |
tokio-cron-scheduler |
|
| Micrometer | metrics oder opentelemetry |
|
| Jakarta Mail | lettre |
Desktop, Spiele, Android und Interop
Abschnitt betitelt „Desktop, Spiele, Android und Interop“| Java | Rust | Hinweise |
|---|---|---|
| JavaFX / Swing | Tauri (Web-UI + Rust-Backend), Slint, egui, Iced |
Tauri 2 und Slint 1.x sind am stabilsten. Siehe GUI-Apps |
| LibGDX, jMonkeyEngine | Bevy, macroquad |
Bevy ist noch vor 1.0 und ändert seine API zwischen Releases. Siehe Spieleentwicklung |
| Code für Android-Apps | Behalte Kotlin oder Java für die UI; pack gemeinsame Logik in eine Rust-Bibliothek, gebaut mit dem Android NDK | uniffi von Mozilla erzeugt Kotlin-Bindings |
| Nativen Code aus Java aufrufen | extern "C"-Funktionen bereitstellen und über die FFM API (ab JDK 22) aufrufen, oder JNI-Funktionen mit dem Crate jni schreiben |
Siehe FFI |
Im direkten Vergleich: aus einer abstrakten Klasse wird ein Trait
Abschnitt betitelt „Im direkten Vergleich: aus einer abstrakten Klasse wird ein Trait“Hier ist eine klassische Java-Hierarchie: eine abstrakte Basisklasse mit einer abstrakten und einer gemeinsamen Methode.
abstract class Employee { protected final String name; Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " verdient " + monthlyPay() + " im Monat"; }}
class Salaried extends Employee { private final int yearly; Salaried(String name, int yearly) { super(name); this.yearly = yearly; } @Override int monthlyPay() { return yearly / 12; }}
class Contractor extends Employee { private final int hours, rate; Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; } @Override int monthlyPay() { return hours * rate; }}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));for (Employee e : staff) System.out.println(e.payslip());Rust hat kein extends. Die gemeinsame Methode wandert als Standardmethode in einen Trait, und jedes Struct behält seine eigenen Daten:
trait Employee { fn name(&self) -> &str; fn monthly_pay(&self) -> u32;
// Eine Standardmethode: wie das konkrete payslip() in der abstrakten Klasse. fn payslip(&self) -> String { format!("{} verdient {} im Monat", self.name(), self.monthly_pay()) }}
struct Salaried { name: String, yearly: u32,}
struct Contractor { name: String, hours: u32, rate: u32,}
impl Employee for Salaried { fn name(&self) -> &str { &self.name } fn monthly_pay(&self) -> u32 { self.yearly / 12 }}
impl Employee for Contractor { fn name(&self) -> &str { &self.name } fn monthly_pay(&self) -> u32 { self.hours * self.rate }}
fn main() { // Box<dyn Employee> spielt die Rolle von „eine Referenz auf irgendeinen Employee“. let staff: Vec<Box<dyn Employee>> = vec![ Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }), Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }), ];
for e in &staff { println!("{}", e.payslip()); }}Ausgabe:
Ada verdient 5000 im MonatLinus verdient 6000 im MonatEin Trait kann keine Felder enthalten. Deshalb wird aus dem protected name eine kleine Methode name(), die jedes Struct implementiert. Das fühlt sich anfangs nach Mehrarbeit an, beseitigt aber eine ganze Klasse von Problemen: keine fragilen Basisklassen, keine vergessenen super-Aufrufe und keine tiefen Hierarchien, durch die du scrollen musst. Wenn du die default-Methoden an Interfaces aus Java 8 kennst, weißt du schon, wie Rust Verhalten teilt. Und wenn die Menge der Typen feststeht (wie bei einem sealed interface in Java 17), ist ein Enum mit match oft einfacher als ein Trait.
Im direkten Vergleich: aus Optional wird Option
Abschnitt betitelt „Im direkten Vergleich: aus Optional wird Option“Optional war in Java ein großer Schritt nach vorn. Es wird aber nur für Rückgabewerte genutzt, und map.get() oder jedes beliebige Feld kann trotzdem null sein:
static Optional<String> findEmail(int userId) {}
for (int id : new int[] {1, 2}) { System.out.println(findEmail(id).map(String::toUpperCase).orElse("keine E-Mail hinterlegt"));}findEmail(1).ifPresent(email -> System.out.println("sende an " + email));Option in Rust hat fast dieselbe API, wird aber überall genutzt, wo ein Wert fehlen könnte, auch beim Nachschlagen in Collections. Und dahinter versteckt sich kein null:
fn find_email(user_id: u32) -> Option<String> { if user_id == 1 { } else { None }}
fn main() { for id in [1, 2] { // map + orElseGet let shown = find_email(id) .map(|email| email.to_uppercase()) .unwrap_or_else(|| "keine E-Mail hinterlegt".to_string()); println!("{shown}"); }
// ifPresent if let Some(email) = find_email(1) { println!("sende an {email}"); }
// flatMap heißt and_then let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string())); println!("{domain:?}");}Ausgabe:
keine E-Mail hinterlegtsende an [email protected]Some("example.com")Der Compiler lässt dich .to_uppercase() erst auf einer Option<String> aufrufen, wenn du dich um None gekümmert hast. Eine NullPointerException gibt es deshalb einfach nicht. Option kostet außerdem nichts extra: Eine Option<&T> ist genauso groß wie eine einfache Referenz. Die Hilfsmethoden lernst du auf der Seite Option kennen.
Im direkten Vergleich: aus Checked Exceptions wird Result
Abschnitt betitelt „Im direkten Vergleich: aus Checked Exceptions wird Result“Hinter Checked Exceptions steckte eine gute Idee: Die Signatur sagt dir, was schiefgehen kann. Hier ist eine typische Java-Version:
class InsufficientFundsException extends Exception { InsufficientFundsException(int needed) { super("es fehlen noch " + needed); }}
static int withdraw(int balance, int amount) throws InsufficientFundsException { if (amount > balance) throw new InsufficientFundsException(amount - balance); return balance - amount;}
static int pay(int balance, int amount) throws InsufficientFundsException { int left = withdraw(balance, amount); System.out.println(amount + " bezahlt, " + left + " übrig"); return left;}
try { pay(100, 80); pay(50, 80); }catch (InsufficientFundsException e) { System.out.println("Fehler: " + e.getMessage()); }Rust behält diese gute Idee und wirft den Rest weg. Der Fehler ist Teil des Rückgabetyps (Result<u32, BankError>), und ? tut, was throws tut: „Wenn das fehlgeschlagen ist, reich den Fehler an meinen Aufrufer weiter.“ Fehler sind meist ein enum, sodass ein einziger Typ jede Art auflisten kann, auf die eine Operation fehlschlagen kann:
use std::fmt;
#[derive(Debug)]enum BankError { InvalidAmount, InsufficientFunds { needed: u32 },}
// Display übernimmt die Rolle von getMessage().impl fmt::Display for BankError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { BankError::InvalidAmount => write!(f, "der Betrag muss größer als null sein"), BankError::InsufficientFunds { needed } => write!(f, "es fehlen noch {needed}"), } }}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> { if amount == 0 { return Err(BankError::InvalidAmount); } if amount > balance { return Err(BankError::InsufficientFunds { needed: amount - balance }); } Ok(balance - amount)}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> { let left = withdraw(balance, amount)?; // wie der Aufruf einer Methode, die „wirft“ println!("{amount} bezahlt, {left} übrig"); Ok(left)}
fn main() { for (balance, amount) in [(100, 80), (50, 80), (100, 0)] { if let Err(e) = pay(balance, amount) { println!("Fehler: {e}"); } }}Ausgabe:
80 bezahlt, 20 übrigFehler: es fehlen noch 30Fehler: der Betrag muss größer als null seinAchte darauf, was alles fehlt: keine try-Blöcke, kein Stack Unwinding, keine Exceptions, die in eine RuntimeException verpackt werden, nur damit sie durch ein Lambda kommen. Fehler sind ganz normale Werte und funktionieren deshalb problemlos in Closures und Iterator-Ketten. Das ? markiert sichtbar jede Stelle, an der die Funktion vorzeitig zurückkehren kann. Display von Hand zu schreiben wird schnell lästig. Das Crate thiserror erzeugt es für dich, und anyhow gibt Anwendungen einen Fehlertyp für alles (siehe anyhow und thiserror). Für echte Bugs, die du in Java mit einer Unchecked Exception melden würdest, hat Rust panic!.
Im direkten Vergleich: aus Streams werden Iteratoren
Abschnitt betitelt „Im direkten Vergleich: aus Streams werden Iteratoren“Eine Stream-Pipeline mit einem groupingBy-Collector:
record Order(String customer, String city, int total) {}
List<Order> orders = List.of( new Order("Ada", "London", 120), new Order("Grace", "New York", 80), new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream() .filter(o -> o.total() > 100) .sorted(Comparator.comparingInt(Order::total).reversed()) .map(Order::customer) .toList();System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream() .collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));In Rust übernimmt .iter() die Rolle von .stream(), und die Adapter haben vertraute Namen:
use std::cmp::Reverse;use std::collections::BTreeMap;
struct Order { customer: String, city: String, total: u32,}
fn order(customer: &str, city: &str, total: u32) -> Order { Order { customer: customer.to_string(), city: city.to_string(), total }}
fn main() { let orders = vec![ order("Ada", "London", 120), order("Grace", "New York", 80), order("Linus", "Helsinki", 200), order("Ken", "New York", 150), ];
// filter + toList let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect(); // sorted(...reversed()): Sortiert wird auf einem Vec, nicht im Iterator. big.sort_by_key(|o| Reverse(o.total)); // map let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect(); println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total)) let mut by_city: BTreeMap<&str, u32> = BTreeMap::new(); for o in &orders { *by_city.entry(o.city.as_str()).or_insert(0) += o.total; } for (city, sum) in &by_city { println!("{city}: {sum}"); }}Ausgabe:
Linus, Ken, AdaHelsinki: 200London: 120New York: 230Wie Streams sind Iteratoren in Rust lazy (sie arbeiten erst bei Bedarf) und laufen erst, wenn etwas sie verbraucht (collect, sum, eine for-Schleife). Anders als bei Streams gibt es keine Klasse Collectors: collect() schaut auf den Typ, den du anforderst (Vec, HashMap, String, …), und baut ihn. Zum Gruppieren ist eine einfache Schleife mit der entry-API die übliche Antwort; das Crate itertools ergänzt Helfer, falls dir groupingBy fehlt. Und Iterator-Ketten werden zu demselben Maschinencode kompiliert wie eine handgeschriebene Schleife, ohne dass unterwegs ints geboxt werden. Mehr dazu unter Iteratoren und Sortieren und Suchen.
Im direkten Vergleich: Type Erasure vs. Monomorphisierung
Abschnitt betitelt „Im direkten Vergleich: Type Erasure vs. Monomorphisierung“Generics in Java gibt es nur beim Kompilieren. Zur Laufzeit ist T gelöscht (Type Erasure). Deshalb kannst du nicht new T() schreiben, und du kannst kein int ohne Boxing in eine List stecken:
static <T> List<T> filled(int n) { List<T> list = new ArrayList<>(); for (int i = 0; i < n; i++) list.add(new T()); // kompiliert nicht: T ist gelöscht return list;}
List<int> ids; // kompiliert auch nicht: du brauchst List<Integer>Rust kompiliert für jeden Typ, mit dem du eine generische Funktion benutzt, eine eigene Kopie davon (das nennt sich Monomorphisierung). Die Funktion weiß also genau, was T ist, und kann T::default() aufrufen:
use std::mem::size_of;
// „T muss einen Standardwert haben und klonbar sein“fn filled<T: Default + Clone>(n: usize) -> Vec<T> { vec![T::default(); n]}
fn main() { let ids: Vec<i32> = filled(3); let names: Vec<String> = filled(2); let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// Kein Boxing: Ein Vec<i32> speichert einfache 4-Byte-Zahlen direkt nebeneinander. println!("ein i32 belegt {} Bytes", size_of::<i32>()); println!("ein bool belegt {} Byte", size_of::<bool>());}Ausgabe:
[0, 0, 0] ["", ""] [false, false]ein i32 belegt 4 Bytesein bool belegt 1 ByteDer Kompromiss: Mehr Kopien des Codes bedeuten größere Binärdateien und längere Kompilierzeiten, dafür ist jeder Aufruf direkt und kann geinlinet werden. Wenn du eine gemeinsame Kopie willst (wie in Java), nimm ein Trait-Objekt wie Box<dyn Trait>. Die Bounds nach dem Doppelpunkt (T: Default + Clone) sind die Rust-Version von <T extends ...>. Siehe Generics und Fortgeschrittene Generics.
Im direkten Vergleich: aus synchronized werden Arc und Mutex
Abschnitt betitelt „Im direkten Vergleich: aus synchronized werden Arc und Mutex“Vier Threads erhöhen einen gemeinsamen Zähler. In Java hängt der Lock am Objekt, und du musst selbst an synchronized denken:
class Counter { private int count = 0; synchronized void increment() { count++; } synchronized int get() { return count; }}
Counter counter = new Counter();List<Thread> threads = new ArrayList<>();for (int i = 0; i < 4; i++) { Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); }); t.start(); threads.add(t);}for (Thread t : threads) t.join();System.out.println("Zähler = " + counter.get());In Rust besitzt der Mutex die Zahl. Es gibt keinen Weg an sie heran, ohne vorher zu sperren. Arc (ein atomar referenzgezählter Pointer) lässt mehrere Threads den Mutex teilen:
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { // Jeder Thread bekommt sein eigenes Handle auf denselben Mutex. let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { // lock() gibt einen Guard zurück; der Lock wird freigegeben, wenn der Guard gedroppt wird. *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("Zähler = {}", *counter.lock().unwrap());}Ausgabe:
Zähler = 4000Das ist der Teil, den Java-Entwickler lieben: Wenn du in Java synchronized vergisst, kompiliert der Code und liefert ab und zu falsche Zahlen. Wenn du in Rust versuchst, ein einfaches i32 oder einen Vec zwischen Threads zu teilen und zu verändern, kompiliert das Programm nicht. Data Races werden vom Typsystem abgefangen (über die Traits Send und Sync). Für einen einzelnen Zähler wäre ein AtomicUsize leichter als ein Mutex, genau wie AtomicInteger. Siehe Nebenläufigkeit und Geteilter Zustand.
Stolpersteine für Java-Entwickler
Abschnitt betitelt „Stolpersteine für Java-Entwickler“Alle Java-Entwickler stolpern über dieselben Dinge. Wenn du sie vorher kennst, werden sie kleiner.
-
Einen Wert an eine Funktion zu übergeben, verschiebt ihn. Wenn du in Java ein Objekt übergibst, übergibst du eine Referenz, und du kannst deine Variable weiter benutzen. Wenn du in Rust einen
StringoderVecals Wert übergibst, wird er in die Funktion verschoben (ein Move):fn greet(name: String) {println!("Hallo, {name}!");}fn main() {let name = String::from("Ada");greet(name);greet(name);}error[E0382]: use of moved value: `name`--> src/main.rs:8:11|6 | let name = String::from("Ada");| ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait7 | greet(name);| ---- value moved here8 | greet(name);| ^^^^ value used here after moveDer Compiler sagt auf Englisch:
namewird benutzt, nachdem sein Wert schon verschoben wurde. Die übliche Lösung ist, die Funktion ausleihen zu lassen:fn greet(name: &str)und der Aufrufgreet(&name). Lies zuerst Ownership; dort wird diese Regel erklärt, und E0382 behandelt genau diesen Fehler im Detail. -
Der Borrow Checker. Du kannst viele Leser (
&T) oder genau einen Schreiber (&mut T) gleichzeitig haben, nie beides. Objektgraphen, die in Java ganz normal sind, etwa ein Kind, das auf sein Elternobjekt zurückzeigt, oder viele Objekte, die ein gemeinsames Objekt halten und verändern, brauchen Smart Pointer oder ein anderes Design. Die ersten Wochen können frustrierend sein. Es wird wirklich leichter. -
Keine Vererbung, kein
protected, keinsuper. Du wirst versucht sein, Klassenhierarchien mit Traits nachzubauen. Probier zuerst Enums und Komposition; die passen meist besser. -
==vergleicht Werte. Ausnahmsweise eine gute Nachricht:a == bvergleicht bei zweiStrings den Text, der klassische Bug==vs..equals()kann also nicht passieren. Bei deinen eigenen Structs funktioniert==erst, wenn duPartialEqper derive ableitest oder implementierst. -
BinaryHeapgibt das größte Element zuerst heraus. DiePriorityQueuein Java liefert das kleinste zuerst. In Rust umschließt du die Elemente mitstd::cmp::Reverse, um das Verhalten eines Min-Heaps zu bekommen. Siehe VecDeque und BinaryHeap. -
Integer-Überlauf passiert nicht still.
Integer.MAX_VALUE + 1läuft in Java leise über. In einem Debug-Build von Rust gibt es eine Panic. Sag mitchecked_add,wrapping_addodersaturating_add, was du meinst. Siehe Integer-Überlauf. -
Stringvs.&str, und keincharAt. Strings in Rust sind UTF-8, deshalb kompilierts[0]nicht. Mits.chars()gehst du Zeichen für Zeichen durch. Funktionen nehmen meist&strentgegen, Structs speichern meistString. Siehe Strings. -
Keine Methodenüberladung. Du kannst keine zwei
print-Methoden mit verschiedenen Parametern haben. Nutze verschiedene Namen (print,print_with_color) oder einen generischen Parameter. -
Keine Laufzeit-Magie. Kein Classpath-Scanning, kein
@Autowired, keine Proxys, die Transaktionen um deine Methoden legen. Alles wird explizit im Code verdrahtet, und Makros laufen beim Kompilieren. Das ist mehr Tipparbeit, aber du siehst immer, was passiert. -
Kein
nullund keine Exceptions. Am Anfang fühlen sichOptionundResultüberall nach Mehrarbeit an. Nach ein paar Wochen merkst du, dass du keineNullPointerExceptionund keinen überraschenden Stack Trace mehr gesehen hast..unwrap()aufzurufen ist beim Lernen in Ordnung. In echtem Code ist es aber die Rust-Version von „dann stürzt es halt ab“. -
Kompilierzeiten. Ein kompletter Build lädt alle Abhängigkeiten und kompiliert sie aus dem Quellcode. Das dauert länger als
javac. Inkrementelle Builds sind viel schneller, und beim Bearbeiten nutzt du am häufigstencargo check(das die Codeerzeugung überspringt). Die gute Nachricht am anderen Ende: Das Programm startet sofort, ganz ohne JVM-Aufwärmphase. -
Async braucht eine Runtime. Es gibt keine virtuellen Threads. Eine
async fntut nichts, bis ein Executor sie ausführt. Deshalb fügen Webprojekte tokio hinzu und schreiben#[tokio::main]übermain. Siehe Async/Await.
Ein Lernpfad für Java-Entwickler
Abschnitt betitelt „Ein Lernpfad für Java-Entwickler“Du musst diese Seite nicht der Reihe nach lesen. Auf diesem Weg überspringst du, was Java dir schon beigebracht hat, und nimmst dir Zeit für das, was in Rust anders ist:
- Erste Schritte mit Rust und Rust-Werkzeuge und Einrichtung: Rust installieren, einen Editor wählen,
cargo newausführen. - Variablen und Datentypen: nur überfliegen, vor allem für
let mutund die Zahlentypen. - Ownership, dann Borrowing, dann Slices: die wichtigsten Seiten für dich. Lass dir Zeit.
- Strings:
Stringvs.&str. - Structs und Enums, dann Match und Pattern Matching.
- Option und Result, dann Eigene Fehlertypen und anyhow und thiserror.
- Traits, Generics, Wichtige Traits und Trait-Objekte: deine Interfaces und abstrakten Klassen.
- Closures und Iteratoren: deine Lambdas und Streams.
- Vektoren, HashMap und VecDeque und BinaryHeap.
- Module, Cargo und Crates und Testen.
- Lifetimes und Smart Pointer.
- Nebenläufigkeit, Geteilter Zustand, Channels und Async/Await.
- JSON mit Serde, Logging und Datenbanken in Rust, danach baust du das Web-API-Projekt.
- Du willst eine bestehende Java-Anwendung beschleunigen? Lies FFI, um zu sehen, wie Rust
extern "C"-Funktionen bereitstellt.
Übungen
Abschnitt betitelt „Übungen“Beide Übungen geben dir etwas Java-Code. Portiere ihn nach Rust.
1. Einen Record und eine Stream-Pipeline portieren
Abschnitt betitelt „1. Einen Record und eine Stream-Pipeline portieren“Dieser Code gibt aus, welche Studierenden bestanden haben, ob jemand die volle Punktzahl erreicht hat und welches das beste Ergebnis ist. Tipp: Iteratoren haben any (wie anyMatch) und max (das eine Option zurückgibt, wie OptionalInt). Für Collectors.joining sammelst du in einen Vec<&str> und rufst .join(", ") auf.
record Student(String name, int score) {}
List<Student> students = List.of( new Student("Ada", 91), new Student("Brian", 42), new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream() .filter(s -> s.score() >= 50) .map(Student::name) .collect(Collectors.joining(", "));boolean perfect = students.stream().anyMatch(s -> s.score() == 100);int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("bestanden: " + passed);System.out.println("volle Punktzahl: " + perfect);System.out.println("Bestwert: " + best);Lösung anzeigen
#[derive(Debug, Clone, PartialEq)]struct Student { name: String, score: u32,}
fn student(name: &str, score: u32) -> Student { Student { name: name.to_string(), score }}
fn main() { let students = vec![ student("Ada", 91), student("Brian", 42), student("Grace", 100), student("Ken", 67), ];
let passed: Vec<&str> = students .iter() .filter(|s| s.score >= 50) .map(|s| s.name.as_str()) .collect(); let perfect = students.iter().any(|s| s.score == 100); let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("bestanden: {}", passed.join(", ")); println!("volle Punktzahl: {perfect}"); println!("Bestwert: {best}");}Ausgabe:
bestanden: Ada, Grace, Kenvolle Punktzahl: trueBestwert: 100Die Derives geben dir das, was ein Java-record gratis mitbringt: eine Debug-Darstellung ({:?}), Kopieren (.clone()) und Wertgleichheit (==). Die Hilfsfunktion student übernimmt die Rolle des Record-Konstruktors. Anders als einen Java-Stream kannst du students.iter() so oft aufrufen, wie du willst, weil jeder Aufruf einen neuen Iterator über denselben Vec erzeugt.
2. Eine Checked Exception mit Ursache portieren
Abschnitt betitelt „2. Eine Checked Exception mit Ursache portieren“Diese Methode liest eine Zeile der Form port=<Zahl> aus einer Konfigurationsdatei. Sie wirft eine Checked Exception ConfigException und verpackt darin die NumberFormatException, die sie von Integer.parseInt bekommt. Portiere sie so, dass read_port ein Result<u16, String> zurückgibt. Tipp: line.split_once('=') gibt eine Option<(&str, &str)> zurück. Mach daraus mit .ok_or_else(...) ein Result. .parse::<u16>() gibt ein Result zurück, und .map_err(...) tauscht dessen Fehler gegen deine eigene Meldung aus – genau so, wie man eine Exception in eine andere verpackt.
class ConfigException extends Exception { ConfigException(String message) { super(message); }}
static int readPort(String line) throws ConfigException { String[] parts = line.split("=", 2); if (parts.length != 2 || !parts[0].trim().equals("port")) throw new ConfigException("erwartet: port=<Zahl>, erhalten: '" + line + "'"); try { return Integer.parseInt(parts[1].trim()); } catch (NumberFormatException e) { throw new ConfigException("ungültige Portnummer: " + parts[1].trim()); }}
for (String line : List.of("port = 8080", "port=achtzig", "host=localhost")) { try { System.out.println("Port ist " + readPort(line)); } catch (ConfigException e) { System.out.println("Konfigurationsfehler: " + e.getMessage()); }}Lösung anzeigen
fn read_port(line: &str) -> Result<u16, String> { let expected = || format!("erwartet: port=<Zahl>, erhalten: '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?; if key.trim() != "port" { return Err(expected()); }
let value = value.trim(); value .parse::<u16>() .map_err(|_| format!("ungültige Portnummer: {value}"))}
fn main() { for line in ["port = 8080", "port=achtzig", "host=localhost"] { match read_port(line) { Ok(port) => println!("Port ist {port}"), Err(e) => println!("Konfigurationsfehler: {e}"), } }}Ausgabe:
Port ist 8080Konfigurationsfehler: ungültige Portnummer: achtzigKonfigurationsfehler: erwartet: port=<Zahl>, erhalten: 'host=localhost'Aus der Klausel throws ConfigException wurde der Rückgabetyp Result, aus try/catch um parseInt wurde ein einziges map_err, und aus throw wurde Err(...). Dass der Port ein u16 ist, ist ein kleiner Bonus: Eine Zahl über 65535 lehnt schon parse selbst ab, was ein int in Java nicht tun würde. Die kleine Closure expected baut die Fehlermeldung nur, wenn sie gebraucht wird, wie ein Supplier in orElseThrow(() -> ...).
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Vieles aus Java nimmst du mit: Generics, Interfaces (als Traits), Lambdas (als Closures) und Stream-Pipelines (als Iteratoren).
- Die großen Umstellungen sind Ownership statt Garbage Collector,
Optionstattnull,Resultund?statt Exceptions sowie Traits plus Komposition statt Vererbung. - Generics werden für jeden Typ einzeln kompiliert (Monomorphisierung). Es gibt also keine Type Erasure und kein Boxing von Zahlen.
- Daten, die sich Threads teilen, stecken in einem
Mutex(umschlossen von einemArc), und ein vergessener Lock ist ein Compilerfehler, kein Race. - Cargo ersetzt Maven oder Gradle; clippy und rustfmt ersetzen Checkstyle, SpotBugs und Formatter; Tests sind eingebaut.
- Statt Spring Boot schau dir axum oder actix-web an, statt JPA SeaORM oder Diesel, statt JDBC und jOOQ sqlx, und statt Jackson nimmst du serde.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Eine abstrakte Java-Klasse hat eine abstrakte Methode und eine konkrete Methode, die sie aufruft. Was ist der übliche Ersatz in Rust?
2. Die PriorityQueue in Java gibt das kleinste Element zuerst zurück. Was gibt BinaryHeap::pop in Rust zurück?
3. Warum kann eine generische Funktion in Rust T::default() aufrufen, während man in Java nicht new T() schreiben kann?
4. Du teilst einen einfachen Vec zwischen Threads und versuchst, ohne Mutex etwas hinzuzufügen. Was passiert in Rust?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Die Programmiersprache Rust – Das Rust-Buch: die deutsche Übersetzung des offiziellen Buchs. Für Java-Entwickler sind die Kapitel 4 (Ownership), 9 (Fehlerbehandlung), 10 (Generics und Traits) und 16 (Nebenläufigkeit) am wichtigsten.
- std::collections (auf Englisch): der eigene Leitfaden der Standardbibliothek zur Wahl zwischen
Vec,VecDeque,HashMap,BTreeMapund den anderen. - JEP 454: Foreign Function & Memory API (auf Englisch): das Feature aus JDK 22, mit dem du native Bibliotheken – auch Rust-Bibliotheken – aus Java aufrufst, ohne JNI-Code zu schreiben.
- Das Crate jni auf docs.rs (auf Englisch): Rust-Bindings für JNI, falls du Rust aus älteren JDKs aufrufen oder zurück nach Java rufen musst.
- Are We Web Yet? (auf Englisch): eine Übersicht aus der Community über die Web-Frameworks, Datenbankbibliotheken und mehr in Rust – falls du deinen Ersatz für den Spring-Boot-Stack suchst.