dreamans

dreamans 查看完整档案

北京编辑  |  填写毕业院校好未来教育科技集团  |  专家研发 编辑 rsy.me 编辑
编辑

专注PHP、Go、微服务、数据库、分布式系统、算法等后端技术。

个人动态

dreamans 收藏了文章 · 8月9日

你不知道的 WebSocket

本文阿宝哥将从多个方面入手,全方位带你一起探索 WebSocket 技术。阅读完本文,你将了解以下内容:

  • 了解 WebSocket 的诞生背景、WebSocket 是什么及它的优点;
  • 了解 WebSocket 含有哪些 API 及如何使用 WebSocket API 发送普通文本和二进制数据;
  • 了解 WebSocket 的握手协议和数据帧格式、掩码算法等相关知识;
  • 了解如何实现一个支持发送普通文本的 WebSocket 服务器。

在最后的 阿宝哥有话说 环节,阿宝哥将介绍 WebSocket 与 HTTP 之间的关系、WebSocket 与长轮询有什么区别、什么是 WebSocket 心跳及 Socket 是什么等内容。

下面我们进入正题,为了让大家能够更好地理解和掌握 WebSocket 技术,我们先来介绍一下什么是 WebSocket。

一、什么是 WebSocket

1.1 WebSocket 诞生背景

早期,很多网站为了实现推送技术,所用的技术都是轮询。轮询是指由浏览器每隔一段时间向服务器发出 HTTP 请求,然后服务器返回最新的数据给客户端。常见的轮询方式分为轮询与长轮询,它们的区别如下图所示:

为了更加直观感受轮询与长轮询之间的区别,我们来看一下具体的代码:

这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而 HTTP 请求与响应可能会包含较长的头部,其中真正有效的数据可能只是很小的一部分,所以这样会消耗很多带宽资源。

比较新的轮询技术是 Comet)。这种技术虽然可以实现双向通信,但仍然需要反复发出请求。而且在 Comet 中普遍采用的 HTTP 长连接也会消耗服务器资源。

在这种情况下,HTML5 定义了 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。Websocket 使用 ws 或 wss 的统一资源标志符(URI),其中 wss 表示使用了 TLS 的 Websocket。如:

ws://echo.websocket.org
wss://echo.websocket.org

WebSocket 与 HTTP 和 HTTPS 使用相同的 TCP 端口,可以绕过大多数防火墙的限制。默认情况下,WebSocket 协议使用 80 端口;若运行在 TLS 之上时,默认使用 443 端口。

1.2 WebSocket 简介

WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。WebSocket 协议在 2011 年由 IETF 标准化为 RFC 6455,后由 RFC 7936 补充规范。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

介绍完轮询和 WebSocket 的相关内容之后,接下来我们来看一下 XHR Polling 与 WebSocket 之间的区别:

1.3 WebSocket 优点

  • 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。
  • 更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于 HTTP 请求需要等待客户端发起请求服务端才能响应,延迟明显更少。
  • 保持连接状态。与 HTTP 不同的是,WebSocket 需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。
  • 更好的二进制支持。WebSocket 定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容。
  • 可以支持扩展。WebSocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议。

由于 WebSocket 拥有上述的优点,所以它被广泛地应用在即时通信、实时音视频、在线教育和游戏等领域。对于前端开发者来说,要想使用 WebSocket 提供的强大能力,就必须先掌握 WebSocket API,下面阿宝哥带大家一起来认识一下 WebSocket API。

二、WebSocket API

在介绍 WebSocket API 之前,我们先来了解一下它的兼容性:

(图片来源:https://caniuse.com/#search=W...

从上图可知,目前主流的 Web 浏览器都支持 WebSocket,所以我们可以在大多数项目中放心地使用它。

在浏览器中要使用 WebSocket 提供的能力,我们就必须先创建 WebSocket 对象,该对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。

使用 WebSocket 构造函数,我们就能轻易地构造一个 WebSocket 对象。接下来我们将从 WebSocket 构造函数、WebSocket 对象的属性、方法及 WebSocket 相关的事件四个方面来介绍 WebSocket API,首先我们从 WebSocket 的构造函数入手:

2.1 构造函数

WebSocket 构造函数的语法为:

const myWebSocket = new WebSocket(url [, protocols]);

相关参数说明如下:

  • url:表示连接的 URL,这是 WebSocket 服务器将响应的 URL。
  • protocols(可选):一个协议字符串或者一个包含协议字符串的数组。这些字符串用于指定子协议,这样单个服务器可以实现多个 WebSocket 子协议。比如,你可能希望一台服务器能够根据指定的协议(protocol)处理不同类型的交互。如果不指定协议字符串,则假定为空字符串。

当尝试连接的端口被阻止时,会抛出 SECURITY_ERR 异常。

2.2 属性

WebSocket 对象包含以下属性:

每个属性的具体含义如下:

  • binaryType:使用二进制的数据类型连接。
  • bufferedAmount(只读):未发送至服务器的字节数。
  • extensions(只读):服务器选择的扩展。
  • onclose:用于指定连接关闭后的回调函数。
  • onerror:用于指定连接失败后的回调函数。
  • onmessage:用于指定当从服务器接受到信息时的回调函数。
  • onopen:用于指定连接成功后的回调函数。
  • protocol(只读):用于返回服务器端选中的子协议的名字。
  • readyState(只读):返回当前 WebSocket 的连接状态,共有 4 种状态:

    • CONNECTING — 正在连接中,对应的值为 0;
    • OPEN — 已经连接并且可以通讯,对应的值为 1;
    • CLOSING — 连接正在关闭,对应的值为 2;
    • CLOSED — 连接已关闭或者没有连接成功,对应的值为 3。
  • url(只读):返回值为当构造函数创建 WebSocket 实例对象时 URL 的绝对路径。

2.3 方法

  • close([code[, reason]]):该方法用于关闭 WebSocket 连接,如果连接已经关闭,则此方法不执行任何操作。
  • send(data):该方法将需要通过 WebSocket 链接传输至服务器的数据排入队列,并根据所需要传输的数据的大小来增加 bufferedAmount 的值 。若数据无法传输(比如数据需要缓存而缓冲区已满)时,套接字会自行关闭。

2.4 事件

使用 addEventListener() 或将一个事件监听器赋值给 WebSocket 对象的 oneventname 属性,来监听下面的事件。

  • close:当一个 WebSocket 连接被关闭时触发,也可以通过 onclose 属性来设置。
  • error:当一个 WebSocket 连接因错误而关闭时触发,也可以通过 onerror 属性来设置。
  • message:当通过 WebSocket 收到数据时触发,也可以通过 onmessage 属性来设置。
  • open:当一个 WebSocket 连接成功时触发,也可以通过 onopen 属性来设置。

介绍完 WebSocket API,我们来举一个使用 WebSocket 发送普通文本的示例。

2.5 发送普通文本

在以上示例中,我们在页面上创建了两个 textarea,分别用于存放 待发送的数据服务器返回的数据。当用户输入完待发送的文本之后,点击 发送 按钮时会把输入的文本发送到服务端,而服务端成功接收到消息之后,会把收到的消息原封不动地回传到客户端。

// const socket = new WebSocket("ws://echo.websocket.org");
// const sendMsgContainer = document.querySelector("#sendMessage");
function send() {
  const message = sendMsgContainer.value;
  if (socket.readyState !== WebSocket.OPEN) {
    console.log("连接未建立,还不能发送消息");
    return;
  }
  if (message) socket.send(message);
}

当然客户端接收到服务端返回的消息之后,会把对应的文本内容保存到 接收的数据 对应的 textarea 文本框中。

// const socket = new WebSocket("ws://echo.websocket.org");
// const receivedMsgContainer = document.querySelector("#receivedMessage");    
socket.addEventListener("message", function (event) {
  console.log("Message from server ", event.data);
  receivedMsgContainer.value = event.data;
});

为了更加直观地理解上述的数据交互过程,我们使用 Chrome 浏览器的开发者工具来看一下相应的过程:

以上示例对应的完整代码如下所示:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebSocket 发送普通文本示例</title>
    <style>
      .block {
        flex: 1;
      }
    </style>
  </head>
  <body>
    <h3>阿宝哥:WebSocket 发送普通文本示例</h3>
    <div style="display: flex;">
      <div class="block">
        <p>即将发送的数据:<button onclick="send()">发送</button></p>
        <textarea id="sendMessage" rows="5" cols="15"></textarea>
      </div>
      <div class="block">
        <p>接收的数据:</p>
        <textarea id="receivedMessage" rows="5" cols="15"></textarea>
      </div>
    </div>

    <script>
      const sendMsgContainer = document.querySelector("#sendMessage");
      const receivedMsgContainer = document.querySelector("#receivedMessage");
      const socket = new WebSocket("ws://echo.websocket.org");

      // 监听连接成功事件
      socket.addEventListener("open", function (event) {
        console.log("连接成功,可以开始通讯");
      });

      // 监听消息
      socket.addEventListener("message", function (event) {
        console.log("Message from server ", event.data);
        receivedMsgContainer.value = event.data;
      });

      function send() {
        const message = sendMsgContainer.value;
        if (socket.readyState !== WebSocket.OPEN) {
          console.log("连接未建立,还不能发送消息");
          return;
        }
        if (message) socket.send(message);
      }
    </script>
  </body>
</html>

其实 WebSocket 除了支持发送普通的文本之外,它还支持发送二进制数据,比如 ArrayBuffer 对象、Blob 对象或者 ArrayBufferView 对象:

const socket = new WebSocket("ws://echo.websocket.org");
socket.onopen = function () {
  // 发送UTF-8编码的文本信息
  socket.send("Hello Echo Server!");
  // 发送UTF-8编码的JSON数据
  socket.send(JSON.stringify({ msg: "我是阿宝哥" }));
  
  // 发送二进制ArrayBuffer
  const buffer = new ArrayBuffer(128);
  socket.send(buffer);
  
  // 发送二进制ArrayBufferView
  const intview = new Uint32Array(buffer);
  socket.send(intview);

  // 发送二进制Blob
  const blob = new Blob([buffer]);
  socket.send(blob);
};

以上代码成功运行后,通过 Chrome 开发者工具,我们可以看到对应的数据交互过程:

下面阿宝哥以发送 Blob 对象为例,来介绍一下如何发送二进制数据。

Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示不可变的类似文件对象的原始数据。

对 Blob 感兴趣的小伙伴,可以阅读 “你不知道的 Blob” 这篇文章。

2.6 发送二进制数据

在以上示例中,我们在页面上创建了两个 textarea,分别用于存放 待发送的数据服务器返回的数据。当用户输入完待发送的文本之后,点击 发送 按钮时,我们会先获取输入的文本并把文本包装成 Blob 对象然后发送到服务端,而服务端成功接收到消息之后,会把收到的消息原封不动地回传到客户端。

当浏览器接收到新消息后,如果是文本数据,会自动将其转换成 DOMString 对象,如果是二进制数据或 Blob 对象,会直接将其转交给应用,由应用自身来根据返回的数据类型进行相应的处理。

数据发送代码

// const socket = new WebSocket("ws://echo.websocket.org");
// const sendMsgContainer = document.querySelector("#sendMessage");
function send() {
  const message = sendMsgContainer.value;
  if (socket.readyState !== WebSocket.OPEN) {
    console.log("连接未建立,还不能发送消息");
    return;
  }
  const blob = new Blob([message], { type: "text/plain" });
  if (message) socket.send(blob);
  console.log(`未发送至服务器的字节数:${socket.bufferedAmount}`);
}

当然客户端接收到服务端返回的消息之后,会判断返回的数据类型,如果是 Blob 类型的话,会调用 Blob 对象的 text() 方法,获取 Blob 对象中保存的 UTF-8 格式的内容,然后把对应的文本内容保存到 接收的数据 对应的 textarea 文本框中。

数据接收代码

// const socket = new WebSocket("ws://echo.websocket.org");
// const receivedMsgContainer = document.querySelector("#receivedMessage");
socket.addEventListener("message", async function (event) {
  console.log("Message from server ", event.data);
  const receivedData = event.data;
  if (receivedData instanceof Blob) {
    receivedMsgContainer.value = await receivedData.text();
  } else {
    receivedMsgContainer.value = receivedData;
  }
 });

同样,我们使用 Chrome 浏览器的开发者工具来看一下相应的过程:

通过上图我们可以很明显地看到,当使用发送 Blob 对象时,Data 栏位的信息显示的是 Binary Message,而对于发送普通文本来说,Data 栏位的信息是直接显示发送的文本消息。

以上示例对应的完整代码如下所示:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebSocket 发送二进制数据示例</title>
    <style>
      .block {
        flex: 1;
      }
    </style>
  </head>
  <body>
    <h3>阿宝哥:WebSocket 发送二进制数据示例</h3>
    <div style="display: flex;">
      <div class="block">
        <p>待发送的数据:<button onclick="send()">发送</button></p>
        <textarea id="sendMessage" rows="5" cols="15"></textarea>
      </div>
      <div class="block">
        <p>接收的数据:</p>
        <textarea id="receivedMessage" rows="5" cols="15"></textarea>
      </div>
    </div>

    <script>
      const sendMsgContainer = document.querySelector("#sendMessage");
      const receivedMsgContainer = document.querySelector("#receivedMessage");
      const socket = new WebSocket("ws://echo.websocket.org");

      // 监听连接成功事件
      socket.addEventListener("open", function (event) {
        console.log("连接成功,可以开始通讯");
      });

      // 监听消息
      socket.addEventListener("message", async function (event) {
        console.log("Message from server ", event.data);
        const receivedData = event.data;
        if (receivedData instanceof Blob) {
          receivedMsgContainer.value = await receivedData.text();
        } else {
          receivedMsgContainer.value = receivedData;
        }
      });

      function send() {
        const message = sendMsgContainer.value;
        if (socket.readyState !== WebSocket.OPEN) {
          console.log("连接未建立,还不能发送消息");
          return;
        }
        const blob = new Blob([message], { type: "text/plain" });
        if (message) socket.send(blob);
        console.log(`未发送至服务器的字节数:${socket.bufferedAmount}`);
      }
    </script>
  </body>
</html>

可能有一些小伙伴了解完 WebSocket API 之后,觉得还不够过瘾。下面阿宝哥将带大家来实现一个支持发送普通文本的 WebSocket 服务器。

三、手写 WebSocket 服务器

在介绍如何手写 WebSocket 服务器前,我们需要了解一下 WebSocket 连接的生命周期。

从上图可知,在使用 WebSocket 实现全双工通信之前,客户端与服务器之间需要先进行握手(Handshake),在完成握手之后才能开始进行数据的双向通信。

握手是在通信电路创建之后,信息传输开始之前。握手用于达成参数,如信息传输率,字母表,奇偶校验,中断过程,和其他协议特性。 握手有助于不同结构的系统或设备在通信信道中连接,而不需要人为设置参数。

既然握手是 WebSocket 连接生命周期的第一个环节,接下来我们就先来分析 WebSocket 的握手协议。

3.1 握手协议

WebSocket 协议属于应用层协议,它依赖于传输层的 TCP 协议。WebSocket 通过 HTTP/1.1 协议的 101 状态码进行握手。为了创建 WebSocket 连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为 “握手”(Handshaking)。

利用 HTTP 完成握手有几个好处。首先,让 WebSocket 与现有 HTTP 基础设施兼容:使得 WebSocket 服务器可以运行在 80 和 443 端口上,这通常是对客户端唯一开放的端口。其次,让我们可以重用并扩展 HTTP 的 Upgrade 流,为其添加自定义的 WebSocket 首部,以完成协商。

下面我们以前面已经演示过的发送普通文本的例子为例,来具体分析一下握手过程。

3.1.1 客户端请求
GET ws://echo.websocket.org/ HTTP/1.1
Host: echo.websocket.org
Origin: file://
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: Zx8rNEkBE4xnwifpuh8DHQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
备注:已忽略部分 HTTP 请求头

字段说明

  • Connection 必须设置 Upgrade,表示客户端希望连接升级。
  • Upgrade 字段必须设置 websocket,表示希望升级到 WebSocket 协议。
  • Sec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均应当弃用。
  • Sec-WebSocket-Key 是随机的字符串,服务器端会用这些数据来构造出一个 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一个特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为 “Sec-WebSocket-Accept” 头的值,返回给客户端。如此操作,可以尽量避免普通 HTTP 请求被误认为 WebSocket 协议。
  • Sec-WebSocket-Extensions 用于协商本次连接要使用的 WebSocket 扩展:客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一个或多个扩展。
  • Origin 字段是可选的,通常用来表示在浏览器中发起此 WebSocket 连接所在的页面,类似于 Referer。但是,与 Referer 不同的是,Origin 只包含了协议和主机名称。
3.1.2 服务端响应
HTTP/1.1 101 Web Socket Protocol Handshake ①
Connection: Upgrade ②
Upgrade: websocket ③
Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④
备注:已忽略部分 HTTP 响应头
  • ① 101 响应码确认升级到 WebSocket 协议。
  • ② 设置 Connection 头的值为 "Upgrade" 来指示这是一个升级请求。HTTP 协议提供了一种特殊的机制,这一机制允许将一个已建立的连接升级成新的、不相容的协议。
  • ③ Upgrade 头指定一项或多项协议名,按优先级排序,以逗号分隔。这里表示升级为 WebSocket 协议。
  • ④ 签名的键值验证协议支持。

介绍完 WebSocket 的握手协议,接下来阿宝哥将使用 Node.js 来开发我们的 WebSocket 服务器。

3.2 实现握手功能

要开发一个 WebSocket 服务器,首先我们需要先实现握手功能,这里阿宝哥使用 Node.js 内置的 http 模块来创建一个 HTTP 服务器,具体代码如下所示:

const http = require("http");

const port = 8888;
const { generateAcceptValue } = require("./util");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("大家好,我是阿宝哥。感谢你阅读“你不知道的WebSocket”");
});

