tfzh

tfzh 查看完整档案

深圳编辑深圳大学  |  土木工程 编辑  |  填写所在公司/组织 segmentfault.com/u/tfzh 编辑
编辑

今日长缨在手 何时缚住苍龙

个人动态

tfzh 赞了文章 · 4月15日

中小型前端团队代码规范工程化最佳实践 - ESLint

前言

There are a thousand Hamlets in a thousand people's eyes.

一千个程序员,就有一千种代码风格。在前端开发中,有几个至今还在争论的代码风格差异:

  • 单引号还是双引号?
  • 代码行结束是否需要分号?
  • 两个空格还是四个空格?
  • ...

这几个代码风格差异在协同开发中经常会被互相吐槽,甚至不能忍受。

除此之外,由于 JavaScript 的灵活性,往往一段代码能有多种写法,这时候也会导致协同时差异。并且,有一些写法可能会导致不易发现的 bug,或者这些写法的性能不好,开发时也应该避免。

为了解决这类静态代码问题,每个团队都需要一个统一的 JavaScript 代码规范,团队成员都遵守这份代码规范来编写代码。当然,靠人来保障代码规范是不可靠的,需要有对应的工具来保障,ESLint 就是这个工具。

有的读者看到这里,可能会说:Prettier 也可以保证代码风格一致。是的,Prettier 确实可以按照设置的规则对代码进行统一格式化,后面的文章也会有对应的介绍。但是需要明确的一点是,Prettier 只会在格式上对代码进行格式化,一些隐藏的代码质量问题 Prettier 是无法发现的,而 ESLint 可以。

关于 ESLint

关于 ESLint,它的 Slogan 是 Find and fix problems in your JavaScript code。如上文所说,它可以发现并修复你 JavaScript 代码中的问题。来看一下官网上描述 ESLint 具备的三个特性:

  • Find Problems。ESLint 通过静态代码分析可以快速发现代码中的问题。ESLint 可以运行在大多数文本编辑器中,并且也可以在工作流中接入 ESLint
  • Fix Automatically。ESLint 发现的很多问题都可以自动修复
  • Customize。可以定制 ESLint 检查规则

基于以上描述,我们在前端工程化中可以这样使用 ESLint:

  1. 基于业界现有的 ESLint 规范和团队代码习惯定制一套统一的 ESLint 代码规则
  2. 将统一代码规则封装成 ESLint 规则包接入
  3. 将 ESLint 接入脚手架、编辑器以及研发工作流中

快速上手

先简单介绍一下如何使用 ESLint,如果已经有所了解的同学,可以直接跳过这一节。

新建一个包含 package.json 的目录(可以在空目录下执行 npm init -y),新建一个 index.js

// index.js
const name = 'axuebin'

安装 eslint :

npm install eslint --save-dev

然后执行 ./node_modules/.bin/eslint --init 或者 npx eslint --init 生成一个 ESLint 配置文件 .eslintc.js

module.exports = {
  env: {
    es2021: true,
  },
  extends: 'eslint:recommended',
  parserOptions: {
    ecmaVersion: 12,
  },
  rules: {},
};

生成好配置文件之后,就可以执行 ./node_modules/.bin/eslint index.js 或者 npx eslint index.js 命令对文件进行检查。结果如下:
image.png
index.js 中的代码命中了 no-unused-vars 这个规则,默认情况下,这个规则是会报 error 的,也就是 ESLint 不允许代码中出现未被使用的变量。这是一个好习惯,有利于代码的维护。

简单配置

我们来尝试配置 ESLint 的检查规则。以分号和引号举例,现在你作为团队代码规范的指定人,希望团队成员开发的代码,都是单引号带分号的。

打开 .eslintrc.js 配置文件,在 rules 中添加相关配置项:

module.exports = {
  env: {
    es2021: true,
  },
  extends: 'eslint:recommended',
  parserOptions: {
    ecmaVersion: 12,
  },
  rules: {
    semi: ['error', 'always'],
    quotes: ['error', 'single'],
  },
};

然后我们将 index.js 中的代码改成:

// index.js
const name = "axuebin"

执行 eslint 命令之后:
image.png
可以看到检查结果如下:

  • [no-unused-vars] 'name' is assigned a value but never used。定义了 name 变量却未使用。
  • [quotes] Strings must use singlequote。字符串必须使用单引号。
  • [semi] Missing semicolon。缺失分号。

老老实实地按照规范修改代码,使用单引号并将加上分号。当然,如果你们希望是双引号和不带分号,修改相应的配置即可。

具体各个规则如何配置可以查看:https://eslint.org/docs/rules

自动修复

执行 eslint xxx --fix 可以自动修复一些代码中的问题,将无法自动修复的问题暴露出来。比如上文中提到的引号和分号的问题,就可以通过 --fix 自动修复,而 no-unused-vars 变量未使用的问题,ESLint 就无法自动修复。
image.png

使用配置包

init 生成的配置文件中,我们看到包含这一行代码:

module.exports = {
  extends: "eslint:recommended"
}

这一行代码的意思是,使用 ESLint 的推荐配置。 extends: 'xxx' 就是 继承,当前的配置继承于 xxx 的配置,在此基础上进行扩展。

因此,我们也可以使用任意封装好的配置,可以在 NPM 上或者 GItHub 上搜索 eslint-config 关键词获取,本文我们将这类封装好的配置称作 “配置集”。比较常见的配置包有以下几个:

  • eslint-config-airbnb: Airbnb 公司提供的配置集
  • eslint-config-prettier: 使用这个配置集,会关闭一些可能与 Prettier 冲突的规则
  • eslint-config-react: create react app 使用的配置集
  • eslint-config-vue: vuejs 使用的配置集
  • ...

最佳实践

简单了解完 ESLint 之后,对于 ESLint 的更多使用细节以及原理,在本篇文章就不展开了,感兴趣的朋友可以在官网详细了解。本文重点还是在于如何在团队工程化体系中落地 ESLint,这里提几个最佳实践。

抽象配置集

对于独立开发者以及业务场景比较简单的小型团队而言,使用现成、完备的第三方配置集是非常高效的,可以较低成本低接入 ESLint 代码检查。

但是,对于中大型团队而言,在实际代码规范落地的过程中我们会发现,不可能存在一个能够完全符合团队风格的三方配置包,我们还是会在 extends 三方配置集的基础上,再手动在 rules 配置里加一些自定义的规则。时间长了,有可能 A 应用和 B 应用里的 rules 就不一样了,就很难达到统一的目的。

这时候,就需要一个中心化的方式来管理配置包:根据团队代码风格整理(或者基于现有的三方配置集)发布一个配置集,团队统一使用这个包,就可以做到中心化管理和更新

除此之外,从技术层面考虑,目前一个前端团队的面对的场景可能比较复杂。比如:

  • 技术选型不一致:框架上 PC 使用 React,H5 使用 Vue;是否使用 TypeScript
  • 跨端场景多:Web 端和小程序端,还有 Node
  • ...

以上问题在真实开发中都是存在的,所以在代码规范的工程化方案落地时,一个单一功能的配置集是不够用的,这时候还需要考虑这个配置集如何抽象。

为了解决以上问题,这里提供一种解决方案的思路:
image.png
具体拆解来看,就是有一个类似 eslint-config-standard 的基础规则集(包括代码风格、变量相关、ES6 语法等),在此基础之上集成社区的一些插件(Vue/React)等,封装成统一的一个 NPM Package 发布,消费时根据当前应用类型通过不同路径来 extends 对应的配置集。

这里有一个 Demo,感兴趣的朋友可以看一下:eslint-config-axuebin

开发插件

ESLint 提供了丰富的配置供开发者选择,但是在复杂的业务场景和特定的技术栈下,这些通用规则是不够用的。ESLint 通过插件的形式赋予了扩展性,开发者可以自定义任意的检查规则,比如 eslint-plugin-vue / eslint-plugin-react 就是 Vue / React 框架中使用的扩展插件,官网也提供了相关文档引导开发者开发一个插件。

一般来说,我们也不需要开发插件,但我们至少需要了解有这么个东西。在做一些团队代码质量检查的时候,我们可能会有一些特殊的业务逻辑,这时候 ESLint 插件是可以帮助我们做一些事情。

这里就不展开了,主要就是一些 AST 的用法,照着官方文档就可以上手,或者可以参考现有的一些插件写法。

脚手架 / CLI 工具

当有了团队的统一 ESLint 配置集和插件之后,我们会将它们集成到脚手架中,方便新项目集成和开箱即用。但是对于一些老项目,如果需要手动改造还是会有一些麻烦的,这时候就可以借助于 CLI 来完成一键升级。

本文结合上文的 Demo eslint-config-axuebin,设计一个简单的 CLI Demo。由于当前配置也比较简单,所以 CLI 只需要做几件简单的事情即可:

  • 询问用户当前项目的类型(是 JavaScript 还是 TypeScript、是 React 还是 Vue)
  • 根据项目类型写 .eslintrc.js 文件
  • 根据项目类型安装所需依赖(比如 vue 需要 eslint-plugin-vue)
  • package.json 的 scripts 中写入 "lint": "eslint src test --fix"  

核心代码如下:

const path = require('path');
const fs = require('fs');
const chalk = require('chalk');
const spawn = require('cross-spawn');

const { askForLanguage, askForFrame } = require('./ask');
const { eslintrcConfig, needDeps } = require('./config');

module.exports = async () => {
  const language = await askForLanguage();
  const frame = await askForFrame();

  let type = language;
  if (frame) {
    type += `/${frame}`;
  }

  fs.writeFileSync(
    path.join(process.cwd(), '.eslintrc.js'),
    `// Documentation\n// https://github.com/axuebin/eslint-config-axuebin\nmodule.exports = ${JSON.stringify(
      eslintrcConfig(type),
      null,
      2
    )}`
  );

  const deps = needDeps.javascript;
  if (language === 'typescript') {
    deps.concat(needDeps.typescript);
  }
  if (frame) {
    deps.concat(needDeps[frame]);
  }

  spawn.sync('npm', ['install', ...deps, '--save'], { stdio: 'inherit' });
};

可运行的 CLI Demo 代码见:axb-lint,在项目目录下执行:axblint eslint 即可,如图:
image.png

自动化

配置了 ESLint 之后,我们需要让开发者感知到 ESLint 的约束。开发者可以自己运行 eslint 命令来跑代码检查,这不够高效,所以我们需要一些自动化手段来做这个事情。当然 在开发时,编辑器也有提供相应的功能可以根据当前工作区下的 ESLint 配置文件来检查当前正在编辑的文件,这个不是我们关心的重点。

一般我们会在有以下几种方式做 ESLint 检查:

  • 开发时:依赖编辑器的能力
  • 手动运行:在终端中手动执行 eslint 命令
  • pre-commit:在提交 git 前自动执行 eslint 命令
  • ci:依赖 git 的持续集成,可以将检查结果输出文件上传到服务器

这里提一下 pre-commit 的方案,在每一次本地开发完成提交代码前就做 ESLint 检查,保证云端的代码是统一规范的。

这种方式非常简单,只需要在项目中依赖 huskylint-staged 即可完成。安装好依赖之后,在 package.json 文件加入以下配置即可:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": "eslint --cache --fix"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  }
}

效果如图所示:
image.png
如果代码跑 ESLint 检查抛了 Error 错误,则会中断 commit 流程:
image.png
这样就可以确保提交到 GitHub 仓库上的代码是统一规范的。(当然,如果认为将这些配置文件都删了,那也是没办法的)

总结

本文介绍了 ESLint 在中小型前端团队的一些最佳实践的想法,大家可以在此基础上扩展,制订一套完善的 ESLint 工作流,落地到自己团队中。

本文是前端代码规范系列文章的其中一篇,后续还有关于 StyleLint/CommitLint/Prettier 等的文章,并且还有一篇完整的关于前端代码规范工程化实践的文章,敬请期待(也有可能就鸽了)。


更多原创文章欢迎关注公众号「玩相机的程序员」,或者加我微信 xb9207 交流

查看原文

赞 30 收藏 18 评论 2

tfzh 赞了文章 · 4月15日

安全地在前后端之间传输数据 - 「1」技术预研

已经不是第一次写这个主题了,最近有朋友拿 5 年前的《Web 应用中保证密码传输安全》来问我:“为什么按你说的一步步做下来,后端解不出来呢?”加解密这种事情,差之毫厘谬以千里,我认为多半就是什么参数没整对,仔细查查改对了就行。代码拿来一看,傻眼了……没毛病啊,为啥解不出来呢?

时间久远,原文附带的源代码已经下不下来了。翻阅各种参考链接的时候从 CodeProject 上找了个代码,把各参数换过去一试,没毛病呀!这可奇了怪了,于是去 RSA.js 的文档(没有专门的文档,就是文档注释)中查,发现 RSA.js 在 2014 年 1 月加入了 Padding 参数,《Web 应用中保证密码传输安全》虽然是 2014 年 2 月写的,但可能阴差阳错用到了老版本。

不就是 Padding 吗,文档也懒得看了,前后端都指定 PKCS1Padding 试试。失败!

那暴力一点,所有 Padding 都试试!

前端使用 RSA.js 在 RSAAPP 中定义的 4 种 Padding,后端 C# 使用 RSAEncryptionPadding 中定义的 5 种 Padding,组合了 20 种情况,逐一试验……好吧,没一个对的!

世界上这么多树,何必非要在这一棵上吊死,何况它还没有发布到 npm …… 理由找够了,咱就换!

网上搜了一圈之后,选择了 JSEncrypt 这个库。

核心知识

在讲 JSEncrypt 之前,咱们回到“安全传输”这一主题。这一主题的关键技术在于加解密,说起加解密,那就是三大类算法:HASH(摘要)算法、对称加密算法和非对称加密算法。基本的安全传输过程可以用一张图来 展示:

image.png

不过这只是最基本的安全传输理论,实际上,证书(公钥)分发等方面仍然存在安全隐患,所以才会有CA、才会有受信根证书……不过这里不作延展,只给个结论:在 Web 前后端传输这个问题上,HTTPS 就是最佳实践,是首选 Web 传输解决方案,只有在不能使用 HTTPS 的情况,才退而求其次,用自己的实现来提高一点安全门槛。

JSEncrypt

JSEncrypt 一个月前刚有新版本,还算活跃。不过在使用方式上跟 RSA.js 不同,它不需要指定 RSA 的参数,而是直接导入一个 PEM 格式的密钥(证书)。关于证书格式呢,就不在这里科普了,总之 PEM 是一种文本格式,Base64 编码。

既然 JSEnrypt 需要导入密钥,这里主要是需要导入公钥。我们来看看 C# 里 RSACryptoServiceProvider 能导出些什么,搜了一下 Export... 方法,导出公约相关的主要就这两个:

因为原始需求是用 .NET,所以先研究 .NET 跟 JSEncrypt 的配合,后面再补充 NodeJS 和 Java 的。
  • ExportRSAPublicKey(),以 PKCS#1 RSAPublicKey 格式导出当前密钥的公钥部分。
  • ExportSubjectPublicKeyInfo(),以 X.509 SubjectPublicKeyInfo 格式导出当前密钥的公钥部分。

还有两个 Try... 前缀的方法作用相似,可以忽略。这两个方法的区别就在于导出的格式不同,一个是 PKCS#1 (Public-Key Cryptography Standards),一个是 SPKI (Subject Public Key Info)。

JSEncrypt 能导入哪种格式呢?文档里没明确说明,不妨试试。

C# 产生密钥并导出

C# 中产生 RSA 密钥对比较简单,使用 RSACryptoServiceProvider 就行,比如产生一对 1024 位的 RSA 密钥,并以 XML 格式导出:

// C# Code

