HTTPS客户端证书校验

简介

互联网正在逐步走向越来越安全的趋势, Chrome 90 将默认使用https。而但凡对这方面有一定了解的都会知道https: 证书, 校验, 签发机构…等等等等。

而这些我们所熟知的东西, 即一般我们所说的通过ssl层进行传输与校验的, 一般指的是服务端证书。而我们今天要说说客户端安全证书。

Client Authenticate

客户端安全证书一般不常见, 只出现在对安全有一定需求的内部系统中。他的作用是规定哪些人可以访问: 客户端根据服务端配置的证书签发来下的子证书来对服务端的资源进行访问, 而服务端会对其进行校验 —— 校验不通过则不允许访问。

服务端证书恰恰相反: 他是客户端来校验服务端是否是一个正确的, 没有被篡改过的服务端。

Handshake

以下是一个客户端进行TLS握手授权的示例:

  • 协商阶段
    • 客户端发送一个 ClientHello 消息到服务端
    • 服务端返回一个 ServerHello 消息
    • 服务端发送他的 Certificate 消息
    • 服务端发送他的 ServerKeyExchange 消息以用于交换秘钥
    • 服务端发送一个 CertificateRequest 来请求客户端发送他的证书
    • 服务端发送一个 ServerHelloDone 消息表示服务端已经完成了协商消息的发送
    • 客户端返回一个 Certificate 消息, 其中包含了客户端的证书
    • 客户端发送 ClientKeyExchange 消息, 其中包含了公钥或者公钥加密的 PreMasterSecret
    • 客户端发送 CertificateVerify 消息, 这是使用客户机证书的私钥对先前握手消息的签名。可以使用客户端证书的公钥来验证此签名。这让服务器知道客户端可以访问证书的私钥,以确保客户端是合法的。
    • 协商完毕, 现在他们双方有一个用于对称加密的随机数秘钥了
  • 客户端发送一个ChangeCipherSpec 记录来告知服务器: 所有的信息都将进行身份验证
  • 服务端返回 ChangeCipherSpec
  • 握手完毕

参考文档

字节跳动面试记录

一面

关于项目:

  • 抽象结构树
    • 如何根据一颗 AST 渲染出组件
  • 富文本编辑器
    • 实现的难点

常规问题:

  • webpack 打包细节

    • commonjs 与 esmodule 是如何相互转换(这里不会)
  • Task 与 Microtask 的区别, 以及时序

    详细说明可以见: https://html.spec.whatwg.org/multipage/webappapis.html#queuing-tasks

    简单的说就是每次执行task之前会把microtask都处理掉。在处理过程中加入的microtask也会按照顺序处理掉

  • 闭包问题:

    1
    2
    3
    4
    5
    for (var i = 0; i < 5; i++) {
    setTimeout(() => {
    console.log(i);
    }, 1000);
    }

    结果是什么? (我回答了5个4, 其实是5个5..扶额)

    然后问如何让其输出 0 1 2 3 4
    解法一:

    1
    2
    3
    4
    5
    6
    7
    for (var i = 0; i < 5; i++) {
    (function (_i) {
    setTimeout(() => {
    console.log(_i);
    }, 1000);
    })(i);
    }

    解法二:

    1
    2
    3
    4
    5
    for (let i = 0; i < 5; i++) {
    setTimeout(() => {
    console.log(i);
    }, 1000);
    }

    我没有说出解法二, 想了半天想出了一个解法三。。

    1
    2
    3
    4
    5
    6
    7
    for (var i = 0; i < 5; i++) {
    Promise.resolve(i).then((i) => {
    setTimeout(() => {
    console.log(i);
    }, 1000);
    });
    }
  • 中文数字转阿拉伯数字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    // 五千三百万零五百零一

    const chineseNumMap = {
    一: 1,
    二: 2,
    三: 3,
    四: 4,
    五: 5,
    六: 6,
    七: 7,
    八: 8,
    九: 9,
    };

    const unitMap = {
    千: 1000,
    百: 100,
    十: 10,
    };

    function parseChineseNumWhenLowerThan10K(chineseNum: string) {
    let res = 0;
    let hasNum = false;
    let currentNum = 0;
    for (const s of chineseNum) {
    if (hasNum === false && chineseNumMap[s]) {
    res += chineseNumMap[s];
    hasNum = true;
    currentNum = chineseNumMap[s];
    } else if (hasNum === true && unitMap[s]) {
    res -= currentNum;
    res += currentNum * unitMap[s];
    hasNum = false;
    currentNum = 0;
    }
    }

    return res;
    }

    function chineseNumToInt(input: string): number {
    const chineseNumArr = input.split("万");
    let res = 0;

    res += parseChineseNumWhenLowerThan10K(chineseNumArr[0]);

    if (typeof chineseNumArr[1] === "string") {
    res *= 10000;
    res += parseChineseNumWhenLowerThan10K(chineseNumArr[1]);
    }

    return res;
    }

    console.log(chineseNumToInt("五千三百万零五百零一"));

二面

一面过后第二天 HR 就打电话过来约二面,就效率方面来说还是非常迅速的

二面没有考察具体代码,主要是问了问项目方面的问题。

比如公司项目的架构,公司团队协作方面是如何协作的,除了平时工作之外有没有做什么其他的事情,有自己的项目么,自己的项目由什么亮点难点这种比较抽象的问题。

其中可能也有混入价值观方面的问题。总之需要注意一下。

然后我这面被刷了,第三天收到了感谢面试的信。至少有个反馈,从这点来看字节做的还是非常不错的。

再战

又被别的部门捞起来了, 之前是飞书, 现在是抖音部门, 就再战一次呗。