server.on("upgrade", function (req, socket) {
  if (req.headers["upgrade"] !== "websocket") {
    socket.end("HTTP/1.1 400 Bad Request");
    return;
  }
  // 读取客户端提供的Sec-WebSocket-Key
  const secWsKey = req.headers["sec-websocket-key"];
  // 使用SHA-1算法生成Sec-WebSocket-Accept
  const hash = generateAcceptValue(secWsKey);
  // 设置HTTP响应头
  const responseHeaders = [
    "HTTP/1.1 101 Web Socket Protocol Handshake",
    "Upgrade: WebSocket",
    "Connection: Upgrade",
    `Sec-WebSocket-Accept: ${hash}`,
  ];
  // 返回握手请求的响应信息
  socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");
});

server.listen(port, () =>
  console.log(`Server running at http://localhost:${port}`)
);

在以上代码中,我们首先引入了 http 模块,然后通过调用该模块的 createServer() 方法创建一个 HTTP 服务器,接着我们监听 upgrade 事件,每次服务器响应升级请求时就会触发该事件。由于我们的服务器只支持升级到 WebSocket 协议,所以如果客户端请求升级的协议非 WebSocket 协议,我们将会返回 “400 Bad Request”。

当服务器接收到升级为 WebSocket 的握手请求时,会先从请求头中获取 “Sec-WebSocket-Key” 的值,然后把该值加上一个特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为 “Sec-WebSocket-Accept” 头的值,返回给客户端。

上述的过程看起来好像有点繁琐,其实利用 Node.js 内置的 crypto 模块,几行代码就可以搞定了:

// util.js
const crypto = require("crypto");
const MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

function generateAcceptValue(secWsKey) {
  return crypto
    .createHash("sha1")
    .update(secWsKey + MAGIC_KEY, "utf8")
    .digest("base64");
}

开发完握手功能之后,我们可以使用前面的示例来测试一下该功能。待服务器启动之后,我们只要对 “发送普通文本” 示例,做简单地调整,即把先前的 URL 地址替换成 ws://localhost:8888,就可以进行功能验证。

感兴趣的小伙们可以试试看,以下是阿宝哥本地运行后的结果:

从上图可知,我们实现的握手功能已经可以正常工作了。那么握手有没有可能失败呢?答案是肯定的。比如网络问题、服务器异常或 Sec-WebSocket-Accept 的值不正确。

下面阿宝哥修改一下 “Sec-WebSocket-Accept” 生成规则,比如修改 MAGIC_KEY 的值,然后重新验证一下握手功能。此时,浏览器的控制台会输出以下异常信息:

WebSocket connection to 'ws://localhost:8888/' failed: Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept' header value

如果你的 WebSocket 服务器要支持子协议的话,你可以参考以下代码进行子协议的处理,阿宝哥就不继续展开介绍了。

// 从请求头中读取子协议
const protocol = req.headers["sec-websocket-protocol"];
// 如果包含子协议,则解析子协议
const protocols = !protocol ? [] : protocol.split(",").map((s) => s.trim());

// 简单起见,我们仅判断是否含有JSON子协议
if (protocols.includes("json")) {
  responseHeaders.push(`Sec-WebSocket-Protocol: json`);
}

好的,WebSocket 握手协议相关的内容基本已经介绍完了。下一步我们来介绍开发消息通信功能需要了解的一些基础知识。

3.3 消息通信基础

在 WebSocket 协议中,数据是通过一系列数据帧来进行传输的。为了避免由于网络中介(例如一些拦截代理)或者一些安全问题,客户端必须在它发送到服务器的所有帧中添加掩码。服务端收到没有添加掩码的数据帧以后,必须立即关闭连接。

3.3.1 数据帧格式

要实现消息通信,我们就必须了解 WebSocket 数据帧的格式:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

可能有一些小伙伴看到上面的内容之后,就开始有点 “懵逼” 了。下面我们来结合实际的数据帧来进一步分析一下:

在上图中,阿宝哥简单分析了 “发送普通文本” 示例对应的数据帧格式。这里我们来进一步介绍一下 Payload length,因为在后面开发数据解析功能的时候,需要用到该知识点。

Payload length 表示以字节为单位的 “有效负载数据” 长度。它有以下几种情形:

  • 如果值为 0-125,那么就表示负载数据的长度。
  • 如果是 126,那么接下来的 2 个字节解释为 16 位的无符号整形作为负载数据的长度。
  • 如果是 127,那么接下来的 8 个字节解释为一个 64 位的无符号整形(最高位的 bit 必须为 0)作为负载数据的长度。

多字节长度量以网络字节顺序表示,有效负载长度是指 “扩展数据” + “应用数据” 的长度。“扩展数据” 的长度可能为 0,那么有效负载长度就是 “应用数据” 的长度。

另外,除非协商过扩展,否则 “扩展数据” 长度为 0 字节。在握手协议中,任何扩展都必须指定 “扩展数据” 的长度,这个长度如何进行计算,以及这个扩展如何使用。如果存在扩展,那么这个 “扩展数据” 包含在总的有效负载长度中。

3.3.2 掩码算法

掩码字段是一个由客户端随机选择的 32 位的值。掩码值必须是不可被预测的。因此,掩码必须来自强大的熵源(entropy),并且给定的掩码不能让服务器或者代理能够很容易的预测到后续帧。掩码的不可预测性对于预防恶意应用的作者在网上暴露相关的字节数据至关重要。

掩码不影响数据荷载的长度,对数据进行掩码操作和对数据进行反掩码操作所涉及的步骤是相同的。掩码、反掩码操作都采用如下算法:

j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
  • original-octet-i:为原始数据的第 i 字节。
  • transformed-octet-i:为转换后的数据的第 i 字节。
  • masking-key-octet-j:为 mask key 第 j 字节。

为了让小伙伴们能够更好的理解上面掩码的计算过程,我们来对示例中 “我是阿宝哥” 数据进行掩码操作。这里 “我是阿宝哥” 对应的 UTF-8 编码如下所示:

E6 88 91 E6 98 AF E9 98 BF E5 AE 9D E5 93 A5

而对应的 Masking-Key 为 0x08f6efb1,根据上面的算法,我们可以这样进行掩码运算:

let uint8 = new Uint8Array([0xE6, 0x88, 0x91, 0xE6, 0x98, 0xAF, 0xE9, 0x98, 
  0xBF, 0xE5, 0xAE, 0x9D, 0xE5, 0x93, 0xA5]);
let maskingKey = new Uint8Array([0x08, 0xf6, 0xef, 0xb1]);
let maskedUint8 = new Uint8Array(uint8.length);

for (let i = 0, j = 0; i < uint8.length; i++, j = i % 4) {
  maskedUint8[i] = uint8[i] ^ maskingKey[j];
}

console.log(Array.from(maskedUint8).map(num=>Number(num).toString(16)).join(' '));

以上代码成功运行后,控制台会输出以下结果:

ee 7e 7e 57 90 59 6 29 b7 13 41 2c ed 65 4a

上述结果与 WireShark 中的 Masked payload 对应的值是一致的,具体如下图所示:

在 WebSocket 协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。那么为什么还要引入数据掩码呢?引入数据掩码是为了防止早期版本的协议中存在的代理缓存污染攻击等问题。

了解完 WebSocket 掩码算法和数据掩码的作用之后,我们再来介绍一下数据分片的概念。

3.3.3 数据分片

WebSocket 的每条消息可能被切分成多个数据帧。当 WebSocket 的接收方收到一个数据帧时,会根据 FIN 的值来判断,是否已经收到消息的最后一个数据帧。

利用 FIN 和 Opcode,我们就可以跨帧发送消息。操作码告诉了帧应该做什么。如果是 0x1,有效载荷就是文本。如果是 0x2,有效载荷就是二进制数据。但是,如果是 0x0,则该帧是一个延续帧。这意味着服务器应该将帧的有效负载连接到从该客户机接收到的最后一个帧。

为了让大家能够更好地理解上述的内容,我们来看一个来自 MDN 上的示例:

Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!

在以上示例中,客户端向服务器发送了两条消息。第一个消息在单个帧中发送,而第二个消息跨三个帧发送。

其中第一个消息是一个完整的消息(FIN=1 且 opcode != 0x0),因此服务器可以根据需要进行处理或响应。而第二个消息是文本消息(opcode=0x1)且 FIN=0,表示消息还没发送完成,还有后续的数据帧。该消息的所有剩余部分都用延续帧(opcode=0x0)发送,消息的最终帧用 FIN=1 标记。

好的,简单介绍了数据分片的相关内容。接下来,我们来开始实现消息通信功能。

3.4 实现消息通信功能

阿宝哥把实现消息通信功能,分解为消息解析与消息响应两个子功能,下面我们分别来介绍如何实现这两个子功能。

3.4.1 消息解析

利用消息通信基础环节中介绍的相关知识,阿宝哥实现了一个 parseMessage 函数,用来解析客户端传过来的 WebSocket 数据帧。出于简单考虑,这里只处理文本帧,具体代码如下所示:

function parseMessage(buffer) {
  // 第一个字节,包含了FIN位,opcode, 掩码位
  const firstByte = buffer.readUInt8(0);
  // [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
  // 右移7位取首位,1位,表示是否是最后一帧数据
  const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
  console.log("isFIN: ", isFinalFrame);
  // 取出操作码,低四位
  /**
   * %x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片;
   * %x1:表示这是一个文本帧(text frame);
   * %x2:表示这是一个二进制帧(binary frame);
   * %x3-7:保留的操作代码,用于后续定义的非控制帧;
   * %x8:表示连接断开;
   * %x9:表示这是一个心跳请求(ping);
   * %xA:表示这是一个心跳响应(pong);
   * %xB-F:保留的操作代码,用于后续定义的控制帧。
   */
  const opcode = firstByte & 0x0f;
  if (opcode === 0x08) {
    // 连接关闭
    return;
  }
  if (opcode === 0x02) {
    // 二进制帧
    return;
  }
  if (opcode === 0x01) {
    // 目前只处理文本帧
    let offset = 1;
    const secondByte = buffer.readUInt8(offset);
    // MASK: 1位,表示是否使用了掩码,在发送给服务端的数据帧里必须使用掩码,而服务端返回时不需要掩码
    const useMask = Boolean((secondByte >>> 7) & 0x01);
    console.log("use MASK: ", useMask);
    const payloadLen = secondByte & 0x7f; // 低7位表示载荷字节长度
    offset += 1;
    // 四个字节的掩码
    let MASK = [];
    // 如果这个值在0-125之间,则后面的4个字节(32位)就应该被直接识别成掩码;
    if (payloadLen <= 0x7d) {
      // 载荷长度小于125
      MASK = buffer.slice(offset, 4 + offset);
      offset += 4;
      console.log("payload length: ", payloadLen);
    } else if (payloadLen === 0x7e) {
      // 如果这个值是126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小;
      console.log("payload length: ", buffer.readInt16BE(offset));
      // 长度是126, 则后面两个字节作为payload length,32位的掩码
      MASK = buffer.slice(offset + 2, offset + 2 + 4);
      offset += 6;
    } else {
      // 如果这个值是127,则后面的8个字节(64位)内容应该被识别成一个64位的二进制数表示数据内容大小
      MASK = buffer.slice(offset + 8, offset + 8 + 4);
      offset += 12;
    }
    // 开始读取后面的payload,与掩码计算,得到原来的字节内容
    const newBuffer = [];
    const dataBuffer = buffer.slice(offset);
    for (let i = 0, j = 0; i < dataBuffer.length; i++, j = i % 4) {
      const nextBuf = dataBuffer[i];
      newBuffer.push(nextBuf ^ MASK[j]);
    }
    return Buffer.from(newBuffer).toString();
  }
  return "";
}

创建完 parseMessage 函数,我们来更新一下之前创建的 WebSocket 服务器:

server.on("upgrade", function (req, socket) {
  socket.on("data", (buffer) => {
    const message = parseMessage(buffer);
    if (message) {
      console.log("Message from client:" + message);
    } else if (message === null) {
      console.log("WebSocket connection closed by the client.");
    }
  });
  if (req.headers["upgrade"] !== "websocket") {
    socket.end("HTTP/1.1 400 Bad Request");
    return;
  }
  // 省略已有代码
});

更新完成之后,我们重新启动服务器,然后继续使用 “发送普通文本” 的示例来测试消息解析功能。以下发送 “我是阿宝哥” 文本消息后,WebSocket 服务器输出的信息。

Server running at http://localhost:8888
isFIN:  true
use MASK:  true
payload length:  15
Message from client:我是阿宝哥

