震惊!JavaScript 竟然可以类型推断!

作为弱类型的 JavaScript 写起来爽,维护起来更

—— 鲁迅·沃梅硕果

近几年,前端技术的发展可以用 Big Boom 来形容,因此 JavaScript 也被大规模的运用在项目中,由此也产生了代码的维护问题,所谓 动态类型一时爽,代码重构火葬场

其实不仅仅是代码重构,在日常开发中也能感受到弱类型语言的不足所带来的不便之处。举个例子,现在有个函数 renderUserList , 作用是将用户列表显示在界面上

function renderUserList(el, userList) {
  let html = '';
  for (const user of userList) {
    html += `<div>
    姓名:${user.age}
    年龄:${user.age}
    </div>`
  }
  el.innerHTML = html;
}

我敢打赌,大家在写这种类型函数的时候,都是在盲写,因为我们不知道传入的 eluserList 到底是什么类型。更不知道 el 下面有哪些方法,写的时候都如此费劲,跟别谈维护了。其实我们可以通过一些简单的操作,让这个函数写起来更轻松,就像下面一样:

类型提示1

类型提示2

那么,到底是怎么实现的呢?接下来就要介绍本文的主角 JSDocVSCode

JSDoc是一个根据 JavaScript 文件中注释信息,生成 JavaScript 应用程序或库、模块的 API 文档 的工具。你可以使用他记录如:命名空间,类,方法,方法参数等。

通俗的讲,JSDoc 是 JavaScript 注释规范的一种,VSCode 利用 JSDoc 规范的特点,配合 typescript 实现了“类型提示”,所以在 VSCode 中基本上是 开箱即用 的,而对于非内置对象,比如 jQuery 的 $,lodash 的 _ 等,则需要单独下载对应的声明文件。

不过实际开发中,在 window 和 mac 上,还是有些差别的,mac 版的 VSCode 会去检查代码,然后自动下载对应的声明文件存放在 ~/Library/Caches/typescript/ (猜测是自动下载的),而 windows 则需要开发者手动通过 npm 去安装需要的声明文件,文末也会提到如何使用声明文件。

另外在 .jsx 中也可以使用 JSDoc,webstorm 也支持通过 JSDoc 实现类型提示, sublime 貌似还不支持。

在 VSCode 中会自动根据 JSDoc 的标注对变量、方法、方法参数等进行类型推断,通过 TypeScript 来进行智能提示,因此从编写注释开始学习 TypeScript 也是一个不错的选择,下面就来一一列举 JSDoc 在代码中的用法。

变量

@type 标注变量的类型

基础类型

/**
 * @type {number}
 */
let n;

/** @type {boolean} */
let flag;

/** @type {string} */
let str;

联合类型

如果一个变量可能是多种类型,则可以使用联合类型

/** 
 * @type {string | boolean}
 */
let x;

自定义类型

我们经常用到自定义类型,也就是 JavaScript 中的对象,对于简单的对象,可以用下面的写法

/**
 * @type {{name: string, age: number}}
 */
let user;

对于键值对比较多的复杂对象,可以使用 @typedef 来定义复杂类型,用 prop 或者 property 来定义对象的属性。

/**
 * @typedef {Object} goods
 * @property {string} name
 * @prop {number} code
 * @prop {string=} thumbnail 用 = 表示该属性是可能存在,也可能不存在
 * @prop {string} [introduction] 也可以给属性名加上 [] 表示这是一个可选属性
 * @prop {string[]} label
 */

 /**
  * @type {goods}
  */
 let phone;

数组

可以使用 [] 或者 Array 表示数组

/**
 * @type {number[]}
 */
let numList;

/**
 * @type {Array<string>}
 */
let strList;

对于已经定义的类型或者已经声明的变量,也是可以直接使用,下面分别声明一个 user 数组和 goods 数组

/**
 * @type {user[]}
 */
let userList;

/**
 * @type {goods[]}
 */
let goodsList;

如果不确定数组的每一项具体类型,可以使用 any * 或者交叉类型

/**
 * @type {any[]}
 */
let arr1;

/**
 * @type {*[]}
 */
let arr2;

/**
 * @type {(user | goods)[]}
 */
let arr3

泛型

/**
 * @template T
 * @param {T} p1
 * @return {T}
 */
function gen(p1) { return p1 }

函数

@name 表示函数的名称

@param 表示函数的参数

@return@returns 表示函数的返回值

一般函数的写法大致分为两种:声明式函数和函数表达式。

函数表达式

/**
 * @type {function (number, number): number}
 */
var getSum = (n1, n2) => n1 + n2;

声明式函数

/**
 * @name fn
 * @param {string} str
 * @param {boolean} flag
 * @returns {*[]}
 */
function fn(str, flag) {
  return [];
}

通过上面的注释写法,便可以在函数 fn 内部正确的识别出两个参数的类型,并且可以知道该函数返回值类型为数组。

对于函数参数的类型,写法和上面的变量写法一致,区别是将 @type 换成了 @param,函数的返回值也是同样的道理。

对象的方法

对函数的注释同样适用于对象的方法

var o = {
  /**
   * @param {string} msg
   * @returns {void}
   */
  say(msg) {
    console.log(msg);
  }
}

内置类型和其它类型

上面的例子只是简单的用到了一些常见的类型,然而在实际开发中,我们用到的不止这些,比如开始文章开头的例子中,有用到了 DOM 对象,那该怎么编写注释呢?其实 VSCode 已经为我们提供了很多的类型了,比如 DOM 对象对应的类型是 HTMLElement , 事件对象对应的类型是 Event,同时 DOM 对象还可以更细化,比如 HTMLCanvasElementHTMLImageElement 等等。

同时,我们在开发中也会用到第三方的类库或框架,通常情况下,这些类库都会有一份以 d.ts 结尾的声明文件,该声明文件中包含了所用到类型的所有提示,以最为经典的 jQuery 为例,如果在时在 webpack 环境下,在通过 npm 安装 jQuery 后,需要再单独安装对应的声明文件 @types/jquery ,这样 VSCode 就可以正确的识别 $ 符号,也可以在 JSDoc 中使用 JQuery, JQueryStatic 等这都类型了,就像下面这样

/**
 * @type {JQuery}
 */
var $btn = $('button');

/**
 * @param {number} userId
 * @returns {JQuery.jqXHR} 
 */
function getUser(userId) {
  return $.get(`/user/${userId}`);
}

大部分情况下,通过 npm 发布的包,都会包含其对应的声明文件,如果没有的话,可以通过这个地址 TypeSearch 来搜索一下并安装 ,如果感兴趣可以到这个仓库 DefinitelyTyped 看看。当然你也可以提供一些仓库内目前还没有声明文件,别人会非常感谢你的!

当然并不是所有的项目都用到了 npm ,仍有很多项目在使用 script 这种方式从 cdn 来引入 .js 文件,这种情况下用不到 webpack ,也用不到 npm ,那这个时候就要从上面所提到的仓库地址 DefinitelyTyped 来下载对应的声明文件了,然后通过 /// <reference path="" /> 这种形式来引入声明文件,就像下面这样

/// <reference path="./node_modules/@types/jquery/index.d.ts"/>

个人建议:即使是通过 cdn 方式来引入 .js 文件,也可以通过 npm 来安装 @types/ ,这样和在每个文件中通过 /// <reference path="" /> 引入声明文件相比,还是方便很多的。

总结

以上便是关于利用 JSDoc 实现 JavaScript 的类型提示。当然还有一些更深入的用法,比如全局模板文件,命名空间等,但是这些和 TypeScript 关系更大一些。当有一天你发现 JSDoc 已经不能满足你的时候,便是向着 TypeScript 大举进攻的时候了。

0

OpenResty 不完全指南

OpenResty 简介

OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台。我们知道开发 Nginx 的模块需要用 C 语言,同时还要熟悉它的源码,成本和门槛比较高。国人章亦春把 LuaJIT VM 嵌入到了 Nginx 中,使得可以直接通过 Lua 脚本在 Nginx 上进行编程,同时还提供了大量的类库(如:lua-resty-mysql lua-resty-redis 等),直接把一个 Nginx 这个 Web Server 扩展成了一个 Web 框架,借助于 Nginx 的高性能,能够快速地构造出一个足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。

Nginx 采用的是 master-worker 模型,一个 master 进程管理多个 worker 进程,worker 真正负责对客户端的请求处理,master 仅负责一些全局初始化,以及对 worker 进行管理。在 OpenResty 中,每个 worker 中有一个 Lua VM,当一个请求被分配到 worker 时,worker 中的 Lua VM 里创建一个 coroutine(协程) 来负责处理。协程之间的数据隔离,每个协程具有独立的全局变量 _G

ngx_lua works.png

OpenResty 处理请求流程

由于 Nginx 把一个请求分成了很多阶段,第三方模块就可以根据自己的行为,挂载到不同阶段处理达到目的。OpenResty 也应用了同样的特性。不同的阶段,有不同的处理行为,这是 OpenResty 的一大特色。OpenResty 处理一个请求的流程参考下图(从 Request start 开始):

image

