一、问题背景:当NuGet包开始"闹脾气"时

最近在帮同事排查一个特别有意思的问题:他们团队开发的NuGet包在部署后,脚本死活不执行。这就像你网购了一个智能灯泡,说明书上说"安装后会自动闪烁三次",结果装上去愣是一点反应都没有。这种情况在.NET Core项目中使用NuGet包时特别常见,尤其是那些需要在安装后自动执行数据库迁移或者配置文件修改的包。

先来看个典型场景:我们有个内部工具包Company.Utils,安装后需要自动在项目里添加一个默认配置文件。按照标准做法,我们在包的tools目录下放了install.ps1脚本:

# Company.Utils包的安装脚本(PowerShell示例)
param($installPath, $toolsPath, $package, $project)

# 尝试在项目根目录创建配置文件
$configFile = Join-Path (Split-Path $project.FullName) "appsettings.utils.json"
if (!(Test-Path $configFile)) {
    @{
        "LogLevel" = "Information";
        "ApiEndpoint" = "https://api.company.com"
    } | ConvertTo-Json | Out-File $configFile
    Write-Host "已成功创建配置文件:$configFile"
}

理想很丰满,现实很骨感。这个脚本在本地测试时运行良好,但到了CI/CD流水线里就悄无声息了,连错误信息都不给。

二、抽丝剥茧:五大常见故障原因

经过和这个问题几天的"亲密接触",我总结出了以下五个最常见的罪魁祸首:

1. 权限不足,脚本被静默拦截

现代开发环境的安全策略越来越严格,特别是在Windows系统上。PowerShell默认执行策略可能是Restricted,这时候脚本根本不会运行。

解决方案是在脚本开头添加权限检查:

# 检查并设置执行策略(需要管理员权限)
$currentPolicy = Get-ExecutionPolicy
if ($currentPolicy -eq "Restricted") {
    Write-Warning "当前执行策略为Restricted,尝试设置为RemoteSigned"
    Set-ExecutionPolicy RemoteSigned -Scope Process -Force
}

2. 脚本路径放错了地方

NuGet对脚本文件的位置有严格要求。必须是:

  • /tools/install.ps1
  • /tools/uninstall.ps1
  • /tools/init.ps1

我曾经见过有开发者把脚本放在/build或者/content目录下,那肯定是不工作的。就像把汽车钥匙放在冰箱里,车当然发动不起来。

3. 脚本执行超时

在大型解决方案中,NuGet包管理器给脚本的执行时间可能不够。默认超时时间是30秒,可以通过在脚本开头添加以下代码来延长:

# 延长PSHost超时时间(单位:毫秒)
$host.UI.RawUI.BufferSize = New-Object Management.Automation.Host.Size (512, 50)
$host.PrivateData.WindowSize = New-Object Management.Automation.Host.Size (100, 50)
$host.PrivateData.WindowTitle = "Company.Utils 安装脚本"

4. 依赖的组件未就绪

有时候脚本本身没问题,但它依赖的组件还没准备好。比如下面这个需要SQL Server LocalDB的案例:

# 检查SQL Server LocalDB是否可用
try {
    $connection = New-Object System.Data.SqlClient.SqlConnection
    $connection.ConnectionString = "Server=(localdb)\mssqllocaldb;Integrated Security=true"
    $connection.Open()
    $connection.Close()
} catch {
    Write-Error "需要先安装SQL Server LocalDB"
    exit 1
}

5. 输出被"吃掉"了

在CI/CD环境中,脚本的输出可能被重定向到虚空。建议总是把关键操作记录到文件:

# 将安装日志写入文件
Start-Transcript -Path (Join-Path $env:TEMP "Company.Utils_install.log")
try {
    # 你的安装代码...
} finally {
    Stop-Transcript
}

三、实战演练:构建健壮的部署后脚本

让我们通过一个完整的示例,演示如何构建一个可靠的NuGet包部署后脚本。这个示例将实现以下功能:

  1. 检查运行环境
  2. 添加项目引用
  3. 修改配置文件
  4. 执行数据库迁移
<#
.SYNOPSIS
    Company.Data 包的安装脚本
.DESCRIPTION
    该脚本在NuGet包安装后执行,用于:
    - 添加EntityFrameworkCore引用
    - 配置数据库连接字符串
    - 执行初始数据迁移
