[翻译] 来自“软件设计哲学”的想法

发布于:2025-02-15 ⋅ 阅读:(122) ⋅ 点赞:(0)

Ideas from "A Philosophy of Software Design"Almost a month ago, I created a telegram channel with the goal of reading tech books consistently, and sharing summaries of them.This week, I have finished reading the first book - “A Philosophy of Sohttps://www.16elt.com/2024/09/25/first-book-of-byte-sized-tech/index.html

在这篇文章中,我将分享最让我产生共鸣的 3 个想法。这本书充满了真知灼见,我认为许多初级和中级软件工程师都可以从中受益,所以我鼓励你自己去读一读!

理念一:对复杂性零容忍

在书的第二章中,作者描述了什么是复杂性以及它的症状有哪些:

  • 变化放大:一个简单的变化需要在许多不同的地方进行改变。
  • 认知负荷:开发人员需要学习很多东西才能完成一项任务。
  • 未知的未知数:不清楚需要更改哪些代码才能完成任务。

作者认为,复杂性不是由单个错误引起的,而是不断积累的。有时我们会说服自己,这里的一点复杂性不会有太大影响,但如果项目中的每个人都采用这种思维方式,项目就会迅速变得复杂。

“为了减缓复杂性的增长,你必须采取零容忍哲学”。

例子

想象一下一个简单的订单处理系统,您可以在其中计算运费并应用折扣。但是,该系统设计不佳,多个服务之间存在重复的逻辑,导致变更放大。假设CheckoutService和都ShippingService使用相同的逻辑来计算折扣,但它在两个地方是单独实现的。

想象一下一个简单的订单处理系统,您可以在其中计算运费并应用折扣。但是,该系统设计不佳,多个服务之间存在重复的逻辑,导致变更放大。假设CheckoutService和都ShippingService使用相同的逻辑来计算折扣,但它在两个地方是单独实现的。

public class CheckoutService
{
    public decimal CalculateTotal(Order order)
    {
        decimal total = order.Items.Sum(item => item.Price);

        // Apply discount logic
        if (order.CouponCode == "SUMMER2024")
        {
            total -= 10;
        }

        return total;
    }
}

public class ShippingService
{
    public decimal CalculateShipping(Order order)
    {
        decimal shippingCost = order.ShippingAddress.Country == "US" ? 5 : 15;

        // Apply discount logic (duplicated)
        if (order.CouponCode == "SUMMER2024")
        {
            shippingCost -= 10;
        }

        return shippingCost;
    }
}

 

为什么不好?

  • 变更放大:如果您想修改折扣的应用方式(例如,引入新的折扣或更改标准),则必须修改CheckoutServiceShippingService

  • 认知负荷:开发人员必须记住更新系统中涉及折扣的每个部分。忘记更新某个部分(例如,遗漏了ShippingService)将导致行为不一致。

  • 未知的未知数:如果新开发人员负责更新折扣逻辑,他们可能不知道相同的折扣逻辑存在于多个地方。他们可能会更新一个而忽略另一个,从而导致运费计算出现错误。

如何改善它?

我们可以重构系统,通过将折扣逻辑封装在其自己的类中来消除重复的逻辑。这样,如果折扣逻辑发生变化,您只需修改一个地方,从而降低整体复杂性。

public class DiscountService
{
    public decimal ApplyDiscount(Order order, decimal total)
    {
        if (order.CouponCode == "SUMMER2024")
        {
            return total - 10;
        }
        return total;
    }
}

public class CheckoutService
{
    private readonly DiscountService _discountService;

    public CheckoutService(DiscountService discountService)
    {
        _discountService = discountService;
    }

    public decimal CalculateTotal(Order order)
    {
        decimal total = order.Items.Sum(item => item.Price);

        // Apply discount through centralized service
        total = _discountService.ApplyDiscount(order, total);

        return total;
    }
}

public class ShippingService
{
    private readonly DiscountService _discountService;

