为IPsec VPN设置在中国大陆的中转节点

要点:
– 以下这种思路是不对的:服务器A是出口服务器,然后服务器B是中转服务器,服务器B用VPN客户端的配置去连接服务器A,然后服务器B也配置VPN服务端连接,服务器B就能当中转节点用了。
– 注意主服务器和中转节点的NAT和防火墙的设置,用来中转的服务器的VPN配置不能加NAT,NAT的操作统一在出口服务器进行。
– VPN的用途是在不安全的互联网上建立一条加密的通道,使得远程工作的员工能安全地访问公司内网的资源不被窃听。VPN不是用来翻墙的,拿VPN用来翻墙,等同于每天开着半挂车通勤上班,生怕别人不知道你要装逼。
– 给懒人复制粘贴准备的配置文件在文末。

为什么要折腾VPN

我又来折腾因特网安全协议了,视无数比IPsec VPN好的爬墙方式为无物。

从2017年到2024年,托openSUSE贡献者MargueriteSu的福,我用IPsec VPN翻墙翻了六年,竟然几乎从来没有被封死过。也许这就是GreatFire.org所说的 “依附的自由” ,因为IPsec服务在跨国企业内网互联等用途上应用的如此广泛,即使这个协议的特征明显的不能再明显,我也依然能用到现在。至于能用到什么时候,可能得等到CCP彻底和外资撕破脸皮或者外贸创汇这条路完全堵死或者国内大乱。

不管怎么说,2024年了,我的梯子还活着,甚至还用它来玩游戏。这篇文章的诞生也和玩游戏不无关系。本人长期沉迷MapleStory,也就是国际服冒险岛。这段时间找了个国服的老朋友帮忙代打,但是他连我用来玩游戏的加速器(梯子)延迟不美丽。在找不到三大运营商通吃的国外服务器的情况下,我想到了这个歪招:找个国内的延迟不错的服务器,搭上VPN服务端,之后透过某种方式把流量全都加密转发到国外的服务器上。这个服务器在国内的延迟有保证,连接国外的服务器也是直连,这样延迟就通过走指定路径的VPN隧道变相降低了。也就是所谓的中转节点:连上国内的VPN服务,实际上打开网页查询当前IP是在国外用来爬墙的IP。

有那么多先进的翻墙协议为什么我却视而不见呢?原因是:先进的翻墙软件在本地的通联方式,一般都是本机启动一个应用层的http或者socks5代理服务器,网游运营商们都在想尽办法锁区,你指望他支持通过本机代理连接游戏服务器,无异于劝老虎下半辈子吃素。只有在网络层上转发数据的VPN,才能帮我欺骗网游客户端,让它认为我的的IP在服务区域内,我才能成功地玩上MapleStory这个垃圾游戏。哦所以为啥我不去找能把socks5服务器包装成虚拟网卡的东西?免费的SSTap停更了;Proxifier我曾经买过,新版本下了试用版好像也能用,但是要求我再掏一次钱,还没有升级优惠,这就非常的不厚道。所以我最后的选择还是操作系统内建的VPN客户端。

“错误”的中转服务器的搭建思路

给VPN做中转节点,有一个想当然的思路实际上是行不通的。假设服务器A是翻墙用的VPS,服务器B用来中转,然后都是提供的IPsec VPN服务;现在服务器B要变成服务器A的傀儡,于是你在服务器B上用NetworkManager提供的IPsec VPN配置模板,把服务器B作为VPN客户端连接上服务器A,这样就实现了“中转”。当你照这个思路配置完了,然后服务器B去连接A,你会发现服务器B确实是连上了服务器A,但是和服务器B建立的SSH连接也断了,你也连不上服务器B提供的VPN服务。你不得不登上云服务器网站,透过服务商提供的VNC连接把VPN关掉。

为什么这个路子行不通?先考虑VPN客户端和服务器之间连接的场景。

VPN客户端可以看作是在内核上设立的一个检查站,这个检查站会检查所有的入站和出站的IP包,本机IP或者对端IP满足了“检查站”设置的规则,这个IP包就会被分流到IPsec隧道。这个规则是通过swanctl.conf里的local_tsremote_ts来设置的。假设有个美帝的公司内网的网段是172.16.0.0/16且提供了IPsec VPN服务,中国出差的员工Carol用strongSwan作为IPsec VPN客户端连接到公司内网,Carol把remote_ts的值设置成172.16.0.0/16用这个配置连上了公司的VPN,此时Carol电脑上的VPN客户端就会分流所有目标IP落在172.16.0.0/16的出站IP包和源IP落在172.16.0.0/16的入站IP包,把这些IP包都引导到IPsec隧道这条路上(把整个IP包加密封装成ESP包再加个新的IP头发给VPN服务器),Carol便可以远程到自己工位上的电脑继续未完成的工作,至于不满足这个规则的其它IP包则保持原来的样子不变。

再来说翻墙的用途,Carol想摸鱼看YouTube,因此Carol想到了白嫖公司的VPN。她可以把remote_ts的值设成0.0.0.0/0,这个时候在VPN客户端的指引下Carol电脑所有的IP包都必须走IPsec隧道,直连互联网的路出入两个方向都被VPN客户端设置的规则封死了。此时有人ping Carol的电脑,由于VPN客户端设置了只有通过IPsec隧道传入传出的IP包才能放行,ping过来的ICMP包不符合这个规则,所以内核必须把这些包丢掉。把Carol的电脑换成用于中转的服务器B,道理是一样的,VPN连接建立以后,连过来的TCP包ISAKMP包都不符合VPN客户端半路插进去的规则,全都要被丢掉,服务器B对外的通信自然也都会中断。

打个政治不正确的比喻,这里(基于策略的)的VPN,就像通往圣城麦加的公路上的路牌。信仰真主的请直行进入IPsec隧道,不信的请靠右行驶进入匝道,通往互联网方向。

在墙外进行的组网实验

我恶补了读大学时学的网络安全课本,又简单看了strongSwan的测试用例。课本明确说了在互联网上安全地互联多个区域的内网就是IPsec的应用场景之一。strongSwan提供的net-net测试用例,讲的也是这个场景下如何配置VPN。

在真正用国内国外两个服务器实现里通外国之前,我用了墙外自己买的两只小鸡(vps)试着实现这个中转的配置。假设两个小鸡的名字分别叫moon和sun,和net-net测试用例保持一致,且moon和sun都已经预先配置好了IPsec VPN服务。现在预期达到的目标是:moon的VPN服务和之前一样正常提供;计算机连接sun也和以前一样,但是连接互联网的流量会通过两台服务器建立的IPsec隧道中转到moon上,统一由moon来处理。这时连接sun的计算机,访问ipip.net查自己的IP应该是moon的IP而不是sun的。我把科班知识都还给了老师了,这个简单的需求,竟然花掉了我两周多的时间。因此这个配置过程需要记录下来作为备忘,不然以后又要踩坑了。然后写这篇烂文又耗掉了快一个月。

strongSwan的测试用例net2net-psk

我有点懒,不想为签发pki证书输那一大坨openssl命令,所以VPN网关之间互联使用的认证方式是预共享密钥。

按照测试用例设定的场景,网关服务器moon和sun背后的电脑需要能互相访问对方网关背后的电脑。把umlswitch0想象成互联网,moon的IP是192.168.0.1,sun的IP是192.168.0.2。成功建立点对点连接后,alice ping bob的IP地址,应该能收到bob的回复;bob ping alice也是一样。这种情况就表示测试通过。

按照测试用例的指示,网关moon和sun的swanctl.conf文件都需要加上这样的一条VPN连接配置。以网关moon为例:

    gw-gw {
        local_addrs  = 192.168.0.1 # moon 的IP,也可以填域名。网关sun的配置和remote_addrs对调
        remote_addrs = 192.168.0.2 # sun 的IP,网关sun的配置和local_addrs对调

        local {
            auth = psk
            id = moon.strongswan.org # 用于验证的预共享密钥ID,实际上可以和remote的那个ID共用一个
        }
        remote {
            auth = psk
            id = sun.strongswan.org # 用于验证的预共享密钥ID
        }
        children {
            net-net {
                local_ts  = 10.0.0.0/24 # moon给VPN客户端的虚拟IP地址,sun的配置和remote_ts对调
                remote_ts = 10.0.1.0/24 # sun给VPN客户端的虚拟IP地址,sun的配置和local_ts对调

                rekey_time = 5400
                rekey_bytes = 500000000
                rekey_packets = 1000000
                esp_proposals = aes128gcm128-x25519
            }
        }
        version = 2
        mobike = no
        reauth_time = 10800
        proposals = aes128-sha256-x25519
    }

# secrets区域要设置两个网关之间验证用的预共享密钥,moon和sun的密钥必须一样才能验证成功
secrets {
    ike_gw {
        id-3a = moon.strongswan.org
        id-3b = sun.strongswan.org
        secret = fSbIdAQ4sEe11D20D/vUR1xvtdYg3JucgwIdiqq7gtC1tNXeXPcJrJhUFJJh
    }
}

我把两个小鸡的VPN连接都仿照测试用例加好了配置,因为之前配置都是ctrl+cv微调的,给客户端的虚拟IP网段都一样,为了避免虚拟IP网段冲突还把其中一个服务器的虚拟IP段改了一下。心想把strongSwan重启一下应该就万事大吉了:我分别用电脑和手机连接moon和sun,之后在手机上ping电脑的虚拟IP,能收到电脑的回应,这样中转节点的事情应该就完事了。然而事实证明我实在是太幼稚了。

先讨论两个VPN服务器后面的计算机互相ping通的问题。ping自然是不可能ping通的,不然我也不会说我太幼稚了。既然自己亲自测试不通过,那就试着找一下自己的环境和测试用例比有哪些地方不一样。找到的第一个不一样的地方,是我的服务器里输入ip route list table 220命令查看IPsec路由表没有任何输出,但是测试用例是有路由表的。没有路由那就尝试加一个!我在服务器moon的终端输入了下面的命令尝试加一条路由直接指向sun,但是服务器不听我的,只会回过来Nexthop has invalid gateway的错误:

ip route add 10.0.1.0/24 via <服务器sun的IP> dev eth0 proto static table 220

看来IPsec的路由表不能这么玩,那么这个网关对网关的连接有没有随着重启strongSwan建立起来呢?输入swanctl --list-sas,一条有关gw-gw的输出都没有,IPsec隧道都没打通那ping通当然是天方夜谭。再看一下测试用例是怎么建立隧道的,看了网页给的console.log才发现,这个隧道需要手动输入命令才能开启:swanctl --initiate --child net-net,在服务器moon上输入这条命令,输出显示这个VPN连接已经建立成功了。

网关对网关的连接打通了,处在两个VPN网关后面两个计算机之间就能ping通了吗?拿衣服!还是不行。不行那就继续玩找不同的游戏,很快又能找到一个不一样的地方:我的VPN配置里网关服务器本身没有虚拟IP地址,服务器moon把10.1.0.1分配给了第一个连过来的客户端,而不是留给自己。为了给VPN服务器也配一个虚拟IP地址,我又股沟了strongSwan的文档,被强行科普了基于策略的IPsec VPN和基于路由的VPN有啥区别。简单来说,VPN配置里想给VPN网关也留一个虚拟IP地址,就需要把VPN配置成基于路由的,而我之前配置的VPN都是基于策略的。路由模式下,面向客户端的VPN连接会绑定在一个虚拟网卡上,方便网管手工写路由表更细致地处理分流规则。相反基于策略的VPN只会看设置的网段,把本地和远端网段都符合规则的IP包强行分流到通往IPsec隧道的路上。文档还说了为配置基于路由的VPN,Linux内核支持VTI接口,然后XFRM接口是替代VTI的墙裂推荐XFRM等等。我把这个路由模式当成了救命稻草,决定先配置起来XFRM接口的虚拟网卡试一下。这个建立和关闭虚拟网卡的操作需要在strongSwan启动和终止的时候进行,所以我照着测试用例route-based/rw-shared-xfrmi给moon和sun写了下面的脚本:

strongswan-up.sh:

#!/bin/sh

# moon的配置
ip link add ipsec0 type xfrm dev eth0 if_id 42
ip link set ipsec0 up 
ip addr add 10.0.0.1/24 dev ipsec0
firewall-cmd --zone=public --change-interface=ipsec0

strongswan-down.sh:

#!/bin/sh

# moon
firewall-cmd --zone=public --remove-interface=ipsec0
ip link del ipsec0

strongswan.conf:

charon {
    # ...

    start-scripts {
        start_1 = /etc/strongswan-up.sh
    }  

    stop-scripts {
        stop_1 = /etc/strongswan-down.sh 
    } 
}

同时swanctl.conf也要做一点修改。面向客户端的连接要把if_id_outif_id_in的值都设置成strongswan-up.sh里指定的42。给客户端分配的虚拟IP地址池需要同步改一下,把10.0.0.1那个地址排除掉,因为这个IP给了服务器自己,具体怎么改测试用例里都有。另外firewalld也要打开同zone(public)转发的选项(–add-forward)这里就不赘述了。后面甚至可以看到配置路由模式的VPN其实是走了个大弯路。

路由模式也配好了,我又试了一次我的手机和电脑之间能不能ping通,还是不行。网络配置已经尽可能做到和测试用例一致,再怀疑只能怀疑到防火墙firewalld头上。果然,把moon和sun的firewalld都关掉,连接两个服务器的计算机互相之间ping对方地址段的虚拟IP就能ping通了。更诡异的是,把其中一方的防火墙关上另一方仍然打开,关掉防火墙的一方是能ping通打开防火墙的一方的计算机的,这证明防火墙的拦截操作其实是发生在出站的一侧。然而我把firewalld的LogDenied选项打开,在系统日志里也找不到有关ping数据包被拦截的消息。

因为每天都要上班被掏空,这个问题困扰了我好几天。直到我在strongSwan文档里看到了这样一句话:

Local firewall stacks generally don’t treat packets with a matching IPsec policy any different from unprotected packets. That means NAT rules also apply to traffic that is supposed to be tunneled.

This often leads to problems, because many hosts have SNAT or MASQUERADE rules set up which change the source IP of the packets, making them not match the negotiated IPsec policies when IPsec processing of outgoing packets happens in the Netfilter packet flow xfrm lookup node. To fix this problem, packets with a matching IPsec policy should skip NAT rules in the POSTROUTING chain of the nat table. This is achieved by inserting a rule that accepts packets with a matching IPsec policy before any NAT rule in the POSTROUTING chain.

iptables -t nat -I POSTROUTING -m policy –pol ipsec –dir out -j ACCEPT

大意是:防火墙的NAT规则会在IP包进入IPsec隧道前生效。所以连接moon的计算机对sun的计算机发起ping请求时,ICMP包的包头的源IP10.0.0.0/24实际上会被防火墙给修改成moon的公网IP。这个IP包因此会由于不符合IPsec策略被流放到互联网,很显然在互联网上ping一个保留给私网的IPv4地址是不可能ping通的。这段话还给了解决办法,就是加上那条iptables规则。但是我试验了下,直接加iptables规则没法绕过firewalld的NAT规则(–query-masquerade输出yes),哪怕安装了iptables-backend-nft这个包。所以NAT的事情完全不应该由firewalld插手,而是得靠手输iptables给指定网段设置NAT规则。具体是在前面的两个服务器的strongswan-up.sh里加入下面的iptables命令:

# moon
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -m policy --dir out --pol ipsec -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE

两条命令的顺序不能调换,否则要进入moon和sun的IPsec隧道的IP包还是会被NAT规则修改。对应地,strongswan-down.sh里把这两条规则删掉:

# moon
iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o eth0 -m policy --dir out --pol ipsec -j ACCEPT
iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE

最后别忘了关掉firewalld的NAT:

firewall-cmd --permanent --remove-masquerade
firewall-cmd --reload

把NAT搞定,连接两个小鸡的电脑终于能用虚拟IP互相ping通对方VPN网关的背后的电脑。但是这时候电脑连上小鸡sun打开ipip.net,看到的本机IP竟然还是sun的IP。原来两个内网之间能ping通仅仅是我要达成的“中转”节点的目标的第一步,这个测试用例并不能完全满足我的需求。

把连接服务器sun的计算机的流量转到moon上

现在两台VPN服务器moon和sun之间IPsec隧道已经成功打通了,两边连接上VPN的计算机都能通过隧道互相用虚拟IP与对面网关和网关后面的计算机进行通信。按理说,要实现把其中一个VPN的网关对外界通信的流量都转发到另一个上,稍微改一下网关与网关之间的IPsec过滤策略应该就够了。

此时两台小鸡moon和sun都设置好了基于路由的IPsec VPN,给VPN客户端分配的虚拟IP网段分别是10.0.0.2-10.0.0.25410.0.1.2-10.0.1.254,moon的虚拟IP是10.0.0.1,sun的是10.0.1.1

起先我的做法是把两个服务器的local_ts和remote_ts调整一下。用于中转的服务器sun,local_ts改成10.0.1.0/24,remote_ts改成0.0.0.0/0;接收方moon这两个值是反过来的0.0.0.0/010.0.1.0/24。修改的意图是让连接sun的客户端所有对互联网和VPN内网的连接都要路由到moon上去。改好了重启strongSwan然后initiate一下连接net-net,在服务器sun上traceroute google.com发现第一跳走的就是服务器moon的IP,我挺高兴的,以为成功了。但是这时候再测试连接sun的计算机,连接上去的客户端反而都上不了网了。

只会散弹枪编程的我,赌气地把两边的服务器的local_ts和remote_ts都改成0.0.0.0/0,再用命令把隧道打通,好家伙,两台服务器的SSH连接全都断了。复习下ts的全名,traffic selector,两边都设成0.0.0.0/0那服务器就只能接受来自IPsec隧道的连接,SSH连接不断才怪呢。所以之前设置的IP过滤策略(local_ts和remote_ts的值)大概是没什么问题的。

回到连不上网的问题,把配置改回到全设置成0.0.0.0/0之前的配置。此时的情形是这样的:
– 连接sun的计算机ping不通moon的虚拟IP10.0.0.1,连接moon的计算机也不行,甚至连服务器sun的虚拟IP10.0.1.1都ping不通。
– 在服务器sun上ping moon的虚拟IP10.0.0.1和连接moon的计算机都是通的。moon ping sun的虚拟IP10.0.1.1也能ping通。

第二条可以证明IPsec隧道本身是没有问题的。至于NAT的影响,遵照前面strongSwan文档的指导,iptables已经被配置成了对要进入IPsec隧道的IP包不做NAT,所以NAT也不是这里的影响因素。而且仔细想一下,在服务器sun上做NAT其实没有任何意义。最初的意图是连接sun的计算机的互联网流量,都通过sun与moon之间的IPsec隧道无脑转发到moon上。假如在sun上就把NAT做了,对应的IP包头里的源IP变成sun的公网IP,这样NAT后的IP包传到moon那里moon也不知道怎么处理。也就是说,按照原本意图配置好的VPN网络,整个网络应该只有moon一个网关处理客户端与互联网的连接,sun起到的作用仅仅相当于连在网关moon上的一台交换机,只不过moon和sun之间连接的介质不是点对点的光纤而是IPsec隧道。综上不管怎么样,都应该把服务器sun的NAT给关掉。

可是把NAT去掉以后,情况还是和之前一样。现在要么是基于路由的IPsec VPN没配好,要么是基于策略的网关对网关的IPsec隧道和基于路由的IPsec VPN冲突。只有知道了ICMP包跑到哪里去了,才能知道为什么连接sun的电脑上不了网还ping不通VPN内网中的电脑。我看了strongSwan的测试用例才知道linux上抓包是用tcpdump。参照man和google把测试用例里的tcpdump命令稍微改了下,分别在moon和sun上运行。这样就能通过IP包的TTL值判断ICMP包是如何在这两个服务器之间流转的:

tcpdump -l --immediate-mode -vv -i eth0 icmp

找台电脑连上sun的VPN,然后在连上VPN的电脑上ping服务器sun的虚拟IP10.0.1.1诊断下第一个问题。两个服务器会看到下面的输出:

# sun 
12:47:23.658780 IP (tos 0x0, ttl 63, id 39615, offset 0, flags [none], proto ICMP (1), length 60)
    10.0.1.1 > 10.0.1.2: ICMP echo reply, id 1, seq 7, length 40
