Linux 内核态实现 Full Cone NAT(2)

接上篇 从DNAT到netfilter内核子系统,浅谈Linux的Full Cone NAT实现

Padavan固件的Cone NAT实现

Padavan 是基于华硕路由器固件的第三方智能路由器固件,这个固件通过给内核 netfilter 打 patch 的方式实现了 Cone NAT。关于该固件实现 Cone NAT 的原理及问题,在 netfilter-full-cone-nat #1 中有简短的讨论。

先来看代码。Padavan 修改了 netfilter 下的 nf_conntrack_core.c 中的 resolve_normal_ct() 函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static inline struct nf_conn *
resolve_normal_ct(/* ... */)
{
//...
/* look for tuple match */
hash = hash_conntrack_raw(&tuple);
#if defined(CONFIG_NAT_CONE)
if (protonum == IPPROTO_UDP && nf_conntrack_nat_mode > 0 && skb->dev != NULL &&
#if IS_ENABLED(CONFIG_PPP)
(skb->dev->ifindex == cone_man_ifindex || skb->dev->ifindex == cone_ppp_ifindex)) {
#else
(skb->dev->ifindex == cone_man_ifindex)) {
#endif
/* CASE III To Cone NAT */
h = __nf_cone_conntrack_find_get(net, &tuple, hash);
} else
#endif
{
/* CASE I.II.IV To Linux NAT */
h = __nf_conntrack_find_get(net, &tuple, hash);
}

// ...
}

可见,在配置项 CONFIG_NAT_CONE 打开时,调用了 __nf_cone_conntrack_find_get() 来取代原本的 __nf_conntrack_find_get()。来看看这个 padavan 新增的 __nf_cone_conntrack_find_get() 函数是如何实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static struct nf_conntrack_tuple_hash *
__nf_cone_conntrack_find_get(struct net *net,
const struct nf_conntrack_tuple *tuple, u32 hash)
{
// ...
begin:
h = __nf_cone_conntrack_find(net, tuple, hash);
if (h) {
// ...
if (unlikely(!nf_ct_cone_tuple_equal(tuple, &h->tuple))) {
nf_ct_put(ct);
goto begin;
}
// ...
}
// ...
return h;
}

其实这个函数的实现和 __nf_conntrack_find_get() 逻辑上完全一致,所以我省略了大部分的展示代码。其实特别的地方在于,这个函数通过调用新增的 __nf_cone_conntrack_find() 来根据 tuple 查找对应的 conntrack ,通过调用 nf_ct_cone_tuple_equal() 来判断两个 tuple 是否相等。这两个 padavan 添加的函数就暗藏了玄机。事实上,__nf_cone_conntrack_find()的实现与 ____nf_conntrack_find() 逻辑上也没有多大不同,而区别就在于:在根据 hash 遍历索引到的 bucket 时,____nf_conntrack_find() 使用 nf_ct_key_equal() 来判断两个 tuple 是否完全相等,而 __nf_cone_conntrack_find() 则使用了 nf_ct_cone_tuple_equal() 来判断两个 tuple 是否“在锥形”上相等。

1
2
3
4
5
6
7
8
9
10
11
static inline bool
nf_ct_cone_tuple_equal(const struct nf_conntrack_tuple *t1, const struct nf_conntrack_tuple *t2) {
if (nf_conntrack_nat_mode == NAT_MODE_FCONE)
return __nf_ct_tuple_dst_equal(t1, t2);
else if (nf_conntrack_nat_mode == NAT_MODE_RCONE)
return (__nf_ct_tuple_dst_equal(t1, t2) &&
nf_inet_addr_cmp(&t1->src.u3, &t2->src.u3) &&
t1->src.l3num == t2->src.l3num);
else
return false;
}

上面的这段代码就是padavan对Cone NAT实现的最关键之处。对于 Full Cone,nf_ct_cone_tuple_equal() 只判断两个tuple的“目标三元组”(目标IP、目标端口、协议号)是否相同;对于 IP Restricted Cone,除了判断“目标三元组”是否相同,还需要同时判断源IP是否相同。注意,此处的tuple是相对于远程主机端,即 conntrack.tuple_hash 中的 TUPLE_REPLY 而言的,“源”指的是远程主机(如UDP服务器),“目标”指的是本机(Linux NAT网关)的 WAN 出口。

相对应的,由于 resolve_normal_ct() 对 conntrack 的查找是基于对 tuple 进行 hash 的,padavan 对 hash_conntrack_raw() 函数也做了少量的调整,来确保在同一锥形中的两个tuple的hash相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static u32 hash_conntrack_raw(const struct nf_conntrack_tuple *tuple) {
unsigned int n;
#if defined(CONFIG_NAT_CONE)
u32 a, b;
if (nf_conntrack_nat_mode == NAT_MODE_FCONE) {
return jhash2(tuple->dst.u3.all, sizeof(tuple->dst.u3.all) / sizeof(u32),
nf_conntrack_hash_rnd ^ (((__force __u16)tuple->dst.u.all << 16) | tuple->dst.protonum)); // dst ip & dst port & dst proto
}
else if (nf_conntrack_nat_mode == NAT_MODE_RCONE) {
a = jhash2(tuple->src.u3.all, sizeof(tuple->src.u3.all) / sizeof(u32), tuple->src.l3num); //src ip & l3 proto
b = jhash2(tuple->dst.u3.all, sizeof(tuple->dst.u3.all) / sizeof(u32), ((__force __u16)tuple->dst.u.all << 16) | tuple->dst.protonum); // dst ip & dst port & dst proto
return jhash_2words(a, b, nf_conntrack_hash_rnd);
}
else
#endif
// ...
}

简而言之,padavan对Cone NAT的实现是这样的:(为简化过程,我们这里只讲Full Cone)
当入站包到达本机(Linux NAT网关)时,resolve_normal_ct() 被调用以用于查找该包所对应的conntrack,原版netfilter的逻辑是根据tuple的“全匹配”来查找对应conntrack,而padavan在启用Cone NAT功能后,只根据tuple的“半匹配”来找到对应的conntrack(“半匹配”即:只匹配单边的地址和端口信息,当Full Cone时,这个单边是tuple的dst)。

举个例子来说明这样实际运行的效果:

  • 内网主机 192.168.1.3:3000 –> Linux网关 NAT后地址:223.0.0.1:3000 (标记为“conntrack 1”) –> 远程主机 123.0.0.1:5000
  • 另一台远程主机 123.2.2.2:6000 –> 223.0.0.1:3000 Linux网关查找到“conntrack 1”并进行restore NAT –> 内网主机 192.168.1.3:3000

映射端口随机化及问题

这里我们讨论SNAT的 --random--random-fully 参数。在未使用该参数时,NAT后的源端口号会尽量保持不变,如果遇到冲突,则从端口范围的最小值(未显式指定时是1024)开始逐一往上寻找可用源端口号;当使用了随机化参数后,NAT后的源端口号是随机的,每次NAT映射产生的端口号都无特别确定的规律可循。

Padavan Cone NAT和端口随机化

从上文的源码分析可以看出,padavan对cone nat的实现是仅考虑了入站方向的,而出站方向则没有做改动。如果我们同时考虑出站方向,当 --random--random-fully未启用 时,出入站的流程是这样的:

  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:3000) 【建立 “conntrack 1” 】 –> 远端主机B(123.0.0.1:5000)
  • 远端主机C(123.2.2.2:6000) –> NAT(223.0.0.1:3000)【重用 “conntrack 1” 】 –> 内网主机A(192.168.1.3:3000)
  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:3000) 【建立 “conntrack 2” 】 –> 远端主机C(123.2.2.2:6000)

注意,当 内网主机A 向另一台 远端主机C 发送出站UDP包时,会建立一个新的 “conntrack 2”,同时进行一次端口分配。由于未启用端口随机化,linux会尽量保持源端口不变,即再次映射成3000端口,这样就保证了这个锥形NAT是双向完整的。

但是如果我们使用了 --random--random-fully,这个过程就变得有趣了:

  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:36000) 【建立 “conntrack 1” 】 –> 远端主机B(123.0.0.1:5000)
  • 远端主机C(123.2.2.2:6000) –> NAT(223.0.0.1:36000)【重用 “conntrack 1” 】 –> 内网主机A(192.168.1.3:3000)
  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:47000) 【建立 “conntrack 2” 】 –> 远端主机C(123.2.2.2:6000)

在建立 “conntrack 2” 的时候,端口随机化导致NAT分配了一个与 “conntrack 1” 截然不同的端口号,如果 远端主机C 对此变动不知情(比如C在Symmetric NAT后面),仍然向 223.0.0.1:36000 发送UDP包,那么导致的结果是:只有A可以收到C发送的包,而C则收不到A发送的包。

这个过程可以简单地通过 netcat 命令模拟,通过显式输入对方的IP:PORT,并使用 -p 参数指定本地的监听端口。

netfilter-full-cone-nat 对端口随机化的改进

以下称 netfilter-full-cone-nat 为 xt_FULLCONENAT ,即此项目的内核模块名称。

与padavan的netfilter patch实现不同,xt_FULLCONENAT是一个第三方内核模块,没有修改conntrack核心部分的代码。padavan的原理是将一个入站packet关联至一个先前建立的conntrack;而xt_FULLCONENAT的原理是通过主动DNAT来还原入站映射。

当未使用 --random--random-fully 时,xt_FULLCONENAT的表现是这样的:

  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:3000) 【SNAT,建立 “conntrack 1” 】 –> 远端主机B(123.0.0.1:5000)
  • 远端主机C(123.2.2.2:6000) –> NAT(223.0.0.1:3000)【DNAT,建立 “conntrack 2” 】 –> 内网主机A(192.168.1.3:3000)
  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:3000) 【复用 “conntrack 2” 】 –> 远端主机C(123.2.2.2:6000)

当使用了 --random--random-fully 时,会变成这样:

  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:36000) 【SNAT,建立 “conntrack 1” 】 –> 远端主机B(123.0.0.1:5000)
  • 远端主机C(123.2.2.2:6000) –> NAT(223.0.0.1:36000)【DNAT,建立 “conntrack 2” 】 –> 内网主机A(192.168.1.3:3000)
  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:36000) 【复用 “conntrack 2” 】 –> 远端主机C(123.2.2.2:6000)

这样看上去很正常。但是,如果我们交换一下第二步和第三步,就会变成这样的结果:

  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:36000) 【SNAT,建立 “conntrack 1” 】 –> 远端主机B(123.0.0.1:5000)
  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:47000) 【SNAT,建立 “conntrack 2” 】 –> 远端主机C(123.2.2.2:6000)
  • 远端主机C(123.2.2.2:6000) –> NAT(223.0.0.1:36000)【DNAT,建立 “conntrack 3” 】 –> 内网主机A(192.168.1.3:3000)

可以见到,如果C在向A发包之前,A先向C发了包,那么NAT会映射成一个新的端口,这就破坏了Cone NAT,同样会造成单边不通的问题。

为此,xt_FULLCONENAT 在最近的commit中改造了NAT映射机制:

  • 除了自动DNAT,SNAT的映射端口也在模块内加以限制,以保证出站端口号的一致性。
  • 因为使用了Cone NAT后,WAN接口上的一个UDP端口号只能关联唯一的内网IP:PORT,默认的linux端口分配机制(即 unique_tuple )不能保证这个关联的唯一性,因此我们要在模块内完成端口分配。

现在,xt_FULLCONENAT 对出站包的处理逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 注:这里的“映射”指本模块内的 natmapping 结构 */
/* 根据内网IP:PORT搜索已经存在的映射 */
src_mapping = get_mapping_by_int_src(ip, original_port);
if (src_mapping != NULL && check_mapping(src_mapping, net, zone)) {
/* 如果这个映射已经存在,就复用这个映射:将NAT端口号限制为该条映射所记录的外网端口号 */
newrange.flags = NF_NAT_RANGE_MAP_IPS | NF_NAT_RANGE_PROTO_SPECIFIED;
newrange.min_proto.udp.port = cpu_to_be16(src_mapping->port);
newrange.max_proto = newrange.min_proto;
} else {
/* 如果未找到已存在的映射,则在本模块内进行端口分配,并创建新的映射 */
want_port = find_appropriate_port(net, zone, original_port, ifindex, range);
newrange.flags = NF_NAT_RANGE_MAP_IPS | NF_NAT_RANGE_PROTO_SPECIFIED;
newrange.min_proto.udp.port = cpu_to_be16(want_port);
newrange.max_proto = newrange.min_proto;
src_mapping = NULL;
}

通过本次改造,xt_FULLCONENAT已经完全兼容了端口随机化下的Cone NAT:当内网IP:PORT不变时,无论出入站顺序和远端地址,都将映射为相同的外网端口号.

  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:36000) 【SNAT,建立 “conntrack 1”,随机分配出站端口 】 –> 远端主机B(123.0.0.1:5000)
  • 内网主机A(192.168.1.3:3000) –> NAT(223.0.0.1:36000) 【SNAT,建立 “conntrack 2”,并复用 “conntrack 1”的出站端口 】 –> 远端主机C(123.2.2.2:6000)
  • 远端主机C(123.2.2.2:6000) –> NAT(223.0.0.1:36000)【复用 “conntrack 2” 】 –> 内网主机A(192.168.1.3:3000)

xt_FULLCONENAT更新日志:映射表老化问题

下面我们来关注 xt_FULLCONENAT 遗留的问题和更新的解决方案。
注:映射项 即 xt_FULLCONENAT 中 struct natmapping 所对应的结构。

主动式映射项老化检测:CONNTRACK_EVENTS

在上篇博文中,我提到:xt_FULLCONENAT 目前暂未能主动判断映射项失效,只能在需要用到该映射项的时候进行一次 check_mapping(),如果失效了就用新的映射替换之,以此十分被动的方法来避免映射表无限增长。

现在,我在 conntrack 核心代码中发现了在其 conntrack 回收函数 gc_worker() 中,在杀死 conntrack 时会触发一次 conntrack event,这让我想到了可以通过注册conntrack event回调来实现主动的映射项老化。

conntrack 事件回调的注册非常简单,在 struct nf_ct_event_notifier 结构体中定义一个回调函数,再通过调用 nf_conntrack_register_notifier() 即可在当前网络命名空间(network namespace)上注册一个事件回调。
接下来我们要做的,就是在回调函数中过滤掉除 IPCT_DESTROY 外的事件,获得对应的 conntrack 和 tuple ,并根据tuple的地址信息找到对应的映射项(但这时并不能立即删除映射项,后文会提到):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int ct_event_cb(unsigned int events, struct nf_ct_event *item) {
// ...
ct = item->ct;
/* we handle only conntrack destroy events */
if (ct == NULL || !(events & (1 << IPCT_DESTROY))) {
return 0;
}
// ...
/* 这里我们不知道这个是出站conntrack(SNAT),还是入站conntrack(DNAT)
* 因此需要做尝试:如果不是出站conntrack,则认为是入站conntrack */
ip = (ct_tuple->src).u3.ip;
port = be16_to_cpu((ct_tuple->src).u.udp.port);
mapping = get_mapping_by_int_src(ip, port);
if (mapping == NULL) {
ct_tuple = &(ct->tuplehash[IP_CT_DIR_REPLY].tuple);
ip = (ct_tuple->src).u3.ip;
port = be16_to_cpu((ct_tuple->src).u.udp.port);
mapping = get_mapping_by_int_src(ip, port);
}
/* 如果都还是找不到,那么认为该conntrack与本模块无关,不作处理 */
/* 如果找到了,就做相应的回收处理 */
}

通过注册conntrack事件回调,我们可以在精准的时机老化对应的映射项了。