指令 使用范围 解释
int_by_lua* init_worker_by_lua* http 初始化全局配置/预加载Lua模块
set_by_lua* server,server if,location,location if 设置nginx变量,此处是阻塞的,Lua代码要做到非常快
rewrite_by_lua* http,server,location,location if rewrite阶段处理,可以实现复杂的转发/重定向逻辑
access_by_lua* http,server,location,location if 请求访问阶段处理,用于访问控制
content_by_lua* location, location if 内容处理器,接收请求处理并输出响应
header_filter_by_lua* http,server,location,location if 设置 heade 和 cookie
body_filter_by_lua* http,server,location,location if 对响应数据进行过滤,比如截断、替换
log_by_lua http,server,location,location if log阶段处理,比如记录访问量/统计平均响应时间

更多详情请参考官方文档

配置 OpenResty

OpenResty 的 Lua 代码是提现在 nginx.conf 的配置文件之中的,可以与配置文件写在一起,也可以把 Lua 脚本放在一个文件中进行加载:

内联在 nginx.conf 中:

server {
    ...
    location /lua_content {
         # MIME type determined by default_type:
         default_type 'text/plain';

         content_by_lua_block {
             ngx.say('Hello,world!')
         }
    }
    ....
}    

通过加载 lua 脚本的方式:

server {
    ...
    location = /mixed {
         rewrite_by_lua_file /path/to/rewrite.lua;
         access_by_lua_file /path/to/access.lua;
         content_by_lua_file /path/to/content.lua;
     }
    ....
} 

OpenResty 变量的共享范围

全局变量

在 OpenResty 中,只有在 init_by_lua*init_worker_by_lua* 阶段才能定义真正的全局变量。因为在其他阶段,OpenResty 会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在上述两个阶段可以定义全局变量,也尽量避免这么做。全局变量能解决的问题,用模块变量也能解决,而且会更清晰,干净。

模块变量

这里将定义在 Lua 模块中的变量称为模块变量。Lua VM 会将 require 进来的模块换成到 package.loaded table 里,模块里的变量都会被缓存起来,在同一个 Lua VM下,模块中的变量在每个请求中是共享的,这样就可以避免使用全局变量来实现共享了,看下面一个例子:

nginx.conf

worker_processes  1;

...
location {
    ...
    lua_code_cache on;
    default_type "text/html";
    content_by_lua_file 'lua/test_module_1.lua'
}

lua/test_module_1.lua

local module1 = require("module1")

module1.hello()

lua/module1.lua

local count = 0
local function hello() 
    count = count + 1
    ngx.say("count: ", count)
end

local _M  = {
    hello = hello
}   

return _M

当通过浏览器访问时,可以看到 count 输出是一个递增的,这也说明了在 lua/module1.lua 的模块变量在每个请求中时共享的:

count: 1
count: 2
.....

另外,如果 worker_processes 的数量大于 1 时呢,得到的结果可能就不一样了。因为每个 worker 中都有一个 Lua VM 了,模块变量仅在同一个 VM 下,所有的请求共享。如果要在多个 Worker 进程间共享请考虑使用 ngx.shared.DICT 或如 Redis 存储了。

本地变量

跟全局变量,模块变量相对,我们这里姑且把 *_by_lua* 里定义的变量称为本地变量。本地变量仅在当前阶段有效,如果需要跨阶段使用,需要借助 ngx.ctx 或者附加到模块变量里。

这里我们使用了 ngx.ctx 表在三个不同的阶段来传递使用变量 foo

location /test {
     rewrite_by_lua_block {
         ngx.ctx.foo = 76
     }
     access_by_lua_block {
         ngx.ctx.foo = ngx.ctx.foo + 3
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.foo)
     }
 }

额外注意,每个请求,包括子请求,都有一份自己的 ngx.ctx 表。例如:

 location /sub {
     content_by_lua_block {
         ngx.say("sub pre: ", ngx.ctx.blah)
         ngx.ctx.blah = 32
         ngx.say("sub post: ", ngx.ctx.blah)
     }
 }

 location /main {
     content_by_lua_block {
         ngx.ctx.blah = 73
         ngx.say("main pre: ", ngx.ctx.blah)
         local res = ngx.location.capture("/sub")
         ngx.print(res.body)
         ngx.say("main post: ", ngx.ctx.blah)
     }
 }

访问 GET /main 输出:

main pre: 73
sub pre: nil  # 子请求中并没有获取到父请求的变量 $pre
sub post: 32
main post: 73

性能开关 lua_code_cache

开启或关闭在 *_by_lua_file(如:set_by_lua_file, content_by_lua_file) 指令中以及 Lua 模块中 Lua 代码的缓存。

若关闭,ngx_lua 会为每个请求创建一个独立的 Lua VM,所有 *_by_lua_file 指令中的代码将不会被缓存到内存中,并且所有的 Lua 模块每次都会从头重新加载。在开发模式下,这给我们带来了不需要 reload nginx 就能调试的便利性,但是在生成环境下,强烈建议开启。 若关闭,即使是一个简单的 Hello World 都会慢上一个数量级(每次 IO 读取和编译消耗很大)。

但是,那些直接写在 nginx.conf 配置文件中的 *_by_lua_block 指令下的代码不会在你编辑下实时更新,只有发送 HUP 信号给 Nginx 才能能够重新。

小案例

通过 OpenResty + Redis 实现动态路由

Nginx 经常用来作为反向代理服务器。通常情况下,我们将后端的服务配置在 Nginx 的 upstream 中,当后端服务有变更时就去修改 upstream 中的配置再通过 reload 的方式使其生效。这个操作如果在后端服务经常发生变更的情况下,操作起来就会显得有些繁琐了。现在利用 Lua + Redis 的方式将 upstream 中的配置放在 Redis 中,以实现动态配置的效果。

架构图

image

原理:

在求请求访问阶段处理(access_by_lua*)通过指定的规则(这个规则根据自己的需求去设计)从 Redis 中去获取相对应的后端服务地址去替换 Nginx 配置中的 proxy_pass 的地址。

流程:

  1. 在 Nginx 配置中创建后端服务地址的变量 $backend_server
    server {
        listen 80;
        server_name app1.example.com;

        location / {
            ...
            set $backend_server '';
        }
    }

同时在 Redis 中存入后端服务的地址。

set app1 10.10.10.10:8080
  1. 使用 ngx_redis2 模块来实现一个读取 Redis 的接口。
    # GET /get?key=some_key
    location = /get {
        internal;                        # 保护这个接口只运行内部调用
        set_unescape_uri $key $arg_key;  # this requires ngx_set_misc
        redis2_query get $key;
        redis2_pass foo.com:6379;        # redis_server and port
    }
  1. 在求请求访问阶段处理利用 ngx.location.capture 模块请求去上个阶段定义的 Redis 接口,并将结果替换 $backend_server
    location / {
        ...
        access_by_lua_block {
            local rds_key = "app1"
            # 从 redis 中获取 key 为 app1 对应的 server_ip
            local res = ngx.location.capture('/get', { args = {key = rds_key}})
            # 解析 redis 结果
            local parser = require("redis.parser")
            local server, typ = parser.parse_reply(res.body)
            if typ ~= parser.BULK_REPLY or not server then
                ngx.log(ngx.ERR, "bad redis response: ", res.body)
                ngx.exit(500)
            end

            ngx.var.backend_server = server
        }
    }
  1. Nginx 转发阶段将请求转发至后端服务。
    location / {
        ...
        access_by_lua_block {...};
        proxy_pass http://$backend_server;
    }

最后,推荐两个基于 OpenResty 的比较实用的两个开源项目:

参考

1+

ConcurrentHashMap 的 size 方法原理分析

前言

JAVA 语言提供了大量丰富的集合, 比如 List, Set, Map 等。其中 Map 是一个常用的一个数据结构,HashMap 是基于 Hash 算法实现 Map 接口而被广泛使用的集类。HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。但是 HashMap 并不是线程安全的, 在多线程场景下使用存在并发和死循环问题。HashMap 结构如图所示:

线程安全的解决方案

线程安全的 Map 的实现有 HashTable 和 ConcurrentHashMap 等。HashTable 对集合读写操作通过 Synchronized 同步保障线程安全, 整个集合只有一把锁, 对集合的操作只能串行执行,性能不高。ConcurrentHashMap 是另一个线程安全的 Map, 通常来说他的性能优于 HashTable。 ConcurrentHashMap 的实现在 JDK1.7 和 JDK 1.8 有所不同。

在 JDK1.7 版本中,ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 组成。简单理解就是ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。 通过 HashMap 查找的时候,根据 hash 值能够快速定位到数组的具体下标,如果发生 Hash 碰撞,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

如何计算 ConcurrentHashMap Size

由上面分析可知,ConcurrentHashMap 更适合作为线程安全的 Map。在实际的项目过程中,我们通常需要获取集合类的长度, 那么计算 ConcurrentHashMap 的元素大小就是一个有趣的问题,因为他是并发操作的,就是在你计算 size 的时候,它还在并发的插入数据,可能会导致你计算出来的 size 和你实际的 size 有差距。本文主要分析下 JDK1.8 的实现。 关于 JDK1.7 简单提一下。

在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。其源码实现:

public int size() {
  final Segment<K,V>[] segments = this.segments;
  int size;
  boolean overflow; // true if size overflows 32 bits
  long sum;         // sum of modCounts
  long last = 0L;   // previous sum
  int retries = -1; // first iteration isn't retry
  try {
    for (;;) {
      if (retries++ == RETRIES_BEFORE_LOCK) {
        for (int j = 0; j < segments.length; ++j)
          ensureSegment(j).lock(); // force creation
      }
      sum = 0L;
      size = 0;
      overflow = false;
      for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> seg = segmentAt(segments, j);
        if (seg != null) {
          sum += seg.modCount;
          int c = seg.count;
          if (c < 0 || (size += c) < 0)
            overflow = true;
        }
      }
      if (sum == last)
        break;
      last = sum;
    }
  } finally {
    if (retries > RETRIES_BEFORE_LOCK) {
      for (int j = 0; j < segments.length; ++j)
        segmentAt(segments, j).unlock();
    }
  }
  return overflow ? Integer.MAX_VALUE : size;
}

JDK1.8 实现相比 JDK 1.7 简单很多,只有一种方案,我们直接看 size() 代码:

    public int size() {
        long n = sumCount();
        return ((n < 0L) ? 0 :
                (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
    }

最大值是 Integer 类型的最大值,但是 Map 的 size 可能超过 MAX_VALUE, 所以还有一个方法 mappingCount(),JDK 的建议使用 mappingCount() 而不是 size()mappingCount() 的代码如下:

   public long mappingCount() {
        long n = sumCount();
        return (n < 0L) ? 0L : n; // ignore transient negative values
    }

以上可以看出,无论是 size() 还是 mappingCount(), 计算大小的核心方法都是 sumCount()sumCount() 的代码如下:

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

分析一下 sumCount() 代码。ConcurrentHashMap 提供了 baseCount、counterCells 两个辅助变量和一个 CounterCell 辅助内部类。sumCount() 就是迭代 counterCells 来统计 sum 的过程。 put 操作时,肯定会影响 size(),在 put() 方法最后会调用 addCount() 方法。

addCount() 代码如下:
- 如果 counterCells == null, 则对 baseCount 做 CAS 自增操作。

  • 如果并发导致 baseCount CAS 失败了使用 counterCells。

  • 如果counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操作,直到成功。

然后,CounterCell 这个类到底是什么?我们会发现它使用了 @sun.misc.Contended 标记的类,内部包含一个 volatile 变量。@sun.misc.Contended 这个注解标识着这个类防止需要防止 "伪共享"。那么,什么又是伪共享呢?

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

CounterCell 代码如下:

    @sun.misc.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

总结

  • JDK1.7 和 JDK1.8 对 size 的计算是不一样的。 1.7 中是先不加锁计算三次,如果三次结果不一样在加锁。
  • JDK1.8 size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。
  • JDK 8 推荐使用mappingCount 方法,因为这个方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值。
0

从 ThreadLocal 的实现看散列算法

引子

最近在看 JDK 的 ThreadLocal 源码时,发现了一段有意思的代码,如下所示。

    private final int threadLocalHashCode = nextHashCode();
    /**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

可以看到,其中定义了一个魔法值 HASH_INCREMENT = 0x61c88647, 对于实例变量 threadLocalHashCode, 每当创建 ThreadLocal 实例时这个值都会 getAndAdd(0x61c88647)

0x61c88647 转化成二进制即为 1640531527,它常用于在散列中增加哈希值。上面的代码注释中也解释到:HASH_INCREMENT 是为了让哈希码能均匀的分布在2的N次方的数组里。

那么 0x61c88647 是怎么起作用的呢?

什么是散列?

ThreadLocal 使用一个自定的的 Map —— ThreadLocalMap 来维护线程本地的值。首先我们先了解一下散列的概念。

散列(Hash)也称为哈希,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。

在实际使用中,不同的输入可能会散列成相同的输出,这时也就产生了冲突。通过上文提到的 HASH_INCREMENT 再借助一定的算法,就可以将哈希码能均匀的分布在 2 的 N 次方的数组里,保证了散列表的离散度,从而降低了冲突几率.

哈希表就是将数据根据散列函数 f(K) 映射到表中的特定位置进行存储。因此哈希表最大的特点就是可以根据 f(K) 函数得到其索引。

HashMap 就是使用哈希表来存储的,并且采用了链地址法解决冲突。

简单来说,哈希表的实现就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被 Hash 后,得到数组下标,把数据放在对应下标元素的链表上。

散列算法

先来说一下散列算法。散列算法的宗旨就是:构造冲突较低的散列地址,保证散列表中数据的离散度。常用的有以下几种散列算法:

除法散列法

散列长度 m, 对于一个小于 m 的数 p 取模,所得结果为散列地址。对 p 的选择很重要,一般取素数或 m

公式:f(k) = k % p (p<=m)

因为求模数其实是通过一个除法运算得到的,所以叫“除法散列法”

平方散列法(平方取中法)

先通过求关键字的平方值扩大相近数的差别,然后根据表长度取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀。

公式:f(k) = ((k * k) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * k) >> 28

斐波那契(Fibonacci)散列法

和平方散列法类似,此种方法使用斐波那契数列的值作为乘数而不是自己。

  1. 对于 16 位整数而言,这个乘数是 40503。
  2. 对于 32 位整数而言,这个乘数是 2654435769。
  3. 对于 64 位整数而言,这个乘数是 11400714819323198485。

具体数字是怎么计算得到的下文有介绍。

为什么使用斐波那契数列后散列更均匀,涉及到相关数学问题,此处不做更多解释。

公式:f(k) = ((k * 2654435769) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * 2654435769) >> 28

这时我们可以隐隐感觉到 0x61c88647 与斐波那契数列有些关系。

随机数法

选择一随机函数,取关键字的随机值作为散列地址,通常用于关键字长度不同的场合。

公式:f(k) = random(k)

链地址法(拉链法)

懂了散列算法,我们再来了解下拉链法。拉链法是为了 HashMap 中降低冲突,除了拉链法,还可以使用开放寻址法、再散列法、链地址法、公共溢出区等方法。这里就只简单介绍了拉链法。

把具有相同散列地址的关键字(同义词)值放在同一个单链表中,称为同义词链表。有 m 个散列地址就有 m 个链表,同时用指针数组 T[0..m-1] 存放各个链表的头指针,凡是散列地址为 i 的记录都以结点方式插入到以 T[i] 为指针的单链表中。T 中各分量的初值应为空指针。

对于HashMap:

HashMap

除法散列(k=16):

HashMap

斐波那契散列:

HashMap

可以看出用斐波那契散列法调整之后会比原来的除法散列离散度好很多。

ThreadLocalMap 的散列

认识完了散列,下面回归最初的问题:0x61c88647 是怎么起作用的呢?

先看一下 ThreadLocalMap 中的 set 方法

private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            ...
}

ThreadLocalMapEntry[] table 的大小必须是 2 的 N 次方(len = 2^N),那 len-1 的二进制表示就是低位连续的 N 个 1, 那 key.threadLocalHashCode & (len-1) 的值就是 threadLocalHashCode 的低 N 位。

然后我们通过代码测试一下,0x61c88647 是否能让哈希码能均匀的分布在 2 的 N 次方的数组里。

public class MagicHashCode {
    private static final int HASH_INCREMENT = 0x61c88647;

    public static void main(String[] args) {
        hashCode(16); //初始化16
        hashCode(32); //后续2倍扩容
        hashCode(64);
    }

    private static void hashCode(Integer length){
        int hashCode = 0;
        for(int i=0; i< length; i++){
            hashCode = i * HASH_INCREMENT+HASH_INCREMENT;//每次递增HASH_INCREMENT
            System.out.print(hashCode & (length-1));
            System.out.print(" ");
        }
        System.out.println();
    }
}

结果:

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 

产生的哈希码分布确实是很均匀,而且没有任何冲突。再看下面一段代码:

public class ThreadHashTest {
    public static void main(String[] args) {
        long l1 = (long) ((1L << 32) * (Math.sqrt(5) - 1)/2);
        System.out.println("as 32 bit unsigned: " + l1);
        int i1 = (int) l1;
        System.out.println("as 32 bit signed:   " + i1);
        System.out.println("MAGIC = " + 0x61c88647);
    }
}

结果:

as 32 bit unsigned: 2654435769
as 32 bit signed:   -1640531527
MAGIC = 1640531527

Process finished with exit code 0
16进制 10进制 2进制 补码
0x61c88647 1640531527 01100001110010001000011001000111 10011110001101110111100110111001

可以发现 0x61c88647 与一个神奇的数字产生了关系,它就是 (Math.sqrt(5) - 1)/2。也就是传说中的黄金比例 0.618(0.618 只是一个粗略值),即 0x61c88647 = 2^32 * 黄金分割比。同时也对应上了上文所提到的斐波那契散列法。

黄金比例与斐波那契数列

最后再简单介绍一下黄金比例,这个概念我们经常能听到,又称黄金分割点。

黄金分割具有严格的比例性、艺术性、和谐性,蕴藏着丰富的美学价值,而且呈现于不少动物和植物的外观。现今很多工业产品、电子产品、建筑物或艺术品均普遍应用黄金分割,展现其功能性与美观性。

对于斐波那契数列大家应该都很熟悉,也都写过递归实现的斐波那契数列。

斐波那契数列又称兔子数列:

  • 第一个月初有一对兔子
  • 第二个月之后(第三个月初),它们可以生育
  • 每月每对可生育的兔子会诞生下一对新兔子
  • 兔子永不死去

转化成数学公式即:

  • f(n) = f(n-1) + f(n-2) (n>1)
  • f(0) = 0
  • f(1) = 1

当n趋向于无穷大时,前一项与后一项的比值越来越逼近黄金比

最后总结下来看,ThreadLocal 中使用了斐波那契散列法,来保证哈希表的离散度。而它选用的乘数值即是2^32 * 黄金分割比

0

理清 Promise 的状态及使用

为什么会有 promise?

因为要解决回调函数的嵌套,也就是所谓的回调地狱,回调地狱长啥样大家应该有点数吧?

doSomethingA((res) =>{
  if (res.data) {
    doSomethingB(res.data, (resB) => {
      if (resB.data) {
        doSomethingC(resB.data)
      }
    })
  }
})

这样的代码不太美观,还得依靠缩进来分清层级。那解决这种回调地狱的方式有很多,最简单方式是定义好一堆具名函数直接调用。那进阶一点方式便是 promise。

promise 是什么?

通过 Promise 构造函数可以创建 promise 对象,promise 是一种通过链式调用的方式来解决回调函数传递的异步方案。
promise 对象具有状态,pending、fulfilled、rejected。状态改变之后就不会再变化。

promise 实例

通过 new 关键字初始化得到新的 promsie 对象。

const promise = new Promise(function(resolve, reject) {
    // ... do something
    if (/* 异步操作成功 */){
        resolve(value);
    } else {
        reject(error);
    }
})

