Mert Özen Her Satırda Daha İleri
Backend .NET SDK, CLI ve MSBuild — #08

MSBuild'i Anlamak: Property, Item, Target, Task, Custom Target ve Binlog ile Build Hatası Ayıklamak

Mert Özen 15 Kas 2026 10 dk 8 görüntülenme
MSBuild'i Anlamak: Property, Item, Target, Task, Custom Target ve Binlog ile Build Hatası Ayıklamak

MSBuild bir projeyi iki fazda işler: evaluation'da property ve item'ları okur, execution'da target'ları çalıştırır; target'larda oluşan değerler evaluation'da görünmez. Bookstore'a commit ve yerel değişiklik bilgisini assembly'ye yazan, Angular istemcisini Inputs/Outputs ile incremental olarak publish'e ekleyen target'lar yazıyor, binlog ile bir property'nin nerede değiştiğini gerçek çıktılarla buluyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, MSBuild 18.9) ile derlenip denenmiştir; Angular istemcisi Node.js 24 ve Angular 22.2 (TypeScript 6.0) ile build edilmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) bookstore-admin aracı artık hangi commit'ten build edildiğini yazıyor:

Version:     1.0.0+63bef849f427cfce32eda3cc55640dc0e9d9d857
Source:      63bef849f427cfce32eda3cc55640dc0e9d9d857
Runtime:     .NET 10.0.15
Environment: (not set)
API:         http://localhost:5080

Commit hash'i MSBuild'deki SourceRevisionId property'sinden geliyor. Değerini 2. yazıda öğrendiğimiz yolla soralım:

dotnet msbuild tools/Bookstore.Admin -getProperty:SourceRevisionId

Cevap boş bir satır. Exe'de olan bir değer, proje dosyasını sorgulayınca neden yok? Sebep MSBuild'in iki fazlı çalışması: property'lerin bir kısmı dosyalar okunurken değil, target'lar çalışırken oluşuyor. Bu yazıda MSBuild'in property, item, target ve task kavramlarını Bookstore'a iki custom target ekleyerek anlatıyoruz: biri commit bilgisini assembly'ye yazıyor, diğeri Angular istemcisini API ile birlikte publish ediyor. Sonra binlog ile "bu değer nereden geldi?" sorusunu cevaplıyoruz.

İki Faz: Evaluation ve Execution

MSBuild bir projeyi iki adımda işler. Evaluation fazında proje dosyasını ve import ettiği her dosyayı 3. yazıdaki sırayla okur; tüm PropertyGroup ve ItemGroup'ları değerlendirir, glob'ları açar, koşulları hesaplar. Bu fazda hiçbir komut çalışmaz. Execution fazında ise istenen target'ları bağımlılık sırasıyla çalıştırır; target'ların içindeki task'lar dosya yazar, derleyiciyi çağırır, yeni property ve item'lar üretir.

SourceRevisionId, SDK'nın InitializeSourceControlInformation target'ı çalışırken git'ten okunuyor, yani execution'da oluşuyor. -getProperty tek başına evaluation sonucunu verir; bir target ile birlikte verilince target'lar bittikten sonraki değeri:

dotnet msbuild tools/Bookstore.Admin -t:Build -getProperty:SourceRevisionId
63bef849f427cfce32eda3cc55640dc0e9d9d857

Bu ayrımın pratik sonucu şu: evaluation'da yazdığın bir koşul, execution'da oluşan bir değeri göremez. <PropertyGroup Condition="'$(SourceRevisionId)' != ''"> proje dosyasının en üstünde her zaman yanlıştır. Böyle bir değere ihtiyacın varsa onu kullanan kodu da bir target'ın içine koyman gerekir.

Property, Item ve Metadata

Property tek bir string'dir ($(Name)), item ise metadata taşıyan bir listedir (@(Name)). Bookstore'un Angular kaynaklarını bir item listesiyle tanımladık:

<PropertyGroup>
  <ClientRoot>$(MSBuildThisFileDirectory)..\..\web\bookstore-web\</ClientRoot>
  <ClientDist>$(ClientRoot)dist\bookstore-web\browser\</ClientDist>
</PropertyGroup>

<ItemGroup>
  <ClientSource Include="$(ClientRoot)src\**;$(ClientRoot)public\**" />
  <ClientSource Include="$(ClientRoot)angular.json;$(ClientRoot)package-lock.json;$(ClientRoot)tsconfig*.json" />
</ItemGroup>