    public ShippingService(DiscountService discountService)
    {
        _discountService = discountService;
    }

    public decimal CalculateShipping(Order order)
    {
        decimal shippingCost = order.ShippingAddress.Country == "US" ? 5 : 15;

        // Apply discount through centralized service
        shippingCost = _discountService.ApplyDiscount(order, shippingCost);

        return shippingCost;
    }
}

总而言之,在第一个示例中,我们看到了变更放大现象,即对折扣逻辑的简单更改需要修改多个服务。通过将逻辑集中化DiscountService,我们消除了这种重复,使系统更易于维护和发展。

想法 2:更小的组件并不一定更有利于模块化

“给定两个功能,应该一起实现它们,还是应该分开实现?”——这个问题是第 9 章的重点。

作者认为,虽然我们的目标是降低系统复杂性,但较小的组件并不一定更有利于模块化,并提到了将功能拆分到更多组件中的一些缺点:

  • “一些复杂性仅仅来自于组件的数量”
  • “细分可能会导致需要额外的代码来管理组件”
  • “分离使得开发人员更难同时看到组件,甚至意识到它们的存在。”
  • “细分可能导致重复”

作者还给出了一些应该合并两段代码的迹象。

  • “他们共享信息。”
  • “它们一起使用”,这必须是双向的。例如,如果我每次使用方法 A,我都会使用方法 B,反之亦然,那么这些方法应该合并。
  • “它们在概念上是重叠的,因为有一个简单的高级类别,包含了这两部分代码”
  • “如果不看另一段代码就很难理解其中一段代码。”

作者提到了常见的“清洁技巧”:“拆分任何长度超过 X 行的方法。”他补充说,“长度本身很少是拆分方法的充分理由。” […] 拆分方法会引入额外的接口,这会增加复杂性。 […] 除非拆分方法可以使整个系统更简单,否则不应拆分方法”。

例子

在此示例中,假设系统中有一个用户注册流程。开发人员将逻辑过度拆分为多个方法,将注册的每个步骤(例如验证用户、将用户保存到数据库以及发送欢迎电子邮件)分开。虽然每个方法都在做自己的事情,但它们都共享信息并且在概念上相关。这种过度拆分会导致不必要的复杂性和开销。

public class UserService
{
    public bool ValidateUser(User user)
    {
        if (string.IsNullOrEmpty(user.Email))
        {
            throw new ArgumentException("Email is required.");
        }
        return true;
    }

    public void SaveUserToDatabase(User user)
    {
        Database.Save(user);
    }

    public void SendWelcomeEmail(User user)
    {
        EmailService.Send("Welcome to our platform!", user.Email);
    }

    public void RegisterUser(User user)
    {
        if (ValidateUser(user))
        {
            SaveUserToDatabase(user);
            SendWelcomeEmail(user);
        }
    }
}

 

为什么不好?

  • 不必要的细分ValidateUserSaveUserToDatabaseSendWelcomeEmail方法过于细粒度,并且始终按照严格的顺序一起使用。拆分这些步骤会给系统添加不必要的接口,而不会提供任何实际灵活性。

  • 认知负荷增加:开发人员现在必须在脑中追踪多个方法,这些方法紧密相关,但又不必要地被分开。这种细分为理解注册过程带来了不必要的复杂性。

  • 信息重叠:这三种方法都与用户注册过程直接相关。它们共享同一个用户对象,并且总是一起调用。如果不考虑其他步骤,很难推断出过程中的某一步骤。

如何改善它?

很简单,在这种情况下,我们可以简单地“内联”方法,如下所示

public class UserService
{
    public void RegisterUser(User user)
    {
        if (string.IsNullOrEmpty(user.Email))
        {
            throw new ArgumentException("Email is required.");
        }

        Database.Save(user);

        EmailService.Send("Welcome to our platform!", user.Email);
    }
}

总而言之,仅仅为了制作更小的方法而拆分功能实际上会增加复杂性,如第一个示例所示。通过合并共享信息且始终一起使用的相关步骤,我们可以减少细分开销、简化界面并使代码更易于理解和维护。