promise 对象创建便会立即执行,但 promise 会保存状态。

基本用法

Promise 定义了一些原型方法来进行状态处理。最常用的有:
- Promise.prototype.then

const pm = new Promise(function(resolve) {
    setTimeout(function() {
        resolve(100)
    }, 2000)
})

pormise 对象通过内部 resolve 函数完成状态的 pending -> fulfilled。此后这个promise 将保留这个状态。可以通过 then 方法去处理状态。

pm.then((val) => {
    console.log(val); // 100
})

then 方法也可以用来处理 rejected 状态,then 方法可以接收 2 个函数参数。

new Promise(function( resolve, reject ) {
    setTimeout(() => {
        reject(new Error('err'))
    }, 2000)
}).then(null, (error) => {
    console.log(error); // error: err 
})

但用 then 的第二个参数去处理错误不是最好的选择。因为大多数情况下我们会用到链式调用。类似:promise.then().then().then()。所以在每个 then 方法去处理错误显得代码很多余,而且也真的没必要。

  • Promise.prototype.catch

catch 方法就是用来做错误统一处理。这样链式调用中我们只需要用 then 来处理 fulfilled 状态,在链的末尾加上 catch 来统一处理错误。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).then(result => {
    return result / 2;
}).catch(err => {
    console.log(err); // num is not defined
})

这里举得例子比较简单,在 then 方法里面没有去 return 新的 promise。可以看到第一个 then 发生了错误,最后的 catch 会捕捉这个错误。catch 实际上是.then(null, rejection)的别名。

  • Promise.prototype.finally()

这个 api 一般用来放在结尾,因为它不管前面的 promise 变为什么,它都会执行里面的回调函数。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).catch(err => {
    console.log(err); // num is not defined
}).finally(() => {
   console.log('complete'); // complete 
})

这便是一个完整的状态处理流程,then() 处理 fulfilled、catch() 处理 rejected、finally 两种都处理。但 finally 目前还只是提案阶段。

  • Promise.all

这个 api 在开发中也比较实用,如果 C 行为依赖于 A、B 行为,A、B 之间又没有依赖关系,而且只有当 A、B 的状态都为 fulfilled,或者有一个变为 rejected 时才会开始 C 行为。这时用 Promise.all() 就显得比较合适。

Promise.all([A, B]).then((resA, resB) => {
    ... // do something
}).catch(() => {
    ... // do something
})

如果不用 Promise.all 也能做到 A、B 完成之后再调用 C,一般会这么写:

promsiseA.then(() => {
  return promsiseB;
}).then(() => {
    ... // do something
}).catch(() => {
    // do something
})

这样就好比强行将 A、B 置为串行,所以对比 Promise.all() 显得效率不高。

  • Promise.race

用法和 Promise.all 一样,C 依赖于 A、B,但只要 A,B 的任一状态改变了,C 便开始执行。

Promise.race([A, B]).then((res) => {
    ... // do something
}).catch(() => {
    ... // do something
})

链式调用

之所以能在 promise 的使用中进行链式调用,是因为 then、catch、finally 方法都会返回新的 promise 对象。链式调用在 js 中也很常见。

[1, 2, 3].map(num => num*2).join('-')  // "2-4-6"

$('#box').find('.info').text('hello world') // jquery 链式

其实链式调用原理都一样,让方法始终返回新的对象就好了。
promise 中的 then、catch、finally 方法也是如此,总是返回新的 promise。这里都能理解,但有趣的是 promise 具有了状态。这让链式变得稍微复杂了些。

  • 状态变化

then、catch、finally 返回 promise 但这些 promise 的状态由谁决定呢?

答案是如果处理了状态那新得到的 promise 的状态由处理函数的具体内容决定,如果没处理状态那得到 promise 的状态直接继承前面 promise 的状态。

假设 promiseA 的状态为 resolved

const promiseB = promiseA.then(() => {
    return 1
}) // resolved 1

const promiseB = promiseA.then(() => {
    const num = 100 
}) // resolved undefined

const promiseB = promiseA.then(() => {
    throw 1
}) // rejected 1

const promiseB = promiseA.then(() => {
    return promiseC
}) // 状态由 promiseC 决定

假设 promiseA 的状态为 rejected

const promiseB = promiseA.then(() => {
    // do anything 
}) // 状态沿用 promiseA: rejected
  • 错误处理
const pm = new Promise((resolve, reject) => {
    reject('err') // 直接将 pm 对象的状态变为 rejected
})

var pm1 = pm.then(result => {
  console.log('hello, then 1');  // 不会执行
  return 100; // 不会执行
});

这里由于pm 的状态是 rejected, 所以 .then 继续将 rejected 状态向下传递,这样我们就能通过末尾的 catch 操作处理异常。

pm
.then(result => {
    return result * 2
}) // rejected
.then(result => {
    return result * 4
}) // rejected
.catch(err => {
    console.log(err)
})

配合 async await 使用

如果能比较熟练的使用 promsie 再过渡到 async、await 也没什么问题。

  • 怎么使用
async function f() {
    await 100;
}

f().then((num) => {
    console.log(num); // 100
})

简单的例子似乎看不出 async await 到底是干什么的,又有什么用。但可以知道 async 返回的是 promise, await 相当于改变状态的操作。

  • 对比 promise 优势在哪?

aysnc 、await 最大的好处是简化 promise 的 .then 操作,让代码更加同步。

pmA.then((dataA) => {
    ...
    return dataB
}).then((dataB) => {
    ...
    return dataC
}).then((dataC) => {
    ...
    console.log('end')
})

换做是 async、await 就可以写成

async f() {
    const dataA = await pmA;
    const dataB = await fb(dataA);
    const dataC = await fc(dataB);
    ...
}

所以不管 await 后面去接什么样的异步操作,整体上还是会保持同步的方式,这样看起来结构清晰。

总结

从 promsie 到 async、await,JS 提供的这些新的特性,都让 JS 的使用体验变得越来越灵活方便。从 ES6 的出现到现在的 ES7、ES8,也都能感受到 JS 在拥有更大的能力去做好更多的事情。关于 promise 的使用还是要多用多体会,不然用 promise 写出回调地狱一样的代码也不是没有可能。最后还是贴出经典的 promise 问答题!据说能回答正确这些写法区别的 promise 掌握的肯定没问题。

// 1
doSomething().then(function () {
  return doSomethingElse()
}).then(/* ... */)

// 2
doSomething().then(function () {
  doSomethingElse()
}).then(/* ... */)

// 3
doSomething().then(doSomethingElse()).then(/* ... */)

// 4
doSomething().then(doSomethingElse).then(/* ... */)
0

逻辑思维:理清思路,表达自己的技巧

为什么要讲逻辑思维

逻辑思维一直是职场的重要技能之一。当遇到某个问题时,你可以运用逻辑思维去梳理问题、分析问题,从而找到问题的本质与解决方案;当需要向他人陈述结论时,你可以运用逻辑思维去梳理即将要表达的内容,划分清晰沟通中的结论、背景、论点,从而将你的结论条理清晰的传达给对方。

几乎可以这么说,一个人的逻辑思维越强,他解决问题的能力就越强,沟通表达就越清晰。

接下来,文章将分别从逻辑思维如何运用在思考过程中、如何运用在沟通表达中去展开介绍。这两种情况的核心区别在于是已有结论对外陈述,还是未有结论对内分析挖掘。因为现在大多数逻辑思维的讲解都是更注重教给我们如何对外陈述,所以在这里我们也从沟通表达的场景开始介绍。

表达框架:金字塔图组织语言,快速表达

在问题被梳理清晰的前提下,运用逻辑思维去沟通表达,一般是遵循 论点/背景--->结论--->理由--->行动 的整体框架。接下来逐个介绍各个环节的关键点:

1. 论点/背景

论点,一般指接下来谈话的中心内容。论点阐述时经常包含背景介绍,它们往往不可分割。

  • 阐述论点,应尽量从对方了解的信息开始阐述;
  • 我们可以使用 5W2H 法来细化论点:When、Where、Who、What、Why、How、How much,通过 5W2H 方法将来各事件元素(时间、地点、人物、事件、原因、如何进展、进展如何等)梳理清楚。