private RSACryptoServiceProvider GenerateRsaKeys(int keySize = 1024)
{
    var rsa = new RSACryptoServiceProvider(keySize);
    var xmlPrivateKey = rsa.ToXmlString(true);
    // 如果需要单独的公钥部分,将传入 `ToXmlString()` 改为 false 就好
    // var xmlPublicKey = rsa.ToXmlString(false);

    File.WriteAllText("RSA_KEY", xmlPrivateKey);
    return rsa;
}

为了能在进程每次重启都使用相同的密钥,上面的示例将产生的 xmlPrivateKey 保存到文件中,重启进程时可以尝试从文件加载导入。注意,由于私钥包含公钥,所以只需要保存 xmlPrivateKey 就够了。那么加载的过程:

// C# Code

private RSACryptoServiceProvider LoadRsaKeys()
{
    if (!File.Exists("RSA_KEY")) { return null; }
    var xmlPrivateKey = File.ReadAllText("RSA_KEY");

    var rsa = new RSACryptoServiceProvider();
    rsa.FromXmlString(xmlPrivateKey);
    return rsa;
}

先尝试导入,不成再新生成的过程就一句话:

// C# Code

var rsa = LoadRsaKeys() ?? GenerateRsaKeys();

导出 XML Key 是为了持久化。JSEncrypt 需要的是 PEM 格式的证书,也就是 Base64 编码的证书。ExportRSAPublicKeyExportSubjectPublicKeyInfo 这两个方法的返回类型都是 byte[],所以需要对它们进行 Base64 编码。这里使用 Viyi.Util 提供的 Base64Encode() 扩展方法来实现:

// C# Code

var pkcs1 = rsa.ExportRSAPublicKey().Base64Encode();
var spki = rsa.ExportSubjectPublicKeyInfo().Base64Encode();

严格的说,PEM 格式还应该加上 -----BEGIN PUBLIC KEY----------END PUBLIC KEY----- 这样的标头标尾,Base64 编码也应该按每行 64 个字符进行折行处理。不过实测 JSEncrypt 导入时不会要求这么严格,省了不少事。

剩下的就是将 pkcs1spki 传递给前端了。Web 应用直接通过 API 返回一个 JSON,或者 TEXT 都行,根据接口规范来决定。当然也可以通过拷贝/粘贴的方式来传递。这里既然是在做实验,那就用 Console.WriteLine 输出到控制台,通过剪贴板来传递好了。

我这里 PKCS#1 导出的是长度为 188 个字符的 Base64:

MIGJAoGB...tAgMBAAE=

SPKI 导出的是长度为 216 个字符的 Base64:

MIGfMA0GC...QIDAQAB

JSEncrypt 导入公钥并加密

JSEncrypt 提供了 setPublicKey()setPrivateKey() 来导入密钥。不过文档中提到它们其实都是 setKey() 的别名,这点需要注意一下。为了避免语义不清,我建议直接使用 setKey()

You can use also setPrivateKey and setPublicKey, they are both alias to setKey

from: http://travistidwell.com/jsen...

那么导入公钥并试验加密的过程大概会是这样:

// JavaScript Code

const pkcs1 = "MIGJAoGB...tAgMBAAE=";   // 注意,这里的 KEY 值仅作示意,并不完整
const spki = "MIGfMA0GC...QIDAQAB";     // 注意,这里的 KEY 值仅作示意,并不完整

[pkcs1, spki].forEach((pKey, i) => {
    const jse = new JSEncrypt();
    jse.setKey(pKey);
    const eCodes = jse.encrypt("Hello World");
    console.log(`[${i} Result]: ${eCodes}`);
});

运行后得到输出(密文也是省略了中间很长一串的 ):

[0 Result]: false
[1 Result]: ZkhFRnigoHt...wXQX4=

看这结果,没啥悬念了,JSEncrypt 只认 SPKI 格式

不过还得去 C# 中验证这个密文是可以解出来的。

C# 验证可以解密 JSEncrypt 生成的密文

上面生成的那一段 ZkhFRnigoHt...wXQX4= 拷贝到 C# 代码中,用来验证解密。C# 使用 RSACryptoServiceProvider.Decrypt() 实例方法来解密,这个方法的第 1 个参数是密文,类型 byte[],是以二进制数据的形式提供的。

第二个参数可以是 boolean 类型,true 表示使用 OAEP 填充方式,false 表示使用 PKCS#1 v1.5;这个参数也可以是 RSAEncryptionPadding 对象,直接从预定义的几个静态对象中选择一个就好。这些在文档中都说得很清楚。因为一般都是使用的 PKCS 填充方式,所以这次赌一把,直接上:

// C# Code

var eCodes = "ZkhFRnigoHt...wXQX4=";    // 示例代码这里省略了中间大部分内容
var rsa = LoadRsaKeys();   // rsa 肯定是使用之前生成的密钥对,要不然没法解密
byte[] data = rsa.Decrypt(eCodes.Base64Decode(), false);
Console.WriteLine(data.GetString());    // GetString 也是 Viyi.Util 中定义的扩展方法,默认用 UTF8 编码

结果正如预期:

Hello World

技术总结

现在,通过实验,Web 前端使用 JSEncrypt 和 .NET 后端之间已经实现了 RSA 加/解密来完成安全的数据传输。其做法总结如下:

  1. 后端产生 RSA 密钥对,保存备用。保存方式可根据实际情况选择:内存、文件、数据库、缓存服务等
  2. 后端以 SPKI 格式导出公钥(别忘了 Base64 编码),通过某种业务接口形式传递给前端,或由前端主动请求获得(比如调用特定 API)
  3. 前端使用 JSEncrypt,通过 setKey() 导入公钥,使用 encrypt() 加密字符串。加密前字符串会按 UTF8 编码成二进制数据。
  4. 后端获得前端加密后的数据(Base64 编码)后,解密成二进制数据,并使用 UTF8 解码成文本。

特别需要注意的一点是:不管以何种方式(XML、PEM 等)将公钥传送给前端的时候,都切记不要把私钥给出去了。这尤其容易发生在使用 .ToXmlString(true) 之后再直接把结果送给前端。不要问我为什么会有这么个提醒,要问就是因为……我见过!

关门放 Node

还没完呢,前面说过要补充 NodeJS 后端的情况。NodeJS 关于加/解密的 SDK 都在 crypto 模块中,

  • 使用 generateKeyPair()generateKeyPairSync() 来产生密钥对
  • 使用 privateDecrypt() 来解密数据
generateKeyPair() 是异步操作。现在 Node 中异步函数很常见,尤其是写 Web 服务端的时候,到处都是异步。不喜欢回调方式的话,可以使用 util 模块中的 promisify() 把它转换一下。
// JavaScript Code, in Node environtment

import { promisify } from "util";
import crypto from "crypto";

const asyncGenerateKeyPair = promisify(crypto.generateKeyPair);

(async () => {
    const { publicKey, privateKey } = await asyncGenerateKeyPair(
        "rsa",
        {
            modulusLength: 1024,
            publicKeyEncoding: {
                type: "spki",
                format: "pem",
            },
            privateKeyEncoding: {
                type: "pkcs1",
                format: "pem"
            }
        }
    );

    console.log(publicKey)
    console.log(privateKey);
})();

generateKeyPair 第 1 个参数是算法,很明显。第 2 个参数是选项,强度 1024 也很明显。只有 publicKeyEncodingprivateKeyEncoding 需要稍微解释一下 —— 其实文档也说得很明白:参考 keyObject.export()

对于公钥,type 可选 "pkcs1" 或者 "spki",之前已经试过,JSEncrypt 只认 "spki",所以没得选。

对于私钥,RSA 只能选 "pkcs1",所以还是没得选。

不过 NodeJS 的 PEM 输出要规范得多,看(同样省略了中间部分):

-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYur0zYBtqOqs98l4rh1J2olBb
... ... ...
8I8y4j9dZw05HD3u7QIDAQAB
-----END PUBLIC KEY-----
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCYur0zYBtqOqs98l4rh1J2olBbYpm5n6aNonWJ6y59smqipfj5
... ... ...
UJKGwVN8328z40R5w0iXqtYNvEhRtYGl0pTBP1FjJKg=
-----END RSA PRIVATE KEY-----

不管是否含标头/标尾,也不管是不是有折行,JSEncrypt 都认,所以倒不用太在意这些细节。总之 JSEncrypt 拿到公钥之后还是跟之前一样,做同样的事情,逻辑代码一个字都不用改。

然后回到 NodeJS 解密:

// JavaScript Code, in Node environtment

import crypto from "crypto";

const eCodes = "ZkhFRnigoHt...wXQX4=";    // 作为示例,偷个懒就用之前的那一段了
const buffer = crypto.privateDecrypt(
    {
        key: privateKey,
        padding: crypto.constants.RSA_PKCS1_PADDING
    },
    Buffer.from(eCodes, "base64")
);

console.log(buffer.toString());

privateDecrypt() 第 1 个参数给私钥,可以是之前导出的私钥 PEM,也可以是没导出的 KeyObject 对象。需要注意的是必须要指定填充方式是 RSA_PKCS1_PADDING,因为文档说默认使用 RSA_PKCS1_OAEP_PADDING

还有一点需要注意的是别忘了 Buffer.from(..., "base64")

解密的结果是保存在 Buffer 中的,直接 toString() 转成字符串就好,显示指定 UTF-8,用 toString("utf-8") 当然也是可以的。

等等,还有 Java 呢

Java 也大同小异,不过说实在,代码量要大不少。为了干这些事情,大概需要导入这么些类:

// Java Code

import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Base64.Decoder;
import java.util.Base64.Encoder;
import javax.crypto.Cipher;

然后是产生密钥对

// Java Code

KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(1024);
KeyPair pair = gen.generateKeyPair();

Encoder base64Encoder = Base64.getEncoder();
String publicKey = base64Encoder.encodeToString(pair.getPublic().getEncoded());
String privateKey = base64Encoder.encodeToString(pair.getPrivate().getEncoded());

// 这里输出 PKCS#8,所以解密时需要用 PKCS8EncodedKeySpec
System.out.println(pair.getPrivate().getFormat());

产生的 publicKeyprivateKey 都是纯纯的 Base64,没有其他内容(没有标头/标尾等)。

然后是解密过程……

// Java Code

String eCode = "k7M0hD....qvdk=";  // 再次声明,这是仅为演示写的阉割版数据

Decoder base64Decoder = Base64.getDecoder();
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(base64Decoder.decode(privateKey));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");

Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, keyFactory.generatePrivate(keySpec));
byte[] data = cipher.doFinal(base64Decoder.decode(eCode));

System.out.println(new String(data, StandardCharsets.UTF_8));

尾声

写完 Java 是真累,所以,以后的后端示例就用 NodeJS 了 —— 不是 Java 的锅,主要是不想切环境。

下节看点:「注册」的 DEMO,安全传输和保存用户密码。「传送门」

查看原文

赞 23 收藏 10 评论 0

tfzh 赞了文章 · 4月7日

硬核图解红黑树并手写实现

前言

在上一篇中我们通过二叉树作为了Map的实现,最后也分析了该版本的时间复杂度以及最糟糕的情况;本篇我们将会使用红黑树来实现Map,改善上一篇中二叉树版本的不足;对于Map接口的定义以及已经实现的公用方法将不会重复叙述,比如二叉树的查找方法(get);不了解的兄弟请查看上一篇《基于二叉树实现Map》

红黑树算是数据结构中比较有难度的知识点,虽然在实际的业务开发工作中使用的不多,但是这是面试官最喜欢问的知识点。

我在之前也看过很多关于红黑树的文章,但是很多都是从红黑树的性质来讲红黑树,根本未从红黑树的理论模型出发讲红黑树,所以造成红黑树比较难理解。

理解并掌握红黑树还是有必要的,Java中的HashMap当链表的节点数超过了8个就会把链表转换成红黑树;TreeMap底层也是用的是红黑树;

红黑树于我们上一篇讨论的二叉树相比,红黑树几乎是完美平衡的,并且能够保证操作的运行时间都是对数级别

在学习红黑树之前,我们先来看看红黑树的性质,针对每一条性质我们都需要问一个Why,带着问题去学习红黑树;如果我们搞懂了这些问题,那么就理解了红黑树本质,当然与实现还有一些距离。

  • 性质1. 结点是红色或黑色。(why:为什么节点要区分颜色,红色节点的作用是什么?)
  • 性质2. 根结点是黑色。(why:为什么根节点必须是黑色)
  • 性质3. 所有叶子都是黑色。(why)
  • 性质4. 从每个叶子到根的所有路径上不能有两个连续的红色结点。(why)
  • 性质5. 从任一节结点其每个叶子的所有路径都包含相同数目的黑色结点。(why)
  • 性质6. 每次新插入的节点都必须是红色(why)

平衡查找树

红黑树是近似平衡的二叉树,红黑树对应的理论模型可以是2-3树,也可以是2-3-4树,所以在学习红黑树之前,我们需要先了解2-3树和2-3-4树;

红黑树与2-3树、2-3-4树的关系就好比接口与实现类的关系;2-3树与2-3-4树是接口,而红黑树是基于接口的实现

2-3树、2-3-4树都是B树的特列情况

2-3树

为了保证树的绝对平衡,允许树中的节点保存多个键值,在2-3树中可以出现一个节点存在一个键值两条链接,也允许同一个节点包含最多两个键三条链接;

2-3树如下图:

查找

2-3树的查找过程与二叉树类似,查找键与节点中的键比较,如果遇到与查找键相等的节点,查找命中;否则继续去对应的子树中去查找,如果遇到空的链接表示查找未命中。

插入

  • 向单键key的节点中插入:当前节点只有一个key, 直接在当前节点新增一个key

  • 向双键key的节点中插入:先插入新的key到当前节点,如果当前节点大于了两个key

  • 向双键key的节点中插入,并且父节点也是双键

通过上面的三种情况的演示,我们发现2-3树和标准的二叉树的生长是不同的,二叉树的生长是由上向下生长的,而2-3树的生长是由下向上的。

在上一篇的二叉树中,我们发现最糟糕的情况是插入的节点有序,导致二叉树退化成了链表,性能下降,我们使用2-3树顺序插入看看如何,例如顺序插入1,2,3,4,5,6,7

由此我们可以看出在最坏的情况下2-3树依然完美平衡的,有比较好的性能。但是这是理论,与真正的实现还是有一段距离

基于2-3树实现的左倾红黑树

了解了红黑树的理论模型2-3树之后,那么就可以基于2-3树来实现我们的红黑树。

由于2-3树中存在着双键的节点,由于我们需要在二叉树中表示出双键的节点,所以我们需要在节点中添加一个颜色的属性color,如果节点是红色,那么表示当前节点和父节点共同组成了2-3树中的双键节点。

对上一篇中二叉树的节点做一些修改,代码如下:

class Node implements TreeNode {
    public static final boolean RED = true;
    public static final boolean BLACK = false;
    public K key;
    public V value;
    public Node left;
    public Node right;
    public boolean color; //RED 或者 BLACK
    public int size = 1;  //当前节点下节点的总个数

    public Node(K key, V value, boolean color) {
        this.key = key;
        this.value = value;
        this.color = color;
    }

    @Override
    public Node getLeft() {
        return this.left;
    }

    @Override
    public Node getRight() {
        return this.right;
    }

    @Override
    public Color getColor() {
        return color ? Color.RED : Color.BLACK;
    }
}

由于红黑树本身也是二叉树,所以在上一篇中实现的二叉树查找方法可以不用做任何的修改就可以直接应用到红黑树。

本篇中我们实现的2-3树红黑树参考算法4,并且我们规定红色节点只能出现在左节点,即左倾红黑树。(为什么规定红色节点只能出现在左节点?其实右边也是可以的,只允许存在左节点是因为能够减少可能出现的情况,实现所需的代码相对较小)

红黑树性质解释