不过,CONNTRACK_EVENTS 并不是 netfilter 中常用的 feature。在内核树中,只有 nf_conntrack_netlink 模块使用了它。
可能正由于此,conntrack notifier被设计成 在同一个network namespace中,同时只能有一个回调被注册 ,能够注册的唯一的事件回调会保存在 net->ct.nf_conntrack_event_cb 。因此这造成了 issue #5 的BUG:该模块不能与其他占用 CONNTRACK_EVENTS 的模块同时共用。linux内核树中占用 CONNTRACK_EVENTS 的模块只有 nf_conntrack_netlink ,用户态的 conntrack 命令(在 conntrack-tools 包中 )依赖这个内核模块,该命令通常用于在用户态查询和更新 conntrack table 。

万幸的是,就算不注册conntrack事件回调,我们仍可以通过老方法,被动地防止映射表无限增长。由于我对映射项建立了双边索引(LAN端和WAN端),过长的映射表虽然会浪费较多的内存,但对索引表项的性能影响很小。

映射项的引用计数及 GC 时机

这里有个地方要注意。上面提到的 CONNTRACK_EVENTS 是针对conntrack而言的,当 IPCT_DESTROY 触发时,认为该conntrack已超时失效。由于本模块中映射项是对Cone NAT而言的,一个映射项会对应多个conntracks。

在 xt_FULLCONENAT 原来的设计中,并没有考虑后来DNAT或SNAT所产生的对应同一个映射项的多个conntracks,对一个映射是否超时的判断,是基于第一个conntrack所对应的tuple mapping->original_tuple 的,因此,可能会造成这样的场景:

  • 内网主机A -> SNAT -> 外网主机B 【保存 original_tuple 到 mapping1】
  • 外网主机C -> DNAT -> 内网主机A 【通过 mapping1 进行DNAT】
  • A与B的通信停止,A与C持续通信,持续5分钟
  • original_tuple 被 conntrack_core 的 gc_worker() 回收
  • 外网主机D -> DNAT -> 丢弃【对 mapping1 进行检查,original_tuple失效,删除 mapping1】

为了解决这个问题,我引入了 映射项引用计数 的概念:当有 N 个 conntrack 与一个映射项关联时,该映射项的引用计数为 N 。
现在一个映射项的结构如下:

1
2
3
4
5
6
7
8
9
10
11
struct nat_mapping {
uint16_t port; /* external UDP port */
int ifindex; /* external interface index*/
__be32 int_addr; /* internal source ip address */
uint16_t int_port; /* internal source port */
int refer_count; /* how many references linked to this mapping
* aka. length of original_tuple_list */
struct list_head original_tuple_list;
struct hlist_node node_by_ext_port;
struct hlist_node node_by_int_src;
};

每当一次SNAT或DNAT时,我们将所对应conntrack的 ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple 追加至 original_tuple_list 列表,并累加引用计数:

1
2
3
4
5
6
7
8
9
10
static void add_original_tuple_to_mapping(struct nat_mapping *mapping, const struct nf_conntrack_tuple* original_tuple) {
struct nat_mapping_original_tuple *item = kmalloc(sizeof(struct nat_mapping_original_tuple), GFP_ATOMIC);
if (item == NULL) {
pr_debug("xt_FULLCONENAT: ERROR: kmalloc() for nat_mapping_original_tuple failed.\n");
return;
}
memcpy(&item->tuple, original_tuple, sizeof(struct nf_conntrack_tuple));
list_add(&item->node, &mapping->original_tuple_list);
(mapping->refer_count)++;
}

当 conntrack 的 IPCT_DESTROY 事件触发,或需要 check_mapping() 时,我们从该映射项的 original_tuple_list 列表中删除该失效的 tuple,并将引用计数递减;当且仅当一个映射项的引用计数为零时,我们才杀掉这个映射项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int ct_event_cb(unsigned int events, struct nf_ct_event *item) {
// ... 接上文的 ct_event_cb(),这里已从conntrack获取到对应的mapping
/* 遍历original_tuple_list,查找并清理失效的tuple */
list_for_each_safe(iter, tmp, &mapping->original_tuple_list) {
original_tuple_item = list_entry(iter, struct nat_mapping_original_tuple, node);
if (nf_ct_tuple_equal(&original_tuple_item->tuple, ct_tuple_origin)) {
list_del(&original_tuple_item->node);
kfree(original_tuple_item);
(mapping->refer_count)--;
}
}
/* 当引用计数减至零时,杀死这个映射项 */
if (mapping->refer_count <= 0) {
kill_mapping(mapping);
}
}

至此,xt_FULLCONENAT 的更新进入了新的阶段。

这里感谢 xd5520026 提出了各种冲突case及修改建议。
And many thanks to 4t0m1k for hacking into the kernel source which helped me figure out the CONNTRACK_EVENTS bug.

从DNAT到netfilter内核子系统,浅谈Linux的Full Cone NAT实现

前言

  • 根据 RFC 3489 中的定义,NAT类型被划分为以下四种: Full Cone, Restricted Cone, Port Restricted Cone, Symmetric. (RFC 5780 中对NAT类型定义有了更严格的拓展分类,但不是十分常用,本文暂不探讨。)
  • STUN过程( RFC 3489RFC 5389 )可以实现内网穿透,从而实现点到点通信。STUN技术被广泛应用于 VoIP、WebRTC、网络游戏、VPN等涉及低延迟点到点通讯的领域。
  • 基于STUN的内网穿透成功率取决于通信双方的NAT类型,其中 Full Cone NAT 的成功率最高。
  • 关于NAT type和STUN协议、STUN过程的详细知识请参考其他文章。本文仅探讨 RFC 3489 下定义的 Full Cone NAT 和 UDP hole punching 技术。
  • 本文中的NAT特指NAPT。ALG等周边技术在部分厂商的linux网络设备上已有相应实现,暂不讨论。
  • 你可以直接到 https://github.com/Chion82/netfilter-full-cone-nat 使用本文的解决方案。

关于NAT类型在Linux上实现的现状

TLDR: Linux内核树上未实现真正意义上的Full Cone NAT,Linux的 SNAT/MASQUERADE(以iptables的配置为例)均是Symmetric NAT。

2018.3 更新:
一个比较少众的华硕路由器第三方固件 padavan 通过netfilter patch的方式实现了 Cone NAT,但是其实现存在较多问题,如不支持端口随机化、内核升级不友好等,具体在下一篇博客中讨论。

现有的在Linux上的Full Cone NAT(?)的实现

之所以“Linux上未真正实现Full Cone NAT”这个问题至今没有被重视,是因为总有那么个“看似能够代替的方案”。事实上,稍微谷歌一下不难找到这样来实现Full Cone NAT的方法(假设eth0是外网出口网卡,内网主机是192.168.1.3):

1
2
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE #普通的SNAT
iptables -t nat -A PREROUTING -i eth0 -j DNAT --to-destination 192.168.1.3 #将入站流量DNAT转发到内网主机192.168.1.3

类似的实现还有NETMAP。这种设置都有一个共同点:入站流量其实是 定向DNAT转发 到某一台内网主机中,从而对该台内网主机而言,它的NAT类型总是Full Cone的。事实上这种设置更像是DMZ host,是不分条件地转发所有入站流量到内网DMZ主机。然而,这种设置只能对该台内网主机有效,对于同一内网下的其他主机,它们的NAT类型仍然是Symmetric。

UPnP技术

为了解决这个问题,UPnP起到了很重要的作用。事实上,UPnP技术基本上解决了家庭用户的内网穿透需求。(鉴于现在大多数用户都使用基于Linux的智能路由器的光猫等)在Linux上,实现UPnP的应用程序通常是 miniupnp
内网主机的应用程序在需要进行内网穿透前,对路由器进行一次UPnP请求,该请求映射一个外网端口(UDP或TCP)到内网主机的端口。miniupnp随后会执行若干条 iptables 命令,例如最重要的是这条:(为便于理解,该命令已简化,事实上miniupnpd会在 nat 表下另建一个链)

1
iptables -t nat -A PREROUTING -i eth0 -p udp --dport 5000 -j DNAT --to-destination 192.168.1.3:5000

这条命令映射了外网的 udp:5000 端口到内网主机 192.168.1.3udp:5000。只要外网端口号不重复,内网内的任何主机都能够再次发起UPnP请求来申请新的映射。映射成功后,该内网主机使用申请的内网端口进行通信时是Full Cone的。

然而,UPnP有较大的局限性:

  • UPnP涉及SSDP组播发现过程,miniupnpd的默认配置更是会向客户端返回全局映射表,相对而言不太安全,不适合应用于复杂的企业级大型网络及ISP入户网络。
  • 一次UPnP请求只能进行一级NAT的映射,当然你也可以转发UPnP请求到上级NAT设备,但是似乎至今没有一款家用智能路由器会这么做。这意味着如果你在家使用两台以上的智能路由器级联上网,仅通过UPnP是无法实现内网穿透的。

综上所述,目前Linux下对于Full Cone NAT的实现均是基于主动的端口映射技术(添加DNAT规则),这种实现一定程度上能满足现今的大多数家用场景,但仍在很多场合下有较大的局限性:如网吧、ISP的NAT(LSN)网关等,这些组织对于Full Cone NAT的实现通常是使用非Linux内核的企业级路由设备,这些设备可能基于如思科的硬转发芯片、FPGA等。

Mailing

对于Full Cone NAT这个feature,可以从内核(具体是netfilter子系统)的mailing list上找到相关的问题:

Configure to Full Cone :
How can I configure IPtables to be Full Cone?
- You cannot. iptable_nat only implements the most sophisticated version
of NAT: fully symmetric.

IPTables and different types of NAT :
“Full cone NAT” can be implemented with 1-to-1 bidirectional NAT using
SNAT+DNAT or NETMAP.

由此可见,他们的回答基本和我们的讨论基本无二:netfilter只实现最成熟的symmetric NAT,如果要实现Full Cone NAT,需要使用一对一的定向DNAT/NETMAP转发。

还有没有必要在Linux上实现真正意义上的Full Cone NAT?

经过对DMZ host和UPnP的分析,我们重新定义何为“真正意义上的Full Cone NAT”:对于内网主机中的任意一台主机,在不借助“主动端口映射”申请(如UPnP)和“手动端口转发配置”(如DMZ host或DNAT规则)下,在路由器端口用完之前,都是Full Cone的;路由器的“入站端口映射规则”是根据“内网主机出站端口映射记录”动态生成的,无论内网主机数量是否改变,内网主机的IP地址是否变更,在不依赖人工改变配置时,内网主机的NAT类型始终为Full Cone。
当然,这个定义只是为了方便理解本文的内容而定下的。

Linux的NAT类型的影响范围:涉及了所有日渐普及的基于Linux的嵌入式网络设备,如家用光猫、家用智能路由器等。事实上,部分小区级别的ISP由于节约成本使用的也是基于Linux的NAT路由器,出于安全考虑这些路由器当然不会开启UPnP,因此这部分ISP的终端用户将不可能得到完整的Full Cone NAT。对于电信、移动等一级运营商,通过光纤入户的普通用户通常经过企业级LSN设备的NAT,至此入户部分的PPPoE拨号终端通常是Full Cone的,但是,由于一部分用户使用光猫拨号再由光猫NAT后路由至用户最终的计算机(这也是运营商推荐的方式),而光猫没有开启UPnP或DMZ(用户通常没有权限配置),这部分用户的终端设备也不可能得到Full Cone NAT而通常是Symmetric NAT。

因此作者认为,还是有必要在Linux上实现Full Cone NAT。Symmetric NAT意味着更高的安全性,而Cone NAT能兼容更多的应用程序。我们当然不会通过硬编码来实现这个feature,而应该是为用户提供一个新的选择。

netfilter内核子系统和conntrack

经过作者的各种尝试,要实现一个高效的原生Full Cone NAT,在应用层是无法实现的了(就算通过程序抓包后实时添加iptables规则,或从用户态刷新conntrack,也非常低效),因此,我们只能深入linux内核来进一步研究。

HACK THE KERNEL!

上文已有提到,在linux中负责处理nat的内核子系统是netfilter,而netfilter对应的前端有iptables和nftables。

为对nat的状态进行跟踪,netfilter引入了conntrack。conntrack用来记录每一个连接(TCP/UDP/ICMP/DCCP等会话)的双向地址和端口(或id, code等会话标识符)信息。netfilter对每一个conntrack定义了一个 struct nf_conn 结构:

1
2
3
4
5
6
7
// include/net/netfilter/nf_conntrack.h
struct nf_conn {
struct nf_conntrack ct_general;
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
unsigned long status;
// ...
};

其中,tuplehash 数组是我们需要关心的。该数组通常有2个成员:tuplehash[IP_CT_DIR_ORIGINAL]tuplehash[IP_CT_DIR_REPLY]。要了解它们分别代表什么,先来看一下 struct nf_conntrack_tuple_hash 里的 struct nf_conntrack_tuple tuple 成员。这个 tuple 结构定义如下(为便于理解,一些不在同一源文件定义的struct和union被整合进来):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// include/net/netfilter/nf_conntrack_tuple.h
struct nf_conntrack_tuple {
struct nf_conntrack_man { //tuple.src 指示了源地址和源会话标识符
union nf_inet_addr u3; //源IP地址(ipv4或ipv6)
union nf_conntrack_man_proto {
// 这个union指示了源会话标识符,可以是一个TCP/UDP端口,或ICMP id,dccp port等。
// 此处省略除TCP、UDP外的其它协议
__be16 all;
struct {
__be16 port;
} tcp;
struct {
__be16 port;
} udp;
// ...
} u;
u_int16_t l3num;
} src;
struct { //tuple.dst 指示了目的地址和目的会话标识符
union nf_inet_addr u3; //目的IP地址
union { //和tuple.src一样,这个union指示了目的会话标识符
__be16 all;
struct {
__be16 port;
} tcp;
struct {
__be16 port;
} udp;
// ...
} u;
u_int8_t protonum; //协议号
u_int8_t dir;
} dst;
};

不难看出,struct nf_conntrack_tuple 结构是一个五元组,其包括了:源地址/源端口/目的地址/目的端口/协议号。而在刚才的 tuplehash 数组中,tuplehash[IP_CT_DIR_ORIGINAL]tuplehash[IP_CT_DIR_REPLY] 分别代表 “源五元组” 和 “期望收到的应答五元组”。在这里,你可以分别把它们暂时简单的理解为出站和入站的五元组,一个conntrack由一对五元组组成。

tuplehash[IP_CT_DIR_ORIGINAL] 中,src 指的是内网主机(或本机)的源地址,dst 指的是TCP/UDP流量的远端目的地址;而在 tuplehash[IP_CT_DIR_REPLY] 中,src 是TCP/UDP的远端主机的地址,dst 是本机的地址。

在内网主机经过NAT网关一次普通的SNAT后,一个conntrack存放的一对五元组tuple应包含如下信息:

  • tuplehash[IP_CT_DIR_ORIGINAL].tuple : 相对于内网主机到远端主机的方向,如 src192.168.1.3:5000 -> dst114.114.114.114:53
  • tuplehash[IP_CT_DIR_REPLY].tuple :相对于远端主机到本机的方向,如 src114.114.114.114:53 -> dst120.239.65.166:38720 (其中120.239.65.166是本机即NAT网关的外网IP,38720是SNAT映射后得到的外网端口)

参考nf_nat,我们可以概括出一次SNAT由如下步骤完成:

  1. 来自内网主机的数据包流经nat表的POSTROUTING链并触发SNAT/MASQUERADE hook,nf_nat 进行源IP转换和端口映射,并调用 nf_nat_setup_info() 对当前conntrack(我们假设这个conntrack命名为conn1)的tuplehash信息进行修改,将转换后的源外网IP和映射后的外网端口写到 tuplehash[IP_CT_DIR_REPLY].tuple.dst 中。
  2. 当有新的数据包流入时,nf_conntrack_core 通过在 resolve_normal_ct() 中根据流入的数据包得到对应的一个tuple,因为这个tuple的信息与 conn1 的 tuplehash[IP_CT_DIR_REPLY].tuple 一致,调用 nf_conntrack_find_get() 即获取到先前的 conn1。
  3. 再根据 conn1 的 tuplehash[IP_CT_DIR_ORIGINAL].tuple 信息,将流入的数据包的目的IP和端口还原成内网主机的IP的端口。

