爱莎之刃

爱莎之刃 查看完整档案

南京编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

爱莎之刃 赞了文章 · 2020-10-11

思否产品推介丨维格表:新一代团队数据协作工具

vika维格表,是支持API的连接型智能表格,新一代的团队数据协作与项目管理神器
无需下载,在线使用: vika.cn
API SDK: vika.js

维格表

“在团队协作中,结构规整数据的价值远大于非结构化数据,所有的可协作能力/可检索能力/数据可视化等数据处理,都建立在结构化数据之上。” —— 「维格表」联合创始人兼 CTO 陈加贝


市面上的软件工具那么多,但却很少可以做好连接和整合,真正帮助我们更好地工作和生活。毕竟,对于使用软件提升「生产力」,大家总是抱有很多期待:

  • 有没有一种工具,可以轻松随意 DIY 出自己的项目管理工具和数据产品?🤔
  • 有没有一种技术,连 1 行代码都不用写,就可以做出业务系统、IT 软件?🤔
  • 有没有一种数据库,可以简单到连不懂编程的人都能使用?🤔
  • 有没有一种表格,支持 API,能存放图片视频,可以结构化地整理信息、连接一切?🤔

2019 年底,一群来自金山、字节跳动、阿里、腾讯、金蝶等各大科技公司的互联网从业者一拍即合,致力于打造国内首款同时面向企业、个人用户的可协作式创新型数字内容管理工具,即「维格表」(vika.cn)。

一、产品介绍:不是 Excel,是搭建 IT 系统的可视化数据库工具

维格表

维格表将复杂的可视化数据库、电子表格、实时网络协同、低代码开发技术四合为一,同时支持API与可视化看板,「拉」「拽」「拖」的简易操作,让复杂技术、项目管理、业务信息管理变得「轻量化」。

很多新朋友们看到维格表,会觉得这是一种新型的 Excel 电子表格。

其实这真是一场尴尬的误会。

维格表(vika.cn)形式上是一种连接型的智能表格,其实是一种强大灵活的 IT 协作系统,技术团队只是为了用户的易用性、舒适性,才做得像 Excel 表格一样简单和易操作。

维格表真正的比较对象,应该是类似 Access、FoxPro、HyperCard、FileMaker 这类的轻量级可视化数据库。

二、应用场景:1000+ 个性化应用场景

维格表

像乐高一样具备可无门槛搭建、可个性化定制的维格表,兼具项目管理、流程协作、文档存储等多项功能,可以涵盖项目管理、客户管理、组织创新、门店管理、API数据管理等1000多个应用场景。

对个人使用者而言,在日常生活场景中,个人的重要信息往往散落在不同的平台、APP中,但每个人往往都有着不同的社会角色。

比如一位40岁的男性厨师,他是一位父亲,同样是儿子,爱好音乐、汽车、数码产品,他需要记录并处理大量杂乱的信息:食谱、儿子的学校课程、老师电话、父母的健康管理、日常记账、数码产品清单等等,为了让生活更加井井有条,他最终选择的信息整合工具,最优选还是Excel。

所以,你会发现,尽管市面上的软件工具都已经这么多了,但大家还是那么喜欢日常使用Excel来做各种数据整理。

但电子表格技术发明的初衷,并不是应用于数据整理,而是用于数值计算,如财务报表的统计等。维格表与Excel看起来相似,但绝不只是「在线」这么简单。

Excel擅长的是数据分析,能够对杂乱结构的数据进行快速整理,就像一个数据画板,依靠人工手动画出数据样式,适合规模不大/一次性的任务。

随着互联网带来生产力的提升,Excel多媒体能力软弱、数据信息分散、难以形成连接的缺点也让现代职场人士陷入「Excel焦虑」中。

而维格表的设计思路则是从数据出发,每一列都有着固定的数据格式,移除合并单元格等自由排版能力,看起来「束缚」,但这非常有利于维护数据的结构化。「自律带来自由」,结构规整的数据价值远远大于非结构化数据,可协作能力、可检索能力、数据可视化都建立在结构化数据的基础上。

简单来说,维格表不是表格,而是一种可以搭建IT系统的可视化数据库工具。

三、Vika 维格开发者:一键 API 窗

在现实中,除了项目管理,我们日常有大量的工作场景,都需要用到可视化数据库能力。

比如CRM、ERP等企业级应用, 市面上的这类垂直领域的企业级应用,不但价格高,而且通常功能复杂,业务固化,不能适应所有的公司业务和发展需要,对于绝大多数企业来说,性价比不高。

维格表则通过表格的轻量可视数据 API 化,让很大一部分的服务端开发都可以被高效取代,这将软件产品的开发成本降低百倍。

对组织者、开发者来说,维格表的实时API功能是一项强大的「减负神器」。

维格表的本质可以说是「长得像表格的数据库」,通过 API 的方式,以 JSON 格式轻松读写维格表内的信息和数据。

你可以在任意一张维格表,点击右上角的 API 按钮,一键弹出 API 窗口,用非常简短的代码方式,使用 Python 、JavaScript 等编程语言的 SDK ( https://github.com/vikadata/v... ),实时地对数据进行读取、批处理、回写,连接到你的其它业务系统,生成插件实现更多各种功能。

图片描述

配合维格表「神奇关联」能力,你可以随意DIY出专属自己的强大云原生IT系统产品,数据与数据之间互相流动,打破了传统软件的壁垒,简短的代码即可实现面向API编程。

这也意味着,通过维格表,今后一个普通小白只要掌握一点基础的 JavaScript 或 Python 语言,也能做出媲美高级架构师的强大软件后台。

四、产品优势:职场新青年的第一款「工作神器」

图片描述

使用 vika 维格表,你只需要花费过去十分之一的时间、百分之一的成本,就可以实现别人通过复杂代码研发,才能换来的业务自动化:

  • 你不必再为固化的 SaaS 软件系统而发愁,通过维格表就能轻松 DIY 出自己的管理工具和系统;
  • 你不必再需要为各种轻型应用软件,独立的搭建数据库和写代码;
  • 通过数据实时协同和可视化的界面,让数据的整理、连接、更新更加便捷;

vika维格表同样是一种让不懂编程的普通人,快速生成 IT 产品的简单开发工具。和 Excel 电子表格相比,维格表使用起来更简单,却更强大,在企业协作、数据结构、整理、分析、API 等方面优势明显,包括:

  • 云端存储与相互连接:数据都是在云端集中式存储,支持私有化部署,零散的信息汇聚到一个平台中来;
  • 时光机回放:以「操作」为粒度存储历史记录,可以像「时光机」一样进行历史回放和回滚;
  • 维格列类型校验:由于维格列有指定属性类型,数据录入会锁定类型,更方便、更准确,你不会因为输错了空格或逗号,导致满屏的「ERROR」了;
  • 多媒体存取能力:图片、视频、文档等各种附件都能存放,结构化地混排各种多媒体信息;
  • 无限视觉变换:不局限于表格的形式,可以进行多种视觉效果的变换,如看板、相册、甘特图、图表等等模式,简单易用;
  • 集装箱式分享:你可以把维格表中的文件夹进行分享,用户看到的不是单个维格表,而是整个文件夹;

当你理解维格表就是一种可视化数据库后,你会发现,维格表未来的应用场景会无比的广阔:

从一场联动数十人的营销活动,到一次个人旅行行程计划;从开店筹备到会员体系管理;从项目成立到跨企业协作;从企业仓储记录到个人数码产品参数清单;从企业的项目协作到个人生活管理……

维格表的玩法,只有你想不到,没有做不到。

图片描述
维格表个人旅游行程场景示例

五、一些奇妙的设计

1、丰富的维格列类型

维格表
类型丰富的维格列

维格表
可以打星的表格

2、个性化视图

维格表
维格表智能变换使用场景

维格表
维格表可切换多种视图

图片描述
拥有彩虹标签、emoji等

3、黑科技玩法

维格表
关联功能

4、「看板」让数据可视化

图片描述
看板视图

六、一些用户反馈

clipboard.png

七、限时免费公测

目前维格表正在限时开放公测免费使用~你可以在电脑浏览器中输入 VIKA.CN,免费领走 100 个系统模板!

彩蛋:在本文的评论区回复关键字「vika」即可获得公测邀请码噢!全新功能,等你来抢先体验~

查看原文

赞 19 收藏 5 评论 45

爱莎之刃 赞了文章 · 2020-08-13

聊聊游戏开发与动画利器 raf

今年下半年打算正式撸一撸小游戏,正好这些天整理一下有关游戏的一些知识,当然了,目前还是打算使用浏览器网页进行游戏开发。

网页游戏开发的优势与未来

如果使用其他语言开发游戏,无论游戏本身大小与否,我们都需要游戏引擎来帮助我们构建开发,而对于浏览器来说,我们在开发小游戏时候,利用浏览器本身提供的组件和 api 就可以直接进行业务处理,我们也可以更加高效的学习与实践游戏逻辑。同时网页游戏的构建与发布也非常简单。

例如像 Js13kGames (Js13kGames是一个针对 HTML5游戏开发者的 JavaScript 编码竞赛,该竞赛的有趣之处在于将文件大小限制设置为13 kb ) 这样的限制代码量的游戏开发竞赛。对于非网页游戏开发来说,这基本上是不可能完成的,因为它们不具备有像浏览器这种量级的通用型的工具。

随着时间的发展,浏览器的功能,性能也在不断提升。通过 WebGL, WebAssembly 各种层出不穷的技术。让很多之前想都不敢想的功能在浏览器上实现。同时,伴随着 5G 到来,网速的提升,在浏览器上开发游戏充满了无限的可能。

当然了,事实上,不同的游戏需要不同的组件,其中包括数学库,随机算法,碰撞及物理引擎,音频,资源管理,AI机制等等等等,不过在浏览器环境下,这些组件都可以做到按需引用。

游戏循环架构与风格

游戏本身是基于动画的。不知道大家在小学的时候有没有买果或者玩过翻纸动画?如果你没有了解过,也可以看一看bilibili 中的视频 高中生自制的翻纸动画短片。视频中通过快速翻动纸张来实现两个火彩人打架的精彩动画。事实上,我们的电脑,手机设备能够展示动画都是基于此原理。

所谓动画,就是不间断,基于时间和逻辑不断更新数据以及重绘界面。其核心一定会有至少一个循环。这里我介绍几种常见架构。

视窗消息泵

在 Windows 平台中,游戏除了需要对自身进行服务外,还需要处理来自于 Windows 系统本身的消息,因此,Windows中有游戏都会有一段被称为消息泵的代码。其原理是先处理 Windows 的消息,之后再处理游戏循环逻辑。

// 不断循环处理
while (true) {
  MSG msg;
  
  // 如果当前消息队列中有消息,取出消息
  while(PeekMessage(&msg, NULL, 0, 0) > 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg)
  }
  
  // 执行游戏循环,类似于更新与重绘
  RunGame()  
}

以上代码的副作用在于默认设置了游戏处理消息的的优先级,循环中先处理了 Windows 内部消息。如果游戏在调整界面或者移动视窗时候,游戏就会卡住不动。

回调与事件驱动

很多游戏框架(包括浏览器)已经在内部实现了主游戏循环,我们无法直接介入内部循环机制,我们只能够填充框架中空缺的自定义部分。通过编写回调函数或者覆盖框架预设行为来实行。

例如一些游戏渲染引擎是这样实现的:

