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

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


  • 首页

  • 归档

  • 搜索

一种基于快速GeoHash实现海量商品与商圈高效匹配的算法

发表于 2018-07-09

摘要

闲鱼app根据交通条件、商场分布情况、住宅区分布情况综合考虑,将城市划分为一个个商圈。杭州部分区域商圈划分如下图所示。

闲鱼的商品是由用户发布的GPS随机分布在地图上的点数据。当用户处于某个商圈范围内时,app会向用户推荐gps位于此商圈中的商品。要实现精准推荐服务,就需要计算出哪些商品是归属于你所处的商圈。

在数据库中,商圈是由多个点围成的面数据,这些面数据形状、大小各异,且互不重叠。商品是以gps标记的点数据,如何能够快速高效地确定海量商品与商圈的归属关系呢?传统而直接的方法是,利用几何学的空间关系计算公式对海量数据实施直接的“点-面”关系计算,来确定每一个商品是否位于每一个商圈内部。闲鱼目前有10亿商品数据,且每天还在快速增加。全国所有城市的商圈数量总和大约为1万,每个商圈的大小不一,边数从10到80不等。如果直接使用几何学点面关系运算,需要的计算量级约为2亿亿次基本运算。按照这个思路,我们尝试过使用阿里巴巴集团内部的离线计算集群来执行计算,结果集群在运行了超过2天之后也未能给出结果。
经过算法改进,我们采用了一种基于GeoHash精确匹配,结合GeoHash非精确匹配并配合小范围几何学关系运算精匹配的算法,大大降低了计算量,高效地实现了离线环境下海量点-面数据的包含关系计算。同样是对10亿条商品和1万条商圈数据做匹配,可以在1天内得到结果。

点数据GeoHash原理与算法

GeoHash是一种对地理坐标进行编码的方法,它将二维坐标映射为一个字符串。每个字符串代表一个特定的矩形,在该矩形范围内的所有坐标都共用这个字符串。字符串越长精度越高,对应的矩形范围越小。 对一个地理坐标编码时,按照初始区间范围纬度[-90,90]和经度[-180,180],计算目标经度和纬度分别落在左区间还是右区间。落在左区间则取0,右区间则取1。然后,对上一步得到的区间继续按照此方法对半查找,得到下一位二进制编码。当编码长度达到业务的进度需求后,根据“偶数位放经度,奇数位放纬度”的规则,将得到的二进制编码穿插组合,得到一个新的二进制串。最后,根据base32的对照表,将二进制串翻译成字符串,即得到地理坐标对应的目标GeoHash字符串。

以坐标“30.280245, 120.027162”为例,计算其GeoHash字符串。 首先对纬度做二进制编码: 1)将[-90,90]平分为2部分,“30.280245”落在右区间(0,90],则第一位取1。 2)将(0,90]平分为2部分,“30.280245”落在左区间(0,45],则第二位取0。 3)不断重复以上步骤,得到的目标区间会越来越小,区间的两个端点也越来越逼近“30.280245”。 下图的流程详细地描述了前几次迭代的过程:

按照上面的流程,继续往下迭代,直到编码位数达到我们业务对精度的需求为止。完整的15位二进制编码迭代表格如下: 得到的纬度二进制编码为10101 01100 01000。

按照同样的流程,对经度做二进制编码,具体迭代详情如下: 得到的经度二进制编码为11010 10101 01101。

按照“偶数位放经度,奇数位放纬度”的规则,将经纬度的二进制编码穿插,得到完成的二进制编码为:11100 11001 10011 10010 00111 00010。由于后续要使用的是base32编码,每5个二进制数对应一个32进制数,所以这里将每5个二进制位转换成十进制位,得到28,25,19,18,7,2。 对照base32编码表,得到对应的编码为:wtmk72。

可以在geohash.org/网站对上述结果进行验证,验证结果如下: 验证结果的前几位与我们的计算结果一致。如果我们利用二分法获取二进制编码时迭代更多次,就会得到验证网站中这样的位数更多的更精确结果。

GeoHash字符串的长度与精度的对应关系如下:

面数据GeoHash编码实现

上一节介绍的标准GeoHash算法只能用来计算二维点坐标对应的GeoHash编码,我们的场景中还需要计算面数据(即GIS中的POLYGON多边形对象)对应的GeoHash编码,需要扩展算法来实现。

算法思路是,先找到目标Polygon的最小外接矩形MBR,计算此MBR西南角坐标对应的GeoHash编码。然后用GeoHash编码的逆算法,反解出此编码对应的矩形GeoHash块。以此GeoHash块为起点,循环往东、往北找相邻的同等大小的GeoHash块,直到找到的GeoHash块完全超出MBR的范围才停止。如此找到的多个GeoHash块,边缘上的部分可能与目标Polygon完全不相交,这部分块需要通过计算剔除掉,如此一来可以减少后续不必要的计算量。

上面的例子中最终得到的结果高清大图如下,其中蓝色的GeoHash块是与原始Polygon部分相交的,橘黄色的GeoHash块是完全被包含在原始Polygon内部的。

上述算法总结成流程图如下:

求临近GeoHash块的快速算法

上一节对面数据进行GeoHash编码的流程图中标记为绿色和橘黄色的两步,分别是要寻找相邻的东边或北边的GeoHash字符串。 传统的做法是,根据当前GeoHash块的反解信息,求出相邻块内部的一点,在对这个点做GeoHash编码,即为相邻块的GeoHash编码。如下图,我们要计算”wtmk72”周围的8个相邻块的编码,就要先利用GeoHash逆算法将”wtmk72”反解出4个顶点的坐标N1、N2、N3、N4,然后由这4个坐标计算出右侧邻接块内部的任意一点坐标N5,再对N5做GeoHash编码,得到的“wtmk78”就是我们要求的右边邻接块的编码。按照同样的方法,求可以求出”wtmk72”周围总共8个邻接块的编码。

这种方法需要先解码一次再编码一次,比较耗时,尤其是在指定的GeoHash字符串长度较长需要循环较多次的情况下。

通过观察GeoHash编码表的规律,结合GeoHash编码使用的Z阶曲线的特性,验证了一种通过查表来快速求相邻GeoHash字符串的方法。

还是以“wtmk72”这个GeoHash字符串为例,对应的10进制数是“28,25,19,18,7,2”,转换成二进制就是11100 11001 10011 10010 00111 00010。其中,w对应11100,这5个二进制位分别代表“经 纬 经 纬 经”;t对应11001,这5个二进制位分别代表“纬 经 纬 经 纬”。由此推广开来可知,GeoHash中的奇数位字符(本例中的’w’、’m’、’7’)代表的二进制位分别对应“经 纬 经 纬 经”,偶数位字符(本例中的’t’、’k’、’2’)代表的二进制位分别对应“纬
经 纬 经 纬”。 ‘w’的二进制11100,转换成方位含义就是“右 上 右 下 左”。’t’的二进制11001,转换成方位含义就是“上 右 下 左 上”。 根据这个字符与方位的转换关系,我们可以知道,奇数位上的字符与位置对照表如下:

偶数位上的字符与位置对照表如下:

这里可以看到一个很有意思的现象,奇数位的对照表和偶数位对照表存在一种转置和翻转的关系。

有了以上两份字符与位置对照表,就可以快速得出每个字符周围的8个字符分别是什么。而要计算一个给定GeoHash字符串周围8个GeoHash值,如果字符串最后一位字符在该方向上未超出边界,则前面几位保持不变,最后一位取此方向上的相邻字符即可;如果最后一位在此方向上超出了对照表边界,则先求倒数第二个字符在此方向上的相邻字符,再求最后一个字符在此方向上相邻字符(对照表环状相邻字符);如果倒数第二位在此方向上的相邻字符也超出了对照表边界,则先求倒数第三位在此方向上的相邻字符。以此类推。

以上面的“wtmk72”举例,要求这个GeoHash字符串的8个相邻字符串,实际就是求尾部字符‘2’的相邻字符。‘2’适用偶数对照表,它的8个相邻字符分别是‘1’、‘3’、‘9’、‘0’、‘8’、‘p’、‘r’、‘x’,其中‘p’、‘r’、‘x’已经超出了对照表的下边界,是将偶数位对照表上下相接组成环状得到的相邻关系。所以,对于这3个超出边界的“下方”相邻字符,需要求倒数第二位的下方相邻字符,即‘7’的下方相邻字符。‘7’是奇数位,适用奇数位对照表,‘7’在对照表中的“下方”相邻字符是‘5’,所以“wtmk72”的8个相邻GeoHash字符串分别是“wtmk71”、“wtmk73”、“wtmk79”、“wtmk70”、“wtmk78”、“wtmk5p”、“wtmk5r”、“wtmk5x”。
利用此相邻字符串快速算法,可以大大提高上一节流程图中面数据GeoHash编码算法的效率。

高效建立海量点数据与面数据的关系

建立海量点数据与面数据的关系的思路是,先将需要匹配的商品GPS数据(点数据)、商圈AOI数据(面数据)按照前面所述的算法,分别计算同等长度的GeoHash编码。每个点数据都对应唯一一个GeoHash字符串;每个面数据都对应一个或多个GeoHash编码,这些编码要么是“完全包含字符串”,要么是“部分包含字符串”。

a)将每个商品的GeoHash字符串与商圈的“完全包含字符串”进行join操作。join得到的结果中出现的<商品,商圈>数据就是能够确定的“某个商品属于某个商圈”的关系。 b)对于剩下的还未被确定关系的商品,将这些商品的GeoHash字符串与商圈的“部分包含字符串”进行join操作。join得到的结果中出现的<商品,商圈>数据是有可能存在的“商品属于某个商圈”的关系,接下来对这批数据中的商品gps和商圈AOI数据进行几何学关系运算,进而从中筛选出确定的“商品属于某个商圈”的关系。

如图,商品1的点数据GeoHash编码为”wtmk70j”,与面数据的“完全包含字符串wtmk70j”join成功,所以可以直接确定商品1属于此面数据。 商品2的点数据GeoHash编码为“wtmk70r”,与面数据的“部分包含字符串wtmk70r”join成功,所以商品2疑似属于面数据,具体是否存在包含关系,还需要后续的点面几何学计算来确定。 商品3的点数据GeoHash编码与面数据的任何GeoHash块编码都匹配不上,所以可以快速确定商品3不属于此面数据。

实际应用中,原始的海量商品GPS范围散布在全国各地,海量商圈数据也散布在全国各个不同的城市。经过a)步骤的操作后,大部分的商品数据已经确定了与商圈的从属关系。剩下的未能匹配上的商品数据,经过b)步骤的GeoHash匹配后,可以将后续“商品-商圈几何学计算”的运算量从“1个商品 x 全国所有商圈”笛卡尔积的量级,降低为“1个商品 x 1个(或几个)商圈”笛卡尔积的量级,减少了绝大部分不必要的几何学运算,而这部分运算是非常耗时的。

在闲鱼的实际应用中,10亿商品和1万商圈数据,使用本文的快速算法,只需要 10亿次GeoHash点编码 + 1万次GeoHash面编码 + 500万次“点是否在面内部”几何学运算,粗略换算为基本运算需要的次数约为1800亿次,运算量远小于传统方法的2亿亿次基本运算。使用阿里巴巴的离线计算平台,本文的算法在不到一天的时间内就完成了全部计算工作。

另外,对于给定的点和多边形,通过几何学计算包含关系的算法不止一种,最常用的算法是射线法。简单来说,就是从这个点出发做一条射线,判断该射线与多边形的交点个数是奇数还是偶数。如果是奇数,说明点在多边形内;否则,点在多边形外。

延伸

面对海量点面数据的空间关系划分,本文采用是的通过GeoHash来降低计算量的思路,本质上来说是利用了空间索引的思想。事实上,在GIS领域有多种实用的空间索引,常见的如R树系列(R树、R+树、R*树)、四叉树、K-D树、网格索引等,这些索引算法各有特点。本文的思路不仅能用来处理点-面关系的相关问题,还可以用来快速处理点-点关系、面-面关系、点-线关系、线-线关系等问题,比如快速确定大范围类的海量公交站台与道路的从属关系、多条道路或铁路是否存在交点等问题。 欢迎大家和我们交流讨论相关的算法优化,也欢迎各路高手加入阿里巴巴-闲鱼团队,和我们一起用技术改变世界。简历投递:guicai.gxy@alibaba-inc.com

扫码关注【闲鱼技术】公众号

参考资料

[1]https://en.wikipedia.org/wiki/Geohash [2]https://en.wikipedia.org/wiki/Pointinpolygon [3]https://www.geeksforgeeks.org/how-to-check-if-a-given-point-lies-inside-a-polygon [4]https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html
[5]http://blog.notdot.net/2009/11/Damn-Cool-Algorithms-Spatial-indexing-with-Quadtrees-and-Hilbert-Curves

本文转载自: 掘金

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

Java Servet 总结

发表于 2018-07-07

Servlet 是 Java Web 开发的起点,几乎所有的 Java Web 框架都是基于 Servlet 的封装,其中最主要的就是 Servlet 和 Filter 接口。我重新学习了一遍 Servlet,对 Java Web 开发有了更深的理解。

1. Servlet 是什么

从 API 可以看出,Servlet 本质是一套接口(Interface)。那么接口的本质是什么?是规范、是协议,所以我们常说要面向接口编程,而不是面向实现。接口是连接 Servlet 和 Servlet 容器(Tomcat、Jetty 等)的关键。

Servlet 接口定义了一套处理网络请求的规范,所有实现 Servlet 的类都需要实现它的五个方法,其中最主要的是两个生命周期方法 init 和 destroy,还有一个处理请求的 service 方法。也就是说,所有实现 Servlet 接口的类,或者说,所有想要处理网络请求的类,都需要回答这三个问题:

  • 你初始化时要做什么
  • 你销毁时要做什么
  • 你接收到请求时要做什么

这是 Java EE 给的一种规范!就像阿西莫夫的机器人三大定律一样,是规范!

看一下 Servlet 的接口定义,即 Servlet 和 Servlet 容器的规范。我们最关心的就是 service 方法,在这里处理请求。

1
2
3
4
5
6
7
8
9
10
11
复制代码public interface Servlet {
void init(ServletConfig var1) throws ServletException;

ServletConfig getServletConfig();

void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException;

String getServletInfo();

void destroy();
}

2. Servlet 如何工作

Servlet 想要工作离不开 Servlet 容器,比如我们最常用的 Tomcat。它监听了某个端口,http 请求过来后,容器根据 url 等信息,确定要将请求交给哪个 Servlet 去处理,然后调用 Servlet 的 service 方法,service 方法返回一个 response 对象,容器将 respose 对象解析之后封装成一个 http 响应返回客户端。

Servlet 工作原理

3. Servlet 体系结构

Servlet 规范类有这么几个:

  • Servlet
  • ServletContext
  • ServletConfig
  • ServletRequest
  • ServletResponse

Servlet 运行模式是典型的「握手型」交互。举个例子:

买早点的场景。找到一家早点铺(SerletContext 开始),看到牌面上写着可以加肉松(ServletConfig),就告诉老板我要加肉松的煎饼果子,拿出手机扫码支付了五块钱(ServletRequest)。老板娴熟地给我摊好,然后递给我(ServletResponse),我就美滋滋地离开了(ServletContext 结束)。

