使用 Web 部署和 .NET 构建一次,随处部署 Web.configs

Build Once, Deploy Everywhere with Web Deploy and .NET Web.configs

我正在努力建立一个持续构建和部署系统,该系统将管理我们的 .NET 应用程序在多个环境中的构建和部署。我们希望这样做,以便我们构建、将该构建部署到我们的开发环境,并且稍后可以选择使用不同的配置文件设置将同一构建部署到我们的测试环境。目前,我们的开发人员习惯于使用 web.config 转换来管理每个环境的配置值,他们更愿意继续这样做。最后,我们希望使用 MS Web Deploy 3.6 及其包部署选项进行部署。

经过一些研究,我们发现并考虑了以下选项:

  1. 使用 Web 部署参数化功能在部署时更改配置文件。这将取代我们希望避免的 web.config 转换。
  2. 运行 每个项目一次 MSBuild configuration/web.config 转换为每个环境生成包含转换后 web.config 的包。这样做的缺点是增加了我们包的构建时间和存储要求。
  3. 同时使用 Web 部署参数化和 web.config 转换。这允许开发人员继续使用 web.configs 来调试其他环境,并避免创建多个包,但需要我们在多个地方维护配置设置。
  4. 在构建时,使用 web.config 转换生成多个配置文件,但只生成一个包,在部署时使用脚本将正确的配置插入包中的正确位置。这似乎说起来容易做起来难,因为这不是 Web 部署的设计工作方式,而且我们的初步评估似乎实施起来很复杂。

还有没有我们没有考虑过的其他选择?有没有一种方法可以让我们像以前一样继续使用 web.configs 但只生成一个 Web Deploy 包?

在 .NET 4.7.1 中,另一种选择是可能的:使用 ConfigurationBuilder.

这个想法是,自定义 class 有机会在 web.config 中包含的值传递给应用程序之前对其进行操作。这允许插入其他配置系统。

例如:使用与 ASP.NET Core 类似的配置方法,它包含的 NuGet 包可以在 .NET Framework 上独立使用,也可以加载 json 和覆盖 json 文件。然后可以使用环境变量(或任何其他值,如 IIS 应用程序池 ID、机器名称等)来确定要使用哪个覆盖 json 文件。

例如:如果有一个 appsettings.json 文件,例如

{
  "appSettings": { "Foo": "FooValue", "Bar": "BarValue" }
}

和一个包含

appsettings.Production.json文件
{
  "appSettings": { "Foo": "ProductionFooValue" }
}

可以像这样编写配置生成器

public class AppSettingsConfigurationBuilder : ConfigurationBuilder
{
    public override ConfigurationSection ProcessConfigurationSection(ConfigurationSection configSection)
    {
        if(configSection is AppSettingsSection appSettingsSection)
        {
            var appSettings = appSettingsSection.Settings;

            var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
            var appConfig = new ConfigurationBuilder()
              .AddJsonFile("appsettings.json", optional: false)
              .AddJsonFile($"appsettings.{environmentName}.json", optional: true)
              .Build();

            appSettings.Add("Foo", appConfig["appSettings:Foo"]);
            appSettings.Add("Bar", appConfig["appSettings:Bar"]);

        }

        return configSection;
    }
}

然后在 Web.config:

中连接配置生成器
<configSections>
  <section name="configBuilders" type="System.Configuration.ConfigurationBuildersSection, System.Configuration, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" restartOnExternalChanges="false" requirePermission="false"/>
</configSections>

<configBuilders>
  <builders>
    <add name="AppSettingsConfigurationBuilder" type="My.Project.AppSettingsConfigurationBuilder, My.Project"/>
  </builders>
</configBuilders>

<appSettings configBuilders="AppSettingsConfigurationBuilder" />

如果您随后在开发机器上将 ASPNETCORE_ENVIRONMENT(仅选择名称,因此 ASP.NET 同一服务器上的核心应用程序将使用相同的默认值)环境变量设置为 Development , ConfigurationManager.AppSettings["Foo"] 会看到 FooValue 而不是 FooProductionValue.

您还可以使用 APP_POOL_ID 硬编码环境名称或使用 IIS 10 功能 set environment variables on app pools。这样你就可以真正地构建一次并将相同的输出复制到不同的服务器,甚至复制到同一台服务器上的多个目录,并且仍然为不同的服务器使用不同的配置。