#>
param($installPath, $toolsPath, $package, $project)

# 1. 初始化环境
$ErrorActionPreference = "Stop"
Start-Transcript -Path (Join-Path $env:TEMP "Company.Data_install.log")

try {
    # 2. 添加必要的程序集引用
    $assemblies = @(
        "Microsoft.EntityFrameworkCore",
        "Microsoft.EntityFrameworkCore.SqlServer",
        "Microsoft.EntityFrameworkCore.Tools"
    )
    
    foreach ($asm in $assemblies) {
        if (-not ($project.Object.References | Where-Object { $_.Name -eq $asm })) {
            $project.Object.References.Add($asm)
            Write-Host "已添加程序集引用:$asm"
        }
    }

    # 3. 更新appsettings.json
    $projectDir = Split-Path $project.FullName
    $appSettingsPath = Join-Path $projectDir "appsettings.json"
    
    if (Test-Path $appSettingsPath) {
        $config = Get-Content $appSettingsPath | ConvertFrom-Json
        if (-not $config.PSObject.Properties['ConnectionStrings']) {
            $config | Add-Member -MemberType NoteProperty -Name "ConnectionStrings" -Value @{}
        }
        
        $config.ConnectionStrings.DefaultConnection = 
            "Server=(localdb)\\mssqllocaldb;Database=CompanyData;Trusted_Connection=True;"
        
        $config | ConvertTo-Json -Depth 10 | Out-File $appSettingsPath -Force
        Write-Host "已更新数据库连接字符串配置"
    }

    # 4. 执行数据库迁移
    $packageManager = Get-VSService "NuGet.UI.IVsPackageManagerProvider" "NuGet.PackageManagement.UI.IPackageManagerProvider"
    $solutionDir = Split-Path (Get-Interface $dte.Solution (Get-TypeName "EnvDTE.Solution")).FullName
    $projectName = $project.Name
    
    Push-Location $solutionDir
    try {
        dotnet ef migrations add InitialCreate --project $projectName --context ApplicationDbContext
        dotnet ef database update --project $projectName
        Write-Host "数据库迁移已完成"
    } finally {
        Pop-Location
    }
} catch {
    Write-Error "安装过程中发生错误:$_"
    exit 1
} finally {
    Stop-Transcript
}

四、避坑指南:那些年我们踩过的坑

在NuGet包脚本开发这条路上,前辈们用血泪换来了这些经验:

  1. 不要在脚本里做不可逆操作
    比如删除文件或者清空数据库。脚本可能会被多次执行,这类操作应该交给用户手动处理。

  2. 考虑跨平台兼容性
    如果你的脚本要在Linux/macOS上运行,记住:

    • 使用PowerShell Core而不是Windows PowerShell
    • 路径分隔符用Join-Path而不是硬编码的"\"
    • 避免调用Windows特有的命令如regedit
  3. 处理项目类型差异
    Web项目、控制台项目和类库项目的结构不同。好的脚本应该能识别项目类型并做相应处理:

# 检测项目类型
$projectTypeGuids = $project.Kind
$isWebProject = $projectTypeGuids -eq "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"

if ($isWebProject) {
    # Web项目特有的配置
    $webConfigPath = Join-Path (Split-Path $project.FullName) "web.config"
} else {
    # 其他项目类型的处理
}
  1. 版本兼容性检查
    如果你的脚本需要特定版本的.NET SDK:
# 检查.NET SDK版本
$sdkVersion = dotnet --version
if ([version]$sdkVersion -lt [version]"6.0.0") {
    Write-Error "需要.NET 6.0或更高版本"
    exit 1
}
  1. 提供回滚机制
    复杂的安装脚本应该记录每一步操作,以便在失败时能够回滚:
# 安装步骤记录
$rollbackSteps = [System.Collections.Generic.List[scriptblock]]::new()

