Tastenkombinationen

Drücken Sie oder , um zwischen den Kapiteln zu navigieren

Drücken Sie S oder /, um im Buch zu suchen

Drücken Sie ?, um diese Hilfe anzuzeigen

Drücken Sie Esc, um diese Hilfe auszublenden

Kenny Kerr ist Softwareentwickler bei Microsoft, wo er an C++- und Rust-Tools und -Bibliotheken für das Windows-Betriebssystem arbeitet. Er ist der Erfinder von C++/WinRT und Rust for Windows. Kenny schrieb auch wiederkehrende Kolumnen und Feature-Artikel für das C/C++ Users Journal, Visual Studio Magazine und MSDN Magazine, ursprünglich Microsoft Systems Journal genannt.

Erste Schritte mit Rust

Kennys Kurse auf Pluralsight

Kenny auf GitHub

Kenny auf YouTube

Kenny auf LinkedIn

Kennys alter Blog auf WordPress

Kennys alter Blog auf asp.net

Erste Schritte mit Rust

Das Projekt windows-rs ist schon seit einiger Zeit verfügbar und obwohl noch viel Arbeit vor mir liegt, dachte ich, ich sollte anfangen, Zeit damit zu verbringen, über Rust für Windows zu schreiben und nicht nur Rust für Windows zu bauen. 😊 Wie ich es für C++/WinRT getan habe, dachte ich, ich würde anfangen, einige kurze "Anleitung" oder "Wie es funktioniert"-Artikel zu schreiben, um Entwicklern zu helfen, einige der Grundlagen des windows-rs-Projekts zu verstehen.

Einige dieser Themen werden für Rust-Entwickler offensichtlich sein, aber vielleicht nicht für den Windows-Entwickler, der neu in Rust ist. Andere Themen sind vielleicht für Windows-Entwickler offensichtlich, aber weniger für den erfahrenen Rust-Entwickler, der neu in Windows ist. So oder so, ich hoffe, Sie finden es nützlich. Fühlen Sie sich frei, ein Issue im Repo zu öffnen, wenn Sie Fragen haben.

Auswahl zwischen den Crates windows und windows-sys

Der windows-Crate bietet Bindungen für die Windows-API, einschließlich C-Stil-APIs wie CreateThreadpool sowie COM- und WinRT-APIs wie DirectX. Dieser Crate bietet die umfassendste API-Abdeckung für das Windows-Betriebssystem. Wo immer möglich, versucht der windows-Crate auch, ein idiomatischeres und sichereres Programmiermodell für Rust-Entwickler bereitzustellen.

Der windows-sys-Crate bietet rohe Bindungen für die Windows-APIs im C-Stil. Ihm fehlt die Unterstützung für COM- und WinRT-APIs. Der windows-sys-Crate entstand aus der Erkenntnis, dass der teuerste Aspekt des windows-Crates in Bezug auf die Kompilierzeit die Kosten für das Kompilieren von Funktionskörpern sind. Der Rust-Compiler investiert viel Mühe in das Kompilieren von Funktionskörpern, daher ist eine Version des windows-Crates, die nur Deklarationen enthält, im Vergleich dazu sowohl kleiner als auch schneller. Das Problem ist, dass COM-Stil-virtuelle Funktionsaufrufe zusätzlichen Code-Gen in Rust erfordern (im Gegensatz zu C++) und dies wiederum zu langsameren Kompilierzeiten führt. Hier kommt der windows-sys-Crate ins Spiel.

Natürlich arbeiten wir weiterhin hart daran, die Leistung sowohl in Bezug auf die zugrunde liegende Rust-Compiler-Toolchain als auch auf die Effizienz des für diese Crates generierten Codes zu verbessern. Wir sind daher zuversichtlich, dass die Kompilierzeit weiter verbessert wird.

Was brauchen Sie?windowswindows-sys
Schnelle Kompilierzeiten sind eines Ihrer wichtigsten Anliegen
Sie benötigen no_std-Unterstützung
Sie benötigen COM- oder WinRT-Unterstützung
Sie würden es vorziehen, APIs zu verwenden, die sich für Rust idiomatisch anfühlen
Minimale unterstützte Rust-Version1.561.56

Wie werden diese Crates erstellt?

Die Crates windows und windows-sys werden aus Metadaten generiert, die die Windows-API beschreiben. Ursprünglich enthielten nur WinRT-APIs Metadaten, aber jetzt werden Metadaten auch für ältere C- und COM-APIs bereitgestellt. Das Projekt win32metadata stellt die Werkzeuge zur Erzeugung der Metadaten bereit, und die Crates windows-metadata und windows-bindgen werden verwendet, um die Metadaten zu lesen und die Crates windows und windows-sys zu generieren. Die Bindungen werden basierend auf den unterschiedlichen Zielen der jeweiligen Crates unterschiedlich generiert. Sie können die genauen Metadatendateien, die zur Generierung einer bestimmten Version der Crates windows und windows-sys verwendet werden, hier finden.

Wie finde ich eine bestimmte API?

Wählen Sie zuerst den Crate aus, den Sie verwenden möchten. Suchen Sie dann in der Dokumentation für den ausgewählten Crate.

Beachten Sie, dass die Dokumentation einen Hinweis enthält, welche Features aktiviert werden müssen, um auf eine bestimmte API zugreifen zu können.

Welche APIs sind enthalten?

Alle Windows-APIs, die vom Windows SDK bereitgestellt werden, sind enthalten, mit wenigen Ausnahmen. Die Definitionen dieser APIs werden aus Metadaten gesammelt und in Rust-Bindungen umgewandelt. Der Prozess der Generierung der Rust-Bindungen lässt bewusst einige APIs aus. APIs werden nur ausgeschlossen, wenn sie (1) für Rust-Entwickler ungeeignet sind und (2) eine große Auswirkung auf die Gesamtgröße der Crates windows und windows-sys haben.

Die Xaml-API ist ausgeschlossen, da sie ohne direkte Sprachunterstützung, die nur das Xaml-Team bieten kann, praktisch unbrauchbar ist. Xaml ist außerdem auf die Entwicklung von C#-Apps ausgerichtet und maßgeschneidert, daher ist diese API für Rust-Entwickler nicht relevant. Die MsHtml-API ist ebenfalls ausgeschlossen, da sie nur für ältere Skriptsprachen von Microsoft wie JScript und VBScript bestimmt ist. Sie ist auch bei weitem das größte Modul, gemessen an der Codezeilenanzahl. Darüber hinaus werden einige veraltete und unbrauchbare APIs ausgeschlossen. Sie können genau sehen, was der windows crate ausschließt und was der windows-sys crate ausschließt.

Darüber hinaus schließt der windows-sys-Crate derzeit alle COM- und WinRT-APIs aus. Der windows-sys-Crate enthält nur Deklarationen, und COM- und WinRT-Aufrufe sind ohne die vom windows-Crate bereitgestellten Abstraktionen viel zu umständlich. Hier sind einige Tipps zur Auswahl zwischen den windows- und windows-sys-Crates.

Wo ist mein Lieblingsmakro aus dem Windows SDK?

Die Crates windows und windows-sys werden aus Metadaten generiert. Diese Metadaten enthalten nur Typdefinitionen und Funktionssignaturen, keine Makros, Header-only-Funktionen oder Funktionskörper. Möglicherweise finden Sie einige Äquivalente zu gängigen C/C++-Hilfs-Makros und -Funktionen im windows-Crate, aber im Allgemeinen haben die Makros keine direkten Entsprechungen in den windows- oder windows-sys-Crates.

Aufrufen Ihrer ersten API mit dem windows-Crate

Sie möchten also ein Gefühl dafür bekommen, wie man eine einfache Windows-API aufruft. Wo soll man anfangen? Schauen wir uns eine relativ einfache API zum Übermitteln von Rückrufen an den Thread-Pool an. Sie können hier mehr über diese API erfahren.

Der erste Schritt ist das Hinzufügen einer Abhängigkeit vom windows-Crate und die Angabe, auf welche Features Sie zugreifen möchten.

[dependencies.windows]
version = "0.52"
features = [
    "Win32_Foundation",
    "Win32_System_Threading",
]

Warum diese beiden Features? Nun, die Thread-Pool-API ist im Modul Win32::System::Threading definiert, und wir werden auch eine Handvoll Definitionen aus dem Modul Win32::Foundation verwenden. Wenn Sie sich nicht sicher sind, bieten die Dokumentationen für jede gegebene API einen hilfreichen Kommentar, der angibt, welche Features erforderlich sind. Hier sind zum Beispiel die Dokumentationen für WaitForThreadpoolWorkCallbacks, wo Sie sehen können, dass es von beiden Features abhängt, da es im Modul Win32::System::Threading definiert ist und von BOOL abhängt, das im Modul Win32::Foundation definiert ist.