通过观察以上的输出信息,我们的 WebSocket 服务器已经可以成功解析客户端发送包含普通文本的数据帧,下一步我们来实现消息响应的功能。

3.4.2 消息响应

要把数据返回给客户端,我们的 WebSocket 服务器也得按照 WebSocket 数据帧的格式来封装数据。与前面介绍的 parseMessage 函数一样,阿宝哥也封装了一个 constructReply 函数用来封装返回的数据,该函数的具体代码如下:

function constructReply(data) {
  const json = JSON.stringify(data);
  const jsonByteLength = Buffer.byteLength(json);
  // 目前只支持小于65535字节的负载
  const lengthByteCount = jsonByteLength < 126 ? 0 : 2;
  const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;
  const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);
  // 设置数据帧首字节,设置opcode为1,表示文本帧
  buffer.writeUInt8(0b10000001, 0);
  buffer.writeUInt8(payloadLength, 1);
  // 如果payloadLength为126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小
  let payloadOffset = 2;
  if (lengthByteCount > 0) {
    buffer.writeUInt16BE(jsonByteLength, 2);
    payloadOffset += lengthByteCount;
  }
  // 把JSON数据写入到Buffer缓冲区中
  buffer.write(json, payloadOffset);
  return buffer;
}

创建完 constructReply 函数,我们再来更新一下之前创建的 WebSocket 服务器:

server.on("upgrade", function (req, socket) {
  socket.on("data", (buffer) => {
    const message = parseMessage(buffer);
    if (message) {
      console.log("Message from client:" + message);
      // 新增以下👇代码
      socket.write(constructReply({ message }));
    } else if (message === null) {
      console.log("WebSocket connection closed by the client.");
    }
  });
});

到这里,我们的 WebSocket 服务器已经开发完成了,接下来我们来完整验证一下它的功能。

从图中可知,我们的开发的简易版 WebSocket 服务器已经可以正常处理普通文本消息了。最后我们来看一下完整的代码:

custom-websocket-server.js

const http = require("http");

const port = 8888;
const { generateAcceptValue, parseMessage, constructReply } = require("./util");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("大家好,我是阿宝哥。感谢你阅读“你不知道的WebSocket”");
});

server.on("upgrade", function (req, socket) {
  socket.on("data", (buffer) => {
    const message = parseMessage(buffer);
    if (message) {
      console.log("Message from client:" + message);
      socket.write(constructReply({ message }));
    } else if (message === null) {
      console.log("WebSocket connection closed by the client.");
    }
  });
  if (req.headers["upgrade"] !== "websocket") {
    socket.end("HTTP/1.1 400 Bad Request");
    return;
  }
  // 读取客户端提供的Sec-WebSocket-Key
  const secWsKey = req.headers["sec-websocket-key"];
  // 使用SHA-1算法生成Sec-WebSocket-Accept
  const hash = generateAcceptValue(secWsKey);
  // 设置HTTP响应头
  const responseHeaders = [
    "HTTP/1.1 101 Web Socket Protocol Handshake",
    "Upgrade: WebSocket",
    "Connection: Upgrade",
    `Sec-WebSocket-Accept: ${hash}`,
  ];
  // 返回握手请求的响应信息
  socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");
});

server.listen(port, () =>
  console.log(`Server running at http://localhost:${port}`)
);

util.js

const crypto = require("crypto");

const MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

function generateAcceptValue(secWsKey) {
  return crypto
    .createHash("sha1")
    .update(secWsKey + MAGIC_KEY, "utf8")
    .digest("base64");
}

function parseMessage(buffer) {
  // 第一个字节,包含了FIN位,opcode, 掩码位
  const firstByte = buffer.readUInt8(0);
  // [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
  // 右移7位取首位,1位,表示是否是最后一帧数据
  const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
  console.log("isFIN: ", isFinalFrame);
  // 取出操作码,低四位
  /**
   * %x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片;
   * %x1:表示这是一个文本帧(text frame);
   * %x2:表示这是一个二进制帧(binary frame);
   * %x3-7:保留的操作代码,用于后续定义的非控制帧;
   * %x8:表示连接断开;
   * %x9:表示这是一个心跳请求(ping);
   * %xA:表示这是一个心跳响应(pong);
   * %xB-F:保留的操作代码,用于后续定义的控制帧。
   */
  const opcode = firstByte & 0x0f;
  if (opcode === 0x08) {
    // 连接关闭
    return;
  }
  if (opcode === 0x02) {
    // 二进制帧
    return;
  }
  if (opcode === 0x01) {
    // 目前只处理文本帧
    let offset = 1;
    const secondByte = buffer.readUInt8(offset);
    // MASK: 1位,表示是否使用了掩码,在发送给服务端的数据帧里必须使用掩码,而服务端返回时不需要掩码
    const useMask = Boolean((secondByte >>> 7) & 0x01);
    console.log("use MASK: ", useMask);
    const payloadLen = secondByte & 0x7f; // 低7位表示载荷字节长度
    offset += 1;
    // 四个字节的掩码
    let MASK = [];
    // 如果这个值在0-125之间,则后面的4个字节(32位)就应该被直接识别成掩码;
    if (payloadLen <= 0x7d) {
      // 载荷长度小于125
      MASK = buffer.slice(offset, 4 + offset);
      offset += 4;
      console.log("payload length: ", payloadLen);
    } else if (payloadLen === 0x7e) {
      // 如果这个值是126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小;
      console.log("payload length: ", buffer.readInt16BE(offset));
      // 长度是126, 则后面两个字节作为payload length,32位的掩码
      MASK = buffer.slice(offset + 2, offset + 2 + 4);
      offset += 6;
    } else {
      // 如果这个值是127,则后面的8个字节(64位)内容应该被识别成一个64位的二进制数表示数据内容大小
      MASK = buffer.slice(offset + 8, offset + 8 + 4);
      offset += 12;
    }
    // 开始读取后面的payload,与掩码计算,得到原来的字节内容
    const newBuffer = [];
    const dataBuffer = buffer.slice(offset);
    for (let i = 0, j = 0; i < dataBuffer.length; i++, j = i % 4) {
      const nextBuf = dataBuffer[i];
      newBuffer.push(nextBuf ^ MASK[j]);
    }
    return Buffer.from(newBuffer).toString();
  }
  return "";
}

function constructReply(data) {
  const json = JSON.stringify(data);
  const jsonByteLength = Buffer.byteLength(json);
  // 目前只支持小于65535字节的负载
  const lengthByteCount = jsonByteLength < 126 ? 0 : 2;
  const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;
  const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);
  // 设置数据帧首字节,设置opcode为1,表示文本帧
  buffer.writeUInt8(0b10000001, 0);
  buffer.writeUInt8(payloadLength, 1);
  // 如果payloadLength为126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小
  let payloadOffset = 2;
  if (lengthByteCount > 0) {
    buffer.writeUInt16BE(jsonByteLength, 2);
    payloadOffset += lengthByteCount;
  }
  // 把JSON数据写入到Buffer缓冲区中
  buffer.write(json, payloadOffset);
  return buffer;
}

module.exports = {
  generateAcceptValue,
  parseMessage,
  constructReply,
};

其实服务器向浏览器推送信息,除了使用 WebSocket 技术之外,还可以使用 SSE(Server-Sent Events)。它让服务器可以向客户端流式发送文本消息,比如服务器上生成的实时消息。为实现这个目标,SSE 设计了两个组件:浏览器中的 EventSource API 和新的 “事件流” 数据格式(text/event-stream)。其中,EventSource 可以让客户端以 DOM 事件的形式接收到服务器推送的通知,而新数据格式则用于交付每一次数据更新。

实际上,SSE 提供的是一个高效、跨浏览器的 XHR 流实现,消息交付只使用一个长 HTTP 连接。然而,与我们自己实现 XHR 流不同,浏览器会帮我们管理连接、 解析消息,从而让我们只关注业务逻辑。篇幅有限,关于 SSE 的更多细节,阿宝哥就不展开介绍了,对 SSE 感兴趣的小伙伴可以自行查阅相关资料。

四、阿宝哥有话说

4.1 WebSocket 与 HTTP 有什么关系

WebSocket 是一种与 HTTP 不同的协议。两者都位于 OSI 模型的应用层,并且都依赖于传输层的 TCP 协议。 虽然它们不同,但是 RFC 6455 中规定:WebSocket 被设计为在 HTTP 80 和 443 端口上工作,并支持 HTTP 代理和中介,从而使其与 HTTP 协议兼容。 为了实现兼容性,WebSocket 握手使用 HTTP Upgrade 头,从 HTTP 协议更改为 WebSocket 协议。

既然已经提到了 OSI(Open System Interconnection Model)模型,这里阿宝哥来分享一张很生动、很形象描述 OSI 模型的示意图:

(图片来源:https://www.networkingsphere....

4.2 WebSocket 与长轮询有什么区别

长轮询就是客户端发起一个请求,服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断请求的数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则等待一定的时间后才返回。

长轮询的本质还是基于 HTTP 协议,它仍然是一个一问一答(请求 — 响应)的模式。而 WebSocket 在握手成功后,就是全双工的 TCP 通道,数据可以主动从服务端发送到客户端。

4.3 什么是 WebSocket 心跳

网络中的接收和发送数据都是使用 SOCKET 进行实现。但是如果此套接字已经断开,那发送数据和接收数据的时候就一定会有问题。可是如何判断这个套接字是否还可以使用呢?这个就需要在系统中创建心跳机制。所谓 “心跳” 就是定时发送一个自定义的结构体(心跳包或心跳帧),让对方知道自己 “在线”。 以确保链接的有效性。

而所谓的心跳包就是客户端定时发送简单的信息给服务器端告诉它我还在而已。代码就是每隔几分钟发送一个固定信息给服务端,服务端收到后回复一个固定信息,如果服务端几分钟内没有收到客户端信息则视客户端断开。

在 WebSocket 协议中定义了 心跳 Ping心跳 Pong 的控制帧:

  • 心跳 Ping 帧包含的操作码是 0x9。如果收到了一个心跳 Ping 帧,那么终端必须发送一个心跳 Pong 帧作为回应,除非已经收到了一个关闭帧。否则终端应该尽快回复 Pong 帧。
  • 心跳 Pong 帧包含的操作码是 0xA。作为回应发送的 Pong 帧必须完整携带 Ping 帧中传递过来的 “应用数据” 字段。如果终端收到一个 Ping 帧但是没有发送 Pong 帧来回应之前的 Ping 帧,那么终端可以选择仅为最近处理的 Ping 帧发送 Pong 帧。此外,可以自动发送一个 Pong 帧,这用作单向心跳。

4.4 Socket 是什么

网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个 socket(套接字),因此建立网络通信连接至少要一对端口号。socket 本质是对 TCP/IP 协议栈的封装,它提供了一个针对 TCP 或者 UDP 编程的接口,并不是另一种协议。通过 socket,你可以使用 TCP/IP 协议。

Socket 的英文原义是“孔”或“插座”。作为 BSD UNIX 的进程通信机制,取后一种意思。通常也称作"套接字",用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。

在Internet 上的主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。Socket 正如其英文原义那样,像一个多孔插座。一台主机犹如布满各种插座的房间,每个插座有一个编号,有的插座提供 220 伏交流电, 有的提供 110 伏交流电,有的则提供有线电视节目。 客户软件将插头插到不同编号的插座,就可以得到不同的服务。—— 百度百科

关于 Socket,可以总结以下几点:

  • 它可以实现底层通信,几乎所有的应用层都是通过 socket 进行通信的。
  • 对 TCP/IP 协议进行封装,便于应用层协议调用,属于二者之间的中间抽象层。
  • TCP/IP 协议族中,传输层存在两种通用协议: TCP、UDP,两种协议不同,因为不同参数的 socket 实现过程也不一样。

下图说明了面向连接的协议的套接字 API 的客户端/服务器关系。

五、参考资源

查看原文

dreamans 赞了文章 · 6月23日

思否独立开发者丨@程序猿 DD:会「偷懒」的程序员头发最多

思否 ID:程序猿DD
独立项目名称:OpenWrite


“解决问题”四个字是程序猿DD 口中的高频词汇,也许是程序员的天性吧,「找出问题-制定解决办法-动手实践」这一系列动作对他来说就像条件反射。

程序员的执行力很强,他们可以把一件很麻烦的事情通过程序变得很简单,不论设计程序的过程有多少难题都乐此不疲。但程序员也很会「偷懒」,他们不会把时间浪费在复杂的事情上,能用程序解决的问题绝不「死干」。

OpenWrite 项目起初只是为了方便身边的技术自媒体的朋友一文多发,省时省力开发的,后来随着更多的平台渠道接入和功能体验的完善,开始人传人地推广,项目也就越做越大了。

程序猿DD 说:“目前 OpenWrite 目前支持的渠道偏技术,所以现在的大用户以技术自媒体和技术爱好者为主,之后会开辟更多通用渠道。”

程序猿DD 的工作台.png

项目名称:OpenWrite

立项时间:2019年下半年

项目自荐语:“如果你热衷于写文记录点滴、分享心得

如果你钟情于markdown的简洁、流畅与纯粹

如果你专注于内容创作,而对很多网络抄袭无可奈何—— 那么希望 OpenWrite 可以帮助你~”

项目目前取得了哪些成就:目前已经有 10000+ 用户信任 openwrite,vip 付费用户超过 200+,月活跃用户达 500+

项目图片:

大气登录页

image.png

渠道列表

image.png

渠道认证配置

image.png

文章编辑和微信实时预览

image.png

选择渠道进行文章扩散

image.png

插件状态和图片配置

image.png

快问快答

1.介绍一下自己

头发很多的程序员。

2.什么时候开始接触编程的,契机是什么?

第一次应该是小学时候的Logo小海龟画画,学校课程接触,感觉还挺有意思的。

3. 如何定义“独立开发者”?

我认为独立开发者通常都有这样一个特性:能用程序解决的问题绝不死干!同时,他们通常既有想法、又具备较强的动手能力。

4. 为什么选择成为一名独立开发者?

在大公司摸爬滚爬过的朋友相信都体会过,想做一件事,即使是非常小的想法,也很可能受到很大限制而无法操刀完成。当你有很多想法,又不断被环境压制的时候,总是需要一些机会去释放才能平衡的。所以,一直依赖业余时间都会将一些想法付诸行动,除了锻炼技术,也是验证自己想法的过程。就这样不知不觉,成为了一名独立开发者。

5. 独立开发过程中遇到过哪些困难?最难搞定的是什么?

由于一直以来都从事后端开发与基础架构的工作,最欠缺的技能就是前端能力,最苦恼的就是想做成啥样,但是做不出想要的效果来。所以,寻觅前端知音是个长期需求。

6. 推荐你最喜欢的一款产品 / 游戏 / App?并说明原因

不玩游戏很久了,推荐个我最常用的笔记产品:印象笔记。 虽然目前Markdown的支持不能说特别好,但是个人编辑、多端同步、多人协作等功能非常符合我个人及团队的工作习惯。我除了很多个人学习笔记之外,还有很多协作工作也都在印象笔记中完成,非常好用!

7. 分享一下你的技术栈?

Java、Spring Boot、Spring Cloud

8. 分享一下你日常的工作流?

上午看新闻、处理邮件、回顾并确认今日安排、处理具体的工作事项、下班、处理一些零散的事情。

9.有什么个人的特别的工作习惯么?

喜欢半夜干活,够静够安静够高效。

10. 日常兴趣爱好?

写博客、写代码、足球、德州、狼人杀。

11. 你小时候的梦想是什么?

没有很大的梦想,就希望每天可以做自己想做的事,然后顺便把钱赚了,不用为生活发愁。

12. 开发项目中有哪些成就感?

项目上线、数据增长、解决各种问题。

13. 对开发者们有什么建议?

不要忽略那些枯燥的基础知识,一定要多读书,眼界光了,认识会很不一定;不要光看不练,一定要多实践,坑踩多了,那些书本上的经验你才会有同感!

14. 近阶段项目有哪些更新,未来会做什么变动?

近阶段最重要的更新方向是对渠道支持的扩散,目前只要支持范围是我们开发者方向的,后续将支持更多通用渠道的文章发布,将适用更多有文章管理与发布需求的用户。再这之后的未来,更重要的则是对文章数据的分析,为用户找出更合理的发布运营路线。

15.对编程初学者和怀抱梦想对年轻人有什么建议?

不要忽略那些枯燥的基础知识,一定要多读书,眼界光了,认识会很不一定;不要光看不练,一定要多实践,坑踩多了,那些书本上的经验你才会有同感!

SegmentFault 公众号

查看原文

赞 17 收藏 3 评论 2

dreamans 赞了文章 · 6月22日

Redis 整理 一文全

Redis

Redis 基本数据类型

Redis支持5种数据类型:

  1. string(字符串)
  2. hash(哈希)
  3. list(列表)
  4. set(集合)
  5. zset(sorted set:有序集合)

String

  1. string:一个key对应一个value。
  2. string类型是二进制安全的,可以存储任何类型的数据
  3. 常用命令:get,set,incr,decr,mget等

hash

  1. hash:一个string类型的field和value的映射表
  2. hash特别适合用于存储对象
  3. 常用命令是hget,hset,hgetall。

list

  1. list:简单的字符串列表,底层实现为双向链表。
  2. 可以作为消息队列系统和取最新N个数据的操作。

set

  1. set:string类型的无序集合,通过hashtable实现。集合内不能出现重复数据。
  2. 可以交集,并集,差集等。
  3. 实现方式:set 的内部实现是一个 value永远为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。
  4. SADD key member1:添加一个或多个成员。SISMEMBER key:返回给定的集合的所有成员。

zset

  1. string类型的集合,且不允许重复的成员
  2. 常用命令zadd,zrange,zrem,zcard。
  3. 而sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。
  4. 实现方式:Redis sorted set的内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap<value,score>里放的是成员到score的映射,而跳跃表里存放的是所有的成员。

Redis高级类型

HyperLongLong

用来近似计算集合的基数(通过前导0的个数反推基数),用于计算访问量。

BitMap

bit通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身会极大的节省存储空间可以此实现BloomFilter。

GeoHash

  1. 地理位置距离排序算法:将二维的经纬度数据映射到一维的整数,这样所有的元素都将挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离会很接近。
  2. Redis中经纬度使用52位的整数进行编码,放进zset中,zset的value元素是key,score是GeoHash的52位整数值。在使用Redis进行Geo查询时,其内部对应的操作其实只是zset(skiplist)的操作。通过zset的score进行排序就可以得到坐标附近的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标。

布隆过滤器:

  1. 是一个bit数组。在要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数来生成多个哈希值并对每一个生成的哈希值指向的置为1。
  2. 特点:

    1. 高效地插入和查询,能够告诉用户"某个东西一定不存在或可能存在"
    2. 不支持删除操作。
  3. 计数删除:将bit位改为数值。每存在一个值对应的位就加1,删除时减1。

image

Redis底层结构

简单动态字符串SDS

Sds:简单动态字符串,实现字符串对象。在redis程序内部用作char*的替代品,当创建一个动态字符串时,free属性为0。

struct sdshdr {
    len;
    free;
    byte[] buf ;
}
  1. 常数复杂度获取字符串长度。
  2. SDS的API缩短SDS的字符串时,不会立即使用内存分配回收多出来的字节,而是记录在free属性中并等待将来使用。
  3. 如果新字符串总长度小于SDS_MAX_PREALLOC那么为字符串分配2倍与所需长度的空间,否则就分配所需长度加上SDS_MAX_REPEALLOC的空间。

Redis双端链表:

应用

  • 事务模块使用双端链表来按顺序保存输入的命令。
  • 服务器模块使用双端链表来保存多个客户端。
  • 订阅/发送模块使用双端链表来保存订阅模式的多个客户端。
  • 事件模块使用双端链表来保存时间事件(time event)。

实现

  1. 双端链表的表头标尾进行插入的复杂度都为o(1),是高效实现LPUSH RPOP等命令的关键。
  2. 双端链表带有len属性,所以链表的长度计算为o(1)。
  3. redis链表是无环的。
  4. 链表节点使用void *指针保存节点值,可以用于保存各种类型的值
typedef struct list{
    listNode* head;
    listNode* tail;
    unsigned long len;
    viod *(*dup)(void *ptr)//节点值复制函数
    void *(*dup)(void *ptr)//节点值释放函数
    int  *(*match)(void *ptr,void *key);//节点值对比函数
}

字典

  1. 实现数据库键空间

    • SET:设置一个字符串键到键空间
    • GET:从键空间取出该字符串的值
    • FLUSHDB:可以清空建空间上的现有的键值对
  2. Redis使用链地址法来解决键冲突
  3. Redis字典使用哈希表作为底层实现,一个哈希表有多个哈希表节点,每个哈希表节点保存了字典中的一个键值对。
typedef struct dictht{
    dictEntry ** table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
}
typedef struct dict{
    dictType *type;//保存了一组用于操作特定类型键值对的函数,
    void *privadata//需要传给类型特定函数的可选参数
    dictht ht[2];//每一项都是哈希表,一般只使用ht[0],ht[1]哈希表只会在对ht[0]进行rehash时使用
    int rehashidx;//rehash索引,当rehash不在进行时,为-1   
}

image

rehash

  1. 该过程不是一次性的,而是分多次,渐进式完成的以防止量大的时rehash对服务器对性能造成影响。分而治之,以降低算量。(该过程中,旧数据会在两个表里查询,新增会全部落入新的hash表)

    1. 为ht[1]分配空间。
    2. 将rehashidx设置为0,表示rehash正式开始。
    3. 在rehahs进行期间,每次对字典执行更新操作时候。会顺带将ht[0]上的rehash到ht[1]同时将rehashidx增一。
    4. 全部完成后rehashidx置为-1,表示rehash过程完成。将ht[1]和ht[0]交换地址。

跳跃表:有序数据结构

跳表的性质

跳表具有如下性质: 

  1. 由很多层结构组成 
  2. 每一层都是一个有序的链表 
  3. 最底层(Level 1)的链表包含所有元素 
  4. 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。 
  5. 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。
  6. Redist两处使用了跳跃表 1.实现有序集合建,集群节点内部。
  7. 插入:程序根据幂次定律(越大的数出现的概率越小,(1/2^n))随机生成一个介于1和32之间的值作为level数组的大小(高度)。
  8. 每一层都有一个前进指针,节点的后退指针用于从表尾向表头访问节点。
  9. 各个节点保存的对象必须唯一,但分值可以相同,相同的对象使用字典排序。

image
image

整数集合

  1. 当一个集合只包含整数,且元素数量不多时,Redist就使用整数集合(intset)作为集合键的底层实现。
  2. 当新添加的元素类型比现有的所有元素都长时,整数集合需先升级才能添加。升级需要对底层数组的所有元素进行类型转换,所以添加新元素的时间复杂度为o(N)。
  3. 升级的好处

    1. 提升灵活性:可以随意将不同的类型整数添加到集合,不需要担心类型错误。
    2. 节约内存:既能让集合同时保存三种不同类型的值,又能确保升级只会在需要的时候进行。
  4. 整数集合不支持降级操作。
typedef struct intset{
    uint32_t encoding;//数组每一项的类型
    uint32_t length;//记录了包含的元素数,
    int8_t contents[];
}

压缩列表

  1. 当列表键只包含少量列表项,并且每一个列表项要么是小整数,要么是长度比较短的字符串,redis使用压缩列表来实现。
  2. 当哈希键只包含少量键值对,并且每一个项键和值要么是小整数,要么是长度比较短的字符串,redis使用压缩列表来实现。
  3. 压缩列表格式如下
[zlbytes][zltail][zllen][entryx][zlend]

* zlbytes:整个压缩链表的内存字节数
* zltail:用于确认尾节点,记录压缩列表尾节点距离压缩列表的起始地址有多少字节
* zllen:记录了压缩列表包含的节点数量
* zlend:特殊值0xff,标记压缩列表的末端

每一个压缩列表的节点:
[previous_entry_length][encoding][content]
* previous_entry_length:记录了前一个节点的长度,压缩列表从表尾到表头的遍历实现
* encoding:content属性所保存数据的类型和长度

连锁更新:在极端情况(多个连续的,长度介于250字节到253字节之间)下产生的连续多长空间扩展,新增/删除节点都会引起连锁更新

redis 对象

  1. redis每创建一个键值对时,redis会分别创建两个对象,键对象,值对象.
typedef struct redisObject{
    unsigned type;//类型 字符串对象 列表对象 哈希对象 集合对象 有序集合对象
    unsigned encoding;//编码 底层使用的数据结构
    void *ptr//数据结构指针
}
  1. Object ENCODING 可以查看底层使用的结构,redist可以根据不同的场景来为一个对象设置不同的编码。

embstr编码

  1. embstr编码用于保存短字符串的一种优化编码方式,通过一次内存分配函数分配一块连续的空间,释放时也只需一次内存释放函数。
  2. embstr保存数据的连续性能够更好的利用缓存带来的优势。
  3. embstr由redisObject和sdshdr组成:

image

int编码

  1. 对于int编码的字符串对象,若某个命令使得对象保持不是整数而是字符串值,则字符串对象编码将从int变为raw。
  2. embstr编码的字符串对象在执行修改命令后,会变成一个raw编码的字符串对象。

image

raw编码

raw对象格式:需要两次内存申请

image

Redis数据类型的底层实现

String

底层使用SDS简单动态字符串实现。

hash

哈希对象的编码使用ziplist或hashtable

  1. ziplist 保存了键的压缩列表节点推入压缩列表的表尾,再将保存了值的压缩列表节点推入压缩列表的表尾,保证了同一键值对总是紧挨着的。
    1. 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节(限值可以修改)
    2. 哈希对象保存的所有键值对数量小于512个(限值可以修改)

则使用ziplist
否使用hashtable

  1. 对于ziplist编码所需两个条件任意一个不被满足时,编码会被转移并保存到字典(hashtable)里

list

列表对象的编码是ziplist(压缩列表)或者linkedlist
若:

  • 列表对象保存的所有字符串元素的长度都小于64(限值可以修改)
  • 列表对象保存的元素数据量小于512个(限值可以修改)

则使用ziplist。
否使用linkedlist。

set

集合对象的编码可以是intset或hashtable

  1. 若集合对象使用字典作为底层实现。
  2. 每一个键都是字符串对象,值对象都是NULL。
  3. 若:

    • 集合保存的所有元素都是整数值
    • 集合对象保存的元素数量不超过512个(限值可以修改)

则使用intset
否使用hashtable

  1. 对于intset编码所需两个条件任意一个不被满足时,编码会被转移并保存到字典(hashtable)里。

zset

  1. 有序集合对象的编码可以是ziplist或skiplist
  2. ziplist实现:

    1. 每一个集合元素使用两个紧挨在一起的压缩列表节点保存。
    2. 第一个保存元素成员,第二个保存元素分值。
    3. 分值较小的元素被放置在靠近表头的方向,分值较大的放置在表尾巴方向。
  3. skiplist实现:使用zset结构作为底层实现。
typedef struct zset{
    zskiplist *zsl;//按照分值从小到大保存所有的集合元素(score/value)
    dict * dict;//为有序集合创建了一个从成员到分值的映射(value->score)
}
  1. 有序集合的每一个元素的成员都是一个字符串对象,每一个元素的分值都是一个double类型的浮点数。
  2. zsl和dict使用指针共享相同元素的成员和分值避免内存浪费。
  3. 若:

    1. 有序集合的元素数量少于128个(限值可以修改)
2.有序集合保存的所有元素成员的长度都小于64(限值可以修改)

则使用ziplist
否使用skiplist
对于ziplist编码所需两个条件任意一个不被满足时,编码会被转移并保存到skiplist结构里

Redis单机操作

Redis命令分类:

Redis命令分为两种类型

  1. 一种可以对任何类型的键执行:DEL EXPIRE RENAME TYPE OBJECT
  2. 一种只能对特定类型的键执行:

    • SET GET APPEND STRLEN 只能对字符串键执行
    • HDEL HSET HGET HLEN 只能对哈性键执行
    • RPUSH LPOP LINSERT LLEN 只能对列表键执行
    • SADD SPOP SINTER SCARD 只能对集键执行
    • ZADD ZCARD ZRANK ZSCORE 只能对有序集合键执行
  3. 类型特定命令所进行的类型检查是通过RedistObject的type属性来完成的

redis:设置过期时间

  1. EXPIRE <key> <ttl> 将键key的生存时间设置为ttl秒
  2. PEXPIRE <key> <ttl> 将键key的生存时间设置为毫秒
  3. EXPIREAT <key> <ttl> 将键key的生存时间设置为timestamp所指定时间(秒)
  4. PEXPIREAT <key> <ttl> 将键key的生存时间设置为timestamp所指定时间(毫秒)(所有命令的基础实现)
  5. PERSIST 移除一个键的过期时间

redisDB的expires字典保存了数据库中所有键的过期时间:

  1. 过期字典的键是一个指针,指向键空间的某个键对象。
  2. 过期字典的值是一个long long类型的整数,用于保存过期时间。

过期删除策略

定时删除

创建一个定时器,在过期时间来临时,立即删除(对cpu不好,服务器应该优先处理客户端的请求)

惰性删除

每次从键空间获取键的时候删除(对内存不友好)

定期删除

每隔一段时间,程序就对象数据库进行一次检查:每隔一段时间执行一次删除过期键操作,并限制操作执行的频率和时间来减少对cpu的影响,随机挑选100个设置了过期时间的key,若删除超过25个,则在合适时期再进行一波删除

Redis采用的过期策略

惰性删除+定期删除。

内存回收

Redis使用引用计数技术实现内存回收,每一个对象的计数信息由redisObject的refcount记录。

内存淘汰算法

redis的内存占用过多的时候,需要使用某种淘汰算法来决定清理掉哪些数据。常用的淘汰算法有下面几种:

  • FIFO:先进先出。判断被存储的时间,离目前最远的数据优先被淘汰。
  • LRU:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。
  • LFU:最不经常使用。在一段时间内,数据被使用次数最少的,优先被淘汰。

对象的空转时长

  1. redisObject结构包含的lru用于记录对象最后一次被命令程序访问的时间。
  2. OBJECT IDLETIME 命令可以打印出给定键的空转时长,该命令访问对象时不会修改LRU属性。

Redis事务

redis通过MULTI,EXEC,WATCH等命令来实现事务功能。

  • MULTI:事务的开始,所有的命令都会进入事务队列。
  • EXEC:服务器遍历执行队列中保存的所有命令。
  • WATCH:乐观锁,在EXEC命令执行之前用来监视任意数量的数据库键,并在执行EXEC命令执行时,检查被监视的键是否至少一个已经被执行过了。若是,服务器拒绝执行事务(每一个Redis数据库都保存着一个watched_keys字典)。

Redis 发布和订阅

  1. 发布和订阅命令:PUBLISH SUBSCRIBE PSUBSCRIBE
  2. 发布者和订阅者都是Redis客户端,
  3. Redis将所有频道的订阅关系都保在服务器状态的pubsub_channels字典。
  4. Redis将所有模式的订阅关系保存在服务器状态的pubsub_patterns属性。
  5. 消息发送时,根据pubsub_channels和pubsub_patterns的信息来发送。

其他

  1. 对象的生命周期:创建对象,操作对象,释放对象
  2. Redis在初始化服务器时,创建一万个字符串对象。包含了0到9999的所有整数值,当服务器需要时就直接使用。
  3. Redis不共享包含字符串的对象只共享包含整数值的字符串对象
  4. 初始化服务器时,程序会根据服务器的dbnum属性来决定创建数据库的数量。Redist默认数据库为0号,通过SELECT来切换目标数据库。

Redis集群

Redis持久化

RDB持久化

  1. SAVE:阻塞Redis服务器,直到RDB文件创建完毕。在阻塞期间服务器不能处理任何命令。
  2. BGSAVE:后台子进程负责创建RDB文件。
  3. RDB的载入:是服务器启动时自动执行的,只要redis服务检测到RDB文件的存在就会自动载入RDB。

AOF持久化:

  1. 通过保存Redis服务器所执行的写命令来记录数据库的状态。
  2. 被写入AOF文件的命令都是纯文本格式。
  3. AOF实现分为命令追加、文件写入、文件同步三个步骤。

    1. 命令追加:服务器执行完一个命令后,以协议的方式追加到服务器状态的aof_buf缓冲区。
    2. 文件写入与同步:服务器每次结束一个事件循环之前会考虑是否将aof_buf内容写入。
  4. Redis读取AOF文件并还原数据库的状态步骤:

    1. 创建一个不带网络连接的伪客户端(Redis只能在客户端上下文中执行)
    2. 从AOF文件中分析并读取一条写命令,使用伪客户端执行
    3. 一直重复步骤2和步骤3
  5. AOF重写:为了解决AOF文件体积膨胀问题,Redis会直接读取服务器当前的数据库状态来创建一个新的AOF文件去替换老的AOF文件。在重写过程中,新的写命令会被保存到缓冲区。

Redis主从模式

主从同步步骤

  1. 从服务器向主服务器发送SYNC命令。
  2. 收到SYNC命令后,主服务器执行BGSAVE生成一个RDB文件。
  3. 主服务器将RDB文件传送给从服务器,从服务器接收并载入这个RDB文件。
  4. 主服务器将记录到缓冲区的所有命令发给从服务器。

同步后的数据一致

  1. 传播:主服务器将写命令发送给从服务器执行。从服务器默认每秒一次的频率向主服务器发送自己的复制偏移量(心跳检测)。
  2. 完整重同步:主服务器创建并发送RDB文件。
  3. 部分重同步:用于处理断线后复制,主服务将断开期间的写命令发送到从服务器。

    1. 主从服务器分别维护一个复制偏移量,如果处于一致状态,主从的偏移量相同。
    2. 复制积压缓冲区:主服务器维护的一个固定长度的FIFO队列保存一部分最近传播的写命令。

Sentinel(哨兵)模式

  1. Sentinel(哨兵):可以检测任意多个主服务器和主服务器下的从服务器。被监控的主服务器进入下线状态时自动将下线的主服务器下的某个从服务器升级为新的主服务器。
  2. Sentinel模式是运行在一个特殊模式下的Redis服务器。它默认每10秒向主服务器发送INFO命令来获取服务器的当前信息。
  3. 一个Sentinel可以通过分析接收到的频道信息来获取其他Sentinel的存在,监视同一个服务器的多个Sentinel可以自动发现对方,各个Sentinel形成网络连接。

故障判定

  1. Sentinel以每秒一次的频率向所有与它创建了命令链接的实例(主服务器、从服务器、Sentinel)发送ping命令,检测其是否在线。如不在线则判定位主观下线。
  2. 当一个主服务器被判断为主观下线后,Sentinel会询问其他Sentinel看它们是否也认为主服务器已经进入了下线状态。收到足够多的已下线判断后,Sentinel就将服务器判定为客观下线然后执行故障转移。
  3. 当一个主服务器被判为客观下线后,监视这个下线服务器的各个Sentinel会进行协商选举一个领头Sentinel。并由领头Sentinel对下线服务器进行故障转移。

领头Sentinel选举

概述
  1. 发现主服务器下线的哨兵具有被选举权,要求其他哨兵为自己投票。
  2. 每个哨兵都只有一票。
  3. 当某个哨兵拥有半票以上,这哨兵将成为领头哨兵并进行故障迁移。
规则
  1. 所有在线Sentinel都有被选举的资格
  2. 每次进行选举后不论是否成功,所有的Sentinel配置纪元都会自增一。
  3. 每一个纪元内的所有Sentinel都有一次机会成为局部领头Sentinel
  4. 若给定时限内没有一个Sentinel被选举为Sentinel,那么各个Sentinel将在一段时间之后再次进行选举。
过程
  1. 每一个发现主服务器进入客观下线的Sentinel都会要求其他Seninel将自己设置为局部领头Sentinel。
  2. 最先向目标Sentinel发送设置要求的源将成为目标Sentinel的局部领头Sentinel之后的所有接收的设置要求都会被拒绝。
  3. 如果某个Sentinel被半数以上Sentinel设置成为了局部领头Sentinel,那么这个Sentinel将成为领头Sentinel。

故障转移

  1. 领头Sentinel会将已下线的主服务器的所有从服务器保存到一个列表里,然后按照以下规则,一项一项的过滤

    1. 删除列表中处于下线或者断线状态的从服务器
    2. 删除列表中最近5秒没有回复领头Sentinel的INFO命令的从服务器
    3. 删除所有与已下线主服务器连接断开超过一定时间的从服务器
  2. 从剩余的从服务器中按照优先级进行排序,然后选出其中偏移量最大的从服务器,再按照运行ID选择运行ID最小的从服务器成为新的主服务器。

Redis集群模式

  1. 集群的整个数据库被分为16384个槽。
  2. 每个节点可以处理0个或最多16384个槽
  3. 若16384个槽都有节点处理时集群处于上线状态,否则处于下线状态。
  4. 一个节点就是一个运行在集群模式下的Redis服务器
  5. Redis集群的节点分为主节点和从节点。主节点用于处理槽,从节点用于复制主节点。
  6. 集群中的各个节点会互相发送消息的方式来交换各个节点的状态消息,在线、疑似下线、已经下线。若半个节点都认为某个节点疑似下线,那么这个节点就被标记为已下线。
  7. 节点使用 CRC16(key) % 16386 指定槽。若发现键所在的槽并非自己负责处理时,节点就会向客户端返回一个MOVED错误指引客户端转向至正在负责槽的节点。
  8. 节点和单机服务器在数据库方面的一个区别是:节点只使用0号数据库而Redis服务器没有这一规则。

集群实现

  1. 每一个节点会使用一个ClusterNode结构记录自己的状态,然后为其他的节点都创建一个相应的clusterNode。每个节点都和其它节点通过互ping保持连接,每个节点保存整个集群的状态信息,可以通过连接任意节点读取或者写入数据。
  2. ClusterNode结构保存了一个节点的当前状态、节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号。
  3. ClusterNode的link属性是一个clusterLink结构。该结构保存了连接节点所需的有关消息:套接字描述,输入缓冲区,输出缓冲区。
  4. clusterState结构记录了当前视角下集群所处的状态、集群上线下线状态、集群包含的节点数。

新节点加入集群

CLUSTER MEET <IP> <Port> 让节点A将另外一个节点B添加到A当前所在的节点集群中。

过程

  1. A为B创建一个clusterNode结构,并添加到自己的clusterState.nodes里。
  2. A向B发送一条MEET消息
  3. B接收到A的MEET消息后,B为A创建一个clusterNode结构,并添加到自己的clusterState.nodes字典里。
  4. B向A返回一条PONG消息。
  5. A收到PONG消息,得知B已经收到了自己的MEET消息。
  6. A向B发送一条PING消息。
  7. B收到PING消息,得知A已经收到了自己的PING消息。
  8. 之后,A将节点B的消息通过Gossip协议传播给集群中的其他节点。

Redis性能

Redis线程模型

  1. Redis线程模型:套接字、I/O多路复用程序、文件事件分排器、事件处理器。
  2. I/O多路复用程序复制监听多个套接字,I/O多路复用程序总是将所有产生事件的套接字放到一个队列里。然后以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。
  3. 事件处理器处理完毕之后,多路复用程序才会发送下一个套接字

image

单线程的Redis

redis是单线程数据为何那么快

  1. 纯内存操作
  2. 单线程机制,避免了上下文切换,同时避免了锁操作
  3. 采用了非阻塞I/O多路复用机制

redis为啥是单线程操作

  1. 多线程的存在基本是因为程序中存在等待,处理器资源使用不充分。redis纯内存操作,cpu不是限制。Redis主要受限于内存和网络。
  2. Redis单线程避免了不必要的上下文切换和竞争条件且实现简单。
  3. Redis利用队列技术,将并发访问变成串行访问。
  4. redis提供的每一个API都是原子操作,单线程保证了事务的原子性。
除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。所以严格来讲Redis不是单线程的。

Redis 6.0 多线程的Redis

多线程的Redis线程模型

  1. I/O多路复用程序复制监听多个套接字,I/O多路复用程序总是将所有产生事件的套接字放到一个队列里
  2. 主线程阻塞,等待IO线程读套接字并解析请求,多个IO线程并行解析。
  3. 主线程根据解析后的命令进行执行,单线程的执行命令
  4. 主线程阻塞,等待IO线程将执行结果回写入套接字

Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行

优点

  1. 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核。
  2. 多线程任务可以分摊 Redis 同步 IO 读写负荷。

Redis应用

缓存相关

  1. 缓存雪崩在某一个时间段里缓存集中过期失效。

解决方式:不同的对象设置不同的过期时间,或者过期时间加一个随机因子,尽量分散时间。

  1. 缓存穿透:查询一个数据库一定不存在的数据,则请求必然会打到数据库。

解决方式

1. 使用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一定不存在与数据库的数据会被这个bitmap拦截掉。
2. 数据库不存在的值,统一返回一个特定值缓存到redis中,代表数据不存在。
  1. 缓存击穿:缓存中没有数据但数据库中有。由于并发用户特别多同时,没有读取到数据,都同时去数据库取数据。造成数据库压力瞬间增大。

解决方式

1. 热点数据永不过期
2. 加分布式锁
3. 设置一个watcher进行锁续期

缓存更新的设计模式:

1. 先删缓存后更新数据库:考虑两个并发请求,一个是要更新操作,另一个是查询操作。更新操作会致使当前缓存失效,删除缓存后。这时查询操作没有命中缓存,就会将数据库中的数据读出来放到缓存中。然后更新操作更新了数据库,此时缓存中数据并不是更新操作的新值,而是原来的数据库中的值。所以说这种更新策略是错误的。

* 优化方案:删除缓存、修改数据库、读取缓存等的操作积压到队列里边,实现串行化。
  1. 先更新数据库后删除缓存:如果更新数据库的时候,正好有读请求到达,此时读到的数据将是脏数据,但是当更新完数据库,会删除旧的缓存,等下次读请求到达时,没有命中缓存,会从数据库重新load到内存中,保证只出现因此脏数据,之后都是正确的数据。

    • 优化方案:考虑删除缓存失败的情况,可以不断重试删除操作,直到成功。
  2. 读写穿透模式

    • 读操作中更新缓存:让缓存服务自己加载,对程序调用来说是透明的。当读操作没有命中缓存时将触发读穿透
    • 写操作中更新缓存:当数据进行更新时,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存自己更新数据库
  3. Write Behind Caching Pattern:在更新数据的时候,只更新缓存,不更新数据库。而我们的缓存会异步地批量更新数据库可能会出现数据不一致的情况:当系统掉电,缓存中数据还没有来得及写到数据库,则会造成一定的数据丢失。

其他

  1. 计算器/限速器:利用Redis中原子性的自增操作,我们可以统计类似用户点赞数、用户访问数等。
  2. 好友关系:利用集合的一些命令,比如求交集、并集、差集等。可以方便搞定一些共同好友、共同爱好之类的功能
  3. Session共享
  4. 排行榜:通过有序集合来做。

其他

redis为什么采用跳表而不是红黑树?

在做范围查找的时候,平衡树比skiplist操作要复杂。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。(实现复杂)

平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。对于高并发的场景下skiplist可以减少锁竞争,获取更高的并发(平衡树插入删除会引发子树调整)

从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。(内存占用少)

从算法实现难度上来比较,skiplist比平衡树要简单得多。

事件

  1. 文本事件:服务器与客户端通信产生相应的文本事件,服务器通过监听并处理这些事件完成一系列的通信。
  2. 时间事件:一些操作需要在特定的时间点执行。时间事件分为:定时事件、周期性事件、服务器将所有的时间事件都放入链表中、每当时间事件执行器运行时、它就遍历整个链表。查找已到达的时间事件。
查看原文

赞 5 收藏 2 评论 0

dreamans 赞了文章 · 6月19日

Go 每日一库之 fyne

简介

Go 语言生态中,GUI 一直是短板,更别说跨平台的 GUI 了。fyne向前迈了一大步。fyne 是 Go 语言编写的跨平台的 UI 库,它可以很方便地移植到手机设备上。fyne使用上非常简单,同时它还提供fyne命令打包静态资源和应用程序。我们先简单介绍基本控件和布局,然后介绍如何发布一个fyne应用程序。

快速使用

本文代码使用 Go Modules。

先初始化:

$ mkdir fyne && cd fyne
$ go mod init github.com/darjun/go-daily-lib/fyne

由于fyne包含一些 C/C++ 的代码,所以需要gcc编译工具。在 Linux/Mac OSX 上,gcc基本是标配,在 windows 上我们有 3 种方式安装gcc工具链:

本文选择TDM-GCC的方式安装。到https://jmeubank.github.io/tdm-gcc/download/下载安装程序并安装。正常情况下安装程序会自动设置PATH路径。打开命令行,键入gcc -v。如果正常输出版本信息,说明安装成功且环境变量设置正确。

安装fyne

$ go get -u fyne.io/fyne

到此准备工作已经完成,我们开始编码。按照惯例,先以Hello, World程序开始:

package main

import (
  "fyne.io/fyne"
  "fyne.io/fyne/app"
  "fyne.io/fyne/widget"
)

func main() {
  myApp := app.New()

  myWin := myApp.NewWindow("Hello")
  myWin.SetContent(widget.NewLabel("Hello Fyne!"))
  myWin.Resize(fyne.NewSize(200, 200))
  myWin.ShowAndRun()
}

运行结果如下:

fyne的使用很简单。每个fyne程序都包括两个部分,一个是应用程序对象myApp,通过app.New()创建。另一个是窗口对象,通过应用程序对象myApp来创建myApp.NewWindow("Hello")myApp.NewWindow()方法中传入的字符串就是窗口标题。

fyne提供了很多常用的组件,通过widget.NewXXX()创建(XXX为组件名)。上面示例中,我们创建了一个Label控件,然后设置到窗口中。最后,调用myWin.ShowAndRun()开始运行程序。实际上myWin.ShowAndRun()等价于

myWin.Show()
myApp.Run()

myWin.Show()显示窗口,myApp.Run()开启事件循环。

注意一点,fyne默认窗口大小是根据内容的宽高来设置的。上面我们调用myWin.Resize()手动设置了大小。否则窗口只能放下字符串Hello Fyne!

fyne包结构划分

fyne将功能划分到多个子包中:

  • fyne.io/fyne:提供所有fyne应用程序代码共用的基础定义,包括数据类型和接口;
  • fyne.io/fyne/app:提供创建应用程序的 API;
  • fyne.io/fyne/canvas:提供Fyne使用的绘制 API;
  • fyne.io/fyne/dialog:提供对话框组件;
  • fyne.io/fyne/layout:提供多种界面布局;
  • fyne.io/fyne/widget:提供多种组件,fyne所有的窗体控件和交互元素都在这个子包中。

Canvas

fyne应用程序中,所有显示元素都是绘制在画布(Canvas)上的。这些元素都是画布对象(CanvasObject)。调用Canvas.SetContent()方法可设置画布内容。Canvas一般和布局(Layout)容器(Container)一起使用。canvas子包中提供了一些基础的画布对象:

package main

import (
  "image/color"
  "math/rand"

  "fyne.io/fyne"
  "fyne.io/fyne/app"
  "fyne.io/fyne/canvas"
  "fyne.io/fyne/layout"
  "fyne.io/fyne/theme"
)

func main() {
  a := app.New()
  w := a.NewWindow("Canvas")

  rect := canvas.NewRectangle(color.White)

  text := canvas.NewText("Hello Text", color.White)
  text.Alignment = fyne.TextAlignTrailing
  text.TextStyle = fyne.TextStyle{Italic: true}

  line := canvas.NewLine(color.White)
  line.StrokeWidth = 5

  circle := canvas.NewCircle(color.White)
  circle.StrokeColor = color.Gray{0x99}
  circle.StrokeWidth = 5

  image := canvas.NewImageFromResource(theme.FyneLogo())
  image.FillMode = canvas.ImageFillOriginal

  raster := canvas.NewRasterWithPixels(
    func(_, _, w, h int) color.Color {
      return color.RGBA{uint8(rand.Intn(255)),
        uint8(rand.Intn(255)),
        uint8(rand.Intn(255)), 0xff}
    },
  )

  gradient := canvas.NewHorizontalGradient(color.White, color.Transparent)

  container := fyne.NewContainerWithLayout(
    layout.NewGridWrapLayout(fyne.NewSize(150, 150)),
    rect, text, line, circle, image, raster, gradient))
  w.SetContent(container)
  w.ShowAndRun()
}

