Automatyzacja projektu z MSBuild-em - 5. Makefile z Mono

Jul 24, 2008 - 7 minute read -

W tym odcinku chciałbym przedstawić jak można dynamicznie budować kompilację, nie używając do tego w ogóle Visual Studio. W następnym odcinku temat będzie podobny, ale już z uwzględnieniem Visual Studio.

Za przykład posłuży nam projekt, który oryginalne był tworzony pod mono z wykorzystaniem plików Makefile. Mowa tu o FaRetSys aka eithne. Do budowy wykorzystamy źródła wersji 0.4.2. Aby być niezależnym oraz nie modyfikować plików źródłowych w żaden sposób, przyjąłem następującą strukturę:

  • ..
    • eithne - katalog, do którego wrzucamy źródła eithne. Na ten katalog będzie wskazywać property $(SourcePath)
    • eithne.msbuild - katalog z plikami, które my będziemy tworzyć oraz dostarczać - $(RootPath)
      • build - zostanie utworzony dynamicznie i tu znajdzie się skompilowana aplikacja
      • lib - katalog, do którego wrzucamy potrzebne biblioteki - $(LibraryPath)
      • tools - raczej oczywiste

Projekt będziemy budować za pomocą “csc.exe” czyli .NET Framework a nie Mono. Do tego celu wykorzystamy oczywiście Microsoft.CSharp.targets. Jako pierwsze skompilujemy źródła IPlugin. Makefile do tych źródeł wygląda następująco.

MCS = mcs
 
TARGET = ../IPlugin.dll
 
IPLUGIN = \
    BPP.cs \
    CommSocket.cs \
    Config.cs \
    DialogMessage.cs \
    DialogQuestion.cs \
    GConfConfig.cs \
    IBlock.cs \
    ICommImage.cs \
    ICommResult.cs \
    IConfig.cs \
    IFactory.cs \
    IImage.cs \
    IInfo.cs \
    IInPlugin.cs \
    IOutPlugin.cs \
    IPlugin.cs \
    IResult.cs \
    IType.cs \
    PluginException.cs \
    Program.cs \
    RegistryConfig.cs \
    ResultSorter.cs \
    Utility.cs
 
RESOURCES = \
    DialogMessage.glade \
    DialogQuestion.glade
 
RESFILES = $(addprefix resources/,$(RESOURCES))
RESCMD = $(addprefix -resource:,$(RESFILES))
 
all: $(TARGET)
 
$(TARGET): $(IPLUGIN) $(RESFILES)
    $(MCS) $(IPLUGIN) $(RESCMD) -out:$(TARGET) -target:library -r:Mono.Posix -unsafe -debug -pkg:gconf-sharp-2.0 -pkg:gtk-sharp-2.0 -pkg:glade-sharp-2.0
 
clean:
    rm -f $(TARGET) $(TARGET).mdb

Jak widać z tego pliku, musimy skompilować “library” z wyszczególnionych plików .cs, załączyć pliki resources oraz stworzyć referencje do m.in. gtk-sharp itp. Za naszą budowę tej biblioteki będzie odpowiadał plik “IPlugin.proj”, który umieścimy w $(RootPath).

Źródła do kompilacji

Visual Studio zazwyczaj dodaje pliki do kompilacji pojedynczo. W naszym przypadku wszystkie pliki znajdują się w jednym miejscu więc bez obaw robimy następujący ItemGroup.

<ItemGroup>
    <Compile Include="$(SourcePath)\IPlugin\*.cs" />
</ItemGroup>

EmbeddedResource

Biblioteka ma zawierać dwa pliki resource “DialogMessage.glade” oraz “DialogQuestion.glade”. Cóż prostszego …

<ItemGroup>
    <EmbeddedResource Include="$(SourcePath)\IPlugin\resources\*.glade" />
</ItemGroup>

… i tutaj mamy pierwszą pułapkę. Kod programu odwołuje się do naszych resources w następujący sposób.

Glade.XML gxml = new Glade.XML(Assembly.GetExecutingAssembly()
                            , "DialogMessage.glade"
                            , "DialogMessageWindow"
                            , null);

Aby taki kod mógł zadziałać musimy dodać metatag LogicalName, czyli nasz kawałek definiujący resource w projekcie powinien wyglądać tak …

<ItemGroup>
    <EmbeddedResource Include="$(SourcePath)\IPlugin\resources\DialogMessage.glade">
        <LogicalName>DialogMessage.glade</LogicalName>
    </EmbeddedResource>
</ItemGroup>

Projekt IPlugin wymaga dwóch plików jako resources ale w trakcie budowy Eithne.exe będziemy potrzebować ich prawie 50. Dodamy je zatem dynamicznie. Do tego potrzebujemy dodatkowy ItemGroup