引用开源中国红薯的一段话,原文 在这里。

为什么我这么强调 HttpServletRequest 和 HttpServletResponse 这两个接口,因为 Web 开发是离不开 HTTP 协议的,而 Servlet 规范其实就是对 HTTP 协议做面向对象的封装,HTTP协议中的请求和响应就是对应了 HttpServletRequest 和 HttpServletResponse 这两个接口。

你可以通过 HttpServletRequest 来获取所有请求相关的信息,包括 URI、Cookie、Header、请求参数等等,别无它路。因此当你使用某个框架时,你想获取 HTTP 请求的相关信息,只要拿到 HttpServletRequest 实例即可。而 HttpServletResponse接口是用来生产 HTTP 回应,包含 Cookie、Header 以及回应的内容等等。

4. Servlet 实践

我们来写一个简单的 Servlet,在 doGet 方法打印所有请求的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
复制代码public class HelloWorld extends HttpServlet {

@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
System.out.println("init helloworld: " + config);
}

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
System.out.println("********doGet********");
resp.setContentType("text/html;charset=UTF-8");
resp.setCharacterEncoding("UTF-8");

System.out.println("method: " + req.getMethod());
System.out.println("charsetEncoding: " + req.getCharacterEncoding());
System.out.println("contentType: " + req.getContentType());
System.out.println("contentLength: " + req.getContentLength());
System.out.println("requestUrl: " + req.getRequestURL());
System.out.println("servletPath: " + req.getServletPath());
System.out.println("contextPath: " + req.getContextPath());
System.out.println("requestUri: " + req.getRequestURI());
System.out.println("locale: " + req.getLocale());
System.out.println("authType: " + req.getAuthType());
System.out.println("scheme: " + req.getScheme());
System.out.println("protocol: " + req.getProtocol());
System.out.println("serverPort: " + req.getServerPort());
System.out.println("remoteHost: " + req.getRemoteHost());
System.out.println("remoteAddr: " + req.getRemoteAddr());
System.out.println("remoteUser: " + req.getRemoteUser());
System.out.println("requestedSessionId: " + req.getRequestedSessionId());
System.out.println("pathInfo: " + req.getPathInfo());
System.out.println("isSecure: " + req.isSecure());
System.out.println("servletName: " + req.getServerName());
System.out.println("pathTranslated: " + req.getPathTranslated());

System.out.println("++headers++");
Enumeration headerNames = req.getHeaderNames();
while (headerNames.hasMoreElements()) {
String paramName = (String) headerNames.nextElement();
String paramValue = req.getHeader(paramName);
System.out.println("name: " + paramName + ", value: " + paramValue);
}
System.out.println("--headers--");

System.out.println("++parameters++");
Enumeration<String> parameterNames = req.getParameterNames();
while (parameterNames.hasMoreElements()) {
String name = parameterNames.nextElement();
String value = req.getParameter(name);
System.out.println("name: " + name + ", value: " + value);
}
System.out.println("--parameters--");

System.out.println("++attributes++");
Enumeration<String> attributeNames = req.getAttributeNames();
while (attributeNames.hasMoreElements()) {
String name = attributeNames.nextElement();
Object value = req.getAttribute(name);
System.out.println("name: " + name + ", value: " + value);
}
System.out.println("--attributes--");

System.out.println("++cookies++");
Cookie[] cookies = req.getCookies();
for (Cookie cookie : cookies) {
System.out.println("name: " + cookie.getName() + ", value: " + URLDecoder.decode(cookie.getValue(), "utf-8"));
}
System.out.println("--cookies--");

PrintWriter writer = resp.getWriter();
try {
writer.println("<h1>Hello world!</h1>");
writer.flush();
} finally {
writer.close();
}
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
System.out.println("--------doPost--------");
doGet(req, resp);
}

@Override
public void destroy() {
super.destroy();
System.out.println("destroy helloworld");
}
}

在 web.xml 配置上面的 Servlet。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">

<servlet>
<servlet-name>HelloWorld</servlet-name>
<servlet-class>com.richie.servlet.HelloWorld</servlet-class>
</servlet>

<servlet-mapping>
<servlet-name>HelloWorld</servlet-name>
<url-pattern>/helloworld</url-pattern>
</servlet-mapping>

</web-app>

配置好 Tomcat,运行输出,然后使用 postman 发送 post 请求 http://localhost:8080/helloworld,并加上参数username=admin&password=123。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
复制代码--------doPost--------
********doGet********
method: POST
charsetEncoding: null
contentType: application/x-www-form-urlencoded
contentLength: 23
requestUrl: http://localhost:8080/helloworld
servletPath: /helloworld
contextPath:
requestUri: /helloworld
locale: zh_CN
authType: null
scheme: http
protocol: HTTP/1.1
serverPort: 8080
remoteHost: 0:0:0:0:0:0:0:1
remoteAddr: 0:0:0:0:0:0:0:1
remoteUser: null
requestedSessionId: F17A359B0544082FC6A6C5F62E672E8A
pathInfo: null
isSecure: false
servletName: localhost
pathTranslated: null
++headers++
name: host, value: localhost:8080
name: connection, value: keep-alive
name: content-length, value: 23
name: cache-control, value: max-age=0
name: origin, value: http://localhost:8080
name: upgrade-insecure-requests, value: 1
name: content-type, value: application/x-www-form-urlencoded
name: user-agent, value: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
name: accept, value: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
name: referer, value: http://localhost:8080/
name: accept-encoding, value: gzip, deflate, br
name: accept-language, value: zh-CN,zh;q=0.9,en;q=0.8
name: cookie, value: __utmz=111872281.1521468435.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none); Idea-734b2b82=47daaaf7-bc69-41ca-9234-dffa6c217ef8; _ga=GA1.1.2085956305.1521468435; Webstorm-717d1cc9=b6b7f7ea-d8d3-4891-8e20-0dca54d5cbd2; __utmc=111872281; __utma=111872281.2085956305.1521468435.1529898141.1530148517.11; SESSION=12913786-3c46-421d-ac2c-02c9c29ae03d; JSESSIONID=F17A359B0544082FC6A6C5F62E672E8A
--headers--
++parameters++
name: username, value: admin
name: password, value: 123
--parameters--
++attributes++
--attributes--
++cookies++
name: __utmz, value: 111872281.1521468435.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none)
name: Idea-734b2b82, value: 47daaaf7-bc69-41ca-9234-dffa6c217ef8
name: _ga, value: GA1.1.2085956305.1521468435
name: Webstorm-717d1cc9, value: b6b7f7ea-d8d3-4891-8e20-0dca54d5cbd2
name: __utmc, value: 111872281
name: __utma, value: 111872281.2085956305.1521468435.1529898141.1530148517.11
name: SESSION, value: 12913786-3c46-421d-ac2c-02c9c29ae03d
name: JSESSIONID, value: F17A359B0544082FC6A6C5F62E672E8A
--cookies--

可以看出,http 请求的基本信息都能取到,每个方法都有它的含义,具体可以参考 菜鸟教程 上的解释。

5. Filter 过滤器

Filter 和 Servlet 一样重要,它可以实现许多功能,比如敏感词过滤、用户验证等。它也是一个接口,和 Servlet 类似,有 init 和 destroy 方法,最重要的是 doFilter 方法。

Filter 主要用于对用户请求进行预处理,也可以对 HttpServletResponse 进行后处理。使用 Filter 的完整流程:Filter 对用户请求进行预处理,接着将请求交给 Servlet 进行处理并生成响应,最后 Filter 再对服务器响应进行后处理。

1
2
3
4
5
6
7
8
9
复制代码public interface Filter {
default void init(FilterConfig filterConfig) throws ServletException {
}

void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException;

default void destroy() {
}
}

除此之外,规范类还有 FilterChain、FilterConfig,Filter 使用了责任链的设计模式,传递的对象就 FilterChain。

6. Filter 工作原理

当我们写好 Filter,并配置对哪个 web 资源进行拦截后,web 服务器每次在调用 Servlet 的 service 方法之前, 都会先调用一下 filter 的 doFilter 方法。因此,在该方法内编写代码可达到如下目的:

  • 调用目标资源之前,让一段代码执行。
  • 是否调用目标资源(即是否让用户访问 web 资源)。
  • 调用目标资源之后,让一段代码执行。

web 服务器在调用 doFilter 方法时,会传递一个 FilterChain 对象进来,FilterChain 对象是 Filter 接口中最重要的一个对象,它也提供了一个 doFilter 方法,开发人员可以根据需求决定是否调用此方法。

Filter原理

7. Filter 实践

简单实现一个拦截器,打印它的生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public class LogFilter implements Filter {

@Override
public void init(FilterConfig filterConfig) {
System.out.println("init logFilter: " + filterConfig);
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("log doFilter pre");
// 一定要调用 filterChain 的 doFilter 方法,继续传递事件
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("log doFilter after");
}

@Override
public void destroy() {
System.out.println("destroy logFilter");
}
}