一面

  • 聊聊项目
  • esmodule 和 commonjs 的区别
  • webpack 打包原理
  • nodejs 的 event loop
  • nodejs 的使用 microtask 和 task 的时机

算法题

路径总和:给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class TreeNode {
left: TreeNode | null = null;
right: TreeNode | null = null;
value: number;
}

function getTreePathSum(node: TreeNode, targetSum: number): boolean {
if (!node) {
return false;
}

if (targetSum - node.value === 0) {
if (node.left === null && node.right === null) {
// 如果为叶子节点: 即没有子节点
return true;
}

// 否则继续往下走。
}

return (
getTreePathSum(node.left, targetSum - node.value) ||
getTreePathSum(node.right, targetSum - node.value)
);
}

const root = new TreeNode();
root.value = 1;
const left = new TreeNode();
left.value = 2;
const right = new TreeNode();
right.value = 3;

root.left = left;
root.right = right;

console.log(getTreePathSum(root, 5));

二面

  • js有哪些类型
  • js中基本类型和引用类型分别存在哪里
    • 基本类型存在栈中,引用类型的数据存在堆中
  • 如何获得地址输入 - 缓存 - 加载模块 - 渲染 - 加载请求 - 渲染可交互 这个过程中各个区域的时延
  • 如何设计一个全链路的监控体系
    我的回答:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    1. 监听所有的用户事件
    并区分重点事件,和普通事件。
    2. 全局捕获错误信息。并将错误信息上报,以及错误信息所在的堆栈信息。
    如果可能的话,同时发送相关的dom接口与网络请求,以及截屏。
    如果是无法复现的一些线上问题,内置一个web录屏工具,在开启一个flag后录屏并将录屏信息上传(rrweb)
    3. 对用户的操作,比如RUM,等关键信息,以及用户的请求。划分不同的measure标准。并通过这些信息来衡量用户的痛苦程度。
    资源是有限的,我们应当优先处理用户最痛苦的部分——即使用频率最高,相对痛苦程度最高的部分。
    4. 增加有效的反馈机制,比如聊天机器人,比如报出异常时弹出反馈错误的模态框。或者引导用户到相关的社区。来尽可能让用户知晓我们会尽快解决问题。
    同时能够收集来自用户的直接反馈,而这些是看log很难看到的信息。
    5. 对线上服务进行监控。使用监控工具来检测各个关键服务,以及相对独立的服务的可访问性。以及相关的报警措施(比如钉钉消息)
  • React Native 的热更新 / 部署
    • 拆解
    • 怎么推送
  • 长列表 VirturalList
    • 如何解决闪屏
  • React Native的事件推送

算法题

二面算法题难度急速升高, 反正我都没做过。

1
2
3
4
5
用 Javascript 对象模拟 DOM 树,并实现它的 render 方法,通过调用该方法可以转成真正的 DOM 节点,例如我们已经实现了 element.js,通过 require('element.js'),我们可以定义 ul, 如下:

function el(tag, props, children) {
return ...
}

我的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function el(tag, props, children) {
const el = document.createElement(tag);
for(let key in props) {
el.setAttrible(key, props[key])
}

const childrenEls = children.map(item => {
if(typeof item === 'string') {
return item;
}

return el(item.tag, item.props, item.children);
})
el.append(...childrenEls)

return el;
}

1
2
3
4
5
6
7
8
9
给你一副类似于如下的地图:
0000000000
0010001011
000010E001
00S0000100
0000001000
1000000000

初始时你在S的位置, 你可以向上下左右四个方向发射一枚子弹; 子弹碰到1时, 会顺时针变向90度, 然后继续前进; 问你在S向哪个方向射击, 能够最快将子弹打入E内; 假设一定有一个方向可以成功, 如上图中向上射击;

我的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const land = [
'0000000000',
'0010001011',
'000010E001',
'00S0000100',
'0000001000',
'1000000000'
].map((str) => {
return str.split('')
});

function findS(target: string): [number, number] {
for(let i = 0; i < land.length; i++) {
for(let j = 0; j < land[i].length; j++) {
if(land[i][j] === target) {
return [i, j]
}
}
}
}

function getLandSymbol(y: number, x: number): '0' | '1' | 'S' | 'E' | undefined {
return land[y][x] as any;
}

const size = [land.length, land[0].length];
const sPos = findS('S');

function tryPath(step: number, currentPos: [number,number], vector: [number, number]): number {
step = step + 1;
const symbol = getLandSymbol(currentPos[0], currentPos[1]);
if(symbol === '1') {
// [-1. 0] => [0, 1]
// [0, 1] => [1, 0]
// [1, 0] => [0, -1]
// [-1, 0] => [0, 1]
vector = [vector[1], -vector[0]] // 顺时针旋转90deg
}else if(symbol === 'E') {
return step;
}else if(symbol === undefined) {
return 999999;
}

return tryPath(step, [currentPos[0] + vector[0], currentPos[1] + vector[1]], vector);
}
console.log(tryPath(0, sPos, [-1, 0])); // 向上

三面

面试官上来就说,之前面了很多技术问题,我们来聊一聊项目吧。

大概问了问项目经历,工作经历,职业规划,如何协作,如何codereview,最近在学什么新技术等等看上去很随意的问题。

回头跟朋友们复盘了一下,其实是一道情商题,只有情商高的人才能答对,而我情商。。不高,就是一个憨憨。

如何面对这种软刀子题:

记住以下几点:

  • 面试不是跟你谈心,作为候选人得揣测对方想听啥。
  • 可以先说下自己有啥优点,经验。能为公司,部门,要做的事情带来什么转机。然后再夸一把公司,部门。说来虚心学习。
  • 学会包装自己,技术是为业务服务的