while(true) {
  // 渲染前执行(游戏子系统逻辑)  
  for(each frameListener) {
    // 回调函数
    frameListener.frameStarted()
  }
  
  // 渲染  
  renderCurrentScene()
  
  // 场景渲染后执行    
  for(each frameListener) {
    // 回调函数  
    frameListener.frameEnded()
  }  
  
  // 结束场景与交换缓冲区
  finalizeSceneAndSwapBuffers()  
}

而另一种回调是基于事件驱动,实现方式为:事件系统会将事件置于不同的队列之中,然后在合适的时机从队列中取出事件进行处理。这种方式也就是浏览器的处理方式,利用消息队列和事件循环来让网页动起来。

while(true) {
  // 从任务队列中取出任务  
  Task task = task_queue.takeTask(); 
    
  // 执行任务
  ProcessTask(task);
  // 执行各种其他任务 Process...  
}

而浏览器提供的回调就包括 setTimeout(延迟执行) 与 setInterval (间隔执行) requestAnimationFrame(动画渲染) requestIdleCallback (低优先级任务)。前两者执行时机由用户指定时机执行,后两者是由浏览器控制执行。

setTimeout 是一个定时器,用来指定某个函数在多少毫秒之后执行。他会返回一个编号,表示当前定时器的编号,同时你也可通过 clearTimeout 加入编号来取消定时器的执行。

// 注册 10 ms 后打印 hello world
const id = setTimeout(() => {
  console.log('hello world')
}, 10)

clearTimeout(id)

结合上面的事件驱动模型,可以看出该回调函数就是在循环中不断执行任务,当发现延迟任务队列中的某个任务超过了当前的时间节点(通过发起时间和设定的延迟时间来计算),就直接取出任务执行调用。等到期的任务都执行完成后,在进行下一个循环过程,通过这样的方式,一个完整的定时器就实现了。浏览器取消定时器则是通过 id 查找到对应的任务,直接将任务从队列中删除。

我们也可以通过 setTimout 回调函数内再次执行 setTimout 来实现 setInterval 函数的功能,看起来也类似于间隔执行,其实还是会有一定的区别。

// 在回调函数完成后才去设置定时器,时间会超过 16 ms
setTimeout(function render(){
  // 执行需要 6 ms
  // 定时 16ms 后
  console.log(+ new Date())
  setTimeout(render, 500);
}, 500)

// 尝试每 16 ms 执行一次,不管内部回调函数耗时
setInterval(function render(){
   console.log(+ new Date())
}, 16)

重点在于,JavaScript 本身是单线程的,同时基于上面的事件驱动代码,我们只能依赖任务加入顺序依次处理任务,无法切断当前任务的执行,我们只能够控制定时器任务何时能够加入队列,却无法控制何时执行,如果其他任务执行的时间过久的话,定时器任务就必须延后执行,开发者没有任何办法。 当然了,社区的力量也是无穷的,facebook 的 React Fiber 就是实现了在渲染更新过程中断当前任务,执行优先级更高的任务的功能。

不过像使用浏览器的系统(包括游戏等)都是软实时系统。所谓软实时系统,就是即使错过限定期限也不会造成灾难性后果——错过了当前帧数,现实世界不会因此造成灾难性后果,与此相比,航空电子,核能发电等系统都属于是硬实时系统,错过期限会有严重的后果。不过就算如此,谁会喜欢一个经常卡顿的系统呢?所以实际业务开发中的性能优化还是重中之重。

当然了, requestAnimationFrame 本身也是回调函数,那么这个函数究竟有什么过人之处可以提升动画性能呢?在此之前,我们先介绍一下屏幕刷新率与 Fps 的区别。

屏幕刷新率与 FPS

屏幕刷新率即图像在屏幕上更新的速度,也即屏幕上的图像每秒钟出现的次数,它的单位是赫兹(Hz)。 对于一般笔记本电脑,这个频率大概是 60Hz,在 Window 10 上 可以通过桌面上右键->显示设置->高级显示设置 中查看和设置。屏幕刷新率表示显示器的物理刷新速度。

image-20200512223617840.png

对于我的电脑来说,无论我目前是在浏览网页还是在挂机状态,当前显示器都以 1 秒刷新 165 次当前界面,该数值取决于显示器。我们也可以通过修改适配器属性->监视器来调整屏幕刷新率,一般来说,我们只要调整到眼睛舒适即可。

事实上,仅仅靠显示屏的刷新率是没有用的,就像上面的循环机制,如果 GPU 处理当前任务的耗时大于当前屏幕刷新的间隔时段。就无法按时提供图像。该特性也就是我们所说的 FPS 每秒传输帧数(Frames Per Second)。而对帧数起到决定性的是电脑中的显卡,显卡性能越强,帧数也就越高。

FPS 帧数是由显卡决定,刷新率是由显示器决定。如果显卡输出 FPS 低于显示屏的刷新率,则在显示屏刷新中将会复用同一张画面。反过来,显示器也会丢弃提供过多的图像。

requestAnimationFrame (Raf) 使用与机制

下面我们就谈一谈 raf 函数对比其他定时器回调的优点。

首先,raf 函数本身并不是一个新特性,就连 IE 10 都提供了支持,所以这里不再介绍兼容。设置这个 API 的目的是为了让各种网页动画效果(DOM动画、Canvas动画、SVG动画、WebGL动画)能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。使用方式如下:

let start = null;
let element = document.getElementById('SomeElementYouWantToAnimate');
element.style.position = 'absolute';

function step(timestamp) {
  if (!start) start = timestamp;
  var progress = timestamp - start;
  element.style.left = Math.min(progress / 10, 200) + 'px';
  if (progress < 2000) {
    window.requestAnimationFrame(step);
  }
}

window.requestAnimationFrame(step);

可以看到,其实函数使用方式和 setTimeout 基本一致,只不过不需要提供第二个参数。

那么在没有第二个参数的情况下,函数究竟多久执行一次呢?raf 充分利用显示器的刷新机制,执行频率和显示屏的刷新频率保持同步,利用这个刷新频率进行页面重绘。也就是说在我的电脑上,raf 函数每秒执行 165 次。也就是 6 ms 执行一次,如果其他任务执行事件过长的话,该函数顺延到合适的时机。也就是其他任务执行时间大于 6 ms,函数就会在 12 ms 时候第二次执行,如果大于 12 ms,则会在 18 ms 时候第二次执行。

当看到 raf 函数和屏幕刷新率一致时候,大家也能大致的猜测出,浏览器为什么要提供 raf 函数了。因为显示器和 GPU 属于两个不同的系统,两者很难协调的运行,即使两者周期一致,也是很难同步起来。

所以当显示器将一帧画面绘制完成后,并在准备读取下一帧之前,显示器会发出一个垂直同步信号(vertical synchronization)给 GPU,简称 VSync。这时候浏览器就会利用 VSync 信号来对 raf 进行调用。

CSS 动画是由浏览器渲染进程自动处理的,所以浏览器直接让 css 动画于 VSync 的时钟保持一致。但是 js 中 setTimout 和 setInterval 由开发者控制,调用时机基本不可能和 VSync 保持一致。所以浏览器为 js 提供了raf 函数,用来和 VSync 的时钟周期同步执行。

针对于 VSync,大家可以参考 理解 VSync 这篇文章。

扩展

注意: 浏览器为了优化后台页面的加载损耗以及降低耗电量,会让没有激活的浏览器标签 setTimeout 执行间隔大于 1s。requestAnimationFrame 执行速率会不断下降,同理 requestIdleCallback 也是如此。

同时,相信浏览器后面也会函数调度提供更加方便的支持,大家感兴趣也可以了解一下 isInputPending,不过该提案还处于起草阶段,工作组尚未批准,更不用说投入生产中了。

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。
博客地址

参考资料

《游戏引擎架构》

深入理解 requestAnimationFrame

理解 VSync

查看原文

赞 8 收藏 4 评论 0

爱莎之刃 赞了文章 · 2020-08-13

QUIC协议详解之Initial包的处理

从服务器发起请求开始追踪,细说数据包在 QUIC 协议中经历的每一步。大量实例代码展示,简明易懂了解 QUIC。

前言

本文介绍了在 QUIC 服务器在收到 QUIC 客户端发起的第一个 UDP 请求— Initial 数据包的分析、处理和解密过程,涉及Initial数据包的格式,数据包头部保护的去除, Packet Number 的计算,负载数据的解密,client hello 的解析,等等。本文的 C 实现采用 OpenSSL,并基于 IETFQUIC Draft-27。

术语

PacketNumber :数据包序号

Initial Packet:初始数据包

Variable-length Integer Encode:可变长度整型编码

HMAC:Hash-based messageauthencation code,基于 Hash 的验证信息码

HKDF: HMAC-based Extract-and-Expand KeyDerivation Function,基于 HMAC 的提取扩展密钥衍生函数

AEAD: authenticated encryption withassociated data, 带有关联数据的认证加密

ECB: Electronic codebook,电子密码本

GCM: Galois/Counter Mode,伽罗瓦/计数器模式

IV: InitialVector, 初始化向量

基本概念介绍

Initial 数据包的结构

Initial 包是长头部结构的数据包,结构如图 3.1 所示,在 CRYPTO 帧后面需要跟上 PADDING 帧,这是 QUIC 协议预防 UDP 攻击的手段之一。一般情况下,CRYPTO 帧太短了(确实也有比较长“一锅炖不下”的情况,可参阅 QTS-TLS 4.3节),服务端为了响应 CRYPTO, 必须发送数据长度大得多的握手包(Handshake Packet),这样就会造成所谓的反射攻击。

QUIC 使用三种方法来抑制此类攻击:

  • 含有 ClientHello 的数据包必须使用 PADDING 帧,达到协议要求的最小数据长度 1200 字节;
  • 当服务端响应未经验证原地址的请求,第一次(firstflight)发送数据时,不允许发送超过三个 UDP 数据报的数据;
  • 确认握手包是带验证的,盲攻击者无法伪造。
typedef struct {
  uint8_t flag;
  uint32_t version;
  uint8_t dcid_length;
  uint8_t *dcid;
  uint8_t scid_length;
  uint8_t *scid;
  uint64_t token_length;
  uint8_t *token;
  uint64_t packet_length;
  uint8_t *payload;} quic_long_header_packet_t;

Packet Number 三种上下文空间

Packet Number 为整型变量,其值在 0 到 2^62-1 之间,它也用于生成数据包加密所需的 nonce。通讯双方维护各自的 Packet Number 体系, 并且分为三个独立的上下文空间:

  • Initial 空间:所有的 Initial 数据包的 Packet Number 均在这个上下文空间里;
  • Handshake 空间:所有的握手数据包;
  • 应用数据空间:所有的 0-RTT 和 1-RTT 包。

所谓的 Packet Number 空间,指得是一种上下文关系,在这个上下文关系里,数据包被处理,被确认。换言之,初始数据包只能使用初始数据包专用的密钥,也只能确认初始数据包。类似的, 握手包只能使用握手包专用的密钥,也只能确认握手数据包。从 Initial 阶段进入 Handshake 阶段后, Initial 阶段使用的密钥就可以被丢弃了,Packet Number 也重新从 0 开始编号。

0-RTT 和 1-RTT 共享同一个 Packet Number 空间,这样做是为了更容易实现这两类数据包的丢包处理算法。

