Eine App obfuskieren, die sich per Konvention selbst registriert: Assembly-Scanning-DI bindet an Typnamen, also hebt Umbenennen Ihre Service-Registrierungen auf
Sehr viele .NET-Apps schreiben ihre DI-Registrierungen nie aus. Sie scannen beim Start eine Assembly – mit Scrutor oder einer handgeschriebenen Reflection-Schleife – und registrieren jeden Typ, dessen Name auf „Service“, „Handler“ oder „Repository“ endet. Diese Konvention ist ein String-Vergleich gegen den Typnamen, und Renaming-Obfuskierung ändert jeden einzelnen dieser Namen, sodass das Prädikat t.Name.EndsWith("Handler") nichts mehr trifft, die Services stillschweigend nie registriert werden und die App beim ersten Request „Unable to resolve service for type ...“ wirft – ein Laufzeitfehler bei grünem Build, weit entfernt von der Zeile, die ihn verursacht hat. Dieser Beitrag trennt die zwei Arten, wie ein Scan einen Typ finden kann: über seinen Namen, der ein Rename nicht überlebt, und über seine Gestalt – zuweisbar an ein Marker-Interface, mit einem Attribut dekoriert, von einer Basisklasse abgeleitet –, die es überlebt, weil das Metadaten-Token-Identitäten sind, die der Rename auf beiden Seiten konsistent umschreibt. Er zeigt die Scrutor-Registrierungen, die brechen, und die äquivalenten, die überleben, erklärt, warum AsImplementedInterfaces unproblematisch ist, das Namensprädikat aber nicht, zieht die Linie, die Nebula zieht, liefert sowohl das empfohlene Refactoring als auch die Exclude-Regel für den Fall, dass Sie nicht refaktorieren können, und endet mit einem Startup-Smoke-Test, der den obfuskierten Build schnell scheitern lässt statt erst beim ersten Request.
Sehr viele .NET-Anwendungen schreiben ihre Service-Registrierungen nie wirklich hin. Sie scannen. Beim Start durchkämmt eine App eine Assembly, findet jeden Typ, der wie ein Service aussieht – meist über eine Namenskonvention – und registriert ihn. Scrutor hat daraus einen Einzeiler gemacht, und handgeschriebene Reflection-Schleifen taten es lange davor. Es ist ordentlich, es ist Convention-over-Configuration, und es hat unter Obfuskierung einen spezifischen, stillen Fehlermodus: Die Konvention ist fast immer ein String-Vergleich gegen den Typnamen, und Renaming ändert jeden Namen.
Das Ergebnis ist die schlimmste Form, die ein Bug annehmen kann. Der Build ist grün – der Scan ist gültiger Code. Die App startet – der Scan läuft ohne Fehler durch. Und dann bittet der erste Request den Container um IOrderService, der Container hat davon nie gehört, und Sie bekommen Unable to resolve service for type 'IOrderService', das auf den Controller zeigt, nirgendwo in der Nähe des Scans, der stillschweigend nichts registriert hat. Dieser Beitrag erklärt, warum das bei Nebula passiert, und die Ein-Zeilen-Änderung, die es beendet.
Die Registrierung, die bricht
Hier ist die Form, die am häufigsten geschrieben wird – die Assembly scannen, alles nehmen, dessen Name auf ein Konventionswort endet, und es gegen seine Interfaces registrieren:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(c => c.Where(t => t.Name.EndsWith("Service"))) // <-- string match on the name
.AsImplementedInterfaces()
.WithScopedLifetime());
Lesen Sie, was dieses Prädikat tatsächlich tut: Beim Start enumeriert es die Typen der Assembly und behält die, deren Name auf die sieben Zeichen S-e-r-v-i-c-e endet. Vor der Obfuskierung sind das OrderService, PricingService, InventoryService. Nachdem Nebula sie in a, b, c umbenannt hat, fragt das Prädikat jeden von ihnen „endet dein Name auf ‘Service’?“, und jeder antwortet nein. Der Scan behält null Typen, AsImplementedInterfaces hat nichts zu binden, und IOrderService wird nie registriert.
Nichts daran ist ein Bug, den der Compiler oder der Obfuscator fangen könnte. Das Prädikat ist gültig. Der Rename hat genau getan, was ihm gesagt wurde. Die beiden Tatsachen sind schlicht unvereinbar, und die Unvereinbarkeit wird erst sichtbar, wenn etwas versucht, einen Service aufzulösen, den der leere Scan nie hinzugefügt hat.
Die Unterscheidung, die zählt: Name vs. Gestalt
Nicht jeder Scan bricht. Der Unterschied liegt darin, ob der Scan einen Typ über seinen Namen oder über seine Gestalt findet, und diese Linie ist das Ganze. Ein Name ist ein String, den Renaming umschreibt – und das String-Literal in Ihrem Prädikat wird nicht mit umgeschrieben, sodass der Vergleich veraltet. Eine Gestalt – implementiert dieses Interface, trägt dieses Attribut, leitet von dieser Basis ab – ist eine Beziehung im Metadatengraphen, und Renaming ist eine konsistente Umschreibung dieses Graphen: Es benennt das Interface und den implementierenden Typ gemeinsam um, sodass „implementiert“ weiterhin gilt.
// SURVIVES — matches on the implements-relationship, a metadata token identity
.AddClasses(c => c.AssignableTo<ICommandHandler>())
// SURVIVES — matches on an attribute type token; renaming keeps attribute and target in sync
.AddClasses(c => c.WithAttribute<ServiceAttribute>())
// BREAKS — matches on a frozen string the rename cannot reach
.AddClasses(c => c.Where(t => t.Name.EndsWith("Handler")))
// BREAKS WORSE — resolves a type from a name string that no longer names anything
.AddSingleton(Type.GetType("Acme.Orders.OrderService")!)
Die Lösung ist eine Zeile, und sie holt die Namen zurück
Geben Sie der Konvention einen Marker und scannen Sie danach statt nach der Schreibweise. Ein Marker-Interface ist am saubersten, weil es zur Laufzeit nichts kostet und genau das sagt, was die Konvention meinte:
public interface IAppService { } // marker; renaming keeps it resolvable by token
public sealed class OrderService : IAppService, IOrderService { /* ... */ }
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(c => c.AssignableTo<IAppService>()) // <-- shape, not name
.AsImplementedInterfaces()
.WithScopedLifetime());
Jetzt fragt der Scan jeden Typ „implementierst du IAppService?“, was die Metadaten vor und nach der Obfuskierung korrekt beantworten. Nebula darf OrderService, IOrderService und jeden anderen Typ der Schicht frei umbenennen, weil der Scan keinen einzigen Namen mehr liest – das Einzige, wovon er abhängt, ist die Implements-Beziehung, und die bewahrt Renaming per Konstruktion. Sie bekommen die Obfuskierung, die Sie wollten, und die Konventionsregistrierung, die Sie wollten, ohne Überschneidung zwischen beiden.
Ein Attribut funktioniert identisch, wenn Sie der Typhierarchie lieber kein Interface hinzufügen möchten:
[Service(ServiceLifetime.Scoped)]
public sealed class PricingService : IPricingService { /* ... */ }
.AddClasses(c => c.WithAttribute<ServiceAttribute>())
Das Attribut wird über sein Typ-Token gematcht, das der Rename auf der Attributklasse und auf jedem damit dekorierten Typ konsistent hält.
Wenn Sie den Scan nicht ändern können
Manchmal steckt der Scan in einer Bibliothek, die Ihnen nicht gehört, und filtert auf einen Namen, den Sie nicht beeinflussen können. Die Notlösung ist, die Namen zu behalten, auf die der Scan angewiesen ist – aber sie eng zu behalten und die Kosten zu verstehen. Eine Nebula-Konfiguration schließt nur die konventionsbenannten Typen vom Renaming aus:
{
"inputs": [ "bin/Release/net8.0/Acme.Orders.dll" ],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "*Service", "action": "keep" },
{ "pattern": "*Handler", "action": "keep" },
{ "pattern": "*Repository", "action": "keep" },
{ "pattern": "Acme.Orders.*", "action": "rename" }
]
}
}
Seien Sie sich über den Tausch im Klaren: Jede keep-Regel hier lässt einen Klassennamen in den Metadaten stehen, und die Menge der Namen, die Sie preisgeben, ist genau Ihre Service-Schicht – die Landkarte dessen, was die App tut. Je weiter vorn die Regel steht, desto mehr gewinnt sie über das breite rename, sodass ein *Service-Keep den Namen jedes Service festnagelt. Deshalb ist das Refactoring die bessere Antwort, wann immer der Scan Ihnen gehört: Ein Prädikat von Name.EndsWith auf AssignableTo umzustellen verwandelt den ganzen keep-Block zurück in rename und gibt die Namen an den Obfuscator zurück. Nutzen Sie das Exclude nur für den Drittanbieter-Scan, an den Sie wirklich nicht herankommen.
Schnell scheitern statt beim ersten Request
Der Grund, warum dieser Bug teuer ist, ist Distanz – der leere Scan und die fehlgeschlagene Auflösung liegen weit auseinander. Schließen Sie die Distanz mit einem Startup-Smoke-Test, der prüft, dass die Services, die Sie erwarten, tatsächlich registriert sind, und führen Sie ihn gegen den obfuskierten Build aus, denn das ist der einzige Build, in dem der Scan leer ausgehen kann:
[Fact]
public void Expected_services_are_registered_after_scanning()
{
using var provider = BuildProductionServiceProvider(); // same Scan(...) as the app
// Resolve the way the app will — if the scan registered nothing, this throws here,
// in a test, instead of on the first production request.
Assert.NotNull(provider.GetService<IOrderService>());
Assert.NotNull(provider.GetService<IPricingService>());
// Or assert the count the convention should have produced:
var handlers = provider.GetServices<ICommandHandler>().ToList();
Assert.Equal(expected: 7, handlers.Count);
}
BuildServiceProvider(validateOnBuild: true) fängt das von sich aus nicht – es validiert, dass die vorhandenen Registrierungen konstruiert werden können, und eine fehlende Registrierung ist schlicht abwesend, nicht ungültig. Sie müssen das Vorhandensein dessen prüfen, was der Scan hätte finden sollen. Lassen Sie diesen Test in CI über die geschützte Assembly laufen, und ein Scan, den Renaming geleert hat, wird im Build rot, was genau der Ort ist, an dem Sie ihn haben wollen, statt in einem Produktions-Stacktrace, der dem Controller die Schuld gibt.
Das Fazit
Konventionsbasierte DI-Registrierung ist weit häufiger, als es aussieht, ein String-Vergleich gegen Typnamen, und Renaming-Obfuskierung ändert jeden einzelnen dieser Namen, während das String-Literal in Ihrem Prädikat eingefroren bleibt – sodass der Scan stillschweigend nichts registriert und die App bei der ersten Auflösung scheitert, einen grünen Build von der Ursache entfernt. Die Lösung ist, den Scan auf Gestalt statt auf Schreibweise matchen zu lassen: AssignableTo ein Marker-Interface oder WithAttribute ein Marker-Attribut, beides schreibt Renaming konsistent über den Metadatengraphen um, sodass der Scan Ihre Typen weiterhin findet, egal wie sie heißen. Diese eine Änderung erlaubt Nebula, Ihre gesamte Service-Schicht umzubenennen – die Namen, die Sie mit der Obfuskierung verbergen wollten –, während die Registrierung weiter funktioniert. Wenn der Scan nicht Ihrer ist, nageln Sie nur die konventionsbenannten Typen mit engen keep-Regeln fest und akzeptieren Sie, dass Sie diese Namen sichtbar lassen. So oder so: Beweisen Sie es mit einem Startup-Test, der die erwarteten Services gegen den obfuskierten Build auflöst, damit ein geleerter Scan in CI scheitert und nie in Produktion.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.