红黑树作为了2-3树的实现,基于2-3树去看红黑树的性质就不在是干瘪瘪的约定,也不需要要强行记忆。

  • 性质1. 结点是红色或黑色。(why:为什么节点要区分颜色,红色节点的作用是什么?)
    在上面我们已经解释过了,要区分颜色主要是想要在二叉树中来表示出2-3树的双键节点的情况,如果是红色节点,那么表示当前节点与父节点共同组成了2-3数的双键;黑色节点表示二叉树中的普通节点
  • 性质2. 根结点是黑色。(why:为什么根节点必须是黑色)
    还是基于红色节点的作用来理解,根节点本身没有父节点,无法组成2-3树双键,所以不可能是红色节点。
  • 性质3. 所有叶子都是黑色。(why)
    此处提到的叶子其实是空链接,在上面的图中空连接未画出。
  • 性质4. 从每个叶子到根的所有路径上不能有两个连续的红色结点。(why)
    此性质还是基于红色节点的作用来理解,如果出现了两个连续的红色节点,那么与父节点组成了3键的节点,但是这在2-3树实现的左倾红黑树中是不允许的。(在基于2-3-4树实现的红黑树中允许出现3键,但是只能是左右两边各一个红色节点,也不能连续,在后面扩展部分会有讲解)
  • 性质5. 从任一节结点到其每个叶子的所有路径都包含相同数目的黑色结点。(why)
    此性质可以基于2-3树的理论模型来理解,因为在红色节点表示与父节点同层高,所以在红黑树中只有黑色节点会贡献树的高度,所以从任一节结点到其每个叶子的所有路径都包含相同数目的黑色结点
  • 性质6. 每次新插入的节点都必须是红色(why)
    此性质在百度百科中未出现,但在一些国外网站上有看到,个人觉得对于理解红黑树也有帮助,所以加在了这里;在上面我们已经演示过了2-3树插入键的过程,先插入键值到节点,然后在判断是否需要分裂,因为优先插入建到当前节点组成2-3树的双键或3键,而在红黑树中只有通过使用红色节点与父节点组成2-3树的双键或3键,所以每次新插入的节点都必须是红色。

2-3树在左倾红黑树中表示

  • 2-3树中单键节点在红黑树中的表示

  • 2-3树中双键节点在红黑树中的表示,由于是左倾红黑树,所以只能出现红色节点在左边

只看节点的变化可能不太直观,我们可以来看一个2-3树如何表示成左倾红黑树

当我们把红色节点拖动到与父节点同一个高度的时候,可以与2-3树对比来看,发现红黑树很好的表示了2-3树

判断节点的颜色

我需要定义个方法来判断节点属于什么颜色,如果是红色就返回true,否则返回false

protected boolean isRed(Node node) {
    if (Objects.isNull(node)) {
        return Node.BLACK;
    }
    return node.color;
}

旋转

在实现红黑树的插入或者删除操作可能会出现红色节点在右边或者两个连续的红色节点,在出现这些情况的时候我们需要通过旋转操作来完成修复。

由于旋转操作完成之后需要修改父节点的链接,所以我们定义的旋转方法需要返回旋转之后的节点来重置父节点的链接

  • 左旋

左旋代码实现如下:

protected Node rotateLeft(Node h) {
    Node x = h.right;
    h.right = x.left;
    x.left = h;
    x.color = h.color;
    h.color = Node.RED;

    size(h); //计算子树节点的个数
    size(x);

    return x;
}

重新指定两个节点的左右子树的链接,并且修改节点的颜色。其次是计算每个子树所包含的节点个数,计算的方式与上一篇中二叉树的size实现类似,这里就不重复叙述,参考《基于二叉树实现Map》

  • 右旋

右旋代码实现如下:

protected Node rotateRight(Node h) {
    Node x = h.left;
    h.left = x.right;
    x.right = h;
    x.color = h.color;
    h.color = Node.RED;

    size(h);
    size(x);

    return x;
}

变色

在2-3树中,当节点中的key值达到了3个就需要进行分裂,其中一个节点将会上浮;那在红黑树中应该如何来表示这个操作呢?

在红黑树中要实现节点分裂,其实就是节点颜色的变化;红黑树经过左旋右旋之后最终都会达到父节点是黑色,左右两个子节点是红色(后面再插入操作中可以看到详细的转变过程),这种状态就对应了2-3树中的三键节点的情况,这时候分裂的操作就是把左右两个子节点的颜色变成黑色,父节点变成红色。

代码实现如下:

/转换颜色,对应了23树中的节点分裂
protected void upSplit(Node node) {
    if (Objects.isNull(node)) {
        return;
    }
    node.left.color = Node.BLACK;
    node.right.color = Node.BLACK;
    node.color = Node.RED;
}

插入

向单键节点插入
  1. 如果插入的键小于当前节点的键值,那么直接新增一个红色左节点

  1. 如果插入的键大于当前节点的键值,那么插入一个红色的右节点,由于只允许左边出现红色节点,所以我们需要进行左旋一次

向双键节点中插入
  1. 向双键节点中插入新的键有三种情况,我们先来看最简单的情况,插入的键值最大,插入之后只需要变化颜色即可,变化过程如下图

  1. 第二种情况是插入的键值最小,插入之后造成了两个红色节点相连,所以需要进行右旋,然后在变色

  1. 第三种情况插入的键值在原来两键之间,需要先进行左旋,再右旋,最后在变色

根据以上各种情况的分析,我们可以总结出统一的变化规律:

  • 若右子节点是红色,左子节点是黑树,那么就进行左旋
  • 若左子节点是红色且他的左子节点也是红色,那么就进行右旋
  • 若左右子节点都是红色,那么就进行颜色转换

图形变化如下:

经过上面的分析之后我们现在可以来代码实现红黑树的插入操作

@Override
public void put(K key, V value) {
    if (Objects.isNull(key)) {
        throw new IllegalArgumentException("the key can't null");
    }
    root = put(root, key, value);
    root.color = Node.BLACK; 
}

private Node put(Node node, K key, V value) {
    if (Objects.isNull(node)) {
        return new Node(key, value, Node.RED);
    }
    int compare = key.compareTo(node.key);
    if (compare > 0) {
        node.right = put(node.right, key, value);
    } else if (compare < 0) {
        node.left = put(node.left, key, value);
    } else {
        node.value = value;
    }

    if (isRed(node.right) && !isRed(node.left)) {
        node = rotateLeft(node);
    }
    if (isRed(node.left) && isRed(node.left.left)) {
        node = rotateRight(node);
    }
    if (isRed(node.left) && isRed(node.right)) {
        upSplit(node);
    }

    size(node);
    return node;
}

由于根节点必须为黑色的性质,防止变色操作把根节点变为红色,所以我们在插入操作之后统一设置一次根节点为黑色;

红黑树的插入操作前半部分和上一篇实现的二叉树的插入操作一致,唯一不同的只有最后三个if操作,这三个操作就是上面总结的统一变化规律的代码实现。

第一个if判断处理如果右节点是红色,左节点是黑色,那么就进行左旋

第二个if判断处理如果左节点时红色且他的左节点也是红色,那么就进行右旋

第三个if判断如果左右两个子节点都是红色,那么就进行变色

删除

因为删除操作可能会造成树不平衡,并且可能会破坏红黑树的性质,所以删除操作会比插入操作更加麻烦。

首先我们需要先回到2-3树的理论模型中,如果我们删除的节点当前是双键节点,那么我们可以直接进行删除操作,树的高度也不会结构也不会发生变化;所以红黑树的删除操作的关键就是需要保证待删除节点是一个双键的节点。

在执行删除操作时我们也会实现到变色的操作,这里的变色和插入是的变色操作恰好相反,父节点变为黑色,两个子节点变为红色

protected void flipColors(Node h) {
    h.color = !h.color;
    h.left.color = !h.left.color;
    h.right.color = !h.right.color;
}

在正式实现删除操作之前,我们先来讨论下红黑树删除最小值和最大值的情况,最后实现的删除操作也会使用到删除最小值和删除最大值

删除最小值

二叉树删除最小值就是一直沿着树的左子树中查找,直到遇到一个节点的左子树为null,那么就删除该节点

红黑树的删除最小值类似,但是我们需要保证待删除的节点是一个双键的节点,所以在在递归到每个节点是都需要保住当前节点是双键节点,那么在最后找到的最小值就一定会在一个双键节点中(因为递归时已经保住的父节点是双键节点)。

那么如果保证当前递归节点是一个双键节点呢?这里就会有3中情况:

  • 当前节点的左子节点是一个双键节点,直接删除

  • 当前节点的左子节点是一个单键节点,但他的兄弟是一个双键节点,那么通过旋转移动一个节点到左子节点形成双键节点之后,再执行删除操作

  • 当前节点的左子节点和右子节点都是单键节点,那么通过变色与父节点共同形成三键节点之后,再执行删除

以上是红黑树删除最小值会遇到的所有情况,针对最后两种情况,为了代码的实现简单,我们考虑把这两种情况进行合并;

先把初始化根节点为红色,再进行变色,然后判断是否node.right.left是红色,如果是就进行旋转操作

删除最小值的代码实现如下:

private Node moveToRedLeft(Node h) {
    flipColors(h);
    if (isRed(h.right.left)) {
        h.right = rotateRight(h.right);
        h = rotateLeft(h);
        flipColors(h);
    }
    return h;
}

@Override
public void deleteMin() {
    if (isEmpty()) {
        throw new NoSuchElementException("BST underflow");
    }

    if (!isRed(root.left) && !isRed(root.right)) {
        root.color = Node.RED;
    }

    root = deleteMin(root);
    if (!isEmpty()) {
        root.color = Node.BLACK;
    }
}

private Node deleteMin(Node h) {
    if (h.left == null) {
        return null;
    }

    if (!isRed(h.left) && !isRed(h.left.left)) {
        h = moveToRedLeft(h);
    }

    h.left = deleteMin(h.left);
    return balance(h);
}

private Node balance(Node h) {
    if (isRed(h.right) && !isRed(h.left)) {
        h = rotateLeft(h);
    }
    if (isRed(h.left) && isRed(h.left.left)) {
        h = rotateRight(h);
    }
    if (isRed(h.left) && isRed(h.right)) {
        flipColors(h);
    }

    h.size = size(h.left) + size(h.right) + 1;
    return h;
}

在删除掉最小值之后,我们需要重新修复红黑树,因为之前我们的操作可能会导致3键节点的存在,删除之后我们需要重新分解3建节点;上面代码中的balance就是重新修复红黑树。

删除最大值

删除最大值思路和删除最小值的思路类似,这里就不详细叙述了,直接上图

删除最大值需要从左节点中借一个节点,代码实现如下:

@Override
public void deleteMax() {
    if (isEmpty()) {
        throw new NoSuchElementException("BST underflow");
    }

    if (!isRed(root.left) && !isRed(root.right)) {
        root.color = Node.RED;
    }

    root = deleteMax(root);
    if (!isEmpty()) {
        root.color = Node.BLACK;
    }

}

private Node deleteMax(Node node) {
    if (isRed(node.left)) { //此处与删除最小值不同,如果左边是红色,那么先借一个节点到右边来
        node = rotateRight(node);
    }
    if (Objects.isNull(node.right)) {
        return null;
    }
    if (!isRed(node.right) && !isRed(node.right.left)) {
        node = moveToRedRight(node);
    }
    node.right = deleteMax(node.right);
    return balance(node);
}

private Node moveToRedRight(Node node) {
    flipColors(node);
    if (isRed(node.left.left)) {
        node = rotateRight(node);
        flipColors(node);
    }
    return node;
}

删除任意节点

在查找路径上进行和删除最小值相同的变换可以保证在查找过程中任意当前节点不会是双键节点;

如果查找的键值在左节点,那么就执行与删除最小值类似的变化,从右边借节点;

如果查找的键值在右节点,那么就执行与删除最大值类似的变化,从左边借节点。

如果待删除的节点处理叶子节点,那么可以直接删除;如果是非叶子节点,那么左子树不为空就与左子树中最大值进行交换,然后删除左子树中的最大值,左子树为空就与右子树最小值进行交换,然后删除右子树中的最小值。

代码实现如下:

@Override
public void delete(K key) {
    if (isEmpty()) {
        throw new NoSuchElementException("BST underflow");
    }

    if (!isRed(root.left) && !isRed(root.right)) {
        root.color = Node.RED;
    }

    root = delete(root, key);
    if (!isEmpty()) {
        root.color = Node.BLACK;
    }
}


private Node delete(Node node, K key) {
    int compare = key.compareTo(node.key);
    if (compare < 0) {//左子树中查找
        if (!isRed(node.left) && !isRed(node.left.left)) {
            node = moveToRedLeft(node);
        }
        node.left = delete(node.left, key);
    } else if (compare > 0) { //右子树中查找
        if (isRed(node.left)) {
            node = rotateRight(node);
        }
        if (!isRed(node.right) && !isRed(node.right.left)) {
            node = moveToRedRight(node);
        }
        node.right = delete(node.right, key);
    } else {
        if (Objects.isNull(node.left) && Objects.isNull(node.right)) {
            return null; //叶子节点直接结束
        }

        if (Objects.nonNull(node.left)) { //左子树不为空
            Node max = max(node.left);
            node.key = max.key;
            node.value = max.value;
            node.left = deleteMax(node.left);
        } else { //右子树不为空
            Node min = min(node.right);
            node.key = min.key;
            node.value = min.value;
            node.right = deleteMin(node.right);
        }
    }
    return balance(node);
}

画出红黑树来验证实现

上面我们已经实现了红黑树的主要代码,但是如何验证我们的红黑树是不是真正的红黑树,最好的方式就是基于我们实现的版本画出红黑树,然后通过红黑树的性质来验证

由于如何画出红黑树不是本篇的重点,所以就不贴出代码了,有需要的朋友可以去仓库中查看;

编写单元来测试我们用红黑树实现的Map,并且画出红黑树验证是否正确

@Test
public void testDelete() throws IOException {
    RedBlack23RedBlackTreeMap<Integer, String> map = new RedBlack23RedBlackTreeMap<>();
    map.put(8, "80");
    map.put(18, "180");
    map.put(5, "50");
    map.put(15, "150");
    map.put(17, "170");
    map.put(25, "250");
    map.put(40, "40");
    map.put(80, "80");
    map.put(30, "30");
    map.put(60, "60");
    map.put(16, "16");

    map.draw("/Users/huaan9527/Desktop/redBlack4.png"); //画出删除之前的红黑树
    map.delete(40);
    map.delete(17);
    map.delete(25);
    map.nodes().forEach(System.out::println); //根据key从小到大顺序打印出节点

    map.draw("/Users/huaan9527/Desktop/redBlack5.png"); //画出删除之后的红黑树
}

顺序打印出node的执行的结果:

删除之前的红黑树

删除之后的红黑树

总结

本篇主要讨论的是基于2-3树实现的红黑树版本,理解并掌握了本篇内容也就掌握了红黑树,面试时根本不虚

为了加深对红黑树的理解,大家可以自行基于2-3-4树去实现红黑树,对比两种红黑树的版本差异,可以参考我的git仓库中的代码


文中所有源码已放入到了github仓库:
https://github.com/silently9527/JavaCore

给出一个红黑树网站演示,该网站实现红黑树的方式与本篇我们实现的方式不同,大家可以参考下:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html

最后(点关注,不迷路)

文中或许会存在或多或少的不足、错误之处,有建议或者意见也非常欢迎大家在评论交流。

最后,写作不易,请不要白嫖我哟,希望朋友们可以点赞评论关注三连,因为这些就是我分享的全部动力来源🙏

程序员常用的IDEA插件:https://github.com/silently9527/ToolsetIdeaPlugin

微信公众号:贝塔学Java