我不知道它是否比上面的选项 4 更简单,但我们要使用的解决方案是 运行 在 运行 宁 MSBuild 之前立即解析 web.config 转换并生成或扩充 parameters.xml 文件。这使我们能够灵活地使用参数化,并且能够修改 web.config 以外的配置文件,同时保留 web.config 转换的 100% 当前功能。这是我们目前为未来寻求者的利益而使用的脚本:

function Convert-XmlElementToString
{
    [CmdletBinding()]
    param([Parameter(Mandatory=$true)] $xml, [String[]] $attributesToExclude)

    $attributesToRemove = @()
    foreach($attr in $xml.Attributes) {
        if($attr.Name.Contains('xdt') -or $attr.Name.Contains('xmlns') -or $attributesToExclude -contains $attr.Name) {
            $attributesToRemove += $attr
        }
    }
    foreach($attr in $attributesToRemove) { $removedAttr = $xml.Attributes.Remove($attr) }

    $sw = New-Object System.IO.StringWriter
    $xmlSettings = New-Object System.Xml.XmlWriterSettings
    $xmlSettings.ConformanceLevel = [System.Xml.ConformanceLevel]::Fragment
    $xmlSettings.Indent = $true
    $xw = [System.Xml.XmlWriter]::Create($sw, $xmlSettings)
    $xml.WriteTo($xw)
    $xw.Close()
    return $sw.ToString()
}

function BuildParameterXml ($name, $match, $env, $value, $parameterXmlDocument) 
{
    $existingNode = $parameterXmlDocument.selectNodes("//parameter[@name='$name']")
    $value = $value.Replace("'","&apos;") #Need to make sure any single quotes in the value don't break XPath

    if($existingNode.Count -eq 0){
        #no existing parameter for this transformation
        $newParamter = [xml]("<parameter name=`"" + $name + "`">" +
                    "<parameterEntry kind=`"XmlFile`" scope=`"\web.config$`" match=`"" + $match + "`" />" +
                    "<parameterValue env=`"" + $env + "`" value=`"`" />" +
                    "</parameter>")
        $newParamter.selectNodes('//parameter/parameterValue').ItemOf(0).SetAttribute('value', $value)
        $imported=$parameterXmlDocument.ImportNode($newParamter.DocumentElement, $true)
        $appendedNode = $parameterXmlDocument.selectNodes('//parameters').ItemOf(0).AppendChild($imported)

    } else {
        #parameter exists but entry is different from an existing entry
        $entryXPath = "//parameter[@name=`"$name`"]/parameterEntry[@kind=`"XmlFile`" and @scope=`"\web.config$`" and @match=`"$match`"]"
        $existingEntry = $parameterXmlDocument.selectNodes($entryXPath)
        if($existingEntry.Count -eq 0) { throw "There is web.config transformation ($name) that conflicts with an existing parameters.xml entry" }

        #parameter exists but environment value is different from an existing environment value
        $envValueXPath = "//parameter[@name='$name']/parameterValue[@env='$env' and @value='$value']"
        $existingEnvValue = $parameterXmlDocument.selectNodes($envValueXPath)
        $existingEnv = $parameterXmlDocument.selectNodes("//parameter[@name=`"$name`"]/parameterValue[@env=`"$env`"]")

        if($existingEnvValue.Count -eq 0 -and $existingEnv.Count -gt 0) { 
            throw "There is web.config transformation ($name) for this environment ($env) that conflicts with an existing parameters.xml value"
        } elseif ($existingEnvValue.Count -eq 0  -and $existingEnv.Count -eq 0) {
            $newParamter = [xml]("<parameterValue env=`"" + $env + "`" value=`"`" />")
            $newParamter.selectNodes('//parameterValue').ItemOf(0).SetAttribute('value', $value)
            $imported=$parameterXmlDocument.ImportNode($newParamter.DocumentElement, $true)
            $appendedNode = $existingNode.ItemOf(0).AppendChild($imported)
        }
    }
}