<ItemGroup>
    <ResourcesToEmbed Include="$(SourcePath)\IPlugin\resources\*.glade" />
</ItemGroup>

Teraz wygenerujemy EmbeddedResource z metatagiem LogicalName za pomocą następującego zadania, które jako ogólnodostępne umieścimy je w Common.Targets

<Target Name="GenerateEmbeddedResources">
    <CreateItem Include="@(ResourcesToEmbed)" AdditionalMetadata="LogicalName=%(ResourcesToEmbed.FileName)%(ResourcesToEmbed.Extension)">
        <Output ItemName="EmbeddedResource" TaskParameter="Include"/>
    </CreateItem>
</Target>

Referencje

Biblioteki potraktujmy jeszcze bardziej brutalnie :). Dodamy je wszystkie i zamiast tworzyć czegoś na styl …

<Reference Include="Mono.Posix">
  <SpecificVersion>False</SpecificVersion>
  <HintPath>..\..\lib\Mono.Posix.dll</HintPath>
</Reference>

… skorzystamy z zadania, które umieszczamy w Common.Targets …

<Target Name="GenerateReferencesFromLibrary">
    <ItemGroup>
        <Libraries Include="$(LibraryPath)\*.dll"/>
    </ItemGroup>
    
    <CreateItem Include="@(Libraries.FileName)" AdditionalMetadata="HintPath=%(Libraries.Identity)">
        <Output ItemName="Reference" TaskParameter="Include"/>
    </CreateItem>
</Target>

Zadania “GenerateEmbeddedResources” oraz “GenerateReferencesFromLibrary” uruchamiany tuż przed budową czyli w pliku Common.Targets modyfikujemy property “BuildDependsOn” w taki sposób

<PropertyGroup>
    <BuildDependsOn>
            GenerateReferencesFromLibrary;
                    GenerateEmbeddedResources;
                            $(BuildDependsOn)
    <BuildDependsOn>
<PropertyGroup>

Pozostałe parametry kompilacji

Ostateczny wygląd IPlugin.proj jest następujący …

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="3.5" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <!-- Root Path definition relative for actual build file -->
    <PropertyGroup>
        <RootPath Condition=" '$(RootPath)' == '' ">$(MSBuildProjectDirectory)</RootPath>
    </PropertyGroup>
    <Import Project="$(RootPath)\Settings.proj" />
    <PropertyGroup>
        <OutputType>Library</OutputType>
        <AssemblyName>IPlugin</AssemblyName>
    </PropertyGroup>
    <ItemGroup>
        <Reference Include="System" />
        <Reference Include="System.Data" />
        <Reference Include="System.Xml" />
    </ItemGroup>
    <ItemGroup>
        <Compile Include="$(SourcePath)\IPlugin\*.cs" />
    </ItemGroup>
    <ItemGroup>
        <ResourcesToEmbed Include="$(SourcePath)\IPlugin\resources\*.glade" />
    </ItemGroup>
    <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
    <Import Project="$(RootPath)\Common.Targets" />
</Project>

OutputType definiuje nam czym mają być skompilowane assembly, w tym wypadku jako biblioteka dll, a AssemblyName jaka ma być jego nazwa. Pozostałe parametry kompilacji znajdują się w pliku Settings.proj, gdyż można powiedzieć, że są wspólne dla pozostałych projektów również.

<PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <SchemaVersion>2.0</SchemaVersion>
    <TargetFrameworkVersion>v2.0</TargetFrameworkVersion>
    <FileAlignment>512</FileAlignment>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DebugSymbols>true</DebugSymbols>
    <DebugType>full</DebugType>
    <Optimize>false</Optimize>
    <OutputPath>$(BuildPath)\Debug\bin\</OutputPath>
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
    <BaseIntermediateOutputPath>$(BuildPath)\temp\Debug\obj\</BaseIntermediateOutputPath>
    <IntermediateOutputPath>$(BuildPath)\temp\Debug\obj\</IntermediateOutputPath>
    <UseHostCompilerIfAvailable>true</UseHostCompilerIfAvailable>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <DebugType>pdbonly</DebugType>
    <Optimize>true</Optimize>
    <OutputPath>$(BuildPath)\Release\bin\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
    <BaseIntermediateOutputPath>$(BuildPath)\temp\Release\obj\</BaseIntermediateOutputPath>
    <IntermediateOutputPath>$(BuildPath)\temp\Release\obj\</IntermediateOutputPath>
    <UseHostCompilerIfAvailable>true</UseHostCompilerIfAvailable>
</PropertyGroup>

