Eine Dapper-Datenzugriffsschicht obfuskieren, ohne das Spalten-zu-Eigenschaft-Mapping zu zerstören
Dapper ordnet die Spaltennamen des Ergebnissatzes zur Materialisierungszeit den öffentlichen Membern Ihres POCO nach Namen zu, ohne auf Groß-/Kleinschreibung zu achten. Benennen Sie diese Member mit einem Obfuskator um, und Dapper hört stillschweigend auf zuzuordnen – keine Ausnahme, nur eine Eigenschaft, die auf null oder 0 bleibt. Hier ist das vollständige Bild: wie Dappers Standard-Typehandler eine Spalte genau an einen Member bindet, warum Ihr SQL-Text in der Regel sicher ist, die Member Ihres POCO aber fragil sind, die Ausweichmöglichkeiten per benutzerdefiniertem Mapping und die präzise nebula.config.json-Keep-List, die die Ergebnis-POCOs fixiert, während der Rest der Datenschicht umbenannt wird.
Ein Mikro-ORM wie Dapper verdient sich seine Geschwindigkeit, indem es sehr wenig Magie betreibt, doch das eine Stück Magie, das es behält, ist genau jenes, das mit der Obfuskation kollidiert. Wenn Sie connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers") schreiben, führt Dapper den Befehl aus, durchläuft den Ergebnissatz und baut für jede Zeile ein Customer, indem es jeden Spaltennamen aus dem Reader einem öffentlichen Member von Customer zuordnet, dessen Name übereinstimmt, ohne Beachtung der Groß-/Kleinschreibung. Dieser Abgleich erfolgt zur Laufzeit per Zeichenfolgenvergleich gegen den Membernamen. Benennen Sie Customer.FullName mit einem Obfuskator in a um, und die Spalte FullName hat nichts mehr, woran sie sich binden könnte – Dapper lässt die Eigenschaft also auf ihrem Standardwert und macht weiter. Keine Ausnahme. Keine Protokollzeile. Nur ein Customer, dessen FullName null ist.
Diese Stille macht das Thema eines sorgfältigen Artikels würdig. Ein kaputter LINQ-Provider wirft eine Ausnahme; ein kaputtes Dapper-Mapping liefert ein plausibel aussehendes Objekt mit Löchern, und Sie erfahren es in der Produktion. Die gute Nachricht: Die fragile Oberfläche ist klein und genau identifizierbar, Sie brauchen also nicht die übliche grobe Reaktion, Ihre gesamte Datenschicht auszuschließen. Dies ist die Bestandsaufnahme dessen, was Dapper nach Namen liest, warum der Rest sicher ist, und die exakte Nebula.NET-Keep-List, die die fragilen Member fixiert, während alles andere umbenannt wird.
Wie Dapper eine Spalte an einen Member bindet
Dappers Standard-Typehandler SqlMapper baut für jedes Paar (Typ, Spaltenmenge) einen Materialisierer und legt ihn im Cache ab. Die Baulogik ist hier der entscheidende Teil. Für einen Typ T mit parameterlosem Konstruktor zählt Dapper die Spalten des Readers auf und sucht für jede ein zuweisbares Ziel auf T, dessen Name unter einem ordinalen Vergleich ohne Beachtung der Groß-/Kleinschreibung dem Spaltennamen gleicht. Es bevorzugt eine öffentliche zuweisbare Eigenschaft; andernfalls bindet es ein öffentliches Feld. Findet es für eine Spalte keinen Member, wird diese Spalte einfach übersprungen. Hat ein Member keine passende Spalte, bleibt er auf dem, was der Konstruktor erzeugt hat: null, 0, false, default.
Daraus folgen unmittelbar drei Konsequenzen, und alle drei bestimmen die Keep-List:
- Der Abgleich erfolgt nach Namen, ohne Beachtung der Groß-/Kleinschreibung.
FullNamebindet an eine SpalteFullName,fullnameoderFULLNAME. Es bindet nicht an einen inaumbenannten Member. Die Groß-/Kleinschreibungs-Faltung hilft Ihnen über eine Umbenennung hinweg nicht; nur der exakte (schreibungsunabhängige) Name überlebt. - Nur öffentliche, zuweisbare Member nehmen teil. Dapper schreibt über einen öffentlichen Eigenschafts-Setter oder ein öffentliches Feld. Ein privates Hintergrundfeld ist für den Standard-Mapper unsichtbar, weshalb Obfuskatoren, die private Felder umbenennen, für Dapper in der Regel harmlos sind – und weshalb gerade die öffentlichen Member fixiert werden müssen.
- Eine fehlende Übereinstimmung ist stumm. Im Standardpfad gibt es keinen strikten Modus. Das Fehlen einer Bindung ist zur Laufzeit nicht von einer Spalte zu unterscheiden, die rechtmäßig
NULLzurückgab.
Warum das SQL sicher, der POCO aber fragil ist
Es lohnt sich, genau zu sein, was der Obfuskator berührt und was nicht, denn diese Asymmetrie ist der eigentliche Grund, warum das Problem beherrschbar ist.
Das SQL in connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers") ist ein Zeichenfolgenliteral. Nebula kann dieses Literal verschlüsseln, damit es nicht im Klartext in Ihrer Assembly liegt, aber die Verschlüsselung ist zur Laufzeit transparent: Die entschlüsselte Zeichenfolge, die an die Datenbank übergeben wird, ist Byte für Byte das, was Sie geschrieben haben. Die Datenbank sieht SELECT Id, FullName, Email und liefert Spalten namens Id, FullName, Email zurück. Spaltennamen als Zeichenfolgenliteral im SQL überstehen die Obfuskation daher unversehrt – sie waren nie Bezeichner in Ihrer IL, sondern nur Daten.
Der POCO ist das Gegenteil. Customer.FullName ist ein echter CLR-Member, ein Bezeichner in den Metadaten, und Bezeichner umzubenennen ist genau das, was ein Obfuskator tut. Die beiden Enden von Dappers Abgleich altern unter der Obfuskation also unterschiedlich: Das Ende des Spaltennamens (aus dem SQL) ist stabil, und das Ende des Membernamens (aus dem POCO) bewegt sich. Der Abgleich, der früher aufging, geht nicht mehr auf. Das Fixieren der POCO-Member richtet beide Enden wieder aufeinander aus.
Hier sind der POCO und der Aufruf, so geschrieben, wie sie in ausgeliefertem Code aussehen sollten:
public sealed class Customer
{
public int Id { get; set; }
public string FullName { get; set; } = "";
public string Email { get; set; } = "";
}
public sealed class CustomerRepository
{
private readonly IDbConnection _db;
public CustomerRepository(IDbConnection db) => _db = db;
public Customer? GetById(int id) =>
_db.QuerySingleOrDefault<Customer>(
"SELECT Id, FullName, Email FROM Customers WHERE Id = @Id",
new { Id = id });
}
Zwei Namen sind hier zur Laufzeit tragend, und kein anderer ist es. Die öffentlichen Member von Customer (Id, FullName, Email) werden mit den Ergebnisspalten abgeglichen. Die Member des anonymen Parameterobjekts new { Id = id } werden mit dem Platzhalter @Id abgeglichen – Dapper liest den Eigenschaftsnamen Id des Objekts und bindet ihn an @Id, wiederum nach Namen. Die Klasse CustomerRepository, das Feld _db, die Methode GetById, der Parameter id: Nichts davon wird zur Laufzeit nach Namen aufgelöst, sodass all das umbenannt werden kann.
Die Parameterbindung hat dieselbe Namensabhängigkeit
Die Parameterseite wird leicht übersehen, weil sie weniger sichtbar ist. Wenn Sie new { Id = id } übergeben, erzeugt Dapper aus dem Membernamen einen Parameter namens Id und gleicht ihn mit dem Token @Id im SQL nach Namen ab. Benennen Sie diesen Member um, und der Platzhalter @Id hat keinen Wert zum Binden, was tatsächlich eine Ausnahme wirft (ein fehlender Parameter ist nicht stumm wie eine fehlende Spalte), aber dennoch ein durch Umbenennung verursachter Bruch ist. Für anonyme Typen ist das in der Praxis meist kein Problem, da die Eigenschaftsnamen der anonymen Typen des C#-Compilers von den meisten Obfuskatoren standardmäßig nicht umbenannt werden – wenn Sie jedoch eine benannte Parameterklasse verwenden, sind ihre öffentlichen Member genauso fragil wie ein Ergebnis-POCO und gehören ebenfalls auf die Keep-List.
Die Keep-List: Fixieren Sie die gemappten Member, benennen Sie den Rest um
Nebulas Umbenennungsdurchlauf wird über nebula.config.json gesteuert. Die Strategie besteht darin, das Umbenennen global einzuschalten und dann den kleinen Satz von Membern herauszuschneiden, die Dapper nach Namen auflöst. Die sauberste Einheit zum Fixieren ist der POCO-Typ (und seine Member); die Repositories und Hilfsklassen überlässt man dem Standard-Umbenennen.
{
"input": "bin/Release/net8.0/Acme.Data.dll",
"rename": {
"enabled": true,
"keep": [
{
"type": "Acme.Data.Models.Customer",
"members": "public",
"reason": "Dapper binds result columns to these public members by name"
},
{
"namespace": "Acme.Data.Models",
"kind": "type",
"members": "public",
"reason": "all query-result POCOs live here; pin their public surface"
}
]
},
"strings": { "encrypt": true },
"controlFlow": { "enabled": true }
}
Der Geltungsbereich "members": "public" ist das entscheidende Detail: Er bewahrt die öffentlichen Eigenschafts- und Feldnamen, die Dapper liest, und lässt Nebula zugleich die privaten Hintergrundfelder umbenennen, die Dappers Standard-Mapper nie berührt. Wenn Sie Ihre POCOs in einem eigenen Namensraum halten (oben Acme.Data.Models), fixiert die Namensraumregel die ganze Familie in einem einzigen Eintrag, sodass ein später hinzugefügtes neues Abfrageergebnis ohne Konfigurationsänderung abgedeckt ist.
Wenn Sie eine benannte Parameterklasse statt anonymer Typen verwenden, fixieren Sie sie auf dieselbe Weise:
{
"type": "Acme.Data.Parameters.CustomerQuery",
"members": "public",
"reason": "Dapper binds @-parameters to these members by name"
}
Alles, was nicht in keep benannt ist, wird umbenannt. CustomerRepository, Ihre Verbindungsfabrik, jede interne SQL-bauende Hilfsklasse: Ihre Bezeichner tragen für Dapper zur Laufzeit keine Bedeutung, sie werden also zu a, b, c, und Ihre geschützte Assembly hält ihre Logik undurchsichtig, während ihr Daten-Mapping weiter funktioniert.
Die Ausweichmöglichkeit: Namen mit einem benutzerdefinierten Type-Map entkoppeln
Wenn Sie lieber keinen Namen bewahren möchten – und die Umbenennungsfläche bis in Ihre POCOs maximieren –, bietet Dapper eine Möglichkeit, die Spalten-zu-Member-Bindung explizit zu machen, sodass sie nicht mehr vom Membernamen abhängt. SqlMapper.SetTypeMap installiert einen Mapper pro Typ, und CustomPropertyTypeMap lässt Sie jede Spalte über einen Delegaten zu einem Member auflösen. Steuern Sie diesen Delegaten über ein Attribut im Stil von [Column] statt über den Membernamen:
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ColumnAttribute : Attribute
{
public ColumnAttribute(string name) => Name = name;
public string Name { get; }
}
public sealed class Customer
{
[Column("Id")] public int Id { get; set; }
[Column("FullName")] public string FullName { get; set; } = "";
[Column("Email")] public string Email { get; set; } = "";
}
// Register once at startup, before the first query.
SqlMapper.SetTypeMap(typeof(Customer), new CustomPropertyTypeMap(
typeof(Customer),
(type, columnName) => type
.GetProperties()
.FirstOrDefault(p => p
.GetCustomAttributes(typeof(ColumnAttribute), false)
.Cast<ColumnAttribute>()
.Any(a => string.Equals(a.Name, columnName,
StringComparison.OrdinalIgnoreCase)))));
Nun läuft die Bindung über die Zeichenfolge "FullName" im Attribut, die ein Literal ist, das Nebula verschlüsselt, aber nie umbenennt – genau wie der SQL-Spaltenname. Der Member, den es dekoriert, ist frei: Nebula kann FullName in a umbenennen, und das benutzerdefinierte Map löst die Spalte FullName weiterhin zu ihm auf, denn der Auflösungsschlüssel ist der Attributwert, nicht der Member-Bezeichner. Bewahren Sie die von der Reflexion verwendeten Attribut-Member (ColumnAttribute.Name und den Typ) auf der Keep-List, da der Delegat sie liest, und Sie können die POCO-Eigenschaftsnamen vollständig aus keep entfernen.
Das ist der Kompromiss zwischen robust und bequem. Die Membernamen zu bewahren (der erste Ansatz) ist einfacher, erfordert keine Codeänderungen und lässt einige wenige öffentliche Bezeichner lesbar. Das benutzerdefinierte Map verbirgt sogar diese Bezeichner, fügt aber eine Registrierung hinzu, die Sie nicht vergessen dürfen, sowie geringe Reflexionskosten bei der ersten Materialisierung. Für die meisten ausgelieferten Codes ist die Keep-List der richtige Standard; greifen Sie zum benutzerdefinierten Map, wenn die Eigenschaftsnamen eines bestimmten POCO selbst sensibel sind.
Den Schutz ausführen und in den Build einbinden
Mit der Konfiguration an Ort und Stelle ist das Schützen der Assembly ein einziger Befehl:
nebula protect --config nebula.config.json --verbose
Nebula liest die Eingabe-Assembly, wendet das Umbenennen unter Beachtung Ihrer Keep-List an, verschlüsselt Zeichenfolgen, verflacht den Kontrollfluss und schreibt die geschützte Assembly anstelle der Eingabe (oder an den output-Pfad, wenn Sie einen festlegen). Die Option --verbose gibt die Zusammenfassung der Umbenennungstabelle aus, die man einmal durchsehen sollte, um zu bestätigen, dass Ihre POCO-Member als bewahrt und Ihre Repository-Typen als umbenannt erscheinen.
Zum Automatisieren hängen Sie es mit einem MSBuild-Ziel nach dem Veröffentlichen ein, damit jeder veröffentlichte Build ohne manuellen Schritt geschützt wird:
<Target Name="NebulaProtect" AfterTargets="Publish">
<Exec Command="nebula protect --config $(MSBuildProjectDirectory)/nebula.config.json --input $(PublishDir)Acme.Data.dll"
WorkingDirectory="$(MSBuildProjectDirectory)" />
</Target>
Da das Ziel mit AfterTargets="Publish" läuft, arbeitet es auf der veröffentlichten Ausgabe statt auf dem Zwischen-Build, sodass Ihre Debug-Builds in der inneren Schleife schnell und unobfuskiert bleiben, während Release-Veröffentlichungen geschützte Binärdateien ausliefern. (Wenn Sie eine Nebula-Pro-Lizenz besitzen, laufen Kontrollfluss- und Zeichenfolgenschutz gemäß Konfiguration auf der veröffentlichten Ausgabe; die kostenlose Stufe wendet den Umbenennungsdurchlauf an, der der Teil ist, von dem dieser Artikel abhängt.)
Mit Glass überprüfen, bevor Sie ausliefern
Der letzte Schritt besteht darin, zu bestätigen, dass das Umbenennen tatsächlich das getan hat, was die Keep-List beabsichtigte – dass die fragilen Member überlebt haben und der Rest nicht. Öffnen Sie die geschützte Assembly in Glass, dem kostenlosen .NET-Decompiler, und sehen Sie sich zwei Dinge an. Zuerst Ihren POCO: Customer sollte weiterhin Id, FullName, Email als öffentliche Eigenschaften mit ihren ursprünglichen Namen zeigen – das ist der Beweis, dass Dapper die Spalten weiterhin binden wird. Zweitens ein Repository: CustomerRepository und seine Member sollten in kurze, undurchsichtige Bezeichner umbenannt erscheinen, was beweist, dass der Rest der Datenschicht tatsächlich geschützt und nicht versehentlich in die Keep-List gefegt wurde.
Wenn Sie eine umbenannte POCO-Eigenschaft sehen, wird Dapper zur Laufzeit stillschweigend Nullwerte dafür zurückgeben – dieser Decompiler-Durchgang ist also nicht optional, er ist der denkbar billigste Ersatz für die Ausnahme, die Dapper nie wirft. Ein schneller Rundlauf – schützen, dekompilieren, die beiden Formen in Augenschein nehmen und dann einen Integrationstest ausführen, der eine Zeile zurückliest und prüft, dass jede Spalte befüllt ist – verwandelt eine Klasse stiller Produktionsfehler in etwas, das Sie auf demselben Build abfangen, der sie erzeugt hat.
Die Form der Regel
Dappers Vertrag mit Ihrem Code ist ein kleiner, namensbasierter: Ergebnisspalten binden an öffentliche POCO-Member nach Namen, und die Member anonymer oder benannter Parameter binden an @Platzhalter nach Namen. Das sind die einzigen Namen, die es zur Laufzeit auflöst, und die einzigen, die Sie fixieren müssen. Alles andere in einer Datenzugriffsschicht – die Repositories, die Verbindungsverwaltung, die Abfragekonstruktion – hat keine solche Abhängigkeit und darf umbenannt, in seinen Zeichenfolgen verschlüsselt und in seinem Kontrollfluss verflacht werden wie jeder andere Code, den Sie schützen. Fixieren Sie die gemappten Member in nebula.config.json oder entkoppeln Sie sie mit einem benutzerdefinierten Type-Map, überprüfen Sie das Ergebnis in Glass, und Sie liefern eine Datenschicht aus, die für einen Reverse-Engineer undurchsichtig und für Dapper transparent ist.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.