Migration von .NET Framework zu .NET Core per PowerShell-Skript statt Klickorgie

Der Dotnet-Doktor  –  4 Kommentare

Leider gibt es bislang kein Migrationswerkzeug von Microsoft, um WPF- und Windows Forms-Projekte auf .NET Core umzustellen. Dieser Beitrag stellt ein PowerShell-Skript vor, das bei der Migration einige manuelle Arbeit abnimmt.

.NET Core 3.0 erscheint am 23. September 2019 und damit auch die Möglichkeit, erstmals Windows-Desktop-Anwendungen mit Windows Forms und Windows Presentation Foundation (WPF) auf .NET Core (allerdings nicht plattformneutral, sondern nur auf Windows) zu betreiben. Zu den Vorteilen einer Umstellung bestehender, .NET-Framework-basierter Desktop-Anwendungen gehören:

  • a) einige neue Bibliotheksfunktionen und Sprachfeatures in C# 8.0, die es nur in .NET Core gibt,
  • b) eine bessere Performance der CLR und einiger Bibliotheksfunktionen unter .NET Core,
  • c) die besseren Deployment-Optionen (u.a. Self-Contained Deployment ohne vorherige Installation) sowie
  • d) der Side-by-Side-Betrieb beliebig vieler Versionen von .NET Core auf einem System.

Klickorgie bei der Migration

Leider gibt es bislang kein Migrationswerkzeug von Microsoft, um .NET Framework-Projekte auf .NET Core umzustellen. Das Marketplace-Werkzeug "Convert Project To .NET Core" funktioniert nicht mit der aktuellen Preview- oder Release-Candidate-Version von .NET Core 3.0.

Zwar können die Programmcode- und Ressourcendateien in vielen (aber nicht allen) Fällen eins zu eins wiederverwendet werden, aber das Projektformat (.csproj/packages.config) hat sich erheblich geändert. Folglich müssen Entwickler die Umstellung auf .NET Core in vielen kleinen manuellen Schritten vollziehen:

  • Code- und Ressourcendatei kopieren
  • Neue Projektdatei anlegen
  • Projektreferenzen wiederherstellen
  • NuGet-Referenzen wiederherstellen
  • Referenzen auf einzelne Assemblies wiederherstellen
  • Für Grafiken, Textdateien u.a. Ressourcen die Build Action "Resource" bzw. "Embedded Resource" wieder einstellen
  • Ggf. für andere Dateiarten (z.B. Entity-Framework-Modell-Dateien .edmx oder typisierte DatatSets .xsd) die Build Action einstellen

Das muss man alles für jedes einzelne Projekt durchlaufen. Eine wahre Klickorgie!

Alternative: Migrieren per PowerShell-Skript

Wie immer in diesen Fällen schreibe ich PowerShell-Skripte, die mir die Arbeit erleichtern. Das unter diesem Beitrag abgedruckte PowerShell-Skript stelle ich exemplarisch zur Verfügung. Es migriert ein WPF-Projekt mit zwei Bibliotheken (DAL und BO) sowie ein Unit-Test-Projekt (mit Windows Application Driver-basierten UI-Tests) von .NET Framework 4.8 nach .NET Core 3.0.

Das Skript liest dabei jeweils einige Informationen aus der alten Projektdatei (.csproj) aus (z.B. Wurzelnamensraum, Projektreferenzen). Aber im Fall der NuGet-Pakete geschieht das absichtlich nicht, denn die alten packages.config-Dateien enthalten nicht nur die direkten Referenzen, sondern auch transitive Referenzen, die in der neuen Welt in der .NET-Core-Projektdatei nicht mehr explizit gebraucht werden. Die neue Projektdatei wird nicht mit dem im .NET Core SDK mitgelieferten Werkzeug dotnet.exe erstellt, sondern mit im Skript abgelegten Vorlagen, denn es werden einige Einstellungen in der Projektdatei benötigt, zum Beispiel für die AssemblyInfo.cs.

Der Fall ist insofern einfach, weil der gesamte in diesem Fall verwendete Programmcode auch unter .NET Core 3.0 noch läuft. Somit kann das neue .NET- Core-Programm am Ende ohne weitere Änderungen kompiliert, gestartet und erfolgreich getestet werden.