三战

又换了个团队继续,直接从二面开始

二面

  • 项目经历。项目是如何打包,如何发布的。
  • 有使用过一些自动化工具来确保项目质量么。
  • 有了解过webpack打包原理么?你们的项目是用什么打包的
  • 了解过lock文件么,升级单个依赖时会遇到什么坑
  • 看你的项目用到过cypress, 你是如何解决cypress的下载问题的

题:

1
2
3
4
5
6
7
8
设计一个组件,用 React 编写,写出伪代码,有三个要求

Input 组件
非受控
Validate:
1. 只接受英文字母

2. 如果出现不合法的字符,就删掉

我的解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, {useState, useCallback} from 'react';

interface Props {
initVal: string;
onChange: (newVal: string) => void;
}
const MyInput: React.FC<Props> = React.memo((props) => {
const [val, setVal] = useState(props.initVal ?? '');
const handleChange = useCallback((e) => {
const text = e.target.value;
const newVal = '';
for(let c of text) {
if(/[a-zA-Z]/.test(c)) {
// 这里忘记怎么取c的ascii码的方法了,直接用正则表达式先对付着
newVal += c
}
}

if(typeof props.onChange === 'function') {
props.onChange(newVal);
}

setVal(newVal);
}, [props.onChange])

return <input value={val} onChange={handleChange} />
})
MyInput.displayName = 'MyInput'

其实还有就是使用jquery时代的代码来进行处理,然后外面包一层react代码
但是我觉得不够react因此没有选择这个方案。

三面

杂七杂八的聊了聊,大部分都忘了,就记录一下记得的

  • 讲讲你的开源项目
  • 你的开源项目是怎么宣传的,取得了什么样的milestone
  • 你的开源项目未来的发展是怎么样的
  • 你公司的产品的基础架构是怎么样的
  • 你对未来三年的职业规划是怎么样的
  • 你希望做什么样的产品,是to b的还是to develop的
  • 你如何实现一个需求,在用户不知道自己想要什么样的功能的情况下
  • 你现在是965, 可以接受996么

一道题:

1
2
3
4
5
6
7
8
9
实现 tom().eat('apple').play('football').sleep(5).eat('apple').play('football')

输出:
// tom
// eat apple
// play football
// 停顿 5 s
// eat apple
// play football

我的写法, 其实不对。主要是一个sleep函数不太会写。求大佬给出正解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function tom() {
return new Tom()
}

function Tom() {
this.p = Promise.resolve()
console.log('tom')
}

Tom.prototype.eat = function(name: string) {
this.p.then(() => {
console.log('eat ' + name);
})

return this;
}

Tom.prototype.play = function(name: string) {
this.p.then(() => {
console.log('play ' + name);
})

return this;
}

Tom.prototype.sleep = function(interval: number) {
this.p.then(() => {
return new Promise(resolve => {
setTimeout(() => {
resolve(1);
}, interval * 1000)
})
})

return this;
}

四战

直接三面

  • 聊了聊项目
  • 怎么做工程化
  • 如何监控前端的性能
  • 我们会做很多页面,如何提升开发体验
  • 了解无头浏览器么?它内部的引擎是如何去计算代码的

题:
实现一个LRU:

我的回答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class LRU {
list: string[] = [];
map: Record<string, unknown> = {};

get(key: string): unknown {
const index = this.list.findIndex((item) => key === item);
if (index >= 0) {
this.list.splice(index, 1);
}
this.list.unshift(key);

return this.map[key];
}

set(key: string, value: unknown): void {
const index = this.list.findIndex((item) => key === item);
if(index >= 0) {
this.list.splice(index, 1);
}
this.list.unshift(key);

if(this.list.length > 1000) {
const _key = this.list.pop();
delete this.map[_key];
}

this.map[key] = value;
}
}

我的不是一个优解。
建议正确的答案参考:

  • Map 实现
  • 双向链表实现

五战

又被教育部门捞起来了,反正我来者不拒继续面呗。

一面

  • 一个空数组的原型链是怎么样的
  • 用过flex么? 讲讲flex, 有那些熟悉,干嘛用的
  • webpack_require esmodule commonjs, 他们的区别是什么。esmodule可以通过拼字符串来动态加载么
  • function的构造函数与class的构造函数有什么区别
  • 讲讲HTTP2解决了什么问题
    • 二进制传输
    • Header 压缩
    • 多路复用
    • server push
    • TLS(虽然HTTP2不强制, 但是chrome, firefox 只支持tls的HTTP2)
  • 讲讲箭头函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
inner = 'window';

function say() {
console.log(inner);
console.log(this.inner);
}

var obj1 = (function() {
var inner = '1-1';
return {
inner: '1-2',
say: function() {
console.log(inner);
console.log(this.inner);
}
}
})();

var obj2 = (function() {
var inner = '2-1';
return {
inner: '2-2',
say: () => {
console.log(inner);
console.log(this.inner);
}
}
})();


say();// window window
obj1.say(); // 1-1 1-2
obj2.say(); // 2-1 window
obj1.say = say;
obj1.say(); // window 1-2
obj1.say = obj2.say;
obj1.say(); // 2-1 window

做算法题:
实现16进制加法,不能将两数直接转成十进制相加再转回十六进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var map = {
a: 10,
b: 11,
c: 12,
d: 13,
e: 14,
f: 15
}

function parseToNumber(hex) {
if(hex === undefined) {
return 0;
}

if(Object.keys(map).includes(hex)) {
return map[hex]
}else {
return Number(hex)
}
}

