65.9K
CodeProject 正在变化。 阅读更多。
Home

使用条件编译构建和维护多个应用程序版本

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (19投票s)

2009年3月16日

CPOL

13分钟阅读

viewsIcon

67113

downloadIcon

308

维护软件应用程序多个版本的技术,并详细介绍条件编译。

目录

引言

让我们设定一下场景。您创建了一个很棒的新应用程序。您想免费提供一个免费版本,但还想提供一个收费的专业版本。免费版本显然需要缺少一些功能,以鼓励人们购买专业版本。

您如何构建多个版本,更重要的是,如何有效地维护它们?在本文中,我将描述一些管理应用程序多个版本的基本技术,并讨论每种技术的优缺点。

示例应用程序

作为本文的指南,我将使用一个包含两个版本的小型示例应用程序。WizzBang Calculator 是一个命令行计算器。专业版本功能齐全,支持加法、减法、乘法和除法。WizzBang 还有一个精简版,只提供加法和减法。

Image of WizzBang calculator - Pro edition

Image of WizzBang calculator - Lite edition

第一步 - 抽象化您的功能

构建多版本应用程序的第一步是将您的功能从用户界面和调用代码中抽象出来。无论您对多个版本有何打算,这都是良好的设计实践,所以希望您已经做了类似的事情。分离得越清晰,管理应用程序的不同版本就越容易。

WizzBang Calculator 应用程序有一个抽象的 `Command` 类,该类获取用户输入并执行操作。为每种操作类型创建一个子类,该子类重写 `PerformOperation` 方法并提供特定的计算。

public abstract class Command
{
    public void ExecuteCommand()
    {
        .
        .
        .
        // Get user input for values.
        .
        .
        .

        // Perform the calculation.
        int result = PerformOperation(firstNumberValue, secondNumberValue);

        // Output the answer.
        Console.WriteLine("The answer is: " + result);
    }

    public abstract int PerformOperation(int firstNumber, int secondNumber);
}

public class AddCommand : Command
{
    public override int PerformOperation(int firstNumber, int secondNumber)
    {
        // Perform the addition operation.
        return firstNumber + secondNumber;
    }
}

应用程序启动时,将创建每个命令子类的实例并将其添加到命令列表中。此列表用于构建用户界面菜单,并为用户提供调用各种命令的方式。

private static List<command> CreateCommands()
{
    List<command> commands = new List<command>();

    commands.Add(new AddCommand());
     commands.Add(new SubtractCommand());
    commands.Add(new MultiplyCommand());
    commands.Add(new DivideCommand());

    return commands;
}

现在您需要创建各种版本。这里有几种选择。我们将逐一介绍每种可能性,并查看每种技术的优点和缺点。

多版本 - 多个源代码

许多人采取的第一种方法是复制他们的应用程序,然后修改副本以删除一些关键功能。虽然一开始这看起来是简单的选择,但它只会导致将来出现更多问题。如果您需要进行错误修复或改进,您会发现自己需要多次进行更改,为应用程序的每个版本进行一次。您应该通常排除此选项。

共享库

比为每个版本维护多个代码库稍好一点的方法是将所有公共代码移到共享程序集中,并只维护一个执行对共享库中的功能的调用的程序集的多个版本。这在一定程度上解决了维护大量代码库副本的问题,但您仍然面临相同的问题,只是程度稍轻一些。

多个源代码分支

想象一下,您要为每个版本维护一套独立的源代码,但想象一下有一种方法可以将更改合并到不同的源代码集中,以避免您进行太多重复工作。是的,有这种方法。大多数现代源代码管理产品都提供了一种方法,可以将您的代码分支成两个版本,独立地处理这两个版本,并将更改从一个分支合并到另一个分支。(请查阅您的源代码管理文档以获取实现此目的的具体方法。)

您将从包含所有功能的初版本开始。然后,您为要向用户提供的每个版本从初版本创建一个分支。在每个分支中,您都可以进行必要的更改,删除不包含在特定版本中的任何功能。

现在,您可以进行基础更改和修复,并将这些更改合并到其他分支,以避免重复工作。

这显然具有减少代码重复的优点。但是,它也带来了自身的复杂性。多个分支可能难以管理。要跟踪哪些更改已合并到哪个分支并不容易。这需要严格的纪律和程序,以确保各个分支不会出现过多不同步并导致问题。

一个版本,多个密钥

许多开发人员采取的一种流行选择是只维护应用程序的一个版本,并在运行时决定允许用户调用哪些功能。这个决定通常通过使用某个密钥或序列号来做出。不同密钥的值可以关联到用户不同的解锁版本。您可以选择自己构建这种功能,也可以使用许多在线可用的应用程序保护库中的一个。

起初,这听起来是一个有吸引力的选择,它确实有一些好处

  • 您只需要维护一个应用程序版本。
  • 用户只需购买新的序列号即可轻松升级到更高版本。