查看原文

赞 15 收藏 9 评论 4

tfzh 赞了文章 · 4月7日

PHP下的异步尝试四:PHP版的Promise

PHP下的异步尝试系列

如果你还不太了解PHP下的生成器和协程,你可以根据下面目录翻阅

  1. PHP下的异步尝试一:初识生成器
  2. PHP下的异步尝试二:初识协程
  3. PHP下的异步尝试三:协程的PHP版thunkify自动执行器
  4. PHP下的异步尝试四:PHP版的Promise
  5. PHP下的异步尝试五:PHP版的Promise的继续完善

Promise 实现

代码结构

│  │  autoload.php
│  │  promise1.php
│  │  promise2.php
│  │  promise3.php
│  │  promise4.php
│  │  promise5.php
│  │
│  └─classes
│          Promise1.php
│          Promise2.php
│          Promise3.php
│          Promise4.php
│          Promise5.php
│          PromiseState.php

尝试一 (Promise基础)

classess/PromiseState.php

final class PromiseState
{
    const PENDING = 'pending';
    const FULFILLED = 'fulfilled';
    const REJECTED = 'rejected';
}

classess/Promise1.php

// 尝试一
class Promise1
{
    private $value;
    private $reason;
    private $state;

    public function __construct(\Closure $func = null)
    {
        $this->state = PromiseState::PENDING;

        $func([$this, 'resolve'], [$this, 'reject']);
    }

    /**
     * 执行回调方法里的resolve绑定的方法
     * @param null $value
     */
    public function resolve($value = null)
    {
        // 回调执行resolve传参的值,赋值给result
        $this->value = $value;

        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::FULFILLED;
        }
    }

    public function reject($reason = null)
    {
        // 回调执行resolve传参的值,赋值给result
        $this->reason = $reason;

        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::REJECTED;
        }
    }

    public function getState()
    {
        return $this->state;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function getReason()
    {
        return $this->reason;
    }
}

promise1.php

require "autoload.php";

$promise = new Promise1(function($resolve, $reject) {
    $resolve("打印我");
});

var_dump($promise->getState());
var_dump($promise->getValue());

结果:

string(9) "fulfilled"
string(9) "打印我"

结论或问题:

我们在这里建构了最基础的Promise模型

尝试二 (增加链式then)

classess/Promise2.php

<?php
// 尝试二 (增加链式then)
class Promise2
{
    private $value;
    private $reason;
    private $state;

    public function __construct(\Closure $func = null)
    {
        $this->state = PromiseState::PENDING;

        $func([$this, 'resolve'], [$this, 'reject']);
    }

    public function then(\Closure $onFulfilled = null, \Closure $onRejected = null)
    {
        // 如果状态是fulfilled,直接回调执行并传参value
        if ($this->state == PromiseState::FULFILLED) {
            $onFulfilled($this->value);
        }

        // 如果状态是rejected,直接回调执行并传参reason
        if ($this->state == PromiseState::REJECTED) {
            $onRejected($this->reason);
        }

        // 返回对象自身,实现链式调用
        return $this;

    }

    /**
     * 执行回调方法里的resolve绑定的方法
     * 本状态只能从pending->fulfilled
     * @param null $value
     */
    public function resolve($value = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::FULFILLED;
            $this->value = $value;
        }
    }

    /**
     * 执行回调方法里的rejected绑定的方法
     * 本状态只能从pending->rejected
     * @param null $reason
     */
    public function reject($reason = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::REJECTED;
            $this->reason = $reason;
        }
    }

    public function getState()
    {
        return $this->state;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function getReason()
    {
        return $this->reason;
    }
}

promise2.php

<?php

require "autoload.php";

$promise = new Promise2(function($resolve, $reject) {
    $resolve("打印我");
});

$promise->then(function ($value) {
    var_dump($value);
}, function ($reason) {
    var_dump($reason);
})->then(function ($value) {
    var_dump($value);
}, function ($reason) {
    var_dump($reason);
});

结果:

string(9) "打印我"
string(9) "打印我"

结论或问题:

我们实现了链式then方法

如果我们的构造里的回调是异步执行的话,那么状态在没有变成fulfilled之前,我们then里的回调方法就永远没法执行

尝试三(真正的链式then)

classess/Promise3.php

// 解决思路:我们肯定要把then传入的回调,放到Promise构造里回调代码执行完后resolve调用后改变了state状态后再调用,所以我们必须存储到一个地方并方便后续调用
// 我们需要改造then、resolve和reject方法
class Promise3
{
    private $value;
    private $reason;
    private $state;
    private $fulfilledCallbacks = [];
    private $rejectedCallbacks = [];

    public function __construct(\Closure $func = null)
    {
        $this->state = PromiseState::PENDING;

        $func([$this, 'resolve'], [$this, 'reject']);
    }

    public function then(\Closure $onFulfilled = null, \Closure $onRejected = null)
    {
        // 如果是异步回调,状态未变化之前,then的回调方法压入相应的数组方便后续调用
        if ($this->state == PromiseState::PENDING) {
            $this->fulfilledCallbacks[] = static function() use ($onFulfilled, $value){
                $onFulfilled($this->value);
            };

            $this->rejectedCallbacks[] = static function() use ($onRejected, $reason){
                $onRejected($this->reason);
            };
        }

        // 如果状态是fulfilled,直接回调执行并传参value
        if ($this->state == PromiseState::FULFILLED) {
            $onFulfilled($this->value);
        }

        // 如果状态是rejected,直接回调执行并传参reason
        if ($this->state == PromiseState::REJECTED) {
            $onRejected($this->reason);
        }

        // 返回对象自身,实现链式调用
        return $this;

    }

    /**
     * 执行回调方法里的resolve绑定的方法
     * 本状态只能从pending->fulfilled
     * @param null $value
     */
    public function resolve($value = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::FULFILLED;
            $this->value = $value;

            array_walk($this->fulfilledCallbacks, function ($callback) {
                $callback();
            });
        }
    }

    /**
     * 执行回调方法里的rejected绑定的方法
     * 本状态只能从pending->rejected
     * @param null $reason
     */
    public function reject($reason = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::REJECTED;
            $this->reason = $reason;
        }
    }

    public function getState()
    {
        return $this->state;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function getReason()
    {
        return $this->reason;
    }
}

promise3.php

require "autoload.php";

$promise = new Promise3(function($resolve, $reject) {
    $resolve("打印我");
});

$promise->then(function ($value) {
    var_dump($value);
}, function ($reason) {
    var_dump($reason);
})->then(function ($value) {
    var_dump($value);
}, function ($reason) {
    var_dump($reason);
});

结果:

string(9) "打印我"
string(9) "打印我"

结论或问题:

我们这次基本实现了真正的链式then方法

不过在Promise/A+里规范,要求then返回每次都要求是一个新的Promise对象
then方法成功执行,相当于返回一个实例一个Promise回调里执行resolve方法,resolve值为then里return的值
then方法执行失败或出错,相当于返回一个实例一个Promise回调里执行rejected方法,rejected值为then里return的值

尝试四(then返回pormise对象, 并传递上一次的结果给下一个Promise对象)

classess/Promise4.php

class Promise4
{
    private $value;
    private $reason;
    private $state;
    private $fulfilledCallbacks = [];
    private $rejectedCallbacks = [];

    public function __construct(\Closure $func = null)
    {
        $this->state = PromiseState::PENDING;

        $func([$this, 'resolve'], [$this, 'reject']);
    }

    public function then(\Closure $onFulfilled = null, \Closure $onRejected = null)
    {
        $thenPromise = new Promise4(function ($reslove, $reject) use (&$thenPromise, $onFulfilled, $onRejected) {

            //$this 代表的当前的Promise对象,不要混淆了

            // 如果是异步回调,状态未变化之前,then的回调方法压入相应的数组方便后续调用
            if ($this->state == PromiseState::PENDING) {
                $this->fulfilledCallbacks[] = static function() use ($thenPromise, $onFulfilled, $reslove, $reject){
                    $value = $onFulfilled($this->value);
                    $this->resolvePromise($thenPromise, $value, $reslove, $reject);
                };

                $this->rejectedCallbacks[] = static function() use ($thenPromise, $onRejected, $reslove, $reject){
                    $reason = $onRejected($this->reason);
                    $this->resolvePromise($thenPromise, $reason, $reslove, $reject);
                };
            }

            // 如果状态是fulfilled,直接回调执行并传参value
            if ($this->state == PromiseState::FULFILLED) {
                $value = $onFulfilled($this->value);
                $this->resolvePromise($thenPromise, $value, $reslove, $reject);
            }

            // 如果状态是rejected,直接回调执行并传参reason
            if ($this->state == PromiseState::REJECTED) {
                $reason = $onRejected($this->reason);
                $this->resolvePromise($thenPromise, $reason, $reslove, $reject);
            }

        });

        // 返回对象自身,实现链式调用
        return $thenPromise;

    }

    /**
     * 解决Pormise链式then传递
     * 可参考 [Promises/A+]2.3 [https://promisesaplus.com/#the-promise-resolution-procedure]
     * @param $thenPromise
     * @param $x            $x为thenable对象
     * @param $resolve
     * @param $reject
     */
    private function resolvePromise($thenPromise, $x, $resolve, $reject)
    {
        $called = false;

        if ($thenPromise === $x) {
            return $reject(new \Exception('循环引用'));
        }

        if ( is_object($x) && method_exists($x, 'then')) {

            $resolveCb = function ($value) use($thenPromise, $resolve, $reject, $called) {
                if ($called) return ;
                $called = true;
                // 成功值y有可能还是promise或者是具有then方法等,再次resolvePromise,直到成功值为基本类型或者非thenable
                $this->resolvePromise($thenPromise, $value, $resolve, $reject);
            };

            $rejectCb = function($reason) use($thenPromise, $resolve, $reject, $called) {
                if ($called) return ;
                $called = true;
                $reject($reason);
            };

            call_user_func_array([$x, 'then'], [$resolveCb, $rejectCb]);

        } else {
            if ($called) return ;
            $called = true;
            $resolve($x);
        }
    }

    /**
     * 执行回调方法里的resolve绑定的方法
     * 本状态只能从pending->fulfilled
     * @param null $value
     */
    public function resolve($value = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::FULFILLED;
            $this->value = $value;

            array_walk($this->fulfilledCallbacks, function ($callback) {
                $callback();
            });
        }
    }

    /**
     * 执行回调方法里的rejected绑定的方法
     * 本状态只能从pending->rejected
     * @param null $reason
     */
    public function reject($reason = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::REJECTED;
            $this->reason = $reason;
        }
    }

    public function getState()
    {
        return $this->state;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function getReason()
    {
        return $this->reason;
    }
}

promise4.php

require "autoload.php";

$promise1 = new Promise4(function($resolve, $reject) {
    $resolve("打印我");
});

$promise2 = $promise1->then(function ($value) {
    var_dump($value);
    return "promise2";
}, function ($reason) {
    var_dump($reason);
});

$promise3 = $promise2->then(function ($value) {
    var_dump($value);
    return new Promise4(function($resolve, $reject) {
        $resolve("promise3");
    });
}, function ($reason) {
    var_dump($reason);
});

$promise4 = $promise3->then(function ($value) {
    var_dump($value);
    return "promise4";
}, function ($reason) {
    var_dump($reason);
});

var_dump($promise4);

结果:

string(9) "打印我"
string(8) "promise2"
string(8) "promise3"
object(Promise4)#15 (5) {
  ["value":"Promise4":private]=>
  string(8) "promise4"
  ["reason":"Promise4":private]=>
  NULL
  ["state":"Promise4":private]=>
  string(9) "fulfilled"
  ["fulfilledCallbacks":"Promise4":private]=>
  array(0) {
  }
  ["rejectedCallbacks":"Promise4":private]=>
  array(0) {
  }
}

结论或问题:

一个基本的Pormise,不过我们上面都是基于成功fulfilled状态的实现
下面我们来增加错误捕获

尝试五(错误捕获)

classess/Promise5.php

class Promise5
{
    private $value;
    private $reason;
    private $state;
    private $fulfilledCallbacks = [];
    private $rejectedCallbacks = [];

    public function __construct(\Closure $func = null)
    {
        $this->state = PromiseState::PENDING;

        $func([$this, 'resolve'], [$this, 'reject']);
    }

    public function then(\Closure $onFulfilled = null, \Closure $onRejected = null)
    {
        // 此处作用是兼容then方法的以下四种参数变化,catchError就是第二种情况
        // 1. then($onFulfilled, null)
        // 2. then(null, $onRejected)
        // 3. then(null, null)
        // 4. then($onFulfilled, $onRejected)
        $onFulfilled = is_callable($onFulfilled) ? $onFulfilled :  function ($value) {return $value;};
        $onRejected = is_callable($onRejected) ? $onRejected :  function ($reason) {throw $reason;};

        $thenPromise = new Promise5(function ($reslove, $reject) use (&$thenPromise, $onFulfilled, $onRejected) {

            //$this 代表的当前的Promise对象,不要混淆了

            // 如果是异步回调,状态未变化之前,then的回调方法压入相应的数组方便后续调用
            if ($this->state == PromiseState::PENDING) {
                $this->fulfilledCallbacks[] = static function() use ($thenPromise, $onFulfilled, $reslove, $reject){
                    try {
                        $value = $onFulfilled($this->value);
                        $this->resolvePromise($thenPromise, $value, $reslove, $reject);
                    } catch (\Exception $e) {
                        $reject($e);
                    }
                };

                $this->rejectedCallbacks[] = static function() use ($thenPromise, $onRejected, $reslove, $reject){
                    try {
                        $reason = $onRejected($this->reason);
                        $this->resolvePromise($thenPromise, $reason, $reslove, $reject);
                    } catch (\Exception $e) {
                        $reject($e);
                    }
                };
            }

            // 如果状态是fulfilled,直接回调执行并传参value
            if ($this->state == PromiseState::FULFILLED) {
                try {
                    $value = $onFulfilled($this->value);
                    $this->resolvePromise($thenPromise, $value, $reslove, $reject);
                } catch (\Exception $e) {
                    $reject($e);
                }
            }

            // 如果状态是rejected,直接回调执行并传参reason
            if ($this->state == PromiseState::REJECTED) {
                try {
                    $reason = $onRejected($this->reason);
                    $this->resolvePromise($thenPromise, $reason, $reslove, $reject);
                } catch (\Exception $e) {
                    $reject($e);
                }
            }

        });

        // 返回对象自身,实现链式调用
        return $thenPromise;

    }

    public function catchError($onRejected)
    {
        return $this->then(null, $onRejected);
    }

    /**
     * 解决Pormise链式then传递
     * 可参考 [Promises/A+]2.3 [https://promisesaplus.com/#the-promise-resolution-procedure]
     * @param $thenPromise
     * @param $x            $x为thenable对象
     * @param $resolve
     * @param $reject
     */
    private function resolvePromise($thenPromise, $x, $resolve, $reject)
    {
        $called = false;

        if ($thenPromise === $x) {
            return $reject(new \Exception('循环引用'));
        }

        if ( is_object($x) && method_exists($x, 'then')) {
            try {
                $resolveCb = function ($value) use ($thenPromise, $resolve, $reject, $called) {
                    if ($called) return;
                    $called = true;
                    // 成功值y有可能还是promise或者是具有then方法等,再次resolvePromise,直到成功值为基本类型或者非thenable
                    $this->resolvePromise($thenPromise, $value, $resolve, $reject);
                };

                $rejectCb = function ($reason) use ($thenPromise, $resolve, $reject, $called) {
                    if ($called) return;
                    $called = true;
                    $reject($reason);
                };

                call_user_func_array([$x, 'then'], [$resolveCb, $rejectCb]);
            } catch (\Exception $e) {
                if ($called) return ;
                $called = true;
                $reject($e);
            }

        } else {
            if ($called) return ;
            $called = true;
            $resolve($x);
        }
    }

