Gone With the Wind

记一次Node和Go的性能测试

以前简单测过go的性能,高并发场景下确实比node会好一些,一直想找个时间系统性地测一下,手头正好有一台前段时间买的游戏主机,装了ubuntu就开测了

准备工作

  1. 测试机和试压机系统都是ubuntu 18.04.1
  2. 首先安装node和go,版本分别如下:

    node 10.13.0

    go 1.11

  3. 测试机和试压机修改fd的限制​ulimit -n 100000​,否则fd很快就用完了。

  4. 如果是试压机是单机,并且QPS非常高的时候,也许你会经常见到试压机有N多的连接都是​TIME_WAIT​状态,具体原因可以在网上搜一下,执行以下命令即可:

    1
    $ sysctl -w net.ipv4.tcp_timestamps=1
    $ sysctl -w net.ipv4.tcp_tw_reuse=1 
    $ sysctl -w net.ipv4.tcp_tw_recycle=1
  5. 测试工具我用的是siege,版本是​3.0.8​。

  6. 测试的js代码和go代码分别如下:

Node(官网的cluster示例代码)

1
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    console.log(`Master ${process.pid} is running`);
    
    // Fork workers.
    for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
    }
    
    cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
});} else {
    // Workers can share any TCP connection
    // In this case it is an HTTP server
    http.createServer((req, res) => {
    res.end('hello world\n');
}).listen(8000);
    
console.log(`Worker ${process.pid} started`);}

Go

1
package main

import(
    "net/http"
    "fmt"
)

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello world")
}

func main() {
    http.HandleFunc("/", hello);
    err := http.ListenAndServe(":8000", nil);
    if err != nil {
            
    }
}

开始测试

首先开始并发量的测试,然而。。。游戏主机的CPU是i7-8700K,性能太强,以至于拿了两台mac也没能压满。。。

四处寻找一番,找到了好几年前花了千把块钱配的nas,上去一看是颗双核的i3-4170,妥了,这下肯定没问题

正式开始

跳过了小插曲,直接开测

I/O密集型场景

  • Node - 多进程模型,可以看到因为所有请求都由master进程转发,master进程成为了瓶颈,在CPU占用100%的情况下,worker进程仅仅只占了50%,因此整体CPU利用率只有70%。
  • qps: 6700


  • Go - 单进程多线程模型,系统剩余CPU差不多也有30%,查了一下原因是因为网卡已经被打满了,千兆网卡看了下已经扎扎实实被打满了。
  • qps: 37000

千兆网卡已经被打满了


在helloworld场景下,如果我们有万兆网卡,那么go的qps就是50000,go是node的7倍多,乍一看这个结果就非常有意思了,下面我们分析下原因:

  1. 首先node官方给的cluster的例子去跑压测是有问题的,CPU物理核心双核,超线程成4核,姑且我们就认为有4核。
  2. cluster的方案,先起主进程,然后再起跟CPU数量一样的子进程,所以现在总共5个进程,4个核心,就会造成上下文频繁切换,QPS异常低下
  3. 基于以上结论,我减少了一个worker进程的数量
  • Node 多进程模型下,1个主进程,3个worker进程
  • qps: 15000 (直接翻倍)


以上的结果证明我们的想法是对的,并且这个场景下CPU利用率几乎达到了100%,不是很稳定,暂且我们可以认为压满了,所以我们可以认为1master4worker场景下,就是因为进程上下文频繁切换导致的qps低下

那么进一步想,如果把进程和CPU绑定,是不是可以进一步提高qps?

  • Node 多进程模型,并且用​taskset​命令把进程和CPU绑定了
  • qps: 17000 (比不绑定cpu性能提高了10%多)


结论 :node在把CPU压满的情况下,最高qps为:17000,而go在cpu利用率剩余30%的情况,qps已经达到了37000,如果网卡允许,那么go理论上可以达到50000左右的qps,是node的2.9倍左右。

CPU密集场景

为了模拟CPU密集场景,并保证两边场景一致,我在node和go中,分别添加了一段如下代码,每次请求循环100W次,然后相加:

1
var b int
for a := 0; a < 1000000; a ++ {
	 b = b + a
}

  • Node 多进程模型:这里我的测试方式是开了4个worker,因为在CPU密集场景下,瓶颈往往在woker进程,并且将4个进程分别和4个核绑定,master进程就让他随风飘摇吧
  • qps: 3000


  • go:go不用做特殊处理,不用感知进程数量,不用绑定cpu,改了代码直接走起
  • qps: 6700,依然是node的两倍多

结论

Node因为用了V8,从而继承了单进程单线程的特性,单进程单线程好处是逻辑简单,什么锁,什么信号量,什么同步,都是浮云,老夫都是await一把梭。

而V8因为最初是使用在浏览器中,各种设置放在node上看起来就不是非常合理,比如最大使用内存在64位下才1.4G,虽然使用buffer可以避开这个问题,但始终是有这种限制,而且单线程的设计(磁盘I/0是多线程实现),注定了在如今的多核场景下必定要开多个进程,而多个进程又会带来进程间通信问题。

Go不是很熟,但是前段时间小玩过几次,挺有意思的,一直听闻Go性能好,今天简单测了下果然不错,但是包管理和错误处理方式实在是让我有点不爽。

总的来说在单机单应用的场景下,Go的性能总体在Node两倍左右,微服务场景下,Go也能起多进程,不过相比在机制上就没那么大优势了。

Node

优点

  1. 单进程单线程,逻辑清晰
  2. 多进程间环境隔离,单个进程down掉不会影响其他进程
  3. 开发语言受众广,上手易

缺点

  1. 多进程模型下,注定对于多核的利用会比较复杂,需要针对不同场景(cpu密集或者I/O密集)来设计程序
  2. 语言本身因为历史遗留问题,存在较多的坑。

Go