function hexAdd(hex1, hex2) {
const arr1 = hex1.split('')
const arr2 = hex2.split('')

let addon = 0;
const result = [];
for(let i = 0; i < Math.max(arr1.length, arr2.length) + 1; i++) {
const num1 = parseToNumber(arr1[arr1.length - 1 - i])
const num2 = parseToNumber(arr2[arr2.length - 1 - i])

let sum = num1 + num2 + addon;
addon = Math.floor(sum / 16);
let rest = sum % 16;
let r = String(rest)
if(rest >= 10) {
const [hex] = Object.entries(map).find(([key, val]) => {
return val === rest
})

r = hex
}

result.unshift(r)
}

return result.join('')
}

console.log(hexAdd('ff', '1'))

结果

最后还是没有成功拿到offer, 很遗憾,原因是【不太适合团队协作】(当然也有可能只是单纯的敷衍)。面试者除了提升自己的硬实力,还应当提升一下自己在职场的软实力,比如朋友推荐的这本书 软技能

六战

被飞书部门又拉起来了,继续

一面

  • 聊一下项目
  • 聊一下你对前端的优化的实践,达成了那些目的
    • 如何判断前端用户体验
  • 聊一下在前端体验方面做的工作
  • 聊聊vue和react的区别
  • 聊聊如何处理中文输入法在输入过程中定时保存状态的问题

算法题

  1. 用css实现一个布局, 来实现一个3x3的布局,每个窗口都要实现16比9的比例

没做出来,关键词 padding-bottom百分比

实现: https://blog.csdn.net/weixin_39357177/article/details/81186498

  1. 实现一个方法,通过栈来存储,实现O(1)的复杂度下获取栈的最小值。

没做出来

框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MiniStack {
store = [];

push(val: number) {
//
}

pop() {
//
}

getMini(): number {
// 实现它
}
}

原题: https://leetcode-cn.com/problems/min-stack/

二面

  • 聊聊公司的项目实现
  • 了解http3么
  • 聊聊react hooks
  • 聊聊公司的业务规范与组件设计规范
  • 聊聊业务代码
  • 聊聊你是如何进行项目优化的
  • 聊聊项目上线的流程
  • 聊聊网络安全性方面的问题,做过xss用户输入防护么
  • 聊聊你是如何关注用户的体验的,主要关心那些指标
  • 让你设计一个倒计时组件,你会怎么设计

做题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 消除字符串中全部的y和连续的xz.
// 用例:
// 'xxyyz' -> 'x'
// 'xxxyyyzzz' -> ''
// 'xyzwzyx' -> 'wzx'

function fixStr(str) {
let result = str;

function loop(str) {
const len = str.length
const fixedStr1 = str.replace('y', '');
if(len !== fixedStr1.length) {
// 变了
loop(fixedStr1)
return;
}

const fixedStr2 = str.replace('xz', '');
if(len !== fixedStr2.length) {
loop(fixedStr2);
return
}


result = str;
}

loop(str)

return result
}
console.log(fixStr('xxyyz'))
console.log(fixStr('xxxyyyzzz'))
console.log(fixStr('xyzwzyx'))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 实现一个功能, 发送5个请求,当请求数
// >=4 报错,则渲染错误页面
// 否则渲染正确页面

function renderErrorPage() {
//
}

function renderSuccessPage(resultMap) {
//
}

const arr = ['a', 'b', 'c', 'd', 'e'];
const successedResultMap = {};

let errorCount = 0;

Promise.all(
arr.map((url, i) =>
fetch(url)
.then((res) => {
successedResultMap[url] = res;
})
.catch(() => {
errorCount++;
})
)
).finally(() => {
if (errorCount >= 4) {
renderErrorPage();
} else {
renderSuccessPage(successedResultMap);
}
});

该问题可以使用Promise.allSettled进行简化

三面

问的和二面差不多,只是更加细一点

算法题在面试官的要求下不做公开

HR面

反正基本不挂人,主要是谈薪的问题。有一个比较难受的点就是字节的薪资是基于你上家薪资来的,或者说大部分大厂的薪资都是基于上家来的。所以比较好的做法是可以先去别的厂然后再来字节996。我就是基础薪资比较低的那种,最后也没感觉有太大的竞争力,比较菜。

结论

万万没想到,我最终还是拿到了Offer。虽然经历比较坎坷,但总不算完全浪费时间。总得来说跳槽这事三分靠运气七分靠实力,但绝对不要被眼前的利益限制住了自己的发展,主要还是要看这次跳槽在自己的职业生涯中能收获什么,而不要为跳而跳。

CORS 现代浏览器跨域请求简明手册

简介

本文主要是阐述与总结现代浏览器的跨域问题

同源策略

同源策略是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

定义

如果两个 URL 的 protocol、port (en-US) (如果有指定的话)和 host 都相同的话,则这两个 URL 是同源。这个方案也被称为“协议/主机/端口元组”,或者直接是 “元组”。(“元组” 是指一组项目构成的整体,双重/三重/四重/五重/等的通用形式)。

下表给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:

URL 结果 原因
http://store.company.com/dir2/other.html 同源 只有路径不同
http://store.company.com/dir/inner/another.html 同源 只有路径不同
https://store.company.com/secure.html 失败 协议不同
http://store.company.com:81/dir/etc.html 失败 端口不同 (http:// 默认端口是80)
http://news.company.com/dir/other.html 失败 主机不同

开始一个跨域请求

你可以使用XMLHttpRequestFetch发起一个跨域请求

你可以在网站http://foo.com发起一个对http://bar.com的请求,如果对方网站许可,那么便能拿到对应的响应,否则则失败。

请求分两类

预检请求