然后配置 web.xml,一般把 filter 配置在所有的 servlet 前面,/* 表示匹配所有的请求。

1
2
3
4
5
6
7
8
9
复制代码    <filter>
<filter-name>LogFilter</filter-name>
<filter-class>com.richie.servlet.LogFilter</filter-class>
</filter>

<filter-mapping>
<filter-name>LogFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

运行后输出,我们可以对请求和响应进行预处理和后处理,

1
2
3
复制代码log doFilter pre
Servlet 处理的方法
log doFilter after

这篇文章详细阐释了 Filter 的有关内容,推荐看看 Java 中的 Filter 过滤器。

另外还有 Listener 监听器的内容,后面再写吧。

多啰嗦几句。其实客户端和服务端的通信,本质上是一种 IO 操作。用户输入网址后(略去查询 DNS ),浏览器从某个端口发出数据包,里面有目标地址、请求参数等等(output)。然后经过网络一层层传递,跨越万水千山,到达服务器被 80 端口捕获到了(input),交给 Servlet 容器处理,根据请求信息拿到数据返回给客户端(output)。这是一对多的数据交换,如果请求数据的人多了,服务端的压力其实蛮大的。

更细一点说,客户端和服务端的通信是一种进程间的通信。运行在 A 主机上的 A1 进程和运行在 B 主机上的 B1 进程进行通信,它是基于 Socket 的通信,端口是一个重要的标识。

(全文完)

本文转载自: 掘金

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

前端&后端程序员必备的Linux基础知识

发表于 2018-07-05

这篇文章的最新版:javaguide.cn/cs-basics/o…

刚刚把文章改了名字:《后端程序员必备的Linux基础知识》->《前端&后端程序员必备的Linux基础知识》。😁

我自己总结的Java学习的系统知识点以及面试问题,目前已经开源,会一直完善下去,欢迎建议和指导欢迎Star: github.com/Snailclimb/…

学习Linux之前,我们先来简单的认识一下操作系统。

一 从认识操作系统开始

1.1 操作系统简介

我通过以下四点介绍什么操作系统:

  • 操作系统(Operation System,简称OS)是管理计算机硬件与软件资源的程序,是计算机系统的内核与基石;
  • 操作系统本质上是运行在计算机上的软件程序 ;
  • 为用户提供一个与系统交互的操作界面 ;
  • 操作系统分内核与外壳(我们可以把外壳理解成围绕着内核的应用程序,而内核就是能操作硬件的程序)。

操作系统分内核与外壳

1.2 操作系统简单分类

  1. Windows: 目前最流行的个人桌面操作系统 ,不做多的介绍,大家都清楚。
  2. Unix: 最早的多用户、多任务操作系统 .按照操作系统的分类,属于分时操作系统。Unix 大多被用在服务器、工作站,现在也有用在个人计算机上。它在创建互联网、计算机网络或客户端/服务器模型方面发挥着非常重要的作用。
    Unix
  3. Linux: Linux是一套免费使用和自由传播的类Unix操作系统.Linux存在着许多不同的Linux版本,但它们都使用了 Linux内核 。Linux可安装在各种计算机硬件设备中,比如手机、平板电脑、路由器、视频游戏控制台、台式计算机、大型机和超级计算机。严格来讲,Linux这个词本身只表示Linux内核,但实际上人们已经习惯了用Linux来形容整个基于Linux内核,并且使用GNU 工程各种工具和数据库的操作系统。

Linux

二 初探Linux

2.1 Linux简介

我们上面已经介绍到了Linux,我们这里只强调三点。

  • 类Unix系统: Linux是一种自由、开放源码的类似Unix的操作系统
  • Linux内核: 严格来说,Linux这个词本身只表示Linux内核
  • Linux之父: 一个编程领域的传奇式人物。他是Linux内核的最早作者,随后发起了这个开源项目,担任Linux内核的首要架构师与项目协调者,是当今世界最著名的电脑程序员、黑客之一。他还发起了Git这个开源项目,并为主要的开发者。

Linux

2.2 Linux诞生简介

  • 1991年,芬兰的业余计算机爱好者Linus Torvalds编写了一款类似Minix的系统(基于微内核架构的类Unix操作系统)被ftp管理员命名为Linux 加入到自由软件基金的GNU计划中;
  • Linux以一只可爱的企鹅作为标志,象征着敢作敢为、热爱生活。

2.3 Linux的分类

Linux根据原生程度,分为两种:

  1. 内核版本: Linux不是一个操作系统,严格来讲,Linux只是一个操作系统中的内核。内核是什么?内核建立了计算机软件与硬件之间通讯的平台,内核提供系统服务,比如文件管理、虚拟内存、设备I/O等;
  2. 发行版本: 一些组织或公司在内核版基础上进行二次开发而重新发行的版本。Linux发行版本有很多种(ubuntu和CentOS用的都很多,初学建议选择CentOS),如下图所示:
    Linux发行版本

三 Linux文件系统概览

3.1 Linux文件系统简介

在Linux操作系统中,所有被操作系统管理的资源,例如网络接口卡、磁盘驱动器、打印机、输入输出设备、普通文件或是目录都被看作是一个文件。

也就是说在LINUX系统中有一个重要的概念:一切都是文件。其实这是UNIX哲学的一个体现,而Linux是重写UNIX而来,所以这个概念也就传承了下来。在UNIX系统中,把一切资源都看作是文件,包括硬件设备。UNIX系统把每个硬件都看成是一个文件,通常称为设备文件,这样用户就可以用读写文件的方式实现对硬件的访问。

3.2 文件类型与目录结构

Linux支持5种文件类型 :
文件类型

Linux的目录结构如下:

Linux文件系统的结构层次鲜明,就像一棵倒立的树,最顶层是其根目录:
Linux的目录结构

常见目录说明:

  • /bin: 存放二进制可执行文件(ls,cat,mkdir等),常用命令一般都在这里;
  • /etc: 存放系统管理和配置文件;
  • /home: 存放所有用户文件的根目录,是用户主目录的基点,比如用户user的主目录就是/home/user,可以用~user表示;
  • /usr : 用于存放系统应用程序;
  • /opt: 额外安装的可选应用程序包所放置的位置。一般情况下,我们可以把tomcat等都安装到这里;
  • /proc: 虚拟文件系统目录,是系统内存的映射。可直接访问这个目录来获取系统信息;
  • /root: 超级用户(系统管理员)的主目录(特权阶级^o^);
  • /sbin: 存放二进制可执行文件,只有root才能访问。这里存放的是系统管理员使用的系统级别的管理命令和程序。如ifconfig等;
  • /dev: 用于存放设备文件;
  • /mnt: 系统管理员安装临时文件系统的安装点,系统提供这个目录是让用户临时挂载其他的文件系统;
  • /boot: 存放用于系统引导时使用的各种文件;
  • /lib : 存放着和系统运行相关的库文件 ;
  • /tmp: 用于存放各种临时文件,是公用的临时文件存储点;
  • /var: 用于存放运行时需要改变数据的文件,也是某些大文件的溢出区,比方说各种服务的日志文件(系统启动日志等。)等;
  • /lost+found: 这个目录平时是空的,系统非正常关机而留下“无家可归”的文件(windows下叫什么.chk)就在这里。

四 Linux基本命令

下面只是给出了一些比较常用的命令。推荐一个Linux命令快查网站,非常不错,大家如果遗忘某些命令或者对某些命令不理解都可以在这里得到解决。

Linux命令大全:man.linuxde.net/

4.1 目录切换命令

  • cd usr: 切换到该目录下usr目录
  • cd ..(或cd../): 切换到上一层目录
  • cd /: 切换到系统根目录
  • cd ~: 切换到用户主目录
  • cd -: 切换到上一个所在目录

4.2 目录的操作命令(增删改查)

  1. mkdir 目录名称: 增加目录
  2. ls或者ll(ll是ls -l的缩写,ll命令以看到该目录下的所有目录和文件的详细信息):查看目录信息
  3. find 目录 参数: 寻找目录(查)

示例:

* 列出当前目录及子目录下所有文件和文件夹: `find .`
* 在`/home`目录下查找以.txt结尾的文件名:`find /home -name "*.txt"`
* 同上,但忽略大小写: `find /home -iname "*.txt"`
* 当前目录及子目录下查找所有以.txt和.pdf结尾的文件:`find . \( -name "*.txt" -o -name "*.pdf" \)`或`find . -name "*.txt" -o -name "*.pdf"`
  1. mv 目录名称 新目录名称: 修改目录的名称(改)

注意:mv的语法不仅可以对目录进行重命名而且也可以对各种文件,压缩包等进行 重命名的操作。mv命令用来对文件或目录重新命名,或者将文件从一个目录移到另一个目录中。后面会介绍到mv命令的另一个用法。
5. mv 目录名称 目录的新位置: 移动目录的位置—剪切(改)

注意:mv语法不仅可以对目录进行剪切操作,对文件和压缩包等都可执行剪切操作。另外mv与cp的结果不同,mv好像文件“搬家”,文件个数并未增加。而cp对文件进行复制,文件个数增加了。
6. cp -r 目录名称 目录拷贝的目标位置: 拷贝目录(改),-r代表递归拷贝

注意:cp命令不仅可以拷贝目录还可以拷贝文件,压缩包等,拷贝文件和压缩包时不 用写-r递归
7. rm [-rf] 目录: 删除目录(删)

注意:rm不仅可以删除目录,也可以删除其他文件或压缩包,为了增强大家的记忆, 无论删除任何目录或文件,都直接使用rm -rf 目录/文件/压缩包

4.3 文件的操作命令(增删改查)

  1. touch 文件名称: 文件的创建(增)
  2. cat/more/less/tail 文件名称 文件的查看(查)
* **`cat`:** 只能显示最后一屏内容
* **`more`:** 可以显示百分比,回车可以向下一行, 空格可以向下一页,q可以退出查看
* **`less`:** 可以使用键盘上的PgUp和PgDn向上 和向下翻页,q结束查看
* **`tail-10` :** 查看文件的后10行,Ctrl+C结束注意:命令 tail -f 文件 可以对某个文件进行动态监控,例如tomcat的日志文件, 会随着程序的运行,日志会变化,可以使用tail -f catalina-2016-11-11.log 监控 文 件的变化
  1. vim 文件: 修改文件的内容(改)

vim编辑器是Linux中的强大组件,是vi编辑器的加强版,vim编辑器的命令和快捷方式有很多,但此处不一一阐述,大家也无需研究的很透彻,使用vim编辑修改文件的方式基本会使用就可以了。

在实际开发中,使用vim编辑器主要作用就是修改配置文件,下面是一般步骤:

vim 文件——>进入文件—–>命令模式——>按i进入编辑模式—–>编辑文件 ——->按Esc进入底行模式—–>输入:wq/q! (输入wq代表写入内容并退出,即保存;输入q!代表强制退出不保存。)
4. rm -rf 文件: 删除文件(删)

同目录删除:熟记 rm -rf 文件 即可

4.4 压缩文件的操作命令

1)打包并压缩文件:

Linux中的打包文件一般是以.tar结尾的,压缩的命令一般是以.gz结尾的。

而一般情况下打包和压缩是一起进行的,打包并压缩后的文件的后缀名一般.tar.gz。
命令:tar -zcvf 打包压缩后的文件名 要打包压缩的文件
其中:

z:调用gzip压缩命令进行压缩

c:打包文件

v:显示运行过程

f:指定文件名

比如:加入test目录下有三个文件分别是 :aaa.txt bbb.txt ccc.txt,如果我们要打包test目录并指定压缩后的压缩包名称为test.tar.gz可以使用命令:tar -zcvf test.tar.gz aaa.txt bbb.txt ccc.txt或:tar -zcvf test.tar.gz /test/

2)解压压缩包:

命令:tar [-xvf] 压缩文件

其中:x:代表解压

示例:

1 将/test下的test.tar.gz解压到当前目录下可以使用命令:tar -xvf test.tar.gz

2 将/test下的test.tar.gz解压到根目录/usr下:tar -xvf xxx.tar.gz -C /usr(- C代表指定解压的位置)

4.5 Linux的权限命令

操作系统中每个文件都拥有特定的权限、所属用户和所属组。权限是操作系统用来限制资源访问的机制,在Linux中权限一般分为读(readable)、写(writable)和执行(excutable),分为三组。分别对应文件的属主(owner),属组(group)和其他用户(other),通过这样的机制来限制哪些用户、哪些组可以对特定的文件进行什么样的操作。通过 ls -l 命令我们可以 查看某个目录下的文件或目录的权限

示例:在随意某个目录下ls -l

第一列的内容的信息解释如下:

下面将详细讲解文件的类型、Linux中权限以及文件有所有者、所在组、其它组具体是什么?

文件的类型:

  • d: 代表目录
  • -: 代表文件
  • l: 代表链接(可以认为是window中的快捷方式)

Linux中权限分为以下几种:

  • r:代表权限是可读,r也可以用数字4表示
  • w:代表权限是可写,w也可以用数字2表示
  • x:代表权限是可执行,x也可以用数字1表示

文件和目录权限的区别:

对文件和目录而言,读写执行表示不同的意义。

对于文件:

权限名称 可执行操作
r 可以使用cat查看文件的内容
w 可以修改文件的内容
x 可以将其运行为二进制文件

对于目录:

权限名称 可执行操作
r 可以查看目录下列表
w 可以创建和删除目录下文件
x 可以使用cd进入目录

在linux中的每个用户必须属于一个组,不能独立于组外。在linux中每个文件有所有者、所在组、其它组的概念。

  • 所有者

一般为文件的创建者,谁创建了该文件,就天然的成为该文件的所有者,用ls ‐ahl命令可以看到文件的所有者 也可以使用chown 用户名 文件名来修改文件的所有者 。

  • 文件所在组

当某个用户创建了一个文件后,这个文件的所在组就是该用户所在的组 用ls ‐ahl命令可以看到文件的所有组 也可以使用chgrp 组名 文件名来修改文件所在的组。

  • 其它组

除开文件的所有者和所在组的用户外,系统的其它用户都是文件的其它组

我们再来看看如何修改文件/目录的权限。

修改文件/目录的权限的命令:chmod

示例:修改/test下的aaa.txt的权限为属主有全部权限,属主所在的组有读写权限,
其他用户只有读的权限

chmod u=rwx,g=rw,o=r aaa.txt

上述示例还可以使用数字表示:

chmod 764 aaa.txt

补充一个比较常用的东西:

假如我们装了一个zookeeper,我们每次开机到要求其自动启动该怎么办?

  1. 新建一个脚本zookeeper
  2. 为新建的脚本zookeeper添加可执行权限,命令是:chmod +x zookeeper
  3. 把zookeeper这个脚本添加到开机启动项里面,命令是: chkconfig --add zookeeper
  4. 如果想看看是否添加成功,命令是:chkconfig --list

4.6 Linux 用户管理

Linux系统是一个多用户多任务的分时操作系统,任何一个要使用系统资源的用户,都必须首先向系统管理员申请一个账号,然后以这个账号的身份进入系统。

用户的账号一方面可以帮助系统管理员对使用系统的用户进行跟踪,并控制他们对系统资源的访问;另一方面也可以帮助用户组织文件,并为用户提供安全性保护。

Linux用户管理相关命令:

  • useradd 选项 用户名:添加用户账号
  • userdel 选项 用户名:删除用户帐号
  • usermod 选项 用户名:修改帐号
  • passwd 用户名:更改或创建用户的密码
  • passwd -S 用户名 :显示用户账号密码信息
  • passwd -d 用户名: 清除用户密码

useradd命令用于Linux中创建的新的系统用户。useradd可用来建立用户帐号。帐号建好之后,再用passwd设定帐号的密码.而可用userdel删除帐号。使用useradd指令所建立的帐号,实际上是保存在/etc/passwd文本文件中。

passwd命令用于设置用户的认证信息,包括用户密码、密码过期时间等。系统管理者则能用它管理系统用户的密码。只有管理者可以指定用户名称,一般用户只能变更自己的密码。

4.7 Linux系统用户组的管理

每个用户都有一个用户组,系统可以对一个用户组中的所有用户进行集中管理。不同Linux 系统对用户组的规定有所不同,如Linux下的用户属于与它同名的用户组,这个用户组在创建用户时同时创建。

用户组的管理涉及用户组的添加、删除和修改。组的增加、删除和修改实际上就是对/etc/group文件的更新。

Linux系统用户组的管理相关命令:

  • groupadd 选项 用户组 :增加一个新的用户组
  • groupdel 用户组:要删除一个已有的用户组
  • groupmod 选项 用户组 : 修改用户组的属性

4.8 其他常用命令

  • pwd: 显示当前所在位置
  • grep 要搜索的字符串 要搜索的文件 --color: 搜索命令,–color代表高亮显示
  • ps -ef/ps aux: 这两个命令都是查看当前系统正在运行进程,两者的区别是展示格式不同。如果想要查看特定的进程可以使用这样的格式:ps aux|grep redis (查看包括redis字符串的进程)

注意:如果直接用ps((Process Status))命令,会显示所有进程的状态,通常结合grep命令查看某进程的状态。

  • kill -9 进程的pid: 杀死进程(-9 表示强制终止。)

先用ps查找进程,然后用kill杀掉

  • 网络通信命令:
+ 查看当前系统的网卡信息:ifconfig
+ 查看与某台机器的连接情况:ping
+ 查看当前系统的端口使用:netstat -an
  • shutdown: shutdown -h now: 指定现在立即关机;shutdown +5 "System will shutdown after 5 minutes":指定5分钟后关机,同时送出警告信息给登入用户。
  • reboot: reboot: 重开机。reboot -w: 做个重开机的模拟(只有纪录并不会真的重开机)。

如果你觉得我的文章对你有帮助话,欢迎关注我的微信公众号:”Java面试通关手册“(一个有温度的微信公众号,无广告,单纯技术分享,期待与你共同进步~坚持原创,分享美文,分享各种Java学习资源。)

本文转载自: 掘金

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

GO千万级消息推送服务 技术核心难点 解决技术难点 架构考量

发表于 2018-06-29

公司此前有一个简单的文章订阅业务,但是采用的是定时拉取的模式,周期比较长,时效性不佳。

于是考虑做一个长连接服务,主动把新产生的文章推送下去。

因为是web场景,所以优先考虑成熟的websocket协议,很多编程语言都有成熟的服务端开发框架。

技术核心难点

系统调用的瓶颈

假设有100万人在线,那么1篇文章会导致100万次推送,10篇文章就是1000万次推送。

根据经验值,linux系统在处理TCP网络系统调用的时候,大概每秒只能处理100万左右个包。

这么看的话,推送1篇文章就已经到达了单机的处理能力极限,这是第一个难点。

锁瓶颈

我们在推送时,需要遍历所有的在线连接,通常这些连接被放在一个集合里。

遍历100万个连接去发送消息,肯定需要花费一个可观的时间,而在推送期间客户端仍旧在不停的上线与下线,所以这个集合是需要上锁做并发保护的。

可见,遍历期间上锁的时间会非常长,而且只能有一个线程顺序遍历集合,这个耗时是无法接受的。

CPU瓶颈

一般客户端与服务端之间基于JSON协议通讯,给每个客户端推送消息前需要对消息做json encode编码。

当在线连接比较少(比如1万)而推送消息比较频繁(每秒10万条)的情况下,我们可以计算得到每秒要json encode编码的次数是:10000 * 100000 = 10^9次。

即便我们提前对10万条消息做json encode后,再向1万个连接做分发,那么每秒也需要10万次的编码。

JSON编码是一个纯CPU计算行为,非常耗费CPU,我们仍旧面临不小的优化压力。

解决技术难点

系统调用瓶颈

仍旧假设100万人在线,那么单机极限就是每秒推送1篇文章,这会带来每秒100万次的网络系统调用。

如果我们想推送100篇文章,仍旧使用单机处理,优化的思路是什么呢?

很简单,我们把100篇文章作为一条消息推送,那么仍旧是每秒100万次系统调用。

无论是10篇,50篇,80篇,我们都合并成1条消息推送,那么100万人在线的推送频次就是恒定的每秒100万次,不随着文章数量的变化而变化。

当然,合并消息不可能无限大,当超过一定的阀值之后,TCP/IP层会进行大包拆分,此时底层实际包频就会超过每秒100万次,再次到达系统调用的极限。

锁瓶颈

在做海量服务架构设计的时候,一个很有用的思路就是:大拆小。

既然100万连接放在一个集合里导致锁粒度太大,那么我们就可以把连接通过哈希的方式散列到多个集合中,每个集合有自己的锁。

当我们推送的时候,可以通过多个线程,分别负责若干个集合的推送任务。

因为推送的集合不同,所以线程之间没有锁竞争关系。而对于同一个集合并发推送多条不同的消息,我们可以把互斥锁换成读写锁,从而支持多线程并发遍历同一个集合发送不同的消息。

其实操作系统管理CPU也是分时的,就像我们的推送任务被拆分成若干小集合一样,每个集合只需要占用一点点的时间片快速完成,而多个集合则尽可能的利用多核的优势实现真并行。

CPU瓶颈

其实当我们通过消息合并的方式减少网络系统调用的时候,我们已经完成了对sys cpu的优化,操作系统用来处理网络系统调用的CPU时间大幅减少。

但是user CPU需要我们继续做优化,我们如果在每个连接级别做json encode,那么1篇文章就会带来100万次encode,是完全无法接受的性能。

因为业务上消息推送分2类,一种是按客户端关注的主题做推送,一种是推送给所有客户端。

基于上述特点,我们可以把消息合并动作提前到消息入口层,即把近一段时间所有要推往某个主题、推给所有在线的消息做消息合并成batch,每个batch可能包含100条消息。当1个batch塞满后或者超时后,经过对其进行一次json encode编码后,即可直接向目标客户端做遍历分发。

经过消息合并前置,编码的CPU消耗不再与在线的连接数有关,也不再直接与要推送的消息条数有关,而是与打包后的batch个数有关,具有量级上的锐减效果。

架构考量

集群化gateway

经过上述的设计后,我们可以用GO来实现一个高并发的websocket长连接网关(gateway)。

gateway可以横向部署构成集群,前端采用LVS/HA/DNS负载均衡。

当我们采用gateway集群化部署之后,当我们想要推送一条消息的时候,需要将消息分发给所有的gateway进程。

逻辑服务logic

因此,我实现了一个Logic服务,它本身是无状态的,负责2个核心功能:

1,为业务提供了HTTP接口提交推送消息,因为作为推送系统的推送频次不会太高。而且业务方在推送前会有很多业务逻辑判定,最终通过HTTP完成推送,相信是一个比较易于接入的方式。

2,负责将推送消息向各gateway进程做分发,在这里采用了HTTP/2作为RPC协议(GRPC就是HTTP/2)保障了单连接的高并发能力,同时保障了不同gateway之间的故障隔离,互不影响。

认证服务

目前尚未引入websocket连接的登录认证,今后存在向特定用户推送的需求时,需要实现认证服务。

认证服务独立于gateway与logic,可以称作Passport。

客户端首先基于公司帐号体系向passport完成登录,得到一个自验证的Login token(例如JWT),然后再发起gateway连接。

gateway验证token后完成uid的识别,整个过程不需要与其他业务系统额外交互,当然也可以增加额外的调用服务验证。

那么当logic希望向特定uid推送消息的时候,当前架构下仍旧必须将消息分发给所有gateway,由gateway找到uid对应的连接。但是这无疑造成了浪费,因为uid可能只连在某一个gateway上,对其他gateway毫无意义。

session会话层

未来可以考虑增加会话层,记录uid与gateway之间的连接关系,这样logic经过session层反查找到uid所在的gateway,完成定向推送即可。

会话层可以做一层单独的服务,采用纯内存的方式保存uid与gateway的关系。

因为gateway宕机等原因,可能导致我们无法及时剔除掉线的会话,所以gateway与session之间应该定时传输健康客户端的心跳信息。

当然,也可以简单粗暴的将会话层用redis集群取代,仅仅提供单一的uid->gateway的反查能力。

源代码

项目代码我开源到了github,代码量非常少,所以感兴趣的话不如读一下源码喽。

go-push

本文转载自: 掘金

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

Java多线程与高并发(一) 并发基础与模拟工具 基本概念

发表于 2018-06-27

面试官:你知道Java的内存模型是什么吗?

基本概念

并发、并行

引用知乎上一个最高赞:

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。

你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。

你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。

并发:系统能处理多个任务,但同时只能处理一个的任务处理机制

并行:系统能处理多个任务,且同时还能处理多个的任务处理机制

高并发:系统能同时并行处理很多请求的任务处理机制

并发基础

这个PDF讲解的是Java线程基础,如果比较熟悉,可以跳过

Java内存模型

主内存:所有变量都保存在主内存中
工作内存:每个线程的独立内存,保存了该线程使用到的变量的主内存副本拷贝,线程对变量的操作必须在工作内存中进行。

每个线程都有自己的本地内存共享副本,如果A线程要更新主内存还要让B线程获取更新后的变量,那么需要:

  1. 将本地内存A中更新共享变量
  2. 将更新的共享变量刷新到主内存中
  3. 线程B从主内存更新最新的共享变量

如果A、B线程同时处理某共享变量,会导致重复计数或者数据冲突。

乱序执行优化

在硬件架构上,处理器为提高运算速度而做的违背代码顺序的优化。执行的方式是将多条指令不按程序顺序发送给不同的多个电路单元,达到更大的CPU利用率。

在单CPU上不会出现问题,在多CPU访问同一块内存的时候可能会出现访问顺序的问题,如下。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
复制代码public class PossibleReordering {
private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(() -> {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
shortWait(50000);
a = 1;
x = b;
});

Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}


public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}

很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。

然而,这段代码的执行结果也可能是(0,0)。代码指令可能并不是严格按照代码语句顺序执行的。a=1和x=b这两个语句的赋值操作的顺序可能被颠倒,或者说,发生了指令“重排序”(reordering)。(事实上,输出了这一结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出)

除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。

内存屏障

那么解决以上问题的方式就是加上内存屏障,就是把所有共享的变量设置为volatile。

内存屏障能禁止重排序的时候将后面的指令排到前面去,且保证变量的可见性。强烈建议读者自己操作一遍加深理解。

并发的优缺点

优点:

  1. 速度:同时处理多个请求响应更快
  2. 设计:程序设计有更多的选择,可能更简单,比如对文件集的读取与处理,单线程需要写个循环去做,多线程可以只写一个文件的操作但用到并发去限制,同时也提高了CPU利用率。
  3. 资源利用:如2

缺点:

  1. 安全性:多个线程共享变量会存在问题
  2. 活跃性:死锁
  3. 性能:多线程导致CPU切换开销太大、消耗过多内存

并发模拟工具

现在我们需要准备一个并发模拟工具,方便测试将来的代码是否线程安全

JMeter、PostMan

待补充

代码模拟

我们将使用JUC的工具类来完成代码模拟并发的场景

CountDownLatch

计数器闭锁是一个能阻塞主线程,让其他线程满足特定条件下再继续执行的工具。比如倒计时5000,每当一个线程完成一次操作就让它执行countDown一次,直到count为0之后输出结果,这样就保证了其他线程一定是满足了特定条件(执行某操作5000次),模拟了并发执行次数。

Semaphore

信号量是一个能阻塞线程且能控制统一时间请求的并发量的工具。比如能保证同时执行的线程最多200个,模拟出稳定的并发量。深入了解请参看第四篇文章。

模拟工具

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
复制代码public class ConcurrencyTest {
private static final int THREAD_COUNT = 5000;
private static final int CONCURRENT_COUNT = 200;
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newCachedThreadPool();
Semaphore semaphore = new Semaphore(CONCURRENT_COUNT);
CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
add();
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(count);
}

private static void add(){
count++;
}
}

执行结果可能是5000可能小于5000。从而证明add方法的写法是线程不安全的写法。

参考

coding.imooc.com/class/195.h…
以及其他超连接引用

号外号外

最近在总结一些针对Java面试相关的知识点,感兴趣的朋友可以一起维护~
地址:github.com/xbox1994/20…

本文转载自: 掘金

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

【译】 在 SnackBar,Navigation 和其他事

发表于 2018-06-21
  • 原文地址:LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)
  • 原文作者:Jose Alcérreca
  • 译文出自:掘金翻译计划
  • 本文永久链接:github.com/xitu/gold-m…
  • 译者:wzasd
  • 校对者:LeeSniper

视图层(Activity 或者 Fragment)与 ViewModel 层进行通讯的一种便捷的方式就是使用 LiveData 来进行观察。这个视图层订阅 Livedata 的数据变化并对其变化做出反应。这适用于连续不断显示在屏幕的数据。

但是,有一些数据只会消费一次,就像是 Snackbar 消息,导航事件或者对话框。

这应该被视为设计问题,而不是试图通过架构组件的库或者扩展来解决这个问题。我们建议您将您的事件视为您的状态的一部分。在本文中,我们将展示一些常见的错误方法,以及推荐的方式。

❌ 错误:1. 使用 LiveData 来解决事件

这种方法来直接的在 LiveData 对象的内部持有 Snackbar 消息或者导航信息。尽管原则上看起来像是普通的 LiveData 对象可以用在这里,但是会出现一些问题。

在一个主/从应用程序中,这里是主 ViewModel:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// 不要使用这个事件
class ListViewModel : ViewModel {
private val _navigateToDetails = MutableLiveData<Boolean>()

val navigateToDetails : LiveData<Boolean>
get() = _navigateToDetails


fun userClicksOnButton() {
_navigateToDetails.value = true
}
}

在视图层(Activity 或者 Fragment):

1
2
3
复制代码myViewModel.navigateToDetails.observe(this, Observer {
if (it) startActivity(DetailsActivity...)
})

这种方法的问题是 _navigateToDetails 中的值会长时间保持为真,并且无法返回到第一个屏幕。一步一步进行分析:

  1. 用户点击按钮 Details Activity 启动。
  2. 用户用户按下返回,回到主 Activity。
  3. 观察者在 Activity 处于回退栈时从非监听状态再次变成监听状态。
  4. 但是该值仍然为 “真”,因此 Detail Activity 启动出错。

解决方法是从 ViewModel 中将导航的标志点击后立刻设为 false;

1
2
3
4
复制代码fun userClicksOnButton() {
_navigateToDetails.value = true
_navigateToDetails.value = false // Don't do this
}

但是,需要记住的一件很重要的事就是 LiveData 储存这个值,但是不保证发出它接受到的每个值。例如:当没有观察者处于监听状态时,可以设置一个值,因此新的值将会替换它。此外,从不同线程设置值的时候可能会导致资源竞争,只会向观察者发出一次改变信号。

但是这种方法的主要问题是难以理解和不简洁。在导航事件发生后,我们如何确保值被重置呢?

❌ 可能更好一些:2. 使用 LiveData 进行事件处理,在观察者中重置事件的初始值

通过这种方法,您可以添加一种方法来从视图中支出您已经处理了该事件,并且重置该事件。

用法

对我们的观察者进行一些小改动,我们就有了这样的解决方案:

1
2
3
4
5
6
复制代码listViewModel.navigateToDetails.observe(this, Observer {
if (it) {
myViewModel.navigateToDetailsHandled()
startActivity(DetailsActivity...)
}
})

像下面这样在 ViewModel 中添加新的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码class ListViewModel : ViewModel {
private val _navigateToDetails = MutableLiveData<Boolean>()

val navigateToDetails : LiveData<Boolean>
get() = _navigateToDetails


fun userClicksOnButton() {
_navigateToDetails.value = true
}

fun navigateToDetailsHandled() {
_navigateToDetails.value = false
}
}

问题

这种方法的问题是有一些死板(每个事件在 ViewModel 中有一个新的方法),并且很容易出错,观察者很容易忘记调用这个 ViewModel 的方法。

✔️ 正确解决方法: 使用 SingleLiveEvent

这个 SingleLiveEvent 类是为了适用于特定场景的解决方法。这是一个只会发送一次更新的 LiveData。

用法

1
2
3
4
5
6
7
8
9
10
11
复制代码class ListViewModel : ViewModel {
private val _navigateToDetails = SingleLiveEvent<Any>()

val navigateToDetails : LiveData<Any>
get() = _navigateToDetails


fun userClicksOnButton() {
_navigateToDetails.call()
}
}
1
2
3
复制代码myViewModel.navigateToDetails.observe(this, Observer {
startActivity(DetailsActivity...)
})

问题

SingleLiveEvent 的问题在于它仅限于一个观察者。如果您无意中添加了多个,则只会调用一个,并且不能保证哪一个。

✔️ 推荐: 使用事件包装器

在这种方法中,您可以明确地管理事件是否已经被处理,从而减少错误。

用法

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
复制代码/**
* Used as a wrapper for data that is exposed via a LiveData that represents an event.
*/
open class Event<out T>(private val content: T) {

var hasBeenHandled = false
private set // Allow external read but not write

/**
* Returns the content and prevents its use again.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}

/**
* Returns the content, even if it's already been handled.
*/
fun peekContent(): T = content
}
1
2
3
4
5
6
7
8
9
10
11
复制代码class ListViewModel : ViewModel {
private val _navigateToDetails = MutableLiveData<Event<String>>()

val navigateToDetails : LiveData<Event<String>>
get() = _navigateToDetails


fun userClicksOnButton(itemId: String) {
_navigateToDetails.value = Event(itemId) // Trigger the event by setting a new Event as a new value
}
}
1
2
3
4
5
复制代码myViewModel.navigateToDetails.observe(this, Observer {
it.getContentIfNotHandled()?.let { // Only proceed if the event has never been handled
startActivity(DetailsActivity...)
}
})

