这些年我在实践中学到的编程知识

2023-12-03 11:57:48 浏览数 (1)

上一篇关于编程的文章在一年前产出,那是在我尚有热情时记录的关于HTTP的安全通信总结。我在上学时,就很爱记笔记。“好记性不如烂笔头”,算是被我贯彻到底。记下来不代表吸收,只是为了捋顺思路和加深印象,还有完整梳理产出后的成就感——又学到了一点有用的东西。

以前总是很执着于“有用”,写技术文章也要求一丝不苟,不讲废话,直奔主题。写的内容即使不能做到深入,也要能科普。但是我忘了,记录的首要作用是帮助自己,其次才是帮助他人。

本文的目的是记录这些年工作中学到的知识,它和书上的知识相同却又不同。书上的知识,有些太过理想主义,实践时需要根据实际情况退而求其次;有些知识看的时候不以为然,实践的时候才知道它的重要性。果然,实践是检验真理的唯一标准。

以下代码示例都使用c ,由GPT产出,感谢科技的力量。 本文持续更新。

更新记录

  • 2023-12-03 增加 成功路径也需要监控和日志上报

性能重要,代码可读性也很重要

代码可读性高不意味性能低

首先,可读性高不代表性能低。例如,获取两个vector的交集,可以有两种方式。 使用std库计算交集:

代码语言:javascript复制
#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec1{1, 2, 3, 4, 5};
    std::vector<int> vec2{4, 5, 6, 7, 8};
    std::vector<int> intersection;

    // 确保两个 vector 已排序
    std::sort(vec1.begin(), vec1.end());
    std::sort(vec2.begin(), vec2.end());

    // 使用  std::set_intersection 算法找到交集
    std::set_intersection(vec1.begin(), vec1.end(),
                          vec2.begin(), vec2.end(),
                          std::back_inserter(intersection));

    // 输出交集
    for (int value : intersection) {
        std::cout << value << " ";
    }

    return 0;
}

自己实现找到交集:

代码语言:javascript复制
 #include <iostream>
#include <vector>

int main() {
    std::vector<int> vec1{1, 2, 3, 4, 5};
    std::vector<int> vec2{4, 5, 6, 7, 8};
    std::vector<int> intersection;

    for (const auto &amp;value1 : vec1) {
        for (const auto &amp;value2 : vec2) {
            if (value1 == value2) {
                // 检查交集中是否已经包含这个元素,避免重复添加
                if (std::find(intersection.begin(), intersection.end(), value1) == intersection.end()) {
                    intersection.push_back(value1);
                }
            }
        }
    }

    // 输出交集
    for (const auto &amp;value : intersection) {
        std::cout << value << " ";
    }

    return 0;
}

从可读性和可维护性来看,第一段代码较好。它用了两个标准库函数,含义从函数名就能看出来。第二段代码包含2个for循环,还要考虑元素去重,需要添加注释。 从性能来看,第一段代码也更好。第一段代码的时间复杂度在两个函数,排序函数为O(nlog(n)),取交集函数为O(n),因此总复杂度O(nlog(n))。第二段代码的时间复杂度为O(n2)。

提高性能可能带来更高的维护成本

在业务系统中,大部分后台服务接口都属于I/O消耗型,磁盘读写、网络通信、数据库操作是消耗资源的大头,数据计算和处理消耗的资源占比较少,一般没有必要用一段晦涩难懂的代码来换取极小的CPU运行时间和内存成本。例如有几个有两种状态的变量需要存储,用一个无符号整形数的每个位来存而不是单独每个用bool来存,我们可以看看这两者之间的差异。

用一个无符号整型数来存储所有变量:

代码语言:javascript复制
#include <iostream>

int main() {
    unsigned int flags = 0; // 初始化一个无符号整数变量,所有位都为0

    // 设置第1位为0,第3位为1
    flags &amp;= ~(1 << 0); // 第1位为0,使用按位与操作
    flags |= (1 << 2);  // 第3位为1,使用按位或操作

    // 检查第1位和第3位是否相同
    bool switchCondition = ((flags &amp; (1 << 0)) == (flags &amp; (1 << 2)));

    if (switchCondition) {
        std::cout << "第1位和第3位相同,执行相关操作" << std::endl;
    } else {
        std::cout << "第1位和第3位不相同,不执行相关操作" << std::endl;
    }
    return 0;
}