预检请求是一个OPTIONS 请求,在跨域时预先发送到服务端以获取该服务器对跨域访问控制的一些配置,以决定接下来的请求是否会被发送。一般以Header头的形式返回, 相关配置一般以Access-Control-*作为开头

实际请求

简单请求

简单请求不会触发CORS 预检请求

若请求满足所有下述条件,则该请求可视为”简单请求”:

  • 使用下列方法之一:
    • GET
    • HEAD
    • POST
  • 除了被用户代理自动设置的首部字段(例如 Connection, User-Agent)和在 Fetch 规范中定义为 禁用首部名称 的其他首部,允许人为设置的字段为 Fetch 规范定义的 对 CORS 安全的首部字段集合。该集合为:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (需要注意额外的限制)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
    • Content-Type 的值仅限于下列三者之一:
      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
  • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
  • 请求中没有使用 ReadableStream 对象。

复杂请求

除了简单请求以外的所有请求都被称为复杂请求,复杂请求在进行跨域访问前会发送一个 CORS 预检请求

跨域请求响应Headers

跨域时携带Cookies

需要确保请求发起时信任对方

1
2
3
fetch('http://bar.com', {
credentials: 'include'
})

credentials 含义:

  • “omit”: Excludes credentials from this request, and causes any credentials sent back in the response to be ignored.
  • “same-origin”: Include credentials with requests made to same-origin URLs, and use any credentials sent back in responses from same-origin URLs.
  • “include”: Always includes credentials with this request, and always use any credentials sent back in the response.

1
2
3
4
5
const req = new XMLHttpRequest();
req.open('GET', url, true);
req.withCredentials = true;
req.onreadystatechange = handler;
req.send();

需要确保服务端响应头返回

1
Access-Control-Allow-Credentials: true

注意此时Access-Control-Allow-Origin不能为 “\“*

需要确保要发送的Cookie满足SameSite条件

注意Chrome 80 后将默认值从原来的None改为Lax, 相关影响可以看如下文章

参考文章

HTTP中的各种缓存标签

  • ETag(如: 50b1c1d4f775c61:df3):
    发送请求时服务端会通过ETag返回资源计算出的实体值,在再次发送请求时浏览器会带上一个If-None-Match(如W / "50b1c1d4f775c61:df3")请求头,服务端会对这个请求与这个资源的实体值进行对比,如果相同则直接返回304
  • Last-Modified(如: Fri , 12 May 2006 18:53:33 GMT):
    发送请求时服务端会通过ETag返回资源最后更新时间,在再次发送请求时浏览器会带上一个If-Modified-Since请求头,服务端会对这个请求与这个资源的最后更新时间进行对比,如果服务端资源的最后更新时间>=If-Modified-Since则返回304
  • Expires/Cache-Control:
    浏览器缓存,如果当前时间<过期时间则不会发送请求。该过程不需要服务端介入,是浏览器本身的缓存行为。可以通过首次请求资源后服务端返回的响应头来被服务端进行控制。
    • Expires来源于http/1.0
    • Cache-Control来源于http/1.1 max-age单位为秒
    • 如果Cache-ControlExpires同时存在,Cache-Control生效

用户操作与缓存

用户操作 Expires/Cache-Control Last-Modified/Etag
地址栏回车 有效 有效
页面链接跳转 有效 有效
新开窗口 有效 有效
前进后退 有效 有效
F5刷新 无效 有效
Ctrl+F5强制刷新 无效 无效

如何开发与坚持维护一个企业级的开源项目

TRPG Engine 是我一直坚持维护的一个开源项目。正如所有的开源项目都起源于开发者的兴趣,TRPG Engine也是我对于跑团这一小众领域的兴趣。

我在没有支援没有赞助,单凭个人爱好独立坚持开发3年以上不间断。前端代码累计提交2700+, 后端代码累计提交1200+,风雨无阻。

谨以此文,分享一下我维护这个开源项目的经历。并与千千万万独立坚持的开源人共勉。

起初

所有的个人项目都起源于想做。因为我想做,因此我建立了这样的项目。最初的目的很单纯,就是作为我学习React的一个实践。

最初选用的技术栈是React + Redux。之所以用React是为了可以共享一部分的代码到React Native, 这样的搭配会很大的减少重复逻辑的开发与后期的维护,以减少成本。

而事实是我在两者共存与中间代码上花费了很多的精力,写了很多中间脚本使网页版与RN版能够相互协作。

明确定位

一个项目,如果要走的长远,必须明确你的产品定位与目标用户。比如同样作为即时通讯应用, 钉钉瞄准的用户群是上班族,slack的用户群是开发者,discord的目标用户是游戏玩家,而qq的用户群是小学生。以此为原点,钉钉开发出了一系列日历,考勤,协同,已读未读,ding等功能。slack只做了大量机器人,discord专注于语音会话与大用户群组聊天,而qq则整出了很多花样。

明确自己的定位,其目标一定是解决某种痛点与缺陷,做出任何的抉择都需要考虑是不是符合自己的定位。一个明确的定位可以帮助你不会花费无意义的时间在没有收益的功能上。

时刻保持热情与思考

时刻记住,项目是为自己做的。它不是一种谋生工具,而是一种兴趣爱好。它最初应当起源于你对现状的小小不满,终止于对此兴趣的终止。不妨增加一些筹码:比如它可以作为你的游戏场,比如他可以作为你谋求更高事业渠道的凭证,比如他可以是你与你伴侣之间的小小私人空间,比如你可以这样告诉自己,你的每一行代码都有可能帮助到素未蒙面的陌生人,而他会感谢你的无私付出。这些会帮助你时刻保持热情与动力,而不至于让这个项目半途而废。

维护自己的门面