但是,它也有缺点。已锁定功能的代码仍然会编译到二进制文件中。这使得破解者有机会破解您的应用程序并提供访问已锁定功能的途径。对于 .NET 来说,这个问题更严重,因为使用 Reflector 或 ILDASM 等工具可以轻松读取代码。混淆器和 IL 加密器可以提供帮助,但混淆器和类似工具的有效性仍然受到广泛质疑。

条件编译

最后一种技术解决了前面一些方法带来的许多问题。条件编译是一种技术,通过一系列“指令”,您可以指示编译器在不同条件下编译或不编译特定代码块。我将演示可用于处理条件编译的各种命令,以及这些命令如何用于管理应用程序的多个版本。

预处理器指令

C# 和 VB.NET 中的条件编译围绕着几个“预处理器指令”。首先,一点背景——预处理器指令起源于 C。C 和 C++ 都有一个预处理器,它会在编译器之前解析代码库并执行一些预处理。可以向预处理器提供指令,指示它应该做什么。C# 和 VB.NET 没有正式的预处理器,但编译器确实识别了几个预处理器指令,并以相同的方式处理它们。如果您来自 C 或 C++ 背景,您会发现 C#/VB.NET 编译器在某些方面存在局限性(例如,您无法创建宏),但它们的功能足以处理我们正在研究的条件编译场景。我们感兴趣的预处理器指令是

#define (Or #CONST in VB.NET)
#undef

#if
#elif (Or #ElseIf in VB.NET)
#else
#endif

`#define` 和 `#undef` 允许您定义(和取消定义)常量。其余四个指令允许您根据这些常量限制编译的代码。

使用预处理器指令

让我们从一个例子开始

#define TEST

#if TEST    
    Console.WriteLine("Test defined");
#else
    Console.WriteLine("Test not defined");
#endif

`#define` 和 `#undef` 指令必须放在文件的开头,在任何其他代码之前,并且仅影响该文件内的常量。`#undef` 指令用于取消定义全局声明的常量(稍后会详细介绍)。

您会注意到,与常规的 `if` 语句不同,您不使用大括号来包围 `if` 块;相反,您在块的底部有一个 `#endif` 指令。好的,让我们运行它看看会得到什么

Image displaying the test code output with the TEST constant defined

正如您所预期的,它输出了“Test defined”。让我们看看如果删除 `#define` 指令并再次运行代码会发生什么

Image displaying the test code output with the TEST constant not defined

同样,正如预期的那样,“Test not defined”是输出。

让我们快速查看 Reflector 中的已编译程序集,看看它的样子。

Image displaying the test assembly as viewed from reflector

您在这里看到的是包含预处理器指令的 `Main` 方法的已编译版本。有趣的是,已编译程序集中没有 `#if`/`#else`/`#endif` 指令,而且重要的是,`else` 块中的代码完全丢失了。那么,发生了什么?预处理器指令不像普通的 `if` 语句那样工作。与普通 `if` 语句不同,预处理器指令在 **编译** 时而不是运行时进行评估。编译器处理指令,评估并确定 `#if` 指令的结果,并且只编译 `#if`/`#else` 块中与评估的 `#if` 语句匹配的部分。

现在,我们有了一种根据定义的常量包含或删除源文件代码的有用方法。让我们快速将其应用于我们的 WizzBang Calculator 应用程序,看看它如何提供帮助。

private static List<command> CreateCommands()
{
    List<command> commands = new List<command>();

    commands.Add(new AddCommand());
    commands.Add(new SubtractCommand());

#if ProEdition
    commands.Add(new MultiplyCommand());
    commands.Add(new DivideCommand());
#endif

    return commands;
}

如果我们稍微修改 `CreateCommands` 方法以包含一些预处理器指令,我们可以确保仅在专业版本中创建乘法和除法命令。我们还可以用 `#if`/`#endif` 指令包装 `MultiplyCommand` 和 `DivideCommand` 类,以确保不仅不会为精简版创建命令对象,而且它们的相应类甚至不会被编译到最终程序集中,从而防止任何恶意的用户找到绕过我们的保护并将其重新包含在精简版中的方法。

#if ProEdition
    public class MultiplyCommand : Command
    {
        .
        .
        .
    }
#endif

最后,我们还可以在 `DisplayTitle` 方法中包含一些指令,以根据版本显示不同的标题。

我们尚未提及的其余预处理器指令是 `#elif`。此指令代表“else if”。它的工作方式类似于常规的 `elseif` 语句,允许您将多个 `if` 语句链接在一起。

您仍然可以使用所有常规的条件运算符和预处理器指令。例如,如果您希望将一段代码包含在已编译的程序集中,如果定义了 `LiteEdition` 常量 **或** `ProEdition` 常量,您可以这样做

#if LiteEdtion || ProEdition
    commands.Add(new AddCommand());
    commands.Add(new SubtractCommand());
    .
    .
    .
#endif

定义常量