用bool类型分别存储变量:

代码语言:javascript复制
#include <iostream>

int main() {
    bool flag1 = false; // 初始化第1位为false(即0)
    bool flag3 = true;  // 初始化第3位为true(即1)

    // 检查第1位和第3位是否相同
    bool switchCondition = (flag1 == flag3);

    if (switchCondition) {
        std::cout << "第1位和第3位相同,执行相关操作" << std::endl;
    } else {
        std::cout << "第1位和第3位不相同,不执行相关操作" << std::endl;
    }

    return 0;
}

在看第一段代码时,我需要“翻译”一下才能明白。实际的业务场景会比上面复杂,需要“翻译”的内容也更多。首先要搞清楚每个位代表的含义,这一点还要依赖注释和文档。这里节省的性能和内存远比理解、维护这段代码的成本低。

除了性能要求达到极致的场景,在大部分业务场景中,我们可以写可读性更高的代码,降低维护成本。

不可能发生的,往往可能发生

这个标题非常哲学。我们大部分代码都在处理异常逻辑,很多我们觉得不可能发生的异常,在真的发生时,正确的异常处理逻辑就显得至关重要。怎么防范、怎么在发生时快速定位解决、怎么在解决后防止再犯,就是我们要考虑的问题。

完备的测试很重要,但很难构造

测试是构建稳健系统的重要一环。有的时候写不难,测很难,因为我们没法覆盖所有的测试用例。构造测试用例主要面对两类问题:“我不知道我不知道”以及“我知道但无法构造”。

我不知道我不知道

我们无法构造视野之外的测试用例。所有测试用例覆盖的都是“我知道”的场景,但是不能覆盖“我不知道”的场景。我们可以通过多人交叉验证来缓解这个问题,例如让多个熟悉业务的人review代码和测试用例,看是否还有遗漏的场景。但这个问题无法得到根本解决,特别是系统在经过逻辑变动、数据迁移等改动,可能所有人都认为测试已包含所有场景,系统也在正常运行,结果某天拿出数据一看,发现有的数据是错误的。所以我们只能通过填补的方式,来扩充测试用例的场景,尽量完备系统。

我知道但无法构造

现网和测试环境的不同,就导致必定有无法构造测试用例的场景。例如二者的机型、数据、量级等。 从数据层面看,例如配置,测试环境和现网就是不一样,如果现网配置写错了也只能通过发现现网异常再修改。这些是无法构造的条件,在这些条件的限制下会出现一些只有现网才能发现的问题,例如隐蔽的内存泄漏导致的进程coredump,它需要一定调用量级才能触发。

这是一段具有内存泄漏问题的代码:

代码语言:javascript复制
#include <iostream>
#include <vector>

class Foo {
public:
    Foo() {
        std::cout << "Foo constructor called." << std::endl;
    }

    ~Foo() {
        std::cout << "Foo destructor called." << std::endl;
    }
};

void create_memory_leak() {
    std::vector<Foo*> fooList;
    for (int i = 0; i < 5;   i) {
        fooList.push_back(new Foo());
    }
    // 未释放动态分配的 Foo 对象,导致内存泄漏
}

int main() {
    create_memory_leak();
    std::cout << "Memory leak created." << std::endl;
    return 0;
}

这段代码引用的函数分配了对象,但是使用完没有释放资源,导致内存泄漏。测试时没问题,到了现网,服务器内存使用率缓慢升高,到了某个内存占满无法分配资源导致程序coredump,才能发现,而且定位也是一件困难的事情。 同样的,我们可以缓解而不能根治这个问题:制定循序渐进的灰度方案,在灰度期间观察服务异常、机器指标变化。

成功路径也需要监控和日志上报

业务关键点包括异常和成功路径。通常我们不会忽略异常路径,但可能会忽略成功路径的监控和日志上报。监控用于看量级,日志用于看程序是否符合业务逻辑。成功路径的监控和日志上报作用有:

  • 判断上报组件是否正常。

