IT牛人博客聚合网站 发现IT技术最优秀的内容, 寻找IT技术的价值 http://www.udpwork.com/ zh_CN http://www.udpwork.com/about hourly 1 Thu, 24 Aug 2017 16:28:02 +0800 <![CDATA[Lua 5.3.4 的一个 bug]]> http://www.udpwork.com/item/16393.html http://www.udpwork.com/item/16393.html#reviews Thu, 24 Aug 2017 10:55:37 +0800 云风 http://www.udpwork.com/item/16393.html 昨天我们一个项目发现了一处死循环的 bug ,经过一整晚的排查,终于确认是 lua 5.3.4 的问题。

起因是最近项目中接入了我前段时间写的一个库,用来给客户端加载大量配置表格数据。它的原理是将数据表先转换为 C 结构,放在一块连续内存里。在运行时,可以根据需要提取出其中用到的部分加载都虚拟机中。这样做可以极大的提高加载速度。项目在用的时候还做了一点点小修改,把数据表都设置成 weaktable ,可以让暂时不用的数据项可以回收掉。

正式后面这个小修改触发了 bug 。

排除掉是我这个库引起的 bug 后,我们把注意力集中在 lua 的实现上。

bug 的现象是:运行一段时间后,某次 table copy 的过程中,对一个 table 的 set 操作陷入了死循环。我们知道 lua 的 table 中有一个闭散列 hash 表,如果在插入新项目时,发现 hash 冲突,则需要重新找到一个空的 slot 并将其串在 hash 查询时所在的 slot 上的链表中。

而 bug 发生时,这个链表损坏了,指向了一个空 slot ,空 slot 的 next 指针指向自己,导致死循环遍历。

从 coredump 上分析,我认为是 hash 查询出来的冲突对象(一个 long string )的数据结构受损。原本在 long string 结构中有一个 extra 变量指示这个对象是否有计算过 hash ,它的值只能是 0 或 1 ,但这里却是 67 。而 hash 值则为 0 (通常 hash 值是 0 的概率非常小),导致重新索引 hash slot 时指向了 slot 0 ,那里是空的。

我们自定义了 lua 的分配器,在分配器中输出 log 显示,在访问这个 slot 前,那个受损的 long string key 对象其实已经被 lua vm 释放过了。

一开始我们怀疑是自定义的内存分配器有 bug ,但很快放弃了这个想法,转而去追查 lua 的 gc 过程。这个 table 是 key value 都是弱的弱表,若只设置 value 为弱则不会触发 bug 。

确认问题出在清除弱表项的环节,也就是 lgc.c 中的 GCSatomic 阶段的 atomic 函数中。它有一个步骤是调用clearkeys(g, g->allweak, NULL);清除在扫描过程标记出来的弱表,并检查 key 是否需要清除。

该函数是这样的:


/*
** clear entries with unmarked keys from all weaktables in list 'l' up
** to element 'f'
*/
static void clearkeys (global_State *g, GCObject *l, GCObject *f) {
  for (; l != f; l = gco2t(l)->gclist) {
    Table *h = gco2t(l);
    Node *n, *limit = gnodelast(h);
    for (n = gnode(h, 0); n 

遍历 hash 表,当 value 不为空,且 key 可以被清除的时候,将 slot 清空。

string 对于 gc 是一个特殊的对象,因为它即是一个 GCObject ,但又被视为值而不是引用。string 并不会因为在 vm 中没有 weak table 之外的地方引用而被清除。对 string 的特殊处理是在 iscleared 函数中完成的。


/*
** tells whether a key or value can be cleared from a weak
** table. Non-collectable objects are never removed from weak
** tables. Strings behave as 'values', so are never removed too. for
** other objects: if really collected, cannot keep them; for objects
** being finalized, keep them in keys, but not in values
*/
static int iscleared (global_State *g, const TValue *o) {
  if (!iscollectable(o)) return 0;
  else if (ttisstring(o)) {
    markobject(g, tsvalue(o));  /* strings are 'values', so are never weak */
    return 0;
  }
  else return iswhite(gcvalue(o));
}


如果发现 key 是一个 string 则会将其标黑。

但是在 clearkeys 里漏掉了一点,如果 value 为 nil 是不会执行 iscleared 函数的。而什么时候 key 为 string , value 为 nil 呢?最简单的途径是主动给 table 的表项设置为 nil 。这样,在 gc 一轮后,hash 表中就可能残留一个已经被释放的 GCObject 指针。

如果这个 string 是一个短 string 其实不会引起问题,因为再次设置 hash 表的时候,short string 是按指针比较的,不会访问其内容;但是 long string 不一样,hash set 时真的会比较对象的内容:两个 long string 是否相等取决于 string 的值相同,而不必是对象内存地址相同。

制作一个纯 lua 的 MWE 很困难,所以我写了一段 C 代码来演示这个问题:


#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdlib.h>
#include <lstring.h>

static void *
l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
    if (nsize == 0) {
        printf("free %p\n", ptr);
        free(ptr);
        return NULL;
    } else {
        return realloc(ptr, nsize);
    }
}

static int
lpointer(lua_State *L) {
    const char * str = luaL_checkstring(L, 1);
    const TString *ts = (const TString *)str - 1;
    lua_pushlightuserdata(L, (void *)ts);
    return 1;
}

const char *source = "\n\
local a = setmetatable({} , { __mode = 'kv' })\n\
a['ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz' ] = {}\n\
print(pointer((next(a))))\n\
a[next(a)] = nil\n\
collectgarbage 'collect'\n\
local key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz'\n\
print(pointer(key))\n\
a[key] = {}\n\
print(pointer((next(a))))\n\
";

int main() {
    lua_State *L = lua_newstate (l_alloc, NULL);
    luaL_openlibs(L);
    lua_pushcfunction(L, lpointer);
    lua_setglobal(L, "pointer");
    luaL_dostring(L, source);

    return 0;
}


运行输出:


...
userdata: 00000000006fedd0     这里是ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
free 00000000006FAB50     这里进入 GC 开始释放不用的对象
free 00000000006FA890
free 00000000006FE940
free 00000000006FE910
free 0000000000000000
free 00000000006FA650
free 00000000006FEDD0      这里显示前面那个长字符串 6FEDD0 已经释放了。
free 00000000006FEBC0
free 0000000000000000
free 00000000006FAA50
free 00000000006F9770
userdata: 00000000006f1eb0   这里构造了一个新的字符串
 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
userdata: 00000000006fedd0   这里显示前面那个已经被释放的 6FEDD0  字符串又回来了。


这个 bug 最简单的修改方法是把 clearkeys 中的 !ttisnil(gval(n)) 条件判断去掉。不过或许还有更完善的解决方案。

我已经将 bug report 到 lua 的邮件列表,暂时尚未被官方确认修正。

8 月 24 日:

官方已确认这个 bug ,见邮件列表 。
]]>
昨天我们一个项目发现了一处死循环的 bug ,经过一整晚的排查,终于确认是 lua 5.3.4 的问题。

起因是最近项目中接入了我前段时间写的一个库,用来给客户端加载大量配置表格数据。它的原理是将数据表先转换为 C 结构,放在一块连续内存里。在运行时,可以根据需要提取出其中用到的部分加载都虚拟机中。这样做可以极大的提高加载速度。项目在用的时候还做了一点点小修改,把数据表都设置成 weaktable ,可以让暂时不用的数据项可以回收掉。

正式后面这个小修改触发了 bug 。

排除掉是我这个库引起的 bug 后,我们把注意力集中在 lua 的实现上。

bug 的现象是:运行一段时间后,某次 table copy 的过程中,对一个 table 的 set 操作陷入了死循环。我们知道 lua 的 table 中有一个闭散列 hash 表,如果在插入新项目时,发现 hash 冲突,则需要重新找到一个空的 slot 并将其串在 hash 查询时所在的 slot 上的链表中。

而 bug 发生时,这个链表损坏了,指向了一个空 slot ,空 slot 的 next 指针指向自己,导致死循环遍历。

从 coredump 上分析,我认为是 hash 查询出来的冲突对象(一个 long string )的数据结构受损。原本在 long string 结构中有一个 extra 变量指示这个对象是否有计算过 hash ,它的值只能是 0 或 1 ,但这里却是 67 。而 hash 值则为 0 (通常 hash 值是 0 的概率非常小),导致重新索引 hash slot 时指向了 slot 0 ,那里是空的。

我们自定义了 lua 的分配器,在分配器中输出 log 显示,在访问这个 slot 前,那个受损的 long string key 对象其实已经被 lua vm 释放过了。

一开始我们怀疑是自定义的内存分配器有 bug ,但很快放弃了这个想法,转而去追查 lua 的 gc 过程。这个 table 是 key value 都是弱的弱表,若只设置 value 为弱则不会触发 bug 。

确认问题出在清除弱表项的环节,也就是 lgc.c 中的 GCSatomic 阶段的 atomic 函数中。它有一个步骤是调用clearkeys(g, g->allweak, NULL);清除在扫描过程标记出来的弱表,并检查 key 是否需要清除。

该函数是这样的:


/*
** clear entries with unmarked keys from all weaktables in list 'l' up
** to element 'f'
*/
static void clearkeys (global_State *g, GCObject *l, GCObject *f) {
  for (; l != f; l = gco2t(l)->gclist) {
    Table *h = gco2t(l);
    Node *n, *limit = gnodelast(h);
    for (n = gnode(h, 0); n 

遍历 hash 表,当 value 不为空,且 key 可以被清除的时候,将 slot 清空。

string 对于 gc 是一个特殊的对象,因为它即是一个 GCObject ,但又被视为值而不是引用。string 并不会因为在 vm 中没有 weak table 之外的地方引用而被清除。对 string 的特殊处理是在 iscleared 函数中完成的。


/*
** tells whether a key or value can be cleared from a weak
** table. Non-collectable objects are never removed from weak
** tables. Strings behave as 'values', so are never removed too. for
** other objects: if really collected, cannot keep them; for objects
** being finalized, keep them in keys, but not in values
*/
static int iscleared (global_State *g, const TValue *o) {
  if (!iscollectable(o)) return 0;
  else if (ttisstring(o)) {
    markobject(g, tsvalue(o));  /* strings are 'values', so are never weak */
    return 0;
  }
  else return iswhite(gcvalue(o));
}


如果发现 key 是一个 string 则会将其标黑。

但是在 clearkeys 里漏掉了一点,如果 value 为 nil 是不会执行 iscleared 函数的。而什么时候 key 为 string , value 为 nil 呢?最简单的途径是主动给 table 的表项设置为 nil 。这样,在 gc 一轮后,hash 表中就可能残留一个已经被释放的 GCObject 指针。

如果这个 string 是一个短 string 其实不会引起问题,因为再次设置 hash 表的时候,short string 是按指针比较的,不会访问其内容;但是 long string 不一样,hash set 时真的会比较对象的内容:两个 long string 是否相等取决于 string 的值相同,而不必是对象内存地址相同。

制作一个纯 lua 的 MWE 很困难,所以我写了一段 C 代码来演示这个问题:


#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdlib.h>
#include <lstring.h>

static void *
l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
    if (nsize == 0) {
        printf("free %p\n", ptr);
        free(ptr);
        return NULL;
    } else {
        return realloc(ptr, nsize);
    }
}

static int
lpointer(lua_State *L) {
    const char * str = luaL_checkstring(L, 1);
    const TString *ts = (const TString *)str - 1;
    lua_pushlightuserdata(L, (void *)ts);
    return 1;
}

const char *source = "\n\
local a = setmetatable({} , { __mode = 'kv' })\n\
a['ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz' ] = {}\n\
print(pointer((next(a))))\n\
a[next(a)] = nil\n\
collectgarbage 'collect'\n\
local key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz'\n\
print(pointer(key))\n\
a[key] = {}\n\
print(pointer((next(a))))\n\
";

int main() {
    lua_State *L = lua_newstate (l_alloc, NULL);
    luaL_openlibs(L);
    lua_pushcfunction(L, lpointer);
    lua_setglobal(L, "pointer");
    luaL_dostring(L, source);

    return 0;
}


运行输出:


...
userdata: 00000000006fedd0     这里是ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
free 00000000006FAB50     这里进入 GC 开始释放不用的对象
free 00000000006FA890
free 00000000006FE940
free 00000000006FE910
free 0000000000000000
free 00000000006FA650
free 00000000006FEDD0      这里显示前面那个长字符串 6FEDD0 已经释放了。
free 00000000006FEBC0
free 0000000000000000
free 00000000006FAA50
free 00000000006F9770
userdata: 00000000006f1eb0   这里构造了一个新的字符串
 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
userdata: 00000000006fedd0   这里显示前面那个已经被释放的 6FEDD0  字符串又回来了。


这个 bug 最简单的修改方法是把 clearkeys 中的 !ttisnil(gval(n)) 条件判断去掉。不过或许还有更完善的解决方案。

我已经将 bug report 到 lua 的邮件列表,暂时尚未被官方确认修正。

8 月 24 日:

官方已确认这个 bug ,见邮件列表 。
]]>
0
<![CDATA[实战Guzzle抓取]]> http://www.udpwork.com/item/16392.html http://www.udpwork.com/item/16392.html#reviews Wed, 23 Aug 2017 20:30:53 +0800 老王 http://www.udpwork.com/item/16392.html 虽然早就知道很多人用 Guzzle 爬数据,但是我却从来没有真正实践过,因为在我的潜意识里,抓取是 Python 的地盘。不过前段时间,当我抓汽车之家数据的时候,好心人跟我提起 Goutte搭配 Guzzle 是最好的爬虫,让我一直记挂在心上,加上最近打算更新一下车型数据,于是我便重写了抓取汽车之家数据的脚本。

因为我是通过接口抓取,而不是网页,所以暂时用不上 Goutte,只用 Guzzle 就可以了,抓取过程中需要注意两点:首先需要注意的是通过并发节省时间,其次需要注意的是失败重试的步骤。算了,我不想说了,直接贴代码吧。

<?php

require "vendor/autoload.php";

use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Middleware;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;

// 品牌
$brands = [];
// 车系
$series = [];
// 车型
$models = [];

// 配置
$configs = [];

$timeout = 10;
$concurrency = 100;

ini_set("memory_limit", "512M");

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
    function($retries) { return $retries < 3; },
    function($retries) { return pow(2, $retries - 1); }
));

$client = new Client([
    "debug" => true,
    "timeout" => $timeout,
    "base_uri" => "https://cars.app.autohome.com.cn",
    "headers" => [
        "User-Agent" => "Android\t6.0.1\tautohome\t8.3.0\tAndroid",
    ],
    "handler" => $stack,
]);

// 品牌列表页
$url = "/cars_v8.3.0/cars/brands-pm2.json";

$response = $client->get($url);
$contents = $response->getBody()->getContents();
$contents = json_decode($contents, true);
$contents = $contents["result"]["brandlist"];

foreach ($contents as $values) {
    $initial = $values["letter"];

    foreach ($values["list"] as $v) {
        $brands[$v["id"]] = [
            "id" => $v["id"],
            "name" => $v["name"],
            "initial" => $initial,
        ];
    }
}

$brands = array_values($brands);

###

$requests = function ($brands) {
    foreach ($brands as $v) {
        $id = $v["id"];
        // 品牌介绍页
        $url = "/cars_v8.3.0/cars/getbrandinfo-pm2-b{$id}.json";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($brands), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$brands) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"]["list"];
        $contents = $contents ? $contents[0]["description"] : "暂无";
        $contents = trim(str_replace(["\r\n", ","], ["\n", ","], $contents));

        $brands[$index]["description"] = $contents;
    },
]);

$pool->promise()->wait();

$requests = function ($brands) {
    foreach ($brands as $v) {
        $id = $v["id"];
        // 车系列表页
        $url = "/cars_v8.3.0/cars/seriesprice-pm2-b{$id}-t16-v8.3.0.json";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($brands), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$series, $brands) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"];

        $brand_id = $brands[$index]["id"];

        foreach (["fctlist", "otherfctlist"] as $field) {
            $values = $contents[$field];

            foreach ($values as $value) {
                $factory = $value["name"];

                foreach ($value["serieslist"] as $v) {
                    list($min, $max) = explode("-", $v["price"]) + [1 => 0];

                    $min_price = $min * 10000;
                    $max_price = $max * 10000;

                    if ($max_price == 0) {
                        $max_price = $min_price;
                    }

                    $series[$v["id"]] = [
                        "id" => $v["id"],
                        "name" => $v["name"],
                        "level" => $v["levelname"],
                        "factory" => $factory,
                        "min_price" => $min_price,
                        "max_price" => $max_price,
                        "brand_id" => $brand_id,
                    ];
                }
            }
        }
    },
]);

$pool->promise()->wait();

$series = array_values($series);

###

$requests = function ($series) {
    foreach ($series as $v) {
        $id = $v["id"];
        // 车型列表页
        $url = "/carinfo_v8.3.0/cars/seriessummary-pm2-s{$id}-t-c110100-v8.3.0.json";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($series), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$models, $series) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"]['enginelist'];

        $series_id = $series[$index]["id"];

        foreach ($contents as $values) {
            if (in_array($values["yearvalue"], [0, 1])) {
                continue;
            }

            foreach ($values["yearspeclist"] as $value) {
                foreach ($value["speclist"] as $v) {
                    if (isset($models[$v["id"]])) {
                        continue;
                    }

                    $price = $v["price"] * 10000;

                    $description = trim($v["description"]);

                    if (!$description) {
                        $description = "暂无";
                    }

                    $models[$v["id"]] = [
                        "id" => $v["id"],
                        "name" => $v["name"],
                        "status" => $v["state"],
                        "price" => $price,
                        "description" => $description,
                        "series_id" => $series_id,
                    ];
                }
            }
        }
    },
]);

$pool->promise()->wait();

$models = array_values($models);

###

$requests = function ($models) {
    foreach ($models as $v) {
        $id = $v["id"];
        // 车型参数页
        $url = "/cfg_v8.3.0/cars/speccompare.ashx?pm=2&type=1&specids={$id}&cityid=110100&site=2&pl=2";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($models), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$models, &$configs) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"];

        $models[$index]["config"] = [];

        foreach (["paramitems", "configitems"] as $key) {
            $values = $contents[$key];

            foreach ($values as $value) {
                $category = $value["itemtype"];

                foreach ($value["items"] as $v) {
                    $id = $v["id"];

                    if ($id < 1) {
                        continue;
                    }

                    $name = $v["name"];
                    $value = $v["modelexcessids"][0]["value"];

                    if ($value != "-") {
                        $models[$index]["config"][$id] = $value;
                    }

                    if (!isset($configs[$category][$id])) {
                        $configs[$category][$id] = [
                            "id" => $id,
                            "name" => $name,
                            "category" => $category,
                        ];
                    }
                }
            }
        }

        $models[$index]["config"] = json_encode(
            $models[$index]["config"], JSON_UNESCAPED_UNICODE
        );
    },
]);

$pool->promise()->wait();

$configs = call_user_func_array("array_merge", $configs);

// todo: 保存数据

?>

编写此类工具性质的脚本无需考虑面向对象之类的弯弯绕,一马平川的流水账往往是最好的选择。运行前记得先通过 composer 安装 guzzle,整个运行过程大概会执行三万次抓取请求,可以抓取汽车之家完整的品牌,车系,车型及配置等相关数据,总耗时大概十分钟左右,效率还是可以接受的。

]]>
虽然早就知道很多人用 Guzzle 爬数据,但是我却从来没有真正实践过,因为在我的潜意识里,抓取是 Python 的地盘。不过前段时间,当我抓汽车之家数据的时候,好心人跟我提起 Goutte搭配 Guzzle 是最好的爬虫,让我一直记挂在心上,加上最近打算更新一下车型数据,于是我便重写了抓取汽车之家数据的脚本。

因为我是通过接口抓取,而不是网页,所以暂时用不上 Goutte,只用 Guzzle 就可以了,抓取过程中需要注意两点:首先需要注意的是通过并发节省时间,其次需要注意的是失败重试的步骤。算了,我不想说了,直接贴代码吧。

<?php

require "vendor/autoload.php";

use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Middleware;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;

// 品牌
$brands = [];
// 车系
$series = [];
// 车型
$models = [];

// 配置
$configs = [];

$timeout = 10;
$concurrency = 100;

ini_set("memory_limit", "512M");

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
    function($retries) { return $retries < 3; },
    function($retries) { return pow(2, $retries - 1); }
));

$client = new Client([
    "debug" => true,
    "timeout" => $timeout,
    "base_uri" => "https://cars.app.autohome.com.cn",
    "headers" => [
        "User-Agent" => "Android\t6.0.1\tautohome\t8.3.0\tAndroid",
    ],
    "handler" => $stack,
]);

// 品牌列表页
$url = "/cars_v8.3.0/cars/brands-pm2.json";

$response = $client->get($url);
$contents = $response->getBody()->getContents();
$contents = json_decode($contents, true);
$contents = $contents["result"]["brandlist"];

foreach ($contents as $values) {
    $initial = $values["letter"];

    foreach ($values["list"] as $v) {
        $brands[$v["id"]] = [
            "id" => $v["id"],
            "name" => $v["name"],
            "initial" => $initial,
        ];
    }
}

$brands = array_values($brands);

###

$requests = function ($brands) {
    foreach ($brands as $v) {
        $id = $v["id"];
        // 品牌介绍页
        $url = "/cars_v8.3.0/cars/getbrandinfo-pm2-b{$id}.json";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($brands), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$brands) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"]["list"];
        $contents = $contents ? $contents[0]["description"] : "暂无";
        $contents = trim(str_replace(["\r\n", ","], ["\n", ","], $contents));

        $brands[$index]["description"] = $contents;
    },
]);

$pool->promise()->wait();

$requests = function ($brands) {
    foreach ($brands as $v) {
        $id = $v["id"];
        // 车系列表页
        $url = "/cars_v8.3.0/cars/seriesprice-pm2-b{$id}-t16-v8.3.0.json";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($brands), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$series, $brands) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"];

        $brand_id = $brands[$index]["id"];

        foreach (["fctlist", "otherfctlist"] as $field) {
            $values = $contents[$field];

            foreach ($values as $value) {
                $factory = $value["name"];

                foreach ($value["serieslist"] as $v) {
                    list($min, $max) = explode("-", $v["price"]) + [1 => 0];

                    $min_price = $min * 10000;
                    $max_price = $max * 10000;

                    if ($max_price == 0) {
                        $max_price = $min_price;
                    }

                    $series[$v["id"]] = [
                        "id" => $v["id"],
                        "name" => $v["name"],
                        "level" => $v["levelname"],
                        "factory" => $factory,
                        "min_price" => $min_price,
                        "max_price" => $max_price,
                        "brand_id" => $brand_id,
                    ];
                }
            }
        }
    },
]);

$pool->promise()->wait();

$series = array_values($series);

###

$requests = function ($series) {
    foreach ($series as $v) {
        $id = $v["id"];
        // 车型列表页
        $url = "/carinfo_v8.3.0/cars/seriessummary-pm2-s{$id}-t-c110100-v8.3.0.json";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($series), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$models, $series) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"]['enginelist'];

        $series_id = $series[$index]["id"];

        foreach ($contents as $values) {
            if (in_array($values["yearvalue"], [0, 1])) {
                continue;
            }

            foreach ($values["yearspeclist"] as $value) {
                foreach ($value["speclist"] as $v) {
                    if (isset($models[$v["id"]])) {
                        continue;
                    }

                    $price = $v["price"] * 10000;

                    $description = trim($v["description"]);

                    if (!$description) {
                        $description = "暂无";
                    }

                    $models[$v["id"]] = [
                        "id" => $v["id"],
                        "name" => $v["name"],
                        "status" => $v["state"],
                        "price" => $price,
                        "description" => $description,
                        "series_id" => $series_id,
                    ];
                }
            }
        }
    },
]);

$pool->promise()->wait();

$models = array_values($models);

###

$requests = function ($models) {
    foreach ($models as $v) {
        $id = $v["id"];
        // 车型参数页
        $url = "/cfg_v8.3.0/cars/speccompare.ashx?pm=2&type=1&specids={$id}&cityid=110100&site=2&pl=2";
        yield new Request("GET", $url);
    }
};

$pool = new Pool($client, $requests($models), [
    "concurrency" => $concurrency,
    "fulfilled" => function ($response, $index) use(&$models, &$configs) {
        $contents = $response->getBody()->getContents();
        $contents = json_decode($contents, true);
        $contents = $contents["result"];

        $models[$index]["config"] = [];

        foreach (["paramitems", "configitems"] as $key) {
            $values = $contents[$key];

            foreach ($values as $value) {
                $category = $value["itemtype"];

                foreach ($value["items"] as $v) {
                    $id = $v["id"];

                    if ($id < 1) {
                        continue;
                    }

                    $name = $v["name"];
                    $value = $v["modelexcessids"][0]["value"];

                    if ($value != "-") {
                        $models[$index]["config"][$id] = $value;
                    }

                    if (!isset($configs[$category][$id])) {
                        $configs[$category][$id] = [
                            "id" => $id,
                            "name" => $name,
                            "category" => $category,
                        ];
                    }
                }
            }
        }

        $models[$index]["config"] = json_encode(
            $models[$index]["config"], JSON_UNESCAPED_UNICODE
        );
    },
]);

$pool->promise()->wait();

$configs = call_user_func_array("array_merge", $configs);

// todo: 保存数据

?>

编写此类工具性质的脚本无需考虑面向对象之类的弯弯绕,一马平川的流水账往往是最好的选择。运行前记得先通过 composer 安装 guzzle,整个运行过程大概会执行三万次抓取请求,可以抓取汽车之家完整的品牌,车系,车型及配置等相关数据,总耗时大概十分钟左右,效率还是可以接受的。

]]>
0
<![CDATA[[转]设计一个容错的微服务架构]]> http://www.udpwork.com/item/16391.html http://www.udpwork.com/item/16391.html#reviews Wed, 23 Aug 2017 13:52:09 +0800 鸟窝 http://www.udpwork.com/item/16391.html 原文:Designing a Microservices Architecture for Failure
翻译:设计一个容错的微服务架构by Jason Geng

微服务架构使得可以通过明确定义的服务边界来隔离故障。但是像在每个分布式系统中一样,发生网络、硬件、应用级别的错误都是很常见的。由于服务依赖关系,任何组件可能暂时无法提供服务。为了尽量减少部分中断的影响,我们需要构建容错服务,来优雅地处理这些中断的响应结果。

本文介绍了基于RisingStack 的 Node.js 咨询和开发经验构建和操作高可用性微服务系统的最常见技术和架构模式。

如果你不熟悉本文中的模式,那并不一定意味着你做错了。建立可靠的系统总是会带来额外的成本。

微服务架构的风险

微服务架构将应用程序逻辑移动到服务,并使用网络层在它们之间进行通信。这种通过网络间通信代替单应用程序内调用的做法,会带来额外的延迟,以及需要协调多个物理和逻辑组件的系统复杂度。分布式系统的复杂性增加也将导致更高的网络故障率。

microservices allow you to achieve graceful service degradation as components can be set up to fail separately.

微服务体系结构的最大优势之一是,团队可以独立设计,开发和部署他们的服务。他们对服务的生命周期拥有完全的所有权。这也意味着团队无法控制他们依赖的服务,因为它更有可能由不同的团队管理。使用微服务架构,我们需要记住,提供者服务可能会临时不可用,由于其他人员发行的错误版本,配置以及其他更改等。

优雅的服务降级

微服务架构的最大优点之一是您可以隔离故障,并在当组件单独故障时,进行优雅的服务降级。 例如,在中断期间,照片共享应用程序中的客户可能无法上传新图片,但仍可以浏览,编辑和共享其现有照片。

微服务容错隔离

在大多数情况下,由于分布式系统中的应用程序相互依赖,因此很难实现这种优雅的服务降级,您需要应用几种故障转移的逻辑(其中一些将在本文后面介绍),以为暂时的故障和中断做准备。

服务间彼此依赖,再没有故障转移逻辑下,服务全部失败。

变更管理

Google的网站可靠性小组发现,大约70%的中断是由现有系统的变化引起的 。当您更改服务中的某些内容时,您将部署新版本的代码或更改某些配置 - 这总有可能会造成故障,或者引入新的bug。

在微服务架构中,服务依赖于彼此。这就是为什么你应该尽量减少故障并限制它的负面影响。要处理变更中的问题,您可以实施变更管理策略和自动回滚 机制。

例如,当您部署新代码或更改某些配置时,您应该先小范围的进行部分的替换,以渐进式的方式替换服务的全部实例。在这期间,需要监视它们,如果您发现它们对您的关键指标有负面影响,应立即进行服务回滚,这称为“金丝雀部署”。

变更管理 - 回滚部署

另一个解决方案可能是您运行两个生产环境。您始终只能部署其中一个,并且在验证新版本是否符合预期之后才,将负载均衡器指向新的。这称为蓝绿或红黑部署。

回滚代码不是坏事 。你不应该在生产中遗留错误的代码,然后考虑出了什么问题。如果必要,越早回滚你的代码越好。

健康检查与负载均衡

实例由于出现故障、部署或自动缩放的情况,会进行持续启动、重新启动或停止操作。它可能导致它们暂时或永久不可用。为避免问题,您的负载均衡器应该从路由中跳过不健康的实例 ,因为它们当前无法为客户或子系统提供服务。

应用实例健康状况可以通过外部观察来确定。您可以通过重复调用GET /health 端点或通过自我报告来实现。现在主流的服务发现 解决方案,会持续从实例中收集健康信息,并配置负载均衡器,将流量仅路由到健康的组件上。

自我修复

自我修复可以帮助应用程序从错误中恢复过来。当应用程序可以采取必要步骤 从故障状态恢复时,我们就可以说它是可以实现自我修复的。在大多数情况下,它由外部系统实现,该系统会监视实例运行状况,并在较长时间内处于故障状态时重新启动它们。自我修复在大多数情况下是非常有用的。但是在某些情况下,持续地重启应用程序可能会导致麻烦 。 当您的应用程序由于超负荷或其数据库连接超时而无法给出健康的运行状况时,这种情况下的频繁的重启就可能就不太合适了。

对于这种特殊的场景(如丢失的数据库连接),要实现满足它的高级自我修复的解决方案可能很棘手。在这种情况下,您需要为应用程序添加额外的逻辑来处理边缘情况,并让外部系统知道实例不需要立即重新启动。

故障转移缓存

由于网络问题和我们系统的变化,服务经常会失败。然而,由于自我修复和负载均衡的保障,它们中的大多数中断是临时的,我们应该找到一个解决方案,使我们的服务在这些故障时服务仍就可以工作。这就是故障转移缓存 (failover caching)的作用,它可以帮助并为我们的应用程序在服务故障时提供必要的数据。

故障转移缓存通常使用两个不同的过期日期 ; 较短的时间告诉您在正常情况下缓存可以使用的过期时间,而较长的时间可以在服务故障时缓存依旧可用的过期时间。

故障转移缓存

请务必提及,只有当服务使用过时的数据比没有数据更好时,才能使用故障转移缓存。

要设置缓存和故障转移缓存,可以在 HTTP 中使用标准响应头。

例如,使用max-age属性可以指定资源被视为有效的最大时间。使用stale-if-error属性,您可以明确在出现故障的情况下,依旧可以从缓存中获取资源的最大时间。

现代的 CDN 和负载均衡器都提供各种缓存和故障转移行为,但您也可以为拥有标准可靠性解决方案的公司创建一个共享库。

重试逻辑

在某些情况下,我们无法缓存数据,或者我们想对其进行更改,但是我们的操作最终都失败了。对于此,我们可以重试我们的操作,因为我们可以预期资源将在一段时间后恢复,或者我们的负载均衡器将请求发送到了健康的实例上。

您应该小心地为您的应用程序和客户端添加重试逻辑,因为大量的重试可能会使事情更糟 ,甚至阻止应用程序恢复,如当服务超载时,大量的重试只能使状况更糟。

在分布式系统中,微服务系统重试可以触发多个其他请求或重试,并启动级联效应 。为了最小化重试的影响,您应该限制它们的数量,并使用指数退避算法来持续增加重试之间的延迟,直到达到最大限制。

当客户端(浏览器,其他微服务等)发起重试,并且客户端不知道在处理请求之前或之后操作失败时,您应该为你的应用程序做好幂等处理 的准备。例如,当您重试购买操作时,您不应该再次向客户收取费用。为每个交易使用唯一的幂等值键 可以帮助处理重试。

限流器和负载降级

流量限制是在一段时间内定义特定客户或应用程序可以接收或处理多少个请求的技术。例如,通过流量限制,您可以过滤掉造成流量峰值的客户和服务,或者您可以确保您的应用程序在自动缩放无法满足时,依然不会超载。

您还可以阻止较低优先级的流量,为关键事务提供足够的资源。

限流器可以阻止流量峰值产生

有一个不同类型的限流器,叫做并发请求限制器。当您有重要的端点,您不应该被调用超过指定的次数,而您仍然想要能提供服务时,这将是有用的。

负载降级的一系列使用,可以确保总是有足够的资源来提供关键交易。它为高优先级请求保留一些资源,不允许低优先级的事务使用它们。负载降级开关是根据系统的整体状态做出决定,而不是基于单个用户的请求量大小。负载降级有助于您的系统恢复,因为当你有一个偶发事件时(可能是一个热点事件),您仍能保持核心功能的正常工作。

要了解有关限流器和负载降级的更多信息,我建议查看这篇Stripe的文章

快速失败原则与独立性