Glob'la eklenen her dosya, MSBuild'in kendiliğinden eklediği well-known metadata ile gelir: %(Filename), %(Extension), %(FullPath), glob'un ** kısmına karşılık gelen %(RecursiveDir) ve dosyanın değiştirilme zamanı. Item'lar transform ile başka bir biçime çevrilebilir: @(ClientFile->'%(Filename)%(Extension)', ', ') listeyi dosya adlarından oluşan virgüllü bir string'e çevirir. Bir task ya da target içindeki bir ItemGroup, %(...) ile metadata'ya başvurduğunda listedeki her eleman için ayrı ayrı işlenir; buna batching denir. Aşağıda ResolvedFileToPublish listesini kurarken bunu kullanıyoruz.

Target ve Task

Target, sırayla çalışan task'ların adlandırılmış bir grubu. Bir target'ı build'e üç yolla bağlarsın: DependsOnTargets önce başka target'ları çalıştırır, BeforeTargets ve AfterTargets ise target'ı SDK'nın mevcut bir target'ının önüne ya da arkasına ekler. Task ise asıl işi yapan .NET class'ı: Exec bir komut çalıştırır, Message log'a yazar, Copy ve RemoveDir dosya işlemleri yapar. Bir task'ın çıktısı Output elemanıyla property ya da item'a alınır.

Commit Bilgisini Assembly'ye Yazmak

Plan, build sırasında git commit hash'ini assembly'ye yazan bir target'tı. Yazmadan önce SDK'nın zaten ne yaptığına baktık ve girişteki Version satırını gördük: .NET 8'den beri Source Link SDK'nın parçası ve AssemblyInformationalVersion'a + ile commit hash'ini ekliyor. Microsoft bunu .NET 8'in breaking change'leri arasında listeliyor; istemiyorsan IncludeSourceRevisionInInformationalVersion'ı false yapabilirsin. Yani hash için target yazmaya gerek yok.

SDK'nın söylemediği bir şey var: exe, commit'lenmemiş değişikliklerle mi build edildi? Bir hatanın kaynağını ararken "bu exe tam olarak o commit mi?" sorusunun cevabı önemli. Bunun için Directory.Build.targets'a bir target ekledik:

<!-- Commit'i ve çalışma klasöründe yerel değişiklik olup olmadığını assembly metadata'sına yazar. -->
<Target Name="AddSourceStatusMetadata"
        BeforeTargets="GetAssemblyAttributes"
        DependsOnTargets="InitializeSourceControlInformation"
        Condition="'$(GenerateAssemblyInfo)' != 'false'">
  <!-- git status her değişen dosya için bir satır yazar; çıktı yoksa çalışma klasörü temizdir. -->
  <Exec Command="git status --porcelain"
        WorkingDirectory="$(MSBuildProjectDirectory)"
        ConsoleToMSBuild="true"
        StandardOutputImportance="low"
        IgnoreExitCode="true">
    <Output TaskParameter="ConsoleOutput" ItemName="_GitStatusLine" />
    <Output TaskParameter="ExitCode" PropertyName="_GitExitCode" />
  </Exec>

  <PropertyGroup>
    <_WorkingTreeDirty>false</_WorkingTreeDirty>
    <_WorkingTreeDirty Condition="'@(_GitStatusLine)' != ''">true</_WorkingTreeDirty>
  </PropertyGroup>

  <ItemGroup Condition="'$(_GitExitCode)' == '0'">
    <AssemblyMetadata Include="SourceRevision" Value="$(SourceRevisionId)" />
    <AssemblyMetadata Include="WorkingTreeDirty" Value="$(_WorkingTreeDirty)" />
  </ItemGroup>
</Target>

Parçaları sırayla okuyalım. DependsOnTargets, SDK'nın git bilgisini okuyan target'ını önce çalıştırıyor; böylece $(SourceRevisionId) dolu. BeforeTargets="GetAssemblyAttributes" bizi tam doğru yere koyuyor: SDK o target'ta AssemblyMetadata item'larını [assembly: AssemblyMetadata(...)] attribute'larına çeviriyor. Exec'in ConsoleToMSBuild'i komutun her çıktı satırını bir item'a dönüştürüyor. IgnoreExitCode ve _GitExitCode koşulu, repo git olmadan (örneğin bir kaynak arşivinden) build edildiğinde build'i kırmadan metadata'yı atlıyor. Alt çizgiyle başlayan adlar MSBuild'de "bu dosyanın içine ait" demek için kullanılan bir gelenek.