在上周发布新项目时,我做了一个非常愚蠢的事情:只在异常路径打监控,在成功路径没有。

在发布过程中,我们发现存储服务返回了一些不符合预期的错误码。其中有一个错误码,在代码中只有一处地方会明确返回,还有一个可能的地方是组件调用。但是没有异常监控和异常日志。在按照代码中的异常逻辑推演可能发生的情况并且加以验证后,我们排除了代码中的问题。然后我们去看了组件代码,才确认这是符合预期的错误,是组件调用导致的。

在一开始排查时,因为异常路径的监控全都为空,所以我们怀疑是否程序走到代码的路径,但是上报出错了或者日志丢失了。我们找到了上层调用的异常监控才推翻这点猜想。假设我们在业务的成功路径也有监控,就能够快速排除这个猜想,更快定位问题。

  • 判断业务是否正常。

在确认程序返回的异常都符合预期后,需要判断业务逻辑的正确性。

假设有一个购物系统,用户可以浏览推荐页面,以及进入奖品详情页点击下单进行购物。现在奖品详情页增加了一个随机掉落代金券的能力,用户点击了就能获得代金券并用于购物。在发布这个新功能,灰度放量时就要关注:调用掉落代金券的接口调用量是否与奖品详情接口调用量相似,是否符合灰度放量比例;存储中的成功单据数量和信息是否符合预期,发放和获得的代金券额度是否符合预期等。

程序运行无异常,不代表业务逻辑正确。产品会对业务数据很敏感,而开发往往会忽略对于业务逻辑正确性的校验。

  • 若接口重构,可以用于确认重构后的程序是否正常运行。

重构没有改变能力,但是代码逻辑的实现和抽象可能变得完全不同。所以重构后,需要关注程序和业务逻辑的监控和上报是否和重构前保持大体一致,如果发现哪个节点的量突然下降或者上升,也许是重构实现有问题。

错误日志重要,运行日志也很重要

在异常路径,我们都知道打日志。但是在正常运行逻辑,很多时候会没有日志。也许是为了简洁、美观,又或是减少日志打印带来的性能和存储损耗。无论什么日志,目的都是为了排查问题。我们容易只关注异常日志,是因为思维在点,而非线。但排查问题时光靠点,无法还原路径;凭借线,才能串起流程。发生异常时,我们需要从开始到异常发生节点的所有信息,才能更快排查问题。线的串联就靠运行日志。

下面是一段模拟用户下单时的处理函数:

代码语言:javascript复制
ErrorCode place_order(const Order&amp; order, RPCService&amp; rpc_service) {
    std::cout << "User " << order.user_id << " placing order for product ID: " << order.product_id 
              << ", quantity: " << order.quantity << std::endl;

    // 检查商品是否存在
    if (!rpc_service.check_product_exists(order.product_id)) {
        std::cout << "Error: Product not found. Product ID: " << order.product_id << std::endl;
        return ErrorCode::PRODUCT_NOT_FOUND;
    }

    // 检查库存
    int stock = rpc_service.get_product_stock(order.product_id);
    if (stock < order.quantity) {
        std::cout << "Error: Insufficient stock. Product ID: " << order.product_id
                  << ", requested: " << order.quantity << ", available: " << stock << std::endl;
        return ErrorCode::INSUFFICIENT_STOCK;
    }
     // 更新库存
    int new_stock = stock - order.quantity;
    if (!rpc_service.update_product_stock(order.product_id, new_stock)) {
        std::cout << "Error: Failed to update stock. Product ID: " << order.product_id
                  << ", new stock: " << new_stock << std::endl;
        return ErrorCode::UPDATE_STOCK_FAILED;
    }

    // 计算总价
    double price = rpc_service.get_product_price(order.product_id);
    double total_price = order.quantity * price;
        std::cout << "Product ID: " << order.product_id
                  << ", price: " << price << ", total_price:" << total_price << std::endl;  
  
  // ... 更新用户账户余额等操作

    std::cout << "Order placed successfully. User ID: " << order.user_id << ", product ID: " << order.product_id
              << ", quantity: " << order.quantity << ", total price: " << total_price << std::endl;
    return ErrorCode::SUCCESS;
}