这种方法的优点在于用户使用 getContentIfNotHandled() 或者 peekContent() 来指定意图。这个方法将事件建模为状态的一部分:他们现在只是一个消耗或者不消耗的消息。

使用事件包装器,您可以将多个观察者添加到一次性事件中。


总之:把事件设计成你的状态的一部分。使用您自己的事件包装器并根据您的需求进行定制。

银弹!若您最终发生大量事件,请使用这个 EventObserver 可以删除很多无用的代码。

感谢 Don Turner,Nick Butcher,和 Chris Banes。

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

本文转载自: 掘金

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

通过selenium突破极验验证实现登录

发表于 2018-06-20

笔者是一名初入Python爬虫的小白,通过书籍(静觅大神出的书籍)的方式学了下突破验证的方式实现模拟登录有此类型的应用的知识。该方法不涉及任何商业关系,如果有违规行为麻烦联系下笔者

实现的步骤分为3步:

  • 1.使应用出现验证的完整图片和带有缺口的图片
  • 2.识别缺口的位置
  • 3.模拟拖动滑块至缺口处,完成验证

引入相关的库

1
2
3
4
5
6
7
8
复制代码from selenium import webdriver;
from selenium.webdriver.support.wait import WebDriverWait;
from selenium.webdriver.support import expected_conditions as EC;
from selenium.webdriver.common.by import By;
from selenium.webdriver import ActionChains;
import time;
from PIL import Image;
from io import BytesIO;

我们定义一个类来实现相关的操作并且定义一些配置

1
2
3
4
复制代码EMAIL='xxx'  #账号
PASSWORD='xxx' #密码(只是简单的处理)
BORDER=6; #开始滑动的小块与左边缘的距离
INIT_LEFT=60; #开始从X轴方向即x=60开始检测缺口的位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
复制代码class GrackGeetest(object):

def __init__(self):
#这边我们开始定义一些相关的参数信息(我们用登录极验官网来做例子,其它的方式类似)
self.url='https://auth.geetest.com/login/';
self.browser=webdriver.Chrome();
self.wait=WebDriverWait(self.browser,20);
self.email=EMAIL;
self.password=PASSWORD;


#实现步骤1相关方法:
def getGeetestButton(self):
#获取点击可以使现验证图出现的按钮节点元素并返回
button=self.wait.until(EC.element_to_be_clickable((By.CLASS_NAME,'geetest_radar_tip')));
return button;

#获取验证图在网页中的位置并以元组的方式返回
def getImagePosition(self):
geetestImage=self.wait.until(EC.presence_of_element_located((By.CLASS_NAME,'geetest_canvas_img')));
time.sleep(2);
location=geetestImage.location;
size=geetestImage.size;
top,bottom,left,right=location['y'],location['y']+size['height'],location['x'],location['x']+size['width'];
return (top,bottom,left,right);