Item'ları target'lar çalıştıktan sonra görmek için dotnet msbuild tools/Bookstore.Admin -t:GetAssemblyAttributes -getItem:AssemblyAttribute kullandık. SDK'nın kendisinin de aynı item'ı kullandığını gördük; 6. yazıda açtığımız IsAotCompatible, assembly'ye IsTrimmable ve IsAotCompatible metadata'sı ekliyor. bookstore-admin info bu metadata'yı reflection ile okuyor. Commit'lenmemiş bir değişiklikle build ettiğimizde:

Version:     1.0.0+63bef849f427cfce32eda3cc55640dc0e9d9d857
Source:      63bef849f427cfce32eda3cc55640dc0e9d9d857 + local changes

Hook seçimi önemli. Aynı işi AfterTargets="Build" ile yapmak işe yaramazdı; assembly o noktada çoktan derlenmiş olur. 6. yazıda bahsettiğimiz .NET 11'in UseOptimizedPublish ayarı da Native AOT (Ahead-of-Time; publish sırasında makine koduna derleme) publish'te tam Build target'ını atlıyor; derleme zincirindeki bir target'a bağlanan bu target orada da çalışmaya devam eder.

Angular Build'ini Publish'e Bağlamak

Bookstore'un Angular istemcisi geliştirmede ng serve ve proxy ile çalışıyor. Production'da API ile tek paket olarak çıkmasını istiyoruz: dotnet publish src/Bookstore.Api Angular build'ini de çalıştırmalı ve çıktıyı wwwroot'a koymalı. Bunun için Bookstore.Api.csproj'a üç target ekledik:

<!-- npm ci yalnız package-lock.json, npm'in node_modules içindeki kopyasından yeniyse. -->
<Target Name="RestoreClient"
        Inputs="$(ClientRoot)package-lock.json"
        Outputs="$(ClientRoot)node_modules\.package-lock.json">
  <Exec Command="npm ci" WorkingDirectory="$(ClientRoot)" />
</Target>

<!-- ng build yalnız bir kaynak dosya build edilmiş index.html'den yeniyse. -->
<Target Name="BuildClient"
        DependsOnTargets="RestoreClient"
        Inputs="@(ClientSource)"
        Outputs="$(ClientDist)index.html">
  <Exec Command="npm run build" WorkingDirectory="$(ClientRoot)" />
</Target>

<!-- BuildClient'tan ayrı; BuildClient atlandığında da dosyalar eklenir. -->
<Target Name="PublishClient"
        DependsOnTargets="BuildClient"
        BeforeTargets="ComputeResolvedFilesToPublishList"
        Condition="'$(SkipClientBuild)' != 'true'">
  <ItemGroup>
    <ClientFile Include="$(ClientDist)**" />
    <ResolvedFileToPublish Include="@(ClientFile)"
                           RelativePath="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
                           CopyToPublishDirectory="PreserveNewest" />
  </ItemGroup>
  <Message Importance="high" Text="Client files: @(ClientFile->'%(RecursiveDir)%(Filename)%(Extension)', ', ')" />
</Target>

ComputeResolvedFilesToPublishList, SDK'nın publish klasörüne gidecek dosyaların listesini çıkardığı target. Biz ondan hemen önce listeye Angular dosyalarını ekliyoruz; RelativePath metadata'sı her dosyanın publish klasöründe nereye gideceğini söylüyor ve %(RecursiveDir) sayesinde alt klasörler korunuyor. Bu target yalnız publish'te çalışır; dotnet build npm'e dokunmaz. Angular'ı ayrı bir CI job'ında build eden bir pipeline -p:SkipClientBuild=true ile bu adımı kapatabiliyor.

dotnet publish src/Bookstore.Api
  Bookstore.Api -> C:\src\Bookstore\artifacts\bin\Bookstore.Api\release\Bookstore.Api.dll
  added 293 packages, and audited 294 packages in 3s
  ...
  > bookstore-web@0.0.0 build
  > ng build
  ...
  Application bundle generation complete. [0.847 seconds] - 2026-11-15T06:38:01.185Z
  Output location: C:\src\Bookstore\web\bookstore-web\dist\bookstore-web
  Client files: favicon.ico, index.html, main-BDBHRKZT.js, styles-S5NLN6H7.css
  Bookstore.Api -> C:\src\Bookstore\artifacts\publish\Bookstore.Api\release\