* 以上过程仅从代码层面推测,未经过严格debug验证,如实际过程有误欢迎提出。

编写一个xt_FULLCONENAT内核模块

要实现一个原生的Full Cone NAT功能,至少需要编写一个netfilter内核模块,外加一个或多个前端模块(前端模块是在用户态的一个.so文件,不涉及内核态API)。

注意,我们现在只关注RFC3489,即只实现UDP的Full Cone。其它协议的内网穿透相对而言不太常用,我们暂且搁置。

在此列出以下两种实现方案:

  • 实现应用于mangle表的hook,对每个流出流入的包进行地址和端口信息修改。相当于手动实现一遍DNAT和SNAT。这种方案可以绕过conntrack对五元组的严格验证,但实现复杂,而且性能较差。
  • 参考现有的NAT模块(如NETMAP),实现应用于nat表的hook。这种方案采用netfilter现有的nat方法来实现地址转换。但是受限于conntrack的五元组约束,除了需要依赖conntrack模块内置的映射规则来进行标准的nat,还需要另行在我们的模块中维护一张映射表。

作者采用了第二种方案。大体的设想是:在nat表的POSTROUTING链和PREROUTING链各添加一个FULLCONENAT规则,对于POSTROUTING的操作,FULLCONENAT与MASQUERADE表现无异,但需要将“端口映射记录”暂存到本模块维护的映射表中;而在PREROUTING链,FULLCONENAT对于每一个未被conntrack记录的入站连接,根据本模块维护的端口映射表,按需DNAT至相应的内网主机。

在前端使用iptables时,应用本模块的规则应该是这样的(假设 eth0 是公网出口网卡):

1
2
iptables -t nat -A POSTROUTING -o eth0 -j FULLCONENAT #same as MASQUERADE  
iptables -t nat -A PREROUTING -i eth0 -j FULLCONENAT #automatically restore NAT for inbound packets

我们使用一个hashmap来维护这个端口映射表。作者定义了这样的一个hashmap节点:

1
2
3
4
5
6
7
struct natmapping {
uint16_t port; /* external port */
__be32 int_addr; /* internal ip address */
uint16_t int_port; /* internal port */
struct nf_conntrack_tuple original_tuple;
struct hlist_node node;
};

当FULLCONENAT target应用在POSTROUTING链时,我们实现与MASQUERADE基本相同的SNAT:

1
2
3
4
__be32 new_ip = get_device_ip(skb->dev);  // 获取interface的本地IP
newrange.min_addr.ip = new_ip; // 将nat ip限制为interface的本地IP
newrange.max_addr.ip = new_ip;
ret = nf_nat_setup_info(ct, &newrange, HOOK2MANIP(xt_hooknum(par))); // 调用nf_nat_setup_info()进行SNAT

然后,我们将映射结果保存到端口映射表中:

1
2
3
4
5
6
7
8
9
10
11
12
ct_tuple_origin = &(ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple);
ct_tuple = &(ct->tuplehash[IP_CT_DIR_REPLY].tuple);
ip = (ct_tuple_origin->src).u3.ip; //这里获取到内网主机的IP
original_port = be16_to_cpu((ct_tuple_origin->src).u.udp.port); //内网主机的端口号
port = be16_to_cpu((ct_tuple->dst).u.udp.port); //映射后的外网端口号

//把映射存进映射表中
mapping = get_mapping(port); //根据外网端口号从映射表中获取对应的映射记录,可能创建了新的hashmap节点
mapping->int_addr = ip; //内网主机的IP
mapping->int_port = original_port; //内网主机的端口号
//拷贝整个tuplehash[IP_CT_DIR_ORIGINAL].tuple,至于为什么这么做稍后会解释
memcpy(&mapping->original_tuple, ct_tuple_origin, sizeof(struct nf_conntrack_tuple));

当FULLCONENAT target应用在PREROUTING链时,我们需要实时查询映射表,并实现相应的DNAT:

1
2
3
4
5
6
7
8
9
10
11
12
13
ct_tuple = &(ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple);
ip = (ct_tuple->src).u3.ip; //外网IP
port = be16_to_cpu((ct_tuple->dst).u.udp.port); //外网端口号
mapping = get_mapping(port); //根据外网端口号从映射表中获取对应的映射记录
if (is_mapping_active(mapping, ct)) {
//如果映射记录有效,则继续根据该映射记录执行DNAT:
newrange.flags |= NF_NAT_RANGE_PROTO_SPECIFIED;
newrange.min_addr.ip = mapping->int_addr; //将nat ip限制为内网主机IP
newrange.max_addr.ip = mapping->int_addr;
newrange.min_proto.udp.port = cpu_to_be16(mapping->int_port); //将nat port限制为内网主机端口号
newrange.max_proto = newrange.min_proto;
ret = nf_nat_setup_info(ct, &newrange, HOOK2MANIP(xt_hooknum(par))); // 调用nf_nat_setup_info()进行DNAT
}

对于如何“在conntrack失效(超时)时,主动从映射表中删除对应的映射记录”,作者目前暂时未找到好的方法(尽量不改动nf_conntrack部分,是否能够注册ct失效回调?)。也就是说现在这个映射表“只能添加和替换记录,而不能删除记录”(映射表的长度不会超过映射端口的范围,暂时不会导致严重的内存泄漏问题)。

为了确保在DNAT之前,先前SNAT时注册的conntrack仍然有效,作者实现了一个 is_mapping_active() 函数,该函数调用 nf_conntrack_find_get() ,通过在映射记录中保存的 original_tuple 来查询对应的conntrack,如果查找结果为空,则认为该conntrack已失效,否则认为该conntrack仍然有效,并继续进行DNAT。当然,这并不是最好的方法,如果你有更好的建议,欢迎联系我。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int is_mapping_active(const struct natmapping* mapping, const struct nf_conn *ct)
{
const struct nf_conntrack_zone *zone;
struct net *net;
struct nf_conntrack_tuple_hash *original_tuple_hash;
if (mapping->port == 0 || mapping->int_addr == 0 || mapping->int_port == 0) {
return 0;
}
/* get corresponding conntrack from the saved tuple */
net = nf_ct_net(ct);
zone = nf_ct_zone(ct);
if (net == NULL || zone == NULL) {
return 0;
}
original_tuple_hash = nf_conntrack_find_get(net, zone, &mapping->original_tuple);
if (original_tuple_hash) {
/* if the corresponding conntrack is found, consider the mapping is active */
return 1;
} else {
return 0;
}
}

至此,我们的xt_FULLCONENAT模块的核心部分已阐述完毕。除此之外,还需要编写对应的前端模块,作者参考了ipt_MASQUERADE,通过简单的关键字替换实现了ipt_FULLCONENAT,作为iptables extension可提供给iptables使用。

这个内核模块以及iptables extension的完整代码、编译和安装方法请参考GitHub仓库: https://github.com/Chion82/netfilter-full-cone-nat

FAQ

  • 使用了Linux设备作为NAT网关,内网主机运行STUN测试的测试结果是Port Restricted Cone NAT,而不是Symmetric NAT。
    这是因为Stun和netfilter对映射要素的理解存在差异造成的,具体可参考 这篇文章。但不能因此就断定linux的SNAT不是Symmetric。实际上,这是因为linux NAT网关上存在这条iptables规则导致的(OpenWRT发行版的firewall默认存在这条规则,虽然是在INPUT的子链中):
    1
    iptables -t filter -A INPUT -i eth0 -j REJECT #或者INPUT的policy为REJECT

如果删除了这条规则(在OpenWRT上则是在Firewall设置中,将wan zone的Input策略从reject改为accept),内网主机运行STUN测试的结果会始终是Symmetric NAT。

kcptun-raw:应对UDP QoS,重新实现kcptun的一次尝试

关于KCP ARQ协议和kcptun请见:
https://github.com/skywind3000/kcp
https://github.com/xtaci/kcptun
本文程序源码:
https://github.com/Chion82/kcptun-raw

UDP断流问题

我们知道kcptun的底层协议是UDP,而很多ISP对大流量UDP会做QoS,这包括了大量issue反映的“断流”问题:在正常运行一段时间之后流量会中断,需要等待数分钟才能恢复,有时候除非更换端口,将一直保持0速度。
kcptun作者对此的解决方法是使用--autoexpire参数设置UDP连接超时并自动重连(补充:UDP虽无连接状态,但仍有会话保持机制),但此举治标不治本,对于QoS频繁的网络环境需要频繁更改UDP端口,而该过程可能会导致kcptun的上层连接(本地TCP连接)中断。
为了解决断流问题,各位网友各出奇招,比如 issue #228 提到的端口随机化、上下行分不同端口等,都能一定程度缓解该问题,然而这仍然都是治标不治本的方法。
你可能理所当然的认为,“下层协议用TCP不就好了”(即kcp over tcp)。是的,ISP对TCP做的QoS要“宽容”很多(完全断流且不能恢复的几率不大),如果将kcptun的下层协议换成TCP,应该可以解决我们的问题?
答案是NO。UDP和TCP的一个重要区别是,UDP是不可靠的、基于message的实时性协议,而TCP是可靠的、基于stream的非实时性协议。而KCP作为可靠的ARQ算法,所依赖的下层协议必须保证实时性,而可靠性并不需要保证。如果我们直接将下层协议换成TCP,则流量会先经过操作系统内核进行拥塞控制和纠错处理,再递交kcp层,这样一来kcp的优化拥塞控制算法就完全没有发挥作用。

伪装TCP流量的可行性

要解决UDP断流问题,不更换下层协议是很难做到的。于是我想到,能不能直接使用网络层的IP packet作为kcp的下层协议。因为边缘路由器一般是在OSI网络层进行转发,所以在公网环境下是可行的。然而我们的客户端都是在ISP内网环境,这将不现实:因为NAT需要跟踪运输层头部数据,如双方的端口信息。后来我发现了 linhua55 同学的 some_kcptun_tools/relayRawSocket 项目,这位同学写了个简单的python脚本实现了“fake TCP to UDP”的中继,通过raw socket来实现带静态TCP头部的IP packet收发,并转发到上层UDP。经测试,这个脚本能一定程度上解决断流问题,但存在带宽利用率不高、不稳定的问题。
受这个项目的启发,我想使用同样的原理重新实现一个简单的kcptun:下层协议更换为带TCP头的packet(通过raw socket或libpcap等实现),使ISP认为这是TCP流量。但这并不是严格的“kcp over tcp”,因为我们完全绕过系统内核的TCP/IP内核栈对流量的管控而直接交由kcp进行拥塞控制和纠错。这样一来,我们我们能够保证下层报文递交到kcp算法时的实时性。
换言之,我们需要 在用户态模拟从IP到TCP的协议栈

在造轮子之前,需要验证这些带了伪TCP header的IP header是否能够在网络上正常收发。于是编写了这么的一个实验程序:
https://gist.github.com/Chion82/699ae432a27507242ea788df324f4e47

该程序修改自网上的一段SYN flood程序。
通过修改IP信息,编译出clientserver两个bin,即可测试本地到服务端的双向连通性。若测试成功,双方都能收到对方的一条text消息。
这个程序使用raw socket实现packet收发,由程序直接拼装TCP和IP报头:在 trans_packet.c 中,借助 struct iphdrstruct tcphdr 两个结构来拼接,TCP头是静态的,置flag SYN=1。
在接收packet时,由于使用了 ETH_P_ALL 过滤器(为什么不使用 IPPROTO_TCP 后面会提到),经过全部网卡的所有packet都会被捕捉,因此要通过判断IP头协议以及TCP头目标端口进行过滤。

这个实验程序说明了伪装TCP流量实现双向通信是可行的。因为要使用raw socket来重写kcptun,我们就称这个项目为“kcptun-raw”吧。

绕过内核TCP/IP协议栈

由于我们在用户态直接使用raw socket发送IP报文,双方的内核都对这个我们手动模拟的TCP连接不知情,因此当内核收到对方发送的IP报文时,内核根据报文中的TCP头信息(这些信息是我们手动拼接的)试图寻找TCP连接跟踪信息并且寻找失败,随即认为该报文是无效的,内核接着会试图“终止”我们模拟的TCP连接,并向对方发送一个RST包。该RST包会导致中间路由器认为连接已被重置,撤销打通的NAT pipe,使得接下来发送的报文都不能到达对方。

因此,我们需要使这些IP报文绕过内核的TCP/IP协议栈,以此来避免内核对我们模拟的TCP连接的干涉。这可以通过Linux的iptablesDROP命令实现。
假设服务端IP为108.0.0.1,模拟TCP连接的端口为888,在服务端的iptables命令是:

1
iptables -A INPUT -p tcp --dport 888 -j DROP

对应的客户端命令是:

1
iptables -A INPUT -p tcp -s 108.0.0.1 --sport 888 -j DROP

在使用了DROP操作后,如果raw socket继续使用 IPPROTO_TCP 过滤器,将无法接收到该端口上的任何报文。因此,我们将使用 ETH_P_ALL,接收流经网卡的全部IP报文。

1
int packet_recv_sd = socket(AF_PACKET , SOCK_DGRAM , htons(ETH_P_IP));

linhua55同学在 Issue #2 中提到Windows下绕过内核协议栈的方法,即通过 ipseccmd.exenetsh 命令。

动态TCP header和模拟三次握手