Cargo erledigt nun die schwere Arbeit, verfolgt die Abhängigkeiten und stellt sicher, dass die Importbibliotheken vorhanden sind, sodass wir diese APIs einfach in Rust ohne weitere Konfiguration aufrufen können. Wir können eine use-Deklaration verwenden, um diese APIs etwas zugänglicher zu machen

#![allow(unused)]
fn main() {
use windows::{core::Result, Win32::System::Threading::*};
}

Um zu "beweisen", dass der Code funktioniert, und ihn gleichzeitig sehr einfach zu halten, verwenden wir einfach den Thread-Pool, um einen Zähler eine bestimmte Anzahl von Malen zu inkrementieren. Hier können wir eine Reader-Writer-Sperre für einen sicheren und Multi-Thread-Zugriff auf die Zählervariable verwenden

#![allow(unused)]
fn main() {
static COUNTER: std::sync::RwLock<i32> = std::sync::RwLock::new(0);
}

Für dieses Beispiel verwende ich einfach eine einfache main-Funktion mit einem großen unsafe-Block, da praktisch alles hier unsafe sein wird. Warum ist das so? Nun, das windows-Crate erlaubt Ihnen, externe Funktionen aufzurufen, und diese werden im Allgemeinen als unsafe angenommen.

fn main() -> Result<()> {
    unsafe {
        
    }

    Ok(())
}

Die Thread-Pool-API ist als eine Reihe von "Objekten" modelliert, die über eine traditionelle C-Stil-API verfügbar gemacht werden. Das erste, was wir tun müssen, ist, ein Arbeits-Objekt zu erstellen

#![allow(unused)]
fn main() {
let work = CreateThreadpoolWork(Some(callback), None, None)?;
}

Der erste Parameter ist ein Zeiger auf eine Callback-Funktion. Die restlichen Parameter sind optional und Sie können mehr darüber in meiner Thread-Pool-Serie auf MSDN lesen.

Der Callback selbst muss ein gültiger C-Stil-Callback gemäß der vom Thread-Pool-API erwarteten Signatur sein. Hier ist ein einfacher Callback, der den Zähler inkrementiert

#![allow(unused)]
fn main() {
extern "system" fn callback(_: PTP_CALLBACK_INSTANCE, _: *mut std::ffi::c_void, _: PTP_WORK) {
    let mut counter = COUNTER.write().unwrap();
    *counter += 1;
}
}

Die Parameter können sicher ignoriert werden, sind aber von Zeit zu Zeit nützlich. An diesem Punkt haben wir ein gültiges Arbeits-Objekt, aber es passiert noch nichts. Um etwas "Arbeit" zu starten, müssen wir das Arbeits-Objekt an den Thread-Pool übermitteln. Sie können dies so oft tun, wie Sie möchten, also lassen Sie uns es zehnmal tun

#![allow(unused)]
fn main() {
for _ in 0..10 {
    SubmitThreadpoolWork(work);
}
}

Sie können nun erwarten, dass die Rückrufe parallel ausgeführt werden, daher die RwLock oben. Natürlich müssen wir mit all dieser Parallelität einen Weg haben, um zu wissen, wann die Arbeit erledigt ist. Das ist die Aufgabe der Funktion WaitForThreadpoolWorkCallbacks

#![allow(unused)]
fn main() {
WaitForThreadpoolWorkCallbacks(work, false);
}

Der zweite Parameter gibt an, ob wir ausstehende Callbacks abbrechen möchten, die noch nicht mit der Ausführung begonnen haben. Das Übergeben von false bedeutet hier, dass wir möchten, dass die Wait-Funktion blockiert, bis die gesamte eingereichte Arbeit abgeschlossen ist. An diesem Punkt können wir das Work-Objekt sicher schließen, um seinen Speicher freizugeben.

#![allow(unused)]
fn main() {
CloseThreadpoolWork(work);
}

Und nur um zu beweisen, dass es zuverlässig funktioniert, können wir den Wert des Zählers ausgeben

#![allow(unused)]
fn main() {
let counter = COUNTER.read().unwrap();
println!("counter: {}", *counter);
}

Das Ausführen des Beispiels sollte ungefähr so etwas ausgeben

counter: 10

Hier ist das vollständige Beispiel als Referenz.

Aufrufen Ihrer ersten API mit dem windows-sys-Crate

Sie möchten also ein Gefühl dafür bekommen, wie man eine einfache Windows-API aufruft. Wo soll man anfangen? Schauen wir uns eine relativ einfache API zum Übermitteln von Rückrufen an den Thread-Pool an. Sie können hier mehr über diese API erfahren.

Der erste Schritt ist das Hinzufügen einer Abhängigkeit vom windows-sys-Crate und die Angabe, auf welche Features Sie zugreifen möchten.

[dependencies.windows-sys]
version = "0.52"
features = [
    "Win32_Foundation",
    "Win32_System_Threading",
]

Warum diese beiden Features? Nun, die Thread-Pool-API ist im Modul Win32::System::Threading definiert, und wir werden auch eine Handvoll Definitionen aus dem Modul Win32::Foundation verwenden. Wenn Sie sich nicht sicher sind, bieten die Dokumentationen für jede gegebene API einen hilfreichen Kommentar, der angibt, welche Features erforderlich sind. Hier sind zum Beispiel die Dokumentationen für WaitForThreadpoolWorkCallbacks, wo Sie sehen können, dass es von beiden Features abhängt, da es im Modul Win32::System::Threading definiert ist und von BOOL abhängt, das im Modul Win32::Foundation definiert ist.

Cargo erledigt nun die schwere Arbeit, verfolgt die Abhängigkeiten und stellt sicher, dass die Importbibliotheken vorhanden sind, sodass wir diese APIs einfach in Rust ohne weitere Konfiguration aufrufen können. Wir können eine use-Deklaration verwenden, um diese APIs etwas zugänglicher zu machen

#![allow(unused)]
fn main() {
use windows_sys::{Win32::Foundation::*, Win32::System::Threading::*};
}

Um zu "beweisen", dass der Code funktioniert, und ihn gleichzeitig sehr einfach zu halten, verwenden wir einfach den Thread-Pool, um einen Zähler eine bestimmte Anzahl von Malen zu inkrementieren. Hier können wir eine Reader-Writer-Sperre für einen sicheren und Multi-Thread-Zugriff auf die Zählervariable verwenden

#![allow(unused)]
fn main() {
static COUNTER: std::sync::RwLock<i32> = std::sync::RwLock::new(0);
}

Für dieses Beispiel verwende ich einfach eine einfache main-Funktion mit einem großen unsafe-Block, da praktisch alles hier unsafe sein wird. Warum ist das so? Nun, das windows-Crate erlaubt Ihnen, externe Funktionen aufzurufen, und diese werden im Allgemeinen als unsafe angenommen.

fn main() {
    unsafe {
        
    }
}

Die Thread-Pool-API ist als eine Reihe von "Objekten" modelliert, die über eine traditionelle C-Stil-API verfügbar gemacht werden. Das erste, was wir tun müssen, ist, ein Arbeits-Objekt zu erstellen

#![allow(unused)]
fn main() {
let work = CreateThreadpoolWork(Some(callback), std::ptr::null_mut(), std::ptr::null());
}

Der erste Parameter ist ein Zeiger auf eine Callback-Funktion. Die restlichen Parameter sind optional und Sie können mehr darüber in meiner Thread-Pool-Serie auf MSDN lesen.

Da diese Funktion Speicher alloziert, ist es möglich, dass sie fehlschlägt, und dies wird durch die Rückgabe eines Null-Zeigers anstelle eines gültigen Work-Objekthandles angezeigt. Wir werden diese Bedingung prüfen und die Funktion GetLastError aufrufen, um den relevanten Fehlercode anzuzeigen.

#![allow(unused)]
fn main() {
if work == 0 {
    println!("{:?}", GetLastError());
    return;
}
}

Der Callback selbst muss ein gültiger C-Stil-Callback gemäß der vom Thread-Pool-API erwarteten Signatur sein. Hier ist ein einfacher Callback, der den Zähler inkrementiert

#![allow(unused)]
fn main() {
extern "system" fn callback(_: PTP_CALLBACK_INSTANCE, _: *mut std::ffi::c_void, _: PTP_WORK) {
    let mut counter = COUNTER.write().unwrap();
    *counter += 1;
}
}

Die Parameter können sicher ignoriert werden, sind aber von Zeit zu Zeit nützlich. An diesem Punkt haben wir ein gültiges Arbeits-Objekt, aber es passiert noch nichts. Um etwas "Arbeit" zu starten, müssen wir das Arbeits-Objekt an den Thread-Pool übermitteln. Sie können dies so oft tun, wie Sie möchten, also lassen Sie uns es zehnmal tun

#![allow(unused)]
fn main() {
for _ in 0..10 {
    SubmitThreadpoolWork(work);
}
}