    /**
     * 执行回调方法里的resolve绑定的方法
     * 本状态只能从pending->fulfilled
     * @param null $value
     */
    public function resolve($value = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::FULFILLED;
            $this->value = $value;

            array_walk($this->fulfilledCallbacks, function ($callback) {
                $callback(); //因为回调本身携带了作用于,所以直接调用,无法参数
            });
        }
    }

    /**
     * 执行回调方法里的rejected绑定的方法
     * 本状态只能从pending->rejected
     * @param null $reason
     */
    public function reject($reason = null)
    {
        if ($this->state == PromiseState::PENDING) {
            $this->state = PromiseState::REJECTED;
            $this->reason = $reason;

            array_walk($this->rejectedCallbacks, function ($callback) {
                $callback(); //因为回调本身携带了作用于,所以直接调用,无法参数
            });
        }
    }

    public function getState()
    {
        return $this->state;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function getReason()
    {
        return $this->reason;
    }
}

promise5.php

require "autoload.php";

$promise1 = new Promise5(function($resolve, $reject) {
    $resolve("打印我");
});

$promise2 = $promise1->then(function ($value) {
    var_dump($value);
    throw new \Exception("promise2 error");
    return "promise2";
}, function ($reason) {
    var_dump($reason->getMessage());
    return "promise3 error return";
});

//我们可以简写then方法,只传入$onFulfilled方法,然后错误会自己冒泡方式到下一个catchError或then里处理。
//$promise3 = $promise2->then(function ($value) {
//    var_dump($value);
//    return new Promise5(function($resolve, $reject) {
//        $resolve("promise3");
//    });
//})->catchError(function ($reason) {
//    var_dump($reason->getMessage());
//    return "promise3 error return";
//});

$promise3 = $promise2->then(function ($value) {
    var_dump($value);
    return new Promise5(function($resolve, $reject) {
        $resolve("promise3");
    });
}, function ($reason) {
    var_dump($reason->getMessage());
    return "promise3 error return";
});

$promise4 = $promise3->then(function ($value) {
    var_dump($value);
    return "promise4";
}, function ($reason) {
    echo $reason->getMessage();
});

var_dump($promise4);

结果:

string(9) "打印我"
string(14) "promise2 error"
string(21) "promise3 error return"
object(Promise4)#10 (5) {
  ["value":"Promise4":private]=>
  string(8) "promise4"
  ["reason":"Promise4":private]=>
  NULL
  ["state":"Promise4":private]=>
  string(9) "fulfilled"
  ["fulfilledCallbacks":"Promise4":private]=>
  array(0) {
  }
  ["rejectedCallbacks":"Promise4":private]=>
  array(0) {
  }
}

结论或问题:

这里我们基础实现了一个可以用于生产环境的Promise
后续我们会接续完善这个Promise的特有方法,比如:finally, all, race, resolve, reject等
后续再介绍用Promise实现的自动执行器等

附录参考

Promises/A+
Promises/A+ 中文
Promise 对象 - ECMAScript 6 入门 阮一峰

查看原文

赞 21 收藏 15 评论 0

tfzh 赞了文章 · 3月17日

搞懂这 9 步,DNS 访问原理就明明白白了

图片

又到了招聘季了,前两天遇到一个面试的小伙伴,他说面试官和他聊得很投机,无意中谈到了DNS请求的过程。他一时语塞随便应付了两句,虽然对方没有追问的意思,但最后面试结果也并不理想。本着边面试边学习的态度,我们来看看DNS请求的过程中涉及到的定义和原理。

DNS的含义和结构

众所周知,在互联网中是用IP来标识一台服务器的。IP地址虽然能够代表一台设备,但是由于记忆起来比较困难,所以将其替换成一个能够理解和识别的名字,这个名字我们称作为域名。例如:www.51cto.com 就是一个域名,在域名后面会定义一个IP地址用来指向网站服务器。那么问题来了,谁来做这个从域名到IP地址的对应呢?答案是通过DNS来实现。

DNS 是域名系统(Domain Name System,缩写:DNS)是互联网的一项服务。它将域名和IP地址相互映射的一个分布式数据库,在数据库中保存域名与IP的对照关系,从而使人更方便地访问互联网。

DNS解析是分布式存储的,从结构上来说最顶层是,根域名服务器(ROOT DNS Server),存储260个顶级域名服务器的IP地址。对于Ipv4来说全球有13个根域名服务器,它储存了每个域(如.com .net .cn)的解析和域名服务器的地址信息。简单的说,根域名服务器就是存放顶级域名服务器地址的。

在根域名服务器下一级就是,顶级域名服务器。例如.com的域名服务器,存储的是一些一级域名的权威DNS服务器地址(如toutiao.com的DNS)。

顶级域名又称一级域名,顶级域名可以分为三类,即gTLD、ccTLD和New gTLD:

  • gTLD:国际顶级域名(generic top-level domains,gTLD),例如:.com/.net/.org等都属于gTLD;
  • ccTLD:国家和地区顶级域名(country code top-level domains,简称ccTLD),例如:中国是.cn域名,日本是.jp域名;
  • New gTLD:新顶级域名(New gTLD),例如:.xyz/.top/.red/.help等新顶级域名。

顶级域名服务器就是根据上面三类保存域名IP对应数据的。

在顶级域名服务器下面一级就是,本地域名服务器(Local DNS)一般是运营商的DNS,主要作用就是代理用户进行域名分析的。

如图1 所示,DNS域名服务器分为三级,从上到下分别是根域名服务器(Root DNS Server)、顶级域名服务器(gTLD、ccTLD、New gTLD)、本地域名服务器(Local DNS Server)。

图片图1 DNS分层结构

DNS解析原理

说完DNS的结构,再来谈谈其运行原理。通过用户访问网页的过程,来描述DNS解析以及获取URL到IP映射的整个过程。其中过程比较复杂,会存在信息的来回传递。画图的过程中我们会简化信息来回传递的线段,重点放在信息传递的路径,通过9步来诠释DNS解析过程。

图片图2 用户请求以及DNS解析的全过程

  • 1、用户请求通过浏览器输入要访问网站的地址,例如:www.51cto.com。浏览器会在自己的缓存中查找URL对应IP地址。如果之前访问过,保存了这个URL对应IP地址的缓存,那么就直接访问IP地址。如果没有缓存,进入到第2步。
  • 2、通过计算机本地的Host文件配置,可以设置URL和IP地址的映射关系。比如windows下是通过C:windwossystem32driveretchosts文件来设置的,linux中则是/etc/named.confg文件。这里查找本地的Host文件,看是有IP地址的缓存。如果在文件中依旧没有找到映射关系,进入第3步。
  • 3、请求Local DNS Server,通过本地运营商获取URL和IP的映射关系。如果在校园网,DNS服务器就在学校,如果是小区网络,DNS服务器是运营商提供的。总之这个服务器在物理位置上离发起请求的计算机比较近。Local DNS Server缓存了大量的DNS解析结果。由于它的性能较好,物理上的距离又比较近,它通常会在很短的时间内返回指定域名的解析结果。80%的DNS解析需求在这一步就满足了。如果在这一步还是没有完成DNS解析,进入第4步
  • 4、通过Root DNS Server进行解析,ROOT DNS Server会根据请求的URL 返回给Local DNS Server顶级域名服务器的地址。例如:查询的是”.com”的域名,就查询 gTL对应的域名服务器的地址。
  • 5、返回顶级域名服务器的地址以后,访问对应的顶级域名服务器(gTLD、ccTLD、New gTLD),并且返回Name Server服务器地址。这个Name Server就是网站注册的域名服务器,上面包含了网站URL和IP的对应信息。例如你在某个域名服务提供商申请的域名,这个域名就由他们的服务器来解析。这个Name Server是由域名提供商维护的。
  • 6、Name Server会把指定域名的A记录或者CNAME返回给Local DNS Server,并且设置一个TTL。
A (Address) 记录是用来指定主机名(或域名)对应的IP地址记录。用户可以将该域名下的网站服务器指向到自己的web server上。同时也可以设置您域名的二级域名。
CNAME:别名记录。这种记录允许您将多个名字映射到另外一个域名。通常用于同时提供WWW和MAIL服务的计算机。例如,有一台计算机名为“host.mydomain.com”(A记录)。它同时提供WWW和MAIL服务,为了便于用户访问服务。服务商从方便维护的角度,一般也建议用户使用CNAME记录绑定域名的。如果主机使用了双线IP,显然使用CNAME也要方便一些。
TTL(Time To Live):也就是设置这个DNS解析在Local DNS Server上面的过期时间。超过了这个过期时间,URL和IP的映射就会被删除,需要获取还要请求Name Server。
  • 7、如果此时获取的是A记录,那么就可以直接访问网站的IP了。但是通常来说大型的网站都会返回CNAME,然后将其传给GTM Server。

GTM(Global Traffic Manager的简写)即全局流量管理,基于网宿智能DNS、分布式监控体系,实现实时故障切换及全球负载均衡,保障应用服务的持续高可用性。传给GTM的目的就是希望通过GTM的负载均衡机制,帮助用户找到最适合自己的服务器IP。

也就是离自己最近,性能最好,服务器状态最健康的。而且大多数的网站会做CDN缓存,此时就更需要使用GTM帮你找到网络节点中适合你的CDN缓存服务器。

  • 8、找到CDN缓存服务器以后,可以直接从服务器上面获取一些静态资源,例如:HTML、CSS、JS和图片。但是一些动态资源,例如商品信息,订单信息,需要通过第9步。
  • 9、对于没有缓存的动态资源需要从应用服务器获取,在应用服务器与互联网之间通常有一层负载均衡器负责反向代理。有它路由到应用服务器上。

总结

NS服务器是用来做URL与IP地址解析的,帮助用户找到要访问服务器的IP。从DNS服务器的结构来说大致分为三层:根域名服务器,顶级域名服务器,本地域名服务器。

申请域名的供应商会提供Name Server作为DNS解析。从用户访问一个网站出发,经过浏览器,本地Host文件、Local DNS Server、Root DNS Server、顶级域名服务器(gTLD、ccTLD、New gTLD)、Name Server、GTM、CDN、Application Server。共经历了九个步骤。

作者:51CTO崔皓
来源:https://blog.51cto.com/142793...

image

查看原文

赞 5 收藏 3 评论 1

tfzh 赞了文章 · 3月16日

彻夜怒肝!Docker 常见疑难杂症解决方案已撸完,快要裂开了。。。

图片

这里主要是为了记录在使用 Docker 的时候遇到的问题及其处理解决方法。

图片

1.Docker 迁移存储目录


默认情况系统会将 Docker 容器存放在/var/lib/docker 目录下

问题起因:今天通过监控系统,发现公司其中一台服务器的磁盘快慢,随即上去看了下,发现 /var/lib/docker 这个目录特别大。由上述原因,我们都知道,在 /var/lib/docker 中存储的都是相关于容器的存储,所以也不能随便的将其删除掉。

那就准备迁移 docker 的存储目录吧,或者对 /var 设备进行扩容来达到相同的目的。更多关于 dockerd 的详细参数,请点击查看 官方文档 地址。

但是需要注意的一点就是,尽量不要用软链, 因为一些 docker 容器编排系统不支持这样做,比如我们所熟知的 k8s 就在内。

# 发现容器启动不了了
ERROR:cannot  create temporary directory!
# 查看系统存储情况
$ du -h --max-depth=1

解决方法1:添加软链接

# 1.停止docker服务
$ sudo systemctl stop docker
# 2.开始迁移目录
$ sudo mv /var/lib/docker /data/
# 3.添加软链接
# sudo ln -s /data/docker /var/lib/docker
# 4.启动docker服务
$ sudo systemctl start docker

解决方法2:改动 docker 配置文件

# 3.改动docker启动配置文件
$ sudo vim /lib/systemd/system/docker.service
ExecStart=/usr/bin/dockerd --graph=/data/docker/
# 4.改动docker启动配置文件
$ sudo vim /etc/docker/daemon.json
{
    "live-restore": true,
    "graph": [ "/data/docker/" ]
}

操作注意事项:在迁移 docker 目录的时候注意使用的命令,要么使用 mv 命令直接移动,要么使用 cp 命令复制文件,但是需要注意同时复制文件权限和对应属性,不然在使用的时候可能会存在权限问题。如果容器中,也是使用 root 用户,则不会存在该问题,但是也是需要按照正确的操作来迁移目录。

# 使用mv命令
$ sudo mv /var/lib/docker /data/docker
# 使用cp命令
$ sudo cp -arv /data/docker /data2/docker

下图中,就是因为启动的容器使用的是普通用户运行进程的,且在运行当中需要使用 /tmp 目录,结果提示没有权限。在我们导入容器镜像的时候,其实是会将容器启动时需要的各个目录的权限和属性都赋予了。如果我们直接是 cp 命令单纯复制文件内容的话,就会出现属性不一致的情况,同时还会有一定的安全问题。

图片

2.Docker 设备空间不足

Increase Docker container size from default 10GB on rhel7.

问题起因一:容器在导入或者启动的时候,如果提示磁盘空间不足的,那么多半是真的因为物理磁盘空间真的有问题导致的。如下所示,我们可以看到 / 分区确实满了。

# 查看物理磁盘空间
$ df -Th
Filesystem    Size    Used    Avail    Use%    Mounted on
/dev/vda1      40G     40G       0G    100%    /
tmpfs         7.8G       0     7.8G      0%    /dev/shm
/dev/vdb1     493G    289G     179G     62%    /mnt

如果发现真的是物理磁盘空间满了的话,就需要查看到底是什么占据了如此大的空间,导致因为容器没有空间无法启动。其中,docker 自带的命令就是一个很好的能够帮助我们发现问题的工具。

# 查看基本信息
# 硬件驱动使用的是devicemapper,空间池为docker-252
# 磁盘可用容量仅剩16.78MB,可用供我们使用
$ docker info
Containers: 1
Images: 28
Storage Driver: devicemapper
 Pool Name: docker-252:1-787932-pool
 Pool Blocksize: 65.54 kB
 Backing Filesystem: extfs
 Data file: /dev/loop0
 Metadata file: /dev/loop1
 Data Space Used: 1.225 GB
 Data Space Total: 107.4 GB
 Data Space Available: 16.78 MB
 Metadata Space Used: 2.073 MB
 Metadata Space Total: 2.147 GB

解决方法:通过查看信息,我们知道正是因为 docker 可用的磁盘空间不足,所以导致启动的时候没有足够的空间进行加载启动镜像。解决的方法也很简单,第一就是清理无效数据文件释放磁盘空间(清除日志),第二就是修改 docker 数据的存放路径(大分区)。

# 显示哪些容器目录具有最大的日志文件
$ du -d1 -h /var/lib/docker/containers | sort -h
# 清除您选择的容器日志文件的内容
$ cat /dev/null > /var/lib/docker/containers/container_id/container_log_name

问题起因二:显然我遇到的不是上一种情况,而是在启动容器的时候,容器启动之后不久就显示是 unhealthy 的状态,通过如下日志发现,原来是复制配置文件启动的时候,提示磁盘空间不足。

后面发现是因为 CentOS7 的系统使用的 docker 容器默认的创建大小就是 10G 而已,然而我们使用的容器却超过了这个限制,导致无法启动时提示空间不足。

2019-08-16 11:11:15,816 INFO spawned: 'app-demo' with pid 835
2019-08-16 11:11:16,268 INFO exited: app (exit status 1; not expected)
2019-08-16 11:11:17,270 INFO gave up: app entered FATAL state, too many start retries too quickly
cp: cannot create regular file '/etc/supervisor/conf.d/grpc-app-demo.conf': No space left on device
cp: cannot create regular file '/etc/supervisor/conf.d/grpc-app-demo.conf': No space left on device
cp: cannot create regular file '/etc/supervisor/conf.d/grpc-app-demo.conf': No space left on device
cp: cannot create regular file '/etc/supervisor/conf.d/grpc-app-demo.conf': No space left on device