在同一连接同一个 Packet Number 空间里,你不能复用包号,包号必须是单调递增的,当然,具体实现的时候草案并不强制要求每次都递增1, 你可以递增 20,30。当 Packet Number 达到 2^62 -1 时,发送方必须关闭该连接。

通讯过程 Packet Number 的处理还有许多细节,比如重复抑制问题,这部分可以参考 QUIC-TLS 部分以及 RFC4303 的 3.4.3 节,这里就不深入展开讨论。

HKDF:基于 HMAC 的密钥衍生函数

密钥衍生函数(KDF)是加密系统最为基本核心的组件,它将初始密钥作为输入,生成一个或多个足够健壮的加密密钥。

HKDF 的提出一方面是为了给其他协议和应用程序提供基本的功能块,同时也为了解决各种不同机制的密钥衍生函数实现的激增问题。它采用“先提取再扩展(extract-and-expand)”的设计方式,逻辑上,一般采用两个步骤来完成密钥衍生。第一步,将输入的字符转换成固定长度的伪随机密钥。第二步,将其扩展成若干个伪随机密钥。一般人们把通过 Diffie-Hellman 交换的共享密文转换为指定长度的密钥,用于加密,完整性检查以及验证。具体原理可参考 RFC5869。

可变长度整型编码

QUIC 协议中大量使用可变长度整型编码,用首字节的高 2 位来表示数据的长度,编码规则如下:

举个例子:

0b00000011 01011110,0x035e => 2Bit=00,代表长度为 1,可用位数 6, 所以,Value = 3

0b01011001 01011110,0x595e => 2Bit=01,代表长度为 2,可用位数 14,所以,Value = 6494

代码如下:

  uint64_t Buffer_pull_uint_var(upai_buffer_t *buf, ssize_t *size)
  {
    CK_RD_BOUNDS(buf, 1)
    uint64_t value;
    switch (*(buf->pos) >> 6) {
    case 0:
        value = *(buf->pos++) & 0x3F;
        if (size != NULL) *size = 1;
        break;
    case 1:
        CK_RD_BOUNDS(buf, 2)
        value = (uint16_t)(*(buf->pos) & 0x3F) << 8 |
                (uint16_t)(*(buf->pos + 1));
        buf->pos += 2;
        if (size != NULL) *size = 2;
        break;
    case 2:
        CK_RD_BOUNDS(buf, 4)
        value = (uint32_t)(*(buf->pos) & 0x3F) << 24 |
                (uint32_t)(*(buf->pos + 1)) << 16 |
                (uint32_t)(*(buf->pos + 2)) << 8 |
                (uint32_t)(*(buf->pos + 3));
        buf->pos += 4;
        if (size != NULL) *size = 4;
        break;

    default:
        CK_RD_BOUNDS(buf, 8)
        value = (uint64_t)(*(buf->pos) & 0x3F) << 56 |
                (uint64_t)(*(buf->pos + 1)) << 48 |
                (uint64_t)(*(buf->pos + 2)) << 40 |
                (uint64_t)(*(buf->pos + 3)) << 32 |
                (uint64_t)(*(buf->pos + 4)) << 24 |
                (uint64_t)(*(buf->pos + 5)) << 16 |
                (uint64_t)(*(buf->pos + 6)) << 8 |
                (uint64_t)(*(buf->pos + 7));
        buf->pos += 8;
        if (size != NULL) *size = 8;
        break;
    }
    return value;}

Initial 包的处理过程

头部明文信息解析

这部分比较简单,直接上代码:

uapi_err_t pull_quic_header(upai_buffer_t *buf, quic_header_packet_t *header){
    int32_t retcode = 0;
    CK_RET(Buffer_pull_uint8(buf, &(header->flag)),
        UPAI_ERR_HEADER|1))

    header->is_long_header = (header->flag & PACKET_LONG_HEADER) == 0 ? -1 : 1;

    if (header->is_long_header > 0) {
        CK_RET(Buffer_pull_uint32(buf, &(header->version)),
            UPAI_ERR_HEADER|2)
        CK_RET(Buffer_pull_uint8(buf, &(header->dcid_length)),
            UPAI_ERR_HEADER|3)
        CK_RET(Buffer_pull_bytes(buf, header->dcid_length, 
            &(header->dcid)),
            UPAI_ERR_HEADER|4)
        CK_RET(Buffer_pull_uint8(buf, &(header->scid_length)),
            UPAI_ERR_HEADER|5)
        CK_RET(Buffer_pull_bytes(buf, header->scid_length , 
            &(header->scid)),
            UPAI_ERR_HEADER|6)

        if (header->version == PROTO_NEGOTIATION) {
            header->packet_type = 0;
        } else {
            header->packet_type = header->flag & PACKET_TYPE_MASK;
        }

        if (header->packet_type == PACKET_TYPE_INITIAL) {
            CK_RET(Buffer_pull_uint_var(buf, NULL, 
                &(header->token_length)),
                UPAI_ERR_HEADER|7)
            CK_RET(Buffer_pull_bytes(buf, header->token_length, 
                &(header->token)),
                UPAI_ERR_HEADER|8)
            CK_RET(Buffer_pull_uint_var(buf, NULL, 
                &(header->packet_length)),
                UPAI_ERR_HEADER|9)

            header->packet_number_offset = buffer_tell(buf);

            CK_RET(Buffer_pull_bytes(buf, header->packet_length, 
                &(header->payload)),
                UPAI_ERR_HEADER|10)
        } else if (header->packet_type == PACKET_TYPE_RETRY) {

            //TODO: deal with retry packet parsing

        } else {
            CK_RET(Buffer_pull_uint_var(buf, NULL, 
                &(header->packet_length)),
                UPAI_ERR_HEADER|11)
            CK_RET(Buffer_pull_bytes(buf, header->packet_length, 
                &(header->payload)),
                UPAI_ERR_HEADER|12)
        }
    } else {

        //TODO: short header parse

    }
    return UPAI_RES_OK;}

生成 KEY, IV, HP

QUIC 协议定义了 4 组加密密钥集,对应四个不同的加密层级,这与 Packet Number 空间有类似的意思,他们是:

  • Initial 密钥集
  • Early Data(0-RTT)密钥集
  • Handshake,握手密钥集
  • Application Data(1-RTT),应用数据密钥集

QUIC 的 CRYPTO 帧和 TCP 上的 TLS 最大不不同点在于,一个 QUIC 数据包里可能含有多个数据帧,协议规范本身也要求,只要在同一加密密钥层里,一个数据包就应该尽可能的多放入数据帧。

解密 Initial 数据包,使用的便是 Initial 密钥集。进入某个加密层级,需要三样东西:

  • 初始密钥
  • AEAD 函数
  • HKDF 函数

QUIC 的 Initial 包的初始机密(Initial secrets)同版本号,目标 Connection ID 相关,加密算法固定为 AES-128-GCM,Initial secrets 的提取方式如下:

uint32_t algorithm_digest_size = _get_algorithm_digest_size(ctx->cipher_name);//SHA256的长度是32
const uint8_t initial_salt_d27 []= {0xc3,0xee,0xf7,0x12,
                       0xc7,0x2e,0xbb,0x5a,
                       0x11,0xa7,0xd2,0x43,
                       0x2b,0xb4,0x63,0x65,
                       0xbe,0xf9,0xf5,0x02};//Draft-27的salt
uint8_t *initial_secrets = (uint8_t *)upai_mem_pool_alloc(algorithm_digest_size);
ret = upai_HKDF_Extract(_get_hash_method(ctx->cipher_name), //SHA256
    initial_salt_d27, 
    sizeof(initial_salt_d27), 
    initial_packet.dcid, 
    initial_packet.dcid_length, 
    initial_secrets);
CK_KG_RET(ret, UPAI_KG_ERR | 1)

提取出 Initial Secrets 之后,便是扩展出 Key,IV 和 HP 了,在这之前,于服务端,需要先扩展出接收机密(receive secrets),需要用“client in”作为标签。标签函数大致长这样:

static uapi_err_tupai_hkdf_label(
    upai_memory_pool_t *m,
    const uint8_t * label,
    uint32_t sz_label,
    const uint8_t * hash_value,
    uint32_t sz_hash_value,
    uint32_t sz,
    uint8_t **out,
    uint32_t *sz_out){
    uint32_t full_size = 10 + sz_label + sz_hash_value;
    if (sz_out != NULL)
        *sz_out = full_size;
    *out = (uint8_t *)upai_mem_pool_alloc(m, full_size);
    (*out)[0] = (uint8_t)((uint16_t)(sz >> 8));
    (*out)[1] = (uint8_t) sz;
    (*out)[2] = 6 + sz_label;
    memcpy(*out+3, "tls13 ", 6);
    memcpy(*out + 9, label, sz_label);
    (*out)[sz_label + 9] = sz_hash_value;
    memcpy(*out + 9 + sz_label + 1, hash_value, sz_hash_value);
    return UPAI_RES_OK;}

有了 receive secrets,接下来就是由它再扩展出以“quic key”为标签的 Key,以“quiciv”为标签的 IV 和以“quic hp”为标签的 HP。前两个用于解密负载,后一个用于去除数据包头部掩码。代码如下所示:

uint8_t *recv_label;
uint32_t sz_recv_label;
uint32_t sz_defined_key = = _get_algorithm_key_size(ctx->cipher_name);
upai_hkdf_label(m, "client in", 9, "", 0, algorithm_digest_size, &recv_label, &sz_recv_label);
uint8_t *recv_secrets = (uint8_t *)upai_mem_pool_alloc(ctx->mem, algorithm_digest_size);
ret = upai_HKDF_Expand(_get_hash_method(ctx->cipher_name),
        initial_secrets,
        sz_initial_secrets,
        recv_label,
        sz_recv_label,
        recv_secrets,
        algorithm_digest_size);
CK_KG_RET(ret, UPAI_KG_ERR | 2)
uint8_t *key, *iv, *hp;uint32_t sz_key, sz_iv, sz_hp;
upai_hkdf_label(m, "quic key", 8, "", 0, sz_defined_key, &key, &sz_key);
upai_hkdf_label(m, "quic iv", 7, "", 0, AEAD_NONCE_LENGTH, &iv, &sz_iv);
upai_hkdf_label(m, "quic hp", 7, "", 0, sz_defined_key, &hp, &sz_hp);
uint8_t *key_for_client = upai_mem_pool_alloc(ctx->mem, sz_defined_key);
uint8_t *iv_for_client = upai_mem_pool_alloc(ctx->mem, AEAD_NONCE_LENGTH);
uint8_t *hp_for_client= upai_mem_pool_alloc(ctx->mem, sz_defined_key);
ret = upai_HKDF_Expand(_get_hash_method(ctx->cipher_name), //Initial包的Hash函数是SHA256
        recv_secrets, algorithm_digest_size, key, sz_key, key_for_client, sz_defined_key);
CK_KG_RET(ret, UPAI_KG_ERR | 3)
ret = upai_HKDF_Expand(_get_hash_method(ctx->cipher_name),
        recv_secrets, 
        algorithm_digest_size, iv, sz_iv, 
        iv_for_client, AEAD_NONCE_LENGTH);
CK_KG_RET(ret, UPAI_KG_ERR | 4)
ret = upai_HKDF_Expand(_get_hash_method(ctx->cipher_name),
        recv_secrets, 
        algorithm_digest_size, hp, sz_hp, 
        hp_for_client, sz_defined_key);
CK_KG_RET(ret, UPAI_KG_ERR | 5)