# moon 
12:47:23.652539 IP (tos 0x0, ttl 64, id 39615, offset 0, flags [none], proto ICMP (1), length 60)
    10.0.1.1 > 10.0.1.2: ICMP echo reply, id 1, seq 7, length 40

试着分析一下。首先服务器sun一定收到了客户端发过来的ping,不然也不会生成回复的ICMP包。然后moon和sun都看到了这个响应的包,并且moon抓到的ICMP包头的TTL是64,sun是63。如果moon和sun之间没有为了中转搞的这个IPsec隧道,sun回复给连接VPN的电脑的ICMP包应该通过虚拟网卡ipsec0发回去。但是这个ICMP包被IPsec隧道配置的过滤策略截胡了,阴差阳错地转到了moon手里,moon收到这个ICMP包,发现这个包满足moon的IPsec路由规则(ip route list table 220命令查看得到输出10.0.1.0/24 via <gw> dev eth0 proto static),又把这个包发回给sun。sun通过网卡eth0收到自己发出去的一模一样的ICMP包,sun不知道怎么处理,只能把这个包丢掉。

这个回程的ICMP包经历的事情,充分证明了基于策略的IPsec隧道会影响到服务器所有的网络流量,包括为VPN设置的虚拟网卡。因此这里的问题是基于策略的IPsec隧道与基于路由的对客户端的VPN连接发生冲突了,不把moon与sun之间的IPsec连接也配置个虚拟网卡估计这个问题是解决不了的。为了爬个墙折腾虚拟网卡配路由表实在是太蛋疼了,每次改之前都必须把strongSwan关掉再修改,不然状态就错了。干脆把VPN改回基于策略的模式,大家都是基于策略的,应该就不会出现刚才说的半路截胡导致冲突的情况。并且服务器sun的作用相当于交换机,在交换机上搞基于路由的VPN就是不对劲的一件事情。

之后我把sun的VPN改回基于策略的模式,这时服务器sun在VPN网络中是没有自己的IP的,客户端的IP地址可以从10.0.1.1开始。然后按照之前对ts的理解,sun给VPN客户端提供10.0.1.0/24的虚拟IP;sun与moon之间的隧道,要把连接moon所有网段在10.0.1.0/24的客户端对外的通信都通过隧道无脑转发给moon。这不就是最初想要达到的目的么,我为啥要想不开去搞基于路由的VPN给自己找麻烦。此时服务器moon的配置还是基于路由的,所以电脑连上sun可以测试一下能不能ping通moon的虚拟IP10.0.0.1。试验是成功的。

现在这种情况下,连接moon和sun的客户端互相之间都能ping通了,但是连接sun的电脑还是上不了网。这个问题出在NAT上,moon的虚拟IP网段是10.0.0.0/24,sun的网段是10.0.1.0/24,因为现在流量都转到moon上了,moon还要把源IP在10.0.1.0/24的IP包给NAT掉,所以需要把moon启用NAT的网段10.0.0.0/24扩大一点。具体操作就是moon的strongswan-up.sh设置NAT的地方要改一下:

iptables -t nat -A POSTROUTING -s 10.0.0.0/16 -o eth0 -m policy --dir out --pol ipsec -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.0.0.0/16 -o eth0 -j MASQUERADE

其实把24改成23就可以,这里只涉及到两个相邻网段的互联。不过为了配置更多的中转节点,改成16也不是不行,这样就能支持下挂254个中转服务器,虽然很明显这个很不现实。

至此服务器sun就变成了一个称职的VPN中转节点。电脑连接sun提供的VPN,浏览ipip.net看到自己的IP只会是moon的,而不是sun的。还有一个小问题需要解决,是个人肯定都会希望服务器重启时这个中转隧道能自动搭起来,两边的服务器任何一个重启,都要在sun的终端手动输入swanctl --initiate --child net-net整个这套配置才能正常跑起来,很显然没有人能忍受的了这种处理方式。解决办法也很简单,在sun的net-net连接下面手动加一行start_action = trap就可以。按照官方的文档,加上这行配置,只要连接sun的客户端对外发起连接(回忆下隧道的traffic selector是怎么配置的)strongSwan就会自动尝试建立moon与sun之间的连接,不需要人工干预。

虽然达成了开头所说的中转服务器的目的,但是这个配置还是有一些缺憾:其一是服务器sun10.0.1.0/24网段的IP互相之间的通信都要走一遍服务器moon,延迟会相对大一些,但是我的目的是爬墙,不考虑内部通信的需求,这个无伤大雅;其二是VPN配置没有处理IPv6的情况,浏览被墙网站DNS解析到了IPv6的IP仍然会收到来自功夫网的警告,但是我折腾IPv4已经很累了暂时不想讨论v6的情况。另外就是moon上配置的基于路由的VPN在中转节点这件事上也没有起到任何用处,不喜欢也可以把moon的基于路由的配置也改回到基于策略的配置。

在国内配置“中转”VPN服务器实现里通外国

我把我一整套的配置搬到了之前买的一台阿里云服务器上,妄想透过阿里云“优质”的线路帮我里通外国。事实证明我还是太幼稚。国内服务器用前面重复说好多次的命令去手动连接国外的服务器,第一步协商都过不去。国外的服务器开了swanctl --log监听,没有任何有关国内服务器发起协商的消息,国内服务器的输出则是直接卡住。所以我还是拿衣服了,ISAKMP这么钩直饵咸专门用来建立安全隧道的协议,阿里云的防火墙不给挡住,我当阿里巴巴那么多员工都是白痴吗?腾讯云干脆也懒得试了,得到的结果想想都知道会是一样的,即使一时半会真打通了,之后来自腾讯云义正词严的问候也是板上钉钉的事。因此,真要在国内机房的服务器里做这种违反《中华人民共和国计算机信息网络国际联网管理暂行规定》的勾当,必须得找一个管的没那么严的机房,这样的机房哪里有,我是真的不知道。

这个中转节点,我最后是在我自己家的路由器上搭起来的。家里的路由器是ARM开发板刷上了OpenWRT。OpenWRT的strongSwan软件包配置有一大堆的问题,最后搭建成功了,每次路由器重启还必须得手输一遍swanctl --load-all重灌一遍VPN连接配置,客户端才能正常连接。不过好歹比输入无数次swanctl --initiate重新挖点对点隧道强一百倍,有时间还得看一下为什么不能自动加载VPN配置。还有IPv6的坑也得填一下。

结论

说实在的,以爬墙为目的,给IPsec VPN做中转节点这种事情真的是一种愚蠢的行为。IPsec这种特征这么明显又被客户端限制而无法随意修改的协议,随时会被功夫网连根拔起。甚至阿里云的例子摆在那,骨干路由器设置一个防火墙规则就能轻松拿捏。因此在这里奉劝大家不要学我当井底之蛙,跳不出IP安全性协议这个巨坑,早日弃暗投明才是正道。另外搭这个东西的意外收获,是我被迫学会了journalctl、tcpdump、ip route这些东西到底怎么用,以及强行恶补了读大学的时候学的计算机网络课程,这些我都还给老师了,我真是个学渣。

这个东西我能想到的用途,是我的不正经的游戏加速器;以及公司使用政企宽带,无法直连国外代理服务器这种情况下进行中转;如果基于路由的VPN搞得好的话,这个中转服务器的配置可能还可以把国内服务器伪装成IPLC机场骗钱。也许还有别的实用场景我还不知道。

最后的最后,我把测试通过的配置文件放在这里。写了这么多的废话,各位肯定都看吐了,这里的配置可以直接拿过去复制粘贴。两个服务器都没有用到基于路由模式的配置。客户端连接时,服务器用证书表明身份(国内服务器用的自签名证书,国外用的Letsencrypt),客户端验证用EAP-MSCHAPv2输入用户名和密码;两个服务器互联用预共享密钥互相验证身份。

moon

swanctl.conf