现在,我们有了区分的 WizzBang 应用程序,可以根据定义的常量编译成两个版本。但是,在源代码中定义这些常量有点棘手。它们必须在每个源文件中定义,并且每次我们想要编译不同版本时都必须更改它们。我们需要一种全局地、在构建时定义这些常量的方法。幸运的是,有几种方法可以做到这一点。最简单也是最常见的方法是通过 Visual Studio 中的项目属性对话框。

首先,让我们为不同的版本创建一些新的构建类型。如果单击“构建”菜单并打开“配置管理器”,您将能够配置不同类型的构建。下拉“活动解决方案配置”组合框并创建一些新的配置。您可以选择从现有配置复制配置选项,以节省您的工作。在这里,您可以看到我为我的每个应用程序版本创建了调试和发布配置

Image demonstrating various build configuration.

创建了各种版本的构建配置后,转到应用程序的项目属性,然后转到“构建”选项卡。您将在顶部看到您可以选择当前正在编辑的构建配置。紧接着,您会看到一个标有“条件编译符号”的文本输入框。在这里,您可以列出您希望为此构建配置定义的任何常量。如果想定义多个常量,可以使用逗号。(请注意,条件编译常量区分大小写)。您还可以使用下面的复选框来决定是否为该构建配置定义 `DEBUG` 或 `TRACE` 常量。(通常,`TRACE` 始终定义,而 `DEBUG` 通常仅在调试版本中定义。)

现在,您会发现当编译器启动时,相应的常量会自动定义。您可以使用标准工具栏上的下拉组合框更改活动的构建配置。Visual Studio 还通过将当前配置下未编译的代码以灰色显示,并自动提供区域风格的 +/- 折叠框来帮助您。

Image demonstrating visual studio conditional code colouring.

您还可以使用 MSBuild 脚本中的 `` 标签来定义常量,这些常量可以像任何其他 MSBuild 属性一样有条件地包含在属性组中。或者,您可以通过命令行使用 `/d` 开关来定义您的常量。

MSBuild:

<DefineConstants>ProEdition</DefineConstants>

Visual Basic command line:
vbc /d:ProEdition=TRUE MyApplication.vb

C# command line:
csc /d:ProEdition MyApplication.cs

Conditional 属性

条件编译工具箱中的最后一个技巧是 `conditional` 属性。`conditional` 属性可以应用于方法,并指示编译器,只有在定义了指定的常量时,才应编译对该方法的调用。

[Conditional("ProEdition")]
private static void CreateProCommands(List<command> commands)
{
    commands.Add(new MultiplyCommand());
    commands.Add(new DivideCommand());
}

像这样使用 `conditional` 属性的好处是,您的代码不会充斥着 `#if`/`#endif` 指令,这会弄乱您主方法的流程。

关于 `conditional` 属性值得注意的一点是,它等同于将 **对方法的调用** 包围在 `#if`/`#endif` 指令中,而不是方法的内容。这意味着方法本身及其中的所有代码仍会被编译到程序集中,只是对该方法的调用被移除了。

这两种技术经常一起使用

[Conditional("ProEdition")]
private static void CreateProCommands(List<command> commands)
{
#if ProEdition
    commands.Add(new MultiplyCommand());
    commands.Add(new DivideCommand());
#endif
}

通过这样做,您可以获得两全其美。方法的代码不会编译到应用程序中,并且对方法的调用会被移除,而不会用大量的 `#if`/`#endif` 指令来混乱您的主方法。(此外,您不会因为调用空方法而浪费性能。这对于经常调用的方法(如日志记录或调试调用)特别有用。)

如果需要在一个常量集已定义时调用一个方法,您可以将多个 `conditional` 属性应用于单个方法。

值得注意的是,公共语言规范允许编译器忽略 `conditional` 属性。C#、J# 和 Visual Basic 编译器支持 `conditional` 属性;但 C++ 和 JScript 编译器不支持。这不会影响我们之前讨论的预处理器指令的支持。

正如我开头提到的,条件编译确实要求您的代码结构良好,并且功能被仔细分离,以便可以轻松地从已编译的程序集中移除代码的相应部分,但这本身就是一种被认为是良好实践的做法。

结论

我们首先简要回顾了管理应用程序多个版本的各种可能技术。我们研究了每种简单方法的陷阱。然后,我们更详细地研究了条件编译。我们发现了条件编译如何用于限制源代码的某些部分不包含在应用程序的每个不同版本中,以及 Visual Studio 如何帮助您管理每个版本的不同构建配置。

最后,我们研究了 `conditional` 属性以及它如何通过自动提供一些条件编译行为来帮助提高代码质量。

条件编译是帮助您构建和管理应用程序不同版本的最佳工具之一。

延伸阅读

一些额外的参考资料

历史

  • 2009年3月18日 - 添加了目录和改进了排序
  • 2009年3月16日 - 初始版本

感谢 Mustafa 最初建议我写这篇文章。

© . All rights reserved.