程序运行结果如下:

canvas.Rectangle是最简单的画布对象了,通过canvas.NewRectangle()创建,传入填充颜色。

canvas.Text是显示文本的画布对象,通过canvas.NewText()创建,传入文本字符串和颜色。该对象可设置对齐方式和字体样式。对齐方式通过设置Text对象的Alignment字段值,取值有:

  • TextAlignLeading:左对齐;
  • TextAlignCenter:中间对齐;
  • TextAlignTrailing:右对齐。

字体样式通过设置Text对象的TextStyle字段值,TextStyle是一个结构体:

type TextStyle struct {
  Bold      bool
  Italic    bool
  Monospace bool
}

对应字段设置为true将显示对应的样式:

  • Bold:粗体;
  • Italic:斜体;
  • Monospace:系统等宽字体。

我们还可以通过设置环境变量FYNE_FONT为一个.ttf文件从而使用外部字体。

canvas.Line是线段,通过canvas.NewLine()创建,传入颜色。可以通过line.StrokeWidth设置线段宽度。默认情况下,线段是从父控件或画布的左上角右下角的。可通过line.Move()line.Resize()修改位置。

canvas.Circle是圆形,通过canvas.NewCircle()创建,传入颜色。另外通过StrokeColorStrokeWidth设置圆形边框的颜色和宽度。