作者在几个ISP网络环境下进行了测试,发现部分环境下静态TCP报头无法传送到服务端,或是服务端返回的packet客户端收不到,这种情况下需要模拟TCP三次握手过程。另外,将TCP报头的seq/ack参数改为动态自增可以增强稳定性,而有的环境下则必须保持定值。
具体测试结果如下(Issue #2):

  • 广东移动:
    • 服务端ack flag必须置为1,否则客户端将一直收不到服务端的packet。
    • seq/ack序列需要一直保持定值,如果一直自增会被QoS随后pipe被掐断;
  • 广电宽带(广东)和电信4G:
    • 必须模拟TCP三次握手过程,即 客户端SYN->服务端SYN+ACK->客户端ACK ,随后pipe才能打通。
    • seq/ack序列无要求,保持定值和一直自增都可以。
  • 服务器提供商(阿里云等):
    • vps之间通信基本无限制,在防火墙关闭的情况下,packet想怎么发都可以(因为没有经过NAT和ISP的QoS的缘故?)

报文分层设计和流控

在确认了下层协议的实现可行性之后,作者即开始动手开发kcptun-raw。

第一次报文分层

作为隧道,最上层协议当然是TCP,而KCP的下层协议是packet。一开始理所当然的想法是,一个上层的TCP连接对应一个KCP连接,而全部KCP连接共用同一个伪造的TCP连接并在其上传输带伪造TCP头的packet作为最下层传输协议。而最下层的packet封包除了传输多个kcp连接的片段数据(KCP segment),还用于传送命令,这些命令包括建立连接、关闭连接和保活命令等。
这样,一次上层TCP连接从建立到断开的分层流程大致如下:
客户端:

TCP ↓ 新连接 ↓ 接收数据 ↑ 发送数据 ↓ 断开连接
KCP ↓ 新连接 ↓ 发送KCP片段 ↑ 接收KCP片段 ↓ 杀死
packet ↓ 推送新连接 ↓ 推送数据段 ↑ 接收数据段 ↓ 推送关闭连接

服务端:

TCP ↑ 新连接 ↑ 发送数据 ↓ 接收数据 ↑ 断开连接
KCP ↑ 新连接 ↑ 接收KCP片段 ↓ 发送KCP片段 ↑ 杀死
packet ↑ 对方推送新连接 ↑ 接收数据段 ↓ 发送数据段 ↑ 对方推送关闭连接

这看起来没有什么问题。然而这忽略了一个很重要的东西:命令封包丢失。如果丢失的是KCP的数据段封包,这没有什么问题——KCP会自动处理好重发、拥塞和纠错策略,以保证上交到TCP层的数据是正确完整的;但是如果丢失的是建立连接或断开连接的命令封包,问题就很严重了,这会导致客户端的新TCP连接迟迟无法响应、或是其中一方的TCP连接变成“僵尸连接”(其中一方已关闭连接,而主动关闭命令未送达)。这正是为什么TCP需要三次握手和四次挥手的原因:为了处理在边界情况下的各种丢包情况。

MUX层、共享KCP连接

要解决这个问题,我们需要重新设计一次报文分层。因为KCP层以上的数据都是可靠的,因此我们可以共享kcp连接,并在此之上传送命令封包和数据封包。要实现这样的设计,我们要在TCP层之下引入MUX层。MUX即Stream Multiplexing,这样我们可以在唯一的KCP流上进行多路复用。为此我们重新设计在KCP流上传输的封包类型:标识了TCP流ID的数据帧、新连接命令、断开连接命令、保活命令。

其次,考虑到加密封包和checksum验证,最终设计的数据流分层以及对应的payload定义如下:

流控

为什么需要流控?考虑这样的一个情景:服务端的TCP连接在1s内接收了100MB的数据(如果是跑在服务器本机的服务,这个速度很正常),并一下子全部经由KCP传送给客户端,而受服务端到客户端的带宽所限,这100MB数据需要2min才能传输完成,此时服务端的KCP发送队列十分拥塞,如果这时服务端上有另一个TCP连接接收到了数据,这部分数据帧将追加到KCP发送队列的队尾,并迟迟发不出去,直至100MB的数据传输完毕。这样的直接表现是,正在全速下载大文件时,发出的新连接请求要等到文件下载完毕后才得到响应,并发性能极差。

为了避免这个问题,我们需要双向流控来实现双边连接的同步:当对方kcp_recv()速度远慢于己方tcp_recv()速度,导致己方KCP发送队列长时,及时暂停己方的tcp_recv();当己方的TCP发送队列长(表现为非阻塞socket下、send() 后得到 EAGAIN ),暂停己方的kcp_recv(),以增加对方的KCP发送队列长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void kcp_update_interval() {
//...
//遍历活跃的TCP连接,并检测其发送队列长度
for (int i = 0; i < vector_total(&open_connections_vector); i++) {
connection = vector_get(&open_connections_vector, i);
if (connection->in_use && (connection->pending_send_buf_len) > MAX_QUEUE_LENGTH * BUFFER_SIZE) {
return; //TCP发送队列过长,暂停KCP接收
}
}
//KCP接收数据
int recv_len = ikcp_recv(kcp, recv_buf, BUFFER_SIZE);
//根据KCP发送队列长度判断是否暂停TCP recv()操作
int stop_recv = (iqueue_get_len(&(kcp->snd_queue)) > MAX_QUEUE_LENGTH) ? 1 : 0;
//...
}

最终实现

在确定了各项设计后,我们可以开始着手开发kcptun-raw了。程序使用非阻塞socket,借助 libev 库实现高性能的事件并发模型,避免使用多线程/进程。

kcptun-raw的开发前后遇到了不少困难,不过最终还是做出来了。经过我的测试,效果还是不错的,单线程下载速度最高在15~20 Mbytes/s,最大并发连接数程序写死了8192(当然一般是到不了这么大的)。

稳定性和保活

为了保证程序长期运行的稳定性,以应对各种不可避免的网络断流问题,我写了两层保活机制:

  • 模拟TCP层保活:packet层的心跳检测,超时则更换客户端端口、重新建立模拟TCP连接。
  • KCP层保活:在KCP流上的心跳检测,timeout时限比TCP层更长,一旦超时,将立即关闭所有上层TCP连接,重新初始化KCP流,并通知对方重新初始化KCP流。
    一般情况下,模拟TCP层的保活可以解决绝大多数断流问题——这正是两层保活的优点:下层的自动重连不会影响到KCP流和TCP连接,而当迫不得已需要重启KCP流时,必须中断全部上层TCP连接,以保证双方数据帧的同步。

经过我的测试和issue反馈,kcptun-raw目前的稳定性已经很不错了。我在软路由上运行kcptun-raw来加速VPN已经大半个月了,期间一直没有重启过。

几个问题?

  • kcptun-raw还有待改进的地方?
    目前kcptun-raw的设计是简化的kcptun,特别是取消了FEC,现在发现取消了FEC后会存在延迟抖动、带宽利用率比不上原版kcptun的问题。日后如果发现有需要,会考虑引入FEC。

  • 4.9内核后引入了BBR拥塞算法,为何还需要kcptun?或者说BBR能否为KCP提供一点改进思路?
    首先,BBR和KCP是工作在两个不同层面的东西:BBR在内核态直接作为TCP/IP协议栈的模块,接管所有TCP连接的拥塞控制,其原理是抛弃老旧的基于丢包检测的窗口控制算法,改为主动探测水管大小,避免网络设备的缓冲区满;KCP则是通过牺牲一部分公平性原则、以大流量换取小延迟的算法,基本原理是激进重传,会消耗20%~30%额外的带宽。经过我的测试,大多时候BBR虽然传输速度比kcptun更快,但是BBR未能解决被QoS的问题:一个TCP连接一旦断流,速度将一直不能恢复,需要重新建立TCP连接方可恢复。这是当然的,因为BBR只是作为接管内核原来的TCP拥塞算法,它是针对per TCP connection而工作的,并没有改变协议栈的分层约定。而kcptun-raw作为上层TCP到下层自定义协议的隧道工具,TCP层以下的协议规则约定是可以自定义的,当下层连接断流了,该层可以自动重连,而这不会导致上层TCP连接的断开,这是解决QoS的关键所在。

  • 另外,快乐膜法师同学基于原版kcptun,也写了一个基于raw socket和伪造TCP的改版,同时支持Linux、MacOS和Windows,并且还有伪装HTTP流量的功能,需要给运营商薅羊毛的同学可以参考:
    https://github.com/ccsexyz/kcpraw

再谈React同构应用:服务端下复用Redux Effects的实践

同构 (universal/isomorphic) React应用旨在服务端(或者是网关层、中途岛层)和客户端(浏览器端)尽可能地复用UI组件的代码,以提高项目的可维护性。当同构应用引入以 Redux 为首的数据流管理、以 react-router 为主的SPA前端路由后,同构应用将变得复杂:我们需要在服务端和客户端之间同步状态(store)和路由信息,并且尽可能地复用这些数据逻辑(如reducers)和路由配置。关于如何搭建这样的一个项目框架,你可以阅读 Server Side Rendering with React and Redux

本文假设你已经熟悉如何搭建一个 React + Redux + react-router 的同构应用,我们来讨论Redux副作用(side effects,后面简称effects)在服务端复用的逐步尝试和实践。

目前的典型场景

目前大多数React同构脚手架均不在服务端复用effects,而是通过直接调用Service模块的方式来加载数据,这使得我们可以直接获知异步任务何时完成,并在回调函数中直接执行我们的渲染逻辑。在渲染逻辑中,因为页面初始数据已经取得,从创建store到调用store.getState()来初始化渲染模板都是同步的,没有任何坑点,它看起来是这样的:

1
2
3
4
5
6
APIService.getTodos().then((initialData) => {
const store = configureStore(makeInitialState(initialData));
const html = ReactDOMServer.renderToString( /* ... */ );
const state = store.getState()
renderFullPage(html, state);
});

例:universal-react-starter-kit

以国内比较流行的脚手架 bodyno/universal-react-starter-kit 为例,其渲染部分的关键代码是这样的:
server/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const initialState = await router(ctx)
const store = createStore(initialState, memoryHistory)
/* ... */
match({history, routes, location: ctx.req.url}, async (err, redirect, props) => {
/* ... */
let layout = {
/* ... */
{type: 'text/javascript', innerHTML: `___INITIAL_STATE__ = ${JSON.stringify(store.getState())}`},
/* ... */
]}
/* ... */
content = renderToString(
<AppContainer layout={layout} />
);
});

其中 await router(ctx)router部分代码如下:
server/router.js

1
2
3
4
5
6
7
8
export default async function (ctx) {
return new Promise((resolve, reject) => {
/* ... */
axios.get('https://api.github.com/zen').then(({data}) => {
resolve({zen: { text: [{text: data}]} })
})
})
}

await router(ctx)在此处就是一次Service API调用。先不论这个router是否名不符实(可能因为是脚手架的原因。router.js应该是给开发者填入代码来实现对应不同路由调用不同的Service),这个脚手架的渲染逻辑跟上文的示例大同小异——直接调用Service模块异步取得初始数据,在回调(await)中通过全同步的方式用初始数据产生store并getState(),然后调用renderToString()渲染。

在服务端通过“直捅Service”的方式来获取页面初始数据,是最直接、最简单的方法。当然我们在客户端绝对不会这么做,在客户端我们会设计好同步的actions和reducers,并通过触发effects来实现异步数据获取。为了使我们的服务端代码更优雅、维护性更强、代码复用度更高,我们希望在服务端能够复用这些actions、reducers和effects。

使用redux-thunk的场景

在服务端执行一个effect是很简单的,我们只需要调用在服务端和客户端间共享的configureStore()函数来创建一个空的store(这时你将拥有effects所必须的middleware),然后调用store.dispatch()来触发一个绑定了effects的action即可。难点是:程序如何得知一个异步effects已经执行完成?这样我们才能在effects完成后调用store.getState()来取得带初始数据的state。
如果你的项目所使用的effects是 redux-thunk,你可以很容易地在服务端复用它们:你只需要在thunk函数中返回一个promise即可——而这是官方建议的标准写法。这样,store.dispatch()可以直接返回这个promise。
你的async thunk action creator看起来是这样的:

1
2
3
4
5
6
7
8
9
10
function fetchTodos() {
return function(dispatch) {
dispatch({ type: 'todos/get' });
return APIService.getTodos()
.then(payload => dispatch({
type: 'todos/get/success',
payload,
}));
}
}

APIService看起来是这样的:

1
2
3
const APIService = {
getTodos: () => fetch('/api/todos').then(response => response.json()),
}

这样,在服务端的渲染逻辑,你可以这样写:

1
2
3
4
5
6
7
const store = configureStore({});
store.dispatch({ type: 'todos/get' })
.then(() => {
const html = ReactDOMServer.renderToString( /* ... */ );
const state = store.getState()
renderFullPage(html, state);
});

另外,还有 redux-promise 的effects解决方案。在服务端复用方面,redux-promise和redux-thunk极为相似,因为使用redux-promise同样可以通过store.dispatch()获得异步任务的promise。
唯一的不同之处是,当使用redux-promise时,async action creator看起来是这样的:

1
2
3
4
5
6
function getTodos() {
return {
type: 'todos/get',
payload: APIService.getTodos(), //action.payload是一个promise
}
}

例:react-redux-universal-hot-example

让我们来看看GitHub上stars最多的Universal React脚手架 erikras/react-redux-universal-hot-example 是怎么解决的。
这个脚手架使用了 redux-async-connect middleware,这使得我们可以绑定一个promise给每一个container,并在服务端使用它提供的loadOnServer()方法获得待渲染的container的异步任务及其promise。
src/containers/App/App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@asyncConnect([{
promise: ({store: {dispatch, getState}}) => {
const promises = [];
if (!isInfoLoaded(getState())) {
promises.push(dispatch(loadInfo()));
}
if (!isAuthLoaded(getState())) {
promises.push(dispatch(loadAuth()));
}
return Promise.all(promises);
}
}])
@connect(
state => ({user: state.auth.user}),
{logout, pushState: push})
export default class App extends Component {
/* ... */
}

src/server.js

1
2
3
4
5
6
7
8
9
10
11
loadOnServer({...renderProps, store, helpers: {client}}).then(() => {
const component = (
<Provider store={store} key="provider">
<ReduxAsyncConnect {...renderProps} />
</Provider>
);
res.status(200);
global.navigator = {userAgent: req.headers['user-agent']};
res.send('<!doctype html>\n' +
ReactDOM.renderToString(<Html assets={webpackIsomorphicTools.assets()} component={component} store={store}/>));
});

从上面的代码中,我们看到:

  • 作者使用redux-async-connect将container和一个promise绑定,这个promise执行多个dispatch()调用,当它们返回的promise都resolve时才resolve自身。
  • 服务端通过调用已经绑定的loadOnServer()方法得到上述的这个promise,从而可以直接在.then()中填写该promise执行完成后的同步渲染逻辑。
  • 之所以能够这么做,还是依赖于redux-thunk的store.dispatch()调用能够返回异步任务对应的promise。

使用redux-saga的场景

然而,对于业务逻辑逐渐复杂的Web APP,redux-thunk或许不能满足复杂的数据流场景。现在国内最流行的Effects方案莫过于 redux-saga 了。

redux-saga使得异步effects完全脱离于原生Redux数据流,没有Async Action creator(你甚至不需要多余的Action Creator)。Saga effects更像是运行于另一个线程的一组任务(除了Web Worker外目前客户端JavaScript还没有真正意义上的多线程),这些任务可以监听特定的action,并在不直接影响Redux数据流的前提下执行异步操作。

因为redux-saga的这些优点,使得它可以实现更复杂的异步数据流,保留更纯净的原生Redux流,这非常优雅。而正因如此,它不会对store.dispatch()的返回值做任何更改——这意味着,在服务端我们不能指望仅仅通过store.dispatch()就能获知我们的初始数据何时到达。

这时我想到了参考已有的、使用redux-saga的同构脚手架。

dva提供的同构脚手架

dva ——蚂蚁金服推出的一个轻量级框架,基于redux、redux-saga和react-router,让你能够使用类似 elm-lang 的声明性风格来组织你的代码。

dva官方提供的同构脚手架是 sorrycc/dva-boilerplate-isomorphic 。让我们来看看它是怎么解决saga在服务端下的渲染的。
server/ssrMiddleware.js

1
2
3
4
5
6
7
8
9
10
11
12
import { fetchList } from '../common/services/user';
// ...
fetchList()
.then(({ err, data }) => {
const initialState = { user: data };
const app = createApp({
history: createMemoryHistory(),
initialState,
}, /* isServer */true);
const html = renderToString(app.start()({ renderProps }));
res.end(renderFullPage(html, initialState));
});

common/services/user.js

1
2
3
4
import request from '../utils/request';
export function fetchList() {
return request('/api/users');
}

看到这里,相信大家都明白了。dva在这里的服务端逻辑是“直捅Service”的。dva的官方脚手架并没有解决我们的问题。

官方建议的runSaga()

事实上,对于redux-saga的服务端渲染问题,早就有关于这个的讨论,参考 issue #13 。而redux-saga已添加了 runSaga() 方法来实现在服务端复用saga effects。

runSaga()接收一个saga对象和必须的store输入输出方法(subscribe()dispatch()等),允许在store上下文之外执行一个saga任务,并返回一个Task实例对象。返回的Task对象中的done属性是一个promise对象的引用,该promise在传入的saga任务执行完成后resolve。

假设我们有这样的一个saga effect:

1
2
3
4
function* getTodos() {
const payload = yield call(APIService.getTodos);
yield put({ type: 'todos/get/success', payload });
}

由于我们可以获得store上下文和sagaMiddleware,在这里我们可以直接使用sagaMiddleware.run()来代替runSaga()sagaMiddleware.run()同样返回对应这个saga任务的Task对象。

1
2
3
4
5
6
7
8
const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, initialState, compose(applyMiddleware(sagaMiddleware)));
const task = sagaMiddleware.run(getTodos);
task.done.then(() => {
const html = ReactDOMServer.renderToString(/* ... */);
const state = store.getState();
renderFullPage(html, state);
});

至此,我们貌似已经能够比较完美地在服务端复用saga effects了。

更为复杂的saga

如果我们的saga比较复杂呢?比如像这样的:

1
2
3
4
5
6
7
8
9
10
function* loginFlow() {
while (true) {
yield take('user/login');
const payload = yield call(APIService.login);
yield put({ type: 'user/login/success', payload });
yield take('user/logout');
yield call(APIService.logout);
yield put({ type: 'user/logout/success' });
}
}