想法 3:异常处理占了很大一部分复杂性

在第 10 章中,作者指出“异常处理是软件系统中最糟糕的复杂性来源之一”。

处理异常的方法有两种:

  • 尝试完成正在进行的工作(即网络数据包丢失?重新发送;数据损坏?从快照恢复)。
  • 中止操作并将异常向上传递。

作者提到,中止会增加更多复杂性。例如,如果数据结构已被部分初始化,则会发生异常 - “异常处理代码必须恢复一致性,例如通过取消在异常发生之前所做的任何更改。”

作者指出,抛出异常并让调用者处理它是多么容易和诱人。他认为,作为某种方法的开发人员,如果你在处理某个异常时遇到困难,那么调用者很可能也不知道如何处理它。

“减少异常处理造成的复杂性损害的最佳方法是减少需要处理异常的地方的数量。”

从这里开始,作者分享了一些如何减少异常处理程序数量的技术。

  • 将错误定义得不存在 - “消除异常处理复杂性的最佳方法是定义 API,以便没有异常需要处理。” 例如,我们可以比较 Windows 和 Linux 上的文件删除方式。如果您想删除一个文件,而该文件在另一个进程中打开,您将收到异常;您无法执行该操作。在 Linux 中,我们可以删除一个打开的文件 - 因为我们首先将其标记为删除。

  • 屏蔽异常 - “在系统的低层检测和处理异常情况,这样较高级别的软件就无需了解该情况。”例如,TCP 通过重新发送数据包来屏蔽数据包丢失。因此,较高级别的软件不需要知道丢失的数据包。它保证拥有所有数据包。

  • 异常聚合 - “异常聚合背后的想法是用一段代码处理许多异常;不是为许多单独的异常编写不同的处理程序,而是用一个处理程序在一个地方处理它们。”

例子

public class FileProcessor
{
    public void ProcessFile(string filePath)
    {
        try
        {
            var config = ReadConfigFile(filePath);
            var processedData = ProcessData(config);
            WriteDataToFile(processedData, filePath);
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"Config file not found: {ex.Message}");
            throw;
        }
        catch (IOException ex)
        {
            Console.WriteLine($"I/O error: {ex.Message}");
            throw new ApplicationException("I/O failure during file processing", ex);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Unexpected error: {ex.Message}");
            throw;
        }
    }

    private Config ReadConfigFile(string filePath)
    {
        if (!File.Exists(filePath))
        {
            throw new FileNotFoundException("The configuration file was not found.");
        }
        return new Config(filePath);
    }

    private void WriteDataToFile(ProcessedData data, string filePath)
    {
        try
        {
            File.WriteAllText(filePath, data.ToString());
        }
        catch (IOException ex)
        {
            Console.WriteLine("Failed to write file");
            throw;
        }
    }
}

 

为什么不好?

  • 异常处理程序过多:代码中各个部分都有多个 try-catch 块,这会导致重复和复杂性。每个方法都有自己的错误处理逻辑,并且异常会向上传递,而没有正确解决核心问题。

  • 中止过多:该ProcessFile方法将处理某些异常(如)的责任FileNotFoundException转交给调用者,增加了复杂性。调用者可能不知道如何处理这些错误,而向上传递这些错误会在整个系统中创建更多处理程序。

解决方法 1:定义不存在的错误

ReadConfigFile第一种技术是设计系统以完全避免异常。我们可以重新设计方法,将文件缺失视为正常情况,而不是异常情况,而不是因为文件丢失等情况而抛出异常。

private Config ReadConfigFile(string filePath)
{
    if (!File.Exists(filePath))
    {
        Console.WriteLine("Config file not found, using default settings.");
        return Config.GetDefaultConfig();  // Default behavior instead of exception
    }

    return new Config(filePath);
}
为什么它更好?
  • 无需异常处理:我们FileNotFoundException通过将配置文件的缺失定义为可接受条件并使用默认后备,完全避免抛出异常。

  • 简化代码:调用者不需要处理丢失的文件 - 当找不到文件时它只会获取默认配置。