举一个简单例子,你想找 Boss 聊聊员工加班的事情,你不能说 “Boss,关于加班我想找你聊一聊“ 。这样显然没有将事件元素陈述清晰,Boss 会无法判断你谈话的内容,他只能去猜测你将要表达内容是关于加班的哪个方面。正确的论点阐述应该是 “最近,年轻员工加班时间增加过多了,我们是不是应该做一些调整?”。这样论点就描述清楚了。

2. 结论

结论,指在接下来谈话中你最想陈述的内容,即便删除所有其他内容你也想保留的内容。结论的陈述需要注意以下三点:

  • 结论是解决问题,而不是阐述事实;
  • 结论和论点不能偏离,也就是说结论与论点间的联系要科学易被听众接受:尽可能做到是/否问题回答是/否;原因问题回答原因;怎么做问题回答怎么做。比如,Boss 问你销售为什么不输入商品的预估数据?你却回答,输入的方式应该设计的简单一些,就是有问题的。Boss 问的是原因,你回答的解决方案,跳跃太大,造成 Boss 难以理解。
  • 遵循金字塔原则,大多数时候结论先行,否则倾听者容易产生疲倦。当希望给倾听者以准备时间或者倾听者自行得出结论的时候,我们才将结论放后面。

3. 理由

理由的陈述关键点在于做到符合 MECE 分析法,筛选“合格”理由作为结论的支撑,避免将一个相似的理由分成3个理由来说。那么如何做到符合MECE原则?如何筛选“正确合格”理由?接下来我们详细介绍下:

MECE分析法(Mutually Exclusive Collectively Exhaustive),理由间相互独立,完全穷尽。

通俗地说,就是在没有重复没有遗漏的状态下进行思考或表达。当然,真实的情况往往是我们难以做到真正的穷尽,所以只是力求不遗漏,覆盖 8~9成 即可。

MECE 的分析方法,主要有以下三种:

  • 日常分类模式:投入 vs 产出、生产 vs 消费、人力 vs 物力 vs 财力、过去 vs 现在 vs 未来...
  • 通过公式推导思维解决遗漏:销售额 = 用户数 * 转化率 * 客单价,因子一目了然;
  • 套用经典商业/业务模型:
    • 战略模型:3C-Customer/Competior/Company、SWOT-Strength/Weakness/Opportunity/Threat
    • 营销模型:4Ps-Product/Price/Place/Promotion、4Cs-Customer/Cost/Communication/Convenient
    • 生产管理模型:QCD-Quantity/Cost/Delivery

常见分析方法为我们提供了 MECE 分析的切入点,那么我们要如何选择 MECE 的理由?一个是参考下面将介绍的筛选合适理由的原则,另一个是注意从反向思考去检查MECE分析所得理由的缺点。

选择“正确合格”的理由作为支撑,遵循以下几个原则:

  • 从对方角度设想,选择能让对方信服的理由,所以面对不同的听众,要有不同的侧重;
  • 对于认同感不强的理由,采用“理由的理由”去支持它。“理由的理由”可以是:数据证明、一般常识/规律、事例的累积、已决断的策略、公司规定/制度等;
  • 有大量事实,但没有结论的场景,我们可以先陈列大量理由,从理由中推导出结论;
  • 已经有结论,但理由支撑没有思路的时候,我们同样可以先陈列大量理由,再整理分类理由。

此外,在理由的陈述中还需注意理由与结论的关联。因为理由是结论的支撑,理由推导得到结论的逻辑被听众理解是你的结论被理解的重要前提。下面介绍两种主要的理由推导结论的逻辑:

  • 归纳并举型:列举理由,通过理由的共通点推测结论,这种推理方式的缺点是显得主观,所以要格外注意考虑例外的情况。
  • 演绎推理型:三段式,"大前提 + 小前提" -> 结论。如果大前提、小前提是错误的,那么演绎推理的结论也是无意义的,所以要注意确保大、小前提是正确的。

4. 行动

有结论还应该有行动,行动应该是明确自身和他人的具体任务,并加以Deadline限制。即人、事、时间都是划分清楚的,在此同样可以采用5W2H来确认行动计划是否清晰。

思考框架:梳理问题,逻辑推导解决问题

在还没有将梳理清晰问题时,我们运用逻辑思维去梳理问题,它一般遵循 Problem-->Why-->How 的框架,接下来分开阐述各环节(因为思考过程中运用的逻辑思维方法与沟通表达所运用的方法类似,所以这里不会再陈述的非常详细):

1. 明确问题

明确问题主要有两个方法:

  • 设定想要的状态,即设定目标或设定参照物。当问题很明确,那么设定“理所当然”的目标即可,如:公司连续两年赤字,那么设定目标为公司盈利即可;当问题不明确,那么需要设定理想目标,如:公司连续10年保持全国第四,那么设定目标可考虑5年内成为全国第一。
  • 把问题具体化到能够思考原因的大小,列举具体事例,从事例中归纳问题。如,年轻员工没有朝气,那么我们可以通过列举具体事例:打招呼有气无力、写资料错误率高、辞职率高等,通过这样的细化,我们就知道具体的问题是什么了。

2. 寻找原因

寻找原因的关键在于,不停地询问为什么,逐步深入挖掘。如何有逻辑地深入挖掘原因?这里仍旧建议 MECE 分析法,与表达框架中介绍的理由陈述相似,毕竟在表达中陈述的理由就是在思考中挖掘得到的原因。

挖掘原因的另外一个重要命题是,我们需要深挖到什么程度?各种行业书籍给出的答案是,追问5次,直到能找到具体的解决方案。对于没有可能性的部分中途就可以停止深挖,不必要刻意追问5次,只对有真正可能性的原因进行探究。

3. 检讨解决方案

深挖直到能想象具体怎么做为止,在探讨解决方案的过程中,先不要过多考虑方案的可行性。这里,对日常生活中常见的两类人给出总结与建议:

  • 对于不思考就行动的人,建议多思考在行动,采用根本原因分析、从零开始思考等方法;
  • 对于思考过多而不行动的人,建议一边行动一边思考,采用假设思考法。

总结

以上,为关于逻辑思维整理思路,表达自身技巧的总结与介绍。但仍有一点是需被注意的:逻辑思维虽然有用,但也并非所有的场合都可以用逻辑思维去解决问题。比如,女朋友大发雷霆和你大吵特吵,你如果试图用逻辑思维去解决,那估计是要凉凉了(手动微笑脸)。。。大多数人建议在需要寻找问题总结结论或者进行结论表达的时候才使用逻辑思维!

0

Actor 模型及 Akka 简介

前提

随着业务的发展,现代分布式系统对于垂直扩展、水平扩展、容错性的要求越来越高。常见的一些编程模式已经不能很好的解决这些问题。

解决并发问题核心是并发线程中的数据通讯问题,一般有两种策略:

  1. 共享数据
  2. 消息传递

共享数据

基于 JVM 内存模型的设计,需要通过加锁等同步机制保证共享数据的一致性。但其实使用锁对于高并发系统并不是一个很好的解决方案:

  1. 运行低效,代价昂贵,非常限制并发。
  2. 调用线程会被阻塞,以致于它不能去做其他有意义的任务。
  3. 很难实现,比较容易出现死锁等各种问题。

消息传递

与共享数据方式相比,消息传递机制的最大优点就是不会产生竞争。实现消息传递的两种常见形式:

  1. 基于 Channel 的消息传递
  2. 基于 Actor 模型的消息传递

常见的 RabbitMQ 等消息队列,都可以认为是基于 Channel 的消息传递模式,而本文主要会介绍 Actor 模型相关内容。

Actor 模型

Actor 的基础就是消息传递,一个 Actor 可以认为是一个基本的计算单元,它能接收消息并基于其执行运算,它也可以发送消息给其他 Actor。Actors 之间相互隔离,它们之间并不共享内存。

Actor 本身封装了状态和行为,在进行并发编程时,Actor 只需要关注消息和它本身。而消息是一个不可变对象,所以 Actor 不需要去关注锁和内存原子性等一系列多线程常见的问题。

所以 Actor 是由状态(State)、行为(Behavior)和邮箱(MailBox,可以认为是一个消息队列)三部分组成:

  1. 状态:Actor 中的状态指 Actor 对象的变量信息,状态由 Actor 自己管理,避免了并发环境下的锁和内存原子性等问题。
  2. 行为:Actor 中的计算逻辑,通过 Actor 接收到的消息来改变 Actor 的状态。
  3. 邮箱:邮箱是 Actor 和 Actor 之间的通信桥梁,邮箱内部通过 FIFO(先入先出)消息队列来存储发送方 Actor 消息,接受方 Actor 从邮箱队列中获取消息。

模型概念

Actor

可以看出按消息的流向,可以将 Actor 分为发送方和接收方,一个 Actor 既可以是发送方也可以是接受方。

另外我们可以了解到 Actor 是串行处理消息的,另外 Actor 中消息不可变。

Actor 模型特点

  1. 对并发模型进行了更高的抽象。
  2. 使用了异步、非阻塞、高性能的事件驱动编程模型。
  3. 轻量级事件处理(1 GB 内存可容纳百万级别 Actor)。

简单了解了 Actor 模型,我们来看一个基于其实现的框架。

Akka Actor