这个task是一个典型的infinite saga flow,也是redux-saga相对于其他effects所独有的特性:我们可以随心所欲地定义“看起来是阻塞”的数据流任务,来解决复杂的业务场景,而无需担心阻塞任务会对UI线程造成影响。
这样的死循环saga数据流在客户端用起来是很高效优雅的,但到了服务端,这将造成严重的问题——这个saga永远不会结束,因此task.done.then()永远不会被回调,我们无法知道我们所需的数据什么时候加载完成。

对于更为普遍的情况,我们是这样定义saga任务的,比如使用蚂蚁的 ant-design/antd-init 脚手架:
src/sagas/todos.js 中定义了todos的saga:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* getTodos() {
const { jsonResult } = yield call(getAll);
if (jsonResult.data) {
yield put({
type: 'todos/get/success',
payload: jsonResult.data,
});
}
}
function* watchTodosGet() {
yield takeLatest('todos/get', getTodos)
}
export default function* () {
yield fork(watchTodosGet);
yield put({ type: 'todos/get', });
}

src/sagas/index.js 负责组合全部model的saga(通过fork()调用),并导出一个rootSaga

1
2
3
4
5
6
7
const context = require.context('./', false, /\.js$/);
const keys = context.keys().filter(item => item !== './index.js' && item !== './SagaManager.js');
export default function* root() {
for (let i = 0; i < keys.length; i ++) {
yield fork(context(keys[i]));
}
}

请注意这里的takeLatest()调用。takeLatest()是redux-saga的一个helper方法,而不是effect方法。参考 redux-saga API文档中的takeLatest,我们可以看到takeLatest()是这样实现的:

1
2
3
4
5
6
7
8
9
10
11
12
function* takeLatest(pattern, saga, ...args) {
const task = yield fork(function* () {
let lastTask
while (true) {
const action = yield take(pattern)
if (lastTask)
yield cancel(lastTask)
lastTask = yield fork(saga, ...args.concat(action))
}
})
return task
}

所以,当我们在saga中进行了一次yield takeLatest()之后,实际上是fork()出了一个带死循环数据流的另一个saga,而这个死循环的saga当然是永远不会结束的,除非它被我们人为cancel()
还有一个问题是关于redux-saga的fork模型:被fork()出来的子saga与其父saga有怎样的生命周期关联?redux-saga的官方文档 给了我们最好的回答:

In fact, attached forks shares the same semantics with the parallel Effect:

  • We’re executing tasks in parallel
  • The parent will terminate after all launched tasks terminate

意思是,父saga只有当其所有fork()出来的子saga都结束后才会结束(这和操作系统的fork模型是类似的)。这意味着,因为其子saga中带有死循环流,我们的rootSaga也是永远不会自发结束的。这样的话,我们就 不能 这么写:

1
2
3
4
5
const task = sagaMiddleware.run(rootSaga);
store.dispatch({ type: 'todos/get' });
task.done.then(() => {
// 这里的代码不会被执行
});

我们只能够直接run()不带死循环流的saga来获得初始数据,像这样:

1
2
3
4
5
6
const task = sagaMiddleware.run(getTodos);
task.done.then(() => {
const html = ReactDOMServer.renderToString(/* ... */);
const state = store.getState();
renderFullPage(html, state);
});

这跟我们刚才提到的官方建议的方法没有任何区别。在服务端我们需要规避那些包含死循环流的saga,如watchTodosGet

这将导致客户端和服务端出现大量的 异构 :在客户端,我们直接执行rootSaga,通过dispatch()特定的action来获取数据并同步到state;而在服务端,我们需要找到并执行可以获取到数据并且不带死循环的saga,如getTodos

使用redux-wait-for-action来搭救

为了将 同构 进行到底,博主写了一个Redux middleware来解决这个问题: redux-wait-for-action 。这个代码不到80行的middleware主要实现了:在dispatch一个action时,同时指定另外一个我们期望收到的action,store.dispatch()返回一个promise,当这个我们期望的action到达时,该promise将resolve。
这样,我们可以在服务端复用rootSaga而不需要关心这个rootSaga何时结束。同时,在服务端创建的store,其生命周期将在http响应完成后结束,我们甚至不需要手动cancel()这个看似不会自发结束的rootSaga——交给GC来杀死它们就行了。
我们不妨写一个在客户端和服务端通用的configureStore()方法来创建我们的store,并且执行我们的rootSaga

1
2
3
4
5
6
7
8
9
10
const configureStore = (initialState) => {
const sagaMiddleware = createSagaMiddleware();
let enhancer = compose(
applyMiddleware(sagaMiddleware),
applyMiddleware(createReduxWaitForMiddleware()),
);
const store = createStore(rootReducer, initialState, enhancer);
sagaMiddleware.run(rootSaga);
return store;
};

在服务端渲染逻辑中,我们只需要直接dispatch()这个action即可——这和在客户端获取数据的方式完全相同:

1
2
3
4
5
6
7
8
9
const store = configureStore({});
store.dispatch({
type: 'todos/get',
[ WAIT_FOR_ACTION ]: 'todos/get/success',
}).then(() => {
const html = ReactDOMServer.renderToString(/* ... */);
const state = store.getState();
renderFullPage(html, state);
})

在上面的示例代码中,我们在dispatch()一个action时,在这个action中增加了一个属性WAIT_FOR_ACTIONWAIT_FOR_ACTION是一个从redux-wait-for-action导入的ES6 Symbol对象,因此你不需担心这会污染你的action),该属性指定了另一个我们所期望的action todos/get/success。这个store.dispatch()调用返回一个promise,当action todos/get/success到达时,这个promise将resolve,因此我们可以在它的.then()中填写我们的渲染逻辑——因为这时我们所需的数据已经准备好。

由于redux-wait-for-action是基于等待action的,它将适用于近乎全部的effects方案(当然,对于redux-thunk和redux-promise则没有这个必要),当以后有更为流行的effects方案时,我们仍然可以使用这个middleware。
关于更具体的使用方法,大家可以参考 README for redux-wait-for-action

更优雅地组织同构应用

以上示例都是基于在服务端进行路由判断并决策执行哪个effects的,当我们的数据模型变得多时,服务端代码将变得复杂。比如:该dispatch todos/get还是profile/get?我们需要对req.url进行一一判断。

借助react-router的match()方法,我们能够得到对应路由下的container组件,如果我们能在每个路由下的container组件中定义一个fetchData()方法来dispatch合适的action,我们就可以大大简化服务端的代码,并且可以同时在服务端和客户端都使用它来加载页面数据。

在每个路由节点对应的container的代码中,添加一个fetchData() 静态 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TodosContainer extends Component {
static fetchData(dispatch) {
return dispatch({
type: 'todos/get',
[ WAIT_FOR_ACTION ]: 'todos/get/success',
});
}
componentDidMount() {
// 这个钩子方法仅会在客户端被调用
TodosContainer.fetchData(this.props.dispatch);
}
// ...
}

在服务端渲染代码中,我们定义一个getReduxPromise()函数,这个函数抽出当前路由下对应的container组件,并调用其中的fetchData()方法,从而得到一个promise。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
match({history, routes, location: req.url}, (error, redirectLocation, renderProps) => {
/* 前面这里需要处理redirectLocation、error和renderProps为null的情况 */
/* ... */
const getReduxPromise = () => {
const component = renderProps.components[renderProps.components.length - 1].WrappedComponent;
const promise = component.fetchData ?
component.fetchData(store.dispatch) :
Promise.resolve();
return promise;
};
getReduxPromise().then(() => {
const initStateString = JSON.stringify(store.getState());
const html = ReactDOMServer.renderToString(
<Provider store={store}>
{ <RouterContext {...renderProps}/> }
</Provider>
);
res.status(200).send(renderFullPage(html, initStateString));
});
});

遇到需要传递cookie或参数的情况,我们可以稍微修改一下fetchData()

1
2
3
4
5
6
7
static fetchData(dispatch, query, cookies) {
return dispatch({
type: 'todos/get',
[ WAIT_FOR_ACTION ]: 'todos/get/success',
query, cookies,
})
}

在服务端调用fetchData()时:

1
component.fetchData(store.dispatch, req.query, req.cookies);

由于客户端一般不需要在XHR中显式加cookie,因此我们在客户端调用fetchData()时忽略cookies参数即可,并在APIService模块中做适当的判断。

另外,为了节省篇幅和便于理解,以上各处示例代码中均没有异常处理部分(或被去除)。在实际项目中,请务必在effects中添加try-catch逻辑,并在promise的处理部分添加.catch()异常处理方法。

博主的脚手架

为了在实践中更好地理解以上所提到的最优化方案,博主写了这个脚手架,同时便于大家快速搭建同构React应用:
react-redux-universal-minimal

ChionLab 2016年底更新记录