Sie können nun erwarten, dass die Rückrufe parallel ausgeführt werden, daher die RwLock oben. Natürlich müssen wir mit all dieser Parallelität einen Weg haben, um zu wissen, wann die Arbeit erledigt ist. Das ist die Aufgabe der Funktion WaitForThreadpoolWorkCallbacks

#![allow(unused)]
fn main() {
WaitForThreadpoolWorkCallbacks(work, 0);
}

Der zweite Parameter gibt an, ob wir ausstehende Callbacks abbrechen möchten, die noch nicht mit der Ausführung begonnen haben. Das Übergeben von 0, also false, bedeutet hier, dass wir möchten, dass die Wait-Funktion blockiert, bis die gesamte eingereichte Arbeit abgeschlossen ist. An diesem Punkt können wir das Work-Objekt sicher schließen, um seinen Speicher freizugeben.

#![allow(unused)]
fn main() {
CloseThreadpoolWork(work);
}

Und nur um zu beweisen, dass es zuverlässig funktioniert, können wir den Wert des Zählers ausgeben

#![allow(unused)]
fn main() {
let counter = COUNTER.read().unwrap();
println!("counter: {}", *counter);
}

Das Ausführen des Beispiels sollte ungefähr so etwas ausgeben

counter: 10

Hier ist das vollständige Beispiel als Referenz.

Aufrufen Ihrer ersten COM-API

COM-APIs sind einzigartig, da sie Funktionalität über Schnittstellen exponieren. Eine Schnittstelle ist nur eine Sammlung von virtuellen Funktionszeigern, die in einer sogenannten vtable oder virtuellen Funktionstabelle zusammengefasst sind. Dies ist nichts, was Rust direkt unterstützt, wie es C++ tut, aber der windows-Crate stellt den notwendigen Code-Gen bereit, um dies möglich und nahtlos zu machen. Eine COM-API beginnt typischerweise immer noch mit einem herkömmlichen C-Stil-Funktionsaufruf, um Ihre Hände an eine COM-Schnittstelle zu bekommen. Von dort aus können Sie möglicherweise weitere Methoden über die Schnittstelle aufrufen.

Einige COM-basierte APIs können sehr kompliziert werden, also beginnen wir mit einem sehr einfachen Beispiel. Die Funktion CreateUri ist offiziell auf MSDN dokumentiert und gibt die IUri-Schnittstelle zurück, die die Ergebnisse des Parsens der gegebenen URI darstellt. Die Rust-Dokumentation für den windows-Crate gibt an, dass sie sich im Modul Win32::System::Com befindet, sodass wir unsere windows-Crate-Abhängigkeit entsprechend konfigurieren können.

[dependencies.windows]
version = "0.52"
features = [
    "Win32_System_Com",
]

Und wir können eine use-Deklaration verwenden, um diese API etwas zugänglicher zu machen. Das core-Modul des windows-Crates bietet auch einige Hilfsmittel, um die Arbeit mit COM-Schnittstellen zu erleichtern, daher werden wir auch dieses einbeziehen.

#![allow(unused)]
fn main() {
use windows::{core::*, Win32::System::Com::*};
}

Für dieses Beispiel verwende ich einfach eine einfache main-Funktion mit einem großen unsafe-Block, da praktisch alles hier unsafe sein wird. Warum ist das so? Nun, das windows-Crate erlaubt Ihnen, externe Funktionen aufzurufen, und diese werden im Allgemeinen als unsafe angenommen.

fn main() -> Result<()> {
    unsafe {
        
        Ok(())
    }
}

Der einzige "interessante" Punkt hier ist die Verwendung des Result-Typs aus dem windows::core-Modul, das Windows-Fehlerbehandlung bereitstellt, um die folgenden API-Aufrufe zu vereinfachen. Und damit können wir die Funktion CreateUri wie folgt aufrufen:

#![allow(unused)]
fn main() {
let uri = CreateUri(w!("http://kennykerr.ca"), Uri_CREATE_CANONICALIZE, 0)?;
}

Hier passiert ziemlich viel. Der erste Parameter ist tatsächlich ein PCWSTR, der einen null-terminierten Wide-String darstellt, der von vielen Windows-APIs verwendet wird. Der windows-Crate bietet das praktische w!-Makro zum Erstellen eines gültigen, null-terminierten Wide-Strings als Konstante zur Kompilierzeit. Der zweite Parameter ist nur das Standard-Flag, das in der offiziellen Dokumentation angegeben ist. Der dritte Parameter ist reserviert und sollte daher null sein.

Das resultierende IUri-Objekt verfügt über verschiedene Methoden, die wir nun zur Inspektion der URI verwenden können. Die offizielle Dokumentation beschreibt die verschiedenen Schnittstellenmethoden und die Rust-Docs geben Ihnen einen schnellen Einblick in ihre verschiedenen Signaturen, damit Sie schnell herausfinden können, wie Sie sie in Rust aufrufen.

#![allow(unused)]
fn main() {
let domain = uri.GetDomain()?;
let port = uri.GetPort()?;

println!("{domain} ({port})");
}

Unter der Haube rufen diese Methoden die virtuellen Funktionen über die COM-Schnittstelle und in die von der API bereitgestellte Implementierung auf. Sie bieten auch eine Menge Fehler- und Signaturtransformationen, um die Verwendung aus Rust sehr natürlich zu gestalten. Und das ist alles. Wenn Sie das Beispiel ausführen, wird etwas wie das hier ausgegeben:

kennykerr.ca (80)

Hier ist das vollständige Beispiel als Referenz.

Aufrufen Ihrer ersten WinRT-API

Windows 8 führte die Windows Runtime ein, die im Kern COM mit einigen zusätzlichen Konventionen ist, um Sprachbindungen nahtloser erscheinen zu lassen. Der windows-Crate macht das Aufrufen von COM-APIs bereits viel nahtloser als für C++-Entwickler, aber WinRT geht weiter, indem es erstklassige Unterstützung für die Modellierung von Konstruktoren, Ereignissen und Klassen-Hierarchien bietet. Beim Aufrufen Ihrer ersten COM-API haben wir gesehen, dass Sie die API immer noch mit einem herkömmlichen C-Stil-DLL-Export initialisieren mussten, bevor Sie COM-Schnittstellenmethoden aufrufen konnten. WinRT funktioniert auf die gleiche Weise, abstrahiert dies aber auf eine verallgemeinerte Weise.

Lassen Sie uns ein einfaches Beispiel verwenden, um dies zu veranschaulichen. Die XmlDocument "Klasse" modelliert ein XML-Dokument, das aus verschiedenen Quellen geladen werden kann. Die Rust-Dokumentation für den windows-Crate gibt an, dass dieser Typ im Modul Data::Xml::Dom vorhanden ist, sodass wir unsere windows-Crate-Abhängigkeit wie folgt konfigurieren können:

[dependencies.windows]
version = "0.52" 
features = [
    "Data_Xml_Dom",
]

Und wir können eine use-Deklaration verwenden, um diese API etwas zugänglicher zu machen. Das core-Modul des windows-Crates bietet nur einige Hilfsmittel, um die Arbeit mit Windows-APIs zu erleichtern, daher werden wir auch dieses einbeziehen.

#![allow(unused)]
fn main() {
use windows::{core::*, Data::Xml::Dom::XmlDocument}; 
}

Für dieses Beispiel verwende ich einfach eine einfache main-Funktion mit einem Result-Typ aus dem windows::core-Modul, um die automatische Fehlerweiterleitung zu ermöglichen und die nachfolgenden API-Aufrufe zu vereinfachen.

fn main() -> Result<()> {

    Ok(())
}

Im Gegensatz zu den vorherigen Win32- und COM-Beispielen werden Sie feststellen, dass diese main-Funktion keinen unsafe-Block benötigt, da WinRT-Aufrufe dank seines stärker eingeschränkten Typsystems als sicher gelten.

Zunächst können wir einfach die Methode new aufrufen, um ein neues XmlDocument-Objekt zu erstellen.

#![allow(unused)]
fn main() {
let doc = XmlDocument::new()?;
}

Dies sieht einer idiomatischen Rust-Art viel ähnlicher als Ihre typische COM-API, aber unter der Haube wird ein ähnlicher Mechanismus verwendet, um die XmlDocument-Implementierung über einen DLL-Export zu instanziieren. Wir können dann die Methode LoadXml aufrufen, um sie zu testen. Es gibt verschiedene andere Optionen zum Laden von XML aus verschiedenen Quellen, über die Sie in der offiziellen Dokumentation oder in den Rust-Dokumentationen für die XmlDocument-API lesen können. Der windows-Crate bietet auch das praktische h!-Makro zum Erstellen eines HSTRING, des Zeichentyps, der von WinRT-APIs verwendet wird.

#![allow(unused)]
fn main() {
doc.LoadXml(h!("<html>hello world</html>"))?;
}

Und schon haben wir ein vollständig formatiertes XML-Dokument, das wir inspizieren können. Für dieses Beispiel greifen wir einfach auf das Dokumentenelement zu und führen dann einige grundlegende Abfragen wie folgt durch:

#![allow(unused)]
fn main() {
let root = doc.DocumentElement()?;
assert!(root.NodeName()? == "html");
println!("{}", root.InnerText()?);
}

Zuerst behaupten wir, dass der Elementname tatsächlich "html" ist, und geben dann den inneren Text des Elements aus. Wie beim vorherigen COM-Beispiel rufen all diese Methoden virtuelle Funktionen über COM-Schnittstellen auf, aber der windows-Crate macht es sehr einfach, solche Aufrufe direkt aus Rust zu tätigen. Und das ist alles. Wenn Sie das Beispiel ausführen, wird etwa Folgendes ausgegeben:

hello world

Hier ist das vollständige Beispiel als Referenz.

Wie frage ich nach einer bestimmten COM-Schnittstelle?

COM- und WinRT-Schnittstellen im windows-Crate implementieren den ComInterface-Trait. Dieser Trait stellt die Methode cast bereit, die intern QueryInterface verwendet, um die aktuelle Schnittstelle in eine andere vom Objekt unterstützte Schnittstelle umzuwandeln. Die Methode cast gibt ein Result<T> zurück, sodass Fehler auf natürliche Weise in Rust behandelt werden können.

Zum Beispiel ist es oft notwendig, die IDXGIDevice-Schnittstelle für ein gegebenes Direct3D-Gerät zu erhalten, um mit anderen Rendering-APIs zu interagieren. So könnten Sie eine Swap-Chain zum Zeichnen und Präsentieren auf einem Direct3D-Gerät erstellen. Stellen wir uns eine einfache Funktion vor, die ein Direct3D-Gerät akzeptiert und die zugrunde liegende DXGI-Factory zurückgibt.

#![allow(unused)]
fn main() {
fn get_dxgi_factory(device: &ID3D11Device) -> Result<IDXGIFactory2> {
}
}

Als Erstes müssen Sie das Direct3D-Gerät nach seiner DXGI-Schnittstelle abfragen oder casten, wie folgt:

#![allow(unused)]
fn main() {
let device = device.cast::<IDXGIDevice>()?;
}

Wenn es bequemer ist, können Sie auch die Typinferenz wie folgt nutzen:

#![allow(unused)]
fn main() {
let device: IDXGIDevice = device.cast()?;
}

Mit der COM-Schnittstelle in der Hand benötigen wir einen unsafe-Block, um ihre Methoden aufzurufen.

#![allow(unused)]
fn main() {
unsafe {
}
}

Innerhalb des unsafe-Blocks können wir den physischen Adapter des Geräts abrufen.

#![allow(unused)]
fn main() {
let adapter = device.GetAdapter()?;
}

Und nur zum Spaß (oder zur Fehlersuche) könnten wir den Namen des Adapters ausgeben.

#![allow(unused)]
fn main() {
if cfg!(debug_assertions) {
    let mut desc = Default::default();
    adapter.GetDesc(&mut desc)?;
    println!("{}", String::from_utf16_lossy(&desc.Description));
}
}

Schließlich können wir den übergeordneten Adapter und auch das DXGI-Factory-Objekt für das Gerät zurückgeben.

#![allow(unused)]
fn main() {
adapter.GetParent()
}

Wenn ich das Beispiel ausführe, erhalte ich die folgenden beeindruckenden Ergebnisse:

AMD FirePro W4100

Hier ist ein umfassenderes DirectX-Beispiel.

Die cast-Methode funktioniert genauso gut für WinRT-Klassen und -Schnittstellen. Sie ist besonders nützlich für die Interoperabilität mit WinRT-APIs.

Wie implementiere ich eine vorhandene COM-Schnittstelle?

In einigen Fällen müssen Sie möglicherweise eine vorhandene COM-Schnittstelle implementieren, anstatt einfach eine vorhandene Implementierung des Betriebssystems aufzurufen. Hier sind das implement-Feature und -Makro nützlich. Der windows-Crate bietet optionale Implementierungsunterstützung, die hinter dem implement-Feature verborgen ist. Sobald es aktiviert ist, kann das implement-Makro verwendet werden, um eine beliebige Anzahl von COM-Schnittstellen zu implementieren. Das Makro kümmert sich um die Implementierung von IUnknown selbst.

Lassen Sie uns eine einfache, von Windows definierte Schnittstelle implementieren, um dies zu veranschaulichen. Die IPersist-Schnittstelle ist im Modul Win32::System::Com definiert, also fügen wir zunächst eine Abhängigkeit vom windows-Crate hinzu und schließen das Feature Win32_System_Com ein.

[dependencies.windows]
version = "0.52"
features = [
    "implement",
    "Win32_System_Com",
]

Das implement-Feature schaltet die Implementierungsunterstützung frei.

Das implement-Makro ist im windows::core-Modul enthalten, also halten wir die Dinge einfach, indem wir alles wie folgt einbeziehen:

#![allow(unused)]
fn main() {
use windows::{core::*, Win32::System::Com::*};
}

Jetzt ist es Zeit für die Implementierung.

#![allow(unused)]
fn main() {
#[implement(IPersist)]
struct Persist(GUID);
}

Das implement-Makro liefert die notwendige Implementierung für die Lebenszeitverwaltung und Schnittstellenerkennung von IUnknown für die im Attribut enthaltenen Schnittstellen. In diesem Fall wird nur IPersist implementiert.

Die Implementierung selbst wird durch einen Trait definiert, der dem Muster <interface name>_Impl folgt, und es liegt an uns, ihn für unsere Implementierung wie folgt zu implementieren:

#![allow(unused)]
fn main() {
impl IPersist_Impl for Persist_Impl {
    fn GetClassID(&self) -> Result<GUID> {
        Ok(self.0)
    }
}
}

Die IPersist-Schnittstelle, ursprünglich hier dokumentiert, hat eine einzige Methode, die eine GUID zurückgibt. Wir implementieren sie einfach, indem wir den Wert zurückgeben, der in unserer Implementierung enthalten ist. Der window-Crate und das implement-Makro kümmern sich um den Rest, indem sie den eigentlichen COM-virtuellen Funktionsaufruf und das virtuelle Funktions-Tabellenlayout bereitstellen, das erforderlich ist, um dies in ein heap-alloziertes und referenzgezähltes COM-Objekt zu verwandeln.

Alles, was bleibt, ist die Verschiebung oder das Boxen der Implementierung in die COM-Implementierung, die vom implement-Makro über den Into-Trait bereitgestellt wird.

#![allow(unused)]
fn main() {
let guid = GUID::new()?;
let persist: IPersist = Persist(guid).into();
}

An diesem Punkt können wir persist einfach als das COM-Objekt behandeln, das es ist.

#![allow(unused)]
fn main() {
let guid2 = unsafe { persist.GetClassID()? };
assert_eq!(guid, guid2);
println!("{:?}", guid);
}

Hier ist ein vollständiges Beispiel.

Wie erstelle ich Stock-Kollektionen für WinRT-Kollektionsschnittstellen?

Über die Implementierung von COM-Schnittstellen selbst hinaus bietet der windows-Crate Stock-Kollektionsimplementierungen für gängige WinRT-Kollektionsschnittstellen. Die Implementierung von WinRT-Kollektionsschnittstellen kann ziemlich herausfordernd sein, daher sollte dies Ihnen in vielen Fällen viel Arbeit ersparen. Das implement-Feature ist erforderlich, um diese Stock-Implementierungen nutzen zu können.

Betrachten wir einige Beispiele. Die WinRT-Kollektionsschnittstellen sind alle im Modul Foundation::Collections definiert, also beginnen wir mit dem Hinzufügen einer Abhängigkeit vom windows-Crate und schließen das Feature Foundation_Collections ein.

[dependencies.windows]
version = "0.52"
features = [
    "implement",
    "Foundation_Collections",
]

Das Erstellen einer Kollektion ist so einfach wie die Verwendung des TryFrom-Traits auf vorhandenen Vec oder BTreeMap, je nach Art der Kollektion.

WinRT-SchnittstelleVon
IIterable<T>Vec<T::Default>
IVectorView<T>Vec<T::Default>
IMapView<K, V>BTreeMap<K::Default, V::Default>

Wenn Sie also eine IIterable-Implementierung von i32-Werten benötigen, können Sie diese wie folgt erstellen:

use windows::{core::*, Foundation::Collections::*};

fn main() -> Result<()> {
    let collection = IIterable::<i32>::try_from(vec![1, 2, 3])?;

    for n in collection {
        println!("{n}");
    }

    Ok(())
}

Die resultierende collection wird alle spezialisierten IIterable<i32>-Methoden implementieren.

Haben Sie T::Default in der obigen Tabelle bemerkt? Die Herausforderung besteht darin, dass, wenn die WinRT-Kollektion nullable Typen enthält, im Gegensatz zu i32, die Kollektion notwendigerweise eine zugrunde liegende Implementierung unterstützen muss, die dies ausdrücken kann. Der assoziierte Typ Default ersetzt T durch Option<T> für solche nullable oder Referenztypen.