优点

  1. 单进程多线程,单个进程即可利用N核
  2. 语言较为成熟,在语言层面就可以规避掉一些问题。
  3. 单从结果上来看,性能确实比node好。

缺点

  1. 包管理方案并不成熟,相对于npm来说,简直被按在地上摩擦
  2. if err != nil ….
  3. 多线程语言无法避开同步,锁,信号量等概念,如果对于锁等处理不当,会使性能大大降低,甚至死锁等。
  4. 本身就一个进程,稍有不慎,进程down了就玩完了。

Hello World

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

一段有意思的Nodejs异步处理代码

首先看下这段代码的输出是什么:

1
setTimeout( () => {
    console.log( 1 );
}, 1 );

setImmediate( () => {
    console.log( 2 );
} );

process.nextTick( () => {
    console.log( 3 );
} );

我猜大概大部分人的电脑上的输出结果是:3 1 2,并且无论你执行多少次,结果永远是3 1 2

再看看下一段代码的执行结果:

1
setTimeout( () => {
    console.log( 1 );
}, 1 );

setImmediate( () => {
    console.log( 2 );
} );

其实就是把process.nextTick调用去掉了,这时候你会发现输出开始变得较为随机:1 2或者2 1

那么第一段代码哪些人的电脑输出结果会不一样?土豪!没错就是土豪,土豪不仅仅在生活上碾压你,在代码上甚至都是如此。

NodeJs的EventLoop

有关NodeJs的EventLoop不多说了,网上讲解文章非常多,官网就有一篇讲得非常好也通俗易懂的文章:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

看完上面的文章也许你对第二段代码的随机输出明白了,但是第一段为何始终都是3 1 2呢?

为什么是3 1 2?

也许你会觉得结果应该会是在3 2 13 1 2之间随机,但是你可以在你自己的电脑上面试试看,基本永远都是3 1 2,那么为什么呢?

上面的文章只介绍了事件循环相关的内容,但是对于nextTick以及整个NodeJs执行流程的介绍并不多,这里自己通过查看源码发现以下流程:

  1. NodeJs启动阶段

    • 准备Node环境
    • 执行代码
    • 检查nextTickQueue,如果有就执行。
  2. 开始事件循环,这一步一定是在启动阶段之后,因为事件循环的前提是得有事件触发,事件触发的前提就是执行了我们的代码,如果没有触发事件,那么Node进程就退出了。

    • timer
    • I/O

看到这里,就已经能非常好地解释上面的代码了:

  1. 首先我们的主程序执行完,nextTickQueue中有一个回调,首先执行这个回调,打印一个console.log

  2. 然后开始事件循环,执行TimerI/O等阶段,然而因为我们设置的timer的超时时间是1毫秒,而我们在nextTick里面的回调的console.log还是比较耗时间的,到了Timer阶段,1毫秒早就过去了,所以执行结果永远都是3 1 2

其实我测试了一下,我的电脑设置为setTimeout( ..., 4 ),4毫秒的延迟后,就会开始出现随机结果,也就是一次console.log差不多花费3毫秒的时间,相当耗时啊,并且在process.nextTick回调里面把console.log去掉后,开始出现随机1 2或者2 1的结果。

为什么土豪的执行结果可能不是3 1 2呢?土豪的电脑配置高,一个console.log也许花不了几微秒,Timer阶段检查1毫秒没到,就往下执行了,之后就到了check阶段,执行setImmediate的回调,输出就变成了3 2 1

所以,努力奋斗吧,争取买得起一台输出结果是3 2 1的电脑,哈哈。

强类型?弱类型?强类型!

“灵活”的JavaScript

以前我很喜欢JavaScript,并不仅仅是因为他是我吃饭的工具,更多因为他非常灵活,从学校了写多了C++或者Java,遇到各种诡异的编译链接等等的错误,到JavaScript弱类型动态语言,简直就是满地撒欢。

我可以使用各种奇淫巧技,虽然比不上Python,也足够让我用1行代码实现别人3行代码的功能,当时我以同样的功能下代码字符越少越牛逼为荣。