去除头部保护

QUIC 协议的 Initial 数据包头部第一个字节的后 4~5 比特,以及头部的 PacketNumber 域是经过 AES-128-ECB 混淆的, 其中第一字节的最后两位指示了 Packet Number 的存储长度,使得数据包的 Pakcet Number 长度不可见。不确定 Packet Number 的长度,负载的解密也无从谈起。加密这两部分的密钥由初始化向量IV以及保护密钥衍生而来。该密钥使用“quic hp”作为标签(生成方式可参考上一节),作用于头部第一字节的最低有效位和 Packet Number 域,如果是长头部,则加密 4 位;若是短头部则加密最低 5 位。不过版本协商包和重试包不需要做头部加密。

以下代码初始化 crypto_context,并执行 remove header protection 操作:

upai_memory_pool_t *m = upai_create_memory_pool(MEM_POOL_SIZE);//创建内存池
//.....
//此处省略若干无关代码
//.....
uint8_t *plain_header;
uint32_t plain_header_len, truncated_pn, pn_length;
upai_crypto_ctx_t *  crypt_ctx = upai_create_quic_crypto(m);
crypt_ctx->initialize(crypt_ctx,
    "AES-128-ECB", //去除头部混淆用的算法
    "AES-128-GCM", //负载部分的加解密算法
    key_for_client, sz_key, //Key
    iv_for_client, sz_iv,   //IV
    hp_for_client, sz_hp);  //HP
crypt_ctx->remove_hp(crypt_ctx,
    Buffer_get_base(quic_buffer), //QUIC数据包存储首地址
    Buffer_get_size(quic_buffer), //长度
    initial_packet.packet_number_offset, //Packet Number域的偏移位置
    &plain_header, //输出的纯文本头部
    &plain_header_len, //长度
    &truncated_pn, //编码后的Packet Number
    &pn_length);//PN存储长度

以下为 crypt_ctx->initialize 函数的头部保护去除初始化部分代码

//header protection init
int res = EVP_CipherInit(ctx->hp_ctx,
EVP_get_cipherbyname(hp_cipher_name), NULL, NULL, 1);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO | 1)
res = EVP_CIPHER_CTX_set_key_length(ctx->hp_ctx, hp_len);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO | 2)
res = EVP_CipherInit_ex(ctx->hp_ctx, NULL, NULL, hp, NULL, 1);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO | 3)

解密头部保护的代码如下

//remove_hp主要代码u
int8_t mask[32] = {0}, buffer[PACKET_LENGTH_MAX] = {0};
int32_t outlen;
uint8_t *sample = packet_buffer + packet_number_offset + PACKET_NUMBER_LENGTH_MAX;
int32_t res = EVP_CipherUpdate(ctx->hp_ctx, mask, &outlen, sample, SAMPLE_LENGTH);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO | 4)
memcpy(buffer, packet_buffer, packet_number_offset + PACKET_NUMBER_LENGTH_MAX);
if (buffer[0] & 0x80) //长头部数据包,后4位去混淆
{
    buffer[0] ^= mask[0] & 0x0f;
} else //短头部数据包,后5位去混淆
{
    buffer[0] ^= mask[0] & 0x1f;
}
int pn_length = (buffer[0] & 0x03) + 1;//第一字节的最低2位指示Packet Number的长度
*truncated_pn = 0;
for (int i = 0; i < pn_length; ++ i) {
buffer[packet_number_offset + i] ^= mask[i + 1];
*truncated_pn = buffer[packet_number_offset + i] | (*truncated_pn) << 8);
}
*plain_header =(uint8_t *) upai_mem_pool_alloc(ctx->mem, packet_number_offset + pn_length);
memcpy(*plain_header, buffer, packet_number_offset + pn_length);
*plain_header_len = packet_number_offset + pn_length;
*packet_number_len = pn_length;

计算 Packet Number

Packet numbers 是大小为 0-2^62-1 之间的整型数值,单调递增,表示数据包的先后顺序, 但是放入 QUIC 数据包头部时却编码成 1-4 字节的数据。通过丢弃 packet number 的高位数据 接收方通过上下文恢复 packet number,这样一来就达到缩减数据长度的目的。

发送端的 packet number 数据存储容量,一般要求是其最近确认收到的数据包的 packet number 与正要发送的数据包的 packet number 之差的两倍以上,如此接收端方能正确解码。

举个例子,如果通讯的某一方收到对方的确认帧,确认己方发出的 packetnumber 为 0xabe8bc 的数据包已收到, 那么如果要发送 packetnumber 为 0xac5c02 的数据包,则至少需要(0xac5c02- 0xabe8bc) 2 = 0xe68c, 16 位的编码空间,如果发送packet number是0xace8fe,则至少需要(0xace8fe - 0xabe8bc)2= 0x20084, 24 位的编码空间。

接收端必须得去掉包头保护,再才能进行 packet number 的解码工作。头部保护去掉后就可以拿到编码过的 packet number 亦即 truncatedpacket number,需根据一定算法还原真实数字。其中 expected 为解码端预期的包号,即已接收的最大包号值加 1。举个例子,当前最大的包号是 0xa82f30ea,那么如果接收到的编码包号是 16 位数据 0x9b32, 那么最终解码出来的 packet number 是 0xa82f9b32。

实现代码如下所示。

uint64_t decode_packet_number(uint32_t truncated, uint8_t num_bits, uint64_t expected){
    uint64_t window = 1L << num_bits;
    uint64_t half_window = (uint64_t )(window/2);
    uint64_t candidate = (expected & ~(window - 1)) | truncated;
    const uint64_t pn_max = 1L << 62;
    if (((int64_t)candidate <= (int64_t)(expected - half_window))
      && (candidate < (pn_max - window))) {
        return candidate + window;
    } else if ((candidate > expected + half_window)&&(candidate >= window)) {
        return candidate - window;
    } else {
        return candidate;
    }}

解密负载内容

Initial 数据包的负载采用的是 AES-128-GCM 加密算法。首先初始化 OpenSSL EVP:

res = EVP_CipherInit_ex(ctx->decrypt_ctx,
    EVP_get_cipherbyname(aead_cipher_name), //Cipher name=AES-128-GCM
    NULL, NULL, NULL, 0);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO|6)
res = EVP_CIPHER_CTX_set_key_length(ctx->decrypt_ctx, key_len);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO|7)
res = EVP_CIPHER_CTX_ctrl(ctx->decrypt_ctx,
    EVP_CTRL_GCM_SET_IVLEN, iv_len, NULL);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO|8)

解密负载时,IV 部分还需要 PacketNumber 参与计算最终生成 nonce,

uint8_t nonce[AEAD_NONCE_LENGTH] = {0};
memcpy(nonce, ctx->iv, AEAD_NONCE_LENGTH);
*plain_payload_len = 0;
*plain_payload = NULL;
uint8_t *data = packet_buffer + plain_header_len;
uint32_t data_len = packet_buffer_len - plain_header_len;
uint8_t buffer_payload[PACKET_LENGTH_MAX] = {0};
for (int i = 0; i < 8; i++) {
    nonce[AEAD_NONCE_LENGTH - 1 - i] ^= (uint8_t )(packet_number >> 8 * i);
    }
int32_t res = EVP_CipherInit_ex(ctx->decrypt_ctx,
    NULL, NULL, ctx->key, nonce, 0);
res = EVP_CIPHER_CTX_ctrl(ctx->decrypt_ctx,
        EVP_CTRL_GCM_SET_TAG,
        AEAD_TAG_LENGTH,
        (void *)(data + (data_len-AEAD_TAG_LENGTH)));
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO|10)
int32_t outlen, outlen2;
res = EVP_CipherUpdate(ctx->decrypt_ctx, NULL, &outlen,
        plain_header,
        plain_header_len);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO|11)
res = EVP_CipherUpdate(ctx->decrypt_ctx, buffer_payload, &outlen,
        data,
        data_len - AEAD_TAG_LENGTH);
CRYPTO_CK_RET(res, UPAI_ERR_CRYPTO|12)
res = EVP_CipherFinal_ex(ctx->decrypt_ctx, NULL, &outlen2);
if (res == 0) {
    return UPAI_ERR_CRYPTO|14;
} else {
    *plain_payload = (uint8_t *) upai_mem_pool_alloc(ctx->mem, outlen);
    memcpy(*plain_payload, buffer_payload, outlen);
    *plain_payload_len = outlen;
    return UPAI_RES_OK;
}

解析 ClientHello

上一节我们拿到了负载的明文, 这个区域存储的是至少一个或者一个以上的数据帧。Initial 数据包负载区第一帧一般是 CRYPTO 数据帧,FrameType 值为 0x06。以下代码获取了 CRYPTO 帧的四个数据段:FrameType,Offset, Length,CryptoData。其中,Offset,为变长整型数值,指示数据在该帧中的字节偏移位置, Length 段,为变长整型数值,指示 Crypto Data 的长度。

uint64_t frame_type, frame_length, frame_offset;
uint8_t *crypto_data;
Ref_buffer(m, payload_buffer, 0, plain_payload, plain_payload_len);
Buffer_pull_uint_var(payload_buffer, NULL, &frame_type);
if (frame_type == FRAME_TYPE_CRYPTO) {
    Buffer_pull_uint_var(plain_payload_buffer, NULL, &frame_offset);
    Buffer_pull_uint_var(plain_payload_buffer, NULL, &frame_length);
    Buffer_pull_bytes(plain_payload_buffer, frame_length, &crypto_data);
}

取得 Crypto Data 后,接着是对该段数据的解析。第一个字节是 HandshakeType,定义如下:


typedef enum {
    client_hello = 1,
    server_hello = 2,
    new_session_ticket = 4,
    end_of_early_data = 5,
    encrypted_extensions = 8,
    certificate = 11,
    certificate_request = 13,
    certificate_verify = 15,
    finished = 20,
    key_update = 24,
    message_hash = 254} handshake_type_t;

显而易见,Initial 包里该段的类型值为 0x01,表明是 ClientHello 数据。接下来便是解析 TLS1.3 的 ClientHello 数据结构。

以下为 RFC8446 的 ClientHello 结构体:


uint16_t ProtocolVersion;opaque Random[32];
uint8 CipherSuite[2];
struct {
      ProtocolVersion legacy_version = 0x0303;    /* TLS v1.2 */
      Random random;
      opaque legacy_session_id<0..32>;
      CipherSuite cipher_suites<2..2^16-2>;
      opaque legacy_compression_methods<1..2^8-1>;
      Extension extensions<8..2^16-1>;
      } ClientHello;

解释一下为什么 legacy_version 是 0x0303: 在 TLS 的前一个版本中,该字段用于版本协商,也表示客户端能支持到的最高版本号。实践证明许多服务器并没有很好地实现版本协商功能,导致了所谓的“版本不宽容”的问题,只要此版号高于服务器能支持的,它就会连带着拒绝其他它它能接受的 ClientHello,在 TLS1.3 中, 客户端可以在 ClientHello 扩展信息的“supported_versions”字段中声明它版本支持的优先级, 因此,为兼容性考虑,legacy_version 就必须设为 0x0303,表示版本 TLS1.2。如此一来, 通过将 legacy_version 等于 0x0303,并在 supported_versions 字段中设 0x0304 为最高优先版本, 就可以表明,此 ClientHello 为 TLS1.3 了。

简单的实现代码如下:

uint8_t handshake_type;
uint8_t h_length;
uint16_t l_length;
uint16_t tls_version;
uint8_t *random_value;
uint8_t session_id_length;
uint8_t *session_id;
uint16_t cipher_suites_length;
uint16_t ciphers[256];
uint8_t compression_length;
uint8_t *compression_methods;
Buffer_pull_uint8(plain_payload_buffer, &handshake_type);
Buffer_pull_uint8(plain_payload_buffer, &h_length);
Buffer_pull_uint16(plain_payload_buffer, &l_length);
Buffer_pull_uint16(plain_payload_buffer, &tls_version);
Buffer_pull_bytes(plain_payload_buffer, 32, &random_value);
Buffer_pull_uint8(plain_payload_buffer, &session_id_length);
Buffer_pull_bytes(plain_payload_buffer, session_id_length, &session_id);
Buffer_pull_uint16(plain_payload_buffer, &cipher_suites_length);
for (int i = 0; i < cipher_suites_length/2;i++){
    Buffer_pull_uint16(plain_payload_buffer, ciphers + i);
    }
Buffer_pull_uint8(plain_payload_buffer, &compression_length);
Buffer_pull_bytes(plain_payload_buffer, compression_length, &compression_methods);

最后,我们来看看 Extension 的结构,引用自 RFC8446。

struct {
    ExtensionType extension_type;
    opaque extension_data<0..2^16-1>;} Extension;
enum {
    server_name(0),                             /* RFC 6066 */
    max_fragment_length(1),                     /* RFC 6066 */
    status_request(5),                          /* RFC 6066 */
    supported_groups(10),                       /* RFC 8422, 7919 */
    signature_algorithms(13),                   /* RFC 8446 */
    use_srtp(14),                               /* RFC 5764 */
    heartbeat(15),                              /* RFC 6520 */
    application_layer_protocol_negotiation(16), /* RFC 7301 */
    signed_certificate_timestamp(18),           /* RFC 6962 */
    client_certificate_type(19),                /* RFC 7250 */
    server_certificate_type(20),                /* RFC 7250 */
    padding(21),                                /* RFC 7685 */
    pre_shared_key(41),                         /* RFC 8446 */
    early_data(42),                             /* RFC 8446 */
    supported_versions(43),                     /* RFC 8446 */
    cookie(44),                                 /* RFC 8446 */
    psk_key_exchange_modes(45),                 /* RFC 8446 */
    certificate_authorities(47),                /* RFC 8446 */
    oid_filters(48),                            /* RFC 8446 */
    post_handshake_auth(49),                    /* RFC 8446 */
    signature_algorithms_cert(50),              /* RFC 8446 */
    key_share(51),                              /* RFC 8446 */
    (65535)} ExtensionType;

总结

到这里,QUIC 协议的解析总算是走出了万里长征的第一步,作为服务端,得回复 ACK 帧,告知客户端“你方请求已经收到”,然后回复 ServerHello,放入 CRYPTO 帧,把该交代的事情交代清楚,该协商的事情协商明白,这两个帧塞在同一个数据包发给客户端,然后,双方就可以愉快的步入 Handshake 的殿堂了。是的,1-RTT 握手过程就是这样。

参考资料

https://tools.ietf.org/html/d...
https://datatracker.ietf.org/...

https://github.com/aiortc/aio...

https://github.com/carlescufi...

https://tools.ietf.org/html/r... TLS1.2

https://tools.ietf.org/html/r... TLS1.3

https://tools.ietf.org/html/r... HKDF

https://tools.ietf.org/html/r... IPEncapsulating Security Payload

推荐阅读

从新冠疫情出发,漫谈 Gossip 协议

QUIC/HTTP3 协议简析

查看原文

赞 7 收藏 5 评论 0

爱莎之刃 收藏了文章 · 2020-07-29

Dubbo 一篇文章就够了:从入门到实战

一 为什么需要 dubbo

很多时候,其实我们使用这个技术的时候,可能都是因为项目需要,所以,我们就用了,但是,至于为什么我们需要用到这个技术,可能自身并不是很了解的,但是,其实了解技术的来由及背景知识,对于理解一项技术还是有帮助的,那么,dubbo是怎么被提上日程的呢?

在互联网的发展过程中,在以前,我们只需要一个服务器,将程序全部打包好就可以,但是,随着流量的增大,常规的垂直应用架构已无法应对,所以,架构就发生了演变。

1 单一应用架构

2 应用和数据库单独部署

3 应用和数据库集群部署

4 数据库压力变大,读写分离

5 使用缓存技术加快速度

6 数据库分库分表

7 应用分为不同的类型拆分

发展到这个阶段的时候,我们发现,应用与应用之间的关系已经十分的复杂了,就会出现以下几个问题(以下摘录于官网):

① 当服务越来越多时,服务 URL 配置管理变得非常困难,F5 硬件负载均衡器的单点压力也越来越大。
② 当进一步发展,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。
③ 接着,服务的调用量越来越大,服务的容量问题就暴露出来,这个服务需要多少机器支撑?什么时候该加机器?

为了解决这由于架构的演变所产生的问题几个问题,于是,dubbo 产生了。当然,解决这个问题的技术不止 dubbo 。

摘录于官网

从上面 Dubbo 的服务治理图我们就可以看到,Duboo 很好了解决了上面所出现的一些问题。

所以,当你的系统架构发展到了这种阶段的时候,就需要考虑使用 Dubbo 了。

二 Dubbo 技术架构

我们已经非常清楚的知道为什么在我们的系统中需要 Dubbo 这项技术了,下面,我们接着唠叨唠叨 Dubbo 的架构。

首先,上一张图(摘自官网)。

摘自官网

看到图之后,可能你对上面的几个概念还是一脸懵逼,无从下手,下面,带你看看这几个角色到底是什么意思?

节点角色说明

节点角色说明
Provider暴露服务的服务提供方
Consumer调用远程服务的服务消费方
Registry服务注册与发现的注册中心
Monitor统计服务的调用次数和调用时间的监控中心
Container服务运行容器

看了这几个概念后似乎发现,其实 Dubbo 的架构也是很简单的(其实现细节是复杂的),为什么这么说呢,有没有发现,其实很像生产者-消费者模型。只是在这种模型上,加上了注册中心和监控中心,用于管理提供方提供的url,以及管理整个过程。

那么,整个发布-订阅的过程就非常的简单了。

  • 启动容器,加载,运行服务提供者
  • 服务提供者在启动时,在注册中心发布注册自己提供的服务
  • 服务消费者在启动时,在注册中心订阅自己所需的服务

如果考虑失败或变更的情况,就需要考虑下面的过程。

  • 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
  • 服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
  • 服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。

通过这番讲解,我相信 Dubbo 的架构我们也轻车熟路了,那就直接入手,开车吧。

三 Dubbo 开始入门

终于走到这一步了,写到这里停了大概一周的时间,主要原因还是最近项目太忙,赶着交差呢,今天希望能一鼓作气,完完整整的写完 dubbo 的基础篇!

3.1 服务端

首先,我们先把服务端的接口写好,因为其实 dubbo 的作用简单来说就是给消费端提供接口。

接口定义
/**
 * xml方式服务提供者接口
 */
public interface ProviderService {

    String SayHello(String word);
}

这个接口非常简单,只是包含一个 SayHello 的方法。

接着,定义它的实现类。

/**
 * xml方式服务提供者实现类
 */
public class ProviderServiceImpl implements ProviderService{

    public String SayHello(String word) {
        return word;
    }
}

这样我们就把我们的接口写好了,那么我们应该怎么将我们的服务暴露出去呢?

导入 maven 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.ouyangsihai</groupId>
    <artifactId>dubbo-provider</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.alibaba/dubbo -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>dubbo</artifactId>
            <version>2.6.6</version>
        </dependency>
        <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.4.10</version>
        </dependency>
        <dependency>
            <groupId>com.101tec</groupId>
            <artifactId>zkclient</artifactId>
            <version>0.5</version>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.32.Final</version>
        </dependency>
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-framework</artifactId>
            <version>2.8.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>2.8.0</version>
        </dependency>

    </dependencies>
</project>

这里使用的 dubbo 的版本是 2.6.6 ,需要注意的是,如果你只导入 dubbo 的包的时候是会报错的,找不到 netty 和 curator 的依赖,所以,在这里我们需要把这两个的依赖加上,就不会报错了。

另外,这里我们使用 zookeeper 作为注册中心。

到目前为止,dubbo 需要的环境就已经可以了,下面,我们就把上面刚刚定义的接口暴露出去。

暴露接口(xml 配置方法)

首先,我们在我们项目的 resource 目录下创建 META-INF.spring 包,然后再创建 provider.xml 文件,名字可以任取哦,如下图。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
       xsi:schemaLocation="http://www.springframework.org/schema/beans        http://www.springframework.org/schema/beans/spring-beans.xsd        http://code.alibabatech.com/schema/dubbo        http://code.alibabatech.com/schema/dubbo/dubbo.xsd">

    <!--当前项目在整个分布式架构里面的唯一名称,计算依赖关系的标签-->
    <dubbo:application name="provider" owner="sihai">
        <dubbo:parameter key="qos.enable" value="true"/>
        <dubbo:parameter key="qos.accept.foreign.ip" value="false"/>
        <dubbo:parameter key="qos.port" value="55555"/>
    </dubbo:application>

    <dubbo:monitor protocol="registry"/>

    <!--dubbo这个服务所要暴露的服务地址所对应的注册中心-->
    <!--<dubbo:registry address="N/A"/>-->
    <dubbo:registry address="N/A" />

    <!--当前服务发布所依赖的协议;webserovice、Thrift、Hessain、http-->
    <dubbo:protocol name="dubbo" port="20880"/>

    <!--服务发布的配置,需要暴露的服务接口-->
    <dubbo:service
            interface="com.sihai.dubbo.provider.service.ProviderService"
            ref="providerService"/>

    <!--Bean bean定义-->
    <bean id="providerService" class="com.sihai.dubbo.provider.service.ProviderServiceImpl"/>

</beans>

① 上面的文件其实就是类似 spring 的配置文件,而且,dubbo 底层就是 spring。
节点:dubbo:application
就是整个项目在分布式架构中的唯一名称,可以在 name 属性中配置,另外还可以配置 owner 字段,表示属于谁。
下面的参数是可以不配置的,这里配置是因为出现了端口的冲突,所以配置。
节点:dubbo:monitor
监控中心配置, 用于配置连接监控中心相关信息,可以不配置,不是必须的参数。
节点:dubbo:registry
配置注册中心的信息,比如,这里我们可以配置 zookeeper 作为我们的注册中心。address 是注册中心的地址,这里我们配置的是 N/A 表示由 dubbo 自动分配地址。或者说是一种直连的方式,不通过注册中心。
节点:dubbo:protocol
服务发布的时候 dubbo 依赖什么协议,可以配置 dubbo、webserovice、Thrift、Hessain、http等协议。
节点:dubbo:service
这个节点就是我们的重点了,当我们服务发布的时候,我们就是通过这个配置将我们的服务发布出去的。interface 是接口的包路径,ref 是第 ⑦ 点配置的接口的 bean。
⑦ 最后,我们需要像配置 spring 的接口一样,配置接口的 bean。

到这一步,关于服务端的配置就完成了,下面我们通过 main 方法将接口发布出去。

发布接口
package com.sihai.dubbo.provider;