In dem nachstehenden Video wird zuerst die klassische .NET Framework-Anwendung mit msbuild.exe übersetzt und dann gestartet. Man sieht in der Titelzeile der Anwendung, dass sie auf .NET Framework 4.8 läuft. Danach folgt die Migration per Skript und der Ablauf der UI-Tests, in denen man nun .NET Core 3.0 in der Titelleiste sieht.


Fazit

Das Skript ist natürlich keine Universallösung für alle Projekte, sondern versteht sich als ein Muster für derartige Migrationsprojekte. Solche Skripte wurden bei uns in der Firma für mehrere echte Migrationen von WPF- und Windows Forms-Projekten auf .NET Core 3.0 verwendet, teilweise mit einige Dutzend Einzelprojekten in der Projektmappe. Das hat viel manuelle und lästige Arbeit gespart.

Skriptcode

Es folgt der Skriptcode für das im Video gezeigte Migrations-Skript in der PowerShell:

. H:\TFS\INT\ITV.util.ps1 # PowerShell Utilities, e.g. Head, H1, H2, Info, Error

CLS
Head "Script-based Migration of a classic WPF Project (.NET Framework) to .NET Core 3.0"
Head "(C) Dr. Holger Schwichtenberg 2019"
Head "Version: 2.0 (24.09.2019)"
# ******************************************************

cd $psscriptroot
$ErrorActionPreference = "stop"
$oldPath = "T:\MLLWPF\MLLWPFClassic\"

if ($args[0] -ne $null)
{
$newPath = $args[0]
}
else {
$newPath = "t:\MLLWPF\MLLWPFCore3\"
}


#region -------------------------- Templates
$projWPFTemplate =
@"
<!-- generated by $($MyInvocation.MyCommand.Name) [DATE] -->
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

<PropertyGroup>
<OutputType>[OutputType]</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<UseWPF>true</UseWPF>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Deterministic>false</Deterministic>
<RootNamespace>[rootnamespace]</RootNamespace>
<ApplicationIcon>[icon]</ApplicationIcon>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishSingleFile>true</PublishSingleFile>
<UseAppHost>true</UseAppHost>
</PropertyGroup>

<!-- Assets -->
<ItemGroup>
[AssetsRef]
</ItemGroup>

<!-- Nuget Packages -->
<ItemGroup>
[NugetReference]
</ItemGroup>

<!-- Projects -->
<ItemGroup>
[ProjectReference]
</ItemGroup>

<!-- DLLs -->
<ItemGroup>
[LibReference]
</ItemGroup>
</Project>

"@

$projLibTemplate =
@"
<!-- generated by $($MyInvocation.MyCommand.Name) [DATE] -->
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>[OutputType]</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Deterministic>false</Deterministic>
<RootNamespace>[rootnamespace]</RootNamespace>

</PropertyGroup>

<!-- Assets -->
<ItemGroup>
[AssetsRef]
</ItemGroup>

<!-- Nuget Packages -->
<ItemGroup>
[NugetReference]
</ItemGroup>

<!-- Projects -->
<ItemGroup>
[ProjectReference]
</ItemGroup>

<!-- DLLs -->
<ItemGroup>
[LibReference]
</ItemGroup>
</Project>

"@

$NugetRefTemplate=
@"
<PackageReference Include="NAME" Version="VERSION" />
"@

$ProjRefTemplate=
@"
<ProjectReference Include="TODO" />
"@

$LibRefTemplate=
@"
<Reference Include="TODO">
<HintPath>..\_Libs\TODO.dll</HintPath>
</Reference>
"@

$AssetTemplate=
@"
<Resource Include="FILE" />
"@
#endregion