include conf.d/*.conf

connections {   
    strongswan-win {
        version = 2
        rekey_time = 0

        local_addrs = 0.0.0.0
        send_certreq = no
        unique = never
        pools = ip_pool
        dpd_delay = 60s
        encap = yes

        proposals = 3des-aes128-aes192-aes256-sha1-sha256-sha384-modp1024,default

        local {
            auth = pubkey
            certs = fullchain.pem
        }

        remote {
            auth = eap-mschapv2
            eap_id = %any
        }

        children {
            net1 {
                esp_proposals = aes256-aes128-3des-des-null-sha1
                local_ts = 0.0.0.0/0, ::/0
            }
        }
    }

    mainland-relay {
        local_addrs = <moon>
        remote_addrs = <sun>

        local {
            auth = psk
            id = <moon>
        }
        remote {
            auth = psk
            id = <sun>
        }
        children {
            relay {
                local_ts = 0.0.0.0/0
                remote_ts = 10.0.1.0/24
                esp_proposals = aes128gcm128-x25519
            }
        }

        version = 2
        mobike = no
        reauth_time = 10800
        proposals = aes128-sha256-x25519
    }
}

pools {
    ip_pool {
        addrs = 10.0.0.0/24
        dns = 208.67.222.222, 208.67.220.220    
    }
}

secrets {
    private_1 {
        file = "privkey.pem"
        secret = ""
    }

    eap_1 {
        id_1 = youraccount
        secret = yourpassword
    }

    ike_gw {
        id_1 = <moon>
        id_2 = <sun>
        secret = "ZaPigkBvgSmLnjRIkplz8E1GKntzKoXnhbzs2uVxmPrvXaDQJrU9gTyJHK/9pp5abmJ0Ke6kLQLms+asyC+eZw=="
    }
}

/etc/strongswan-up.sh

#!/bin/sh

iptables -t nat -A POSTROUTING -s 10.0.0.0/16 -o eth0 -m policy --dir out --pol ipsec -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.0.0.0/16 -o eth0 -j MASQUERADE

/etc/strongswan-down.sh

#!/bin/sh

iptables -t nat -D POSTROUTING -s 10.0.0.0/16 -o eth0 -m policy --dir out --pol ipsec -j ACCEPT
iptables -t nat -D POSTROUTING -s 10.0.0.0/16 -o eth0 -j MASQUERADE

strongswan.conf

# strongswan.conf - strongSwan configuration file
#
# Refer to the strongswan.conf(5) manpage for details
#
# Configuration changes should be made in the included files

charon {
    load_modular = yes
    plugins {
        include strongswan.d/charon/*.conf
        duplicheck {
            enable = no
        }
    }

    start-scripts {
        start_1 = /etc/strongswan-up.sh
    }

    stop-scripts {
        stop_1 = /etc/strongswan-down.sh 
    }
}

include strongswan.d/*.conf

sun

swanctl.conf

# Include config snippets
include conf.d/*.conf

connections {   
    windows-relay {
        version = 2
        rekey_time = 0

        local_addrs = 0.0.0.0
        send_certreq = no
        send_cert = always
        pools = ip_pool
        dpd_delay = 60s
        encap = yes

        proposals = 3des-aes128-aes192-aes256-sha1-sha256-sha384-modp1024,default

        local {
            auth = pubkey
            certs = cert_sun.pem
        }

        remote {
            auth = eap-mschapv2
            eap_id = %any
        }

        children {
            client-conn {
                esp_proposals = aes256-aes128-3des-des-null-sha1
                local_ts = 0.0.0.0/0, ::/0
            }
        }
    }

    relay {
        local_addrs = <sun>
        remote_addrs = <moon>


        local {
            auth = psk
            id = <sun>
        }
        remote {
            auth = psk
            id = <moon>
        }
        children {
            net-net {
                local_ts  = 10.0.1.0/24
                remote_ts = 0.0.0.0/0
                start_action = trap
                esp_proposals = aes128gcm128-x25519
            }
        }

        version = 2
        encap = no
        mobike = no
        reauth_time = 10800
        proposals = aes128-sha256-x25519
    }
}

pools {
    ip_pool {
        addrs = 10.0.1.0/24
        dns = 208.67.222.222, 208.67.220.220    
    }
}

secrets {
    private_1 {
        file = "key_sun.pem"
        secret = ""
    }

    eap_1 {
        id_1 = youraccount
        secret = yourpassword
    }

    ike_gw {
        id_1 = <sun> 
        id_2 = <moon>
        secret = "ZaPigkBvgSmLnjRIkplz8E1GKntzKoXnhbzs2uVxmPrvXaDQJrU9gTyJHK/9pp5abmJ0Ke6kLQLms+asyC+eZw=="
    }
}

/etc/strongswan-up.sh

/etc/strongswan-down.sh

strongswan.conf

# strongswan.conf - strongSwan configuration file
#
# Refer to the strongswan.conf(5) manpage for details
#
# Configuration changes should be made in the included files

charon {
    load_modular = yes
    plugins {
        include strongswan.d/charon/*.conf
        duplicheck {
            enable = no
        }
    }
}

include strongswan.d/*.conf

Dev-C++ 一日游

敝司搞了个IT技能竞赛,说白了就是搞编程竞赛,就是做算法题那一套。但是题目限定了语言只能用Java和C,不能用C++。然而我没写过Java更别提参加过Java项目,只能写C语言,所以一切都得从头来,这就吃了大亏。比赛居然还要专门的电脑上提交代码,上面没有C++的开发环境。眼看比赛要开始了,20个G的visual studio铁定是复制不过去的,安装也是很费时间的。所以需要一个迷你的开发环境。我突然想起了Dev-C++,大学的时候用过这个东西,当时体验是起码看起来比VC6要好一点。

google了下才知道,2021年Dev-C++居然还在更新。现在是Embarcadero,那个Delphi现任的维护者在维护这个项目。代码提交还很活跃,GitHub上看到最新的提交日竟然是六天前。看到更新日志里说,现在最新的版本还加上了对C++17/20的语法支持,好像还可以的样子。所以2021年用Dev-C++写代码是什么样的一种体验呢?怀着好奇的心理我把Embarcadero维护的Dev-C++下载了下来。毕竟标榜自己是C++的IDE,代码提示和调试这些可能不会差到哪里去。没想到等待我的是噩梦般的体会。

绑定mingw和gdb的 Dev-C++ 很快下载好了。首先是启动界面, Embarcadero为这个古老的IDE加上了时髦的自定义界面主题,还贴心地问你要不要关联h/cpp文件(算了)。再进到启动画面,这界面和喷气大脑和巨硬的竞品比好像也没差到哪里去。可惜,当我把代码复制过来的一瞬间,我被雷到了。代码里的中文字符怎么都消失了?按了下Ctrl+A全选,咦,原来中文还是在的,很明显这是个挺严重的BUG:中文字符不能正常显示。忍了吧,毕竟C语言用中文标识符是作死的一种行为,不影响写代码那就忽略掉。

然后是搬代码的体验,Dev-C++的自动完成还是功能还是像旧版本一样蛋疼。输入括号引号尖括号之类字符的时候,它还是会自作主张地给你多补一个对应的括号。举个例子,输入”{}”在新版本的Dev-C++里面还是会打成”{}}”。在以前这种类型的自动完成可能还不错,适应下就可以了。但是这已经是2021年了,自动完成做成这个样子还是不要标榜自己是IDE了。

代码写完了来看看调试,这个是我感到最蛋疼的地方。首先是看变量的值,不像visual studio和别的商业IDE,Dev-C++要看某个变量的值必须在界面里手动添加才可以。它没法自动把函数里的局部变量的值都给你展示出来。然后是显示指针指向的东西,这个就更蛋疼了。调试界面只给你显示指针的值,不告诉你指针指向的东西到底是什么样的。虽然加个*p的监视也可以,但是这样仍然很不方便。尤其是你需要调试链表和树这种用指针构建起来的数据结构的时候。写的编程题要用到树,调试的时候把我给搞疯了。更蛋疼的是,Dev-C++在调试的时候总会崩溃,甚至还没法把gdb.exe结束掉,任务管理器都没用。

所以到2021年了,被Embarcadero接手继续维护的Dev-C++可以用吗?我的看法是,算了吧。确实得承认,无数OIer对它有深厚的感情。我也祝愿Delphi的维护者能成功让Dev-C++这个老项目焕发第二春。然而2021年如果有人还在推荐它作为初学者入手C/C++的软件,这就是在谋财害命了。我觉得残废的自动完成和不友好的调试界面对初学者的打击是很大的,只会疯狂劝退。不在乎大小和专有软件,Visual Studio一直有Express版,甚至本家Embarcadero自己的C++ Builder也出了免费版,嫌卸载麻烦不便携,然而自己的电脑根本没有这个问题。至于打开cpp文件就可以编译出程序更方便,嗯,vs新建个项目真的那么麻烦吗?最最重要的是,已经没有人用Dev-C++写项目了,举个例子,CMake根本没有给你生成Dev-C++工程文件的选项,然而Code::Blocks却是有的。 那些根本不能用IDE的地方,例如内核开发,更没有Dev-C++什么事情。它真的老了,千万别再说它适合初学者这种话了。

Dev-C++被你说的这么垃圾,对你说的你的比赛有什么影响吗?我当时卡在了怎么从头实现一个环形缓冲区上,数据结构真的忘记了,题还是得刷。然后调试太蛋疼,看不到数据结构的样子,对我也是帮了很大的倒忙。不管怎么说还是感谢下吧,不然连工具链都没的用也就别干活了。

ps: 我知道Dev-C++一些国人的Fork对这些不足是做了改进的,例如小熊猫版解决了局部变量的问题。然而实际项目没有人用才是最大的问题。

在 IPv4/IPv6 双栈网络环境下配置基于 strongSwan 的 IPsec VPN

Update 2021-11-06: 1.strongswan.org 被墙了,功夫网威武。2.更正了同一用户使用不同终端登录时会挤掉已有的VPN连接的问题。

要爬墙为啥想不开要用VPN?代理服务器他不香吗?代理比VPN不知道高到哪里去了。用代理的话国内国外能分流,配置一般来说只要改一个配置文件就可以即开即用。用VPN的话,首先国内外分流就特别麻烦,不省VPS流量,访问国内网站或者想打个国服还得忍受延迟爆表;之后,VPN报文的特征那么明显;功夫网想墙掉是分分钟的事情;最后,VPN的配置起来比代理难多了,要配置个能用的VPN你要考虑好IP地址,还要改内核设置使得客户端连上VPN后正常爬墙。只为了爬墙用代理就能轻松爬,你为什么想不开要用VPN?

背景

在我这里ss和v2ray真的不香,所以我被迫捡起来了VPN的传统艺能。配好了IPsec VPN发现IPsec意外地香。同一台服务器上代理全部趴窝的时候,连上IPsec VPN看油管却能看4K@60fps的视频毫无中断问题。

虽然IPsec VPN确实爽了,但是还是有个地方很不如意:照以往经验配置出来的IPsec VPN只支持IPv4网络,不支持IPv6。在工信部大力推动IPv6部署的情况下,目前的VPN配置已经跟不上了时代的潮流,VPN只支持IPv4,IPv6的流量就仍然是直连的,没法走VPN翻过高墙。为啥要大力推进IPv6部署啊?不就是因为v6的墙建好了。 有双栈网络接入且只有IPv4的流量能走VPN的情况下,要访问一个被墙的网站,如果被墙的域名有AAAA的记录且客户端这里得到了正确的IPv6结果,又由于IPv6是直连的,收到来自功夫网的RST包警告便是必然的事情。

要解决这个问题有两种思路,第一种思路是关掉操作系统的IPv6支持。 不更改家庭网关设置的情况下,电脑上操作起来还是很容易的,windows和linux都能方便地关掉IPv6,只有v4地址也就不存在v6流量走不了VPN的问题了。但是手机上想全关掉IPv6的支持不容易,以Android为例,Android确实可以关掉4G网络的IPv6支持,选项藏的很深而已(Settings -> Connections -> Mobile networks -> Access Point Names -> 选择接入点 -> 把 APN Protocol 和 APN roaming protocol 都设成IPv4),但是碰到支持IPv6的wifi接入点怎么办呢?对不起,Android不支持。当wifi接入点的设置不是自己能控制的时候这就很难受。第二种思路,是在连上VPN的时候让VPN服务器给客户端分配一个能联网的IPv6地址。 这样IPv6的流量就也可以走VPN了,这也是本文要讨论的问题。

怎样用strongSwan搭建IPsec VPN的教程网上有很多。之前我是照 openSUSE wiki 上 MargueriteSu 大佬写的教程学会怎样配置的。推荐看一下,能学到许多东西。然而教程的完成日期已经是7年前了,一些情况和现在不一样。还有其它的教程,它们都告诉你需要去修改ipsec.confipsec.secrets这两个配置文件来配置VPN,且对IPv6的情况只字未提。在防火墙和配置证书的部分和现在的情况也不一样,例如MargueriteSu的教程里还在用SuSEFirewall2,可是openSUSE已经完全弃用了SuSEFirewall投奔firewalld;有的发行版,例如Ubuntu,预置的strongSwan包也是不带生成证书的功能的。找现成的教程无果,去翻了strongSwan项目的主页,我惊奇地发现strongSwan的示例里已经没有这两个文件了,它已经过时了,取而代之的是单个文件swanctl.conf。为了跟上潮流,遇到问题的时候能在官方处得到启发,当然也要用新的配置方式。怎样用新的配置文件配置VPN也是本文要讨论的话题。

目标

这里要达到的目标是:在VPS上配置一个通过IPv4和IPv6地址都能访问的IPsec VPN。通过这两种方式连接到VPN后,客户端都能同时获取到能联网的IPv4和IPv6地址。VPN服务端通过合法的证书来表明服务器的正确身份,客户端的认证协议采用EAP-MSCHAPv2。Windows自带的VPN客户端和Android的strongSwan APP都能连接上VPN服务器且能正常上网。

VPN服务器能给客户端分配能用的IPv6地址,IPv6流量不能走VPN的问题也就迎刃而解了。

环境

我的VPS用的linux发行版是openSUSE Tumbleweed。写文章的时候发行版预置的strongSwan版本是5.8.4。

既然我们要让VPN客户端获得IPv6地址,那么VPS有IPv6地址便是实践这篇文章所述事情的一个必要条件。VPS没IPv6地址,不能访问v6网络,那还怎么给客户端v6地址呢?另外,你的VPS获得的IPv6地址最好是/64的,并且支持SLAAC,也就是说后半部分64位的分配权在你手里,你的VPS可以在后面的64位后缀里任意选一个。这样我们给VPN客户端分配IPv6地址会更方便一些。如果不明白,可以向你的VPS运营商咨询,这些都是服务商需要给你解决的问题。

假如VPS服务商给你的IPv6地址是/128的,也就是你只拥有一个IPv6地址的分配权,那就不好玩了,必须得用NAT64这种没有办法的办法。这不在本文的讨论范围之内。

配置VPN连接

配置IPv4环境下的VPN服务器

在IPv4环境下配置VPN没什么好说的,网上的教程完全可以用来参考。这里也几乎就是把其它教程的意思重复一遍。最大的问题是如何把ipsec.confipsec.secrets转成swanctl.conf里面的语法格式。

安装软件

用zypper命令安装strongSwan:

zypper in strongswan

因为用的是suse,所以想要gui的话可以去YaST控制台的Software Management界面搜,具体过程不赘述。

配置服务端证书

之前说了,VPN服务端这边需要一个有效的证书来表明服务端的身份。

你有自己的域名的话,你可以把你手上的域名解析到你的vps上,然后向letsencrypt申请SSL证书。和架网站不一样,这里解析域名最好是IPv4和IPv6分开解析。例如把v4地址解析到gateway.your.domain,v6地址解析到gateway-v6.your.domain。理由不仅有方便区分,还有强国的IPv6路由配置比较奇葩,使用IPv6地址连接时很容易碰到延迟爆炸连线质量极差的情况。申请LetsEncrypt证书的环节我这里直接用了EFF提供的certbot脚本。

安装certbot:

zypper in python3-certbot

打开防火墙:

firewall-cmd --add-service http
firewall-cmd --add-service https
firewall-cmd --permanent --add-service http
firewall-cmd --permanent --add-service https

用Certbot申请证书:

certbot run --domain gateway.your.domain --domain gateway-v6.your.domain

证书申请成功的时候,脚本会告诉你申请过来的合法证书存放的位置,类似于这样:/etc/letsencrypt/live/gateway.your.domain/fullchain.pem。之后配置VPN连接的时候会用上。

因为letsencrypt证书只有三个月的有效期,所以我们需要有个systemd服务和定时器,用来定时检查letsencrypt证书是否过期。风滚草提供了一个包certbot-systemd-timer帮我们代劳,它会在/usr/lib/systemd/system目录下安装刚才说的服务和定时器,名字叫certbot-renew

zypper in certbot-systemd-timer

假如你用的是别的发行版,你可能需要自己写一个service和timer。service要做的事情很简单,就是调用一下/usr/bin/certbot renew命令,当然视发行版不同文件路径可能有变化。

解决证书问题,还有个途径是自建一套PKI体系。可以用strongSwan自带的pki功能也可以用OpenSSL,这不在本文的讨论范围内。用OpenSSL这里有篇文章可供参考:
https://jamielinux.com/docs/openssl-certificate-authority/index.html

配置VPN服务端

swanctl.conf

因为ipsec.confipsec.secrets被宣告为过时了,所以这里我们在swanctl.conf里面配置VPN连接。strongSwan wiki里有把设置从这两个文件迁移到swanctl.conf的教程。这里就照着wiki页面的内容把其他教程里的配置迁移了一下:

include conf.d/*.conf

connections {
    strongswan-win {
        version = 2
        rekey_time = 0

        local_addrs = 0.0.0.0
        send_certreq = no
        pools = ip_pool
        unique = never
        dpd_delay = 60s
        encap = yes

        proposals = aes256-sha384-modp1024,default

        local {
            auth = pubkey
            certs = fullchain.pem
        }

        remote {
            auth = eap-mschapv2
            eap_id = %any
        }

        children {
            net1 {
                local_ts = 0.0.0.0/0
            }
        }
    }
}

pools {
    ip_pool {
        addrs = 10.0.1.0/24
        dns = 208.67.222.222, 208.67.220.220
    }
}

secrets {
    private_1 {
        file = "privkey.pem"
        secret = ""
    }

    eap_1 {
        id_1 = fuckgfw
        secret = "makevpngreatagain"
    }
}

显然swanctl.conf分为三个部分,connectionspoolssecrets。其中,connections对应的是ipsec.conf里的内容;pools指定了要分配给VPN客户端的虚拟IP地址池的DNS服务器,这里定义了一个名叫ip_pool的IP池;secrets对应的是ipsec.secrets文件的内容,以private做前缀的项定义了VPN服务端要使用的服务端证书私钥,eap前缀的项定义了VPN连接要用到的用户名和密码,这里的例子里,用户名是fuckgfw密码是makevpngreatagain

再简单解释下connections栏里的内容:
version = 2: 使用IKEv2认证方法。
rekey_time = 0: 应对Windows内置的IPsec客户端的BUG,见MargueriteSu写的教程。
local_addrs = 0.0.0.0: VPN服务端要监听服务端所有可用的IP地址。
send_certreq = no: 是否要求客户端发送证书,这里我们要求客户端使用用户名密码验证,所以设为no。
pools = ip_pool: 使用名为ip_pool的地址池用来给VPN客户端分配地址。
unique = never: 永远不把已经登录的用户踢下线,即使新连接的VPN客户端发送了INITIAL_CONTACT报文。这项默认的设定值是”no”,也就是服务端只有收到INITIAL_CONTACT报文的时候才会把已建立连接的用户踢下来。但是不同客户端的行为会有差异,例如windows自带的VPN客户端不会发送这个报文,但是strongSwan for Android会发送,导致的结果就是你的Android手机登录VPN时,你电脑的VPN会断线。因此把这个值设成never。
dpd_delay = 60s: dpd的全称是dead peer detection,顾名思义是用来检测VPN客户端是否还在线。这里设定检测的时间间隔为一分钟。
encap = yes: 强制把ESP包封装至端口号为4500的的udp包中,无视VPN客户端是否在NAT后面,这可以避免IPsec ESP包被一些错误配置的骨干路由器丢掉。
proposals = aes256-sha384-modp1024,default: 指定IPsec连接使用的加密方式。前面的aes256-sha384-modp1024是为windows客户端量身定制的,这是windows支持的几种IPsec加密方式之一;后面的default是为其它类型的客户端(例如linux和Android)准备的。
local: 指定验证服务器身份的方式,这里设定的方式为服务器出示有效的证书,证书文件名叫fullchain.pem
remote: 指定客户端身份验证的方式,这里使用了eap-mschapv2。eap_id = %any表示secrets栏规定的任何EAP身份都有效.
children: 配置IPsec SA的行为,我的理解是这里控制了客户端连接上VPN服务器后的一些行为,例如能访问的网段。里面的local_ts就是控制这个的。

strongswan.conf

# strongswan.conf - strongSwan configuration file
#
# Refer to the strongswan.conf(5) manpage for details
#
# Configuration changes should be made in the included files

charon {
    load_modular = yes
    plugins {
        include strongswan.d/charon/*.conf
            duplicheck {
                enable = no
            }
    }
}

include strongswan.d/*.conf

这里只改了一个设置:duplicheck.enable = no,使得同一套用户凭证可以多终端在线。

启动strongSwan

把之前申请好的letsencrypt证书在swanctl目录下创建软链接,让strongSwan能找到这些证书:

mkdir /etc/swanctl/x509
mkdir /etc/swanctl/x509ca
mkdir /etc/swanctl/private
ln -s /etc/letsencrypt/live/gateway.your.domain/fullchain.pem /etc/swanctl/x509/fullchain.pem
ln -s /etc/letsencrypt/live/gateway.your.domain/chain.pem /etc/swanctl/x509ca/chain.pem
ln -s /etc/letsencrypt/live/gateway.your.domain/privkey.pem /etc/swanctl/private/privkey.pem

之后启动strongSwan:

systemctl enable strongswan
systemctl start strongswan

启动成功没有报错的情况下,可以输入命令swanctl --list-certs验证下服务器证书是否已经加载成功。
至此,strongSwan这边的配置完毕。

配置防火墙和系统内核设置

要让strongSwan VPN 能正常工作能爬墙,还需要改下防火墙和系统设置。
首先配置防火墙:

firewall-cmd --add-service ipsec
firewall-cmd --permanent --add-service ipsec
firewall-cmd --add-masquerade
firewall-cmd --permanent --add-masquerade

这里的含义是放行IPsec所需的500、4500端口以及ESP包,并且开启IPv4 NAT。

然后改内核设置,打开/etc/sysctl.conf加上以下几行:

net.ipv4.ip_forward = 1
net.ipv6.conf.all.accept_ra = 2

第一行的意思是打开IPv4转发。第二行的意思是在开启转发状态下让linux能接受IPv6的RA(Router advertisement)消息,不加上这个选项你的VPS就不能获得IPv6地址了。原因是打开了转发,Linux会把自己认成路由器,然后自动禁用掉IPv6自动配置,把值设成2就是要让这两者共存。
最后重启下VPS或者输入sysctl -p,你的VPN服务器应该就可以正常工作了。

配置VPN客户端

假如之前配置服务器证书的时候你选择了自建PKI体系,那么如何给客户端系统添加根证书信任不在本文的讨论范围内。

Linux

Linux下的IPsec VPN客户端就是strongSwan本身,strongSwan项目开发了一个networkmanager图形界面的接口,可以通过图形界面方便地连接VPN。

在networkmanager的管理界面里新建一个IPsec/IKEv2(strongswan)类型的连接,按照下图里的方式配置就可以。需要注意的是,在图形界面里配置的时候自己得手动把服务器证书拉下来,然后在连接里指定。

Windows

Windows下用自带的IPsec VPN客户端。

添加VPN连接需要去网络和共享中心,选择添加新连接,弹出的向导里选择“连接到工作区”->“否,创建新连接”->“使用我的网络连接”->地址填上VPS的地址或者你的域名。

之后回到网络和共享中心,点“更改适配器设置”,找到刚才添加的VPN连接,右键属性,点“安全”选项卡,VPN类型选IKEv2,数据加密类型选”最大程度的加密”,认证方式选“EAP-MSCHAPv2”点确定。
连接VPN时,输入用户名密码fuckgfw/makevpngreatagain,连接成功后你就可以上谷歌看油管了。

Windows 10 最新版可能会有点小问题,连接的时候会提示你“IKE身份验证凭据不可接受”。原因是win10智障,不会顺着证书路径判断VPN证书是否有效(也可能仅仅LetsEncrypt会有这个问题,因为LetsEncrypt曾经换过CA)。解决办法是把VPN服务端的证书放在“受信任的根证书办法机构”里,具体位置需要打开certmgr.msc管理证书。吊诡的是,这样操作然后连接VPN成功以后,即使把添加到根证书的VPN证书删掉也能连接成功。

Android

Android本身内置了IPsec VPN的支持,但是我们配的认证类型Android是不支持的。不过strongSwan项目官方做了一个Android客户端。

别想了,国内的应用市场是不可能上架的。你能找到这个APP算我输。 所幸strongSwan官网有apk,地址是 https://download.strongswan.org/Android/ 自然我们要下载最新版。

打开APP,选”添加VPN”配置,服务器地址填上VPS地址或者域名,VPN类型选”IKEv2 EAP(用户名/密码)”。用户名密码填上fuckgfw/makevpngreatagain,点保存。最后连接新添加的VPN。

iOS 和 macOS

抱歉,我太穷了,买不起高贵的苹果设备。所以对应的客户端设置欠奉。

让IPsec VPN支持IPv6

假如你当前的网络环境只能获得IPv4地址,当你做完上面的事情之后,你应该就可以看油管刷推特了。但是你有IPv6的话,你现在还不能连接上正常的国际互联网。原因前面说了,IPv6的流量没法走IPv4的VPN。假如你不想舍弃掉IPv6网络,那么你就得想办法让VPN客户端得到一个能用的IPv6地址。或者假如你想在IPv4的网络环境下获得访问IPv6网络的能力,也可以照下面的内容做。

这里添加IPv6地址的方法,要感谢imbushuo大佬的鼎力支持。大概两年前我问过他怎样让openConnect VPN客户端得到IPv6地址的问题,他帮我解决了然后整理成了文章。相同的办法也可以套用到strongSwan VPN上。

给客户端分配IPv6地址

swanctl.conf里定义一个IPv6地址池,且让之前配置的VPN连接使用这个IPv6地址池。

connections {
    strongswan-win {
        ...
        pools = ip_pool, ip_pool6 # 让strongSwan在ip_pool6地址池里也挑个IPv6的IP分配给客户端
        ...
        children {
            net1 {
                local_ts = 0.0.0.0/0, ::/0 # 加上::/0允许VPN客户端可以访问IPv6网络
        }
    }
}

pools {
    ip_pool {
        addrs = 10.0.1.0/24
        dns = 208.67.222.222, 208.67.220.220
    }
    ip_pool6 {
        # 前64位是VPS服务商给你的IPv6前缀,后面可以随便写,但是如果你有其他VPN服务注意不要相互冲突
        # 这里给分配的IPv6地址池一共有2^24 - 2 = 16777214个IPv6地址,
        # 从200a:abcd:abcd:abcd:1234:1234:3400:1 到 200a:abcd:abcd:abcd:1234:1234:34ff:fffe。
        addrs = 200a:abcd:abcd:abcd:1234:1234:3400::/104 
        dns = 2620:119:35::35, 2620:119:53::53
    }
}

修改内核设置和防火墙

修改完swanctl.conf然后重启strongswan服务,再连VPN,你可以发现你已经有了一个IPv6地址了。但是访问 https://ipv6-test.com/ 测试IPv6连接时,网站还是提示你不能访问IPv6网络。不过这时候在命令行里ping你的VPS的IPv6地址是可以ping通的,traceroute的时候也是只有一条记录(VPN模式下你的VPS就是你的网关,所以应该不会有中间地址)。要让客户端能连上IPv6互联网,还需要一些善后的工作。

sysctl.conf里加上以下设置,打开对IPv6流量的转发和ND Proxy:

net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.all.proxy_ndp = 1

修改防火墙设置,让防火墙放行通过VPS转发的IPv6包:

firewall-cmd --permanent --direct --add-rule ipv6 filter FORWARD 0 -j ACCEPT

为了让连上VPN的客户端对外部可见,需要把连接VPN的客户端宣告为VPS的邻居,这样外部的有IPv6网络的机器才能通过VPS找到VPN客户端获得的IPv6地址(不知道我的理解对不对)。可以用下面的命令来完成:

ip -6 neigh add proxy <VPN客户端获得的IPv6 IP> dev <获得VPS IP的网卡名>

显然,连上VPN后为了获得能用的IPv6地址还要登上VPS输一条命令是一件麻烦的事。所以strongSwan和ocserv一样,也提供了客户端连接/断开连接时自动调用Shell脚本的功能。脚本的输出会一并输出到strongSwan的log里。strongSwan提供了一个脚本示例,用来展示默认情况下VPN服务端的行为和传入到脚本中的环境变量。仿照示例我写了个这样的脚本,当然是照官网给的实例剪掉了不需要的部分做出来的:

# define a minimum PATH environment in case it is not set
PATH="/sbin:/bin:/usr/sbin:/usr/bin:@sbindir@"
export PATH

# comment to disable logging VPN connections to syslog
VPN_LOGGING=1
#
# tag put in front of each log entry:
TAG=vpn
#
# syslog facility and priority used:
FAC_PRIO=local0.notice

case "$PLUTO_VERB:$1" in
up-client:)
    # connection to my client subnet coming up
    # If you are doing a custom version, firewall commands go here.
    echo "a client connected"
    echo "ip neigh add proxy $PLUTO_PEER_SOURCEIP dev ens3"
    ip neigh add proxy $PLUTO_PEER_SOURCEIP4_1 dev ens3
    ;;
down-client:)
    # connection to my client subnet going down
    # If you are doing a custom version, firewall commands go here.
    echo "a client disconnected"
    ip neigh delete proxy $PLUTO_PEER_SOURCEIP4_1 dev ens3
    ;;
up-client-v6:)
    # connection to my client subnet coming up
    # If you are doing a custom version, firewall commands go here.
    echo "a client connected v6"
    echo "ip -6 neigh add proxy $PLUTO_PEER_SOURCEIP6_1 dev ens3"
    ip -6 neigh add proxy $PLUTO_PEER_SOURCEIP6_1 dev ens3
    ;;
down-client-v6:)
    # connection to my client subnet going down
    # If you are doing a custom version, firewall commands go here.
    echo "a client disconnected v6"
    ip -6 neigh delete proxy $PLUTO_PEER_SOURCEIP6_1 dev ens3
    ;;
esac

写好了脚本,还需要在swanctl.conf里声明一下:

不要忘了给脚本加上可执行的权限!

connections {
    strongswan-win {
        ...
        children {
            net1 {
                local_ts = 0.0.0.0/0, ::/0
                updown = /etc/swanctl/strongswan-ip-neigh.sh
            }
        }
    }
}

最后重启下strongswan服务。

systemctl restart strongswan

对Windows的特别处理

现在,你可以用strongSwan的Android客户端连接下VPN,然后访问 https://ipv6-test.com/ 检测下试试看。网站应该会提示你你已经可以连接上IPv6网络了。并且网站提示你你的IPv6地址是swanctl.conf里设定的IPv6地址池里的地址。再尝试连接下 ipv6.google.com,应该也能正确显示网页。

Linux上的结果应该也是一样。

但是当你在windows下连接VPN时,VPN连接状态里的IPv6网络还是无网络访问权限。这是windows的问题,解决办法是对VPN适配器手工加一条到::/0的路由,让windows知道所有的IPv6连接都要走这个VPN。以管理员权限打开一个命令提示符窗口,输入命令:

netsh interface ipv6 add route ::/0 "<windows里你的VPN连接名>"

也登录下网站测试试试看,结果应该和Android/Linux是一样的。

让IPsec VPN连接在IPv6网络可用

VPN服务器在IPv6网络下的配置和IPv4下几乎一样。不同的是除了监听地址要变成”::”,还需要把encap = yes去掉。Linux和windows在IPv6网络环境下没法把ESP包封装进UDP协议里,加上这个选项windows会提示invalid payload received。

    strongswan-win-v6 {
        version = 2
        rekey_time = 0

        local_addrs = ::
        send_certreq = no
        pools = ip_pool, ip_pool6
        dpd_delay = 60s

        proposals = aes256-sha384-modp1024,default

        local {
            auth = pubkey
            certs = fullchain.pem
        }

        remote {
            auth = eap-mschapv2
            eap_id = %any
        }

        children {
            net1 {
                local_ts = 0.0.0.0/0, ::/0
                updown = /etc/swanctl/strongswan-ip-neigh.sh
            }
        }
    }

当然如果你就是看encap = yes这个选项不顺眼,也可以不给IPv6连接单独弄VPN配置。在原来的配置里把这个选项拿掉,然后local_addrs的值改成0.0.0.0, ::,这样也是可以的。

在纯IPv6网络环境下建立VPN连接,甚至可以让你的电脑获得访问IPv4网络的能力。

另外多说一嘴,strongSwan的Android客户端并不支持连接只有IPv6地址的IPsec VPN服务器。毕竟客户端在非Root环境下也能用,所以它建立VPN连接只能依赖把IPsec流量封装进UDP这个手段,无论系统本身是否能获得公网IP。众所周知linux下要发裸IP包是需要root权限的。然而linux在IPv6情况下不支持把IPsec怼进UDP里,所以就只好不支持了。见 https://wiki.strongswan.org/issues/892

排错

查看服务端日志能定位绝大部分的问题。

VPN配置文件出错时,使用systemd重启VPN服务会失败。这时候用systemctl status看状态能看到strongSwan解析配置文件解析到哪里出了错。

要在服务端跟踪VPN连接的日志,可以输入swanctl --log命令。有了日志了,报错了,然后怎么办?当然是用咕狗搜日志内容啦!

后记

在我“自力更生”在风滚草上成功搭上IPv6隧道之后,我又试了别的发行版。目前试验过的发行版有 openSUSE Leap 15.1 和 CentOS 8。相同配置下出现了和风滚草不太一样的情况,具体是为什么需要求助下。

情况是这样的:sysctl.conf里IPv4和IPv6的转发都打开、给VPN客户端的IPv6地址池也写上、但是ND Proxy的记录没有添加的情况下(就是最后的脚本做的事情),客户端获得的IPv6地址居然就已经可以联网了!只不过网站显示的IPv6 IP是VPS的而不是VPN客户端获得的。别的地方ping VPN客户端获得的IP也ping不通。加上了ND Proxy虽然能ping通了,在VPN客户端搭web服务器监听v6地址然后curl也能出结果,但是网站给出的IPv6 IP还是VPS的。

IPsec真的是深不可测的一个协议。 没准哪里就会碰到坑。这也是刚开始要吐槽的原因。

代理服务器不香吗!

参考资料

strongSwan 项目

大佬们的教程

声明:仅分享心得,不提供答疑服务。

“爱马仕铅笔”购买记

去买“爱马仕铅笔”的原因

爱马仕铅笔其实是个梗,这个梗指的是雅思。中国大陆的雅思考试不准带文具,但是会附赠个不太好用的、上面有IELTS字样的子弹铅笔,还有个同样有IELTS字样的橡皮擦。因为雅思考试的报名费不菲,成绩又总卡小分,考完试之后又有个可以拿回去纪念的东西,所以这可以拿回去的铅笔就收获了“史上最贵的铅笔”的称号。另有诗曰:“一个铅笔一个擦,两千不到带回家”。更讽刺的是这诗描述的事实已经不对了。2018年的报名费涨到了2020元,所以要改成“两千块钱带回家”;2020年已经变成了2170元,不知道这个应该怎么编。

另外Google考证了下,爱马仕真的出过铅笔!

所以为啥要买这个铅笔,哦不,去考雅思呢。原因其一是一直觉得在国内工作实在没什么前途,觉得雅思成绩是个脱脂的门票之一;其二是想知道自己英语是什么水准,在中国能熟练使用英语确实很多人都达不到(意味着竞争力);其三是失业了给自己找点事情做,顺带逃避工作。因为原因一,上班的时候就买过剑桥6-10,然后因为工作太忙和自己的懒惰,这五套题就一直放着吃灰,这又引出了第四个原因。国际认可的英语测试还有个ETS开的TOEFL,考托福还得另买TPO,都买了剑桥习题册为啥不去烤鸭呢。

准备吃烤鸭也是这段时间blog咕咕咕的最大的原因。

准备考试

因为以上的原因,我去ielts.neea.cn报了12月的雅思考试,学术类的。又由于工作三年了已经完全无法在纸上工整地写字所以选了机考。10月末去和母亲和母亲的亲戚们一起旅游了,回来赶上了双十一,所以用来准备考试的时间只有一个多月。

着手准备的时候,我的英语水平大概是这样:高考英语130多,大一上学期裸考四级考了560分,下学期裸考六级考了502分。之后就再也没有参加过任何英语考试。大三没英语课英语一度荒废了一阵子,下学期有了稳定的途径爬墙,可以上YouTube了,开英语字幕勉强能看懂老外在用英语吹逼。实习上班的时候,有感于在百毒用汉语搜项目中遇到的问题实在是搜不到答案,便开始有意识地用英语关键字在bing上搜,发现了一片新的天地。答案里碰到了不会的单词就去oxforddictionaries.com查。开始这样做之后,看懂技术文档变得基本没有什么阻碍,也能在github用蹩脚的英语提点issue,但是看BBC、纽约时报、卫报之类的还是吃力。尝试强迫自己看,能看懂,但是读完一篇文章花的时间非常多。听力这方面自从有了烤鸭的想法,看YouTube视频便开始强迫自己关掉英语字幕去强听。可以说,之后的英语能力完全靠自己有意识地去用来维持,也不知道是什么水平。

旅完游回来就开始做买回来的那五本剑桥习题册,先从剑桥6开始做。第一天做题听力被虐惨了,好多都没听见写不出来答案,完全没勇气去对答案;但是阅读的结果出乎意料,在一小时内勉强做完居然只错了两道题!顿时有了信心。听力虽然错了很多,但可能是搞明白了雅思听力在考什么,之后做习题的成绩都没有第一次那样惨。之后维持着每两天左右做一套习题,错的有多有少,但是都在答案说的you are likely to get an acceptable score的范围内。阅读错了的题再去原文找也都能找到信息点。后来发现这么多习题真的做不完,又买了13和14。都说难的13和14竟然也都能得到7~8分。所以听力阅读这方面应该是没什么问题的。

至于中国人传统的弱项——口语和写作,我感觉我并没有什么发言权,因为我也是这样的人。之前的写作能力仅限于用工地英语在github提issue,语句能做到大致基本通顺。问过别人,说这种情况只要能做到基本没有严重的语法错误,就可以拿到6分。都推荐的雅思考官Simon博客,也都说了:只要基本无语法错误且道理能说的通不牵强附会,就能拿到6分。我练写作的方法是先在Word里关掉语法检查,写完了再打开Grammarly检查语法错误,改完了语法错误后再照范文找差距。这么弄大概是很low的一种方式。给ieltsbro雅思哥充过钱批改写作,批改的结果也说是能得6~6.5分。

再来说口语,在口语这方面我更没有发言权。我的英语口语真的很烂。准备口语能做的只有在雅思哥上把Part2的话题全溜一遍,在考试前甚至已经放弃治疗不打算准备一些口语话题了。网上的各路大神已经强调了很多遍,不要背Part2,否则必定5.5及以下。我准备的方式是把口语Part2话题都弄到了一篇Word文档上,然后里面的提示都写的是关键的短语,把关键点记住按照这些key point盲说确实是能流畅地刷出来,可是拦不住碰到实在是没话说放弃治疗不想准备的Part2话题啊艹屮艸…… 英语口语这东西找人尽力去陪练应该能提升很大,可惜我准备考试的时候没有这个条件。以后的打算是决定去speaky上碰碰运气。

参加考试与结果

我预定了考试前一天早上飞到北京的机票。当天早上到北京之后,先去找考点位置,找到了就在附近找个酒店住下。考点附近是使馆区,住宿费是真的贵到天上!然而条件却非常不怎么样,暖气是坏的,插座是接触不良的,但是没办法,谁叫它离考点近呢?在酒店住下后我把特意留下的剑桥14test4做了一下,其余的时间就是继续被口语折磨。那时候已经对部分Part2话题放弃了治疗,但是准备的都是能口若悬河的,就看运气了。

选择机考的一大优势是口试可以和笔试放在同一天进行。如果必须异地参加考试,选择机考能省去一大笔交通费和住宿费。我报的是下午的笔试,所以口试放在了上午。北京的机考中心其实是British Council在北京的总部。位置在亮马河大厦,是一对写字楼,亮马河大厦的楼层指引把考点的位置写成了“BC教育集团(北京)有限公司”。坐电梯上去可以看到是典型的写字楼布局,一边是BC总部的大门(考点),另一边是储物柜用来存不能带进考场的物品。除去储物柜和椅子之外的留下的面积非常小,只能容纳十多个人的样子。考试前一天踩点的时候,看到了一堆考生穿着羽绒服在BC总部大门前排队,看来报机考的人还是很多。

考试当天早上我起了个大早。口试的时间是当天的早上九点十分,很多Part2话题仍然不太熟,于是我定了的很早的闹钟用来空出时间和口语做斗争。事实证明这点努力是徒劳的。考点要求提前30分钟到场,但是到了考试的地方我发现工作人员其实是分批放人的,每个批次放三个人进去。进去的时候只允许带身份证,准考证不能带进去,工作人员竟然也没看准考证。到时间了进去做了一堆例行公事:检查物品、拍照和指纹验证,最后戴上了史上最值钱的纸做的手环,是橙色款的。全部做完之后就等待考官叫人进行口试。可以说,等待口试的这段时间应该是整个考试中最紧张的时段。因为你不知道你准备的那堆破玩意儿到底入不入考官的法眼;你也不知道一些实在憋不出的话题到底会不会被考官抽中然后翻车,雅思哥的口语回忆在这时根本没有什么卵用;最后,你更不知道Part3考官会提什么不相关的问题来刁难你。

忐忑不安的状态下等了十几分钟,轮到我了。考官是一位印度裔的人士。首先是问我How are you之类的,我内心其实是非常想答小学英语的标准答案的:“I’m fine, thanks. And you?”。我克制住了自己,跟考官说了下我真的非常紧张,这是我第一次考雅思等等等。接下来又问了是否工作、喜欢什么样的音乐、是否喜欢野生动物这样的问题,轮番的why问的我有点懵。重头戏是Part2,Part2的题卡是请描述你和别人的有趣的对话,我脑袋嗡的一下,这正好是其中一个用我的经验描述不出来的一个问题。时间到了,我只能临场发挥,说我真的没有这样的经历,在我的记忆里没有什么对话是非常有趣的之类的瞎编的话;最后也没能说够一分钟以上。Part3更是崩了,考官问了好几个抽象的问题,实在是不知道怎么应付。最后到时间了考官还向我说have a nice day。我也想有个nice day但是被这么问我心情真的没那么好啊!总体来说,考官虽然是印度裔,但是发音是非常标准的,我感觉我的渣英语真的配不上这位考官,尤其是Part2血崩,所以口语翻车无误。出了亮马河大厦,我和同行的两个人互相问了下考官问了啥,发现都是跪,但是没细问也不好意思细问,所以也不知道他们是什么样。

确认了自己的口试已经翻车也没办法,心态不能崩,万一有希望呢,笔试不能一起翻车,不能破罐子破摔。

下午是笔试时间,入场的模式是差不多的。进去仍然要检查随身物品验证身份,胳膊上又戴了一次史上最值钱的纸带,这回是绿色款。笔试考场是电脑靠墙围了一圈,然后监考的坐中间,天花板上密密麻麻的全是监控。座位都用隔音板分割了起来,考生不能看到其他人。再来说电脑和考试系统,电脑都是无盘工作站,考试系统可能和TOEFL iBT差不多,应该是连上了BC的内网,然后系统界面是用Web技术实现的。每进行一个科目,监考人员都会发来一张新的纸,这张纸写着登录考试系统用的用户名和密码,这张纸本身也可以用来做笔记。再来说试题难度,听力和阅读的难度和剑桥真题集的难度的差距真的不大,做起来相对还是顺手的,阅读做完甚至能多出来五分钟的时间用来查错。写作的小作文考的是个表格,大作文是要谈论广告的影响和如何应对之类的,瞎写了一通感觉应该能有6分。总体来说笔试还算顺利,但是口试翻车了又有什么用。

顺带说一下关于爱马仕铅笔的事情,前面都说了“一个铅笔一个擦,两千块钱带回家”。机考是没有橡皮擦的,然后收到的爱马仕铅笔是粉红色的一款,居然把纪念橡皮擦阉割了,BC真的很无耻。另外有传闻说整个考试能见到两种颜色的爱马仕铅笔,这是完全错误的。口语考官Part2给的是个马克笔,根本不是什么爱马仕铅笔。

考试结束又在北京呆了一个白天,当天晚上就坐火车回家了。之后就是等成绩,一个多月苦苦准备的东西突然结束了,心态有点空虚,什么都不愿意做。网站说机考5~7天后出成绩,第七天早上等来了成绩发布通知的邮件。当天12点登录了网站看到了成绩:听力7.5阅读8.0写作6.0口语5.5总成绩7.0。最刺眼的大概是口语5.5,果然翻车了;然而总分却是7分——一个让无数人羡慕的分数。这成绩非常尴尬,7分据统计确实只有10%的中国考生能考到,非常高,但是细看小分口语5.5又不那么合格,好多学校都要求口语6.0和以上的。然而我大学毕业的绩点非常差,2.5/4.0。英语能力达到要求了GPA不达标又有什么用呢?好大学肯定都不看不上的。这东西只能用来做个语言能力证明吧,顺带说明了口语需要加强。

收获

那么这次购买爱马仕铅笔的经历有什么收获吗?简短地说一下。

首先是考雅思的经历对英语能力的影响。我觉得烤鸭这个经历对英语能力提升可能没那么大,最关键的还是自己平时的积累,指望准备几个月烤鸭然后英语突飞猛进是不可能的。我感觉我的最大收获可能是在写作这方面,因为写作不能出现太多的语法错误,我在British Council的网站上把初高中的的语法全都重新捋了一遍。最终被Grammarly查出来的语法错误虽然是少了很多,但是表达能力上却没有什么特别大的进步。原因是为了不出语法错误能用朴实的稳妥的表述就用朴实的,这是达到6分的标准之一。要在语言运用方面取得进步还是得依靠平时的积累,这不是一次两次的备考就能做到的。

再来说分数本身。以我的经历来看,雅思只要求总分的话,7分其实是个非常简单就能达到的目标,真正困难的是小分,尤其是口语和写作。也因为这两个科目,总分到7.5和以上是非常困难的一件事,这要求口语和写作必须到7分以上,L9.0/R9.0/W6.0/S6.0这种极端的组合是几乎不可能做到的。再来说7分,平均分到6.75就能进位到7分了。在写作和口语都保证6分的情况下,听力和阅读只要7.5分就可以,也就是最多错7道题。我感觉在文章都读懂的情况下错7道以下应该是很容易达到的一个目标。再来看单项7分,写作要冲7分必须在保证没有语法错误的前提下使用复杂句式疯狂卖弄文采,口语的7分我看了官方视频我是真的做不到那么流畅。可以想到,很多人二战三战,就是差在口语和写作的小分上了。

最后说分数的用途。单看总成绩7分似乎不错,然而有个5.5真的很尴尬。我能想到的第一个用途是考上了研究生申个英语免修,因为国内研究生不看小分的!考上了北大研究生,要英语免修也只要7分而已。然而这说的好像我能考上北大的研究生一样,我这种数学1和习概全都做不下去的人还考个屁的研究生。能想到的第二个用途可能是混个英语培训机构的兼职,听说也很赚钱,阅读8分可能一些培训机构会喜欢吧。另外我不知道的是,即使小分都弄到6分以上了,我那个糟糕的绩点 + 三年工作经验真的能帮助弄到一个offer吗?想来想去,最实际的用途大概是吹逼。下次走在街上被人推销学英语,尤其是海淀黄庄这种地方,大概是有底气说“劳资雅思考了7分还培训个屁”这句话了。

所以,爱马仕铅笔还会继续购买吗?手感非常好,可能用完了还要继续买(内心十万头草泥马呼啸而过)!即使没有什么签证要求,真的准备脱脂口语能力也是必须得加强的。过几个月有心情了可能还会再报一次试水吧,听力阅读写作大致是有底了,重点当然是要想办法把口语搞上去。

Latitude 7300 使用体验

失业了之后败家买了个笔记本,理由是出门有时候只带手机实在是不方便。使用体验瞎写点流水账。

因为家里已经有了畅玩吃鸡(虽然根本不玩吃鸡)的大奶,所以对笔记本的性能要求没有那么过分,对游戏本完全不感冒。笔记本用途大概有上网和轻度写代码,也会玩一些小游戏和《我的世界》,3A大作是根本不可能碰的。

购买

心目中理想的笔记本电脑大概是这样的:足够轻巧方便携带;CPU能支撑的住轻度开发软件的需求;续航时间要长,能在没有电源的情况下坚持够长的时间;不要有亮机独显消耗电力;内存和(固态)硬盘不是焊死的,能升级;屏幕不能是万恶的1366×768分辨率,因为在扔掉的旧笔记本电脑上被这分辨率坑了五年。其实说白了就是想要个续航长的性能够用的商务本。

按照这个想法来挑,好多笔记本都被排除了。苹果MacBook和戴尔XPS都中枪,因为内存都是焊死的。联想商务系列的标杆ThinkPad X1 Carbon最新款因为内存焊死也中枪。挑来挑去傻多戴的Latitude 7300似乎不错,符合我心目中的笔记本电脑所有的特征。看了这个评测视频https://www.youtube.com/watch?v=MQ9brQcjghM更加坚定了买的念头,铝合金外壳真帅!然后看了戴尔官网的价格,加上官网折扣,价格是10000大洋符合预期的预算,无脑买买买!不过可惜的是中国区并没有铝合金版,只有美国戴尔有,可是我并没有从美帝海淘的条件,所以只能买碳纤维版的了。

买之前在Google里找了下这款笔记本有没有使用评测之类的,找到了逼乎专栏上的这篇文章:https://zhuanlan.zhihu.com/p/65371687,这里介绍了购买同款碰到的一些坑。

从下单到签收,等电脑大概等了一周的时间。到手之后觉得碳纤维版的做工其实还可以,当然是肯定没铝合金版好看的。铝合金外壳版那是无可争议地炫,没有了铝合金外壳,逼格顿时完全比不上MacBook,整体外观更像是那种普通的商务本。网上那么多评测文章所以就不发开箱图了。

使用

我买的配置是这样的:
– 显示屏:13吋1920×1080 IPS屏幕
– CPU: i7-8665U
– 内存:16GB
– 固态硬盘:Intel的不知名512G nvme固态
– 电池: 60Wh

电脑到手之后用了两个多月,说下使用感受和一些坑。

先说下插口和显示屏。不像MacBook Pro为了追求轻薄只给了俩雷电3,这个本给了很多实用的接口。左右两边给了两个USB口、一个TypeC/雷电3、一个HDMI、还有个耳机插孔,甚至还有microSD读卡器和SIM卡插槽。SIM卡插槽国行没阉割好评。那个typeC口也可以用来给笔记本充电。 可惜这代Latitude把RJ45网口阉割掉了,要网口必须另买适配器。 大概是因为定位的目标人群是公司的高层,不需要到现场去干活,对RJ45的需求并没有那么迫切。

显示屏是13吋1080p屏,不过同样型号的乞丐版有更差劲的1366×768显示屏,要买的话得留心眼。当初买的时候心里只想摆脱掉1366带鱼屏,没有意识到的是13吋的屏幕强上1080p分辨率,工作空间是大了,屏幕的点距却非常小,100%缩放的时候文字也非常小;这只能说鱼和熊掌不可兼得。惊喜的是笔记本的屏幕能做到180度开合,商务本牛逼,180度开合意味着在床上玩电脑不需要让脑袋迁就屏幕的角度,可以把腿立起来玩,好评。再看看人家苹果,9102年了,拥有那么强大的工业设计能力,在定价上万的笔记本上居然连180度开合这个小小的需求都不肯加。

国行没把SIM卡槽阉割掉,所以WWAN卡对应的4G模块应该也没有遭殃。照官方指南拆机看了下,拆机是把D面的八个螺丝拧下来,在屏幕铰链的地方把卡扣抠开(这个地方最薄弱),整个后盖就能拿下来了。非常好拆。之后主板电池硬盘风扇几大件都摆在面前,很容易就能拿下来。和我想的一样,左边的4G模块插槽并没有被阉割。但是显示屏里面并没有预留天线,安装天线必须得自己想办法。


图里那个大号的扩展卡就是后装的4G模块。

4G模块也是用的M.2插槽,安装的位置也可以容纳一个2242规格的M.2固态硬盘。手里有办宽带被迫一起办的闲置手机卡,为了把手机卡的流量套餐利用起来,决定在这上面装4G模块。选的是逼乎专栏里提到的Dell DW5816e。关于没有预留天线的问题,我在电池和掌托之间找到了一个能放贴片天线的地方,天线的导线从电池和主板之间的空隙引出来。一切装好,打上驱动之后4G模块居然能正常工作了。测试了下,中国联通4G的信号不好,移动4G信号却非常不错,很诡异。不管怎么说,现在笔记本可以不借助手机就可以随时随地上网了,只要有手机信号就行,甚至还能收发短信,不过不能打电话。整体体验是除了存款在燃烧之外没有任何不适。

本子还有个惊喜,它的默认配置带TPM2.0模块,系统激活后它附带的固态硬盘就是处于加密状态的。TPM模块的生产商在tpm.msc里看到是英飞凌而不是强国认证的国民科技。TPM2.0掺入了强国自主知识产权的加密算法,不知道这个模块使用的是什么标准。

说完了好的一面,再说下缺点。实用的插口是有了,官网看到的效果图里,这些插口占满了左右两边的空间。可是这好歹是装了i7(低压)的本子,还是四核八线程的,不装风扇是不可能的。然而风扇的出风口在哪里呢,本子拿到手之后知道了这个问题的答案:在笔记本本体和屏幕之间的缝隙里,而且出风口开的非常小。相信看到这里都应该能明白我要说什么意思了:散热稀烂。出风口开得小了怎样在如此薄的机身里压住四核八线程的怪兽呢?当然是靠超高转速的风扇咯!最后戴尔在笔记本里安了个最大转速高达7000RPM的恶魔。笔记本满载工作的时候,这个散热风扇就会发出非常刺耳的高频噪音! 那篇逼乎专栏文章也着重提到了这一点,并且说到了换机对改善问题效果不大,所以这应该是这个机器的通病,看评测7400拆机和7300的结构非常类似,应该也有这个问题。

这个散热差其实不仅是戴尔的问题,英特尔也应该来背锅。英特尔被AMD锐龙打的猝不及防,意识到低压U不能再用双核四线程来糊弄人了。因此在8代移动处理器里挤了一大管牙膏,从双核四线程升级到了四核八线程。核心数多了,发热量自然也会上去,怎样让TDP变得好看些?英特尔祭出了祖传的降频保TDP大法。i7-8665U这颗CPU在全核心频率降到1.9GHz的时候TDP终于到了15W这个好看的数字。各大笔记本电脑厂商看到TDP没怎么变,于是原有的模具想都没想就都拿过来用,到最终用户这里的结果就是散热稀烂,续航也没有上代好看。8代处理器要做到性能满意还得看厂商下更多的工夫优化,这个就得看厂商犯不犯懒了。

散热具体能差到什么程度呢,编译一个上万行的项目,风扇就要狂转了。CPU的50W真男人模式能持续五秒钟,之后是20W的增强模式。虽然睿频爆发模式最长能持续28秒,但是在这之前CPU就已经到90度以上要降频,然后功耗一直卡在12W不动。整个过程下来风扇声音没有小下来的时候,转速到5000rpm的时候噪音就已经不能忍受了。更气人的是风扇这么卖力地转,在屏幕转轴处的出风口却感受不到多少风量,有种上当受骗的感觉。

我觉得散热是这机器唯一也是最大的缺点,说了这么多,就此打住。

风扇实在是太吵,我一直在找这个问题的解决办法。在Google找到了reddit帖子,说给CPU降电压能减少功耗和发热。降电压的工具有Intel Extreme Tuning Utility和ThrottleStop。前面Intel官方的工具确实能降压,但是笔记本进入休眠状态时,系统就崩溃自动重启了,不能从休眠状态中恢复回先前的状态。Google搜索发现原因是戴尔砍掉了笔记本的S3休眠模式,只保留了standby类型。我又试了ThrottleStop,这工具也能把CPU电压成功降下来,只是配置麻烦些,不过最重要的是降压配置不会丢掉。给CPU电压降了80mV,散热风扇不像以前那样动不动就狂转了,确实有效果,算是部分解决了散热差的问题。

结论

  • 本子做工还不错,扩展接口也可以,照官方维护指南拆解非常好拆,好评。
  • 国行没阉割SIM卡槽WWAN卡好评,TPM是英飞凌的不是天朝国民技术的,好评。
  • 散热太差,风扇太吵,负分差评。
  • 嫌苹果本和XPS焊死,又想要足够薄,又想续航长,又想要点扩展性,这个本子是个好选择。作为自用来说除了散热略差真的挑不出什么缺点。可惜个人购买时本子的定价还是太美丽了,能走公司批量采购有巨大折扣的话是坠吼的。

买这个笔记本有什么感言呢,我觉得我应该去买Latitude 7400然后另买内存,13吋的屏幕还是太小了。

用静态链接Qt库的方式使用Qt(下)

上篇文章:用静态链接Qt库的方式使用Qt(上)

上篇文章讲了如何编译出静态库形式的Qt,这篇文章讲怎样把编译好的Qt静态库链接到程序上。这里主要说的是寻找引用Qt库正确途径的过程,嫌太长只想知道方法可以直接看结尾处的结论。

尝试把Qt静态库链接到程序上

既然已经把Qt静态库编译好了,那么就把它链接到我们的CMake项目吧!

Qt本身确实是支持用CMake构建的项目的,还提供了CMake项目使用Qt库可用的文档:https://doc.qt.io/qt-5/cmake-manual.html。自己的项目使用Qt动态库的时候,Qt提供支持的CMake脚本放在lib/cmake这个文件夹下。configure的时候把Qt5_DIR的值设成lib/cmake/Qt5,Qt提供的CMake脚本就能帮你完成后续的寻找Qt库依赖的工作。编译发布包的时候,只要再运行一下windeployqt.exe,这个程序就能把你的项目需要的所有Qt DLL复制到发布包所在的目录下。对应的CMake脚本语句写起来也非常简单,这里就不详细说了。

静态库形式的Qt库是不是也可以这样简单地就完事呢?来到静态库Qt的安装目录,切到lib目录下就能看到编译成静态库的Qt库,debug版和Release版的都有,debug版的库文件名都是以d结尾的。这个目录下面也有CMake子目录,里面存了和动态库Qt一致的CMake脚本,看起来链接静态库Qt只要照葫芦画瓢就可以。再切回自己的项目,把cmake缓存清一下,再把Qt5_DIR的值指向静态库Qt下同样的目录。CMake程序很顺利地把项目configure完了,VS解决方案也生成出来了。

那么下一步就是编译。生成程序的过程也是非常地顺利,直到构建过程到了链接生成二进制文件的这一步:

Qt5Core.lib(qcoreapplication_win.obj) : error LNK2001: 无法解析的外部符号 _VerQueryValueW@16
7>Qt5Widgets.lib(qwindowsxpstyle.obj) : error LNK2019: 无法解析的外部符号 __imp__OpenThemeData@8,该符号在函数 "public: static void * __cdecl QWindowsXPStylePrivate::createTheme(int,struct HWND__ *)" (?createTheme@QWindowsXPStylePrivate@@SAPAXHPAUHWND__@@@Z) 中被引用
7>Qt5Widgets.lib(qwindowsvistastyle.obj) : error LNK2001: 无法解析的外部符号 __imp__OpenThemeData@8
7>Qt5Widgets.lib(qwindowsxpstyle.obj) : error LNK2019: 无法解析的外部符号 __imp__CloseThemeData@4,该符号在函数 "public: void __thiscall QWindowsXPStylePrivate::cleanupHandleMap(void)" (?cleanupHandleMap@QWindowsXPStylePrivate@@QAEXXZ) 中被引用

......

7>Qt5Gui.lib(qpnghandler.obj) : error LNK2019: 无法解析的外部符号 _png_get_text,该符号在函数 "public: void __thiscall QPngHandlerPrivate::readPngTexts(struct png_info_def *)" (?readPngTexts@QPngHandlerPrivate@@QAEXPAUpng_info_def@@@Z) 中被引用
7>Qt5Gui.lib(qpnghandler.obj) : error LNK2019: 无法解析的外部符号 _png_set_text,该符号在函数 "void __cdecl set_text(class QImage const &,struct png_struct_def *,struct png_info_def *,class QString const &)" (?set_text@@YAXABVQImage@@PAUpng_struct_def@@PAUpng_info_def@@ABVQString@@@Z) 中被引用
7>Qt5Gui.lib(qpnghandler.obj) : error LNK2019: 无法解析的外部符号 _png_get_tRNS,该符号在函数 "void __cdecl setup_qt(class QImage &,struct png_struct_def *,struct png_info_def *,class QSize,bool *,float,float)" (?setup_qt@@YAXAAVQImage@@PAUpng_struct_def@@PAUpng_info_def@@VQSize@@PA_NMM@Z) 中被引用
7>Qt5Gui.lib(qpnghandler.obj) : error LNK2019: 无法解析的外部符号 _png_set_tRNS,该符号在函数 "public: bool __thiscall QPNGImageWriter::writeImage(class QImage const &,int,class QString const &,int,int)" (?writeImage@QPNGImageWriter@@QAE_NABVQImage@@HABVQString@@HH@Z) 中被引用
7>Qt5Gui.lib(qpnghandler.obj) : error LNK2019: 无法解析的外部符号 _png_set_option,该符号在函数 "public: bool __thiscall QPngHandlerPrivate::readPngHeader(void)" (?readPngHeader@QPngHandlerPrivate@@QAE_NXZ) 中被引用
...... fatal error LNK1120: 151 个无法解析的外部命令

卧槽,怎么突然多出来这么多无法解析的外部符号!

解决 “无法解析的外部符号”

仔细看这些无法找到实现的函数符号,可以看到其中包含的函数名有不同的特征:

名称类似__imp__function@16_func@8的函数

这类函数的函数名的特点大概有:函数名中的短语之间没有分隔;函数的前缀都包含下划线,后缀都带@字符加一串数字。前者是Win32 API提供的函数名典型的命名方法,几乎没有例外。后者表明在x86环境下,函数使用了__stdcall的调用约定(详情请去google找name mangling这个关键字)。所以这类函数基本上都是Win32 API提供的函数。用函数名做关键字,在google上找提供这个函数的Windows DLL,之后在target_link_libraries里加上对应的lib文件,这种类型的函数的链接问题就都能解决。

_pcre_hb_png等等开头的函数

这些都是Qt项目本身使用的第三方库提供的函数。Qt都使用了哪些第三方库,在这里可以找到:https://doc.qt.io/qt-5/configure-options.html#third-party-libraries。Qt为了方便开发,在项目源代码文件中包含了这些库的源码,编译的时候也默认编译使用自己项目里的第三方库源代码。那么问题来了,为什么动态库Qt肯定也用了这些第三方库,我在windeployqt.exe复制的DLL里面却找不到这些库的存在?既然用到了这些库为什么静态库Qt就不能把这些库生成的二进制代码直接包含进类似Qt5Gui.lib这样的静态库里面?

我的粗略的理解是这样的:Qt编译成DLL时,最终生成的是一个完整的PE可执行文件,PE文件本身就要求依赖的代码要么在PE导入表中声明,要么代码本身就存在PE文件内部,既然没在导入表中找到第三方库的DLL,那么这些第三方库在链接生成PE文件时就必然已经包含进DLL中。编译成静态库时,lib文件是一系列由源代码编译生成的中间文件组成的集合,自然没有PE文件的限制。而由于项目管理的限制,第三方库这部分代码又不能直接整合进类似Qt5Gui.lib的静态库里面,要这样做就得在每个用了这个第三方库的Qt项目的makefile里编译第三方库的源文件,这样链接的时候必然会出现无数函数符号冲突的错误。因此第三方库的代码没法包含进Qt静态库中。

因此必须得想办法把这些第三方库找回来。拿pcre库为例,在静态库Qt安装目录下找pcre的字眼,可以在lib目录下找到两个静态库qtpcre2.libqtpcre2d.lib,仔细看,其余的第三方库lib都能这样找到,例如qtlibpng.lib。那么就把这些第三方库也包含进里面,最后链接的第三方库的列表大概是这样的:

set(QT5_LIB_DIR "${Qt5_DIR}/../..")
set(Qt5_STATIC_LINK_LIBRARIES
  debug ${Qt5_LIB_DIR}/qtpcre2d.lib optimized ${Qt5_LIB_DIR}/qtpcre2.lib
  debug ${Qt5_LIB_DIR}/qtharfbuzzd.lib optimized ${Qt5_LIB_DIR}/qtharfbuzz.lib
  debug ${Qt5_LIB_DIR}/qtlibpngd.lib optimized ${Qt5_LIB_DIR}/qtlibpng.lib
  debug ${Qt5_LIB_DIR}/qtfreetyped.lib optimized ${Qt5_LIB_DIR}/qtfreetype.lib
)

解决Qt程序所需的插件依赖

解决完Windows API和Qt第三方库的链接问题后,再重新构建项目,这次构建程序居然成功了!既然构建成功了那就尝试运行下构建出来的程序,程序的主界面没有出来反而弹出了一个报错对话框,向你泼了一盆冷水:

对话框提示我们程序里缺了让Qt程序正常运行的插件:”windows”。这个”windows”插件是什么鬼?把项目调回到链接Qt 动态库的配置上,看下windeployqt.exe复制过来DLL,其中有个qwindows.dll,在platform目录下。这个DLL在Qt安装目录的位置是在plugins/platforms/qwindows.dll,它被放在了plugins的目录下,所以这个qwindows.dll应该就是那个名叫”windows”的Qt插件。

不过Qt的文档说了,链接Qt的静态库必然会牺牲掉Qt框架按需动态加载插件的特性,这时的程序必须在代码里声明自己要用到的所有Qt插件。声明插件的方式是在C++代码里加上类似Q_IMPORT_PLUGIN(pluginName)这样的语句。所以这个qwindows.dll对应的静态库是什么?在Qt静态库的安装目录下可以找到plugins/platforms/qwindows.lib这个文件,这个应该是对应的静态库文件。于是先尝试这样做:

  • 在C++代码里加上以下语句引入”windows”插件:
    #include <QtPlugin>
    
    #ifdef QT_STATIC
    Q_IMPORT_PLUGIN(windows)
    #endif
    
  • 在CMake脚本里加上以下语句链接qwindows.lib:
    set(Qt5_STATIC_PLUGIN_LIBRARIES
    debug ${Qt5_ROOT_DIR}/plugins/platforms/qwindowsd.lib optimized ${Qt5_ROOT_DIR}/plugins/platforms/qwindows.lib
    )

尝试编译,然后编译失败了:

error LNK2019: 无法解析的外部符号 "struct QStaticPlugin const __cdecl qt_static_plugin_windows(void)" (?qt_static_plugin_windows@@YA?BUQStaticPlugin@@XZ),该符号在函数 "void __cdecl `dynamic initializer for 'staticwindowsInstance''(void)" (??__EstaticwindowsInstance@@YAXXZ) 中被引用

所以该从哪里获得qwindows.dll插件在C++代码里的正确名称?这个问题百思不得其解,直到我打开了Qt库bin目录下的qtplugininfo.exe。原来这个程序就是用来把Qt插件的基本数据dump出来的。按照这个程序的帮助输入了下面的命令,终于知道了插件的名字原来叫QWindowsIntegrationPlugin

qtplugininfo C:\Qt\Qt5.9.8\5.9.8\msvc2015\plugins\platforms\qwindows.dll -p classname

把前面的”windows”改成QWindowsIntegrationPlugin,继续尝试编译,怎么还是报找不到外部符号的错?

1>qwindows.lib(qwindowsintegration.obj) : error LNK2019: 无法解析的外部符号 "public: __thiscall QWindowsFontDatabase::QWindowsFontDatabase(void)" (??0QWindowsFontDatabase@@QAE@XZ),该符号在函数 "public: virtual class QPlatformFontDatabase * __thiscall QWindowsIntegration::fontDatabase(void)const " (?fontDatabase@QWindowsIntegration@@UBEPAVQPlatformFontDatabase@@XZ) 中被引用
...
1>qwindows.lib(iaccessible2.obj) : error LNK2019: 无法解析的外部符号 "bool __cdecl QAccessibleBridgeUtils::performEffectiveAction(class QAccessibleInterface *,class QString const &)" (?performEffectiveAction@QAccessibleBridgeUtils@@YA_NPAVQAccessibleInterface@@ABVQString@@@Z),该符号在函数 "public: virtual long __stdcall QWindowsIA2Accessible::doAction(long)" (?doAction@QWindowsIA2Accessible@@UAGJJ@Z) 

这些名叫QWindowsFontDatabaseQAccessibleBridgeUtils等等的C++类又是怎么回事?肯定是又有一些额外生成的Qt内部的静态库依赖需要我们手动包含进程序里面。继续在Qt静态库目录里面找,发现了两个lib文件,名叫Qt5FontDatabaseSupport.libQt5AccessbilitySupport.lib。把这俩静态库也加到链接列表里面去,果然错误少了很多。lib目录下面还有别的这样结尾的库文件,索性把它们都加到target_link_libraries列表里面,反正没有引用的话最终程序也不会包含多出来的静态库。最终的CMake脚本是这样的:

  file(GLOB 
    ${QT5_LIB_DIR}/*Supportd.lib
  )
  file(GLOB Qt5_STATIC_LIBRARIES_SUPPORT_RELEASE
    ${QT5_LIB_DIR}/*Support.lib
  )
  foreach(support_lib_debug IN LISTS Qt5_STATIC_LIBRARIES_SUPPORT_DEBUG)
    list(APPEND Qt5_STATIC_LINK_LIBRARIES
    debug ${support_lib_debug}
  )
  endforeach()

  foreach(support_lib_release IN LISTS Qt5_STATIC_LIBRARIES_SUPPORT_RELEASE)
    list(APPEND Qt5_STATIC_LINK_LIBRARIES
      optimized ${support_lib_release}
    )
  endforeach()

经过这一番折腾之后,我们的程序主界面终于出来了,可是还有一些不对劲的地方,比如窗口的图标不好用。再重新回顾下动态链接Qt时windeployqt.exe复制的dll,里面还有个DLL文件叫qico.dll。如法炮制把qico.dll对应的插件静态链接到程序上,再以此类推把其余缺少的插件补上。
最后,我们使用Qt静态库的程序终于能像使用Qt动态库的程序那样能正常运行了。因为Qt库的代码与主程序整合到了一起,所以主程序的大小会增大到15MB以上。不过与NW.js或者Electron比起来,这占的空间简直是太小了,而且整个程序全都是编译成汇编的原生代码,在性能上还是有天然优势的嘿嘿嘿……

总结

总结下静态链接Qt库遇到的问题和注意事项。

要把Qt库静态链接到自己的项目上,除了照常链接Qt库之外,还需要把Qt依赖的Windows库、Qt依赖的第三方库、程序依赖的Qt插件都嵌进程序里:
– Windows库很好解决,碰到“无法解析的外部符号”错误,只要去查google和msdn就能知道要需要链接上哪个Windows库。不过Qt库的依赖未知,所以只能碰到一个解决一个。
– Qt依赖的第三方库——harfbuzz、libpng等等,存储的位置也在lib目录下,名字的特点是前面都带小写的qt
– 要保证程序依赖的Qt插件都能正常运行,需要把Qt主项目包含的支持库也链接到主程序上,这些库名字的特点是文件名结尾都是*Support.lib
– 要找全项目所需的Qt插件,动态链接Qt库的配置下,windeployqt.exe复制过来的DLL列表是很重要的依据。要查询插件DLL的文件名,有一种可靠的方式是使用Qt带的qtplugininfo工具。要静态链接某个插件,把该工具获取到的插件名在C++代码里用Q_IMPORT_PLUGIN声明一下,之后把Qt插件对应的静态库版本链接到程序上,这个插件就能正确加载了。

最后,项目里用到Qt库还是直接引用Qt DLL省心省力,windeployqt提供一站式解决方案。正确静态链接Qt库要花的工夫比无脑复制DLL多得多。除非为了部署方便,想把程序做成单个可执行文件,否则在项目里引用Qt DLL无论如何都是首选,这样做还没有法律风险之虞。

用静态链接Qt库的方式使用Qt(上)

文中涉及的静态链接Qt的方法,都是仅限于在Windows下的情况。

首先说点废话套话。用Qt框架写GUI程序,要做出来能在其它电脑上运行的软件包是一件麻烦的事情。编译生成出来的程序通常情况下要带一大堆Qt的DLL文件,还需要用特定的目录结构组织起来。Qt虽然提供了windeployqt.exe可以一条命令就能把Qt应用程序依赖的Qt库复制到程序所在的目录,能保证程序正常运行,但是对一些简单的项目来说,程序目录下带了一堆DLL文件和qm文件一点也不简洁,编译出来的程序只有单个可执行文件,只复制一个EXE就是要比EXE附带一大堆DLL要简单。要让使用Qt的程序不依赖Qt的DLL就能运行,就得想办法把Qt库静态链接到程序中与程序合为一体。

软件许可协议问题

虽然代码的许可协议在天朝几乎等同于废纸,但是还是强调一下静态链接带来的法律风险。

Qt提供了两种许可方式:商业许可证或者LGPL许可证。在LGPL协议的情景下引用Qt的代码,动态链接Qt库时,自己的项目是闭源的没有任何的问题,只是修改了Qt代码时要把相应的修改也开源出来;静态链接Qt库时,避免违反协议要做的事情就麻烦的多,按照GNU FAQ(https://www.gnu.org/licenses/gpl-faq.en.html#LGPLStaticVsDynamic)的说法,闭源软件可以静态链接LGPL协议的库,但是必须提供途径使得用户可以方便地把LGPL库的修改链接到闭源的代码上,重组成新的程序;实际操作上就是要把调用LGPL库的代码都公开,并且公开代码生成出来的所有.obj文件或者.o文件,还要提供链接生成新程序的链接命令;这套操作实践起来是如此的困难以至于从省事的角度上不如直接将源码公开。必须有静态链接的需求,开发的还是闭源软件,又不差钱的情况下,最省事的方式就是向The Qt Company买Qt许可证为Qt的发展做贡献了,Qt的商业许可证完全没有LGPL这些条条框框的限制。

因此要静态链接Qt库到自己的项目时,请务必考虑这样做会带来的法律风险。

编译静态库形式的Qt库

Qt提供的安装包默认是没有静态库形式的Qt库的,编译成静态库的Qt库需要自己来编译。事实上动态库形式的Qt库确实也能满足绝大部分的需求,但咱们现在必须要静态库形式的Qt库,就是看Qt5Core.dll这堆DLL不顺眼。所以接着往下写。

我这里编译的Qt库的版本是5.9.8,在5.12版成为LTS版本之前,是最新的LTS版本。

获取Qt的源代码

虽然通过Qt的安装程序就自动安装Qt的源代码到电脑上,但是要考虑到Qt是一个巨大的项目,编译生成出来的中间文件大小也是非常可观不可小视的,我这里没有编译QtWebEngine,编译成功后源代码的目录大小就超过了10GB,之后install出来的Qt静态库所在目录的大小也有将近5GB,要编译QtWebEngine就涉及到了Chromium内核,占用的空间只会更大。而Qt安装程序会默认把Qt安装到操作系统所在的分区,由于磁盘空间不够编译失败是一件很尴尬的事情,所以建议把源代码复制到磁盘空间够的地方来编译,或者单独下载源代码然后解压到空间够的地方。

这里先下载Qt的源代码压缩包,然后解压:
https://download.qt.io/official_releases/qt/5.9/5.9.8/single/qt-everywhere-opensource-src-5.9.8.zip

编译的环境要求

Qt的这篇文档指出了Windows环境编译Qt库所需的工具:https://doc.qt.io/qt-5.9/windows-requirements.html

编译过程依赖的工具有:
– ActivePerl: 不太推荐5.28和以后的版本,5.28版废弃了一些Perl模块会导致编译不成功,编译OpenSSL 1.0.2的时候用了5.28版,在configure时会出错。
– Python: 用的是Python3,编译QtWebEngine时编译脚本不支持Python3,不编译QtWebEngine我这里用Python3是没有问题的,不过2020年Python2就停止维护了,Chromium的depot_tools工具集里面依赖的Python2什么时候能换掉啊!
– GPerf、Bison、Flex:Qt依赖的ANGLE库编译过程中使用了gnuwin32项目里的这些程序。为了编译的方便,Qt的源码包里默认提供了这些程序,在gnuwin32/bin目录下。
– OpenSSL: Qt使用OpenSSL提供对TLS的支持,Qt5.9只支持OpenSSL 1.0.2版,但是Qt5.12支持OpenSSL 1.1和更高的版本。

编译前请务必保证所有所需的工具加入到了PATH环境变量中。

Qt5.9在Windows下支持用MinGW/Clang/MSVC(2013/2015/2017)编译器编译,这里我用了vc2017的x86工具集编译Qt库。

Configure和编译

Qt的文档给出了在Windows下从源码构建的具体步骤:https://doc.qt.io/qt-5.9/windows-building.html

  • 先打开VC命令提示符,因为我要用vc2017工具集生成x86版的Qt静态库,所以我在开始菜单里选Visual Studio 2017/x86 Native Tools Command Prompt for VS 2017这个快捷方式打开命令提示符,这个快捷方式会运行vcvarsall.bat帮你把msvc对应的工具集配置好。

  • 把编译依赖的工具所在的路径加入到PATH环境变量,假设源码被解压到了C:\qt5-src

    SET PATH=C:\qt5-src\qtbase\bin;C:\qt5-src\gnuwin32\bin;%PATH%
    
  • 切换到源码目录,用下面的命令来配置编译所需的工具集:
    configure -static -skip webengine -opensource -debug-and-release -no-pch -nomake tests -nomake examples -force-debug-info -platform win32-msvc -opengl desktop -prefix F:/Qt-5.9-static-msvc2017-x86
    
    • 解释下configure命令的参数:
    • -static: 把Qt库编译成静态库。
    • -skip webengine: 不要编译QtWebEngine。文中也没有涉及这个模块的编译。
    • -opensource: 向configure脚本声明我们要按LGPL协议的要求使用Qt库。
    • -debug-and-release: 编译生成debug版和release版两份Qt库(windows only)。
    • -no-pch: 编译过程中不使用预编译头。
    • -nomake tests: 不要编译Qt的测试用例。
    • -nomake examples: 不要编译Qt带的示例程序。
    • -static: 把Qt库编译成静态库。
    • -force-debug-info: 为Release版的Qt库生成调试符号(编译成静态库大概不需要,去掉应该也可以)。
    • -platform win32-msvc: 用MSVC编译器编译Qt库。
    • -opengl desktop: 使用操作系统提供的OpenGL API。
    • -prefix F:/Qt-5.9-static-msvc2017-x86: 编译成功然后运行install命令时把Qt库安装到F:/Qt-5.9-static-msvc2017-x86这个目录。
    • 要添加TLS的支持,需要在configure脚本里指定预先编译好的OpenSSL所在的目录。如何编译OpenSSL1.0.2版可以参照这篇博客:http://p-nand-q.com/programming/windows/building_openssl_with_visual_studio_2013.html
    • 添加参数的方法是configure参数里加上这些:
      -ssl -openssl-runtime -I "<path-to-openssl-1.0.2>/include"

      • 参数的含义是:生成出的二进制文件要支持TLS;使用动态库形式的OpenSSL库,程序运行时会动态加载OpenSSL的DLL;把OpenSSL头文件所在的目录加入到configure的环境变量中。
    • configure脚本可以加的参数不止这些,输入configure --help 能看到所有参数对应的帮助。
  • 输入configure命令后脚本会提示你是否同意LGPL协议,输入y同意(再次请注意法律风险!):
    Microsoft (R) Program Maintenance Utility Version 14.16.27034.0
    Copyright (C) Microsoft Corporation.  All rights reserved.
    
    
    This is the Qt Open Source Edition.
    
    You are licensed to use this software under the terms of
    the GNU Lesser General Public License (LGPL) version 3
    or the GNU General Public License (GPL) version 2.
    
    Type 'L' to view the GNU Lesser General Public License version 3 (LGPLv3).
    Type 'G' to view the GNU General Public License version 2 (GPLv2).
    Type 'y' to accept this license offer.
    Type 'n' to decline this license offer.
    
    Do you accept the terms of either license? 
    
  • configure完成时脚本会输出一份编译Qt设定选项的摘要,可以核对下是否符合自己的想法:
Configure summary:

Build type: win32-msvc (i386, CPU features: sse sse2)
Configuration: sse2 sse3 ssse3 sse4_1 sse4_2 avx avx2 compile_examples f16c force_debug_info largefile debug_and_release release debug build_all c++11 concurrent dbus no-pkg-config release_tools static stl
Build options:
  Mode ................................... debug and release (with debug info); default link: debug; optimized tools
  Optimize release build for size ........ no
  Building shared libraries .............. no
..........................
Qt Multimedia:
  ALSA ................................... no
  GStreamer 1.0 .......................... no
  GStreamer 0.10 ......................... no
  Video for Linux ........................ no
  OpenAL ................................. no
  PulseAudio ............................. no
  Resource Policy (libresourceqt5) ....... no
  Windows Audio Services ................. yes
  DirectShow ............................. yes
  Windows Media Foundation ............... yes
  Media player backend ................... DirectShow

Note: Using static linking will disable the use of dynamically
loaded plugins. Make sure to import all needed static plugins,
or compile needed modules into the library.

Note: No wayland-egl support detected. Cross-toolkit compatibility disabled.

Qt is now configured for building. Just run 'nmake'.
Once everything is built, you must run 'nmake install'.
Qt will be installed into 'F:\Qt-5.9-static-msvc2017-x86'.

Prior to reconfiguration, make sure you remove any leftovers from
the previous build.
  • 之后就是编译流程,输入命令:
nmake

或者可以用Qt文档介绍的jom工具让CPU核心得到充分利用,输入命令:

jom -j <core count>

Qt是个庞大的项目,编译时间当然也会非常漫长。电脑的CPU是八核十六线程的锐龙,编译大概花了半个多小时……

  • 编译成功后,输入命令nmake install把生成出来的Qt静态库安装到configure脚本指定的目录。
    用jom工具时,命令改成jom install

编译这就完成了。在安装目录的lib子目录下就能看到生成出来的Qt静态库,Debug版的和Release版的都有。

Qt代码量虽然庞大,但是整个编译过程并没有脱离configure -> make -> make install这个套路。难点还是指定好正确的configure参数和安装好所需的依赖,只要按照文档配置好是没有什么阻碍的。

其实真正难受的地方是在使用Qt静态库上面,Qt文档只说了如何在qmake项目里使用静态库的Qt,Qt提供的CMake脚本并没有考虑到静态库的情况,这个之后再介绍踩的坑。

参考资料

感谢一些博客和Qt社区帖子对这篇文章和我想办法编译静态Qt库提供的帮助:

下篇 用静态链接Qt库的方式使用Qt(下)

给Electron做C++开发的那些坑

Electron这东西太火了,客户端程序全面Electron化DSSQ不可避,几乎所有新上线的客户端程序你打开安装目录都能看到熟悉的app.asarelectron.asar。在前东家干活做客户端,项目经理也执意要上Electron框架,说有那么多成功案例了,还有你看看咱公司别的项目组用Electron做的项目多漂亮,这东西一定很好用;而且你看公司这么多前端高手,你写界面多费劲啊,界面这边前端全包了就行了,你只要把后面的cpp代码维护好,给前端提供合适的API就行,你看怎么样。我的心情那是十万头羊驼呼啸而过。因为眼前的这个项目不仅仅是要用Electron,最终生成出来的程序还要在一套代码的基础上在Windows/Linux/Mac平台上全都能正常编译运行,甚至还要在某些机构因为安全可靠原因定制的Linux上跑。用Electron这就算了,这涉及到多个语言的交互,一套代码还要在三个平台上跑,Linux上甚至缺依赖,这坑不是一般的大啊!可是经理执意认为他的想法是正确的,毕竟那么多成功的案例(?),必须得走这个道路。虽然心中一万个不愿意,然而为了讨生活,只好硬着头皮按照项目经理的思路把项目推进下去。

最终基本达成需求的客户端是做出来了,也成功地实现了跨平台,在”安全可靠”的linux发行版上也能正常运行。客户端是在Electron框架下Web和C++代码混合的架构,Web代码负责界面展现,c++代码包揽了与服务端通信、存储本地数据的工作。做出来的成果虽然能用,但是无论是界面还是后台处理的代码,里面都有很多不尽如人意的地方,给客户展示的程序没有做到差强人意,直到我离职的时候,还有很多缺陷亟待解决。

同样,在开发和测试的过程中,也毫无悬念地踩了许多的坑,更发生了许多不愉快的事情。这里把当时遇到的坑和教训写下来做备忘,分享出来。

文章的内容都是基于自己的体验所写,随着文中涉及项目版本的迭代,事实情况可能有变化,所以仅供参考。

编译你的C++插件

写c和C++程序,编译是一道绕不过去的坎,所以首先来讲编译。

用过Electron的人应该都知道,Electron大体上是Node.js和Chromium的缝合怪。我的理解是让Node.js和Chromium共享同一个V8脚本引擎,然后再导出一些和客户端相关的API,例如操作窗口、任务栏等等,把这些都完成了,Electron这东西就做出来了。Chromium和Node.js大部分的代码都是用c++实现的,所以理所当然地也可以用C++为它们开发插件。

然后Electron给出的解决方案是什么呢?在Eletcron文档里搜native这个字眼,找到了这个:https://electronjs.org/docs/tutorial/using-native-node-modules,所以Electron给的方案是,你可以写Node.js的插件为你的客户端提供后台支持,写好的Node插件构建的时候链接到Electron带的Node.js实例上,你的JS代码就能调用你用C++写的插件了。那么再看看怎样用C++给Node.js写插件吧,你就找到了这个文档:https://nodejs.org/dist/latest-v10.x/docs/api/addons.html,它告诉你,给Node.js写C++插件需要用到node-gyp这个工具,这个工具调用的是gyp这个google写的构建工具来构建你的伟大的C++插件。之后你照着Node.js文档给的示例,成功写出了你的第一个Node插件;然后又照着Electron的文档把你的Node.js插件链接到了Electron上,之后又写了个HTML给Electron用,里面成功调用了你写的插件。

很有成就感,很轻松,是不是?先别激动,Node.js给的示例太简单,里面只寥寥几句说了gyp这个工具应该怎么用,实际的需求一定会有坑,官方文档是不可能面面俱到全部解答的。

就这样你开始写项目了。假如你负责的用了Electron的东西要在Windows上跑,然后你需要调用DirectShow来操作摄像头捕获自定义分辨率的图像。虽然Chromium自身就带了摄像头的支持,但是功能很有限,不能用来完成要求你去实现的需求,所以你不得不去用DirectShow(当然DirectShow过时了微软推荐用Media Foundation)。用DirectShow你必须在最终的程序里链接这两个库:quartz.libStrmiids.lib。怎么链接这两个静态库?Node.js没告诉你。既然如此那就去找GYP这个工具的文档吧。google第一条,https://gyp.gsrc.io,好!点开看了,你绝望地发现,google对你的疑问做出的答案是四个英文字母:TODO。玛德这文档里一坨坨的TODO和没写有什么区别吗?

必须要用node-gyp,还有最后一条路可走,那就是看GYP的源码找源码里的设定,但是GYP是用Python2写的,假如你不会Python那这就是个很大的困难,更何况Python2要被抛弃了。你的时间很有限的话,也不可能投入大部分精力去hack GYP这个没文档的东西。

绝望的你把目光投向了另一个著名的跨平台构建工具:CMake。CMake是个好东西,文档比GYP不知道强多少倍了,好多C++开源项目也在用CMake作为构建工具,这样也方便把第三方库集成进我们的项目,开心!但是还有一个问题,怎么让我们的Node插件正确地链接到Electron上?在npm库里找cmake的字眼,找到了个cmake-js模块,这个模块在介绍里把GYP毫不留情地损了一顿,然后开始推广CMake的种种好处,最后说明了下自己为帮助CMake项目链接到Node.js/Electron上做了什么样的努力,在configure你写的CMakeLists.txt时,cmake-js在CMake环境里自动帮你配置好了Node.js的头文件和链接库的全局变量,只要在CMake脚本里加上这两个环境变量就可以。看起来还不错。

于是你尝试用cmake-js构建你的插件,代码规模小的时候似乎还不错。代码量开始增长,需要重新组织项目结构的时候,事情突然变得微妙了起来。cmake-js把CMake的环境变量CMAKE_RUNTIME_OUTPUT_DIRECTORYCMAKE_LIBRARY_OUTPUT_DIRECTORY都给改写成了与node-gyp类似的形式,cmake-js的用意大概是想让插件直接生成到指定的目录下。但是假如你的插件项目是依赖cmake的install命令来安装,cmake-js的这个举动就会给你带来麻烦。

cmake-js还有个毛病,给node.exe构建c++插件的问题算是解决了,但是给electron构建插件,它用了一种丑陋的方式。要对特定的Electron版本构建插件,需要在package.json里定义一个”cmake-js”字段,里面写上node runtime的类型是electron,然后指定个版本号,这样cmake-js才能正确地构建出指定版本electron可用的二进制文件,如果你的整个项目需要更新Electron版本,你就需要同时改两个地方,有点麻烦同时又有遗漏的可能。

最后我参与的项目用了这样的构建路线:代码是用CMake组织起来的,因为一些库是动态链接到插件主库,需要自己来设计最终生成文件的目录结构,用到了CMake的install命令。这样node-gyp和cmake-js都不好用了。处理链接Node.js的问题时,我在CMake脚本里声明了外部变量,configure的时候必须正确指定Node.js头文件和链接库所在的路径。需要获取Node.js或者Electron的头文件和库时,node-gyp提供了node-gyp install命令能把指定版本的Node.js头文件和lib文件下载到在特定的目录,windows环境下保存的目录在%LOCALAPPDATA%\node-gyp\Cache\<版本号>,下载成功后就把头文件和静态库保存的目录传给CMake脚本。为Electron构建插件也是同理,只不过命令换成了node-gyp install --target=4.2.9 --arch=x64 --dist-url=https://electronjs.org/headers。为了方便,还写了一个JS脚本按顺序调用node-gyp和CMake把整个的构建过程封装了起来。

这样构建确实麻烦,但是假如你不决定把你的C++插件上传到npm库供所有人使用,这种构建方式对最终的用户没有任何影响,因为用户能接触到的只有二进制文件。

我的感觉是,给Node.js/Electron写C++插件,项目大到一定程度时,真正靠谱的只有自己手动处理Node.js实例的链接问题。node-gyp给开发者提供的支持太少,而cmake-js又包办了一些不该包办的东西,专门给Electron开发插件时也不那么好用。只有自己手动处理依赖才是最灵活的方式。手动解决依赖在你需要为Electron打补丁时,处理起来也最方便。

在Electron环境下调试C++插件的代码

用Electron写客户端,假如涉及到C/C++代码,大多数情况是写C++库的负责实现功能然后导出API,前端工程师负责实现界面,然后调用c++库导出的JS API。毕竟这就是Electron所谓的长处么,Web技术写界面厉害,效果非常绚丽,那就用Web写;C++性能高,和操作系统交互方便,有这些需求的代码用C++写。两者都能发挥自己的长处,听起来似乎非常美好。

然而又懂C++又懂JS框架的人凤毛麟角。所以通常情况下界面和后端肯定是要不同的人分别推进的。那么在c++这边,给JS提供的API实现好了,怎么调试代码来保证功能是正确实现的呢?这情况有点像写Web系统,只不过做Web系统给前端工程师们的东西不是一个HTTP URL,给Electron做C++插件给是给前端工程师用JavaScript能直接调用的接口。HTTP API还好说,有个Postman神器(居然也是用Electron框架做的!),用来测试HTTP接口非常好用。可是直接给JS API就没那么简单了。

按照Electron的思路,API实现完了,想要在Electron环境下测试API,大概流程是这样的:Electron是个GUI框架,意味着要测试接口,你得先用Electron画出个GUI出来,点界面里面的按钮来测试;具体实现是首先要写个index.js,里面让Electron创建一个浏览器窗口,之后让新建出来的浏览器窗口加载一个网页,网页里把按钮画出来,在网页附带的js脚本里再调用实现好的C++函数;之后要导出新功能的时候,继续在网页里加按钮,然后按钮调用新加的方法;每次新加功能要测试的时候都要在测试脚本(网页)里修改两个以上的地方,真的很麻烦。C++代码出错了要现场调试附加到Electron的时候,要找到已经加载C++插件的进程也很麻烦:插件是在主进程中加载时比较好办,要调试的进程就是为了测试而启动的那个进程;插件是在html中直接引用的时候,加载C++插件的Electron进程是渲染进程,这时候就麻烦了,你不得不去挨个试Electron主进程fork出来的子进程,看哪个加载了自己写的C++模块,或者Windows下去用Process Explorer去看。之后才能开展调试的工作。

只用c#/c++做客户端的时候,我心中理想的调试方式是在Visual Studio里面加好断点,按F5,等代码编译完,之后就可以开展调试的工作,很舒服。那么调试给Electron写的C++代码,真的就不能做到这样轻松吗?虽然是给Electron写插件,但是我们调用的都是Node.js的API,也一直是参照Node.js文档给的指导写的C++代码,所以完全可以为Node.js编译写好的C++代码嘛!给Node.js写C++插件,要测试的时候只要写好一个js测试脚本就够了。调试也简单,在vs里面把启动进程设成node.exe,然后在启动参数里启动之前写好的测试脚本,这样设置好调试选项,按F5就能直接调试C++插件。有了方便和可靠的调试方法,C++代码的大部分问题都能解决掉。

开发时调试代码的问题,用这种变通的方式算是圆满解决了。不过还是得研究如何在Electron环境下调试,因为与前端工程师们的代码整合的时候还是要面对Electron环境,前端在Electron环境下发现了C++代码的BUG,在Node.js环境下没发现,于是还是得去想出一种方法去调试解决它。要在前端工程师开发界面的环境下调试C++代码,由于前端配置开发环境方式多种多样,这种环境下的调试没有唯一的解决方案,不过主要的思路还是去想办法附加上正确的Electron进程,在vs的调试选项里启动electron.exe然后调试是一件不现实的事情。例如前东家的项目里用到了electron-vue,electron-vue项目启动开发模式时是用Webpack启动了一个本地Web服务器,之后启动Electron的时候是从本地Web服务器加载界面元素的,这就意味着单纯启动Electron.exe是不可能正确启动前端工程师的开发环境的。

假如能成功地附加到正确的进程,除了初始化相关的代码,其余的代码应该都能调试到。要调试插件初始化相关的c++代码,如果是在主进程加载c++插件,Electron提供了--inspect-brk启动参数,可以让程序在入口点JS之前中断运行等待调试器附加。这时候把vs调试器附加到electron.exe上,在C++的相关初始化代码处加断点,之后再让Node.js的调试器连接到inspect-brk参数启动的调试端口,程序就会中断在刚才加的C++断点上;如果C++插件是在渲染进程上加载的,暂时没有什么好办法。

JS代码怎样引用C++插件

Electron程序启动时会启动多个子进程,主进程负责管理窗口消息和处理与系统有关的界面相关的功能,渲染进程负责解析网页元素,然后将解析结果传回给主进程显示。所以要加载C++模块也存在两种可能:在主进程和渲染进程里加载。这两种加载方式各有利弊,实际用的时候都有一些坑。

渲染进程中引用C++插件非常容易,只要在网页加载的JS代码里直接用require('plugin')加载模块就行。这种方式引用C++代码的优点是效率高,网页端的JS代码能直接读取到C++代码返回的对象。还有得益于现代浏览器的多进程架构,C++代码出问题了,整个Electron进程不至于全部崩溃,只有当前网页对应的渲染进程会崩溃退出,这时主进程会收到渲染进程崩溃的通知,然后主进程可以立刻恢复崩溃的页面的显示。有利必有弊,因为每个渲染进程在同一时间内只会负责处理一个页面的显示工作,所以调用主进程的接口新建一个BrowserWindow打开新窗口时,会有一个新的渲染进程来负责新页面的显示,也就意味着在新窗口加载的页面中require C++模块相当于又加载了一次。你是没办法访问之前已经加载的那个插件的实例的。假如有要求C++模块同时只能存在一个实例,这里加载了多个实例也会导致不可预知的后果出现。另外的一个坑是JS代码这样引用插件,C++模块中不能有让JS陷入挂起状态的代码,例如C++模块导出的方法会弹出模态对话框,模态对话框会阻塞JS脚本的运行直至对话框关闭为止,渲染进程中调用这个方法会就会让页面陷入未响应的状态。C++代码中有这样的方法时,这个模块就必须在主进程中引用。

主进程中引用C++模块,相比在渲染进程中引用要麻烦点。首先当然是要在主进程的入口点js中require要require的模块,之后要把require的结果注册成全局变量。网页中的JS脚本要调用已经在主进程加载的模块时,用类似这样的语句:

  const module = require('electron').remote.getGlobal('module');

之后调用module导出的方法时,调用的就都是Electron主进程中加载的C++模块,而不会在渲染进程中重新加载一遍模块。

在主进程中引用C++模块,能保证模块的实例只会初始化一次,所有的页面访问到的C++插件都是同一个实例。上文中提到的不能在渲染进程中引用的C++模块,在主进程中运行也是没有问题的。要调试C++代码的时候,找到正确的Electron实例也很方便。这样做也有显而易见的缺点:require('electron').remote引用主进程的代码逻辑是依靠进程间通讯进行的,开销比直接访问处于渲染进程中的JS对象大很多,频繁访问主进程中的对象会让程序的性能变得非常差,大部分的时间会耽误在等待主进程的IPC消息上;C++代码在主线程卡死时,Electron的所有窗口都会变成未响应状态;C++代码里面出了问题让宿主Electron进程崩溃时,这种引用方式会让整个应用程序崩溃,没有恢复的机会。所以要在主进程中引用Node.js模块时,出于性能考虑必须想办法减少渲染进程访问主进程对象的频率,对C++代码质量的要求也更严格,毕竟交给客户的产品不能频繁未响应或者崩溃退出。

开Electron项目的坑,具体要使用哪种引用方式,需要根据具体的情境来决定。我参与的项目最初出于保证唯一实例和调试方便的考虑,把C++模块放在了主进程加载。但是看Discord是把所有的C++模块都放到了渲染进程上去加载,有点不太明白Discord是怎么做的。后来出于进程间通讯性能太差和崩溃容易处理的考虑,一直在尝试把自己的C++代码也放到渲染进程上加载,可惜这部分工作直到离职还没做完。

Electron 4.0 之前和之后

4.0前和4.0后的区别

Electron版本4.0之前和之后的版本差异非常大,这里的差异指的是C++这边看到的差异。

Electron 4.0之前,构建代码使用的工具是和Node.js一致的GYP,就是那个用Python2写的没有文档的GYP。项目附带的Node.js是集成在版本库中的,位置在vendor/node下,要编译这时期的Electron代码,还需要用到GitHub团队为Electron量身定制的另外一个库:libchromiumcontent。这个库按照Chromium的Content API把Chromium浏览器封装成了库,然后Electron本身调用这个库实现对Chromium的集成。没有修改配置的情况下,编译脚本会从github上拉取这个库的编译好的二进制文件。Debug模式下libchromiumcontent是编译成动态库,Release下是编译成静态库与Electron主程序集成到一起。郁闷的是这个二进制文件不包含调试符号,导致调试Debug版的Electron的源码时,会发现找不到所有与Chromium相关的C++代码对应的符号,也就没法跟踪Chromium相关的代码。要调试Chromium的部分,就得想办法先编译出来libchromiumcontent,之后再让Electron的编译脚本用自己编译出来的libchromiumcontent。整个编译过程非常的tricky,碰到错误了还需要读懂编译用的Python2脚本然后想办法改(一般是网络错误,国内的网络环境大家都懂)。

再说下编译出来的程序。这时的Electron用Release配置编译出来的二进制文件大概是这样的情况:Electron主程序是动态链接C运行库的,解压后的Electron目录可以看到一堆api-ms-win-crt-xxxx.dll;Node.js库被编译成动态库文件链接到Electron主程序上;Chromium内核使用的V8脚本引擎在Node.js编译生成的动态库内,与Node.js共享同一个。整个项目在Windows下编译代码用的是MSVC,在linux和macOS下用的是clang。这时候开发C++插件实际上调用的都是Node.js的动态库导出的方法,在windows下是node.dll,linux下是libnode.so,mac下是libnode.dylib。提供的SDK这方面,用node-gyp下载过来的Electron头文件与Node.js下载过来的头文件的目录结构也不一样。构建C++代码要手工处理Node.js依赖的时候要分别处理Node.js和Electron两种情况。

Electron版本4.0之后,编译的工具链和最终生成的文件发生了翻天覆地的变化。最重大的变化是构建工具从GYP换成了Chromium项目正在使用的GN,理由和我当时的体验差不多:GYP没有文档、工具本身用Python2实现效率低、迁移到GN工具能更方便地跟进Chromium项目的步伐整合新版本的Chromium内核。其次是为Electron量身定制的libchromiumcontent库被废弃了,现在的编译步骤是用depot_tools里面的工具gclient先把chromium源码checkout过来,之前做了点配置让gclient顺带把Electron也checkout下来。之后build的时候其实build的是Chromium项目,Electron项目只是其中的一个子项目,但是之前checkout的过程中在Chromium源码里打上了Electron的私货。因为整个build的过程会直接拉取Chromium源码而不是像以前那样下载编译好的文件,所以从源码构建Electron对梯子速度和稳定性的要求变得更高了。

这时候构建出来的Electron程序与以前比较也是大不一样。Node.js编译成了静态库集成进了Electron主程序,Node.js的API改为由主程序导出的符号来提供。同样,主程序C运行库也改成了静态链接。从官网下载的release解压后再也看不到Universal CRT运行库和Node.js的动态库了。编译Electron使用的编译器在Windows/Mac/Linux三个平台上被统一成了Clang。至于用node-gyp下载过来的SDK,Electron提供的Node.js头文件改成了与Node.js一致的目录结构,Windows下另外提供的node.lib实际上是编译生成的electron.lib。这里有坑,后面会讲到。

所以如此大的变化对C++插件有什么影响呢。当时我发现的这个带来的坑都是在Windows上出现的。

新版本Electron正确加载C++模块必须做的trick

第一个坑是Electron的文档明确指出的,相应的文档在这里:https://electronjs.org/docs/tutorial/using-native-node-modules#a-note-about-win_delay_load_hook。新版本把Node.js静态链接到主程序上,Node.js的API都由electron.lib来导出,为了与之前的工具链兼容,下载过来的lib文件名仍然是node.lib。但是由此带来了一个弊病,生成出来的为Electron编译的C++插件在PE导入表里依赖的不是期望的electron.exe而是node.exe。这个问题会让C++扩展无法正确被Electron加载。Electron给的解决方案是,如果C++插件是用node-gyp编译的,binding.gyp文件里必须把win_delay_load_hook的值设为true;如果要自己处理Node.js依赖问题(原来Electron官方也知道让C++代码链接到Electron上是一件非常tricky的事情啊!),需要保证生成C++插件的链接器做了以下命令做的事情:

 link.exe /OUT:"foo.node" "...\node.lib" delayimp.lib /DELAYLOAD:node.exe /DLL
     "my_addon.obj" "win_delay_load_hook.obj"

简单解释下,就是要打开DLL的延迟加载功能,然后需要把node-gyp附带的一个C++源文件win_delay_load_hook.cc加入到编译过程中。
用了CMake处理编译时,要完成这个事情,需要在CMakeLists.txt里面加上类似这样的内容:

# 源文件要加上win_delay_load_hook.cc
add_library(plugin SHARED
  win_delay_load_hook.cc
)

# 添加HOST_BINARY宏定义,这个宏有什么用途后面会说
target_compile_definitions(plugin PRIVATE
  HOST_BINARY="node.exe"
)

# 链接node.lib和delayimp.lib
target_link_libraries(plugin
  node.lib
  delayimp.lib
)

# 设置链接器参数
set_target_properties(plugin PROPERTIES
  LINK_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} /DELAYLOAD:node.exe"
)

平白无故多的宏定义是什么意思呢?来看下win_delay_load_hook.cc写了什么:

/*
 * When this file is linked to a DLL, it sets up a delay-load hook that
 * intervenes when the DLL is trying to load the host executable
 * dynamically. Instead of trying to locate the .exe file it'll just
 * return a handle to the process image.
 *
 * This allows compiled addons to work when the host executable is renamed.
 */

