Ewolucja .NET: integracja AI i natywnych funkcji mobilnych w nowoczesnych aplikacjach internetowych
Milan Jovanović niedawno opublikował przekonujące argumenty przeciwko przedwczesnemu wersjonowaniu API. Główny punkt: większość zespołów sięga po v2 zbyt wcześnie, ponieważ brakuje im strategii ewolucji kontraktu. Wersjonowanie jest narzędziem kompatybilności, a nie strategią projektową.
Argument ten rezonuje z naszym zespołem inżynieryjnym w Iron Software. Wysyłamy biblioteki .NET, co oznacza, że publiczna powierzchnia naszych produktów jest API. Każda sygnatura metody, każda właściwość, każde domyślne zachowanie to umowa znajdująca się wewnątrz tysięcy kodów klientów. Podniesienie do nowej wersji głównej nie jest wydaniem. To projekt migracyjny dla każdego w dół strumienia.
Następuje spojrzenie programisty na artykuł Milana z perspektywy autora biblioteki oraz jak te same zasady kompatybilności mają zastosowanie, czy wysyłasz REST API, czy pakiet NuGet.
TL;DR
- Wersjonowanie nie jest strategią projektową. To wyjście awaryjne, gdy współistnienie zawodzi.
- Łamanie zmian kryje się w zachowaniu, nie tylko w URL-ach czy schematach.
- Cztery zasady kompatybilności: nie usuwaj, nie zmieniaj przetwarzania, nie zacieśniaj walidacji, pozostawaj dodawania opcjonalne.
- Nowa operacja jest prawie zawsze tańsza niż nowa wersja.
- Rzeczywista deprecjacja wymaga sygnałów runtime i telemetrii, a nie tylko aktualizacji dokumentacji.
Zasady HTTP mają zastosowanie do API biblioteki
Milan opiera dyskusję na REST API dla /orders, ale te same zasady dotyczą, gdy twoje API jest publiczną klasą C# dołączoną w pakiecie NuGet. Mapowanie jest bezpośrednie:
| Zmiana w REST API | Równoważnik w bibliotece NuGet |
|---|---|
| Zmiana nazwy pola JSON | Zmiana nazwy publicznej właściwości |
| Usunięcie punktu końcowego | Usunięcie publicznej metody |
| Zacieśnianie walidacji żądania | Dodanie parametru obowiązkowego, który nie przyjmuje wartości null |
| Zmiana zachowania operacji | Zmiana, co robi metoda w tle |
| Dodanie wymaganego pola | Dodanie wymaganego parametru konstruktora |
Jeśli kiedykolwiek ściągnąłeś główną wersję popularnej biblioteki .NET i spędziłeś pół dnia na naprawianiu przekształconych API, stawiłeś czoła decyzji v2, która mogła być prawdopodobnie obsłużona w sposób dodający.
Co faktycznie łamie konsumentów
Lista Milana jest precyzyjna:
- Usuwanie lub zmiana nazwy pól
- Zmiana znaczenia istniejących danych
- Zacieśnianie walidacji żądania
- Zmiana paginacji lub formatów błędów
- Zakładanie, że wartości typu enum są zamknięte na zawsze
Drugi element to ten, który najczęściej zaskakuje zespoły: zmiana znaczenia istniejących danych bez zmiany ich kształtu. JSON wygląda tak samo. Podpis C# wygląda tak samo. Wszystko się kompiluje. Nic nie wyrzuca błędów podczas działania. Ale pole teraz znaczy coś innego, i każdy konsument, który polegał na starych semantykach, jest cicho nieprawidłowy.
Przykład Milana:
// Before
{ "total": 100 }
// After
{ "total": { "amount": 100, "currency": "USD" } }Taka sama nazwa pola. Taki sam punkt końcowy. Każdy klient, który analizował total jako liczbę, teraz nie działa.
Odpowiednik w bibliotece to zmiana tego, co metoda zwraca lub jak interpretuje swoje dane wejściowe. Metoda Save(), która wcześniej nadpisywała, a teraz dodaje. Parametr Trim, którego domyślna wartość zmienia się z true na false. Metoda, która wcześniej wyrzucała wyjątek przy nieprawidłowym wejściu, a teraz zwraca wartość domyślną w ciszy.
Cztery zasady zgodności
Milan podsumowuje zasady jako: nie zabieraj niczego, nie zmieniaj zasad przetwarzania, nie czyn opcjonalnych rzeczy wymaganymi, a wszystko, co dodasz, musi być opcjonalne. Cztery zasady warte trzymania przed każdym zespołem odpowiedzialnym za publiczne API:
- Zostaw istniejące pola i zachowanie na miejscu.
- Nie przekształcaj opcjonalnych danych żądania w wymagane dane.
- Nie zmieniaj tego, co robi istniejąca operacja.
- Wszystko nowe dodawaj jako dodatki i ustaw jako domyślnie opcjonalne.
To bezpośrednio mapuje na projektowanie bibliotek. "Nie zabieraj niczego" oznacza nie usuwaj publicznych członków. "Nie zmieniaj zasad przetwarzania" oznacza, że istniejące metody powinny działać tak, jak wtedy, gdy zostały opublikowane. "Nie czyn opcjonalnych rzeczy wymaganymi" oznacza, żeby nie dodawać wymaganych parametrów do istniejącej metody; zamiast tego zapewnij przeciążenie. "Dodatkowo i opcjonalnie" oznacza, że nowa funkcjonalność powinna znajdować się w nowych metodach lub opcjonalnych parametrach z rozsądnymi domyślnymi wartościami.
Jak to wygląda w praktyce
Najprostszym sposobem zilustrowania tych zasad jest rzeczywista decyzja API, więc oto jedna z naszych.
Kilka wydań temu, IronPDF musiał wspierać bogatszy zestaw opcji renderowania dla konwersji HTML na PDF: niestandardowe rozmiary papieru, niestandardowe marginesy, emulacja mediów CSS, szablony nagłówków i stopek i inne. Prostym podejściem byłoby zmienienie istniejącej metody renderowania, aby zaakceptować nowe opcje. Taka decyzja zniszczyłaby każdemu klientowi używającemu prostego formularza API.
Dla kontekstu, biblioteka instaluje się poprzez standardowe kanały pakietowe .NET:
# .NET CLI
dotnet add package IronPdf
# Package Manager Console
Install-Package IronPdf# .NET CLI
dotnet add package IronPdf
# Package Manager Console
Install-Package IronPdfPakiet IronPdf NuGet zgromadził ponad 18 milionów pobrań, co jest częścią, dlaczego stabilność API ma znaczenie: każda zmieniająca zasada odbije się na tyle integracji.
Zamiast tego wysłane zostało podejście:
// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");' The original, three-year-old API. Still works. Still unchanged.
Dim renderer As New ChromePdfRenderer()
Dim pdf As PdfDocument = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")
' New rendering options live on an options object, not in the method signature.
renderer = New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4
renderer.RenderingOptions.MarginTop = 20
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print
renderer.RenderingOptions.HtmlHeader = New HtmlHeaderFooter With {.HtmlFragment = "..."}
pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")Trzy obserwacje dotyczące tej decyzji:
- Oryginalna sygnatura
RenderHtmlAsPdf(string html)pozostaje niezmieniona. Klienci, którzy dokonali aktualizacji, nie musieli modyfikować ani jednej linii kodu. - Nowe możliwości żyją na obiekcie opcji, do których konsumenci sami muszą dopasować. Metoda nie ma nowych wymaganych parametrów.
- Domyślne ustawienia
RenderingOptionsgenerują wynik równoważny z poprzednim API. Zachowanie nie zmienia się dla nikogo, kto nic nie skonfiguruje.
To są zasady 1, 2 i 4 z listy Milana zastosowane na raz. Produkt się rozwijał. Kontrakt nie.
Pokusa wysłania RenderHtmlAsPdfV2(string html, RenderingOptions options) była realna. Na stronie referencyjnej API wyglądałoby to schludniej. Kosztowałoby to jednak każdemu klientowi migrację. Wybraliśmy inaczej.
Lektura wyrozumialych
Drugą połową argumentu Milana "dodaj-nie-zastępuj" jest to, że konsumenci również mają odpowiedzialność. Dobry klient powinien ignorować pola, których nie rozumie.
W .NET System.Text.Json ignoruje nieznane właściwości domyślnie, co jest właściwym domyślnym zachowaniem. Ryzyko zwykle pojawia się w dwóch miejscach:
- Generowane SDK z ścisłymi schematami, które odrzucają nieoczekiwane pola
- Testy kontraktu, które sprawdzają dokładną równość JSON
Obie zamieniają deklarowane "ignoru-jemy nieznane pola" w gwarancję jako pułapkę. Jeśli Twój CI przestaje działać w momencie, gdy serwer dodaje nową opcjonalną właściwość, nie masz zgodności wstecz. Masz detektor regresji przebrany za politykę zgodności.
Zachowanie jest częścią kontraktu
Sekcja Milana o DELETE /orders/{id} cicho przechodzącym z miękkiego usunięcia na twarde usunięcie, jest najjaśniejszym opisem tego problemu, jaki widzieliśmy.
URL jest taki sam. Treść żądania jest taka sama. Kształt odpowiedzi jest taki sam. To, co operacja robi na serwerze, jest inne.
To jest najniebezpieczniejsza kategoria zmiany łamiącej, ponieważ nic w schemacie nie wychwytuje jej. Specyfikacja OpenAPI jest identyczna. Generowany klient się kompiluje. Testy integracji przechodzą. A każdy konsument, który zbudował narzędzia wokół "usunięte zamówienia są odzyskiwalne", cicho niszczy dane w produkcji.
Odpowiednik w bibliotece zmienia to, co metoda robi bez zmiany jej sygnatury. Przykłady, których świadomie unikaliśmy:
- Metoda
Save(), która wcześniej opróżniała synchronicznie, a teraz cicho staje się asynchroniczna. - Metoda OCR, która zwracała surowe wyniki i zaczynała je przetwarzać
- Czytnik kodów kreskowych, który rzucał wyjątek przy nieczytelnych danych wejściowych, a zaczyna zwracać pusty ciąg
Każde z tych to łamanie kontraktu przebrane za usprawnienie. Prawidłową odpowiedzią jest to samo co u Milana: dodaj nową metodę lub opcję, zostaw stare zachowanie niezmienione i deprecjonuj starą ścieżkę tylko wtedy, gdy telemetria wskazuje, że to bezpieczne.
Zacieśnianie walidacji
Ten temat ostatecznie dotknie każdy zespół. Istnieją dwie wersje tego samego błędu:
- Przekształcanie istniejącego opcjonalnego pola w wymagane
- Dodawanie nowego pola i oznaczanie go jako wymagane od pierwszego dnia
Oba załamują starsze klienty. Ścieżka punktu końcowego nie przesuwa się, ale żądania, które wcześniej się udawały, teraz zawodzą w czasie pracy.
Wersja błędu na poziomie biblioteki to dodanie wymaganego parametru konstruktora lub uczynienie istniejącego opcjonalnego parametru obligatoryjnym. Każde istniejące wywołanie łamie się w czasie kompilacji, co jest preferowane nad awarią w czasie wykonia, ale nadal narzuca koszty migracji każdemu konsumentowi.
Bezpieczniejsze ścieżki:
- Akceptowanie brakujących wartości w trakcie okna przejściowego i odgadywanie domyślnych wartości, gdzie to możliwe.
- Dodanie nowego przeciążenia lub konstruktora, który wymaga bogatszej formy danych wejściowych.
- Wprowadzenie nowej operacji lub konstruktora na surowszy przepływ pracy.
Podstawowa zasada pozostaje stała: wszystko dodawane do kontraktu musi być opcjonalne, a wszystko, co wcześniej było opcjonalne, musi pozostać opcjonalne. Jeśli rzeczywiście potrzebne są surowsze wymagania, powinny one znajdować się w nowej operacji, a nie w zacieśniającym istniejącej.
Nowa operacja jest prawie zawsze tańsza niż nowa wersja
To zasada, która najbardziej warto przyswoić.
Kiedy przypadek użycia ewoluował rzeczywiście poza to, co istniejący punkt końcowy czysto obsługuje, powszechnym odruchem jest przeciążenie punktu końcowego flagami:
POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=trueLub, bardziej zakłócająco, zadeklarować zmianę jako problem wersjonowania i rozpocząć pracę nad /v2/orders. Oba są zwykle błędne. Czystsze podejście to nowa operacja obok istniejącej:
POST /orders
POST /orders/quote
POST /checkout-sessionsKażda operacja ma czysty kontrakt, odrębne uprawnienia, niezależną walidację i swoją ścieżkę ewolucji. Oryginalny punkt końcowy pozostaje prosty. Reszta API nie jest wciągana do zwiększenia głównej wersji.
W kontekście biblioteki odpowiednik to dodanie nowej metody zamiast przeciążania istniejącej z opcjonalnymi parametrami, aż staje się nieczytelna. ExtractText() pozostaje prostym ekstraktorem tekstu. ExtractTextWithLayout() staje się bogatszym wariantem. ExtractStructuredDocument() staje się najbogatszym. Trzy metody z jasnymi kontraktami są lepsze niż jedna metoda z ośmioma opcjonalnymi parametrami.
Deprecjonowanie z rozmysłem
To jest połowa zarządzania zmianami API, której większość zespołów pomija, i to od niej zależy, czy strategia zadziała.
Prawdziwe deprecjonowanie to nie jest notatka w changelogu. Obejmuje cztery kroki:
- Oznaczyć pole lub punkt niezużywalny jako przestarzałe w opisie OpenAPI (lub za pomocą atrybutu
[Obsolete]w świecie .NET). - Sygnalizowanie deprecjonowania w czasie pracy, aby jego ruch na żywo mogła go wykryć.
- Powiązanie z rzeczywistym przewodnikiem migracyjnym.
- Pomiar użycia za pomocą telemetrii w celu ustalenia, kiedy usunięcie jest bezpieczne.
Dla HTTP API, sygnalizacja w czasie pracy jest prosta:
Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/migrations/orders-total>; rel="deprecation"Dla bibliotek .NET odpowiednikiem jest [Obsolete("Use NewMethod zamiast. Zostanie usunięte w v2026.x", DiagnosticId = "IRON001")] attribute paired with a UrlFormat wskazujący na stronę migracji. Ostrzeżenie kompilatora pojawia się w każdym wyniku kompilacji konsumenta, identyfikator diagnostyczny pozwala na świadome stłumienie, a link daje konsumentom udokumentowaną ścieżkę migracji.
Krok telemetrii nie podlega negocjacjom. Bez wiedzy, którzy klienci jeszcze nadal polegają na przestarzałej metodzie, usunięcie staje się zgadywaniem. Rezultatem jest albo przedwczesne usunięcie, które łamie aktywne integracje, albo nieokreślone koszty utrzymania, które niszczą sens deprecjacji.
Kiedy wersjonowanie jest słuszne
Milan nie jest antywersyjny, i my też nie. Wersjonowanie jest właściwe, gdy:
- Stare i nowe semantyki rzeczywiście nie mogą współistnieć
- Model zasobów zmienił się fundamentalnie
- Zasady zgodności wymusiłyby kontrakt, o którym nikt nie może rozmyślnie rozmyślać
Celem nie jest całkowite unikanie wersjonowania. Celem jest sięgnięcie po nie, ponieważ współistnienie zawiodło, a nie dlatego, że to był pierwszy pomysł na stole.
Gdy wersjonowanie jest konieczne, powinno być połączone z rzeczywistym procesem deprecjacji. Trudną pracą nie jest wysyłanie v2. Trudną pracą jest zdjęcie konsumentów z v1.
Zasada podejmowania decyzji
Ramowanie Milana to właściwe, które należy zastosować:
- Czy mogę dodać zamiast zastępować?
- Czy stare i nowe kontrakty mogą współistnieć w trakcie okna migracji?
- Czy mogę wprowadzić nową operację zamiast modyfikować starą?
- Czy mogę zdeprecjonować stary kształt za pomocą dokumentacji, nagłówków i telemetrii?
Jeśli odpowiedź na wszystkie cztery jest tak, nowa wersja prawdopodobnie nie jest potrzebna. Jeśli odpowiedź jest nie, i dwa światy rzeczywiście nie mogą współistnieć, wersjonuj z rozmysłem.
Projektuj kontrakty do ewolucji. Traktuj konsumentów jako długowieczne integracje, a nie dzisiejszy kod. Zarezerwuj wersjonowanie dla przypadków, w których rzeczywiście wyczerpano możliwości zgodności.
Dla pełnej wersji, w tym dłuższych przykładów pracujących, przeczytaj oryginalny post Milana.
Gdy wybierasz bibliotekę .NET, na której będziesz polegać, warto zadać pytanie, wokół którego zbudowane jest postanowienie Milana: czy ta biblioteka nadal będzie wyglądać jak API, z które zintegrowałem w ciągu trzech lat?
Na to pytanie staramy się odpowiedzieć przy każdym wydaniu. Proste wywołania z 2020 roku nadal działają. Nowe możliwości są obok nich, opcjonalne i dodatkiem. Bez wymuszonych migracji do głównej wersji.
Jeśli to podejście do projektowania bibliotek odpowiada Twoim potrzebom, rozpocznij bezpłatny 30-dniowy okres próbny i przejrzyj dokumentację API osobiście. Wprowadzenie na pięć minut przeprowadza przez instalację, aktywację licencji i pierwszy renderowany PDF. Sam pakiet jest oddalony o jedno polecenie od każdego projektu .NET:
Dla środowisk, w których NuGet nie jest preferowaną ścieżką, bezpośrednie pobieranie dostarcza plik DLL i instalator dla Windows.