import com.alibaba.dubbo.config.ApplicationConfig;
import com.alibaba.dubbo.config.ProtocolConfig;
import com.alibaba.dubbo.config.RegistryConfig;
import com.alibaba.dubbo.config.ServiceConfig;
import com.alibaba.dubbo.container.Main;
import com.sihai.dubbo.provider.service.ProviderService;
import com.sihai.dubbo.provider.service.ProviderServiceImpl;
import org.springframework.context.support.ClassPathXmlApplicationContext;

import java.io.IOException;

/**
 * xml方式启动
 *
 */
public class App 
{
    public static void main( String[] args ) throws IOException {
        //加载xml配置文件启动
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("META-INF/spring/provider.xml");
        context.start();
        System.in.read(); // 按任意键退出
    }
}

发布接口非常简单,因为 dubbo 底层就是依赖 spring 的,所以,我们只需要通过 ClassPathXmlApplicationContext 拿到我们刚刚配置好的 xml ,然后调用 context.start() 方法就启动了。

看到下面的截图,就算是启动成功了,接口也就发布出去了。

你以为到这里就结束了了,并不是的,我们拿到 dubbo 暴露出去的 url分析分析。

dubbo 暴露的 url

dubbo://192.168.234.1:20880/com.sihai.dubbo.provider.service.ProviderService?anyhost=true&application=provider&bean.name=com.sihai.dubbo.provider.service.ProviderService&bind.ip=192.168.234.1&bind.port=20880&dubbo=2.0.2&generic=false&interface=com.sihai.dubbo.provider.service.ProviderService&methods=SayHello&owner=sihai&pid=8412&qos.accept.foreign.ip=false&qos.enable=true&qos.port=55555&side=provider&timestamp=1562077289380

分析

① 首先,在形式上我们发现,其实这么牛逼的 dubbo 也是用类似于 http 的协议发布自己的服务的,只是这里我们用的是 dubbo 协议
dubbo://192.168.234.1:20880/com.sihai.dubbo.provider.service.ProviderService
上面这段链接就是 ? 之前的链接,构成:协议://ip:端口/接口。发现是不是也没有什么神秘的。
anyhost=true&application=provider&bean.name=com.sihai.dubbo.provider.service.ProviderService&bind.ip=192.168.234.1&bind.port=20880&dubbo=2.0.2&generic=false&interface=com.sihai.dubbo.provider.service.ProviderService&methods=SayHello&owner=sihai&pid=8412&qos.accept.foreign.ip=false&qos.enable=true&qos.port=55555&side=provider&timestamp=1562077289380
? 之后的字符串,分析后你发现,这些都是刚刚在 provider.xml 中配置的字段,然后通过 & 拼接而成的,闻到了 http 的香味了吗?

终于,dubbo 服务端入门了。下面我们看看拿到了 url 后,怎么消费呢?

3.2 消费端

上面提到,我们在服务端提供的只是点对点的方式提供服务,并没有使用注册中心,所以,下面的配置也是会有一些不一样的。

消费端环境配置

首先,我们在消费端的 resource 下建立配置文件 consumer.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
       xsi:schemaLocation="http://www.springframework.org/schema/beans        http://www.springframework.org/schema/beans/spring-beans.xsd        http://code.alibabatech.com/schema/dubbo        http://code.alibabatech.com/schema/dubbo/dubbo.xsd">

    <!--当前项目在整个分布式架构里面的唯一名称,计算依赖关系的标签-->
    <dubbo:application name="consumer" owner="sihai"/>

    <!--dubbo这个服务所要暴露的服务地址所对应的注册中心-->
    <!--点对点的方式-->
    <dubbo:registry address="N/A" />
    <!--<dubbo:registry address="zookeeper://localhost:2181" check="false"/>-->

    <!--生成一个远程服务的调用代理-->
    <!--点对点方式-->
    <dubbo:reference id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"
                     url="dubbo://192.168.234.1:20880/com.sihai.dubbo.provider.service.ProviderService"/>

    <!--<dubbo:reference id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"/>-->

</beans>

分析

① 发现这里的 dubbo:applicationdubbo:registry 是一致的。
dubbo:reference :我们这里采用点对点的方式,所以,需要配置在服务端暴露的 url 。

maven 依赖

和服务端一样

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.ouyangsihai</groupId>
    <artifactId>dubbo-consumer</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>com.ouyangsihai</groupId>
            <artifactId>dubbo-provider</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.alibaba/dubbo -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>dubbo</artifactId>
            <version>2.6.6</version>
        </dependency>
        <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.4.10</version>
        </dependency>
        <dependency>
            <groupId>com.101tec</groupId>
            <artifactId>zkclient</artifactId>
            <version>0.5</version>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.32.Final</version>
        </dependency>
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-framework</artifactId>
            <version>2.8.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>2.8.0</version>
        </dependency>
    </dependencies>
</project>
调用服务
package com.sihai.dubbo.consumer;

import com.alibaba.dubbo.config.ApplicationConfig;
import com.alibaba.dubbo.config.ReferenceConfig;
import com.alibaba.dubbo.config.RegistryConfig;
import com.sihai.dubbo.provider.service.ProviderService;
import org.springframework.context.support.ClassPathXmlApplicationContext;

import java.io.IOException;

/**
 * xml的方式调用
 *
 */
public class App 
{
    public static void main( String[] args ) throws IOException {

        ClassPathXmlApplicationContext context=new ClassPathXmlApplicationContext("consumer.xml");
        context.start();
        ProviderService providerService = (ProviderService) context.getBean("providerService");
        String str = providerService.SayHello("hello");
        System.out.println(str);
        System.in.read();

    }
}

这里和服务端的发布如出一辙。


如此,我们就成功调用接口了。

四 加入 zookeeper 作为注册中心

在前面的案例中,我们没有使用任何的注册中心,而是用一种直连的方式进行的。但是,实际上很多时候,我们都是使用 dubbo + zookeeper 的方式,使用 zookeeper 作为注册中心,这里,我们就介绍一下 zookeeper 作为注册中心的使用方法。

这里,我们在前面的入门实例中进行改造。

4.1 服务端

在服务端中,我们只需要修改 provider.xml 即可。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
       xsi:schemaLocation="http://www.springframework.org/schema/beans        http://www.springframework.org/schema/beans/spring-beans.xsd        http://code.alibabatech.com/schema/dubbo        http://code.alibabatech.com/schema/dubbo/dubbo.xsd">

    <!--当前项目在整个分布式架构里面的唯一名称,计算依赖关系的标签-->
    <dubbo:application name="provider" owner="sihai">
        <dubbo:parameter key="qos.enable" value="true"/>
        <dubbo:parameter key="qos.accept.foreign.ip" value="false"/>
        <dubbo:parameter key="qos.port" value="55555"/>
    </dubbo:application>

    <dubbo:monitor protocol="registry"/>

    <!--dubbo这个服务所要暴露的服务地址所对应的注册中心-->
    <!--<dubbo:registry address="N/A"/>-->
    <dubbo:registry address="zookeeper://localhost:2181" check="false"/>

    <!--当前服务发布所依赖的协议;webserovice、Thrift、Hessain、http-->
    <dubbo:protocol name="dubbo" port="20880"/>

    <!--服务发布的配置,需要暴露的服务接口-->
    <dubbo:service
            interface="com.sihai.dubbo.provider.service.ProviderService"
            ref="providerService"/>

    <!--Bean bean定义-->
    <bean id="providerService" class="com.sihai.dubbo.provider.service.ProviderServiceImpl"/>

</beans>

重点关注这句话

<dubbo:registry address="zookeeper://localhost:2181" />

在 address 中,使用我们的 zookeeper 的地址。

如果是 zookeeper 集群的话,使用下面的方式。

<dubbo:registry protocol="zookeeper" address="192.168.11.129:2181,192.168.11.137:2181,192.168.11.138:2181"/>

服务端的配置就好了,其他的跟 入门案例 一样。

4.2 消费端

跟服务端一样,在消费端,我们也只需要修改 consumer.xml 即可。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
       xsi:schemaLocation="http://www.springframework.org/schema/beans        http://www.springframework.org/schema/beans/spring-beans.xsd        http://code.alibabatech.com/schema/dubbo        http://code.alibabatech.com/schema/dubbo/dubbo.xsd">

    <!--当前项目在整个分布式架构里面的唯一名称,计算依赖关系的标签-->
    <dubbo:application name="consumer" owner="sihai"/>

    <!--dubbo这个服务所要暴露的服务地址所对应的注册中心-->
    <!--点对点的方式-->
    <!--<dubbo:registry address="N/A" />-->
    <dubbo:registry address="zookeeper://localhost:2181" check="false"/>

    <!--生成一个远程服务的调用代理-->
    <!--点对点方式-->
    <!--<dubbo:reference id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"
                     url="dubbo://192.168.234.1:20880/com.sihai.dubbo.provider.service.ProviderService"/>-->

    <dubbo:reference id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"/>

</beans>

① 注册中心配置跟服务端一样。

<dubbo:registry address="zookeeper://localhost:2181"/>

② dubbo:reference
由于我们这里使用 zookeeper 作为注册中心,所以,跟点对点的方式是不一样的,这里不再需要 dubbo 服务端提供的 url 了,只需要直接引用服务端提供的接口即可。

<dubbo:reference id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"/>

好了,消费端也配置好了,这样就可以使用修改的入门案例,重新启动运行了。

同样成功了。

这时候的区别在于,将 dubbo 发布的 url 注册到了 zookeeper,消费端从 zookeeper 消费,zookeeper 相当于一个中介,给消费者提供服务。

你以为这就完了?不,好戏才刚刚开始呢。

五 多种配置方式

入门实例的时候,我们使用的是 xml 配置的方式,对 dubbo 的环境进行了配置,但是,官方还提供了其他的配置方式,这里我们也一一分解。

5.1 API配置方式

这种方式其实官方是不太推荐的,官方推荐使用 xml 配置的方式,但是,在有的时候测试的时候,还是可以用的到的,另外,为了保证完整性,这些内容还是有必要讲讲的。

首先还是回到服务端工程。

服务端

这里我们使用 api 的方式配置,所以,provider.xml 这个配置文件就暂时不需要了,我们只需要在上面的 AppApi 这个类中的 main 方法中用 api配置及启动即可。

package com.sihai.dubbo.provider;

import com.alibaba.dubbo.config.ApplicationConfig;
import com.alibaba.dubbo.config.ProtocolConfig;
import com.alibaba.dubbo.config.RegistryConfig;
import com.alibaba.dubbo.config.ServiceConfig;
import com.sihai.dubbo.provider.service.ProviderService;
import com.sihai.dubbo.provider.service.ProviderServiceImpl;

import java.io.IOException;

/**
 * Api方式启动
 * api的方式调用不需要其他的配置,只需要下面的代码即可。
 * 但是需要注意,官方建议:
 * Api方式用于测试用例使用,推荐xml的方式
 */