#ifdef _MSC_VER

#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif

#include <windows.h>

#include <delayimp.h>
#include <string.h>

static FARPROC WINAPI load_exe_hook(unsigned int event, DelayLoadInfo* info) {
  HMODULE m;
  if (event != dliNotePreLoadLibrary)
    return NULL;

  if (_stricmp(info->szDll, HOST_BINARY) != 0)
    return NULL;

  m = GetModuleHandle(NULL);
  return (FARPROC) m;
}

decltype(__pfnDliNotifyHook2) __pfnDliNotifyHook2 = load_exe_hook;

#endif

代码的意思应该很清楚,加了延迟加载功能后,在DllMain函数执行时操作系统会通知win_delay_load_hook.cc里面定义的回调函数。回调函数检测到要延迟加载的DLL名字是node.exe时,会直接返回当前进程的句柄(GetModuleHandle(NULL);)。从而让Electron能成功地链接给node.exe的C++插件载入到内存。这里面的HOST_BINARY宏正常情况下是未定义的,所以前面的CMake脚本要加上这个宏定义。

std::fstream让C++模块崩溃

第二个坑是Electron静态链接Node.js和C运行库带来的副作用。静态链接Node.js使得部分不该导出的C++符号被错误地导出来,其中包括但可能不限于C++标准库相关的东西,例如std::stringstreamstd::fstream。假如C++插件动态链接了C/C++运行库,在使用std::fstream这类C++标准库中的类时就会有很神奇的事情发生。初始化fstream的实例时,编译出来的机器指令错误地调用了electron.exe导出的std::fstream构造函数的实现,对文件流一番操作,准备关闭文件流时,代码又调用了动态链接的C++运行库导出的std::fstream析构函数的实现。因为两个C++运行库实例(electron自带的和C++插件动态链接的)使用了不同的操作系统堆,所以调用析构函数会让程序立刻Crash掉。但是很神奇的是用node-gyp编译的C++插件就没有这个问题,打开node-gyp生成的vcxproj文件,原来node-gyp编译C++插件默认用的选项是静态链接C/C++运行库,这时用的fstream就都是C++插件自带的fstream实现,也就不会因为使用的堆不同而Crash。