修复 2:屏蔽异常

接下来,我们在内部处理较低级别的异常,以便较高级别无需担心它们。这通常用于网络故障、文件 I/O 或类似情况,重试或回退机制可以掩盖问题。

private void WriteDataToFile(ProcessedData data, string filePath)
{
    // Mask exceptions: Retry writing the file instead of throwing an exception
    int retryCount = 3;
    while (retryCount > 0)
    {
        try
        {
            File.WriteAllText(filePath, data.ToString());
            break;
        }
        catch (IOException)
        {
            retryCount--;
            if (retryCount == 0)
            {
                Console.WriteLine("Failed to write after retries. Aborting.");
                // Depending on the system requirements, you might have to throw
                // an exception here as the issue was not transient,
                // or for example save the data to a tmp file, 
                // then try to write it to filePath at a later stage.
            }
        }
    }
}

 

为什么它更好?

  • 屏蔽重试:我们最多尝试重试三次文件写入操作。这可以屏蔽IOException瞬时错误(例如临时文件系统问题),这意味着更高级别的代码无需担心这些异常。

解决方案 3:异常聚合

我们无需为每个可能的错误编写单独的异常处理程序,而是可以汇总异常并在一个地方进行处理。这避免了重复的异常处理逻辑。

鉴于我们仍然会抛出异常IOException,以下是您可能想要聚合异常的方式。

public void ProcessFile(string filePath)
{
    try
    {
        var config = ReadConfigFile(filePath);
        var processedData = ProcessData(config);
        WriteDataToFile(processedData, filePath);
    }
    catch (IOException ex) when (ex is FileNotFoundException || ex is UnauthorizedAccessException)
    {
        // Aggregated exception handler for all I/O-related exceptions
        Console.WriteLine($"I/O failure: {ex.Message}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Unexpected error: {ex.Message}");
        throw;
    }
}

为什么它更好?
  • 聚合异常:我们在单个处理程序中处理所有IOException相关错误(例如找不到文件或拒绝访问),避免重复的错误处理逻辑。

  • 更简单的代码:我们不需要编写多个 catch 块,而是在一个地方处理多个相关异常,从而减少了处理程序的数量。

ApiSmart

ApiSmart 是ApiHug 插件内置 AI 支持功能; ApiSmart 作为一个入门AI智能编码,和本地AI 学习调试工具, 做到 练学习操 一体;

一边练习,一边增加你对AI 体验, 一边提升你的工作效率, 一箭三雕。

  1. 精准携上下文, 代码片段位置
  2. 识别语言, 当然IDEA内非常简单
  3. 精炼的prompt, 当然你也可以覆盖他

ApiHug - API design Copilot - IntelliJ IDEs Plugin | Marketplacehttps://plugins.jetbrains.com/plugin/23534-apihug--api-design-copilot

 ApiHug 的 ApiSmart 利用 Langchain4j 与大型语言模型(LLM)供应商进行通信;因此基本上任何 Langchain4j 支持的供应商都可以被 ApiSmart 支持;

ApiSmart Api design Copilot - ApiHugApiSmart make your api design and implement happierhttps://apihug.com/zhCN-docs/copilot

实操

创建Deepseek API密钥

接下来我们来到Deepseek这边,创建供Continue使用的API密钥。

访问Deepseek开放平台对应页面( DeepSeek ),点击「创建API key」

图片

按引导完成新的密钥创建,记得复制之后「妥善保存」该密钥:

图片

修改ApiSmart配置

ApiHug>Settings>AI>Vendor

下面就可以进行愉快的调用了

1. 编辑右击→ 菜单  ApiSmart/Ask|Test

2. 右边 toolwindow 直接发问


网站公告

今日签到

点亮在社区的每一天
去签到