如果一个项目想要宣传,一个门面则非常重要。这可能是一个README, 一片文章,一个官网。但是如果没有任何说明,告诉别人这是什么,你在做什么。又能期待谁来阅读你的源码呢?

自己是第一用户

对于个人项目来说,自己才是自己产品最忠实的用户,只有这样才能不断发掘产品的不足,并不断改善。如果自己都不想用自己的产品,那么这个产品还有存在的必要么?

开发

回到技术细节:

因为技术在不断更新。而有的库又逐步步入生命周期的终结,很多情况不得不花费时间去迁移。

TRPG Engine经历过几次大的迁移/迭代:

  • 原来TRPG Engine的后端是使用的node-orm2. 然后这个库被弃用了。因此花了很大经历将其迁移到sequelize,包括代码的修改与数据库的变更。这个没什么好分享的,纯粹是当时的选择有限,而库的生命周期比较短。
  • 最早的TRPG Engine使用的是用纯粹的js写的,但是随着业务的不断增多,代码复杂度的变高,我选择使用Typescript对我的项目进行重构。从长远来看这一步是非常值得的,TS的类型系统长远的帮助到了一个项目的健康发展。(其实有点小遗憾,从目前的角度来看flow在一些细节做得比ts更加好)。
  • React ClassComponentReact Hooks: hooks无疑拥有更高的抽象性,使用Hooks能抛弃HOC这种很难被typescript支持的写法。同时更方便代码的复用,特别是对于React和React Native代码并存的项目来说。
  • 前端重构, 重写界面。原来的TRPG Engine是参考的钉钉设计的,但实际的迭代中,频道的需求更为突出。即需要由原来的单层消息列表变成双层消息列表。因此花费了几个月时间写了一版类似于Discord设计的新版页面,并且颜色也从原来的亮色变成保护眼睛的暗色。

善用单元测试

为了一个项目的长远发展,测试用例与持续集成都是必要的。特别是单元测试,在很多时候能帮助到项目的提前预知问题。坚持每个bug都有一个对应的单元测试用例,防止再次出现类似的问题。

TRPG Engine 虽然开始写测试用例比较晚,但是一旦有机会就会补充一些测试用例,以防止出现一些边缘问题。

目前TRPG Engine前端有223个测试用例,后端有284个测试用例,还远远不够一个项目的健康发展。

学会数据迁移

为了方便数据库的升级,自己fork了一个sequelize-auto-migrations库用于生成数据库的迁移脚本, 因为原作者已经不维护了。

数据库迁移脚本可以保证在任意环境下都能生成正确的数据库格式。这对于关系型数据库来说非常重要。

兴趣是原始驱动力

与商业项目不同的是,个人项目可以把一切自己感兴趣、想做的东西都想办法以某种形式糅合到自己的项目中。

TRPG Engine就拥有这些有意思的系统:

  • 基于xml描述与js沙盒的动态计算的表单系统
  • 基于slate实现的笔记系统与富文本输入框
  • 基于meta信息的动态面板
  • 基于BBCode的消息解释器

运维

为了维护与运营这个项目,唯一的支出就是在服务器和OSS上了,零零散散也花费了几千元在上面了。一台2核4G的主服务器, 在主服务器上运行Mysql, Redis与Mysql。另外还有一台1核1G的小型服务器作为语音服务器与测试服务器。前端的代码都是本地编译好后提交到第三方对象存储上,这样用户的访问会更加迅速与快捷。

赞助?

曾经也考虑过要不要和别人一样放出二维码,看看能不能获得一些打赏,后来想了想还是算了。一方面没有多少的关注,我本人也比较低调不善于宣传。另一方面感觉,掺杂了利益后的开源项目,总感觉失去了纯粹性。爱好只能是爱好,如果成为一种牟利方式,那么可能就不像当初那么纯粹了。

感谢

最后感谢所有开源作者的无私奉献,正是你们存在才能让这个互联网世界变得更加精彩。

从 Webpack 到 Snowpack, 编译速度提升十倍以上——TRPG Engine迁移小记

动机

TRPG Engine经过长久以来的迭代,项目已经显得非常臃肿了。数分钟的全量编译, 每次按下保存都会触发一次10s1m不等的增量编译让我苦不堪言, 庞大的依赖使其每一次编译都会涉及很多文件和很多包,长时的编译时间大大降低了开发效率与迭代速度。

优化方式

经过一段时间的考察,我选择了Snowpack作为解决方案。与Webpack不同的是,除了第一次的全量编译以外,Snowpack的增量编译不会涉及到庞大的node_modules文件夹, 准确来说只会编译变更文件本身。甚至于如果没有对依赖进行变更,下次的全量编译会直接动用之前编译的文件缓存,不需要花时间等待node_modules的编译。

为什么会这么快?这是由于Snowpack本身的实现与设计哲学有关的。相比Webpack, Snowpack利用了现代浏览器的本身的module系统,跳过复杂的模型之间的组织编译过程而只关注于变更文件本身的编译,这样当然快了。

Snowpack官方的一张图来说:

snowpack的最小编译单位是文件,而webpack的最小编译单位为chunk, 而chunk还需要额外的计算, 不论是编译部分还是编译后的组装部分。snowpack的设计逻辑天生决定了她的速度。

优化前(使用webpack):

全量编译:

增量编译:

全量请求用时:

优化后(使用snowpack):

全量编译:

增量编译:

(看不到编译用时,但是体感在1s内. 而且该效果在电脑运行其他应用时更加显著)

全量请求用时:

  • 使用http1

  • 使用http2

以上测试是保证电脑在空闲时间,且保存与操作内容为同一文件

该用时已经是平时操作的最快时间,为此我的MBR重启了一次强制清空了swap空间, 实际表现会更加显著