解决方法1:改动 docker 启动配置文件

# /etc/docker/daemon.json
{
    "live-restore": true,
    "storage-opt": [ "dm.basesize=20G" ]
}

解决方法2:改动 systemctl 的 docker 启动文件

# 1.stop the docker service
$ sudo systemctl stop docker
# 2.rm exised container
$ sudo rm -rf /var/lib/docker
# 2.edit your docker service file
$ sudo vim /usr/lib/systemd/system/docker.service
# 3.find the execution line
ExecStart=/usr/bin/dockerd
and change it to:
ExecStart=/usr/bin/dockerd --storage-opt dm.basesize=20G
# 4.start docker service again
$ sudo systemctl start docker
# 5.reload daemon
$ sudo systemctl daemon-reload

问题起因三:还有一种情况也会让容器无法启动,并提示磁盘空间不足,但是使用命令查看发现并不是因为物理磁盘真的不足导致的。而是,因为对于分区的 inode 节点数满了导致的。

# 报错信息
No space left on device

解决方法:因为 ext3 文件系统使用 inode table 存储 inode 信息,而 xfs 文件系统使用 B+ tree 来进行存储。考虑到性能问题,默认情况下这个 B+ tree 只会使用前 1TB 空间,当这 1TB 空间被写满后,就会导致无法写入 inode 信息,报磁盘空间不足的错误。我们可以在 mount 时,指定 inode64 即可将这个 B+ tree 使用的空间扩展到整个文件系统。

# 查看系统的inode节点使用情况
$ sudo df -i
# 尝试重新挂载
$ sudo mount -o remount -o noatime,nodiratime,inode64,nobarrier /dev/vda1

补充知识:文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector)。每个扇区储存 512 字节(相当于0.5KB)。操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。这种由多个扇区组成的”块”,是文件存取的最小单位。”块”的大小,最常见的是4KB,即连续八个 sector 组成一个 block 块。文件数据都储存在”块”中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做“索引节点”(inode)。每一个文件都有对应的 inode,里面包含了除了文件名以外的所有文件信息。

inode 也会消耗硬盘空间,所以硬盘格式化的时候,操作系统自动将硬盘分成两个区域。一个是数据区,存放文件数据;另一个是 inode 区(inode table),存放 inode 所包含的信息。每个 inode 节点的大小,一般是 128 字节或 256 字节。inode 节点的总数,在格式化时就给定,一般是每1KB或每2KB就设置一个 inode 节点。

# 每个节点信息的内容
$ stat check_port_live.sh
  File: check_port_live.sh
  Size: 225           Blocks: 8          IO Block: 4096   regular file
Device: 822h/2082d    Inode: 99621663    Links: 1
Access: (0755/-rwxr-xr-x)  Uid: ( 1006/  escape)   Gid: ( 1006/  escape)
Access: 2019-07-29 14:59:59.498076903 +0800
Modify: 2019-07-29 14:59:59.498076903 +0800
Change: 2019-07-29 23:20:27.834866649 +0800
 Birth: -
# 磁盘的inode使用情况
$ df -i
Filesystem                 Inodes   IUsed     IFree IUse% Mounted on
udev                     16478355     801  16477554    1% /dev
tmpfs                    16487639    2521  16485118    1% /run
/dev/sdc2               244162560 4788436 239374124    2% /
tmpfs                    16487639       5  16487634    1% /dev/shm

3.Docker 缺共享链接库

Docker 命令需要对/tmp 目录下面有访问权限

问题起因:给系统安装完 compose 之后,查看版本的时候,提示缺少一个名为 libz.so.1 的共享链接库。第一反应就是,是不是系统少安装那个软件包导致的。随即,搜索了一下,将相关的依赖包都给安装了,却还是提示同样的问题。

# 提示错误信息
$ docker-compose --version
error while loading shared libraries: libz.so.1: failed to map segment from shared object: Operation not permitted

解决方法:后来发现,是因为系统中 docker 没有对 /tmp 目录的访问权限导致,需要重新将其挂载一次,就可以解决了。

# 重新挂载
$ sudo mount /tmp -o remount,exec

4.Docker 容器文件损坏

对 dockerd 的配置有可能会影响到系统稳定

问题起因:容器文件损坏,经常会导致容器无法操作。正常的 docker 命令已经无法操控这台容器了,无法关闭、重启、删除。正巧,前天就需要这个的问题,主要的原因是因为重新对 docker 的默认容器进行了重新的分配限制导致的。

# 操作容器遇到类似的错误
b'devicemapper: Error running deviceCreate (CreateSnapDeviceRaw) dm_task_run failed'

解决方法:可以通过以下操作将容器删除/重建。

# 1.关闭docker
$ sudo systemctl stop docker
# 2.删除容器文件
$ sudo rm -rf /var/lib/docker/containers
# 3.重新整理容器元数据
$ sudo thin_check /var/lib/docker/devicemapper/devicemapper/metadata
$ sudo thin_check --clear-needs-check-flag /var/lib/docker/devicemapper/devicemapper/metadata
# 4.重启docker
$ sudo systemctl start docker

5.Docker 容器优雅重启

不停止服务器上面运行的容器,重启 dockerd 服务是多么好的一件事

问题起因:默认情况下,当 Docker 守护程序终止时,它会关闭正在运行的容器。从 Docker-ce 1.12 开始,可以在配置文件中添加 live-restore 参数,以便在守护程序变得不可用时容器保持运行。需要注意的是 Windows 平台暂时还是不支持该参数的配置。

# Keep containers alive during daemon downtime
$ sudo vim /etc/docker/daemon.yaml
{
  "live-restore": true
}
# 在守护进程停机期间保持容器存活
$ sudo dockerd --live-restore
# 只能使用reload重载
# 相当于发送SIGHUP信号量给dockerd守护进程
$ sudo systemctl reload docker
# 但是对应网络的设置需要restart才能生效
$ sudo systemctl restart docker

解决方法:可以通过以下操作将容器删除/重建。

# /etc/docker/daemon.yaml
{
    "registry-mirrors": ["https://vec0xydj.mirror.aliyuncs.com"],  # 配置获取官方镜像的仓库地址
    "experimental": true,  # 启用实验功能
    "default-runtime": "nvidia",  # 容器的默认OCI运行时(默认为runc)
    "live-restore": true,  # 重启dockerd服务的时候容易不终止
    "runtimes": {  # 配置容器运行时
        "nvidia": {
            "path": "/usr/bin/nvidia-container-runtime",
            "runtimeArgs": []
        }
    },
    "default-address-pools": [  # 配置容器使用的子网地址池
        {
            "scope": "local",
            "base":"172.17.0.0/12",
            "size":24
        }
    ]
}

6.Docker 容器无法删除

找不到对应容器进程是最吓人的

问题起因:今天遇到 docker 容器无法停止/终止/删除,以为这个容器可能又出现了 dockerd 守护进程托管的情况,但是通过ps -ef <container id>无法查到对应的运行进程。哎,后来开始开始查 supervisor 以及 Dockerfile 中的进程,都没有。这种情况的可能原因是容器启动之后,之后,主机因任何原因重新启动并且没有优雅地终止容器。剩下的文件现在阻止你重新生成旧名称的新容器,因为系统认为旧容器仍然存在。

# 删除容器
$ sudo docker rm -f f8e8c3..
Error response from daemon: Conflict, cannot remove the default name of the container

解决方法:找到 /var/lib/docker/containers/ 下的对应容器的文件夹,将其删除,然后重启一下 dockerd 即可。我们会发现,之前无法删除的容器没有了。

# 删除容器文件
$ sudo rm -rf /var/lib/docker/containers/f8e8c3...65720
# 重启服务
$ sudo systemctl restart docker.service

7.Docker 容器中文异常

容器存在问题话,记得优先在官网查询

问题起因:今天登陆之前部署的 MySQL 数据库查询,发现使用 SQL 语句无法查询中文字段,即使直接输入中文都没有办法显示。

# 查看容器支持的字符集
root@b18f56aa1e15:# locale -a
C
C.UTF-8
POSIX

解决方法:Docker 部署的 MySQL 系统使用的是 POSIX 字符集。然而 POSIX 字符集是不支持中文的,而 C.UTF-8 是支持中文的只要把系统中的环境 LANG 改为 "C.UTF-8" 格式即可解决问题。同理,在 K8S 进入 pod 不能输入中文也可用此方法解决。

# 临时解决
docker exec -it some-mysql env LANG=C.UTF-8 /bin/bash
# 永久解决
docker run --name some-mysql 
    -e MYSQL_ROOT_PASSWORD=my-secret-pw 
    -d mysql:tag --character-set-server=utf8mb4 
    --collation-server=utf8mb4_unicode_ci

8.Docker 容器网络互通

了解 Docker 的四种网络模型

问题起因:在本机部署 Nginx 容器想代理本机启动的 Python 后端服务程序,但是对代码服务如下的配置,结果访问的时候一直提示 502 错误。

# 启动Nginx服务
$ docker run -d -p 80:80 $PWD:/etc/nginx nginx
nginx
server {
    ...
    location /api {
        proxy_pass http://localhost:8080
    }
    ...
}

解决方法:后面发现是因为 nginx.conf 配置文件中的 localhost 配置的有问题,由于 Nginx 是在容器中运行,所以 localhost 为容器中的 localhost,而非本机的 localhost,所以导致无法访问。

可以将 nginx.conf 中的 localhost 改为宿主机的 IP 地址,就可以解决 502 的错误。

# 查询宿主机IP地址 => 172.17.0.1
$ ip addr show docker0
docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:d5:4c:f2:1e brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:d5ff:fe4c:f21e/64 scope link
       valid_lft forever preferred_lft forever
nginx
server {
    ...
    location /api {
        proxy_pass http://172.17.0.1:8080
    }
    ...
}

当容器使用 host 网络时,容器与宿主共用网络,这样就能在容器中访问宿主机网络,那么容器的 localhost 就是宿主机的 localhost 了。

# 服务的启动方式有所改变(没有映射出来端口)
# 因为本身与宿主机共用了网络,宿主机暴露端口等同于容器中暴露端口
$ docker run -d -p 80:80 --network=host $PWD:/etc/nginx nginxx

9.Docker 容器总线错误

总线错误看到的时候还是挺吓人了

问题起因:在 docker 容器中运行程序的时候,提示 bus error 错误。

# 总线报错
$ inv app.user_op --name=zhangsan
Bus error (core dumped)

解决方法:原因是在 docker 运行的时候,shm 分区设置太小导致 share memory 不够。不设置 –shm-size 参数时,docker 给容器默认分配的 shm 大小为 64M,导致程序启动时不足。

# 启动docker的时候加上--shm-size参数(单位为b,k,m或g)
$ docker run -it --rm --shm-size=200m pytorch/pytorch:latest

解决方法:还有一种情况就是容器内的磁盘空间不足,也会导致 bus error 的报错,所以清除多余文件或者目录,就可以解决了。

# 磁盘空间不足
$ df -Th
Filesystem     Type     Size  Used Avail Use% Mounted on
overlay        overlay    1T    1T    0G 100% /
shm            tmpfs     64M   24K   64M   1% /dev/shm

10.Docker NFS 挂载报错

总线错误看到的时候还是挺吓人了

问题起因:我们将服务部署到 openshift 集群中,启动服务调用资源文件的时候,报错信息如下所示。从报错信息中,得知是在 Python3 程序执行 read_file() 读取文件的内容,给文件加锁的时候报错了。但是奇怪的是,本地调试的时候发现服务都是可以正常运行的,文件加锁也是没问题的。后来发现,在 openshift 集群中使用的是 NFS 挂  载的共享磁盘。

# 报错信息
Traceback (most recent call last):
    ......
    File "xxx/utils/storage.py", line 34, in xxx.utils.storage.LocalStorage.read_file
OSError: [Errno 9] Bad file descriptor
# 文件加锁代码
...
    with open(self.mount(path), 'rb') as fileobj:
        fcntl.flock(fileobj, fcntl.LOCK_EX)
        data = fileobj.read()
    return data
...

解决方法:从下面的信息得知,要在 Linux 中使用 flock() 的话,就需要升级内核版本到 2.6.11+ 才行。后来才发现,这实际上是由 RedHat 內核中的一个错误引起的,并在 kernel-3.10.0-693.18.1.el7 版本中得到修复。所以对于 NFSv3 和 NFSv4 服务而已,就需要升级 Linux 内核版本才能够解决这个问题。

# https://t.codebug.vip/questions-930901.htm
$ In Linux kernels up to 2.6.11, flock() does not lock files over NFS (i.e.,
the scope of locks was limited to the local system). [...] Since Linux 2.6.12,
NFS clients support flock() locks by emulating them as byte-range locks on the entire file.

11.Docker 默认使用网段

启动的容器网络无法相互通信,很是奇怪!

问题起因:我们在使用 Docker 启动服务的时候,发现有时候服务之前可以相互连通,而有时间启动的多个服务之前却出现了无法访问的情况。究其原因,发现原来是因为使用的内部私有地址网段不一致导致的。有点服务启动到了 172.17 - 172.31 的网段,有的服务跑到了 192.169.0 - 192.168.224 的网段,这样导致服务启动之后出现无法访问的情况。

image.png

解决方法:上述问题的处理方式,就是手动指定 Docker 服务的启动网段,就可以了。

# 查看docker容器配置
$ cat /etc/docker/daemon.json
{
    "registry-mirrors": ["https://vec0xydj.mirror.aliyuncs.com"],
    "default-address-pools":[{"base":"172.17.0.0/12","size":24}],
    "experimental": true,
    "default-runtime": "nvidia",
    "live-restore": true,
    "runtimes": {
        "nvidia": {
            "path": "/usr/bin/nvidia-container-runtime",
            "runtimeArgs": []
        }
    }
}

12.Docker 服务启动串台

使用 docker-compose 命令各自启动两组服务,发现服务会串台!

问题起因:在两个不同名称的目录目录下面,使用 docker-compose 来启动服务,发现当 A 组服务启动完毕之后,再启动 B 组服务的时候,发现 A 组当中对应的一部分服务又重新启动了一次,这就非常奇怪了!因为这个问题的存在会导致,A 组服务和 B 组服务无法同时启动。之前还以为是工具的 Bug,后来请教了“上峰”,才知道了原因,恍然大悟。

# 服务目录结构如下所示
A: /data1/app/docker-compose.yml
B: /data2/app/docker-compose.yml

解决方法:发现 A 和 B 两组服务会串台的原因,原来是 docker-compose 会给启动的容器加 label 标签,然后根据这些 label 标签来识别和判断对应的容器服务是由谁启动的、谁来管理的,等等。而这里,我们需要关注的 label 变量是 com.docker.compose.project,其对应的值是使用启动配置文件的目录的最底层子目录名称,即上面的 app 就是对应的值。我们可以发现, A 和 B 两组服务对应的值都是 app,所以启动的时候被认为是同一个,这就出现了上述的问题。如果需要深入了解的话,可以去看对应源代码。

图片

# 可以将目录结构调整为如下所示
A: /data/app1/docker-compose.yml
B: /data/app2/docker-compose.yml
A: /data1/app-old/docker-compose.yml
B: /data2/app-new/docker-compose.yml

或者使用 docker-compose 命令提供的参数 -p 来规避该问题的发生。

# 指定项目项目名称
$ docker-compose -f ./docker-compose.yml -p app1 up -d

13.Docker 命令调用报错

在编写脚本的时候常常会执行 docker 相关的命令,但是需要注意使用细节!