在微服务架构中,我们想要做到让我们的服务具备快速失败与相互独立的能力。为了在服务级别上进行故障隔离,我们可以使用舱壁模式。你可以在本文的后面阅读更多有关舱壁的内容。

我们也希望我们的组件能够快速失败,因为我们不希望对于有故障的服务,在请求超时后才断开。没有什么比挂起的请求和无响应的 UI 更令人失望。这不仅浪费资源,而且还会影响用户体验。我们的服务在调用链中是相互调用的,所以在这些延迟累加之前,我们应该特别注意防止挂起操作。

你想到的第一个想法是对每个服务调用都设置明确的超时等级。这种方法的问题是,您不能知道真正合理的超时值是多少,因为网络故障和其他问题发生的某些情况只会影响一两次操作。在这种情况下,如果只有其中一些超时,您可能不想拒绝这些请求。

我们可以说,在微服务种通过使用超时来达到快速失败的效果是一种反模式的,你应该避免使用它。取而代之,您可以应用断路器模式,依据操作的成功与失败统计数据决定。

舱壁模式

工业中使用舱壁将船舶划分为几个部分,以便在船体破坏的情况下,可以将船舶各个部件密封起来。

舱壁的概念在软件开发中可以被应用在隔离资源上。

通过应用舱壁模式,我们可以保护有限的资源不被耗尽。例如,对于一个有连接数限制的数据库实例来说,如果我们有两种连接它的操作,我们采用可以采用两个连接池的方式进行连接,来代替仅采用一个共享连接池的方式。由于这种客户端与资源进行了隔离,超时或过度使用池的操作页不会使其他操作失败。

泰坦尼克号沉没的主要原因之一是其舱壁设计失败,水可以通过上面的甲板倒在舱壁的顶部,导致整个船体淹没。

泰坦尼克号舱壁设计(无效的设计)

断路器

为了限制操作的持续时间,我们可以使用超时。超时可以防止挂起操作并保持系统响应。然而,在微服务中使用静态、精细的超时是一种反模式,因为我们处于高度动态的环境中,几乎不可能提出在每种情况下都能正常工作的正确的时间限制。

替代这种静态超时的手段是,我们可以使用断路器来处理错误。断路器以现实世界的电子元件命名,因为它们的作用是相同的。您可以保护资源,并帮助他们使用断路器进行恢复。它们在分布式系统中非常有用,因为在分布式系统中,重复故障可能导致雪球效应并使整个系统瘫痪。

当特定类型的错误在短时间内多次发生时,断路器会被断开。开路的断路器可以防止进一步的请求 - 就像我们平时所说的电路跳闸一样。断路器通常在一定时间后关闭,在这期间可以为底层服务提供足够的空间来恢复。

请记住,并不是所有的错误都应该触发断路器。例如,您可能希望跳过客户端问题,例如具有4xx响应代码的请求,但不包括5xx服务器端故障。一些断路器也具有半开状态。在这种状态下,服务发送第一个请求以检查系统可用性,同时让其他请求失败。如果这个第一个请求成功,它将使断路器恢复到关闭状态并使流量流动。否则,它保持打开。

断路器

测试故障

您应该不断测试您系统的常见问题,以确保您的服务可以抵抗各种故障。您应经常测试故障,让您的团队具备故障处理的能力。

对于测试,您可以使用外部服务来标识实例组,并随机终止此组中的一个实例。这样,您可以准备单个实例故障,但您甚至可以关闭整个区域来模拟云提供商的故障。

最流行的测试解决方案之一是 Netflix 的ChaosMonkey 弹性工具

结尾

实施和运行可靠的服务并不容易。 您需要付出很多努力,同时公司也要有相应的财力投入。

可靠性有很多层次和方面,因此找到最适合您团队的解决方案很重要。您应该使可靠性成为您的业务决策流程中的一个因素,并为其分配足够的预算和时间。

关键点

  • 动态环境和分布式系统(如微服务)会导致更高的故障机率;
  • 服务应该做到故障隔离,到达优雅降级,来提升用户体验;
  • 70%的中断是由变化引起的,代码回滚不是一件坏事;
  • 做到服务快速失败与独立性。团队是无法控制他们所依赖的服务情况;
  • 缓存、舱壁、断路器和限流器等架构模式与技术有助于构建可靠的微服务架构。
]]>
原文:Designing a Microservices Architecture for Failure
翻译:设计一个容错的微服务架构by Jason Geng

微服务架构使得可以通过明确定义的服务边界来隔离故障。但是像在每个分布式系统中一样,发生网络、硬件、应用级别的错误都是很常见的。由于服务依赖关系,任何组件可能暂时无法提供服务。为了尽量减少部分中断的影响,我们需要构建容错服务,来优雅地处理这些中断的响应结果。

本文介绍了基于RisingStack 的 Node.js 咨询和开发经验构建和操作高可用性微服务系统的最常见技术和架构模式。

如果你不熟悉本文中的模式,那并不一定意味着你做错了。建立可靠的系统总是会带来额外的成本。

微服务架构的风险

微服务架构将应用程序逻辑移动到服务,并使用网络层在它们之间进行通信。这种通过网络间通信代替单应用程序内调用的做法,会带来额外的延迟,以及需要协调多个物理和逻辑组件的系统复杂度。分布式系统的复杂性增加也将导致更高的网络故障率。

microservices allow you to achieve graceful service degradation as components can be set up to fail separately.

微服务体系结构的最大优势之一是,团队可以独立设计,开发和部署他们的服务。他们对服务的生命周期拥有完全的所有权。这也意味着团队无法控制他们依赖的服务,因为它更有可能由不同的团队管理。使用微服务架构,我们需要记住,提供者服务可能会临时不可用,由于其他人员发行的错误版本,配置以及其他更改等。

优雅的服务降级

微服务架构的最大优点之一是您可以隔离故障,并在当组件单独故障时,进行优雅的服务降级。 例如,在中断期间,照片共享应用程序中的客户可能无法上传新图片,但仍可以浏览,编辑和共享其现有照片。

微服务容错隔离

在大多数情况下,由于分布式系统中的应用程序相互依赖,因此很难实现这种优雅的服务降级,您需要应用几种故障转移的逻辑(其中一些将在本文后面介绍),以为暂时的故障和中断做准备。

服务间彼此依赖,再没有故障转移逻辑下,服务全部失败。

变更管理

Google的网站可靠性小组发现,大约70%的中断是由现有系统的变化引起的 。当您更改服务中的某些内容时,您将部署新版本的代码或更改某些配置 - 这总有可能会造成故障,或者引入新的bug。

在微服务架构中,服务依赖于彼此。这就是为什么你应该尽量减少故障并限制它的负面影响。要处理变更中的问题,您可以实施变更管理策略和自动回滚 机制。

例如,当您部署新代码或更改某些配置时,您应该先小范围的进行部分的替换,以渐进式的方式替换服务的全部实例。在这期间,需要监视它们,如果您发现它们对您的关键指标有负面影响,应立即进行服务回滚,这称为“金丝雀部署”。

变更管理 - 回滚部署

另一个解决方案可能是您运行两个生产环境。您始终只能部署其中一个,并且在验证新版本是否符合预期之后才,将负载均衡器指向新的。这称为蓝绿或红黑部署。

回滚代码不是坏事 。你不应该在生产中遗留错误的代码,然后考虑出了什么问题。如果必要,越早回滚你的代码越好。

健康检查与负载均衡

实例由于出现故障、部署或自动缩放的情况,会进行持续启动、重新启动或停止操作。它可能导致它们暂时或永久不可用。为避免问题,您的负载均衡器应该从路由中跳过不健康的实例 ,因为它们当前无法为客户或子系统提供服务。

应用实例健康状况可以通过外部观察来确定。您可以通过重复调用GET /health 端点或通过自我报告来实现。现在主流的服务发现 解决方案,会持续从实例中收集健康信息,并配置负载均衡器,将流量仅路由到健康的组件上。

自我修复

自我修复可以帮助应用程序从错误中恢复过来。当应用程序可以采取必要步骤 从故障状态恢复时,我们就可以说它是可以实现自我修复的。在大多数情况下,它由外部系统实现,该系统会监视实例运行状况,并在较长时间内处于故障状态时重新启动它们。自我修复在大多数情况下是非常有用的。但是在某些情况下,持续地重启应用程序可能会导致麻烦 。 当您的应用程序由于超负荷或其数据库连接超时而无法给出健康的运行状况时,这种情况下的频繁的重启就可能就不太合适了。

对于这种特殊的场景(如丢失的数据库连接),要实现满足它的高级自我修复的解决方案可能很棘手。在这种情况下,您需要为应用程序添加额外的逻辑来处理边缘情况,并让外部系统知道实例不需要立即重新启动。

故障转移缓存

由于网络问题和我们系统的变化,服务经常会失败。然而,由于自我修复和负载均衡的保障,它们中的大多数中断是临时的,我们应该找到一个解决方案,使我们的服务在这些故障时服务仍就可以工作。这就是故障转移缓存 (failover caching)的作用,它可以帮助并为我们的应用程序在服务故障时提供必要的数据。

故障转移缓存通常使用两个不同的过期日期 ; 较短的时间告诉您在正常情况下缓存可以使用的过期时间,而较长的时间可以在服务故障时缓存依旧可用的过期时间。

故障转移缓存

请务必提及,只有当服务使用过时的数据比没有数据更好时,才能使用故障转移缓存。

要设置缓存和故障转移缓存,可以在 HTTP 中使用标准响应头。

例如,使用max-age属性可以指定资源被视为有效的最大时间。使用stale-if-error属性,您可以明确在出现故障的情况下,依旧可以从缓存中获取资源的最大时间。

现代的 CDN 和负载均衡器都提供各种缓存和故障转移行为,但您也可以为拥有标准可靠性解决方案的公司创建一个共享库。

重试逻辑

在某些情况下,我们无法缓存数据,或者我们想对其进行更改,但是我们的操作最终都失败了。对于此,我们可以重试我们的操作,因为我们可以预期资源将在一段时间后恢复,或者我们的负载均衡器将请求发送到了健康的实例上。

您应该小心地为您的应用程序和客户端添加重试逻辑,因为大量的重试可能会使事情更糟 ,甚至阻止应用程序恢复,如当服务超载时,大量的重试只能使状况更糟。

在分布式系统中,微服务系统重试可以触发多个其他请求或重试,并启动级联效应 。为了最小化重试的影响,您应该限制它们的数量,并使用指数退避算法来持续增加重试之间的延迟,直到达到最大限制。

当客户端(浏览器,其他微服务等)发起重试,并且客户端不知道在处理请求之前或之后操作失败时,您应该为你的应用程序做好幂等处理 的准备。例如,当您重试购买操作时,您不应该再次向客户收取费用。为每个交易使用唯一的幂等值键 可以帮助处理重试。

限流器和负载降级

流量限制是在一段时间内定义特定客户或应用程序可以接收或处理多少个请求的技术。例如,通过流量限制,您可以过滤掉造成流量峰值的客户和服务,或者您可以确保您的应用程序在自动缩放无法满足时,依然不会超载。

您还可以阻止较低优先级的流量,为关键事务提供足够的资源。

限流器可以阻止流量峰值产生

有一个不同类型的限流器,叫做并发请求限制器。当您有重要的端点,您不应该被调用超过指定的次数,而您仍然想要能提供服务时,这将是有用的。

负载降级的一系列使用,可以确保总是有足够的资源来提供关键交易。它为高优先级请求保留一些资源,不允许低优先级的事务使用它们。负载降级开关是根据系统的整体状态做出决定,而不是基于单个用户的请求量大小。负载降级有助于您的系统恢复,因为当你有一个偶发事件时(可能是一个热点事件),您仍能保持核心功能的正常工作。

要了解有关限流器和负载降级的更多信息,我建议查看这篇Stripe的文章

快速失败原则与独立性

在微服务架构中,我们想要做到让我们的服务具备快速失败与相互独立的能力。为了在服务级别上进行故障隔离,我们可以使用舱壁模式。你可以在本文的后面阅读更多有关舱壁的内容。

我们也希望我们的组件能够快速失败,因为我们不希望对于有故障的服务,在请求超时后才断开。没有什么比挂起的请求和无响应的 UI 更令人失望。这不仅浪费资源,而且还会影响用户体验。我们的服务在调用链中是相互调用的,所以在这些延迟累加之前,我们应该特别注意防止挂起操作。

你想到的第一个想法是对每个服务调用都设置明确的超时等级。这种方法的问题是,您不能知道真正合理的超时值是多少,因为网络故障和其他问题发生的某些情况只会影响一两次操作。在这种情况下,如果只有其中一些超时,您可能不想拒绝这些请求。

我们可以说,在微服务种通过使用超时来达到快速失败的效果是一种反模式的,你应该避免使用它。取而代之,您可以应用断路器模式,依据操作的成功与失败统计数据决定。

舱壁模式

工业中使用舱壁将船舶划分为几个部分,以便在船体破坏的情况下,可以将船舶各个部件密封起来。

舱壁的概念在软件开发中可以被应用在隔离资源上。

通过应用舱壁模式,我们可以保护有限的资源不被耗尽。例如,对于一个有连接数限制的数据库实例来说,如果我们有两种连接它的操作,我们采用可以采用两个连接池的方式进行连接,来代替仅采用一个共享连接池的方式。由于这种客户端与资源进行了隔离,超时或过度使用池的操作页不会使其他操作失败。

泰坦尼克号沉没的主要原因之一是其舱壁设计失败,水可以通过上面的甲板倒在舱壁的顶部,导致整个船体淹没。

泰坦尼克号舱壁设计(无效的设计)

断路器

为了限制操作的持续时间,我们可以使用超时。超时可以防止挂起操作并保持系统响应。然而,在微服务中使用静态、精细的超时是一种反模式,因为我们处于高度动态的环境中,几乎不可能提出在每种情况下都能正常工作的正确的时间限制。

替代这种静态超时的手段是,我们可以使用断路器来处理错误。断路器以现实世界的电子元件命名,因为它们的作用是相同的。您可以保护资源,并帮助他们使用断路器进行恢复。它们在分布式系统中非常有用,因为在分布式系统中,重复故障可能导致雪球效应并使整个系统瘫痪。

当特定类型的错误在短时间内多次发生时,断路器会被断开。开路的断路器可以防止进一步的请求 - 就像我们平时所说的电路跳闸一样。断路器通常在一定时间后关闭,在这期间可以为底层服务提供足够的空间来恢复。

请记住,并不是所有的错误都应该触发断路器。例如,您可能希望跳过客户端问题,例如具有4xx响应代码的请求,但不包括5xx服务器端故障。一些断路器也具有半开状态。在这种状态下,服务发送第一个请求以检查系统可用性,同时让其他请求失败。如果这个第一个请求成功,它将使断路器恢复到关闭状态并使流量流动。否则,它保持打开。

断路器

测试故障

您应该不断测试您系统的常见问题,以确保您的服务可以抵抗各种故障。您应经常测试故障,让您的团队具备故障处理的能力。

对于测试,您可以使用外部服务来标识实例组,并随机终止此组中的一个实例。这样,您可以准备单个实例故障,但您甚至可以关闭整个区域来模拟云提供商的故障。

最流行的测试解决方案之一是 Netflix 的ChaosMonkey 弹性工具

结尾

实施和运行可靠的服务并不容易。 您需要付出很多努力,同时公司也要有相应的财力投入。

可靠性有很多层次和方面,因此找到最适合您团队的解决方案很重要。您应该使可靠性成为您的业务决策流程中的一个因素,并为其分配足够的预算和时间。

关键点

  • 动态环境和分布式系统(如微服务)会导致更高的故障机率;
  • 服务应该做到故障隔离,到达优雅降级,来提升用户体验;
  • 70%的中断是由变化引起的,代码回滚不是一件坏事;
  • 做到服务快速失败与独立性。团队是无法控制他们所依赖的服务情况;
  • 缓存、舱壁、断路器和限流器等架构模式与技术有助于构建可靠的微服务架构。
]]>
0
<![CDATA[你的鞋都比你聪明]]> http://www.udpwork.com/item/16390.html http://www.udpwork.com/item/16390.html#reviews Tue, 22 Aug 2017 07:28:09 +0800 阮一峰 http://www.udpwork.com/item/16390.html 1、

2017年2月,世界移动通讯大会(MWC)在巴塞罗那召开,今年的演讲嘉宾是日本首富软银集团 CEO 孙正义。

他的演讲主题是《为什么人工智能肯定会超越人类?》。他提到,人类的智能是一个正态分布,IQ 测试假设平均智能是100,标准差是15,因此95%的人的智商在正负两个标准差范围内(即70~130)。爱因斯坦的智商可以达到190,也就是六个标准差,这意味着他比99.99966%的人都要聪明。但是从整体来看,人类的智能是有限的。

人类的智能也几乎固定不变,不会随着时间发展,很难说现代人就比古人聪明,未来的人也未必更聪明。因为智能的生理基础是大脑,人的脑容量只有1300多毫升,包含了300亿个神经元细胞。一万多年前就是如此,再过一万年,大脑可能还是这样大小,不太可能越长越大。

人工智能的基础是大规模集成电路,指甲大小(1平方厘米)的芯片,可以集成上百万个电子元件。有人预测,这个数字每过两年就翻一倍。孙正义的预测是,30年后的集成电路,电子元件将是现在的100万倍,即1万亿个!相比大脑的神经元细胞(300亿个),他的结论是,人工智能大约在2018年就能达到人类的智力,30年后的2047年,人工智能的 IQ 将达到10000。

(图片说明:2047年的晶体管集成程度,将是2017年的100万倍。)

(图片说明:左下角是人的 IQ,右侧是人工智能的 IQ。)

"你想想,那个时候你的鞋子里内置芯片的电子元件数量,都比你的脑细胞还多。你穿的鞋子都比你聪明。"

2、

孙正义出身底层,祖父是从韩国大邱移民至日本当矿工的朝鲜人。他的巨额财富来自对于未来的准确判断和投资。他早期曾经投资过思科和雅虎,都发了大财。1999年,他遇到了马云,只谈了6分钟,就决定投资2000万美元,成了阿里巴巴最大股东,这笔钱的回报率后来超过2500倍

如果这一次孙正义依然正确,那么未来不仅仅是鞋子,你的住宅、汽车、手表、马桶等等,所有可以装上芯片的东西都会比你聪明。孙正义说,他的钱都投资在三个领域----人工智能、物联网和智能机器人----赌这个预言一定成真。

实际上,智能鞋子已经上市了,内置芯片,"搭载六轴传感器,可以测量日常步数,里程,消耗等数据,在开启跑步模式后,还可计算并记录跑步过程中前掌着地,触地时长和腾空比例的专业运动数据,根据这些数据实时调整运动方式方法。"还有的鞋子会自动系鞋带,"用户穿上时,会激活脚后跟的传感器,运动鞋就开始自动调整松紧"。

现在的鞋就这么先进了,再过30年,它们会变成什么样?

3、

我们周围的所有东西,以后都会装上传感器和芯片,都会具备智能,比人类更聪明。现在已经有了智能手机、智能电视机、智能手表、智能电饭煲、智能牙刷、智能内衣......这样的智能产品将会越来越多。

它们收集和处理各种数据,到头来变得比你更了解你。你还记得上个月的今天,你去了哪里,停留了多久,遇到谁,吃了什么吗?你每天几点入睡,每分钟的心跳是多少,有没有做梦?它们都知道。

这些海量的数据,经过统计学处理,就可以精确地刻画你,发现你最有可能的行为是什么。更重要的是,它们还会自动替你做出最优决策。要是你不知道下一步走哪条路,就让你的鞋做决定好了。

4、

以 GPS 为例,开车去市中心,根本不用自己选路线,导航软件早就选好了。就算很熟悉道路,你最好还是听从软件的安排,因为软件比你掌握更多的信息。有一回,我打的去火车站,出租车司机发现软件给出的路线不是最近的,自做主张抄近路,没料到有一段单行道正在施工,根本走不通。

这只是一个小例子:软件的选择优于你的选择。以后不仅仅是路线,所有的决定都将是软件替你来做。

你想晚上去锻炼,是健身房好,还是马路慢跑好?你的鞋就会告诉你,慢跑比较好,因为天气晴朗,风力适中,公共绿地里面的樱花开了,一路上可以闻到香气,而且你的小区正在流行感冒,健身房里面被感染的可能性大于40%。

再比如公司聚会,你不知道该找谁聊天,但是你的鞋知道。经过分析社交网站的资料,发现你与张小姐的爱好相似,你们上一周还看过相同的电影,碰巧她还是单身,于是你的鞋建议你走向张小姐,互相认识一下。

机器比你聪明,知道你的 DNA,了解你每顿饭摄入的热量,它比你更了解你,还了解其他相关信息,那么最优决策就是自己不要决策了,都听从机器的安排。它的决策才是对你最有利的决策。

5、

如果将来都是人工智能代替人在决策,那么个人、个性、自我这些词就没多大意义了。古希腊神庙刻着一行字,"认识你自己";苹果公司创始人乔布斯说,你要听从内心的声音。这些都不必要了。

算法刻画的你,才是真正的你。 《未来简史》里面说:

"别再浪费时间研究哲学、冥想或精神分析,你应该系统性地收集自己的生物统计数据,允许算法为你分析这些数据,告诉你你是谁、该做些什么。"

曾经有一本畅销书《内向者优势》,解释内向和外向根本不是性格问题,而是生理问题。内向者的多巴胺分泌比较少,在公开场合容易产生疲倦,而外向者的多巴胺分泌比较多,人越多越容易兴奋。将来,人工智能会精确知道每一类活动的多巴胺指数,选出最合适你的活动,这比你自己选择可靠多了。

6、

以前,人们认为,"智能"和"意识"差不多是同义词,不能独立存在,只有具备意识的生物才可能具备智能。

人工智能的兴起,使得这种想法不成立了。智能完全可以不需要意识,独立存在。没有意识的机器,也可以具备智能。 这种无意识的智能,依靠的不是认知,而是模式匹配。我新买了一台扫地机器人,它对我家的房型一清二楚,根本不会撞墙。原因不是它意识到那里有墙,而是经过第一天的反复试错以后,它记住了那里没有路。再比如,机器能够认出照片里的鸟,不是因为它认识鸟,而是因为它发现这个形状可以与数据库里面鸟的形状匹配。

意识与智能的分离,最受企业欢迎。因为企业需要的是智能,而不是意识。员工如果能够减少个人意识,增加更多的生产线上的智能,就能更符合企业的需要。

你的鞋一天天变得更加智能,你由于不用决策了,你本人的智能高低也就不重要了,你的自我意识也会变得淡漠,因为发展个性的结果,无非就是变成你的鞋预测你将会成为的样子。最终来说,人工智能不仅取代了一部分人的智能,还将使得人们缺乏个人意识,不知道自己主张什么,想要什么,因为软件都替你安排好了。 西方的民主制度可能也不必要了,因为一人一票的公民投票有一个前提:每个人知道自己想要什么。

(说明:本文选自我正在写的新书《未来世界的幸存者》,点击这里免费阅读全书。)

(完)

文档信息

]]>
1、

2017年2月,世界移动通讯大会(MWC)在巴塞罗那召开,今年的演讲嘉宾是日本首富软银集团 CEO 孙正义。

他的演讲主题是《为什么人工智能肯定会超越人类?》。他提到,人类的智能是一个正态分布,IQ 测试假设平均智能是100,标准差是15,因此95%的人的智商在正负两个标准差范围内(即70~130)。爱因斯坦的智商可以达到190,也就是六个标准差,这意味着他比99.99966%的人都要聪明。但是从整体来看,人类的智能是有限的。

人类的智能也几乎固定不变,不会随着时间发展,很难说现代人就比古人聪明,未来的人也未必更聪明。因为智能的生理基础是大脑,人的脑容量只有1300多毫升,包含了300亿个神经元细胞。一万多年前就是如此,再过一万年,大脑可能还是这样大小,不太可能越长越大。

人工智能的基础是大规模集成电路,指甲大小(1平方厘米)的芯片,可以集成上百万个电子元件。有人预测,这个数字每过两年就翻一倍。孙正义的预测是,30年后的集成电路,电子元件将是现在的100万倍,即1万亿个!相比大脑的神经元细胞(300亿个),他的结论是,人工智能大约在2018年就能达到人类的智力,30年后的2047年,人工智能的 IQ 将达到10000。

(图片说明:2047年的晶体管集成程度,将是2017年的100万倍。)

(图片说明:左下角是人的 IQ,右侧是人工智能的 IQ。)

"你想想,那个时候你的鞋子里内置芯片的电子元件数量,都比你的脑细胞还多。你穿的鞋子都比你聪明。"

2、

孙正义出身底层,祖父是从韩国大邱移民至日本当矿工的朝鲜人。他的巨额财富来自对于未来的准确判断和投资。他早期曾经投资过思科和雅虎,都发了大财。1999年,他遇到了马云,只谈了6分钟,就决定投资2000万美元,成了阿里巴巴最大股东,这笔钱的回报率后来超过2500倍

如果这一次孙正义依然正确,那么未来不仅仅是鞋子,你的住宅、汽车、手表、马桶等等,所有可以装上芯片的东西都会比你聪明。孙正义说,他的钱都投资在三个领域----人工智能、物联网和智能机器人----赌这个预言一定成真。

实际上,智能鞋子已经上市了,内置芯片,"搭载六轴传感器,可以测量日常步数,里程,消耗等数据,在开启跑步模式后,还可计算并记录跑步过程中前掌着地,触地时长和腾空比例的专业运动数据,根据这些数据实时调整运动方式方法。"还有的鞋子会自动系鞋带,"用户穿上时,会激活脚后跟的传感器,运动鞋就开始自动调整松紧"。

现在的鞋就这么先进了,再过30年,它们会变成什么样?

3、

我们周围的所有东西,以后都会装上传感器和芯片,都会具备智能,比人类更聪明。现在已经有了智能手机、智能电视机、智能手表、智能电饭煲、智能牙刷、智能内衣......这样的智能产品将会越来越多。

它们收集和处理各种数据,到头来变得比你更了解你。你还记得上个月的今天,你去了哪里,停留了多久,遇到谁,吃了什么吗?你每天几点入睡,每分钟的心跳是多少,有没有做梦?它们都知道。

这些海量的数据,经过统计学处理,就可以精确地刻画你,发现你最有可能的行为是什么。更重要的是,它们还会自动替你做出最优决策。要是你不知道下一步走哪条路,就让你的鞋做决定好了。

4、

以 GPS 为例,开车去市中心,根本不用自己选路线,导航软件早就选好了。就算很熟悉道路,你最好还是听从软件的安排,因为软件比你掌握更多的信息。有一回,我打的去火车站,出租车司机发现软件给出的路线不是最近的,自做主张抄近路,没料到有一段单行道正在施工,根本走不通。

这只是一个小例子:软件的选择优于你的选择。以后不仅仅是路线,所有的决定都将是软件替你来做。

你想晚上去锻炼,是健身房好,还是马路慢跑好?你的鞋就会告诉你,慢跑比较好,因为天气晴朗,风力适中,公共绿地里面的樱花开了,一路上可以闻到香气,而且你的小区正在流行感冒,健身房里面被感染的可能性大于40%。

再比如公司聚会,你不知道该找谁聊天,但是你的鞋知道。经过分析社交网站的资料,发现你与张小姐的爱好相似,你们上一周还看过相同的电影,碰巧她还是单身,于是你的鞋建议你走向张小姐,互相认识一下。

机器比你聪明,知道你的 DNA,了解你每顿饭摄入的热量,它比你更了解你,还了解其他相关信息,那么最优决策就是自己不要决策了,都听从机器的安排。它的决策才是对你最有利的决策。

5、

如果将来都是人工智能代替人在决策,那么个人、个性、自我这些词就没多大意义了。古希腊神庙刻着一行字,"认识你自己";苹果公司创始人乔布斯说,你要听从内心的声音。这些都不必要了。

算法刻画的你,才是真正的你。 《未来简史》里面说:

"别再浪费时间研究哲学、冥想或精神分析,你应该系统性地收集自己的生物统计数据,允许算法为你分析这些数据,告诉你你是谁、该做些什么。"

曾经有一本畅销书《内向者优势》,解释内向和外向根本不是性格问题,而是生理问题。内向者的多巴胺分泌比较少,在公开场合容易产生疲倦,而外向者的多巴胺分泌比较多,人越多越容易兴奋。将来,人工智能会精确知道每一类活动的多巴胺指数,选出最合适你的活动,这比你自己选择可靠多了。

6、

以前,人们认为,"智能"和"意识"差不多是同义词,不能独立存在,只有具备意识的生物才可能具备智能。

人工智能的兴起,使得这种想法不成立了。智能完全可以不需要意识,独立存在。没有意识的机器,也可以具备智能。 这种无意识的智能,依靠的不是认知,而是模式匹配。我新买了一台扫地机器人,它对我家的房型一清二楚,根本不会撞墙。原因不是它意识到那里有墙,而是经过第一天的反复试错以后,它记住了那里没有路。再比如,机器能够认出照片里的鸟,不是因为它认识鸟,而是因为它发现这个形状可以与数据库里面鸟的形状匹配。

意识与智能的分离,最受企业欢迎。因为企业需要的是智能,而不是意识。员工如果能够减少个人意识,增加更多的生产线上的智能,就能更符合企业的需要。

你的鞋一天天变得更加智能,你由于不用决策了,你本人的智能高低也就不重要了,你的自我意识也会变得淡漠,因为发展个性的结果,无非就是变成你的鞋预测你将会成为的样子。最终来说,人工智能不仅取代了一部分人的智能,还将使得人们缺乏个人意识,不知道自己主张什么,想要什么,因为软件都替你安排好了。 西方的民主制度可能也不必要了,因为一人一票的公民投票有一个前提:每个人知道自己想要什么。

(说明:本文选自我正在写的新书《未来世界的幸存者》,点击这里免费阅读全书。)

(完)

文档信息

]]>
0
<![CDATA[711 的成功之道 - 读《零售的哲学》]]> http://www.udpwork.com/item/16389.html http://www.udpwork.com/item/16389.html#reviews Sun, 20 Aug 2017 09:38:08 +0800 唐巧 http://www.udpwork.com/item/16389.html

这周读完了 711 创始人铃木敏文的自述图书《零售的哲学》,分享一下我的读书心得。

我发现铃木敏文做生意的方法论特别简单,整本书其实讲的道理就几个,只不过反复讲。读完整个总结下来,作者想表达的就只有几点:

  1. 搞明白问题的本质。
  2. 执行上做到极致。
  3. 把心理学融入到商业中。
  4. 主动寻求变化。
  5. 遵循“假设->执行->检验”的步骤来做尝试。

下面我就分别总结书中的以上观点。

搞明白问题的本质

铃木敏文从第一章开始,到最后一章结束,都在贯穿他的反对轻信常识的观点。本书的第一章叫做「一切从“打破常识”开始」,最后一章叫做「打破“常识”」。可见“常识”是多么的有误导性!在书中,铃木敏文介绍了多次他打破“常识”,发现问题本质的例子。

比如,在决定经营 711 便利店时,当时日本的小型商店经营得非常惨淡。普通人认为是大型超市挤压了便利店的生存空间,当时确实 711 的母公司伊藤洋化堂在大型超市上很挣钱。但是铃木敏文认为小型商店的关键问题是:1、生产效率低下 2、没有差异化的竞争点。他认为解决了这两个关键问题之后,便利店是有很大的机会的。

为了解决这两个关键问题,711 实施了:1、密集选址战略 2、构建产品研发与供应的基础体系 3、注重与员工的直接沟通。第1,2点解决了小型商店的生产效率问题,而第3点持续产生一线的数据和想法,有利于构建差异化的竞争方案。最终成就了 711 成为了一家全球成功的便利店。

在决定收购 711 原本的品牌授权方:美国的南方公司时。铃木敏文同样洞查出了南方公司失败的本质:他们没有构建出有竞争力的主营业务以及美国社会产品结构的变化。随着收购后对美国公司实施单品管理、物流配送改革等措施后,很快就有效果了。

铃木敏文在书中讲看清楚本质的故事很多,也讲了很多他自己的分析,但是如何能够看清本质?对于这个问题,他并没有这方面详细的方法论。书中有一些经验似乎是不太好借鉴的,比如他喜欢通过广播大量地收集信息,喜欢开会演讲。当然也有一些是可以学习的,比如他总是会亲自到 711 店里面去体验商品和购物流程。

我自己认为,要搞明白问题的本质需要做到 3 点:1、有效的信息获取。2、适当的讨论。3、独立的思考决策以及检验。这点有机会再展开论述。

执行上做到极致

铃木敏文在书中的很多做法显示出他是一个“不计成本”的人,他经常为了一个理念,花几年时间来做一件事情。几年时间啊!一件事情从 0 到 1 得花费几年时间,对于大多数公司都是不可接受的。而他愿意坚持做这样的事情。在书中,他介绍了 711:

  • 为了采取全年无休的经营方式。花了两年,才说服山崎面包在假期供货。
  • 为了在便利店中提供 ATM 取款服务,在和别的银行谈判无果的情况下,花了两年独立申请银行服务牌照。
  • 为了让炒饭的口感有“大火爆炒”的传统口感,花了一年八个月的研发。
  • 为了解决中国人喜欢吃热菜的习惯,同时符合中国政府食品安全要求,研发出中央厨房配制,无需明火就可以在便利店简单加热售卖的现煮食品。当然,这一方法很快被所有中国便利店「借鉴」了。