为什么Electron 4.0之前就没有这个问题?那时候Electron主程序还是动态链接C/C++运行库的,新加载的C++插件如果是动态链接C/C++运行库就会直接使用在内存里的C/C++运行库DLL实例,也不会存在操作系统堆不一致导致Crash的问题。

要解决这个问题倒是可以把C++插件使用的C/C++运行库也改成静态链接的,在CMake里面这样做就可以:

target_compile_options(plugin PRIVATE $<$<CONFIG:Debug>:/MTd>)
target_compile_options(plugin PRIVATE $<$<CONFIG:MinSizeRel>:/MT>)
target_compile_options(plugin PRIVATE $<$<CONFIG:RelWithDebInfo>:/MT>)
target_compile_options(plugin PRIVATE $<$<CONFIG:Release>:/MT>)

但是静态链接C/C++运行库还会引入其它的问题,特别是在C++插件依赖第三方库的情况下,直接修改成静态链接是有很大概率碰上链接错误的,没条件修改第三方库的链接方式那就只好凉拌了。幸运的是在Electron 4.2.x和以后的版本里这个问题被修复了https://github.com/electron/electron/pull/18281

Electron静态链接C/C++运行库还有一个小坑,之前Electron自带了C/C++运行库的DLL,于是最终生成安装程序部署安装的时候没必要安装VC++运行库。现在改成静态链接Electron就没必要自带C/C++运行库DLL了。所以假如插件是动态链接C/C++运行库,那么就必须在安装程序中去设法安装VC++2015或者2017的运行库,否则如果目标系统没有装运行库,C++插件照样会加载失败。