public class AppApi
{
    public static void main( String[] args ) throws IOException {

        // 服务实现
        ProviderService providerService = new ProviderServiceImpl();

        // 当前应用配置
        ApplicationConfig application = new ApplicationConfig();
        application.setName("provider");
        application.setOwner("sihai");

        // 连接注册中心配置
        RegistryConfig registry = new RegistryConfig();
        registry.setAddress("zookeeper://localhost:2181");
//        registry.setUsername("aaa");
//        registry.setPassword("bbb");

        // 服务提供者协议配置
        ProtocolConfig protocol = new ProtocolConfig();
        protocol.setName("dubbo");
        protocol.setPort(20880);
        //protocol.setThreads(200);

        // 注意:ServiceConfig为重对象,内部封装了与注册中心的连接,以及开启服务端口

        // 服务提供者暴露服务配置
        ServiceConfig<ProviderService> service = new ServiceConfig<ProviderService>(); // 此实例很重,封装了与注册中心的连接,请自行缓存,否则可能造成内存和连接泄漏
        service.setApplication(application);
        service.setRegistry(registry); // 多个注册中心可以用setRegistries()
        service.setProtocol(protocol); // 多个协议可以用setProtocols()
        service.setInterface(ProviderService.class);
        service.setRef(providerService);
        service.setVersion("1.0.0");

        // 暴露及注册服务
        service.export();
    }
}

分析

看到上面的代码是不是云里雾里,不要慌,我们通过对照 xml 的方式分析一下。

registry 的 xml 方式
<dubbo:registry protocol="zookeeper" address="localhost:2181"/>
API 的方式
RegistryConfig registry = new RegistryConfig();
registry.setAddress("zookeeper://localhost:2181");

dubbo:registry节点对应RegistryConfig ,xml 的属性对应 API 方式用 set 方法就可以了。对比之下,你就会发现,如果 API 的方式不熟悉,可以对照 xml 配置方式就可以。

其他 API

org.apache.dubbo.config.ServiceConfig
org.apache.dubbo.config.ReferenceConfig
org.apache.dubbo.config.ProtocolConfig
org.apache.dubbo.config.RegistryConfig
org.apache.dubbo.config.MonitorConfig
org.apache.dubbo.config.ApplicationConfig
org.apache.dubbo.config.ModuleConfig
org.apache.dubbo.config.ProviderConfig
org.apache.dubbo.config.ConsumerConfig
org.apache.dubbo.config.MethodConfig
org.apache.dubbo.config.ArgumentConfig

更详细的可以查看官方文档:
http://dubbo.apache.org/zh-cn...

我们再看看我配置的消费端的 Api 方式。

消费端

同样,我们不需要 consumer.xml 配置文件了,只需要在 main 方法中启动即可。

package com.sihai.dubbo.consumer;

import com.alibaba.dubbo.config.ApplicationConfig;
import com.alibaba.dubbo.config.ReferenceConfig;
import com.alibaba.dubbo.config.RegistryConfig;
import com.sihai.dubbo.provider.service.ProviderService;

/**
 * api的方式调用
 * api的方式调用不需要其他的配置,只需要下面的代码即可。
 * 但是需要注意,官方建议:
 * Api方式用于测试用例使用,推荐xml的方式
 */
public class AppApi {

    public static void main(String[] args) {
        // 当前应用配置
        ApplicationConfig application = new ApplicationConfig();
        application.setName("consumer");
        application.setOwner("sihai");

        // 连接注册中心配置
        RegistryConfig registry = new RegistryConfig();
        registry.setAddress("zookeeper://localhost:2181");

        // 注意:ReferenceConfig为重对象,内部封装了与注册中心的连接,以及与服务提供方的连接

        // 引用远程服务
        ReferenceConfig<ProviderService> reference = new ReferenceConfig<ProviderService>(); // 此实例很重,封装了与注册中心的连接以及与提供者的连接,请自行缓存,否则可能造成内存和连接泄漏
        reference.setApplication(application);
        reference.setRegistry(registry); // 多个注册中心可以用setRegistries()
        reference.setInterface(ProviderService.class);

        // 和本地bean一样使用xxxService
        ProviderService providerService = reference.get(); // 注意:此代理对象内部封装了所有通讯细节,对象较重,请缓存复用
        providerService.SayHello("hello dubbo! I am sihai!");
    }
}

这部分的 API 配置的方式就到这了,注意:官方推荐 xml 的配置方法

5.2 注解配置方式

注解配置方式还是需要了解一下的,现在微服务都倾向于这种方式,这也是以后发展的趋势,0 配置应该是这几年的趋势。

那么如何对 dubbo 使用注解的方式呢?我们先看服务端。

服务端

第一步:定义接口及实现类,在上面的截图中的 annotation 包下

package com.sihai.dubbo.provider.service.annotation;

/**
 * 注解方式接口
 */
public interface ProviderServiceAnnotation {
    String SayHelloAnnotation(String word);
}
package com.sihai.dubbo.provider.service.annotation;

import com.alibaba.dubbo.config.annotation.Service;

/**
 * 注解方式实现类
 */
@Service(timeout = 5000)
public class ProviderServiceImplAnnotation implements ProviderServiceAnnotation{

    public String SayHelloAnnotation(String word) {
        return word;
    }
}

@Service

@Service 用来配置 Dubbo 的服务提供方。

第二步:组装服务提供方。通过 Spring 中 Java Config 的技术(@Configuration)和 annotation 扫描(@EnableDubbo)来发现、组装、并向外提供 Dubbo 的服务。

package com.sihai.dubbo.provider.configuration;

import com.alibaba.dubbo.config.ApplicationConfig;
import com.alibaba.dubbo.config.ProtocolConfig;
import com.alibaba.dubbo.config.ProviderConfig;
import com.alibaba.dubbo.config.RegistryConfig;
import com.alibaba.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 注解方式配置
 */
@Configuration
@EnableDubbo(scanBasePackages = "com.sihai.dubbo.provider.service.annotation")
public class DubboConfiguration {

    @Bean // #1 服务提供者信息配置
    public ProviderConfig providerConfig() {
        ProviderConfig providerConfig = new ProviderConfig();
        providerConfig.setTimeout(1000);
        return providerConfig;
    }

    @Bean // #2 分布式应用信息配置
    public ApplicationConfig applicationConfig() {
        ApplicationConfig applicationConfig = new ApplicationConfig();
        applicationConfig.setName("dubbo-annotation-provider");
        return applicationConfig;
    }

    @Bean // #3 注册中心信息配置
    public RegistryConfig registryConfig() {
        RegistryConfig registryConfig = new RegistryConfig();
        registryConfig.setProtocol("zookeeper");
        registryConfig.setAddress("localhost");
        registryConfig.setPort(2181);
        return registryConfig;
    }

    @Bean // #4 使用协议配置,这里使用 dubbo
    public ProtocolConfig protocolConfig() {
        ProtocolConfig protocolConfig = new ProtocolConfig();
        protocolConfig.setName("dubbo");
        protocolConfig.setPort(20880);
        return protocolConfig;
    }
}

分析

  • 通过 @EnableDubbo 指定在com.sihai.dubbo.provider.service.annotation 下扫描所有标注有 @Service 的类
  • 通过 @ConfigurationDubboConfiguration 中所有的 @Bean 通过 Java Config 的方式组装出来并注入给 Dubbo 服务,也就是标注有 @Service 的类。这其中就包括了:

    • ProviderConfig:服务提供方配置
    • ApplicationConfig:应用配置
    • RegistryConfig:注册中心配置
    • ProtocolConfig:协议配置

看起来很复杂,其实。。。

第三步:启动服务

package com.sihai.dubbo.provider;

import com.alibaba.dubbo.config.spring.context.annotation.DubboComponentScan;
import com.sihai.dubbo.provider.configuration.DubboConfiguration;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import sun.applet.Main;

import java.io.IOException;

/**
 * 注解启动方式
 */
public class AppAnnotation {

    public static void main(String[] args) throws IOException {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DubboConfiguration.class); 
        context.start();
        System.in.read(); 
    }
}

发现输出下面信息就表示 success 了。

消费端

同样我们下看看消费端的工程,有一个感性认识。
图片.png

第一步:引用服务

package com.sihai.dubbo.consumer.Annotation;

import com.alibaba.dubbo.config.annotation.Reference;
import com.sihai.dubbo.provider.service.annotation.ProviderServiceAnnotation;
import org.springframework.stereotype.Component;

/**
 * 注解方式的service
 */
@Component("annotatedConsumer")
public class ConsumerAnnotationService {

    @Reference
    private ProviderServiceAnnotation providerServiceAnnotation;

    public String doSayHello(String name) {
        return providerServiceAnnotation.SayHelloAnnotation(name);
    }
}

ConsumerAnnotationService 类中,通过 @Reference 引用服务端提供的类,然后通过方法调用这个类的方式,给消费端提供接口。
注意:如果这里找不到 ProviderServiceAnnotation 类,请在服务端先把服务端工程用 Maven intall 一下,然后将服务端的依赖放到消费端的 pom 中。如下:

<dependency>
          <groupId>com.ouyangsihai</groupId>
            <artifactId>dubbo-provider</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

第二步:组装服务消费者
这一步和服务端是类似的,这里就不在重复了。

package com.sihai.dubbo.consumer.configuration;

import com.alibaba.dubbo.config.ApplicationConfig;
import com.alibaba.dubbo.config.ConsumerConfig;
import com.alibaba.dubbo.config.RegistryConfig;
import com.alibaba.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

/**
 * 注解配置类
 */
@Configuration
@EnableDubbo(scanBasePackages = "com.sihai.dubbo.consumer.Annotation")
@ComponentScan(value = {"com.sihai.dubbo.consumer.Annotation"})
public class ConsumerConfiguration {
    @Bean // 应用配置
    public ApplicationConfig applicationConfig() {
        ApplicationConfig applicationConfig = new ApplicationConfig();
        applicationConfig.setName("dubbo-annotation-consumer");
        Map<String, String> stringStringMap = new HashMap<String, String>();
        stringStringMap.put("qos.enable","true");
        stringStringMap.put("qos.accept.foreign.ip","false");
        stringStringMap.put("qos.port","33333");
        applicationConfig.setParameters(stringStringMap);
        return applicationConfig;
    }

    @Bean // 服务消费者配置
    public ConsumerConfig consumerConfig() {
        ConsumerConfig consumerConfig = new ConsumerConfig();
        consumerConfig.setTimeout(3000);
        return consumerConfig;
    }

    @Bean // 配置注册中心
    public RegistryConfig registryConfig() {
        RegistryConfig registryConfig = new RegistryConfig();
        registryConfig.setProtocol("zookeeper");
        registryConfig.setAddress("localhost");
        registryConfig.setPort(2181);
        return registryConfig;
    }
}

第三步:发起远程调用

main 方法中通过启动一个 Spring Context,从其中查找到组装好的 Dubbo 的服务消费者,并发起一次远程调用。

package com.sihai.dubbo.consumer;

import com.sihai.dubbo.consumer.Annotation.ConsumerAnnotationService;
import com.sihai.dubbo.consumer.configuration.ConsumerConfiguration;
import com.sihai.dubbo.provider.service.ProviderService;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

import java.io.IOException;

/**
 * 注解方式启动
 *
 */
public class AppAnnotation
{
    public static void main( String[] args ) throws IOException {

        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ConsumerConfiguration.class); 
        context.start(); // 启动
        ConsumerAnnotationService consumerAnnotationService = context.getBean(ConsumerAnnotationService.class); 
        String hello = consumerAnnotationService.doSayHello("annotation"); // 调用方法
        System.out.println("result: " + hello); // 输出结果

    }
}

结果

六 常用场景

在下面的讲解中,都会是以 xml 配置的方式来讲解的,这也是 dubbo 官方比较推荐的方式。以下的操作都是在服务端的 xml 配置文件和消费端的配置文件来讲解的。

6.1 启动时检查

