开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

面试官问:能否模拟实现JS的call和apply方法

发表于 2018-11-30

前言

你好,我是若川。这是面试官问系列的第三篇,旨在帮助读者提升JS基础知识,包含new、call、apply、this、继承相关知识。

面试官问系列文章如下:感兴趣的读者可以点击阅读。

1.面试官问:能否模拟实现JS的new操作符

2.面试官问:能否模拟实现JS的bind方法

3.面试官问:能否模拟实现JS的call和apply方法

4.面试官问:JS的this指向

5.面试官问:JS的继承

之前写过两篇《面试官问:能否模拟实现JS的new操作符》和《面试官问:能否模拟实现JS的bind方法》

其中模拟bind方法时是使用的call和apply修改this指向。但面试官可能问:能否不用call和apply来实现呢。意思也就是需要模拟实现call和apply的了。

附上之前写文章写过的一段话:已经有很多模拟实现call和apply的文章,为什么自己还要写一遍呢。学习就好比是座大山,人们沿着不同的路登山,分享着自己看到的风景。你不一定能看到别人看到的风景,体会到别人的心情。只有自己去登山,才能看到不一样的风景,体会才更加深刻。

先通过MDN认识下call和apply

MDN 文档:Function.prototype.call()

语法

1
复制代码fun.call(thisArg, arg1, arg2, ...)

thisArg

在fun函数运行时指定的this值。需要注意的是,指定的this值并不一定是该函数执行时真正的this值,如果这个函数处于非严格模式下,则指定为null和undefined的this值会自动指向全局对象(浏览器中就是window对象),同时值为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象。

arg1, arg2, …

指定的参数列表

返回值

返回值是你调用的方法的返回值,若该方法没有返回值,则返回undefined。

MDN 文档:Function.prototype.apply()

1
复制代码func.apply(thisArg, [argsArray])

thisArg

可选的。在 func 函数运行时使用的 this 值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装。

argsArray

可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。如果该参数的值为 null 或 undefined,则表示不需要传入任何参数。从ECMAScript 5 开始可以使用类数组对象。

返回值

调用有指定this值和参数的函数的结果。
直接先看例子1

call 和 apply 的异同

相同点:

1、call和apply的第一个参数thisArg,都是func运行时指定的this。而且,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装。

2、都可以只传递一个参数。

不同点:apply只接收两个参数,第二个参数可以是数组也可以是类数组,其实也可以是对象,后续的参数忽略不计。call接收第二个及以后一系列的参数。

看两个简单例子1和2**:

1
2
3
4
5
6
7
8
9
10
复制代码// 例子1:浏览器环境 非严格模式下
var doSth = function(a, b){
console.log(this);
console.log([a, b]);
}
doSth.apply(null, [1, 2]); // this是window // [1, 2]
doSth.apply(0, [1, 2]); // this 是 Number(0) // [1, 2]
doSth.apply(true); // this 是 Boolean(true) // [undefined, undefined]
doSth.call(undefined, 1, 2); // this 是 window // [1, 2]
doSth.call('0', 1, {a: 1}); // this 是 String('0') // [1, {a: 1}]
1
2
3
4
5
6
7
8
9
复制代码// 例子2:浏览器环境 严格模式下
'use strict';
var doSth2 = function(a, b){
console.log(this);
console.log([a, b]);
}
doSth2.call(0, 1, 2); // this 是 0 // [1, 2]
doSth2.apply('1'); // this 是 '1' // [undefined, undefined]
doSth2.apply(null, [1, 2]); // this 是 null // [1, 2]

typeof 有7种类型(undefined number string boolean symbol object function),笔者都验证了一遍:更加验证了相同点第一点,严格模式下,函数的this值就是call和apply的第一个参数thisArg,非严格模式下,thisArg值被指定为 null 或 undefined 时this值会自动替换为指向全局对象,原始值则会被自动包装,也就是new Object()。

重新认识了call和apply会发现:它们作用都是一样的,改变函数里的this指向为第一个参数thisArg,如果明确有多少参数,那可以用call,不明确则可以使用apply。也就是说完全可以不使用call,而使用apply代替。

也就是说,我们只需要模拟实现apply,call可以根据参数个数都放在一个数组中,给到apply即可。

模拟实现 apply

既然准备模拟实现apply,那先得看看ES5规范。ES5规范 英文版,ES5规范 中文版。apply的规范下一个就是call的规范,可以点击打开新标签页去查看,这里摘抄一部分。

Function.prototype.apply (thisArg, argArray)

当以 thisArg 和 argArray 为参数在一个 func 对象上调用 apply 方法,采用如下步骤:

1.如果 IsCallable(func) 是 false, 则抛出一个 TypeError 异常。

2.如果 argArray 是 null 或 undefined, 则返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。

3.返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。

4.如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常。

5~8 略

9.提供 thisArg 作为 this 值并以 argList 作为参数列表,调用 func 的 [[Call]] 内部方法,返回结果。

apply 方法的 length 属性是 2。

在外面传入的 thisArg 值会修改并成为 this 值。thisArg 是 undefined 或 null 时它会被替换成全局对象,所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。

结合上文和规范,如何将函数里的this指向第一个参数thisArg呢,这是一个问题。
这时候请出例子3:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// 浏览器环境 非严格模式下
var doSth = function(a, b){
console.log(this);
console.log(this.name);
console.log([a, b]);
}
var student = {
name: '若川',
doSth: doSth,
};
student.doSth(1, 2); // this === student // true // '若川' // [1, 2]
doSth.apply(student, [1, 2]); // this === student // true // '若川' // [1, 2]

可以得出结论1:在对象student上加一个函数doSth,再执行这个函数,这个函数里的this就指向了这个对象。那也就是可以在thisArg上新增调用函数,执行后删除这个函数即可。
知道这些后,我们试着容易实现第一版本:

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 getGlobalObject(){
return this;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){ // `apply` 方法的 `length` 属性是 `2`。
// 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
if(typeof this !== 'function'){
throw new TypeError(this + ' is not a function');
}

// 2.如果 argArray 是 null 或 undefined, 则
// 返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}

// 3.如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常 .
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}

if(typeof thisArg === 'undefined' || thisArg === null){
// 在外面传入的 thisArg 值会修改并成为 this 值。
// ES3: thisArg 是 undefined 或 null 时它会被替换成全局对象 浏览器里是window
thisArg = getGlobalObject();
}

// ES3: 所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。
thisArg = new Object(thisArg);
var __fn = '__fn';
thisArg[__fn] = this;
// 9.提供 thisArg 作为 this 值并以 argList 作为参数列表,调用 func 的 [[Call]] 内部方法,返回结果
var result = thisArg[__fn](...argsArray);
delete thisArg[__fn];
return result;
};

实现第一版后,很容易找出两个问题:

  • 1.__fn 同名覆盖问题,thisArg对象上有__fn,那就被覆盖了然后被删除了。

针对问题1
解决方案一:采用ES6 Sybmol() 独一无二的。可以本来就是模拟ES3的方法。如果面试官不允许用呢。
解决方案二:自己用Math.random()模拟实现独一无二的key。面试时可以直接用生成时间戳即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码// 生成UUID 通用唯一识别码
// 大概生成 这样一串 '18efca2d-6e25-42bf-a636-30b8f9f2de09'
function generateUUID(){
var i, random;
var uuid = '';
for (i = 0; i < 32; i++) {
random = Math.random() * 16 | 0;
if (i === 8 || i === 12 || i === 16 || i === 20) {
uuid += '-';
}
uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random))
.toString(16);
}
return uuid;
}
// 简单实现
// '__' + new Date().getTime();

如果这个key万一这对象中还是有,为了保险起见,可以做一次缓存操作。比如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码var student = {
name: '若川',
doSth: 'doSth',
};
var originalVal = student.doSth;
var hasOriginalVal = student.hasOwnProperty('doSth');
student.doSth = function(){};
delete student.doSth;
// 如果没有,`originalVal`则为undefined,直接赋值新增了一个undefined,这是不对的,所以需判断一下。
if(hasOriginalVal){
student.doSth = originalVal;
}
console.log('student:', student); // { name: '若川', doSth: 'doSth' }
  • 2.使用了ES6扩展符...

解决方案一:采用eval来执行函数。

eval把字符串解析成代码执行。

MDN 文档:eval

语法

1
复制代码eval(string)

参数

string

表示JavaScript表达式,语句或一系列语句的字符串。表达式可以包含变量以及已存在对象的属性。

返回值

执行指定代码之后的返回值。如果返回值为空,返回undefined

解决方案二:但万一面试官不允许用eval呢,毕竟eval是魔鬼。可以采用new Function()来生成执行函数。
MDN 文档:Function

语法

1
复制代码new Function ([arg1[, arg2[, ...argN]],] functionBody)

参数

arg1, arg2, … argN

被函数使用的参数的名称必须是合法命名的。参数名称是一个有效的JavaScript标识符的字符串,或者一个用逗号分隔的有效字符串的列表;例如“×”,“theValue”,或“A,B”。

functionBody

一个含有包括函数定义的JavaScript语句的字符串。

接下来看两个例子:

1
2
3
复制代码简单例子:
var sum = new Function('a', 'b', 'return a + b');
console.log(sum(2, 6));
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
复制代码// 稍微复杂点的例子:
var student = {
name: '若川',
doSth: function(argsArray){
console.log(argsArray);
console.log(this.name);
}
};
// var result = student.doSth(['Rowboat', 18]);
// 用new Function()生成函数并执行返回结果
var result = new Function('return arguments[0][arguments[1]](arguments[2][0], arguments[2][1])')(student, 'doSth', ['Rowboat', 18]);
// 个数不定
// 所以可以写一个函数生成函数代码:
function generateFunctionCode(argsArrayLength){
var code = 'return arguments[0][arguments[1]](';
for(var i = 0; i < argsArrayLength; i++){
if(i > 0){
code += ',';
}
code += 'arguments[2][' + i + ']';
}
code += ')';
// return arguments[0][arguments[1]](arg1, arg2, arg3...)
return code;
}

你可能不知道在ES3、ES5中 undefined 是能修改的

可能大部分人不知道。ES5中虽然在全局作用域下不能修改,但在局部作用域中也是能修改的,不信可以复制以下测试代码在控制台执行下。虽然一般情况下是不会的去修改它。

1
2
3
4
5
复制代码function test(){
var undefined = 3;
console.log(undefined); // chrome下也是 3
}
test();

所以判断一个变量a是不是undefined,更严谨的方案是typeof a === 'undefined'或者a === void 0;
这里面用的是void,void的作用是计算表达式,始终返回undefined,也可以这样写void(0)。
更多可以查看韩子迟的这篇文章:为什么用「void 0」代替「undefined」
解决了这几个问题,比较容易实现如下代码。

使用 new Function() 模拟实现的apply

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
复制代码// 浏览器环境 非严格模式
function getGlobalObject(){
return this;
}
function generateFunctionCode(argsArrayLength){
var code = 'return arguments[0][arguments[1]](';
for(var i = 0; i < argsArrayLength; i++){
if(i > 0){
code += ',';
}
code += 'arguments[2][' + i + ']';
}
code += ')';
// return arguments[0][arguments[1]](arg1, arg2, arg3...)
return code;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){ // `apply` 方法的 `length` 属性是 `2`。
// 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
if(typeof this !== 'function'){
throw new TypeError(this + ' is not a function');
}
// 2.如果 argArray 是 null 或 undefined, 则
// 返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}
// 3.如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常 .
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}
if(typeof thisArg === 'undefined' || thisArg === null){
// 在外面传入的 thisArg 值会修改并成为 this 值。
// ES3: thisArg 是 undefined 或 null 时它会被替换成全局对象 浏览器里是window
thisArg = getGlobalObject();
}
// ES3: 所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。
thisArg = new Object(thisArg);
var __fn = '__' + new Date().getTime();
// 万一还是有 先存储一份,删除后,再恢复该值
var originalVal = thisArg[__fn];
// 是否有原始值
var hasOriginalVal = thisArg.hasOwnProperty(__fn);
thisArg[__fn] = this;
// 9.提供 `thisArg` 作为 `this` 值并以 `argList` 作为参数列表,调用 `func` 的 `[[Call]]` 内部方法,返回结果。
// ES6版
// var result = thisArg[__fn](...args);
var code = generateFunctionCode(argsArray.length);
var result = (new Function(code))(thisArg, __fn, argsArray);
delete thisArg[__fn];
if(hasOriginalVal){
thisArg[__fn] = originalVal;
}
return result;
};

利用模拟实现的apply模拟实现call

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
复制代码Function.prototype.callFn = function call(thisArg){
var argsArray = [];
var argumentsLength = arguments.length;
for(var i = 0; i < argumentsLength - 1; i++){
// argsArray.push(arguments[i + 1]);
argsArray[i] = arguments[i + 1];
}
console.log('argsArray:', argsArray);
return this.applyFn(thisArg, argsArray);
}
// 测试例子
var doSth = function (name, age){
var type = Object.prototype.toString.call(this);
console.log(typeof doSth);
console.log(this === firstArg);
console.log('type:', type);
console.log('this:', this);
console.log('args:', [name, age], arguments);
return 'this--';
};

var name = 'window';

var student = {
name: '若川',
age: 18,
doSth: 'doSth',
__fn: 'doSth',
};
var firstArg = student;
var result = doSth.applyFn(firstArg, [1, {name: 'Rowboat'}]);
var result2 = doSth.callFn(firstArg, 1, {name: 'Rowboat'});
console.log('result:', result);
console.log('result2:', result2);

细心的你会发现注释了这一句argsArray.push(arguments[i + 1]);,事实上push方法,内部也有一层循环。所以理论上不使用push性能会更好些。面试官也可能根据这点来问时间复杂度和空间复杂度的问题。

1
2
3
4
5
6
7
8
9
10
复制代码// 看看V8引擎中的具体实现:
function ArrayPush() {
var n = TO_UINT32( this.length ); // 被push的对象的length
var m = %_ArgumentsLength(); // push的参数个数
for (var i = 0; i < m; i++) {
this[ i + n ] = %_Arguments( i ); // 复制元素 (1)
}
this.length = n + m; // 修正length属性的值 (2)
return this.length;
};