因为文件依赖于浏览器的耗时,而浏览器需要串行请求依赖,因此耗时会更加长

但实际使用中使用snowpack会更加优秀。因为其相比webpack会大大节约电脑资源。在webpack编译时会占用大量的电脑资源,会影响到其他操作

遇到的坑与解决方案

TRPG Engine算是非常经典的Webpack应用了, 使用了各种Loader。光通用配置就有250+行,各种优化配置,各种 alias。等等长时间迭代积攒下来的配置,因此毫不意外的会遇到很多问题与坑。

以下是我遇到的问题与解决方案:

  • 问题1:
    • 入口文件使用的是HtmlwebpackPlugin编译的handlebars文件,而snowpack不支持handlebars文件作为入口
    • 解决方案:重写一个snowpack专用的入口文件。使用handlebars主要解决的是dll的问题,snowpack不需要处理这部分的优化因此直接跳过
  • 问题2:
    • snowpack加载文件策略与node不同。有同名文件和文件夹会优先使用文件夹的index.js作为路径解析。具体看现象可以参考这个讨论: https://github.com/snowpackjs/snowpack/discussions/1320
    • 解决方案:改名字,让文件夹与文件名不会出现重复。包括同名但是大小写不同的问题,因为底层是nodefs.stat实现,在大小写敏感的系统下依旧会视为同名
  • 问题3:
    • TRPG Engine不但有web端,还有react-native端,而react-native是无法被正常解析的。我只想要处理web端的开发环境使用snowpack优化开发体验
    • 解决方案: exclude配置手动过滤
  • 问题4:
    • tspath不支持,虽然有了@snowpack/plugin-typescript但是不支持tspath。
    • 解决方案: 手动写了一个自动解析的逻辑将其变成对应的alias加到配置上
  • 问题5:

    • 在css中引入了字体文件,但是无法正常加载。因为snowpack无法正确识别url指定的资源并将其打包(webpack是使用css-loader来实现的)
    • 解决方案:
      1
      2
      3
      scripts: {
      'mount:font': 'mount src/web/assets/fonts --to /main/fonts',
      },
  • 问题6:

    • 对于一些特殊的写法我不想影响webpack的实现但是snowpack不支持这种写法。比如使用externals实现的配置引入, 比如DefinePlugin实现的process.env(在snowpack中必须使用import.meta.env), 再比如require的使用
    • 解决方案: 我实现了一个snowpack-plugin-replace插件用于将这些东西全部替换成我想要的代码。具体使用如下:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      [
      'snowpack-plugin-replace',
      {
      list: [
      {
      from: /process\.env/g,
      to: 'import.meta.env',
      },
      {
      from: `require("../../package.json").version`,
      to: '"0.0.0"',
      },
      {
      from: `const resBundle = require("i18next-resource-store-loader!./langs/index.js");`,
      to: 'import resBundle from "./langs/zh-CN/translation.json"',
      },
      {
      from: 'import Config from "config";',
      to: `const Config = ${JSON.stringify({
      sentry: require('config').get('sentry'),
      })};`,
      },
      ],
      },
      ],
  • 问题7:

    • rollup抛出无法解析this的警告
    • 解决方案: 使用context指向window来移除警告
      1
      2
      3
      4
      5
      installOptions: {
      rollup: {
      context: 'window',
      },
      },
  • 问题8:

    • snowpack打包目标路径与原有的build文件夹冲突
    • 解决方案: 修改输出目录为.snowpack并在gitignore中添加该文件夹
      1
      2
      3
      devOptions: {
      out: '.snowpack',
      },
  • 问题9:

    • 使用@snowpack/plugin-typescript内部包对全局变量的声明会出现重复声明的报错
    • 解决方案: tsconfig"skipLibCheck": true
  • 问题10:
    • 现有的依赖需要@babel/plugin-transform-runtime提供的helpers作为全局依赖
    • 解决方案: 经检查是用到了regenerator功能,手动安装regenerator-runtime并在包前引入import 'regenerator-runtime/runtime';
  • 问题11:
  • 问题12:
    • 在使用less的import逻辑无法正常运行,这是由于snowpack的具体实现决定的。
    • 暂时无法解决,使用snowpack-plugin-replace将其替换为css文件导入作为临时解决方案, 见讨论: Github 使用@snowpack/plugin-run-script插件将其手动编译后放在公共文件中。可以参考这个commit

总结

Snowpack虽然作为一个新兴的打包工具,目前尚不是非常完善, 功能也没有webpack这样丰富与齐全。但是它的新的打包设计对于有一定规模的前端应用还是非常优秀的。能极大提升开发效率。不失为一种好的解决方案。当然最后输出还是需要使用webpack对其进行一定的优化,毕竟原生的module支持目前浏览器的支持度还没有达到覆盖一个理想的地步https://caniuse.com/es6-module

最后这是我最后提交的pr

TRPG Engine —— 一个功能完善即时通讯解决方案

项目背景

除了传统的聊天软件,还有为固定需求打造的定位其他的聊天软件

比如钉钉立足于工作流,slack专注于程序员之间的项目沟通。而TRPG Engine就是一款为了小众的跑团玩家所打造的通用即时通讯解决方案

项目亮点

  • 基于XML描述的人物卡系统(可以理解为动态表单) - Playground
  • Web端与RN端共享redux状态的实践与工具链
  • 多端并存与代码共享(Web端, RN端, Portal端 (Portal端是RN端通过webview进行一部分中间操作的方式,类似于各种手机App的H5端) )
  • 其他的一些自研实用工具,如RN端WEB端通用Portal组件, 快速生成通用表单, 基于BBCode的消息解释器, 通用缓存管理机制
  • 工程化代码,可拓展性强
  • 移动端兼容与PWA