API tarafında birkaç satır gerekti: UseDefaultFiles, UseStaticFiles ve Angular'ın kendi route'ları için MapFallbackToFile("index.html"). Fallback'i ilk eklediğimizde bir yan etkiyi fark ettik: /api/ altındaki bilinmeyen bir adres de 200 ve index.html dönüyordu. API istemcisi JSON yerine HTML alır ve hatayı geç anlar. /api/{**path} için 404 dönen ayrı bir fallback ekledik. Publish edilmiş API'yi yerelde çalıştırıp veritabanına gitmeyen adreslerle denedik: / ve /books/9780441172719 text/html, /main-BDBHRKZT.js text/javascript, /api/nothing 404 ve application/problem+json döndü.

Inputs ve Outputs ile Incremental Build

Inputs ve Outputs, target'ın ne zaman atlanabileceğini MSBuild'e söyler: her output, her input'tan yeniyse target çalışmaz. İkinci publish'in ayrıntılı log'unda (-v:d) bunu gördük:

Skipping target "RestoreClient" because all output files are up-to-date with respect to the input files.
Skipping target "BuildClient" because all output files are up-to-date with respect to the input files.

RestoreClient'ın output'u npm'in kendi tuttuğu node_modules/.package-lock.json dosyası; npm ci onu her kurulumda yazıyor, bu yüzden lock file değişmedikçe target atlanıyor. Farkı ölçmek için iki attribute'u geçici olarak kaldırıp publish'i üçer kez tekrarladık: her publish yaklaşık 4.6 saniye sürdü, çünkü npm ci her seferinde node_modules'ü silip yeniden kurdu ve Angular yeniden build edildi. Attribute'lar yerindeyken aynı publish 0.6 saniyede bitti. Bookstore'un istemcisi küçük; gerçek bir uygulamada bu fark dakikalar olur.

PublishClient'ı neden ayrı bir target yaptık? Dosya listesini BuildClient'ın içinde kursaydık, target atlandığı build'lerde ne olacağı MSBuild'in skipped target'larla ilgili kurallarına kalırdı. Ayrı ve Inputs'suz bir target her publish'te çalışır ve listeyi o anki dist klasöründen kurar. Pahalı işi ve ucuz işi ayırmak, incremental target yazmanın en sade yolu.

Temizlik de bir target: <Target Name="CleanClient" AfterTargets="Clean"> içinde RemoveDir task'ı dist klasörünü siliyor ve dotnet clean src/Bookstore.Api bunu da yapıyor. Aynı işi Exec ile rm -rf yazarak yapsaydık, Windows'ta Exec komutu cmd.exe ile çalıştırdığı için komut bulunamazdı. Dosya işlemleri için Copy, Delete, MakeDir, RemoveDir task'larını kullan; Exec'i npm ve git gibi her platformda aynı adla çalışan araçlara bırak.

Binlog ile "Bu Değer Nereden Geldi?"

Build'in içinde ne olduğunu görmenin aracı binary log. -bl ile alınır, sıkıştırılmış tek bir dosyadır ve diagnostic seviyedeki tüm mesajları, import edilen her dosyanın içeriğini, property ve item'ların değişikliklerini, task'ların input ve output'larını içerir. 5. yazıdaki soruyu tekrar soralım: Bookstore.Contracts'ta TargetFramework'ü kim boşaltıyor?

dotnet build src/Bookstore.Contracts -bl
dotnet msbuild msbuild.binlog -noconlog "-flp:v=diag;logfile=diag.log"
Select-String -Path diag.log -Pattern 'Property reassignment: \$\(TargetFramework\)' -List
diag.log:92: C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj(5,5): message : Property reassignment: $(TargetFramework)="" (previous value: "net10.0") at C:\src\Bookstore\src\Bookstore.Contracts\Bookstore.Contracts.csproj (5,5)

İkinci komut build'i tekrar çalıştırmıyor; binlog'u "yeniden oynatıp" diagnostic seviyede bir metin log'u yazıyor. Cevap tek satırda: değer csproj'un 5. satırında, net10.0'dan boş string'e değişmiş. PowerShell'de -flp parametresini tırnak içine almak gerekiyor, çünkü ; orada komut ayıracı.

Neden baştan -v:diag ile build almıyoruz? İki TFM'li (Target Framework Moniker; hedef framework adı) tek bir küçük projenin diagnostic log'u 134 bin satır tuttu; binlog 500 KB. -v:diag build'i yavaşlatır, çıktıyı terminale döker ve hangi bilgiye ihtiyacın olacağını önceden bilmeni ister. Binlog'u bir kez alırsın, sonra istediğin kadar sorgularsın. Binlog'ları ağaç yapısında gezmek, target sürelerini görmek ve property aramak için MSBuild Structured Log Viewer var (msbuildlog.com); bu yazıda GUI'yi (grafik arayüz) kullanmadık, her şeyi komut satırından yaptık.