#截取当前页面
def getChromePage(self):
pageShot=self.browser.get_screenshot_as_png();
pageShot=Image.open(BytesIO(pageShot));
return pageShot;

#从网页中截取该验证图片并返回
def getGeetestImage(self,name='geetest.png'):
top,bottom,left,right=self.getImagePosition();
#截取当前页面的图片
pageShot=self.getChromePage();
#截取其中出现的验证图的位置
captchaImage=pageShot.crop((left,top,right,bottom));
captchaImage.save(name);#保存到当前的文件夹中
return captchaImage;

#实现步骤2相关方法:识别缺口位置
def getSlider(self):
#获取可拖动的滑块对象
slider=self.wait.until(EC.element_to_be_clickable((By.CLASS_NAME,'geetest_slider_button')));
return slider;

#通过对比2张图的像素点的差距得出缺口位置
def getGap(self,image1,image2):
left=60;
#size[0]->width,size[1]->height
for i in range(left,image1.size[0]):
for j in range(image1.size[1]):
if not self.isPixelEqual(image1,image2,i,j):
#因为小滑块和缺口是同一条水平线上的所以就只取x轴方向上的值
left=i;
return left;
return left;


def isPixelEqual(self,image1,image2,x,y):
#判断2个像素是否相同
pixel1=image1.load()[x,y]; #pixel1,pixel2为rgb值,是一个元组
pixel2=image2.load()[x,y];
#阀值当超出这个阀值的时候则证明这2个像素点不匹配,为缺口的左上角的像素点
threshold=60;
if abs(pixel1[0]-pixel2[0])<threshold and abs(pixel1[1]-pixel2[1])<threshold and abs(pixel1[2]-pixel2[2])<threshold :
return True;
else:
return False;

#步骤三相关方法:最关键的一步也是突破极验验证机器学习算法的一步
#采用物理中物体的分阶段改变加速度的方式,这里采用先加速后减速的方式
#公式 x=v0*t+1/2*a*t*t v=v0+a*t

def getTrack(self,distance):
#distance偏移量
#移动轨迹
tranck=[];
#当前位移
current=0;
#开始减速的阀值
mid=distance*4/5;
#计算间隔
t=0.2;
#初速度
v=0;
while current<distance:
if current<mid:
a=2;
else:
#开始减速
a=-3;
#初速度
v0=v;
#当前速度
v=v0+a*t;
#位移
move=v0*t+1/2*a*t*t;
#当前位移
current+=move;
#加入轨迹
track.append(round(move));
return track;

#按照运动轨迹移动滑块
def moveToGap(self,slider,tracks):
#拖动滑块到缺口处
ActionChains(self.browser).click_and_hold(slider).perform();
for x in tracks:
ActionChains(self.browser).move_by_offset(xoffset=x,yoffset=0).perform();
time.sleep(0.5);
ActionChains(self.browser).release().perform();

#最后模拟点击登录应用就行了
def login(self):
button=self.wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR,'#base > div.content-outter > div > div > div:nth-child(3) > div > form > div:nth-child(5) > div > button')));
button.click();
time.sleep(10);


#接下来直接实现通过一个方法将这整个过程连接起来
def sendUserAndPassword(self):
self.browser.get(self.url);
#通过类选择器,我是直接在浏览器那边复制过来的,所以比较长,可以通过其它方式得到该元素(右键那个网页元素就有一些选择可以看看哈)
email=self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR,'#base > div.content-outter > div > div > div:nth-child(3) > div > form > div:nth-child(1) > div > div.ivu-input-wrapper.ivu-input-type.ivu-input-group.ivu-input-group-with-prepend > input')));
password=self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR,'#base > div.content-outter > div > div > div:nth-child(3) > div > form > div:nth-child(2) > div > div.ivu-input-wrapper.ivu-input-type.ivu-input-group.ivu-input-group-with-prepend > input')));
email.send_keys(self.email);
password.send_keys(self.password);




def doVerifyLogin(self):
#步骤1:

#输入账号密码
self.sendUserAndPassword();
#点击验证按钮
verifyButton=self.getGeetestButton();
verifyButton.click();
#开始获取2张验证图
image1=self.getGeetestImage('geetest1.png');
#点击小滑块得到有缺口的验证图
slider=self.getSlider();
slider.click();
#获取带缺口的验证图
image2=self.getGeetestImage('geetest2.png');

#步骤2:
#获取缺口位置
gap=self.getGap(image1,image2);
#缺口的位置需要减去那个小滑块与左边那一小段距离
gap-=BORDER;

#步骤3:
#移动轨迹
track=self.getTrack();
#拖动滑块
self.moveToGap(slider,track);
#最后判断是否成功了,不成功就重新操作这一过程
try:
success = self.wait.until(
EC.text_to_be_present_in_element((By.CLASS_NAME, 'geetest_success_radar_tip_content'), '验证成功'))
print(success)
# 失败后重试
if not success:
self.doVerifyLogin()
else:
self.login()
except:
self.doVerifyLogin();

if __name__ == '__main__':
crack = GrackGeetest();
crack.doVerifyLogin();

以上就是完整的代码了,同时需要安装ChromeDriver,安装的具体过程找下搜索引擎问问

本文转载自: 掘金

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

针对ASPNET Core Web API的先进架构

发表于 2018-06-20

本点要点

  • 与传统的ASP.NET相比,ASP.NET Core的新架构提供了一些好处
  • ASP.NET Core从一开始就包含对依赖注入的支持
  • 单一职责原则简化了实施和设计。
  • 端口和适配器模式将业务逻辑与其他依赖项分离
  • 解耦的架构使测试更容易、更健壮

.NET Core 最初是在2016年发布的,随着.NET Core 2.0的发布,微软拥有了下一个通用、模块化、跨平台和开源的平台主版本。.NET Core已经创建了许多API,在当前版本的.net框架中均可用。它最初是为下一代ASP.NET解决方案而创建的,但现在成了许多其他场景的驱动和基础,包括物联网、云计算和下一代移动解决方案。在本系列文章中,我们将探讨.NET Core的一些好处,以及它如何不仅能使传统的.NET开发人员受益,还能使所有需要为市场带来健壮、高效和经济的解决方案的技术人员受益。

InfoQ的这篇文章是这个系列文章“.NET Core ”的一部分。您可以通过RSS订阅接收通知。

如今的互联网与五年前已经完全不同了,更不用说20年前我刚开始做专业开发人员的时候了。今天,Web api连接了由Web应用和移动应用驱动的现代互联网。有一种技能非常需要,那就是创建其他开发人员也可以使用的健壮的Web api。驱动大多数现代web和移动应用的API都需要具有稳定性和可靠性,以便在流量达到性能限制时仍能继续服务。

本文的目的是描述ASP.NET Core 2.0 Web API解决方案的体系结构,它使用了Hexagonal架构和端口和适配器模式。首先,我们来看看.NET Core和ASP.NET Core的新特性,它们对现代Web API很有帮助。

本文示例中的解决方案和所有代码都可以在我的GitHub存储库 ChinookASPNETCoreAPIHex中找到。

用于Web API的.NET Core 和 ASP.NET Core

ASP.NET Core是微软在.NET Core的基础上构建的一个新的web框架,用来摆脱.NET 1.0以来的遗留技术。相比之下,ASP.NET 4.6仍然使用System.Webassembly(它包含了所有WebForms类库),也因此引入了最近的ASP.NET MVC 5解决方案。通过摆脱这些遗留依赖和从头开始开发框架,并为跨平台执行进行了架构设计,ASP.NET Core 2.0为开发人员提供了更好的性能。使用ASP.NET Core 2.0,你的解决方案将在Linux上和Windows上均可有效运转。