function Migrate-Project($projectfile, $template, $newPath, $outputtype, $projects, $libs, $nugets)
{ <#
.SYNOPSIS
convert a .NET Framework project to .NET Core
#>
h1 "Converting .NET Framework project to .NET Core"

#region -------------------------- Getting data from existing project file
h2 "Getting data from existing project file..."
$projectfile = [System.IO.Path]::Combine($oldPath, $projectfile)
print "Project file: $projectfile"
$sourcefolder = (get-item $projectfile).DirectoryName
$newProjectName = [System.IO.Path]::GetFileNameWithoutExtension((get-item $projectfile).Name)
$newProjectFolder = [System.IO.Path]::Combine($newpath,$newProjectName)
$newProjectFilePath = [System.IO.Path]::Combine($newProjectFolder,"$($newProjectName).csproj")
print "Source path: $sourcefolder"
print "Target path: $newProjectFolder"

if (-not (test-path $projectfile)) { throw "Project file not found!" }
$rootnamespace = Get-RegExFromFile $projectfile '<RootNamespace>(.*)</RootNamespace>'
if ($rootnamespace -eq $null) { $rootnamespace = $newProjectName }
print "Rootnamespace: $rootnamespace"
if ($projects -eq $null) {
$projects = Get-RegExFromFile $projectfile '<ProjectReference Include="(.*)"'
if ($project -ne $null) { $projects | foreach { print "Projektreference: $_" } }
}

$applicationicon = Get-RegExFromFile $projectfile '<ApplicationIcon>(.*)</ApplicationIcon>'
print "applicationicon: $applicationicon"

#endregion

#region -------------------------- Remove Destination Folder"
h2 "Clean Destination Folder $newProjectFolder"
if (test-path $newProjectFolder) {
warning "Removing existing files in $newProjectFolder..."
rd $newProjectFolder -Force -Recurse
}
#endregion

#region -------------------------- Copy Code
h2 "Copy Code $newProjectName to $newProjectFolder"
Copy-Item $sourcefolder $newProjectFolder -Recurse -Force
dir $newProjectFolder | out-default

h2 "Remove unused files in $newProjectName..."
rd $newProjectFolder\bin -Recurse
rd $newProjectFolder\obj -Recurse
remove-item $newProjectFolder\*.vspscc
remove-item $newProjectFolder\*.sln
remove-item $newProjectFolder\*.csproj
dir $newProjectFolder -Recurse | Set-ItemProperty -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue | out-default
dir $newProjectFolder | out-default
#endregion

#region -------------------------- Creating new project file
h2 "Creating new project file ($newProjectFilePath )..."
$csproj = $template
$csproj = $csproj.Replace("[DATE]",(get-Date))
$csproj = $csproj.Replace("[OutputType]",$outputtype)
$csproj = $csproj.Replace("[rootnamespace]",$rootnamespace)
$csproj = $csproj.Replace("[icon]",$applicationicon)

$projRef = ""
foreach($r in $projects)
{
print "Project: $r"
$projRef += " " + $ProjRefTemplate.Replace("TODO",$r) + "`n"
}
$csproj = $csproj.Replace("[ProjectReference]",$projRef.TrimEnd())

$assetRef = ""
if (test-path $newProjectFolder\assets)
{
$assets = dir $newProjectFolder\assets
foreach($a in $assets)
{
print "Asset: $a"
$assetRef += " " + $AssetTemplate.Replace("FILE","assets\$($a.name)") + "`n"
}
}
$csproj = $csproj.Replace("[AssetsRef]",$assetRef.TrimEnd())

$nugetref = ""
foreach($n in $nugets.keys)
{
print "Nuget: $n $($nugets[$n])"
$nugetref += " " + $NugetRefTemplate.Replace("NAME",$n).Replace("VERSION",$nugets[$n]) + "`n"
}
$csproj = $csproj.Replace("[NugetReference]",$nugetref.TrimEnd())

$libref = ""
foreach($l in $libs)
{
print "DLL: $l"
$libref += " " + $LibRefTemplate.Replace("TODO",$l)
}
$csproj = $csproj.Replace("[LibReference]",$libref.TrimEnd())

print $csproj
$csproj | Set-Content $newProjectFilePath -Force

#endregion

#region -------------------------- Build
h2 "Build $newProjectName..."
cd $newProjectFolder
#dotnet restore | out-default
dotnet build | out-default
#endregion
return $newProjectFolder
}

#region ############################ Main