canvas.Image是图像,可以通过已加载的程序资源创建(canvas.NewImageFromResource()),传入资源对象。或通过文件路径创建(canvas.NewImageFromFile()),传入文件路径。或通过已构造的image.Image对象创建(canvas.NewImageFromImage())。可以通过FillMode设置图像的填充模式:

  • ImageFillStretch:拉伸,填满空间;
  • ImageFillContain:保持宽高比;
  • ImageFillOriginal:保持原始大小,不缩放。

下面程序演示了这 3 种创建图像的方式:

package main

import (
  "image"
  "image/color"

  "fyne.io/fyne"
  "fyne.io/fyne/app"
  "fyne.io/fyne/canvas"
  "fyne.io/fyne/layout"
  "fyne.io/fyne/theme"
)

func main() {
  a := app.New()
  w := a.NewWindow("Hello")

  img1 := canvas.NewImageFromResource(theme.FyneLogo())
  img1.FillMode = canvas.ImageFillOriginal

  img2 := canvas.NewImageFromFile("./luffy.jpg")
  img2.FillMode = canvas.ImageFillOriginal

  image := image.NewAlpha(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})
  for i := 0; i < 100; i++ {
    for j := 0; j < 100; j++ {
      image.Set(i, j, color.Alpha{uint8(i % 256)})
    }
  }
  img3 := canvas.NewImageFromImage(image)
  img3.FillMode = canvas.ImageFillOriginal

  container := fyne.NewContainerWithLayout(
    layout.NewGridWrapLayout(fyne.NewSize(150, 150)),
    img1, img2, img3)
  w.SetContent(container)
  w.ShowAndRun()
}

theme.FyneLogo()是 Fyne 图标资源,luffy.jpg是磁盘中的文件,最后创建一个image.Image,从中生成canvas.Image

最后一种是梯度渐变效果,有两种类型canvas.LinearGradient(线性渐变)和canvas.RadialGradient(放射渐变),指从一种颜色渐变到另一种颜色。线性渐变又分为两种水平线性渐变垂直线性渐变,分别通过canvas.NewHorizontalGradient()canvas.NewVerticalGradient()创建。放射渐变通过canvas.NewRadialGradient()创建。我们在上面的示例中已经看到了水平线性渐变的效果,接下来一起看看放射渐变的效果:

func main() {
  a := app.New()
  w := a.NewWindow("Canvas")

  gradient := canvas.NewRadialGradient(color.White, color.Transparent)
  w.SetContent(gradient)
  w.Resize(fyne.NewSize(200, 200))
  w.ShowAndRun()
}

运行效果如下:

放射效果就是从中心向周围渐变。

Widget

窗体控件是一个Fyne应用程序的主要组成部分。它们能适配当前的主题,并且处理与用户的交互。

Label

标签(Label)是最简单的一个控件了,用于显示字符串。它有点类似于canvas.Text,不同之处在于Label可以处理简单的格式化,例如\n

func main() {
  myApp := app.New()
  myWin := myApp.NewWindow("Label")

  l1 := widget.NewLabel("Name")
  l2 := widget.NewLabel("da\njun")

  container := fyne.NewContainerWithLayout(layout.NewVBoxLayout(), l1, l2)
  myWin.SetContent(container)
  myWin.Resize(fyne.NewSize(150, 150))
  myWin.ShowAndRun()
}

第二个widget.Label\n后面的内容会在下一行渲染:

Button

按钮(Button)控件让用户点击,给用户反馈。Button可以包含文本,图标或两者皆有。调用widget.NewButton()创建一个默认的文本按钮,传入文本和一个无参的回调函数。带图标的按钮需要调用widget.NewButtonWithIcon(),传入文本和回调参数,还需要一个fyne.Resource类型的图标资源:

func main() {
  myApp := app.New()
  myWin := myApp.NewWindow("Button")

  btn1 := widget.NewButton("text button", func() {
    fmt.Println("text button clicked")
  })

  btn2 := widget.NewButtonWithIcon("icon", theme.HomeIcon(), func() {
    fmt.Println("icon button clicked")
  })

  container := fyne.NewContainerWithLayout(layout.NewVBoxLayout(), btn1, btn2)
  myWin.SetContent(container)
  myWin.Resize(fyne.NewSize(150, 50))
  myWin.ShowAndRun()
}

上面创建了一个文本按钮和一个图标按钮,theme子包中包含一些默认的图标资源,也可以加载外部的图标。运行:

点击按钮,对应的回调就会被调用,试试看!

Box

盒子控件(Box)就是一个简单的水平或垂直的容器。在内部,Box对子控件采用盒状布局(Box Layout),详见后文布局。我们可以通过传入控件对象给widget.NewHBox()widget.NewVBox()创建盒子。或者调用已经创建好的widget.Box对象的Append()Prepend()向盒子中添加控件。前者在尾部追加,后者在头部添加。

func main() {
  myApp := app.New()
  myWin := myApp.NewWindow("Box")

  content := widget.NewVBox(
    widget.NewLabel("The top row of VBox"),
    widget.NewHBox(
      widget.NewLabel("Label 1"),
      widget.NewLabel("Label 2"),
    ),
  )
  content.Append(widget.NewButton("Append", func() {
    content.Append(widget.NewLabel("Appended"))
  }))
  content.Append(widget.NewButton("Prepend", func() {
    content.Prepend(widget.NewLabel("Prepended"))
  }))

  myWin.SetContent(content)
  myWin.Resize(fyne.NewSize(150, 150))
  myWin.ShowAndRun()
}

我们甚至可以嵌套widget.Box控件,这样就可以实现比较灵活的布局。上面的代码中添加了两个按钮,点击时分别在尾部和头部添加一个Label

Entry

输入框(Entry)控件用于给用户输入简单的文本内容。调用widget.NewEntry()即可创建一个输入框控件。我们一般保存输入框控件的引用,以便访问其Text字段来获取内容。注册OnChanged回调函数。每当内容有修改时,OnChanged就会被调用。我们可以调用SetReadOnly(true)设置输入框的只读属性。方法SetPlaceHolder()用来设置占位字符串,设置字段Multiline让输入框接受多行文本。另外,我们可以使用NewPasswordEntry()创建一个密码输入框,输入的文本不会以明文显示。

func main() {
  myApp := app.New()
  myWin := myApp.NewWindow("Entry")

  nameEntry := widget.NewEntry()
  nameEntry.SetPlaceHolder("input name")
  nameEntry.OnChanged = func(content string) {
    fmt.Println("name:", nameEntry.Text, "entered")
  }

  passEntry := widget.NewPasswordEntry()
  passEntry.SetPlaceHolder("input password")

  nameBox := widget.NewHBox(widget.NewLabel("Name"), layout.NewSpacer(), nameEntry)
  passwordBox := widget.NewHBox(widget.NewLabel("Password"), layout.NewSpacer(), passEntry)

  loginBtn := widget.NewButton("Login", func() {
    fmt.Println("name:", nameEntry.Text, "password:", passEntry.Text, "login in")
  })

  multiEntry := widget.NewEntry()
  multiEntry.SetPlaceHolder("please enter\nyour description")
  multiEntry.MultiLine = true

  content := widget.NewVBox(nameBox, passwordBox, loginBtn, multiEntry)
  myWin.SetContent(content)
  myWin.ShowAndRun()
}

这里我们实现了一个简单的登录界面:

Checkbox/Radio/Select

CheckBox是简单的选择框,每个选择是独立的,例如爱好可以是足球、篮球,也可以都是。创建方法widget.NewCheck(),传入选项字符串(足球,篮球)和回调函数。回调函数接受一个bool类型的参数,表示该选项是否选中。

Radio是单选框,每个组内只能选择一个,例如性别,只能是男或女(?)。创建方法widget.NewRadio(),传入字符串切片和回调函数作为参数。回调函数接受一个字符串参数,表示选中的选项。也可以使用Selected字段读取选中的选项。

Select是下拉选择框,点击时显示一个下拉菜单,点击选择。选项非常多的时候,比较适合用Select。创建方法widget.NewSelect(),参数与NewRadio()完全相同。

func main() {
  myApp := app.New()
  myWin := myApp.NewWindow("Choices")

  nameEntry := widget.NewEntry()
  nameEntry.SetPlaceHolder("input name")

  passEntry := widget.NewPasswordEntry()
  passEntry.SetPlaceHolder("input password")

  repeatPassEntry := widget.NewPasswordEntry()
  repeatPassEntry.SetPlaceHolder("repeat password")

  nameBox := widget.NewHBox(widget.NewLabel("Name"), layout.NewSpacer(), nameEntry)
  passwordBox := widget.NewHBox(widget.NewLabel("Password"), layout.NewSpacer(), passEntry)
  repeatPasswordBox := widget.NewHBox(widget.NewLabel("Repeat Password"), layout.NewSpacer(), repeatPassEntry)

  sexRadio := widget.NewRadio([]string{"male", "female", "unknown"}, func(value string) {
    fmt.Println("sex:", value)
  })
  sexBox := widget.NewHBox(widget.NewLabel("Sex"), sexRadio)

  football := widget.NewCheck("football", func(value bool) {
    fmt.Println("football:", value)
  })
  basketball := widget.NewCheck("basketball", func(value bool) {
    fmt.Println("basketball:", value)
  })
  pingpong := widget.NewCheck("pingpong", func(value bool) {
    fmt.Println("pingpong:", value)
  })
  hobbyBox := widget.NewHBox(widget.NewLabel("Hobby"), football, basketball, pingpong)

  provinceSelect := widget.NewSelect([]string{"anhui", "zhejiang", "shanghai"}, func(value string) {
    fmt.Println("province:", value)
  })
  provinceBox := widget.NewHBox(widget.NewLabel("Province"), layout.NewSpacer(), provinceSelect)

  registerBtn := widget.NewButton("Register", func() {
    fmt.Println("name:", nameEntry.Text, "password:", passEntry.Text, "register")
  })

  content := widget.NewVBox(nameBox, passwordBox, repeatPasswordBox,
    sexBox, hobbyBox, provinceBox, registerBtn)
  myWin.SetContent(content)
  myWin.ShowAndRun()
}

这里我们实现了一个简单的注册界面:

Form

表单控件(Form)用于对很多Label和输入控件进行布局。如果指定了OnSubmitOnCancel函数,表单控件会自动添加对应的Button按钮。我们调用widget.NewForm()传入一个widget.FormItem切片创建Form控件。每一项中一个字符串作为Label的文本,一个控件对象。创建好Form对象之后还能调用其Append(label, widget)方法添加控件。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Form")

  nameEntry := widget.NewEntry()
  passEntry := widget.NewPasswordEntry()

  form := widget.NewForm(
    &widget.FormItem{"Name", nameEntry},
    &widget.FormItem{"Pass", passEntry},
  )
  form.OnSubmit = func() {
    fmt.Println("name:", nameEntry.Text, "pass:", passEntry.Text, "login in")
  }
  form.OnCancel = func() {
    fmt.Println("login canceled")
  }

  myWindow.SetContent(form)
  myWindow.Resize(fyne.NewSize(150, 150))
  myWindow.ShowAndRun()
}