Binlog'u paylaşmadan önce içeriğini düşün. Denememizde -p ile verilen bir property değeri replay log'unda sekiz kez geçti; build'in kullanmadığı bir ortam değişkeni ise hiç geçmedi. Import edilen dosyaların tam içeriği de binlog'da. CI'da binlog'u artifact olarak saklıyorsan ona secret gibi davran: komut satırından verilen connection string ve token'lar orada.

Üretimde Dikkat

CI'da her build'i -bl ile al ve başarısız build'lerde binlog'u kısa ömürlü, erişimi kısıtlı bir artifact olarak sakla. Kırmızı bir build'i yerelde tekrar üretmeye çalışmak yerine binlog'u açmak çoğu zaman dakikalar kazandırır.

Custom target'larını incremental yaz ve ölç. Bir target'ın her build'de çalışıp çalışmadığını -v:d çıktısındaki Skipping target satırından ya da binlog'daki target sürelerinden görürsün. Inputs ve Outputs eksik bir target, geliştiricinin her dotnet build'ine sessizce saniyeler ekler.

Target'ları SDK'nın iç target adlarına bağlarken dikkatli ol. GetAssemblyAttributes ve ComputeResolvedFilesToPublishList gibi alt çizgisiz target'lar SDK'nın genel yüzeyi sayılır; .NET 11'in breaking change notu da publish'e dosya eklemek için ComputeResolvedFilesToPublishList'ten önceki noktayı öneriyor. Alt çizgiyle başlayan SDK target'ları ise iç detaydır ve bir sonraki SDK sürümünde değişebilir. 9. yazıda SDK sürümü değiştirirken bu tür bağlantıları kontrol edeceğiz.

Sık Düşülen Tuzaklar

Her build'de çalışan pahalı target. Inputs/Outputs olmadan npm ci ve ng build her publish'te tekrar çalıştı; bizim küçük istemcimizde bile her seferinde dört saniye.

Exec ile platforma bağımlı komut. rm, cp ya da bash'e özgü bir sözdizimi Windows'ta cmd.exe'de çalışmaz; tersi de geçerli. MSBuild'in kendi task'larını kullan.

Evaluation'da execution değerine bakmak. Target'larda oluşan bir property'yi proje dosyasının en üstündeki bir koşulda kullanmak her zaman boş değeri görür.

Hash için gereksiz target. .NET 8'den beri SDK commit hash'ini InformationalVersion'a kendisi ekliyor; aynı işi yapan bir Exec git rev-parse target'ı build'i yalnız yavaşlatır.

Sorunu -v:diag ile aramak. Yüz binlerce satır içinde aradığını bulmak, binlog'u bir kez alıp sorgulamaktan hem yavaş hem zor.

Küçük Bir Deneme

★☆☆ dotnet build tools/Bookstore.Admin -bl ile bir binlog al, diagnostic log'a çevir ve AddSourceStatusMetadata target'ının ne kadar sürdüğünü bul. İpucu: -flp parametresine ;PerformanceSummary eklersen log'un sonunda target'ların sürelerini gösteren bir özet çıkar.

★★☆ PublishClient'a, Angular build'i production dışında bir configuration ile çalıştıran bir ClientConfiguration property'si ekle (npm run build -- --configuration $(ClientConfiguration)). Değeri -p ile değiştirdiğinde BuildClient'ın tekrar çalışmadığını gör ve bunu nasıl düzelteceğine karar ver.

★★★ Commit'in tarihini de metadata'ya ekle. git log -1 --format=%cI'yı Exec ile çalıştırmayı dene ve % karakterinin MSBuild'de %25 olarak yazılması gerektiğini gör. Windows'ta cmd.exe'nin de %'yi yorumladığını hesaba katarak komutun iki platformda aynı sonucu verdiğini test et.

Bu yazıda build'in kendisine kod yazdık. Serinin son yazısında geriye bakacağız: .NET 6'dan 11'e SDK'da neyin değiştiğini, eski bir projeyi okurken nelere dikkat etmek gerektiğini ve Bookstore'un CI'ını SDK 11'e taşırken nelerin kırıldığını göreceğiz.