Akka 是一个构建在 JVM 上,基于 Actor 模型的的并发框架,为构建伸缩性强,有弹性的响应式并发应用提高更好的平台。

ActorSystem

ActorSystem 可以看做是 Actor 的系统工厂或管理者。主要有以下功能:

  • 管理调度服务
  • 配置相关参数
  • 日志功能

Actor 层次结构

Akka 官网展示的 Actor 层次结构示意图

Akka 有在系统中初始化三个 Actor:

  1. / 所谓的根监护人。这是系统中所有 Actor 的父亲,当系统被终止时,它也是最后一个被停止的。
  2. /user 这是所有用户创建的 Actor 的父亲。不要被 user 这个名字所迷惑,他与最终用户没有关系,也和用户处理无关。你使用 Akka 库所创建的所有 Actor 的路径都将以/user/开头
  3. /system系统监护人

我们可以使用 system.actorOf() 来创建一个在 /user 路径下的 Actor。尽管它只是在用户创建的层次的最高级 Actor,但是我们把它称作顶级 Actor。

Akka 里的 Actor 总是属于其父母。可以通过调用 context.actorOf() 创建一个 Actor。这种方式向现有的 Actor 树内加入了一个新的 Actor,这个 Actor 的创建者就成为了这个 Actor 的父 Actor。

Actor 生命周期

Akka Actor 生命周期示意图
生命周期

Actor 在被创建后存在,并且在用户请求关闭时消失。当 Actor 被关闭后,其所有的子Actor 都将被依次地关闭.

AKKA 为 Actor 生命周期的每个阶段都提供了钩子(hook)方法,我们可以通过重写这些方法来管理 Actor 的生命周期。

Actor 被定义为 trait,可以认为就是一个接口,其中一个典型的方法对是 preStart()postStop(),顾名思义,两个方法分别在启动和停止时被调用。

ActorRef

在使用 system.actorOf() 创建 Actor 时,其实返回的是一个 ActorRef 对象。

ActorRef 可以看做是 Actor 的引用,是一个 Actor 的不可变,可序列化的句柄(handle),它可能不在本地或同一个 ActorSystem 中,它是实现网络空间位置透明性的关键设计。

ActorRef 最重要功能是支持向它所代表的 Actor 发送消息:

ref ! message

Dispatcher 和 MailBox

ActorRef 将消息处理能力委派给 Dispatcher,实际上,当我们创建 ActorSystem 和 ActorRef 时,Dispatcher 和 MailBox 就已经被创建了。

Dispatcher 从 ActorRef 中获取消息并传递给 MailBox,Dispatcher 封装了一个线程池,之后在线程池中执行 MailBox。

因为 MailBox 实现了 Runnable 接口,所以可以通过 Java 的线程池调用。

流程

通过了解上面的一些概念,我们可以 Akka Actor 的处理流程归纳如下:

  1. 创建 ActorSystem
  2. 通过 ActorSystem 创建 ActorRef,并将消息发送到 ActorRef
  3. ActorRef 将消息传递到 Dispatcher中
  4. Dispatcher 依次的将消息发送到 Actor 邮箱中
  5. Dispatcher 将邮箱推送至一个线程中
  6. 邮箱取出一条消息并委派给 Actor 的 receive 方法

简略流程图如下:
Akka Actor 流程图

EventBus

接下来我们看一个 Actor 的应用:EventBus。在异步处理场景下,运用最为广泛的消息处理模式即是 Pub-Sub 模式。基于 Pub-Sub 模式,还可以根据不同的场景衍生出特殊的模式,例如针对一个 Publisher 和多个 Subscriber,演化为 Broadcast 模式和 Message Router 模式。

EventBus 则通过引入总线来彻底解除 Publisher 与 Subscriber 之间的耦合,类似设计模式中的 Mediator 模式。总线就是 Mediator,用以协调 Publisher 与 Subscriber 之间的关系。对于 Publisher 而言,只需要把消息发布给 EventBus 即可;对于 Subscriber 而言,只需要在 EventBus 注册需要处理的事件并实现处理流程即可。

在没有使用 EventBus 的时候,Publisher 必须显式的调用 Subscriber 的方法。例如订单支付成功后,必须在订单处理模块调用积分模块处理积分,调用服务号模块进行通知。而且这样的显示调用会越来越多,每次都要去修改订单模块加一个调用。这样订单处理模块和那些模块就都紧密耦合在一起了。我们看看 EventBus 怎么解决这个问题。

EventBus 定义

要使用 Akka EventBus, 首先要实现一个 EventBus 接口。

trait EventBus {
  type Event
  type Classifier
  type Subscriber

  //#event-bus-api
  def subscribe(subscriber: Subscriber, to: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber, from: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber): Unit

  def publish(event: Event): Unit
  //#event-bus-api
}

如上所示:

  1. Event 就是需要发布到总线上的事件
  2. Classifier 分类器用于对订阅者进行绑定和筛选
  3. Subscriber 注册到总线上的订阅者。

所幸的是,我们不需要要从头实现 EventBus 接口,Akka 提供了一个 LookupClassification 帮助我们实现 Pub-Sub 模式,我们要做的最主要就是实现 publish 方法。

class XrEventBus extends EventBus with LookupClassification {
  type Event = XrEvent
  type Classifier = XrEventType
  type Subscriber = ActorRef

  override protected def publish(event: Event, subscriber: Subscriber): Unit = {
    subscriber ! event
  }
  // 其他方法...
}

可以看到:

  1. Event 的类型是我们自己定义的 XrEvent。
  2. 分类起是基于 XrEventType,也就是事件类型的。我们系统中定义了很多时间类型,例如 XrEventType.ORDER_PAID 是订单支付事件,XrEventType.DOC_REGISTERED 是用户注册事件。
  3. Subscriber 其实就是一个 Actor。
  4. Publisher 只是简单的将 Event 作为一个消息发布给所有 Subscriber。

事件发布和订阅

Subscriber 这边则需要实现对事件的处理。

class ScoreEventHandler extends Actor with Logging {
  override def receive = {
    // 订单支付成功
    case XrEvent(XrEventType.ORDER_PAID, order: OrderResponse) =>
      // 处理订单支付成功事件

    // 处理其他事件
  }
}

然后我们通过调用 EventBus.subscribe 进行事件订阅。

  val eventBus = new XrEventBus

  // 积分事件处理模块
  val scoreEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[ScoreEventHandler], name = "scoreEventHandler"))
  eventBus.subscribe(scoreEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

  // 微信服务号事件处理模块
  val weixinXrEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[WeixinXrMessageActor], name = "weixinXrEventHandler"))
  eventBus.subscribe(weixinXrEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

最后,我们的订单处理模块只需要调用 EventBus.publish 发布订单支付事件就好了。至于那些需要处理该事件的模块,自然会去订阅这个事件。上面 XrEventBus 的实现里可以看到,发布其实就是用 Actor 的消息发送机制,将消息发布给了所有的 Subscriber。

XrEventBus.publish(XrEventType.ORDER_PAID, new OrderResponse(order, product))

至此,我们的订单处理模块和积分处理模块、微信服务号模块就安全解耦了,很漂亮不是吗?

总结

当然 Actor 还有其他很多应用场景。例如并发流式处理,甚至我们系统中的定时任务,也是通过 Actor 实现的。

总之,Actor 为我们提供了更高层次的并发抽象模型,让我们不必关心底层的实现,只需着重实现业务逻辑。对于一些并发的场景,是很值得尝试的一种方案。

0

中文房间之争-浅论到底什么是智能

中文房间问题

1980 年,美国哲学家 John Searle 提出了一个思维实验:中文房间(Chinese Room Argument),它是这样的:

假想在一个密闭的房间中,有一个完全不懂任何中文的美国人。他手上有这两样东西:1)所有的中文字符集(数据);2)如何处理这些中文字符的规则书(程序)。现在,门外有人在纸条上用中文写上一个问题,递进房间当中(输入)。房间里这个完全不懂中文的美国人,按照手头的规则书,把问题的正确回答拼凑出来后递出去(输出)。按照图灵测试的标准,房间里的人被认为具有理解中文的智能,然而,实际上他对中文一无所知。

要注意的是,这本规则书上仅仅只是基于语法(Syntax)的,而不涉及任何语义(Semantics)。也就是说,你可以理解成,这本规则书上,罗列了一切可能的中文问题,并给出了相应的中文回答,房间中的人,只需要按照对应的回答,拼凑出相应的中文字符递出去,但这个过程中,他对问题和答案是什么意思,一无所知。

这个思维实验提出后,长达三十余年的时间里,各方都提供了各种回复与反驳。Searle 最初只是希望借助于这个思维实验,来指出图灵测试在验证智能方面并不是完备的,即我们该如何辨别智能。不过,随着论战的升级,它实际上指向着一个历史更悠久的路线之争:智能是可计算的吗?

其实,所谓路线之争,本来就没有绝对的对错,关键在于你持有什么立场和信念。我们就来看看,中文房间面临的各种诘难和相应的回复。

系统论回复(System Reply)

最常见的回复,也是 Searle 在其论文中首先驳斥的,就是系统论。在这种观点下,房间中的那个人的确不理解中文,然而,如果你把房间当作一个整体系统,则可以说这个系统是理解中文的。这一派观点的人做出了一个类比:想像一下人类大脑中的神经元,单独拎出来一个神经元,它显然不理解中文,然而,人作为一个整体,是可以理解语言的。