我看完书后,倒不认为铃木敏文是光有匹夫之勇的人,他能够判断一件事情成功的收益巨大,所以愿意在研发阶段投入足够多的时间和人力成本。而对于大部分事情,确实也并不存在一定搞不定的情况。投入了足够多的精力,通常都可以找到一定的解法。

把心理学融入到商业中

这部分内容,铃木敏文举的最成功的例子,是让商店返还 5% 的消费税的形式,代替原本让消费者麻木的降阶促销。这两件事情看起来本质上都是降价,但是返还消费税的形式迎合了顾客的情感诉求,令他们在心理上更容易接受。

主动寻求变化

因为时代不停在变化,当市场环境变化时,原本的商业形式如果不做变化,那么就可能面临失败。铃木敏文说:711 是一家不断主动做出改变的公司。他确实在不断调整经营的方案,甚至不反对“朝令夕改”。书中的第六章的标题赫然写着:经营理应“朝令夕改”。

由于主动寻求变化,711 做出了很多有先见之明的事情,例如推出送货上门服务,增加网络购物,针对日本的老年人提供送餐服务等。

主动寻求变化和刚开始说的搞明白问题的本质其实是衔接在一起的,我们在搞明白问题的本质后,不能把这件事情就归为结论了。而是应该随着时间变化,不断地反复思考当时的结论是否仍然成立。很可能市场变化了,结论也就变了。

遵循“假设->执行->检验”的步骤来做尝试

“假设->执行-检验”其实和精益创业中提到的 MVP(最小可执行产品)类似。在我们寻求变化,发现新的问题后,应该在小范围内尝试解决他。711 的很多尝试都是先在几家便利店中试行,然后再全国推广。这种最小风险的尝试,有利于公司大胆地试错。

总结

如果拿一句话来总结,711 的创始人铃木敏文的经营哲学就是:主动观察市场的变化,在搞清楚问题的本质后,做到极致的执行,在执行过程中利用 MVP 来试错,并且融入心理学知识。

听我总结不如自己买来翻翻,推荐给大家~ 以下是该书的思维导图。

]]>

这周读完了 711 创始人铃木敏文的自述图书《零售的哲学》,分享一下我的读书心得。

我发现铃木敏文做生意的方法论特别简单,整本书其实讲的道理就几个,只不过反复讲。读完整个总结下来,作者想表达的就只有几点:

  1. 搞明白问题的本质。
  2. 执行上做到极致。
  3. 把心理学融入到商业中。
  4. 主动寻求变化。
  5. 遵循“假设->执行->检验”的步骤来做尝试。

下面我就分别总结书中的以上观点。

搞明白问题的本质

铃木敏文从第一章开始,到最后一章结束,都在贯穿他的反对轻信常识的观点。本书的第一章叫做「一切从“打破常识”开始」,最后一章叫做「打破“常识”」。可见“常识”是多么的有误导性!在书中,铃木敏文介绍了多次他打破“常识”,发现问题本质的例子。

比如,在决定经营 711 便利店时,当时日本的小型商店经营得非常惨淡。普通人认为是大型超市挤压了便利店的生存空间,当时确实 711 的母公司伊藤洋化堂在大型超市上很挣钱。但是铃木敏文认为小型商店的关键问题是:1、生产效率低下 2、没有差异化的竞争点。他认为解决了这两个关键问题之后,便利店是有很大的机会的。

为了解决这两个关键问题,711 实施了:1、密集选址战略 2、构建产品研发与供应的基础体系 3、注重与员工的直接沟通。第1,2点解决了小型商店的生产效率问题,而第3点持续产生一线的数据和想法,有利于构建差异化的竞争方案。最终成就了 711 成为了一家全球成功的便利店。

在决定收购 711 原本的品牌授权方:美国的南方公司时。铃木敏文同样洞查出了南方公司失败的本质:他们没有构建出有竞争力的主营业务以及美国社会产品结构的变化。随着收购后对美国公司实施单品管理、物流配送改革等措施后,很快就有效果了。

铃木敏文在书中讲看清楚本质的故事很多,也讲了很多他自己的分析,但是如何能够看清本质?对于这个问题,他并没有这方面详细的方法论。书中有一些经验似乎是不太好借鉴的,比如他喜欢通过广播大量地收集信息,喜欢开会演讲。当然也有一些是可以学习的,比如他总是会亲自到 711 店里面去体验商品和购物流程。

我自己认为,要搞明白问题的本质需要做到 3 点:1、有效的信息获取。2、适当的讨论。3、独立的思考决策以及检验。这点有机会再展开论述。

执行上做到极致

铃木敏文在书中的很多做法显示出他是一个“不计成本”的人,他经常为了一个理念,花几年时间来做一件事情。几年时间啊!一件事情从 0 到 1 得花费几年时间,对于大多数公司都是不可接受的。而他愿意坚持做这样的事情。在书中,他介绍了 711:

  • 为了采取全年无休的经营方式。花了两年,才说服山崎面包在假期供货。
  • 为了在便利店中提供 ATM 取款服务,在和别的银行谈判无果的情况下,花了两年独立申请银行服务牌照。
  • 为了让炒饭的口感有“大火爆炒”的传统口感,花了一年八个月的研发。
  • 为了解决中国人喜欢吃热菜的习惯,同时符合中国政府食品安全要求,研发出中央厨房配制,无需明火就可以在便利店简单加热售卖的现煮食品。当然,这一方法很快被所有中国便利店「借鉴」了。

我看完书后,倒不认为铃木敏文是光有匹夫之勇的人,他能够判断一件事情成功的收益巨大,所以愿意在研发阶段投入足够多的时间和人力成本。而对于大部分事情,确实也并不存在一定搞不定的情况。投入了足够多的精力,通常都可以找到一定的解法。

把心理学融入到商业中

这部分内容,铃木敏文举的最成功的例子,是让商店返还 5% 的消费税的形式,代替原本让消费者麻木的降阶促销。这两件事情看起来本质上都是降价,但是返还消费税的形式迎合了顾客的情感诉求,令他们在心理上更容易接受。

主动寻求变化

因为时代不停在变化,当市场环境变化时,原本的商业形式如果不做变化,那么就可能面临失败。铃木敏文说:711 是一家不断主动做出改变的公司。他确实在不断调整经营的方案,甚至不反对“朝令夕改”。书中的第六章的标题赫然写着:经营理应“朝令夕改”。

由于主动寻求变化,711 做出了很多有先见之明的事情,例如推出送货上门服务,增加网络购物,针对日本的老年人提供送餐服务等。

主动寻求变化和刚开始说的搞明白问题的本质其实是衔接在一起的,我们在搞明白问题的本质后,不能把这件事情就归为结论了。而是应该随着时间变化,不断地反复思考当时的结论是否仍然成立。很可能市场变化了,结论也就变了。

遵循“假设->执行->检验”的步骤来做尝试

“假设->执行-检验”其实和精益创业中提到的 MVP(最小可执行产品)类似。在我们寻求变化,发现新的问题后,应该在小范围内尝试解决他。711 的很多尝试都是先在几家便利店中试行,然后再全国推广。这种最小风险的尝试,有利于公司大胆地试错。

总结

如果拿一句话来总结,711 的创始人铃木敏文的经营哲学就是:主动观察市场的变化,在搞清楚问题的本质后,做到极致的执行,在执行过程中利用 MVP 来试错,并且融入心理学知识。

听我总结不如自己买来翻翻,推荐给大家~ 以下是该书的思维导图。

]]>
0
<![CDATA[全文搜索引擎 Elasticsearch 入门教程]]> http://www.udpwork.com/item/16388.html http://www.udpwork.com/item/16388.html#reviews Thu, 17 Aug 2017 07:36:20 +0800 阮一峰 http://www.udpwork.com/item/16388.html 全文搜索属于最常见的需求,开源的Elasticsearch(以下简称 Elastic)是目前全文搜索引擎的首选。

它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow、Github 都采用它。

Elastic 的底层是开源库Lucene。但是,你没法直接用 Lucene,必须自己写代码去调用它的接口。Elastic 是 Lucene 的封装,提供了 REST API 的操作接口,开箱即用。

本文从零开始,讲解如何使用 Elastic 搭建自己的全文搜索引擎。每一步都有详细的说明,大家跟着做就能学会。

一、安装

Elastic 需要 Java 8 环境。如果你的机器还没安装 Java,可以参考这篇文章,注意要保证环境变量JAVA_HOME正确设置。

安装完 Java,就可以跟着官方文档安装 Elastic。直接下载压缩包比较简单。

$ wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.5.1.zip
$ unzip elasticsearch-5.5.1.zip
$ cd elasticsearch-5.5.1/ 

接着,进入解压后的目录,运行下面的命令,启动 Elastic。

$ ./bin/elasticsearch

如果这时报错"max virtual memory areas vm.maxmapcount [65530] is too low",要运行下面的命令。

$ sudo sysctl -w vm.max_map_count=262144

如果一切正常,Elastic 就会在默认的9200端口运行。这时,打开另一个命令行窗口,请求该端口,会得到说明信息。

$ curl localhost:9200

{
  "name" : "atntrTf",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "tf9250XhQ6ee4h7YI11anA",
  "version" : {
    "number" : "5.5.1",
    "build_hash" : "19c13d0",
    "build_date" : "2017-07-18T20:44:24.823Z",
    "build_snapshot" : false,
    "lucene_version" : "6.6.0"
  },
  "tagline" : "You Know, for Search"
}

上面代码中,请求9200端口,Elastic 返回一个 JSON 对象,包含当前节点、集群、版本等信息。

按下 Ctrl + C,Elastic 就会停止运行。

默认情况下,Elastic 只允许本机访问,如果需要远程访问,可以修改 Elastic 安装目录的config/elasticsearch.yml文件,去掉network.host的注释,将它的值改成0.0.0.0,然后重新启动 Elastic。

network.host: 0.0.0.0

上面代码中,设成0.0.0.0让任何人都可以访问。线上服务不要这样设置,要设成具体的 IP。

二、基本概念

2.1 Node 与 Cluster

Elastic 本质上是一个分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个 Elastic 实例。

单个 Elastic 实例称为一个节点(node)。一组节点构成一个集群(cluster)。

2.2 Index

Elastic 会索引所有字段,经过处理后写入一个反向索引(Inverted Index)。查找数据的时候,直接查找该索引。

所以,Elastic 数据管理的顶层单位就叫做 Index(索引)。它是单个数据库的同义词。每个 Index (即数据库)的名字必须是小写。

下面的命令可以查看当前节点的所有 Index。

$ curl -X GET 'http://localhost:9200/_cat/indices?v'

2.3 Document

Index 里面单条的记录称为 Document(文档)。许多条 Document 构成了一个 Index。

Document 使用 JSON 格式表示,下面是一个例子。

{
  "user": "张三",
  "title": "工程师",
  "desc": "数据库管理"
}

同一个 Index 里面的 Document,不要求有相同的结构(scheme),但是最好保持相同,这样有利于提高搜索效率。

2.4 Type

Document 可以分组,比如weather这个 Index 里面,可以按城市分组(北京和上海),也可以按气候分组(晴天和雨天)。这种分组就叫做 Type,它是虚拟的逻辑分组,用来过滤 Document。

不同的 Type 应该有相似的结构(schema),举例来说,id字段不能在这个组是字符串,在另一个组是数值。这是与关系型数据库的表的一个区别。性质完全不同的数据(比如products和logs)应该存成两个 Index,而不是一个 Index 里面的两个 Type(虽然可以做到)。

下面的命令可以列出每个 Index 所包含的 Type。

$ curl 'localhost:9200/_mapping?pretty=true'

根据规划,Elastic 6.x 版只允许每个 Index 包含一个 Type,7.x 版将会彻底移除 Type。

三、新建和删除 Index

新建 Index,可以直接向 Elastic 服务器发出 PUT 请求。下面的例子是新建一个名叫weather的 Index。

$ curl -X PUT 'localhost:9200/weather'

服务器返回一个 JSON 对象,里面的acknowledged字段表示操作成功。

{
  "acknowledged":true,
  "shards_acknowledged":true
}

然后,我们发出 DELETE 请求,删除这个 Index。

$ curl -X DELETE 'localhost:9200/weather'

四、中文分词设置

首先,安装中文分词插件。这里使用的是ik,也可以考虑其他插件(比如smartcn)。

$ ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v5.5.1/elasticsearch-analysis-ik-5.5.1.zip

上面代码安装的是5.5.1版的插件,与 Elastic 5.5.1 配合使用。

接着,重新启动 Elastic,就会自动加载这个新安装的插件。

然后,新建一个 Index,指定需要分词的字段。这一步根据数据结构而异,下面的命令只针对本文。基本上,凡是需要搜索的中文字段,都要单独设置一下。

$ curl -X PUT 'localhost:9200/accounts' -d '
{
  "mappings": {
    "person": {
      "properties": {
        "user": {
          "type": "text",
          "analyzer": "ik_max_word",
          "search_analyzer": "ik_max_word"
        },
        "title": {
          "type": "text",
          "analyzer": "ik_max_word",
          "search_analyzer": "ik_max_word"
        },
        "desc": {
          "type": "text",
          "analyzer": "ik_max_word",
          "search_analyzer": "ik_max_word"
        }
      }
    }
  }
}'

上面代码中,首先新建一个名称为accounts的 Index,里面有一个名称为person的 Type。person有三个字段。

  • user
  • title
  • desc

这三个字段都是中文,而且类型都是文本(text),所以需要指定中文分词器,不能使用默认的英文分词器。

Elastic 的分词器称为analyzer。我们对每个字段指定分词器。

"user": {
  "type": "text",
  "analyzer": "ik_max_word",
  "search_analyzer": "ik_max_word"
}

上面代码中,analyzer是字段文本的分词器,search_analyzer是搜索词的分词器。ik_max_word分词器是插件ik提供的,可以对文本进行最大数量的分词。

五、数据操作

5.1 新增记录

向指定的 /Index/Type 发送 PUT 请求,就可以在 Index 里面新增一条记录。比如,向/accounts/person发送请求,就可以新增一条人员记录。

$ curl -X PUT 'localhost:9200/accounts/person/1' -d '
{
  "user": "张三",
  "title": "工程师",
  "desc": "数据库管理"
}' 

服务器返回的 JSON 对象,会给出 Index、Type、Id、Version 等信息。

{
  "_index":"accounts",
  "_type":"person",
  "_id":"1",
  "_version":1,
  "result":"created",
  "_shards":{"total":2,"successful":1,"failed":0},
  "created":true
}

如果你仔细看,会发现请求路径是/accounts/person/1,最后的1是该条记录的 Id。它不一定是数字,任意字符串(比如abc)都可以。

新增记录的时候,也可以不指定 Id,这时要改成 POST 请求。

$ curl -X POST 'localhost:9200/accounts/person' -d '
{
  "user": "李四",
  "title": "工程师",
  "desc": "系统管理"
}'

上面代码中,向/accounts/person发出一个 POST 请求,添加一个记录。这时,服务器返回的 JSON 对象里面,_id字段就是一个随机字符串。

{
  "_index":"accounts",
  "_type":"person",
  "_id":"AV3qGfrC6jMbsbXb6k1p",
  "_version":1,
  "result":"created",
  "_shards":{"total":2,"successful":1,"failed":0},
  "created":true
}

注意,如果没有先创建 Index(这个例子是accounts),直接执行上面的命令,Elastic 也不会报错,而是直接生成指定的 Index。所以,打字的时候要小心,不要写错 Index 的名称。

5.2 查看记录

向/Index/Type/Id发出 GET 请求,就可以查看这条记录。

$ curl 'localhost:9200/accounts/person/1?pretty=true'

上面代码请求查看/accounts/person/1这条记录,URL 的参数pretty=true表示以易读的格式返回。

返回的数据中,found字段表示查询成功,_source字段返回原始记录。

{
  "_index" : "accounts",
  "_type" : "person",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "user" : "张三",
    "title" : "工程师",
    "desc" : "数据库管理"
  }
}

如果 Id 不正确,就查不到数据,found字段就是false。

$ curl 'localhost:9200/weather/beijing/abc?pretty=true'

{
  "_index" : "accounts",
  "_type" : "person",
  "_id" : "abc",
  "found" : false
}

5.3 删除记录

删除记录就是发出 DELETE 请求。

$ curl -X DELETE 'localhost:9200/accounts/person/1'

这里先不要删除这条记录,后面还要用到。

5.4 更新记录

更新记录就是使用 PUT 请求,重新发送一次数据。

$ curl -X PUT 'localhost:9200/accounts/person/1' -d '
{
    "user" : "张三",
    "title" : "工程师",
    "desc" : "数据库管理,软件开发"
}' 

{
  "_index":"accounts",
  "_type":"person",
  "_id":"1",
  "_version":2,
  "result":"updated",
  "_shards":{"total":2,"successful":1,"failed":0},
  "created":false
}

上面代码中,我们将原始数据从"数据库管理"改成"数据库管理,软件开发"。 返回结果里面,有几个字段发生了变化。

"_version" : 2,
"result" : "updated",
"created" : false

可以看到,记录的 Id 没变,但是版本(version)从1变成2,操作类型(result)从created变成updated,created字段变成false,因为这次不是新建记录。

六、数据查询

6.1 返回所有记录

使用 GET 方法,直接请求/Index/Type/_search,就会返回所有记录。

$ curl 'localhost:9200/accounts/person/_search'

{
  "took":2,
  "timed_out":false,
  "_shards":{"total":5,"successful":5,"failed":0},
  "hits":{
    "total":2,
    "max_score":1.0,
    "hits":[
      {
        "_index":"accounts",
        "_type":"person",
        "_id":"AV3qGfrC6jMbsbXb6k1p",
        "_score":1.0,
        "_source": {
          "user": "李四",
          "title": "工程师",
          "desc": "系统管理"
        }
      },
      {
        "_index":"accounts",
        "_type":"person",
        "_id":"1",
        "_score":1.0,
        "_source": {
          "user" : "张三",
          "title" : "工程师",
          "desc" : "数据库管理,软件开发"
        }
      }
    ]
  }
}

上面代码中,返回结果的took字段表示该操作的耗时(单位为毫秒),timed_out字段表示是否超时,hits字段表示命中的记录,里面子字段的含义如下。

  • total:返回记录数,本例是2条。
  • max_score:最高的匹配程度,本例是1.0。
  • hits:返回的记录组成的数组。

返回的记录中,每条记录都有一个_score字段,表示匹配的程序,默认是按照这个字段降序排列。

6.2 全文搜索

Elastic 的查询非常特别,使用自己的查询语法,要求 GET 请求带有数据体。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "软件" }}
}'

上面代码使用Match 查询,指定的匹配条件是desc字段里面包含"软件"这个词。返回结果如下。

{
  "took":3,
  "timed_out":false,
  "_shards":{"total":5,"successful":5,"failed":0},
  "hits":{
    "total":1,
    "max_score":0.28582606,
    "hits":[
      {
        "_index":"accounts",
        "_type":"person",
        "_id":"1",
        "_score":0.28582606,
        "_source": {
          "user" : "张三",
          "title" : "工程师",
          "desc" : "数据库管理,软件开发"
        }
      }
    ]
  }
}

Elastic 默认一次返回10条结果,可以通过size字段改变这个设置。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "管理" }},
  "size": 1
}'

上面代码指定,每次只返回一条结果。

还可以通过from字段,指定位移。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "管理" }},
  "from": 1,
  "size": 1
}'

上面代码指定,从位置1开始(默认是从位置0开始),只返回一条结果。

6.3 逻辑运算

如果有多个搜索关键字, Elastic 认为它们是or关系。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "软件 系统" }}
}'

上面代码搜索的是软件 or 系统。

如果要执行多个关键词的and搜索,必须使用布尔查询

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query": {
    "bool": {
      "must": [
        { "match": { "desc": "软件" } },
        { "match": { "desc": "系统" } }
      ]
    }
  }
}'

七、参考链接

(完)

文档信息

]]>
全文搜索属于最常见的需求,开源的Elasticsearch(以下简称 Elastic)是目前全文搜索引擎的首选。

它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow、Github 都采用它。

Elastic 的底层是开源库Lucene。但是,你没法直接用 Lucene,必须自己写代码去调用它的接口。Elastic 是 Lucene 的封装,提供了 REST API 的操作接口,开箱即用。

本文从零开始,讲解如何使用 Elastic 搭建自己的全文搜索引擎。每一步都有详细的说明,大家跟着做就能学会。

一、安装

Elastic 需要 Java 8 环境。如果你的机器还没安装 Java,可以参考这篇文章,注意要保证环境变量JAVA_HOME正确设置。

安装完 Java,就可以跟着官方文档安装 Elastic。直接下载压缩包比较简单。

$ wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.5.1.zip
$ unzip elasticsearch-5.5.1.zip
$ cd elasticsearch-5.5.1/ 

接着,进入解压后的目录,运行下面的命令,启动 Elastic。

$ ./bin/elasticsearch

如果这时报错"max virtual memory areas vm.maxmapcount [65530] is too low",要运行下面的命令。

$ sudo sysctl -w vm.max_map_count=262144

如果一切正常,Elastic 就会在默认的9200端口运行。这时,打开另一个命令行窗口,请求该端口,会得到说明信息。

$ curl localhost:9200

{
  "name" : "atntrTf",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "tf9250XhQ6ee4h7YI11anA",
  "version" : {
    "number" : "5.5.1",
    "build_hash" : "19c13d0",
    "build_date" : "2017-07-18T20:44:24.823Z",
    "build_snapshot" : false,
    "lucene_version" : "6.6.0"
  },
  "tagline" : "You Know, for Search"
}

上面代码中,请求9200端口,Elastic 返回一个 JSON 对象,包含当前节点、集群、版本等信息。

按下 Ctrl + C,Elastic 就会停止运行。

默认情况下,Elastic 只允许本机访问,如果需要远程访问,可以修改 Elastic 安装目录的config/elasticsearch.yml文件,去掉network.host的注释,将它的值改成0.0.0.0,然后重新启动 Elastic。

network.host: 0.0.0.0

上面代码中,设成0.0.0.0让任何人都可以访问。线上服务不要这样设置,要设成具体的 IP。

二、基本概念

2.1 Node 与 Cluster

Elastic 本质上是一个分布式数据库,允许多台服务器协同工作,每台服务器可以运行多个 Elastic 实例。

单个 Elastic 实例称为一个节点(node)。一组节点构成一个集群(cluster)。

2.2 Index

Elastic 会索引所有字段,经过处理后写入一个反向索引(Inverted Index)。查找数据的时候,直接查找该索引。

所以,Elastic 数据管理的顶层单位就叫做 Index(索引)。它是单个数据库的同义词。每个 Index (即数据库)的名字必须是小写。

下面的命令可以查看当前节点的所有 Index。

$ curl -X GET 'http://localhost:9200/_cat/indices?v'

2.3 Document

Index 里面单条的记录称为 Document(文档)。许多条 Document 构成了一个 Index。

Document 使用 JSON 格式表示,下面是一个例子。

{
  "user": "张三",
  "title": "工程师",
  "desc": "数据库管理"
}

同一个 Index 里面的 Document,不要求有相同的结构(scheme),但是最好保持相同,这样有利于提高搜索效率。

2.4 Type

Document 可以分组,比如weather这个 Index 里面,可以按城市分组(北京和上海),也可以按气候分组(晴天和雨天)。这种分组就叫做 Type,它是虚拟的逻辑分组,用来过滤 Document。

不同的 Type 应该有相似的结构(schema),举例来说,id字段不能在这个组是字符串,在另一个组是数值。这是与关系型数据库的表的一个区别。性质完全不同的数据(比如products和logs)应该存成两个 Index,而不是一个 Index 里面的两个 Type(虽然可以做到)。

下面的命令可以列出每个 Index 所包含的 Type。

$ curl 'localhost:9200/_mapping?pretty=true'

根据规划,Elastic 6.x 版只允许每个 Index 包含一个 Type,7.x 版将会彻底移除 Type。

三、新建和删除 Index

新建 Index,可以直接向 Elastic 服务器发出 PUT 请求。下面的例子是新建一个名叫weather的 Index。

$ curl -X PUT 'localhost:9200/weather'

服务器返回一个 JSON 对象,里面的acknowledged字段表示操作成功。

{
  "acknowledged":true,
  "shards_acknowledged":true
}

然后,我们发出 DELETE 请求,删除这个 Index。

$ curl -X DELETE 'localhost:9200/weather'

四、中文分词设置

首先,安装中文分词插件。这里使用的是ik,也可以考虑其他插件(比如smartcn)。

$ ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v5.5.1/elasticsearch-analysis-ik-5.5.1.zip

上面代码安装的是5.5.1版的插件,与 Elastic 5.5.1 配合使用。

接着,重新启动 Elastic,就会自动加载这个新安装的插件。

然后,新建一个 Index,指定需要分词的字段。这一步根据数据结构而异,下面的命令只针对本文。基本上,凡是需要搜索的中文字段,都要单独设置一下。

$ curl -X PUT 'localhost:9200/accounts' -d '
{
  "mappings": {
    "person": {
      "properties": {
        "user": {
          "type": "text",
          "analyzer": "ik_max_word",
          "search_analyzer": "ik_max_word"
        },
        "title": {
          "type": "text",
          "analyzer": "ik_max_word",
          "search_analyzer": "ik_max_word"
        },
        "desc": {
          "type": "text",
          "analyzer": "ik_max_word",
          "search_analyzer": "ik_max_word"
        }
      }
    }
  }
}'

上面代码中,首先新建一个名称为accounts的 Index,里面有一个名称为person的 Type。person有三个字段。

  • user
  • title
  • desc

这三个字段都是中文,而且类型都是文本(text),所以需要指定中文分词器,不能使用默认的英文分词器。

Elastic 的分词器称为analyzer。我们对每个字段指定分词器。

"user": {
  "type": "text",
  "analyzer": "ik_max_word",
  "search_analyzer": "ik_max_word"
}

上面代码中,analyzer是字段文本的分词器,search_analyzer是搜索词的分词器。ik_max_word分词器是插件ik提供的,可以对文本进行最大数量的分词。

五、数据操作

5.1 新增记录

向指定的 /Index/Type 发送 PUT 请求,就可以在 Index 里面新增一条记录。比如,向/accounts/person发送请求,就可以新增一条人员记录。

$ curl -X PUT 'localhost:9200/accounts/person/1' -d '
{
  "user": "张三",
  "title": "工程师",
  "desc": "数据库管理"
}' 

服务器返回的 JSON 对象,会给出 Index、Type、Id、Version 等信息。

{
  "_index":"accounts",
  "_type":"person",
  "_id":"1",
  "_version":1,
  "result":"created",
  "_shards":{"total":2,"successful":1,"failed":0},
  "created":true
}

如果你仔细看,会发现请求路径是/accounts/person/1,最后的1是该条记录的 Id。它不一定是数字,任意字符串(比如abc)都可以。

新增记录的时候,也可以不指定 Id,这时要改成 POST 请求。

$ curl -X POST 'localhost:9200/accounts/person' -d '
{
  "user": "李四",
  "title": "工程师",
  "desc": "系统管理"
}'

上面代码中,向/accounts/person发出一个 POST 请求,添加一个记录。这时,服务器返回的 JSON 对象里面,_id字段就是一个随机字符串。

{
  "_index":"accounts",
  "_type":"person",
  "_id":"AV3qGfrC6jMbsbXb6k1p",
  "_version":1,
  "result":"created",
  "_shards":{"total":2,"successful":1,"failed":0},
  "created":true
}

注意,如果没有先创建 Index(这个例子是accounts),直接执行上面的命令,Elastic 也不会报错,而是直接生成指定的 Index。所以,打字的时候要小心,不要写错 Index 的名称。

5.2 查看记录

向/Index/Type/Id发出 GET 请求,就可以查看这条记录。

$ curl 'localhost:9200/accounts/person/1?pretty=true'

上面代码请求查看/accounts/person/1这条记录,URL 的参数pretty=true表示以易读的格式返回。

返回的数据中,found字段表示查询成功,_source字段返回原始记录。

{
  "_index" : "accounts",
  "_type" : "person",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "user" : "张三",
    "title" : "工程师",
    "desc" : "数据库管理"
  }
}

如果 Id 不正确,就查不到数据,found字段就是false。

$ curl 'localhost:9200/weather/beijing/abc?pretty=true'

{
  "_index" : "accounts",
  "_type" : "person",
  "_id" : "abc",
  "found" : false
}

5.3 删除记录

删除记录就是发出 DELETE 请求。

$ curl -X DELETE 'localhost:9200/accounts/person/1'

这里先不要删除这条记录,后面还要用到。

5.4 更新记录

更新记录就是使用 PUT 请求,重新发送一次数据。

$ curl -X PUT 'localhost:9200/accounts/person/1' -d '
{
    "user" : "张三",
    "title" : "工程师",
    "desc" : "数据库管理,软件开发"
}' 

{
  "_index":"accounts",
  "_type":"person",
  "_id":"1",
  "_version":2,
  "result":"updated",
  "_shards":{"total":2,"successful":1,"failed":0},
  "created":false
}

上面代码中,我们将原始数据从"数据库管理"改成"数据库管理,软件开发"。 返回结果里面,有几个字段发生了变化。

"_version" : 2,
"result" : "updated",
"created" : false

可以看到,记录的 Id 没变,但是版本(version)从1变成2,操作类型(result)从created变成updated,created字段变成false,因为这次不是新建记录。

六、数据查询

6.1 返回所有记录

使用 GET 方法,直接请求/Index/Type/_search,就会返回所有记录。

$ curl 'localhost:9200/accounts/person/_search'

{
  "took":2,
  "timed_out":false,
  "_shards":{"total":5,"successful":5,"failed":0},
  "hits":{
    "total":2,
    "max_score":1.0,
    "hits":[
      {
        "_index":"accounts",
        "_type":"person",
        "_id":"AV3qGfrC6jMbsbXb6k1p",
        "_score":1.0,
        "_source": {
          "user": "李四",
          "title": "工程师",
          "desc": "系统管理"
        }
      },
      {
        "_index":"accounts",
        "_type":"person",
        "_id":"1",
        "_score":1.0,
        "_source": {
          "user" : "张三",
          "title" : "工程师",
          "desc" : "数据库管理,软件开发"
        }
      }
    ]
  }
}

上面代码中,返回结果的took字段表示该操作的耗时(单位为毫秒),timed_out字段表示是否超时,hits字段表示命中的记录,里面子字段的含义如下。

  • total:返回记录数,本例是2条。
  • max_score:最高的匹配程度,本例是1.0。
  • hits:返回的记录组成的数组。

返回的记录中,每条记录都有一个_score字段,表示匹配的程序,默认是按照这个字段降序排列。

6.2 全文搜索

Elastic 的查询非常特别,使用自己的查询语法,要求 GET 请求带有数据体。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "软件" }}
}'

上面代码使用Match 查询,指定的匹配条件是desc字段里面包含"软件"这个词。返回结果如下。

{
  "took":3,
  "timed_out":false,
  "_shards":{"total":5,"successful":5,"failed":0},
  "hits":{
    "total":1,
    "max_score":0.28582606,
    "hits":[
      {
        "_index":"accounts",
        "_type":"person",
        "_id":"1",
        "_score":0.28582606,
        "_source": {
          "user" : "张三",
          "title" : "工程师",
          "desc" : "数据库管理,软件开发"
        }
      }
    ]
  }
}

Elastic 默认一次返回10条结果,可以通过size字段改变这个设置。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "管理" }},
  "size": 1
}'

上面代码指定,每次只返回一条结果。

还可以通过from字段,指定位移。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "管理" }},
  "from": 1,
  "size": 1
}'

上面代码指定,从位置1开始(默认是从位置0开始),只返回一条结果。

6.3 逻辑运算

如果有多个搜索关键字, Elastic 认为它们是or关系。

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query" : { "match" : { "desc" : "软件 系统" }}
}'

上面代码搜索的是软件 or 系统。

如果要执行多个关键词的and搜索,必须使用布尔查询

$ curl 'localhost:9200/accounts/person/_search'  -d '
{
  "query": {
    "bool": {
      "must": [
        { "match": { "desc": "软件" } },
        { "match": { "desc": "系统" } }
      ]
    }
  }
}'

七、参考链接

(完)

文档信息

]]>
0
<![CDATA[Lua 5.3.4 的一个 bug]]> http://www.udpwork.com/item/16386.html http://www.udpwork.com/item/16386.html#reviews Wed, 16 Aug 2017 10:18:48 +0800 云风 http://www.udpwork.com/item/16386.html 昨天我们一个项目发现了一处死循环的 bug ,经过一整晚的排查,终于确认是 lua 5.3.4 的问题。

起因是最近项目中接入了我前段时间写的一个库,用来给客户端加载大量配置表格数据。它的原理是将数据表先转换为 C 结构,放在一块连续内存里。在运行时,可以根据需要提取出其中用到的部分加载都虚拟机中。这样做可以极大的提高加载速度。项目在用的时候还做了一点点小修改,把数据表都设置成 weaktable ,可以让暂时不用的数据项可以回收掉。

正式后面这个小修改触发了 bug 。

排除掉是我这个库引起的 bug 后,我们把注意力集中在 lua 的实现上。

bug 的现象是:运行一段时间后,某次 table copy 的过程中,对一个 table 的 set 操作陷入了死循环。我们知道 lua 的 table 中有一个闭散列 hash 表,如果在插入新项目时,发现 hash 冲突,则需要重新找到一个空的 slot 并将其串在 hash 查询时所在的 slot 上的链表中。