问题起因:CI 更新环境执行了一个脚本,但是脚本执行过程中报错了,如下所示。通过对应的输出信息,可以看到提示说正在执行的设备不是一个 tty。

图片

随即,查看了脚本发现报错地方是执行了一个 exec 的 docker 命令,大致如下所示。很奇怪的是,手动执行或直接调脚本的时候,怎么都是没有问题的,但是等到 CI 调用的时候怎么都是有问题。后来好好看下下面这个命令,注意到 -it 这个参数了。

# 脚本调用docker命令
docker exec -it <container_name> psql -Upostgres ......
我们可以一起看下 exec 命令的这两个参数,自然就差不多理解了。
-i/-interactive #即使没有附加也保持 STDIN 打开;如果你需要执行命令则需要开启这个选项
-t/–tty #分配一个伪终端进行执行;一个连接用户的终端与容器 stdin 和 stdout 的桥梁

解决方法:docker exec 的参数 -t 是指 Allocate a pseudo-TTY 的意思,而 CI 在执行 job 的时候并不是在 TTY 终端中执行,所以 -t 这个参数会报错。

图片

14.Docker 定时任务异常

在 Crontab 定时任务中也存在 Docker 命令执行异常的情况!

问题起因:今天发现了一个问题,就是在备份 Mysql 数据库的时候,使用 docker 容器进行备份,然后使用 Crontab 定时任务来触发备份。但是发现备份的 MySQL 数据库居然是空的,但是手动执行对应命令切是好的,很奇怪。

# Crontab定时任务
0 */6 * * * 
    docker exec -it <container_name> sh -c 
        'exec mysqldump --all-databases -uroot -ppassword ......'

解决方法:后来发现是因为执行的 docker 命令多个 -i 导致的。因为 Crontab 命令执行的时候,并不是交互式的,所以需要把这个去掉才可以。总结就是,如果你需要回显的话则需要 -t 选项,如果需要交互式会话则需要 -i 选项。

-i/-interactive #即使没有附加也保持 STDIN 打开;如果你需要执行命令则需要开启这个选项
-t/–tty  #分配一个伪终端进行执行;一个连接用户的终端与容器 stdin 和 stdout 的桥梁

15.Docker 变量使用引号

compose 里边环境变量带不带引号的问题!

问题起因:使用过 compose 的同学可能都遇到过,我们在编写启动配置文件的时候,添加环境变量的时候到底是使用单引号、双引号还是不使用引号。时间长了,可能我们总是三者是一样的,可以相互使用。但是,直到最后我们发现坑越来越多,越来越隐晦。

反正我是遇到过很多是因为添加引号导致的服务启动问题,后来得出的结论就是一律不适用引号。裸奔,体验前所未有的爽快!直到现在看到了 Github 中对应的 issus 之后,才终于破案了。

# TESTVAR="test"
在Compose中进行引用TESTVAR变量,无法找到
# TESTVAR=test
在Compose中进行引用TESTVAR变量,可以找到
# docker run -it --rm -e TESTVAR="test" test:latest
后来发现docker本身其实已经正确地处理了引号的使用

解决方法:得到的结论就是,因为 Compose 解析 yaml 配置文件,发现引号也进行了解释包装。这就导致原本的 TESTVAR="test" 被解析成了 'TESTVAR="test"',所以我们在引用的时候就无法获取到对应的值。现在解决方法就是,不管是我们直接在配置文件添加环境变量或者使用 env_file 配置文件,能不使用引号就不适用引号。

  1. Docker 删除镜像报错

无法删除镜像,归根到底还是有地方用到了!

问题起因:清理服器磁盘空间的时候,删除某个镜像的时候提示如下信息。提示需要强制删除,但是发现及时执行了强制删除依旧没有效果。

# 删除镜像
$ docker rmi 3ccxxxx2e862
Error response from daemon: conflict: unable to delete 3ccxxxx2e862 (cannot be forced) - image has dependent child images
# 强制删除
$ dcoker rmi -f 3ccxxxx2e862
Error response from daemon: conflict: unable to delete 3ccxxxx2e862 (cannot be forced) - image has dependent child images

解决方法:后来才发现,出现这个原因主要是因为 TAG,即存在其他镜像引用了这个镜像。这里我们可以使用如下命令查看对应镜像文件的依赖关系,然后根据对应 TAG 来删除镜像。

# 查询依赖 - image_id表示镜像名称
$ docker image inspect --format='{{.RepoTags}} {{.Id}} {{.Parent}}' $(docker image ls -q --filter since=<image_id>)
# 根据TAG删除镜像
$ docker rmi -f c565xxxxc87f
bash
# 删除悬空镜像
$ docker rmi $(docker images --filter "dangling=true" -q --no-trunc)

17.Docker 普通用户切换

切换 Docker 启动用户的话,还是需要注意下权限问题的!

问题起因:我们都知道在 Docker 容器里面使用 root 用户的话,是不安全的,很容易出现越权的安全问题,所以一般情况下,我们都会使用普通用户来代替 root 进行服务的启动和管理的。今天给一个服务切换用户的时候,发现 Nginx 服务一直无法启动,提示如下权限问题。因为对应的配置文件也没有配置 var 相关的目录,无奈 🤷‍♀ !️

# Nginx报错信息
nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (13: Permission denied)
2020/11/12 15:25:47 [emerg] 23#23: mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)

解决方法:后来发现还是 nginx.conf 配置文件,配置的有问题,需要将 Nginx 服务启动时候需要的文件都配置到一个无权限的目录,即可解决。