更多关于.NET Core和ASP.NET Core的好处,你可以阅读本系列的其他三篇文章。第一篇是Maarten Balliauw的《性能是.NET的核心特性》、Chris Klug的《
ASP.NET Core –简洁的力量》,以及最后Eric Boyd的《[Azure和.NET Core的完美契合](http:// Azure and .NET Core Are Beautiful Together)》。

体系架构

构建一个优秀的API依赖于伟大的架构。我们将从ASP.NET Core 的内置功能来研究API设计和开发的许多方面以形成哲学和最终设计模式的架构。这个架构背后有很多计划和想法,让我们开始吧。

依赖注入

在我们深入研究ASP的架构之前.NET Core Web API解决方案,我想讨论一下我所认为的使.NET核心开发人员过得更好的单一好处;即依赖注入(DI)。现在,我知道你会说我们在.NET框架和ASP.NET解决方案中有依赖注入。我同意,但是我们过去使用的依赖注入是来自第三方的商业提供商或者开源库。他们做得很好,但是对于.NET开发人员来说,有一个很陡峭的学习曲线,并且所有的依赖注入库都有自己独特的处理方法。今天,有了.NET Core,我们从一开始就将依赖注入集成到框架中了。此外,它的用法非常简单,它是立即可用的。

我们需要在API中使用依赖注入的原因是,它允许我们有最好的经验来解耦架构层,并允许我们模拟数据层,或者为API构建多个数据源。

要使用.NET Core 依赖注入 框架,请确保您的项目引用了Microsoft.AspNetCore.AllNuGet包(它包含对Microsoft.Extnesions.DependencyInjection.Abstractionspackage的依赖关系)。这个包提供了对IServiceCollection接口的访问,该接口具有一个System.IServiceProvider 接口,您可以调用GetService从IServiceCollection接口获得所需的服务,需要添加项目所需的服务。

要了解更多关于.NET Core 依赖注入的信息,我建议您阅读以下关于MSDN的文档:ASP.NET中依赖注入的介绍。

现在我们来看看为什么要像我一样做API架构设计的原理。设计任何架构的两个方面都依赖于这两个概念:允许深度可维护性,以及在解决方案中使用经过验证的模式和架构。

API的可维护性

对于任何工程过程来说,可维护性是指一个产品易于被维护:发现缺陷、纠正发现的缺陷、无需替换仍在工作的组件即可修复或更换有缺陷的组件、预防意外故障、最大限度地提高产品的使用寿命、有能力满足新的需求、使未来的维护更容易,以及能应对环境变化。如果没有精心规划和可执行的架构,就很难做到以上种种。

可维护性是一个长期的问题,应该从您的API的远景来看。考虑到这一点,你需要做出决定,实现未来的愿景而不是走那些看上去能过得更轻松的捷径。在一开始就做出艰难的决定将使你的项目有一个很长的生命周期,并提供用户所需的好处。

什么使软件架构具有高可维护性?如何评估API是否可被维护?

  • 我们的体系结构是否允许最小化对系统其他领域的影响甚至为零?
  • 对API的调试应该是容易的,不需要设置困难。我们应该建立模式并通过常用的方法(例如浏览器调试工具)。
  • 测试应该是自动化的,并且清晰明了,不能太复杂。

接口和实现

我的API体系结构的关键是使用C#接口来支持其他实现。如果您已经用C#编写了.NET代码,那么您可能已经使用了接口。我在解决方案中使用接口在领域层中构建一个契约,该契约保证我为API开发的任何数据层都遵循数据存储库的契约。它还允许我的API项目中的控制器遵守另一个已设立的契约,以获得正确的方法来处理领域项目的Supervisor中的API方法。接口对于.NET Core 是非常重要的,如果您需要了解更多的信息,请点击此处。

端口和适配器模式

我们希望整个API解决方案中的对象具有单一职责。这将使我们在需要修复缺陷或增强代码时让对象保持简单和易于修改。如果您的代码中有一些“代码异味”,那么您可能违反了单一责任原则。一般情况下,我关注接口契约的实现的长度和复杂性。我的方法中没有代码行限制,但是如果它已经超过了您IDE中的一个视图,那么它可能就太长了。此外,我还检查方法的圈复杂度,以确定项目方法和函数的复杂性。

端口和适配器模式(又称六角形架构)可以解决业务逻辑与其他依赖项(如数据访问或API框架)耦合过于紧密的问题。使用此模式将允许您的API解决方案具有清晰的边界、具有单一职责的良好命名的对象,最终使其更容易开发和维护。

我们可以很直观地把这个模式看作一个洋葱,端口位于六边形的外部,而适配器和业务逻辑的位置更靠近核心。我将架构的外部连接视为端口。被消费的API端点或Entity Framework Core 2.0所使用的数据库连接将成为典型的端口范例,而内部数据存储库则是适配器。

接下来,让我们看看架构的逻辑部分和一些演示代码示例。

领域(Domain)层

在查看API和领域层之前,我们需要解释如何通过接口和API业务逻辑的实现构建契约。我们来看看领域层。领域层具有以下功能:

  • 定义将在整个解决方案中使用的实体对象。这些模型将表示数据层的数据模型(DataModel)。
  • 定义视图模型(ViewModel),将由API层针对HTTP请求和响应作为单个对象或对象集来使用。
  • 定义接口,我们的数据层可以通过这些接口实现数据访问逻辑。
  • 实现将包含从API层调用的方法的Supervisor。每个方法都代表一个API调用,并将数据从注入的数据层转换为视图模型以返回。

我们的域实体对象代表我们用来存储和检索用于API业务逻辑的数据的数据库。每个实体对象都将包含SQL表中的属性。如下即为照片实体Album。

1
2
3
4
5
6
7
8
9
复制代码__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__public sealed class Album
{
public int AlbumId { get; set; }
public string Title { get; set; }
public int ArtistId { get; set; }

public ICollection<Track> Tracks { get; set; } = new HashSet<Track>();
public Artist Artist { get; set; }
}__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__

SQL数据库中的Album表有三表:AlbumId、Title和ArtistId。这三个属性是专辑实体的一部分,另外还有艺术家的名字以及相关艺术家和一组相关歌曲。正如我们将在API体系结构的其他层中看到的,我们将针对该项目中的视图模型构建此实体对象的定义。

视图模型是实体的扩展,并帮助为api的使用者提供更多的信息。让我们看看视图模型。它与相册实体非常相似,但具有额外的属性。在API的设计中,我确定每个相册应该在从API返回的有效负载中包含艺术家的名字。这能让API使用者拥有关于相册的关键信息,而不必在数据载荷中再传递Artist视图模型(特别是当我们返回大量Album时)。下面是我们的Album视图模型的一个示例。

1
2
3
4
5
6
7
8
9
10
复制代码__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__public class AlbumViewModel
{
public int AlbumId { get; set; }
public string Title { get; set; }
public int ArtistId { get; set; }
public string ArtistName { get; set; }

public ArtistViewModel Artist { get; set; }
public IList<TrackViewModel> Tracks { get; set; }
}__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__

在领域层中另一部分需要开发的是契约,它们会经过该层中为每个实体定义的接口。同样,我们将使用Album实体来展示所定义的接口。

1
2
3
4
5
6
7
8
9
复制代码__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__public interface IAlbumRepository : IDisposable
{
Task<List<Album>> GetAllAsync(CancellationToken ct = default(CancellationToken));
Task<Album> GetByIdAsync(int id, CancellationToken ct = default(CancellationToken));
Task<List<Album>> GetByArtistIdAsync(int id, CancellationToken ct = default(CancellationToken));
Task<Album> AddAsync(Album newAlbum, CancellationToken ct = default(CancellationToken));
Task<bool> UpdateAsync(Album album, CancellationToken ct = default(CancellationToken));
Task<bool> DeleteAsync(int id, CancellationToken ct = default(CancellationToken));
}__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__

如上例所示,接口定义了为Album实体实现数据访问方法所需的方法。每个实体对象和接口都有良好的定义和简单化,使下一层可以得到良好的定义。

最后,领域项目的核心是Supervisor类。它的用途是在实体和视图模型之间进行转换,并在API端点和数据访问逻辑之外执行业务逻辑。让Supervisor来处理这些还将隔离逻辑,使转换和业务逻辑能够进行单元测试。

查看获取和传递单个Album到API端点的Supervisor方法,我们可以看到将API前端连接到数据访问的逻辑注入到了Supervisor中,而仍然保持每个Album是独立的。

1
2
3
4
5
6
7
8
复制代码__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__public async Task<AlbumViewModel> GetAlbumByIdAsync(int id, CancellationToken ct = default(CancellationToken))
{
var albumViewModel = AlbumCoverter.Convert(await _albumRepository.GetByIdAsync(id, ct));
albumViewModel.Artist = await GetArtistByIdAsync(albumViewModel.ArtistId, ct);
albumViewModel.Tracks = await GetTrackByAlbumIdAsync(albumViewModel.AlbumId, ct);
albumViewModel.ArtistName = albumViewModel.Artist.Name;
return albumViewModel;
}__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__

在领域项目中维护大部分代码和逻辑会使每个项目保持并遵守单一职责原则。

数据层

我们将看到的API体系结构的下一层是数据层。在我们所示例的解决方案中,使用的是Entity Framework Core 2.0。这意味着我们不仅拥有已定义的Entity Framework Core的DBContext,还有为SQL数据库中的每个实体生成的数据模型。如果我们以专辑实体的数据模型为例来看,会发现在数据库中存有三个属性,还有包含一组与专辑相关的歌曲,以及艺术家对象的相关属性。

虽然您可以拥有大量的数据层实现,但请记住,它必须遵守在领域层上记录的要求;每个数据层实现必须与领域层中详细的视图模型和存储库接口一起工作。我们为API开发的体系结构使用仓储模式将API层连接到数据层。使用依赖注入(正如我们前面讨论过的)对我们实现的每个存储库对象进行了处理。我们将讨论在着眼于API层时如何使用依赖项注入和代码。数据层的关键是使用领域层中开发的接口实现每个实体存储库。以领域层的专辑存储库为例,它就是实现了IAlbumRepository接口。每个存储库都将注入DBContext,允许使用实体框架核心访问SQL数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
复制代码__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__public class AlbumRepository : IAlbumRepository
{
private readonly ChinookContext _context;

public AlbumRepository(ChinookContext context)
{
_context = context;
}

private async Task<bool> AlbumExists(int id, CancellationToken ct = default(CancellationToken))
{
return await GetByIdAsync(id, ct) != null;
}

public void Dispose()
{
_context.Dispose();
}

public async Task<List<Album>> GetAllAsync(CancellationToken ct = default(CancellationToken))
{
return await _context.Album.ToListAsync(ct);
}

public async Task<Album> GetByIdAsync(int id, CancellationToken ct = default(CancellationToken))
{
return await _context.Album.FindAsync(id);
}

public async Task<Album> AddAsync(Album newAlbum, CancellationToken ct = default(CancellationToken))
{
_context.Album.Add(newAlbum);
await _context.SaveChangesAsync(ct);
return newAlbum;
}

public async Task<bool> UpdateAsync(Album album, CancellationToken ct = default(CancellationToken))
{
if (!await AlbumExists(album.AlbumId, ct))
return false;
_context.Album.Update(album);

_context.Update(album);
await _context.SaveChangesAsync(ct);
return true;
}

public async Task<bool> DeleteAsync(int id, CancellationToken ct = default(CancellationToken))
{
if (!await AlbumExists(id, ct))
return false;
var toRemove = _context.Album.Find(id);
_context.Album.Remove(toRemove);
await _context.SaveChangesAsync(ct);
return true;
}

public async Task<List<Album>> GetByArtistIdAsync(int id, CancellationToken ct = default(CancellationToken))
{
return await _context.Album.Where(a => a.ArtistId == id).ToListAsync(ct);
}
}__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__

拥有封装所有数据访问的数据层将有助于更好地测试API。我们可以构建多个数据访问实现:一个用于SQL数据库存储,另一个可以用于云NoSQL 存储模式,最后一个用于解决方案中的单元测试的模拟存储实现。

API层

我们将看到的最后一层是您的API使用者将发生交互的区域。这一层包含Web API端点逻辑的代码,包括控制器。这个解决方案的API项目将有一个单独的职责,那就是处理web服务器接收到的HTTP请求并返回HTTP响应,无论成功还是失败。在这个项目中,将会有非常少的业务逻辑。我们将处理在领域或数据项目中发生的异常和错误,以有效地与API的使用者进行通信。此通信将使用HTTP响应代码和在HTTP响应报文中返回的任何数据。

在ASP.NET Core 2.0 Web API,路由是使用Routing属性来处理的。如果您需要了解更多关于ASP.NET Core中Routing属性的内容,请移步此处。我们还使用依赖项注入将Supervisor分配给每个控制器。每个控制器的操作方法都有一个相应的Supervisor方法,用于处理API调用的逻辑。下面我有一个Album控制器的片段来展示这些概念。

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
复制代码__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__[Route("api/[controller]")]
public class AlbumController : Controller
{
private readonly IChinookSupervisor _chinookSupervisor;

public AlbumController(IChinookSupervisor chinookSupervisor)
{
_chinookSupervisor = chinookSupervisor;
}

[HttpGet]
[Produces(typeof(List<AlbumViewModel>))]
public async Task<IActionResult> Get(CancellationToken ct = default(CancellationToken))
{
try
{
return new ObjectResult(await _chinookSupervisor.GetAllAlbumAsync(ct));
}
catch (Exception ex)
{
return StatusCode(500, ex);
}
}

...
}__Wed Jun 20 2018 14:53:22 GMT+0800 (CST)____Wed Jun 20 2018 14:53:22 GMT+0800 (CST)__

这个解决方案的Web API项目非常简略。我努力让这个解决方案中的代码尽可能的少,因为将来它可以被另一种交互形式所替代。

结论

正如我所展示的,设计和开发一个伟大的ASP.NET Core 2.0 Web API解决方案具有洞察力,以便拥有一个解耦的体系结构,该体系结构将允许每个层都是可测试的,并遵循单一的职责原则。我希望我的信息将允许您创建和维护您的产品Web api,以满足您的组织的需要。

关于作者

Chris Woodruff (Woody)拥有密歇根州立大学工程学院的计算机科学学位。Woody已经开发和架构软件解决方案超过20年,并且曾经致力于许多不同的平台和工具。他是一个社区领袖,为GRDevNight、GRDevDay、West Michigan Day of .NET和CodeMash之类的活动贡献过力量。他还帮助把广受欢迎的Give Camp活动带到西密歇根,那里的技术专业人士提供他们的时间和发展专业知识,以帮助当地的非营利组织。作为一个演讲者和播客作者,Woody已经讲过和讨论了很多话题,包括数据库设计和开源。他在Visual
C#、数据平台和SQL方面一直是微软的MVP,并在2010年被公认为全球最优秀的20个MVPs之一。Woody是JetBrains的开发者,并且在北美推广.NET,.NET Core和JetBrains的产品。

.NET Core 最初是在2016年发布的,随着.NET Core 2.0的发布,微软拥有了下一个通用、模块化、跨平台和开源的平台主版本。.NETCore已经创建了许多API,在当前版本的.net框架中均可用。它最初是为下一代ASP.NET解决方案而创建的,但现在成了许多其他场景的驱动和基础,包括物联网、云计算和下一代移动解决方案。在本系列文章中,我们将探讨.NET Core的一些好处,以及它如何不仅能使传统的.NET开发人员受益,还能使所有需要为市场带来健壮、高效和经济的解决方案的技术人员受益。

InfoQ的这篇文章是“.NET Core”这个系列的一部分。您可以通过RSS订阅接收通知。

查看英文原文:Advanced Architecture for ASP.NET Core Web API

本文转载自: 掘金

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

Goroutine并发调度模型深度解析&手撸一个协程池 Go

发表于 2018-06-19

golanggoroutine协程池Groutine Pool 高并发

并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题;Go语言作为一个出道以来就自带 『高并发』光环的富二代编程语言,它的并发(并行)编程肯定是值得开发者去探究的,而Go语言中的并发(并行)编程是经由goroutine实现的,goroutine是golang最重要的特性之一,具有使用成本低、消耗资源低、能效高等特点,官方宣称原生goroutine并发成千上万不成问题,于是它也成为Gopher们经常使用的特性。

Goroutine是优秀的,但不是完美的,在极大规模的高并发场景下,也可能会暴露出问题,什么问题呢?又有什么可选的解决方案?本文将通过runtime对goroutine的调度分析,帮助大家理解它的机理和发现一些内存和调度的原理和问题,并且基于此提出一种个人的解决方案 — 一个高性能的Goroutine Pool(协程池)。

Goroutine & Scheduler

Goroutine,Go语言基于并发(并行)编程给出的自家的解决方案。goroutine是什么?通常goroutine会被当做coroutine(协程)的 golang实现,从比较粗浅的层面来看,这种认知也算是合理,但实际上,goroutine并非传统意义上的协程,现在主流的线程模型分三种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),传统的协程库属于用户级线程模型,而goroutine和它的Go Scheduler在底层实现上其实是属于两级线程模型,因此,有时候为了方便理解可以简单把goroutine类比成协程,但心里一定要有个清晰的认知
— goroutine并不等同于协程。

线程那些事儿

互联网时代以降,由于在线用户数量的爆炸,单台服务器处理的连接也水涨船高,迫使编程模式由从前的串行模式升级到并发模型,而几十年来,并发模型也是一代代地升级,有IO多路复用、多进程以及多线程,这几种模型都各有长短,现代复杂的高并发架构大多是几种模型协同使用,不同场景应用不同模型,扬长避短,发挥服务器的最大性能,而多线程,因为其轻量和易用,成为并发编程中使用频率最高的并发模型,而后衍生的协程等其他子产品,也都基于它,而我们今天要分析的 goroutine 也是基于线程,因此,我们先来聊聊线程的三大模型:

线程的实现模型主要有3种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),它们之间最大的差异就在于用户线程与内核调度实体(KSE,Kernel Scheduling Entity)之间的对应关系上。而所谓的内核调度实体 KSE 就是指可以被操作系统内核调度器调度的对象实体(这说的啥玩意儿,敢不敢通俗易懂一点?)。简单来说 KSE 就是内核级线程,是操作系统内核的最小调度单元,也就是我们写代码的时候通俗理解上的线程了(这么说不就懂了嘛!装什么13)。

用户级线程模型

用户线程与内核线程KSE是多对一(N : 1)的映射模型,多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个KSE在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的 协程库 基本上都属于这种方式(比如python的gevent)。由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让CPU在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如I/O阻塞)而被CPU给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有CPU时钟中断的,从而没有轮转调度),整个进程被挂起。即便是多CPU的机器,也无济于事,因为在用户级线程模型下,一个CPU关联运行的是整个用户进程,进程内的子线程绑定到CPU执行是由用户进程调度的,内部线程对CPU是不可见的,此时可以理解为CPU的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。

内核级线程模型

用户线程与内核线程KSE是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如Java的java.lang.Thread、C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的KSE静态绑定,因此其调度完全由操作系统内核调度器去做。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以CPU可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。

两级线程模型

两级线程模型是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核KSE是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程KSE关联,于是进程内的多个线程可以绑定不同的KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的所有线程并不与KSE一一绑定,而是可以动态绑定同一个KSE, 当某个KSE因为其绑定的线程的阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新与其他KSE绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作),也就是
— 『薛定谔的模型』(误),因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而Go语言中的runtime调度器就是采用的这种实现方案,实现了Goroutine与KSE之间的动态关联,不过Go语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到KSE的『调度』,内核调度器实现KSE到CPU上的『调度』。

G-P-M 模型概述

每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的goroutine来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。因此,Go语言做了它自己的『线程』。

在Go语言中,每一个goroutine是一个独立的执行单元,相较于每个OS线程固定分配2M内存的模式,goroutine的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达1GB(64位机器最大是1G,32位机器最大是256M),且完全由golang自己的调度器 Go Scheduler 来调度。此外,GC还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go程序可以同时并发成千上万个goroutine是得益于它强劲的调度器和高效的内存模型。Go的创造者大概对goroutine的定位就是屠龙刀,因为他们不仅让goroutine作为golang并发编程的最核心组件(开发者的程序都是基于goroutine运行的)而且golang中的许多标准库的实现也到处能见到goroutine的身影,比如net/http这个包,甚至语言本身的组件runtime运行时和GC垃圾回收器都是运行在goroutine上的,作者对goroutine的厚望可见一斑。

任何用户线程最终肯定都是要交由OS线程来执行的,goroutine(称为G)也不例外,但是G并不直接绑定OS线程运行,而是由Goroutine Scheduler中的 P - Logical Processor (逻辑处理器)来作为两者的『中介』,P可以看作是一个抽象的资源或者一个上下文,一个P绑定一个OS线程,在golang的实现里把OS线程抽象成一个数据结构:M,G实际上是由M通过P来进行调度运行的,但是在G的层面来看,P提供了G运行所需的一切资源和环境,因此在G看来P就是运行它的
“CPU”,由 G、P、M 这三种由Go抽象出来的实现,最终形成了Go调度器的基本结构:

  • G: 表示Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。
  • P: Processor,表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。
  • M: Machine,OS线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。

关于P,我们需要再絮叨几句,在Go 1.0发布的时候,它的调度器其实G-M模型,也就是没有P的,调度过程全由G和M完成,这个模型暴露出一些问题:

  • 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作,比如:创建、重新调度等都要上锁;
  • goroutine传递问题:M经常在M之间传递『可运行』的goroutine,这导致调度延迟增大以及额外的性能损耗;
  • 每个M做内存缓存,导致内存占用过高,数据局部性较差;
  • 由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗。

这些问题实在太扎眼了,导致Go1.0虽然号称原生支持并发,却在并发性能上一直饱受诟病,然后,Go语言委员会中一个核心开发大佬看不下了,亲自下场重新设计和实现了Go调度器(在原有的G-M模型中引入了P)并且实现了一个叫做 work-stealing 的调度算法:

  • 每个P维护一个G的本地队列;
  • 当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中;
  • 当一个G在M里执行结束后,P会从队列中把该G取出;如果此时P的队列为空,即没有其他G可以执行, M就随机选择另外一个P,从其可执行的G队列中取走一半。

该算法避免了在goroutine调度时使用全局锁。

至此,Go调度器的基本模型确立:

G-P-M模型

G-P-M 模型调度

Go调度器工作时会维护两种用来保存G的任务队列:一种是一个Global任务队列,一种是每个P维护的Local任务队列。