而 bug 发生时,这个链表损坏了,指向了一个空 slot ,空 slot 的 next 指针指向自己,导致死循环遍历。

从 coredump 上分析,我认为是 hash 查询出来的冲突对象(一个 long string )的数据结构受损。原本在 long string 结构中有一个 extra 变量指示这个对象是否有计算过 hash ,它的值只能是 0 或 1 ,但这里却是 67 。而 hash 值则为 0 (通常 hash 值是 0 的概率非常小),导致重新索引 hash slot 时指向了 slot 0 ,那里是空的。

我们自定义了 lua 的分配器,在分配器中输出 log 显示,在访问这个 slot 前,那个受损的 long string key 对象其实已经被 lua vm 释放过了。

一开始我们怀疑是自定义的内存分配器有 bug ,但很快放弃了这个想法,转而去追查 lua 的 gc 过程。这个 table 是 key value 都是弱的弱表,若只设置 value 为弱则不会触发 bug 。

确认问题出在清除弱表项的环节,也就是 lgc.c 中的 GCSatomic 阶段的 atomic 函数中。它有一个步骤是调用clearkeys(g, g->allweak, NULL);清除在扫描过程标记出来的弱表,并检查 key 是否需要清除。

该函数是这样的:


/*
** clear entries with unmarked keys from all weaktables in list 'l' up
** to element 'f'
*/
static void clearkeys (global_State *g, GCObject *l, GCObject *f) {
  for (; l != f; l = gco2t(l)->gclist) {
    Table *h = gco2t(l);
    Node *n, *limit = gnodelast(h);
    for (n = gnode(h, 0); n 

遍历 hash 表,当 value 不为空,且 key 可以被清除的时候,将 slot 清空。

string 对于 gc 是一个特殊的对象,因为它即是一个 GCObject ,但又被视为值而不是引用。string 并不会因为在 vm 中没有 weak table 之外的地方引用而被清除。对 string 的特殊处理是在 iscleared 函数中完成的。


/*
** tells whether a key or value can be cleared from a weak
** table. Non-collectable objects are never removed from weak
** tables. Strings behave as 'values', so are never removed too. for
** other objects: if really collected, cannot keep them; for objects
** being finalized, keep them in keys, but not in values
*/
static int iscleared (global_State *g, const TValue *o) {
  if (!iscollectable(o)) return 0;
  else if (ttisstring(o)) {
    markobject(g, tsvalue(o));  /* strings are 'values', so are never weak */
    return 0;
  }
  else return iswhite(gcvalue(o));
}


如果发现 key 是一个 string 则会将其标黑。

但是在 clearkeys 里漏掉了一点,如果 value 为 nil 是不会执行 iscleared 函数的。而什么时候 key 为 string , value 为 nil 呢?最简单的途径是主动给 table 的表项设置为 nil 。这样,在 gc 一轮后,hash 表中就可能残留一个已经被释放的 GCObject 指针。

如果这个 string 是一个短 string 其实不会引起问题,因为再次设置 hash 表的时候,short string 是按指针比较的,不会访问其内容;但是 long string 不一样,hash set 时真的会比较对象的内容:两个 long string 是否相等取决于 string 的值相同,而不必是对象内存地址相同。

制作一个纯 lua 的 MWE 很困难,所以我写了一段 C 代码来演示这个问题:


#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdlib.h>
#include <lstring.h>

static void *
l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
    if (nsize == 0) {
        printf("free %p\n", ptr);
        free(ptr);
        return NULL;
    } else {
        return realloc(ptr, nsize);
    }
}

static int
lpointer(lua_State *L) {
    const char * str = luaL_checkstring(L, 1);
    const TString *ts = (const TString *)str - 1;
    lua_pushlightuserdata(L, (void *)ts);
    return 1;
}

const char *source = "\n\
local a = setmetatable({} , { __mode = 'kv' })\n\
a['ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz' ] = {}\n\
print(pointer((next(a))))\n\
a[next(a)] = nil\n\
collectgarbage 'collect'\n\
local key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz'\n\
print(pointer(key))\n\
a[key] = {}\n\
print(pointer((next(a))))\n\
";

int main() {
    lua_State *L = lua_newstate (l_alloc, NULL);
    luaL_openlibs(L);
    lua_pushcfunction(L, lpointer);
    lua_setglobal(L, "pointer");
    luaL_dostring(L, source);

    return 0;
}


运行输出:


...
userdata: 00000000006fedd0     这里是ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
free 00000000006FAB50     这里进入 GC 开始释放不用的对象
free 00000000006FA890
free 00000000006FE940
free 00000000006FE910
free 0000000000000000
free 00000000006FA650
free 00000000006FEDD0      这里显示前面那个长字符串 6FEDD0 已经释放了。
free 00000000006FEBC0
free 0000000000000000
free 00000000006FAA50
free 00000000006F9770
userdata: 00000000006f1eb0   这里构造了一个新的字符串
 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
userdata: 00000000006fedd0   这里显示前面那个已经被释放的 6FEDD0  字符串又回来了。


这个 bug 最简单的修改方法是把 clearkeys 中的 !ttisnil(gval(n)) 条件判断去掉。不过或许还有更完善的解决方案。

我已经将 bug report 到 lua 的邮件列表,暂时尚未被官方确认修正。
]]>
昨天我们一个项目发现了一处死循环的 bug ,经过一整晚的排查,终于确认是 lua 5.3.4 的问题。

起因是最近项目中接入了我前段时间写的一个库,用来给客户端加载大量配置表格数据。它的原理是将数据表先转换为 C 结构,放在一块连续内存里。在运行时,可以根据需要提取出其中用到的部分加载都虚拟机中。这样做可以极大的提高加载速度。项目在用的时候还做了一点点小修改,把数据表都设置成 weaktable ,可以让暂时不用的数据项可以回收掉。

正式后面这个小修改触发了 bug 。

排除掉是我这个库引起的 bug 后,我们把注意力集中在 lua 的实现上。

bug 的现象是:运行一段时间后,某次 table copy 的过程中,对一个 table 的 set 操作陷入了死循环。我们知道 lua 的 table 中有一个闭散列 hash 表,如果在插入新项目时,发现 hash 冲突,则需要重新找到一个空的 slot 并将其串在 hash 查询时所在的 slot 上的链表中。

而 bug 发生时,这个链表损坏了,指向了一个空 slot ,空 slot 的 next 指针指向自己,导致死循环遍历。

从 coredump 上分析,我认为是 hash 查询出来的冲突对象(一个 long string )的数据结构受损。原本在 long string 结构中有一个 extra 变量指示这个对象是否有计算过 hash ,它的值只能是 0 或 1 ,但这里却是 67 。而 hash 值则为 0 (通常 hash 值是 0 的概率非常小),导致重新索引 hash slot 时指向了 slot 0 ,那里是空的。

我们自定义了 lua 的分配器,在分配器中输出 log 显示,在访问这个 slot 前,那个受损的 long string key 对象其实已经被 lua vm 释放过了。

一开始我们怀疑是自定义的内存分配器有 bug ,但很快放弃了这个想法,转而去追查 lua 的 gc 过程。这个 table 是 key value 都是弱的弱表,若只设置 value 为弱则不会触发 bug 。

确认问题出在清除弱表项的环节,也就是 lgc.c 中的 GCSatomic 阶段的 atomic 函数中。它有一个步骤是调用clearkeys(g, g->allweak, NULL);清除在扫描过程标记出来的弱表,并检查 key 是否需要清除。

该函数是这样的:


/*
** clear entries with unmarked keys from all weaktables in list 'l' up
** to element 'f'
*/
static void clearkeys (global_State *g, GCObject *l, GCObject *f) {
  for (; l != f; l = gco2t(l)->gclist) {
    Table *h = gco2t(l);
    Node *n, *limit = gnodelast(h);
    for (n = gnode(h, 0); n 

遍历 hash 表,当 value 不为空,且 key 可以被清除的时候,将 slot 清空。

string 对于 gc 是一个特殊的对象,因为它即是一个 GCObject ,但又被视为值而不是引用。string 并不会因为在 vm 中没有 weak table 之外的地方引用而被清除。对 string 的特殊处理是在 iscleared 函数中完成的。


/*
** tells whether a key or value can be cleared from a weak
** table. Non-collectable objects are never removed from weak
** tables. Strings behave as 'values', so are never removed too. for
** other objects: if really collected, cannot keep them; for objects
** being finalized, keep them in keys, but not in values
*/
static int iscleared (global_State *g, const TValue *o) {
  if (!iscollectable(o)) return 0;
  else if (ttisstring(o)) {
    markobject(g, tsvalue(o));  /* strings are 'values', so are never weak */
    return 0;
  }
  else return iswhite(gcvalue(o));
}


如果发现 key 是一个 string 则会将其标黑。

但是在 clearkeys 里漏掉了一点,如果 value 为 nil 是不会执行 iscleared 函数的。而什么时候 key 为 string , value 为 nil 呢?最简单的途径是主动给 table 的表项设置为 nil 。这样,在 gc 一轮后,hash 表中就可能残留一个已经被释放的 GCObject 指针。

如果这个 string 是一个短 string 其实不会引起问题,因为再次设置 hash 表的时候,short string 是按指针比较的,不会访问其内容;但是 long string 不一样,hash set 时真的会比较对象的内容:两个 long string 是否相等取决于 string 的值相同,而不必是对象内存地址相同。

制作一个纯 lua 的 MWE 很困难,所以我写了一段 C 代码来演示这个问题:


#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdlib.h>
#include <lstring.h>

static void *
l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
    if (nsize == 0) {
        printf("free %p\n", ptr);
        free(ptr);
        return NULL;
    } else {
        return realloc(ptr, nsize);
    }
}

static int
lpointer(lua_State *L) {
    const char * str = luaL_checkstring(L, 1);
    const TString *ts = (const TString *)str - 1;
    lua_pushlightuserdata(L, (void *)ts);
    return 1;
}

const char *source = "\n\
local a = setmetatable({} , { __mode = 'kv' })\n\
a['ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz' ] = {}\n\
print(pointer((next(a))))\n\
a[next(a)] = nil\n\
collectgarbage 'collect'\n\
local key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' .. 'abcdefghijklmnopqrstuvwxyz'\n\
print(pointer(key))\n\
a[key] = {}\n\
print(pointer((next(a))))\n\
";

int main() {
    lua_State *L = lua_newstate (l_alloc, NULL);
    luaL_openlibs(L);
    lua_pushcfunction(L, lpointer);
    lua_setglobal(L, "pointer");
    luaL_dostring(L, source);

    return 0;
}


运行输出:


...
userdata: 00000000006fedd0     这里是ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
free 00000000006FAB50     这里进入 GC 开始释放不用的对象
free 00000000006FA890
free 00000000006FE940
free 00000000006FE910
free 0000000000000000
free 00000000006FA650
free 00000000006FEDD0      这里显示前面那个长字符串 6FEDD0 已经释放了。
free 00000000006FEBC0
free 0000000000000000
free 00000000006FAA50
free 00000000006F9770
userdata: 00000000006f1eb0   这里构造了一个新的字符串
 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
userdata: 00000000006fedd0   这里显示前面那个已经被释放的 6FEDD0  字符串又回来了。


这个 bug 最简单的修改方法是把 clearkeys 中的 !ttisnil(gval(n)) 条件判断去掉。不过或许还有更完善的解决方案。

我已经将 bug report 到 lua 的邮件列表,暂时尚未被官方确认修正。
]]>
0
<![CDATA[CUDA 8 on Amazon Linux 2017.03.1 HVM]]> http://www.udpwork.com/item/16385.html http://www.udpwork.com/item/16385.html#reviews Wed, 16 Aug 2017 08:06:32 +0800 qyjohn http://www.udpwork.com/item/16385.html I was able to install CUDA 8 on the EC2 instance with the following steps. It should be noted that the EC2 instance was created with a root EBS volume of 100 GB to avoid running into storage space issues.

#
# STEP 1: Install Nvidia Driver
# 384.66 is a version that has support for K80
#
cd ~
sudo yum install -y gcc kernel-devel-`uname -r`
wgethttp://us.download.nvidia.com/XFree86/Linux-x86_64/384.66/NVIDIA-Linux-x86_64-384.66.run
sudo /bin/bash ./NVIDIA-Linux-x86_64-384.66.run
nvidia-smi

#
# STEP 2: Install CUDA Repo
#
wgethttps://developer.nvidia.com/compute/cuda/8.0/Prod2/local_installers/cuda-repo-rhel6-8-0-local-ga2-8.0.61-1.x86_64-rpm
sudo rpm -i cuda-repo-rhel6-8-0-local-ga2-8.0.61-1.x86_64-rpm

#
# STEP 3: Install CUDA Toolkit
#
sudo yum install cuda-toolkit-8-0
export PATH=$PATH:/usr/local/cuda-8.0/bin
nvcc –version

#
# STEP 4: Compile a sample program (deviceQuery) to use CUDA
#
cd /usr/local/cuda-8.0
sudo chown -R ec2-user:ec2-user samples
cd samples/1_Utilities/deviceQuery
make
./deviceQuery

At this point everything should be all set. I have also compiled and tested some other sample code from the samples folder and they all seemed to work.

A quick example on cuBLAS can be obtained fromhttp://docs.nvidia.com/cuda/cublas/. Simply copy Example 1 or Example 2 from this web page and save it as test.c, then compile and run the code with the following commands. I have tested both of them and verified them to be working.

#
# STEP 5: Compile and test cuBLAS code
#
nvcc test.c -lcublas -o test
./test

]]>
I was able to install CUDA 8 on the EC2 instance with the following steps. It should be noted that the EC2 instance was created with a root EBS volume of 100 GB to avoid running into storage space issues.

#
# STEP 1: Install Nvidia Driver
# 384.66 is a version that has support for K80
#
cd ~
sudo yum install -y gcc kernel-devel-`uname -r`
wgethttp://us.download.nvidia.com/XFree86/Linux-x86_64/384.66/NVIDIA-Linux-x86_64-384.66.run
sudo /bin/bash ./NVIDIA-Linux-x86_64-384.66.run
nvidia-smi

#
# STEP 2: Install CUDA Repo
#
wgethttps://developer.nvidia.com/compute/cuda/8.0/Prod2/local_installers/cuda-repo-rhel6-8-0-local-ga2-8.0.61-1.x86_64-rpm
sudo rpm -i cuda-repo-rhel6-8-0-local-ga2-8.0.61-1.x86_64-rpm

#
# STEP 3: Install CUDA Toolkit
#
sudo yum install cuda-toolkit-8-0
export PATH=$PATH:/usr/local/cuda-8.0/bin
nvcc –version

#
# STEP 4: Compile a sample program (deviceQuery) to use CUDA
#
cd /usr/local/cuda-8.0
sudo chown -R ec2-user:ec2-user samples
cd samples/1_Utilities/deviceQuery
make
./deviceQuery

At this point everything should be all set. I have also compiled and tested some other sample code from the samples folder and they all seemed to work.

A quick example on cuBLAS can be obtained fromhttp://docs.nvidia.com/cuda/cublas/. Simply copy Example 1 or Example 2 from this web page and save it as test.c, then compile and run the code with the following commands. I have tested both of them and verified them to be working.

#
# STEP 5: Compile and test cuBLAS code
#
nvcc test.c -lcublas -o test
./test

]]>
0
<![CDATA[]]> http://www.udpwork.com/item/16387.html http://www.udpwork.com/item/16387.html#reviews Tue, 15 Aug 2017 21:20:00 +0800 EverET http://www.udpwork.com/item/16387.html 最近在用 grpc1 ,发现 grpc 的 Python server 目前还没有像 Flask 那样的修改后自动 reload ,开发不是很方便。 所以就看看有什么比较好的实现,发现 werkzeug2 已经有个比较好的实现,而且 Flask 用的就是它。就不用重复发明轮子了。 假设我们的启动 server 的代码写在了 run_server 里面,我们可以将其传入到 werkzeug 的 run_with_reloader ,就会拥有监控文件改变自动 reload 的功能。 1 2 3 4 5 6 7 from werkzeug._reloader import run_with_reloader main_func = partial(run_server, grpc_host, grpc_port, concurrent) if autoreload: run_with_reloader(main_func) else: main_func() 原理 入口程序(主进程)进入 run_with_reloader 后,因为此时环境变量中没有 WERKZEUG_RUN_MAIN,所以不会运行主逻辑 run_server 。而是会取出自己的命令行参数,设置好 WERKZEUG_RUN_MAIN 环境变量,通过 subprocess 创建一个自己,这个时候子进程判断设置了 WERKZEUG_RUN_MAIN,此时才运行真正的程序逻辑。 werkzeug 的 reloader 封装了 Stat 和 WatchDog 两种 reloader ,当子进程监控到文件改变后,会调用 trigger_reload 退出自己。然后主进程判断特殊的返回码3后再次启动子进程。 从而完成了监控文件改变,自动 reload 自己的功能。 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 class ReloaderLoop(object): # ... def restart_with_reloader(self): """Spawn a new Python interpreter with the same arguments as this one, but running the reloader thread. """ while 1: _log('info', ' * Restarting with %s' % self.name) args = _get_args_for_reloading() new_environ = os.environ.copy() new_environ['WERKZEUG_RUN_MAIN'] = 'true' # ... exit_code = subprocess.call(args, env=new_environ, close_fds=False) if exit_code != 3: return exit_code def trigger_reload(self, filename): self.log_reload(filename) sys.exit(3) def run_with_reloader(main_func, extra_files=None, interval=1, reloader_type='auto'): """Run the given function in an independent python interpreter.""" import signal reloader = reloader_loops[reloader_type](extra_files, interval) signal.signal(signal.SIGTERM, lambda *args: sys.exit(0)) try: if os.environ.get('WERKZEUG_RUN_MAIN') == 'true': t = threading.Thread(target=main_func, args=()) t.setDaemon(True) t.start() reloader.run() else: sys.exit(reloader.restart_with_reloader()) except KeyboardInterrupt: pass Links https://github.com/grpc/grpc↩ werkzeug.pocoo.org↩ PermaLink: http://everet.org/python-autoreload.htmlTags: Python ]]>

]]>
最近在用 grpc1 ,发现 grpc 的 Python server 目前还没有像 Flask 那样的修改后自动 reload ,开发不是很方便。 所以就看看有什么比较好的实现,发现 werkzeug2 已经有个比较好的实现,而且 Flask 用的就是它。就不用重复发明轮子了。 假设我们的启动 server 的代码写在了 run_server 里面,我们可以将其传入到 werkzeug 的 run_with_reloader ,就会拥有监控文件改变自动 reload 的功能。 1 2 3 4 5 6 7 from werkzeug._reloader import run_with_reloader main_func = partial(run_server, grpc_host, grpc_port, concurrent) if autoreload: run_with_reloader(main_func) else: main_func() 原理 入口程序(主进程)进入 run_with_reloader 后,因为此时环境变量中没有 WERKZEUG_RUN_MAIN,所以不会运行主逻辑 run_server 。而是会取出自己的命令行参数,设置好 WERKZEUG_RUN_MAIN 环境变量,通过 subprocess 创建一个自己,这个时候子进程判断设置了 WERKZEUG_RUN_MAIN,此时才运行真正的程序逻辑。 werkzeug 的 reloader 封装了 Stat 和 WatchDog 两种 reloader ,当子进程监控到文件改变后,会调用 trigger_reload 退出自己。然后主进程判断特殊的返回码3后再次启动子进程。 从而完成了监控文件改变,自动 reload 自己的功能。 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 class ReloaderLoop(object): # ... def restart_with_reloader(self): """Spawn a new Python interpreter with the same arguments as this one, but running the reloader thread. """ while 1: _log('info', ' * Restarting with %s' % self.name) args = _get_args_for_reloading() new_environ = os.environ.copy() new_environ['WERKZEUG_RUN_MAIN'] = 'true' # ... exit_code = subprocess.call(args, env=new_environ, close_fds=False) if exit_code != 3: return exit_code def trigger_reload(self, filename): self.log_reload(filename) sys.exit(3) def run_with_reloader(main_func, extra_files=None, interval=1, reloader_type='auto'): """Run the given function in an independent python interpreter.""" import signal reloader = reloader_loops[reloader_type](extra_files, interval) signal.signal(signal.SIGTERM, lambda *args: sys.exit(0)) try: if os.environ.get('WERKZEUG_RUN_MAIN') == 'true': t = threading.Thread(target=main_func, args=()) t.setDaemon(True) t.start() reloader.run() else: sys.exit(reloader.restart_with_reloader()) except KeyboardInterrupt: pass Links https://github.com/grpc/grpc↩ werkzeug.pocoo.org↩ PermaLink: http://everet.org/python-autoreload.htmlTags: Python ]]>

]]>
0
<![CDATA[净推荐值(NPS):用户忠诚度测量的基本原理及方法]]> http://www.udpwork.com/item/16384.html http://www.udpwork.com/item/16384.html#reviews Tue, 15 Aug 2017 20:27:31 +0800 UXC http://www.udpwork.com/item/16384.html 初识NPS

作为互联网行业的用户体验从业者,我们都或多或少会接触一些衡量用户与产品或服务之间关系的指标,常见的指标如活跃度、留存率、用户满意度等。近几年,NPS(Net Promoter Score净推荐值)在国内流行起来,越来越多的行业及企业开始使用NPS指标作为衡量用户口碑的工具,如通信服务行业的中国移动、金融保险行业的中国平安、互联网行业的天猫和腾讯、家电企业海信等。中国平安从2013年开始引入NPS评价指标,并在2016年8月首次对外发布了公司NPS的相关数据,平安集团经过两年多的NPS建设及实施,不断推进用户口碑的显著改善和各项业务指标数据的良性增长。

NPS被越来越多的国内企业接受和使用,根本上是受到企业内部经营和外在环境等因素的综合影响。首先,日趋激烈的市场竞争驱动企业转化增长方式,促使企业从跑马圈地的粗放经营走向重视用户价值、培养忠诚用户并提升社会美誉的精细经营,企业营销的核心转向对消费者口碑和忠诚度的关注。据洞察中国(CHINA INSIGHT)的研究统计,75%的中国消费者在使用或购买产品时更信赖家人和朋友推荐,因此,如何让用户推荐其正在使用或消费的产品成为企业关心的核心问题。其次,借鉴NPS在欧美等国家取得的成功经验,近几年苹果、微软、亚马逊、飞利浦等国际企业通过遥遥领先的NPS水平,实现了市场份额和营收利润的双赢。而且,随着社交网络的发展,领先的NPS为企业带来的效果还将不断放大。最后,国内用户体验团队专业性和影响力的不断提升,使企业具备了构建和实践NPS体系的能力,国内很多互联网公司NPS体系搭建的推动和践行者均由用户研究团队在主导和负责。

 

什么是NPS

净推荐值(NPS)最早由贝恩咨询公司客户忠诚度业务创始人佛雷德·赖克哈尔徳(Fred Reichheld)在2003年哈佛大学商业评论《你需要致力于增长的一个数字(The Number You Need to Grow)》的文章中首次提到。雷德·赖克哈尔徳提出净推荐值主要有以下几个方面的考虑:首先,他认为NPS是衡量忠诚度的有效指标,通过衡量用户的忠诚度,可以帮助区分企业的”不良利润”和”良性利润”,即哪些是以伤害用户利益或体验为代价而获得的利润,哪些是通过与用户积极合作而获得的利润,追求良性利润和避免不良利润是企业赢得未来和长期利益的关键因素。其次,与其它衡量忠诚度的指标相比,NPS分值与企业盈利增长之间存在非常强的相关性,如图一所示,高NPS分值公司的复合年增长率比普通公司高两倍以上。而其它指标如满意度、留存率与增长率的相关性较弱,无法准确定义用户是由于忠诚还是其它原因使用或购买某个产品。此外,传统的满意度模型比较复杂,理解成本较高,而且调研问卷冗长,导致用户的参与意愿不高。

%e5%9b%be%e4%b8%80

图一

*数据来源:2016年贝恩中国大众品牌净推荐值研究

NPS模型可以简单的被理解为两个主要部分,第一个部分是根据用户对一个标准问题的回答来对用户进行分类,这个问题的通常问法是:“你有多大可能把我们(或这个产品/服务/品牌)推荐给朋友或同事?请从0分到10分打分”。这个问题是雷德·赖克哈尔徳在对20个常用的用户忠诚度测试问题进行调查和筛选,并结合不同行业上千名用户的实际购买行为数据综合分析后最终确定的,他认为基于这个问题采集的答案最能有效预测用户的重复购买和推荐行为。另外一个部分是在第一个问题基础上进行后续问题提问:“你给出这个分数的主要原因是什么?”,为用户提供反馈问题和原因的完成流程。因此,NPS的核心思想是按照忠诚度对用户进行分类,并深入了解用户推荐或不推荐产品的原因,然后鼓励企业采取多种措施,尽量的增加推荐者和减少批评者,从而赢得企业的良性增长。

NPS的计算方式如图二所示,根据用户愿意推荐的程度在0-10分之间来打分,0分代表完全没有可能推荐,10分代表极有可能推荐,然后依据得分将用户分为三组:

%e5%9b%be%e4%ba%8c

图二

推荐者(得分在9-10分之间):是产品忠诚的用户,他们会继续使用或购买产品,并愿意将产品引荐给其他人。

被动者(得分在7-8分之间):是满意但不热心的用户,他们几乎不会向其它人推荐产品,并且他们可以被竞争对手轻易拉拢。

贬损者(得分0-6分之间):是不满意的用户,他们对产品感到不满甚至气愤,可能在朋友和同事面前讲产品的坏话,并阻止身边的人使用产品。

NPS值就是用推荐者所占百分比与贬损者所占百分比的差额,即净推荐值(NPS)=(推荐者数/总样本数)*100%-(贬损者数/总样本数)*100%,净推荐值的区间在-100%到100%之间。一般来说,NPS分值在50%以上被认为是不错的,如果NPS得分在70-80%之间说明企业已经拥有一批高忠诚度的口碑用户。2016年,贝恩公司对中国市场消费品及消费服务行业的NPS水平进行研究,各行业主要品牌的NPS值如图三所示。

%e5%9b%be%e4%b8%89

图三

*数据来源:2016年贝恩中国大众品牌净推荐值研究

对于打算实践NPS的企业来说,建立可靠可信的NPS测量机制是至关重要的,NPS测量的质量可能受到以下几个方面因素的影响。首先,用户抽样方法的可靠性,能否获得目标用户较高的回应率,如果用户回应率较低,可能会产生误导性的结果。雷德·赖克哈尔徳列举了某企业的用户回应率较低时,估算的企业NPS值(50%)与真实NPS值(-22%)之间存在较大的偏差,如图四所示。针对互联网产品而言,线上问卷调查的用户填答率通常不高,有必要进一步对不填答者的用户行为数据或业务数据进行深入分析,并与填答者的数据进行比较,以避免用户抽样造成的NPS分值偏差。其次, NPS值和用户行为之间的关系,即NPS值是否预示了真实的用户行为,能否避免测量过程中的各种反馈偏差、作弊和人为操作等问题,所以有必要在测量过程中定期的对测量结果和真实用户行为数据进行关联分析,确保用户行为与NPS值相吻合。最后,测量时是否选择了合适的分制,分制是否符合不同地区用户的打分习惯,以及是否能确保用户理解正确。目前,从0分(完全没有可能)到10分(极有可能)的十分制标准是较为通用的选择。

%e5%9b%be%e5%9b%9b

图四

*图表来源:终极问题2.0:客户驱动的企业未来

 

如何实践NPS

虽然NPS的模型简单且容易理解,但真正在企业搭建NPS评测体系并不是一件容易的事,例如中国平安、天猫等企业都曾平均花费2-3年甚至更长时间计划和落实NPS体系,实践NPS对于企业来说是一件长期和需要持续投入的项目。一般来说,以下几个方面对企业选择和实践NPS并取得成功会产生重要的影响,如图五所示:

%e5%9b%be%e4%ba%94

图五

1)树立以用户为中心的价值观念。NPS体系的核心思想是提倡良性利润,鼓励企业与用户积极合作,以互联网产品为例,以恶化用户关系为代价换取点击率或KPI达成都是违背用户价值理念的。树立自上而下的用户为中心公司价值观,有助于不同职能部门员工加深理解自身的工作和责任,便于NPS项目的推行和落地。

2)将NPS落实到公司流程。NPS管理可能涉及企业多个部门,比如财务、行政、人力资源、市场研究、产品、运营、研发、设计、客服等,将NPS调研获得的分值和用户反馈等作为关键决策的依据,并建立由各部门组成的优化机制和流程,形成从问题发现、优化、效果评估的管理闭环,真正将分值作为产品优化和体验改善的驱动力。

3)建立可信赖的数据监测和根本原因分析(root-cause analysis)机制。数据质量是NPS管理的关键,主要包括两个方面的数据监测,一方面是定期调查的NPS分值表现以及真实的用户行为和业务数据表现,NPS分值如果不能准确可靠地反映用户对产品的感知和行为,则无法评价用户的忠诚度水平和预测未来增长。另一方面是为用户提供反馈打分原因及理由的渠道,例如在问卷中直接提供开放性问题或利用互联网产品本身的问题反馈渠道,正确的问题处理机制和快速的产品执行力是实现减少贬损者和提升产品口碑的关键。

4)可建立适当的问责制。将NPS值与员工的工作绩效挂钩,作为员工考核绩效的一部分。雷德·赖克哈尔徳认为企业应根据自身情况选择恰当的时机,但不应该操之过急,过早的把NPS和员工绩效挂钩可能会产生负面效果,如NPS值本身被“KPI化”,盲目追求高分值从而导致目标和过程被扭曲,以及可能会对相关责任人产生较大压力,产生排斥心理或催生出作弊和人为操纵的问题。理想的时机是在企业能够合理解释NPS分数以及掌握数据随机变化范围的规律以后。

 

NPS的局限性

在使用NPS方法测量企业用户忠诚度的同时,也需要认识到方法本身的局限性和不足,NPS在方法论本身仍在不断优化过程中,在使用NPS过程中需要注意以下几个方面的问题:

1)NPS不能完全代替满意度测量。因为NPS模型本身的简洁性,无法从多个角度描述用户满意度,而满意度研究考察的角度相对丰富和全面,因此,可以考虑在NPS指标报警后结合用户满意度研究进行综合分析,确定问题发生的具体原因和普遍性。此外,可以将NPS结合其它指标进行分析,以便取得更好的效果,例如Airbnb的NPS团队曾尝试将NPS分值与用户的综合评分相结合进行分析,可以更加精准的预测用户的复购率。

2)NPS值并不能解释所有情况下的企业增长。因为在用户忠诚度之外仍有许多其它因素影响增长,如广告促销或线上线下的运营活动带来的用户数量和营收增长,或者企业本身具有垄断性质,即使NPS值很低,但仍然有可能实现增长。在互联网行业,尤其像电商行业,线上促销和日常运营活动较多,有必要通过合适的方式区分哪些是自然流量增长和哪些是运营活动带来的增长,并在NPS分值与增长关系的预测中给以适当的考虑。

3)NPS调查的结果不能用来决策哪些功能需要设计和开发。因为NPS调查过程中,鲜有用户会反馈需要什么新功能,所以,涉及到用户需求或服务创新的问题可能需要采用其它适合的研究方法。

 

 

主要参考资料:

1、《终极问题2.0:客户驱动的企业未来》,雷德·赖克哈尔徳. 北京:中信出版社,2013

2、贝恩咨询NPS官网,http://www.netpromotersystem.com/

]]>
初识NPS

作为互联网行业的用户体验从业者,我们都或多或少会接触一些衡量用户与产品或服务之间关系的指标,常见的指标如活跃度、留存率、用户满意度等。近几年,NPS(Net Promoter Score净推荐值)在国内流行起来,越来越多的行业及企业开始使用NPS指标作为衡量用户口碑的工具,如通信服务行业的中国移动、金融保险行业的中国平安、互联网行业的天猫和腾讯、家电企业海信等。中国平安从2013年开始引入NPS评价指标,并在2016年8月首次对外发布了公司NPS的相关数据,平安集团经过两年多的NPS建设及实施,不断推进用户口碑的显著改善和各项业务指标数据的良性增长。

NPS被越来越多的国内企业接受和使用,根本上是受到企业内部经营和外在环境等因素的综合影响。首先,日趋激烈的市场竞争驱动企业转化增长方式,促使企业从跑马圈地的粗放经营走向重视用户价值、培养忠诚用户并提升社会美誉的精细经营,企业营销的核心转向对消费者口碑和忠诚度的关注。据洞察中国(CHINA INSIGHT)的研究统计,75%的中国消费者在使用或购买产品时更信赖家人和朋友推荐,因此,如何让用户推荐其正在使用或消费的产品成为企业关心的核心问题。其次,借鉴NPS在欧美等国家取得的成功经验,近几年苹果、微软、亚马逊、飞利浦等国际企业通过遥遥领先的NPS水平,实现了市场份额和营收利润的双赢。而且,随着社交网络的发展,领先的NPS为企业带来的效果还将不断放大。最后,国内用户体验团队专业性和影响力的不断提升,使企业具备了构建和实践NPS体系的能力,国内很多互联网公司NPS体系搭建的推动和践行者均由用户研究团队在主导和负责。

 

什么是NPS