写这个文章的时候,在Electron 6.0的release notes里看到官方团队已经决定停止支持3.x版本了。所以建议是能用4.x以后的版本就用,二次开发也最好基于这之后的版本,最好是用4.2.x以后的版本,开发的坑确实少很多。

与前端工程师们配合工作

在Electron上开发,假如互相之间没有技术栈的交集,写cpp的和写JS的前端工程师互相沟通起来简直就是互相对牛弹琴。写cpp看不透层出不穷的前端框架,写前端的认为cpp高深莫测看不懂Node.js的C++ API。两个群体之间唯一的共同话题几乎只有cpp给Node.js提供的JavaScript形式的API。所以互相之间约定好API的声明是非常重要的事情。然而两者在这上面要担负的责任却不是对等的。

写C++的要负责API的实现,把API测试正确,还要处理好编译让前端工程师能顺利调用C++代码。前端们只需要拿到C++模块的二进制文件,负责调用导出的API,然后看预期结果对不对就行,前端工程师发现API的调用结果与预期不符的时候,还是会把锅甩到C++工程师头上。前端已经开始使用C++模块导出的API时,C++这边API的声明发生了与之前不兼容的变化,已有的前端代码调用就会出错。这时还得麻烦前端工程师来修改对应的调用,可是有时候时间紧急还是必须得写C++的自己去改,这就很尴尬,自己挖的坑还得自己填。种种不如意的情况都要求写C++模块的人去学习前端工程师们的使用的框架和代码,对能力的要求相对要高一些。