Remove-Item $newPath/* -Recurse -Force -ea SilentlyContinue

md $newPath -ea SilentlyContinue
explorer $newPath
$nugets = @{
"System.ComponentModel.Annotations"="4.5.0"
}

Migrate-Project MiracleListLight_BO\MiracleListLight_BO.csproj $projLibTemplate $newPath "Library" $null $null $nugets

$nugets = @{
"ITV.AppUtil.NETStandard.Core"="3.0.1";
"Microsoft.EntityFrameworkCore.SqlServer"="2.2.6";
"Microsoft.EntityFrameworkCore.Sqlite"="2.2.6";
}

Migrate-Project MiracleListLight_DAL\MiracleListLight_DAL.csproj $projLibTemplate $newPath "Library" $null $null $nugets

$nugets = @{
"Appium.WebDriver"="4.0.0.6-beta";
"Microsoft.NET.Test.Sdk"="16.2.0";
"MSTest.TestAdapter"="1.4.0";
"MSTest.Testframework"="1.4.0";
}

Migrate-Project MiracleListLight_UITests\MiracleListLight_UITests.csproj $projWPFTemplate $newPath "Library" $null $null $nugets


$nugets = @{
"ITV.AppUtil.NETStandard.Core"="3.0.1";
"Microsoft.EntityFrameworkCore"="2.2.6";
"Microsoft.Windows.Compatibility"="3.0.0-rc1.19456.4"
}

$newProjectFolder = Migrate-Project MiracleListLight_WPFUI\MiracleListLight_WPFUI.csproj $projWPFTemplate $newPath "WinEXE" $null $null $nugets

h1 "Copy .sln file..."
copy $oldPath/MiracleListLight_WPF.sln $newPath/MiracleListLight_WPF.sln

h1 "Testing if EXE exists.."
$exe1 = [System.Io.Path]::Combine($newProjectFolder,"bin\Debug\netcoreapp3.0\win-x64\MiracleListLight_WPFUI.exe")

if (-not (test-path $exe1)) { error "EXE not found: $exe" ; return }
success "EXE found: $exe1"

h1 "Publish App..."
$singpleFilePubPath = "t:\MLLWPF\MLLWPFCore-SingleFilePublish"
$wpf = [System.Io.Path]::Combine($newPath,"MiracleListLight_WPFUI")
cd $wpf
dotnet publish -o $singpleFilePubPath
explorer $singpleFilePubPath
$exe = [System.Io.Path]::Combine($singpleFilePubPath, "MiracleListLight_WPFUI.exe")
if (-not (test-path $exe)) { error "EXE not found: $exe" ; return }
success "EXE found: $exe"

h1 "UI Testing..."
$uitests = [System.Io.Path]::Combine($newPath,"MiracleListLight_UITests")
cd $uitests
Replace-TextInFile "app.config" "H:\ML\MiracleListLight\MiracleListLight_WPFUI\bin\Debug\MiracleListLight_WPFUI.exe" $exe1
dotnet test
#endregion

success "Migration completed"

Es folgt noch ein Ausschnitt aus der im Skript verwendeten Hilfsbibliothek für die farbigen Ausgaben und die Dateiänderungen.
# PowerShell Utilities / Colorful output and other
# (C) Dr. Holger Schwichtenberg, www.IT-Visions.de, 2007-2019
function Get-RegExFromFile($path, $pattern)
<#
.SYNOPSIS
Get a tag oder value from a project file
#>
{
$content = get-content $path
$matches = ($content | select-string -pattern $pattern).Matches
if ( $matches -eq $null) { return $null }
$matches | foreach { $_.Groups[1].Value }
}

function Replace-TextInFile($path, $oldtext, $newtext)
{
<#
.SYNOPSIS
Replace a text in a file
#>
((Get-Content -path $path -Raw).Replace($oldtext, $newtext)) | Set-Content -Path $path
}

function Replace-RegExInFile($path, $reg, $newtext)
{
<#
.SYNOPSIS
Replace a text in a file
#>
(Get-Content -path $path -Raw) -replace $reg,$newtext | Set-Content -Path $path -Encoding UTF8
}



function head()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor Blue -BackgroundColor White
}

function print()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s
}

function h1()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor black -BackgroundColor Yellow
}

function h2()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor White -BackgroundColor Green
}

function h3()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor White -BackgroundColor DarkBlue
}

function error()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor white -BackgroundColor red
}

function warning()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor yellow
}

function info()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor cyan
}

function success()
{
[CmdletBinding()]
Param( [Parameter(ValueFromPipeline)]$s)
Write-Host $s -ForegroundColor green
}