净推荐值(NPS)最早由贝恩咨询公司客户忠诚度业务创始人佛雷德·赖克哈尔徳(Fred Reichheld)在2003年哈佛大学商业评论《你需要致力于增长的一个数字(The Number You Need to Grow)》的文章中首次提到。雷德·赖克哈尔徳提出净推荐值主要有以下几个方面的考虑:首先,他认为NPS是衡量忠诚度的有效指标,通过衡量用户的忠诚度,可以帮助区分企业的”不良利润”和”良性利润”,即哪些是以伤害用户利益或体验为代价而获得的利润,哪些是通过与用户积极合作而获得的利润,追求良性利润和避免不良利润是企业赢得未来和长期利益的关键因素。其次,与其它衡量忠诚度的指标相比,NPS分值与企业盈利增长之间存在非常强的相关性,如图一所示,高NPS分值公司的复合年增长率比普通公司高两倍以上。而其它指标如满意度、留存率与增长率的相关性较弱,无法准确定义用户是由于忠诚还是其它原因使用或购买某个产品。此外,传统的满意度模型比较复杂,理解成本较高,而且调研问卷冗长,导致用户的参与意愿不高。

%e5%9b%be%e4%b8%80

图一

*数据来源:2016年贝恩中国大众品牌净推荐值研究

NPS模型可以简单的被理解为两个主要部分,第一个部分是根据用户对一个标准问题的回答来对用户进行分类,这个问题的通常问法是:“你有多大可能把我们(或这个产品/服务/品牌)推荐给朋友或同事?请从0分到10分打分”。这个问题是雷德·赖克哈尔徳在对20个常用的用户忠诚度测试问题进行调查和筛选,并结合不同行业上千名用户的实际购买行为数据综合分析后最终确定的,他认为基于这个问题采集的答案最能有效预测用户的重复购买和推荐行为。另外一个部分是在第一个问题基础上进行后续问题提问:“你给出这个分数的主要原因是什么?”,为用户提供反馈问题和原因的完成流程。因此,NPS的核心思想是按照忠诚度对用户进行分类,并深入了解用户推荐或不推荐产品的原因,然后鼓励企业采取多种措施,尽量的增加推荐者和减少批评者,从而赢得企业的良性增长。

NPS的计算方式如图二所示,根据用户愿意推荐的程度在0-10分之间来打分,0分代表完全没有可能推荐,10分代表极有可能推荐,然后依据得分将用户分为三组:

%e5%9b%be%e4%ba%8c

图二

推荐者(得分在9-10分之间):是产品忠诚的用户,他们会继续使用或购买产品,并愿意将产品引荐给其他人。

被动者(得分在7-8分之间):是满意但不热心的用户,他们几乎不会向其它人推荐产品,并且他们可以被竞争对手轻易拉拢。

贬损者(得分0-6分之间):是不满意的用户,他们对产品感到不满甚至气愤,可能在朋友和同事面前讲产品的坏话,并阻止身边的人使用产品。

NPS值就是用推荐者所占百分比与贬损者所占百分比的差额,即净推荐值(NPS)=(推荐者数/总样本数)*100%-(贬损者数/总样本数)*100%,净推荐值的区间在-100%到100%之间。一般来说,NPS分值在50%以上被认为是不错的,如果NPS得分在70-80%之间说明企业已经拥有一批高忠诚度的口碑用户。2016年,贝恩公司对中国市场消费品及消费服务行业的NPS水平进行研究,各行业主要品牌的NPS值如图三所示。

%e5%9b%be%e4%b8%89

图三

*数据来源:2016年贝恩中国大众品牌净推荐值研究

对于打算实践NPS的企业来说,建立可靠可信的NPS测量机制是至关重要的,NPS测量的质量可能受到以下几个方面因素的影响。首先,用户抽样方法的可靠性,能否获得目标用户较高的回应率,如果用户回应率较低,可能会产生误导性的结果。雷德·赖克哈尔徳列举了某企业的用户回应率较低时,估算的企业NPS值(50%)与真实NPS值(-22%)之间存在较大的偏差,如图四所示。针对互联网产品而言,线上问卷调查的用户填答率通常不高,有必要进一步对不填答者的用户行为数据或业务数据进行深入分析,并与填答者的数据进行比较,以避免用户抽样造成的NPS分值偏差。其次, NPS值和用户行为之间的关系,即NPS值是否预示了真实的用户行为,能否避免测量过程中的各种反馈偏差、作弊和人为操作等问题,所以有必要在测量过程中定期的对测量结果和真实用户行为数据进行关联分析,确保用户行为与NPS值相吻合。最后,测量时是否选择了合适的分制,分制是否符合不同地区用户的打分习惯,以及是否能确保用户理解正确。目前,从0分(完全没有可能)到10分(极有可能)的十分制标准是较为通用的选择。

%e5%9b%be%e5%9b%9b

图四

*图表来源:终极问题2.0:客户驱动的企业未来

 

如何实践NPS

虽然NPS的模型简单且容易理解,但真正在企业搭建NPS评测体系并不是一件容易的事,例如中国平安、天猫等企业都曾平均花费2-3年甚至更长时间计划和落实NPS体系,实践NPS对于企业来说是一件长期和需要持续投入的项目。一般来说,以下几个方面对企业选择和实践NPS并取得成功会产生重要的影响,如图五所示:

%e5%9b%be%e4%ba%94

图五

1)树立以用户为中心的价值观念。NPS体系的核心思想是提倡良性利润,鼓励企业与用户积极合作,以互联网产品为例,以恶化用户关系为代价换取点击率或KPI达成都是违背用户价值理念的。树立自上而下的用户为中心公司价值观,有助于不同职能部门员工加深理解自身的工作和责任,便于NPS项目的推行和落地。

2)将NPS落实到公司流程。NPS管理可能涉及企业多个部门,比如财务、行政、人力资源、市场研究、产品、运营、研发、设计、客服等,将NPS调研获得的分值和用户反馈等作为关键决策的依据,并建立由各部门组成的优化机制和流程,形成从问题发现、优化、效果评估的管理闭环,真正将分值作为产品优化和体验改善的驱动力。

3)建立可信赖的数据监测和根本原因分析(root-cause analysis)机制。数据质量是NPS管理的关键,主要包括两个方面的数据监测,一方面是定期调查的NPS分值表现以及真实的用户行为和业务数据表现,NPS分值如果不能准确可靠地反映用户对产品的感知和行为,则无法评价用户的忠诚度水平和预测未来增长。另一方面是为用户提供反馈打分原因及理由的渠道,例如在问卷中直接提供开放性问题或利用互联网产品本身的问题反馈渠道,正确的问题处理机制和快速的产品执行力是实现减少贬损者和提升产品口碑的关键。

4)可建立适当的问责制。将NPS值与员工的工作绩效挂钩,作为员工考核绩效的一部分。雷德·赖克哈尔徳认为企业应根据自身情况选择恰当的时机,但不应该操之过急,过早的把NPS和员工绩效挂钩可能会产生负面效果,如NPS值本身被“KPI化”,盲目追求高分值从而导致目标和过程被扭曲,以及可能会对相关责任人产生较大压力,产生排斥心理或催生出作弊和人为操纵的问题。理想的时机是在企业能够合理解释NPS分数以及掌握数据随机变化范围的规律以后。

 

NPS的局限性

在使用NPS方法测量企业用户忠诚度的同时,也需要认识到方法本身的局限性和不足,NPS在方法论本身仍在不断优化过程中,在使用NPS过程中需要注意以下几个方面的问题:

1)NPS不能完全代替满意度测量。因为NPS模型本身的简洁性,无法从多个角度描述用户满意度,而满意度研究考察的角度相对丰富和全面,因此,可以考虑在NPS指标报警后结合用户满意度研究进行综合分析,确定问题发生的具体原因和普遍性。此外,可以将NPS结合其它指标进行分析,以便取得更好的效果,例如Airbnb的NPS团队曾尝试将NPS分值与用户的综合评分相结合进行分析,可以更加精准的预测用户的复购率。

2)NPS值并不能解释所有情况下的企业增长。因为在用户忠诚度之外仍有许多其它因素影响增长,如广告促销或线上线下的运营活动带来的用户数量和营收增长,或者企业本身具有垄断性质,即使NPS值很低,但仍然有可能实现增长。在互联网行业,尤其像电商行业,线上促销和日常运营活动较多,有必要通过合适的方式区分哪些是自然流量增长和哪些是运营活动带来的增长,并在NPS分值与增长关系的预测中给以适当的考虑。

3)NPS调查的结果不能用来决策哪些功能需要设计和开发。因为NPS调查过程中,鲜有用户会反馈需要什么新功能,所以,涉及到用户需求或服务创新的问题可能需要采用其它适合的研究方法。

 

 

主要参考资料:

1、《终极问题2.0:客户驱动的企业未来》,雷德·赖克哈尔徳. 北京:中信出版社,2013

2、贝恩咨询NPS官网,http://www.netpromotersystem.com/

]]>
0
<![CDATA[移动 H5 首屏秒开优化方案探讨]]> http://www.udpwork.com/item/16383.html http://www.udpwork.com/item/16383.html#reviews Mon, 14 Aug 2017 19:35:55 +0800 bang http://www.udpwork.com/item/16383.html 随着移动设备性能不断增强,web 页面的性能体验逐渐变得可以接受,又因为 web 开发模式的诸多好处(跨平台,动态更新,减体积,无限扩展),APP 客户端里出现越来越多内嵌 web 页面(为了配上当前流行的说法,以下把所有网页都称为 H5 页面,虽然可能跟 H5 没关系),很多 APP 把一些功能模块改成用 H5 实现。

虽然说 H5 页面性能变好了,但如果没针对性地做一些优化,体验还是很糟糕的,主要两部分体验:

  1. 页面启动白屏时间:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
  2. 响应流畅度:由于 webkit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。

本文先不讨论第二点,只讨论第一点,怎样减少白屏时间。对 APP 里的一些使用 H5 实现的功能模块,怎样加快它们的启动速度,让它们启动的体验接近原生。

过程

为什么打开一个 H5 页面会有一长段白屏时间?因为它做了很多事情,大概是:

初始化 webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 -> dom 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片

一些简单的页面可能没有 JS 请求数据 这一步,但大部分功能模块应该是有的,根据当前用户信息,JS 向后台请求相关数据再渲染,是常规开发方式。

一般页面在 dom 渲染后能显示雏形,在这之前用户看到的都是白屏,等到下载渲染图片后整个页面才完整显示,首屏秒开优化就是要减少这个过程的耗时。

前端优化

上述打开一个页面的过程有很多优化点,包括前端和客户端,常规的前端和后端的性能优化在桌面时代已经有最佳实践,主要的是:

  1. 降低请求量: 合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
  2. 加快请求速度: 预解析DNS,减少域名数,并行加载,CDN 分发。
  3. 缓存: HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存localStorage。
  4. 渲染: JS/CSS优化,加载顺序,服务端渲染,pipeline。

其中对首屏启动速度影响最大的就是网络请求,所以优化的重点就是缓存,这里着重说一下前端对请求的缓存策略。我们再细分一下,分成 HTML 的缓存,JS/CSS/image 资源的缓存,以及 json 数据的缓存。

HTML 和 JS/CSS/image 资源都属于静态文件,HTTP 本身提供了缓存协议,浏览器实现了这些协议,可以做到静态文件的缓存,具体可以参考这里,总的来说,就是两种缓存:

  1. 询问是否有更新:根据 If-Modified-Since / ETag 等协议向后端请求询问是否有更新,没有更新返回304,浏览器使用本地缓存。
  2. 直接使用本地缓存:根据协议里的 Cache-Control / Expires 字段去确定多长时间内可以不去发请求询问更新,直接使用本地缓存。

前端能做的最大限度的缓存策略是:HTML 文件每次都向服务器询问是否有更新,JS/CSS/Image资源文件则不请求更新,直接使用本地缓存。那 JS/CSS 资源文件如何更新?常见做法是在在构建过程中给每个资源文件一个版本号或hash值,若资源文件有更新,版本号和 hash 值变化,这个资源请求的 URL 就变化了,同时对应的 HTML 页面更新,变成请求新的资源URL,资源也就更新了。

json 数据的缓存可以用 localStorage 缓存请求下来的数据,可以在首次显示时先用本地数据,再请求更新,这都由前端 JS 控制。

这些缓存策略可以实现 JS/CSS 等资源文件以及用户数据的缓存的全缓存,可以做到每次都直接使用本地缓存数据,不用等待网络请求。但 HTML 文件的缓存做不到,对于 HTML 文件,如果把 Expires / max-age 时间设长了,长时间只使用本地缓存,那更新就不及时,如果设短了,每次打开页面都要发网络请求询问是否有更新,再确定是否使用本地资源,一般前端在这里的策略是每次都请求,这在弱网情况下用户感受到的白屏时间仍然会很长。所以 HTML 文件的“缓存”和跟“更新”间存在矛盾。

客户端优化

接着轮到客户端出场了,桌面时代受限于浏览器,H5 页面无法做更多的优化,现在 H5 页面是内嵌在客户端 APP 上,客户端有更多的权限,于是客户端上可以超出浏览器的范围,做更多的优化。

HTML 缓存

先接着缓存说,在客户端有更自由的缓存策略,客户端可以拦截 H5 页面的所有请求,由自己管理缓存,针对上述 HTML 文件的“缓存”和“更新”之间的矛盾,我们可以用这样的策略解决:

  1. 在客户端拦截请求,首次请求 HTML 文件后缓存数据,第二次不发请求,直接使用缓存数据。
  2. 什么时候去请求更新?这个更新请求可以客户端自由控制策略,可以在使用本地缓存打开本地页面后再在后台发起请求询问更新缓存,下次打开时生效;也可以在 APP 启动时或某个时机在后台去发起请求预更新,提升用户访问最新代码的几率。

这样看起来已经比较完美了,HTML 文件在用客户端的策略缓存,其余资源和数据沿用上述前端的缓存方式,这样一个 H5 页面第二次访问从 HTML 到 JS/CSS/Image 资源,再到数据,都可以直接从本地读取,无需等待网络请求,同时又能保持尽可能的实时更新,解决了缓存问题,大大提升 H5 页面首屏启动速度。

问题

上述方案似乎已完整解决缓存问题,但实际上还有很多问题:

  1. 没有预加载: 第一次打开的体验很差,所有数据都要从网络请求。
  2. 缓存不可控: 缓存的存取由系统 webview 控制,无法控制它的缓存逻辑,带来的问题包括: i. 清理逻辑不可控,缓存空间有限,可能缓存几张大图片后,重要的 HTML/JS/CSS 缓存就被清除了。 ii.磁盘 IO 无法控制,无法从磁盘预加载数据到内存。
  3. 更新体验差: 后台 HTML/JS/CSS 更新时全量下载,数据量大,弱网下载耗时长。
  4. 无法防劫持: 若 HTML 页面被运营商或其他第三方劫持,将长时间缓存劫持的页面。

这些问题在客户端上都是可以被解决的,只不过有点麻烦,简单描述下:

  1. 可以配置一个预加载列表,在APP启动或某些时机时提前去请求,这个预加载列表需要包含所需 H5 模块的页面和资源,还需要考虑到一个H5模块有多个页面的情况,这个列表可能会很大,也需要工具生成和管理这个预加载列表。
  2. 客户端可以接管所有请求的缓存,不走 webview 默认缓存逻辑,自行实现缓存机制,可以分缓存优先级以及缓存预加载。
  3. 可以针对每个 HTML 和资源文件做增量更新,只是实现和管理起来比较麻烦。
  4. 在客户端使用 httpdns + https 防劫持。

上面的解决方案实现起来十分繁琐,原因就是各个 HTML 和资源文件很多很分散,管理困难,有个较好的方案可以解决这些问题,就是离线包。

离线包

既然很多问题都是文件分散管理困难引起,而我们这里的使用场景是使用 H5 开发功能模块,那很容易想到把一个个功能模块的所有相关页面和资源打包下发,这个压缩包可以称为功能模块的离线包。使用离线包的方案,可以相对较简单地解决上述几个问题:

  1. 可以预先下载整个离线包,只需要按业务模块配置,不需要按文件配置,离线包包含业务模块相关的所有页面,可以一次性预加载。
  2. 离线包核心文件和页面动态的图片资源文件缓存分离,可以更方便地管理缓存,离线包也可以整体提前加载进内存,减少磁盘 IO 耗时。
  3. 离线包可以很方便地根据版本做增量更新。
  4. 离线包以压缩包的方式下发,同时会经过加密和校验,运营商和第三方无法对其劫持篡改。

到这里,对于使用 H5 开发功能模块,离线包是一个挺不错的方案了,简单复述一下离线包的方案:

  1. 后端使用构建工具把同一个业务模块相关的页面和资源打包成一个文件,同时对文件加密/签名。
  2. 客户端根据配置表,在自定义时机去把离线包拉下来,做解压/解密/校验等工作。
  3. 根据配置表,打开某个业务时转接到打开离线包的入口页面。
  4. 拦截网络请求,对于离线包已经有的文件,直接读取离线包数据返回,否则走 HTTP 协议缓存逻辑。
  5. 离线包更新时,根据版本号后台下发两个版本间的 diff 数据,客户端合并,增量更新。

更多优化

离线包方案在缓存上已经做得差不多了,还可以再配上一些细节优化:

公共资源包

每个包都会使用相同的 JS 框架和 CSS 全局样式,这些资源重复在每一个离线包出现太浪费,可以做一个公共资源包提供这些全局文件。

预加载 webview

无论是 iOS 还是 Android,本地 webview 初始化都要不少时间,可以预先初始化好 webview。这里分两种预加载:

  1. 首次预加载:在一个进程内首次初始化 webview 与第二次初始化不同,首次会比第二次慢很多。原因预计是 webview 首次初始化后,即使 webview 已经释放,但一些多 webview 共用的全局服务或资源对象仍没有释放,第二次初始化时不需要再生成这些对象从而变快。我们可以在 APP 启动时预先初始化一个 webview 然后释放,这样等用户真正走到 H5 模块去加载 webview时就变快了。
  2. webview 池:可以用两个或多个 webview 重复使用,而不是每次打开 H5 都新建 webview。不过这种方式要解决页面跳转时清空上一个页面,另外若一个 H5 页面上 JS 出现内存泄漏,就影响到其他页面,在 APP 运行期间都无法释放了。

可以参考美团点评的这篇文章

预加载数据

理想情况下离线包的方案第一次打开时所有 HTML/JS/CSS 都使用本地缓存,无需等待网络请求,但页面上的用户数据还是需要实时拉,这里可以做个优化,在 webview 初始化的同时并行去请求数据,webview 初始化是需要一些时间的,这段时间没有任何网络请求,在这个时机并行请求可以节省不少时间。

具体实现上,首先可以在配置表注明某个离线包需要预加载的 URL,客户端在 webview 初始化同时发起请求,请求由一个管理器管理,请求完成时缓存结果,然后 webview 在初始化完毕后开始请求刚才预加载的 URL,客户端拦截到请求,转接到刚才提到的请求管理器,若预加载已完成就直接返回内容,若未完成则等待。

Fallback

如果用户访问某个离线包模块时,这个离线包还没有下载,或配置表检测到已有新版本但本地是旧版本的情况如何处理?几种方案:

  1. 简单的方案是如果本地离线包没有或不是最新,就同步阻塞等待下载最新离线包。这种用户打开的体验更差了,因为离线包体积相对较大。
  2. 也可以是如果本地有旧包,用户本次就直接使用旧包,如果没有再同步阻塞等待,这种会导致更新不及时,无法确保用户使用最新版本。
  3. 还可以对离线包做一个线上版本,离线包里的文件在服务端有一一对应的访问地址,在本地没有离线包时,直接访问对应的线上地址,跟传统打开一个在线页面一样,这种体验相对等待下载整个离线包较好,也能保证用户访问到最新。

第三种 Fallback 的方式还带来兜底的好处,在一些意外情况离线包出错的时候可以直接访问线上版本,功能不受影响,此外像公共资源包更新不及时导致版本没有对应上时也可以直接访问线上版本,是个不错的兜底方案。

上述几种方案策略也可以混着使用,看业务需求。

使用客户端接口

网路和存储接口如果使用 webkit 的 ajax 和 localStorage 会有不少限制,难以优化,可以在客户端提供这些接口给 JS,客户端可以在网络请求上做像 DNS 预解析/IP直连/长连接/并行请求等更细致的优化,存储也使用客户端接口也能做读写并发/用户隔离等针对性优化。

服务端渲染

早期 web 页面里,JS 只是负责交互,所有内容都是直接在 HTML 里,到现代 H5 页面,很多内容已经依赖 JS 逻辑去决定渲染什么,例如等待 JS 请求 JSON 数据,再拼接成 HTML 生成 DOM 渲染到页面上,于是页面的渲染展现就要等待这一整个过程,这里有一个耗时,减少这里的耗时也是白屏优化的范围之内。

优化方法可以是人为减少 JS 渲染逻辑,也可以是更彻底地,回归到原始,所有内容都由服务端返回的 HTML 决定,无需等待 JS 逻辑,称之为服务端渲染。是否做这种优化视业务情况而定,毕竟这种会带来开发模式变化/流量增大/服务端开销增大这些负面影响。手Q的部分页面就是使用服务端渲染的方式,称为动态直出,见文章

最后

从前端优化,到客户端缓存,到离线包,到更多的细节优化,做到上述这些点,H5 页面在启动上差不多可以媲美原生的体验了。

总结起来,大体优化思路就是:缓存/预加载/并行,缓存一切网络请求,尽量在用户打开之前就加载好所有内容,能并行做的事不串行做。这里有些优化手段需要做好一整套工具和流程支持,需要跟开发效率权衡,视实际需求优化。

另外上述讨论的是针对功能模块类的 H5 页面秒开的优化方案,客户端 APP 上除了功能模块,其他一些像营销活动/外部接入的 H5 页面可能有些优化点就不适用,还需要视实际情况和需求而定。另外微信小程序就是属于功能模块的类别,差不多是这个套路。

这里讨论了 H5 页面首屏启动时间的优化,上述优化过后,基本上耗时只剩 webview 本身的启动/渲染机制问题了,这个问题跟后续的响应流畅度的问题一起属于另一个优化范围,就是类 RN / Weex 这样的方案,有机会再探讨。

]]>
随着移动设备性能不断增强,web 页面的性能体验逐渐变得可以接受,又因为 web 开发模式的诸多好处(跨平台,动态更新,减体积,无限扩展),APP 客户端里出现越来越多内嵌 web 页面(为了配上当前流行的说法,以下把所有网页都称为 H5 页面,虽然可能跟 H5 没关系),很多 APP 把一些功能模块改成用 H5 实现。

虽然说 H5 页面性能变好了,但如果没针对性地做一些优化,体验还是很糟糕的,主要两部分体验:

  1. 页面启动白屏时间:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
  2. 响应流畅度:由于 webkit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。

本文先不讨论第二点,只讨论第一点,怎样减少白屏时间。对 APP 里的一些使用 H5 实现的功能模块,怎样加快它们的启动速度,让它们启动的体验接近原生。

过程

为什么打开一个 H5 页面会有一长段白屏时间?因为它做了很多事情,大概是:

初始化 webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 -> dom 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片

一些简单的页面可能没有 JS 请求数据 这一步,但大部分功能模块应该是有的,根据当前用户信息,JS 向后台请求相关数据再渲染,是常规开发方式。

一般页面在 dom 渲染后能显示雏形,在这之前用户看到的都是白屏,等到下载渲染图片后整个页面才完整显示,首屏秒开优化就是要减少这个过程的耗时。

前端优化

上述打开一个页面的过程有很多优化点,包括前端和客户端,常规的前端和后端的性能优化在桌面时代已经有最佳实践,主要的是:

  1. 降低请求量: 合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
  2. 加快请求速度: 预解析DNS,减少域名数,并行加载,CDN 分发。
  3. 缓存: HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存localStorage。
  4. 渲染: JS/CSS优化,加载顺序,服务端渲染,pipeline。

其中对首屏启动速度影响最大的就是网络请求,所以优化的重点就是缓存,这里着重说一下前端对请求的缓存策略。我们再细分一下,分成 HTML 的缓存,JS/CSS/image 资源的缓存,以及 json 数据的缓存。

HTML 和 JS/CSS/image 资源都属于静态文件,HTTP 本身提供了缓存协议,浏览器实现了这些协议,可以做到静态文件的缓存,具体可以参考这里,总的来说,就是两种缓存:

  1. 询问是否有更新:根据 If-Modified-Since / ETag 等协议向后端请求询问是否有更新,没有更新返回304,浏览器使用本地缓存。
  2. 直接使用本地缓存:根据协议里的 Cache-Control / Expires 字段去确定多长时间内可以不去发请求询问更新,直接使用本地缓存。

前端能做的最大限度的缓存策略是:HTML 文件每次都向服务器询问是否有更新,JS/CSS/Image资源文件则不请求更新,直接使用本地缓存。那 JS/CSS 资源文件如何更新?常见做法是在在构建过程中给每个资源文件一个版本号或hash值,若资源文件有更新,版本号和 hash 值变化,这个资源请求的 URL 就变化了,同时对应的 HTML 页面更新,变成请求新的资源URL,资源也就更新了。

json 数据的缓存可以用 localStorage 缓存请求下来的数据,可以在首次显示时先用本地数据,再请求更新,这都由前端 JS 控制。

这些缓存策略可以实现 JS/CSS 等资源文件以及用户数据的缓存的全缓存,可以做到每次都直接使用本地缓存数据,不用等待网络请求。但 HTML 文件的缓存做不到,对于 HTML 文件,如果把 Expires / max-age 时间设长了,长时间只使用本地缓存,那更新就不及时,如果设短了,每次打开页面都要发网络请求询问是否有更新,再确定是否使用本地资源,一般前端在这里的策略是每次都请求,这在弱网情况下用户感受到的白屏时间仍然会很长。所以 HTML 文件的“缓存”和跟“更新”间存在矛盾。

客户端优化

接着轮到客户端出场了,桌面时代受限于浏览器,H5 页面无法做更多的优化,现在 H5 页面是内嵌在客户端 APP 上,客户端有更多的权限,于是客户端上可以超出浏览器的范围,做更多的优化。

HTML 缓存

先接着缓存说,在客户端有更自由的缓存策略,客户端可以拦截 H5 页面的所有请求,由自己管理缓存,针对上述 HTML 文件的“缓存”和“更新”之间的矛盾,我们可以用这样的策略解决:

  1. 在客户端拦截请求,首次请求 HTML 文件后缓存数据,第二次不发请求,直接使用缓存数据。
  2. 什么时候去请求更新?这个更新请求可以客户端自由控制策略,可以在使用本地缓存打开本地页面后再在后台发起请求询问更新缓存,下次打开时生效;也可以在 APP 启动时或某个时机在后台去发起请求预更新,提升用户访问最新代码的几率。

这样看起来已经比较完美了,HTML 文件在用客户端的策略缓存,其余资源和数据沿用上述前端的缓存方式,这样一个 H5 页面第二次访问从 HTML 到 JS/CSS/Image 资源,再到数据,都可以直接从本地读取,无需等待网络请求,同时又能保持尽可能的实时更新,解决了缓存问题,大大提升 H5 页面首屏启动速度。

问题

上述方案似乎已完整解决缓存问题,但实际上还有很多问题:

  1. 没有预加载: 第一次打开的体验很差,所有数据都要从网络请求。
  2. 缓存不可控: 缓存的存取由系统 webview 控制,无法控制它的缓存逻辑,带来的问题包括: i. 清理逻辑不可控,缓存空间有限,可能缓存几张大图片后,重要的 HTML/JS/CSS 缓存就被清除了。 ii.磁盘 IO 无法控制,无法从磁盘预加载数据到内存。
  3. 更新体验差: 后台 HTML/JS/CSS 更新时全量下载,数据量大,弱网下载耗时长。
  4. 无法防劫持: 若 HTML 页面被运营商或其他第三方劫持,将长时间缓存劫持的页面。

这些问题在客户端上都是可以被解决的,只不过有点麻烦,简单描述下:

  1. 可以配置一个预加载列表,在APP启动或某些时机时提前去请求,这个预加载列表需要包含所需 H5 模块的页面和资源,还需要考虑到一个H5模块有多个页面的情况,这个列表可能会很大,也需要工具生成和管理这个预加载列表。
  2. 客户端可以接管所有请求的缓存,不走 webview 默认缓存逻辑,自行实现缓存机制,可以分缓存优先级以及缓存预加载。
  3. 可以针对每个 HTML 和资源文件做增量更新,只是实现和管理起来比较麻烦。
  4. 在客户端使用 httpdns + https 防劫持。

上面的解决方案实现起来十分繁琐,原因就是各个 HTML 和资源文件很多很分散,管理困难,有个较好的方案可以解决这些问题,就是离线包。

离线包

既然很多问题都是文件分散管理困难引起,而我们这里的使用场景是使用 H5 开发功能模块,那很容易想到把一个个功能模块的所有相关页面和资源打包下发,这个压缩包可以称为功能模块的离线包。使用离线包的方案,可以相对较简单地解决上述几个问题:

  1. 可以预先下载整个离线包,只需要按业务模块配置,不需要按文件配置,离线包包含业务模块相关的所有页面,可以一次性预加载。
  2. 离线包核心文件和页面动态的图片资源文件缓存分离,可以更方便地管理缓存,离线包也可以整体提前加载进内存,减少磁盘 IO 耗时。
  3. 离线包可以很方便地根据版本做增量更新。
  4. 离线包以压缩包的方式下发,同时会经过加密和校验,运营商和第三方无法对其劫持篡改。

到这里,对于使用 H5 开发功能模块,离线包是一个挺不错的方案了,简单复述一下离线包的方案:

  1. 后端使用构建工具把同一个业务模块相关的页面和资源打包成一个文件,同时对文件加密/签名。
  2. 客户端根据配置表,在自定义时机去把离线包拉下来,做解压/解密/校验等工作。
  3. 根据配置表,打开某个业务时转接到打开离线包的入口页面。
  4. 拦截网络请求,对于离线包已经有的文件,直接读取离线包数据返回,否则走 HTTP 协议缓存逻辑。
  5. 离线包更新时,根据版本号后台下发两个版本间的 diff 数据,客户端合并,增量更新。

更多优化

离线包方案在缓存上已经做得差不多了,还可以再配上一些细节优化:

公共资源包

每个包都会使用相同的 JS 框架和 CSS 全局样式,这些资源重复在每一个离线包出现太浪费,可以做一个公共资源包提供这些全局文件。

预加载 webview

无论是 iOS 还是 Android,本地 webview 初始化都要不少时间,可以预先初始化好 webview。这里分两种预加载:

  1. 首次预加载:在一个进程内首次初始化 webview 与第二次初始化不同,首次会比第二次慢很多。原因预计是 webview 首次初始化后,即使 webview 已经释放,但一些多 webview 共用的全局服务或资源对象仍没有释放,第二次初始化时不需要再生成这些对象从而变快。我们可以在 APP 启动时预先初始化一个 webview 然后释放,这样等用户真正走到 H5 模块去加载 webview时就变快了。
  2. webview 池:可以用两个或多个 webview 重复使用,而不是每次打开 H5 都新建 webview。不过这种方式要解决页面跳转时清空上一个页面,另外若一个 H5 页面上 JS 出现内存泄漏,就影响到其他页面,在 APP 运行期间都无法释放了。

可以参考美团点评的这篇文章

预加载数据

理想情况下离线包的方案第一次打开时所有 HTML/JS/CSS 都使用本地缓存,无需等待网络请求,但页面上的用户数据还是需要实时拉,这里可以做个优化,在 webview 初始化的同时并行去请求数据,webview 初始化是需要一些时间的,这段时间没有任何网络请求,在这个时机并行请求可以节省不少时间。

具体实现上,首先可以在配置表注明某个离线包需要预加载的 URL,客户端在 webview 初始化同时发起请求,请求由一个管理器管理,请求完成时缓存结果,然后 webview 在初始化完毕后开始请求刚才预加载的 URL,客户端拦截到请求,转接到刚才提到的请求管理器,若预加载已完成就直接返回内容,若未完成则等待。

Fallback

如果用户访问某个离线包模块时,这个离线包还没有下载,或配置表检测到已有新版本但本地是旧版本的情况如何处理?几种方案:

  1. 简单的方案是如果本地离线包没有或不是最新,就同步阻塞等待下载最新离线包。这种用户打开的体验更差了,因为离线包体积相对较大。
  2. 也可以是如果本地有旧包,用户本次就直接使用旧包,如果没有再同步阻塞等待,这种会导致更新不及时,无法确保用户使用最新版本。
  3. 还可以对离线包做一个线上版本,离线包里的文件在服务端有一一对应的访问地址,在本地没有离线包时,直接访问对应的线上地址,跟传统打开一个在线页面一样,这种体验相对等待下载整个离线包较好,也能保证用户访问到最新。

第三种 Fallback 的方式还带来兜底的好处,在一些意外情况离线包出错的时候可以直接访问线上版本,功能不受影响,此外像公共资源包更新不及时导致版本没有对应上时也可以直接访问线上版本,是个不错的兜底方案。

上述几种方案策略也可以混着使用,看业务需求。

使用客户端接口

网路和存储接口如果使用 webkit 的 ajax 和 localStorage 会有不少限制,难以优化,可以在客户端提供这些接口给 JS,客户端可以在网络请求上做像 DNS 预解析/IP直连/长连接/并行请求等更细致的优化,存储也使用客户端接口也能做读写并发/用户隔离等针对性优化。

服务端渲染