使用Form能大大简化表单的构建,我们使用Form重新编写了上面的登录界面:

注意SubmitCancel按钮是自动生成的!

ProgressBar

进度条控件(ProgressBar)用来表示任务的进度,例如文件下载的进度。创建方法widget.NewProgressBar(),默认最小值为0.0,最大值为1.1,可通过Min/Max字段设置。调用SetValue()方法来控制进度。还有一种进度条是循环动画,它表示有任务在进行中,并不能表示具体的完成情况。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("ProgressBar")

  bar1 := widget.NewProgressBar()
  bar1.Min = 0
  bar1.Max = 100
  bar2 := widget.NewProgressBarInfinite()

  go func() {
    for i := 0; i <= 100; i ++ {
      time.Sleep(time.Millisecond * 500)
      bar1.SetValue(float64(i))
    }
  }()

  content := widget.NewVBox(bar1, bar2)
  myWindow.SetContent(content)
  myWindow.Resize(fyne.NewSize(150, 150))
  myWindow.ShowAndRun()
}

在另一个 goroutine 中更新进度。效果如下:

TabContainer

标签容器(TabContainer)允许用户在不同的内容面板之间切换。标签可以是文本或图标。创建方法widget.NewTabContainer(),传入widget.TabItem作为参数。widget.TabItem可通过widget.NewTabItem(label, widget)创建。标签还可以设置位置:

  • TabLocationBottom:显示在底部;
  • TabLocationLeading:显示在顶部左边;
  • TabLocationTrailing:显示在顶部右边。

看示例:

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("TabContainer")

  nameLabel := widget.NewLabel("Name: dajun")
  sexLabel := widget.NewLabel("Sex: male")
  ageLabel := widget.NewLabel("Age: 18")
  addressLabel := widget.NewLabel("Province: shanghai")
  addressLabel.Hide()
  profile := widget.NewVBox(nameLabel, sexLabel, ageLabel, addressLabel)

  musicRadio := widget.NewRadio([]string{"on", "off"}, func(string) {})
  showAddressCheck := widget.NewCheck("show address?", func(value bool) {
    if !value {
      addressLabel.Hide()
    } else {
      addressLabel.Show()
    }
  })
  memberTypeSelect := widget.NewSelect([]string{"junior", "senior", "admin"}, func(string) {})

  setting := widget.NewForm(
    &widget.FormItem{"music", musicRadio},
    &widget.FormItem{"check", showAddressCheck},
    &widget.FormItem{"member type", memberTypeSelect},
  )

  tabs := widget.NewTabContainer(
    widget.NewTabItem("Profile", profile),
    widget.NewTabItem("Setting", setting),
  )

  myWindow.SetContent(tabs)
  myWindow.Resize(fyne.NewSize(200, 200))
  myWindow.ShowAndRun()
}

上面代码编写了一个简单的个人信息面板和设置面板,点击show address?可切换地址信息是否显示:


Toolbar

工具栏(Toolbar)是很多 GUI 应用程序必备的部分。工具栏将常用命令用图标的方式很形象地展示出来,方便使用。创建方法widget.NewToolbar(),传入多个widget.ToolbarItem作为参数。最常使用的ToolbarItem有命令(Action)、分隔符(Separator)和空白(Spacer),分别通过widget.NewToolbarItemAction(resource, callback)/widget.NewToolbarSeparator()/widget.NewToolbarSpacer()创建。命令需要指定回调,点击时触发。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Toolbar")

  toolbar := widget.NewToolbar(
    widget.NewToolbarAction(theme.DocumentCreateIcon(), func() {
      fmt.Println("New document")
    }),
    widget.NewToolbarSeparator(),
    widget.NewToolbarAction(theme.ContentCutIcon(), func() {
      fmt.Println("Cut")
    }),
    widget.NewToolbarAction(theme.ContentCopyIcon(), func() {
      fmt.Println("Copy")
    }),
    widget.NewToolbarAction(theme.ContentPasteIcon(), func() {
      fmt.Println("Paste")
    }),
    widget.NewToolbarSpacer(),
    widget.NewToolbarAction(theme.HelpIcon(), func() {
      log.Println("Display help")
    }),
  )

  content := fyne.NewContainerWithLayout(
    layout.NewBorderLayout(toolbar, nil, nil, nil),
    toolbar, widget.NewLabel(`Lorem ipsum dolor, 
    sit amet consectetur adipisicing elit.
    Quidem consectetur ipsam nesciunt,
    quasi sint expedita minus aut,
    porro iusto magnam ducimus voluptates cum vitae.
    Vero adipisci earum iure consequatur quidem.`),
  )
  myWindow.SetContent(content)
  myWindow.ShowAndRun()
}

工具栏一般使用BorderLayout,将工具栏放在其他任何控件上面,布局后文会详述。运行:

扩展控件

标准的 Fyne 控件提供了最小的功能集和定制化以适应大部分的应用场景。有些时候,我们需要更高级的功能。除了自己编写控件外,我们还可以扩展现有的控件。例如,我们希望图标控件widget.Icon能响应鼠标左键、右键和双击。首先编写一个构造函数,调用ExtendBaseWidget()方法获得基础的控件功能:

type tappableIcon struct {
  widget.Icon
}

func newTappableIcon(res fyne.Resource) *tappableIcon {
  icon := &tappableIcon{}
  icon.ExtendBaseWidget(icon)
  icon.SetResource(res)

  return icon
}

然后实现相关的接口:

// src/fyne.io/fyne/canvasobject.go
// 鼠标左键
type Tappable interface {
  Tapped(*PointEvent)
}

// 鼠标右键或长按
type SecondaryTappable interface {
  TappedSecondary(*PointEvent)
}

// 双击
type DoubleTappable interface {
  DoubleTapped(*PointEvent)
}

接口实现:

func (t *tappableIcon) Tapped(e *fyne.PointEvent) {
  log.Println("I have been left tapped at", e)
}

func (t *tappableIcon) TappedSecondary(e *fyne.PointEvent) {
  log.Println("I have been right tapped at", e)
}

func (t *tappableIcon) DoubleTapped(e *fyne.PointEvent) {
  log.Println("I have been double tapped at", e)
}

最后使用:

func main() {
  a := app.New()
  w := a.NewWindow("Tappable")
  w.SetContent(newTappableIcon(theme.FyneLogo()))
  w.Resize(fyne.NewSize(200, 200))
  w.ShowAndRun()
}

运行,点击图标控制台有相应输出:

2020/06/18 06:44:02 I have been left tapped at &{{110 97} {106 93}}
2020/06/18 06:44:03 I have been left tapped at &{{110 97} {106 93}}
2020/06/18 06:44:05 I have been right tapped at &{{88 102} {84 98}}
2020/06/18 06:44:06 I have been right tapped at &{{88 102} {84 98}}
2020/06/18 06:44:06 I have been left tapped at &{{88 101} {84 97}}
2020/06/18 06:44:07 I have been double tapped at &{{88 101} {84 97}}

输出的fyne.PointEvent中有绝对位置(对于窗口左上角)和相对位置(对于容器左上角)。

Layout

布局(Layout)就是控件如何在界面上显示,如何排列的。要想界面好看,布局是必须要掌握的。几乎所有的 GUI 框架都提供了布局或类似的接口。实际上,在前面的示例中我们已经在fyne.NewContainerWithLayout()函数中使用了布局。

BoxLayout

盒状布局(BoxLayout)是最常使用的一个布局。它将控件都排在一行或一列。在fyne中,我们可以通过layout.NewHBoxLayout()创建一个水平盒装布局,通过layout.NewVBoxLayout()创建一个垂直盒装布局。水平布局中的控件都排列在一行中,每个控件的宽度等于其内容的最小宽度(MinSize().Width),它们都拥有相同的高度,即所有控件的最大高度(MinSize().Height)。

垂直布局中的控件都排列在一列中,每个控件的高度等于其内容的最小高度,它们都拥有相同的宽度,即所有控件的最大宽度。

一般地,在BoxLayout中使用layout.NewSpacer()辅助布局,它会占满剩余的空间。对于水平盒状布局来说,第一个控件前添加一个layout.NewSpacer(),所有控件右对齐。最后一个控件后添加一个layout.NewSpacer(),所有控件左对齐。前后都有,那么控件中间对齐。如果在中间有添加一个layout.NewSpacer(),那么其它控件两边对齐。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Box Layout")

  hcontainer1 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
    canvas.NewText("left", color.White),
    canvas.NewText("right", color.White))

  // 左对齐
  hcontainer2 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
    layout.NewSpacer(),
    canvas.NewText("left", color.White),
    canvas.NewText("right", color.White))

  // 右对齐
  hcontainer3 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
    canvas.NewText("left", color.White),
    canvas.NewText("right", color.White),
    layout.NewSpacer())

  // 中间对齐
  hcontainer4 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
    layout.NewSpacer(),
    canvas.NewText("left", color.White),
    canvas.NewText("right", color.White),
    layout.NewSpacer())

  // 两边对齐
  hcontainer5 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
    canvas.NewText("left", color.White),
    layout.NewSpacer(),
    canvas.NewText("right", color.White))

  myWindow.SetContent(fyne.NewContainerWithLayout(layout.NewVBoxLayout(),
    hcontainer1, hcontainer2, hcontainer3, hcontainer4, hcontainer5))
  myWindow.Resize(fyne.NewSize(200, 200))
  myWindow.ShowAndRun()
}

运行效果:

GridLayout

格子布局(GridLayout)每一行有固定的列,添加的控件数量超过这个值时,后面的控件将会在新的行显示。创建方法layout.NewGridLayout(cols),传入每行的列数。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Grid Layout")

  img1 := canvas.NewImageFromResource(theme.FyneLogo())
  img2 := canvas.NewImageFromResource(theme.FyneLogo())
  img3 := canvas.NewImageFromResource(theme.FyneLogo())
  myWindow.SetContent(fyne.NewContainerWithLayout(layout.NewGridLayout(2),
    img1, img2, img3))
  myWindow.Resize(fyne.NewSize(300, 300))
  myWindow.ShowAndRun()
}

运行效果:

该布局有个优势,我们缩放界面时,控件会自动调整大小。试试看~

GridWrapLayout

GridWrapLayoutGridLayout的扩展。GridWrapLayout创建时会指定一个初始size,这个size会应用到所有的子控件上,每个子控件都保持这个size。初始,每行一个控件。如果界面大小变化了,这些子控件会重新排列。例如宽度翻倍了,那么一行就可以排两个控件了。有点像流动布局:

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Grid Wrap Layout")

  img1 := canvas.NewImageFromResource(theme.FyneLogo())
  img2 := canvas.NewImageFromResource(theme.FyneLogo())
  img3 := canvas.NewImageFromResource(theme.FyneLogo())
  myWindow.SetContent(
    fyne.NewContainerWithLayout(
      layout.NewGridWrapLayout(fyne.NewSize(150, 150)),
      img1, img2, img3))
  myWindow.ShowAndRun()
}

初始:

加大宽度:

再加大宽度:

BorderLayout

边框布局(BorderLayout)比较常用于构建用户界面,上面例子中的Toolbar一般都和BorderLayout搭配使用。创建方法layout.NewBorderLayout(top, bottom, left, right),分别传入顶部、底部、左侧、右侧的控件对象。添加到容器中的控件如果是这些边界对象,则显示在对应位置,其他都显示在中心:

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Border Layout")

  left := canvas.NewText("left", color.White)
  right := canvas.NewText("right", color.White)
  top := canvas.NewText("top", color.White)
  bottom := canvas.NewText("bottom", color.White)
  content := widget.NewLabel(`Lorem ipsum dolor, 
  sit amet consectetur adipisicing elit.
  Quidem consectetur ipsam nesciunt,
  quasi sint expedita minus aut,
  porro iusto magnam ducimus voluptates cum vitae.
  Vero adipisci earum iure consequatur quidem.`)

  container := fyne.NewContainerWithLayout(
    layout.NewBorderLayout(top, bottom, left, right),
    top, bottom, left, right, content,
  )
  myWindow.SetContent(container)
  myWindow.ShowAndRun()
}

效果:

FormLayout

表单布局(FormLayout)其实就是一个 2 列的GridLayout,但是针对表单做了一些微调。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Border Layout")

  nameLabel := canvas.NewText("Name", color.Black)
  nameValue := canvas.NewText("dajun", color.White)
  ageLabel := canvas.NewText("Age", color.Black)
  ageValue := canvas.NewText("18", color.White)

  container := fyne.NewContainerWithLayout(
    layout.NewFormLayout(),
    nameLabel, nameValue, ageLabel, ageValue,
  )
  myWindow.SetContent(container)
  myWindow.Resize(fyne.NewSize(150, 150))
  myWindow.ShowAndRun()
}

运行效果:

CenterLayout

CenterLayout将容器内的所有控件显示在中心位置,按传入的顺序显示。最后传入的控件显示最上层。CenterLayout中所有控件将保持它们的最小尺寸(大小能容纳其内容)。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Center Layout")

  image := canvas.NewImageFromResource(theme.FyneLogo())
  image.FillMode = canvas.ImageFillOriginal
  text := canvas.NewText("Fyne Logo", color.Black)

  container := fyne.NewContainerWithLayout(
    layout.NewCenterLayout(),
    image, text,
  )
  myWindow.SetContent(container)
  myWindow.ShowAndRun()
}

运行结果:

字符串Fyne Logo显示在图片上层。如果我们把textimage顺序对调,字符串将会被图片挡住,无法看到。动手试一下~

MaxLayout

MaxLayoutCenterLayout类似,不同之处在于MaxLayout会让容器内的元素都显示为最大尺寸(等于容器的大小)。细心的朋友可能发现了,在CenterLayout的示例中。我们设置了图片的填充模式为ImageFillOriginal。如果不设置填充模式,图片的默认MinSize(1, 1)。可以fmt.Println(image.MinSize())验证一下。这样图片就不会显示在界面中。

MaxLayout的容器中,我们不需要这样处理:

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Max Layout")

  image := canvas.NewImageFromResource(theme.FyneLogo())
  text := canvas.NewText("Fyne Logo", color.Black)

  container := fyne.NewContainerWithLayout(
    layout.NewMaxLayout(),
    image, text,
  )
  myWindow.SetContent(container)
  myWindow.Resize(fyne.Size(200, 200))
  myWindow.ShowAndRun()
}

运行结果:

注意,canvas.Text显示为左对齐了。如果要居中对齐,设置其Alignment属性为fyne.TextAlignCenter

自定义 Layout

内置布局在子包layout中。它们都实现了fyne.Layout接口:

// src/fyne.io/fyne/layout.go
type Layout interface {
  Layout([]CanvasObject, Size)
  MinSize(objects []CanvasObject) Size
}

要实现自定义的布局,只需要实现这个接口。下面我们实现一个台阶(对角)的布局,好似一个矩阵的对角线,从左上到右下。首先定义一个新的类型。然后实现接口fyne.Layout的两个方法:

type diagonal struct {
}