try {
    # 示例:添加配置文件
    $configContent = @{ Key = "Value" }
    $configPath = "appsettings.custom.json"
    
    if (Test-Path $configPath) {
        $backupPath = "$configPath.backup"
        Copy-Item $configPath $backupPath
        $rollbackSteps.Add({ Remove-Item $configPath -Force; if (Test-Path $backupPath) { Move-Item $backupPath $configPath } })
    }
    
    $configContent | ConvertTo-Json | Out-File $configPath
    $rollbackSteps.Add({ Remove-Item $configPath -Force })
    
    # ...其他安装步骤
} catch {
    Write-Warning "开始回滚..."
    for ($i = $rollbackSteps.Count - 1; $i -ge 0; $i--) {
        try { & $rollbackSteps[$i] } catch { Write-Warning "回滚步骤$i失败: $_" }
    }
    throw
}

五、高级技巧:让脚本更智能

对于企业级NuGet包,我们可以让安装脚本更加智能化:

  1. 参数化配置
    通过读取包的自定义元数据来决定安装行为:
# 读取包的metadata
$packageMetadata = $package.Metadata
$configTemplate = $packageMetadata["configTemplate"]

if ($configTemplate) {
    $templatePath = Join-Path $toolsPath $configTemplate
    if (Test-Path $templatePath) {
        Copy-Item $templatePath (Join-Path (Split-Path $project.FullName) "appsettings.utils.json")
    }
}
  1. 条件执行
    根据环境变量决定是否执行某些操作:
# 只在CI环境中执行完整迁移
if ($env:CI -eq "true") {
    dotnet ef database update --verbose
} else {
    Write-Host "本地环境,跳过数据库迁移(使用已有数据库)"
}
  1. 多项目解决方案支持
    在包含多个项目的解决方案中,可能需要跨项目配置:
# 获取解决方案中的所有项目
$solution = Get-Interface $dte.Solution (Get-TypeName "EnvDTE.Solution")
$projects = $solution.Projects | Where-Object { $_.Kind -eq "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}" }

# 查找Web项目
$webProject = $projects | Where-Object { 
    $_.Object.References | Where-Object { $_.Name -eq "System.Web" }
}

if ($webProject) {
    # 配置Web项目的连接字符串
}

六、终极解决方案:当一切都不管用时

如果经过上述所有检查脚本还是不执行,可以尝试以下"终极大法":

  1. 手动调用脚本测试:
# 在Package Manager Console中手动执行
Invoke-InstallScript -InstallPath "packages\Company.Utils.1.0.0" -ToolsPath "packages\Company.Utils.1.0.0\tools" -Package $null -Project (Get-Project)
  1. 检查Visual Studio的ActivityLog.xml文件(通常在%AppData%\Microsoft\VisualStudio\<version>\ActivityLog.xml),查找与NuGet相关的错误。

  2. 使用Process Monitor监控NuGet包管理器的文件访问行为,看看它是否真的尝试加载了你的脚本。

  3. 终极方案:将关键逻辑移到程序集中的一个静态方法里,然后在项目文件中通过Target触发:

<!-- 在NuGet包的build目录下的.props文件 -->
<Target Name="CompanyUtilsInstall" AfterTargets="Build">
    <Exec Command="dotnet Company.Utils.Tasks.dll --install" />
</Target>

这种方法虽然绕过了NuGet的脚本执行机制,但更加可靠,特别是在CI/CD环境中。

七、总结与最佳实践

经过这一番折腾,我总结出了NuGet包部署后脚本的"生存法则":

  1. 保持脚本简单:只做必要的最小操作,复杂的逻辑应该放在程序集中通过API调用。

  2. 充分记录日志:日志要包含足够的信息,但也要注意不要记录敏感数据。

  3. 考虑幂等性:脚本应该可以安全地多次执行,不会因为重复执行而出错。

  4. 明确失败原因:当脚本失败时,应该给出清晰明确的错误信息,而不是默默地退出。

  5. 测试!测试!再测试!:在各种环境(开发机、CI服务器、干净环境)中测试你的脚本。

最后送大家一个检查清单,在发布NuGet包前逐项核对:

  • [ ] 脚本放在正确的tools目录下
  • [ ] 测试过在受限权限下的执行
  • [ ] 验证过在干净环境中的表现
  • [ ] 检查了所有可能的错误路径
  • [ ] 提供了足够的日志输出
  • [ ] 考虑了跨平台兼容性
  • [ ] 实现了必要的回滚机制

记住,一个好的NuGet包部署后脚本应该像优秀的餐厅服务员——在你不注意的时候就把事情办妥了,只有在出问题时才会礼貌地提醒你。