Betrachten wir ein etwas konstruierteres Beispiel. Hier erstellen wir eine IMapView mit Strings als Schlüssel und Schnittstellen als Werte. WinRT-Strings sind nicht nullable, Schnittstellen aber schon. WinRT-Strings werden im windows-Crate durch HSTRING dargestellt, und für die Schnittstelle verwenden wir einfach eine IStringable-Implementierung.

#![allow(unused)]
fn main() {
use windows::Foundation::*;

#[implement(IStringable)]
struct Value(&'static str);

impl IStringable_Impl for Value {
    fn ToString(&self) -> Result<HSTRING> {
        Ok(self.0.into())
    }
}
}

Wir können nun eine std-Sammlung wie folgt erstellen:

#![allow(unused)]
fn main() {
use std::collections::*;

let map = BTreeMap::from([
    ("hello".into(), Some(Value("HELLO").into())),
    ("hello".into(), Some(Value("WORLD").into())),
]);
}

Der Rust-Compiler leitet den genauen Typ natürlich ab: BTreeMap<HSTRING, Option<IStringable>>.

Schließlich können wir diese BTreeMap mit dem TryInto-Trait in eine WinRT-Kollektion verpacken, wie folgt:

#![allow(unused)]
fn main() {
let map: IMapView<HSTRING, IStringable> = map.try_into()?;

for pair in map {
    println!("{} - {}", pair.Key()?, pair.Value()?.ToString()?);
}
}

Verständnis des windows-targets-Crates

Die Crates windows und windows-sys hängen vom windows-targets-Crate für Linker-Unterstützung ab. Der windows-targets-Crate enthält Import-Libs, unterstützt semantische Versionierung und optionale Unterstützung für raw-dylib. Er stellt explizite Import-Bibliotheken für die folgenden Ziele bereit:

  • i686_msvc
  • x86_64_msvc
  • aarch64_msvc
  • i686_gnu
  • x86_64_gnu
  • x86_64_gnullvm
  • aarch64_gnullvm

Eine Import-Lib enthält Informationen, die der Linker verwendet, um externe Referenzen auf Funktionen aufzulösen, die von DLLs exportiert werden. Dies ermöglicht es dem Betriebssystem, eine bestimmte DLL und einen Funktions-Export zur Ladezeit zu identifizieren. Import-Libs sind sowohl toolchain- als auch architekturabhängig. Das bedeutet, dass unterschiedliche Lib-Dateien erforderlich sind, je nachdem, ob Sie mit den MSVC- oder GNU-Toolchains kompilieren und ob Sie für die x86- oder ARM64-Architekturen kompilieren. Beachten Sie, dass Import-Bibliotheken keinen Code enthalten, wie es statische Bibliotheken tun.

Während die GNU- und MSVC-Toolchains oft einige Import-Libs zur Unterstützung der C++-Entwicklung bereitstellen, sind diese Lib-Dateien oft unvollständig, fehlen oder sind einfach falsch. Dies kann zu Linkerfehlern führen, die sehr schwer zu diagnostizieren sind. Der windows-targets-Crate stellt sicher, dass alle von den windows- und windows-sys-Crates definierten Funktionen ohne Abhängigkeit von impliziten Lib-Dateien, die von der Toolchain verteilt werden, verlinkt werden können. Dies stellt sicher, dass Abhängigkeiten mit Cargo verwaltet werden können und vereinfacht die plattformübergreifende Kompilierung. Der windows-targets-Crate enthält auch versionsspezifische Lib-Dateinamen, die die Semver-Kompatibilität gewährleisten. Ohne diese Funktionalität wählt der Linker einfach den ersten passenden Lib-Dateinamen aus und kann fehlende oder inkonsistente Imports nicht auflösen.

Hinweis: Normalerweise müssen Sie sich überhaupt keine Gedanken über den windows-targets-Crate machen. Die windows- und windows-sys-Crates hängen automatisch vom windows-targets-Crate ab. Nur in seltenen Fällen müssen Sie ihn direkt verwenden.

Beginnen Sie damit, Folgendes zu Ihrer Cargo.toml-Datei hinzuzufügen

[dependencies.windows-targets]
version = "0.52"

Verwenden Sie das link-Makro, um die externen Funktionen zu definieren, die Sie aufrufen möchten.

#![allow(unused)]
fn main() {
windows_targets::link!("kernel32.dll" "system" fn SetLastError(code: u32));
windows_targets::link!("kernel32.dll" "system" fn GetLastError() -> u32);
}

Nutzen Sie nach Bedarf beliebige Windows-APIs.

fn main() {
    unsafe {
        SetLastError(1234);
        assert_eq!(GetLastError(), 1234);
    }
}

Standardmäßig veranlasst das link-Makro den Linker, die gebündelten Import-Libs zu verwenden. Das Kompilieren mit dem windows_raw_dylib Rust-Build-Flag veranlasst Cargo, das Herunterladen der Import-Libs komplett zu überspringen und stattdessen raw-dylib zu verwenden, um Imports automatisch aufzulösen. Der Rust-Compiler erstellt dann die Import-Einträge direkt. Dies funktioniert, ohne dass Sie Ihren Code ändern müssen. Ohne den windows-targets-Crate erfordert der Wechsel zwischen Linker- und raw-dylib-Imports sehr komplexe Codeänderungen. Zum Zeitpunkt des Schreibens ist das raw-dylib-Feature noch nicht stabil.

Standalone-Code-Generierung

Selbst mit einer Auswahl zwischen den windows- und windows-sys-Crates bevorzugen einige Entwickler möglicherweise die Verwendung völlig eigenständiger Bindungen. Der windows-bindgen-Crate ermöglicht es Ihnen, vollständig eigenständige Bindungen für Windows-APIs mit einem einzigen Funktionsaufruf zu generieren, den Sie von einem Test ausführen können, um die Generierung von Bindungen zu automatisieren. Dies kann dazu beitragen, Ihre Abhängigkeiten zu reduzieren und gleichzeitig einen nachhaltigen Weg für zukünftige API-Anforderungen zu bieten, oder einfach nur Ihre Bindungen von Zeit zu Zeit zu aktualisieren, um automatische Fehlerbehebungen von Microsoft zu erhalten.

Warnung: Die eigenständige Code-Generierung sollte nur als letzter Ausweg für die anspruchsvollsten Szenarien verwendet werden. Es ist viel einfacher, den windows-sys-Crate zu verwenden und Cargo die Verwaltung dieser Abhängigkeit zu überlassen. Dieser windows-sys-Crate bietet Rohbindungen, ist gut getestet und weit verbreitet und sollte Ihre Kompilierzeit nicht wesentlich beeinträchtigen.

Beginnen Sie damit, Folgendes zu Ihrer Cargo.toml-Datei hinzuzufügen

[dependencies.windows-targets]
version = "0.52"

[dev-dependencies.windows-bindgen]
version = "0.52"

Der windows-bindgen-Crate wird nur für die Generierung von Bindungen benötigt und ist daher nur eine Dev-Abhängigkeit. Der windows-targets-Crate ist eine gemeinsame Abhängigkeit der windows- und windows-sys-Crates und enthält nur Import-Libs für unterstützte Ziele. Dies stellt sicher, dass Sie alle Windows-API-Funktionen verlinken können, die Sie möglicherweise benötigen.

Schreiben Sie einen Test, um Bindungen wie folgt zu generieren:

#![allow(unused)]
fn main() {
#[test]
fn bindgen() {
    let args = [
        "--out",
        "src/bindings.rs",
        "--config",
        "flatten",
        "--filter",
        "Windows.Win32.System.SystemInformation.GetTickCount",
    ];

    windows_bindgen::bindgen(args).unwrap();
}
}

Nutzen Sie nach Bedarf beliebige Windows-APIs.

mod bindings;

fn main() {
    unsafe {
        println!("{}", bindings::GetTickCount());
    }
}

Erstellen Ihrer ersten DLL in Rust

Als Systemprogrammiersprache mit ähnlicher Linkage-Unterstützung wie C und C++ ist es ziemlich einfach, eine DLL in Rust zu erstellen. Rust hat jedoch seine eigene Vorstellung von Bibliotheken, die sich stark von C und C++ unterscheiden. Es geht also nur darum, die richtige Konfiguration zu finden, um die gewünschte Ausgabe zu erzielen.

Wie bei den meisten Rust-Projekten können Sie mit Cargo beginnen und eine grundlegende Vorlage verwenden, aber es ist so einfach, dass wir es hier von Hand erstellen, um zu sehen, was involviert ist. Lassen Sie uns eine Verzeichnisstruktur wie folgt erstellen:

> hello_world
  Cargo.toml
  > src
    lib.rs

Nur zwei Verzeichnisse und zwei Dateien. Da ist das Verzeichnis hello_world, das das Projekt als Ganzes enthält. In diesem Verzeichnis haben wir eine Datei Cargo.toml, die Metadaten für das Projekt oder Paket enthält, Informationen, die zum Kompilieren des Pakets benötigt werden.

[package]
name = "hello_world"
edition = "2021"

[lib]
crate-type = ["cdylib"]

Im Minimalfall enthält der Abschnitt [package] den Namen und die Rust-Edition, mit der Ihr Paket kompiliert wird.

Nur-Rust-Bibliotheken enthalten im Allgemeinen keinen [lib]-Abschnitt. Dieser ist notwendig, wenn Sie genau steuern müssen, wie das Projekt verwendet und verlinkt wird. In diesem Fall verwenden wir 'cdylib', das eine dynamische Systembibliothek darstellt und unter Windows einer DLL entspricht.

Das Unterverzeichnis src enthält die Rust-Quelldatei lib.rs, in der wir Funktionen hinzufügen können, die wir aus der DLL exportieren möchten. Hier ist ein einfaches Beispiel:

#[no_mangle]
extern "system" fn HelloWorld() -> i32 {
    123
}

Das Attribut [no_mangle] teilt dem Compiler einfach mit, das Namens-Mangling zu deaktivieren und den Funktionsnamen wörtlich als exportierte Kennung zu verwenden. Der Funktionsqualifizierer extern "system" gibt die ABI oder Aufrufkonvention an, die für die Funktion erwartet wird. Der String "system" repräsentiert die systemspezifische Aufrufkonvention, die unter Windows im Allgemeinen "stdcall" entspricht.

Und das ist alles! Sie können das Paket jetzt bauen und es wird eine DLL erzeugen.

> cargo build -p hello_world

Cargo wird die resultierenden Binärdateien im Zielverzeichnis ablegen, von wo aus Sie sie dann in jeder anderen Programmiersprache verwenden können.

> dir /b target\debug\hello_world.*
hello_world.d
hello_world.dll
hello_world.dll.exp
hello_world.dll.lib
hello_world.pdb

Hier ist ein einfaches Beispiel in C++:

#include <stdint.h>
#include <stdio.h>

extern "C" {
    int32_t __stdcall HelloWorld();
}

int main() {
    printf("%d\n", HelloWorld());
}

Sie können es wie folgt mit MSVC erstellen:

cl hello_world.cpp hello_world.dll.lib

Das Werkzeug dumpbin kann verwendet werden, um Imports und Exports weiter zu inspizieren.

> dumpbin /nologo /exports hello_world.dll

Dump of file hello_world.dll

File Type: DLL

  Section contains the following exports for hello_world.dll

    00000000 characteristics
    FFFFFFFF time date stamp
        0.00 version
           1 ordinal base
           1 number of functions
           1 number of names

    ordinal hint RVA      name

          1    0 00001000 HelloWorld = HelloWorld
> dumpbin /nologo /imports hello_world.exe

Dump of file hello_world.exe

File Type: EXECUTABLE IMAGE

  Section contains the following imports:

    hello_world.dll
             140017258 Import Address Table
             140021200 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                           0 HelloWorld

Implementieren einer traditionellen API im Win32-Stil

Nachdem wir nun wissen, wie man eine DLL in Rust erstellt, wollen wir uns ansehen, was zur Implementierung einer einfachen API im Win32-Stil gehört. Obwohl WinRT im Allgemeinen die bessere Wahl für neue Betriebssystem-APIs ist, sind APIs im Win32-Stil weiterhin wichtig. Möglicherweise müssen Sie eine vorhandene API in Rust neu implementieren oder benötigen einfach eine feinere Kontrolle über das Typsystem oder das Aktivierungsmodell aus irgendeinem Grund.

Um es einfach, aber realistisch zu halten, implementieren wir eine JSON-Validierungs-API. Die Idee ist, eine Möglichkeit zu bieten, eine gegebene JSON-Zeichenkette effizient gegen ein bekanntes Schema zu validieren. Effizienz erfordert, dass das Schema vorkompiliert ist, sodass wir ein logisches JSON-Validator-Objekt erstellen können, das separat vom Prozess der Validierung der JSON-Zeichenkette erstellt und freigegeben werden kann. Sie können sich eine hypothetische API im Win32-Stil wie folgt vorstellen:

HRESULT __stdcall CreateJsonValidator(char const* schema, size_t schema_len, uintptr_t* handle);

HRESULT __stdcall ValidateJson(uintptr_t handle, char const* value, size_t value_len, char** sanitized_value, size_t* sanitized_value_len);

void __stdcall CloseJsonValidator(uintptr_t handle);

Die Funktion CreateJsonValidator sollte das Schema kompilieren und es über das zurückgegebene handle verfügbar machen.

Das Handle kann dann an die Funktion ValidateJson übergeben werden, um die Validierung durchzuführen. Die Funktion kann optional eine bereinigte Version des JSON-Wertes zurückgeben.

Das JSON-Validator-Handle kann später mit der Funktion CloseJsonValidator freigegeben werden, wodurch der vom Validator-"Objekt" belegte Speicher freigegeben wird.

Sowohl die Erstellung als auch die Validierung können fehlschlagen, sodass diese Funktionen einen HRESULT zurückgeben, wobei reiche Fehlerinformationen über die Funktion GetErrorInfo verfügbar sind.

Wir verwenden die windows-Kiste für grundlegende Windows-Fehlerbehandlung und Typunterstützung. Die beliebte serde_json-Kiste wird zum Parsen von JSON-Strings verwendet. Leider bietet sie keine Schema-Validierung. Eine schnelle Online-Suche zeigt, dass die jsonschema-Kiste das Haupt- oder einzige Mittel zu sein scheint. Sie wird für dieses Beispiel ausreichen. Der Fokus liegt hier nicht wirklich auf der speziellen Implementierung, sondern eher auf dem Prozess des allgemeinen Aufbaus einer solchen API in Rust.

Angesichts dieser Abhängigkeiten und dessen, was wir über die Erstellung einer DLL in Rust gelernt haben, sollte die Cargo.toml-Datei des Projekts wie folgt aussehen.

[package]
name = "json_validator"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
jsonschema = "0.17"
serde_json = "1.0"

[dependencies.windows]
version = "0.52"
features = [
    "Win32_Foundation",
    "Win32_System_Com",
]

Wir können eine use-Deklaration verwenden, um uns die Dinge ein wenig zu erleichtern.

#![allow(unused)]
fn main() {
use jsonschema::JSONSchema;
use windows::{core::*, Win32::Foundation::*, Win32::System::Com::*};
}

Und beginnen wir mit der API-Funktion CreateJsonValidator. So könnte die C++-Deklaration in Rust aussehen.

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "system" fn CreateJsonValidator(
    schema: *const u8,
    schema_len: usize,
    handle: *mut usize,
) -> HRESULT {
    create_validator(schema, schema_len, handle).into()
}
}

