Migracja projektów .NET w praktyce
Dług technologiczny w świecie programowania jest znanym zjawiskiem. Część firm zna konsekwencje zastania się i na bieżąco usprawnia kod, chociaż “nie przynosi” to korzyści biznesowi. Z drugiej strony mamy od groma rozwiązań i firm, które zostają przy starych zasadach i boją się ruszyć kod, bo coś może pójść nie tak. Dzisiaj opiszę jak można w łatwy i prosty sposób migrować projekty od .NET Framework 2.0 do .NET Framework 4.8, .NET Standard 2.0 i .NET Core 2.2 za pomocą jednego kliknięcia, zarazem zachowując kompatybilność wsteczną.
Piotr Czech. Konsultant w firmie VLOG, gdzie wspiera rozwój oprogramowania u klientów. Budował systemy oparte o RODO oraz mobilne systemy telemetryczne zbierające i przetwarzający dane o kierowcach w celu obniżenia ubezpieczeń, między zadaniami na poprawianie bugów. Entuzjasta podejść architektonicznych w systemach oraz budowania wydajnych rozwiązań opartych o platformę .NET poprzez eksplorację nowych technik oraz uczenie innych… i gonienie ich, jeśli nie przykładają się do kodu.
Spis treści
Geneza
Przed pojawieniem się Visual Studio 2017 projekty, które tworzyliśmy miały pewien standard (w tym wypadku zaszłości .NET Frameworka) wpisane w pliki .csproj.
Przykładowy plik .csproj zaczynał się tak:
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="12.0" > <Import Project="$(MSBuildExtensionsPath)$(MSBuildToolsVersion)Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)$(MSBuildToolsVersion)Microsoft.Common.props')" /> <PropertyGroup> <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> <ProjectGuid>c32ace4e-47ff-4c88-ba7d-9ae4e2e2bb91</ProjectGuid> <OutputType>Library</OutputType> <AppDesignerFolder>Properties</AppDesignerFolder> <RootNamespace>ClassLibrary</RootNamespace> <AssemblyName>ClassLibrary</AssemblyName> <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> <FileAlignment>512</FileAlignment> <Deterministic>true</Deterministic> </PropertyGroup> <--Resztę pominięto dla przejrzystości--> </project>
Taki plik mógł mieć ponad 1000 lini kodu, jeśli na przykład tworzyliśmy aplikacje typu SPA, sama wydajność takiego projektu była tragiczna, jeśli chodzi o indeksowanie ogromnych ilości plików.
Taki plik posiadał wiele magicznych sformułowań i plików jak Microsoft.Common.props, z rozszerzeniami .targets czy ustawień, które nie mówią nic.
Nowe podejście
Jako, że wielkimi krokami zbliża się .NET 5 to trzeba było podjąć decyzje o uproszczeniu tego procesu, integracji trzech środowisk oraz zmienienia podejścia do zarządzania paczkami nugetowymi. Pierwsze szlaki przetarły rozwiązania zawarte w Visual Studio 2017.
M.in:
- Wprowadzono nowego formatu plików .csproj wykorzystującego Microsoft.NET.Sdk
- Dodano nowy format zaciągania paczek nugetowych znanych pod nazwą PackageReference
- Następstwem tego było również wprowadzenie zmian w zarządzaniu zależnościami do projektów znane pod nazwą ProjectReference.
- Modularności samego Visual Studio, który wprowadził zasadę YAGNI i grupowania funkcjonalności wokół tylko potrzebnych modułów. Chcesz tworzyć projekty w Xamarin? Instalujesz tylko potrzebne komponenty dla niego.
Nowy format
Nowy format plików został uproszczony i dla bibliotek wygląda on tak:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net48</TargetFramework> </PropertyGroup> </Project>
Dla aplikacji konsolowych:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net48</TargetFramework> </PropertyGroup> </Project>
A dla projektów z testami:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net48</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.0.1" /> <PackageReference Include="MSTest.TestAdapter" Version="1.4.0" /> <PackageReference Include="MSTest.TestFramework" Version="1.4.0" /> </ItemGroup> </Project
Przy okazji testów już widać nowy system paczek. Zaletą nowego podejścia jest to, że działają jak wirus, jeśli jeden projekt się zaraził to każdy inny, który go używa również go ma.
Dzięki temu wystarczy tylko raz dodać paczkę, a ona będzie dostępna w każdym projekcie, który używa tego konkretnego projektu. To samo tyczy się referencji do projektów.
Idąc dalej za ciosem, jesteśmy w stanie dzięki nowego formatowi zbudować projekt, który będzie targetował naraz trzy platformy!
Przykładowy format projektu, który agreguje zewnętrzne paczki:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFrameworks>netstandard2.0;netcoreapp2.2;net48</TargetFrameworks> </PropertyGroup> <ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp2.2'"> <PackageReference Include="Microsoft.AspNetCore.All" Version="2.2.0" /> </ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net48'"> <PackageReference Include="System.Security.Principal.Windows" Version="4.5.1" /> </ItemGroup> <ItemGroup> <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> </ItemGroup> </Project>
Newtonsoft.Json będzie dostępny dla każdej platformy a reszta paczek według platformy.
To co się zmieniło to TargetFramework stał się TargetFrameworks.
Projekty ASP.NET nie są komaptybilne z nowym formatem ze względu na zaszłości!
To oznacza, że dla nich zostaje stary format.
Masowa migracja i standaryzacja kodu
Nie ma sensu wykonywać kodu migracji ręcznie, więc powstał CsprojToVs2017. Pójdźmy jednak o krok dalej.
Poza samą migracją przydałby się:
- System do analizy kodu.
- Pliki konfiguracyjne, aby programiści pisali kod według tego samego standardu.
- Ustawienia środowiska, aby nie trzeba było robić tego ręcznie.
Wszystko zrobić za jednym zamachem.
W takim celu istnieje mechanizm, który składa się z dwóch plików Directory.Build.props oraz Directory.Build.targets.
Zadaniem pierwszego jest zdefiniowanie ustawień takich jak wersja języka, załączonych plików czy załączonych paczek. Zadaniem drugiego jest zdefiniowanie skryptów, które się uruchomią podczas budowania projektu.
Directory.Build oznacza tyle, że plik umiejscowiony na poziomie solucji zdefiniuje dla każdego projektu takie same reguły i zostaną one dołączone do pliku .csproj bez potrzeby ręcznego kopiowania ich do każdego projektu.
Przykład Directory.Build.props:
<?xml version="1.0" encoding="utf-8"?> <Project > <--Flagi--> <Import Project="DeploymentSettings.props" /> <--Ustalenie globalnych wartości dla każdej paczki--> <PropertyGroup> <Company>Xeinaemm Consulting Piotr Czech</Company> <Authors>$(Company)</Authors> <Copyright>Copyright (c) $(Company) $([System.DateTime]::Now.Year)</Copyright> <Trademark>$(Company)TM</Trademark> <Product>$(Company) Projects</Product> </PropertyGroup> <--Ustalenie globalnych ustawień dla projektów--> <PropertyGroup> <LangVersion>latest</LangVersion> <NoWarn>1591;1701;1702;8032;NU1701;AD0001</NoWarn> <GeneratePackageOnBuild>false</GeneratePackageOnBuild> <CodeAnalysisRuleSet>....Xeinaemm.ruleset</CodeAnalysisRuleSet> <Deterministic>true</Deterministic> <BuildInParallel>false</BuildInParallel> </PropertyGroup> <--Ustalenie globalnych plików załączonych do projektu--> <ItemGroup> <AdditionalFiles Include="....stylecop.json" Visible="false" /> <None Include="....Xeinaemm.ruleset" Visible="false" /> <None Include=".....editorconfig" Visible="false" /> <None Include="content*" Pack="true" PackagePath="content" /> <None Include="build*" Pack="true" PackagePath="build" /> <None Include="lib*" Pack="true" PackagePath="lib" /> </ItemGroup> <--Jeśli projekt nie posiada pliku packages.config to domyślnie konwertuj go do nowego systemu--> <Choose> <When Condition="!Exists('$(MSBuildProjectDirectory)packages.config')"> <PropertyGroup> <RestoreProjectStyle>PackageReference</RestoreProjectStyle> </PropertyGroup> </When> </Choose> <--Jeśli projekt nie jest współdzieloną biblioteką i ma nowy system to zainstaluj współdzieloną biblioteką--> <Choose> <When Condition="!Exists('$(MSBuildProjectDirectory)packages.config') and '$(IsSharedLibrary)' == 'false'"> <ItemGroup> <PackageReference Include="Xeinaemm.All" Version="*" PrivateAssets="All" /> </ItemGroup> </When> </Choose> </Project>
Przykład Directory.Build.targets:
<?xml version="1.0" encoding="utf-8" ?> <Project > <--Skrypty PowerShell uruchamiane w CMD--> <PropertyGroup Label="Scripts"> <PowerShell>powershell -NoProfile -ExecutionPolicy Unrestricted -command</PowerShell> <Pack>dotnet pack "$(MSBuildProjectDirectory)$(ProjectFileName)" --no-build -o $(NugetDestinationFolder) -c Release</Pack> <Migrate>$(PowerShell) dotnet tool install --global Project2015To2017.Migrate2017.Tool %26 dotnet migrate-2017 migrate $(MSBuildProjectDirectory) -n -a -t net48</Migrate> <FindLatestNugetPackage>"[System.IO.Directory]::GetFiles(%27$(NugetDestinationFolder)%27, %27Xeinaemm.Analyzer%2A%27) | Select-String %27[0-9]+.[0-9]+.[0-9]+%27 | %25%25{ %24_.Matches.Value } | Select-Object -Last 1"</FindLatestNugetPackage> </PropertyGroup> <--Przed budowaniem projektu znajdź paczkę z analizatorami i zaciągnij pliki konfiguracyjne--> <Target Name="BeforeBuild" Condition="$(DownloadFiles) == 'true'"> <Exec Condition="Exists('$(NuGetPackageRoot)Xeinaemm.Analyzer') and '$(IsSharedLibrary)' == 'false'" ConsoleToMsBuild="true" Command="$(PowerShell) $(FindLatestNugetPackage)"> <Output TaskParameter="ConsoleOutput" PropertyName="AnalyzerPackageVersion" /> </Exec> <ItemGroup Condition="Exists('$(NuGetPackageRoot)Xeinaemm.Analyzer') and '$(IsSharedLibrary)' == 'false'"> <ContentToCopy Include="$(NuGetPackageRoot)Xeinaemm.Analyzer$(AnalyzerPackageVersion)content*.*" /> <BuildToCopy Include="$(NuGetPackageRoot)Xeinaemm.Analyzer$(AnalyzerPackageVersion)build*.*" /> </ItemGroup> <ItemGroup Condition="'$(IsSharedLibrary)' == 'true'"> <ContentToCopy Include="$(MSBuildProjectDirectory)content*.*" /> <BuildToCopy Include="$(MSBuildProjectDirectory)build*.*" /> </ItemGroup> <Copy SourceFiles="@(ContentToCopy)" DestinationFiles="@(ContentToCopy ->'$(SolutionDir)%(RecursiveDir)%(Filename)%(Extension)')" /> <Copy SourceFiles="@(BuildToCopy)" DestinationFiles="@(BuildToCopy ->'$(SolutionDir)%(RecursiveDir)%(Filename)%(Extension)')" /> </Target> <--Jeśli ustawiona flaga migracji, wykonaj migracje i wyczyść projekt ze zbędnych plików--> <Target Name="Clean" Condition="Exists('$(MSBuildProjectDirectory)PropertiesAssemblyInfo.cs') and $(MigrateProjects) == 'true'"> <Exec Command="$(Migrate)"/> <RemoveDir Directories="$(MSBuildProjectDirectory)PropertiesAssemblyInfo.cs" Condition="!Exists('$(MSBuildProjectDirectory)packages.config')" /> </Target> <--Jeśli ustawiona flaga, po zbudowaniu projektu wygeneruj i wgraj paczkę nugetową do repozytorium--> <Target Name="PackNugets" AfterTargets="AfterBuild" Condition="!Exists('$(MSBuildProjectDirectory)packages.config') and '$(DeployNugetPackages)'=='true'"> <Exec Command="$(Pack)"/> </Target> </Project>
DeploymentSettings.props dla współdzielonej biblioteki:
<?xml version="1.0" encoding="utf-8"?> <Project > <PropertyGroup> <IsSharedLibrary>true</IsSharedLibrary> <DeployNugetPackages>true</DeployNugetPackages> <MigrateProjects>false</MigrateProjects> <DownloadFiles>true</DownloadFiles> <NugetDestinationFolder>C:NugetSource</NugetDestinationFolder> </PropertyGroup> </Project>
DeploymentSettings.props dla standardowych solucji:
<?xml version="1.0" encoding="utf-8"?> <Project > <PropertyGroup> <IsSharedLibrary>false</IsSharedLibrary> <DeployNugetPackages>false</DeployNugetPackages> <MigrateProjects>false</MigrateProjects> <DownloadFiles>true</DownloadFiles> <NugetDestinationFolder>C:NugetSource</NugetDestinationFolder> </PropertyGroup> </Project>
<?xml version="1.0" encoding="utf-8"?> <configuration> <config> <add key="http_proxy" value="host" /> <add key="http_proxy.user" value="username" /> <add key="http_proxy.password" value="encrypted_password" /> </config> <packageRestore> <add key="enabled" value="True" /> <add key="automatic" value="True" /> </packageRestore> <packageSources> <add key="Local Repository" value="C:NugetSource" /> </packageSources> </configuration>
Kopiujemy Directory.Build.props, Directory.Build.targets, DeploymentSettings.props oraz nuget.config do folderu z solucją. Przed budowaniem projektu system szuka paczki Xeinaemm.Analyzer, z której pobiera pliki konfiguracyjne zdefiniowane w folderze content i build. Buduje projekt a potem jeśli chcemy wdrażać paczki do lokalnego repozytorium to wykonuje paczkowanie.
Sama w sobie migracja projektów znajduje się w Visual Studio pod komendą “Clean” lub “Clean Solution”. Po migracji trzeba pamiętać o przestawieniu flagi w DeploymentSettings.props, aby nie wykonywał za każdym razem migracji projektów.
Wsteczna kompatybilność
Aby zapewnić wsteczną kompatybilność większość wymienionych funkcjonalności jest wyłączona, jeśli znajdzie w projekcie plik packages.config, ponieważ stary i nowy system nie może działać naraz w tym samym projekcie.
Aby zapewnić centralne zarządzanie paczkami nugetowymi, warto pomyśleć o stworzeniu projektów, które zawierają tylko paczki np. Xeinaemm.Nuget.Autofac. W moim wypadku jest to agregat dla wszystkich paczek pod nazwą Xeinaemm.Nuget.
Dzięki takiemu zabiegowi nowy system jest w stanie korzystać z wirusowego zaciągania paczek, są one zdefiniowane w jednym miejscu, a zarazem stary system dostaje wersje paczek ustalone z góry, dzięki czemu eliminujemy problem różnych wersji tej samej paczki.
Dodatkowo taki podział paczek na grupy pozwala na zaciąganie tylko potrzebnych zależności w starych projektach. Dla nowego formatu wszystko może być w jednym miejscu, ponieważ i tak nie zauważymy tych zależności, gdyż zmieniony system nie zaciąga czegoś czego nie używamy, chociaż mamy sposobność używania (można to zauważyć przy rozwinięciu dowolnego projektu lub paczki w sekcji Dependencies dla nowego formatu).
Podsumowanie
Całe rozwiązanie znajduje się pod Xeinaemm.Standard dla współdzielonej biblioteki oraz SpaTemplate dla standardowej solucji. Znajdziesz tam również rozwiązania jak automatyczne generowanie kodu dzięki temu rozwiązaniu. Tymczasem mam nadzieję, że wyniosłeś coś dla siebie, do następnego!
Zdjęcie główne artykułu pochodzi z unsplash.com.