依赖

  • MySql 5.7+
  • Redis

功能列表

通用功能

  • 用户登入登出
  • 用户注册
  • 私聊/群聊
  • 头像上传与裁剪
  • 用户设置
  • 好友管理
    • 好友发送邀请/同意邀请
  • 基于BBCode的消息解析器
    • url内容
    • 图片内容
    • @提及
  • 多种消息类型
    • 通用消息
    • 提示消息
    • 卡片消息
  • 消息回复与消息撤回
  • 自动抓取消息内的网址的预览信息
  • 基于slate的富文本编辑器
  • 消息通知
    • 移动端基于upush。包括本地进程未被杀死的本地推送与本地被杀死后的upush推送
  • 多种文件管理策略
    • 头像上传七牛云或本地
    • 聊天图片使用外置图片服务转发到第三方图床
    • 聊天文件存储在本地,定时删除
  • 单向聊天消息机器人
  • 群组多面板
    • 多面板类型: 目前有笔记面板与文字频道
    • 面板的编辑/删除/拖拽排序
  • app热更新与apk更新
    • 热更新基于自部署的codepush服务器, apk更新会自动获取最新的apk版本
  • app下载管理
  • 多国语言(中英, 尚未完全覆盖)

跑团相关

  • 基于Slate的笔记系统
  • 人物卡系统
    • 基于XML的布局描述与内置JS沙盒解释器来解释js脚本
    • 人物卡的切换与切换时发送消息变换头像与名字
    • 人物卡分享与Fork
  • 投骰表达式与消息拦截器
  • 输入时向所有人发送输入状态
  • 在线招募系统

线上监控

  • 计划任务记录
  • 接口耗时统计
  • 请求限流
  • 系统日志: 日志会被转发到loggly或本地记录。其他的操作相关会存储到数据库
    • 用户登录记录
    • 机器人记录
    • 投骰记录
    • oss文件记录
  • 登录/注册统计汇总
  • 前端后端错误汇报

项目规模

  • 开发时间: 3年
  • 所用数据表: 61张
  • 功能完整的多端:
    • 两版网页端
    • 基于React Native的安卓端

预览

在线地址: https://trpg.moonrailgun.com/
开源地址: https://github.com/TRPGEngine/Client

React 注意事项

Inline Style 内联样式

内联样式有一些简写操作

比如:

1
<div style={{height: 10}}></div>

转化为

1
<div style="height: 10px"></div>


在jsx中会自动加上px单位。但需要注意的是, 并不是每种属性都会自动加上单位的。容易出问题的比如lineHeight

这里有一个列表,以下属性不会自动加上px

  • animationIterationCount
  • boxFlex
  • boxFlexGroup
  • boxOrdinalGroup
  • columnCount
  • fillOpacity
  • flex
  • flexGrow
  • flexPositive
  • flexShrink
  • flexNegative
  • flexOrder
  • fontWeight
  • lineClamp
  • lineHeight
  • opacity
  • order
  • orphans
  • stopOpacity
  • strokeDashoffset
  • strokeOpacity
  • strokeWidth
  • tabSize
  • widows
  • zIndex
  • zoom

参考文章: Shorthand for Specifying Pixel Values in style props

又拍云整站网页存放到OSS的解决方案

背景

前后端分离后允许前端页面静态访问,因此整站放到OSS上成为可能

但是有一个问题就是要如何实现原来单页应用上刷新页面无法获取到正确的网页文件的问题

该问题在nginx上的解决方案是这样的:

1
2
3
location / {
try_files $uri /index.html;
}

解决方案

又拍云边缘规则编程模式下配置规则

1
$WHEN($NOT($MATCH($_URI,'[\\.]')))/index.html

表示所有连接都返回根目录的index.html文件

更加复杂的场景

对于实际场景中,可能有多个应用并存,比如我的TRPG Engine中就有多个单页应用存在一个域名下

三个应用:

  • /index.html 主应用
  • /playground/index.html playground页,只有一个页面
  • /portal/index.html portal页, 会有多个页面存在

我给出配置如下:

优先级1: playground break

1
$WHEN($EQ($_URI,'/playground'))/playground/index.html

优先级2: portal break

1
$WHEN($ALL($MATCH($_URI,'^/portal'),$NOT($MATCH($_URI,'[\\.]'))))/portal/index.html

优先级3: 单页应用

1
$WHEN($NOT($MATCH($_URI,'[\\.]')))/index.html

其中break的意思是满足一个条件则不会继续往下匹配

参考链接

参考链接: https://cnodejs.org/topic/5badd93037a6965f59051d40

记一次重启网络服务后网络不通的调试

背景

我的网络服务端是用docker搭建的Nginx服务器。在一次重启网络服务service network restart后监控机器人告知服务挂了。检查以后发现服务可以ping通,查询日志无异常,docker服务反复重启也无法使其正常生效。考虑到使用的是弹性公网会不会是虚拟网络之间的问题,经排查后也不存在该问题。

解决方案

在google找方案的时候受到启发,输入netstat -tunlp检查监听端口。果然没有只有一条tcp6 :::80的监听而没有应当有的0.0.0.0:80的监听。那么问题就出在端口方面,而我docker的状态明明写的是0.0.0.0:80->80/tcp啊,为什么会没有监听ipv4的端口?

为使其能够正常运行。编辑/etc/sysctl.conf加上一条以开启ipv4转发

1
net.ipv4.ip_forward=1

如果改行已存在则直接修改.

成功后输入sysctl -p检查变更已成功应用。

无需重启即可生效,检查后发现网络服务已恢复正常