行文至此,就基本结束了,你可能还发现就是写的非严格模式下,thisArg原始值会包装成对象,添加函数并执行,再删除。而严格模式下还是原始值这个没有实现,而且万一这个对象是冻结对象呢,Object.freeze({}),是无法在这个对象上添加属性的。所以这个方法只能算是非严格模式下的简版实现。最后来总结一下。

总结

通过MDN认识call和apply,阅读ES5规范,到模拟实现apply,再实现call。

就是使用在对象上添加调用apply的函数执行,这时的调用函数的this就指向了这个thisArg,再返回结果。引出了ES6 Symbol,ES6的扩展符...、eval、new Function(),严格模式等。

事实上,现实业务场景不需要去模拟实现call和apply,毕竟是ES3就提供的方法。但面试官可以通过这个面试题考察候选人很多基础知识。如:call、apply的使用。ES6 Symbol,ES6的扩展符...,eval,new Function(),严格模式,甚至时间复杂度和空间复杂度等。

读者发现有不妥或可改善之处,欢迎指出。另外觉得写得不错,可以点个赞,也是对笔者的一种支持。

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
复制代码// 最终版版 删除注释版,详细注释看文章
// 浏览器环境 非严格模式
function getGlobalObject(){
return this;
}
function generateFunctionCode(argsArrayLength){
var code = 'return arguments[0][arguments[1]](';
for(var i = 0; i < argsArrayLength; i++){
if(i > 0){
code += ',';
}
code += 'arguments[2][' + i + ']';
}
code += ')';
return code;
}
Function.prototype.applyFn = function apply(thisArg, argsArray){
if(typeof this !== 'function'){
throw new TypeError(this + ' is not a function');
}
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}
if(typeof thisArg === 'undefined' || thisArg === null){
thisArg = getGlobalObject();
}
thisArg = new Object(thisArg);
var __fn = '__' + new Date().getTime();
var originalVal = thisArg[__fn];
var hasOriginalVal = thisArg.hasOwnProperty(__fn);
thisArg[__fn] = this;
var code = generateFunctionCode(argsArray.length);
var result = (new Function(code))(thisArg, __fn, argsArray);
delete thisArg[__fn];
if(hasOriginalVal){
thisArg[__fn] = originalVal;
}
return result;
};
Function.prototype.callFn = function call(thisArg){
var argsArray = [];
var argumentsLength = arguments.length;
for(var i = 0; i < argumentsLength - 1; i++){
argsArray[i] = arguments[i + 1];
}
return this.applyFn(thisArg, argsArray);
}

扩展阅读

《JavaScript设计模式与开发实践》- 第二章 第 2 章 this、call和apply

JS魔法堂:再次认识Function.prototype.call

不用call和apply方法模拟实现ES5的bind方法

JavaScript深入之call和apply的模拟实现

笔者精选文章

学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK

学习 lodash 源码整体架构,打造属于自己的函数式编程类库

学习 underscore 源码整体架构,打造属于自己的函数式编程类库

学习 jQuery 源码整体架构,打造属于自己的 js 类库

面试官问:JS的继承

面试官问:JS的this指向

面试官问:能否模拟实现JS的call和apply方法

面试官问:能否模拟实现JS的bind方法

面试官问:能否模拟实现JS的new操作符

前端使用puppeteer 爬虫生成《React.js 小书》PDF并合并

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。

个人博客

segmentfault前端视野专栏,开通了前端视野专栏,欢迎关注~

掘金专栏,欢迎关注~

知乎前端视野专栏,开通了前端视野专栏,欢迎关注~

github blog,求个star^_^~

微信公众号 若川视野

可能比较有趣的微信公众号,长按扫码关注。也可以加微信 ruochuan12,注明来源,拉您进【前端视野交流群】。

若川视野

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

五分钟看懂一个高难度的排序:堆排序

发表于 2018-11-26

预备知识:堆结构

堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。

大顶堆

小顶堆

堆排序

堆排序(Heapsort)是指利用堆这种数据结构(后面的【图解数据结构】内容会讲解分析)所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:

  • 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
  • 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;

堆排序的平均时间复杂度为 Ο(nlogn)。

算法步骤

  1. 创建一个堆 H[0……n-1];
  2. 把堆首(最大值)和堆尾互换;
  3. 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
  4. 重复步骤 2,直到堆的尺寸为 1。

来源:github.com/hustcc/JS-S…

算法演示

排序动画过程解释

  1. 首先,将所有的数字存储在堆中
  2. 按大顶堆构建堆,其中大顶堆的一个特性是数据将被从大到小取出,将取出的数字按照相反的顺序进行排列,数字就完成了排序
  3. 在这里数字 5 先入堆
  4. 数字 2 入堆
  5. 数字 7 入堆, 7 此时是最后一个节点,与最后一个非叶子节点(也就是数字 5 )进行比较,由于 7 大于 5 ,所以 7 和 5 交互
  6. 按照上述的操作将所有数字入堆,然后从左到右,从上到下进行调整,构造出大顶堆
  7. 入堆完成之后,将堆顶元素取出,将末尾元素置于堆顶,重新调整结构,使其满足堆定义
  8. 堆顶元素数字 7 取出,末尾元素数字 4 置于堆顶,为了维护好大顶堆的定义,最后一个非叶子节点数字 5 与 4 比较,而后交换两个数字的位置
  9. 反复执行调整+交换步骤,直到整个序列有序

代码实现

为了更好的让读者用自己熟悉的编程语言来理解动画,笔者将贴出多种编程语言的参考代码,代码全部来源于网上。

Go代码实现

Java代码实现

Python代码实现

JavaScript代码实现

如果你是iOS开发者,可以在GitHub上 github.com/MisterBooo/… 获取更直观可调试运行的源码。

你可以在公众号 五分钟学算法 获取更多排序内容。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

第三方支付的流程分析与总结 国内支付 国际支付 总结 skr

发表于 2018-11-26

这几年的工作中一直与支付打交到,借着 skr-shop 这个项目来与大家一起分享探索一下支付系统该怎么设计、怎么做。我们先从支付的一些常见流程出发分析,找出这些支付的共性,抽象后再去探讨具体的数据库设计、代码结构设计。

相关项目:

  • PHP 版本的支付SDK
  • Go 版本的支付SDK-开发中

支付整体而言的一个流程是:给第三方发起了一笔交易,用户通过第三方完成支付,第三方告诉我支付成功,我把用户购买的产品给用户。

pay-1

看似简单的流程,这里边不同的支付机构却有不同的处理。下面以我接触过的一些支付来总结一下

国内支付

国内的典型支付代表是:支付宝、微信、银行(以招商银行为例),由于国内的支付都支持多种渠道的支付方式,为了描述简单,我们均以pc上的支付为例进行讲解。

支付宝

支付宝的接入是我觉得最简单的一种支付。对于在PC上的支付能力,支付宝提供了【电脑支付】。当用户下单后,商户系统根据支付宝的规则构建好一个url,用户跳转到这个url后进入到支付宝的支付页面,然后完成支付流程。

在支付成功后,支付宝会通过 同步通知、异步通知 两种方式告诉商户系统支付成功,并且两种通知方式的结果都是可信的,而且异步通知的消息延迟也非常短暂。

对于退款流程,支付宝支持全额、部分退款。并且能够根据商户的退款单号区分是否是同一笔退款进而避免了重复退款的可能。支付的退款是调用后同步返回结果,不会异步通知。

微信支付

微信并没有提供真的PC支付能力,但是我们可以利用【扫码支付】来达成电脑支付的目的。扫码支付有两种模式,这里以模式二为例。

微信调用下单接口获取到这个二维码链接,然后用户扫码后,进入支付流程。完成支付后微信会 异步通知,但是这里并没有 同步通知,因此前端页面只能通过定时轮训的方式检查这笔交易是否支付,直到查询到成功、或者用户主动关闭页面。

退款流程与支付宝最大的不同是,有一个 异步通知 需要商户系统进行处理。

第一个不同点:

  1. 异步通知的接口需要处理多种不同类型的异步消息

招商银行

随着在线支付在国内的蓬勃发展,各家银行也是不断推出自己的在线支付能力。其中的佼佼者当属 招商银行。大家经常用的滴滴上面就有该支付方式,可以体验一下。

招商支付使用的是银行卡,因此首次用户必须进行绑卡。因此这里可能就多了一个流程,首先得记录用户是否绑过卡,然后用于签名的公钥会发生变化,需要定期更新。

招商所有平台的支付体验都是一致的,会跳转到招行的H5页面完成逻辑,支付成功后并不会自动跳回商户,也就是没有 同步通知,它的支付结果只会走异步通知流程,延迟非常短暂。

退款流程与支付宝一样,也是同步返回退款结果,没有异步通知。

第二个不同点:

  1. 支付前需要检查用户是否签约过,有签约流程

小结

国内在线支付流程相对都比较完善,接入起来也非常容易。需要注意的一点是:退款后之前支付的单子依然是支付成功状态,并不会变成退款状态。因为退款与支付属于不同的交易。

这一点基本上是国内在线支付的通用做法。

国际支付

国际支付的平台非常多,包括像支付宝、微信也在扩展这一块市场。我以我接触的几家支付做一个简单的总结。

WorldPay

这是比较出名的一家国际支付公司,它主要做的是银行卡支付,公司在英国

支付流程上,也是根据规则构建好请求的url后,直接跳转到 WorldPay 的页面,通过信用卡完成支付。这里比较麻烦的处理机制是:支付成功后,他首次给你的异步/同步消息通知并不能作为支付成功的依据。真的从银行确认划款成功后,才会给出真的支付成功通知。这中间还可能会异步通知告诉你支付请求被拒绝。最头痛的是不同状态的异步消息时间间隔都是按照分钟以上级别的延迟来计算

退款流程上,状态跟微信一样,需要通过异步消息来确认退款状态。其次它的不同点在于无法根据商户退款单号来确认是否已经发起过退款,因此对于它来说只要请求一次退款接口,那它就默认发起了一次退款。

第三、四不同点:

  1. 支付成功后的通知状态有多种,涉及到商户系统业务流程的特殊处理
  2. 退款不支持商户退款单号,无法支持防重复退款需要商户自己处理

Assist

这是俄罗斯的一家支付公司,这也是一家搞死人不偿命的公司,看下面介绍

它的支付发起是需要构建一个form表单,向它post支付相关的数据。成功后会跳转到它的支付页,用户完成支付即可。对于 同步通知,它需要用户手动触发跳回商户,与招商的逻辑很像,同步也仅仅是做返回并不会真的告知支付结果。异步通知 才是真的告知支付状态。比较恶心的是,支付时必须传入指定格式的商品信息,这会在部分退款时用到。

现在来说退款,退款也是与 WorldPay 一样,不支持商户的退款单号,因此防重方面也许自己的系统进行设计。并且如果是部分退款,需要传入指定的退款商品,这就会出现一个非常尴尬的局面:部分退款的金额与任何一个商品金额都对应不上,退款则会失败。

第五个不同点:

  1. 部分退款时需要传入部分退款的商品信息,并且金额要一致

Doku

接下来再来聊聊印尼的这家支付机构 doku。由于印尼这个国家信用卡的普及程度并不高,它的在线支付提供一种超商支付方式。

什么是超商支付呢?也就是用户在网络上完成下单后,会获取到一个二维码或者条形码。用户拿着这个条形码到超商(711、全家这种)通过收银员扫码,付现金给超商,完成支付流程。

这种方式带来的问题是,用户长时间不去支付,导致订单超时关单后才去付款。对整个业务流程以及用户体验带来很多伤害。

再来说退款,由于存在超商这种支付方式,导致这种支付无法支持在线自动退款,需要人工收集用户银行卡信息,然后完成转账操作。非常痛苦不堪。

第六个不同点:

  1. 线上没有付款,只有获取付款码,退款需要通过人工操作

AmazonPay

亚马逊出品,与支付宝非常类似。提供的是集成式的钱包流程。

支付时直接构建一个url,然后跳转到亚马逊即可完成支付。它还提供一种授权模式,能够不用跳转amazon,再商户端即完成支付。

支付成功后也会同步跳转,同步通知 的内容可以作为支付是否成功的判断依据。经过实际检查 异步通知 的到达会稍有延迟,大概10s以内。

退款方面也支持商户退款单号可以依赖此进行防重。但是退款的状态也是基于异步来的。

总结

这其中还有一些国际支付,如:PayPal、GooglePay、PayTM 等知名支付机构没有进行介绍,是因为基本它们的流程也都在上面的模式之中。我们后续的代码结构设计、数据库设计都基于满足上面的各种支付模型来完成设计。

最后,赠送大家一副脑图,这是接入一家支付时必须弄清楚的问题清单

pay-2

下篇预告:《支付数据库与代码结构设计》

这是我们几个小伙伴利用业余时间思考的一些业务设计,如果有写的不对或者不完善的地方,希望大家多多评论,互相学习互相进步~

项目地址: github.com/skr-shop/ma…

skr-shop项目成员简介

排名不分先后,字典序

昵称 简介 个人博客
AStraw 研究生创业者, 现于小米科技海外商城组从事商城后端研发工作 ——–
Dayu Payment开源作者,服务端开发者 dayutalk.cn
lwhcv 曾就职于百度/融360, 现于小米科技海外商城组从事商城后端研发工作 ——–
TIGERB PHP框架EasyPHP作者,拥有A/B/C轮电商创业公司工作经验,现于小米科技海外商城组从事商城后端研发工作 TIGERB.cn

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官问:能否模拟实现JS的bind方法

发表于 2018-11-21

前言

你好,我是若川。这是面试官问系列的第二篇,旨在帮助读者提升JS基础知识,包含new、call、apply、this、继承相关知识。

面试官问系列文章如下:感兴趣的读者可以点击阅读。

1.面试官问:能否模拟实现JS的new操作符

2.面试官问:能否模拟实现JS的bind方法

3.面试官问:能否模拟实现JS的call和apply方法

4.面试官问:JS的this指向

5.面试官问:JS的继承