当通过go关键字创建一个新的goroutine的时候,它会优先被放入P的本地队列。为了运行goroutine,M需要持有(绑定)一个P,接着M会启动一个OS线程,循环从P的本地队列里取出一个goroutine并执行。当然还有上文提及的 work-stealing调度算法:当M执行完了当前P的Local队列里的所有G后,P也不会就这么在那躺尸啥都不干,它会先尝试从Global队列寻找G来执行,如果Global队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。

如果一切正常,调度器会以上述的那种方式顺畅地运行,但这个世界没这么美好,总有意外发生,以下分析goroutine在两种例外情况下的行为。

Go runtime会在下面的goroutine被阻塞的情况下运行另外一个goroutine:

  • blocking syscall (for example opening a file)
  • network input
  • channel operations
  • primitives in the sync package

这四种场景又可归类为两种类型:

用户态阻塞/唤醒

当goroutine因为channel操作或者network I/O而阻塞时(实际上golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G,这里仅仅是举个栗子),对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。

系统调用阻塞

当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于 block on syscall 状态,此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。

以上就是从宏观的角度对Goroutine和它的调度器进行的一些概要性的介绍,当然,Go的调度中更复杂的抢占式调度、阻塞调度的更多细节,大家可以自行去找相关资料深入理解,本文只讲到Go调度器的基本调度过程,为后面自己实现一个Goroutine Pool提供理论基础,这里便不再继续深入上述说的那几个调度了,事实上如果要完全讲清楚Go调度器,一篇文章的篇幅也实在是捉襟见肘,所以想了解更多细节的同学可以去看看Go调度器 G-P-M 模型的设计者 Dmitry Vyukov 写的该模型的设计文档《
Go Preemptive Scheduler Design》以及直接去看源码,G-P-M模型的定义放在src/runtime/runtime2.go里面,而调度过程则放在了src/runtime/proc.go里。

大规模Goroutine的瓶颈

既然Go调度器已经这么牛逼优秀了,我们为什么还要自己去实现一个golang的 Goroutine Pool 呢?事实上,优秀不代表完美,任何不考虑具体应用场景的编程模式都是耍流氓!有基于G-P-M的Go调度器背书,go程序的并发编程中,可以任性地起大规模的goroutine来执行任务,官方也宣称用golang写并发程序的时候随便起个成千上万的goroutine毫无压力。

然而,你起1000个goroutine没有问题,10000也没有问题,10w个可能也没问题;那,100w个呢?1000w个呢?(这里只是举个极端的例子,实际编程起这么大规模的goroutine的例子极少)这里就会出问题,什么问题呢?

  1. 首先,即便每个goroutine只分配2KB的内存,但如果是恐怖如斯的数量,聚少成多,内存暴涨,就会对GC造成极大的负担,写过java的同学应该知道jvm GC那万恶的STW(Stop The World)机制,也就是GC的时候会挂起用户程序直到垃圾回收完,虽然Go1.8之后的GC已经去掉了STW以及优化成了并行GC,性能上有了不小的提升,但是,如果太过于频繁地进行GC,依然会有性能瓶颈;
  2. 其次,还记得前面我们说的runtime和GC也都是goroutine吗?是的,如果goroutine规模太大,内存吃紧,runtime调度和垃圾回收同样会出问题,虽然G-P-M模型足够优秀,韩信点兵,多多益善,但你不能不给士兵发口粮(内存)吧?巧妇难为无米之炊,没有内存,Go调度器就会阻塞goroutine,结果就是P的Local队列积压,又导致内存溢出,这就是个死循环…,甚至极有可能程序直接Crash掉,本来是想享受golang并发带来的快感效益,结果却得不偿失。

一个http标准库引发的血案

我想,作为golang拥趸的Gopher们一定都使用过它的net/http标准库,很多人都说用golang写web server完全可以不用借助第三方的web framework,仅用net/http标准库就能写一个高性能的web server,的确,我也用过它写过web server,简洁高效,性能表现也相当不错,除非有比较特殊的需求否则一般的确不用借助第三方web framework,但是天下没有白吃的午餐,net/http为啥这么快?要搞清这个问题,从源码入手是最好的途径。孔子曾经曰过:源码面前,如同裸奔。所以,高清无码是阻碍程序猿发展大大滴绊脚石啊,源码才是我们进步阶梯,切记切记!

接下来我们就来先看看net/http内部是怎么实现的。

net/http接收请求且开始处理的源码放在src/net/http/server.go里,先从入口函数ListenAndServe进去:

1
2
3
4
5
6
7
8
9
10
11
go复制代码func (srv *Server) ListenAndServe() error {
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

看到最后那个srv.Serve调用了吗?没错,这个Serve方法里面就是实际处理http请求的逻辑,我们再进入这个方法内部:

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码func (srv *Server) Serve(l net.Listener) error {
defer l.Close()
...
// 不断循环取出TCP连接
for {
// 看我看我!!!
rw, e := l.Accept()
...
// 再看我再看我!!!
go c.serve(ctx)
}
}

首先,这个方法的参数(l net.Listener) ,是一个TCP监听的封装,负责监听网络端口,rw, e := l.Accept()则是一个阻塞操作,从网络端口取出一个新的TCP连接进行处理,最后go c.serve(ctx)就是最后真正去处理这个http请求的逻辑了,看到前面的go关键字了吗?没错,这里启动了一个新的goroutine去执行处理逻辑,而且这是在一个无限循环体里面,所以意味着,每来一个请求它就会开一个goroutine去处理,相当任性粗暴啊…,不过有Go调度器背书,一般来说也没啥压力,然而,如果,我是说如果哈,突然一大波请求涌进来了(比方说黑客搞了成千上万的肉鸡DDOS你,没错!就这么倒霉!),这时候,就很成问题了,他来10w个请求你就要开给他10w个goroutine,来100w个你就要老老实实开给他100w个,线程调度压力陡升,内存爆满,再然后,你就跪了…

釜底抽薪

有问题,就一定有解决的办法,那么,有什么方案可以减缓大规模goroutine对系统的调度和内存压力?要想解决问题,最重要的是找到造成问题的根源,这个问题根源是什么?goroutine的数量过多导致资源侵占,那要解决这个问题就要限制运行的goroutine数量,合理复用,节省资源,具体就是 — goroutine池化。

超大规模并发的场景下,不加限制的大规模的goroutine可能造成内存暴涨,给机器带来极大的压力,吞吐量下降和处理速度变慢还是其次,更危险的是可能使得程序crash。所以,goroutine池化是有其现实意义的。

首先,100w个任务,是不是真的需要100w个goroutine来处理?未必!用1w个goroutine也一样可以处理,让一个goroutine多处理几个任务就是了嘛,池化的核心优势就在于对goroutine的复用。此举首先极大减轻了runtime调度goroutine的压力,其次,便是降低了对内存的消耗。

有一个商场,来了1000个顾客买东西,那么该如何安排导购员服务这1000人呢?有两种方案:

第一,我雇1000个导购员实行一对一服务,这种当然是最高效的,但是太浪费资源了,雇1000个人的成本极高且管理困难,这些可以先按下不表,但是每个顾客到商场买东西也不是一进来就马上买,一般都得逛一逛,选一选,也就是得花时间挑,1000个导购员一对一盯着,效率极低;这就引出第二种方案:我只雇10个导购员,就在商场里待命,有顾客需要咨询的时候招呼导购员过去进行处理,导购员处理完之后就回来,等下一个顾客需要咨询的时候再去,如此往返反复…

第二种方案有没有觉得很眼熟?没错,其基本思路就是模拟一个I/O多路复用,通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。关于多路复用,不在本文的讨论范围之内,便不再赘述,详细原理可以参考 I/O多路复用。

第一种方案就是net/http标准库采用的:来一个请求开一个goroutine处理;第二种方案就是Goroutine Pool(I/O多路复用)。

实现一个 Goroutine Pool

因为上述陈列的一些由于goroutine规模过大而可能引发的问题,需要有方案来解决这些问题,上文已经分析过,把goroutine池化是一种行之有效的方案,基于此,可以实现一个Goroutine Pool,复用goroutine,减轻runtime的调度压力以及缓解内存压力,依托这些优化,在大规模goroutine并发的场景下可以极大地提高并发性能。

哎玛!前面絮絮叨叨了这么多,终于进入正题了,接下来就开始讲解如何实现一个高性能的Goroutine Pool,秒杀原生并发的goroutine,在执行速度和占用内存上提高并发程序的性能。好了,话不多说,开始装逼分析。

设计思路

Goroutine Pool 的实现思路大致如下:

启动服务之时先初始化一个 Goroutine Pool 池,这个Pool维护了一个类似栈的FILO队列 ,里面存放负责处理任务的Worker,然后在client端提交task到Pool中之后,在Pool内部,接收task之后的核心操作是:

  1. 检查当前Worker队列中是否有空闲的Worker,如果有,取出执行当前的task;
  2. 没有空闲Worker,判断当前在运行的Worker是否已超过该Pool的容量,是 — 阻塞等待直至有Worker被放回Pool;否 — 新开一个Worker(goroutine)处理;
  3. 每个Worker执行完任务之后,放回Pool的队列中等待。

调度过程如下:

按照这个设计思路,我实现了一个高性能的Goroutine Pool,较好地解决了上述的大规模调度和资源占用的问题,在执行速度和内存占用方面相较于原生goroutine并发占有明显的优势,尤其是内存占用,因为复用,所以规避了无脑启动大规模goroutine的弊端,可以节省大量的内存。

完整的项目代码可以在我的github上获取:传送门,也欢迎提意见和交流。

实现细节

Goroutine Pool的设计原理前面已经讲过了,整个调度过程相信大家应该可以理解了,但是有一句老话说得好,空谈误国,实干兴邦,设计思路有了,具体实现的时候肯定会有很多细节、难点,接下来我们通过分析这个Goroutine Pool的几个核心实现以及它们的联动来引导大家过一遍Goroutine Pool的原理。

首先是Pool struct:

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
go复制代码type sig struct{}

type f func() error

// Pool accept the tasks from client,it limits the total
// of goroutines to a given number by recycling goroutines.
type Pool struct {
// capacity of the pool.
capacity int32

// running is the number of the currently running goroutines.
running int32

// freeSignal is used to notice pool there are available
// workers which can be sent to work.
freeSignal chan sig

// workers is a slice that store the available workers.
workers []*Worker

// release is used to notice the pool to closed itself.
release chan sig

// lock for synchronous operation
lock sync.Mutex

once sync.Once
}

Pool是一个通用的协程池,支持不同类型的任务,亦即每一个任务绑定一个函数提交到池中,批量执行不同类型任务,是一种广义的协程池;本项目中还实现了另一种协程池 — 批量执行同类任务的协程池PoolWithFunc,每一个PoolWithFunc只会绑定一个任务函数pf,这种Pool适用于大批量相同任务的场景,因为每个Pool只绑定一个任务函数,因此PoolWithFunc相较于Pool会更加节省内存,但通用性就不如前者了,为了让大家更好地理解协程池的原理,这里我们用通用的Pool来分析。

capacity是该Pool的容量,也就是开启worker数量的上限,每一个worker绑定一个goroutine;running是当前正在执行任务的worker数量;freeSignal是一个信号,因为Pool开启的worker数量有上限,因此当全部worker都在执行任务的时候,新进来的请求就需要阻塞等待,那当执行完任务的worker被放回Pool之时,如何通知阻塞的请求绑定一个空闲的worker运行呢?freeSignal就是来做这个事情的;workers是一个slice,用来存放空闲worker,请求进入Pool之后会首先检查workers中是否有空闲worker,若有则取出绑定任务执行,否则判断当前运行的worker是否已经达到容量上限,是—阻塞等待,否—新开一个worker执行任务;release是当关闭该Pool支持通知所有worker退出运行以防goroutine泄露;lock是一个锁,用以支持Pool的同步操作;once用在确保Pool关闭操作只会执行一次。

提交任务到Pool

p.Submit(task f)如下:

1
2
3
4
5
6
7
8
9
go复制代码// Submit submit a task to pool
func (p *Pool) Submit(task f) error {
if len(p.release) > 0 {
return ErrPoolClosed
}
w := p.getWorker()
w.sendTask(task)
return nil
}

第一个if判断当前Pool是否已被关闭,若是则不再接受新任务,否则获取一个Pool中可用的worker,绑定该task执行。

获取可用worker(核心)

p.getWorker()源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
stylus复制代码// getWorker returns a available worker to run the tasks.
func (p *Pool) getWorker() *Worker {
var w *Worker
// 标志,表示当前运行的worker数量是否已达容量上限
waiting := false
// 涉及从workers队列取可用worker,需要加锁
p.lock.Lock()
workers := p.workers
n := len(workers) - 1
// 当前worker队列为空(无空闲worker)
if n < 0 {
// 运行worker数目已达到该Pool的容量上限,置等待标志
if p.running >= p.capacity {
waiting = true
// 否则,运行数目加1
} else {
p.running++
}
// 有空闲worker,从队列尾部取出一个使用
} else {
<-p.freeSignal
w = workers[n]
workers[n] = nil
p.workers = workers[:n]
}
// 判断是否有worker可用结束,解锁
p.lock.Unlock()

if waiting {
// 阻塞等待直到有空闲worker
<-p.freeSignal
p.lock.Lock()
workers = p.workers
l := len(workers) - 1
w = workers[l]
workers[l] = nil
p.workers = workers[:l]
p.lock.Unlock()
// 当前无空闲worker但是Pool还没有满,
// 则可以直接新开一个worker执行任务
} else if w == nil {
w = &Worker{
pool: p,
task: make(chan f),
}
w.run()
}
return w
}

上面的源码中加了较为详细的注释,结合前面的设计思路,相信大家应该能理解获取可用worker绑定任务执行这个协程池的核心操作,这里主要关注一个地方:达到Pool容量限制之后,额外的任务请求需要阻塞等待idle worker,这里是为了防止无节制地创建goroutine,事实上Go调度器有一个复用机制,每次使用go关键字的时候它会检查当前结构体M中的P中,是否有可用的结构体G。如果有,则直接从中取一个,否则,需要分配一个新的结构体G。如果分配了新的G,需要将它挂到runtime的相关队列中,但是调度器却没有限制goroutine的数量,这在瞬时性goroutine爆发的场景下就可能来不及复用G而依然创建了大量的goroutine,所以ants除了复用还做了限制goroutine数量。

其他部分可以依照注释理解,这里不再赘述。

任务执行

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
go复制代码// Worker is the actual executor who runs the tasks,
// it starts a goroutine that accepts tasks and
// performs function calls.
type Worker struct {
// pool who owns this worker.
pool *Pool

// task is a job should be done.
task chan f
}

// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *Worker) run() {
//atomic.AddInt32(&w.pool.running, 1)
go func() {
//监听任务列表,一旦有任务立马取出运行
for f := range w.task {
if f == nil {
atomic.AddInt32(&w.pool.running, -1)
return
}
f()

//回收复用
w.pool.putWorker(w)
}
}()
}

// stop this worker.
func (w *Worker) stop() {
w.sendTask(nil)
}

// sendTask sends a task to this worker.
func (w *Worker) sendTask(task f) {
w.task <- task
}