function UpdateSetParams ($node, $originalXml, $path, $env, $parametersXml) 
{
    foreach ($childNode in $node.ChildNodes) 
    {
        $xdtValue = ""
        $name = ""
        $match = ($path + $childNode.toString())

        if($childNode.Attributes -and $childNode.Attributes.GetNamedItem('xdt:Locator').Value) {
            $hasMatch = $childNode.Attributes.GetNamedItem('xdt:Locator').Value -match ".?\((.*?)\).*"
            $name = $childNode.Attributes.GetNamedItem($matches[1]).Value
            $match = $match + "[@" + $matches[1] + "=`'" + $name + "`']"
        }

        if($childNode.Attributes -and $childNode.Attributes.GetNamedItem('xdt:Transform')) {
            $xdtValue = $childNode.Attributes.GetNamedItem('xdt:Transform').Value
        }

        if($xdtValue -eq 'Replace') {
            if($childNode.Attributes.GetNamedItem('xdt:Locator').Value) {
                $hasMatch = $childNode.Attributes.GetNamedItem('xdt:Locator').Value -match ".?\((.*?)\).*"
                $name = $childNode.Attributes.GetNamedItem($matches[1]).Value
            } else {
                $name = $childNode.toString()
            }
            $nodeString = Convert-XmlElementToString $childNode.PsObject.Copy()

            BuildParameterXml $name $match $env $nodeString $parametersXml

        } elseif ($xdtValue.Contains('RemoveAttributes')) {

            if($originalXml.selectNodes($match).Count -gt 0) {
                $hasMatch = $xdtValue -match ".?\((.*?)\).*"
                $nodeString = Convert-XmlElementToString $originalXml.selectNodes($match).ItemOf(0).PsObject.Copy() $matches[1].Split(',')

                $newParamter = BuildParameterXml $childNode.toString() $match $env $nodeString $parametersXml

                $newParamters += $newParamter
            }
        } elseif ($xdtValue.Contains('SetAttributes')) { 
            if($originalXml.selectNodes($match).Count -gt 0) {
                $nodeCopy = $originalXml.selectNodes($match).ItemOf(0).PsObject.Copy()
                $hasMatch = $xdtValue -match ".?\((.*?)\).*"
                foreach($attr in $matches[1].Split(',')){
                    $nodeCopy.SetAttribute($attr, $childNode.Attributes.GetNamedItem($attr).Value)
                }
                $nodeString = Convert-XmlElementToString $nodeCopy

                BuildParameterXml $childNode.toString() "($match)[1]" $env $nodeString $parametersXml
            }
        } elseif ($xdtValue) {
            throw "Yikes! the script doesn't know how to handle this transformation!"
        }
        #Recurse into this node to check if it has transformations on its children
        if($childNode) {
            UpdateSetParams $childNode $originalXml ($match + "/") $env $parametersXml
        }
    }
}

function TransformConfigsIntoParamters ($webConfigPath, $webConfigTransformPath, $parametersXml) 
{
    #Parse out the environment names
    $hasMatch = $webConfigTransformPath -match ".?web\.(.*?)\.config.*"
    [xml]$transformXml = Get-Content $webConfigTransformPath
    [xml]$webConfigXml = Get-Content $webConfigPath
    UpdateSetParams $transformXml $webConfigXml '//' $matches[1] $parametersXml
}

$applicationRoot = $ENV:WORKSPACE

if(Test-Path ($applicationRoot + '\parameters.xml')) {
    [xml]$parametersXml = Get-Content ($applicationRoot + '\parameters.xml')
    $parametersNode = $parametersXml.selectNodes('//parameters').ItemOf(0)
} else {
    [System.XML.XMLDocument]$parametersXml=New-Object System.XML.XMLDocument
    [System.XML.XMLElement]$parametersNode=$parametersXml.CreateElement("parameters")
    $appendedNode = $parametersXml.appendChild($parametersNode)
}

TransformConfigsIntoParamters ($applicationRoot + '\web.config') ($applicationRoot + '\web.Development.config') $parametersXml
TransformConfigsIntoParamters ($applicationRoot + '\web.config') ($applicationRoot + '\web.SystemTest.config') $parametersXml
TransformConfigsIntoParamters ($applicationRoot + '\web.config') ($applicationRoot + '\web.Production.config') $parametersXml

$parametersXml.Save($applicationRoot + '\parameters.xml')