Nichts Aufregendes hier. Wir verwenden einfach die Definition von HRESULT aus der windows-Kiste. Die Implementierung ruft eine andere Funktion namens create_validator für ihre Implementierung auf. Wir tun dies, damit wir die syntaktische Bequemlichkeit des Standard-Result-Typs für die Fehlerweitergabe nutzen können. Die Spezialisierung von Result, die von der windows-Kiste bereitgestellt wird, unterstützt weiter die Umwandlung eines Result in ein HRESULT, während ihre reichhaltigen Fehlerinformationen an den Aufrufer weitergegeben werden. Dafür wird das nachfolgende into() verwendet.

Die Funktion create_validator sieht wie folgt aus.

#![allow(unused)]
fn main() {
unsafe fn create_validator(schema: *const u8, schema_len: usize, handle: *mut usize) -> Result<()> {
    // ...

    Ok(())
}
}

Wie Sie sehen können, hat sie exakt dieselben Parameter und tauscht einfach das HRESULT gegen ein Result aus, das den Einheitentyp oder nichts außer Erfolgs- oder Fehlerinformationen zurückgibt.

Zuerst müssen wir das bereitgestellte Schema mit serde_json parsen. Da wir an mehreren Stellen JSON parsen müssen, werden wir dies einfach in eine wiederverwendbare Hilfsfunktion einfügen.

#![allow(unused)]
fn main() {
unsafe fn json_from_raw_parts(value: *const u8, value_len: usize) -> Result<serde_json::Value> {
    if value.is_null() {
        return Err(E_POINTER.into());
    }

    let value = std::slice::from_raw_parts(value, value_len);

    let value =
        std::str::from_utf8(value).map_err(|_| Error::from(ERROR_NO_UNICODE_TRANSLATION))?;

    serde_json::from_str(value).map_err(|error| Error::new(E_INVALIDARG, format!("{error}").into()))
}
}