Dubbo 缺省会在启动时检查依赖的服务是否可用,不可用时会抛出异常,阻止 Spring 初始化完成,以便上线时,能及早发现问题,默认 `check="true"。

但是,有的时候,我们并不是都需要启动时就检查的,比如测试的时候,我们是需要更快速的启动,所以,这种场景的时候,我们是需要关闭这个功能的。

下面,我们看看如何使用这个功能。

在服务端注册的时候(客户端注册时同样适用);

<dubbo:registry protocol="zookeeper" address="localhost:2181,localhost:2182,localhost:2183" check="false"/>

在客户端引用服务端服务的时候;

<dubbo:reference check="false" id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"/>

就是这么简单,就是这么强!

6.2 集群容错

dubbo 也是支持集群容错的,同时也有很多可选的方案,其中,默认的方案是 failover,也就是重试机制。

首先,我们先把所有的容错机制都整理一遍,然后再看看使用。

集群模式说明使用方法
Failover Cluster失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。可通过 retries="2" 来设置重试次数(不含第一次)。cluster="xxx" xxx:集群模式名称 ,例如cluster="failover"
Failfast Cluster快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
Failsafe Cluster失败安全,出现异常时,直接忽略。
Failback Cluster失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
Forking Cluster并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="2" 来设置最大并行数。
Broadcast Cluster广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。

使用实例
在发布服务或者引用服务的时候设置

<!--服务发布的配置,需要暴露的服务接口-->
    <dubbo:service cluster="failover" retries="2"
            interface="com.sihai.dubbo.provider.service.ProviderService"
            ref="providerService"/>
<dubbo:reference cluster="failover" retries="2" check="false" id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"/>

6.3 负载均衡

负载均衡想必是一个再熟悉不过的概念了,所以,dubbo 支持也是再正常不过了,这里也总结一下 dubbo 支持的负载均衡的一些方案及使用方法。

负载均衡模式说明使用方法
Random LoadBalance随机 按权重设置随机概率<dubbo:service loadbalance="xxx"/> xxx:负载均衡方法
RoundRobin LoadBalance轮询 按公约后的权重设置轮询比率。
LeastActive LoadBalance最少活跃调用数 相同活跃数的随机,活跃数指调用前后计数差。
ConsistentHash LoadBalance一致性 Hash 相同参数的请求总是发到同一提供者。 当某一台提供者挂时,原本发往该提供者的请求,基于虚拟节点,平摊到其它提供者,不会引起剧烈变动。

6.4 直连提供者

在开发及测试环境下,经常需要绕过注册中心,只测试指定服务提供者,所以,这种情况下,我们只需要直接连接服务端的地即可,其实,这种方法在前面的讲解已经使用到了,第一种讲解的方式就是这种方式,因为这种方式简单。

使用

<dubbo:reference id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"
                     url="dubbo://192.168.234.1:20880/com.sihai.dubbo.provider.service.ProviderService"/>

说明:可以看到,只要在消费端在 dubbo:reference 节点使用 url 给出服务端的方法即可。

6.5 只订阅

只订阅就是只能够订阅服务端的服务,而不能够注册。

引用官方的使用场景如下:

为方便开发测试,经常会在线下共用一个所有服务可用的注册中心,这时,如果一个正在开发中的服务提供者注册,可能会影响消费者不能正常运行。
可以让服务提供者开发方,只订阅服务(开发的服务可能依赖其它服务),而不注册正在开发的服务,通过直连测试正在开发的服务。
<dubbo:registry register="false" protocol="zookeeper" address="localhost:2181,localhost:2182,localhost:2183" check="false"/>

① 使用只订阅方式

当在服务提供端使用 register="false" 的时候,我们使用下面的方式获取服务端的服务;

<dubbo:reference cluster="failover" retries="2" check="false" id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService"/>

启动信息
图片.png
发现,这时候并不是向注册中心 zookeeper 注册,而只是做了发布服务和启动netty

② 不使用只订阅方式

<dubbo:registry protocol="zookeeper" address="localhost:2181,localhost:2182,localhost:2183" check="false"/>

启动信息

可以发现,这里就向注册中心 zookeeper 注册了。

6.6 只注册

只注册正好跟前面的只订阅相反,这个时候可以向注册中心注册,但是,消费端却不能够读到服务。

应用场景

如果有两个镜像环境,两个注册中心,有一个服务只在其中一个注册中心有部署,另一个注册中心还没来得及部署,而两个注册中心的其它应用都需要依赖此服务。这个时候,可以让服务提供者方只注册服务到另一注册中心,而不从另一注册中心订阅服务。

使用说明

<dubbo:registry subscribe="false" address="localhost:2181"></dubbo:registry>

在服务端的 dubbo:registry 节点下使用 subscribe="false" 来声明这个服务是只注册的服务。

这个时候消费端调用的时候是不能调用的。

6.7 多协议机制

在前面我们使用的协议都是 dubbo 协议,但是 dubbo 除了支持这种协议外还支持其他的协议,比如,rmi、hessian等,另外,而且还可以用多种协议同时暴露一种服务。

使用方法

① 一种接口使用一种协议

先声明多种协议

 <!--当前服务发布所依赖的协议;webserovice、Thrift、Hessain、http-->
    <dubbo:protocol name="dubbo" port="20880"/>
    <dubbo:protocol name="rmi" port="1099" />

然后在发布接口的时候使用具体协议

<!--服务发布的配置,需要暴露的服务接口-->
    <dubbo:service cluster="failover" retries="2"
            interface="com.sihai.dubbo.provider.service.ProviderService"
            ref="providerService"/>
    <dubbo:service cluster="failover" retries="2"
                   interface="com.sihai.dubbo.provider.service.ProviderService"
                   ref="providerService" protocol="rmi"/>

在输出日志中,就可以找到rmi发布的接口。

rmi://192.168.234.1:1099/com.sihai.dubbo.provider.service.ProviderService?anyhost=true&application=provider&bean.name=com.sihai.dubbo.provider.service.ProviderService&cluster=failover&dubbo=2.0.2&generic=false&interface=com.sihai.dubbo.provider.service.ProviderService&methods=SayHello&owner=sihai&pid=796&retries=2&side=provider&timestamp=1564281053185, dubbo version: 2.6.6, current host: 192.168.234.1

② 一种接口使用多种协议
声明协议和上面的方式一样,在发布接口的时候有一点不一样。

<dubbo:service cluster="failover" retries="2"
                   interface="com.sihai.dubbo.provider.service.ProviderService"
                   ref="providerService" protocol="rmi,dubbo"/>

说明:protocol属性,可以用,隔开,使用多种协议。

6.8 多注册中心

Dubbo 支持同一服务向多注册中心同时注册,或者不同服务分别注册到不同的注册中心上去,甚至可以同时引用注册在不同注册中心上的同名服务。

服务端多注册中心发布服务

一个服务可以在不同的注册中心注册,当一个注册中心出现问题时,可以用其他的注册中心。

注册

<!--多注册中心-->
    <dubbo:registry protocol="zookeeper" id="reg1" timeout="10000" address="localhost:2181"/>
    <dubbo:registry protocol="zookeeper" id="reg2" timeout="10000" address="localhost:2182"/>
    <dubbo:registry protocol="zookeeper" id="reg3" timeout="10000" address="localhost:2183"/>

发布服务

<!--服务发布的配置,需要暴露的服务接口-->
    <dubbo:service cluster="failover" retries="2"
            interface="com.sihai.dubbo.provider.service.ProviderService"
            ref="providerService" registry="reg1"/>
    <dubbo:service cluster="failover" retries="2"
                   interface="com.sihai.dubbo.provider.service.ProviderService"
                   ref="providerService" protocol="rmi" registry="reg2"/>

说明:使用registry="reg2"指定该接口使用的注册中心,同时也可以使用多个,用隔开,例如,registry="reg1,,reg2"

消费端多注册中心引用服务

首先,先向不同注册中心注册;

<!--多注册中心-->
    <dubbo:registry protocol="zookeeper" id="reg1" timeout="10000" address="localhost:2181"/>
    <dubbo:registry protocol="zookeeper" id="reg2" timeout="10000" address="localhost:2182"/>
    <dubbo:registry protocol="zookeeper" id="reg3" timeout="10000" address="localhost:2183"/>

其次,不同的消费端服务引用使用不同的注册中心;

!--不同的服务使用不同的注册中心-->
    <dubbo:reference cluster="failover" retries="2" check="false" id="providerService"
                     interface="com.sihai.dubbo.provider.service.ProviderService" registry="reg1"/>
    <dubbo:reference cluster="failover" retries="2" check="false" id="providerService2"
                     interface="com.sihai.dubbo.provider.service.ProviderService" registry="reg2"/>

说明:上面分别使用注册中心1和注册中心2。

6.9 多版本

不同的服务是有版本不同的,版本可以更新并且升级,同时,不同的版本之间是不可以调用的。

<!--服务发布的配置,需要暴露的服务接口-->
    <dubbo:service cluster="failover" retries="2"
            interface="com.sihai.dubbo.provider.service.ProviderService"
            ref="providerService" registry="reg1" version="1.0.0"/>
    <dubbo:service cluster="failover" retries="2"
                   interface="com.sihai.dubbo.provider.service.ProviderService"
                   ref="providerService" protocol="rmi" registry="reg2" version="1.0.0"/>

加入了版本控制。

6.10 日志管理

dubbo 也可以将日志信息记录或者保存到文件中的。

① 使用accesslog输出到log4j

<dubbo:protocol accesslog="true" name="dubbo" port="20880"/>
    <dubbo:protocol accesslog="true" name="rmi" port="1099" />

② 输出到文件

<dubbo:protocol accesslog="http://localhost/log.txt" name="dubbo" port="20880"/>
    <dubbo:protocol accesslog="http://localhost/log2.txt" name="rmi" port="1099" />

七 总结

这篇文章就到这里了,主要讲了一下几个内容
1、为什么需要dubbo
2、dubbo架构简析
3、dubbo入门
4、zookeeper注册中心加入dubbo
5、dubbo多种配置方式(xml、api、注解)
6、常用场景介绍
下一篇文章,将讲讲源码分析。

文章有不当之处,欢迎指正,如果喜欢微信阅读,你也可以关注我的微信公众号好好学java,获取优质学习资源。
查看原文

爱莎之刃 关注了专栏 · 2020-03-21

前端全栈开发者

专栏首发于公众号《前端全栈开发者》,订阅关注第一时间阅读好文

关注 2975

爱莎之刃 关注了专栏 · 2020-03-21

终身学习者

我要先坚持分享20年,大家来一起见证吧。

关注 52523

爱莎之刃 关注了专栏 · 2020-03-21

前端笔记本

此后如没有炬火,我便是唯一的光?

关注 5940

爱莎之刃 关注了用户 · 2020-03-21

shuangyueliao @shuangyueliao

关注 2975

爱莎之刃 关注了专栏 · 2020-03-21

宜信技术学院

宜信技术学院是宜信旗下的金融科技平台。专注分享金融科技深度文章。

关注 11389

爱莎之刃 关注了专栏 · 2020-03-21

CodeSheep的技术分享

分享虚拟化、容器化、API化、微服务化的WEB技术,更多务实、能看懂、可复现的原创文章尽在作者公众号 CodeSheep,欢迎订阅

关注 5182

认证与成就

  • 获得 1 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-03-21
个人主页被 317 人浏览