Worker回收(goroutine复用)

1
2
3
4
5
6
7
stylus复制代码// putWorker puts a worker back into free pool, recycling the goroutines.
func (p *Pool) putWorker(worker *Worker) {
p.lock.Lock()
p.workers = append(p.workers, worker)
p.lock.Unlock()
p.freeSignal <- sig{}
}

结合前面的p.Submit(task f)和p.getWorker(),提交任务到Pool之后,获取一个可用worker,每新建一个worker实例之时都需要调用w.run()启动一个goroutine监听worker的任务列表task,一有任务提交进来就执行;所以,当调用worker的sendTask(task f)方法提交任务到worker的任务队列之后,马上就可以被接收并执行,当任务执行完之后,会调用w.pool.putWorker(w *Worker)方法将这个已经执行完任务的worker从当前任务解绑放回Pool中,以供下个任务可以使用,至此,一个任务从提交到完成的过程就此结束,Pool调度将进入下一个循环。

动态扩容或者缩小池容量

1
2
3
4
5
6
7
8
9
10
11
12
arduino复制代码// ReSize change the capacity of this pool
func (p *Pool) ReSize(size int) {
if size < p.Cap() {
diff := p.Cap() - size
for i := 0; i < diff; i++ {
p.getWorker().stop()
}
} else if size == p.Cap() {
return
}
atomic.StoreInt32(&p.capacity, int32(size))
}

概括起来,ants Goroutine Pool的调度过程图示如下:

彩蛋

还记得前面我说除了通用的Pool struct之外,本项目还实现了一个PoolWithFunc struct—一个执行批量同类任务的协程池,PoolWithFunc相较于Pool,因为一个池只绑定一个任务函数,省去了每一次task都需要传送一个任务函数的代价,因此其性能优势比起Pool更明显,这里我们稍微讲一下一个协程池只绑定一个任务函数的细节:

上码!

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
go复制代码type pf func(interface{}) error

// PoolWithFunc accept the tasks from client,it limits the total
// of goroutines to a given number by recycling goroutines.
type PoolWithFunc struct {
// capacity of the pool.
capacity int32

// running is the number of the currently running goroutines.
running int32

// freeSignal is used to notice pool there are available
// workers which can be sent to work.
freeSignal chan sig

// workers is a slice that store the available workers.
workers []*WorkerWithFunc

// release is used to notice the pool to closed itself.
release chan sig

// lock for synchronous operation
lock sync.Mutex

// pf is the function for processing tasks
poolFunc pf

once sync.Once
}

PoolWithFunc struct中的大部分字段和Pool struct基本一致,重点关注poolFunc pf,这是一个函数类型,也就是该Pool绑定的指定任务函数,而client提交到这种类型的Pool的数据就不再是一个任务函数task f了,而是poolFunc pf任务函数的形参,然后交由WorkerWithFunc处理:

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
go复制代码// WorkerWithFunc is the actual executor who runs the tasks,
// it starts a goroutine that accepts tasks and
// performs function calls.
type WorkerWithFunc struct {
// pool who owns this worker.
pool *PoolWithFunc

// args is a job should be done.
args chan interface{}
}

// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *WorkerWithFunc) run() {
go func() {
for args := range w.args {
if args == nil || len(w.pool.release) > 0 {
atomic.AddInt32(&w.pool.running, -1)
return
}
w.pool.poolFunc(args)
w.pool.putWorker(w)
}
}()
}

上面的源码可以看到WorkerWithFunc是一个类似Worker的结构,只不过监听的是函数的参数队列,每接收到一个参数包,就直接调用PoolWithFunc绑定好的任务函数poolFunc pf任务函数执行任务,接下来的流程就和Worker是一致的了,执行完任务后就把worker放回协程池,等待下次使用。

至于其他逻辑如提交task、获取Worker绑定任务等基本复用自Pool struct,具体细节有细微差别,但原理一致,万变不离其宗,有兴趣的同学可以看我在github上的源码:Goroutine Pool协程池 ants 。

Benchmarks

吹了这么久的Goroutine Pool,那都是虚的,理论上池化可以复用goroutine,提升性能节省内存,没有benchmark数据之前,好像也不能服众哈!所以,本章就来进行一次实测,验证一下再大规模goroutine并发的场景下,Goroutine Pool的表现是不是真的比原生Goroutine并发更好!

测试机器参数:

1
2
3
apache复制代码OS : macOS High Sierra
Processor : 2.7 GHz Intel Core i5
Memory : 8 GB 1867 MHz DDR3

Pool测试

测试代码传送门

测试结果: 这里为了模拟大规模goroutine的场景,两次测试的并发次数分别是100w和1000w,前两个测试分别是执行100w个并发任务不使用Pool和使用了ants的Goroutine Pool的性能,后两个则是1000w个任务下的表现,可以直观的看出在执行速度和内存使用上,ants的Pool都占有明显的优势。100w的任务量,使用ants,执行速度与原生goroutine相当甚至略快,但只实际使用了不到5w个goroutine完成了全部任务,且内存消耗仅为原生并发的40%;而当任务量达到1000w,优势则更加明显了:用了70w左右的goroutine完成全部任务,执行速度比原生goroutine提高了100%,且内存消耗依旧保持在不使用Pool的40%左右。

PoolWithFunc测试

测试代码传送门

测试结果:

  • Benchmarkxxx-4 格式为基准测试函数名-GOMAXPROCS,后面的-4代表测试函数运行时对应的CPU核数
  • 1 表示执行的次数
  • xx ns/op 表示每次的执行时间
  • xx B/op 表示每次执行分配的总字节数(内存消耗)
  • xx allocs/op 表示每次执行发生了多少次内存分配

因为PoolWithFunc这个Pool只绑定一个任务函数,也即所有任务都是运行同一个函数,所以相较于Pool对原生goroutine在执行速度和内存消耗的优势更大,上面的结果可以看出,执行速度可以达到原生goroutine的300%,而内存消耗的优势已经达到了两位数的差距,原生goroutine的内存消耗达到了ants的35倍且原生goroutine的每次执行的内存分配次数也达到了ants45倍,1000w的任务量,ants的初始分配容量是5w,因此它完成了所有的任务依旧只使用了5w个goroutine!事实上,ants的Goroutine
Pool的容量是可以自定义的,也就是说使用者可以根据不同场景对这个参数进行调优直至达到最高性能。

吞吐量测试

上面的benchmarks出来以后,我当时的内心是这样的:

但是太顺利反而让我疑惑,因为结合我过去这20几年的坎坷人生来看,事情应该不会这么美好才对,果不其然,细细一想,虽然ants Groutine Pool能在大规模并发下执行速度和内存消耗都对原生goroutine占有明显优势,但前面的测试demo相信大家注意到了,里面使用了WaitGroup,也就是用来对goroutine同步的工具,所以上面的benchmarks中主进程会等待所有子goroutine完成任务后才算完成一次性能测试,然而又有多少场景是单台机器需要扛100w甚至1000w同步任务的?基本没有啊!结果就是造出了屠龙刀,可是世界上没有龙啊!也是无情…

彼时,我内心变成了这样: 幸好,ants在同步批量任务方面有点曲高和寡,但是如果是异步批量任务的场景下,就有用武之地了,也就是说,在大批量的任务无须同步等待完成的情况下,可以再测一下ants和原生goroutine并发的性能对比,这个时候的性能对比也即是吞吐量对比了,就是在相同大规模数量的请求涌进来的时候,ants和原生goroutine谁能用更快的速度、更少的内存『吞』完这些请求。

测试代码传送门

测试结果:

10w 吞吐量

100w 吞吐量

1000W 吞吐量

因为在我的电脑上测试1000w吞吐量的时候原生goroutine已经到了极限,因此程序直接把电脑拖垮了,无法正常测试了,所以1000w吞吐的测试数据只有antsPool的。

从该demo测试吞吐性能对比可以看出,使用ants的吞吐性能相较于原生goroutine可以保持在26倍的性能压制,而内存消耗则可以达到1020倍的节省优势。

总结

至此,一个高性能的 Goroutine Pool 开发就完成了,事实上,原理不难理解,总结起来就是一个『复用』,具体落实到代码细节就是锁同步、原子操作、channel通信等这些技巧的使用,ant这整个项目没有借助任何第三方的库,用golang的标准库就完成了所有功能,因为本身golang的语言原生库已经足够优秀,很多时候开发golang项目的时候是可以保持轻量且高性能的,未必事事需要借助第三方库。

关于ants的价值,其实前文也提及过了,ants在大规模的异步&同步批量任务处理都有着明显的性能优势(特别是异步批量任务),而单机上百万上千万的同步批量任务处理现实意义不大,但是在异步批量任务处理方面有很大的应用价值,所以我个人觉得,Goroutine Pool真正的价值还是在:

  1. 限制并发的goroutine数量;
  2. 复用goroutine,减轻runtime调度压力,提升程序性能;
  3. 规避过多的goroutine侵占系统资源(CPU&内存)。

后记

Go语言的三位最初的缔造者 — Rob Pike、Robert Griesemer 和 Ken Thompson 中,Robert Griesemer 参与设计了Java的HotSpot虚拟机和Chrome浏览器的JavaScript V8引擎,Rob Pike 在大名鼎鼎的bell lab侵淫多年,参与了Plan9操作系统、C编译器以及多种语言编译器的设计和实现,Ken Thompson 更是图灵奖得主、Unix之父、C语言之父。这三人在计算机史上可是元老级别的人物,特别是 Ken
Thompson ,是一手缔造了Unix和C语言计算机领域的上古大神,所以Go语言的设计哲学有着深深的Unix烙印:简单、模块化、正交、组合、pipe、功能短小且聚焦等;而令许多开发者青睐于Go的简洁、高效编程模式的原因,也正在于此。

Go语言的三个爸爸

本文从三大线程模型到Go并发调度器再到自定制的 Goroutine Pool,算是较为完整的窥探了整个Go语言并发模型的前世今生,我们也可以看到,Go的设计当然不完美,比如一直被诟病的error处理模式、不支持泛型、差强人意的包管理以及面向对象模式的过度抽象化等等,实际上没有任何一门编程语言敢说自己是完美的,还是那句话,任何不考虑应用场景和语言定位的争执都毫无意义,而Go的定位从出道开始就是系统编程语言&云计算编程语言(这个有点模糊),而Go的作者们也一直坚持的是用最简单抽象的工程化设计完成最复杂的功能,所以如果从这个层面去看Go的并发模型,就可以看出其实除了G-P-M模型中引入的
P ,并没有太多革新的原创理论,两级线程模型是早已成熟的理论,抢占式调度更不是什么新鲜的调度模式,Go的伟大之处是在于它诞生之初就是依照Go在谷歌:以软件工程为目的的语言设计而设计的,Go其实就是将这些经典的理论和技术以一种优雅高效的工程化方式组合了起来,并用简单抽象的API或语法糖开放给使用者,Go一直致力于找寻一个高性能&开发效率的双赢点,目前为止,它做得远不够完美,但足够优秀。另外Go通过引入channel与goroutine协同工作,将一种区别于锁&原子操作的并发编程模式
— CSP 带入了Go语言,对开发人员在并发编程模式上的思考有很大的启发。

从本文中对Go调度器的分析以及antsGoroutine Pool 的设计与实现过程,对Go的并发模型做了一次解构和优化思考,在ants中的代码实现对锁同步、原子操作、channel通信的使用也做了一次较为全面的实践,希望对Gopher们在Go语言并发模型与并发编程的理解上能有所裨益。

感谢阅读。

参考

  • Go并发编程实战(第2版)
  • Go语言学习笔记
  • go-coding-in-go-way
  • 也谈goroutine调度器
  • [The Go scheduler]

本文由 潘建锋 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为: 2018/06/19 02:22

本文转载自: 掘金

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

智能合约开发 - 如何实现一键化登录 Dapp

发表于 2018-06-18

任何有帐户体系的网站和 app 都会有自己的登录模块,有时候还会集成 oauth2 (weibo, weixin,github)一键化登录.开发者肯定也都或多或少的开发过注册,登录的功能。那么基于以太坊的 Dapp 中登录功能会有什么区别呢?本文主要介绍了 Dapp 帐号体系的构成,以及如何基于 Metamask 开发一键化登录的功能。

首先 Dapp 跟普通的网站(app)没多少区别,完全可以延续之前的帐号体系登录,注册。在需要用到以太坊区块链的时候(比如创建交易,支付等)调用钱包或者 MetaMask 插件即可。

当然本身以太坊就有自己的帐号,每个人都可以创建 Address 来和区块链交互,所以如果我们的 Dapp 跟 Address 能够绑定并实现登录的话,整体的体验会好很多。

解决方案是利用私钥对 payload 加密生成 signature,然后再用

ecdsa_recover 方法对 signature 解密可以拿到对应的公钥。

我们来看下具体的步骤 (默认已经安装 MetaMask)

(1)前端点击登录按钮,首先通过 web3.eth.accounts[0] 拿到 publicAddress,然后去后端拿 nonce

1
复制代码httpClient.post('/api/user', {address})

(2)后端拿到 address 后,会去数据库查询是否存在这个用户,如果有,则直接返回跟 address 对应的 nonce。没有的话,会执行一个注册用户的过程,同样返回 nonce。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
public_address = models.CharField(max_length=256)
nonce = models.IntegerField()

def user()
public_address = boby.get("address")
profiles = Profile.objects.filter(public_address=public_address.lower())

if not profiles.exists():
user = User.objects.create(username=public_address.lower())
profile = Profile.objects.create(user=user, public_address=public_address.lower(), nonce=generate_nonce())
else:
profile = profiles[0]
return profile

(3) 前端拿到 nonce 之后会去找 MetaMask 签名生成 signature

1
复制代码web3.personal.sign(nonce, public_address, callback);

(4) 最后拿 signature 和 address 到后端验证是否签名正确

1
复制代码httpClient.post('/api/login', {address, signature});

(5) 后端验证,验证成功则完成登录(基于 session 或 jwt)操作。

1
2
3
4
复制代码message_hash = defunct_hash_message(text="signature nonce:%s" % profile.nonce)
recover_address = w3.eth.account.recoverHash(message_hash, signature=signature)
if recover_address and recover_address.lower() == public_address:
user_login(request, profile.user)

这里面的 recoverHash 函数是 python web3.py 模块的。由于以太坊的椭圆曲线加密函数签名参数与比特币不太一样,所以如果用其他椭圆加解密包没法验证通过的话,可查看是不是参数设置不同。

(6) 登录成功后,前后端交互就跟普通 http 请求没什么区别了。


借用一张图来说明登录流程 (challenge 跟上文的 nonce 一致)

最后,我们其实可以看到目前这个登录方式强依赖于 MetaMask 插件,对于手机端 App 是无效的,需要有专门的钱包 app 来完成签名等功能。不过可以展望下,如果 Dapp 越来越多,那么这些基础插件会越来越丰富和完善。

参考链接:

  1. One-click Login with Blockchain: A MetaMask Tutorial
  1. amaurymartiny/login-with-metamask-demo
  1. m0t0k1ch1/metamask-login-sample

本文转载自: 掘金

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

1…889890891…956

开发者博客

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