早期 web 页面里,JS 只是负责交互,所有内容都是直接在 HTML 里,到现代 H5 页面,很多内容已经依赖 JS 逻辑去决定渲染什么,例如等待 JS 请求 JSON 数据,再拼接成 HTML 生成 DOM 渲染到页面上,于是页面的渲染展现就要等待这一整个过程,这里有一个耗时,减少这里的耗时也是白屏优化的范围之内。

优化方法可以是人为减少 JS 渲染逻辑,也可以是更彻底地,回归到原始,所有内容都由服务端返回的 HTML 决定,无需等待 JS 逻辑,称之为服务端渲染。是否做这种优化视业务情况而定,毕竟这种会带来开发模式变化/流量增大/服务端开销增大这些负面影响。手Q的部分页面就是使用服务端渲染的方式,称为动态直出,见文章

最后

从前端优化,到客户端缓存,到离线包,到更多的细节优化,做到上述这些点,H5 页面在启动上差不多可以媲美原生的体验了。

总结起来,大体优化思路就是:缓存/预加载/并行,缓存一切网络请求,尽量在用户打开之前就加载好所有内容,能并行做的事不串行做。这里有些优化手段需要做好一整套工具和流程支持,需要跟开发效率权衡,视实际需求优化。

另外上述讨论的是针对功能模块类的 H5 页面秒开的优化方案,客户端 APP 上除了功能模块,其他一些像营销活动/外部接入的 H5 页面可能有些优化点就不适用,还需要视实际情况和需求而定。另外微信小程序就是属于功能模块的类别,差不多是这个套路。

这里讨论了 H5 页面首屏启动时间的优化,上述优化过后,基本上耗时只剩 webview 本身的启动/渲染机制问题了,这个问题跟后续的响应流畅度的问题一起属于另一个优化范围,就是类 RN / Weex 这样的方案,有机会再探讨。

]]>
0
<![CDATA[SYN和RTO]]> http://www.udpwork.com/item/16382.html http://www.udpwork.com/item/16382.html#reviews Sun, 13 Aug 2017 15:21:38 +0800 老王 http://www.udpwork.com/item/16382.html 前两天,我在微博上推荐了一篇朝花夕拾的文章:The story of one latency spike,文章中介绍了 cloudflare 工程师如何一步一步 debug 网络延迟问题,细细读来受益良多,不过我并不打算详细介绍那篇文章的细枝末节, 本文只摘录一个点:

When debugging network problems the delays of 1s, 30s are very characteristic. They may indicate packet loss since the SYN packets are usually retransmitted at times 1s, 3s, 7s, 15, 31s.

为什么是 1 秒、3 秒、7 秒、15 秒、31 秒?说来惭愧,我以前从没有注意过 SYN 重建时的时间特征,知耻而后勇,正好借此机会来一探究竟。

下面让我们通过一个实验来重现一下 SYN 超时重传的现象:

  1. 在服务端屏蔽请求:「iptables -A INPUT –dport 1234 –syn -j DROP」
  2. 在服务端监听 1234 端口:「nc -l 1234」
  3. 在客户端开启抓包:「tcpdump -nn -i any port 1234」
  4. 在客户端发起请求:「date; nc <SERVER> 1234; date」

经过一段时间的等待后,我们可以看到 tcpdump 输出如下:

12:53:15.511826 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:16.511042 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:18.511058 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:22.511042 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:30.511065 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:46.511068 IP CLIENT.53384 > SERVER.1234: Flags [S], ...

第一行是正常发出的 SYN,后面五行是超时重传发出的 SYN,相对于正常发出的 SYN,它们的延迟分别是:1 秒、3 秒、7 秒、15 秒、31 秒,正好符合开头的描述。之所以重传五次是因为 net.ipv4.tcp_syn_retries 的缺省值是 5。

此外,我们可以看到两次 date 命令输出的时间如下:

Sun Aug 13 12:53:15 CST 2017
Sun Aug 13 12:54:18 CST 2017

可见整个握手过程从开始到超时一共持续了 63 秒,其中 31 秒是总计五次 SYN 发送的时间,剩下的 32 秒是确认第五次 SYN 超时的时间(2 的 5 次方)。由此可见,在握手阶段一旦出现严重丢包,网络延迟会非常久,很多时候这是没有必要的,比如 Web 服务器,可以考虑设置 net.ipv4.tcp_syn_retries 为 2 或者 3 比较合适,一旦出现严重丢包,与其不断重传,不如及时放弃。

如果要研究得更深入些,我们还需要了解 RTO(retransmission timeout),即超时重传时间,具体计算方法很复杂,我就不多说了,有兴趣的可以参考本文解决的推荐链接,你只要知道系统会根据网络连接的情况动态调整该值的大小即可。不过在 SYN 握手阶段,网络连接还没有建立起来,如果此时发生丢包,那么因为系统没有可以参照的 RTT(Round-Trip Time),所以此时只能给出系统缺省设置的 RTO:

#define TCP_RTO_MAX	((unsigned)(120*HZ))
#define TCP_RTO_MIN	((unsigned)(HZ/5))
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))

...

unsigned long timeo;

if (req->num_timeout++ == 0)
    atomic_dec(&queue->young);
timeo = min(TCP_TIMEOUT_INIT << req->num_timeout, TCP_RTO_MAX);
mod_timer(&req->rsk_timer, jiffies + timeo);
return;

可见 RTO 的最大值是 120 秒,最小值是 200 毫秒,在连接建立前的初始值是 1 秒,如果经过多次重传,每次 RTO 的值翻倍,但最大不得超过 120 秒:

  1. 第 1 次重传:超时时间是 2 的 0 次方,也就是 1 秒。
    延迟:1 秒
  2. 第 2 次重传:超时时间是 2 的 1 次方,也就是 2 秒。
    延迟:1 + 2 = 3 秒
  3. 第 3 次重传:超时时间是 2 的 2 次方,也就是 4 秒。
    延迟:1+ 2 + 4 = 7 秒
  4. 第 4 次重传:超时时间是 2 的 3 次方,也就是 8 秒。
    延迟:1 + 2 + 4 + 8 = 15 秒
  5. 第 5 次重传:超时时间是 2 的 4 次方,也就是 16 秒。
    延迟:1 + 2 + 4 + 8 + 16 = 31 秒

说点题外话,很多人在应用层实现失败重试逻辑的时候,往往是单纯的循环重试,这样过于生硬,重试的成功率往往也不高,可以考虑一下 TCP 的翻翻算法。

还有一点需要说明的是,在建立连接后,因为目前网络都很快,所以大部分连接的 RTO 都会接近 TCP_RTO_MIN,也就是 200ms,可以通过「ss -int」命令来确认。关于超时重传还有很多细节需要考虑,下面列出一些资料:

本文相当于快餐,建议仔细阅读如上正餐。

]]>
前两天,我在微博上推荐了一篇朝花夕拾的文章:The story of one latency spike,文章中介绍了 cloudflare 工程师如何一步一步 debug 网络延迟问题,细细读来受益良多,不过我并不打算详细介绍那篇文章的细枝末节, 本文只摘录一个点:

When debugging network problems the delays of 1s, 30s are very characteristic. They may indicate packet loss since the SYN packets are usually retransmitted at times 1s, 3s, 7s, 15, 31s.

为什么是 1 秒、3 秒、7 秒、15 秒、31 秒?说来惭愧,我以前从没有注意过 SYN 重建时的时间特征,知耻而后勇,正好借此机会来一探究竟。

下面让我们通过一个实验来重现一下 SYN 超时重传的现象:

  1. 在服务端屏蔽请求:「iptables -A INPUT –dport 1234 –syn -j DROP」
  2. 在服务端监听 1234 端口:「nc -l 1234」
  3. 在客户端开启抓包:「tcpdump -nn -i any port 1234」
  4. 在客户端发起请求:「date; nc <SERVER> 1234; date」

经过一段时间的等待后,我们可以看到 tcpdump 输出如下:

12:53:15.511826 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:16.511042 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:18.511058 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:22.511042 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:30.511065 IP CLIENT.53384 > SERVER.1234: Flags [S], ...
12:53:46.511068 IP CLIENT.53384 > SERVER.1234: Flags [S], ...

第一行是正常发出的 SYN,后面五行是超时重传发出的 SYN,相对于正常发出的 SYN,它们的延迟分别是:1 秒、3 秒、7 秒、15 秒、31 秒,正好符合开头的描述。之所以重传五次是因为 net.ipv4.tcp_syn_retries 的缺省值是 5。

此外,我们可以看到两次 date 命令输出的时间如下:

Sun Aug 13 12:53:15 CST 2017
Sun Aug 13 12:54:18 CST 2017

可见整个握手过程从开始到超时一共持续了 63 秒,其中 31 秒是总计五次 SYN 发送的时间,剩下的 32 秒是确认第五次 SYN 超时的时间(2 的 5 次方)。由此可见,在握手阶段一旦出现严重丢包,网络延迟会非常久,很多时候这是没有必要的,比如 Web 服务器,可以考虑设置 net.ipv4.tcp_syn_retries 为 2 或者 3 比较合适,一旦出现严重丢包,与其不断重传,不如及时放弃。

如果要研究得更深入些,我们还需要了解 RTO(retransmission timeout),即超时重传时间,具体计算方法很复杂,我就不多说了,有兴趣的可以参考本文解决的推荐链接,你只要知道系统会根据网络连接的情况动态调整该值的大小即可。不过在 SYN 握手阶段,网络连接还没有建立起来,如果此时发生丢包,那么因为系统没有可以参照的 RTT(Round-Trip Time),所以此时只能给出系统缺省设置的 RTO:

#define TCP_RTO_MAX	((unsigned)(120*HZ))
#define TCP_RTO_MIN	((unsigned)(HZ/5))
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))

...

unsigned long timeo;

if (req->num_timeout++ == 0)
    atomic_dec(&queue->young);
timeo = min(TCP_TIMEOUT_INIT << req->num_timeout, TCP_RTO_MAX);
mod_timer(&req->rsk_timer, jiffies + timeo);
return;

可见 RTO 的最大值是 120 秒,最小值是 200 毫秒,在连接建立前的初始值是 1 秒,如果经过多次重传,每次 RTO 的值翻倍,但最大不得超过 120 秒:

  1. 第 1 次重传:超时时间是 2 的 0 次方,也就是 1 秒。
    延迟:1 秒
  2. 第 2 次重传:超时时间是 2 的 1 次方,也就是 2 秒。
    延迟:1 + 2 = 3 秒
  3. 第 3 次重传:超时时间是 2 的 2 次方,也就是 4 秒。
    延迟:1+ 2 + 4 = 7 秒
  4. 第 4 次重传:超时时间是 2 的 3 次方,也就是 8 秒。
    延迟:1 + 2 + 4 + 8 = 15 秒
  5. 第 5 次重传:超时时间是 2 的 4 次方,也就是 16 秒。
    延迟:1 + 2 + 4 + 8 + 16 = 31 秒

说点题外话,很多人在应用层实现失败重试逻辑的时候,往往是单纯的循环重试,这样过于生硬,重试的成功率往往也不高,可以考虑一下 TCP 的翻翻算法。

还有一点需要说明的是,在建立连接后,因为目前网络都很快,所以大部分连接的 RTO 都会接近 TCP_RTO_MIN,也就是 200ms,可以通过「ss -int」命令来确认。关于超时重传还有很多细节需要考虑,下面列出一些资料:

本文相当于快餐,建议仔细阅读如上正餐。

]]>
0
<![CDATA[基于办公的 IM 的基础设计]]> http://www.udpwork.com/item/16381.html http://www.udpwork.com/item/16381.html#reviews Fri, 11 Aug 2017 15:46:23 +0800 云风 http://www.udpwork.com/item/16381.html 现在的 IM 在设计上是基于会话的,多个人可以组成一个会话,相当于一个聊天室,当一个人加入到一个会话后,就可以看到从加入开始之后这个聊天室里所有参与人的发言。有的 IM 会把两人对话也抽象成同一个东西,也可能出于优化的考虑把双人对话特殊处理。

所以,这些 IM 在操作界面上会有一个会话列表:表现出来会是联系人名单、聊天群列表等等。选中会话列表中的项目,进入会话查看聊天记录、发言,就是这类 IM 的使用逻辑。

我认为,这种对即时通讯的抽象方式,其实是不适合办公环境的。和日常个人社交环境不同,办公群体其实是一个相对关系密切的团体,我们通常不会拉黑一个同事不让他给你发消息,也不会拒收公司发的通告,也不会因为一个同事平常不和你打交道就拒绝建立联系。项目组里的讨论,也未见得是多么保密的事情,需要防止隔壁组的同事旁听。你也很少会在办公 IM 上和妹子私聊谈人生理想。

我们这几年使用腾讯的 RTX 作为公司办公使用,我就感受到了太多这类设计缺陷。比如,有同事找我有事,我忽略了他的私聊信息;找人一般在对方活跃的项目群组里吼;程序群沦为了日常扯淡的位置,常常同时讨论着不同的问题,线索及其混乱。“群”这个设计,我在很多年前就思考过,我一直觉得需要在根本上换个角度看待社交聊天的需求。

我现在的想法是这样的:

作为办公 IM ,我们不应该基于固定会话(群)来设计,而应该是“通知”和“话题”。

所谓通知,就是有人发起了一条消息,他需要把这条消息传达给某些对象,对象可以是人,也可以是某个组织:比如程序、游戏项目组、等等。

组织并非是群那样的聊天室,而仅仅是一个标签,由人来关注标签,而不是去组织这个聊天室里有多少听众。

而话题,则是由消息或旧话题衍生而来。任何通知消息、话题内部消息,都可以变成一个新话题。话题也可以包含在一则消息里转发给某个对象。

用户的客户端应该把所有的通知按时间线排列在一起,呈现在同一个地方。也就是说,无论是谁给我发消息(默认就是通知)都应该投递在一起,而不是像现在 RTX 那样只是在系统托盘里闪烁提醒、也不是微信 qq 那样,联系人名单上多出一个小红点。

而一旦我回复一个通知消息,其实就把这则通知转化成了一个话题,在时间线上,话题内的消息是归属在一起的。同一时刻,无论你的思维切换多么快,其实在短时间内你只能聚焦在一个话题上,所以客户端界面是很容易表达的,把当前话题展开在主界面(通知的时间线)即可,切换话题后自然可以折叠起来。

话题并不是聊天室、它更像是论坛的帖子。一个话题可以有很多人参与(至少发言一次),更应该支持更多的人浏览。我们不应该按聊天室的思路:用户只有在加入聊天室的那一刻开始,才能收到后续的消息,而应该像论坛那样,他只是打开了这个话题帖,可以随时聊天过去到现在发生的事情。话题内的任何一个消息,都可以由用户展开为新话题,老话题对新话题只是一个引用链接而已,并不需要有层级关系,我们也可以把任意一个话题或尚未转化为话题的消息转发出去,如果有人对他评论,就生成了新话题。

话题是一个有时效性的东西,对于办公来说,如果一个话题超过 8 小时没有新的消息,就可以认为这个话题已经结束了。但是事后我们依然可以对老话题浏览,或是继续讨论,而继续讨论就是生成的一个新话题了。

只要生成话题足够方便,每个用户的主时间线上就只会有不多的通知消息,信息传达更为有效。而管理每天的消息、检索旧消息也有很强的时间线。不像现有的 IM 群聊天,每天的聊天内容会被自然的组织成话题,这些话题上标识了参与人数、归属的组织的 tag 、继承于哪个父话题或通知、经历的时间段、衍生出哪些后续话题,等等。

即使是两个人之间的对话,也同样应该是话题的形式,而不应该把消息直接组织成一长串的聊天历史。话题未必有明确的主题,只是一种更自然的信息聚合形式而已。

对于办公场合来说,有意个最重要的优势:用户群有足够的自律。基于这种自律,我认为上面的思路若实现出来很容易推广使用。

在自律之外,或许还需要一些权限管理。这些权限管理应该是相对松散简单的,主要是限制用户订阅特定组织的 tag (比如一般员工不能订阅管理层的 tag ),限制围观特定话题(比如两人之间的私聊话题默认就是不对第三人开放权限的),话题可以锁定不准转发。权限设置的细节还需要进一步推敲。

]]>
现在的 IM 在设计上是基于会话的,多个人可以组成一个会话,相当于一个聊天室,当一个人加入到一个会话后,就可以看到从加入开始之后这个聊天室里所有参与人的发言。有的 IM 会把两人对话也抽象成同一个东西,也可能出于优化的考虑把双人对话特殊处理。

所以,这些 IM 在操作界面上会有一个会话列表:表现出来会是联系人名单、聊天群列表等等。选中会话列表中的项目,进入会话查看聊天记录、发言,就是这类 IM 的使用逻辑。

我认为,这种对即时通讯的抽象方式,其实是不适合办公环境的。和日常个人社交环境不同,办公群体其实是一个相对关系密切的团体,我们通常不会拉黑一个同事不让他给你发消息,也不会拒收公司发的通告,也不会因为一个同事平常不和你打交道就拒绝建立联系。项目组里的讨论,也未见得是多么保密的事情,需要防止隔壁组的同事旁听。你也很少会在办公 IM 上和妹子私聊谈人生理想。

我们这几年使用腾讯的 RTX 作为公司办公使用,我就感受到了太多这类设计缺陷。比如,有同事找我有事,我忽略了他的私聊信息;找人一般在对方活跃的项目群组里吼;程序群沦为了日常扯淡的位置,常常同时讨论着不同的问题,线索及其混乱。“群”这个设计,我在很多年前就思考过,我一直觉得需要在根本上换个角度看待社交聊天的需求。

我现在的想法是这样的:

作为办公 IM ,我们不应该基于固定会话(群)来设计,而应该是“通知”和“话题”。

所谓通知,就是有人发起了一条消息,他需要把这条消息传达给某些对象,对象可以是人,也可以是某个组织:比如程序、游戏项目组、等等。

组织并非是群那样的聊天室,而仅仅是一个标签,由人来关注标签,而不是去组织这个聊天室里有多少听众。

而话题,则是由消息或旧话题衍生而来。任何通知消息、话题内部消息,都可以变成一个新话题。话题也可以包含在一则消息里转发给某个对象。

用户的客户端应该把所有的通知按时间线排列在一起,呈现在同一个地方。也就是说,无论是谁给我发消息(默认就是通知)都应该投递在一起,而不是像现在 RTX 那样只是在系统托盘里闪烁提醒、也不是微信 qq 那样,联系人名单上多出一个小红点。

而一旦我回复一个通知消息,其实就把这则通知转化成了一个话题,在时间线上,话题内的消息是归属在一起的。同一时刻,无论你的思维切换多么快,其实在短时间内你只能聚焦在一个话题上,所以客户端界面是很容易表达的,把当前话题展开在主界面(通知的时间线)即可,切换话题后自然可以折叠起来。

话题并不是聊天室、它更像是论坛的帖子。一个话题可以有很多人参与(至少发言一次),更应该支持更多的人浏览。我们不应该按聊天室的思路:用户只有在加入聊天室的那一刻开始,才能收到后续的消息,而应该像论坛那样,他只是打开了这个话题帖,可以随时聊天过去到现在发生的事情。话题内的任何一个消息,都可以由用户展开为新话题,老话题对新话题只是一个引用链接而已,并不需要有层级关系,我们也可以把任意一个话题或尚未转化为话题的消息转发出去,如果有人对他评论,就生成了新话题。

话题是一个有时效性的东西,对于办公来说,如果一个话题超过 8 小时没有新的消息,就可以认为这个话题已经结束了。但是事后我们依然可以对老话题浏览,或是继续讨论,而继续讨论就是生成的一个新话题了。

只要生成话题足够方便,每个用户的主时间线上就只会有不多的通知消息,信息传达更为有效。而管理每天的消息、检索旧消息也有很强的时间线。不像现有的 IM 群聊天,每天的聊天内容会被自然的组织成话题,这些话题上标识了参与人数、归属的组织的 tag 、继承于哪个父话题或通知、经历的时间段、衍生出哪些后续话题,等等。

即使是两个人之间的对话,也同样应该是话题的形式,而不应该把消息直接组织成一长串的聊天历史。话题未必有明确的主题,只是一种更自然的信息聚合形式而已。

对于办公场合来说,有意个最重要的优势:用户群有足够的自律。基于这种自律,我认为上面的思路若实现出来很容易推广使用。

在自律之外,或许还需要一些权限管理。这些权限管理应该是相对松散简单的,主要是限制用户订阅特定组织的 tag (比如一般员工不能订阅管理层的 tag ),限制围观特定话题(比如两人之间的私聊话题默认就是不对第三人开放权限的),话题可以锁定不准转发。权限设置的细节还需要进一步推敲。

]]>
0
<![CDATA[Swift 5 的蓝图:ABI 稳定]]> http://www.udpwork.com/item/16380.html http://www.udpwork.com/item/16380.html#reviews Wed, 09 Aug 2017 20:33:48 +0800 图拉鼎 http://www.udpwork.com/item/16380.html 今天凌晨,我看到 Swift 开发小组的现任掌门 Ted Kremenek 贴出了名为「Swift 5: start your engines」的一条Twitter,预示着 Swift 5 的开发工作即将展开了。

老实说,Swift 4 的变化不大,而且有一些「反复」的修改,比如:String 又变成 collection,private 和 fileprivate 的反复。好在做好了 Swift 3 -> Swift 4 的兼容工作,也算是比较重要的一件大事。

那么 Swift 5 有哪些变化?在看完新的 Swift Evolution 以后,我提炼出了这些内容:

ABI Stability

ABI 稳定,从 Swift 3 推到 Swift 4,又延后到 Swift 5,这次应该是真的了。为什么这么说呢?

因为这次目标很明确,先做到 Swift Standard Library 的 ABI 稳定。从开发者和用户的角度来说,iOS 12(macOS 10.13)将内置 Swift 的运行库,这意味着所有用 Swift 写的 App,将不用再自带一份多达几 MB 的 Swift 库了。整个 iOS/macOS 将可以省上不少空间。

往后 Swift Standard Library 也可以随操作系统进行升级,而 App 不用重新编译就可以获得不论是性能还是 Bug 的修复。

可以说 ABI 稳定做到了一大半。另外一半,Swift 开发小组没有承诺会在 Swift 5 完成,这一半就是:Module Stability。Module Stability 主要是面向 Framework 级别的分发。和 Library 不同,Framework 在处理 Public Interface 方面会复杂一些。具体的我也没有太深究。

总之,Swift 5 先做到 Swift Standard Library 的稳定,已经是极好的消息了!

Memory ownership model

Swift 5 将要引入由 Cyclone/Rust 启发的内存保护模型,这是 Swift 的目标「安全」的一部分。实际上 Swift 4 已经做了一部分。在看 WWDC 17 的视频的时候,我记得 Swift 4 已经做到了「Enforce Exclusive Access to Memory」,比如在遍历一个数组的时候就去修改内容,编译器会进行错误提示。

至于 Swift 5 要如何做「Memory ownership model」,去看 Rust 的现状应该就能略知一二了,更何况现在 Swift 开发小组里面本来就有很多 Rust 跳槽(挖)来的人。

可以预见,Swift 5 将在内存操作和使用方面,更安全,更易用。

String 的增强

String 这个语言的基础类,在 Swift 4 进一步标准以后,在 Swift 5 将迎来进一步增强。比如 Regular Expression 的支持。

在现在的 Swift App 里,我们还需要调用 Cocoa 的 NSRegularExpression 来进行正则表达式的支持,一门强大的语言却没有内置相关支持,这怎么都说不过去。

所以下个版本可以期待一下了,这个究竟能不能完成还不好说。

打好 Concurrency 的基础

同上,Swift 现在的并发依然需要借助 GCD 这个外部的库去做,虽然 GCD 已经足够好用了,但是语言层面没法拥有像其他语言 async/await 一样的支持,显然是个缺点。

Swift 5 将「打好 Concurrency 的基础」,这说明这个版本不会有内置并发的功能出现。

Proposal 规则之变更

从 Swift 5 开始,每个 Proposal 都需要一个的实现才有可能被合并,这使得提需求的门槛一下子提高了!

「你有啥对 Swift 5 的期望和意见?没问题,尽管提,我们这是开放社区。但是,如果你不同时给出一份代码,那么这个提议将永远是提议而已了。」

总结

Swift 4 将于下个月正式发布,这个版本将带来真正的无痛迁移——我已经亲自在自己的项目上做过试验,不用改一行代码就能顺利跑起来啦。

可以说 Swift 4 已经改变了之前 Swift 每个版本都要有一次大型 break 的固有印象,这门语言各方面也都越来越稳健。

至于 Swift 5 的研发到底会不会达到目标,让我们拭目以待吧!

本站架设于Linode 东京机房,同时使用云梯进行科学上网

]]>
今天凌晨,我看到 Swift 开发小组的现任掌门 Ted Kremenek 贴出了名为「Swift 5: start your engines」的一条Twitter,预示着 Swift 5 的开发工作即将展开了。

老实说,Swift 4 的变化不大,而且有一些「反复」的修改,比如:String 又变成 collection,private 和 fileprivate 的反复。好在做好了 Swift 3 -> Swift 4 的兼容工作,也算是比较重要的一件大事。

那么 Swift 5 有哪些变化?在看完新的 Swift Evolution 以后,我提炼出了这些内容:

ABI Stability

ABI 稳定,从 Swift 3 推到 Swift 4,又延后到 Swift 5,这次应该是真的了。为什么这么说呢?

因为这次目标很明确,先做到 Swift Standard Library 的 ABI 稳定。从开发者和用户的角度来说,iOS 12(macOS 10.13)将内置 Swift 的运行库,这意味着所有用 Swift 写的 App,将不用再自带一份多达几 MB 的 Swift 库了。整个 iOS/macOS 将可以省上不少空间。

往后 Swift Standard Library 也可以随操作系统进行升级,而 App 不用重新编译就可以获得不论是性能还是 Bug 的修复。

可以说 ABI 稳定做到了一大半。另外一半,Swift 开发小组没有承诺会在 Swift 5 完成,这一半就是:Module Stability。Module Stability 主要是面向 Framework 级别的分发。和 Library 不同,Framework 在处理 Public Interface 方面会复杂一些。具体的我也没有太深究。

总之,Swift 5 先做到 Swift Standard Library 的稳定,已经是极好的消息了!

Memory ownership model

Swift 5 将要引入由 Cyclone/Rust 启发的内存保护模型,这是 Swift 的目标「安全」的一部分。实际上 Swift 4 已经做了一部分。在看 WWDC 17 的视频的时候,我记得 Swift 4 已经做到了「Enforce Exclusive Access to Memory」,比如在遍历一个数组的时候就去修改内容,编译器会进行错误提示。

至于 Swift 5 要如何做「Memory ownership model」,去看 Rust 的现状应该就能略知一二了,更何况现在 Swift 开发小组里面本来就有很多 Rust 跳槽(挖)来的人。

可以预见,Swift 5 将在内存操作和使用方面,更安全,更易用。

String 的增强

String 这个语言的基础类,在 Swift 4 进一步标准以后,在 Swift 5 将迎来进一步增强。比如 Regular Expression 的支持。

在现在的 Swift App 里,我们还需要调用 Cocoa 的 NSRegularExpression 来进行正则表达式的支持,一门强大的语言却没有内置相关支持,这怎么都说不过去。

所以下个版本可以期待一下了,这个究竟能不能完成还不好说。

打好 Concurrency 的基础

同上,Swift 现在的并发依然需要借助 GCD 这个外部的库去做,虽然 GCD 已经足够好用了,但是语言层面没法拥有像其他语言 async/await 一样的支持,显然是个缺点。

Swift 5 将「打好 Concurrency 的基础」,这说明这个版本不会有内置并发的功能出现。

Proposal 规则之变更

从 Swift 5 开始,每个 Proposal 都需要一个的实现才有可能被合并,这使得提需求的门槛一下子提高了!

「你有啥对 Swift 5 的期望和意见?没问题,尽管提,我们这是开放社区。但是,如果你不同时给出一份代码,那么这个提议将永远是提议而已了。」

总结

Swift 4 将于下个月正式发布,这个版本将带来真正的无痛迁移——我已经亲自在自己的项目上做过试验,不用改一行代码就能顺利跑起来啦。

可以说 Swift 4 已经改变了之前 Swift 每个版本都要有一次大型 break 的固有印象,这门语言各方面也都越来越稳健。

至于 Swift 5 的研发到底会不会达到目标,让我们拭目以待吧!

本站架设于Linode 东京机房,同时使用云梯进行科学上网

]]>
0
<![CDATA[Swift 5 的蓝图:ABI 稳定]]> http://www.udpwork.com/item/16379.html http://www.udpwork.com/item/16379.html#reviews Wed, 09 Aug 2017 16:44:08 +0800 图拉鼎 http://www.udpwork.com/item/16379.html 今天凌晨,我看到 Swift 开发小组的现任掌门 Ted Kremenek 贴出了名为「Swift 5: start your engines」的一条Twitter,预示着 Swift 5 的开发工作即将展开了。

老实说,Swift 4 的变化不大,而且有一些「反复」的修改,比如:String 又变成 collection,private 和 fileprivate 的反复。好在做好了 Swift 3 -> Swift 4 的兼容工作,也算是比较重要的一件大事。

那么 Swift 5 有哪些变化?在看完新的 Swift Evolution 以后,我提炼出了这些内容:

ABI Stability

ABI 稳定,从 Swift 3 推到 Swift 4,又延后到 Swift 5,这次应该是真的了。为什么这么说呢?

因为这次目标很明确,先做到 Swift Standard Library 的 ABI 稳定。从开发者和用户的角度来说,iOS 12(macOS 10.13)将内置 Swift 的运行库,这意味着所有用 Swift 写的 App,将不用再自带一份多达几 MB 的 Swift 库了。整个 iOS/macOS 将可以省上不少空间。

往后 Swift Standard Library 也可以随操作系统进行升级,而 App 不用重新编译就可以获得不论是性能还是 Bug 的修复。

可以说 ABI 稳定做到了一大半。另外一半,Swift 开发小组没有承诺会在 Swift 5 完成,这一半就是:Module Stability。Module Stability 主要是面向 Framework 级别的分发。和 Library 不同,Framework 在处理 Public Interface 方面会复杂一些。具体的我也没有太深究。

总之,Swift 5 先做到 Swift Standard Library 的稳定,已经是极好的消息了!

Memory ownership model

Swift 5 将要引入由 Cyclone/Rust 启发的内存保护模型,这是 Swift 的目标「安全」的一部分。实际上 Swift 4 已经做了一部分。在看 WWDC 17 的视频的时候,我记得 Swift 4 已经做到了「Enforce Exclusive Access to Memory」,比如在遍历一个数组的时候就去修改内容,编译器会进行错误提示。

至于 Swift 5 要如何做「Memory ownership model」,去看 Rust 的现状应该就能略知一二了,更何况现在 Swift 开发小组里面本来就有很多 Rust 跳槽(挖)来的人。

可以预见,Swift 5 将在内存操作和使用方面,更安全,更易用。

String 的增强

String 这个语言的基础类,在 Swift 4 进一步标准以后,在 Swift 5 将迎来进一步增强。比如 Regular Expression 的支持。

在现在的 Swift App 里,我们还需要调用 Cocoa 的 NSRegularExpression 来进行正则表达式的支持,一门强大的语言却没有内置相关支持,这怎么都说不过去。

所以下个版本可以期待一下了,这个究竟能不能完成还不好说。

打好 Concurrency 的基础

同上,Swift 现在的并发依然需要借助 GCD 这个外部的库去做,虽然 GCD 已经足够好用了,但是语言层面没法拥有像其他语言 async/await 一样的支持,显然是个缺点。

Swift 5 将「打好 Concurrency 的基础」,这说明这个版本不会有内置并发的功能出现。

Proposal 规则之变更

从 Swift 5 开始,每个 Proposal 都需要一个的实现才有可能被合并,这使得提需求的门槛一下子提高了!

「你有啥对 Swift 5 的期望和意见?没问题,尽管提,我们这是开放社区。但是,如果你不同时给出一份代码,那么这个提议将永远是提议而已了。」

总结

Swift 4 将于下个月正式发布,这个版本将带来真正的无痛迁移——我已经亲自在自己的项目上做过试验,不用改一行代码就能顺利跑起来啦。

可以说 Swift 4 已经改变了之前 Swift 每个版本都要有一次大型 break 的固有印象,这门语言各方面也都越来越稳健。

至于 Swift 5 的研发到底会不会达到目标,让我们拭目以待吧!

本站架设于Linode 东京机房,同时使用云梯进行科学上网

]]>
今天凌晨,我看到 Swift 开发小组的现任掌门 Ted Kremenek 贴出了名为「Swift 5: start your engines」的一条Twitter,预示着 Swift 5 的开发工作即将展开了。

老实说,Swift 4 的变化不大,而且有一些「反复」的修改,比如:String 又变成 collection,private 和 fileprivate 的反复。好在做好了 Swift 3 -> Swift 4 的兼容工作,也算是比较重要的一件大事。

那么 Swift 5 有哪些变化?在看完新的 Swift Evolution 以后,我提炼出了这些内容:

ABI Stability

ABI 稳定,从 Swift 3 推到 Swift 4,又延后到 Swift 5,这次应该是真的了。为什么这么说呢?

因为这次目标很明确,先做到 Swift Standard Library 的 ABI 稳定。从开发者和用户的角度来说,iOS 12(macOS 10.13)将内置 Swift 的运行库,这意味着所有用 Swift 写的 App,将不用再自带一份多达几 MB 的 Swift 库了。整个 iOS/macOS 将可以省上不少空间。

往后 Swift Standard Library 也可以随操作系统进行升级,而 App 不用重新编译就可以获得不论是性能还是 Bug 的修复。