用过React的同学都知道,经常会使用bind来绑定this。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码import React, { Component } from 'react';
class TodoItem extends Component{
constructor(props){
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(){
console.log('handleClick');
}
render(){
return (
<div onClick={this.handleClick}>点击</div>
);
};
}
export default TodoItem;

那么面试官可能会问是否想过bind到底做了什么,怎么模拟实现呢。

附上之前写文章写过的一段话:已经有很多模拟实现bind的文章,为什么自己还要写一遍呢。学习就好比是座大山,人们沿着不同的路登山,分享着自己看到的风景。你不一定能看到别人看到的风景,体会到别人的心情。只有自己去登山,才能看到不一样的风景,体会才更加深刻。

先看一下bind是什么。从上面的React代码中,可以看出bind执行后是函数,并且每个函数都可以执行调用它。
眼见为实,耳听为虚。读者可以在控制台一步步点开例子1中的obj:

1
2
3
4
5
6
复制代码var obj = {};
console.log(obj);
console.log(typeof Function.prototype.bind); // function
console.log(typeof Function.prototype.bind()); // function
console.log(Function.prototype.bind.name); // bind
console.log(Function.prototype.bind().name); // bound

`Function.prototype.bind`

因此可以得出结论1:

1、bind是Functoin原型链中Function.prototype的一个属性,每个函数都可以调用它。

2、bind本身是一个函数名为bind的函数,返回值也是函数,函数名是bound。(打出来就是bound加上一个空格)。
知道了bind是函数,就可以传参,而且返回值'bound '也是函数,也可以传参,就很容易写出例子2:

后文统一 bound 指原函数original bind之后返回的函数,便于说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码var obj = {
name: '若川',
};
function original(a, b){
console.log(this.name);
console.log([a, b]);
return false;
}
var bound = original.bind(obj, 1);
var boundResult = bound(2); // '若川', [1, 2]
console.log(boundResult); // false
console.log(original.bind.name); // 'bind'
console.log(original.bind.length); // 1
console.log(original.bind().length); // 2 返回original函数的形参个数
console.log(bound.name); // 'bound original'
console.log((function(){}).bind().name); // 'bound '
console.log((function(){}).bind().length); // 0

由此可以得出结论2:

1、调用bind的函数中的this指向bind()函数的第一个参数。

2、传给bind()的其他参数接收处理了,bind()之后返回的函数的参数也接收处理了,也就是说合并处理了。

3、并且bind()后的name为bound + 空格 + 调用bind的函数名。如果是匿名函数则是bound + 空格。

4、bind后的返回值函数,执行后返回值是原函数(original)的返回值。

5、bind函数形参(即函数的length)是1。bind后返回的bound函数形参不定,根据绑定的函数原函数(original)形参个数确定。

根据结论2:我们就可以简单模拟实现一个简版bindFn

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
复制代码// 第一版 修改this指向,合并参数
Function.prototype.bindFn = function bind(thisArg){
if(typeof this !== 'function'){
throw new TypeError(this + 'must be a function');
}
// 存储函数本身
var self = this;
// 去除thisArg的其他参数 转成数组
var args = [].slice.call(arguments, 1);
var bound = function(){
// bind返回的函数 的参数转成数组
var boundArgs = [].slice.call(arguments);
// apply修改this指向,把两个函数的参数合并传给self函数,并执行self函数,返回执行结果
return self.apply(thisArg, args.concat(boundArgs));
}
return bound;
}
// 测试
var obj = {
name: '若川',
};
function original(a, b){
console.log(this.name);
console.log([a, b]);
}
var bound = original.bindFn(obj, 1);
bound(2); // '若川', [1, 2]

如果面试官看到你答到这里,估计对你的印象60、70分应该是会有的。
但我们知道函数是可以用new来实例化的。那么bind()返回值函数会是什么表现呢。

接下来看例子3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码var obj = {
name: '若川',
};
function original(a, b){
console.log('this', this); // original {}
console.log('typeof this', typeof this); // object
this.name = b;
console.log('name', this.name); // 2
console.log('this', this); // original {name: 2}
console.log([a, b]); // 1, 2
}
var bound = original.bind(obj, 1);
var newBoundResult = new bound(2);
console.log(newBoundResult, 'newBoundResult'); // original {name: 2}

从例子3种可以看出this指向了new bound()生成的新对象。

可以分析得出结论3:

1、bind原先指向obj的失效了,其他参数有效。

2、new bound的返回值是以original原函数构造器生成的新对象。original原函数的this指向的就是这个新对象。
另外前不久写过一篇文章:面试官问:能否模拟实现JS的new操作符。简单摘要:
new做了什么:

1.创建了一个全新的对象。

2.这个对象会被执行[[Prototype]](也就是__proto__)链接。

3.生成的新对象会绑定到函数调用的this。

4.通过new创建的每个对象将最终被[[Prototype]]链接到这个函数的prototype对象上。

5.如果函数没有返回对象类型Object(包含Functoin, Array, Date, RegExg, Error),那么new表达式中的函数调用会自动返回这个新的对象。

所以相当于new调用时,bind的返回值函数bound内部要模拟实现new实现的操作。
话不多说,直接上代码。

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
复制代码// 第三版 实现new调用
Function.prototype.bindFn = function bind(thisArg){
if(typeof this !== 'function'){
throw new TypeError(this + ' must be a function');
}
// 存储调用bind的函数本身
var self = this;
// 去除thisArg的其他参数 转成数组
var args = [].slice.call(arguments, 1);
var bound = function(){
// bind返回的函数 的参数转成数组
var boundArgs = [].slice.call(arguments);
var finalArgs = args.concat(boundArgs);
// new 调用时,其实this instanceof bound判断也不是很准确。es6 new.target就是解决这一问题的。
if(this instanceof bound){
// 这里是实现上文描述的 new 的第 1, 2, 4 步
// 1.创建一个全新的对象
// 2.并且执行[[Prototype]]链接
// 4.通过`new`创建的每个对象将最终被`[[Prototype]]`链接到这个函数的`prototype`对象上。
// self可能是ES6的箭头函数,没有prototype,所以就没必要再指向做prototype操作。
if(self.prototype){
// ES5 提供的方案 Object.create()
// bound.prototype = Object.create(self.prototype);
// 但 既然是模拟ES5的bind,那浏览器也基本没有实现Object.create()
// 所以采用 MDN ployfill方案 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/create
function Empty(){}
Empty.prototype = self.prototype;
bound.prototype = new Empty();
}
// 这里是实现上文描述的 new 的第 3 步
// 3.生成的新对象会绑定到函数调用的`this`。
var result = self.apply(this, finalArgs);
// 这里是实现上文描述的 new 的第 5 步
// 5.如果函数没有返回对象类型`Object`(包含`Functoin`, `Array`, `Date`, `RegExg`, `Error`),
// 那么`new`表达式中的函数调用会自动返回这个新的对象。
var isObject = typeof result === 'object' && result !== null;
var isFunction = typeof result === 'function';
if(isObject || isFunction){
return result;
}
return this;
}
else{
// apply修改this指向,把两个函数的参数合并传给self函数,并执行self函数,返回执行结果
return self.apply(thisArg, finalArgs);
}
};
return bound;
}

面试官看到这样的实现代码,基本就是满分了,心里独白:这小伙子/小姑娘不错啊。不过可能还会问this instanceof bound不准确问题。
上文注释中提到this instanceof bound也不是很准确,ES6 new.target很好的解决这一问题,我们举个例子4:

instanceof 不准确,ES6 new.target很好的解决这一问题

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
复制代码function Student(name){
if(this instanceof Student){
this.name = name;
console.log('name', name);
}
else{
throw new Error('必须通过new关键字来调用Student。');
}
}
var student = new Student('若');
var notAStudent = Student.call(student, '川'); // 不抛出错误,且执行了。
console.log(student, 'student', notAStudent, 'notAStudent');

function Student2(name){
if(typeof new.target !== 'undefined'){
this.name = name;
console.log('name', name);
}
else{
throw new Error('必须通过new关键字来调用Student2。');
}
}
var student2 = new Student2('若');
var notAStudent2 = Student2.call(student2, '川');
console.log(student2, 'student2', notAStudent2, 'notAStudent2'); // 抛出错误

细心的同学可能会发现了这版本的代码没有实现bind后的bound函数的nameMDN Function.name和lengthMDN Function.length。面试官可能也发现了这一点继续追问,如何实现,或者问是否看过es5-shim的源码实现L201-L335。如果不限ES版本。其实可以用ES5的Object.defineProperties来实现。

1
2
3
4
5
6
7
8
复制代码Object.defineProperties(bound, {
'length': {
value: self.length,
},
'name': {
value: 'bound ' + self.name,
}
});

es5-shim的源码实现bind

直接附上源码(有删减注释和部分修改等)

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
56
57
58
59
60
61
62
复制代码var $Array = Array;
var ArrayPrototype = $Array.prototype;
var $Object = Object;
var array_push = ArrayPrototype.push;
var array_slice = ArrayPrototype.slice;
var array_join = ArrayPrototype.join;
var array_concat = ArrayPrototype.concat;
var $Function = Function;
var FunctionPrototype = $Function.prototype;
var apply = FunctionPrototype.apply;
var max = Math.max;
// 简版 源码更复杂些。
var isCallable = function isCallable(value){
if(typeof value !== 'function'){
return false;
}
return true;
};
var Empty = function Empty() {};
// 源码是 defineProperties
// 源码是bind笔者改成bindFn便于测试
FunctionPrototype.bindFn = function bind(that) {
var target = this;
if (!isCallable(target)) {
throw new TypeError('Function.prototype.bind called on incompatible ' + target);
}
var args = array_slice.call(arguments, 1);
var bound;
var binder = function () {
if (this instanceof bound) {
var result = apply.call(
target,
this,
array_concat.call(args, array_slice.call(arguments))
);
if ($Object(result) === result) {
return result;
}
return this;
} else {
return apply.call(
target,
that,
array_concat.call(args, array_slice.call(arguments))
);
}
};
var boundLength = max(0, target.length - args.length);
var boundArgs = [];
for (var i = 0; i < boundLength; i++) {
array_push.call(boundArgs, '$' + i);
}
// 这里是Function构造方式生成形参length $1, $2, $3...
bound = $Function('binder', 'return function (' + array_join.call(boundArgs, ',') + '){ return binder.apply(this, arguments); }')(binder);

if (target.prototype) {
Empty.prototype = target.prototype;
bound.prototype = new Empty();
Empty.prototype = null;
}
return bound;
};

你说出es5-shim源码bind实现,感慨这代码真是高效、严谨。面试官心里独白可能是:你就是我要找的人,薪酬福利你可以和HR去谈下。

最后总结一下

1、bind是Function原型链中的Function.prototype的一个属性,它是一个函数,修改this指向,合并参数传递给原函数,返回值是一个新的函数。

2、bind返回的函数可以通过new调用,这时提供的this的参数被忽略,指向了new生成的全新对象。内部模拟实现了new操作符。

3、es5-shim源码模拟实现bind时用Function实现了length。

事实上,平时其实很少需要使用自己实现的投入到生成环境中。但面试官通过这个面试题能考察很多知识。比如this指向,原型链,闭包,函数等知识,可以扩展很多。

读者发现有不妥或可改善之处,欢迎指出。另外觉得写得不错,可以点个赞,也是对笔者的一种支持。

文章中的例子和测试代码放在github中bind模拟实现 github。bind模拟实现 预览地址 F12看控制台输出,结合source面板查看效果更佳。

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
复制代码// 最终版 删除注释 详细注释版请看上文
Function.prototype.bind = Function.prototype.bind || function bind(thisArg){
if(typeof this !== 'function'){
throw new TypeError(this + ' must be a function');
}
var self = this;
var args = [].slice.call(arguments, 1);
var bound = function(){
var boundArgs = [].slice.call(arguments);
var finalArgs = args.concat(boundArgs);
if(this instanceof bound){
if(self.prototype){
function Empty(){}
Empty.prototype = self.prototype;
bound.prototype = new Empty();
}
var result = self.apply(this, finalArgs);
var isObject = typeof result === 'object' && result !== null;
var isFunction = typeof result === 'function';
if(isObject || isFunction){
return result;
}
return this;
}
else{
return self.apply(thisArg, finalArgs);
}
};
return bound;
}

参考

OshotOkill翻译的 深入理解ES6 简体中文版 - 第三章 函数(虽然笔者是看的纸质书籍,但推荐下这本在线的书)

MDN Function.prototype.bind

冴羽: JavaScript深入之bind的模拟实现

《react状态管理与同构实战》侯策:从一道面试题,到“我可能看了假源码”

笔者精选文章

学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK

学习 lodash 源码整体架构,打造属于自己的函数式编程类库

学习 underscore 源码整体架构,打造属于自己的函数式编程类库

学习 jQuery 源码整体架构,打造属于自己的 js 类库

面试官问:JS的继承

面试官问:JS的this指向

面试官问:能否模拟实现JS的call和apply方法

面试官问:能否模拟实现JS的bind方法

面试官问:能否模拟实现JS的new操作符

前端使用puppeteer 爬虫生成《React.js 小书》PDF并合并

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。

个人博客

segmentfault前端视野专栏,开通了前端视野专栏,欢迎关注~

掘金专栏,欢迎关注~

知乎前端视野专栏,开通了前端视野专栏,欢迎关注~

github blog,求个star^_^~

微信公众号 若川视野

可能比较有趣的微信公众号,长按扫码关注。也可以加微信 ruochuan12,注明来源,拉您进【前端视野交流群】。

若川视野

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Shiro 拓展之 Restful URL 鉴权

发表于 2018-11-18

前言

在使用 Shiro 的过程中,遇到一个痛点,就是对 restful 支持不太好,也查了很多资料,各种各样的方法都有,要不就是功能不完整,要不就是解释不清楚,还有一些对原有功能的侵入性太强,经过一番探索,算是最简的配置下完成了需要的功能,这里给大家分享下。大家如果又更好的方案,也可以在评论区留言,互相探讨下。

虽然深入到了源码进行分析,但过程并不复杂,希望大家可以跟着我的思路捋顺了耐心看下去,而不是看见源码贴就抵触。

分析

首先先回顾下 Shiro 的过滤器链,一般我们都有如下配置:

1
2
3
4
复制代码/login.html = anon
/login = anon
/users = perms[user:list]
/** = authc

不太熟悉的朋友可以了解下这篇文章:Shiro 过滤器。

其中 /users 请求对应到 perms 过滤器,对应的类: org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter,其中的 onAccessDenied 方法是在没有权限时被调用的, 源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {

Subject subject = getSubject(request, response);
// 如果未登录, 则重定向到配置的 loginUrl
if (subject.getPrincipal() == null) {
saveRequestAndRedirectToLogin(request, response);
} else {
// 如果当前用户没有权限, 则跳转到 UnauthorizedUrl
// 如果没有配置 UnauthorizedUrl, 则返回 401 状态码.
String unauthorizedUrl = getUnauthorizedUrl();
if (StringUtils.hasText(unauthorizedUrl)) {
WebUtils.issueRedirect(request, response, unauthorizedUrl);
} else {
WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
return false;
}

我们可以在这里可以判断当前请求是否时 AJAX 请求,如果是,则不跳转到 logoUrl 或 UnauthorizedUrl 页面,而是返回 JSON 数据。

还有一个方法是 pathsMatch,是将当前请求的 url 与所有配置的 perms 过滤器链进行匹配,是则进行权限检查,不是则接着与下一个过滤器链进行匹配,源码如下:

1
2
3
4
5
复制代码protected boolean pathsMatch(String path, ServletRequest request) {
String requestURI = getPathWithinApplication(request);
log.trace("Attempting to match pattern '{}' with current requestURI '{}'...", path, requestURI);
return pathsMatch(path, requestURI);
}

方法

了解完这两个方法,我来说说如何利用这两个方法来实现功能。

我们可以从配置的过滤器链来入手,原先的配置如:

1
复制代码/users = perms[user:list]

我们可以改为 /user==GET,/user==POST 方式。== 用来分隔, 后面的部分指 HTTP Method。

使用这种方式还要注意一个方法,即:org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver 中的 getChain 方法,用来获取当前请求的 URL 应该使用的过滤器,源码如下:

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
复制代码public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
// 1. 判断有没有配置过滤器链, 没有一个过滤器都没有则直接返回 null
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

// 2. 获取当前请求的 URL
String requestURI = getPathWithinApplication(request);

// 3. 遍历所有的过滤器链
for (String pathPattern : filterChainManager.getChainNames()) {

// 4. 判断当前请求的 URL 与过滤器链中的 URL 是否匹配.
if (pathMatches(pathPattern, requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. " +
"Utilizing corresponding filter chain...");
}
// 5. 如果路径匹配, 则获取其实现类.(如 perms[user:list] 或 perms[user:delete] 都返回 perms)
// 具体对 perms[user:list] 或 perms[user:delete] 的判断是在上面讲到的 PermissionsAuthorizationFilter 的 pathsMatch 方法中.
return filterChainManager.proxy(originalChain, pathPattern);
}
}

return null;
}

这里大家需要注意,第四步的判断,我们已经将过滤器链,也就是这里的 pathPattern 改为了 /xxx==GET 这种方式,而请求的 URL 却仅包含 /xxx,那么这里的 pathMatches 方法是肯定无法匹配成功,所以我们需要在第四步判断的时候,只判断前面的 URL 部分。

整个过程如下:

  1. 在过滤器链上对 restful 请求配置需要的 HTTP Method,如:/user==DELETE。
  2. 修改 PathMatchingFilterChainResolver 的 getChain 方法,当前请求的 URL 与过滤器链匹配时,过滤器只取 URL 部分进行判断。
  3. 修改过滤器的 pathsMatch 方法,判断当前请求的 URL 与请求方式是否与过滤器链中配置的一致。
  4. 修改过滤器的 onAccessDenied 方法,当访问被拒绝时,根据普通请求和 AJAX 请求分别返回 HTML 和 JSON 数据。

下面我们逐步来实现:

实现

过滤器链添加 http method

在我的项目中是从数据库获取的过滤器链,所以有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码public Map<String, String> getUrlPermsMap() {
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();

filterChainDefinitionMap.put("/favicon.ico", "anon");
filterChainDefinitionMap.put("/css/**", "anon");
filterChainDefinitionMap.put("/fonts/**", "anon");
filterChainDefinitionMap.put("/images/**", "anon");
filterChainDefinitionMap.put("/js/**", "anon");
filterChainDefinitionMap.put("/lib/**", "anon");
filterChainDefinitionMap.put("/login", "anon");

List<Menu> menus = selectAll();
for (Menu menu : menus) {
String url = menu.getUrl();
if (!"".equals(menu.getMethod())) {
url += ("==" + menu.getMethod());
}
String perms = "perms[" + menu.getPerms() + "]";
filterChainDefinitionMap.put(url, perms);
}
filterChainDefinitionMap.put("/**", "authc");
return filterChainDefinitionMap;
}

如: /xxx==GET = perms[user:list]这里的 getUrl,getMethod 和 getPerms 分别对应 /xxx,GET 和 user:list。

不过需要注意的是,如果在 XML 里配置,会被 Shiro 解析成 /xxx 和 =GET = perms[user:list],解决办法是使用其他符号代替 ==。

修改 PathMatchingFilterChainResolver 的 getChain 方法

由于 Shiro 没有提供相应的接口,且我们不能直接修改源码,所以我们需要新建一个类继承 PathMatchingFilterChainResolver 并重写 getChain 方法,然后替换掉 PathMatchingFilterChainResolver 即可。

首先继承并重写方法:

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
复制代码package im.zhaojun.shiro;

import org.apache.shiro.web.filter.mgt.FilterChainManager;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.FilterChain;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;

public class RestPathMatchingFilterChainResolver extends PathMatchingFilterChainResolver {

private static final Logger log = LoggerFactory.getLogger(RestPathMatchingFilterChainResolver.class);

@Override
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
}

String requestURI = getPathWithinApplication(request);

//the 'chain names' in this implementation are actually path patterns defined by the user. We just use them
//as the chain name for the FilterChainManager's requirements
for (String pathPattern : filterChainManager.getChainNames()) {

String[] pathPatternArray = pathPattern.split("==");

// 只用过滤器链的 URL 部分与请求的 URL 进行匹配
if (pathMatches(pathPatternArray[0], requestURI)) {
if (log.isTraceEnabled()) {
log.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. " +
"Utilizing corresponding filter chain...");
}
return filterChainManager.proxy(originalChain, pathPattern);
}
}

return null;
}
}

然后替换掉 PathMatchingFilterChainResolver,它是在 ShiroFilterFactoryBean 的 createInstance 方法里初始化的。

所以同样的套路,继承 ShiroFilterFactoryBean 并重写 createInstance 方法,将 new PathMatchingFilterChainResolver(); 改为 new RestPathMatchingFilterChainResolver(); 即可。

代码如下:

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
56
57
58
59
60
61
复制代码package im.zhaojun.shiro;

import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.mgt.FilterChainManager;
import org.apache.shiro.web.filter.mgt.FilterChainResolver;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanInitializationException;

public class RestShiroFilterFactoryBean extends ShiroFilterFactoryBean {

private static final Logger log = LoggerFactory.getLogger(RestShiroFilterFactoryBean.class);

@Override
protected AbstractShiroFilter createInstance() {

log.debug("Creating Shiro Filter instance.");

SecurityManager securityManager = getSecurityManager();
if (securityManager == null) {
String msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
}

if (!(securityManager instanceof WebSecurityManager)) {
String msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
}

FilterChainManager manager = createFilterChainManager();

//Expose the constructed FilterChainManager by first wrapping it in a
// FilterChainResolver implementation. The AbstractShiroFilter implementations
// do not know about FilterChainManagers - only resolvers:
PathMatchingFilterChainResolver chainResolver = new RestPathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);

//Now create a concrete ShiroFilter instance and apply the acquired SecurityManager and built
//FilterChainResolver. It doesn't matter that the instance is an anonymous inner class
//here - we're just using it because it is a concrete AbstractShiroFilter instance that accepts
//injection of the SecurityManager and FilterChainResolver:
return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
}

private static final class SpringShiroFilter extends AbstractShiroFilter {
protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
super();
if (webSecurityManager == null) {
throw new IllegalArgumentException("WebSecurityManager property cannot be null.");
}
setSecurityManager(webSecurityManager);
if (resolver != null) {
setFilterChainResolver(resolver);
}
}
}
}

最后记得将 ShiroFilterFactoryBean 改为 RestShiroFilterFactoryBean。

XML 方式:

1
2
3
复制代码<bean id="shiroFilter" class="im.zhaojun.shiro.RestShiroFilterFactoryBean">
<!-- 参数配置略 -->
</bean>

Bean 方式:

1
2
3
4
5
6
复制代码@Bean
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
// 参数配置略
return shiroFilterFactoryBean;
}

修改过滤器的 pathsMatch 方法

同样新建一个类继承原有的 PermissionsAuthorizationFilter 并重写 pathsMatch 方法:

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
复制代码package im.zhaojun.shiro.filter;

import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.StringUtils;
import org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
* 修改后的 perms 过滤器, 添加对 AJAX 请求的支持.
*/
public class RestAuthorizationFilter extends PermissionsAuthorizationFilter {

private static final Logger log = LoggerFactory
.getLogger(RestAuthorizationFilter.class);

@Override
protected boolean pathsMatch(String path, ServletRequest request) {
String requestURI = this.getPathWithinApplication(request);

String[] strings = path.split("==");

if (strings.length <= 1) {
// 普通的 URL, 正常处理
return this.pathsMatch(strings[0], requestURI);
} else {
// 获取当前请求的 http method.
String httpMethod = WebUtils.toHttp(request).getMethod().toUpperCase();

// 匹配当前请求的 http method 与 过滤器链中的的是否一致
return httpMethod.equals(strings[1].toUpperCase()) && this.pathsMatch(strings[0], requestURI);
}
}
}

修改过滤器的 onAccessDenied 方法

同样是上一步的类,重写 onAccessDenied 方法即可:

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
复制代码/**
* 当没有权限被拦截时:
* 如果是 AJAX 请求, 则返回 JSON 数据.
* 如果是普通请求, 则跳转到配置 UnauthorizedUrl 页面.
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
Subject subject = getSubject(request, response);
// 如果未登录
if (subject.getPrincipal() == null) {
// AJAX 请求返回 JSON
if (im.zhaojun.util.WebUtils.isAjaxRequest(WebUtils.toHttp(request))) {
if (log.isDebugEnabled()) {
log.debug("用户: [{}] 请求 restful url : {}, 未登录被拦截.", subject.getPrincipal(), this.getPathWithinApplication(request)); }
Map<String, Object> map = new HashMap<>();
map.put("code", -1);
im.zhaojun.util.WebUtils.writeJson(map, response);
} else {
// 其他请求跳转到登陆页面
saveRequestAndRedirectToLogin(request, response);
}
} else {
// 如果已登陆, 但没有权限
// 对于 AJAX 请求返回 JSON
if (im.zhaojun.util.WebUtils.isAjaxRequest(WebUtils.toHttp(request))) {
if (log.isDebugEnabled()) {
log.debug("用户: [{}] 请求 restful url : {}, 无权限被拦截.", subject.getPrincipal(), this.getPathWithinApplication(request));
}

Map<String, Object> map = new HashMap<>();
map.put("code", -2);
map.put("msg", "没有权限啊!");
im.zhaojun.util.WebUtils.writeJson(map, response);
} else {
// 对于普通请求, 跳转到配置的 UnauthorizedUrl 页面.
// 如果未设置 UnauthorizedUrl, 则返回 401 状态码
String unauthorizedUrl = getUnauthorizedUrl();
if (StringUtils.hasText(unauthorizedUrl)) {
WebUtils.issueRedirect(request, response, unauthorizedUrl);
} else {
WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}

}
return false;
}

重写完 pathsMatch 和 onAccessDenied 方法后,将这个类替换原有的 perms 过滤器的类:

XML 方式:

1
2
3
4
5
6
7
8
9
10
复制代码<bean id="shiroFilter" class="im.zhaojun.shiro.RestShiroFilterFactoryBean">
<!-- 参数配置略 -->
<property name="filters">
<map>
<entry key="perms" value-ref="restAuthorizationFilter"/>
</map>
</property>
</bean>

<bean id="restAuthorizationFilter" class="im.zhaojun.shiro.filter.RestAuthorizationFilter"/>

Bean 方式:

1
2
3
4
5
6
7
8
9
复制代码@Bean
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
Map<String, Filter> filters = shiroFilterFactoryBean.getFilters();
filters.put("perms", new RestAuthorizationFilter());

// 其他配置略
return shiroFilterFactoryBean;
}

这里只改了 perms 过滤器,对于其他过滤器也是同样的道理,重写过滤器的 pathsMatch 和 onAccessDenied 方法,并覆盖原有过滤器即可。

结语

基本的过程就是这些,这是我在学习 Shiro 的过程中的一些见解,希望可以帮助到大家。具体应用的项目地址为:github.com/zhaojun1998…,功能在不断完善中,代码可能有些粗糙,还请见谅。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java 程序员都该懂的 HashMap 先了解一下 Map

发表于 2018-11-16

HashMap 一直是非常常用的数据结构,也是面试中十分常问到的集合类型,今天就来说说 HashMap。

但是为什么要专门说明是 Java8 的 HashMap 呢?我们都知道,Java8 有很多大的变化和改动,如函数式编程等,而 HashMap 也有了一个比较大的变化。

先了解一下 Map

常见的Map类型有以下几种:

HashMap:
  • 无序
  • 访问速度快
  • key不允许重复(只允许存在一个null key)
LinkedHashMap:
  • 有序
  • HashMap 子类
TreeMap:
  • TreeMap 中保存的记录会根据 Key 排序(默认为升序排序),因此使用 Iterator 遍历时得到的记录是排过序的
  • 因为需要排序,所以TreeMap 中的 key 必须实现 Comparable 接口,否则会报 ClassCastException 异常
  • TreeMap 会按照其 key 的 compareTo 方法来判断 key 是否重复

除了上面几种以外,我们还可能看到过一个叫 Hashtable 的类:

Hashtable:
  • 一个遗留类,线程安全,与 HashMap 类似
  • 当不需要线程安全时,选择 HashMap 代替
  • 当需要线程安全时,使用 ConcurrentHashMap 代替

HashMap

我们现在来正式看一下 HashMap

首先先了解一下 HashMap 内部的一些主要特点:

  • 使用哈希表(散列表)来进行数据存储,并使用链地址法来解决冲突
  • 当链表长度大于等于 8 时,将链表转换为红黑树来存储
  • 每次进行二次幂的扩容,即扩容为原容量的两倍

字段

HashMap 有以下几个字段:

  • Node[] table:存储数据的哈希表;初始长度 length = 16(DEFAULT_INITIAL_CAPACITY),扩容时容量为原先的两倍(n * 2)
  • final float loadFactor:负载因子,确定数组长度与当前所能存储的键值对最大值的关系;不建议轻易修改,除非情况特殊
  • int threshold:所能容纳的 key-value 对极限 ;threshold = length * Load factor,当存在的键值对大于该值,则进行扩容
  • int modCount:HashMap 结构修改次数(例如每次 put 新值使则自增 1)
  • int size:当前 key-value 个数

值得一提的是,HashMap 中数组的初始大小为 16,这是为什么呢?这个我会在后面讲 put 方法的时候说到。

方法

hash(Object key)

我们都知道,Object 类的 hashCode 方法与 HashMap 息息相关,因为 HashMap 便是通过 hashCode 来确定一个 key 在数组中的存储位置。(这里大家都应该了解一下 hashCode 与 equals 方法之间的关系与约定,这里就不多说了)

Java 8 之前的做法和现在的有所不同,Java 8 对此进行了改进,优化了该算法

1
2
3
4
复制代码static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

值得注意的是,HashMap 并非直接使用 hashCode 作为哈希值,而是通过这里的 hash 方法对 hashCode 进行一系列的移位和异或处理,这样处理的目的是为了有效地避免哈希碰撞

我们可以看到,通过这样的计算方式,key 的 hash 值高 16 位不变,低 16 位与高 16 位异或作为 key 的最终 hash 值;我们后面会知道,HashMap 通过 (n - 1) & hash 来决定元素的位置(其中 n 是当前数组大小)

很显然,这种计算方式决定了元素的位置只关系到低位的数值,这样会使得哈希碰撞出现的可能性增加,因此我们利用 hash 值高位与低位的异或处理来降低冲突的可能性,使得元素的位置不单单取决于低位

put(K key, V value)

put 方法是 HashMap 里面一个十分核心的方法,关系到了 HashMap 对数据的存储问题。

1
2
3
复制代码public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

put 方法直接调用了 putVal 方法,这里我为大家加上了注释,可以配合下面的流程图一步步感受:

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
复制代码final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
HashMap.Node<K, V>[] tab;
HashMap.Node<K, V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//初始化哈希表
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//通过哈希值找到对应的位置,如果该位置还没有元素存在,直接插入
tab[i] = newNode(hash, key, value, null);
else {
HashMap.Node<K, V> e;
K k;
//如果该位置的元素的 key 与之相等,则直接到后面重新赋值
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof HashMap.TreeNode)
//如果当前节点为树节点,则将元素插入红黑树中
e = ((HashMap.TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
else {
//否则一步步遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//插入元素到链尾
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
//元素个数大于等于 8,改造为红黑树
treeifyBin(tab, hash);
break;
}
//如果该位置的元素的 key 与之相等,则重新赋值
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//前面当哈希表中存在当前key时对e进行了赋值,这里统一对该key重新赋值更新
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//检查是否超出 threshold 限制,是则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

主要的逻辑步骤在此:

有个值得注意的有趣的地方:在 Java 8 之前,HashMap 插入数据时一直是插入到链表表头;而到了 Java 8 之后,则改为了尾部插入。至于头插入有什么缺点,其中一个就是在并发的情况下因为插入而进行扩容时可能会出现链表环而发生死循环;当然,HashMap 设计出来本身就不是用于并发的情况的。

(1)HashMap 初始大小为何是 16

每当插入一个元素时,我们都需要计算该值在数组中的位置,即p = tab[i = (n - 1) & hash]。

当 n = 16 时,n - 1 = 15,二进制为 1111,这时和 hash 作与运算时,元素的位置完全取决与 hash 的大小

倘若不是 16,如 n = 10,n - 1 = 9,二进制为 1001,这时作与运算,很容易出现重复值,如 1101 & 1001,1011 & 1001,1111 & 1001,结果都是一样的,所以选择 16 以及 每次扩容都乘以二的原因也可想而知了

(2)懒加载

我们在 HashMap 的构造函数中可以发现,哈希表 Node[] table 并没有在一开始就完成初始化;观察 put 方法可以发现:

1
2
复制代码if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

当发现哈希表为空或者长度为 0 时,会使用 resize 方法进行初始化,这里很显然运用了 lazy-load 原则,当哈希表被首次使用时,才进行初始化

(3)树化

Java8 中,HashMap 最大的变动就是增加了树化处理,当链表中元素大于等于 8,这时有可能将链表改造为红黑树的数据结构,为什么我这里说可能呢?

1
2
3
4
5
6
7
复制代码final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
int n, index; HashMap.Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//......
}

我们可以观察树化处理的方法 treeifyBin,发现当tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY为 true 时,只会进行扩容处理,而没有进行树化;MIN_TREEIFY_CAPACITY 规定了 HashMap 可以树化的最小表容量为 64,这是因为当一开始哈希表容量较小是,哈希碰撞的几率会比较大,而这个时候出现长链表的可能性会稍微大一些,这种原因下产生的长链表,我们应该优先选择扩容而避免这类不必要的树化。

那么,HashMap 为什么要进行树化呢?我们都知道,链表的查询效率大大低于数组,而当过多的元素连成链表,会大大降低查询存取的性能;同时,这也涉及到了一个安全问题,一些代码可以利用能够造成哈希冲突的数据对系统进行攻击,这会导致服务端 CPU 被大量占用。

resize()

扩容方法同样是 HashMap 中十分核心的方法,同时也是比较耗性能的操作。

我们都知道数组是无法自动扩容的,所以我们需要重新计算新的容量,创建新的数组,并将所有元素拷贝到新数组中,并释放旧数组的数据。

与以往不同的是,Java8 规定了 HashMap 每次扩容都为之前的两倍(n*2),也正是因为如此,每个元素在数组中的新的索引位置只可能是两种情况,一种为不变,一种为原位置 + 扩容长度(即偏移值为扩容长度大小);反观 Java8 之前,每次扩容需要重新计算每个值在数组中的索引位置,增加了性能消耗

接下来简单给大家说明一下,上一段话是什么意思:
前面讲 put 的时候我们知道每个元素在哈希表数组中的位置等于 (n - 1) & hash,其中 n 是当前数组的大小,hash 则是前面讲到的 hash 方法计算出来的哈希值

图中我们可以看到,扩容前 0001 0101 和 0000 0101 两个 hash 值最终的计算出来的数组中的位置都是 0000 0101,即为 5,此时数组大小为 0000 1111 + 1 即 16

扩容后,数组从 16 扩容为两倍即 32(0001 1111),此时原先两个 hash 值计算出来的结果分别为 0001 0101 和 0000 0101 即 21 和 5,两个数之间刚好相差 16,即数组的扩容大小

这个其实很容易理解,数组扩容为原来的两倍后,n - 1 改变为 2n - 1,即在原先的二进制的最高位发生了变化

因此进行 & 运算后,出来的结果只可能是两种情况,一种是毫无影响,一种为原位置 + 扩容长度

那么源代码中是如何判断是这两种情况的哪一种呢?我们前面说到,HashMap 中数组的大小始终为 16 的倍数,因此 hash & n 和 hash & (2n - 1) 分别计算出来的值中高位是相等的

因此源码中使用了一个非常简单的方法(oldCap 是原数组的大小,即 n)

1
2
3
4
5
复制代码if ((e.hash & oldCap) == 0) {
...
} else {
...
}

当 e.hash & oldCap 等于 0 时,元素位置不变,当非 0 时,位置为原位置 + 扩容长度

get(Object key)

了解了 HashMap 的存储机制后,get 方法也很好理解了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码final HashMap.Node<K,V> getNode(int hash, Object key) {
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
//检查当前位置的第一个元素,如果正好是该元素,则直接返回
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//否则检查是否为树节点,则调用 getTreeNode 方法获取树节点
if (first instanceof HashMap.TreeNode)
return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);
//遍历整个链表,寻找目标元素
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

主要就四步:

  1. 哈希表是否为空或者目标位置是否存在元素
  2. 是否为第一个元素
  3. 如果是树节点,寻找目标树节点
  4. 如果是链表结点,遍历链表寻找目标结点

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

51信用卡 Android 架构演进实践 组件化 or 插件

发表于 2018-11-13

本文首发于51NB技术公众号,原文链接 51信用卡Android架构演进实践

随着业务的快速扩张,原本小作坊式的单个工程的开发模式越来与不能满足实际需求。早在两年多以前,51信用卡管家就向下沉淀出了单独的公用基础库,一些通用的功能组件和个别独立的业务被拆分成 SDK,形成了一套中型项目、多人并行的开发模式,也为未来组件化拆分做准备。

image-20181023162224348

这套框架运行了一段时间之后,伴随着单应用内业务需求的增加、开发人员数量的增多、基础库数量的膨胀,导致了一些问题:

  • 主工程代码耦合严重,牵一发而动全身
  • 需求测试影响面大,不能聚焦单一业务模块
  • 主工程代码越来越多,编译耗时
  • 依赖倒置,业务代码依赖App工程
  • SDK 界限模糊,基础库和业务库界限不明确
  • 业务模块间可以任意依赖调用,依赖规则不明确
  • 类库越来越多,不好管理

除了以上问题,动态化需求也越来越强烈,依赖 Hybrid + H5 打开页面慢的问题也凸显出来。

这些问题推动我们更进一步的升级开发构架。

组件化 or 插件化

动态化

最近两年,插件化框架层出不穷,各大厂都放出了自家开源的插件化框架。作为 Native 动态化与性能兼顾的插件化方案,很多公司选择插件化作为动态化技术方案。动态性通常有两部分的作用:一是动态热修复;二是动态下发业务插件。对于第一点,我们有热修复框架可以完成这部分工作;对于第二点,我们使用了 Hybrid 加载H5的方式实现,虽然性能上有所欠缺,但完全切到 Native 来做有点推倒重来的意思,并且跟业界同学交流后,对于动态下发业务插件用到的情况也不多,业务更新主要还是依靠 App 升级来实现。技术方案没有最优解,选择适合自己的才是最好的。

由于插件化也存在一些弊端,比如不可避免的 hook framework、修改 aapt、包装 Gradle Plugin、代理组件等等非常规操作,日常维护也是一笔不小的开销,稳定性、兼容性、新版本适配等等问题都需要考虑进去。对于 Android 端是否使用插件化,公司内部做过一些讨论,结论是不急着上,边走边看,先把业务组件拆分出来再说。

如今回过头看,自从 Android P发布以来,限制 hook framework 后,插件化逐渐开始式微,后面走向大概率是维护成本越来越高,成本收益比逐渐降低,最终弃坑不用。

除了插件化外,动态化方案近两年比较火的就是以 ReactNative、Weex 为代表的大前端方向,结合51信用卡的实际情况,最终选择拥抱大前端, Weex 作为动态化方案,以 Native 为主, Hybrid 离线化方案为辅,Weex 逐步迭代的架构开发模式。

Weex 的基础建设和前端同学合作,历经大半年时间,目前已经稳定应用在51信用卡各个 App 上,Weex 作为动态化页面的首选方案,已经完成了线上数百个页面的开发需求。配合离线化方案,各项性能指标也都达到要求。

组件分离

代码解耦与代码隔离,最有效的方案是工程隔离。审视我们最初的方案,每个 SDK 对应单独的仓库,通过 maven 依赖,通过工程分离隔离代码,这种方案没有问题,只不过需要往前更近一步,各个业务模块也需要独立主工程,拆分成独立的业务组件。

同时,划分清楚代码边界,控制依赖关系,梳理清楚层次结构,最终形成如下图所示的架构。

组件化层次.001

整体架构上提供三种容器:

  • Native 容器,采用组件化架构,用于原生业务开发
  • Hybrid 容器,webview 加载 H5,配合离线化方案
  • Weex 容器,用于编写常规的页面,js 动态转化成 Native 控件,天然具有动态化特性,配合离线化方案,达到页面秒开的效果,同时共用 Hybrid 沉淀出的比较完善的 PG 方法

同时,Hybrid 和 Weex 依赖于原生提供的方法,通过 JsBridge 进行通信,目前共有 200 多个 PG 方法供 js 调用。长远来看,这三种容器并不会互相取代,相反地,它们应该是相互依存、取长补短、长期共存的状态。

组件化实践

Native 容器对应上图中各个层级的定义:

  • 工程 App,各个应用工程,目前已有十多个应用并行开发,51信用卡管家作为平台应用,其余应用为独立的业务工程应用
  • 业务组件,独立的业务组件,一般为复合业务组件,api 与实现分离,相互之间依赖隔离
  • 基础业务 SDK,独立的小的单功能模块,提供基础功能,目前这一层级中还包含遗留未改造的部分业务组件
  • 基础 Lib,业务无关的基础组件

组件化拆分的核心诉求是解耦合,提高组件内聚,所以应该从诉求出发,在沿用当下开发模式,并且不强依赖组件化框架的情况下,逐渐的进行组件化拆分。

通过工程隔离进而进行组件化拆分后,基本可以解决上面提到的问题:

  • 高内聚,低耦合,代码边界清晰,代码变动影响面可以准确评估
  • 提高开发效率,每个组件可以独立打包,单独调试,最多几十秒就可以完成打包过程
  • 每个组件负责组件内的事情,理论上只要保证组件内部稳定,接入工程 App 后也不会产生新的问题
  • 降低 App 工程编译时间,最理想的情况是,App 工程仅仅是一个空壳,用于加载各个组件

解耦,一般需要避免直接依赖,转为间接依赖,简单来说就是依赖隔离。对于组件化而言,每个组件都是单独的实现,单个组件对外提供的服务尽可能单一,依赖尽可能少;同时,依赖其它组件功能或页面的情况下,尽可能避免直接依赖,最好依赖中间层进行集中式管理,然后再进行逻辑分发。所以我们一般采用分总分的结构:组件内部分别注册,编译时生成汇总代码、运行时集中式管理,调用时处理逻辑分发。

image-20181026181156315

组件化需要解耦处理的几个基础模块:

  • 页面路由
  • 模块间调用
  • 消息总线
  • 数据总线

下面依次介绍。

页面路由

路由分发本质上是把直接依赖引用转化为中心化管理分发的一个过程,由于组件化拆分后,各个业务组件间不存在直接的依赖关系,所以必然要有一个统一收集页面跳转规则进而再分发的过程。

51信用卡在 2017 年就在进行路由化实践,以应对后面进行的组件化拆分需求,并沉淀出一套自研的路由框架 U51OkDeepLink,它也采用分总分结构,主要原理是组件内注册路由,编译时在组件内生成独立的路由表,并用 AOP 在编译时做好所有组件内路由表汇总的工作,调用初始化方法时进行路由表汇总,页面跳转时再进行管理分发,其用法很简单:

1
2
3
4
5
6
7
8
9
复制代码//组件内注册路由
public interface SampleService {
@Path("/main")
@Activity(MainActivity.class)
void startMainActivity(@Query("key") String key);
}

//其余组件唤起页面
new DeepLinkClient(context).buildRequest("old://app/main?key=value").addQuery("key2", "2").start();

并且支持强大的异步特性,支持跳转过程中的中间逻辑处理。

其原理图如下

router

感兴趣的读者可以阅读 Android 组件化 —— 路由设计最佳实践 获取更多技术细节。

模块间调用

组件间层次和边界模糊问题的产生,根本原因是各个业务组件间的相互依赖关系混乱,为了进行业务组件间的隔离,首先要做好组件之间的服务调用解耦。

这里采用的是 ServiceLoader 的模式,组件工程目录一般如下所示

image-20181024204942179

每个组件内一般声明三个 module:

  • api module,声明对外暴露的服务接口和对外暴露的实体类及 Event 事件
  • imp module,依赖 api module,是 api module 的具体实现,不对外暴露细节,不允许其他组件对 imp module 进行直接依赖
  • app module,是工程的壳,可以直接运行调试,通过 SDKTemplate 创建生成,包含各种运行时所需环境

业务组件之间依赖 api 库的服务接口,imp 库作为实现动态查找。版本发布时,同时发布 api 和 imp 两个库,并且保证 api 和 imp 具有相同版本号,这个在组件发版时统一管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码 //组件内 api module 接口声明
@Service
public interface TestService {
void sayHello();
}

//组件内 imp module 接口实现
@ServiceImpl
public class TestServiceImpl implements TestService {
@Override
public void sayHello() {
}
}

//跨组件调用
compile 'com.u51.android:test-lib-api:$version'

CommentService service = ServicesLoader.getInstance().getService(TestService.class);
service.sayHello();

它的实现原理与路由类似,也是采用分总分结构,在编译时通过 APT 生成汇总代码,调用时动态查找注入 Service 及其实现类的绑定关系。

与路由初始化汇总路由表不同的是,ServiceLoader 在调用时查找,省去了初始化的逻辑,Service 不会像路由这么多,查找起来不会存在遍历太慢的问题。

消息总线

消息总线是基于 EventBus 实现的跨三端(Native、Hybrid、Weex)事件管理分发组件 U51EventBus。跨三端是指在任意一端注册监听后,在事件触发时都可以得到响应。

对于原生开发来说,EventBus 本身可以满足需求,虽然有点事件满天飞的缺点,但是还在可接受范围之内。对于业务组件来说,其 Event 类需要放在 api module 中进行暴露。

对于 Hybrid 和 Weex 来说,一般的 bridge 都是 callback 形式得到异步响应,对于全局事件通知支持不太友好。通过 bridge 通道连接 U51EventBus 消息总线,打通跨三端全局的事件监听及分发,得以实现任意事件可以在 Native、H5、Weex 之间相互发送和监听。比如,类似登陆、登出操作在 Native 发出后,全局已打开的 H5 或 Weex 页面可以立即得到感知。

其实现原理也是采用分总分结构,在编译时对 EventBus 进行了定制封装,事件分发还是使用的原有的 EventBus 分发逻辑。

数据总线

数据存储采用基于 Room 实现的统一 KV 存储框架,底层数据库依然是 sqlite,性能这块没有做特别强调,强制其在子线程中进行操作,用于支持日常开发中配置和业务数据的存取操作。

另外,数据总线支持按模块进行存取,每个业务组件都可以定义自有 tag,避免字段冲突问题。

跨平台混合开发实践

无论从早期的 PhoneGap、Cordova,还是近年来比较火的 ReactNative、Weex,到最近两年崛起的 Flutter,跨平台混合开发一直深受众多开发青睐。究其原因,还是其跨平台和动态化是原生开发所不具备的特性。

Hybrid 容器实践

Native 和 H5 混合开发一般是比较常见的混合开发模式,H5 开发效率高、迭代快速、不依赖 App 发版,51信用卡众多 App 产品中,有很多页面都是用 H5 来开发,嵌入原生 App 中使用 webview 进行加载显示。

早期 H5 容器在各个 App 中分别独立实现,没有统一的架构和规范,导致对 H5 的支持效率较低,PG 方法(来源于 PhoneGap)的开发、测试和维护都相当的混乱,重复性工作太多。

Native 层提供一套通用性强、功能丰富、稳定性高的 H5 容器对业务的高速发展至关重要。

image-20181105173903973

插件管理

由于 H5 不具备直接调用原生方法,所以原生壳要提供一套通用的通信方式,一般为 JsBridge,在 Android 端,实现 JsBridge 通信的通道一般有以下几种:

  • shouldOverrideUrlLoading
  • addJavascriptInterface
  • onJsPrompt/onJsAlert

而通道不是关键,怎样管理和维护 PG 方法调用才是核心。为此,我们把每个方法定义为一个 Plugin,用插件的形式管理 PG 方法,这样可以做到每个插件独立运行,互不干扰。插件管理也是采用分总分结构,在各个业务组件中分别注册,编译是通过 APT 生成汇总代码,运行时进行插件汇总,最后调用通过 PluginManager 查找分发逻辑。

插件注册代码如下,其中 onExecute() 方法在 js 调用该方法时触发,执行结果通过 evaluateJavaScript() 方法异步返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码@JsPlugin(name = TestPlugin.PLUGIN_NAME, loadOnInit = false, version = 1)
public class TestPlugin extends EnNiuJsPlugin {
public static final String PLUGIN_NAME = "TestPlugin";

@Override
public String getPluginName() {
return PLUGIN_NAME;
}
...
@Override
public boolean onExecute(String args) {
doSomething();
callbackContext.callback(...);
return true;
}
}

其中,H5 容器和插件都具有 Activity 生命周期感知能力,插件的生命周期:

image-20181105191433330

配套设施

插件统一通过插件管理平台进行维护管理,目前已有200+插件。PG 插件作为基础通用功能,采取集中式管理机制,任何人在新增、修改插件都需要进行相关负责人审核,以避免出现 Android、iOS 两端实现不统一,版本间实现不统一等问题。

image-20181105191949792

插件调试通过调试平台进行操作,浏览器中打开调试地址,App 端通过调试工具扫码建立连接,即可进行插件调试。

image-20181105192429943

离线加载

Hybrid 混合开发的一大劣势就是性能比较差,打开页面较慢,特别是在弱网情况下。由于51信用卡业务大部分都是静态资源请求,参考业界做法,我们实现了动态下发离线包的方式来提升H5页面打开速度。

lixianbao

这里细节问题不具体展开。

除了以上提到的实践外,我们还做了很多工作,比如 UI 统一、Back 键拦截、公共参数处理、PG 白名单机制、H5监控、PG 方法监控等等,限于文章篇幅,这里不再一一列出,敬请关注后续相关文章。

Weex 容器实践

在 Hybrid 已有配套基础上,51信用卡选择了 Weex 作为跨平台方案,经过一年的踩坑填坑过程,目前已经有 20+ 个项目、数百个 Weex 页面在线上稳定运行,并且,目前 Weex 方案趋于成熟,已经作为51信用卡端内首选业务方案。

共享插件

由于 Hybrid 良好的面向接口编程特性,在进行 Weex 基础建设过程中,很方便的就把已有的插件集成进来,并且共享已沉淀的配套设施。

1
2
3
4
5
6
7
8
9
复制代码public class ENBridgeModule extends WXModule {
@JSMethod
public synchronized void send(String method, String args, JSCallback jsCallback) {
...
weexWebView = weexEngine.getWeexVirtualWebView();
EnNiuJsBridge enNiuJsBridge = weexWebView.getEnNiuJsBridge();
enNiuJsBridge.notify(pg);
}
}

注册 Weex 的 Module,并且每个 Weex Engine 中会新建出一个虚拟 webview,用于桥接 JsBridge 进而调用 PluginManager 进行插件逻辑分发。

Weex 容器实践在之前的文章中已经提到过一部分,具体请看 Weex避坑指南-理论篇 ,后续还将有 Weex 实践相关的文章放出,这里不做过多篇幅的介绍,敬请关注后续相关文章。

工程化实践

工程化本质上是为了提高研发效率。51信用卡客户端团队自研的大风车管理平台,用于 App 管理、持续集成、类库管理、发版管理等,围绕客户端研发上下游流程,建立统一的管理入口。

目前,51信用卡 iOS 和 Android 共 30 多个应用 App、 200 多个类库依托大风车平台进行管理。下面主要介绍下类库管理相关内容。

类库管理

51信用卡目前有 100 多个 Android 类库,每个类库对应一个独立的 Gitlab 仓库。过多的独立组件及独立仓库,管理起来有些麻烦。

image-20181030193630574

依托于大风车平台,所有类库的名称、最新版本及标签类型都会展示在列表页,标签类型对应组件化架构的层次结构,包括:基础组件、单业务功能组件、多业务功能组件。

在类库详情页,会有库的功能描述、groupId:artifactId 依赖信息、版本历史记录、分支信息、README、CHANGELOG、负责人等详情信息。

所有的类库管理工作都可以在大风车完成,包括新建类库、类库发版、查阅相关信息等等,这大大提高了基础组的研发效率,降低了团队间的沟通成本。

并且 App 工程中,该 App 所依赖的所有类库信息一目了然,在多人维护、多类库并行开发、类库频繁发版的情况下,依赖类库信息 check 更加便捷。

image-20181031160854953

版本管理

由于类库之间是仓库隔离,所以它们的依赖关系是 maven 依赖,所有类库的 aar 包都需要发布到内部 maven 服务器上,上传工作由 PublishMavenPlugin 完成。

SNAPSHOT 预览版

对于开发调试阶段,每个类库自带 DemoApp 工程,所以采用源码依赖;开发完成后,类库使用SNAPSHOT版本(比如 1.0.0-SNAPSHOT)发布到 maven 服务器,接入 App 工程后 push 代码触发大风车打包,进行集成测试。需要修改类库时,可以再重复发布相同版本的SNAPSHOT版本。

SNAPSHOT版本可以在开发同学自己的机器上进行打包发布。

正式版

对于发布阶段,类库必须使用正式版本发布,由于正式版本不可重复发布,这也就要求开发同学保证每个正式版本的版本质量,在正式发布前都应达到发布标准。

由于类库内部也存在相互依赖的情况,所以在类库正式发布时,不允许依赖包含SNAPSHOT版本的类库,DependencyCheck工作也会在 PublishMavenPlugin 完成。

同时,正式版本不允许开发同学在本机打包发布,PublishMavenPlugin 会检测是否在云端打包环境。功能分支经 CodeReview 后合并 master 分支,然后创建对应版本的 tag,触发大风车进行打包发布工作,发布成功后,会邮件通知 Android 组同学,并附带 CHANGELOG。

image-20181105204154472

依赖管理

依赖传递

App 工程下采用 compile 依赖,compile 会解析类库 maven 包中的 pom 文件,进而间接依赖 pom 文件中声明的其他类库,也就是依赖传递。正常情况下,依赖传递会减少不必要的类库声明,当出现版本冲突时会自动处理 merge 操作。

但是,在多人协同工作、多类库并行开发情况下,事情变得有些复杂。考虑一种情况,应用 A 依赖类库 B,类库 B 依赖类库 C,正常情况下,A 中只需要声明依赖 B 即可,C 会被依赖传递过去。如果 C 中改变了方法签名,并且在应用 A 中显示声明依赖 C,编译时和运行时会分别出现什么情况?在编译时没有问题,正常编译通过;在运行时,当运行到类库 B 中使用的类库 C 中被改变签名的方法时,App crash。这是因为,maven 在处理类库版本 merge 时,会将 C 升级到最高版本,而此时 B 中已经编译好的 class 中使用的还是老版本 C 中的方法。

为了处理这个问题,我们使用 APICheckGradlePlugin 在编译时进行 check 操作,当发现被调用的方法找不到时,主动报错,将错误提前暴露在编译期,而非在运行时。同时内部强调 API 接口的向下兼容性,不用的方法标记为废弃,而非直接修改其方法签名或删除方法。

APICheckGradlePlugin 核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码try {
c.getClassPool().get(callClassName)
isClassNotFound = false
m.getMethod()
} catch (NotFoundException e) {
if (isClassNotFound) {
dealException(String.format("在%s类中的第%d行是用到的%s类不存在", className, line, callClassName))
} else {
dealException(String.format("在%s类中的第%d行是用到的%s类的%s方法不存在", className, line, callClassName, methodName))
}
}

多module发布

上文中提到,在多业务组件库工程中会有多个 module,一个 api module,一个 imp module,在使用 DemoApp 编译调试时采用源码依赖, imp module 依赖 api module,App 依赖 imp module,这样在打包上传 maven 时,会出现无法一起上传的问题;并且我们也要确保 api 和 imp 的版本号一致。为了解决这个问题,需要在上传时动态修改他们的 pom 文件,代码如下:

1
2
3
4
5
6
复制代码modifyPom { pom ->
pom.dependencies.findAll { dep -> dep.groupId == rootProject.name }.collect { dep ->
dep.groupId = pom.groupId = rootProject.groupId
dep.version = pom.version = rootProject.sdkVersion
}
}

一键创建项目

模板工程

由于每个新建组件类库的 App 工程需要运行时环境基本相同,包括网络环境、调试环境、gradle 配置、通用依赖配置等等,这些重复性的工作最好放在一起统一处理。为此,我们创建了组件库的模板工程,只需要 clone 下来模板仓库,然后修改一些代码即可开发需求代码。

一键创建类库

但是,这种方式依然有很多共性的工作,比如 clone 代码、修改类库名、修改 groupId:artifactId、创建新的类库仓库、push 代码、在大风车中新建类库关联仓库地址等等操作。这些共性操作仍然可以用机器来操作,所以我们在大风车新建类库这一步中,把前面所有要做的事情全部做完,只需要在新建类库时填入必要的参数,一键就可以创建出可用的类库项目。

image-20181031201425732

一键创建应用

随着我司 App 越来越多,新建 App 的配置同样面临类库刚开始时的困扰,新建 App 与新建类库本质上是一样的,只不过所需参数更多一些,并且这些参数可能不固定,有些 App 需要有些 App 不需要。参考类库,我们提取共性操作,创建了 App 的模板工程,并且对接大风车,一键即可创建出 App 工程,那些可变的参数留在模板工程中按需手动配置。

模块负责人

在组件化初步开始时,我们的每个模块都有固定的负责人,每个人手上都有固定的若干个模块,责任人对自己负责的模块负责。

但是随着组内的人员变动和业务变动,导致一些模块频繁易主,一些模块的文档长期处于不被维护状态,README 和 CHANGELOG 常年失修。

依赖大风车的类库管理,重新为每个模块指定负责人,并且梳理现存类库哪些缺失文档,进行补全。自从大风车自动抄送类库发版 CHANGELOG 后,CHANGELOG 不全的情况也大幅改善,基本每个新的版本都会附上该版本所做修改。

同时,我们也强调 CodeReview 机制,每个模块在提测前进行 CodeReview,强制merge request 必须有人点赞后才能合并 master 分支等等代码审查机制。未来,我们可能会进一步实践负责人 backup 方案,主副负责人相互 review,扩大大家技术视野的同时,可以进一步提高大家的主人翁意识。

总结

好的架构不是设计出来的,而是演进出来的。本文简单阐述了51信用卡 Android 架构演进的一些实践经验,同时我们坚信技术方案没有最优解,重要的是要选择选择适合自己的。脱离所处环境和问题本身谈技术方案,都将不能得到适合自身的开发架构。同时,我们也应当吸取和借鉴业界优秀的架构和设计理念,并将其根据自身适用场景加以改造,在理论和实践中逐渐交替探索演进。

当然,我们目前所使用的架构依然存在一些问题,比如组件拆分不完全、主工程业务仍然很多、CodeReview 机制不健全、代码扫描不够严格、一些组件库没有严格按照 api 工程来改造、一些老的组件依然没有 api module等等问题。我们也应该看到,正是因为这些实际的问题在推动我们进行技术改造,架构升级。同时,我们也要审视行业内大的方向,紧跟技术趋势,主动拥抱变化,毕竟技术世界唯一不变的,便是变化。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

架构整洁之道导读(二)续

发表于 2018-11-12

关于组件聚合张力图的讨论

周三的午休时间,我在ThoughtWorks北京办公室分享了一场《架构整洁之道导读》。当谈到分享组件聚合原则的时候,很多同事表示难以理解。究其缘由,是我们无法将组件违反原则的后果对应到真实项目的问题上,这就导致原则和实践之间的不一致。讨论的过程异常激烈,但是很遗憾地最终并没有得到一个服众的结论。所以为了进一步澄清这些争议点,我决定专门组织一场针对组件聚合原则张力图的讨论会。在吴大师的鼓动下,时间定在下周四晚上的8点半,与会人员大多是咨询团队的技术教练,也有我们项目上的客户。

在这场长达两个半小时的讨论会上,没想到首先出现争议的点居然是组件的定义。

组件是软件部署的最小单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。

对于这样的定义,大魔头提出了质疑:library(库)并不能独立部署。但凡出现明显的逻辑漏洞的时候,我们最好的方式是抛开译文回去看原文。

Components are the units of deployment. They are the smallest entities that can be deployed as part of a system.

阅读原文之后,我们发现“组件是软件部署的最小单元。”这句话翻译得并没有太大问题,但是第二句就有损原意了,原意是说可以作为系统的一部分被部署的最小实体,而没有强调部署过程这种动态的概念,否则就和前一句是同义反复。所以这个定义里面并没有说组件可以独立部署。后面提到组件可以被链接到一个独立可执行文件或者归档文件,又或者,可以被打包成.jar、.dll或者.exe文件,并以动态加载的插件形式实现独立部署。

解读组件的定义

来自原文:

Components can be linked together into a single executable. Or they can be aggregated together into a single archive, such as a .war file. Or they can be independently deployed as separate dynamically loaded plugins, such as.jar or .dll or .exe
files.

来自讨论:

20:56:56 From tianjie : These dynamically linked files, which can be plugged together at runtime, are the software components of our architectures.

联系上下文理解之后,我们知道:组件可以被设计成独立部署的,但是并不是所有的组件都是可以独立部署的。这是要澄清的,不然讨论聚合原则的时候容易出现偏差。

吴大师接着解释说,组件应该是个逻辑单元,而不是物理单元。强制某个代码模块就是一个物理的部署单元是不合适的。另外,鲍勃大叔在介绍架构边界时,也表明了一样的观点:架构的边界并不是服务的边界。

解读REP原则

我按照自己的思路解释过REP、CCP和CRP原则[1]之后,讨论的焦点很快聚集到REP原则的解读和实践意义上。

吴大师认为REP原则如果简单解读成没有发布过程就不能复用,它就和CCP、CRP原则的排斥力量不均衡,无法形成稳定的三角关系,那么这个张力图就显得有点鸡肋。

尚奇受到CAP(分布式系统基本原理,一致性,可用性和分区容错性)原则的启发提出了另一个解读方向。他说,CAP原则在分布式系统的实践里,都会先站住P原则,然后在C和A中权衡。那么在REP、CCP和CRP三角关系里,REP原则就相当于这里的P原则,必须先满足然后再去取舍CCP和CRP。

大魔头理解REP的意思是可复用性就是组件是独立可复用的。假如回到没有Maven这些工具,没有依赖管理的年代,如果我们所依赖的包还依赖其它第三方包,那么这个包就不能叫做独立可复用。

21:13:04 From YangYun : 我倒是理解REP的意思是你发布出来的一个可重用的包就是独立可重用的,你不能让我必须带着别的jar包才能用它。 21:14:04 From YangYun : The granule of reuse is the granule of release

他接着说,假如有两个提供同样功能的包,其中一个没有第三方的依赖,而另一个有,那我当然选择前者。

技术教练Sara举出了一个相对复杂但是很有启发性的例子。

21:46:35 From Qian Ping : 假设项目包含sub module ABC

  • 如果ABC单纯sub module没有打成jar,又互相直接复用了,就是违反了REP
  • 如果每个sub module,打成jar,互相复用的时候是通过对方特定版本的jar(如snapshot版本),就是符合REP
  • 如果符合REP了,而所有sub module是跟随整个项目一起升级版本,就是符合CCP因为他们是一体一起发布的
  • 这时假如A依赖B和C,我这次单纯想改C,他们一起升版本了。但其实B的Jar完全没有变化,这个对B来说就是一个不必要的发布,B又貌似应该分离出去,但如果它分离出去了,就又离REP和CCP远了

对于最后一句的表述,她澄清道:

之前有遇到一个情况,比如组件A,然后它里面需要用到一个common library, lib里面其实包含了比如3个sub module(1/2/3),全部都是A需要复用的, 这时候如果要改1/2/3里面任意的东西,都会一起升级lib,然后在A里面对应升级版本。

后来,有一些新组件B,它只需要用到common lib里面的3,不需要1/2,于是3一直被改和打包版本。 此时1/2会跟着升版本号,但其实1/2内容本身是完全没有变化的,只是版本号升了。

这个场景中引入了两个组件A和B分别依赖common library的某些模块。在我们讨论一个组件依赖时,面临的约束要简单很多,但是复用的初衷就是给多个组件去依赖,所以这个假设是很有价值。

Sara分析的思路如下:

如果分离出去,等于我有两个common lib(1/2 和 3), 对于B来说,B只需要3这么一个lib是比较完美的,反正改了3再改B就好了。

但对于A来说,它就需要同时升级1/2的lib和3的lib,等于要3个发布,而它原来只需要2个发布(1/2/3 + A),所以离CRP远了,同时它也要分别维护两个lib分别的版本升级,所以CCP也比原来差了。

在她的分析下,我们发现CRP和CCP不单是互相排斥的,还有可能两者都无法满足。造成这种结果的原因在于1/2/3模块形成的这个common library对于A组件而言都符合CCP和CRP原则,但是对于B组件而言,是不满足REP和CRP原则的,因为每次想要依赖3模块,就得全部依赖1/2/3整个common library(复用困难)。反之,如果我们将3从1/2/3中拆出来成为独立的组件,那就几乎宣告对于A组件而言势必违反CCP和CRP原则,但是B组件却获得了符合REP和CRP原则的好处。

她接着补充道:

其实后来说起对应微服务的时候有另外一个想法,就是比如说我系统里面多个组件需要用计提(Mark to market[2])这么一个功能,说白了就是一条公式,那通常可以有几个做法

  • 直接把这个公式复制到要用的组件,code level的复用,没有版本 -> REP bad, CCP bad, but CRP not bad (因为要更改时候发布次数还是一样的)
  • 把公式写到一个common lib里面再进行复用 -> REP good, CCP good, CRP bad(多发布一次)
  • 把公式放在一个独立service -> REP good, CCP bad(因为要维护多一个服务), CRP good

这个观点就上升到不同层次的复用性上,可以算是对组件聚合原则的普适性的探索。

当话题再次被聚焦到复用性时,技术教练MoMo提出一个观点:我们现在讨论就是可复用组件应该遵循的原则,而REP是对复用粒度的定义。至于那些那些常年采用SNAPSHOT(Java项目里Maven常用的开发版本号),没有发布概念的组件,就不该纳入复用的考虑范围内,那些也就不是REP的反模式。

与此同时,阎王指出了一个翻译上的失误。组件粘合张力图中REP原则的简短描述是“为复用性而组合”,而原文其实是”Group for reusers”,翻译过来应该是为了复用者而组合,复用性的英文是 Reusability。所以为了复用者发布,考虑的就是对外部的承诺。

tension diagram

外部资料

大魔头在加班写方案和讨论的间隙,快速查阅了一些资料,比如wiki上对于REP原则的定义:

21:45:05 From YangYun : Reuse-release Equivalence Principle (REP) REP essentially means that the package must be created with reusable classes – “Either all of the classes inside the package are reusable, or none of them are”. The classes must
also be of the same family. Classes that are unrelated to the purpose of the package should not be included. A package constructed as a family of reusable classes tends to be most useful and reusable. - wiki百科里

在wiki的定义里,可以看到REP原则包含CRP和CCP原则的成分,如此看来,这三大原则并不符合MCME分类原则,就连鲍勃大叔在书中也是模棱两可的态度——REP维护共同的大主题,组件中的类和模块也必须紧密相关,这基本是CCP和CRP的简版描述。

然后大魔头查找到“粒度”这个词在软件设计中详细定义,这是对REP原则定义(软件复用的最小粒度等同于其发布的最小粒度)的分解和再认知。

21:57:03 From YangYun : condor.depaul.edu/dmumaugh/OO…

granularity

21:58:29 From YangYun : fi.ort.edu.uy/innovaporta…

design principles
这些观点和学术建议很有代表性,值得大家反复揣摩和思考。

反模式

软件工程师一般有个“正难则反”的习惯。原则较抽象,但是模式很具体,反模式更能指导实践。接下来,大家开始讨论哪些是违反了REP原则的反模式。

首当其冲的就是git submodule,在某些项目中,这种通过源代码划分模块并共享的方式还是挺常见的。因为共享的是代码,所以每次共享代码更新,势必要让依赖方重新编译,发布和部署。这种做法对于复用是痛苦的。

其次是常年使用SNAPSHOT版本的某些项目。这些项目的特点一般都是某个产品团队底下,内部团队之间有复用的要求。缺点其实也很明显,常年SNAPSHOT等于没有版本和发布的流程。使用者并不知道SNAPSHOT中哪些是稳定的,哪些是修改的,拿到的版本到底是最新的还是遗留的,我需要的功能在这个功能有包含,还是你包含了太多我不需要的升级。这种也是复用痛苦的。

REP原则小结

综合以上两个例子以及其它讨论,我们得出了一个好玩的结论:软件工程发展到现在,REP原则已经是基本的要求,它的存在有可能是鲍勃大叔年代感老了的体现。


于 2018-11-12


  1. 架构整洁之道导读(二)组件聚合 ↩
  2. Mark to market 按市值计价 ↩

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

斗图会输?不存在的

发表于 2018-11-08

本文概要

前言:

本文非常浅显易懂,可以说是零基础也可快速掌握。如有疑问,欢迎留言,笔者会第一时间回复。本文代码存于github

一、分析表情包网址

进入斗图啦

1、进入斗图啦网址,点击**“最新表情”**,再点击第二、第三页,得出规律如下:

第一页:www.doutula.com/photo/list/…
第三页:www.doutula.com/photo/list/…
第四页:www.doutula.com/photo/list/…

可以看出,page 的值跟点击的页数有关,因此,我们就拿到了要爬取的 url

2、打开检查元素,可以看到html源码

html源码

可以看出,每一页的图片都在 div 标签里面,然后每一个 a 标签包含一个图片,
html源码

我们要取到表情包,就是要取得 a 标签包含的图片 url,我们可以利用 xpath 语法。

二、实践

a、获取 img 标签取到的 img 有 gif 的信息,我们需要过滤掉
imgs = html.xpath("//div[#class='page-content text-center'//img[@class != 'gif']")

b、接下来是获取图片的 url,上面的代码如下:

1
2
3
4
复制代码    for img in imgs:
# print(etree.tostring(img))
img_url = img.get('data-original') # 不知道为什么多个 !data ,去掉它
img_url = img_url.replace("!dta", "")

c、截取后缀,得到文件名,并保存

1
2
3
4
5
复制代码        alt = img.get('alt') # 获取图片名字
# alt 可能某些情况下需要处理非法字符(这些字符不可以当做名字保存)
suffix = os.path.splitext(img_url)[1] # 对url进行分割,取数组中的第二位,得到后缀名
filename = alt + suffix
request.urlretrieve(img_url, 'images/' + filename) # 保存图片

这样下来,就已经可以快速保存你所需要的表情包了,论斗图,谁比得过你

全部代码如下:

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
复制代码def parse_page(url):

headers = {
'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36'
}
response = requests.get(url, headers=headers)
# print(response.text) # 打印html源代码
html = etree.HTML(response.text)
imgs = html.xpath("//div[@class='page-content text-center']//img[@class!='gif']")
for img in imgs:
# print(etree.tostring(img))
img_url = img.get('data-original') # 不知道为什么多个 !data ,去掉它
img_url = img_url.replace("!dta", "")
# print(img_url)
alt = img.get('alt') # 获取图片名字
# alt 可能某些情况下需要处理非法字符(这些字符不可以当做名字保存)
print(alt)
suffix = os.path.splitext(img_url)[1] # 对url进行分割,取数组中的第二位,得到后缀名
filename = alt + suffix
print(filename)
request.urlretrieve(img_url, 'images/' + filename) # 保存图片

def main():
for x in range(1,51): # # 爬取前50页 range(1,3) 这里相当于 1 2
url = 'https://www.doutula.com/photo/list/?page=%d' % x
parse_page(url)
break

最终结果:

斗图.png

区区20几行代码,就可以造就一个斗图西方求败的你,赶快来行动吧!

当然,还可以更高级一点,就是利用多线程,异步进行爬取、下载,几秒钟就可以下载到上千张的表情包!相关代码,我也放到了 github ,需要的朋友自行去look look!




更多精彩内容,请关注公众号 “bigdeveloper”——程序员大咖秀

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

整理杂乱的系统日志

发表于 2018-11-06

由来


最近有个需求需要统计一个方法的耗时,这个方法前后各打出一条日志,类似于 [INFO] 20180913 19:24:01.442 method start/end unique_id,unique_id 是我们框架为了区分每一个请求而生成的唯一ID。

由于在高并发场景下, start 和 end 的日志并不一定是挨着的,很可能方法执行期间,又有其他的日志输出了出来,于是产生了这样的日志:

1
2
3
4
5
复制代码`[INFO] 20180913 19:24:01.442 method start aaa`
[INFO] 20180913 19:24:01.452 method start bbb
[INFO] 20180913 19:24:01.456 do something ccc
[INFO] 20180913 19:24:01.562 method end aaa
...

由于日志在服务器上,不想再把日志 down 下来,又因为日志比较规范,于是决定自己写个 shell 脚本来处理这些数据。花了一下午时间,换了 4 个 shell 脚本,才优雅地处理了这些数据,其中走进了思维误区,踩了一个扩展问题的大坑。

转载随意,请注明来源地址:zhenbianshu.github.io ,文章持续修订。

初入坑


思路

处理这个问题的第一步,肯定是拿到要处理的这些数据,首先用 grep 命令加输出重定向可以轻松地拿到这些数据,一共 76W。

由于需要考虑不同请求日志的穿插问题,又加上用久了 PHP 的数组和 Java 的 map 而形成的惯性思维,又加上我最近学习的 bash 的关联数据用法,我决定把 start 日志和 end 日志,拆分为两个文件,然后将这些数据生成两个大的关联数组,以 unique_id 为键,以当时的时间戳为值,分别存储请求的开始时间(arr_start)和结束时间(arr_end)。最后再遍历结束时间数组(arr_end),并查找开始时间数组内的值,进行减法运算,将差值输出到另一个文件里。

这样,写出的脚本就是这样:

脚本

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
复制代码`#!/bin/bash`

# 获取准确时间的函数
function get_acc_time() {
arr=($1)
date_str=${arr[1]}" "${arr[2]}
# date -d "date_str" "+%format" 可以指定输入日期,以替代默认的当前时间
# cut 根据 '.' 切分,并取第二个字段
echo `date -d "$date_str" "+%s"`"."`echo ${arr[2]} | cut -d '.' -f2`
}

# 使用 -A 声明关联数组
declare -A arr_start
declare -A arr_end

# 构造开始时间数组
while read -r start_line
do
arr_start[${arr[5]}]=`get_acc_time $start_line`
done < $start_file

# 构造结束时间数组
while read -r end_line
do
arr_end[${arr[5]}]=`get_acc_time $end_line`
done < $end_file

# 遍历结束时间数组
for request in ${!arr_end[*]}
do
end_time=${arr_end[$request]}
start_time=${arr_start[$request]}
if [ -z "$start_time" ]; then
continue;
fi
# 直接使用 bc 不会显示小数点前的 0
echo `print "%.3f" echo "scale=3;$end_time-$start_time"| bc` >> $out_file
done

越陷越深


这个脚本有个缺陷就是它执行得非常慢(后面小节有对它速度的分析),而且中途没有输出,我根本不知道它什么进度,运行了半个小时还没有输出,急于解决问题,我就换了一个方法。

排序并行法

这时我想让它忙尽快有结果输出出来,让我随时能看到进度,而且只有部分结果出来时我也能进行分析。那么有没有办法让我在遍历结束日志的时候能很轻松地找到开始日志里面对应的那条请求日志呢?

因为日志是按时间排序的,如果保持其时间序的话,我每次查找开始日志都得在一定的时间范围内找,而且遍历到下一条结束日志后,开始日志的查找起点也不好确定。 如果用上面的日志示例,我查找 unique_id 为 aaa 的请求时,我必须查找 19:24:01.442-19:24:01.562 这一时间范围内的所有日志,而且查找 unique_id 为 bbb 的请求时,无法确定时间起点,如果从开头遍历的话,消耗会非常大。

这个方法肯定是不可行的,但我可以把这些请求以 unique_id 排序,排序后它们会像两条并行的线,就像:

1
2
复制代码`开始日志 aaa bbb ccc ddd eee fff`
结束日志 aaa bbb ccc ddd eee fff

我只需要记录每一个 unique_id 在结束日志里的的行数,查找开始时间时,直接取开始日志里的对应行就可以了。

使用 sort -k 5 start.log >> start_sorted.log 将日志排下序,再写脚本处理。

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
复制代码`#!/bin/bash`

function get_acc_time() {
date_str=$1" "$2
acc_time=`date -d "$date_str" "+%s"`"."`echo $2 | cut -d '.' -f2`
echo $acc_time
}

total=`cat $end_file | wc -l`
i=0
while read -r start_line
do
i=`expr $i + 1`
arr_start=($start_line)

# 如果取不到的话去下一行取
for j in `seq $i $total`
do
end_line=`awk "NR==$j" $end_file` // 用 awk 直接取到第 N 行的数据
arr_end=($end_line)

# 判断两条日志的 unique_id 一样
if [ "${arr_start[5]}" = "${arr_end[5]}" ];then
break
fi
i=`expr $i + 1`
done

start_time=`get_acc_time ${arr_start[1]} ${arr_start[2]}`
end_time=`get_acc_time ${arr_end[1]} ${arr_end[2]}`

echo `print "%.3f" echo "scale=3;$end_time-$start_time"| bc` >> $out_file
done < $start_file

非常遗憾的是,这个脚本执行得非常慢,以每秒 1 个结果的速度输出,不过我还没有技穷,于是想新的办法来解决。

全量加载法

这次我以为是 awk 执行得很慢,于是我想使用新的策略来替代 awk,这时我想到将日志全量加载到内存中处理。 于是我又写出了新的脚本:

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
复制代码`#!/bin/bash`
function get_time() {
date_str=$1" "`echo $2 | cut -d '.' -f1`
acc_time=`date -d "$date_str" "+%s"`"."`echo $2 | cut -d '.' -f2`
echo $acc_time
}

SAVEIFS=$IFS # 保存系统原来的分隔符(空格)
IFS=$'\n' # 将分隔符设置为换行,这样才能将文件按行分隔为数组

start_lines=(`cat $start_file`)
end_lines=(`cat $end_file`)

total=`cat $end_file | wc -l`
i=0
IFS=$SAVEIFS # 将分隔符还设置回空格,后续使用它将每行日志切分为数组
for start_line in ${start_lines[*]}
do
arr_start=($start_line)

for j in `seq $i $total`
do
end_line=${end_lines[$j]}
arr_end=($end_line)

if [ -z "$end_line" -o "${arr_start[5]}" = "${arr_end[5]}" ];then
break
fi
i=`expr $i + 1`
done

i=`expr $i + 1`
start_time=`get_time ${arr_start[1]} ${arr_start[2]}`
end_time=`get_time ${arr_end[1]} ${arr_end[2]}`

echo `print "%.3f" echo "scale=3;$end_time-$start_time"| bc` >> $out_file
done

脚本运行起来后,由于需要一次加载整个日志文件,再生成大数组,系统顿时严重卡顿(幸好早把日志传到了测试机上),一阵卡顿过后,我看着依然每秒 1 个的输出沉默了。

新的思路


这时终于想到问一下边上的同事,跟同事讲了一下需求,又说了我怎么做的之后,同事的第一反应是 你为啥非要把日志拆开?,顿时豁然开朗了,原来我一开始就错了。

如果不把日志分开,而是存在同一个文件的话,根据 unique_id 排序的话,两个请求的日志一定是在一起的。再用 paste 命令稍做处理,将相邻的两条日志合并成一行,再使用循环读就行了,命令很简单: cat start.log end.log | sort -k 5 | paste - - cost.log,文件生成后,再写脚本来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码`#!/bin/bash`
function get_time() {
date_str=$1" "`echo $2 | cut -d '.' -f1`
acc_time=`date -d "$date_str" "+%s"`"."`echo $2 | cut -d '.' -f2`
echo $acc_time
}

while read -r start_line
do
arr_s=($start_line)

start_time=`get_time ${arr_s[1]} ${arr_s[2]}`
end_time=`get_time ${arr_s[5]} ${arr_s[6]}`

# 每行前面输出一个 unique_id
echo -e ${arr_s[5]}" \c" >> $out_file
echo `print "%.3f" echo "scale=3;$end_time-$start_time"| bc` >> $out_file
done < $start_file

再次运行,发现速度虽然还不尽如人意,但每秒至少能有几十个输出了。使用 split 将文件拆分为多个,开启多个进程同时处理,半个多小时,终于将结果统计出来了。

脚本运行速度分析


问题虽然解决了,但脚本运行慢的原因却不可放过,于是今天用 strace 命令分析了一下。 由于 strace 的 -c 选项只统计系统调用的时间,而系统调用实际上是非常快的,我更需要的时查看的是各个系统调用之间的时间,于是我使用 -r 选项输出了两个步骤之间的相对时间,统计了各步骤间相对时间耗时。

read 慢

从统计数据可以看到它的很大一部分时间都消耗在 read 步骤上,那么, read 为什么会这么慢呢?

仔细检视代码发现我使用很多

stace 的 `-f` 选项跟踪子进程,看到输出如下: ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/11/6/166e6ca873491866~tplv-t2oaga2asx-jj-mark:3024:0:0:0:q75.png)
1
2
3
4
5
6
7
8
9
10
11
12


![](https://gitee.com/songjianzaina/juejin_p7/raw/master/img/c6ec770b6c4e6de9be806d27694d636e7b1e98c86991d4682dae0085c5049c42)


可以看出进程创建一个子进程并执行命令,到最后回收子进程的消耗是很大的,需要对子进程进行信号处理,文件描述符等操作。最终工作的代码只有一个 `write` 且耗时很短。


由于脚本是完全同步运行的,所以子进程耗时很长,主进程的 read 也只能等待,导致整个脚本的耗时增加。


为了验证我的猜测,我把脚本简写后,使用 `time` 命令统计了耗时分布。

复制代码#!/bin/bash
while read -r start_line
do
str=echo "hello"
done < $start_file


![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/11/6/166e6ca89e80d2dc~tplv-t2oaga2asx-jj-mark:3024:0:0:0:q75.png)


![](https://gitee.com/songjianzaina/juejin_p7/raw/master/img/7fc9b417ceb6bf68a5a958081fe6c32f5c4bb542f0c81b8f2b762cb20a184674)


可以看得出来,绝大部分时间都是系统时间。


#### 循环慢


另外一个问题是,最终解决问题的脚本和全量加载法的脚本在主要步骤上并没有太大差异,但效率为什么会差这么多呢?


我忽然注意到脚本里的一个循环 `for j in `seq $i $total``, 这个语句也创建了子进程,那它跟主进程之间的交互就是问题了所在了, 在脚本运行初期,`$i` 非常小,而 `$total` 是结束日志的总行数:76W,如果 seq 命令是产生一个这么大的数组。。。


我使用 strace 跟踪了这个脚本,发现有大量的此类系统调用: ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/11/6/166e6ca8cd96fb6b~tplv-t2oaga2asx-jj-mark:3024:0:0:0:q75.png)


![](https://gitee.com/songjianzaina/juejin_p7/raw/master/img/c94a5b2fa0fa602a7f0bc4f7b31ced258b44608b3ce8297d1a91f2efa77df416)


总算破案了。


小结
--




---


在这个问题的解决上,我的做法有很多不对的地方。


首先,解决问题过程中,脚本不正常地效率低,我没有仔细分析,而是在不停地避开问题,寻找新的解决方案,但新方案的实施也总有困难,结果总在不停地试错路上。


然后是解决问题有些一根筋了,看似找到了一个又一个方案,其实这些方案都是旧方案的补丁,而没有真正地解决问题。从A问题引入了B问题,然后为了解决B问题又引入了C问题,直到撞到南墙。


在第一家公司,初入编程领域时,我当时的 leader 老是跟我们强调一定要避免 X-Y 问题。针对 X 问题提出了一个方案,在方案实施过程中,遇到了问题 Y,于是不停地查找 Y 问题的解决办法,而忽略了原来的问题 X。有时候,方案可能是完全错误的,解决 Y 问题可能完全没有意义,换一种方案,原来的问题就全解决了。


在跟别人交流问题时,我一直把初始需求说清楚,避免此类问题,没想到这次不知不觉就沉入其中了,下次一定注意。


关于本文有什么问题可以在下面留言交流,如果您觉得本文对您有帮助,可以点击下面的 `推荐` 支持一下我,博客一直在更新,欢迎 `关注`。




**本文转载自:** [掘金](https://juejin.cn/post/6844903704961761294)

*[开发者博客 – 和开发相关的 这里全都有](https://dev.newban.cn/)*
1…882883884…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%