Searle 对此的回复是:让我们更极端一点,现在房间中的这个美国人,背下来了所有的中文字符和整本规则书。现在,他离开房间出门和真正的中国人交谈。对面走来了一个人问他「你给王菊投票了吗?」,他根据大脑里内化的规则书回答「没有」。尽管他对「王菊」和「投票」是什么,一无所知。Searle 在 1984 年进一步指出,系统论一方的错误在于,误把这个问题当作整体论和还原论之争的一个分支,然而实际上,他驳斥的是单凭语法本身并不足以构建语义。

系统论的一个变体是「虚拟心智论(Virtual Mind Reply)」。和系统论一样,它同样不认为房间中的人具有智能,但也不认为这个系统整体具有智能。事实上,它完全不关心智能是通过谁或什么(Agent)展现了出来,而只在意在整个过程当中,是否有智能被创造了出来。许多人在这一观点上进行了进一步的演化,继而讨论到:中文房间之中,真正的智能体现在那本规则书上。正反双方对这本规则书是否是可穷尽的,以及意识和本体论的行为主义之间的关系展开了辩驳,不过讨论的内容过于学究,这里就不展开了。

英国物理学家和数学家 Roger Penrose 在 2002 年回应过这一系列相关的观点,把「中文房间」问题扩大化到「中文体育馆(Chinese Gym)」变体。现在,让全中国所有的人,每个人模拟一个大脑中的神经元来处理信息。难道中国这个国家,或者在这个过程当中,存在着任何形式的智能吗?好吧,虽然这个变体的思维实验,核心目的是驳斥智能不需要依附主体的这一看法,但如果你想到了《三体》一书中,三体星人全民模拟一台计算机的行径,很多人的确可能会觉得这个过程当中存在着智能。

机器人回复(Robot Reply)

机器人回复赞同 Searl 的观点,即中文房间中无论是那个人,还是房间整体,都不存在智能可言。但原因在于,仅仅语言是不足以构成智能的,按照这一方的观点,如果我们造出了一个机器人,它不仅仅可以根据语法处理中文,还可以识别图像、声音、运动,并给出相应的肢体、表情等反馈,这个机器人就具备了智能。按这样的标准,西部世界中的接待者(Hosts)显然是具备智能的。

Searl 和一众人并不认为这样的机器人真正具备了智能,反驳的主要切入点,是从意向性(Intentionality)和因果关系(Causal Connection)两点入手的。通俗一点来说,这样的智能只是一系列反应的组合,硅谷最新一季当中的 Fiona 就是典型代表,她的语言、面部表情、肢体动作只不过是按照程序例行公事,却没有自发的因果性。

v2-2b8d6ad7f7bcb3317a41f8ff13d2f730_r

模拟大脑回复(Brain Simulator Reply)

模拟大脑回复跳开了中文房间这个问题,而是直接向 Searl 质问:如果我们用非生物的方式,模拟出了一个和大脑神经元一模一样工作的装置,它拥有智能吗?想像一下西部世界中接待员脑中的白色装置,就可以认为是这个,显然西部世界里的机器人,比上面硅谷中的那个 Fiona 更像拥有智能的人类。

Searl 的回复依然是不能。模拟大脑本身并不意味着智能,他在这里给出了一个很类似于上文「中文体育馆(Chinese Gym)」的思维实验变体,目前来看,Searl 就是既不认同智能直接等同于大脑的电化学信号。不过,在这个方向上讨论下去,Searl 隐晦地表达出了他对于什么是智能的深层看法。

模拟大脑回复一方进一步质问:我们现在不模拟大脑了,而是逐步替换大脑。假设有一个人,我们把他大脑中的突触,一个接一个,替换成特制的晶体管,这些晶体管能十分逼真地模拟大脑突触。那么,当全部替换完成后,这个人还拥有智能吗?

Searl 一方的回答是:在我不知道他被替换前,我可能会认为他拥有智能。但一旦当我知道它是一台图灵机后,我便不再认为他具备智能。这个看似已经近乎于狡辩的回复,其实暗藏着 Searl 一方对智能更深层的看法:凡是可以被计算的,都不再是智能。其实,直到今天,也没有人可以给智能下一个令所有人满意的定义,而中文房间更深层次的分歧正在于:智能是一个可计算的问题吗?

他人心智回复(Other Minds Reply)和直觉回复(Intuition Reply)

还有许多人绕开了关于智能的定义,而尝试从如何辨别智能的角度出发。在他们看来,我们之所以认为中文房间中的人,或机器人不具备智能,纯粹是出于我们固有的偏见。按照同样的标准,我们一样也无法判定身边的人是否真的具备智能,或者仅仅只是表现出智能。这一系列的回复,都强调图灵测试的重要性,正在于摒弃我们固有的偏见,而单纯从结果出发来辨别智能是否存在。

美国哲学家 Daniel Dennett 提出了哲学僵尸(Philosophical Zombie),也可以作为一个补充回应。用一个非常简化但并不准确的版本来说,这里的僵尸不是美剧 Walking Dead 中的僵尸,而是指那些只表现出智能,却并不真的具备智能的人类,假设他们存在的话。Daniel Dennett 认为,首先智能显然是一个进化出来的产物。其次,中文房间提出的观点如果成立,即表现出智能的主体并不一定真正具备智能。那么,两个同样在行为上表现出智能的主体,前者不具备真正的智能(只是装得像),而后者具备真正的智能,通过他的一系列的论证(这里就简化不展开,不考虑他论证的正确性),最后得到的结论是:前者比后者更有生存优势,故而地球上大多数人,其实都是只表现出智能,却不真正具备智能的僵尸。

能看到这,说明你肯定不是一具僵尸了。

0

从零搭建一个基于Istio的服务网格

上篇文章从微服务1.0时代的三大痛点(技术门槛高,多语言支持不足和代码侵入性强)说起,由此引出服务网格的起源和演化历史。但古语有云纸上得来终觉浅,绝知此事要躬行,不亲自撸一遍命令,怎敢跟人提服务网格?本篇我将教大家如何在本地从零搭建一个基于Istio的服务网格,从而对服务网格有一个更直观的认识。

1 通关密码:上上下下左左右右ABAB

  • 原料:Mac一台,VPN账号一枚
  • 做法:依序安装和运行KubernetesMinikube,Istio

mario

2 穿墙大法:Shadowsocks

无论是Kubernetes、Minikube还是Istio,官方提供的安装文档都非常详尽,只要英文过关,依葫芦画瓢基本上都能跑通。但如果你在国内,还得加一个必要条件,学会如何突破网络审查,俗称fan墙。

Mac下的穿墙软件我首推Shadowsocks,同时支持Socks5代理和HTTP代理,最新版本可以从GitHub下载。

3 小Boss: kubectl!

3.1 安装

Kubernetes是Istio首推的运行平台,因此作为第一步,我们首先来安装kubectl,Kubernetes的命令行工具,用来控制Kubernetes集群。根据官方文档,Mac下安装kubectl只需要一行命令,brew install kubectl,这简单、极致的用户体验让你感动的想哭。But wait...

3.2 穿墙1: Brew

你敲完命令,踌躇满志的按下回车之后,可能会发现,屏幕迟迟没有输出,10秒,30秒,1分钟,3分钟,10分钟。。。恭喜你,你被墙了!

Brew默认的镜像源是GitHub,而GitHub时不时会被墙,即使不被墙访问速度有时也慢的令人发指,导致Brew命令也常常超时甚至失败。解决办法要么换源,要么给GitHub配上Socks5代理。对码农而言,我更推荐后一种,方法如下:

1) 打开~/.gitconfig文件,如果不存在则新建

2) 在文件末尾添加如下配置并保存:

[http "https://github.com"]
  proxy = socks5://127.0.0.1:1086
[https "https://github.com"]
  proxy = socks5://127.0.0.1:1086

注:socks5://127.0.0.1:1086是Shadowsocks默认开启的Socks5代理地址。

配上Socks5代理之后,一般就可以妥妥的运行Brew命令了。

3.3 验证

安装好kubectl之后,直接运行kubectl version查看版本号。完整的kubectl命令列表在这里可以找到。如果想进一步学习常见的kubectl命令,可以访问Kubernetes Playground完成在线练习。

4 中Boss: Minikube!

4.1 安装

安装完kubectl,接下来就是在本地搭建Kubernetes集群,Minikube是最简单的一种搭建方式,它通过VM模拟了一个单节点的Kubernetes集群。官方文档给出了Mac下的安装命令。

curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-amd64 && \
chmod +x minikube && \
sudo mv minikube /usr/local/bin/

Minikube默认使用的VM Driver是VirutalBox,因此启动Minikube之前,还要安装VirtualBox。

4.2 启动

安装好Minikube和VirutalBox之后,可运行如下命令第一次启动Minikube:

minikube start --docker-env HTTP_PROXY=http://<本机IP>:1087 --docker-env HTTPS_PROXY=http://<本机IP>:1087

注:官方文档给出的启动命令带有--vm-driver=xhyve,而事实上最新版本的Minikube已经废弃了xhyve driver,应去除。

4.3 穿墙2: Docker