func (d *diagonal) MinSize(objects []fyne.CanvasObject) fyne.Size {
  w, h := 0, 0
  for _, o := range objects {
    childSize := o.MinSize()

    w += childSize.Width
    h += childSize.Height
  }

  return fyne.NewSize(w, h)
}

func (d *diagonal) Layout(objects []fyne.CanvasObject, containerSize fyne.Size) {
  pos := fyne.NewPos(0, 0)
  for _, o := range objects {
    size := o.MinSize()
    o.Resize(size)
    o.Move(pos)

    pos = pos.Add(fyne.NewPos(size.Width, size.Height))
  }
}

MinSize()返回所有子控件的MinSize之和。Layout()从左上到右下排列控件。然后是使用:

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Diagonal Layout")

  img1 := canvas.NewImageFromResource(theme.FyneLogo())
  img1.FillMode = canvas.ImageFillOriginal
  img2 := canvas.NewImageFromResource(theme.FyneLogo())
  img2.FillMode = canvas.ImageFillOriginal
  img3 := canvas.NewImageFromResource(theme.FyneLogo())
  img3.FillMode = canvas.ImageFillOriginal

  container := fyne.NewContainerWithLayout(
    &diagonal{},
    img1, img2, img3,
  )
  myWindow.SetContent(container)
  myWindow.ShowAndRun()
}

运行结果:

fyne demo

fyne提供了一个 Demo,演示了大部分控件和布局的使用。可使用下面命令安装,执行:

$ go get fyne.io/fyne/cmd/fyne_demo
$ fyne_demo

效果图:

fyne命令

fyne库为了方便开发者提供了fyne命令。fyne可以用来将静态资源打包进可执行程序,还能将整个应用程序打包成可发布的形式。fyne命令通过下面命令安装:

$ go get fyne.io/fyne/cmd/fyne

安装完成之后fyne就在$GOPATH/bin目录中,将$GOPATH/bin添加到系统$PATH中就可以直接运行fyne命令了。

静态资源

其实在前面的示例中我们已经多次使用了fyne内置的静态资源,使用最多的要属fyne.FyneLogo()了。下面我们有两个图片image1.png/image2.jpg。我们使用fyne bundle命令将这两个图片打包进代码:

$ fyne bundle image1.png >> bundled.go
$ fyne bundle -append image2.jpg >> bundled.go

第二个命令指定-append选项表示添加到现有文件中,生成的文件如下:

// bundled.go
package main

import "fyne.io/fyne"

var resourceImage1Png = &fyne.StaticResource{
  StaticName: "image1.png",
  StaticContent: []byte{...}}

var resourceImage2Jpg = &fyne.StaticResource{
  StaticName: "image2.jpg",
  StaticContent: []byte{...}}

实际上就是将图片内容存入一个字节切片中,我们在代码中就可以调用canvas.NewImageFromResource(),传入resourceImage1PngresourceImage2Jpg来创建canvas.Image对象了。

func main() {
  myApp := app.New()
  myWindow := myApp.NewWindow("Bundle Resource")

  img1 := canvas.NewImageFromResource(resourceImage1Png)
  img1.FillMode = canvas.ImageFillOriginal
  img2 := canvas.NewImageFromResource(resourceImage2Jpg)
  img2.FillMode = canvas.ImageFillOriginal
  img3 := canvas.NewImageFromResource(theme.FyneLogo())
  img3.FillMode = canvas.ImageFillOriginal

  container := fyne.NewContainerWithLayout(
    layout.NewGridLayout(1),
    img1, img2, img3,
  )
  myWindow.SetContent(container)
  myWindow.ShowAndRun()
}

运行结果:

注意,由于现在是两个文件,不能使用go run main.go,应该用go run .

theme.FyneLogo()实际上是也是提前打包进代码的,代码文件是bundled-icons.go

// src/fyne.io/fyne/theme/icons.go
func FyneLogo() fyne.Resource {
  return fynelogo
}

// src/fyne.io/fyne/theme/bundled-icons.go
var fynelogo = &fyne.StaticResource{
  StaticName: "fyne.png",
  StaticContent: []byte{}}

发布应用程序

发布图像应用程序到多个操作系统是非常复杂的任务。图形界面应用程序通常有图标和一些元数据。fyne命令提供了将应用程序发布到多个平台的支持。使用fyne package命令将创建一个可在其它计算机上安装/运行的应用程序。在 Windows 上,fyne package会创建一个.exe文件。在 macOS 上,会创建一个.app文件。在 Linux 上,会生成一个.tar.xz文件,可手动安装。

我们将上面的应用程序打包成一个exe文件:

$ fyne package -os windows -icon icon.jpg

上面命令会在同目录下生成两个文件bundle.exefyne.syso,将这两个文件拷贝到任何目录或其他 Windows 计算机都可以通过直接双击bundle.exe运行了。没有其他的依赖。

fyne还支持交叉编译,能在 windows 上编译 mac 的应用程序,不过需要安装额外的工具,感兴趣可自行探索。

总结

fyne提供了丰富的组件和功能,我们介绍的只是很基础的一部分,还有剪切板、快捷键、滚动条、菜单等等等等内容。fyne命令实现打包静态资源和应用程序,非常方便。fyne还有其他高级功能留待大家探索、挖掘~

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. fyne GitHub:https://github.com/fyne-io/fyne
  2. fyne 官网:https://fyne.io/
  3. fyne 官方入门教程:https://developer.fyne.io/tour/introduction/hello.html
  4. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib

我的博客:https://darjun.github.io

欢迎关注我的微信公众号【GoUpUp】,共同学习,一起进步~

查看原文

赞 7 收藏 4 评论 0

dreamans 赞了文章 · 6月11日

疫情期间的Go分享

前序

Hello,大家好~ 由于新冠状病毒的原因、现场面试全部改为视频面试。下面来分享下疫情期间的一些面试经验和反思哦。

中序

    switch "company" {
    case "某未来":
        process:=` 1-2面 -120分钟 
1. mysql .索引相关.select * from table  where id=1 或  where a=1  数据是怎么查出来的。(id 主键 a是普通索引或者a 无索引)
2. update set a=1 from table where a=2 会加锁吗。怎么加的。会锁表吗 。
3. redolog undolog binlog 的作用。
4. mysql主从怎么实现的
5. go 调度模型。发生网络io,会怎么调度。发生阻塞的IO会怎么调度。epoll详解
6. go gc 相关问题
7. go channel 的实现原理。
8. 实现一个并发模型。生产者消费者 (在线写)
9. 既然你提到了gearman.有其他的队列可以替代吗?比如哪些?可以举例吗?
10. redis 的删除策略。定时 定期 惰性 lru(要写)
11. redis 各类型的底层数据结构讲讲~
12. etcd 怎么保持一致性的? 脑裂怎么解决的?
13. 你对分布式怎么看?可以举个例子吗?
14. 线上突然大量502.怎么排查?
15. 架构设计-微信朋友圈
16. 线上熔断降级怎么做的
17. 描述 APP push推送架构设计
18. 既然你说XX奖励系统模型 不合理。如果让你重新设计。你会怎么设计?
19. 讲一下open-falcon的架构设计吧。metrics 采集标准?
20. 为什么你们把php换成go ?优点是什么?为什么单机 go 的吞吐比php 高?原因是?
21. 举几个例子 说明下 你对代码做优化的地方?提升的原因是什么?
22. 你怎么评估重构业务。重构的标准是什么?怎么灰度切换?
24. 你对自己未来的规划是怎么样子的?
             3面 - 60分钟
1. C++了解吗?
2. 虚拟地址和位地址怎么映射?
3. C++段错误是什么情况引起的?
4. Go runtime 了解吗?讲一下调度。发生文件IO的时候 G 怎么调度的?
5. 有10亿用户,让你设计一个社区架构。包括点赞 发帖 删帖 的积分架构、期间一直在追问设计合不合理。不断的优化~
6. 给你1亿个URL 。爬取信息。会遇到什么问题?从 CPU 磁盘 网络 等方面。这个聊了很多case
7. 平常喜欢看什么书?想往管理层走 还是技术路线。
        `
        suggest:=`
        该公司在线教育龙头,团队氛围很棒。核心团队主要由360、滴滴、swoole系组成。
`
    case "某度":
        process:=` 1-3 面 总计3小时
1. 设计一个抢红包的系统架构、如何保证每个人抢到、讨论了半天、如何抗住流量?
2. mysql 隔离级别、mvvc  索引种类和区别、gap、innodb、
3. 给你一字符串、abcabc 找出最长出现偶数次的字符串
4. 业务场景上、mysql redis 怎么达到分布式一致性
5. 设计一个直播系统、包含送礼、长连接、推送、实效性、等 (在线画架构图)
6. 公司负责业务的职责

1. 手写前缀树
2. 设计一个缓存系统
3. leetcode AB数组升序 中间数
4. Go runtime讲解- 网上文章很多。不想听网上的文章。有没有独到的见解
5. 讲解open-falcon架构设计
6. 讲解jaeger 全链路原理、 traceid 的生成规则
7. etcd源码分析

      大概就记住这些、其他忘记了、
`
    case "某滴":
    process:=`1面 100分钟
1. 在线画推送系统架构-期间会不断地问问题。
2. redis 为什么是单线程? 一直聊得很深。讲到epoll 底层 和两种模式。 红黑树
3. HTTP FTP区别、迅雷传输文件的协议格式,为什么用udp?
4. ping ip 这一过程发生了什么?
5. 如果让你负责一个项目。你怎么保证项目高效稳定零BUG?
6. 打开一个socket 发生了什么?怎么写一个socket服务器?
7. http 状态码 你知道哪些?
8. 长连接写过吗? 你们全都用的rpc 请求吗?讲一下grpc 和ws
9. 在线写算法、获取一个二叉树的最小深度。
10. 分布式事务?
2面 没记
3面 60分钟
1.给你一个10PB文件  3000台机器。如何做字典树排序?
2.一直在聊各个公司所做的项目架构

`
case "某巴巴":
    process:=`
1. int int64的区别、 占多少位、慢慢分析。 
2. go 逃逸分析   
3. go gc  三色标记-黑白灰
4. 反射了解吗 —主要做序列化、反序列化、GORM、yaml/ini 等文件解析 —设备信息存储的是json格式的数据。接收按照interface来
reflect.TypeOf(app).String()  来处理的。传输不一致  
reflect.TypeOf(regId).String() == "map[string]interface {}” 
1. im 了解吗? 核心是什么?我是谁 我在那?? 
2. go map 底层源码了解吗?
3. redis list set 区别。什么场景使用
4. 写代码~ 用 map 设计一个 set集合。支持增删改查
5. []byte{}  string 的区别
6. rocketmq 底层原理
`

}

后序

建议大家在业余时间,要及时的积累知识点,巩固底层源码和自己做的项目架构哦~。这样无论是在任何时刻,都能保持良好的求职状态。居安思危ing~

祝大家都能找到心满意足的工作

附件模板

我平时知识点巩固的模板分享给大家~
左侧是线索cues 记录大的模块概念,右侧是我们自己的一些小心地和笔记
最下面是对 整体知识板块的总结~
image.png

查看原文

赞 13 收藏 5 评论 3

dreamans 赞了文章 · 6月11日

思否 8 周年系列活动丨和 CEO 高阳聊聊天

2020 年是 SegmentFault 思否社区成立的第 8 年,我们策划了一系列活动与大家互动。微博抽奖活动已经进行到第二轮,接下来还有很多精彩内容将轮番登场。

本周五(6 月 12 日),SegmentFault 思否 CEO 高阳准备了丰富的礼物和大家一起聊天,回顾 SegmentFault 8 年来背后的故事,以及正在做的事情。

戳链接报名参与直播: https://ke.sifou.com/course/1...

360x640@3x(2).png

如果你也有创业梦想,可以来听听 SegmentFault 的创业故事;

如果你是开发,欢迎来了解思否的支持计划,为自己寻找更多机会;

如果你觉得思否的吉祥物可爱「Super Unicat 独角猫」可爱,也可以和高阳聊聊吉祥物背后的设计有哪些寓意。

思否八周年系列活动.png

思否吉祥物 Super Unicat 独角猫(左);思否 8 周年定制文化衫(右)

无论想了解思否背后的故事,还是想得到专业支持,SegmentFault 思否 CEO 高阳都将知无不言,言无不尽。

直播中还将不定时发起 3 轮抽奖活动,人人有机会获得丰富礼品哦,赶快搬好小板凳扫码进群吧!

思否八周年活动群_20200601153207.png

查看原文

赞 13 收藏 1 评论 7

dreamans 关注了标签 · 5月29日

kubernetes

Kubernetes是Google开源的容器集群管理系统,使用Golang开发,其提供应用部署、维护、扩展机制等功能,利用Kubernetes能方便地管理跨机器运行容器化的应用。https://github.com/GoogleCloudPlatform/kubernetes

关注 795

dreamans 关注了用户 · 5月9日

民工哥 @jishuroad

民工哥,10多年职场老司机的经验分享,坚持自学一路从技术小白成长为互联网企业信息技术部门的负责人。

我的新书:《Linux系统运维指南》

微信公众号:民工哥技术之路

民工哥:知乎专栏

欢迎关注,我们一同交流,相互学习,共同成长!!

关注 2783

dreamans 赞了回答 · 5月6日

DolphinDB ploop函数的用法

这里可使用DolphinDB database的部分应用实现。部分应用是指固定一个函数的部分参数,产生一个参数较少的函数。

ploop(piep{,data_dhz},conf)

关注 2 回答 1

dreamans 赞了问题 · 5月6日

DolphinDB ploop函数的用法

Usage: ploop(func, args...). The length of all args must be consistent.

def piep(c,mutable data_dhz){
 Future_s=select last(askPrice1) as askPrice1,last(bidPrice1) as bidPrice1 from Future_source where product=`j
 Trading_t=select * from Trading_Table
 configs=select * from trad_model_config where strategy=`DHZ,hide>0,state>0
 Func_DHZ(c,configs,data_dhz,Trading_t,Future_s)
 }
 
def DHZ(mutable ss,msg){
 data_dhz=select last(product) as symbol,last(time) as time,first(price_index) as open,max(price_index) as high,min(price_index) as low,last(price_index) as close,last(price_index) as askPrice1,last(price_index) as bidPrice1 from future_index where product =`j group by tradingDay,time.minute()  
 configs=select * from trad_model_config where strategy=`DHZ,hide>0,state>0
 
 conf=[]
 for (c in 0..(size(configs)-1)){
  conf.append!(c)
  }
 ploop(piep,conf,data_dhz)

}

上述代码,在执行时报错:msgId=32223644 length=400 exception=Usage: ploop(func, args...). The length of all args must be consistent.
这里有方法改成参数数量不一致也能运行吗?
我的需求是固定data_dhz,而conf是多个,即输入ploop(piep,[1,2,3,4],data_dhz),达到ploop(piep,[1,2,3,4],[data_dhz,data_dhz,data_dhz,data_dhz])的效果。谢谢!

关注 2 回答 1

认证与成就

  • 获得 151 次点赞
  • 获得 8 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 8 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • Syncd - 自动化部署工具

    syncd是一款开源的代码部署工具,它具有简单、高效、易用等特点,可以提高团队的工作效率.

注册于 2014-02-20
个人主页被 1.5k 人浏览