反过来前端工程师在这方面也不轻松,虽然说前端工程师需要的只是C++模块的二进制文件和API的声明,但是要看懂C++模块的编译方法以及配置好工作环境也是个不容易的事情。我参与项目的时候,几乎每新来一个前端加入开发,我就得亲自去帮忙在他的电脑上面把VC2017、Python2/3、CMake和环境变量都安装配置一遍,虽然项目的readme里面已经把环境搭建的方法写得自认为很详细了,但是在各种电脑上面还是有很多未知的坑,尤其是VC++2017的安装,几乎没有装对的,到头来我还得亲自跑腿去帮忙。为了从支持的无底洞里面解脱出来,强烈建议写C++的为C++模块配置一个持续集成环境,这样前端工程师们就不需要为编译操心了,只要从网站上下载最新的二进制文件就行,大家都轻松。

结论

Electron这个东西确实很优秀很强大,毕竟把Chromium和Node.js两个大坑整合到了一起,还抽象出了一组助力客户端开发的基本API,这本身就是一件了不起的事情,至少以我而言我是没有能力做这件事。抛开弊病不谈,Discord、Skype、Postman、VSCode……甚至Visual Studio安装程序都改用Electron来做界面了。那么多成功的案例摆在那里,表明这东西确实是受到大家认可的。大量的前端工程师们透过Electron这个框架进入了客户端开发的领域,为一切都可以用JavaScript实现的愿景立下了汗马功劳。所以这东西真的有那么好吗?虽然在JavaScript那边看起来很光鲜,但是客户端的逻辑很多情况下要比Web页面复杂的多,Electron更是塞了个高度复杂的Chromium内核,只用JavaScript时就已经需要弄懂Electron的进程间通讯是怎么回事。在C++代码这边,更是一个混乱邪恶的世界,把C++代码正确的链接到Electron上已经是一件tricky的工作,Electron环境和单纯的Node.js环境之间更有巨大不同,还有无数的坑等待去发掘。所以很明显Electron虽然看起来易用,但是实际上对开发者的要求非常高。个人做一些小东西可能还不错,公司真的要拿它做复杂的项目,必须得有一位懂Web前端也懂Node.js的大佬坐镇,涉及到其它语言的开发,更是需要去有人懂Chromium和Node.js的源代码或者有这个能力去攻克它们。否则这东西并不会表现的像它承诺的那样好用,碰到一些问题真的是完全没法找原因,项目也会一而再再而三的延期。