早上好。本站从建立至今已将近一年,博主最近对小站进行了若干修改和调整,具体包括:

  1. 新的样式主题 Uzume ,并保留原主题 Miria ,在站点顶部banner可切换主题。
    • 新主题 Uzume 角色是 天王星うずめ (天王星涡芽),出自游戏 新次元ゲイム ネプテューヌVII (新次元游戏 海王星VII,PSN港区译作 新次元遊戲 戰機少女VII )。Banner题图为博主亲自合成所得。
    • 原主题 Miria 角色是 赤城みりあ (赤城米莉亚),出自游戏、动画和漫画 アイドルマスター シンデレラガールス (偶像大师 灰姑娘女孩)系列。
  2. 针对境内访问用户,对网站作了以下调整优化:
    • 新增CDN加速节点CN2( https://cn2.chionlab.moe ),保留原加速节点CloudFlare( https://blog.chionlab.moe )。对于境内和境外(或科学上网)用户,在访问本站时会自动切换。当然,你也可以在本站顶部手动切换CDN加速线路。
    • 针对境内用户无法访问Disqus的问题,新增多说评论模块。CN2节点默认屏蔽Disqus评论模块,只保留多说评论模块;原CloudFlare节点则同时保留Disqus和多说两个评论模块。问题:多说模块请求了第三方http资源,因此在访问本站文章时浏览器可能会报安全策略warning,但不影响体验。
    • 将Google Fonts的 Source Code Pro font face本地化,提高字体资源加载速度。

以上调整各处,除了CDN节点部署,均通过修改hexo和hexo主题源码完成,若有必要,以后将发表博文以提供修改思路和指点。

TCP keepalive的探究 (2) : 浏览器的Keepalive机制

上文介绍了TCP Keepalive机制以及其在linux中的编程实现,本文将继续介绍这种机制在浏览器中的运用,并以Chrome为例。

HTTP1.1中的Connection: Keep-Alive

在介绍Chrome对TCP Keepalive的实现之前,我们先来了解一下第七层协议HTTP1.1中的Connection字段。注意,本章节讨论的Keepalive为七层协议(HTTP1.1)中的Keep-Alive机制。

HTTP1.1协议头(header)中的Connection字段可取这两个值的其中之一:keep-alive, close
该字段在请求头(request header)和响应头(response header)中都可以存在,这说明,客户端可以申请开启Keep-Alive,而服务端可以接受Keep-Alive请求,或者拒绝并在响应头中告知客户端。

作用机理

这里以一次完整的HTTP1.1网站访问来说明。

  1. 客户端浏览器向 www.bilibili.com:80 建立TCP连接,并在此TCP连接上传输七层报文,请求GET /index.html资源,在请求头中,Connection置为keep-alive
  2. 服务端向浏览器返回index.html的文件内容,响应报头中Connection置为keep-alive,随后,不关闭和客户端的TCP连接
  3. 客户端复用该TCP连接,并请求GET /style.css资源,请求头置Connectionkeep-alive
  4. 服务器向浏览器返回index.css文件内容,仍然不关闭该TCP连接。
  5. 客户端继续复用该TCP连接请求多个同域资源。
  6. 客户端所需的各种资源都请求完毕,但是因为客户端的最后一次资源请求头中仍置Connectionkeep-alive,该TCP连接仍未被关闭。
  7. 如果在一段时间(通常是3分钟左右)内客户端没有使用该TCP连接请求资源,服务器可能会关闭该连接。连接被关闭后,客户端需要重新向该域建立TCP连接才能继续请求数据。

几点细节

  • HTTP1.1的Keep-Alive机制仅对同域下的网络请求有效。比如,对于http://www.bilibili.com/index.htmlhttp://www.bilibili.com/style.css这两个资源请求,浏览器能够复用其TCP连接,而对于非同域下的http://space.bilibili.com/index.html,则需要重新建立一次TCP连接。

  • 服务器有权拒绝客户端的Keep-Alive请求,在响应头中置Connectionclose,并在传输一次完整的响应报文后主动关闭TCP连接,在这之后,客户端如需向该域请求资源,则需重新建立TCP连接。而事实上,即使客户端和服务端都开启了Keep-Alive,服务端一般会主动关闭非活动的连接,否则会造成资源浪费。

  • Keep-Alive虽然可以在一定程度上通过复用TCP连接来提高页面资源加载性能,但是受HTTP1.1的max-connection限制,提高的性能很有限。很多时候,为了加快更多资源的加载,通常会使用多个不同域名的CDN。而在HTTP2中,通过二进制数据帧的方式来传输同域下多资源,可以解决这个问题。关于HTTP2的传输机制,可以参考这篇文章

Chrome对TCP连接的保活机制

上篇章节中我们熟悉了七层协议中HTTP1.1的Keep-Alive机制,本章节我们介绍Chrome对四层协议的TCP Keepalive的实现。

Chrome何时需要启用TCP Keepalive?
假定服务器启用了HTTP1.1 Keep-Alive,浏览器与服务器建立TCP连接,并在该TCP连接上有序地传输多个HTTP1.1七层报文,以此来请求多个资源。对于同域下,在浏览器完成一次请求并获得对应资源后,若一段时间内暂时未有新的资源请求(资源请求可能由页面JavaScript发出,如Ajax),直至下次请求发出前,该TCP连接保持空闲状态。而在这段空闲时间内,浏览器需要对该TCP连接进行保活。

下面我们将通过Wireshark抓包来验证。

从上面的抓包结果中看到,在服务器返回完整HTTP 200报文的45秒后(Time=72),本地发出了第一个TCP Keepalive探测包并收到来自服务器的ACK。

这说明,Chrome对于可复用的TCP连接,采用的保活机制是TCP层(传输层)自带的Keepalive机制,通过TCP Keepalive探测包的方式实现,而不是在七层报文上自成协议来传输其它数据。

而实际上,由于HTTP1.1对时序和报文的约定,浏览器也不可在七层实现保活。假设,客户端在通过HTTP1.1获取一次资源后,若在这个TCP连接上发送一个0x70(无意义的数据,在七层实现保活的方式大多如此),服务器会在应用层接收到并缓存该数据,一段时间后客户端发送有效的HTTP请求报头,则服务端CGI应用程序收到的数据是0x70再接上一段HTTP请求头,这被认为是无效的HTTP报文,服务器则会返回400响应头,告知客户端这是坏的请求(Bad Request)。

所以,浏览器在处理HTTP1.1请求所对应的TCP连接的保活时,通过使用TCP Keepalive机制,来避免污染七层(应用层)的传输数据。

待续

本篇主要介绍浏览器对TCP Keepalive的运用,内容简单。结合本篇内容,作者将在下篇文章中详细说明作者在使用shadowsocks浏览web时遇到的问题、解决方案以及一点思考。

TCP keepalive的探究 (1) : NAT和保活机制

关于应用层的TCP连接保活(Keepalive)机制,相信大家都听说过。对于长连接TCP保活,典型的方法是发送应用层的心跳包,但这将增加开发人员的工作量:需要专门为心跳包制定协议。而在Linux的socket通信API中,自带了TCP_KEEPALIVE的相关参数设定,通过这种方式实现TCP长连接保活,无需修改原程序的逻辑,开发人员不需要关心心跳包的实现。本系列文章将从路由器NAT原理、keepalive基本的代码实现、浏览器保活机制、存在的问题几个方面逐步深入探究。

NAT

为什么要使用TCP keepalive?这得从NAT(地址转换)原理开始讲起。狭义上,NAT分为SNAT(原地址转换)和DNAT(目标地址转换),关于DNAT,有兴趣的同学可以自行查阅,本文只讨论SNAT。

我们都知道,路由器的最基本功能是对第三层(网络层)上的IP报文进行转发。实际上,路由器还有很关键的一个功能,这便是NAT。特别是对于ISP对普通用户链路上的路由器,NAT功能尤为重要。

为什么要使用NAT?原因很简单:IPv4地址非常稀缺。上网需求庞大,这使得ISP不可能为每一个入网用户都提供一个独立的公网IP,因此通常情况下,ISP会把用户接入局域网,使得多个用户共享同一个公网IP,而每一个用户各分得一个局域网内网IP。而连接公网和局域网的这台路由器,称之为网关(gateway),NAT的过程就发生在这台网关路由器上。

三层地址转换

局域网内的主机向公网发出的网络层IP报文,将经由网关被转发至公网,而在该转发过程中发生了地址转换。网关将该IP报文中的 源IP地址 从”该主机的内网IP”修改为”网关的公网IP”。

比如,局域网主机获得的内网IP为192.168.1.100,网关的公网IP为210.177.63.2,局域网主机向公网目标主机发出的IP报文中,源IP字段数据为192.168.1.100,在经过网关时,该字段数据将被修改为210.177.63.2

为什么要这么做,相信大家已经猜到了。公网上的目标主机在收到这个IP报文后,需要知道这个IP报文的来源地址,并向该来源地址发送响应报文,但如果不经过NAT,目标主机拿到的来源地址是192.168.1.100,这显然是一个公网上不可访问到的私有地址,目标主机无法将响应报文发送到正确的来源主机上。开启了NAT之后,IP报文的来源地址被网关修改为210.177.63.2,这是一个公网地址,目标主机将向这个地址(即网关路由器的公网地址)发送响应报文。

但是请注意,如果这个IP报文的数据段不含传输层协议报文,而是一个pure的网络层packet,来自目标主机的响应报文是不能被网关准确转发到多台局域网主机中的其中一台的。(ICMP报文除外,其报头中有Identifier字段用于标识不同的主机或进程,网关在处理Identifier时类似于下面提到的运输层端口)

传输层端口转换表

在三层地址转换中,我们可以保证局域网内主机向公网发出的IP报文能顺利到达目的主机,但是从目的主机返回的IP报文却不能准确送至指定局域网主机(我们不能让网关把IP报文广播至全部局域网主机,因为这样必然会带来安全和性能问题)。为了解决这个问题,网关路由器需要借助传输层端口,通常情况下是TCP或UDP端口,由此来生成一张端口转换表。

让我们通过一个实例来说明端口转换表如何运作。
假设局域网主机A192.168.1.100需要与公网上的目标主机B210.199.38.2:80进行一次TCP通信。其中A所在局域网的网关C的公网IP地址为210.177.63.2。步骤如下:

1. 局域网主机A192.168.1.100发出TCP连接请求,A上的TCP端口为系统分配的53600。该TCP握手包中,包含源地址和端口192.168.1.100:53600,目的地址和端口210.199.38.2:80
2. 网关C将该包的原地址和端口修改为210.177.63.2:63000,其中63000是网关分配的临时端口。
3. 网关C在端口转换表中增加一条记录:

内网主机IP 内网主机端口 网关端口 目的主机IP 目的主机端口
192.168.1.100 53600 63000 210.199.38.2 80

4. 网关C将修改后的TCP包发送至目的主机B。
5. 目的主机B收到后,发送响应TCP包。该响应TCP包含有以下信息:源地址和端口210.199.38.2:80,目的地址和端口210.177.63.2:63000
6. 网关C收到这个来自B的响应包后,随即在端口转换表中查找记录。该记录须符合以下条件:目的主机IP==210.199.38.2,目的主机端口==80,网关端口==63000
7. 网关C搜索到这条记录,记录显示内网主机IP为192.168.1.100,内网主机端口为53600
8. 网关C将该包的目的地址和端口修改为192.168.1.100:53600
9. 网关C随即将该修改后的TCP包转发至192.168.1.100:53600,即局域网主机A。此时运输层数据的一次交换已完成。

问题所在

在网关C上,由于端口数量有限(0~65535),端口转换表的维护占用系统资源,因此不能无休止地向端口转换表中增加记录。对于过期的记录,网关需要将其删除。如何判断哪些是过期记录?网关认为,一段时间内无活动的连接是过期的,应定时检测转换表中的非活动连接,并将之丢弃。而这个丢弃的过程,网关不会以任何的方式通告该连接的任何一端。

那么问题就来了:如果一个客户端应用程序由于业务需要,需要与服务端维持长连接(如TCP聊天程序),而如果在特别长的时间内(在博主的ISP环境下,该时间在3分钟左右),这个连接没有任何的数据交换,网关会认为这个连接过期并将这个连接从端口转换表中丢弃。该连接被丢弃时,客户端和服务端对此是完全无感知的。在连接被丢弃后,客户端将收不到服务端的数据推送,客户端发送的数据包也不能到达服务端。

第一次实验

让我们使用TCP测试工具netcat来实际实验一下。

  • 在公网服务器上,使用nc -l 9999命令监听TCP端口9999
  • 在局域网主机上,使用nc XX.XX.XX.XX 9999命令连接到这台公网服务器的9999端口。
  • 进行基本的双向发包测试。
  • 不关闭连接,在空闲5分钟后再进行双向发包测试。

在我的例子中,在双方建立TCP连接后,客户端(局域网主机)发送一行hello from client,服务端发送一行hello from server
等待5分钟,然后客户端发送一行test from client

通过wireshark在客户端主机上抓包,跟踪这个TCP连接得出如下结果:

从上图可得出:

  • 在第144秒时,通过TCP三次握手,双方建立连接。
  • 随后双方各发一行hello信息,并都成功接收到ACK响应包,证明发送成功。
  • 在第500秒时,客户端发送test from client,但是没有收到对方响应ACK,导致客户端多次重发(TCP Retransmission),但是仍然收不到ACK。

在服务端上,仅能收到客户端一开始发送的hello from client,5分钟后客户端发送的test from client并不能收到:

而在服务端尝试发送test from server,客户端也收不到了。

这表明,在这空闲的5分钟内,网关路由器已经“掐断”了这个TCP连接,导致5分钟后该连接不可再用。但无论是客户端还是服务端,都不知道这个连接已经作废了,因此客户端在发包没有收到ACK后仍在尝试重发,双方的netcat进程仍然没有退出,说明了网关在掐断连接时并没有通知双方。

TCP Keepalive

如果我们的业务需要我们维持长连接,这就要避免网关“干掉”我们的长连接。解决方法就是,让网关认为我们的TCP连接是活动的。在应用层,我们可以通过定时发送心跳包的方式实现。而如果使用Linux提供的TCP_KEEPALIVE,在应用层我们可完全不关心心跳包何时发送、发送什么内容,这一切由操作系统自动管理:操作系统会在该TCP连接上定时发送探测包,探测包既能像心跳包一样起到连接保活的作用,也能自动检测连接的有效性,并自动关闭无效连接。

在Linux全局内核设置中,有以下三个参数:

1
2
3
4
5
6
# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9

  • tcp_keepalive_time: 如果在该时间内没有数据往来,则发送探测包。
  • tcp_keepalive_intvl: 探测包发送间隔时间。
  • tcp_keepalive_probes: 尝试探测的次数。如果发送的探测包次数超过该值仍然没有收到对方响应,则认为连接已失效并关闭连接。

TCP Keepalive默认是关闭的。要启用这个特性,需要在程序中如下设置(代码实例来自Linux下TCP keepalive属性的表现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <netinet/tcp.h>

int keepAlive = 1; // 开启keepalive属性
int keepIdle = 60; // 如该连接在60秒内没有任何数据往来,则进行探测
int keepInterval = 5; // 探测时发包的时间间隔为5 秒
int keepCount = 3; // 探测尝试的次数.如果第1次探测包就收到响应了,则后2次的不再发.

setsockopt(rs, SOL_SOCKET, SO_KEEPALIVE, (void *)&keepAlive, sizeof(keepAlive));
setsockopt(rs, SOL_TCP, TCP_KEEPIDLE, (void*)&keepIdle, sizeof(keepIdle)); //对应tcp_keepalive_time
setsockopt(rs, SOL_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval)); //对应tcp_keepalive_intvl
setsockopt(rs, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount)); //对应tcp_keepalive_probes

如果省略TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT三个属性的设置,将使用上文的三个系统全局默认值。

第二次实验

这次我们使用 netcat-keepalive 来测试。这个Github上的开源项目在netcat的基础上加入了上述的代码。参数说明请参照README。

测试方法基本不变。唯一的不同之处是,客户端使用netcat-keepalive,并开启TCP Keepalive特性。

客户端上的测试结果和wireshark抓包记录如下:

抓包记录显示,在空闲的5分钟内,客户端每隔30秒发送一个TCP探测包(TCP Keep-Alive),并收到服务端ACK(TCP Keep-Alive ACK)。在5分钟后客户端发送test from client,服务端发送test from server,均发送成功。

服务端上的截图:

这证明,我们通过TCP Keepalive,成功地阻止了网关路由器丢弃我们的TCP长连接,所以我们在5分钟后仍能够使用这个长连接进行通信。

让我们来看看这个TCP Keep-Alive探测包是个什么东西:

由上图可看出,探测包是一个特殊的TCP包:它的长度为零,Flags位ACK置1,Options置为两个NOP,而它的端口信息和普通的TCP数据包是一样的。

对于服务端响应的TCP Keep-Alive ACK探测包,是由服务器操作系统发送的。实际上,在使用应用层TCP编程时,并不能收到这个探测包,所以服务端应用程序对该探测包是无感知的。

待续…

本文从NAT基本原理介绍了TCP Keepalive的原理和基本实现,在下篇文章中,我们将探究Chrome浏览器对于TCP保活的实现。

mixins是有害的(Mixins Considered Harmful)[下篇]

上篇

原文:Facebook React: Mixins Considered Harmful

Migrating from Mixins
Let’s make it clear that mixins are not technically deprecated. If you use React.createClass(), you may keep using them. We only say that they didn’t work well for us, and so we won’t recommend using them in the future.
Every section below corresponds to a mixin usage pattern that we found in the Facebook codebase. For each of them, we describe the problem and a solution that we think works better than mixins. The examples are written in ES5 but once you don’t need mixins, you can switch to ES6 classes if you’d like.
We hope that you find this list helpful. Please let us know if we missed important use cases so we can either amend the list or be proven wrong!

从Mixins迁移

有一点需要说明的是,从技术上来讲,mixins不是被弃用的。如果你在使用React.createClass(),你可以继续使用它们。我们只是说它们对我们而言不能很好地运用,并且我们不推荐在未来中继续使用它们。下面的每一章节对应了我们在Facebook代码库中发现的mixin的使用场景。对于每种情况,我们会说明问题所在,并展示我们认为比使用mixins更好的解决方案。示例都使用ES5编写,但当你不再需要mixins时,你可以随心所欲地切换到ES6 classes。
我们希望你能从这个列表中得到帮助。如果我们缺漏了一些比较重要的应用场景,请告知我们,因此我们能拓展这个列表,或者证明其中的部分是错误的。

Performance Optimizations
One of the most commonly used mixins is PureRenderMixin. You might be using it in some components to prevent unnecessary re-renders when the props and state are shallowly equal to the previous props and state:

性能优化

使用率最高的mixins之一是 PureRenderMixin 。你可能正在一些组件中使用它,当props和state跟上次的值是浅层相等时,可避免不必要的重渲染

1
2
3
4
5
6
7
8
var PureRenderMixin = require('react-addons-pure-render-mixin');

var Button = React.createClass({
mixins: [PureRenderMixin],

// ...

});

解决方案

To express the same without mixins, you can use the shallowCompare function directly instead:

为了达到相同的效果而不使用mixins,你可以直接使用shallowCompare

1
2
3
4
5
6
7
8
9
10
var shallowCompare = require('react-addons-shallow-compare');

var Button = React.createClass({
shouldComponentUpdate: function(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
},

// ...

});

If you use a custom mixin implementing a shouldComponentUpdate function with different algorithm, we suggest exporting just that single function from a module and calling it directly from your components.

We understand that more typing can be annoying. For the most common case, we plan to introduce a new base class called React.PureComponent in the next minor release. It uses the same shallow comparison as PureRenderMixin does today.

如果你使用一个自定义的mixin,以不同的算法实现 shouldComponentUpdate 方法,我们建议从模块中导出该单一的方法,并在你的组件中直接调用它。
我们理解频繁的编码是令人不快的。对于更普遍的情况,我们计划在下一个小版本发布中引入一个新的基类React.PureComponent。它将使用浅层对比算法,正如今天的PureRenderMixin

Subscriptions and Side Effects
The second most common type of mixins that we encountered are mixins that subscribe a React component to a third-party data source. Whether this data source is a Flux Store, an Rx Observable, or something else, the pattern is very similar: the subscription is created in componentDidMount, destroyed in componentWillUnmount, and the change handler calls this.setState().

订阅和副作用

我们遇到的第二种最常见的mixins类型是那些用来订阅React组件到第三方数据源的mixins。无论这些数据源是一个Flux Store,还是一个Rx Observable,抑或是其他的,该模式都是相似的:订阅在componentDidMount中产生,在componentWillUnmount中被销毁,而变更处理函数将调用 this.setState()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
var SubscriptionMixin = {
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
}
};

var CommentList = React.createClass({
mixins: [SubscriptionMixin],

render: function() {
// Reading comments from state managed by mixin.
var comments = this.state.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});

module.exports = CommentList;

Solution

If there is just one component subscribed to this data source, it is fine to embed the subscription logic right into the component. Avoid premature abstractions.

If several components used this mixin to subscribe to a data source, a nice way to avoid repetition is to use a pattern called “higher-order components”. It can sound intimidating so we will take a closer look at how this pattern naturally emerges from the component model.

解决方案

如果只有一个组件被订阅到这个数据源,直接将订阅逻辑内嵌到该组件中不失为一个良策。避免草率的抽象。

如果多个组件都使用这个mixin来订阅到一个数据源,一个好的避免重复冗余的方法是使用一种被称为“高阶组件(higher-order components,又称HOC)”的模式。这听起来让人生畏,所以我们将仔细分析这个模式如何自然地套用到组件模型上。

Higher-Order Components Explained
Let’s forget about React for a second. Consider these two functions that add and multiply numbers, logging the results as they do that:

高阶组件的解释

让我们暂时忘记React。想想这两个实现相加和相乘的函数,通过这样来实现记录计算结果:

1
2
3
4
5
6
7
8
9
10
11
function addAndLog(x, y) {
var result = x + y;
console.log('result:', result);
return result;
}

function multiplyAndLog(x, y) {
var result = x * y;
console.log('result:', result);
return result;
}

These two functions are not very useful but they help us demonstrate a pattern that we can later apply to components.

Let’s say that we want to extract the logging logic out of these functions without changing their signatures. How can we do this? An elegant solution is to write a higher-order function, that is, a function that takes a function as an argument and returns a function.

Again, it sounds more intimidating than it really is:

这两个函数并不是十分有用,但它们可以帮助我们描述一个典型的模式,这个模式我们之后将把它应用到组件上。

假设我们想从这些函数中抽离记录逻辑而不修改它们的签名。如何做到这点?一个优雅的方案是,写一个更高阶的函数,这个更高阶的函数实际上是一个将函数作为其参数,并返回一个新函数的函数。

又一次,它听起来让人生畏,但实际上它是更简单的:

1
2
3
4
5
6
7
8
9
10
function withLogging(wrappedFunction) {
// Return a function with the same API...
return function(x, y) {
// ... that calls the original function
var result = wrappedFunction(x, y);
// ... but also logs its result!
console.log('result:', result);
return result;
};
}

The withLogging higher-order function lets us write add and multiply without the logging statements, and later wrap them to get addAndLog and multiplyAndLog with exactly the same signatures as before:

这个 withLogging 高阶函数让我们在实现相加和相乘逻辑时不需考虑记录逻辑,在这之后我们通过嵌套的方式来得到与之前签名一致的 addAndLogmultiplyAndLog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function add(x, y) {
return x + y;
}

function multiply(x, y) {
return x * y;
}

function withLogging(wrappedFunction) {
return function(x, y) {
var result = wrappedFunction(x, y);
console.log('result:', result);
return result;
};
}

// Equivalent to writing addAndLog by hand:
var addAndLog = withLogging(add);

// Equivalent to writing multiplyAndLog by hand:
var multiplyAndLog = withLogging(multiply);

Higher-order components are a very similar pattern, but applied to components in React. We will apply this transformation from mixins in two steps.

As a first step, we will split our CommentList component in two, a child and a parent. The child will be only concerned with rendering the comments. The parent will set up the subscription and pass the up-to-date data to the child via props.

高阶组件是一种非常相似的模式,只不过它是应用在React组件上的而已。我们将这种转换应用到mixins上,只需要两步即可。

第一步,我们将CommentList组件分为子和父两部分。子组件只关心渲染评论,而父组件将设置订阅,并将最新的数据通过props传递到子组件上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// This is a child component.
// It only renders the comments it receives as props.
var CommentList = React.createClass({
render: function() {
// Note: now reading from props rather than state.
var comments = this.props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});

// This is a parent component.
// It subscribes to the data source and renders <CommentList />.
var CommentListWithSubscription = React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},

render: function() {
// We pass the current state as props to CommentList.
return <CommentList comments={this.state.comments} />;
}
});