你可能已经注意到,上面的启动命令中带了两个--docker-env参数,都指向了Shadowsocks开启的HTTP代理,为啥呢?还是因为墙。Minikube默认使用Docker作为容器运行时,并在VM中内置了一个Docker守护进程,使用的镜像源是DockerHub。如果你经常使用Docker,那你一定知道在国内使用Docker一般都要修改镜像源(比如阿里云的容器镜像服务)或者使用代理,否则拉取速度也是慢的令人发指。由于Minikube使用的是内置的Docker守护进程,使用代理更为方便,但要注意,开启Shadowsocks HTTP代理时,需要修改代理的侦听地址为本机IP,而不是默认的127.0.0.1,否则在VM中的Docker守护进程是无法访问到这个代理的。

注:--docker-env参数只有在第一次启动Minikube时需要,之后启动直接运行minikube start即可。如果需要修改代理地址,可编辑~/.minikube/machines/minikube/config.json文件。

4.4 验证

安装完Minikube之后,就可以试着创建第一个Kubernetes服务了,具体步骤参考官方文档

5 大Boss: Istio!

5.1 安装

拿到了kubectl和Minikube两大神器,搭建Istio可以说是水到渠成了。基本步骤如下,

1) 启动Minikube

minikube start \
  --extra-config=controller-manager.ClusterSigningCertFile="/var/lib/localkube/certs/ca.crt" \
  --extra-config=controller-manager.ClusterSigningKeyFile="/var/lib/localkube/certs/ca.key" \
  --extra-config=apiserver.Admission.PluginNames=NamespaceLifecycle,LimitRanger,ServiceAccount,PersistentVolumeLabel,DefaultStorageClass,DefaultTolerationSeconds,MutatingAdmissionWebhook,ValidatingAdmissionWebhook,ResourceQuota \
  --kubernetes-version=v1.9.0

2) 下载并解压Istio安装包

curl -L https://git.io/getLatestIstio | sh -

3) 进入安装目录(假设为istio-0.7),将bin/目录添加到PATH环境变量

cd istio-0.7
export PATH=$PWD/bin:$PATH

4) 部署Istio的核心组件(包括外部流量网关Ingress, 管理Envoy实例生命周期的Pilot以及执行访问控制和使用策略的Mixer)到Kubernetes

kubectl apply -f install/kubernetes/istio.yaml

注:如果你需要启用Istio的Mutual TLS Authentication(服务身份验证)功能,可以改为运行kubectl apply -f install/kubernetes/istio-auth.yaml

至此,一个基于Istio的服务网格就算安装完成了。One more thing,还记得上篇文章提到的服务网格所独有的边车模式吗?为了将一个具体的服务接入Istio,需要为每一个服务实例创建一个配套的边车进程。根据官方文档,Istio提供手动和自动两种方式来创建边车进程,前者发生于部署阶段,而后者发生于Pod创建阶段,推荐使用后者,具体步骤参考官方文档,限于篇幅,这里就不再赘述。

5.2 验证

安装完Istio之后,可运行kubectl get pods -n istio-system查看所有Istio相关的Pods,确保这些Pods都处于Running状态。然后,你就可以开始Istio的探索之旅了,建议从官方提供的Bookinginfo示例应用起步,这里就不再展开。

NAME                                      READY     STATUS    RESTARTS   AGE
istio-ca-59f6dcb7d9-5mll5                 1/1       Running   18         42d
istio-ingress-779649ff5b-x2qmn            1/1       Running   26         42d
istio-mixer-7f4fd7dff-6l5g5               3/3       Running   54         42d
istio-pilot-5f5f76ddc8-6867m              2/2       Running   36         42d
istio-sidecar-injector-7947777478-gzcfz   1/1       Running   9          41d

6 参考

0

容器管理利器:Web Terminal 简介

一. 前言

在微服务大行其道的今天,容器恰巧又是微服务的主要载体,所以我们操作的对象也由最开始的「物理机」到「虚拟机」再到今天的「容器」。由于这些载体的变更,我们的使用方式也需要随之发生一些改变。比如一个最常用的登入操作,「虚拟机」下我们可能通过 ssh 的方式 ,但如果是容器呢?ssh 的方式就需要在每个容器中都运行一个 sshd 进程,这种做法可行但略显繁琐,也不太符合一个容器只运行一个进程的思想。
那么有没有一个即方便快捷又安全的登入方式呢?

有,通过 Web Terminal 的方式,通过 Web 的方式即可以避免对客户端的依赖又能够实现用户权限控制。目前,有很多开源的 Web Terminal 的项目,基本上都是通过 ssh 代理的方式调用并返回一个 shell 的虚拟终端(pty)。

ssh_proxy.png

二. 实现容器的 Web Terminal

2.1 架构图

docker ws.png

2.2 前端 Web Termianl 页面

Linux 终端返回的内容会带很多特殊的字符,比如我输入一个 ls 指令,终端返回的结果如下:

'l'
's'
'\r\n'
'\x1b[0;0mRUNNING_PID\x1b[0m  \x1b[1;34mbin\x1b[0m          \x1b[1;34mconf\x1b[0m         \x1b[1;34mlib\x1b[0m\r\nbash-4.3# '

这样我们就需要自己做穷举处理了,这里推荐使用一款的模拟 Terminal 的 JavaScript 库 xterm.js。这个库已经帮我们做了这些复杂操作。

<script>
  var term = new Terminal();
  term.open(document.getElementById('terminal'));
  term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>

可以看到它已经将 \x1B[1;3;31mxterm.js\x1B[0m 这些特殊字符变成了红色:

xterm-example.png

2.3 调用 Docker Daemon API 返回 Shell 虚拟终端

在平常的命令行操作下,我们经常会使用 docker exec -i -t <container_id> /bin/sh 来模拟一个 Shell 的伪终端。在 Web Terminal 实现里,我们需要通过 API 调用的方式来实现同样的操作。当然,我们首先要确保 Docker Daemon 的远程调用是开启的。

  1. 先调用 execCreate 来创建一个 Exec。在调用时,需要指定TtyAttachStdinAttachStdoutAttachStderr 参数均为 true,Cmd 参数为 bash,这样才能获得 bash 进程的标准输入输出和错误;

    request

    POST /v1.24/containers/e90e34656806/exec HTTP/1.1
    Content-Type: application/json
    
    {
      "AttachStdin": true,
      "AttachStdout": true,
      "AttachStderr": true,
      "Cmd": ["sh"],
      "DetachKeys": "ctrl-p,ctrl-q",
      "Tty": true,
      ...
    }
    
  2. 如果调用 execCreate 成功,调用请求会返回该 Exec 的 ID,根据这个 ID 继续调用execStart 接口。在调用时,需要指定 Detach 为 False,Tty 为 True,这样才会返回一个 HTTP 的 stream:

    request

    POST /v1.24/exec/e90e34656806/start HTTP/1.1
    Content-Type: application/json
    
    {
     "Detach": false,
     "Tty": true
    }
    

    response:

    HTTP/1.1 200 OK
    Content-Type: application/vnd.docker.raw-stream
    
    
    {{ STREAM }}
    

2.4 d-terminal

d-terminal 是这个系统的核心,它分成两个部分:

  1. 一部分用于处理用户端的输入和输出,以及存储和展示后端 Docker Dameon 主机的 IP 和 container_id。因为像 top 这样的监控命令需要服务端定时推送数据给客户端,所以使用了 WebSocket 协议以支持服务端推送。
  2. 另一部分用于调用 Docker Daemon 返回虚拟终端。对于终端来说,通常是你输入一个字符就会立马返回,直到你输入一个 "归位键" 终端才会把你输入的字符拼接成一个字符串并发送给 shell 解释器,并将 shell 解释器的结果返回。为了提升使用流畅性,新启用了一个线程去调用 Docker Daemon API,当然也可以使用像 epoll 这样的多路复用技术来实现。

main.png

d-terminal 是使用 Python 实现的 Web 应用,核心代码如下:

@sockets.route('/echo')
def echo_socket(ws):
    ...
    # 调用 Docker API 返回一个虚拟终端
    docker_daemon_sock = get_tty()._sock
    # 启动一个与 Docker Daemon 通讯的子线
    docker_daemon_sock_thd = DockerDaemonSock(ws, docker_daemon_sock)
    docker_daemon_sock_thd.start()

    while not ws.closed:
        message = ws.receive() # 接收 terminal 的输入
        # 将用户的输入发送那个 docker daemon
        docker_daemon_sock.send(bytes(message, encoding='utf-8'))


# 子线程 DockerDaemonSock 类
class DockerDaemonSock(threading.Thread):
    def __init__(self, ws, docker_daemon_sock):
        super(DockerDaemonSock, self).__init__()
        self.ws = ws
        self.docker_daemon_sock = docker_daemon_sock

    def run(self):
        while not self.ws.closed:
            try:
                # 接收 docker daemon 的返回
                resp = self.docker_daemon_sock.recv(2048)
                if resp:
                    # 将 docker daemon 的返回发送给前端 terminal
                    self.ws.send(str(resp, encoding='utf-8'))
                else:
                    print("docker socket closed.")
                    self.ws.close()
            except Exception as e:
                print("docker termial socket error: {}".format(e))
                self.ws.close()

三. 总结

上述仅仅是描述了一个最基本的实现,完全是为了抛砖引玉,后续可以通过在中间层添加一些扩展,比如,用户权限的分配,与自己环境中的容器编排引擎集成等,最终作为 Pass 平台的一个基础的组成部分。

最后,上述的 demo 可去 github 具体查看。效果如下:

image

四. 参考

0