nginx
user  www-data;
worker_processes  1;
error_log  /data/logs/master_error.log warn;
pid        /dev/shm/nginx.pid;
events {
    worker_connections  1024;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    gzip               on;
    sendfile           on;
    tcp_nopush         on;
    keepalive_timeout  65;
    client_body_temp_path  /tmp/client_body;
    fastcgi_temp_path      /tmp/fastcgi_temp;
    proxy_temp_path        /tmp/proxy_temp;
    scgi_temp_path         /tmp/scgi_temp;
    uwsgi_temp_path        /tmp/uwsgi_temp;
    include /etc/nginx/conf.d/*.conf;
}

史上最全、最详细的Docker学习资料  推荐给你看一看。

作者: Escape
链接: https://escapelife.github.io/...

image

查看原文

赞 22 收藏 17 评论 0

tfzh 发布了文章 · 3月4日

记录一个Ant Design Vue Select组件的隐藏属性模糊查询 微坑

好记性不如烂笔头,记录最近遇到的一个Select组件多隐藏属性模糊查询的需求,以及踩的一个微坑。

1. 实现简单的模糊查询

如果只是对Select组件Optionlable/value做模糊查询的话, 文档或者其他文章都讲过,可以使用下面的查询

<a-select
    v-model="search_obj.advertiser"
    style="width: 240px;"
    mode="multiple"
    :maxTagCount="1"                    
    :allowClear="true"
    :filterOption="filterOption"
>
    <a-select-option value="">
        全部
    </a-select-option>
    <a-select-option  v-for="(value, key) in show_advertiser_map" :value="value.advertiser_id">
        {{value.alias}}
    </a-select-option>
</a-select>
filterOption(input, option) {
      return (
        option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
      );
},

如下图所示:可以对option.componentOptions.children[0].text或者option.componentOptions.propsData.value做过滤,就能实现模糊查询的效果

2.实现隐藏属性的模糊查询

2.1正确思路:自定义attrs属性

<a-select-option
v-for="(value, key) in show_advertiser_map"
:value="value.advertiser_id"
:user_name="value.user_name"
>
    {{value.alias}}
</a-select-option>

a-select-option标签增加了一个user_name的属性

如图,就把问题转化为了情形1,使用filterOption函数过滤选项就好了,但是如果有很多个属性需要过滤呢?岂不是要添加很多个attrs属性,于是我尝试了更改思路

2.2 微坑思路:动态更改option数组show_advertiser_map

option是使用数组循环渲染出来的,那么监听input的变化,动态调整数组不就好了么?

<a-select
    v-model="search_obj.advertiser"
    style="width: 240px;"
    mode="multiple"
    :maxTagCount="1"                    
    :allowClear="true"
    @search="filterAdvertiser"
>
    <a-select-option value="">
        全部
    </a-select-option>
    <a-select-option  v-for="(value, key) in show_advertiser_map" :value="value.advertiser_id">
        {{value.alias}}
    </a-select-option>
</a-select>
if (val == '') {
    this.show_advertiser_map = this.advertiser_map[this.search_obj.game]
        return;
    }
    let arr = [];
    this.advertiser_map[this.search_obj.game].forEach((v) => {
        if (v.alias.indexOf(val) > -1
            || v.advertiser_id.indexOf(val) > -1
            || v.advertiser_name.indexOf(val) > -1
            || v.company_name.indexOf(val) > -1
            || v.platform == val
            || v.user_name.indexOf(val) > -1
        ) {
            arr.push(v)
        }
    })
this.show_advertiser_map = arr
console.log(this.show_advertiser_map)

我打印了最终原数组的值,发现是符合预期的,但是实际上select组件选项却依旧是对vlaue做了过滤,导致渲染出来的结果是不符合预期的。可是文档里面写的是filterOption接收bool/function,默认值是true,于是我尝试增加:

:filterOption="true"

依旧对vlaue做了过滤

再次尝试增加:

:filterOption="() => { return true; }"

符合预期!Done!

然后我尝试看Select组件对应的代码:

 filterOption: _vueTypes2['default'].oneOfType([_vueTypes2['default'].bool, _vueTypes2['default'].func]),

确实是接受bool/function

     var filterFn = this.$props.filterOption;
      if ((0, _propsUtil.hasProp)(this, 'filterOption')) {
        if (filterFn === true) {
          filterFn = defaultFilter.bind(this);
        }
      } else {
        filterFn = defaultFilter.bind(this);
      }

但是当是true,却有一个defaultFilter,ok, 离真相越来越近了,继续看defaultFilter是啥

export function defaultFilterFn(input, child) {
  var props = getPropsData(child);
  if (props.disabled) {
    return false;
  }
  var value = getPropValue(child, this.optionFilterProp);
  if (value.length && value[0].text) {
    value = value[0].text;
  } else {
    value = String(value);
  }
  return value.toLowerCase().indexOf(input.toLowerCase()) > -1;
}

哦豁,是对value进行过滤,怪不得

3.总结

  • 不指定filterOption, 是对选项的value进行过滤;
  • 普通指定filterOption, 可以对选项的lable/value进行过滤;
  • 直接动态调整Array, 需要指定filterOption返回恒真
查看原文

赞 1 收藏 0 评论 0

tfzh 赞了文章 · 3月4日

有道写作浏览器扩展实践

有道写作浏览器扩展作为一款为网页增加英文语法批改的辅助工具,允许用户在任意网页上绝大部分的富文本编辑器、多行文本输入框中编辑英文文本,可实时得到批改结果反馈,并自行接受建议自动修改,实现完美写作
来源/ 有道技术团队公众号
作者/ 李靖雯
编辑/ 刘振宇

一、背景介绍

有道写作服务是有道出品的写作智能批改产品,为用户提供优质的作文拼写、语法、样式方面的批改服务。有道写作不仅仅支持浏览器扩展形式,还支持在其他平台使用:例如有道词典 APP-作文批改、Web 在线端、Word 插件、PC 词典内。欢迎各位体验。

http://write.youdao.com/

浏览器插件在浏览器里面的称呼是 Browser Extension,也就是浏览器扩展,是一个扩展网页浏览器功能的插件。它主要基于 HTML、JavaScript、CSS 开发,同时由于是扩展特性,可以利用浏览器本身提供的底层 API 接口进行开发,可以给所用页面或者特定页面添加一些特殊功能而不影响原本页面逻辑。

每个支持扩展的浏览器有自己下载扩展的应用商店,可以直接在应用商店下载。有些产品自己提供浏览器扩展的 .crx 文件让用户下载并安装。

二、适配浏览器

有道写作在 Windows/Mac 系统都可安装,适配 Chrome、360安全浏览器、360极速浏览器、Edge 新版浏览器等,在以上浏览器商店中搜索有道写作,点击安装按钮即可。

三、功能介绍&效果展示

在介绍开发思路与实践之前,我们先来直观地看一下有道写作浏览器扩展的实际效果,并对其功能进行简单的介绍。

3.1 表现方式

视觉效果就是,给错误的文本字符下面画一条横线,在 hover 的时候,可以给文本增加一个高亮的效果。在选接受建议的时候,可以替换成我们想要的文本数据。

image

3.2 适用场景

>>> 在线邮件编辑:

163邮箱

Outlook 邮箱

Gmail

>>> 社交动态、评论:

Facebook

微博动态

评论

>>> 工具、笔记类:

有道翻译

Google 翻译

石墨文档

3.3 功能介绍

>>> 实时批改:

支持一边修改一边实时提供批改反馈,展示批改错误数量。

>>> 语法检测:

image

>>> 增强编辑框:

可以查看每一个错误反馈详细内容,并可分错误类型过滤查看结果。

>>> 接受建议:

点击接受建议时候替换正确文本。

image

四、开发思路

需求:扩展需要针对页面上的可输入文本的编辑框赋予批改的功能

4.1 适配编辑器

那么,网页中可输入文本的编辑框都有哪些呢?

通常我们常见可输入编辑框有:

  • 基于 Web 的表单可以输入文本控件:input、textarea
<input value="123"/>
<textarea>123456</textarea>
  • 可编辑属性的元素:contenteditable
<blockquote contenteditable="true">
    <p>Edit this content to add your own quote</p>
</blockquote> 

Input 元素通常是一行且输入范围较短的内容,考虑到批改交互的功能,我们的扩展针对以下可输入较多文本的编辑器进行兼容:

  • contenteditable 富文本编辑器
  • textarea
  • 其他文档编辑器

4.2 富文本编辑器

我们常见基于 contenteditable 实现的富文本编辑器有百度编辑器、draft.js、 有道云笔记(旧版)等等。

相比 textarea,富文本编辑器可以包含很多不同标签,可以以用来渲染成不同字体颜色的文本、图片、附件、视频、音频等等元素。

实现基于浏览器的富文本编辑器的四要素

四代编辑器的技术选型

  • 第一代编辑器主要是通过有限的 execCommand 指令对 html 文档进行操作。
  • 第二代则是在 execCommand 基础上,添加更多自定义指令甚至自己实现指令方式修改 html 文档。
  • 第三代是引入数据模型(json/xml),绑定自定义实现指令从而渲染html文档。
  • 第四代主要是直接抛弃整个 contenteditbale,单独制定选区和监听输入事件。

更多关于编辑器的介绍,可参考有道技术团队之前发布的文章:

为什么要介绍富文本编辑器内容呢,因为了解多这些编辑器实现方式和保存机制可以帮助后面实现并优化扩展的功能。

4.3 初想

一开始的想法是,将原始编辑器的纯文本内容提取并发送到服务端,然后根据服务端返回的数据进行重新的拼接,在错误节点位置使用特殊标记标签进行标注。

以有道写作 Web 端为例:

使用这种方法实现批改效果的还有 163 邮箱英文智能检查、Gmail 自带写信语法检测功能等。这种方法适合我们自定义的编辑器,可以自己控制文本的渲染和指令。

但由于浏览器扩展是基于别人写的编辑器上进行的辅助工具,不能随意修改其文本格式和样式。比如复制带有划线的文本进行粘贴,会出现冗余的划线(除非原本的编辑器有做粘贴文本的标签过滤),但是不能寄希望于别人写的编辑器都有这个功能。

4.4 实现

需要分别从两个部分进行考虑:

  1. 如何定位画线
  2. 如何接受建议替换正确文本

如何定位画线,并且可以给予其高亮的效果呢?

需要解决的问题是:需要在不影响原编辑器的格式以及功能前提下,将结果划线部分加入到界面上。

>>>contenteditabe:

  • 第一步:虚拟辅助器边框

虚拟一个元素(大小相同,位置相对)在原始编辑器之上,将结果划线标注在这个元素之上,我称之为辅助器。

因为是覆盖在原始编辑器上,需要禁止辅助器的鼠标响应行为,在 hover 的时候需要将鼠标位置同步到辅助器之上。

辅助器需要和原编辑器相同,才能定位准确,需要获得原编辑器以下属性。

  • 第二步:找准定位

问题:如果单纯提取元素的 innerHTML/InnerText/textContent 作为批改请求的参数,无法实现准确定位。

原因:服务端返回结果是根据纯文本定位,网页上的编辑器格式是HTML文档格式,包含不同字体不同格式的标签。

举个例子:html 中有两个块级元素,分别展示两句话,差异只在于两句话 font-size 不一样。

通过 element.textContent 提取出来的内容都是相同的,校验返回的错误标志结果也相同,如下:

因为无法从纯文本的角度得到两种情况差异,难点就在于:需要解析 html 格式,将服务端数据转换到实际格式位置中。

要知道,这相当于要在一个空白的白板元素里添加一个多个绝对定位的高亮元素。需要知道每个错误相对原编辑框的相对位移,和自身宽高。

下面是一个反向推敲的过程:

  1. 我需要得到的是 hightlightElement : { top, letf, width, height };
  2. 通过 range.getClientRects() 可以获得我们想要的数据。
  3. 于是需要知道如何获取一个错误节点对应的 range。

  1. 需要找到对应的开头节点和它的相对位移、以及结束节点和它的相对位移。range: { startNode, stratOffest, endNode, endOffest}。方法就是通过错误节点在纯文本的开始(fixedposStart)跟结束位置(fixedposEnd)通过遍历全文每一个文本节点(textNodes)的数据长度(textNodes.nodeValue)进行计算得出。
  2. fixedposStart、fixedposEnd根据服务端返回数据经过稍微计算可得出。全文每一个文本节点(textNodes)需要通过过滤某些标签得到。
  3. 所以需要先思考如何处理 html 中各种标签问题。

所以划线的原理是:提取其纯文本的 textnode 节点,根据结果 position 匹配开始的节点和位移、结束节点和位移,获取其文本片段 range 对应编辑器的 x,y,height,width,画出高亮区域。

具体步骤如下:

a. 根据原文所有 html 标签加工过滤,提取纯文本和加工后的文本节点集合:

html 内有各种标签节点,需要根据这些标签不同意义,对标签内的文本进行加工。比如针对 p 标签,通常是表示段落,需要将其包裹的内容后面添加一个换行符。

p 标签处理例子:
问题: 这个换行符是一起发给服务端的,服务端返回来的数据定位也算上了这个换行符。
解决方案: 过滤标签的同时记录文本处理过的位置,在后面的计算反向处理。同时还需要注意字符的转义问题,尤其注意零宽字符的处理。

b. 提取纯文本节点:

(上图文本内容根据标签内容分成5个纯文本节点)

c. 结合服务端数据计算每个错误全文定位:

比如 has 错误对应的错误节点信息。

d. 根据定位获取每个错误节点文本片段:

e. 通过文本片段获取相对视口的位置:

划线步骤图

  • 第三步:在assist范围内画出线和高亮

contenteditable 集合辅助器工作的流程图

>>> textarea:

textarea 本身是无法获取其 textnode 的,它相当于只有一个节点。考虑将其转换成文本节点:

  • 创建一个隐形 mirror,这个 mirror 具备与原始编辑器相同边框大小、可编辑区域。

  • textarea 任何文本变动同步到 mirror
this.textarea.addEventListener('input',this.mirror.update);
  • 再为这个 mirror 创建一个 assist,同理上面处理 contenteditbale 的流程相一致。

>>>关于突变:

编辑器其实就是一个普通的元素,以下编辑器的交互会引起我们页面内文本节点的变化:

  • 文本内容变化
  • 尺寸变化(窗口变大变小)
  • 位置变化
  • 字体大小变化(加粗,居中)
  • 滚动

这些变化也就影响我们定位的变化,称之为突变。需要处理每一个突变引起的重新定位问题(重点难点)。

同时,需要监听原始编辑器的输入、字体变化、编辑器尺寸变化等等触发 assist 的重新定位方法。

// 通过ResizeObserver监听编辑器尺寸变化 objResizeObserver = new ResizeObserver((entries) => {
    var entry = entries[0];
    this.elementResizeHandler(entry.target)
}); 

ResizeObserver 兼容性问题需要通过 polyfill 库文件解决。

重新定位方法(mutation):

  • 通过新旧 textnode array 比对,正向遍历节点集合和反向遍历节点集合,得到被修改的 textnode 是哪一个段文本节点 textnode 集合。
  • 只需要处理被影响的 textnode 所对应的错误节点集合根据移动的 offest 计算后面影响的节点位移。

  • 其他错误相对自己 textnode 的位移是不会改变的。

如何接受一个建议,替换文本:

替换文本意味要修改原编辑器的数据甚至格式,就会造成刚才说的对部分编辑器会引起格式错乱和保存失败的情况。

难点:不影响原始数据存储格式,不影响原始编辑器撤回操作,同时还能触发原编辑器保存机制。

解决方法:不直接用脚本修改 dom 节点,模拟用户修改数据的方式:选中文字,替换内容。

以新版有道云笔记为例子:

  1. 通过之前复杂计算得到结果片段,根据结果片段计算出对于可视窗口的位置,得到 {top, left, height, width}。
  2. 模拟鼠标从左向右滑动的操作事件加在内容区域。

  1. 找到自定义的自绘区域。
  2. 一个错误结果中可能涉及不同的样式,我们仅获取当前节点第一个片段的字体样式,模拟一个粘贴事件。

  1. 在自绘区域触发自定义粘贴事件。

4.5 增强编辑框

入口在点击右下角按钮或者 hover 出现结果卡片时候,点击详细建议进入

>>>增强编辑框的作用:

  • 提供更大的编辑空间
  • 查看详细的批改结果

增强编辑框是一个特殊的 contenteditable 编辑器。

>>> 初始化、关闭赋值:

在初始化增强编辑器的时候,直接获取原编辑器数据,这里忽略了原编辑器的一些样式、图片,只使用 html 数据部分。

在增强编辑器中编辑后返回原编辑器时候,需要将新数据返回赋值。

>>> 通信:

增强编辑框是嵌入页面的 iframe,只在顶层页面出现。与原来页面的通信是通过postMessage 方式。

(注意:postMessage 不能传递 html 元素和过于复杂的 json object)

如果是原本编辑器是 iframe,需要找到最上层 window.top,利用 window.top 和增强编辑框进行通信。

五、整体流程

上图为有道写作浏览器扩展从注入到浏览器页面,以及运行的大致流程。

为了在不影响用户操作前提下,扩展脚本只会在当前页面空闲时候加载,并且批改功能只在部分被用户点击 focus 的编辑器中激活。

以上是开发有道写作浏览器扩展过程中的开发思路和部分技术实现细节,借此机会分享给大家,欢迎与有道技术团队一起探讨更多关于前端、浏览器扩展的知识问题。

查看原文

赞 17 收藏 9 评论 2

tfzh 发布了文章 · 2月15日

记录一次快速注册接口的优化

1 新旧版本实现

旧版本:PHP语言实现,使用多次DB查询来生成一个用户名,再DB查询用户表,确保用户名未重复

新版本:PHP语言实现,从Redis直接获取一个可用用户名,注册时候再判断是否未重复

Go版本:Go语言实现,其他一致

这里的优化主要有两点:

1.主要是采用发号器的思想,预生成可用的账号,而不是每次用的时候才生成

2.把判断是否未重复滞后,真正完成注册的那一步才去判断,因为很多用户下载了游戏,未必走到注册那一步,大概只有80%出头的激活注册率

2 压测表现

ab压测各情形下均多次压测,取较中间值的一次数据

2.1 旧版本

terence@k8s-master:~$ ab -c 10 -n 50 -p post http://dksdk_api.test/api/v7/user/registerone
ab: Could not open POST data file (post): No such file or directory
terence@k8s-master:~$ ab -c 10 -n 50 -p a.txt http://dksdk_api.test/api/v7/user/registerone
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dksdk_api.test (be patient).....done


Server Software:        nginx
Server Hostname:        dksdk_api.test
Server Port:            80

Document Path:          /api/v7/user/registerone
Document Length:        175 bytes

Concurrency Level:      10
Time taken for tests:   25.667 seconds
Complete requests:      50
Failed requests:        40
   (Connect: 0, Receive: 0, Length: 40, Exceptions: 0)
Total transferred:      29886 bytes
Total body sent:        7950
HTML transferred:       8636 bytes
Requests per second:    1.95 [#/sec] (mean)
Time per request:       5133.476 [ms] (mean)
Time per request:       513.348 [ms] (mean, across all concurrent requests)
Transfer rate:          1.14 [Kbytes/sec] received
                        0.30 kb/s sent
                        1.44 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.2      0       1
Processing:  2091 4931 1898.5   4688   10802
Waiting:     2091 4931 1898.5   4688   10802
Total:       2091 4931 1898.6   4688   10802

Percentage of the requests served within a certain time (ms)
  50%   4688
  66%   6068
  75%   6105
  80%   6189
  90%   7721
  95%   8355
  98%  10802
  99%  10802
 100%  10802 (longest request)

平均耗时513ms,其中Lumen框架启动大概30~100ms以内,DB查询视情况是否理想大概500ms左右,非对称加密个位数级别若干毫秒可忽略不计

2.2 新版本

terence@k8s-master:~$ ab -c 10 -n 50 -p a.txt http://dksdk_api_v2.test/api/v7/user/registerone
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dksdk_api_v2.test (be patient).....done


Server Software:        nginx
Server Hostname:        dksdk_api_v2.test
Server Port:            80

Document Path:          /api/v7/user/registerone
Document Length:        346 bytes

Concurrency Level:      10
Time taken for tests:   1.715 seconds
Complete requests:      50
Failed requests:        40
   (Connect: 0, Receive: 0, Length: 40, Exceptions: 0)
Total transferred:      26941 bytes
Total body sent:        8100
HTML transferred:       17241 bytes
Requests per second:    29.15 [#/sec] (mean)
Time per request:       343.076 [ms] (mean)
Time per request:       34.308 [ms] (mean, across all concurrent requests)
Transfer rate:          15.34 [Kbytes/sec] received
                        4.61 kb/s sent
                        19.95 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.2      0       1
Processing:    80  336 133.9    343     621
Waiting:       80  336 133.9    343     621
Total:         81  337 133.8    343     621

Percentage of the requests served within a certain time (ms)
  50%    343
  66%    375
  75%    410
  80%    474
  90%    521
  95%    547
  98%    621
  99%    621
 100%    621 (longest request)

平均耗时34ms,只有旧版本的6.6%,其中Lumen框架启动大概30~100ms以内,DB查询大概200ms左右,非对称加密个位数级别若干毫秒可忽略不计

2.3 旧版本 开启Opcache

terence@k8s-master:~$ ab -c 10 -n 50 -p a.txt http://dksdk_api.test/api/v7/user/registerone
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dksdk_api.test (be patient).....done


Server Software:        nginx
Server Hostname:        dksdk_api.test
Server Port:            80

Document Path:          /api/v7/user/registerone
Document Length:        420 bytes

Concurrency Level:      10
Time taken for tests:   19.284 seconds
Complete requests:      50
Failed requests:        27
   (Connect: 0, Receive: 0, Length: 27, Exceptions: 0)
Total transferred:      42057 bytes
Total body sent:        7950
HTML transferred:       20807 bytes
Requests per second:    2.59 [#/sec] (mean)
Time per request:       3856.747 [ms] (mean)
Time per request:       385.675 [ms] (mean, across all concurrent requests)
Transfer rate:          2.13 [Kbytes/sec] received
                        0.40 kb/s sent
                        2.53 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       0
Processing:  1830 3689 1213.6   3527    7273
Waiting:     1830 3689 1213.6   3527    7273
Total:       1830 3690 1213.6   3527    7273

Percentage of the requests served within a certain time (ms)
  50%   3527
  66%   3911
  75%   4534
  80%   4907
  90%   5604
  95%   5639
  98%   7273
  99%   7273
 100%   7273 (longest request)

平均耗时385ms, 相当于只节省了Lumen框架启动所需的时间,耗时大头的DB查询还是原样

2.4 新版本 开启Opcache

terence@k8s-master:~$ ab -c 10 -n 50 -p a.txt http://dksdk_api_v2.test/api/v7/user/registerone
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dksdk_api_v2.test (be patient).....done


Server Software:        nginx
Server Hostname:        dksdk_api_v2.test
Server Port:            80

Document Path:          /api/v7/user/registerone
Document Length:        345 bytes

Concurrency Level:      10
Time taken for tests:   0.143 seconds
Complete requests:      50
Failed requests:        26
   (Connect: 0, Receive: 0, Length: 26, Exceptions: 0)
Total transferred:      26951 bytes
Total body sent:        8100
HTML transferred:       17251 bytes
Requests per second:    349.37 [#/sec] (mean)
Time per request:       28.623 [ms] (mean)
Time per request:       2.862 [ms] (mean, across all concurrent requests)
Transfer rate:          183.91 [Kbytes/sec] received
                        55.27 kb/s sent
                        239.18 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       0
Processing:     4   27  18.4     22      89
Waiting:        4   27  18.4     22      89
Total:          4   27  18.4     22      90

Percentage of the requests served within a certain time (ms)
  50%     22
  66%     31
  75%     36
  80%     42
  90%     52
  95%     56
  98%     90
  99%     90
 100%     90 (longest request)

平均耗时2.86ms, 节省了Lumen框架启动所需的时间,没有DB查询,只有一次Redis查询,毫秒级别能就搞定了

2.5 新版本 Go语言

terence@k8s-master:~$ ab -c 10 -n 50 -p a.txt http://192.168.10.83:8080/registerone
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 192.168.10.83 (be patient).....done


Server Software:        
Server Hostname:        192.168.10.83
Server Port:            8080

Document Path:          /registerone
Document Length:        0 bytes

Concurrency Level:      10
Time taken for tests:   0.050 seconds
Complete requests:      50
Failed requests:        0
Total transferred:      3750 bytes
Total body sent:        7550
HTML transferred:       0 bytes
Requests per second:    1007.03 [#/sec] (mean)
Time per request:       9.930 [ms] (mean)
Time per request:       0.993 [ms] (mean, across all concurrent requests)
Transfer rate:          73.76 [Kbytes/sec] received
                        148.50 kb/s sent
                        222.25 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    4   3.0      3      10
Processing:     0    5   3.3      6      17
Waiting:        0    4   3.1      5      10
Total:          5    9   4.4      8      20

Percentage of the requests served within a certain time (ms)
  50%      8
  66%      9
  75%     10
  80%     11
  90%     19
  95%     19
  98%     20
  99%     20
 100%     20 (longest request)

平均耗时0.993ms,真香

查看原文

赞 0 收藏 0 评论 0

tfzh 关注了用户 · 2月3日

TIGERB @tigerb

// Trying to be the person you want to be.

// 时刻夯实基础
// 时刻对新技术保持热忱

// 个人博客 http://TIGERB.cn
// 轻量级PHP框架EasyPHP 作者 http://easy-php.tigerb.cn
// 电商设计手册|SkrShop 作者 https://github.com/skr-shop/m...

// 新的目标成为一名优秀的 Gopher

关注 2061

认证与成就

  • 获得 72 次点赞
  • 获得 8 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 7 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-09-22
个人主页被 3.1k 人浏览