可以说 ABI 稳定做到了一大半。另外一半,Swift 开发小组没有承诺会在 Swift 5 完成,这一半就是:Module Stability。Module Stability 主要是面向 Framework 级别的分发。和 Library 不同,Framework 在处理 Public Interface 方面会复杂一些。具体的我也没有太深究。

总之,Swift 5 先做到 Swift Standard Library 的稳定,已经是极好的消息了!

Memory ownership model

Swift 5 将要引入由 Cyclone/Rust 启发的内存保护模型,这是 Swift 的目标「安全」的一部分。实际上 Swift 4 已经做了一部分。在看 WWDC 17 的视频的时候,我记得 Swift 4 已经做到了「Enforce Exclusive Access to Memory」,比如在遍历一个数组的时候就去修改内容,编译器会进行错误提示。

至于 Swift 5 要如何做「Memory ownership model」,去看 Rust 的现状应该就能略知一二了,更何况现在 Swift 开发小组里面本来就有很多 Rust 跳槽(挖)来的人。

可以预见,Swift 5 将在内存操作和使用方面,更安全,更易用。

String 的增强

String 这个语言的基础类,在 Swift 4 进一步标准以后,在 Swift 5 将迎来进一步增强。比如 Regular Expression 的支持。

在现在的 Swift App 里,我们还需要调用 Cocoa 的 NSRegularExpression 来进行正则表达式的支持,一门强大的语言却没有内置相关支持,这怎么都说不过去。

所以下个版本可以期待一下了,这个究竟能不能完成还不好说。

打好 Concurrency 的基础

同上,Swift 现在的并发依然需要借助 GCD 这个外部的库去做,虽然 GCD 已经足够好用了,但是语言层面没法拥有像其他语言 async/await 一样的支持,显然是个缺点。

Swift 5 将「打好 Concurrency 的基础」,这说明这个版本不会有内置并发的功能出现。

Proposal 规则之变更

从 Swift 5 开始,每个 Proposal 都需要一个的实现才有可能被合并,这使得提需求的门槛一下子提高了!

「你有啥对 Swift 5 的期望和意见?没问题,尽管提,我们这是开放社区。但是,如果你不同时给出一份代码,那么这个提议将永远是提议而已了。」

总结

Swift 4 将于下个月正式发布,这个版本将带来真正的无痛迁移——我已经亲自在自己的项目上做过试验,不用改一行代码就能顺利跑起来啦。

可以说 Swift 4 已经改变了之前 Swift 每个版本都要有一次大型 break 的固有印象,这门语言各方面也都越来越稳健。

至于 Swift 5 的研发到底会不会达到目标,让我们拭目以待吧!

本站架设于Linode 东京机房,同时使用云梯进行科学上网

]]>
0
<![CDATA[Koa 框架教程]]> http://www.udpwork.com/item/16378.html http://www.udpwork.com/item/16378.html#reviews Wed, 09 Aug 2017 07:29:00 +0800 阮一峰 http://www.udpwork.com/item/16378.html Node 主要用在开发 Web 应用。这决定了使用 Node,往往离不开 Web 应用框架。

Koa就是一种简单好用的 Web 框架。它的特点是优雅、简洁、表达力强、自由度高。本身代码只有1000多行,所有功能都通过插件实现,很符合 Unix 哲学。

本文从零开始,循序渐进,教会你如何使用 Koa 写出自己的 Web 应用。每一步都有简洁易懂的示例,希望让大家一看就懂。

零、准备

首先,检查 Node 版本。

$ node -v
v8.0.0

Koa 必须使用 7.6 以上的版本。如果你的版本低于这个要求,就要先升级 Node。

然后,克隆本文的配套示例库。(如果不方便使用 Git,也可以下载zip 文件解压。)

$ git clone https://github.com/ruanyf/koa-demos.git

接着,进入示例库,安装依赖。

$ cd koa-demos
$ npm install

所有示例源码,都在demos目录下面。

一、基本用法

1.1 架设 HTTP 服务

只要三行代码,就可以用 Koa 架设一个 HTTP 服务。

// demos/01.js
const Koa = require('koa');
const app = new Koa();

app.listen(3000);

运行这个脚本。

$ node demos/01.js

打开浏览器,访问 http://127.0.0.1:3000 。你会看到页面显示"Not Found",表示没有发现任何内容。这是因为我们并没有告诉 Koa 应该显示什么内容。

1.2 Context 对象

Koa 提供一个 Context 对象,表示一次对话的上下文(包括 HTTP 请求和 HTTP 回复)。通过加工这个对象,就可以控制返回给用户的内容。

Context.response.body属性就是发送给用户的内容。请看下面的例子(完整的代码看这里)。

// demos/02.js
const Koa = require('koa');
const app = new Koa();

const main = ctx => {
  ctx.response.body = 'Hello World';
};

app.use(main);
app.listen(3000);

上面代码中,main函数用来设置ctx.response.body。然后,使用app.use方法加载main函数。

你可能已经猜到了,ctx.response代表 HTTP Response。同样地,ctx.request代表 HTTP Request。

运行这个 demo。

$ node demos/02.js

访问 http://127.0.0.1:3000 ,现在就可以看到"Hello World"了。

1.3 HTTP Response 的类型

Koa 默认的返回类型是text/plain,如果想返回其他类型的内容,可以先用ctx.request.accepts判断一下,客户端希望接受什么数据(根据 HTTP Request 的Accept字段),然后使用ctx.response.type指定返回类型。请看下面的例子(完整代码看这里)。

// demos/03.js
const main = ctx => {
  if (ctx.request.accepts('xml')) {
    ctx.response.type = 'xml';
    ctx.response.body = '<data>Hello World</data>';
  } else if (ctx.request.accepts('json')) {
    ctx.response.type = 'json';
    ctx.response.body = { data: 'Hello World' };
  } else if (ctx.request.accepts('html')) {
    ctx.response.type = 'html';
    ctx.response.body = '<p>Hello World</p>';
  } else {
    ctx.response.type = 'text';
    ctx.response.body = 'Hello World';
  }
};

运行这个 demo。

$ node demos/03.js

访问 http://127.0.0.1:3000 ,现在看到的就是一个 XML 文档了。

1.4 网页模板

实际开发中,返回给用户的网页往往都写成模板文件。我们可以让 Koa 先读取模板文件,然后将这个模板返回给用户。请看下面的例子(完整代码看这里)。

// demos/04.js
const fs = require('fs');

const main = ctx => {
  ctx.response.type = 'html';
  ctx.response.body = fs.createReadStream('./demos/template.html');
};

运行这个 Demo。

$ node demos/04.js

访问 http://127.0.0.1:3000 ,看到的就是模板文件的内容了。

二、路由

2.1 原生路由

网站一般都有多个页面。通过ctx.request.path可以获取用户请求的路径,由此实现简单的路由。请看下面的例子(完整代码看这里)。

// demos/05.js
const main = ctx => {
  if (ctx.request.path !== '/') {
    ctx.response.type = 'html';
    ctx.response.body = '<a href="/">Index Page</a>';
  } else {
    ctx.response.body = 'Hello World';
  }
};

运行这个 demo。

$ node demos/05.js

访问 http://127.0.0.1:3000/about ,可以看到一个链接,点击后就跳到首页。

2.2 koa-route 模块

原生路由用起来不太方便,我们可以使用封装好的koa-route模块。请看下面的例子(完整代码看这里)。

// demos/06.js
const route = require('koa-route');

const about = ctx => {
  ctx.response.type = 'html';
  ctx.response.body = '<a href="/">Index Page</a>';
};

const main = ctx => {
  ctx.response.body = 'Hello World';
};

app.use(route.get('/', main));
app.use(route.get('/about', about));

上面代码中,根路径/的处理函数是main,/about路径的处理函数是about。

运行这个 demo。

$ node demos/06.js

访问 http://127.0.0.1:3000/about ,效果与上一个例子完全相同。

2.3 静态资源

如果网站提供静态资源(图片、字体、样式表、脚本......),为它们一个个写路由就很麻烦,也没必要。koa-static模块封装了这部分的请求。请看下面的例子(完整代码看这里)。

// demos/12.js
const path = require('path');
const serve = require('koa-static');

const main = serve(path.join(__dirname));
app.use(main);

运行这个 Demo。

$ node demos/12.js

访问 http://127.0.0.1:3000/12.js,在浏览器里就可以看到这个脚本的内容。

2.4 重定向

有些场合,服务器需要重定向(redirect)访问请求。比如,用户登陆以后,将他重定向到登陆前的页面。ctx.response.redirect()方法可以发出一个302跳转,将用户导向另一个路由。请看下面的例子(完整代码看这里)。

// demos/13.js
const redirect = ctx => {
  ctx.response.redirect('/');
  ctx.response.body = '<a href="/">Index Page</a>';
};

app.use(route.get('/redirect', redirect));

运行这个 demo。

$ node demos/13.js

访问 http://127.0.0.1:3000/redirect ,浏览器会将用户导向根路由。

三、中间件

3.1 Logger 功能

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)。为了理解中间件,我们先看一下 Logger (打印日志)功能的实现。

最简单的写法就是在main函数里面增加一行(完整代码看这里)。

// demos/07.js
const main = ctx => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  ctx.response.body = 'Hello World';
};

运行这个 Demo。

$ node demos/07.js

访问 http://127.0.0.1:3000 ,命令行就会输出日志。

1502144902843 GET /

3.2 中间件的概念

上一个例子里面的 Logger 功能,可以拆分成一个独立函数(完整代码看这里)。

// demos/08.js
const logger = (ctx, next) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  next();
}
app.use(logger);

像上面代码中的logger函数就叫做"中间件"(middleware),因为它处在 HTTP Request 和 HTTP Response 中间,用来实现某种中间功能。app.use()用来加载中间件。

基本上,Koa 所有的功能都是通过中间件实现的,前面例子里面的main也是中间件。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next函数。只要调用next函数,就可以把执行权转交给下一个中间件。

运行这个 demo。

$ node demos/08.js

访问 http://127.0.0.1:3000 ,命令行窗口会显示与上一个例子相同的日志输出。

3.3 中间件栈

多个中间件会形成一个栈结构(middle stack),以"先进后出"(first-in-last-out)的顺序执行。

  1. 最外层的中间件首先执行。
  2. 调用next函数,把执行权交给下一个中间件。
  3. ...
  4. 最内层的中间件最后执行。
  5. 执行结束后,把执行权交回上一层的中间件。
  6. ...
  7. 最外层的中间件收回执行权之后,执行next函数后面的代码。

请看下面的例子(完整代码看这里)。

// demos/09.js
const one = (ctx, next) => {
  console.log('>> one');
  next();
  console.log('<< one');
}

const two = (ctx, next) => {
  console.log('>> two');
  next(); 
  console.log('<< two');
}

const three = (ctx, next) => {
  console.log('>> three');
  next();
  console.log('<< three');
}

app.use(one);
app.use(two);
app.use(three);

运行这个 demo。

$ node demos/09.js

访问 http://127.0.0.1:3000 ,命令行窗口会有如下输出。

>> one
>> two
>> three
<< three
<< two
<< one

如果中间件内部没有调用next函数,那么执行权就不会传递下去。作为练习,你可以将two函数里面next()这一行注释掉再执行,看看会有什么结果。

3.4 异步中间件

迄今为止,所有例子的中间件都是同步的,不包含异步操作。如果有异步操作(比如读取数据库),中间件就必须写成async 函数。请看下面的例子(完整代码看这里)。

// demo02/10.js
const fs = require('fs.promised');
const Koa = require('koa');
const app = new Koa();

const main = async function (ctx, next) {
  ctx.response.type = 'html';
  ctx.response.body = await fs.readFile('./demos/template.html', 'utf8');
};

app.use(main);
app.listen(3000);

上面代码中,fs.readFile是一个异步操作,必须写成await fs.readFile(),然后中间件必须写成 async 函数。

运行这个 demo。

$ node demos/10.js

访问 http://127.0.0.1:3000 ,就可以看到模板文件的内容。

3.5 中间件的合成

koa-compose模块可以将多个中间件合成为一个。请看下面的例子(完整代码看这里)。

// demos/11.js
const compose = require('koa-compose');

const logger = (ctx, next) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  next();
}

const main = ctx => {
  ctx.response.body = 'Hello World';
};

const middlewares = compose([logger, main]);
app.use(middlewares);

运行这个 demo。

$ node demos/11.js

访问 http://127.0.0.1:3000 ,就可以在命令行窗口看到日志信息。

四、错误处理

4.1 500 错误

如果代码运行过程中发生错误,我们需要把错误信息返回给用户。HTTP 协定约定这时要返回500状态码。Koa 提供了ctx.throw()方法,用来抛出错误,ctx.throw(500)就是抛出500错误。请看下面的例子(完整代码看这里)。

// demos/14.js
const main = ctx => {
  ctx.throw(500);
};

运行这个 demo。

$ node demos/14.js

访问 http://127.0.0.1:3000,你会看到一个500错误页"Internal Server Error"。

4.2 404错误

如果将ctx.response.status设置成404,就相当于ctx.throw(404),返回404错误。请看下面的例子(完整代码看这里)。

// demos/15.js
const main = ctx => {
  ctx.response.status = 404;
  ctx.response.body = 'Page Not Found';
};

运行这个 demo。

$ node demos/15.js

访问 http://127.0.0.1:3000 ,你就看到一个404页面"Page Not Found"。

4.3 处理错误的中间件

为了方便处理错误,最好使用try...catch将其捕获。但是,为每个中间件都写try...catch太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。请看下面的例子(完整代码看这里)。

// demos/16.js
const handler = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.body = {
      message: err.message
    };
  }
};

const main = ctx => {
  ctx.throw(500);
};

app.use(handler);
app.use(main);

运行这个 demo。

$ node demos/16.js

访问 http://127.0.0.1:3000 ,你会看到一个500页,里面有报错提示{"message":"Internal Server Error"}。

4.4 error 事件的监听

运行过程中一旦出错,Koa 会触发一个error事件。监听这个事件,也可以处理错误。请看下面的例子(完整代码看这里)。

// demos/17.js
const main = ctx => {
  ctx.throw(500);
};

app.on('error', (err, ctx) =>
  console.error('server error', err);
);

运行这个 demo。

$ node demos/17.js

访问 http://127.0.0.1:3000 ,你会在命令行窗口看到"server error xxx"。

4.5 释放 error 事件

需要注意的是,如果错误被try...catch捕获,就不会触发error事件。这时,必须调用ctx.app.emit(),手动释放error事件,才能让监听函数生效。请看下面的例子(完整代码看这里)。

// demos/18.js`
const handler = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.type = 'html';
    ctx.response.body = '<p>Something wrong, please contact administrator.</p>';
    ctx.app.emit('error', err, ctx);
  }
};

const main = ctx => {
  ctx.throw(500);
};

app.on('error', function(err) {
  console.log('logging error ', err.message);
  console.log(err);
});

上面代码中,main函数抛出错误,被handler函数捕获。catch代码块里面使用ctx.app.emit()手动释放error事件,才能让监听函数监听到。

运行这个 demo。

$ node demos/18.js

访问 http://127.0.0.1:3000 ,你会在命令行窗口看到logging error。

五、Web App 的功能

5.1 Cookies

ctx.cookies用来读写 Cookie。请看下面的例子(完整代码看这里)。

// demos/19.js
const main = function(ctx) {
  const n = Number(ctx.cookies.get('view') || 0) + 1;
  ctx.cookies.set('view', n);
  ctx.response.body = n + ' views';
}

运行这个 demo。

$ node demos/19.js

访问 http://127.0.0.1:3000 ,你会看到1 views。刷新一次页面,就变成了2 views。再刷新,每次都会计数增加1。

5.2 表单

Web 应用离不开处理表单。本质上,表单就是 POST 方法发送到服务器的键值对。koa-body模块可以用来从 POST 请求的数据体里面提取键值对。请看下面的例子(完整代码看这里)。

// demos/20.js
const koaBody = require('koa-body');

const main = async function(ctx) {
  const body = ctx.request.body;
  if (!body.name) ctx.throw(400, '.name required');
  ctx.body = { name: body.name };
};

app.use(koaBody());

运行这个 demo。

$ node demos/20.js

打开另一个命令行窗口,运行下面的命令。

$ curl -X POST --data "name=Jack" 127.0.0.1:3000
{"name":"Jack"}

$ curl -X POST --data "name" 127.0.0.1:3000
name required

上面代码使用 POST 方法向服务器发送一个键值对,会被正确解析。如果发送的数据不正确,就会收到错误提示。

2.3 文件上传

koa-body模块还可以用来处理文件上传。请看下面的例子(完整代码看这里)。

// demos/21.js
const os = require('os');
const path = require('path');
const koaBody = require('koa-body');

const main = async function(ctx) {
  const tmpdir = os.tmpdir();
  const filePaths = [];
  const files = ctx.request.body.files || {};

  for (let key in files) {
    const file = files[key];
    const filePath = path.join(tmpdir, file.name);
    const reader = fs.createReadStream(file.path);
    const writer = fs.createWriteStream(filePath);
    reader.pipe(writer);
    filePaths.push(filePath);
  }

  ctx.body = filePaths;
};

app.use(koaBody({ multipart: true }));

运行这个 demo。

$ node demos/21.js

打开另一个命令行窗口,运行下面的命令,上传一个文件。注意,/path/to/file要更换为真实的文件路径。

$ curl --form upload=@/path/to/file http://127.0.0.1:3000
["/tmp/file"]

六、参考链接

(完)

文档信息

]]>
Node 主要用在开发 Web 应用。这决定了使用 Node,往往离不开 Web 应用框架。

Koa就是一种简单好用的 Web 框架。它的特点是优雅、简洁、表达力强、自由度高。本身代码只有1000多行,所有功能都通过插件实现,很符合 Unix 哲学。

本文从零开始,循序渐进,教会你如何使用 Koa 写出自己的 Web 应用。每一步都有简洁易懂的示例,希望让大家一看就懂。

零、准备

首先,检查 Node 版本。

$ node -v
v8.0.0

Koa 必须使用 7.6 以上的版本。如果你的版本低于这个要求,就要先升级 Node。

然后,克隆本文的配套示例库。(如果不方便使用 Git,也可以下载zip 文件解压。)

$ git clone https://github.com/ruanyf/koa-demos.git

接着,进入示例库,安装依赖。

$ cd koa-demos
$ npm install

所有示例源码,都在demos目录下面。

一、基本用法

1.1 架设 HTTP 服务

只要三行代码,就可以用 Koa 架设一个 HTTP 服务。

// demos/01.js
const Koa = require('koa');
const app = new Koa();

app.listen(3000);

运行这个脚本。

$ node demos/01.js

打开浏览器,访问 http://127.0.0.1:3000 。你会看到页面显示"Not Found",表示没有发现任何内容。这是因为我们并没有告诉 Koa 应该显示什么内容。

1.2 Context 对象

Koa 提供一个 Context 对象,表示一次对话的上下文(包括 HTTP 请求和 HTTP 回复)。通过加工这个对象,就可以控制返回给用户的内容。

Context.response.body属性就是发送给用户的内容。请看下面的例子(完整的代码看这里)。

// demos/02.js
const Koa = require('koa');
const app = new Koa();

const main = ctx => {
  ctx.response.body = 'Hello World';
};

app.use(main);
app.listen(3000);

上面代码中,main函数用来设置ctx.response.body。然后,使用app.use方法加载main函数。

你可能已经猜到了,ctx.response代表 HTTP Response。同样地,ctx.request代表 HTTP Request。

运行这个 demo。

$ node demos/02.js

访问 http://127.0.0.1:3000 ,现在就可以看到"Hello World"了。

1.3 HTTP Response 的类型

Koa 默认的返回类型是text/plain,如果想返回其他类型的内容,可以先用ctx.request.accepts判断一下,客户端希望接受什么数据(根据 HTTP Request 的Accept字段),然后使用ctx.response.type指定返回类型。请看下面的例子(完整代码看这里)。

// demos/03.js
const main = ctx => {
  if (ctx.request.accepts('xml')) {
    ctx.response.type = 'xml';
    ctx.response.body = '<data>Hello World</data>';
  } else if (ctx.request.accepts('json')) {
    ctx.response.type = 'json';
    ctx.response.body = { data: 'Hello World' };
  } else if (ctx.request.accepts('html')) {
    ctx.response.type = 'html';
    ctx.response.body = '<p>Hello World</p>';
  } else {
    ctx.response.type = 'text';
    ctx.response.body = 'Hello World';
  }
};

运行这个 demo。

$ node demos/03.js

访问 http://127.0.0.1:3000 ,现在看到的就是一个 XML 文档了。

1.4 网页模板

实际开发中,返回给用户的网页往往都写成模板文件。我们可以让 Koa 先读取模板文件,然后将这个模板返回给用户。请看下面的例子(完整代码看这里)。

// demos/04.js
const fs = require('fs');

const main = ctx => {
  ctx.response.type = 'html';
  ctx.response.body = fs.createReadStream('./demos/template.html');
};

运行这个 Demo。

$ node demos/04.js

访问 http://127.0.0.1:3000 ,看到的就是模板文件的内容了。

二、路由

2.1 原生路由

网站一般都有多个页面。通过ctx.request.path可以获取用户请求的路径,由此实现简单的路由。请看下面的例子(完整代码看这里)。

// demos/05.js
const main = ctx => {
  if (ctx.request.path !== '/') {
    ctx.response.type = 'html';
    ctx.response.body = '<a href="/">Index Page</a>';
  } else {
    ctx.response.body = 'Hello World';
  }
};

运行这个 demo。

$ node demos/05.js

访问 http://127.0.0.1:3000/about ,可以看到一个链接,点击后就跳到首页。

2.2 koa-route 模块

原生路由用起来不太方便,我们可以使用封装好的koa-route模块。请看下面的例子(完整代码看这里)。

// demos/06.js
const route = require('koa-route');

const about = ctx => {
  ctx.response.type = 'html';
  ctx.response.body = '<a href="/">Index Page</a>';
};

const main = ctx => {
  ctx.response.body = 'Hello World';
};

app.use(route.get('/', main));
app.use(route.get('/about', about));

上面代码中,根路径/的处理函数是main,/about路径的处理函数是about。

运行这个 demo。

$ node demos/06.js

访问 http://127.0.0.1:3000/about ,效果与上一个例子完全相同。

2.3 静态资源

如果网站提供静态资源(图片、字体、样式表、脚本......),为它们一个个写路由就很麻烦,也没必要。koa-static模块封装了这部分的请求。请看下面的例子(完整代码看这里)。

// demos/12.js
const path = require('path');
const serve = require('koa-static');

const main = serve(path.join(__dirname));
app.use(main);

运行这个 Demo。

$ node demos/12.js

访问 http://127.0.0.1:3000/12.js,在浏览器里就可以看到这个脚本的内容。

2.4 重定向

有些场合,服务器需要重定向(redirect)访问请求。比如,用户登陆以后,将他重定向到登陆前的页面。ctx.response.redirect()方法可以发出一个302跳转,将用户导向另一个路由。请看下面的例子(完整代码看这里)。

// demos/13.js
const redirect = ctx => {
  ctx.response.redirect('/');
  ctx.response.body = '<a href="/">Index Page</a>';
};

app.use(route.get('/redirect', redirect));

运行这个 demo。

$ node demos/13.js

访问 http://127.0.0.1:3000/redirect ,浏览器会将用户导向根路由。

三、中间件

3.1 Logger 功能

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)。为了理解中间件,我们先看一下 Logger (打印日志)功能的实现。

最简单的写法就是在main函数里面增加一行(完整代码看这里)。

// demos/07.js
const main = ctx => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  ctx.response.body = 'Hello World';
};

运行这个 Demo。

$ node demos/07.js

访问 http://127.0.0.1:3000 ,命令行就会输出日志。

1502144902843 GET /

3.2 中间件的概念

上一个例子里面的 Logger 功能,可以拆分成一个独立函数(完整代码看这里)。

// demos/08.js
const logger = (ctx, next) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  next();
}
app.use(logger);

像上面代码中的logger函数就叫做"中间件"(middleware),因为它处在 HTTP Request 和 HTTP Response 中间,用来实现某种中间功能。app.use()用来加载中间件。

基本上,Koa 所有的功能都是通过中间件实现的,前面例子里面的main也是中间件。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next函数。只要调用next函数,就可以把执行权转交给下一个中间件。

运行这个 demo。

$ node demos/08.js

访问 http://127.0.0.1:3000 ,命令行窗口会显示与上一个例子相同的日志输出。

3.3 中间件栈

多个中间件会形成一个栈结构(middle stack),以"先进后出"(first-in-last-out)的顺序执行。

  1. 最外层的中间件首先执行。
  2. 调用next函数,把执行权交给下一个中间件。
  3. ...
  4. 最内层的中间件最后执行。
  5. 执行结束后,把执行权交回上一层的中间件。
  6. ...
  7. 最外层的中间件收回执行权之后,执行next函数后面的代码。

请看下面的例子(完整代码看这里)。

// demos/09.js
const one = (ctx, next) => {
  console.log('>> one');
  next();
  console.log('<< one');
}

const two = (ctx, next) => {
  console.log('>> two');
  next(); 
  console.log('<< two');
}

const three = (ctx, next) => {
  console.log('>> three');
  next();
  console.log('<< three');
}

app.use(one);
app.use(two);
app.use(three);

运行这个 demo。

$ node demos/09.js

访问 http://127.0.0.1:3000 ,命令行窗口会有如下输出。

>> one
>> two
>> three
<< three
<< two
<< one

如果中间件内部没有调用next函数,那么执行权就不会传递下去。作为练习,你可以将two函数里面next()这一行注释掉再执行,看看会有什么结果。

3.4 异步中间件

迄今为止,所有例子的中间件都是同步的,不包含异步操作。如果有异步操作(比如读取数据库),中间件就必须写成async 函数。请看下面的例子(完整代码看这里)。

// demo02/10.js
const fs = require('fs.promised');
const Koa = require('koa');
const app = new Koa();

const main = async function (ctx, next) {
  ctx.response.type = 'html';
  ctx.response.body = await fs.readFile('./demos/template.html', 'utf8');
};

app.use(main);
app.listen(3000);

上面代码中,fs.readFile是一个异步操作,必须写成await fs.readFile(),然后中间件必须写成 async 函数。

运行这个 demo。

$ node demos/10.js

访问 http://127.0.0.1:3000 ,就可以看到模板文件的内容。

3.5 中间件的合成

koa-compose模块可以将多个中间件合成为一个。请看下面的例子(完整代码看这里)。

// demos/11.js
const compose = require('koa-compose');

const logger = (ctx, next) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  next();
}

const main = ctx => {
  ctx.response.body = 'Hello World';
};

const middlewares = compose([logger, main]);
app.use(middlewares);

运行这个 demo。

$ node demos/11.js

访问 http://127.0.0.1:3000 ,就可以在命令行窗口看到日志信息。

四、错误处理

4.1 500 错误

如果代码运行过程中发生错误,我们需要把错误信息返回给用户。HTTP 协定约定这时要返回500状态码。Koa 提供了ctx.throw()方法,用来抛出错误,ctx.throw(500)就是抛出500错误。请看下面的例子(完整代码看这里)。

// demos/14.js
const main = ctx => {
  ctx.throw(500);
};

运行这个 demo。

$ node demos/14.js

访问 http://127.0.0.1:3000,你会看到一个500错误页"Internal Server Error"。

4.2 404错误

如果将ctx.response.status设置成404,就相当于ctx.throw(404),返回404错误。请看下面的例子(完整代码看这里)。

// demos/15.js
const main = ctx => {
  ctx.response.status = 404;
  ctx.response.body = 'Page Not Found';
};

运行这个 demo。

$ node demos/15.js

访问 http://127.0.0.1:3000 ,你就看到一个404页面"Page Not Found"。

4.3 处理错误的中间件

为了方便处理错误,最好使用try...catch将其捕获。但是,为每个中间件都写try...catch太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。请看下面的例子(完整代码看这里)。

// demos/16.js
const handler = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.body = {
      message: err.message
    };
  }
};

const main = ctx => {
  ctx.throw(500);
};

app.use(handler);
app.use(main);

运行这个 demo。

$ node demos/16.js

访问 http://127.0.0.1:3000 ,你会看到一个500页,里面有报错提示{"message":"Internal Server Error"}。

4.4 error 事件的监听

运行过程中一旦出错,Koa 会触发一个error事件。监听这个事件,也可以处理错误。请看下面的例子(完整代码看这里)。

// demos/17.js
const main = ctx => {
  ctx.throw(500);
};

app.on('error', (err, ctx) =>
  console.error('server error', err);
);

运行这个 demo。

$ node demos/17.js

访问 http://127.0.0.1:3000 ,你会在命令行窗口看到"server error xxx"。

4.5 释放 error 事件

需要注意的是,如果错误被try...catch捕获,就不会触发error事件。这时,必须调用ctx.app.emit(),手动释放error事件,才能让监听函数生效。请看下面的例子(完整代码看这里)。

// demos/18.js`
const handler = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.type = 'html';
    ctx.response.body = '<p>Something wrong, please contact administrator.</p>';
    ctx.app.emit('error', err, ctx);
  }
};

const main = ctx => {
  ctx.throw(500);
};

app.on('error', function(err) {
  console.log('logging error ', err.message);
  console.log(err);
});

上面代码中,main函数抛出错误,被handler函数捕获。catch代码块里面使用ctx.app.emit()手动释放error事件,才能让监听函数监听到。

运行这个 demo。

$ node demos/18.js

访问 http://127.0.0.1:3000 ,你会在命令行窗口看到logging error。

五、Web App 的功能

5.1 Cookies

ctx.cookies用来读写 Cookie。请看下面的例子(完整代码看这里)。

// demos/19.js
const main = function(ctx) {
  const n = Number(ctx.cookies.get('view') || 0) + 1;
  ctx.cookies.set('view', n);
  ctx.response.body = n + ' views';
}

运行这个 demo。

$ node demos/19.js

访问 http://127.0.0.1:3000 ,你会看到1 views。刷新一次页面,就变成了2 views。再刷新,每次都会计数增加1。

5.2 表单

Web 应用离不开处理表单。本质上,表单就是 POST 方法发送到服务器的键值对。koa-body模块可以用来从 POST 请求的数据体里面提取键值对。请看下面的例子(完整代码看这里)。

// demos/20.js
const koaBody = require('koa-body');

const main = async function(ctx) {
  const body = ctx.request.body;
  if (!body.name) ctx.throw(400, '.name required');
  ctx.body = { name: body.name };
};

app.use(koaBody());

运行这个 demo。

$ node demos/20.js

打开另一个命令行窗口,运行下面的命令。

$ curl -X POST --data "name=Jack" 127.0.0.1:3000
{"name":"Jack"}

$ curl -X POST --data "name" 127.0.0.1:3000
name required

上面代码使用 POST 方法向服务器发送一个键值对,会被正确解析。如果发送的数据不正确,就会收到错误提示。

2.3 文件上传

koa-body模块还可以用来处理文件上传。请看下面的例子(完整代码看这里)。

// demos/21.js
const os = require('os');
const path = require('path');
const koaBody = require('koa-body');

const main = async function(ctx) {
  const tmpdir = os.tmpdir();
  const filePaths = [];
  const files = ctx.request.body.files || {};

  for (let key in files) {
    const file = files[key];
    const filePath = path.join(tmpdir, file.name);
    const reader = fs.createReadStream(file.path);
    const writer = fs.createWriteStream(filePath);
    reader.pipe(writer);
    filePaths.push(filePath);
  }

  ctx.body = filePaths;
};

app.use(koaBody({ multipart: true }));

运行这个 demo。

$ node demos/21.js

打开另一个命令行窗口,运行下面的命令,上传一个文件。注意,/path/to/file要更换为真实的文件路径。

$ curl --form upload=@/path/to/file http://127.0.0.1:3000
["/tmp/file"]

六、参考链接

(完)

文档信息

]]>
0
<![CDATA[Paradox 的数据文件格式]]> http://www.udpwork.com/item/16338.html http://www.udpwork.com/item/16338.html#reviews Tue, 08 Aug 2017 20:06:51 +0800 云风 http://www.udpwork.com/item/16338.html Paradox 是我很喜欢的一个游戏公司,在所谓 P 社 5 萌中,十字军之王和钢铁雄心都只有浅尝,但在维多利亚和群星上均投入了大量时间和精力。

这些游戏基于同一套引擎,所以数据文件格式也是共通的。P 社开放了 Mod ,允许玩家来修改游戏,所以数据文件都是明文文本存放在文件系统中,这给了我们一个极好的学习机会:对于游戏从业者,我很有兴趣看看成熟引擎是如何管理游戏数据和游戏逻辑的。

据我所接触到的国内游戏公司,包括我们自己公司在内,游戏数据大都是基于 excel 这种二维表来表达的。我把它称为 csv 模式。这种模式的特点是,基础数据结构基于若干张二维表,每张表有不确定的行数,但每行有固定了列数。用它做基础数据结构的缺陷是很明显的,比如它很难表达树状层级结构。这往往就依赖做一个中间层,规范一些使用格式,在其上模拟出复杂数据结构。

另一种在软件行业广泛使用的基础数据结构是 json/xml 模式。json 比 xml 要简单。它的特点就是定义了两种基础的复合结构,字典和数组,允许结构嵌套。基于这种模式管理游戏数据的我也见过一些。不过对于策划来说,编辑树结构的数据终究不如 excel 拉表方便。查看起来也没有特别好的可视化工具,所以感觉用的人要少一些。

