最近一直在做自動部署工具,主要利用到了Msbuild的自定義Task,通過Task我們可以自定義編譯、部署過程減少人工直接干預。Msbuild的詳細用法,可以去園子里搜一下,有很多的基礎教程,這里就不贅述了,還是集中說一下增量發布的問題。
增量主要涉及到三部分內容,程序、配置和靜態文件(例如CSS、JS等),程序的增量比較簡單,通過版本對比或者TFS的修改記錄便可以查詢出被修改過的程序集。配置文件增量大致有兩種,全增量和部分增量。全增量也很簡單,直接把修改過的配置文件復制到發布包就OK了;部分增量需要我們比較這個配置里所有修改過的節點、屬性,並且只輸出這些改動。之前做發布包均是通過VS人工比較兩個版本的配置文件,手動COPY出來,再放入到發布包。這樣做在配置文件比較少的時候沒有問題,但是整個項目的工程較多,配置文件非常多就很麻煩了,每次單獨做增量配置文件就要花費很長時間。我們可以利用Task來完成整個項目的增量部署包制作。靜態文件的處理通常只是將開發版本進行壓縮輸出,這個園子里已經有成熟實例了,我們這里就不單獨寫出來了。
目錄
1. Hello Task
新建一個Library項目,命名為HelloTask,同時新建一個HelloTask類,添加如下引用
using Microsoft.Build.Framework; using Microsoft.Build.Utilities;
添加如下代碼
1 public class HelloTask : Microsoft.Build.Utilities.Task 2 { 3 public override bool Execute() 4 { 5 Log.LogMessage("Hello Task!"); 6 return true; 7 } 8 }
這是再在Solution里添加一個名為HelloTask.Web的空Web項目,用來做實驗(其它隨便什么類型項目都行,WEB項目主要是后面做增量實驗的時候有用),右鍵單擊Publish,新建一個名為HelloTask的部署配置文件,Publish Method選File System,選好發布路徑。這時,在Properties會多一個PublishProfiles\HelloTask.pubxml文件,這便是發布的配置,這個也可以直接寫入到msbuild的配置里去。修改為如下內容
<?xml version="1.0" encoding="utf-8"?> <!-- This file is used by the publish/package process of your Web project. You can customize the behavior of this process by editing this MSBuild file. In order to learn more about this please visit http://go.microsoft.com/fwlink/?LinkID=208121. --> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <UsingTask AssemblyFile="$(MSBuildProjectDirectory)\..\HelloTask.Lib\HelloTask.dll" TaskName="HelloTask"></UsingTask> <PropertyGroup> <WebPublishMethod>FileSystem</WebPublishMethod> <LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration> <LastUsedPlatform>Any CPU</LastUsedPlatform> <SiteUrlToLaunchAfterPublish /> <LaunchSiteAfterPublish>True</LaunchSiteAfterPublish> <ExcludeApp_Data>False</ExcludeApp_Data> <publishUrl>Publish</publishUrl> <DeleteExistingFiles>False</DeleteExistingFiles> </PropertyGroup> <Target Name="HelloTask" AfterTargets="GatherAllFilesToPublish"> <HelloTask></HelloTask> </Target> </Project>
注意AssemblyFile的路徑,需要指向HelloTask.dll的輸出目錄,這里我在Output里修改過。然后右鍵選中Web項目,選擇Publish, 輸出窗口便可以看到輸出,同時在設置的發布路徑下可以看到整個Web項目的輸出。
要實現自定義Task,我們主要需要實現Microsoft.Build.Framework.dll里面ITask接口,其定義如下
namespace Microsoft.Build.Framework { public interface ITask : object { bool Execute(); IBuildEngine BuildEngine { get; set; } ITaskHost HostObject { get; set; } } }
BuildEngine定義了編譯引擎接口,HostObject定義了編譯的宿主信息,這里Microsoft.Build.Utilities里為我們實現了這個接口,我們只需要重寫Execute這個方法就行。上面就實現了一個非常簡單的輸出Hello Task的功能。
2. TFS 基本操作
雖然現在Git滿天飛,但是在做.Net項目是我們還是依然再用TFS,畢竟和VS集成度最好。這里需要使用TFS API來對項目和配置文件的修改狀態進行判斷,從而實現增量輸出。在Task項目里再添加如下引用
using Microsoft.TeamFoundation.Client; using Microsoft.TeamFoundation.VersionControl.Client;
然后我們分別介紹下后面需要用到的操作,其他TFS的API童鞋們自行研究吧,
public static VersionControlServer Open(string path) { var info = Workstation.Current.GetLocalWorkspaceInfo(path); var uri = info.ServerUri; //var uri = "..." var tfsCollection = new TfsTeamProjectCollection(uri); return tfsCollection.GetService<VersionControlServer>(); }
這里在獲取VersionControlServer實例的時候轉了下彎,其實可以直接輸入服務器的uri,但是這里首先獲取了本地的代碼倉庫,從本地的代碼倉庫再獲取到了服務器的uri;同時TfsTeamProjectCollection有多個重載,可以顯示的提供登陸憑據,這里我們偷懶,直接利用系統里已經存好的憑據登陸,具體可以查看
返回的VersionControlServer實例是后續操作的基礎,因此在這個靜態類的我們將它放入
public static VersionControlServer SourceControl { get; set; }
TFS里,每一次Check-In,會提交一個Changeset,里面包含了本次改動的所有Change,這些Change都對應到Item的,即我們版本管理的每一個文件。我們發布時,通常的做法是在當前版本的代碼上打上相應的標注Label,使其成為一個特定的版本。那么,在增量發布中,我們就可以通過這些特性的標注來獲取版本間的差別。創建標注的代碼如下
public static void CreateLabel(string scope, string label) { var itemSpec = new ItemSpec(scope, RecursionType.Full); var labelItemSpec = new LabelItemSpec(itemSpec, VersionSpec.Latest, false); var vslabel = new VersionControlLabel(SourceControl, label, SourceControl.AuthorizedUser, scope, label); SourceControl.CreateLabel(vslabel, new[] { labelItemSpec }, LabelChildOption.Replace); }
scope為這個標注的范圍,通常我們以解決方案為發布的基本單元,那么傳入這個解決方案的目錄就可以了。查詢標注的代碼如下
public static VersionControlLabel QueryLabel(string scope, string label) { return SourceControl.QueryLabels(label, null, null, true, scope, VersionSpec.Latest).FirstOrDefault(); }
如果要查詢所有標注,第一個參數可以傳入null。有了這些標注的操作,我們就可以獲取這些特定版本之間的的Changeset了
public static IEnumerable<Changeset> Changes(string scope, string label1, string label2 ) { var vsLabel1 = QueryLabel(scope,label1); var vsLabel2 = QueryLabel(scope, label2); var vsLabelSpec1 = new LabelVersionSpec(vsLabel1.Name, vsLabel1.Scope); var vsLabelSpec2 = new LabelVersionSpec(vsLabel2.Name, vsLabel2.Scope); return SourceControl.QueryHistory(vsLabelSpec1.Scope, VersionSpec.Latest, 0, RecursionType.Full, null, vsLabelSpec1, vsLabelSpec2, int.MaxValue, true, false) .Cast<Changeset>(); }
這里需要注意的是label1的版本一定要比label2的版本新,否則在調用QueryHistory的時候是沒有返回的。我們還要用到的一個方法是獲取特定標注下的某個文件,
public static Item GetSpecVersion(string scope, string label, Item item) { var vslabel = QueryLabel(scope,label); return SourceControl.GetItem(item.ServerItem, new LabelVersionSpec(vslabel.Name, vslabel.Scope)); }
有了這些方法,我們就可以開始實現增量發布了。
3. Start增量發布
正經Coding之前我們做點准備工作,方便調試。在Solution里在添加一個HelloTask.Task.Debug的Console項目,加入一個批處理文件debug.bat,內容如下
@echo off C:\Windows\Microsoft.NET\Framework64\v4.0.30319\msbuild "D:\HelloTask\HelloTask.Web\HelloTask.Web.csproj" /t:GatherAllFilesToPublish /p:PublishProfile=HelloTask;SolutionDir=D:\HelloTask\ /v:q
然后修改其屬性Copy to Output Direcotry -> Coy always,同時修改Debug項目的輸出目錄到HelloTask.dll的目錄。然后修改在Execute()加入調試代碼
#if DEBUG System.Diagnostics.Debugger.Launch(); #endif
還需要修改整個Solution的啟動項目為HelloTask.Task.Debug,這樣使用F5我們就可以直接調試程序了。批處理是執行IDE里面右鍵Publish的功能,Console是為了給Solution提供一個啟動的項目,這里用Process去調用批處理主要是為了瞬間讓IDE退出HelloTask.Task.Console的執行,從而准備好進入到HelloTask的調試,這樣不用開多個IDE了。
同時為了配合增量功能的實現,我們繼續修改發布的配置文件HelloTask.pubxml,增加修改如下內容
<ItemGroup> <Label Include="label1"> <From>lable2</From> </Label> </ItemGroup> <Target Name="HelloTask" AfterTargets="GatherAllFilesToPublish"> <HelloTask SolutionDir="$(MSBuildProjectDirectory)\..\" Version="@(Label)"></HelloTask> </Target>
再定義一些其他必要的結構如 ProjectItem 和 ChangedItem
public class ProjectItem { public static List<ProjectItem> ProjectCollection { get; set; } public static void GetAll(string solutionDir) { ProjectCollection = new List<ProjectItem>(); Directory.GetFiles(solutionDir, "*.csproj", SearchOption.AllDirectories) .ToList() .ForEach(t => ProjectCollection.Add(new ProjectItem(t))); } public static ProjectItem Find(Item item) { return ProjectCollection.ToList() .FirstOrDefault(t => item.ServerItem.IndexOf(t.Name) >= 0); } public string Name { get; set; } public string Path { get; set; } public string AssemblyName { get; set; } public bool Changed { get; set; } public string OutputType { get; set; } public ProjectItem(string path) { Path = path; Name = System.IO.Path.GetFileNameWithoutExtension(path); var doc = new XmlDocument(); doc.Load(path); var ns = new XmlNamespaceManager(doc.NameTable); ns.AddNamespace("ns", "http://schemas.microsoft.com/developer/msbuild/2003"); var node = doc.SelectSingleNode("//ns:PropertyGroup//ns:OutputType", ns); OutputType = node!=null? node.InnerText :string.Empty; node=doc.SelectSingleNode("//ns:PropertyGroup//ns:AssemblyName", ns); AssemblyName = node != null ? node.InnerText : string.Empty; this.Changed =false; } }
public class ChangedItem { public ChangedItem(ChangeType changeType, Microsoft.TeamFoundation.VersionControl.Client.Change change) { ChangeType = changeType; Change = change; } public ChangeType ChangeType { get; set; } public Microsoft.TeamFoundation.VersionControl.Client.Change Change { get; set; } public static IEnumerable<ChangedItem> Build(IEnumerable<Changeset> changesets) { var list = new List<ChangedItem>(); changesets = changesets.OrderBy(t => t.CreationDate); var changes = new List<Microsoft.TeamFoundation.VersionControl.Client.Change>(); changesets.ToList().ForEach(changeset => changes.AddRange(changeset.Changes)); changes.Distinct(new ChangeComparer()) .ToList() .ForEach(change =>{ var changeType = ChangeType.None; changes.Where(t => t.Item.ItemId == change.Item.ItemId) .ToList() .ForEach(t => changeType = changeType | t.ChangeType); list.Add(new ChangedItem(changeType, change)); }); return list; } } public class ChangeComparer : IEqualityComparer<Microsoft.TeamFoundation.VersionControl.Client.Change> { public bool Equals(Microsoft.TeamFoundation.VersionControl.Client.Change x, Microsoft.TeamFoundation.VersionControl.Client.Change y) { return x.Item.ItemId == y.Item.ItemId; } public int GetHashCode(Microsoft.TeamFoundation.VersionControl.Client.Change obj) { return obj.Item.ItemId.GetHashCode(); } }
主要說一下ChangedItem吧,因為我們在label之間查詢返回的是多個Changeset,因此可能會返回一個文件的多次修改狀態,因此,我們需要將這些狀態組合在一起來判斷兩個label之間這些文件的最終狀態,MS剛好提供了ChangeType這個這個Flags的枚舉,我要做的只用將同一個文件的多個ChangeType進行或操作。
然后定義了接口IAdditionable
public interface IAdditionable { void Republish(string publishFolder, string tempFolder); }
同時有一個默認的實現DefaultAddition
public class DefaultAddition : IAdditionable { public ChangedItem ChangedItem { get; set; } public ProjectItem ProjectItem { get; set; } public DefaultAddition(ChangedItem changedItem) { this.ChangedItem = changedItem; this.ProjectItem = ProjectItem.Find(changedItem.Change.Item); } public virtual void Republish(string publishFolder,string tempFolder) { FileUtility.CopyTo(GetAbsolutePath(publishFolder), GetAbsolutePath(tempFolder)); } protected string GetRelativePath() { var start = ChangedItem.Change.Item.ServerItem.IndexOf(ProjectItem.Name) + ProjectItem.Name.Length; return ChangedItem.Change.Item.ServerItem.Substring(start).Trim('/').Trim('\\'); } protected string GetAbsolutePath(string dir) { return Path.Combine(dir, GetRelativePath()); } }
默認的增量發布直接將文件Copy過去,這里實現的Republish標明為virtual類型,因為后面針對不同的的類型,我們將會直接繼承DefaultAddition這個,同時重寫Republish這個方法。FileUtility里提供了一些安全的文件操作方法,這個就懶得貼了。
4. DLL 和 配置文件 增量發布
然后首先實現一個個簡單的DLL增量,DLL增量無非是查找哪些項目里的*.cs文件被修改過(這里我們不考慮極端情況),改過我們就像這個項目的DLL添加到增量發布包里面。因此新建一個類ProjectAddition,具體實現如下,
public class ProjectAddition : DefaultAddition { public ProjectAddition(ChangedItem changedItem) : base(changedItem) { this.ChangedItem = changedItem; } public override void Republish(string publishFolder, string tempFolder) { var bin = "bin"; var assembly = string.Format("{0}.{1}", ProjectItem.AssemblyName, ProjectItem.OutputType == "Library" ? "dll" : "exe"); var pdb = string.Format("{0}.pdb", ProjectItem.AssemblyName); var assemblyFrom = Path.Combine(publishFolder, bin, assembly); var assemblyTo = Path.Combine(tempFolder, bin, assembly); var pdbFrom = Path.Combine(publishFolder, bin, pdb); var pdbTo = Path.Combine(tempFolder, bin, pdb); FileUtility.CopyTo(assemblyFrom,assemblyTo); FileUtility.CopyTo(pdbFrom,pdbTo); } }
因為發布的是Web項目,所以所有的dll都放在bin目錄下,這里為了方便路徑直接寫在了里方法里,其實應該寫在配置,同時如果輸出的時候有pdb調試文件,也會自動復制。然后我們需要一個工廠方法來對增量的類型進行構造,再添加AdditionManger
public class ChangeManger { static ChangeManger() { ChangeCollection = new List<IAdditionable>(); } public static List<IAdditionable> ChangeCollection { get; set; } public static void AddChange(ChangedItem changeItem) { var ext = Path.GetExtension(changeItem.Change.Item.ServerItem).ToLower(); IAdditionable changed = null; switch (ext) { case ".cs": changed = new ProjectAddition(changeItem); break; case ".config": changed = new ConfigurationAddition(changeItem); break; default: changed = new DefaultAddition(changeItem); break; } if (!ChangeCollection.Exists(t => t.Equals(changed))) ChangeCollection.Add(changed); } public static void AdditionChange() { var tempFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var publishFolder = @"D:\HelloTask\HelloTask.Web\Publish"; ChangeCollection.ForEach(t => t.Republish(publishFolder, tempFolder)); } }
這里一並給出了后面將要實現的ConfigurationAddition,這里.cs文件會根據它的項目信息返回前面提到的 ProjectItem實例,同時后面保證了在ChangeCollection里每個項目是唯一的。 AdditionChange這個方法則批量調動了增量接口。其中,里面的publishFolder也應該寫入配置,這里偷懶了下。
配置的增量發布實現起來稍微麻煩點,同時要向完全自動化還有點距離,因為在XML里面,對於一個List對象的修改操作本身存在二義性,我們無法准確的獲知這個節點的的修改狀態,因此我們這里對配置增量的策略是將所有的有修改的節點增量輸出,同時將List類型的對象整體輸出。
一個測試例子如下,
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <singleNode>1</singleNode> <complexNode> <unchange>content</unchange> <changed>11</changed> </complexNode> <links> <link> <name>百度</name> <url>www.baidu.com</url> </link> <link> <name>Google1</name> <url>http://www.google.com</url> </link> <link> <name>Bing</name> <url>http://www.bing.com</url> </link> </links> </Configuration>
修改后的配置如下,
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <singleNode>1</singleNode> <complexNode> <unchange>content</unchange> <changed>22</changed> </complexNode> <links> <link> <name>百度</name> <url>http://www.baidu.com</url> </link> <link> <name>Google</name> <url>http://www.google.com</url> </link> </link> <link> <name>Bing</name> <url>http://www.bing.com</url> </link> </links> </Configuration>
期待的增量配置如下,
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <complexNode> <changed>22</changed> </complexNode> <links> <link> <name>百度</name> <url>http://www.baidu.com</url> </link> <link> <name>Google</name> <url>http://www.google.com</url> </link> </links> </Configuration>
這里的link對象雖然分別只修改了一個元素的內容,但是我們將整個link全部輸出,是為了排除可能存在的二義性,帶來的問題是如果這樣的link節點較大,輸出的增量配置文件也會較大,但是畢竟作為增量配置,需要修改的內容明確。
然后具體實現如下,簡單說來通過對配置元素層級遞歸,每一層對兩個配置之間相互求差集,在將差集進行增量輸出
public class ConfigurationAddition : DefaultAddition { public ConfigurationAddition(ChangedItem changedItem) : base(changedItem) { this.ChangedItem = changedItem; } public override void Republish(string publishFolder, string tempFolder) { if (ChangedItem.ChangeType.HasFlag(ChangeType.Add)) { base.Republish(publishFolder, tempFolder); return; } var docAddit = new XmlDocument(); var docThis = new XmlDocument(); docThis.Load(GetAbsolutePath(publishFolder)); var itemSpec = TfsUtility.GetSpecVersion(this.Scope,this.Label,this.ChangedItem.Change.Item); var docSpec = new XmlDocument(); docSpec.Load(itemSpec.DownloadFile()); //導入XML Declaration if (docThis.FirstChild.NodeType == XmlNodeType.XmlDeclaration) docAddit.AppendChild(docAddit.ImportNode(docThis.FirstChild, true)); //設置DOcumentElement為增量文檔的第一個節點 var nodeThis = (XmlNode)docThis.DocumentElement; var nodeSpec = (XmlNode)docSpec.DocumentElement; var nodeAddit = docAddit.ImportNode(nodeThis, true); RecursiveCompareChildNode(nodeThis, nodeSpec, nodeAddit, docAddit); docAddit.AppendChild(nodeAddit); var path = Path.ChangeExtension(GetAbsolutePath(tempFolder), ".addition.config"); FileUtility.CreateIfNotExists(path); docAddit.Save(path); } private void RecursiveCompareChildNode(XmlNode nodeThis, XmlNode nodeSpec, XmlNode nodeAddit, XmlDocument docAddit) { //如果當前節點是LIST對象,直接輸出整個節點 if (nodeThis.ParentNode != null && nodeThis.ParentNode.ChildNodes.Cast<XmlNode>() .Count(t => t.Name == nodeThis.Name && t.NodeType == XmlNodeType.Element) > 1) return; nodeAddit.InnerXml = string.Empty; var listThis = nodeThis.ChildNodes.Cast<XmlNode>().ToList(); var listSpec = nodeSpec.ChildNodes.Cast<XmlNode>().ToList(); //*完全一樣的elements會被忽略掉 var comparer = new XmlNodeComparer(); var exceptsThis = listThis.Except(listSpec, comparer).ToList(); var exceptsSpec = listSpec.Except(listThis, comparer).ToList(); foreach (var exceptNode in exceptsThis) { var childAddit = docAddit.ImportNode(exceptNode, true); //如沒有element子節點,直接加入到增量文檔 if (exceptNode.ChildNodes.Cast<XmlNode>().All(t => t.NodeType != XmlNodeType.Element)) { nodeAddit.AppendChild(childAddit); continue; } var childSpec = nodeSpec.ChildNodes .Cast<XmlNode>() .ToList() .FirstOrDefault(t => NodePathCompare(t, exceptNode)); nodeAddit.AppendChild(childAddit); } foreach (var exceptNode in exceptsSpec) { if (!exceptsThis.Exists(t => t.Name == exceptNode.Name)) nodeAddit.AppendChild(docAddit.ImportNode(exceptNode, true)); } } private bool NodePathCompare(XmlNode node1, XmlNode node2) { var node1path = node1.Name.GetHashCode(); var node2path = node2.Name.GetHashCode(); var node = node1; while (node.ParentNode != null) { node = node.ParentNode; node1path += node.Name.GetHashCode(); } node = node2; while (node.ParentNode != null) { node = node.ParentNode; node2path += node.Name.GetHashCode(); } return node1path == node2path; } }//class
最后在我們的HelloTask里對這個AdditionManger進行調用就行了。目前方法可以輸出兩個配置之間的差異,但是還不能確定對這個節點具體的操作,后續如果研究出來將會補上。