Die Funktion json_from_raw_parts beginnt damit zu prüfen, ob der Zeiger auf einen UTF-8-String nicht null ist, und gibt in solchen Fällen E_POINTER zurück. Wir können dann den Zeiger und die Länge in einen Rust-Slice und von dort in einen String-Slice umwandeln, wobei wir sicherstellen, dass es sich tatsächlich um einen gültigen UTF-8-String handelt. Schließlich rufen wir serde_json auf, um den String in einen JSON-Wert für die weitere Verarbeitung umzuwandeln.

Nachdem wir nun JSON parsen können, ist die Vervollständigung der Funktion create_validator relativ einfach.

#![allow(unused)]
fn main() {
unsafe fn create_validator(schema: *const u8, schema_len: usize, handle: *mut usize) -> Result<()> {
    let schema = json_from_raw_parts(schema, schema_len)?;

    let compiled = JSONSchema::compile(&schema)
        .map_err(|error| Error::new(E_INVALIDARG, error.to_string().into()))?;

    if handle.is_null() {
        return Err(E_POINTER.into());
    }

    *handle = Box::into_raw(Box::new(compiled)) as usize;

    Ok(())
}
}

Der JSON-Wert, in diesem Fall das JSON-Schema, wird an JSONSchema::compile übergeben, um die kompilierte Darstellung zu erzeugen. Obwohl bekannt ist, dass der Wert zu diesem Zeitpunkt JSON ist, ist er möglicherweise nicht tatsächlich ein gültiges JSON-Schema. In solchen Fällen geben wir E_INVALIDARG zurück und fügen die Fehlermeldung vom JSON-Schema-Compiler hinzu, um bei der Fehlersuche zu helfen. Schließlich, vorausgesetzt, der Handle-Zeiger ist nicht null, können wir die kompilierte Darstellung auf den Heap verschieben und als "Handle" zurückgeben.

Nun kommen wir zur Funktion CloseJsonValidator, da sie eng mit dem obigen Boxing-Code zusammenhängt. Boxing bedeutet lediglich, den Wert auf den Heap zu verschieben. Die Funktion CloseJsonValidator muss daher das Objekt "droppen" und die Heap-Allokation freigeben.

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "system" fn CloseJsonValidator(handle: usize) {
    if handle != 0 {
        _ = Box::from_raw(handle as *mut JSONSchema);
    }
}
}

Wir können eine kleine Absicherung hinzufügen, falls ein null Handle übergeben wird. Dies ist eine ziemlich übliche Komfortfunktion, um generische Programmierung für Aufrufer zu vereinfachen, aber ein Aufrufer kann die Indirektionskosten des Aufrufs von CloseJsonValidator generell vermeiden, wenn er weiß, dass das Handle null ist.

Schließlich betrachten wir die Implementierung der Funktion ValidateJson.

#![allow(unused)]
fn main() {
#[no_mangle]
unsafe extern "system" fn ValidateJson(
    handle: usize,
    value: *const u8,
    value_len: usize,
    sanitized_value: *mut *mut u8,
    sanitized_value_len: *mut usize,
) -> HRESULT {
    validate(
        handle,
        value,
        value_len,
        sanitized_value,
        sanitized_value_len,
    )
    .into()
}
}

Auch hier leitet die Implementierung aus Bequemlichkeitsgründen an eine Result zurückgebende Funktion weiter.

#![allow(unused)]
fn main() {
unsafe fn validate(
    handle: usize,
    value: *const u8,
    value_len: usize,
    sanitized_value: *mut *mut u8,
    sanitized_value_len: *mut usize,
) -> Result<()> {
    // ...
}
}

Zuerst müssen wir sicherstellen, dass wir überhaupt einen gültigen Handle haben, bevor wir ihn in eine JSONSchema-Objekt-Referenz umwandeln.

#![allow(unused)]
fn main() {
if handle == 0 {
    return Err(E_HANDLE.into());
}

let schema = &*(handle as *const JSONSchema);
}

Das sieht etwas kompliziert aus, aber wir wandeln einfach den undurchsichtigen Handle in einen JSONSchema-Zeiger um und geben dann eine Referenz zurück, um den Besitz nicht zu übernehmen.

Als Nächstes müssen wir den bereitgestellten JSON-Wert parsen.

#![allow(unused)]
fn main() {
let value = json_from_raw_parts(value, value_len)?;
}

Auch hier verwenden wir die praktische Hilfsfunktion json_from_raw_parts und lassen die Fehlerweitergabe automatisch über den ?-Operator abwickeln.

Zu diesem Zeitpunkt können wir die Schema-Validierung durchführen und optional eine bereinigte Kopie des JSON-Werts zurückgeben.

#![allow(unused)]
fn main() {
if schema.is_valid(&value) {
    if !sanitized_value.is_null() && !sanitized_value_len.is_null() {
        let value = value.to_string();

        *sanitized_value = CoTaskMemAlloc(value.len()) as _;

        if (*sanitized_value).is_null() {
            return Err(E_OUTOFMEMORY.into());
        }

        (*sanitized_value).copy_from(value.as_ptr(), value.len());
        *sanitized_value_len = value.len();
    }

    Ok(())
} else {
    // ...
}
}

Vorausgesetzt, der JSON-Wert stimmt mit dem kompilierten Schema überein, prüfen wir, ob der Aufrufer Zeiger zum Zurückgeben einer bereinigten Kopie des JSON-Werts bereitgestellt hat. In diesem Fall rufen wir to_string auf, um eine String-Darstellung direkt vom JSON-Parser zurückzugeben, verwenden CoTaskMemAlloc, um einen Puffer für die Rückgabe an den Aufrufer zu allokieren, und kopieren den resultierenden UTF-8-String in diesen Puffer.

Wenn etwas schiefgeht, können wir das kompilierte Schema abrufen, um eine praktische Fehlermeldung zu erzeugen, bevor wir E_INVALIDARG an den Aufrufer zurückgeben.

#![allow(unused)]
fn main() {
let mut message = String::new();

if let Some(error) = schema.validate(&value).unwrap_err().next() {
    message = error.to_string();
}

Err(Error::new(E_INVALIDARG, message.into()))
}

Die Methode validate gibt eine Sammlung von Fehlern zurück. Der Einfachheit halber geben wir nur den ersten zurück.

Und das war's! Ihre erste Win32-ähnliche API in Rust. Das vollständige Beispiel finden Sie hier.

Arbeiten mit Strings in der Windows-Kiste

Es gibt mehrere String-Typen in den Windows-APIs, darunter:

  • PSTR/PCSTR: Ein Zeiger auf einen nullterminierten String, der aus Zeichen (u8) besteht. Strings sollten mit der Codepage des aktuellen Threads kodiert werden. Ein 'C' steht für einen "konstanten" (schreibgeschützten) String.
  • PWSTR/PCWSTR: Ein Zeiger auf einen nullterminierten String, der aus 'Wide-Zeichen' (u16) besteht und mit UTF-16 kodiert ist.
  • BSTR: Ein binärer String, der üblicherweise in COM/OLE-Funktionen verwendet wird. Er besteht aus u16-Zeichen gefolgt von einem Nullterminator. Den Strings wird ihre Länge vor dem Zeiger vorangestellt, und einige Funktionen verwenden sie, um beliebige Binärdaten (einschließlich Daten mit Nulls) zu übergeben, wobei sie sich auf das Präfix und nicht auf den Terminator verlassen. Sie können jedoch *normalerweise* als normale, nullterminierte Wide-Strings verwendet werden.
  • HSTRING: Ein Handle für einen Windows Runtime-String. HSTRINGS sind UTF-16 und unveränderlich.

Beachten Sie, dass Sie BSTR oder HSTRING an Funktionen übergeben können, die PCWSTR erwarten.

Leider entspricht keiner dieser Typen exakt den Rust-Typen. Wir können jedoch die windows-strings-Kiste verwenden, um uns zu helfen.

Arten von API-Funktionen (schmal oder breit)

Die Win32-API teilt String-Funktionen in ihre "schmale" Version (endet auf 'A', wie MessageBoxA) und ihre "breite" Version (endet auf 'W', wie MessageBoxW) ein. Schmale Versionen der API erwarten u8-Byte-Strings, die mit der Codepage des aktuellen Threads kodiert sind, während breite Versionen UTF-16 erwarten.

Als allgemeine Empfehlung sollten Sie breite Versionen bevorzugen; es ist viel einfacher, zwischen den UTF-8-Strings von Rust und den UTF-16-Strings von Windows zu konvertieren.

Aufrufen von APIs, die Strings verbrauchen

Werfen wir einen Blick auf ein Beispiel mit der einfachen MessageBox-Funktion, die ein Pop-up-Dialogfeld anzeigt. Wir verwenden für dieses Beispiel die breite Version (MessageBoxW).