最开始,我以为 P 社的数据文件是偏向于后一种 json 模式。但实际研究下来又觉得有很大的不同。今天我尝试用 lpeg 写了一个简单的 parser 试图把它读进 lua vm ,写完 parser 后突然醒悟过来,其实它就是基于的嵌套 list ,不正是 lisp 吗?想明白这点后,有种醍醐灌顶的感觉,的确 lisp 模式要比 json 模式简洁的多,并不比 csv 模式复杂。但表达能力却强于它们两者,的确是一个更好的数据组织方案。

我们来看一个从群星中随便摘录的例子(有点长,但挺有代表性):

country_event = {
    id = primitive.16
    hide_window = yes

    trigger = {
        is_country_type = primitive
        has_country_flag = early_space_age
        #NOT = { has_country_flag = recently_advanced }
        OR = {
            AND = {
                exists = from
                from = {
                    OR = {
                        is_country_type = default
                        is_country_type = awakened_fallen_empire
                    }
                }
            }
            years_passed > 25
        }
    }

    mean_time_to_happen = {
        years = 100

        modifier = {
            factor = 0.6
            has_country_flag = acquired_tech
        }
    }

    immediate = {
        remove_country_flag = early_space_age
        set_country_flag = primitives_can_into_space
        set_country_type = default
        change_country_flag = random
        if = {
            limit = { is_species_class = MAM }
            set_graphical_culture = mammalian_01
        }
        if = {
            limit = { is_species_class = REP }
            set_graphical_culture = reptilian_01
        }
        if = {
            limit = { is_species_class = AVI }
            set_graphical_culture = avian_01
        }
        if = {
            limit = { is_species_class = ART }
            set_graphical_culture = arthropoid_01
        }
        if = {
            limit = { is_species_class = MOL }
            set_graphical_culture = molluscoid_01
        }
        if = {
            limit = { is_species_class = FUN }
            set_graphical_culture = fungoid_01
        }
        change_government = {
            authority = random
            civics = random
        }
        set_name = random
        if = {
            limit = {
                home_planet = {
                    has_observation_outpost = yes
                }
            }
            home_planet = {
                observation_outpost_owner = {
                    country_event = { id = primitive.17 }
                }
            }
        }
        add_minerals = 1000 # enough for a spaceport and then some
        add_energy = 500
        add_influence = 300
        capital_scope = {
            every_tile = {
                limit = {
                    has_blocker = yes
                    NOR = {
                        has_blocker = tb_decrepit_dwellings
                        has_blocker = tb_failing_infrastructure
                    }
                }
                remove_blocker = yes
            }
            while = {
                limit = {
                    num_pops 

起初,我很疑惑在这个格式中,为啥赋值和相等都用的 = ,这不是容易引起歧义么?但是你从 lisp 的角度来看就简单了。等于号只是为了便于策划书写和阅读的一个变形。所谓 id = primitive.16 你可以理解为 ( id, primitive.16 )  而 iscountrytype = default 一样可以理解为 ( iscountrytype , default ) 。 而 


create_army = {
                name = random
                owner = PREV
                species = owner_main_species
                type = "defense_army"
            }


本质上是 ( create_army , ( ( name, random ) , (owner, PREV), (species, owner_main_species), (type, "defense_army") ) )。

基础数据结构只要能表达出来,怎么理解这些 list 是更上层的工作,这就和我们在 csv 中去模拟树结构是一样的道理。只不过 years_passed > 25 这样的东西,被翻译成 ( years_passed, > , 25 ) 有三个元素。上层解析的时候,如果确定它是一个逻辑表达式,就很容易在 2  个元素的 list 中间插入一个 = 补全。

这种结构很容易描述一些控制结构,比如上面例子中的 if  。我还在其它数据中发现了 repeat while 等控制结构,这些都是上层的工作,和底层数据模型无关。但不得不说,lisp 模式比 csv 模式更容易做此类控制结构。

把这种数据结构翻译成 lua 也很容易:只需要用 lua table 的 array 来保存即可。但为了使用方便,可以加一个代理结构。如果上层业务想把一个 list 解析成字典,就在 cache 中临时生成一个 hash 表加快查询即可。我们甚至可以把它直接存在 C 内存中,只在 lua 中暴露出遍历以及高层的访问方法。所谓高层的访问方法指,可以直接读取 if repeat 等控制结构,或是把带 AND OR 这样的复合 list 直接翻译成一个条件表达式。

8 月 8 日补充:

我实现了一个简单的 lua parser : https://github.com/cloudwu/pdxparser 。
]]>
Paradox 是我很喜欢的一个游戏公司,在所谓 P 社 5 萌中,十字军之王和钢铁雄心都只有浅尝,但在维多利亚和群星上均投入了大量时间和精力。

这些游戏基于同一套引擎,所以数据文件格式也是共通的。P 社开放了 Mod ,允许玩家来修改游戏,所以数据文件都是明文文本存放在文件系统中,这给了我们一个极好的学习机会:对于游戏从业者,我很有兴趣看看成熟引擎是如何管理游戏数据和游戏逻辑的。

据我所接触到的国内游戏公司,包括我们自己公司在内,游戏数据大都是基于 excel 这种二维表来表达的。我把它称为 csv 模式。这种模式的特点是,基础数据结构基于若干张二维表,每张表有不确定的行数,但每行有固定了列数。用它做基础数据结构的缺陷是很明显的,比如它很难表达树状层级结构。这往往就依赖做一个中间层,规范一些使用格式,在其上模拟出复杂数据结构。

另一种在软件行业广泛使用的基础数据结构是 json/xml 模式。json 比 xml 要简单。它的特点就是定义了两种基础的复合结构,字典和数组,允许结构嵌套。基于这种模式管理游戏数据的我也见过一些。不过对于策划来说,编辑树结构的数据终究不如 excel 拉表方便。查看起来也没有特别好的可视化工具,所以感觉用的人要少一些。

最开始,我以为 P 社的数据文件是偏向于后一种 json 模式。但实际研究下来又觉得有很大的不同。今天我尝试用 lpeg 写了一个简单的 parser 试图把它读进 lua vm ,写完 parser 后突然醒悟过来,其实它就是基于的嵌套 list ,不正是 lisp 吗?想明白这点后,有种醍醐灌顶的感觉,的确 lisp 模式要比 json 模式简洁的多,并不比 csv 模式复杂。但表达能力却强于它们两者,的确是一个更好的数据组织方案。

我们来看一个从群星中随便摘录的例子(有点长,但挺有代表性):

country_event = {
    id = primitive.16
    hide_window = yes

    trigger = {
        is_country_type = primitive
        has_country_flag = early_space_age
        #NOT = { has_country_flag = recently_advanced }
        OR = {
            AND = {
                exists = from
                from = {
                    OR = {
                        is_country_type = default
                        is_country_type = awakened_fallen_empire
                    }
                }
            }
            years_passed > 25
        }
    }

    mean_time_to_happen = {
        years = 100

        modifier = {
            factor = 0.6
            has_country_flag = acquired_tech
        }
    }

    immediate = {
        remove_country_flag = early_space_age
        set_country_flag = primitives_can_into_space
        set_country_type = default
        change_country_flag = random
        if = {
            limit = { is_species_class = MAM }
            set_graphical_culture = mammalian_01
        }
        if = {
            limit = { is_species_class = REP }
            set_graphical_culture = reptilian_01
        }
        if = {
            limit = { is_species_class = AVI }
            set_graphical_culture = avian_01
        }
        if = {
            limit = { is_species_class = ART }
            set_graphical_culture = arthropoid_01
        }
        if = {
            limit = { is_species_class = MOL }
            set_graphical_culture = molluscoid_01
        }
        if = {
            limit = { is_species_class = FUN }
            set_graphical_culture = fungoid_01
        }
        change_government = {
            authority = random
            civics = random
        }
        set_name = random
        if = {
            limit = {
                home_planet = {
                    has_observation_outpost = yes
                }
            }
            home_planet = {
                observation_outpost_owner = {
                    country_event = { id = primitive.17 }
                }
            }
        }
        add_minerals = 1000 # enough for a spaceport and then some
        add_energy = 500
        add_influence = 300
        capital_scope = {
            every_tile = {
                limit = {
                    has_blocker = yes
                    NOR = {
                        has_blocker = tb_decrepit_dwellings
                        has_blocker = tb_failing_infrastructure
                    }
                }
                remove_blocker = yes
            }
            while = {
                limit = {
                    num_pops 

起初,我很疑惑在这个格式中,为啥赋值和相等都用的 = ,这不是容易引起歧义么?但是你从 lisp 的角度来看就简单了。等于号只是为了便于策划书写和阅读的一个变形。所谓 id = primitive.16 你可以理解为 ( id, primitive.16 )  而 iscountrytype = default 一样可以理解为 ( iscountrytype , default ) 。 而 


create_army = {
                name = random
                owner = PREV
                species = owner_main_species
                type = "defense_army"
            }


本质上是 ( create_army , ( ( name, random ) , (owner, PREV), (species, owner_main_species), (type, "defense_army") ) )。

基础数据结构只要能表达出来,怎么理解这些 list 是更上层的工作,这就和我们在 csv 中去模拟树结构是一样的道理。只不过 years_passed > 25 这样的东西,被翻译成 ( years_passed, > , 25 ) 有三个元素。上层解析的时候,如果确定它是一个逻辑表达式,就很容易在 2  个元素的 list 中间插入一个 = 补全。

这种结构很容易描述一些控制结构,比如上面例子中的 if  。我还在其它数据中发现了 repeat while 等控制结构,这些都是上层的工作,和底层数据模型无关。但不得不说,lisp 模式比 csv 模式更容易做此类控制结构。

把这种数据结构翻译成 lua 也很容易:只需要用 lua table 的 array 来保存即可。但为了使用方便,可以加一个代理结构。如果上层业务想把一个 list 解析成字典,就在 cache 中临时生成一个 hash 表加快查询即可。我们甚至可以把它直接存在 C 内存中,只在 lua 中暴露出遍历以及高层的访问方法。所谓高层的访问方法指,可以直接读取 if repeat 等控制结构,或是把带 AND OR 这样的复合 list 直接翻译成一个条件表达式。

8 月 8 日补充:

我实现了一个简单的 lua parser : https://github.com/cloudwu/pdxparser 。
]]>
0
<![CDATA[本博客开始支持 TLS 1.3]]> http://www.udpwork.com/item/16377.html http://www.udpwork.com/item/16377.html#reviews Sun, 06 Aug 2017 14:37:02 +0800 JerryQu http://www.udpwork.com/item/16377.html 几个月前,我在升级本博客所用 Nginx 时,顺手加上了对 TLS 1.3 的支持,本文贴出详细的步骤和注意事项。有关 TLS 1.3 的介绍可以看 CloudFlare 的这篇文章:An overview of TLS 1.3 and Q&A。需要注意目前 Chrome 和 Firefox 支持的是 TLS 1.3 draft 18,暂时不要用在生产环境。

安装依赖

我的 VPS 系统是 Ubuntu 16.04.3 LTS,如果你使用其它发行版,与包管理有关的命令请自行调整。

首先安装依赖库和编译要用到的工具:

sudo apt-get install build-essential libpcre3 libpcre3-dev zlib1g-dev unzip git

获取必要组件

nginx-ct和ngx-brotli与本文主题无关,不过都是常用的 Nginx 组件,一并记录在这里。

nginx-ct

nginx-ct模块用于启用Certificate Transparency功能。直接从 github 上获取源码:

wget -O nginx-ct.zip -c https://github.com/grahamedgecombe/nginx-ct/archive/v1.3.2.zip
unzip nginx-ct.zip

ngx_brotli

本站支持 Google 开发的Brotli压缩格式,它通过内置分析大量网页得出的字典,实现了更高的压缩比率,同时几乎不影响压缩 / 解压速度。

以下是让 Nginx 支持 Brotli 所需准备工作,这些工作是一次性的。首先安装 libbrotli:

sudo apt-get install autoconf libtool automake

git clone https://github.com/bagder/libbrotli
cd libbrotli

# 如果提示 error: C source seen but 'CC' is undefined,可以在 configure.ac 最后加上 AC_PROG_CC
./autogen.sh

./configure
make
sudo make install

cd  ../

默认 libbrotli 装在/usr/local/lib/libbrotlienc.so.1,如果后续启动 Nginx 时提示找不到这个文件,那么可以把它软链到/lib或者/usr/lib目录。如果还有问题,请参考这篇文章查找解决方案。

接下来获取ngx_brotli源码:

git clone https://github.com/google/ngx_brotli.git
cd ngx_brotli

git submodule update --init

cd ../

OpenSSL

为了支持 TLS 1.3,需要使用 OpenSSL 1.1.1 的 draft-18 分支:

git clone -b tls1.3-draft-18 --single-branch https://github.com/openssl/openssl.git openssl

编译并安装 Nginx

接着就可以获取 Nginx 源码,编译并安装:

wget -c https://nginx.org/download/nginx-1.13.3.tar.gz
tar zxf nginx-1.13.3.tar.gz

cd nginx-1.13.3/

./configure --add-module=../ngx_brotli --add-module=../nginx-ct-1.3.2 --with-openssl=../openssl --with-openssl-opt='enable-tls1_3 enable-weak-ssl-ciphers' --with-http_v2_module --with-http_ssl_module --with-http_gzip_static_module

make
sudo make install

enable-tls1_3是让 OpenSSL 支持 TLS 1.3 的关键选项;而enable-weak-ssl-ciphers的作用是让 OpenSSL 继续支持 3DES 等不安全的 Cipher Suite,如果你打算继续支持 IE8,才需要加上这个选项。

除了http_v2和http_ssl这两个 HTTP/2 必备模块之外,我还额外启用了http_gzip_static,需要启用哪些模块需要根据自己实际情况来决定。

以上步骤会把 Nginx 装到/usr/local/nginx/目录,如需更改路径可以在 configure 时指定。

WEB 站点配置

在 Nginx 的站点配置中,以下两个参数需要修改:

ssl_protocols              TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # 增加 TLSv1.3
ssl_ciphers                TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-128-GCM-SHA256:TLS13-AES-128-CCM-8-SHA256:TLS13-AES-128-CCM-SHA256:EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+ECDSA+AES128:EECDH+aRSA+AES128:RSA+AES128:EECDH+ECDSA+AES256:EECDH+aRSA+AES256:RSA+AES256:EECDH+ECDSA+3DES:EECDH+aRSA+3DES:RSA+3DES:!MD5;

包含TLS13是 TLS 1.3 新增的 Cipher Suite,加在最前面即可;如果你不打算继续支持 IE8,可以去掉包含3DES的 Cipher Suite。

本博客完整的 Nginx 配置,请点击这里查看。

验证是否支持 TLS 1.3

目前最新版 Chrome 和 Firefox 都支持 TLS 1.3,但需要手动开启:

  • Chrome,将chrome://flags/中的Maximum TLS version enabled改为TLS 1.3(Chrome 62 中需要将TLS 1.3改为Enabled (Draft),感谢 @TsuranSonoda 指出);
  • Firefox,将about:config中的security.tls.version.max改为4;

本博客多次推荐的Qualys SSL Labs's SSL Server Test也支持验证服务端是否支持 TLS 1.3,非常方便,继续推荐。

本文链接:https://imququ.com/post/enable-tls-1-3.html参与讨论

]]>
几个月前,我在升级本博客所用 Nginx 时,顺手加上了对 TLS 1.3 的支持,本文贴出详细的步骤和注意事项。有关 TLS 1.3 的介绍可以看 CloudFlare 的这篇文章:An overview of TLS 1.3 and Q&A。需要注意目前 Chrome 和 Firefox 支持的是 TLS 1.3 draft 18,暂时不要用在生产环境。

安装依赖

我的 VPS 系统是 Ubuntu 16.04.3 LTS,如果你使用其它发行版,与包管理有关的命令请自行调整。

首先安装依赖库和编译要用到的工具:

sudo apt-get install build-essential libpcre3 libpcre3-dev zlib1g-dev unzip git

获取必要组件

nginx-ct和ngx-brotli与本文主题无关,不过都是常用的 Nginx 组件,一并记录在这里。

nginx-ct

nginx-ct模块用于启用Certificate Transparency功能。直接从 github 上获取源码:

wget -O nginx-ct.zip -c https://github.com/grahamedgecombe/nginx-ct/archive/v1.3.2.zip
unzip nginx-ct.zip

ngx_brotli

本站支持 Google 开发的Brotli压缩格式,它通过内置分析大量网页得出的字典,实现了更高的压缩比率,同时几乎不影响压缩 / 解压速度。

以下是让 Nginx 支持 Brotli 所需准备工作,这些工作是一次性的。首先安装 libbrotli:

sudo apt-get install autoconf libtool automake

git clone https://github.com/bagder/libbrotli
cd libbrotli

# 如果提示 error: C source seen but 'CC' is undefined,可以在 configure.ac 最后加上 AC_PROG_CC
./autogen.sh

./configure
make
sudo make install

cd  ../

默认 libbrotli 装在/usr/local/lib/libbrotlienc.so.1,如果后续启动 Nginx 时提示找不到这个文件,那么可以把它软链到/lib或者/usr/lib目录。如果还有问题,请参考这篇文章查找解决方案。

接下来获取ngx_brotli源码:

git clone https://github.com/google/ngx_brotli.git
cd ngx_brotli

git submodule update --init

cd ../

OpenSSL

为了支持 TLS 1.3,需要使用 OpenSSL 1.1.1 的 draft-18 分支:

git clone -b tls1.3-draft-18 --single-branch https://github.com/openssl/openssl.git openssl

编译并安装 Nginx

接着就可以获取 Nginx 源码,编译并安装:

wget -c https://nginx.org/download/nginx-1.13.3.tar.gz
tar zxf nginx-1.13.3.tar.gz

cd nginx-1.13.3/

./configure --add-module=../ngx_brotli --add-module=../nginx-ct-1.3.2 --with-openssl=../openssl --with-openssl-opt='enable-tls1_3 enable-weak-ssl-ciphers' --with-http_v2_module --with-http_ssl_module --with-http_gzip_static_module

make
sudo make install

enable-tls1_3是让 OpenSSL 支持 TLS 1.3 的关键选项;而enable-weak-ssl-ciphers的作用是让 OpenSSL 继续支持 3DES 等不安全的 Cipher Suite,如果你打算继续支持 IE8,才需要加上这个选项。

除了http_v2和http_ssl这两个 HTTP/2 必备模块之外,我还额外启用了http_gzip_static,需要启用哪些模块需要根据自己实际情况来决定。

以上步骤会把 Nginx 装到/usr/local/nginx/目录,如需更改路径可以在 configure 时指定。

WEB 站点配置

在 Nginx 的站点配置中,以下两个参数需要修改:

ssl_protocols              TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # 增加 TLSv1.3
ssl_ciphers                TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-128-GCM-SHA256:TLS13-AES-128-CCM-8-SHA256:TLS13-AES-128-CCM-SHA256:EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+ECDSA+AES128:EECDH+aRSA+AES128:RSA+AES128:EECDH+ECDSA+AES256:EECDH+aRSA+AES256:RSA+AES256:EECDH+ECDSA+3DES:EECDH+aRSA+3DES:RSA+3DES:!MD5;

包含TLS13是 TLS 1.3 新增的 Cipher Suite,加在最前面即可;如果你不打算继续支持 IE8,可以去掉包含3DES的 Cipher Suite。

本博客完整的 Nginx 配置,请点击这里查看。

验证是否支持 TLS 1.3

目前最新版 Chrome 和 Firefox 都支持 TLS 1.3,但需要手动开启:

  • Chrome,将chrome://flags/中的Maximum TLS version enabled改为TLS 1.3(Chrome 62 中需要将TLS 1.3改为Enabled (Draft),感谢 @TsuranSonoda 指出);
  • Firefox,将about:config中的security.tls.version.max改为4;

本博客多次推荐的Qualys SSL Labs's SSL Server Test也支持验证服务端是否支持 TLS 1.3,非常方便,继续推荐。

本文链接:https://imququ.com/post/enable-tls-1-3.html参与讨论

]]>
0
<![CDATA[品茶作业 — 2017年早春景迈古树]]> http://www.udpwork.com/item/16375.html http://www.udpwork.com/item/16375.html#reviews Sat, 05 Aug 2017 15:00:43 +0800 qyjohn http://www.udpwork.com/item/16375.html IMG_3198IMG_3187

这一份作业,记的是剑年师兄赠送的2017年早春景迈古树,一个月前由岳父岳母从北京捎来雪梨。这段时间迫于生计疲于奔命,竟然一直都没有打开过。今天终于有了些许闲暇,也来不及沐浴焚香,便直奔Chatswood火车站的Tea Journal而去。经过三个小时的品饮,整理成茶记一篇,请剑年师兄指正。

茶具:110 CC白瓷盖碗
用水:市政自来水
水壶:随手泡
水温:80度到100度之间
茶量:6.0 克
水量:80 CC左右
冲泡:5秒到30秒

这一块茶饼,干净清爽,条索分明。清闻花香淡雅,无烟味。茶饼压制较松,可以轻易用茶针撬开。取茶叶适量置于茶荷内,茶叶大小匀称,通体银毫;大部分叶片完整,亦有些许碎屑。用沸水冲洗白瓷盖碗,趁热将茶叶投入盖碗中震荡数次醒茶。温热的茶叶兰香浓郁,无异味。冲洗茶具及醒茶后不再烧水,直接用随手泡中的热水冲第一泡茶。从第二泡起,每泡茶均重新烧水,使用滚水进行冲泡。出汤时将茶汤注入公道杯,又由公道杯分注入茶杯。

第一泡:水温90度,急冲点注,即冲即出。茶汤浅黄,底部有些许碎末;清闻有淡雅花香,略带蜜香。叶底清闻有浓郁兰香。茶汤入口略甜,很淡,又略带些许奶香,不苦不涩。饮后三五分钟,感觉上颚有轻微回甘,舍底生津。回甘与生津缓慢地蔓延到双颊与舌根,不强烈。

第二泡:水温100度,急冲点注,即冲即出。茶汤浅黄,但是汤色比前一泡稍深,底部有些许碎末;清闻花香更浓,蜜香更显。叶底兰香扑面。茶汤入口微甜,奶香更浓;饮后有极其轻微的涩感,主要体现在上颚;不苦。待茶汤稍凉后再饮,涩感不显,愈饮愈甜。饮后三五分钟,由喉咙处开始生津回甘,口腔有些许舒张感,不强烈。回甘与生津蔓延到整个口腔后变成满口清甜,不强烈。

第三泡:水温100度,急冲浇注,即冲即出。茶汤鹅黄,底部有些许碎末;清闻花香转淡,蜜香不显。叶底兰香更浓,茶叶开始舒展,叶片挺拔有活力。茶汤入口微甜,奶香转淡;茶汤带极轻微涩感,不苦。待茶汤稍凉后再饮,甜味更显,涩感更轻。饮后三五分钟,由舌底开始生津回甘,口腔舒张感更加明显,但是也不强烈。又过三五分钟后,回甘转变成满口淡淡的清甜。

第四泡:水温100度,缓慢浇注,注水10秒后出汤。茶汤鹅黄,底部有些许碎末;清闻花香更淡,无其他香味。叶底兰香依然浓郁,茶叶更加舒展。茶汤入口微甜,依然有些许奶香;舌面有些许涩感,不苦。待茶汤稍凉后再饮,甜味更显。饮后三五分钟,由舌底和双颊开始生津回甘,不强烈。稍后回甘又转变成满口清甜,持久绵长。

中场休息十五分钟。

第五泡:水温100度,缓慢浇注,注水15秒后出汤。茶汤橙黄,底部有些许碎末,花香不显。叶底兰香依旧显扬,茶叶已经完全舒展开来;叶片颜色以嫩绿为主,又有几片紫芽。茶汤入口微甜,奶香极淡;舌面有些许涩感,又有极轻微的苦。苦味片刻之后很快消退,变成轻微的回甘,口腔有轻微的舒张感。三五分钟后,由舌底开始感觉到轻微涩感,又伴随着稍微强烈一些的生津和回甘。七八分钟后,回甘和生津的感觉逐渐消失,留下满口淡淡的清甜。

第六泡:水温100度,缓慢浇注,注水20秒后出汤。茶汤橙黄,花香不显。叶底兰香开始减弱,但是依然清晰可闻。茶汤入口很甜,奶香不显,又带极轻微的苦涩。苦味片刻之后很快消退,变成轻微的回甘。回甘逐渐明显,但是始终不强烈,随后又转成满口甜味。甜味持久绵长,极柔和,舌尖的感觉尤为明显。缓慢地回味品饮的感受,又能感受到舌底和两颊开始产生轻微的回甘,随之又变成柔和的甜味蔓延至整个口腔。

第七泡:水温100度,缓慢浇注,注水25秒后出汤。茶汤橙黄,花香不显,茶汤入口很甜,奶香不显,依然带极轻微的苦涩。苦味很快消退,随之是轻微的回甘,口腔有轻微的舒张感,继而变成满口清甜。

第八泡:水温100度,缓慢浇注,注水25秒后出汤。茶汤橙黄,花香不显,茶汤入口很甜,奶香不显,依然带极轻微的苦涩。苦味很快消退,随之是轻微的回甘,口腔有轻微的舒张感,继而变成满口清甜。

倒一杯温开水,缓缓浇过舌面。一阵强烈的甜味由舌面升起,继而延伸到整个口腔。

如下图片,分别是第一、三、五、七泡的茶汤。

IMG_3176IMG_3181
IMG_3186
IMG_3191

景迈是一款温柔的茶。记得第一次喝到景迈,是2009年的栋雨云飞版景迈古树顶芽,当时就很喜欢。那时在品茶作业里写道:“这一款茶,象极了十五六岁的小家碧玉,满面羞涩,外敛内秀,温柔婉转,感人至深。”八年以后,个人的际遇和环境都与当时有了很大的不同,可是对于景迈的感觉,犹如初见。

]]>
IMG_3198IMG_3187

这一份作业,记的是剑年师兄赠送的2017年早春景迈古树,一个月前由岳父岳母从北京捎来雪梨。这段时间迫于生计疲于奔命,竟然一直都没有打开过。今天终于有了些许闲暇,也来不及沐浴焚香,便直奔Chatswood火车站的Tea Journal而去。经过三个小时的品饮,整理成茶记一篇,请剑年师兄指正。

茶具:110 CC白瓷盖碗
用水:市政自来水
水壶:随手泡
水温:80度到100度之间
茶量:6.0 克
水量:80 CC左右
冲泡:5秒到30秒

这一块茶饼,干净清爽,条索分明。清闻花香淡雅,无烟味。茶饼压制较松,可以轻易用茶针撬开。取茶叶适量置于茶荷内,茶叶大小匀称,通体银毫;大部分叶片完整,亦有些许碎屑。用沸水冲洗白瓷盖碗,趁热将茶叶投入盖碗中震荡数次醒茶。温热的茶叶兰香浓郁,无异味。冲洗茶具及醒茶后不再烧水,直接用随手泡中的热水冲第一泡茶。从第二泡起,每泡茶均重新烧水,使用滚水进行冲泡。出汤时将茶汤注入公道杯,又由公道杯分注入茶杯。

第一泡:水温90度,急冲点注,即冲即出。茶汤浅黄,底部有些许碎末;清闻有淡雅花香,略带蜜香。叶底清闻有浓郁兰香。茶汤入口略甜,很淡,又略带些许奶香,不苦不涩。饮后三五分钟,感觉上颚有轻微回甘,舍底生津。回甘与生津缓慢地蔓延到双颊与舌根,不强烈。

第二泡:水温100度,急冲点注,即冲即出。茶汤浅黄,但是汤色比前一泡稍深,底部有些许碎末;清闻花香更浓,蜜香更显。叶底兰香扑面。茶汤入口微甜,奶香更浓;饮后有极其轻微的涩感,主要体现在上颚;不苦。待茶汤稍凉后再饮,涩感不显,愈饮愈甜。饮后三五分钟,由喉咙处开始生津回甘,口腔有些许舒张感,不强烈。回甘与生津蔓延到整个口腔后变成满口清甜,不强烈。

第三泡:水温100度,急冲浇注,即冲即出。茶汤鹅黄,底部有些许碎末;清闻花香转淡,蜜香不显。叶底兰香更浓,茶叶开始舒展,叶片挺拔有活力。茶汤入口微甜,奶香转淡;茶汤带极轻微涩感,不苦。待茶汤稍凉后再饮,甜味更显,涩感更轻。饮后三五分钟,由舌底开始生津回甘,口腔舒张感更加明显,但是也不强烈。又过三五分钟后,回甘转变成满口淡淡的清甜。

第四泡:水温100度,缓慢浇注,注水10秒后出汤。茶汤鹅黄,底部有些许碎末;清闻花香更淡,无其他香味。叶底兰香依然浓郁,茶叶更加舒展。茶汤入口微甜,依然有些许奶香;舌面有些许涩感,不苦。待茶汤稍凉后再饮,甜味更显。饮后三五分钟,由舌底和双颊开始生津回甘,不强烈。稍后回甘又转变成满口清甜,持久绵长。

中场休息十五分钟。

第五泡:水温100度,缓慢浇注,注水15秒后出汤。茶汤橙黄,底部有些许碎末,花香不显。叶底兰香依旧显扬,茶叶已经完全舒展开来;叶片颜色以嫩绿为主,又有几片紫芽。茶汤入口微甜,奶香极淡;舌面有些许涩感,又有极轻微的苦。苦味片刻之后很快消退,变成轻微的回甘,口腔有轻微的舒张感。三五分钟后,由舌底开始感觉到轻微涩感,又伴随着稍微强烈一些的生津和回甘。七八分钟后,回甘和生津的感觉逐渐消失,留下满口淡淡的清甜。

第六泡:水温100度,缓慢浇注,注水20秒后出汤。茶汤橙黄,花香不显。叶底兰香开始减弱,但是依然清晰可闻。茶汤入口很甜,奶香不显,又带极轻微的苦涩。苦味片刻之后很快消退,变成轻微的回甘。回甘逐渐明显,但是始终不强烈,随后又转成满口甜味。甜味持久绵长,极柔和,舌尖的感觉尤为明显。缓慢地回味品饮的感受,又能感受到舌底和两颊开始产生轻微的回甘,随之又变成柔和的甜味蔓延至整个口腔。

第七泡:水温100度,缓慢浇注,注水25秒后出汤。茶汤橙黄,花香不显,茶汤入口很甜,奶香不显,依然带极轻微的苦涩。苦味很快消退,随之是轻微的回甘,口腔有轻微的舒张感,继而变成满口清甜。

第八泡:水温100度,缓慢浇注,注水25秒后出汤。茶汤橙黄,花香不显,茶汤入口很甜,奶香不显,依然带极轻微的苦涩。苦味很快消退,随之是轻微的回甘,口腔有轻微的舒张感,继而变成满口清甜。

倒一杯温开水,缓缓浇过舌面。一阵强烈的甜味由舌面升起,继而延伸到整个口腔。

如下图片,分别是第一、三、五、七泡的茶汤。

IMG_3176IMG_3181
IMG_3186
IMG_3191

景迈是一款温柔的茶。记得第一次喝到景迈,是2009年的栋雨云飞版景迈古树顶芽,当时就很喜欢。那时在品茶作业里写道:“这一款茶,象极了十五六岁的小家碧玉,满面羞涩,外敛内秀,温柔婉转,感人至深。”八年以后,个人的际遇和环境都与当时有了很大的不同,可是对于景迈的感觉,犹如初见。

]]>
0
<![CDATA[光明网 (광명망)]]> http://www.udpwork.com/item/16376.html http://www.udpwork.com/item/16376.html#reviews Sat, 05 Aug 2017 12:06:00 +0800 Difan Zhang http://www.udpwork.com/item/16376.html 互联网和人都是健忘的。温水煮青蛙的策略也是成功的。

大部分人不会回忆起的是,就在二十年前, 169 拨号后得到的网络是没有 Internet 权限的 —— 只有拨更贵的 163 才有因特网权限。这种类似于常识的知识,居然现在我都没想起来。

前几年,有人说 Gmail 不可能封掉, Google 不可能封掉。这几年纷纷都实现了 —— 因为有几年的缓冲时间,似乎也没有什么抱怨。

光明网对中国并不是一个陌生的事物,将来也不会是一个陌生的事物 —— 想象一个只有微信、微博的网络 —— 似乎也没有什么不妥?某一天,光明网建成的时候,也只不过会造成一小撮人 —— 不,敌人 —— 的抱怨而已。大部分人,看着微博微信,用着百度地图,也挺开心的。

]]>
互联网和人都是健忘的。温水煮青蛙的策略也是成功的。

大部分人不会回忆起的是,就在二十年前, 169 拨号后得到的网络是没有 Internet 权限的 —— 只有拨更贵的 163 才有因特网权限。这种类似于常识的知识,居然现在我都没想起来。

前几年,有人说 Gmail 不可能封掉, Google 不可能封掉。这几年纷纷都实现了 —— 因为有几年的缓冲时间,似乎也没有什么抱怨。

光明网对中国并不是一个陌生的事物,将来也不会是一个陌生的事物 —— 想象一个只有微信、微博的网络 —— 似乎也没有什么不妥?某一天,光明网建成的时候,也只不过会造成一小撮人 —— 不,敌人 —— 的抱怨而已。大部分人,看着微博微信,用着百度地图,也挺开心的。

]]>
0