Zwróćcie uwagę na ustawienie true dla AllowUnsafeBlocks, które jest wymagane w tym wypadku do kompilacji oraz na to że pliki automatycznie są wysyłane do odpowiedniego podkatalogu w katalogu “build” czyli $(BuildPath).

Circular reference

Jednak istnieje jedno zagrożenie. Plik Settings.proj jest zarówno importowany na początku pliku IPlugin.proj jak i na początku Commons.Target. Oznacza to, że przypisywanie zmiennych w projektach odbywa się w następującej kolejności:

  1. Settings.proj
  2. IPlugin.Proj
  3. Settings.proj
  4. Common.Targets

Jeżeli Setting.proj ustawia jakąś wartość zmiennej ABC, a potem IPlugin.proj zmienia tę wartość, to zanim dotrze ona do Common.Targets, z powrotem zostanie zamieniona na wartość z Settings.proj. Jest to w większości wypadków efekt niepożądany i nawet msbuild nas o tym informuje stosownym komunikatem

C:\Projects\eithne.msbuild>msbuild IPlugin.proj
...
C:\Projects\eithne.msbuild\Common.Targets(9,10): warning MSB4011: There is a circular
reference involving the import of file "C:\Projects\eithne.msbuild\Settings.proj". 
This file may have been imported more than once, or you may have attempted to import
the main project file. All except the first instance of this file will be ignored.
...

Aby się przed tym uchronić należy nadać warunek przed kolejnym importem najlepiej oparty o jakąś zmienną, która jest zdefiniowana tylko w pliku Settings.proj i która nie będziemy zazwyczaj modyfikować przy użyciu linii poleceń. Zatem import w pliku Common.Target powinien wyglądać tak…

<Import Project="Settings.proj" Condition="$(ToolsPath) == ''"/>

Budowa pliku Exe

Budowa gdk-cairo.dll wygląda tak samo jak IPlugin.proj. Służy od tego plik Gdk-Cairo.proj.

Za plik exe odpowiadać będzie Eithne.proj. W stosunku do pozostałych plików proj różni się typem budowanego assembly …

<PropertyGroup>
    <OutputType>WinExe</OutputType>
    <AssemblyName>Eithne</AssemblyName>
</PropertyGroup>

… większej ilości resources …

<ItemGroup>
    <ResourcesToEmbed Include="$(SourcePath)\resources\*.glade"/>
    <ResourcesToEmbed Include="$(SourcePath)\resources\pixmaps\*.png"/>
</ItemGroup>

… wykluczenia pliku gdk-cairo.cs z kompilacji …

<ItemGroup>
    <Compile Include="$(SourcePath)\*.cs" Exclude="$(SourcePath)\gdk-cairo.cs" />
</ItemGroup>

… oraz ustawienia zależności pomiędzy pozostałymi projektami czyli …

<ItemGroup>
    <ProjectReference Include="$(RootPath)\IPlugin.proj">
    </ProjectReference>
    <ProjectReference Include="$(RootPath)\Gdk-Cairo.proj">
    </ProjectReference>
</ItemGroup>

Teraz kompilując projekt Eithne.proj, pozostałe dwa projekty również zostaną zbudowane.

Kompilacja hurtowa pluginów

Źródła pluginów znajdują się w katalogu Plugins, który jest wskazywany przez zmienną $(PluginsSourcePath). Jest ich 28 i oznacza to 28 plików Makefile. Czy również oznacza to że musimy robić 28 plików .proj ? Niekoniecznie. Wszystkie te pluginy tak naprawdę różnią się nazwą zatem możemy zrobić projekt w stylu szablonu. Nazwijmy go PluginTemplate.proj. Oto on …

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="3.5" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <!-- Root Path definition relative for actual build file -->
    <PropertyGroup>
        <RootPath Condition=" '$(RootPath)' == '' ">$(MSBuildProjectDirectory)</RootPath>
    </PropertyGroup>
    <Import Project="$(RootPath)\Settings.proj" />
    <PropertyGroup>
        <ProjectName>$(PluginName)</ProjectName>
        <OutputType>Library</OutputType>
        <AssemblyName>$(PluginName)</AssemblyName>
    </PropertyGroup>
    <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
        <OutputPath>$(BuildPath)\temp\Debug\bin\Plugins\</OutputPath>
        <DeployPath>$(BuildPath)\Debug\bin\Plugins</DeployPath>
    </PropertyGroup>
    <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
        <OutputPath>$(BuildPath)\temp\Release\bin\Plugins\</OutputPath>
        <DeployPath>$(BuildPath)\Release\bin\Plugins</DeployPath>
    </PropertyGroup>
    <ItemGroup>
        <Reference Include="System" />
        <Reference Include="System.Data" />
        <Reference Include="System.Xml" />
    </ItemGroup>
    <ItemGroup>
        <ProjectReference Include="$(RootPath)\IPlugin.proj"/>
    </ItemGroup>
    <ItemGroup>
        <Compile Include="$(PluginsSourcePath)\$(PluginName)\*.cs" />
    </ItemGroup>
    <ItemGroup>
        <ResourcesToEmbed Include="$(PluginsSourcePath)\$(PluginName)\resources\*.*" />
    </ItemGroup>
    <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
    <Import Project="$(RootPath)\Common.Targets" />
    <Target Name="AfterBuild">
        <ItemGroup>
            <BuildFiles Include="$(TargetDir)\$(TargetName).*" ></BuildFiles>
        </ItemGroup>
        <Copy SourceFiles="@(BuildFiles)" DestinationFolder="$(DeployPath)" ContinueOnError="true"/>
    </Target>    
    