最后用两个自问自答来表达对Electron的看法。

有人要高薪找懂Electron的人来开发你愿意去吗?我的回答是愿意。谁会和钱过不去?既然踩那么多坑了,自然有经验去掌控新项目,项目的进展也可能会更快因为可能能少走很多弯路,这是件双赢的事情。

个人独立写小东西会首选Electron框架吗?我的回答是坚决不用。先不说两种语言交互本来就比单一语言开发要复杂这件事,单是眼花缭乱的前端框架就已经能让我崩溃了。抛开前端框架不谈,Electron这东西弄个Hello World都复杂的要命,Qt GUI的Hello World不超过20行C++代码就能解决,《Windows程序设计第五版》里面的那个Windows Hello虽然甩的概念多但是也都可以在同一个C++源文件内解决,哪个都比Electron简单。Electron对内存和硬盘空间的需求也实在是太饥渴。写客户端对界面没要求的话,我还不如用Qt或者WPF这样的框架,起码我对这些更熟悉,它们也不像Electron那样臃肿。

uv_async_t让Node.js脚本停止运行

在公司搬砖给Node.js写C++插件的时候遇到了一个BUG:在为Node.js导出的C++类中,如果存储了uv_async_t对象,会让一些Node.js的async/await操作不能正常工作。套上async/await,代码执行到C++代码通过回调函数异步返回给JS数据之后,脚本就停止工作了。

C++部分的代码大致是这样的:

#include <nan.h>
#include <memory>
#include <thread>
#include <vector>

// simple wrapper for uv_async_t
class uvAsyncEvent
{
public:
    uvAsyncEvent(void* context, uv_async_cb callback)
    {
        m_pAsyncHandle.reset(new uv_async_t());
        m_pAsyncHandle->data = context;
        uv_async_init(uv_default_loop(), m_pAsyncHandle.get(), callback);
    }
    ~uvAsyncEvent()
    {
    }
    void* GetContext() const
    {
        return m_pAsyncHandle->data;
    }
    void Notify()
    {
        uv_async_send(m_pAsyncHandle.get());
    }

private:
    struct Deleter
    {
        void operator()(uv_async_t* asyncEvent) const
        {
            if (asyncEvent)
            {
                uv_close((uv_handle_t*)asyncEvent, Deleter::uvCloseCallback);
            }
        }

        static void uvCloseCallback(uv_handle_t* handle)
        {
            delete (uv_async_t*)handle;
        }
    };
    std::unique_ptr<uv_async_t, Deleter> m_pAsyncHandle;
};

// a c++ class exported to Node.js which holds uv_async_t handles
class Hang
    : public Nan::ObjectWrap
{
public:
    static NAN_MODULE_INIT(Init)
    {
        v8::Local<v8::FunctionTemplate> tpl = Nan::New<v8::FunctionTemplate>(New);
        tpl->SetClassName(Nan::New("Hang").ToLocalChecked());
        tpl->InstanceTemplate()->SetInternalFieldCount(1);

        Nan::SetPrototypeMethod(tpl, "start", Start);
        Nan::SetPrototypeMethod(tpl, "stop", Stop);
        Nan::SetPrototypeMethod(tpl, "setCallback", SetCallback);

        constructor.Reset(Nan::GetFunction(tpl).ToLocalChecked());

        Nan::Set(target, Nan::New("Hang").ToLocalChecked(), Nan::GetFunction(tpl).ToLocalChecked());
    }

private:
    Hang()
        : m_pEvent(std::make_unique<uvAsyncEvent>(this, asyncCallback)) // async/await hangs
        , m_bRunning(true)
    {
    }
public:
    ~Hang()
    {
    }

    static NAN_METHOD(New)
    {
        if (info.IsConstructCall()) {
            // Invoked as constructor: `new Hang(...)`
            Hang* obj = new Hang();
            obj->Wrap(info.This());

            info.GetReturnValue().Set(info.This());

            v8::Local<v8::Object> context = v8::Local<v8::Object>::Cast(info[0]);
        }
        else {
            // Invoked as plain function `Hang(...)`, turn into construct call.
            const int argc = 1;
            v8::Local<v8::Value> argv[argc] = { info[0] };
            v8::Local<v8::Function> cons = Nan::New(constructor);
            v8::Local<v8::Object> result =
                Nan::NewInstance(cons, argc, argv).ToLocalChecked();
            info.GetReturnValue().Set(result);
        }
    }

    static NAN_METHOD(SetCallback)
    {
        Hang* obj = ObjectWrap::Unwrap<Hang>(info.Holder());

        if (!info[0]->IsFunction())
        {
            Nan::ThrowTypeError("arg 1 is not function");
            return;
        }

        obj->m_evCallback.reset(new Nan::Callback(info[0].As<v8::Function>()));
    }

    static NAN_METHOD(Start)
    {
        Hang* obj = ObjectWrap::Unwrap<Hang>(info.Holder());

        obj->m_bRunning = true;

        struct ThreadRunner
        {
            ThreadRunner(Hang& h)
                : m_obj(h)
            {
            }

            // Run
            void operator()()
            {
                while (m_obj.m_bRunning) {
                    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
                    m_obj.m_pEvent->Notify();
                }
            }
        private:
            Hang& m_obj;
        };

        if (!obj->m_workThread)
        {
            obj->m_workThread = std::make_unique<std::thread>(ThreadRunner(*obj));
        }

    }

    static NAN_METHOD(Stop)
    {
        Hang* obj = ObjectWrap::Unwrap<Hang>(info.Holder());

        obj->m_bRunning = false;
        if (obj->m_workThread)
        {
            obj->m_workThread->join();
            obj->m_workThread.reset();
        }

        obj->m_pEvent.reset();
    }

private:
    static void asyncCallback(uv_async_t* handle)
    {
        Hang* pThis = (Hang*)handle->data;
        Nan::HandleScope scope;

        auto ret = Nan::New("async callback").ToLocalChecked();

        v8::Local<v8::Value> argv[] = { ret };

        Nan::Call(*pThis->m_evCallback, 1, argv);

    }

private:
    std::unique_ptr<uvAsyncEvent> m_pEvent;
    std::unique_ptr<Nan::Callback> m_evCallback;

    std::unique_ptr<std::thread> m_workThread;
    bool m_bRunning;

private:
    static Nan::Persistent<v8::Function> constructor;
};
Nan::Persistent<v8::Function> Hang::constructor;

// an asynchronous function for test
NAN_METHOD(testAsync)
{
    class Worker : public Nan::AsyncWorker
    {
    public:
        Worker(Nan::Callback* callbk)
            : Nan::AsyncWorker(callbk)
        {
        }

        void Execute() override
        {
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }

        void HandleOKCallback() override
        {
            Nan::HandleScope scope;

            int argc = 1;
            std::unique_ptr<v8::Local<v8::Value>[]> argv(new v8::Local<v8::Value>[argc]());

            argv[0] = Nan::New("async task result").ToLocalChecked();

            Nan::Call(*callback, argc, argv.get());
        }
    };

    Nan::Callback* callbk = new Nan::Callback(Nan::To<v8::Function>(info[0]).ToLocalChecked());

    Worker* pWorker = new Worker(callbk);
    Nan::AsyncQueueWorker(pWorker);
}

NAN_MODULE_INIT(InitAll) 
{
    Hang::Init(target);
    Nan::SetMethod(target, "testAsync", testAsync);
}

NODE_MODULE(hang, InitAll)

用来测试的脚本:

const { Hang, testAsync } = require('./build/Release/hang');
const { EventEmitter } = require('events');

let h = new Hang();

// calling start method or not does not affect the problem
// h.start();

function doTestAsync() {
  return new Promise((resolve, reject) => {
    testAsync((data) => {
      // This line of code got executed without any problems
      // after 100ms of the function call(according to the C++ code)
      console.log(data);
      resolve(data);
    });
  });
}

function doSetTimeoutAsync() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let data = 'timeout async';
      console.log(data);
      resolve(data);
    }, 100)
  });
}

// this is ok
//testAsync((data) => {
//    console.log(data);
//});

async function testHang() {
  let asyncRet = await doSetTimeoutAsync();

  // no problem
  console.log("after async call1");
  let asyncRet2 = await doTestAsync();

  // async/await operation blocks due to the creation of "hang"
  // I paused the execution via the debugger, then continue, 
  // Dramatically this line gets executed
  console.log("after async call2");
  let asyncRet3 = await doTestAsync();
  console.log("after async call3");
  let asyncRet4 = await doTestAsync();
  console.log("after async call4");

  h.stop();
}


testHang();

背景和BUG表现

公司搬砖的项目里,要建立一个TCP长连接,用于与服务端之间通信和保持在线状态。通常情况下会单独启动一个线程用来维护连接,现在要把从服务端接收到的数据传给JS,一个很明显的思路是在工作线程里调用uv_async_send来通知主线程(能调用V8的API的线程)有数据传过来。

代码本身是简化后的模型。C++模块导出了一个Hang类和一个testAsync函数,Hang类对应的C++类管理了一个uv_async_t对象,testAsync函数使用了Nan::AsyncWorker用于测试Node.js的异步调用。

发生的问题是:直接通过命令行运行脚本,测试脚本会卡在调用doTestAsync之后,after async call2不会被输出出来。在vscode中启用调试模式时得到的结果也一样。程序的输出是这样的:

timeout async
after async call1
async task result

诡异的是,在vscode中把脚本暂停,脚本成功中断在了Node.js内部的代码里,然后继续运行,后面的输出居然输出了出来。然后脚本继续卡在第二次调用doTestAsync之后。如此暂停和继续运行,测试脚本居然能一路跑下去。

如果把断点停在testAsync提供的回调函数里,会发现程序走过resolve(data)之后就不能继续运行了。

把脚本本身改写成回调金字塔的样式,就像下面的代码一样,也是没有问题的。但是这样真的是太丑了,而且async/await是官方推崇的方式,总算能彻底解决回调金字塔地狱的问题,因为出现了这状况丫的还得改回去……

testAsync((data) => {
  console.log(data);
  console.log("after async call2");
  testAsync((data) => {
    console.log(data);
    console.log("after async call3");
    testAsync((data) => {
      console.log(data);
      console.log("after async call4");
    });
  });
});

尝试解决

首先用二分法找问题原因,逐个排查最近的修改,发现去掉了创建uv_async_t对象的代码,这个问题就不再出现了。

于是整理了下代码,写了个最小的可以必现这个问题的示例。信心满满地向Node.js提交了个issue,然后这个issue一小时后被项目管理者打入了nodejs/help的冷宫,而且没人回答我提出的问题。

既然没有人愿意回答我的问题,那就自己干!

不管怎么说先build个Debug版本的Node.js程序,Release版本经过优化,找错没有那么容易。签出代码库,按照vcbuild.bat的指示来构建,好了,Debug版的Node.js程序有了。因为ABI的问题,又手动建了个vcxproj项目把刚才的c++代码链接到了debug版生成出来的node.lib上。

然后开始调试。先找下Promise是在哪里实现的,Node.js中用JS写的实现都是放在lib目录下。在这个目录下搜索Promise字样,无果。然后在C++代码里继续搜,发现v8.h中声明了一个Promise类。Promise类里面声明了个Resolve方法。于是抱着试试看的心态在这个方法的实现(在deps/v8/src/api.cc)里加了断点。然后在vs的调试选项里面让Debug版的Node.js进程执行我们的测试脚本,脚本照样还是卡住了,api.cc里面的断点没有进去,看来找的Promise的实现的位置不对。

继续找Promise的实现,看到Promise类的声明里有个PromiseState枚举类型,应该是用来表示Promise对象当前的状态的,心想v8脚本引擎的内外应该都用同一种类型,用”查找所有引用”找了一下,原来v8脚本引擎内部还有个v8::internal::JSPromise类(在deps/v8/src/objects/js-promise.h里)。F12进去,看到了这样的代码,在deps/v8/src/objects.cc里面,这个应该是我们要找的Promise.resolve的实现了:

Handle<Object> JSPromise::Fulfill(Handle<JSPromise> promise,
                                  Handle<Object> value) {
  ...
  // 6. Set promise.[[PromiseState]] to "fulfilled".
  promise->set_status(Promise::kFulfilled);

  // 7. Return TriggerPromiseReactions(reactions, value).
  return TriggerPromiseReactions(isolate, reactions, value,
                                 PromiseReaction::kFulfill);
}

// static
MaybeHandle<Object> JSPromise::Resolve(Handle<JSPromise> promise,
                                       Handle<Object> resolution) {
  Isolate* const isolate = promise->GetIsolate();

  isolate->RunPromiseHook(PromiseHookType::kResolve, promise,
                          isolate->factory()->undefined_value());

  ...

  // 7. If Type(resolution) is not Object, then
  if (!resolution->IsJSReceiver()) {
    // a. Return FulfillPromise(promise, resolution).
    return Fulfill(promise, resolution);
  }

  ...

  // 10. Let thenAction be then.[[Value]].
  // 11. If IsCallable(thenAction) is false, then
  if (!then_action->IsCallable()) {
    // a. Return FulfillPromise(promise, resolution).
    return Fulfill(promise, resolution);
  }

  // 12. Perform EnqueueJob("PromiseJobs", PromiseResolveThenableJob,
  //                        «promise, resolution, thenAction»).
  Handle<PromiseResolveThenableJobTask> task =
      isolate->factory()->NewPromiseResolveThenableJobTask(
          promise, Handle<JSReceiver>::cast(then_action),
          Handle<JSReceiver>::cast(resolution), isolate->native_context());
  if (isolate->debug()->is_active() && resolution->IsJSPromise()) {
    // Mark the dependency of the new {promise} on the {resolution}.
    Object::SetProperty(resolution,
                        isolate->factory()->promise_handled_by_symbol(),
                        promise, LanguageMode::kStrict)
        .Check();
  }
  isolate->EnqueueMicrotask(task);

  // 13. Return undefined.
  return isolate->factory()->undefined_value();
}

在这个函数里加了断点,重新调试。发现在程序卡住之前,Node.js居然没有在JSPromise这里中断!甚至在after async call1输出之前,Node.js进程也没中断在刚才在objects.cc中加的断点上。

如果换一种调用JS脚本的方式:在shell模式下运行Node.js,输入命令require('./hang.js')调用脚本,在JSPromise里加的断点生效了,但是程序之后卡住的时候,仍然没进到加的断点上。

两种运行脚本的方式得到不同结果的原因,大概是在命令参数里直接运行脚本的时候,Node.js把脚本的JS代码包括Promise操作全都JIT掉了,JSPromise里面的那些代码根本没有用。

看来这个问题和Promise的实现是没有关系的。至于还有什么其它原因实在是百思不得其解。

转机

问题解决

这个问题拖了将近一周的时间,直到有一天想这个问题的时候拖了下控制台窗口,改变了下Node.js控制台窗口的大小。改变了窗口大小之后,await后面的语句居然被执行了!

期待的结果出现了,赶紧分析下到底发生了什么让结果是对的。再次打开Node.js项目里编译生成的node.sln,用我们的debug版的Node.js启动测试脚本,脚本毫无悬念地卡死。这时候在VS调试器里暂停node.exe的运行,看到主线程中断在了下面的位置:

可以看到Node.js调用了Windows的IOCP。一直等待的情况下,只有IOCP管理的HANDLE对应的IO操作完成或者用PostQueuedCompletionStatus直接向IOCP对象发消息,IOCP的等待操作才会退出。再看一下node.exe调用GetQueuedCompletionStatusEx传进去的超时时间是多少,现在这个状态下这个值是-1。所以说程序卡死的原因是一直在等待一个IOCP事件传过来。然而尝试修改前面的timeout数值,改成不是一直等待,重新编译代码,卡死的问题仍然存在。

GetQueuedCompletionStatusEx的下一行加个断点,然后让node.exe继续跑下去。这之后拖一下控制台窗口,继续单步跟下去,可以看到IOCP消息对应的libuv消息(uv_req_t)类型是UV_SIGNAL_REQ。继续跟到循环结束走到下一轮循环,单步调试到uv_process_signal_req的时候(在uv_process_reqs里面),可以看到信号的编号是28。编号为28的信号是什么信号?查了下是SIGWINCH,在控制台窗口大小发生改变时,操作系统会向对应的进程发送这个信号。不管这么多,继续单步调试跑到下面signal_wrap.cc的代码时,代码走过wrap->MakeCallback这一行之后,await后面的JS代码要输出的东西输出到了屏幕上。

  static void OnSignal(uv_signal_t* handle, int signum) {
    SignalWrap* wrap = ContainerOf(&SignalWrap::handle_, handle);
    Environment* env = wrap->env();
    HandleScope handle_scope(env->isolate());
    Context::Scope context_scope(env->context());

    Local<Value> arg = Integer::New(env->isolate(), signum);
    wrap->MakeCallback(env->onsignal_string(), 1, &arg);
  }

所以这个MakeCallback到底做了什么?看代码可以知道SignalWrap也是向JS环境导出的一个类,在JS端的名字叫Signal。在node项目的lib目录下狂搜,找到了与SIGWINCH信号相关的几个处理函数,但是这些函数和执行当前要继续执行的JS代码都没有任何关系。所以可能是C++代码的问题?
换一种思路来找代码能正常运行的原因。既然控制台都有输出,在Windows下向控制台窗口输出,最终都会调用kernel32.dll导出的WriteConsole函数。又因为涉及到字符串的函数Windows都会提供分别处理char*wchar_t*的两种函数,所以需要加两个断点,按Ctrl+B打开添加断点窗口,手动加上这两个断点:

  {,,kernel32.dll}WriteConsoleA
  {,,kernel32.dll}WriteConsoleW

在启用这两个断点调试的情况下去单步调试MakeCallback方法的实现,最终锁定到了这一行C++代码:

代码中断在env_->tick_callback_function()->Call(process, 0, nullptr).IsEmpty()这一行时,让代码继续运行,node进程就会立刻中断在kernel32.dll导出的WriteConsoleW函数上。看函数的名字tick_callback_function,这个应该就是用来执行等待运行的JS代码的相关的函数,也就是测试脚本里await之后的部分。

所以结论是:拖动控制台窗口能让async/await继续运行,完全是个副作用。原因只不过是Node.js要运行操作系统信号相关的处理函数的时候,顺带会把等待执行的JS代码拎过来也执行一下而已。鬼使神差地就让脚本按预期的方式工作了。至于为什么新建uv_async_t对象就会让Node.js陷入无限等待IOCP消息的状态,这个我实在是无力去追查。

SIGWINCH信号是如何在windows下模拟出来的

Windows本身是不支持SIGWINCH信号的,Node.js(libuv)用启动监听线程监听控制台窗口大小变化的方法模拟了这个信号。窗口大小变化的消息本身是用PostQueuedCompletionStatus直接向主线程IOCP发消息发过去的。要验证可以Ctrl+B手动加个断点{,,kernelbase.dll}PostQueuedCompletionStatus,然后拖动控制台窗口,程序就会中断在监听控制台窗口的线程里面。

解决办法

既然系统向Node.js进程发送SIGWINCH信号会迫使Node.js去运行下个事件循环需要执行的JS代码,那么解决方法也就出来了。

方法是:在测试脚本前面加个定时器,向当前Node.js进程不停地发送SIGWINCH信号,脚本就能继续跑下去直到结束:

setInterval(() => {
    process.emit('SIGWINCH');
}, 100);

这个解决方法实在是过于草生(中日双语),我始终认为这是Node.js的BUG,应该在事件循环处于空闲的状态时就去处理等待执行的JS代码,然而官方不理,当然原因也可能是真的不好处理。

感想

  • 二分法找BUG万岁。
  • 带JIT的脚本引擎找问题是真难,因为JIT出来的汇编代码产出的调用堆栈和编译器生成出来的约定不一样。然后你就没办法看到调用JIT代码之前的堆栈发生了什么。
  • 有些固有的BUG估计是没有人会理的,久而久之估计就成了feature。

后续

Node.js 8.x 10.x 12.x 在Windows/Linux上都有这个问题,Mac没条件试。