Wenn Sie eine Windows-API mit einem String-Literal aufrufen möchten, bietet die windows-strings-Kiste Makros, um Windows-Strings aus String-Literalen zu generieren.

  • h! generiert ein HSTRING aus einem String-Literal, fügt einen Nullterminator hinzu und konvertiert es in UTF-16.
  • w! macht dasselbe, generiert aber stattdessen ein PCWSTR anstelle eines HSTRING.
  • s! generiert ein PCSTR mit einem Nullterminator. *Vorsicht: Dies führt keine Konvertierungen durch, sondern fügt lediglich einen Nullterminator hinzu.*

Wenn wir die Message Box aufrufen wollten, könnten wir die Windows-Kiste mit dem Feature Win32_UI_WindowsAndMessaging verwenden und Folgendes aufrufen:

#![allow(unused)]
fn main() {
// use string literals when calling a message box. 
let text = h!("Hello from rust!");
let caption = h!("From Rust");

unsafe {
    // call the MessageBox function and return MESSAGEBOX_RESULT
    UI::WindowsAndMessaging::MessageBoxW(None, 
        text, 
        caption,
        UI::WindowsAndMessaging::MESSAGEBOX_STYLE(0) // message box OK
    )
}
}

Das funktioniert, aber was ist, wenn wir dieselbe Funktion mit einem Rust-String aufrufen wollen? Das ist etwas komplizierter. Wir könnten manuell in eine UTF-16-Byte-Sequenz konvertieren und selbst den Nullterminator hinzufügen, so:

#![allow(unused)]
fn main() {
// this works for any &str, not just literals
let text = "I am a message to display!";
let caption = "Message from Rust!";

// convert our text and caption to UTF-16 bytes,
// add null terminators using chain, and then collect
// the result into a vec
let text = text.encode_utf16()
    .chain(iter::once(0u16))
    .collect::<Vec<u16>>();
let caption = caption.encode_utf16()
    .chain(iter::once(0u16))
    .collect::<Vec<u16>>();

// call the API, wrapping our vec pointer in a PCWSTR struct.
unsafe {
    UI::WindowsAndMessaging::MessageBoxW(None, 
        PCWSTR(text.as_ptr()), 
        PCWSTR(caption.as_ptr()),
        UI::WindowsAndMessaging::MESSAGEBOX_STYLE(0) // message box OK
    )
}
}

Das ist jedoch umständlich. Wir können die Komfortfunktionen in der windows-strings-Kiste verwenden, um dies viel einfacher zu machen, indem wir die Rust-Strings in HSTRING konvertieren.

#![allow(unused)]
fn main() {
let text = "I am a message to display!";
let caption = "Message from Rust!";

// convert our strings into UTF-16 
// this incurrs a performance cost because there is a copy + conversion
// from the standard rust utf-8 string. 

// we are using HSTRING, which is an immutable UTF-16 string
// in the windows-strings crate. It can be generated from a standard
// rust string, and it can be used in place of a PCWSTR anywhere in the
// windows API. 

unsafe {
    UI::WindowsAndMessaging::MessageBoxW(None, 
        &HSTRING::from(text), 
        &HSTRING::from(caption),
        UI::WindowsAndMessaging::MESSAGEBOX_STYLE(0) // message box OK
    )
    }
}

Das ist viel ergonomischer – es kümmert sich für Sie um die Nullterminierung und die UTF-16-Konvertierung.

Aufrufen von APIs, die Strings generieren

Windows-APIs, die Strings generieren, erfordern normalerweise einen zweistufigen Aufruf. Beim ersten Aufruf der API übergeben Sie einen NULL-Zeiger für den String-Puffer und rufen die Länge des zu generierenden Strings ab.

Dies ermöglicht es Ihnen, den Puffer entsprechend zu allokieren und die Funktion dann erneut mit einem entsprechend dimensionierten Puffer aufzurufen.

Für dieses Beispiel verwenden wir die GetComputerNameW-Funktion. Dies erfordert das Feature Win32_System_WindowsProgramming aus der Windows-Kiste.

#![allow(unused)]
fn main() {
let mut buff_len = 0u32;

unsafe {
    // this function will return an error code because it
    // did not actually write the string. This is normal.
    let e = GetComputerNameW(None, &mut buff_len).unwrap_err();
    debug_assert_eq!(e.code(), HRESULT::from(ERROR_BUFFER_OVERFLOW));
}

// buff len now has the length of the string (in UTF-16 characters)
// the function would like to write. This *does include* the
// null terminator. Let's create a vector buffer and feed that to the function.
let mut buffer = Vec::<u16>::with_capacity(buff_len as usize);

unsafe {
    WindowsProgramming::GetComputerNameW(
        Some(PWSTR(buffer.as_mut_ptr())), 
        &mut buff_len).unwrap();

    // set the vector length
    // buff_len now includes the size, which *does not include* the null terminator.
    // let's set the length to just before the terminator so we don't have to worry
    // about it in later conversions.
    buffer.set_len(buff_len);
}

// we can now convert this to a valid Rust string
// omitting the null terminator
String::from_utf16_lossy(&buffer)
}

Es lohnt sich, auf die Funktionsweise des Längenparameters einzugehen. Für GetComputerNameW:

  • Bei der Eingabe repräsentiert sie die Größe des Puffers *einschließlich des Nullterminators* in wchar.
  • Wenn die Funktion einen Pufferüberlauf zurückgibt, ist der zurückgegebene Längenparameter die benötigte Puffergröße *einschließlich des Nullterminators* in wchars.
  • Wenn die Funktion erfolgreich in den Puffer geschrieben hat, ist die Länge die Anzahl der geschriebenen wchars, *ohne den Nullterminator*.

Dieses Verhalten ist in der Dokumentation der Funktion dokumentiert – bei der Verwendung der Windows-API seien Sie vorsichtig und prüfen Sie, was die Funktion in Bezug auf Nullterminatoren erwartet.

Unabhängig davon funktioniert dies, aber wir können es besser machen. Computernamen sind nur bis zu MAX_COMPUTERNAME_LENGTH lang, was magere 16 Zeichen sind. Wir können hier eine Heap-Allokation vermeiden und einfach Arrays verwenden, da wir unsere Pufferlänge zur Kompilierzeit kennen.

#![allow(unused)]
fn main() {
// avoid the heap allocation since we already know how big this 
// buffer needs to be at compile time. 

let mut name = [0u16; MAX_COMPUTERNAME_LENGTH as usize + 1];
let mut len = name.len() as u32;

// we can also skip the two-step call, since we know our buffer
// is already larger than any possible computer name

unsafe {
    GetComputerNameW(
        Some(PWSTR(name.as_mut_ptr())), 
        &mut len)
        .unwrap();
}

// the function writes to len with the number of 
// UTF-16 characters in the string. We can use this
// to slice the buffer. 
String::from_utf16_lossy(&name[..len as usize])
}

Wenn wir jedoch die Heap-Allokation (und ein paar zusätzliche Systemaufrufe) nicht scheuen, gibt es eine ergonomischere Option. Die windows-strings-Kiste enthält HStringBuilder, das wir anstelle des Arrays verwenden können. Dies ermöglicht uns einfachere Konvertierungen.

#![allow(unused)]
fn main() {
// pre-allocate a HSTRING buffer on the heap
// (you do not need to add one to len for the null terminator,
// the hstring builder will handle that automatically)

let mut buffer = HStringBuilder::new(
    MAX_COMPUTERNAME_LENGTH as usize);

let mut len = buffer.len() as u32 + 1;

unsafe {
    GetComputerNameW(
        Some(PWSTR(buffer.as_mut_ptr())), 
        &mut len).unwrap();
}

// we can now generate a valid HSTRING from the HStringBuilder
let buffer = HSTRING::from(buffer);

// and we can now return a rust string from the HSTRING:
buffer.to_string_lossy()
}

Wenn Sie direkt mit UTF-16-Strings arbeiten müssen, sollten Sie die widestring-Kiste in Betracht ziehen, die UTF-16-bewusst ist. Dies ermöglicht es Ihnen, Elemente zu pushen/poppen/anzufügen, ohne den String in einen nativen Rust-UTF-8-String konvertieren zu müssen. Zur Vollständigkeit hier ein Beispiel für die Rückgabe eines widestring und das Anhängen einiger Ausrufezeichen.

#![allow(unused)]
fn main() {
// for this example, we'll just use an array again

let mut name = [0u16; MAX_COMPUTERNAME_LENGTH as usize + 1];
let mut len = name.len() as u32;

unsafe {
    GetComputerNameW(
        Some(PWSTR(name.as_mut_ptr())), 
        &mut len)
        .unwrap();
}

// we can make a UTF16Str slice directly from the buffer,
// without needing to do any copy. This will error if the buffer
// isn't valid UTF-16. 
let wstr = Utf16Str::from_slice(&name[..len as usize])
    .unwrap();

// this can be displayed as is.
println!("Computer name is {}", wstr);

// we can also transfer it into owned string, which can
// be appended or modified. 
let mut wstring = Utf16String::from(wstr);

// let's append another string. We'll use a macro to avoid
// any UTF conversion at runtime. 
wstring = wstring + utf16str!("!!!");
}