</Project>

Jak widać, najistotniejszy jest parametr $(PluginName). Dzięki niemu możemy budować plugin w następujący sposób.

C:\Projects\eithne.msbuild> msbuild PluginTemplate.proj /p:PluginName=Best

Jedyną różnicą w porównaniu od poprzednich projektów jest prymitywny deployment.  OutputPath musi wskazywać na jakąś tymczasową lokalizację a potem gotowe assemblies muszą być kopiowane do nowej lokalizacji. Jeżeli tego nie zrobimy zadziała wówczas target “IncrementalClean”, który nam wyczyści z “OutputPath” pliki z poprzednio budowanego plugina. Dzieję się tak gdyż tak naprawdę projekt o jednej nazwie PluginTemplate.proj buduje assemblies za każdym razem o innych nazwach więc traktuje poprzednio budowane pliki jako obce. Ale ten prosty deployment umieszczony w “AfterBuild” chroni nas przed tym.

Dzieki takiemu rozwiazaniu, dołożenie przez programistę nowego pluginu nie powoduje zmiany plików proj. Wystarczy, że umieści go w nowym podkatalogu. Mamy coś w stylu Convention over Configuration.

Teraz już pozostaje zbudować jednym poleceniem wszystkie pluginy. Zrobimy to przy wykorzystaniu nowego pliku Plugins.proj, który wygląda tak…

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="3.5" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <!-- Root Path definition relative for actual build file -->
    <PropertyGroup>
        <RootPath Condition=" '$(RootPath)' == '' ">$(MSBuildProjectDirectory)</RootPath>
    </PropertyGroup>
    <Import Project="$(RootPath)\Settings.proj" />
    
    <ItemGroup>
        <PluginsToBuild Include="$(PluginsSourcePath)\**" Exclude="$(PluginsSourcePath)\**\resources\*;$(PluginsSourcePath)\*" ></PluginsToBuild>
    </ItemGroup>
 
    <!-- Import 3rd party targets -->
    <Import Project="$(MSBuildExtensionsPath)\MSBuildCommunityTasks\MSBuild.Community.Tasks.Targets" />
 
    <Target Name="Build"  DependsOnTargets="GetPluginNames">
        <MSBuild Projects="PluginTemplate.proj" Targets="Build" Properties="PluginName=%(PluginNames.Identity)"></MSBuild>
    </Target>
    
    <Target Name="GetPluginNames">
        <RegexReplace Input="%(PluginsToBuild.RecursiveDir)" Expression="\\" Replacement="" Count="1">
            <Output ItemName ="PluginNames" TaskParameter="Output" />
        </RegexReplace>
    </Target>
</Project>

W zadaniu “GetPluginNames” tworzymy listę nazw podkatalogów, którą później wykorzystujemy do uruchomienia tasku MSBuild  budującego projekt PluginTemplate.proj tyle razy ile jest podkatalogów.

Podsumowanie

Gotowe rozwiązanie można pobrać stąd -> eithne.msbuild.zip. Nie zawiera ono bibliotek wymaganych do kompilacji i uruchomienia, typu mono, gdk itp. Dla chętnych biblioteki są w oddzielnym pliku eithne.msbuild.lib.zip lub można dostarczyć je samemu kopiując do katalogu “lib”.

Jak widać do naszej budowy potrzebujemy mniej własnych plików .proj aniżeli plików Makefile w oryginale. Aby rozkoszować się aplikacją zbudowaną na Windowsach bez użycia Mono wystarczy …

C:\Projects\eithne.msbuild> msbuild Eithne.proj
C:\Projects\eithne.msbuild> msbuild Plugins.proj
C:\Projects\eithne.msbuild> build\Debug\bin\Eithne.exe