module.exports = CommentListWithSubscription;

There is just one final step left to do.

Remember how we made withLogging() take a function and return another function wrapping it? We can apply a similar pattern to React components.

We will write a new function called withSubscription(WrappedComponent). Its argument could be any React component. We will pass CommentList as WrappedComponent, but we could also apply withSubscription() to any other component in our codebase.

This function would return another component. The returned component would manage the subscription and render with the current data.

We call this pattern a “higher-order component”.

The composition happens at React rendering level rather than with a direct function call. This is why it doesn’t matter whether the wrapped component is defined with createClass(), as an ES6 class or a function. If WrappedComponent is a React component, the component created by withSubscription() can render it.

只剩下最后一步了。

还记得我们如何使得withLogging()传入一个函数并返回另一个嵌套它的函数吗?我们可以将相似的模式应用到React组件上来。

我们将编写一个新的函数,叫做withSubscription(WrappedComponent)。它的参数可以是任意的React组件。我们将传递CommentList作为WrappedComponent,但我们也可以在我们的代码基中将withSubscription()应用到任意其他的组件上。

这个函数会返回另一个组件。返回的组件将会管理好订阅,并渲染包含数据的<WrappedComponent />

我们把这种模式称为一个“高阶组件”。

这种合成发生在React的渲染层,而不是通过一个直接的函数调用。这就是为什么无论内嵌的组件是由createClass()创建的,还是由ES6 class生成的,抑或是一个函数,都无关紧要了。如果WrappedComponent是一个React组件,通过withSubscription()创建的组件都能渲染它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// This function takes a component...
function withSubscription(WrappedComponent) {
// ...and returns another component...
return React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
// ... that takes care of the subscription...
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},

render: function() {
// ... and renders the wrapped component with the fresh data!
return <WrappedComponent comments={this.state.comments} />;
}
});
}

Now we can declare CommentListWithSubscription by applying withSubscription to CommentList:

现在我们可以通过应用withSubscriptionCommentList上来声明CommentListWithSubscription了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var CommentList = React.createClass({
render: function() {
var comments = this.props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});

// withSubscription() returns a new component that
// is subscribed to the data source and renders
// <CommentList /> with up-to-date data.
var CommentListWithSubscription = withSubscription(CommentList);

// The rest of the app is interested in the subscribed component
// so we export it instead of the original unwrapped CommentList.
module.exports = CommentListWithSubscription;

Solution, Revisited
Now that we understand higher-order components better, let’s take another look at the complete solution that doesn’t involve mixins. There are a few minor changes that are annotated with inline comments:

解决方案,重现

现在我们能更好的理解高阶组件了,让我们来再看一次完整的、无需涉及mixins的解决方案。内联的注释有少量修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function withSubscription(WrappedComponent) {
return React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},

render: function() {
// Use JSX spread syntax to pass all props and state down automatically.
return <WrappedComponent {...this.props} {...this.state} />;
}
});
}

// Optional change: convert CommentList to a functional component
// because it doesn't use lifecycle hooks or state.
function CommentList(props) {
var comments = props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}

// Instead of declaring CommentListWithSubscription,
// we export the wrapped component right away.
module.exports = withSubscription(CommentList);

Higher-order components are a powerful pattern. You can pass additional arguments to them if you want to further customize their behavior. After all, they are not even a feature of React. They are just functions that receive components and return components that wrap them.

Like any solution, higher-order components have their own pitfalls. For example, if you heavily use refs, you might notice that wrapping something into a higher-order component changes the ref to point to the wrapping component. In practice we discourage using refs for component communication so we don’t think it’s a big issue. In the future, we might consider adding ref forwarding to React to solve this annoyance.

高阶组件是一个强大的模式。你可以给它们传递更多的参数,如果你想要进一步高度定制它们的行为。毕境,它们甚至不是React的特性之一。它们只是接受传入组件,并返回嵌套了传入组件的新组件的函数而已。

就像其它解决方案,高阶函数同样有他们的潜在风险。比如,如果你大量地使用refs(组件引用),你可能会发现,将任意组件嵌套进高阶组件里面时,内层组件的ref会被改变。在实践中我们不建议使用refs来实现组件间通信,所以我们不认为这是个大问题。在未来,我们将考虑引入ref重定向到React中来解决这个问题。

Rendering Logic
The next most common use case for mixins that we discovered in our codebase is sharing rendering logic between components.

Here is a typical example of this pattern:

渲染逻辑

在我们的代码库中,我们发现的下一个常见的mixins用例是组件间渲染逻辑的共享。

以下是这个模式的典型例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var RowMixin = {
// Called by components from render()
renderHeader: function() {
return (
<div className='row-header'>
<h1>
{this.getHeaderText() /* Defined by components */}
</h1>
</div>
);
}
};

var UserRow = React.createClass({
mixins: [RowMixin],

// Called by RowMixin.renderHeader()
getHeaderText: function() {
return this.props.user.fullName;
},

render: function() {
return (
<div>
{this.renderHeader() /* Defined by RowMixin */}
<h2>{this.props.user.biography}</h2>
</div>
)
}
});

Multiple components may be sharing RowMixin to render the header, and each of them would need to define getHeaderText().

多个组件可能共享了RowMixin来渲染行头,而每个这些组件都需要定义一个getHeaderText()方法。

Solution

If you see rendering logic inside a mixin, it’s time to extract a component!

Instead of RowMixin, we will define a component. We will also replace the convention of defining a getHeaderText() method with the standard mechanism of top-data flow in React: passing props.

Finally, since neither of those components currently need lifecycle hooks or state, we can declare them as simple functions:

解决方案

如果你看见了一个mixin里面含有渲染逻辑,那么是时候把它们抽离到组件中了!

我们将定义一个<Row>组件来取代RowMixin。我们也将会把借由定义一个getHeaderText()方法来实现转换的方式替换成React中标准的自顶向下数据流机制:传递props。

最后,因为这些组件现在都不再需要生命周期钩子和状态了,我们会把他们定义为简单的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function RowHeader(props) {
return (
<div className='row-header'>
<h1>{props.text}</h1>
</div>
);
}

function UserRow(props) {
return (
<div>
<RowHeader text={props.user.fullName} />
<h2>{props.user.biography}</h2>
</div>
);
}

Props keep component dependencies explicit, easy to replace, and enforceable with tools like Flow and TypeScript.

Props使得组件依赖保持显式、易于替换、对诸如Flow和TypeScript一类的工具更易执行。

Note:

Defining components as functions is not required. There is also nothing wrong with using lifecycle hooks and state—they are first-class React features. We use functional components in this example because they are easier to read and we didn’t need those extra features, but classes would work just as fine.

备注:
将组件定义为函数不是必需的。使用React的头等特性:生命周期钩子和状态也是没有任何错误的。我们在这个示例中使用函数式组件,因为它们可以更易于阅读,并且我们不需要那些另外的特性,但使用classes也是一样的效果。

Context
Another group of mixins we discovered were helpers for providing and consuming React context. Context is an experimental unstable feature, has certain issues, and will likely change its API in the future. We don’t recommend using it unless you’re confident there is no other way of solving your problem.

Nevertheless, if you already use context today, you might have been hiding its usage with mixins like this:

上下文(Context)

我们发现的另外一系列mixins是提供和消费React Context的辅助器。Context是一个实验性的不稳定特性,存在确定的缺陷,而且它的API在未来可能会被改变。我们不推荐使用它,除非你十分确定没有其他方法来解决你的问题。

尽管如此,如果你已经使用了context,你可能把它的使用隐藏在了mixins里,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var RouterMixin = {
contextTypes: {
router: React.PropTypes.object.isRequired
},

// The mixin provides a method so that components
// don't have to use the context API directly.
push: function(path) {
this.context.router.push(path)
}
};

var Link = React.createClass({
mixins: [RouterMixin],

handleClick: function(e) {
e.stopPropagation();

// This method is defined in RouterMixin.
this.push(this.props.to);
},

render: function() {
return (
<a onClick={this.handleClick}>
{this.props.children}
</a>
);
}
});

module.exports = Link;

Solution
We agree that hiding context usage from consuming components is a good idea until the context API stabilizes. However, we recommend using higher-order components instead of mixins for this.

Let the wrapping component grab something from the context, and pass it down with props to the wrapped component:

解决方案

在context的API稳定之前,我们认为,将context的调用在组件中隐藏起来是个好主意。不过,我们推荐使用高阶组件来取代mixins来实现这点。

让外层组件从context中获取数据,并通过props传递到内层组件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function withRouter(WrappedComponent) {
return React.createClass({
contextTypes: {
router: React.PropTypes.object.isRequired
},

render: function() {
// The wrapper component reads something from the context
// and passes it down as a prop to the wrapped component.
var router = this.context.router;
return <WrappedComponent {...this.props} router={router} />;
}
});
};

var Link = React.createClass({
handleClick: function(e) {
e.stopPropagation();

// The wrapped component uses props instead of context.
this.props.router.push(this.props.to);
},

render: function() {
return (
<a onClick={this.handleClick}>
{this.props.children}
</a>
);
}
});

// Don't forget to wrap the component!
module.exports = withRouter(Link);

If you’re using a third party library that only provides a mixin, we encourage you to file an issue with them linking to this post so that they can provide a higher-order component instead. In the meantime, you can create a higher-order component around it yourself in exactly the same way.

如果你在使用一个只提供mixin的第三方库,我们建议你去提交一个issue,引用本文链接,让他们去做成高阶组件。在这期间,通过完全一样的方式,你可以自己动手围绕它做一个高阶组件。

Utility Methods
Sometimes, mixins are used solely to share utility functions between components:

通用方法

有时候,mixins仅仅是用作在组件间共享的通用工具函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var ColorMixin = {
getLuminance(color) {
var c = parseInt(color, 16);
var r = (c & 0xFF0000) >> 16;
var g = (c & 0x00FF00) >> 8;
var b = (c & 0x0000FF);
return (0.299 * r + 0.587 * g + 0.114 * b);
}
};

var Button = React.createClass({
mixins: [ColorMixin],

render: function() {
var theme = this.getLuminance(this.props.color) > 160 ? 'dark' : 'light';
return (
<div className={theme}>
{this.props.children}
</div>
)
}
});

Solution
Put utility functions into regular JavaScript modules and import them. This also makes it easier to test them or use them outside of your components:

解决方案

将通用的工具方法放入常规的JavaScript模块中,并引入它们。这同样使得测试和组件外调用变得简单:

1
2
3
4
5
6
7
8
9
10
11
12
var getLuminance = require('../utils/getLuminance');

var Button = React.createClass({
render: function() {
var theme = getLuminance(this.props.color) > 160 ? 'dark' : 'light';
return (
<div className={theme}>
{this.props.children}
</div>
)
}
});

Other Use Cases
Sometimes people use mixins to selectively add logging to lifecycle hooks in some components. In the future, we intend to provide an official DevTools API that would let you implement something similar without touching the components. However it’s still very much a work in progress. If you heavily depend on logging mixins for debugging, you might want to keep using those mixins for a little longer.

If you can’t accomplish something with a component, a higher-order component, or a utility module, it could be mean that React should provide this out of the box. File an issue to tell us about your use case for mixins, and we’ll help you consider alternatives or perhaps implement your feature request.

Mixins are not deprecated in the traditional sense. You can keep using them with React.createClass(), as we won’t be changing it further. Eventually, as ES6 classes gain more adoption and their usability problems in React are solved, we might split React.createClass() into a separate package because most people wouldn’t need it. Even in that case, your old mixins would keep working.

We believe that the alternatives above are better for the vast majority of cases, and we invite you to try writing React apps without using mixins.

其他用例

有时候,人们使用mixins来向一些组件添加选择性的生命周期钩子日志记录。在未来,我们计划提供一个官方的开发工具API来实现相似功能,而无需触碰组件代码。虽然这仍有大量正在进度中的工作需要完成。如果你十分依赖日志记录mixins来调试,你可能还要继续保持使用它们一段时间。

如果你借助一个组件、一个高阶组件、或者一个通用模块,仍然不能完成一些事情,这意味着React应该是难以完成这样的事情的。向我们提交一个issue,告诉我们你的mixins使用场景,我们会帮助你考虑可选的方案,或者是在未来实现你的新特性请求。

Mixins在传统感官中不是完全抛弃的。你可以通过React.createClass()继续使用它们,因为我们不会在未来修改它。最终,当ES6 classes得到更广泛的采用,并且它们在React中使用上的问题得到解决时,我们也许会将React.createClass()分离到独立的包之中,因为大多数人不再需要它。即使是在那样的情况下,你的老mixins仍然能够继续工作。

我们相信,以上所提到的可选方案对于绝大多数的场景是更好的选择,我们邀请你来尝试在不使用mixins的情况下编写React应用。

mixins是有害的(Mixins Considered Harmful)[上篇]

原文:Facebook React: Mixins Considered Harmful

“How do I share the code between several components?” is one of the first questions that people ask when they learn React. Our answer has always been to use component composition for code reuse. You can define a component and use it in several other components.

“我如何在多个组件(components)之间共享代码?”,这是React初学者的问题之一。我们的答案一直都是,通过组件组合的方法来实现代码复用。你可以定义一个组件,并在其它的组件中使用它。

It is not always obvious how a certain pattern can be solved with composition. React is influenced by functional programming but it came into the field that was dominated by object-oriented libraries. It was hard for engineers both inside and outside of Facebook to give up on the patterns they were used to.

通过组件组合的方式来解决某一种情况不总是显而易见的。React受函数式编程影响,但结果它成为了由面向对象库组成的存在。无伦是Facebook内部员工,还是非Facebook的程序员,抛弃以往的开发方式都是困难的。

To ease the initial adoption and learning, we included certain escape hatches into React. The mixin system was one of those escape hatches, and its goal was to give you a way to reuse code between components when you aren’t sure how to solve the same problem with composition.

为了让入门学习变得简单,我们引入了一些解决方案(原文“escape hatches”即逃生舱,此处语义为解决问题的一些trick)。Mixin系统是其中的一个方法,它的目的是,当你不知道如何通过组件组合来解决问题时,来给你一个方法来实现组件间的代码复用。