这段代码可能发生下列异常:进入函数前、执行函数中、执行后进程coredump;执行函数时发生异常;函数执行成功了,但是运行结果不符合预期等。

运行代码在排查上述问题时都能提供很大帮助:在进程coredump时,在没有coredump日志的前提下能够靠运行日志判断问题代码的大概范围;在执行函数时发生异常或者运行结果不符合预期,例如这件商品的总价为10元,程序中计算出来的却是100元,虽然结算成功了,但是账出了问题,这时候就要从运行日志入手排查。

当然,带有关键信息的运行日志才是有用的。例如在在交易场景,订单是关键信息,在用户操作场景,用户Id是关键信息。在运行日志,我们可以打印重要的关键索引,例如单号和用户Id,目的是为了串流程,还原程序运行路径,快速定位问题。 运行日志对于不熟悉业务逻辑的人来说更为重要。特别是业务交接初期,不清楚逻辑但仍要排查问题时,清晰关键的运行日志可以节省很多时间。

打日志吧,打错误日志,打运行日志,打清晰的关键的日志,打逢人看了都得夸你一声好的日志。打吧,打日志吧。

防御式编程比想象中重要

最常见的防御式编程场景就是参数校验。我们对系统外部的参数校验往往很严格,但对系统内部的参数校验可能会忽略,因为我们相信提供参数的人,或者参数就来自自己,我们相信自己。

但出错不一定是故意的,也可能是无意的。没有人希望自己写bug,但世界上不存在一个没有bug的系统,这就是矛盾所在。防御的关键就在于谁都不相信,包括自己。

下面是一个将字符串转为double的函数:

代码语言:javascript复制
#include <iostream>
#include <string>
#include <boost/lexical_cast.hpp>

double string_to_double(const std::string&amp; str) {
//    try {
        return boost::lexical_cast<double>(str);
//    } catch (const boost::bad_lexical_cast&amp; e) {
//        std::cerr << "Error: Invalid input string "" << str << "". Cannot convert to double." << std::endl;
//        return 0.0;
//    }
}

int main() {
    std::string str1 = "3.14";
    std::string str2 = "invalid";

    double num1 = string_to_double(str1);
    double num2 = string_to_double(str2);

    std::cout << "Converted value of str1: " << num1 << std::endl;
    std::cout << "Converted value of str2: " << num2 << std::endl;

    return 0;
}

如果上面的代码不用try-catch处理转换失败,传入错误数据转为double出错就会导致进程coredump。很遗憾,我写过类似的代码,我有罪。由于忽略最基本的参数异常处理,导致程序崩溃,是一件非常糟糕的事情。我也因此认识到程序是非常脆弱的,所以要竭尽全力地保护它。

在很久之前,我和同事们讨论过一个问题:如果Web层校验了参数,那么应用层、领域层以及还再次校验吗?如果需要校验,相同的校验规则如果改变,那么就要改几个地方,维护成本会变高。

我现在的想法是:是否要校验、校验的规则取决于每一层的业务逻辑。

例如Web层负责和前端交互,它需要保证所有输入参数合法性和契约保持一致,因此需要校验。更严格一点,输出参数也要保证,如果校验不通过,则返回错误。再到应用层,它的参数校验和业务逻辑相关,例如用户是否命中标签,用户行为是否符合预期等。领域层的参数校验则和领域规则相关,再往下的基础设施层,例如DAO的参数校验则和数据规则、键有关系。对于相同的校验逻辑,最底层一定要有。这样才能保证无论新增几个上层调用,系统都能够正确运行。

参数校验只是防御式编程的最常见实践之一,还有很多我们日常在用的方式,例如修复错误数据(string转为double失败则赋值0.0)、调用下游服务失败选择容错或者降级、程序崩溃时的重新启动等恢复机制等等。

时刻警惕达摩克利斯之剑。

文档的首要作用是帮助自己

我是一个习惯写文档的人。不是因为我天生喜欢,而是因为我记性不好,思考又慢。例如学一个算法,别人需要30分钟,我需要1个小时,并且过两天就会忘记。虽然我不太行,但我又不喜欢加班,所以我会通过文档来帮助我提高工作效率。