然而慢慢地,别人开始接手我写的代码,包括自己开始看以前自己写的代码,虽然别人有三行代码,但是一眼就看明白了三行代码所表达的意思,而我的一行代码,看了好几分钟才能看明白所表达的意思,有时候还不一定正确,也许需要跑起来才能知道效果,我开始质疑自己的这种行为,比如以下行为:

  • ~~n, 效果等同于Math.floor( n ),虽然运行更快,但是一眼并不能看出什么意思,而函数Math.floor一眼就能看出什么意思。
  • n === n,效果等同于isNaN( n ),因为NaN并不等于他自己。
  • (![]+[])[+[]]+(![]+[])[+!+[]]+([![]]+[][[]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]甚至包括这种,我还真写过,只是没有这么夸张。

灵活的代价

下面的代码是一个真实的例子,我调用了别人的代码,他的函数:

1
function test() {
    ...

    // 这里返回是一个Promise
    return doSomeAsync();
}

返回的是一个Promise,所以我用await去调用,到这里并没有问题。

而出现问题来源于一次需求的增加,在执行这种函数时,需要判断一些意外情况,如果出现意外,就弹出一个对话框,让用户点击确认后才继续执行,于是他的代码变成了:

1

function test( needConfirm ) {

    ...

    if ( needConfirm ) {
        Dialog.show( {
            content: '您确定要继续执行么?',
            onOk: () => {
                doSomeAsync();
            }
        } );
        return;
    }

    // 这里返回是一个Promise
    return doSomeAsync();

}

这么改动乍一看也没什么问题,如果需要确认,就弹出一个对话框,给用户确认,当用户点击确认的时候,在回调函数里面执行原本的操作。

而对于我的代码来说,我依赖于doSomeAsync执行的结束,所以,当我任然使用await test( true )去调用时,我的代码就报错了,因为true === needConfirm的时候返回的是undefinedawait并不会等待,而是继续执行下面的代码,所以导致了错误的出现。

“束缚”的TypeScript

相信以上的代码绝大部分人都写过,我们再仔细看下,其实上面这段代码的问题在于函数的行为不一致,正常情况是返回一个Promise,而分支情况是返回一个undefined,我们看看在TypeScript下怎么写这段代码:

1
function test( needConfirm: boolean ): Promise<void> {

    ...

    if ( true === needConfirm ) {
        Dialog.show( {
            content: '您确认要继续执行么?',
            onOk: () => {
                doSomeAsync();
            }
        } );
        return;
    }

    return doSomeAsync();
}

如果你任然这么写,那么TypeScript的编译阶段就会报错,因为你规定了函数需要返回一个Promise,但是却存在返回undefined的情况,你不得不去修改你的代码:

1

functon test( needConfirm: boolean ): Promise<void> {

    ...

    if ( true === needConfirm ) {

        let resolveFunc = null;

        Dialog.show( {
            content: '您确定要继续执行么?',
            onOk: async function(): Promise<void> {
                const result = await doSomeAsync();
                resolveFunc( result );
            }
        } );

        return new Promise( ( resolve, reject ) => {
            resolveFunc = resolve;
        } );
    }
    return doSomeAsync();
}

编译通过,函数行为一致,调用者也无需感知里面的逻辑。

最后

强类型写起来限制会更多,然而对于代码的可维护性,可运行性都有很大的提升。

现在前端的复杂性提升地非常快,几年前也许前端仅仅只是更多作为展示型页面存在,但是现在的前端已经承载了太多的东西,用户的交互也发生了很大变化,JavaScript也可以写ReactNative的APP,可以写NodeJs服务,可以用NodeWebkit等工具写跨平台的客户端,使用TypeScript严格按照强类型语言去写你的应用,让你的应用具备更高的可读性和可维护性。

当然事事无绝对,还是得看你的场景,杀鸡焉用牛刀,JavaScript自有存在的意义,至少是目前浏览器官方语言,当你的应用重逻辑时,放手用`TypeScript吧。

在typescript中使用delegate来进行解耦

无处不在的耦合

通常你写的一个React组件,如果需要和别的组件发生交互,那么你会怎么做?

1
public componentDidMount(): void {
	this.refs.mycom.test();
}

public render() {
	return (
		<MyCom ref="mycom" />
	);
}

这种做法相对来说比较普遍,也比较合情合理,那么如果MyCom组件执行了什么操作之后需要通知到父组件呢?

1
public onNotify(): void {

}

public render() {
	return (
		<MyCom notify={ this.onNotify.bind( this ) } ref="mycom" />
	);
}

很多时候我们会选择传进去一个回调函数,当MyCom需要发送通知的时候再调用props.notify

虽然这种方式能实现我们的需求,但也仅仅是实现而已,你会发现这种方式并不是很优雅,而且当你的回调函数多了之后,那简直就是噩梦,后来来使用这个组件的人完全不知道需要传哪些回调函数进去,也不知道哪些回调是可选的,哪些是必选的。

protocol

ProtocolOc里面的概念,类似Java里面的Interface,和TypeScript中的Interface也比较类似,其中有一个用处就是用于两个类之间的通信解耦,而delegate更多的是一种约定俗成的规则,看看具体怎么用:

首先我有一个A类,用

A.h

1
@protocol ADelegate
	- (void) methodFromA;
@end

@interface A : NSObject
// 声明委托变量
@property (assign, nonatomic) id<ADelegate> delegate;
    - (void) test;
@end

A.m

1

// 在A类的实现文件里面,在需要进行回调的时候,只需要调用当前实例的delegate对象,而不用理会到底是谁调用了自己,而且对方肯定实现了delegate指向的protocol
// 需要的时候调用
[self.delegate methodFromA];

此时,我有一个B类,需要调用A类,同时需要监听回调函数:

B.h

1
#import "A.h"

// 这里实现了Adelegate Protocol
@interface B : NSObject <ADelegate>
...
@end

B.m

1
- (void) someMethod {
	A *aInstance = [[A alloc] init];
	// 这里把当前实例赋值给A类实例的delegate属性
	aInstance.delegate = self;	
}

// 实现了ADelegate
- (void) methodFromA {
	当A类调用self.delegate.methodFromA时,就可以在这里执行你自己的代码了
	// ...
}

TypeScript中的delegate

上面可以看到其实delegate的使用非常简单,只是一种约定的规则,同时delegate其实也是一种设计模式,在JS中其实也可以使用,只是JS是弱类型语言,无法通过编译器来约束一些行为,所以依赖于TypeScript的强类型,我们也可以来使用一下delegate

组件A

1
export interface ADelegate {
	methodFromA();
}

export class A extends React.Component<any, any> {
	public delegate: ADelegate;

	public render() {

		// 调用delegate的方法
		this.delegate.methodFromA();

		return (
			<div></div>
		);

	}

}

组件B

1
import { A, ADelegate } from 'A';

// 这里实现了Interface: ADelegate
export class B extends React.Component<any, any> implements ADelegate {
	// 从A实现的回调。
	public methodFromA(): void {

	}

	public componentDidMount(): void {
		const aCom: A = <A>this.refs.acom;
		aCom.delegate = this;
	}

	public render() {
		return (
			<A ref="acom" />
		);
	}
}

效果

上面的代码可以看到,A组件面向的不是调用他的组件,而是他自己声明的delegate,完全无需关心是谁在调用自己,在什么场景下调用自己

而B组件也完全不用关心A组件的具体实现,只要实现A组件提供的Interface:ADelegate,从中选择自己需要的方法即可,两者实现解耦,面向delegate编程。

webpack的chunk id和cdn缓存之间的爱恨情仇

之前项目中引入了一个插件,把webpack的chunk id使用md5代替,使得其无序化,开发环境没有问题,但是在配合CDN使用的时候,却发现每次发布新版本之后,页面直接报错打不开,提示加载某某chunk的时候无法找到,清除缓存之后问题解决,然而把id无序化插件去掉之后就没问题了,看来问题和缓存和chunk id有关。

起因

webpack chunk

先说说webpack的chunk是怎么一回事,如下图:

有四个项目文件,分别为:file1.js, file2.js, file3.js, file4.js.

1
file1.js
file2.js

file3.js
file4.js

其中,file1依赖了file2file3依赖了file4,在webpack里面,通常情况下一个文件就是一个module,有时候也有一个文件多个module的情况,不过不多见,webpack会给每一个module分配一个id,这个id是自增的:

1
file1.js -> module1, id: 1
file2.js -> module2, id: 2

file3.js -> module3, id: 3
file4.js -> module4, id: 4

因为file1file2对于file3file4都没有什么依赖关系,所以webpack把这四个文件分成了两个chunk,同时分别给了一个id,也是自增的:

1
chunkA: ChunkId1
	-> module1
	-> module2

chunkB: ChunkId2
	-> module3
	-> module4

一个chunk就对应于webpack打包之后的文件,于是,打包之后我们现在有两个文件:

1

file1.js
            filea.hash.js
file2.js

----------------------------------

file3.js
            fileb.hash.js
file4.js

两个chunk所对应的打包后的文件名为: ${fileName}.${md5}.js.

CDN缓存

项目中使用了覆盖式的CDN缓存方式,也就是不同发布之间没有版本概念,而是直接覆盖原来的文件,因为文件名中有MD5值,所以只要文件内容改变,那么文件名也就改变。

好处是如果新版本没有变更的文件,可以让浏览器复用之前的缓存,达到最小更新目的。

问题出现

原本这一切都运行得不错,直到后来在项目中加入了一个webpack插件,用来做chunk id的无序化,至于加入这个插件的原因,是因为webpack自带的chunk id的生成规则:

webpack chunk id生成方式

webpack的chunk id是从1开始自增的,具体有自己的规则,比如根据引用顺序来给chunk id给定一个id,文件内容的改变并不会影响id,只要引用顺序不变,那么即使文件内容如何改变,都不会影响chunk id,多次打包之后的值都是一样的。

chunk id无序化

上面说chunk id从1开始自增,也就是不同项目之间,chunk id可能都是一样的,这就导致跨项目间调用chunk变得困难了,为了解决这个问题,就引入了一个插件,可以让chunk id无序化,实质上就是用MD5值来代替了chunk id。

为何在CDN场景下发布新版本会导致找不到chunk呢?

把chunk id用MD5值代替,看起来确实没有问题,在版本化的CDN场景下使用或者说在本地开发环境中使用确实是没有问题的,问题出在和覆盖式的CDN同时使用时候的问题。

问题根源

首先我们看chunka和chunkb打包之后的代码:

chunka:

1
...
__webpack__require.e( '1' ).then( '....' );
...

chunkb:

1
webpackJsonp([1],{
	...
});

chunka中代码,打包前的代码为:

1
import( './file3' );

chunka中需要引用id为1的chunk,然后chunkb的代码中,首先用webpackJsonp方法定义了自己的chunk id为:1,这样,整个代码就联系起来了,也就能正常运行了。

MD5替代chunk id

如果使用MD5来替换原先的chunk id呢?

chunka:

1
...
__webpack__require.e( 'md51' ).then( '....' );
...

chunkb:

1
webpackJsonp(['md51'],{
	...
});

看起来也没什么问题,反正只要引用的chunk id和声明的chunk id对应上就可以了。尝试修改了一些file3的内容,也就是chunkb的内容有了变更之后:

chunka:

1
...
__webpack__require.e( 'md52' ).then( '....' );
...

chunkb:

1
webpackJsonp(['md52'],{
	...
});

chunkb的chunk id变成了: md52,同时引用和声明的chunk id都能对应上,运行没问题,那么问题出在哪里呢?我们看看webpack生成文件的文件名:

1
${fileName}.${md5}.js

因为文件名中有MD5值,所以当修改了chunkb之后,那么chunkb对应的文件名肯定就变更了,这个没问题,而chunka其实内容并没有变更,所以chunka的md5没有变化,也就是说chunka的文件名不变。

然而在引入chunk id无序化插件之前,chunkb的内容变化了,chunk id是不会变的,所以chunk b变化之后,打包后的chunka的文件名和内容都是没有变化的,使用浏览器缓存也不会有任何问题。

缓存带来的问题

chunk id变成MD5之后,chunkb的改变,会导致chunka的文件名没有变,然而chuna的代码其实是变化了的,因为引用chunkb的时候是要填写chunkb的chunk id的,因为chunk b的内容改变,其chunk id也跟着改变,浏览器根据文件名缓存,因为文件名没有变化,所以一直读取的都是以前的文件,也就导致了找不到某某chunk的根源,以前因为内容变化了,chunk id是不会变化的,所以使用并没有问题。

webpack MD5生成

也许你会说chunka的内容其实是变化了,为啥md5没有改变?其实一开始我也是这么想的,后来看过webpack代码之后就知道原因了:

上面是生成hash的事件,而替换代码则远远在这之后,其实之前也猜到个大概,从业务上讲其实chunka的内容并没有任何变更,hash也是不应该改变的。

最近对于懒加载的一些思考

记得我刚毕业那会,前端还是jQuery的天下,没有什么项目是一个jQuery搞不定的,如果有,那就再加个插件。

后来慢慢出现了一些前端模块化工具,直至seajs的出现,把模块化推向了一个高潮,期间听说了gmail前端有44万行的JS代码,也是非常震惊。

现在的前端为了更好的用户体验,更快地适应业务,逻辑正在变得越来越复杂,同时也带来越来越多的问题。

体积臃肿的前端

使用webpack的同学对下面这张图很熟悉了,一般我们用webpack打出来的包,都会包含一大堆东西,node_modules文件内容甚至能占到90%以上。

冰山效应

很多时候,用户打开一个页面,同时也加载了很多在这个页面上并不会运行的代码,甚至是永远都不会运行的代码,页面上的这些代码就像海洋里的冰山,运行的只是海平面上很小一部分,很大一部分在海平面下的代码,仅仅只会拖慢我们首页的打开时间。

按需加载

从有模块化起,貌似就有了按需加载功能,很多工具都提供了按需加载的功能,以webpack为例:

1
const jQuery = await import( 'jQuery' );
 
const model = await import( './model/data.js' );

webpack内置函数import很容易就可以实现按需加载的功能,看起来似乎问题就已经解了?然而这只是问题的第一步,仅仅有了按需加载的能力,问题也会随之而来。

预加载

按需加载必然会带来一次网络请求,假设一个场景:

1
用户点击一个按钮,然后跳出一个弹框。

普通情况下,点击按钮之后发生的事情如下:

1
点击按钮(ms) -> 渲染弹框(ms)

如果我们把弹框做按需加载,那么点击按钮之后发生的事情如下:

1
点击按钮(ms) -> 加载弹框代码(s) -> 渲染弹框(ms)

中间多了一个步骤,并且是按秒计算的从网络加载代码,点击一个按钮之后需要等待一段时间,才会弹出弹框,用户操作非常不流畅,如果存在大量这种场景,体验或许还不如不用按需加载。

有什么办法能解这个问题么?一种方案是:预加载,请看下图:

预加载步骤

  1. 首先页面上收集用户的点击行为日志。
  2. 拿到点击行为日志,得出用户最常用的操作路径,这部分数据在静态打包的时候注入到前端。
  3. 每个人的操作路径会有区别,这里可以在前端也记录一份当前用户的点击数据,这里很多时候虽然本地都已经有缓存了,不过提前把缓存加载到内存,也可以节省很多时间。
  4. 在浏览器空闲时,按照第二步和第三步的数据计算权重,最终决定加载顺序。
  5. 同时,前端可以根据鼠标运动轨迹,实时地来预测下一步可能会加载的内容,作为一个旁路辅助。这里目前并没有想到一个很好的办法。

缓存

鉴于目前前端代码普遍存在于CDN的情况,按需加载带来的另外一个非常有优势的地方在于CDN缓存,假设我们有一份jQuery,有两个文件分别依赖了jQuery,情况如下:

a.js

1
var jQuery = function(){ /*jquery代码*/ }

jQuery.query( 'xxx' );

b.js

1
var jQuery = function(){ /*jquery代码*/ }

jQuery.query( 'xxx' );

用户在打开A页面和B页面的时候,分别需要下载a.jsb.js,这时候重复下载了两份jQuery,即使他们是一样的,同时在用户的磁盘,也缓存了两份jQuery

按需加载后的CDN缓存

那么如果我们用按需加载的方式呢?

http://cdn.com/jquery.js

1
var jQuery = function(){ /*jquery代码*/ }

a.js

1
var jQuery = import( 'http://cdn.com/jquery.js' );

jQuery.query( 'xxx' );

b.js

1
var jQuery = import( 'http://cdn.com/jquery.js' );

jQuery.query( 'xxx' );

jQuery.js只会被下载一次,所有页面如果用的都是一个cdn地址,那么只要用户打开过任何一个页面,之后的页面即使用户从未打开过,jQuery也已经被缓存,这带来的是一个1+1>2的效果。

理想中的按需加载

最后说一说我理想中的按需加载,看下图:

  1. 逻辑层和展示层完全剥离。
  2. 用户操作一个页面的顺序,一定是:
    1. 眼睛看到展示层,发现有一个按钮。
    2. 大脑告诉你,这个是一个按钮,是可以点击的。
    3. 用手控制鼠标去点击按钮。
    4. 执行点击按钮之后事情。
  3. 首屏加载的时候,我们可以只加载展示层,逻辑层代码一定是在用户点击按钮之后才会执行。
  4. 首屏加载完成,利用用户看页面,并点击按钮的时间,预加载逻辑层代码,实现最快速的首屏展示。

什么是MTU?为什么MTU值普遍都是1500?

大学那会我玩魔兽世界,我的职业是法师,然后经常有朋友找我我带小号,带小号的方式是冲到血色副本里面把所有怪拉到一起,然后一起用AOE技能瞬间杀掉,在学校玩的时候没什么问题,但是放假在家的时候,我发现每次我拉好怪,放技能AOE的那个瞬间,很大概率会掉线,也不是网速问题,当时很多人也遇到同样的问题,看到个帖子说,把自己的MTU改成1480就行了,当时也不知道啥是MTU,就改了,发现还真的可以,就愉快地打游戏去了,多年以后我才知道MTU的重要性。

什么是MTU

Maximum Transmission Unit,缩写MTU,中文名是:最大传输单元。

这是哪一层网络的概念?

从下面这个表格中可以看到,在7层网络协议中,MTU是数据链路层的概念。MTU限制的是数据链路层的上层协议的payload的大小,例如IP,ICMP等。

OSI中的层 功能 TCP/IP协议族
应用层 文件传输,电子邮件,文件服务,虚拟终端 TFTP,HTTP,SNMP,FTP,SMTP,DNS,Telnet
表示层 数据格式化,代码转换,数据加密 没有协议
会话层 解除或建立与别的接点的联系 没有协议
传输层 提供端对端的接口 TCP,UDP
网络层 为数据包选择路由 IP,ICMP,RIP,OSPF,BGP,IGMP
数据链路层 传输有地址的帧以及错误检测功能 SLIP,CSLIP,PPP,ARP,RARP,MTU
物理层 以二进制数据形式在物理媒体上传输数据 ISO2110,IEEE802,IEEE802.2

MTU有什么用?

举一个最简单的场景,你在家用自己的笔记本上网,用的是路由器,路由器连接电信网络,然后访问了www.baidu.com,从你的笔记本出发的一个以太网数据帧总共经过了以下路径:

1
笔记本 -> 路由器 -> 电信机房 -> 服务器

其中,每个节点都有一个MTU值,如下:

1
1500     1500                 1500
笔记本 -> 路由器 -> 电信机房  -> 服务器

假设现在我把笔记本的MTU最大值设置成了1700,然后发送了一个超大的ip数据包(2000),这时候在以外网传输的时候会被拆成2个包,一个1700,一个300,然后加上头信息进行传输。

1
1700     1500                1500
笔记本 -> 路由器 -> 电信机房 -> 服务器

路由器接收到了一个1700的帧,发现大于自己设置的最大值:1500,如果IP包DF标志位为1,也就是不允许分包,那么路由器直接就把这个包丢弃了,根本就不会到达电信机房,也就到不了服务器了,所以,到这里我们就会发现,MTU其实就是在每一个节点的管控值,只要是大于这个值的数据帧,要么选择分片,要么直接丢弃。

为什么是1500?

其实一个标准的以太网数据帧大小是:1518,头信息有14字节,尾部校验和FCS占了4字节,所以真正留给上层协议传输数据的大小就是:1518 - 14 - 4 = 1500,那么,1518这个值又是从哪里来的呢?

假设取一个更大的值

假设MTU值和IP数据包大小一致,一个IP数据包的大小是:65535,那么加上以太网帧头和为,一个以太网帧的大小就是:65535 + 14 + 4 = 65553,看起来似乎很完美,发送方也不需要拆包,接收方也不需要重组。

那么假设我们现在的带宽是:100Mbps,因为以太网帧是传输中的最小可识别单元,再往下就是0101所对应的光信号了,所以我们的一条带宽同时只能发送一个以太网帧。如果同时发送多个,那么对端就无法重组成一个以太网帧了,在100Mbps的带宽中(假设中间没有损耗),我们计算一下发送这一帧需要的时间:

1
( 65553 * 8 ) / ( 100 * 1024 * 1024 ) ≈ 0.005(s)

在100M网络下传输一帧就需要5ms,也就是说这5ms其他进程发送不了任何数据。如果是早先的电话拨号,网速只有2M的情况下:

1
( 65553 * 8 ) / ( 2 * 1024 * 1024 ) ≈ 0.100(s)

100ms,这简直是噩梦。其实这就像红绿灯,时间要设置合理,交替通行,不然同一个方向如果一直是绿灯,那么另一个方向就要堵成翔了。

既然大了不行,那设置小一点可以么?

假设MTU值设置为100,那么单个帧传输的时间,在2Mbps带宽下需要:

1
( 100 * 8 ) / ( 2 * 1024 * 1024 ) * 1000 ≈ 5(ms)

时间上已经能接受了,问题在于,不管MTU设置为多少,以太网头帧尾大小是固定的,都是14 + 4,所以在MTU为100的时候,一个以太网帧的传输效率为:

1
( 100 - 14 - 4 ) / 100 = 82%

写成公式就是:( T - 14 - 4 ) / T,当T趋于无穷大的时候,效率接近100%,也就是MTU的值越大,传输效率最高,但是基于上一点传输时间的问题,来个折中的选择吧,既然头加尾是18,那就凑个整来个1500,总大小就是1518,传输效率:

1
1500 / 1518 =  98.8%

100Mbps传输时间:

1
( 1518 * 8 ) / ( 100 * 1024 * 1024 ) * 1000 = 0.11(ms)

2Mbps传输时间:

1
( 1518 * 8 ) / ( 2 * 1024 * 1024 ) * 1000 = 5.79(ms)

总体上时间都还能接受

最小值被限制在64

为什么是64呢?

这个其实和以太网帧在半双工下的碰撞有关,感兴趣的同学可以自行去搜索。

在我玩游戏的时候,为什么把MTU改成1480就不卡了?

路由器默认值大多都是1500,理论上是没有问题的,那为什么我玩游戏的时候改成1480才能流畅呢?原因在于当时我使用的是ADSL上网的方式,ADSL使用的PPPoE协议。

PPPoE

PPPoE协议介于以太网和IP之间,协议分为两部分,PPP( Point to Point Protocol )和oE( over Ethernet ),也就是以太网上的PPP协议,而PPPoE协议头信息为:

1
| VER(4bit) | TYPE(4bit) | CODE(8bit) | SESSION-ID(16bit) | LENGTH(16bit) |

这里总共是48位,也就是6个字节,那么另外2个字节是什么呢?答案是PPP协议的ID号,占用两个字节,所以在PPPoE环境下,最佳MTU值应该是:1500 - 4 - 2 = 1492。

我的上网方式

当时我的上网路径如下:

1
PC -> 路由器 -> 电信

我在路由器进行拨号,然后PC连接路由器进行上网。

最根本原因

问题就出在路由器拨号,如果是PC拨号,那么PC会进行PPPoE的封装,会按照MTU:1492来进行以太网帧的封装,即使通过路由器,路由器这时候也只是转发而已,不会进行拆包。

而当用路由器拨号时,PC并不知道路由器的通信方式,会以网卡的设置,默认1500的MTU来进行以太网帧的封装,到达路由器时,由于路由器需要进行PPPoE协议的封装,加上8字节的头信息,这样一来,就必须进行拆包,路由器把这一帧的内容拆成两帧发送,一帧是1492,一帧是8,然后分别加上PPPoE的头进行发送。

平时玩游戏不卡,是因为数据量路由器还处理得过来,而当进行群怪AOE的时候,由于短时间数据量过大,路由器处理不过来,就会发生丢包卡顿的情况,也就掉线了。

帖子里面提到的1480,猜测可能是尽量设小一点,避免二次拨号带来的又一次PPPoE的封装,因为时间久远,没办法回到当时的场景再去抓包了。

结论

1518这个值是考虑到传输效率以及传输时间而折中选择的一个值,并且由于目前网络链路中的节点太多,其中某个节点的MTU值如果和别的节点不一样,就很容易带来拆包重组的问题,甚至会导致无法发送。

前端如何获取主域名(根域名)

背景

最近项目中需要获取url的主域名,比如www.baidu.com那么就需要获取baidu.com,看似简单,.号分隔,取到最后两位就行,但是坑爹的是有xxx.com.cn这类域名,还有很多日本的域名,类似toei.aichi.jp等,这些都无法通过这种简单的取最后两位的方式来获取,看来只能枚举了。

Public Suffix List

这问题肯定是早有人就遇到了,于是各路有识之士已经帮你完整得准备好了一个列表,里面全部都是那些奇葩域名,一些jp域名也是让我长见识了,不知道各位老司机在秋名山飙车的时候有没有见过这些个域名:

1
秋田.jp
群馬.jp
香川.jp
高知.jp
鳥取.jp
鹿児島.jp
// jp geographic type names
// http://jprs.jp/doc/rule/saisoku-1.html
*.kawasaki.jp
*.kitakyushu.jp
*.kobe.jp
*.nagoya.jp
*.sapporo.jp
*.sendai.jp
*.yokohama.jp
!city.kawasaki.jp
!city.kitakyushu.jp
!city.kobe.jp
!city.nagoya.jp
!city.sapporo.jp
!city.sendai.jp
!city.yokohama.jp
// 4th level registration
aisai.aichi.jp

感兴趣的朋友可以看看这个github项目:https://github.com/wrangr/psl

这里有各种主域名的列表:https://publicsuffix.org/list/public_suffix_list.dat.

浏览器其实也有内置类似的东西,用来做域名判断,cookie存储之类的事宜。

pls的问题

问题看似好像解决了,已经有现成的脚本去获取,但是仔细一看这脚本竟然有将近200K,而我自己的脚本才10K,既然浏览器已经内置了pls,那浏览器有没有暴露内置接口呢?很遗憾,搜索了一下并没有,而且浏览器那么多,即使chrome暴露了,IE肯定没有,等等,刚刚好像我们说到浏览器用来做域名判断,cookie存储,那我们能不能用这类方式间接地去调用内置pls呢?

最终解决方案

目前想到有两种方式可以间接去调,document.doamindocument.cookie,测试一下就会发现,如果你尝试把当前域名设置为com.cn或者把cookie设置到com.cn上面,浏览器并不会生效,document.domain在第二次设置的时候,firefox会抛错,看来并不是很合适,而且可能多多少少会影响到业务,cookie设置方便,而且清除也方便,上代码:

1
function getMainHost() {
  let key  = `mh_${Math.random()}`;
  let keyR = new RegExp( `(^|;)\\s*${key}=12345` );
  let expiredTime = new Date( 0 );
  let domain = document.domain;
  let domainList = domain.split( '.' );

  let urlItems   = [];
  // 主域名一定会有两部分组成
  urlItems.unshift( domainList.pop() );
  // 慢慢从后往前测试
  while( domainList.length ) {
    urlItems.unshift( domainList.pop() );
    let mainHost = urlItems.join( '.' );
    let cookie   = `${key}=${12345};domain=.${mainHost}`;

    document.cookie = cookie;

    //如果cookie存在,则说明域名合法
    if ( keyR.test( document.cookie ) ) {
      document.cookie = `${cookie};expires=${expiredTime}`;
      return mainHost;
    }
  }
}

拉了差不多几十个pls里面的域名,跑了一下单元测试,没有问题。

iOS大保健 - 基础信息的收集

本文列出了各种iOS的基础信息的获取方式。

系统信息

  • 设备型号
1
struct utsname systemInfo;
uname(&systemInfo);
NSString *platform = [NSString stringWithCString:systemInfo.machine encoding:NSASCIIStringEncoding];
  • 操作系统名称
1
UIDevice *device = [UIDevice currentDevice];
[device systemName];
  • 操作系统版本
1
UIDevice *device = [UIDevice currentDevice];
[device systemVersion];
  • 设备型号,如: iPad,iPod等。
1
UIDevice *device = [UIDevice currentDevice];
[device model];
  • 屏幕分辨率
1
UIScreen *screen     = [UIScreen mainScreen];
CGSize screenSize    = [[UIScreen mainScreen] bounds].size;
CGFloat scale        = screen.scale;
int screenX          = screenSize.width * scale;
int screenY          = screenSize.height *scale;
NSString *resolution = [NSString stringWithFormat:@"%d*%d", screenX, screenY];
  • 电池电量
1
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
[[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryLevelDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) {
     // Level has changed
     NSLog(@"Battery Level Change");
     NSLog(@"电池电量:%.2f", [UIDevice currentDevice].batteryLevel);
 }];

网络信息

需要引入库:CoreTelephony.framework

  • 网络类型,如:WiFi,4G等。
1
NSString *networktype = nil;
NSArray *subviews = [[[[UIApplication sharedApplication] valueForKey:@"statusBar"] valueForKey:@"foregroundView"]subviews];
NSNumber *dataNetworkItemView = nil;
for (id subview in subviews) {
    if([subview isKindOfClass:[NSClassFromString(@"UIStatusBarDataNetworkItemView") class]]) {
        dataNetworkItemView = subview;
        break;
    }
}
switch ([[dataNetworkItemView valueForKey:@"dataNetworkType"]integerValue]) {
    case 0:
        networktype = @"UNKNOW";
        break;
    case 1:
        networktype = @"2G";
        break;
    case 2:
        networktype = @"3G";
        break;
    case 3:
        networktype = @"4G";
        break;
    case 4:
        networktype = @"LTE";
        break;
    case 5:
        networktype = @"Wi-Fi";
        break;
    default:
        break;
}
return networktype;
  • 蜂窝网络类型
1
CTTelephonyNetworkInfo *telephonyInfo = [[CTTelephonyNetworkInfo alloc] init];
if ( [telephonyInfo respondsToSelector:@selector(currentRadioAccessTechnology)] ) {
    id radioAccessType = [telephonyInfo performSelector:@selector(currentRadioAccessTechnology)
                                             withObject:nil];
    
    if ( [radioAccessType isKindOfClass:[NSString class]] ) {
        NSString *accessType = (NSString *)radioAccessType;
        if ( [accessType hasPrefix:@"CTRadioAccessTechnology"] && accessType.length >= 23 ) {
            NSString *accessTypeStr = [accessType substringFromIndex:23];
            return accessTypeStr;
        }
    }
}
    
return @"UNKNOW";
  • 运营商信息
1
CTTelephonyNetworkInfo *telephonyInfo = [[CTTelephonyNetworkInfo alloc] init];
CTCarrier *carrier    = [telephonyInfo subscriberCellularProvider];
NSString *carrierName = [carrier carrierName];
if ( nil != carrierName ) {
    return carrierName;
}
return @"UNKNOW";

应用状态

  • APP启动,或者由后台进入前台
1
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil];
  • APP从后台进入前台
1
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationBecomeActive) name:UIApplicationWillEnterForegroundNotification object:nil];
  • APP进入后台
1
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterBackground) name: UIApplicationDidEnterBackgroundNotification object:nil];
  • APP在前台锁屏
1
#import <notify.h>
#define NotificationLock CFSTR("com.apple.springboard.lockcomplete")
#define NotificationChange CFSTR("com.apple.springboard.lockstate")
#define NotificationPwdUI CFSTR("com.apple.springboard.hasBlankedScreen")

static void screenLockStateChanged(CFNotificationCenterRef center,void* observer,CFStringRef name,const void* object,CFDictionaryRef userInfo)
{
	NSString* lockstate = (__bridge NSString*)name;
	if ([lockstate isEqualToString:(__bridge  NSString*)NotificationLock]) {
	    NSLog(@"locked.");
	} else {
	    NSLog(@"lock state changed.");
	}
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
	CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), NULL, screenLockStateChanged, NotificationLock, NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
	CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), NULL, screenLockStateChanged, NotificationChange, NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
	return YES;
}
  • APP在后台锁屏,通过循环的方式检测
1
static void setScreenStateCb()
{
uint64_t locked;

__block int token = 0;
notify_register_dispatch("com.apple.springboard.lockstate",&token,dispatch_get_main_queue(),^(int t){
});
	notify_get_state(token, &locked);
	NSLog(@"%d",(int)locked);
}

- (void)applicationDidEnterBackground:(UIApplication *)application
{
	while (YES) {
	    setScreenStateCb();
	    sleep(5); // 循环5s
	}
}
  • 获取当前APP的CPU使用率
1
kern_return_t kr;
task_info_data_t tinfo;
mach_msg_type_number_t task_info_count;
    
task_info_count = TASK_INFO_MAX;
kr = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)tinfo, &task_info_count);
if (kr != KERN_SUCCESS) {
    return;
}
    
task_basic_info_t      basic_info;
thread_array_t         thread_list;
mach_msg_type_number_t thread_count;
    
thread_info_data_t     thinfo;
mach_msg_type_number_t thread_info_count;
    
thread_basic_info_t basic_info_th;
uint32_t stat_thread = 0; // Mach threads
    
basic_info = (task_basic_info_t)tinfo;
    
// get threads in the task
kr = task_threads(mach_task_self(), &thread_list, &thread_count);
if (kr != KERN_SUCCESS) {
    return;
}
if (thread_count > 0)
    stat_thread += thread_count;
    
long tot_sec = 0;
long tot_usec = 0;
float tot_cpu = 0;
int j;
    
for (j = 0; j < thread_count; j++)
{
    thread_info_count = THREAD_INFO_MAX;
    kr = thread_info(thread_list[j], THREAD_BASIC_INFO,
                     (thread_info_t)thinfo, &thread_info_count);
    if (kr != KERN_SUCCESS) {
        return;
    }
    
    basic_info_th = (thread_basic_info_t)thinfo;
    
    if (!(basic_info_th->flags & TH_FLAGS_IDLE)) {
        tot_sec = tot_sec + basic_info_th->user_time.seconds + basic_info_th->system_time.seconds;
        tot_usec = tot_usec + basic_info_th->system_time.microseconds + basic_info_th->system_time.microseconds;
        tot_cpu = tot_cpu + basic_info_th->cpu_usage / (float)TH_USAGE_SCALE * 100.0;
    }
    
} // for each thread
    
kr = vm_deallocate(mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t));
assert(kr == KERN_SUCCESS);
    
NSLog(@"CPU Usage: %f \n", tot_cpu);
  • 获取当前APP的内存使用情况
1
task_basic_info_data_t taskInfo;
mach_msg_type_number_t infoCount = TASK_BASIC_INFO_COUNT;
kern_return_t kernReturn = task_info(mach_task_self(),
                                     TASK_BASIC_INFO, (task_info_t)&taskInfo, &infoCount);

if(kernReturn != KERN_SUCCESS) {
    return;
}
    
NSLog(@"Memory Usage: %f", taskInfo.resident_size / 1024.0 / 1024.0);