Three years passed since React was released. The landscape has changed. Multiple view libraries now adopt a component model similar to React. Using composition over inheritance to build declarative user interfaces is no longer a novelty. We are also more confident in the React component model, and we have seen many creative uses of it both internally and in the community.
In this post, we will consider the problems commonly caused by mixins. Then we will suggest several alternative patterns for the same use cases. We have found those patterns to scale better with the complexity of the codebase than mixins.

React发布后三年过去了,大环境发生了改变。大多数视图库现在都采用类似React的组件模型。通过多个组件在继承关系之上的组合来构建用户界面不再是一个新奇的方式。我们也对React的组件模型更加自信,并且在内部和社区中,都看到了许多具有创新性的使用方式。
在这篇文章中,我们会讨论由mixins造成的普遍问题。然后我们会提出一些同等情况下的可选替代方案。这些新的方案,在同等的代码复杂度下,比用mixins的可扩展性更好。

为什么说Mixins不好?

At Facebook, React usage has grown from a few components to thousands of them. This gives us a window into how people use React. Thanks to declarative rendering and top-down data flow, many teams were able to fix a bunch of bugs while shipping new features as they adopted React.

在Facebook,React的使用从少量的组件演变成上千的组件数量。这给我们看见了人们是如何使用React的。多亏于声明性的渲染和自上而下的数据流,很多团队能够在迁移项目到React的时候修复一些bug。

However it’s inevitable that some of our code using React gradually became incomprehensible. Occasionally, the React team would see groups of components in different projects that people were afraid to touch. These components were too easy to break accidentally, were confusing to new developers, and eventually became just as confusing to the people who wrote them in the first place. Much of this confusion was caused by mixins. At the time, I wasn’t working at Facebook but I came to the same conclusions after writing my fair share of terrible mixins.

但是,一个很难避免的情况是,一些代码在使用了React了之后逐渐降低了可读性。有时,使用React的开发团队中会出现一些人们不太愿意去触碰的组件,而这些组件在不同的项目中被使用了。这些组件太容易意外损坏,这不但困扰了新加入的开发者,最终也困扰了一开始编写这些组件的人。这些麻烦的问题大多是由mixins造成的。在那时,我还未在Facebook工作,但在使用了一系列糟糕的mixins之后,我也能得出跟现在一样的结论。

This doesn’t mean that mixins themselves are bad. People successfully employ them in different languages and paradigms, including some functional languages. At Facebook, we extensively use traits in Hack which are fairly similar to mixins. Nevertheless, we think that mixins are unnecessary and problematic in React codebases. Here’s why.

这并不代表mixins都是不好的。人们成功地在不同的语言和范例中应用了mixins,其中包括了一些函数式语言。在Facebook,我们大量使用了类似mixins的一些比较hack的实现方式。我们认为mixins在React中是不再必要的,而且是非常容易出问题的。接下来讨论这是为什么。

Mixins引入了隐性的依赖

Sometimes a component relies on a certain method defined in the mixin, such as getClassName(). Sometimes it’s the other way around, and mixin calls a method like renderHeader() on the component. JavaScript is a dynamic language so it’s hard to enforce or document these dependencies.
Mixins break the common and usually safe assumption that you can rename a state key or a method by searching for its occurrences in the component file. You might write a stateful component and then your coworker might add a mixin that reads this state. In a few months, you might want to move that state up to the parent component so it can be shared with a sibling. Will you remember to update the mixin to read a prop instead? What if, by now, other components also use this mixin?

有时候一个组件依赖一个在mixin中定义的确定的方法,比如getClassName()。有时候在另一个场景下,mixin在组件上调用了一个方法,比如renderHeader()。JavaScript是一种动态语言,所以去强制定义或者记录这些依赖是很困难的。
Mixins打破了一个通用的、通常是安全的假设:你可以通过在组件源码文件中搜索的方式来重命名一个方法或者一个状态的key。你写了一个具有状态的组件,然后你的组员加入了一个mixin来读取它的状态。过了一两个月,你想把这个状态挪到父组件上,来实现跟相邻组件共享。你会记得同时更新这个mixin的代码,把它改为读取prop吗?再如果,现在还有其它组件也使用了这个mixin?

These implicit dependencies make it hard for new team members to contribute to a codebase. A component’s render() method might reference some method that isn’t defined on the class. Is it safe to remove? Perhaps it’s defined in one of the mixins. But which one of them? You need to scroll up to the mixin list, open each of those files, and look for this method. Worse, mixins can specify their own mixins, so the search can be deep.
Often, mixins come to depend on other mixins, and removing one of them breaks the other. In these situations it is very tricky to tell how the data flows in and out of mixins, and what their dependency graph looks like. Unlike components, mixins don’t form a hierarchy: they are flattened and operate in the same namespace.

这些隐形的依赖使得新成员在现有代码基础上继续开发变得困难。一个组件的render()方法也许引用了一些不在本类中定义的方法,删除它们是否安全?也许它们定义在mixins中,但是在哪个里面呢?你需要滚动到mixin列表,打开每个mixin的源码,来找这些方法。更坏的是,mixins可以定义它们自己的mixins,所以这次查找是一次深度查找。
经常地,mixins还依赖其它的mixins,如果你删除其中之一,可能会波及到另外的。在这种情况下,说明数据如何在mixins流入流出就变得很棘手了,更别说画出它们之间的依赖关系图。不像组件,mixins不会构成继承链:它们是扁平化的,并在同一个命名空间中起作用。

Mixins造成命名冲突

There is no guarantee that two particular mixins can be used together. For example, if FluxListenerMixin defines handleChange() and WindowSizeMixin defines handleChange(), you can’t use them together. You also can’t define a method with this name on your own component.
It’s not a big deal if you control the mixin code. When you have a conflict, you can rename that method on one of the mixins. However it’s tricky because some components or other mixins may already be calling this method directly, and you need to find and fix those calls as well.

从没有保证说任意两个mixins可以在一起使用。比如,如果FluxListenerMixin定义了handleChange()WindowSizeMixin也定义了handleChange(),你就不能把它们拿在一块用。你也不能在你的组件中用这个名字来命名方法。
如果你能控制mixin的代码,那问题是不大的。当你遇到了命名冲突,你可以在其中的mixin中修改那个方法的名字。但是,如果有另外的mixins或是组件已经直接调用了这个方法,这就变得很棘手了,你需要同时找到和修复这些调用。

If you have a name conflict with a mixin from a third party package, you can’t just rename a method on it. Instead, you have to use awkward method names on your component to avoid clashes.
The situation is no better for mixin authors. Even adding a new method to a mixin is always a potentially breaking change because a method with the same name might already exist on some of the components using it, either directly or through another mixin. Once written, mixins are hard to remove or change. Bad ideas don’t get refactored away because refactoring is too risky.

如果你在使用一个第三方包的mixin时遇到了命名冲突,你就不能改它的方法名了。取而代之,你需要在你的组件中使用很蹩脚的方法名来避免冲突。
这样的情况对于mixin作者来说并没有好多少。加入一个新方法到mixin中总是一个潜在的风险,因为在已经使用了这个mixin的组件中,可能早就存在同名的方法了,无伦是直接调用还是通过其它mixin来调用。一旦mixins写好,就很困难去修改或者移除其中的东西。一些欠佳的实现方式得不到重构,因为重构的风险太大。

Mixins造成滚雪球式的复杂性

Even when mixins start out simple, they tend to become complex over time. The example below is based on a real scenario I’ve seen play out in a codebase.
A component needs some state to track mouse hover. To keep this logic reusable, you might extract handleMouseEnter(), handleMouseLeave() and isHovering() into a HoverMixin. Next, somebody needs to implement a tooltip. They don’t want to duplicate the logic in HoverMixin so they create a TooltipMixin that uses HoverMixin. TooltipMixin reads isHovering() provided by HoverMixin in its componentDidUpdate() and either shows or hides the tooltip.

虽然mixins是从简单开始的,但它们会随着时间变得越来越复杂。下面的例子是基于一个真实的情况。
一个组件需要一些状态来跟踪鼠标的悬浮(hover)。为了使这个逻辑可复用,你抽取了handleMouseEnter()handleMouseLeave()isHovering()方法到一个HoverMixin里。接下来,有人需要实现一个悬浮提示框(tooltip)。他们不想拷贝HoverMixin里的逻辑代码,因此创建了一个TooltipMixin,这个TooltipMixin引用了HoverMixinTooltipMixin在它的componentDidUpdate()中读取由HoverMixin提供的isHovering()来显示或者隐藏提示框。

A few months later, somebody wants to make the tooltip direction configurable. In an effort to avoid code duplication, they add support for a new optional method called getTooltipOptions() to TooltipMixin. By this time, components that show popovers also use HoverMixin. However popovers need a different hover delay. To solve this, somebody adds support for an optional getHoverOptions() method and implements it in TooltipMixin. Those mixins are now tightly coupled.
This is fine while there are no new requirements. However this solution doesn’t scale well. What if you want to support displaying multiple tooltips in a single component? You can’t define the same mixin twice in a component. What if the tooltips need to be displayed automatically in a guided tour instead of on hover? Good luck decoupling TooltipMixin from HoverMixin. What if you need to support the case where the hover area and the tooltip anchor are located in different components? You can’t easily hoist the state used by mixin up into the parent component. Unlike components, mixins don’t lend themselves naturally to such changes.

几个月后,有人想让这个提示框的弹出方向变得可配置。为了避免代码重复,他们添加了一个新的配置方法getTooltipOptions()TooltipMixin。在这时,需要弹出浮层的组件也使用了HoverMixin。但是浮层需要不同的鼠标悬浮延时。为了解决这个问题,有人添加并实现了一个配置方法getHoverOptions()TooltipMixin中。这两个mixins现在紧紧耦合在一起了。
如果没有新的需求,这样是没有问题的。但是这个方法的可扩展性并不强。如果你想在同一个组件里面支持显示多个提示框呢?你不能在一个组件里面定义两次同一个mixin。如果提示框需要在用户引导里自动弹出,而不是在鼠标悬浮时弹出呢?你想解耦TooltipMixinHoverMixin?祝你好运。如果你想让鼠标悬浮点和提示框锚点在不同的组件中呢?你不能轻易地将mixin使用的状态抬升到父组件中。不像组件,mixins在遇到这些改变时并不能很自然地交付。

Every new requirement makes the mixins harder to understand. Components using the same mixin become increasingly coupled with time. Any new capability gets added to all of the components using that mixin. There is no way to split a “simpler” part of the mixin without either duplicating the code or introducing more dependencies and indirection between mixins. Gradually, the encapsulation boundaries erode, and since it’s hard to change or remove the existing mixins, they keep getting more abstract until nobody understands how they work.
These are the same problems we faced building apps before React. We found that they are solved by declarative rendering, top-down data flow, and encapsulated components. At Facebook, we have been migrating our code to use alternative patterns to mixins, and we are generally happy with the results. You can read about those patterns below.

每个新需求让mixins变得越来越难以理解。随着时间,使用同一个mixin的组件之间的耦合度变得越来越高。任何新的功能都会同时被附加到所有使用了这个mixin的组件。没有方法去分离这个mixin的“更简单”的部分,除非去拷贝其中的代码,或者在mixins之间引入更多的依赖和奇技淫巧。逐渐地,原来的封装会瓦解,并且因为更改或者移除已经存在的mixins是困难的,它们会变得更抽象,直到没人理解它们是怎么工作的。
这些问题跟我们在React出来之前构建应用程序时遇到的问题是一样的。我们认为这些问题可以通过声明性的渲染、自上而下的数据流和组件封装来解决。在Facebook,我们已经将代码的实现方式从mixins迁移到了取而代之的模式,并且我们对结果很乐观。你可以继续阅读来了解我们的新模式。

OpenWRT下双WAN配置

晚上好。博主前段时间因沉迷CGSS和PS4游戏,长时间未更新博客,实在不好。现在正值暑假,博主在公司实习,今晚趁未加班,写一篇早就想写的openwrt路由器干货。

本文讲述如何在openwrt家用智能路由器上配置双WAN带宽叠加。

前提条件

  • 两条或更多的宽带,或者是支持单线多拨的宽带。
  • 已经安装MWAN3及luci图形化配置界面(Pandorabox固件默认已安装)。

VLAN配置

什么是VLAN?VLAN是在同一物理局域网内用于划分若干个不同广播域(子网)的技术,子网内的主机可以互相通信,不同子网的主机之间不可互相通信。
什么是VLAN ID?用于标识每个VLAN子网的ID。
为什么要划分VLAN?在OpenWRT下,接口是根据VLAN划分的,每个逻辑接口(interface)可对应一个VLAN ID作为物理接口,这将在后面的步骤中体现出来。

在openwrt的web配置页面上,进入 网络->交换机 (Network->Switch)。
默认情况下,已经分配的VLAN应该有1个或者2个。
通过插拔网线的方法,将配置页上的端口和路由器的物理RJ45接口对应上来。
在小米路由器mini上,默认分配如下两个vlan:

其中,VLAN1用作LAN,连接了除端口4以外的所有物理端口;VLAN2是默认的WAN,只连接端口4。(此处端口4即为小米路由器mini上的蓝色WAN RJ45物理端口)
注意,端口状态“不关联”(untagged),即该端口作为本VLAN成员,进行二层交换;若选择“关联”(tagged),端口之间通信无二层交换,而是冲突广播(hub方式)。

选择一个端口作为第二个WAN口的端口,在现有的VLAN配置中将其设置为“关”,然后新建一个VLAN,将该端口设置为“不关联”,其他端口设置为“关”,CPU设置为“关联”。注意,小米路由器mini有一个特殊的端口7,按照原有的两个VLAN,将其设置为“关联”即可。
如图,博主选择端口1来作为第二个WAN端口,在VLAN1中将其设置为“关”,并在新建的VLAN3中设置其为“不关联”。

保存即可。

新建WAN接口

进入 网络->接口,将当前WAN接口更名为WAN1,并添加一个新接口,命名为WAN2
WAN2的配置中,设置第二条宽带的拨号方式,在“物理设置”中选择刚才添加的VLAN3(eth0.3)。

重要
进入WAN1的编辑页,在“高级设置”中,勾选“使用默认网关”,填写“使用网关跃点”为40;
进入WAN2的编辑页,在“高级设置”中,勾选“使用默认网关”,填写“使用网关跃点”为41;

若有更多的WAN需要添加,方法类似,需要注意每个WAN接口的网关跃点必须不一样。

设置完成后,在接口总览中应该能看到两个WAN都成功获取到IP,如果是PPPoE方式,应该都已经拨号成功。

MWAN3配置

接下来需要通过MWAN3实现多WAN负载均衡。

进入 网络->负载均衡。

  • 接口配置
    进入 配置->接口。
    删除所有已有的默认接口。
    添加两个接口,分别为WAN1WAN2
    在接口详情的“跟踪的IP地址”中,可添加几个国内的主机IP作为检测接口是否上线的ping地址。当ping该IP多次超时后,即该接口视作下线。
    博主的固件版本下,这个跟踪功能并不好使,经常误判断接口下线,因此我清空了跟踪的IP地址,并视作接口始终上线。

  • 成员配置
    进入 配置->成员,删除所有已有的默认成员,添加两个成员,分别命名为wan_1, wan_2
    成员wan_1设置接口为WAN1,跃点数1,接口比重1;
    成员wan_2设置接口为WAN2,跃点数1,接口比重1;

  • 策略配置
    进入 配置->策略,添加一个策略balanced(或者编辑已有的balanced策略),使用的成员为wan_1, wan_2

  • 规则配置
    进入 配置->规则,保留已有的https规则。如果没有default_rule规则,则添加一条default_rule规则,目标地址设置为0.0.0.0/0,协议选择all,使用的策略为balanced,其他留空。

  • 保存并应用全部设置,此时应该能够实现双线负载均衡了。

至此,openwrt路由器上的双WAN配置实现带宽叠加已经完成了,可以测速看看了。