我写的文档主要用于三方面:测试、维护项目、排查问题。在做中大型需求时都会同步写文档。

让测试文档当我的测试助手

测试文档的阅读对象包括后台开发、前端开发、产品。内容包括业务测试全流程,每个流程每种对象可以如何操作,以及常见问题等。

后台开发的部分是在写bug和自测时让我和一起开发的后台同事使用;前端和产品的部分则是在前端联调和产品验收的阶段使用。所有能用可视化界面操作工具的流程,例如清除数据,修改单据状态等我都会让在文档中记录,让前端和产品自主操作。省下来的时间,我就写bug。

让项目文档当我的业务地图

项目文档的阅读对象是现在以及未来需要共同维护项目的同事,它包括设计序列图、方案对比和选型、存储设计、契约设计、测试方案、调用量评估、发布和回滚方案、排期评估等。

  • 设计序列图:用于直观地展示系统架构和服务交互。
  • 方案对比和选型:用于分析各个方案之间的好坏,包括不同方案的多维度对比和具体实现。写完后需要跟组内同事过一遍,再来决定最终方案。
  • 存储设计:需要考虑表的量级、字段、索引,所有设计都要考虑现在和未来的情况,例如是否分表,分多少表就要考虑数据的增长趋势。设计字段和索引时,要权衡保留可扩展性降低的成本和改造成本。
  • 契约设计:包括web层、应用层、领域层每个层的接口以及公共协议。设计先行,然后才是编程。
  • 测试方案:这里的测试方案包括测试用例以及结果。区别于上文的测试文档,测试文档的作用是提供操作方法,例如从购物到下单的完整流程需要如何操作,这里则指记录从购物到下单的所有用例的结果。
  • 调用量评估
    • 评估的参考内容包括上游峰值流量和产品放量策略。峰值可以从完整业务周期取,例如一年,需要涵盖各种活动带来的突增流量。如果是新增的活动类项目,还要参考业务过往的同类型的活动流量。
    • 评估的对象为项目涉及的每一个调用服务。如果流量很大,还需要考虑引入缓存、将qps和可用性更低的下游尽量在后面调用等措施来降低大流量带来的性能消耗和成本。
  • 发布和回滚方案
    • 发布顺序:发布顺序按照依赖关系,分别为依赖方、基础设施(数据库等)、领域层服务、应用层服务、web层服务,同层级的服务可以同时发布。除了尚无调用的新服务,一般按照依赖关系发布,出现问题更好排查。
    • 发布时间:避开业务高峰期以及饭点等无人观察的情况。
    • 灰度策略:发布应该制定循序渐进的灰度策略,例如1%-5%-15%-30%-50%-75%-100%,目的是为了更早发现问题,影响范围更小。需要注意,只有本次特性被验证到才算灰度成功。所以要配置相应的观测手段,例如监控上报和日志。
    • 回滚:要考虑是否可以直接回滚,不能的话需要怎么做。
  • 排期评估:排期评估我会用一个表格填写,包括设计、编码、联调、代码review及验收、发布几个阶段的评估。评估时要实事求是,不要为了进度而压缩或者恶意摸鱼。如果项目优先级高,可以通过增加人力以及将次要能力排到下个迭代解决。排期时间我会按照自己的水平预估,由其他同事负责则再调整。整体排期出来后,先给leader过一遍,调整过后和前端、产品对齐。

项目文档的每个部分都可以视情况增加或者减少,它的目的不是为了写而写,而是维护项目,为现在和下一个接手的人提供一张索引地图。

让其他文档当我的提效工具

对于一个大型业务,我还会写它的开发知识、问题排查文档。开发知识文档包括对代码逻辑的解释、常用的测试数据和方法等,在前期不理解业务时,能够帮助自己更快上手。问题排查文档包括现网问题的排查思路、工具,例如怎么用关键词快速定位到相关日志,这些都能帮助帮助我更快地解决问题。

最后,无论对于什么文档,最重要的一点是,及时更新。

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:cloud.tencent.com/developer/s…

0 人点赞