From 84ae12296cc88adcccf1c0bd37b71715946a8394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B9=BE?= Date: Wed, 16 Oct 2024 00:01:16 +0800 Subject: [PATCH] first commit --- ...底是工具先行还是文化先行?.md | 129 ++ ...否找到了DevOps的实施路线图?.md | 125 ++ ...转型,我们应该从何处入手?.md | 150 +++ ...业实施DevOps的常见路径和问题.md | 111 ++ ...:帮助DevOps快速落地的源动力.md | 148 +++ ...):精益驱动的敏捷开发方法.md | 120 ++ ...):精益驱动的敏捷开发方法.md | 138 +++ ...易被忽视的DevOps工程实践基础.md | 159 +++ ...:让研发高效协作的关键要素.md | 191 +++ ...的CI和我说的CI是一回事吗?.md | 143 +++ ...化测试:DevOps的阿克琉斯之踵.md | 163 +++ ...:丰田和亚马逊给我们的启示.md | 146 +++ ...务:那些不可忽视的潜在问题.md | 161 +++ ...皆代码是一种什么样的体验?.md | 169 +++ ...管理:低风险的部署发布策略.md | 157 +++ ...混沌工程:软件领域的反脆弱.md | 189 +++ ...何建立完整的DevOps度量体系?.md | 168 +++ ...:PDCA体系和持续改进的意义.md | 151 +++ ...企业DevOps平台建设的三个阶段.md | 225 ++++ ...道:DevOps产品设计的五个层次.md | 133 +++ ...流水线必备的十大特征(上).md | 225 ++++ ...流水线必备的十大特征(下).md | 205 ++++ ...何建设企业级数据度量平台?.md | 196 +++ ...成千人规模的产品要怎么做?.md | 237 ++++ ...:那些你不能忽视的开源工具.md | 286 +++++ ...:云原生应用时代的平台思考.md | 203 ++++ ...业的DevOps实战转型案例(上).md | 196 +++ ...业的DevOps实战转型案例(下).md | 198 ++++ ...型问题答疑及如何高效学习(1).md | 210 ++++ ...典型问题答疑及如何高效学习.md | 210 ++++ ...选择一款合适的流水线工具?.md | 165 +++ ...DevOps工程师的必备技能(上).md | 135 +++ ...习DevOps不得不了解的经典资料.md | 195 +++ ...DevOps工程师的必备技能(下).md | 128 ++ ...DevOps组织和文化的那些趣事儿.md | 160 +++ ...产品经理是如何设计产品的?.md | 107 ++ ...结束语持续改进,成就非凡!.md | 65 + ...与实现,提升你的职场竞争力.md | 110 ++ ...境搭建:千里之行,始于足下.md | 396 +++++++ ...:抓住URL,就理解了半个Dubbo.md | 222 ++++ ...析,接口实现两极反转(上).md | 353 ++++++ ...析,接口实现两极反转(下).md | 659 +++++++++++ ...量定时任务,一个时间轮搞定.md | 141 +++ ...ator,求你别用ZkClient了(上).md | 133 +++ ...ator,求你别用ZkClient了(下).md | 856 ++++++++++++++ .../08代理模式与常见实现.md | 630 ++++++++++ ...用它做网络编程都说好(上).md | 122 ++ ...用它做网络编程都说好(下).md | 230 ++++ .../11简易版RPC框架实现(上).md | 359 ++++++ .../12简易版RPC框架实现(下).md | 773 ++++++++++++ ...低ZooKeeper压力的一个常用手段.md | 221 ++++ ...试机制是网络操作的基本保证.md | 356 ++++++ ...实现,官方推荐注册中心实践.md | 447 +++++++ ...序列化算法,总有一款适合你.md | 193 +++ ...一套兼容所有NIO框架的设计?.md | 234 ++++ ...数据,我们只是数据的搬运工.md | 282 +++++ ...码与线程模型一文打尽(上).md | 567 +++++++++ ...码与线程模型一文打尽(下).md | 571 +++++++++ ...搞懂Request-Response模型(上).md | 417 +++++++ ...搞懂Request-Response模型(下).md | 432 +++++++ ...核心接口介绍,RPC层骨架梳理.md | 412 +++++++ ...露和服务引用的全流程(上).md | 516 ++++++++ ...露和服务引用的全流程(下).md | 370 ++++++ ...,带你一起探秘Invoker(上).md | 381 ++++++ ...,带你一起探秘Invoker(下).md | 505 ++++++++ ...理帮你隐藏了多少底层细节?.md | 873 ++++++++++++++ ...-RPC,Dubbo跨语言就是如此简单.md | 0 ...扩展Dubbo框架的常用手段指北.md | 1012 ++++++++++++++++ ...ectory实现,探秘服务目录玄机.md | 598 ++++++++++ ...到底怎么走,它说了算(上).md | 495 ++++++++ ...到底怎么走,它说了算(下).md | 0 ...初探Dubbo动态配置的那些事儿.md | 371 ++++++ ...载均衡策略,这里都有(上).md | 452 +++++++ ...载均衡策略,这里都有(下).md | 412 +++++++ ...容错:一个好汉三个帮(上).md | 581 +++++++++ ...容错:一个好汉三个帮(下).md | 952 +++++++++++++++ ...值不用怕,Merger合并器来帮忙.md | 401 +++++++ ...远程调用,Mock机制帮你搞定.md | 447 +++++++ ...餐:一键通关服务发布全流程.md | 746 ++++++++++++ .../42加餐:服务引用流程全解析.md | 594 ++++++++++ ...自省设计方案:新版本新方案.md | 119 ++ ...何避免注册中心数据量膨胀?.md | 1053 +++++++++++++++++ ...方案中的服务发布订阅(上).md | 337 ++++++ ...方案中的服务发布订阅(下).md | 621 ++++++++++ ...and本地化配置,我都要(上).md | 392 ++++++ ...and本地化配置,我都要(下).md | 304 +++++ .../49结束语认真学习,缩小差距.md | 25 + .../01认知:ElasticSearch基础概念.md | 140 +++ ...知:ElasticStack生态和场景方案.md | 173 +++ ...03安装:ElasticSearch和Kibana安装.md | 293 +++++ ...入门:查询和聚合的基础使用.md | 359 ++++++ .../05索引:索引管理详解.md | 245 ++++ ...引:索引模板(IndexTemplate)详解.md | 289 +++++ ...查询:DSL查询之复合查询详解.md | 501 ++++++++ ...查询:DSL查询之全文搜索详解.md | 491 ++++++++ .../09查询:DSL查询之Term详解.md | 242 ++++ ...合:聚合查询之Bucket聚合详解.md | 602 ++++++++++ ...合:聚合查询之Metric聚合详解.md | 928 +++++++++++++++ ...:聚合查询之Pipline聚合详解.md | 266 +++++ ...图解构筑对ES原理的初步认知.md | 405 +++++++ ...ES原理知识点补充和整体结构.md | 197 +++ ...:ES原理之索引文档流程详解.md | 433 +++++++ ...:ES原理之读取文档流程详解.md | 301 +++++ ...化:ElasticSearch性能优化详解.md | 446 +++++++ ...讯万亿级Elasticsearch技术实践.md | 280 +++++ .../19资料:AwesomeElasticsearch.md | 353 ++++++ .../20WrapperQuery.md | 96 ++ .../21备份和迁移.md | 130 ++ .../01前言-教程内容导读.md | 161 +++ .../02Flutter开发环境的搭建.md | 176 +++ .../03新手村基础Dart语法(上).md | 477 ++++++++ .../04新手村基础Dart语法(下).md | 458 +++++++ .../05Flutter计数器项目解读.md | 319 +++++ .../06猜数字界面交互与需求分析.md | 169 +++ .../07使用组件构建静态界面.md | 346 ++++++ .../08状态数据与界面更新.md | 179 +++ .../09校验结果与提示信息.md | 156 +++ .../10动画使用与状态周期.md | 299 +++++ .../11猜数字整理与总结.md | 317 +++++ ...电子木鱼界面交互与需求分析.md | 154 +++ .../13电子木鱼静态界面构建.md | 267 +++++ .../14计数变化与音效播放.md | 322 +++++ .../15弹出选项与切换状态.md | 419 +++++++ .../16用滑动列表展示记录.md | 224 ++++ .../17电子木鱼整理与总结.md | 308 +++++ ...白板绘制界面交互与需求分析.md | 132 +++ .../19认识自定义绘制组件.md | 233 ++++ .../20通过手势在白板上绘制.md | 295 +++++ .../21白板画笔的参数设置.md | 308 +++++ .../22撤销功能与画板优化.md | 210 ++++ .../23应用界面整合.md | 303 +++++ .../24数据的持久化存储.md | 477 ++++++++ .../25网络数据的访问.md | 484 ++++++++ .../26教程总结与展望.md | 193 +++ ...端从业者都应该学习Flutter?.md | 75 ++ ...·从0开始搭建Flutter工程环境.md | 224 ++++ .../02预习篇·Dart语言概览.md | 139 +++ ...解跨平台方案的历史发展逻辑.md | 151 +++ ...其他方案的关键技术是什么?.md | 195 +++ ...码是如何运行在原生系统上的.md | 161 +++ ...量:Dart是如何表示信息的?.md | 192 +++ ...符:Dart是如何处理信息的?.md | 294 +++++ ...08综合案例:掌握Dart核心特性.md | 374 ++++++ ...09Widget,构建Flutter界面的基石.md | 175 +++ .../10Widget中的State到底是什么?.md | 213 ++++ ...生命周期,我们是在说什么?.md | 229 ++++ ...片和按钮在Flutter中怎么用?.md | 218 ++++ ...eView_ListView在Flutter中是什么?.md | 313 +++++ ...控件在父容器中排版的位置?.md | 265 +++++ ...该选用何种方式自定义Widget?.md | 276 +++++ ...如何定制不同风格的App主题?.md | 200 ++++ ...置和字体在Flutter中怎么用?.md | 208 ++++ ...件库在Flutter中要如何管理?.md | 160 +++ .../19用户交互事件该如何响应?.md | 218 ++++ ...递数据,你只需要记住这三招.md | 308 +++++ ...Flutter是这样实现页面切换的.md | 225 ++++ .../22如何构造炫酷的动画效果?.md | 299 +++++ ...程模型怎么保证UI运行流畅?.md | 354 ++++++ .../24HTTP网络编程与JSON解析.md | 397 +++++++ ...地存储与数据库的使用和优化.md | 245 ++++ ...droid_iOS平台特定实现?(一).md | 170 +++ ...droid_iOS平台特定实现?(二).md | 419 +++++++ ...原生应用中混编Flutter工程?.md | 273 +++++ ...,该用何种方案管理导航栈?.md | 272 +++++ ...么需要做状态管理,怎么做?.md | 290 +++++ .../31如何实现原生推送能力?.md | 540 +++++++++ ...多语言我们还需要注意什么_.md | 237 ++++ ...适配不同分辨率的手机屏幕?.md | 234 ++++ ...34如何理解Flutter的编译模式?.md | 243 ++++ .../35HotReload是怎么做到的?.md | 254 ++++ ...过工具链优化开发调试效率?.md | 213 ++++ ...化FlutterApp的整体性能表现?.md | 232 ++++ ...过自动化测试提高交付质量?.md | 318 +++++ ...何做好异常捕获与信息采集?.md | 548 +++++++++ ...量,我们需要关注这三个指标.md | 251 ++++ ...合理稳定的Flutter工程结构?.md | 124 ++ ...效的FlutterApp打包发布环境?.md | 306 +++++ ...Flutter混合开发框架(一)?.md | 91 ++ ...Flutter混合开发框架(二)?.md | 415 +++++++ ...,与你说说专栏的那些思考题.md | 459 +++++++ .../结束语勿畏难,勿轻略.md | 40 + ...写给0基础入门的Go语言学习者.md | 71 ++ .../00导读学习专栏的正确姿势.md | 68 ++ ...着学,你也能成为Go语言高手.md | 61 + .../01工作区和GOPATH.md | 220 ++++ .../02命令源码文件.md | 254 ++++ .../Go语言核心36讲/03库源码文件.md | 0 .../04程序实体的那些事儿(上).md | 206 ++++ .../05程序实体的那些事儿(中).md | 158 +++ .../06程序实体的那些事儿(下).md | 221 ++++ .../Go语言核心36讲/07数组和切片.md | 176 +++ .../08container包中的那些容器.md | 158 +++ .../09字典的操作和约束.md | 162 +++ .../10通道的基本操作.md | 179 +++ .../11通道的高级玩法.md | 233 ++++ .../12使用函数的正确姿势.md | 257 ++++ .../13结构体及其方法的使用法门.md | 252 ++++ .../14接口类型的合理运用.md | 221 ++++ .../15关于指针的有限操作.md | 227 ++++ .../16go语句及其执行规则(上).md | 156 +++ .../17go语句及其执行规则(下).md | 137 +++ .../18if语句、for语句和switch语句.md | 249 ++++ .../19错误处理(上).md | 168 +++ .../20错误处理(下).md | 95 ++ ...recover函数以及defer语句(上).md | 111 ++ ...recover函数以及defer语句(下).md | 176 +++ ...测试的基本规则和流程(上).md | 111 ++ ...测试的基本规则和流程(下).md | 161 +++ .../25更多的测试手法.md | 209 ++++ .../26sync.Mutex与sync.RWMutex.md | 217 ++++ .../27条件变量sync.Cond(上).md | 143 +++ .../28条件变量sync.Cond(下).md | 99 ++ .../29原子操作(上).md | 96 ++ .../30原子操作(下).md | 132 +++ .../31sync.WaitGroup和sync.Once.md | 176 +++ .../32context.Context类型.md | 201 ++++ .../33临时对象池sync.Pool.md | 179 +++ .../34并发安全字典sync.Map(上).md | 131 ++ .../35并发安全字典sync.Map(下).md | 174 +++ .../36unicode与字符编码.md | 259 ++++ .../37strings包与字符串操作.md | 214 ++++ .../38bytes包与字节串操作(上).md | 143 +++ .../39bytes包与字节串操作(下).md | 135 +++ .../40io包中的接口和工具(上).md | 215 ++++ .../41io包中的接口和工具(下).md | 109 ++ .../42bufio包中的数据类型(上).md | 132 +++ .../43bufio包中的数据类型(下).md | 130 ++ .../44使用os包中的API(上).md | 132 +++ .../45使用os包中的API(下).md | 112 ++ .../46访问网络服务.md | 178 +++ .../47基于HTTP协议的网络服务.md | 182 +++ .../48程序性能分析基础(上).md | 119 ++ .../49程序性能分析基础(下).md | 166 +++ ...尾声愿你披荆斩棘,所向无敌.md | 73 ++ .../新年彩蛋完整版思考题答案.md | 350 ++++++ ...从0开始搭建一个企业级Go应用.md | 90 ++ ...我们要实现什么样的Go项目?.md | 165 +++ ...配置一个基本的Go开发环境?.md | 313 +++++ ...部署:如何快速部署IAM系统?.md | 937 +++++++++++++++ ...目开发杂乱无章,如何规范?.md | 346 ++++++ ...迥异、难以阅读,如何规范?.md | 545 +++++++++ ...可维护、可扩展的代码目录?.md | 523 ++++++++ ...何设计合理的多人开发模式?.md | 397 +++++++ ...如何设计Go项目的开发流程?.md | 202 ++++ ...:如何管理应用的生命周期?.md | 238 ++++ ...法:怎么写出优雅的Go项目?.md | 611 ++++++++++ ...计模式:Go常用设计模式概述.md | 692 +++++++++++ ...(上):如何设计RESTfulAPI?.md | 233 ++++ .../13API风格(下):RPCAPI介绍.md | 479 ++++++++ ...:如何编写高质量的Makefile?.md | 476 ++++++++ ...是如何进行研发流程管理的?.md | 461 ++++++++ ...查:如何进行静态代码检查?.md | 439 +++++++ ...档:如何生成SwaggerAPI文档?.md | 469 ++++++++ ...如何设计一套科学的错误码?.md | 248 ++++ ...理(下):如何设计错误包?.md | 618 ++++++++++ ...如何设计日志包并记录日志?.md | 427 +++++++ ...把手教你从0编写一个日志包.md | 604 ++++++++++ ...Pflag、Viper、Cobra核心功能介绍.md | 888 ++++++++++++++ ...建一个优秀的企业应用框架?.md | 504 ++++++++ ...核心功能有哪些,如何实现?.md | 756 ++++++++++++ ...应用程序如何进行访问认证?.md | 298 +++++ ...设计和实现访问认证功能的?.md | 703 +++++++++++ ...模型是如何进行资源授权的?.md | 345 ++++++ ...apiserver设计,看Web服务的构建.md | 677 +++++++++++ ...apiserver服务核心功能实现讲解.md | 862 ++++++++++++++ ...0ORM:CURD神器GORM包介绍及实战.md | 824 +++++++++++++ ...ver设计,看数据流服务的设计.md | 686 +++++++++++ ...效处理应用程序产生的数据?.md | 537 +++++++++ ...如何设计出一个优秀的GoSDK?.md | 404 +++++++ ...下):IAM项目GoSDK设计和实现.md | 644 ++++++++++ ...实现一个命令行客户端工具?.md | 448 +++++++ ...言单元测试和性能测试用例?.md | 505 ++++++++ ...言其他测试类型及IAM测试介绍.md | 744 ++++++++++++ ...如何分析Go语言代码的性能?.md | 514 ++++++++ ...:APIServer性能测试和调优实战.md | 468 ++++++++ ...及负载均衡、高可用组件介绍.md | 294 +++++ ...):IAM系统生产环境部署实战.md | 665 +++++++++++ ...统安全加固、水平扩缩容实战.md | 534 +++++++++ ...(上):虚拟化技术演进之路.md | 385 ++++++ ...和应用生命周期技术演进之路.md | 314 +++++ ...于Kubernetes的云原生架构设计.md | 323 +++++ .../46如何制作Docker镜像?.md | 305 +++++ ...编写Kubernetes资源定义文件?.md | 618 ++++++++++ .../48IAM容器化部署实战.md | 616 ++++++++++ ...上):Helm服务编排基础知识.md | 407 +++++++ ...基于Helm的服务编排部署实战.md | 345 ++++++ .../51基于GitHubActions的CI实战.md | 566 +++++++++ ...别放送GoModules依赖包管理全讲.md | 413 +++++++ .../特别放送GoModules实战.md | 403 +++++++ .../特别放送IAM排障指南.md | 510 ++++++++ ...送分布式作业系统设计和实现.md | 525 ++++++++ ...目中最常用的Makefile核心语法.md | 507 ++++++++ ...晰、可直接套用的Go编码规范.md | 1039 ++++++++++++++++ ...何从小白进阶成Go语言专家?.md | 17 + ...自己的Go研发之路走得更远?.md | 238 ++++ .../01阅读此专栏的正确姿势.md | 130 ++ ...境准备:千里之行,始于足下.md | 356 ++++++ ...指标:没有量化,就没有改进.md | 106 ++ ...知识:不积跬步,无以至千里.md | 163 +++ ...技术:不积细流,无以成江河.md | 917 ++++++++++++++ ...载器:山不辞土,故能成其高.md | 455 +++++++ ...模型:海不辞水,故能成其深.md | 239 ++++ ...解:博观而约取、厚积而薄发.md | 368 ++++++ ...具:工欲善其事,必先利其器.md | 887 ++++++++++++++ ...具:海阔凭鱼跃,天高任鸟飞.md | 341 ++++++ ...介:十步杀一人,千里不留行.md | 456 +++++++ ...关工具:山高月小,水落石出.md | 435 +++++++ ...的GC算法(GC的背景与原理).md | 291 +++++ .../14常见的GC算法(ParallelCMSG1).md | 319 +++++ ...绍:苟日新、日日新、又日新.md | 492 ++++++++ ...绍:会当凌绝顶、一览众山小.md | 519 ++++++++ ...日志解读与分析(基础配置).md | 468 ++++++++ ...解读与分析(实例分析上篇).md | 335 ++++++ ...解读与分析(实例分析中篇).md | 411 +++++++ ...解读与分析(实例分析下篇).md | 392 ++++++ ...与分析(番外篇可视化工具).md | 252 ++++ ...曲而后晓声、观千剑而后识器.md | 668 +++++++++++ ...上篇(内存布局与分析工具).md | 634 ++++++++++ ...关工具下篇(常见问题分析).md | 686 +++++++++++ ...绍:欲穷千里目,更上一层楼.md | 377 ++++++ ...级工具:它山之石,可以攻玉.md | 654 ++++++++++ ...题排查分析上篇(调优经验).md | 436 +++++++ 322 files changed, 104488 insertions(+) create mode 100644 专栏/DevOps实战笔记/03DevOps的实施:到底是工具先行还是文化先行?.md create mode 100644 专栏/DevOps实战笔记/04DevOps的衡量:你是否找到了DevOps的实施路线图?.md create mode 100644 专栏/DevOps实战笔记/05价值流分析:关于DevOps转型,我们应该从何处入手?.md create mode 100644 专栏/DevOps实战笔记/06转型之路:企业实施DevOps的常见路径和问题.md create mode 100644 专栏/DevOps实战笔记/07业务敏捷:帮助DevOps快速落地的源动力.md create mode 100644 专栏/DevOps实战笔记/08精益看板(上):精益驱动的敏捷开发方法.md create mode 100644 专栏/DevOps实战笔记/09精益看板(下):精益驱动的敏捷开发方法.md create mode 100644 专栏/DevOps实战笔记/10配置管理:最容易被忽视的DevOps工程实践基础.md create mode 100644 专栏/DevOps实战笔记/11分支策略:让研发高效协作的关键要素.md create mode 100644 专栏/DevOps实战笔记/12持续集成:你说的CI和我说的CI是一回事吗?.md create mode 100644 专栏/DevOps实战笔记/13自动化测试:DevOps的阿克琉斯之踵.md create mode 100644 专栏/DevOps实战笔记/14内建质量:丰田和亚马逊给我们的启示.md create mode 100644 专栏/DevOps实战笔记/15技术债务:那些不可忽视的潜在问题.md create mode 100644 专栏/DevOps实战笔记/16环境管理:一切皆代码是一种什么样的体验?.md create mode 100644 专栏/DevOps实战笔记/17部署管理:低风险的部署发布策略.md create mode 100644 专栏/DevOps实战笔记/18混沌工程:软件领域的反脆弱.md create mode 100644 专栏/DevOps实战笔记/19正向度量:如何建立完整的DevOps度量体系?.md create mode 100644 专栏/DevOps实战笔记/20持续改进:PDCA体系和持续改进的意义.md create mode 100644 专栏/DevOps实战笔记/21开源还是自研:企业DevOps平台建设的三个阶段.md create mode 100644 专栏/DevOps实战笔记/22产品设计之道:DevOps产品设计的五个层次.md create mode 100644 专栏/DevOps实战笔记/23持续交付平台:现代流水线必备的十大特征(上).md create mode 100644 专栏/DevOps实战笔记/24持续交付平台:现代流水线必备的十大特征(下).md create mode 100644 专栏/DevOps实战笔记/25让数据说话:如何建设企业级数据度量平台?.md create mode 100644 专栏/DevOps实战笔记/26平台产品研发:三个月完成千人规模的产品要怎么做?.md create mode 100644 专栏/DevOps实战笔记/27巨人的肩膀:那些你不能忽视的开源工具.md create mode 100644 专栏/DevOps实战笔记/28迈向云端:云原生应用时代的平台思考.md create mode 100644 专栏/DevOps实战笔记/29向前一步:万人规模企业的DevOps实战转型案例(上).md create mode 100644 专栏/DevOps实战笔记/30向前一步:万人规模企业的DevOps实战转型案例(下).md create mode 100644 专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习(1).md create mode 100644 专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习.md create mode 100644 专栏/DevOps实战笔记/期末总结在云时代,如何选择一款合适的流水线工具?.md create mode 100644 专栏/DevOps实战笔记/特别放送(一)成为DevOps工程师的必备技能(上).md create mode 100644 专栏/DevOps实战笔记/特别放送(三)学习DevOps不得不了解的经典资料.md create mode 100644 专栏/DevOps实战笔记/特别放送(二)成为DevOps工程师的必备技能(下).md create mode 100644 专栏/DevOps实战笔记/特别放送(五)关于DevOps组织和文化的那些趣事儿.md create mode 100644 专栏/DevOps实战笔记/特别放送(四)Jenkins产品经理是如何设计产品的?.md create mode 100644 专栏/DevOps实战笔记/结束语持续改进,成就非凡!.md create mode 100644 专栏/Dubbo源码解读与实战-完/00开篇词深入掌握Dubbo原理与实现,提升你的职场竞争力.md create mode 100644 专栏/Dubbo源码解读与实战-完/01Dubbo源码环境搭建:千里之行,始于足下.md create mode 100644 专栏/Dubbo源码解读与实战-完/02Dubbo的配置总线:抓住URL,就理解了半个Dubbo.md create mode 100644 专栏/Dubbo源码解读与实战-完/03DubboSPI精析,接口实现两极反转(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/04DubboSPI精析,接口实现两极反转(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/05海量定时任务,一个时间轮搞定.md create mode 100644 专栏/Dubbo源码解读与实战-完/06ZooKeeper与Curator,求你别用ZkClient了(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/07ZooKeeper与Curator,求你别用ZkClient了(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/08代理模式与常见实现.md create mode 100644 专栏/Dubbo源码解读与实战-完/09Netty入门,用它做网络编程都说好(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/10Netty入门,用它做网络编程都说好(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/11简易版RPC框架实现(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/12简易版RPC框架实现(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/13本地缓存:降低ZooKeeper压力的一个常用手段.md create mode 100644 专栏/Dubbo源码解读与实战-完/14重试机制是网络操作的基本保证.md create mode 100644 专栏/Dubbo源码解读与实战-完/15ZooKeeper注册中心实现,官方推荐注册中心实践.md create mode 100644 专栏/Dubbo源码解读与实战-完/16DubboSerialize层:多种序列化算法,总有一款适合你.md create mode 100644 专栏/Dubbo源码解读与实战-完/17DubboRemoting层核心接口分析:这居然是一套兼容所有NIO框架的设计?.md create mode 100644 专栏/Dubbo源码解读与实战-完/18Buffer缓冲区:我们不生产数据,我们只是数据的搬运工.md create mode 100644 专栏/Dubbo源码解读与实战-完/19Transporter层核心实现:编解码与线程模型一文打尽(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/20Transporter层核心实现:编解码与线程模型一文打尽(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/21Exchange层剖析:彻底搞懂Request-Response模型(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/22Exchange层剖析:彻底搞懂Request-Response模型(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/23核心接口介绍,RPC层骨架梳理.md create mode 100644 专栏/Dubbo源码解读与实战-完/24从Protocol起手,看服务暴露和服务引用的全流程(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/25从Protocol起手,看服务暴露和服务引用的全流程(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/26加餐:直击Dubbo“心脏”,带你一起探秘Invoker(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/27加餐:直击Dubbo“心脏”,带你一起探秘Invoker(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/28复杂问题简单化,代理帮你隐藏了多少底层细节?.md create mode 100644 专栏/Dubbo源码解读与实战-完/29加餐:HTTP协议+JSON-RPC,Dubbo跨语言就是如此简单.md create mode 100644 专栏/Dubbo源码解读与实战-完/30Filter接口,扩展Dubbo框架的常用手段指北.md create mode 100644 专栏/Dubbo源码解读与实战-完/31加餐:深潜Directory实现,探秘服务目录玄机.md create mode 100644 专栏/Dubbo源码解读与实战-完/32路由机制:请求到底怎么走,它说了算(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/33路由机制:请求到底怎么走,它说了算(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/34加餐:初探Dubbo动态配置的那些事儿.md create mode 100644 专栏/Dubbo源码解读与实战-完/35负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/36负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/37集群容错:一个好汉三个帮(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/38集群容错:一个好汉三个帮(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/39加餐:多个返回值不用怕,Merger合并器来帮忙.md create mode 100644 专栏/Dubbo源码解读与实战-完/40加餐:模拟远程调用,Mock机制帮你搞定.md create mode 100644 专栏/Dubbo源码解读与实战-完/41加餐:一键通关服务发布全流程.md create mode 100644 专栏/Dubbo源码解读与实战-完/42加餐:服务引用流程全解析.md create mode 100644 专栏/Dubbo源码解读与实战-完/43服务自省设计方案:新版本新方案.md create mode 100644 专栏/Dubbo源码解读与实战-完/44元数据方案深度剖析,如何避免注册中心数据量膨胀?.md create mode 100644 专栏/Dubbo源码解读与实战-完/45加餐:深入服务自省方案中的服务发布订阅(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/46加餐:深入服务自省方案中的服务发布订阅(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/47配置中心设计与实现:集中化配置and本地化配置,我都要(上).md create mode 100644 专栏/Dubbo源码解读与实战-完/48配置中心设计与实现:集中化配置and本地化配置,我都要(下).md create mode 100644 专栏/Dubbo源码解读与实战-完/49结束语认真学习,缩小差距.md create mode 100644 专栏/ElasticSearch知识体系详解/01认知:ElasticSearch基础概念.md create mode 100644 专栏/ElasticSearch知识体系详解/02认知:ElasticStack生态和场景方案.md create mode 100644 专栏/ElasticSearch知识体系详解/03安装:ElasticSearch和Kibana安装.md create mode 100644 专栏/ElasticSearch知识体系详解/04入门:查询和聚合的基础使用.md create mode 100644 专栏/ElasticSearch知识体系详解/05索引:索引管理详解.md create mode 100644 专栏/ElasticSearch知识体系详解/06索引:索引模板(IndexTemplate)详解.md create mode 100644 专栏/ElasticSearch知识体系详解/07查询:DSL查询之复合查询详解.md create mode 100644 专栏/ElasticSearch知识体系详解/08查询:DSL查询之全文搜索详解.md create mode 100644 专栏/ElasticSearch知识体系详解/09查询:DSL查询之Term详解.md create mode 100644 专栏/ElasticSearch知识体系详解/10聚合:聚合查询之Bucket聚合详解.md create mode 100644 专栏/ElasticSearch知识体系详解/11聚合:聚合查询之Metric聚合详解.md create mode 100644 专栏/ElasticSearch知识体系详解/12聚合:聚合查询之Pipline聚合详解.md create mode 100644 专栏/ElasticSearch知识体系详解/13原理:从图解构筑对ES原理的初步认知.md create mode 100644 专栏/ElasticSearch知识体系详解/14原理:ES原理知识点补充和整体结构.md create mode 100644 专栏/ElasticSearch知识体系详解/15原理:ES原理之索引文档流程详解.md create mode 100644 专栏/ElasticSearch知识体系详解/16原理:ES原理之读取文档流程详解.md create mode 100644 专栏/ElasticSearch知识体系详解/17优化:ElasticSearch性能优化详解.md create mode 100644 专栏/ElasticSearch知识体系详解/18大厂实践:腾讯万亿级Elasticsearch技术实践.md create mode 100644 专栏/ElasticSearch知识体系详解/19资料:AwesomeElasticsearch.md create mode 100644 专栏/ElasticSearch知识体系详解/20WrapperQuery.md create mode 100644 专栏/ElasticSearch知识体系详解/21备份和迁移.md create mode 100644 专栏/Flutter入门教程/01前言-教程内容导读.md create mode 100644 专栏/Flutter入门教程/02Flutter开发环境的搭建.md create mode 100644 专栏/Flutter入门教程/03新手村基础Dart语法(上).md create mode 100644 专栏/Flutter入门教程/04新手村基础Dart语法(下).md create mode 100644 专栏/Flutter入门教程/05Flutter计数器项目解读.md create mode 100644 专栏/Flutter入门教程/06猜数字界面交互与需求分析.md create mode 100644 专栏/Flutter入门教程/07使用组件构建静态界面.md create mode 100644 专栏/Flutter入门教程/08状态数据与界面更新.md create mode 100644 专栏/Flutter入门教程/09校验结果与提示信息.md create mode 100644 专栏/Flutter入门教程/10动画使用与状态周期.md create mode 100644 专栏/Flutter入门教程/11猜数字整理与总结.md create mode 100644 专栏/Flutter入门教程/12电子木鱼界面交互与需求分析.md create mode 100644 专栏/Flutter入门教程/13电子木鱼静态界面构建.md create mode 100644 专栏/Flutter入门教程/14计数变化与音效播放.md create mode 100644 专栏/Flutter入门教程/15弹出选项与切换状态.md create mode 100644 专栏/Flutter入门教程/16用滑动列表展示记录.md create mode 100644 专栏/Flutter入门教程/17电子木鱼整理与总结.md create mode 100644 专栏/Flutter入门教程/18白板绘制界面交互与需求分析.md create mode 100644 专栏/Flutter入门教程/19认识自定义绘制组件.md create mode 100644 专栏/Flutter入门教程/20通过手势在白板上绘制.md create mode 100644 专栏/Flutter入门教程/21白板画笔的参数设置.md create mode 100644 专栏/Flutter入门教程/22撤销功能与画板优化.md create mode 100644 专栏/Flutter入门教程/23应用界面整合.md create mode 100644 专栏/Flutter入门教程/24数据的持久化存储.md create mode 100644 专栏/Flutter入门教程/25网络数据的访问.md create mode 100644 专栏/Flutter入门教程/26教程总结与展望.md create mode 100644 专栏/Flutter核心技术与实战/00开篇词为什么每一位大前端从业者都应该学习Flutter?.md create mode 100644 专栏/Flutter核心技术与实战/01预习篇·从0开始搭建Flutter工程环境.md create mode 100644 专栏/Flutter核心技术与实战/02预习篇·Dart语言概览.md create mode 100644 专栏/Flutter核心技术与实战/03深入理解跨平台方案的历史发展逻辑.md create mode 100644 专栏/Flutter核心技术与实战/04Flutter区别于其他方案的关键技术是什么?.md create mode 100644 专栏/Flutter核心技术与实战/05从标准模板入手,体会Flutter代码是如何运行在原生系统上的.md create mode 100644 专栏/Flutter核心技术与实战/06基础语法与类型变量:Dart是如何表示信息的?.md create mode 100644 专栏/Flutter核心技术与实战/07函数、类与运算符:Dart是如何处理信息的?.md create mode 100644 专栏/Flutter核心技术与实战/08综合案例:掌握Dart核心特性.md create mode 100644 专栏/Flutter核心技术与实战/09Widget,构建Flutter界面的基石.md create mode 100644 专栏/Flutter核心技术与实战/10Widget中的State到底是什么?.md create mode 100644 专栏/Flutter核心技术与实战/11提到生命周期,我们是在说什么?.md create mode 100644 专栏/Flutter核心技术与实战/12经典控件(一):文本、图片和按钮在Flutter中怎么用?.md create mode 100644 专栏/Flutter核心技术与实战/13经典控件(二):UITableView_ListView在Flutter中是什么?.md create mode 100644 专栏/Flutter核心技术与实战/14经典布局:如何定义子控件在父容器中排版的位置?.md create mode 100644 专栏/Flutter核心技术与实战/15组合与自绘,我该选用何种方式自定义Widget?.md create mode 100644 专栏/Flutter核心技术与实战/16从夜间模式说起,如何定制不同风格的App主题?.md create mode 100644 专栏/Flutter核心技术与实战/17依赖管理(一):图片、配置和字体在Flutter中怎么用?.md create mode 100644 专栏/Flutter核心技术与实战/18依赖管理(二):第三方组件库在Flutter中要如何管理?.md create mode 100644 专栏/Flutter核心技术与实战/19用户交互事件该如何响应?.md create mode 100644 专栏/Flutter核心技术与实战/20关于跨组件传递数据,你只需要记住这三招.md create mode 100644 专栏/Flutter核心技术与实战/21路由与导航,Flutter是这样实现页面切换的.md create mode 100644 专栏/Flutter核心技术与实战/22如何构造炫酷的动画效果?.md create mode 100644 专栏/Flutter核心技术与实战/23单线程模型怎么保证UI运行流畅?.md create mode 100644 专栏/Flutter核心技术与实战/24HTTP网络编程与JSON解析.md create mode 100644 专栏/Flutter核心技术与实战/25本地存储与数据库的使用和优化.md create mode 100644 专栏/Flutter核心技术与实战/26如何在Dart层兼容Android_iOS平台特定实现?(一).md create mode 100644 专栏/Flutter核心技术与实战/27如何在Dart层兼容Android_iOS平台特定实现?(二).md create mode 100644 专栏/Flutter核心技术与实战/28如何在原生应用中混编Flutter工程?.md create mode 100644 专栏/Flutter核心技术与实战/29混合开发,该用何种方案管理导航栈?.md create mode 100644 专栏/Flutter核心技术与实战/30为什么需要做状态管理,怎么做?.md create mode 100644 专栏/Flutter核心技术与实战/31如何实现原生推送能力?.md create mode 100644 专栏/Flutter核心技术与实战/32适配国际化,除了多语言我们还需要注意什么_.md create mode 100644 专栏/Flutter核心技术与实战/33如何适配不同分辨率的手机屏幕?.md create mode 100644 专栏/Flutter核心技术与实战/34如何理解Flutter的编译模式?.md create mode 100644 专栏/Flutter核心技术与实战/35HotReload是怎么做到的?.md create mode 100644 专栏/Flutter核心技术与实战/36如何通过工具链优化开发调试效率?.md create mode 100644 专栏/Flutter核心技术与实战/37如何检测并优化FlutterApp的整体性能表现?.md create mode 100644 专栏/Flutter核心技术与实战/38如何通过自动化测试提高交付质量?.md create mode 100644 专栏/Flutter核心技术与实战/39线上出现问题,该如何做好异常捕获与信息采集?.md create mode 100644 专栏/Flutter核心技术与实战/40衡量FlutterApp线上质量,我们需要关注这三个指标.md create mode 100644 专栏/Flutter核心技术与实战/41组件化和平台化,该如何组织合理稳定的Flutter工程结构?.md create mode 100644 专栏/Flutter核心技术与实战/42如何构建高效的FlutterApp打包发布环境?.md create mode 100644 专栏/Flutter核心技术与实战/43如何构建自己的Flutter混合开发框架(一)?.md create mode 100644 专栏/Flutter核心技术与实战/44如何构建自己的Flutter混合开发框架(二)?.md create mode 100644 专栏/Flutter核心技术与实战/特别放送温故而知新,与你说说专栏的那些思考题.md create mode 100644 专栏/Flutter核心技术与实战/结束语勿畏难,勿轻略.md create mode 100644 专栏/Go语言核心36讲/00导读写给0基础入门的Go语言学习者.md create mode 100644 专栏/Go语言核心36讲/00导读学习专栏的正确姿势.md create mode 100644 专栏/Go语言核心36讲/00开篇词跟着学,你也能成为Go语言高手.md create mode 100644 专栏/Go语言核心36讲/01工作区和GOPATH.md create mode 100644 专栏/Go语言核心36讲/02命令源码文件.md create mode 100644 专栏/Go语言核心36讲/03库源码文件.md create mode 100644 专栏/Go语言核心36讲/04程序实体的那些事儿(上).md create mode 100644 专栏/Go语言核心36讲/05程序实体的那些事儿(中).md create mode 100644 专栏/Go语言核心36讲/06程序实体的那些事儿(下).md create mode 100644 专栏/Go语言核心36讲/07数组和切片.md create mode 100644 专栏/Go语言核心36讲/08container包中的那些容器.md create mode 100644 专栏/Go语言核心36讲/09字典的操作和约束.md create mode 100644 专栏/Go语言核心36讲/10通道的基本操作.md create mode 100644 专栏/Go语言核心36讲/11通道的高级玩法.md create mode 100644 专栏/Go语言核心36讲/12使用函数的正确姿势.md create mode 100644 专栏/Go语言核心36讲/13结构体及其方法的使用法门.md create mode 100644 专栏/Go语言核心36讲/14接口类型的合理运用.md create mode 100644 专栏/Go语言核心36讲/15关于指针的有限操作.md create mode 100644 专栏/Go语言核心36讲/16go语句及其执行规则(上).md create mode 100644 专栏/Go语言核心36讲/17go语句及其执行规则(下).md create mode 100644 专栏/Go语言核心36讲/18if语句、for语句和switch语句.md create mode 100644 专栏/Go语言核心36讲/19错误处理(上).md create mode 100644 专栏/Go语言核心36讲/20错误处理(下).md create mode 100644 专栏/Go语言核心36讲/21panic函数、recover函数以及defer语句(上).md create mode 100644 专栏/Go语言核心36讲/22panic函数、recover函数以及defer语句(下).md create mode 100644 专栏/Go语言核心36讲/23测试的基本规则和流程(上).md create mode 100644 专栏/Go语言核心36讲/24测试的基本规则和流程(下).md create mode 100644 专栏/Go语言核心36讲/25更多的测试手法.md create mode 100644 专栏/Go语言核心36讲/26sync.Mutex与sync.RWMutex.md create mode 100644 专栏/Go语言核心36讲/27条件变量sync.Cond(上).md create mode 100644 专栏/Go语言核心36讲/28条件变量sync.Cond(下).md create mode 100644 专栏/Go语言核心36讲/29原子操作(上).md create mode 100644 专栏/Go语言核心36讲/30原子操作(下).md create mode 100644 专栏/Go语言核心36讲/31sync.WaitGroup和sync.Once.md create mode 100644 专栏/Go语言核心36讲/32context.Context类型.md create mode 100644 专栏/Go语言核心36讲/33临时对象池sync.Pool.md create mode 100644 专栏/Go语言核心36讲/34并发安全字典sync.Map(上).md create mode 100644 专栏/Go语言核心36讲/35并发安全字典sync.Map(下).md create mode 100644 专栏/Go语言核心36讲/36unicode与字符编码.md create mode 100644 专栏/Go语言核心36讲/37strings包与字符串操作.md create mode 100644 专栏/Go语言核心36讲/38bytes包与字节串操作(上).md create mode 100644 专栏/Go语言核心36讲/39bytes包与字节串操作(下).md create mode 100644 专栏/Go语言核心36讲/40io包中的接口和工具(上).md create mode 100644 专栏/Go语言核心36讲/41io包中的接口和工具(下).md create mode 100644 专栏/Go语言核心36讲/42bufio包中的数据类型(上).md create mode 100644 专栏/Go语言核心36讲/43bufio包中的数据类型(下).md create mode 100644 专栏/Go语言核心36讲/44使用os包中的API(上).md create mode 100644 专栏/Go语言核心36讲/45使用os包中的API(下).md create mode 100644 专栏/Go语言核心36讲/46访问网络服务.md create mode 100644 专栏/Go语言核心36讲/47基于HTTP协议的网络服务.md create mode 100644 专栏/Go语言核心36讲/48程序性能分析基础(上).md create mode 100644 专栏/Go语言核心36讲/49程序性能分析基础(下).md create mode 100644 专栏/Go语言核心36讲/尾声愿你披荆斩棘,所向无敌.md create mode 100644 专栏/Go语言核心36讲/新年彩蛋完整版思考题答案.md create mode 100644 专栏/Go语言项目开发实战/00开篇词从0开始搭建一个企业级Go应用.md create mode 100644 专栏/Go语言项目开发实战/01IAM系统概述:我们要实现什么样的Go项目?.md create mode 100644 专栏/Go语言项目开发实战/02环境准备:如何安装和配置一个基本的Go开发环境?.md create mode 100644 专栏/Go语言项目开发实战/03项目部署:如何快速部署IAM系统?.md create mode 100644 专栏/Go语言项目开发实战/04规范设计(上):项目开发杂乱无章,如何规范?.md create mode 100644 专栏/Go语言项目开发实战/05规范设计(下):commit信息风格迥异、难以阅读,如何规范?.md create mode 100644 专栏/Go语言项目开发实战/06目录结构设计:如何组织一个可维护、可扩展的代码目录?.md create mode 100644 专栏/Go语言项目开发实战/07工作流设计:如何设计合理的多人开发模式?.md create mode 100644 专栏/Go语言项目开发实战/08研发流程设计(上):如何设计Go项目的开发流程?.md create mode 100644 专栏/Go语言项目开发实战/09研发流程设计(下):如何管理应用的生命周期?.md create mode 100644 专栏/Go语言项目开发实战/10设计方法:怎么写出优雅的Go项目?.md create mode 100644 专栏/Go语言项目开发实战/11设计模式:Go常用设计模式概述.md create mode 100644 专栏/Go语言项目开发实战/12API风格(上):如何设计RESTfulAPI?.md create mode 100644 专栏/Go语言项目开发实战/13API风格(下):RPCAPI介绍.md create mode 100644 专栏/Go语言项目开发实战/14项目管理:如何编写高质量的Makefile?.md create mode 100644 专栏/Go语言项目开发实战/15研发流程实战:IAM项目是如何进行研发流程管理的?.md create mode 100644 专栏/Go语言项目开发实战/16代码检查:如何进行静态代码检查?.md create mode 100644 专栏/Go语言项目开发实战/17API文档:如何生成SwaggerAPI文档?.md create mode 100644 专栏/Go语言项目开发实战/18错误处理(上):如何设计一套科学的错误码?.md create mode 100644 专栏/Go语言项目开发实战/19错误处理(下):如何设计错误包?.md create mode 100644 专栏/Go语言项目开发实战/20日志处理(上):如何设计日志包并记录日志?.md create mode 100644 专栏/Go语言项目开发实战/21日志处理(下):手把手教你从0编写一个日志包.md create mode 100644 专栏/Go语言项目开发实战/22应用构建三剑客:Pflag、Viper、Cobra核心功能介绍.md create mode 100644 专栏/Go语言项目开发实战/23应用构建实战:如何构建一个优秀的企业应用框架?.md create mode 100644 专栏/Go语言项目开发实战/24Web服务:Web服务核心功能有哪些,如何实现?.md create mode 100644 专栏/Go语言项目开发实战/25认证机制:应用程序如何进行访问认证?.md create mode 100644 专栏/Go语言项目开发实战/26IAM项目是如何设计和实现访问认证功能的?.md create mode 100644 专栏/Go语言项目开发实战/27权限模型:5大权限模型是如何进行资源授权的?.md create mode 100644 专栏/Go语言项目开发实战/28控制流(上):通过iam-apiserver设计,看Web服务的构建.md create mode 100644 专栏/Go语言项目开发实战/29控制流(下):iam-apiserver服务核心功能实现讲解.md create mode 100644 专栏/Go语言项目开发实战/30ORM:CURD神器GORM包介绍及实战.md create mode 100644 专栏/Go语言项目开发实战/31数据流:通过iam-authz-server设计,看数据流服务的设计.md create mode 100644 专栏/Go语言项目开发实战/32数据处理:如何高效处理应用程序产生的数据?.md create mode 100644 专栏/Go语言项目开发实战/33SDK设计(上):如何设计出一个优秀的GoSDK?.md create mode 100644 专栏/Go语言项目开发实战/34SDK设计(下):IAM项目GoSDK设计和实现.md create mode 100644 专栏/Go语言项目开发实战/35效率神器:如何设计和实现一个命令行客户端工具?.md create mode 100644 专栏/Go语言项目开发实战/36代码测试(上):如何编写Go语言单元测试和性能测试用例?.md create mode 100644 专栏/Go语言项目开发实战/37代码测试(下):Go语言其他测试类型及IAM测试介绍.md create mode 100644 专栏/Go语言项目开发实战/38性能分析(上):如何分析Go语言代码的性能?.md create mode 100644 专栏/Go语言项目开发实战/39性能分析(下):APIServer性能测试和调优实战.md create mode 100644 专栏/Go语言项目开发实战/40软件部署实战(上):部署方案及负载均衡、高可用组件介绍.md create mode 100644 专栏/Go语言项目开发实战/41软件部署实战(中):IAM系统生产环境部署实战.md create mode 100644 专栏/Go语言项目开发实战/42软件部署实战(下):IAM系统安全加固、水平扩缩容实战.md create mode 100644 专栏/Go语言项目开发实战/43技术演进(上):虚拟化技术演进之路.md create mode 100644 专栏/Go语言项目开发实战/44技术演进(下):软件架构和应用生命周期技术演进之路.md create mode 100644 专栏/Go语言项目开发实战/45基于Kubernetes的云原生架构设计.md create mode 100644 专栏/Go语言项目开发实战/46如何制作Docker镜像?.md create mode 100644 专栏/Go语言项目开发实战/47如何编写Kubernetes资源定义文件?.md create mode 100644 专栏/Go语言项目开发实战/48IAM容器化部署实战.md create mode 100644 专栏/Go语言项目开发实战/49服务编排(上):Helm服务编排基础知识.md create mode 100644 专栏/Go语言项目开发实战/50服务编排(下):基于Helm的服务编排部署实战.md create mode 100644 专栏/Go语言项目开发实战/51基于GitHubActions的CI实战.md create mode 100644 专栏/Go语言项目开发实战/特别放送GoModules依赖包管理全讲.md create mode 100644 专栏/Go语言项目开发实战/特别放送GoModules实战.md create mode 100644 专栏/Go语言项目开发实战/特别放送IAM排障指南.md create mode 100644 专栏/Go语言项目开发实战/特别放送分布式作业系统设计和实现.md create mode 100644 专栏/Go语言项目开发实战/特别放送给你一份Go项目中最常用的Makefile核心语法.md create mode 100644 专栏/Go语言项目开发实战/特别放送给你一份清晰、可直接套用的Go编码规范.md create mode 100644 专栏/Go语言项目开发实战/直播加餐如何从小白进阶成Go语言专家?.md create mode 100644 专栏/Go语言项目开发实战/结束语如何让自己的Go研发之路走得更远?.md create mode 100644 专栏/JVM核心技术32讲(完)/01阅读此专栏的正确姿势.md create mode 100644 专栏/JVM核心技术32讲(完)/02环境准备:千里之行,始于足下.md create mode 100644 专栏/JVM核心技术32讲(完)/03常用性能指标:没有量化,就没有改进.md create mode 100644 专栏/JVM核心技术32讲(完)/04JVM基础知识:不积跬步,无以至千里.md create mode 100644 专栏/JVM核心技术32讲(完)/05Java字节码技术:不积细流,无以成江河.md create mode 100644 专栏/JVM核心技术32讲(完)/06Java类加载器:山不辞土,故能成其高.md create mode 100644 专栏/JVM核心技术32讲(完)/07Java内存模型:海不辞水,故能成其深.md create mode 100644 专栏/JVM核心技术32讲(完)/08JVM启动参数详解:博观而约取、厚积而薄发.md create mode 100644 专栏/JVM核心技术32讲(完)/09JDK内置命令行工具:工欲善其事,必先利其器.md create mode 100644 专栏/JVM核心技术32讲(完)/10JDK内置图形界面工具:海阔凭鱼跃,天高任鸟飞.md create mode 100644 专栏/JVM核心技术32讲(完)/11JDWP简介:十步杀一人,千里不留行.md create mode 100644 专栏/JVM核心技术32讲(完)/12JMX与相关工具:山高月小,水落石出.md create mode 100644 专栏/JVM核心技术32讲(完)/13常见的GC算法(GC的背景与原理).md create mode 100644 专栏/JVM核心技术32讲(完)/14常见的GC算法(ParallelCMSG1).md create mode 100644 专栏/JVM核心技术32讲(完)/15Java11ZGC和Java12Shenandoah介绍:苟日新、日日新、又日新.md create mode 100644 专栏/JVM核心技术32讲(完)/16OracleGraalVM介绍:会当凌绝顶、一览众山小.md create mode 100644 专栏/JVM核心技术32讲(完)/17GC日志解读与分析(基础配置).md create mode 100644 专栏/JVM核心技术32讲(完)/18GC日志解读与分析(实例分析上篇).md create mode 100644 专栏/JVM核心技术32讲(完)/19GC日志解读与分析(实例分析中篇).md create mode 100644 专栏/JVM核心技术32讲(完)/20GC日志解读与分析(实例分析下篇).md create mode 100644 专栏/JVM核心技术32讲(完)/21GC日志解读与分析(番外篇可视化工具).md create mode 100644 专栏/JVM核心技术32讲(完)/22JVM的线程堆栈等数据分析:操千曲而后晓声、观千剑而后识器.md create mode 100644 专栏/JVM核心技术32讲(完)/23内存分析与相关工具上篇(内存布局与分析工具).md create mode 100644 专栏/JVM核心技术32讲(完)/24内存分析与相关工具下篇(常见问题分析).md create mode 100644 专栏/JVM核心技术32讲(完)/25FastThread相关的工具介绍:欲穷千里目,更上一层楼.md create mode 100644 专栏/JVM核心技术32讲(完)/26面临复杂问题时的几个高级工具:它山之石,可以攻玉.md create mode 100644 专栏/JVM核心技术32讲(完)/27JVM问题排查分析上篇(调优经验).md diff --git a/专栏/DevOps实战笔记/03DevOps的实施:到底是工具先行还是文化先行?.md b/专栏/DevOps实战笔记/03DevOps的实施:到底是工具先行还是文化先行?.md new file mode 100644 index 0000000..8992fb6 --- /dev/null +++ b/专栏/DevOps实战笔记/03DevOps的实施:到底是工具先行还是文化先行?.md @@ -0,0 +1,129 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 DevOps的实施:到底是工具先行还是文化先行? + 你好,我是石雪峰。 + +当一家企业好不容易接纳了DevOps的思想,并下定决心开始实施的时候,总会面临这样一个两难的选择:工具和文化,到底应该哪个先行? + +的确,在DevOps的理论体系之中,工具和文化分别占据了半壁江山。在跟别人讨论这个话题的时候,我们往往会划分为两个不同的“阵营”,争论不休,每一方都有自己的道理,难以说服彼此。在DevOps的世界中,工具和文化哪个先行的问题,就好比豆浆应该是甜的还是咸的一样,一直没有一个定论。 + +可是,对于很多刚刚接触DevOps的人来说,如果不把这个问题弄清楚,后续的DevOps实践之路难免会跑偏。所以无论如何,这碗豆浆我先干为敬,今天我们就先来聊聊这个话题。 + +DevOps工具 + +随着DevOps理念的深入人心,各种以DevOps命名的工具如雨后春笋般出现在我们身边,甚至有很多老牌工具,为了顺应DevOps时代的发展,主动将产品名称改为DevOps。最具代表性的,就是去年9月份微软研发协作平台VSTS(Visual Studio Team Services)正式更名为Azure DevOps,这也进一步地印证,DevOps已经成为了各类工具平台建设的核心理念。 + +在上一讲中,我提到高效率和高质量是DevOps的核心价值,而工具和自动化就是提升效率最直接的手段,让一切都自动化可以说是DevOps的行为准则。 + +一切软件交付过程中的手动环节,都是未来可以尝试进行优化的方向。即便在运维圈里面,ITIL(IT基础架构库)一直是运维赖以生存的基石,也并不妨碍自动化的理念逐步深入到ITIL流程之中,从而在受控的基础上不断优化流程流转效率。 + +另外,正因为所有人都认可自动化的价值,工具平台的引入和建设就成为了DevOps打动人的关键因素之一。 + +同时,现在业界的很多开源工具已经相当成熟,以Netflix、Amazon、Etsy等为代表的优秀公司也在不断将内部的工具平台进行对外开放,各方面的参考资料和使用案例比比皆是。 + +无论是单纯使用,还是基于这些工具进行二次开发,成本都已经没那么高了,一个稍微成熟点的小团队可以在很短的时间内完成一款工具的开发。以我之前所在的团队为例,从0开始组建到第一款产品落地推广,前后不过两个多月的时间,而且与业内的同类产品相比较,毫不逊色。 + +不过,这也带来一个副作用,那就是企业内部的工具平台泛滥,很多同质化的工具在完成从0到1的过程后就停滞不前,陷入重复的怪圈,显然也是一种资源浪费。 + +当然,对于工具决定论的支持者来说,这并不是什么大问题,因为引入工具就是DevOps的最佳实施路径。 + +有时候,当你问别人“你们公司的DevOps做得怎么样啦?”你可能会得到这样的回答:“我们的所有团队都已经开始使用Jenkins了。”听起来感觉怪怪的。如果只是使用了最新最强大的DevOps工具,就能实现软件交付效率的腾飞,那么世界500强的公司早就实现DevOps了。 + +很多公司引入了完整的敏捷项目管理工具,但是却以传统项目管理的方式来使用这套工具,效率跟以前相比并没有明显的提升。对于自研平台来说,也是同样的道理。如果仅仅是把线下的审批流程搬到线上执行,固然能提升一部分执行效率,但是对于企业期望的质变来说,却是相距甚远。 + +说到底,工具没法解决人的问题,这样一条看似取巧的路径,却没法解决企业的根本问题。这时候,就需要文化闪亮登场了。 + +DevOps文化 + +在谈论DevOps文化之前,我先跟你分享一个故事。 + +上世纪80年代,美国加州有一家汽车制造公司,叫作NUMMI。当时这家公司隶属于通用公司,但是由于劳资关系紧张,这家公司一直以来都是通用旗下效益最差的公司。员工整天上班喝酒,赌博,整个工厂乌烟瘴气,旷工率甚至一度达到了20%。通用公司忍无可忍,最后关闭了这家公司。 + +后来,日本丰田公司想在美国联合建厂,于是跟通用达成了合资协议。美国联合汽车工会(UAW)希望新公司可以重新雇佣之前遭到解雇的员工,通用公司本来不想接受,但是令人惊讶的是,丰田公司却同意了。因为他们认为,NUMMI工厂之前的情况更多是系统的原因,而不是人的原因。 + +接下来,丰田公司将新招募的员工送到日本进行培训。短短三个月后,整个公司的面貌焕然一新,半年后,一跃成为整个通用集团效益最好的公司。 + +由此可见,在不同的文化制度下,相同的人发挥出来的生产力也会有天壤之别。 + +类似的故事并非个例,曾经有一群美国专家到日本参观和学习生产流水线,他们发现了一件有趣的事情。 + +在美国公司的生产线里面,总有一个人拿着橡胶的锤子在敲打车门,目的是检查车门是否安装完好。但即便如此,车门的质量依然很差。可是,在日本公司的工厂里面,却没有这样的角色。 + +他们就好奇地问道:“你们如何保障车门没有问题呢?”日方的专家回复说:“我们在设计车门的时候,就已经保证它不会出问题了。”你看,同样是采用流水线技术的两家公司,结果却大不相同。 + +类比DevOps,如果在我们的软件交付过程中,始终依靠这个拿锤子的人来保障产品的质量,出了问题总是抱怨没有会使用锤子的优秀人才,或许这个流程本身就出了问题。 + +回到文化本身,良好的文化不仅可以让流程和工具发挥更大的作用,更重要的是,它能够诱发人们思考当前的流程和工具哪里是有问题的,从而引出更多有关流程和工具的优化需求,促使流程和工具向更加有力的支持业务发展的方向持续改进。 + +可是,企业内部的DevOps文化本身就是虚无缥缈的事情,你很难去量化团队的文化水平,进而改变企业的文化。盲目地空谈文化,对组织也是一种伤害。因为脱离实践,文化就会变成无根之水。当组织迟迟无法看到DevOps带来的实际收益时,就会丧失转型的热情和信心。 + +所以,我们需要先改变行为,再通过行为来改变文化。而改变行为最关键的,就是要建立一种有效的机制。就像我一直强调的那样,机制就是人们愿意做,而且做了有好处的事情。 + +回想之前提到的某金融公司的案例,如果他们的老板只是喊了句口号“我们要在年底完成DevOps试点落地”,那么年底即便项目成功,本质上也不会有什么改变。相反,他们在内部建立了一种机制,包括OKR指标的设定、关键指标达成后的激励、成立专项的工作小组、引入外部的咨询顾问,以及一套客观的评判标准,这一切都保证了团队走在正确的道路上。而承载这套客观标准的就是一套通用的度量平台,说到底,还是需要将规则内建于工具之中,并通过工具来指导实践。 + +这样一来,当团队通过DevOps获得了实实在在的改变,那么DevOps所倡导的职责共担、持续改进的文化自然也会生根发芽。 + +所以你看,DevOps中的文化和工具,本身就是一体两面,我们既不能盲目地奉行工具决定论,上来就大干快干地采购和建设工具,也不能盲目地空谈文化,在内部形成一种脱离实际的风气。 + +DevOps的3个支柱 + +对工具和文化的体系化认知,可以归纳到DevOps的3个支柱之中,即人(People)、流程(Process)和平台(Platform)。3个支柱之间两两组合,构成了我们实施DevOps的“正确姿势”,只强调其中一个维度的重要性,明显是很片面的。 + + + +人 + 流程 = 文化 + +在具体的流程之下,人会形成一套行为准则,而这套行为准则会潜移默化地影响软件交付效率和质量的方方面面。这些行为准则组合到一起,就构成了企业内部的文化。 + +一种正向的文化可以弥合流程和平台方面的缺失,推动二者的持续改进,同时可以让相同的流程和平台在不同的人手中产生迥异的效果。就好像《一代宗师》里面的那句经典台词:“真正的高手,比拼的不是武功,而是思想。”而指导DevOps落地发展的思想,就是DevOps的文化了。 + +举个例子,在谷歌SRE的实践中,研发交付的应用需要自运维一段时间,并且要在达到一定的质量指标之后才会交接给SRE进行运维。但是,为了避免出现“研发一走,运维背锅”的情况,他们还建立了“打回”的流程,也就是当SRE运维一段时间后,如果发现应用稳定性不达标,就会重新交还给开发自己负责维护,这样一来,研发就会主动地保障线上应用的质量。而且在这个过程,SRE也会给予技术和平台方面的支持,从而形成了责任共担和质量导向的文化。 + +类似的,有些公司设有线上安全点数的机制,在一定的额度范围内,允许团队出现问题,并且不追究责任。这就可以激励团队更加主动地完成交付活动,不必每一次都战战兢兢,生怕出错。通过流程和行为的改变,团队的文化也在慢慢地改进。 + +由此看来,虽然我们很难直接改变文化,但是却可以定义期望文化下的行为表现,并通过流程的改进来改变大家的行为,从而让文化得以生根发芽,茁壮成长。 + +流程 + 平台 = 工具 + +企业内部流程的标准化,是构成自动化的前提。试想一下,如果没有一套标准的规则,每一项工作都需要人介入进行判断和分析,那么结果势必会受到人的因素的影响,这样的话,又如何做到自动化呢? + +而平台的最大意义,就是承载企业内部的标准化流程。当这些标准化流程被固化在平台之中时,所有人都能够按照一套规则沟通,沟通效率显然会大幅提升。 + +平台上固化的每一种流程,其实都是可以用来解决实际问题的工具。很多人分不清工具和平台的关系,好像只要引入或者开发了一个工具,都可以称之为平台,也正因为这样,企业内部的平台比比皆是。 + +实际上,平台除了有用户量、认可度、老板加持等因素之外,还会有3个显著特征。 + + +吸附效应:平台会不断地吸收中小型的工具,逐渐成为一个能力集合体。 +规模效应:平台的成本不会随着使用方的扩展而线性增加,能够实现规模化。 +积木效应:平台具备基础通用共享能力,能够快速搭建新的业务实现。 + + +简单来说,平台就是搭台子,工具来唱戏。平台提供场所,进行宣传,吸引用户,同时还能提供演出的道具,以及数据方面的分析。观众的喜好各不相同,但是平台将各种戏汇集在一起,就能满足大多数人的需求。如果平台把唱戏的事情做了,难以聚焦“台子”的质量,就离倒闭不远了。同样,如果唱戏的整天琢磨着建平台,那么戏本身的品质就难以不断精进。所以是做平台,还是做工具,无关好坏,只关乎选择。 + +平台 + 人 = 培训赋能 + +平台是标准化流程的载体,一方面可以规范和约束员工的行为,另一方面,通过平台赋能,所有人都能以相同的操作,获得相同的结果。这样一来,跨领域之间的交接和专家就被平台所取代,当一件事情不再依赖于个人的时候,等待的浪费就会大大降低,平台就成了组织内部的能力集合体。 + +但与此同时,当我们定义了期望达到的目标,并提供了平台工具,那么对人的培训就变得至关重要,因为只有这样,才能让工具平台发挥最大的效用。更加重要的是,通过最终的用户使用验证,可以发现大量的可改进空间,进一步推动平台能力的提升,从而带动组织整体的飞轮效应,加速组织的进化。 + +所以你看,文化、工具和培训作为DevOps建设的3个重心,折射出来的是对组织流程、平台和人的关注,三位一体,缺一不可。 + +最后,跟你分享一个关于美国第一资本的例子。他们最初在实施DevOps时,采用的是外包方式,修改一个很小的问题都需要走复杂的变更流程,需要几天的时间。后来,他们决定采用“开源为先”的策略,并且严格审查原本的商业采购流程。除此之外,他们还基于开源工具搭建自己的平台,并在公司内部进行跨领域角色的交叉培养,交付效率大幅提升,实现了从每天迭代一次到每天多次的线上部署。 + +总结 + +讲到这里,我们今天的专栏内容就到尾声了。在这一讲中,我跟你讨论了DevOps中的工具和文化的实际价值,以及潜在的问题和挑战,最终推导出DevOps的3个支柱,也就是人、流程和平台,这3个支柱缺一不可。只有通过人、流程和平台的有机结合,在文化、工具和人员培训赋能领域共同推进,才能实现DevOps的真正落地实施。 + +思考题 + +最后,给你留一个思考题:你们公司的哪些文化是非常吸引你的?这些文化对于DevOps的实施又有哪些帮助呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/04DevOps的衡量:你是否找到了DevOps的实施路线图?.md b/专栏/DevOps实战笔记/04DevOps的衡量:你是否找到了DevOps的实施路线图?.md new file mode 100644 index 0000000..275dbad --- /dev/null +++ b/专栏/DevOps实战笔记/04DevOps的衡量:你是否找到了DevOps的实施路线图?.md @@ -0,0 +1,125 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 DevOps的衡量:你是否找到了DevOps的实施路线图? + 你好,我是石雪峰。今天我们来聊聊DevOps的实施路线图。 + +商业领域有一本特别经典的书,叫作《跨越鸿沟》,这本书中提出了一个“技术采纳生命周期定律”,对高科技行业来说,它的地位堪比摩尔定律。 + + + +简单来说,这个定律描述了一项新技术从诞生到普及要经历的5个阶段,这5个阶段分别对应一类特殊人群,即创新者、早期使用者、早期大众、晚期大众和落后者。这个定律表明,技术的发展不是线性的,需要经历一段蛰伏期,才能最终跨越鸿沟为大众所接受,成为业界主流。 + +当然,DevOps这项所谓的新技术,在企业内部的落地也注定不是一帆风顺的。那么在这种情况下,你是否找到了DevOps的实施路线图呢? + +从2017年第一届DevOpsDays大会中国站举办以来,DevOps正式在国内驶入了发展的快车道。从一门鲜为人知的新技术思想,到现在在各个行业的蓬勃发展,各种思想和实践的激烈碰撞,DevOps的理念和价值可谓是深入人心。 + +这样看来,DevOps已经成功地跨越了技术发展的鸿沟,从早期使用者阶段进入了早期大众的阶段,而这也意味着越来越多的公司开始尝试DevOps。 + +在2017年底,Forrester的一组调查数据显示,将近50%的受访公司表示已经引入并正在实施DevOps,30%的公司表示有意向和计划来开启这项工作,而对DevOps完全不感兴趣的仅占1%。可以说,2018年就是企业落地DevOps的元年。 + +但是,就像你要去往一个未知的目的地时,需要导航帮你规划路径、实时定位,并在出现意外情况时及时提示你是否要重新规划路径一样,企业在实施DevOps的过程中,其实也面临着相似的问题。企业自身难以清晰定位DevOps的现状,客观评估DevOps相关的能力水平,识别当前所面临的最大瓶颈以及实施DevOps的阶段性成果预期…… + +回顾整个IT行业的发展历程,新思想和新技术的发展,总是同标准化的模型和框架相伴相生的。 + +我认为,任何技术的成熟,都是以模型和框架的稳定为标志的。因为当技术跨越初期的鸿沟,面对的是广大受众,如果没有一套模型和框架来帮助大众快速跟上节奏,找准方向,是很难大规模推广并健康发展的。 + +比如,软件开发领域的CMMI模型(软件能力成熟度模型)、运维行业的ITIL模型等,在各自的领域都久负盛名,甚至一度被各个领域的从业者奉为圭臬和行为准则,成为衡量能力高低的标尺。 + +我曾经在国内某大型通讯设备公司参与过CMMI评级项目。当时,就算业务压力再大,只要是关于通过评级的事情,所有部门都会高优先级支持。由此可见,整个公司都非常重视这个认证评级项目。 + +那么问题来了,在DevOps这项新思想和新技术不断走向成熟的过程中,是否也有类似的模型和框架,能够指导企业内部的DevOps转型落地工作呢? + +答案是有的,而且有很多。只要你去谷歌上搜一下DevOps框架、模型等关键词,就能看到非常多的结果。尤其是国外的一些知名公司,比如Atlassian、CloudBees、CA等,基本上都有一套自己的模型和框架,来帮助企业识别当前的DevOps能力水平并加以改进。 + +我之前参与过工信部旗下的中国信息通讯研究院牵头制定的一套DevOps能力成熟度模型。这套模型覆盖了软件交付的方方面面,包括敏捷开发管理、持续交付和技术运营三大部分,同时,也有与应用架构设计、安全和组织结构对应的内容。 + +不仅如此,对于开发DevOps工具的企业来说,系统和工具模型更加偏向于平台能力,稍加整理就可以作为平台需求输入到开发团队中。目前已经有不少公司在参考这套模型进行DevOps实践。下图展示了这个模型的整体框架,如果你正在企业内部推进DevOps落地的话,可以参考一下。 + + + +步骤与原则 + +业界有这么多模型和框架,是不是随便找一个,直接照着做就行了呢?当然不是。 + +毕竟,每家企业所处的行业现状、竞争压力、市场竞争态势都不尽相同,组织架构、战略目标、研发能力、资源投入等方面也千差万别,很难有一条标准的路径,让大家齐步走。比如,同样是金融企业,让万人规模的大银行和百人规模的城商行同台竞技,本身就有点强人所难。 + +所以,在实际参考模型和框架的时候,我认为应该尽量遵循以下步骤和原则: + +1.识别差距 + +从“道法术器”的角度来说,DevOps的成熟度模型和框架处于“法”这个层面,也就是一整套实施DevOps的方法论,相当于是一幅战略地图,最重要的就是对DevOps实施所涉及到的领域和能力图谱建立全面的认知。 + +通过和模型、框架进行对标,可以快速识别出企业当前存在的短板和差距,并建立企业当前的能力状态基线,用于对比改进后所取得的效果。 + +2.锚定目标 + +数字化转型的核心在于优化软件交付效率。通过对标模型框架,企业需要明确什么是影响软件交付效率进一步提升的最大瓶颈,当前存在的最大痛点是什么,哪些能力的改善有助于企业达成预定的目标……同时,要根据企业的现状,甄别对标的差距结果,识别出哪些是真实有效的,哪些可以通过平台能力快速补齐。 + +比如,对于一家提供CRM软件的公司来说,容器化部署虽然在环境管理、部署发布等领域有非常多的优势,但并非当前的核心瓶颈和亟需解决的问题,那么就不应该纳入近期的改进列表中。 + +通过现状分析,企业可以把有限的资源聚焦在那些高优先级的任务上,识别出改进目标和改进后要达到的预期效果。这些效果需要尽量客观和可量化,比如缩短50%的环境准备时长。 + +3.关注能力 + +模型和框架是能力和实践的集合,也就是道法术器的“术”这个层面,所以在应用模型的过程中,核心的关注点应该在能力本身,而不是单纯地比较数字和结果。 + +比如,亚马逊每天23000次部署的案例经常会被拿来举例子。这个数字的确相当惊人,但反过来想想,所有企业都需要达到这么高的部署频率吗?举个例子,一个客户端应用可以在几分钟内构建完成,但同样是构建,对于大型系统软件来说可能需要几个小时,那么到底多长时间才算达标呢? + +我们不能只关注这些明星企业所达到的成就,而忽略了自身的需求。所以,正确的做法是根据锚定的目标识别所需要的能力,再导入与能力相匹配的实践,不断强化实践,从而使能力本身得到提升。 + +4.持续改进 + +模型和框架本身也不是一成不变的,也需要像DevOps一样不断迭代更新,以适应更高的软件交付需要。另外,从今年的DevOps状态报告就可以看出,达到精英级别的比例从2018年的7%快速提升到2019年的20%,也就是说,行业整体的能力也在不断提升,这就对企业的软件交付能力提出了更高的要求。 + +好了,以上这些就是我总结的企业应用DevOps能力模型和框架的步骤和原则。DevOps作为一个系统性工程,同样需要与之配套的立体化实施方法,只有将方法、实践和工具结合起来,全方位推进,才有可能获得成效。 + +为了帮助你更好地理解DevOps实施的过程,我贴了一幅经典的部署引力图。 + + + +可以看出,当软件发布的频率从100天1次进化到1天100次的时候,分支策略、测试能力、软件架构、发布策略、基础设施能力,以及数据库能力都要进行相应的改动。比如分支策略要从长线分支变成基于特性的主干开发模式,而架构也要从大的单体应用,不断解耦和服务化。在实际应用中,企业涉及的领域甚至更多,因为这些仅仅是技术层面的问题,而组织文化方面也不可或缺。 + +实践案例 + +最后,我再跟你分享一个我之前参与改进的一个客户的案例。 + +刚开始跟这个客户交流的时候,他千头万绪,抓不准重点,甚至由于组织严格划分职责边界,基本上每讲到一块内容,他就要拉相应的人过来聊,在许多人都聊完之后,项目的全貌才被拼凑出来。我相信这并不是个例,很多公司其实都是如此。 + +于是,我们引入了能力成熟度模型,并基于模型对企业现有的能力水平进行了一次全盘梳理,并初步识别出了100多个问题点和40多个差距项。下面这张图就是汇总的大盘图,当然,部分数据进行了处理。 + + + +接下来,针对识别出来的这些差距点,我逐项跟企业进行了沟通,重点在于锚定一期的改进目标和具体工作事项。在沟通过程中,我发现由于企业所处行业的特殊性,或者客观条件不具备,有些内容并非优先改进事项,于是将改进事项缩减为30个,并识别出这些改进事项的相互依赖和预期目标。比如,这个企业之前初始化一套环境需要2周左右的时间,为了加快整体交付能力,我们将改进目标定到1周以内完成。 + +好啦,有了改进目标和预期效果之后,就要分析哪些关键能力制约了交付效率的提升。还拿刚才那个例子来说,核心问题在于环境的初始化过程复杂以及审批流程冗长。其中,原有的初始化过程是研发整理一份部署需求文档,来说明应用所依赖的环境和版本信息,并且这个需求还被整合到一个40多页的文档中。运维团队根据这个文档部署,每次都很不顺利,因为软件功能迭代所依赖的环境也在不断更新,但文档写出来就再也没人维护了。所以,很多人说文档即过时,就是这个道理。 + +识别出核心能力在于自动化环境管理之后,团队决定引入基础设施即代码的实践来解决这个问题。关于具体的技术细节,我会在后面的内容中展开,这里你只需要知道,通过将写在文档中的环境配置说明,转变成配置化的信息,并维护在专门的版本控制系统中,从而使得基础环境的初始化可以在分钟级完成。 + +当然,审批环境的优化属于非技术问题,而是流程和组织方面的问题。当大家认识到这些审批在一定程度上制约了发布频率的提升,就主动改进了现有流程。针对不同的环境进行不同级别的审批,使得单次审批可以在当天完成。 + +这样优化下来,环境准备的时长大大缩短,从当初的2周缩短到了2天,改进效果非常明显。接下来,团队又识别出新的差距,锚定新的目标和预期效果,并且有针对性地补齐能力建设,走上了持续改进的阶段。 + +由此可以看出,DevOps的能力实践和能力框架模型相辅相成:能力实践定义了企业落地DevOps的路线图和主要建设顺序,能力模型可以指导支撑方法的各类实践的落地建设;能力实践时刻跟随企业价值交付的导向,而能力模型的积累和沉淀,能够让企业游刃有余地面对未来的各种挑战。 + +至于ITIL和CMMI,这些过往的框架体系自身也在跟随DevOps的大潮在持续演进,比如以流程合规为代表的ITIL最近推出了第4个版本。我们引用一下ITIL V4的指导原则,包括:关注价值、关注现状、交互式流程和反馈、协作和可视化、自动化和持续优化、极简原则和关注实践。 + +看起来是不是有点DevOps的味道呢?需要注意的是,DevOps不会彻底颠覆ITIL,只会在保证合规的前提下,尽可能地优化现有流程,将流动、反馈和持续学习改进的方法注入ITIL之中,从全局视角持续优化企业的价值交付流程。 + +总结 + +总结一下,今天我给你介绍了新技术和新思想的发展需要面对的鸿沟,而能力模型和框架是技术和思想走向成熟的标志,对于DevOps而言,也是如此。在面对诸多模型和框架的时候,企业需要立足自身,识别差异,锚定目标,关注能力,并持续改善软件的开发交付效率。DevOps的实施需要立体化的实施框架,通过模型、方法、能力和实践的相互作用,实现全方位的能力提升。 + +到此为止,我们整体介绍了DevOps的基本概念、核心价值、实施方法和路线图,帮助你建立了一套有关DevOps的宏观概念。接下来我们就会开始深入细节,尤其是针对每一项核心实践,我会介绍其背后的理念、实施步骤,以及所依赖的能力模型,手把手地帮助你真正落地DevOps。 + +思考题 + +最后,给你留一道思考题:关于CMMI、ITIL和DevOps,你觉得它们之间的关系是怎样的呢?企业该如何兼顾多套模型框架呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/05价值流分析:关于DevOps转型,我们应该从何处入手?.md b/专栏/DevOps实战笔记/05价值流分析:关于DevOps转型,我们应该从何处入手?.md new file mode 100644 index 0000000..921f096 --- /dev/null +++ b/专栏/DevOps实战笔记/05价值流分析:关于DevOps转型,我们应该从何处入手?.md @@ -0,0 +1,150 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 价值流分析:关于DevOps转型,我们应该从何处入手? + 你好,我是石雪峰。 + +关于“DevOps如何落地”的问题,向来是关注度很高的,所以,从今天开始,我会用16讲的篇幅跟你聊聊这个话题的方方面面。作为“落地实践篇”的第1讲,我先跟你聊聊DevOps转型的那些事儿。 + +相信你一定听说过持续交付吧?现在,几乎每家实施DevOps的企业都宣称他们已经有了一套持续交付平台,或者是正在建设持续交付平台。但是,如果你认为只需要做好持续交付平台就够了,那就有点OUT了。因为现在国外很多搞持续交付产品的公司,都在一门心思地做另外一件事情,这就是VSM价值流交付平台。 + +比如,Jenkins的主要维护者CloudBees公司最新推出的DevOptics产品,主打VSM功能,而经典的持续交付产品GoCD的VSM视图也一直为人所称道。那么,这个VSM究竟是个啥玩意儿呢? + +要说清楚VSM,首先就要说清楚什么是价值。简单来说,价值就是那些带给企业生存发展的核心资源,比如生产力、盈利能力、市场份额、用户满意度等。 + +VSM是Value Stream Mapping的缩写,也就是我们常说的价值流图。它起源于传统制造业的精益思想,用于分析和管理一个产品交付给用户所经历的业务流、信息流,以及各个阶段的移交过程。 + +说白了,VSM就是要说清楚在需求提出后,怎么一步步地加工原材料,进行层层的质量检查,最终将产品交付给用户的过程。通过观察完整流程中各个环节的流动效率和交付质量,识别不合理的、低效率的环节,进行优化,从而实现整体效率的提升。 + +这就好比我们在餐厅点了一道菜,这个需求提出后,要经历点单、原材料初加工(洗菜)、原材料细加工(切菜)、制作(炒菜),最终被服务员端到餐桌上的完整过程。但有时候,厨师已经把菜做好摆在窗口的小桌上了,结果负责上菜的服务员正在忙,等他(她)忙完了,才把菜端到我们的餐桌上,结果热腾腾的锅气就这么流失了。 + +对软件开发来说,也是如此。由于部门职责的划分,每个人关注的都是自己眼前的事情,这使得软件交付过程变得碎片化,以至于没有一个人能说清楚整个软件交付过程的方方面面。 + +所以,通过使用价值流图对软件交付过程进行建模,使整个过程可视化,从而识别出交付的瓶颈和各个环节之间的依赖关系,这恰恰是“DevOps三步工作法”的第一步“流动”所要解决的问题。 + +我简单介绍下“DevOps三步工作法”。它来源于《DevOps实践指南》,可以是说整本书的核心主线。高度抽象的“三步工作法”,概括了DevOps的通用实施路径。 + + +第一步:流动。通过工作可视化,限制在制品数量,并注入一系列的工程实践,从而加速从开发到运营的流动过程,实现低风险的发布。 +第二步:反馈。通过注入流动各个过程的反馈能力,使缺陷在第一时间被发现,用户和运营数据第一时间展示,从而提升组织的响应能力。 +第三步:持续学习和试验。没有任何文化和流程是天生完美的,通过团队激励学习分享,将持续改进注入日常工作,使组织不断进步。 + + +关键要素 + +你并不需要花大力气去研究生产制造业中的价值流分析到底是怎么玩的,你只要了解有关VSM的几个关键要素和核心思想就行了。那么,VSM中有哪些关键要素和概念呢?有3点是你必须要了解的。 + + +前置时间(Lead Time,简称LT)。前置时间在DevOps中是一项非常重要的指标。具体来说,它是指一个需求从提出(典型的就是创建一个需求任务)的时间点开始,一直到最终上线交付给用户为止的时间周期。这部分时间直接体现了软件开发团队的交付速率,并且可以用来计算交付吞吐量。DevOps的核心使命之一就是优化这段时长。 +增值活动时间和不增值活动时间(Value Added Time/Non-Value Added Time,简称VAT/NVAT)。在精益思想中,最重要的就是消除浪费,也就是说最大化流程中那些增值活动的时长,降低不增值活动的时长。在软件开发行业中,典型的不增值活动有很多,比如无意义的会议、需求的反复变更、开发的缺陷流向下游带来的返工等。 +完成度和准确度(% Complete/Accurate,简称%C/A)。这个指标用来表明工作的质量,也就是有多少工作因为质量不符合要求而被下游打回。这里面蕴含了大量的沟通和返工成本,从精益的视角来看,也是一种浪费。 + + +在实践中,企业往往将需求作为抓手,来串联打通各个环节,而前置时间是需求管理的自然产物,采集的难度不在于系统本身,而在于各环节的操作是否及时有效。有的团队也在使用需求管理工具,但是前置时长大多只有几秒钟。问题就在于,他们都是习惯了上线以后,一下子把任务状态直接从开始拖到最后,这样就失去了统计的意义。 + +需要注意的是,关于前置时间,有很多种解释,一般建议采用需求前置时间和开发前置时间两个指标进行衡量,关于这两个指标的定义,你可以简单了解一下。 + + +需求前置时间:从需求提出(创建任务),到完成开发、测试、上线,最终验收通过的时间周期,考查的是团队整体的交付能力,也是用户核心感知的周期。 + +开发前置时间:从需求开始开发(进入开发中状态),到完成开发、测试、上线,最终验收通过的时间周期,考查的是团队的开发能力和工程能力。 + + +对于增值活动时长,我的建议是初期不用过分精细,可以优先把等待时长统计出来,比如一个需求从准备就绪,到进入开发阶段,这段时间就是等待期。同前置时间一样,很多时候,研发的操作习惯也会影响数据的准确性,比如有的研发喜欢一次性把所有的需求都放到开发阶段,然后再一个个处理掉,这就导致很多实际的等待时间难以识别。所以,如果完全依靠人的操作来确保流程的准确性,就会存在很大的变数。通过流程和平台的结合,来驱动流程的自动化流转,这才是DevOps的正确姿势。 + +举个例子,研发开发完成发起提测后,本次关联的需求状态可以自动从“开发中”变成“待测试”状态,而不是让人手动去修改状态,这样就可以避免人为因素的影响。通过代码,流水线和需求平台绑定,从而实现状态的自动流转。 + +关于完成度和准确度,在使用VSM的初期可以暂不处理。实际上,我见过一些公司在跑通主流程之后,着手建设质量门禁相关的指标,比如研发自测通过率,这些指标就客观地反映了VSM的完成度和准确度。关于质量门禁,在专栏后面我会花一讲的时间来介绍,你一定不要错过。 + +方式 + +关于VSM的关键要素,知道这些就足够了。那么作为企业DevOps转型工作的第一步,我们要如何开展一次成功的VSM活动呢?一般来说,有2种方式。 + +1.召开一次企业内部价值流程梳理的工作坊或者会议。 + +这是我比较推荐的一种方式。对于大型企业而言,可以选取改进项目对象中某个核心的业务模块,参加会议的人员需要覆盖软件交付的所有环节,包括工具平台提供方。而且,参会人员要尽量是相对资深的,因为他们对自身所负责的业务和上下游都有比较深刻的理解,比较容易识别出问题背后的根本原因。 + +不过,这种方式的实施成本比较高。毕竟,这么多关键角色能够在同一时间坐在一起本身就比较困难。另外,面对面沟通的时候,为了给对方保留面子,大家多少都会有所保留,这样就会隐藏很多真实的问题。 + +所以,一般情况下,像团队内部的敏捷回顾会,或者是版本发布总结会,都是很合适的机会,只需要邀请部分平常不参会的成员就行了。 + +2.内部人员走访。 + +如果第1种方式难以开展,你可以退而求其次地采用第2种方式。通常来说,企业内部的DevOps转型工作都会有牵头人,甚至会成立转型小组,那么可以由这个小组中的成员对软件交付的各个环节的团队进行走访。这种方式在时间上是比较灵活的,但对走访人的要求比较高,最好是DevOps领域的专家,同时是企业内部的老员工,这样可以跟受访人有比较深入坦诚的交流。 + +无论哪种方式,你都需要识别出几个关键问题,缩小谈话范围,避免漫无目的地东拉西扯,尽量做到有效沟通。比如,可以建立一个问题列表: + + +在价值交付过程中,你所在团队的主要职责是什么? +你所在团队的上下游团队有哪些? +价值在当前环节的处理方式,时长是怎样的? +有哪些关键系统支持了价值交付工作? +是否存在等待或其他类型的浪费? +工作向下游流转后被打回的比例是多少? + + +为了方便你更好地理解这些问题,我给你提供一份测试团队的访谈示例。 + + + +通过访谈交流,我们就可以对整个软件交付过程有一个全面的认识,并根据交付中的环节、上下游关系、处理时长、识别出来的等待浪费时长等,按照VSM模型图画出当前部门的价值流交付图,以及各个阶段的典型工具,如下图所示: + + + +当然,实际交付流程相当复杂,涉及到多种角色之间的频繁互动,是对DevOps转型团队的一种考验。因为这不仅需要团队对软件开发流程有深刻的认识,还要充分了解DevOps的理念和精髓,在沟通方面还得是一把好手,能够快速地跟陌生人建立起信任关系。 + +价值 + +话说回来,为什么VSM会是企业DevOps转型的第一步呢?实际上,它的价值绝不仅限于输出了一幅价值流交付图而已。VSM具有非常丰富的价值,包括以下几个方面: + +1.看见全貌。 + +如果只关注单点问题,我们会很容易陷入局部优化的怪圈。DevOps追求的是价值流动效率最大化,也就是说,就算单点能力再强,单点之间的割裂和浪费对于价值交付效率的影响也是超乎想象的。所以,对于流程改进来说,第一步,也是最重要的一步,就是能够看见全貌,这样才能从全局视角找到可优化的瓶颈点,从而提升整体的交付效率。 + +另外,对于全局交付的建模,最终也会体现到软件持续交付流水线的建设上,因为流水线反映的就是企业客观的交付流程。这也就很好理解,为啥很多做持续交付流水线的公司,现在都延伸到了价值流交付平台上。因为这两者之间本身就存在一些共性,只不过抽象的级别和展现方式不同罢了。 + +2.识别问题。 + +在谈到企业交付效率的时候,我们很容易泛泛而谈,各种感觉满天飞,但感觉既不可度量,也不靠谱,毕竟,它更多地是依赖于个人认知。换句话说,即便交付效率提升了,也不知道是为啥提升的。 + +而VSM中的几个关键指标,也就是前置时长、增值和不增值时长,以及完成度和准确度,都是可以客观量化改进的指标。当面对这样一幅价值流图的时候,我们很容易就能识别出当前最重要的问题和改进事项。 + +3.促进沟通。 + +DevOps倡导通过团队成员间的沟通和协作来提升交付效率,但客观现实是,在很多企业中,团队成员基本都是“网友关系”。即便都在一个楼里办公,也会因为部门不同坐在不同的地方,基本上只靠即时通讯软件和邮件交流。偶尔开会的时候能见上一面,但也很少有深入的交流。如果团队之间处于你不认识我、我也不认识你的状况下,又怎么有效协作呢? + +另外,很多时候,在我们开展VSM梳理的时候,团队才第一次真正了解上下游团队的职责、工作方式,以及让他们痛苦低效的事情。这时,我们通常会设身处地地想:“只要我们多做一点点,就能大大改善兄弟团队的生存状况了。”实际上,这种同理心对打破协作的壁垒很有帮助,可以为改善团队内部文化带来非常正面的影响。实际上,这也是我推荐你用会议或者工作坊的方式推进VSM的根本原因。 + +4.驱动度量。 + +我们都认可数据的力量,让数据驱动改进。但是,面对这么庞杂的数据体系,到底哪些才是真正有价值的呢?VSM就可以回答这个问题。 + +在VSM访谈的时候,我们要问一个团队的交付周期、准确率等指标问题,如果你发现这个团队支支吾吾,只能给出模糊的回答,这时你就要注意啦,这里本身就大有问题。因为这就表示当前环节的度量指标不够清晰,或者指标过于复杂,团队不清楚关键的结果指标。 + +另外,如果数据的提取需要大量时间,比如需要采用人为统计算数的方式,那么这就体现了这个环节的平台建设能力不足,无法自动化地收集和统计数据,甚至有些关键数据还没有沉淀到数据系统中,只能通过人工本地化的方式进行管理。 + +这些都是DevOps转型的过程中需要解决的问题,可以优先处理。可以说,VSM是一场团队协作的试炼。收集VSM数据的过程本身,就需要平台间的打通和数据共享,以及自动化的推进,这有助于度量活动的开展。 + +5. 价值展现。 + +对于企业而言,任何投入都需要有产出。要实现DevOps的转型,企业需要投入大量的精力。那么如何让高层领导明白企业交付效率改善所带来的价值呢?价值流梳理就是一种很好的方式。因为VSM从价值分析而来,到价值优化而去,本身就是在回答DevOps对于企业的价值问题。 + +总结 + +在这一讲中,我给你介绍了DevOps转型的第一步——VSM价值流图,包括它的来源、3个关键要素,以及在企业中开展VSM的2种方式。最后,我介绍了VSM的5大价值,分别是看见全貌、识别问题、促进沟通、驱动度量和价值展现。 + +就像我们常说的,DevOps转型是一场没有终点的旅程,VSM的梳理也不会是一帆风顺的。因为对于企业价值交付流程的梳理,需要随着认知的深入不断地进行迭代和优化。不过,好的开始是成功的一半,当我们开始梳理VSM的时候,我们的着眼点就会慢慢调整到DevOps模式,并真正地开启我们的DevOps转型之旅。 + + + +思考题 + +最后,给你留一道思考题:你认为在公司内部梳理价值流的最大障碍是什么?在提取价值流图中的3个关键要素的数据时,你遇到过什么挑战吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/06转型之路:企业实施DevOps的常见路径和问题.md b/专栏/DevOps实战笔记/06转型之路:企业实施DevOps的常见路径和问题.md new file mode 100644 index 0000000..84a8280 --- /dev/null +++ b/专栏/DevOps实战笔记/06转型之路:企业实施DevOps的常见路径和问题.md @@ -0,0 +1,111 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 转型之路:企业实施DevOps的常见路径和问题 + 你好,我是石雪峰。今天我来跟你聊聊企业实施DevOps的常见路径和问题。 + +由于种种原因,我曾直接或者间接地参与过一些企业的DevOps转型过程,也跟很多企业的DevOps负责人聊过他们的转型故事。这些企业的转型过程并不是一帆风顺的,在最开始引入DevOps的时候,他们也面临很多普遍的问题,比如企业业务都忙不过来了,根本没有时间和精力投入转型工作之中,或者是企业内部的系统在经历几代建设之后变得非常庞大,以至于谁都不敢轻易改变。 + +但是,即便存在着种种问题,我也始终认为,DevOps转型之路应该是有迹可循的。很多企业所面临的问题并不是独一无二的,甚至可以说,很多公司都是这样一步步走过来的。所以,在转型之初,如果能够参考借鉴一条常见的路径,并且对可能遇到的问题事先做好准备,企业的转型过程会顺利很多。 + +两种轨迹 + +其实,对于企业的转型来说,DevOps也并没有什么特别之处,跟更早之前的敏捷转型一样,如果想在企业内部推行一种新的模式,无外乎有两种可行的轨迹:一种是自底向上,一种是自顶向下。 + +自底向上 + +在这种模式下,企业内部的DevOps引入和实践源自于一个小部门或者小团队,他们可能是DevOps的早期倡导者和实践者,为了解决自身团队内部,以及上下游团队交互过程中的问题,开始尝试使用DevOps模式。由于团队比较小,而且内部的相关资源调动起来相对简单,所以这种模式比较容易在局部获得效果。 + +当然,DevOps的核心在于团队间的协作,仅仅一个小团队内部的改进还算不上是DevOps转型。但是,就像刚刚提到的那样,如果企业太大以至于很难一次性改变的话,的确需要一些有识之士来推动这个过程。如果你也身处在这样一个团队之中,那么我给你的建议是采用“羽化原则”,也就是首先在自己团队内部,以及和自己团队所负责的业务范围有强依赖关系的上下游团队之间建立联系,一方面不断扩展自己团队的能力范围,另一方面,逐步模糊上下游团队的边界,由点及面地打造DevOps共同体。 + +当然,如果想让DevOps转型的效果最大化,你一定要想方设法地让高层知晓局部改进的效果,让他们认可这样的尝试,最终实现横向扩展,在企业内部逐步铺开。 + +自顶向下 + +你还记得我在专栏第2讲中提到的那家把DevOps定义为愿景OKR指标的金融企业吗?这就是典型的自顶向下模式,也就是企业高层基于自己对于行业趋势发展的把握和团队现状的了解,以行政命令的方式下达任务目标。在这种模式下,公司领导有足够的意愿来推动DevOps转型并投入资源,各个团队也有足够清晰的目标。 + +那么,这样是不是就万事大吉了呢?其实不然。在企业内部有这样一种说法:只要有目标,就一定能达成。因为公司领导对于细节的把握很难做到面面俱到,团队为了达成上层目标,总是能想到一些视角或数据来证明目标已经达成,这样的DevOps转型说不定对公司业务和团队而言反而是一种伤害。 + +举个例子,有一次,我跟一家公司的DevOps转型负责人聊天。我问道:“你们的前置时间是多久?”他回答说:“一周。”我心想,这还挺好的呀。于是就进一步追问:“这个前置时间是怎么计算的呢?”他回答说:“我们计算的是从开发开始到功能测试完成的时间。”我心想,这好像有点问题。于是,我就又问道:“那从业务方提出需求到上线发布的时间呢?”他回答:“这个啊,大概要两个月时间。”你看,难怪业务方抱怨不断呢,提个需求两个月才能上线。但是,如果仅仅看一周的开发时长,感觉是不是还不错呢? + +所以,一套客观有效的度量指标就变得非常重要,关于这个部分,我会用两讲的时间来和你详细聊聊。 + +说到这儿,不知道你发现了没有,无论企业的DevOps转型采用哪条轨迹,寻求管理层的认可和支持都是一个必选项。如果没有管理层的支持,DevOps转型之路将困难重重。因为无论在什么时代,变革一直都是一场勇敢者的游戏。对于一家成熟的企业而言,无论是组织架构、团队文化,还是工程能力、协作精神,都是长期沉淀的结果,而不是在一朝一夕间建立的。 + +除此之外,转型工作还需要持续的资源投入,这些必须借助企业内部相对比较high level的管理层的推动,才能最终达成共识并快速落地。如果你所在的公司恰好有这样一位具备前瞻性视角的高层领导,那么恭喜你,你已经获得了DevOps转型道路上至关重要的资源。 + +我之前的公司就有这样一位领导,他一直非常关心内部研发效率的提升。听说他要投入大量资源加紧进行DevOps能力建设时,我兴奋地描绘了一幅美好的图景,但当时他说了一句意味深长的话:“这个事你一旦做起来,就会发现并不容易。”后来在实施DevOps的过程中,这句话无数次得到了印证。 + +通用路径 + +因此,你看,管理层的支持只是推动DevOps转型的要素之一,在实际操作过程中,还需要很多技巧。为了帮助你少走弯路,我总结提取了一条通用路径,现在分享给你。 + +第1步:寻找合适的试点项目 + +试点项目是企业内部最初引入DevOps实践并实施改进工作的业务对象。可以说,一个合适的项目对于企业积累DevOps实践经验是至关重要的。我认为,一个合适的项目应该具备以下几个特征: + + +贴近核心业务。DevOps要以业务价值为导向。对于核心业务,管理层的关注度足够高,各项业务指标也相对比较完善,如果改进效果可以通过核心业务指标来呈现,会更有说服力。同时,核心业务的资源投入会有长期保障。毕竟,你肯定不希望DevOps转型落地项目因为业务调整而半途而废吧。 +倾向敏捷业务。敏捷性质的业务需求量和变更都比较频繁,更加容易验证DevOps改造所带来的效果。如果一个业务以稳定为主要诉求,整体处于维护阶段,变更的诉求和意愿都比较低,那么这对于DevOps而言,就不是一个好的选择。我之前在跟一家军工企业沟通的时候,了解到他们每年就固定上线两次,那么在这种情况下,你说还有没有必要搞DevOps呢? +改进意愿优先。如果公司内部的团队心比天高,完全瞧不上DevOps,觉得自己当前的流程是最完美的,那么,你再跟他们费力强调DevOps的价值,结果很可能事倍功半。相反,那些目前绩效一般般的团队都有非常强烈的改进诉求,也更加愿意配合转型工作。这时,团队的精力就可以聚焦于做事本身,而不会浪费在反复拉锯的沟通上。 + + +第2步:寻找团队痛点 + +找到合适的团队,大家一拍即合,接下来就需要识别团队的痛点了。所谓痛点,就是当前最影响团队效率的事情,同时也是改进之后可以产生最大效益的事情。 + +不知道你有没有读过管理学大师高德拉特的经典图书《目标》,他在这本书中,提出的最重要的理论就是约束理论。关于这个理论,我会在后面的内容中展开介绍,现在你只需要记得“木桶原理”就行了,即最短的木板决定了团队的容量。 + +至于如何找寻痛点,我已经在上一讲详细介绍过了。你不妨在内部试点团队中开展一次价值流分析活动,相信你会有很多意外的发现。如果你不记得具体怎么做了,可以回到第5讲复习一下。 + +第3步:快速建立初期成功 + +找到了合适的团队,也识别出了一大堆改进事项,你是不是感觉前景一片大好,准备撸起袖子加油干了呢?打住!这个时候,切记不要把面铺得太广,把战线拉得太长,这其实是DevOps转型初期最典型的一个陷阱。 + +首先,转型初期资源投入有限,难以支撑大量任务并行。其次,由于团队成员之间还没有完全建立起信任关系,那些所谓的最佳实践很容易水土不服。如果生搬硬套的话,很可能会导致大量摩擦,从而影响改进效果。最后,管理层的耐心也没有想象中那么多,如果迟迟看不到效果,很容易影响后续资源的投入。 + +所以,最关键的就是识别一个改进点,定义一个目标。比如,环境申请和准备时间过程,那么就可以定义这样一个指标:优化50%的环境准备时长。这样一来,团队的目标会更加明确,方便任务的拆解和细化,可以在几周内见到明显的成果。 + +第4步:快速展示和持续改进 + +取得阶段性的成果之后,要及时向管理层汇报,并且在团队内部进行总结。这样,一方面可以增强管理层和团队的信心,逐步加大资源投入;另一方面,也能够及时发现改进过程中的问题,在团队内部形成持续学习的氛围,激发团队成员的积极性,可以从侧面改善团队的文化。 + +当然,类似这样的案例在企业内部都极具价值。如果可以快速扩展,那么效果就不仅仅局限于小团队内部,而是会上升到公司层面,影响力就会更加明显了。 + +以上这四个步骤,基本涵盖了企业DevOps转型的通用路径。不过即便完全按照这样的路径进行转型,也很难一帆风顺。在这条路径之下,也隐藏着一些可以预见的问题,最典型的就是DevOps转型的J型曲线,这也是在2018年DevOps状态报告中的一个重点发现。 + + + +在转型之初,团队需要快速识别出主要问题,并给出解决方案。在这个阶段,整个团队的效能水平比较低,可以通过一些实践引入和工具的落地,快速提升自动化的能力和水平,从而帮助团队获得初期的成功。 + +但是,随着交付能力的提升,质量能力和技术债务的问题开始显现。比如,由于大量的手工回归测试,团队难以压缩测试周期,从而导致交付周期陷入瓶颈;项目架构的问题带来的技术债务导致集成问题增多,耦合性太强导致改动牵一发而动全身…… + +这个时候,团队开始面临选择:是继续推进呢?还是停滞不前呢?继续推进意味着团队需要分出额外的精力来加强自动化核心能力的预研和建设,比如优化构建时长、提升自动化测试覆盖率等,这些都需要长期的投入,甚至有可能会导致一段时间内团队交付能力的下降。 + +与此同时,与组织的固有流程和边界问题相关的人为因素,也会制约企业效率的进一步提升。如何让团队能够有信心减少评审和审批流程,同样依赖于质量保障体系的建设。如果团队迫于业务压力,暂缓DevOps改进工作,那就意味着DevOps难以真正落地发挥价值,很多DevOps项目就是这样“死”掉的。 + +那么说到这儿,你可能会问,这些到底应该由哪个团队来负责呢?换句话说,企业进行DevOps转型,是否需要组建一个专职负责的团队呢?如果需要的话,团队的构成又是怎样的呢? + +关于这些问题,我的建议是,在转型初期,建立一个专职的转型工作小组还是很有必要的。这个团队主要由DevOps转型关联团队的主要负责人、DevOps专家和外部咨询顾问等牵头组成,一般是各自领域的专家或者资深成员,相当于DevOps实施的“大脑”,主要负责制定DevOps转型项目计划、改进目标识别、技术方案设计和流程改造等。 + +除了核心团队,管理团队和工具团队也很重要。我挂一个转型小组的团队组成示意图供你参考。当然,DevOps所倡导的是一专多能,跨领域的人才对于企业DevOps的实施同样不可或缺,在挑选小组成员的时候,这一点你也需要注意下。 + + + +总结 + +今天,我给你介绍了企业DevOps转型的常见轨迹,分别是自底向上和自顶向下。无论采用哪种轨迹,寻求管理层的支持都至关重要。接下来,我和你一起梳理了DevOps转型的通用路径,你要注意的是,任何变革都不会是一帆风顺的,企业的DevOps转型也是如此。在经历初期的成功之后,我们很容易陷入“J型曲线”之中,如果不能突破困局,就很容易导致转型半途而废,回到起点。最后,我们一起探讨了是否需要专职的DevOps转型团队。在企业刚刚开始尝试DevOps的时候,这样的团队对于快速上手和建立团队的信心还是很有必要的。 + +无论如何,就像陆游在《冬夜读书示子聿》一诗中写的那样:“纸上得来终觉浅,绝知此事要躬行。”听过了太多实施DevOps的方法和路径,却还是无法真正享受到它的巨大效益,差的可能就是:先干再说的信心和动力吧。 + +思考题 + +你在企业中实施DevOps时,遇到过什么问题吗?你是怎么解决这个问题的呢?你是否走过一些弯路呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/07业务敏捷:帮助DevOps快速落地的源动力.md b/专栏/DevOps实战笔记/07业务敏捷:帮助DevOps快速落地的源动力.md new file mode 100644 index 0000000..a388bc0 --- /dev/null +++ b/专栏/DevOps实战笔记/07业务敏捷:帮助DevOps快速落地的源动力.md @@ -0,0 +1,148 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 业务敏捷:帮助DevOps快速落地的源动力 + 你好,我是石雪峰,今天我要跟你分享的主题是业务敏捷,那么,我们先来聊一聊,什么是业务敏捷,为什么需要业务敏捷呢? + +先试想这样一个场景:你们公司内部成立了专项小组,计划用三个月时间验证DevOps落地项目的可行性。当要跟大老板汇报这个事情的时候,作为团队的负责人,你开始发愁,怎么才能将DevOps的价值和业务价值关联起来,以表明DevOps对业务价值的拉动和贡献呢? + +如果朝着这个方向思考,就很容易钻进死胡同。因为,从来没有一种客观的证据表明,软件交付效率的提升,和公司的股价提升有什么对应关系。换句话说,软件交付效率的提升,并不能直接影响业务的价值。 + +实际上,软件交付团队一直在努力通过各种途径改善交付效率,但如果你的前提是需求都是靠谱的、有效的,那你恐怕就要失望了。因为,实际情况是,业务都是在不断的试错中摸着石头过河,抱着“宁可错杀一千,也不放过一个”的理念,各种天马行空的需求一起上阵,搞得软件交付团队疲于奔命,宝贵的研发资源都消耗在了业务的汪洋大海中。但是,这些业务究竟带来了多少价值,却很少有人能说得清楚。 + +在企业中推行DevOps的时间越长,就越会发现,开发、测试和运维团队之间的沟通障碍固然存在,但实际上,业务部门和IT部门之间的鸿沟,有时候会更加严重。试问有多少公司的业务方能够满意IT部门的交付效率,又有多少IT团队不会把矛头指向业务方呢?说白了,就一句话:如果业务不够敏捷,IT再怎么努力也没用啊!所以,我觉得很有必要跟你聊一聊有关需求的话题。 + +回到最开始的那个问题,如果DevOps不能直接提升公司的业务价值,那么为什么又要推行DevOps呢?实际上,如果你把DevOps的价值拆开业务价值和交付能力两个部分,就很好理解了。 + +在现在这个多变的时代,没人能够准确地预测需求的价值。所以,交付能力的提升,可以帮助业务以最小的成本进行试错,将新功能快速交付给用户。同时,用户和市场的情况又能够快速地反馈给业务方,从而帮助业务校准方向。而业务的敏捷能力高低,恰恰体现在对功能的设计和需求的把握上,如果不能灵活地调整需求,专注于最有价值的事情,同样会拖累交付能力,导致整体效率的下降。 + +也就是说,在这样一种快速迭代交付的模式下,业务敏捷和交付能力二者缺一不可。 + +所以,开发更少的功能,聚焦用户价值,持续快速验证,就成了产品需求管理的核心思想。 + +开发更少的功能 + +很多时候,团队面临的最大问题,就是需求太多。但实际上,很多需求一开始就没想好,甚至在设计和开发阶段还在不断变更,这就给交付团队带来了极大的困扰。所以,在把握需求质量的前提下,如何尽可能地减小需求交付批次,采用最小的实现方案,保证高优先级的需求可以快速交付,从而提升上线实验和反馈的频率,就成了最关键的问题。 + +关于需求分析,比较常见的方法就是影响地图。 + +影响地图是通过简单的“Why-Who-How-What”分析方法,实现业务目标和产品功能之间的映射关系。 + + +Why代表目标,它可以是一个核心的业务目标,也可以是一个实际的用户需求。 +Who代表影响对象,也就是通过影响谁来实现这个目标。 +How代表影响,也就是怎样影响用户以实现我们的目标。 +What代表需要交付什么样的功能,可以带来期望的影响。 + + +如果你是第一次接触影响地图,可能会听起来有点晕。没关系,我给你举个例子,来帮你理解这套分析方法。 + +比如,一个专栏希望可以在上线3个月内吸引1万名用户,那么,这个Why,也就是最核心的业务目标。为了达成这个目标,需要影响的角色包含哪些呢?其实就包含了作者、平台提供方、渠道方和最终用户。需要对他们施加哪些影响呢?对作者来说,需要快速地回答用户的问题,提升内容的质量;对平台来说,需要对专栏进行重点曝光,增加营销活动;对渠道方来说,需要提高推广力度和渠道引流;对于用户来说,增加分享有礼、免费试读和个人积分等活动。 + +那么基于以上这些影响方式,转化为最终的实际需求,就形成了一张完整的影响地图,如下图所示: + + + +你可能会问,需求这么多,优先级要怎么安排呢?别急,现在我就给你介绍一下“卡诺模型”。 + + +卡诺模型(Kano Model),是日本大师授野纪昭博士提出的一套需求分析方法,它对理解用户需求,对其进行分类和排序方面有着很深刻的洞察。 + + + + +卡诺模型将产品需求划分为五种类型: + + +兴奋型:指超乎用户想象的需求,是可遇不可求的功能。比如用户想要一个更好的功能手机,乔布斯带来了iPhone,这会给用户带来极大的满足感。 +期望型:用户的满意度会随着这类需求数量的增多而线性增长,做得越多,效果越好,但难以有质的突破。比如,一个电商平台最开始是卖书,后面逐步扩展到卖电脑、家居用品等多个类别。用户更多的线性需求被满足,满意度自然也会提升。 +必备型:这些是产品必须要有的功能,如果没有的话,会带来非常大的影响。不过有这些功能的话,也没人会夸你做得有多好,比如安全机制和风控机制等。 +无差别型:做了跟没做一样,这就是典型的无用功。比如你花了好大力气做了一个需求,但是几乎没有用户使用,这个需求就属于无差别型。 +反向型:无中生有类需求,实际上根本不具备使用条件,或者用户压根不这么想。这类需求做出来以后,通常会给用户带来很大的困扰,成为被吐槽的对象。 + + +对于五类需求来说,核心要做到3点: + + +优先规划期望型和必备型需求,将其纳入日常的交付迭代中,保持一定的交付节奏; +识别无差别型和反向型需求,这些对于用户来说并没有产生价值。如果团队对需求的分类有争议,可以进一步开展用户调研和分析。 +追求兴奋型需求,因为它会带来产品的竞争壁垒和差异化。不过,对于大公司而言,经常会遇到创新者的窘境,也就是坚持固有的商业模式,而很难真正投入资源创新和自我颠覆。这就要采用精益创业的思想,采用MVP(最小可行产品)的思路,进行快速验证,并且降低试错成本,以抓住新的机遇。 + + +在面对一大堆业务需求的时候,首先要进行识别和分类。当然,最开始时,人人都相信自己的需求是期望型,甚至是兴奋型的,这也可以理解。毕竟,这就好比公司里面所有的缺陷问题等级都是最高级一样,因为只要不提最高级,就会被其他最高级的问题淹没,而长期得不到解决。而解决的方法,就是让数据说话,为需求的价值建立反馈机制,而这里提到的价值,就是用户价值。 + +聚焦用户价值 + +“以终为始”,这四个字在精益、DevOps等很多改进的话题中经常会出现。说白了,就是要“指哪打哪,而不是打哪指哪”。产品开发方经常会问:“这个功能这么好,为什么用户就不用呢?”这就是典型的用产品功能视角看问题,嘴上喊着“用户是上帝”的口号,但实际上,自己却用上帝视角来看待具体问题。 + +如果你所在的公司也在搞敏捷转型,那你应该也听说过用户故事这个概念。需求不是需求,而是故事,这也让很多人不能理解。那么,用户故事是不是换了个马甲的需求呢? + +关于这个问题,我曾经特意请教过一位国内的敏捷前辈,他的话让我记忆犹新。他说,从表面上看,用户故事是一种采用故事来描述需求的形式,但实际上是业务敏捷性的重要手段。它改变的不仅仅是需求的书写方式,还是需求达成共识的方式。也就是说,如果所谓的敏捷转型,没有对需求进行拆解,对需求达成共识的方式进行改变,对需求的价值进行明晰,那么可能只是在做迭代开发,而跟敏捷没啥关系。 + +在以往进行需求讨论的时候,往往有两个极端:一种是一句话需求,典型的“给你一个眼神,你自己体会”的方式,反正我就要做这件事,至于为什么做、怎么做一概不管,你自己看着办;另一种是上来就深入实现细节,讨论表字段怎么设计、模块怎么划分,恨不得撸起袖子就跟研发一起写代码。 + +每次需求讨论都是一场唇枪舌剑,达成的共识都是以一方妥协为前提的,这样显然不利于团队的和谐发展。更重要的是,始终在功能层面就事论事,而不关注用户视角,这样交付出来的需求很难达到预期。 + +而用户故事则是以用户的价值为核心,圈定一种角色,表明通过什么样的活动,最终达到什么样的价值。团队在讨论需求的时候,采用一种讲故事的形式,代入到设定的用户场景之中,跟随用户的操作路径,从而达成用户的目标,解决用户的实际问题。这样做的好处在于,经过团队的共同讨论和沟通,产品、研发和测试对需求目标可以达成共识,尤其是对想要带给用户的价值达成共识。 + +在这个过程中,团队不断探索更好的实现方案和实现路径,并补充关联的用户故事,从而形成完整的待办事项。更重要的是,团队成员逐渐培养了用户和产品思维,不再拘泥于技术实现本身,增强了彼此之间的信任,积极性方面也会有所改善,从而提升整个团队的敏捷性。 + +用户故事的粒度同样需要进行拆分,拆分的原则是针对一类用户角色,交付一个完整的用户价值,也就是说用户故事不能再继续拆分的粒度。当然,在实际工作中,拆分的粒度还是以迭代周期为参考,在一个迭代周期内交付完成即可,一般建议是3~5天。检验用户故事拆分粒度是否合适,可以遵循INVEST原则。 + +那么,什么是INVEST原则呢? + + +Independent(独立的):减少用户故事之间的依赖,可以让用户故事更加灵活地验证和交付,而避免大批量交付对于业务敏捷性而言至关重要。 +Negotiable(可协商的):用户故事不应该是滴水不漏、行政命令式的,而是要抛出一个场景描述,并在需求沟通阶段不断细化完成。 +Valuable(有价值的):用户故事是以用户价值为核心的,所以每个故事都是在对用户交付价值,所以要站在用户的视角思考问题,避免像最近特别火的那句话一样:“我不要你觉得,我要我觉得。” +Estimatable(可评估的):用户故事应该可以粗略评估工作量,无论是故事点数还是时间,都可以。如果是一个预研性质的故事,则需要进一步深挖可行性,避免不知道为什么做而做。 +Small(小的):用户故事应该是最小的交付颗粒度,所以按照敏捷开发方式,无论迭代还是看板,都需要在一个交付周期内完成。 +Testable(可测试的):也就是验收条件,如果没有办法证明需求已经完成,也就没有办法进行验收和交付。 + + +持续快速验证 + +所谓用户价值,说起来多少有些虚无缥缈。的确,就像我们无法预测未来一样,需求的价值难以预测,但是需求的价值却可以定义。所以,需求价值的定义,可以理解为需求价值的度量,分为客观指标和主观2个方面。 + + +客观指标:也就是客观数据能够表明的指标,比如对电商行业来说,可以从购买流程角度,识别商品到达率、详情到达率、加入购物车率、完成订单率等等; +主观指标:也就是用户体验、用户满意度、用户推荐率等等,无法直接度量,只能通过侧面数据关联得出。 + + +但是无论是客观指标,还是主观指标,每一个需求在提出的时候,可以在这些指标中选择需求上线后的预期,并定义相关的指标。一方面加强价值导向,让产品交付更有价值的需求,另外一方面,也强调数据导向,尽量客观地展现实际结果。 + +当然,产品需求是一个复杂的体系,相互之间也会有影响和依赖,怎么从多种指标中识别出关键指标,并跟需求本身进行关联,这就是一门学问了。不过你别担心,我会在度量相关的内容中跟你详细讨论一下。 + +在很多企业中,精益创业的MVP思想已经深入人心了。面对未知的市场环境和用户需求,为了快速验证一个想法,可以通过一个最小化的产品实现来获取真实的市场反馈,并根据反馈数据修正产品目标和需求优先级,从而持续迭代产品需求。 + + + +这套思想基本上放之四海而皆准,但是在企业中实际应用的时候,也会出现跑偏的情况。比如,在需求提出的时候,产品预定义了一组指标,但是在上线后由于缺乏数据支撑,需求价值的评估变成了纯粹的主观题,比如业务方自主判断需求是达到预期,符合预期还是未达到预期。这样一来,十有八九统计出来的结果都是符合预期及以上。但问题是,这样推导出来的结果对产品方向是否真的有帮助呢? + +所以,采用客观有效的反馈机制就成了必选项。从技术层面来说,一个业务需求的背后,一般都会关联一个埋点需求。所谓埋点分析,是网站分析的一种常用的数据采集方法。设计良好的埋点功能,不仅能帮助采集用户操作行为,还能识别完整的上下文操作路径,甚至进行用户画像的聚类和分析,帮助识别不同类型用户的行为习惯。从用户层面来说,众测机制和用户反馈渠道是比较常见的两种方式,核心就是既要听用户怎么说,也要看用户怎么做。 + +总结 + +DevOps的关注点要从研发环节继续向上游延伸,一直把业务团队包括进来。也就是说,IT部门不仅仅是被动的按照业务需求交付功能,还要更加快速地提供业务数据反馈,辅助业务决策。同时,交付能力的提升也进一步降低了业务的试错成本,而业务的敏捷性也决定了研发交付的需求价值和交付节奏,通过影响地图进行需求分析,再通过卡诺模型分析需求属性和优先级,通过用户故事和整个团队达成共识,通过持续快速验证,帮助产品在正确的道路上发展前进。 + +引入业务的DevOps,就成了BizDevOps,这也是DevOps发展的一种潮流。最后,我帮你梳理下BizDevOps的核心理念: + + +对齐业务和开发目标、指标; +把握安全、合规指标; +及时对齐需求,减少无用开发; +体现DevOps的价值; +让开发团队开始接触业务,不单单是执行,调动积极性。 + + +思考题 + +你所在的企业中对于需求的价值是如何衡量的呢?是否有一套指标体系可以客观地展现需求的价值呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/08精益看板(上):精益驱动的敏捷开发方法.md b/专栏/DevOps实战笔记/08精益看板(上):精益驱动的敏捷开发方法.md new file mode 100644 index 0000000..ec1932c --- /dev/null +++ b/专栏/DevOps实战笔记/08精益看板(上):精益驱动的敏捷开发方法.md @@ -0,0 +1,120 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 精益看板(上):精益驱动的敏捷开发方法 + 你好,我是石雪峰。 + +提到敏捷开发方法,你可能会情不自禁地联想到双周迭代、每日站会、需求拆分等。的确,作为一种快速灵活、拥抱变化的研发模式,敏捷的价值已经得到了行业的普遍认可。但是,即便敏捷宣言已经发表了将近20个年头,很多公司依然挣扎在敏捷转型的道路上,各种转型失败的案例比比皆是。 + +我曾经就见过一家公司,一度在大规模推行敏捷。但是,这家公司很多所谓的敏捷教练都是项目经理兼任的,他们的思维和做事习惯还是项目制的方式。即便每天把团队站会开得有模有样,看板摆得到处都是,但从产品的交付结果来看,并没有什么显著的变化。没过多久,由于组织架构的调整,轰轰烈烈的敏捷转型项目就不了了之了。 + +这家公司虽然表面上采用了业界流行的敏捷实践,也引入了敏捷工具,但是团队并没有对敏捷的价值达成共识,团队领导兼任Scrum Master,好好的站会变成了每日工作汇报会。甚至在敏捷项目复盘会上,领导还宣称:“敏捷就是要干掉变化,我们的目标就是保证团队按照计划进行。”这种“貌合神离”的敏捷,并不能帮助企业达到灵活响应变化、快速交付价值的预期效果。 + +作为一种最广泛的敏捷框架,Scrum的很多理念和实践都深入人心,比如很多时候,迭代式开发几乎等同于跟敏捷开发。但是,Scrum对于角色的定义并不容易理解,在推行Scrum的时候,如果涉及到组织变革,就会举步维艰。 + +实际上,企业的敏捷转型并没有一条通用的路径,所用的方法也没有一定之规。今天,我就跟你聊聊另外一种主流的敏捷开发方法——精益看板。与Scrum相比,看板方法的渐进式改变过程更加容易被团队接受。我之前所在的团队通过长期实践看板方法,不仅使产品交付更加顺畅,还提升了团队的整体能力。 + +那么,这个神奇的精益看板是怎么回事呢? + +如果你之前没听说过精益看板,还是很有必要简单了解下它的背景的。其实,“看板”是一个日语词汇,泛指日常生活中随处可见的广告牌。而在生产制造系统中,看板作为一种信号卡,主要用于传递信息。很多人认为看板是丰田公司首创的,其实并非如此,比如在我之前所在的尼康公司的生产制造车间里,看板同样大量存在。 + +当然,看板之所以能广为人知,还是离不开丰田生产系统。《改变世界的机器》一书首次提到了著名的丰田准时化生产系统,而看板正是其中的核心工具。 + +简单来说,看板系统是一种拉动式的生产方式。区别于以往的大规模批量生产,看板采用按需生产的方式。也就是说,下游环节会在需要的时候,通过看板通知上游环节需要生产的工件和数量,然后上游再启动生产工作。 + +说白了,所谓拉动式生产,就是从后端消费者的需求出发,向前推导,需要什么就生产什么,而不是生产出来一大堆没人要的东西,从而达到减低库存、加速半成品流动和灵活响应变化的目的。我你分享一张有关丰田生产方式的图片,它演示了整个丰田生产方式的运作过程,你可以参考一下。 + + + + +图片来源:https://www.toyota-europe.com/world-of-toyota/this-is-toyota/toyota-production-system + + +软件开发中的看板方法,借鉴了丰田生产系统的精益思想,同样以限制在制品数量、加快价值流动为核心理念。也就是说,如果没有在制品限制的拉动系统,只能说是一个可视化系统,而不是看板系统,这一点非常重要。 + +比如,很多团队都在使用Jira,并在Jira中建立了覆盖各个开发阶段的看板,围绕它进行协作,这就是一个典型的可视化板,而非看板。那么,为什么对于看板方法而言,约束在制品数量如此重要呢? + +就像刚才提到的,加快价值流动是精益看板的核心。在软件开发中,这个价值可能是一个新功能,也可能是缺陷修复,体验优化。根据利特尔法则,我们知道:平均吞吐率=在制品数量/平均前置时间。其中,在制品数量就是当前团队并行处理的工作事项的数量。关于前置时间,你应该并不陌生,作为衡量DevOps产出效果的核心指标,它代表了从需求交付开发开始到上线发布这段时间的长度。 + +比如,1个加油站只有1台加油设备,每辆车平均加油时长是5分钟,如果有10辆车在等待,那么前置时长就是50分钟。 + +但是,这只是在假设队列中的工作都是顺序依次执行的情况下,在实际的软件开发过程中。如果一个开发人员同时处理10件事情,那么在每一件事情上真正投入的时间绝不是1/10。 + +还拿刚刚的例子来说,如果1台加油设备要给10辆车加油,这就意味着给每一辆车加油前后的动作都要重复一遍,比如取出加油枪、挪车等。这样一来,任务切换的成本会造成极大的资源消耗,导致最终加满一辆车的时长远远超过5分钟。 + +所以,在制品数量会影响前置时间,并行的任务数量越多,前置时间就会越长,也就是交付周期变长,这显然不是理想的状态。 + +不仅如此,前置时间还会影响交付质量,前置时间增长,则质量下降。这并不难理解。比如,随着工作数量的增多,复杂性也在增加,多任务切换总会导致失误。另外,人的记性没那么可靠。对于一个需求,刚开始跟产品沟通的时候就是最清晰的,但是过了一段时间就有点想不起来是怎么回事了。这个时候,如果按照自己的想法来做,很有可能因为对需求的理解不到位,最终带来大量的返工。 + +再进一步展开来看的话,软件开发工作总是伴随着各种变化和意外。如果交付周期比需求变化周期更长,那就意味着紧急任务增多。比如老板发现一个线上缺陷,必须高优先级修复,类似的紧急任务增多,就会导致在制品数量进一步增多。这样一来,团队就陷入了一个向下螺旋,这对团队的士气和交付预期都会造成非常不好的影响,以至于有些团队90%的精力都用来修复问题了,根本没时间交付需求和创新。 + +更加严重的问题是,这个时候,业务部门对IT部门的信任度就会直线下降。业务部门往往会想:“既然无法预测需求的交付实践,那好吧,我只能一次性压给你一大堆需求。”这样一来,就进一步导致了在制品数量的上升。 + +可见,一个小小的在制品数量,牵动的是整个研发团队的信心。我把刚刚提到的连环关系整理了一下,如下图所示: + + + +当然,针对刚才加油站的问题,你可能会说:“多加几台加油设备,不就完了吗?何必依赖同一台机器呢?”的确,当并行任务过多的时候,适当增加人员有助于缓解这个问题,但是前置时间的缩短是有上限的。这就好比10个人干一个月的事情,给你100个人3天做完,这就是软件工程管理的经典图书《人月神话》所讨论的故事了,我就不赘述了。这里你只需要知道,随着人数的增多,人与人之间的沟通成本会呈指数级上升。而且,从短期来看,由于内部培训、适应环境等因素,新人的加入甚至会拖慢原有的交付速度。 + +了解了精益看板的核心理念,以及约束在制品数量的重要性,也就掌握了看板实践的正确方向。那么,在团队中要如何开始一步步地实施精益看板方法呢?在实施的过程中,又有哪些常见的坑,以及应对措施呢?这正是我要重点跟你分享的问题。我把精益看板的实践方法分为了五个步骤。 + + +第一步:可视化流程; +第二步:定义清晰的规则; +第三步:限制在制品数量; +第四步:管理工作流程; +第五步:建立反馈和持续改进。 + + +今天,我先给你介绍精益看板实践方法的第一步:可视化流程。在下一讲中,我会继续跟你聊聊剩余的四步实践。 + +第一步:可视化流程 + +在看板方法中,提高价值的流动效率,快速交付用户价值是核心原则,所以第1步就是要梳理价值交付流程,通过对现有流程的建模,让流程变得可视化。关于价值流建模的话题,在专栏第5讲中我已经介绍过了,如果你不记得了,别忘记回去复习一下。 + +其实,在组织内部,无论采用什么研发模式,组织结构是怎样的,价值交付的流程一直都是存在的。所以,在最开始,我们只需要忠实客观地把这个现有流程呈现出来就可以了,而无需对现有流程进行优化和调整。也正因为如此,看板方法的引入初期给组织带来的冲击相对较小,不会因为剧烈变革引起组织的强烈不适甚至是反弹。所以,看板方法是一种相对温和的渐进式改进方法。 + +接下来,就可以根据价值流定义看板了。看板的设计没有一个标准样式,因为每个组织的价值流都不相同。对于刚刚上手看板方法的团队来说,看板的主要构成元素可以简单概括成“一列一行”。 + +1.一列。 + +这是指看板的竖向队列,是按照价值流转的各个主要阶段进行划分的,比如常见的需求、开发、测试、发布等。对识别出来的每一列进一步可以划分成“进行中”和“已完成”两种状态,这也是精益看板拉动式生产的一个显著特征。对于列的划分粒度可以很细,比如开发阶段可以进一步细分成设计、编码、自测、评审、提测等环节,或者就作为一个单独的开发环节存在。划分的标准主要有两点: + + +是否构成一个独立的环节。比如对于前后端分离的开发来说,前端开发和后端开发就是两个独立的环节,一般由不同的角色负责,这种就比较适合独立阶段。 +是否存在状态的流转和移交。看板是驱动上下游协同的信号卡,所以,我们需要重点关注存在上下游交付和评审的环节,这也是提示交付吞吐率和前置时长的关键节点。 + + +除此之外,看板的设计需要定义明确的起点和终点。对于精益看板来说,覆盖端到端的完整价值交付环节是比较理想的状态。但实际上,在刚开始推行看板方法的时候,由于组织架构、团队分工等多种因素,只能在力所能及的局部环节建立看板,比如开发测试环节,这并不是什么大问题,可以在局部优化产出效果之后,再尝试向前或向后延伸。 + +另外,即便看板可以覆盖端到端的完整流程,各个主要阶段的关注点各不相同,所以,也会采用看板分类分级的方式。对于开发看板来说,起点一般是需求准备就绪,也就是说,需求经过分析评审设计并同研发团队沟通一致准备进入开发的状态,终点可以是提测或者发布状态。流程的起点和终点同样要体现在看板设计中,以表示在局部环节的完整工作流程。 + + + +2.一行。 + +这是指看板横向的泳道。泳道用于需求与需求之间划清界限,尤其在使用物理看板的时候,经常会因为便利贴贴的位置随意等原因导致混乱,而定义泳道就可以很好地解决这个问题。比如,高速公路上都画有不同的行车道,这样车辆就可以在各自的车道内行驶。 + +当然,泳道的意义不只如此。泳道还可以按照不同维度划分。比如,有的看板设计中会加入紧急通道,用于满足紧急需求的插入。另外,非业务类的技术改进需求,也可以在独立泳道中进行。对于前后端分离的项目来说,一个需求会拆分成前端任务和后端任务,只有当前后端任务都完成之后才能进行验收。这时,就可以把前后端任务放在同一个泳道中,从而体现需求和任务的关联关系,以及任务与任务之间的依赖关系,快速识别当前阻塞交付的瓶颈点。 + +当然,看板的设计没有一定之规。在我们团队的看板中,往往还有挂起类需求区域、缺陷区域,以及技术攻关类区域等,用于管理特定的问题类型。比如对于长期挂起的需求,在一定时间之后就可以从看板中移除,毕竟,如果是几个月都没有进入任务队列的需求,可能就不是真正的需求,这些可以根据团队的实际情况灵活安排。如果你在使用Jira这样的工具,虽然没有区域的概念,但是可以通过泳道来实现,比如按照史诗任务维度区分泳道,然后新建对应区域的史诗任务就可以啦。 + + + +总结 + +今天,我给你介绍了敏捷常用的两种框架Scrum和看板。看板来源于丰田生产系统,以拉动式生产为最典型的特征。关注价值流动,加速价值流动是精益看板的核心,限制在制品数量就是核心实践,因为,在制品数量会直接影响团队的交付周期和产品质量,甚至还会影响团队之间的信任,导致团队进入向下螺旋。 + +在团队中实践精益看板,可以分为五个步骤,分别是:可视化流程、定义清晰的规则、限制在制品数量、管理工作流程和建立反馈并持续改进。今天我给你介绍了第一个步骤,也就是可视化流程,通过价值流分析将团队的交付路径可视化,建立起看板的主要结构,那么接下来就是开始应用看板了。下一讲,我会跟你聊聊其余的四个步骤,敬请期待。 + +思考题 + +最后,给你留一个思考题:你所在的公司是否也在实践敏捷呢?在敏捷转型的过程中,你遇到的最大问题、踩过的最大的坑是什么呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/09精益看板(下):精益驱动的敏捷开发方法.md b/专栏/DevOps实战笔记/09精益看板(下):精益驱动的敏捷开发方法.md new file mode 100644 index 0000000..7e4285e --- /dev/null +++ b/专栏/DevOps实战笔记/09精益看板(下):精益驱动的敏捷开发方法.md @@ -0,0 +1,138 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 精益看板(下):精益驱动的敏捷开发方法 + 你好,我是石雪峰。在上一讲中,我给你介绍了两种常见的敏捷框架:Scrum和精益看板。我重点提到,关注价值流动是精益的核心理念,限制在制品数量则是核心实践。此外,我还给你介绍了实施精益看板第一步:可视化流程。那么今天,我会继续介绍剩余的四个步骤。 + +先提一句,如果你比较关心工具使用方面的问题,我给你分享一份有关常见的工具配置和使用方面的资料,你可以点击网盘下载,提取码是mrtd。 + +好了,现在正式开始今天的内容。 + +第二步:定义清晰的规则 + +在完成可视化流程之后,看板的雏形就出来啦。接下来你要做的,就是定义清晰的规则。 + +可视化的意义不仅在于让人看得见,还在于让人看得懂。工作时间久了,我们很容易产生一种感觉,那就是沟通的成本甚至要大于工作的成本。沟通的最主要目的就是同步和传递信息,如果有一种途径可以提升信息传递的效率,那岂不是很好吗? + +而看板恰恰有一个重要的意义,就是状态可视化。团队的所有成员可以通过看板了解当前在进行的任务状态、流程中的瓶颈点、任务与任务之间的依赖关系等信息,从而自发地采取相应的活动,来保证价值交付的顺畅,使整个项目能够有条不紊地交付。 + +当然,如果想要做到这点,光靠可视化流程还远远不够,你还需要在看板的设计中融入一定的规则。这些规则可以大大地降低团队成员之间的沟通成本,统一团队的沟通语言,形成团队成员之间的默契。看板的规则包含两个方面,一个是可视化规则,另一个是显式化规则,我分别来介绍一下。 + +1.可视化规则。 + +在上一讲中,我们提到,看板中的主要构成元素是“一列一行”。实际上,看板中卡片的设计也有讲究,主要有3点。 + + +卡片的颜色:用于区分不同的任务类型,比如需求(绿色)、缺陷(红色)和改进事项(蓝色); +卡片的内容:用于显示任务的主要信息,比如电子看板ID号,需求的名称、描述、负责人、预估工作量和停留时长等; +卡片的依赖和阻塞状态:用于提起关注,比如在卡片上通过张贴不同的标志,表示当前卡片的健康程度,对于存在依赖和阻塞状态的卡片,需要团队高优先级协调和处理。这样一来,看板就显得主次分明啦。 + + +2.显式化规则。 + +看板除了要让人看得懂,还要让人会操作,这一点非常重要。尤其是在引入看板的初期,大家对这个新鲜事物还比较陌生,所以,定义清晰的操作规则就显得格外重要了。而且,在团队习惯操作之前,需要反复地强调以加深团队的印象,慢慢培养团队的习惯。当团队习惯了使用看板之后,效率就会大大提升。这些规则包括: + + +谁来负责整理和移动卡片? +什么时间点进行卡片操作? +卡片的操作步骤是怎样的?(比如,卡片每停留一天需要做一次标记。) +什么时候需要线下沟通?(比如缺陷和阻塞) +哪些标识代表当前最高优先级的任务? +看板卡片的填充规则是怎样的? +谁来保障线下和线上看板的状态一致性? + + +还是那句话,这些规则在团队内部可能一直都存在,属于心照不宣的那种类型,但是,通过看板将规则显示化,无论是对于规则的明确,新人的快速上手,还是团队内部的持续改进,都有着非常大的好处。 + +第三步:限制在制品数量 + +限制在制品数量是看板的核心,也是最难把握的一个环节,主要问题就在于把数量限制为多少比较合适的呢? + +要回答这个问题,首先要明确一点:应用看板方法只能暴露团队的现有问题,而不能解决团队的现有问题。 + +怎么理解这句话呢?这就是说,当在制品数量没有限制的时候,团队的交付时间和交付质量都会受到影响,这背后的原因可能是需求把控不到位,发布频率不够高,自动化程度不足以支撑快速交付,组织间的依赖和系统架构耦合太强……这些都是团队的固有问题,并非是使用看板方法就能统统解决掉的。 + +但看板方法的好处在于,通过降低在制品数量,可以将这些潜在的问题逐步暴露出来。比如,在极端情况下,假设我们将在制品数量设置为1,也就是说,团队当前只工作在一个需求上,按道理来说,交付的前置时间会大大缩短。但实际上,团队发现由于测试环境不就绪,导致无法验收交付,或者交付窗口过长,错过一个窗口就要再等2周的时间,到头来还是不能达到快速交付价值的目标。那么,这里的原因就在于测试环境初始化问题和交付频率的问题。这些都是团队固有的问题,只不过在没有那么高的交付节奏要求时,并没有显现出来而已。 + +所以,如果你能够摆正心态,正视团队的固有问题,你就会明白,限制在制品数量绝不仅仅是纠结一个数字这么简单的。在我看来,限制在制品数量有两个关键节点:一个是需求流入节点,一个是需求交付节点。 + +1.需求流入节点。 + +这里的关键是限制需求的流入。你可能就会说,这太不靠谱了,面对如狼似虎的业务方,研发团队只能做个小绵羊,毕竟只要你敢say“no”,业务方就直接立刻写邮件抄送老板了。 + +其实,需求的PK是个永恒的话题,敢问哪个研发经理没经历过几十、上百次需求PK的腥风血雨呢?我之前就因为同项目团队需求PK得过于激烈,一度做好了被扫地出门的准备。但是,后来我们发现,到头来大家还是一根绳子上的蚂蚱,在资源有限的前提下,一次提100个需求和提10个需求,从交付时长来看,其实并没有什么区别。所以,限制在制品数量只是换了一个方式PK需求,从之前业务方提供一大堆需求,让研发团队给排期的方式,变成了根据需求的优先级限制并行任务数量的方式。 + +当然,研发团队需要承诺业务方以最快的速度交付最高优先级的需求。如果业务方看到需求的确按照预期的时间上线甚至是提前上线,他们就会慢慢习惯这种做法,团队之间的信任也就一点点建立起来了。 + +2.需求流出节点。 + +这里的关键在于加速需求的流出。在一般的看板中,最容易出现堆积的就是待发布的状态列,因为发布活动经常要根据项目的节奏安排,由专门团队在专门的时间窗口进行。如果发现待发布需求大量堆积,这时候就有理由推动下游加快发布节奏,或者以一种更加灵活的方式进行发布。 + +毕竟,DevOps所倡导的是“You build it,you run it”的理念,这也是亚马逊公司最为经典的团队理念,意思是开发团队自己负责业务的发布,每个发布单元都是独立的,彼此没有强依赖关系,从而实现团队自制。通过建立安全发布的能力,将发布变成一件平常的事情,这才真正有助于需求价值的快速交付。说白了,要想做到业务敏捷,就得想发就发,做完一个上一个。 + +至于要将在制品数量限制为多少,我的建议是采用渐进式优化的方式。你可以从团队人数和需求的现状出发,在每个开发人员不过载的前提下,比如并行不超过三件事,根据当前处理中的任务数量进行约定,然后观察各个环节的积压情况,再通过第四步实践进行调整,最终达到一个稳定高效的状态。 + +第四步:管理工作流程 + +在专栏第5讲中,我提到过精益理论中的增值环节和不增值环节,而会议一般都会被归为不增值环节。于是,有人就会产生这样一种误解:“那是不是所有不增值的环节都要被消除掉,以达到最高的流动效率呢?” + +如果这么想的话,那是不是类似项目经理这样的角色也就不需要了呢?毕竟,他们看起来并没有直接参与到软件开发的活动中。显然,这是很片面的想法。实际上,在精益的不增值活动中,还可以进一步划分出必要不增值活动和不必要不增值活动,有些会议虽然不直接增值,但却是非常必要的。所以,我们不能简单地认为精益就等于不开会、不审批。 + +看板方法同样根植于组织的日常活动之中,所以,同样需要配套的管理流程,来保障看板机制的顺畅运转。在看板方法中,常见的有三种会议,分别是每日站会、队列填充会议和发布规划会议。 + +1.每日站会。 + +接触过敏捷的团队应该都非常熟悉每日站会。但是,与Scrum方法的“夺命三连问”(昨天做了什么?今天计划做什么?有什么困难或者阻塞?)相比,看板方法的站会则略有不同。因为,我们在第二步制定了清晰的规则,团队的现状已经清晰可见,只需要同步下重点任务就可以了。看板方法更加关注两点: + + +待交付的任务。看板追求价值的快速流动,所以,对于在交付环节阻塞的任务,你要重点关注是什么原因导致的。 +紧急、缺陷、阻塞和长期没有更新的任务。这些任务在规则中也有相应的定义,如果出现了这些问题,团队需要最高优先级进行处理。这里有一个小技巧,就是当卡片放置在看板之中时,每停留一天,卡片的负责人就会手动增加一个小圆点标记,通过这个标记的数量,就可以看出哪些任务已经停留了太长时间。而对于使用电子看板的团队来说,这就更加简单了。比如,Jira本身就支持停留时长的显示。当然,你也可以自建过滤器,按照停留时长排序,重点关注Top问题的情况。 + + +每日站会要尽量保持高效,对于一些存在争议的问题,或者是技术细节的讨论,可以放在会后单独进行。同时,会议的组织者也要尽量观察每日站会的执行效果,如果出现停顿或者不顺畅的情况,那就意味着规则方面有优化空间。比如,如果每日站会依赖一名组织者来驱动整个过程,只要这个人不发问,团队就不说话,这就说明规则不够清晰。另外,对于站会中迸发出来的一些灵感或者好点子,可以都记录下来,作为优化事项跟进解决。 + +2.队列填充会议。 + +队列填充会议的目标有两点:一个是对任务的优先级进行排序,一个是展示需求开发的状态。一般情况下,队列填充会议需要业务方、技术方和产品项目负责人参与进来,对需求的优先级达成一致,并填充到看板的就绪状态中。 + +在初期,我建议在每周固定时间举行会议,这样有助于整个团队共享需求交付节奏,了解需求交付状态,帮助业务方和技术方建立良好的合作和信任关系,在会议上也可以针对在制品数量进行讨论和调整。 + +3.发布规划会议。 + +发布规划会议以最终交付为目标。一般情况下,项目的交付节奏会影响队列填充的节奏,二者最好保持同步。另外,随着部署和发布的分离,研发团队越来越趋近于持续开发持续部署,而发布由业务方统一规划把控,发布规划会议有助于研发团队和业务方的信息同步,从而实现按节奏部署和按需发布的理想状态。 + +第五步:建立反馈和持续改进 + +实际上,无论是DevOps还是精益看板,任何一套方法框架的终点都是持续改进。因为,作为一种新的研发思想和研发方法,只有结合业务实际,并根据自身的情况持续优化规则、节奏、工具和流程,才能更好地为业务服务。关于这部分的内容,我会在度量和持续改进中进行详细介绍。你要始终记得,没有天然完美的解决方案,只有持续优化的解决方案。看板方法的实践是一个循序渐进的过程。为此看板创始人David J Anderson总结了看板方法的成熟度模型,用于指导中大型团队实践看板方法,如下图所示: + + + + +图片来源:http://leankanban.com/kmm/ + + +这个模型将看板的成熟度划分为7个等级。除此之外,它还针对每一级的每一个实践维度,给出了具体的能力参考,对看板方法的实施有非常强的指导作用,可以用于对标现有的能力图谱。 + +如果你想获取更加详细的信息,可以点击在这一讲的开头我分享给你的链接,作为补充参考。 + +总结 + +好啦,回顾一下,在这两讲中,我先给你介绍了看板的背景和起源。看板来源于生产制造行业,是一种常用的生产信号传递方式,同时,看板也是以丰田生产系统为代表的精益生产的核心工具,也就是以拉动为核心的按需生产方式。 + +接着,我跟你探讨了为什么要限制在制品数量,以及背后的理念,也就是缩短交付前置时长,以快速、高质量、可预期的交付方式,在业务方和IT部门之间建立起合作信任关系。 + +除此之外,我还给你介绍了精益看板的5个核心实践,包括:可视化流程,定义清晰的规则,约束在制品数量,管理工作流程和建立反馈持续改进。掌握了这些,你就获取了开启精益看板之旅的钥匙。在真正进行实践之后,相信你会有更多的收获和感悟。 + +需要提醒你的是,僵化的实践方法,脱离对人的关注,可以说是影响精益看板在组织内落地的最大障碍。就像《丰田之道》中提到的那样,持续改进和对人的尊重,才是一切改进方法的终极坐标,这一点是我们必须要注意的。 + +思考题 + +最后,给你留一个思考题:如果让你现在开始在团队中推行精益看板方法,你觉得有哪些挑战吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/10配置管理:最容易被忽视的DevOps工程实践基础.md b/专栏/DevOps实战笔记/10配置管理:最容易被忽视的DevOps工程实践基础.md new file mode 100644 index 0000000..230e532 --- /dev/null +++ b/专栏/DevOps实战笔记/10配置管理:最容易被忽视的DevOps工程实践基础.md @@ -0,0 +1,159 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 配置管理:最容易被忽视的DevOps工程实践基础 + 你好,我是石雪峰。从今天开始,专栏正式进入了工程实践的部分。在DevOps的体系中,工程实践所占的比重非常大,而且和我们的日常工作息息相关。正因为如此,DevOps包含了大量的工程实践,很多我们都耳熟能详,比如持续集成、自动化测试、自动化部署等等,这些基本上是实践DevOps的必选项。 + +可是,还有一些实践常常被人们所忽视,但这并不代表它们已经被淘汰或者是不那么重要了。恰恰相反,它们同样是DevOps能够发挥价值的根基,配置管理(Configuration Management)就是其中之一。它的理念在软件开发过程中无处不在,可以说是整个DevOps工程实践的基础。所以今天我们就来聊一聊配置管理。 + +说了这么多,那软件配置管理到底是个啥呢? + +熟悉运维的同学可能会说,不就是类似Ansible、Saltstack的环境配置管理工具吗?还有人会说,CMDB配置管理数据库也是配置管理吧?这些说法都没错。配置管理这个概念在软件开发领域应用得非常普遍,几乎可以说无处不在,但是刚刚提到的这些概念,都是细分领域内的局部定义。 + +我今天要讲到的配置管理,是一个宏观的概念,是站在软件交付全生命周期的视角,对整个开发过程进行规范管理,控制变更过程,让协作更加顺畅,确保整个交付过程的完整、一致和可追溯。 + +看到这里,我估计你可能已经晕掉了。的确,配置管理的理论体系非常庞大。但是没关系,你只需要把四个核心理念记在心中就足够了。这四个理念分别是:版本变更标准化,将一切纳入版本控制,全流程可追溯和单一可信数据源。 + +1. 版本变更标准化 + +版本控制是配置管理中的一个非常核心的概念,而对于软件来说,最核心的资产就是源代码。现在很多公司都在使用类似Git、SVN之类的工具管理源代码,这些工具其实都是版本控制系统。版本描述了软件交付产物的状态,可以说,从第一行软件代码写下开始,版本就已经存在了。 + +现代软件开发越来越复杂,往往需要多人协作,所以,如何管理每个开发者的版本,并把它们有效地集成到一起,就成了一个难题。实际上,版本控制系统就是为了解决这个问题的。试想一下,如果没有这么一套系统的话,所有代码都在本地,不要说其他人了,就连自己都会搞不清楚哪个是最新代码。那么,当所有人的代码集成到一起的时候,那该是多么混乱啊! + +不仅如此,如果线上发生了严重问题,也找不到对应的历史版本,只能直接把最新的代码发布上去,简直就是灾难。 + +配置管理中的另一个核心概念是变更。我们对软件做的任何改变都可以称之为一次变更,比如一个需求,一行代码,甚至是一个环境配置。版本来源于变更。对于变更而言,核心就是要记录:谁,在什么时间,做了什么改动,具体改了哪些内容,又是谁批准的。 + +这样看来,好像也没什么复杂的,因为现代版本控制系统基本都具备记录变更的功能。那么,是不是只要使用了版本控制系统,就做到变更管理了呢? + +的确,版本控制系统的出现,大大简化了管理变更的成本,至少是不用人工记录了。但是,从另一方面来看,用好版本控制系统也需要有一套规则和行为规范。 + +比如,版本控制系统需要打通公司的统一认证系统,也就是任何人想要访问版本控制系统,都需要经过公司统一登录的认证。同时,在使用Git的时候,你需要正确配置本地信息,尤其是用户名和邮箱信息,这样才能在提交时生成完整的用户信息。另外,系统本身也需要增加相关的校验机制,避免由于员工配置错误导致无效信息被提交入库。 + +改动说明一般就是版本控制系统的提交记录,一个完整的提交记录应该至少包括以下几个方面的内容: + + +提交概要信息:简明扼要地用一句话说明这个改动实现了哪些功能,修复了哪些问题; +提交详细信息:详细说明改动的细节和改动方式,是否有潜在的风险和遗留问题等; +提交关联需求:是哪次变更导致的这次提交修改,还需要添加上游系统编号以关联提交和原始变更。 + + +这些改动应该遵循一种标准化的格式,并且有相关的格式说明和书写方式,比如有哪些关键字,每一行的长度,变更编号的区隔是使用逗号、空格还是分号等等。如果按照这个标准来书写每次的变更记录,其实成本还是很高的,更不要说使用英文来书写的话,英文的表达方式和内容展现形式又是一个难题。 + +我跟你分享一个极品的提交注释,你可以参考一下。 + + +switch to Flask-XML-RPC dependency + +CR: PBX-2222 + +The Flask-XML-RPC-Re fork has Python 3 support, but it has a couple + +other problems. + + +test suite does not pass + +latest code is not tagged + +uncompiled source code is not distributed via PyPI + + +The Flask-XML-RPC module is essentially dead upstream, but it is + +packaged in EPEL 7 and Fedora. This module will get us far enough to- +the + +point that we can complete phase one for this project. + +When we care about Python 3, we can drop XML-RPC entirely and get the + +service consumers to switch to a REST API instead. + +(Note, with this change, the Travis CI tests will fail for Python 3.- +The + +solution is to drop XML-RPC support.) + + +这时,肯定有人会问,花这么大力气做这个事情,会不会有点得不偿失呢?从局部来看,的确如此。但是,换个角度想,当其他人看到你的改动,或者是评审你的代码的时候,如果通过提交记录就能清晰地了解你的意图,而不是一脸蒙地把你叫过来,让你再讲一遍,这样节约的时间比当时你书写提交记录的时间要多得多。 + +所以你看,一套标准化的规则和行为习惯,可以降低协作过程中的沟通成本,一次性把事情做对,这也是标准和规范的重要意义。 + +当然,如果标准化流程要完全依靠人的自觉性来保障,那就太不靠谱了。毕竟,人总是容易犯错的,会影响到标准的执行效果。所以,当团队内部经过不断磨合,逐步形成一套规范之后,最好还是用自动化的手段保障流程的标准化。 + +这样做的好处有两点:一方面,可以降低人为因素的影响,如果你不按标准来,就会寸步难行,也减少了人为钻空子的可能性。比如,有时候因为懒,每次提交都写同样一个需求变更号,这样的确满足了标准化的要求,但是却产生了大量无效数据。这时候,你就可以适当增加一些校验机制,比如只允许添加你名下的变更,或者是只允许开放状态的变更号等等。另一方面,在标准化之后,很多重复性的工作就可以自动化完成,标准化的信息也方便计算机分析提取,这样就可以提升流程的流转效率。 + +可以说,标准化是自动化的前提,自动化又是DevOps最核心的实践。这样看来,说配置管理是DevOps工程实践的基础就一点不为过了吧。 + +2. 将一切纳入版本控制 + +如果说,今天这一讲的内容,你只需要记住一句话,那就是将一切纳入版本控制,这是配置管理的金科玉律。你可能会问,需要将什么样的内容纳入版本控制呢?我会毫不犹豫地回答你:“一切都需要!”比如软件源代码、配置文件、测试编译脚本、流水线配置、环境配置、数据库变更等等,你能想到的一切,皆有版本,皆要被纳入管控。 + +这是因为,软件本身就是一个复杂的集合体,任何变更都可能带来问题,所以,全程版本控制赋予了我们全流程追溯的能力,并且可以快速回退到某个时间点的版本状态,这对于定位和修复问题是非常重要的。 + +之前,我就遇到过一个问题。一个iOS应用发灰度版本的时候一切正常,但是正式版本就遇到了无法下载的情况。当时因为临近上线,为了查这个问题,可以说是全员上阵,团队甚至开始互相抱怨,研发说代码没有变化,所以是运维的问题;运维说环境没动过,所以是研发的问题。结果到最后才发现,这是由于一个工具版本升级,某个参数的默认值从“关闭”变成了“打开”导致的。 + +所以你看,如果对所有内容都纳入版本控制,快速对比两个版本,列出差异点,那么,解决这种问题也就是分分钟的事情,大不了就把所有改动都还原回去。 + +纳入版本控制的价值不止如此。实际上,很多DevOps实践都是基于版本控制来实现的,比如,环境管理方面推荐采用基础设施即代码的方式管理环境,也就是说把用代码化的方式描述复杂的环境配置,同时把它纳入版本控制系统中。这样一来,任何环境变更都可以像提交代码一样来完成,不仅变更的内容一目了然,还可以很轻松地实现自动化。把原本复杂的事情简单化,每一个人都可以完成环境变更。 + +这样一来,开发和运维之间的鸿沟就被逐渐抹平了,DevOps的真谛也是如此。所以,现在行业内流行的“什么什么即代码”,其背后的核心都是版本控制。 + +不过,这里我需要澄清一下,纳入版本控制并不等同于把所有内容都放到Git中管理。有些时候,我们很容易把能力和工具混为一谈。Git只是一种流行的版本控制系统而已,而这里强调的其实是一种能力,工具只是能力的载体。比如,Git本身不擅长管理大文件,那么可以把这些大文件放到Artifactory或者其他自建平台上进行管理。 + +对自建系统来说,实现版本控制的方式有很多种,比如,可以针对每次变更,插入一组新的数据,或者直接复用Git这种比较成熟的工具作为后台。唯一不变的要求就是,无论使用什么样的系统和工具,都需要把版本控制的能力考虑进去。 + +另外,在实践将一切纳入版本控制的时候,你可以参考一条小原则。如果你不确定是否需要纳入版本控制,有一个简单的判断方法就是:如果这个产物可以通过其他产物来重现,那么就可以作为制品管理,而无需纳入版本控制。 + +举个例子,软件包可以通过源代码和工具重新打包生成,那么,代码、工具和打包环境就需要纳入管控,而生成的软件包可以作为制品;软件的测试报告如果可以通过测试管理平台重新自动化生成,那么同样可以将其视为制品,但前提是,测试管理平台可以针对每一个版本重新生成测试报告。 + +3. 全流程可追溯 + +对传统行业来说,全流程可追溯的能力从来不是可选项,而是必选项。像航空航天、企业制造、金融行业等,对变更的管控都是非常严谨的,一旦出现问题,就要追溯当时的全部数据,像软件源代码、测试报告、运行环境等等。如果由于缺乏管理,难以提供证据证明基于当时的客观情况已经做了充分的验证,就会面临巨额的罚款和赔偿,这可不是闹着玩的事情。像最近流行的区块链技术,除了发币以外,最典型的场景也是全流程可追溯。所以说,技术可以日新月异,但很多理念都是长久不变的。 + +对于配置管理来说,除了追溯能力以外,还有一个重要的价值,就是记录关联和依赖关系。怎么理解这句话呢?我先提个问题,在你的公司里面,针对任意一个需求,你们是否能够快速识别出它所关联的代码、版本、测试案例、上线记录、缺陷信息、用户反馈信息和上线监控数据呢?对于任意一个应用,是否可以识别出它所依赖的环境,中间件,上下游存在调用关系的系统、服务和数据呢? + +如果你的回答是“yes”,那么恭喜你,你们公司做得非常好。不过,绝大多数公司都是无法做到这一点的。因为这不仅需要系统与系统之间的关联打通、数据联动,也涉及到一整套完整的管理机制。 + +DevOps非常强调价值导向,强调团队内部共享目标,这个目标其实就是业务目标。但实际情况是,业务所关注的维度,和开发、测试、运维所关注的维度都各不相同。业务关心的是提出的需求有没有上线,而开发关心的是这个需求的代码有没有集成,运维关心的是包含这个代码的版本是否上线。所以,如果不能把这些信息串联打通,就没有真正做到全流程可追溯。 + +关于这个问题,我给你的建议是把握源头,建立主线。所谓源头,对于软件开发而言,最原始的就是需求,所有的变更都来源于需求。所以,首先要统一管理需求,无论是开发需求、测试需求还是运维需求。 + +接下来,要以需求作为抓手,去关联下游环节,打通数据,这需要系统能力的支持,也需要规则的支持。比如,每次变更都要强制关联需求编号,针对不同的需求等级定义差异化流程,这样既可以减少无意义的审批环节,给予团队一定的灵活性,也达到了全流程管控的目标。这是一个比较漫长的过程,但不积跬步,无以至千里,DevOps也需要一步一个脚印地建设才行。 + +4. 单一可信数据源 + +最后,我想单独谈谈单一可信数据源。很多人不理解这是什么东西,我举个例子你就明白了。 + +有一个网络热词叫作“官宣”,也就是官方宣布的意思。一般情况下,官宣的信息都是板上钉钉的,可信度非常高。可问题是,如果有多个官宣的渠道,信息还都不一样,你怎么知道要相信哪一个呢?这就是单一可信数据源的意义。 + +试想一下,我们花了很大力气来建设版本控制的能力,但如果数据源本身不可靠,缺乏统一管控,那岂不是白忙一场吗?所以,对于软件开发来说,必须要有统一的管控: + + +对于代码来说,要有统一的版本控制系统,不能代码满天飞; +对于版本来说,要有统一的渠道,不能让人随便本地打个包就传到线上去了; +对于开发依赖的组件来说,要有统一的源头,不能让来路不明的组件直接集成到系统中。这不仅对于安全管控来说至关重要,对于企业内部的信息一致性也是不可或缺的。 + + +同时,单一可信数据源也要能覆盖企业内部元数据的管控。比如,企业内部经常出现这种情况,同样是应用,在A部门的系统中叫作123,在B部门的系统中叫作ABC,在打通两边平台的时候,这就相当于“鸡同鸭讲”,完全对不上。再比如,信息安全团队维护了一套应用列表,但实际上,在业务系统中,很多应用都已经下线且不再维护了,这样一来,不仅会造成资源浪费,还伴随着非常大的安全风险。 + +很多时候,类似的这些问题都是因为缺乏统一的顶层规划和设计导致的,这一点,在建立配置管理能力的时候请你格外关注一下。 + +总结 + +今天我给你介绍了DevOps工程实践的基础配置管理,以及配置管理的四大理念,分别是版本变更标准化、将一切纳入版本控制、全流程可追溯和单一可信数据源,希望能帮你掌握配置管理的全局概念。 + +虽然配置管理看起来并不起眼,但是就像那句经典的话一样:“岁月静好,是因为有人替你负重前行。” 对于任何一家企业来说,信息过载都是常态,而配置管理的最大价值正是将信息序列化,对信息进行有效的整理、归类、记录和关联。而软件开发标准和有序,也是协同效率提升的源头,所以,配置管理的重要性再怎么强调都不为过。 + +思考题 + +你在企业中遇到过哪些配置管理方面的难题呢?你们的配置管理体系又是如何建立的呢?你遇到过因为缺乏单一可信数据源而导致“鸡同鸭讲”的有趣故事吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/11分支策略:让研发高效协作的关键要素.md b/专栏/DevOps实战笔记/11分支策略:让研发高效协作的关键要素.md new file mode 100644 index 0000000..ec915db --- /dev/null +++ b/专栏/DevOps实战笔记/11分支策略:让研发高效协作的关键要素.md @@ -0,0 +1,191 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 分支策略:让研发高效协作的关键要素 + 你好,我是石雪峰。今天我们来聊聊分支策略。 + +在上一讲中,我反复强调过一个理念,那就是将一切纳入版本控制。其实,现代版本控制系统不仅可以记录版本和变更记录,还有一个非常重要的功能,那就是分支管理。 + +现代软件开发讲究效率和质量,大多依赖于多团队间的协作来实现。对于一些大型软件来说,即便是百人团队规模的协作也没什么奇怪的。如果软件架构没有良好的拆分,很有可能出现几百人在一个代码仓库里面工作的情况。这时,分支管理就成了不可或缺的功能。 + +一方面,分支可以隔离不同开发人员的改动,给他们提供一个相对独立的空间,让他们能够完成自己的开发任务。另一方面,整个团队也需要根据软件的发布节奏来完成代码提交、审核、集成、测试等工作。 + +所以,如果说多人软件协作项目中有一个灵魂的话,我认为,这个灵魂就是分支策略。可以说,分支策略就是软件协作模式和发布模式的风向标。选择一种符合DevOps开发模式的分支策略,对于DevOps的实践落地也会大有帮助。 + +今天,我会给你拆解一些常见的分支策略,帮你了解这些策略的核心流程、优缺点,以及适用的场景和案例。 + +主干开发,分支发布 + + + + +图片来源:- +https://paulhammant.com/2013/12/04/what_is_your_branching_model/ + + +在这种分支策略下,开发团队共享一条主干分支,所有的代码都直接提交到主干分支上,主干分支就相当于是一个代码的全量合集。在软件版本发布之前,会基于主干拉出一条以发布为目的的短分支。 + +你需要注意一下这句话里的两个关键词: + + +以发布为目的。这条分支存在的意义不是开发新功能,而是对现有功能进行验收,并在达到一定的质量标准后对外发布。一般来说,新功能不会基于这条分支提交,只有一些Bugfix会集成进来。所以,对于这种发布分支会有比较严格的权限管控。毕竟,谁都不想让那些乱七八糟、未经验证的功能跑到发布分支上来。 +短分支。这条发布分支一般不会存在太长时间,只要经过回归验证,满足发布标准后,就可以直接对外发布,这时,这条分支的历史使命也就结束了。除非上线之后发现一些紧急问题需要修复,才会继续在这条分支上修改验证,并将改动同步回主干分支。所以,只要在主干分支和发布分支并行存在的时间段内,所有发布分支上的改动都需要同步回主分支,这也是我们不希望这条分支存在时间过长的原因,因为这会导致重复工作量的线性累计。 + + +对于以版本节奏驱动的软件项目来说,这种分支策略非常常见,比如客户端产品,或者是那种需要在客户终端升级的智能硬件产品,像智能手机、智能电视等。 + +早在很多年前,乐视刚刚推出超级电视的时候,喊过一个口号叫“周周更新”。要知道,当时智能电视产品的更新频率普遍是几个月一次。 + +其实,如果你了解分支策略的话,你就会发现,“周周更新”的背后也没什么特别的。当时,我所在的团队恰好负责智能电视产品线的分支策略,采用的就是主干开发、分支发布的模式。其中基于主干的发布分支提前两周拉出,然后在发布分支上进行回归验证,并在第一周发出体验版本给喜欢尝鲜的用户试用。然后,根据用户反馈和后台收集的问题进行进一步修正,并最终发布一个稳定版本。我把当时的分支策略图分享给你,你可以参考一下。 + + + +这种模式的优势有三个: + + +对于研发团队来说,只有一条主线分支,不需要在多条分支间切换。 +在发布分支拉出之后,主干分支依然处于可集成状态,研发节奏可以保持在一个相对平稳的状态。 +发布分支一般以版本号命名,清晰易懂,线上哪个版本出了问题,就在哪个分支上修复。 + + +不过,这种模式也存在着缺点和挑战: + + +它对主线分支的质量要求很高。如果主线分支出了问题,就会block所有开发团队的工作。对于一个百人团队、每日千次的提交规模来说,如果不对提交加以约束,这种情况的发生频率就会非常高。 +它对团队协作的节奏要求很高。如果主线分支上的功能没有及时合入,但是业务方又坚持要在指定版本上线这个功能,这就会导致发布分支“难产”。甚至有些时候,会被迫允许部分未开发完成的功能在发布分支上继续开发,这会给发布分支的质量和稳定性造成很大的挑战。 +在主线和发布分支并存期间,有可能会导致两边提交不同步的情况。比如,发布分支修复了一个线上问题,但是由于没有同步回主线,导致同样的问题在下一个版本中复现。测试出来的问题越多,这种情况出现的概率就越大,更不要说多版本并存的情况了。 + + +这些问题的解决方法包括以下几点: + + +建立提交的准入门禁,不允许不符合质量标准的代码合入主线。 +采用版本火车的方式,加快版本的迭代速度,功能“持票上车”,如果跟不上这个版本就随下个版本上线。另外,可以采用功能开关、热修复等手段,打破版本发布的固定节奏,以一种更加灵活的方式对外发布。 +通过自动化手段扫描主线和发布分支的差异,建立一种规则。比如Hotfix必须主线和发布分支同时提交,或者发布分支上线后,由专人反向同步等。 + + +分支开发,主干发布 + + + + +图片来源:https://paulhammant.com/2013/12/04/what_is_your_branching_model/ + + +当开发接到一个任务后,会基于主干拉出一条特性开发分支,在特性分支上完成功能开发验证之后,通过Merge request或者Pull request的方式发起合并请求,在评审通过后合入主干,并在主干完成功能的回归测试。开源社区流行的GitHub模式其实就是属于这种。 + +根据特性和团队的实际情况,还可以进一步细分为两种情况: + + +每条特性分支以特性编号或需求编号命名,在这条分支上,只完成一个功能的开发; +以开发模块为单位,拉出一条长线的特性分支,并在这条分支上进行开发协作。 + + +两者的区别就在于特性分支存活的周期,拉出时间越长,跟主干分支的差异就越大,分支合并回去的冲突也就越大。所以,对于长线模式来说,要么是模块拆分得比较清晰,不会有其他人动这块功能,要么就是保持同主干的频繁同步。随着需求拆分粒度的变小,短分支的方式其实更合适。 + +这种模式下的优势也有两点: + + +分支开发相对比较独立,不会因为并行导致互相干扰。同时,特性只有在开发完成并验收通过后才会合入主干,对主干分支的质量起到了保护作用; +随着特性分支的流行,在这种模式下,分支成了特性天然的载体。一个特性所关联的所有代码可以保存在一条特性分支上,这为以特性为粒度进行发布的模式来说提供了一种新的可能性。也就是说,如果你想要发布哪个特性,就可以直接将特性分支合并到发布分支上,这就让某一个特性变得“可上可下”,而不是混在一大堆代码当中,想拆也拆不出来。 + + +关于这种特性分支发布的方法,我给你提供一份参考资料,你可以了解一下。不过,我想提醒你的是,特性发布虽然看起来很好,但是有三个前置条件:第一个是特性拆分得足够小,第二是有强大的测试环境作支撑,可以满足灵活的特性组合验证需求,第三是要有一套自动化的特性管理工具。 + +当然,分支开发、主干发布的模式也有缺点和挑战: + + +非常考验团队特性拆分的能力。如果一个特性过大,会导致大量并行开发的分支存在,分支的集成周期拉长,潜在的冲突也会增多。另外,分支长期存在也会造成跟主线差异过大的问题。所以,特性的粒度和分支存活的周期是关键要素。根据经验来看,分支存活的周期一般不要超过一周。 +对特性分支的命名规范要求很高。由于大量特性分支的拉出,整个代码仓库会显得非常乱。面对一大堆分支,谁也说不清到底哪个还活着,哪个已经没用了。所以,如果能够跟变更管理系统打通,自动化创建分支就最好了。 +特性分支的原子性和完整性,保证一个特性的关联改动需要提交到一条分支上,而不是到处都是。同时,特性分支上的提交也需要尽量清晰,典型的就是原子性提交。 + + +我之前所在的一个团队就是采用的这种分支策略。有一次,我为了分支策略的执行细节跟研发负责人争得面红耳赤,争论的核心点就是:当特性分支合并回主干的时候,到底要不要对特性分支上的代码进行整理? + +只要做过开发,你就会知道,很少有人能只用一次提交就把代码写对的,因为总是会有这样那样的问题,导致特性分支上的提交乱七八糟。 + +在合入主干的时候,为了保证代码的原子性,其实是有机会对代码提交进行重新编排的,Git在这方面可以说非常强大。如果你熟练掌握git rebase命令,就可以快速合并分拆提交,将每一个提交整理为有意义的原子性的提交,再合入主干,或者干脆把特性分支上的改动压合成一个提交。当然,这样做的代价就是不断重写特性分支的历史,给研发团队带来额外的工作量。我跟你分享一些常见的命令。 + + +比如:当前特性分支feature1,主分支master,那么,你可以执行以下命令整理提交历史: + +git checkout feature1 && git fetch origin && git rebase -i origin/master + + + +最常见的操作包括:- +p:选择提交;- +r:更新提交的注释信息;- +e:编辑提交,可以将一个提交拆分成多个;- +s:压合提交,将多个提交合并成一个;- +f:类似压合提交,但是放弃这个提交的注释信息,直接使用合并提交的注释信息;- +当然,在git rebase的交互界面中,你也可以调整提交的顺序,比如将特性功能和关联的Bugfix整合在一起。 + + +需要提醒你的是,分支策略代表了研发团队的行为准则,每个团队都需要磨合出一套适合自己的模式来。 + +主干开发,主干发布 + + + + +图片来源:https://paulhammant.com/2013/12/04/what_is_your_branching_model/ + + +今天给你介绍的第三种分支策略是主干开发、主干发布。武学高手修炼到一定境界之后,往往会发现大道至简,分支策略也是如此。所以,第三种分支策略可以简单理解为没有策略。团队只有一条分支,开发人员的代码改动都直接集成到这条主干分支上,同时,软件的发布也基于这条主干分支进行。 + +对于持续交付而言,最理想的情况就是,每一次提交都能经历一系列的自动化环境并部署到生产环境上面,而这种模式距离这个目标就更近了一点。 + +可想而知,如果想要做到主干分支在任何时间都处于可发布状态,那么,这就对每一次提交的代码质量要求非常高。 + +在一些追求工程卓越的公司里,你要提交一行代码,就必须经历“九九八十一难”,因为有一系列的自动化验收手段,还有极为严格的代码评审机制来保证你的提交不会把主干分支搞挂掉。当然,即便如此,问题也是难以避免的,那我们该怎么做呢?这里我就要给你介绍下Facebook的分支策略演进案例了。 + +Facebook最早采用的也是主干开发、分支发布的策略,每天固定发布两次。但是,随着业务发展的压力增大,团队对于发布频率有了更高的要求,这种分支策略已经无法满足每天多次发布的需求了。于是,他们开始着手改变分支策略,从主干开发、分支发布的模式,演变成了主干开发、主干发布的模式。 + +为了保证主干分支的质量,自动化验收手段是必不可少的,因此,每一次代码提交都会触发完整的编译构建、单元测试、代码扫描、自动化测试等过程。在代码合入主干后,会进行按需发布,先是发布到内部环境,也就是只有Facebook的员工才能看到这个版本,如果发现问题就立刻修复,如果没有问题,再进一步开放发布给2%的线上生产用户,同时自动化检测线上的反馈数据。直到确认一切正常,才会对所有用户开放。 + +最后,通过分支策略和发布策略的整合,注入自动化质量验收和线上数据反馈能力,最终将发布频率从固定的每天2次,提升到每天多次,甚至实现了按需发布的模式。Facebook最新的分支策略如图所示: + + + + +图片来源:https://engineering.fb.com/web/rapid-release-at-massive-scale/ + + +看到这里,你可能会问:“在这三种典型策略中,哪种策略是最好的?我应该如何选择呢?”其实,这个问题也困扰着很多公司。 + +的确,不同类型、规模、行业的软件项目采用的分支策略可能都不尽相同,同时,发布频率、软件架构、基础设施能力、人员能力水平等因素也在制约着分支策略的应用效果。 + +所以,很难说有一种通用的分支策略可以满足所有场景的需求。但是,有些分支策略的原则更加适合于快速迭代发布的场景,也就更加适合DevOps的发展趋势。所以,我个人比较推荐的是主干开发结合特性分支的模式,也就是团队共享一条开发主干,特性开发基于主干拉出特性分支,快速开发验收并回归主干,同时,在特性分支和主干分别建立不同的质量门禁和自动化验收能力。 + +这样做的好处在于,可以加快代码集成频率,特性相对独立清晰,并且主干分支又可以保持一定的质量水平。不过,在执行的过程中,你需要遵守以下原则: + + +团队共享一条主干分支; +特性分支的存活周期要尽量短,最好不要超过3天; +每天向主干合并一次代码,如果特性分支存在超过1天,那么每天都要同步主干代码; +谨慎使用功能开关等技术手段,保持代码干净和历史清晰; +并行分支越少越好,如果可能的话,尽量采用主干发布。 + + +关于最后一条,你需要注意的是,是否需要发布分支,主要取决于项目的发布模式。对于按照版本方式发布的项目来说,比如App、智能硬件系统,以及依赖大量外部系统联调的核心系统,可以按照发布固定的节奏拉出发布分支;对于发布节奏较快、系统架构拆分后相对独立的应用来说,可以直接采用主干发布的模式,并结合安全发布策略把控整体的发布质量。 + +这种分支发布的策略图如下所示: + + + +总结 + +今天,我给你介绍了三种分支策略,建议你对照我给你分享的分支策略图,好好理解一下。另外, 我还介绍了适合DevOps模式的分支策略以及一些使用原则。还记得我最开始说的吗?分支策略就是研发协作和发布模式的风向标,分支策略的变化对整个研发团队的习惯和节奏都是一个非常大的调整,找到适合当前团队的分支策略,才是最重要的。 + +思考题 + +你目前所在的团队采用的是哪种分支策略?你觉得当前的分支策略有哪些问题或改进空间吗?你是否经历过分支策略的调整呢?如果有的话,你在这个过程中踩过什么“坑”吗?有没有什么心得呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/12持续集成:你说的CI和我说的CI是一回事吗?.md b/专栏/DevOps实战笔记/12持续集成:你说的CI和我说的CI是一回事吗?.md new file mode 100644 index 0000000..4e73426 --- /dev/null +++ b/专栏/DevOps实战笔记/12持续集成:你说的CI和我说的CI是一回事吗?.md @@ -0,0 +1,143 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 持续集成:你说的CI和我说的CI是一回事吗? + 你好,我是石雪峰。今天我来跟你聊聊CI。 + +之前,我曾应邀参加某公司的DevOps交流活动,他们质量团队的负责人分享了DevOps平台建设方面的经验,其中有一大半时间都在讲CI。刚开始还挺好的,可是后来,我越听越觉得奇怪,以至于在交流环节,我只想提一个问题:“你觉得CI是个啥意思?”后来,为了不被主办方鄙视,话到嘴边我又努力憋回去了。 + +回来的路上,我就一直在思考这个问题。很多时候,人们嘴上总是挂着CI,但是他们说的CI和我理解的CI好像并不是一回事。比如,有时候CI被用来指代负责内部工具平台建设的团队;有时候,CI类似一种技术实践,间接等同于软件的编译和打包;有时候,CI又成了一种职能和角色,指代负责版本的集成和发布的人。可见,CI的定义跟DevOps一样,每个人的理解都千差万别。 + +可问题是,如果不能理解CI原本的含义,怎么发挥CI真正的价值呢?以CI的名义打造的平台又怎么能不跑偏,并且解决真正的问题呢? + +所以,今天,就让我们一起重新认识下这个“最熟悉的陌生人”。 + +CI是Continuous Integration的缩写,也就是我们熟悉的持续集成,顾名思义,这里面有两个关键的问题:集成什么东西?为什么要持续?要回答这两个问题,就得从CI诞生的历史说起了。 + +在20世纪90年代,软件开发还是瀑布模式的天下,人们发现,在很长一段时间里,软件是根本无法运行的。因为按照项目计划,软件的功能被拆分成各个模块,由不同的团队分别开发,只有到了开发完成之后的集成阶段,软件才会被真正地组装到一起。可是,往往几个月开发下来,到了集成的时候,大量分支合并带来的冲突和功能问题集中爆发,团队疲于奔命,各种救火,甚至有时候发现压根集成不起来。 + +我最初工作的时候,做的就是类似这样的项目。我们负责客户端程序的开发,到了集成的时候才发现,客户的数据库使用的是Oracle,而我们为了省事,使用的是微软Office套件中的Access,估计现在很多刚工作的年轻工程师都没听说过这个数据库,这就导致客户下发的数据没法导入到本地数据库中。结果,整整一个元旦假期,我们都在加班加点,好不容易赶工了一个数据中间层,这才把两端集成起来。 + +所以,软件集成是一件高风险的、不确定的事情,国外甚至有个专门的说法,叫作“集成地狱”。也正因为如此,人们就更倾向于不做集成,这就导致开发末端的集成环节变得更加困难,从而形成了一个恶性循环。 + +为了解决这个问题,CI的思想应运而生。CI本身源于肯特·贝克(Kent Beck)在1996年提出的极限编程方法(ExtremeProgramming,简称XP)。顾名思义,极限编程是一种软件开发方法,作为敏捷开发的方法之一,目的在于通过缩短开发周期,提高发布频率来提升软件质量,改善用户需求响应速度。 + +不知道为什么,每次听到极限编程,我心中都热血沸腾。不管在任何时代,总有那么一群程序员走在时代前沿,代表和传承着极客精神,就像咱们平台的名字极客时间,就代表了不甘于平庸、追求极致的精神,特别好。 + +扯远了,让我们回归正题。极限编程方法中提出的实践,现在看来依然相当前沿,比如结对编程、软件重构、测试驱动开发、编程规范等,这些词我们都耳熟能详,但是真正能做到的却是凤毛麟角。其中还有一个特别有意思的实践规范,叫作每周40小时工作制,也就是一周工作5天,每天工作8小时。联想到前些日子在网络上引发激烈争论的“996”,就可以看出,极限编程方法在国内的发展还是任重而道远啊。 + +当然,在这么多实践中,持续集成可以说是第一个被广泛接受和认可的。 + +关于CI的定义,我在这里引用一下马丁·福勒(Martin Fowler)的一篇博客中的内容,这也是当前最为业界公认的定义之一: + + +CI是一种软件开发实践,团队成员频繁地将他们的工作成果集成到一起(通常每人每天至少提交一次,这样每天就会有多次集成),并且在每次提交后,自动触发运行一次包含自动化验证集的构建任务,以便尽早地发现集成问题。 + + +CI采用了一种反常规的思路来解决软件集成的困境,其核心理念就是:越是痛苦的事情,就要越频繁地做。很多人不理解为什么,举个例子你就明白了。我小时候身体非常不好,经常要喝中药,第一次喝的时候,每喝一口都想吐,可是连续喝了一个星期之后,我发现中药跟水的味道也没什么区别。这其实是因为人的适应力很强,慢慢就习惯了中药的味道。对于软件开发来说,也是这个道理。 + +如果开发周期末端的一次性集成有这么大的风险和不确定性,那不如把集成的频率提高,让每次集成的内容减少,这样即便失败,影响的也仅仅是一次小的集成内容,问题定位和修复都可以更加快速地完成。这样一来,不仅提高了软件的质量,也大大降低了最后阶段的返工所带来的浪费,还提升了软件交付效率。 + +你可能会说,这个道理我也懂啊,我们的持续集成就是这样的。别急,我们一起来测试一下。 + +假如你认为自己所在的项目和团队在践行CI,那么你可以思考3个问题,看看你们是否做到了。 + + + +每一次代码提交,是否都会触发一次完整的流水线? + +每次流水线是否会触发自动化的测试环节? + +如果流水线出现了问题,是否能够在10分钟之内修复? + + + +我曾在现场做过很多次这个测试,如果参与者认为做到了,就会举手表示;如果没有做到,就会把手放下。每次面对一群自信满满的CI“信徒们”,三连问的结果总会让人“暗爽”,因为最开始几乎所有人都会举手,他们坚信自己在实践持续集成。但接下来,我每问一个问题,就会有一半的人把手放下,坚持到最后的人寥寥无几,这几个人面对周边人的目光,内心也开始怀疑起来,如果我再适时地追问两下,基本就都放下了。 + +这么看来,CI听起来简单易懂,但实施起来并没有那么容易。可以说CI涵盖了三个阶段,每个阶段都蕴含了一组思想和实践,只有把这些都做到了,那才是真正地在实施CI。接下来,让我们逐一看下这三个阶段。 + +第一阶段:每次提交触发完整的流水线 + +第一个阶段的关键词是:快速集成。这是对CI核心理念的最好诠释,也就是集成速度做到极致,每次变更都会触发CI。 + +当然,这里的变更有可能是代码变更,也有可能是配置、环境、数据变更。我之前强调过,要将一切都纳入版本控制,这样,所有的元数据变更都会被版本管理系统捕获,并通过事件或者Webhook的方式通知持续集成平台。 + +对于现代的持续集成平台,比如大家常用的Jenkins,默认支持多种触发方式,比如定时触发、轮询触发或者Webhook触发。那么,如果想做到每次提交都触发持续集成的话,首先就需要打通版本控制系统和持续集成系统,比如GitLab和Jenkins的集成,网上已经有很多现成的材料,大家照着操作一般都不会有太多问题。但是,只要打通两个系统就足够了吗?显然没有这么简单。实施提交触发流水线,还需要一些前置条件。 + +1.统一的分支策略。 + +既然CI的目的是集成,那么首先就需要有一条以集成为目的的分支。这条分支可以是研发主线,也可以是专门的集成分支,一旦这条分支上发生任何变更,就会触发相应的CI过程。那么,可能有人会问,很多时候开发都是在特性分支或者版本分支上进行的,难道这些分支上的提交就不要经过CI环节了吗?这就引出了第2个前置条件。 + +2.清晰的集成规则。 + +对于一个大中型团队来说,每天的提交量是非常惊人的,这就要求持续集成具备足够的吞吐率,能够及时处理这些请求。而对于不同分支来说,持续集成的步骤和要求也不尽相同。不同分支的集成目的不同,相应的环节自然也不相同。 + +比如,对于研发特性分支而言,目的主要是快速验证和反馈,那么速度就是不可忽视的因素,所以这个层面的持续集成,主要以验证打包和代码质量为主;而对于系统集成分支而言,它的目的不仅是验证打包和代码质量,还要关注接口和业务层面的正确性,所以集成的步骤会更加复杂,成本也会随之上升。所以,根据分支策略选择合适的集成规则,对于CI的有效运转来说非常重要。 + +3.标准化的资源池。 + +资源池作为CI的基础设施,重要性不言而喻。 + +首先,资源池需要实现环境标准化,也就是任何任务在任何节点都具备可运行的能力,这个能力就包括了工具、配置等一系列要素。如果CI任务在一个节点可以运行,跑到另外一个节点就运行失败,那么CI的公信力就会受到影响。 + +另外,资源池的并发吞吐量应该可以满足集中提交的场景,可以动态按需初始化的资源池就成了最佳选择。当然,同时还要兼顾成本因素,因为大量资源的投入如果没有被有效利用,那么造成的浪费是巨大的。 + +4.足够快的反馈周期。 + +越是初级CI,对速度的敏感性就越强。一般来讲,如果CI环节超过10~15分钟还没有反馈结果,那么研发人员就会失去耐心,所以CI的运行速度是一个需要纳入监控的重要指标。对于不同的系统而言,要约定能够容忍的CI最大时长,如果超过这个时长,同样会导致CI失败。所以,这就需要环境、平台、开发团队共同维护。 + +你看,一套基本可用的CI所依赖的条件远不止这些,核心还是为了能够在最短的时间内完成集成动作并给出反馈。如果你们公司已经实现了代码提交的CI,并且不会有大量失败和排队的情况发生,那么,恭喜你,第一阶段就算通过了。 + +第二阶段:每次流水线触发自动化测试 + +第二个阶段的关键词是:质量内建。关于质量内建,我会在专栏后面的内容中详细介绍。实际上,CI的目的是尽早发现问题,这些问题既包括构建失败,也包括质量不达标,比如测试不通过,或者代码规约静态扫描等不符合标准。 + +我见过的很多CI都是“瘸腿”CI,因为缺失了自动化测试的能力注入,或者自动化测试的能力很差,基本无法发现有效问题。这里面有几个重要的关注点,我们来看一下。 + +1.匹配合适的测试活动。 + +对于不同层级的CI而言,同样需要根据集成规则来确定需要注入的质量活动。比如,最初级的提交集成就不适合那些运行过于复杂、时间太长的测试活动,快速的代码检查和冒烟测试就足以证明这个版本已经达到了最基本的要求。而对于系统层的集成来说,质量要求会更高,这样一来,一些接口测试、UI测试等就可以纳入到CI里面来。 + +2.树立测试结果的公信度。 + +自动化测试的目标是帮助研发提前发现问题,但是,如果因为自动化测试能力自身的缺陷或者环境不稳定等因素,造成了CI的大量失败,那么,这个CI对于研发来说就可有可无了。所以,我们要对CI失败进行分类分级,重点关注那些异常和误报的情况,并进行相应的持续优化和改善。 + +3.提升测试活动的有效性。 + +考虑到CI对于速度的敏感性,那么如何在最短的时间内运行最有效的测试任务,就成了一个关键问题。显然,大而全的测试套件是不合时宜的,只有在基础功能验证的基础上,结合与本次CI的变更点相关的测试任务,发现问题的概率才会大大提升。所以,根据CI变更,自动识别匹配对应的测试任务也是一个挑战。 + +当你的CI已经集成了自动化验证集,并且该验证集可以有效地发现问题,那么恭喜你,第二阶段也成功了。但这并不是“一锤子买卖”,毕竟,由于业务需求的不断变化,自动化测试要持续更新,才能保证始终有效。 + +第三阶段:出了问题可以在第一时间修复 + +到现在为止,我们已经做到了快速集成和质量内建,说实话,利用现有的开源工具和框架快速搭建一套CI平台并不困难,真正让CI发挥价值的关键,还是在于团队面对持续集成的态度,以及团队内是否建立了持续集成的文化。 + +硅谷的很多公司都有一种不成文的规定,那就是员工每天下班前要先确认持续集成是正常的,然后再离开公司,同时,公司也不建议在深夜或者周末上线代码,因为一旦出了问题,很难在第一时间修复,造成的影响难以估计。 + +其实,很多企业并不知道他们花费大量人力、物力建设CI的平均修复时长是多少,也缺乏这方面的数据统计。就现状而言,有些时候,他们可以做到在10分钟内修复,而有些时候就需要几个小时,原因可能是负责人出去开会了,或者是赶上了午休的时间。 + +当然,也有一些企业质疑10分钟这个时间长度,因为软件项目的特殊性,很有可能每次集成周期就远大于10分钟。如果你也是这样想的,那你可能就误解CI的理念和初衷了,毕竟我也不相信马丁·福勒能够保证在10分钟内修复问题。在这么短的时间里,人为因素其实并不可控,所以,人不是关键,建立机制才是关键。 + +什么是机制呢?机制就是一种约定,人们愿意遵守这样的行为,并且做了会得到好处。对于CI而言,保证集成主线的可用性,其实就是团队成员间的一种约定。这不在于谁出的问题谁去修复,而在于我们是否能够保证CI的稳定性,足够清楚问题的降级路径,并且主动关注、分析和推动问题解决。 + +另外,团队要建立清晰的规则,比如10分钟内没有修复则自动回滚代码,比如当CI“亮红灯”的时候,团队不再提交新的代码,因为在错误的基础上没有办法验证新的提交,这时需要集体放下手中的工作,共同恢复CI的状态。 + +只有团队成员深信CI带给团队的长期好处远大于短期投入,并且愿意身体力行地践行CI,这个“10分钟”规则才有可能得到保障,并落在实处。 + +总结 + +在这一讲中,我们回顾了CI诞生的历史和CI试图解决的根本问题。同时,我们也介绍了CI落地建设的三个阶段和其中的核心理念,即快速集成、质量内建和文化建立。 + +最后,我特别想再提一点,很多人经常会把工具和实践混为一谈,一旦结果没有达到预期,就会质疑实践是否靠谱,工具是否好用,很容易陷入工具决定论的怪圈。实际上,CI的核心理念从未有过什么改变,但工具却一直在升级换代。工具是实践的载体,实践是工具的根基,单纯的工具建设仅仅是千里之行的一小步,这一点,我们必须要明白。 + +思考题 + +可以说,一个良好的CI体现了整个研发团队方方面面的能力,那么,你对企业内部实践CI都有哪些问题和心得呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/13自动化测试:DevOps的阿克琉斯之踵.md b/专栏/DevOps实战笔记/13自动化测试:DevOps的阿克琉斯之踵.md new file mode 100644 index 0000000..a134ab1 --- /dev/null +++ b/专栏/DevOps实战笔记/13自动化测试:DevOps的阿克琉斯之踵.md @@ -0,0 +1,163 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 自动化测试:DevOps的阿克琉斯之踵 + 你好,我是石雪峰。 + +在古希腊神话中,战神阿克琉斯英勇无比,浑身刀枪不入,唯独脚后跟是他的致命弱点。在特洛伊战争中,他的脚后跟被一箭射中,倒地身亡,从此,阿克琉斯之踵就被用来形容致命的缺陷。我今天要跟你聊的自动化测试,就是DevOps的阿克琉斯之踵。 + +我之前走访过很多公司,我发现,在工程实践领域,比如配置管理、持续集成等,他们实践得还不错,但是却有两大通病,一个是研发度量,另一个就是自动化测试。 + +没有人会否认自动化测试的价值,而且很多公司也都或多或少地在实践自动化测试。但从整体来看,自动化测试的实施普遍不成体系,大多都在关注单点工具。另外,团队对自动化测试的真实效果也存在疑惑。如果不能解决这些问题,就很难突破实践DevOps的天花板。 + +那么,自动化测试究竟要解决什么问题,又适合哪些业务形态和测试场景呢?我们该如何循序渐进地推进建设,并且正确地度量效果以免踩坑呢?这些问题,就是我要在这一讲中跟你分享的重点内容。 + +自动化测试要解决什么问题? + +产品交付速度的提升,给测试工作带来了很大的挑战。一方面,测试时间被不断压缩,以前三天的测试工作要在一天内完成。另一方面,需求的变化也给测试工作的开展带来了很大的不确定性。这背后核心的问题是,业务功能的累加导致测试范围不断扩大,但这跟测试时长的压缩是矛盾的。说白了,就是要测试的内容越来越多,但是测试的时间却越来越短。 + +全面测试会带来相对更好的质量水平,但是投入的时间和人力成本也是巨大的,而快速迭代交付就意味着要承担一定的风险。那么,究竟是要速度,还是要质量,这是一个很难回答的问题。 + +所以,要想提升测试效率,自然就会联想到自动化手段。实际上,自动化测试适用于以下几种典型场景: + + +有大量机械的重复操作,并且会反复执行的场景,比如批量的回归测试; +有明确的设计规范且相对稳定的场景,比如接口测试; +大批量、跨平台的兼容性测试,比如覆盖多种版本和多种机型的测试,几十个机型还可以接受,如果覆盖成百上千个机型,就只能依靠自动化了; +长时间不间断执行的测试,比如压力测试、可用性测试等。 + + +这些典型场景往往都具备几个特征:设计明确、功能稳定、可多次重复、长期大批量执行等,核心就是通过自动化手段来解决测试成本的问题,也就是人的问题。但这并不意味着手工测试就没有价值了。相反,当人从重复性劳动中解放出来后,就可以投入到更有价值的测试活动中,比如探索性测试、易用性测试、用户验收测试等,这些都属于手工测试的范畴。 + +这听上去还挺合理的,可是,为什么很多公司还是倾向于采用手工测试的方式呢?实际上,并非所有的测试活动都适合自动化,而且,自动化测试建设也面临着一些问题。 + + +投入产出比:很多需求基本上只会上线一次(比如促销活动类需求),那么,实现自动化测试的成本要比手动测试高得多,而且以后也不会再用了,这显然有点得不偿失。 +上手门槛:自动化测试依赖代码方式实现,要开发一套配置化的测试框架和平台,对架构设计和编码能力都有很大的要求。但是,测试人员的编码能力一般相对较弱。 +维护成本高:无论是测试环境、测试用例还是测试数据,都需要随着需求的变化不断进行调整,否则就很容易因为自动化测试过时,导致执行失败。 +测试设备投入高:比如,移动App的测试需要有大量的手机资源,想要覆盖所有的手机型号、操作系统版本,本身就不太现实。更何况,有限的机器还经常被测试人员拿去做本地调试,这就进一步加剧了线上测试没有可用资源的情况。 + + +自动化测试的设计 + +这么看来,自动化测试并不是一把万能钥匙,我们也不能指望一切测试都实现自动化。只有在合适的领域,自动化测试才能发挥出最大价值。那么,你可能就要问了,面对这么多种测试类型,到底要从哪里启动自动化测试的建设呢? + +首先,我来给你介绍一下经典的测试三角形。这个模型描述了从单元测试、集成测试到UI测试的渐进式测试过程。越是靠近底层,用例的执行速度就越快,维护成本也越低。而在最上层的UI层,执行速度要比单元测试和接口测试要慢,比手工测试要快,相应的维护成本要远高于单元测试和接口测试。 + + + + +图片来源:“DevOps Handbook” + + +这样看来,从靠近底层的单元测试入手是一个投入产出相对比较高的选择。但实际上,单元测试的执行情况因公司而异,有的公司能做到80%的覆盖率,但有的公司却寸步难行。毕竟,单元测试更多是由开发主导的,开发领导的态度就决定了运行的效果。但不可否认的是,单元测试还是非常必要的,尤其是针对核心服务,比如核心交易模块的覆盖率。当然,好的单元测试需要研发投入大量的精力。 + +对于UI层来说,执行速度和维护成本走向了另外一个极端,这也并不意味着就没有必要投入UI自动化建设。UI层是唯一能够模拟用户真实操作场景的端到端测试,页面上的一个按钮可能触发内部几十个函数调用,和单元测试每次只检查一个函数的逻辑不同,UI测试更加关注模块集成后的联动逻辑,是集成测试最有效的手段。 + +另外,很多测试人员都是从UI开始接触自动化的,再加上相对成熟的测试工具和框架,实施不依赖于源码,也是一种比较容易上手的自动化手段。在实际应用中,UI自动化可以帮助我们节省人工测试成本,提高功能测试的测试效率。不过,它的缺点也是比较明显的:随着敏捷迭代的速度越来越快,UI控件的频繁变更会导致控件定位不稳定,提高了用例脚本的维护成本。 + +综合考虑投入产出比和上手难度的话,位于中间层的接口测试就成了一种很好的选择。一方面,现代软件架构无论是分层还是服务调用模式,对接口的依赖程度都大大增加。比如典型的前后端分离的开发模式,前后端基本都是在围绕着接口进行开发联调。另一方面,与单元测试相比,接口测试调用的业务逻辑更加完整,并且具备清晰的接口定义,适合采用自动化的方式执行。 + +正因为如此,对于基于Web的应用来说,我更推荐椭圆形模型,也就是以中间层的API接口测试为主,以单元测试和UI测试为辅。你可以参考一下分层自动化测试模型图。 + + + +自动化测试的开发 + +有效的自动化测试离不开工具和平台的支持。以接口测试为例,最早都是通过cURL、Postman、JMeter等工具单机执行的。但是,一次成功的接口测试,除了能够发起服务请求之外,还需要前置的测试数据准备和后置的测试结果校验。对于企业的实际业务来说,不仅需要单接口的执行,还需要相对复杂的多接口,而且带有逻辑的执行,这就依赖于调用接口的编排能力,甚至是内建的Mock服务。 + +不仅如此,测试数据、用例、脚本的管理,测试过程中数据的收集、度量、分析和展示,以及测试报告的发送等,都是一个成熟的自动化测试框架应该具备的功能。 + +比如,对于UI自动化测试来说,最让人头疼的就是UI控件变化后的用例维护成本问题。解决方法就是操作层获取控件和控件本身的定位方法,进行解耦,这依赖于框架的设计与实现。在实际操作控件时,你可以通过自定义名称的方式来调用控件,自定义名称在控件相关配置文件中进行定义。在具体操作时,可以通过操作层之下的代理层来处理。示例代码如下: + +public void searchItem(String id) { + getTextBox("SearchBar").clearText(); + getTextBox("SearchBar").setText(id); + getButton("Search").click(); +} + + +在代码中,搜索条控件被定义为SearchBar,通过调用代理层的getTextBox方法,得到一个文本输入框类型对象,并调用该对象的清除方法。然后,在对应的控件配置文件中添加对应的自定义名称和控件的定位方法。 + +这样一来,即便控件发生改变,对于实际操作层的代码来说,由于采用的是自定义名称,所以你不需要修改逻辑,只要在对应的控件配置文件中,替换控件的定位方法就行了。关于具体的控件配置文件,示例代码如下: + + + + + + + //XCUIElementTypeNavigatorBar[@name="MainPageView"]/XCUIElementTypeOther/... + + + + + + + +当然,为了简化测试人员的编写用例成本,你可以在操作层使用Page-Object模式,针对页面或模块封装操作方式,通过一种符合认知的方式,来实现具体的功能操作。这样一来,在实际编写用例的时候,你就可以非常简单地调用操作层的接口定义。示例代码如下: + +@TestDriver(driverClass = AppiumDriver.class) +public void TC001() { + String id='10000' + page.main.switchView(3); + page.cart.clearShoppingCart(); + page.main.switchView(0); + page.search.searchProduct(id); + page.infolist.selectlist(0); + page.infodetail.clickAddCart(); + Assert.assertTrue(page.cart.isProductCartExist(), "商品添加成功") +} + + +从这些示例中,我们可以看出,一个良好的自动化测试框架,可以显著降低测试人员编写测试用例的门槛,以及测试用例的维护成本。对于一个成熟的平台来说,平台易用性是非常重要的能力,通过DSL方式来声明测试过程,可以让测试人员聚焦在测试业务逻辑的设计和构建上,大大提升自动化测试的实现效率。 + +关于自动化测试框架的能力模型,我给你分享你一份资料,你可以点击网盘获取,提取码是gk9w。这个能力模型从测试脚本封装、测试数据解耦、测试流程编排、报告生成等多个方面,展示了框架建设的各个阶段应该具备的能力。 + +自动化测试结果分析 + +那么,我们该如何衡量自动化测试的结果呢?当前比较常用的方式是覆盖率,不过问题是,测试覆盖率提升就能发现更多的缺陷吗? + +一家大型金融公司的单元测试覆盖率达到了80%,接口覆盖率更是达到了100%,从这个角度来看,他们的自动化测试做得相当不错。但是,当我问到自动化测试发现的问题数量占到整体问题的比例时,他们的回答有点出人意料。在这么高的覆盖率基础上,自动化测试发现的问题占比仅仅在5%左右。那么,花了这么大力气建设的自动化测试,最后仅仅发现了5%的有效问题,这是不是说明自动化测试的投入产出比不高呢? + +实际上,说自动化测试是为了发现更多的缺陷,这是一个典型的认知误区。在实际项目中,手工测试发现的缺陷数量要比自动化测试发现的缺陷数量多得多。自动化测试更多是在帮助守住软件质量的底线,尤其是应用在回归测试中,自动化测试可以确保工作正常的已有功能不会因为新功能的引入而带来质量回退。可以这么说,如果自动化测试覆盖率足够高,那么软件质量一定不会差到哪儿去。 + +在自动化测试领域,除了追求覆盖率一个指标以外,自动化测试的结果分析也值得重点关注一下。如果自动化测试的结果并不准确,甚至带来大量误报的话,这对团队来说反而是一种干扰。关于测试误报,是指由于非开发代码变更导致的自动化测试用例执行失败的情况。业界对于误报率的普遍定义是: + + +自动化测试误报率=非开发变更引入的问题用例数量/测试失败的用例数量 + +比如,单次自动化测试执行了100个用例,其中有20个用例失败,这20个失败用例有5个是由于本次功能或代码变更引入的,也就是真实的缺陷,那么误报率就等于:(20 - 5)/20 = 75% + + +测试误报率是体现自动化测试稳定性的一个核心指标。对于不同测试类型和产品形态,误报的的原因有很多。比如测试环境的网络不稳定导致的连接超时、测试脚本和测试工具本身的固有缺陷导致的执行失败、测试数据不齐备、测试资源不可用等等。 + +由于测试误报的客观存在,即便执行了自动化测试并给出了测试结果,但还是需要人工审查判断之后,才能将真正的问题上报缺陷系统。这样一来,在自动化执行末端加入了人工处理,就导致自动化测试难以大规模推行,这也是自动化测试略显“鸡肋”的原因之一。 + +那么,要如何解决这个问题呢?这就要依赖于自动化测试结果的分析啦。 + + +对自动化测试的问题进行分类。你要弄清楚一次失败是环境问题、网络问题、功能变更,还是系统缺陷?你需要将失败的用例归纳到这些分类之中。当一个类别的问题非常多的时候,你可以考虑进行拆分,比如网络问题,你可以拆分为网络不可达、延迟超时、域名解析错误等等。 +增加已有分类的自动识别能力。比如,对于捕获到的常见异常,可以根据异常信息自动上报到对应的错误分类,从而简化人工识别和归类错误的工作量。 +提升自动化测试工具和环境的健壮性,对已知问题增加一定的重试机制。 +持续积累和丰富错误分类,有针对性地开展改进工作,从而不断提升自动化测试的稳定性。 + + +我跟你分享一幅某公司的自动化测试结果分析示意图。通过统计错误的分类,可以看出错误的占比情况,并且针对常见的误报类型进行有针对性的优化,并建立度量指标来跟踪长期结果,从而保证自动化测试结果的整体可信度。这些工作都需要长期的投入才能看出成效,这也是让自动化测试价值最大化和团队能力提升的必经之路。 + + + +总结 + +总结一下,这一讲我给你介绍了有关自动化测试的四个方面,包括自动化测试要解决的问题和适用场景、实施的路径、框架工具开发的典型思路以及结果分析的要点。希望能够帮你建立起对自动化测试这个“老大难”问题的全面认知,让你在推进自动化测试能力建设的时候有迹可循。 + +思考题 + +你所在的企业在进行自动化建设时,有哪些困境和问题,你们是如何解决的呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/14内建质量:丰田和亚马逊给我们的启示.md b/专栏/DevOps实战笔记/14内建质量:丰田和亚马逊给我们的启示.md new file mode 100644 index 0000000..3296755 --- /dev/null +++ b/专栏/DevOps实战笔记/14内建质量:丰田和亚马逊给我们的启示.md @@ -0,0 +1,146 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 内建质量:丰田和亚马逊给我们的启示 + 你好,我是石雪峰,今天我来跟你聊一个非常重要的话题:内建质量。 + +我之前给你讲过一个故事,说的是在美国汽车工厂装配流水线的末端,总是有个人在拿着橡胶锤子敲打车门,以检查车门是否安装良好。我还说,如果一个公司要靠“拿锤子的人”来保证质量,这就说明,这个公司的流程本身可能就有问题。 + +这个观点并不是我凭空捏造出来的,而是来自于质量管理大师爱德华·戴明博士经典的质量管理14条原则。其中,第3条指出,不应该将质量依赖于检验工作,因为检验工作既昂贵,又不可靠。最重要的是,检验工作并不直接提升产品质量,只是为了证明质量有缺陷。而正确的做法是将质量内建于整个流程之中,并通过有效的控制手段来证明流程自身的有效性。 + +为什么内建质量如此重要? + +在传统的软件开发过程中,检验质量的“锤子”往往都握在测试团队的手中。他们在软件交付的末端,通过一系列的“锤子”来“敲打”软件产品的方方面面,试图找到一些潜在的问题。 + +这样做的问题是,测试通过尽可能全面的回归测试来验证产品质量符合预期,成本是巨大的,但是效果却不见得有多好。 + +因为测试只能基于已知的产品设计进行验证,但那些潜在的风险有可能连开发自己都不知道。比如,开发引入了一些第三方的类库,但这些库本身存在缺陷,那么,如果测试没有回归到这个场景,就很有可能出现漏测和生产事故。 + +另外,由于测试存在的意义在于发现更多的缺陷,有些团队的考核指标甚至直接关联缺陷提交数量,以及缺陷修复数量。那么,这里的前提就是假设产品是存在缺陷的。于是,测试团队为了发现问题而发现问题,在研发后面围追堵截,这也造成了开发和测试之间的隔阂和对立,这显然不是DevOps所倡导的状态。 + +那么,解决这个问题的正确“姿势”,就是内建质量啦! + +关于内建质量,有个经典的案例就是丰田公司的安灯系统,也叫作安灯拉绳。丰田的汽车生产线上方有一条绳子,如果生产线上的员工发现了质量问题,就可以拉动安灯系统通知管理人员,并停止生产线,以避免带有缺陷的产品不断流向下游。 + +要知道,在生产制造业中,生产线恨不得24小时运转,因为这样可以最大化地利用时间,生产更多的产品。可是现在,随随便便一个员工就可以让整条生产线停转,丰田公司是怎么想的呢? + +其实,这背后的理念就是“Fail fast”,即快速失败。如果工人发现了有缺陷的产品,却要经过层层审批才能停止生产线,就会有大量带有缺陷的产品流向下游,所以,停止生产线并不是目的,及时发现问题和解决问题才是目的。 + +当启动安灯系统之后,管理人员、产线质量控制人员等相关人员会立刻聚集到一起解决这个问题,并尽快使生产线重新恢复运转。更重要的是,这些经验会被积累下来,并融入组织的能力之中。 + +内建质量扭转了看待产品质量的根本视角,也就是说,团队所做的一切不是为了验证产品存在问题,而是为了确保产品没有问题。 + +几年前,我在华为参加转正答辩的时候,被问到一个问题:“华为的质量观是怎样的?”答案是三个字:“零缺陷。”我当时并不理解,人非圣贤,孰能无过?产品零缺陷简直就是反常理。但是,后来我慢慢明白,所谓零缺陷,并不是说产品的Bug数量等于零,这其实是一种质量观念,倡导全员质量管理,构建质量文化。每一个人在工作的时候,都要力争第一时间发现和解决缺陷。 + +所以,总结一下,内建质量有两个核心原则: + + +问题发现得越早,修复成本就越低; +质量是每个人的责任,而不是质量团队的责任。 + + +说了这么多,你应该已经对内建质量有了初步的认识。那么接下来,我来给你介绍下内建质量的实践思路、操作步骤、常见问题以及应对方法。 + +内建质量的实施思路 + +既然是内建质量,那么,我们就应该在软件交付的各个环节中注入质量控制的能力。 + +在需求环节,可以定义清晰的需求准入规则,比如需求的价值衡量指标是否客观、需求的技术可行性是否经过了验证、需求的依赖是否充分评估、需求描述是否清晰、需求拆分是否合理、需求验收条件是否明确等等。 + +通过前置需求质量控制,可以减少不靠谱的需求流入。在很多公司,“一句话需求”和“老板需求”是非常典型的例子。由于没有进行充分沟通,研发就跟着感觉走,结果交付出来的东西完全不是想要的,这就带来了返工浪费。 + +在开发阶段,代码评审和持续集成就是一个非常好的内建质量的实践。在代码评审中,要尽量确认编码是否和需求相匹配,业务逻辑是否清晰。另外,通过一系列的自动化检查机制,来验证编码风格、风险、安全漏洞等。 + +在测试阶段,可以通过各类自动化测试,以及手工探索测试,覆盖安全、性能、可靠性等,来保障产品质量;在部署和发布阶段,可以增加数据库监控、危险操作扫描、线上业务监控等多种手段。 + +从实践的角度来说,每个环节都可以控制质量,那么,我们要优先加强哪个环节呢? + +根据内建质量的第一原则,我们知道,如果可以在代码刚刚提交的时候就发现和修复缺陷,成本和影响都是最低的。如果等到产品上线后,发现了线上质量问题,再回过头来定位和修复问题,并重新发布软件,成本将会呈指数级增长。 + +所以,研发环节作为整个软件产品的源头,是内建质量的最佳选择。那么,具体要怎么实施呢? + +内建质量的实施步骤 + +第一步:选择适合的检查类型 + +以持续集成阶段的代码检查为例,除了有单元测试、代码风格检查、代码缺陷和漏洞检查、安全检查等等,还有各种各样的检查工具。但实际上,这些并不是都需要的。至少在刚开始实践的时候,如果一股脑全上,那么研发基本上就不用干活了。 + +所以,选择投入产出比相对比较高的检查类型,是一种合理的策略。比如代码风格与缺陷漏洞相比,检查缺陷漏洞显然更加重要,因为一旦发生代码缺陷和漏洞,就会引发线上事故。所以,这么看来,如果是客户端业务,Infer扫描就可以优先实施起来。虽然我们不能忽视编码风格问题,但这并不是需要第一时间强制执行的。 + +第二步:定义指标并达成一致 + +确定检查类型之后,就要定义具体的质量指标了。质量指标分两个层面,一个是指标项,一个是参考值,我分别来介绍一下。 + +指标项是针对检查类型所采纳的具体指标,比如单元测试覆盖率这个检查项,可采纳的指标就包括行、指令、类、函数等。那么,我们要以哪个为准呢?这个一般需要同研发负责人达成一致,并兼顾行业的一些典型做法,比如单测行覆盖率就是一个比较好的选择。 + +另外,很多时候,在既有项目启用检查的时候,都会有大量的技术债。关于技术债,我会在下一讲展开介绍。简单来说,就是欠了一堆债,一时半会儿又还不了,怎么办呢?这个时候,比较合适的做法就是选择动态指标,比如增量代码覆盖率,也就是只关注增量代码的情况,对存量代码暂不做要求。 + +指标项定义明确之后,就要定义参考值了。这个参考值会直接影响质量门禁是否生效,以及生效后的行为。 + +我简单介绍下质量门禁。质量门禁就类似一道安全门,通过门禁时进行检查,如果不满足指标,则门禁报警,禁止通过。这就跟交警查酒驾一样,酒精含量如果超过一定的指标,就会触发报警。 + +参考值的定义是一门艺术。对于不同的项目,甚至是同一个项目的不同模块来说,我们很难用“一刀切”的方式定义数值。我比较推荐的做法是将静态指标和动态指标结合起来使用。 + +静态指标就是固定值,对于漏洞、安全等问题来说,采取零容忍的态度,只要存在就绝不放过。而动态指标是以考查增量和趋势为主,比如基线值是100,你就可以将参考值定义成小于等于100,也就是不允许增加。你还可以根据不同的问题等级,定义不同的参考值,比如严格检查致命和阻塞问题,其余的不做限制。 + +最后,对于这个指标,你一定要跟研发团队达成共识,也就是说,团队要能够认可并且执行下去。所以,定义指标的时候要充分采纳对方的建议。 + +第三步:建立自动化执行和检查能力 + +无论公司使用的是开源工具还是自研工具,都需要支持自动化执行和检查的能力。根据检查时机的不同,你也可以在提测平台、发布平台上集成质量门禁的功能,并给出检查结果的反馈。 + +按照快速失败的原则,质量门禁的生效节点要尽量靠近指标数据的产生环节。比如,如果要检查编码风格,最佳的时间点是在研发本地的IDE中进行,其次是在版本控制系统中进行并反馈结果,而不是到了最后发布的时间点再反馈失败。 + +现代持续交付流水线平台都具备质量门禁的功能,常见的配置和生效方式有两种: + + +在持续交付平台上配置规则,也就是不同指标和参考值组合起来,形成一组规则,并将规则关联到具体的执行任务中。这样做的好处是,各个生成指标数据的子系统只需要将数据提供给持续交付平台就行了,至于门禁是否通过,完全依靠持续交付平台进行判断。另外,一般配置规则的都是质量人员,提供这样一个单独的入口,可以简化配置成本。具体的实现逻辑,如图所示: + + + + + +在各个子系统中配置质量门禁。比如,在UI自动化测试平台上配置门禁的指标,当持续交付平台调用UI自动化测试的时候,直接反馈门禁判断的结果。如果检查不通过,则流水线直接失败。 + + +第四步:定义问题处理方式 + +完成以上三步之后,就已经开始进行自动化检查了,而检查的结果和处理方式,对质量门禁能否真正起到作用非常重要。一般来说,质量门禁都具有强制属性,也就是说,如果没有达到检查指标,就会立即停止并给予反馈。 + +在实际执行的过程中,质量门禁的结果可能存在多种选项,比如失败、告警、人工确认等。这些都需要在制定规则的时候定义清楚,通过一定的告警值和人工确认方式,可以对质量进行渐进式管控,以达到持续优化的目标。 + +另外,你需要对所有软件交付团队成员宣导质量规则和门禁标准,并明确通知方式、失败的处理方式等。否则,检查出问题却没人处理,这个门禁就形同虚设了。 + +第五步:持续优化和改进 + +无论是检查能力、指标、参考值,还是处理方式,只有在运行起来后才能知道是否有问题。所以,在推行的初期,也应该具备一定程度的灵活性,比如对指标规则的修订、指标级别和参考值的调整等,核心目标不是为了通过质量门禁,而是为了质量提升,这才是最重要的。 + +内建质量的常见问题 + +内建质量说起来并不复杂,但想要执行到位却很困难,那么,到底有哪些常见的问题呢?我总结了一些常见问题和处理建议,做成了表格,你可以参考一下。 + + + +最后,我再给你分享一个亚马逊的故事。2012年,安灯系统被引入亚马逊公司,一线客服如果收到客户反馈或者观察到商品有潜在的质量和安全风险,就可以发出告警邮件,并将商品设置为“不可购买”的状态,说白了,就是强制下架。客服居然可以不经过任何审批,直接把商品下架,不怕遭到供应商的投诉吗? + +实际上,这正是亚马逊践行以客户为中心的理念和原则的真实写照,每个人都为最终质量负责,没有例外。当员工得知自己被赋予了这样大的权限时,每个人都会尽自己的力量为质量工作加分。即便偶尔会有错误操作,这也是团队内部难能可贵的学习经验。 + +在公司中,无论是建立质量门禁的规则,还是开发一套平台系统,其实都不是最困难的事情,难的是,在实际过程中,有多少正常流程走了特殊审批?有多少发布是走的紧急通道?又有多少人会说开启了质量门禁,就会阻碍业务交付? + +说到底,还是要问问自己,你愿意付出多少代价,来践行自己的理念和原则,先上再说?我想,能在这一点上达成共识,才是内建质量落地的终极要素吧。 + +总结 + +总结一下,在这一讲中,我通过两个故事给你介绍了内建质量的背景和原则,那就是尽早发现问题,尽早修复,以及每个人都是质量的负责人。另外,我还给你介绍了实施内建质量的五个常见步骤。希望你始终记得,质量是生产出来的,而不是测试出来的。掌握了内建质量,你就揭开了DevOps高效率和高质量并存的秘密。 + +思考题 + +你所在的企业中是否启用了强制的质量门禁呢?可以分享一些你觉得效果良好的规则吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/15技术债务:那些不可忽视的潜在问题.md b/专栏/DevOps实战笔记/15技术债务:那些不可忽视的潜在问题.md new file mode 100644 index 0000000..4e02c5c --- /dev/null +++ b/专栏/DevOps实战笔记/15技术债务:那些不可忽视的潜在问题.md @@ -0,0 +1,161 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 技术债务:那些不可忽视的潜在问题 + 你好,我是石雪峰,今天我来跟你聊聊技术债务。 + +如果要问软件开发人员在项目中最不愿意遇到的事情,答案很可能是接手了一个别人开发了一半的系统。而且,系统开发的时间越长,开发人员的抵触情绪也就越大。那么,既然是同一种代码语言,同一种语法规则,至少还是一个能运行的东西,开发人员为什么要发自内心地抵触呢?我猜,很可能是不想看别人写的代码。之所以会这样,看不懂和怕改错是一个非常重要的原因,而这些,其实都是技术债务的结果。 + +什么是技术债务? + +那么,究竟什么是技术债务呢?它是从哪里来的呢?好好地写个代码,咋还欠债了呢? + +试想这样一种场景,老板拍下来一个紧急需求,要求你在3天内开发完成上线。在评估需求和设计的时候,你发现,要实现这个功能,有两种方案: + + +方案1:采用分层架构,引入消息队列。这样做的好处是结构清晰,功能解耦,但是需要1周的时间; +方案2:直接在原有代码的基础上修修补补,硬塞进去一块逻辑和页面,这样做需要2天时间,还有1天时间来测试。 + + +那么,你会选择哪个方案呢? + +我想,在大多数情况下,你可能都会选择方案2,因为业务的需求优先级始终是最高的。尤其是当下,市场竞争恨不得以秒来计算,先发优势非常明显。 + +而技术债务,就是指团队在开发过程中,为了实现短期目标选择了一种权宜之计,而非更好的解决方案,所要付出的代价。这个代价就是团队后续维护这套代码的额外工作成本,并且只要是债务就会有利息,债务偿还得越晚,代价也就越高。 + +实际上,带来技术债务的原因有很多,除了压力之下的快速开发之外,还包括不明真相的临时解决方案、新员工技术水平不足,和历史债务累积下来的无奈之举等。总之,代码维护的时间越长,引入的技术债务就会越多,从而使团队背上沉重的负担。 + +技术债务长什么样? + +简单来说,你可以把技术债务理解为不好的代码。但是这里的“不好”,究竟是哪里不好呢?我相信,写过代码的人,或多或少都有过这样的经历: + + +一份代码里面定义了一堆全局变量,各个角落都在引用; +一个脚本仓库里面,一大堆名字看起来差不多的脚本,内容也都差不多; +一个函数里面修修补补写了上千行; +数据表查询各种神奇的关联; +参数传递纯靠肉眼计算顺序; +因为修改一段代码引发了一系列莫名其妙的问题; +…… + + +那么,究竟要如何对代码的技术债务进行分类呢?我们可以借用“Sonar Code Quality Testing Essentials”一书中的代码“七宗罪”,也就是复杂性、重复代码、代码规范、注释有效性、测试覆盖度、潜在缺陷和系统架构七种典型问题。你可以参考一下这七种类型对应的解释和描述: + + + +除了低质量的代码问题之外,还有很多其他类型的技术债务,比如不合理的架构、过时的技术、冷门的技术语言等等。 + +比如,我们公司之前基于Ruby语言开发了一套系统,但是与Java、Python等流行语言相比,Ruby比较小众,所以很难找到合适的工程师,也影响了系统的进一步发展。再比如,到2020年元旦,官方即将停止为Python 2.x分支提供任何支持,如果现在你们的新系统还在采用Python 2进行开发,那么很快就将面对升级大版本的问题。虽然官方提供了一些减少迁移成本的方案,但是,从系统稳定性等方面来讲,依然有着非常大的潜在工作量。 + +为什么要重视技术债务? + +那么问题来了,为什么要重视技术债务呢?或者说,烂代码会有什么问题呢? + +从用户的角度来说,技术债务的多少好像并不影响用户的直观体验,说白了就是不耽误使用,应该有的功能都很正常。那么,回到最开始的那个例子,既然2天开发的系统,和1周开发的系统,从使用的角度来说并没有什么区别,那是不是就意味着,理应选择时间成本更低的方案呢? + +显然没有这么简单。举个例子,一个人出门时衣着得体,但是家里却乱成一团,找点东西总是要花很长时间,这当然不是什么值得骄傲的事情。对于软件来说,也是如此。技术债务最直接的影响就是内部代码质量的高低。如果软件内部质量很差,会带来3个方面的影响: + +1.额外的研发成本 + +对一个架构清晰、代码规范、逻辑有序、注释全面的系统来说,新增一个特性可能只需要1~2天时间。但是,同样的需求,在一个混乱的代码里面,可能要花上1周甚至是更长的时间。因为,单是理解原有代码的逻辑、理清调用关系、把所有潜在的坑趟出来就不是件容易的事情。更何况还有大量重复的代码,每个地方都要修改一遍,一不小心就会出问题。 + +2.不稳定的产品质量 + +代码质量越差,修改问题所带来的影响可能就越大,因为你不知道改了一处内容,会在哪个边缘角落引发异常问题。而且,这类代码往往也没有可靠的测试案例,能够保证修改前和修改后的逻辑是正确的。如果新增一个功能,导致了严重的线上问题,这时就要面临是继续修改还是回滚的选择问题。因为如果继续修改,可能会越错越多,就像一个无底洞一样,怎么都填不满。 + +3.难以维护的产品 + +正是由于以上这些问题,研发人员在维护这种代码的时候往往是小心加谨慎,生怕出问题。这样一来,研发人员宁愿修修补补,也不愿意改变原有的逻辑,这就会导致代码质量陷入一种不断变坏的向下螺旋,越来越难以维护,问题越积累越多,直到再也没办法维护的那一天,就以重构的名义,推倒重来。其实这压根就不是重构,而是重写。 + +另外,如果研发团队整天跟这样的项目打交道,团队的学习能力和工作积极性都有可能受到影响。可见,技术债务的积累就像真的债务一样,属于“出来混,迟早要还”的那种,只不过是谁来还的问题而已。 + +如何量化技术债务? + +软件开发不像是银行贷款,技术债务看不见摸不着,所以,我们需要一套计算方法把这种债务量化出来。目前业界比较常用的开源软件,就是SonarQube。在SonarQube中,技术债是基于SQALE方法计算出来的。关于SQALE,全称是Software Quality Assessment based on Lifecycle Expectations,这是一种开源算法。当然,今天的重点不是讲这个算法,你可以在官网查看更多的内容。同时,我再跟你分享一篇关于SQALE算法的文章,它可以帮你更深入地研究代码质量。 + +Sonar通过将不同类型的规则,按照一套标准的算法进行识别和统计,最终汇总成一个时间,也就是说,要解决扫描出来的这些问题,需要花费的时间成本大概是多少,从而对代码质量有一种直观的认识。 + +Sonar提供了一种通用的换算公式。举个例子,如下图所示,在Sonar的默认规则中,数据越界问题被定义为严重级别的问题,换算出来的技术债务等于15分钟。这里的15分钟,就是根据前面提到的SQALE分析模型计算得出的。当然,你也可以在规则配置里面对每一条规则的预计修复时间进行自定义。 + + + +计算出来的技术债务会因为开启的规则数量和种类的不同而不同。就像我在上一讲中提到的那样,团队内部对规则达成共识,是非常重要的。因为只有达成了共识,才能在这个基础上进行优化。否则,如果规则库变来变去,技术债务指标也会跟着变化,这样就很难看出团队代码质量的长期走势了。 + +另外,在Sonar中,还有一个更加直观的指标来表示代码质量,这就是SQALE级别。SQALE的级别为A、B、C、D、E,其中A是最高等级,意味着代码质量水平最高。级别的算法完全是基于技术债务比例得来的。简单来说,就是根据当前代码的行数,计算修复技术债务的时间成本和完全重写这个代码的时间成本的比例。在极端情况下,一份代码的技术债务修复时长甚至比完全推倒重写还要长,这就说明代码已经到了无法维护的境地。所以在具体实践的时候,也会格外重视代码的SQALE级别的健康程度。 + + +技术债务比例 = 修复已有技术债务的时间 / 完全重写全部代码的时间 + + +将代码行数引入进来,可以更加客观地计算整体质量水平。毕竟,一个10万行的代码项目和一个1千行的代码项目比较技术债务本身就没有意义。其实,这里体现了一种更加可视化的度量方式。比如,现在很多公司在做团队的效能度量时,往往会引入一大堆的指标来计算,根本看不懂。更加高级的做法,是将各种指标汇总成一组算法,并根据算法给出相应的评级。 + +当然,如果你想知道评级的计算方法,也可以层层展开,查看详细的数据。比如,持续集成能力,它是由持续集成频率、持续集成时长、持续集成成功率、问题修复时长等多个指标共同组成的。如果在度量过程中,你发现持续集成的整体评分不高,就可以点击进去查看每个指标的数据和状态,以及详细的执行历史。这种数据关联和下钻的能力对构建数据度量体系而言非常重要。 + +通过将技术债务可视化,团队会对代码质量有更直观的认识,那么接下来,就要解决这些问题了。 + +解决方法和原则 + +我走访过很多公司,他们都懂得技术债务的危害,不仅把Sonar搭建起来了,还定时执行了,但问题是没时间。的确,很多时候,我们没时间做单测,没时间做代码评审,没时间解决技术债务,但是这样一路妥协,啥时候是个头儿呢? + +前几天,我去拜访一家国内最大的券商公司,眼前一亮。这样一家所谓的传统企业,在研项目的技术债务居然是个位数。在跟他们深入交流之后,我发现,公司在这方面下了大力气,高层领导强力管控,质量门禁严格执行,所以才获得了这样的效果。 + +所以,从来没有一切外部条件都具备的时候,要做的就是先干再说。那么,要想解决技术债务,有哪些步骤呢? + + +共识:团队内部要对技术债务的危害、解决项目的目标、规则的选择和制定达成一致意见。 +可见:通过搭建开源的Sonar平台,将代码扫描整合进持续交付流水线中,定期或者按需执行,让技术债务变得可视化和可量化。不仅如此,Sonar平台还能针对识别出来的问题,给出建议的解决方法,这对于团队快速提升编码水平,大有帮助。 +止损:针对核心业务模块,对核心指标类型,比如vulnerability,缺陷的严重和阻塞问题设定基线,也就是控制整体数量不再增长。 +改善:创建技术优化需求,并在迭代中留出一定的时间修复已有问题,或者采用集中突击的方式搞定大头儿,再持续改进。 + + +在解决技术债务的过程中,要遵循4条原则。 + + +让技术债务呈良性下降趋势。一种好的趋势意味着一个好的起点,也是团队共同维护技术债务的一种约定。 +优先解决高频修改的问题。技术债务的利息就是引入新功能的额外成本,那么对于高频修改的模块来说,这种成本会快速累积,这也就意味着修复的产出是最大的。至于哪些代码是高频修改的,只要通过分析版本控制系统就可以看出来。 +在新项目中启动试点。如果现有的代码过于庞大,不可能在短时间内完成修复,那么你可以选择控制增长,同时在新项目中试点执行,一方面磨合规则的有效性,另一方面,也能试点质量门禁、IDE插件集成等自动化流程。 +技术债务无法被消灭,也不要等到太晚。只要还在开发软件项目,技术债务就基本上无法避免,所以不需要一下子把目标定得太高,循序渐进就行了。但同时,技术债务的累积也不是无穷无尽的,等到再也无法维护的时候就太迟了。 + + +在刚开始解决技术债务的时候,最大的问题不是参考指标太少,而是太多了。所以团队需要花大量时间来Review规则。关于这个问题,我给你两条建议:第一,参考代码质量平台的默认问题级别。一般来说,阻塞和严重的问题的优先级比一般问题更高,这也是基于代码质量平台长时间的专业积累得出的结论。第二,你可以参考业界优秀公司的实践经验,比如很多公司都在参考阿里巴巴的Java开发手册,京东也有自己的编码规约。最后,我总结了一些影响比较大的问题类型,建议你优先进行处理。 + + +大量重复代码; +类之间的耦合严重; +方法过于复杂; +条件判断嵌套太多; +缺少必要的异常处理; +多表关联和缺少索引; +代码风险和缺陷; +安全漏洞。 + + +总结 + +在这一讲中,我给你介绍了什么是技术债。而技术债的成本,就是团队后续开发新功能的额外成本。技术债务有很多形态,典型的就是代码“七宗罪”。除此之外,我还跟你聊了下技术债的影响,以及量化技术债务的方法。最后,我给出了一些解决方法和原则,希望能帮你攻克技术债这个难题。 + + + +最近这两年,智能研发的声音不绝于耳,其中关于使用人工智能和大数据技术提升代码质量的方法,是目前的一个热门研究领域。通过技术手段,辅助研发解决技术问题,在未来是一种趋势。如果你在公司中从事的是研发辅助和效率提升类的工作,建议你深入研究下相关的学术文章,这对你的工作会大有裨益。 + + +参考资料: + + +通过持续监控实现代码克隆的定制化管理 +基于代码大数据的软件开发质量追溯体系 +代码克隆那点事:开发人员为何克隆?现状如何改变? + + + +思考题 + +你遇到过印象深刻的烂代码吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/16环境管理:一切皆代码是一种什么样的体验?.md b/专栏/DevOps实战笔记/16环境管理:一切皆代码是一种什么样的体验?.md new file mode 100644 index 0000000..6b49525 --- /dev/null +++ b/专栏/DevOps实战笔记/16环境管理:一切皆代码是一种什么样的体验?.md @@ -0,0 +1,169 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 环境管理:一切皆代码是一种什么样的体验? + 你好,我是石雪峰。 + +网上经常流传着一些有关偏见地图的段子,通俗点说,“偏见地图”就是说网友对世界其他地方的印象,比如很多人认为天津人都会说相声。 + +如果软件开发中也有偏见地图的话,那么,对不熟悉运维的人来说,提到运维团队,可能就觉得是维护环境的那帮人。于是,环境就成了软件行业的“头号背锅侠”。比如,线上出故障了,可以是环境配置错误;测试有些功能没测到,可以是没有测试环境;开发出Bug了,也不管三七二十一,先甩给环境再说……所以你看,好像什么问题都可能跟环境相关。这种没来由的偏见,也加剧了开发和运维之间的不信任。 + +环境管理的挑战 + +那么,为啥环境总是让人这么不放心呢?其实,这是因为,现代企业所面对的业务复杂性,很大程度上都可以直观地体现在环境管理的方方面面上。总结起来,我认为一共有5点: + +1.环境种类繁多 + +首先,软件关联的环境种类越来越多,比如开发环境、测试环境、UAT用户验收测试环境、预发布环境、灰度环境、生产环境等。光是分清这些环境的名字和作用,就不是件容易的事情。 + +2.环境复杂性上升 + +现代应用的架构逐渐从单体应用向微服务应用转变。随着服务的拆分,各种缓存、路由、消息、通知等服务缺一不可,任何一个地方配置出错,应用都有可能无法正常运行。这还不包括各种服务之间的依赖和调用关系,这就导致很多企业部署一套完整环境的代价极高,甚至变成了不可能完成的任务。 + +3.环境一致性难以保证 + +比如,那句经典的甩锅名言“在我的机器上没问题”说的就是环境不一致的问题。如果无法保证各种环境配置的一致性,那么类似的问题就会无休止地发生。实际上,在很多企业中,生产环境由专门的团队管理维护,管理配置还算受控。但是对于开发环境来说,基本都属于一个黑盒子,毕竟是研发本地的电脑,即便想管也管不到。 + +4.环境交付速度慢 + +由于职责分离,环境的申请流程一般都比较冗长,从提起申请到交付可用的环境,往往需要2周甚至更长的时间。 + +一方面,这跟公司内部的流程审批有关。我见过一家企业申请一套环境需要5级审批,想象一下,于一家扁平化组织的公司,从员工到CEO之间的层级可能也没有5级。另一方面,环境配置过程依赖手动完成,过程繁琐,效率也不高,大多数情况下,环境配置文档都属于过时状态,并不会根据应用升级而动态调整,这么一来二去,几天就过去了。 + +5.环境变更难以追溯 + +产品上线以后出现问题,查了半天才发现,原来是某个环境参数的配置导致的。至于这个配置是谁改的,什么时间改的,为什么修改,经过了哪些评审,一概不知,这就给线上环境的稳定性带来了极大的挑战和潜在的风险。要知道,环境配置变更的重要性,一点也不亚于代码变更,通常都需要严格管控。 + +基础设施即代码 + +你可能会问,有没有一种方法,可以用来解决这些问题呢?还真有!这就是基础设施即代码。可以这么说,如果没有采用基础设施即代码的实践,DevOps一定走不远。那么,到底什么是基础设施即代码呢? + +基础设施即代码就是用一种描述性的语言,通过文本管理环境配置,并且自动化完成环境配置的方式。典型的就是以CAPS为代表的自动化环境配置管理工具,也就是Chef、Ansible、Puppet和Saltstacks四个开源工具的首字母缩写。 + +这个概念听起来比较抽象,那么,所谓基础设施即代码,这个描述基础设施的代码长什么样子呢?我给你分享一段Ansible的配置示例,你可以参考一下。 + +--- + - name: Playbook + hosts: webservers + become: yes + become_user: root + tasks: + - name: ensure apache is at the latest version + yum: + name: httpd + state: latest + - name: ensure apache is running + service: + name: httpd + state: started + + +无论你是否了解Ansible,单就这段代码而言,即便你不是专业运维或者工具专家,在注释的帮助下,你也大概能理解这个环境配置过程。实际上,这段代码就做了两件事:安装http的软件包,并启动相关服务。 + +为什么基础设施即代码能够解决以上问题呢? + +首先,对于同一个应用来说,各种环境的配置过程大同小异,只是在一些配置参数和依赖服务方面有所差别。通过将所有环境的配置过程代码化,每个环境都对应一份配置文件,可以实现公共配置的复用。当环境发生变更时,就不再需要登录机器,而是直接修改环境的配置文件。这样一来,环境配置就成了一份活的文档,再也不会因为更新不及时而失效了。 + +其次,环境的配置过程,完全可以使用工具自动化批量完成。你只需要引用对应环境的配置文件即可,剩下的事情都交给工具。而且,即便各台机器的初始配置不一样,工具也可以保证环境的最终一致性。由于现代工具普遍支持幂等性原则,即便执行完整的配置过程,工具也会自动检测哪些步骤已经配置过了,然后跳过这个步骤继续后面的操作。这样一来,大批量环境的配置效率就大大提升了。 + +最后,既然环境配置变成了代码,自然可以直接纳入版本控制系统中进行管理,享受版本控制的福利。任何环境的配置变更都可以通过类似Git命令的方式来实现,不仅收敛了环境配置的入口,还让所有的环境变更都完全可追溯。 + +基础设施即代码的实践,通过人人可以读懂的代码将原本复杂的技术简单化,这样一来,即便是团队中不懂运维的角色,也能看懂和修改这个过程。这不仅让团队成员有了一种共同的语言,还大大减少了不同角色之间的依赖,降低了沟通协作成本。这也是基础设施即代码的隐形价值所在,特别符合DevOps所倡导的协作原则。 + +看到这儿,你可能会说,这不就是一种自动化手段吗?好像也没什么特别的呀。回头想想,DevOps的初衷就是打破开发和运维的隔阂,但究竟要如何打通呢? + +在大多数公司,部署上线的工作都是由专职的运维团队来负责,开发团队只要将测试通过的软件包提供给运维团队就行了。所以,开发和运维的自然边界就在于软件包交付的环节,只有打通开发环节的软件集成验收的CI流水线和运维环节的应用部署CD流水线上线,才能真正实现开发运维的一体化。而当版本控制系统遇上基础设施即代码,就形成了一种绝妙的组合,那就是GitOps。 + +开发运维打通的GitOps实践 + +顾名思义,GitOps就是基于版本控制系统Git来实现的一套解决方案,核心在于基于Git这样一个统一的数据源,通过类似代码提交过程中的拉取请求的方式,也就是Pull Request,来完成应用从开发到运维的交付过程,让开发和运维之间的协作可以基于Git来实现。 + +虽然GitOps最初是基于容器技术和Kubernetes平台来实现的,但它的理念并不局限于使用容器技术,实际上,它的核心在于通过代码化的方式来描述应用部署的环境和部署过程。 + +在GitOps中,每一个环境对应一个环境配置仓库,这个仓库中包含了应用部署所需要的一切过程。比如,使用Kubernetes的时候,就是应用的一组资源描述文件,比如部署哪个版本,开放哪些端口,部署过程是怎样的。 + +当然,你也可以使用Helm工具来统一管理这些资源文件。如果你还不太熟悉Kubernetes,可以简单地把它理解为云时代的Linux,而Helm就是RPM或者APT这些包管理工具,通过应用打包的方式,来简化应用的部署过程。 + +除了基于Kubernetes的应用,你也可以使用类似Ansible Playbook的方式。只不过与现成的Helm工具相比,使用Ansible时,需要自己实现一些部署脚本,不过这也不是一件复杂的事情。 + +你可以看看下面的这段配置文件示例。这些配置文件采用了yml格式,它描述了应用部署的主要信息,其中,镜像名称使用参数形式,会有一个独立的文件来统一管理这些变量,你可以根据应用的实际版本进行替换,以达到部署不同应用的目标。 + +apiVersion: extensions/v1beta1 +kind: Deployment +spec: + replicas: 1 + template: + metadata: + labels: + app: demo + spec: + containers: + - name: demo + image: "{{ .Values.image.tag }}" + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + + +现在,我们来看看这个方案是如何实现的。 + +首先,开发人员提交新的代码改动到Git仓库,这会自动触发持续集成流水线,对于常见的版本控制系统来说,配置钩子就可以实现。当代码经过一系列的构建、测试和检查环节,并最终通过持续集成流水线之后,就会生成一个新版本的应用,并上传到制品库中,典型的就是Docker镜像文件或者war包的形式。 + +以上面的配置为例,假如生成了应用的1.0版本镜像,接下来,会自动针对测试环境的配置仓库创建一个代码合并请求,变更的内容就是修改镜像名称的版本号为1.0。这个时候,开发或者测试人员可以通过接受合并的方式,将这段环境变更配置合入主干,并再一次自动化地触发部署流水线,将新版本的应用部署到测试环境中。每次应用的部署采用相同的过程,一般就是将最新版本的应用制品拷贝到服务器并且重启,或者更新容器镜像并触发滚动升级。 + +这个时候,测试环境就部署完成了,当然,如果使用Kubernetes,可以利用命名空间的特性,快速创建出一套独立的环境,这是使用传统部署的应用所不具备的优势。在测试环境验收通过后,可以将代码合并到主分支,再一次触发完整的集成流水线环节,进行更加全面的测试工作。 + +当流水线执行成功后,可以自动针对预发布环境的配置仓库创建一个合并请求,当评审通过后,系统自动完成预发布环境的部署。如果职责分离要求预发布环境的部署必须由运维人员来操作,把合并代码的权限只开放给运维人员就行了。当运维人员收到通知后,可以登录版本控制系统,查看本次变更的范围,评估影响,并按照部署节奏完成部署。而这个操作,只需要在界面点击按钮就可以实现了。这样一来,开发和运维团队的协作就不再是一个黑盒子了。大家基于代码提交评审的方式完成应用的交付部署,整个过程中的配置过程和参数信息都是透明共享的。 + +我跟你分享一幅流程图,希望可以帮你充分地理解这个分层部署的过程。 + + + +那么,GitOps的好处究竟有哪些呢? + +首先,就是环境配置的共享和统一管理。原本复杂的环境配置过程通过代码化的方式管理起来,每个人都能看懂。这对于开发自运维来说,大大地简化了部署的复杂度。 + +另外,所有最新的环境配置都以Git仓库中为准,每一次的变更和部署过程也同样由版本控制系统进行记录。即便仅仅是环境工具的升级,也需要经过以上的完整流程,从而实现了环境和工具升级的层层验证。所以,这和基础设施即代码的理念可以说有异曲同工之妙。 + +开发环境的治理实践 + +关于开发环境的治理,我再给你举一个实际的案例。对于智能硬件产品开发来说,最大的痛点就是各种环境和工具的配置非常复杂,每个新员工入职,配置环境就要花上几天时间。另外,由于工具升级频繁和多平台并行开发的需要,开发经常需要在多种工具之间进行来回切换,管理成本很高。 + +关于这个问题,同样可以采用基础设施即代码的方法,生成一个包含全部工具依赖的Docker镜像,并分发给开发团队。在开发时仅需要拉起一个容器,将代码目录挂载进去,就可以生成一个完全标准化的研发环境。当工具版本升级时,可以重新制作一个新的镜像,开发本地拉取后,所有的工具就升级完成了,这大大简化了研发环境的维护成本。 + +其实,我们也可以发挥创新能力,把多种工具结合起来使用,以解决实际问题。比如,我们团队之前要同时支持虚拟化设备和容器化两种环境,虚拟化可以采用传统的Ansible方式完成环境部署,但容器化依赖于镜像的Dockerfile。这就存在一个问题:要同时维护两套配置,每次升级的时候也要同时修改虚拟化和容器化的配置文件。于是,为了简化这个过程,就可以把两者的优势结合起来,使用单一数据源维护标准环境。 + +具体来说,在Dockerfile中,除了基础环境和启动脚本,环境配置部分同样采用Ansible的方式完成,这样每次在生成一个新的镜像时,就可以使用相同的方式完成环境的初始化过程,配置示例如下: + +FROM harbor.devops.com:5000/test:ansible +MAINTAINER XX <[email protected]> +ADD ./docker /docker +WORKDIR /docker +RUN export TMPDIR=/var/tmp && ansible-playbook -v -i playbooks/inventories/docker playbooks/docker_container.yml + + +开发本地测试的实践 + +其实,我始终认为,环境管理是DevOps推行过程中的一个潜在“大坑”。为了提升开发者的效率,业界也在探索很多新的实践方向。我在前面也给你介绍过快速失败的理念,只有在第一时间反馈失败,才能最小化问题修复成本。而对于研发来说,由于测试环境的缺失,往往要等到代码提交并部署完成之后才能获取反馈,这个周期显然是可以优化的。关于如何解决开发本地测试的问题,在Jenkins社区也有一些相关的实践。 + +比如,你基于Kubernetes创建了一套最小测试环境,按照正常过程来说,如果改动一行代码,你需要经过代码提交、打包镜像、上传制品、更新服务器镜像等,才能开始调试。但如果你使用KSync工具,这些过程统统可以省略。KSync可以帮你建立本地工作空间和远端容器目录的关联,并自动同步代码。也就是说,只要在本地IDE里面修改了一行代码,保存之后,KSync就可以帮你把本地代码传到线上的容器中,对于类似Python这样的解释型语言来说特别省事。 + +谷歌也开源了一套基于容器开发自动部署工具Skaffold,跟KSync类似,使用Skaffold命令就可以创建一套Kubernetes环境。当本地修改一行代码之后,Skaffold会自动帮你重新生成镜像文件,推送远端,并部署生效,让代码开发变得所见即所得。研发只需要专注于写代码这件事情,其余的全部自动化,这也是未来DevOps工程实践的一个发展方向。 + +总结 + +今天,我给你介绍了企业环境管理的五个难题:种类多,复杂性,一致性,交付速度和变更追溯,并解释了为什么基础设施即代码是解决环境管理问题的最佳实践,还跟你分享了三个基础设施即代码的案例,希望能够帮助你理解这个过程。 + +如果你不太了解Kubernetes和容器,可能会有些内容难以消化。我想跟你说的是,无论采用什么技术,代码化管理的方式都是未来的发展趋势,建议你结合文章中的代码和流程图仔细梳理一下,并且尝试使用CAPS工具重新定义环境部署过程,将环境配置过程实现代码化。如果有问题,可以及时在留言区提问。 + +思考题 + +你认为推行开发自运维的最大难点是什么?关于解决这些难点,你有什么建议吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/17部署管理:低风险的部署发布策略.md b/专栏/DevOps实战笔记/17部署管理:低风险的部署发布策略.md new file mode 100644 index 0000000..3ab42df --- /dev/null +++ b/专栏/DevOps实战笔记/17部署管理:低风险的部署发布策略.md @@ -0,0 +1,157 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 部署管理:低风险的部署发布策略 + 你好,我是石雪峰,今天我来跟你聊聊部署管理。 + +在DevOps年度状态报告中,有四个核心的结果指标,其中仅“部署”这一项就占了两个关键指标,分别是部署频率和部署失败率。顺便提一下,另外两个指标是前置时长和平均故障修复时长。 + +对DevOps来说,部署活动就相当于软件交付最后一公里的最后一百米冲刺。只有通过部署发布,软件真正交付到最终用户手中的时候,前面走过的路才真正创造了价值。 + +部署和发布这两个概念,经常会被混用,但严格来说,部署和发布代表两种不同的实践。部署是一组技术实践,表示通过技术手段,将本次开发测试完成的功能实体(比如代码、二进制包、配置文件、数据库等)应用到指定环境的过程,包括开发环境、预发布环境、生产环境等。部署的结果是对服务器进行变更,但是这个变更结果不一定对外可见。 + +发布,也就是Release,更偏向一种业务实践,也就是将部署完成的功能正式生效,对用户可见和提供服务的过程。发布的时机往往同业务需求密切相关。很多时候,部署和发布并不是同步进行的,比如,对于电商业务来说,要在0点上线新的活动,那么如果部署和发布不分离,就意味着要在0点的前1秒,完成所有服务器的变更,这显然是不现实的。 + +那么,我想请你思考这样一个问题:所谓的低风险发布,是不是要在发布之前确保本次变更的功能万无一失了,才会真正地执行发布动作呢? + +事实上,即使没这么说,很多公司也都是这样做的。传统软件工程在流程设计的时候,也是希望通过层层的质量手段,来尽可能全面地验证交付产品的质量。典型的应用就是测试的V模型,从单元测试、集成测试、系统测试,到用户验收,还有各类专项测试,其实都是为了在发布之前发现更多的问题,以此来保障产品的质量。 + +那么,在DevOps模式下,是否也倡导同样的质量思想呢?我觉得这是一个有待商榷的问题。 + +实际上,随着发布频率的加速,留给测试活动的时间越来越有限了。与此同时,现在业务的复杂度,也比十年前高了不知道多少个等级。每次发布涉及PC端、移动端,还有小程序、H5等多种形态,更别提成百上千的终端设备了。要在有限的时间里,完成所有的测试活动,本来就是件很有挑战的事情。而且,各个公司都在衡量测试开发比,更是限制了测试人力投入的增长,甚至还要不断下降。 + +你当然可以通过自动化手段来提升测试活动的效率,但穷尽测试本来就是个伪命题。那么,明明说了DevOps可以又快又好,难道是骗人的吗? + +当然不是。这里的核心就在于DevOps模式下,质量思想发生了转变。简单概括就是:要在保障一定的质量水平的前提下,尽量加快发布节奏,并通过低风险发布手段,以及线上测试和监控能力,尽早地发现问题,并以一种最简单的手段来快速恢复。 + +这里面有几个关键词:一定的质量水平,低风险发布手段,线上测试和监控,以及快速恢复。我分别来给你解释一下。 + +一定的质量水平 + +这个“一定”要怎么理解呢?对于不同形态的软件来说,质量标准的高低自然是不相同的。比如,我有一个制造卫星的同学,他们对于软件质量的要求就是要做到几年磨一剑,甚至是不计成本的。但对于互联网这种快速迭代的业务来说,大家都习惯了默认会出问题,所以在圈定测试范围和测试覆盖的基础上,只要完成严重问题的修复即可发布,低级别的问题可以在后续的众测和灰度的环节继续处理。 + +所以,与定义一个发布质量标准相比,更重要的随着DevOps的推广,扭转团队的质量观念。质量不再是测试团队自身的事情,而是整个交付团队的事情。如果出现了线上问题,团队要一起来定位和修复,并且反思如何避免类似的问题再次发生,从失败中学习。 + +而测试能力的向前、向后延伸,一方面,提供了工具和平台以帮助开发更容易地进行自测;另一方面,加强针对线上监控埋点等类型的测试,可以保证线上问题可以快速暴露,正常获取辅助分析用户行为的数据,这会全面提升整体的发布质量。 + +低风险的发布手段 + +既然发布是一件不可回避的高风险事情,那么,为了降低发布活动的风险,就需要有一些手段了。典型的包括以下几种:蓝绿部署,灰度发布和暗部署。 + +1.蓝绿部署 + +蓝绿部署就是为应用准备两套一模一样的环境,一套是蓝环境,一套是绿环境,每次只有一套环境提供线上服务。这里的蓝和绿,只是用于区分两套环境的标志而已。在新版本上线时,先将新版本的应用部署到没有提供线上服务的环境中,进行上线前验证,验证通过后就达到了准备就绪的状态。在发布时间点,只要将原本指向线上环境的路由切换成另外一套环境,整个发布过程就完成了。 + +一般来说,这种方式的实现成本比较高。因为有两套一模一样的环境,只有一套用于真正地提供线上服务。为了减少资源浪费,在实际操作中,另外一套环境可以当作预发布环境使用,用来在上线之前验证新功能。另外,在这种模式下,数据库普遍还是采用同一套实例,通过向下兼容的方式支持多个版本的应用。 + + + + +图片来源:https://www.gocd.org/2017/07/25/blue-green-deployments.html + + +2.灰度发布 + +灰度发布,也叫金丝雀发布。与蓝绿部署相比,灰度发布更加灵活,成本也更低,所以,在企业中是一种更为普遍的低风险发布方式。 + +灰度发布有很多种实现机制,最典型的就是采用一种渐进式的滚动升级来完成整个应用的发布过程。当发布新版本应用时,根据事先设计好的灰度计划,将新应用部署到一定比例的节点上。当用户流量打到这部分节点的时候,就可以使用新的功能了。 + +值得注意的是,要保证同一个用户的行为一致性,不能时而看到新功能,时而看到老功能。当然,解决办法也有很多,比如通过用户ID或者cookie的方式来识别用户,并划分不同的组来保证。 + +新版本应用在部分节点验证通过后,再逐步放量,部署更多的节点,依次循环,最终完成所有节点的部署,将所有应用都升级到新版本。分批部署只是实现灰度发布的方法之一,利用配置中心和特性开关,同样可以实现指向性更强的灰度策略。比如,针对不同的用户、地域、设备类型进行灰度。 + +对于移动端应用来说,灰度发布的过程也是必不可少的。我以iOS平台应用为例,带你梳理下发布的步骤。首先,公司的内部用户可以自行下载安装企业包,进行新版本验证和试用。试用OK后,再通过官方的Testflight平台对外开启灰度,这样只有一部分用户可以收到新版本通知,并且在Testflight中安装新版本。灰度指标符合预期后,再开启全量用户升级。 + +现在很多应用都采用了动态下发页面的方法,同样可以使用特性开关,来控制不同用户看到不同的功能。 + + + + +图片来源:https://www.gocd.org/2017/07/25/blue-green-deployments.html + + +3.暗部署 + +随着A/B测试的兴起,暗部署的方式也逐渐流行起来。所谓暗部署,就是在用户不知道的情况下进行线上验证的一种方法。比如后端先行的部署方式,把一个包含新功能的接口发布上线,这个时候,由于没有前端导向这个接口,用户并不会真实地调用到这个接口。当用户进行了某些操作后,系统会将用户的流量在后台复制一份并打到新部署的接口上,以验证接口的返回结果和性能是否符合预期。 + +比如,对于电商业务场景来说,当用户搜索了一个关键字后,后台有两种算法,会给出两种返回结果,然后可以根据用户的实际操作,来验证哪种算法的命中率更高,从而实现了在线的功能验证。 + + + + +图片来源:https://www.gocd.org/2017/07/25/blue-green-deployments.html + + +以上这三种低风险发布手段,如果应用规模整体不大,蓝绿部署是提升系统可用性的最好手段,比如各类Hot-standby的解决方案,其实就是蓝绿部署的典型应用。而对于大规模系统来说,考虑到成本和收益,灰度发布显然就成了性价比最高的做法。如果想要跑一些线上的测试收集真实用户反馈,那么,暗部署是一种不错的选择。 + +线上测试和监控 + +那么,如何验证多种发布模式是正常的呢?核心就在于线上测试和监控了。实际上,在DevOps中有一种全新的理念,那就是:监控就是一种全量的测试。 + +你可能会问,为什么要在线上进行测试?这岂不是非常不安全的行为吗?如果按照以往的做法,你应该做的就是花费大量精力来建立一个全仿真的预发布环境,尽可能地模拟线上环境的内容,以达到验证功能可用性的目标。但只要做过测试的团队就知道,测试环境永远不能替代生产环境,即便在测试环境做再多的回归,到了生产环境,依旧还是会有各种各样的问题。 + +关于测试环境和生产环境,有一个特别有趣的比喻:测试环境就像动物园,你能在里面看到各种野生动物,它们都活得都挺好的;生产环境就像大自然,你永远无法想象动物园里的动物回到大自然之后会有什么样的行为,它们面临的就是一个完全未知的世界。产生这种差异的原因有很多,比如环境设备的差异、用户行为和流量的差异、依赖服务的差异等,每一个变量都会影响组合的结果。 + +那么,既然无法事先模拟发布后会遇到的所有场景,该如何做线上验证呢?比较常见的,有三种手段。 + +1.采用灰度发布、用户众测等方式,逐步观察用户行为并收集用户数据,以验证新版本的可用性是否符合预期。 + +这里的主要实践之一就是埋点功能。在互联网产品中,埋点是一种最常用的产品分析和数据采集方法,也是数据驱动决策的主要依据之一。它的价值就在于,根据预先设计的收集和监控数据的方法,采集用户的行为、产品质量、运营数据等多维度的数据。 + +大型公司一般都实现了自己的埋点SDK,根据产品设计需求,可以自动化地采集数据,并配置采集粒度;对于小公司来说,像友盟这种第三方统计工具,就可以满足绝大多数情况的需求了。 + +2.用户反馈。 + +除了自动化的采集数据之外,用户主动的反馈也是获取产品信息的第一手资料。而用户反馈的渠道有很多,公司里面一般都有用户运营和舆情监控系统,用于按照“关键字”等自动爬取各个主流渠道的产品信息。一旦发现负面的反馈,就第一时间进行止损。 + +3.使用线上流量测试。 + +这一点在讲暗部署时我也提到过,最典型的实践就是流量镜像。除了做线上的A/B测试,最常用的就是将线上真实的用户流量复制下来,以实时或者离线的方式回放到预发布环境中用于功能测试。 + +除此之外,流量镜像还有很多高级的玩法。像是根据需求选择性地过滤一些信息,比如使用只读的查询内容来验证搜索接口。另外,还可以按照倍数放大和缩小流量,以达到服务压测的目的。还有,可以自动比对线上服务和预发布服务的返回结果,以验证相同的流量过来时,两个版本之间系统的行为是否一致。另外,流量镜像的数据可以离线保存,这对于一些偶发的、难以复现的用户问题,提供了非常难得的数据积累,可以帮助研发团队进一步分析,以避免此类问题的再次发生。 + +在工具层面,我推荐你使用开源的GoReplay工具。它基于Go语言实现,作用于HTTP层,不需要对系统进行大量改造,并且能很好地支持我刚才提到的功能。 + +快速恢复 + +一旦发现新版本发布后不符合预期,或者有严重的缺陷,最重要的就是尽快控制局面,解决故障。平均故障修复时长(MTTR)是DevOps的四个核心指标之一,DevOps的质量信心不仅来源于层层的质量门禁和自动化验证,出现问题可以快速定位和修复,也是不可忽视的核心能力之一。 + +平均故障修复时长可以进一步拆解为平均故障检测时长(MTTD)、平均故障识别诊断时长(MTTI),以及平均故障修复时长(MTTR)。在故障发生后,根据服务可用性指标SLA,对问题进行初步分析定位,明确解决方案。在这个领域,一款好用的线上诊断工具,可以大大地帮助你缓解燃眉之急。比如阿里的开源工具Arthas,就可以实时监控堆栈信息、JVM信息,调用参数,查看返回结果,跟踪节点耗时等,甚至还能查看内存占用、反编译源码等,堪称问题诊断利器。 + + + +初步对问题进行分析定位后,你可以有两种选择:向前修复和向后回滚。 + +向前修复就是快速修改代码并发布一个新版本上线,向后回滚就是将系统部署的应用版本回滚到前一个稳定版本。无论选择哪一种,考验的都是自动化的部署流水线和自动化的回滚能力,这也是团队发布能力的最佳体现。而在DevOps的结果指标中,部署前置时长描述的恰恰就是这段时长。当然,最佳实践就是自动化的流水线。往往在这个时候,你就会希望流水线更快一些,更自动化一些。 + + + +最后,再提一点,你可能在很多大会上听过“故障自愈”,也就是出现问题系统可以自动修复。这听起来有点神奇,但实际上,故障自愈的第一步,就要做好服务降级和兜底策略。这两个听起来很专业的词是啥意思呢?别着急,我给你举个例子,你就明白了。 + +我给你截了两张某购物App的图片,你可以对比看下有什么不同。 + + + +如果你仔细看的话,你会发现,单这一个页面就有大大小小8个差异。所以,服务降级就是指,在流量高峰的时候,将非主路径上的功能进行临时下线,保证业务的可用性。典型的做法就是通过功能开关的方式来手动或自动地屏蔽一些功能。 + + + +而兜底策略是指,当极端情况发生时,比如服务不响应、网络连接中断,或者调用服务出现异常的时候,也不会出现崩溃。常见的做法就是缓存和兜底页面,以及前端比较流行的骨架屏等。 + +总结 + +在这一讲中,我给你介绍了DevOps模式下质量思想的转变,那就是要在保障一定的质量水平的前提下,尽量加快发布节奏,并通过低风险发布手段,以及线上测试和监控能力,尽早地发现问题,并以一种最简单的手段来快速恢复。 + +质量活动是有成本的,为了保证快速迭代发布,一定程度的问题发生并不是末日,更重要的是通过质量活动向前向后延伸,并在生产环境加强监控和测试。同时,三种典型的低风险发布方式可以满足不同业务场景的需求。当问题发生时,不仅要做到快速识别,快速修复,还要提前通过服务降级、兜底策略等机制保证系统服务的连续性。 + +思考题 + +你所在的企业采用了哪些手段来保障部署活动是安全可靠的呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/18混沌工程:软件领域的反脆弱.md b/专栏/DevOps实战笔记/18混沌工程:软件领域的反脆弱.md new file mode 100644 index 0000000..a675e85 --- /dev/null +++ b/专栏/DevOps实战笔记/18混沌工程:软件领域的反脆弱.md @@ -0,0 +1,189 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 混沌工程:软件领域的反脆弱 + 你好,我是石雪峰。 + +经济学领域有一本特别有名的书,叫作《反脆弱》。它的核心理念就是,在面对普遍存在又不可预估的不确定性时,通过一种行之有效的方法,不仅可以规避重大风险,还能够利用风险获取超出预期的回报。另外,通过积极地试错,控制损失成本,还能不断提升在不确定性事件发生时的收益。 + +不仅仅要规避风险,还要在风险中受益,这听起来是不是很神奇?其实,在软件工程领域,也有类似的思想和实践,可以帮助我们在面对极其复杂且规模庞大的分布式系统时,有效地应对不可预见的故障,不仅可以从容不迫地应对,还能从中获益,并且通过频繁、大量地实验,识别并解决潜在的风险点,从而提升对于复杂系统的信心。这就是今天我要给你分享的主题:混沌工程。 + +什么是混沌工程? + +混沌工程作为软件领域的一门新兴学科,就和它的名字一样,让很多人感到非常“混沌”。那么,混沌工程究竟是从何而来,又是要解决什么问题呢? + +我们先来看看混沌原则网站对混沌工程的定义: + + +Chaos Engineering is the discipline of experimenting on a distributed system in order to build confidence in the system’s capability to withstand turbulent conditions in production. + +混沌工程是一门在分布式系统上进行实验的学科,目的是建立人们对于复杂系统在生产环境中抵御突发事件的信心。 + + +简单来说,混沌工程要解决的,就是复杂环境下的分布式系统的反脆弱问题。那么,我们所要面对的“复杂的分布式”的真实世界是怎样的呢? + +我给你举个例子。对于一个大型的平台来说,每日在线的活动数以万计,服务的用户可以达到千万级别。为了满足这种规模的业务量级,仅客户端就有300多个组件,后端服务更是不计其数。 + +可以想象,这样一套复杂的系统,任何一个地方出了一点小问题,都有可能带来线上事故。 + +另外,随着微服务、容器化等技术的兴起,业务驱动自组织团队独立发布的频率越来越高,再加上架构的不断更新演进,可以说,几乎没有人能完整地梳理清楚一套系统的服务间调用关系,这就让复杂系统变成了一个“黑洞”。不管外围如何敲敲打打,都很难窥探到核心问题。 + +为了让你对复杂的真实系统有更加直观的认识,我跟你分享一张Netflix公司在2014年公开的微服务调用关系图,你可以参考一下。 + + + + +图片来源:https://www.slideshare.net/BruceWong3/the-case-for-chaos?from_action=save + + +面对这样复杂的分布式系统,想要通过穷尽全面的测试来保障质量,不出线上问题几乎是不可能的事情。因为测试的假设前提都是为了验证软件的预期行为,而真实世界的问题却从来不按套路出牌,被动遵循已有的经验并不能预防和解决未知的问题。 + +尤其是,如果系统的可用性是基于某一个服务不会出问题来设计的话,那么,这个服务十有八九会出问题。 + +比如,前不久,我们内部的平台就出现了一次宕机,原因是依赖的一个基础服务的认证模块出现了异常,从而导致存储数据失败。因为平台的所有基础数据都在这个看似万无一失的服务上保存,即便监控第一时间发现了这个问题,但是除了等待之外,我们什么都做不了。结果,平台的可用性直接从4个9掉到了3个9。 + +既然面对复杂的分布式系统,我们无法避免异常事件的发生,那么有什么更好的办法,来应对这种不确定性吗?Netflix公司给出了他们的回答,而这正是混沌工程诞生的初衷。 + +区别于以往的方式,混沌工程采取了一种更加积极的方式,换了一个思路主动出击。那就是,尽可能在这些故障和缺陷发生之前,通过一系列的实验,在真实环境中验证系统在故障发生时的表现。根据实验的结果来识别风险问题,并且有针对性地进行系统改造和安全加固,从而提升对于整个系统可用性的信心。 + +服务可用性实践 + +看到这儿,你可能就要问了,这不就是日常的系统可用性保障活动吗?我们公司也有类似的实践呀,比如故障演练、服务降级方案、全链路压测等,这些基本都是大促活动到来前必需的备战活动。 + +的确,这些实践与混沌工程有相似之处,毕竟,混沌工程就是从这些实践中发展起来的,但是,思路又略有不同。 + +比较正规的公司基本上都会有一套完整的数据备份机制和服务应急响应预案,就是为了当灾难发生时,可以保证系统的可用性和核心数据的安全。 + +比如,故障演练就是针对以往发生过的问题进行有针对性地模拟演练。通过事先定义好的演练范围,然后人为模拟事故发生,触发应急响应预案,快速地进行故障定位和服务切换,并观察整个过程的耗时和各项数据指标的表现。 + +故障演练针对的大多是可以预见到的问题,比如机器层面的物理机异常关机、断电,设备层面的磁盘空间写满、I/O变慢,网络层面的网络延迟、DNS解析异常等。这些问题说起来事无巨细,但基本上都有一条清晰的路径,有明确的触发因素,监控事项和解决方法。 + +另外,在故障演练的过程中,很难覆盖所有的故障类型,只能选择典型的故障进行验证。但是实际问题发生时,往往是多个变量一起出问题,逐个排查下来非常耗时耗力。 + +很多公司为了模拟线上的真实场景,于是就引入了全链路压测的技术。对于大促密集的电商行业来说,尤为重要。 + +对于一次完整的压测来说,大致的过程是这样的: + + +首先,准备压测计划,调试压测脚本和环境,对压测容量和范围进行预估; +然后,为了保证线上流量不受影响完成机房线路切换,确保在压测过程中没有线上真实流量的引入; +接着,根据预定义的压测场景执行压测计划,观察流量峰值并动态调整; +最后,在压测完成后,再次进行流量切换并汇总压测结果,识别压测问题。在压测过程中,除了关注QPS指标之外,还要关注TP99、CPU使用率、CPU负载、内存、TCP连接数等,从而客观地体现出大流量下服务的可用性。 + + +从业务层面来说,面对多变的环境因素,完善的服务降级预案和系统兜底机制也是必不可少的。在业务压力比较大的时候,可以适当地屏蔽一些对用户感知不大的服务,比如推荐、辅助工具、日志打印、状态提示等,保证最核心流程的可用性。另外,适当地引入排队机制也能在一定程度上分散瞬时压力。 + +好啦,说了这么多服务可用性的方法,是不是把这些都做到位就可以确保万无一失了呢?答案是否定的。这是因为,这些活动都是在打有准备之仗。但实际上,很多问题都是无法预知的。 + +既然现有的实践并不能帮助我们拓展对不可用性的认知,那么就需要一种有效的实验方法,帮助我们基于各种要素排列组合,从而在问题发生之前,发现这些潜在的风险。 + +比如,Netflix公司著名的“混乱猴子(Chaos Monkey)”,就是用来随机关闭生产环境的实例的工具。在生产环境放任一个“猴子”乱搞事情,这是疯了吗?还真不是。Netflix的“猴子军团”的威力一个比一个巨大,甚至可以直接干掉一个云服务可用区。 + +这背后的原因就是,即便是云服务上,也不能确保它们的服务是永远可靠的,所以,不要把可用性的假设建立在依赖服务不会出问题上。 + +当然,Netflix并没有权限真正关闭云服务上的可用区,他们只是模拟了这个过程,并由此来促使工程团队建立多区域的可用性系统,促进研发团队直面失败的架构设计,不断磨练工程师对弹性系统的认知。 + +引用Netflix的混沌工程师Nora Jones的话来说 + + +混沌工程不是为了制造问题,而是为了揭示问题。 + + +必须要强调的是,在引入混沌工程的实践之前,首先需要确保现有的服务已经具备了弹性模式,并且能够在应急响应预案和自动化工具的支撑下尽早解决可能出现的问题。 + +如果现有的服务连基本的可恢复性这个条件都不具备的话,那么这种混沌实验是没有意义的。我跟你分享一幅混沌工程的决策图,你可以参考一下: + + + + +图片来源:https://blog.codecentric.de/en/2018/07/chaos-engineering/ + + +混沌工程的原则 + +混沌工程不像是以往的工具和实践,作为一门学科,它具有非常丰富的内涵和外沿。你在进入这个领域之前,有必要了解下混沌工程的五大原则:建立稳定状态的假设、真实世界的事件、在生产中试验、持续的自动化实验、最小影响范围。 + +我们分别来看一下这五条原则要如何进行实践。 + +1.建立稳定状态的假设 + +关于系统的稳定状态,就是说,有哪些指标可以证明当前系统是正常的、健康的。实际上,无论是技术指标,还是业务指标,现有的监控系统都已经足够强大了,稍微有一点抖动,都能在第一时间发现这些问题。 + +比如,对于技术指标来说,前面在压测部分提到的指标就很有代表性(QPS、TP99、CPU使用率等);而对于业务指标来说,根据公司具体业务的不同会有所不同。 + +举个例子,对于游戏来说,在线用户数和平均在线时长就很重要;对于电商来说,各种到达率、结算完成率,以及更加宏观的GMV、用户拉新数等,都能表现出业务的健康程度。 + +与技术指标相比,业务指标更加重要,尤其是对电商这种活动密集型的行业来说,业务指标会受到活动的影响,但基于历史数据分析,总体趋势是比较明显的。 + +当业务指标发生大量的抖动时(比如瞬时降低提升),就意味着系统出现了异常。比如,几天前微信支付出现问题,从监控来看,支付的成功率就受到了比较明显的影响。 + +在真实世界中,为了描述一种稳定状态,需要一组指标构成一种模型,而不是单一指标。无论是否采用混沌工程,识别出这类指标的健康状态都是至关重要的。而且,还要围绕它们建立一整套完善的数据采集、监控、预警机制。 + +我给你提供了一些参考指标,汇总在了下表中。 + + + +2.真实世界的事件 + +真实世界的很多问题都来源于过往踩过的“坑”,即便是特别不起眼的事件,都会带来严重的后果。 + +比如,我印象比较深的一次故障就是,服务器在处理并发任务的时候,CPU跑满,系统直接卡死。通过调查发现,在出现问题的时候,系统的I/O Wait很高,这就说明磁盘发生了I/O瓶颈。经过仔细地分析,最终发现是磁盘Raid卡上的电池没电了,从而导致磁盘Raid模式的降级。 + +像这种事情,你很难通过监控所有Raid卡的电池容量来规避问题,也不可能在每次模拟故障的时候,故意换上没电的电池来进行演练。 + +所以,既然我们无法模拟所有的异常事情,投入产出比最高的就是选择重要指标(比如设备可用性、网络延迟,以及各类服务器问题),进行有针对性地实验。另外,可以结合类似全链路压测等手段,从全局视角测试系统整体运作的可用性,通过和稳定状态的假设指标进行对比,来识别潜在的问题。 + +3.在生产中实验 + +跟测试领域的“质量右移理念”一样,混沌工程同样鼓励在靠近生产环境的地方进行实验,甚至直接在生产环境中进行实验。 + +这是因为,真实世界的问题,只有在生产环境中才会出现。一个小规模的预发布环境更多的是验证系统行为和功能符合产品设计,也就是从功能的角度出发,来验证有没有新增缺陷和质量回退。 + +但是,系统的行为会根据真实的流量和用户的行为而改变。比如,流量明星的一则消息就可能导致微博的系统崩溃,这是在测试环境很难复现的场景。 + +但客观来说,在生产环境中进行实验,的确存在风险,这就要求实验范围可控,并且具备随时停止实验的能力。还是最开始的那个原则,如果系统没有为弹性模式做好准备,那么就不要开启生产实验。 + +还以压测为例,我们可以随机选择部分业务模块,并圈定部分实验节点,然后开启常态化压测。通过定期将线上流量打到被测业务上,观察突发流量下的指标表现,以及是否会引发系统雪崩,断路器是否生效等,往往在没有准备的时候才能发现真实问题。这种手段作为混沌工程的一种实践,已经普遍应用到大型公司的在线系统之中了。 + +4.持续的自动化实验 + +自动化是所有重复性活动的最佳解决方案。通过自动化的实验和自动化结果分析,我们可以保证混沌工程的诸多实践可以低成本、自动化地执行。正因为如此,以混沌工程为名的工具越来越多。 + +比如,商业化的混沌工程平台Gremlins就可以支持不可用依赖、网络不可达、突发流量等场景。今年,阿里也开源了他们的混沌工具ChaosBlade,缩短了构建混沌工程的路径,引入了更多的实践场景。另外,开源的Resilience4j和Hystrix也都是非常好用的工具。无论是自研,还是直接采用,都可以帮助你快速上手。 + +我相信,随着越来越多工具的成熟,未来混沌工程也会成为CI/CD流水线的一部分,被纳入到日常工作中来。 + +5.最小的影响范围 + +混沌工程实践的原则就是不要干扰真实用户的使用,所以,在一开始将实验控制在一个较小的范围内,是非常有必要的,这样可以避免由于实验失控带来的更大问题。 + +比如,圈定一小部分用户,或者服务范围,可以帮助我们客观地评估实验的可行性。假设要实验一个API对错误的处理能力,我们可以部署一个新的API实验集群,并修改路由导流0.5%的流量用于线上实验。在这个集群中通过故障注入的方式,验证API是否能够处理流量带来的错误场景。这有点类似于一个灰度实验环境,或者暗部署的方式。 + +除了可以用于验证新功能,做线上的A/B测试,同样适用于混沌工程的故障注入。 + +这五大原则共同勾勒出了混沌工程的全景图,描述系统稳定状态的前提下,将真实世界的事件在生产环境中进行实验,并控制最小影响范围,引入自动化方式持续进行。作为一种全新的工程领域,混沌工程还要走很长的路,才能跨越技术演进的鸿沟。 + + +参考资料:- +Netflix混沌工程成熟度模型- +混沌工程资料集- +Netflix混沌工程手册 + + +总结 + +在这一讲中,我给你介绍了一个应对复杂分布式系统可用性挑战的新学科——混沌工程。实际上,混沌工程采用了一种全新的思路,在系统中主动注入混沌进行实验,以此来发现潜在的真实世界的问题。在服务可用性方面,我们一直在努力实践,比如,故障演练、服务降级、全链路压测已经成为了大型系统的标配。最后,我给你介绍了混沌工程的5个实践原则,希望可以帮助你建立更加全面的认知。 + +不可否认,目前国内在混沌工程领域的实践还处于摸索实验阶段,但是随着系统的复杂性越来越高,混沌工程也注定会跨越技术发展的鸿沟,成为解决复杂系统可用性问题的利器。 + +思考题 + +关于真实世界中发生的异常事件,你有哪些独特的经历呢?结合混沌工程的实践,你有什么新的思路吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/19正向度量:如何建立完整的DevOps度量体系?.md b/专栏/DevOps实战笔记/19正向度量:如何建立完整的DevOps度量体系?.md new file mode 100644 index 0000000..f9e9304 --- /dev/null +++ b/专栏/DevOps实战笔记/19正向度量:如何建立完整的DevOps度量体系?.md @@ -0,0 +1,168 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 正向度量:如何建立完整的DevOps度量体系? + 你好,我是石雪峰。到今天为止,我用14讲的篇幅给你通盘梳理了DevOps的工程实践,基本涵盖了工程实践的方方面面。但是,就像那句经典的“不仅要低头看路,还要抬头看天”说的一样,我们花了这么大的力气投入工程实践的建设,结果是不是符合我们的预期呢? + +所以,在工程实践的最后两讲,我想跟你聊聊度量和持续改进的话题,今天先来看看DevOps的度量体系。 + +我相信,对于每个公司来说,度量都是必不可少的实践,也是管理层最重视的实践。在实施度量的时候,很多人都把管理学大师爱德华·戴明博士的“If you can’t measure it, you can’t manage it”奉为实践圭臬。 + +但是,回过头来想想,有多少度量指标是为了度量而度量的?花了好大力气度量出来的数据会有人看吗?度量想要解决的,到底是什么问题呢? + +所以,度量不是目的,而是手段,也就是说度量的目标是“做正确的事”,而度量的手段是“正确地做事”。 + +那么,什么才是度量领域正确的事情呢?如果想要弄清楚DevOps中的度量长什么样子,关键就是要回到DevOps对于软件交付的核心诉求上。 + +简而言之,对于IT交付来说,DevOps希望做到的就是持续、快速和高质量的价值交付。价值可以是一个功能特性,可以是用户体验的提升,也可以是修复阻塞用户的缺陷。 + +明确了这一点,也就明确了DevOps的度量想要达到的目标,就是为了证明,经过一系列的改进工作,与过去相比,团队的交付速度更快了,交付质量更高了。如果度量的结果不能导向这两个核心目标,那么显然就走错了方向,也就得不到实际想要的结果了。 + +如果只有大方向,往往还是不知道具体要怎么做。这个时候,就需要把目标和方向拆解成一系列的度量指标了。那么,怎样定义好的度量指标和不好的度量指标呢? + +如何定义指标? + +前几天,我被派到某仓库做流水线工人,这个经历让我深刻地理解了工业制造和软件行业的巨大差异。 + +如果你现在问我,决定工业生产流水线速度的是什么?我可以告诉你,答案就是,流水线本身。因为流水线的传送带的速度是一定的,产线速度也就可以直观地量化出来。 + +但是,软件开发不像工业制造,开发的过程看不见摸不着,除了工程师真正编写代码的时间,还要包括构思、设计和测试的时间,以及完成各类流程的时间等等。这个过程中可能还存在着各种并行工作的切换和打断,所以,没法用工业流水线的方式来衡量开发人员的效率。 + +于是,为了达到量化的目的,很多指标就被人为地设计出来了。 + +比如,以准时提测率这个指标为例,这个指标采用的是百分制的形式,按时提测得100分,延期一天得90分,延期两天得70分,以此类推,要是延期五天及以上,就只能0分了。这样的指标看起来似乎足够客观公平,但是仔细想想,延期1天1小时和延期1天23小时,似乎也没有太大区别,得分的高低并不能反映真实的情况。 + +在各个公司的度量体系中,类似的人造指标可谓比比皆是。可见,不好的指标总是五花八门,各有各的样子。不过,好的指标大多具备一些典型的特征。 + +1.明确受众。 + +指标不能脱离受众而单独存在,在定义指标的同时,要定义它所关联的对象,也就是这个指标是给谁看。 + +不同的人关注点自然也不一样,即便指标本身看起来没有什么问题,但是如果使用错位了,也很难产生预期的价值。比如,给非技术出身的老板看单元测试的覆盖率,就没有什么太大意义。 + +2.直指问题。 + +在NBA中,优秀的球员总是自带体系的。所谓体系,就是围绕这个球员的核心能力的一整套战术打法,可以解决球队的实际问题,所以,这个球员的表现就成了整支球队的“晴雨表”。 + +而好的指标也应该是直指问题的,你一看到这个指标,就能意识到问题所在,并自然而然地进行改进,而不是看了跟没看见一样,也不知道具体要做什么。 + +比如,构建失败率很高,团队就会意识到代码的提交质量存在问题,需要加强事前的验证工作。 + +3.量化趋势。 + +按照SMART原则,好的指标应该是可以衡量的,而且是可以通过客观数据来自证的。 + +比如,用户满意度这种指标看起来很好,但很难用数据衡量;再比如,项目达成率这个指标,如果只是靠手工填写,那就没啥说服力。 + +同时,好的度量指标应该能展现趋势。也就是说,经过一段时间的沉淀,指标是变好了,还是变坏了,距离目标是更近了,还是更远了,这些都应该是一目了然的。 + +4.充满张力。 + +指标不应该孤立存在,而是应该相互关联构成一个整体。好的指标应该具有一定的张力,向上可以归并到业务结果,向下可以层层分解到具体细节。这样通过不同维度的数据抽取,可以满足不同视角的用户需求。 + +比如,单纯地度量需求交付个数,就没有太大意义。因为需求的颗粒度会直接影响数量,如果只是把一个需求拆成两个,从而达到需求交付速度加倍的效果,这就失去了度量的意义。 + +定义指标有哪些原则? + +明白了好的度量指标的典型特征,接下来,我们就来看看定义DevOps度量的五条原则: + + +全局指标优于局部指标:过度的局部优化可能对整体产出并无意义,从而偏离了度量的核心,也就是提升交付速度和交付质量。 +综合指标优于单一指标:从单一维度入手会陷入只见树木不见森林的困境,综合指标更加客观。所以,要解决一个问题,就需要一组指标来客观指引。 +结果指标优于过程指标:首先要有结果指标,以结果为导向,以过程为途径,一切过程指标都应该归结到结果指标。 +团队指标优于个人指标:优先考核团队指标而非个人指标,团队共享指标有助于形成内部合力,减少内部的割裂。 +灵活指标优于固化指标:指标的设立是为了有针对性地实施改进,需要考虑业务自身的差异性和改进方向,而非简单粗暴的“一刀切”,并且随着团队能力的上升,指标也需要适当的调整,从而不断挑战团队的能力。 + + +哪些指标最重要? + +基于以上的指标特征和指导原则,并结合业界大厂的一些实践,我给你推荐一套DevOps度量体系。 + +虽然各个公司的度量指标体系都不尽相同,但是我认为这套体系框架足以满足大多数场景,如下图所示: + + + +1.交付效率 + + +需求前置时间:从需求提出到完成整个研发交付过程,并最终上线发布的时间。对业务方和用户来说,这个时间是最能客观反映团队交付速度的指标。这个指标还可以进一步细分为需求侧,也就是从需求提出、分析、设计、评审到就绪的时长,以及业务侧,也就是研发排期、开发、测试、验收、发布的时长。对于价值流分析来说,这就代表了完整的价值流时长。 +开发前置时间:从需求进入排期、研发真正动工的时间点开始,一直到最终上线发布的时长。它体现的是研发团队的交付能力,也就是一个需求进来后,要花多久才能完成整个开发过程。 + + +2.交付能力 + + +发布频率:单位时间内的系统发布次数。原则上发布频率越高,代表交付能力越强。这依赖于架构结构和团队自治、独立发布的能力。每个团队都可以按照自己的节奏安全地发布,而不依赖于关联系统和发布窗口期的约束。 +发布前置时间:指研发提交一行代码到最终上线发布的时间,是团队持续交付工程能力的最直观的考查指标,依赖于全流程自动化的流水线能力和自动化测试能力。这也是DevOps状态报告中的核心指标之一。 +交付吞吐量:单位时间内交付的需求点数。也就是,单位时间内交付的需求个数乘以需求颗粒度,换算出来的点数,它可以体现出标准需求颗粒度下的团队交付能力。 + + +3.交付质量 + + +线上缺陷密度:单位时间内需求缺陷比例,也就是平均每个需求所产生的缺陷数量,缺陷越多,说明需求交付质量越差。 +线上缺陷分布:所有缺陷中的严重致命等级缺陷所占的比例。这个比例的数值越高,说明缺陷等级越严重,体现了质量的整体可控性。 +故障修复时长:从有效缺陷提出到修复完成并上线发布的时间。一方面,这个指标考查了故障定位和修复的时间,另外一方面,也考查了发布前置时间,只有更快地完成发布上线过程,才能更快地修复问题。 + + +这三组、八项指标体现了团队的交付效率、交付能力和交付质量,从全局视角考查了关键的结果指标,可以用于展现团队DevOps改进的效果和价值产出。不过,定义指标只能说是DevOps度量的一小步,只有让这些指标发挥价值,才能说是有意义的度量。 + +如何开启度量工作? + +在企业内部开启度量工作,可以分为四个步骤。 + +第1步:细化指标。 + +一个完整的指标,除了定义之外,还需要明确指标名、指标描述、指标级别(团队级/组织级)、指标类型、适用场景范围及目标用户、数据采集方式和标准参考值。 + +以交付指标为例,我汇总了一份细化后的指标内容,你可以参考下表。其实不仅仅是核心结果指标,只要是在度量体系内定义的指标,都需要进行细化。 + + + +关于指标的参考值,对于不同的业务形态,参考值也有所不同。比如就单元测试覆盖率而言,无人车的业务和普通的互联网业务的差别可能会非常大。 + +所以参考值的选定,需要结合业务实际来分析并达成共识。而且,度量指标本身也需要建立定期更新的机制,以适应于整个团队的能力。 + +第2步:收集度量数据 + +度量指标需要客观数据的支撑,而数据往往都来源于各个不同的平台。所以,在定义指标的时候,你需要评估是否有足够的客观数据来支撑这个指标的衡量。 + +在采集度量数据的初期,我们面临的最大问题不仅是系统众多、数据口径不一致,还包括数据的准确性。 + +举个例子,比如开发交付周期这个指标,一般都是计算一个需求从开始开发到线上发布的时间长度。但是,如果开发人员迟迟不把这个需求设置为“已解决”或者“待测试”状态,那么统计出来的开发周期就存在大量的失真,很难反映出客观、真实的情况。 + +这就需要从流程和平台两个层面入手解决。比如,一方面,从流程层面制定研发操作规范,让每一名研发人员都清楚在什么时间点需要改变需求卡片状态;另一方面,建设平台能力,提供易用性的方式辅助研发,甚至自动流转需求状态。 + +第3步:建立可视化平台。 + +度量指标毕竟是要给人看的,度量数据也需要有一个地方可以收集和运算,这就依赖于度量可视化平台的建设了。关于如何建设一个支持多维度视图、对接多系统数据,以及灵活可编排的度量平台,我会在工具篇给你分享一个案例,帮助你破解度量平台建设的关键问题。 + +第4步:识别瓶颈并持续改进。 + +当数据做到了可信和可视化之后,团队面临的问题和瓶颈会自然而然浮现出来。如何通过指标牵引并驱动团队实施改进,这也是下一讲我们要讨论的核心内容。 + +我给你提供一些常用的度量指标和相关定义,你可以点击网盘链接获取,提取码是c7F3。需要注意的是,指标宜少不宜多,宜精不宜烂,对于企业的DevOps度量而言,这也是最常见的问题,定义了一大堆的指标,却不知道要拿来做什么。 + +只有将指标的定义细化,并在团队内部达成共识,仔细甄别数据的完整和有效性,并做到满足不同维度视角的可视化,才具备了驱动团队进行改进的基础,这一点请你一定要记住。 + +总结 + +总结一下,DevOps度量想要达到的目标,就是证明团队经过一系列的改进工作,与过去相比,交付速度更快了,交付质量也更高了。所以,交付效率和交付质量是最为核心的两个目标。只有围绕这两个目标建立的度量体系,才没有走错方向。 + +好的指标一般都具备四种特性:明确受众、直指问题、量化趋势和充满张力。结合指标特征和指导原则,以及业界大厂的一些实践,我给你介绍了三组、八项核心结果指标,包括效率指标、能力指标和质量指标。最后,我给你介绍了建立度量体系的四个步骤,希望可以帮助你一步步地搭建持续改进的基石。 + +度量是把双刃剑,做得不好反而会伤害团队的士气。如果本末倒置,把度量结果跟个人的绩效相绑定,就很容易使度量这个事情变了味道。很多大公司反反复复地在建立度量体系,就是因为前一个体系被人摸透,变成了数字游戏,于是就失去了原有的目的,只能推倒重来。 + +还是那句话,度量只是一种手段,而非目的。归根结底,度量的真正目的还是团队效率的提升和业务的成功。只有通过度量激起团队自发的改进意愿,提升团队改进的创造性和积极性,才是所谓的“正向度量”,这也是我最想传达给你的理念。 + +思考题 + +你所在的企业是否也在建设DevOps的度量体系呢?你觉得,这些度量指标数据对改进当前的工作是否起到了正面作用呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/20持续改进:PDCA体系和持续改进的意义.md b/专栏/DevOps实战笔记/20持续改进:PDCA体系和持续改进的意义.md new file mode 100644 index 0000000..3d04ade --- /dev/null +++ b/专栏/DevOps实战笔记/20持续改进:PDCA体系和持续改进的意义.md @@ -0,0 +1,151 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 持续改进:PDCA体系和持续改进的意义 + 你好,我是石雪峰。 + +今天是“工程实践篇”的最后一节课,如果你现在问我,在这么多的工程实践中,什么能力是团队在推行DevOps时最应该具备的?我会毫不犹豫地告诉你,那就是持续改进。 + +很多同学在留言区问我:“雪峰老师,我们公司已经搭建了Gitlab,也跟Jenkins实现了打通,做到了自动化的编译打包和发布工作。可是接下来,我们还有啥可以做的呢?我感到很迷茫啊。” + +所以,这就引申出来一个问题:“一个团队做到什么程度,才算是达到了DevOps呢?” + +每每遇到这样的问题,我就会回想起,几年前我去国内一家知名公司的杭州总部交流的经历。 + +当时,负责跟我们对接的是这家公司DevOps的主要推动人,可以说,他见证了这家巨头公司的DevOps转型全过程。在交流时,我问了他一个问题,他的回答让我印象特别深刻。 + +我问他:“你觉得,你们公司是在什么时候实现DevOps转型的呢?”他想了想,说:“现在,我们公司已经没有专职的测试和专职的运维了,基础架构也早就容器化了。这些事情,都是业务发展到一定阶段之后自然而然发生的,只不过,DevOps火起来以后,我们才发现,原来我们一直在做的就是DevOps。所以,很难说在哪个时间点完成了DevOps转型。对我们来说,最重要的就是团队具备了一种能力,就是始终能够找到新的突破,持续追求更好的状态。” + +我想,这段话应该非常能够代表一个团队实施DevOps转型时期望达到的状态吧。 + +其实,如果你有机会去跟谷歌、Netflix的工程师交流一下,你就会发现,这些业界DevOps做得特别牛的公司,内部都不怎么提DevOps的概念。因为,他们早就对DevOps的这些实践习以为常了。很多知名的工具平台,都是内部员工自发地为了解决一些问题而开发出来的。 + +比如,像Gerrit这种非常流行的代码在线评审和管理工具,最开始就是为了解决谷歌内部缺少一种基于Git并且具备权限管控的代码评审工具的问题,才被开发出来的,你可以了解下这段历史。 + +你看,遇到一个钉子,从而造个锤子,和拿着一把锤子,满世界找钉子就是两种截然不同的做法。但很多时候,我们采用的都是后一种做法,手里拿着一堆锤子,却找不到钉子在哪里。 + +所以,如果一定要让我来回答,DevOps做到什么程度,就算是实现转型落地了?那么,我的回答是,核心就是团队已经具备了持续改进的能力,而不只是简简单单地引入了几个工具,建立了几个度量指标而已。 + +说到这儿,你可能会说,这个所谓的持续改进,怎么感觉无处不在呢?似乎很多工程实践的落地方法中,最后一步都是持续改进。那么,持续改进的意义到底是什么呢?为什么一切活动的终极目标都是持续改进呢? + +这是因为,每家公司面临的问题都不一样,从0到1的过程相对比较简单,可以对照着工程实践,快速地引入工具,建立流程,补齐能力短板。但是,从1到N的过程,就需要团队根据业务需要,自行识别改进目标了。 + +还以最开始那个问题为例,基于Gitlab和Jenkins搭建了自动化构建和发布的能力之后,你觉得还有哪些可行的改进方向呢?比如,测试是否注入其中了呢?是否建立了质量门禁机制呢?数据库变更是否实现了自动化呢?构建发布的速度是否足够理想,构建资源是否存在瓶颈? + +能想到的方向有很多,但哪个才是现阶段最重要、价值最大化的点,说到底,还是要看业务的需求,没办法泛泛而谈。 + +谈到持续改进,有一个非常著名的方法体系,叫作PDCA,也称为戴明环。没错,你从名称就能看出,这套方法体系同样来自于质量管理大师戴明博士。PDCA是四个英文单词的缩写,也就是Plan(计划)、Do(实施)、Check(检查)和Action(行动)。 + +PDCA提供了一套结构化的实施框架,任何一项改进类工作,都可以划分为这四个实施阶段。通过PDCA循环的不断迭代,驱动组织进入一种良性循环,不断识别出新的待改进问题。针对这些问题,首先要进行根因分析,制定具体的实施计划。然后,不定期地检查实施的结果和预期目标是否一致。最后,要对改进结果进行复盘,把做得好的地方保留下来,把做得不好的地方纳入下一阶段的循环中,继续改进。 + + + +这个方法听起来也没什么复杂的,每个人都能够理解,关键在于是否真正地用心在做。 + +我再给你分享一个真实的例子。 + +大概两年前,我参与到一家中型企业的DevOps转型工作当中。这家企业刚开始接触DevOps时的状态呢,我就不细说了,反正就是基本啥都没有。代码库使用的是SVN,构建打包都在本地完成,版本发布要两个月,而且经常是多版本并行的节奏,光同步代码就需要专人完成。 + +经过半年多的改造之后,团队内部的整体工具链体系初具规模,版本发布节奏也缩短到了一个月一次,团队对达到的成绩非常满意。 + +当然,这并不是重点,重点是,我上个月又碰到了这个项目的负责人。她跟我说,他们现在的发布节奏已经实现了两周一次,甚至不定期还有临时版本发布。我很好奇,他们究竟是怎么做到的。 + +原来,最开始导入改进方案的时候,我给项目组提到过容器化的思路,但是因为当时客观条件不具备,就没有继续推进下去。没想到,在短短不到一年的时间里,他们已经实现了容器化部署,自建的PaaS平台也有模有样,即便是跟很多大公司相比,也毫不逊色。 + +她说:“这段DevOps转型的过程,带给我们的不仅仅是一些常见的工程实践和工具平台,更重要的是一双总能发现不完美的眼睛和追求极致的态度,以及对这类问题的认知方法。这些驱动我们不断地找到新的方法解决新的问题。” + +的确,很多工程实践和工具平台,在公司内部其实只是一小步,之后遇到的问题和挑战还会有很多。这时候,我们能够依靠的终极奥义就是持续改进的思想,而构建持续改进的核心,就在于构建一个学习型组织。 + +那么,究竟要从哪里开始学习呢?在学习和改进的过程中又有哪些比较推荐的做法呢?我总结了四个实践,你可以参考一下。 + +鼓励正向回溯和总结 + +从失败中学习是我们从小就懂的道理。一个团队对待故障的态度,很大程度上就反映了他们对于持续改进的态度。系统出现故障是谁都不愿意遇到的事情,但在真实世界中,这是没法避免的。 + +在很多公司里面,出现故障之后,有几种常见的做法: + + +把相关方拉到一起,定级定责,也就是确定问题级别和主要的责任方; +轻描淡写地回个改进邮件,但是没有明确的时间节点,即便有,也没人跟踪; +把问题归结为不可复现的偶发事故,最后不了了之。 + + +与这些做法相比,更好的方法是建立一种正向回溯和总结的机制。也就是说,当问题发生之后,事先准备一份详尽的故障分析报告,并拉上相关方一起彻底分析问题的根因,并给出改进任务的具体时间点。 + +故障回溯并不一定以确定责任为第一要务,更重要的是,要识别系统流程中的潜在问题和漏洞,并通过后续机制来进行保障,比如增加测试用例、增加产品走查事项等等。 + +其实,大到线上故障,小到日常错误,都值得回溯和总结。 + +比如,我们每天都会遇到形形色色的编译错误,如果每个人遇到同样的问题,都要爬一次同样的坑,显然是非常低效的。 + +这就需要有团队来负责收集和总结这些常见的错误,并提取关键错误信息和常见解决方法,形成一个案例库。同时,在构建系统中嵌入一个自动化服务,下次再有人遇到编译错误的时候,就可以自动匹配案例库,并给他推送一个问题分析报告和解决建议,帮助团队成员快速解决问题。 + +这样,随着团队智慧的不断积累,越来越多的问题会被识别出来,从而实现组织知识共享和研发辅助的能力,这在很多大公司里面都是一个重点建设方向。仔细想想,这本身就是一个PDCA的过程。 + +不过,这里要补充一点,团队实施持续改进的过程,不应该是一次大而全的变革,而应该是一系列小而高频的改进动作。因为大的变革往往影响众多,很容易半途而废,而小的改进更加温和,也更加容易成功。为了方便你理解,我跟你分享一张示意图。 + + + +预留固定时间进行改进 + +很多时候,团队都处于忙碌的状态,时间似乎成了推行DevOps的最大敌人。于是,团队就陷入了一种太忙以至于没时间改进的状态中。 + +如果团队选择在同等时间内去做更多的功能,那就说明,至少在当前这个阶段,业务开发的重要性要高于DevOps建设的重要性。 + +可问题是,业务的需求是没有止境的。有时候,我去问一线员工:“你觉得有什么地方,是DevOps可以帮你的吗?”要么大家会说“没什么特别的,现在挺好”,要么就是一些非常琐碎的点。实际上,这只能说明,要么是没想过这个事情,要么就是不知道还有更好的做法。但是,如果不能调动一线员工的积极性,持续改进也就无从谈起了。 + +所以,正确的做法是,在团队的日常迭代中,事先给改进类工作预留一部分时间,或者是在业务相对不那么繁忙的时候(比如大促刚刚结束,团队在调整状态的时候),在改进工作上多花些时间。 + +这些工作量主要用于解决非功能需求、技术改进类问题,比如修复技术债务、单元测试用例补充、度量识别出来的改进事项等。通过将这部分改进时间固定下来,可以培养团队持续改进的文化。 + +我比较推荐的做法是,在团队的Backlog中新增一类任务,专门用于记录和跟踪这类持续改进的内容。在迭代计划会议上,对这类问题进行分析,并预估工作量,保证团队有固定的时间来应对这些问题。 + +另外,很多公司也开始流行举办Hackathon Day(黑客马拉松),是说在有限的时间里通过编程实现自身的想法和创意,在这个过程中,充满了积极探索的精神、自由散发的思维和挑战极限的理念,通过团队协作与互相激发,实现创意到开发的全过程。 + +我们团队最近也在准备参加今年的黑客马拉松,希望通过这个途径寻求合作共建,除了解决内部效率提升的“老大难”问题,还能提升团队成员的积极性,在更大的舞台上展现DevOps的价值,一举两得。 + +在团队内部共享业务指标 + +很多时候团队成员都像是临时工一样,对于自己所负责的需求和业务的表现一概不知。如果团队成员对一件事情没有归属感,那么又如何激发他们的责任感和自我驱动意识呢? + +所以,对于业务的指标和表现,需要尽可能地在团队内部做到透明,让团队成员可以接触真实世界的用户反馈和评价,以及业务的度量信息。 + +在一个新功能开发完成上线之后,要能实时查看这个需求的上线状态。如果需求分析时已经关联了业务考核指标,那么,同样可以将该业务关联的指标数据进行展示。这样,研发就会知道自己交付的内容有多少问题,用户的真实反馈是怎样的,从而促使团队更多地站在用户的视角思考问题。 + +除了业务指标,DevOps的指标体系也应该对内部公开透明。大家可以查看自己所在团队的表现,以及在公司内部的整体水平。 + +适当的侧向压力,会促使大家更加主动地接受改进工作,并且通过度量数据展示改进的效果,从而形成正向的循环。 + +激励创造性,并将价值最大化 + +每个团队中都不乏有创新意愿和思想的员工,他们总是能从墨守成规的规范中找到可以进行优化的点。 + +比如,之前,我们团队的一个测试人员发现,日常埋点测试费时费力,而且没有数据统计。于是,她就自己利用业余时间开发了一个小工具,用工具来承载这部分工作,效率大幅提升。 + +如果更多人知道这样的创新,并且在更大范围内使用,不仅可以提升更多人的效率,让团队整体受益,而且还可以减少类似的重复建设,让有想法的员工一起参与工具优化。 + +比较好的做法是,在团队成员的绩效目标中,增加对团队贡献和技术创新的要求,在团队内部鼓励创新类工作。另外,在团队内部建立对应的选拔和激励机制,为好的想法投入资源,把它们变成可以解决类似问题的工具。 + +很多公司也开始注意到这种内部知识复用的重要性,所以,无论是代码库开源,还是公共基础组件的市的建设,甚至是公司级的平台治理系统,都可以帮助你快速地复用已有的能力,避免一直重复造轮子。 + +总结 + +就像每个工程实践的终点都是持续改进一样,我们专栏的“工程实践篇”同样以持续改进的实践作为收尾。 + +我始终认为,团队是否建立了持续改进的文化,是评估团队的DevOps实践效果的重要参考。在这一讲中,我给你介绍了PDCA的持续改进方法体系,也就是通过计划、实施、检查、行动这四个步骤的持续迭代,不断把团队推向更优的状态,促使团队进入正向发展的车道。 + +另外,我给你介绍了四个持续改进落地的方法,包括在失败中总结和学习,建立固定的改进时间,在团队内部共享指标、培养团队的责任感,以及激发团队的创造力并将价值最大化。这些方法的核心就是想打造一个学习型的组织和文化,给DevOps的生根发芽提供丰饶的养分。 + +从下一讲开始,我们将进入“工具实践篇”,我会给你介绍一些核心工具的设计思想、建设路径,以及一些常见开源工具的使用方法等,敬请期待。 + +思考题 + +除了我提到的这四种持续改进的手段,你所在的公司,有什么活动可以促进持续改进文化的建设吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/21开源还是自研:企业DevOps平台建设的三个阶段.md b/专栏/DevOps实战笔记/21开源还是自研:企业DevOps平台建设的三个阶段.md new file mode 100644 index 0000000..10083c9 --- /dev/null +++ b/专栏/DevOps实战笔记/21开源还是自研:企业DevOps平台建设的三个阶段.md @@ -0,0 +1,225 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 开源还是自研:企业DevOps平台建设的三个阶段 + 你好,我是石雪峰,从今天开始,专栏正式进入了“平台工具篇”。 + +在这个全新的章节,我重点想讲三个方面的内容: + + +帮助你梳理企业内部DevOps平台的实施路径,理清平台建设的主体脉络; +给你分享一些核心平台的建设经验,这些经验都来自于生产一线; +给你分析一下DevOps平台的发展方向和热门趋势,让你在进行平台建设时,能够跟上潮流。 + + +我想跟你说的是,没有人天生就是DevOps平台的产品经理,但每一个人都能成为DevOps平台的产品经理。 + +因为,DevOps平台的产品与业务方向的产品不同,它要解决的就是一线研发交付团队的实际问题。 + +普通的产品经理没有研发交付的背景,很难理解研发交付的困境,而研发交付团队又缺少产品经理的技能和思路。所以,这个领域的人才少之又少,基本只能靠内部培养,我希望你能通过专栏的学习,摸索出一些产品设计的门道。 + +好了,今天,我们就来聊一聊企业DevOps平台建设的话题。 + +就像我之前提到的那样,在企业内部推行DevOps,工具不是万能的,但是没有工具,却是万万不能的。 + +当企业决定引入DevOps工具的时候,无外乎有三种选择:直接使用开源工具;采购商业工具;自己研发工具。 + +你可能会说,如果有能力,当然是选自研工具啊,自主可控,又有核心竞争力。可是,在DevOps状态报告中,却有一些不同的发现。 + +那些倾向于使用完全自建工具的企业,效能水平往往不高。所谓的完全自建工具,是指不依赖于开源解决方案,整个工具完全由自己来实现。而那些大部分采用开源工具的企业,效能水平反而不差。 + +这就有点反常理了。企业花了这么大的时间和精力来建设内部工具,到最后却没有达到预期的效果,究竟是为什么呢? + +在我看来,这是因为没有找到企业内部平台建设的正确路径。我们要在正确的时候,做正确的事情,太超前,或者太落后,都是会有问题的。 + +那么,接下来,我就跟你聊聊企业DevOps平台建设的三个阶段。 + +阶段一:从无到有 + +在这个阶段,企业的DevOps平台建设处于刚刚起步的状态,在整个交付过程中,还有大量的本地操作和重复性的操作。 + +另外,企业内部一般也没有一个成体系的工具团队,来专门负责平台能力建设。 + +那么,对于这个阶段,我给你的建议是:引入开源工具和商业工具,快速补齐现有的能力短板。 + +所谓能力短板,其实就是当前交付工具链体系中缺失的部分,尤其是高频操作,或者是涉及多人协作的部分,比如,需求管理、持续集成等。 + +无论是开源工具,还是商业工具,基本都是比较成熟的、拿来即用的,这种“即战力”是当前企业最需要的。因为,工具的引入解决了从无到有的问题,可以直接提升单点效率。这也是在DevOps转型初期,团队的效率能够飞速提升的主要原因。 + +看到这里,你可能会问两个问题:“如何选择工具?”“为什么商业工具也是可选项?” + +其实,这也是团队在引入工具的初期,最头疼的两个典型问题,我们一一来看下。 + +如何选择工具? + +现在,以DevOps为名的工具太多了。想要在这么多工具中,选择一款合适的,你要怎么做呢? + +有的人可能会把相关工具的功能列表拉出来,然后逐项比对,看哪个工具的功能更加强大。其实,我觉得,在从无到有的阶段,不需要这么复杂,核心原则就是选择主流工具。 + +主流工具就是业内大家用得比较多的,在各种分享文章里面高频出现的,使用经验一搜一大把的那种工具。我给你提供一些工具,你可以参考一下: + + +需求管理工具Jira; +知识管理工具Confluence; +版本控制系统GitLab; +持续集成工具Jenkins; +代码质量工具SonarQube; +构建工具Maven/Gradle; +制品管理Artifactory/Harbor; +配置管理工具Ansible; +配置中心Apollo; +测试工具RF/Selenium/Appium/Jmeter/TestNG; +安全合规工具BlackDuck/Fortify; +…… + + +在初期,工具要解决的大多是单点问题,主流工具意味着更好的可扩展性,比如有完整的接口列表,甚至对其他工具已经内置了插件支持。 + +另外,很多开发实践都是基于主流工具来设计的。业内对于这些工具摸索得也比较深,有很多现成的实践经验,这些都对应了快速补齐能力短板的目标。 + +我之前见过一家大型金融机构,他们也在考虑将代码管理从SVN切换到Git。但是,他们选择的Git平台既不是开源的GitLab、Gerrit,也不是商业化的主流工具,而是一个听都没听过的开源工具。 + +这个工具的操作流程跟一般工具都不太一样,配套的评审、集成功能也都不够完善。最后,这家机构还是改用主流工具了。 + +为什么商业工具也是可选项? + +随着开源工具的成熟和完善,越来越多的公司,甚至是传统企业,都开始积极拥抱开源,似乎开源就是代表未来的趋势。 + +那么,是不是只选择开源工具就行了,不用考虑商业工具了呢?我觉得,这种想法也是比较片面的。 + +商业工具的优势一直都存在,比如,专业性、安全性、扩展性、技术支持力度等。其实,很多开源工具都有商业版本。 + +比如,很多公司即便有开源的Nexus,制品管理工具Artifactory也都是标配。因为,Artifactory无论是在支持的制品类型、分布式部署、附加制品安全漏洞检查,还是在与外部工具的集成等方面,都有着明显的优势。 + +另外,像Jira这种需求和缺陷管理工具,与Confluence深度集成的话,足够满足绝大多数公司的需求。 + +再举个例子,安卓开发最常见的Gradle工具,它的商业版本可以直接让你的编译速度提升一个数量级。在最开始时,你可能觉得够用就行,但是当你开始追求极致效率的时候,这些都是核心竞争力。 + +选择商业工具的理由有很多,不选的理由大多就是一个字:贵。针对这个问题,我要说的是,要分清一笔支出到底是成本,还是投资。 + +就跟购买黄金一样,虽然也花了钱,但这是一笔投资,未来可以保值和增值,甚至是变现。对于商业工具来说,也是同样的道理。如果一款商业工具可以大幅提升团队效率,最后的产出可能远超最开始的投资。如果我们组建一个团队,仿照商业工具,开发一套自研工具,重复造轮子的成本也可能一点不少。所以,重点就是要看怎么算这笔账。 + +阶段二:从小到大 + +经过了第一个阶段,企业交付链路上的工具基本都已经齐全了。团队对于工具的需求开始从够用到好用进行转变。另外,随着业务发展,团队扩大,差异化需求也成了摆在面前的问题。再加上,人和数据都越来越多,工具的重要性与日俱增。 + +那么,工具的稳定性、可靠性,以及大规模使用的性能问题,也开始凸显出来。 + +对于这个阶段,我给你的建议是:使用半自建工具和定制商业工具,来解决自己的问题。 + +所谓半自建工具,大多数情况下,还是基于开源工具的二次开发,或者是对开源工具进行一次封装,在开源工具上面实现需要的业务逻辑和交互界面。 + +比如,基于Jenkins封装一套自己的构建打包平台,完全可以利用Jenkins API和插件扩展实现。我附上了一幅架构示意图,你可以参考一下。 + + + +那么,半自建工具有哪些注意事项呢?虽然各个领域的工具职能千差万别,但从我的经验来看,主要有两点:设计时给扩展留出空间;实现时关注元数据治理。 + +设计时给扩展留出空间 + +刚开始建设平台的时候,很容易就事论事,眼前有什么问题,就提供什么功能。这固然是比较务实的态度,但对于平台而言,还是要有顶层设计,给未来留出扩展性。这么说可能比较抽象,我来给你举几个实际的例子,也是我们之前踩过的“坑”。 + +案例一: + +平台的初期设计没有考虑租户的特性,只是为了满足单一业务的使用。当功能比较成熟,想要对外输出的时候,我们发现,要重新在更高的维度插入租户,导致系统需要进行大幅改造,不仅功能页面需要调整,连权限模型都要重新设计。 + +如果在设计平台之初,就考虑到未来的扩展需求,把单一业务实现为一个平台租户,会不会更好些呢? + +案例二: + +为了满足快速上线的需要,我们对Jenkins进行了简单封装,实现了在线打包平台。但是,打包页面的参数都“写死”在了页面中。另外,每接入一个项目,就需要单独实现一个页面。后来,面对上百个应用的接入所带来的差异化需求,平台只能推倒重来。 + +如果最开始在设计的时候,就采用接口获取的方式,将参数实现配置化,会不会更好些呢? + +除此之外,在技术选型的时候,前后端分离的开发方式、主流的技术栈选型、一些典型的设计模式、相对统一的语言类型,其实都有助于平台空间的后续扩展。 + +功能可以快速迭代,人员可以快速进入团队,形成战斗力,在设计平台的时候,这些都是需要思考的问题。 + +当然,顶层规划,不代表过度设计。我只是说,要在可以预见的范围内,预留一些空间,从而规避后期的尴尬。 + +实现时关注元数据治理 + +所谓元数据,也就是常说的meta-data,可以理解为钥匙链,这些数据可以串起整个平台的数据结构。比如应用名称、模块名称、安全ID等等。 + +各个平台在组织数据结构的时候,都需要用到这些元数据,而且一旦使用了,轻易都不好改变。因为,在数据模型里面,这些元数据很有可能已经作为各种主键、外键的约束存在了。 + +对于单一平台来说,怎么维护这些元数据,都没什么大问题,但是,对于后续平台间的打通而言,这些元数据就成了一种标准语言。如果平台间的语言不通,就需要加入大量的翻译处理过程,这就导致系统性耦合加大,连接变得脆弱。 + +比如,同样是购物车模块,在我的平台里面叫购物车,而在你的平台里面叫shopping-cart,而且还按照平台划分,比如shopping-cart-android、shopping-cart-ios,甚至还有一些特性维度,比如shopping-cart-feature1等等。显然,想让两边的数据对齐,并不容易。 + +当然,元数据的治理并不是单一平台能够解决的事情,这同样需要顶层规划。 + +比如,在公司内部建立统一的CMDB,在其中统一管理应用信息。或者,建立应用创建审批流程,通用一个标准化流程,来管控应用的生命周期,同时管理应用的基础信息。这些都属于技术债务,做得越晚,还债的成本就越高。 + +阶段三:从繁到简 + +到了第三个阶段,恭喜你已经在DevOps平台建设方面有了一定的积累,在各个垂直领域也积累了成功案例。那么,在这个阶段,我们要解决的主要问题有3点: + + +平台太多。做一件事情,需要各种切来切去; +平台太复杂。想要实现一个功能,需要对相关人员进行专业培训,他们才能做对; +平台价值说不清。比如,使用平台,能带来多大价值?能给团队和业务带来多大贡献? + + +对于这个阶段,我给你的建议是:使用整合工具来化繁为简,统一界面,简化操作,有效度量。 + +整合工具,就是包含了开源工具、半自研工具、商业工具的集合。 + +你要提供的不再是一个工具,而是一整套的解决方案;不是解决一个问题,而是解决交付过程中方方面面的问题。 + +企业工具平台治理 + +如果最开始没有一个顶层规划,到了这个时候,企业内部大大小小的工具平台应该有很多。你需要做的第一步,就是平台化治理工作。 + +首先,你要识别出来有哪些工具平台,使用情况是怎样的,比如有哪些业务在使用,实现了哪些功能。 + +如果要把所有工具平台收编起来,这不是一件容易的事情,甚至超出了技术的范畴。尤其是对很多大企业来说,工具平台是很多团队的根基,如果不需要这个平台,就意味着团队的重心也得调整。 + +所以,我给你的第一条建议是比较温和可行的,那就是,找到软件交付的主路径。用一个平台覆盖这条主路径,从而串联各个单点上的能力,让一些真正好的平台能够脱颖而出。而要做到这个事情,就需要持续交付流水线了。 + +这些年来,我一直在从事持续交付平台的建设,也总结了很多经验。我会在后面的内容中,跟你好好聊聊,如何设计一个现代的持续交付流水线平台。 + +流水线平台与一体化平台之间,还是有很大差距的。毕竟,各种工具平台的设计思路、操作路径、界面风格,差别很大。 + +所以,在实际操作的过程中,我给你的第二条建议就是,区分平台和工具,让平台脱颖而出。 + +比如,测试环境存在大量的工具,而一整套测试平台,实际上可以满足测试方方面面的需求,也就是说,测试人员只要在这个平台上工作就够了。当企业内部繁杂的工具收敛为几个核心平台之后,对于用户来说,就减少了界面切换的场景,可以通过平台和平台对接完成日常工作。 + +打造自服务的工具平台 + +到了这个阶段,自服务就成了平台建设的核心理念。 + +所谓自服务,就是用户可以自行登录平台实现自己的操作,查看自己关心的数据,获取有效的信息。 + +而想要实现自服务,简化操作是必经之路。说白了,如果一件事情只要一键就能完成,这才是真正地实现了自服务。 + +这么说可能有点夸张。但是,打破职能间的壁垒,实现跨职能的赋能,依靠的就是平台的自服务能力。很多时候,当你在埋怨“平台设计得这么简单,为啥还是有人不会用”的时候,其实这只能说明一个问题,就是平台依然不够简单。 + +之前,Jenkins社区就发起过一个项目,叫作“5 Click,5 Minutes”,意思是希望用户只需要5次点击,花5分钟时间,就能完成一个Jenkins服务的建立。 + +这个项目的结果,就是现在的Jenkins创建导航,通过把建立服务的成本降到最低,从而帮助更多的用户上手使用。 + +你看,用户体验是否简单,与技术是否高深无关,重点在于是否能够换位思考。所以,在建设平台的时候,要始终保有一份同理心。 + +总结 + +企业内部的平台化建设是个长期问题,如果你要问我,企业要建设DevOps平台,有什么经验总结吗?我的回答就是“四化”:标准化、自动化、服务化和数据化。实际上,这些也是指导平台建设的核心理念。 + + +标准化:一切皆有规则,一切皆有标准; +自动化:干掉一切不必要的手工操作环节,能一键完成的,绝不操作两次; +服务化:面向用户设计,而不是面向专家设计,让每个人都能在没有外界依赖的前提下,完成自己的工作; +数据化:对数据进行收集、汇总、分析和展示,让客观数据呈现出来,让数据指导持续改进。 + + +思考题 + +最后,关于平台化建设,你有什么私藏的好工具吗?可以分享一下吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/22产品设计之道:DevOps产品设计的五个层次.md b/专栏/DevOps实战笔记/22产品设计之道:DevOps产品设计的五个层次.md new file mode 100644 index 0000000..b558fe4 --- /dev/null +++ b/专栏/DevOps实战笔记/22产品设计之道:DevOps产品设计的五个层次.md @@ -0,0 +1,133 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 产品设计之道:DevOps产品设计的五个层次 + 你好,我是石雪峰。 + +在上一讲中,我们聊到了企业DevOps平台建设的三个阶段。那么,一个平台产品到底做到什么样,才算是好的呢?不知道你有没有想过这个问题,反正做产品的这些年来,我一直都在思考这个事儿。直到我听到了梁宁的专栏里面讲到的用户体验的五层要素,才发现,无论什么产品,其实都是为了解决一群特定的人在特定场景的特定问题。 + +那么,回到我们的DevOps产品,我们可以借鉴一下梁宁老师的思路,来看看DevOps产品设计体验的五个层次:战略存在层、能力圈层、资源结构层、角色框架层和感知层。 + + + +这么多专有名词一股脑地蹦出来,估计你头都大了吧?没关系,接下来我会逐一解释一下。 + +第一个层次:战略存在层 + +在决定开发一个DevOps产品的时候,我们首先要回答的根本问题就是,这个产品解决了什么样的痛点问题?换句话说,我们希望用户通过这个产品得到什么?显然,目标用户和痛点问题的不同,会从根本上导致两套DevOps产品之间相距甚远。 + +举个例子,业界很多大公司在内部深耕DevOps平台很多年,有非常多很好的实践。但是,当他们准备把这些内部平台对外开放,提供给C端用户使用的时候,会发现存在着严重的水土不服问题。 + +有些时候,内外部产品团队有独立的两套产品,对外提供的产品版本甚至比对内的版本要差上几年。这就是用户群体的不同造成的。C端用户相对轻量级,需要的功能大多在具体的点上,而企业内部因为多年的积累,有大量的固有流程、系统、规则需要兼顾。所以,整套产品很重,甚至是完全封闭的一套体系,难以跟用户现有的平台进行打通。 + +所以,我见过很多产品团队,他们对自己初期的产品定位并非在用户需求本身,而是在同类竞争对手身上。也就是说,他们先从模仿业界做得比较好的同类产品开始,从产品设计、功能模块到用户交互等,一股脑地参考同类产品,美其名曰“至少先赶上业界主流水平再说”。于是乎,团队开足马力在这条路上渐行渐远。 + +当然,借鉴同类产品的先进经验,这个做法本身并没有什么问题,毕竟,这些经验已经经过市场和用户的检验,至少走偏的风险不大。可问题是,同类产品的经验并不能作为自己产品的战略。 + +亚马逊的CEO贝佐斯就说过一句特别著名的话:“要把战略建立在不变的事物上。”比如,如果竞争对手推出了一项新的功能,或者他们改变了自己的方向,那么我们的战略是否要随之变化,继续迎头赶上呢?这是一个值得产品团队深思的问题。 + +以我所在的电商行业为例,我们的产品始终在强调用户体验,但好的产品设计和用户体验绝不是因为友商做了什么花哨的改变,而是始终着眼于那些长久不变的事物之上,也就是多、快、好、省。因为,不管什么时候,用户选择在你的平台购物,肯定不会是因为你的产品比其他家的要贵吧?同样的道理,对于DevOps产品来说,也是这样。 + +那么,有没有永远不变的内容可以作为DevOps产品的战略定位呢?显然也是有的,那就是:效率、质量、成本和安全。归根结底,产品的任何功能都是要为战略服务的。比如,构建加速,要解决的就是效率问题,而弹性资源池,自然更加关注成本方面的问题。在任何时候,如果你的产品能在某个点上做到极致,那么恭喜你,你就找到了自己产品的立身之本。 + +明确目标用户,定义刚性需求,服务于典型场景,并最终在某一个点上突出重围,这就是我们在准备做DevOps产品的时候首先要想清楚的问题。无论是对内产品,还是对外产品,道理都是一样的。 + +第二个层次:能力圈层 + +战略很好,但是不能当饭吃。为了实现战略目标,我们需要做点什么,这就是需要产品化的能力。所谓产品化,就是将一个战略或者想法通过产品分析、设计、实验并最终落地的过程。 + +很少公司会有魄力一上来就投入百人团队开发DevOps产品,大多数情况下,都是一两个有志青年搭建起草台班子,从一个最简单的功能开始做起。资源的稀缺性决定了我们永远处于喂不饱的状态,而在这个时候,最重要的就是所有为,有所不为。 + +我们一定要明确,哪些是自己产品的核心竞争力,而哪些是我们的边界和底线,现阶段是不会去触碰的。当我们用这样一个圈子把自己框起来的时候,至少在短期内,目标是可以聚焦的。 + +当然,随着产品的价值体现,资源会随之而扩充,这个时候,我们就可以调整、扩大自己的能力圈。但说到底,这些能力都是为了实现产品战略而存在的,这一点永远不要忘记。 + +我还是拿个实际的案例来说明这个问题。之前在企业内部启动持续交付流水线项目的时候,我们这个草台班子总共才4个人,而我们面对的是千人的协同开发团队。在每个业务领域内部,都有很多的产品工具平台在提供服务,缺少的就是平台间的打通。 + +对于企业而言,一套完整覆盖端到端的研发协作平台看起来很美,但是,要做这么一套东西,投入巨大不说,还会同现有的工具平台产生冲突,这样就变成了一个零和游戏。 + +所谓零和游戏,就是所有玩家资源总和保持固定,只是在游戏过程中,资源的分配方式发生了改变。 + +就现在的这个例子来说,如果平台潜在用户总量是一定的,有一方向前一步,必定有另外一方向后一步,这显然不是我们这个“小虾米团队”现阶段能做到的。 + +所以,我们就给自己的产品定义了一个能力圈,它的边界就在于不去替换现有的工具平台,而是只专注于做链路打通的事情。这样一来,既有平台仍然可以单独提供服务,也可以通过标准化的方式提供插件,对接到我们的平台上来,我们的平台就成了它们的另外一套入口,有助于用户规模的扩大。 + +而对于我们自己来说,这些平台能力的注入,也扩展了我们自己的能力圈外沿,这些既有平台的用户就成了我们的潜在用户群体。这种双赢的模式,后来被证明是行之有效的,平台获得了很大的成功。 + +在跟很多朋友交流产品思路的时候,我总是把主航道和护城河理论挂在嘴边。所谓主航道,就是产品的核心能力,直接反射了产品战略的具体落地方式。对于流水线产品来说,这个能力来源于对软件交付过程的覆盖,而不论你将来开发任何产品,这条主路径都是无法回避的。那么,产品就有了茁壮成长的环境和土壤。而护城河就是你这个产品的不可替代性,或者是为了替代你的产品需要付出的高额代价。 + +还是引用流水线产品的例子,我们的护城河一方面来源于用户数据的沉淀,另一方面就在于这些外部能力的接入。你看,随着接入平台的增多,我们自身产品的护城河也越发难以逾越,这就是对于能力圈更加长远的考量了。 + +第三个层次:资源结构层 + +为什么做和做什么的问题,我们已经解决了,接下来,我们就要掂量掂量自己在资源方面有哪些优势了。 + +资源这个事儿吧,就像刚才提到的,永远是稀缺的,但这对于所有人来说都是公平的。所以,对资源的整合和调动能力就成了核心竞争力。当你没有竞争对手的时候,用户选择你的产品并不是什么难事,因为既然解决了一个痛点问题,又没有更好的选择,用一用也无妨。 + +可现实情况是,无论是企业内部,还是外部,我们都身处在一个充满竞争的环境,最开始能够吸引用户的点,说起来也很可笑,很多时候就在于让用户占了你的资源的便宜,也就是用户认为你的产品有一些资源是他们不具备的。 + +举个例子,在很长一段时间内,App的构建和打包都是基于本地的一台电脑来做的,这样做的风险不用多说,但是大家也没什么更好的选择。尤其是面对iOS这种封闭的生态环境,想要实现虚拟化、动态化也不是一句话的事情,甚至有可能触犯苹果的规则红线。 + +这时,如果你的产品申请了一批服务器,并且以标准化的方式部署在了生产机房,那么这些资源就成了产品的核心能力之一。 + +随着越来越多的用户跑来占便宜,产品对于大规模资源的整合能力就会不断提升,从而进一步压低平均使用成本,这就形成了一个正向循环。 + +产品蕴含的资源除了这些看得见、摸得着的机器以外,还有很多方面,比如,硬实力方面的,像速度快、机器多、单一领域技术沉淀丰富,又比如,强制性的,像审批入口、安全规则,还有软性的用户习惯,数据积累等等。 + +对于内部DevOps产品来说,还有一项资源是至关重要的,那就是领导支持。这一点我们已经在专栏第6讲中深入讨论过了,我就不再赘述了。 + +第四个层次:角色框架层 + +当用户开始使用你的产品时,不要忘了,他们是来解决问题的,而每一个问题背后都存在一个场景,以及在这个场景中用户的角色。脱离这个场景和角色的设定,单纯讨论问题是没有意义的。 + +所以,我们总说,要站在用户的角度来看待问题,要在他们当时的场景下,去解决他们的问题,而不是远远地观望着,甚至以上帝视角俯视全局。 + +举个例子,当你和其他部门在为了一个功能设计争得面红耳赤,差点就要真人PK的时候,你们的领导走进了会议室,你猜怎样,瞬间气氛就缓和起来,似乎刚才什么也没发生过。这难道是因为我们的情绪管理能力很强吗?其实不然,这主要是因为我们身处的场景发生了变化,我们的角色也发生了改变。 + +再举个产品的例子,当我们在开发流水线产品的时候,为了满足用户不同分支构建任务的需求,我们提供了分支参数的功能。但是,在收集反馈的时候,全都是负面声音,难道这是个“伪需求”吗? + +其实不是。通过实际数据,我们可以看到,很多用户已经开始使用这个功能了。这不是得了便宜又卖乖吗?问题就在于,我们没有站在用户当时的角色框架下,来思考这个问题。 + +因为,分支功能是需要用户手动输入的,但分支名又长又容易出错,每次都要从另外一个系统或者本地复制粘贴。当这个场景出现一次的时候并不是什么大事,但是,如果每个人每天都要做几十次的话,这就是大问题了。其实,解决思路很简单,增加历史信息或者自动关联的功能就可以啦。 + +所以你看,有时候我们不需要多么伟大的创造和颠覆,基于核心场景的微创新也能起到正向的作用。 + +说到底,其实就是一句话:不要让你的产品只有专业人士才会使用。 + +为了兼容灵活性,很多产品都提供了很多配置,但是,对于当时这个场景来说,绝大多数配置,都是没人关心的。产品应该提供抽象能力屏蔽很多细节,而不是暴露很多细节,甚至,好的产品自身就是使用说明书。这一点,在注意力变得格外稀缺的现在,重要性不可忽视。 + +第五个层次:感知层 + +现在,我们来看看最后一个层次:感知层,这也是距离用户最近的一个层次。 + +不可否认,这是一个看脸的时代,但是产品终究是给人用的,而不是给人看的。所以,很多人甚至强调,对于内部产品来说,UI完全不重要,家丑不外扬就好了。 + +可是,换位思考一下,你希望自己每天打交道的是一个设计凌乱、完全没有美感的产品吗? + +答案很有可能是否定的。可这对于很多DevOps的产品经理来说,是最难的一点。这是因为,没有人天生就是DevOps产品经理,很多人都是半路出家,做开发的,做测试的,甚至是当老板的。 + +让不专业的人做专业的事情,结果可想而知,好多产品功能的设计都堪称是“反人类”的。 + +关于这个层次,我提供两点建议: + + +多跟前端工程师交流。现在的前端框架已经非常成熟了,基于模板,我们可以快速地搭建出一个平台。而且,模板的框架自身,也蕴含着很多的设计思想。 + +多学习一些基本的设计原则。你可以参考Element官网上的设计理念章节,里面谈到了一致、反馈、效率和可控四个方面,每个方面又涉及很多细节。参照着成熟的产品,再对照这些基本设计理念,你放心,你会进步神速的。 + + +总结 + +今天,我们介绍了DevOps产品设计的五个层次,包括:战略存在层、能力圈层、资源结构层、角色框架层和感知层。其实,当用户吐槽你的产品,或者产品迟迟没有提升的时候,我们可能就要沉下心来,对照着这五个层次,来看看问题到底出在哪里了。 + +思考题 + +你有用到过什么好的DevOps产品吗?它们有哪些功能,让你眼前一亮,不由得为这个产品点赞吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/23持续交付平台:现代流水线必备的十大特征(上).md b/专栏/DevOps实战笔记/23持续交付平台:现代流水线必备的十大特征(上).md new file mode 100644 index 0000000..3247b94 --- /dev/null +++ b/专栏/DevOps实战笔记/23持续交付平台:现代流水线必备的十大特征(上).md @@ -0,0 +1,225 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 持续交付平台:现代流水线必备的十大特征(上) + 你好,我是石雪峰。 + +作为DevOps工程实践的集大成者和软件交付的“理想国”,持续交付对企业的DevOps落地起到了举足轻重的作用。我接触过的企业全都在建设自己的流水线平台,由此可见,流水线是持续交付中最核心的实践,也是持续交付实践最直接的体现。 + +那么,如何建设一个现代流水线平台呢?这个平台,应该具备哪些特性呢? + +根据我自己在企业内部建设落地流水线平台的经验,以及业界各家公司的平台设计理念,我提取、总结了现代流水线设计的十大特性。 + +在接下来的两讲中,我会结合平台设计,给你逐一拆解这些特性背后的理念,以及如何把这些理念落地在平台设计中。我把这十个特性汇总在了下面的这张图片里。今天,我先给你介绍下前五个特性。 + + + +特性一:打造平台而非能力中心 + +与其他DevOps平台相比,流水线平台有一个非常典型的特征,那就是,它是唯一一个贯穿软件交付端到端完整流程的平台。正因为这样,流水线平台承载了整个软件交付过程方方面面的能力,比如,持续集成能力、自动化测试能力、部署发布能力,甚至是人工审批的能力等。 + +那么,我们把软件交付过程中所需要的能力都直接做到流水线平台上,是不是就可以了呢? + +这个想法是好的,但是在企业中,这并不具备可操作性。因为软件交付的每一个环节都是一项非常专业的工作,比如,仅仅是自动化测试能力这一项做好,就需要一个具备专业技能的团队的长期投入。 + +而且,把所有能力都做到流水线平台中,会使平台变得非常臃肿。再说了,我们也很难组建一个这么大的团队,来实现这个想法。 + +另外,企业的DevOps平台建设并不是一两天的事情。每家企业内部都有很多固有平台,这些平台长期存在,已经成为了团队软件交付日常操作的一部分。如果全部推倒重来,不仅会打破团队的习惯,影响短期效率,还会带来重复建设的巨大成本,这并不利于流水线平台的快速落地。 + +那么,既然这条路走不通,流水线平台如何定位才比较合理呢?我认为,正确的做法是,将持续交付流水线平台和垂直业务平台分开,并定义彼此的边界。 + +所谓的垂直业务平台,就是指单一专业领域的能力平台,比如自动化测试平台、代码质量平台、运维发布平台等等,这些也是软件交付团队日常打交道最频繁的平台。 + +流水线平台只专注于流程编排、过程可视化,并提供底层可复用的基础能力。比如,像是运行资源池、用户权限管控、任务编排调度流程等等。 + +垂直业务平台则专注于专业能力的建设、一些核心业务的逻辑处理、局部环节的精细化数据管理等。垂直业务平台可以独立对外服务,也可以以插件的形式,将平台能力提供给流水线平台。 + +这样一来,我们就可以快速复用现有的能力,做到最小成本的建设。随着能力的不断扩展,流水线平台支持的交付流程也会变得非常灵活。 + +借用《持续交付2.0》中的一句话来说,流水线平台仅作为任务的调度者、执行者和记录者,并不需要侵入垂直业务平台内部。 + + + +这样设计的好处很明显。 + +从流水线平台的角度来看,通过集成和复用现有的垂直业务能力,可以快速拓展能力图谱,满足不同用户的需求。 + +从垂直业务平台的角度来看,它们可以持续向技术纵深方向发展,把每一块的能力都做精、做透,这有助于企业积累核心竞争力。另外,流水线可以将更多用户导流到平台中,让垂直业务平台接触更多的用户使用场景。 + +不仅如此,在执行过程中,流水线携带了大量的软件开发过程信息,比如本次任务包含哪些需求,有哪些变更,这些信息可以在第一时间通知垂直业务平台。垂直业务平台拿到这些过程信息之后,可以通过精准测试等手段,大大提升运行效率。这里的核心就是构建一个企业内部DevOps平台的良好生态。 + +业界很多知名的软件设计都体现了这个思路。比如,Jenkins的插件中心、GitHub的Marketplace。它们背后的理念,都是基于平台,建立一种生态。 + +我之所以把这个特性放在第一个来介绍,就是因为,这直接决定了流水线平台的定位和后续的设计理念。关于具体怎么设计平台实现能力的快速接入,我会在第八个特性中进行深入介绍。 + +特性二:可编排和可视化 + +在现代软件开发中,多种技术栈并存,渐渐成为了一种常态。 + +举个最简单的例子,对于一个前后端分离的项目来说,前端技术栈和后端技术栈显然是不一样的;对于微服务风格的软件架构来说,每个模块都应该具备持续交付的能力。 + +所以,传统的标准化软件构建发布路径已经很难满足多样化开发模式的需要了。这样看来,流水线平台作为软件交付的过程载体,流程可编排的能力就变得必不可少了。 + +所谓的流程可编排能力,就是指用户可以自行定义软件交付过程的每一个步骤,以及各个步骤之间的先后执行顺序。说白了,就是“我的模块我做主,我需要增加哪些交付环节,我自己说了算”。 + +但是,很多现有的“流水线”平台采用的还是几个“写死”的固定阶段,比如构建、测试、发布,以至于即便有些技术栈不需要进行编译,也不能跳过这个环节。 + +我之前就见过一家企业,他们把生成版本标签的动作放在了上线检查阶段。我问了之后才知道,这个步骤没有地方可以放了,只能被临时扔在这里。你看,这样一来,整个交付过程看起来的样子和实际的样子可能并不一样,这显然不是可视化所期待的结果。 + +流程可编排,需要平台前端提供一个可视化的界面,来方便用户定义流水线过程。典型的方式就是,将流水线过程定义为几个阶段,每个阶段按顺序执行。在每个阶段,可以按需添加步骤,这些步骤可以并行执行,也可以串行执行。 + +前端将编排结果以一种标准化的格式进行保存(一般都是以JSON的形式),传递给后端处理。后端流程引擎需要对用户编排的内容进行翻译处理,并传递给执行器,来解释运行即可。 + +你可以参考一下下面这张流程编排的示意图。在实际运行的过程中,你可以点击每一个步骤,查看对应的运行结果、日志和状态信息。 + + + +从表面上看,这主要是在考验平台前端的开发能力,但实际上,编排的前提是系统提供了可编排的对象,这个对象一般称为原子。 + +所谓原子,就是一个能完成一项具体的独立任务的组件。这些组件要具备一定的通用性,尽量与业务无关。 + +比如下载代码这个动作,无论是前端项目,还是后端项目,做的事情其实都差不多,核心要实现的就是通过几个参数,完成从版本控制系统拉取代码的动作。那么,这就很适合成为一项原子。 + +原子的设计是流水线平台的精髓,因为原子体现了平台的通用性、可复用性和独立性。 + +以我们比较熟悉的Jenkins为例,一个原子就是流水线中的一个代码片段。通过封装特性,将实现隐藏在函数实现内部,对外暴露调用方法。用户只需要知道如何使用,不需要关心内部实现。 + +要想自己实现一个原子,其实并不复杂,在Jenkins中添加一段Groovy代码就行了。示例代码如下: + +// sample_atom_entrance.groovy +def Sample_Atom(Map map) { + new SampleAtom(this).callExecution(map) +} + + +// src/com/sample/atoms/SampleAtom.groovy +class SampleAtom extends AbstractAtom { + + + SampleAtom(steps) { + super(steps) + } + + + @Override + def execute() { + // Override execute function from AbstractAtom + useAtom() + } + + private def useAtom(){ + steps.echo "RUNNING SAMPLE ATOM FUNCTION..." + } + + +特性三:流水线即代码 + +这些年来,“什么什么即代码”的理念已经深入人心了。在应用配置领域,有 Configuration As Code,在服务器领域,有 Infrastructure As Code……流水线的设计与实现,同样需要做到 Pipeline As Code,也就是流水线即代码。 + +比如,Jenkins 2.0 中引入的 Jenkinsfile 就是一个典型的实现。另外,Gitlab中提供的GitlabCI,同样是通过一种代码化的方式和描述式的语言,来展示流水线的业务逻辑和运行方式。 + +流水线代码化的好处不言而喻:借助版本控制系统的强大功能,流水线代码和业务代码一样纳入版本控制系统,可以简单追溯每次流水线的变更记录。 + +在执行流水线的过程中,如果流水线配置发生了变化,同样需要体现在本次流水线的变更日志里面。甚至是,在版本的Release Notes中也增加流水线、环境的变更记录信息。一旦发生异常,这些信息会大大提升问题的定位速度。 + +当然,如果只是想要实现流水线变更追溯,你也可以采用其他方式。比如,将流水线配置存放在后台数据库中,并在每次流水线任务执行时,记录当时数据库中的版本信息。 + +实际上,流水线即代码的好处远不止于此。因为它大大地简化了流水线的配置成本,和原子一样,是构成现代流水线的另外一个支柱。 + +我跟你分享一个流水线即代码的示例。在这个例子中,你可以看到,整个软件交付流程,都以一种非常清晰的方式描述出来了。即便你不是流水线的专家,也能看懂和使用。 + +image: maven:latest + + +stages: + - build + - test + - run + + +variables: + MAVEN_CLI_OPTS: "--batch-mode" + GITLAB_BASE_URL: "https://gitlab.com" + DEP_PROJECT_ID: 8873767 + + +build: + stage: build + script: + - mvn $MAVEN_CLI_OPTS compile + + +test: + stage: test + script: + - mvn $MAVEN_CLI_OPTS test + + +run: + stage: run + script: + - mvn $MAVEN_CLI_OPTS package + - mvn $MAVEN_CLI_OPTS exec:java -Dexec.mainClass="com.example.app.A + + +特性四:流水线实例化 + +作为软件交付流程的建模,流水线跟面向对象语言里面的类和实例非常相似。一个类可以初始化多个对象,每个对象都有自己的内存空间,可以独立存在,流水线也要具备这种能力。 + +首先,流水线需要支持参数化执行。 + +通过输入不同的参数,控制流水线的运行结果,甚至是控制流水线的执行过程。 + +比如,一条流水线应该满足不同分支的构建需要,那么,这就需要将分支作为参数提取出来,在运行时,根据不同的需要,手动或者自动获取。 + +考虑到这种场景,在平台设计中,你可以事先约定一种参数的格式。这里定义的标准格式,就是以“#”开头,后面加上参数名称。通过在流水线模板中定义这样的参数,一个业务可以快速复用已有的流水线,不需要重新编排,只要修改运行参数即可。 + +其次,流水线的每一次执行,都可以理解为是一个实例化的过程。 + +每个实例基于执行时间点的流水线配置,生成一个快照,这个快照不会因为流水线配置的变更而变更。如果想要重新触发这次任务,就需要根据当时的快照运行,从而实现回溯历史的需求。 + +最后,流水线需要支持并发执行能力。 + +这就是说,流水线可以触发多次,生成多个运行实例。这考察的不仅是流水线的调度能力、队列能力,还有持久化数据的管理能力。 + +因为,每次执行都需要有独立的工作空间。为了加速流水线运行,需要在空间中完成静态数据的挂载,比如代码缓存、构建缓存等。有些流水线平台不支持并发,其实就是因为没有解决好这个问题。 + +特性五:有限支持原则 + +流水线的设计目标,应该是满足大多数、常见场景下的快速使用,并提供一定程度的定制化可扩展能力,而不是满足所有需求。 + +在设计流水线功能的时候,我们往往会陷入一个怪圈:我们想要去抽象一个通用的模型,满足所有的业务场景,但是我们会发现,业务总是有这样或者那样的特殊需求。这就像是拿着一张大网下水捞鱼,总是会有漏网之鱼,于是,网做得越来越大。对于平台来说,平台最后会变得非常复杂。 + +比如,拿最常见的安卓应用构建来说,目前绝大多数企业都在使用Gradle工具,通用命令其实只有两步: + +gradle clean +gradle assemblerelease / gradle assembledebug + + +但是,在实际的业务场景中,应用A用到了Node.js,需要安装npm;应用B用到了Git-lfs大文件,需要先执行安装指令;应用C更甚,需要根据选项,配置执行Patch模式和完整打包模式。 + +如果试图在一个框架中满足所有人的需求,就会让配置和逻辑变得非常复杂。无论是开发实现,还是用户使用,都会变得难以上手。 + +以Jenkins原生的Xcode编译步骤为例,这个步骤提供了53个参数选项,满足了绝大多数场景的需求,但是也陷入到了参数的汪洋大海中。 + +所以,流水线设计要提供有限的可能性,而非穷举所有变量因素。 + +在设计参数接口的时候,我们要遵循“奥卡姆剃刀法则”,也就是,“如无必要,勿增实体”。如果有用户希望给原子增加一个变量参数,那么,我们首先要想的是,这个需求是不是90%的人都会用到的功能。如果不是,就不要轻易放在原子设计中。 + +你可能会问,这样的话,用户的差异化诉求,该如何满足呢?其实,这很简单,你可以在平台中提供一些通用类原子能力,比如,执行自定义脚本的能力、调用http接口的能力、用户自定义原子的能力,等等。只要能提供这些能力,就可以满足用户的差异化需求了。 + +总结 + +在这一讲中,我给你介绍了现代流水线设计的前五大特性,分别是打造平台而非能力中心、可编排和可视化、流水线即代码、流水线实例化,以及有限支持原则。在下一讲中,我会继续介绍剩余的五大特性,敬请期待。 + +思考题 + +你所在的企业有在使用流水线吗?你觉得,流水线还有什么必不可少的特性吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/24持续交付平台:现代流水线必备的十大特征(下).md b/专栏/DevOps实战笔记/24持续交付平台:现代流水线必备的十大特征(下).md new file mode 100644 index 0000000..02d99c1 --- /dev/null +++ b/专栏/DevOps实战笔记/24持续交付平台:现代流水线必备的十大特征(下).md @@ -0,0 +1,205 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 持续交付平台:现代流水线必备的十大特征(下) + 你好,我是石雪峰。今天,我来接着跟你聊聊现代流水线必备的十大特性的下半部分,分别是流程可控、动静分离配置化、快速接入、内建质量门禁和数据采集聚合。 + + + +特性六:流程可控 + +在上一讲中,我提到过,流水线是覆盖软件交付端到端完整过程的平台,也就是说,流水线的主要作用是驱动软件交付过程的效率提升和状态可视化。 + +在线下交流的时候,我发现,不少同学对这个概念的理解都存在着一些误区,他们觉得需要建设一条大而全的流水线,在这条流水线上完成软件交付的所有过程。 + +其实,流水线是要覆盖端到端的流程,但这并不是说,一定要有一条流水线跑通从代码提交开始到软件发布为止的全流程。实际上,在企业中,往往是多条流水线覆盖不同的环节,比如开发阶段流水线、集成阶段流水线,以及部署阶段流水线。这些流水线一起覆盖了整个软件交付流程。 + +这就体现了流水线的流程可控性,流水线可以为了满足不同阶段的业务目标而存在,并且每条流水线上实现的功能都不相同。为了达到这个目的,流水线需要支持多种触发方式,比如定时触发、手动触发、事件触发等。其中,事件触发就是实现持续集成的一个非常重要的能力。 + +以Gitlab为例,你可以在代码仓库中添加Webhook,Webhook的地址就是触发流水线任务的API,这个API可以通过Gitlab的API实现自动注册。 + +需要注意的是,要实现Webhook的自动注册,访问Gitlab的账号时必须要有对应代码仓库的Master级别权限,否则是无法添加成功的。 + +当注册完成Webhook,代码仓库捕获到对应的事件后,比如代码Push动作,会自动调用Webhook,并且将本次代码提交的基础信息(比如分支、提交人等)传递给注册地址。 + +流水线平台接收到接口访问后,可以根据规则过滤请求,最典型的就是触发分支信息。当满足规则条件后,则执行流水线任务,并将结果再次通过Gitlab的API写回到代码仓库中。这样一来,每次提交历史都会关联一个流水线的执行记录,可以用于辅助代码合并评审。 + +我画了一张流程图,它展示了刚刚我所描述的过程以及调用的接口信息。 + + + +除了多种触发方式以外,流水线还需要支持人工审批。这也就是说,每个阶段的流转可以是自动的,上一阶段完成后,就自动执行下一阶段;也可以是手动执行的,必须经过人为确认才能继续执行,这里的人为确认需要配合权限的管控。 + +其实,人工审批的场景在软件交付过程中非常常见。如果是自建流程引擎,人工审批就不难实现,但是,如果你是基于Jenkins来实现这个过程,虽然Jenkins提供了input方法来实现人为审批的功能,但我还是比较推荐你自己通过扩展代码来实现。比如,将每个原子的执行过程抽象为before()、execute() 和 after() 三个阶段,可以将人工审批的逻辑写在before()或者after()方法中。 + +这样一来,对于所有原子都可以默认执行基类方法,从而获得人工审批的能力。是否开启人工审批,可以通过原子配置中的参数实现。你就不需要在每个原子中人工注入input方法了,流水线的执行过程会更加清晰。 + +我给你分享一个抽象原子类的设计实现,如下面的代码所示: + +abstract class AbstractAtom extends AtomExecution { + def atomExecution() { + this.beforeAtomExecution() + // 原子预处理步骤,你可以将通用执行逻辑,比如人工审批等写在这里 + echo('AtomBefore') + before() + // 原子主体核心逻辑 + echo('AtomExecution') + execute() + // 原子后处理步骤,你可以将通用执行逻辑,比如人工审批等写在这里 + echo('AtomAfter') + after() + this.afterAtomExecution() + } +} + + +特性七:动静分离配置化 + +流水线的灵活性不仅体现在流程可编排、流程可控方面,每一个原子都需要持续迭代功能。那么,如何在不改变代码的情况下,实现原子的动态化配置呢? + +这就需要用到动静分离的设计方法了。那么,什么是动静分离呢? + +其实,动静分离就是一种配置化的实现方式。这就是指,将需要频繁调整或者用户自定义的内容,保存在一个静态的配置文件中。然后,系统加载时通过读取接口获取配置数据,并动态生成用户可见的交互界面。 + +你可能觉得有点抽象,我来给你举个例子。你可以看一下下面这张截图。 + + + +如果我想对某一个原子扩展一个新的功能,提供一个新的用户配置参数,传统的做法就是在前端页面中增加一段html代码。这样的话,原子功能的每一次变更都需要前端配合调整,原子的独立性就不复存在了,而是跟页面强耦合在一起。 + +另外,前端页面加入了这么多业务逻辑,如果哪天需要同时兼容不同的原子版本,那么前端页面也需要保存两套。一两个应用这么玩也就罢了,如果有上百个应用,那简直没法想象。 + +那么,具体要怎么做呢?最重要的就是定义一套标准的原子数据结构。 + +比如,在上面这张图的左侧部分,我给你提供了一个参考结构。对于所有的原子来说,它对外暴露的功能都是通过这套标准化的方式来定义的。前端在加载原子的时候,后端提供的接口获取原子的数据结构,并按照约定的参数类型,渲染成不同的控件类型。 + +不仅如此,控件的属性也可以灵活调整,比如控件的默认值是什么,控件是否属于必填项,是否存在可输入字符限制等等。那么,当你想增加一个新的参数的时候,只需要修改原子配置,而不需要修改前端代码。结构定义和具体实现的分离,可以大幅简化原子升级的灵活性。 + +无论在原子结构设计,还是前后端交互等领域,定义一个通用的数据结构是设计标准化的系统的最佳实践。 + +对于流水线平台来说,除了原子,很多地方都会用到配置化的方式。比如,系统报告中体现的字段和图表类型等,就是为了满足用户差异化的需求。而且,将配置纳入版本控制,你也可以快速查询原子配置的变更记录,达到一切变更皆可追溯的目标。 + +特性八:快速接入 + +前面我提到过,流水线的很多能力都不是自己提供的,而是来源于垂直业务平台。那么,在建设流水线平台的时候,能否快速地实现外部平台能力的接入,就成了一个必须要解决的问题。 + +经典的解决方式就是提供一种插件机制,来实现平台能力的接入。比如,Jenkins平台就是通过这种方式,建立了非常强大的插件生态。但是,如果每个平台的接入都需要企业内部自己来实现插件的话,那么,企业对于平台接入的意愿就会大大降低。 + +实际上,接入成本的高低,直接影响了平台能力的拓展,而流水线平台支持的能力多少,就是平台的核心竞争力。 + +那么,有没有一种更加轻量化的平台接入方法呢?我给你提供一个解决思路:自动化生成平台关联的原子代码。 + +在第七个特性中,我们已经将原子的数据结构通过一种标准化的描述式语言定义完成了,那原子的实现代码是否可以也自动化生成呢?实际上,在大多数情况下,外部平台打通有两种类型。 + + +平台方提供一个本地执行的工具,也就是类似SonarQube的Scanner的方式,通过在本地调用这个工具,实现相应的功能。 +通过接口调用的方式,实现平台与平台间的交互,调用的实现过程无外乎同步和异步两种模式。 + + +既然平台接入存在一定的共性,那么,我们就可以规划解题方法了。 + +首先,流水线平台需要定义一套标准的接入方式。以接口调用类型为例,接入平台需要提供一个任务调用接口、一个状态查询接口以及一个结果上报接口。 + + +任务调用接口:用于流水线触发任务,一般由接入平台定义和实现。对于比较成熟的平台来说,这类接口一般都是现成的。接口调用参数可以直接转换成原子的参数,一些平台的配置化信息(比如接口地址、接口协议等),都可以定义在原子的数据结构中。 +状态查询接口:用于流水线查询任务的执行状态,获取任务的执行进度。这个接口也是由接入平台定义和实现的,返回的内容一般包括任务状态和执行日志等。 +数据上报接口:用于任务将执行结果上报给流水线平台进行保存。这个接口由流水线平台定义,并提供一套标准的数据接口给到接入方。接入方必须按照这个标准接口上报数据,以简化数据上报的过程。 + + +通过将平台接入简化为几个标准步骤,可以大幅简化平台接入的实现成本。按照我们的经验,一套平台的接入基本都可以在几天内完成。 + +特性九:内建质量门禁 + +在第14讲中,我给你介绍了内建质量的理念,以及相关的实施步骤。你还记得内建质量的两大原则吗? + + +问题发现得越早,修复成本就越低; +质量是每个人的责任,而不是质量团队的责任。 + + +毫无疑问,持续交付流水线是内建质量的最好阵地,而具体的展现形式就是质量门禁。通过在持续交付流水线的各个阶段注入质量检查能力,可以让内建质量真正落地。 + +一般来说,流水线平台都应该具备质量门禁的能力,我们甚至要把它作为流水线平台的一级能力进行建设。在流水线平台上,要完成质量规则制定、门禁数据收集和检查,以及门禁结果报告的完整闭环。质量门禁大多数来源于垂直业务平台,比如,UI自动化测试平台就可以提供自动化测试通过率等指标。只有将用于门禁的数据上报到流水线平台,才能够激活检查功能。 + +那么,质量门禁的功能应该如何设计呢? + +从后向前倒推,首先是设置门禁检查功能。这个功能也是一种流水线的通用能力,所以和人工审核的功能类似,也可以放在原子执行的after()步骤中,或者独立出来一个步骤就叫作qualityGates()。 + +每次原子执行时都会走到这个步骤,在步骤中校验当前流水线是否已经开启了门禁检查功能,并且当前原子是否提供了门禁检查能力。如果发现已配置门禁规则,而且当前原子在检查范围内,就等待运行结果返回,提取数据,并触发检查工作。你可以参考下面的示例代码。 + +def qualityGates() { + // 获取质量门禁配置以及生效状态 + boolean isRun = qualityGateAction.fetchQualityGateConfig(host, token, pipelineId, oneScope) + // 激活检查的情况等待结果返回,最多等待30分钟 + if (isRun) { + syncHandler.doSyncOperation( + 30, + 'MINUTES', + { + // 等待执行结果返回,质量门禁功能必须同步执行 + return httpUtil.doGetToExternalResult(host, externalMap.get(oneScope), token) + }) + // 提取返回数据 + qualityGateAction.fetchExecutionResult(host, token, externalMap.get(oneScope), buildId) + // 验证质量门禁 + qualityGateAction.verify(oneScope) + } +} + + +解决了如何检查的问题,我们再往前一步,看看质量门禁的规则应该如何定义。 + +在企业内,定义和管理质量规则的一般都是QA团队,所以需要给他们提供一个统一入口,方便他们进行规则配置和具体数值的调整。 + +对质量门禁来说,检查的类型可以说是多种多样的。 + + +从比较类型来说,可以比较结果大于、等于、小于、包含、不包含等; +从比较结果来说,可以是失败值、警告值。失败值是指,只要满足这个条件,就直接终止流水线执行。而警告值是说,如果满足这个条件,就给一个警告标记,但是不会终止流水线执行。 + + +这些条件,往往需要根据QA团队定义的规则来适配。 + +质量规则可以由一组子规则共同组成,比如,单元测试通过率100%、行覆盖率大于50%、严重阻塞代码问题等于0…… + +所以,你看,想要定义一个灵活的质量门禁,就需要在系统设计方面花点功夫了。在之前的实践中,我们就采用了适配器加策略模式的方式,这样可以满足规则的灵活扩展。 + +策略模式是23种设计模式中比较常用的一种。如果你之前没有了解过,我给你推荐一篇参考文章。如果想要深入学习设计模式,极客时间也有相应的专栏,或者你也可以购买经典的《设计模式》一书。其实,核心就在于面向接口而非面向过程开发,通过实现不同的接口类,来实现不同的检查策略。 + +特性十:数据聚合采集 + +作为软件交付过程的载体,流水线的可视化就体现在可以在流水线上看到每一个环节的执行情况。这是什么意思呢? + +在系统没有打通的时候,如果你想看测试的执行结果,就要跑到测试系统上看;如果想看数据库变更的执行状态,就得去数据库管理平台上看。这里的问题就是,没有一个统一的地方可以查看本次发布的所有状态信息,而这也是流水线的可视化要解决的问题。 + +当平台的能力以原子的形式接入流水线之后,流水线需要有能力获取本次执行相关的结果数据,这也是在平台对接的时候,务必要求子系统实现数据上报接口的原因。至于上报数据的颗粒度,其实并没有一定之规,原则就是满足用户对最基本的结果数据的查看需求。 + +以单元测试为例,需要收集的数据包括两个方面,一个是单元测试的执行结果,比如一共多少用例,执行多少,成功失败分别多少。另外,即使收集覆盖率信息,至少也要包含各个维度的覆盖率指标。但是,对于具体每个文件的覆盖率情况,这种粒度的数据量比较大,可以通过生成报告的方式来呈现,不用事无巨细地都上报到流水线后台进行保存。 + +在企业内部没有建立独立的数据度量平台之前,流水线平台承载了全流程数据的展示功能。但是,毕竟流水线的目标是为了展示客观的数据结果,而不是在于对数据进行分析挖掘。所以,当企业开始建设数据度量平台时,流水线也可以作为数据源之一,满足度量平台对于各项工程能力的度量需求。 + +总结 + +到此为止,我给你完整地介绍了现代流水线必备的十大特性。其实,流水线的功能特性远不止这10个。随着云计算和云原生应用的发展,云原生流水线也成为了越来越多人讨论的话题。关于这方面的内容,我会在后续的课程中给你分享我的一些想法。 + +可以说,一个好的持续交付流水线平台,就是企业DevOps能力的巅峰展现。这也难怪,越来越多的公司开始在这个领域发力,甚至把它作为核心能力对外输出,成为企业商业化运作的一份子。深入掌握这10个特性,并把它们落实在流水线平台的建设中,是企业DevOps平台建设的必经之路。 + +就像美国著名女演员莉莉·汤姆林(Lily Tomlin)的那句经典名言所说的那样: + + +The road to success is always under construction.(通往成功的道路,永远在建设之中) + + +企业迈向持续交付的成功之路也不是一帆风顺的,永无止境的追求是指引我们前进的方向,也希望你能在流水线建设之路上不断思考,不断实践,持续精进。 + +思考题 + +你目前在使用的流水线平台有哪些不好用、待改进,或者是“反人类”的设计吗?看完这两讲的内容,你有什么新的想法和改进建议吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/25让数据说话:如何建设企业级数据度量平台?.md b/专栏/DevOps实战笔记/25让数据说话:如何建设企业级数据度量平台?.md new file mode 100644 index 0000000..4199a5e --- /dev/null +++ b/专栏/DevOps实战笔记/25让数据说话:如何建设企业级数据度量平台?.md @@ -0,0 +1,196 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 让数据说话:如何建设企业级数据度量平台? + 你好,我是石雪峰。今天我来跟你聊聊数据度量平台。 + +先说个题外话。在2019年的DevOps World | Jenkins World大会上,CloudBees公司重磅发布了他们的全新产品:SDM - Software Delivery Management 。在我看来,这注定是一个跨时代的产品。 + +简单来说,SDM想要解决的问题就是,将割裂的软件开发流程收敛到一个平台上,通过收集软件开发全流程的数据,并进行智能分析,从而让整个软件交付过程的方方面面对所有人都可视化。 + +无论这个产品最终是否能够获得成功,它背后的设计理念绝对是非常超前的,因为这是第一次有一个解决方案把业务视角和开发视角连接了起来。 + +对业务人员来说,他们能够实时看到特性的交付进度;对开发人员来说,他们也能实时看到交付特性的业务指标和用户反馈;对管理人员来说,他们可以纵观整个流程,发现交付过程中的阻塞和效率瓶颈。 + +这听起来是不是很神奇呢?别急,关于这个产品的更多特性,我会在后续的特别放送中给你带来更多的介绍,敬请期待。 + +言归正传,我走访过的公司无一例外地都在花大力气建设数据度量平台。这些度量平台虽然看起来长得都不一样,但是他们想要解决的核心问题都是一致的,那就是软件研发过程可视化。 + +为什么可视化对于软件研发来说这么重要呢?这是因为,可视化可以大幅降低软件开发的协作成本,增加研发过程的透明度,从而大大减少研发过程中的浪费和返工。 + +举个最简单的例子,每周开会的时间成本一般都比较高,但如果老板能对项目的状态有清晰的了解,何必还要费这么大力气汇报工作呢? + +在专栏的第19讲中,我给你介绍了DevOps度量体系的相关内容。你还记得好的度量指标一般都具有的典型特征吗?这些特征就是明确受众、直指问题、量化趋势、充满张力。 + +其实,在评价一个度量平台的时候,这些特征同样适用。因为,在数据度量平台上呈现的内容,正是度量指标。这也就是说,将度量指标的数据和详情汇总起来,再根据度量指标的维度,展现出各式各样的视图,从而满足不同用户的需求。 + +这样一来,整个团队的交付情况,包括交付效率和质量,就可以通过客观数据展示出来,而不再依赖于个人的主观臆测。有了客观的数据做尺子,团队的改进空间也就一目了然了。 + +听起来是不是特别美好?但实际上,度量平台要想满足这种预期,可不是一件简单的事情。 + +我认为,在数据度量平台的建设和落地过程中,事前、事中和事后这三个阶段都存在着大量的挑战。接下来,我就从这三个阶段入手,给你聊聊度量平台建设的一些思路。 + +事前:指标共识 + +毫无疑问,度量指标是数据度量平台的基础。在建设平台之前,如果指标本身的定义、数据来源、计算方法、统计口径等没有在团队内部达成共识的话,那么,数据度量平台呈现出来的数据也同样是有问题的。 + +我给你举个例子。需求流转周期这个指标,一般是计算需求卡片在需求的各个状态的停留时长的总和,包括分析、设计、开发、测试、发布等。 + +其中的测试流转周期计算的是,从需求卡片进入待测试状态到测试完成进入待发布状态的时长,例如5天。但是,在真正支持测试任务的系统中,也有一个测试流转周期。这个流转周期计算的是每个测试任务的平均执行时间,这样算出来的测试周期可能只有1天。 + +先不说这两种计算方式谁对谁错,我想表达的是,即便是针对同一个指标,在不同平台、根据不同计算方法得到的结果也大不相同。 + +如果不能把指标的定义对齐,那么在实施度量的过程中,大家就会不清楚到底哪个数据是正确的,这显然不利于度量工作的推进。 + +另外,在定义度量指标的时候,一般都会召开指标评审会议。但这个时候,因为拿不出具体的数据,大家光盯着指标定义看,往往也看不出什么问题。等到平台上的数据出来了,才发现有些数据好像不太对。于是,要再针对指标重新梳理定义,而这往往就意味着平台开发的返工和数据重新计算。在平台建设的过程中,数据校准和指标对齐工作花费的时间很有可能比开发平台本身的时间都要多。 + +“数据本身不会说话,是人们赋予了数据意义”,而“这个意义“就是度量指标。 + +在定义指标的时候,大家都愿意选择对自己有利的解释,这就导致大家看待数据的视角无法对齐。 + +所以,在实施度量平台建设之前,最重要的就是细化度量指标的数据源和计算方法,而且一定要细化到可以落地并拿出数据结果的程度。 + +比如,开发交付周期这个指标一般是指从研发真正动工的时间点开始,一直到最终上线发布为止的时长。但是这个描述还是不够细化,所以,我们团队对这个指标的描述是:从研发在需求管理平台上将一个任务拖拽到开始的开发阶段起,一直到这个任务变成已发布状态为止的时间周期。 + +这里的任务类型包括特性、缺陷和改进任务三种,不包含史诗任务和技术预研任务类型。我们会对已达到交付状态的任务进行统计,未完成的不在统计范围中。你看,只有描述到这种颗粒度,研发才知道应该如何操作,数据统计才知道要如何获取有效的数据范围。 + +我建议你在着手启动数据度量平台建设之前,至少要保证这些指标数据可以通过线下、甚至是手工的方式统计出来,并在内部达成共识。 + +切忌一上来就开始盲目建设!很多时候,我们虽然花了大力气建设平台,最终也建设出来了,但结果却没人关注,核心问题还是出在了指标上。 + +数据平台作为企业内部的公信平台,数据的准确性至关重要。如果数据出现了偏差,不仅会导致错误的判断,带来错误的结果,还会对平台自身的运营推广造成很大的伤害。 + +事中:平台建设 + +随着软件交付活动复杂性的上升,在整个交付过程中用到的工具平台也越来越多。虽然通过持续交付流水线平台实现了交付链路的打通,通过交付流水线来驱动各个环节的工具平台来完成工作,但是,客观来说,企业内部的工具平台依然是割裂的状态,而非完整的一体化平台。 + +这就带来一个问题:每个平台或多或少都有自己的数据度量能力,甚至也有精细化维度的数据展示,但是这些数据都是存储在各个工具平台自身的数据库中的。 + +我给你举个例子。Jira是一个业界使用比较普遍的需求管理平台,也是一个成熟的商业工具,所以,对于这类商业化系统都提供了比较完善的API。再加上Jira自带的JQL查询语言,可以相对比较简单地查询并获取元数据信息。但是,对于一个自研平台来说,对外开发的API可能相对简单,甚至有的系统都没有对外暴露API。在这种情况下,如果想要获取平台数据,要么依赖于开发新的API,要么就只能通过JDBC直接访问后台数据库的形式来提取数据。 + +不仅如此,还有些平台的数据是通过消息推送的方式来获取的,无法主动地获取数据,只能通过订阅消息队列广播的方式来获取。 + +所以,你看,对于不同的元数据平台,数据获取的方式也是千差万别的。 + +挑战一:大量数据源平台对接 + +那么,作为一个统一的数据度量平台,面对的第一个挑战就是,如何从这些种类繁多的平台中提取有用的数据,并且保证数据源接入的隔离性,做到灵活接入呢? + +我给你的建议还是采用流水线设计的思路,那就是插件化,只不过,这次要实现的插件是数据采集器。你可以看一下这张简单的架构示意图: + + + +采集器是针对每一个对接的数据源平台实现的,它的作用就是对每个数据源进行数据建模,从而对平台屏蔽各种数据获取方式,将采集到的数据进行统一格式化上报和存储。在采集器上面可以设计一个Operation层,用来调整采集器的执行频率,控制采集数据的范围。 + +如果数据量比较大,你也可以让采集器对接类似Kafka这样的消息队列,这些都可以按需实现。这样一来,新平台如果想要接入,只需要针对这个平台的数据特性实现一个采集器即可,平台的整体架构并不需要变化。 + +你可以看看下面的这段采集器的示例代码: + +@Override + public void collect(FeatureCollector collector) { + logBanner(featureSettings.getJiraBaseUrl()); + int count = 0; + + try { + long projectDataStart = System.currentTimeMillis(); + ProjectDataClientImpl projectData = new ProjectDataClientImpl(this.featureSettings, + this.projectRepository, this.featureCollectorRepository, jiraClient); + count = projectData.updateProjectInformation(); + log("Project Data", projectDataStart, count); + } catch (Exception e) { + // catch exception here so we don't blow up the collector completely + LOGGER.error("Failed to collect jira information", e); + } + } + + +挑战二:海量数据存储分析 + +一般来说,常见的数据存储方式无外乎RDMS关系型数据库和NoSQL非关系型数据库两种类型。那么,究竟应该如何选择,还是要看数据度量平台的数据特征。 + + +第一个典型特征就是数据量大。对于一个大型公司而言,每天的代码提交就有近万笔,单单这部分数据就有几十万、上百万条。 +第二个特征就是数据结构不统一。这个其实很好理解,毕竟需求相关的数据字段和代码相关的数据字段基本上没有什么共性,而且字段的数量也会根据指标的调整而调整。 +第三个特征就是数据访问频繁。度量平台需要在大规模的数据集中进行随机访问、数据的读取运算等操作,这就要求很好的横向扩展能力。 + + +另外,数据度量平台一般都会保存元数据和加工数据。所谓元数据,就是采集过来的、未加工过的数据,而加工数据则是经过数据清洗和数据处理的数据。 + +我还是举个代码库的例子来说明一下。元数据就是一条条用户的代码提交记录,而加工数据则是按照分钟维度聚合过的提交信息,包括数量、行数变化等。这些加工过的数据可以很简单地提供给前端进行图表展示。存储加工数据的原因就在于,避免每次实时的大量数据运算,以提升度量平台的性能。 + +基于这些典型特征和场景,不难看出,非关系型数据库更加适合于大量元数据的保存。 + +我推荐你使用HBase,这是一个适合于非结构化数据存储的数据库,天生支持分布式存储系统。而对于加工数据的保存,你可以采用关系型数据库MySQL。 + +当然,数据库的选型不止这一种,业界还有很多开源、商业工具。比如,开源的数据度量平台Hygieia就采用的是MongoDB,而商业工具中的Insight也在业内的很多大型公司在大规模使用。 + +我再给你分享一幅数据度量的架构图。从这张图中,你可以看到,底层数据都是基于HBase和HDFS来存储的。 + + + +挑战三:度量视图的定制化显示 + +度量平台需要满足不同维度视角的需求,所以一般都会提供多个Dashboard,比如管理层Dashboard、技术经理Dashboard、个人Dashboard等。但是,这种预置的Dashboard很难满足每个人的差异化需求,就像“一千个人眼里有一千个哈姆雷特”一样,度量平台的视图也应该是千人千面的。 + +那么如果想要实现度量视图的自定义,比如支持图标位置的拖拽和编辑,自己增加新的组件、并按照自定义视图发送报告等,那就需要在前端页面开发时下点功夫了。好在对于现代前端框架,都有现成的解决方案,你只需要引用对应的组件即可。 + +我给你推荐两个前端组件,你可以参考一下: + + +插件一 +插件二 + + +这两个组件都可以支持widget的拖拽、缩放、自动对齐、添加、删除等常见操作。这样一来,每个人都可以自由地按需定制自己的工作台视图,不同角色的人员也可以定制和发送报告,而不需要从度量平台提取数据,再手动整理到PPT里面了。 + +以vue-grid-layout为例,在使用时,你可以将echarts图表放在自定义组件里面,同时你也可以自己实现一些方法,具体的方法可以参考一下这篇文章。 + +在了解了刚开始建设度量平台的三个常见挑战之后,你应该已经对度量平台的架构有了一个大体的认识,接下来,我们来看看第三个阶段。 + +事后:规则落地 + +以现在的开发效率来说,建设一个数据度量平台并不是件困难的事情。实际上,建设度量平台只能说是迈出了数据度量的第1步,而剩余的99步都依赖于平台的运营推广。 + +这么说一点也不夸张,甚至可以说,如果根本没人关心度量平台上的数据,那么可能连第1步的意义都要画上个问号。 + +在开始运营的时候,度量平台面临的最大挑战就是数据的准确性,这也是最容易被人challenge的地方。 + +造成数据不准确的原因有很多,比如,度量指标自身的计算方式问题、一些异常数据引入的问题、部门维度归类聚合的问题。但是实际上,往往带来最多问题的还是研发操作不规范。 + +举个例子,像需求交付周期这种数据强依赖于需求卡片流转的操作是否规范,如果研发上线后一次性把卡片拖拽到上线状态,那么这样算出来的需求交付周期可能只有几秒钟,显然是有问题的。 + +正确的做法是,根据真实的状态进行流转,比如研发提测关联需求,后台自动将需求卡片流转到待测试状态;测试验收通过后,卡片再次自动流转到测试完成状态等。尽量实现自动化操作,而不是依赖于人的自觉性。 + +再举个例子,像需求关联的代码行数,如果研发提交的时候并没有对代码和需求建立关联,那么统计出来的数据也会有很大的失真。这些不规范的数据并不会因为后续操作的改变而改变,也很难进行数据的修复和清理,会一直留存在度量系统的数据池中,是抹不掉的印记。 + +所以,度量平台只有通过项目自上而下的驱动才能起到真正的作用。要对不规范的操作建立规则,对恶意操作的数据进行审计,把度量发现的问题纳入持续改进,对每项指标的走势进行跟踪和定位。 + +另外,为了让数据可以直指问题,在度量平台中,也需要体现出来当前的数据是好还是坏。 + +方式和方法有很多,比如,建立参考值(比如对于单测覆盖率制定最低50%的参考值),这样在度量图表中就能体现出当前数据和参考值的差距。或者,你也可以在每一项可以横向比较的指标旁边,体现当前处在大部门的哪个位置,是前10%,还是后10%?这样的数据都有助于推动改进行为。 + +说到底,度量的目的是持续改进。如果统计了100个指标的数据,并都体现在度量平台上,却说不出来到底哪个指标给团队带来了改进,以及改进是如何实现的,那么,这种度量平台的价值又在哪里呢? + +总结 + +好啦,在这一讲中,我给你介绍了建设数据度量平台的核心价值,也就是让软件交付过程变得可视化。在这一点上,业界各大公司的思路都是一致的。也正因为如此,数据度量平台是当前企业DevOps平台建设不可或缺的一环。 + +在平台建设的时候,你需要关注事前、事中和事后三个阶段的事情。 + + +事前就是要对指标的定义达成共识。这里的指标要细化到数据源和详细的计算公式层面,即便没有度量平台,也可以计算出相应的结果; +事中就是平台建设方面,面对多数据源平台可以采用采集器插件的方式灵活适配,建议使用HBase等非关系型数据库进行数据存储,可以利用现有的前端组件来实现可视化界面展示。 +事后就是数据的运营和规则落地。只有度量数据能够反映出问题,并驱动团队改进,度量才有意义。 + + +思考题 + +你在企业中建设和应用度量平台的时候,还遇到过哪些问题呢?你又是如何解决的呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/26平台产品研发:三个月完成千人规模的产品要怎么做?.md b/专栏/DevOps实战笔记/26平台产品研发:三个月完成千人规模的产品要怎么做?.md new file mode 100644 index 0000000..61e9f7a --- /dev/null +++ b/专栏/DevOps实战笔记/26平台产品研发:三个月完成千人规模的产品要怎么做?.md @@ -0,0 +1,237 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 平台产品研发:三个月完成千人规模的产品要怎么做? + 你好,我是石雪峰。 + +虽然我们之前聊了这么多的平台建设思路,但是,可能很多人都没有机会经历一个平台从构思到开发、再到推广落地的完整过程。 + +如果要开发一个千人使用的DevOps产品,需要多长时间呢?你可能会说需要半年甚至是更长的时间,我之前也是这么觉得的。 + +但是,2018年,在启动流水线平台建设的时候,老板“大手一挥”,要求在三个月内见到成效,我都快惊呆了。 + +因为,我们要真正地从零开始:原型图都没有一张,代码都没有一行,临时组建的一个草台班子还分散在北京、上海两地,团队成员之前都没怎么打过招呼,这能行吗? + +今天,我想给你分享的就是这个真实的故事。我来跟你一起复盘下这次“急行军”的历程,看看我们做对了什么,又做错了什么,有哪些干货是可以拿来就用的,又有哪些“坑”是你一定要努力回避的。 + +其实,作为一个非专业的DevOps产品经理,你终将面对这样的挑战,但你要相信,只要开始去做了,就没有什么是不可能的。 + +项目启动 + +时间回到一年前,当时我所在的这个“草台班子”是个啥情况呢?团队组成是这样的:两个后台开发在北京,一个半前端开发在上海,还有一个基础设施工程师和一个流水线开发工程师,再加上半个全能打杂的产品经理,也就是我,满打满算一共6个人。 + +项目从11月中旬开始构思,12月初开启动会,当时,除了我之外,没有任何人清楚我们要做的到底是个什么玩意儿。这该怎么办呢? + +玩过游戏的同学应该都知道打好开局有多重要,所以,为了这个Kickoff会议,我事先做了大量的准备工作,其中就包括0.2版本的产品原型图。与其说是一个原型图,不如说就是一个草稿,简陋得不能再简陋了。 + + + +项目的Kickoff会议是项目组成员和未来产品的第一次见面,留下一个积极的印象非常重要。所以,从第一刻开始,我就铆足了精神。 + +首先,我发出了一封热情洋溢的会议邀请。在会议邀请中,我仔细地陈述了我们为什么要做这件事,为什么是现在,为什么不做不行。 + +在正式开会的时候,我再一次明确了项目的重要性和紧急性,并给大家演示了第一版的系统原型图(没错,就是简陋到极致的刚刚的这张原型图)。 + +即便这样,三个月的工期也让大家非常焦虑。为了缓解紧张情绪,证明这个项目的可行性,我还做了两件事: + + +搭建了一个系统demo,几个简单的页面; +由于用到了另外一个开源产品的核心技术,于是,我就对这个技术进行了简单演示。 + + +虽然我自己心里对这个计划也相当“打鼓”,但我还是希望告诉大家,这并不是不可能的任务,努力帮助大家树立信心。 + +在项目启动会上,团队达成了两个非常关键的结论:一个是系统方案选型;另一个是建立协作机制。 + +首先,由于时间紧任务重,我们决定使用更易于协作的前后端分离的开发模式。后来,事实证明,这是一个非常明智的选择。这不仅大幅提升了开发效率,也大大降低了之后向移动端迁移的成本。在开发移动端产品的时候,后端接口大部分都可以直接拿来使用。 + +在技术框架方面,由于大家对前后端分离的模式达成了共识,我们就采用Python+Django+VUE的方式来做。你可能会问,为啥不用基于Java的Spring系列呢?因为我觉得,对于内部系统来说,这些典型的框架应付起来基本都绰绰有余,关键还是要选你熟悉的、易上手的那个。从这个角度来看,Python显然有着得天独厚的优势。即便之前只是写写脚本,想要上手Python也不是一件困难的事情。 + +在项目协作方面,我等会儿会专门提到,由于团队成员分散在北京、上海两地,彼此之间不够熟悉和信任,所以,建立固定的沟通机制就非常重要。 + +至少,在项目初期,我们每周都要开两次电话会议: + + +一次是面向全员的。一方面同步项目的最新进展,另一方面,也给大家一些紧迫感,让大家觉得“其他人都在按照计划执行,自己也不能落后”。 +另外一次是面向跨地域骨干的。这主要还是为了增进联系,并且对一些核心问题进行二次的进展确认。不拉上全员,也是为了避免过多地浪费项目成员的时间。 + + +最后,项目毕竟还是有一些技术风险的,所以还需要启动预研。我们这个项目的主要风险是在前端交互上。 + +这是一个从来没人实现过的场景,有大量的用户界面编排操作在里面。所以,我们专门指定了一位同学,让他啥也别想,一门心思地进行技术攻关。 + +事实证明,但凡能打硬仗的同事,在后来都是非常靠谱且独当一面的,这与年龄无关,哪怕是应届生,也同样如此。 + +讲到这里,我要先给你总结一下在项目启动阶段要重点关注的几件事情: + + +明确项目目标,树立团队的信心; +沟通开发模式和技术架构选型,以快速开发和简单上手为导向; +建立沟通渠道,保持高频联系; +识别项目的技术风险,提前开启专项预研。 + + +开发策略 + +人类社会活动的每一个环节,都需要越来越多的人为了同一个目标推进工作,软件开发也不例外。那么,我们是怎么做的呢? + +首先,就是研发环境容器化。 + +对于接触一个全新技术栈的开发来说,本地搭建一套完整可运行的环境总是绕不过去的坎。即便是对照着文档一步步操作,也总会有遗漏的地方。除此之外,项目依赖的各种中间件,哪怕稍微有一个版本不一致,最后一旦出现问题,就要查很久。 + +既然如此,为什么不一上来就采用标准化的环境呢?这就可以发挥容器技术的优势了。主力后台开发同学自己认领了这个任务,先在本地完成环境搭建并调试通过,接着把环境配置容器化。这样一来,新人加入项目后,几分钟就能完成一套可以工作的本地开发环境。即便后续要升级环境组件,比如Django框架版本,也非常简单,只要推送一个镜像上去,再重启本地环境就可以了。 + +其次,就是选择分支策略。虽然DevOps倡导的是主干开发,但是我们还是选择了“三分支”的策略,因为我们搭建了三套环境。 + +测试环境对应dev分支作为开发主线,所有新功能在特性分支开发,自测通过后,再通过MR到dev分支并部署到测试环境进行验收测试,一般验收测试由需求提出方负责。 + +接下来,定期每周两次从dev上master分支,master分支对应了预发布环境,保证跟生产环境的一致性,数据也会定期进行同步。只有在预发布环境最终验收通过后,才具备上线生产环境的条件。通过将master分支合并到release分支,最后完成生产环境部署。这种分支策略的示意图如下: + + + +为什么要采用三套环境的“三分支”策略呢? + +这里的主要原因就是,团队处于组建初期,磨合不到位,经常会出现前后端配置不一致的情况。更何况,我们这个项目不只有前后端开发,还有核心原子业务开发,以及基础设施维护。任何一方的步调不一致,都会导致出现问题。 + +另外,内部平台开发往往有个通病,就是没有专职测试。这也能理解,总共才几个人的“草台班子”,哪来的测试资源啊?所以,基本上只能靠研发和产品把关。 + +但是,毕竟测试也是个专业的工种,这么一来,总会有各种各样的问题。再加上,产品需求本身就没有那么清晰、灵活多变,所以,多一套环境,多一套安全。 + +但不可否认的是,这种策略并非是最优解,只不过是适应当时场景下的可行方案。当团队磨合到位,而且也比较成熟之后,就可以简化一条分支和一套环境了。不过,前提是,只有快速迭代,快速上线,才能发挥两套环境的优势。 + + +Use what you build to build what you use.(使用你开发的工具来开发你的工具) + + +这是我们一以贯之的理念。既然是DevOps平台,那么团队也要有DevOps的样子,所以,作为一个全功能团队,研发自上线和研发自运维就发挥到了极致。 + +同时,我们并没有使用公司统一的上线流程,而是自己建立了一个标准化的上线流程并固化在工具里面,团队的每一个人都能完成上线动作。 + +这样一来,就不会再依赖于某个具体的人员了,这就保持了最大的灵活性。即便赶上大促封网,也不会阻塞正常的开发活动。 + +开发协作流程 + +仅仅是做到上面这几点,还不足以让整个团队高效运转起来,因为缺少了最重要的研发协作流程。 + +作为项目负责人,我花了很大的精力优化研发协作流程,制定研发协作规范。当这一切正常运转起来后,我发现,这些前期的投入都是非常值得的。 + +在工具层面,我们使用了Jira。对于小团队来说,Jira的功能就足够优秀了,可以满足大多数场景的需求。但是Jira的缺点在于,使用和配置门槛稍微有点高。因此,团队里面需要有一个熟悉Jira的成员,才能把这套方法“玩”下去。 + +在Jira里面,我们采用了精益看板加上迭代的方式,基本上两周一个迭代,保持开发交付的节奏。这种开发工作流刚好适配我们的分支策略和多环境部署。 + +需求统一纳入Backlog管理,当迭代开始时,就拖入待开发状态,研发挑选任务启动开发,并进入开发中。当开发完成后,也就意味着功能已经在测试环境部署。这个时候,就可以等待功能验收。只有在验收通过之后,才会发布到预发布环境。并经过二次验收后,最终上线发布给用户。 + +开发流程并不复杂,你可以看一下下面这两版流程图。 + +图片版: + + + +文字版: + + + +定义好开发工作流之后,接下来,就需要明确原则和规范了。对于一个新组建的团队来说,规则是消除分歧和误解的最好手段,所以一定要让这些规则足够得清晰易懂。比如,在我们内部就有一个“3-2-1”原则: + +3:创建任务三要素 + + +有详细的问题说明和描述 +有清晰的验收标准 +有具体的经办人和迭代排期 + + +2:处理任务两要素 + + +在开发中,代码变更要关联Jira任务号 +在开发完成后,要添加Jira注释,说明改动内容和影响范围 + + +1:解决任务一要素 + + +问题报告人负责任务验收关闭 + + +当然,团队规则远不止这几条。你要打造自己团队内部的规则,并且反复地强调规则,帮助大家养成习惯。这样一来,你会发现,研发效率提升和自组织团队都会慢慢成为现实。 + +除此之外,你也不要高估人的主动性,期望每个人都能自觉地按照规则执行。所以,定期和及时的提醒就非常必要。比如,每天增加定时邮件通知,告诉大家有哪些需求需要验收,有哪些可以上线发布,尽量让每个人都明白应该去哪里获取最新的信息。 + +另外,每次开周会时,都要强调规则的执行情况,甚至每天的站会也要按需沟通。只有保持短促、高频的沟通,才能产生理想的效果。 + +产品运营策略 + +关于产品运营策略,“酒香不怕巷子深”的理念已经有些过时了。想要一个产品获得成功,团队不仅要做得好,还要善于运营和宣传,而这又是技术团队的一大软肋。 + +开发团队大多只知道如何实现功能,却不知道应该怎么做产品运营。往往也正因为如此,团队很难获取用户的真实反馈,甚至开发了很多天才的功能,用户都不知道。产品开发变成了“自嗨”,这肯定不符合产品设计的初衷。 + +考虑到这些,我们在平台运营的时候,也采取了一些手段。我想提醒你的是,很多事情其实没有没有多难,关键就看有没有想,有没有坚持做。 + +比如,你可以建立内部用户沟通群,在产品初期尽量选择一些活跃的种子用户来试用。那些特别感兴趣、愿意尝试新事物、不断给你提建议的都是超级用户。这些用户未来都是各个团队中的“星星之火”,在项目初期,你一定要识别出这些用户。 + +另外,每一次上线都发布一个release notes,并通过邮件和内部沟通群的方式通知全员,一方面可以宣传新功能,另一方面,也是很重要的一方面,就是保持存在感的刷新。你要让用户知道这个产品是在高速迭代的过程中的,而且每次都有不一样的新东西,总有一样会吸引到他们,或者让他们主动提出自己的问题。 + +在用户群里面,注意要及时响应用户的问题。你可以在团队内部建立OnCall机制,每周团队成员轮值解决一线用户的问题,既可以保证问题的及时收敛,也能让远离用户的开发真真切切地听到用户的声音。这样的话,在需求规划会和迭代回顾会的时候,开发就会更多地主动参与讨论。 + +以上这些都是比较常规的手段,在我们的产品运营中,还有两个方法特别有效,我也推荐给你。 + +平台运营就跟打广告是一样的,越是在人流最大、关注度最高的地方打广告,效果也就越好。每个公司一般都有类似的首页,比如公司内部的技术首页、技术论坛、日常办公的OA系统等等,这些地方其实都会有宣传的渠道和入口。你要做的就是找到这个入口,并联系上负责这个渠道的人员。我们的产品就一度实现了热门站点的霸屏,宣传效果非常明显,用户量直线上升。 + + + +另一个方法有些取巧,但对于技术团队来说,也非常适用,那就是通过技术分享的渠道来宣传产品。 + +相信每个团队都会有定期的技术分享渠道,或者是技术公众号等,你可以把平台的核心技术点和设计思想提炼出来,拟定一个分享话题,并在内部最大范围的技术分享渠道中进行分享。 + +很多时候,单纯地宣传一个产品,很多人是“不感冒”的。但是,如果你在讲一些新技术,并结合产品化落地的事情,对技术人员的吸引力就会大很多。所以,换个思路做运营,也是提升产品知名度的好方法。我把我之前总结的产品运营渠道和手段汇总成了一幅脑图,也分享给你。 + + + +团队文化建设 + +最后,我想再跟你简单聊聊团队文化建设的事情。毕竟,无论什么样的工具、流程、目标,最终都是依靠人来完成的。如果忽略对人的关注,就等同于本末倒置,不是一个成熟的团队管理者应该做的事情。我给你分享我的两点感受。 + +1.让专业的人做专业的事情 + +很多时候,千万不要小看专业度这个事情。任何一个组织内部的职能都需要专业能力的支撑,这些专业能力都是量变引发的质变。 + +我举个最简单的例子,你还记得我在前面提到的0.2版本的原型草稿吗?实际上,到了0.3版本,引用前端工程师话来说,“原型做得比系统还漂亮”。这是为什么呢?难道是我这个“半吊子”产品经理突然开窍了吗? + +显然不是。其实答案很简单,就是我去找了专业产品经理做外援,让他帮我改了两天的原型图。对于专业的人来说,这些事情再简单不过了。 + +找专业的人来做这些事情,不仅可以帮助你快速地跨越鸿沟,也能留下很多现成的经验,供你以后使用,这绝对不是一个人埋头苦干可以做得到的。 + +不仅是产品方面,技术领域也是一样的。我们要勇于承认自己的无知,善于向别人求助,否则到头来,损失的时间和机会都是自己买单,得不偿失。 + +2.抓大放小,适当地忽略细节 + +在协作的过程中,团队总会在一些细节上产生冲突。如果任由团队成员在细节上争论不休,久而久之,就会影响团队之间的信任感。这个时候,就需要引导团队将注意力集中在大的方向上,适当地暂缓细节讨论,以保证团队的协作效率。 + +比如,一个业务逻辑是放在前端处理,还是放在后端处理,结果并没有太大区别,说白了,就是放在哪儿都行。但是,前端同学会坚持认为,逻辑处理都应该由后端来解决,以降低前端和业务的耦合性,这样说也没有错。可是,后端同学也会有自己的想法,比如针对前端拦截器的处理机制,后端到底要不要配合着返回前端要求的返回码,而不是直接抛出http原始的返回码呢? + +类似的这些问题,没有谁对谁错之分,但是真要是纠结起来,也不是一两句话就能说清楚的。 + +这个时候,就需要有人拍板,选择一条更加符合常规的方式推进,并预留出后续的讨论空间。甚至,为了促进多地合作,自己人这边要适当地牺牲一些,以此来换取合作的顺利推进。这样一来,你会发现,有些不可调和的事情,在项目不断成功、人员不断磨合的过程中,也就不是个事情了。 + +总结 + +在这一讲中,关于如何开发产品,可以说,我是把自己在过去几个项目经历中的总结倾囊相授了。 + +其实,就像我在讲“DevOps工程师需要的技能”中提到的那样,软实力(比如沟通协作、同理心、持续改进等)对促进产品快速迭代开发演进有着重大的作用。作为非专业产品经理,我也在慢慢地积累自己的产品心经,有机会再给你好好聊聊。 + +你可能还在想,最终千人的目标是否实现了呢?我想说的是,有些时候,真实生活比故事还要精彩。 + +就在预订目标的倒数第二天,平台用户只有997个。当时,我跟同事吐槽这个数字,他们说要不要拉几个用户进来,我说:“算了吧,随它去吧。“结果你猜怎样?在当天周五下班的时候,我又去平台上看了一眼,不多不少刚好1000个注册用户。当时我的第一感觉就是,要相信,当我们把自己的全身心和热情都灌注在一个产品的开发过程中时,美好的事情会自然而然地发生。 + +思考题 + +你对这一讲的哪部分内容印象最深刻呢?你有什么其他有助于产品快速研发落地的观点吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/27巨人的肩膀:那些你不能忽视的开源工具.md b/专栏/DevOps实战笔记/27巨人的肩膀:那些你不能忽视的开源工具.md new file mode 100644 index 0000000..b3c3606 --- /dev/null +++ b/专栏/DevOps实战笔记/27巨人的肩膀:那些你不能忽视的开源工具.md @@ -0,0 +1,286 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 巨人的肩膀:那些你不能忽视的开源工具 + 你好,我是石雪峰。 + +自研工具平台对公司来说是一件高成本和高投入的事情,对于技术人员的要求也非常高。很少有公司能够像BAT一样投入近百人的团队来开发内部系统工具,毕竟,如果没有这么大规模的团队,平台产生的收益也比较有限。 + +另外,也很少有公司像一些行业头部公司一样,会直接投入大量资金购买成熟的商业化工具或者通过乙方合作的方式联合共建。 + +这些方法的长期投入都比较大,不太适用于中小型企业。那么,有其他可以低成本、快速见效的解决方案吗? + +实际上,现在的开源工具已经非常成熟了,只要稍加熟悉,就能快速地基于开源工具搭建一整套研发交付工具链平台。 + +几年前,我跟几个朋友利用业余时间就搭建了这样一套开源的端到端流水线解决方案。我依稀记得,这个解决方案架构图是在北京开往上海的高铁上完成的。目前,这个方案在行业内广为流传,成为了很多公司搭建自己内部工具链平台的参考资料。这个系统的架构图如下: + + + +今天,我会基于这个解决方案,给你介绍一下研发代码提交阶段、集成测试阶段和部署发布阶段的工具使用技巧,工具选型以主流开源解决方案为主,商业工具为辅,涵盖了Jira、GitLab、Jenkins、SonarQube和Kubernetes等,希望可以手把手地帮助你快速搭建一套完整的持续交付平台。 + +对于持续交付工具链体系来说,工具的连通性是核心要素,所以我不会花太多时间介绍工具应该如何搭建,毕竟这类资料有很多,或者,你参考一下官网的搭建文档就可以了。尤其是现在很多工具都提供了容器化的部署方式,进一步简化了自身工具的建设成本。 + +需求管理 - Jira + +在Jira官网上的醒目位置,写着一句话:敏捷开发工具的第一选择。在我看来,Atlassian公司的确有这个底气,因为Jira确实足够优秀,跟Confluence的组合几乎已经成为了很多企业的标配。这也是为什么我没有选择开源工具Redmine或者其他诸如Teambition等的SaaS化服务。 + +当然,近些年来,各大厂商也在积极地对外输出研发工具能力,以腾讯的TAPD为代表的敏捷协同开发工具,就使用得非常广泛。但是,其实产品的思路都大同小异,搞定了Jira,其他工具基本也就不在话下了。 + +作为敏捷协同工具,Jira新建工程可以选择团队的研发模式是基于Scrum,还是看板方法,你可以按需选择。在专栏的第8讲和第9讲中,我给你介绍了精益看板,你完全可以在Jira中定制自己团队的可视化看板。 + +看板的配置过程并不复杂,我把它整理成了文档,你可以点击网盘链接获取,提取码是mrtd。需要提醒你的一点是:别忘了添加WIP在制品约束,别让你的精益看板变成了可视化看板。 + +需求作为一切开发工作的起点,是贯穿整个研发工作的重要抓手。对于Jira来说,重点是要实现跟版本控制系统和开发者工具的打通。接下来,我们分别来看下应该如何实现。 + +如果你也在使用特性分支开发模式,你应该知道,一个特性就对应到一个Jira中的任务。通过任务来创建特性分支,并且将所有分支上的提交绑定到具体任务上,从而建立清晰的特性代码关联。我给你推荐两种实现方式。 + +第一种方式是基于Jira提供的原生插件,比如 Git Integration for Jira。这个插件配置起来非常简单,你只需要添加版本控制系统的地址和认证方式即可。然后,你就可以在Jira上进行查看提交信息、对比差异、创建分支和MR等操作。但是这个插件属于收费版本,你可以免费使用30天,到期更新即可。 + + + +第二种方式,就是使用Jira和GitLab的Webhook进行打通。 + +首先,你要在GitLab项目的“设置 - 集成”中找到Jira选项,按下图添加相应配置即可。配置完成之后,你只需要在提交注释中添加一个Jira的任务ID,就可以实现Jira任务和代码提交的关联,这些关联体现在Jira任务的Issue links部分。 + +另外,你也可以实现Jira任务的状态自动流转操作,无需手动移动任务卡片。我给你提供一份 配置说明 ,你可以参考一下。 + + + +不过,如果只是这样的话,还不能实现根据Jira任务来自动创建分支,所以接下来,还要进行Jira的Webhook配置。在Jira的系统管理界面中,你可以找到“高级设置 - Webhook”选项,添加Webhook后,可以绑定各种系统提供的事件,比如创建任务、更新任务等,这基本可以满足绝大多数场景的需求。 + +假设我们的系统在创建Jira任务的时候,要自动在GitLab中基于主线创建一条分支,那么你可以将GitLab提供的创建分支API写在Jira触发的Webhook地址中。参考样例如下: + + +https : //这里替换成你的GitLab服务地址/repository/branches?branch=${issue.key}&ref=master&private_token=[这里替换成你的账号Token] + + + + +到这里,Jira和GitLab的打通就完成了。我们来总结下已经实现的功能: + + +GitLab每次代码变更状态都会同步到Jira任务中,并且实现了Jira任务和代码的自动关联(Issue links); +可以在MR中增加关键字 Fixes/Resolves/Closes Jira任务号,实现Jira的自动状态流转; +每次在Jira中创建任务时,都会自动创建特性分支。 + + +关于Jira和开发者工具的打通,我把操作步骤也分享给你。你可以点击网盘链接获取,提取码是kf3t。现在很多工具平台的建设都是以服务开发者为导向的,所以距离开发者最近的IDE工具就成了新的效率提升阵地,包括云IDE、IDE插件等,都是为了方便开发者可以在IDE里面完成所有的日常任务,对于管理分支和Jira任务,自然也不在话下。 + +代码管理 - GitLab + +这个示例项目中的开发流程是怎样的呢?我们一起来看下。 + +第1步:在需求管理平台创建任务,这个任务一般都是可以交付的特性。你还记得吗?通过前面的步骤,我们已经实现了自动创建特性分支。 + +第2步:开发者在特性分支上进行开发和本地自测,在开发完成后,再将代码推送到特性分支,并触发提交阶段的流水线。这条流水线主要用于快速验证提交代码的基本质量。 + +第3步:当提交阶段流水线通过之后,开发者创建合并请求(Merge Request),申请将特性分支合并到主干代码中。 + +第4步:代码评审者对合并请求进行Review,发现问题的话,就在合并请求中指出来,最终接受合并请求,并将特性代码合入主干。 + +第5步:代码合入主干后,立即触发集成阶段流水线。这个阶段的检查任务更加丰富,测试人员可以手动完成测试环境部署,并验证新功能。 + +第6步:特性经历了测试环境、预发布环境,并通过部署流水线最终部署到生产环境中。 + +在专栏的第12讲中,我提到过,持续集成的理念是通过尽早和及时的代码集成,从而建立代码质量的快速反馈环。所以,版本控制系统和持续集成系统也需要双向打通。 + +这里的双向打通是指版本控制系统可以触发持续集成系统,持续集成的结果也需要返回给版本控制系统。 + +接下来,我们看看具体怎么实现。 + +代码提交触发持续集成 + +首先,你需要在Jenkins中安装GitLab插件。这个插件提供了很多GitLab环境变量,用于获取GitLab的信息,比如,gitlabSourceBranch这个参数就非常有用,它可以提取本次触发的Webhook的分支信息。毕竟,这个信息只有GitLab知道。只有同步给Jenkins,才能拉取正确的分支代码执行持续集成过程。 + +当GitLab监听到代码变更的事件后,会自动调用这个插件提供的Webhook地址,并实现解析Webhook数据和触发Jenkins任务的功能。 + +其实,我们在自研流水线平台的时候,也可以参考这个思路:通过后台调用GitLab的API完成Webhook的自动注册,从而实现对代码变更事件的监听和任务的自动化执行。 + +当GitLab插件安装完成后,你可以在Jenkins任务的Build Triggers中发现一个新的选项,勾选这个选项,就可以激活GitLab自动触发配置。其中比较重要的两个信息,我在下面的图片中用红色方块圈出来了。 + + + + +上面的链接就是Webhook地址,每个Jenkins任务都不相同; +下面的是这个Webhook对应的认证Token。 + + +你需要把这两个信息一起添加到GitLab的集成配置中。打开GitLab仓库的“设置-集成”选项,可以看到GitLab的Webhook配置页面,将Jenkins插件生成的地址和Token信息复制到配置选项中,并勾选对应的触发选项。 + +GitLab默认提供了多种触发选项,在下面的截图中,只勾选了Push事件,也就是只有监听到Git Push动作的时候,才会触发Webhook。当然,你可以配置监听的分支信息,只针对特性分支执行关联的Jenkins任务。在GitLab中配置完成后,可以看到新添加的Webhook信息,点击“测试”验证是否可以正常执行,如果一切正常,则会提示“200-OK”。 + + + +持续集成更新代码状态 + +打开Jenkins的系统管理页面,找到GitLab配置,添加GitLab服务器的地址和认证方式。注意,这里的Credentials要选择GitLab API Token类型,对应的Token可以在GitLab的“用户 - 设置 - Access Tokens”中生成。由于Token的特殊性,只有在生成的时候可见,以后就再也看不到了。所以,在生成Token以后,你需要妥善地保存这个信息。 + +- + + +那么,配置完成后,要如何更新GitLab的提交状态呢?这就需要用到插件提供的更新构建结果命令了。 + +对于自由风格类型的Jenkins任务,你可以添加构建后处理步骤 - Publish build status to GitLab,它会自动将排队的任务更新为“Pending”,运行的任务更新为“Running”,完成的任务根据结果更新为“Success”或者是“Failed”。 + +对于使用流水线的任务来说,官方也提供了相应的示例代码,你只需要对照着写在Jenkinsfile里面就可以了。 + +updateGitlabCommitStatus name: 'build', state: 'success' + + +这样一来,每次提交代码触发的流水线结果也会显示在GitLab的提交状态中,可以在查看合并请求时作为参考。有的公司更加直接:如果流水线的状态不是成功状态,那么就会自动关闭提交的合并请求。其实无论采用哪种方式,初衷都是希望开发者在第一时间修复持续集成的问题。 + + + +我们再阶段性地总结一下已经实现的功能: + + +每次GitLab上的代码提交都可以通过Webhook触发对应的Jenkins任务。具体触发哪个任务,取决于你将哪个Jenkins任务的地址添加到了GitLab的Webhook配置中; +每次Jenkins任务执行完毕后,会将执行结果写到GitLab的提交记录中。你可以查看执行状态,决定是否接受合并请求。 + + +代码质量 - SonarQube + +SonarQube作为一个常见的开源代码质量平台,可以用来实现静态代码扫描,发现代码中的缺陷和漏洞,还提供了比较基础的安全检查能力。除此之外,它还能收集单元测试的覆盖率、代码重复率等。 + +对于刚开始关注代码质量和技术债务的公司来说,是一个比较容易上手的选择。关于技术债务,在专栏的第15讲中有深入讲解,如果你不记得了,别忘记回去复习一下。 + +那么,代码质量检查这类频繁执行的例行工作,也比较适合自动化完成,最佳途径就是集成到流水线中,也就是需要跟Jenkins进行打通。我稍微介绍一下执行的逻辑,希望可以帮你更好地理解这个配置的过程。 + +SonarQube平台实际包含两个部分: + + +一个是平台端,用于收集和展示代码质量数据,这也是我们比较常用的功能。 +另外一个是客户端,也就是SonarQube的Scanner工具。这个工具是在客户端本地执行的,也就是跟代码在一个环境中,用于真正地执行分析、收集和上报数据。这个工具之所以不是特别引人注意,是因为在Jenkins中,后台配置了这个工具,如果发现节点上没有找到工具,它就会自动下载。你可以在Jenkins的全局工具配置中找到它。 + + + + +了解了代码质量扫描的执行逻辑之后,我们就可以知道,对于SonarQube和Jenkins的集成,只需要单向进行即可。这也就是说,只要保证Jenkins的Scanner工具采集到的数据可以正确地上报到SonarQube平台端即可。 + +这个配置也非常简单,你只需要在Jenkins的全局设置中添加SonarQube的平台地址就行了。注意勾选第一个选项,保证SonarQube服务器的配置信息可以自动注入流水线的环境变量中。 + + + +在执行Jenkins任务的时候,同样可以针对自由风格的任务和流水线类型的任务,添加不同的上报方式。关于具体的内容,你可以参考SonarQube的官方网站,这里就不赘述了。 + +到此为止,我们已经实现了GitLab、Jenkins和SonarQube的打通。我给你分享一幅系统关系示意图,希望可以帮助你更好地了解系统打通的含义和实现过程。 + + + +环境管理 - Kubernetes + +最后,我们再来看看环境管理部分。作为云原生时代的操作系统,Kubernetes已经成为了云时代容器编排的事实标准。对于DevOps工程师来说,Kubernetes属于必学必会的技能,这个趋势已经非常明显了。 + +在示例项目中,我们同样用到了Kubernetes作为基础环境,所有Jenkins任务的环境都通过Kubernetes来动态初始化生成。 + +这样做的好处非常多。一方面,可以实现环境的标准化。所有环境配置都是以代码的形式写在Dockerfile中的,实现了环境的统一可控。另一方面,环境的资源利用率大大提升,不再依托于宿主机自身的环境配置和资源大小,你只需要告诉Kubernetes需要多少资源,它就会帮助你找到合适的物理节点运行容器。资源的调度和分配统一通过Kubernetes完成,这就进一步提升了资源的有效利用率。想要初始化一套完整的环境,对于中小系统来说,是分分钟就可以完成的事情。关于这一点,我会在讲“云原生时代应用的平台建设”时跟你探讨。 + +那么,想要实现动态初始化环境,需要打通Jenkins和Kubernetes。好在Jenkins已经提供了官方的Kubernetes插件来完成这个功能。你可以在Jenkins系统配置中添加云 - Kubernetes,然后再参考下图进行配置。 + +需要注意的是,必须正确配置Jenkins的地址(系统配置 - Jenkins Location),否则会导致新建容器无法连接Jenkins。 + + + +生成动态节点时,需要使用到JNLP协议,我推荐你使用Jenkins官方提供的镜像。 + +JNLP协议的全称是Java Network Launch Protocol,是一种通用的远程连接Java应用的协议方式。典型的使用场景就是在构建节点(也就是习惯上的Slave节点)上发起向Master节点的连接请求,将构建节点主动挂载到Jenkins Master上,供Master调度使用。区别于使用SSH长连接的方式,这种动态连接的协议特别适合于Kubernetes这类的动态节点。镜像配置如下图所示: + + + +在配置动态节点的时候,有几个要点你需要特别关注下。 + + +静态目录挂载。由于每次生成一个全新的容器环境,所以就需要将代码缓存(比如.git目录)、依赖缓存(.m2, .gradle, .npm)以及外部工具等静态数据通过volume的方式挂载到容器中,以免每次重新下载时影响执行时间。 +如果你的Jenkins也是在Kubernetes中运行的,注意配置Jenkins的JNLP端口号(使用环境变量:JENKINS_SLAVE_AGENT_PORT)。否则,在系统中配置的端口号是不会生效的。 +由于每次初始化容器有一定的时间损耗,所以你可以配置一个等待时长。这样一来,在任务运行结束后,环境还会保存一段时间。如果这个时候有新任务运行,就可以直接复用已有的容器环境,而无需重新生成。 +如果网络条件不好,可以适当地加大创建容器的超时时间,默认是100秒。如果在这个时间内无法完成容器创建,那么Jenkins就会自动杀掉创建过程并重新尝试。 + + +如果一切顺利,动态Kubernetes环境就也可以使用了。这时,我们就可以完整地运行一条流水线了。在设计流水线的时候,你需要注意的是流水线的分层。具体的流水线步骤,我已经写在了系统架构图中。比如,提交阶段流水线需要完成拉取代码、编译打包、单元测试和代码质量分析四个步骤,对应的代码如下: + +// pipeline 2.0 - Commit stage - front-end +pipeline { + agent { + // Kubernetes节点的标签 + label 'pipeline-slave' + + + } + environment { + // 镜像仓库地址 + HARBOR_HOST= '123.207.154.16' + IMAGE_NAME = "front-end" + REPO = 'front-end' + HOST_CODE_DIR = "/home/jenkins-slave/workspace/${JOB_NAME}" + GROUP = 'weaveworksdemos' + COMMIT = "${currentBuild.id}" + TAG = "${currentBuild.id}" + TEST_ENV_NAME = 'test' + STAGE_ENV_NAME = 'staging' + PROD_ENV_NAME = 'prod' + BUILD_USER = "${BUILD_USER_ID}" + // 需要挂载到容器中的静态数据 + COMMON_VOLUME = ' -v /nfs/.m2:/root/.m2 -v /nfs/.sonar:/root/.sonar -v /nfs/.npm:/root/.npm ' + } + stages { + stage('Checkout') { + steps { + git branch: 'xxx', credentialsId: '707ff66e-1bac-4918-9cb7-fb9c0c3a0946', url: 'http://1.1.1.1/shixuefeng/front-end.git' + } + } + stage('Prepare Test') { + steps { + sh ''' + docker build -t ${IMAGE_NAME} -f test/Dockerfile . + docker run --rm -v ${HOST_CODE_DIR}:/usr/src/app ${IMAGE_NAME} /usr/local/bin/cnpm install + ''' + } + } + stage('Code Quality') { + parallel { + stage('Unit Test') { + steps { + sh ''' + docker run --rm -v ${HOST_CODE_DIR}:/usr/src/app ${IMAGE_NAME} /usr/local/bin/cnpm test + ''' + } + } + stage('Static Scan') { + steps { + sh 'echo "sonar.exclusions=node_modules/**" >> sonar-project.properties' + script { + def scannerHome = tool 'SonarQubeScanner'; + withSonarQubeEnv('DevOpsSonar') { + sh "${scannerHome}/bin/sonar-scanner" + updateGitlabCommitStatus name: 'build', state: 'success' + } + } + } + } + } + } + } +} + + +如果你按照刚刚我所介绍的步骤操作的话,你就会得到这样一张完整的流水线演示效果图: + + + +结合Jenkins自身的人工审批环节,可以实现多环境的自动和手动部署,构建一个真正的端到端持续交付流水线。 + +总结 + +在今天的课程中,我通过一个开源流水线的解决方案,给你介绍了如何建立一个开源工具为主的持续交付流水线平台。你应该也有感觉,对于DevOps来说,真正的难点不在于工具本身,而在于如何基于整个研发流程将工具串联打通,把它们结合在一起,发挥出最大的优势。这些理念对于自建平台来说也同样适用,你需要在实践中多加尝试,才能在应用过程中游刃有余。 + +思考题 + +关于这套开源流水线解决方案,你对整体的工具链、配置、设计思路还有什么疑问吗?在实施过程中,你遇到了哪些绕不过去的问题呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/28迈向云端:云原生应用时代的平台思考.md b/专栏/DevOps实战笔记/28迈向云端:云原生应用时代的平台思考.md new file mode 100644 index 0000000..986fe88 --- /dev/null +++ b/专栏/DevOps实战笔记/28迈向云端:云原生应用时代的平台思考.md @@ -0,0 +1,203 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 迈向云端:云原生应用时代的平台思考 + 你好,我是石雪峰。 + +最近几年,相信你一定从各种场合听到过“云原生”这个词。比如云原生应用的12要素、最近大火的现象级技术Docker,以及容器编排技术Kubernetes。其中,Kubernetes背后的CNCF,也就是云原生应用基金会,也成了各大企业争相加入的组织。 + +DevOps似乎也一直跟云技术有着说不清的关系,比如容器、微服务、不可变基础设施以及服务网格、声明式API等都是DevOps技术领域中的常客。云原生应用似乎天生就和DevOps是绝配,自带高可用、易维护、高扩展、持续交付的光环。 + +那么,所谓的云原生,到底是什么意思呢?我引用一下来自于CNCF的官方定义: + + +Cloud native computing uses an open source software stack to deploy applications as microservices, packaging each part into its own container, and dynamically orchestrating those containers to optimize resource utilization.- +云原生使用一种开源软件技术栈来部署微服务应用,将每个组件打包到它自己的容器中,并且通过动态编排来优化资源的利用率。 + + +我总结一下这里面的关键字:开源软件、微服务应用、容器化部署和动态编排。那么,简单来说,云原生应用就是将微服务风格的架构应用,以容器化的方式部署在云平台上,典型的是以Kubernetes为核心的云平台,从而受益于云服务所带来的各种好处。 + +我在专栏中也反复强调过,容器技术和Kubernetes是划时代的技术,是每一个学习DevOps的工程师的必备技能。就像很多年前要人手一本《鸟哥的Linux私房菜》在学习Linux一样,Kubernetes作为云时代的Linux,同样值得你投入精力。 + +今天,我并不是要跟你讲Kubernetes,我想通过一个项目,以及最近两年我的亲身经历,给你分享一下,云原生究竟会带给DevOps怎样的改变。这个项目就是Jenkins X。 + +在2018年初,我分享过有关Jenkins X的文章,在短短几天的时间内,阅读量就过万了。这一方面体现了Jenkins在国内的巨大影响力,另外一方面,也凸显了Jenkins与这个时代的冲突和格格不入。为什么这么说呢?因为Jenkins作为一个15年的老系统,浑身上下充满了云原生的反模式 ,比如: + + +Jenkins是一个Java单体应用,运行在JVM之上,和其他典型的Java应用并没有什么区别; +Jenkins使用文件存储,以及各种加载模式、资源调度机制等,确保了它天生不支持高可用; +Jenkins虽然提供了流水线,但是流水线依然是执行在主节点上,这就意味着随着任务越来越多,主节点消耗的资源也就越来越多,不仅难以扩展,还非常容易被随便一个不靠谱的任务搞挂掉。 + + +举个最简单的例子,如果一个任务输出了500MB的日志,当你在Jenkins上点击查看全部日志的时候,那就保佑自己的服务器能挺过去吧。因为很多时候,服务器可能直接就死掉了。当然,我非常不建议你在生产环境做这个实验。 + +那么,如果想让Jenkins实现云原生化,要怎么做呢?有的同学可能会说:“把Jenkins放到容器中,然后丢给Kubernetes管理不就行了吗?”如果你也是这么想的,那就说明,无论是对Kubernetes还是云原生应用,你的理解还不够到位。我来给你列举下,如果要把Jenkins改造为一个真正的云原生应用,要解决哪些问题: + + +可插拔式的存储(典型的像是S3、OSS) +外部制品管理 +Credentials管理 +Configuration管理 +测试报告和覆盖率报告管理 +日志管理 +Jenkins Job +…… + + +你看,我还只是列举了其中一部分,以云原生应用12要素的标准来说,要做的改造还有很多。 + +以日志为例,当前Jenkins的所有日志都是写在Master节点上的,如果想改造成云原生应用的方法,首先就是要把日志看作一种输出流。输出流不应该由应用管理,写在应用运行节点的本地,而是应该由专门的日志服务来负责收集、分析、整理和展示。比如ElasticSearch、Fluent,或者是AWS的CloudWatch Logs,都可以实现这个功能。 + +那么,Jenkins X是怎么解决这个问题的呢? + +我们来试想一个场景:当开发工程师想要开发一个云原生应用的时候,他需要做什么? + +首先,他需要有一套可以运行的Kubernetes环境。考虑到各种不可抗力因素,这绝对不是一件简单的事情。尤其是在几年前,如果有人能够通过二进制的方式完成Kubernetes集群的搭建和部署,这一定是一件值得吹牛的事情。好在现在公司里面都有专人负责Kubernetes集群维护,各大公有云厂商也都提供了这方面的支持。 + +现在,我们继续回到工程师的视角。 + +当他接到一个需求后,他首先需要修改代码,然后把代码编译打包,在本地测试通过。接下来,他要将代码提交到版本控制系统,手动触发流水线任务,并等待执行完毕。如果碰巧这次调整了编译命令,他还要修改流水线配置文件。最后,经过千辛万苦,生成了一个镜像文件,并把镜像文件推送到镜像服务器上。这还没完,他还需要修改测试环境的Kubernetes资源配置,调用kubectl命令完成应用的更新并等待部署完成。如果对于这次修改,系统验证出了新的问题,那么不好意思,刚刚的这些步骤都需要重头来过。 + +你看,虽然云原生应用有这么多好处,但是大大提升了开发的复杂度。一个工程师必须要熟悉Kubernetes、流水线、镜像、打包、部署等一系列的环节和新技术新工具,才有可能完成一次部署。如果这些操作都依赖于外部门或者其他人,那你就且等着吧。这么看来,这条路是走不下去的。 + +在云时代,一切皆服务。那么,在云原生应用时代,DevOps或持续交付理应也是以一种服务的形式存在。就好比你在用电的时候,一定不会去考虑电厂是怎么运转的,电是怎么送到家里来的,你只要负责用就可以了。 + +那么,我们来看看Jenkins X是怎么一步步地把Jenkins“干掉”的。其实,我希望你能记得,是不是Jenkins X本身并不重要,在这个过程中使用到的工具和技术,以及它们背后的设计理念,才是更重要的。 + +1.自动化生成依赖的配置文件 + +对于一个云原生应用来说,除了源代码本身之外,还依赖于哪些配置文件呢?其中就包括: + + +Dockerfile:用于生成Docker镜像 +Jenkinsfile:应用关联的流水线配置 +Helm Chart:把应用打包并部署运行在Kubernetes上的资源文件 +Skaffold:用于在Kubernetes中生成Docker image的工具 + + +考虑到你可能不太熟悉这个Skaffold工具,我简单介绍一下。 + +实际上,如果想在 Kubernetes 环境中生成Docker镜像,你会发现,一般来说,这都依赖于Docker服务,也就是Docker daemon。那么常见的做法无外乎Docker-in-Docker和Docker-outside-Docker。 + +其中,Docker-in-Docker就是在基础镜像中提供内建的Docker daemon和镜像生成环境,这依赖于官方镜像的支持。而Docker-outside-Docker比较好理解,就是将宿主机的Docker daemon挂载到Docker镜像里面。 + +有三种典型的实现方式:第一种是挂载节点的Docker daemon,第二种就是使用云平台提供的外部服务,比如Google Cloud Builder,第三种就是使用无需Docker daemon也能打包的方案,比如常见的Kaniko。 + +而Skaffold想要解决的就是,你不需要再关心如何生成镜像、推送镜像和运行镜像,它会通通帮你搞定,依赖的就是skaffold.yaml文件。 + +这些文件如果让研发手动生成,那会让研发的门槛变得非常高。好在你可以通过Draft工具来自动化这些操作。Draft是微软开源的一个工具,它包含两个部分。 + + +源代码分析器。它可以自动扫描你的源代码,根据代码特征,识别出你所用到的代码类型,比如JavaScript、Python等。 +build pack。简单来说,build pack就是一种语言对应的模板。通过在模板中定义好预设的环境依赖配置文件,包括上面提到的Dockerfile、Jenkinsfile等,从而实现依赖项的自动生成和创建。当然,你也可以定义自己的build pack,并作为模板在内部项目中使用。 + + +很多时候,模板都是一种特别好的思路,它可以大大简化初始配置成本,提升环境和服务的标准化程度。对于流水线来说,也是如此,毕竟,不是很多人都是这方面的专家,只要能针对90%的场景提供一组或几组最佳实践的模板就足够了。 + +这样一来,无论是已经存在的代码,还是权限初始化的项目,研发都不需要操心如何实现代码打包、生成镜像,以及部署的过程。这也会大大节省研发的精力。毕竟,就像我刚刚提到的,不是每个人都是容器和构建方面的专家。 + +2.自动化流水线过程 + +当应用初始化完成之后,流水线应该是开箱即用的状态。也就是说,比如项目采用的是特性分支加主干开发分支发布的策略,那么,build pack中就预置了针对每条分支的流水线配置文件。这些文件定义了每条分支需要经过的检查过程。 + +那么,当研发提交代码的时候,对应的流水线就会被自动触发。对于研发来说,这一切都是无感知的。只有在必要的时候(比如出现了问题),系统才会通知研发查看错误信息。这就要求流水线的Jenkinsfile要自动生成,版本控制系统和CI/CD系统也需要自动打通。比如,Webhook的注册和配置、MR的评审条件、自动过滤的分支信息等等,都是需要自动化完成的。 + +这里所用到的技术主要有三点。 + + +流水线即代码。毕竟,只有代码化的流水线配置才有可能自动化。 +流水线的抽象和复用。以典型的Jenkinsfile为例,大多数操作应该提取到公共库,也就是shared library中,而不应该hard code在流水线配置文件里面,以提升抽象水平和能力复用。 +流水线的条件判断。对于同一条流水线来说,根据不同的条件,可以实现不同的执行路径。 + + +3.自动化多环境部署 + +对于传统应用来说,尤其是对上下游依赖比较复杂的应用来说,环境管理是个老大难的问题。Kubernetes的出现大大简化了这个过程。当然,前提是云原生应用部署在Kubernetes上时,所有依赖都是环境中的资源。 + +依靠Kubernetes强大的资源管理能力,能够动态初始化出来一套环境,是一种巨大的进步。 + +Jenkins X默认就提供了预发环境和生产环境。不仅如此,对于每一次的代码提交所产生的PR,Jenkins X都会自动初始化一个预览环境出来,并自动完成应用在预览环境的部署。这样一来,每次代码评审的时候,都能够打开预览环境查看应用的功能是否就绪。通过借助用户视角来验收这些功能,也提升了最终交付的质量。 + +这里面所用到的技术,除了之前我在第16讲中给你介绍过的GitOps,主要就是Prow工具。 + +你可以把Prow看作ChatOps的一种具体实现。实际上,它提供的是一种高度扩展的Webhook时间处理能力。比如,你可以通过对话的方式,输入 /approve 命令,Prow接收到这个命令后,就会触发对应的Webhook,并实现流水线的自动执行以及一系列的后台操作。 + +4. 使用云原生流水线 + +在今年年初,Jenkins X进行了一次全面的升级,开始支持Tekton流水线。Tekton的前身是2018年初创建的KNative项目,这是一个面向Kubernetes的Serverless解决方案。但随着这个项目边界的扩大,它渐渐地把整个交付流程的编排都纳入了进来,于是就成立了Tekton项目,用来提供Kubernetes原生的流水线能力。 + +Tekton提供了最底层的能力,Jenkins X提供了上层抽象,也就是通过一个yaml文件的形式来描述整个交付过程。我给你分享了一个流水线配置文件的例子: + +agent: + label: jenkins-maven + container: maven +pipelines: + pullRequest: + build: + steps: + - sh: mvn versions:set -DnewVersion=$PREVIEW_VERSION + - sh: mvn install + release: + setVersion: + steps: + - sh: echo \$(jx-release-version) > VERSION + comment: so we can retrieve the version in later steps + - sh: mvn versions:set -DnewVersion=\$(cat VERSION) + - sh: jx step tag --version \$(cat VERSION) + build: + steps: + - sh: mvn clean deploy + + +在这个例子中,你可以看到,流水线过程是通过yaml格式来描述的,而不是通过我们之前所熟悉的groovy格式。另外,在这个文件中,你基本上也看不到Tekton中的资源类型,比如Task、TaskRun等。 + +实际上,Jenkins X基于Jenkins原有的流水线语法结构,重新定义了一套基于yaml格式的语法。你依然可以使用以前的命令在yaml中完成整个流水线的定义,但是,在后台,Jenkins X会将这个文件转换成Tekton需要使用的CRD资源并触发Kubernetes执行。 + +说白了,用户看起来还是在使用Jenkins,但实际上,流水线的执行引擎已经从原来的JVM变成了现在Kubernetes。流水线的执行和调度由Kubernetes来完成,整个过程中每一步的环境都是动态初始化生成的容器,所有的数据都是通过外部存储来保存的。 + +经过这次升级,终于实现了真正意义上的平台云原生化改造。关于这个全新的Jenkins流水线语法定义,你可以参考下官方文档。 + +我再给你分享一幅Serverless Jenkins和Tekton的关系示意图,希望可以帮助你更好地理解背后的实现机制。 + + + + +https://dzone.com/articles/move-toward-next-generation-pipelines + + +最终,我们希望达到的目的,就是不再有一个一直存在的Jenkins Master实例等待用户调用,而是一种被称为是“Ephemeral Jenkins”的机制,也就是一次性的Jenkins,只有在需要的时候才会启动一个Master实例,用完了就关闭掉,从一种静态服务变成了一种转瞬即逝的动态服务,也就是看似不在、又无处不在的形式,以此来驱动云原生应用的CI/CD之旅。 + +讲到这里,我们回头再看看最开始的那个场景。对于享受了云原生流水线服务的工程师而言,他所需要关注的就只有把代码写好这一件事情,其他原本需要他操心的事情,都已经通过后台的自动化、模板化实现了。 + +即便是在本地开发调试,你也完全可以利用Kubernetes提供的环境管理能力,甚至在IDE里面,只要保存代码,就能完成从打包、镜像生成、推送、环境初始化和部署的完整过程。我相信,这也是云原生工具赋能研发的终极追求。 + +总结 + +最近这两年,经常有人问我,Jenkins是不是过时了?类似Argo、Drone等更轻量化的解决方案是否更加适合云原生应用的发展? + +其实,社区的开发者也在问自己这样的问题,而答案就是Jenkins X项目。这个项目整合了大量的开源工具和云原生解决方案,其中包括: + + +基于Kubernetes的云原生开发体验 +自动化的CI/CD流程 +多套预置的环境,并能够灵活初始化环境 +使用GitOps在多环境之间进行部署晋级 +云原生的流水线架构和面向用户的易用配置 +可插接自定义的流水线执行引擎 + + +我必须要承认,云原生带给平台的改变是巨大且深刻的。这两年,我一方面惊叹于社区的巨大活力和创新力,另一方面,我也深刻地意识到“未来已来”,这种变更的脚步越来越近。 + +在云原生时代,我们需要打造的也应该是一个自动化、服务化、高度扩展的平台。这也就是说,用于打造云原生应用的平台自身也应该具备云原生应用的特征,并通过平台最大化地赋能研发工程师,提升他们的生产力水平。 + +思考题 + +对于DevOps的落地推行来说,建设工具仅仅是完成了第一步,那么,如何让工具发挥真正的威力,并在团队中真正地进行推广落地呢?你有哪些建议呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/29向前一步:万人规模企业的DevOps实战转型案例(上).md b/专栏/DevOps实战笔记/29向前一步:万人规模企业的DevOps实战转型案例(上).md new file mode 100644 index 0000000..020b889 --- /dev/null +++ b/专栏/DevOps实战笔记/29向前一步:万人规模企业的DevOps实战转型案例(上).md @@ -0,0 +1,196 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 29 向前一步:万人规模企业的DevOps实战转型案例(上) + 你好,我是石雪峰。 + +“向前一步”这个名字,来源于Facebook的首席运营官谢丽尔·桑德伯格的一本书《向前一步:女性,工作及领导意志》。她在书中鼓励女性在职场中向前一步,勇于面对挑战,追求自己的人生目标。 + +我之所以选择用这个名字作为案例的标题,是因为在企业中,DevOps转型并不是一件容易的事情,我们也需要有勇气向前迈出一小步,去承担这个使命。哪怕只是改变了一个小问题,也是转型过程中不可忽视的力量源泉。 + +在专栏最后的案例环节,我会用两讲给你介绍下微软这些年的DevOps转型故事,以及我在国内企业中的实践总结和经验。 + +今天,我们先从管理实践和文化层面入手,来看看这家传统的软件巨头是如何在经历了移动互联网时代的迷失之后,在容器云和AI时代再一次独占鳌头的。 + +微软的DevOps转型并不是一个突然的决定,随着云服务的兴起,用户需求激增,对发布节奏的要求越来越高。这些通过需求的数量就能体现出来。2016年的用户需求数量比过去4年的总量还要多,到了2017年,需求数量达到了2016年的2倍,这就要求团队能够以更快的速度完成交付。 + +要知道,如果你期望的优化水平是提升10%的交付能力,那在原有的组织架构、流程规则下做局部优化就有可能实现。但是,如果要达到200%的优化效果,就需要做出巨大的改变了。 + +建立面向交付的特性团队 + +微软之前的组织架构跟很多公司一样,也是按照职能划分的,比如分为项目管理团队、开发团队、测试团队和运维团队。每个团队都比较封闭,部门墙的问题非常严重,给团队内部的协作效率造成了很大的影响。 + + + +为了应对需求交付的压力,微软首先进行了一次组织架构调整,将开发团队和测试团队整合为工程团队。于是,测试的职能在团队中消失了,转而变成了面向开发的开发工程师和面向测试的开发工程师,他们和产品管理团队一起完成项目的敏捷推进。在敏捷的理念中,测试活动应该内嵌于开发环节之中,通过把两个部门整合起来,就完成了测试注入研发的工作。 + + + +虽然开发和测试团队融合到了一起,但是交付工作依然依赖于独立的运维团队来完成,这就造成了一个问题:即便开发效率再高,如果运维能力跟不上,那也是没有意义的。 + +于是,微软开启了第二次组织变革,这一次的核心是构建特性交付团队,并赋予团队自治的能力。 + +所谓的特性交付团队,就是我们常说的“全功能团队”,实际上,这就是把横向的按照职能划分的组织变成垂直跨职能的组织。这个团队中包含了要完成功能交付的所有角色(比如产品、开发、测试和运维),可以闭环地完成整个交付工作。 + +在这个过程中,微软引入了一种叫作自组织团队的形式。与传统的管理层自上而下安排组织的方式不同的是,员工可以自由地选择想要加入的特性团队。这种新的自由组队的方式为每个人都提供了学习新知识的机会。 + +你可能觉得,这么搞的话,组织不是乱掉了?高手都希望跟高手在一起,那剩下的同学怎么办呢?其实,我在国内的一家公司也见过类似的玩法,他们解决这个问题的核心方法就是“传帮带”模式。 + +比如,一个职能依赖某种特殊技能,但这种技能在团队内部非常稀缺,无论拥有这种特殊技能的这名成员去到哪个小队,剩下的组都会出问题。所以,这家公司就强制采用“老带新”的模式,也就是师傅对新人进行集中培训,给新人快速赋能。而且,这种“师徒关系”会长期存在,如果新人遇到什么问题,都可以请教师傅。当然,对于新人来说,也会有相应的考查机制。这种模式就有助于公司达成内部成员互相学习的目标。 + +根据数据统计,虽然只有不到20%的员工选择了岗位变化,但是这种方式却给100%的员工提供了选择的可能性。对于一家官僚政治出名的公司来说,这就可以大大地调动员工的积极性。 + + + +实际上,特性交付团队还有几个显著的特征: + + +拥有团队独立的办公空间,大家都坐在一起,在沟通时基本可以靠“吼”; +一般由10~12个团队成员组成; +具有明确的工作目标和职责; +为了保证稳定性,一旦组队成功,未来的12~18个月不再改变; +自己管控特性向生产环境部署; +团队自治。 + + +无独有偶,国内某大型公司在推进DevOps转型的初期,也做了类似的事情。为了加速研发和运维的融合,它们将一个大的应用运维团队拆分到了各个业务线里面。 + +不仅如此,研发开始向全栈转型,承接运维工作,而运维自身的工作释放出去后,就要求团队进行能力升级。运维团队需要具备研发能力,来不断地开发和优化运维工具,以降低研发、运维的成本。 + +这个过程说起来轻松,但实际在做的时候,就需要非常强的组织执行力,甚至还需要高层背书,自上而下地贯彻这样的要求。转型的过程对于每个人来说都是很痛苦的,但也只有经过这样剧烈的变革,才让DevOps转型成为了现实,而不仅仅只是说说而已,或者只是在几个小部门之间搞来搞去。 + +我经常说一句话:“想在不改变流程的前提下,实现企业的DevOps转型是不现实的。”至于团队的组织架构是否要调整,还是由交付效率来决定的。 + +转型初期的引入工具和推广阶段对组织的冲击力没有那么大,但是,当转型到达了“深水区”之后,组织的变革就成了一个非常现实的问题。 + +根据“康威定律”,一个团队交付的系统结构和他们的组织结构是相同的。其实换个角度来说,软件交付的流程也是跟当前的组织结构保持一致的。只要有一个独立的测试团队,就总会有一个独立的测试阶段。而正是因为这样的一个个阶段,才带来了内部协作的部门墙和效率瓶颈,而这都是DevOps转型需要考虑的事情。 + +自组织敏捷团队 + +回到案例部分,为了促进特性交付团队的自治,微软在敏捷开发计划方面也进行了一定的调整。 + +首先,按照不同的维度,他们分为四种计划。 + + +迭代维度:设定为3周一个迭代; +计划维度:包含了3个迭代; +Season维度:6个月包含了两个计划周期; +Scenario维度:长达18个月的远景图。 + + + + +其中,管理层负责规划长期目标和全景图,也就是回答“我们要去哪里”的问题;而中短期目标,也就是迭代和计划,由自组织团队自行决定,这回答的就是“我们如何去到那里”的问题。 + +交付节奏按照迭代来进行,每3周的迭代会有一部分价值产出。随着迭代的不断推进,再逐步更新、优化计划目标,并反馈给长期规划,进行互动和调整。也就是说,6~18个月的长期计划并不是一成不变的,团队会基于每个迭代和计划的交付增量以及用户的反馈进行调整,建立起一种“计划 - 交付 - 学习”的闭环路径,不断地校准产品目标和整体方向,保证长期规划的有效性,从而规避了原本瀑布模式下的在项目初期决定未来开发路径的潜在问题。 + +毕竟,在这个快速变化的时代,谁也无法保证你的计划是一成不变、永远有效的。 + + + +现在,特性交付团队的迭代和计划是由自己来决定了,但是你别忘了,每个成功的项目都需要成百上千人的协作。那么,如何保证团队目标的一致性和互相的配合度呢? + +微软引入了三种实践方法,分别是迭代邮件、团队交流、体验评审。 + +1.迭代邮件 + +在每个迭代的开始和结束时,团队都会发出迭代计划和状态邮件。在邮件中,除了明确本次迭代的特性完成情况以及下个迭代的交付计划之外,为了帮助其他团队成员更好地了解这个迭代的功能,他们还将这些功能录制成视频附在邮件里。不仅如此,待办事项的列表和看板状态也都以链接的形式被附在了邮件中。 + + + +2.团队交流 + +在每次迭代完成的时候,团队成员都要问自己三个问题: + + +下一步的待办事项中包含哪些内容? +有哪些技术债务的累积和非功能特性? +有哪些遗留问题? + + +团队中的每个成员都要亲自完成这项任务,这不仅是为了减少信息传递的损失,更重要的是建立一种仪式感,帮助大家更加理性地安排迭代计划。毕竟,一旦把任务安排好了,就要按时完成。 + +3.体验评审 + +在分析需求之初,采用用户故事的形式,站在用户的角度,以场景化的方式来描述用户所处的现状,以及这个特性想要解决的问题。那么,不同团队的成员就可以站在用户的视角来实现这个功能。 + +特别有意思的是,微软在管理特性的时候,会尽量保持对原始用户需求的关联。他们会在特性旁边附上原始的用户需求。 + +很多时候,开发要处理的任务都是被产品人员翻译过的用户需求,而并非原始的用户需求,以至于在开发的时候,我们并不知道要解决的核心问题是什么。通过关联原始用户需求,每个人都能在开发、测试、交付的过程中,站在用户的视角来重新审视一下,我们交付的功能到底是不是用户想要的。 + +这些环节的变化带来了一系列积极的影响。 + +首先,团队成员的积极性大大提高。因为他们觉得自己是用户体验的首要负责人,他们有责任自己修复并解决用户的实际问题。 + +其次,团队无需再等待领导的规划。在符合整体项目计划的前提下,他们可以自行制定计划。 + +最后,计划的更新是由持续学习来驱动的。比如,团队会给用户经常使用的功能添加埋点,观察用户使用的数据情况,定期关注和解决用户反馈信息。 + +持续的增量交付和不断的反馈建议,也是现在保证产品需求有效性的最佳手段。毕竟,业务敏捷是DevOps的源头,如果业务自己对需求都没有明确的衡量方法,那么即便拥有了最强的持续交付能力,也是跟“蒙眼狂奔”一样。所以,想要推进DevOps,敏捷开发实践和需求价值分析都是必不可少的要素。 + +在微软,为了促进有效反馈,他们的度量体系也很好,非常值得一说。对于微软来说,获取用户的真实行为数据至关重要。他们在建设指标体系的时候,出发点大多落脚在考量哪些指标对业务衡量有直接作用上,而不是衡量团队的产出以及个人的产出。 + +他们采用的指标包括以下几个方面: + + +使用维度:用户增长、用户满意度、特性交付情况等; +效率维度:构建时长、自测时长、部署时长等。 +在线站点健康度:错误定位时长、用户影响时长、线上问题的遗留时长等。 + + +但是,某些国内流行的指标却并没有被纳入绩效考核,比如完成时长、代码行数、缺陷数量等。 + + + +你可能会说,这也没什么特殊的啊,但是,你要知道,微软对于用户的关注不止如此。 + +我给你举个具体的例子。一般情况下,我们在度量系统可用性的时候,都是面向系统整体的,比如保证整体可用性达到4个9,也就是在99.99%的情况下是可用的。但是,微软认为,系统可用性应该更进一步,要以账号的维度来进行度量和统计。 + +当我们站在系统整体的视角时,很多个人用户的行为就被整体数据掩盖了,也就是我们常说的“被平均”了。但是,如果站在账号的视角,也就是每一个用户的视角来看待这个问题的时候,我们就会发现,用户是真真切切地遇到了一些问题。 + +比如,某一个账号下服务不可用的情况出现频率比较高,那么,与其等着用户上网吐槽,倒不如提前跟用户取得联系,主动帮助他们解决问题。在联系用户的邮件中,不仅要清楚地描述团队观察到的客观情况,还要提供建议的解决方案。如果用户无法自主完成定位和修复,还可以通过邮件中的联系方式和团队取得联系,寻求进一步的帮助。 + +微软对用户的关注不仅体现在系统可用性的度量方面,在特性开关方面也是如此。 + +特性开关是一种比较常见的在运行时控制特性是否对外可见的技术手段。在微软的产品中,也大量地使用到了特性开关的技术,但他们的特性开关可以细化到用户级别,也就是可以将用户添加到或者移出列表中,从而控制每一个用户的可见特性。 + +这样一来,如果某些新特性影响了特定用户的使用,就可以通过这种方式处理,无需部署,直接将特性下线。这不仅有助于问题的快速解决,还提供了一种更加精细化的实验机制。与灰度发布相比,基于特性的发布也更加灵活。 + +团队转型从中型团队入手 + +在转型团队的选择方面,微软的经历带给我们的启示是,尽量避免从大型团队开始入手。 + +在DevOps转型的过程中,常见的思维方式是,先把企业内部最核心和最大的团队搞定。只要把最复杂的部分搞定了,其他中小团队的需求也就都可以满足了,他们会自然而然地跟上转型的节奏。 + +但是事实上,这些大团队往往都有一些独特的流程以及特殊的需求,对系统工具和流程的定制化程度较高,实现起来也最复杂,甚至对他们来说,转型工作的优先级并不是最高的,总会因为这样那样的需求导致转型工作一拖再拖。这对于转型工作来说,并不是一件好事。 + +所以,微软调整了他们的策略,采用了“middle-out”的方法,也就是专注于中型团队(40到100人)。这些团队由于资源不像大团队那样充足,对外有充分的需求。而且,这种规模的团队可以快速地评估现状,收集团队的必要信息,而不是猜测他们到底需要什么。 + +通过持续细小的改进,帮助这样的团队做得更好,内部的传播让更多的团队主动联系他们并寻求帮助,从而建立了一个有效的持续改进循环。 + +总结 + +今天,我给你介绍了微软DevOps转型的上半部分内容。我们来简单总结一下。 + + +为了满足快速交付的需求,他们打破组织的原有架构,建立了面向特性交付的跨职能组织; +通过团队自治,他们将计划分为短期目标和长期目标,短期目标(包括迭代和计划)都由特性团队来自主决定; +在度量方面,他们更加关注业务指标的表现。而且,无论是在系统可用性方面,还是在特性开关方面,他们都细化到了具体的用户级别,以保证每个用户的使用体验; +在选择转型团队方面,他们主动避开了最复杂的团队,而是从能够把握住的中型团队做起,积累成功经验,然后不断传播。 + + +最后,我再提一点自己的想法。这两年来,在DevOps领域,特性的出镜率越来越高。因为特性是更加符合DevOps快速交付原则的需求颗粒度。所以,业界的各大公司在基于特性的需求管理、基于特性的分支策略、基于特性的发布和价值追踪策略等层面,都有很多实践和思考。比如,今年CloudBees公司发布的SDM产品就是基于特性维度的。 + +我相信,未来的DevOps也会朝着这个方向发展。打造一整套基于特性开发的研发模式,是一个值得我们花精力好好思考的点。 + +思考题 + +你认为,基于特性维度的开发和交付,有哪些流程、工具、规则是有效的呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/30向前一步:万人规模企业的DevOps实战转型案例(下).md b/专栏/DevOps实战笔记/30向前一步:万人规模企业的DevOps实战转型案例(下).md new file mode 100644 index 0000000..0c2cbe7 --- /dev/null +++ b/专栏/DevOps实战笔记/30向前一步:万人规模企业的DevOps实战转型案例(下).md @@ -0,0 +1,198 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 向前一步:万人规模企业的DevOps实战转型案例(下) + 你好,我是石雪峰。今天,我们接着上一讲的内容,继续来聊一聊微软DevOps转型的故事。 + +经常有人会问,企业的DevOps转型应该由哪个团队来负责,是否要组建一个全新的DevOps团队呢?带着这个问题,我们来看看微软是怎么做的。 + +1ES + +微软有一个特殊的团队,叫作1ES。1ES是One Engineering System的缩写,直译过来就是“一套工程系统”的意思。从这个名字,相信你就可以看出来,在微软内部,有一套统一的工程能力平台来支撑微软内部各种形态产品的研发交付工作。没错!这个1ES团队包含了近200名工程师,作为组织级的研发效能团队,他们的目标就是通过一整套通用的工程能力平台,来提升内部的研发交付效率。 + +1ES团队的工作职责可不仅仅是开发通用工具平台,他们还要负责公司的文化转型、最新的工程方法导入试验、研发过程改进、安全合规性检查、内部研发效率咨询以及在工程团队推广最佳实践等等,可以说是一个“全功能”的企业研发效能和生产力团队。截至2018年,数据显示,总共有近10万名用户在1ES提供的平台上协同办公。 + + + +但国内的现状是,很多企业对于研发效能的关注才刚刚起步。即便有人员负责类似的事情,也大多分散在各个业务内部,难以形成合力。组建了企业级统一的研发效能团队,而且规模能够跟微软的1ES相提并论的企业,基本上一只手就可以数得过来,就更别提建立一套统一的工程能力平台了。我曾见过一家大型企业,他们内部的工具平台有1700多个,殊不知,这里面有多少的重复建设和资源浪费。 + +那么,你以为微软的1ES团队天生就是这样“一统天下”的吗?还真不是这么回事。 + +事实上,1ES团队的历史可以追溯到2014年。当时,微软新上任的CEO萨提亚·纳德拉非常重视研发能力建设,他致力于通过最好的工具来赋能研发团队。结果,微软的每个部门都会根据自己的实际情况采购自己习惯的工具平台,这就导致整个公司内部的工具、流程和成熟度差异巨大。差异化的工具和流程进一步增强了不同团队之间的共享和协作,内部人员转岗的成本极高,因为他们到了新团队以后,要从头开始适应一切。 + +为了解决这个问题,1ES团队识别了三大领域:工作计划管理、版本控制和构建能力。他们先在企业内部识别哪些团队没有使用公司构建的统一工具,然后自顶向下强推。这背后的核心理念就是“Use what we ship to ship what we use”,也就是使用他们对外发布的工具来研发团队自己的工具。 + +不知道你发现没有,这三个领域都是软件交付的主路径,需求和任务管理、版本控制和构建系统无一不是核心系统。当你想要建立一个统一的效能平台的时候,最重要的就是抓住主路径上的核心系统。 + +关于“如何基于核心系统扩展一整套解决方案”,我给你推荐一篇GitHub的博客,你可以看看他们是如何思考这个问题的。 + +在接下来的几年里面,1ES团队推动VSTS(也就是现在的Azure DevOps)成为了微软内部的工具平台标准,平台的用户也从最开始的几千个人增长到了后来的10万多人。 + +正是从2010年开始至今150个迭代的千锤百炼,才造就了后来Azure DevOps产品的大放异彩。可以说,无论是从设计理念、功能,还是用户体验等方面,微软的Azure DevOps平台在当今业界都是首屈一指的。 + + + +持续交付 + +持续交付是DevOps转型的核心部分,1ES提供的统一工程能力平台让这一切成为了可能。那么,微软的持续交付做到了什么程度呢? + +从2019年3月份的数据来看,他们每天部署82,000次、创建28,000个工作项,每个月有44万个提交请求、460万次构建和240万次的提交数量。 + +无论把这些数据的哪一项拿出来,都是非常惊人的,这体现了微软卓越的工程能力水平。 + + + +那么,微软是如何一步步走到今天的呢?我们先来看看DevOps中最重要的、也是“老大难”的测试部分,看看微软是如何实现在6分钟内完成6万个测试用例的。 + +其实,早在2014年,微软在测试中遇到的问题跟大多数公司没什么两样:测试耗时太长、测试频繁失败、主线质量不可靠、迭代周期末端的质量远远达不到发布门槛。 + +这些问题严重到什么程度呢?我给你列举几个数字,你就明白了。 + + +每天的自动化测试耗时22个小时; +全功能自动化测试长达2天; +仅有60%的P0级别用例可以执行成功; +在过往的8年里面,甚至没有一次每日自动化测试是全部通过的。 + + +不仅如此,团队成员之间对单元测试存在着巨大的分歧:研发不愿意花时间写单元测试;团队不认为可以通过单元测试替代功能测试;甚至连用不用Mock,他们在理念上也存在着冲突。 + +历史总是惊人的相似。在我之前的公司里面,研发总能找到各种理由苦口婆心地说服你他们不需要写单元测试,或者是,各种环境问题导致单元测试压根没法执行完成,因为引用了大量的外部服务。 + +微软的解法是,停止这种无意义的争论,为了达成预期目标前进。他们先从能达成共识的部分开始推进,并重新整理了内部的测试模型,如下图所示: + + + + +L0级:这是没有外部依赖的单元测试。这部分在代码合并请求中执行,执行时长小于60ms; +L1级:这是存在外部依赖的单元测试,测试时间一般小于2秒,平均400ms左右; +L2级:是面向接口的功能测试,在预发环境执行; +L3级:也就是在生产环境下执行的线上测试。 + + +在明确了整体策略之后,团队开始对测试活动进行改造。整个改造过程可以划分为四个阶段: + +阶段一:从L0/L1级测试入手 + +在这个阶段,尽可能地简化L0/L1级测试的执行成本,编写高质量的测试用例。 + +根据我在企业里面推行单测的经验,抛开“到底应不应该写单测”这个事情不说,最大的争议点就是分工的问题。从做事的角度来说,包含几个方面:工具和框架选型、规则整理输出、工具平台开发、数据的度量和可视化建设。 + +为了加快单测的推行,我建议,前期工具和框架选型,由自身的开发和测试工程师或者有经验的DevOps工程师一起完成,并在试点项目跑通。接下来,研发完成规则的梳理,包括单测的书写规则、工具环境配置规则等等,平台方面启动单测相关的能力建设,目的就是研发只需要写单测代码,具体的执行、数据分析、报告统计都交给平台完成。最后,在团队内部进行推广,并持续更新迭代规则和工具。在这个阶段,尽量不要新增每日测试用例。 + +阶段二:分析已有的每日测试用例 + +在这个阶段,重点要识别几个方面的内容: + + +哪些测试用例已经过时,可以删掉? +哪些测试用例可以转移到L0/L1级完成? +哪些测试可以整合进SDK中专项进行(比如性能测试)? + + +这一步骤的目的就是让每日测试用例集合尽可能地“瘦身”,加快执行速度。毕竟,每次跑几十个小时,一旦失败的话,就没有第二次机会了。 + +阶段三:将每日测试转化为L2级测试 + +接口测试是一种性价比相对更高的测试类型,所以,推进面向接口的自动化测试建设可以兼顾测试的执行效率和业务的覆盖情况。 + +在这个阶段,我们需要完善接口自动化测试框架,提供代码、配置和多接口验证等多种测试类型。除此之外,要集中统一的管理系统的API,一方面进行API的治理,另一方面,加强研发和测试基于API的协作,把所有的变更版本线上化。一旦研发更新了API定义,测试可以在同一个地方更新他们的测试用例和Mock数据,从而实现基于API的在线协同工作。 + +阶段四:建设L3级测试 + +这就是在生产环境的线上测试,主要是通过监控机制来诊断系统的健康度。这部分内容我在第17讲中提到过,如果你不记得了,可以回去复习一下。 + +随着L0/L1级测试的不断增多,这些测试都可以纳入到代码合并请求中自动执行。另外,L2级的API接口测试同样可以纳入到流水线中。 + +通过40多个迭代的持续努力,以及考核机制的促进作用,整个测试的分布情况发生了明显的反转。 + +你可以看到,每日测试的数量不断减少,L0级别的测试不断增多,到后来,L1/L2级的测试也相对稳定下来。你要知道,这40多个迭代可是花了将近3年的时间。如果以后谁再跟你说“3个月就能搞定单测”,你可千万别跟他聊天。 + + + +持续部署 + +持续交付的终点是持续部署,那么,微软在部署层面又做了哪些事情呢? + +首先,微软不承认半自动化部署这个事情。其实很多时候,部署动作都不是一次性完成的。有些命令或者步骤并没有线上化,或者就是非高频的动作没有做到工具里面,还是需要通过手动复制一段命令的方式来实现。 + +经常有人会问:“我们的大部分操作都实现了自动化,这算不算做得不错了呢?”我的回答也很简单:“对于一个没有基础或者非专业的人来说,他是否可以完成这项任务?”坦率地说,这有点“抬杠”的性质,但事实上,如果一个平台做完了,结果还是要依赖于指定人去操作,那你就得想想这个事情的意义和未来的价值了。 + +之前我在做一个项目的时候,就遇到过类似的案例。为了解决配置变更的问题,团队成员实现了一个非常复杂的任务,但是在评审的时候,我们发现,这个任务并不能解决所有问题,到头来还是需要他手动入库操作。手动入库的成本其实还好,但是为了自动而自动,结果得不偿失,这就有点浪费时间和精力了。 + +那么,要想解决所有人都能部署的需求,要做的就是完全的自动化。把所有的操作都内嵌于流水线之中,并且纳入版本控制,用于记录变更信息。使用同一套工具实现多环境部署,通过配置中心完成不同环境的配置下发。 + +这样做的好处有很多,一方面,可以在不同的环境中完善部署工具的健壮性,避免由于部署方式或者工具的差异带来的潜在风险。另一方面,与生产环境的部署相比,测试环境的部署心理压力没有那么大。当大家都熟悉测试环境的部署过程之后,对生产环境的部署就是小菜一碟了。 + +为了实现安全低风险的部署,微软引入了“部署环”的概念,你可以把部署环理解为将部署活动拆分成了几个阶段。每一次生产部署都需要经过五环验证过程,即便是配置变更,也是如此,不存在额外的紧急通道。这五个部署环分别是: + + +金丝雀(内部用户) +小批量外部用户 +大批量外部用户 +国际用户 +其他所有用户 + + +通过渐进式的部署方式,每一个新的版本都缓慢地经过每一环的验证,并逐步放量,开放给所有用户。其中有几个点值得我们借鉴。 + +1.通过流水线打通CI/CD + +我们可以这样理解CI/CD: + + +CI的目的是生成一个可以用于部署的包。这个包可以是war包、tar包、ear包,也可以是镜像,这取决于系统的部署方式。 +CD的目的是将这个包部署到生产环境,并发布给用户。 + + +所以,CI和CD的结合点就在于制品库,通过流水线调度部署包在制品库中的流转,从而完成制品的晋级。我发现,很多大厂都是用部署前重新打包的方式,人为地将CI和CD的过程割裂开来,这并不是一种好的处理方式。 + +2.持续部署并不意味着全自动 + +我们都知道,持续部署能力是考查一个公司DevOps能力的最好指标(比如前面我提到的微软每天能够部署8万多次)。那么,这是不是说,每次变更都要经过自动化过程部署到生产环境呢?答案是不一定。 + +你可以看一下这幅微软开发的全景图,其中,在CD过程中,每一环的部署都需要人工确认来完成,这背后的核心理念是控制“爆炸半径”。 + + + +既然无法彻底阻止失败,那么是否能够控制影响范围呢?“部署环”的设计理念正是如此,为了做到这一点,适当的人工管控还是很有必要的。 + +那么,如何确认部署是成功的呢? + +微软定义了非常详细的保障在线服务可用性的规则,其中最重要的就是,明确线上服务状态永远处于第一优先级。你可能觉得,本来不就应该是这样的吗?但是,在实际工作中,我们会发现,内部工具团队经常专注于实现新功能,而把线上的报警放在一边。 + +要想解决这个问题,除了明确线上为先的理念之外,制定相应的规则也是很重要的。比如,微软的值班工程师叫作DRI(Designated Responsible Individual),也就是“指定责任人”。微软明确要求,每个在岗工程师必须在工作日5分钟内、休息日15分钟内响应问题,并把这纳入到了人员和团队的考核之中。另外,通过每周、每月的线上服务状态报告,以及每次事故的详尽故障分析,不断在内部强化线上为先的理念。 + +总结 + +在这个案例中,我给你介绍了微软在转型过程中的几个重点,包括自动化测试能力、统一工程平台和工程团队、分级持续部署、组织变革、团队自治和文化转变等。这些都是在实际的DevOps转型过程中,企业所面对的最为头疼的事情。微软的经历是否给你带来了一些启发呢?当然,想要做好DevOps,可绝对不只是做好这几点就够了的。 + +对于DevOps的转型过程,微软的理念是: + + +A journey of a thousand miles begins with a single sprint. + + +这就是咱们常说的“千里之行,始于足下”。DevOps不是一种魔法,可以立即见效,而是每次变好一点点,每个人都在不断地思考“我能为DevOps建设做点什么”。这就像微软的自动化测试转型过程一样,你能看到整个趋势在不断变好,慢慢变成了现在这样,每次提交可以在10分钟左右完成近9万个自动化测试。 + +微软一直在致力于推广DevOps,并且不断把自己的经验通过各种形式分享出来。仅仅从这一点上,我们就能看出微软的文化转变、向开放开源的转变。我再跟你分享一些微软DevOps转型的资料,你可以参考一下。 + + +资料1. https://docs.microsoft.com/en-us/azure/devops/learn/devops-at-microsoft/ + +资料2. https://azure.microsoft.com/en-us/solutions/devops/devops-at-microsoft/ + + +你还记得我在第6讲中提到的DevOps转型的J型曲线吗?其实无论是DevOps转型,还是研发效率建设,都是一个长期、琐碎的过程。你要做的,就是树立自己的信心,做正确的事情,并期待美好的事情自然发生。 + +思考题 + +通过案例学习DevOps是一种特别好的方法,在案例中,你不仅能借鉴别人的经验,也能学习到系统背后的设计理念。那么,你有什么好的案例学习途径吗?可以分享一下吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习(1).md b/专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习(1).md new file mode 100644 index 0000000..dab28be --- /dev/null +++ b/专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习(1).md @@ -0,0 +1,210 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 期中总结 3个典型问题答疑及如何高效学习 (1) + 你好,我是石雪峰。不知不觉中,专栏已经上线快两个月了,整体进度过半。我在专栏写作之初就给自己定下了一个小目标:认真对待每一条留言。到现在,单单是回复留言,我就已经写了3万多字了。 + +其实,对我来说,每一次看留言和回复留言,都是一个不断反思和学习的过程。实际上,很多时候,很多留言和讨论甚至比文章本身都更精彩,也更接地气。 + +今天是期中总结,我分为两个部分内容来讲: + + +第1部分:我从众多留言中挑选了3个典型问题,进一步展开讲解。 +第2部分:我想跟你说说心里话。两个月的高强度写作,也让我从一个讲师的角度,重新审视了“如何高效学习”这件事情,我把这些想法分享给你,希望可以帮助你更好地提升自己。 + + +典型问题 + +首先,我们来看一些典型问题。 + +问题一 + + +敏捷开发模式没有花费大量时间去研究业务,这会不会出现因为对业务没有分析透,导致方向偏离,甚至系统开发到一半发现总体业务架构不合理,需要返工的情况呢? + + +相信你也知道,实施DevOps有助于产品快速和高质量的交付,那么,我想问的是:快速和高质量的交付,是否就一定意味着业务的成功呢?显然没有这么简单。 + +实际上,影响业务成功的因素有很多,比如,行业趋势、产品竞争力、用户消费习惯、政策法律法规等等。在这众多因素之中,需求质量的高低,或者说,需求是否靠谱,也很重要。 + +毕竟,如果交付了一大堆没用的需求,不仅没法提升业务,反而还会浪费大量的时间和精力,错过真正有价值的机会。 + +我们身处在一个飞速变化的时代,企业对于用户想要什么其实并不清楚。很多需求都是人为拍脑袋拍出来的。在提出一个新需求的时候,需求价值到底有多少呢?这不仅很难预测,而且还很难衡量。 + +所以,产品人员就倾向于采用“广撒网”的方式,提出一大堆需求,来提升命中的几率。毕竟,如果一次猜不对,打不准,那就多打几次呗。 + +这么看来,采用敏捷开发方式,还是瀑布开发方式,与需求是否靠谱并没有直接关系。即便是采用瀑布模式,依然也有“大力出悲剧”的案例,比如摩托罗拉的铱星计划。 + +既然无法事先预测需求是否靠谱,那么要解决这个问题,就需要业务团队和交付团队的通力协作了。 + +从业务侧来说,就是要采取精益创业的思想,通过最小可行产品,将需求进行拆解,通过原型产品降低市场的试错成本。这就引出了我在“业务敏捷”这一讲中提到的影响地图、卡诺模型、用户故事等一系列的手段和方法,核心还是采用持续迭代、小步快跑的方式来获取市场反馈。正因为如此,更加灵活拥抱变化的敏捷开发模式才被广泛地接受。 + +说完了业务侧,再来看看交付侧。 + +一个想法被提出来以后,需要经过软件开发交付过程,才能最终交付到用户手中。那么,就要用尽一切手段来缩短这条交付链路的时长。 + +如果开发的时间成本是一定的,那么剩余的部分,就是DevOps中的各种工程实践试图要去解决的问题。 + +比如,通过持续集成来降低软件集成中的解决成本,降低软件缺陷在最后一刻被发现的修复成本;通过自动化测试,降低大量手工回归测试用例执行的成本,降低新功能导致已有功能出现回退的修复成本。软件交付过程中的其他部分也大都如此,这也是每个领域都会有自己的实践集合的原因。 + +反过来看,功能上线之后,依然需要交付侧提取、汇总和及时反馈业务指标,来证明需求的靠谱程度,从而帮助业务侧更加有序地进行决策。对反映不好的功能及时止损,对反映不错的功能加大投入。 + +这样一来,业务侧的需求拆解、需求分析减小了需求颗粒度,提升了需求的靠谱度;交付侧的工程实践大大缩短了上线交付周期,提升了质量。这就帮助业务在不增加成本的前提下,可以验证更多的需求。这个过程的成本越低,频率越高,企业存活下来的几率和整体竞争力也会越高。这也正是DevOps想要解决的核心真问题。 + +问题二 + + +公司对于配置管理的关注度不是很高,有没有什么好的落地实践方法,来建设完整的配置管理体系呢? + + +在专栏的第10讲,我从4个核心原则出发,介绍了配置管理的相关知识,引起了很多同学的共鸣。 + +的确,作为一个长期被忽视,但是格外重要的实践,配置管理不仅是诸多DevOps工程实践的基础,也是工程能力的集大成者。 + +正因为如此,配置管理体系的建设,并不只是做好配置管理就够了。实际上,这还依赖于其他工程实践的共同实施。 + +关于配置管理怎么落地,我跟你分享一个案例。 + +这家公司最早也没有专职的配置管理,软件的集成和发布都是由研发团队自行管理的。推动建立配置管理体系的契机,源于公司决定加快版本发布节奏,从三周一个版本变成两周一个版本。看起来,这只是版本发布周期缩短了一周,但是,就像我在专栏第4讲中演示的部署引力图一样,想要达成这个目标,需要方方面面的努力,其中就包括配置管理。 + +于是,公司决定引入配置管理岗位。初期,他们重点就做两件事: + + +重新定义分支策略,从长分支改为了短分支加特性分支的模式; +管理集成权限,从任何时间都能集成代码,到按照版本周期管控集成。 + + +在这个过程中,配置管理同学梳理了代码仓库的目录结构和存储方式,并基于开发流程建立了在线提测平台,从而实现了研发过程的线上化以及权限管理的自动化。 + +接下来,配置管理与平台和流程相结合,开发过程开始向前、向后延展。 + + +向前:在需求管理阶段,建立需求和代码的关联规范,严格约束代码提交检查,并且将构建工具和环境配置等纳入统一管控,可追溯历史变更; +向后:在部署运维阶段,定义版本发布和上线规则,建立单一可信的发布渠道,可统一查询所有正式发布版本的信息,包括版本关联的需求信息、代码信息、测试信息等。 + + +团队在走上有序开发的正轨之后,就针对发现的问题,逐步加强了平台和自动化能力的建设。 + + +代码提交失控:做集成线上化,测试验收通过之后,自动合并代码; +环境差异大:通过容器化和服务端配置管理工具,实现统一的初始化; +构建速度慢:通过网络改造和增量编译等,提升构建速度。 + + +这样一来,版本发布这件事情,从原本耗时耗力的操作,最终变成了一键式的操作,团队也达成了预期的双周发版的目标。 + +在这个案例中,配置管理更多是从流程和平台入手,通过规则制定、权限管控、统一信息源,以及版本控制手段,重塑了整个开发协作的交付过程。 + +所以,在把握原则的基础上,面对诸多实践,想要确定哪些实践可以解决实际问题,最好是要从预期结果进行反推。 + +如果你不知道该从哪里入手,不妨看看现在的软件交付流程是否是由配置管理来驱动的,是否还有一些数据是失控和混乱的状态,版本的信息是否还无法完整回溯。如果是的话,那么,这些都是大有可为的事情。 + +总之,任何一家公司想要落地配置管理,都可以先从标准化到自动化,然后再到数据化和服务化。这是一条相对通用的路径,也是实施配置管理的总体指南。 + +问题三 + + +度量指标要如何跟组织和个人关联?这么多指标,到底该如何跟项目关联起来呢? + + +我在第19讲中介绍了正向度量的实践,引发了一个小高潮。文章发出后,有不少同学加我好友,并跟我深入沟通和探讨了度量建设的问题。由此可见,当前,企业的研发度量应该是一个大热门。 + +但是,度量这个事情吧,你越做就越会发现,这是个无底洞。那么,在最最开始,有没有可以用来指导实践的参考步骤呢?当然是有的。我总结了四个步骤:找抓手、对大数、看差距、分级别。 + +第1步:找抓手。 + +对于度量体系建设来说,很多公司其实都大同小异。最开始的时候,核心都是需要有一个抓手来梳理整个研发过程。这个抓手,往往就是需求。因为,只有需求是贯穿研发交付过程始终的,没有之一。 + +当然,你也可以思考一下,除了需求,是否还有其他选项?那么,围绕需求的核心指标,首先是需要提取的内容。如果,连一个需求在交付周期内各个阶段的流转时长都没有,那么,这个度量就是不合格的。 + +第2步:对大数。 + +对大数,也就是说,当度量系统按照指标定义,提取和运算出来指标数据之后,最重要的就是验证数据的真实有效性,并且让团队认可这个客观数据。 + +很多时候,如果公司里面没有一套权威指标,各个部门、系统就都会有自己的度量口径。如果是在没有共识的前提下讨论这个事情,基本也没什么意义。所以,说白了,一定要让团队认可这些大数的合理性。 + +第3步:找差距。 + +抓手有了,核心大数也有了,大家也都承认这个度量数据的客观有效性了。但是,在这个阶段,肯定有些地方还是明显不合理。这个时候,就需要对这个领域进一步进行拆分。比如,测试周期在大的阶段里只是一个数字,但实际上,这里面包含了N多个过程;比如,功能测试、产品走查、埋点测试等等。 + +如果没有把表面问题,细分成各个步骤的实际情况,你就很难说清楚,到底是哪个步骤导致的问题。所以,在达成共识的前提下,识别可改进的内容,这就是一个阶段性的胜利。 + +第4步:分级别。 + +实际上,不是所有指标都可以关联到个人的。比如,如果要计算个人的需求前置周期,这是不是感觉有点怪呢?同样,应用的上线崩溃率这种指标,也很难关联到一个具体的部门。 + +所以,我们需要根据不同的视角和维度划分指标。比如,可以划分组织级指标、团队级指标和项目级指标。 + +划分指标的核心还是由大到小,从指标受众和试图解决的问题出发,进行层层拆解,从而直达问题的根本原因,比如用户操作原因、数据计算原因、自动化平台原因等等。当然,这是一件非常细致的工作。 + +我们再来回顾下,我们刚刚深入剖析了3个DevOps的典型问题。 + +首先,你要非常清楚地知道,DevOps在面对未知需求时的解题方法和解题套路,那就是业务侧尽量拆解分析靠谱需求,交付侧以最快、最低的成本完成交付。它们之间就是一个命运共同体,一荣俱荣,一损俱损。 + +配置管理作为DevOps的核心基础实践,在实施的过程中,并不只局限在单一领域。实际上,要从研发流程优化的视角出发,驱动标准化、自动化和数据可视化的能力建设。 + +最后,关于度量指标部分,你要注意的是,向上,要支撑核心指标;向下,要层层分解,展示真实细节。 + +讲解完这3个典型问题之后,接下来进入第2部分,这也是我极力要求增加的部分。其实,我就是想跟你说说心里话。 + +如何高效学习? + +跟你一样,我也是极客时间的用户,订阅了很多感兴趣的课程。在学习的过程中,我一直在思考,如何在有限的时间内高效学习。直到我自己成为了课程老师,从用户和老师两个角度思考这个问题,有了一些感悟,想要跟你分享一下。 + +忙,是现在大多数人的真实生活写照。我们每天从早到晚,忙于工作,忙于开会,忙于刷手机……忙得一塌糊涂。 + +但是,如果要问,过去的一天,自己都在忙什么,要么是大脑一片空白,要么是碎碎念式的流水账。可见,我们每天忙的很多事情,都没有什么价值。 + +其实,很多事情,都没有我们想象得那么重要。我们常常把目光聚焦于眼前,眼前的事情就变成了整个世界。但是,如果把时间拉长到一周,甚至一年,你会发现,这些事情,做与不做没有什么分别。 + +正因为时刻处于忙碌的状态,所以,抽出一整段时间学习,就变成了一件奢侈的事情。但我要祝贺你,因为至少你比大多数人有意识,有危机感,愿意拿出零碎的时间,来充实自己。 + +既然花了这么难得的时间,你肯定希望能有所收获,无论是在知识上,还是能力上,抑或是见识上,至少不白白浪费这段时间。 + +那么,我想问的是,你真的有收获吗? + +史蒂芬·科维曾经说过,大多数人聆听的目的是为了“怼回去”,而不是为了真正的理解。 + + +Most of people listen with the intent to reply, but not with the intent to understand. + + +这里面的“怼回去”稍微有点夸张,实际上,我发现,当我在交流的时候,脑海里总是不自觉地想象如何回复对方,而不是专心地听对方讲话,感悟他的意图和情绪。 + +所以你看,听这种学习方式,总是会受到固有思维模式的影响。也就是说,在很多时候,我们往往会把自己置身于一种评论者的身份。 + +那么,什么是评论者的身份呢?这就是说,站在一种置身事外的立场,以一种审视的角度,来看待每一件事情,并试图找到一些问题。当然,这些问题,都是在已有的认知局限中发现的。 + +这些反馈,对于知识的生产者而言,其实是一件好事,因为他能够时刻审视自己,反思自己,并从中找到不足之处。 + +但是,对学习者来说,能不能在学习的过程中,暂时放弃评论者的身份,转而做一个实践者呢? + +比如,以极客时间的专栏为例,对于作者提到的内容,你有哪些不同的观点呢?面对同样的问题,你又有哪些更好的手段呢? + +其实,每一个作者之所以能成为作者,都有他的独到之处。那么,能够让他的思想为你所用,让他的知识与你互补,让你自己成为交流的赢家,这才是对得起时间的更好选择。 + +最后,以极客时间的专栏为例,我认为: + + +60分的体验,就是可以看完所有的文稿,而不是仅仅听完课程音频; +70分的体验,就是可以仔细学习文稿中的附加资源,比如代码、流程图以及补充的学习信息等。这些都是精选的内容,可以帮助你在15分钟之外,扩充自己的知识面; +80分的体验,就是可以积极参与到专栏的留言和讨论中,甚至可以就自己的问题跟作者深入交流,建立连接; +90分的体验,就是可以结合工作中的实际场景,给出自己的思考和答案,并积累出自己的一整套知识体系,并且,可以反向输出给其他人; +100分的体验,就是持续改进。我想,能够具备这种思想,可能要比100分本身更重要。 + + +那么,你想做到多少分的体验呢?你可以自己想一想。 + +好了,接下来,我们即将进入“工具实践篇”,希望你可以继续保持学习的热情,坚持下去,期待美好的事情自然发生。 + +思考题 + +对于前面已经更新的内容,你还有什么疑惑点吗?或者说,你在实践的过程中,有什么问题吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习.md b/专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习.md new file mode 100644 index 0000000..78e527e --- /dev/null +++ b/专栏/DevOps实战笔记/期中总结3个典型问题答疑及如何高效学习.md @@ -0,0 +1,210 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 期中总结 3个典型问题答疑及如何高效学习 + 你好,我是石雪峰。不知不觉中,专栏已经上线快两个月了,整体进度过半。我在专栏写作之初就给自己定下了一个小目标:认真对待每一条留言。到现在,单单是回复留言,我就已经写了3万多字了。 + +其实,对我来说,每一次看留言和回复留言,都是一个不断反思和学习的过程。实际上,很多时候,很多留言和讨论甚至比文章本身都更精彩,也更接地气。 + +今天是期中总结,我分为两个部分内容来讲: + + +第1部分:我从众多留言中挑选了3个典型问题,进一步展开讲解。 +第2部分:我想跟你说说心里话。两个月的高强度写作,也让我从一个讲师的角度,重新审视了“如何高效学习”这件事情,我把这些想法分享给你,希望可以帮助你更好地提升自己。 + + +典型问题 + +首先,我们来看一些典型问题。 + +问题一 + + +敏捷开发模式没有花费大量时间去研究业务,这会不会出现因为对业务没有分析透,导致方向偏离,甚至系统开发到一半发现总体业务架构不合理,需要返工的情况呢? + + +相信你也知道,实施DevOps有助于产品快速和高质量的交付,那么,我想问的是:快速和高质量的交付,是否就一定意味着业务的成功呢?显然没有这么简单。 + +实际上,影响业务成功的因素有很多,比如,行业趋势、产品竞争力、用户消费习惯、政策法律法规等等。在这众多因素之中,需求质量的高低,或者说,需求是否靠谱,也很重要。 + +毕竟,如果交付了一大堆没用的需求,不仅没法提升业务,反而还会浪费大量的时间和精力,错过真正有价值的机会。 + +我们身处在一个飞速变化的时代,企业对于用户想要什么其实并不清楚。很多需求都是人为拍脑袋拍出来的。在提出一个新需求的时候,需求价值到底有多少呢?这不仅很难预测,而且还很难衡量。 + +所以,产品人员就倾向于采用“广撒网”的方式,提出一大堆需求,来提升命中的几率。毕竟,如果一次猜不对,打不准,那就多打几次呗。 + +这么看来,采用敏捷开发方式,还是瀑布开发方式,与需求是否靠谱并没有直接关系。即便是采用瀑布模式,依然也有“大力出悲剧”的案例,比如摩托罗拉的铱星计划。 + +既然无法事先预测需求是否靠谱,那么要解决这个问题,就需要业务团队和交付团队的通力协作了。 + +从业务侧来说,就是要采取精益创业的思想,通过最小可行产品,将需求进行拆解,通过原型产品降低市场的试错成本。这就引出了我在“业务敏捷”这一讲中提到的影响地图、卡诺模型、用户故事等一系列的手段和方法,核心还是采用持续迭代、小步快跑的方式来获取市场反馈。正因为如此,更加灵活拥抱变化的敏捷开发模式才被广泛地接受。 + +说完了业务侧,再来看看交付侧。 + +一个想法被提出来以后,需要经过软件开发交付过程,才能最终交付到用户手中。那么,就要用尽一切手段来缩短这条交付链路的时长。 + +如果开发的时间成本是一定的,那么剩余的部分,就是DevOps中的各种工程实践试图要去解决的问题。 + +比如,通过持续集成来降低软件集成中的解决成本,降低软件缺陷在最后一刻被发现的修复成本;通过自动化测试,降低大量手工回归测试用例执行的成本,降低新功能导致已有功能出现回退的修复成本。软件交付过程中的其他部分也大都如此,这也是每个领域都会有自己的实践集合的原因。 + +反过来看,功能上线之后,依然需要交付侧提取、汇总和及时反馈业务指标,来证明需求的靠谱程度,从而帮助业务侧更加有序地进行决策。对反映不好的功能及时止损,对反映不错的功能加大投入。 + +这样一来,业务侧的需求拆解、需求分析减小了需求颗粒度,提升了需求的靠谱度;交付侧的工程实践大大缩短了上线交付周期,提升了质量。这就帮助业务在不增加成本的前提下,可以验证更多的需求。这个过程的成本越低,频率越高,企业存活下来的几率和整体竞争力也会越高。这也正是DevOps想要解决的核心真问题。 + +问题二 + + +公司对于配置管理的关注度不是很高,有没有什么好的落地实践方法,来建设完整的配置管理体系呢? + + +在专栏的第10讲,我从4个核心原则出发,介绍了配置管理的相关知识,引起了很多同学的共鸣。 + +的确,作为一个长期被忽视,但是格外重要的实践,配置管理不仅是诸多DevOps工程实践的基础,也是工程能力的集大成者。 + +正因为如此,配置管理体系的建设,并不只是做好配置管理就够了。实际上,这还依赖于其他工程实践的共同实施。 + +关于配置管理怎么落地,我跟你分享一个案例。 + +这家公司最早也没有专职的配置管理,软件的集成和发布都是由研发团队自行管理的。推动建立配置管理体系的契机,源于公司决定加快版本发布节奏,从三周一个版本变成两周一个版本。看起来,这只是版本发布周期缩短了一周,但是,就像我在专栏第4讲中演示的部署引力图一样,想要达成这个目标,需要方方面面的努力,其中就包括配置管理。 + +于是,公司决定引入配置管理岗位。初期,他们重点就做两件事: + + +重新定义分支策略,从长分支改为了短分支加特性分支的模式; +管理集成权限,从任何时间都能集成代码,到按照版本周期管控集成。 + + +在这个过程中,配置管理同学梳理了代码仓库的目录结构和存储方式,并基于开发流程建立了在线提测平台,从而实现了研发过程的线上化以及权限管理的自动化。 + +接下来,配置管理与平台和流程相结合,开发过程开始向前、向后延展。 + + +向前:在需求管理阶段,建立需求和代码的关联规范,严格约束代码提交检查,并且将构建工具和环境配置等纳入统一管控,可追溯历史变更; +向后:在部署运维阶段,定义版本发布和上线规则,建立单一可信的发布渠道,可统一查询所有正式发布版本的信息,包括版本关联的需求信息、代码信息、测试信息等。 + + +团队在走上有序开发的正轨之后,就针对发现的问题,逐步加强了平台和自动化能力的建设。 + + +代码提交失控:做集成线上化,测试验收通过之后,自动合并代码; +环境差异大:通过容器化和服务端配置管理工具,实现统一的初始化; +构建速度慢:通过网络改造和增量编译等,提升构建速度。 + + +这样一来,版本发布这件事情,从原本耗时耗力的操作,最终变成了一键式的操作,团队也达成了预期的双周发版的目标。 + +在这个案例中,配置管理更多是从流程和平台入手,通过规则制定、权限管控、统一信息源,以及版本控制手段,重塑了整个开发协作的交付过程。 + +所以,在把握原则的基础上,面对诸多实践,想要确定哪些实践可以解决实际问题,最好是要从预期结果进行反推。 + +如果你不知道该从哪里入手,不妨看看现在的软件交付流程是否是由配置管理来驱动的,是否还有一些数据是失控和混乱的状态,版本的信息是否还无法完整回溯。如果是的话,那么,这些都是大有可为的事情。 + +总之,任何一家公司想要落地配置管理,都可以先从标准化到自动化,然后再到数据化和服务化。这是一条相对通用的路径,也是实施配置管理的总体指南。 + +问题三 + + +度量指标要如何跟组织和个人关联?这么多指标,到底该如何跟项目关联起来呢? + + +我在第19讲中介绍了正向度量的实践,引发了一个小高潮。文章发出后,有不少同学加我好友,并跟我深入沟通和探讨了度量建设的问题。由此可见,当前,企业的研发度量应该是一个大热门。 + +但是,度量这个事情吧,你越做就越会发现,这是个无底洞。那么,在最最开始,有没有可以用来指导实践的参考步骤呢?当然是有的。我总结了四个步骤:找抓手、对大数、看差距、分级别。 + +第1步:找抓手。 + +对于度量体系建设来说,很多公司其实都大同小异。最开始的时候,核心都是需要有一个抓手来梳理整个研发过程。这个抓手,往往就是需求。因为,只有需求是贯穿研发交付过程始终的,没有之一。 + +当然,你也可以思考一下,除了需求,是否还有其他选项?那么,围绕需求的核心指标,首先是需要提取的内容。如果,连一个需求在交付周期内各个阶段的流转时长都没有,那么,这个度量就是不合格的。 + +第2步:对大数。 + +对大数,也就是说,当度量系统按照指标定义,提取和运算出来指标数据之后,最重要的就是验证数据的真实有效性,并且让团队认可这个客观数据。 + +很多时候,如果公司里面没有一套权威指标,各个部门、系统就都会有自己的度量口径。如果是在没有共识的前提下讨论这个事情,基本也没什么意义。所以,说白了,一定要让团队认可这些大数的合理性。 + +第3步:找差距。 + +抓手有了,核心大数也有了,大家也都承认这个度量数据的客观有效性了。但是,在这个阶段,肯定有些地方还是明显不合理。这个时候,就需要对这个领域进一步进行拆分。比如,测试周期在大的阶段里只是一个数字,但实际上,这里面包含了N多个过程;比如,功能测试、产品走查、埋点测试等等。 + +如果没有把表面问题,细分成各个步骤的实际情况,你就很难说清楚,到底是哪个步骤导致的问题。所以,在达成共识的前提下,识别可改进的内容,这就是一个阶段性的胜利。 + +第4步:分级别。 + +实际上,不是所有指标都可以关联到个人的。比如,如果要计算个人的需求前置周期,这是不是感觉有点怪呢?同样,应用的上线崩溃率这种指标,也很难关联到一个具体的部门。 + +所以,我们需要根据不同的视角和维度划分指标。比如,可以划分组织级指标、团队级指标和项目级指标。 + +划分指标的核心还是由大到小,从指标受众和试图解决的问题出发,进行层层拆解,从而直达问题的根本原因,比如用户操作原因、数据计算原因、自动化平台原因等等。当然,这是一件非常细致的工作。 + +我们再来回顾下,我们刚刚深入剖析了3个DevOps的典型问题。 + +首先,你要非常清楚地知道,DevOps在面对未知需求时的解题方法和解题套路,那就是业务侧尽量拆解分析靠谱需求,交付侧以最快、最低的成本完成交付。它们之间就是一个命运共同体,一荣俱荣,一损俱损。 + +配置管理作为DevOps的核心基础实践,在实施的过程中,并不只局限在单一领域。实际上,要从研发流程优化的视角出发,驱动标准化、自动化和数据可视化的能力建设。 + +最后,关于度量指标部分,你要注意的是,向上,要支撑核心指标;向下,要层层分解,展示真实细节。 + +讲解完这3个典型问题之后,接下来进入第2部分,这也是我极力要求增加的部分。其实,我就是想跟你说说心里话。 + +如何高效学习? + +跟你一样,我也是极客时间的用户,订阅了很多感兴趣的课程。在学习的过程中,我一直在思考,如何在有限的时间内高效学习。直到我自己成为了课程老师,从用户和老师两个角度思考这个问题,有了一些感悟,想要跟你分享一下。 + +忙,是现在大多数人的真实生活写照。我们每天从早到晚,忙于工作,忙于开会,忙于刷手机……忙得一塌糊涂。 + +但是,如果要问,过去的一天,自己都在忙什么,要么是大脑一片空白,要么是碎碎念式的流水账。可见,我们每天忙的很多事情,都没有什么价值。 + +其实,很多事情,都没有我们想象得那么重要。我们常常把目光聚焦于眼前,眼前的事情就变成了整个世界。但是,如果把时间拉长到一周,甚至一年,你会发现,这些事情,做与不做没有什么分别。 + +正因为时刻处于忙碌的状态,所以,抽出一整段时间学习,就变成了一件奢侈的事情。但我要祝贺你,因为至少你比大多数人有意识,有危机感,愿意拿出零碎的时间,来充实自己。 + +既然花了这么难得的时间,你肯定希望能有所收获,无论是在知识上,还是能力上,抑或是见识上,至少不白白浪费这段时间。 + +那么,我想问的是,你真的有收获吗? + +史蒂芬·科维曾经说过,大多数人聆听的目的是为了“怼回去”,而不是为了真正的理解。 + + +Most of people listen with the intent to reply, but not with the intent to understand. + + +这里面的“怼回去”稍微有点夸张,实际上,我发现,当我在交流的时候,脑海里总是不自觉地想象如何回复对方,而不是专心地听对方讲话,感悟他的意图和情绪。 + +所以你看,听这种学习方式,总是会受到固有思维模式的影响。也就是说,在很多时候,我们往往会把自己置身于一种评论者的身份。 + +那么,什么是评论者的身份呢?这就是说,站在一种置身事外的立场,以一种审视的角度,来看待每一件事情,并试图找到一些问题。当然,这些问题,都是在已有的认知局限中发现的。 + +这些反馈,对于知识的生产者而言,其实是一件好事,因为他能够时刻审视自己,反思自己,并从中找到不足之处。 + +但是,对学习者来说,能不能在学习的过程中,暂时放弃评论者的身份,转而做一个实践者呢? + +比如,以极客时间的专栏为例,对于作者提到的内容,你有哪些不同的观点呢?面对同样的问题,你又有哪些更好的手段呢? + +其实,每一个作者之所以能成为作者,都有他的独到之处。那么,能够让他的思想为你所用,让他的知识与你互补,让你自己成为交流的赢家,这才是对得起时间的更好选择。 + +最后,以极客时间的专栏为例,我认为: + + +60分的体验,就是可以看完所有的文稿,而不是仅仅听完课程音频; +70分的体验,就是可以仔细学习文稿中的附加资源,比如代码、流程图以及补充的学习信息等。这些都是精选的内容,可以帮助你在15分钟之外,扩充自己的知识面; +80分的体验,就是可以积极参与到专栏的留言和讨论中,甚至可以就自己的问题跟作者深入交流,建立连接; +90分的体验,就是可以结合工作中的实际场景,给出自己的思考和答案,并积累出自己的一整套知识体系,并且,可以反向输出给其他人; +100分的体验,就是持续改进。我想,能够具备这种思想,可能要比100分本身更重要。 + + +那么,你想做到多少分的体验呢?你可以自己想一想。 + +好了,接下来,我们即将进入“工具实践篇”,希望你可以继续保持学习的热情,坚持下去,期待美好的事情自然发生。 + +思考题 + +对于前面已经更新的内容,你还有什么疑惑点吗?或者说,你在实践的过程中,有什么问题吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/期末总结在云时代,如何选择一款合适的流水线工具?.md b/专栏/DevOps实战笔记/期末总结在云时代,如何选择一款合适的流水线工具?.md new file mode 100644 index 0000000..dadf51c --- /dev/null +++ b/专栏/DevOps实战笔记/期末总结在云时代,如何选择一款合适的流水线工具?.md @@ -0,0 +1,165 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 期末总结 在云时代,如何选择一款合适的流水线工具? + 你好,我是石雪峰。今天是期末总结,我们来聊一聊,在云时代,如何选择一款合适的流水线工具。 + +在过去的几年里,我一直专注于软件持续交付的工程实践领域。我发现,越来越多的公司(无论规模大小)开始重视软件持续交付能力的建设了,基本上每家公司都有自己的流水线平台。 + +以前提到CI/CD工具,基本上就默认是Jenkins,也没什么其他太好的选项。但是最近两年,随着云容器技术的快速发展,在CI/CD流水线领域,新工具和解决方案出现了爆发式的增长。比如不甘寂寞的GitLab CI、轻量级的容器化解决方案Drone。最近一段时间,GitHub的Actions也火了一把。可见,作为软件交付主路径上的核心工具,流水线是每一家企业都不愿意错过的领域。 + +对于行业发展来说,这当然是好事情。老牌工具Jenkins自己都开始反省:“在云容器时代,是不是过于保守?十几年的老架构是否已经难以支撑云时代的快速发展了?”于是他们就另辟蹊径,孵化出了Jenkins X项目。 + +但是,对于用户来说,选择工具时就很为难:“这些工具看起来大同小异,要解决的也是类似的问题,到底应该选择哪个呢?” + +今天,我就来给你梳理一下流行的CI/CD工具,并给你提供一些选择建议。我挑选了5个工具,分为3组介绍,分别是Jenkins系的Jenkins和Jenkins X、版本控制系统系的GitLab CI和GitHub Actions,以及新兴的、正在快速普及的云原生解决方案Drone。我会从5个方面入手,对它们进行对比和介绍,包括工具的易用性、流水线设计、插件生态、扩展性配置以及适用场景。 + +Jenkins/Jenkins X + +关于Jenkins,我想已经不需要做太多介绍了。在过去的15年里面,Jenkins一直都在为无数的软件开发者默默服务。从一组数字中,我们就能看出来它的影响力:官方能统计到的集群数有26万多个、插件将近1700个、执行的任务数超过3000万次,这还不包括大量公司自建、本地电脑运行的节点信息。另外,一年两次的Jenkins全球大会往往能够吸引上千人参与,这对于国外的技术大会来说,已经是超大规模的盛会了。 + +当然,Jenkins的优缺点也很明显。 + + +优点:普及率高,搞过开发的基本应该都接触过;插件生态成熟且丰富,可以适用于任何场景。 +缺点:软件架构和UI设计风格有些过时,配置操作比较复杂;插件的安全性、通用性方面也存在很多问题,最重要的是,在云容器领域,多少有些格格不入。 + + +我重点说说Jenkins X。很多人都不清楚Jenkins和Jenkins X是什么关系,这就好比刚开始我们很难说清楚Java和JavaScript的关系一样。实际上,JavaScript除了名字上带有“Java”字眼,蹭了个热度之外,本质上它们之间并没有什么关系。而对于Jenkins和Jenkins X来说,虽然并不能说二者一点关系没有,但其实它们面对的场景和要解决的问题是不同的。所以,并不能说Jenkins X就是下一代Jenkins,或者是Jenkins迟早会迁移到Jenkins X上面。 + +Jenkins X最开始的确是作为Jenkins的子项目存在的,但是发展到现在,它已经有了独立的品牌和Logo,并且和Jenkins一起作为CDF(持续交付基金会)的初始项目。Jenkins X想要解决的核心问题是Kubernetes上的原生CI/CD解决方案。所以,Jenkins X和Kubernetes是强绑定的关系,它致力于通过一系列的自动化工具和最佳实践,来降低云原生环境下的研发配置和使用CI/CD的成本,并尽可能地做成开箱即用的状态。 + +而Jenkins更像一个百宝箱,你可以通过插件扩展来解决各种各样的问题,并没有一定之规。 + +我给你举个例子,来形象地对比一下Jenkins和Jenkins X这两个项目。 + +Jenkins就好比你在开车,你知道目的地,但是走哪条路,开多快,中间要不要休息一下,什么时候加油,这些都是你自己来决定的。当然,灵活性带来的就是多变性,你并不知道是不是下一秒就封路了或者是汽车突然坏了。 + +而Jenkins X更像是一辆高速列车,你只要上对了车,列车会把你安全、快速地送往目的地,而你并不需要关心这个车是怎么设计的,时速应该是多少,甚至你在哪里能够下车,它都规定好了。 + +Jenkins X项目中内建了大量的开源工具和解决方案,可以说是开源工具的理想国和试验田,核心目的就是为了简单、快速、开箱即用。比如对Tekton的集成,就被视为对Jenkins自身的颠覆,因为这彻底改变了Jenkins流水线调度机制。因为在Jenkins X看来,Jenkins只不过是Jenkins X中的一个应用,是一个黑盒子,编排通过Tekton来实现,换句话说,即便你想用其他应用来取代Jenkins,也不是不可能的。 + +值得注意的是,Jenkins X中有很多约束,比如你必须使用GitOps的方案来完成应用的晋级和部署,没有其他的选择。如果你没有使用Helm管理应用,也不想使用GitOps,那就现阶段来说,Jenkins X对你就不是一个可选项。 + +我们来总结一下Jenkins X项目: + + +工具的易用性:采用了开箱即用的设计,提供大量的模板来降低新应用上手CI/CD的成本。虽然安装复杂,但是目前已经提供了JX Boot工具,通过初始化向导帮你完成环境搭建。而且,随着云服务商的引入,环境方面应该都是可以默认提供的,就像你不需要操心如何搭建Kubernetes一样,因为会有人以服务的形式把Jenkins X提供出来。 +流水线设计:Tekton取代了Jenkins,成为了流水线的默认引擎,作为Kubernetes的原生解决方案,这也是未来的发展趋势。在编排方面,它采用了yaml方式,继承了原有Jenkinsfile的语法特征,并对Tekton的资源进行隐藏和抽象,通过描述式的语言,以代码化的方式实现,可以说是当前的通用解决方案。不过,它目前并没有提供可视化的编排界面。 +插件生态:继承了Jenkins丰富的插件生态,以及庞大的开发者社区。 +扩展性配置:采用容器化的解决方案,对于Tekton来说更是如此。每个步骤都在容器中完成,可扩展性非常强。 +适用场景:我认为,Jenkins X项目现在还处于快速开发的阶段,适用于原型产品验证。对于那些没有固有模式,想要沿用Jenkins X的设计流程的项目来说,可以尝试使用。不过由于云服务商的接入度不足,目前应该还存在很多挑战,你可以保持学习和跟进。毕竟,这个项目中的很多工具和设计思路都是非常有价值的。 + + + + +GitLab CI/GitHub Actions + +除了Jenkins,国内使用比较多的应该当属GitLab CI了。前些年也有过社区的讨论,到底应该使用GitLab CI,还是Jenkins?很显然,这样的讨论并不能达成共识,毕竟“萝卜白菜,各有所爱”。而GitHub Actions的推出,也是看中了流水线编排领域的“蛋糕”。曾经,GitHub和TravisCI是珠联璧合,可以说是“开源双碧”。GitHub也一再强调,自己只想把代码托管服务做到极致,其他领域都交给合作伙伴完成。但是今天的Package功能和Actions功能都体现出了GitHub自建生态的野心。 + +其实,这两个产品有很多相似之处,因为它们都是依托于一个成熟的代码托管平台衍生出来的原生流水线功能。 + +对于软件开发而言,最重要的无疑就是源代码。之前,我有个同事就说过,只要掌握了源代码,你就可以为所欲为了。比如,基于代码拓展代码评审工具、内建各类静态动态代码检查功能、增加包管理和依赖管理工具等,这些是代码编译之前和编译之后的必备功能。增加内建的持续集成功能,也有助于在代码评审的时候做到机器辅助。 + +当这些功能都集成到代码托管系统中时,你就会发现,它不再是一个简单的版本控制系统了,而是一整套DevOps平台。它们的设计理念是,一个平台解决所有DevOps的工具问题。这一点在GitLab的路线图规划中,也体现得淋漓尽致,GitLab对主流工具都进行了对比,并提供了一个工具的全景图。可以说在行业对标方面,GitLab是做到极致了。你可以参考一下下面这张全景图和他们自己写的对比文章。 + + + + +图片来源:https://about.gitlab.com/devops-tools/ + + +回到流水线方面,GitLab CI和GitHub Actions都和版本控制系统进行了深度集成。我们还是从五个方面来整体看一下。 + +1.工具的易用性 + + +易于上手:由于是内建功能,GitLab CI/GitHub Actions使用起来都非常简单,你并不需要单独构建和维护一个独立的CI服务器来实现这个功能。 +原生体验:由于是原生功能,所以无论是在流水线状态展示方面,还是在代码评审流程的集成方面,它们都做到了原生化的体验,显示的信息和丰富程度是外部独立的CI工具所无法比拟的。 +一体化协同平台:工具链繁多、集成配置复杂、信息分散,都是DevOps工具方面的痛点问题。而一体化的研发协同平台的价值就在于能够集中解决这些问题。开发者不需要在各种工具系统中跳来跳去,可以在一个地方解决所有问题,在一个地方看到所有有用的数据。 +在线文档:GitLab的文档和示例都非常丰富,GitHub就相对薄弱一些,不过两者的文档基本都够用。 + + +2.流水线设计 + + +流水线描述:GitLab CI和GitHub Actions都采用了yaml形式的流水线过程描述文件,二者的语法规则虽然不同,但基本上大同小异。但相对来说,GitHub的语法规则更加符合当前Kubernetes的资源描述风格。关于这两个产品的语法风格,你可以看下这两份资料:GitHub Actions,GitLab CI +流水线编辑:两个产品都支持在线编辑流水线文件,GitHub在这方面更加人性化一些。当你打开Actions的时候,系统会给你推荐一些模板,你可以直接选择生成Actions配置。如果想自己编辑Actions文件的话,系统的右侧也提供了很多示例代码片段,让你可以通过简单的复制、粘贴完成这项工作。另外,GitHub新版本提供了在线的可视化编辑器,毕竟GitHub Actions是全新设计的,集合了各方面的优势。 + + +3.插件生态 + + +GitLab生态:作为一个开源软件,GitLab的优势也恰恰在于开源,官方对于社区PR和feature的响应也是非常及时的。但是,由于GitLab是基于Ruby语言、Rails框架开发的,这个语言就成了比较大的瓶颈,毕竟,熟练掌握Ruby语言的国内开发者相对还是比较少的,所以GitLab的插件生态并没有做起来。 +GitHub生态:GitHub有建设Marketplace的长期经验,再加上开源贡献者众多,所以,在短短一年左右的时间里,他们已经积累了1700多个Actions组件,可以帮助你快速地搭建自己的流水线。从扩展性和生态丰富性方面来说,GitHub更胜一筹。 +使用成本:必须要强调的是,GitHub是商业软件,虽然对待开源项目采用免费策略,但是如果企业级使用的话,成本也是必须要考虑的因素之一,而自建GitLab如果采用社区版本,就没有这么多限制了,这也是优势之一。 + + +4.扩展性配置 + +它们都支持多种环境类型。GitLab很早就提供了对容器和Kubernetes的支持,GitHub在这方面自然也不会落后,官方提供了Linux、Windows和Mac环境的支持,你也可以自建节点并注册到GitHub中。不过必须强调一点,GitHub如果是非企业版本的话,是不支持私有化部署的,这也就意味着,如果你想把企业内部的资源注册到GitHub上,那么就意味着这些资源必须对外可见。 + +5.适用场景 + +由于国内GitLab自建服务的普及,如果你对CI的功能要求没有那么高,那么GitLab CI就足够了。但是,在功能广度方面,由于缺少庞大的插件生态,很多功能还是更多地依赖于你自己实现,所以,如果软件交付流程非常复杂,依赖于多种环境,GitLab CI就不是那么适用了。 + +而GitHub在企业中的使用场景就更加有限了,一方面是成本问题,另一方面,SaaS化服务依赖于内部开放性。所以,如果是开源项目,或者创业项目不希望自己维护一套很重的研发基础设施,那么我建议你考虑使用GitHub的方案。 + +在最新发布的2019年Forrester的趋势报告中,GitLab和Jenkisn都入选了云原生CI工具的榜单,并且处于行业领先地位,你可以看一下报告的图片。虽然图中没有写明Jenkins,但是其背后的CloudBees公司,以及目前在云原生项目Jenkins X中有深度合作的Google公司都处于领先地位,由此可以看出,各大公司都已经开始在云原生领域布局了。 + + + +Drone + +这也是一个近来冉冉升起的CI工具领域的新星。在咱们专栏的留言中,有很多同学提到过这个工具,可见,好工具是会自己说话的。 + +Drone主打的就是云原生CI,整体设计非常轻量级,即便没有什么经验,一两天也能快速上手搭建。在我看来,Jenkins X虽然也是主打云原生,但由于引入了大量组件和流程约束,整体还是略显笨重一些。相反,Drone的实现非常优雅,无论是流水线的语法,还是环境的扩展性方面,都让人不由得赞叹。 + +作为一个开源软件,Drone使用Go语言实现。在我看来,Go就是为云原生而存在的,无论是Docker、Kubernetes,还是我参与的Jenkins X项目,都是通过Go语言来实现的。所以,这个项目对于内部开发团队快速提升Go语言的DevOps平台建设能力,也是一个很好的参考学习案例。 + +对于Drone平台,我目前也在学习和探索阶段,我从下面这几个方面谈谈我个人的看法。 + +1.工具的易用性 + +Drone的搭建非常简单,你可以采用自建服务的形式,也可以使用SaaS服务。UI风格设计体现了恰到好处的理念,整体非常清爽,同时也能跟其他工具(如GitHub)进行集成。 + +2.流水线设计 + +作为云原生的解决方案,流水线同样采用yaml形式、具备描述式表达和流水线即代码的功能。虽然没有过于复杂的语法,但是Drone的流水线语法风格是我个人最喜欢的,它的结构非常清晰。 + +3.插件生态 + +Drone也提供了插件机制,而且官方还提供了对主流版本控制系统和云服务商的集成支持。虽然数量远远比不上Jenkins生态,但是你能想到的基本都有了。比如常见的Artifactory、SonarQube、Ansible等工具,甚至还包含了对微信、钉钉这类国内流行的通讯软件的集成。由于它的开放特性,未来它也会提供更多的插件。 + +4.扩展性配置 + +对于Drone来说,最大的特征就是容器优先。上面提到的这些工具虽然都支持容器,但是并没有把容器作为默认支持的第一选项。而在Drone中,容器则是标配,这也是典型的云原生CI工具的特征:一切都在容器中运行。也正因为如此,非容器化开发部署的项目如果采用Drone就不太合适了。另外,除了容器方式之外,Drone也支持本地执行,这为一些特殊的场景提供了可能性(比如绑定设备的自动化测试等)。 + +5.适用场景 + +我认为,Drone在云原生CI/CD方面的设计代表了未来的趋势。对于基于容器开发交付的产品来说,如果你在寻找一个对应的云原生解决方案,那么我推荐你用Drone。它也比较适合于中小型团队、初创公司想要快速受益于CI/CD,又不想投入太多精力的场景。同时,作为一款Go语言开发的开源软件,随着业务扩展,你大可以自建插件,满足差异化的需求。 + +总结 + +最后,为了方便你理解和进行对比学习,我把这五个云原生流水线工具的特征汇总了图片里。 + + + +到此为止,这几款主流的流水线工具,我就介绍完了。在文章的最后,我还想再补充两点: + + +工具并非决定性的因素,不要轻易陷入“工具决定论”的思想之中,就好比真正的编程高手可能都不需要IDE,选择好的工具,并不代表就有好的结果。 +工具是“存在即合理”的,它们都有各自擅长的领域,没有绝对意义上的最好,只有最适合的场景。另外,即便是同一个工具,在不同的人手中发挥的作用也不一样,选择自己最熟悉的工具,一般都不会有错。比如你要问我选择什么工具的话,我肯定推荐Jenkins。但这并不是因为Jenkins完美无缺,而仅仅是因为我用得顺手而已。 + + +思考题 + +对于Drone这款工具在生产环境的应用,你有哪些实际的经验,又踩过哪些“坑”呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/特别放送(一)成为DevOps工程师的必备技能(上).md b/专栏/DevOps实战笔记/特别放送(一)成为DevOps工程师的必备技能(上).md new file mode 100644 index 0000000..ac1d3e7 --- /dev/null +++ b/专栏/DevOps实战笔记/特别放送(一)成为DevOps工程师的必备技能(上).md @@ -0,0 +1,135 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送(一)成为DevOps工程师的必备技能(上) + 你好,我是石雪峰,今天到了“特别放送”环节。有很多留言问道:“DevOps专家这个岗位,需要的技能和技术栈有哪些?成长路径是怎样的呢?” + +我相信这应该是很多刚开始接触DevOps的同学最关心的问题。毕竟,从实用的角度出发,每个人都希望能够尽快上手实践。所以今天,我来跟你聊聊,我认为的DevOps工程师的必备技能以及学习路径。不过在此之前,我们要先了解DevOps工程师的岗位职责。 + +全球最大职业社交网站LinkedIn(领英)2018年发布的一份报告显示,当今全球最热门的招聘职位分别是DevOps工程师、企业客户经理和前端开发工程师。其中,排名第一的就是DevOps工程师。 + +无独有偶,2019年全球最大知识共享平台Stack Overflow的开发者调查报告显示,在薪资排行榜上,DevOps工程师排名第三,仅次于技术经理和SRE(网站可靠性工程师)。而在去年的调查报告中,DevOps工程师的收入甚至排名第二。 + +无论是人才市场需求,还是收入薪资水平,这种种迹象都表明,DevOps工程师已经成为了当今最炙手可热的岗位,收入也攀升至IT行业的金字塔顶端。难怪有越来越多的人开始接触和学习DevOps。 + +但是,DevOps这样一个刚刚诞生10年的“新兴事物”,并不像一门专业技术那样,有一条相对清晰的学习路径,以及经典的学习资料,比如你要学习Java,就可以从《Java编程思想》看起。 + +除此之外,DevOps似乎又跟软件工程的方方面面有着说不清的关系。我跟你分享一幅DevOps技能发展路线图,根据这幅路线图,你要从编程语言入手,理解操作系统原理、系统性能、网络安全、基础设施即代码、CI/CD、运维监控和云技术等等。 + + + + +图片来源:https://roadmap.sh/devops + + +怎么样,是不是看到这么一堆名词就瞬间头大了吧?如果要把这些所有的技术全部精通,那至少得是CTO级别的岗位。对普通人来说,这并不太现实。毕竟,啥都懂点儿,但是啥都不精通,本身就是IT从业者在职业发展道路上的大忌。 + +如果要说清楚这个岗位,核心就是要回答3个问题: + + +DevOps工程师在公司内承担的主要职责是什么? +为了更好地承担这种职责,需要哪些核心技能?尤其是从我接触过的这些公司来看,有哪些技能是当前最为紧俏的呢? +学习和掌握这些技能,是否存在一条可参考的路径呢? + + +接下来,我们就重点聊一聊这些内容。 + +DevOps工程师的岗位职责 + +关于DevOps工程师这个岗位,一直以来都存在着很大的争议。很多人认为DevOps应该是一种文化或者实践,而不应该成为一个全新的职位或者部门,因为这样会增加公司内部的协作壁垒。 + +其实,我倒觉得没有必要纠结于这个Title,因为很多时候,DevOps跟公司内部已有的角色存在着重叠。比如,开发变成了DevOps开发,运维变成了DevOps运维。另外,在不同的公司里面,类似角色的岗位名称也大不相同。比如,在DevOps状态报告中,DevOps就和SRE被归为一类进行统计。而在公司中实际负责推行DevOps的部门,至少我见过的就有工程效能团队、运维团队、配管团队,甚至还有项目管理团队。可见,不同公司对于DevOps工程师的职责定义也同样存在着差异。 + +但不管怎样,我觉得谈到DevOps工程师职责的时候,除了本职工作的内容以外,至少还应该额外关注3个方面: + +1.工具平台开发 + +关于工具平台开发,争议应该是最小的,而且这也是很多公司推行DevOps的起点。因为工具是自动化的载体,而自动化可以说是DevOps的灵魂。随着公司规模越来越大,研发内部的协作成本也随之水涨船高,那么工具平台的能力水平就决定了公司交付能力的上限。 + +但问题是,因为种种原因,很多公司只有大大小小的分散工具,并没有一套完整的研发协同工作平台,这本身就制约了协作效率的提升。你可以想象一下,研发每天要在大大小小的系统里面“跳来跳去”,很多功能甚至还是重复的,这显然是很浪费时间的。 + +比如,你明明已经在代码托管平台上做了代码评审,结果提测平台上面还有个必填项是“你是否做过了评审?”是不是很让人抓狂呢?这背后的主要原因,就是缺乏顶层设计,或者压根就没有专人或者团队负责这个事情。这样一来,团队各自为战,发现一个痛点就开发一个工具,发现一个场景就引入一个系统,再加上考核指标偏爱从0到1的创造性工作,也难怪每个高T升级都要有自己的系统加持了。但如果任由这种趋势发展下去,内部的重复建设就难以避免了。 + +所以,对于DevOps工程师而言,除了要关注原有的工具重构、新功能的开发之外,更要聚焦于整个软件交付流程,将现有的工具全面打通,以实现可控的全流程自动化。也就是说,不仅仅要追求点状的工具,还要包括整条线上的工具链,从而形成覆盖软件交付完整流程的工具体系。 + +另外,工具平台同样是标准化流程的载体,同时也是DevOps实践的载体,所以在设计实现时,需要考虑这些实践的支持。举个例子,在配置管理领域,将一切纳入版本控制是不二法则。那么,在建设工具平台的时候,就需要始终有这样的意识,比如记录流水线的每一次配置变更的版本,并且能够支持快速的对比回溯。 + +2.流程实践落地 + +其次,无论是工具平台的推广落地,还是结合平台的流程改进,都需要有人来做。毕竟,即便是完全相同的工具,在不同人的手里,发挥的作用也千差万别,把好好的敏捷管理工具用成了瀑布模式的人也不是少数。而针对流程本身的优化,也是提升协作效率的有效手段。 + +比如在有的公司里,单元测试需要手动执行,那么当工具平台具备自动化执行的能力,并且能够输出相应的报告时,这部分的操作流程就应该线上化完成。再比如,以往申请环境需要走严格的线上审批流程,当环境实现自动化管理之后,这些流程都可以变为自服务,通过工具平台进行跨领域角色的交叉赋能,从而实现流程优化的目标。 + +另外,我接触过的一些公司倾向于在不改变流程的前提下,推动DevOps落地。坦率地说,这种想法是不现实的。如果流程上没有约束开发和测试共同为结果负责,那开发为什么要跟测试共同承担责任呢?出了问题又怎么可能不扯皮呢?因此,如果你在公司内部负责流程改进,遇到问题就应该多问几个为什么,找到问题的本源,然后将流程和工具相结合,双管齐下地进行改进。 + +所以,理念和实践的宣导,内部员工的培训,持续探索和发现流程的潜在优化点,这些也都是DevOps工程师要考虑的事情。 + +3.技术预研试点 + +最后,各种新技术新工具层出不穷,哪些适用于公司现有的业务,哪些是个大坑呢?如果适合的话,要如何结合公司的实际情况,评估潜在的工具和解决方案,而不是盲目地跟随业界最佳实践呢?类似技术债务的识别和偿还这种重要不紧急的事情,到底什么时候做合适呢? + +另外,如果公司决定开始推行单元测试,那么,选用什么样的框架,制定什么样的标准,选择什么样的指标,如何循序渐进地推进呢?这些同样非常考验团队的功底。如果步子一下子跨得太大了,到最后就可能成为形式主义了。 + +你可能会觉得,我就是一个小开发、小运维,怎么能推动这么大的事情呢?但实际上,DevOps从来都不是某一个人,或者某一个角色的职责,而是整个研发交付团队所共享的职责。在你力所能及的范围内,比如在你所在的部门内部,开展DevOps的理念宣导和技术培训,鼓动领导参加行业的大会,在和上下游团队协作的时候向前一步,这些都是DevOps所倡导的自服务团队应该具备的能力。 + +DevOps工程师的主要技能 + +说完了DevOps工程师主要负责的事情,接下来我们就来看看DevOps工程师所要具备的能力。我从实用的角度出发,总结了DevOps工程师的核心能力模型。 + +其中,能力模型分为两个方面:专业能力和通用能力。专业能力也就是常说的硬实力,是IT从业人员身上的特有能力,比如软件工程师会写代码,就跟导演会拍电影,司机会开车一样。而通用能力,更加接近于软实力,这些能力并不局限于某一个岗位或者职业,是所有人都应该努力培养的能力。很多时候,当硬实力到达天花板之后,软实力的差异将决定一个人未来的高度,这一点非常重要。 + +软实力 + +我们今天先从软实力说起。在讲具体的软实力之前,我先跟你分享一个小故事。 + +我在国外听过这样一种说法:在企业中,印度裔的工程师往往比华裔工程师的岗位职级要高。为什么会这样呢?我曾经做过一个跨中美印三地的工程团队的负责人,我发现,每次我跟印度工程师交代一个事情,他们总能又快又好地做出一个特别清晰漂亮的PPT。我特意问过他们是怎么做到的。原来,他们在上学时受过这方面的训练,还专门练习过表达、演讲等技能,可见,事出必有因,软实力对个人的发展至关重要。 + +那么,作为一名DevOps工程师,需要具备什么软实力呢? + +1.沟通能力 + +DevOps倡导的核心理念就是沟通和协作,所以,难怪沟通能力会排在软实力的第一名。 + +在推动DevOps落地的过程中,你需要同时具备向上沟通、向下沟通和横向沟通的能力。提炼DevOps实施框架和落地价值,寻求领导层的支持,需要向上沟通;打破组织间的边界,建立跨团队的协同,需要横向沟通;引导团队快速完善平台工具能力,表明工作的意义和价值,提升大家的主动性,需要向下沟通。所以你看,其实每天的工作中都充满了大量的沟通。 + +需要注意的是,沟通能力不仅限于语言能力,很多时候,开发运维的沟通是基于代码完成的。所以,良好的注释风格、清晰结构化的描述方式……这些细节往往也能提升沟通的效率。 + +比如有一种很DevOps的方式,就是ChatOps,是以GitHub的Hubot为代表的对话式运维,慢慢扩展为人机交互的一种形式。通过建立一种通用的沟通语言,打破开发和运维之间的隔阂。 + +2.同理心 + +DevOps希望团队可以共享目标,共担责任,但是实际上,哪个团队不想更加自动化、更加高效地工作呢?所以,DevOps工程师要能够站在对方的角度来看问题,设身处地地想想他们的困难是什么,我能做些什么来帮助他们。这种同理心也是弥合团队分歧,建立良好的协作文化所必需的能力。 + +除此之外,培养团队以用户为中心的思想,也是很好的方式。这里的用户,不是外部用户,而是在交付流程中存在交付关系的上下游部门。在交付一个版本的时候,要尽力做到最好,而不是不管三七二十一,先丢过去再说。 + +我还是要再强调一下,同理心只有在流程和机制的保证之下才能生根发芽。 + +3.学习能力 + +DevOps工程师需要了解的东西真得很多,因此,能够在有限的时间里快速学习新的技能,并且有意愿主动地改进提升,也是一种能力。 + +在DevOps工程师的眼里,从来没有“完美”二字。比如完美的流程、完美的技术实现、完美的软件架构等。他们似乎天生就有一种能力,那就是能发现问题并时刻想着可以做到更好。但实际上,如果没有日积月累的思考,没有外部优秀实践的学习,没有开放的沟通和交流,是没有办法知道,原来还有一种更好的工作方式的。引用质量管理大师戴明博士的一句话: + + +Don’t just do the same things better – find better things to do. + + +很多时候,我们都在等待一个完美的时机,比方说,你打算学习一个新的知识点,但要等到工作都完成了,没人来打扰,有大段的时间投入才开始学习。但实际上,哪来这么多准备就绪的时候呢?真正的学习者都是在没有条件来创造条件的过程中学习的。所以,如果想开始学习DevOps,我信奉的原则只有一个,那就是先干再说。 + +总结 + +今天,我给你介绍了DevOps工程师的前景,可以说,现在是这个岗位的黄金时期。我还给你介绍了DevOps工程师的主要职责,包括工具平台开发,流程实践落地和技术预研试点,这些都是在完成本职工作的基础上需要额外考虑的。在个人技能要求方面,我重点提到了3项软实力,希望你始终记得,软实力不等于玩虚的,这对未来个人的发展高度至关重要。 + +在下一讲中,我会跟你分享DevOps工程师必备的硬技能,以及成长路径,敬请期待。 + +思考题 + +你所在的公司是否有DevOps工程师的岗位呢?他们的职责要求是怎样的呢?你觉得还有哪些软实力是DevOps工程师所必备的呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/特别放送(三)学习DevOps不得不了解的经典资料.md b/专栏/DevOps实战笔记/特别放送(三)学习DevOps不得不了解的经典资料.md new file mode 100644 index 0000000..a5fba0b --- /dev/null +++ b/专栏/DevOps实战笔记/特别放送(三)学习DevOps不得不了解的经典资料.md @@ -0,0 +1,195 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送(三)学习DevOps不得不了解的经典资料 + 你好,我是石雪峰。 + +今天又到了特别放送的环节,在学习交流DevOps的过程中,经常有人会问这样的问题: + + +我想学习DevOps,可以推荐一些好的书和资源吗? +DevOps相关的最新行业案例,我可以在哪里获取呢? +你是怎么知道这么多有趣的故事的呢? + + +这些问题的“出镜率”特别高,所以,我今天专门来跟你聊聊有关DevOps学习资料的事情。 + +你应该也有感觉,在这个信息爆炸的时代,如果想要了解一个新的事物,相关的信息不是太少,而是太多了。像DevOps这种热门话题,相关的资料网上一搜就一大把。各种新书也像“采用了DevOps实践”一样,发布频率越来越快。信息一多,我们就很容易焦虑,这么多资料,什么时候才能看完啊? + +更何况,如果单单只是臻选有用的资料,就要花费大量的时间,按照精益的理论来说,这也是不增值的活动呀。在这个时间稀缺的时代,想要花大段的时间投入到一件事情上,找到一个靠谱和有价值的信息,就成了很多人开始学习的第一步, + +所以,为了让你在专栏之余可以更加有效地持续学习,我特意整理了一份我认为DevOps从业人员需要了解和关注的资料,你可以参考一下。 + +需要强调的是,有针对性地精读一本好书的一部分内容,要比泛泛地读好几本书要更有收获一些,也就是“贵精不贵多”,先定下一个小目标,然后沉下心来反复地学习实践,这个道理在大多数领域都是适用的。 + +一份报告 + +如果说,DevOps领域有行业公认的权威资料的话,DevOps状态报告自然是不二之选。 + +从2014年开始,这份报告每年发布一次,主要编写方也经历了好几次变迁,从最开始的Puppet实验室、IT Revolution到DORA(DevOps Research & Assessment)的加入,再到去年,DORA和Puppet分家,两边各自推出了自己的DevOps状态报告。 + +但从影响力来说,我更推荐DORA的这份报告,从去年开始,这份报告正式改名为:加速度,DevOps状态报告。 + +提到DORA,你可能不太熟悉,但是如果说到DORA的两位核心创始人Nicole博士和Jez Humble,相信你一定有所耳闻,他们也是我今天推荐的一些书的作者。 + +有意思的是,去年DORA宣布加入谷歌,其主要成员也被谷歌云收编,比如Jez Humble,目前就是谷歌云的技术布道师。 + +回到报告本身,我在2017年就开始进行报告的本地化工作。从近两年来看,报告的体量在持续扩大,比如,今年的报告洋洋洒洒有80页内容,而且是全英文的。 + +那么,关于这份报告,重点是要看什么呢?纵观过去几年的报告模式,我给你画个重点:核心是看趋势、看模型和看实践。 + +首先看趋势。 + +每年的报告都会有一些核心发现,这些发现代表了DevOps行业的发展趋势。比如,今年的报告就指出,云计算能力的使用依然是高效能组织和中低效能组织的分水岭,所以,如果公司还在纠结是否要上云,不妨从DevOps的角度思考一下,使用云计算能力带给交付能力的提升可以有多明显。 + +另外,公司内的DevOps组织比例也从2014年的14%提升到了今年的27%。由此可见,越来越多的公司在拥抱DevOps,至少从组织层面可以看到,越来越多带有DevOps职责,或者是以DevOps命名的团队出现。这对于公司内部职责的划分和团队架构演进,具有一定的指导意义。 + +当然,不得不提的,还有衡量DevOps实施效果的4个核心指标,也就是变更前置时间、部署频率、变更失败率和故障修复时长。 + +从2014年的第一份报告开始,每年的报告都在对比这4个核心指标在不同效能团队之间的变化和差异。实际上,就我观察,国内很多公司的DevOps度量体系,都深受这些指标的影响,或多或少都有它们的影子。 + +可以说,这4个指标已经成为了衡量DevOps效果的事实标准,甚至有人直接把指标拿给老板看,说:“你看,高效能组织比低效能组织的故障恢复时长要快2000倍,由此可以证明,DevOps是势在必行的。” + +我个人觉得,没有必要纠结于数字本身,这东西吧,看看就好,更多的还是要透过数据看趋势。 + +比如,去年的指标数据就显示,在交付能力方面,不同组织间的差距在缩小,相应的质量维度的指标差异却在拉大。这就说明,通过初期的自动化能力建设,团队可以快速地提升交付水平。但是,由于缺少质量能力的配套,很容易产生更多的问题,这就带来一个警示,在快速提升交付能力的同时,质量建设也不能落在后面。 + + + +关于报告,其次是看模型。 + +我在第4讲中提到过一个观点:任何技术的走向成熟,都是以模型和框架的稳定为标志的。因为当技术跨越初期的鸿沟,在面对广大的受众时,如果没有一套模型和框架来帮助大众快速跟上节奏,找准方向,是难以大规模推广和健康发展的。 + +在软件开发领域是这样,在其他行业也是如此,要不然,为啥会有那么多国标存在呢?所以说,模型和框架的建立是从无序到有序的分水岭。 + +在今年的状态报告中,研发效能模型进一步细化为软件交付运维模型和生产力模型。今天我不会深入解析模型本身,但我会在专栏后面的内容中结合实际案例进行详细解释,从而帮助你更好地理解。 + +但是,从过往的报告可以看出,每一年关于模型的进化是整个报告的核心内容,报告也在不断覆盖新的领域,试图更加全面地揭示影响软件开发效能的核心要素。在实践DevOps的时候,你可以参考这个能力模型,识别当前的瓶颈点,在遇到拿捏不准的决策时,也可以参考模型中要素的影响关系。 + +比如,公司内部经常会争论是否需要更加严格的审批流程,希望借助严格的审批流程,促使软件交付更加有序和可靠。很多系统和需求在提出的时候,都是以这种思想为指导的。我一直对这种流程的有效性抱有怀疑,加入更多的领导审批环节,除了出问题的时候大家一起“背锅”之外,并没有带来什么增值活动。 + +在今年的模型中,这种观点得到了印证。重流程管控不利于软件交付效能的提升,轻流程管控也不会影响软件交付质量,关键要看公司是否选择一种“更好”的做法来实现管控的目的。 + + + +最后,我们要重点关注实践。 + +在实施DevOps的时候,经常会有这样的困扰:道理都懂,却仍然做不好DevOps。所以,DevOps落地的核心无外乎实践和文化,而实践又是看得见摸得着的,这一点当然值得关注。在状态报告中,有很大篇幅都在介绍实践部分,这些实践都是在大多数公司实施总结出来的,并且得到了实际的验证,具有很强的参考性。 + +比如,今年的报告重点介绍了技术债务、灾难恢复测试和变更管理流程这几个方面的实践,这些都是企业实施DevOps时的必经之路。 + +比如灾难恢复测试,很多公司都有非常详尽的文档,但是如果找他们要操作记录,他们却又很难拿出来。 + +我之前就见过一家国内Top的公司,说是在做关键数据的备份,但实际去看才发现,这个备份任务已经很长时间处于失败状态了。 + +如果有定期的灾难恢复测试,类似的这种问题是一定可以发现的。而往往在灾难发生的时候,才能体现一家公司的工程能力水平。 + +比如,Netflix正是因为混沌工程,才没有受到AWS云服务down机的影响,这和日常的演练是密不可分的。 + +从2014年至今的DevOps状态报告的中英文版本,我已经收集并整理好了,你可以点击网盘链接获取,提取码是mgl1。 + +几本好书 + +讲完了报告,接下来,我再给你推荐几本好书。 + +1.《持续交付》&《持续交付2.0》 + +谈到DevOps里面的工程实践,持续交付可以说是软件工程实践的终极目标。对于在企业内部推进DevOps工程能力建设的人来说,这两本书可以说是案头必备,常看常新。 + +对我自己来说,因为2011年机缘巧合地拿到了第一版第一次印刷的《持续交付》这本书,我的职业生涯彻底改变了。因为我第一次发现,原来软件交付领域有这么多门道。帮助组织提升交付效率这个事情,真是大有可为。 + +《持续交付》围绕着软件交付的原则,给出了一系列的思想、方法和实践,核心在于:以一种可持续的方式,安全快速地把你的变更(特性、配置、缺陷、试验),交付到生产环境上,让用户使用。你可以参考一下软件交付的8大原则。 + + +为软件交付创建一个可重复且可靠的过程 +将几乎所有事情自动化 +将一切纳入版本控制 +频繁地做痛苦的事情 +内建质量 +DONE意味着已发布 +交付过程是每个成员的责任 +持续改进 + + +很多人都有《持续交付》这本书,但我敢打赌,真正能沉下心来把这本书看透的人并不多,因为这本书里面通篇都是文字,而且有些难懂,如果没有相关的实践背景,基本上就跟看天书差不多了。 + +所以,通读《持续交付》并不是一个好的选择,我建议你尽量带着问题有选择性地去读。 + +到了《持续交付2.0》,乔梁老师创新性地将精益创业的思想和《持续交付》结合起来,更加强调IT和业务间的快速闭环,也更加适应当今DevOps的发展潮流。 + +另外,乔梁老师的文笔更加流畅,读起来更加轻松,他会结合案例进行说明,对于实际操作的指导性也更强。毫无疑问,他是国内软件工程领域的集大成者。 + +如果你对软件开发流程的工程实践不太了解,你可以读一读这两本书。 + +当然,对于开发、测试、运维人员这些软件交付过程中必不可少的角色来说,也可以用来拓展知识领域。 + +2.《精益创业》&《Scrum精髓》&《精益产品开发》&《精益开发与看板方法》 + +关于管理实践和精益方面,我给你推荐4本书。 + +《精益创业》提出的MVP(最小可行产品)思想已经被很多的企业奉为圭臬。它的核心是,只有经过真实市场和用户的验证,想法才是真正有效的,产品需要在不断的验证和反馈过程中持续学习,持续迭代,而不是试图一步到位,耗尽所有资源,从而失去了回旋的余地。 + +《Scrum精髓》适合于使用Scrum框架的敏捷团队学习和实践,以避免Scrum实施过程中形似而不神似的问题。同时,这也是立志成为Scrum Master的同学的红宝书。 + +《精益产品开发》是何勉老师在2017年出版的一本基于精益思想和精益看板方法的著作。在精益软件开发领域,这本书和李智桦老师的《精益看板方法》,都是看一本就够了的好书。 + +这几本书比较适合想要了解敏捷,或者是在实际工作中践行敏捷开发方法的同学阅读。另外,精益思想可以说是DevOps的理论源泉,很多的文化导向,以及持续改进类工作都跟精益思想有密切的关系。 + +3.《DevOps实践指南》&《Accelerate:加速》 + +如果你想了解DevOps的全貌以及核心理论体系和实践,《DevOps实践指南》和《Accelerate:加速》就是最好的选择了。这两本书的作者都是DevOps行业内的领军人物,作为Thought Leader,他们引领的DevOps的体系在不断向前演进。 + +其中,《DevOps实践指南》,也就是俗称的Handbook,重点介绍了DevOps实践的三步工作法,还包含了大量DevOps实施过程中的参考案例。而《Accelerate:加速》的作者就是DevOps状态报告的作者。他在这本书中揭示了状态报告背后的科学方法,并提出了DevOps能力成长模型,以帮助你全面提升软件交付能力。 + +4.《凤凰项目》&《人月神话》&《目标》 + +最后,我想再推荐三本小说,这也是我读过的非常耐看的几本书了。 + +其中,《凤凰项目》提出的DevOps三步工作法和《DevOps实践指南》一脉相承;《人月神话》是IT行业非常经典的图书,畅销40余年;《目标》则是约束理论的提出者高德拉特的经典著作,他所提出的改进五步法构成了现代持续改进的基础。 + +大会,网站和博客 + +当然,报告和书只是DevOps资源中的一小部分,还有很多信息来源于大会、网站和博客,我挑选了一些优质资源,分享给你。 + + +DEOS :DevOps国际峰会,以案例总结著称; +DevOpsDays:大名鼎鼎的DevOpsDays社区; +TheNewStack :综合性网站,盛产高质量的电子书; +DevOps.com :综合性网站; +DZone : 综合性网站,盛产高质量的电子书; +Azure DevOps:综合性网站,盛产高质量的电子书; +Martin Fowler :Martin Fowler的博客; +CloudBees Devops :Jenkins背后的公司的博客。 + + +在这些资源中,有一些值得你重点关注一下。 + +比如,Gene Kim发起的DOES(DevOps企业峰会)就是获取实践案例的绝佳场地;而DZone和NewStack经常会推出免费的电子书和报告,也值得订阅;Martin Fowler的博客,每一篇内容都是精品,对于很多技术细节可以说是起到了正本清源的作用,值得好好品味。 + +说了这么多,最后我还想再花一点点时间,跟你聊聊学习这个事情。我跟你分享一幅美国学者爱德加·戴尔提出的学习金字塔模型图,这个模型也是目前比较有参考性的模型之一。 + + + + +图片来源:https://www.businessdirect.bt.com/ + + +在这个模型中,学习的方式分为两种,一种是主动学习,一种是被动学习。其实,无论是读书,看视频,还是听专栏,都属于是被动式的学习,最终收获的知识可能只有输入信息的一半儿,这还是在记性比较好的情况下。大多数时候,看得越多,忘得越多,这并不是一种特别有效的学习方式。 + +实际上,对于DevOps这种理念实践、技术文化、硬技能、软实力交织在一起的内容来说,主动学习的方式是不可或缺的,比如案例讨论,线下交流,在实践中学习等。 + +所以,希望你能多思考,多总结,结合工作中的实际问题,摸索着给出答案,并积极分享,跟大家讨论。只有主动思考,才能消化吸收,最终总结沉淀出一套自己的DevOps体系认知。 + +总结讨论 + +好了,今天我跟你聊了DevOps的学习资料,包括状态报告、书籍和大会、网站、博客。不过,对于DevOps来说,这些也仅仅是点到为止。 + +我想请你来聊一聊,你自己在学习和实践DevOps的过程中,有没有私藏的干货和渠道呢?如果有的话,希望你可以分享出来,我们共建一个DevOps相关的资源库,并在GitHub上进行开源维护,从而帮助更多人了解和学习DevOps。 + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/特别放送(二)成为DevOps工程师的必备技能(下).md b/专栏/DevOps实战笔记/特别放送(二)成为DevOps工程师的必备技能(下).md new file mode 100644 index 0000000..11dfa21 --- /dev/null +++ b/专栏/DevOps实战笔记/特别放送(二)成为DevOps工程师的必备技能(下).md @@ -0,0 +1,128 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送(二)成为DevOps工程师的必备技能(下) + 你好,我是石雪峰。在上一讲,我介绍了DevOps工程师的具体职责以及DevOps工程师必备的3项软实力,分别是沟通能力、同理心和学习能力。有了这些认知之后,我们今天来看看“重头戏”:DevOps工程师必备的硬实力以及学习路径。 + +DevOps工程师必备的硬实力 + +所谓硬实力,说白了就是指一个人的技术能力。软实力通常是“只可意会不可言传”的,但技术本身就具体多了,重要的是,技术水平的高低相对来说也更好衡量。在公司里面,技术人员要想获得晋升,重点就是依靠技术能力。 + +IT行业覆盖的技术领域非常广,而且近些年的新技术也是层出不穷的,从入门到精通任何一门技术,都需要大量时间和精力的投入。那么,在面对这么多技术的时候,究竟要选择从哪个开始入手,真是一个难题。对于希望成为DevOps工程师,甚至是DevOps专家的你来说,究竟有哪些必须掌握的核心技术呢? + +1.代码能力 + +现在这个时代,代码能力可以说是最重要的硬实力了。IT行业自然不用说,像运维有运维开发,测试也有测试开发,就连产品经理都要懂代码,不然可能都没办法跟开发同学顺畅交流。 + +对于工具平台自身的建设而言,代码能力自然是重中之重。这不仅仅在于通过写代码来实现工具平台本身,还在于你能了解开发的完整过程。这些平台的用户每天跟代码打交道的时间可能比跟人打交道的时间还多,如果你不能理解他们的日常工作方式,那么你做出来的工具平台,又怎么能真正解决团队的问题呢? + +这里提到的代码能力包含两个方面,分别是脚本语言能力和高级语言编程能力。 + + +脚本语言能力。这对于运维工程师来说自然是驾轻就熟,各种VIM、Emacs手到擒来,Shell和Python也是轻车熟路。而对于开发人员来说,难点不在于语法本身,而在于对关联操作系统和命令的理解上。毕竟,脚本语言是一种快速的自动化手段,追求的是高效开发,简单易用。 +高级语言编程能力。你需要至少掌握一门高级语言,无论是Java、Python还是Ruby和PHP。其实语言只是工具,你不用过度纠结于选择哪门语言,要求只有一个,就是你能用它来解决实际问题,比如能够支持你实现面向移动端或者Web端的工具平台开发。为了写出好代码,而不仅仅是写出能用的代码,你也需要对于一些常见的开发框架和开发模式有所了解。这是一个相对漫长的过程,绝对不是什么“21天精通XX语言”就够了。因为看得懂和写得好,完全是两码事。 + + +好的代码是需要不断打磨和推敲的。与其说写好代码是一门技术,不如说是一种信仰。我们团队的内部沟通群名叫作“WBC团队”,“WBC”也就是“Write Better Code”的缩写,这其实也是我们团队对自己的一种激励。在日常的开发过程中,我们会不断发现和总结更好的实现方式,在内部分享,互相学习,从而持续提升代码能力。我截取了一部分我们最近优化流水线脚本的经验总结,你可以参考一下。其实,每个人都能总结出自己的代码心经。 + + + +2.自动化能力 + +在自动化方面,你首先需要对CI/CD,也就是持续集成和持续交付,建立起比较全面的认知。因为CI/CD可以说是DevOps工程领域的核心实践,目前大部分公司都在集中建设软件的持续交付能力,尤其是以流水线为代表的持续交付平台,很多时候就同DevOps平台划上了等号。 + +接下来,为了实现全流程的自动化,你需要能够熟练使用CI/CD各个关键节点上的典型工具,并且了解它们的设计思路。 + +一方面,目前很多公司都在拥抱开源,参与开源,开源工具自身的成熟度也非常高,并且逐渐取代商业工具,成为了主流方案。通过直接使用开源工具,或者基于开源工具进行二次开发,也是自动化领域投入产出比最高的方式。所以,像版本控制工具Git、代码托管平台Gitlab、CI工具Jenkins、代码扫描工具Sonar、自动化配置管理Ansible、容器领域的Docker、K8S等等,这些高频使用的工具都是你优先学习的目标。 + +另一方面,无论是开源工具,还是自研工具,工具与工具之间的链路打通也是自动化的重要因素。所以,在理解开源工具的实现方式的基础上,就要能做到进可攻,退可守。无论是封装,还是自研,有了工具的加持,CI/CD也会更加游刃有余。 + +关于DevOps的工具图谱,我跟你分享一个信通院的DevOps能力成熟度模型版本,供你参考。值得注意的是,工具不在多,而在精。其实,工具的设计思路和理念有共通之处,只要精通单个节点上的工具,就可以做到以点带面。 + + + +3.IT基础能力 + +我始终认为,运维是个特别值得尊敬的工种,也是DevOps诞生的原点。如果你不是运维出身,那你要重点掌握运维的基础概念,最起码要了解Linux操作系统方面的基础知识,包括一些常用的系统命令使用,以及网络基础和路由协议等。毕竟,对于开发者来说,他们通常习惯基于IDE(集成开发环境)图形界面工作。比如,如果问一个iOS开发同学怎么通过命令行的方式进行构建调试,或者如何用代码的方式实现工程的自动化配置,他可能就答不上来了。 + +另外,随着基础设施即代码的技术不断成熟,你还要能看懂环境的配置信息,应用自动化构建、运行和部署的方式等,甚至可以自行修改环境和应用配置,这样才能实现所谓的开发自运维。虽然在大多数公司,运维的专业能力一般都会通过运维平台对外提供服务,但对于基础概念,还是需要既知其然,也知其所以然。 + +4.容器云能力 + +云计算对于软件开发和部署所带来的变化是革命性的。未来企业上云,或者基于云平台的软件开发会慢慢成为主流。而容器技术又天生适合DevOps,Kubernetes可以说是云时代的Linux,基于它所建立的一整套生态环境,为应用云化带来了极大的便利。 + +所以,无论是容器技术的代表Docker,还是实际上的容器编排标准Kubernetes,你也同样需要熟悉和掌握。尤其是在云时代,基于容器技术的应用开发和部署方式,都是DevOps工程师必须了解的。 + +5.业务和流程能力 + +在任何时候,DevOps的目标都是服务于业务目标,DevOps本身也从来不是墨守成规的方式,而是代表了一种变革的力量。所以,加强对业务的理解,有助于识别出DevOps改进的重点方向,而流程化的思维建设,有助于突破单点,放眼全局。 + +很多时候,企业需要的不仅仅是一个工具,而是工具所关联的一整套解决方案,其中最重要的就是业务流程。 + +对于DevOps工程师来说,要有能力发现当前流程中的瓶颈点,并且知道一个更加优化的流程应该是怎样的,这一点也是制约工程师进一步拓展能力的瓶颈之一。 + +举个例子,对于开发DevOps平台工具来说,你可能认为最合适承担的团队就是开发团队,因为他们的代码能力最强。但是实际上,DevOps平台的设计,很多时候都是由最熟悉企业内部研发流程的团队来主导的。正因为DevOps工程师的工作应该同业务紧密联系,更加关注于全局交付视角,所以很多时候,配置管理、质量管理、项目管理和技术运维团队更多地在承担相近的角色。毕竟,只有方向正确,所做的一切才是加法。 + + + +学习路径 + +那么,要想成为DevOps工程师,是否有一条普适性的学习路径呢?实际上,这个问题就跟我们要在公司推行DevOps,是否存在一条通用的改进路径一样,并不是一个容易回答的问题。 + +从前面的能力模型可以看出,DevOps工程师特别符合现在这个时代的要求,他具备多重复合能力,是典型的全栈工程师,或者“梳子型”人才。因为只有这样,才能充分弥合不同角色之间的认知鸿沟,堪称团队内部的万金油。 + +基于过往在公司内部推行DevOps的经验,以及当前行业的发展趋势,我有几条建议送给你: + +1.集中强化代码能力 + +未来的世界是软件驱动的世界。我们以前总说的必备能力,比如外语、开车等,未来都可以被软件所取代。而编程能力即将成为下一个必备能力,甚至连国务院发布的《新一代人工智能发展规划》中都提到,要在中小学普及推广编程教育。 + +而写可以用的代码,和写好的代码之间,距离绝不只是一点点而已。你可能会说,以后都用人工智能来编程了,可问题是人工智能从何而来?又是谁来训练和标注人工智能的呢?所以,越是基础的能力,越不会过时,比如数学、核心的编程思想、数据结构,以及基于代码构建对世界的认知和建模能力。 + +所以,如果你现在只是刚开始接触代码,我建议你给自己定一个目标,专门强化自己的代码能力,至少花1年时间,从新手变成熟手,这对于你未来在IT行业的发展,至关重要。 + +跟你分享一个小技巧。你可以基于成熟的开源软件来边学习边应用,比如像Adminset这种轻量级的自动化运维平台,已经可以解决大多数中小公司的问题了。其实,代码能力不仅仅是掌握语法和框架,更重要的是基于场景,整体设计数据和业务流程,并通过代码实现出来。毕竟,只有结合实际的应用场景进行学习,才是最有效率的。 + +2.培养跨职能领域核心能力 + +相信经过几年的工作,你已经具备了当前岗位所需要的基本能力,这是你当前赖以为生的根本。那么在这些能力的基础上,逐步发展跨领域跨界的能力,尤其是那些核心能力,就成了投入产出比最高的事情。 + +举个例子,如果你是软件开发工程师,那么恭喜你,你已经走在了代码的道路上,接下来,运维能力就是你要尝试攻克的下一个目标。而在这些目标中,比如操作系统、自动化部署以及云能力,就是你要最优先发展的跨界能力,因为它们是运维的核心,也是了解运维最好的出发点。反过来说,如果你从事的是运维行业,那么除了常用的脚本以外,核心代码能力就是你的目标。 + +其实,我们每天的工作其实都离不开跨界,比如,运维每天部署的应用,为什么要部署这么多实例?每个实例之间的调用关系又是怎样的?多问几个为什么,往往就有新的收获。 + +不仅如此,在接触跨领域的时候,除了基础核心技能,那些最常见的工具,你也要花时间来了解。现在网上的资料足够多,快速入门应该并不困难。 + +3.DevOps核心理念和业务思维 + +如果你不理解DevOps到底是什么,那何谈成为DevOps工程师呢?因此,像DevOps中的核心理念,比如精益敏捷、持续交付,以及很多实践,你都要有所了解。当然,如果你订阅了这个专栏,我将带你走过前面的这段路,你可以快速地进入下一阶段,在实战中练习。 + +DevOps在公司的落地是大势所趋,也许你所在的团队也会参与其中,那么除了做好自己的本职工作外,你也可以多参与,多思考,看看推进的过程是怎样的,涉及到的角色又在做些什么,项目的整体进展和计划是什么。在实战中练习和补齐短板,对于积累经验来说,是不可或缺的。很多时候,不是没有学习的机会,只是我们自己不想看到罢了。 + +另外,可能你现在距离业务还比较远,那么你可以尝试了解一些大的业务目标,多跟你所在团队的上下游进行沟通,看看他们现在的关注点在什么地方。既然业务的目标需要整个团队紧密协作才能完成,那么每个团队都是其中的一份子,所以他们身上也同样体现了业务的目标。 + +4.潜移默化的软实力建设 + +类似沟通能力、同理心、自驱力、学习能力、主动性等,无论从事任何职业,都是你身上的闪光点。很多天生或者从小养成的习惯,需要长时间潜移默化的训练才能有效果。 + +很多时候,IT从业人员给人的印象都是不善表达,再加上东方文化的影响,本身就比较含蓄,这对很多沟通和表达来说,都是潜在的障碍。这个时候,就要尽量把握已有的机会,比如多参加团队内部的读书分享、公司内部的讲师培训报名等。即便刚开始分享的内容还不足你脑中的1%,但至少也是一个好的起点。我的建议就是6个字:勤练习,多总结。就像DevOps一样,持续改进和持续反馈,培养自己的自信心。 + +总结 + +总结一下,我在这两讲给你介绍了DevOps工程师要重点关注的3大职责,分别是工具平台开发、流程实践落地和技术预研试点。另外,我还基于实用角度提炼了8大核心能力模型,分为3条软实力和5条实力,并给出了4条提升DevOps核心能力的建议。为了方便你复习和理解,我画了一张脑图,把这两讲内容进行了汇总,你可以参考一下。 + + + +最后,我想强调的是,就像DevOps没有明确的定义一样,DevOps工程师的技能也没有明确的限定,所以,你要时刻保持好奇心,持续学习,总结出自己的能力体系,并在实践积累经验,这样才能在激烈的竞争中占得先机。 + +思考题 + +针对我们这两讲的内容,你觉得自己需要提升哪方面的能力呢?你有哪些快速提升能力的小窍门吗? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/特别放送(五)关于DevOps组织和文化的那些趣事儿.md b/专栏/DevOps实战笔记/特别放送(五)关于DevOps组织和文化的那些趣事儿.md new file mode 100644 index 0000000..679d506 --- /dev/null +++ b/专栏/DevOps实战笔记/特别放送(五)关于DevOps组织和文化的那些趣事儿.md @@ -0,0 +1,160 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送(五)关于DevOps组织和文化的那些趣事儿 + 你好,我是石雪峰,今天又到了特别放送环节。写到这儿,专栏已经接近尾声了,我想再跟你聊聊DevOps的组织和文化。 + +DevOps文化好像是一个矛盾结合体:一方面,文化这种东西似乎只可意会不可言传;另一方面,文化对DevOps实践的重要性又是毋庸置疑的。 + +在各种行业大会上,关于文化的议题总是屈指可数。原因也很简单,关于文化,一般都说不明白,即便能说明白,也改变不了什么。因为文化的改变可不是像引入一个工具那么简单,很多时候都需要思想上的转变。 + +谈到DevOps文化,我想到去年我和几个朋友一起组织《DevOps实践指南》的拆书帮活动。这个活动就是,通过连续几周的线上分享,我们帮助大家总结提炼书中的核心知识。 + +在分享的过程中,有这样一件事,我印象特别深刻。事情的起源是原书的第14章中有这样一段描述: + + +团队在客户面前没有任何需要隐藏的,对自己也同样如此。与其把影响线上系统的问题视为一种秘密,不如尽可能地将它透明化,主动将内部的问题广而告之给外部用户。 + + +某大型公司的IT负责人刚好负责分享这个章节,他表示,为了尊重原文,他保留了这段描述,但是在国内的环境下,这并不现实。即便是他自己,一个坚定的DevOps实践者,也很难做到这种程度。因为如果把公司内部的问题通通开放给客户,那估计转天就可以收拾东西回家了。 + +也正因为公司一般不会在第一时间对外公布故障,所以也难怪,这些事情基本都是通过“云头条”这类公众号第一时间公布出来的。 + +但是,似乎大家的记忆力也都不太好,很多时候,这些事情过去了也就不了了之了,除了听说“谁又背锅了,谁又被牵连了“之类的流言蜚语之外,也没有什么特别之处。 + +这也可以理解,毕竟家丑不可外扬,内部吐吐槽也就罢了,如果凡事都到外面去宣传,那公司岂不是形象全无?更有甚者,还会影响用户对公司的信心。你想,如果天天就你问题最多,那谁还敢用你的服务呢? + +我们都知道DevOps文化的几个关键词:协作、分享共担、无指责文化、在错误中学习……这些道理大家都懂,但真正遇到问题、需要平衡不同部门利益的时候,是否还能以这些文化为准则,来指导行为模式,就是另外一码事了。 + +说白了,如果想看团队是否具备DevOps文化,与嘴上说说相比,更重要的是看怎么做。所以,今天我给你分享几个故事,看看在面对同样的问题时,其他公司是怎么做的,并思考一下,为什么这样是一种更好的做法? + +GitLab删库的故事 + +时间回到2017年1月31日,全球最大的代码托管协作平台之一的GitLab出现了一次长达18小时的停机事故,原因居然是一个IT工程师把生产数据库的数据给清空了。 + +由于遇到了爬虫攻击,主备数据库之间的同步延迟已经超过了WAL的记录上限,导致数据同步无法完成。当时,遇到这种问题的操作就是移除所有备份数据库上的数据记录,然后全量触发一次新的同步。但是,由于数据库配置并发数和连接数等一系列的配置问题,导致数据库的数据备份一直失败。 + +这个时候,时间已经来到了标准国际时间的晚上11点半。由于时差的关系,对于身在荷兰的工程师来说,这时已经是深夜1点半了。当值工程师认为有可能是之前失败的同步遗留的数据导致的数据库备份失败,所以决定再一次手动清空备份服务器的数据。 + +但是,也许是由于疏忽,他并没有意识到,当时他操作的是生产数据库。几秒钟后,当他回过神来取消操作的时候,一切都已经来不及了。最终的结果是,总共有超过300G的线上数据丢失,直接导致了服务进入恢复模式。 + +按道理说,这种事情虽然难以接受,但其实并不少见。更加严重的是,当GitLab尝试恢复数据的时候才发现,他们所谓的“精心设计”的多重备份机制,竟然都无法拯救被删除的数据。 + +最夸张的是,直到这会儿,他们才发现,由于升级后工具版本不匹配,数据库的定时备份一直处于失败状态。他们原以为邮件会告警这个问题,但巧合再一次出现,针对自动任务的报警也没有生效。 + +事已至此,要么是隐藏事实,然后给外界一个不疼不痒的解释,要么就是把问题完全公开,甚至是具体到每一个细节,你会选择怎么处理呢? + +GitLab公司的选择是后者。他们第一时间将系统离线,并将事件的所有细节和分析过程记录在一个公开的谷歌文档中。不仅如此,他们还在世界上最大的视频网站YouTube上对恢复过程进行全程直播。 + +考虑到有些用户不看YouTube,他们还在Twitter上同步更新问题状态,硬生生地将一场事故变成了一个热门话题。当时,同时在线观看直播的用户超过5000人,甚至一度冲到了热门榜的第二位。 + +除此之外,在几天后,公司的CEO亲自给出了一篇长达4000字的问题回溯记录,包含问题发生的背景、时间线、核心原因分析,针对每一种备份机制的说明,以及将近20条后续改进事项,由此获取了用户的信任和认可。可以说,在这一点上,他们真的做到了透明、公开和坦诚相待,并且做到了极致。 + + +问题回溯的资料: https://about.gitlab.com/2017/02/10/postmortem-of-database-outage-of-january-31/ + + +至于那位倒霉的工程师的结果,估计你也听说了,对他的惩罚就是强迫他看了几十分钟的《彩虹猫》动画。说实话,这个动画有点无聊。但是,如果这种事情发生在咱们身边,估计直接就被开掉了。我知道你肯定好奇这个《彩虹猫》到底是个啥动画片,我也特别无聊地找来看了下,如下所示: + + + +从此以后,GitLab的开放越发“变本加厉”。现在你可以在任何时间去查看服务的实时状态,包括每一次过往的事故分析。同时,名叫“GitLab状态”的Twitter账号实时更新当前的问题,目标就是在任何用户发现问题之前,尽量主动地将问题暴露出来,至今已经发布了将近6000条问题。 + +同时,你还可以查看GitLab服务的详细监控视图和监控数据,包括GitLab的运维标准手册、备份脚本。这些通通都是对外开放的。只要你想用,你就可以直接拿来使用;如果你觉得哪里不靠谱,也可以直接提交改动给他们。我提取了一些截图和地址,你可以参考一下。 + +1.GitLab状态Twitter:https://twitter.com/gitlabstatus + + + +2.GitLab状态网页:https://status.gitlab.com/ + + + +3.GitLab内部监控大屏:https://dashboards.gitlab.com/ + + + + + +这并不是GitLab公司发疯了,实际上,开放已经成为了主流公司的标配。比如,在GitHub上,你同样可以看到类似的信息。 + + + +故事讲到这儿,就可以告一段落了。面对事故的态度,很大程度上体现了公司的文化。 + +首先,就是在错误中学习。 + +GitLab的分析报告不仅是对问题本身的描述,很大程度上也是希望把他们的经验,尤其是修复过程中的经验分享出来,通过错误来积累经验,改善现有的流程和工具,从而彻底地避免类似问题的出现。 + +每个人、每个公司都会犯错,对错误的态度和重视程度,决定了成长的高度。所以,假如说我要去一家公司面试,面试官问我有没有问题,那我非常关心的一定是他们公司对错误的态度,以及具体的实际行动。 + +另外,就是建立信任和及时反馈,公开透明是关键。这不仅是对外部用户而言的,对内部协作的部门和组织来说,也是这样。因为只有充分的透明,才能赢得对方的信任,很多事情才有得聊,否则,建立协作、责任共担的文化,就成了一句空谈。 + +在开始建立DevOps文化的时候,你首先要明白,上下游所需要的信息是否能够自主简单、随时地获取到,如果不能的话,这就是一个很好的潜在改进事项。 + +Etsy三只袖子毛衣的故事 + +Etsy是美国的一家手工艺电商平台,从2015年上市以来,它的市值一度接近80亿美元。当然,除了快速增长的市值以外,最为人称道的就是它们的DevOps能力,而它们的案例也大量出现在了《DevOps实践指南》一书中。 + +那么,为什么这个名不见经传的公司能够做到这种程度呢?实际上,通过一件小事,我们就能看出来原因。 + +你可能不知道的是,一家在线电子商务公司每日浏览频率最高的单体页面不是首页,也不是具体哪个商品的页面,而是网站的不可用页面,也就是我们习惯说的502页面。有些公司甚至为了提升502页面的用户体验,利用好这部分流量,在502页面做了很多文章,比如把502页面作为一个产品推广的阵地等。 + +当Etsy的网站不可用的时候,你看到的是一个小姑娘在织毛衣的画面,而这个毛衣竟然有三只袖子。 + + + +实际上,“三只袖子的毛衣”代表了Etsy对于错误的态度。我们都知道,一件毛衣应该只有两只袖子,这是常识。如果有人真的织出来第三只袖子,我们的第一反应就是觉得这很可笑,这只是个人的问题,却很少去想他为什么会做这种反常识的事情,背后的根因是什么。 + +但是,Etsy公司却不是这样的。在每年的年终总结大会上,公司都会颁发各种奖项,其中一个奖项的奖品就是“三只袖子的毛衣”,获奖者是公司年度引入最大问题的个人。 + +这是因为,在他们看来,犯错误并不是什么大不了的事情。错误本身并不是个人的问题,而是公司系统和制度的问题,正因为有了这样的错误,才给了公司改进和成长的空间。从某种意义上说,这也是一种贡献。 + +当然,除了制造噱头之外,通过这种行为,其实公司想表达的是它们对文化的偏好,也就是要建立一种心理安全、快速变化、及时反馈、鼓励创新的文化,由此来激发整个团队的士气和战斗力。 + +无独有偶,2019年的DevOps状态报告也特别指出,心理安全的文化氛围,有助于团队生产力的提升。更重要的是,状态报告还把它作为一条重要能力,放入了DevOps能力模型之中。 + +因为,只有当员工感受到心理安全时,才会把注意力集中在解决问题和快速完成工作上,而不是花费大量的时间用于互相攻击和部门政治。在跨部门寻求合作的时候,才会思考如何让组织的价值最大化,而不是想“谁过来动了我的奶酪,我要如何制造更高的门槛来保护自己的利益”。对于DevOps这种注重协作的研发模式来说,这一点真的太重要了。 + +Netflix招聘成年人的故事 + +美国硅谷聚集了世界上大多数精英的IT公司,但是精英中的精英,就是FAANG,也就是Facebook、Apple、Amazon、Netflix和Google这五家公司的首字母简称,这五家公司基本引领了硅谷技术的风向标。大多数人对其中的4家公司都非常熟悉,但是对Netflix却知之甚少。那么,这家公司凭啥能跻身为精英中的精英呢? + +如果我告诉你,在Netflix,每个工程师不仅拿着数一数二的薪水,还可以自己决定什么时候休假,爱休多长时间就休多长时间,而且报销不需要经过审批,填多少就报多少。另外,即便只加入公司一天就离职了,公司给予的补偿也足够他们活上一年半载。 + +看到这里,你是不是觉得这家公司的老板疯了呢? + +这个叫作里德·哈斯廷斯的人还真没疯。我所说的这一切,背后的原因都被记录在了《奈飞文化手册》一书中,这也是号称硅谷最重要的文件的作者在离开Netflix之后写的一本阐述Netflix文化的书。 + +Netflix认为,与其建立种种流程来约束员工,不如砍掉所有不必要的流程,给员工一个自由发挥自我价值的空间。因为,把所有员工都视为一个成年人是他们的行为准则。作为一个成年人,你应该能够为自己的行为负责,同时为公司的发展负责,由此做出最好的选择,并付出最大的努力。 + +正是这种开放的氛围,使得Netflix至今开源了171个项目和插件。其中,像混沌工程的鼻祖混乱猴子(Chaos Monkey)、断路器工具Hystrix、服务注册工具Eureka、部署工具Spinnaker ,都是DevOps领域最为著名的开源工具。 + +开源为先的共享精神正在成为越来越多的公司重视开源的动力之一。让真正优秀的人做有价值的事情,而不是让他们整日为复杂的流程、公司的内部政治和无意义的工作所影响,他们才能发挥最大的价值。对DevOps来说,也是如此。 + +总结 + +说到这儿,三个故事已经讲完了。我们来总结一下在DevOps文化中最为知名的几点内容: + + +建立免责的文化,并在错误中学习; +通过对外开放透明,建立信任,促进协作; +打造心理安全的氛围,鼓励创新; +开源为先的共享精神。 + + +改变企业文化,绝不是一个人、一句话的事情,管理层的认同和导向非常重要。但是,我们并不能期望每家公司都能成为FAANG一样的硅谷巨头。所以,从我做起,从力所能及的范围做起,别觉得文化跟自己没有关系,这才是最重要的。 + +最后,希望你看完这一讲以后,可以重新审视一下,团队内部是否建立了正向的错误回溯机制?是否鼓励内部分享和创新?是否和上下游之间做到了开放和协作为先?是否在身体力行地减少重复建设? + +思考题 + +你对今天的哪些内容印象最深刻呢?你又有哪些跟DevOps文化相关的故事可以拿来分享呢? + +欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/特别放送(四)Jenkins产品经理是如何设计产品的?.md b/专栏/DevOps实战笔记/特别放送(四)Jenkins产品经理是如何设计产品的?.md new file mode 100644 index 0000000..8aca2d5 --- /dev/null +++ b/专栏/DevOps实战笔记/特别放送(四)Jenkins产品经理是如何设计产品的?.md @@ -0,0 +1,107 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送(四)Jenkins产品经理是如何设计产品的? + 你好,我是石雪峰。这是一期临时增加的特别放送。 + +前两天,我去葡萄牙里斯本参加了2019年的DevOps World | Jenkins World大会。这是一年一度的社区聚会,参会人会围绕Jenkins和DevOps展开为期3天的密集交流,信息量非常大。很多新技术、行业趋势、产品设计思路都在大会上涌现了出来,我觉得非常有价值,也很有必要整理出来,分享给你。 + +2019年是Jenkins诞生15周年,对于任何一个软件来说,15年都不是一个短暂的时间。在这个时间点,社区也在展望过去15年来的Jenkins发展历程,并憧憬下一个15年Jenkins的变化。 + +可以说,从DevOps产品的角度来说,Jenkins本身就是一个非常出色的典型案例。 + +最开始,这是一个由于Jenkins创始人KK无法忍受同事天天导致编译失败而开发的一个人项目。到今天,这个项目已经有将近900名或全职、或兼职的贡献者,26万多个Master节点,超过3000万个任务了。这些数字还仅仅是官方可以统计到的部分,如果再加上企业内网、个人电脑上的实例,那就更加不计其数了。 + +今年,我印象最深刻的是,Jenkins创始人KK并没有在主会场上讲太多的产品细节、设计思路、发展方向等,而是仅仅用了10多分钟回顾了自己的心路历程。在演讲的最后,他将舞台交给了一位Jenkins产品经理。这位产品经理是何方神圣呢?为什么是一位产品经理来讲这些内容呢?这激起了我极大的好奇心。 + +一直以来,KK都被视为Jenkins的头号产品经理。的确,技术专家兼产品经理是比较普遍的一个现象。这是因为,与普通面向用户的产品相比,DevOps产品有几个非常鲜明的特征。 + + +技术背景要求高。因为DevOps产品要解决的很多问题都是一线的技术问题; +面向的用户是开发人员。这就意味着,如果你不了解开发的真实工作方式,就很难设计出开发友好的产品; +专业工具繁多。产品引用到的开源组件和工具都是专业领域的内容,比如Jenkins就是一个典型的持续集成系统,如果你不了解Jenkins,又怎么设计Jenkins呢? + + +在几天的会议过程中,针对DevOps产品经理面对的这些挑战,我专门跟这位神奇的Jenkins产品经理进行了沟通。他就是Jeremy Hartley,一个来自荷兰的大哥。 + +我先给你介绍下社区的运作方式。以Jenkins这个产品为例,它背后的主要贡献者都来自于CloudBees公司。虽然这些人都属于同一个公司,但实际上,他们大多各自分散在家办公,一年到头也见不了几次面。 + +比如,产品经理Jeremy在荷兰,创始人KK在加州,基础设施的负责人Oliver在比利时,K8S的插件维护者在西班牙。因此,每年的FOSDEM(年初的欧洲最大的开源软件大会),以及年末的Jenkins World大会,就成了这些世界各地的开发者汇聚到一起的难得机会。 + +言归正传,与产品经理的积极外向、滔滔不绝的一般形象不同,Jeremy可以说是一个异类。他从始至终都给人一种温文尔雅的感觉,甚至在公开演讲的时候,他的语气也非常平和,没有太多的情绪表达,只是把他和他的产品的故事娓娓道来。 + +Jeremy早先在一家互联网在线视频公司干了10年。他半开玩笑地说,即便干了10年,也不如跟腾讯合作一个项目来得出名。后来,他加入XebiaLabs。这是一家专门做DevOps平台产品的公司,在国内可能不是特别出名,但如果提到DevOps工具元素周期图,相信你肯定听说过,这就是这家公司迭代更新的。 + + + + +图片来源:https://xebialabs.com/periodic-table-of-devops-tools/ + + +在今年的4月份,他加入了CloudBees,成为了主管开源和商业版本Jenkins的高级产品经理。在跟他交流的过程中,我对产品经理这部分内容的印象非常深刻。我梳理了一些要点,分享给你。如果你已经是DevOps产品经理,或者是立志要成为DevOps产品经理的话,你一定要认真看一下。 + +一、自我颠覆 + +什么叫自我颠覆呢?我给你举个例子。比如,Jenkins的用户UI项目Blue Ocean,很多人应该都知道,目前这个项目的主要开发已经停止了。社区仍然会修复缺陷和安全漏洞,也会接受开发者共享的PR,但是不会再投入专职工程师进行开发工作了,新需求也都处于无限暂停的状态。 + +实际上,不仅仅是Blue Ocean,去年Jenkins大会上星光闪耀的项目,比如Five super power、Jolt in Jenkins、Evergreen等项目,也都因为方向调整和人员变动而处于半终止、暂缓开发的状态。那么,为什么在短短一年的时间内,会有这么大的颠覆性变化呢?我把这个问题抛给了Jeremy。 + +他的观点是,这些项目并非没有意义,但是确实没有达到项目原本的预期。对于产品经理来说,管理预期是一项非常重要的能力。当需求走到产品经理的时候,做哪个、不做哪个经常是个问题。团队往往会进行协商,挑选出来最有希望的项目,但这并不代表这些项目注定会成功。相反,很多想法只有做了才知道是不是靠谱,用户是不是买单。如果使用场景有限,又没有很好的增长性,及时叫停反而是一种好的选择。 + +Blue Ocean项目诞生之初,可以说是让人眼前一亮,充满期待,甚至一度和Jenkins流水线一起被视为2.0版本的最大功能。但是几年之后,由于产品性能、插件扩展支持等种种原因,真正在企业中大规模使用的机会并不多。正因为项目没有达到预期,产品团队就决定停止这个项目。 + +但是与此同时,全新的Jenkins用户界面项目已经被提到了日程表中。这个全新的用户界面大量借鉴了Blue Ocean的设计思路,并最终通过一套用户界面,取代了现有的Blue Ocean。我想,正是这种不断的自我颠覆,才让一个15年的软件始终保持着活力和创新力。 + +二、化繁为简 + +对于Jenkins这样的产品来说,很多插件都是开发者提供的,但是开发者往往倾向于追求功能的全面性,这从很多插件的设计中就能看出来。 + +开发者不加筛选地把所有功能都罗列在用户面前,自然是得心应手。但是,对普通用户来说,当他第一眼看到这个复杂产品的时候,他的使用意愿就会大打折扣。 + +另外,面对这么多的插件,从表面上看,用户好像有很多选择,但是,有些插件的名字长得差不多,你并不知道哪个能用。或者,有些插件适用于当前的Jenkins版本,但是一旦Jenkins升级,它们就无法正常使用了。但是,用户在升级之前并不知道是否适配,往往是在升级完成之后才会发现问题,只能再进行版本回滚。类似这些插件使用中的问题,都给用户带来了很大的使用障碍。 + +在探讨这个问题的时候,Jeremy也认为,系统过于复杂有悖于产品设计的初衷,但是,作为一个公开的平台,他们并不能约束开发者的行为,所以就需要一种方法来平衡功能的全面性和功能的易用性。 + +比如,在重新考虑Jenkins插件生态的时候,一方面,产品团队会针对全新的业务场景提供官方的插件支持。举个例子,在云原生开发场景下,通过和云服务商深度合作,提供更多的官方插件,来满足典型的云服务商的使用场景。无论是对亚马逊的AWS、微软的Azure,还是未来国内的主流云服务商,他们都会通过这种方式来进行合作。无论你使用的开源产品,还是商业产品,都能通过这个项目来获得收益。 + +另一方面,产品团队也会进一步对现有的1600多款插件进行分类,并将其中的一部分插件纳入CloudBees的保障项目之下。这就意味着,将由CloudBees公司来保证这类插件的兼容性和可用性。对于专业用户来说,他们依然可以按照自己的方式自由地选择和开发插件,而对于普通用户来说,官方推荐的插件集合就足够了。 + +不仅仅是插件,产品的易用性体现在产品设计的方方面面。凡是阻塞用户使用的问题,都是需要优先解决的。 + +比如,对于一个10多年的产品来说,历史积累的文档数量巨大,很多时候,用户都无法找到真正有用的信息。所以,Jenkins产品团队启动了一个文档治理的项目,会重新梳理所有文档,并把它们迁移到GitHub平台上。另外,他们还会结合新的产品功能,整理出最佳实践。比如,对于流水线使用来说,官方也总结了很多最佳实践供入门者参考,你可以结合前面两讲的内容一起学习。 + +要始终记得,不要让你的产品只有专家才会使用。将复杂的问题简单化,是产品经理不论何时都要思考的问题。 + +三、退后一步 + +DevOps的产品经理大多是技术人员出身,因此会特别容易一上来就深入细节,甚至是代码实现的细节。 + +Jeremy同样也是程序员出身,他做过很长一段时间的前端开发。当我问他“一个好的产品应该如何平衡用户视角和实现视角”的时候,他给我的回答是,要尽量退后一步来看问题。 + +退后一步,就是说不要把关注点只聚焦在问题表面,而是要尽量站在旁边,以第三方的视角来全面审视问题。 + +他举了个Jenkins的流水线即代码的例子。在实际使用的时候,流水线文件中经常会有大量的代码,有时候,流水线代码甚至会有上千行。代码越多,系统的不稳定因素就越多,测试起来也越麻烦。同时,按照现有的运行机制来说,很多代码都是运行在master节点上的,这就给集群的master节点带来了很大压力。 + +要想解决这个问题,从实现的角度出发,就是提供一种标准化、结构化的语法格式,也就是声明式流水线语法,以此来降低流水线的编写难度,减少流水线代码量,并且让这个代码结构更加清晰。但是,这些优化依然不能解决集群master节点压力过大的问题,这就相当于问题只看了一部分。 + +退后一步来看,这就需要一种全新的视角,来提升流水线整体的隔离性。所以,产品团队目前就在设计一种新的流水线组件 building block,也就是构建块。 + +所谓构建块,是指一整块的代码片段,而不是一条条独立的指令。这些构建块结合到一起,就可以满足一个具体场景的问题。比如Maven打包构建的场景,构建块可以帮你解决环境、工具、构建命令等一系列问题。这些构建块以代码形式在子节点上运行,既降低了流水线的编写难度,也缓解了master节点上的压力。对用户来说,使用构建块也更为简单,可以直接把它放在自定义的步骤中执行。 + +对于产品经理来说,找到方案、解决问题自然是职责所在,但与此同时,他们往往需要同时保有两种思维,即用户思维和实现思维。能够在这两种思维之间自由切换,是产品经理走向成熟的标志。 + +总结 + +说到这儿,我来回答一下最开始的那个问题,也就是“为什么是产品经理来分享产品的规划呢?”这是因为,无论要开发一个多大还是多小的产品,都需要有这样一拨人来退后一步,找到用户的真实问题,化繁为简,实现这个功能,并不断颠覆自己,持续打磨和改进。这对于任何一个想要解决更多人问题的产品来说,都是至关重要的。 + +思考讨论 + +关于这次Jenkins World大会,你还有什么希望进一步了解的内容吗?欢迎你积极提问,我会知无不言。 + +如果你觉得这篇文章对你有所帮助,也欢迎你把文章分享给你的朋友。 + + + + \ No newline at end of file diff --git a/专栏/DevOps实战笔记/结束语持续改进,成就非凡!.md b/专栏/DevOps实战笔记/结束语持续改进,成就非凡!.md new file mode 100644 index 0000000..c0626f2 --- /dev/null +++ b/专栏/DevOps实战笔记/结束语持续改进,成就非凡!.md @@ -0,0 +1,65 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 持续改进,成就非凡! + 不知道你是否看过或者听说过《中国好声音》这个节目?在这个节目中,导师总会发出“灵魂拷问”:“你的梦想是什么?” + +和很多“80后”的男孩子一样,我最初的梦想就是当一名飞行员,翱翔天空。但是随着视力越来越差,身体越长越高,我才发现,并非所有的梦想都能实现。好在我还留了一手,因为我还有另外一个梦想,那就是当一名老师。现在,我的这个梦想已经在极客时间上实现了。 + +为什么想当老师呢?说真的,我也不记得当初是怎么想的了,可能是因为在中小学生眼中,老师这个形象都是霸气侧漏的。但随着年龄的增大,我越发觉得,当老师这个事情真的没有那么容易。你应该也听说过“教学相长”这个词,但你有没有想过,“教”为什么在“学”的前面,是“教学相长”,而不是“学教相长”呢? + +或许,只有当你的身份从一名学生变为一位老师的时候,你才能真的想明白这个问题。实际上,很多时候,教的人可能比学的人收获要大得多。为什么这么说呢? + +任何一门课程,任何一个知识点,你在学的时候可以不懂,大不了就当没听过,等到真正用到的时候,临时再学也是可以的。但是,作为老师,你不仅要懂,还要逻辑清晰、思维缜密,甚至要尽可能地用有趣的方式把别人教会,这可就没那么简单了。 + +不过,任何一个知识领域都是博大精深的,你不可能对每一个细节都了如指掌,这就会逼着你不断学习、不断思考、不断精进。我想,这就是输出式学习之所以高效的奥秘所在。 + +对于专栏写作来说,这个道理也同样适用。几个月的持续输出,无论是对精力、体力,还是家庭和谐力都是一场漫长的试炼。在专栏完结的时间点,我看到的不仅仅是20万字的内容,更多的是自己身上的不足,而这些都是我成长道路上的灯塔,指引我面向未来,持续精进。 + +在最后,我想给你分享我在专栏写作中的三个心得,希望这些心得可以帮助你在未来的学习道路上披荆斩棘,无往而不利。 + +当你跨越技术领域的门槛之后,知识的体系化程度就成了决定你未来发展高度的一个重要因素。只有建立了自己的知识体系,并不断地吸收外界精华,你才能让这些知识和经验在身体内不断循环、沉淀,并最终成为你的一部分。这也是写作专栏几个月以来,我想给你分享的第一个心得:建立自己的知识体系,持续进行输出式学习。 + +对于一篇专栏的写作来说,你知道什么时间点最可怕吗?那就是当你打开一个空白的文档,却不知道第一个字应该写什么的时候。这跟我们平时的工作是很相似的,你知道这件事要做到什么程度,可就是不知道该如何开始。脑子里思绪万千,身体的疲劳有时还在同你作对,当你在不断地自我怀疑的时候,时间却悄悄地跑掉了,而你终究还是得自己面对这个问题。这该如何是好呢? + +有句经典的话大概是这么说的:“一件事情,当你不想去做的时候,理由可以有一百个,但是当你决定做的时候,理由只有一个,那就是做。”很多事情并没有你想象的那么困难。我们不是科学家,也不是要解决人类的未解之谜,我们面对的都是身边的问题。我们之所以觉得这些事情很难,缺少的往往不是能力、经验和学识,而是“先干再说”的勇气和信心。因为只要开始做了,你就已经成功一半了。对于DevOps这种改进类工作来说,更是如此,你要先想尽一切办法完成它,有机会再追求完美,这可比一开始就全盘规划要实际得多。这也是我想分享给你的第二个心得:完成比完美更重要,很多事情可以先干再说。 + +我们家也有一句特别经典的话,那就是有日子就快。这句话的意思就是,对于一件事情,你只要确定了里程碑,时间就会带领你快速地抵达那个终点。比如,对于一个项目的推进来说,事先看见全貌和里程碑节点就是至关重要的。在专栏的写作过程中,我认为最最重要的一份素材,就是编辑同学帮我整理的《专栏发布排期计划》,里面注明了我每星期、每天需要完成的任务。 + +虽然计划就是为了被打破而存在的,它永远也赶不上变化,尤其是在软件的世界里,Delay似乎是一件不可避免的事情,但是,这个计划存在的目的是帮你守住一件事情的下限。既然最差也就如此了,多做一点就多一点成功,那你又何必纠结和焦虑呢?所以,在推进项目的时候,尤其是在依赖多人协作的时候,一个清晰的项目计划至关重要。这恰恰是我想分享给你的第三个心得:让计划帮你守住底线,让行动为成功添砖加瓦。 + +我想,此时此刻还在坚持看下去、听下去的你内心里一定有一团火焰,激励着自己有朝一日可以脱颖而出。因此,在最后的最后,我特别想给你分享一些我个人的职业生涯发展的经验,这也是帮助我从一个默默无闻的小兵成长为极客时间作者的秘密。 + +1.找到自己适合的领域 + +要知道,并非所有人都适合所有领域。有的人天生就是编程高手,有的人天生就爱与人沟通,与其在你不擅长的领域死磕,不如找到自己擅长的领域并不断深耕。与此同时,要以这个领域为起点,不断向外扩展,营造自己的“护城河”体系,提升自己的专业素养。这些是你将来安身立命的本事,你一定要让自己有几个拿得出手的核心技能。如果你现在还答不上来你擅长的领域和核心技能是什么,那么2020年,请继续努力。 + +2.打造自己的专属标签 + +当你掌握了一门核心手艺之后,你可以在这个圈子里不断地总结和分享,建立起别人对你的初始认知。只要你用心,你就会发现,这种分享的机会其实有很多,如果你苦于没有途径,欢迎你来找我。 + +不过,这还并不足以让你脱颖而出,最多也只能达到平均水准。这时候,你需要的就是等待一个机会,比如一门新技术、一种新思想、一个新工具,什么都可以。然后,你要快速地抓住这个机会,让自己站在第一线,去分享,去实践,去布道,让它成为你的专属标签。 + +3.不断积累成功,打造自己的良好口碑 + +你要知道,有一种能力,叫作“让别人相信你”的能力。企业在为某个职位寻找合适的人选时,为什么选你而不选别人呢?除了你自身过硬的技术素养之外,你能不能让别人相信你的能力,是你能否突破天花板的重要因素。那这种能力从何而来呢?我认为,这是来自于过往点滴的积累,最终由量变产生的质变。所以,请你善待每一个机会,善待每一个人。在企业中,要么提升自己的执行力,要么提升自己的创新力,要么让自己能够快速地整合资源,只有这样,你才能具备成功的资本。 + +4.保持责任心、进取心和事业心 + +不管做什么事情,最重要的就是责任心,要把自己该做的事情做好,做正确的事情,而不仅仅是KPI要求的事情。另外,你要保持进取心,并且对新事物、新技术保持长久的好奇和开放的心态,而不是故步自封,局限在自己的一亩三分地上。如果可能的话,要把自己的工作视为一个事业,你要保持着“每一行代码都是你的名片,每一个产品都是你的代言人”的信念,和团队一起努力,共同成长,只要还有一丝改进空间,就不要轻言放弃。 + +正如这篇文章的标题所说的,只有持续改进,才能成就非凡,也必将不枉此生。 + +希望这个专栏能够带给你一些灵感和新知,不管怎样,感谢你陪我一起走过2019年的夏天、秋天和冬天。 + +最后,我给你准备了一份调研问卷,欢迎你点击下面的图片,去填写问卷,给我和专栏提供一些宝贵的建议,期待你的反馈。 + + + +P.S. 最最后,感谢我的夫人在这半年里给予我的无私“支持和理解”,如果不是她,这个专栏早就写完了。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/00开篇词深入掌握Dubbo原理与实现,提升你的职场竞争力.md b/专栏/Dubbo源码解读与实战-完/00开篇词深入掌握Dubbo原理与实现,提升你的职场竞争力.md new file mode 100644 index 0000000..e72b3a7 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/00开篇词深入掌握Dubbo原理与实现,提升你的职场竞争力.md @@ -0,0 +1,110 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力 + 你好,我是杨四正,接下来一段时间我们会一起来探究 Dubbo。 + +我曾在电商、新零售、短视频、直播等领域的多家互联网企业任职,期间我在业务线没日没夜地“搬过砖”,在基础组件部门“造过轮子”,也在架构部门搞过架构设计,目前依旧在从事基础架构的相关工作,主要负责公司的 Framework、RPC 框架、数据库中间件等方向的开发和运维工作。我深入研究过多个开源中间件,平时喜欢以文会友,分享源码分析的经验和心得。 + +为什么要学习 Dubbo + +我们在谈论任何一项技术的时候,都需要强调它所适用的业务场景,因为: 技术之所以有价值,就是因为它解决了一些业务场景难题。 + +一家公司由小做大,业务会不断发展,随之而来的是 DAU、订单量、数据量的不断增长,用来支撑业务的系统复杂度也会不断提高,模块之间的依赖关系也会日益复杂。这时候我们一般会从单体架构进入集群架构(如下图所示),在集群架构中通过负载均衡技术,将流量尽可能均摊到集群中的每台机器上,以此克服单台机器硬件资源的限制,做到横向扩展。 + + + +单体架构 VS 集群架构 + +之后,又由于业务系统本身的实现较为复杂、扩展性较差、性能也有上限,代码和功能的复用能力较弱,我们会将一个巨型业务系统拆分成多个微服务,根据不同服务对资源的不同要求,选择更合理的硬件资源。例如,有些流量较小的服务只需要几台机器构成的集群即可,而核心业务则需要成百上千的机器来支持,这样就可以最大化系统资源的利用率。 + +另外一个好处是,可以在服务维度进行重用,在需要某个服务的时候,直接接入即可,从而提高开发效率。拆分成独立的服务之后(如下图所示),整个服务可以最大化地实现重用,也可以更加灵活地扩展。 + + + +微服务架构图 + +但是在微服务架构落地的过程中,我们需要解决的问题有很多,如: + + +服务之间如何高性能地通信? +服务调用如何做到负载均衡、FailOver、限流? +如何有效地划清服务边界? +如何进行服务治理? +…… + + +Apache Dubbo是一款高性能、轻量级的开源 Java RPC 框架,它提供了三大核心能力: + + +面向接口的远程方法调用; +可靠、智能的容错和负载均衡; +服务自动注册和发现能力。 + + +简单地说, Dubbo 是一个分布式服务框架,致力于提供高性能、透明化的 RPC 远程服务调用方案以及服务治理方案,以帮助我们解决微服务架构落地时的问题。 + +Dubbo 是由阿里开源,后来加入了 Apache 基金会,目前已经从孵化器毕业,成为 Apache 的顶级项目。Apache Dubbo 目前已经有接近 32.8 K 的 Star、21.4 K 的 Fork,其热度可见一斑, 很多互联网大厂(如阿里、滴滴、去哪儿网等)都是直接使用 Dubbo 作为其 RPC 框架,也有些大厂会基于 Dubbo 进行二次开发实现自己的 RPC 框架 ,如当当网的 DubboX。 + +作为一名 Java 工程师,深入掌握 Dubbo 的原理和实现已经是大势所趋,并且成为你职场竞争力的关键项。拉勾网显示,研发工程师、架构师等高薪岗位,都要求你熟悉并曾经深入使用某种 RPC 框架,一线大厂更是要求你至少深入了解一款 RPC 框架的原理和核心实现。 + + + + + + + +(职位信息来源:拉勾网) + +而 Dubbo 就是首选。Dubbo 和 Spring Cloud 是目前主流的微服务框架,阿里、京东、小米、携程、去哪儿网等互联网公司的基础设施早已落成,并且后续的很多项目还是以 Dubbo 为主。Dubbo 重启之后,已经开始规划 3.0 版本,相信后面还会有更加惊艳的表现。 + +另外,RPC 框架的核心原理和设计都是相通的,阅读过 Dubbo 源码之后,你再去了解其他 RPC 框架的代码,就是一件非常简单的事情了。 + +阅读 Dubbo 源码的痛点 + +学习和掌握一项技能的时候,一般都是按照“是什么”“怎么用”“为什么”(原理)逐层深入的: + + + +同样,你可以通过阅读官方文档或是几篇介绍性的文章,迅速了解 Dubbo 是什么;接下来,再去上手,用 Dubbo 写几个项目,从而更加全面地熟悉 Dubbo 的使用方式和特性,成为一名“熟练工”,但这也是很多开发者所处的阶段。而“有技术追求”的开发者,一般不会满足于每天只是写写业务代码,而是会开始研究 Dubbo 的源码实现以及底层原理,这就对应了上图中的核心层:“原理”。 + +而开始阅读源码时,不少开发者会提前去网上查找资料,或者直接埋头钻研源码,并因为这样的学习路径而普遍面临一些痛点问题: + + +网络资料不少,但大多是复制 Dubbo 官方文档,甚至干脆就是粘贴了一堆 Dubbo 源码过来,没有任何自己的个人实践和经验分享,学习花费精力不说,收获却不大。 +相关资料讲述的 Dubbo 版本比较陈旧,没有跟上最新的设计和优化,有时候还会误导你。或者切入点很小,只针对 Dubbo 的一个流程进行介绍,看完之后,你只知道这一条调用分支上的相关内容,代码一旦运行到其他地方,还是一脸懵。 +若抛开参考资料,自己直接去阅读 Dubbo 源码,你本身又需要具备一定的技术功底,而且要对整个开源项目有比较高的熟练度,这样你才能够循着它的核心逻辑去快速掌握它。而对于一个相对陌生的开源项目来说,这可能就是一个非常痛苦的过程了,并且最致命的是,由于对整个架构的“视野”受限,你很可能会迷失在代码迷宫中,最后虽然也花了很大力气去阅读和 Debug 源码,却在关上 IDEA 之后依然“雾里看花”。 + + +课程设置 + +我曾经分享过各种开源项目的源码分析资料,并且收到大家的一致好评,所以我决定和拉勾教育合作,开设一个系列课程,根据自己丰富的开源项目分析经验来带你一起阅读 Dubbo 源码,希望帮你做到融会贯通,并在实践中能够举一反三。 + +具体来说,在这个课程中我会: + + +从基础知识开始,通过丰富的 Demo 演示,手把手带你分析 Dubbo 涉及的核心知识点。之后再带你使用这些核心技术,通过编写一个简易版本的 RPC 框架串联所有知识点。 +带你自底向上剖析 Dubbo 的源码,深入理解 Dubbo 的工作原理及核心实现,让你不再停留在简单使用 Dubbo 的阶段,做到知其然,也知其所以然。例如,Provider 是如何将服务发布到注册中心的、Consumer 是如何从注册中心订阅服务的,等等问题都可以在这里找到解答。 +点名 Dubbo 源码中的设计模式,让你了解设计模式的优秀实践方式,帮助你从“纸上谈兵”变成“用兵如神”,这样在你进行架构设计以及代码编写的时候,就可以真正使用这些设计模式,让你的代码扩展性更强、可维护性更好。 +带你领略 Dubbo 2.7.5 版本之后的最新优化和设计,让你紧跟时代潮流,更好地反馈到工作实践中。 + + +本课程的每一个知识点都是你深入理解 Dubbo 的进步阶梯,整个分析 Dubbo 实现的过程,就是一步步到达山顶,成为高手的过程。你也可以通过目录,快速了解这个课程的知识体系结构。 + + + +讲师寄语 + +最后,我想和你说的是: 沉迷于代码,但不要只沉迷于代码。 + +阅读源码的目的是提升自身的技术能力,而提升技术能力的目的是更好地支持业务。阅读源码不是终点,你还需要结合实际业务,更好地体会开源项目的设计理念,并将这种设计应用到实践中。 + +让我们开启一次紧张刺激的 Dubbo 探秘之旅!我也希望你能在留言区与我分享你的 Dubbo 学习情况,分享你的成长心得和学习痛点,学习不是单向的输出,而是一次交流反馈的过程!加油。 + +为便于你更好地学习,我将整个 Dubbo 的源码(带注释的)放到 GitHub 上了,你可以按需查看:https://github.com/xxxlxy2008/dubbo。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/01Dubbo源码环境搭建:千里之行,始于足下.md b/专栏/Dubbo源码解读与实战-完/01Dubbo源码环境搭建:千里之行,始于足下.md new file mode 100644 index 0000000..9bb8b82 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/01Dubbo源码环境搭建:千里之行,始于足下.md @@ -0,0 +1,396 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 Dubbo 源码环境搭建:千里之行,始于足下 + 好的开始是成功的一半,阅读源码也是一样。 很多同学在下定决心阅读一个开源框架之后,就一头扎进去,迷失在代码“迷宫”中。此时,有同学意识到,需要一边 Debug 一边看;然后又有一批同学在搭建源码环境的时候兜兜转转,走上了放弃之路;最后剩下为数不多的同学,搭建完了源码环境,却又不知道如何模拟请求让源码执行到自己想要 Debug 的地方。 + +以上这些痛点问题你是不是很熟悉?是不是也曾遇到过?没关系,本课时我就来手把手带领你搭建 Dubbo 源码环境。 + + +在开始搭建源码环境之前,我们会先整体过一下 Dubbo 的架构,这可以帮助你了解 Dubbo 的基本功能以及核心角色。 +之后我们再动手搭建 Dubbo 源码环境,构建一个 Demo 示例可运行的最简环境。 +完成源码环境搭建之后,我们还会深入介绍 Dubbo 源码中各个核心模块的功能,这会为后续分析各个模块的实现做铺垫。 +最后,我们再详细分析下 Dubbo 源码自带的三个 Demo 示例,简单回顾一下 Dubbo 的基本用法,这三个示例也将是我们后续 Debug 源码的入口。 + + +Dubbo 架构简介 + +为便于你更好理解和学习,在开始搭建 Dubbo 源码环境之前,我们先来简单介绍一下 Dubbo 架构中的核心角色,帮助你简单回顾一下 Dubbo 的架构,也帮助不熟悉 Dubbo 的小伙伴快速了解 Dubbo。下图展示了 Dubbo 核心架构: + + + +Dubbo 核心架构图 + + +Registry:注册中心。 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。 +Provider:服务提供者。 在它启动的时候,会向 Registry 进行注册操作,将自己服务的地址和相关配置信息封装成 URL 添加到 ZooKeeper 中。 +Consumer:服务消费者。 在它启动的时候,会向 Registry 进行订阅操作。订阅操作会从 ZooKeeper 中获取 Provider 注册的 URL,并在 ZooKeeper 中添加相应的监听器。获取到 Provider URL 之后,Consumer 会根据负载均衡算法从多个 Provider 中选择一个 Provider 并与其建立连接,最后发起对 Provider 的 RPC 调用。 如果 Provider URL 发生变更,Consumer 将会通过之前订阅过程中在注册中心添加的监听器,获取到最新的 Provider URL 信息,进行相应的调整,比如断开与宕机 Provider 的连接,并与新的 Provider 建立连接。Consumer 与 Provider 建立的是长连接,且 Consumer 会缓存 Provider 信息,所以一旦连接建立,即使注册中心宕机,也不会影响已运行的 Provider 和 Consumer。 +Monitor:监控中心。 用于统计服务的调用次数和调用时间。Provider 和 Consumer 在运行过程中,会在内存中统计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。监控中心在上面的架构图中并不是必要角色,监控中心宕机不会影响 Provider、Consumer 以及 Registry 的功能,只会丢失监控数据而已。 + + +搭建Dubbo源码环境 + +当然,要搭建Dubbo 源码环境,你首先需要下载源码。这里你可以直接从官方仓库 https://github.com/apache/dubboFork 到自己的仓库,直接执行下面的命令去下载代码: + +git clone [email protected]:xxxxxxxx/dubbo.git + + +然后切换分支,因为目前最新的是 Dubbo 2.7.7 版本,所以这里我们就用这个新版本: + +git checkout -b dubbo-2.7.7 dubbo-2.7.7 + + +接下来,执行 mvn 命令进行编译: + +mvn clean install -Dmaven.test.skip=true + + +最后,执行下面的命令转换成 IDEA 项目: + +mvn idea:idea // 要是执行报错,就执行这个 mvn idea:workspace + + +然后,在 IDEA 中导入源码,因为这个导入过程中会下载所需的依赖包,所以会耗费点时间。 + +Dubbo源码核心模块 + +在 IDEA 成功导入 Dubbo 源码之后,你看到的项目结构如下图所示: + + + +下面我们就来简单介绍一下这些核心模块的功能,至于详细分析,在后面的课时中我们还会继续讲解。 + + +dubbo-common 模块: Dubbo 的一个公共模块,其中有很多工具类以及公共逻辑,例如课程后面紧接着要介绍的 Dubbo SPI 实现、时间轮实现、动态编译器等。 + + + + + +dubbo-remoting 模块: Dubbo 的远程通信模块,其中的子模块依赖各种开源组件实现远程通信。在 dubbo-remoting-api 子模块中定义该模块的抽象概念,在其他子模块中依赖其他开源组件进行实现,例如,dubbo-remoting-netty4 子模块依赖 Netty 4 实现远程通信,dubbo-remoting-zookeeper 通过 Apache Curator 实现与 ZooKeeper 集群的交互。 + + + + + +dubbo-rpc 模块: Dubbo 中对远程调用协议进行抽象的模块,其中抽象了各种协议,依赖于 dubbo-remoting 模块的远程调用功能。dubbo-rpc-api 子模块是核心抽象,其他子模块是针对具体协议的实现,例如,dubbo-rpc-dubbo 子模块是对 Dubbo 协议的实现,依赖了 dubbo-remoting-netty4 等 dubbo-remoting 子模块。 dubbo-rpc 模块的实现中只包含一对一的调用,不关心集群的相关内容。 + + + + + +dubbo-cluster 模块: Dubbo 中负责管理集群的模块,提供了负载均衡、容错、路由等一系列集群相关的功能,最终的目的是将多个 Provider 伪装为一个 Provider,这样 Consumer 就可以像调用一个 Provider 那样调用 Provider 集群了。 +dubbo-registry 模块: Dubbo 中负责与多种开源注册中心进行交互的模块,提供注册中心的能力。其中, dubbo-registry-api 子模块是顶层抽象,其他子模块是针对具体开源注册中心组件的具体实现,例如,dubbo-registry-zookeeper 子模块是 Dubbo 接入 ZooKeeper 的具体实现。 + + + + + +dubbo-monitor 模块: Dubbo 的监控模块,主要用于统计服务调用次数、调用时间以及实现调用链跟踪的服务。 +dubbo-config 模块: Dubbo 对外暴露的配置都是由该模块进行解析的。例如,dubbo-config-api 子模块负责处理 API 方式使用时的相关配置,dubbo-config-spring 子模块负责处理与 Spring 集成使用时的相关配置方式。有了 dubbo-config 模块,用户只需要了解 Dubbo 配置的规则即可,无须了解 Dubbo 内部的细节。 + + + + + +dubbo-metadata 模块: Dubbo 的元数据模块(本课程后续会详细介绍元数据的内容)。dubbo-metadata 模块的实现套路也是有一个 api 子模块进行抽象,然后其他子模块进行具体实现。 + + + + + +dubbo-configcenter 模块: Dubbo 的动态配置模块,主要负责外部化配置以及服务治理规则的存储与通知,提供了多个子模块用来接入多种开源的服务发现组件。 + + + + +Dubbo 源码中的 Demo 示例 + +在 Dubbo 源码中我们可以看到一个 dubbo-demo 模块,共包括三个非常基础 的 Dubbo 示例项目,分别是: 使用 XML 配置的 Demo 示例、使用注解配置的 Demo 示例 以及 直接使用 API 的 Demo 示例 。下面我们将从这三个示例的角度,简单介绍 Dubbo 的基本使用。同时,这三个项目也将作为后续 Debug Dubbo 源码的入口,我们会根据需要在其之上进行修改 。不过在这儿之前,你需要先启动 ZooKeeper 作为注册中心,然后编写一个业务接口作为 Provider 和 Consumer 的公约。 + +启动 ZooKeeper + +在前面 Dubbo 的架构图中,你可以看到 Provider 的地址以及配置信息是通过注册中心传递给 Consumer 的。 Dubbo 支持的注册中心尽管有很多, 但在生产环境中, 基本都是用 ZooKeeper 作为注册中心 。因此,在调试 Dubbo 源码时,自然需要在本地启动 ZooKeeper。 + +那怎么去启动 ZooKeeper 呢? + +首先,你得下载 zookeeper-3.4.14.tar.gz 包(下载地址: https://archive.apache.org/dist/zookeeper/zookeeper-3.4.14/)。下载完成之后执行如下命令解压缩: + +tar -zxf zookeeper-3.4.14.tar.gz + + +解压完成之后,进入 zookeeper-3.4.14 目录,复制 conf/zoo_sample.cfg 文件并重命名为 conf/zoo.cfg,之后执行如下命令就可以启动 ZooKeeper了。 + +>./bin/zkServer.sh start + +# 下面为输出内容 + +ZooKeeper JMX enabled by default + +Using config: /Users/xxx/zookeeper-3.4.14/bin/../conf/zoo.cfg # 配置文件 + +Starting zookeeper ... STARTED # 启动成功 + + +业务接口 + +在使用 Dubbo 之前,你还需要一个业务接口,这个业务接口可以认为是 Dubbo Provider 和 Dubbo Consumer 的公约,反映出很多信息: + + +Provider ,如何提供服务、提供的服务名称是什么、需要接收什么参数、需要返回什么响应; +Consumer ,如何使用服务、使用的服务名称是什么、需要传入什么参数、会得到什么响应。 + + +dubbo-demo-interface 模块就是定义业务接口的地方,如下图所示: + + + +其中,DemoService 接口中定义了两个方法: + +public interface DemoService { + + String sayHello(String name); // 同步调用 + + // 异步调用 + + default CompletableFuture sayHelloAsync(String name) { + + return CompletableFuture.completedFuture(sayHello(name)); + + } + +} + + +Demo 1:基于 XML 配置 + +在 dubbo-demo 模块下的 dubbo-demo-xml 模块,提供了基于 Spring XML 的 Provider 和 Consumer。 + +我们先来看 dubbo-demo-xml-provider 模块,其结构如下图所示: + + + +在其 pom.xml 中除了一堆 dubbo 的依赖之外,还有依赖了 DemoService 这个公共接口: + + + + org.apache.dubbo + + dubbo-demo-interface + + ${project.parent.version} + + + + +DemoServiceImpl 实现了 DemoService 接口,sayHello() 方法直接返回一个字符串,sayHelloAsync() 方法返回一个 CompletableFuture 对象。 + +在 dubbo-provider.xml 配置文件中,会将 DemoServiceImpl 配置成一个 Spring Bean,并作为 DemoService 服务暴露出去: + + + + + + + + + + +还有就是指定注册中心地址(就是前面 ZooKeeper 的地址),这样 Dubbo 才能把暴露的 DemoService 服务注册到 ZooKeeper 中: + + + + + + +最后,在 Application 中写个 main() 方法,指定 Spring 配置文件并启动 ClassPathXmlApplicationContext 即可。 + +接下来再看 dubbo-demo-xml-consumer 模块,结构如下图所示: + + + +在 pom.xml 中同样依赖了 dubbo-demo-interface 这个公共模块。 + +在 dubbo-consumer.xml 配置文件中,会指定注册中心地址(就是前面 ZooKeeper 的地址),这样 Dubbo 才能从 ZooKeeper 中拉取到 Provider 暴露的服务列表信息: + + + + + + +还会使用 dubbo:reference 引入 DemoService 服务,后面可以作为 Spring Bean 使用: + + + + + + +最后,在 Application 中写个 main() 方法,指定 Spring 配置文件并启动 ClassPathXmlApplicationContext 之后,就可以远程调用 Provider 端的 DemoService 的 sayHello() 方法了。 + +Demo 2:基于注解配置 + +dubbo-demo-annotation 模块是基于 Spring 注解配置的示例,无非就是将 XML 的那些配置信息转移到了注解上。 + +我们先来看 dubbo-demo-annotation-provider 这个示例模块: + +public class Application { + + public static void main(String[] args) throws Exception { + + // 使用AnnotationConfigApplicationContext初始化Spring容器, + + // 从ProviderConfiguration这个类的注解上拿相关配置信息 + + AnnotationConfigApplicationContext context = + + new AnnotationConfigApplicationContext( + + ProviderConfiguration.class); + + context.start(); + + System.in.read(); + + } + + @Configuration // 配置类 + + // @EnableDubbo注解指定包下的Bean都会被扫描,并做Dubbo服务暴露出去 + + @EnableDubbo(scanBasePackages = "org.apache.dubbo.demo.provider") + + // @PropertySource注解指定了其他配置信息 + + @PropertySource("classpath:/spring/dubbo-provider.properties") + + static class ProviderConfiguration { + + @Bean + + public RegistryConfig registryConfig() { + + RegistryConfig registryConfig = new RegistryConfig(); + + registryConfig.setAddress("zookeeper://127.0.0.1:2181"); + + return registryConfig; + + } + + } + +} + + +这里,同样会有一个 DemoServiceImpl 实现了 DemoService 接口,并且在 org.apache.dubbo.demo.provider 目录下,能被扫描到,暴露成 Dubbo 服务。 + +接着再来看 dubbo-demo-annotation-consumer 模块,其中 Application 中也是通过 AnnotationConfigApplicationContext 初始化 Spring 容器,也会扫描指定目录下的 Bean,会扫到 DemoServiceComponent 这个 Bean,其中就通过 @Reference 注解注入 Dubbo 服务相关的 Bean: + +@Component("demoServiceComponent") + +public class DemoServiceComponent implements DemoService { + + @Reference // 注入Dubbo服务 + + private DemoService demoService; + + @Override + + public String sayHello(String name) { + + return demoService.sayHello(name); + + } + + // 其他方法 + +} + + +Demo 3:基于 API 配置 + +在有的场景中,不能依赖于 Spring 框架,只能使用 API 来构建 Dubbo Provider 和 Consumer,比较典型的一种场景就是在写 SDK 的时候。 + +先来看 dubbo-demo-api-provider 模块,其中 Application.main() 方法是入口: + +// 创建一个ServiceConfig的实例,泛型参数是业务接口实现类, + +// 即DemoServiceImpl + +ServiceConfig service = new ServiceConfig<>(); + +// 指定业务接口 + +service.setInterface(DemoService.class); + +// 指定业务接口的实现,由该对象来处理Consumer的请求 + +service.setRef(new DemoServiceImpl()); + +// 获取DubboBootstrap实例,这是个单例的对象 + +DubboBootstrap bootstrap = DubboBootstrap.getInstance(); + +//生成一个 ApplicationConfig 的实例、指定ZK地址以及ServiceConfig实例 + +bootstrap.application(new ApplicationConfig("dubbo-demo-api-provider")) + + .registry(new RegistryConfig("zookeeper://127.0.0.1:2181")) + + .service(service) + + .start() + + .await(); + + +这里,同样会有一个 DemoServiceImpl 实现了 DemoService 接口,并且在 org.apache.dubbo.demo.provider 目录下,能被扫描到,暴露成 Dubbo 服务。 + +再来看 dubbo-demo-api-consumer 模块,其中 Application 中包含一个普通的 main() 方法入口: + + // 创建ReferenceConfig,其中指定了引用的接口DemoService + + ReferenceConfig reference = new ReferenceConfig<>(); + + reference.setInterface(DemoService.class); + + reference.setGeneric("true"); + + + + // 创建DubboBootstrap,指定ApplicationConfig以及RegistryConfig + + DubboBootstrap bootstrap = DubboBootstrap.getInstance(); + + bootstrap.application(new ApplicationConfig("dubbo-demo-api-consumer")) + + .registry(new RegistryConfig("zookeeper://127.0.0.1:2181")) + + .reference(reference) + + .start(); + + // 获取DemoService实例并调用其方法 + + DemoService demoService = ReferenceConfigCache.getCache() + + .get(reference); + + String message = demoService.sayHello("dubbo"); + + System.out.println(message); + + +总结 + +在本课时,我们首先介绍了 Dubbo 的核心架构以及各核心组件的功能,接下来又搭建了 Dubbo 源码环境,并详细介绍了 Dubbo 核心模块的功能,为后续分析 Dubbo 源码打下了基础。最后我们还深入分析了 Dubbo 源码中自带的三个 Demo 示例,现在你就可以以这三个 Demo 示例为入口 Debug Dubbo 源码了。 + +在后面的课时中,我们将解决几个问题:Dubbo 是如何与 ZooKeeper 等注册中心进行交互的?Provider 与 Consumer 之间是如何交互的?为什么我们在编写业务代码的时候,感受不到任何网络交互?Dubbo Provider 发布到注册中心的数据是什么?Consumer 为何能正确识别?两者的统一契约是什么?这个契约是如何做到可扩展的?这个契约还会用在 Dubbo 的哪些地方?这些问题你也可以提前思考一下,在后面的课程中我会一一为你解答。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/02Dubbo的配置总线:抓住URL,就理解了半个Dubbo.md b/专栏/Dubbo源码解读与实战-完/02Dubbo的配置总线:抓住URL,就理解了半个Dubbo.md new file mode 100644 index 0000000..6647d52 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/02Dubbo的配置总线:抓住URL,就理解了半个Dubbo.md @@ -0,0 +1,222 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo + 你好,我是杨四正,今天我和你分享的主题是 Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo 。 + +在互联网领域,每个信息资源都有统一的且在网上唯一的地址,该地址就叫 URL(Uniform Resource Locator,统一资源定位符),它是互联网的统一资源定位标志,也就是指网络地址。 + +URL 本质上就是一个特殊格式的字符串。一个标准的 URL 格式可以包含如下的几个部分: + +protocol://username:password@host:port/path?key=value&key=value + + + +protocol:URL 的协议。我们常见的就是 HTTP 协议和 HTTPS 协议,当然,还有其他协议,如 FTP 协议、SMTP 协议等。 +username/password:用户名/密码。 HTTP Basic Authentication 中多会使用在 URL 的协议之后直接携带用户名和密码的方式。 +host/port:主机/端口。在实践中一般会使用域名,而不是使用具体的 host 和 port。 +path:请求的路径。 +parameters:参数键值对。一般在 GET 请求中会将参数放到 URL 中,POST 请求会将参数放到请求体中。 + + +URL 是整个 Dubbo 中非常基础,也是非常核心的一个组件,阅读源码的过程中你会发现很多方法都是以 URL 作为参数的,在方法内部解析传入的 URL 得到有用的参数,所以有人将 URL 称为Dubbo 的配置总线。 + +例如,在下一课时介绍的 Dubbo SPI 核心实现中,你会看到 URL 参与了扩展实现的确定;在本课程后续介绍注册中心实现的时候,你还会看到 Provider 将自身的信息封装成 URL 注册到 ZooKeeper 中,从而暴露自己的服务, Consumer 也是通过 URL 来确定自己订阅了哪些 Provider 的。 + +由此可见,URL 之于 Dubbo 是非常重要的,所以说“抓住 URL,就理解了半个 Dubbo”。那本文我们就来介绍 URL 在 Dubbo 中的应用,以及 URL 作为 Dubbo 统一契约的重要性,最后我们再通过示例说明 URL 在 Dubbo 中的具体应用。 + +Dubbo 中的 URL + +Dubbo 中任意的一个实现都可以抽象为一个 URL,Dubbo 使用 URL 来统一描述了所有对象和配置信息,并贯穿在整个 Dubbo 框架之中。这里我们来看 Dubbo 中一个典型 URL 的示例,如下: + +dubbo://172.17.32.91:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=dubbo-demo-api-provider&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&methods=sayHello,sayHelloAsync&pid=32508&release=&side=provider×tamp=1593253404714dubbo://172.17.32.91:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=dubbo-demo-api-provider&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&methods=sayHello,sayHelloAsync&pid=32508&release=&side=provider×tamp=1593253404714 + + +这个 Demo Provider 注册到 ZooKeeper 上的 URL 信息,简单解析一下这个 URL 的各个部分: + + +protocol:dubbo 协议。 +username/password:没有用户名和密码。 +host/port:172.17.32.91:20880。 +path:org.apache.dubbo.demo.DemoService。 +parameters:参数键值对,这里是问号后面的参数。 + + +下面是 URL 的构造方法,你可以看到其核心字段与前文分析的 URL 基本一致: + +public URL(String protocol, + + String username, + + String password, + + String host, + + int port, + + String path, + + Map parameters, + + Map> methodParameters) { + + if (StringUtils.isEmpty(username) + + && StringUtils.isNotEmpty(password)) { + + throw new IllegalArgumentException("Invalid url"); + + } + + this.protocol = protocol; + + this.username = username; + + this.password = password; + + this.host = host; + + this.port = Math.max(port, 0); + + this.address = getAddress(this.host, this.port); + + while (path != null && path.startsWith("/")) { + + path = path.substring(1); + + } + + this.path = path; + + if (parameters == null) { + + parameters = new HashMap<>(); + + } else { + + parameters = new HashMap<>(parameters); + + } + + this.parameters = Collections.unmodifiableMap(parameters); + + this.methodParameters = Collections.unmodifiableMap(methodParameters); + +} + + +另外,在 dubbo-common 包中还提供了 URL 的辅助类: + + +URLBuilder, 辅助构造 URL; +URLStrParser, 将字符串解析成 URL 对象。 + + +契约的力量 + +对于 Dubbo 中的 URL,很多人称之为“配置总线”,也有人称之为“统一配置模型”。虽然说法不同,但都是在表达一个意思,URL 在 Dubbo 中被当作是“公共的契约”。一个 URL 可以包含非常多的扩展点参数,URL 作为上下文信息贯穿整个扩展点设计体系。 + +其实,一个优秀的开源产品都有一套灵活清晰的扩展契约,不仅是第三方可以按照这个契约进行扩展,其自身的内核也可以按照这个契约进行搭建。如果没有一个公共的契约,只是针对每个接口或方法进行约定,就会导致不同的接口甚至同一接口中的不同方法,以不同的参数类型进行传参,一会儿传递 Map,一会儿传递字符串,而且字符串的格式也不确定,需要你自己进行解析,这就多了一层没有明确表现出来的隐含的约定。 + +所以说,在 Dubbo 中使用 URL 的好处多多,增加了便捷性: + + +使用 URL 这种公共契约进行上下文信息传递,最重要的就是代码更加易读、易懂,不用花大量时间去揣测传递数据的格式和含义,进而形成一个统一的规范,使得代码易写、易读。 +使用 URL 作为方法的入参(相当于一个 Key/Value 都是 String 的 Map),它所表达的含义比单个参数更丰富,当代码需要扩展的时候,可以将新的参数以 Key/Value 的形式追加到 URL 之中,而不需要改变入参或是返回值的结构。 +使用 URL 这种“公共的契约”可以简化沟通,人与人之间的沟通消耗是非常大的,信息传递的效率非常低,使用统一的契约、术语、词汇范围,可以省去很多沟通成本,尽可能地提高沟通效率。 + + +Dubbo 中的 URL 示例 + +了解了 URL 的结构以及 Dubbo 使用 URL 的原因之后,我们再来看 Dubbo 中的三个真实示例,进一步感受 URL 的重要性。 + +1. URL 在 SPI 中的应用 + +Dubbo SPI 中有一个依赖 URL 的重要场景——适配器方法,是被 @Adaptive 注解标注的, URL 一个很重要的作用就是与 @Adaptive 注解一起选择合适的扩展实现类。 + +例如,在 dubbo-registry-api 模块中我们可以看到 RegistryFactory 这个接口,其中的 getRegistry() 方法上有 @Adaptive({“protocol”}) 注解,说明这是一个适配器方法,Dubbo 在运行时会为其动态生成相应的 “$Adaptive” 类型,如下所示: + +public class RegistryFactory$Adaptive + + implements RegistryFactory { + + public Registry getRegistry(org.apache.dubbo.common.URL arg0) { + + if (arg0 == null) throw new IllegalArgumentException("..."); + + org.apache.dubbo.common.URL url = arg0; + + // 尝试获取URL的Protocol,如果Protocol为空,则使用默认值"dubbo" + + String extName = (url.getProtocol() == null ? "dubbo" : + + url.getProtocol()); + + if (extName == null) + + throw new IllegalStateException("..."); + + // 根据扩展名选择相应的扩展实现,Dubbo SPI的核心原理在下一课时深入分析 + + RegistryFactory extension = (RegistryFactory) ExtensionLoader + + .getExtensionLoader(RegistryFactory.class) + + .getExtension(extName); + + return extension.getRegistry(arg0); + + } + +} + + +我们会看到,在生成的 RegistryFactory$Adaptive 类中会自动实现 getRegistry() 方法,其中会根据 URL 的 Protocol 确定扩展名称,从而确定使用的具体扩展实现类。我们可以找到 RegistryProtocol 这个类,并在其 getRegistry() 方法中打一个断点, Debug 启动上一课时介绍的任意一个 Demo 示例中的 Provider,得到如下图所示的内容: + + + +这里传入的 registryUrl 值为: + +zookeeper://127.0.0.1:2181/org.apache.dubbo... + + +那么在 RegistryFactory$Adaptive 中得到的扩展名称为 zookeeper,此次使用的 Registry 扩展实现类就是 ZookeeperRegistryFactory。至于 Dubbo SPI 的完整内容,我们将在下一课时详细介绍,这里就不再展开了。 + +2. URL 在服务暴露中的应用 + +我们再来看另一个与 URL 相关的示例。上一课时我们在介绍 Dubbo 的简化架构时提到,Provider 在启动时,会将自身暴露的服务注册到 ZooKeeper 上,具体是注册哪些信息到 ZooKeeper 上呢?我们来看 ZookeeperRegistry.doRegister() 方法,在其中打个断点,然后 Debug 启动 Provider,会得到下图: + + + +传入的 URL 中包含了 Provider 的地址(172.18.112.15:20880)、暴露的接口(org.apache.dubbo.demo.DemoService)等信息, toUrlPath() 方法会根据传入的 URL 参数确定在 ZooKeeper 上创建的节点路径,还会通过 URL 中的 dynamic 参数值确定创建的 ZNode 是临时节点还是持久节点。 + +3. URL 在服务订阅中的应用 + +Consumer 启动后会向注册中心进行订阅操作,并监听自己关注的 Provider。那 Consumer 是如何告诉注册中心自己关注哪些 Provider 呢? + +我们来看 ZookeeperRegistry 这个实现类,它是由上面的 ZookeeperRegistryFactory 工厂类创建的 Registry 接口实现,其中的 doSubscribe() 方法是订阅操作的核心实现,在第 175 行打一个断点,并 Debug 启动 Demo 中 Consumer,会得到下图所示的内容: + + + +我们看到传入的 URL 参数如下: + +consumer://...?application=dubbo-demo-api-consumer&category=providers,configurators,routers&interface=org.apache.dubbo.demo.DemoService... + + +其中 Protocol 为 consumer ,表示是 Consumer 的订阅协议,其中的 category 参数表示要订阅的分类,这里要订阅 providers、configurators 以及 routers 三个分类;interface 参数表示订阅哪个服务接口,这里要订阅的是暴露 org.apache.dubbo.demo.DemoService 实现的 Provider。 + +通过 URL 中的上述参数,ZookeeperRegistry 会在 toCategoriesPath() 方法中将其整理成一个 ZooKeeper 路径,然后调用 zkClient 在其上添加监听。 + +通过上述示例,相信你已经感觉到 URL 在 Dubbo 体系中称为“总线”或是“契约”的原因了,在后面的源码分析中,我们还将看到更多关于 URL 的实现。 + +总结 + +在本课时,我们重点介绍了 Dubbo 对 URL 的封装以及相关的工具类,然后说明了统一契约的好处,当然也是 Dubbo 使用 URL 作为统一配置总线的好处,最后我们还介绍了 Dubbo SPI、Provider 注册、Consumer 订阅等场景中与 URL 相关的实现,这些都可以帮助你更好地感受 URL 在其中发挥的作用。 + +这里你可以想一下,在其他框架或是实际工作中,有没有类似 Dubbo URL 这种统一的契约?欢迎你在留言区分享你的想法。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/03DubboSPI精析,接口实现两极反转(上).md b/专栏/Dubbo源码解读与实战-完/03DubboSPI精析,接口实现两极反转(上).md new file mode 100644 index 0000000..e4de09a --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/03DubboSPI精析,接口实现两极反转(上).md @@ -0,0 +1,353 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 Dubbo SPI 精析,接口实现两极反转(上) + Dubbo 为了更好地达到 OCP 原则(即“对扩展开放,对修改封闭”的原则),采用了“微内核+插件”的架构。那什么是微内核架构呢?微内核架构也被称为插件化架构(Plug-in Architecture),这是一种面向功能进行拆分的可扩展性架构。内核功能是比较稳定的,只负责管理插件的生命周期,不会因为系统功能的扩展而不断进行修改。功能上的扩展全部封装到插件之中,插件模块是独立存在的模块,包含特定的功能,能拓展内核系统的功能。 + +微内核架构中,内核通常采用 Factory、IoC、OSGi 等方式管理插件生命周期,Dubbo 最终决定采用 SPI 机制来加载插件,Dubbo SPI 参考 JDK 原生的 SPI 机制,进行了性能优化以及功能增强。因此,在讲解 Dubbo SPI 之前,我们有必要先来介绍一下 JDK SPI 的工作原理。 + +JDK SPI + +SPI(Service Provider Interface)主要是被框架开发人员使用的一种技术。例如,使用 Java 语言访问数据库时我们会使用到 java.sql.Driver 接口,不同数据库产品底层的协议不同,提供的 java.sql.Driver 实现也不同,在开发 java.sql.Driver 接口时,开发人员并不清楚用户最终会使用哪个数据库,在这种情况下就可以使用 Java SPI 机制在实际运行过程中,为 java.sql.Driver 接口寻找具体的实现。 + +1. JDK SPI 机制 + +当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时,JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。 + +下面我们通过一个简单的示例演示下 JDK SPI 的基本使用方式: + +.png] + +首先我们需要创建一个 Log 接口,来模拟日志打印的功能: + +public interface Log { + + void log(String info); + +} + + +接下来提供两个实现—— Logback 和 Log4j,分别代表两个不同日志框架的实现,如下所示: + +public class Logback implements Log { + + @Override + + public void log(String info) { + + System.out.println("Logback:" + info); + + } + +} + +public class Log4j implements Log { + + @Override + + public void log(String info) { + + System.out.println("Log4j:" + info); + + } + +} + + +在项目的 resources/META-INF/services 目录下添加一个名为 com.xxx.Log 的文件,这是 JDK SPI 需要读取的配置文件,具体内容如下: + +com.xxx.impl.Log4j + +com.xxx.impl.Logback + + +最后创建 main() 方法,其中会加载上述配置文件,创建全部 Log 接口实现的实例,并执行其 log() 方法,如下所示: + +public class Main { + + public static void main(String[] args) { + + ServiceLoader serviceLoader = + + ServiceLoader.load(Log.class); + + Iterator iterator = serviceLoader.iterator(); + + while (iterator.hasNext()) { + + Log log = iterator.next(); + + log.log("JDK SPI"); + + } + + } + +} + +// 输出如下: + +// Log4j:JDK SPI + +// Logback:JDK SPI + + +2. JDK SPI 源码分析 + +通过上述示例,我们可以看到 JDK SPI 的入口方法是 ServiceLoader.load() 方法,接下来我们就对其具体实现进行深入分析。 + +在 ServiceLoader.load() 方法中,首先会尝试获取当前使用的 ClassLoader(获取当前线程绑定的 ClassLoader,查找失败后使用 SystemClassLoader),然后调用 reload() 方法,调用关系如下图所示: + + + +在 reload() 方法中,首先会清理 providers 缓存(LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名,Value 为实现类的对象。之后创建 LazyIterator 迭代器,用于读取 SPI 配置文件并实例化实现类对象。 + +ServiceLoader.reload() 方法的具体实现,如下所示: + +// 缓存,用来缓存 ServiceLoader创建的实现对象 + +private LinkedHashMap providers = new LinkedHashMap<>(); + +public void reload() { + + providers.clear(); // 清空缓存 + + lookupIterator = new LazyIterator(service, loader); // 迭代器 + +} + + +在前面的示例中,main() 方法中使用的迭代器底层就是调用了 ServiceLoader.LazyIterator 实现的。Iterator 接口有两个关键方法:hasNext() 方法和 next() 方法。这里的 LazyIterator 中的next() 方法最终调用的是其 nextService() 方法,hasNext() 方法最终调用的是 hasNextService() 方法,调用关系如下图所示: + + + +首先来看 LazyIterator.hasNextService() 方法,该方法主要负责查找 META-INF/services 目录下的 SPI 配置文件,并进行遍历,大致实现如下所示: + +private static final String PREFIX = "META-INF/services/"; + +Enumeration configs = null; + +Iterator pending = null; + +String nextName = null; + +private boolean hasNextService() { + + if (nextName != null) { + + return true; + + } + + if (configs == null) { + + // PREFIX前缀与服务接口的名称拼接起来,就是META-INF目录下定义的SPI配 + + // 置文件(即示例中的META-INF/services/com.xxx.Log) + + String fullName = PREFIX + service.getName(); + + // 加载配置文件 + + if (loader == null) + + configs = ClassLoader.getSystemResources(fullName); + + else + + configs = loader.getResources(fullName); + + } + + // 按行SPI遍历配置文件的内容 + + while ((pending == null) || !pending.hasNext()) { + + if (!configs.hasMoreElements()) { + + return false; + + } + + // 解析配置文件 + + pending = parse(service, configs.nextElement()); + + } + + nextName = pending.next(); // 更新 nextName字段 + + return true; + +} + + +在 hasNextService() 方法中完成 SPI 配置文件的解析之后,再来看 LazyIterator.nextService() 方法,该方法负责实例化 hasNextService() 方法读取到的实现类,其中会将实例化的对象放到 providers 集合中缓存起来,核心实现如下所示: + +private S nextService() { + + String cn = nextName; + + nextName = null; + + // 加载 nextName字段指定的类 + + Class c = Class.forName(cn, false, loader); + + if (!service.isAssignableFrom(c)) { // 检测类型 + + fail(service, "Provider " + cn + " not a subtype"); + + } + + S p = service.cast(c.newInstance()); // 创建实现类的对象 + + providers.put(cn, p); // 将实现类名称以及相应实例对象添加到缓存 + + return p; + +} + + +以上就是在 main() 方法中使用的迭代器的底层实现。最后,我们再来看一下 main() 方法中使用ServiceLoader.iterator() 方法拿到的迭代器是如何实现的,这个迭代器是依赖 LazyIterator 实现的一个匿名内部类,核心实现如下: + +public Iterator iterator() { + + return new Iterator() { + + // knownProviders用来迭代providers缓存 + + Iterator> knownProviders + + = providers.entrySet().iterator(); + + public boolean hasNext() { + + // 先走查询缓存,缓存查询失败,再通过LazyIterator加载 + + if (knownProviders.hasNext()) + + return true; + + return lookupIterator.hasNext(); + + } + + public S next() { + + // 先走查询缓存,缓存查询失败,再通过 LazyIterator加载 + + if (knownProviders.hasNext()) + + return knownProviders.next().getValue(); + + return lookupIterator.next(); + + } + + // 省略remove()方法 + + }; + +} + + +3. JDK SPI 在 JDBC 中的应用 + +了解了 JDK SPI 实现的原理之后,我们再来看实践中 JDBC 是如何使用 JDK SPI 机制加载不同数据库厂商的实现类。 + +JDK 中只定义了一个 java.sql.Driver 接口,具体的实现是由不同数据库厂商来提供的。这里我们就以 MySQL 提供的 JDBC 实现包为例进行分析。 + +在 mysql-connector-java-*.jar 包中的 META-INF/services 目录下,有一个 java.sql.Driver 文件中只有一行内容,如下所示: + +com.mysql.cj.jdbc.Driver + + +在使用 mysql-connector-java-*.jar 包连接 MySQL 数据库的时候,我们会用到如下语句创建数据库连接: + +String url = "jdbc:xxx://xxx:xxx/xxx"; + +Connection conn = DriverManager.getConnection(url, username, pwd); + + +DriverManager 是 JDK 提供的数据库驱动管理器,其中的代码片段,如下所示: + +static { + + loadInitialDrivers(); + + println("JDBC DriverManager initialized"); + +} + + +在调用 getConnection() 方法的时候,DriverManager 类会被 Java 虚拟机加载、解析并触发 static 代码块的执行;在 loadInitialDrivers() 方法中通过 JDK SPI 扫描 Classpath 下 java.sql.Driver 接口实现类并实例化,核心实现如下所示: + +private static void loadInitialDrivers() { + + String drivers = System.getProperty("jdbc.drivers") + + // 使用 JDK SPI机制加载所有 java.sql.Driver实现类 + + ServiceLoader loadedDrivers = + + ServiceLoader.load(Driver.class); + + Iterator driversIterator = loadedDrivers.iterator(); + + while(driversIterator.hasNext()) { + + driversIterator.next(); + + } + + String[] driversList = drivers.split(":"); + + for (String aDriver : driversList) { // 初始化Driver实现类 + + Class.forName(aDriver, true, + + ClassLoader.getSystemClassLoader()); + + } + +} + + +在 MySQL 提供的 com.mysql.cj.jdbc.Driver 实现类中,同样有一段 static 静态代码块,这段代码会创建一个 com.mysql.cj.jdbc.Driver 对象并注册到 DriverManager.registeredDrivers 集合中(CopyOnWriteArrayList 类型),如下所示: + +static { + + java.sql.DriverManager.registerDriver(new Driver()); + +} + + +在 getConnection() 方法中,DriverManager 从该 registeredDrivers 集合中获取对应的 Driver 对象创建 Connection,核心实现如下所示: + +private static Connection getConnection(String url, java.util.Properties info, Class caller) throws SQLException { + + // 省略 try/catch代码块以及权限处理逻辑 + + for(DriverInfo aDriver : registeredDrivers) { + + Connection con = aDriver.driver.connect(url, info); + + return con; + + } + +} + + +总结 + +本文我们通过一个示例入手,介绍了 JDK 提供的 SPI 机制的基本使用,然后深入分析了 JDK SPI 的核心原理和底层实现,对其源码进行了深入剖析,最后我们以 MySQL 提供的 JDBC 实现为例,分析了 JDK SPI 在实践中的使用方式。 + +JDK SPI 机制虽然简单易用,但是也存在一些小瑕疵,你可以先思考一下,在下一课时剖析 Dubbo SPI 机制的时候,我会为你解答该问题。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/04DubboSPI精析,接口实现两极反转(下).md b/专栏/Dubbo源码解读与实战-完/04DubboSPI精析,接口实现两极反转(下).md new file mode 100644 index 0000000..4b52b7b --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/04DubboSPI精析,接口实现两极反转(下).md @@ -0,0 +1,659 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 Dubbo SPI 精析,接口实现两极反转(下) + 在上一课时,我们一起学习了 JDK SPI 的基础使用以及核心原理,不过 Dubbo 并没有直接使用 JDK SPI 机制,而是借鉴其思想,实现了自身的一套 SPI 机制,这就是本课时将重点介绍的内容。 + +Dubbo SPI + +在开始介绍 Dubbo SPI 实现之前,我们先来统一下面两个概念。 + + +扩展点:通过 SPI 机制查找并加载实现的接口(又称“扩展接口”)。前文示例中介绍的 Log 接口、com.mysql.cj.jdbc.Driver 接口,都是扩展点。 +扩展点实现:实现了扩展接口的实现类。 + + +通过前面的分析可以发现,JDK SPI 在查找扩展实现类的过程中,需要遍历 SPI 配置文件中定义的所有实现类,该过程中会将这些实现类全部实例化。如果 SPI 配置文件中定义了多个实现类,而我们只需要使用其中一个实现类时,就会生成不必要的对象。例如,org.apache.dubbo.rpc.Protocol 接口有 InjvmProtocol、DubboProtocol、RmiProtocol、HttpProtocol、HessianProtocol、ThriftProtocol 等多个实现,如果使用 JDK SPI,就会加载全部实现类,导致资源的浪费。 + +Dubbo SPI 不仅解决了上述资源浪费的问题,还对 SPI 配置文件扩展和修改。 + +首先,Dubbo 按照 SPI 配置文件的用途,将其分成了三类目录。 + + +META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。 +META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。 +META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。 + + +然后,Dubbo 将 SPI 配置文件改成了 KV 格式,例如: + +dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol + + +其中 key 被称为扩展名(也就是 ExtensionName),当我们在为一个接口查找具体实现类时,可以指定扩展名来选择相应的扩展实现。例如,这里指定扩展名为 dubbo,Dubbo SPI 就知道我们要使用:org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,只实例化这一个扩展实现即可,无须实例化 SPI 配置文件中的其他扩展实现类。 + +使用 KV 格式的 SPI 配置文件的另一个好处是:让我们更容易定位到问题。假设我们使用的一个扩展实现类所在的 jar 包没有引入到项目中,那么 Dubbo SPI 在抛出异常的时候,会携带该扩展名信息,而不是简单地提示扩展实现类无法加载。这些更加准确的异常信息降低了排查问题的难度,提高了排查问题的效率。 + +下面我们正式进入 Dubbo SPI 核心实现的介绍。 + +1. @SPI 注解 + +Dubbo 中某个接口被 @SPI注解修饰时,就表示该接口是扩展接口,前文示例中的 org.apache.dubbo.rpc.Protocol 接口就是一个扩展接口: + + + +@SPI 注解的 value 值指定了默认的扩展名称,例如,在通过 Dubbo SPI 加载 Protocol 接口实现时,如果没有明确指定扩展名,则默认会将 @SPI 注解的 value 值作为扩展名,即加载 dubbo 这个扩展名对应的 org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,相关的 SPI 配置文件在 dubbo-rpc-dubbo 模块中,如下图所示: + + + +那 ExtensionLoader 是如何处理 @SPI 注解的呢? + +ExtensionLoader 位于 dubbo-common 模块中的 extension 包中,功能类似于 JDK SPI 中的 java.util.ServiceLoader。Dubbo SPI 的核心逻辑几乎都封装在 ExtensionLoader 之中(其中就包括 @SPI 注解的处理逻辑),其使用方式如下所示: + +Protocol protocol = ExtensionLoader + + .getExtensionLoader(Protocol.class).getExtension("dubbo"); + + +这里首先来了解一下 ExtensionLoader 中三个核心的静态字段。 + + +strategies(LoadingStrategy[]类型): LoadingStrategy 接口有三个实现(通过 JDK SPI 方式加载的),如下图所示,分别对应前面介绍的三个 Dubbo SPI 配置文件所在的目录,且都继承了 Prioritized 这个优先级接口,默认优先级是 + + + DubboInternalLoadingStrategy > DubboLoadingStrategy > ServicesLoadingStrateg + + + + + +EXTENSION_LOADERS(ConcurrentMap类型) +:Dubbo 中一个扩展接口对应一个 ExtensionLoader 实例,该集合缓存了全部 ExtensionLoader 实例,其中的 Key 为扩展接口,Value 为加载其扩展实现的 ExtensionLoader 实例。 +EXTENSION_INSTANCES(ConcurrentMap, Object>类型):该集合缓存了扩展实现类与其实例对象的映射关系。在前文示例中,Key 为 Class,Value 为 DubboProtocol 对象。 + + +下面我们再来关注一下 ExtensionLoader 的实例字段。 + + +type(Class类型):当前 ExtensionLoader 实例负责加载扩展接口。 +cachedDefaultName(String类型):记录了 type 这个扩展接口上 @SPI 注解的 value 值,也就是默认扩展名。 +cachedNames(ConcurrentMap, String>类型):缓存了该 ExtensionLoader 加载的扩展实现类与扩展名之间的映射关系。 +cachedClasses(Holder>>类型):缓存了该 ExtensionLoader 加载的扩展名与扩展实现类之间的映射关系。cachedNames 集合的反向关系缓存。 +cachedInstances(ConcurrentMap>类型):缓存了该 ExtensionLoader 加载的扩展名与扩展实现对象之间的映射关系。 + + +ExtensionLoader.getExtensionLoader() 方法会根据扩展接口从 EXTENSION_LOADERS 缓存中查找相应的 ExtensionLoader 实例,核心实现如下: + +public static ExtensionLoader getExtensionLoader(Class type) { + + ExtensionLoader loader = + + (ExtensionLoader) EXTENSION_LOADERS.get(type); + + if (loader == null) { + + EXTENSION_LOADERS.putIfAbsent(type, + + new ExtensionLoader(type)); + + loader = (ExtensionLoader) EXTENSION_LOADERS.get(type); + + } + + return loader; + +} + + +得到接口对应的 ExtensionLoader 对象之后会调用其 getExtension() 方法,根据传入的扩展名称从 cachedInstances 缓存中查找扩展实现的实例,最终将其实例化后返回: + +public T getExtension(String name) { + + // getOrCreateHolder()方法中封装了查找cachedInstances缓存的逻辑 + + Holder holder = getOrCreateHolder(name); + + Object instance = holder.get(); + + if (instance == null) { // double-check防止并发问题 + + synchronized (holder) { + + instance = holder.get(); + + if (instance == null) { + + // 根据扩展名从SPI配置文件中查找对应的扩展实现类 + + instance = createExtension(name); + + holder.set(instance); + + } + + } + + } + + return (T) instance; + +} + + +在 createExtension() 方法中完成了 SPI 配置文件的查找以及相应扩展实现类的实例化,同时还实现了自动装配以及自动 Wrapper 包装等功能。其核心流程是这样的: + + +获取 cachedClasses 缓存,根据扩展名从 cachedClasses 缓存中获取扩展实现类。如果 cachedClasses 未初始化,则会扫描前面介绍的三个 SPI 目录获取查找相应的 SPI 配置文件,然后加载其中的扩展实现类,最后将扩展名和扩展实现类的映射关系记录到 cachedClasses 缓存中。这部分逻辑在 loadExtensionClasses() 和 loadDirectory() 方法中。 +根据扩展实现类从 EXTENSION_INSTANCES 缓存中查找相应的实例。如果查找失败,会通过反射创建扩展实现对象。 +自动装配扩展实现对象中的属性(即调用其 setter)。这里涉及 ExtensionFactory 以及自动装配的相关内容,本课时后面会进行详细介绍。 +自动包装扩展实现对象。这里涉及 Wrapper 类以及自动包装特性的相关内容,本课时后面会进行详细介绍。 +如果扩展实现类实现了 Lifecycle 接口,在 initExtension() 方法中会调用 initialize() 方法进行初始化。 + + +private T createExtension(String name) { + + Class clazz = getExtensionClasses().get(name); // --- 1 + + if (clazz == null) { + + throw findException(name); + + } + + try { + + T instance = (T) EXTENSION_INSTANCES.get(clazz); // --- 2 + + if (instance == null) { + + EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance()); + + instance = (T) EXTENSION_INSTANCES.get(clazz); + + } + + injectExtension(instance); // --- 3 + + Set> wrapperClasses = cachedWrapperClasses; // --- 4 + + if (CollectionUtils.isNotEmpty(wrapperClasses)) { + + for (Class wrapperClass : wrapperClasses) { + + instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance)); + + } + + } + + initExtension(instance); // ---5 + + return instance; + + } catch (Throwable t) { + + throw new IllegalStateException("Extension instance (name: " + name + ", class: " + + + type + ") couldn't be instantiated: " + t.getMessage(), t); + + } + +} + + +2. @Adaptive 注解与适配器 + +@Adaptive 注解用来实现 Dubbo 的适配器功能,那什么是适配器呢?这里我们通过一个示例进行说明。Dubbo 中的 ExtensionFactory 接口有三个实现类,如下图所示,ExtensionFactory 接口上有 @SPI 注解,AdaptiveExtensionFactory 实现类上有 @Adaptive 注解。 + + + +AdaptiveExtensionFactory 不实现任何具体的功能,而是用来适配 ExtensionFactory 的 SpiExtensionFactory 和 SpringExtensionFactory 这两种实现。AdaptiveExtensionFactory 会根据运行时的一些状态来选择具体调用 ExtensionFactory 的哪个实现。 + +@Adaptive 注解还可以加到接口方法之上,Dubbo 会动态生成适配器类。例如,Transporter接口有两个被 @Adaptive 注解修饰的方法: + +@SPI("netty") + +public interface Transporter { + + @Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY}) + + RemotingServer bind(URL url, ChannelHandler handler) throws RemotingException; + + @Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY}) + + Client connect(URL url, ChannelHandler handler) throws RemotingException; + +} + + +Dubbo 会生成一个 Transporter$Adaptive 适配器类,该类继承了 Transporter 接口: + +public class Transporter$Adaptive implements Transporter { + + public org.apache.dubbo.remoting.Client connect(URL arg0, ChannelHandler arg1) throws RemotingException { + + // 必须传递URL参数 + + if (arg0 == null) throw new IllegalArgumentException("url == null"); + + URL url = arg0; + + // 确定扩展名,优先从URL中的client参数获取,其次是transporter参数 + + // 这两个参数名称由@Adaptive注解指定,最后是@SPI注解中的默认值 + + String extName = url.getParameter("client", + + url.getParameter("transporter", "netty")); + + if (extName == null) + + throw new IllegalStateException("..."); + + // 通过ExtensionLoader加载Transporter接口的指定扩展实现 + + Transporter extension = (Transporter) ExtensionLoader + + .getExtensionLoader(Transporter.class) + + .getExtension(extName); + + return extension.connect(arg0, arg1); + + } + + ... // 省略bind()方法 + +} + + +生成 Transporter$Adaptive 这个类的逻辑位于 ExtensionLoader.createAdaptiveExtensionClass() 方法,若感兴趣你可以看一下相关代码,其中涉及的 javassist 等方面的知识,在后面的课时中我们会进行介绍。 + +明确了 @Adaptive 注解的作用之后,我们回到 ExtensionLoader.createExtension() 方法,其中在扫描 SPI 配置文件的时候,会调用 loadClass() 方法加载 SPI 配置文件中指定的类,如下图所示: + + + +loadClass() 方法中会识别加载扩展实现类上的 @Adaptive 注解,将该扩展实现的类型缓存到 cachedAdaptiveClass 这个实例字段上(volatile修饰): + +private void loadClass(){ + + if (clazz.isAnnotationPresent(Adaptive.class)) { + + // 缓存到cachedAdaptiveClass字段 + + cacheAdaptiveClass(clazz, overridden); + + } else ... // 省略其他分支 + +} + + +我们可以通过 ExtensionLoader.getAdaptiveExtension() 方法获取适配器实例,并将该实例缓存到 cachedAdaptiveInstance 字段(Holder类型)中,核心流程如下: + + +首先,检查 cachedAdaptiveInstance 字段中是否已缓存了适配器实例,如果已缓存,则直接返回该实例即可。 +然后,调用 getExtensionClasses() 方法,其中就会触发前文介绍的 loadClass() 方法,完成 cachedAdaptiveClass 字段的填充。 +如果存在 @Adaptive 注解修饰的扩展实现类,该类就是适配器类,通过 newInstance() 将其实例化即可。如果不存在 @Adaptive 注解修饰的扩展实现类,就需要通过 createAdaptiveExtensionClass() 方法扫描扩展接口中方法上的 @Adaptive 注解,动态生成适配器类,然后实例化。 +接下来,调用 injectExtension() 方法进行自动装配,就能得到一个完整的适配器实例。 +最后,将适配器实例缓存到 cachedAdaptiveInstance 字段,然后返回适配器实例。 + + +getAdaptiveExtension() 方法的流程涉及多个方法,这里不再粘贴代码,感兴趣的同学可以参考上述流程分析相应源码。 + +此外,我们还可以通过 API 方式(addExtension() 方法)设置 cachedAdaptiveClass 这个字段,指定适配器类型(这个方法你知道即可)。 + +总之,适配器什么实际工作都不用做,就是根据参数和状态选择其他实现来完成工作。 。 + +3. 自动包装特性 + +Dubbo 中的一个扩展接口可能有多个扩展实现类,这些扩展实现类可能会包含一些相同的逻辑,如果在每个实现类中都写一遍,那么这些重复代码就会变得很难维护。Dubbo 提供的自动包装特性,就可以解决这个问题。 Dubbo 将多个扩展实现类的公共逻辑,抽象到 Wrapper 类中,Wrapper 类与普通的扩展实现类一样,也实现了扩展接口,在获取真正的扩展实现对象时,在其外面包装一层 Wrapper 对象,你可以理解成一层装饰器。 + +了解了 Wrapper 类的基本功能,我们回到 ExtensionLoader.loadClass() 方法中,可以看到: + +private void loadClass(){ + + ... // 省略前面对@Adaptive注解的处理 + + } else if (isWrapperClass(clazz)) { // ---1 + + cacheWrapperClass(clazz); // ---2 + + } else ... // 省略其他分支 + +} + + + +在 isWrapperClass() 方法中,会判断该扩展实现类是否包含拷贝构造函数(即构造函数只有一个参数且为扩展接口类型),如果包含,则为 Wrapper 类,这就是判断 Wrapper 类的标准。 +将 Wrapper 类记录到 cachedWrapperClasses(Set>类型)这个实例字段中进行缓存。 + + +前面在介绍 createExtension() 方法时的 4 处,有下面这段代码,其中会遍历全部 Wrapper 类并一层层包装到真正的扩展实例对象外层: + +Set> wrapperClasses = cachedWrapperClasses; + +if (CollectionUtils.isNotEmpty(wrapperClasses)) { + + for (Class wrapperClass : wrapperClasses) { + + instance = injectExtension((T) wrapperClass + + .getConstructor(type).newInstance(instance)); + + } + +} + + +4. 自动装配特性 + +在 createExtension() 方法中我们看到,Dubbo SPI 在拿到扩展实现类的对象(以及 Wrapper 类的对象)之后,还会调用 injectExtension() 方法扫描其全部 setter 方法,并根据 setter 方法的名称以及参数的类型,加载相应的扩展实现,然后调用相应的 setter 方法填充属性,这就实现了 Dubbo SPI 的自动装配特性。简单来说,自动装配属性就是在加载一个扩展点的时候,将其依赖的扩展点一并加载,并进行装配。 + +下面简单看一下 injectExtension() 方法的具体实现: + +private T injectExtension(T instance) { + + if (objectFactory == null) { // 检测objectFactory字段 + + return instance; + + } + + for (Method method : instance.getClass().getMethods()) { + + ... // 如果不是setter方法,忽略该方法(略) + + if (method.getAnnotation(DisableInject.class) != null) { + + continue; // 如果方法上明确标注了@DisableInject注解,忽略该方法 + + } + + // 根据setter方法的参数,确定扩展接口 + + Class pt = method.getParameterTypes()[0]; + + ... // 如果参数为简单类型,忽略该setter方法(略) + + // 根据setter方法的名称确定属性名称 + + String property = getSetterProperty(method); + + // 加载并实例化扩展实现类 + + Object object = objectFactory.getExtension(pt, property); + + if (object != null) { + + method.invoke(instance, object); // 调用setter方法进行装配 + + } + + } + + return instance; + +} + + +injectExtension() 方法实现的自动装配依赖了 ExtensionFactory(即 objectFactory 字段),前面我们提到过 ExtensionFactory 有 SpringExtensionFactory 和 SpiExtensionFactory 两个真正的实现(还有一个实现是 AdaptiveExtensionFactory 是适配器)。下面我们分别介绍下这两个真正的实现。 + +第一个,SpiExtensionFactory。 根据扩展接口获取相应的适配器,没有到属性名称: + +@Override + +public T getExtension(Class type, String name) { + + if (type.isInterface() && type.isAnnotationPresent(SPI.class)) { + + // 查找type对应的ExtensionLoader实例 + + ExtensionLoader loader = ExtensionLoader + + .getExtensionLoader(type); + + if (!loader.getSupportedExtensions().isEmpty()) { + + return loader.getAdaptiveExtension(); // 获取适配器实现 + + } + + } + + return null; + +} + + +第二个,SpringExtensionFactory。 将属性名称作为 Spring Bean 的名称,从 Spring 容器中获取 Bean: + +public T getExtension(Class type, String name) { + + ... // 检查:type必须为接口且必须包含@SPI注解(略) + + for (ApplicationContext context : CONTEXTS) { + + // 从Spring容器中查找Bean + + T bean = BeanFactoryUtils.getOptionalBean(context,name,type); + + if (bean != null) { + + return bean; + + } + + } + + return null; + +} + + +5. @Activate注解与自动激活特性 + +这里以 Dubbo 中的 Filter 为例说明自动激活特性的含义,org.apache.dubbo.rpc.Filter 接口有非常多的扩展实现类,在一个场景中可能需要某几个 Filter 扩展实现类协同工作,而另一个场景中可能需要另外几个实现类一起工作。这样,就需要一套配置来指定当前场景中哪些 Filter 实现是可用的,这就是 @Activate 注解要做的事情。 + +@Activate 注解标注在扩展实现类上,有 group、value 以及 order 三个属性。 + + +group 属性:修饰的实现类是在 Provider 端被激活还是在 Consumer 端被激活。 +value 属性:修饰的实现类只在 URL 参数中出现指定的 key 时才会被激活。 +order 属性:用来确定扩展实现类的排序。 + + +我们先来看 loadClass() 方法对 @Activate 的扫描,其中会将包含 @Activate 注解的实现类缓存到 cachedActivates 这个实例字段(Map类型,Key为扩展名,Value为 @Activate 注解): + +private void loadClass(){ + + if (clazz.isAnnotationPresent(Adaptive.class)) { + + // 处理@Adaptive注解 + + cacheAdaptiveClass(clazz, overridden); + + } else if (isWrapperClass(clazz)) { // 处理Wrapper类 + + cacheWrapperClass(clazz); + + } else { // 处理真正的扩展实现类 + + clazz.getConstructor(); // 扩展实现类必须有无参构造函数 + + ...// 兜底:SPI配置文件中未指定扩展名称,则用类的简单名称作为扩展名(略) + + String[] names = NAME_SEPARATOR.split(name); + + if (ArrayUtils.isNotEmpty(names)) { + + // 将包含@Activate注解的实现类缓存到cachedActivates集合中 + + cacheActivateClass(clazz, names[0]); + + for (String n : names) { + + // 在cachedNames集合中缓存实现类->扩展名的映射 + + cacheName(clazz, n); + + // 在cachedClasses集合中缓存扩展名->实现类的映射 + + saveInExtensionClass(extensionClasses, clazz, n, + + overridden); + + } + + } + + } + +} + + +使用 cachedActivates 这个集合的地方是 getActivateExtension() 方法。首先来关注 getActivateExtension() 方法的参数:url 中包含了配置信息,values 是配置中指定的扩展名,group 为 Provider 或 Consumer。下面是 getActivateExtension() 方法的核心逻辑: + + +首先,获取默认激活的扩展集合。默认激活的扩展实现类有几个条件:①在 cachedActivates 集合中存在;②@Activate 注解指定的 group 属性与当前 group 匹配;③扩展名没有出现在 values 中(即未在配置中明确指定,也未在配置中明确指定删除);④URL 中出现了 @Activate 注解中指定的 Key。 +然后,按照 @Activate 注解中的 order 属性对默认激活的扩展集合进行排序。 +最后,按序添加自定义扩展实现类的对象。 + + +public List getActivateExtension(URL url, String[] values, + + String group) { + + List activateExtensions = new ArrayList<>(); + + // values配置就是扩展名 + + List names = values == null ? + + new ArrayList<>(0) : asList(values); + + if (!names.contains(REMOVE_VALUE_PREFIX + DEFAULT_KEY)) {// ---1 + + getExtensionClasses(); // 触发cachedActivates等缓存字段的加载 + + for (Map.Entry entry : + + cachedActivates.entrySet()) { + + String name = entry.getKey(); // 扩展名 + + Object activate = entry.getValue(); // @Activate注解 + + String[] activateGroup, activateValue; + + if (activate instanceof Activate) { // @Activate注解中的配置 + + activateGroup = ((Activate) activate).group(); + + activateValue = ((Activate) activate).value(); + + } else { + + continue; + + } + + if (isMatchGroup(group, activateGroup) // 匹配group + + // 没有出现在values配置中的,即为默认激活的扩展实现 + + && !names.contains(name) + + // 通过"-"明确指定不激活该扩展实现 + + && !names.contains(REMOVE_VALUE_PREFIX + name) + + // 检测URL中是否出现了指定的Key + + && isActive(activateValue, url)) { + + // 加载扩展实现的实例对象,这些都是激活的 + + activateExtensions.add(getExtension(name)); + + } + + } + + // 排序 --- 2 + + activateExtensions.sort(ActivateComparator.COMPARATOR); + + } + + List loadedExtensions = new ArrayList<>(); + + for (int i = 0; i < names.size(); i++) { // ---3 + + String name = names.get(i); + + // 通过"-"开头的配置明确指定不激活的扩展实现,直接就忽略了 + + if (!name.startsWith(REMOVE_VALUE_PREFIX) + + && !names.contains(REMOVE_VALUE_PREFIX + name)) { + + if (DEFAULT_KEY.equals(name)) { + + if (!loadedExtensions.isEmpty()) { + + // 按照顺序,将自定义的扩展添加到默认扩展集合前面 + + activateExtensions.addAll(0, loadedExtensions); + + loadedExtensions.clear(); + + } + + } else { + + loadedExtensions.add(getExtension(name)); + + } + + } + + } + + if (!loadedExtensions.isEmpty()) { + + // 按照顺序,将自定义的扩展添加到默认扩展集合后面 + + activateExtensions.addAll(loadedExtensions); + + } + + return activateExtensions; + +} + + +最后举个简单的例子说明上述处理流程,假设 cachedActivates 集合缓存的扩展实现如下表所示: + + + +在 Provider 端调用 getActivateExtension() 方法时传入的 values 配置为 “demoFilter3、-demoFilter2、default、demoFilter1”,那么根据上面的逻辑: + + +得到默认激活的扩展实实现集合中有 [ demoFilter4, demoFilter6 ]; +排序后为 [ demoFilter6, demoFilter4 ]; +按序添加自定义扩展实例之后得到 [ demoFilter3, demoFilter6, demoFilter4, demoFilter1 ]。 + + +总结 + +本课时我们深入全面地讲解了 Dubbo SPI 的核心实现:首先介绍了 @SPI 注解的底层实现,这是 Dubbo SPI 最核心的基础;然后介绍了 @Adaptive 注解与动态生成适配器类的核心原理和实现;最后分析了 Dubbo SPI 中的自动包装和自动装配特性,以及 @Activate 注解的原理。 + +Dubbo SPI 是 Dubbo 框架实现扩展机制的核心,希望你仔细研究其实现,为后续源码分析过程打下基础。 + +也欢迎你在留言区分享你的学习心得和实践经验。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/05海量定时任务,一个时间轮搞定.md b/专栏/Dubbo源码解读与实战-完/05海量定时任务,一个时间轮搞定.md new file mode 100644 index 0000000..5bcf500 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/05海量定时任务,一个时间轮搞定.md @@ -0,0 +1,141 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 海量定时任务,一个时间轮搞定 + 在很多开源框架中,都需要定时任务的管理功能,例如 ZooKeeper、Netty、Quartz、Kafka 以及 Linux 操作系统。 + +JDK 提供的 java.util.Timer 和 DelayedQueue 等工具类,可以帮助我们实现简单的定时任务管理,其底层实现使用的是堆这种数据结构,存取操作的复杂度都是 O(nlog(n)),无法支持大量的定时任务。在定时任务量比较大、性能要求比较高的场景中,为了将定时任务的存取操作以及取消操作的时间复杂度降为 O(1),一般会使用时间轮的方式。 + +时间轮是一种高效的、批量管理定时任务的调度模型。时间轮一般会实现成一个环形结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务;指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。 + + + +时间轮环形结构示意图 + +需要注意的是,单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,通常会使用多级时间轮以及持久化存储与时间轮结合的方案。 + +那在 Dubbo 中,时间轮的具体实现方式是怎样的呢?本课时我们就重点探讨下。Dubbo 的时间轮实现位于 dubbo-common 模块的 org.apache.dubbo.common.timer 包中,下面我们就来分析时间轮涉及的核心接口和实现。 + +核心接口 + +在 Dubbo 中,所有的定时任务都要继承 TimerTask 接口。TimerTask 接口非常简单,只定义了一个 run() 方法,该方法的入参是一个 Timeout 接口的对象。Timeout 对象与 TimerTask 对象一一对应,两者的关系类似于线程池返回的 Future 对象与提交到线程池中的任务对象之间的关系。通过 Timeout 对象,我们不仅可以查看定时任务的状态,还可以操作定时任务(例如取消关联的定时任务)。Timeout 接口中的方法如下图所示: + +.png + +Timer 接口定义了定时器的基本行为,如下图所示,其核心是 newTimeout() 方法:提交一个定时任务(TimerTask)并返回关联的 Timeout 对象,这有点类似于向线程池提交任务的感觉。 + + + +HashedWheelTimeout + +HashedWheelTimeout 是 Timeout 接口的唯一实现,是 HashedWheelTimer 的内部类。HashedWheelTimeout 扮演了两个角色: + + +第一个,时间轮中双向链表的节点,即定时任务 TimerTask 在 HashedWheelTimer 中的容器。 +第二个,定时任务 TimerTask 提交到 HashedWheelTimer 之后返回的句柄(Handle),用于在时间轮外部查看和控制定时任务。 + + +HashedWheelTimeout 中的核心字段如下: + + +prev、next(HashedWheelTimeout类型),分别对应当前定时任务在链表中的前驱节点和后继节点。 +task(TimerTask类型),指实际被调度的任务。 +deadline(long类型),指定时任务执行的时间。这个时间是在创建 HashedWheelTimeout 时指定的,计算公式是:currentTime(创建 HashedWheelTimeout 的时间) + delay(任务延迟时间) - startTime(HashedWheelTimer 的启动时间),时间单位为纳秒。 +state(volatile int类型),指定时任务当前所处状态,可选的有三个,分别是 INIT(0)、CANCELLED(1)和 EXPIRED(2)。另外,还有一个 STATE_UPDATER 字段(AtomicIntegerFieldUpdater类型)实现 state 状态变更的原子性。 +remainingRounds(long类型),指当前任务剩余的时钟周期数。时间轮所能表示的时间长度是有限的,在任务到期时间与当前时刻的时间差,超过时间轮单圈能表示的时长,就出现了套圈的情况,需要该字段值表示剩余的时钟周期。 + + +HashedWheelTimeout 中的核心方法有: + + +isCancelled()、isExpired() 、state() 方法, 主要用于检查当前 HashedWheelTimeout 状态。 +cancel() 方法, 将当前 HashedWheelTimeout 的状态设置为 CANCELLED,并将当前 HashedWheelTimeout 添加到 cancelledTimeouts 队列中等待销毁。 +expire() 方法, 当任务到期时,会调用该方法将当前 HashedWheelTimeout 设置为 EXPIRED 状态,然后调用其中的 TimerTask 的 run() 方法执行定时任务。 +remove() 方法, 将当前 HashedWheelTimeout 从时间轮中删除。 + + +HashedWheelBucket + +HashedWheelBucket 是时间轮中的一个槽,时间轮中的槽实际上就是一个用于缓存和管理双向链表的容器,双向链表中的每一个节点就是一个 HashedWheelTimeout 对象,也就关联了一个 TimerTask 定时任务。 + +HashedWheelBucket 持有双向链表的首尾两个节点,分别是 head 和 tail 两个字段,再加上每个 HashedWheelTimeout 节点均持有前驱和后继的引用,这样就可以正向或是逆向遍历整个双向链表了。 + +下面我们来看 HashedWheelBucket 中的核心方法。 + + +addTimeout() 方法:新增 HashedWheelTimeout 到双向链表的尾部。 +pollTimeout() 方法:移除双向链表中的头结点,并将其返回。 +remove() 方法:从双向链表中移除指定的 HashedWheelTimeout 节点。 +clearTimeouts() 方法:循环调用 pollTimeout() 方法处理整个双向链表,并返回所有未超时或者未被取消的任务。 +expireTimeouts() 方法:遍历双向链表中的全部 HashedWheelTimeout 节点。 在处理到期的定时任务时,会通过 remove() 方法取出,并调用其 expire() 方法执行;对于已取消的任务,通过 remove() 方法取出后直接丢弃;对于未到期的任务,会将 remainingRounds 字段(剩余时钟周期数)减一。 + + +HashedWheelTimer + +HashedWheelTimer 是 Timer 接口的实现,它通过时间轮算法实现了一个定时器。HashedWheelTimer 会根据当前时间轮指针选定对应的槽(HashedWheelBucket),从双向链表的头部开始迭代,对每个定时任务(HashedWheelTimeout)进行计算,属于当前时钟周期则取出运行,不属于则将其剩余的时钟周期数减一操作。 + +下面我们来看 HashedWheelTimer 的核心属性。 + + +workerState(volatile int类型):时间轮当前所处状态,可选值有 init、started、shutdown。同时,有相应的 AtomicIntegerFieldUpdater 实现 workerState 的原子修改。 +startTime(long类型):当前时间轮的启动时间,提交到该时间轮的定时任务的 deadline 字段值均以该时间戳为起点进行计算。 +wheel(HashedWheelBucket[]类型):该数组就是时间轮的环形队列,每一个元素都是一个槽。当指定时间轮槽数为 n 时,实际上会取大于且最靠近 n 的 2 的幂次方值。 +timeouts、cancelledTimeouts(LinkedBlockingQueue类型):timeouts 队列用于缓冲外部提交时间轮中的定时任务,cancelledTimeouts 队列用于暂存取消的定时任务。HashedWheelTimer 会在处理 HashedWheelBucket 的双向链表之前,先处理这两个队列中的数据。 +tick(long类型):该字段在 HashedWheelTimer$Worker 中,是时间轮的指针,是一个步长为 1 的单调递增计数器。 +mask(int类型):掩码, mask = wheel.length - 1,执行 ticks & mask 便能定位到对应的时钟槽。 +ticksDuration(long类型):时间指针每次加 1 所代表的实际时间,单位为纳秒。 +pendingTimeouts(AtomicLong类型):当前时间轮剩余的定时任务总数。 +workerThread(Thread类型):时间轮内部真正执行定时任务的线程。 +worker(Worker类型):真正执行定时任务的逻辑封装这个 Runnable 对象中。 + + +时间轮对外提供了一个 newTimeout() 接口用于提交定时任务,在定时任务进入到 timeouts 队列之前会先调用 start() 方法启动时间轮,其中会完成下面两个关键步骤: + + +确定时间轮的 startTime 字段; +启动 workerThread 线程,开始执行 worker 任务。 + + +之后根据 startTime 计算该定时任务的 deadline 字段,最后才能将定时任务封装成 HashedWheelTimeout 并添加到 timeouts 队列。 + +下面我们来分析时间轮指针一次转动的全流程。 + + +时间轮指针转动,时间轮周期开始。 +清理用户主动取消的定时任务,这些定时任务在用户取消时,会记录到 cancelledTimeouts 队列中。在每次指针转动的时候,时间轮都会清理该队列。 +将缓存在 timeouts 队列中的定时任务转移到时间轮中对应的槽中。 +根据当前指针定位对应槽,处理该槽位的双向链表中的定时任务。 +检测时间轮的状态。如果时间轮处于运行状态,则循环执行上述步骤,不断执行定时任务。如果时间轮处于停止状态,则执行下面的步骤获取到未被执行的定时任务并加入 unprocessedTimeouts 队列:遍历时间轮中每个槽位,并调用 clearTimeouts() 方法;对 timeouts 队列中未被加入槽中循环调用 poll()。 +最后再次清理 cancelledTimeouts 队列中用户主动取消的定时任务。 + + +上述核心逻辑在 HashedWheelTimer$Worker.run() 方法中,若你感兴趣的话,可以翻看一下源码进行分析。 + +Dubbo 中如何使用定时任务 + +在 Dubbo 中,时间轮并不直接用于周期性操作,而是只向时间轮提交执行单次的定时任务,在上一次任务执行完成的时候,调用 newTimeout() 方法再次提交当前任务,这样就会在下个周期执行该任务。即使在任务执行过程中出现了 GC、I/O 阻塞等情况,导致任务延迟或卡住,也不会有同样的任务源源不断地提交进来,导致任务堆积。 + +Dubbo 中对时间轮的应用主要体现在如下两个方面: + + +失败重试, 例如,Provider 向注册中心进行注册失败时的重试操作,或是 Consumer 向注册中心订阅时的失败重试等。 +周期性定时任务, 例如,定期发送心跳请求,请求超时的处理,或是网络连接断开后的重连机制。 + + +总结 + +本课时我们重点介绍了 Dubbo 中时间轮相关的内容: + + +首先介绍了 JDK 提供的 Timer 定时器以及 DelayedQueue 等工具类的问题,并说明了时间轮的解决方案; +然后深入讲解了 Dubbo 对时间轮的抽象,以及具体实现细节; +最后还说明了 Dubbo 中时间轮的应用场景,在我们后面介绍 Dubbo 其他模块的时候,你还会看到时间轮的身影。 + + +这里再给你留个课后思考题:如果存在海量定时任务,并且这些任务的开始时间跨度非常长,例如,有的是 1 分钟之后执行,有的是 1 小时之后执行,有的是 1 年之后执行,那你该如何对时间轮进行扩展,处理这些定时任务呢?欢迎你在留言区分享你的想法,期待看到你的答案。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/06ZooKeeper与Curator,求你别用ZkClient了(上).md b/专栏/Dubbo源码解读与实战-完/06ZooKeeper与Curator,求你别用ZkClient了(上).md new file mode 100644 index 0000000..ea2d4d4 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/06ZooKeeper与Curator,求你别用ZkClient了(上).md @@ -0,0 +1,133 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上) + 在前面我们介绍 Dubbo 简化架构的时候提到过,Dubbo Provider 在启动时会将自身的服务信息整理成 URL 注册到注册中心,Dubbo Consumer 在启动时会向注册中心订阅感兴趣的 Provider 信息,之后 Provider 和 Consumer 才能建立连接,进行后续的交互。可见,一个稳定、高效的注册中心对基于 Dubbo 的微服务来说是至关重要的。 + +Dubbo 目前支持 Consul、etcd、Nacos、ZooKeeper、Redis 等多种开源组件作为注册中心,并且在 Dubbo 源码也有相应的接入模块,如下图所示: + + + +Dubbo 官方推荐使用 ZooKeeper 作为注册中心,它是在实际生产中最常用的注册中心实现,这也是我们本课时要介绍 ZooKeeper 核心原理的原因。 + +要与 ZooKeeper 集群进行交互,我们可以使用 ZooKeeper 原生客户端或是 ZkClient、Apache Curator 等第三方开源客户端。在后面介绍 dubbo-registry-zookeeper 模块的具体实现时你会看到,Dubbo 底层使用的是 Apache Curator。Apache Curator 是实践中最常用的 ZooKeeper 客户端。 + +ZooKeeper 核心概念 + +Apache ZooKeeper 是一个针对分布式系统的、可靠的、可扩展的协调服务,它通常作为统一命名服务、统一配置管理、注册中心(分布式集群管理)、分布式锁服务、Leader 选举服务等角色出现。很多分布式系统都依赖与 ZooKeeper 集群实现分布式系统间的协调调度,例如:Dubbo、HDFS 2.x、HBase、Kafka 等。ZooKeeper 已经成为现代分布式系统的标配。 + +ZooKeeper 本身也是一个分布式应用程序,下图展示了 ZooKeeper 集群的核心架构。 + + + +ZooKeeper 集群的核心架构图 + + +Client 节点:从业务角度来看,这是分布式应用中的一个节点,通过 ZkClient 或是其他 ZooKeeper 客户端与 ZooKeeper 集群中的一个 Server 实例维持长连接,并定时发送心跳。从 ZooKeeper 集群的角度来看,它是 ZooKeeper 集群的一个客户端,可以主动查询或操作 ZooKeeper 集群中的数据,也可以在某些 ZooKeeper 节点(ZNode)上添加监听。当被监听的 ZNode 节点发生变化时,例如,该 ZNode 节点被删除、新增子节点或是其中数据被修改等,ZooKeeper 集群都会立即通过长连接通知 Client。 +Leader 节点:ZooKeeper 集群的主节点,负责整个 ZooKeeper 集群的写操作,保证集群内事务处理的顺序性。同时,还要负责整个集群中所有 Follower 节点与 Observer 节点的数据同步。 +Follower 节点:ZooKeeper 集群中的从节点,可以接收 Client 读请求并向 Client 返回结果,并不处理写请求,而是转发到 Leader 节点完成写入操作。另外,Follower 节点还会参与 Leader 节点的选举。 +Observer 节点:ZooKeeper 集群中特殊的从节点,不会参与 Leader 节点的选举,其他功能与 Follower 节点相同。引入 Observer 角色的目的是增加 ZooKeeper 集群读操作的吞吐量,如果单纯依靠增加 Follower 节点来提高 ZooKeeper 的读吞吐量,那么有一个很严重的副作用,就是 ZooKeeper 集群的写能力会大大降低,因为 ZooKeeper 写数据时需要 Leader 将写操作同步给半数以上的 Follower 节点。引入 Observer 节点使得 ZooKeeper 集群在写能力不降低的情况下,大大提升了读操作的吞吐量。 + + +了解了 ZooKeeper 整体的架构之后,我们再来了解一下 ZooKeeper 集群存储数据的逻辑结构。ZooKeeper 逻辑上是按照树型结构进行数据存储的(如下图),其中的节点称为 ZNode。每个 ZNode 有一个名称标识,即树根到该节点的路径(用 “/” 分隔),ZooKeeper 树中的每个节点都可以拥有子节点,这与文件系统的目录树类似。 + + + +ZooKeeper 树型存储结构 + +ZNode 节点类型有如下四种: + + +持久节点。 持久节点创建后,会一直存在,不会因创建该节点的 Client 会话失效而删除。 +持久顺序节点。 持久顺序节点的基本特性与持久节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。 +临时节点。 创建临时节点的 ZooKeeper Client 会话失效之后,其创建的临时节点会被 ZooKeeper 集群自动删除。与持久节点的另一点区别是,临时节点下面不能再创建子节点。 +临时顺序节点。 基本特性与临时节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。 + + +在每个 ZNode 中都维护着一个 stat 结构,记录了该 ZNode 的元数据,其中包括版本号、操作控制列表(ACL)、时间戳和数据长度等信息,如下表所示: + + + +我们除了可以通过 ZooKeeper Client 对 ZNode 进行增删改查等基本操作,还可以注册 Watcher 监听 ZNode 节点、其中的数据以及子节点的变化。一旦监听到变化,则相应的 Watcher 即被触发,相应的 ZooKeeper Client 会立即得到通知。Watcher 有如下特点: + + +主动推送。 Watcher 被触发时,由 ZooKeeper 集群主动将更新推送给客户端,而不需要客户端轮询。 +一次性。 数据变化时,Watcher 只会被触发一次。如果客户端想得到后续更新的通知,必须要在 Watcher 被触发后重新注册一个 Watcher。 +可见性。 如果一个客户端在读请求中附带 Watcher,Watcher 被触发的同时再次读取数据,客户端在得到 Watcher 消息之前肯定不可能看到更新后的数据。换句话说,更新通知先于更新结果。 +顺序性。 如果多个更新触发了多个 Watcher ,那 Watcher 被触发的顺序与更新顺序一致。 + + +消息广播流程概述 + +ZooKeeper 集群中三种角色的节点(Leader、Follower 和 Observer)都可以处理 Client 的读请求,因为每个节点都保存了相同的数据副本,直接进行读取即可返回给 Client。 + +对于写请求,如果 Client 连接的是 Follower 节点(或 Observer 节点),则在 Follower 节点(或 Observer 节点)收到写请求将会被转发到 Leader 节点。下面是 Leader 处理写请求的核心流程: + + +Leader 节点接收写请求后,会为写请求赋予一个全局唯一的 zxid(64 位自增 id),通过 zxid 的大小比较就可以实现写操作的顺序一致性。 +Leader 通过先进先出队列(会给每个 Follower 节点都创建一个队列,保证发送的顺序性),将带有 zxid 的消息作为一个 proposal(提案)分发给所有 Follower 节点。 +当 Follower 节点接收到 proposal 之后,会先将 proposal 写到本地事务日志,写事务成功后再向 Leader 节点回一个 ACK 响应。 +当 Leader 节点接收到过半 Follower 的 ACK 响应之后,Leader 节点就向所有 Follower 节点发送 COMMIT 命令,并在本地执行提交。 +当 Follower 收到消息的 COMMIT 命令之后也会提交操作,写操作到此完成。 +最后,Follower 节点会返回 Client 写请求相应的响应。 + + +下图展示了写操作的核心流程: + + + +写操作核心流程图 + +崩溃恢复 + +上面写请求处理流程中,如果发生 Leader 节点宕机,整个 ZooKeeper 集群可能处于如下两种状态: + + +当 Leader 节点收到半数以上 Follower 节点的 ACK 响应之后,会向各个 Follower 节点广播 COMMIT 命令,同时也会在本地执行 COMMIT 并向连接的客户端进行响应。如果在各个 Follower 收到 COMMIT 命令前 Leader 就宕机了,就会导致剩下的服务器没法执行这条消息。 +当 Leader 节点生成 proposal 之后就宕机了,而其他 Follower 并没有收到此 proposal(或者只有一小部分 Follower 节点收到了这条 proposal),那么此次写操作就是执行失败的。 + + +在 Leader 宕机后,ZooKeeper 会进入崩溃恢复模式,重新进行 Leader 节点的选举。 + +ZooKeeper 对新 Leader 有如下两个要求: + + +对于原 Leader 已经提交了的 proposal,新 Leader 必须能够广播并提交,这样就需要选择拥有最大 zxid 值的节点作为 Leader。 +对于原 Leader 还未广播或只部分广播成功的 proposal,新 Leader 能够通知原 Leader 和已经同步了的 Follower 删除,从而保证集群数据的一致性。 + + +ZooKeeper 选主使用的是 ZAB 协议,如果展开介绍的话内容会非常多,这里我们就通过一个示例简单介绍 ZooKeeper 选主的大致流程。 + +比如,当前集群中有 5 个 ZooKeeper 节点构成,sid 分别为 1、2、3、4 和 5,zxid 分别为 10、10、9、9 和 8,此时,sid 为 1 的节点是 Leader 节点。实际上,zxid 包含了 epoch(高 32 位)和自增计数器(低 32 位) 两部分。其中,epoch 是“纪元”的意思,标识当前 Leader 周期,每次选举时 epoch 部分都会递增,这就防止了网络隔离之后,上一周期的旧 Leader 重新连入集群造成不必要的重新选举。该示例中我们假设各个节点的 epoch 都相同。 + +某一时刻,节点 1 的服务器宕机了,ZooKeeper 集群开始进行选主。由于无法检测到集群中其他节点的状态信息(处于 Looking 状态),因此每个节点都将自己作为被选举的对象来进行投票。于是 sid 为 2、3、4、5 的节点,投票情况分别为(2,10)、(3,9)、(4,9)、(5,8),同时各个节点也会接收到来自其他节点的投票(这里以(sid, zxid)的形式来标识一次投票信息)。 + + +对于节点 2 来说,接收到(3,9)、(4,9)、(5,8)的投票,对比后发现自己的 zxid 最大,因此不需要做任何投票变更。 +对于节点 3 来说,接收到(2,10)、(4,9)、(5,8)的投票,对比后由于 2 的 zxid 比自己的 zxid 要大,因此需要更改投票,改投(2,10),并将改投后的票发给其他节点。 +对于节点 4 来说,接收到(2,10)、(3,9)、(5,8)的投票,对比后由于 2 的 zxid 比自己的 zxid 要大,因此需要更改投票,改投(2,10),并将改投后的票发给其他节点。 +对于节点 5 来说,也是一样,最终改投(2,10)。 + + +经过第二轮投票后,集群中的每个节点都会再次收到其他机器的投票,然后开始统计投票,如果有过半的节点投了同一个节点,则该节点成为新的 Leader,这里显然节点 2 成了新 Leader节点。 + +Leader 节点此时会将 epoch 值加 1,并将新生成的 epoch 分发给各个 Follower 节点。各个 Follower 节点收到全新的 epoch 后,返回 ACK 给 Leader 节点,并带上各自最大的 zxid 和历史事务日志信息。Leader 选出最大的 zxid,并更新自身历史事务日志,示例中的节点 2 无须更新。Leader 节点紧接着会将最新的事务日志同步给集群中所有的 Follower 节点,只有当半数 Follower 同步成功,这个准 Leader 节点才能成为正式的 Leader 节点并开始工作。 + +总结 + +本课时我们重点介绍了 ZooKeeper 的核心概念以及 ZooKeeper 集群的基本工作原理: + + +首先介绍了 ZooKeeper 集群中各个节点的角色以及职能; +然后介绍了 ZooKeeper 中存储数据的逻辑结构以及 ZNode 节点的相关特性; +紧接着又讲解了 ZooKeeper 集群读写数据的核心流程; +最后我们通过示例分析了 ZooKeeper 集群的崩溃恢复流程。 + + +在下一课时,我们将介绍 Apache Curator 的相关内容。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/07ZooKeeper与Curator,求你别用ZkClient了(下).md b/专栏/Dubbo源码解读与实战-完/07ZooKeeper与Curator,求你别用ZkClient了(下).md new file mode 100644 index 0000000..3b6e30e --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/07ZooKeeper与Curator,求你别用ZkClient了(下).md @@ -0,0 +1,856 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 ZooKeeper 与 Curator,求你别用 ZkClient 了(下) + 在上一课时我们介绍了 ZooKeeper 的核心概念以及工作原理,这里我们再简单了解一下 ZooKeeper 客户端的相关内容,毕竟在实际工作中,直接使用客户端与 ZooKeeper 进行交互的次数比深入 ZooKeeper 底层进行扩展和二次开发的次数要多得多。从 ZooKeeper 架构的角度看,使用 Dubbo 的业务节点也只是一个 ZooKeeper 客户端罢了。 + +ZooKeeper 官方提供的客户端支持了一些基本操作,例如,创建会话、创建节点、读取节点、更新数据、删除节点和检查节点是否存在等,但在实际开发中只有这些简单功能是根本不够的。而且,ZooKeeper 本身的一些 API 也存在不足,例如: + + +ZooKeeper 的 Watcher 是一次性的,每次触发之后都需要重新进行注册。 +会话超时之后,没有实现自动重连的机制。 +ZooKeeper 提供了非常详细的异常,异常处理显得非常烦琐,对开发新手来说,非常不友好。 +只提供了简单的 byte[] 数组的接口,没有提供基本类型以及对象级别的序列化。 +创建节点时,如果节点存在抛出异常,需要自行检查节点是否存在。 +删除节点就无法实现级联删除。 + + +常见的第三方开源 ZooKeeper 客户端有 ZkClient 和 Apache Curator。 + +ZkClient 是在 ZooKeeper 原生 API 接口的基础上进行了包装,虽然 ZkClient 解决了 ZooKeeper 原生 API 接口的很多问题,提供了非常简洁的 API 接口,实现了会话超时自动重连的机制,解决了 Watcher 反复注册等问题,但其缺陷也非常明显。例如,文档不全、重试机制难用、异常全部转换成了 RuntimeException、没有足够的参考示例等。可见,一个简单易用、高效可靠的 ZooKeeper 客户端是多么重要。 + +Apache Curator 基础 + +Apache Curator 是 Apache 基金会提供的一款 ZooKeeper 客户端,它提供了一套易用性和可读性非常强的 Fluent 风格的客户端 API ,可以帮助我们快速搭建稳定可靠的 ZooKeeper 客户端程序。 + +为便于你更全面了解 Curator 的功能,我整理出了如下表格,展示了 Curator 提供的 jar 包: + + + +下面我们从最基础的使用展开,逐一介绍 Apache Curator 在实践中常用的核心功能,开始我们的 Apache Curator 之旅。 + +1. 基本操作 + +简单了解了 Apache Curator 各个组件的定位之后,下面我们立刻通过一个示例上手使用 Curator。首先,我们创建一个 Maven 项目,并添加 Apache Curator 的依赖: + + + + org.apache.curator + + curator-recipes + + 4.0.1 + + + + +然后写一个 main 方法,其中会说明 Curator 提供的基础 API 的使用: + +public class Main { + + public static void main(String[] args) throws Exception { + + // Zookeeper集群地址,多个节点地址可以用逗号分隔 + + String zkAddress = "127.0.0.1:2181"; + + // 重试策略,如果连接不上ZooKeeper集群,会重试三次,重试间隔会递增 + + RetryPolicy retryPolicy = + + new ExponentialBackoffRetry(1000, 3); + + // 创建Curator Client并启动,启动成功之后,就可以与Zookeeper进行交互了 + + CuratorFramework client = + + CuratorFrameworkFactory.newClient(zkAddress, retryPolicy); + + client.start(); + + // 下面简单说明Curator中常用的API + + // create()方法创建ZNode,可以调用额外方法来设置节点类型、添加Watcher + + // 下面是创建一个名为"user"的持久节点,其中会存储一个test字符串 + + String path = client.create().withMode(CreateMode.PERSISTENT) + + .forPath("/user", "test".getBytes()); + + System.out.println(path); + + // 输出:/user + + // checkExists()方法可以检查一个节点是否存在 + + Stat stat = client.checkExists().forPath("/user"); + + System.out.println(stat!=null); + + // 输出:true,返回的Stat不为null,即表示节点存在 + + // getData()方法可以获取一个节点中的数据 + + byte[] data = client.getData().forPath("/user"); + + System.out.println(new String(data)); + + // 输出:test + + // setData()方法可以设置一个节点中的数据 + + stat = client.setData().forPath("/user","data".getBytes()); + + data = client.getData().forPath("/user"); + + System.out.println(new String(data)); + + // 输出:data + + // 在/user节点下,创建多个临时顺序节点 + + for (int i = 0; i < 3; i++) { + + client.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL) + + .forPath("/user/child-"; + + } + + // 获取所有子节点 + + List children = client.getChildren().forPath("/user"); + + System.out.println(children); + + // 输出:[child-0000000002, child-0000000001, child-0000000000] + + // delete()方法可以删除指定节点,deletingChildrenIfNeeded()方法 + + // 会级联删除子节点 + + client.delete().deletingChildrenIfNeeded().forPath("/user"); + + } + +} + + +2. Background + +上面介绍的创建、删除、更新、读取等方法都是同步的,Curator 提供异步接口,引入了BackgroundCallback 这个回调接口以及 CuratorListener 这个监听器,用于处理 Background 调用之后服务端返回的结果信息。BackgroundCallback 接口和 CuratorListener 监听器中接收一个 CuratorEvent 的参数,里面包含事件类型、响应码、节点路径等详细信息。 + +下面我们通过一个示例说明 BackgroundCallback 接口以及 CuratorListener 监听器的基本使用: + +public class Main2 { + + public static void main(String[] args) throws Exception { + + // Zookeeper集群地址,多个节点地址可以用逗号分隔 + + String zkAddress = "127.0.0.1:2181"; + + // 重试策略,如果连接不上ZooKeeper集群,会重试三次,重试间隔会递增 + + RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3); + + // 创建Curator Client并启动,启动成功之后,就可以与Zookeeper进行交互了 + + CuratorFramework client = CuratorFrameworkFactory + + .newClient(zkAddress, retryPolicy); + + client.start(); + + // 添加CuratorListener监听器,针对不同的事件进行处理 + + client.getCuratorListenable().addListener( + + new CuratorListener() { + + public void eventReceived(CuratorFramework client, + + CuratorEvent event) throws Exception { + + switch (event.getType()) { + + case CREATE: + + System.out.println("CREATE:" + + + event.getPath()); + + break; + + case DELETE: + + System.out.println("DELETE:" + + + event.getPath()); + + break; + + case EXISTS: + + System.out.println("EXISTS:" + + + event.getPath()); + + break; + + case GET_DATA: + + System.out.println("GET_DATA:" + + + event.getPath() + "," + + + new String(event.getData())); + + break; + + case SET_DATA: + + System.out.println("SET_DATA:" + + + new String(event.getData())); + + break; + + case CHILDREN: + + System.out.println("CHILDREN:" + + + event.getPath()); + + break; + + default: + + } + + } + + }); + + // 注意:下面所有的操作都添加了inBackground()方法,转换为后台操作 + + client.create().withMode(CreateMode.PERSISTENT) + + .inBackground().forPath("/user", "test".getBytes()); + + client.checkExists().inBackground().forPath("/user"); + + client.setData().inBackground().forPath("/user", + + "setData-Test".getBytes()); + + client.getData().inBackground().forPath("/user"); + + for (int i = 0; i < 3; i++) { + + client.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL) + + .inBackground().forPath("/user/child-"); + + } + + client.getChildren().inBackground().forPath("/user"); + + // 添加BackgroundCallback + + client.getChildren().inBackground(new BackgroundCallback() { + + public void processResult(CuratorFramework client, + + CuratorEvent event) throws Exception { + + System.out.println("in background:" + + + event.getType() + "," + event.getPath()); + + } + + }).forPath("/user"); + + client.delete().deletingChildrenIfNeeded().inBackground() + + .forPath("/user"); + + System.in.read(); + + } + +} + +// 输出: + +// CREATE:/user + +// EXISTS:/user + +// GET_DATA:/user,setData-Test + +// CREATE:/user/child- + +// CREATE:/user/child- + +// CREATE:/user/child- + +// CHILDREN:/user + +// DELETE:/user + + +3. 连接状态监听 + +除了基础的数据操作,Curator 还提供了监听连接状态的监听器——ConnectionStateListener,它主要是处理 Curator 客户端和 ZooKeeper 服务器间连接的异常情况,例如, 短暂或者长时间断开连接。 + +短暂断开连接时,ZooKeeper 客户端会检测到与服务端的连接已经断开,但是服务端维护的客户端 Session 尚未过期,之后客户端和服务端重新建立了连接;当客户端重新连接后,由于 Session 没有过期,ZooKeeper 能够保证连接恢复后保持正常服务。 + +而长时间断开连接时,Session 已过期,与先前 Session 相关的 Watcher 和临时节点都会丢失。当 Curator 重新创建了与 ZooKeeper 的连接时,会获取到 Session 过期的相关异常,Curator 会销毁老 Session,并且创建一个新的 Session。由于老 Session 关联的数据不存在了,在 ConnectionStateListener 监听到 LOST 事件时,就可以依靠本地存储的数据恢复 Session 了。 + +这里 Session 指的是 ZooKeeper 服务器与客户端的会话。客户端启动的时候会与服务器建立一个 TCP 连接,从第一次连接建立开始,客户端会话的生命周期也开始了。客户端能够通过心跳检测与服务器保持有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watch 事件通知。 + +我们可以设置客户端会话的超时时间(sessionTimeout),当服务器压力太大、网络故障或是客户端主动断开连接等原因导致连接断开时,只要客户端在 sessionTimeout 规定的时间内能够重新连接到 ZooKeeper 集群中任意一个实例,那么之前创建的会话仍然有效。ZooKeeper 通过 sessionID 唯一标识 Session,所以在 ZooKeeper 集群中,sessionID 需要保证全局唯一。 由于 ZooKeeper 会将 Session 信息存放到硬盘中,即使节点重启,之前未过期的 Session 仍然会存在。 + +public class Main3 { + + public static void main(String[] args) throws Exception { + + // Zookeeper集群地址,多个节点地址可以用逗号分隔 + + String zkAddress = "127.0.0.1:2181"; + + // 重试策略,如果连接不上ZooKeeper集群,会重试三次,重试间隔会递增 + + RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3); + + // 创建Curator Client并启动,启动成功之后,就可以与Zookeeper进行交互了 + + CuratorFramework client = CuratorFrameworkFactory + + .newClient(zkAddress, retryPolicy); + + client.start(); + + // 添加ConnectionStateListener监听器 + + client.getConnectionStateListenable().addListener( + + new ConnectionStateListener() { + + public void stateChanged(CuratorFramework client, + + ConnectionState newState) { + + // 这里我们可以针对不同的连接状态进行特殊的处理 + + switch (newState) { + + case CONNECTED: + + // 第一次成功连接到ZooKeeper之后会进入该状态。 + + // 对于每个CuratorFramework对象,此状态仅出现一次 + + break; + + case SUSPENDED: // ZooKeeper的连接丢失 + + break; + + case RECONNECTED: // 丢失的连接被重新建立 + + break; + + case LOST: + + // 当Curator认为会话已经过期时,则进入此状态 + + break; + + case READ_ONLY: // 连接进入只读模式 + + break; + + } + + } + + }); + + } + +} + + +4. Watcher + +Watcher 监听机制是 ZooKeeper 中非常重要的特性,可以监听某个节点上发生的特定事件,例如,监听节点数据变更、节点删除、子节点状态变更等事件。当相应事件发生时,ZooKeeper 会产生一个 Watcher 事件,并且发送到客户端。通过 Watcher 机制,就可以使用 ZooKeeper 实现分布式锁、集群管理等功能。 + +在 Curator 客户端中,我们可以使用 usingWatcher() 方法添加 Watcher,前面示例中,能够添加 Watcher 的有 checkExists()、getData()以及 getChildren() 三个方法,下面我们来看一个具体的示例: + +public class Main4 { + + public static void main(String[] args) throws Exception { + + // Zookeeper集群地址,多个节点地址可以用逗号分隔 + + String zkAddress = "127.0.0.1:2181"; + + // 重试策略,如果连接不上ZooKeeper集群,会重试三次,重试间隔会递增 + + RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3); + + // 创建Curator Client并启动,启动成功之后,就可以与Zookeeper进行交互了 + + CuratorFramework client = CuratorFrameworkFactory + + .newClient(zkAddress, retryPolicy); + + client.start(); + + try { + + client.create().withMode(CreateMode.PERSISTENT) + + .forPath("/user", "test".getBytes()); + + } catch (Exception e) { + + } + + // 这里通过usingWatcher()方法添加一个Watcher + + List children = client.getChildren().usingWatcher( + + new CuratorWatcher() { + + public void process(WatchedEvent event) throws Exception { + + System.out.println(event.getType() + "," + + + event.getPath()); + + } + + }).forPath("/user"); + + System.out.println(children); + + System.in.read(); + + } + +} + + +接下来,我们打开 ZooKeeper 的命令行客户端,在 /user 节点下先后添加两个子节点,如下所示: + + + +此时我们只得到一行输出: + +NodeChildrenChanged,/user + + +之所以这样,是因为通过 usingWatcher() 方法添加的 CuratorWatcher 只会触发一次,触发完毕后就会销毁。checkExists() 方法、getData() 方法通过 usingWatcher() 方法添加的 Watcher 也是一样的原理,只不过监听的事件不同,你若感兴趣的话,可以自行尝试一下。 + +相信你已经感受到,直接通过注册 Watcher 进行事件监听不是特别方便,需要我们自己反复注册 Watcher。Apache Curator 引入了 Cache 来实现对 ZooKeeper 服务端事件的监听。Cache 是 Curator 中对事件监听的包装,其对事件的监听其实可以近似看作是一个本地缓存视图和远程ZooKeeper 视图的对比过程。同时,Curator 能够自动为开发人员处理反复注册监听,从而大大简化了代码的复杂程度。 + +实践中常用的 Cache 有三大类: + + +NodeCache。 对一个节点进行监听,监听事件包括指定节点的增删改操作。注意哦,NodeCache 不仅可以监听数据节点的内容变更,也能监听指定节点是否存在,如果原本节点不存在,那么 Cache 就会在节点被创建后触发 NodeCacheListener,删除操作亦然。 +PathChildrenCache。 对指定节点的一级子节点进行监听,监听事件包括子节点的增删改操作,但是不对该节点的操作监听。 +TreeCache。 综合 NodeCache 和 PathChildrenCache 的功能,是对指定节点以及其子节点进行监听,同时还可以设置监听的深度。 + + +下面通过示例介绍上述三种 Cache 的基本使用: + +public class Main5 { + + public static void main(String[] args) throws Exception { + + // Zookeeper集群地址,多个节点地址可以用逗号分隔 + + String zkAddress = "127.0.0.1:2181"; + + // 重试策略,如果连接不上ZooKeeper集群,会重试三次,重试间隔会递增 + + RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3); + + // 创建Curator Client并启动,启动成功之后,就可以与Zookeeper进行交互了 + + CuratorFramework client = CuratorFrameworkFactory + + .newClient(zkAddress, retryPolicy); + + client.start(); + + // 创建NodeCache,监听的是"/user"这个节点 + + NodeCache nodeCache = new NodeCache(client, "/user"); + + // start()方法有个boolean类型的参数,默认是false。如果设置为true, + + // 那么NodeCache在第一次启动的时候就会立刻从ZooKeeper上读取对应节点的 + + // 数据内容,并保存在Cache中。 + + nodeCache.start(true); + + if (nodeCache.getCurrentData() != null) { + + System.out.println("NodeCache节点初始化数据为:" + + + new String(nodeCache.getCurrentData().getData())); + + } else { + + System.out.println("NodeCache节点数据为空"); + + } + + // 添加监听器 + + nodeCache.getListenable().addListener(() -> { + + String data = new String(nodeCache.getCurrentData().getData()); + + System.out.println("NodeCache节点路径:" + nodeCache.getCurrentData().getPath() + + + ",节点数据为:" + data); + + }); + + // 创建PathChildrenCache实例,监听的是"user"这个节点 + + PathChildrenCache childrenCache = new PathChildrenCache(client, "/user", true); + + // StartMode指定的初始化的模式 + + // NORMAL:普通异步初始化 + + // BUILD_INITIAL_CACHE:同步初始化 + + // POST_INITIALIZED_EVENT:异步初始化,初始化之后会触发事件 + + childrenCache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE); + + // childrenCache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT); + + // childrenCache.start(PathChildrenCache.StartMode.NORMAL); + + List children = childrenCache.getCurrentData(); + + System.out.println("获取子节点列表:"); + + // 如果是BUILD_INITIAL_CACHE可以获取这个数据,如果不是就不行 + + children.forEach(childData -> { + + System.out.println(new String(childData.getData())); + + }); + + childrenCache.getListenable().addListener(((client1, event) -> { + + System.out.println(LocalDateTime.now() + " " + event.getType()); + + if (event.getType().equals(PathChildrenCacheEvent.Type.INITIALIZED)) { + + System.out.println("PathChildrenCache:子节点初始化成功..."); + + } else if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_ADDED)) { + + String path = event.getData().getPath(); + + System.out.println("PathChildrenCache添加子节点:" + event.getData().getPath()); + + System.out.println("PathChildrenCache子节点数据:" + new String(event.getData().getData())); + + } else if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_REMOVED)) { + + System.out.println("PathChildrenCache删除子节点:" + event.getData().getPath()); + + } else if (event.getType().equals(PathChildrenCacheEvent.Type.CHILD_UPDATED)) { + + System.out.println("PathChildrenCache修改子节点路径:" + event.getData().getPath()); + + System.out.println("PathChildrenCache修改子节点数据:" + new String(event.getData().getData())); + + } + + })); + + // 创建TreeCache实例监听"user"节点 + + TreeCache cache = TreeCache.newBuilder(client, "/user").setCacheData(false).build(); + + cache.getListenable().addListener((c, event) -> { + + if (event.getData() != null) { + + System.out.println("TreeCache,type=" + event.getType() + " path=" + event.getData().getPath()); + + } else { + + System.out.println("TreeCache,type=" + event.getType()); + + } + + }); + + cache.start(); + + System.in.read(); + + } + +} + + +此时,ZooKeeper 集群中存在 /user/test1 和 /user/test2 两个节点,启动上述测试代码,得到的输出如下: + +NodeCache节点初始化数据为:test //NodeCache的相关输出 + +获取子节点列表:// PathChildrenCache的相关输出 + +xxx + +xxx2 + +// TreeCache监听到的事件 + +TreeCache,type=NODE_ADDED path=/user + +TreeCache,type=NODE_ADDED path=/user/test1 + +TreeCache,type=NODE_ADDED path=/user/test2 + +TreeCache,type=INITIALIZED + + +接下来,我们在 ZooKeeper 命令行客户端中更新 /user 节点中的数据: + + + +得到如下输出: + +TreeCache,type=NODE_UPDATED path=/user + +NodeCache节点路径:/user,节点数据为:userData + + +创建 /user/test3 节点: + + + +得到输出: + +TreeCache,type=NODE_ADDED path=/user/test3 + +2020-06-26T08:35:22.393 CHILD_ADDED + +PathChildrenCache添加子节点:/user/test3 + +PathChildrenCache子节点数据:xxx3 + + +更新 /user/test3 节点的数据: + + + +得到输出: + +TreeCache,type=NODE_UPDATED path=/user/test3 + +2020-06-26T08:43:54.604 CHILD_UPDATED + +PathChildrenCache修改子节点路径:/user/test3 + +PathChildrenCache修改子节点数据:xxx33 + + +删除 /user/test3 节点: + + + +得到输出: + +TreeCache,type=NODE_REMOVED path=/user/test3 + +2020-06-26T08:44:06.329 CHILD_REMOVED + +PathChildrenCache删除子节点:/user/test3 + + +curator-x-discovery 扩展库 + +为了避免 curator-framework 包过于膨胀,Curator 将很多其他解决方案都拆出来了,作为单独的一个包,例如:curator-recipes、curator-x-discovery、curator-x-rpc 等。 + +在后面我们会使用到 curator-x-discovery 来完成一个简易 RPC 框架的注册中心模块。curator-x-discovery 扩展包是一个服务发现的解决方案。在 ZooKeeper 中,我们可以使用临时节点实现一个服务注册机制。当服务启动后在 ZooKeeper 的指定 Path 下创建临时节点,服务断掉与 ZooKeeper 的会话之后,其相应的临时节点就会被删除。这个 curator-x-discovery 扩展包抽象了这种功能,并提供了一套简单的 API 来实现服务发现机制。curator-x-discovery 扩展包的核心概念如下: + + +ServiceInstance。 这是 curator-x-discovery 扩展包对服务实例的抽象,由 name、id、address、port 以及一个可选的 payload 属性构成。其存储在 ZooKeeper 中的方式如下图展示的这样。 + + + + + +ServiceProvider。 这是 curator-x-discovery 扩展包的核心组件之一,提供了多种不同策略的服务发现方式,具体策略有轮询调度、随机和黏性(总是选择相同的一个)。得到 ServiceProvider 对象之后,我们可以调用其 getInstance() 方法,按照指定策略获取 ServiceInstance 对象(即发现可用服务实例);还可以调用 getAllInstances() 方法,获取所有 ServiceInstance 对象(即获取全部可用服务实例)。 +ServiceDiscovery。 这是 curator-x-discovery 扩展包的入口类。开始必须调用 start() 方法,当使用完成应该调用 close() 方法进行销毁。 +ServiceCache。 如果程序中会频繁地查询 ServiceInstance 对象,我们可以添加 ServiceCache 缓存,ServiceCache 会在内存中缓存 ServiceInstance 实例的列表,并且添加相应的 Watcher 来同步更新缓存。查询 ServiceCache 的方式也是 getInstances() 方法。另外,ServiceCache 上还可以添加 Listener 来监听缓存变化。 + + +下面通过一个简单示例来说明一下 curator-x-discovery 包的使用,该示例中的 ServerInfo 记录了一个服务的 host、port 以及描述信息。 + +public class ZookeeperCoordinator { + + private ServiceDiscovery serviceDiscovery; + + private ServiceCache serviceCache; + + private CuratorFramework client; + + private String root; + + // 这里的JsonInstanceSerializer是将ServerInfo序列化成Json + + private InstanceSerializer serializer = + + new JsonInstanceSerializer<>(ServerInfo.class); + + ZookeeperCoordinator(Config config) throws Exception { + + this.root = config.getPath(); + + // 创建Curator客户端 + + client = CuratorFrameworkFactory.newClient( + + config.getHostPort(), new ExponentialBackoffRetry(...)); + + client.start(); // 启动Curator客户端 + + client.blockUntilConnected(); // 阻塞当前线程,等待连接成功 + + // 创建ServiceDiscovery + + serviceDiscovery = ServiceDiscoveryBuilder + + .builder(ServerInfo.class) + + .client(client) // 依赖Curator客户端 + + .basePath(root) // 管理的Zk路径 + + .watchInstances(true) // 当ServiceInstance加载 + + .serializer(serializer) + + .build(); + + serviceDiscovery.start(); // 启动ServiceDiscovery + + // 创建ServiceCache,监Zookeeper相应节点的变化,也方便后续的读取 + + serviceCache = serviceDiscovery.serviceCacheBuilder() + + .name(root) + + .build(); + + serviceCache.start(); // 启动ServiceCache + + } + + public void registerRemote(ServerInfo serverInfo)throws Exception{ + + // 将ServerInfo对象转换成ServiceInstance对象 + + ServiceInstance thisInstance = + + ServiceInstance.builder() + + .name(root) + + .id(UUID.randomUUID().toString()) // 随机生成的UUID + + .address(serverInfo.getHost()) // host + + .port(serverInfo.getPort()) // port + + .payload(serverInfo) // payload + + .build(); + + // 将ServiceInstance写入到Zookeeper中 + + serviceDiscovery.registerService(thisInstance); + + } + + public List queryRemoteNodes() { + + List ServerInfoDetails = new ArrayList<>(); + + // 查询 ServiceCache 获取全部的 ServiceInstance 对象 + + List> serviceInstances = + + serviceCache.getInstances(); + + serviceInstances.forEach(serviceInstance -> { + + // 从每个ServiceInstance对象的playload字段中反序列化得 + + // 到ServerInfo实例 + + ServerInfo instance = serviceInstance.getPayload(); + + ServerInfoDetails.add(instance); + + }); + + return ServerInfoDetails; + + } + +} + + +curator-recipes 简介 + +Recipes 是 Curator 对常见分布式场景的解决方案,这里我们只是简单介绍一下,具体的使用和原理,就先不做深入分析了。 + + +Queues。提供了多种的分布式队列解决方法,比如:权重队列、延迟队列等。在生产环境中,很少将 ZooKeeper 用作分布式队列,只适合在压力非常小的情况下,才使用该解决方案,所以建议你要适度使用。 +Counters。全局计数器是分布式系统中很常用的工具,curator-recipes 提供了 SharedCount、DistributedAtomicLong 等组件,帮助开发人员实现分布式计数器功能。 +Locks。java.util.concurrent.locks 中提供的各种锁相信你已经有所了解了,在微服务架构中,分布式锁也是一项非常基础的服务组件,curator-recipes 提供了多种基于 ZooKeeper 实现的分布式锁,满足日常工作中对分布式锁的需求。 +Barries。curator-recipes 提供的分布式栅栏可以实现多个服务之间协同工作,具体实现有 DistributedBarrier 和 DistributedDoubleBarrier。 +Elections。实现的主要功能是在多个参与者中选举出 Leader,然后由 Leader 节点作为操作调度、任务监控或是队列消费的执行者。curator-recipes 给出的实现是 LeaderLatch。 + + +总结 + +本课时我们重点介绍了 Apache Curator 相关的内容: + + +首先将 Apache Curator 与其他 ZooKeeper 客户端进行了对比,Apache Curator 的易用性是选择 Apache Curator 的重要原因。 +接下来,我们通过示例介绍了 Apache Curator 的基本使用方式以及实际使用过程中的一些注意点。 +然后,介绍了 curator-x-discovery 扩展库的基本概念和使用。 +最后,简单介绍了 curator-recipes 提供的强大功能。 + + +关于 Apache Curator,你有什么其他的见解?欢迎你在评论区给我留言,与我分享。 + +zk-demo 链接:https://github.com/xxxlxy2008/zk-demo 。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/08代理模式与常见实现.md b/专栏/Dubbo源码解读与实战-完/08代理模式与常见实现.md new file mode 100644 index 0000000..21af5db --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/08代理模式与常见实现.md @@ -0,0 +1,630 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 代理模式与常见实现 + 动态代理机制在 Java 中有着广泛的应用,例如,Spring AOP、MyBatis、Hibernate 等常用的开源框架,都使用到了动态代理机制。当然,Dubbo 中也使用到了动态代理,在后面开发简易版 RPC 框架的时候,我们还会参考 Dubbo 使用动态代理机制来屏蔽底层的网络传输以及服务发现的相关实现。 + +本课时我们主要从基础知识开始讲起,首先介绍代理模式的基本概念,之后重点介绍 JDK 动态代理的使用以及底层实现原理,同时还会说明 JDK 动态代理的一些局限性,最后再介绍基于字节码生成的动态代理。 + +代理模式 + +代理模式是 23 种面向对象的设计模式中的一种,它的类图如下所示: + + + +图中的 Subject 是程序中的业务逻辑接口,RealSubject 是实现了 Subject 接口的真正业务类,Proxy 是实现了 Subject 接口的代理类,封装了一个 RealSubject 引用。在程序中不会直接调用 RealSubject 对象的方法,而是使用 Proxy 对象实现相关功能。 + +Proxy.operation() 方法的实现会调用其中封装的 RealSubject 对象的 operation() 方法,执行真正的业务逻辑。代理的作用不仅仅是正常地完成业务逻辑,还会在业务逻辑前后添加一些代理逻辑,也就是说,Proxy.operation() 方法会在 RealSubject.operation() 方法调用前后进行一些预处理以及一些后置处理。这就是我们常说的“代理模式”。 + +使用代理模式可以控制程序对 RealSubject 对象的访问,如果发现异常的访问,可以直接限流或是返回,也可以在执行业务处理的前后进行相关的预处理和后置处理,帮助上层调用方屏蔽底层的细节。例如,在 RPC 框架中,代理可以完成序列化、网络 I/O 操作、负载均衡、故障恢复以及服务发现等一系列操作,而上层调用方只感知到了一次本地调用。 + +代理模式还可以用于实现延迟加载的功能。我们知道查询数据库是一个耗时的操作,而有些时候查询到的数据也并没有真正被程序使用。延迟加载功能就可以有效地避免这种浪费,系统访问数据库时,首先可以得到一个代理对象,此时并没有执行任何数据库查询操作,代理对象中自然也没有真正的数据;当系统真正需要使用数据时,再调用代理对象完成数据库查询并返回数据。常见 ORM 框架(例如,MyBatis、 Hibernate)中的延迟加载的原理大致也是如此。 + +另外,代理对象可以协调真正RealSubject 对象与调用者之间的关系,在一定程度上实现了解耦的效果。 + +JDK 动态代理 + +上面介绍的这种代理模式实现,也被称为“静态代理模式”,这是因为在编译阶段就要为每个RealSubject 类创建一个 Proxy 类,当需要代理的类很多时,就会出现大量的 Proxy 类。 + +这种场景下,我们可以使用 JDK 动态代理解决这个问题。JDK 动态代理的核心是InvocationHandler 接口。这里提供一个 InvocationHandler 的Demo 实现,代码如下: + +public class DemoInvokerHandler implements InvocationHandler { + + private Object target; // 真正的业务对象,也就是RealSubject对象 + + public DemoInvokerHandler(Object target) { // 构造方法 + + this.target = target; + + } + + public Object invoke(Object proxy, Method method, Object[] args) + + throws Throwable { + + // ...在执行业务方法之前的预处理... + + Object result = method.invoke(target, args); + + // ...在执行业务方法之后的后置处理... + + return result; + + } + + public Object getProxy() { + + // 创建代理对象 + + return Proxy.newProxyInstance(Thread.currentThread() + + .getContextClassLoader(), + + target.getClass().getInterfaces(), this); + + } + +} + + +接下来,我们可以创建一个 main() 方法来模拟上层调用者,创建并使用动态代理: + +public class Main { + + public static void main(String[] args) { + + Subject subject = new RealSubject(); + + DemoInvokerHandler invokerHandler = + + new DemoInvokerHandler(subject); + + // 获取代理对象 + + Subject proxy = (Subject) invokerHandler.getProxy(); + + // 调用代理对象的方法,它会调用DemoInvokerHandler.invoke()方法 + + proxy.operation(); + + } + +} + + +对于需要相同代理逻辑的业务类,只需要提供一个 InvocationHandler 接口实现类即可。在 Java 运行的过程中,JDK会为每个 RealSubject 类动态生成相应的代理类并加载到 JVM 中,然后创建对应的代理实例对象,返回给上层调用者。 + +了解了 JDK 动态代理的基本使用之后,下面我们就来分析 JDK动态代理创建代理类的底层实现原理。不同JDK版本的 Proxy 类实现可能有细微差别,但核心思路不变,这里使用 1.8.0 版本的 JDK。 + +JDK 动态代理相关实现的入口是 Proxy.newProxyInstance() 这个静态方法,它的三个参数分别是加载动态生成的代理类的类加载器、业务类实现的接口和上面介绍的InvocationHandler对象。Proxy.newProxyInstance()方法的具体实现如下: + +public static Object newProxyInstance(ClassLoader loader, + + Class[] interfaces, InvocationHandler h) + + throws IllegalArgumentException { + + final Class[] intfs = interfaces.clone(); + + // ...省略权限检查等代码 + + Class cl = getProxyClass0(loader, intfs); // 获取代理类 + + // ...省略try/catch代码块和相关异常处理 + + // 获取代理类的构造方法 + + final Constructor cons = cl.getConstructor(constructorParams); + + final InvocationHandler ih = h; + + return cons.newInstance(new Object[]{h}); // 创建代理对象 + +} + + +通过 newProxyInstance()方法的实现可以看到,JDK 动态代理是在 getProxyClass0() 方法中完成代理类的生成和加载。getProxyClass0() 方法的具体实现如下: + +private static Class getProxyClass0 (ClassLoader loader, + + Class... interfaces) { + + // 边界检查,限制接口数量(略) + + // 如果指定的类加载器中已经创建了实现指定接口的代理类,则查找缓存; + + // 否则通过ProxyClassFactory创建实现指定接口的代理类 + + return proxyClassCache.get(loader, interfaces); + +} + + +proxyClassCache 是定义在 Proxy 类中的静态字段,主要用于缓存已经创建过的代理类,定义如下: + +private static final WeakCache[], Class> proxyClassCache + + = new WeakCache<>(new KeyFactory(), + + new ProxyClassFactory()); + + +WeakCache.get() 方法会首先尝试从缓存中查找代理类,如果查找不到,则会创建 Factory 对象并调用其 get() 方法获取代理类。Factory 是 WeakCache 中的内部类,Factory.get() 方法会调用 ProxyClassFactory.apply() 方法创建并加载代理类。 + +ProxyClassFactory.apply() 方法首先会检测代理类需要实现的接口集合,然后确定代理类的名称,之后创建代理类并将其写入文件中,最后加载代理类,返回对应的 Class 对象用于后续的实例化代理类对象。该方法的具体实现如下: + +public Class apply(ClassLoader loader, Class[] interfaces) { + + // ... 对interfaces集合进行一系列检测(略) + + // ... 选择定义代理类的包名(略) + + // 代理类的名称是通过包名、代理类名称前缀以及编号这三项组成的 + + long num = nextUniqueNumber.getAndIncrement(); + + String proxyName = proxyPkg + proxyClassNamePrefix + num; + + // 生成代理类,并写入文件 + + byte[] proxyClassFile = ProxyGenerator.generateProxyClass( + + proxyName, interfaces, accessFlags); + + + + // 加载代理类,并返回Class对象 + + return defineClass0(loader, proxyName, proxyClassFile, 0, + + proxyClassFile.length); + +} + + +ProxyGenerator.generateProxyClass() 方法会按照指定的名称和接口集合生成代理类的字节码,并根据条件决定是否保存到磁盘上。该方法的具体代码如下: + +public static byte[] generateProxyClass(final String name, + + Class[] interfaces) { + + ProxyGenerator gen = new ProxyGenerator(name, interfaces); + + // 动态生成代理类的字节码,具体生成过程不再详细介绍,感兴趣的读者可以继续分析 + + final byte[] classFile = gen.generateClassFile(); + + // 如果saveGeneratedFiles值为true,会将生成的代理类的字节码保存到文件中 + + if (saveGeneratedFiles) { + + java.security.AccessController.doPrivileged( + + new java.security.PrivilegedAction() { + + public Void run() { + + // 省略try/catch代码块 + + FileOutputStream file = new FileOutputStream( + + dotToSlash(name) + ".class"); + + file.write(classFile); + + file.close(); + + return null; + + } + + } + + ); + + } + + return classFile; // 返回上面生成的代理类的字节码 + +} + + +最后,为了清晰地看到JDK动态生成的代理类的真正定义,我们需要将上述生成的代理类的字节码进行反编译。上述示例为RealSubject生成的代理类,反编译后得到的代码如下: + +public final class $Proxy37 + + extends Proxy implements Subject { // 实现了Subject接口 + + // 这里省略了从Object类继承下来的相关方法和属性 + + private static Method m3; + + static { + + // 省略了try/catch代码块 + + // 记录了operation()方法对应的Method对象 + + m3 = Class.forName("com.xxx.Subject") + + .getMethod("operation", new Class[0]); + + } + + // 构造方法的参数就是我们在示例中使用的DemoInvokerHandler对象 + + public $Proxy11(InvocationHandler var1) throws { + + super(var1); + + } + + public final void operation() throws { + + // 省略了try/catch代码块 + + // 调用DemoInvokerHandler对象的invoke()方法 + + // 最终调用RealSubject对象的对应方法 + + super.h.invoke(this, m3, (Object[]) null); + + } + +} + + +至此JDK 动态代理的基本使用以及核心原理就介绍完了。简单总结一下,JDK 动态代理的实现原理是动态创建代理类并通过指定类加载器进行加载,在创建代理对象时将InvocationHandler对象作为构造参数传入。当调用代理对象时,会调用 InvocationHandler.invoke() 方法,从而执行代理逻辑,并最终调用真正业务对象的相应方法。 + +CGLib + +JDK 动态代理是 Java 原生支持的,不需要任何外部依赖,但是正如上面分析的那样,它只能基于接口进行代理,对于没有继承任何接口的类,JDK 动态代理就没有用武之地了。 + +如果想对没有实现任何接口的类进行代理,可以考虑使用 CGLib。 + +CGLib(Code Generation Library)是一个基于 ASM 的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLib 采用字节码技术实现动态代理功能,其底层原理是通过字节码技术为目标类生成一个子类,并在该子类中采用方法拦截的方式拦截所有父类方法的调用,从而实现代理的功能。 + +因为 CGLib 使用生成子类的方式实现动态代理,所以无法代理 final 关键字修饰的方法(因为final 方法是不能够被重写的)。这样的话,CGLib 与 JDK 动态代理之间可以相互补充:在目标类实现接口时,使用 JDK 动态代理创建代理对象;当目标类没有实现接口时,使用 CGLib 实现动态代理的功能。在 Spring、MyBatis 等多种开源框架中,都可以看到JDK动态代理与 CGLib 结合使用的场景。 + +CGLib 的实现有两个重要的成员组成。 + + +Enhancer:指定要代理的目标对象以及实际处理代理逻辑的对象,最终通过调用 create() 方法得到代理对象,对这个对象所有的非 final 方法的调用都会转发给 MethodInterceptor 进行处理。 +MethodInterceptor:动态代理对象的方法调用都会转发到intercept方法进行增强。 + + +这两个组件的使用与 JDK 动态代理中的 Proxy 和 InvocationHandler 相似。 + +下面我们通过一个示例简单介绍 CGLib 的使用。在使用 CGLib 创建动态代理类时,首先需要定义一个 Callback 接口的实现, CGLib 中也提供了多个Callback接口的子接口,如下图所示: + + + +这里以 MethodInterceptor 接口为例进行介绍,首先我们引入 CGLib 的 maven 依赖: + + + + cglib + + cglib + + 3.3.0 + + + + +下面是 CglibProxy 类的具体代码,它实现了 MethodInterceptor 接口: + +public class CglibProxy implements MethodInterceptor { + + // 初始化Enhancer对象 + + private Enhancer enhancer = new Enhancer(); + + + + public Object getProxy(Class clazz) { + + enhancer.setSuperclass(clazz); // 指定生成的代理类的父类 + + enhancer.setCallback(this); // 设置Callback对象 + + return enhancer.create(); // 通过ASM字节码技术动态创建子类实例 + + } + + + + // 实现MethodInterceptor接口的intercept()方法 + + public Object intercept(Object obj, Method method, Object[] args, + + MethodProxy proxy) throws Throwable { + + System.out.println("前置处理"); + + Object result = proxy.invokeSuper(obj, args); // 调用父类中的方法 + + System.out.println("后置处理"); + + return result; + + } + +} + + +下面我们再编写一个要代理的目标类以及 main 方法进行测试,具体如下: + +public class CGLibTest { // 目标类 + + public String method(String str) { // 目标方法 + + System.out.println(str); + + return "CGLibTest.method():" + str; + + } + + + + public static void main(String[] args) { + + CglibProxy proxy = new CglibProxy(); + + // 生成CBLibTest的代理对象 + + CGLibTest proxyImp = (CGLibTest) + + proxy.getProxy(CGLibTest.class); + + // 调用代理对象的method()方法 + + String result = proxyImp.method("test"); + + System.out.println(result); + + // ---------------- + + // 输出如下: + + // 前置代理 + + // test + + // 后置代理 + + // CGLibTest.method():test + + } + +} + + +到此,CGLib 基础使用的内容就介绍完了,在后面介绍 Dubbo 源码时我们还会继续介绍涉及的 CGLib 内容。 + +Javassist + +Javassist 是一个开源的生成 Java 字节码的类库,其主要优点在于简单、快速,直接使用Javassist 提供的 Java API 就能动态修改类的结构,或是动态生成类。 + +Javassist 的使用比较简单,首先来看如何使用 Javassist 提供的 Java API 动态创建类。示例代码如下: + +public class JavassistMain { + + public static void main(String[] args) throws Exception { + + ClassPool cp = ClassPool.getDefault(); // 创建ClassPool + + // 要生成的类名称为com.test.JavassistDemo + + CtClass clazz = cp.makeClass("com.test.JavassistDemo"); + + + + StringBuffer body = null; + + // 创建字段,指定了字段类型、字段名称、字段所属的类 + + CtField field = new CtField(cp.get("java.lang.String"), + + "prop", clazz); + + // 指定该字段使用private修饰 + + field.setModifiers(Modifier.PRIVATE); + + + + // 设置prop字段的getter/setter方法 + + clazz.addMethod(CtNewMethod.setter("getProp", field)); + + clazz.addMethod(CtNewMethod.getter("setProp", field)); + + // 设置prop字段的初始化值,并将prop字段添加到clazz中 + + clazz.addField(field, CtField.Initializer.constant("MyName")); + + + + // 创建构造方法,指定了构造方法的参数类型和构造方法所属的类 + + CtConstructor ctConstructor = new CtConstructor( + + new CtClass[]{}, clazz); + + // 设置方法体 + + body = new StringBuffer(); + + body.append("{\n prop=\"MyName\";\n}"); + + ctConstructor.setBody(body.toString()); + + clazz.addConstructor(ctConstructor); // 将构造方法添加到clazz中 + + + + // 创建execute()方法,指定了方法返回值、方法名称、方法参数列表以及 + + // 方法所属的类 + + CtMethod ctMethod = new CtMethod(CtClass.voidType, "execute", + + new CtClass[]{}, clazz); + + // 指定该方法使用public修饰 + + ctMethod.setModifiers(Modifier.PUBLIC); + + // 设置方法体 + + body = new StringBuffer(); + + body.append("{\n System.out.println(\"execute():\" " + + + "+ this.prop);"); + + body.append("\n}"); + + ctMethod.setBody(body.toString()); + + clazz.addMethod(ctMethod); // 将execute()方法添加到clazz中 + + // 将上面定义的JavassistDemo类保存到指定的目录 + + clazz.writeFile("/Users/xxx/"); + + // 加载clazz类,并创建对象 + + Class c = clazz.toClass(); + + Object o = c.newInstance(); + + // 调用execute()方法 + + Method method = o.getClass().getMethod("execute", + + new Class[]{}); + + method.invoke(o, new Object[]{}); + + } + +} + + +执行上述代码之后,在指定的目录下可以找到生成的 JavassistDemo.class 文件,将其反编译,得到 JavassistDemo 的代码如下: + +public class JavassistDemo { + + private String prop = "MyName"; + + public JavassistDemo() { + + prop = "MyName"; + + } + + public void setProp(String paramString) { + + this.prop = paramString; + + } + + public String getProp() { + + return this.prop; + + } + + public void execute() { + + System.out.println("execute():" + this.prop); + + } + +} + + +Javassist 也可以实现动态代理功能,底层的原理也是通过创建目标类的子类的方式实现的。这里使用 Javassist 为上面生成的 JavassitDemo 创建一个代理对象,具体实现如下: + +public class JavassitMain2 { + + public static void main(String[] args) throws Exception { + + ProxyFactory factory = new ProxyFactory(); + + // 指定父类,ProxyFactory会动态生成继承该父类的子类 + + factory.setSuperclass(JavassistDemo.class); + + // 设置过滤器,判断哪些方法调用需要被拦截 + + factory.setFilter(new MethodFilter() { + + public boolean isHandled(Method m) { + + if (m.getName().equals("execute")) { + + return true; + + } + + return false; + + } + + }); + + // 设置拦截处理 + + factory.setHandler(new MethodHandler() { + + @Override + + public Object invoke(Object self, Method thisMethod, + + Method proceed, Object[] args) throws Throwable { + + System.out.println("前置处理"); + + Object result = proceed.invoke(self, args); + + System.out.println("执行结果:" + result); + + System.out.println("后置处理"); + + return result; + + } + + }); + + // 创建JavassistDemo的代理类,并创建代理对象 + + Class c = factory.createClass(); + + JavassistDemo JavassistDemo = (JavassistDemo) c.newInstance(); + + JavassistDemo.execute(); // 执行execute()方法,会被拦截 + + System.out.println(JavassistDemo.getProp()); + + } + +} + + +Javassist 的基础知识就介绍到这里。Javassist可以直接使用 Java 语言的字符串生成类,还是比较好用的。Javassist 的性能也比较好,是 Dubbo 默认的代理生成方式。 + +总结 + +本课时我们首先介绍了代理模式的核心概念和用途,让你对代理模式有初步的了解;然后介绍了 JDK 动态代理使用,并深入到 JDK 源码中分析了 JDK 动态代理的实现原理,以及 JDK 动态代理的局限;最后我们介绍了 CGLib和Javassist这两款代码生成工具的基本使用,简述了两者生成代理的原理。 + +那你还知道哪些实现动态代理的方式呢?欢迎你在评论区留言讨论。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/09Netty入门,用它做网络编程都说好(上).md b/专栏/Dubbo源码解读与实战-完/09Netty入门,用它做网络编程都说好(上).md new file mode 100644 index 0000000..7a4ea56 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/09Netty入门,用它做网络编程都说好(上).md @@ -0,0 +1,122 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 Netty 入门,用它做网络编程都说好(上) + 了解 Java 的同学应该知道,JDK 本身提供了一套 NIO 的 API,但是这一套原生的 API 存在一系列的问题。 + + +Java NIO 的 API 非常复杂。 要写出成熟可用的 Java NIO 代码,需要熟练掌握 JDK 中的 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等组件,还要理解其中一些反人类的设计以及底层原理,这对新手来说是非常不友好的。 +如果直接使用 Java NIO 进行开发,难度和开发量会非常大。我们需要自己补齐很多可靠性方面的实现,例如,网络波动导致的连接重连、半包读写等。这就会导致一些本末倒置的情况出现:核心业务逻辑比较简单,但补齐其他公共能力的代码非常多,开发耗时比较长。这时就需要一个统一的 NIO 框架来封装这些公共能力了。 +JDK 自身的 Bug。其中比较出名的就要属 Epoll Bug 了,这个 Bug 会导致 Selector 空轮询,CPU 使用率达到 100%,这样就会导致业务逻辑无法执行,降低服务性能。 + + +Netty 在 JDK 自带的 NIO API 基础之上进行了封装,解决了 JDK 自身的一些问题,具备如下优点: + + +入门简单,使用方便,文档齐全,无其他依赖,只依赖 JDK 就够了。 +高性能,高吞吐,低延迟,资源消耗少。 +灵活的线程模型,支持阻塞和非阻塞的I/O 模型。 +代码质量高,目前主流版本基本没有 Bug。 + + +正因为 Netty 有以上优点,所以很多互联网公司以及开源的 RPC 框架都将其作为网络通信的基础库,例如,Apache Spark、Apache Flink、 Elastic Search 以及我们本课程分析的 Dubbo 等。 + +下面我们将从 I/O 模型和线程模型的角度详细为你介绍 Netty 的核心设计,进而帮助你全面掌握 Netty 原理。 + +Netty I/O 模型设计 + +在进行网络 I/O 操作的时候,用什么样的方式读写数据将在很大程度上决定了 I/O 的性能。作为一款优秀的网络基础库,Netty 就采用了 NIO 的 I/O 模型,这也是其高性能的重要原因之一。 + +1. 传统阻塞 I/O 模型 + +在传统阻塞型 I/O 模型(即我们常说的 BIO)中,如下图所示,每个请求都需要独立的线程完成读数据、业务处理以及写回数据的完整操作。 + + + +一个线程在同一时刻只能与一个连接绑定,如下图所示,当请求的并发量较大时,就需要创建大量线程来处理连接,这就会导致系统浪费大量的资源进行线程切换,降低程序的性能。我们知道,网络数据的传输速度是远远慢于 CPU 的处理速度,连接建立后,并不总是有数据可读,连接也并不总是可写,那么线程就只能阻塞等待,CPU 的计算能力不能得到充分发挥,同时还会导致大量线程的切换,浪费资源。 + + + +2. I/O 多路复用模型 + +针对传统的阻塞 I/O 模型的缺点,I/O 复用的模型在性能方面有不小的提升。I/O 复用模型中的多个连接会共用一个 Selector 对象,由 Selector 感知连接的读写事件,而此时的线程数并不需要和连接数一致,只需要很少的线程定期从 Selector 上查询连接的读写状态即可,无须大量线程阻塞等待连接。当某个连接有新的数据可以处理时,操作系统会通知线程,线程从阻塞状态返回,开始进行读写操作以及后续的业务逻辑处理。I/O 复用的模型如下图所示: + + + +Netty 就是采用了上述 I/O 复用的模型。由于多路复用器 Selector 的存在,可以同时并发处理成百上千个网络连接,大大增加了服务器的处理能力。另外,Selector 并不会阻塞线程,也就是说当一个连接不可读或不可写的时候,线程可以去处理其他可读或可写的连接,这就充分提升了 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程切换。如下图所示: + + + +从数据处理的角度来看,传统的阻塞 I/O 模型处理的是字节流或字符流,也就是以流式的方式顺序地从一个数据流中读取一个或多个字节,并且不能随意改变读取指针的位置。而在 NIO 中则抛弃了这种传统的 I/O 流概念,引入了 Channel 和 Buffer 的概念,可以从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。Buffer 不像传统 I/O 中的流那样必须顺序操作,在 NIO 中可以读写 Buffer 中任意位置的数据。 + +Netty 线程模型设计 + +服务器程序在读取到二进制数据之后,首先需要通过编解码,得到程序逻辑可以理解的消息,然后将消息传入业务逻辑进行处理,并产生相应的结果,返回给客户端。编解码逻辑、消息派发逻辑、业务处理逻辑以及返回响应的逻辑,是放到一个线程里面串行执行,还是分配到不同的线程中执行,会对程序的性能产生很大的影响。所以,优秀的线程模型对一个高性能网络库来说是至关重要的。 + +Netty 采用了 Reactor 线程模型的设计。 Reactor 模式,也被称为 Dispatcher 模式,核心原理是 Selector 负责监听 I/O 事件,在监听到 I/O 事件之后,分发(Dispatch)给相关线程进行处理。 + +为了帮助你更好地了解 Netty 线程模型的设计理念,我们将从最基础的单 Reactor 单线程模型开始介绍,然后逐步增加模型的复杂度,最终到 Netty 目前使用的非常成熟的线程模型设计。 + +1. 单 Reactor 单线程 + +Reactor 对象监听客户端请求事件,收到事件后通过 Dispatch 进行分发。如果是连接建立的事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立的事件,而是数据的读写事件,则 Reactor 会将事件分发对应的 Handler 来处理,由这里唯一的线程调用 Handler 对象来完成读取数据、业务处理、发送响应的完整流程。当然,该过程中也可能会出现连接不可读或不可写等情况,该单线程会去执行其他 Handler 的逻辑,而不是阻塞等待。具体情况如下图所示: + + + +单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。 + +但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型。 + +2. 单 Reactor 多线程 + +在单 Reactor 多线程的架构中,Reactor 监控到客户端请求之后,如果连接建立的请求,则由Acceptor 通过 accept 处理,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立请求,则 Reactor 会将事件分发给调用连接对应的 Handler 来处理。到此为止,该流程与单 Reactor 单线程的模型基本一致,唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池。 + + + +单 Reactor 多线程模型 + +很明显,单 Reactor 多线程的模型可以充分利用多核 CPU 的处理能力,提高整个系统的吞吐量,但引入多线程模型就要考虑线程并发、数据共享、线程调度等问题。在这个模型中,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,其中就包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。 + +3. 主从 Reactor 多线程 + +为了解决单 Reactor 多线程模型中的问题,我们可以引入多个 Reactor。其中,Reactor 主线程负责通过 Acceptor 对象处理 MainReactor 监听到的连接建立事件,当Acceptor 完成网络连接的建立之后,MainReactor 会将建立好的连接分配给 SubReactor 进行后续监听。 + +当一个连接被分配到一个 SubReactor 之上时,会由 SubReactor 负责监听该连接上的读写事件。当有新的读事件(OP_READ)发生时,Reactor 子线程就会调用对应的 Handler 读取数据,然后分发给 Worker 线程池中的线程进行处理并返回结果。待处理结束之后,Handler 会根据处理结果调用 send 将响应返回给客户端,当然此时连接要有可写事件(OP_WRITE)才能发送数据。 + + + +主从 Reactor 多线程模型 + +主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。 + +4. Netty 线程模型 + +Netty 同时支持上述几种线程模式,Netty 针对服务器端的设计是在主从 Reactor 多线程模型的基础上进行的修改,如下图所示: + + + +Netty 抽象出两组线程池:BossGroup 专门用于接收客户端的连接,WorkerGroup 专门用于网络的读写。BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup,相当于一个事件循环组,其中包含多个事件循环 ,每一个事件循环是 NioEventLoop。 + +NioEventLoop 表示一个不断循环的、执行处理任务的线程,每个 NioEventLoop 都有一个Selector 对象与之对应,用于监听绑定在其上的连接,这些连接上的事件由 Selector 对应的这条线程处理。每个 NioEventLoopGroup 可以含有多个 NioEventLoop,也就是多个线程。 + +每个 Boss NioEventLoop 会监听 Selector 上连接建立的 accept 事件,然后处理 accept 事件与客户端建立网络连接,生成相应的 NioSocketChannel 对象,一个 NioSocketChannel 就表示一条网络连接。之后会将 NioSocketChannel 注册到某个 Worker NioEventLoop 上的 Selector 中。 + +每个 Worker NioEventLoop 会监听对应 Selector 上的 read/write 事件,当监听到 read/write 事件的时候,会通过 Pipeline 进行处理。一个 Pipeline 与一个 Channel 绑定,在 Pipeline 上可以添加多个 ChannelHandler,每个 ChannelHandler 中都可以包含一定的逻辑,例如编解码等。Pipeline 在处理请求的时候,会按照我们指定的顺序调用 ChannelHandler。 + +总结 + +在本课时我们重点介绍了网络 I/O 的一些背景知识,以及 Netty 的一些宏观设计模型。 + + +首先,我们介绍了 Java NIO 的一些缺陷和不足,这也是 Netty 等网络库出现的重要原因之一。 +接下来,我们介绍了 Netty 在 I/O 模型上的设计,阐述了 I/O 多路复用的优势。 +最后,我们从基础的单 Reactor 单线程模型开始,一步步深入,介绍了常见的网络 I/O 线程模型,并介绍了 Netty 目前使用的线程模型。 + + +当然,关于 Netty 的相关内容,也欢迎你在留言区与我分享和交流。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/10Netty入门,用它做网络编程都说好(下).md b/专栏/Dubbo源码解读与实战-完/10Netty入门,用它做网络编程都说好(下).md new file mode 100644 index 0000000..f42bb29 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/10Netty入门,用它做网络编程都说好(下).md @@ -0,0 +1,230 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 Netty 入门,用它做网络编程都说好(下) + 在上一课时,我们从 I/O 模型以及线程模型两个角度,宏观介绍了 Netty 的设计。在本课时,我们就深入到 Netty 内部,介绍一下 Netty 框架核心组件的功能,并概述它们的实现原理,进一步帮助你了解 Netty 的内核。 + +这里我们依旧采用之前的思路来介绍 Netty 的核心组件:首先是 Netty 对 I/O 模型设计中概念的抽象,如 Selector 等组件;接下来是线程模型的相关组件介绍,主要是 NioEventLoop、NioEventLoopGroup 等;最后再深入剖析 Netty 处理数据的相关组件,例如 ByteBuf、内存管理的相关知识。 + +Channel + +Channel 是 Netty 对网络连接的抽象,核心功能是执行网络 I/O 操作。不同协议、不同阻塞类型的连接对应不同的 Channel 类型。我们一般用的都是 NIO 的 Channel,下面是一些常用的 NIO Channel 类型。 + + +NioSocketChannel:对应异步的 TCP Socket 连接。 +NioServerSocketChannel:对应异步的服务器端 TCP Socket 连接。 +NioDatagramChannel:对应异步的 UDP 连接。 + + +上述异步 Channel 主要提供了异步的网络 I/O 操作,例如:建立连接、读写操作等。异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用返回时所请求的 I/O 操作已完成。I/O 操作返回的是一个 ChannelFuture 对象,无论 I/O 操作是否成功,Channel 都可以通过监听器通知调用方,我们通过向 ChannelFuture 上注册监听器来监听 I/O 操作的结果。 + +Netty 也支持同步 I/O 操作,但在实践中几乎不使用。绝大多数情况下,我们使用的是 Netty 中异步 I/O 操作。虽然立即返回一个 ChannelFuture 对象,但不能立刻知晓 I/O 操作是否成功,这时我们就需要向 ChannelFuture 中注册一个监听器,当操作执行成功或失败时,监听器会自动触发注册的监听事件。 + +另外,Channel 还提供了检测当前网络连接状态等功能,这些可以帮助我们实现网络异常断开后自动重连的功能。 + +Selector + +Selector 是对多路复用器的抽象,也是 Java NIO 的核心基础组件之一。Netty 就是基于 Selector 对象实现 I/O 多路复用的,在 Selector 内部,会通过系统调用不断地查询这些注册在其上的 Channel 是否有已就绪的 I/O 事件,例如,可读事件(OP_READ)、可写事件(OP_WRITE)或是网络连接事件(OP_ACCEPT)等,而无须使用用户线程进行轮询。这样,我们就可以用一个线程监听多个 Channel 上发生的事件。 + +ChannelPipeline&ChannelHandler + +提到 Pipeline,你可能最先想到的是 Linux 命令中的管道,它可以实现将一条命令的输出作为另一条命令的输入。Netty 中的 ChannelPipeline 也可以实现类似的功能:ChannelPipeline 会将一个 ChannelHandler 处理后的数据作为下一个 ChannelHandler 的输入。 + +下图我们引用了 Netty Javadoc 中对 ChannelPipeline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常是如何处理 I/O 事件的。Netty 中定义了两种事件类型:入站(Inbound)事件和出站(Outbound)事件。这两种事件就像 Linux 管道中的数据一样,在 ChannelPipeline 中传递,事件之中也可能会附加数据。ChannelPipeline 之上可以注册多个 ChannelHandler(ChannelInboundHandler 或 ChannelOutboundHandler),我们在 ChannelHandler 注册的时候决定处理 I/O 事件的顺序,这就是典型的责任链模式。 + + + +从图中我们还可以看到,I/O 事件不会在 ChannelPipeline 中自动传播,而是需要调用ChannelHandlerContext 中定义的相应方法进行传播,例如:fireChannelRead() 方法和 write() 方法等。 + +这里我们举一个简单的例子,如下所示,在该 ChannelPipeline 上,我们添加了 5 个 ChannelHandler 对象: + +ChannelPipeline p = socketChannel.pipeline(); + +p.addLast("1", new InboundHandlerA()); + +p.addLast("2", new InboundHandlerB()); + +p.addLast("3", new OutboundHandlerA()); + +p.addLast("4", new OutboundHandlerB()); + +p.addLast("5", new InboundOutboundHandlerX()); + + + +对于入站(Inbound)事件,处理序列为:1 → 2 → 5; +对于出站(Outbound)事件,处理序列为:5 → 4 → 3。 + + +可见,入站(Inbound)与出站(Outbound)事件处理顺序正好相反。 + +入站(Inbound)事件一般由 I/O 线程触发。举个例子,我们自定义了一种消息协议,一条完整的消息是由消息头和消息体两部分组成,其中消息头会含有消息类型、控制位、数据长度等元数据,消息体则包含了真正传输的数据。在面对一块较大的数据时,客户端一般会将数据切分成多条消息发送,服务端接收到数据后,一般会先进行解码和缓存,待收集到长度足够的字节数据,组装成有固定含义的消息之后,才会传递给下一个 ChannelInboudHandler 进行后续处理。 + +在 Netty 中就提供了很多 Encoder 的实现用来解码读取到的数据,Encoder 会处理多次 channelRead() 事件,等拿到有意义的数据之后,才会触发一次下一个 ChannelInboundHandler 的 channelRead() 方法。 + +出站(Outbound)事件与入站(Inbound)事件相反,一般是由用户触发的。 + +ChannelHandler 接口中并没有定义方法来处理事件,而是由其子类进行处理的,如下图所示,ChannelInboundHandler 拦截并处理入站事件,ChannelOutboundHandler 拦截并处理出站事件。 + + + +Netty 提供的 ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter 主要是帮助完成事件流转功能的,即自动调用传递事件的相应方法。这样,我们在自定义 ChannelHandler 实现类的时候,就可以直接继承相应的 Adapter 类,并覆盖需要的事件处理方法,其他不关心的事件方法直接使用默认实现即可,从而提高开发效率。 + +ChannelHandler 中的很多方法都需要一个 ChannelHandlerContext 类型的参数,ChannelHandlerContext 抽象的是 ChannleHandler 之间的关系以及 ChannelHandler 与ChannelPipeline 之间的关系。ChannelPipeline 中的事件传播主要依赖于ChannelHandlerContext 实现,在 ChannelHandlerContext 中维护了 ChannelHandler 之间的关系,所以我们可以从 ChannelHandlerContext 中得到当前 ChannelHandler 的后继节点,从而将事件传播到后续的 ChannelHandler。 + +ChannelHandlerContext 继承了 AttributeMap,所以提供了 attr() 方法设置和删除一些状态属性信息,我们可将业务逻辑中所需使用的状态属性值存入到 ChannelHandlerContext 中,然后这些属性就可以随它传播了。Channel 中也维护了一个 AttributeMap,与 ChannelHandlerContext 中的 AttributeMap,从 Netty 4.1 开始,都是作用于整个 ChannelPipeline。 + +通过上述分析,我们可以了解到,一个 Channel 对应一个 ChannelPipeline,一个 ChannelHandlerContext 对应一个ChannelHandler。 如下图所示: + + + +最后,需要注意的是,如果要在 ChannelHandler 中执行耗时较长的逻辑,例如,操作 DB 、进行网络或磁盘 I/O 等操作,一般会在注册到 ChannelPipeline 的同时,指定一个线程池异步执行 ChannelHandler 中的操作。 + +NioEventLoop + +在前文介绍 Netty 线程模型的时候,我们简单提到了 NioEventLoop 这个组件,当时为了便于理解,只是简单将其描述成了一个线程。 + +一个 EventLoop 对象由一个永远都不会改变的线程驱动,同时一个 NioEventLoop 包含了一个 Selector 对象,可以支持多个 Channel 注册在其上,该 NioEventLoop 可以同时服务多个 Channel,每个 Channel 只能与一个 NioEventLoop 绑定,这样就实现了线程与 Channel 之间的关联。 + +我们知道,Channel 中的 I/O 操作是由 ChannelPipeline 中注册的 ChannelHandler 进行处理的,而 ChannelHandler 的逻辑都是由相应 NioEventLoop 关联的那个线程执行的。 + +除了与一个线程绑定之外,NioEvenLoop 中还维护了两个任务队列: + + +普通任务队列。用户产生的普通任务可以提交到该队列中暂存,NioEventLoop 发现该队列中的任务后会立即执行。这是一个多生产者、单消费者的队列,Netty 使用该队列将外部用户线程产生的任务收集到一起,并在 Reactor 线程内部用单线程的方式串行执行队列中的任务。例如,外部非 I/O 线程调用了 Channel 的 write() 方法,Netty 会将其封装成一个任务放入 TaskQueue 队列中,这样,所有的 I/O 操作都会在 I/O 线程中串行执行。 + + + + + +定时任务队列。当用户在非 I/O 线程产生定时操作时,Netty 将用户的定时操作封装成定时任务,并将其放入该定时任务队列中等待相应 NioEventLoop 串行执行。 + + +到这里我们可以看出,NioEventLoop 主要做三件事:监听 I/O 事件、执行普通任务以及执行定时任务。NioEventLoop 到底分配多少时间在不同类型的任务上,是可以配置的。另外,为了防止 NioEventLoop 长时间阻塞在一个任务上,一般会将耗时的操作提交到其他业务线程池处理。 + +NioEventLoopGroup + +NioEventLoopGroup 表示的是一组 NioEventLoop。Netty 为了能更充分地利用多核 CPU 资源,一般会有多个 NioEventLoop 同时工作,至于多少线程可由用户决定,Netty 会根据实际上的处理器核数计算一个默认值,具体计算公式是:CPU 的核心数 * 2,当然我们也可以根据实际情况手动调整。 + +当一个 Channel 创建之后,Netty 会调用 NioEventLoopGroup 提供的 next() 方法,按照一定规则获取其中一个 NioEventLoop 实例,并将 Channel 注册到该 NioEventLoop 实例,之后,就由该 NioEventLoop 来处理 Channel 上的事件。EventLoopGroup、EventLoop 以及 Channel 三者的关联关系,如下图所示: + + + +前面我们提到过,在 Netty 服务器端中,会有 BossEventLoopGroup 和 WorkerEventLoopGroup 两个 NioEventLoopGroup。通常一个服务端口只需要一个ServerSocketChannel,对应一个 Selector 和一个 NioEventLoop 线程。 + +BossEventLoop 负责接收客户端的连接事件,即 OP_ACCEPT 事件,然后将创建的 NioSocketChannel 交给 WorkerEventLoopGroup; WorkerEventLoopGroup 会由 next() 方法选择其中一个 NioEventLoopGroup,并将这个 NioSocketChannel 注册到其维护的 Selector 并对其后续的I/O事件进行处理。 + + + +如上图,BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一个 Selector 对象,其上注册了一个 ServerSocketChannel,BoosEventLoop 会不断轮询 Selector 监听连接事件,在发生连接事件时,通过 accept 操作与客户端创建连接,创建 SocketChannel 对象。然后将 accept 操作得到的 SocketChannel 交给 WorkerEventLoopGroup,在Reactor 模式中 WorkerEventLoopGroup 中会维护多个 EventLoop,而每个 EventLoop 都会监听分配给它的 SocketChannel 上发生的 I/O 事件,并将这些具体的事件分发给业务线程池处理。 + +ByteBuf + +通过前文的介绍,我们了解了 Netty 中数据的流向,这里我们再来介绍一下数据的容器——ByteBuf。 + +在进行跨进程远程交互的时候,我们需要以字节的形式发送和接收数据,发送端和接收端都需要一个高效的数据容器来缓存字节数据,ByteBuf 就扮演了这样一个数据容器的角色。 + +ByteBuf 类似于一个字节数组,其中维护了一个读索引和一个写索引,分别用来控制对 ByteBuf 中数据的读写操作,两者符合下面的不等式: + +0 <= readerIndex <= writerIndex <= capacity + + + + +ByteBuf 提供的读写操作 API 主要操作底层的字节容器(byte[]、ByteBuffer 等)以及读写索引这两指针,你若感兴趣的话,可以查阅相关的 API 说明,这里不再展开介绍。 + +Netty 中主要分为以下三大类 ByteBuf: + + +Heap Buffer(堆缓冲区)。这是最常用的一种 ByteBuf,它将数据存储在 JVM 的堆空间,其底层实现是在 JVM 堆内分配一个数组,实现数据的存储。堆缓冲区可以快速分配,当不使用时也可以由 GC 轻松释放。它还提供了直接访问底层数组的方法,通过 ByteBuf.array() 来获取底层存储数据的 byte[] 。 +Direct Buffer(直接缓冲区)。直接缓冲区会使用堆外内存存储数据,不会占用 JVM 堆的空间,使用时应该考虑应用程序要使用的最大内存容量以及如何及时释放。直接缓冲区在使用 Socket 传递数据时性能很好,当然,它也是有缺点的,因为没有了 JVM GC 的管理,在分配内存空间和释放内存时,比堆缓冲区更复杂,Netty 主要使用内存池来解决这样的问题,这也是 Netty 使用内存池的原因之一。 +Composite Buffer(复合缓冲区)。我们可以创建多个不同的 ByteBuf,然后提供一个这些 ByteBuf 组合的视图,也就是 CompositeByteBuf。它就像一个列表,可以动态添加和删除其中的 ByteBuf。 + + +内存管理 + +Netty 使用 ByteBuf 对象作为数据容器,进行 I/O 读写操作,其实 Netty 的内存管理也是围绕着ByteBuf 对象高效地分配和释放。从内存管理角度来看,ByteBuf 可分为 Unpooled 和 Pooled 两类。 + + +Unpooled,是指非池化的内存管理方式。每次分配时直接调用系统 API 向操作系统申请 ByteBuf,在使用完成之后,通过系统调用进行释放。Unpooled 将内存管理完全交给系统,不做任何特殊处理,使用起来比较方便,对于申请和释放操作不频繁、操作成本比较低的 ByteBuf 来说,是比较好的选择。 +Pooled,是指池化的内存管理方式。该方式会预先申请一块大内存形成内存池,在需要申请 ByteBuf 空间的时候,会将内存池中一部分合理的空间封装成 ByteBuf 给服务使用,使用完成后回收到内存池中。前面提到 DirectByteBuf 底层使用的堆外内存管理比较复杂,池化技术很好地解决了这一问题。 + + +下面我们从如何高效分配和释放内存、如何减少内存碎片以及在多线程环境下如何减少锁竞争这三个方面介绍一下 Netty 提供的 ByteBuf 池化技术。 + +Netty 首先会向系统申请一整块连续内存,称为 Chunk(默认大小为 16 MB),这一块连续的内存通过 PoolChunk 对象进行封装。之后,Netty 将 Chunk 空间进一步拆分为 Page,每个 Chunk 默认包含 2048 个 Page,每个 Page 的大小为 8 KB。 + +在同一个 Chunk 中,Netty 将 Page 按照不同粒度进行分层管理。如下图所示,从下数第 1 层中每个分组的大小为 1 * PageSize,一共有 2048 个分组;第 2 层中每个分组大小为 2 * PageSize,一共有 1024 个组;第 3 层中每个分组大小为 4 * PageSize,一共有 512 个组;依次类推,直至最顶层。 + + + +1. 内存分配&释放 + +当服务向内存池请求内存时,Netty 会将请求分配的内存数向上取整到最接近的分组大小,然后在该分组的相应层级中从左至右寻找空闲分组。例如,服务请求分配 3 * PageSize 的内存,向上取整得到的分组大小为 4 * PageSize,在该层分组中找到完全空闲的一组内存进行分配即可,如下图: + + + +当分组大小 4 * PageSize 的内存分配出去后,为了方便下次内存分配,分组被标记为全部已使用(图中红色标记),向上更粗粒度的内存分组被标记为部分已使用(图中黄色标记)。 + +Netty 使用完全平衡树的结构实现了上述算法,这个完全平衡树底层是基于一个 byte 数组构建的,如下图所示: + + + +具体的实现逻辑这里就不再展开讲述了,你若感兴趣的话,可以参考 Netty 代码。 + +2. 大对象&小对象的处理 + +当申请分配的对象是超过 Chunk 容量的大型对象,Netty 就不再使用池化管理方式了,在每次请求分配内存时单独创建特殊的非池化 PoolChunk 对象进行管理,当对象内存释放时整个PoolChunk 内存释放。 + +如果需要一定数量空间远小于 PageSize 的 ByteBuf 对象,例如,创建 256 Byte 的 ByteBuf,按照上述算法,就需要为每个小 ByteBuf 对象分配一个 Page,这就出现了很多内存碎片。Netty 通过再将 Page 细分的方式,解决这个问题。Netty 将请求的空间大小向上取最近的 16 的倍数(或 2 的幂),规整后小于 PageSize 的小 Buffer 可分为两类。 + + +微型对象:规整后的大小为 16 的整倍数,如 16、32、48、……、496,一共 31 种大小。 +小型对象:规整后的大小为 2 的幂,如 512、1024、2048、4096,一共 4 种大小。 + + +Netty 的实现会先从 PoolChunk 中申请空闲 Page,同一个 Page 分为相同大小的小 Buffer 进行存储;这些 Page 用 PoolSubpage 对象进行封装,PoolSubpage 内部会记录它自己能分配的小 Buffer 的规格大小、可用内存数量,并通过 bitmap 的方式记录各个小内存的使用情况(如下图所示)。虽然这种方案不能完美消灭内存碎片,但是很大程度上还是减少了内存浪费。 + + + +为了解决单个 PoolChunk 容量有限的问题,Netty 将多个 PoolChunk 组成链表一起管理,然后用 PoolChunkList 对象持有链表的 head。 + +Netty 通过 PoolArena 管理 PoolChunkList 以及 PoolSubpage。 + +PoolArena 内部持有 6 个 PoolChunkList,各个 PoolChunkList 持有的 PoolChunk 的使用率区间有所不同,如下图所示: + + + +6 个 PoolChunkList 对象组成双向链表,当 PoolChunk 内存分配、释放,导致使用率变化,需要判断 PoolChunk 是否超过所在 PoolChunkList 的限定使用率范围,如果超出了,需要沿着 6 个 PoolChunkList 的双向链表找到新的合适的 PoolChunkList ,成为新的 head。同样,当新建 PoolChunk 分配内存或释放空间时,PoolChunk 也需要按照上面逻辑放入合适的PoolChunkList 中。 + + + +从上图可以看出,这 6 个 PoolChunkList 额定使用率区间存在交叉,这样设计的原因是:如果使用单个临界值的话,当一个 PoolChunk 被来回申请和释放,内存使用率会在临界值上下徘徊,这就会导致它在两个 PoolChunkList 链表中来回移动。 + +PoolArena 内部持有 2 个 PoolSubpage 数组,分别存储微型 Buffer 和小型 Buffer 的PoolSubpage。相同大小的 PoolSubpage 组成链表,不同大小的 PoolSubpage 链表的 head 节点保存在 tinySubpagePools 或者 smallSubpagePools 数组中,如下图: + + + +3. 并发处理 + +内存分配释放不可避免地会遇到多线程并发场景,PoolChunk 的完全平衡树标记以及 PoolSubpage 的 bitmap 标记都是多线程不安全的,都是需要加锁同步的。为了减少线程间的竞争,Netty 会提前创建多个 PoolArena(默认数量为 2 * CPU 核心数),当线程首次请求池化内存分配,会找被最少线程持有的 PoolArena,并保存线程局部变量 PoolThreadCache 中,实现线程与 PoolArena 的关联绑定。 + +Netty 还提供了延迟释放的功能,来提升并发性能。当内存释放时,PoolArena 并没有马上释放,而是先尝试将该内存关联的 PoolChunk 和 Chunk 中的偏移位置等信息存入 ThreadLocal 的固定大小缓存队列中,如果该缓存队列满了,则马上释放内存。当有新的分配请求时,PoolArena 会优先访问线程本地的缓存队列,查询是否有缓存可用,如果有,则直接分配,提高分配效率。 + +总结 + +在本课时,我们主要介绍了 Netty 核心组件的功能和原理: + + +首先介绍了 Channel、ChannelFuture、Selector 等组件,它们是构成 I/O 多路复用的核心。 +之后介绍了 EventLoop、EventLoopGroup 等组件,它们与 Netty 使用的主从 Reactor 线程模型息息相关。 +最后深入介绍了 Netty 的内存管理,主要从内存分配管理、内存碎片优化以及并发分配内存等角度进行了介绍。 + + +那你还知道哪些优秀的网络库或网络层设计呢?欢迎你留言讨论。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/11简易版RPC框架实现(上).md b/专栏/Dubbo源码解读与实战-完/11简易版RPC框架实现(上).md new file mode 100644 index 0000000..a210c4d --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/11简易版RPC框架实现(上).md @@ -0,0 +1,359 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 简易版 RPC 框架实现(上) + 这是“基础知识”部分的最后一课时,我们将会运用前面介绍的基础知识来做一个实践项目 —— 编写一个简易版本的 RPC 框架,作为“基础知识”部分的总结和回顾。 + +RPC 是“远程过程调用(Remote Procedure Call)”的缩写形式,比较通俗的解释是:像本地方法调用一样调用远程的服务。虽然 RPC 的定义非常简单,但是相对完整的、通用的 RPC 框架涉及很多方面的内容,例如注册发现、服务治理、负载均衡、集群容错、RPC 协议等,如下图所示: + + + +简易 RPC 框架的架构图 + +本课时我们主要实现RPC 框架的基石部分——远程调用,简易版 RPC 框架一次远程调用的核心流程是这样的: + + +Client 首先会调用本地的代理,也就是图中的 Proxy。 +Client 端 Proxy 会按照协议(Protocol),将调用中传入的数据序列化成字节流。 +之后 Client 会通过网络,将字节数据发送到 Server 端。 +Server 端接收到字节数据之后,会按照协议进行反序列化,得到相应的请求信息。 +Server 端 Proxy 会根据序列化后的请求信息,调用相应的业务逻辑。 +Server 端业务逻辑的返回值,也会按照上述逻辑返回给 Client 端。 + + +这个远程调用的过程,就是我们简易版本 RPC 框架的核心实现,只有理解了这个流程,才能进行后续的开发。 + +项目结构 + +了解了简易版 RPC 框架的工作流程和实现目标之后,我们再来看下项目的结构,为了方便起见,这里我们将整个项目放到了一个 Module 中了,如下图所示,你可以按照自己的需求进行模块划分。 + + + +那这各个包的功能是怎样的呢?我们就来一一说明。 + + +protocol:简易版 RPC 框架的自定义协议。 +serialization:提供了自定义协议对应的序列化、反序列化的相关工具类。 +codec:提供了自定义协议对应的编码器和解码器。 +transport:基于 Netty 提供了底层网络通信的功能,其中会使用到 codec 包中定义编码器和解码器,以及 serialization 包中的序列化器和反序列化器。 +registry:基于 ZooKeeper 和 Curator 实现了简易版本的注册中心功能。 +proxy:使用 JDK 动态代理实现了一层代理。 + + +自定义协议 + +当前已经有很多成熟的协议了,例如 HTTP、HTTPS 等,那为什么我们还要自定义 RPC 协议呢? + +从功能角度考虑,HTTP 协议在 1.X 时代,只支持半双工传输模式,虽然支持长连接,但是不支持服务端主动推送数据。从效率角度来看,在一次简单的远程调用中,只需要传递方法名和加个简单的参数,此时,HTTP 请求中大部分数据都被 HTTP Header 占据,真正的有效负载非常少,效率就比较低。 + +当然,HTTP 协议也有自己的优势,例如,天然穿透防火墙,大量的框架和开源软件支持 HTTP 接口,而且配合 REST 规范使用也是很便捷的,所以有很多 RPC 框架直接使用 HTTP 协议,尤其是在 HTTP 2.0 之后,如 gRPC、Spring Cloud 等。 + +这里我们自定义一个简易版的 Demo RPC 协议,如下图所示: + + + +在 Demo RPC 的消息头中,包含了整个 RPC 消息的一些控制信息,例如,版本号、魔数、消息类型、附加信息、消息 ID 以及消息体的长度,在附加信息(extraInfo)中,按位进行划分,分别定义消息的类型、序列化方式、压缩方式以及请求类型。当然,你也可以自己扩充 Demo RPC 协议,实现更加复杂的功能。 + +Demo RPC 消息头对应的实体类是 Header,其定义如下: + +public class Header { + + private short magic; // 魔数 + + private byte version; // 协议版本 + + private byte extraInfo; // 附加信息 + + private Long messageId; // 消息ID + + private Integer size; // 消息体长度 + + ... // 省略getter/setter方法 + +} + + +确定了 Demo RPC 协议消息头的结构之后,我们再来看 Demo RPC 协议消息体由哪些字段构成,这里我们通过 Request 和 Response 两个实体类来表示请求消息和响应消息的消息体: + +public class Request implements Serializable { + + private String serviceName; // 请求的Service类名 + + private String methodName; // 请求的方法名称 + + private Class[] argTypes; // 请求方法的参数类型 + + private Object[] args; // 请求方法的参数 + + ... // 省略getter/setter方法 + +} + +public class Response implements Serializable { + + private int code = 0; // 响应的错误码,正常响应为0,非0表示异常响应 + + private String errMsg; // 异常信息 + + private Object result; // 响应结果 + + ... // 省略getter/setter方法 + +} + + +注意,Request 和 Response 对象是要进行序列化的,需要实现 Serializable 接口。为了让这两个类的对象能够在 Client 和 Server 之间跨进程传输,需要进行序列化和反序列化操作,这里定义一个 Serialization 接口,统一完成序列化相关的操作: + +public interface Serialization { + + byte[] serialize(T obj)throws IOException; + + T deSerialize(byte[] data, Class clz)throws IOException; + +} + + +在 Demo RPC 中默认使用 Hessian 序列化方式,下面的 HessianSerialization 就是基于 Hessian 序列化方式对 Serialization 接口的实现: + +public class HessianSerialization implements Serialization { + + public byte[] serialize(T obj) throws IOException { + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + + HessianOutput hessianOutput = new HessianOutput(os); + + hessianOutput.writeObject(obj); + + return os.toByteArray(); + + } + + public T deSerialize(byte[] data, Class clazz) + + throws IOException { + + ByteArrayInputStream is = new ByteArrayInputStream(data); + + HessianInput hessianInput = new HessianInput(is); + + return (T) hessianInput.readObject(clazz); + + } + +} + + +在有的场景中,请求或响应传输的数据比较大,直接传输比较消耗带宽,所以一般会采用压缩后再发送的方式。在前面介绍的 Demo RPC 消息头中的 extraInfo 字段中,就包含了标识消息体压缩方式的 bit 位。这里我们定义一个 Compressor 接口抽象所有压缩算法: + +public interface Compressor { + + byte[] compress(byte[] array) throws IOException; + + byte[] unCompress(byte[] array) throws IOException; + +} + + +同时提供了一个基于 Snappy 压缩算法的实现,作为 Demo RPC 的默认压缩算法: + +public class SnappyCompressor implements Compressor { + + public byte[] compress(byte[] array) throws IOException { + + if (array == null) { return null; } + + return Snappy.compress(array); + + } + + public byte[] unCompress(byte[] array) throws IOException { + + if (array == null) { return null; } + + return Snappy.uncompress(array); + + } + +} + + +编解码实现 + +了解了自定义协议的结构之后,我们再来解决协议的编解码问题。 + +前面课时介绍 Netty 核心概念的时候我们提到过,Netty 每个 Channel 绑定一个 ChannelPipeline,并依赖 ChannelPipeline 中添加的 ChannelHandler 处理接收到(或要发送)的数据,其中就包括字节到消息(以及消息到字节)的转换。Netty 中提供了 ByteToMessageDecoder、 MessageToByteEncoder、MessageToMessageEncoder、MessageToMessageDecoder 等抽象类来实现 Message 与 ByteBuf 之间的转换以及 Message 之间的转换,如下图所示: + + + +Netty 提供的 Decoder 和 Encoder 实现 + +在 Netty 的源码中,我们可以看到对很多已有协议的序列化和反序列化都是基于上述抽象类实现的,例如,HttpServerCodec 中通过依赖 HttpServerRequestDecoder 和 HttpServerResponseEncoder 来实现 HTTP 请求的解码和 HTTP 响应的编码。如下图所示,HttpServerRequestDecoder 继承自 ByteToMessageDecoder,实现了 ByteBuf 到 HTTP 请求之间的转换;HttpServerResponseEncoder 继承自 MessageToMessageEncoder,实现 HTTP 响应到其他消息的转换(其中包括转换成 ByteBuf 的能力)。 + + + +Netty 中 HTTP 协议的 Decoder 和 Encoder 实现 + +在简易版 RPC 框架中,我们的自定义请求暂时没有 HTTP 协议那么复杂,只要简单继承 ByteToMessageDecoder 和 MessageToMessageEncoder 即可。 + +首先来看 DemoRpcDecoder,它实现了 ByteBuf 到 Demo RPC Message 的转换,具体实现如下: + +public class DemoRpcDecoder extends ByteToMessageDecoder { + + protected void decode(ChannelHandlerContext ctx, + + ByteBuf byteBuf, List out) throws Exception { + + if (byteBuf.readableBytes() < Constants.HEADER_SIZE) { + + return; // 不到16字节的话无法解析消息头,暂不读取 + + } + + // 记录当前readIndex指针的位置,方便重置 + + byteBuf.markReaderIndex(); + + // 尝试读取消息头的魔数部分 + + short magic = byteBuf.readShort(); + + if (magic != Constants.MAGIC) { // 魔数不匹配会抛出异常 + + byteBuf.resetReaderIndex(); // 重置readIndex指针 + + throw new RuntimeException("magic number error:" + magic); + + } + + // 依次读取消息版本、附加信息、消息ID以及消息体长度四部分 + + byte version = byteBuf.readByte(); + + byte extraInfo = byteBuf.readByte(); + + long messageId = byteBuf.readLong(); + + int size = byteBuf.readInt(); + + Object request = null; + + // 心跳消息是没有消息体的,无须读取 + + if (!Constants.isHeartBeat(extraInfo)) { + + // 对于非心跳消息,没有积累到足够的数据是无法进行反序列化的 + + if (byteBuf.readableBytes() < size) { + + byteBuf.resetReaderIndex(); + + return; + + } + + // 读取消息体并进行反序列化 + + byte[] payload = new byte[size]; + + byteBuf.readBytes(payload); + + // 这里根据消息头中的extraInfo部分选择相应的序列化和压缩方式 + + Serialization serialization = + + SerializationFactory.get(extraInfo); + + Compressor compressor = CompressorFactory.get(extraInfo); + + // 经过解压缩和反序列化得到消息体 + + request = serialization.deserialize( + + compressor.unCompress(payload), Request.class); + + } + + // 将上面读取到的消息头和消息体拼装成完整的Message并向后传递 + + Header header = new Header(magic, version, extraInfo, + + messageId, size); + + Message message = new Message(header, request); + + out.add(message); + + } + +} + + +接下来看 DemoRpcEncoder,它实现了 Demo RPC Message 到 ByteBuf 的转换,具体实现如下: + +class DemoRpcEncoder extends MessageToByteEncoder{ + + @Override + + protected void encode(ChannelHandlerContext channelHandlerContext, + + Message message, ByteBuf byteBuf) throws Exception { + + Header header = message.getHeader(); + + // 依次序列化消息头中的魔数、版本、附加信息以及消息ID + + byteBuf.writeShort(header.getMagic()); + + byteBuf.writeByte(header.getVersion()); + + byteBuf.writeByte(header.getExtraInfo()); + + byteBuf.writeLong(header.getMessageId()); + + Object content = message.getContent(); + + if (Constants.isHeartBeat(header.getExtraInfo())) { + + byteBuf.writeInt(0); // 心跳消息,没有消息体,这里写入0 + + return; + + } + + // 按照extraInfo部分指定的序列化方式和压缩方式进行处理 + + Serialization serialization = + + SerializationFactory.get(header.getExtraInfo()); + + Compressor compressor = + + CompressorFactory.get(header.getExtraInfo()); + + byte[] payload = compressor.compress( + + serialization.serialize(content)); + + byteBuf.writeInt(payload.length); // 写入消息体长度 + + byteBuf.writeBytes(payload); // 写入消息体 + + } + +} + + +总结 + +本课时我们首先介绍了简易 RPC 框架的基础架构以及其处理一次远程调用的基本流程,并对整个简易 RPC 框架项目的结构进行了简单介绍。接下来,我们讲解了简易 RPC 框架使用的自定义协议格式、序列化/反序列化方式以及压缩方式,这些都是远程数据传输不可或缺的基础。然后,我们又介绍了 Netty 中的编解码体系,以及 HTTP 协议相关的编解码器实现。最后,我们还分析了简易 RPC 协议对应的编解码器,即 DemoRpcEncoder 和 DemoRpcDecoder。 + +在下一课时,我们将自底向上,继续介绍简易 RPC 框架的剩余部分实现。 + +简易版 RPC 框架 Demo 的链接:https://github.com/xxxlxy2008/demo-prc 。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/12简易版RPC框架实现(下).md b/专栏/Dubbo源码解读与实战-完/12简易版RPC框架实现(下).md new file mode 100644 index 0000000..9396b25 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/12简易版RPC框架实现(下).md @@ -0,0 +1,773 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 简易版 RPC 框架实现(下) + 在上一课时中,我们介绍了整个简易 RPC 框架项目的结构和工作原理,并且介绍了简易 RPC 框架底层的协议结构、序列化/反序列化实现、压缩实现以及编解码器的具体实现。本课时我们将继续自底向上,介绍简易 RPC 框架的剩余部分实现。 + +transport 相关实现 + +正如前文介绍 Netty 线程模型的时候提到,我们不能在 Netty 的 I/O 线程中执行耗时的业务逻辑。在 Demo RPC 框架的 Server 端接收到请求时,首先会通过上面介绍的 DemoRpcDecoder 反序列化得到请求消息,之后我们会通过一个自定义的 ChannelHandler(DemoRpcServerHandler)将请求提交给业务线程池进行处理。 + +在 Demo RPC 框架的 Client 端接收到响应消息的时候,也是先通过 DemoRpcDecoder 反序列化得到响应消息,之后通过一个自定义的 ChannelHandler(DemoRpcClientHandler)将响应返回给上层业务。 + +DemoRpcServerHandler 和 DemoRpcClientHandler 都继承自 SimpleChannelInboundHandler,如下图所示: + + + +DemoRpcClientHandler 和 DemoRpcServerHandler 的继承关系图 + +下面我们就来看一下这两个自定义的 ChannelHandler 实现: + +public class DemoRpcServerHandler extends + + SimpleChannelInboundHandler> { + + // 业务线程池 + + static Executor executor = Executors.newCachedThreadPool(); + + protected void channelRead0(final ChannelHandlerContext ctx, + + Message message) throws Exception { + + byte extraInfo = message.getHeader().getExtraInfo(); + + if (Constants.isHeartBeat(extraInfo)) { // 心跳消息,直接返回即可 + + channelHandlerContext.writeAndFlush(message); + + return; + + } + + // 非心跳消息,直接封装成Runnable提交到业务线程 + + executor.execute(new InvokeRunnable(message, cxt)); + + } + +} + +public class DemoRpcClientHandler extends + + SimpleChannelInboundHandler> { + + protected void channelRead0(ChannelHandlerContext ctx, + + Message message) throws Exception { + + NettyResponseFuture responseFuture = + + Connection.IN_FLIGHT_REQUEST_MAP + + .remove(message.getHeader().getMessageId()); + + Response response = message.getContent(); + + // 心跳消息特殊处理 + + if (response == null && Constants.isHeartBeat( + + message.getHeader().getExtraInfo())) { + + response = new Response(); + + response.setCode(Constants.HEARTBEAT_CODE); + + } + + responseFuture.getPromise().setSuccess(response); + + } + +} + + +注意,这里有两个点需要特别说明一下。一个点是 Server 端的 InvokeRunnable,在这个 Runnable 任务中会根据请求的 serviceName、methodName 以及参数信息,调用相应的方法: + +class InvokeRunnable implements Runnable { + + private ChannelHandlerContext ctx; + + private Message message; + + public void run() { + + Response response = new Response(); + + Object result = null; + + try { + + Request request = message.getContent(); + + String serviceName = request.getServiceName(); + + // 这里提供BeanManager对所有业务Bean进行管理,其底层在内存中维护了 + + // 一个业务Bean实例的集合。感兴趣的同学可以尝试接入Spring等容器管 + + // 理业务Bean + + Object bean = BeanManager.getBean(serviceName); + + // 下面通过反射调用Bean中的相应方法 + + Method method = bean.getClass().getMethod( + + request.getMethodName(), request.getArgTypes()); + + result = method.invoke(bean, request.getArgs()); + + } catch (Exception e) { // 省略异常处理 + + } finally { + + } + + response.setResult(result); // 设置响应结果 + + // 将响应消息返回给客户端 + + ctx.writeAndFlush(new Message(message.getHeader(), response)); + + } + +} + + +另一个点是 Client 端的 Connection,它是用来暂存已发送出去但未得到响应的请求,这样,在响应返回时,就可以查找到相应的请求以及 Future,从而将响应结果返回给上层业务逻辑,具体实现如下: + +public class Connection implements Closeable { + + private static AtomicLong ID_GENERATOR = new AtomicLong(0); + + public static Map> + + IN_FLIGHT_REQUEST_MAP = new ConcurrentHashMap<>(); + + private ChannelFuture future; + + private AtomicBoolean isConnected = new AtomicBoolean(); + + public Connection(ChannelFuture future, boolean isConnected) { + + this.future = future; + + this.isConnected.set(isConnected); + + } + + public NettyResponseFuture request(Message message, long timeOut) { + + // 生成并设置消息ID + + long messageId = ID_GENERATOR.incrementAndGet(); + + message.getHeader().setMessageId(messageId); + + // 创建消息关联的Future + + NettyResponseFuture responseFuture = new NettyResponseFuture(System.currentTimeMillis(), + + timeOut, message, future.channel(), new DefaultPromise(new DefaultEventLoop())); + + // 将消息ID和关联的Future记录到IN_FLIGHT_REQUEST_MAP集合中 + + IN_FLIGHT_REQUEST_MAP.put(messageId, responseFuture); + + try { + + future.channel().writeAndFlush(message); // 发送请求 + + } catch (Exception e) { + + // 发送请求异常时,删除对应的Future + + IN_FLIGHT_REQUEST_MAP.remove(messageId); + + throw e; + + } + + return responseFuture; + + } + + // 省略getter/setter以及close()方法 + +} + + +我们可以看到,Connection 中没有定时清理 IN_FLIGHT_REQUEST_MAP 集合的操作,在无法正常获取响应的时候,就会导致 IN_FLIGHT_REQUEST_MAP 不断膨胀,最终 OOM。你也可以添加一个时间轮定时器,定时清理过期的请求消息,这里我们就不再展开讲述了。 + +完成自定义 ChannelHandler 的编写之后,我们需要再定义两个类—— DemoRpcClient 和 DemoRpcServer,分别作为 Client 和 Server 的启动入口。DemoRpcClient 的实现如下: + +public class DemoRpcClient implements Closeable { + + protected Bootstrap clientBootstrap; + + protected EventLoopGroup group; + + private String host; + + private int port; + + public DemoRpcClient(String host, int port) throws Exception { + + this.host = host; + + this.port = port; + + clientBootstrap = new Bootstrap(); + + // 创建并配置客户端Bootstrap + + group = NettyEventLoopFactory.eventLoopGroup( + + Constants.DEFAULT_IO_THREADS, "NettyClientWorker"); + + clientBootstrap.group(group) + + .option(ChannelOption.TCP_NODELAY, true) + + .option(ChannelOption.SO_KEEPALIVE, true) + + .channel(NioSocketChannel.class) + + // 指定ChannelHandler的顺序 + + .handler(new ChannelInitializer() { + + protected void initChannel(SocketChannel ch) { + + ch.pipeline().addLast("demo-rpc-encoder", + + new DemoRpcEncoder()); + + ch.pipeline().addLast("demo-rpc-decoder", + + new DemoRpcDecoder()); + + ch.pipeline().addLast("client-handler", + + new DemoRpcClientHandler()); + + } + + }); + + } + + public ChannelFuture connect() { // 连接指定的地址和端口 + + ChannelFuture connect = clientBootstrap.connect(host, port); + + connect.awaitUninterruptibly(); + + return connect; + + } + + public void close() { + + group.shutdownGracefully(); + + } + +} + + +通过 DemoRpcClient 的代码我们可以看到其 ChannelHandler 的执行顺序如下: + + + +客户端 ChannelHandler 结构图 + +另外,在创建EventLoopGroup时并没有直接使用NioEventLoopGroup,而是在 NettyEventLoopFactory 中根据当前操作系统进行选择,对于 Linux 系统,会使用 EpollEventLoopGroup,其他系统则使用 NioEventLoopGroup。 + +接下来我们再看DemoRpcServer 的具体实现: + +public class DemoRpcServer { + + private EventLoopGroup bossGroup; + + private EventLoopGroup workerGroup; + + private ServerBootstrap serverBootstrap; + + private Channel channel; + + protected int port; + + public DemoRpcServer(int port) throws InterruptedException { + + this.port = port; + + // 创建boss和worker两个EventLoopGroup,注意一些小细节, + + // workerGroup 是按照中的线程数是按照 CPU 核数计算得到的, + + bossGroup = NettyEventLoopFactory.eventLoopGroup(1, "boos"); + + workerGroup = NettyEventLoopFactory.eventLoopGroup( + + Math.min(Runtime.getRuntime().availableProcessors() + 1, + + 32), "worker"); + + serverBootstrap = new ServerBootstrap().group(bossGroup, + + workerGroup).channel(NioServerSocketChannel.class) + + .option(ChannelOption.SO_REUSEADDR, Boolean.TRUE) + + .childOption(ChannelOption.TCP_NODELAY, Boolean.TRUE) + + .handler(new LoggingHandler(LogLevel.INFO)) + + .childHandler(new ChannelInitializer() + + { // 指定每个Channel上注册的ChannelHandler以及顺序 + + protected void initChannel(SocketChannel ch) { + + ch.pipeline().addLast("demp-rpc-decoder", + + new DemoRpcDecoder()); + + ch.pipeline().addLast("demo-rpc-encoder", + + new DemoRpcEncoder()); + + ch.pipeline().addLast("server-handler", + + new DemoRpcServerHandler()); + + } + + }); + + } + + public ChannelFuture start() throws InterruptedException { + + ChannelFuture channelFuture = serverBootstrap.bind(port); + + channel = channelFuture.channel(); + + channel.closeFuture(); + + return channelFuture; + + } + +} + + +通过对 DemoRpcServer 实现的分析,我们可以知道每个 Channel 上的 ChannelHandler 顺序如下: + + + +服务端 ChannelHandler 结构图 + +registry 相关实现 + +介绍完客户端和服务端的通信之后,我们再来看简易 RPC 框架的另一个基础能力——服务注册与服务发现能力,对应 demo-rpc 项目源码中的 registry 包。 + +registry 包主要是依赖 Apache Curator 实现了一个简易版本的 ZooKeeper 客户端,并基于 ZooKeeper 实现了注册中心最基本的两个功能:Provider 注册以及 Consumer 订阅。 + +这里我们先定义一个 Registry 接口,其中提供了注册以及查询服务实例的方法,如下图所示: + + + +ZooKeeperRegistry 是基于 curator-x-discovery 对 Registry 接口的实现类型,其中封装了之前课时介绍的 ServiceDiscovery,并在其上添加了 ServiceCache 缓存提高查询效率。ZooKeeperRegistry 的具体实现如下: + +public class ZookeeperRegistry implements Registry { + + private InstanceSerializer serializer = + + new JsonInstanceSerializer<>(ServerInfo.class); + + private ServiceDiscovery serviceDiscovery; + + private ServiceCache serviceCache; + + private String address = "localhost:2181"; + + public void start() throws Exception { + + String root = "/demo/rpc"; + + // 初始化CuratorFramework + + CuratorFramework client = CuratorFrameworkFactory + + .newClient(address, new ExponentialBackoffRetry(1000, 3)); + + client.start(); // 启动Curator客户端 + + client.blockUntilConnected(); // 阻塞当前线程,等待连接成 + + client.createContainers(root); + + // 初始化ServiceDiscovery + + serviceDiscovery = ServiceDiscoveryBuilder + + .builder(ServerInfo.class) + + .client(client).basePath(root) + + .serializer(serializer) + + .build(); + + serviceDiscovery.start(); // 启动ServiceDiscovery + + // 创建ServiceCache,监Zookeeper相应节点的变化,也方便后续的读取 + + serviceCache = serviceDiscovery.serviceCacheBuilder() + + .name(root) + + .build(); + + serviceCache.start(); // 启动ServiceCache + + } + + @Override + + public void registerService(ServiceInstance service) + + throws Exception { + + serviceDiscovery.registerService(service); + + } + + @Override + + public void unregisterService(ServiceInstance service) + + throws Exception { + + serviceDiscovery.unregisterService(service); + + } + + @Override + + public List> queryForInstances( + + String name) throws Exception { + + // 直接根据name进行过滤ServiceCache中的缓存数据 + + return serviceCache.getInstances().stream() + + .filter(s -> s.getName().equals(name)) + + .collect(Collectors.toList()); + + } + +} + + +通过对 ZooKeeperRegistry的分析可以得知,它是基于 Curator 中的 ServiceDiscovery 组件与 ZooKeeper 进行交互的,并且对 Registry 接口的实现也是通过直接调用 ServiceDiscovery 的相关方法实现的。在查询时,直接读取 ServiceCache 中的缓存数据,ServiceCache 底层在本地维护了一个 ConcurrentHashMap 缓存,通过 PathChildrenCache 监听 ZooKeeper 中各个子节点的变化,同步更新本地缓存。这里我们简单看一下 ServiceCache 的核心实现: + +public class ServiceCacheImpl implements ServiceCache, + + PathChildrenCacheListener{//实现PathChildrenCacheListener接口 + + // 关联的ServiceDiscovery实例 + + private final ServiceDiscoveryImpl discovery; + + // 底层的PathChildrenCache,用于监听子节点的变化 + + private final PathChildrenCache cache; + + // 本地缓存 + + private final ConcurrentMap> instances + + = Maps.newConcurrentMap(); + + public List> getInstances(){ // 返回本地缓存内容 + + return Lists.newArrayList(instances.values()); + + } + + public void childEvent(CuratorFramework client, + + PathChildrenCacheEvent event) throws Exception{ + + switch(event.getType()){ + + case CHILD_ADDED: + + case CHILD_UPDATED:{ + + addInstance(event.getData(), false); // 更新本地缓存 + + notifyListeners = true; + + break; + + } + + case CHILD_REMOVED:{ // 更新本地缓存 + + instances.remove(instanceIdFromData(event.getData())); + + notifyListeners = true; + + break; + + } + + } + + ... // 通知ServiceCache上注册的监听器 + + } + +} + + +proxy 相关实现 + +在简易版 Demo RPC 框架中,Proxy 主要是为 Client 端创建一个代理,帮助客户端程序屏蔽底层的网络操作以及与注册中心之间的交互。 + +简易版 Demo RPC 使用 JDK 动态代理的方式生成代理,这里需要编写一个 InvocationHandler 接口的实现,即下面的 DemoRpcProxy。其中有两个核心方法:一个是 newInstance() 方法,用于生成代理对象;另一个是 invoke() 方法,当调用目标对象的时候,会执行 invoke() 方法中的代理逻辑。 + +下面是 DemoRpcProxy 的具体实现: + +public class DemoRpcProxy implements InvocationHandler { + + // 需要代理的服务(接口)名称 + + private String serviceName; + + // 用于与Zookeeper交互,其中自带缓存 + + private Registry registry; + + public DemoRpcProxy(String serviceName, Registry + + registry) throws Exception { // 初始化上述两个字段 + + this.serviceName = serviceName; + + this.registry = registry; + + } + + public static T newInstance(Class clazz, + + Registry registry) throws Exception { + + // 创建代理对象 + + return (T) Proxy.newProxyInstance(Thread.currentThread() + + .getContextClassLoader(), new Class[]{clazz}, + + new DemoRpcProxy(clazz.getName(), registry)); + + } + + @Override + + public Object invoke(Object proxy, Method method, Object[] args) + + throws Throwable { + + // 从Zookeeper缓存中获取可用的Server地址,并随机从中选择一个 + + List> serviceInstances = + + registry.queryForInstances(serviceName); + + ServiceInstance serviceInstance = serviceInstances + + .get(ThreadLocalRandom.current() + + .nextInt(serviceInstances.size())); + + // 创建请求消息,然后调用remoteCall()方法请求上面选定的Server端 + + String methodName = method.getName(); + + Header header =new Header(MAGIC, VERSION_1...); + + Message message = new Message(header, + + new Request(serviceName, methodName, args)); + + return remoteCall(serviceInstance.getPayload(), message); + + } + + protected Object remoteCall(ServerInfo serverInfo, + + Message message) throws Exception { + + if (serverInfo == null) { + + throw new RuntimeException("get available server error"); + + } + + // 创建DemoRpcClient连接指定的Server端 + + DemoRpcClient demoRpcClient = new DemoRpcClient( + + serverInfo.getHost(), serverInfo.getPort()); + + ChannelFuture channelFuture = demoRpcClient.connect() + + .awaitUninterruptibly(); + + // 创建对应的Connection对象,并发送请求 + + Connection connection = new Connection(channelFuture, true); + + NettyResponseFuture responseFuture = + + connection.request(message, Constants.DEFAULT_TIMEOUT); + + // 等待请求对应的响应 + + return responseFuture.getPromise().get( + + Constants.DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS); + + } + +} + + +从 DemoRpcProxy 的实现中我们可以看到,它依赖了 ServiceInstanceCache 获取ZooKeeper 中注册的 Server 端地址,同时依赖了 DemoRpcClient 与Server 端进行通信,上层调用方拿到这个代理对象后,就可以像调用本地方法一样进行调用,而不再关心底层网络通信和服务发现的细节。当然,这个简易版 DemoRpcProxy 的实现还有很多可以优化的地方,例如: + + +缓存 DemoRpcClient 客户端对象以及相应的 Connection 对象,不必每次进行创建。 +可以添加失败重试机制,在请求出现超时的时候,进行重试。 +可以添加更加复杂和灵活的负载均衡机制,例如,根据 Hash 值散列进行负载均衡、根据节点 load 情况进行负载均衡等。 + + +你若感兴趣的话可以尝试进行扩展,以实现一个更加完善的代理层。 + +使用方接入 + +介绍完 Demo RPC 的核心实现之后,下面我们讲解下Demo RPC 框架的使用方式。这里涉及Consumer、DemoServiceImp、Provider三个类以及 DemoService 业务接口。 + + + +使用接入的相关类 + +首先,我们定义DemoService 接口作为业务 Server 接口,具体定义如下: + +public interface DemoService { + + String sayHello(String param); + +} + + +DemoServiceImpl对 DemoService 接口的实现也非常简单,如下所示,将参数做简单修改后返回: + +public class DemoServiceImpl implements DemoService { + + public String sayHello(String param) { + + return "hello:" + param; + + } + +} + + +了解完相应的业务接口和实现之后,我们再来看Provider的实现,它的角色类似于 Dubbo 中的 Provider,其会创建 DemoServiceImpl 这个业务 Bean 并将自身的地址信息暴露出去,如下所示: + +public class Provider { + + public static void main(String[] args) throws Exception { + + // 创建DemoServiceImpl,并注册到BeanManager中 + + BeanManager.registerBean("demoService", + + new DemoServiceImpl()); + + // 创建ZookeeperRegistry,并将Provider的地址信息封装成ServerInfo + + // 对象注册到Zookeeper + + ZookeeperRegistry discovery = + + new ZookeeperRegistry<>(); + + discovery.start(); + + ServerInfo serverInfo = new ServerInfo("127.0.0.1", 20880); + + discovery.registerService( + + ServiceInstance.builder().name("demoService") + + .payload(serverInfo).build()); + + // 启动DemoRpcServer,等待Client的请求 + + DemoRpcServer rpcServer = new DemoRpcServer(20880); + + rpcServer.start(); + + } + +} + + +最后是Consumer,它类似于 Dubbo 中的 Consumer,其会订阅 Provider 地址信息,然后根据这些信息选择一个 Provider 建立连接,发送请求并得到响应,这些过程在 Proxy 中都予以了封装,那Consumer 的实现就很简单了,可参考如下示例代码: + +public class Consumer { + + public static void main(String[] args) throws Exception { + + // 创建ZookeeperRegistr对象 + + ZookeeperRegistry discovery = new ZookeeperRegistry<>(); + + // 创建代理对象,通过代理调用远端Server + + DemoService demoService = DemoRpcProxy.newInstance(DemoService.class, discovery); + + // 调用sayHello()方法,并输出结果 + + String result = demoService.sayHello("hello"); + + System.out.println(result); + + } + +} + + +总结 + +本课时我们首先介绍了简易 RPC 框架中的transport 包,它在上一课时介绍的编解码器基础之上,实现了服务端和客户端的通信能力。之后讲解了registry 包如何实现与 ZooKeeper 的交互,完善了简易 RPC 框架的服务注册与服务发现的能力。接下来又分析了proxy 包的实现,其中通过 JDK 动态代理的方式,帮接入方屏蔽了底层网络通信的复杂性。最后,我们编写了一个简单的 DemoService 业务接口,以及相应的 Provider 和 Consumer 接入简易 RPC 框架。 + +在本课时最后,留给你一个小问题:在 transport 中创建 EventLoopGroup 的时候,为什么针对 Linux 系统使用的 EventLoopGroup会有所不同呢?期待你的留言。 + +简易版 RPC 框架 Demo 的链接:https://github.com/xxxlxy2008/demo-prc 。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/13本地缓存:降低ZooKeeper压力的一个常用手段.md b/专栏/Dubbo源码解读与实战-完/13本地缓存:降低ZooKeeper压力的一个常用手段.md new file mode 100644 index 0000000..4a9d5b8 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/13本地缓存:降低ZooKeeper压力的一个常用手段.md @@ -0,0 +1,221 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 本地缓存:降低 ZooKeeper 压力的一个常用手段 + 从这一课时开始,我们就进入了第二部分:注册中心。注册中心(Registry)在微服务架构中的作用举足轻重,有了它,服务提供者(Provider) 和消费者(Consumer) 就能感知彼此。从下面的 Dubbo 架构图中可知: + + + +Dubbo 架构图 + + +Provider 从容器启动后的初始化阶段便会向注册中心完成注册操作; +Consumer 启动初始化阶段会完成对所需 Prov·ider 的订阅操作; +另外,在 Provider 发生变化时,需要通知监听的 Consumer。 + + +Registry 只是 Consumer 和 Provider 感知彼此状态变化的一种便捷途径而已,它们彼此的实际通讯交互过程是直接进行的,对于 Registry 来说是透明无感的。Provider 状态发生变化了,会由 Registry 主动推送订阅了该 Provider 的所有 Consumer,这保证了 Consumer 感知 Provider 状态变化的及时性,也将和具体业务需求逻辑交互解耦,提升了系统的稳定性。 + +Dubbo 中存在很多概念,但有些理解起来就特别费劲,如本文的 Registry,翻译过来的意思是“注册中心”,但它其实是应用本地的注册中心客户端,真正的“注册中心”服务是其他独立部署的进程,或进程组成的集群,比如 ZooKeeper 集群。本地的 Registry 通过和 ZooKeeper 等进行实时的信息同步,维持这些内容的一致性,从而实现了注册中心这个特性。另外,就 Registry 而言,Consumer 和 Provider 只是个用户视角的概念,它们被抽象为了一条 URL 。 + +从本课时开始,我们就真正开始分析 Dubbo 源码了。首先看一下本课程第二部分内容在 Dubbo 架构中所处的位置(如下图红框所示),可以看到这部分内容在整个 Dubbo 体系中还是相对独立的,没有涉及 Protocol、Invoker 等 Dubbo 内部的概念。等介绍完这些概念之后,我们还会回看图中 Registry 红框之外的内容。 + + + +整个 Dubbo 体系图 + +核心接口 + +作为“注册中心”部分的第一课时,我们有必要介绍下 dubbo-registry-api 模块中的核心抽象接口,如下图所示: + + + +在 Dubbo 中,一般使用 Node 这个接口来抽象节点的概念。Node不仅可以表示 Provider 和 Consumer 节点,还可以表示注册中心节点。Node 接口中定义了三个非常基础的方法(如下图所示): + + + + +getUrl() 方法返回表示当前节点的 URL; +isAvailable() 检测当前节点是否可用; +destroy() 方法负责销毁当前节点并释放底层资源。 + + +RegistryService 接口抽象了注册服务的基本行为,如下图所示: + + + + +register() 方法和 unregister() 方法分别表示注册和取消注册一个 URL。 +subscribe() 方法和 unsubscribe() 方法分别表示订阅和取消订阅一个 URL。订阅成功之后,当订阅的数据发生变化时,注册中心会主动通知第二个参数指定的 NotifyListener 对象,NotifyListener 接口中定义的 notify() 方法就是用来接收该通知的。 +lookup() 方法能够查询符合条件的注册数据,它与 subscribe() 方法有一定的区别,subscribe() 方法采用的是 push 模式,lookup() 方法采用的是 pull 模式。 + + +Registry 接口继承了 RegistryService 接口和 Node 接口,如下图所示,它表示的就是一个拥有注册中心能力的节点,其中的 reExportRegister() 和 reExportUnregister() 方法都是委托给 RegistryService 中的相应方法。 + + + +RegistryFactory 接口是 Registry 的工厂接口,负责创建 Registry 对象,具体定义如下所示,其中 @SPI 注解指定了默认的扩展名为 dubbo,@Adaptive 注解表示会生成适配器类并根据 URL 参数中的 protocol 参数值选择相应的实现。 + +@SPI("dubbo") + +public interface RegistryFactory { + + @Adaptive({"protocol"}) + + Registry getRegistry(URL url); + +} + + +通过下面两张继承关系图可以看出,每个 Registry 实现类都有对应的 RegistryFactory 工厂实现,每个 RegistryFactory 工厂实现只负责创建对应的 Registry 对象。 + + + +RegistryFactory 继承关系图 + + + +Registry 继承关系图 + +其中,RegistryFactoryWrapper 是 RegistryFactory 接口的 Wrapper 类,它在底层 RegistryFactory 创建的 Registry 对象外层封装了一个 ListenerRegistryWrapper ,ListenerRegistryWrapper 中维护了一个 RegistryServiceListener 集合,会将 register()、subscribe() 等事件通知到 RegistryServiceListener 监听器。 + +AbstractRegistryFactory 是一个实现了 RegistryFactory 接口的抽象类,提供了规范 URL 的操作以及缓存 Registry 对象的公共能力。其中,缓存 Registry 对象是使用 HashMap 集合实现的(REGISTRIES 静态字段)。在规范 URL 的实现逻辑中,AbstractRegistryFactory 会将 RegistryService 的类名设置为 URL path 和 interface 参数,同时删除 export 和 refer 参数。 + +AbstractRegistry + +AbstractRegistry 实现了 Registry 接口,虽然 AbstractRegistry 本身在内存中实现了注册数据的读写功能,也没有什么抽象方法,但它依然被标记成了抽象类,从前面的Registry 继承关系图中可以看出,Registry 接口的所有实现类都继承了 AbstractRegistry。 + +为了减轻注册中心组件的压力,AbstractRegistry 会把当前节点订阅的 URL 信息缓存到本地的 Properties 文件中,其核心字段如下: + + +registryUrl(URL类型)。 该 URL 包含了创建该 Registry 对象的全部配置信息,是 AbstractRegistryFactory 修改后的产物。 +properties(Properties 类型)、file(File 类型)。 本地的 Properties 文件缓存,properties 是加载到内存的 Properties 对象,file 是磁盘上对应的文件,两者的数据是同步的。在 AbstractRegistry 初始化时,会根据 registryUrl 中的 file.cache 参数值决定是否开启文件缓存。如果开启文件缓存功能,就会立即将 file 文件中的 KV 缓存加载到 properties 字段中。当 properties 中的注册数据发生变化时,会写入本地的 file 文件进行同步。properties 是一个 KV 结构,其中 Key 是当前节点作为 Consumer 的一个 URL,Value 是对应的 Provider 列表,包含了所有 Category(例如,providers、routes、configurators 等) 下的 URL。properties 中有一个特殊的 Key 值为 registies,对应的 Value 是注册中心列表,其他记录的都是 Provider 列表。 +syncSaveFile(boolean 类型)。 是否同步保存文件的配置,对应的是 registryUrl 中的 save.file 参数。 +registryCacheExecutor(ExecutorService 类型)。 这是一个单线程的线程池,在一个 Provider 的注册数据发生变化的时候,会将该 Provider 的全量数据同步到 properties 字段和缓存文件中,如果 syncSaveFile 配置为 false,就由该线程池异步完成文件写入。 +lastCacheChanged(AtomicLong 类型)。 注册数据的版本号,每次写入 file 文件时,都是全覆盖写入,而不是修改文件,所以需要版本控制,防止旧数据覆盖新数据。 +registered(Set 类型)。 这个比较简单,它是注册的 URL 集合。 +subscribed(ConcurrentMap 类型)。 表示订阅 URL 的监听器集合,其中 Key 是被监听的 URL, Value 是相应的监听器集合。 +notified(ConcurrentMap>类型)。 该集合第一层 Key 是当前节点作为 Consumer 的一个 URL,表示的是该节点的某个 Consumer 角色(一个节点可以同时消费多个 Provider 节点);Value 是一个 Map 集合,该 Map 集合的 Key 是 Provider URL 的分类(Category),例如 providers、routes、configurators 等,Value 就是相应分类下的 URL 集合。 + + +介绍完 AbstractRegistry 的核心字段之后,我们接下来就再看看 AbstractRegistry 依赖这些字段都提供了哪些公共能力。 + +1. 本地缓存 + +作为一个 RPC 框架,Dubbo 在微服务架构中解决了各个服务间协作的难题;作为 Provider 和 Consumer 的底层依赖,它会与服务一起打包部署。dubbo-registry 也仅仅是其中一个依赖包,负责完成与 ZooKeeper、etcd、Consul 等服务发现组件的交互。 + +当 Provider 端暴露的 URL 发生变化时,ZooKeeper 等服务发现组件会通知 Consumer 端的 Registry 组件,Registry 组件会调用 notify() 方法,被通知的 Consumer 能匹配到所有 Provider 的 URL 列表并写入 properties 集合中。 + +下面我们来看 notify() 方法的核心实现: + +// 注意入参,第一个URL参数表示的是Consumer,第二个NotifyListener是第一个参数对应的监听器,第三个参数是Provider端暴露的URL的全量数据 + +protected void notify(URL url, NotifyListener listener, + + List urls) { + + ... // 省略一系列边界条件的检查 + + Map> result = new HashMap<>(); + + for (URL u : urls) { + + // 需要Consumer URL与Provider URL匹配,具体匹配规则后面详述 + + if (UrlUtils.isMatch(url, u)) { + + // 根据Provider URL中的category参数进行分类 + + String category = u.getParameter("category", "providers"); + + List categoryList = result.computeIfAbsent(category, + + k -> new ArrayList<>()); + + categoryList.add(u); + + } + + } + + if (result.size() == 0) { + + return; + + } + + Map> categoryNotified = + + notified.computeIfAbsent(url, u -> new ConcurrentHashMap<>()); + + for (Map.Entry> entry : result.entrySet()) { + + String category = entry.getKey(); + + List categoryList = entry.getValue(); + + categoryNotified.put(category, categoryList); // 更新notified + + listener.notify(categoryList); // 调用NotifyListener + + // 更新properties集合以及底层的文件缓存 + + saveProperties(url); + + } + +} + + +在 saveProperties() 方法中会取出 Consumer 订阅的各个分类的 URL 连接起来(中间以空格分隔),然后以 Consumer 的 ServiceKey 为键值写到 properties 中,同时 lastCacheChanged 版本号会自增。完成 properties 字段的更新之后,会根据 syncSaveFile 字段值来决定是在当前线程同步更新 file 文件,还是向 registryCacheExecutor 线程池提交任务,异步完成 file 文件的同步。本地缓存文件的具体路径是: + +/.dubbo/dubbo-registry-[当前应用名]-[当前Registry所在的IP地址].cache + + +这里首先关注第一个细节:UrlUtils.isMatch() 方法。该方法会完成 Consumer URL 与 Provider URL 的匹配,依次匹配的部分如下所示: + + +匹配 Consumer 和 Provider 的接口(优先取 interface 参数,其次再取 path)。双方接口相同或者其中一方为“*”,则匹配成功,执行下一步。 +匹配 Consumer 和 Provider 的 category。 +检测 Consumer URL 和 Provider URL 中的 enable 参数是否符合条件。 +检测 Consumer 和 Provider 端的 group、version 以及 classifier 是否符合条件。 + + +第二个细节是:URL.getServiceKey() 方法。该方法返回的 ServiceKey 是 properties 集合以及相应缓存文件中的 Key。ServiceKey 的格式如下: + +[group]/{interface(或path)}[:version] + + +AbstractRegistry 的核心是本地文件缓存的功能。 在 AbstractRegistry 的构造方法中,会调用 loadProperties() 方法将上面写入的本地缓存文件,加载到 properties 对象中。 + +在网络抖动等原因而导致订阅失败时,Consumer 端的 Registry 就可以调用 getCacheUrls() 方法获取本地缓存,从而得到最近注册的 Provider URL。可见,AbstractRegistry 通过本地缓存提供了一种容错机制,保证了服务的可靠性。 + +2. 注册/订阅 + +AbstractRegistry 实现了 Registry 接口,它实现的 registry() 方法会将当前节点要注册的 URL 缓存到 registered 集合,而 unregistry() 方法会从 registered 集合删除指定的 URL,例如当前节点下线的时候。 + +subscribe() 方法会将当前节点作为 Consumer 的 URL 以及相关的 NotifyListener 记录到 subscribed 集合,unsubscribe() 方法会将当前节点的 URL 以及关联的 NotifyListener 从 subscribed 集合删除。 + +这四个方法都是简单的集合操作,这里我们就不再展示具体代码了。 + +单看 AbstractRegistry 的实现,上述四个基础的注册、订阅方法都是内存操作,但是 Java 有继承和多态的特性,AbstractRegistry 的子类会覆盖上述四个基础的注册、订阅方法进行增强。 + + + +3. 恢复/销毁 + +AbstractRegistry 中还有另外两个需要关注的方法:recover() 方法和destroy() 方法。 + +在 Provider 因为网络问题与注册中心断开连接之后,会进行重连,重新连接成功之后,会调用 recover() 方法将 registered 集合中的全部 URL 重新走一遍 register() 方法,恢复注册数据。同样,recover() 方法也会将 subscribed 集合中的 URL 重新走一遍 subscribe() 方法,恢复订阅监听器。recover() 方法的具体实现比较简单,这里就不再展示,你若感兴趣的话,可以参考源码进行学习。 + +在当前节点下线的时候,会调用 Node.destroy() 方法释放底层资源。AbstractRegistry 实现的 destroy() 方法会调用 unregister() 方法和 unsubscribe() 方法将当前节点注册的 URL 以及订阅的监听全部清理掉,其中不会清理非动态注册的 URL(即 dynamic 参数明确指定为 false)。AbstractRegistry 中 destroy() 方法的实现比较简单,这里我们也不再展示,如果你感兴趣话,同样可以参考源码进行学习。 + +总结 + +本课时是 Dubbo 注册中心分析的第一个课时,我们首先介绍了注册中心在整个 Dubbo 架构中的位置,以及 Registry、 RegistryService、 RegistryFactory 等核心接口的功能。接下来我们还详细讲解了 AbstractRegistry 这个抽象类提供的公共能力,主要是从本地缓存、注册/订阅、恢复/销毁这三方面进行了分析。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/14重试机制是网络操作的基本保证.md b/专栏/Dubbo源码解读与实战-完/14重试机制是网络操作的基本保证.md new file mode 100644 index 0000000..a22416a --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/14重试机制是网络操作的基本保证.md @@ -0,0 +1,356 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 重试机制是网络操作的基本保证 + 在真实的微服务系统中, ZooKeeper、etcd 等服务发现组件一般会独立部署成一个集群,业务服务通过网络连接这些服务发现节点,完成注册和订阅操作。但即使是机房内部的稳定网络,也无法保证两个节点之间的请求一定成功,因此 Dubbo 这类 RPC 框架在稳定性和容错性方面,就受到了比较大的挑战。为了保证服务的可靠性,重试机制就变得必不可少了。 + +所谓的 “重试机制”就是在请求失败时,客户端重新发起一个一模一样的请求,尝试调用相同或不同的服务端,完成相应的业务操作。能够使用重试机制的业务接口得是“幂等”的,也就是无论请求发送多少次,得到的结果都是一样的,例如查询操作。 + +核心设计 + +在上一课时中,我们介绍了 AbstractRegistry 中的 register()/unregister()、subscribe()/unsubscribe() 以及 notify() 等核心操作,详细分析了通过本地缓存实现的容错功能。其实,这几个核心方法同样也是重试机制的关注点。 + +dubbo-registry 将重试机制的相关实现放到了 AbstractRegistry 的子类—— FailbackRegistry 中。如下图所示,接入 ZooKeeper、etcd 等开源服务发现组件的 Registry 实现,都继承了 FailbackRegistry,也就都拥有了失败重试的能力。 + + + +FailbackRegistry 设计核心是:覆盖了 AbstractRegistry 中 register()/unregister()、subscribe()/unsubscribe() 以及 notify() 这五个核心方法,结合前面介绍的时间轮,实现失败重试的能力;真正与服务发现组件的交互能力则是放到了 doRegister()/doUnregister()、doSubscribe()/doUnsubscribe() 以及 doNotify() 这五个抽象方法中,由具体子类实现。这是典型的模板方法模式的应用。 + +核心字段介绍 + +分析一个实现类的第一步就是了解其核心字段,那 FailbackRegistry 的核心字段有哪些呢? + + +retryTimer(HashedWheelTimer 类型):用于定时执行失败重试操作的时间轮。 +retryPeriod(int 类型):重试操作的时间间隔。 +failedRegistered(ConcurrentMap类型):注册失败的 URL 集合,其中 Key 是注册失败的 URL,Value 是对应的重试任务。 +failedUnregistered(ConcurrentMap类型):取消注册失败的 URL 集合,其中 Key 是取消注册失败的 URL,Value 是对应的重试任务。 +failedSubscribed(ConcurrentMap类型):订阅失败 URL 集合,其中 Key 是订阅失败的 URL + Listener 集合,Value 是相应的重试任务。 +failedUnsubscribed(ConcurrentMap类型):取消订阅失败的 URL 集合,其中 Key 是取消订阅失败的 URL + Listener 集合,Value 是相应的重试任务。 +failedNotified(ConcurrentMap类型):通知失败的 URL 集合,其中 Key 是通知失败的 URL + Listener 集合,Value 是相应的重试任务。 + + +在 FailbackRegistry 的构造方法中,首先会调用父类 AbstractRegistry 的构造方法完成本地缓存相关的初始化操作,然后从传入的 URL 参数中获取重试操作的时间间隔(即retry.period 参数)来初始化 retryPeriod 字段,最后初始化 retryTimer****时间轮。整个代码比较简单,这里就不展示了。 + +核心方法实现分析 + +FailbackRegistry 对 register()/unregister() 方法和 subscribe()/unsubscribe() 方法的具体实现非常类似,所以这里我们就只介绍其中register() 方法的具体实现流程。 + + +根据 registryUrl 中 accepts 参数指定的匹配模式,决定是否接受当前要注册的 Provider URL。 +调用父类 AbstractRegistry 的 register() 方法,将 Provider URL 写入 registered 集合中。 +调用 removeFailedRegistered() 方法和 removeFailedUnregistered() 方法,将该 Provider URL 从 failedRegistered 集合和 failedUnregistered 集合中删除,并停止相关的重试任务。 +调用 doRegister() 方法,与服务发现组件进行交互。该方法由子类实现,每个子类只负责接入一个特定的服务发现组件。 +在 doRegister() 方法出现异常的时候,会根据 URL 参数以及异常的类型,进行分类处理:待注册 URL 的 check 参数为 true(默认值为 true);待注册的 URL 不是 consumer 协议;registryUrl 的 check 参数也为 true(默认值为 true)。若满足这三个条件或者抛出的异常为 SkipFailbackWrapperException,则直接抛出异常。否则,就会创建重试任务并添加到 failedRegistered 集合中。 + + +明确 register() 方法的核心流程之后,我们再来看 register() 方法的具体代码实现: + +public void register(URL url) { + + if (!acceptable(url)) { + + logger.info("..."); // 打印相关的提示日志 + + return; + + } + + super.register(url); // 完成本地文件缓存的初始化 + + // 清理failedRegistered集合和failedUnregistered集合,并取消相关任务 + + removeFailedRegistered(url); + + removeFailedUnregistered(url); + + try { + + doRegister(url); // 与服务发现组件进行交互,具体由子类实现 + + } catch (Exception e) { + + Throwable t = e; + + // 检测check参数,决定是否直接抛出异常 + + boolean check = getUrl().getParameter(Constants.CHECK_KEY, + + true) && url.getParameter(Constants.CHECK_KEY, true) + + && !CONSUMER_PROTOCOL.equals(url.getProtocol()); + + boolean skipFailback = t instanceof + + SkipFailbackWrapperException; + + if (check || skipFailback) { + + if (skipFailback) { + + t = t.getCause(); + + } + + throw new IllegalStateException("Failed to register"); + + } + + // 如果不抛出异常,则创建失败重试的任务,并添加到failedRegistered集合中 + + addFailedRegistered(url); + + } + +} + + +从以上代码可以看出,当 Provider 向 Registry 注册 URL 的时候,如果注册失败,且未设置 check 属性,则创建一个定时任务,添加到时间轮中。 + +下面我们再来看看创建并添加这个重试任务的相关方法——addFailedRegistered() 方法,具体实现如下: + +private void addFailedRegistered(URL url) { + + FailedRegisteredTask oldOne = failedRegistered.get(url); + + if (oldOne != null) { // 已经存在重试任务,则无须创建,直接返回 + + return; + + } + + FailedRegisteredTask newTask = new FailedRegisteredTask(url, + + this); + + oldOne = failedRegistered.putIfAbsent(url, newTask); + + if (oldOne == null) { + + // 如果是新建的重试任务,则提交到时间轮中,等待retryPeriod毫秒后执行 + + retryTimer.newTimeout(newTask, retryPeriod, + + TimeUnit.MILLISECONDS); + + } + +} + + +重试任务 + +FailbackRegistry.addFailedRegistered() 方法中创建的 FailedRegisteredTask 任务以及其他的重试任务,都继承了 AbstractRetryTask 抽象类,如下图所示: + + + +在 AbstractRetryTask 中维护了当前任务关联的 URL、当前重试的次数等信息,在其 run() 方法中,会根据重试 URL 中指定的重试次数(retry.times 参数,默认值为 3)、任务是否被取消以及时间轮的状态,决定此次任务的 doRetry() 方法是否正常执行。 + +public void run(Timeout timeout) throws Exception { + + if (timeout.isCancelled() || timeout.timer().isStop() || isCancel()) { // 检测定时任务状态和时间轮状态 + + return; + + } + + if (times > retryTimes) { // 检查重试次数 + + logger.warn("..."); + + return; + + } + + try { + + doRetry(url, registry, timeout); // 执行重试 + + } catch (Throwable t) { + + reput(timeout, retryPeriod); // 重新添加定时任务,等待重试 + + } + +} + + +如果任务的 doRetry() 方法执行出现异常,AbstractRetryTask 会通过 reput() 方法将当前任务重新放入时间轮中,并递增当前任务的执行次数。 + +protected void reput(Timeout timeout, long tick) { + + if (timeout == null) { // 边界检查 + + throw new IllegalArgumentException(); + + } + + Timer timer = timeout.timer(); // 检查定时任务 + + if (timer.isStop() || timeout.isCancelled() || isCancel()) { + + return; + + } + + times++; // 递增times + + // 添加定时任务 + + timer.newTimeout(timeout.task(), tick, TimeUnit.MILLISECONDS); + +} + + +AbstractRetryTask 将 doRetry() 方法作为抽象方法,留给子类实现具体的重试逻辑,这也是模板方法的使用。 + +在子类 FailedRegisteredTask 的 doRetry() 方法实现中,会再次执行关联 Registry 的 doRegister() 方法,完成与服务发现组件交互。如果注册成功,则会调用 removeFailedRegisteredTask() 方法将当前关联的 URL 以及当前重试任务从 failedRegistered 集合中删除。如果注册失败,则会抛出异常,执行上文介绍的 reput ()方法重试。 + +protected void doRetry(URL url, FailbackRegistry registry, Timeout timeout) { + + registry.doRegister(url); // 重新注册 + + registry.removeFailedRegisteredTask(url); // 删除重试任务 + +} + +public void removeFailedRegisteredTask(URL url) { + + failedRegistered.remove(url); + +} + + +另外,在 register() 方法入口处,会主动调用 removeFailedRegistered() 方法和 removeFailedUnregistered() 方法来清理指定 URL 关联的定时任务: + +public void register(URL url) { + + super.register(url); + + removeFailedRegistered(url); // 清理FailedRegisteredTask定时任务 + + removeFailedUnregistered(url); // 清理FailedUnregisteredTask定时任务 + + try { + + doRegister(url); + + } catch (Exception e) { + + addFailedRegistered(url); + + } + +} + + +其他核心方法 + +unregister() 方法以及 unsubscribe() 方法的实现方式与 register() 方法类似,只是调用的 do*() 抽象方法、依赖的 AbstractRetryTask 有所不同而已,这里就不再展开细讲。 + +你还记得上一课时我们介绍的 AbstractRegistry 通过本地文件缓存实现的容错机制吗?FailbackRegistry.subscribe() 方法在处理异常的时候,会先获取缓存的订阅数据并调用 notify() 方法,如果没有缓存相应的订阅数据,才会检查 check 参数决定是否抛出异常。 + +通过上一课时对 AbstractRegistry.notify() 方法的介绍,我们知道其核心逻辑之一就是回调 NotifyListener。下面我们就来看一下 FailbackRegistry 对 notify() 方法的覆盖: + +protected void notify(URL url, NotifyListener listener, + + List urls) { + + ... // 检查url和listener不为空(略) + + try { + + // FailbackRegistry.doNotify()方法实际上就是调用父类 + + // AbstractRegistry.notify()方法,没有其他逻辑 + + doNotify(url, listener, urls); + + } catch (Exception t) { + + // doNotify()方法出现异常,则会添加一个定时任务 + + addFailedNotified(url, listener, urls); + + } + +} + + +addFailedNotified() 方法会创建相应的 FailedNotifiedTask 任务,添加到 failedNotified 集合中,同时也会添加到时间轮中等待执行。如果已存在相应的 FailedNotifiedTask 重试任务,则会更新任务需要处理的 URL 集合。 + +在 FailedNotifiedTask 中维护了一个 URL 集合,用来记录当前任务一次运行需要通知的 URL,每执行完一次任务,就会清空该集合,具体实现如下: + +protected void doRetry(URL url, FailbackRegistry registry, + + Timeout timeout) { + + // 如果urls集合为空,则会通知所有Listener,该任务也就啥都不做了 + + if (CollectionUtils.isNotEmpty(urls)) { + + listener.notify(urls); + + urls.clear(); + + } + + reput(timeout, retryPeriod); // 将任务重新添加到时间轮中等待执行 + +} + + +从上面的代码可以看出,FailedNotifiedTask 重试任务一旦被添加,就会一直运行下去,但真的是这样吗?在 FailbackRegistry 的 subscribe()、unsubscribe() 方法中,可以看到 removeFailedNotified() 方法的调用,这里就是清理 FailedNotifiedTask 任务的地方。我们以 FailbackRegistry.subscribe() 方法为例进行介绍: + +public void subscribe(URL url, NotifyListener listener) { + + super.subscribe(url, listener); + + removeFailedSubscribed(url, listener); // 关注这个方法 + + try { + + doSubscribe(url, listener); + + } catch (Exception e) { + + addFailedSubscribed(url, listener); + + } + +} + +// removeFailedSubscribed()方法中会清理FailedSubscribedTask、FailedUnsubscribedTask、FailedNotifiedTask三类定时任务 + +private void removeFailedSubscribed(URL url, NotifyListener listener) { + + Holder h = new Holder(url, listener); // 清理FailedSubscribedTask + + FailedSubscribedTask f = failedSubscribed.remove(h); + + if (f != null) { + + f.cancel(); + + } + + removeFailedUnsubscribed(url, listener);// 清理FailedUnsubscribedTask + + removeFailedNotified(url, listener); // 清理FailedNotifiedTask + +} + + +介绍完 FailbackRegistry 中最核心的注册/订阅实现之后,我们再来关注其实现的恢复功能,也就是 recover() 方法。该方法会直接通过 FailedRegisteredTask 任务处理 registered 集合中的全部 URL,通过 FailedSubscribedTask 任务处理 subscribed 集合中的 URL 以及关联的 NotifyListener。 + +FailbackRegistry 在生命周期结束时,会调用自身的 destroy() 方法,其中除了调用父类的 destroy() 方法之外,还会调用时间轮(即 retryTimer 字段)的 stop() 方法,释放时间轮相关的资源。 + +总结 + +本课时重点介绍了 AbstractRegistry 的实现类——FailbackRegistry 的核心实现,它主要是在 AbstractRegistry 的基础上,提供了重试机制。具体方法就是通过之前课时介绍的时间轮,在 register()/ unregister()、subscribe()/ unsubscribe() 等核心方法失败时,添加重试定时任务,实现重试机制,同时也添加了相应的定时任务清理逻辑。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/15ZooKeeper注册中心实现,官方推荐注册中心实践.md b/专栏/Dubbo源码解读与实战-完/15ZooKeeper注册中心实现,官方推荐注册中心实践.md new file mode 100644 index 0000000..284c1f2 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/15ZooKeeper注册中心实现,官方推荐注册中心实践.md @@ -0,0 +1,447 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 ZooKeeper 注册中心实现,官方推荐注册中心实践 + Dubbo 支持 ZooKeeper 作为注册中心服务,这也是 Dubbo 推荐使用的注册中心。为了让你能更好地理解 ZooKeeper 在 Dubbo 中的应用,接下来我们就先简单回顾下 ZooKeeper。 + + +Dubbo 本身是一个分布式的 RPC 开源框架,各个依赖于 Dubbo 的服务节点都是单独部署的,为了让 Provider 和 Consumer 能够实时获取彼此的信息,就得依赖于一个一致性的服务发现组件实现注册和订阅。Dubbo 可以接入多种服务发现组件,例如,ZooKeeper、etcd、Consul、Eureka 等。其中,Dubbo 特别推荐使用 ZooKeeper。 + +ZooKeeper 是为分布式应用所设计的高可用且一致性的开源协调服务。它是一个树型的目录服务,支持变更推送,非常适合应用在生产环境中。 + +下面是 Dubbo 官方文档中的一张图,展示了 Dubbo 在 Zookeeper 中的节点层级结构: + + + +Zookeeper 存储的 Dubbo 数据 + +图中的“dubbo”节点是 Dubbo 在 Zookeeper 中的根节点,“dubbo”是这个根节点的默认名称,当然我们也可以通过配置进行修改。 + +图中 Service 这一层的节点名称是服务接口的全名,例如 demo 示例中,该节点的名称为“org.apache.dubbo.demo.DemoService”。 + +图中 Type 这一层的节点是 URL 的分类,一共有四种分类,分别是:providers(服务提供者列表)、consumers(服务消费者列表)、routes(路由规则列表)和 configurations(配置规则列表)。 + +根据不同的 Type 节点,图中 URL 这一层中的节点包括:Provider URL 、Consumer URL 、Routes URL 和 Configurations URL。 + +ZookeeperRegistryFactory + +在前面第 13 课时介绍 Dubbo 注册中心核心概念的时候,我们讲解了 RegistryFactory 这个工厂接口以及其子类 AbstractRegistryFactory,AbstractRegistryFactory 仅仅是提供了缓存 Registry 对象的功能,并未真正实现 Registry 的创建,具体的创建逻辑是由子类完成的。在 dubbo-registry-zookeeper 模块中的 SPI 配置文件(目录位置如下图所示)中,指定了RegistryFactory 的实现类—— ZookeeperRegistryFactory。 + + + +RegistryFactory 的 SPI 配置文件位置 + +ZookeeperRegistryFactory 实现了 AbstractRegistryFactory,其中的 createRegistry() 方法会创建 ZookeeperRegistry 实例,后续将由该 ZookeeperRegistry 实例完成与 Zookeeper 的交互。 + +另外,ZookeeperRegistryFactory 中还提供了一个 setZookeeperTransporter() 方法,你可以回顾一下之前我们介绍的 Dubbo SPI 机制,会通过 SPI 或 Spring Ioc 的方式完成自动装载。 + +ZookeeperTransporter + +dubbo-remoting-zookeeper 模块是 dubbo-remoting 模块的子模块,但它并不依赖 dubbo-remoting 中的其他模块,是相对独立的,所以这里我们可以直接介绍该模块。 + +简单来说,dubbo-remoting-zookeeper 模块是在 Apache Curator 的基础上封装了一套 Zookeeper 客户端,将与 Zookeeper 的交互融合到 Dubbo 的体系之中。 + +dubbo-remoting-zookeeper 模块中有两个核心接口:ZookeeperTransporter 接口和 ZookeeperClient 接口。 + +ZookeeperTransporter 只负责一件事情,那就是创建 ZookeeperClient 对象。 + +@SPI("curator") + +public interface ZookeeperTransporter { + + @Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY}) + + ZookeeperClient connect(URL url); + +} + + +我们从代码中可以看到,ZookeeperTransporter 接口被 @SPI 注解修饰,成为一个扩展点,默认选择扩展名 “curator” 的实现,其中的 connect() 方法用于创建 ZookeeperClient 实例(该方法被 @Adaptive 注解修饰,我们可以通过 URL 参数中的 client 或 transporter 参数覆盖 @SPI 注解指定的默认扩展名)。 + + + +按照前面对 Registry 分析的思路,作为一个抽象实现,AbstractZookeeperTransporter 肯定是实现了创建 ZookeeperClient 之外的其他一些增强功能,然后由子类继承。不然的话,直接由 CuratorZookeeperTransporter 实现 ZookeeperTransporter 接口创建 ZookeeperClient 实例并返回即可,没必要在继承关系中再增加一层抽象类。 + +public class CuratorZookeeperTransporter extends + + AbstractZookeeperTransporter { + + // 创建ZookeeperClient实例 + + public ZookeeperClient createZookeeperClient(URL url) { + + return new CuratorZookeeperClient(url); + + } + +} + + +AbstractZookeeperTransporter 的核心功能有如下: + + +缓存 ZookeeperClient 实例; +在某个 Zookeeper 节点无法连接时,切换到备用 Zookeeper 地址。 + + +在配置 Zookeeper 地址的时候,我们可以配置多个 Zookeeper 节点的地址,这样的话,当一个 Zookeeper 节点宕机之后,Dubbo 就可以主动切换到其他 Zookeeper 节点。例如,我们提供了如下的 URL 配置: + +zookeeper://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?backup=127.0.0.1:8989,127.0.0.1:9999 + + +AbstractZookeeperTransporter 的 connect() 方法首先会得到上述 URL 中配置的 127.0.0.1:2181、127.0.0.1:8989 和 127.0.0.1:9999 这三个 Zookeeper 节点地址,然后从 ZookeeperClientMap 缓存(这是一个 Map,Key 为 Zookeeper 节点地址,Value 是相应的 ZookeeperClient 实例)中查找一个可用 ZookeeperClient 实例。如果查找成功,则复用 ZookeeperClient 实例;如果查找失败,则创建一个新的 ZookeeperClient 实例返回并更新 ZookeeperClientMap 缓存。 + +ZookeeperClient 实例连接到 Zookeeper 集群之后,就可以了解整个 Zookeeper 集群的拓扑,后续再出现 Zookeeper 节点宕机的情况,就是由 Zookeeper 集群本身以及 Apache Curator 共同完成故障转移。 + +ZookeeperClient + +从名字就可以看出,ZookeeperClient 接口是 Dubbo 封装的 Zookeeper 客户端,该接口定义了大量的方法,都是用来与 Zookeeper 进行交互的。 + + +create() 方法:创建 ZNode 节点,还提供了创建临时 ZNode 节点的重载方法。 +getChildren() 方法:获取指定节点的子节点集合。 +getContent() 方法:获取某个节点存储的内容。 +delete() 方法:删除节点。 +add*Listener() / remove*Listener() 方法:添加/删除监听器。 +close() 方法:关闭当前 ZookeeperClient 实例。 + + +AbstractZookeeperClient 作为 ZookeeperClient 接口的抽象实现,主要提供了如下几项能力: + + +缓存当前 ZookeeperClient 实例创建的持久 ZNode 节点; +管理当前 ZookeeperClient 实例添加的各类监听器; +管理当前 ZookeeperClient 的运行状态。 + + +我们来看 AbstractZookeeperClient 的核心字段,首先是 persistentExistNodePath(ConcurrentHashSet类型)字段,它缓存了当前 ZookeeperClient 创建的持久 ZNode 节点路径,在创建 ZNode 节点之前,会先查这个缓存,而不是与 Zookeeper 交互来判断持久 ZNode 节点是否存在,这就减少了一次与 Zookeeper 的交互。 + +dubbo-remoting-zookeeper 对外提供了 StateListener、DataListener 和 ChildListener 三种类型的监听器。 + + +StateListener:主要负责监听 Dubbo 与 Zookeeper 集群的连接状态,包括 SESSION_LOST、CONNECTED、RECONNECTED、SUSPENDED 和 NEW_SESSION_CREATED。 + + + + + +DataListener:主要监听某个节点存储的数据变化。 + + + + + +ChildListener:主要监听某个 ZNode 节点下的子节点变化。 + + + + +在 AbstractZookeeperClient 中维护了 stateListeners、listeners 以及 childListeners 三个集合,分别管理上述三种类型的监听器。虽然监听内容不同,但是它们的管理方式是类似的,所以这里我们只分析 listeners 集合的操作: + +public void addDataListener(String path, + + DataListener listener, Executor executor) { + + // 获取指定path上的DataListener集合 + + ConcurrentMap dataListenerMap = + + listeners.computeIfAbsent(path, k -> new ConcurrentHashMap<>()); + + // 查询该DataListener关联的TargetDataListener + + TargetDataListener targetListener = + + dataListenerMap.computeIfAbsent(listener, + + k -> createTargetDataListener(path, k)); + + // 通过TargetDataListener在指定的path上添加监听 + + addTargetDataListener(path, targetListener, executor); + +} + + +这里的 createTargetDataListener() 方法和 addTargetDataListener() 方法都是抽象方法,由 AbstractZookeeperClient 的子类实现,TargetDataListener 是 AbstractZookeeperClient 中标记的一个泛型。 + +为什么 AbstractZookeeperClient 要使用泛型定义?这是因为不同的 ZookeeperClient 实现可能依赖不同的 Zookeeper 客户端组件,不同 Zookeeper 客户端组件的监听器实现也有所不同,而整个 dubbo-remoting-zookeeper 模块对外暴露的监听器是统一的,就是上面介绍的那三种。因此,这时就需要一层转换进行解耦,这层解耦就是通过 TargetDataListener 完成的。 + + +虽然在 Dubbo 2.7.7 版本中只支持 Curator,但是在 Dubbo 2.6.5 版本的源码中可以看到,ZookeeperClient 还有使用 ZkClient 的实现。 + + +在最新的 Dubbo 版本中,CuratorZookeeperClient 是 AbstractZookeeperClient 的唯一实现类,在其构造方法中会初始化 Curator 客户端并阻塞等待连接成功: + +public CuratorZookeeperClient(URL url) { + + super(url); + + int timeout = url.getParameter("timeout", 5000); + + int sessionExpireMs = url.getParameter("zk.session.expire", + + 60000); + + CuratorFrameworkFactory.Builder builder = + + CuratorFrameworkFactory.builder() + + .connectString(url.getBackupAddress())//zk地址(包括备用地址) + + .retryPolicy(new RetryNTimes(1, 1000)) // 重试配置 + + .connectionTimeoutMs(timeout) // 连接超时时长 + + .sessionTimeoutMs(sessionExpireMs); // session过期时间 + + ... // 省略处理身份验证的逻辑 + + client = builder.build(); + + // 添加连接状态的监听 + + client.getConnectionStateListenable().addListener( + + new CuratorConnectionStateListener(url)); + + client.start(); + + boolean connected = client.blockUntilConnected(timeout, + + TimeUnit.MILLISECONDS); + + ... // 检测connected这个返回值,连接失败抛出异常 + +} + + +CuratorZookeeperClient 与 Zookeeper 交互的全部操作,都是围绕着这个 Apache Curator 客户端展开的, Apache Curator 的具体使用方式在前面的第 6 和 7 课时已经介绍过了,这里就不再赘述。 + +内部类 CuratorWatcherImpl 就是 CuratorZookeeperClient 实现 AbstractZookeeperClient 时指定的泛型类,它实现了 TreeCacheListener 接口,可以添加到 TreeCache 上监听自身节点以及子节点的变化。在 childEvent() 方法的实现中我们可以看到,当 TreeCache 关注的树型结构发生变化时,会将触发事件的路径、节点内容以及事件类型传递给关联的 DataListener 实例进行回调: + +public void childEvent(CuratorFramework client, TreeCacheEvent event) throws Exception { + + if (dataListener != null) { + + TreeCacheEvent.Type type = event.getType(); + + EventType eventType = null; + + String content = null; + + String path = null; + + switch (type) { + + case NODE_ADDED: + + eventType = EventType.NodeCreated; + + path = event.getData().getPath(); + + content = event.getData().getData() == null ? "" : new String(event.getData().getData(), CHARSET); + + break; + + case NODE_UPDATED: + + ... + + case NODE_REMOVED: + + ... + + // 省略其他时间的处理 + + } + + // 回调DataListener,传递触发事件的path、节点内容以及事件类型 + + dataListener.dataChanged(path, content, eventType); + + } + +} + + +在 CuratorZookeeperClient 的 addTargetDataListener() 方法实现中,我们可以看到 TreeCache 的创建、启动逻辑以及添加 CuratorWatcherImpl 监听的逻辑: + +protected void addTargetDataListener(String path, CuratorZookeeperClient.CuratorWatcherImpl treeCacheListener, Executor executor) { + + // 创建TreeCache + + TreeCache treeCache = TreeCache.newBuilder(client, path).setCacheData(false).build(); + + treeCacheMap.putIfAbsent(path, treeCache); // 缓存TreeCache + + if (executor == null) { // 添加监听 + + treeCache.getListenable().addListener(treeCacheListener); + + } else { + + treeCache.getListenable().addListener(treeCacheListener, executor); + + } + + treeCache.start(); // 启动 + +} + + +如果需要在回调中获取全部 Child 节点,那么 dubbo-remoting-zookeeper 调用方需要使用 ChildListener(在下面即将介绍的 ZookeeperRegistry 中可以看到 ChildListener 相关使用方式)。CuratorWatcherImpl 也是 ChildListener 与 CuratorWatcher 的桥梁,具体实现方式与上述逻辑类似,这里不再展开。 + +到此为止,dubbo-remoting-zookeeper 模块的核心实现就介绍完了,该模块作为 Dubbo 与 Zookeeper 交互的基础,不仅支撑了基于 Zookeeper 的注册中心的实现,还支撑了基于 Zookeeper 的服务发现的实现。这里我们重点关注基于 Zookeeper 的注册中心实现。 + +ZookeeperRegistry + +下面我们回到 dubbo-registry-zookeeper 模块,继续分析基于 Zookeeper 的注册中心实现。 + +在 ZookeeperRegistry 的构造方法中,会通过 ZookeeperTransporter 创建 ZookeeperClient 实例并连接到 Zookeeper 集群,同时还会添加一个连接状态的监听器。在该监听器中主要关注RECONNECTED 状态和 NEW_SESSION_CREATED 状态,在当前 Dubbo 节点与 Zookeeper 的连接恢复或是 Session 恢复的时候,会重新进行注册/订阅,防止数据丢失。这段代码比较简单,我们就不展开分析了。 + +doRegister() 方法和 doUnregister() 方法的实现都是通过 ZookeeperClient 找到合适的路径,然后创建(或删除)相应的 ZNode 节点。这里唯一需要注意的是,doRegister() 方法注册 Provider URL 的时候,会根据 dynamic 参数决定创建临时 ZNode 节点还是持久 ZNode 节点(默认创建临时 ZNode 节点),这样当 Provider 端与 Zookeeper 会话关闭时,可以快速将变更推送到 Consumer 端。 + +这里注意一下 toUrlPath() 这个方法得到的路径,是由下图中展示的方法拼装而成的,其中每个方法对应本课时开始展示的 Zookeeper 节点层级图中的一层。 + + + +doSubscribe() 方法的核心是通过 ZookeeperClient 在指定的 path 上添加 ChildListener 监听器,当订阅的节点发现变化的时候,会通过 ChildListener 监听器触发 notify() 方法,在 notify() 方法中会触发传入的 NotifyListener 监听器。 + +从 doSubscribe() 方法的代码结构可看出,doSubscribe() 方法的逻辑分为了两个大的分支。 + +一个分支是处理:订阅 URL 中明确指定了 Service 层接口的订阅请求。该分支会从 URL 拿到 Consumer 关注的 category 节点集合,然后在每个 category 节点上添加 ChildListener 监听器。下面是 Demo 示例中 Consumer 订阅的三个 path,图中展示了构造 path 各个部分的相关方法: + + + +下面是这个分支的核心源码分析: + +List urls = new ArrayList<>(); + +for (String path : toCategoriesPath(url)) { // 要订阅的所有path + + // 订阅URL对应的Listener集合 + + ConcurrentMap listeners = + + zkListeners.computeIfAbsent(url, + + k -> new ConcurrentHashMap<>()); + + // 一个NotifyListener关联一个ChildListener,这个ChildListener会回调 + + // ZookeeperRegistry.notify()方法,其中会回调当前NotifyListener + + ChildListener zkListener = listeners.computeIfAbsent(listener, + + k -> (parentPath, currentChilds) -> + + ZookeeperRegistry.this.notify(url, k, + + toUrlsWithEmpty(url, parentPath, currentChilds))); + + // 尝试创建持久节点,主要是为了确保当前path在Zookeeper上存在 + + zkClient.create(path, false); + + // 这一个ChildListener会添加到多个path上 + + List children = zkClient.addChildListener(path, + + zkListener); + + if (children != null) { + + // 如果没有Provider注册,toUrlsWithEmpty()方法会返回empty协议的URL + + urls.addAll(toUrlsWithEmpty(url, path, children)); + + } + +} + +// 初次订阅的时候,会主动调用一次notify()方法,通知NotifyListener处理当前已有的 + +// URL等注册数据 + +notify(url, listener, urls); + + +doSubscribe() 方法的另一个分支是处理:监听所有 Service 层节点的订阅请求,例如,Monitor 就会发出这种订阅请求,因为它需要监控所有 Service 节点的变化。这个分支的处理逻辑是在根节点上添加一个 ChildListener 监听器,当有 Service 层的节点出现的时候,会触发这个 ChildListener,其中会重新触发 doSubscribe() 方法执行上一个分支的逻辑(即前面分析的针对确定的 Service 层接口订阅分支)。 + +下面是针对这个分支核心代码的分析: + +String root = toRootPath(); // 获取根节点 + +// 获取NotifyListener对应的ChildListener + +ConcurrentMap listeners = + + zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>()); + +ChildListener zkListener = listeners.computeIfAbsent(listener, k -> + + (parentPath, currentChilds) -> { + + for (String child : currentChilds) { + + child = URL.decode(child); + + if (!anyServices.contains(child)) { + + anyServices.add(child); // 记录该节点已经订阅过 + + // 该ChildListener要做的就是触发对具体Service节点的订阅 + + subscribe(url.setPath(child).addParameters("interface", + + child, "check", String.valueOf(false)), k); + + } + + } + +}); + +zkClient.create(root, false); // 保证根节点存在 + +// 第一次订阅的时候,要处理当前已有的Service层节点 + +List services = zkClient.addChildListener(root, zkListener); + +if (CollectionUtils.isNotEmpty(services)) { + + for (String service : services) { + + service = URL.decode(service); + + anyServices.add(service); + + subscribe(url.setPath(service).addParameters(INTERFACE_KEY, + + service, "check", String.valueOf(false)), listener); + + } + +} + + +ZookeeperRegistry 提供的 doUnsubscribe() 方法实现会将 URL 和 NotifyListener 对应的 ChildListener 从相关的 path 上删除,从而达到不再监听该 path 的效果。 + +总结 + +本课时我们重点介绍了 Dubbo 接入 Zookeeper 作为注册中心的核心实现。 + +首先我们快速回顾了 Zookeeper 的基础内容,以及作为 Dubbo 注册中心时 Zookeeper 存储的具体内容,之后介绍了针对 Zookeeper 的 RegistryFactory 实现—— ZookeeperRegistryFactory。 + +接下来我们讲解了 Dubbo 接入 Zookeeper 时使用的组件实现,重点分析了 ZookeeperTransporter 和 ZookeeperClient 实现,它们底层依赖 Apache Curator 与 Zookeeper 完成交互。 + +最后,我们还说明了 ZookeeperRegistry 是如何通过 ZookeeperClient 接入 Zookeeper,实现 Registry 的相关功能。 + +关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/16DubboSerialize层:多种序列化算法,总有一款适合你.md b/专栏/Dubbo源码解读与实战-完/16DubboSerialize层:多种序列化算法,总有一款适合你.md new file mode 100644 index 0000000..aabc45c --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/16DubboSerialize层:多种序列化算法,总有一款适合你.md @@ -0,0 +1,193 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 Dubbo Serialize 层:多种序列化算法,总有一款适合你 + 通过前面课时的介绍,我们知道一个 RPC 框架需要通过网络通信实现跨 JVM 的调用。既然需要网络通信,那就必然会使用到序列化与反序列化的相关技术,Dubbo 也不例外。下面我们从 Java 序列化的基础内容开始,介绍一下常见的序列化算法,最后再分析一下 Dubbo 是如何支持这些序列化算法的。 + +Java 序列化基础 + +Java 中的序列化操作一般有如下四个步骤。 + +第一步,被序列化的对象需要实现 Serializable 接口,示例代码如下: + +public class Student implements Serializable { + + private static final long serialVersionUID = 1L; + + private String name; + + private int age; + + private transient StudentUtil studentUtil; + +} + + +在这个示例中我们可以看到transient 关键字,它的作用就是:在对象序列化过程中忽略被其修饰的成员属性变量。一般情况下,它可以用来修饰一些非数据型的字段以及一些可以通过其他字段计算得到的值。通过合理地使用 transient 关键字,可以降低序列化后的数据量,提高网络传输效率。 + +第二步,生成一个序列号 serialVersionUID,这个序列号不是必需的,但还是建议你生成。serialVersionUID 的字面含义是序列化的版本号,只有序列化和反序列化的 serialVersionUID 都相同的情况下,才能够成功地反序列化。如果类中没有定义 serialVersionUID,那么 JDK 也会随机生成一个 serialVersionUID。如果在某些场景中,你希望不同版本的类序列化和反序列化相互兼容,那就需要定义相同的 serialVersionUID。 + +第三步,根据需求决定是否要重写 writeObject()/readObject() 方法,实现自定义序列化。 + +最后一步,调用 java.io.ObjectOutputStream 的 writeObject()/readObject() 进行序列化与反序列化。 + +既然 Java 本身的序列化操作如此简单,那为什么市面上还依旧出现了各种各样的序列化框架呢?因为这些第三方序列化框架的速度更快、序列化的效率更高,而且支持跨语言操作。 + +常见序列化算法 + +为了帮助你快速了解 Dubbo 支持的序列化算法,我们这里就对其中常见的序列化算法进行简单介绍。 + +Apache Avro 是一种与编程语言无关的序列化格式。Avro 依赖于用户自定义的 Schema,在进行序列化数据的时候,无须多余的开销,就可以快速完成序列化,并且生成的序列化数据也较小。当进行反序列化的时候,需要获取到写入数据时用到的 Schema。在 Kafka、Hadoop 以及 Dubbo 中都可以使用 Avro 作为序列化方案。 + +FastJson 是阿里开源的 JSON 解析库,可以解析 JSON 格式的字符串。它支持将 Java 对象序列化为 JSON 字符串,反过来从 JSON 字符串也可以反序列化为 Java 对象。FastJson 是 Java 程序员常用到的类库之一,正如其名,“快”是其主要卖点。从官方的测试结果来看,FastJson 确实是最快的,比 Jackson 快 20% 左右,但是近几年 FastJson 的安全漏洞比较多,所以你在选择版本的时候,还是需要谨慎一些。 + +Fst(全称是 fast-serialization)是一款高性能 Java 对象序列化工具包,100% 兼容 JDK 原生环境,序列化速度大概是JDK 原生序列化的 4~10 倍,序列化后的数据大小是 JDK 原生序列化大小的 1⁄3 左右。目前,Fst 已经更新到 3.x 版本,支持 JDK 14。 + +Kryo 是一个高效的 Java 序列化/反序列化库,目前 Twitter、Yahoo、Apache 等都在使用该序列化技术,特别是 Spark、Hive 等大数据领域用得较多。Kryo 提供了一套快速、高效和易用的序列化 API。无论是数据库存储,还是网络传输,都可以使用 Kryo 完成 Java 对象的序列化。Kryo 还可以执行自动深拷贝和浅拷贝,支持环形引用。Kryo 的特点是 API 代码简单,序列化速度快,并且序列化之后得到的数据比较小。另外,Kryo 还提供了 NIO 的网络通信库——KryoNet,你若感兴趣的话可以自行查询和了解一下。 + +Hessian2 序列化是一种支持动态类型、跨语言的序列化协议,Java 对象序列化的二进制流可以被其他语言使用。Hessian2 序列化之后的数据可以进行自描述,不会像 Avro 那样依赖外部的 Schema 描述文件或者接口定义。Hessian2 可以用一个字节表示常用的基础类型,这极大缩短了序列化之后的二进制流。需要注意的是,在 Dubbo 中使用的 Hessian2 序列化并不是原生的 Hessian2 序列化,而是阿里修改过的 Hessian Lite,它是 Dubbo 默认使用的序列化方式。其序列化之后的二进制流大小大约是 Java 序列化的 50%,序列化耗时大约是 Java 序列化的 30%,反序列化耗时大约是 Java 序列化的 20%。 + +Protobuf(Google Protocol Buffers)是 Google 公司开发的一套灵活、高效、自动化的、用于对结构化数据进行序列化的协议。但相比于常用的 JSON 格式,Protobuf 有更高的转化效率,时间效率和空间效率都是 JSON 的 5 倍左右。Protobuf 可用于通信协议、数据存储等领域,它本身是语言无关、平台无关、可扩展的序列化结构数据格式。目前 Protobuf提供了 C++、Java、Python、Go 等多种语言的 API,gRPC 底层就是使用 Protobuf 实现的序列化。 + +dubbo-serialization + +Dubbo 为了支持多种序列化算法,单独抽象了一层 Serialize 层,在整个 Dubbo 架构中处于最底层,对应的模块是 dubbo-serialization 模块。 dubbo-serialization 模块的结构如下图所示: + + + +dubbo-serialization-api 模块中定义了 Dubbo 序列化层的核心接口,其中最核心的是 Serialization 这个接口,它是一个扩展接口,被 @SPI 接口修饰,默认扩展实现是 Hessian2Serialization。Serialization 接口的具体实现如下: + +@SPI("hessian2") // 被@SPI注解修饰,默认是使用hessian2序列化算法 + +public interface Serialization { + + // 每一种序列化算法都对应一个ContentType,该方法用于获取ContentType + + String getContentType(); + + // 获取ContentType的ID值,是一个byte类型的值,唯一确定一个算法 + + byte getContentTypeId(); + + // 创建一个ObjectOutput对象,ObjectOutput负责实现序列化的功能,即将Java + + // 对象转化为字节序列 + + @Adaptive + + ObjectOutput serialize(URL url, OutputStream output) throws IOException; + + // 创建一个ObjectInput对象,ObjectInput负责实现反序列化的功能,即将 + + // 字节序列转换成Java对象 + + @Adaptive + + ObjectInput deserialize(URL url, InputStream input) throws IOException; + +} + + +Dubbo 提供了多个 Serialization 接口实现,用于接入各种各样的序列化算法,如下图所示: + + + +这里我们以默认的 hessian2 序列化方式为例,介绍 Serialization 接口的实现以及其他相关实现。 Hessian2Serialization 实现如下所示: + +public class Hessian2Serialization implements Serialization { + + public byte getContentTypeId() { + + return HESSIAN2_SERIALIZATION_ID; // hessian2的ContentType ID + + } + + public String getContentType() { // hessian2的ContentType + + return "x-application/hessian2"; + + } + + public ObjectOutput serialize(URL url, OutputStream out) throws IOException { // 创建ObjectOutput对象 + + return new Hessian2ObjectOutput(out); + + } + + public ObjectInput deserialize(URL url, InputStream is) throws IOException { // 创建ObjectInput对象 + + return new Hessian2ObjectInput(is); + + } + +} + + +Hessian2Serialization 中的 serialize() 方法创建的 ObjectOutput 接口实现为 Hessian2ObjectOutput,继承关系如下图所示: + + + +在 DataOutput 接口中定义了序列化 Java 中各种数据类型的相应方法,如下图所示,其中有序列化 boolean、short、int、long 等基础类型的方法,也有序列化 String、byte[] 的方法。 + + + +ObjectOutput 接口继承了 DataOutput 接口,并在其基础之上,添加了序列化对象的功能,具体定义如下图所示,其中的 writeThrowable()、writeEvent() 和 writeAttachments() 方法都是调用 writeObject() 方法实现的。 + + + +Hessian2ObjectOutput 中会封装一个 Hessian2Output 对象,需要注意,这个对象是 ThreadLocal 的,与线程绑定。在 DataOutput 接口以及 ObjectOutput 接口中,序列化各类型数据的方法都会委托给 Hessian2Output 对象的相应方法完成,实现如下: + +public class Hessian2ObjectOutput implements ObjectOutput { + + private static ThreadLocal OUTPUT_TL = ThreadLocal.withInitial(() -> { + + // 初始化Hessian2Output对象 + + Hessian2Output h2o = new Hessian2Output(null); h2o.setSerializerFactory(Hessian2SerializerFactory.SERIALIZER_FACTORY); + + h2o.setCloseStreamOnClose(true); + + return h2o; + + }); + + private final Hessian2Output mH2o; + + public Hessian2ObjectOutput(OutputStream os) { + + mH2o = OUTPUT_TL.get(); // 触发OUTPUT_TL的初始化 + + mH2o.init(os); + + } + + public void writeObject(Object obj) throws IOException { + + mH2o.writeObject(obj); + + } + + ... // 省略序列化其他类型数据的方法 + +} + + +Hessian2Serialization 中的 deserialize() 方法创建的 ObjectInput 接口实现为 Hessian2ObjectInput,继承关系如下所示: + + + +Hessian2ObjectInput 具体的实现与 Hessian2ObjectOutput 类似:在 DataInput 接口中实现了反序列化各种类型的方法,在 ObjectInput 接口中提供了反序列化 Java 对象的功能,在 Hessian2ObjectInput 中会将所有反序列化的实现委托为 Hessian2Input。 + +了解了 Dubbo Serialize 层的核心接口以及 Hessian2 序列化算法的接入方式之后,你就可以亲自动手,去阅读其他序列化算法对应模块的代码。 + +总结 + +在本课时,我们首先介绍了 Java 序列化的基础知识,帮助你快速了解序列化和反序列化的基本概念。然后,介绍了常见的序列化算法,例如,Arvo、Fastjson、Fst、Kryo、Hessian、Protobuf 等。最后,深入分析了 dubbo-serialization 模块对各个序列化算法的接入方式,其中重点说明了 Hessian2 序列化方式。 + +关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/17DubboRemoting层核心接口分析:这居然是一套兼容所有NIO框架的设计?.md b/专栏/Dubbo源码解读与实战-完/17DubboRemoting层核心接口分析:这居然是一套兼容所有NIO框架的设计?.md new file mode 100644 index 0000000..bd2ed20 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/17DubboRemoting层核心接口分析:这居然是一套兼容所有NIO框架的设计?.md @@ -0,0 +1,234 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计? + 在本专栏的第二部分,我们深入介绍了 Dubbo 注册中心的相关实现,下面我们开始介绍 dubbo-remoting 模块,该模块提供了多种客户端和服务端通信的功能。在 Dubbo 的整体架构设计图中,我们可以看到最底层红色框选中的部分即为 Remoting 层,其中包括了 Exchange、Transport和Serialize 三个子层次。这里我们要介绍的 dubbo-remoting 模块主要对应 Exchange 和 Transport 两层。 + + + +Dubbo 整体架构设计图 + +Dubbo 并没有自己实现一套完整的网络库,而是使用现有的、相对成熟的第三方网络库,例如,Netty、Mina 或是 Grizzly 等 NIO 框架。我们可以根据自己的实际场景和需求修改配置,选择底层使用的 NIO 框架。 + +下图展示了 dubbo-remoting 模块的结构,其中每个子模块对应一个第三方 NIO 框架,例如,dubbo-remoting-netty4 子模块使用 Netty4 实现 Dubbo 的远程通信,dubbo-remoting-grizzly 子模块使用 Grizzly 实现 Dubbo 的远程通信。 + + + +其中的 dubbo-remoting-zookeeper,我们在前面第 15 课时介绍基于 Zookeeper 的注册中心实现时已经讲解过了,它使用 Apache Curator 实现了与 Zookeeper 的交互。 + +dubbo-remoting-api 模块 + +需要注意的是,Dubbo 的 dubbo-remoting-api 是其他 dubbo-remoting-* 模块的顶层抽象,其他 dubbo-remoting 子模块都是依赖第三方 NIO 库实现 dubbo-remoting-api 模块的,依赖关系如下图所示: + + + +我们先来看一下 dubbo-remoting-api 中对整个 Remoting 层的抽象,dubbo-remoting-api 模块的结构如下图所示: + + + +一般情况下,我们会将功能类似或是相关联的类放到一个包中,所以我们需要先来了解 dubbo-remoting-api 模块中各个包的功能。 + + +buffer 包:定义了缓冲区相关的接口、抽象类以及实现类。缓冲区在NIO框架中是一个不可或缺的角色,在各个 NIO 框架中都有自己的缓冲区实现。这里的 buffer 包在更高的层面,抽象了各个 NIO 框架的缓冲区,同时也提供了一些基础实现。 +exchange 包:抽象了 Request 和 Response 两个概念,并为其添加很多特性。这是整个远程调用非常核心的部分。 +transport 包:对网络传输层的抽象,但它只负责抽象单向消息的传输,即请求消息由 Client 端发出,Server 端接收;响应消息由 Server 端发出,Client端接收。有很多网络库可以实现网络传输的功能,例如 Netty、Grizzly 等, transport 包是在这些网络库上层的一层抽象。 +其他接口:Endpoint、Channel、Transporter、Dispatcher 等顶层接口放到了org.apache.dubbo.remoting 这个包,这些接口是 Dubbo Remoting 的核心接口。 + + +下面我们就来介绍 Dubbo 是如何抽象这些核心接口的。 + +传输层核心接口 + +在 Dubbo 中会抽象出一个“端点(Endpoint)”的概念,我们可以通过一个 ip 和 port 唯一确定一个端点,两个端点之间会创建 TCP 连接,可以双向传输数据。Dubbo 将 Endpoint 之间的 TCP 连接抽象为通道(Channel),将发起请求的 Endpoint 抽象为客户端(Client),将接收请求的 Endpoint 抽象为服务端(Server)。这些抽象出来的概念,也是整个 dubbo-remoting-api 模块的基础,下面我们会逐个进行介绍。 + +Dubbo 中Endpoint 接口的定义如下: + + + +如上图所示,这里的 get*() 方法是获得 Endpoint 本身的一些属性,其中包括获取 Endpoint 的本地地址、关联的 URL 信息以及底层 Channel 关联的 ChannelHandler。send() 方法负责数据发送,两个重载的区别在后面介绍 Endpoint 实现的时候我们再详细说明。最后两个 close() 方法的重载以及 startClose() 方法用于关闭底层 Channel ,isClosed() 方法用于检测底层 Channel 是否已关闭。 + +Channel 是对两个 Endpoint 连接的抽象,好比连接两个位置的传送带,两个 Endpoint 传输的消息就好比传送带上的货物,消息发送端会往 Channel 写入消息,而接收端会从 Channel 读取消息。这与第 10 课时介绍的 Netty 中的 Channel 基本一致。 + + + +下面是Channel 接口的定义,我们可以看出两点:一个是 Channel 接口继承了 Endpoint 接口,也具备开关状态以及发送数据的能力;另一个是可以在 Channel 上附加 KV 属性。 + + + +ChannelHandler 是注册在 Channel 上的消息处理器,在 Netty 中也有类似的抽象,相信你对此应该不会陌生。下图展示了 ChannelHandler 接口的定义,在 ChannelHandler 中可以处理 Channel 的连接建立以及连接断开事件,还可以处理读取到的数据、发送的数据以及捕获到的异常。从这些方法的命名可以看到,它们都是动词的过去式,说明相应事件已经发生过了。 + + + +需要注意的是:ChannelHandler 接口被 @SPI 注解修饰,表示该接口是一个扩展点。 + +在前面课时介绍 Netty 的时候,我们提到过有一类特殊的 ChannelHandler 专门负责实现编解码功能,从而实现字节数据与有意义的消息之间的转换,或是消息之间的相互转换。在dubbo-remoting-api 中也有相似的抽象,如下所示: + +@SPI + +public interface Codec2 { + + @Adaptive({Constants.CODEC_KEY}) + + void encode(Channel channel, ChannelBuffer buffer, Object message) + + throws IOException; + + @Adaptive({Constants.CODEC_KEY}) + + Object decode(Channel channel, ChannelBuffer buffer) + + throws IOException; + + enum DecodeResult { + + NEED_MORE_INPUT, SKIP_SOME_INPUT + + } + +} + + +这里需要关注的是 Codec2 接口被 @SPI 接口修饰了,表示该接口是一个扩展接口,同时其 encode() 方法和 decode() 方法都被 @Adaptive 注解修饰,也就会生成适配器类,其中会根据 URL 中的 codec 值确定具体的扩展实现类。 + +DecodeResult 这个枚举是在处理 TCP 传输时粘包和拆包使用的,之前简易版本 RPC 也处理过这种问题,例如,当前能读取到的数据不足以构成一个消息时,就会使用 NEED_MORE_INPUT 这个枚举。 + +接下来看Client 和 RemotingServer 两个接口,分别抽象了客户端和服务端,两者都继承了 Channel、Resetable 等接口,也就是说两者都具备了读写数据能力。 + + + +Client 和 Server 本身都是 Endpoint,只不过在语义上区分了请求和响应的职责,两者都具备发送的能力,所以都继承了 Endpoint 接口。Client 和 Server 的主要区别是 Client 只能关联一个 Channel,而 Server 可以接收多个 Client 发起的 Channel 连接。所以在 RemotingServer 接口中定义了查询 Channel 的相关方法,如下图所示: + + + +Dubbo 在 Client 和 Server 之上又封装了一层Transporter 接口,其具体定义如下: + +@SPI("netty") + +public interface Transporter { + + @Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY}) + + RemotingServer bind(URL url, ChannelHandler handler) + + throws RemotingException; + + @Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY}) + + Client connect(URL url, ChannelHandler handler) + + throws RemotingException; + +} + + +我们看到 Transporter 接口上有 @SPI 注解,它是一个扩展接口,默认使用“netty”这个扩展名,@Adaptive 注解的出现表示动态生成适配器类,会先后根据“server”“transporter”的值确定 RemotingServer 的扩展实现类,先后根据“client”“transporter”的值确定 Client 接口的扩展实现。 + +Transporter 接口的实现有哪些呢?如下图所示,针对每个支持的 NIO 库,都有一个 Transporter 接口实现,散落在各个 dubbo-remoting-* 实现模块中。 + + + +这些 Transporter 接口实现返回的 Client 和 RemotingServer 具体是什么呢?如下图所示,返回的是 NIO 库对应的 RemotingServer 实现和 Client 实现。 + + + + +相信看到这里,你应该已经发现 Transporter 这一层抽象出来的接口,与 Netty 的核心接口是非常相似的。那为什么要单独抽象出 Transporter层,而不是像简易版 RPC 框架那样,直接让上层使用 Netty 呢? + +其实这个问题的答案也呼之欲出了,Netty、Mina、Grizzly 这个 NIO 库对外接口和使用方式不一样,如果在上层直接依赖了 Netty 或是 Grizzly,就依赖了具体的 NIO 库实现,而不是依赖一个有传输能力的抽象,后续要切换实现的话,就需要修改依赖和接入的相关代码,非常容易改出 Bug。这也不符合设计模式中的开放-封闭原则。 + +有了 Transporter 层之后,我们可以通过 Dubbo SPI 修改使用的具体 Transporter 扩展实现,从而切换到不同的 Client 和 RemotingServer 实现,达到底层 NIO 库切换的目的,而且无须修改任何代码。即使有更先进的 NIO 库出现,我们也只需要开发相应的 dubbo-remoting-* 实现模块提供 Transporter、Client、RemotingServer 等核心接口的实现,即可接入,完全符合开放-封闭原则。 + +在最后,我们还要看一个类——Transporters,它不是一个接口,而是门面类,其中封装了 Transporter 对象的创建(通过 Dubbo SPI)以及 ChannelHandler 的处理,如下所示: + +public class Transporters { + + private Transporters() { + + // 省略bind()和connect()方法的重载 + + public static RemotingServer bind(URL url, + + ChannelHandler... handlers) throws RemotingException { + + ChannelHandler handler; + + if (handlers.length == 1) { + + handler = handlers[0]; + + } else { + + handler = new ChannelHandlerDispatcher(handlers); + + } + + return getTransporter().bind(url, handler); + + } + + public static Client connect(URL url, ChannelHandler... handlers) + + throws RemotingException { + + ChannelHandler handler; + + if (handlers == null || handlers.length == 0) { + + handler = new ChannelHandlerAdapter(); + + } else if (handlers.length == 1) { + + handler = handlers[0]; + + } else { // ChannelHandlerDispatcher + + handler = new ChannelHandlerDispatcher(handlers); + + } + + return getTransporter().connect(url, handler); + + } + + public static Transporter getTransporter() { + + // 自动生成Transporter适配器并加载 + + return ExtensionLoader.getExtensionLoader(Transporter.class) + + .getAdaptiveExtension(); + + } + +} + + +在创建 Client 和 RemotingServer 的时候,可以指定多个 ChannelHandler 绑定到 Channel 来处理其中传输的数据。Transporters.connect() 方法和 bind() 方法中,会将多个 ChannelHandler 封装成一个 ChannelHandlerDispatcher 对象。 + +ChannelHandlerDispatcher 也是 ChannelHandler 接口的实现类之一,维护了一个 CopyOnWriteArraySet 集合,它所有的 ChannelHandler 接口实现都会调用其中每个 ChannelHandler 元素的相应方法。另外,ChannelHandlerDispatcher 还提供了增删该 ChannelHandler 集合的相关方法。 + +到此为止,Dubbo Transport 层的核心接口就介绍完了,这里简单总结一下: + + +Endpoint 接口抽象了“端点”的概念,这是所有抽象接口的基础。 +上层使用方会通过 Transporters 门面类获取到 Transporter 的具体扩展实现,然后通过 Transporter 拿到相应的 Client 和 RemotingServer 实现,就可以建立(或接收)Channel 与远端进行交互了。 +无论是 Client 还是 RemotingServer,都会使用 ChannelHandler 处理 Channel 中传输的数据,其中负责编解码的 ChannelHandler 被抽象出为 Codec2 接口。 + + +整个架构如下图所示,与 Netty 的架构非常类似。 + + + +Transporter 层整体结构图 + +总结 + +本课时我们首先介绍了 dubbo-remoting 模块在 Dubbo 架构中的位置,以及 dubbo-remoting 模块的结构。接下来分析了 dubbo-remoting 模块中各个子模块之间的依赖关系,并重点介绍了 dubbo-remoting-api 子模块中各个包的核心功能。最后我们还深入分析了整个 Transport 层的核心接口,以及这些接口抽象出来的 Transporter 架构。 + +关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/18Buffer缓冲区:我们不生产数据,我们只是数据的搬运工.md b/专栏/Dubbo源码解读与实战-完/18Buffer缓冲区:我们不生产数据,我们只是数据的搬运工.md new file mode 100644 index 0000000..0a12b59 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/18Buffer缓冲区:我们不生产数据,我们只是数据的搬运工.md @@ -0,0 +1,282 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工 + Buffer 是一种字节容器,在 Netty 等 NIO 框架中都有类似的设计,例如,Java NIO 中的ByteBuffer、Netty4 中的 ByteBuf。Dubbo 抽象出了 ChannelBuffer 接口对底层 NIO 框架中的 Buffer 设计进行统一,其子类如下图所示: + + + +ChannelBuffer 继承关系图 + +下面我们就按照 ChannelBuffer 的继承结构,从顶层的 ChannelBuffer 接口开始,逐个向下介绍,直至最底层的各个实现类。 + +ChannelBuffer 接口 + +ChannelBuffer 接口的设计与 Netty4 中 ByteBuf 抽象类的设计基本一致,也有 readerIndex 和 writerIndex 指针的概念,如下所示,它们的核心方法也是如出一辙。 + + +getBytes()、setBytes() 方法:从参数指定的位置读、写当前 ChannelBuffer,不会修改 readerIndex 和 writerIndex 指针的位置。 +readBytes() 、writeBytes() 方法:也是读、写当前 ChannelBuffer,但是 readBytes() 方法会从 readerIndex 指针开始读取数据,并移动 readerIndex 指针;writeBytes() 方法会从 writerIndex 指针位置开始写入数据,并移动 writerIndex 指针。 +markReaderIndex()、markWriterIndex() 方法:记录当前 readerIndex 指针和 writerIndex 指针的位置,一般会和 resetReaderIndex()、resetWriterIndex() 方法配套使用。resetReaderIndex() 方法会将 readerIndex 指针重置到 markReaderIndex() 方法标记的位置,resetwriterIndex() 方法同理。 +capacity()、clear()、copy() 等辅助方法用来获取 ChannelBuffer 容量以及实现清理、拷贝数据的功能,这里不再赘述。 +factory() 方法:该方法返回创建 ChannelBuffer 的工厂对象,ChannelBufferFactory 中定义了多个 getBuffer() 方法重载来创建 ChannelBuffer,如下图所示,这些 ChannelBufferFactory的实现都是单例的。 + + + + +ChannelBufferFactory 继承关系图 + +AbstractChannelBuffer 抽象类实现了 ChannelBuffer 接口的大部分方法,其核心是维护了以下四个索引。 + + +readerIndex、writerIndex(int 类型):通过 readBytes() 方法及其重载读取数据时,会后移 readerIndex 索引;通过 writeBytes() 方法及其重载写入数据的时候,会后移 writerIndex 索引。 +markedReaderIndex、markedWriterIndex(int 类型):实现记录 readerIndex(writerIndex)以及回滚 readerIndex(writerIndex)的功能,前面我们已经介绍过markReaderIndex() 方法、resetReaderIndex() 方法以及 markWriterIndex() 方法、resetWriterIndex() 方法,你可以对比学习。 + + +AbstractChannelBuffer 中 readBytes() 和 writeBytes() 方法的各个重载最终会通过 getBytes() 方法和 setBytes() 方法实现数据的读写,这些方法在 AbstractChannelBuffer 子类中实现。下面以读写一个 byte 数组为例,进行介绍: + +public void readBytes(byte[] dst, int dstIndex, int length) { + + // 检测可读字节数是否足够 + + checkReadableBytes(length); + + // 将readerIndex之后的length个字节数读取到dst数组中dstIndex~ + + // dstIndex+length的位置 + + getBytes(readerIndex, dst, dstIndex, length); + + // 将readerIndex后移length个字节 + + readerIndex += length; + +} + +public void writeBytes(byte[] src, int srcIndex, int length) { + + // 将src数组中srcIndex~srcIndex+length的数据写入当前buffer中 + + // writerIndex~writerIndex+length的位置 + + setBytes(writerIndex, src, srcIndex, length); + + // 将writeIndex后移length个字节 + + writerIndex += length; + +} + + +Buffer 各实现类解析 + +了解了 ChannelBuffer 接口的核心方法以及 AbstractChannelBuffer 的公共实现之后,我们再来看 ChannelBuffer 的具体实现。 + +HeapChannelBuffer 是基于字节数组的 ChannelBuffer 实现,我们可以看到其中有一个 array(byte[]数组)字段,它就是 HeapChannelBuffer 存储数据的地方。HeapChannelBuffer 的 setBytes() 以及 getBytes() 方法实现是调用 System.arraycopy() 方法完成数组操作的,具体实现如下: + +public void setBytes(int index, byte[] src, int srcIndex, int length) { + + System.arraycopy(src, srcIndex, array, index, length); + +} + +public void getBytes(int index, byte[] dst, int dstIndex, int length) { + + System.arraycopy(array, index, dst, dstIndex, length); + +} + + +HeapChannelBuffer 对应的 ChannelBufferFactory 实现是 HeapChannelBufferFactory,其 getBuffer() 方法会通过 ChannelBuffers 这个工具类创建一个指定大小 HeapChannelBuffer 对象,下面简单介绍两个 getBuffer() 方法重载: + +@Override + +public ChannelBuffer getBuffer(int capacity) { + + // 新建一个HeapChannelBuffer,底层的会新建一个长度为capacity的byte数组 + + return ChannelBuffers.buffer(capacity); + +} + +@Override + +public ChannelBuffer getBuffer(byte[] array, int offset, int length) { + + // 新建一个HeapChannelBuffer,并且会拷贝array数组中offset~offset+lenght + + // 的数据到新HeapChannelBuffer中 + + return ChannelBuffers.wrappedBuffer(array, offset, length); + +} + + +其他 getBuffer() 方法重载这里就不再展示,你若感兴趣的话可以参考源码进行学习。 +DynamicChannelBuffer 可以认为是其他 ChannelBuffer 的装饰器,它可以为其他 ChannelBuffer 添加动态扩展容量的功能。DynamicChannelBuffer 中有两个核心字段: + + +buffer(ChannelBuffer 类型),是被修饰的 ChannelBuffer,默认为 HeapChannelBuffer。 +factory(ChannelBufferFactory 类型),用于创建被修饰的 HeapChannelBuffer 对象的 ChannelBufferFactory 工厂,默认为 HeapChannelBufferFactory。 + + +DynamicChannelBuffer 需要关注的是 ensureWritableBytes() 方法,该方法实现了动态扩容的功能,在每次写入数据之前,都需要调用该方法确定当前可用空间是否足够,调用位置如下图所示: + + + +ensureWritableBytes() 方法如果检测到底层 ChannelBuffer 对象的空间不足,则会创建一个新的 ChannelBuffer(空间扩大为原来的两倍),然后将原来 ChannelBuffer 中的数据拷贝到新 ChannelBuffer 中,最后将 buffer 字段指向新 ChannelBuffer 对象,完成整个扩容操作。ensureWritableBytes() 方法的具体实现如下: + +public void ensureWritableBytes(int minWritableBytes) { + + if (minWritableBytes <= writableBytes()) { + + return; + + } + + int newCapacity; + + if (capacity() == 0) { + + newCapacity = 1; + + } else { + + newCapacity = capacity(); + + } + + int minNewCapacity = writerIndex() + minWritableBytes; + + while (newCapacity < minNewCapacity) { + + newCapacity <<= 1; + + } + + ChannelBuffer newBuffer = factory().getBuffer(newCapacity); + + newBuffer.writeBytes(buffer, 0, writerIndex()); + + buffer = newBuffer; + +} + + +ByteBufferBackedChannelBuffer 是基于 Java NIO 中 ByteBuffer 的 ChannelBuffer 实现,其中的方法基本都是通过组合 ByteBuffer 的 API 实现的。下面以 getBytes() 方法和 setBytes() 方法的一个重载为例,进行分析: + +public void getBytes(int index, byte[] dst, int dstIndex, int length) { + + ByteBuffer data = buffer.duplicate(); + + try { + + // 移动ByteBuffer中的指针 + + data.limit(index + length).position(index); + + } catch (IllegalArgumentException e) { + + throw new IndexOutOfBoundsException(); + + } + + // 通过ByteBuffer的get()方法实现读取 + + data.get(dst, dstIndex, length); + +} + +public void setBytes(int index, byte[] src, int srcIndex, int length) { + + ByteBuffer data = buffer.duplicate(); + + // 移动ByteBuffer中的指针 + + data.limit(index + length).position(index); + + // 将数据写入底层的ByteBuffer中 + + data.put(src, srcIndex, length); + +} + + +ByteBufferBackedChannelBuffer 的其他方法实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +NettyBackedChannelBuffer 是基于 Netty 中 ByteBuf 的 ChannelBuffer 实现,Netty 中的 ByteBuf 内部维护了 readerIndex 和 writerIndex 以及 markedReaderIndex、markedWriterIndex 这四个索引,所以 NettyBackedChannelBuffer 没有再继承 AbstractChannelBuffer 抽象类,而是直接实现了 ChannelBuffer 接口。 + +NettyBackedChannelBuffer 对 ChannelBuffer 接口的实现都是调用底层封装的 Netty ByteBuf 实现的,这里就不再展开介绍,你若感兴趣的话也可以参考相关代码进行学习。 + +相关 Stream 以及门面类 + +在 ChannelBuffer 基础上,Dubbo 提供了一套输入输出流,如下图所示: + + + +ChannelBufferInputStream 底层封装了一个 ChannelBuffer,其实现 InputStream 接口的 read*() 方法全部都是从 ChannelBuffer 中读取数据。ChannelBufferInputStream 中还维护了一个 startIndex 和一个endIndex 索引,作为读取数据的起止位置。ChannelBufferOutputStream 与 ChannelBufferInputStream 类似,会向底层的 ChannelBuffer 写入数据,这里就不再展开,你若感兴趣的话可以参考源码进行分析。 + +最后要介绍 ChannelBuffers 这个门面类,下图展示了 ChannelBuffers 这个门面类的所有方法: + + + +对这些方法进行分类,可归纳出如下这些方法。 + + +dynamicBuffer() 方法:创建 DynamicChannelBuffer 对象,初始化大小由第一个参数指定,默认为 256。 +buffer() 方法:创建指定大小的 HeapChannelBuffer 对象。 +wrappedBuffer() 方法:将传入的 byte[] 数字封装成 HeapChannelBuffer 对象。 +directBuffer() 方法:创建 ByteBufferBackedChannelBuffer 对象,需要注意的是,底层的 ByteBuffer 使用的堆外内存,需要特别关注堆外内存的管理。 +equals() 方法:用于比较两个 ChannelBuffer 是否相同,其中会逐个比较两个 ChannelBuffer 中的前 7 个可读字节,只有两者完全一致,才算两个 ChannelBuffer 相同。其核心实现如下示例代码: + + +public static boolean equals(ChannelBuffer bufferA, ChannelBuffer bufferB) { + + final int aLen = bufferA.readableBytes(); + + if (aLen != bufferB.readableBytes()) { + + return false; // 比较两个ChannelBuffer的可读字节数 + + } + + final int byteCount = aLen & 7; // 只比较前7个字节 + + int aIndex = bufferA.readerIndex(); + + int bIndex = bufferB.readerIndex(); + + for (int i = byteCount; i > 0; i--) { + + if (bufferA.getByte(aIndex) != bufferB.getByte(bIndex)) { + + return false; // 前7个字节发现不同,则返回false + + } + + aIndex++; + + bIndex++; + + } + + return true; + +} + + + +compare() 方法:用于比较两个 ChannelBuffer 的大小,会逐个比较两个 ChannelBuffer 中的全部可读字节,具体实现与 equals() 方法类似,这里就不再重复讲述。 + + +总结 + +本课时重点介绍了 dubbo-remoting 模块 buffers 包中的核心实现。我们首先介绍了 ChannelBuffer 接口这一个顶层接口,了解了 ChannelBuffer 提供的核心功能和运作原理;接下来介绍了 ChannelBuffer 的多种实现,其中包括 HeapChannelBuffer、DynamicChannelBuffer、ByteBufferBackedChannelBuffer 等具体实现类,以及 AbstractChannelBuffer 这个抽象类;最后分析了 ChannelBufferFactory 使用到的 ChannelBuffers 工具类以及在 ChannelBuffer 之上封装的 InputStream 和 OutputStream 实现。 + +关于本课时,你若还有什么疑问或想法,欢迎你留言跟我分享。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/19Transporter层核心实现:编解码与线程模型一文打尽(上).md b/专栏/Dubbo源码解读与实战-完/19Transporter层核心实现:编解码与线程模型一文打尽(上).md new file mode 100644 index 0000000..57fd1bd --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/19Transporter层核心实现:编解码与线程模型一文打尽(上).md @@ -0,0 +1,567 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 Transporter 层核心实现:编解码与线程模型一文打尽(上) + 在第 17 课时中,我们详细介绍了 dubbo-remoting-api 模块中 Transporter 相关的核心抽象接口,本课时将继续介绍 dubbo-remoting-api 模块的其他内容。这里我们依旧从 Transporter 层的 RemotingServer、Client、Channel、ChannelHandler 等核心接口出发,介绍这些核心接口的实现。 + +AbstractPeer 抽象类 + +首先,我们来看 AbstractPeer 这个抽象类,它同时实现了 Endpoint 接口和 ChannelHandler 接口,如下图所示,它也是 AbstractChannel、AbstractEndpoint 抽象类的父类。 + + + +AbstractPeer 继承关系 + + +Netty 中也有 ChannelHandler、Channel 等接口,但无特殊说明的情况下,这里的接口指的都是 Dubbo 中定义的接口。如果涉及 Netty 中的接口,会进行特殊说明。 + + +AbstractPeer 中有四个字段:一个是表示该端点自身的 URL 类型的字段,还有两个 Boolean 类型的字段(closing 和 closed)用来记录当前端点的状态,这三个字段都与 Endpoint 接口相关;第四个字段指向了一个 ChannelHandler 对象,AbstractPeer 对 ChannelHandler 接口的所有实现,都是委托给了这个 ChannelHandler 对象。从上面的继承关系图中,我们可以得出这样一个结论:AbstractChannel、AbstractServer、AbstractClient 都是要关联一个 ChannelHandler 对象的。 + +AbstractEndpoint 抽象类 + +我们顺着上图的继承关系继续向下看,AbstractEndpoint 继承了 AbstractPeer 这个抽象类。AbstractEndpoint 中维护了一个 Codec2 对象(codec 字段)和两个超时时间(timeout 字段和 connectTimeout 字段),在 AbstractEndpoint 的构造方法中会根据传入的 URL 初始化这三个字段: + +public AbstractEndpoint(URL url, ChannelHandler handler) { + + super(url, handler); // 调用父类AbstractPeer的构造方法 + + // 根据URL中的codec参数值,确定此处具体的Codec2实现类 + + this.codec = getChannelCodec(url); + + // 根据URL中的timeout参数确定timeout字段的值,默认1000 + + this.timeout = url.getPositiveParameter(TIMEOUT_KEY, + + DEFAULT_TIMEOUT); + + // 根据URL中的connect.timeout参数确定connectTimeout字段的值,默认3000 + + this.connectTimeout = url.getPositiveParameter( + + Constants.CONNECT_TIMEOUT_KEY, Constants.DEFAULT_CONNECT_TIMEOUT); + +} + + +在[第 17 课时]介绍 Codec2 接口的时候提到它是一个 SPI 扩展点,这里的 AbstractEndpoint.getChannelCodec() 方法就是基于 Dubbo SPI 选择其扩展实现的,具体实现如下: + +protected static Codec2 getChannelCodec(URL url) { + + // 根据URL的codec参数获取扩展名 + + String codecName = url.getParameter(Constants.CODEC_KEY, "telnet"); + + if (ExtensionLoader.getExtensionLoader(Codec2.class).hasExtension(codecName)) { // 通过ExtensionLoader加载并实例化Codec2的具体扩展实现 + + return ExtensionLoader.getExtensionLoader(Codec2.class).getExtension(codecName); + + } else { // Codec2接口不存在相应的扩展名,就尝试从Codec这个老接口的扩展名中查找,目前Codec接口已经废弃了,所以省略这部分逻辑 + + } + +} + + +另外,AbstractEndpoint 还实现了 Resetable 接口(只有一个 reset() 方法需要实现),虽然 AbstractEndpoint 中的 reset() 方法比较长,但是逻辑非常简单,就是根据传入的 URL 参数重置 AbstractEndpoint 的三个字段。下面是重置 codec 字段的代码片段,还是调用 getChannelCodec() 方法实现的: + +public void reset(URL url) { + + // 检测当前AbstractEndpoint是否已经关闭(略) + + // 省略重置timeout、connectTimeout两个字段的逻辑 + + try { + + if (url.hasParameter(Constants.CODEC_KEY)) { + + this.codec = getChannelCodec(url); + + } + + } catch (Throwable t) { + + logger.error(t.getMessage(), t); + + } + +} + + +Server 继承路线分析 + +AbstractServer 和 AbstractClient 都实现了 AbstractEndpoint 抽象类,我们先来看 AbstractServer 的实现。AbstractServer 在继承了 AbstractEndpoint 的同时,还实现了 RemotingServer 接口,如下图所示: + + + +AbstractServer 继承关系图 + +AbstractServer 是对服务端的抽象,实现了服务端的公共逻辑。AbstractServer 的核心字段有下面几个。 + + +localAddress、bindAddress(InetSocketAddress 类型):分别对应该 Server 的本地地址和绑定的地址,都是从 URL 中的参数中获取。bindAddress 默认值与 localAddress 一致。 +accepts(int 类型):该 Server 能接收的最大连接数,从 URL 的 accepts 参数中获取,默认值为 0,表示没有限制。 +executorRepository(ExecutorRepository 类型):负责管理线程池,后面我们会深入介绍 ExecutorRepository 的具体实现。 +executor(ExecutorService 类型):当前 Server 关联的线程池,由上面的 ExecutorRepository 创建并管理。 + + +在 AbstractServer 的构造方法中会根据传入的 URL初始化上述字段,并调用 doOpen() 这个抽象方法完成该 Server 的启动,具体实现如下: + +public AbstractServer(URL url, ChannelHandler handler) { + + super(url, handler); // 调用父类的构造方法 + + // 根据传入的URL初始化localAddress和bindAddress + + localAddress = getUrl().toInetSocketAddress(); + + String bindIp = getUrl().getParameter(Constants.BIND_IP_KEY, getUrl().getHost()); + + int bindPort = getUrl().getParameter(Constants.BIND_PORT_KEY, getUrl().getPort()); + + if (url.getParameter(ANYHOST_KEY, false) || NetUtils.isInvalidLocalHost(bindIp)) { + + bindIp = ANYHOST_VALUE; + + } + + bindAddress = new InetSocketAddress(bindIp, bindPort); + + // 初始化accepts等字段 + + this.accepts = url.getParameter(ACCEPTS_KEY, DEFAULT_ACCEPTS); + + this.idleTimeout = url.getParameter(IDLE_TIMEOUT_KEY, DEFAULT_IDLE_TIMEOUT); + + try { + + doOpen(); // 调用doOpen()这个抽象方法,启动该Server + + } catch (Throwable t) { + + throw new RemotingException("..."); + + } + + // 获取该Server关联的线程池 + + executor = executorRepository.createExecutorIfAbsent(url); + +} + + +ExecutorRepository + +在继续分析 AbstractServer 的具体实现类之前,我们先来了解一下 ExecutorRepository 这个接口。 + +ExecutorRepository 负责创建并管理 Dubbo 中的线程池,该接口虽然是个 SPI 扩展点,但是只有一个默认实现—— DefaultExecutorRepository。在该默认实现中维护了一个 ConcurrentMap> 集合(data 字段)缓存已有的线程池,第一层 Key 值表示线程池属于 Provider 端还是 Consumer 端,第二层 Key 值表示线程池关联服务的端口。 + +DefaultExecutorRepository.createExecutorIfAbsent() 方法会根据 URL 参数创建相应的线程池并缓存在合适的位置,具体实现如下: + +public synchronized ExecutorService createExecutorIfAbsent(URL url) { + + // 根据URL中的side参数值决定第一层key + + String componentKey = EXECUTOR_SERVICE_COMPONENT_KEY; + + if (CONSUMER_SIDE.equalsIgnoreCase(url.getParameter(SIDE_KEY))) { + + componentKey = CONSUMER_SIDE; + + } + + Map executors = data.computeIfAbsent(componentKey, k -> new ConcurrentHashMap<>()); + + // 根据URL中的port值确定第二层key + + Integer portKey = url.getPort(); + + ExecutorService executor = executors.computeIfAbsent(portKey, k -> createExecutor(url)); + + // 如果缓存中相应的线程池已关闭,则同样需要调用createExecutor()方法 + + // 创建新的线程池,并替换掉缓存中已关闭的线程持,这里省略这段逻辑 + + return executor; + +} + + +在 createExecutor() 方法中,会通过 Dubbo SPI 查找 ThreadPool 接口的扩展实现,并调用其 getExecutor() 方法创建线程池。ThreadPool 接口被 @SPI 注解修饰,默认使用 FixedThreadPool 实现,但是 ThreadPool 接口中的 getExecutor() 方法被 @Adaptive 注解修饰,动态生成的适配器类会优先根据 URL 中的 threadpool 参数选择 ThreadPool 的扩展实现。ThreadPool 接口的实现类如下图所示: + + + +ThreadPool 继承关系图 + +不同实现会根据 URL 参数创建不同特性的线程池,这里以CacheThreadPool为例进行分析: + +public Executor getExecutor(URL url) { + + String name = url.getParameter(THREAD_NAME_KEY, DEFAULT_THREAD_NAME); + + // 核心线程数量 + + int cores = url.getParameter(CORE_THREADS_KEY, DEFAULT_CORE_THREADS); + + // 最大线程数量 + + int threads = url.getParameter(THREADS_KEY, Integer.MAX_VALUE); + + // 缓冲队列的最大长度 + + int queues = url.getParameter(QUEUES_KEY, DEFAULT_QUEUES); + + // 非核心线程的最大空闲时长,当非核心线程空闲时间超过该值时,会被回收 + + int alive = url.getParameter(ALIVE_KEY, DEFAULT_ALIVE); + + // 下面就是依赖JDK的ThreadPoolExecutor创建指定特性的线程池并返回 + + return new ThreadPoolExecutor(cores, threads, alive, TimeUnit.MILLISECONDS, + + queues == 0 ? new SynchronousQueue() : + + (queues < 0 ? new LinkedBlockingQueue() + + : new LinkedBlockingQueue(queues)), + + new NamedInternalThreadFactory(name, true), new AbortPolicyWithReport(name, url)); + +} + + +再简单说一下其他 ThreadPool 实现创建的线程池。 + + +LimitedThreadPool:与 CacheThreadPool 一样,可以指定核心线程数、最大线程数以及缓冲队列长度。区别在于,LimitedThreadPool 创建的线程池的非核心线程不会被回收。 +FixedThreadPool:核心线程数和最大线程数一致,且不会被回收。 + + +上述三种类型的线程池都是基于 JDK ThreadPoolExecutor 线程池,在核心线程全部被占用的时候,会优先将任务放到缓冲队列中缓存,在缓冲队列满了之后,才会尝试创建新线程来处理任务。 + +EagerThreadPool 创建的线程池是 EagerThreadPoolExecutor(继承了 JDK 提供的 ThreadPoolExecutor),使用的队列是 TaskQueue(继承了LinkedBlockingQueue)。该线程池与 ThreadPoolExecutor 不同的是:在线程数没有达到最大线程数的前提下,EagerThreadPoolExecutor 会优先创建线程来执行任务,而不是放到缓冲队列中;当线程数达到最大值时,EagerThreadPoolExecutor 会将任务放入缓冲队列,等待空闲线程。 + +EagerThreadPoolExecutor 覆盖了 ThreadPoolExecutor 中的两个方法:execute() 方法和 afterExecute() 方法,具体实现如下,我们可以看到其中维护了一个 submittedTaskCount 字段(AtomicInteger 类型),用来记录当前在线程池中的任务总数(正在线程中执行的任务数+队列中等待的任务数)。 + +public void execute(Runnable command) { + + // 任务提交之前,递增submittedTaskCount + + submittedTaskCount.incrementAndGet(); + + try { + + super.execute(command); // 提交任务 + + } catch (RejectedExecutionException rx) { + + final TaskQueue queue = (TaskQueue) super.getQueue(); + + try { + + // 任务被拒绝之后,会尝试再次放入队列中缓存,等待空闲线程执行 + + if (!queue.retryOffer(command, 0, TimeUnit.MILLISECONDS)) { + + // 再次入队被拒绝,则队列已满,无法执行任务 + + // 递减submittedTaskCount + + submittedTaskCount.decrementAndGet(); + + throw new RejectedExecutionException("Queue capacity is full.", rx); + + } + + } catch (InterruptedException x) { + + // 再次入队列异常,递减submittedTaskCount + + submittedTaskCount.decrementAndGet(); + + throw new RejectedExecutionException(x); + + } + + } catch (Throwable t) { // 任务提交异常,递减submittedTaskCount + + submittedTaskCount.decrementAndGet(); + + throw t; + + } + +} + +protected void afterExecute(Runnable r, Throwable t) { + + // 任务指定结束,递减submittedTaskCount + + submittedTaskCount.decrementAndGet(); + +} + + +看到这里,你可能会有些疑惑:没有看到优先创建线程执行任务的逻辑啊。其实重点在关联的 TaskQueue 实现中,它覆盖了 LinkedBlockingQueue.offer() 方法,会判断线程池的 submittedTaskCount 值是否已经达到最大线程数,如果未超过,则会返回 false,迫使线程池创建新线程来执行任务。示例代码如下: + +public boolean offer(Runnable runnable) { + + // 获取当前线程池中的活跃线程数 + + int currentPoolThreadSize = executor.getPoolSize(); + + // 当前有线程空闲,直接将任务提交到队列中,空闲线程会直接从中获取任务执行 + + if (executor.getSubmittedTaskCount() < currentPoolThreadSize) { + + return super.offer(runnable); + + } + + // 当前没有空闲线程,但是还可以创建新线程,则返回false,迫使线程池创建 + + // 新线程来执行任务 + + if (currentPoolThreadSize < executor.getMaximumPoolSize()) { + + return false; + + } + + // 当前线程数已经达到上限,只能放到队列中缓存了 + + return super.offer(runnable); + +} + + +线程池最后一个相关的小细节是 AbortPolicyWithReport ,它继承了 ThreadPoolExecutor.AbortPolicy,覆盖的 rejectedExecution 方法中会输出包含线程池相关信息的 WARN 级别日志,然后进行 dumpJStack() 方法,最后才会抛出RejectedExecutionException 异常。 + +我们回到 Server 的继承线上,下面来看基于 Netty 4 实现的 NettyServer,它继承了前文介绍的 AbstractServer,实现了 doOpen() 方法和 doClose() 方法。这里重点看 doOpen() 方法,如下所示: + +protected void doOpen() throws Throwable { + + // 创建ServerBootstrap + + bootstrap = new ServerBootstrap(); + + // 创建boss EventLoopGroup + + bossGroup = NettyEventLoopFactory.eventLoopGroup(1, "NettyServerBoss"); + + // 创建worker EventLoopGroup + + workerGroup = NettyEventLoopFactory.eventLoopGroup( + + getUrl().getPositiveParameter(IO_THREADS_KEY, Constants.DEFAULT_IO_THREADS), + + "NettyServerWorker"); + + // 创建NettyServerHandler,它是一个Netty中的ChannelHandler实现, + + // 不是Dubbo Remoting层的ChannelHandler接口的实现 + + final NettyServerHandler nettyServerHandler = new NettyServerHandler(getUrl(), this); + + // 获取当前NettyServer创建的所有Channel,这里的channels集合中的 + + // Channel不是Netty中的Channel对象,而是Dubbo Remoting层的Channel对象 + + channels = nettyServerHandler.getChannels(); + + // 初始化ServerBootstrap,指定boss和worker EventLoopGroup + + bootstrap.group(bossGroup, workerGroup) + + .channel(NettyEventLoopFactory.serverSocketChannelClass()) + + .option(ChannelOption.SO_REUSEADDR, Boolean.TRUE) + + .childOption(ChannelOption.TCP_NODELAY, Boolean.TRUE) + + .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + + .childHandler(new ChannelInitializer() { + + @Override + + protected void initChannel(SocketChannel ch) throws Exception { + + // 连接空闲超时时间 + + int idleTimeout = UrlUtils.getIdleTimeout(getUrl()); + + // NettyCodecAdapter中会创建Decoder和Encoder + + NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyServer.this); + + ch.pipeline() + + // 注册Decoder和Encoder + + .addLast("decoder", adapter.getDecoder()) + + .addLast("encoder", adapter.getEncoder()) + + // 注册IdleStateHandler + + .addLast("server-idle-handler", new IdleStateHandler(0, 0, idleTimeout, MILLISECONDS)) + + // 注册NettyServerHandler + + .addLast("handler", nettyServerHandler); + + } + + }); + + // 绑定指定的地址和端口 + + ChannelFuture channelFuture = bootstrap.bind(getBindAddress()); + + channelFuture.syncUninterruptibly(); // 等待bind操作完成 + + channel = channelFuture.channel(); + +} + + +看完 NettyServer 实现的 doOpen() 方法之后,你会发现它和简易版 RPC 框架中启动一个 Netty 的 Server 端基本流程类似:初始化 ServerBootstrap、创建 Boss EventLoopGroup 和 Worker EventLoopGroup、创建 ChannelInitializer 指定如何初始化 Channel 上的 ChannelHandler 等一系列 Netty 使用的标准化流程。 + +其实在 Transporter 这一层看,功能的不同其实就是注册在 Channel 上的 ChannelHandler 不同,通过 doOpen() 方法得到的 Server 端结构如下: + + + +NettyServer 模型 + +核心 ChannelHandler + +下面我们来逐个看看这四个 ChannelHandler 的核心功能。 + +首先是decoder 和 encoder,它们都是 NettyCodecAdapter 的内部类,如下图所示,分别继承了 Netty 中的 ByteToMessageDecoder 和 MessageToByteEncoder: + + + +还记得 AbstractEndpoint 抽象类中的 codec 字段(Codec2 类型)吗?InternalDecoder 和 InternalEncoder 会将真正的编解码功能委托给 NettyServer 关联的这个 Codec2 对象去处理,这里以 InternalDecoder 为例进行分析: + +private class InternalDecoder extends ByteToMessageDecoder { + + protected void decode(ChannelHandlerContext ctx, ByteBuf input, List out) throws Exception { + + // 将ByteBuf封装成统一的ChannelBuffer + + ChannelBuffer message = new NettyBackedChannelBuffer(input); + + // 拿到关联的Channel + + NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler); + + do { + + // 记录当前readerIndex的位置 + + int saveReaderIndex = message.readerIndex(); + + // 委托给Codec2进行解码 + + Object msg = codec.decode(channel, message); + + // 当前接收到的数据不足一个消息的长度,会返回NEED_MORE_INPUT, + + // 这里会重置readerIndex,继续等待接收更多的数据 + + if (msg == Codec2.DecodeResult.NEED_MORE_INPUT) { + + message.readerIndex(saveReaderIndex); + + break; + + } else { + + if (msg != null) { // 将读取到的消息传递给后面的Handler处理 + + out.add(msg); + + } + + } + + } while (message.readable()); + + } + +} + + +你是不是发现 InternalDecoder 的实现与我们简易版 RPC 的 Decoder 实现非常相似呢? + +InternalEncoder 的具体实现就不再展开讲解了,你若感兴趣可以翻看源码进行研究和分析。 + +接下来是IdleStateHandler,它是 Netty 提供的一个工具型 ChannelHandler,用于定时心跳请求的功能或是自动关闭长时间空闲连接的功能。它的原理到底是怎样的呢?在 IdleStateHandler 中通过 lastReadTime、lastWriteTime 等几个字段,记录了最近一次读/写事件的时间,IdleStateHandler 初始化的时候,会创建一个定时任务,定时检测当前时间与最后一次读/写时间的差值。如果超过我们设置的阈值(也就是上面 NettyServer 中设置的 idleTimeout),就会触发 IdleStateEvent 事件,并传递给后续的 ChannelHandler 进行处理。后续 ChannelHandler 的 userEventTriggered() 方法会根据接收到的 IdleStateEvent 事件,决定是关闭长时间空闲的连接,还是发送心跳探活。 + +最后来看NettyServerHandler,它继承了 ChannelDuplexHandler,这是 Netty 提供的一个同时处理 Inbound 数据和 Outbound 数据的 ChannelHandler,从下面的继承图就能看出来。 + + + +NettyServerHandler 继承关系图 + +在 NettyServerHandler 中有 channels 和 handler 两个核心字段。 + + +channels(Map集合):记录了当前 Server 创建的所有 Channel,从下图中可以看到,连接创建(触发 channelActive() 方法)、连接断开(触发 channelInactive()方法)会操作 channels 集合进行相应的增删。 + + + + + +handler(ChannelHandler 类型):NettyServerHandler 内几乎所有方法都会触发该 Dubbo ChannelHandler 对象(如下图)。 + + + + +这里以 write() 方法为例进行简单分析: + +public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + + super.write(ctx, msg, promise); // 将发送的数据继续向下传递 + + // 并不影响消息的继续发送,只是触发sent()方法进行相关的处理,这也是方法 + + // 名称是动词过去式的原因,可以仔细体会一下。其他方法可能没有那么明显, + + // 这里以write()方法为例进行说明 + + NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler); + + handler.sent(channel, msg); + +} + + +在 NettyServer 创建 NettyServerHandler 的时候,可以看到下面的这行代码: + +final NettyServerHandler nettyServerHandler = new NettyServerHandler(getUrl(), this); + + +其中第二个参数传入的是 NettyServer 这个对象,你可以追溯一下 NettyServer 的继承结构,会发现它的最顶层父类 AbstractPeer 实现了 ChannelHandler,并且将所有的方法委托给其中封装的 ChannelHandler 对象,如下图所示: + + + +也就是说,NettyServerHandler 会将数据委托给这个 ChannelHandler。 + +到此为止,Server 这条继承线就介绍完了。你可以回顾一下,从 AbstractPeer 开始往下,一路继承下来,NettyServer 拥有了 Endpoint、ChannelHandler 以及RemotingServer多个接口的能力,关联了一个 ChannelHandler 对象以及 Codec2 对象,并最终将数据委托给这两个对象进行处理。所以,上层调用方只需要实现 ChannelHandler 和 Codec2 这两个接口就可以了。 + + + +总结 + +本课时重点介绍了 Dubbo Transporter 层中 Server 相关的实现。 + +首先,我们介绍了 AbstractPeer 这个最顶层的抽象类,了解了 Server、Client 和 Channel 的公共属性。接下来,介绍了 AbstractEndpoint 抽象类,它提供了编解码等 Server 和 Client 所需的公共能力。最后,我们深入分析了 AbstractServer 抽象类以及基于 Netty 4 实现的 NettyServer,同时,还深入剖析了涉及的各种组件,例如,ExecutorRepository、NettyServerHandler 等。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/20Transporter层核心实现:编解码与线程模型一文打尽(下).md b/专栏/Dubbo源码解读与实战-完/20Transporter层核心实现:编解码与线程模型一文打尽(下).md new file mode 100644 index 0000000..a43903d --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/20Transporter层核心实现:编解码与线程模型一文打尽(下).md @@ -0,0 +1,571 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 Transporter 层核心实现:编解码与线程模型一文打尽(下) + 在上一课时中,我们深入分析了 Transporter 层中 Server 相关的核心抽象类以及基于 Netty 4 的实现类。本课时我们继续分析 Transporter 层中剩余的核心接口实现,主要涉及 Client 接口、Channel 接口、ChannelHandler 接口,以及相关的关键组件。 + +Client 继承路线分析 + +在上一课时分析 AbstractEndpoint 的时候可以看到,除了 AbstractServer 这一条继承线之外,还有 AbstractClient 这条继承线,它是对客户端的抽象。AbstractClient 中的核心字段有如下几个。 + + +connectLock(Lock 类型):在 Client 底层进行连接、断开、重连等操作时,需要获取该锁进行同步。 +needReconnect(Boolean 类型):在发送数据之前,会检查 Client 底层的连接是否断开,如果断开了,则会根据 needReconnect 字段,决定是否重连。 +executor(ExecutorService 类型):当前 Client 关联的线程池,线程池的具体内容在上一课时已经详细介绍过了,这里不再赘述。 + + +在 AbstractClient 的构造方法中,会解析 URL 初始化 needReconnect 字段和 executor字段,如下示例代码: + +public AbstractClient(URL url, ChannelHandler handler) throws RemotingException { + + super(url, handler); // 调用父类的构造方法 + + // 解析URL,初始化needReconnect值 + + needReconnect = url.getParameter("send.reconnect", false); + + initExecutor(url); // 解析URL,初始化executor + + doOpen(); // 初始化底层的NIO库的相关组件 + + // 创建底层连接 + + connect(); // 省略异常处理的逻辑 + +} + + +与 AbstractServer 类似,AbstractClient 定义了 doOpen()、doClose()、doConnect()和doDisConnect() 四个抽象方法给子类实现。 + +下面来看基于 Netty 4 实现的 NettyClient,它继承了 AbstractClient 抽象类,实现了上述四个 do*() 抽象方法,我们这里重点关注 doOpen() 方法和 doConnect() 方法。在 NettyClient 的 doOpen() 方法中会通过 Bootstrap 构建客户端,其中会完成连接超时时间、keepalive 等参数的设置,以及 ChannelHandler 的创建和注册,具体实现如下所示: + +protected void doOpen() throws Throwable { + + // 创建NettyClientHandler + + final NettyClientHandler nettyClientHandler = new NettyClientHandler(getUrl(), this); + + bootstrap = new Bootstrap(); // 创建Bootstrap + + bootstrap.group(NIO_EVENT_LOOP_GROUP) + + .option(ChannelOption.SO_KEEPALIVE, true) + + .option(ChannelOption.TCP_NODELAY, true) + + .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + + .channel(socketChannelClass()); + + // 设置连接超时时间,这里使用到AbstractEndpoint中的connectTimeout字段 + + bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, Math.max(3000, getConnectTimeout())); + + bootstrap.handler(new ChannelInitializer() { + + protected void initChannel(SocketChannel ch) throws Exception { + + // 心跳请求的时间间隔 + + int heartbeatInterval = UrlUtils.getHeartbeat(getUrl()); + + // 通过NettyCodecAdapter创建Netty中的编解码器,这里不再重复介绍 + + NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyClient.this); + + // 注册ChannelHandler + + ch.pipeline().addLast("decoder", adapter.getDecoder()) + + .addLast("encoder", adapter.getEncoder()) + + .addLast("client-idle-handler", new IdleStateHandler(heartbeatInterval, 0, 0, MILLISECONDS)) + + .addLast("handler", nettyClientHandler); + + // 如果需要Socks5Proxy,需要添加Socks5ProxyHandler(略) + + } + + }); + +} + + +得到的 NettyClient 结构如下图所示: + + + +NettyClient 结构图 + +NettyClientHandler 的实现方法与上一课时介绍的 NettyServerHandler 类似,同样是实现了 Netty 中的 ChannelDuplexHandler,其中会将所有方法委托给 NettyClient 关联的 ChannelHandler 对象进行处理。两者在 userEventTriggered() 方法的实现上有所不同,NettyServerHandler 在收到 IdleStateEvent 事件时会断开连接,而 NettyClientHandler 则会发送心跳消息,具体实现如下: + +public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + + if (evt instanceof IdleStateEvent) { + + NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler); + + Request req = new Request(); + + req.setVersion(Version.getProtocolVersion()); + + req.setTwoWay(true); + + req.setEvent(HEARTBEAT_EVENT); // 发送心跳请求 + + channel.send(req); + + } else { + + super.userEventTriggered(ctx, evt); + + } + +} + + +Channel 继承线分析 + +除了上一课时介绍的 AbstractEndpoint 之外,AbstractChannel 也继承了 AbstractPeer 这个抽象类,同时还继承了 Channel 接口。AbstractChannel 实现非常简单,只是在 send() 方法中检测了底层连接的状态,没有实现具体的发送消息的逻辑。 + +这里我们依然以基于 Netty 4 的实现—— NettyChannel 为例,分析它对 AbstractChannel 的实现。NettyChannel 中的核心字段有如下几个。 + + +channel(Channel类型):Netty 框架中的 Channel,与当前的 Dubbo Channel 对象一一对应。 +attributes(Map类型):当前 Channel 中附加属性,都会记录到该 Map 中。NettyChannel 中提供的 getAttribute()、hasAttribute()、setAttribute() 等方法,都是操作该集合。 +active(AtomicBoolean):用于标识当前 Channel 是否可用。 + + +另外,在 NettyChannel 中还有一个静态的 Map 集合(CHANNEL_MAP 字段),用来缓存当前 JVM 中 Netty 框架 Channel 与 Dubbo Channel 之间的映射关系。从下图的调用关系中可以看到,NettyChannel 提供了读写 CHANNEL_MAP 集合的方法: + + + +NettyChannel 中还有一个要介绍的是 send() 方法,它会通过底层关联的 Netty 框架 Channel,将数据发送到对端。其中,可以通过第二个参数指定是否等待发送操作结束,具体实现如下: + +public void send(Object message, boolean sent) throws RemotingException { + + // 调用AbstractChannel的send()方法检测连接是否可用 + + super.send(message, sent); + + boolean success = true; + + int timeout = 0; + + // 依赖Netty框架的Channel发送数据 + + ChannelFuture future = channel.writeAndFlush(message); + + if (sent) { // 等待发送结束,有超时时间 + + timeout = getUrl().getPositiveParameter(TIMEOUT_KEY, DEFAULT_TIMEOUT); + + success = future.await(timeout); + + } + + Throwable cause = future.cause(); + + if (cause != null) { + + throw cause; + + } + + // 出现异常会调用removeChannelIfDisconnected()方法,在底层连接断开时, + + // 会清理CHANNEL_MAP缓存(略) + +} + + +ChannelHandler 继承线分析 + +前文介绍的 AbstractServer、AbstractClient 以及 Channel 实现,都是通过 AbstractPeer 实现了 ChannelHandler 接口,但只是做了一层简单的委托(也可以说成是装饰器),将全部方法委托给了其底层关联的 ChannelHandler 对象。 + +这里我们就深入分析 ChannelHandler 的其他实现类,涉及的实现类如下所示: + + + +ChannelHandler 继承关系图 + +其中ChannelHandlerDispatcher在[第 17 课时]已经介绍过了,它负责将多个 ChannelHandler 对象聚合成一个 ChannelHandler 对象。 + +ChannelHandlerAdapter是 ChannelHandler 的一个空实现,TelnetHandlerAdapter 继承了它并实现了 TelnetHandler 接口。至于Dubbo 对 Telnet 的支持,我们会在后面的课时中单独介绍,这里就先不展开分析了。 + +从名字上看,ChannelHandlerDelegate接口是对另一个 ChannelHandler 对象的封装,它的两个实现类 AbstractChannelHandlerDelegate 和 WrappedChannelHandler 中也仅仅是封装了另一个 ChannelHandler 对象。 + +其中,AbstractChannelHandlerDelegate有三个实现类,都比较简单,我们来逐个讲解。 + + +MultiMessageHandler:专门处理 MultiMessage 的 ChannelHandler 实现。MultiMessage 是 Exchange 层的一种消息类型,它其中封装了多个消息。在 MultiMessageHandler 收到 MultiMessage 消息的时候,received() 方法会遍历其中的所有消息,并交给底层的 ChannelHandler 对象进行处理。 +DecodeHandler:专门处理 Decodeable 的 ChannelHandler 实现。实现了 Decodeable 接口的类都会提供了一个 decode() 方法实现对自身的解码,DecodeHandler.received() 方法就是通过该方法得到解码后的消息,然后传递给底层的 ChannelHandler 对象继续处理。 +HeartbeatHandler:专门处理心跳消息的 ChannelHandler 实现。在 HeartbeatHandler.received() 方法接收心跳请求的时候,会生成相应的心跳响应并返回;在收到心跳响应的时候,会打印相应的日志;在收到其他类型的消息时,会传递给底层的 ChannelHandler 对象进行处理。下面是其核心实现: + + +public void received(Channel channel, Object message) throws RemotingException { + + setReadTimestamp(channel); // 记录最近的读写事件时间戳 + + if (isHeartbeatRequest(message)) { // 收到心跳请求 + + Request req = (Request) message; + + if (req.isTwoWay()) { // 返回心跳响应,注意,携带请求的ID + + Response res = new Response(req.getId(), req.getVersion()); + + res.setEvent(HEARTBEAT_EVENT); + + channel.send(res); + + return; + + } + + if (isHeartbeatResponse(message)) { // 收到心跳响应 + + // 打印日志(略) + + return; + + } + + handler.received(channel, message); + +} + + +另外,我们可以看到,在 received() 和 send() 方法中,HeartbeatHandler 会将最近一次的读写时间作为附加属性记录到 Channel 中。 + +通过上述介绍,我们发现 AbstractChannelHandlerDelegate 下的三个实现,其实都是在原有 ChannelHandler 的基础上添加了一些增强功能,这是典型的装饰器模式的应用。 + +Dispatcher 与 ChannelHandler + +接下来,我们介绍 ChannelHandlerDelegate 接口的另一条继承线——WrappedChannelHandler,其子类主要是决定了 Dubbo 以何种线程模型处理收到的事件和消息,就是所谓的“消息派发机制”,与前面介绍的 ThreadPool 有紧密的联系。 + + + +WrappedChannelHandler 继承关系图 + +从上图中我们可以看到,每个 WrappedChannelHandler 实现类的对象都由一个相应的 Dispatcher 实现类创建,下面是 Dispatcher 接口的定义: + +@SPI(AllDispatcher.NAME) // 默认扩展名是all + +public interface Dispatcher { + + // 通过URL中的参数可以指定扩展名,覆盖默认扩展名 + + @Adaptive({"dispatcher", "dispather", "channel.handler"}) + + ChannelHandler dispatch(ChannelHandler handler, URL url); + +} + + +AllDispatcher 创建的是 AllChannelHandler 对象,它会将所有网络事件以及消息交给关联的线程池进行处理。AllChannelHandler覆盖了 WrappedChannelHandler 中除了 sent() 方法之外的其他网络事件处理方法,将调用其底层的 ChannelHandler 的逻辑放到关联的线程池中执行。 + +我们先来看 connect() 方法,其中会将CONNECTED 事件的处理封装成ChannelEventRunnable提交到线程池中执行,具体实现如下: + +public void connected(Channel channel) throws RemotingException { + + ExecutorService executor = getExecutorService(); // 获取公共线程池 + + // 将CONNECTED事件的处理封装成ChannelEventRunnable提交到线程池中执行 + + executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.CONNECTED)); + + // 省略异常处理的逻辑 + +} + + +这里的 getExecutorService() 方法会按照当前端点(Server/Client)的 URL 从 ExecutorRepository 中获取相应的公共线程池。 + +disconnected()方法处理连接断开事件,caught() 方法处理异常事件,它们也是按照上述方式实现的,这里不再展开赘述。 + +received() 方法会在当前端点收到数据的时候被调用,具体执行流程是先由 IO 线程(也就是 Netty 中的 EventLoopGroup)从二进制流中解码出请求,然后调用 AllChannelHandler 的 received() 方法,其中会将请求提交给线程池执行,执行完后调用 sent()方法,向对端写回响应结果。received() 方法的具体实现如下: + +public void received(Channel channel, Object message) throws RemotingException { + + // 获取线程池 + + ExecutorService executor = getPreferredExecutorService(message); + + try { + + // 将消息封装成ChannelEventRunnable任务,提交到线程池中执行 + + executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message)); + + } catch (Throwable t) { + + // 如果线程池满了,请求会被拒绝,这里会根据请求配置决定是否返回一个说明性的响应 + + if(message instanceof Request && t instanceof RejectedExecutionException){ + + sendFeedback(channel, (Request) message, t); + + return; + + } + + throw new ExecutionException("..."); + + } + +} + + +getPreferredExecutorService() 方法对响应做了特殊处理:如果请求在发送的时候指定了关联的线程池,在收到对应的响应消息的时候,会优先根据请求的 ID 查找请求关联的线程池处理响应。 + +public ExecutorService getPreferredExecutorService(Object msg) { + + if (msg instanceof Response) { + + Response response = (Response) msg; + + DefaultFuture responseFuture = DefaultFuture.getFuture(response.getId()); // 获取请求关联的DefaultFuture + + if (responseFuture == null) { + + return getSharedExecutorService(); + + } else { // 如果请求关联了线程池,则会获取相关的线程来处理响应 + + ExecutorService executor = responseFuture.getExecutor(); + + if (executor == null || executor.isShutdown()) { + + executor = getSharedExecutorService(); + + } + + return executor; + + } + + } else { // 如果是请求消息,则直接使用公共的线程池处理 + + return getSharedExecutorService(); + + } + +} + + +这里涉及了 Request 和 Response 的概念,是 Exchange 层的概念,在后面会展开介绍,这里你只需要知道它们是不同的消息类型即可。 + +注意,AllChannelHandler 并没有覆盖父类的 sent() 方法,也就是说,发送消息是直接在当前线程调用 sent() 方法完成的。 + +下面我们来看剩余的 WrappedChannelHandler 的实现。ExecutionChannelHandler(由 ExecutionDispatcher 创建)只会将请求消息派发到线程池进行处理,也就是只重写了 received() 方法。对于响应消息以及其他网络事件(例如,连接建立事件、连接断开事件、心跳消息等),ExecutionChannelHandler 会直接在 IO 线程中进行处理。 + +DirectChannelHandler 实现(由 DirectDispatcher 创建)会在 IO 线程中处理所有的消息和网络事件。 + +MessageOnlyChannelHandler 实现(由 MessageOnlyDispatcher 创建)会将所有收到的消息提交到线程池处理,其他网络事件则是由 IO 线程直接处理。 + +ConnectionOrderedChannelHandler 实现(由 ConnectionOrderedDispatcher 创建)会将收到的消息交给线程池进行处理,对于连接建立以及断开事件,会提交到一个独立的线程池并排队进行处理。在 ConnectionOrderedChannelHandler 的构造方法中,会初始化一个线程池,该线程池的队列长度是固定的: + +public ConnectionOrderedChannelHandler(ChannelHandler handler, URL url) { + + super(handler, url); + + String threadName = url.getParameter(THREAD_NAME_KEY, DEFAULT_THREAD_NAME); + + // 注意,该线程池只有一个线程,队列的长度也是固定的, + + // 由URL中的connect.queue.capacity参数指定 + + connectionExecutor = new ThreadPoolExecutor(1, 1, + + 0L, TimeUnit.MILLISECONDS, + + new LinkedBlockingQueue(url.getPositiveParameter(CONNECT_QUEUE_CAPACITY, Integer.MAX_VALUE)), + + new NamedThreadFactory(threadName, true), + + new AbortPolicyWithReport(threadName, url) + + ); + + queuewarninglimit = url.getParameter(CONNECT_QUEUE_WARNING_SIZE, DEFAULT_CONNECT_QUEUE_WARNING_SIZE); + +} + + +在 ConnectionOrderedChannelHandler 的 connected() 方法和 disconnected() 方法实现中,会将连接建立和断开事件交给上述 connectionExecutor 线程池排队处理。 + +在上面介绍 WrappedChannelHandler 各个实现的时候,我们会看到其中有针对 ThreadlessExecutor 这种线程池类型的特殊处理,例如,ExecutionChannelHandler.received() 方法中就有如下的分支逻辑: + +public void received(Channel channel, Object message) throws RemotingException { + + // 获取线程池(请求绑定的线程池或是公共线程池) + + ExecutorService executor = getPreferredExecutorService(message); + + if (message instanceof Request) { // 请求消息直接提交给线程池处理 + + executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message)); + + } else if (executor instanceof ThreadlessExecutor) { + + // 针对ThreadlessExecutor这种线程池类型的特殊处理 + + executor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message)); + + } else { + + handler.received(channel, message); + + } + +} + + +ThreadlessExecutor 优化 + +ThreadlessExecutor 是一种特殊类型的线程池,与其他正常的线程池最主要的区别是:ThreadlessExecutor 内部不管理任何线程。 + +我们可以调用 ThreadlessExecutor 的execute() 方法,将任务提交给这个线程池,但是这些提交的任务不会被调度到任何线程执行,而是存储在阻塞队列中,只有当其他线程调用 ThreadlessExecutor.waitAndDrain() 方法时才会真正执行。也说就是,执行任务的与调用 waitAndDrain() 方法的是同一个线程。 + +那为什么会有 ThreadlessExecutor 这个实现呢?这主要是因为在 Dubbo 2.7.5 版本之前,在 WrappedChannelHandler 中会为每个连接启动一个线程池。 + + +老版本中没有 ExecutorRepository 的概念,不会根据 URL 复用同一个线程池,而是通过 SPI 找到 ThreadPool 实现创建新线程池。 + + +此时,Dubbo Consumer 同步请求的线程模型如下图所示: + + + +Dubbo Consumer 同步请求线程模型 + +从图中我们可以看到下面的请求-响应流程: + + +业务线程发出请求之后,拿到一个 Future 实例。 +业务线程紧接着调用 Future.get() 阻塞等待请求结果返回。 +当响应返回之后,交由连接关联的独立线程池进行反序列化等解析处理。 +待处理完成之后,将业务结果通过 Future.set() 方法返回给业务线程。 + + +在这个设计里面,Consumer 端会维护一个线程池,而且线程池是按照连接隔离的,即每个连接独享一个线程池。这样,当面临需要消费大量服务且并发数比较大的场景时,例如,典型网关类场景,可能会导致 Consumer 端线程个数不断增加,导致线程调度消耗过多 CPU ,也可能因为线程创建过多而导致 OOM。 + +为了解决上述问题,Dubbo 在 2.7.5 版本之后,引入了 ThreadlessExecutor,将线程模型修改成了下图的样子: + + + +引入 ThreadlessExecutor 后的结构图 + + +业务线程发出请求之后,拿到一个 Future 对象。 +业务线程会调用 ThreadlessExecutor.waitAndDrain() 方法,waitAndDrain() 方法会在阻塞队列上等待。 +当收到响应时,IO 线程会生成一个任务,填充到 ThreadlessExecutor 队列中, +业务线程会将上面添加的任务取出,并在本线程中执行。得到业务结果之后,调用 Future.set() 方法进行设置,此时 waitAndDrain() 方法返回。 +业务线程从 Future 中拿到结果值。 + + +了解了 ThreadlessExecutor 出现的缘由之后,接下来我们再深入了解一下 ThreadlessExecutor 的核心实现。首先是 ThreadlessExecutor 的核心字段,有如下几个。 + + +queue(LinkedBlockingQueue类型):阻塞队列,用来在 IO 线程和业务线程之间传递任务。 +waiting、finished(Boolean类型):ThreadlessExecutor 中的 waitAndDrain() 方法一般与一次 RPC 调用绑定,只会执行一次。当后续再次调用 waitAndDrain() 方法时,会检查 finished 字段,若为true,则此次调用直接返回。当后续再次调用 execute() 方法提交任务时,会根据 waiting 字段决定任务是放入 queue 队列等待业务线程执行,还是直接由 sharedExecutor 线程池执行。 +sharedExecutor(ExecutorService类型):ThreadlessExecutor 底层关联的共享线程池,当业务线程已经不再等待响应时,会由该共享线程执行提交的任务。 +waitingFuture(CompletableFuture类型):指向请求对应的 DefaultFuture 对象,其具体实现我们会在后面的课时详细展开介绍。 + + +ThreadlessExecutor 的核心逻辑在 execute() 方法和 waitAndDrain() 方法。execute() 方法相对简单,它会根据 waiting 状态决定任务提交到哪里,相关示例代码如下: + +public void execute(Runnable runnable) { + + synchronized (lock) { + + if (!waiting) { // 判断业务线程是否还在等待响应结果 + + // 不等待,则直接交给共享线程池处理任务 + + sharedExecutor.execute(runnable); + + } else {// 业务线程还在等待,则将任务写入队列,然后由业务线程自己执行 + + queue.add(runnable); + + } + + } + +} + + +waitAndDrain() 方法中首先会检测 finished 字段值,然后获取阻塞队列中的全部任务并执行,执行完成之后会修改finished和 waiting 字段,标识当前 ThreadlessExecutor 已使用完毕,无业务线程等待。 + +public void waitAndDrain() throws InterruptedException { + + if (finished) { // 检测当前ThreadlessExecutor状态 + + return; + + } + + // 获取阻塞队列中获取任务 + + Runnable runnable = queue.take(); + + synchronized (lock) { + + waiting = false; // 修改waiting状态 + + runnable.run(); // 执行任务 + + } + + runnable = queue.poll(); // 如果阻塞队列中还有其他任务,也需要一并执行 + + while (runnable != null) { + + runnable.run(); // 省略异常处理逻辑 + + runnable = queue.poll(); + + } + + finished = true; // 修改finished状态 + +} + + +到此为止,Transporter 层对 ChannelHandler 的实现就介绍完了,其中涉及了多个 ChannelHandler 的装饰器,为了帮助你更好地理解,这里我们回到 NettyServer 中,看看它是如何对上层 ChannelHandler 进行封装的。 + +在 NettyServer 的构造方法中会调用 ChannelHandlers.wrap() 方法对传入的 ChannelHandler 对象进行修饰: + +protected ChannelHandler wrapInternal(ChannelHandler handler, URL url) { + + return new MultiMessageHandler(new HeartbeatHandler(ExtensionLoader.getExtensionLoader(Dispatcher.class) + + .getAdaptiveExtension().dispatch(handler, url))); + +} + + +结合前面的分析,我们可以得到下面这张图: + + + +Server 端 ChannelHandler 结构图 + +我们可以在创建 NettyServerHandler 的地方添加断点 Debug 得到下图,也印证了上图的内容: + + + +总结 + +本课时我们重点介绍了 Dubbo Transporter 层中 Client、 Channel、ChannelHandler 相关的实现以及优化。 + +首先我们介绍了 AbstractClient 抽象接口以及基于 Netty 4 的 NettyClient 实现。接下来,介绍了 AbstractChannel 抽象类以及 NettyChannel 实现。最后,我们深入分析了 ChannelHandler 接口实现,其中详细分析 WrappedChannelHandler 等关键 ChannelHandler 实现,以及 ThreadlessExecutor 优化。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/21Exchange层剖析:彻底搞懂Request-Response模型(上).md b/专栏/Dubbo源码解读与实战-完/21Exchange层剖析:彻底搞懂Request-Response模型(上).md new file mode 100644 index 0000000..637c323 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/21Exchange层剖析:彻底搞懂Request-Response模型(上).md @@ -0,0 +1,417 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 Exchange 层剖析:彻底搞懂 Request-Response 模型(上) + 在前面的课程中,我们深入介绍了 Dubbo Remoting 中的 Transport 层,了解了 Dubbo 抽象出来的端到端的统一传输层接口,并分析了以 Netty 为基础的相关实现。当然,其他 NIO 框架的接入也是类似的,本课程就不再展开赘述了。 + +在本课时中,我们将介绍 Transport 层的上一层,也是 Dubbo Remoting 层中的最顶层—— Exchange 层。Dubbo 将信息交换行为抽象成 Exchange 层,官方文档对这一层的说明是:封装了请求-响应的语义,即关注一问一答的交互模式,实现了同步转异步。在 Exchange 这一层,以 Request 和 Response 为中心,针对 Channel、ChannelHandler、Client、RemotingServer 等接口进行实现。 + +下面我们从 Request 和 Response 这一对基础类开始,依次介绍 Exchange 层中 ExchangeChannel、HeaderExchangeHandler 的核心实现。 + +Request 和 Response + +Exchange 层的 Request 和 Response 这两个类是 Exchange 层的核心对象,是对请求和响应的抽象。我们先来看Request 类的核心字段: + +public class Request { + + // 用于生成请求的自增ID,当递增到Long.MAX_VALUE之后,会溢出到Long.MIN_VALUE,我们可以继续使用该负数作为消息ID + + private static final AtomicLong INVOKE_ID = new AtomicLong(0); + + private final long mId; // 请求的ID + + private String mVersion; // 请求版本号 + + // 请求的双向标识,如果该字段设置为true,则Server端在收到请求后, + + // 需要给Client返回一个响应 + + private boolean mTwoWay = true; + + // 事件标识,例如心跳请求、只读请求等,都会带有这个标识 + + private boolean mEvent = false; + + // 请求发送到Server之后,由Decoder将二进制数据解码成Request对象, + + // 如果解码环节遇到异常,则会设置该标识,然后交由其他ChannelHandler根据 + + // 该标识做进一步处理 + + private boolean mBroken = false; + + // 请求体,可以是任何Java类型的对象,也可以是null + + private Object mData; + +} + + +接下来是 Response 的核心字段: + +public class Response { + + // 响应ID,与相应请求的ID一致 + + private long mId = 0; + + // 当前协议的版本号,与请求消息的版本号一致 + + private String mVersion; + + // 响应状态码,有OK、CLIENT_TIMEOUT、SERVER_TIMEOUT等10多个可选值 + + private byte mStatus = OK; + + private boolean mEvent = false; + + private String mErrorMsg; // 可读的错误响应消息 + + private Object mResult; // 响应体 + +} + + +ExchangeChannel & DefaultFuture + +在前面的课时中,我们介绍了 Channel 接口的功能以及 Transport 层对 Channel 接口的实现。在 Exchange 层中定义了 ExchangeChannel 接口,它在 Channel 接口之上抽象了 Exchange 层的网络连接。ExchangeChannel 接口的定义如下: + + + +ExchangeChannel 接口 + +其中,request() 方法负责发送请求,从图中可以看到这里有两个重载,其中一个重载可以指定请求的超时时间,返回值都是 Future 对象。 + + + +HeaderExchangeChannel 继承关系图 + +从上图中可以看出,HeaderExchangeChannel 是 ExchangeChannel 的实现,它本身是 Channel 的装饰器,封装了一个 Channel 对象,其 send() 方法和 request() 方法的实现都是依赖底层修饰的这个 Channel 对象实现的。 + +public void send(Object message, boolean sent) throws RemotingException { + + if (message instanceof Request || message instanceof Response + + || message instanceof String) { + + channel.send(message, sent); + + } else { + + Request request = new Request(); + + request.setVersion(Version.getProtocolVersion()); + + request.setTwoWay(false); + + request.setData(message); + + channel.send(request, sent); + + } + +} + +public CompletableFuture request(Object request, int timeout, ExecutorService executor) throws RemotingException { + + Request req = new Request(); // 创建Request对象 + + req.setVersion(Version.getProtocolVersion()); + + req.setTwoWay(true); + + req.setData(request); + + DefaultFuture future = DefaultFuture.newFuture(channel, + + req, timeout, executor); // 创建DefaultFuture + + channel.send(req); + + return future; + +} + + +注意这里的 request() 方法,它返回的是一个 DefaultFuture 对象。通过前面课时的介绍我们知道,io.netty.channel.Channel 的 send() 方法会返回一个 ChannelFuture 方法,表示此次发送操作是否完成,而这里的DefaultFuture 就表示此次请求-响应是否完成,也就是说,要收到响应为 Future 才算完成。 + +下面我们就来深入介绍一下请求发送过程中涉及的 DefaultFuture 以及HeaderExchangeChannel的内容。 + +首先来了解一下 DefaultFuture 的具体实现,它继承了 JDK 中的 CompletableFuture,其中维护了两个 static 集合。 + + +CHANNELS(Map集合):管理请求与 Channel 之间的关联关系,其中 Key 为请求 ID,Value 为发送请求的 Channel。 +FUTURES(Map集合):管理请求与 DefaultFuture 之间的关联关系,其中 Key 为请求 ID,Value 为请求对应的 Future。 + + +DefaultFuture 中核心的实例字段包括如下几个。 + + +request(Request 类型)和 id(Long 类型):对应请求以及请求的 ID。 +channel(Channel 类型):发送请求的 Channel。 +timeout(int 类型):整个请求-响应交互完成的超时时间。 +start(long 类型):该 DefaultFuture 的创建时间。 +sent(volatile long 类型):请求发送的时间。 +timeoutCheckTask(Timeout 类型):该定时任务到期时,表示对端响应超时。 +executor(ExecutorService 类型):请求关联的线程池。 + + +DefaultFuture.newFuture() 方法创建 DefaultFuture 对象时,需要先初始化上述字段,并创建请求相应的超时定时任务: + +public static DefaultFuture newFuture(Channel channel, Request request, int timeout, ExecutorService executor) { + + // 创建DefaultFuture对象,并初始化其中的核心字段 + + final DefaultFuture future = new DefaultFuture(channel, request, timeout); + + future.setExecutor(executor); + + // 对于ThreadlessExecutor的特殊处理,ThreadlessExecutor可以关联一个waitingFuture,就是这里创建DefaultFuture对象 + + if (executor instanceof ThreadlessExecutor) { + + ((ThreadlessExecutor) executor).setWaitingFuture(future); + + } + + // 创建一个定时任务,用处理响应超时的情况 + + timeoutCheck(future); + + return future; + +} + + +在 HeaderExchangeChannel.request() 方法中完成 DefaultFuture 对象的创建之后,会将请求通过底层的 Dubbo Channel 发送出去,发送过程中会触发沿途 ChannelHandler 的 sent() 方法,其中的 HeaderExchangeHandler 会调用 DefaultFuture.sent() 方法更新 sent 字段,记录请求发送的时间戳。后续如果响应超时,则会将该发送时间戳添加到提示信息中。 + +过一段时间之后,Consumer 会收到对端返回的响应,在读取到完整响应之后,会触发 Dubbo Channel 中各个 ChannelHandler 的 received() 方法,其中就包括上一课时介绍的 WrappedChannelHandler。例如,AllChannelHandler 子类会将后续 ChannelHandler.received() 方法的调用封装成任务提交到线程池中,响应会提交到 DefaultFuture 关联的线程池中,如上一课时介绍的 ThreadlessExecutor,然后由业务线程继续后续的 ChannelHandler 调用。(你也可以回顾一下上一课时对 Transport 层 Dispatcher 以及 ThreadlessExecutor 的介绍。) + +当响应传递到 HeaderExchangeHandler 的时候,会通过调用 handleResponse() 方法进行处理,其中调用了 DefaultFuture.received() 方法,该方法会找到响应关联的 DefaultFuture 对象(根据请求 ID 从 FUTURES 集合查找)并调用 doReceived() 方法,将 DefaultFuture 设置为完成状态。 + +public static void received(Channel channel, Response response, boolean timeout) { // 省略try/finally代码块 + + // 清理FUTURES中记录的请求ID与DefaultFuture之间的映射关系 + + DefaultFuture future = FUTURES.remove(response.getId()); + + if (future != null) { + + Timeout t = future.timeoutCheckTask; + + if (!timeout) { // 未超时,取消定时任务 + + t.cancel(); + + } + + future.doReceived(response); // 调用doReceived()方法 + + }else{ // 查找不到关联的DefaultFuture会打印日志(略)} + + // 清理CHANNELS中记录的请求ID与Channel之间的映射关系 + + CHANNELS.remove(response.getId()); + +} + +// DefaultFuture.doReceived()方法的代码片段 + +private void doReceived(Response res) { + + if (res == null) { + + throw new IllegalStateException("response cannot be null"); + + } + + if (res.getStatus() == Response.OK) { // 正常响应 + + this.complete(res.getResult()); + + } else if (res.getStatus() == Response.CLIENT_TIMEOUT || res.getStatus() == Response.SERVER_TIMEOUT) { // 超时 + + this.completeExceptionally(new TimeoutException(res.getStatus() == Response.SERVER_TIMEOUT, channel, res.getErrorMessage())); + + } else { // 其他异常 + + this.completeExceptionally(new RemotingException(channel, res.getErrorMessage())); + + } + + // 下面是针对ThreadlessExecutor的兜底处理,主要是防止业务线程一直阻塞在ThreadlessExecutor上 + + if (executor != null && executor instanceof ThreadlessExecutor) { + + ThreadlessExecutor threadlessExecutor = (ThreadlessExecutor) executor; + + if (threadlessExecutor.isWaiting()) { + + // notifyReturn()方法会向ThreadlessExecutor提交一个任务,这样业务线程就不会阻塞了,提交的任务会尝试将DefaultFuture设置为异常结束 + + threadlessExecutor.notifyReturn(new IllegalStateException("The result has returned...")); + + } + + } + +} + + +下面我们再来看看响应超时的场景。在创建 DefaultFuture 时调用的 timeoutCheck() 方法中,会创建 TimeoutCheckTask 定时任务,并添加到时间轮中,具体实现如下: + +private static void timeoutCheck(DefaultFuture future) { + + TimeoutCheckTask task = new TimeoutCheckTask(future.getId()); + + future.timeoutCheckTask = TIME_OUT_TIMER.newTimeout(task, future.getTimeout(), TimeUnit.MILLISECONDS); + +} + + +TIME_OUT_TIMER 是一个 HashedWheelTimer 对象,即 Dubbo 中对时间轮的实现,这是一个 static 字段,所有 DefaultFuture 对象共用一个。 + +TimeoutCheckTask 是 DefaultFuture 中的内部类,实现了 TimerTask 接口,可以提交到时间轮中等待执行。当响应超时的时候,TimeoutCheckTask 会创建一个 Response,并调用前面介绍的 DefaultFuture.received() 方法。示例代码如下: + +public void run(Timeout timeout) { + + // 检查该任务关联的DefaultFuture对象是否已经完成 + + if (future.getExecutor() != null) { // 提交到线程池执行,注意ThreadlessExecutor的情况 + + future.getExecutor().execute(() -> notifyTimeout(future)); + + } else { + + notifyTimeout(future); + + } + +} + +private void notifyTimeout(DefaultFuture future) { + + // 没有收到对端的响应,这里会创建一个Response,表示超时的响应 + + Response timeoutResponse = new Response(future.getId()); + + timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT); + + timeoutResponse.setErrorMessage(future.getTimeoutMessage(true)); + + // 将关联的DefaultFuture标记为超时异常完成 + + DefaultFuture.received(future.getChannel(), timeoutResponse, true); + +} + + +HeaderExchangeHandler + +在前面介绍 DefaultFuture 时,我们简单说明了请求-响应的流程,其实无论是发送请求还是处理响应,都会涉及 HeaderExchangeHandler,所以这里我们就来介绍一下 HeaderExchangeHandler 的内容。 + +HeaderExchangeHandler 是 ExchangeHandler 的装饰器,其中维护了一个 ExchangeHandler 对象,ExchangeHandler 接口是 Exchange 层与上层交互的接口之一,上层调用方可以实现该接口完成自身的功能;然后再由 HeaderExchangeHandler 修饰,具备 Exchange 层处理 Request-Response 的能力;最后再由 Transport ChannelHandler 修饰,具备 Transport 层的能力。如下图所示: + + + +ChannelHandler 继承关系总览图 + +HeaderExchangeHandler 作为一个装饰器,其 connected()、disconnected()、sent()、received()、caught() 方法最终都会转发给上层提供的 ExchangeHandler 进行处理。这里我们需要聚焦的是 HeaderExchangeHandler 本身对 Request 和 Response 的处理逻辑。 + + + +received() 方法处理的消息分类 + +结合上图,我们可以看到在received() 方法中,对收到的消息进行了分类处理。 + + +只读请求会由handlerEvent() 方法进行处理,它会在 Channel 上设置 channel.readonly 标志,后续介绍的上层调用中会读取该值。 + + +void handlerEvent(Channel channel, Request req) throws RemotingException { + + if (req.getData() != null && req.getData().equals(READONLY_EVENT)) { + + channel.setAttribute(Constants.CHANNEL_ATTRIBUTE_READONLY_KEY, Boolean.TRUE); + + } + +} + + + +双向请求由handleRequest() 方法进行处理,会先对解码失败的请求进行处理,返回异常响应;然后将正常解码的请求交给上层实现的 ExchangeHandler 进行处理,并添加回调。上层 ExchangeHandler 处理完请求后,会触发回调,根据处理结果填充响应结果和响应码,并向对端发送。 + + +void handleRequest(final ExchangeChannel channel, Request req) throws RemotingException { + + Response res = new Response(req.getId(), req.getVersion()); + + if (req.isBroken()) { // 请求解码失败 + + Object data = req.getData(); + + // 设置异常信息和响应码 + + res.setErrorMessage("Fail to decode request due to: " + msg); + + res.setStatus(Response.BAD_REQUEST); + + channel.send(res); // 将异常响应返回给对端 + + return; + + } + + Object msg = req.getData(); + + // 交给上层实现的ExchangeHandler进行处理 + + CompletionStage future = handler.reply(channel, msg); + + future.whenComplete((appResult, t) -> { // 处理结束后的回调 + + if (t == null) { // 返回正常响应 + + res.setStatus(Response.OK); + + res.setResult(appResult); + + } else { // 处理过程发生异常,设置异常信息和错误码 + + res.setStatus(Response.SERVICE_ERROR); + + res.setErrorMessage(StringUtils.toString(t)); + + } + + channel.send(res); // 发送响应 + + }); + +} + + + +单向请求直接委托给上层 ExchangeHandler 实现的 received() 方法进行处理,由于不需要响应,HeaderExchangeHandler 不会关注处理结果。 +对于 Response 的处理,前文已提到了,HeaderExchangeHandler 会通过handleResponse() 方法将关联的 DefaultFuture 设置为完成状态(或是异常完成状态),具体内容这里不再展开讲述。 +对于 String 类型的消息,HeaderExchangeHandler 会根据当前服务的角色进行分类,具体与 Dubbo 对 telnet 的支持相关,后面的课时会详细介绍,这里就不展开分析了。 + + +接下来我们再来看sent() 方法,该方法会通知上层 ExchangeHandler 实现的 sent() 方法,同时还会针对 Request 请求调用 DefaultFuture.sent() 方法记录请求的具体发送时间,该逻辑在前文也已经介绍过了,这里不再重复。 + +在connected() 方法中,会为 Dubbo Channel 创建相应的 HeaderExchangeChannel,并将两者绑定,然后通知上层 ExchangeHandler 处理 connect 事件。 + +在disconnected() 方法中,首先通知上层 ExchangeHandler 进行处理,之后在 DefaultFuture.closeChannel() 通知 DefaultFuture 连接断开(其实就是创建并传递一个 Response,该 Response 的状态码为 CHANNEL_INACTIVE),这样就不会继续阻塞业务线程了,最后再将 HeaderExchangeChannel 与底层的 Dubbo Channel 解绑。 + +总结 + +本课时我们重点介绍了 Dubbo Exchange 层中对 Channel 和 ChannelHandler 接口的实现。 + +我们首先介绍了 Exchange 层中请求-响应模型的基本抽象,即 Request 类和 Response 类。然后又介绍了 ExchangeChannel 对 Channel 接口的实现,同时还说明了发送请求之后得到的 DefaultFuture 对象,这也是上一课时遗留的小问题。最后,讲解了 HeaderExchangeHandler 是如何将 Transporter 层的 ChannelHandler 对象与上层的 ExchangeHandler 对象相关联的。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/22Exchange层剖析:彻底搞懂Request-Response模型(下).md b/专栏/Dubbo源码解读与实战-完/22Exchange层剖析:彻底搞懂Request-Response模型(下).md new file mode 100644 index 0000000..ac6543e --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/22Exchange层剖析:彻底搞懂Request-Response模型(下).md @@ -0,0 +1,432 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 Exchange 层剖析:彻底搞懂 Request-Response 模型(下) + 在上一课时中,我们重点分析了 Exchange 层中 Channel 接口以及 ChannelHandler 接口的核心实现,同时还介绍 Request、Response 两个基础类,以及 DefaultFuture 这个 Future 实现。本课时,我们将继续讲解 Exchange 层其他接口的实现逻辑。 + +HeaderExchangeClient + +HeaderExchangeClient 是 Client 装饰器,主要为其装饰的 Client 添加两个功能: + + +维持与 Server 的长连状态,这是通过定时发送心跳消息实现的; +在因故障掉线之后,进行重连,这是通过定时检查连接状态实现的。 + + +因此,HeaderExchangeClient 侧重定时轮资源的分配、定时任务的创建和取消。 + +HeaderExchangeClient 实现的是 ExchangeClient 接口,如下图所示,间接实现了 ExchangeChannel 和 Client 接口,ExchangeClient 接口是个空接口,没有定义任何方法。 + + + +HeaderExchangeClient 继承关系图 + +HeaderExchangeClient 中有以下两个核心字段。 + + +client(Client 类型):被修饰的 Client 对象。HeaderExchangeClient 中对 Client 接口的实现,都会委托给该对象进行处理。 +channel(ExchangeChannel 类型):Client 与服务端建立的连接,HeaderExchangeChannel 也是一个装饰器,在前面我们已经详细介绍过了,这里就不再展开介绍。HeaderExchangeClient 中对 ExchangeChannel 接口的实现,都会委托给该对象进行处理。 + + +HeaderExchangeClient 构造方法的第一个参数封装 Transport 层的 Client 对象,第二个参数 startTimer参与控制是否开启心跳定时任务和重连定时任务,如果为 true,才会进一步根据其他条件,最终决定是否启动定时任务。这里我们以心跳定时任务为例: + +private void startHeartBeatTask(URL url) { + + if (!client.canHandleIdle()) { // Client的具体实现决定是否启动该心跳任务 + + AbstractTimerTask.ChannelProvider cp = () -> Collections.singletonList(HeaderExchangeClient.this); + + // 计算心跳间隔,最小间隔不能低于1s + + int heartbeat = getHeartbeat(url); + + long heartbeatTick = calculateLeastDuration(heartbeat); + + // 创建心跳任务 + + this.heartBeatTimerTask = new HeartbeatTimerTask(cp, heartbeatTick, heartbeat); + + // 提交到IDLE_CHECK_TIMER这个时间轮中等待执行 + + IDLE_CHECK_TIMER.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS); + + } + +} + + +重连定时任务是在 startReconnectTask() 方法中启动的,其中会根据 URL 中的参数决定是否启动任务。重连定时任务最终也是提交到 IDLE_CHECK_TIMER 这个时间轮中,时间轮定义如下: + +private static final HashedWheelTimer IDLE_CHECK_TIMER = new HashedWheelTimer( + + new NamedThreadFactory("dubbo-client-idleCheck", true), 1, TimeUnit.SECONDS, TICKS_PER_WHEEL); + + +其实,startReconnectTask() 方法的具体实现与前面展示的 startHeartBeatTask() 方法类似,这里就不再赘述。 + +下面我们继续回到心跳定时任务进行分析,你可以回顾第 20 课时介绍的 NettyClient 实现,其 canHandleIdle() 方法返回 true,表示该实现可以自己发送心跳请求,无须 HeaderExchangeClient 再启动一个定时任务。NettyClient 主要依靠 IdleStateHandler 中的定时任务来触发心跳事件,依靠 NettyClientHandler 来发送心跳请求。 + +对于无法自己发送心跳请求的 Client 实现,HeaderExchangeClient 会为其启动 HeartbeatTimerTask 心跳定时任务,其继承关系如下图所示: + + + +TimerTask 继承关系图 + +我们先来看 AbstractTimerTask 这个抽象类,它有三个字段。 + + +channelProvider(ChannelProvider类型):ChannelProvider 是 AbstractTimerTask 抽象类中定义的内部接口,定时任务会从该对象中获取 Channel。 +tick(Long类型):任务的过期时间。 +cancel(boolean类型):任务是否已取消。 + + +AbstractTimerTask 抽象类实现了 TimerTask 接口的 run() 方法,首先会从 ChannelProvider 中获取此次任务相关的 Channel 集合(在 Client 端只有一个 Channel,在 Server 端有多个 Channel),然后检查 Channel 的状态,针对未关闭的 Channel 执行 doTask() 方法处理,最后通过 reput() 方法将当前任务重新加入时间轮中,等待再次到期执行。 + +AbstractTimerTask.run() 方法的具体实现如下: + +public void run(Timeout timeout) throws Exception { + + // 从ChannelProvider中获取任务要操作的Channel集合 + + Collection c = channelProvider.getChannels(); + + for (Channel channel : c) { + + if (channel.isClosed()) { // 检测Channel状态 + + continue; + + } + + doTask(channel); // 执行任务 + + } + + reput(timeout, tick); // 将当前任务重新加入时间轮中,等待执行 + +} + + +doTask() 是一个 AbstractTimerTask 留给子类实现的抽象方法,不同的定时任务执行不同的操作。例如,HeartbeatTimerTask.doTask() 方法中会读取最后一次读写时间,然后计算距离当前的时间,如果大于心跳间隔,就会发送一个心跳请求,核心实现如下: + +protected void doTask(Channel channel) { + + // 获取最后一次读写时间 + + Long lastRead = lastRead(channel); + + Long lastWrite = lastWrite(channel); + + if ((lastRead != null && now() - lastRead > heartbeat) + + || (lastWrite != null && now() - lastWrite > heartbeat)) { + + // 最后一次读写时间超过心跳时间,就会发送心跳请求 + + Request req = new Request(); + + req.setVersion(Version.getProtocolVersion()); + + req.setTwoWay(true); + + req.setEvent(HEARTBEAT_EVENT); + + channel.send(req); + + } + +} + + +这里 lastRead 和 lastWrite 时间戳,都是从要待处理 Channel 的附加属性中获取的,对应的 Key 分别是:KEY_READ_TIMESTAMP、KEY_WRITE_TIMESTAMP。你可以回顾前面课程中介绍的 HeartbeatHandler,它属于 Transport 层,是一个 ChannelHandler 的装饰器,在其 connected() 、sent() 方法中会记录最后一次写操作时间,在其 connected()、received() 方法中会记录最后一次读操作时间,在其 disconnected() 方法中会清理这两个时间戳。 + +在 ReconnectTimerTask 中会检测待处理 Channel 的连接状态,以及读操作的空闲时间,对于断开或是空闲时间较长的 Channel 进行重连,具体逻辑这里就不再展开了。 + +HeaderExchangeClient 最后要关注的是它的关闭流程,具体实现在 close() 方法中,如下所示: + +public void close(int timeout) { + + startClose(); // 将closing字段设置为true + + doClose(); // 关闭心跳定时任务和重连定时任务 + + channel.close(timeout); // 关闭HeaderExchangeChannel + +} + + +在 HeaderExchangeChannel.close(timeout) 方法中首先会将自身的 closed 字段设置为 true,这样就不会继续发送请求。如果当前 Channel 上还有请求未收到响应,会循环等待至收到响应,如果超时未收到响应,会自己创建一个状态码将连接关闭的 Response 交给 DefaultFuture 处理,与收到 disconnected 事件相同。然后会关闭 Transport 层的 Channel,以 NettyChannel 为例,NettyChannel.close() 方法会先将自身的 closed 字段设置为 true,清理 CHANNEL_MAP 缓存中的记录,以及 Channel 的附加属性,最后才是关闭 io.netty.channel.Channel。 + +HeaderExchangeServer + +下面再来看 HeaderExchangeServer,其继承关系如下图所示,其中 Endpoint、RemotingServer、Resetable 这三个接口我们在前面已经详细介绍过了,这里不再重复。 + + + +HeaderExchangeServer 的继承关系图 + +与前面介绍的 HeaderExchangeClient 一样,HeaderExchangeServer 是 RemotingServer 的装饰器,实现自 RemotingServer 接口的大部分方法都委托给了所修饰的 RemotingServer 对象。 + +在 HeaderExchangeServer 的构造方法中,会启动一个 CloseTimerTask 定时任务,定期关闭长时间空闲的连接,具体的实现方式与 HeaderExchangeClient 中的两个定时任务类似,这里不再展开分析。 + +需要注意的是,前面课时介绍的 NettyServer 并没有启动该定时任务,而是靠 NettyServerHandler 和 IdleStateHandler 实现的,原理与 NettyClient 类似,这里不再展开,你若感兴趣的话,可以回顾第 20课时或是查看 CloseTimerTask 的具体实现。 + +在 19 课时介绍 Transport Server 的时候,我们并没有过多介绍其关闭流程,这里我们就通过 HeaderExchangeServer 自顶向下梳理整个 Server 端关闭流程。先来看 HeaderExchangeServer.close() 方法的关闭流程: + + +将被修饰的 RemotingServer 的 closing 字段设置为 true,表示这个 Server 端正在关闭,不再接受新 Client 的连接。你可以回顾第 19 课时中介绍的 AbstractServer.connected() 方法,会发现 Server 正在关闭或是已经关闭时,则直接关闭新建的 Client 连接。 +向 Client 发送一个携带 ReadOnly 事件的请求(根据 URL 中的配置决定是否发送,默认为发送)。在接收到该请求之后,Client 端的 HeaderExchangeHandler 会在 Channel 上添加 Key 为 “channel.readonly” 的附加信息,上层调用方会根据该附加信息,判断该连接是否可写。 +循环去检测是否还存在 Client 与当前 Server 维持着长连接,直至全部 Client 断开连接或超时。 +更新 closed 字段为 true,之后 Client 不会再发送任何请求或是回复响应了。 +取消 CloseTimerTask 定时任务。 +调用底层 RemotingServer 对象的 close() 方法。以 NettyServer 为例,其 close() 方法会先调用 AbstractPeer 的 close() 方法将自身的 closed 字段设置为 true;然后调用 doClose() 方法关闭 boss Channel(即用来接收客户端连接的 Channel),关闭 channels 集合中记录的 Channel(这些 Channel 是与 Client 之间的连接),清理 channels 集合;最后,关闭 bossGroup 和 workerGroup 两个线程池。 + + +HeaderExchangeServer.close() 方法的核心逻辑如下: + +public void close(final int timeout) { + + startClose(); // 将底层RemotingServer的closing字段设置为true,表示当前Server正在关闭,不再接收连接 + + if (timeout > 0) { + + final long max = (long) timeout; + + final long start = System.currentTimeMillis(); + + if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) { + + // 发送ReadOnly事件请求通知客户端 + + sendChannelReadOnlyEvent(); + + } + + while (HeaderExchangeServer.this.isRunning() + + && System.currentTimeMillis() - start < max) { + + Thread.sleep(10); // 循环等待客户端断开连接 + + } + + } + + doClose(); // 将自身closed字段设置为true,取消CloseTimerTask定时任务 + + server.close(timeout); // 关闭Transport层的Server + +} + + +通过对上述关闭流程的分析,你就可以清晰地知道 HeaderExchangeServer 优雅关闭的原理。 + +HeaderExchanger + +对于上层来说,Exchange 层的入口是 Exchangers 这个门面类,其中提供了多个 bind() 以及 connect() 方法的重载,这些重载方法最终会通过 SPI 机制,获取 Exchanger 接口的扩展实现,这个流程与第 17 课时介绍的 Transport 层的入口—— Transporters 门面类相同。 + +我们可以看到 Exchanger 接口的定义与前面介绍的 Transporter 接口非常类似,同样是被 @SPI 接口修饰(默认扩展名为“header”,对应的是 HeaderExchanger 这个实现),bind() 方法和 connect() 方法也同样是被 @Adaptive 注解修饰,可以通过 URL 参数中的 exchanger 参数值指定扩展名称来覆盖默认值。 + +@SPI(HeaderExchanger.NAME) + +public interface Exchanger { + + @Adaptive({Constants.EXCHANGER_KEY}) + + ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException; + + @Adaptive({Constants.EXCHANGER_KEY}) + + ExchangeClient connect(URL url, ExchangeHandler handler) throws RemotingException; + +} + + +Dubbo 只为 Exchanger 接口提供了 HeaderExchanger 这一个实现,其中 connect() 方法创建的是 HeaderExchangeClient 对象,bind() 方法创建的是 HeaderExchangeServer 对象,如下图所示: + + + +HeaderExchanger 门面类 + +从 HeaderExchanger 的实现可以看到,它会在 Transport 层的 Client 和 Server 实现基础之上,添加前文介绍的 HeaderExchangeClient 和 HeaderExchangeServer 装饰器。同时,为上层实现的 ExchangeHandler 实例添加了 HeaderExchangeHandler 以及 DecodeHandler 两个修饰器: + +public class HeaderExchanger implements Exchanger { + + public static final String NAME = "header"; + + @Override + + public ExchangeClient connect(URL url, ExchangeHandler handler) throws RemotingException { + + return new HeaderExchangeClient(Transporters.connect(url, new DecodeHandler(new HeaderExchangeHandler(handler))), true); + + } + + @Override + + public ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException { + + return new HeaderExchangeServer(Transporters.bind(url, new DecodeHandler(new HeaderExchangeHandler(handler)))); + + } + +} + + +再谈 Codec2 + +在前面第 17 课时介绍 Dubbo Remoting 核心接口的时候提到,Codec2 接口提供了 encode() 和 decode() 两个方法来实现消息与字节流之间的相互转换。需要注意与 DecodeHandler 区分开来,DecodeHandler 是对请求体和响应结果的解码,Codec2 是对整个请求和响应的编解码。 + +这里重点介绍 Transport 层和 Exchange 层对 Codec2 接口的实现,涉及的类如下图所示: + + + +AbstractCodec抽象类并没有实现 Codec2 中定义的接口方法,而是提供了几个给子类用的基础方法,下面简单说明这些方法的功能。 + + +getSerialization() 方法:通过 SPI 获取当前使用的序列化方式。 +checkPayload() 方法:检查编解码数据的长度,如果数据超长,会抛出异常。 +isClientSide()、isServerSide() 方法:判断当前是 Client 端还是 Server 端。 + + +接下来看TransportCodec,我们可以看到这类上被标记了 @Deprecated 注解,表示已经废弃。TransportCodec 的实现非常简单,其中根据 getSerialization() 方法选择的序列化方法对传入消息或 ChannelBuffer 进行序列化或反序列化,这里就不再介绍 TransportCodec 实现了。 + +TelnetCodec继承了 TransportCodec 序列化和反序列化的基本能力,同时还提供了对 Telnet 命令处理的能力。 + +最后来看ExchangeCodec,它在 TelnetCodec 的基础之上,添加了处理协议头的能力。下面是 Dubbo 协议的格式,能够清晰地看出协议中各个数据所占的位数: + + + +Dubbo 协议格式 + +结合上图,我们来深入了解一下 Dubbo 协议中各个部分的含义: + + +0~7 位和 8~15 位分别是 Magic High 和 Magic Low,是固定魔数值(0xdabb),我们可以通过这两个 Byte,快速判断一个数据包是否为 Dubbo 协议,这也类似 Java 字节码文件里的魔数。 +16 位是 Req/Res 标识,用于标识当前消息是请求还是响应。 +17 位是 2Way 标识,用于标识当前消息是单向还是双向。 +18 位是 Event 标识,用于标识当前消息是否为事件消息。 +19~23 位是序列化类型的标志,用于标识当前消息使用哪一种序列化算法。 +24~31 位是 Status 状态,用于记录响应的状态,仅在 Req/Res 为 0(响应)时有用。 +32~95 位是 Request ID,用于记录请求的唯一标识,类型为 long。 +96~127 位是序列化后的内容长度,该值是按字节计数,int 类型。 +128 位之后是可变的数据,被特定的序列化算法(由序列化类型标志确定)序列化后,每个部分都是一个 byte [] 或者 byte。如果是请求包(Req/Res = 1),则每个部分依次为:Dubbo version、Service name、Service version、Method name、Method parameter types、Method arguments 和 Attachments。如果是响应包(Req/Res = 0),则每个部分依次为:①返回值类型(byte),标识从服务器端返回的值类型,包括返回空值(RESPONSE_NULL_VALUE 2)、正常响应值(RESPONSE_VALUE 1)和异常(RESPONSE_WITH_EXCEPTION 0)三种;②返回值,从服务端返回的响应 bytes。 + + +可以看到 Dubbo 协议中前 128 位是协议头,之后的内容是具体的负载数据。协议头就是通过 ExchangeCodec 实现编解码的。 + +ExchangeCodec 的核心字段有如下几个。 + + +HEADER_LENGTH(int 类型,值为 16):协议头的字节数,16 字节,即 128 位。 +MAGIC(short 类型,值为 0xdabb):协议头的前 16 位,分为 MAGIC_HIGH 和 MAGIC_LOW 两个字节。 +FLAG_REQUEST(byte 类型,值为 0x80):用于设置 Req/Res 标志位。 +FLAG_TWOWAY(byte 类型,值为 0x40):用于设置 2Way 标志位。 +FLAG_EVENT(byte 类型,值为 0x20):用于设置 Event 标志位。 +SERIALIZATION_MASK(int 类型,值为 0x1f):用于获取序列化类型的标志位的掩码。 + + +在 ExchangeCodec 的 encode() 方法中会根据需要编码的消息类型进行分类,其中 encodeRequest() 方法专门对 Request 对象进行编码,具体实现如下: + +protected void encodeRequest(Channel channel, ChannelBuffer buffer, Request req) throws IOException { + + Serialization serialization = getSerialization(channel); + + byte[] header = new byte[HEADER_LENGTH]; // 该数组用来暂存协议头 + + // 在header数组的前两个字节中写入魔数 + + Bytes.short2bytes(MAGIC, header); + + // 根据当前使用的序列化设置协议头中的序列化标志位 + + header[2] = (byte) (FLAG_REQUEST | serialization.getContentTypeId()); + + if (req.isTwoWay()) { // 设置协议头中的2Way标志位 + + header[2] |= FLAG_TWOWAY; + + } + + if (req.isEvent()) { // 设置协议头中的Event标志位 + + header[2] |= FLAG_EVENT; + + } + + // 将请求ID记录到请求头中 + + Bytes.long2bytes(req.getId(), header, 4); + + // 下面开始序列化请求,并统计序列化后的字节数 + + // 首先使用savedWriteIndex记录ChannelBuffer当前的写入位置 + + int savedWriteIndex = buffer.writerIndex(); + + // 将写入位置后移16字节 + + buffer.writerIndex(savedWriteIndex + HEADER_LENGTH); + + // 根据选定的序列化方式对请求进行序列化 + + ChannelBufferOutputStream bos = new ChannelBufferOutputStream(buffer); + + ObjectOutput out = serialization.serialize(channel.getUrl(), bos); + + if (req.isEvent()) { // 对事件进行序列化 + + encodeEventData(channel, out, req.getData()); + + } else { // 对Dubbo请求进行序列化,具体在DubboCodec中实现 + + encodeRequestData(channel, out, req.getData(), req.getVersion()); + + } + + out.flushBuffer(); + + if (out instanceof Cleanable) { + + ((Cleanable) out).cleanup(); + + } + + bos.flush(); + + bos.close(); // 完成序列化 + + int len = bos.writtenBytes(); // 统计请求序列化之后,得到的字节数 + + checkPayload(channel, len); // 限制一下请求的字节长度 + + Bytes.int2bytes(len, header, 12); // 将字节数写入header数组中 + + // 下面调整ChannelBuffer当前的写入位置,并将协议头写入Buffer中 + + buffer.writerIndex(savedWriteIndex); + + buffer.writeBytes(header); + + // 最后,将ChannelBuffer的写入位置移动到正确的位置 + + buffer.writerIndex(savedWriteIndex + HEADER_LENGTH + len); + +} + + +encodeResponse() 方法编码响应的方式与 encodeRequest() 方法编码请求的方式类似,这里就不再展开介绍了,感兴趣的同学可以参考源码进行学习。对于既不是 Request,也不是 Response 的消息,ExchangeCodec 会使用从父类继承下来的能力来编码,例如对 telnet 命令的编码。 + +ExchangeCodec 的 decode() 方法是 encode() 方法的逆过程,会先检查魔数,然后读取协议头和后续消息的长度,最后根据协议头中的各个标志位构造相应的对象,以及反序列化数据。在了解协议头结构的前提下,再去阅读这段逻辑就十分轻松了,这就留给你自己尝试分析一下。 + +总结 + +本课时我们重点介绍了 Dubbo Exchange 层中对 Client 和 Server 接口的实现。 + +我们首先介绍了 HeaderExchangeClient 对 ExchangeClient 接口的实现,以及 HeaderExchangeServer 对 ExchangeServer 接口的实现,这两者是在 Transport 层 Client 和 Server 的基础上,添加了新的功能。接下来,又讲解了 HeaderExchanger 这个用来创建 HeaderExchangeClient 和 HeaderExchangeServer 的门面类。最后,分析了 Dubbo 协议的格式,以及处理 Dubbo 协议的 ExchangeCodec 实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/23核心接口介绍,RPC层骨架梳理.md b/专栏/Dubbo源码解读与实战-完/23核心接口介绍,RPC层骨架梳理.md new file mode 100644 index 0000000..e9d0db6 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/23核心接口介绍,RPC层骨架梳理.md @@ -0,0 +1,412 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 核心接口介绍,RPC 层骨架梳理 + 在前面的课程中,我们深入介绍了 Dubbo 架构中的 Dubbo Remoting 层的相关内容,了解了 Dubbo 底层的网络模型以及线程模型。从本课时开始,我们就开始介绍 Dubbo Remoting 上面的一层—— Protocol 层(如下图所示),Protocol 层是 Remoting 层的使用者,会通过 Exchangers 门面类创建 ExchangeClient 以及 ExchangeServer,还会创建相应的 ChannelHandler 实现以及 Codec2 实现并交给 Exchange 层进行装饰。 + + + +Dubbo 架构中 Protocol 层的位置图 + +Protocol 层在 Dubbo 源码中对应的是 dubbo-rpc 模块,该模块的结构如下图所示: + + + +dubbo-rpc 模块结构图 + +我们可以看到有很多模块,和 dubbo-remoting 模块类似,其中 dubbo-rpc-api 是对具体协议、服务暴露、服务引用、代理等的抽象,是整个 Protocol 层的核心。剩余的模块,例如,dubbo-rpc-dubbo、dubbo-rpc-grpc、dubbo-rpc-http 等,都是 Dubbo 支持的具体协议,可以看作dubbo-rpc-api 模块的具体实现。 + +dubbo-rpc-api + +这里我们首先来看 dubbo-rpc-api 模块的包结构,如下图所示: + + + +dubbo-rpc-api 模块的包结构图 + +根据上图展示的 dubbo-rpc-api 模块的结构,我们可以看到 dubbo-rpc-api 模块包括了以下几个核心包。 + + +filter 包:在进行服务引用时会进行一系列的过滤,其中包括了很多过滤器。 +listener 包:在服务发布和服务引用的过程中,我们可以添加一些 Listener 来监听相应的事件,与 Listener 相关的接口 Adapter、Wrapper 实现就在这个包内。 +protocol 包:一些实现了 Protocol 接口以及 Invoker 接口的抽象类位于该包之中,它们主要是为 Protocol 接口的具体实现以及 Invoker 接口的具体实现提供一些公共逻辑。 +proxy 包:提供了创建代理的能力,在这个包中支持 JDK 动态代理以及 Javassist 字节码两种方式生成本地代理类。 +support 包:包括了 RpcUtils 工具类、Mock 相关的 Protocol 实现以及 Invoker 实现。 + + +没有在上述 package 中的接口和类,是更为核心的抽象接口,上述 package 内的类更多的是这些接口的实现类。下面我们就来介绍这些在 org.apache.dubbo.rpc 包下的核心接口。 + +核心接口 + +在 Dubbo RPC 层中涉及的核心接口有 Invoker、Invocation、Protocol、Result、Exporter、ProtocolServer、Filter 等,这些接口分别抽象了 Dubbo RPC 层的不同概念,看似相互独立,但又相互协同,一起构建出了 DubboRPC 层的骨架。下面我们将逐一介绍这些核心接口的含义。 + +首先要介绍的是 Dubbo 中非常重要的一个接口——Invoker 接口。可以说,Invoker 渗透在整个 Dubbo 代码实现里,Dubbo 中的很多设计思路都会向 Invoker 这个概念靠拢,但这对于刚接触这部分代码的同学们来说,可能不是很友好。 + +这里我们借助如下这样一个精简的示意图来对比说明两种最关键的 Invoker:服务提供 Invoker 和服务消费 Invoker。 + + + +Invoker 核心示意图 + +以 dubbo-demo-annotation-consumer 这个示例项目中的 Consumer 为例,它会拿到一个 DemoService 对象,如下所示,这其实是一个代理(即上图中的 Proxy),这个 Proxy 底层就会通过 Invoker 完成网络调用: + +@Component("demoServiceComponent") + +public class DemoServiceComponent implements DemoService { + + @Reference + + private DemoService demoService; + + @Override + + public String sayHello(String name) { + + return demoService.sayHello(name); + + } + +} + + +紧接着我们再来看一个 dubbo-demo-annotation-provider 示例中的 Provider 实现: + +@Service + +public class DemoServiceImpl implements DemoService { + + @Override + + public String sayHello(String name) { + + return "Hello " + name + ", response from provider: " + RpcContext.getContext().getLocalAddress(); + + } + +} + + +这里的 DemoServiceImpl 类会被封装成为一个 AbstractProxyInvoker 实例,并新生成对应的 Exporter 实例。当 Dubbo Protocol 层收到一个请求之后,会找到这个 Exporter 实例,并调用其对应的 AbstractProxyInvoker 实例,从而完成 Provider 逻辑的调用。这里我先帮你找出了最重要的两类 Invoker ,简单介绍了它们工作场景,当然 Dubbo 中还有其他类型的 Invoker,后面我们再一一介绍。 + +下面来看 Invoker 这个接口的具体定义,如下所示: + +public interface Invoker extends Node { + + // 服务接口 + + Class getInterface(); + + // 进行一次调用,也有人称之为一次"会话",你可以理解为一次调用 + + Result invoke(Invocation invocation) throws RpcException; + +} + + +Invocation 接口是 Invoker.invoke() 方法的参数,抽象了一次 RPC 调用的目标服务和方法信息、相关参数信息、具体的参数值以及一些附加信息,具体定义如下: + +public interface Invocation { + + // 调用Service的唯一标识 + + String getTargetServiceUniqueName(); + + // 调用的方法名称 + + String getMethodName(); + + // 调用的服务名称 + + String getServiceName(); + + // 参数类型集合 + + Class[] getParameterTypes(); + + // 参数签名集合 + + default String[] getCompatibleParamSignatures() { + + return Stream.of(getParameterTypes()) + + .map(Class::getName) + + .toArray(String[]::new); + + } + + // 此次调用具体的参数值 + + Object[] getArguments(); + + // 此次调用关联的Invoker对象 + + Invoker getInvoker(); + + // Invoker对象可以设置一些KV属性,这些属性并不会传递给Provider + + Object put(Object key, Object value); + + Object get(Object key); + + Map getAttributes(); + + // Invocation可以携带一个KV信息作为附加信息,一并传递给Provider, + + // 注意与 attribute 的区分 + + Map getAttachments(); + + Map getObjectAttachments(); + + void setAttachment(String key, String value); + + void setAttachment(String key, Object value); + + void setObjectAttachment(String key, Object value); + + void setAttachmentIfAbsent(String key, String value); + + void setAttachmentIfAbsent(String key, Object value); + + void setObjectAttachmentIfAbsent(String key, Object value); + + String getAttachment(String key); + + Object getObjectAttachment(String key); + + String getAttachment(String key, String defaultValue); + + Object getObjectAttachment(String key, Object defaultValue); + +} + + +Result 接口是 Invoker.invoke() 方法的返回值,抽象了一次调用的返回值,其中包含了被调用方返回值(或是异常)以及附加信息,我们也可以添加回调方法,在 RPC 调用方法结束时会触发这些回调。Result 接口的具体定义如下: + +public interface Result extends Serializable { + + // 获取/设置此次调用的返回值 + + Object getValue(); + + void setValue(Object value); + + // 如果此次调用发生异常,则可以通过下面三个方法获取 + + Throwable getException(); + + void setException(Throwable t); + + boolean hasException(); + + // recreate()方法是一个复合操作,如果此次调用发生异常,则直接抛出异常, + + // 如果没有异常,则返回结果 + + Object recreate() throws Throwable; + + // 添加一个回调,当RPC调用完成时,会触发这里添加的回调 + + Result whenCompleteWithContext(BiConsumer fn); + + CompletableFuture thenApply(Function fn); + + // 阻塞线程,等待此次RPC调用完成(或是超时) + + Result get() throws InterruptedException, ExecutionException; + + Result get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException; + + // Result中同样可以携带附加信息 + + Map getAttachments(); + + Map getObjectAttachments(); + + void addAttachments(Map map); + + void addObjectAttachments(Map map); + + void setAttachments(Map map); + + void setObjectAttachments(Map map); + + String getAttachment(String key); + + Object getObjectAttachment(String key); + + String getAttachment(String key, String defaultValue); + + Object getObjectAttachment(String key, Object defaultValue); + + void setAttachment(String key, String value); + + void setAttachment(String key, Object value); + + void setObjectAttachment(String key, Object valu + +} + + +在上面介绍 Provider 端的 Invoker 时提到,我们的业务接口实现会被包装成一个 AbstractProxyInvoker 对象,然后由 Exporter 暴露出去,让 Consumer 可以调用到该服务。Exporter 暴露 Invoker 的实现,说白了,就是让 Provider 能够根据请求的各种信息,找到对应的 Invoker。我们可以维护一个 Map,其中 Key 可以根据请求中的信息构建,Value 为封装相应服务 Bean 的 Exporter 对象,这样就可以实现上述服务发布的要求了。 + +我们先来看 Exporter 接口的定义: + +public interface Exporter { + + // 获取底层封装的Invoker对象 + + Invoker getInvoker(); + + // 取消发布底层的Invoker对象 + + void unexport(); + +} + + +为了监听服务发布事件以及取消暴露事件,Dubbo 定义了一个 SPI 扩展接口——ExporterListener 接口,其定义如下: + +@SPI + +public interface ExporterListener { + + // 当有服务发布的时候,会触发该方法 + + void exported(Exporter exporter) throws RpcException; + + // 当有服务取消发布的时候,会触发该方法 + + void unexported(Exporter exporter); + +} + + +虽然 ExporterListener 是个扩展接口,但是 Dubbo 本身并没有提供什么有用的扩展实现,我们需要自己提供具体实现监听感兴趣的事情。 + +相应地,我们可以添加 InvokerListener 监听器,监听 Consumer 引用服务时触发的事件,InvokerListener 接口的定义如下: + +@SPI + +public interface InvokerListener { + + // 当服务引用的时候,会触发该方法 + + void referred(Invoker invoker) throws RpcException; + + // 当销毁引用的服务时,会触发该方法 + + void destroyed(Invoker invoker); + +} + + +Protocol 接口是整个 Dubbo Protocol 层的核心接口之一,其中定义了 export() 和 refer() 两个核心方法,具体定义如下: + +@SPI("dubbo") // 默认使用DubboProtocol实现 + +public interface Protocol { + + // 默认端口 + + int getDefaultPort(); + + // 将一个Invoker暴露出去,export()方法实现需要是幂等的, + + // 即同一个服务暴露多次和暴露一次的效果是相同的 + + @Adaptive + + Exporter export(Invoker invoker) throws RpcException; + + // 引用一个Invoker,refer()方法会根据参数返回一个Invoker对象, + + // Consumer端可以通过这个Invoker请求到Provider端的服务 + + @Adaptive + + Invoker refer(Class type, URL url) throws RpcException; + + // 销毁export()方法以及refer()方法使用到的Invoker对象,释放 + + // 当前Protocol对象底层占用的资源 + + void destroy(); + + // 返回当前Protocol底层的全部ProtocolServer + + default List getServers() { + + return Collections.emptyList(); + + } + +} + + +在 Protocol 接口的实现中,export() 方法并不是简单地将 Invoker 对象包装成 Exporter 对象返回,其中还涉及代理对象的创建、底层 Server 的启动等操作;refer() 方法除了根据传入的 type 类型以及 URL 参数查询 Invoker 之外,还涉及相关 Client 的创建等操作。 + +Dubbo 在 Protocol 层专门定义了一个 ProxyFactory 接口,作为创建代理对象的工厂。ProxyFactory 接口是一个扩展接口,其中定义了 getProxy() 方法为 Invoker 创建代理对象,还定义了 getInvoker() 方法将代理对象反向封装成 Invoker 对象。 + +@SPI("javassist") + +public interface ProxyFactory { + + // 为传入的Invoker对象创建代理对象 + + @Adaptive({PROXY_KEY}) + + T getProxy(Invoker invoker) throws RpcException; + + @Adaptive({PROXY_KEY}) + + T getProxy(Invoker invoker, boolean generic) throws RpcException; + + // 将传入的代理对象封装成Invoker对象,可以暂时理解为getProxy()的逆操作 + + @Adaptive({PROXY_KEY}) + + Invoker getInvoker(T proxy, Class type, URL url) throws RpcException; + +} + + +看到 ProxyFactory 上的 @SPI 注解,我们知道其默认实现使用 javassist 来创建代码对象,当然,Dubbo 还提供了其他方式来创建代码,例如 JDK 动态代理。 + +ProtocolServer 接口是对前文介绍的 RemotingServer 的一层简单封装,其实现也都非常简单,这里就不再展开。 + +最后一个要介绍的核心接口是 Filter 接口。关于 Filter,相信做过 Java Web 编程的同学们会非常熟悉这个基础概念,Java Web 开发中的 Filter 是用来拦截 HTTP 请求的,Dubbo 中的 Filter 接口功能与之类似,是用来拦截 Dubbo 请求的。 + +在 Dubbo 的 Filter 接口中,定义了一个 invoke() 方法将请求传递给后续的 Invoker 进行处理(后续的这个 Invoker 对象可能是一个 Filter 封装而成的)。Filter 接口的具体定义如下: + +@SPI + +public interface Filter { + + // 将请求传给后续的Invoker进行处理 + + Result invoke(Invoker invoker, Invocation invocation) throws RpcException; + + interface Listener { // 用于监听响应以及异常 + + void onResponse(Result appResponse, Invoker invoker, Invocation invocation); + + void onError(Throwable t, Invoker invoker, Invocation invocation); + + } + +} + + +Filter 也是一个扩展接口,Dubbo 提供了丰富的 Filter 实现来进行功能扩展,当然我们也可以提供自己的 Filter 实现来扩展 Dubbo 的功能。 + +总结 + +本课时我们首先介绍了 Dubbo RPC 层在整个 Dubbo 框架中所处的位置,然后说明了 dubbo-rpc-api 层的结构以及其中各个包提供的基本功能。接下来,我们还详细介绍了 Dubbo RPC 层中涉及的核心接口,包括 Invoker、Invocation、Protocol、Result、ProxyFactory、ProtocolServer 等核心接口,以及 ExporterListener、Filter 等扩展类的接口。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/24从Protocol起手,看服务暴露和服务引用的全流程(上).md b/专栏/Dubbo源码解读与实战-完/24从Protocol起手,看服务暴露和服务引用的全流程(上).md new file mode 100644 index 0000000..de9e8fe --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/24从Protocol起手,看服务暴露和服务引用的全流程(上).md @@ -0,0 +1,516 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 从 Protocol 起手,看服务暴露和服务引用的全流程(上) + 在上一课时我们讲解了 Protocol 的核心接口,那本课时我们就以 Protocol 接口为核心,详细介绍整个 Protocol 的核心实现。下图展示了 Protocol 接口的继承关系: + + + +Protocol 接口继承关系图 + +其中,AbstractProtocol提供了一些 Protocol 实现需要的公共能力以及公共字段,它的核心字段有如下三个。 + + +exporterMap(Map>类型):用于存储出去的服务集合,其中的 Key 通过 ProtocolUtils.serviceKey() 方法创建的服务标识,在 ProtocolUtils 中维护了多层的 Map 结构(如下图所示)。首先按照 group 分组,在实践中我们可以根据需求设置 group,例如,按照机房、地域等进行 group 划分,做到就近调用;在 GroupServiceKeyCache 中,依次按照 serviceName、serviceVersion、port 进行分类,最终缓存的 serviceKey 是前面三者拼接而成的。 + + + + +groupServiceKeyCacheMap 结构图 + + +serverMap(Map类型):记录了全部的 ProtocolServer 实例,其中的 Key 是 host 和 port 组成的字符串,Value 是监听该地址的 ProtocolServer。ProtocolServer 就是对 RemotingServer 的一层简单封装,表示一个服务端。 +invokers(Set>类型):服务引用的集合。 + + +AbstractProtocol 没有对 Protocol 的 export() 方法进行实现,对 refer() 方法的实现也是委托给了 protocolBindingRefer() 这个抽象方法,然后由子类实现。AbstractProtocol 唯一实现的方法就是 destory() 方法,其首先会遍历 Invokers 集合,销毁全部的服务引用,然后遍历全部的 exporterMap 集合,销毁发布出去的服务,具体实现如下: + +public void destroy() { + + for (Invoker invoker : invokers) { + + if (invoker != null) { + + invokers.remove(invoker); + + invoker.destroy(); // 关闭全部的服务引用 + + } + + } + + for (String key : new ArrayList(exporterMap.keySet())) { + + Exporter exporter = exporterMap.remove(key); + + if (exporter != null) { + + exporter.unexport(); // 关闭暴露出去的服务 + + } + + } + +} + + +export 流程简析 + +了解了 AbstractProtocol 提供的公共能力之后,我们再来分析Dubbo 默认使用的 Protocol 实现类—— DubboProtocol 实现。这里我们首先关注 DubboProtocol 的 export() 方法,也就是服务发布的相关实现,如下所示: + +public Exporter export(Invoker invoker) throws RpcException { + + URL url = invoker.getUrl(); + + // 创建ServiceKey,其核心实现在前文已经详细分析过了,这里不再重复 + + String key = serviceKey(url); + + // 将上层传入的Invoker对象封装成DubboExporter对象,然后记录到exporterMap集合中 + + DubboExporter exporter = new DubboExporter(invoker, key, exporterMap); + + exporterMap.put(key, exporter); + + ... // 省略一些日志操作 + + // 启动ProtocolServer + + openServer(url); + + // 进行序列化的优化处理 + + optimizeSerialization(url); + + return exporter; + +} + + +1. DubboExporter + +这里涉及的第一个点是 DubboExporter 对 Invoker 的封装,DubboExporter 的继承关系如下图所示: + + + +DubboExporter 继承关系图 + +AbstractExporter 中维护了一个 Invoker 对象,以及一个 unexported 字段(boolean 类型),在 unexport() 方法中会设置 unexported 字段为 true,并调用 Invoker 对象的 destory() 方法进行销毁。 + +DubboExporter 也比较简单,其中会维护底层 Invoker 对应的 ServiceKey 以及 DubboProtocol 中的 exportMap 集合,在其 unexport() 方法中除了会调用父类 AbstractExporter 的 unexport() 方法之外,还会清理该 DubboExporter 实例在 exportMap 中相应的元素。 + +2. 服务端初始化 + +了解了 Exporter 实现之后,我们继续看 DubboProtocol 中服务发布的流程。从下面这张调用关系图中可以看出,openServer() 方法会一路调用前面介绍的 Exchange 层、Transport 层,并最终创建 NettyServer 来接收客户端的请求。 + + + +export() 方法调用栈 + +下面我们将逐个介绍 export() 方法栈中的每个被调用的方法。 + +首先,在 openServer() 方法中会根据 URL 判断当前是否为服务端,只有服务端才能创建 ProtocolServer 并对外服务。如果是来自服务端的调用,会依靠 serverMap 集合检查是否已有 ProtocolServer 在监听 URL 指定的地址;如果没有,会调用 createServer() 方法进行创建。openServer() 方法的具体实现如下: + +private void openServer(URL url) { + + String key = url.getAddress(); // 获取host:port这个地址 + + boolean isServer = url.getParameter(IS_SERVER_KEY, true); + + if (isServer) { // 只有Server端才能启动Server对象 + + ProtocolServer server = serverMap.get(key); + + if (server == null) { // 无ProtocolServer监听该地址 + + synchronized (this) { // DoubleCheck,防止并发问题 + + server = serverMap.get(key); + + if (server == null) { + + // 调用createServer()方法创建ProtocolServer对象 + + serverMap.put(key, createServer(url)); + + } + + } + + } else { + + // 如果已有ProtocolServer实例,则尝试根据URL信息重置ProtocolServer + + server.reset(url); + + } + + } + +} + + +createServer() 方法首先会为 URL 添加一些默认值,同时会进行一些参数值的检测,主要有五个。 + + +HEARTBEAT_KEY 参数值,默认值为 60000,表示默认的心跳时间间隔为 60 秒。 +CHANNEL_READONLYEVENT_SENT_KEY 参数值,默认值为 true,表示 ReadOnly 请求需要阻塞等待响应返回。在 Server 关闭的时候,只能发送 ReadOnly 请求,这些 ReadOnly 请求由这里设置的 CHANNEL_READONLYEVENT_SENT_KEY 参数值决定是否需要等待响应返回。 +CODEC_KEY 参数值,默认值为 dubbo。你可以回顾 Codec2 接口中 @Adaptive 注解的参数,都是获取该 URL 中的 CODEC_KEY 参数值。 +检测 SERVER_KEY 参数指定的扩展实现名称是否合法,默认值为 netty。你可以回顾 Transporter 接口中 @Adaptive 注解的参数,它决定了 Transport 层使用的网络库实现,默认使用 Netty 4 实现。 +检测 CLIENT_KEY 参数指定的扩展实现名称是否合法。同 SERVER_KEY 参数的检查流程。 + + +完成上述默认参数值的设置之后,我们就可以通过 Exchangers 门面类创建 ExchangeServer,并封装成 DubboProtocolServer 返回。 + +private ProtocolServer createServer(URL url) { + + url = URLBuilder.from(url) + + // ReadOnly请求是否阻塞等待 + + .addParameterIfAbsent(CHANNEL_READONLYEVENT_SENT_KEY, Boolean.TRUE.toString()) + + // 心跳间隔 + + .addParameterIfAbsent(HEARTBEAT_KEY, String.valueOf(DEFAULT_HEARTBEAT)) + + .addParameter(CODEC_KEY, DubboCodec.NAME) // Codec2扩展实现 + + .build(); + + // 检测SERVER_KEY参数指定的Transporter扩展实现是否合法 + + String str = url.getParameter(SERVER_KEY, DEFAULT_REMOTING_SERVER); + + if (str != null && str.length() > 0 && !ExtensionLoader.getExtensionLoader(Transporter.class).hasExtension(str)) { + + throw new RpcException("..."); + + } + + // 通过Exchangers门面类,创建ExchangeServer对象 + + ExchangeServer server = Exchangers.bind(url, requestHandler); + + ... // 检测CLIENT_KEY参数指定的Transporter扩展实现是否合法(略) + + // 将ExchangeServer封装成DubboProtocolServer返回 + + return new DubboProtocolServer(server); + +} + + +在 createServer() 方法中还有几个细节需要展开分析一下。第一个是创建 ExchangeServer 时,使用的 Codec2 接口实现实际上是 DubboCountCodec,对应的 SPI 配置文件如下: + + + +Codec2 SPI 配置文件 + +DubboCountCodec 中维护了一个 DubboCodec 对象,编解码的能力都是 DubboCodec 提供的,DubboCountCodec 只负责在解码过程中 ChannelBuffer 的 readerIndex 指针控制,具体实现如下: + +public Object decode(Channel channel, ChannelBuffer buffer) throws IOException { + + int save = buffer.readerIndex(); // 首先保存readerIndex指针位置 + + // 创建MultiMessage对象,其中可以存储多条消息 + + MultiMessage result = MultiMessage.create(); + + do { + + // 通过DubboCodec提供的解码能力解码一条消息 + + Object obj = codec.decode(channel, buffer); + + // 如果可读字节数不足一条消息,则会重置readerIndex指针 + + if (Codec2.DecodeResult.NEED_MORE_INPUT == obj) { + + buffer.readerIndex(save); + + break; + + } else { // 将成功解码的消息添加到MultiMessage中暂存 + + result.addMessage(obj); + + logMessageLength(obj, buffer.readerIndex() - save); + + save = buffer.readerIndex(); + + } + + } while (true); + + if (result.isEmpty()) { // 一条消息也未解码出来,则返回NEED_MORE_INPUT错误码 + + return Codec2.DecodeResult.NEED_MORE_INPUT; + + } + + if (result.size() == 1) { // 只解码出来一条消息,则直接返回该条消息 + + return result.get(0); + + } + + // 解码出多条消息的话,会将MultiMessage返回 + + return result; + +} + + +DubboCountCodec、DubboCodec 都实现了第 22 课时介绍的 Codec2 接口,其中 DubboCodec 是 ExchangeCodec 的子类。 + + + +DubboCountCodec 及 DubboCodec 继承关系图 + +我们知道 ExchangeCodec 只处理了 Dubbo 协议的请求头,而 DubboCodec 则是通过继承的方式,在 ExchangeCodec 基础之上,添加了解析 Dubbo 消息体的功能。在第 22 课时介绍 ExchangeCodec 实现的时候,我们重点分析了 encodeRequest() 方法,即 Request 请求的编码实现,其中会调用 encodeRequestData() 方法完成请求体的编码。 + +DubboCodec 中就覆盖了 encodeRequestData() 方法,按照 Dubbo 协议的格式编码 Request 请求体,具体实现如下: + +protected void encodeRequestData(Channel channel, ObjectOutput out, Object data, String version) throws IOException { + + // 请求体相关的内容,都封装在了RpcInvocation + + RpcInvocation inv = (RpcInvocation) data; + + out.writeUTF(version); // 写入版本号 + + String serviceName = inv.getAttachment(INTERFACE_KEY); + + if (serviceName == null) { + + serviceName = inv.getAttachment(PATH_KEY); + + } + + // 写入服务名称 + + out.writeUTF(serviceName); + + // 写入Service版本号 + + out.writeUTF(inv.getAttachment(VERSION_KEY)); + + // 写入方法名称 + + out.writeUTF(inv.getMethodName()); + + // 写入参数类型列表 + + out.writeUTF(inv.getParameterTypesDesc()); + + // 依次写入全部参数 + + Object[] args = inv.getArguments(); + + if (args != null) { + + for (int i = 0; i < args.length; i++) { + + out.writeObject(encodeInvocationArgument(channel, inv, i)); + + } + + } + + // 依次写入全部的附加信息 + + out.writeAttachments(inv.getObjectAttachments()); + +} + + +RpcInvocation 实现了上一课时介绍的 Invocation 接口,如下图所示: + + + +RpcInvocation 继承关系图 + +下面是 RpcInvocation 中的核心字段,通过读写这些字段即可实现 Invocation 接口的全部方法。 + + +targetServiceUniqueName(String类型):要调用的唯一服务名称,其实就是 ServiceKey,即 interface/group:version 三部分构成的字符串。 +methodName(String类型):调用的目标方法名称。 +serviceName(String类型):调用的目标服务名称,示例中就是org.apache.dubbo.demo.DemoService。 +parameterTypes(Class[]类型):记录了目标方法的全部参数类型。 +parameterTypesDesc(String类型):参数列表签名。 +arguments(Object[]类型):具体参数值。 +attachments(Map类型):此次调用的附加信息,可以被序列化到请求中。 +attributes(Map类型):此次调用的属性信息,这些信息不能被发送出去。 +invoker(Invoker类型):此次调用关联的 Invoker 对象。 +returnType(Class类型):返回值的类型。 +invokeMode(InvokeMode类型):此次调用的模式,分为 SYNC、ASYNC 和 FUTURE 三类。 + + +我们在上面的继承图中看到 RpcInvocation 的一个子类—— DecodeableRpcInvocation,它是用来支持解码的,其实现的 decode() 方法正好是 DubboCodec.encodeRequestData() 方法对应的解码操作,在 DubboCodec.decodeBody() 方法中就调用了这个方法,调用关系如下图所示: + + + +decode() 方法调用栈 + +这个解码过程中有个细节,在 DubboCodec.decodeBody() 方法中有如下代码片段,其中会根据 DECODE_IN_IO_THREAD_KEY 这个参数决定是否在 DubboCodec 中进行解码(DubboCodec 是在 IO 线程中调用的)。 + +// decode request. + +Request req = new Request(id); + +... // 省略Request中其他字段的设置 + +Object data; + +DecodeableRpcInvocation inv; + +// 这里会检查DECODE_IN_IO_THREAD_KEY参数 + +if (channel.getUrl().getParameter(DECODE_IN_IO_THREAD_KEY, DEFAULT_DECODE_IN_IO_THREAD)) { + + inv = new DecodeableRpcInvocation(channel, req, is, proto); + + inv.decode(); // 直接调用decode()方法在当前IO线程中解码 + +} else { // 这里只是读取数据,不会调用decode()方法在当前IO线程中进行解码 + + inv = new DecodeableRpcInvocation(channel, req, + + new UnsafeByteArrayInputStream(readMessageData(is)), proto); + +} + +data = inv; + +req.setData(data); // 设置到Request请求的data字段 + +return req; + + +如果不在 DubboCodec 中解码,那会在哪里解码呢?你可以回顾第 20 课时介绍的 DecodeHandler(Transport 层),它的 received() 方法也是可以进行解码的,另外,DecodeableRpcInvocation 中有一个 hasDecoded 字段来判断当前是否已经完成解码,这样,三者配合就可以根据 DECODE_IN_IO_THREAD_KEY 参数决定执行解码操作的线程了。 + +如果你对线程模型不清楚,可以依次回顾一下 Exchangers、HeaderExchanger、Transporters 三个门面类的 bind() 方法,以及 Dispatcher 各实现提供的线程模型,搞清楚各个 ChannelHandler 是由哪个线程执行的,这些知识点在前面课时都介绍过了,不再重复。这里我们就直接以 AllDispatcher 实现为例给出结论。 + + +IO 线程内执行的 ChannelHandler 实现依次有:InternalEncoder、InternalDecoder(两者底层都是调用 DubboCodec)、IdleStateHandler、MultiMessageHandler、HeartbeatHandler 和 NettyServerHandler。 +在非 IO 线程内执行的 ChannelHandler 实现依次有:DecodeHandler、HeaderExchangeHandler 和 DubboProtocol$requestHandler。 + + +在 DubboProtocol 中有一个 requestHandler 字段,它是一个实现了 ExchangeHandlerAdapter 抽象类的匿名内部类的实例,间接实现了 ExchangeHandler 接口,其核心是 reply() 方法,具体实现如下: + +public CompletableFuture reply(ExchangeChannel channel, Object message) throws RemotingException { + + ... // 这里省略了检查message类型的逻辑,通过前面Handler的处理,这里收到的message必须是Invocation类型的对象 + + Invocation inv = (Invocation) message; + + // 获取此次调用Invoker对象 + + Invoker invoker = getInvoker(channel, inv); + + ... // 针对客户端回调的内容,在后面详细介绍,这里不再展开分析 + + // 将客户端的地址记录到RpcContext中 + + RpcContext.getContext().setRemoteAddress(channel.getRemoteAddress()); + + // 执行真正的调用 + + Result result = invoker.invoke(inv); + + // 返回结果 + + return result.thenApply(Function.identity()); + +} + + +其中 getInvoker() 方法会先根据 Invocation 携带的信息构造 ServiceKey,然后从 exporterMap 集合中查找对应的 DubboExporter 对象,并从中获取底层的 Invoker 对象返回,具体实现如下: + +Invoker getInvoker(Channel channel, Invocation inv) throws RemotingException { + + ... // 省略对客户端Callback以及stub的处理逻辑,后面单独介绍 + + String serviceKey = serviceKey(port, path, (String) inv.getObjectAttachments().get(VERSION_KEY), + + (String) inv.getObjectAttachments().get(GROUP_KEY)); + + DubboExporter exporter = (DubboExporter) exporterMap.get(serviceKey); + + ... // 查找不到相应的DubboExporter对象时,会直接抛出异常,这里省略了这个检测 + + return exporter.getInvoker(); // 获取exporter中获取Invoker对象 + +} + + +到这里,我们终于见到了对 Invoker 对象的调用,对 Invoker 实现的介绍和分析,在后面课时我们会深入介绍,这里就先专注于 DubboProtocol 的相关内容。 + +3. 序列化优化处理 + +下面我们回到 DubboProtocol.export() 方法继续分析,在完成 ProtocolServer 的启动之后,export() 方法最后会调用 optimizeSerialization() 方法对指定的序列化算法进行优化。 + +这里先介绍一个基础知识,在使用某些序列化算法(例如, Kryo、FST 等)时,为了让其能发挥出最佳的性能,最好将那些需要被序列化的类提前注册到 Dubbo 系统中。例如,我们可以通过一个实现了 SerializationOptimizer 接口的优化器,并在配置中指定该优化器,如下示例代码: + +public class SerializationOptimizerImpl implements SerializationOptimizer { + + public Collection getSerializableClasses() { + + List classes = new ArrayList<>(); + + classes.add(xxxx.class); // 添加需要被序列化的类 + + return classes; + + } + +} + + +在 DubboProtocol.optimizeSerialization() 方法中,就会获取该优化器中注册的类,通知底层的序列化算法进行优化,序列化的性能将会被大大提升。当然,在进行序列化的时候,难免会级联到很多 Java 内部的类(例如,数组、各种集合类型等),Kryo、FST 等序列化算法已经自动将JDK 中的常用类进行了注册,所以无须重复注册它们。 + +下面我们回头来看 optimizeSerialization() 方法,分析序列化优化操作的具体实现细节: + +private void optimizeSerialization(URL url) throws RpcException { + + // 根据URL中的optimizer参数值,确定SerializationOptimizer接口的实现类 + + String className = url.getParameter(OPTIMIZER_KEY, ""); + + Class clazz = Thread.currentThread().getContextClassLoader().loadClass(className); + + // 创建SerializationOptimizer实现类的对象 + + SerializationOptimizer optimizer = (SerializationOptimizer) clazz.newInstance(); + + // 调用getSerializableClasses()方法获取需要注册的类 + + for (Class c : optimizer.getSerializableClasses()) { + + SerializableClassRegistry.registerClass(c); + + } + + optimizers.add(className); + +} + + +SerializableClassRegistry 底层维护了一个 static 的 Map(REGISTRATIONS 字段),registerClass() 方法就是将待优化的类写入该集合中暂存,在使用 Kryo、FST 等序列化算法时,会读取该集合中的类,完成注册操作,相关的调用关系如下图所示: + + + +getRegisteredClasses() 方法的调用位置 + +按照 Dubbo 官方文档的说法,即使不注册任何类进行优化,Kryo 和 FST 的性能依然普遍优于Hessian2 和 Dubbo 序列化。 + +总结 + +本课时我们重点介绍了 DubboProtocol 发布一个 Dubbo 服务的核心流程。首先,我们介绍了 AbstractProtocol 这个抽象类为 Protocol 实现类提供的公共能力和字段,然后我们结合 Dubbo 协议对应的 DubboProtocol 实现,讲解了发布一个 Dubbo 服务的核心流程,其中涉及整个服务端核心启动流程、RpcInvocation 实现、DubboProtocol.requestHandler 字段调用 Invoker 对象以及序列化相关的优化处理等内容。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/25从Protocol起手,看服务暴露和服务引用的全流程(下).md b/专栏/Dubbo源码解读与实战-完/25从Protocol起手,看服务暴露和服务引用的全流程(下).md new file mode 100644 index 0000000..db61c88 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/25从Protocol起手,看服务暴露和服务引用的全流程(下).md @@ -0,0 +1,370 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 从 Protocol 起手,看服务暴露和服务引用的全流程(下) + 在上一课时,我们以 DubboProtocol 实现为基础,详细介绍了 Dubbo 服务发布的核心流程。在本课时,我们继续介绍 DubboProtocol 中服务引用相关的实现。 + +refer 流程 + +下面我们开始介绍 DubboProtocol 中引用服务的相关实现,其核心实现在 protocolBindingRefer() 方法中: + +public Invoker protocolBindingRefer(Class serviceType, URL url) throws RpcException { + + optimizeSerialization(url); // 进行序列化优化,注册需要优化的类 + + // 创建DubboInvoker对象 + + DubboInvoker invoker = new DubboInvoker(serviceType, url, getClients(url), invokers); + + // 将上面创建DubboInvoker对象添加到invoker集合之中 + + invokers.add(invoker); + + return invoker; + +} + + +关于 DubboInvoker 的具体实现,我们先暂时不做深入分析。这里我们需要先关注的是getClients() 方法,它创建了底层发送请求和接收响应的 Client 集合,其核心分为了两个部分,一个是针对共享连接的处理,另一个是针对独享连接的处理,具体实现如下: + +private ExchangeClient[] getClients(URL url) { + + // 是否使用共享连接 + + boolean useShareConnect = false; + + // CONNECTIONS_KEY参数值决定了后续建立连接的数量 + + int connections = url.getParameter(CONNECTIONS_KEY, 0); + + List shareClients = null; + + if (connections == 0) { // 如果没有连接数的相关配置,默认使用共享连接的方式 + + useShareConnect = true; + + // 确定建立共享连接的条数,默认只建立一条共享连接 + + String shareConnectionsStr = url.getParameter(SHARE_CONNECTIONS_KEY, (String) null); + + connections = Integer.parseInt(StringUtils.isBlank(shareConnectionsStr) ? ConfigUtils.getProperty(SHARE_CONNECTIONS_KEY, + + DEFAULT_SHARE_CONNECTIONS) : shareConnectionsStr); + + // 创建公共ExchangeClient集合 + + shareClients = getSharedClient(url, connections); + + } + + // 整理要返回的ExchangeClient集合 + + ExchangeClient[] clients = new ExchangeClient[connections]; + + for (int i = 0; i < clients.length; i++) { + + if (useShareConnect) { + + clients[i] = shareClients.get(i); + + } else { + + // 不使用公共连接的情况下,会创建单独的ExchangeClient实例 + + clients[i] = initClient(url); + + } + + } + + return clients; + +} + + +当使用独享连接的时候,对每个 Service 建立固定数量的 Client,每个 Client 维护一个底层连接。如下图所示,就是针对每个 Service 都启动了两个独享连接: + + + +Service 独享连接示意图 + +当使用共享连接的时候,会区分不同的网络地址(host:port),一个地址只建立固定数量的共享连接。如下图所示,Provider 1 暴露了多个服务,Consumer 引用了 Provider 1 中的多个服务,共享连接是说 Consumer 调用 Provider 1 中的多个服务时,是通过固定数量的共享 TCP 长连接进行数据传输,这样就可以达到减少服务端连接数的目的。 + + + +Service 共享连接示意图 + +那怎么去创建共享连接呢?创建共享连接的实现细节是在 getSharedClient() 方法中,它首先从 referenceClientMap 缓存(Map`> 类型)中查询 Key(host 和 port 拼接成的字符串)对应的共享 Client 集合,如果查找到的 Client 集合全部可用,则直接使用这些缓存的 Client,否则要创建新的 Client 来补充替换缓存中不可用的 Client。示例代码如下: + +private List getSharedClient(URL url, int connectNum) { + + String key = url.getAddress(); // 获取对端的地址(host:port) + + // 从referenceClientMap集合中,获取与该地址连接的ReferenceCountExchangeClient集合 + + List clients = referenceClientMap.get(key); + + // checkClientCanUse()方法中会检测clients集合中的客户端是否全部可用 + + if (checkClientCanUse(clients)) { + + batchClientRefIncr(clients); // 客户端全部可用时 + + return clients; + + } + + locks.putIfAbsent(key, new Object()); + + synchronized (locks.get(key)) { // 针对指定地址的客户端进行加锁,分区加锁可以提高并发度 + + clients = referenceClientMap.get(key); + + if (checkClientCanUse(clients)) { // double check,再次检测客户端是否全部可用 + + batchClientRefIncr(clients); // 增加应用Client的次数 + + return clients; + + } + + connectNum = Math.max(connectNum, 1); // 至少一个共享连接 + + // 如果当前Clients集合为空,则直接通过initClient()方法初始化所有共享客户端 + + if (CollectionUtils.isEmpty(clients)) { + + clients = buildReferenceCountExchangeClientList(url, connectNum); + + referenceClientMap.put(key, clients); + + } else { // 如果只有部分共享客户端不可用,则只需要处理这些不可用的客户端 + + for (int i = 0; i < clients.size(); i++) { + + ReferenceCountExchangeClient referenceCountExchangeClient = clients.get(i); + + if (referenceCountExchangeClient == null || referenceCountExchangeClient.isClosed()) { + + clients.set(i, buildReferenceCountExchangeClient(url)); + + continue; + + } + + // 增加引用 + + referenceCountExchangeClient.incrementAndGetCount(); + + } + + } + + // 清理locks集合中的锁对象,防止内存泄漏,如果key对应的服务宕机或是下线, + + // 这里不进行清理的话,这个用于加锁的Object对象是无法被GC的,从而出现内存泄漏 + + locks.remove(key); + + return clients; + + } + +} + + +这里使用的 ExchangeClient 实现是 ReferenceCountExchangeClient,它是 ExchangeClient 的一个装饰器,在原始 ExchangeClient 对象基础上添加了引用计数的功能。 + +ReferenceCountExchangeClient 中除了持有被修饰的 ExchangeClient 对象外,还有一个 referenceCount 字段(AtomicInteger 类型),用于记录该 Client 被应用的次数。从下图中我们可以看到,在 ReferenceCountExchangeClient 的构造方法以及 incrementAndGetCount() 方法中会增加引用次数,在 close() 方法中则会减少引用次数。 + + + +referenceCount 修改调用栈 + +这样,对于同一个地址的共享连接,就可以满足两个基本需求: + + +当引用次数减到 0 的时候,ExchangeClient 连接关闭; +当引用次数未减到 0 的时候,底层的 ExchangeClient 不能关闭。 + + +还有一个需要注意的细节是 ReferenceCountExchangeClient.close() 方法,在关闭底层 ExchangeClient 对象之后,会立即创建一个 LazyConnectExchangeClient ,也有人称其为“幽灵连接”。具体逻辑如下所示,这里的 LazyConnectExchangeClient 主要用于异常情况的兜底: + +public void close(int timeout) { + + // 引用次数减到0,关闭底层的ExchangeClient,具体操作有:停掉心跳任务、重连任务以及关闭底层Channel,这些在前文介绍HeaderExchangeClient的时候已经详细分析过了,这里不再赘述 + + if (referenceCount.decrementAndGet() <= 0) { + + if (timeout == 0) { + + client.close(); + + } else { + + client.close(timeout); + + } + + // 创建LazyConnectExchangeClient,并将client字段指向该对象 + + replaceWithLazyClient(); + + } + +} + +private void replaceWithLazyClient() { + + // 在原有的URL之上,添加一些LazyConnectExchangeClient特有的参数 + + URL lazyUrl = URLBuilder.from(url) + + .addParameter(LAZY_CONNECT_INITIAL_STATE_KEY, Boolean.TRUE) + + .addParameter(RECONNECT_KEY, Boolean.FALSE) + + .addParameter(SEND_RECONNECT_KEY, Boolean.TRUE.toString()) + + .addParameter("warning", Boolean.TRUE.toString()) + + .addParameter(LazyConnectExchangeClient.REQUEST_WITH_WARNING_KEY, true) + + .addParameter("_client_memo", "referencecounthandler.replacewithlazyclient") + + .build(); + + // 如果当前client字段已经指向了LazyConnectExchangeClient,则不需要再次创建LazyConnectExchangeClient兜底了 + + if (!(client instanceof LazyConnectExchangeClient) || client.isClosed()) { + + // ChannelHandler依旧使用原始ExchangeClient使用的Handler,即DubboProtocol中的requestHandler字段 + + client = new LazyConnectExchangeClient(lazyUrl, client.getExchangeHandler()); + + } + +} + + +LazyConnectExchangeClient 也是 ExchangeClient 的装饰器,它会在原有 ExchangeClient 对象的基础上添加懒加载的功能。LazyConnectExchangeClient 在构造方法中不会创建底层持有连接的 Client,而是在需要发送请求的时候,才会调用 initClient() 方法进行 Client 的创建,如下图调用关系所示: + + + +initClient() 方法的调用位置 + +initClient() 方法的具体实现如下: + +private void initClient() throws RemotingException { + + if (client != null) { // 底层Client已经初始化过了,这里不再初始化 + + return; + + } + + connectLock.lock(); + + try { + + if (client != null) { return; } // double check + + // 通过Exchangers门面类,创建ExchangeClient对象 + + this.client = Exchangers.connect(url, requestHandler); + + } finally { + + connectLock.unlock(); + + } + +} + + +在这些发送请求的方法中,除了通过 initClient() 方法初始化底层 ExchangeClient 外,还会调用warning() 方法,其会根据当前 URL 携带的参数决定是否打印 WARN 级别日志。为了防止瞬间打印大量日志的情况发生,这里有打印的频率限制,默认每发送 5000 次请求打印 1 条日志。你可以看到在前面展示的兜底场景中,我们就开启了打印日志的选项。 + +分析完 getSharedClient() 方法创建共享 Client 的核心流程之后,我们回到 DubboProtocol 中,继续介绍创建独享 Client 的流程。 + +创建独享 Client 的入口在DubboProtocol.initClient() 方法,它首先会在 URL 中设置一些默认的参数,然后根据 LAZY_CONNECT_KEY 参数决定是否使用 LazyConnectExchangeClient 进行封装,实现懒加载功能,如下代码所示: + +private ExchangeClient initClient(URL url) { + + // 获取客户端扩展名并进行检查,省略检测的逻辑 + + String str = url.getParameter(CLIENT_KEY, url.getParameter(SERVER_KEY, DEFAULT_REMOTING_CLIENT)); + + // 设置Codec2的扩展名 + + url = url.addParameter(CODEC_KEY, DubboCodec.NAME); + + // 设置默认的心跳间隔 + + url = url.addParameterIfAbsent(HEARTBEAT_KEY, String.valueOf(DEFAULT_HEARTBEAT)); + + ExchangeClient client; + + // 如果配置了延迟创建连接的特性,则创建LazyConnectExchangeClient + + if (url.getParameter(LAZY_CONNECT_KEY, false)) { + + client = new LazyConnectExchangeClient(url, requestHandler); + + } else { // 未使用延迟连接功能,则直接创建HeaderExchangeClient + + client = Exchangers.connect(url, requestHandler); + + } + + return client; + +} + + +这里涉及的 LazyConnectExchangeClient 装饰器以及 Exchangers 门面类在前面已经深入分析过了,就不再赘述了。 + +DubboProtocol 中还剩下几个方法没有介绍,这里你只需要简单了解一下它们的实现即可。 + + +batchClientRefIncr() 方法:会遍历传入的集合,将其中的每个 ReferenceCountExchangeClient 对象的引用加一。 +buildReferenceCountExchangeClient() 方法:会调用上面介绍的 initClient() 创建 Client 对象,然后再包装一层 ReferenceCountExchangeClient 进行修饰,最后返回。该方法主要用于创建共享 Client。 + + +destroy方法 + +在 DubboProtocol 销毁的时候,会调用 destroy() 方法释放底层资源,其中就涉及 export 流程中创建的 ProtocolServer 对象以及 refer 流程中创建的 Client。 + +DubboProtocol.destroy() 方法首先会逐个关闭 serverMap 集合中的 ProtocolServer 对象,相关代码片段如下: + +for (String key : new ArrayList<>(serverMap.keySet())) { + + ProtocolServer protocolServer = serverMap.remove(key); + + if (protocolServer == null) { continue;} + + RemotingServer server = protocolServer.getRemotingServer(); + + // 在close()方法中,发送ReadOnly请求、阻塞指定时间、关闭底层的定时任务、关闭相关线程池,最终,会断开所有连接,关闭Server。这些逻辑在前文介绍HeaderExchangeServer、NettyServer等实现的时候,已经详细分析过了,这里不再展开 + + server.close(ConfigurationUtils.getServerShutdownTimeout()); + +} + + +ConfigurationUtils.getServerShutdownTimeout() 方法返回的阻塞时长默认是 10 秒,我们可以通过 dubbo.service.shutdown.wait 或是 dubbo.service.shutdown.wait.seconds 进行配置。 + +之后,DubboProtocol.destroy() 方法会逐个关闭 referenceClientMap 集合中的 Client,逻辑与上述关闭ProtocolServer的逻辑相同,这里不再重复。只不过需要注意前面我们提到的 ReferenceCountExchangeClient 的存在,只有引用减到 0,底层的 Client 才会真正销毁。 + +最后,DubboProtocol.destroy() 方法会调用父类 AbstractProtocol 的 destroy() 方法,销毁全部 Invoker 对象,前面已经介绍过 AbstractProtocol.destroy() 方法的实现,这里也不再重复。 + +总结 + +本课时我们继续上一课时的话题,以 DubboProtocol 为例,介绍了 Dubbo 在 Protocol 层实现服务引用的核心流程。我们首先介绍了 DubboProtocol 初始化 Client 的核心逻辑,分析了共享连接和独立连接的模型,后续还讲解了ReferenceCountExchangeClient、LazyConnectExchangeClient 等装饰器的功能和实现,最后说明了 destroy() 方法释放底层资源的相关实现。 + +关于 DubboProtocol,你若还有什么疑问或想法,欢迎你留言跟我分享。下一课时,我们将开始深入介绍 Dubbo 的“心脏”—— Invoker 接口的相关实现,这是我们的一篇加餐文章,记得按时来听课。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/26加餐:直击Dubbo“心脏”,带你一起探秘Invoker(上).md b/专栏/Dubbo源码解读与实战-完/26加餐:直击Dubbo“心脏”,带你一起探秘Invoker(上).md new file mode 100644 index 0000000..46dd79d --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/26加餐:直击Dubbo“心脏”,带你一起探秘Invoker(上).md @@ -0,0 +1,381 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(上) + 在前面课时介绍 DubboProtocol 的时候我们看到,上层业务 Bean 会被封装成 Invoker 对象,然后传入 DubboProtocol.export() 方法中,该 Invoker 被封装成 DubboExporter,并保存到 exporterMap 集合中缓存。 + +在 DubboProtocol 暴露的 ProtocolServer 收到请求时,经过一系列解码处理,最终会到达 DubboProtocol.requestHandler 这个 ExchangeHandler 对象中,该 ExchangeHandler 对象会从 exporterMap 集合中取出请求的 Invoker,并调用其 invoke() 方法处理请求。 + +DubboProtocol.protocolBindingRefer() 方法则会将底层的 ExchangeClient 集合封装成 DubboInvoker,然后由上层逻辑封装成代理对象,这样业务层就可以像调用本地 Bean 一样,完成远程调用。 + +深入 Invoker + +首先,我们来看 AbstractInvoker 这个抽象类,它继承了 Invoker 接口,继承关系如下图所示: + + + +AbstractInvoker 继承关系示意图 + +从图中可以看到,最核心的 DubboInvoker 继承自AbstractInvoker 抽象类,AbstractInvoker 的核心字段有如下几个。 + + +type(Class 类型):该 Invoker 对象封装的业务接口类型,例如 Demo 示例中的 DemoService 接口。 +url(URL 类型):与当前 Invoker 关联的 URL 对象,其中包含了全部的配置信息。 +attachment(Map 类型):当前 Invoker 关联的一些附加信息,这些附加信息可以来自关联的 URL。在 AbstractInvoker 的构造函数的某个重载中,会调用 convertAttachment() 方法,其中就会从关联的 URL 对象获取指定的 KV 值记录到 attachment 集合中。 +available(volatile boolean类型)、destroyed(AtomicBoolean 类型):这两个字段用来控制当前 Invoker 的状态。available 默认值为 true,destroyed 默认值为 false。在 destroy() 方法中会将 available 设置为 false,将 destroyed 字段设置为 true。 + + +在 AbstractInvoker 中实现了 Invoker 接口中的 invoke() 方法,这里有点模板方法模式的感觉,其中先对 URL 中的配置信息以及 RpcContext 中携带的附加信息进行处理,添加到 Invocation 中作为附加信息,然后调用 doInvoke() 方法发起远程调用(该方法由 AbstractInvoker 的子类具体实现),最后得到 AsyncRpcResult 对象返回。 + +public Result invoke(Invocation inv) throws RpcException { + + // 首先将传入的Invocation转换为RpcInvocation + + RpcInvocation invocation = (RpcInvocation) inv; + + invocation.setInvoker(this); + + // 将前文介绍的attachment集合添加为Invocation的附加信息 + + if (CollectionUtils.isNotEmptyMap(attachment)) { + + invocation.addObjectAttachmentsIfAbsent(attachment); + + } + + // 将RpcContext的附加信息添加为Invocation的附加信息 + + Map contextAttachments = RpcContext.getContext().getObjectAttachments(); + + if (CollectionUtils.isNotEmptyMap(contextAttachments)) { + + invocation.addObjectAttachments(contextAttachments); + + } + + // 设置此次调用的模式,异步还是同步 + + invocation.setInvokeMode(RpcUtils.getInvokeMode(url, invocation)); + + // 如果是异步调用,给这次调用添加一个唯一ID + + RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation); + + AsyncRpcResult asyncResult; + + try { // 调用子类实现的doInvoke()方法 + + asyncResult = (AsyncRpcResult) doInvoke(invocation); + + } catch (InvocationTargetException e) {// 省略异常处理的逻辑 + + } catch (RpcException e) { // 省略异常处理的逻辑 + + } catch (Throwable e) { + + asyncResult = AsyncRpcResult.newDefaultAsyncResult(null, e, invocation); + + } + + RpcContext.getContext().setFuture(new FutureAdapter(asyncResult.getResponseFuture())); + + return asyncResult; + +} + + +接下来,需要深入介绍的第一个类是 RpcContext。 + +RpcContext + +RpcContext 是线程级别的上下文信息,每个线程绑定一个 RpcContext 对象,底层依赖 ThreadLocal 实现。RpcContext 主要用于存储一个线程中一次请求的临时状态,当线程处理新的请求(Provider 端)或是线程发起新的请求(Consumer 端)时,RpcContext 中存储的内容就会更新。 + +下面来看 RpcContext 中两个InternalThreadLocal的核心字段,这两个字段的定义如下所示: + +// 在发起请求时,会使用该RpcContext来存储上下文信息 + +private static final InternalThreadLocal LOCAL = new InternalThreadLocal() { + + @Override + + protected RpcContext initialValue() { + + return new RpcContext(); + + } + +}; + +// 在接收到响应的时候,会使用该RpcContext来存储上下文信息 + +private static final InternalThreadLocal SERVER_LOCAL = ... + + +JDK 提供的 ThreadLocal 底层实现大致如下:对于不同线程创建对应的 ThreadLocalMap,用于存放线程绑定信息,当用户调用ThreadLocal.get() 方法获取变量时,底层会先获取当前线程 Thread,然后获取绑定到当前线程 Thread 的 ThreadLocalMap,最后将当前 ThreadLocal 对象作为 Key 去 ThreadLocalMap 表中获取线程绑定的数据。ThreadLocal.set() 方法的逻辑与之类似,首先会获取绑定到当前线程的 ThreadLocalMap,然后将 ThreadLocal 实例作为 Key、待存储的数据作为 Value 存储到 ThreadLocalMap 中。 + +Dubbo 的 InternalThreadLocal 与 JDK 提供的 ThreadLocal 功能类似,只是底层实现略有不同,其底层的 InternalThreadLocalMap 采用数组结构存储数据,直接通过 index 获取变量,相较于 Map 方式计算 hash 值的性能更好。 + +这里我们来介绍一下 dubbo-common 模块中的 InternalThread 这个类,它继承了 Thread 类,Dubbo 的线程工厂 NamedInternalThreadFactory 创建的线程类其实都是 InternalThread 实例对象,你可以回顾前面第 19 课时介绍的 ThreadPool 接口实现,它们都是通过 NamedInternalThreadFactory 这个工厂类来创建线程的。 + +InternalThread 中主要提供了 setThreadLocalMap() 和 threadLocalMap() 两个方法,用于设置和获取 InternalThreadLocalMap。InternalThreadLocalMap 中的核心字段有如下四个。 + + +indexedVariables(Object[] 类型):用于存储绑定到当前线程的数据。 +NEXT_INDEX(AtomicInteger 类型):自增索引,用于计算下次存储到 indexedVariables 数组中的位置,这是一个静态字段。 +slowThreadLocalMap(ThreadLocal 类型):当使用原生 Thread 的时候,会使用该 ThreadLocal 存储 InternalThreadLocalMap,这是一个降级策略。 +UNSET(Object 类型):当一个与线程绑定的值被删除之后,会被设置为 UNSET 值。 + + +在 InternalThreadLocalMap 中获取当前线程绑定的InternalThreadLocaMap的静态方法,都会与 slowThreadLocalMap 字段配合实现降级,也就是说,如果当前线程为原生 Thread 类型,则根据 slowThreadLocalMap 获取InternalThreadLocalMap。这里我们以 getIfSet() 方法为例: + +public static InternalThreadLocalMap getIfSet() { + + Thread thread = Thread.currentThread(); // 获取当前线程 + + if (thread instanceof InternalThread) { // 判断当前线程的类型 + + // 如果是InternalThread类型,直接获取InternalThreadLocalMap返回 + + return ((InternalThread) thread).threadLocalMap(); + + } + + // 原生Thread则需要通过ThreadLocal获取InternalThreadLocalMap + + return slowThreadLocalMap.get(); + +} + + +InternalThreadLocalMap 中的 get()、remove()、set() 等方法都有类似的降级操作,这里不再一一重复。 + +在拿到 InternalThreadLocalMap 对象之后,我们就可以调用其 setIndexedVariable() 方法和 indexedVariable() 方法读写,这里我们得结合InternalThreadLocal进行讲解。在 InternalThreadLocal 的构造方法中,会使用 InternalThreadLocalMap.NEXT_INDEX 初始化其 index 字段(int 类型),在 InternalThreadLocal.set() 方法中就会将传入的数据存储到 InternalThreadLocalMap.indexedVariables 集合中,具体的下标位置就是这里的 index 字段值: + +public final void set(V value) { + + if (value == null|| value == InternalThreadLocalMap.UNSET){ + + remove(); // 如果要存储的值为null或是UNSERT,则直接清除 + + } else { + + // 获取当前线程绑定的InternalThreadLocalMap + + InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get(); + + // 将value存储到InternalThreadLocalMap.indexedVariables集合中 + + if (threadLocalMap.setIndexedVariable(index, value)) { + + // 将当前InternalThreadLocal记录到待删除集合中 + + addToVariablesToRemove(threadLocalMap, this); + + } + + } + +} + + +InternalThreadLocal 的静态变量 VARIABLES_TO_REMOVE_INDEX 是调用InternalThreadLocalMap 的 nextVariableIndex 方法得到的一个索引值,在 InternalThreadLocalMap 数组的对应位置保存的是 Set 类型的集合,也就是上面提到的“待删除集合”,即绑定到当前线程所有的 InternalThreadLocal,这样就可以方便管理对象及内存的释放。 + +接下来我们继续看 InternalThreadLocalMap.setIndexedVariable() 方法的实现: + +public boolean setIndexedVariable(int index, Object value) { + + Object[] lookup = indexedVariables; + + if (index < lookup.length) { // 将value存储到index指定的位置 + + Object oldValue = lookup[index]; + + lookup[index] = value; + + return oldValue == UNSET; + + } else { + + // 当index超过indexedVariables数组的长度时,需要对indexedVariables数组进行扩容 + + expandIndexedVariableTableAndSet(index, value); + + return true; + + } + +} + + +明确了设置 InternalThreadLocal 变量的流程之后,我们再来分析读取 InternalThreadLocal 变量的流程,入口在 InternalThreadLocal 的 get() 方法。 + +public final V get() { + + // 获取当前线程绑定的InternalThreadLocalMap + + InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get(); + + // 根据当前InternalThreadLocal对象的index字段,从InternalThreadLocalMap中读取相应的数据 + + Object v = threadLocalMap.indexedVariable(index); + + if (v != InternalThreadLocalMap.UNSET) { + + return (V) v; // 如果非UNSET,则表示读取到了有效数据,直接返回 + + } + + // 读取到UNSET值,则会调用initialize()方法进行初始化,其中首先会调用initialValue()方法进行初始化,然后会调用前面介绍的setIndexedVariable()方法和addToVariablesToRemove()方法存储初始化得到的值 + + return initialize(threadLocalMap); + +} + + +我们可以看到,在 RpcContext 中,LOCAL 和 SERVER_LOCAL 两个 InternalThreadLocal 类型的字段都实现了 initialValue() 方法,它们的实现都是创建并返回 RpcContext 对象。 + +理解了 InternalThreadLocal 的底层原理之后,我们回到 RpcContext 继续分析。RpcContext 作为调用的上下文信息,可以记录非常多的信息,下面介绍其中的一些核心字段。 + + +attachments(Map 类型):可用于记录调用上下文的附加信息,这些信息会被添加到 Invocation 中,并传递到远端节点。 +values(Map 类型):用来记录上下文的键值对信息,但是不会被传递到远端节点。 +methodName、parameterTypes、arguments:分别用来记录调用的方法名、参数类型列表以及具体的参数列表,与相关 Invocation 对象中的信息一致。 +localAddress、remoteAddress(InetSocketAddress 类型):记录了自己和远端的地址。 +request、response(Object 类型):可用于记录底层关联的请求和响应。 +asyncContext(AsyncContext 类型):异步Context,其中可以存储异步调用相关的 RpcContext 以及异步请求相关的 Future。 + + +DubboInvoker + +通过前面对 DubboProtocol 的分析我们知道,protocolBindingRefer() 方法会根据调用的业务接口类型以及 URL 创建底层的 ExchangeClient 集合,然后封装成 DubboInvoker 对象返回。DubboInvoker 是 AbstractInvoker 的实现类,在其 doInvoke() 方法中首先会选择此次调用使用 ExchangeClient 对象,然后确定此次调用是否需要返回值,最后调用 ExchangeClient.request() 方法发送请求,对返回的 Future 进行简单封装并返回。 + +protected Result doInvoke(final Invocation invocation) throws Throwable { + + RpcInvocation inv = (RpcInvocation) invocation; + + // 此次调用的方法名称 + + final String methodName = RpcUtils.getMethodName(invocation); + + // 向Invocation中添加附加信息,这里将URL的path和version添加到附加信息中 + + inv.setAttachment(PATH_KEY, getUrl().getPath()); + + inv.setAttachment(VERSION_KEY, version); + + ExchangeClient currentClient; // 选择一个ExchangeClient实例 + + if (clients.length == 1) { + + currentClient = clients[0]; + + } else { + + currentClient = clients[index.getAndIncrement() % clients.length]; + + } + + boolean isOneway = RpcUtils.isOneway(getUrl(), invocation); + + // 根据调用的方法名称和配置计算此次调用的超时时间 + + int timeout = calculateTimeout(invocation, methodName); + + if (isOneway) { // 不需要关注返回值的请求 + + boolean isSent = getUrl().getMethodParameter(methodName, Constants.SENT_KEY, false); + + currentClient.send(inv, isSent); + + return AsyncRpcResult.newDefaultAsyncResult(invocation); + + } else { // 需要关注返回值的请求 + + // 获取处理响应的线程池,对于同步请求,会使用ThreadlessExecutor,ThreadlessExecutor的原理前面已经分析过了,这里不再赘述;对于异步请求,则会使用共享的线程池,ExecutorRepository接口的相关设计和实现在前面已经详细分析过了,这里不再重复。 + + ExecutorService executor = getCallbackExecutor(getUrl(), inv); + + // 使用上面选出的ExchangeClient执行request()方法,将请求发送出去 + + CompletableFuture appResponseFuture = + + currentClient.request(inv, timeout, executor).thenApply(obj -> (AppResponse) obj); + + // 这里将AppResponse封装成AsyncRpcResult返回 + + AsyncRpcResult result = new AsyncRpcResult(appResponseFuture, inv); + + result.setExecutor(executor); + + return result; + + } + +} + + +在 DubboInvoker.invoke() 方法中有一些细节需要关注一下。首先是根据 URL 以及 Invocation 中的配置,决定此次调用是否为oneway 调用方式。 + +public static boolean isOneway(URL url, Invocation inv) { + + boolean isOneway; + + if (Boolean.FALSE.toString().equals(inv.getAttachment(RETURN_KEY))) { + + isOneway = true; // 首先关注的是Invocation中"return"这个附加属性 + + } else { + + isOneway = !url.getMethodParameter(getMethodName(inv), RETURN_KEY, true); // 之后关注URL中,调用方法对应的"return"配置 + + } + + return isOneway; + +} + + +oneway 指的是客户端发送消息后,不需要得到响应。所以,对于那些不关心服务端响应的请求,就比较适合使用 oneway 通信,如下图所示: + + + +oneway 和 twoway 通信方式对比图 + +可以看到发送 oneway 请求的方式是send() 方法,而后面发送 twoway 请求的方式是 request() 方法。通过之前的分析我们知道,request() 方法会相应地创建 DefaultFuture 对象以及检测超时的定时任务,而 send() 方法则不会创建这些东西,它是直接将 Invocation 包装成 oneway 类型的 Request 发送出去。 + +在服务端的 HeaderExchangeHandler.receive() 方法中,会针对 oneway 请求和 twoway 请求执行不同的分支处理:twoway 请求由 handleRequest() 方法进行处理,其中会关注调用结果并形成 Response 返回给客户端;oneway 请求则直接交给上层的 DubboProtocol.requestHandler,完成方法调用之后,不会返回任何 Response。 + +我们就结合如下示例代码来简单说明一下 HeaderExchangeHandler.request() 方法中的相关片段。 + +public void received(Channel channel, Object message) throws RemotingException { + + final ExchangeChannel exchangeChannel = HeaderExchangeChannel.getOrAddChannel(channel); + + if (message instanceof Request) { + + if (request.isTwoWay()) { + + handleRequest(exchangeChannel, request); + + } else { + + handler.received(exchangeChannel, request.getData()); + + } + + } else ... // 省略其他分支的展示 + +} + + +总结 + +本课时我们重点介绍了 Dubbo 最核心的接口—— Invoker。首先,我们介绍了 AbstractInvoker 抽象类提供的公共能力;然后分析了 RpcContext 的功能和涉及的组件,例如,InternalThreadLocal、InternalThreadLocalMap 等;最后我们说明了 DubboInvoker 对 doinvoke() 方法的实现,并区分了 oneway 和 twoway 两种类型的请求。 + +下一课时,我们将继续介绍 DubboInvoker 的实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/27加餐:直击Dubbo“心脏”,带你一起探秘Invoker(下).md b/专栏/Dubbo源码解读与实战-完/27加餐:直击Dubbo“心脏”,带你一起探秘Invoker(下).md new file mode 100644 index 0000000..064d093 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/27加餐:直击Dubbo“心脏”,带你一起探秘Invoker(下).md @@ -0,0 +1,505 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(下) + 关于 DubboInvoker,在发送完oneway 请求之后,会立即创建一个已完成状态的 AsyncRpcResult 对象(主要是其中的 responseFuture 是已完成状态)。这在上一课时我们已经讲解过了。 + +本课时我们将继续介绍 DubboInvoker 处理 twoway 请求和响应的相关实现,其中会涉及响应解码、同步/异步响应等相关内容;完成对 DubboInvoker 的分析之后,我们还会介绍 Dubbo 中与 Listener、Filter 相关的 Invoker 装饰器。 + +再探 DubboInvoker + +那 DubboInvoker 对twoway 请求的处理又是怎样的呢?接下来我们就来重点介绍下。首先,DubboInvoker 会调用 getCallbackExecutor() 方法,根据不同的 InvokeMode 返回不同的线程池实现,代码如下: + +protected ExecutorService getCallbackExecutor(URL url, Invocation inv) { + + ExecutorService sharedExecutor = ExtensionLoader.getExtensionLoader(ExecutorRepository.class).getDefaultExtension().getExecutor(url); + + if (InvokeMode.SYNC == RpcUtils.getInvokeMode(getUrl(), inv)) { + + return new ThreadlessExecutor(sharedExecutor); + + } else { + + return sharedExecutor; + + } + +} + + +InvokeMode 有三个可选值,分别是 SYNC、ASYNC 和 FUTURE。这里对于 SYNC 模式返回的线程池是 ThreadlessExecutor,至于其他两种异步模式,会根据 URL 选择对应的共享线程池。 + +SYNC 表示同步模式,是 Dubbo 的默认调用模式,具体含义如下图所示,客户端发送请求之后,客户端线程会阻塞等待服务端返回响应。 + + + +SYNC 调用模式图 + +在拿到线程池之后,DubboInvoker 就会调用 ExchangeClient.request() 方法,将 Invocation 包装成 Request 请求发送出去,同时会创建相应的 DefaultFuture 返回。注意,这里还加了一个回调,取出其中的 AppResponse 对象。AppResponse 表示的是服务端返回的具体响应,其中有三个字段。 + + +result(Object 类型):响应结果,也就是服务端返回的结果值,注意,这是一个业务上的结果值。例如,在我们前面第 01 课时的 Demo 示例(即 dubbo-demo 模块中的 Demo)中,Provider 端 DemoServiceImpl 返回的 “Hello Dubbo xxx” 这一串字符串。 +exception(Throwable 类型):服务端返回的异常信息。 +attachments(Map 类型):服务端返回的附加信息。 + + +这里请求返回的 AppResponse 你可能不太熟悉,但是其子类 DecodeableRpcResult 你可能就有点眼熟了,DecodeableRpcResult 表示的是一个响应,与其对应的是 DecodeableRpcInvocation(它表示的是请求)。在第 24 课时介绍 DubboCodec 对 Dubbo 请求体的编码流程中,我们已经详细介绍过 DecodeableRpcInvocation 了,你可以回顾一下 DubboCodec 的 decodeBody() 方法,就会发现 DecodeableRpcResult 的“身影”。 + +1. DecodeableRpcResult + +DecodeableRpcResult 解码核心流程大致如下: + + +首先,确定当前使用的序列化方式,并对字节流进行解码。 +然后,读取一个 byte 的标志位,其可选值有六种枚举,下面我们就以其中的 RESPONSE_VALUE_WITH_ATTACHMENTS 为例进行分析。 +标志位为 RESPONSE_VALUE_WITH_ATTACHMENTS 时,会先通过 handleValue() 方法处理返回值,其中会根据 RpcInvocation 中记录的返回值类型读取返回值,并设置到 result 字段。 +最后,再通过 handleAttachment() 方法读取返回的附加信息,并设置到 DecodeableRpcResult 的 attachments 字段中。 + + +public Object decode(Channel channel, InputStream input) throws IOException { + + // 反序列化 + + ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), serializationType) + + .deserialize(channel.getUrl(), input); + + byte flag = in.readByte(); // 读取一个byte的标志位 + + // 根据标志位判断当前结果中包含的信息,并调用不同的方法进行处理 + + switch (flag) { + + case DubboCodec.RESPONSE_NULL_VALUE: + + break; + + case DubboCodec.RESPONSE_VALUE: + + handleValue(in); + + break; + + case DubboCodec.RESPONSE_WITH_EXCEPTION: + + handleException(in); + + break; + + case DubboCodec.RESPONSE_NULL_VALUE_WITH_ATTACHMENTS: + + handleAttachment(in); + + break; + + case DubboCodec.RESPONSE_VALUE_WITH_ATTACHMENTS: + + handleValue(in); + + handleAttachment(in); + + break; + + case DubboCodec.RESPONSE_WITH_EXCEPTION_WITH_ATTACHMENTS: + + default: + + throw new IOException("..." ); + + } + + if (in instanceof Cleanable) { + + ((Cleanable) in).cleanup(); + + } + + return this; + +} + + +decode() 方法中其他分支的代码这里就不再展示了,你若感兴趣的话可以参考 DecodeableRpcResult 源码进行分析。 + +2. AsyncRpcResult + +在 DubboInvoker 中还有一个 AsyncRpcResult 类,它表示的是一个异步的、未完成的 RPC 调用,其中会记录对应 RPC 调用的信息(例如,关联的 RpcContext 和 Invocation 对象),包括以下几个核心字段。 + + +responseFuture(CompletableFuture 类型):这个 responseFuture 字段与前文提到的 DefaultFuture 有紧密的联系,是 DefaultFuture 回调链上的一个 Future。后面 AsyncRpcResult 之上添加的回调,实际上都是添加到这个 Future 之上。 +storedContext、storedServerContext(RpcContext 类型):用于存储相关的 RpcContext 对象。我们知道 RpcContext 是与线程绑定的,而真正执行 AsyncRpcResult 上添加的回调方法的线程可能先后处理过多个不同的 AsyncRpcResult,所以我们需要传递并保存当前的 RpcContext。 +executor(Executor 类型):此次 RPC 调用关联的线程池。 +invocation(Invocation 类型):此次 RPC 调用关联的 Invocation 对象。 + + +在 AsyncRpcResult 构造方法中,除了接收发送请求返回的 CompletableFuture 对象,还会将当前的 RpcContext 保存到 storedContext 和 storedServerContext 中,具体实现如下: + +public AsyncRpcResult(CompletableFuture future, Invocation invocation) { + + this.responseFuture = future; + + this.invocation = invocation; + + this.storedContext = RpcContext.getContext(); + + this.storedServerContext = RpcContext.getServerContext(); + +} + + +通过 whenCompleteWithContext() 方法,我们可以为 AsyncRpcResult 添加回调方法,而这个回调方法会被包装一层并注册到 responseFuture 上,具体实现如下: + +public Result whenCompleteWithContext(BiConsumer fn) { + + // 在responseFuture之上注册回调 + + this.responseFuture = this.responseFuture.whenComplete((v, t) -> { + + beforeContext.accept(v, t); + + fn.accept(v, t); + + afterContext.accept(v, t); + + }); + + return this; + +} + + +这里的 beforeContext 首先会将当前线程的 RpcContext 记录到 tmpContext 中,然后将构造函数中存储的 RpcContext 设置到当前线程中,为后面的回调执行做准备;而 afterContext 则会恢复线程原有的 RpcContext。具体实现如下: + +private RpcContext tmpContext; + +private RpcContext tmpServerContext; + +private BiConsumer beforeContext = (appResponse, t) -> { + + // 将当前线程的 RpcContext 记录到 tmpContext 中 + + tmpContext = RpcContext.getContext(); + + tmpServerContext = RpcContext.getServerContext(); + + // 将构造函数中存储的 RpcContext 设置到当前线程中 + + RpcContext.restoreContext(storedContext); + + RpcContext.restoreServerContext(storedServerContext); + +}; + +private BiConsumer afterContext = (appResponse, t) -> { + + // 将tmpContext中存储的RpcContext恢复到当前线程绑定的RpcContext + + RpcContext.restoreContext(tmpContext); + + RpcContext.restoreServerContext(tmpServerContext); + +}; + + +这样,AsyncRpcResult 就可以处于不断地添加回调而不丢失 RpcContext 的状态。总之,AsyncRpcResult 整个就是为异步请求设计的。 + +在前面的分析中我们看到,RpcInvocation.InvokeMode 字段中可以指定调用为 SYNC 模式,也就是同步调用模式,那 AsyncRpcResult 这种异步设计是如何支持同步调用的呢? 在 AbstractProtocol.refer() 方法中,Dubbo 会将 DubboProtocol.protocolBindingRefer() 方法返回的 Invoker 对象(即 DubboInvoker 对象)用 AsyncToSyncInvoker 封装一层。 + +AsyncToSyncInvoker 是 Invoker 的装饰器,负责将异步调用转换成同步调用,其 invoke() 方法的核心实现如下: + +public Result invoke(Invocation invocation) throws RpcException { + + Result asyncResult = invoker.invoke(invocation); + + if (InvokeMode.SYNC == ((RpcInvocation) invocation).getInvokeMode()) { + + // 调用get()方法,阻塞等待响应返回 + + asyncResult.get(Integer.MAX_VALUE, TimeUnit.MILLISECONDS); + + } + + return asyncResult; + +} + + +其实 AsyncRpcResult.get() 方法底层调用的就是 responseFuture 字段的 get() 方法,对于同步请求来说,会先调用 ThreadlessExecutor.waitAndDrain() 方法阻塞等待响应返回,具体实现如下所示: + +public Result get() throws InterruptedException, ExecutionException { + + if (executor != null && executor instanceof ThreadlessExecutor) { + + // 针对ThreadlessExecutor的特殊处理,这里调用waitAndDrain()等待响应 + + ThreadlessExecutor threadlessExecutor = (ThreadlessExecutor) executor; + + threadlessExecutor.waitAndDrain(); + + } + + // 非ThreadlessExecutor线程池的场景中,则直接调用Future(最底层是DefaultFuture)的get()方法阻塞 + + return responseFuture.get(); + +} + + +ThreadlessExecutor 针对同步请求的优化,我们在前面的第 20 课时已经详细介绍过了,这里不再重复。 + +最后要说明的是,AsyncRpcResult 实现了 Result 接口,如下图所示: + + + +AsyncRpcResult 继承关系图 + +AsyncRpcResult 对 Result 接口的实现,例如,getValue() 方法、recreate() 方法、getAttachments() 方法等,都会先调用 getAppResponse() 方法从 responseFuture 中拿到 AppResponse 对象,然后再调用其对应的方法。这里我们以 recreate() 方法为例,简单分析一下: + +public Result getAppResponse() { // 省略异常处理的逻辑 + + if (responseFuture.isDone()) { // 检测responseFuture是否已完成 + + return responseFuture.get(); // 获取AppResponse + + } + + // 根据调用方法的返回值,生成默认值 + + return createDefaultValue(invocation); + +} + +public Object recreate() throws Throwable { + + RpcInvocation rpcInvocation = (RpcInvocation) invocation; + + if (InvokeMode.FUTURE == rpcInvocation.getInvokeMode()) { + + return RpcContext.getContext().getFuture(); + + } + + // 调用AppResponse.recreate()方法 + + return getAppResponse().recreate(); + +} + + +AppResponse.recreate() 方法实现比较简单,如下所示: + +public Object recreate() throws Throwable { + + if (exception != null) { // 存在异常则直接抛出异常 + + // 省略处理堆栈信息的逻辑 + + throw exception; + + } + + return result; // 正常返回无异常时,直接返回result + +} + + +这里我们注意到,在 recreate() 方法中,AsyncRpcResult 会对 FUTURE 特殊处理。如果服务接口定义的返回参数是 CompletableFuture,则属于 FUTURE 模式,FUTURE 模式也属于 Dubbo 提供的一种异步调用方式,只不过是服务端异步。FUTURE 模式下拿到的 CompletableFuture 对象其实是在 AbstractInvoker 中塞到 RpcContext 中的,在 AbstractInvoker.invoke() 方法中有这么一段代码: + +RpcContext.getContext().setFuture( + + new FutureAdapter(asyncResult.getResponseFuture())); + + +这里拿到的其实就是 AsyncRpcResult 中 responseFuture,即前面介绍的 DefaultFuture。可见,无论是 SYNC 模式、ASYNC 模式还是 FUTURE 模式,都是围绕 DefaultFuture 展开的。 + +其实,在 Dubbo 2.6.x 及之前的版本提供了一定的异步编程能力,但其异步方式存在如下一些问题: + + +Future 获取方式不够直接,业务需要从 RpcContext 中手动获取。 +Future 接口无法实现自动回调,而自定义 ResponseFuture(这是 Dubbo 2.6.x 中类)虽支持回调,但支持的异步场景有限,并且还不支持 Future 间的相互协调或组合等。 +不支持 Provider 端异步。 + + +Dubbo 2.6.x 及之前版本中使用的 Future 是在 Java 5 中引入的,所以存在以上一些功能设计上的问题;而在 Java 8 中引入的 CompletableFuture 进一步丰富了 Future 接口,很好地解决了这些问题。Dubbo 在 2.7.0 版本已经升级了对 Java 8 的支持,同时基于 CompletableFuture 对当前的异步功能进行了增强,弥补了上述不足。 + +因为 CompletableFuture 实现了 CompletionStage 和 Future 接口,所以它还是可以像以前一样通过 get() 阻塞或者 isDone() 方法轮询的方式获得结果,这就保证了同步调用依旧可用。当然,在实际工作中,不是很建议用 get() 这样阻塞的方式来获取结果,因为这样就丢失了异步操作带来的性能提升。 + +另外,CompletableFuture 提供了良好的回调方法,例如,whenComplete()、whenCompleteAsync() 等方法都可以在逻辑完成后,执行该方法中添加的 action 逻辑,实现回调的逻辑。同时,CompletableFuture 很好地支持了 Future 间的相互协调或组合,例如,thenApply()、thenApplyAsync() 等方法。 + +正是由于 CompletableFuture 的增强,我们可以更加流畅地使用回调,不必因为等待一个响应而阻塞着调用线程,而是通过前面介绍的方法告诉 CompletableFuture 完成当前逻辑之后,就去执行某个特定的函数。在 Demo 示例(即 dubbo-demo 模块中的 Demo )中,返回 CompletableFuture 的 sayHelloAsync() 方法就是使用的 FUTURE 模式。 + +好了,DubboInvoker 涉及的同步调用、异步调用的原理和底层实现就介绍到这里了,我们可以通过一张流程图进行简单总结,如下所示: + + + +DubboInvoker 核心流程图 + +在 Client 端发送请求时,首先会创建对应的 DefaultFuture(其中记录了请求 ID 等信息),然后依赖 Netty 的异步发送特性将请求发送到 Server 端。需要说明的是,这整个发送过程是不会阻塞任何线程的。之后,将 DefaultFuture 返回给上层,在这个返回过程中,DefaultFuture 会被封装成 AsyncRpcResult,同时也可以添加回调函数。 + +当 Client 端接收到响应结果的时候,会交给关联的线程池(ExecutorService)或是业务线程(使用 ThreadlessExecutor 场景)进行处理,得到 Server 返回的真正结果。拿到真正的返回结果后,会将其设置到 DefaultFuture 中,并调用 complete() 方法将其设置为完成状态。此时,就会触发前面注册在 DefaulFuture 上的回调函数,执行回调逻辑。 + +Invoker 装饰器 + +除了上面介绍的 DubboInvoker 实现之外,Invoker 接口还有很多装饰器实现,这里重点介绍 Listener、Filter 相关的 Invoker 实现。 + +1. ListenerInvokerWrapper + +在前面的第 23 课时中简单提到过 InvokerListener 接口,我们可以提供其实现来监听 refer 事件以及 destroy 事件,相应地要实现 referred() 方法以及 destroyed() 方法。 + +ProtocolListenerWrapper 是 Protocol 接口的实现之一,如下图所示: + + + +ProtocolListenerWrapper 继承关系图 + +ProtocolListenerWrapper 本身是 Protocol 接口的装饰器,在其 export() 方法和 refer() 方法中,会分别在原有 Invoker 基础上封装一层 ListenerExporterWrapper 和 ListenerInvokerWrapper。 + +ListenerInvokerWrapper 是 Invoker 的装饰器,其构造方法参数列表中除了被修饰的 Invoker 外,还有 InvokerListener 列表,在构造方法内部会遍历整个 InvokerListener 列表,并调用每个 InvokerListener 的 referred() 方法,通知它们 Invoker 被引用的事件。核心逻辑如下: + +public ListenerInvokerWrapper(Invoker invoker, List listeners) { + + this.invoker = invoker; // 底层被修饰的Invoker对象 + + this.listeners = listeners; // 监听器集合 + + if (CollectionUtils.isNotEmpty(listeners)) { + + for (InvokerListener listener : listeners) { + + if (listener != null) {// 在服务引用过程中触发全部InvokerListener监听器 + + listener.referred(invoker); + + } + + } + + } + +} + + +在 ListenerInvokerWrapper.destroy() 方法中,首先会调用被修饰 Invoker 对象的 destroy() 方法,之后循环调用全部 InvokerListener 的 destroyed() 方法,通知它们该 Invoker 被销毁的事件,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +与 InvokerListener 对应的是 ExporterListener 监听器,其实现类可以通过实现 exported() 方法和 unexported() 方法监听服务暴露事件以及取消暴露事件。 + +相应地,在 ProtocolListenerWrapper 的 export() 方法中也会在原有 Invoker 之上用 ListenerExporterWrapper 进行一层封装,ListenerExporterWrapper 的构造方法中会循环调用全部 ExporterListener 的 exported() 方法,通知其服务暴露的事件,核心逻辑如下所示: + +public ListenerExporterWrapper(Exporter exporter, List listeners) { + + this.exporter = exporter; + + this.listeners = listeners; + + if (CollectionUtils.isNotEmpty(listeners)) { + + RuntimeException exception = null; + + for (ExporterListener listener : listeners) { + + if (listener != null) { + + listener.exported(this); + + } + + } + + } + +} + + +ListenerExporterWrapper.unexported() 方法的逻辑与上述 exported() 方法的实现基本类似,这里不再赘述。 + +这里介绍的 ListenerInvokerWrapper 和 ListenerExporterWrapper 都是被 @SPI 注解修饰的,我们可以提供相应的扩展实现,然后配置 SPI 文件监听这些事件。 + +2. Filter 相关的 Invoker 装饰器 + +Filter 接口是 Dubbo 为用户提供的一个非常重要的扩展接口,将各个 Filter 串联成 Filter 链并与 Invoker 实例相关。构造 Filter 链的核心逻辑位于 ProtocolFilterWrapper.buildInvokerChain() 方法中,ProtocolFilterWrapper 的 refer() 方法和 export() 方法都会调用该方法。 + +buildInvokerChain() 方法的核心逻辑如下: + + +首先会根据 URL 中携带的配置信息,确定当前激活的 Filter 扩展实现有哪些,形成 Filter 集合。 +遍历 Filter 集合,将每个 Filter 实现封装成一个匿名 Invoker,在这个匿名 Invoker 中,会调用 Filter 的 invoke() 方法执行 Filter 的逻辑,然后由 Filter 内部的逻辑决定是否将调用传递到下一个 Filter 执行。 + + +buildInvokerChain() 方法的具体实现如下: + +private static Invoker buildInvokerChain(final Invoker invoker, String key, String group) { + + Invoker last = invoker; + + // 根据 URL 中携带的配置信息,确定当前激活的 Filter 扩展实现有哪些,形成 Filter 集合 + + List filters = ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension(invoker.getUrl(), key, group); + + if (!filters.isEmpty()) { + + for (int i = filters.size() - 1; i >= 0; i--) { + + final Filter filter = filters.get(i); + + final Invoker next = last; + + // 遍历 Filter 集合,将每个 Filter 实现封装成一个匿名 Invoker + + last = new Invoker() { + + @Override + + public Result invoke(Invocation invocation) throws RpcException { + + Result asyncResult; + + try { + + // 调用 Filter 的 invoke() 方法执行 Filter 的逻辑,然后由 Filter 内部的逻辑决定是否将调用传递到下一个 Filter 执行 + + asyncResult = filter.invoke(next, invocation); + + } catch (Exception e) { + + ... // 省略异常时监听器的逻辑 + + } finally { + + } + + return asyncResult.whenCompleteWithContext((r, t) -> { + + ... // 省略监听器的处理逻辑 + + }); + + } + + }; + + } + + } + + return last; + +} + + +在 Filter 接口内部还定义了一个 Listener 接口,有一些 Filter 实现会同时实现这个内部 Listener 接口,当 invoke() 方法执行正常结束时,会调用该 Listener 的 onResponse() 方法进行通知;当 invoke() 方法执行出现异常时,会调用该 Listener 的 onError() 方法进行通知。 + +另外,还有一个 ListenableFilter 抽象类,它继承了 Filter 接口,在原有 Filter 的基础上添加了一个 listeners 集合(ConcurrentMap 集合)用来记录一次请求需要触发的监听器。需要注意的是,在执行 invoke() 调用之前,我们可以调用 addListener() 方法添加 Filter.Listener 实例进行监听,完成一次 invoke() 方法之后,这些添加的 Filter.Listener 实例就会立即从 listeners 集合中删除,也就是说,这些 Filter.Listener 实例不会在调用之间共享。 + +总结 + +本课时主要介绍的是 Dubbo 中 Invoker 接口的核心实现,这也是 Dubbo 最核心的实现之一。 + +紧接上一课时,我们分析了 DubboInvoker 对 twoway 请求的处理逻辑,其中展开介绍了涉及的 DecodeableRpcResult 以及 AsyncRpcResult 等核心类,深入讲解了 Dubbo 的同步、异步调用实现原理,说明了 Dubbo 在 2.7.x 版本之后的相关改进。最后,我们还介绍了 Invoker 接口的几个装饰器,其中涉及用于注册监听器的 ListenerInvokerWrapper 以及 Filter 相关的 Invoker 装饰器。 + +下一课时,我们将深入介绍 Dubbo RPC 层中代理的相关实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/28复杂问题简单化,代理帮你隐藏了多少底层细节?.md b/专栏/Dubbo源码解读与实战-完/28复杂问题简单化,代理帮你隐藏了多少底层细节?.md new file mode 100644 index 0000000..b98db17 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/28复杂问题简单化,代理帮你隐藏了多少底层细节?.md @@ -0,0 +1,873 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 复杂问题简单化,代理帮你隐藏了多少底层细节? + 在前面介绍 DubboProtocol 的相关实现时,我们知道 Protocol 这一层以及后面介绍的 Cluster 层暴露出来的接口都是 Dubbo 内部的一些概念,业务层无法直接使用。为了让业务逻辑能够无缝使用 Dubbo,我们就需要将业务逻辑与 Dubbo 内部概念打通,这就用到了动态生成代理对象的功能。Proxy 层在 Dubbo 架构中的位置如下所示(虽然在架构图中 Proxy 层与 Protocol 层距离很远,但 Proxy 的具体代码实现就位于 dubbo-rpc-api 模块中): + + + +Dubbo 架构中 Proxy 层的位置图 + +在 Consumer 进行调用的时候,Dubbo 会通过动态代理将业务接口实现对象转化为相应的 Invoker 对象,然后在 Cluster 层、Protocol 层都会使用 Invoker。在 Provider 暴露服务的时候,也会有 Invoker 对象与业务接口实现对象之间的转换,这同样也是通过动态代理实现的。 + +实现动态代理的常见方案有:JDK 动态代理、CGLib 动态代理和 Javassist 动态代理。这些方案的应用都还是比较广泛的,例如,Hibernate 底层使用了 Javassist 和 CGLib,Spring 使用了 CGLib 和 JDK 动态代理,MyBatis 底层使用了 JDK 动态代理和 Javassist。 + +从性能方面看,Javassist 与 CGLib 的实现方式相差无几,两者都比 JDK 动态代理性能要高,具体高多少,这就要看具体的机器、JDK 版本、测试基准的具体实现等条件了。 + +Dubbo 提供了两种方式来实现代理,分别是 JDK 动态代理和 Javassist。我们可以在 proxy 这个包内,看到相应工厂类,如下图所示: + + + +ProxyFactory 核心实现的位置 + +了解了 Proxy 存在的必要性以及 Dubbo 提供的两种代理生成方式之后,下面我们就开始对 Proxy 层的实现进行深入分析。 + +ProxyFactory + +关于 ProxyFactory 接口,我们在前面的第 23 课时中已经介绍过了,这里做一下简单回顾。ProxyFactory 是一个扩展接口,其中定义了两个核心方法:一个是 getProxy() 方法,为 Invoker 对象创建代理对象;另一个是 getInvoker() 方法,将代理对象反向封装成 Invoker 对象。 + +@SPI("javassist") + +public interface ProxyFactory { + + // 为传入的Invoker对象创建代理对象 + + @Adaptive({PROXY_KEY}) + + T getProxy(Invoker invoker) throws RpcException; + + @Adaptive({PROXY_KEY}) + + T getProxy(Invoker invoker, boolean generic) throws RpcException; + + // 将传入的代理对象封装成Invoker对象 + + @Adaptive({PROXY_KEY}) + + Invoker getInvoker(T proxy, Class type, URL url) throws RpcException; + +} + + +看到 ProxyFactory 上的 @SPI 注解我们知道,其默认实现使用 Javassist 来创建代码对象。 + +AbstractProxyFactory 是代理工厂的抽象类,继承关系如下图所示: + + + +AbstractProxyFactory 继承关系图 + +AbstractProxyFactory + +AbstractProxyFactory 主要处理的是需要代理的接口,具体实现在 getProxy() 方法中: + +public T getProxy(Invoker invoker, boolean generic) throws RpcException { + + Set> interfaces = new HashSet<>();// 记录要代理的接口 + + // 获取URL中interfaces参数指定的接口 + + String config = invoker.getUrl().getParameter(INTERFACES); + + if (config != null && config.length() > 0) { + + // 按照逗号切分interfaces参数,得到接口集合 + + String[] types = COMMA_SPLIT_PATTERN.split(config); + + for (String type : types) { // 记录这些接口信息 + + interfaces.add(ReflectUtils.forName(type)); + + } + + } + + if (generic) { // 针对泛化接口的处理 + + if (!GenericService.class.isAssignableFrom(invoker.getInterface())) { + + interfaces.add(GenericService.class); + + } + + // 从URL中获取interface参数指定的接口 + + String realInterface = invoker.getUrl().getParameter(Constants.INTERFACE); + + interfaces.add(ReflectUtils.forName(realInterface)); + + } + + // 获取Invoker中type字段指定的接口 + + interfaces.add(invoker.getInterface()); + + // 添加EchoService、Destroyable两个默认接口 + + interfaces.addAll(Arrays.asList(INTERNAL_INTERFACES)); + + // 调用抽象的getProxy()重载方法 + + return getProxy(invoker, interfaces.toArray(new Class[0])); + +} + + +AbstractProxyFactory 从多个地方获取需要代理的接口之后,会调用子类实现的 getProxy() 方法创建代理对象。 + +JavassistProxyFactory 对 getProxy() 方法的实现比较简单,直接委托给了 dubbo-common 模块中的 Proxy 工具类进行代理类的生成。下面我们就来深入分析 Proxy 生成代理类的全流程。 + +Proxy + +在 dubbo-common 模块,Proxy 中的 getProxy() 方法提供了动态创建代理类的核心实现。这个创建代理类的流程比较长,为了便于你更好地理解,这里我们将其拆开,一步步进行分析。 + +首先是查找 PROXY_CACHE_MAP 这个代理类缓存(new WeakHashMap>() 类型),其中第一层 Key 是 ClassLoader 对象,第二层 Key 是上面整理得到的接口拼接而成的,Value 是被缓存的代理类的 WeakReference(弱引用)。 + +WeakReference(弱引用)的特性是:WeakReference 引用的对象生命周期是两次 GC 之间,也就是说当垃圾收集器扫描到只具有弱引用的对象时,无论当前内存空间是否足够,都会回收该对象。(由于垃圾收集器是一个优先级很低的线程,不一定会很快发现那些只具有弱引用的对象。) + +WeakReference 的特性决定了它特别适合用于数据可恢复的内存型缓存。查找缓存的结果有下面三个: + + +如果缓存中查找不到任务信息,则会在缓存中添加一个 PENDING_GENERATION_MARKER 占位符,当前线程后续创建生成代理类并最终替换占位符。 +如果在缓存中查找到了 PENDING_GENERATION_MARKER 占位符,说明其他线程已经在生成相应的代理类了,当前线程会阻塞等待。 +如果缓存中查找到完整代理类,则会直接返回,不会再执行后续动态代理类的生成。 + + +下面是 Proxy.getProxy() 方法中对 PROXY_CACHE_MAP 缓存进行查询的相关代码片段: + +public static Proxy getProxy(ClassLoader cl, Class... ics) { + + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < ics.length; i++) { // 循环处理每个接口类 + + String itf = ics[i].getName(); + + if (!ics[i].isInterface()) { // 传入的必须是接口类,否则直接报错 + + throw new RuntimeException(itf + " is not a interface."); + + } + + // 加载接口类,加载失败则直接报错 + + Class tmp = Class.forName(itf, false, cl); + + if (tmp != ics[i]) { + + throw new IllegalArgumentException("..."); + + } + + sb.append(itf).append(';'); // 将接口类的完整名称用分号连接起来 + + } + + // 接口列表将会作为第二层集合的Key + + String key = sb.toString(); + + final Map cache; + + synchronized (PROXY_CACHE_MAP) { // 加锁同步 + + cache = PROXY_CACHE_MAP.computeIfAbsent(cl, k -> new HashMap<>()); + + } + + Proxy proxy = null; + + synchronized (cache) { // 加锁 + + do { + + Object value = cache.get(key); + + if (value instanceof Reference) { // 获取到WeakReference + + proxy = (Proxy) ((Reference) value).get(); + + if (proxy != null) { // 查找到缓存的代理类 + + return proxy; + + } + + } + + if (value == PENDING_GENERATION_MARKER) { // 获取到占位符 + + cache.wait(); // 阻塞等待其他线程生成好代理类,并添加到缓存中 + + } else { // 设置占位符,由当前线程生成代理类 + + cache.put(key, PENDING_GENERATION_MARKER); + + break; // 退出当前循环 + + } + + } + + while (true); + + } + + ... ... // 后续动态生成代理类的逻辑 + +} + + +完成缓存的查找之后,下面我们再来看代理类的生成过程。 + +第一步,调用 ClassGenerator.newInstance() 方法创建 ClassLoader 对应的 ClassPool。ClassGenerator 中封装了 Javassist 的基本操作,还定义了很多字段用来暂存代理类的信息,在其 toClass() 方法中会用这些暂存的信息来动态生成代理类。下面就来简单说明一下这些字段。 + + +mClassName(String 类型):代理类的类名。 +mSuperClass(String 类型):代理类父类的名称。 +mInterfaces(Set 类型):代理类实现的接口。 +mFields(List类型):代理类中的字段。 +mConstructors(List类型):代理类中全部构造方法的信息,其中包括构造方法的具体实现。 +mMethods(List类型):代理类中全部方法的信息,其中包括方法的具体实现。 +mDefaultConstructor(boolean 类型):标识是否为代理类生成的默认构造方法。 + + +在 ClassGenerator 的 toClass() 方法中,会根据上述字段用 Javassist 生成代理类,具体实现如下: + +public Class toClass(ClassLoader loader, ProtectionDomain pd) { + + if (mCtc != null) { + + mCtc.detach(); + + } + + // 在代理类继承父类的时候,会将该id作为后缀编号,防止代理类重名 + + long id = CLASS_NAME_COUNTER.getAndIncrement(); + + CtClass ctcs = mSuperClass == null ? null : mPool.get(mSuperClass); + + if (mClassName == null) { // 确定代理类的名称 + + mClassName = (mSuperClass == null || javassist.Modifier.isPublic(ctcs.getModifiers()) + + ? ClassGenerator.class.getName() : mSuperClass + "$sc") + id; + + } + + mCtc = mPool.makeClass(mClassName); // 创建CtClass,用来生成代理类 + + if (mSuperClass != null) { // 设置代理类的父类 + + mCtc.setSuperclass(ctcs); + + } + + // 设置代理类实现的接口,默认会添加DC这个接口 + + mCtc.addInterface(mPool.get(DC.class.getName())); + + if (mInterfaces != null) { + + for (String cl : mInterfaces) { + + mCtc.addInterface(mPool.get(cl)); + + } + + } + + if (mFields != null) { // 设置代理类的字段 + + for (String code : mFields) { + + mCtc.addField(CtField.make(code, mCtc)); + + } + + } + + if (mMethods != null) { // 生成代理类的方法 + + for (String code : mMethods) { + + if (code.charAt(0) == ':') { + + mCtc.addMethod(CtNewMethod.copy(getCtMethod(mCopyMethods.get(code.substring(1))), + + code.substring(1, code.indexOf('(')), mCtc, null)); + + } else { + + mCtc.addMethod(CtNewMethod.make(code, mCtc)); + + } + + } + + } + + if (mDefaultConstructor) { // 生成默认的构造方法 + + mCtc.addConstructor(CtNewConstructor.defaultConstructor(mCtc)); + + } + + if (mConstructors != null) { // 生成构造方法 + + for (String code : mConstructors) { + + if (code.charAt(0) == ':') { + + mCtc.addConstructor(CtNewConstructor + + .copy(getCtConstructor(mCopyConstructors.get(code.substring(1))), mCtc, null)); + + } else { + + String[] sn = mCtc.getSimpleName().split("\\$+"); // inner class name include $. + + mCtc.addConstructor( + + CtNewConstructor.make(code.replaceFirst(SIMPLE_NAME_TAG, sn[sn.length - 1]), mCtc)); + + } + + } + + } + + return mCtc.toClass(loader, pd); + +} + + +第二步,从 PROXY_CLASS_COUNTER 字段(AtomicLong类型)中获取一个 id 值,作为代理类的后缀,这主要是为了避免类名重复发生冲突。 + +第三步,遍历全部接口,获取每个接口中定义的方法,对每个方法进行如下处理: + + +加入 worked 集合(Set 类型)中,用来判重。 +将方法对应的 Method 对象添加到 methods 集合(List 类型)中。 +获取方法的参数类型以及返回类型,构建方法体以及 return 语句。 +将构造好的方法添加到 ClassGenerator 中的 mMethods 集合中进行缓存。 + + +相关代码片段如下所示: + +long id = PROXY_CLASS_COUNTER.getAndIncrement(); + +String pkg = null; + +ClassGenerator ccp = null, ccm = null; + +ccp = ClassGenerator.newInstance(cl); + +Set worked = new HashSet<>() + +List methods = new ArrayList>(); + +for (int i = 0; i < ics.length; i++) { + + if (!Modifier.isPublic(ics[i].getModifiers())) { + + String npkg = ics[i].getPackage().getName(); + + if (pkg == null) { // 如果接口不是public的,则需要保证所有接口在一个包下 + + pkg = npkg; + + } else { + + if (!pkg.equals(npkg)) { + + throw new IllegalArgumentException("non-public interfaces from different packages"); + + } + + } + + } + + ccp.addInterface(ics[i]); // 向ClassGenerator中添加接口 + + for (Method method : ics[i].getMethods()) { // 遍历接口中的每个方法 + + String desc = ReflectUtils.getDesc(method); + + // 跳过已经重复方法以及static方法 + + if (worked.contains(desc) || Modifier.isStatic(method.getModifiers())) { + + continue; + + } + + if (ics[i].isInterface() && Modifier.isStatic(method.getModifiers())) { + + continue; + + } + + worked.add(desc); // 将方法描述添加到worked这个Set集合中,进行去重 + + int ix = methods.size(); + + Class rt = method.getReturnType(); // 获取方法的返回值 + + Class[] pts = method.getParameterTypes(); // 获取方法的参数列表 + + // 创建方法体 + + StringBuilder code = new StringBuilder("Object[] args = new Object[").append(pts.length).append("];"); + + for (int j = 0; j < pts.length; j++) { + + code.append(" args[").append(j).append("] = ($w)$").append(j + 1).append(";"); + + } + + code.append(" Object ret = handler.invoke(this, methods[").append(ix).append("], args);"); + + if (!Void.TYPE.equals(rt)) { // 生成return语句 + + code.append(" return ").append(asArgument(rt, "ret")).append(";"); + + } + + // 将生成好的方法添加到ClassGenerator中缓存 + + methods.add(method); + + ccp.addMethod(method.getName(), method.getModifiers(), rt, pts, method.getExceptionTypes(), code.toString()); + + } + +} + + +这里我们以 Demo 示例(即 dubbo-demo 模块中的 Demo)中的 sayHello() 方法为例,生成的方法如下所示: + +public java.lang.String sayHello(java.lang.String arg0){ + + Object[] args = new Object[1]; + + args[0] = ($w)$1; + + // 这里通过InvocationHandler.invoke()方法调用目标方法 + + Object ret = handler.invoke(this, methods[3], args); + + return (java.lang.String)ret; + +} + + +这里的方法调用其实是:委托 InvocationHandler 对象的 invoke() 方法去调用真正的实例方法。 + +第四步,开始创建代理实例类(ProxyInstance)和代理类。这里我们先创建代理实例类,需要向 ClassGenerator 中添加相应的信息,例如,类名、默认构造方法、字段、父类以及一个 newInstance() 方法,具体实现如下: + +String pcn = pkg + ".proxy" + id; // 生成并设置代理类类名 + +ccp.setClassName(pcn); + +// 添加字段,一个是前面生成的methods集合,另一个是InvocationHandler对象 + +ccp.addField("public static java.lang.reflect.Method[] methods;"); + +ccp.addField("private " + InvocationHandler.class.getName() + " handler;"); + +// 添加构造方法 + +ccp.addConstructor(Modifier.PUBLIC, new Class[]{InvocationHandler.class}, new Class[0], "handler=$1;"); + +ccp.addDefaultConstructor(); // 默认构造方法 + +Class clazz = ccp.toClass(); + +clazz.getField("methods").set(null, methods.toArray(new Method[0])); + + +这里得到的代理实例类中每个方法的实现,都类似于上面提到的 sayHello() 方法的实现,即通过 InvocationHandler.invoke()方法调用目标方法。 + +接下来创建代理类,它实现了 Proxy 接口,并实现了 newInstance() 方法,该方法会直接返回上面代理实例类的对象,相关代码片段如下: + +String fcn = Proxy.class.getName() + id; + +ccm = ClassGenerator.newInstance(cl); + +ccm.setClassName(fcn); + +ccm.addDefaultConstructor(); // 默认构造方法 + +ccm.setSuperClass(Proxy.class); // 实现Proxy接口 + +// 实现newInstance()方法,返回上面创建的代理实例类的对象 + +ccm.addMethod("public Object newInstance(" + InvocationHandler.class.getName() + " h){ return new " + pcn + "($1); }"); + +Class pc = ccm.toClass(); + +proxy = (Proxy) pc.newInstance(); + + +生成的代理类如下所示: + +package com.apache.dubbo.common.bytecode; + +public class Proxy0 implements Proxy { + + public void Proxy0() {} + + public Object newInstance(InvocationHandler h){ + + return new proxy0(h); + + } + +} + + +第五步,也就是最后一步,在 finally 代码块中,会释放 ClassGenerator 的相关资源,将生成的代理类添加到 PROXY_CACHE_MAP 缓存中保存,同时会唤醒所有阻塞在 PROXY_CACHE_MAP 缓存上的线程,重新检测需要的代理类是否已经生成完毕。相关代码片段如下: + +if (ccp != null) { // 释放ClassGenerator的相关资源 + + ccp.release(); + +} + +if (ccm != null) { + + ccm.release(); + +} + +synchronized (cache) { // 加锁 + + if (proxy == null) { + + cache.remove(key); + + } else { // 填充PROXY_CACHE_MAP缓存 + + cache.put(key, new WeakReference(proxy)); + + } + + cache.notifyAll(); // 唤醒所有阻塞在PROXY_CACHE_MAP上的线程 + +} + + +getProxy() 方法实现 + +分析完 Proxy 使用 Javassist 生成代理类的完整流程之后,我们再回头看一下 JavassistProxyFactory 工厂的 getProxy() 方法实现。这里首先通过前面分析的 getProxy() 方法获取 Proxy 对象,然后调用 newInstance() 方法获取目标类的代理对象,具体如下所示: + +public T getProxy(Invoker invoker, Class[] interfaces) { + + return (T) Proxy.getProxy(interfaces).newInstance(new InvokerInvocationHandler(invoker)); + +} + + +相比之下,JdkProxyFactory 对 getProxy() 方法的实现就简单很多,直接使用 JDK 自带的 java.lang.reflect.Proxy 生成代理对象,你可以参考前面第 8 课时中 JDK 动态代理的基本使用方式以及原理: + +public T getProxy(Invoker invoker, Class[] interfaces) { + + return (T) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), interfaces, new InvokerInvocationHandler(invoker)); + +} + + +InvokerInvocationHandler + +无论是 Javassist 还是 JDK 生成的代理类,都会将方法委托给 InvokerInvocationHandler 进行处理。InvokerInvocationHandler 中维护了一个 Invoker 对象,也是前面 getProxy() 方法传入的第一个参数,这个 Invoker 不是一个简单的 DubboInvoker 对象,而是在 DubboInvoker 之上经过一系列装饰器修饰的 Invoker 对象。 + +在 InvokerInvocationHandler 的 invoke() 方法中,首先会针对特殊的方法进行处理,比如 toString()、$destroy() 等方法。之后,对于业务方法,会创建相应的 RpcInvocation 对象调用 Invoker.invoke() 方法发起 RPC 调用,具体实现如下: + +public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + + // 对于Object中定义的方法,直接调用Invoker对象的相应方法即可 + + if (method.getDeclaringClass() == Object.class) { + + return method.invoke(invoker, args); + + } + + String methodName = method.getName(); + + Class[] parameterTypes = method.getParameterTypes(); + + if (parameterTypes.length == 0) { // 对$destroy等方法的特殊处理 + + if ("$destroy".equals(methodName)) { + + invoker.destroy(); + + return null; + + } + + } + + ... // 省略其他特殊处理的方法 + + // 创建RpcInvocation对象,后面会作为远程RPC调用的参数 + + RpcInvocation rpcInvocation = new RpcInvocation(method, invoker.getInterface().getName(), args); + + String serviceKey = invoker.getUrl().getServiceKey(); + + rpcInvocation.setTargetServiceUniqueName(serviceKey); + + if (consumerModel != null) { + + rpcInvocation.put(Constants.CONSUMER_MODEL, consumerModel); + + rpcInvocation.put(Constants.METHOD_MODEL, consumerModel.getMethodModel(method)); + + } + + // 调用invoke()方法发起远程调用,拿到AsyncRpcResult之后,调用recreate()方法获取响应结果(或是Future) + + return invoker.invoke(rpcInvocation).recreate(); + +} + + +Wrapper + +Invoker 是 Dubbo 的核心模型。在 Dubbo 中,Provider 的业务层实现会被包装成一个 ProxyInvoker,然后这个 ProxyInvoker 还会被 Filter、Listener 以及其他装饰器包装。ProxyFactory 的 getInvoker 方法就是将业务接口实现封装成 ProxyInvoker 入口。 + +我们先来看 JdkProxyFactory 中的实现。JdkProxyFactory 会创建一个匿名 AbstractProxyInvoker 的实现,其中的 doInvoke() 方法是通过 Java 原生的反射技术实现的,具体实现如下: + +public Invoker getInvoker(T proxy, Class type, URL url) { + + return new AbstractProxyInvoker(proxy, type, url) { + + @Override + + protected Object doInvoke(T proxy, String methodName, + + Class[] parameterTypes, Object[] arguments) throws Throwable { + + // 使用反射方式查找methodName对应的方法,并进行调用 + + Method method = proxy.getClass().getMethod(methodName, parameterTypes); + + return method.invoke(proxy, arguments); + + } + + }; + +} + + +在前面两个课时中我们已经介绍了 Invoker 接口的一个重要实现分支—— AbstractInvoker 以及它的一个实现 DubboInvoker。AbstractProxyInvoker 是 Invoker 接口的另一个实现分支,继承关系如下图所示,其实现类都是 ProxyFactory 实现中的匿名内部类。 + + + +在 AbstractProxyInvoker 实现的 invoke() 方法中,会将 doInvoke() 方法返回的结果封装成 CompletableFuture 对象,然后再封装成 AsyncRpcResult 对象返回,具体实现如下: + +public Result invoke(Invocation invocation) throws RpcException { + + // 执行doInvoke()方法,调用业务实现 + + Object value = doInvoke(proxy, invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments()); + + // 将value值封装成CompletableFuture对象 + + CompletableFuture future = wrapWithFuture(value); + + // 再次转换,转换为CompletableFuture类型 + + CompletableFuture appResponseFuture = future.handle((obj, t) -> { + + AppResponse result = new AppResponse(); + + if (t != null) { + + if (t instanceof CompletionException) { + + result.setException(t.getCause()); + + } else { + + result.setException(t); + + } + + } else { + + result.setValue(obj); + + } + + return result; + + }); + + // 将CompletableFuture封装成AsyncRpcResult返回 + + return new AsyncRpcResult(appResponseFuture, invocation); + +} + + +了解了 AbstractProxyInvoker 以及 JdkProxyFactory 返回的实现之后,我们再来看 JavassistProxyFactory.getInvoker() 方法返回的实现。首先该方法会通过 Wrapper 创建一个包装类,然后创建一个实现了 AbstractProxyInvoker 的匿名内部类,其 doInvoker() 方法会直接委托给 Wrapper 对象的 InvokeMethod() 方法,具体实现如下: + +public Invoker getInvoker(T proxy, Class type, URL url) { + + // 通过Wrapper创建一个包装类对象 + + final Wrapper wrapper = Wrapper.getWrapper(proxy.getClass().getName().indexOf('$') < 0 ? proxy.getClass() : type); + + // 创建一个实现了AbstractProxyInvoker的匿名内部类,其doInvoker()方法会直接委托给Wrapper对象的InvokeMethod()方法 + + return new AbstractProxyInvoker(proxy, type, url) { + + @Override + + protected Object doInvoke(T proxy, String methodName, + + Class[] parameterTypes, Object[] arguments) throws Throwable { + + return wrapper.invokeMethod(proxy, methodName, parameterTypes, arguments); + + } + + }; + +} + + +Wrapper 类本身是抽象类,是对 Java 类的一种包装。Wrapper 会从 Java 类中的字段和方法抽象出相应 propertyName 和 methodName,在需要调用一个字段或方法的时候,会根据传入的方法名和参数进行匹配,找到对应的字段和方法进行调用。 + +Wrapper.getWrapper() 方法会根据不同的 Java 对象,使用 Javassist 生成一个相应的 Wrapper 实现对象。下面我们就来一起分析下 getWrapper() 方法实现: + + +首先检测该 Java 类是否实现了 DC 这个标识接口,在前面介绍 Proxy 抽象类的时候,我们提到过这个接口; +检测 WRAPPER_MAP 集合(Map, Wrapper> 类型)中是否缓存了对应的 Wrapper 对象,如果已缓存则直接返回,如果未缓存则调用 makeWrapper() 方法动态生成 Wrapper 实现类,以及相应的实例对象,并写入缓存中。 + + +makeWrapper() 方法的实现非常长,但是逻辑并不复杂,该方法会遍历传入的 Class 对象的所有 public 字段和 public 方法,构建组装 Wrapper 实现类需要的 Java 代码。具体实现有如下三个步骤。 + +第一步,public 字段会构造相应的 getPropertyValue() 方法和 setPropertyValue() 方法。例如,有一个名为“name”的 public 字段,则会生成如下的代码: + +// 生成的getPropertyValue()方法 + +public Object getPropertyValue(Object o, String n){ + + DemoServiceImpl w; + + try{ + + w = ((DemoServiceImpl)$1); + + }catch(Throwable e){ + + throw new IllegalArgumentException(e); + + } + + if( $2.equals(" if( $2.equals("name") ){ + + return ($w)w.name; + + } + +} + +// 生成的setPropertyValue()方法 + +public void setPropertyValue(Object o, String n, Object v){ + + DemoServiceImpl w; + + try{ + + w = ((DemoServiceImpl)$1); + + }catch(Throwable e){ + + throw new IllegalArgumentException(e); + + } + + if( $2.equals("name") ){ + + w.name=(java.lang.String)$3; return; + + } + +} + + +第二步,处理 public 方法,这些 public 方法会添加到 invokeMethod 方法中。以 Demo 示例(即 dubbo-demo 模块中的 demo )中的 DemoServiceImpl 为例,生成的 invokeMethod() 方法实现如下: + +public Object invokeMethod(Object o, String n, Class[] p, Object[] v) throws java.lang.reflect.InvocationTargetException { + + org.apache.dubbo.demo.provider.DemoServiceImpl w; + + try { + + w = ((org.apache.dubbo.demo.provider.DemoServiceImpl) $1); + + } catch (Throwable e) { + + throw new IllegalArgumentException(e); + + } + + try { + + // 省略getter/setter方法 + + if ("sayHello".equals($2) && $3.length == 1) { + + return ($w) w.sayHello((java.lang.String) $4[0]); + + } + + if ("sayHelloAsync".equals($2) && $3.length == 1) { + + return ($w) w.sayHelloAsync((java.lang.String) $4[0]); + + } + + } catch (Throwable e) { + + throw new java.lang.reflect.InvocationTargetException(e); + + } + + throw new NoSuchMethodException("Not found method"); + +} + + +第三步,完成了上述 Wrapper 实现类相关信息的填充之后,makeWrapper() 方法会通过 ClassGenerator 创建 Wrapper 实现类,具体原理与前面 Proxy 创建代理类的流程类似,这里就不再赘述。 + +总结 + +本课时主要介绍了 dubbo-rpc-api 模块中“代理”相关的内容。首先我们从 ProxyFactory.getProxy() 方法入手,详细介绍了 JDK 方式和 Javassist 方式创建动态代理类的底层原理,以及其中使用的 InvokerInvocationHandler 的实现。接下来我们又通过 ProxyFactory.getInvoker() 方法入手,重点讲解了 Wrapper 的生成过程和核心原理。 + +下面这张简图很好地展示了 Dubbo 中 Proxy 和 Wrapper 的重要性: + + + +Proxy 和 Wrapper 远程调用简图 + +Consumer 端的 Proxy 底层屏蔽了复杂的网络交互、集群策略以及 Dubbo 内部的 Invoker 等概念,提供给上层使用的是业务接口。Provider 端的 Wrapper 是将个性化的业务接口实现,统一转换成 Dubbo 内部的 Invoker 接口实现。正是由于 Proxy 和 Wrapper 这两个组件的存在,Dubbo 才能实现内部接口和业务接口的无缝转换。 + +关于“代理”相关的内容,你若还有什么想法,欢迎你留言跟我分享。下一课时,我们会再做一个加餐,介绍 Dubbo 中支持的 HTTP 协议的相关内容。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/29加餐:HTTP协议+JSON-RPC,Dubbo跨语言就是如此简单.md b/专栏/Dubbo源码解读与实战-完/29加餐:HTTP协议+JSON-RPC,Dubbo跨语言就是如此简单.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/Dubbo源码解读与实战-完/30Filter接口,扩展Dubbo框架的常用手段指北.md b/专栏/Dubbo源码解读与实战-完/30Filter接口,扩展Dubbo框架的常用手段指北.md new file mode 100644 index 0000000..10b20f5 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/30Filter接口,扩展Dubbo框架的常用手段指北.md @@ -0,0 +1,1012 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 Filter 接口,扩展 Dubbo 框架的常用手段指北 + 在前面的第 27 课时中,我们介绍了 ProtocolFilterWrapper 的具体实现,这里简单回顾一下。在 buildInvokerChain() 方法中,ProtocolFilterWrapper 会加载 Dubbo 以及应用程序提供的 Filter 实现类,然后构造成 Filter 链,最后通过装饰者模式在原有 Invoker 对象基础上添加执行 Filter 链的逻辑。 + +Filter 链的组装逻辑设计得非常灵活,其中可以通过“-”配置手动剔除 Dubbo 原生提供的、默认加载的 Filter,通过“default”来代替 Dubbo 原生提供的 Filter,这样就可以很好地控制哪些 Filter 要加载,以及 Filter 的真正执行顺序。 + +Filter 是扩展 Dubbo 功能的首选方案,并且 Dubbo 自身也提供了非常多的 Filter 实现来扩展自身功能。在回顾了 ProtocolFilterWrapper 加载 Filter 的大致逻辑之后,我们本课时就来深入介绍 Dubbo 内置的多种 Filter 实现类,以及自定义 Filter 扩展 Dubbo 的方式。 + +在开始介绍 Filter 接口实现之前,我们需要了解一下 Filter 在 Dubbo 架构中的位置,这样才能明确 Filter 链处理请求/响应的位置,如下图红框所示: + + + +Filter 在 Dubbo 架构中的位置 + +ConsumerContextFilter + +ConsumerContextFilter 是一个非常简单的 Consumer 端 Filter 实现,它会在当前的 RpcContext 中记录本地调用的一些状态信息(会记录到 LOCAL 对应的 RpcContext 中),例如,调用相关的 Invoker、Invocation 以及调用的本地地址、远端地址信息,具体实现如下: + +public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + RpcContext context = RpcContext.getContext(); + + context.setInvoker(invoker) // 记录Invoker + + .setInvocation(invocation) // 记录Invocation + + // 记录本地地址以及远端地址 + + .setLocalAddress(NetUtils.getLocalHost(), 0) + + .setRemoteAddress(invoker.getUrl().getHost(), invoker.getUrl().getPort()) + + // 记录远端应用名称等信息 + + .setRemoteApplicationName(invoker.getUrl() + + .getParameter(REMOTE_APPLICATION_KEY)) + + .setAttachment(REMOTE_APPLICATION_KEY, invoker.getUrl().getParameter(APPLICATION_KEY)); + + if (invocation instanceof RpcInvocation) { + + ((RpcInvocation) invocation).setInvoker(invoker); + + } + + // 检测是否超时 + + Object countDown = context.get(TIME_COUNTDOWN_KEY); + + if (countDown != null) { + + TimeoutCountDown timeoutCountDown = (TimeoutCountDown) countDown; + + if (timeoutCountDown.isExpired()) { + + return AsyncRpcResult.newDefaultAsyncResult( + + new RpcException("...."), invocation); + + } + + } + + return invoker.invoke(invocation); + +} + + +这里使用的 TimeoutCountDown 对象用于检测当前调用是否超时,其中有三个字段。 + + +timeoutInMillis(long 类型):超时时间,单位为毫秒。 +deadlineInNanos(long 类型):超时的时间戳,单位为纳秒。 +expired(boolean 类型):标识当前 TimeoutCountDown 关联的调用是否已超时。 + + +在 TimeoutCountDown.isExpire() 方法中,会比较当前时间与 deadlineInNanos 字段记录的超时时间戳。正如上面看到的逻辑,如果请求超时,则不再发起远程调用,直接让 AsyncRpcResult 异常结束。 + +ActiveLimitFilter + +ActiveLimitFilter 是 Consumer 端用于限制一个 Consumer 对于一个服务端方法的并发调用量,也可以称为“客户端限流”。下面我们就来看下 ActiveLimitFilter 的具体实现: + +public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + URL url = invoker.getUrl(); // 获得url对象 + + String methodName = invocation.getMethodName();// 获得方法名称 + + // 获取最大并发数 + + int max = invoker.getUrl().getMethodParameter(methodName, ACTIVES_KEY, 0); + + // 获取该方法的状态信息 + + final RpcStatus rpcStatus = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()); + + if (!RpcStatus.beginCount(url, methodName, max)) { // 尝试并发度加一 + + long timeout = invoker.getUrl().getMethodParameter(invocation.getMethodName(), TIMEOUT_KEY, 0); + + long start = System.currentTimeMillis(); + + long remain = timeout; + + synchronized (rpcStatus) { // 加锁 + + while (!RpcStatus.beginCount(url, methodName, max)) { // 再次尝试并发度加一 + + rpcStatus.wait(remain); // 当前线程阻塞,等待并发度降低 + + // 检测是否超时 + + long elapsed = System.currentTimeMillis() - start; + + remain = timeout - elapsed; + + if (remain <= 0) { + + throw new RpcException(...); + + } + + } + + } + + } + + // 添加一个attribute + + invocation.put(ACTIVELIMIT_FILTER_START_TIME, System.currentTimeMillis()); + + return invoker.invoke(invocation); + +} + + +从 ActiveLimitFilter.invoke() 方法的代码中可以看到,其核心实现与 RpcStatus 对象密切相关。RpcStatus 中维护了两个集合,分别是: + + +SERVICE_STATISTICS 集合(ConcurrentMap 类型),这个集合记录了当前 Consumer 调用每个服务的状态信息,其中 Key 是 URL,Value 是对应的 RpcStatus 对象; +METHOD_STATISTICS 集合(ConcurrentMap> 类型),这个集合记录了当前 Consumer 调用每个服务方法的状态信息,其中第一层 Key 是 URL ,第二层 Key 是方法名称,第三层是对应的 RpcStatus 对象。 + + +RpcStatus 中统计了很多调用相关的信息,核心字段有如下几个。 + + +active(AtomicInteger 类型):当前并发度。这也是 ActiveLimitFilter 中关注的并发度。 +total(AtomicLong 类型):调用的总数。 +failed(AtomicInteger 类型):失败的调用数。 +totalElapsed(AtomicLong 类型):所有调用的总耗时。 +failedElapsed(AtomicLong 类型):所有失败调用的总耗时。 +maxElapsed(AtomicLong 类型):所有调用中最长的耗时。 +failedMaxElapsed(AtomicLong 类型):所有失败调用中最长的耗时。 +succeededMaxElapsed(AtomicLong 类型):所有成功调用中最长的耗时。 + + +另外,RpcStatus 提供了上述字段的 getter/setter 方法,用于读写这些字段值,这里不再展开分析。 + +RpcStatus 中的 beginCount() 方法会在远程调用开始之前执行,其中会从 SERVICE_STATISTICS 集合和 METHOD_STATISTICS 集合中获取服务和服务方法对应的 RpcStatus 对象,然后分别将它们的 active 字段加一,相关实现如下: + +public static boolean beginCount(URL url, String methodName, int max) { + + max = (max <= 0) ? Integer.MAX_VALUE : max; + + // 获取服务对应的RpcStatus对象 + + RpcStatus appStatus = getStatus(url); + + // 获取服务方法对应的RpcStatus对象 + + RpcStatus methodStatus = getStatus(url, methodName); + + if (methodStatus.active.get() == Integer.MAX_VALUE) { // 并发度溢出 + + return false; + + } + + for (int i; ; ) { + + i = methodStatus.active.get(); + + if (i + 1 > max) { // 并发度超过max上限,直接返回false + + return false; + + } + + if (methodStatus.active.compareAndSet(i, i + 1)) { // CAS操作 + + break; // 更新成功后退出当前循环 + + } + + } + + appStatus.active.incrementAndGet(); // 单个服务的并发度加一 + + return true; + +} + + +ActiveLimitFilter 在继承 Filter 接口的同时,还继承了 Filter.Listener 这个内部接口,在其 onResponse() 方法的实现中,不仅会调用 RpcStatus.endCount() 方法完成调用监控的统计,还会调用 notifyFinish() 方法唤醒阻塞在对应 RpcStatus 对象上的线程,具体实现如下: + +public void onResponse(Result appResponse, Invoker invoker, Invocation invocation) { + + String methodName = invocation.getMethodName(); // 获取调用的方法名称 + + URL url = invoker.getUrl(); + + int max = invoker.getUrl().getMethodParameter(methodName, ACTIVES_KEY, 0); + + // 调用 RpcStatus.endCount() 方法完成调用监控的统计 + + RpcStatus.endCount(url, methodName, getElapsed(invocation), true); + + // 调用 notifyFinish() 方法唤醒阻塞在对应 RpcStatus 对象上的线程 + + notifyFinish(RpcStatus.getStatus(url, methodName), max); + +} + + +在 RpcStatus.endCount() 方法中,会对服务和服务方法两个维度的 RpcStatus 中的所有字段进行更新,完成统计: + +private static void endCount(RpcStatus status, long elapsed, boolean succeeded) { + + status.active.decrementAndGet(); // 请求完成,降低并发度 + + status.total.incrementAndGet(); // 调用总次数增加 + + status.totalElapsed.addAndGet(elapsed); // 调用总耗时增加 + + if (status.maxElapsed.get() < elapsed) { // 更新最大耗时 + + status.maxElapsed.set(elapsed); + + } + + if (succeeded) { // 如果此次调用成功,则会更新成功调用的最大耗时 + + if (status.succeededMaxElapsed.get() < elapsed) { + + status.succeededMaxElapsed.set(elapsed); + + } + + } else { // 如果此次调用失败,则会更新失败调用的最大耗时 + + status.failed.incrementAndGet(); + + status.failedElapsed.addAndGet(elapsed); + + if (status.failedMaxElapsed.get() < elapsed) { + + status.failedMaxElapsed.set(elapsed); + + } + + } + +} + + +ContextFilter + +在前面第 26 课时介绍 AbstractInvoker 的时候,我们提到其 invoke() 方法中有如下一段逻辑: + +Map contextAttachments = + + RpcContext.getContext().getObjectAttachments(); + +if (CollectionUtils.isNotEmptyMap(contextAttachments)) { + + invocation.addObjectAttachments(contextAttachments); + +} + + +这里将 RpcContext 中的附加信息添加到 Invocation 中,一并传递到 Provider 端。那在 Provider 端是如何获取 Invocation 中的附加信息,并设置到 RpcContext 中的呢? + +ContextFilter 是 Provider 端的一个 Filter 实现,它主要用来初始化 Provider 端的 RpcContext。 ContextFilter 首先会从 Invocation 中获取 Attachments 集合,并对该集合中的 Key 进行过滤,其中会将 UNLOADING_KEYS 集合中的全部 Key 过滤掉;之后会初始化 RpcContext 以及 Invocation 的各项信息,例如,Invocation、Attachments、localAddress、remoteApplication、超时时间等;最后调用 Invoker.invoke() 方法执行 Provider 的业务逻辑。ContextFilter.Invoke() 方法的具体逻辑如下所示: + +public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + Map attachments = invocation.getObjectAttachments(); + + ... ... // 省略过滤UNLOADING_KEYS集合的逻辑 + + RpcContext context = RpcContext.getContext(); // 获取RpcContext + + context.setInvoker(invoker) // 设置RpcContext中的信息 + + .setInvocation(invocation) + + .setLocalAddress(invoker.getUrl().getHost(), + + invoker.getUrl().getPort()); + + String remoteApplication = (String) invocation.getAttachment(REMOTE_APPLICATION_KEY); + + if (StringUtils.isNotEmpty(remoteApplication)) { + + context.setRemoteApplicationName(remoteApplication); + + } else { + + context.setRemoteApplicationName((String) context.getAttachment(REMOTE_APPLICATION_KEY)); + + } + + long timeout = RpcUtils.getTimeout(invocation, -1); + + if (timeout != -1) { // 设置超时时间 + + context.set(TIME_COUNTDOWN_KEY, TimeoutCountDown.newCountDown(timeout, TimeUnit.MILLISECONDS)); + + } + + if (attachments != null) { // 向RpcContext中设置Attachments + + if (context.getObjectAttachments() != null) { + + context.getObjectAttachments().putAll(attachments); + + } else { + + context.setObjectAttachments(attachments); + + } + + } + + if (invocation instanceof RpcInvocation) { // 向Invocation设置Invoker + + ((RpcInvocation) invocation).setInvoker(invoker); + + } + + try { + + // 在整个调用过程中,需要保持当前RpcContext不被删除,这里会将remove开关关掉,这样,removeContext()方法不会删除LOCAL RpcContext了 + + context.clearAfterEachInvoke(false); + + return invoker.invoke(invocation); + + } finally { + + // 重置remove开关 + + context.clearAfterEachInvoke(true); + + // 清理RpcContext,当前线程处理下一个调用的时候,会创建新的RpcContext + + RpcContext.removeContext(true); + + RpcContext.removeServerContext(); + + } + +} + + +ContextFilter 继承了 Filter 接口的同时,还继承了 Filter.Listener 这个内部接口。在 ContextFilter.onResponse() 方法中,会将 SERVER_LOCAL 这个 RpcContext 中的附加信息添加到 AppResponse 的 attachments 字段中,返回给 Consumer。 + +public void onResponse(Result appResponse, Invoker invoker, Invocation invocation) { + +appResponse.addObjectAttachments(RpcContext.getServerContext().getObjectAttachments()); + +} + + +AccessLogFilter + +AccessLogFilter 主要用于记录日志,它的主要功能是将 Provider 或者 Consumer 的日志信息写入文件中。AccessLogFilter 会先将日志消息放入内存日志集合中缓存,当缓存大小超过一定阈值之后,会触发日志的写入。若长时间未触发日志文件写入,则由定时任务定时写入。 + +AccessLogFilter.invoke() 方法的核心实现如下: + +public Result invoke(Invoker invoker, Invocation inv) throws RpcException { + + String accessLogKey = invoker.getUrl().getParameter(ACCESS_LOG_KEY); + + if (ConfigUtils.isNotEmpty(accessLogKey)) { // 获取ACCESS_LOG_KEY + + // 构造AccessLogData对象,其中记录了日志信息,例如,调用的服务名称、方法名称、version等 + + AccessLogData logData = buildAccessLogData(invoker, inv); + + log(accessLogKey, logData); + + } + + // 调用下一个Invoker + + return invoker.invoke(inv); + +} + + +在 log() 方法中,会按照 ACCESS_LOG_KEY 的值,找到对应的 AccessLogData 集合,然后完成缓存写入;如果缓存大小超过阈值,则触发文件写入。具体实现如下: + +private void log(String accessLog, AccessLogData accessLogData) { + + // 根据ACCESS_LOG_KEY获取对应的缓存集合 + + Set logSet = LOG_ENTRIES.computeIfAbsent(accessLog, k -> new ConcurrentHashSet<>()); + + if (logSet.size() < LOG_MAX_BUFFER) { // 缓存大小未超过阈值 + + logSet.add(accessLogData); + + } else { // 缓存大小超过阈值,触发缓存数据写入文件 + + writeLogSetToFile(accessLog, logSet); + + // 完成文件写入之后,再次写入缓存 + + logSet.add(accessLogData); + + } + +} + + +在 writeLogSetToFile() 方法中,会按照 ACCESS_LOG_KEY 的值将日志信息写入不同的日志文件中: + + +如果 ACCESS_LOG_KEY 配置的值为 true 或 default,会使用 Dubbo 默认提供的统一日志框架,输出到日志文件中; +如果 ACCESS_LOG_KEY 配置的值不为 true 或 default,则 ACCESS_LOG_KEY 配置值会被当作 access log 文件的名称,AccessLogFilter 会创建相应的目录和文件,并完成日志的输出。 + + +private void writeLogSetToFile(String accessLog, Set logSet) { + + try { + + if (ConfigUtils.isDefault(accessLog)) { + + // ACCESS_LOG_KEY配置值为true或是default + + processWithServiceLogger(logSet); + + } else { // ACCESS_LOG_KEY配置既不是true也不是default的时候 + + File file = new File(accessLog); + + createIfLogDirAbsent(file); // 创建目录 + + renameFile(file); // 创建日志文件,这里会以日期为后缀,滚动创建 + + // 遍历logSet集合,将日志逐条写入文件 + + processWithAccessKeyLogger(logSet, file); + + } + + } catch (Exception e) { + + logger.error(e.getMessage(), e); + + } + +} + +private void processWithAccessKeyLogger(Set logSet, File file) throws IOException { + + // 创建FileWriter,写入指定的日志文件 + + try (FileWriter writer = new FileWriter(file, true)) { + + for (Iterator iterator = logSet.iterator(); + + iterator.hasNext(); + + iterator.remove()) { + + writer.write(iterator.next().getLogMessage()); + + writer.write(System.getProperty("line.separator")); + + } + + writer.flush(); + + } + +} + + +在 AccessLogFilter 的构造方法中,会启动一个定时任务,定时调用上面介绍的 writeLogSetToFile() 方法,定时写入日志,具体实现如下: + +// 启动一个线程池 + +private static final ScheduledExecutorService LOG_SCHEDULED = + + Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("Dubbo-Access-Log", true)); + +// 启动一个定时任务,定期执行writeLogSetToFile()方法,完成日志写入 + +public AccessLogFilter() { + + LOG_SCHEDULED.scheduleWithFixedDelay( + + this::writeLogToFile, LOG_OUTPUT_INTERVAL, + + LOG_OUTPUT_INTERVAL, TimeUnit.MILLISECONDS); + +} + + +为便于你更好地理解这部分内容,下面我们再来看一下 Dubbo 对各种日志框架的支持,在 processWithServiceLogger() 方法中我们可以看到 Dubbo 是通过 LoggerFactory 来支持各种第三方日志框架的: + +private void processWithServiceLogger(Set logSet) { + + for (Iterator iterator = logSet.iterator(); + + iterator.hasNext(); + + iterator.remove()) { // 遍历logSet集合 + + AccessLogData logData = iterator.next(); + + // 通过LoggerFactory获取Logger对象,并写入日志 + + LoggerFactory.getLogger(LOG_KEY + "." + logData.getServiceName()).info(logData.getLogMessage()); + + } + +} + + +在 LoggerFactory 中维护了一个 LOGGERS 集合(Map 类型),其中维护了当前使用的全部 FailsafeLogger 对象;FailsafeLogger 对象中封装了一个 Logger 对象,这个 Logger 接口是 Dubbo 自己定义的接口,Dubbo 针对每种第三方框架都提供了一个 Logger 接口的实现,如下图所示: + + + +Logger 接口的实现 + +FailsafeLogger 是 Logger 对象的装饰器,它在每个 Logger 日志写入操作之外,都添加了 try/catch 异常处理。其他的 Dubbo Logger 实现类则是封装了相应第三方的 Logger 对象,并将日志输出操作委托给第三方的 Logger 对象完成。这里我们以 Log4j2Logger 为例进行简单分析: + +public class Log4j2Logger implements Logger { + + // 维护了一个log4j日志框架中的Logger对象,实现了适配器的功能 + + private final org.apache.logging.log4j.Logger logger; + + public Log4j2Logger(org.apache.logging.log4j.Logger logger) { + + this.logger = logger; + + } + + @Override + + public void info(String msg, Throwable e) { + + logger.info(msg, e); // 直接调用log4j日志框架的Logger写入日志 + + } + + ... // 省略info()方法的其他重载,省略error、trace、warn、debug等方法 + +} + + +在 LoggerFactory.getLogger() 方法中,是通过其中的 LOGGER_ADAPTER 字段(LoggerAdapter 类型) 获取 Logger 实现对象的: + +public static Logger getLogger(String key) { + + return LOGGERS.computeIfAbsent(key, k -> + + new FailsafeLogger(LOGGER_ADAPTER.getLogger(k))); + +} + + +LOGGER_ADAPTER 字段在 LoggerFactory.setLogger() 方法中,通过 SPI 机制初始化: + +public static void setLoggerAdapter(String loggerAdapter) { + + if (loggerAdapter != null && loggerAdapter.length() > 0) { + + setLoggerAdapter(ExtensionLoader.getExtensionLoader( + + LoggerAdapter.class).getExtension(loggerAdapter)); + + } + +} + + +LoggerAdapter 被 @SPI 注解修饰,是一个扩展接口,如下图所示,LoggerAdapter 对应每个第三方框架的一个相应实现,用于创建相应的 Dubbo Logger 实现对象。 + + + +LoggerAdapter 接口实现 + +以 Log4j2LoggerAdapter 为例,其核心在 getLogger() 方法中,主要是创建 Log4j2Logger 对象,具体实现如下: + +public class Log4j2LoggerAdapter implements LoggerAdapter { + + @Override + + public Logger getLogger(String key) { // 创建Log4j2Logger适配器 + + return new Log4j2Logger(LogManager.getLogger(key)); + + } + +} + + +ClassLoaderFilter + +ClassLoaderFilter 是 Provider 端的一个 Filter 实现,主要功能是切换类加载器。 + +在 ClassLoaderFilter.invoke() 方法中,首先获取当前线程关联的 contextClassLoader,然后将其 ContextClassLoader 设置为 invoker.getInterface().getClassLoader(),也就是加载服务接口类的类加载器;之后执行 invoker.invoke() 方法,执行后续的 Filter 逻辑以及业务逻辑;最后,将当前线程关联的 contextClassLoader 重置为原来的 contextClassLoader。ClassLoaderFilter 的核心逻辑如下: + +public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + ClassLoader ocl = Thread.currentThread().getContextClassLoader(); + + // 更新当前线程绑定的ClassLoader Thread.currentThread().setContextClassLoader(invoker.getInterface().getClassLoader()); + + try { + + return invoker.invoke(invocation); + + } finally { + + Thread.currentThread().setContextClassLoader(ocl); + + } + +} + + +ExecuteLimitFilter + +ExecuteLimitFilter 是 Dubbo 在 Provider 端限流的实现,与 Consumer 端的限流实现 ActiveLimitFilter 相对应。ExecuteLimitFilter 的核心实现与 ActiveLimitFilter类似,也是依赖 RpcStatus 的 beginCount() 方法和 endCount() 方法来实现 RpcStatus.active 字段的增减,具体实现如下: + +public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + URL url = invoker.getUrl(); + + String methodName = invocation.getMethodName(); + + int max = url.getMethodParameter(methodName, EXECUTES_KEY, 0); + + // 尝试增加active的值,当并发度达到executes配置指定的阈值,则直接抛出异常 + + if (!RpcStatus.beginCount(url, methodName, max)) { + + throw new RpcException("..."); + + } + + invocation.put(EXECUTE_LIMIT_FILTER_START_TIME, System.currentTimeMillis()); + + return invoker.invoke(invocation); // 执行后续Filter以及业务逻辑 + +} + + +ExecuteLimitFilter 同时还实现了 Filter 内部的 Listener 接口,在 onResponse() 方法和 onError() 方法中会调用 RpcStatus.endCount() 方法,减小 active 的值,同时完成对一次调用的统计,具体实现比较简单,这里就不再展示。 + +TimeoutFilter + +在前文介绍 ConsumerContextFilter 的时候可以看到,如果通过 TIME_COUNTDOWN_KEY 在 RpcContext 中配置了 TimeCountDown,就会对 TimeoutCountDown 进行检查,判定此次请求是否超时。然后,在 DubboInvoker 的 doInvoker() 方法实现中可以看到,在发起请求之前会调用 calculateTimeout() 方法确定该请求还有多久过期: + +private int calculateTimeout(Invocation invocation, String methodName) { + + Object countdown = RpcContext.getContext().get(TIME_COUNTDOWN_KEY); + + int timeout = DEFAULT_TIMEOUT; + + if (countdown == null) { // RpcContext中没有指定TIME_COUNTDOWN_KEY,则使用timeout配置 + + // 获取timeout配置指定的超时时长,默认值为1秒 + + timeout = (int) RpcUtils.getTimeout(getUrl(), methodName, RpcContext.getContext(), DEFAULT_TIMEOUT); + + if (getUrl().getParameter(ENABLE_TIMEOUT_COUNTDOWN_KEY, false)) { + + // 如果开启了ENABLE_TIMEOUT_COUNTDOWN_KEY,则通过TIMEOUT_ATTACHENT_KEY将超时时间传递给Provider端 + + invocation.setObjectAttachment(TIMEOUT_ATTACHENT_KEY, timeout); + + } + + } else { + + // 当前RpcContext中已经通过TIME_COUNTDOWN_KEY指定了超时时间,则使用该值作为超时时间 + + TimeoutCountDown timeoutCountDown = (TimeoutCountDown) countdown; + + timeout = (int) timeoutCountDown.timeRemaining(TimeUnit.MILLISECONDS); + + // 将剩余超时时间放入attachment中,传递给Provider端 + + invocation.setObjectAttachment(TIMEOUT_ATTACHENT_KEY, timeout); + + } + + return timeout; + +} + + +当请求到达 Provider 时,ContextFilter 会根据 Invocation 中的 attachment 恢复 RpcContext 的attachment,其中就包含 TIMEOUT_ATTACHENT_KEY(对应的 Value 会恢复成 TimeoutCountDown 对象)。 + +TimeoutFilter 是 Provider 端另一个涉及超时时间的 Filter 实现,其 invoke() 方法实现比较简单,直接将请求转发给后续 Filter 处理。在 TimeoutFilter 对 onResponse() 方法的实现中,会从 RpcContext 中读取上述 TimeoutCountDown 对象,并检查此次请求是否超时。如果请求已经超时,则会将 AppResponse 中的结果清空,同时打印一条警告日志,具体实现如下: + +public void onResponse(Result appResponse, Invoker invoker, Invocation invocation) { + + Object obj = RpcContext.getContext().get(TIME_COUNTDOWN_KEY); + + if (obj != null) { + + TimeoutCountDown countDown = (TimeoutCountDown) obj; + + if (countDown.isExpired()) { // 检查结果是否超时 + + ((AppResponse) appResponse).clear(); // 清理结果信息 + + if (logger.isWarnEnabled()) { + + logger.warn("..."); + + } + + } + + } + +} + + +TpsLimitFilter + +TpsLimitFilter 是 Provider 端对 TPS 限流的实现。TpsLimitFilter 中维护了一个 TPSLimiter 接口类型的对象,其默认实现是 DefaultTPSLimiter,由它来控制 Provider 端的 TPS 上限值为多少。TpsLimitFilter.invoke() 方法的具体实现如下所示: + +public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + // 超过限流之后,直接抛出异常 + + if (!tpsLimiter.isAllowable(invoker.getUrl(), invocation)) { + + throw new RpcException("... "); + + } + + return invoker.invoke(invocation); + +} + + +TPSLimiter 接口中的核心是 isAllowable() 方法。在 DefaultTPSLimiter 实现中,使用ConcurrentHashMap(stats 字段)为每个 ServiceKey 维护了一个相应的 StatItem 对象;在 isAllowable() 方法实现中,会从 URL 中读取 tps 参数值(默认为 -1,即没有限流),对于需要限流的请求,会从 stats 集合中获取(或创建)相应 StatItem 对象,然后调用 StatItem 对象的isAllowable() 方法判断是否被限流,具体实现如下: + +public boolean isAllowable(URL url, Invocation invocation) { + + int rate = url.getParameter(TPS_LIMIT_RATE_KEY, -1); + + long interval = url.getParameter(TPS_LIMIT_INTERVAL_KEY, DEFAULT_TPS_LIMIT_INTERVAL); + + String serviceKey = url.getServiceKey(); + + if (rate > 0) { // 需要限流,尝试从stats集合中获取相应的StatItem对象 + + StatItem statItem = stats.get(serviceKey); + + if (statItem == null) { // 查询stats集合失败,则创建新的StatItem对象 + + stats.putIfAbsent(serviceKey, new StatItem(serviceKey, rate, interval)); + + statItem = stats.get(serviceKey); + + } else { // URL中参数发生变化时,会重建对应的StatItem + + if (statItem.getRate() != rate || statItem.getInterval() != interval) { + + stats.put(serviceKey, new StatItem(serviceKey, rate, interval)); + + statItem = stats.get(serviceKey); + + } + + } + + return statItem.isAllowable(); + + } else { // 不需要限流,则从stats集合中清除相应的StatItem对象 + + StatItem statItem = stats.get(serviceKey); + + if (statItem != null) { + + stats.remove(serviceKey); + + } + + } + + return true; + +} + + +在 StatItem 中会记录如下一些关键信息。 + + +name(String 类型):对应的 ServiceKey。 +rate(int 类型):一段时间内能通过的 TPS 上限。 +token(LongAdder 类型):初始值为 rate 值,每通过一个请求 token 递减一,当减为 0 时,不再通过任何请求,实现限流的作用。 +interval(long 类型):重置 token 值的时间周期,这样就实现了在 interval 时间段内能够通过 rate 个请求的效果。 + + +下面我们来看 StatItem 中 isAllowable() 方法的实现: + +public boolean isAllowable() { + + long now = System.currentTimeMillis(); + + if (now > lastResetTime + interval) { // 周期性重置token + + token = buildLongAdder(rate); + + lastResetTime = now; // 记录最近一次重置token的时间戳 + + } + + if (token.sum() < 0) { // 请求限流 + + return false; + + } + + token.decrement(); // 请求正常通过 + + return true; + + } + + +到这里,Dubbo 中提供的核心 Filter 实现就介绍完了。不过,还有 EchoFilter 和 ExceptionFilter 这两个实现没有详细介绍,就留给你自行分析了,相信在了解上述 Filter 实现之后,你就可以非常轻松地阅读这两个 Filter 的源码。 + +自定义 Filter 实践 + +在了解完 Dubbo 加载 Filter 的原理以及 Dubbo 提供的多种 Filter 实现之后,下面我们就开始动手实现一个自定义的 Filter 实现,来进一步扩展 Dubbo 的功能。这里我们编写两个自定义的 Filter 实现类—— JarVersionConsumerFilter 和 JarVersionProviderFilter。 + + +JarVersionConsumerFilter 会获取服务接口所在 jar 包的版本,并作为 attachment 随请求发送到 Provider 端。 +JarVersionProviderFilter 会统计请求中携带的 jar 包版本,并周期性打印(实践中一般会和监控数据一起生成报表)。 + + +在实践中,我们可以通过这两个 Filter 实现,搞清楚当前所有 Consumer 端升级接口 jar 包的情况。 + +首先,我们来看 JarVersionConsumerFilter 实现中的几个关键点。 + + +JarVersionConsumerFilter 被 @Activate 注解修饰,其中的 group 字段值为 CommonConstants.CONSUMER,会在 Consumer 端自动激活,order 字段值为 -1 ,是最后执行的 Filter。 +JarVersionConsumerFilter 中维护了一个 LoadingCache 用于缓存各个业务接口与对应 jar 包版本号之间的映射关系。 +在 invoke() 方法的实现中,会通过 LoadingCache 查询接口所在 jar 包的版本号,然后记录到 Invocation 的 attachment 之中,发送到 Provider 端。 + + +下面是 JarVersionConsumerFilter 的具体实现: + +@Activate(group = {CommonConstants.CONSUMER}, order = -1) + +public class JarVersionConsumerFilter implements Filter { + + private static final String JAR_VERSION_NAME_KEY = "dubbo.jar.version"; + + // 通过一个LoadingCache缓存各个Class所在的jar包版本 + + private LoadingCache, String> versionCache = CacheBuilder.newBuilder() + + .maximumSize(1024).build(new CacheLoader, String>() { + + @Override + + public String load(Class key) throws Exception { + + return getJarVersion(key); + + } + + }); + + @Override + + public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + Map attachments = invocation.getAttachments(); + + String version = versionCache.getUnchecked(invoker.getInterface()); + + if (!StringUtils.isBlank(version)) { // 添加版本号 + + attachments.put(JAR_VERSION_NAME_KEY, version); + + } + + return invoker.invoke(invocation); + + } + + // 读取Classpath下的"/META-INF/MANIFEST.MF"文件,获取jar包版本 + + private String getJarVersion(Class clazz) { + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(clazz.getResourceAsStream("/META-INF/MANIFEST.MF")))) { + + String s = null; + + while ((s = reader.readLine()) != null) { + + int i = s.indexOf("Implementation-Version:"); + + if (i > 0) { + + return s.substring(i); + + } + + } + + } catch (IOException e) { + + // 省略异常处理逻辑 + + } + + return ""; + + } + +} + + +JarVersionProviderFilter 的实现就非常简单了,它会读取请求中的版本信息,并将关联的计数器加一。另外,JarVersionProviderFilter 的构造方法中会启动一个定时任务,每隔一分钟执行一次,将统计结果打印到日志中(在生产环境一般会将这些统计数据生成报表展示)。 + +JarVersionProviderFilter 既然要运行在 Provider 端,那就需要将其 @Activate 注解的 group 字段设置为 CommonConstants.PROVIDER 常量。JarVersionProviderFilter 的具体实现如下: + +@Activate(group = {CommonConstants.PROVIDER}, order = -1) + +public class JarVersionProviderFilter implements Filter { + + private static final String JAR_VERSION_NAME_KEY = "dubbo.jar.version"; + + private static final Map versionState = new ConcurrentHashMap<>(); + + private static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE = Executors.newScheduledThreadPool(1); + + public JarVersionProviderFilter() { // 启动定时任务 + + SCHEDULED_EXECUTOR_SERVICE.schedule(() -> { + + for (Map.Entry entry : versionState.entrySet()) { + + System.out.println(entry.getKey() + ":" + entry.getValue().getAndSet(0)); // 打印日志并将统计数据重置 + + } + + }, 1, TimeUnit.MINUTES); + + } + + @Override + + public Result invoke(Invoker invoker, Invocation invocation) throws RpcException { + + String versionAttachment = invocation.getAttachment(JAR_VERSION_NAME_KEY); + + if (!StringUtils.isBlank(versionAttachment)) { + + AtomicLong count = versionState.computeIfAbsent(versionAttachment, v -> new AtomicLong(0L)); + + count.getAndIncrement(); // 递增该版本的统计值 + + } + + return invoker.invoke(invocation); + + } + +} + + +最后,我们需要在 Provider 项目的 /resources/META-INF/dubbo 目录下添加一个 SPI 配置文件,文件名称为 org.apache.dubbo.rpc.Filter,具体内容如下: + +version-provider=org.apache.dubbo.demo.provider.JarVersionProviderFilter + + +同样,也需要在 Consumer 项目相同位置添加相同的 SPI 配置文件(文件名称也相同),具体内容如下: + +version-consumer=org.apache.dubbo.demo.consumer.JarVersionConsumerFilter + + +总结 + +本课时重点介绍了 Dubbo 中 Filter 接口的相关实现。首先,我们回顾了 Filter 链的加载流程实现;然后详细分析了 Dubbo 中多个内置的 Filter 实现,这些内置 Filter 对于实现 Dubbo 核心功能是不可或缺的;最后,我们还阐述了自定义 Filter 扩展 Dubbo 功能的流程,并通过一个统计 jar 包版本的示例进行说明。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/31加餐:深潜Directory实现,探秘服务目录玄机.md b/专栏/Dubbo源码解读与实战-完/31加餐:深潜Directory实现,探秘服务目录玄机.md new file mode 100644 index 0000000..5be496b --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/31加餐:深潜Directory实现,探秘服务目录玄机.md @@ -0,0 +1,598 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 31 加餐:深潜 Directory 实现,探秘服务目录玄机 + 从这一课时我们就进入“集群”模块了,今天我们分享的是一篇加餐文章,主题是:深潜 Directory 实现,探秘服务目录玄机。 + +在生产环境中,为了保证服务的可靠性、吞吐量以及容错能力,我们通常会在多个服务器上运行相同的服务端程序,然后以集群的形式对外提供服务。根据各项性能指标的要求不同,各个服务端集群中服务实例的个数也不尽相同,从几个实例到几百个实例不等。 + +对于客户端程序来说,就会出现几个问题: + + +客户端程序是否要感知每个服务端地址? +客户端程序的一次请求,到底调用哪个服务端程序呢? +请求失败之后的处理是重试,还会是抛出异常? +如果是重试,是再次请求该服务实例,还是尝试请求其他服务实例? +服务端集群如何做到负载均衡,负载均衡的标准是什么呢? +…… + + +为了解决上述问题,Dubbo 独立出了一个实现集群功能的模块—— dubbo-cluster。 + + + +dubbo-cluster 结构图 + +作为 dubbo-cluster 模块分析的第一课时,我们就首先来了解一下 dubbo-cluster 模块的架构以及最核心的 Cluster 接口。 + +Cluster 架构 + +dubbo-cluster 模块的主要功能是将多个 Provider 伪装成一个 Provider 供 Consumer 调用,其中涉及集群的容错处理、路由规则的处理以及负载均衡。下图展示了 dubbo-cluster 的核心组件: + + + +Cluster 核心接口图 + +由图我们可以看出,dubbo-cluster 主要包括以下四个核心接口: + + +Cluster 接口,是集群容错的接口,主要是在某些 Provider 节点发生故障时,让 Consumer 的调用请求能够发送到正常的 Provider 节点,从而保证整个系统的可用性。 +Directory 接口,表示多个 Invoker 的集合,是后续路由规则、负载均衡策略以及集群容错的基础。 +Router 接口,抽象的是路由器,请求经过 Router 的时候,会按照用户指定的规则匹配出符合条件的 Provider。 +LoadBalance 接口,是负载均衡接口,Consumer 会按照指定的负载均衡策略,从 Provider 集合中选出一个最合适的 Provider 节点来处理请求。 + + +Cluster 层的核心流程是这样的:当调用进入 Cluster 的时候,Cluster 会创建一个 AbstractClusterInvoker 对象,在这个 AbstractClusterInvoker 中,首先会从 Directory 中获取当前 Invoker 集合;然后按照 Router 集合进行路由,得到符合条件的 Invoker 集合;接下来按照 LoadBalance 指定的负载均衡策略得到最终要调用的 Invoker 对象。 + +了解了 dubbo-cluster 模块的核心架构和基础组件之后,我们后续将会按照上面架构图的顺序介绍每个接口的定义以及相关实现。 + +Directory 接口详解 + +Directory 接口表示的是一个集合,该集合由多个 Invoker 构成,后续的路由处理、负载均衡、集群容错等一系列操作都是在 Directory 基础上实现的。 + +下面我们深入分析一下 Directory 的相关内容,首先是 Directory 接口中定义的方法: + +public interface Directory extends Node { + + // 服务接口类型 + + Class getInterface(); + + // list()方法会根据传入的Invocation请求,过滤自身维护的Invoker集合,返回符合条件的Invoker集合 + + List> list(Invocation invocation) throws RpcException; + + // getAllInvokers()方法返回当前Directory对象维护的全部Invoker对象 + + List> getAllInvokers(); + + // Consumer端的URL + + URL getConsumerUrl(); + +} + + +AbstractDirectory 是 Directory 接口的抽象实现,其中除了维护 Consumer 端的 URL 信息,还维护了一个 RouterChain 对象,用于记录当前使用的 Router 对象集合,也就是后面课时要介绍的路由规则。 + +AbstractDirectory 对 list() 方法的实现也比较简单,就是直接委托给了 doList() 方法,doList() 是个抽象方法,由 AbstractDirectory 的子类具体实现。 + +Directory 接口有 RegistryDirectory 和 StaticDirectory 两个具体实现,如下图所示: + + + +Directory 接口继承关系图 + +其中,RegistryDirectory 实现中维护的 Invoker 集合会随着注册中心中维护的注册信息动态发生变化,这就依赖了 ZooKeeper 等注册中心的推送能力;StaticDirectory 实现中维护的 Invoker 集合则是静态的,在 StaticDirectory 对象创建完成之后,不会再发生变化。 + +下面我们就来分别介绍 Directory 接口的这两个具体实现。 + +1. StaticDirectory + +StaticDirectory 这个 Directory 实现比较简单,在构造方法中,StaticDirectory 会接收一个 Invoker 集合,并赋值到自身的 invokers 字段中,作为底层的 Invoker 集合。在 doList() 方法中,StaticDirectory 会使用 RouterChain 中的 Router 从 invokers 集合中过滤出符合路由规则的 Invoker 对象集合,具体实现如下: + +protected List> doList(Invocation invocation) throws RpcException { + + List> finalInvokers = invokers; + + if (routerChain != null) { // 通过RouterChain过滤出符合条件的Invoker集合 + + finalInvokers = routerChain.route(getConsumerUrl(), invocation); + + } + + return finalInvokers == null ? Collections.emptyList() : finalInvokers; + +} + + +在创建 StaticDirectory 对象的时候,如果没有传入 RouterChain 对象,则会根据 URL 构造一个包含内置 Router 的 RouterChain 对象: + +public void buildRouterChain() { + + RouterChain routerChain = RouterChain.buildChain(getUrl()); // 创建内置Router集合 + + // 将invokers与RouterChain关联 + + routerChain.setInvokers(invokers); + + this.setRouterChain(routerChain); // 设置routerChain字段 + +} + + +2. RegistryDirectory + +RegistryDirectory 是一个动态的 Directory 实现,实现了 NotifyListener 接口,当注册中心的服务配置发生变化时,RegistryDirectory 会收到变更通知,然后RegistryDirectory 会根据注册中心推送的通知,动态增删底层 Invoker 集合。 + +下面我们先来看一下 RegistryDirectory 中的核心字段。 + + +cluster(Cluster 类型):集群策略适配器,这里通过 Dubbo SPI 方式(即 ExtensionLoader.getAdaptiveExtension() 方法)动态创建适配器实例。 +routerFactory(RouterFactory 类型):路由工厂适配器,也是通过 Dubbo SPI 动态创建的适配器实例。routerFactory 字段和 cluster 字段都是静态字段,多个 RegistryDirectory 对象通用。 +serviceKey(String 类型):服务对应的 ServiceKey,默认是 {interface}:[group]:[version] 三部分构成。 +serviceType(Class 类型):服务接口类型,例如,org.apache.dubbo.demo.DemoService。 +queryMap(Map 类型):Consumer URL 中 refer 参数解析后得到的全部 KV。 +directoryUrl(URL 类型):只保留 Consumer 属性的 URL,也就是由 queryMap 集合重新生成的 URL。 +multiGroup(boolean类型):是否引用多个服务组。 +protocol(Protocol 类型):使用的 Protocol 实现。 +registry(Registry 类型):使用的注册中心实现。 +invokers(volatile List 类型):动态更新的 Invoker 集合。 +urlInvokerMap(volatile Map< String, Invoker> 类型):Provider URL 与对应 Invoker 之间的映射,该集合会与 invokers 字段同时动态更新。 +cachedInvokerUrls(volatile Set类型):当前缓存的所有 Provider 的 URL,该集合会与 invokers 字段同时动态更新。 +configurators(volatile List< Configurator>类型):动态更新的配置信息,配置的具体内容在后面的分析中会介绍到。 + + +在 RegistryDirectory 的构造方法中,会根据传入的注册中心 URL 初始化上述核心字段,具体实现如下: + +public RegistryDirectory(Class serviceType, URL url) { + + // 传入的url参数是注册中心的URL,例如,zookeeper://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?...,其中refer参数包含了Consumer信息,例如,refer=application=dubbo-demo-api-consumer&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&pid=13423®ister.ip=192.168.124.3&side=consumer(URLDecode之后的值) + + super(url); + + shouldRegister = !ANY_VALUE.equals(url.getServiceInterface()) && url.getParameter(REGISTER_KEY, true); + + shouldSimplified = url.getParameter(SIMPLIFIED_KEY, false); + + this.serviceType = serviceType; + + this.serviceKey = url.getServiceKey(); + + // 解析refer参数值,得到其中Consumer的属性信息 + + this.queryMap = StringUtils.parseQueryString(url.getParameterAndDecoded(REFER_KEY)); + + // 将queryMap中的KV作为参数,重新构造URL,其中的protocol和path部分不变 + + this.overrideDirectoryUrl = this.directoryUrl = turnRegistryUrlToConsumerUrl(url); + + String group = directoryUrl.getParameter(GROUP_KEY, ""); + + this.multiGroup = group != null && (ANY_VALUE.equals(group) || group.contains(",")); + +} + + +在完成初始化之后,我们来看 subscribe() 方法,该方法会在 Consumer 进行订阅的时候被调用,其中调用 Registry 的 subscribe() 完成订阅操作,同时还会将当前 RegistryDirectory 对象作为 NotifyListener 监听器添加到 Registry 中,具体实现如下: + +public void subscribe(URL url) { + + setConsumerUrl(url); + + // 将当前RegistryDirectory对象作为ConfigurationListener记录到CONSUMER_CONFIGURATION_LISTENER中 + + CONSUMER_CONFIGURATION_LISTENER.addNotifyListener(this); + + serviceConfigurationListener = new ReferenceConfigurationListener(this, url); + + // 完成订阅操作,注册中心的相关操作在前文已经介绍过了,这里不再重复 + + registry.subscribe(url, this); + +} + + +我们看到除了作为 NotifyListener 监听器之外,RegistryDirectory 内部还有两个 ConfigurationListener 的内部类(继承关系如下图所示),为了保持连贯,这两个监听器的具体原理我们在后面的课时中会详细介绍,这里先不展开讲述。 + + + +RegistryDirectory 内部的 ConfigurationListener 实现 + +通过前面对 Registry 的介绍我们知道,在注册 NotifyListener 的时候,监听的是 providers、configurators 和 routers 三个目录,所以在这三个目录下发生变化的时候,就会触发 RegistryDirectory 的 notify() 方法。 + +在 RegistryDirectory.notify() 方法中,首先会按照 category 对发生变化的 URL 进行分类,分成 configurators、routers、providers 三类,并分别对不同类型的 URL 进行处理: + + +将 configurators 类型的 URL 转化为 Configurator,保存到 configurators 字段中; +将 router 类型的 URL 转化为 Router,并通过 routerChain.addRouters() 方法添加 routerChain 中保存; +将 provider 类型的 URL 转化为 Invoker 对象,并记录到 invokers 集合和 urlInvokerMap 集合中。 + + +notify() 方法的具体实现如下: + +public synchronized void notify(List urls) { + + // 按照category进行分类,分成configurators、routers、providers三类 + + Map> categoryUrls = urls.stream() + + .filter(Objects::nonNull) + + .filter(this::isValidCategory) + + .filter(this::isNotCompatibleFor26x) + + .collect(Collectors.groupingBy(this::judgeCategory)); + + // 获取configurators类型的URL,并转换成Configurator对象 + + List configuratorURLs = categoryUrls.getOrDefault(CONFIGURATORS_CATEGORY, Collections.emptyList()); + + this.configurators = Configurator.toConfigurators(configuratorURLs).orElse(this.configurators); + + // 获取routers类型的URL,并转成Router对象,添加到RouterChain中 + + List routerURLs = categoryUrls.getOrDefault(ROUTERS_CATEGORY, Collections.emptyList()); + + toRouters(routerURLs).ifPresent(this::addRouters); + + // 获取providers类型的URL,调用refreshOverrideAndInvoker()方法进行处理 + + List providerURLs = categoryUrls.getOrDefault(PROVIDERS_CATEGORY, Collections.emptyList()); + + ... // 在Dubbo3.0中会触发AddressListener监听器,但是现在AddressListener接口还没有实现,所以省略这段代码 + + refreshOverrideAndInvoker(providerURLs); + +} + + +我们这里首先来专注providers 类型 URL 的处理,具体实现位置在 refreshInvoker() 方法中,具体实现如下: + +private void refreshInvoker(List invokerUrls) { + + // 如果invokerUrls集合不为空,长度为1,并且协议为empty,则表示该服务的所有Provider都下线了,会销毁当前所有Provider对应的Invoker。 + + if (invokerUrls.size() == 1 && invokerUrls.get(0) != null + + && EMPTY_PROTOCOL.equals(invokerUrls.get(0).getProtocol())) { + + this.forbidden = true; // forbidden标记设置为true,后续请求将直接抛出异常 + + this.invokers = Collections.emptyList(); + + routerChain.setInvokers(this.invokers); // 清空RouterChain中的Invoker集合 + + destroyAllInvokers(); // 关闭所有Invoker对象 + + } else { + + this.forbidden = false; // forbidden标记设置为false,RegistryDirectory可以正常处理后续请求 + + Map> oldUrlInvokerMap = this.urlInvokerMap; // 保存本地引用 + + if (invokerUrls == Collections.emptyList()) { + + invokerUrls = new ArrayList<>(); + + } + + if (invokerUrls.isEmpty() && this.cachedInvokerUrls != null) { + + // 如果invokerUrls集合为空,并且cachedInvokerUrls不为空,则将使用cachedInvokerUrls缓存的数据, + + // 也就是说注册中心中的providers目录未发生变化,invokerUrls则为空,表示cachedInvokerUrls集合中缓存的URL为最新的值 + + invokerUrls.addAll(this.cachedInvokerUrls); + + } else { + + // 如果invokerUrls集合不为空,则用invokerUrls集合更新cachedInvokerUrls集合 + + // 也就是说,providers发生变化,invokerUrls集合中会包含此时注册中心所有的服务提供者 + + this.cachedInvokerUrls = new HashSet<>(); + + this.cachedInvokerUrls.addAll(invokerUrls);//Cached invoker urls, convenient for comparison + + } + + if (invokerUrls.isEmpty()) { + + return; // 如果invokerUrls集合为空,即providers目录未发生变更,则无须处理,结束本次更新服务提供者Invoker操作。 + + } + + // 将invokerUrls转换为对应的Invoker映射关系 + + Map> newUrlInvokerMap = toInvokers(invokerUrls); + + if (CollectionUtils.isEmptyMap(newUrlInvokerMap)) { + + return; + + } + + // 更新invokers字段和urlInvokerMap集合 + + List> newInvokers = Collections.unmodifiableList(new ArrayList<>(newUrlInvokerMap.values())); + + routerChain.setInvokers(newInvokers); + + // 针对multiGroup的特殊处理,合并多个group的Invoker + + this.invokers = multiGroup ? toMergeInvokerList(newInvokers) : newInvokers; + + this.urlInvokerMap = newUrlInvokerMap; + + // 比较新旧两组Invoker集合,销毁掉已经下线的Invoker + + destroyUnusedInvokers(oldUrlInvokerMap, newUrlInvokerMap); + + } + +} + + +通过对 refreshInvoker() 方法的介绍,我们可以看出,其最核心的逻辑是 Provider URL 转换成 Invoker 对象,也就是 toInvokers() 方法。下面我们就来深入 toInvokers() 方法内部,看看其具体的转换逻辑: + +private Map> toInvokers(List urls) { + + ... // urls集合为空时,直接返回空Map + + Set keys = new HashSet<>(); + + String queryProtocols = this.queryMap.get(PROTOCOL_KEY); // 获取Consumer端支持的协议,即protocol参数指定的协议 + + for (URL providerUrl : urls) { + + if (queryProtocols != null && queryProtocols.length() > 0) { + + boolean accept = false; + + String[] acceptProtocols = queryProtocols.split(","); + + for (String acceptProtocol : acceptProtocols) { // 遍历所有Consumer端支持的协议 + + if (providerUrl.getProtocol().equals(acceptProtocol)) { + + accept = true; + + break; + + } + + } + + if (!accept) { + + continue; // 如果当前URL不支持Consumer端的协议,也就无法执行后续转换成Invoker的逻辑 + + } + + } + + if (EMPTY_PROTOCOL.equals(providerUrl.getProtocol())) { + + continue; // 跳过empty协议的URL + + } + + // 如果Consumer端不支持该URL的协议(这里通过SPI方式检测是否有对应的Protocol扩展实现),也会跳过该URL + + if (!ExtensionLoader.getExtensionLoader(Protocol.class).hasExtension(providerUrl.getProtocol())) { + + logger.error("..."); + + continue; + + } + + // 合并URL参数,这个合并过程,在本课时后面展开介绍 + + URL url = mergeUrl(providerUrl); + + // 获取完整URL对应的字符串,也就是在urlInvokerMap集合中的key + + String key = url.toFullString(); + + if (keys.contains(key)) { // 跳过重复的URL + + continue; + + } + + keys.add(key); // 记录key + + // 匹配urlInvokerMap缓存中的Invoker对象,如果命中缓存,直接将Invoker添加到newUrlInvokerMap这个新集合中即可; + + // 如果未命中缓存,则创建新的Invoker对象,然后添加到newUrlInvokerMap这个新集合中 + + Map> localUrlInvokerMap = this.urlInvokerMap; + + Invoker invoker = localUrlInvokerMap == null ? null : localUrlInvokerMap.get(key); + + if (invoker == null) { + + try { + + boolean enabled = true; + + if (url.hasParameter(DISABLED_KEY)) { // 检测URL中的disable和enable参数,决定是否能够创建Invoker对象 + + enabled = !url.getParameter(DISABLED_KEY, false); + + } else { + + enabled = url.getParameter(ENABLED_KEY, true); + + } + + if (enabled) { // 这里通过Protocol.refer()方法创建对应的Invoker对象 + + invoker = new InvokerDelegate<>(protocol.refer(serviceType, url), url, providerUrl); + + } + + } catch (Throwable t) { + + logger.error("Failed to refer invoker for interface:" + serviceType + ",url:(" + url + ")" + t.getMessage(), t); + + } + + if (invoker != null) { // 将key和Invoker对象之间的映射关系记录到newUrlInvokerMap中 + + newUrlInvokerMap.put(key, invoker); + + } + + } else {// 缓存命中,直接将urlInvokerMap中的Invoker转移到newUrlInvokerMap即可 + + newUrlInvokerMap.put(key, invoker); + + } + + } + + keys.clear(); + + return newUrlInvokerMap; + +} + + +toInvokers() 方法的代码虽然有点长,但核心逻辑就是调用 Protocol.refer() 方法创建 Invoker 对象,其他的逻辑都是在判断是否调用该方法。 + +在 toInvokers() 方法内部,我们可以看到调用了 mergeUrl() 方法对 URL 参数进行合并。在 mergeUrl() 方法中,会将注册中心中 configurators 目录下的 URL(override 协议),以及服务治理控制台动态添加的配置与 Provider URL 进行合并,即覆盖 Provider URL 原有的一些信息,具体实现如下: + +private URL mergeUrl(URL providerUrl) { + + // 首先,移除Provider URL中只在Provider端生效的属性,例如,threadname、threadpool、corethreads、threads、queues等参数。 + + // 然后,用Consumer端的配置覆盖Provider URL的相应配置,其中,version、group、methods、timestamp等参数以Provider端的配置优先 + + // 最后,合并Provider端和Consumer端配置的Filter以及Listener + + providerUrl = ClusterUtils.mergeUrl(providerUrl, queryMap); + + // 合并configurators类型的URL,configurators类型的URL又分为三类: + + // 第一类是注册中心Configurators目录下新增的URL(override协议) + + // 第二类是通过ConsumerConfigurationListener监听器(监听应用级别的配置)得到的动态配置 + + // 第三类是通过ReferenceConfigurationListener监听器(监听服务级别的配置)得到的动态配置 + + // 这里只需要先了解:除了注册中心的configurators目录下有配置信息之外,还有可以在服务治理控制台动态添加配置, + + // ConsumerConfigurationListener、ReferenceConfigurationListener监听器就是用来监听服务治理控制台的动态配置的 + + // 至于服务治理控制台的具体使用,在后面详细介绍 + + providerUrl = overrideWithConfigurator(providerUrl); + + // 增加check=false,即只有在调用时,才检查Provider是否可用 + + providerUrl = providerUrl.addParameter(Constants.CHECK_KEY, String.valueOf(false)); + + // 重新复制overrideDirectoryUrl,providerUrl在经过第一步参数合并后(包含override协议覆盖后的属性)赋值给overrideDirectoryUrl。 + + this.overrideDirectoryUrl = this.overrideDirectoryUrl.addParametersIfAbsent(providerUrl.getParameters()); + + ... // 省略对Dubbo低版本的兼容处理逻辑 + + return providerUrl; + +} + + +完成 URL 到 Invoker 对象的转换(toInvokers() 方法)之后,其实在 refreshInvoker() 方法的最后,还会根据 multiGroup 的配置决定是否调用 toMergeInvokerList() 方法将每个 group 中的 Invoker 合并成一个 Invoker。下面我们一起来看 toMergeInvokerList() 方法的具体实现: + +private List> toMergeInvokerList(List> invokers) { + + List> mergedInvokers = new ArrayList<>(); + + Map>> groupMap = new HashMap<>(); + + for (Invoker invoker : invokers) { // 按照group将Invoker分组 + + String group = invoker.getUrl().getParameter(GROUP_KEY, ""); + + groupMap.computeIfAbsent(group, k -> new ArrayList<>()); + + groupMap.get(group).add(invoker); + + } + + if (groupMap.size() == 1) { // 如果只有一个group,则直接使用该group分组对应的Invoker集合作为mergedInvokers + + mergedInvokers.addAll(groupMap.values().iterator().next()); + + } else if (groupMap.size() > 1) { // 将每个group对应的Invoker集合合并成一个Invoker + + for (List> groupList : groupMap.values()) { + + // 这里使用到StaticDirectory以及Cluster合并每个group中的Invoker + + StaticDirectory staticDirectory = new StaticDirectory<>(groupList); + + staticDirectory.buildRouterChain(); + + mergedInvokers.add(CLUSTER.join(staticDirectory)); + + } + + } else { + + mergedInvokers = invokers; + + } + + return mergedInvokers; + +} + + +这里使用到了 Cluster 接口的相关功能,我们在后面课时还会继续深入分析 Cluster 接口及其实现,你现在可以将 Cluster 理解为一个黑盒,知道其 join() 方法会将多个 Invoker 对象转换成一个 Invoker 对象即可。 + +到此为止,RegistryDirectory 处理一次完整的动态 Provider 发现流程就介绍完了。 + +最后,我们再分析下RegistryDirectory 中另外一个核心方法—— doList() 方法,该方法是 AbstractDirectory 留给其子类实现的一个方法,也是通过 Directory 接口获取 Invoker 集合的核心所在,具体实现如下: + +public List> doList(Invocation invocation) { + + if (forbidden) { // 检测forbidden字段,当该字段在refreshInvoker()过程中设置为true时,表示无Provider可用,直接抛出异常 + + throw new RpcException("..."); + + } + + if (multiGroup) { + + // multiGroup为true时的特殊处理,在refreshInvoker()方法中针对multiGroup为true的场景,已经使用Router进行了筛选,所以这里直接返回接口 + + return this.invokers == null ? Collections.emptyList() : this.invokers; + + } + + List> invokers = null; + + // 通过RouterChain.route()方法筛选Invoker集合,最终得到符合路由条件的Invoker集合 + + invokers = routerChain.route(getConsumerUrl(), invocation); + + return invokers == null ? Collections.emptyList() : invokers; + +} + + +总结 + +在本课时,我们首先介绍了 dubbo-cluster 模块的整体架构,简单说明了 Cluster、Directory、Router、LoadBalance 四个核心接口的功能。接下来我们就深入介绍了 Directory 接口的定义以及 StaticDirectory、RegistryDirectory 两个类的核心实现,其中 RegistryDirectory 涉及动态查找 Provider URL 以及处理动态配置的相关逻辑,显得略微复杂了一点,希望你能耐心学习和理解。关于这部分内容,你若有不懂或不理解的地方,也欢迎你留言和我交流。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/32路由机制:请求到底怎么走,它说了算(上).md b/专栏/Dubbo源码解读与实战-完/32路由机制:请求到底怎么走,它说了算(上).md new file mode 100644 index 0000000..580989b --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/32路由机制:请求到底怎么走,它说了算(上).md @@ -0,0 +1,495 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 32 路由机制:请求到底怎么走,它说了算(上) + 作为 dubbo-cluster 模块分析的第二课时,本课时我们就来介绍一下 dubbo-cluster 模块中涉及的另一个核心概念—— Router。 + +Router 的主要功能就是根据用户配置的路由规则以及请求携带的信息,过滤出符合条件的 Invoker 集合,供后续负载均衡逻辑使用。在上一课时介绍 RegistryDirectory 实现的时候,我们就已经看到了 RouterChain 这个 Router 链的存在,但是没有深入分析,下面我们就来深入 Router 进行分析。 + +RouterChain、RouterFactory 与 Router + +首先我们来看 RouterChain 的核心字段。 + + +invokers(List`> 类型):当前 RouterChain 对象要过滤的 Invoker 集合。我们可以看到,在 StaticDirectory 中是通过 RouterChain.setInvokers() 方法进行设置的。 +builtinRouters(List 类型):当前 RouterChain 激活的内置 Router 集合。 +routers(List 类型):当前 RouterChain 中真正要使用的 Router 集合,其中不仅包括了上面 builtinRouters 集合中全部的 Router 对象,还包括通过 addRouters() 方法添加的 Router 对象。 + + +在 RouterChain 的构造函数中,会在传入的 URL 参数中查找 router 参数值,并根据该值获取确定激活的 RouterFactory,之后通过 Dubbo SPI 机制加载这些激活的 RouterFactory 对象,由 RouterFactory 创建当前激活的内置 Router 实例,具体实现如下: + +private RouterChain(URL url) { + + // 通过ExtensionLoader加载激活的RouterFactory + + List extensionFactories = ExtensionLoader.getExtensionLoader(RouterFactory.class) + + .getActivateExtension(url, "router"); + + // 遍历所有RouterFactory,调用其getRouter()方法创建相应的Router对象 + + List routers = extensionFactories.stream() + + .map(factory -> factory.getRouter(url)) + + .collect(Collectors.toList()); + + initWithRouters(routers); // 初始化buildinRouters字段以及routers字段 + +} + +public void initWithRouters(List builtinRouters) { + + this.builtinRouters = builtinRouters; + + this.routers = new ArrayList<>(builtinRouters); + + this.sort(); // 这里会对routers集合进行排序 + +} + + +完成内置 Router 的初始化之后,在 Directory 实现中还可以通过 addRouter() 方法添加新的 Router 实例到 routers 字段中,具体实现如下: + +public void addRouters(List routers) { + + List newRouters = new ArrayList<>(); + + newRouters.addAll(builtinRouters); // 添加builtinRouters集合 + + newRouters.addAll(routers); // 添加传入的Router集合 + + CollectionUtils.sort(newRouters); // 重新排序 + + this.routers = newRouters; + +} + + +RouterChain.route() 方法会遍历 routers 字段,逐个调用 Router 对象的 route() 方法,对 invokers 集合进行过滤,具体实现如下: + +public List> route(URL url, Invocation invocation) { + + List> finalInvokers = invokers; + + for (Router router : routers) { // 遍历全部的Router对象 + + finalInvokers = router.route(finalInvokers, url, invocation); + + } + + return finalInvokers; + +} + + +了解了 RouterChain 的大致逻辑之后,我们知道真正进行路由的是 routers 集合中的 Router 对象。接下来我们再来看 RouterFactory 这个工厂接口,RouterFactory 接口是一个扩展接口,具体定义如下: + +@SPI + +public interface RouterFactory { + + @Adaptive("protocol") // 动态生成的适配器会根据protocol参数选择扩展实现 + + Router getRouter(URL url); + +} + + +RouterFactory 接口有很多实现类,如下图所示: + + + +RouterFactory 继承关系图 + +下面我们就来深入介绍下每个 RouterFactory 实现类以及对应的 Router 实现对象。Router 决定了一次 Dubbo 调用的目标服务,Router 接口的每个实现类代表了一个路由规则,当 Consumer 访问 Provider 时,Dubbo 根据路由规则筛选出合适的 Provider 列表,之后通过负载均衡算法再次进行筛选。Router 接口的继承关系如下图所示: + + + +Router 继承关系图 + +接下来我们就开始介绍 RouterFactory 以及 Router 的具体实现。 + +ConditionRouterFactory&ConditionRouter + +首先来看 ConditionRouterFactory 实现,其扩展名为 condition,在其 getRouter() 方法中会创建 ConditionRouter 对象,如下所示: + +public Router getRouter(URL url) { + + return new ConditionRouter(url); + +} + + +ConditionRouter 是基于条件表达式的路由实现类,下面就是一条基于条件表达式的路由规则: + +host = 192.168.0.100 => host = 192.168.0.150 + + +在上述规则中,=>之前的为 Consumer 匹配的条件,该条件中的所有参数会与 Consumer 的 URL 进行对比,当 Consumer 满足匹配条件时,会对该 Consumer 的此次调用执行 => 后面的过滤规则。 + +=> 之后为 Provider 地址列表的过滤条件,该条件中的所有参数会和 Provider 的 URL 进行对比,Consumer 最终只拿到过滤后的地址列表。 + +如果 Consumer 匹配条件为空,表示 => 之后的过滤条件对所有 Consumer 生效,例如:=> host != 192.168.0.150,含义是所有 Consumer 都不能请求 192.168.0.150 这个 Provider 节点。 + +如果 Provider 过滤条件为空,表示禁止访问所有 Provider,例如:host = 192.168.0.100 =>,含义是 192.168.0.100 这个 Consumer 不能访问任何 Provider 节点。 + +ConditionRouter 的核心字段有如下几个。 + + +url(URL 类型):路由规则的 URL,可以从 rule 参数中获取具体的路由规则。 +ROUTE_PATTERN(Pattern 类型):用于切分路由规则的正则表达式。 +priority(int 类型):路由规则的优先级,用于排序,该字段值越大,优先级越高,默认值为 0。 +force(boolean 类型):当路由结果为空时,是否强制执行。如果不强制执行,则路由结果为空的路由规则将会自动失效;如果强制执行,则直接返回空的路由结果。 +whenCondition(Map 类型):Consumer 匹配的条件集合,通过解析条件表达式 rule 的 => 之前半部分,可以得到该集合中的内容。 +thenCondition(Map 类型):Provider 匹配的条件集合,通过解析条件表达式 rule 的 => 之后半部分,可以得到该集合中的内容。 + + +在 ConditionRouter 的构造方法中,会根据 URL 中携带的相应参数初始化 priority、force、enable 等字段,然后从 URL 的 rule 参数中获取路由规则进行解析,具体的解析逻辑是在 init() 方法中实现的,如下所示: + +public void init(String rule) { + + // 将路由规则中的"consumer."和"provider."字符串清理掉 + + rule = rule.replace("consumer.", "").replace("provider.", ""); + + // 按照"=>"字符串进行分割,得到whenRule和thenRule两部分 + + int i = rule.indexOf("=>"); + + String whenRule = i < 0 ? null : rule.substring(0, i).trim(); + + String thenRule = i < 0 ? rule.trim() : rule.substring(i + 2).trim(); + + // 解析whenRule和thenRule,得到whenCondition和thenCondition两个条件集合 + + Map when = StringUtils.isBlank(whenRule) || "true".equals(whenRule) ? new HashMap() : parseRule(whenRule); + + Map then = StringUtils.isBlank(thenRule) || "false".equals(thenRule) ? null : parseRule(thenRule); + + this.whenCondition = when; + + this.thenCondition = then; + +} + + +whenCondition 和 thenCondition 两个集合中,Key 是条件表达式中指定的参数名称(例如 host = 192.168.0.150 这个表达式中的 host)。ConditionRouter 支持三类参数: + + +服务调用信息,例如,method、argument 等; +URL 本身的字段,例如,protocol、host、port 等; +URL 上的所有参数,例如,application 等。 + + +Value 是 MatchPair 对象,包含两个 Set 类型的集合—— matches 和 mismatches。在使用 MatchPair 进行过滤的时候,会按照下面四条规则执行。 + + +当 mismatches 集合为空的时候,会逐个遍历 matches 集合中的匹配条件,匹配成功任意一条即会返回 true。这里具体的匹配逻辑以及后续 mismatches 集合中条件的匹配逻辑,都是在 UrlUtils.isMatchGlobPattern() 方法中实现,其中完成了如下操作:如果匹配条件以 “$” 符号开头,则从 URL 中获取相应的参数值进行匹配;当遇到 “” 通配符的时候,会处理”“通配符在匹配条件开头、中间以及末尾三种情况。 +当 matches 集合为空的时候,会逐个遍历 mismatches 集合中的匹配条件,匹配成功任意一条即会返回 false。 +当 matches 集合和 mismatches 集合同时不为空时,会优先匹配 mismatches 集合中的条件,成功匹配任意一条规则,就会返回 false;若 mismatches 中的条件全部匹配失败,才会开始匹配 matches 集合,成功匹配任意一条规则,就会返回 true。 +当上述三个步骤都没有成功匹配时,直接返回 false。 + + +上述流程具体实现在 MatchPair 的 isMatch() 方法中,比较简单,这里就不再展示。 + +了解了每个 MatchPair 的匹配流程之后,我们来看parseRule() 方法是如何解析一条完整的条件表达式,生成对应 MatchPair 的,具体实现如下: + +private static Map parseRule(String rule) throws ParseException { + + Map condition = new HashMap(); + + MatchPair pair = null; + + Set values = null; + + // 首先,按照ROUTE_PATTERN指定的正则表达式匹配整个条件表达式 + + final Matcher matcher = ROUTE_PATTERN.matcher(rule); + + while (matcher.find()) { // 遍历匹配的结果 + + // 每个匹配结果有两部分(分组),第一部分是分隔符,第二部分是内容 + + String separator = matcher.group(1); + + String content = matcher.group(2); + + if (StringUtils.isEmpty(separator)) { // ---(1) 没有分隔符,content即为参数名称 + + pair = new MatchPair(); + + // 初始化MatchPair对象,并将其与对应的Key(即content)记录到condition集合中 + + condition.put(content, pair); + + } + + else if ("&".equals(separator)) { // ---(4) + + // &分隔符表示多个表达式,会创建多个MatchPair对象 + + if (condition.get(content) == null) { + + pair = new MatchPair(); + + condition.put(content, pair); + + } else { + + pair = condition.get(content); + + } + + }else if ("=".equals(separator)) { // ---(2) + + // =以及!=两个分隔符表示KV的分界线 + + if (pair == null) { + + throw new ParseException("...""); + + } + + values = pair.matches; + + values.add(content); + + }else if ("!=".equals(separator)) { // ---(5) + + if (pair == null) { + + throw new ParseException("..."); + + } + + values = pair.mismatches; + + values.add(content); + + }else if (",".equals(separator)) { // ---(3) + + // 逗号分隔符表示有多个Value值 + + if (values == null || values.isEmpty()) { + + throw new ParseException("..."); + + } + + values.add(content); + + } else { + + throw new ParseException("..."); + + } + + } + + return condition; + +} + + +介绍完 parseRule() 方法的实现之后,我们可以再通过下面这个条件表达式示例的解析流程,更深入地体会 parseRule() 方法的工作原理: + +host = 2.2.2.2,1.1.1.1,3.3.3.3 & method !=get => host = 1.2.3.4 + + +经过 ROUTE_PATTERN 正则表达式的分组之后,我们得到如下分组: + + + +Rule 分组示意图 + +我们先来看 => 之前的 Consumer 匹配规则的处理。 + + +分组 1 中,separator 为空字符串,content 为 host 字符串。此时会进入上面示例代码展示的 parseRule() 方法中(1)处的分支,创建 MatchPair 对象,并以 host 为 Key 记录到 condition 集合中。 +分组 2 中,separator 为 “=” 空字符串,content 为 “2.2.2.2” 字符串。处理该分组时,会进入 parseRule() 方法中(2) 处的分支,在 MatchPair 的 matches 集合中添加 “2.2.2.2” 字符串。 +分组 3 中,separator 为 “,” 字符串,content 为 “3.3.3.3” 字符串。处理该分组时,会进入 parseRule() 方法中(3)处的分支,继续向 MatchPair 的 matches 集合中添加 “3.3.3.3” 字符串。 +分组 4 中,separator 为 “&” 字符串,content 为 “method” 字符串。处理该分组时,会进入 parseRule() 方法中(4)处的分支,创建新的 MatchPair 对象,并以 method 为 Key 记录到 condition 集合中。 +分组 5 中,separator 为 “!=” 字符串,content 为 “get” 字符串。处理该分组时,会进入 parseRule() 方法中(5)处的分支,向步骤 4 新建的 MatchPair 对象中的 mismatches 集合添加 “get” 字符串。 + + +最后,我们得到的 whenCondition 集合如下图所示: + + + +whenCondition 集合示意图 + +同理,parseRule() 方法解析上述表达式 => 之后的规则得到的 thenCondition 集合,如下图所示: + + + +thenCondition 集合示意图 + +了解了 ConditionRouter 解析规则的流程以及 MatchPair 内部的匹配原则之后,ConditionRouter 中最后一个需要介绍的内容就是它的 route() 方法了。 + +ConditionRouter.route() 方法首先会尝试前面创建的 whenCondition 集合,判断此次发起调用的 Consumer 是否符合表达式中 => 之前的 Consumer 过滤条件,若不符合,直接返回整个 invokers 集合;若符合,则通过 thenCondition 集合对 invokers 集合进行过滤,得到符合 Provider 过滤条件的 Invoker 集合,然后返回给上层调用方。ConditionRouter.route() 方法的核心实现如下: + +public List> route(List> invokers, URL url, Invocation invocation) + + throws RpcException { + + ... // 通过enable字段判断当前ConditionRouter对象是否可用 + + ... // 当前invokers集合为空,则直接返回 + + if (!matchWhen(url, invocation)) { // 匹配发起请求的Consumer是否符合表达式中=>之前的过滤条件 + + return invokers; + + } + + List> result = new ArrayList>(); + + if (thenCondition == null) { // 判断=>之后是否存在Provider过滤条件,若不存在则直接返回空集合,表示无Provider可用 + + return result; + + } + + for (Invoker invoker : invokers) { // 逐个判断Invoker是否符合表达式中=>之后的过滤条件 + + if (matchThen(invoker.getUrl(), url)) { + + result.add(invoker); // 记录符合条件的Invoker + + } + + } + + if (!result.isEmpty()) { + + return result; + + } else if (force) { // 在无Invoker符合条件时,根据force决定是返回空集合还是返回全部Invoker + + return result; + + } + + return invokers; + +} + + +ScriptRouterFactory&ScriptRouter + +ScriptRouterFactory 的扩展名为 script,其 getRouter() 方法中会创建一个 ScriptRouter 对象并返回。 + +ScriptRouter 支持 JDK 脚本引擎的所有脚本,例如,JavaScript、JRuby、Groovy 等,通过 type=javascript 参数设置脚本类型,缺省为 javascript。下面我们就定义一个 route() 函数进行 host 过滤: + +function route(invokers, invocation, context){ + + var result = new java.util.ArrayList(invokers.size()); + + var targetHost = new java.util.ArrayList(); + + targetHost.add("10.134.108.2"); + + for (var i = 0; i < invokers.length; i) { // 遍历Invoker集合 + + // 判断Invoker的host是否符合条件 + + if(targetHost.contains(invokers[i].getUrl().getHost())){ + + result.add(invokers[i]); + + } + + } + + return result; + +} + +route(invokers, invocation, context) // 立即执行route()函数 + + +我们可以将上面这段代码进行编码并作为 rule 参数的值添加到 URL 中,在这个 URL 传入 ScriptRouter 的构造函数时,即可被 ScriptRouter 解析。 + +ScriptRouter 的核心字段有如下几个。 + + +url(URL 类型):路由规则的 URL,可以从 rule 参数中获取具体的路由规则。 +priority(int 类型):路由规则的优先级,用于排序,该字段值越大,优先级越高,默认值为 0。 +ENGINES(ConcurrentHashMap 类型):这是一个 static 集合,其中的 Key 是脚本语言的名称,Value 是对应的 ScriptEngine 对象。这里会按照脚本语言的类型复用 ScriptEngine 对象。 +engine(ScriptEngine 类型):当前 ScriptRouter 使用的 ScriptEngine 对象。 +rule(String 类型):当前 ScriptRouter 使用的具体脚本内容。 +function(CompiledScript 类型):根据 rule 这个具体脚本内容编译得到。 + + +在 ScriptRouter 的构造函数中,首先会初始化 url 字段以及 priority 字段(用于排序),然后根据 URL 中的 type 参数初始化 engine、rule 和 function 三个核心字段 ,具体实现如下: + +public ScriptRouter(URL url) { + + this.url = url; + + this.priority = url.getParameter(PRIORITY_KEY, SCRIPT_ROUTER_DEFAULT_PRIORITY); + + // 根据URL中的type参数值,从ENGINES集合中获取对应的ScriptEngine对象 + + engine = getEngine(url); + + // 获取URL中的rule参数值,即为具体的脚本 + + rule = getRule(url); + + Compilable compilable = (Compilable) engine; + + // 编译rule字段中的脚本,得到function字段 + + function = compilable.compile(rule); + +} + + +接下来看 ScriptRouter 对 route() 方法的实现,其中首先会创建调用 function 函数所需的入参,也就是 Bindings 对象,然后调用 function 函数得到过滤后的 Invoker 集合,最后通过 getRoutedInvokers() 方法整理 Invoker 集合得到最终的返回值。 + +public List> route(List> invokers, URL url, Invocation invocation) throws RpcException { + + // 创建Bindings对象作为function函数的入参 + + Bindings bindings = createBindings(invokers, invocation); + + if (function == null) { + + return invokers; + + } + + // 调用function函数,并在getRoutedInvokers()方法中整理得到的Invoker集合 + + return getRoutedInvokers(function.eval(bindings)); + +} + +private Bindings createBindings(List> invokers, Invocation invocation) { + + Bindings bindings = engine.createBindings(); + + // 与前面的javascript的示例脚本结合,我们可以看到这里在Bindings中为脚本中的route()函数提供了invokers、Invocation、context三个参数 + + bindings.put("invokers", new ArrayList<>(invokers)); + + bindings.put("invocation", invocation); + + bindings.put("context", RpcContext.getContext()); + + return bindings; + +} + + +总结 + +本课时重点介绍了 Router 接口的相关内容。首先我们介绍了 RouterChain 的核心实现以及构建过程,然后讲解了 RouterFactory 接口和 Router 接口中核心方法的功能。接下来,我们还深入分析了ConditionRouter 对条件路由功能的实现,以及ScriptRouter 对脚本路由功能的实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/33路由机制:请求到底怎么走,它说了算(下).md b/专栏/Dubbo源码解读与实战-完/33路由机制:请求到底怎么走,它说了算(下).md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/Dubbo源码解读与实战-完/34加餐:初探Dubbo动态配置的那些事儿.md b/专栏/Dubbo源码解读与实战-完/34加餐:初探Dubbo动态配置的那些事儿.md new file mode 100644 index 0000000..303d266 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/34加餐:初探Dubbo动态配置的那些事儿.md @@ -0,0 +1,371 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 34 加餐:初探 Dubbo 动态配置的那些事儿 + 在前面第 31 课时中我们详细讲解了 RegistryDirectory 相关的内容,作为一个 NotifyListener 监听器,RegistryDirectory 会同时监听注册中心的 providers、routers 和 configurators 三个目录。通过 RegistryDirectory 处理 configurators 目录的逻辑,我们了解到 configurators 目录中动态添加的 URL 会覆盖 providers 目录下注册的 Provider URL,Dubbo 还会按照 configurators 目录下的最新配置,重新创建 Invoker 对象(同时会销毁原来的 Invoker 对象)。 + +在老版本的 Dubbo 中,我们可以通过服务治理控制台向注册中心的 configurators 目录写入动态配置的 URL。在 Dubbo 2.7.x 版本中,动态配置信息除了可以写入注册中心的 configurators 目录之外,还可以写入外部的配置中心,这部分内容我们将在后面的课时详细介绍,今天这一课时我们重点来看写入注册中心的动态配置。 + +首先,我们需要了解一下 configurators 目录中 URL 都有哪些协议以及这些协议的含义,然后还要知道 Dubbo 是如何解析这些 URL 得到 Configurator 对象的,以及 Configurator 是如何与已有的 Provider URL 共同作用得到实现动态更新配置的效果。 + +基础协议 + +首先,我们需要了解写入注册中心 configurators 中的动态配置有 override 和 absent 两种协议。下面是一个 override 协议的示例: + +override://0.0.0.0/org.apache.dubbo.demo.DemoService?category=configurators&dynamic=false&enabled=true&application=dubbo-demo-api-consumer&timeout=1000 + + +那这个 URL 中各个部分的含义是怎样的呢?下面我们就一个一个来分析下。 + + +override,表示采用覆盖方式。Dubbo 支持 override 和 absent 两种协议,我们也可以通过 SPI 的方式进行扩展。 +0.0.0.0,表示对所有 IP 生效。如果只想覆盖某个特定 IP 的 Provider 配置,可以使用该 Provider 的具体 IP。 +org.apache.dubbo.demo.DemoService,表示只对指定服务生效。 +category=configurators,表示该 URL 为动态配置类型。 +dynamic=false,表示该 URL 为持久数据,即使注册该 URL 的节点退出,该 URL 依旧会保存在注册中心。 +enabled=true,表示该 URL 的覆盖规则已生效。 +application=dubbo-demo-api-consumer,表示只对指定应用生效。如果不指定,则默认表示对所有应用都生效。 +timeout=1000,表示将满足以上条件 Provider URL 中的 timeout 参数值覆盖为 1000。如果想覆盖其他配置,可以直接以参数的形式添加到 override URL 之上。 + + +在 Dubbo 的官网中,还提供了一些简单示例,我们这里也简单解读一下。 + + +禁用某个 Provider,通常用于临时剔除某个 Provider 节点: + + +override://10.20.153.10/com.foo.BarService?category=configurators&dynamic=false&disabled=true + + + +调整某个 Provider 的权重为 200: + + +override://10.20.153.10/com.foo.BarService?category=configurators&dynamic=false&weight=200 + + + +调整负载均衡策略为 LeastActiveLoadBalance(负载均衡的内容会在下一课时详细介绍): + + +override://10.20.153.10/com.foo.BarService?category=configurators&dynamic=false&loadbalance=leastactive + + + +服务降级,通常用于临时屏蔽某个出错的非关键服务(mock 机制的具体实现我们会在后面的课时详细介绍): + + +override://0.0.0.0/com.foo.BarService?category=configurators&dynamic=false&application=foo&mock=force:return+null + + +Configurator + +当我们在注册中心的 configurators 目录中添加 override(或 absent)协议的 URL 时,Registry 会收到注册中心的通知,回调注册在其上的 NotifyListener,其中就包括 RegistryDirectory。我们在第 31 课时中已经详细分析了 RegistryDirectory.notify() 处理 providers、configurators 和 routers 目录变更的流程,其中 configurators 目录下 URL 会被解析成 Configurator 对象。 + +Configurator 接口抽象了一条配置信息,同时提供了将配置 URL 解析成 Configurator 对象的工具方法。Configurator 接口具体定义如下: + +public interface Configurator extends Comparable { + + // 获取该Configurator对象对应的配置URL,例如前文介绍的override协议URL + + URL getUrl(); + + // configure()方法接收的参数是原始URL,返回经过Configurator修改后的URL + + URL configure(URL url); + + // toConfigurators()工具方法可以将多个配置URL对象解析成相应的Configurator对象 + + static Optional> toConfigurators(List urls) { + + // 创建ConfiguratorFactory适配器 + + ConfiguratorFactory configuratorFactory = ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class) + + .getAdaptiveExtension(); + + List configurators = new ArrayList<>(urls.size()); // 记录解析的结果 + + for (URL url : urls) { + + // 遇到empty协议,直接清空configurators集合,结束解析,返回空集合 + + if (EMPTY_PROTOCOL.equals(url.getProtocol())) { + + configurators.clear(); + + break; + + } + + Map override = new HashMap<>(url.getParameters()); + + override.remove(ANYHOST_KEY); + + if (override.size() == 0) { // 如果该配置URL没有携带任何参数,则跳过该URL + + configurators.clear(); + + continue; + + } + + // 通过ConfiguratorFactory适配器选择合适ConfiguratorFactory扩展,并创建Configurator对象 + + configurators.add(configuratorFactory.getConfigurator(url)); + + } + + Collections.sort(configurators); // 排序 + + return Optional.of(configurators); + + } + + // 排序首先按照ip进行排序,所有ip的优先级都高于0.0.0.0,当ip相同时,会按照priority参数值进行排序 + + default int compareTo(Configurator o) { + + if (o == null) { + + return -1; + + } + + int ipCompare = getUrl().getHost().compareTo(o.getUrl().getHost()); + + if (ipCompare == 0) { + + int i = getUrl().getParameter(PRIORITY_KEY, 0); + + int j = o.getUrl().getParameter(PRIORITY_KEY, 0); + + return Integer.compare(i, j); + + } else { + + return ipCompare; + + } + + } + + +ConfiguratorFactory 接口是一个扩展接口,Dubbo 提供了两个实现类,如下图所示: + + + +ConfiguratorFactory 继承关系图 + +其中,OverrideConfiguratorFactory 对应的扩展名为 override,创建的 Configurator 实现是 OverrideConfigurator;AbsentConfiguratorFactory 对应的扩展名是 absent,创建的 Configurator 实现类是 AbsentConfigurator。 + +Configurator 接口的继承关系如下图所示: + + + +Configurator 继承关系图 + +其中,AbstractConfigurator 中维护了一个 configuratorUrl 字段,记录了完整的配置 URL。AbstractConfigurator 是一个模板类,其核心实现是 configure() 方法,具体实现如下: + +public URL configure(URL url) { + + // 这里会根据配置URL的enabled参数以及host决定该URL是否可用,同时还会根据原始URL是否为空以及原始URL的host是否为空,决定当前是否执行后续覆盖逻辑 + + if (!configuratorUrl.getParameter(ENABLED_KEY, true) || configuratorUrl.getHost() == null || url == null || url.getHost() == null) { + + return url; + + } + + // 针对2.7.0之后版本,这里添加了一个configVersion参数作为区分 + + String apiVersion = configuratorUrl.getParameter(CONFIG_VERSION_KEY); + + if (StringUtils.isNotEmpty(apiVersion)) { // 对2.7.0之后版本的配置处理 + + String currentSide = url.getParameter(SIDE_KEY); + + String configuratorSide = configuratorUrl.getParameter(SIDE_KEY); + + // 根据配置URL中的side参数以及原始URL中的side参数值进行匹配 + + if (currentSide.equals(configuratorSide) && CONSUMER.equals(configuratorSide) && 0 == configuratorUrl.getPort()) { + + url = configureIfMatch(NetUtils.getLocalHost(), url); + + } else if (currentSide.equals(configuratorSide) && PROVIDER.equals(configuratorSide) && url.getPort() == configuratorUrl.getPort()) { + + url = configureIfMatch(url.getHost(), url); + + } + + } else { // 2.7.0版本之前对配置的处理 + + url = configureDeprecated(url); + + } + + return url; + +} + + +这里我们需要关注下configureDeprecated() 方法对历史版本的兼容,其实这也是对注册中心 configurators 目录下配置 URL 的处理,具体实现如下: + +private URL configureDeprecated(URL url) { + + // 如果配置URL中的端口不为空,则是针对Provider的,需要判断原始URL的端口,两者端口相同,才能执行configureIfMatch()方法中的配置方法 + + if (configuratorUrl.getPort() != 0) { + + if (url.getPort() == configuratorUrl.getPort()) { + + return configureIfMatch(url.getHost(), url); + + } + + } else { + + // 如果没有指定端口,则该配置URL要么是针对Consumer的,要么是针对任意URL的(即host为0.0.0.0) + + // 如果原始URL属于Consumer,则使用Consumer的host进行匹配 + + if (url.getParameter(SIDE_KEY, PROVIDER).equals(CONSUMER)) { + + return configureIfMatch(NetUtils.getLocalHost(), url); + + } else if (url.getParameter(SIDE_KEY, CONSUMER).equals(PROVIDER)) { + + // 如果是Provider URL,则用0.0.0.0来配置 + + return configureIfMatch(ANYHOST_VALUE, url); + + } + + } + + return url; + +} + + +configureIfMatch() 方法会排除匹配 URL 中不可动态修改的参数,并调用 Configurator 子类的 doConfigurator() 方法重写原始 URL,具体实现如下: + +private URL configureIfMatch(String host, URL url) { + + if (ANYHOST_VALUE.equals(configuratorUrl.getHost()) || host.equals(configuratorUrl.getHost())) { // 匹配host + + String providers = configuratorUrl.getParameter(OVERRIDE_PROVIDERS_KEY); + + if (StringUtils.isEmpty(providers) || providers.contains(url.getAddress()) || providers.contains(ANYHOST_VALUE)) { + + String configApplication = configuratorUrl.getParameter(APPLICATION_KEY, + + configuratorUrl.getUsername()); + + String currentApplication = url.getParameter(APPLICATION_KEY, url.getUsername()); + + if (configApplication == null || ANY_VALUE.equals(configApplication) + + || configApplication.equals(currentApplication)) { // 匹配application + + // 排除不能动态修改的属性,其中包括category、check、dynamic、enabled还有以~开头的属性 + + Set conditionKeys = new HashSet(); + + conditionKeys.add(CATEGORY_KEY); + + conditionKeys.add(Constants.CHECK_KEY); + + conditionKeys.add(DYNAMIC_KEY); + + conditionKeys.add(ENABLED_KEY); + + conditionKeys.add(GROUP_KEY); + + conditionKeys.add(VERSION_KEY); + + conditionKeys.add(APPLICATION_KEY); + + conditionKeys.add(SIDE_KEY); + + conditionKeys.add(CONFIG_VERSION_KEY); + + conditionKeys.add(COMPATIBLE_CONFIG_KEY); + + conditionKeys.add(INTERFACES); + + for (Map.Entry entry : configuratorUrl.getParameters().entrySet()) { + + String key = entry.getKey(); + + String value = entry.getValue(); + + if (key.startsWith("~") || APPLICATION_KEY.equals(key) || SIDE_KEY.equals(key)) { + + conditionKeys.add(key); + + // 如果配置URL与原URL中以~开头的参数值不相同,则不使用该配置URL重写原URL + + if (value != null && !ANY_VALUE.equals(value) + + && !value.equals(url.getParameter(key.startsWith("~") ? key.substring(1) : key))) { + + return url; + + } + + } + + } + + // 移除配置URL不支持动态配置的参数之后,调用Configurator子类的doConfigure方法重新生成URL + + return doConfigure(url, configuratorUrl.removeParameters(conditionKeys)); + + } + + } + + } + + return url; + +} + + +我们再反过来仔细审视一下 AbstractConfigurator.configure() 方法中针对 2.7.0 版本之后动态配置的处理,其中会根据 side 参数明确判断配置 URL 和原始 URL 属于 Consumer 端还是 Provider 端,判断逻辑也更加清晰。匹配之后的具体替换过程同样是调用 configureIfMatch() 方法实现的,这里不再重复。 + +Configurator 的两个子类实现非常简单。在 OverrideConfigurator 的 doConfigure() 方法中,会直接用配置 URL 中剩余的全部参数,覆盖原始 URL 中的相应参数,具体实现如下: + +public URL doConfigure(URL currentUrl, URL configUrl) { + + // 直接调用addParameters()方法,进行覆盖 + + return currentUrl.addParameters(configUrl.getParameters()); + +} + + +在 AbsentConfigurator 的 doConfigure() 方法中,会尝试用配置 URL 中的参数添加到原始 URL 中,如果原始 URL 中已经有了该参数是不会被覆盖的,具体实现如下: + +public URL doConfigure(URL currentUrl, URL configUrl) { + + // 直接调用addParametersIfAbsent()方法尝试添加参数 + + return currentUrl.addParametersIfAbsent(configUrl.getParameters()); + +} + + +到这里,Dubbo 2.7.0 版本之前的动态配置核心实现就介绍完了,其中我们也简单涉及了 Dubbo 2.7.0 版本之后一些逻辑,只不过没有全面介绍 Dubbo 2.7.0 之后的配置格式以及核心处理逻辑,不用担心,这些内容我们将会在后面的“配置中心”章节继续深入分析。 + +总结 + +本课时我们主要介绍了 Dubbo 中配置相关的实现。我们首先通过示例分析了 configurators 目录中涉及的 override 协议 URL、absent 协议 URL 的格式以及各个参数的含义,然后还详细讲解了 Dubbo 解析 configurator URL 得到的 Configurator 对象,以及 Configurator 覆盖 Provider URL 各个参数的具体实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/35负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md b/专栏/Dubbo源码解读与实战-完/35负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md new file mode 100644 index 0000000..e67877a --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/35负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md @@ -0,0 +1,452 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上) + 在前面的课时中,我们已经详细介绍了 dubbo-cluster 模块中的 Directory 和 Router 两个核心接口以及核心实现,同时也介绍了这两个接口相关的周边知识。本课时我们继续按照下图的顺序介绍 LoadBalance 的相关内容。 + + + +LoadBalance 核心接口图 + +LoadBalance(负载均衡)的职责是将网络请求或者其他形式的负载“均摊”到不同的服务节点上,从而避免服务集群中部分节点压力过大、资源紧张,而另一部分节点比较空闲的情况。 + +通过合理的负载均衡算法,我们希望可以让每个服务节点获取到适合自己处理能力的负载,实现处理能力和流量的合理分配。常用的负载均衡可分为软件负载均衡(比如,日常工作中使用的 Nginx)和硬件负载均衡(主要有 F5、Array、NetScaler 等,不过开发工程师在实践中很少直接接触到)。 + +常见的 RPC 框架中都有负载均衡的概念和相应的实现,Dubbo 也不例外。Dubbo 需要对 Consumer 的调用请求进行分配,避免少数 Provider 节点负载过大,而剩余的其他 Provider 节点处于空闲的状态。因为当 Provider 负载过大时,就会导致一部分请求超时、丢失等一系列问题发生,造成线上故障。 + +Dubbo 提供了 5 种负载均衡实现,分别是: + + +基于 Hash 一致性的 ConsistentHashLoadBalance; +基于权重随机算法的 RandomLoadBalance; +基于最少活跃调用数算法的 LeastActiveLoadBalance; +基于加权轮询算法的 RoundRobinLoadBalance; +基于最短响应时间的 ShortestResponseLoadBalance 。 + + +LoadBalance 接口 + +上述 Dubbo 提供的负载均衡实现,都是 LoadBalance 接口的实现类,如下图所示: + + + +LoadBalance 继承关系图 + +LoadBalance 是一个扩展接口,默认使用的扩展实现是 RandomLoadBalance,其定义如下所示,其中的 @Adaptive 注解参数为 loadbalance,即动态生成的适配器会按照 URL 中的 loadbalance 参数值选择扩展实现类。 + +@SPI(RandomLoadBalance.NAME) + +public interface LoadBalance { + + @Adaptive("loadbalance") + + Invoker select(List> invokers, URL url, Invocation invocation) throws RpcException; + +} + + +LoadBalance 接口中 select() 方法的核心功能是根据传入的 URL 和 Invocation,以及自身的负载均衡算法,从 Invoker 集合中选择一个 Invoker 返回。 + +AbstractLoadBalance 抽象类并没有真正实现 select() 方法,只是对 Invoker 集合为空或是只包含一个 Invoker 对象的特殊情况进行了处理,具体实现如下: + +public Invoker select(List> invokers, URL url, Invocation invocation) { + + if (CollectionUtils.isEmpty(invokers)) { + + return null; // Invoker集合为空,直接返回null + + } + + if (invokers.size() == 1) { // Invoker集合只包含一个Invoker,则直接返回该Invoker对象 + + return invokers.get(0); + + } + + // Invoker集合包含多个Invoker对象时,交给doSelect()方法处理,这是个抽象方法,留给子类具体实现 + + return doSelect(invokers, url, invocation); + +} + + +另外,AbstractLoadBalance 还提供了一个 getWeight() 方法,该方法用于计算 Provider 权重,具体实现如下: + +int getWeight(Invoker invoker, Invocation invocation) { + + int weight; + + URL url = invoker.getUrl(); + + if (REGISTRY_SERVICE_REFERENCE_PATH.equals(url.getServiceInterface())) { + + // 如果是RegistryService接口的话,直接获取权重即可 + + weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, DEFAULT_WEIGHT); + + } else { + + weight = url.getMethodParameter(invocation.getMethodName(), WEIGHT_KEY, DEFAULT_WEIGHT); + + if (weight > 0) { + + // 获取服务提供者的启动时间戳 + + long timestamp = invoker.getUrl().getParameter(TIMESTAMP_KEY, 0L); + + if (timestamp > 0L) { + + // 计算Provider运行时长 + + long uptime = System.currentTimeMillis() - timestamp; + + if (uptime < 0) { + + return 1; + + } + + // 计算Provider预热时长 + + int warmup = invoker.getUrl().getParameter(WARMUP_KEY, DEFAULT_WARMUP); + + // 如果Provider运行时间小于预热时间,则该Provider节点可能还在预热阶段,需要重新计算服务权重(降低其权重) + + if (uptime > 0 && uptime < warmup) { + + weight = calculateWarmupWeight((int)uptime, warmup, weight); + + } + + } + + } + + } + + return Math.max(weight, 0); + +} + + +calculateWarmupWeight() 方法的目的是对还在预热状态的 Provider 节点进行降权,避免 Provider 一启动就有大量请求涌进来。服务预热是一个优化手段,这是由 JVM 本身的一些特性决定的,例如,JIT 等方面的优化,我们一般会在服务启动之后,让其在小流量状态下运行一段时间,然后再逐步放大流量。 + +static int calculateWarmupWeight(int uptime, int warmup, int weight) { + + // 计算权重,随着服务运行时间uptime增大,权重ww的值会慢慢接近配置值weight + + int ww = (int) ( uptime / ((float) warmup / weight)); + + return ww < 1 ? 1 : (Math.min(ww, weight)); + +} + + +了解了 LoadBalance 接口的定义以及 AbstractLoadBalance 提供的公共能力之后,下面我们开始逐个介绍 LoadBalance 接口的具体实现。 + +ConsistentHashLoadBalance + +ConsistentHashLoadBalance 底层使用一致性 Hash 算法实现负载均衡。为了让你更好地理解这部分内容,我们先来简单介绍一下一致性 Hash 算法相关的知识点。 + +1. 一致性 Hash 简析 + +一致性 Hash 负载均衡可以让参数相同的请求每次都路由到相同的服务节点上,这种负载均衡策略可以在某些 Provider 节点下线的时候,让这些节点上的流量平摊到其他 Provider 上,不会引起流量的剧烈波动。 + +下面我们通过一个示例,简单介绍一致性 Hash 算法的原理。 + +假设现在有 1、2、3 三个 Provider 节点对外提供服务,有 100 个请求同时到达,如果想让请求尽可能均匀地分布到这三个 Provider 节点上,我们可能想到的最简单的方法就是 Hash 取模,即 hash(请求参数) % 3。如果参与 Hash 计算的是请求的全部参数,那么参数相同的请求将会落到同一个 Provider 节点上。不过此时如果突然有一个 Provider 节点出现宕机的情况,那我们就需要对 2 取模,即请求会重新分配到相应的 Provider 之上。在极端情况下,甚至会出现所有请求的处理节点都发生了变化,这就会造成比较大的波动。 + +为了避免因一个 Provider 节点宕机,而导致大量请求的处理节点发生变化的情况,我们可以考虑使用一致性 Hash 算法。一致性 Hash 算法的原理也是取模算法,与 Hash 取模的不同之处在于:Hash 取模是对 Provider 节点数量取模,而一致性 Hash 算法是对 2^32 取模。 + +一致性 Hash 算法需要同时对 Provider 地址以及请求参数进行取模: + +hash(Provider地址) % 2^32 + +hash(请求参数) % 2^32 + + +Provider 地址和请求经过对 2^32 取模得到的结果值,都会落到一个 Hash 环上,如下图所示: + + + +一致性 Hash 节点均匀分布图 + +我们按顺时针的方向,依次将请求分发到对应的 Provider。这样,当某台 Provider 节点宕机或增加新的 Provider 节点时,只会影响这个 Provider 节点对应的请求。 + +在理想情况下,一致性 Hash 算法会将这三个 Provider 节点均匀地分布到 Hash 环上,请求也可以均匀地分发给这三个 Provider 节点。但在实际情况中,这三个 Provider 节点地址取模之后的值,可能差距不大,这样会导致大量的请求落到一个 Provider 节点上,如下图所示: + + + +一致性 Hash 节点非均匀分布图 + +这就出现了数据倾斜的问题。所谓数据倾斜是指由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到少量请求的情况。 + +为了解决一致性 Hash 算法中出现的数据倾斜问题,又演化出了 Hash 槽的概念。 + +Hash 槽解决数据倾斜的思路是:既然问题是由 Provider 节点在 Hash 环上分布不均匀造成的,那么可以虚拟出 n 组 P1、P2、P3 的 Provider 节点 ,让多组 Provider 节点相对均匀地分布在 Hash 环上。如下图所示,相同阴影的节点均为同一个 Provider 节点,比如 P1-1、P1-2……P1-99 表示的都是 P1 这个 Provider 节点。引入 Provider 虚拟节点之后,让 Provider 在圆环上分散开来,以避免数据倾斜问题。 + + + +数据倾斜解决示意图 + +2. ConsistentHashSelector 实现分析 + +了解了一致性 Hash 算法的基本原理之后,我们再来看一下 ConsistentHashLoadBalance 一致性 Hash 负载均衡的具体实现。首先来看 doSelect() 方法的实现,其中会根据 ServiceKey 和 methodName 选择一个 ConsistentHashSelector 对象,核心算法都委托给 ConsistentHashSelector 对象完成。 + +protected Invoker doSelect(List> invokers, URL url, Invocation invocation) { + + // 获取调用的方法名称 + + String methodName = RpcUtils.getMethodName(invocation); + + // 将ServiceKey和方法拼接起来,构成一个key + + String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName; + + // 注意:这是为了在invokers列表发生变化时都会重新生成ConsistentHashSelector对象 + + int invokersHashCode = invokers.hashCode(); + + // 根据key获取对应的ConsistentHashSelector对象,selectors是一个ConcurrentMap集合 + + ConsistentHashSelector selector = (ConsistentHashSelector) selectors.get(key); + + if (selector == null || selector.identityHashCode != invokersHashCode) { // 未查找到ConsistentHashSelector对象,则进行创建 + + selectors.put(key, new ConsistentHashSelector(invokers, methodName, invokersHashCode)); + + selector = (ConsistentHashSelector) selectors.get(key); + + } + + // 通过ConsistentHashSelector对象选择一个Invoker对象 + + return selector.select(invocation); + +} + + +下面我们来看 ConsistentHashSelector,其核心字段如下所示。 + + +virtualInvokers(TreeMap`> 类型):用于记录虚拟 Invoker 对象的 Hash 环。这里使用 TreeMap 实现 Hash 环,并将虚拟的 Invoker 对象分布在 Hash 环上。 +replicaNumber(int 类型):虚拟 Invoker 个数。 +identityHashCode(int 类型):Invoker 集合的 HashCode 值。 +argumentIndex(int[] 类型):需要参与 Hash 计算的参数索引。例如,argumentIndex = [0, 1, 2] 时,表示调用的目标方法的前三个参数要参与 Hash 计算。 + + +接下来看 ConsistentHashSelector 的构造方法,其中的主要任务是: + + +构建 Hash 槽; +确认参与一致性 Hash 计算的参数,默认是第一个参数。 + + +这些操作的目的就是为了让 Invoker 尽可能均匀地分布在 Hash 环上,具体实现如下: + +ConsistentHashSelector(List> invokers, String methodName, int identityHashCode) { + + // 初始化virtualInvokers字段,也就是虚拟Hash槽 + + this.virtualInvokers = new TreeMap>(); + + // 记录Invoker集合的hashCode,用该hashCode值来判断Provider列表是否发生了变化 + + this.identityHashCode = identityHashCode; + + URL url = invokers.get(0).getUrl(); + + // 从hash.nodes参数中获取虚拟节点的个数 + + this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160); + + // 获取参与Hash计算的参数下标值,默认对第一个参数进行Hash运算 + + String[] index = COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, HASH_ARGUMENTS, "0")); + + argumentIndex = new int[index.length]; + + for (int i = 0; i < index.length; i++) { + + argumentIndex[i] = Integer.parseInt(index[i]); + + } + + // 构建虚拟Hash槽,默认replicaNumber=160,相当于在Hash槽上放160个槽位 + + // 外层轮询40次,内层轮询4次,共40*4=160次,也就是同一节点虚拟出160个槽位 + + for (Invoker invoker : invokers) { + + String address = invoker.getUrl().getAddress(); + + for (int i = 0; i < replicaNumber / 4; i++) { + + // 对address + i进行md5运算,得到一个长度为16的字节数组 + + byte[] digest = md5(address + i); + + // 对digest部分字节进行4次Hash运算,得到4个不同的long型正整数 + + for (int h = 0; h < 4; h++) { + + // h = 0 时,取 digest 中下标为 0~3 的 4 个字节进行位运算 + + // h = 1 时,取 digest 中下标为 4~7 的 4 个字节进行位运算 + + // h = 2 和 h = 3时,过程同上 + + long m = hash(digest, h); + + virtualInvokers.put(m, invoker); + + } + + } + + } + +} + + +最后,请求会通过 ConsistentHashSelector.select() 方法选择合适的 Invoker 对象,其中会先对请求参数进行 md5 以及 Hash 运算,得到一个 Hash 值,然后再通过这个 Hash 值到 TreeMap 中查找目标 Invoker。具体实现如下: + +public Invoker select(Invocation invocation) { + + // 将参与一致性Hash的参数拼接到一起 + + String key = toKey(invocation.getArguments()); + + // 计算key的Hash值 + + byte[] digest = md5(key); + + // 匹配Invoker对象 + + return selectForKey(hash(digest, 0)); + +} + +private Invoker selectForKey(long hash) { + + // 从virtualInvokers集合(TreeMap是按照Key排序的)中查找第一个节点值大于或等于传入Hash值的Invoker对象 + + Map.Entry> entry = virtualInvokers.ceilingEntry(hash); + + // 如果Hash值大于Hash环中的所有Invoker,则回到Hash环的开头,返回第一个Invoker对象 + + if (entry == null) { + + entry = virtualInvokers.firstEntry(); + + } + + return entry.getValue(); + +} + + +RandomLoadBalance + +RandomLoadBalance 使用的负载均衡算法是加权随机算法。RandomLoadBalance 是一个简单、高效的负载均衡实现,它也是 Dubbo 默认使用的 LoadBalance 实现。 + +这里我们通过一个示例来说明加权随机算法的核心思想。假设我们有三个 Provider 节点 A、B、C,它们对应的权重分别为 5、2、3,权重总和为 10。现在把这些权重值放到一维坐标轴上,[0, 5) 区间属于节点 A,[5, 7) 区间属于节点 B,[7, 10) 区间属于节点 C,如下图所示: + + + +权重坐标轴示意图 + +下面我们通过随机数生成器在 [0, 10) 这个范围内生成一个随机数,然后计算这个随机数会落到哪个区间中。例如,随机生成 4,就会落到 Provider A 对应的区间中,此时 RandomLoadBalance 就会返回 Provider A 这个节点。 + +接下来我们再来看 RandomLoadBalance 中 doSelect() 方法的实现,其核心逻辑分为三个关键点: + + +计算每个 Invoker 对应的权重值以及总权重值; +当各个 Invoker 权重值不相等时,计算随机数应该落在哪个 Invoker 区间中,返回对应的 Invoker 对象; +当各个 Invoker 权重值相同时,随机返回一个 Invoker 即可。 + + +RandomLoadBalance 经过多次请求后,能够将调用请求按照权重值均匀地分配到各个 Provider 节点上。下面是 RandomLoadBalance 的核心实现: + +protected Invoker doSelect(List> invokers, URL url, Invocation invocation) { + + int length = invokers.size(); + + boolean sameWeight = true; + + // 计算每个Invoker对象对应的权重,并填充到weights[]数组中 + + int[] weights = new int[length]; + + // 计算第一个Invoker的权重 + + int firstWeight = getWeight(invokers.get(0), invocation); + + weights[0] = firstWeight; + + // totalWeight用于记录总权重值 + + int totalWeight = firstWeight; + + for (int i = 1; i < length; i++) { + + // 计算每个Invoker的权重,以及总权重totalWeight + + int weight = getWeight(invokers.get(i), invocation); + + weights[i] = weight; + + // Sum + + totalWeight += weight; + + // 检测每个Provider的权重是否相同 + + if (sameWeight && weight != firstWeight) { + + sameWeight = false; + + } + + } + + // 各个Invoker权重值不相等时,计算随机数落在哪个区间上 + + if (totalWeight > 0 && !sameWeight) { + + // 随机获取一个[0, totalWeight) 区间内的数字 + + int offset = ThreadLocalRandom.current().nextInt(totalWeight); + + // 循环让offset数减去Invoker的权重值,当offset小于0时,返回相应的Invoker + + for (int i = 0; i < length; i++) { + + offset -= weights[i]; + + if (offset < 0) { + + return invokers.get(i); + + } + + } + + } + + // 各个Invoker权重值相同时,随机返回一个Invoker即可 + + return invokers.get(ThreadLocalRandom.current().nextInt(length)); + +} + + +总结 + +本课时我们重点介绍了 Dubbo Cluster 层中负载均衡相关的内容。首先我们介绍了 LoadBalance 接口的定义以及 AbstractLoadBalance 抽象类提供的公共能力。然后我们还详细讲解了 ConsistentHashLoadBalance 的核心实现,其中还简单说明了一致性 Hash 算法的基础知识点。最后,我们又一块儿分析了 RandomLoadBalance 的基本原理和核心实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/36负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md b/专栏/Dubbo源码解读与实战-完/36负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md new file mode 100644 index 0000000..4beb548 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/36负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md @@ -0,0 +1,412 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 36 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下) + 在上一课时我们了解了 LoadBalance 接口定义以及 AbstractLoadBalance 抽象类的内容,还详细介绍了 ConsistentHashLoadBalance 以及 RandomLoadBalance 这两个实现类的核心原理和大致实现。本课时我们将继续介绍 LoadBalance 的剩余三个实现。 + +LeastActiveLoadBalance + +LeastActiveLoadBalance 使用的是最小活跃数负载均衡算法。它认为当前活跃请求数越小的 Provider 节点,剩余的处理能力越多,处理请求的效率也就越高,那么该 Provider 在单位时间内就可以处理更多的请求,所以我们应该优先将请求分配给该 Provider 节点。 + +LeastActiveLoadBalance 需要配合 ActiveLimitFilter 使用,ActiveLimitFilter 会记录每个接口方法的活跃请求数,在 LeastActiveLoadBalance 进行负载均衡时,只会从活跃请求数最少的 Invoker 集合里挑选 Invoker。 + +在 LeastActiveLoadBalance 的实现中,首先会选出所有活跃请求数最小的 Invoker 对象,之后的逻辑与 RandomLoadBalance 完全一样,即按照这些 Invoker 对象的权重挑选最终的 Invoker 对象。下面是 LeastActiveLoadBalance.doSelect() 方法的具体实现: + +protected Invoker doSelect(List> invokers, URL url, Invocation invocation) { + + // 初始化Invoker数量 + + int length = invokers.size(); + + // 记录最小的活跃请求数 + + int leastActive = -1; + + // 记录活跃请求数最小的Invoker集合的个数 + + int leastCount = 0; + + // 记录活跃请求数最小的Invoker在invokers数组中的下标位置 + + int[] leastIndexes = new int[length]; + + // 记录活跃请求数最小的Invoker集合中,每个Invoker的权重值 + + int[] weights = new int[length]; + + // 记录活跃请求数最小的Invoker集合中,所有Invoker的权重值之和 + + int totalWeight = 0; + + // 记录活跃请求数最小的Invoker集合中,第一个Invoker的权重值 + + int firstWeight = 0; + + // 活跃请求数最小的集合中,所有Invoker的权重值是否相同 + + boolean sameWeight = true; + + for (int i = 0; i < length; i++) { // 遍历所有Invoker,获取活跃请求数最小的Invoker集合 + + Invoker invoker = invokers.get(i); + + // 获取该Invoker的活跃请求数 + + int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive(); + + // 获取该Invoker的权重 + + int afterWarmup = getWeight(invoker, invocation); + + weights[i] = afterWarmup; + + // 比较活跃请求数 + + if (leastActive == -1 || active < leastActive) { + + // 当前的Invoker是第一个活跃请求数最小的Invoker,则记录如下信息 + + leastActive = active; // 重新记录最小的活跃请求数 + + leastCount = 1; // 重新记录活跃请求数最小的Invoker集合个数 + + leastIndexes[0] = i; // 重新记录Invoker + + totalWeight = afterWarmup; // 重新记录总权重值 + + firstWeight = afterWarmup; // 该Invoker作为第一个Invoker,记录其权重值 + + sameWeight = true; // 重新记录是否权重值相等 + + } else if (active == leastActive) { + + // 当前Invoker属于活跃请求数最小的Invoker集合 + + leastIndexes[leastCount++] = i; // 记录该Invoker的下标 + + totalWeight += afterWarmup; // 更新总权重 + + if (sameWeight && afterWarmup != firstWeight) { + + sameWeight = false; // 更新权重值是否相等 + + } + + } + + } + + // 如果只有一个活跃请求数最小的Invoker对象,直接返回即可 + + if (leastCount == 1) { + + return invokers.get(leastIndexes[0]); + + } + + // 下面按照RandomLoadBalance的逻辑,从活跃请求数最小的Invoker集合中,随机选择一个Invoker对象返回 + + if (!sameWeight && totalWeight > 0) { + + int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight); + + for (int i = 0; i < leastCount; i++) { + + int leastIndex = leastIndexes[i]; + + offsetWeight -= weights[leastIndex]; + + if (offsetWeight < 0) { + + return invokers.get(leastIndex); + + } + + } + + } + + return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]); + +} + + +ActiveLimitFilter 以及底层的 RpcStatus 记录活跃请求数的具体原理,在前面的[第 30 课时]中我们已经详细分析过了,这里不再重复,如果有不清楚的地方,你可以回顾之前课时相关的内容。 + +RoundRobinLoadBalance + +RoundRobinLoadBalance 实现的是加权轮询负载均衡算法。 + +轮询指的是将请求轮流分配给每个 Provider。例如,有 A、B、C 三个 Provider 节点,按照普通轮询的方式,我们会将第一个请求分配给 Provider A,将第二个请求分配给 Provider B,第三个请求分配给 Provider C,第四个请求再次分配给 Provider A……如此循环往复。 + +轮询是一种无状态负载均衡算法,实现简单,适用于集群中所有 Provider 节点性能相近的场景。 但现实情况中就很难保证这一点了,因为很容易出现集群中性能最好和最差的 Provider 节点处理同样流量的情况,这就可能导致性能差的 Provider 节点各方面资源非常紧张,甚至无法及时响应了,但是性能好的 Provider 节点的各方面资源使用还较为空闲。这时我们可以通过加权轮询的方式,降低分配到性能较差的 Provider 节点的流量。 + +加权之后,分配给每个 Provider 节点的流量比会接近或等于它们的权重比。例如,Provider 节点 A、B、C 权重比为 5:1:1,那么在 7 次请求中,节点 A 将收到 5 次请求,节点 B 会收到 1 次请求,节点 C 则会收到 1 次请求。 + +在 Dubbo 2.6.4 版本及之前,RoundRobinLoadBalance 的实现存在一些问题,例如,选择 Invoker 的性能问题、负载均衡时不够平滑等。在 Dubbo 2.6.5 版本之后,这些问题都得到了修复,所以这里我们就来介绍最新的 RoundRobinLoadBalance 实现。 + +每个 Provider 节点有两个权重:一个权重是配置的 weight,该值在负载均衡的过程中不会变化;另一个权重是 currentWeight,该值会在负载均衡的过程中动态调整,初始值为 0。 + +当有新的请求进来时,RoundRobinLoadBalance 会遍历 Invoker 列表,并用对应的 currentWeight 加上其配置的权重。遍历完成后,再找到最大的 currentWeight,将其减去权重总和,然后返回相应的 Invoker 对象。 + +下面我们通过一个示例说明 RoundRobinLoadBalance 的执行流程,这里我们依旧假设 A、B、C 三个节点的权重比例为 5:1:1。 + + + + +处理第一个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [0, 0, 0] 变为 [5, 1, 1]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-2, 1, 1]。 +处理第二个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [-2, 1, 1] 变为 [3, 2, 2]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-4, 2, 2]。 +处理第三个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [-4, 2, 2] 变为 [1, 3, 3]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 B。最后,将节点 B 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [1, -4, 3]。 +处理第四个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [1, -4, 3] 变为 [6, -3, 4]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-1, -3, 4]。 +处理第五个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [-1, -3, 4] 变为 [4, -2, 5]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 C。最后,将节点 C 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [4, -2, -2]。 +处理第六个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [4, -2, -2] 变为 [9, -1, -1]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [2, -1, -1]。 +处理第七个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [2, -1, -1] 变为 [7, 0, 0]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [0, 0, 0]。 + + +到此为止,一个轮询的周期就结束了。 + +而在 Dubbo 2.6.4 版本中,上面示例的一次轮询结果是 [A, A, A, A, A, B, C],也就是说前 5 个请求会全部都落到 A 这个节点上。这将会使节点 A 在短时间内接收大量的请求,压力陡增,而节点 B 和节点 C 此时没有收到任何请求,处于完全空闲的状态,这种“瞬间分配不平衡”的情况也就是前面提到的“不平滑问题”。 + +在 RoundRobinLoadBalance 中,我们为每个 Invoker 对象创建了一个对应的 WeightedRoundRobin 对象,用来记录配置的权重(weight 字段)以及随每次负载均衡算法执行变化的 current 权重(current 字段)。 + +了解了 WeightedRoundRobin 这个内部类后,我们再来看 RoundRobinLoadBalance.doSelect() 方法的具体实现: + +protected Invoker doSelect(List> invokers, URL url, Invocation invocation) { + + String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName(); + + // 获取整个Invoker列表对应的WeightedRoundRobin映射表,如果为空,则创建一个新的WeightedRoundRobin映射表 + + ConcurrentMap map = methodWeightMap.computeIfAbsent(key, k -> new ConcurrentHashMap<>()); + + int totalWeight = 0; + + long maxCurrent = Long.MIN_VALUE; + + long now = System.currentTimeMillis(); // 获取当前时间 + + Invoker selectedInvoker = null; + + WeightedRoundRobin selectedWRR = null; + + for (Invoker invoker : invokers) { + + String identifyString = invoker.getUrl().toIdentityString(); + + int weight = getWeight(invoker, invocation); + + // 检测当前Invoker是否有相应的WeightedRoundRobin对象,没有则进行创建 + + WeightedRoundRobin weightedRoundRobin = map.computeIfAbsent(identifyString, k -> { + + WeightedRoundRobin wrr = new WeightedRoundRobin(); + + wrr.setWeight(weight); + + return wrr; + + }); + + // 检测Invoker权重是否发生了变化,若发生变化,则更新WeightedRoundRobin的weight字段 + + if (weight != weightedRoundRobin.getWeight()) { + + weightedRoundRobin.setWeight(weight); + + } + + // 让currentWeight加上配置的Weight + + long cur = weightedRoundRobin.increaseCurrent(); + + // 设置lastUpdate字段 + + weightedRoundRobin.setLastUpdate(now); + + // 寻找具有最大currentWeight的Invoker,以及Invoker对应的WeightedRoundRobin + + if (cur > maxCurrent) { + + maxCurrent = cur; + + selectedInvoker = invoker; + + selectedWRR = weightedRoundRobin; + + } + + totalWeight += weight; // 计算权重总和 + + } + + if (invokers.size() != map.size()) { + + map.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > RECYCLE_PERIOD); + + } + + if (selectedInvoker != null) { + + // 用currentWeight减去totalWeight + + selectedWRR.sel(totalWeight); + + // 返回选中的Invoker对象 + + return selectedInvoker; + + } + + return invokers.get(0); + +} + + +ShortestResponseLoadBalance + +ShortestResponseLoadBalance 是Dubbo 2.7 版本之后新增加的一个 LoadBalance 实现类。它实现了最短响应时间的负载均衡算法,也就是从多个 Provider 节点中选出调用成功的且响应时间最短的 Provider 节点,不过满足该条件的 Provider 节点可能有多个,所以还要再使用随机算法进行一次选择,得到最终要调用的 Provider 节点。 + +了解了 ShortestResponseLoadBalance 的核心原理之后,我们一起来看 ShortestResponseLoadBalance.doSelect() 方法的核心实现,如下所示: + +protected Invoker doSelect(List> invokers, URL url, Invocation invocation) { + + // 记录Invoker集合的数量 + + int length = invokers.size(); + + // 用于记录所有Invoker集合中最短响应时间 + + long shortestResponse = Long.MAX_VALUE; + + // 具有相同最短响应时间的Invoker个数 + + int shortestCount = 0; + + // 存放所有最短响应时间的Invoker的下标 + + int[] shortestIndexes = new int[length]; + + // 存储每个Invoker的权重 + + int[] weights = new int[length]; + + // 存储权重总和 + + int totalWeight = 0; + + // 记录第一个Invoker对象的权重 + + int firstWeight = 0; + + // 最短响应时间Invoker集合中的Invoker权重是否相同 + + boolean sameWeight = true; + + for (int i = 0; i < length; i++) { + + Invoker invoker = invokers.get(i); + + RpcStatus rpcStatus = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()); + + // 获取调用成功的平均时间,具体计算方式是:调用成功的请求数总数对应的总耗时 / 调用成功的请求数总数 = 成功调用的平均时间 + + // RpcStatus 的内容在前面课时已经介绍过了,这里不再重复 + + long succeededAverageElapsed = rpcStatus.getSucceededAverageElapsed(); + + // 获取的是该Provider当前的活跃请求数,也就是当前正在处理的请求数 + + int active = rpcStatus.getActive(); + + // 计算一个处理新请求的预估值,也就是如果当前请求发给这个Provider,大概耗时多久处理完成 + + long estimateResponse = succeededAverageElapsed * active; + + // 计算该Invoker的权重(主要是处理预热) + + int afterWarmup = getWeight(invoker, invocation); + + weights[i] = afterWarmup; + + if (estimateResponse < shortestResponse) { + + // 第一次找到Invoker集合中最短响应耗时的Invoker对象,记录其相关信息 + + shortestResponse = estimateResponse; + + shortestCount = 1; + + shortestIndexes[0] = i; + + totalWeight = afterWarmup; + + firstWeight = afterWarmup; + + sameWeight = true; + + } else if (estimateResponse == shortestResponse) { + + // 出现多个耗时最短的Invoker对象 + + shortestIndexes[shortestCount++] = i; + + totalWeight += afterWarmup; + + if (sameWeight && i > 0 + + && afterWarmup != firstWeight) { + + sameWeight = false; + + } + + } + + } + + if (shortestCount == 1) { + + return invokers.get(shortestIndexes[0]); + + } + + // 如果耗时最短的所有Invoker对象的权重不相同,则通过加权随机负载均衡的方式选择一个Invoker返回 + + if (!sameWeight && totalWeight > 0) { + + int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight); + + for (int i = 0; i < shortestCount; i++) { + + int shortestIndex = shortestIndexes[i]; + + offsetWeight -= weights[shortestIndex]; + + if (offsetWeight < 0) { + + return invokers.get(shortestIndex); + + } + + } + + } + + // 如果耗时最短的所有Invoker对象的权重相同,则随机返回一个 + + return invokers.get(shortestIndexes[ThreadLocalRandom.current().nextInt(shortestCount)]); + +} + + +总结 + +今天我们紧接上一课时介绍了 LoadBalance 接口的剩余三个实现。 + +我们首先介绍了 LeastActiveLoadBalance 实现,它使用最小活跃数负载均衡算法,选择当前请求最少的 Provider 节点处理最新的请求;接下来介绍了 RoundRobinLoadBalance 实现,它使用加权轮询负载均衡算法,弥补了单纯的轮询负载均衡算法导致的问题,同时随着 Dubbo 版本的升级,也将其自身不够平滑的问题优化掉了;最后介绍了 ShortestResponseLoadBalance 实现,它会从响应时间最短的 Provider 节点中选择一个 Provider 节点来处理新请求。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/37集群容错:一个好汉三个帮(上).md b/专栏/Dubbo源码解读与实战-完/37集群容错:一个好汉三个帮(上).md new file mode 100644 index 0000000..611161d --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/37集群容错:一个好汉三个帮(上).md @@ -0,0 +1,581 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 37 集群容错:一个好汉三个帮(上) + 你好,我是杨四正,今天我和你分享的主题是集群容错:一个好汉三个帮(上篇)。 + +在前面的课时中,我们已经对 Directory、Router、LoadBalance 等概念进行了深入的剖析,本课时将重点分析 Cluster 接口的相关内容。 + +Cluster 接口提供了我们常说的集群容错功能。 + +集群中的单个节点有一定概率出现一些问题,例如,磁盘损坏、系统崩溃等,导致节点无法对外提供服务,因此在分布式 RPC 框架中,必须要重视这种情况。为了避免单点故障,我们的 Provider 通常至少会部署在两台服务器上,以集群的形式对外提供服务,对于一些负载比较高的服务,则需要部署更多 Provider 来抗住流量。 + +在 Dubbo 中,通过 Cluster 这个接口把一组可供调用的 Provider 信息组合成为一个统一的 Invoker 供调用方进行调用。经过 Router 过滤、LoadBalance 选址之后,选中一个具体 Provider 进行调用,如果调用失败,则会按照集群的容错策略进行容错处理。 + +Dubbo 默认内置了若干容错策略,并且每种容错策略都有自己独特的应用场景,我们可以通过配置选择不同的容错策略。如果这些内置容错策略不能满足需求,我们还可以通过自定义容错策略进行配置。 + +了解了上述背景知识之后,下面我们就正式开始介绍 Cluster 接口。 + +Cluster 接口与容错机制 + +Cluster 的工作流程大致可以分为两步(如下图所示):①创建 Cluster Invoker 实例(在 Consumer 初始化时,Cluster 实现类会创建一个 Cluster Invoker 实例,即下图中的 merge 操作);②使用 Cluster Invoker 实例(在 Consumer 服务消费者发起远程调用请求的时候,Cluster Invoker 会依赖前面课时介绍的 Directory、Router、LoadBalance 等组件得到最终要调用的 Invoker 对象)。 + + + +Cluster 核心流程图 + +Cluster Invoker 获取 Invoker 的流程大致可描述为如下: + + +通过 Directory 获取 Invoker 列表,以 RegistryDirectory 为例,会感知注册中心的动态变化,实时获取当前 Provider 对应的 Invoker 集合; +调用 Router 的 route() 方法进行路由,过滤掉不符合路由规则的 Invoker 对象; +通过 LoadBalance 从 Invoker 列表中选择一个 Invoker; +ClusterInvoker 会将参数传给 LoadBalance 选择出的 Invoker 实例的 invoke 方法,进行真正的远程调用。 + + +这个过程是一个正常流程,没有涉及容错处理。Dubbo 中常见的容错方式有如下几个。 + + +Failover Cluster:失败自动切换。它是 Dubbo 的默认容错机制,在请求一个 Provider 节点失败的时候,自动切换其他 Provider 节点,默认执行 3 次,适合幂等操作。当然,重试次数越多,在故障容错的时候带给 Provider 的压力就越大,在极端情况下甚至可能造成雪崩式的问题。 +Failback Cluster:失败自动恢复。失败后记录到队列中,通过定时器重试。 +Failfast Cluster:快速失败。请求失败后返回异常,不进行任何重试。 +Failsafe Cluster:失败安全。请求失败后忽略异常,不进行任何重试。 +Forking Cluster:并行调用多个 Provider 节点,只要有一个成功就返回。 +Broadcast Cluster:广播多个 Provider 节点,只要有一个节点失败就失败。 +Available Cluster:遍历所有的 Provider 节点,找到每一个可用的节点,就直接调用。如果没有可用的 Provider 节点,则直接抛出异常。 +Mergeable Cluster:请求多个 Provider 节点并将得到的结果进行合并。 + + +下面我们再来看 Cluster 接口。Cluster 接口是一个扩展接口,通过 @SPI 注解的参数我们知道其使用的默认实现是 FailoverCluster,它只定义了一个 join() 方法,在其上添加了 @Adaptive 注解,会动态生成适配器类,其中会优先根据 Directory.getUrl() 方法返回的 URL 中的 cluster 参数值选择扩展实现,若无 cluster 参数则使用默认的 FailoverCluster 实现。Cluster 接口的具体定义如下所示: + +@SPI(FailoverCluster.NAME) + +public interface Cluster { + + @Adaptive + + Invoker join(Directory directory) throws RpcException; + +} + + +Cluster 接口的实现类如下图所示,分别对应前面提到的多种容错策略: + + + +Cluster 接口继承关系 + +在每个 Cluster 接口实现中,都会创建对应的 Invoker 对象,这些都继承自 AbstractClusterInvoker 抽象类,如下图所示: + + + +AbstractClusterInvoker 继承关系图 + +通过上面两张继承关系图我们可以看出,Cluster 接口和 Invoker 接口都会有相应的抽象实现类,这些抽象实现类都实现了一些公共能力。下面我们就来深入介绍 AbstractClusterInvoker 和 AbstractCluster 这两个抽象类。 + +AbstractClusterInvoker + +了解了 Cluster Invoker 的继承关系之后,我们首先来看 AbstractClusterInvoker,它有两点核心功能:一个是实现的 Invoker 接口,对 Invoker.invoke() 方法进行通用的抽象实现;另一个是实现通用的负载均衡算法。 + +在 AbstractClusterInvoker.invoke() 方法中,会通过 Directory 获取 Invoker 列表,然后通过 SPI 初始化 LoadBalance,最后调用 doInvoke() 方法执行子类的逻辑。在 Directory.list() 方法返回 Invoker 集合之前,已经使用 Router 进行了一次筛选,你可以回顾前面[第 31 课时]对 RegistryDirectory 的分析。 + +public Result invoke(final Invocation invocation) throws RpcException { + + // 检测当前Invoker是否已销毁 + + checkWhetherDestroyed(); + + // 将RpcContext中的attachment添加到Invocation中 + + Map contextAttachments = RpcContext.getContext().getObjectAttachments(); + + if (contextAttachments != null && contextAttachments.size() != 0) { + + ((RpcInvocation) invocation).addObjectAttachments(contextAttachments); + + } + + // 通过Directory获取Invoker对象列表,通过对RegistryDirectory的介绍我们知道,其中已经调用了Router进行过滤 + + List> invokers = list(invocation); + + // 通过SPI加载LoadBalance + + LoadBalance loadbalance = initLoadBalance(invokers, invocation); + + RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation); + + // 调用doInvoke()方法,该方法是个抽象方法 + + return doInvoke(invocation, invokers, loadbalance); + +} + +protected List> list(Invocation invocation) throws RpcException { + + return directory.list(invocation); // 调用Directory.list()方法 + +} + + +下面我们来看一下 AbstractClusterInvoker 是如何按照不同的 LoadBalance 算法从 Invoker 集合中选取最终 Invoker 对象的。 + +AbstractClusterInvoker 并没有简单粗暴地使用 LoadBalance.select() 方法完成负载均衡,而是做了进一步的封装,具体实现在 select() 方法中。在 select() 方法中会根据配置决定是否开启粘滞连接特性,如果开启了,则需要将上次使用的 Invoker 缓存起来,只要 Provider 节点可用就直接调用,不会再进行负载均衡。如果调用失败,才会重新进行负载均衡,并且排除已经重试过的 Provider 节点。 + +// 第一个参数是此次使用的LoadBalance实现,第二个参数Invocation是此次服务调用的上下文信息, + +// 第三个参数是待选择的Invoker集合,第四个参数用来记录负载均衡已经选出来、尝试过的Invoker集合 + +protected Invoker select(LoadBalance loadbalance, Invocation invocation, List> invokers, List> selected) throws RpcException { + + if (CollectionUtils.isEmpty(invokers)) { + + return null; + + } + + // 获取调用方法名 + + String methodName = invocation == null ? StringUtils.EMPTY_STRING : invocation.getMethodName(); + + // 获取sticky配置,sticky表示粘滞连接,所谓粘滞连接是指Consumer会尽可能地 + + // 调用同一个Provider节点,除非这个Provider无法提供服务 + + boolean sticky = invokers.get(0).getUrl() + + .getMethodParameter(methodName, CLUSTER_STICKY_KEY, DEFAULT_CLUSTER_STICKY); + + // 检测invokers列表是否包含sticky Invoker,如果不包含, + + // 说明stickyInvoker代表的服务提供者挂了,此时需要将其置空 + + if (stickyInvoker != null && !invokers.contains(stickyInvoker)) { + + stickyInvoker = null; + + } + + // 如果开启了粘滞连接特性,需要先判断这个Provider节点是否已经重试过了 + + if (sticky && stickyInvoker != null // 表示粘滞连接 + + && (selected == null || !selected.contains(stickyInvoker)) // 表示stickyInvoker未重试过 + + ) { + + // 检测当前stickyInvoker是否可用,如果可用,直接返回stickyInvoker + + if (availablecheck && stickyInvoker.isAvailable()) { + + return stickyInvoker; + + } + + } + + // 执行到这里,说明前面的stickyInvoker为空,或者不可用 + + // 这里会继续调用doSelect选择新的Invoker对象 + + Invoker invoker = doSelect(loadbalance, invocation, invokers, selected); + + if (sticky) { // 是否开启粘滞,更新stickyInvoker字段 + + stickyInvoker = invoker; + + } + + return invoker; + +} + + +doSelect() 方法主要做了两件事: + + +一是通过 LoadBalance 选择 Invoker 对象; +二是如果选出来的 Invoker 不稳定或不可用,会调用 reselect() 方法进行重选。 + + +private Invoker doSelect(LoadBalance loadbalance, Invocation invocation, + + List> invokers, List> selected) throws RpcException { + + // 判断是否需要进行负载均衡,Invoker集合为空,直接返回null + + if (CollectionUtils.isEmpty(invokers)) { + + return null; + + } + + if (invokers.size() == 1) { // 只有一个Invoker对象,直接返回即可 + + return invokers.get(0); + + } + + // 通过LoadBalance实现选择Invoker对象 + + Invoker invoker = loadbalance.select(invokers, getUrl(), invocation); + + // 如果LoadBalance选出的Invoker对象,已经尝试过请求了或不可用,则需要调用reselect()方法重选 + + if ((selected != null && selected.contains(invoker)) // Invoker已经尝试调用过了,但是失败了 + + || (!invoker.isAvailable() && getUrl() != null && availablecheck) // Invoker不可用 + + ) { + + try { + + // 调用reselect()方法重选 + + Invoker rInvoker = reselect(loadbalance, invocation, invokers, selected, availablecheck); + + // 如果重选的Invoker对象不为空,则直接返回这个 rInvoker + + if (rInvoker != null) { + + invoker = rInvoker; + + } else { + + int index = invokers.indexOf(invoker); + + try { + + // 如果重选的Invoker对象为空,则返回该Invoker的下一个Invoker对象 + + invoker = invokers.get((index + 1) % invokers.size()); + + } catch (Exception e) { + + logger.warn("..."); + + } + + } + + } catch (Throwable t) { + + logger.error("..."); + + } + + } + + return invoker; + +} + + +reselect() 方法会重新进行一次负载均衡,首先对未尝试过的可用 Invokers 进行负载均衡,如果已经全部重试过了,则将尝试过的 Provider 节点过滤掉,然后在可用的 Provider 节点中重新进行负载均衡。 + +private Invoker reselect(LoadBalance loadbalance, Invocation invocation, + + List> invokers, List> selected, boolean availablecheck) throws RpcException { + + // 用于记录要重新进行负载均衡的Invoker集合 + + List> reselectInvokers = new ArrayList<>( + + invokers.size() > 1 ? (invokers.size() - 1) : invokers.size()); + + // 将不在selected集合中的Invoker过滤出来进行负载均衡 + + for (Invoker invoker : invokers) { + + if (availablecheck && !invoker.isAvailable()) { + + continue; + + } + + if (selected == null || !selected.contains(invoker)) { + + reselectInvokers.add(invoker); + + } + + } + + // reselectInvokers不为空时,才需要通过负载均衡组件进行选择 + + if (!reselectInvokers.isEmpty()) { + + return loadbalance.select(reselectInvokers, getUrl(), invocation); + + } + + // 只能对selected集合中可用的Invoker再次进行负载均衡 + + if (selected != null) { + + for (Invoker invoker : selected) { + + if ((invoker.isAvailable()) // available first + + && !reselectInvokers.contains(invoker)) { + + reselectInvokers.add(invoker); + + } + + } + + } + + if (!reselectInvokers.isEmpty()) { + + return loadbalance.select(reselectInvokers, getUrl(), invocation); + + } + + return null; + +} + + +AbstractCluster + +常用的 ClusterInvoker 实现都继承了 AbstractClusterInvoker 类型,对应的 Cluster 扩展实现都继承了 AbstractCluster 抽象类。AbstractCluster 抽象类的核心逻辑是在 ClusterInvoker 外层包装一层 ClusterInterceptor,从而实现类似切面的效果。 + +下面是 ClusterInterceptor 接口的定义: + +@SPI + +public interface ClusterInterceptor { + + // 前置拦截方法 + + void before(AbstractClusterInvoker clusterInvoker, Invocation invocation); + + // 后置拦截方法 + + void after(AbstractClusterInvoker clusterInvoker, Invocation invocation); + + // 调用ClusterInvoker的invoke()方法完成请求 + + default Result intercept(AbstractClusterInvoker clusterInvoker, Invocation invocation) throws RpcException { + + return clusterInvoker.invoke(invocation); + + } + + // 这个Listener用来监听请求的正常结果以及异常 + + interface Listener { + + void onMessage(Result appResponse, AbstractClusterInvoker clusterInvoker, Invocation invocation); + + void onError(Throwable t, AbstractClusterInvoker clusterInvoker, Invocation invocation); + + } + +} + + +在 AbstractCluster 抽象类的 join() 方法中,首先会调用 doJoin() 方法获取最终要调用的 Invoker 对象,doJoin() 是个抽象方法,由 AbstractCluster 子类根据具体的策略进行实现。之后,AbstractCluster.join() 方法会调用 buildClusterInterceptors() 方法加载 ClusterInterceptor 扩展实现类,对 Invoker 对象进行包装。具体实现如下: + +private Invoker buildClusterInterceptors(AbstractClusterInvoker clusterInvoker, String key) { + + AbstractClusterInvoker last = clusterInvoker; + + // 通过SPI方式加载ClusterInterceptor扩展实现 + + List interceptors = ExtensionLoader.getExtensionLoader(ClusterInterceptor.class).getActivateExtension(clusterInvoker.getUrl(), key); + + if (!interceptors.isEmpty()) { + + for (int i = interceptors.size() - 1; i >= 0; i--) { + + // 将InterceptorInvokerNode收尾连接到一起,形成调用链 + + final ClusterInterceptor interceptor = interceptors.get(i); + + final AbstractClusterInvoker next = last; + + last = new InterceptorInvokerNode<>(clusterInvoker, interceptor, next); + + } + + } + + return last; + +} + +@Override + +public Invoker join(Directory directory) throws RpcException { + + // 扩展名称由reference.interceptor参数确定 + + return buildClusterInterceptors(doJoin(directory), directory.getUrl().getParameter(REFERENCE_INTERCEPTOR_KEY)); + +} + + +InterceptorInvokerNode 会将底层的 AbstractClusterInvoker 对象以及关联的 ClusterInterceptor 对象封装到一起,还会维护一个 next 引用,指向下一个 InterceptorInvokerNode 对象。 + +在 InterceptorInvokerNode.invoke() 方法中,会先调用 ClusterInterceptor 的前置逻辑,然后执行 intercept() 方法调用 AbstractClusterInvoker 的 invoke() 方法完成远程调用,最后执行 ClusterInterceptor 的后置逻辑。具体实现如下: + +public Result invoke(Invocation invocation) throws RpcException { + + Result asyncResult; + + try { + + interceptor.before(next, invocation); // 前置逻辑 + + // 执行invoke()方法完成远程调用 + + asyncResult = interceptor.intercept(next, invocation); + + } catch (Exception e) { + + if (interceptor instanceof ClusterInterceptor.Listener) { + + // 出现异常时,会触发监听器的onError()方法 + + ClusterInterceptor.Listener listener = (ClusterInterceptor.Listener) interceptor; + + listener.onError(e, clusterInvoker, invocation); + + } + + throw e; + + } finally { + + // 执行后置逻辑 + + interceptor.after(next, invocation); + + } + + return asyncResult.whenCompleteWithContext((r, t) -> { + + if (interceptor instanceof ClusterInterceptor.Listener) { + + ClusterInterceptor.Listener listener = (ClusterInterceptor.Listener) interceptor; + + if (t == null) { + + // 正常返回时,会调用onMessage()方法触发监听器 + + listener.onMessage(r, clusterInvoker, invocation); + + } else { + + listener.onError(t, clusterInvoker, invocation); + + } + + } + + }); + +} + + +Dubbo 提供了两个 ClusterInterceptor 实现类,分别是 ConsumerContextClusterInterceptor 和 ZoneAwareClusterInterceptor,如下图所示: + + + +ClusterInterceptor 继承关系图 + +在 ConsumerContextClusterInterceptor 的 before() 方法中,会在 RpcContext 中设置当前 Consumer 地址、此次调用的 Invoker 等信息,同时还会删除之前与当前线程绑定的 Server Context。在 after() 方法中,会删除本地 RpcContext 的信息。ConsumerContextClusterInterceptor 的具体实现如下: + +public void before(AbstractClusterInvoker invoker, Invocation invocation) { + + // 获取当前线程绑定的RpcContext + + RpcContext context = RpcContext.getContext(); + + // 设置Invoker、Consumer地址等信息 context.setInvocation(invocation).setLocalAddress(NetUtils.getLocalHost(), 0); + + if (invocation instanceof RpcInvocation) { + + ((RpcInvocation) invocation).setInvoker(invoker); + + } + + RpcContext.removeServerContext(); + +} + +public void after(AbstractClusterInvoker clusterInvoker, Invocation invocation) { + + RpcContext.removeContext(true); // 删除本地RpcContext的信息 + +} + + +ConsumerContextClusterInterceptor 同时继承了 ClusterInterceptor.Listener 接口,在其 onMessage() 方法中,会获取响应中的 attachments 并设置到 RpcContext 中的 SERVER_LOCAL 之中,具体实现如下: + +public void onMessage(Result appResponse, AbstractClusterInvoker invoker, Invocation invocation) { + +// 从AppResponse中获取attachment,并设置到SERVER_LOCAL这个RpcContext中 RpcContext.getServerContext().setObjectAttachments(appResponse.getObjectAttachments()); + +} + + +介绍完 ConsumerContextClusterInterceptor,我们再来看 ZoneAwareClusterInterceptor。 + +在 ZoneAwareClusterInterceptor 的 before() 方法中,会从 RpcContext 中获取多注册中心相关的参数并设置到 Invocation 中(主要是 registry_zone 参数和 registry_zone_force 参数,这两个参数的具体含义,在后面分析 ZoneAwareClusterInvoker 时详细介绍),ZoneAwareClusterInterceptor 的 after() 方法为空实现。ZoneAwareClusterInterceptor 的具体实现如下: + +public void before(AbstractClusterInvoker clusterInvoker, Invocation invocation) { + + RpcContext rpcContext = RpcContext.getContext(); + + // 从RpcContext中获取registry_zone参数和registry_zone_force参数 + + String zone = (String) rpcContext.getAttachment(REGISTRY_ZONE); + + String force = (String) rpcContext.getAttachment(REGISTRY_ZONE_FORCE); + + // 检测用户是否提供了ZoneDetector接口的扩展实现 + + ExtensionLoader loader = ExtensionLoader.getExtensionLoader(ZoneDetector.class); + + if (StringUtils.isEmpty(zone) && loader.hasExtension("default")) { + + ZoneDetector detector = loader.getExtension("default"); + + zone = detector.getZoneOfCurrentRequest(invocation); + + force = detector.isZoneForcingEnabled(invocation, zone); + + } + + // 将registry_zone参数和registry_zone_force参数设置到Invocation中 + + if (StringUtils.isNotEmpty(zone)) { + + invocation.setAttachment(REGISTRY_ZONE, zone); + + } + + if (StringUtils.isNotEmpty(force)) { + + invocation.setAttachment(REGISTRY_ZONE_FORCE, force); + + } + +} + + +需要注意的是,ZoneAwareClusterInterceptor 没有实现 ClusterInterceptor.Listener 接口,也就是不提供监听响应的功能。 + +总结 + +本课时我们主要介绍的是 Dubbo Cluster 层中容错机制相关的内容。首先,我们了解了集群容错机制的作用。然后,我们介绍了 Cluster 接口的定义以及其各个实现类的核心功能。之后,我们深入讲解了 AbstractClusterInvoker 的实现,其核心是实现了一套通用的负载均衡算法。最后,我们还分析了 AbstractCluster 抽象实现类以及其中涉及的 ClusterInterceptor 接口的内容。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/38集群容错:一个好汉三个帮(下).md b/专栏/Dubbo源码解读与实战-完/38集群容错:一个好汉三个帮(下).md new file mode 100644 index 0000000..3ad1472 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/38集群容错:一个好汉三个帮(下).md @@ -0,0 +1,952 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 38 集群容错:一个好汉三个帮(下) + 你好,我是杨四正,今天我和你分享的主题是集群容错:一个好汉三个帮(下篇)。 + +在上一课时,我们介绍了 Dubbo Cluster 层中集群容错机制的基础知识,还说明了 Cluster 接口的定义以及其各个实现类的核心功能。同时,我们还分析了 AbstractClusterInvoker 抽象类以及 AbstractCluster 抽象实现类的核心实现。 + +那接下来在本课时,我们将介绍 Cluster 接口的全部实现类,以及相关的 Cluster Invoker 实现类。 + +FailoverClusterInvoker + +通过前面对 Cluster 接口的介绍我们知道,Cluster 默认的扩展实现是 FailoverCluster,其 doJoin() 方法中会创建一个 FailoverClusterInvoker 对象并返回,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new FailoverClusterInvoker<>(directory); + +} + + +FailoverClusterInvoker 会在调用失败的时候,自动切换 Invoker 进行重试。下面来看 FailoverClusterInvoker 的核心实现: + +public Result doInvoke(Invocation invocation, final List> invokers, LoadBalance loadbalance) throws RpcException { + + List> copyInvokers = invokers; + + // 检查copyInvokers集合是否为空,如果为空会抛出异常 + + checkInvokers(copyInvokers, invocation); + + String methodName = RpcUtils.getMethodName(invocation); + + // 参数重试次数,默认重试2次,总共执行3次 + + int len = getUrl().getMethodParameter(methodName, RETRIES_KEY, DEFAULT_RETRIES) + 1; + + if (len <= 0) { + + len = 1; + + } + + RpcException le = null; + + // 记录已经尝试调用过的Invoker对象 + + List> invoked = new ArrayList>(copyInvokers.size()); + + Set providers = new HashSet(len); + + for (int i = 0; i < len; i++) { + + // 第一次传进来的invokers已经check过了,第二次则是重试,需要重新获取最新的服务列表 + + if (i > 0) { + + checkWhetherDestroyed(); + + // 这里会重新调用Directory.list()方法,获取Invoker列表 + + copyInvokers = list(invocation); + + // 检查copyInvokers集合是否为空,如果为空会抛出异常 + + checkInvokers(copyInvokers, invocation); + + } + + // 通过LoadBalance选择Invoker对象,这里传入的invoked集合, + + // 就是前面介绍AbstractClusterInvoker.select()方法中的selected集合 + + Invoker invoker = select(loadbalance, invocation, copyInvokers, invoked); + + // 记录此次要尝试调用的Invoker对象,下一次重试时就会过滤这个服务 + + invoked.add(invoker); + + RpcContext.getContext().setInvokers((List) invoked); + + try { + + // 调用目标Invoker对象的invoke()方法,完成远程调用 + + Result result = invoker.invoke(invocation); + + // 经过尝试之后,终于成功,这里会打印一个警告日志,将尝试过来的Provider地址打印出来 + + if (le != null && logger.isWarnEnabled()) { + + logger.warn("..."); + + } + + return result; + + } catch (RpcException e) { + + if (e.isBiz()) { // biz exception. + + throw e; + + } + + le = e; + + } catch (Throwable e) { // 抛出异常,表示此次尝试失败,会进行重试 + + le = new RpcException(e.getMessage(), e); + + } finally { + + // 记录尝试过的Provider地址,会在上面的警告日志中打印出来 + + providers.add(invoker.getUrl().getAddress()); + + } + + } + + // 达到重试次数上限之后,会抛出异常,其中会携带调用的方法名、尝试过的Provider节点的地址(providers集合)、全部的Provider个数(copyInvokers集合)以及Directory信息 + + throw new RpcException(le.getCode(), "..."); + +} + + +FailbackClusterInvoker + +FailbackCluster 是 Cluster 接口的另一个扩展实现,扩展名是 failback,其 doJoin() 方法中创建的 Invoker 对象是 FailbackClusterInvoker 类型,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new FailbackClusterInvoker<>(directory); + +} + + +FailbackClusterInvoker 在请求失败之后,返回一个空结果给 Consumer,同时还会添加一个定时任务对失败的请求进行重试。下面来看 FailbackClusterInvoker 的具体实现: + +protected Result doInvoke(Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + Invoker invoker = null; + + try { + + // 检测Invoker集合是否为空 + + checkInvokers(invokers, invocation); + + // 调用select()方法得到此次尝试的Invoker对象 + + invoker = select(loadbalance, invocation, invokers, null); + + // 调用invoke()方法完成远程调用 + + return invoker.invoke(invocation); + + } catch (Throwable e) { + + // 请求失败之后,会添加一个定时任务进行重试 + + addFailed(loadbalance, invocation, invokers, invoker); + + return AsyncRpcResult.newDefaultAsyncResult(null, null, invocation); // 请求失败时,会返回一个空结果 + + } + +} + + +在 doInvoke() 方法中,请求失败时会调用 addFailed() 方法添加定时任务进行重试,默认每隔 5 秒执行一次,总共重试 3 次,具体实现如下: + +private void addFailed(LoadBalance loadbalance, Invocation invocation, List> invokers, Invoker lastInvoker) { + + if (failTimer == null) { + + synchronized (this) { + + if (failTimer == null) { // Double Check防止并发问题 + + // 初始化时间轮,这个时间轮有32个槽,每个槽代表1秒 + + failTimer = new HashedWheelTimer( + + new NamedThreadFactory("failback-cluster-timer", true), + + 1, + + TimeUnit.SECONDS, 32, failbackTasks); + + } + + } + + } + + // 创建一个定时任务 + + RetryTimerTask retryTimerTask = new RetryTimerTask(loadbalance, invocation, invokers, lastInvoker, retries, RETRY_FAILED_PERIOD); + + try { + + // 将定时任务添加到时间轮中 + + failTimer.newTimeout(retryTimerTask, RETRY_FAILED_PERIOD, TimeUnit.SECONDS); + + } catch (Throwable e) { + + logger.error("..."); + + } + +} + + +在 RetryTimerTask 定时任务中,会重新调用 select() 方法筛选合适的 Invoker 对象,并尝试进行请求。如果请求再次失败且重试次数未达到上限,则调用 rePut() 方法再次添加定时任务,等待进行重试;如果请求成功,也不会返回任何结果。RetryTimerTask 的核心实现如下: + +public void run(Timeout timeout) { + + try { + + // 重新选择Invoker对象,注意,这里会将上次重试失败的Invoker作为selected集合传入 + + Invoker retryInvoker = select(loadbalance, invocation, invokers, Collections.singletonList(lastInvoker)); + + lastInvoker = retryInvoker; + + retryInvoker.invoke(invocation); // 请求对应的Provider节点 + + } catch (Throwable e) { + + if ((++retryTimes) >= retries) { // 重试次数达到上限,输出警告日志 + + logger.error("..."); + + } else { + + rePut(timeout); // 重试次数未达到上限,则重新添加定时任务,等待重试 + + } + + } + +} + +private void rePut(Timeout timeout) { + + if (timeout == null) { // 边界检查 + + return; + + } + + Timer timer = timeout.timer(); + + if (timer.isStop() || timeout.isCancelled()) { // 检查时间轮状态、检查定时任务状态 + + return; + + } + + // 重新添加定时任务 + + timer.newTimeout(timeout.task(), tick, TimeUnit.SECONDS); + +} + + +FailfastClusterInvoker + +FailfastCluster 的扩展名是 failfast,在其 doJoin() 方法中会创建 FailfastClusterInvoker 对象,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new FailfastClusterInvoker<>(directory); + +} + + +FailfastClusterInvoker 只会进行一次请求,请求失败之后会立即抛出异常,这种策略适合非幂等的操作,具体实现如下: + +public Result doInvoke(Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + checkInvokers(invokers, invocation); + + // 调用select()得到此次要调用的Invoker对象 + + Invoker invoker = select(loadbalance, invocation, invokers, null); + + try { + + return invoker.invoke(invocation); // 发起请求 + + } catch (Throwable e) { + + // 请求失败,直接抛出异常 + + if (e instanceof RpcException && ((RpcException) e).isBiz()) { + + throw (RpcException) e; + + } + + throw new RpcException("..."); + + } + +} + + +FailsafeClusterInvoker + +FailsafeCluster 的扩展名是 failsafe,在其 doJoin() 方法中会创建 FailsafeClusterInvoker 对象,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new FailsafeClusterInvoker<>(directory); + +} + + +FailsafeClusterInvoker 只会进行一次请求,请求失败之后会返回一个空结果,具体实现如下: + +public Result doInvoke(Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + try { + + // 检测Invoker集合是否为空 + + checkInvokers(invokers, invocation); + + // 调用select()得到此次要调用的Invoker对象 + + Invoker invoker = select(loadbalance, invocation, invokers, null); + + // 发起请求 + + return invoker.invoke(invocation); + + } catch (Throwable e) { + + // 请求失败之后,会打印一行日志并返回空结果 + + logger.error("..."); + + return AsyncRpcResult.newDefaultAsyncResult(null, null, invocation); + + } + +} + + +ForkingClusterInvoker + +ForkingCluster 的扩展名称为 forking,在其 doJoin() 方法中,会创建一个 ForkingClusterInvoker 对象,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new ForkingClusterInvoker<>(directory); + +} + + +ForkingClusterInvoker 中会维护一个线程池(executor 字段,通过 Executors.newCachedThreadPool() 方法创建的线程池),并发调用多个 Provider 节点,只要有一个 Provider 节点成功返回了结果,ForkingClusterInvoker 的 doInvoke() 方法就会立即结束运行。 + +ForkingClusterInvoker 主要是为了应对一些实时性要求较高的读操作,因为没有并发控制的多线程写入,可能会导致数据不一致。 + +ForkingClusterInvoker.doInvoke() 方法首先从 Invoker 集合中选出指定个数(forks 参数决定)的 Invoker 对象,然后通过 executor 线程池并发调用这些 Invoker,并将请求结果存储在 ref 阻塞队列中,则当前线程会阻塞在 ref 队列上,等待第一个请求结果返回。下面是 ForkingClusterInvoker 的具体实现: + +public Result doInvoke(final Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + try { + + // 检查Invoker集合是否为空 + + checkInvokers(invokers, invocation); + + final List> selected; + + // 从URL中获取forks参数,作为并发请求的上限,默认值为2 + + final int forks = getUrl().getParameter(FORKS_KEY, DEFAULT_FORKS); + + final int timeout = getUrl().getParameter(TIMEOUT_KEY, DEFAULT_TIMEOUT); + + if (forks <= 0 || forks >= invokers.size()) { + + // 如果forks为负数或是大于Invoker集合的长度,会直接并发调用全部Invoker + + selected = invokers; + + } else { + + // 按照forks指定的并发度,选择此次并发调用的Invoker对象 + + selected = new ArrayList<>(forks); + + while (selected.size() < forks) { + + Invoker invoker = select(loadbalance, invocation, invokers, selected); + + if (!selected.contains(invoker)) { + + selected.add(invoker); // 避免重复选择 + + } + + } + + } + + RpcContext.getContext().setInvokers((List) selected); + + // 记录失败的请求个数 + + final AtomicInteger count = new AtomicInteger(); + + // 用于记录请求结果 + + final BlockingQueue ref = new LinkedBlockingQueue<>(); + + for (final Invoker invoker : selected) { // 遍历 selected 列表 + + executor.execute(() -> { // 为每个Invoker创建一个任务,并提交到线程池中 + + try { + + // 发起请求 + + Result result = invoker.invoke(invocation); + + // 将请求结果写到ref队列中 + + ref.offer(result); + + } catch (Throwable e) { + + int value = count.incrementAndGet(); + + if (value >= selected.size()) { + + // 如果失败的请求个数超过了并发请求的个数,则向ref队列中写入异常 + + ref.offer(e); + + } + + } + + }); + + } + + try { + + // 当前线程会阻塞等待任意一个请求结果的出现 + + Object ret = ref.poll(timeout, TimeUnit.MILLISECONDS); + + if (ret instanceof Throwable) { // 如果结果类型为Throwable,则抛出异常 + + Throwable e = (Throwable) ret; + + throw new RpcException("..."); + + } + + return (Result) ret; // 返回结果 + + } catch (InterruptedException e) { + + throw new RpcException("..."); + + } + + } finally { + + // 清除上下文信息 + + RpcContext.getContext().clearAttachments(); + + } + +} + + +BroadcastClusterInvoker + +BroadcastCluster 这个 Cluster 实现类的扩展名为 broadcast,在其 doJoin() 方法中创建的是 BroadcastClusterInvoker 类型的 Invoker 对象,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new BroadcastClusterInvoker<>(directory); + +} + + +在 BroadcastClusterInvoker 中,会逐个调用每个 Provider 节点,其中任意一个 Provider 节点报错,都会在全部调用结束之后抛出异常。BroadcastClusterInvoker通常用于通知类的操作,例如通知所有 Provider 节点更新本地缓存。 + +下面来看 BroadcastClusterInvoker 的具体实现: + +public Result doInvoke(final Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + // 检测Invoker集合是否为空 + + checkInvokers(invokers, invocation); + + RpcContext.getContext().setInvokers((List) invokers); + + RpcException exception = null; // 用于记录失败请求的相关异常信息 + + Result result = null; + + // 遍历所有Invoker对象 + + for (Invoker invoker : invokers) { + + try { + + // 发起请求 + + result = invoker.invoke(invocation); + + } catch (RpcException e) { + + exception = e; + + logger.warn(e.getMessage(), e); + + } catch (Throwable e) { + + exception = new RpcException(e.getMessage(), e); + + logger.warn(e.getMessage(), e); + + } + + } + + if (exception != null) { // 出现任何异常,都会在这里抛出 + + throw exception; + + } + + return result; + +} + + +AvailableClusterInvoker + +AvailableCluster 这个 Cluster 实现类的扩展名为 available,在其 join() 方法中创建的是 AvailableClusterInvoker 类型的 Invoker 对象,具体实现如下: + +public Invoker join(Directory directory) throws RpcException { + + return new AvailableClusterInvoker<>(directory); + +} + + +在 AvailableClusterInvoker 的 doInvoke() 方法中,会遍历整个 Invoker 集合,逐个调用对应的 Provider 节点,当遇到第一个可用的 Provider 节点时,就尝试访问该 Provider 节点,成功则返回结果;如果访问失败,则抛出异常终止遍历。 + +下面是 AvailableClusterInvoker 的具体实现: + +public Result doInvoke(Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + for (Invoker invoker : invokers) { // 遍历整个Invoker集合 + + if (invoker.isAvailable()) { // 检测该Invoker是否可用 + + // 发起请求,调用失败时的异常会直接抛出 + + return invoker.invoke(invocation); + + } + + } + + // 没有找到可用的Invoker,也会抛出异常 + + throw new RpcException("No provider available in " + invokers); + +} + + +MergeableClusterInvoker + +MergeableCluster 这个 Cluster 实现类的扩展名为 mergeable,在其 doJoin() 方法中创建的是 MergeableClusterInvoker 类型的 Invoker 对象,具体实现如下: + +public AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new MergeableClusterInvoker(directory); + +} + + +MergeableClusterInvoker 会对多个 Provider 节点返回结果合并。如果请求的方法没有配置 Merger 合并器(即没有指定 merger 参数),则不会进行结果合并,而是直接将第一个可用的 Invoker 结果返回。下面来看 MergeableClusterInvoker 的具体实现: + +protected Result doInvoke(Invocation invocation, List> invokers, LoadBalance loadbalance) throws RpcException { + + checkInvokers(invokers, invocation); + + String merger = getUrl().getMethodParameter(invocation.getMethodName(), MERGER_KEY); + + // 判断要调用的目标方法是否有合并器,如果没有,则不会进行合并, + + // 找到第一个可用的Invoker直接调用并返回结果 + + if (ConfigUtils.isEmpty(merger)) { + + for (final Invoker invoker : invokers) { + + if (invoker.isAvailable()) { + + try { + + return invoker.invoke(invocation); + + } catch (RpcException e) { + + if (e.isNoInvokerAvailableAfterFilter()) { + + log.debug("No available provider for service" + getUrl().getServiceKey() + " on group " + invoker.getUrl().getParameter(GROUP_KEY) + ", will continue to try another group."); + + } else { + + throw e; + + } + + } + + } + + } + + return invokers.iterator().next().invoke(invocation); + + } + + // 确定目标方法的返回值类型 + + Class returnType; + + try { + + returnType = getInterface().getMethod( + + invocation.getMethodName(), invocation.getParameterTypes()).getReturnType(); + + } catch (NoSuchMethodException e) { + + returnType = null; + + } + + // 调用每个Invoker对象(异步方式),将请求结果记录到results集合中 + + Map results = new HashMap<>(); + + for (final Invoker invoker : invokers) { + + RpcInvocation subInvocation = new RpcInvocation(invocation, invoker); + + subInvocation.setAttachment(ASYNC_KEY, "true"); + + results.put(invoker.getUrl().getServiceKey(), invoker.invoke(subInvocation)); + + } + + Object result = null; + + List resultList = new ArrayList(results.size()); + + // 等待结果返回 + + for (Map.Entry entry : results.entrySet()) { + + Result asyncResult = entry.getValue(); + + try { + + Result r = asyncResult.get(); + + if (r.hasException()) { + + log.error("Invoke " + getGroupDescFromServiceKey(entry.getKey()) + + + " failed: " + r.getException().getMessage(), + + r.getException()); + + } else { + + resultList.add(r); + + } + + } catch (Exception e) { + + throw new RpcException("Failed to invoke service " + entry.getKey() + ": " + e.getMessage(), e); + + } + + } + + if (resultList.isEmpty()) { + + return AsyncRpcResult.newDefaultAsyncResult(invocation); + + } else if (resultList.size() == 1) { + + return resultList.iterator().next(); + + } + + if (returnType == void.class) { + + return AsyncRpcResult.newDefaultAsyncResult(invocation); + + } + + // merger如果以"."开头,后面为方法名,这个方法名是远程目标方法的返回类型中的方法 + + // 得到每个Provider节点返回的结果对象之后,会遍历每个返回对象,调用merger参数指定的方法 + + if (merger.startsWith(".")) { + + merger = merger.substring(1); + + Method method; + + try { + + method = returnType.getMethod(merger, returnType); + + } catch (NoSuchMethodException e) { + + throw new RpcException("Can not merge result because missing method [ " + merger + " ] in class [ " + + + returnType.getName() + " ]"); + + } + + if (!Modifier.isPublic(method.getModifiers())) { + + method.setAccessible(true); + + } + + // resultList集合保存了所有的返回对象,method是Method对象,也就是merger指定的方法 + + // result是最后返回调用方的结果 + + result = resultList.remove(0).getValue(); + + try { + + if (method.getReturnType() != void.class + + && method.getReturnType().isAssignableFrom(result.getClass())) { + + for (Result r : resultList) { // 反射调用 + + result = method.invoke(result, r.getValue()); + + } + + } else { + + for (Result r : resultList) { // 反射调用 + + method.invoke(result, r.getValue()); + + } + + } + + } catch (Exception e) { + + throw new RpcException("Can not merge result: " + e.getMessage(), e); + + } + + } else { + + Merger resultMerger; + + if (ConfigUtils.isDefault(merger)) { + + // merger参数为true或者default,表示使用默认的Merger扩展实现完成合并 + + // 在后面课时中会介绍Merger接口 + + resultMerger = MergerFactory.getMerger(returnType); + + } else { + + //merger参数指定了Merger的扩展名称,则使用SPI查找对应的Merger扩展实现对象 + + resultMerger = ExtensionLoader.getExtensionLoader(Merger.class).getExtension(merger); + + } + + if (resultMerger != null) { + + List rets = new ArrayList(resultList.size()); + + for (Result r : resultList) { + + rets.add(r.getValue()); + + } + + // 执行合并操作 + + result = resultMerger.merge( + + rets.toArray((Object[]) Array.newInstance(returnType, 0))); + + } else { + + throw new RpcException("There is no merger to merge result."); + + } + + } + + return AsyncRpcResult.newDefaultAsyncResult(result, invocation); + +} + + +ZoneAwareClusterInvoker + +ZoneAwareCluster 这个 Cluster 实现类的扩展名为 zone-aware,在其 doJoin() 方法中创建的是 ZoneAwareClusterInvoker 类型的 Invoker 对象,具体实现如下: + +protected AbstractClusterInvoker doJoin(Directory directory) throws RpcException { + + return new ZoneAwareClusterInvoker(directory); + +} + + +在 Dubbo 中使用多个注册中心的架构如下图所示: + + + +双注册中心结构图 + +Consumer 可以使用 ZoneAwareClusterInvoker 先在多个注册中心之间进行选择,选定注册中心之后,再选择 Provider 节点,如下图所示: + + + +ZoneAwareClusterInvoker 在多注册中心之间进行选择的策略有以下四种。 + + +找到preferred 属性为 true 的注册中心,它是优先级最高的注册中心,只有该中心无可用 Provider 节点时,才会回落到其他注册中心。 +根据请求中的 zone key 做匹配,优先派发到相同 zone 的注册中心。 +根据权重(也就是注册中心配置的 weight 属性)进行轮询。 +如果上面的策略都未命中,则选择第一个可用的 Provider 节点。 + + +下面来看 ZoneAwareClusterInvoker 的具体实现: + +public Result doInvoke(Invocation invocation, final List> invokers, LoadBalance loadbalance) throws RpcException { + + // 首先找到preferred属性为true的注册中心,它是优先级最高的注册中心,只有该中心无可用 Provider 节点时,才会回落到其他注册中心 + + for (Invoker invoker : invokers) { + + MockClusterInvoker mockClusterInvoker = (MockClusterInvoker) invoker; + + if (mockClusterInvoker.isAvailable() && mockClusterInvoker.getRegistryUrl() + + .getParameter(REGISTRY_KEY + "." + PREFERRED_KEY, false)) { + + return mockClusterInvoker.invoke(invocation); + + } + + } + + // 根据请求中的registry_zone做匹配,优先派发到相同zone的注册中心 + + String zone = (String) invocation.getAttachment(REGISTRY_ZONE); + + if (StringUtils.isNotEmpty(zone)) { + + for (Invoker invoker : invokers) { + + MockClusterInvoker mockClusterInvoker = (MockClusterInvoker) invoker; + + if (mockClusterInvoker.isAvailable() && zone.equals(mockClusterInvoker.getRegistryUrl().getParameter(REGISTRY_KEY + "." + ZONE_KEY))) { + + return mockClusterInvoker.invoke(invocation); + + } + + } + + String force = (String) invocation.getAttachment(REGISTRY_ZONE_FORCE); + + if (StringUtils.isNotEmpty(force) && "true".equalsIgnoreCase(force)) { + + throw new IllegalStateException("..."); + + } + + } + + // 根据权重(也就是注册中心配置的weight属性)进行轮询 + + Invoker balancedInvoker = select(loadbalance, invocation, invokers, null); + + if (balancedInvoker.isAvailable()) { + + return balancedInvoker.invoke(invocation); + + } + + // 选择第一个可用的 Provider 节点 + + for (Invoker invoker : invokers) { + + MockClusterInvoker mockClusterInvoker = (MockClusterInvoker) invoker; + + if (mockClusterInvoker.isAvailable()) { + + return mockClusterInvoker.invoke(invocation); + + } + + } + + throw new RpcException("No provider available in " + invokers); + +} + + +总结 + +本课时我们重点介绍了 Dubbo 中 Cluster 接口的各个实现类的原理以及相关 Invoker 的实现原理。这里重点分析的 Cluster 实现有:Failover Cluster、Failback Cluster、Failfast Cluster、Failsafe Cluster、Forking Cluster、Broadcast Cluster、Available Cluster 和 Mergeable Cluster。除此之外,我们还分析了多注册中心的 ZoneAware Cluster 实现。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/39加餐:多个返回值不用怕,Merger合并器来帮忙.md b/专栏/Dubbo源码解读与实战-完/39加餐:多个返回值不用怕,Merger合并器来帮忙.md new file mode 100644 index 0000000..a71c53f --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/39加餐:多个返回值不用怕,Merger合并器来帮忙.md @@ -0,0 +1,401 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 39 加餐:多个返回值不用怕,Merger 合并器来帮忙 + 你好,我是杨四正,今天我和你分享的主题是 Merger 合并器。 + +在上一课时中,我们分析 MergeableClusterInvoker 的具体实现时讲解过这样的内容:MergeableClusterInvoker 中会读取 URL 中的 merger 参数值,如果 merger 参数以 “.” 开头,则表示 “.” 后的内容是一个方法名,这个方法名是远程目标方法的返回类型中的一个方法,MergeableClusterInvoker 在拿到所有 Invoker 返回的结果对象之后,会遍历每个返回结果,并调用 merger 参数指定的方法,合并这些结果值。 + +其实,除了上述指定 Merger 方法名称的合并方式之外,Dubbo 内部还提供了很多默认的 Merger 实现,这也就是本课时将要分析的内容。本课时将详细介绍 MergerFactory 工厂类、Merger 接口以及针对 Java 中常见数据类型的 Merger 实现。 + +MergerFactory + +在 MergeableClusterInvoker 使用默认 Merger 实现的时候,会通过 MergerFactory 以及服务接口返回值类型(returnType),选择合适的 Merger 实现。 + +在 MergerFactory 中维护了一个 ConcurrentHashMap 集合(即 MERGER_CACHE 字段),用来缓存服务接口返回值类型与 Merger 实例之间的映射关系。 + +MergerFactory.getMerger() 方法会根据传入的 returnType 类型,从 MERGER_CACHE 缓存中查找相应的 Merger 实现,下面我们来看该方法的具体实现: + +public static Merger getMerger(Class returnType) { + + if (returnType == null) { // returnType为空,直接抛出异常 + + throw new IllegalArgumentException("returnType is null"); + + } + + Merger result; + + if (returnType.isArray()) { // returnType为数组类型 + + // 获取数组中元素的类型 + + Class type = returnType.getComponentType(); + + // 获取元素类型对应的Merger实现 + + result = MERGER_CACHE.get(type); + + if (result == null) { + + loadMergers(); + + result = MERGER_CACHE.get(type); + + } + + // 如果Dubbo没有提供元素类型对应的Merger实现,则返回ArrayMerger + + if (result == null && !type.isPrimitive()) { + + result = ArrayMerger.INSTANCE; + + } + + } else { + + // 如果returnType不是数组类型,则直接从MERGER_CACHE缓存查找对应的Merger实例 + + result = MERGER_CACHE.get(returnType); + + if (result == null) { + + loadMergers(); + + result = MERGER_CACHE.get(returnType); + + } + + } + + return result; + +} + + +loadMergers() 方法会通过 Dubbo SPI 方式加载 Merger 接口全部扩展实现的名称,并填充到 MERGER_CACHE 集合中,具体实现如下: + +static void loadMergers() { + + // 获取Merger接口的所有扩展名称 + + Set names = ExtensionLoader.getExtensionLoader(Merger.class) + + .getSupportedExtensions(); + + for (String name : names) { // 遍历所有Merger扩展实现 + + Merger m = ExtensionLoader.getExtensionLoader(Merger.class).getExtension(name); + + // 将Merger实例与对应returnType的映射关系记录到MERGER_CACHE集合中 + + MERGER_CACHE.putIfAbsent(ReflectUtils.getGenericClass(m.getClass()), m); + + } + +} + + +ArrayMerger + +在 Dubbo 中提供了处理不同类型返回值的 Merger 实现,其中不仅有处理 boolean[]、byte[]、char[]、double[]、float[]、int[]、long[]、short[] 等基础类型数组的 Merger 实现,还有处理 List、Set、Map 等集合类的 Merger 实现,具体继承关系如下图所示: + + + +Merger 继承关系图 + +我们首先来看 ArrayMerger 实现:当服务接口的返回值为数组的时候,会使用 ArrayMerger 将多个数组合并成一个数组,也就是将二维数组拍平成一维数组。ArrayMerger.merge() 方法的具体实现如下: + +public Object[] merge(Object[]... items) { + + if (ArrayUtils.isEmpty(items)) { + + // 传入的结果集合为空,则直接返回空数组 + + return new Object[0]; + + } + + int i = 0; + + // 查找第一个不为null的结果 + + while (i < items.length && items[i] == null) { + + i++; + + } + + // 所有items数组中全部结果都为null,则直接返回空数组 + + if (i == items.length) { + + return new Object[0]; + + } + + Class type = items[i].getClass().getComponentType(); + + int totalLen = 0; + + for (; i < items.length; i++) { + + if (items[i] == null) { // 忽略为null的结果 + + continue; + + } + + Class itemType = items[i].getClass().getComponentType(); + + if (itemType != type) { // 保证类型相同 + + throw new IllegalArgumentException("Arguments' types are different"); + + } + + totalLen += items[i].length; + + } + + if (totalLen == 0) { // 确定最终数组的长度 + + return new Object[0]; + + } + + Object result = Array.newInstance(type, totalLen); + + int index = 0; + + // 遍历全部的结果数组,将items二维数组中的每个元素都加到result中,形成一维数组 + + for (Object[] array : items) { + + if (array != null) { + + for (int j = 0; j < array.length; j++) { + + Array.set(result, index++, array[j]); + + } + + } + + } + + return (Object[]) result; + +} + + +其他基础数据类型数组的 Merger 实现,与 ArrayMerger 的实现非常类似,都是将相应类型的二维数组拍平成同类型的一维数组,这里以 IntArrayMerger 为例进行分析: + +public int[] merge(int[]... items) { + + if (ArrayUtils.isEmpty(items)) { + + // 检测传入的多个int[]不能为空 + + return new int[0]; + + } + + // 直接使用Stream的API将多个int[]数组拍平成一个int[]数组 + + return Arrays.stream(items).filter(Objects::nonNull) + + .flatMapToInt(Arrays::stream) + + .toArray(); + +} + + +剩余的其他基础类型的 Merger 实现类,例如,FloatArrayMerger、IntArrayMerger、LongArrayMerger、BooleanArrayMerger、ByteArrayMerger、CharArrayMerger、DoubleArrayMerger 等,这里就不再赘述,你若感兴趣的话可以参考源码进行学习。 + +MapMerger + +SetMerger、ListMerger 和 MapMerger 是针对 Set 、List 和 Map 返回值的 Merger 实现,它们会将多个 Set(或 List、Map)集合合并成一个 Set(或 List、Map)集合,核心原理与 ArrayMerger 的实现类似。这里我们先来看 MapMerger 的核心实现: + +public Map merge(Map... items) { + + if (ArrayUtils.isEmpty(items)) { + + // 空结果集时,这就返回空Map + + return Collections.emptyMap(); + + } + + // 将items中所有Map集合中的KV,添加到result这一个Map集合中 + + Map result = new HashMap(); + + Stream.of(items).filter(Objects::nonNull).forEach(result::putAll); + + return result; + +} + + +接下来再看 SetMerger 和 ListMerger 的核心实现: + +public Set merge(Set... items) { + + if (ArrayUtils.isEmpty(items)) { + + // 空结果集时,这就返回空Set集合 + + return Collections.emptySet(); + + } + + // 创建一个新的HashSet集合,传入的所有Set集合都添加到result中 + + Set result = new HashSet(); + + Stream.of(items).filter(Objects::nonNull).forEach(result::addAll); + + return result; + +} + +public List merge(List... items) { + + if (ArrayUtils.isEmpty(items)) { + + // 空结果集时,这就返回空Set集合 + + return Collections.emptyList(); + + } + + // 通过Stream API将传入的所有List集合拍平成一个List集合并返回 + + return Stream.of(items).filter(Objects::nonNull) + + .flatMap(Collection::stream) + + .collect(Collectors.toList()); + +} + + +自定义 Merger 扩展实现 + +介绍完 Dubbo 自带的 Merger 实现之后,下面我们还可以尝试动手写一个自己的 Merger 实现,这里我们以 dubbo-demo-xml 中的 Provider 和 Consumer 为例进行修改。 + +首先我们在 dubbo-demo-xml-provider 示例模块中发布两个服务,分别属于 groupA 和 groupB,相应的 dubbo-provider.xml 配置如下: + + + + + + + + + + + + + + + + + + + + + + + + + + +接下来,在 dubbo-demo-xml-consumer 示例模块中进行服务引用,dubbo-consumer.xml 配置文件的具体内容如下: + + + + + + + + + + + + + + +然后,在 dubbo-demo-xml-consumer 示例模块的 /resources/META-INF/dubbo 目录下,添加一个名为 org.apache.dubbo.rpc.cluster.Merger 的 Dubbo SPI 配置文件,其内容如下: + +String=org.apache.dubbo.demo.consumer.StringMerger + + +StringMerger 实现了前面介绍的 Merger 接口,它会将多个 Provider 节点返回的 String 结果值拼接起来,具体实现如下: + +public class StringMerger implements Merger { + + @Override + + public String merge(String... items) { + + if (ArrayUtils.isEmpty(items)) { // 检测空返回值 + + return ""; + + } + + String result = ""; + + for (String item : items) { // 通过竖线将多个Provider的返回值拼接起来 + + result += item + "|"; + + } + + return result; + + } + +} + + +最后,我们依次启动 Zookeeper、dubbo-demo-xml-provider 示例模块和 dubbo-demo-xml-consumer 示例模块。在控制台中我们会看到如下输出: + +result: Hello world, response from provider: 172.17.108.179:20880|Hello world, response from provider: 172.17.108.179:20880| + + +总结 + +本课时我们重点介绍了 MergeableCluster 中涉及的 Merger 合并器相关的知识点。 + + +首先,我们介绍了 MergerFactory 工厂类的核心功能,它可以配合远程方法调用的返回值,选择对应的 Merger 实现,完成结果的合并。 +然后,我们深入分析了 Dubbo 自带的 Merger 实现类,涉及 Java 中各个基础类型数组的 Merger 合并器实现,例如,IntArrayMerger、LongArrayMerger 等,它们都是将多个特定类型的一维数组拍平成相同类型的一维数组。 +除了这些基础类型数组的 Merger 实现,Dubbo 还提供了 List、Set、Map 等集合类的 Merger 实现,它们的核心是将多个集合中的元素整理到一个同类型的集合中。 +最后,我们还以 StringMerger 为例,介绍了如何自定义 Merger 合并器。 + + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/40加餐:模拟远程调用,Mock机制帮你搞定.md b/专栏/Dubbo源码解读与实战-完/40加餐:模拟远程调用,Mock机制帮你搞定.md new file mode 100644 index 0000000..dc90e17 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/40加餐:模拟远程调用,Mock机制帮你搞定.md @@ -0,0 +1,447 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 40 加餐:模拟远程调用,Mock 机制帮你搞定 + 你好,我是杨四正,今天我和你分享的主题是:Dubbo 中的 Mock 机制。 + +Mock 机制是 RPC 框架中非常常见、也非常有用的功能,不仅可以用来实现服务降级,还可以用来在测试中模拟调用的各种异常情况。Dubbo 中的 Mock 机制是在 Consumer 这一端实现的,具体来说就是在 Cluster 这一层实现的。 + +在前面第 38 课时中,我们深入介绍了 Dubbo 提供的多种 Cluster 实现以及相关的 Cluster Invoker 实现,其中的 ZoneAwareClusterInvoker 就涉及了 MockClusterInvoker 的相关内容。本课时我们就来介绍 Dubbo 中 Mock 机制的全链路流程,不仅包括与 Cluster 接口相关的 MockClusterWrapper 和 MockClusterInvoker,我们还会回顾前面课程的 Router 和 Protocol 接口,分析它们与 Mock 机制相关的实现。 + +MockClusterWrapper + +Cluster 接口有两条继承线(如下图所示):一条线是 AbstractCluster 抽象类,这条继承线涉及的全部 Cluster 实现类我们已经在[第 37 课时]中深入分析过了;另一条线是 MockClusterWrapper 这条线。 + + + +Cluster 继承关系图 + +MockClusterWrapper 是 Cluster 对象的包装类,我们在之前[第 4 课时]介绍 Dubbo SPI 机制时已经分析过 Wrapper 的功能,MockClusterWrapper 类会对 Cluster 进行包装。下面是 MockClusterWrapper 的具体实现,其中会在 Cluster Invoker 对象的基础上使用 MockClusterInvoker 进行包装: + +public class MockClusterWrapper implements Cluster { + + private Cluster cluster; + + // Wrapper类都会有一个拷贝构造函数 + + public MockClusterWrapper(Cluster cluster) { + + this.cluster = cluster; + + } + + @Override + + public Invoker join(Directory directory) throws RpcException { + + // 用MockClusterInvoker进行包装 + + return new MockClusterInvoker(directory, + + this.cluster.join(directory)); + + } + +} + + +MockClusterInvoker + +MockClusterInvoker 是 Dubbo Mock 机制的核心,它主要是通过 invoke()、doMockInvoke() 和 selectMockInvoker() 这三个核心方法来实现 Mock 机制的。 + +下面我们就来逐个介绍这三个方法的具体实现。 + +首先来看 MockClusterInvoker 的 invoke() 方法,它会先判断是否需要开启 Mock 机制。如果在 mock 参数中配置的是 force 模式,则会直接调用 doMockInvoke() 方法进行 mock。如果在 mock 参数中配置的是 fail 模式,则会正常调用 Invoker 发起请求,在请求失败的时候,会调动 doMockInvoke() 方法进行 mock。下面是 MockClusterInvoker 的 invoke() 方法的具体实现: + +public Result invoke(Invocation invocation) throws RpcException { + + Result result = null; + + // 从URL中获取方法对应的mock配置 + + String value = getUrl().getMethodParameter(invocation.getMethodName(), MOCK_KEY, Boolean.FALSE.toString()).trim(); + + if (value.length() == 0 || "false".equalsIgnoreCase(value)) { + + // 若mock参数未配置或是配置为false,则不会开启Mock机制,直接调用底层的Invoker + + result = this.invoker.invoke(invocation); + + } else if (value.startsWith("force")) { + + //force:direct mock + + // 若mock参数配置为force,则表示强制mock,直接调用doMockInvoke()方法 + + result = doMockInvoke(invocation, null); + + } else { + + // 如果mock配置的不是force,那配置的就是fail,会继续调用Invoker对象的invoke()方法进行请求 + + try { + + result = this.invoker.invoke(invocation); + + } catch (RpcException e) { + + if (e.isBiz()) { // 如果是业务异常,会直接抛出 + + throw e; + + } + + // 如果是非业务异常,会调用doMockInvoke()方法返回mock结果 + + result = doMockInvoke(invocation, e); + + } + + } + + return result; + +} + + +在 doMockInvoke() 方法中,首先调用 selectMockInvoker() 方法获取 MockInvoker 对象,并调用其 invoke() 方法进行 mock 操作。doMockInvoke() 方法的具体实现如下: + +private Result doMockInvoke(Invocation invocation, RpcException e) { + + Result result = null; + + Invoker minvoker; + + // 调用selectMockInvoker()方法过滤得到MockInvoker + + List> mockInvokers = selectMockInvoker(invocation); + + if (CollectionUtils.isEmpty(mockInvokers)) { + + // 如果selectMockInvoker()方法未返回MockInvoker对象,则创建一个MockInvoker + + minvoker = (Invoker) new MockInvoker(getUrl(), directory.getInterface()); + + } else { + + minvoker = mockInvokers.get(0); + + } + + try { + + // 调用MockInvoker.invoke()方法进行mock + + result = minvoker.invoke(invocation); + + } catch (RpcException me) { + + if (me.isBiz()) { // 如果是业务异常,则在Result中设置该异常 + + result = AsyncRpcResult.newDefaultAsyncResult(me.getCause(), invocation); + + } else { + + throw new RpcException(...); + + } + + } catch (Throwable me) { + + throw new RpcException(...); + + } + + return result; + +} + + +selectMockInvoker() 方法中并没有进行 MockInvoker 的选择或是创建,它仅仅是将 Invocation 附属信息中的 invocation.need.mock 属性设置为 true,然后交给 Directory 中的 Router 集合进行处理。selectMockInvoker() 方法的具体实现如下: + +private List> selectMockInvoker(Invocation invocation) { + + List> invokers = null; + + if (invocation instanceof RpcInvocation) { + + // 将Invocation附属信息中的invocation.need.mock属性设置为true + + ((RpcInvocation) invocation).setAttachment(INVOCATION_NEED_MOCK, Boolean.TRUE.toString()); + + invokers = directory.list(invocation); + + } + + return invokers; + +} + + +MockInvokersSelector + +在[第 32 课时]和[第 33 课时]中,我们介绍了 Router 接口多个实现类,但当时并没有深入介绍 Mock 相关的 Router 实现类—— MockInvokersSelector,它的继承关系如下图所示: + + + +MockInvokersSelector 继承关系图 + +MockInvokersSelector 是 Dubbo Mock 机制相关的 Router 实现,在未开启 Mock 机制的时候,会返回正常的 Invoker 对象集合;在开启 Mock 机制之后,会返回 MockInvoker 对象集合。MockInvokersSelector 的具体实现如下: + +public List> route(final List> invokers, + + URL url, final Invocation invocation) throws RpcException { + + if (CollectionUtils.isEmpty(invokers)) { + + return invokers; + + } + + if (invocation.getObjectAttachments() == null) { + + // attachments为null,会过滤掉MockInvoker,只返回正常的Invoker对象 + + return getNormalInvokers(invokers); + + } else { + + String value = (String) invocation.getObjectAttachments().get(INVOCATION_NEED_MOCK); + + if (value == null) { + + // invocation.need.mock为null,会过滤掉MockInvoker,只返回正常的Invoker对象 + + return getNormalInvokers(invokers); + + } else if (Boolean.TRUE.toString().equalsIgnoreCase(value)) { + + // invocation.need.mock为true,会过滤掉MockInvoker,只返回正常的Invoker对象 + + return getMockedInvokers(invokers); + + } + + } + + // invocation.need.mock为false,则会将MockInvoker和正常的Invoker一起返回 + + return invokers; + +} + + +在 getMockedInvokers() 方法中,会根据 URL 的 Protocol 进行过滤,只返回 Protocol 为 mock 的 Invoker 对象,而 getNormalInvokers() 方法只会返回 Protocol 不为 mock 的 Invoker 对象。这两个方法的具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +MockProtocol & MockInvoker + +介绍完 Mock 功能在 Cluster 层的相关实现之后,我们还要来看一下 Dubbo 在 RPC 层对 Mock 机制的支持,这里涉及 MockProtocol 和 MockInvoker 两个类。 + +首先来看 MockProtocol,它是 Protocol 接口的扩展实现,扩展名称为 mock。MockProtocol 只能通过 refer() 方法创建 MockInvoker,不能通过 export() 方法暴露服务,具体实现如下: + +final public class MockProtocol extends AbstractProtocol { + + public int getDefaultPort() { return 0;} + + public Exporter export(Invoker invoker) throws RpcException { + + // 直接抛出异常,无法暴露服务 + + throw new UnsupportedOperationException(); + + } + + public Invoker protocolBindingRefer(Class type, URL url) throws RpcException { + + // 直接创建MockInvoker对象 + + return new MockInvoker<>(url, type); + + } + +} + + +下面我们再来看 MockInvoker 是如何解析各类 mock 配置的,以及如何根据不同 mock 配置进行不同处理的。这里我们重点来看 MockInvoker.invoke() 方法,其中针对 mock 参数进行的分类处理具体有下面三条分支。 + + +mock 参数以 return 开头:直接返回 mock 参数指定的固定值,例如,empty、null、true、false、json 等。mock 参数中指定的固定返回值将会由 parseMockValue() 方法进行解析。 +mock 参数以 throw 开头:直接抛出异常。如果在 mock 参数中没有指定异常类型,则抛出 RpcException,否则抛出指定的 Exception 类型。 +mock 参数为 true 或 default 时,会查找服务接口对应的 Mock 实现;如果是其他值,则直接作为服务接口的 Mock 实现。拿到 Mock 实现之后,转换成 Invoker 进行调用。 + + +MockInvoker.invoke() 方法的具体实现如下所示: + +public Result invoke(Invocation invocation) throws RpcException { + + if (invocation instanceof RpcInvocation) { + + ((RpcInvocation) invocation).setInvoker(this); + + } + + // 获取mock值(会从URL中的methodName.mock参数或mock参数获取) + + String mock = null; + + if (getUrl().hasMethodParameter(invocation.getMethodName())) { + + mock = getUrl().getParameter(invocation.getMethodName() + "." + MOCK_KEY); + + } + + if (StringUtils.isBlank(mock)) { + + mock = getUrl().getParameter(MOCK_KEY); + + } + + if (StringUtils.isBlank(mock)) { // 没有配置mock值,直接抛出异常 + + throw new RpcException(new IllegalAccessException("mock can not be null. url :" + url)); + + } + + // mock值进行处理,去除"force:"、"fail:"前缀等 + + mock = normalizeMock(URL.decode(mock)); + + if (mock.startsWith(RETURN_PREFIX)) { // mock值以return开头 + + mock = mock.substring(RETURN_PREFIX.length()).trim(); + + try { + + // 获取响应结果的类型 + + Type[] returnTypes = RpcUtils.getReturnTypes(invocation); + + // 根据结果类型,对mock值中结果值进行转换 + + Object value = parseMockValue(mock, returnTypes); + + // 将固定的mock值设置到Result中 + + return AsyncRpcResult.newDefaultAsyncResult(value, invocation); + + } catch (Exception ew) { + + throw new RpcException("mock return invoke error. method :" + invocation.getMethodName() + + + ", mock:" + mock + ", url: " + url, ew); + + } + + } else if (mock.startsWith(THROW_PREFIX)) { // mock值以throw开头 + + mock = mock.substring(THROW_PREFIX.length()).trim(); + + if (StringUtils.isBlank(mock)) { // 未指定异常类型,直接抛出RpcException + + throw new RpcException("mocked exception for service degradation."); + + } else { // 抛出自定义异常 + + Throwable t = getThrowable(mock); + + throw new RpcException(RpcException.BIZ_EXCEPTION, t); + + } + + } else { // 执行mockService得到mock结果 + + try { + + Invoker invoker = getInvoker(mock); + + return invoker.invoke(invocation); + + } catch (Throwable t) { + + throw new RpcException("Failed to create mock implementation class " + mock, t); + + } + + } + +} + + +针对 return 和 throw 的处理逻辑比较简单,但 getInvoker() 方法略微复杂些,其中会处理 MOCK_MAP 缓存的读写、Mock 实现类的查找、生成和调用 Invoker,具体实现如下: + +private Invoker getInvoker(String mockService) { + + // 尝试从MOCK_MAP集合中获取对应的Invoker对象 + + Invoker invoker = (Invoker) MOCK_MAP.get(mockService); + + if (invoker != null) { + + return invoker; + + } + + // 根据serviceType查找mock的实现类 + + Class serviceType = (Class) ReflectUtils.forName(url.getServiceInterface()); + + T mockObject = (T) getMockObject(mockService, serviceType); + + // 创建Invoker对象 + + invoker = PROXY_FACTORY.getInvoker(mockObject, serviceType, url); + + if (MOCK_MAP.size() < 10000) { // 写入缓存 + + MOCK_MAP.put(mockService, invoker); + + } + + return invoker; + +} + + +在 getMockObject() 方法中会检查 mockService 参数是否为 true 或 default,如果是的话,则在服务接口后添加 Mock 字符串,作为服务接口的 Mock 实现;如果不是的话,则直接将 mockService 实现作为服务接口的 Mock 实现。getMockObject() 方法的具体实现如下: + +public static Object getMockObject(String mockService, Class serviceType) { + + if (ConfigUtils.isDefault(mockService)) { + + // 如果mock为true或default值,会在服务接口后添加Mock字符串,得到对应的实现类名称,并进行实例化 + + mockService = serviceType.getName() + "Mock"; + + } + + Class mockClass = ReflectUtils.forName(mockService); + + if (!serviceType.isAssignableFrom(mockClass)) { + + // 检查mockClass是否继承serviceType接口 + + throw new IllegalStateException("..."); + + } + + return mockClass.newInstance(); + +} + + +总结 + +本课时我们重点介绍了 Dubbo 中 Mock 机制涉及的全部内容。 + + +首先,我们介绍了 Cluster 接口的 MockClusterWrapper 实现类,它负责创建 MockClusterInvoker 对象,是 Dubbo Mock 机制的入口。 +接下来,我们介绍了 MockClusterInvoker 这个 Cluster 层的 Invoker 实现,它是 Dubbo Mock 机制的核心,会根据配置决定请求是否启动了 Mock 机制以及在何种情况下才会触发 Mock。 +随后,我们又讲解了 MockInvokersSelector 这个 Router 接口实现,它会在路由规则这个层面决定是否返回 MockInvoker 对象。 +最后,我们分析了 Protocol 层与 Mock 相关的实现—— MockProtocol,以及 MockInvoker 这个真正进行 Mock 操作的 Invoker 实现。在 MockInvoker 中会解析各类 Mock 配置,并根据不同 Mock 配置进行不同的 Mock 操作。 + + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/41加餐:一键通关服务发布全流程.md b/专栏/Dubbo源码解读与实战-完/41加餐:一键通关服务发布全流程.md new file mode 100644 index 0000000..9ace59e --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/41加餐:一键通关服务发布全流程.md @@ -0,0 +1,746 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 41 加餐:一键通关服务发布全流程 + 在前面的课时中,我们已经将整个 Dubbo 的核心实现进行了分析。接下来的两个课时,我们将串联 Dubbo 中的这些核心实现,分析 Dubbo服务发布和服务引用的全流程,帮助你将之前课时介绍的独立知识点联系起来,形成一个完整整体。 + +本课时我们就先来重点关注 Provider 节点发布服务的过程,在这个过程中会使用到之前介绍的很多 Dubbo 核心组件。我们从 DubboBootstrap 这个入口类开始介绍,分析 Provider URL 的组装以及服务发布流程,其中会详细介绍本地发布和远程发布的核心流程。 + +DubboBootstrap 入口 + +在[第 01 课时]dubbo-demo-api-provider 示例的 Provider 实现中我们可以看到,整个 Provider 节点的启动入口是 DubboBootstrap.start() 方法,在该方法中会执行一些初始化操作,以及一些状态控制字段的更新,具体实现如下: + +public DubboBootstrap start() { + + if (started.compareAndSet(false, true)) { // CAS操作,保证启动一次 + + ready.set(false); // 用于判断当前节点是否已经启动完毕,在后面的Dubbo QoS中会使用到该字段 + + // 初始化一些基础组件,例如,配置中心相关组件、事件监听、元数据相关组件,这些组件在后面将会进行介绍 + + initialize(); + + // 重点:发布服务 + + exportServices(); + + if (!isOnlyRegisterProvider() || hasExportedServices()) { + + // 用于暴露本地元数据服务,后面介绍元数据的时候会深入介绍该部分的内容 + + exportMetadataService(); + + // 用于将服务实例注册到专用于服务发现的注册中心 + + registerServiceInstance(); + + } + + // 处理Consumer的ReferenceConfig + + referServices(); + + if (asyncExportingFutures.size() > 0) { + + // 异步发布服务,会启动一个线程监听发布是否完成,完成之后会将ready设置为true + + new Thread(() -> { + + this.awaitFinish(); + + ready.set(true); + + }).start(); + + } else { // 同步发布服务成功之后,会将ready设置为true + + ready.set(true); + + } + + } + + return this; + +} + + +不仅是直接通过 API 启动 Provider 的方式会使用到 DubboBootstrap,在 Spring 与 Dubbo 集成的时候也是使用 DubboBootstrap 作为服务发布入口的,具体逻辑在 DubboBootstrapApplicationListener 这个 Spring Context 监听器中,如下所示: + +public class DubboBootstrapApplicationListener extends OneTimeExecutionApplicationContextEventListener + + implements Ordered { + + private final DubboBootstrap dubboBootstrap; + + public DubboBootstrapApplicationListener() { + + // 初始化DubboBootstrap对象 + + this.dubboBootstrap = DubboBootstrap.getInstance(); + + } + + @Override + + public void onApplicationContextEvent(ApplicationContextEvent event) { + + // 监听ContextRefreshedEvent事件和ContextClosedEvent事件 + + if (event instanceof ContextRefreshedEvent) { + + onContextRefreshedEvent((ContextRefreshedEvent) event); + + } else if (event instanceof ContextClosedEvent) { + + onContextClosedEvent((ContextClosedEvent) event); + + } + + } + + private void onContextRefreshedEvent(ContextRefreshedEvent event) { + + dubboBootstrap.start(); // 启动DubboBootstrap + + } + + private void onContextClosedEvent(ContextClosedEvent event) { + + dubboBootstrap.stop(); + + } + + @Override + + public int getOrder() { + + return LOWEST_PRECEDENCE; + + } + +} + + +这里我们重点关注的是exportServices() 方法,它是服务发布核心逻辑的入口,其中每一个服务接口都会转换为对应的 ServiceConfig 实例,然后通过代理的方式转换成 Invoker,最终转换成 Exporter 进行发布。服务发布流程中涉及的核心对象转换,如下图所示: + + + +服务发布核心流程图 + +exportServices() 方法的具体实现如下: + +private void exportServices() { + + // 从配置管理器中获取到所有的要暴露的服务配置,一个接口类对应一个ServiceConfigBase实例 + + configManager.getServices().forEach(sc -> { + + ServiceConfig serviceConfig = (ServiceConfig) sc; + + serviceConfig.setBootstrap(this); + + if (exportAsync) { // 异步模式,获取一个线程池来异步执行服务发布逻辑 + + ExecutorService executor = executorRepository.getServiceExporterExecutor(); + + Future future = executor.submit(() -> { + + sc.export(); + + exportedServices.add(sc); + + }); + + // 记录异步发布的Future + + asyncExportingFutures.add(future); + + } else {// 同步发布 + + sc.export(); + + exportedServices.add(sc); + + } + + }); + +} + + +ServiceConfig + +在 ServiceConfig.export() 方法中,服务发布的第一步是检查参数,第二步会根据当前配置决定是延迟发布还是立即调用 doExport() 方法进行发布,第三步会通过 exported() 方法回调相关监听器,具体实现如下: + +public synchronized void export() { + + if (!shouldExport()) { + + return; + + } + + if (bootstrap == null) { + + bootstrap = DubboBootstrap.getInstance(); + + bootstrap.init(); + + } + + // 检查并更新各项配置 + + checkAndUpdateSubConfigs(); + + ... // 初始化元数据相关服务 + + if (shouldDelay()) { // 延迟发布 + + DELAY_EXPORT_EXECUTOR.schedule(this::doExport, getDelay(), TimeUnit.MILLISECONDS); + + } else { // 立即发布 + + doExport(); + + } + + exported(); // 回调监听器 + +} + + +在 checkAndUpdateSubConfigs() 方法中,会去检查各项配置是否合理,并补齐一些缺省的配置信息,这个方法非常冗长,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +完成配置的检查之后,再来看 doExport() 方法,其中首先调用 loadRegistries() 方法加载注册中心信息,即将 RegistryConfig 配置解析成 registryUrl。无论是使用 XML、Annotation,还是 API 配置方式,都可以配置多个注册中心地址,一个服务接口可以同时注册在多个不同的注册中心。 + +RegistryConfig 是 Dubbo 的多个配置对象之一,可以通过解析 XML、Annotation 中注册中心相关的配置得到,对应的配置如下(当然,也可以直接通过 API 创建得到): + + + + +RegistryUrl 的格式大致如下(为了方便查看,这里将每个 URL 参数单独放在一行中展示): + +// path是Zookeeper的地址 + +registry://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService? + +application=dubbo-demo-api-provider + +&dubbo=2.0.2 + +&pid=9405 + +®istry=zookeeper // 使用的注册中心是Zookeeper + +×tamp=1600307343086 + + +加载注册中心信息得到 RegistryUrl 之后,会遍历所有的 ProtocolConfig,依次调用 doExportUrlsFor1Protocol(protocolConfig, registryURLs) 在每个注册中心发布服务。一个服务接口可以以多种协议进行发布,每种协议都对应一个 ProtocolConfig,例如我们在 Demo 示例中,只使用了 dubbo 协议,对应的配置是:。 + +组装服务 URL + +doExportUrlsFor1Protocol() 方法的代码非常长,这里我们分成两个部分进行介绍:一部分是组装服务的 URL,另一部分就是后面紧接着介绍的服务发布。 + +组装服务的 URL核心步骤有如下 7 步。 + + +获取此次发布使用的协议,默认使用 dubbo 协议。 +设置服务 URL 中的参数,这里会从 MetricsConfig、ApplicationConfig、ModuleConfig、ProviderConfig、ProtocolConfig 中获取配置信息,并作为参数添加到 URL 中。这里调用的 appendParameters() 方法会将 AbstractConfig 中的配置信息存储到 Map 集合中,后续在构造 URL 的时候,会将该集合中的 KV 作为 URL 的参数。 +解析指定方法的 MethodConfig 配置以及方法参数的 ArgumentConfig 配置,得到的配置信息也是记录到 Map 集合中,后续作为 URL 参数。 +根据此次调用是泛化调用还是普通调用,向 Map 集合中添加不同的键值对。 +获取 token 配置,并添加到 Map 集合中,默认随机生成 UUID。 +获取 host、port 值,并开始组装服务的 URL。 +根据 Configurator 覆盖或新增 URL 参数。 + + +下面是 doExportUrlsFor1Protocol() 方法组装 URL 的核心实现: + +private void doExportUrlsFor1Protocol(ProtocolConfig protocolConfig, List registryURLs) { + + String name = protocolConfig.getName(); // 获取协议名称 + + if (StringUtils.isEmpty(name)) { // 默认使用Dubbo协议 + + name = DUBBO; + + } + + Map map = new HashMap(); // 记录URL的参数 + + map.put(SIDE_KEY, PROVIDER_SIDE); // side参数 + + // 添加URL参数,例如Dubbo版本、时间戳、当前PID等 + + ServiceConfig.appendRuntimeParameters(map); + + // 下面会从各个Config获取参数,例如,application、interface参数等 + + AbstractConfig.appendParameters(map, getMetrics()); + + AbstractConfig.appendParameters(map, getApplication()); + + AbstractConfig.appendParameters(map, getModule()); + + AbstractConfig.appendParameters(map, provider); + + AbstractConfig.appendParameters(map, protocolConfig); + + AbstractConfig.appendParameters(map, this); + + MetadataReportConfig metadataReportConfig = getMetadataReportConfig(); + + if (metadataReportConfig != null && metadataReportConfig.isValid()) { + + map.putIfAbsent(METADATA_KEY, REMOTE_METADATA_STORAGE_TYPE); + + } + + if (CollectionUtils.isNotEmpty(getMethods())) { // 从MethodConfig中获取URL参数 + + for (MethodConfig method : getMethods()) { + + AbstractConfig.appendParameters(map, method, method.getName()); + + String retryKey = method.getName() + ".retry"; + + if (map.containsKey(retryKey)) { + + String retryValue = map.remove(retryKey); + + if ("false".equals(retryValue)) { + + map.put(method.getName() + ".retries", "0"); + + } + + } + + List arguments = method.getArguments(); + + if (CollectionUtils.isNotEmpty(arguments)) { + + for (ArgumentConfig argument : arguments) { // 从ArgumentConfig中获取URL参数 + + ... ... + + } + + } + + } + + } + + if (ProtocolUtils.isGeneric(generic)) { // 根据generic是否为true,向map中添加不同的信息 + + map.put(GENERIC_KEY, generic); + + map.put(METHODS_KEY, ANY_VALUE); + + } else { + + String revision = Version.getVersion(interfaceClass, version); + + if (revision != null && revision.length() > 0) { + + map.put(REVISION_KEY, revision); + + } + + String[] methods = Wrapper.getWrapper(interfaceClass).getMethodNames(); + + if (methods.length == 0) { + + map.put(METHODS_KEY, ANY_VALUE); + + } else { + + // 添加method参数 + + map.put(METHODS_KEY, StringUtils.join(new HashSet(Arrays.asList(methods)), ",")); + + } + + } + + // 添加token到map集合中,默认随机生成UUID + + if(ConfigUtils.isEmpty(token) && provider != null) { + + token = provider.getToken(); + + } + + if (!ConfigUtils.isEmpty(token)) { + + if (ConfigUtils.isDefault(token)) { + + map.put(TOKEN_KEY, UUID.randomUUID().toString()); + + } else { + + map.put(TOKEN_KEY, token); + + } + + } + + // 将map数据放入serviceMetadata中,这与元数据相关,后面再详细介绍其作用 + + serviceMetadata.getAttachments().putAll(map); + + // 获取host、port值 + + String host = findConfigedHosts(protocolConfig, registryURLs, map); + + Integer port = findConfigedPorts(protocolConfig, name, map); + + // 根据上面获取的host、port以及前文获取的map集合组装URL + + URL url = new URL(name, host, port, getContextPath(protocolConfig).map(p -> p + "/" + path).orElse(path), map); + + // 通过Configurator覆盖或添加新的参数 + + if (ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class) + + .hasExtension(url.getProtocol())) { + + url = ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class) + + .getExtension(url.getProtocol()).getConfigurator(url).configure(url); + + } + + ... ... + +} + + +经过上述准备操作之后,得到的服务 URL 如下所示(为了方便查看,这里将每个 URL 参数单独放在一行中展示): + +dubbo://172.17.108.185:20880/org.apache.dubbo.demo.DemoService? + +anyhost=true + +&application=dubbo-demo-api-provider + +&bind.ip=172.17.108.185 + +&bind.port=20880 + +&default=true + +&deprecated=false + +&dubbo=2.0.2 + +&dynamic=true + +&generic=false + +&interface=org.apache.dubbo.demo.DemoService + +&methods=sayHello,sayHelloAsync + +&pid=3918 + +&release= + +&side=provider + +×tamp=1600437404483 + + +服务发布入口 + +完成了服务 URL 的组装之后,doExportUrlsFor1Protocol() 方法开始执行服务发布。服务发布可以分为远程发布和本地发布,具体发布方式与服务 URL 中的 scope 参数有关。 + +scope 参数有三个可选值,分别是 none、remote 和 local,分别代表不发布、发布到本地和发布到远端注册中心,从下面介绍的 doExportUrlsFor1Protocol() 方法代码中可以看到: + + +发布到本地的条件是 scope != remote; +发布到注册中心的条件是 scope != local。 + + +scope 参数的默认值为 null,也就是说,默认会同时在本地和注册中心发布该服务。下面来看 doExportUrlsFor1Protocol() 方法中发布服务的具体实现: + +private void doExportUrlsFor1Protocol(ProtocolConfig protocolConfig, List registryURLs) { + + ... ...// 省略组装服务URL的过程 + + // 从URL中获取scope参数,其中可选值有none、remote、local三个, + + // 分别代表不发布、发布到本地以及发布到远端,具体含义在下面一一介绍 + + String scope = url.getParameter(SCOPE_KEY); + + if (!SCOPE_NONE.equalsIgnoreCase(scope)) { // scope不为none,才进行发布 + + if (!SCOPE_REMOTE.equalsIgnoreCase(scope)) {// 发布到本地 + + exportLocal(url); + + } + + if (!SCOPE_LOCAL.equalsIgnoreCase(scope)) { // 发布到远端的注册中心 + + if (CollectionUtils.isNotEmpty(registryURLs)) { // 当前配置了至少一个注册中心 + + for (URL registryURL : registryURLs) { // 向每个注册中心发布服务 + + // injvm协议只在exportLocal()中有用,不会将服务发布到注册中心 + + // 所以这里忽略injvm协议 + + if (LOCAL_PROTOCOL.equalsIgnoreCase(url.getProtocol())){ + + continue; + + } + + // 设置服务URL的dynamic参数 + + url = url.addParameterIfAbsent(DYNAMIC_KEY, registryURL.getParameter(DYNAMIC_KEY)); + + // 创建monitorUrl,并作为monitor参数添加到服务URL中 + + URL monitorUrl = ConfigValidationUtils.loadMonitor(this, registryURL); + + if (monitorUrl != null) { + + url = url.addParameterAndEncoded(MONITOR_KEY, monitorUrl.toFullString()); + + } + + // 设置服务URL的proxy参数,即生成动态代理方式(jdk或是javassist),作为参数添加到RegistryURL中 + + String proxy = url.getParameter(PROXY_KEY); + + if (StringUtils.isNotEmpty(proxy)) { + + registryURL = registryURL.addParameter(PROXY_KEY, proxy); + + } + + // 为服务实现类的对象创建相应的Invoker,getInvoker()方法的第三个参数中,会将服务URL作为export参数添加到RegistryURL中 + + // 这里的PROXY_FACTORY是ProxyFactory接口的适配器 + + Invoker invoker = PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, registryURL.addParameterAndEncoded(EXPORT_KEY, url.toFullString())); + + // DelegateProviderMetaDataInvoker是个装饰类,将当前ServiceConfig和Invoker关联起来而已,invoke()方法透传给底层Invoker对象 + + DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this); + + // 调用Protocol实现,进行发布 + + // 这里的PROTOCOL是Protocol接口的适配器 + + Exporter exporter = PROTOCOL.export(wrapperInvoker); + + exporters.add(exporter); + + } + + } else { + + // 不存在注册中心,仅发布服务,不会将服务信息发布到注册中心。Consumer没法在注册中心找到该服务的信息,但是可以直连 + + // 具体的发布过程与上面的过程类似,只不过不会发布到注册中心 + + Invoker invoker = PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, url); + + DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker, this); + + Exporter exporter = PROTOCOL.export(wrapperInvoker); + + exporters.add(exporter); + + } + + // 元数据相关操作 + + WritableMetadataService metadataService = WritableMetadataService.getExtension(url.getParameter(METADATA_KEY, DEFAULT_METADATA_STORAGE_TYPE)); + + if (metadataService != null) { + + metadataService.publishServiceDefinition(url); + + } + + } + + } + + this.urls.add(url); + +} + + +本地发布 + +了解了本地发布、远程发布的入口逻辑之后,下面我们开始深入本地发布的逻辑。 + +在 exportLocal() 方法中,会将 Protocol 替换成 injvm 协议,将 host 设置成 127.0.0.1,将 port 设置为 0,得到新的 LocalURL,大致如下: + +injvm://127.0.0.1/org.apache.dubbo.demo.DemoService?anyhost=true + +&application=dubbo-demo-api-provider + +&bind.ip=172.17.108.185 + +&bind.port=20880 + +&default=true + +&deprecated=false + +&dubbo=2.0.2 + +&dynamic=true + +&generic=false + +&interface=org.apache.dubbo.demo.DemoService + +&methods=sayHello,sayHelloAsync + +&pid=4249 + +&release= + +&side=provider + +×tamp=1600440074214 + + +之后,会通过 ProxyFactory 接口适配器找到对应的 ProxyFactory 实现(默认使用 JavassistProxyFactory),并调用 getInvoker() 方法创建 Invoker 对象;最后,通过 Protocol 接口的适配器查找到 InjvmProtocol 实现,并调用 export() 方法进行发布。 exportLocal() 方法的具体实现如下: + +private void exportLocal(URL url) { + + URL local = URLBuilder.from(url) // 创建新URL + + .setProtocol(LOCAL_PROTOCOL) + + .setHost(LOCALHOST_VALUE) + + .setPort(0) + + .build(); + + // 本地发布 + + Exporter exporter = PROTOCOL.export( + + PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, local)); + + exporters.add(exporter); + +} + + +InjvmProtocol 的相关实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +远程发布 + +介绍完本地发布之后,我们再来看远程发布的核心逻辑,远程服务发布的流程相较本地发布流程,要复杂得多。 + +在 doExportUrlsFor1Protocol() 方法中,远程发布服务时,会遍历全部 RegistryURL,并根据 RegistryURL 选择对应的 Protocol 扩展实现进行发布。我们知道 RegistryURL 是 “registry://” 协议,所以这里使用的是 RegistryProtocol 实现。 + +下面来看 RegistryProtocol.export() 方法的核心流程: + +public Exporter export(final Invoker originInvoker) throws RpcException { + + // 将"registry://"协议转换成"zookeeper://"协议 + + URL registryUrl = getRegistryUrl(originInvoker); + + // 获取export参数,其中存储了一个"dubbo://"协议的ProviderURL + + URL providerUrl = getProviderUrl(originInvoker); + + // 获取要监听的配置目录,这里会在ProviderURL的基础上添加category=configurators参数,并封装成对OverrideListener记录到overrideListeners集合中 + + final URL overrideSubscribeUrl = getSubscribedOverrideUrl(providerUrl); + + final OverrideListener overrideSubscribeListener = new OverrideListener(overrideSubscribeUrl, originInvoker); + + overrideListeners.put(overrideSubscribeUrl, overrideSubscribeListener); + + // 初始化时会检测一次Override配置,重写ProviderURL + + providerUrl = overrideUrlWithConfig(providerUrl, overrideSubscribeListener); + + // 导出服务,底层会通过执行DubboProtocol.export()方法,启动对应的Server + + final ExporterChangeableWrapper exporter = doLocalExport(originInvoker, providerUrl); + + // 根据RegistryURL获取对应的注册中心Registry对象,其中会依赖之前课时介绍的RegistryFactory + + final Registry registry = getRegistry(originInvoker); + + // 获取将要发布到注册中心上的Provider URL,其中会删除一些多余的参数信息 + + final URL registeredProviderUrl = getUrlToRegistry(providerUrl, registryUrl); + + // 根据register参数值决定是否注册服务 + + boolean register = providerUrl.getParameter(REGISTER_KEY, true); + + if (register) { // 调用Registry.register()方法将registeredProviderUrl发布到注册中心 + + register(registryUrl, registeredProviderUrl); + + } + + // 将Provider相关信息记录到的ProviderModel中 + + registerStatedUrl(registryUrl, registeredProviderUrl, register); + + // 向注册中心进行订阅override数据,主要是监听该服务的configurators节点 + + registry.subscribe(overrideSubscribeUrl, overrideSubscribeListener); + + exporter.setRegisterUrl(registeredProviderUrl); + + exporter.setSubscribeUrl(overrideSubscribeUrl); + + // 触发RegistryProtocolListener监听器 + + notifyExport(exporter); + + return new DestroyableExporter<>(exporter); + +} + + +我们可以看到,远程发布流程大致可分为下面 5 个步骤。 + + +准备 URL,比如 ProviderURL、RegistryURL 和 OverrideSubscribeUrl。 +发布 Dubbo 服务。在 doLocalExport() 方法中调用 DubboProtocol.export() 方法启动 Provider 端底层 Server。 +注册 Dubbo 服务。在 register() 方法中,调用 ZookeeperRegistry.register() 方法向 Zookeeper 注册服务。 +订阅 Provider 端的 Override 配置。调用 ZookeeperRegistry.subscribe() 方法订阅注册中心 configurators 节点下的配置变更。 +触发 RegistryProtocolListener 监听器。 + + +远程发布的详细流程如下图所示: + + + +服务发布详细流程图 + +总结 + +本课时我们重点介绍了 Dubbo 服务发布的核心流程。 + +首先我们介绍了 DubboBootstrap 这个入口门面类中与服务发布相关的方法,重点是 start() 和 exportServices() 两个方法;然后详细介绍了 ServiceConfig 类的三个核心步骤:检查参数、立即(或延迟)执行 doExport() 方法进行发布、回调服务发布的相关监听器。 + +接下来,我们分析了doExportUrlsFor1Protocol() 方法,它是发布一个服务的入口,也是规定服务发布流程的地方,其中涉及 Provider URL 的组装、本地服务发布流程以及远程服务发布流程,对于这些步骤,我们都进行了详细的分析。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/42加餐:服务引用流程全解析.md b/专栏/Dubbo源码解读与实战-完/42加餐:服务引用流程全解析.md new file mode 100644 index 0000000..a49c5e3 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/42加餐:服务引用流程全解析.md @@ -0,0 +1,594 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 42 加餐:服务引用流程全解析 + Dubbo 作为一个 RPC 框架,暴露给用户最基本的功能就是服务发布和服务引用。在上一课时,我们已经分析了服务发布的核心流程。那么在本课时,我们就接着深入分析服务引用的核心流程。 + +Dubbo 支持两种方式引用远程的服务: + + +服务直连的方式,仅适合在调试服务的时候使用; +基于注册中心引用服务,这是生产环境中使用的服务引用方式。 + + +DubboBootstrap 入口 + +在上一课时介绍服务发布的时候,我们介绍了 DubboBootstrap.start() 方法的核心流程,其中除了会调用 exportServices() 方法完成服务发布之外,还会调用 referServices() 方法完成服务引用,这里就不再贴出 DubboBootstrap.start() 方法的具体代码,你若感兴趣的话可以参考源码进行学习。 + +在 DubboBootstrap.referServices() 方法中,会从 ConfigManager 中获取所有 ReferenceConfig 列表,并根据 ReferenceConfig 获取对应的代理对象,入口逻辑如下: + +private void referServices() { + + if (cache == null) { // 初始ReferenceConfigCache + + cache = ReferenceConfigCache.getCache(); + + } + + configManager.getReferences().forEach(rc -> { + + // 遍历ReferenceConfig列表 + + ReferenceConfig referenceConfig = (ReferenceConfig) rc; + + referenceConfig.setBootstrap(this); + + if (rc.shouldInit()) { // 检测ReferenceConfig是否已经初始化 + + if (referAsync) { // 异步 + + CompletableFuture future = ScheduledCompletableFuture.submit( + + executorRepository.getServiceExporterExecutor(), + + () -> cache.get(rc) + + ); + + asyncReferringFutures.add(future); + + } else { // 同步 + + cache.get(rc); + + } + + } + + }); + +} + + +这里的 ReferenceConfig 是哪里来的呢?在[第 01 课时]dubbo-demo-api-consumer 示例中,我们可以看到构造 ReferenceConfig 对象的逻辑,这些新建的 ReferenceConfig 对象会通过 DubboBootstrap.reference() 方法添加到 ConfigManager 中进行管理,如下所示: + +public DubboBootstrap reference(ReferenceConfig referenceConfig) { + + configManager.addReference(referenceConfig); + + return this; + +} + + +ReferenceConfigCache + +服务引用的核心实现在 ReferenceConfig 之中,一个 ReferenceConfig 对象对应一个服务接口,每个 ReferenceConfig 对象中都封装了与注册中心的网络连接,以及与 Provider 的网络连接,这是一个非常重要的对象。 + +为了避免底层连接泄漏造成性能问题,从 Dubbo 2.4.0 版本开始,Dubbo 提供了 ReferenceConfigCache 用于缓存 ReferenceConfig 实例。 + +在 dubbo-demo-api-consumer 示例中,我们可以看到 ReferenceConfigCache 的基本使用方式: + +ReferenceConfig reference = new ReferenceConfig<>(); + +reference.setInterface(DemoService.class); + +... + +// 这一步在DubboBootstrap.start()方法中完成 + +ReferenceConfigCache cache = ReferenceConfigCache.getCache(); + +... + +DemoService demoService = ReferenceConfigCache.getCache().get(reference); + + +在 ReferenceConfigCache 中维护了一个静态的 Map(CACHE_HOLDER)字段,其中 Key 是由 Group、服务接口和 version 构成,Value 是一个 ReferenceConfigCache 对象。在 ReferenceConfigCache 中可以传入一个 KeyGenerator 用来修改缓存 Key 的生成逻辑,KeyGenerator 接口的定义如下: + +public interface KeyGenerator { + + String generateKey(ReferenceConfigBase referenceConfig); + +} + + +默认的 KeyGenerator 实现是 ReferenceConfigCache 中的匿名内部类,其对象由 DEFAULT_KEY_GENERATOR 这个静态字段引用,具体实现如下: + +public static final KeyGenerator DEFAULT_KEY_GENERATOR = referenceConfig -> { + + String iName = referenceConfig.getInterface(); + + if (StringUtils.isBlank(iName)) { // 获取服务接口名称 + + Class clazz = referenceConfig.getInterfaceClass(); + + iName = clazz.getName(); + + } + + if (StringUtils.isBlank(iName)) { + + throw new IllegalArgumentException("No interface info in ReferenceConfig" + referenceConfig); + + } + + // Key的格式是group/interface:version + + StringBuilder ret = new StringBuilder(); + + if (!StringUtils.isBlank(referenceConfig.getGroup())) { + + ret.append(referenceConfig.getGroup()).append("/"); + + } + + ret.append(iName); + + if (!StringUtils.isBlank(referenceConfig.getVersion())) { + + ret.append(":").append(referenceConfig.getVersion()); + + } + + return ret.toString(); + +}; + + +在 ReferenceConfigCache 实例对象中,会维护下面两个 Map 集合。 + + +proxies(ConcurrentMap, ConcurrentMap>类型):该集合用来存储服务接口的全部代理对象,其中第一层 Key 是服务接口的类型,第二层 Key 是上面介绍的 KeyGenerator 为不同服务提供方生成的 Key,Value 是服务的代理对象。 +referredReferences(ConcurrentMap> 类型):该集合用来存储已经被处理的 ReferenceConfig 对象。 + + +我们回到 DubboBootstrap.referServices() 方法中,看一下其中与 ReferenceConfigCache 相关的逻辑。 + +首先是 ReferenceConfigCache.getCache() 这个静态方法,会在 CACHE_HOLDER 集合中添加一个 Key 为“*DEFAULT*”的 ReferenceConfigCache 对象(使用默认的 KeyGenerator 实现),它将作为默认的 ReferenceConfigCache 对象。 + +接下来,无论是同步服务引用还是异步服务引用,都会调用 ReferenceConfigCache.get() 方法,创建并缓存代理对象。下面就是 ReferenceConfigCache.get() 方法的核心实现: + +public T get(ReferenceConfigBase referenceConfig) { + + // 生成服务提供方对应的Key + + String key = generator.generateKey(referenceConfig); + + // 获取接口类型 + + Class type = referenceConfig.getInterfaceClass(); + + // 获取该接口对应代理对象集合 + + proxies.computeIfAbsent(type, _t -> new ConcurrentHashMap<>()); + + ConcurrentMap proxiesOfType = proxies.get(type); + + // 根据Key获取服务提供方对应的代理对象 + + proxiesOfType.computeIfAbsent(key, _k -> { + + // 服务引用 + + Object proxy = referenceConfig.get(); + + // 将ReferenceConfig记录到referredReferences集合 + + referredReferences.put(key, referenceConfig); + + return proxy; + + }); + + return (T) proxiesOfType.get(key); + +} + + +ReferenceConfig + +通过前面的介绍我们知道,ReferenceConfig 是服务引用的真正入口,其中会创建相关的代理对象。下面先来看 ReferenceConfig.get() 方法: + +public synchronized T get() { + + if (destroyed) { // 检测当前ReferenceConfig状态 + + throw new IllegalStateException("..."); + + } + + if (ref == null) {// ref指向了服务的代理对象 + + init(); // 初始化ref字段 + + } + + return ref; + +} + + +在 ReferenceConfig.init() 方法中,首先会对服务引用的配置进行处理,以保证配置的正确性。这里的具体实现其实本身并不复杂,但由于涉及很多的配置解析和处理逻辑,代码就显得非常长,我们就不再一一展示,你若感兴趣的话可以参考源码进行学习。 + +ReferenceConfig.init() 方法的核心逻辑是调用 createProxy() 方法,调用之前会从配置中获取 createProxy() 方法需要的参数: + +public synchronized void init() { + + if (initialized) { // 检测ReferenceConfig的初始化状态 + + return; + + } + + if (bootstrap == null) { // 检测DubboBootstrap的初始化状态 + + bootstrap = DubboBootstrap.getInstance(); + + bootstrap.init(); + + } + + ... // 省略其他配置的检查 + + Map map = new HashMap(); + + map.put(SIDE_KEY, CONSUMER_SIDE); // 添加side参数 + + // 添加Dubbo版本、release参数、timestamp参数、pid参数 + + ReferenceConfigBase.appendRuntimeParameters(map); + + // 添加interface参数 + + map.put(INTERFACE_KEY, interfaceName); + + ... // 省略其他参数的处理 + + String hostToRegistry = ConfigUtils.getSystemProperty(DUBBO_IP_TO_REGISTRY); + + if (StringUtils.isEmpty(hostToRegistry)) { + + hostToRegistry = NetUtils.getLocalHost(); + + } else if (isInvalidLocalHost(hostToRegistry)) { + + throw new IllegalArgumentException("..."); + + } + + // 添加ip参数 + + map.put(REGISTER_IP_KEY, hostToRegistry); + + // 调用createProxy()方法 + + ref = createProxy(map); + + ...// 省略其他代码 + + initialized = true; + + // 触发ReferenceConfigInitializedEvent事件 + + dispatch(new ReferenceConfigInitializedEvent(this, invoker)); + +} + + +ReferenceConfig.createProxy() 方法中处理了多种服务引用的场景,例如,直连单个/多个Provider、单个/多个注册中心。下面是 createProxy() 方法的核心流程,大致可以梳理出这么 5 个步骤。 + + +根据传入的参数集合判断协议是否为 injvm 协议,如果是,直接通过 InjvmProtocol 引用服务。 +构造 urls 集合。Dubbo 支持直连 Provider和依赖注册中心两种服务引用方式。如果是直连服务的模式,我们可以通过 url 参数指定一个或者多个 Provider 地址,会被解析并填充到 urls 集合;如果通过注册中心的方式进行服务引用,则会调用 AbstractInterfaceConfig.loadRegistries() 方法加载所有注册中心。 +如果 urls 集合中只记录了一个 URL,通过 Protocol 适配器选择合适的 Protocol 扩展实现创建 Invoker 对象。如果是直连 Provider 的场景,则 URL 为 dubbo 协议,这里就会使用 DubboProtocol 这个实现;如果依赖注册中心,则使用 RegistryProtocol 这个实现。 +如果 urls 集合中有多个注册中心,则使用 ZoneAwareCluster 作为 Cluster 的默认实现,生成对应的 Invoker 对象;如果 urls 集合中记录的是多个直连服务的地址,则使用 Cluster 适配器选择合适的扩展实现生成 Invoker 对象。 +通过 ProxyFactory 适配器选择合适的 ProxyFactory 扩展实现,将 Invoker 包装成服务接口的代理对象。 + + +通过上面的流程我们可以看出createProxy() 方法中有两个核心:一是通过 Protocol 适配器选择合适的 Protocol 扩展实现创建 Invoker 对象;二是通过 ProxyFactory 适配器选择合适的 ProxyFactory 创建代理对象。 + +下面我们来看 createProxy() 方法的具体实现: + +private T createProxy(Map map) { + + if (shouldJvmRefer(map)) { // 根据url的协议、scope以及injvm等参数检测是否需要本地引用 + + // 创建injvm协议的URL + + URL url = new URL(LOCAL_PROTOCOL, LOCALHOST_VALUE, 0, interfaceClass.getName()).addParameters(map); + + // 通过Protocol的适配器选择对应的Protocol实现创建Invoker对象 + + invoker = REF_PROTOCOL.refer(interfaceClass, url); + + if (logger.isInfoEnabled()) { + + logger.info("Using injvm service " + interfaceClass.getName()); + + } + + } else { + + urls.clear(); + + if (url != null && url.length() > 0) { + + String[] us = SEMICOLON_SPLIT_PATTERN.split(url); // 配置多个URL的时候,会用分号进行切分 + + if (us != null && us.length > 0) { // url不为空,表明用户可能想进行点对点调用 + + for (String u : us) { + + URL url = URL.valueOf(u); + + if (StringUtils.isEmpty(url.getPath())) { + + url = url.setPath(interfaceName); // 设置接口完全限定名为URL Path + + } + + if (UrlUtils.isRegistry(url)) { // 检测URL协议是否为registry,若是,说明用户想使用指定的注册中心 + + // 这里会将map中的参数整理成一个参数添加到refer参数中 + + urls.add(url.addParameterAndEncoded(REFER_KEY, StringUtils.toQueryString(map))); + + } else { + + // 将map中的参数添加到url中 + + urls.add(ClusterUtils.mergeUrl(url, map)); + + } + + } + + } + + } else { + + if (!LOCAL_PROTOCOL.equalsIgnoreCase(getProtocol())) { + + checkRegistry(); + + // 加载注册中心的地址RegistryURL + + List us = ConfigValidationUtils.loadRegistries(this, false); + + if (CollectionUtils.isNotEmpty(us)) { + + for (URL u : us) { + + URL monitorUrl = ConfigValidationUtils.loadMonitor(this, u); + + if (monitorUrl != null) { + + map.put(MONITOR_KEY, URL.encode(monitorUrl.toFullString())); + + } + + // 将map中的参数整理成refer参数,添加到RegistryURL中 + + urls.add(u.addParameterAndEncoded(REFER_KEY, StringUtils.toQueryString(map))); + + } + + } + + if (urls.isEmpty()) { // 既不是服务直连,也没有配置注册中心,抛出异常 + + throw new IllegalStateException("..."); + + } + + } + + } + + if (urls.size() == 1) { + + // 在单注册中心或是直连单个服务提供方的时候,通过Protocol的适配器选择对应的Protocol实现创建Invoker对象 + + invoker = REF_PROTOCOL.refer(interfaceClass, urls.get(0)); + + } else { + + // 多注册中心或是直连多个服务提供方的时候,会根据每个URL创建Invoker对象 + + List> invokers = new ArrayList>(); + + URL registryURL = null; + + for (URL url : urls) { + + invokers.add(REF_PROTOCOL.refer(interfaceClass, url)); + + if (UrlUtils.isRegistry(url)) { // 确定是多注册中心,还是直连多个Provider + + registryURL = url; + + } + + } + + if (registryURL != null) { + + // 多注册中心的场景中,会使用ZoneAwareCluster作为Cluster默认实现,多注册中心之间的选择 + + URL u = registryURL.addParameterIfAbsent(CLUSTER_KEY, ZoneAwareCluster.NAME); + + invoker = CLUSTER.join(new StaticDirectory(u, invokers)); + + } else { + + // 多个Provider直连的场景中,使用Cluster适配器选择合适的扩展实现 + + invoker = CLUSTER.join(new StaticDirectory(invokers)); + + } + + } + + } + + if (shouldCheck() && !invoker.isAvailable()) { + + // 根据check配置决定是否检测Provider的可用性 + + invoker.destroy(); + + throw new IllegalStateException("..."); + + } + + ...// 元数据处理相关的逻辑 + + // 通过ProxyFactory适配器选择合适的ProxyFactory扩展实现,创建代理对象 + + return (T) PROXY_FACTORY.getProxy(invoker, ProtocolUtils.isGeneric(generic)); + +} + + +RegistryProtocol + +在直连 Provider 的场景中,会使用 DubboProtocol.refer() 方法完成服务引用,DubboProtocol.refer() 方法的具体实现在前面[第 25 课时]中已经详细介绍过了,这里我们重点来看存在注册中心的场景中,Dubbo Consumer 是如何通过 RegistryProtocol 完成服务引用的。 + +在 RegistryProtocol.refer() 方法中,会先根据 URL 获取注册中心的 URL,再调用 doRefer 方法生成 Invoker,在 refer() 方法中会使用 MergeableCluster 处理多 group 引用的场景。 + +public Invoker refer(Class type, URL url) throws RpcException { + + url = getRegistryUrl(url); // 从URL中获取注册中心的URL + + // 获取Registry实例,这里的RegistryFactory对象是通过Dubbo SPI的自动装载机制注入的 + + Registry registry = registryFactory.getRegistry(url); + + if (RegistryService.class.equals(type)) { + + return proxyFactory.getInvoker((T) registry, type, url); + + } + + // 从注册中心URL的refer参数中获取此次服务引用的一些参数,其中就包括group + + Map qs = StringUtils.parseQueryString(url.getParameterAndDecoded(REFER_KEY)); + + String group = qs.get(GROUP_KEY); + + if (group != null && group.length() > 0) { + + if ((COMMA_SPLIT_PATTERN.split(group)).length > 1 || "*".equals(group)) { + + // 如果此次可以引用多个group的服务,则Cluser实现使用MergeableCluster实现, + + // 这里的getMergeableCluster()方法就会通过Dubbo SPI方式找到MergeableCluster实例 + + return doRefer(getMergeableCluster(), registry, type, url); + + } + + } + + // 如果没有group参数或是只指定了一个group,则通过Cluster适配器选择Cluster实现 + + return doRefer(cluster, registry, type, url); + +} + + +在 doRefer() 方法中,首先会根据 URL 初始化 RegistryDirectory 实例,然后生成 Subscribe URL 并进行注册,之后会通过 Registry 订阅服务,最后通过 Cluster 将多个 Invoker 合并成一个 Invoker 返回给上层,具体实现如下: + +private Invoker doRefer(Cluster cluster, Registry registry, Class type, URL url) { + + // 创建RegistryDirectory实例 + + RegistryDirectory directory = new RegistryDirectory(type, url); + + directory.setRegistry(registry); + + directory.setProtocol(protocol); + + // 生成SubscribeUrl,协议为consumer,具体的参数是RegistryURL中refer参数指定的参数 + + Map parameters = new HashMap(directory.getConsumerUrl().getParameters()); + + URL subscribeUrl = new URL(CONSUMER_PROTOCOL, parameters.remove(REGISTER_IP_KEY), 0, type.getName(), parameters); + + if (directory.isShouldRegister()) { + + directory.setRegisteredConsumerUrl(subscribeUrl); // 在SubscribeUrl中添加category=consumers和check=false参数 + + registry.register(directory.getRegisteredConsumerUrl()); // 服务注册,在Zookeeper的consumers节点下,添加该Consumer对应的节点 + + } + + directory.buildRouterChain(subscribeUrl); // 根据SubscribeUrl创建服务路由 + + // 订阅服务,toSubscribeUrl()方法会将SubscribeUrl中category参数修改为"providers,configurators,routers" + + // RegistryDirectory的subscribe()在前面详细分析过了,其中会通过Registry订阅服务,同时还会添加相应的监听器 + + directory.subscribe(toSubscribeUrl(subscribeUrl)); + + // 注册中心中可能包含多个Provider,相应地,也就有多个Invoker, + + // 这里通过前面选择的Cluster将多个Invoker对象封装成一个Invoker对象 + + Invoker invoker = cluster.join(directory); + + // 根据URL中的registry.protocol.listener参数加载相应的监听器实现 + + List listeners = findRegistryProtocolListeners(url); + + if (CollectionUtils.isEmpty(listeners)) { + + return invoker; + + } + + // 为了方便在监听器中回调,这里将此次引用使用到的Directory对象、Cluster对象、Invoker对象以及SubscribeUrl + + // 封装到一个RegistryInvokerWrapper中,传递给监听器 + + RegistryInvokerWrapper registryInvokerWrapper = new RegistryInvokerWrapper<>(directory, cluster, invoker, subscribeUrl); + + for (RegistryProtocolListener listener : listeners) { + + listener.onRefer(this, registryInvokerWrapper); + + } + + return registryInvokerWrapper; + +} + + +这里涉及的 RegistryDirectory、Router 接口、Cluster 接口及其相关的扩展实现,我们都已经在前面的课时详细分析过了,这里不再重复。 + +总结 + +本课时,我们重点介绍了 Dubbo 服务引用的整个流程。 + + +首先,我们介绍了 DubboBootStrap 这个入口门面类与服务引用相关的方法,其中涉及 referServices()、reference() 等核心方法。 +接下来,我们分析了 ReferenceConfigCache 这个 ReferenceConfig 对象缓存,以及 ReferenceConfig 实现服务引用的核心流程。 +最后,我们还讲解了 RegistryProtocol 从注册中心引用服务的核心实现。 + + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/43服务自省设计方案:新版本新方案.md b/专栏/Dubbo源码解读与实战-完/43服务自省设计方案:新版本新方案.md new file mode 100644 index 0000000..d81a236 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/43服务自省设计方案:新版本新方案.md @@ -0,0 +1,119 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 43 服务自省设计方案:新版本新方案 + 随着微服务架构的不断发展和普及,RPC 框架成为微服务架构中不可或缺的重要角色,Dubbo 作为 Java 生态中一款成熟的 RPC 框架也在随着技术的更新换代不断发展壮大。当然,传统的 Dubbo 架构也面临着新思想、新生态和新技术带来的挑战。 + +在微服务架构中,服务是基本单位,而 Dubbo 架构中服务的基本单位是 Java 接口,这种架构上的差别就会带来一系列挑战。从 2.7.5 版本开始,Dubbo 引入了服务自省架构,来应对微服务架构带来的挑战。具体都有哪些挑战呢?下面我们就来详细说明一下。 + +注册中心面临的挑战 + +在开始介绍注册中心面临的挑战之前,我们先来回顾一下前面课时介绍过的 Dubbo 传统架构以及这个架构中最核心的组件: + + + +Dubbo 核心架构图 + +结合上面这张架构图,我们可以一起回顾一下这些核心组件的功能。 + + +Registry:注册中心。 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。 +Provider:服务提供者。 在它启动的时候,会向 Registry 进行注册操作,将自己服务的地址和相关配置信息封装成 URL 添加到 ZooKeeper 中。 +Consumer:服务消费者。 在它启动的时候,会向 Registry 进行订阅操作。订阅操作会从 ZooKeeper 中获取 Provider 注册的 URL,并在 ZooKeeper 中添加相应的监听器。获取到 Provider URL 之后,Consumer 会根据 URL 中相应的参数选择 LoadBalance、Router、Cluster 实现,创建相应的 Invoker 对象,然后封装服务接口的代理对象,返回给上层业务。上层业务调用该代理对象的方法,就会执行远程调用。 +Monitor:监控中心。 用于统计服务的调用次数和调用时间。Provider 和 Consumer 在运行过程中,会在内存中统计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。监控中心在上面的架构图中并不是必要角色,监控中心宕机不会影响 Provider、Consumer 以及 Registry 的功能,只会丢失监控数据而已。 + + +通过前面对整个 Dubbo 实现体系的介绍,我们知道URL 是贯穿整个 Dubbo 注册与发现的核心。Provider URL 注册到 ZooKeeper 上的大致格式如下: + +dubbo://192.168.0.100:20880/org.apache.dubbo.demo.DemoService?anyhost=true&application=demo-provider&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&group=groupA&interface=org.apache.dubbo.demo.DemoService&metadata-type=remote&methods=sayHello,sayHelloAsync&pid=59975&release=&side=provider×tamp=1601390276192 + + +其中包括 Provider 的 IP、Port、服务接口的完整名称、Dubbo 协议的版本号、分组信息、进程 ID 等。 + +我们常用的注册中心,比如,ZooKeeper、Nacos 或 etcd 等,都是中心化的基础设施。注册中心基本都是以内存作为核心存储,其内存使用量与服务接口的数量以及 Provider 节点的个数是成正比的,一个 Dubbo Provider 节点可以注册多个服务接口。随着业务发展,服务接口的数量会越来越多,为了支撑整个系统的流量增长,部署的 Dubbo Provider 节点和 Dubbo Consumer 节点也会不断增加,这就导致注册中心的内存压力越来越大。 + +在生产环境中为了避免单点故障,在搭建注册中心的时候,都会使用高可用方案。这些高可用方案的本质就是底层的一致性协议,例如,ZooKeeper 使用的是 Zab 协议,etcd 使用的是 Raft 协议。当注册数据频繁发生变化的时候,注册中心集群的内部节点用于同步数据的网络开销也会增大。 + +从注册中心的外部看,Dubbo Provider 和 Dubbo Consumer 都可以算作注册中心的客户端,都会与注册中心集群之间维护长连接,这也会造成一部分网络开销和资源消耗。 + +在使用类似 ZooKeeper 的注册中心实现方案时,注册中心会主动将注册数据的变化推送到客户端。假设一个 Dubbo Consumer 订阅了 N 个服务接口,每个服务接口由 M 个 Provider 节点组成的集群提供服务,在 Provider 节点进行机器迁移的时候,就会涉及 M * N 个 URL 的更新,这些变更事件都会通知到每个 Dubbo Consumer 节点,这就造成了注册中心在处理通知方面的压力。 + +总之,在超大规模的微服务落地实践中,从内存、网络开销、通知等多个角度看,注册中心以及整个 Dubbo 传统架构都受到了不少的挑战和压力。 + +Dubbo 的改进方案 + +Dubbo 从 2.7.0 版本开始增加了简化 URL的特性,从 URL 中抽出的数据会被存放至元数据中心。但是这次优化只是缩短了 URL 的长度,从内存使用量以及降低通知频繁度的角度降低了注册中心的压力,并没有减少注册中心 URL 的数量,所以注册中心所承受的压力还是比较明显的。 + +Dubbo 2.7.5 版本引入了服务自省架构,进一步降低了注册中心的压力。在此次优化中,Dubbo 修改成应用为粒度的服务注册与发现模型,最大化地减少了 Dubbo 服务元信息注册数量,其核心流程如下图所示: + + + +服务自省架构图 + +上图展示了引入服务自省之后的 Dubbo 服务注册与发现的核心流程,Dubbo 会按照顺序执行这些操作(当其中一个操作失败时,后续操作不会执行)。 + +我们首先来看 Provider 侧的执行流程: + +1.发布所有业务接口中定义的服务接口,具体过程与[第 41 课时]中介绍的发布流程相同; + +2.发布 MetadataService 接口,该接口的发布由 Dubbo 框架自主完成; + +3.将 Service Instance 注册到注册中心; + +4.建立所有的 Service ID 与 Service Name 的映射,并同步到配置中心。 + +接下来,我们再来看Consumer 侧的执行流程: + +5.注册当前 Consumer 的 Service Instance,Dubbo 允许 Consumer 不进行服务注册,所以这一步操作是可选的; + +6.从配置中心获取 Service ID 与 Service Name 的映射关系; + +7.根据 Service ID 从注册中心获取 Service Instance 集合; + +8.随机选择一个 Service Instance,从中获取 MetadataService 的元数据,这里会发起 MetadataService 的调用,获取该 Service Instance 所暴露的业务接口的 URL 列表,从该 URL 列表中可以过滤出当前订阅的 Service 的 URL; + +9.根据步骤 8 中获取的业务接口 URL 发起远程调用。 + +至于上图中涉及的一些新概念,为方便你理解,这里我们对它们的具体实现进行一个简单的介绍。 + + +Service Name:服务名称,例如,在一个电商系统中,有用户服务、商品服务、库存服务等。 +Service Instance:服务实例,表示单个 Dubbo 应用进程,多个 Service Instance 构成一个服务集群,拥有相同的 Service Name。 +Service ID:唯一标识一个 Dubbo 服务,由 ${protocol}:${interface}:${version}:${group} 四部分构成。 + + +在有的场景中,我们会在线上部署两组不同配置的服务节点,来验证某些配置是否生效。例如,共有 100 个服务节点,平均分成 A、B 两组,A 组服务节点超时时间(即 timeout)设置为 3000 ms,B 组的超时时间(即 timeout)设置为 2000 ms,这样的话该服务就有了两组不同的元数据。 + +按照前面介绍的优化方案,在订阅服务的时候,会得到 100 个 ServiceInstance,因为每个 ServiceInstance 发布的服务元数据都有可能不一样,所以我们需要调用每个 ServiceInstance 的 MetadataService 服务获取元数据。 + +为了减少 MetadataService 服务的调用次数,Dubbo 提出了服务修订版本的优化方案,其核心思想是:将每个 ServiceInstance 发布的服务 URL 计算一个 hash 值(也就是 revision 值),并随 ServiceInstance 一起发布到注册中心;在 Consumer 端进行订阅的时候,对于 revision 值相同的 ServiceInstance,不再调用 MetadataService 服务,直接共用一份 URL 即可。下图展示了 Dubbo 服务修订的核心逻辑: + + + +引入 Dubbo 服务修订的 Consumer 端交互图 + +通过该流程图,我们可以看到 Dubbo Consumer 端实现服务修订的流程如下。 + + +Consumer 端通过服务发现 API 从注册中心获取 Provider 端的 ServiceInstance 列表。 +注册中心返回 100 台服务实例,其中 revision 为 1 的 ServiceInstance 编号是 0~49,revision 为 2 的 ServiceInstance 编号是 50~99。 +Consumer 端在这 100 台服务实例中随机选择一台,例如,选择到编号为 68 的 ServiceInstance。 +Consumer 端调用 ServiceInstance 68 暴露的 MetadataService 服务,获得其发布的 Dubbo 服务 URL 列表,并在本地内存中建立 revision 为 2 的服务 URL 列表缓存。 +Consumer 端再从剩余的 99 台服务实例中随机选择一台,例如,选中了 ServiceInstance 30,发现其 revision 值为 1,且本地缓存中没有 revision 为 1 的服务 URL 列表缓存。此时,Consumer 会如步骤 4 一样发起 MetadataService 调用,从 ServiceInstance 30 获取服务 URL 列表,并更新缓存。 + + +由于此时的本地缓存已经覆盖了当前场景中全部的 revision 值,后续再次随机选择的 ServiceInstance 的 revision 不是 1 就是 2,都会落到本地缓存中,不会再次发起 MetadataService 服务调用。后续其他 ServiceInstance 的处理都会复用本地缓存的这两个 URL 列表,并根据 ServiceInstance 替换相应的参数(例如,host、port 等),这样即可得到 ServiceInstance 发布的完整的服务 URL 列表。 + +一般情况下,revision 的数量不会很多,那么 Consumer 端发起的 MetadataService 服务调用次数也是有限的,不会随着 ServiceInstance 的扩容而增长。这样就避免了同一服务的不同版本导致的元数据膨胀。 + +总结 + +在本课时,我们重点介绍了 Dubbo 的服务自省架构的相关内容。 + +首先,我们一起复习了 Dubbo 的传统架构以及传统架构中基础组建的核心功能和交互流程。然后分析了 Dubbo 传统架构在超大规模微服务落地实践中面临的各项挑战和压力。最后,我们重点讲解了 Dubbo 2.7.5 版本之后引入的服务自省方案,服务自省方案可以很好地应对 Dubbo 面临的诸多挑战,并缓解基于 Dubbo 实现的、超大规模的微服务系统压力。在此基础上,我们还特别介绍了 Dubbo 服务修订方案是如何避免元数据膨胀的具体原理。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/44元数据方案深度剖析,如何避免注册中心数据量膨胀?.md b/专栏/Dubbo源码解读与实战-完/44元数据方案深度剖析,如何避免注册中心数据量膨胀?.md new file mode 100644 index 0000000..2cd2c31 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/44元数据方案深度剖析,如何避免注册中心数据量膨胀?.md @@ -0,0 +1,1053 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 44 元数据方案深度剖析,如何避免注册中心数据量膨胀? + 在上一课时,我们详细介绍了 Dubbo 传统架构面临的挑战,以及 Dubbo 2.7.5 版本引入的服务自省方案是如何应对这些挑战的。 + +本课时我们将从服务自省方案的基础设施开始介绍其具体实现。我们首先会介绍元数据相关的基础类的定义,然后介绍元数据的上报以及元数据服务的相关内容,同时还会介绍 Service ID 与 Service Name 是如何映射的。 + +ServiceInstance + +Service Instance 唯一标识一个服务实例,在 Dubbo 的源码中对应 ServiceInstance 接口,该接口的具体定义如下: + +public interface ServiceInstance extends Serializable { + + // 唯一标识 + + String getId(); + + // 获取当前ServiceInstance所属的Service Name + + String getServiceName(); + + // 获取当前ServiceInstance的host + + String getHost(); + + // 获取当前ServiceInstance的port + + Integer getPort(); + + // 当前ServiceInstance的状态 + + default boolean isEnabled() { + + return true; + + } + + // 检测当前ServiceInstance的状态 + + default boolean isHealthy() { + + return true; + + } + + // 获取当前ServiceInstance关联的元数据,这些元数据以KV格式存储 + + Map getMetadata(); + + // 计算当前ServiceInstance对象的hashCode值 + + int hashCode(); + + // 比较两个ServiceInstance对象 + + boolean equals(Object another); + +} + + +DefaultServiceInstance 是 ServiceInstance 的唯一实现,DefaultServiceInstance 是一个普通的 POJO 类,其中的核心字段如下。 + + +id(String 类型):ServiceInstance 唯一标识。 +serviceName(String 类型):ServiceInstance 关联的 Service Name。 +host(String 类型):ServiceInstance 的 host。 +port(Integer 类型):ServiceInstance 的 port。 +enabled(boolean 类型):ServiceInstance 是否可用的状态。 +healthy(boolean 类型):ServiceInstance 的健康状态。 +metadata(Map 类型):ServiceInstance 关联的元数据。 + + +ServiceDefinition + +Dubbo 元数据服务与我们业务中发布的 Dubbo 服务无异,Consumer 端可以调用一个 ServiceInstance 的元数据服务获取其发布的全部服务的元数据。 + +说到元数据,就不得不提到 ServiceDefinition 这个类,它可以来描述一个服务接口的定义,其核心字段如下。 + + +canonicalName(String 类型):接口的完全限定名称。 +codeSource(String 类型):服务接口所在的完整路径。 +methods(List 类型):接口中定义的全部方法描述信息。在 MethodDefinition 中记录了方法的名称、参数类型、返回值类型以及方法参数涉及的所有 TypeDefinition。 +types(List 类型):接口定义中涉及的全部类型描述信息,包括方法的参数和字段,如果遇到复杂类型,TypeDefinition 会递归获取复杂类型内部的字段。在 dubbo-metadata-api 模块中,提供了多种类型对应的 TypeBuilder 用于创建对应的 TypeDefinition,对于没有特定 TypeBuilder 实现的类型,会使用 DefaultTypeBuilder。 + + + + +TypeBuilder 接口实现关系图 + +在服务发布的时候,会将服务的 URL 中的部分数据封装为 FullServiceDefinition 对象,然后作为元数据存储起来。FullServiceDefinition 继承了 ServiceDefinition,并在 ServiceDefinition 基础之上扩展了 params 集合(Map 类型),用来存储 URL 上的参数。 + +MetadataService + +接下来看 MetadataService 接口,在上一讲我们提到Dubbo 中的每个 ServiceInstance 都会发布 MetadataService 接口供 Consumer 端查询元数据,下图展示了 MetadataService 接口的继承关系: + + + +MetadataService 接口继承关系图 + +在 MetadataService 接口中定义了查询当前 ServiceInstance 发布的元数据的相关方法,具体如下所示: + +public interface MetadataService { + + String serviceName(); // 获取当前ServiceInstance所属服务的名称 + + default String version() { + + return VERSION; // 获取当前MetadataService接口的版本 + + } + + // 获取当前ServiceInstance订阅的全部URL + + default SortedSet getSubscribedURLs(){ + + throw new UnsupportedOperationException("This operation is not supported for consumer."); + + } + + // 获取当前ServiceInstance发布的全部URL + + default SortedSet getExportedURLs() { + + return getExportedURLs(ALL_SERVICE_INTERFACES); + + } + + // 根据服务接口查找当前ServiceInstance暴露的全部接口 + + default SortedSet getExportedURLs(String serviceInterface) { + + return getExportedURLs(serviceInterface, null); + + } + + // 根据服务接口和group两个条件查找当前ServiceInstance暴露的全部接口 + + default SortedSet getExportedURLs(String serviceInterface, String group) { + + return getExportedURLs(serviceInterface, group, null); + + } + + // 根据服务接口、group和version三个条件查找当前ServiceInstance暴露的全部接口 + + default SortedSet getExportedURLs(String serviceInterface, String group, String version) { + + return getExportedURLs(serviceInterface, group, version, null); + + } + + // 根据服务接口、group、version和protocol四个条件查找当前ServiceInstance暴露的全部接口 + + SortedSet getExportedURLs(String serviceInterface, String group, String version, String protocol); + + // 根据指定条件查询ServiceDefinition + + String getServiceDefinition(String interfaceName, String version, String group); + + String getServiceDefinition(String serviceKey); + +} + + +在 MetadataService 接口中定义的都是查询元数据的方法,在其子接口 WritableMetadataService 中添加了一些发布元数据的写方法,具体定义如下: + +@SPI(DEFAULT_METADATA_STORAGE_TYPE) + +public interface WritableMetadataService extends MetadataService { + + @Override + + default String serviceName() { + + // ServiceName默认是从ApplicationModel中获取 + + // ExtensionLoader、DubboBootstrap以及ApplicationModel是单个Dubbo进程范围内的单例对象, + + // ExtensionLoader用于Dubbo SPI机制加载扩展实现,DubboBootstrap用于启动Dubbo进程, + + // ApplicationModel用于表示一个Dubbo实例,其中维护了多个ProviderModel对象表示当前Dubbo实例发布的服务, + + // 维护了多个ConsumerModel对象表示当前Dubbo实例引用的服务。 + + return ApplicationModel.getApplication(); + + } + + boolean exportURL(URL url); // 发布该URL所代表的服务 + + boolean unexportURL(URL url); // 注销该URL所代表的服务 + + default boolean refreshMetadata(String exportedRevision, String subscribedRevision) { + + return true; // 刷新元数据 + + } + + boolean subscribeURL(URL url); // 订阅该URL所代表的服务 + + boolean unsubscribeURL(URL url); // 取消订阅该URL所代表的服务 + + // 发布Provider端的ServiceDefinition + + void publishServiceDefinition(URL providerUrl); + + // 获取WritableMetadataService的默认扩展实现 + + static WritableMetadataService getDefaultExtension() { + + return getExtensionLoader(WritableMetadataService.class).getDefaultExtension(); + + } + + // 获取WritableMetadataService接口指定的扩展实现(无指定扩展名称,则返回默认扩展实现) + + static WritableMetadataService getExtension(String name) { + + return getExtensionLoader(WritableMetadataService.class).getOrDefaultExtension(name); + + } + +} + + +WritableMetadataService 接口被 @SPI 注解修饰,是一个扩展接口,在前面的继承关系图中也可以看出,它有两个比较基础的扩展实现,分别是 InMemoryWritableMetadataService(默认扩展实现) 和 RemoteWritableMetadataServiceDelegate,对应扩展名分别是 local 和 remote。 + +下面我们先来看 InMemoryWritableMetadataService 的实现,其中维护了三个核心集合。 + + +exportedServiceURLs(ConcurrentSkipListMap`> 类型):用于记录当前 ServiceInstance 发布的 URL 集合,其中 Key 是 ServiceKey(即 interface、group 和 version 三部分构成),Value 是对应的 URL 集合。 +subscribedServiceURLs(ConcurrentSkipListMap`> 类型):用于记录当前 ServiceInstance 引用的 URL 集合,其中 Key 是 ServiceKey(即 interface、group 和 version 三部分构成),Value 是对应的 URL 集合。 +serviceDefinitions(ConcurrentSkipListMap 类型):用于记录当前 ServiceInstance 发布的 ServiceDefinition 信息,其中 Key 为 Provider URL 的ServiceKey,Value 为对应的 ServiceDefinition 对象序列化之后的 JSON 字符串。 + + +InMemoryWritableMetadataService 对 getExportedURLs()、getSubscribedURLs() 以及 getServiceDefinition() 方法的实现,就是查询上述三个集合的数据;对 (un)exportURL()、(un)subscribeURL() 和 publishServiceDefinition() 方法的实现,就是增删上述三个集合的数据。 + +(un)exportURL()、(un)subscribeURL() 等方法都是非常简单的集合操作,我们就不再展示,你若感兴趣的话可以参考源码进行学习。 这里我们重点来看一下 publishServiceDefinition() 方法对 ServiceDefinition 的处理: + +public void publishServiceDefinition(URL providerUrl) { + + // 获取服务接口 + + String interfaceName = providerUrl.getParameter(INTERFACE_KEY); + + if (StringUtils.isNotEmpty(interfaceName) + + && !ProtocolUtils.isGeneric(providerUrl.getParameter(GENERIC_KEY))) { + + Class interfaceClass = Class.forName(interfaceName); + + // 创建服务接口对应的ServiceDefinition对象 + + ServiceDefinition serviceDefinition = ServiceDefinitionBuilder.build(interfaceClass); + + Gson gson = new Gson(); + + // 将ServiceDefinition对象序列化为JSON对象 + + String data = gson.toJson(serviceDefinition); + + // 将ServiceDefinition对象序列化之后的JSON字符串记录到serviceDefinitions集合 + + serviceDefinitions.put(providerUrl.getServiceKey(), data); + + return; + + } + +} + + +在 RemoteWritableMetadataService 实现中封装了一个 InMemoryWritableMetadataService 对象,并对 publishServiceDefinition() 方法进行了覆盖,具体实现如下: + +public void publishServiceDefinition(URL url) { + + // 获取URL中的side参数值,决定调用publishProvider()还是publishConsumer()方法 + + String side = url.getParameter(SIDE_KEY); + + if (PROVIDER_SIDE.equalsIgnoreCase(side)) { + + publishProvider(url); + + } else { + + publishConsumer(url); + + } + +} + + +在 publishProvider() 方法中,首先会根据 Provider URL 创建对应的 FullServiceDefinition 对象,然后通过 MetadataReport 进行上报,具体实现如下: + +private void publishProvider(URL providerUrl) throws RpcException { + + // 删除pid、timestamp、bind.ip、bind.port等参数 + + providerUrl = providerUrl.removeParameters(PID_KEY, TIMESTAMP_KEY, Constants.BIND_IP_KEY, + + Constants.BIND_PORT_KEY, TIMESTAMP_KEY); + + // 获取服务接口名称 + + String interfaceName = providerUrl.getParameter(INTERFACE_KEY); + + if (StringUtils.isNotEmpty(interfaceName)) { + + Class interfaceClass = Class.forName(interfaceName); // 反射 + + // 创建服务接口对应的FullServiceDefinition对象,URL中的参数会记录到FullServiceDefinition的params集合中 + + FullServiceDefinition fullServiceDefinition = ServiceDefinitionBuilder.buildFullDefinition(interfaceClass, + + providerUrl.getParameters()); + + // 获取MetadataReport并上报FullServiceDefinition + + getMetadataReport().storeProviderMetadata(new MetadataIdentifier(providerUrl.getServiceInterface(), + + providerUrl.getParameter(VERSION_KEY), providerUrl.getParameter(GROUP_KEY), + + PROVIDER_SIDE, providerUrl.getParameter(APPLICATION_KEY)), fullServiceDefinition); + + return; + + } + +} + + +publishConsumer() 方法则相对比较简单:首先会清理 Consumer URL 中 pid、timestamp 等参数,然后将 Consumer URL 中的参数集合进行上报。 + +不过,在 RemoteWritableMetadataService 中的 exportURL()、subscribeURL()、getExportedURLs()、getServiceDefinition() 等一系列方法都是空实现,这是为什么呢?其实我们从 RemoteWritableMetadataServiceDelegate 中就可以找到答案,注意,RemoteWritableMetadataServiceDelegate 才是 MetadataService 接口的 remote 扩展实现。 + +在 RemoteWritableMetadataServiceDelegate 中同时维护了一个 InMemoryWritableMetadataService 对象和 RemoteWritableMetadataService 对象,exportURL()、subscribeURL() 等发布订阅相关的方法会同时委托给这两个 MetadataService 对象,getExportedURLs()、getServiceDefinition() 等查询方法则只会调用 InMemoryWritableMetadataService 对象进行查询。这里我们以 exportURL() 方法为例进行说明: + +public boolean exportURL(URL url) { + + return doFunction(WritableMetadataService::exportURL, url); + +} + +private boolean doFunction(BiFunction func, URL url) { + + // 同时调用InMemoryWritableMetadataService对象和RemoteWritableMetadataService对象的exportURL()方法 + + return func.apply(defaultWritableMetadataService, url) && func.apply(remoteWritableMetadataService, url); + +} + + +MetadataReport + +元数据中心是 Dubbo 2.7.0 版本之后新增的一项优化,其主要目的是将 URL 中的一部分内容存储到元数据中心,从而减少注册中心的压力。 + +元数据中心的数据只是给本端自己使用的,改动不需要告知对端,例如,Provider 修改了元数据,不需要实时通知 Consumer。这样,在注册中心存储的数据量减少的同时,还减少了因为配置修改导致的注册中心频繁通知监听者情况的发生,很好地减轻了注册中心的压力。 + +MetadataReport 接口是 Dubbo 节点与元数据中心交互的桥梁,其继承关系如下图所示: + + + +MetadataReport 继承关系图 + +我们先来看一下 MetadataReport 接口的核心定义: + +public interface MetadataReport { + + // 存储Provider元数据 + + void storeProviderMetadata(MetadataIdentifier providerMetadataIdentifier, ServiceDefinition serviceDefinition); + + // 存储Consumer元数据 + + void storeConsumerMetadata(MetadataIdentifier consumerMetadataIdentifier, Map serviceParameterMap); + + // 存储、删除Service元数据 + + void saveServiceMetadata(ServiceMetadataIdentifier metadataIdentifier, URL url); + + void removeServiceMetadata(ServiceMetadataIdentifier metadataIdentifier); + + // 查询暴露的URL + + List getExportedURLs(ServiceMetadataIdentifier metadataIdentifier); + + // 查询订阅数据 + + void saveSubscribedData(SubscriberMetadataIdentifier subscriberMetadataIdentifier, Set urls); + + List getSubscribedURLs(SubscriberMetadataIdentifier subscriberMetadataIdentifier); + + // 查询ServiceDefinition + + String getServiceDefinition(MetadataIdentifier metadataIdentifier); + +} + + +了解了 MetadataReport 接口定义的核心行为之后,接下来我们就按照其实现的顺序来介绍:先来分析 AbstractMetadataReport 抽象类提供的公共实现,然后以 ZookeeperMetadataReport 这个具体实现为例,介绍 MetadataReport 如何与 ZooKeeper 配合实现元数据上报。 + +1. AbstractMetadataReport + +AbstractMetadataReport 中提供了所有 MetadataReport 的公共实现,其核心字段如下: + +private URL reportURL; // 元数据中心的URL,其中包含元数据中心的地址 + +// 本地磁盘缓存,用来缓存上报的元数据 + +File file; + +final Properties properties = new Properties(); + +// 内存缓存 + +final Map allMetadataReports = new ConcurrentHashMap<>(4); + +// 该线程池除了用来同步本地内存缓存与文件缓存,还会用来完成异步上报的功能 + +private final ExecutorService reportCacheExecutor = Executors.newFixedThreadPool(1, new NamedThreadFactory("DubboSaveMetadataReport", true)); + +// 用来暂存上报失败的元数据,后面会有定时任务进行重试 + +final Map failedReports = new ConcurrentHashMap<>(4); + +boolean syncReport; // 是否同步上报元数据 + +// 记录最近一次元数据上报的版本,单调递增 + +private final AtomicLong lastCacheChanged = new AtomicLong(); + +// 用于重试的定时任务 + +public MetadataReportRetry metadataReportRetry; + +// 当前MetadataReport实例是否已经初始化 + +private AtomicBoolean initialized = new AtomicBoolean(false); + + +在 AbstractMetadataReport 的构造方法中,首先会初始化本地的文件缓存,然后创建 MetadataReportRetry 重试任务,并启动一个周期性刷新的定时任务,具体实现如下: + +public AbstractMetadataReport(URL reportServerURL) { + + setUrl(reportServerURL); + + // 默认的本地文件缓存 + + String defaultFilename = System.getProperty("user.home") + "/.dubbo/dubbo-metadata-" + reportServerURL.getParameter(APPLICATION_KEY) + "-" + reportServerURL.getAddress().replaceAll(":", "-") + ".cache"; + + String filename = reportServerURL.getParameter(FILE_KEY, defaultFilename); + + File file = null; + + if (ConfigUtils.isNotEmpty(filename)) { + + file = new File(filename); + + if (!file.exists() && file.getParentFile() != null && !file.getParentFile().exists()) { + + if (!file.getParentFile().mkdirs()) { + + throw new IllegalArgumentException("..."); + + } + + } + + if (!initialized.getAndSet(true) && file.exists()) { + + file.delete(); + + } + + } + + this.file = file; + + // 将file文件中的内容加载到properties字段中 + + loadProperties(); + + // 是否同步上报元数据 + + syncReport = reportServerURL.getParameter(SYNC_REPORT_KEY, false); + + // 创建重试任务 + + metadataReportRetry = new MetadataReportRetry(reportServerURL.getParameter(RETRY_TIMES_KEY, DEFAULT_METADATA_REPORT_RETRY_TIMES), + + reportServerURL.getParameter(RETRY_PERIOD_KEY, DEFAULT_METADATA_REPORT_RETRY_PERIOD)); + + // 是否周期性地上报元数据 + + if (reportServerURL.getParameter(CYCLE_REPORT_KEY, DEFAULT_METADATA_REPORT_CYCLE_REPORT)) { + + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("DubboMetadataReportTimer", true)); + + // 默认每隔1天将本地元数据全部刷新到元数据中心 + + scheduler.scheduleAtFixedRate(this::publishAll, calculateStartTime(), ONE_DAY_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + + } + +} + + +在 AbstractMetadataReport.storeProviderMetadata() 方法中,首先会根据 syncReport 字段值决定是同步上报还是异步上报:如果是同步上报,则在当前线程执行上报操作;如果是异步上报,则在 reportCacheExecutor 线程池中执行上报操作。具体的上报操作是在storeProviderMetadataTask() 方法中完成的: + +private void storeProviderMetadataTask(MetadataIdentifier providerMetadataIdentifier, ServiceDefinition serviceDefinition) { + + try { + + // 将元数据记录到allMetadataReports集合 + + allMetadataReports.put(providerMetadataIdentifier, serviceDefinition); + + // 如果之前上报失败,则在failedReports集合中有记录,这里上报成功之后会将其删除 + + failedReports.remove(providerMetadataIdentifier); + + // 将元数据序列化成JSON字符串 + + Gson gson = new Gson(); + + String data = gson.toJson(serviceDefinition); + + // 上报序列化后的元数据 + + doStoreProviderMetadata(providerMetadataIdentifier, data); + + // 将序列化后的元数据保存到本地文件缓存中 + + saveProperties(providerMetadataIdentifier, data, true, !syncReport); + + } catch (Exception e) { + + // 如果上报失败,则在failedReports集合中进行记录,然后由metadataReportRetry任务中进行重试 + + failedReports.put(providerMetadataIdentifier, serviceDefinition); + + metadataReportRetry.startRetryTask(); + + } + +} + + +我们可以看到这里调用了 doStoreProviderMetadata() 方法和 saveProperties() 方法。其中, doStoreProviderMetadata() 方法是一个抽象方法,对于不同的元数据中心实现有不同的实现,这个方法的具体实现在后面会展开分析。saveProperties() 方法中会更新 properties 字段,递增本地缓存文件的版本号,最后(同步/异步)执行 SaveProperties 任务,更新本地缓存文件的内容,具体实现如下: + +private void saveProperties(MetadataIdentifier metadataIdentifier, String value, boolean add, boolean sync) { + + if (file == null) { + + return; + + } + + if (add) { // 更新properties中的元数据 + + properties.setProperty(metadataIdentifier.getUniqueKey(KeyTypeEnum.UNIQUE_KEY), value); + + } else { + + properties.remove(metadataIdentifier.getUniqueKey(KeyTypeEnum.UNIQUE_KEY)); + + } + + // 递增版本 + + long version = lastCacheChanged.incrementAndGet(); + + if (sync) { // 同步更新本地缓存文件 + + new SaveProperties(version).run(); + + } else { // 异步更新本地缓存文件 + + reportCacheExecutor.execute(new SaveProperties(version)); + + } + +} + + +下面我们再来看 SaveProperties 任务的核心方法—— doSaveProperties() 方法,该方法中实现了刷新本地缓存文件的全部操作。 + +private void doSaveProperties(long version) { + + if (version < lastCacheChanged.get()) { // 对比当前版本号和此次SaveProperties任务的版本号 + + return; + + } + + if (file == null) { // 检测本地缓存文件是否存在 + + return; + + } + + try { + + // 创建lock文件 + + File lockfile = new File(file.getAbsolutePath() + ".lock"); + + if (!lockfile.exists()) { + + lockfile.createNewFile(); + + } + + try (RandomAccessFile raf = new RandomAccessFile(lockfile, "rw"); + + FileChannel channel = raf.getChannel()) { + + FileLock lock = channel.tryLock(); // 对lock文件加锁 + + if (lock == null) { + + throw new IOException("Can not lock the metadataReport cache file " + file.getAbsolutePath() + ", ignore and retry later, maybe multi java process use the file, please config: dubbo.metadata.file=xxx.properties"); + + } + + try { + + if (!file.exists()) { // 保证本地缓存文件存在 + + file.createNewFile(); + + } + + // 将properties中的元数据保存到本地缓存文件中 + + try (FileOutputStream outputFile = new FileOutputStream(file)) { + + properties.store(outputFile, "Dubbo metadataReport Cache"); + + } + + } finally { + + lock.release(); // 释放lock文件上的锁 + + } + + } + + } catch (Throwable e) { + + if (version < lastCacheChanged.get()) { // 比较版本号 + + return; + + } else { // 如果写文件失败,则重新提交SaveProperties任务,再次尝试 + + reportCacheExecutor.execute(new SaveProperties(lastCacheChanged.incrementAndGet())); + + } + + } + +} + + +了解了刷新本地缓存文件的核心逻辑之后,我们再来看 AbstractMetadataReport 中失败重试的逻辑。MetadataReportRetry 中维护了如下核心字段: + +// 执行重试任务的线程池 + +ScheduledExecutorService retryExecutor = Executors.newScheduledThreadPool(0, new NamedThreadFactory("DubboMetadataReportRetryTimer", true)); + +// 重试任务关联的Future对象 + +volatile ScheduledFuture retryScheduledFuture; + +// 记录重试任务的次数 + +final AtomicInteger retryCounter = new AtomicInteger(0); + +// 重试任务的时间间隔 + +long retryPeriod; + +// 无失败上报的元数据之后,重试任务会再执行600次,才会销毁 + +int retryTimesIfNonFail = 600; + +// 失败重试的次数上限,默认为100次,即重试失败100次之后会放弃 + +int retryLimit; + + +在 startRetryTask() 方法中,MetadataReportRetry 会创建一个重试任务,并提交到 retryExecutor 线程池中等待执行(如果已存在重试任务,则不会创建新任务)。在重试任务中会调用 AbstractMetadataReport.retry() 方法完成重新上报,当然也会判断 retryLimit 等执行条件,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +AbstractMetadataReport.retry() 方法的具体实现如下: + +public boolean retry() { + + return doHandleMetadataCollection(failedReports); + +} + +private boolean doHandleMetadataCollection(Map metadataMap) { + + if (metadataMap.isEmpty()) { // 没有上报失败的元数据 + + return true; + + } + + // 遍历failedReports集合中失败上报的元数据,逐个调用storeProviderMetadata()方法或storeConsumerMetadata()方法重新上报 + + Iterator> iterable = metadataMap.entrySet().iterator(); + + while (iterable.hasNext()) { + + Map.Entry item = iterable.next(); + + if (PROVIDER_SIDE.equals(item.getKey().getSide())) { + + this.storeProviderMetadata(item.getKey(), (FullServiceDefinition) item.getValue()); + + } else if (CONSUMER_SIDE.equals(item.getKey().getSide())) { + + this.storeConsumerMetadata(item.getKey(), (Map) item.getValue()); + + } + + } + + return false; + +} + + +在 AbstractMetadataReport 的构造方法中,会根据 reportServerURL(也就是后面的 metadataReportURL)参数启动一个“天”级别的定时任务,该定时任务会执行 publishAll() 方法,其中会通过 doHandleMetadataCollection() 方法将 allMetadataReports 集合中的全部元数据重新进行上报。该定时任务默认是在凌晨 02:00~06:00 启动,每天执行一次。 + +到此为止,AbstractMetadataReport 为子类实现的公共能力就介绍完了,其他方法都是委托给了相应的 do() 方法,这些 do() 方法都是在 AbstractMetadataReport 子类中实现的。 + + + +2. BaseMetadataIdentifier + +在 AbstractMetadataReport 上报元数据的时候,元数据对应的 Key 都是BaseMetadataIdentifier 类型的对象,其继承关系如下图所示: + + + +BaseMetadataIdentifier 继承关系图 + + +MetadataIdentifier 中包含了服务接口、version、group、side 和 application 五个核心字段。 +ServiceMetadataIdentifier 中包含了服务接口、version、group、side、revision 和 protocol 六个核心字段。 +SubscriberMetadataIdentifier 中包含了服务接口、version、group、side 和 revision 五个核心字段。 + + +3. MetadataReportFactory & MetadataReportInstance + +MetadataReportFactory 是用来创建 MetadataReport 实例的工厂,具体定义如下: + +@SPI("redis") + +public interface MetadataReportFactory { + + @Adaptive({"protocol"}) + + MetadataReport getMetadataReport(URL url); + +} + + +MetadataReportFactory 是个扩展接口,从 @SPI 注解的默认值可以看出Dubbo 默认使用 Redis 实现元数据中心。 +Dubbo 提供了针对 ZooKeeper、Redis、Consul 等作为元数据中心的 MetadataReportFactory 实现,如下图所示: + + + +MetadataReportFactory 继承关系图 + +这些 MetadataReportFactory 实现都继承了 AbstractMetadataReportFactory,在 AbstractMetadataReportFactory 提供了缓存 MetadataReport 实现的功能,并定义了一个 createMetadataReport() 抽象方法供子类实现。另外,AbstractMetadataReportFactory 实现了 MetadataReportFactory 接口的 getMetadataReport() 方法,下面我们就来简单看一下该方法的实现: + +public MetadataReport getMetadataReport(URL url) { + + // 清理export、refer参数 + + url = url.setPath(MetadataReport.class.getName()) + + .removeParameters(EXPORT_KEY, REFER_KEY); + + String key = url.toServiceString(); + + LOCK.lock(); + + try { + + // 从SERVICE_STORE_MAP集合(ConcurrentHashMap类型)中查询是否已经缓存有对应的MetadataReport对象 + + MetadataReport metadataReport = SERVICE_STORE_MAP.get(key); + + if (metadataReport != null) { // 直接返回缓存的MetadataReport对象 + + return metadataReport; + + } + + // 创建新的MetadataReport对象,createMetadataReport()方法由子类具体实现 + + metadataReport = createMetadataReport(url); + + // 将MetadataReport缓存到SERVICE_STORE_MAP集合中 + + SERVICE_STORE_MAP.put(key, metadataReport); + + return metadataReport; + + } finally { + + LOCK.unlock(); + + } + +} + + +MetadataReportInstance 是一个单例对象,其中会获取 MetadataReportFactory 的适配器,并根据 init() 方法传入的 metadataReportURL 选择对应的 MetadataReportFactory 创建 MetadataReport 实例,这也是当前 Dubbo 进程全局唯一的 MetadataReport 实例。 + +MetadataReportInstance 的具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +4. ZookeeperMetadataReport + +下面我们来看 dubbo-metadata-report-zookeeper 模块是如何接入 ZooKeeper 作为元数据中心的。 + +我们首先关注 dubbo-metadata-report-zookeeper 模块的 SPI 文件,可以看到 ZookeeperMetadataReportFactory 的扩展名称是 zookeeper: + +zookeeper=org.apache.dubbo.metadata.store.zookeeper.ZookeeperMetadataReportFactory + + +在 ZookeeperMetadataReportFactory 的 createMetadataReport() 方法中会创建 ZookeeperMetadataReport 这个 MetadataReport 实现类的对象。 + +在 ZookeeperMetadataReport 中维护了一个 ZookeeperClient 实例用来和 ZooKeeper 进行交互。ZookeeperMetadataReport 读写元数据的根目录是 metadataReportURL 的 group 参数值,默认值为 dubbo。 + +下面再来看 ZookeeperMetadataReport 对 AbstractMetadataReport 中各个 do*() 方法的实现,这些方法核心都是通过 ZookeeperClient 创建、查询、删除对应的 ZNode 节点,没有什么复杂的逻辑,关键是明确一下操作的 ZNode 节点的 path 是什么。 + +doStoreProviderMetadata() 方法和 doStoreConsumerMetadata() 方法会调用 storeMetadata() 创建相应的 ZNode 节点: + +private void storeMetadata(MetadataIdentifier metadataIdentifier, String v) { + + zkClient.create(getNodePath(metadataIdentifier), v, false); + +} + +String getNodePath(BaseMetadataIdentifier metadataIdentifier) { + + return toRootDir() + metadataIdentifier.getUniqueKey(KeyTypeEnum.PATH); + +} + + +MetadataIdentifier 对象对应 ZNode 节点的 path 默认格式是 : + +/dubbo/metadata/服务接口/version/group/side/application + + +对应 ZNode 节点的 Value 是 ServiceDefinition 序列化后的 JSON 字符串。 + +doSaveMetadata()、doRemoveMetadata() 以及 doGetExportedURLs() 方法参数是 ServiceMetadataIdentifier 对象,对应的 ZNode 节点 path 是: + +/dubbo/metadata/服务接口/version/group/side/protocol/revision + + +doSaveSubscriberData()、doGetSubscribedURLs() 方法的参数是 SubscriberMetadataIdentifier 对象,对应的 ZNode 节点 path 是: + +/dubbo/metadata/服务接口/version/group/side/revision + + +MetadataServiceExporter + +了解了 MetadataService 接口的核心功能和底层实现之后,我们接着再来看 MetadataServiceExporter 接口,这个接口负责将 MetadataService 接口作为一个 Dubbo 服务发布出去。 + +下面来看 MetadataServiceExporter 接口的具体定义: + +public interface MetadataServiceExporter { + + // 将MetadataService作为一个Dubbo服务发布出去 + + MetadataServiceExporter export(); + + // 注销掉MetadataService服务 + + MetadataServiceExporter unexport(); + + // MetadataService可能以多种协议发布,这里返回发布MetadataService服务的所有URL + + List getExportedURLs(); + + // 检测MetadataService服务是否已经发布 + + boolean isExported(); + +} + + +MetadataServiceExporter 只有 ConfigurableMetadataServiceExporter 这一个实现,如下图所示: + + + +MetadataServiceExporter 继承关系图 + +ConfigurableMetadataServiceExporter 的核心实现是 export() 方法,其中会创建一个 ServiceConfig 对象完成 MetadataService 服务的发布: + +public ConfigurableMetadataServiceExporter export() { + + if (!isExported()) { + + // 创建ServiceConfig对象 + + ServiceConfig serviceConfig = new ServiceConfig<>(); + + serviceConfig.setApplication(getApplicationConfig()); + + serviceConfig.setRegistries(getRegistries()); + + serviceConfig.setProtocol(generateMetadataProtocol()); // 设置Protocol(默认是Dubbo) + + serviceConfig.setInterface(MetadataService.class); // 设置服务接口 + + serviceConfig.setRef(metadataService); // 设置MetadataService对象 + + serviceConfig.setGroup(getApplicationConfig().getName()); // 设置group + + serviceConfig.setVersion(metadataService.version()); // 设置version + + // 发布MetadataService服务,ServiceConfig发布服务的流程在前面已经详细分析过了,这里不再展开 + + serviceConfig.export(); + + this.serviceConfig = serviceConfig; + + } else { + + ... // 输出日志 + + } + + return this; + +} + + +ServiceNameMapping + +ServiceNameMapping 接口的主要功能是实现 Service ID 到 Service Name 之间的转换,底层会依赖配置中心实现数据存储和查询。ServiceNameMapping 接口的定义如下: + +@SPI("default") + +public interface ServiceNameMapping { + + // 服务接口、group、version、protocol四部分构成了Service ID,并与当前Service Name之间形成映射,记录到配置中心 + + void map(String serviceInterface, String group, String version, String protocol); + + // 根据服务接口、group、version、protocol四部分构成的Service ID,查询对应的Service Name + + Set get(String serviceInterface, String group, String version, String protocol); + + // 获取默认的ServiceNameMapping接口的扩展实现 + + static ServiceNameMapping getDefaultExtension() { + + return getExtensionLoader(ServiceNameMapping.class).getDefaultExtension(); + + } + +} + + +DynamicConfigurationServiceNameMapping 是 ServiceNameMapping 的默认实现,也是唯一实现,其中会依赖 DynamicConfiguration 读写配置中心,完成 Service ID 和 Service Name 的映射。首先来看 DynamicConfigurationServiceNameMapping 的 map() 方法: + +public void map(String serviceInterface, String group, String version, String protocol) { + + // 跳过MetadataService接口的处理 + + if (IGNORED_SERVICE_INTERFACES.contains(serviceInterface)) { + + return; + + } + + // 获取DynamicConfiguration对象 + + DynamicConfiguration dynamicConfiguration = DynamicConfiguration.getDynamicConfiguration(); + + // 从ApplicationModel中获取Service Name + + String key = getName(); + + String content = valueOf(System.currentTimeMillis()); + + execute(() -> { + + // 在配置中心创建映射关系,这里的buildGroup()方法虽然接收四个参数,但是只使用了serviceInterface + + // 也就是使用创建了服务接口到Service Name的映射 + + // 可以暂时将配置中心理解为一个KV存储,这里的Key是buildGroup()方法返回值+Service Name构成的,value是content(即时间戳) + + dynamicConfiguration.publishConfig(key, buildGroup(serviceInterface, group, version, protocol), content); + + }); + +} + + +在 DynamicConfigurationServiceNameMapping.get() 方法中,会根据传入的服务接口名称、group、version、protocol 组成 Service ID,查找对应的 Service Name,如下所示: + +public Set get(String serviceInterface, String group, String version, String protocol) { + + // 获取DynamicConfiguration对象 + DynamicConfiguration dynamicConfiguration = DynamicConfiguration.getDynamicConfiguration(); + + // 根据Service ID从配置查找Service Name + Set serviceNames = new LinkedHashSet<>(); + + execute(() -> { + Set keys = dynamicConfiguration.getConfigKeys(buildGroup(serviceInterface, group, version, protocol)); + serviceNames.addAll(keys); + + }); + + // 返回查找到的全部Service Name + return Collections.unmodifiableSet(serviceNames); + +} + + +总结 + +本课时我们重点介绍了服务自省架构中元数据相关的实现。 + + +首先我们介绍了 ServiceInstance 和 ServiceDefinition 是如何抽象一个服务实例以及服务定义的。 +紧接着讲解了元数据服务接口的定义,也就是 MetadataService 接口及核心的扩展实现。 +接下来详细分析了 MetadataReport 接口的实现,了解了它是如何与元数据中心配合,实现元数据上报功能的。 +然后还说明了 MetadataServiceExporter 的实现,了解了发布元数据服务的核心原理。 +最后,我们介绍了 ServiceNameMapping 接口以及其默认实现,它实现了 Service ID 与 Service Name 的映射,也是服务自省架构中不可或缺的一部分。 + + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/45加餐:深入服务自省方案中的服务发布订阅(上).md b/专栏/Dubbo源码解读与实战-完/45加餐:深入服务自省方案中的服务发布订阅(上).md new file mode 100644 index 0000000..9c8954b --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/45加餐:深入服务自省方案中的服务发布订阅(上).md @@ -0,0 +1,337 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 45 加餐:深入服务自省方案中的服务发布订阅(上) + 在前面[第 43 课时]中介绍 Dubbo 的服务自省方案时,我们可以看到除了需要元数据方案的支持之外,还需要服务发布订阅功能的支持,这样才能构成完整的服务自省架构。 + +本课时我们就来讲解一下 Dubbo 中服务实例的发布与订阅功能的具体实现:首先说明 ServiceDiscovery 接口的核心定义,然后再重点介绍以 ZooKeeper 为注册中心的 ZookeeperServiceDiscovery 实现,这其中还会涉及相关事件监听的实现。 + +ServiceDiscovery 接口 + +ServiceDiscovery 主要封装了针对 ServiceInstance 的发布和订阅操作,你可以暂时将其理解成一个 ServiceInstance 的注册中心。ServiceDiscovery 接口的定义如下所示: + +@SPI("zookeeper") +public interface ServiceDiscovery extends Prioritized { + // 初始化当前ServiceDiscovery实例,传入的是注册中心的URL + void initialize(URL registryURL) throws Exception; + // 销毁当前ServiceDiscovery实例 + void destroy() throws Exception; + // 发布传入的ServiceInstance实例 + void register(ServiceInstance serviceInstance) throws RuntimeException; + // 更新传入的ServiceInstance实例 + void update(ServiceInstance serviceInstance) throws RuntimeException; + // 注销传入的ServiceInstance实例 + void unregister(ServiceInstance serviceInstance) throws RuntimeException; + // 查询全部Service Name + Set getServices(); + // 分页查询时默认每页的条数 + default int getDefaultPageSize() { + return 100; + } + // 根据ServiceName分页查询ServiceInstance + default List getInstances(String serviceName) throws NullPointerException { + List allInstances = new LinkedList<>(); + int offset = 0; + int pageSize = getDefaultPageSize(); + // 分页查询ServiceInstance + Page page = getInstances(serviceName, offset, pageSize); + allInstances.addAll(page.getData()); + while (page.hasNext()) { + offset += page.getDataSize(); + page = getInstances(serviceName, offset, pageSize); + allInstances.addAll(page.getData()); + } + return unmodifiableList(allInstances); + } + default Page getInstances(String serviceName, int offset, int pageSize) throws NullPointerException, + IllegalArgumentException { + return getInstances(serviceName, offset, pageSize, false); + } + default Page getInstances(String serviceName, int offset, int pageSize, boolean healthyOnly) throws + NullPointerException, IllegalArgumentException, UnsupportedOperationException { + throw new UnsupportedOperationException("Current implementation does not support pagination query method."); + } + default Map> getInstances(Iterable serviceNames, int offset, int requestSize) throws + NullPointerException, IllegalArgumentException { + Map> instances = new LinkedHashMap<>(); + for (String serviceName : serviceNames) { + instances.put(serviceName, getInstances(serviceName, offset, requestSize)); + } + return unmodifiableMap(instances); + } + // 添加ServiceInstance监听器 + default void addServiceInstancesChangedListener(ServiceInstancesChangedListener listener) + throws NullPointerException, IllegalArgumentException { + } + // 触发ServiceInstancesChangedEvent事件 + default void dispatchServiceInstancesChangedEvent(String serviceName) { + dispatchServiceInstancesChangedEvent(serviceName, getInstances(serviceName)); + } + default void dispatchServiceInstancesChangedEvent(String serviceName, String... otherServiceNames) { + dispatchServiceInstancesChangedEvent(serviceName, getInstances(serviceName)); + if (otherServiceNames != null) { + Stream.of(otherServiceNames) + .filter(StringUtils::isNotEmpty) + .forEach(this::dispatchServiceInstancesChangedEvent); + } + } + default void dispatchServiceInstancesChangedEvent(String serviceName, Collection serviceInstances) { + dispatchServiceInstancesChangedEvent(new ServiceInstancesChangedEvent(serviceName, serviceInstances)); + } + default void dispatchServiceInstancesChangedEvent(ServiceInstancesChangedEvent event) { + getDefaultExtension().dispatch(event); + } +} + + +ServiceDiscovery 接口被 @SPI 注解修饰,是一个扩展点,针对不同的注册中心,有不同的 ServiceDiscovery 实现,如下图所示: + + + +ServiceDiscovery 继承关系图 + +在 Dubbo 创建 ServiceDiscovery 对象的时候,会通过 ServiceDiscoveryFactory 工厂类进行创建。ServiceDiscoveryFactory 接口也是一个扩展接口,Dubbo 只提供了一个默认实现—— DefaultServiceDiscoveryFactory,其继承关系如下图所示: + + + +ServiceDiscoveryFactory 继承关系图 + +在 AbstractServiceDiscoveryFactory 中维护了一个 ConcurrentMap 类型的集合(discoveries 字段)来缓存 ServiceDiscovery 对象,并提供了一个 createDiscovery() 抽象方法来创建 ServiceDiscovery 实例。 + +public ServiceDiscovery getServiceDiscovery(URL registryURL) { + String key = registryURL.toServiceStringWithoutResolving(); + return discoveries.computeIfAbsent(key, k -> createDiscovery(registryURL)); +} + + +在 DefaultServiceDiscoveryFactory 中会实现 createDiscovery() 方法,使用 Dubbo SPI 机制获取对应的 ServiceDiscovery 对象,具体实现如下: + +protected ServiceDiscovery createDiscovery(URL registryURL) { + String protocol = registryURL.getProtocol(); + ExtensionLoader loader = getExtensionLoader(ServiceDiscovery.class); + return loader.getExtension(protocol); +} + + +ZookeeperServiceDiscovery 实现分析 + +Dubbo 提供了多个 ServiceDiscovery 用来接入多种注册中心,下面我们以 ZookeeperServiceDiscovery 为例介绍 Dubbo 是如何接入 ZooKeeper 作为注册中心,实现服务实例发布和订阅的。 + +在 ZookeeperServiceDiscovery 中封装了一个 Apache Curator 中的 ServiceDiscovery 对象来实现与 ZooKeeper 的交互。在 initialize() 方法中会初始化 CuratorFramework 以及 Curator ServiceDiscovery 对象,如下所示: + + public void initialize(URL registryURL) throws Exception { + ... // 省略初始化EventDispatcher的相关逻辑 + // 初始化CuratorFramework + this.curatorFramework = buildCuratorFramework(registryURL); + // 确定rootPath,默认是"/services" + this.rootPath = ROOT_PATH.getParameterValue(registryURL); + // 初始化Curator ServiceDiscovery并启动 + this.serviceDiscovery = buildServiceDiscovery(curatorFramework, rootPath); + this.serviceDiscovery.start(); +} + + +在 ZookeeperServiceDiscovery 中的方法基本都是调用 Curator ServiceDiscovery 对象的相应方法实现,例如,register()、update() 、unregister() 方法都会调用 Curator ServiceDiscovery 对象的相应方法完成 ServiceInstance 的添加、更新和删除。这里我们以 register() 方法为例: + +public void register(ServiceInstance serviceInstance) throws RuntimeException { + doInServiceRegistry(serviceDiscovery -> { + serviceDiscovery.registerService(build(serviceInstance)); + }); +} +// 在build()方法中会将Dubbo中的ServiceInstance对象转换成Curator中的ServiceInstance对象 +public static org.apache.curator.x.discovery.ServiceInstance build(ServiceInstance serviceInstance) { + ServiceInstanceBuilder builder = null; + // 获取Service Name + String serviceName = serviceInstance.getServiceName(); + String host = serviceInstance.getHost(); + int port = serviceInstance.getPort(); + // 获取元数据 + Map metadata = serviceInstance.getMetadata(); + // 生成的id格式是"host:ip" + String id = generateId(host, port); + // ZookeeperInstance是Curator ServiceInstance的payload + ZookeeperInstance zookeeperInstance = new ZookeeperInstance(null, serviceName, metadata); + builder = builder().id(id).name(serviceName).address(host).port(port) + .payload(zookeeperInstance); + return builder.build(); +} + + +除了上述服务实例发布的功能之外,在服务实例订阅的时候,还会用到 ZookeeperServiceDiscovery 查询服务实例的信息,这些方法都是直接依赖 Apache Curator 实现的,例如,getServices() 方法会调用 Curator ServiceDiscovery 的 queryForNames() 方法查询 Service Name,getInstances() 方法会通过 Curator ServiceDiscovery 的 queryForInstances() 方法查询 Service Instance。 + +EventListener 接口 + +ZookeeperServiceDiscovery 除了实现了 ServiceDiscovery 接口之外,还实现了 EventListener 接口,如下图所示: + + + +ZookeeperServiceDiscovery 继承关系图 + +也就是说,ZookeeperServiceDiscovery 本身也是 EventListener 实现,可以作为 EventListener 监听某些事件。下面我们先来看 Dubbo 中 EventListener 接口的定义,其中关注三个方法:onEvent() 方法、getPriority() 方法和 findEventType() 工具方法。 + +@SPI +@FunctionalInterface +public interface EventListener extends java.util.EventListener, Prioritized { + // 当发生该EventListener对象关注的事件时,该EventListener的onEvent()方法会被调用 + void onEvent(E event); + // 当前EventListener对象被调用的优先级 + default int getPriority() { + return MIN_PRIORITY; + } + // 获取传入的EventListener对象监听何种Event事件 + static Class findEventType(EventListener listener) { + return findEventType(listener.getClass()); + } + + static Class findEventType(Class listenerClass) { + Class eventType = null; + // 检测传入listenerClass是否为Dubbo的EventListener接口实现 + if (listenerClass != null && EventListener.class.isAssignableFrom(listenerClass)) { + eventType = findParameterizedTypes(listenerClass) + .stream() + .map(EventListener::findEventType) // 获取listenerClass中定义的Event泛型 + .filter(Objects::nonNull) + .findAny() + // 获取listenerClass父类中定义的Event泛型 + .orElse((Class) findEventType(listenerClass.getSuperclass())); + } + return eventType; + } + ... // findEventType()方法用来过滤传入的parameterizedType是否为Event或Event子类(这里省略该方法的实现) +} + + +Dubbo 中有很多 EventListener 接口的实现,如下图所示: + + + +EventListener 继承关系图 + +我们先来重点关注 ZookeeperServiceDiscovery 这个实现,在其 onEvent() 方法(以及 addServiceInstancesChangedListener() 方法)中会调用 registerServiceWatcher() 方法重新注册: + +public void onEvent(ServiceInstancesChangedEvent event) { + // 发生ServiceInstancesChangedEvent事件的Service Name + String serviceName = event.getServiceName(); + // 重新注册监听器 + registerServiceWatcher(serviceName); +} +protected void registerServiceWatcher(String serviceName) { + // 构造要监听的path + String path = buildServicePath(serviceName); + // 创建监听器ZookeeperServiceDiscoveryChangeWatcher并记录到watcherCaches缓存中 + CuratorWatcher watcher = watcherCaches.computeIfAbsent(path, key -> + new ZookeeperServiceDiscoveryChangeWatcher(this, serviceName)); + // 在path上添加上面构造的ZookeeperServiceDiscoveryChangeWatcher监听器, + // 来监听子节点的变化 + curatorFramework.getChildren().usingWatcher(watcher).forPath(path); +} + + +ZookeeperServiceDiscoveryChangeWatcher 是 ZookeeperServiceDiscovery 配套的 CuratorWatcher 实现,其中 process() 方法实现会关注 NodeChildrenChanged 事件和 NodeDataChanged 事件,并调用关联的 ZookeeperServiceDiscovery 对象的 dispatchServiceInstancesChangedEvent() 方法,具体实现如下: + +public void process(WatchedEvent event) throws Exception { + // 获取监听到的事件类型 + Watcher.Event.EventType eventType = event.getType(); + // 这里只关注NodeChildrenChanged和NodeDataChanged两种事件类型 + if (NodeChildrenChanged.equals(eventType) || NodeDataChanged.equals(eventType)) { + // 调用dispatchServiceInstancesChangedEvent()方法,分发ServiceInstancesChangedEvent事件 + zookeeperServiceDiscovery.dispatchServiceInstancesChangedEvent(serviceName); + } +} + + +通过上面的分析我们可以知道,ZookeeperServiceDiscoveryChangeWatcher 的核心就是将 ZooKeeper 中的事件转换成了 Dubbo 内部的 ServiceInstancesChangedEvent 事件。 + +EventDispatcher 接口 + +通过上面对 ZookeeperServiceDiscovery 实现的分析我们知道,它并没有对 dispatchServiceInstancesChangedEvent() 方法进行覆盖,那么在 ZookeeperServiceDiscoveryChangeWatcher 中调用的 dispatchServiceInstancesChangedEvent() 方法就是 ServiceDiscovery 接口中的默认实现。在该默认实现中,会通过 Dubbo SPI 获取 EventDispatcher 的默认实现,并分发 ServiceInstancesChangedEvent 事件,具体实现如下: + +default void dispatchServiceInstancesChangedEvent(ServiceInstancesChangedEvent event) { + EventDispatcher.getDefaultExtension().dispatch(event); +} + + +下面我们来看 EventDispatcher 接口的具体定义: + +@SPI("direct") +public interface EventDispatcher extends Listenable> { + // 该线程池用于串行调用被触发的EventListener,也就是direct模式 + Executor DIRECT_EXECUTOR = Runnable::run; + // 将被触发的事件分发给相应的EventListener对象 + void dispatch(Event event); + // 获取direct模式中使用的线程池 + default Executor getExecutor() { + return DIRECT_EXECUTOR; + } + // 工具方法,用于获取EventDispatcher接口的默认实现 + static EventDispatcher getDefaultExtension() { + return ExtensionLoader.getExtensionLoader(EventDispatcher.class).getDefaultExtension(); + } +} + + +EventDispatcher 接口被 @SPI 注解修饰,是一个扩展点,Dubbo 提供了两个具体实现——ParallelEventDispatcher 和 DirectEventDispatcher,如下图所示: + + + +EventDispatcher 继承关系图 + +在 AbstractEventDispatcher 中维护了两个核心字段。 + + +listenersCache(ConcurrentMap, List> 类型):用于记录监听各类型事件的 EventListener 集合。在 AbstractEventDispatcher 初始化时,会加载全部 EventListener 实现并调用 addEventListener() 方法添加到 listenersCache 集合中。 +executor(Executor 类型):该线程池在 AbstractEventDispatcher 的构造函数中初始化。在 AbstractEventDispatcher 收到相应事件时,由该线程池来触发对应的 EventListener 集合。 + + +AbstractEventDispatcher 中的 addEventListener()、removeEventListener()、getAllEventListeners() 方法都是通过操作 listenersCache 集合实现的,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +AbstractEventDispatcher 中另一个要关注的方法是 dispatch() 方法,该方法会从 listenersCache 集合中过滤出符合条件的 EventListener 对象,并按照串行或是并行模式进行通知,具体实现如下: + +public void dispatch(Event event) { + // 获取通知EventListener的线程池,默认为串行模式,也就是direct实现 + Executor executor = getExecutor(); + executor.execute(() -> { + sortedListeners(entry -> entry.getKey().isAssignableFrom(event.getClass())) + .forEach(listener -> { + if (listener instanceof ConditionalEventListener) { // 针对ConditionalEventListener的特殊处理 + ConditionalEventListener predicateEventListener = (ConditionalEventListener) listener; + if (!predicateEventListener.accept(event)) { + return; + } + } + // 通知EventListener + listener.onEvent(event); + }); + }); +} +// 这里的sortedListeners方法会对listenerCache进行过滤和排序 +protected Stream sortedListeners(Predicate, List>> predicate) { + return listenersCache + .entrySet() + .stream() + .filter(predicate) + .map(Map.Entry::getValue) + .flatMap(Collection::stream) + .sorted(); +} + + +AbstractEventDispatcher 已经实现了 EventDispatcher 分发 Event 事件、通知 EventListener 的核心逻辑,然后在 ParallelEventDispatcher 和 DirectEventDispatcher 确定是并行通知模式还是串行通知模式即可。 + +在 ParallelEventDispatcher 中通知 EventListener 的线程池是 ForkJoinPool,也就是并行模式;在 DirectEventDispatcher 中使用的是 EventDispatcher.DIRECT_EXECUTOR 线程池,也就是串行模式。这两个 EventDispatcher 的具体实现比较简单,这里就不再展示。 + +我们回到 ZookeeperServiceDiscovery,在其构造方法中会获取默认的 EventDispatcher 实现对象,并调用 addEventListener() 方法将 ZookeeperServiceDiscovery 对象添加到 listenersCache 集合中监听 ServiceInstancesChangedEvent 事件。ZookeeperServiceDiscovery 直接继承了 ServiceDiscovery 接口中 dispatchServiceInstancesChangedEvent() 方法的默认实现,并没有进行覆盖,在该方法中,会获取默认的 EventDispatcher 实现并调用 dispatch() 方法分发 ServiceInstancesChangedEvent 事件。 + +总结 + +在本课时,我们重点介绍了 Dubbo 服务自省方案中服务实例发布和订阅的基础。 + +首先,我们说明了 ServiceDiscovery 接口的核心定义,其中定义了服务实例发布和订阅的核心方法。接下来我们分析了以 ZooKeeper 作为注册中心的 ZookeeperServiceDiscovery 实现,其中还讲解了在 ZookeeperServiceDiscovery 上添加监听器的相关实现以及 ZookeeperServiceDiscovery 处理 ServiceInstancesChangedEvent 事件的机制。 + +下一课时,我们将继续介绍 Dubbo 服务自省方案中的服务实例发布以及订阅实现,记得按时来听课。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/46加餐:深入服务自省方案中的服务发布订阅(下).md b/专栏/Dubbo源码解读与实战-完/46加餐:深入服务自省方案中的服务发布订阅(下).md new file mode 100644 index 0000000..89553bc --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/46加餐:深入服务自省方案中的服务发布订阅(下).md @@ -0,0 +1,621 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 46 加餐:深入服务自省方案中的服务发布订阅(下) + 在课程第二部分(13~15 课时)中介绍 Dubbo 传统框架中的注册中心部分实现时,我们提到了 Registry、RegistryFactory 等与注册中心交互的接口。为了将 ServiceDiscovery 接口的功能与 Registry 融合,Dubbo 提供了一个 ServiceDiscoveryRegistry 实现,继承关系如下所示: + + + +ServiceDiscoveryRegistry 、ServiceDiscoveryRegistryFactory 继承关系图 + +由图我们可以看到:ServiceDiscoveryRegistryFactory(扩展名称是 service-discovery-registry)是 ServiceDiscoveryRegistry 对应的工厂类,继承了 AbstractRegistryFactory 提供的公共能力。 + +ServiceDiscoveryRegistry 是一个面向服务实例(ServiceInstance)的注册中心实现,其底层依赖前面两个课时介绍的 ServiceDiscovery、WritableMetadataService 等组件。 + +ServiceDiscoveryRegistry 中的核心字段有如下几个。 + + +serviceDiscovery(ServiceDiscovery 类型):用于 ServiceInstance 的发布和订阅。 +subscribedServices(Set 类型):记录了当前订阅的服务名称。 +serviceNameMapping(ServiceNameMapping 类型):用于 Service ID 与 Service Name 之间的转换。 +writableMetadataService(WritableMetadataService 类型):用于发布和查询元数据。 +registeredListeners(Set 类型):记录了注册的 ServiceInstancesChangedListener 的唯一标识。 +subscribedURLsSynthesizers(List 类型):将 ServiceInstance 的信息与元数据进行合并,得到订阅服务的完整 URL。 + + +在 ServiceDiscoveryRegistry 的构造方法中,会初始化上述字段: + +public ServiceDiscoveryRegistry(URL registryURL) { + // 初始化父类,其中包括FailbackRegistry中的时间轮和重试定时任务以及AbstractRegistry中的本地文件缓存等 + super(registryURL); + // 初始化ServiceDiscovery对象 + this.serviceDiscovery = createServiceDiscovery(registryURL); + // 从registryURL中解析出subscribed-services参数,并按照逗号切分,得到subscribedServices集合 + this.subscribedServices = parseServices(registryURL.getParameter(SUBSCRIBED_SERVICE_NAMES_KEY)); + // 获取DefaultServiceNameMapping对象 + this.serviceNameMapping = ServiceNameMapping.getDefaultExtension(); + // 初始化WritableMetadataService对象 + String metadataStorageType = getMetadataStorageType(registryURL); + this.writableMetadataService = WritableMetadataService.getExtension(metadataStorageType); + // 获取目前支持的全部SubscribedURLsSynthesizer实现,并初始化 + this.subscribedURLsSynthesizers = initSubscribedURLsSynthesizers(); +} + + +在 createServiceDiscovery() 方法中,不仅会加载 ServiceDiscovery 的相应实现,还会在外层添加 EventPublishingServiceDiscovery 装饰器,在 register()、initialize() 等方法前后触发相应的事件,具体实现如下: + +protected ServiceDiscovery createServiceDiscovery(URL registryURL) { + // 根据registryURL获取对应的ServiceDiscovery实现 + ServiceDiscovery originalServiceDiscovery = getServiceDiscovery(registryURL); + // ServiceDiscovery外层添加一层EventPublishingServiceDiscovery修饰器, + // EventPublishingServiceDiscovery会在register()、initialize()等方法前后触发相应的事件, + // 例如,在register()方法的前后分别会触发ServiceInstancePreRegisteredEvent和ServiceInstanceRegisteredEvent + ServiceDiscovery serviceDiscovery = enhanceEventPublishing(originalServiceDiscovery); + execute(() -> { // 初始化ServiceDiscovery + serviceDiscovery.initialize(registryURL.addParameter(INTERFACE_KEY, ServiceDiscovery.class.getName()) + .removeParameter(REGISTRY_TYPE_KEY)); + }); + return serviceDiscovery; +} + + +Registry 接口的核心是服务发布和订阅,ServiceDiscoveryRegistry 既然实现了 Registry 接口,必然也要实现了服务注册和发布的功能。 + +服务注册 + +在 ServiceDiscoveryRegistry 的 register() 中,首先会检测待发布 URL 中的 side 参数,然后调用父类的 register() 方法。我们知道 FailbackRegistry.register() 方法会回调子类的 doRegister() 方法,而 ServiceDiscoveryRegistry.doRegister() 方法直接依赖 WritableMetadataService 的 exportURL() 方法,完成元数据的发布。 + +public final void register(URL url) { + if (!shouldRegister(url)) { // 检测URL中的side参数是否为provider + return; + } + super.register(url); +} + +@Override +public void doRegister(URL url) { + // 将元数据发布到MetadataService + if (writableMetadataService.exportURL(url)) { + ... // 输出INFO日志 + } else { + ... // 输出WARN日志 + } +} + + +ServiceDiscoveryRegistry.unregister() 方法的实现逻辑也是类似的,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +服务订阅 + +接下来看 ServiceDiscoveryRegistry.subscribe() 方法的实现,其中也是首先会检测待发布 URL 中的 side 参数,然后调用父类的 subscribe() 方法。我们知道 FailbackRegistry.subscribe() 方法会回调子类的 doSubscribe() 方法。在 ServiceDiscoveryRegistry 的 doSubscribe() 方法中,会执行如下完整的订阅流程: + + +调用 WriteMetadataService.subscribeURL() 方法在 subscribedServiceURLs 集合中记录当前订阅的 URL; +通过订阅的 URL 获取 Service Name; +根据 Service Name 获取 ServiceInstance 集合; +根据 ServiceInstance 调用相应的 MetadataService 服务,获取元数据,其中涉及历史数据的清理和缓存更新等操作; +将 ServiceInstance 信息以及对应的元数据信息进行合并,得到完整的 URL; +触发 NotifyListener 监听器; +添加 ServiceInstancesChangedListener 监听器。 + + +下面来看 ServiceDiscoveryRegistry.doSubscribe() 方法的具体实现: + +protected void subscribeURLs(URL url, NotifyListener listener) { + // 记录该订阅的URL + writableMetadataService.subscribeURL(url); + // 获取订阅的Service Name + Set serviceNames = getServices(url); + if (CollectionUtils.isEmpty(serviceNames)) { + throw new IllegalStateException("..."); + } + // 执行后续的订阅操作 + serviceNames.forEach(serviceName -> subscribeURLs(url, listener, serviceName)); +} + + +我们这就展开一步步来解析上面的这个流程。 + +1. 获取 Service Name + +首先来看 getServices() 方法的具体实现:它会首先根据 subscribeURL 的 provided-by 参数值获取订阅的 Service Name 集合,如果获取失败,则根据 Service ID 获取对应的 Service Name 集合;如果此时依旧获取失败,则尝试从 registryURL 中的 subscribed-services 参数值获取 Service Name 集合。下面来看 getServices() 方法的具体实现: + +protected Set getServices(URL subscribedURL) { + Set subscribedServices = new LinkedHashSet<>(); + // 首先尝试从subscribeURL中获取provided-by参数值,其中封装了全部Service Name + String serviceNames = subscribedURL.getParameter(PROVIDED_BY); + if (StringUtils.isNotEmpty(serviceNames)) { + // 解析provided-by参数值,得到全部的Service Name集合 + subscribedServices = parseServices(serviceNames); + } + if (isEmpty(subscribedServices)) { + // 如果没有指定provided-by参数,则尝试通过subscribedURL构造Service ID, + // 然后通过ServiceNameMapping的get()方法查找Service Name + subscribedServices = findMappedServices(subscribedURL); + if (isEmpty(subscribedServices)) { + // 如果subscribedServices依旧为空,则返回registryURL中的subscribed-services参数值 + subscribedServices = getSubscribedServices(); + } + } + return subscribedServices; +} + + +2. 查找 Service Instance + +接下来看 subscribeURLs(url, listener, serviceName) 这个重载的具体实现,其中会根据 Service Name 从 ServiceDiscovery 中查找对应的 ServiceInstance 集合,以及注册ServiceInstancesChangedListener 监听。 + +protected void subscribeURLs(URL url, NotifyListener listener, String serviceName) { + // 根据Service Name获取ServiceInstance对象 + List serviceInstances = serviceDiscovery.getInstances(serviceName); + // 调用另一个subscribeURLs()方法重载 + subscribeURLs(url, listener, serviceName, serviceInstances); + // 添加ServiceInstancesChangedListener监听器 + registerServiceInstancesChangedListener(url, new ServiceInstancesChangedListener(serviceName) { + @Override + public void onEvent(ServiceInstancesChangedEvent event) { + subscribeURLs(url, listener, event.getServiceName(), new ArrayList<>(event.getServiceInstances())); + } + }); +} + + +在 subscribeURLs(url, listener, serviceName, serviceInstances) 这个重载中,主要是根据前面获取的 ServiceInstance 实例集合,构造对应的、完整的 subscribedURL 集合,并触发传入的 NotifyListener 监听器,如下所示: + +protected void subscribeURLs(URL subscribedURL, NotifyListener listener, String serviceName, + Collection serviceInstances) { + List subscribedURLs = new LinkedList<>(); + // 尝试通过MetadataService获取subscribedURL集合 + subscribedURLs.addAll(getExportedURLs(subscribedURL, serviceInstances)); + if (subscribedURLs.isEmpty()) { // 如果上面的尝试失败 + // 尝试通过SubscribedURLsSynthesizer获取subscribedURL集合 + subscribedURLs.addAll(synthesizeSubscribedURLs(subscribedURL, serviceInstances)); + } + // 触发NotifyListener监听器 + listener.notify(subscribedURLs); +} + + +这里构造完整 subscribedURL 可以分为两个分支。 + + +第一个分支:结合传入的 subscribedURL 以及从元数据中获取每个 ServiceInstance 的对应参数,组装成每个 ServiceInstance 对应的完整 subscribeURL。该部分实现在 getExportedURLs() 方法中,也是订阅操作的核心。 + +第二个分支:当上述操作无法获得完整的 subscribeURL 集合时,会使用 SubscribedURLsSynthesizer,基于 subscribedURL 拼凑出每个 ServiceInstance 对应的完整的 subscribedURL。该部分实现在 synthesizeSubscribedURLs() 方法中,目前主要针对 rest 协议。 + + +3. getExportedURLs() 方法核心实现 + +getExportedURLs() 方法主要围绕 serviceRevisionExportedURLsCache 这个集合展开的,它是一个 Map> 类型的集合,其中第一层 Key 是 Service Name,第二层 Key 是 Revision,最终的 Value 值是 Service Name 对应的最新的 URL 集合。 + +(1)清理过期 URL + +在 getExportedURLs() 方法中,首先会调用 expungeStaleRevisionExportedURLs() 方法销毁全部已过期的 URL 信息,具体实现如下: + +private void expungeStaleRevisionExportedURLs(List serviceInstances) { + // 从第一个ServiceInstance即可获取Service Name + String serviceName = serviceInstances.get(0).getServiceName(); + // 获取该Service Name当前在serviceRevisionExportedURLsCache中对应的URL集合 + Map> revisionExportedURLsMap = serviceRevisionExportedURLsCache + .computeIfAbsent(serviceName, s -> new LinkedHashMap()); + if (revisionExportedURLsMap.isEmpty()) { // 没有缓存任何URL,则无须后续清理操作,直接返回即可 + return; + } + // 获取Service Name在serviceRevisionExportedURLsCache中缓存的修订版本 + Set existedRevisions = revisionExportedURLsMap.keySet(); + // 从ServiceInstance中获取当前最新的修订版本 + Set currentRevisions = serviceInstances.stream() + .map(ServiceInstanceMetadataUtils::getExportedServicesRevision) + .collect(Collectors.toSet()); + // 获取要删除的陈旧修订版本:staleRevisions = existedRevisions(copy) - currentRevisions + Set staleRevisions = new HashSet<>(existedRevisions); + staleRevisions.removeAll(currentRevisions); + // 从revisionExportedURLsMap中删除staleRevisions集合中所有Key对应的URL集合 + staleRevisions.forEach(revisionExportedURLsMap::remove); +} + + +我们看到这里是通过 ServiceInstanceMetadataUtils 工具类从每个 ServiceInstance 的 metadata 集合中获取最新的修订版本(Key 为 dubbo.exported-services.revision),那么该修订版本的信息是在哪里写入的呢?我们来看一个新接口—— ServiceInstanceCustomizer,具体定义如下: + +@SPI +public interface ServiceInstanceCustomizer extends Prioritized { + void customize(ServiceInstance serviceInstance); +} + + +关于 ServiceInstanceCustomizer 接口,这里需要关注三个点:①该接口被 @SPI 注解修饰,是一个扩展点;②该接口继承了 Prioritized 接口;③该接口中定义的 customize() 方法可以用来自定义 ServiceInstance 信息,其中就包括控制 metadata 集合中的数据。 + +也就说,ServiceInstanceCustomizer 的多个实现可以按序调用,实现 ServiceInstance 的自定义。下图展示了 ServiceInstanceCustomizer 接口的所有实现类: + + + +ServiceInstanceCustomizer 继承关系图 + +我们首先来看 ServiceInstanceMetadataCustomizer 这个抽象类,它主要是对 ServiceInstance 中 metadata 这个 KV 集合进行自定义修改,这部分逻辑在 customize() 方法中,如下所示: + +public final void customize(ServiceInstance serviceInstance) { + // 获取ServiceInstance对象的metadata字段 + Map metadata = serviceInstance.getMetadata(); + // 生成要添加到metadata集合的KV值 + String propertyName = resolveMetadataPropertyName(serviceInstance); + String propertyValue = resolveMetadataPropertyValue(serviceInstance); + // 判断待添加的KV值是否为空 + if (!isBlank(propertyName) && !isBlank(propertyValue)) { + String existedValue = metadata.get(propertyName); + boolean put = existedValue == null || isOverride(); + if (put) { // 是否覆盖原值 + metadata.put(propertyName, propertyValue); + } + } +} + + +生成 KV 值的 resolveMetadataPropertyName()、resolveMetadataPropertyValue() 方法以及 isOverride() 方法都是抽象方法,在 ServiceInstanceMetadataCustomizer 子类中实现。 + +在 ExportedServicesRevisionMetadataCustomizer 这个实现中,resolveMetadataPropertyName() 方法返回 “dubbo.exported-services.revision” 固定字符串,resolveMetadataPropertyValue() 方法会通过 WritableMetadataService 获取当前 ServiceInstance 对象发布的全部 URL,然后计算 revision 值。具体实现如下: + +protected String resolveMetadataPropertyValue(ServiceInstance serviceInstance) { + // 从ServiceInstance对象的metadata集合中获取当前ServiceInstance存储元数据的方式(local还是remote) + String metadataStorageType = getMetadataStorageType(serviceInstance); + // 获取相应的WritableMetadataService对象,并获取当前ServiceInstance发布的全部元数据 + WritableMetadataService writableMetadataService = getExtension(metadataStorageType); + SortedSet exportedURLs = writableMetadataService.getExportedURLs(); + // 计算整个exportedURLs集合的revision值 + URLRevisionResolver resolver = new URLRevisionResolver(); + return resolver.resolve(exportedURLs); +} + + +这里需要说明下计算 revision 值的核心实现:首先获取每个服务接口的方法签名以及对应 URL 参数集合,然后计算 hashCode 并加和返回,如果通过上述方式没有拿到 revision 值,则返回 “N/A” 占位符字符串。URLRevisionResolver.resolve() 方法的具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +在 SubscribedServicesRevisionMetadataCustomizer 这个实现中,resolveMetadataPropertyName() 方法返回的是 “dubbo.subscribed-services.revision” 固定字符串,resolveMetadataPropertyValue() 方法会通过 WritableMetadataService 获取当前 ServiceInstance 对象引用的全部 URL,然后计算 revision 值并返回。具体实现如下: + +protected String resolveMetadataPropertyValue(ServiceInstance serviceInstance) { + String metadataStorageType = getMetadataStorageType(serviceInstance); + WritableMetadataService writableMetadataService = getExtension(metadataStorageType); + // 获取subscribedServiceURLs集合 + SortedSet subscribedURLs = writableMetadataService.getSubscribedURLs(); + URLRevisionResolver resolver = new URLRevisionResolver(); + // 计算revision值 + return resolver.resolve(subscribedURLs); +} + + +在 MetadataServiceURLParamsMetadataCustomizer 这个实现中,resolveMetadataPropertyName() 方法返回 “dubbo.metadata-service.url-params” 固定字符串,resolveMetadataPropertyValue() 方法返回 MetadataService 服务 URL 的参数。 + +对于 RefreshServiceMetadataCustomizer 这个实现,我们首先关注其执行顺序, 它覆盖了 getPriority() 方法,具体实现如下: + +public int getPriority() { + return MIN_PRIORITY; // 执行优先级最低 +} + + +这就保证了 RefreshServiceMetadataCustomizer 在前面介绍的 ServiceInstanceMetadataCustomizer 实现之后执行(ServiceInstanceMetadataCustomizer 的优先级为 NORMAL_PRIORITY)。 + +customize() 方法的实现中,RefreshServiceMetadataCustomizer 会分别获取该 ServiceInstance 发布服务的 URL revision 以及引用服务的 URL revision,并更新到元数据中心。具体实现如下: + +public void customize(ServiceInstance serviceInstance) { + String metadataStoredType = getMetadataStorageType(serviceInstance); + WritableMetadataService writableMetadataService = getExtension(metadataStoredType); + // 从ServiceInstance.metadata集合中获取两个revision,并调用refreshMetadata()方法进行更新 + writableMetadataService.refreshMetadata(getExportedServicesRevision(serviceInstance), + getSubscribedServicesRevision(serviceInstance)); +} + + +在 WritableMetadataService 接口的实现中,只有 RemoteWritableMetadataService 实现了 refreshMetadata() 方法,其中会判断两个 revision 值是否发生变化,如果发生了变化,则将相应的 URL 集合更新到元数据中心。如下所示: + +public boolean refreshMetadata(String exportedRevision, String subscribedRevision) { + boolean result = true; + // 比较当前ServiceInstance的exportedRevision是否发生变化 + if (!StringUtils.isEmpty(exportedRevision) && !exportedRevision.equals(this.exportedRevision)) { + // 发生变化的话,会更新exportedRevision字段,同时将exportedServiceURLs集合中的URL更新到元数据中心 + this.exportedRevision = exportedRevision; + boolean executeResult = saveServiceMetadata(); + if (!executeResult) { + result = false; + } + } + // 比较当前ServiceInstance的subscribedRevision是否发生变化 + if (!StringUtils.isEmpty(subscribedRevision) && !subscribedRevision.equals(this.subscribedRevision) + && CollectionUtils.isNotEmpty(writableMetadataService.getSubscribedURLs())) { + // 发生变化的话,会更新subscribedRevision字段,同时将subscribedServiceURLs集合中的URL更新到元数据中心 + this.subscribedRevision = subscribedRevision; + SubscriberMetadataIdentifier metadataIdentifier = new SubscriberMetadataIdentifier(); + metadataIdentifier.setApplication(serviceName()); + metadataIdentifier.setRevision(subscribedRevision); + boolean executeResult = throwableAction(getMetadataReport()::saveSubscribedData, metadataIdentifier, + writableMetadataService.getSubscribedURLs()); + if (!executeResult) { + result = false; + } + } + return result; +} + + +在 EventListener 接口的实现中有一个名为 CustomizableServiceInstanceListener 的实现,它会监听 ServiceInstancePreRegisteredEvent,在其 onEvent() 方法中,加载全部 ServiceInstanceCustomizer 实现,并调用全部 customize() 方法完成 ServiceInstance 的自定义。具体实现如下: + +public void onEvent(ServiceInstancePreRegisteredEvent event) { + // 加载全部ServiceInstanceCustomizer实现 + ExtensionLoader loader = + ExtensionLoader.getExtensionLoader(ServiceInstanceCustomizer.class); + // 按序实现ServiceInstance自定义 + loader.getSupportedExtensionInstances().forEach(customizer -> { + customizer.customize(event.getServiceInstance()); + }); +} + + +(2)更新 Revision 缓存 + +介绍完 ServiceInstanceMetadataCustomizer 的内容之后,下面我们回到 ServiceDiscoveryRegistry 继续分析。 + +在清理完过期的修订版本 URL 之后,接下来会检测所有 ServiceInstance 的 revision 值是否已经存在于 serviceRevisionExportedURLsCache 缓存中,如果某个 ServiceInstance 的 revision 值没有在该缓存中,则会调用该 ServiceInstance 发布的 MetadataService 接口进行查询,这部分逻辑在 initializeRevisionExportedURLs() 方法中实现。具体实现如下: + +private List initializeRevisionExportedURLs(ServiceInstance serviceInstance) { + if (serviceInstance == null) { // 判空 + return emptyList(); + } + // 获取Service Name + String serviceName = serviceInstance.getServiceName(); + // 获取该ServiceInstance.metadata中携带的revision值 + String revision = getExportedServicesRevision(serviceInstance); + // 从serviceRevisionExportedURLsCache集合中获取该revision值对应的URL集合 + Map> revisionExportedURLsMap = getRevisionExportedURLsMap(serviceName); + List revisionExportedURLs = revisionExportedURLsMap.get(revision); + + if (revisionExportedURLs == null) { // serviceRevisionExportedURLsCache缓存没有命中 + // 调用该ServiceInstance对应的MetadataService服务,获取其发布的URL集合 + revisionExportedURLs = getExportedURLs(serviceInstance); + if (revisionExportedURLs != null) { // 调用MetadataService服务成功之后,更新到serviceRevisionExportedURLsCache缓存中 + revisionExportedURLsMap.put(revision, revisionExportedURLs); + } + } else { // 命中serviceRevisionExportedURLsCache缓存 + ... // 打印日志 + } + return revisionExportedURLs; +} + + +(3)请求 MetadataService 服务 + +这里我们可以看到,请求某个 ServiceInstance 的 MetadataService 接口的实现是在 getExportedURLs() 方法中实现的,与我们前面整个课程介绍的请求普通业务接口的原理类似。具体实现如下: + +private List getExportedURLs(ServiceInstance providerServiceInstance) { + List exportedURLs = null; + // 获取指定ServiceInstance实例存储元数据的类型 + String metadataStorageType = getMetadataStorageType(providerServiceInstance); + try { + // 创建MetadataService接口的本地代理 + MetadataService metadataService = MetadataServiceProxyFactory.getExtension(metadataStorageType) + .getProxy(providerServiceInstance); + if (metadataService != null) { + // 通过本地代理,请求该ServiceInstance的MetadataService服务 + SortedSet urls = metadataService.getExportedURLs(); + exportedURLs = toURLs(urls); + } + } catch (Throwable e) { + exportedURLs = null; // 置空exportedURLs + } + return exportedURLs; +} + + +这里涉及一个新的接口——MetadataServiceProxyFactory,它是用来创建 MetadataService 本地代理的工厂类,继承关系如下所示: + + + +MetadataServiceProxyFactory 继承关系图 + +在 BaseMetadataServiceProxyFactory 中提供了缓存 MetadataService 本地代理的公共功能,其中维护了一个 proxies 集合(HashMap 类型),Key 是 Service Name 与一个 ServiceInstance 的 revision 值的组合,Value 是该 ServiceInstance 对应的 MetadataService 服务的本地代理对象。创建 MetadataService 本地代理的功能是在 createProxy() 抽象方法中实现的,这个方法由 BaseMetadataServiceProxyFactory 的子类具体实现。 + +下面来看 BaseMetadataServiceProxyFactory 的两个实现——DefaultMetadataServiceProxyFactory 和 RemoteMetadataServiceProxyFactory。 + +DefaultMetadataServiceProxyFactory 在其 createProxy() 方法中,会先通过 MetadataServiceURLBuilder 获取 MetadataService 接口的 URL,然后通过 Protocol 接口引用指定 ServiceInstance 发布的 MetadataService 服务,得到对应的 Invoker 对象,最后通过 ProxyFactory 在 Invoker 对象的基础上创建 MetadataService 本地代理。 + +protected MetadataService createProxy(ServiceInstance serviceInstance) { + MetadataServiceURLBuilder builder = null; + ExtensionLoader loader + = ExtensionLoader.getExtensionLoader(MetadataServiceURLBuilder.class); + Map metadata = serviceInstance.getMetadata(); + // 在使用Spring Cloud的时候,metadata集合中会包含METADATA_SERVICE_URLS_PROPERTY_NAME整个Key + String dubboURLsJSON = metadata.get(METADATA_SERVICE_URLS_PROPERTY_NAME); + if (StringUtils.isNotEmpty(dubboURLsJSON)) { + builder = loader.getExtension(SpringCloudMetadataServiceURLBuilder.NAME); + } else { + builder = loader.getExtension(StandardMetadataServiceURLBuilder.NAME); + } + // 构造MetadataService服务对应的URL集合 + List urls = builder.build(serviceInstance); + // 引用服务,创建Invoker,注意,即使MetadataService接口使用了多种协议,这里也只会使用第一种协议 + Invoker invoker = protocol.refer(MetadataService.class, urls.get(0)); + // 创建MetadataService的本地代理对象 + return proxyFactory.getProxy(invoker); +} + + +这里我们来看 MetadataServiceURLBuilder 接口中创建 MetadataService 服务对应的 URL 的逻辑,下图展示了 MetadataServiceURLBuilder 接口的实现: + + + +MetadataServiceURLBuilder 继承关系图 + +其中,SpringCloudMetadataServiceURLBuilder 是兼容 Spring Cloud 的实现,这里就不深入分析了。我们重点来看 StandardMetadataServiceURLBuilder 的实现,其中会根据 ServiceInstance.metadata 携带的 URL 参数、Service Name、ServiceInstance 的 host 等信息构造 MetadataService 服务对应 URL,如下所示: + +public List build(ServiceInstance serviceInstance) { + // 从metadata集合中获取"dubbo.metadata-service.url-params"这个Key对应的Value值, + // 这个Key是在MetadataServiceURLParamsMetadataCustomizer中写入的 + Map> paramsMap = getMetadataServiceURLsParams(serviceInstance); + List urls = new ArrayList<>(paramsMap.size()); + // 获取Service Name + String serviceName = serviceInstance.getServiceName(); + // 获取ServiceInstance监听的host + String host = serviceInstance.getHost(); + // MetadataService接口可能被发布成多种协议,遍历paramsMap集合,为每种协议都生成对应的URL + for (Map.Entry> entry : paramsMap.entrySet()) { + String protocol = entry.getKey(); + Map params = entry.getValue(); + int port = Integer.parseInt(params.get(PORT_KEY)); + URLBuilder urlBuilder = new URLBuilder() + .setHost(host) + .setPort(port) + .setProtocol(protocol) + .setPath(MetadataService.class.getName()); + params.forEach((name, value) -> urlBuilder.addParameter(name, valueOf(value))); + urlBuilder.addParameter(GROUP_KEY, serviceName); + urls.add(urlBuilder.build()); + } + return urls; +} + + +接下来我们看 RemoteMetadataServiceProxyFactory 这个实现类,其中的 createProxy() 方法会直接创建一个 RemoteMetadataServiceProxy 对象并返回。在前面第 44 课时介绍 MetadataService 接口的时候,我们重点介绍的是 WritableMetadataService 这个子接口下的实现,并没有提及 RemoteMetadataServiceProxy 这个实现。下图是 RemoteMetadataServiceProxy 在继承体系中的位置: + + + +RemoteMetadataServiceProxy 继承关系图 + +RemoteMetadataServiceProxy 作为 RemoteWritableMetadataService 的本地代理,其 getExportedURLs()、getServiceDefinition() 等方法的实现,完全依赖于 MetadataReport 进行实现。这里以 getExportedURLs() 方法为例: + +public SortedSet getExportedURLs(String serviceInterface, String group, String version, String protocol) { + // 通过getMetadataReport()方法获取MetadataReport实现对象,并通过其getExportedURLs()方法进行查询,查询条件封装成ServiceMetadataIdentifier传入,其中包括服务接口、group、version以及revision等一系列信息,以ZookeeperMetadataReport实现为例真正有用的信息是revision和protocol + return toSortedStrings(getMetadataReport().getExportedURLs( + new ServiceMetadataIdentifier(serviceInterface, group, version, PROVIDER_SIDE, revision, protocol))); +} + + +到此为止,serviceRevisionExportedURLsCache 缓存中各个修订版本的 URL 已经更新到最新数据。 + +(4)生成 SubcribedURL + +在拿到最新修订版本的 URL 集合之后,接下来会调用 cloneExportedURLs() 方法,结合模板 URL(也就是 subscribedURL)以及各个 ServiceInstance 发布出来的元数据,生成要订阅服务的最终 subscribedURL 集合。 + +private List cloneExportedURLs(URL subscribedURL, Collection serviceInstances) { + if (isEmpty(serviceInstances)) { + return emptyList(); + } + List clonedExportedURLs = new LinkedList<>(); + serviceInstances.forEach(serviceInstance -> { + // 获取该ServiceInstance的host + String host = serviceInstance.getHost(); + // 获取该ServiceInstance的模板URL集合,getTemplateExportedURLs()方法会根据Service Name以及当前ServiceInstance的revision + // 从serviceRevisionExportedURLsCache缓存中获取对应的URL集合,另外,还会根据subscribedURL的protocol、group、version等参数进行过滤 + getTemplateExportedURLs(subscribedURL, serviceInstance) + .stream() + // 删除timestamp、pid等参数 + .map(templateURL -> templateURL.removeParameter(TIMESTAMP_KEY)) + .map(templateURL -> templateURL.removeParameter(PID_KEY)) + .map(templateURL -> { + // 从ServiceInstance.metadata集合中获取该protocol对应的端口号 + String protocol = templateURL.getProtocol(); + int port = getProtocolPort(serviceInstance, protocol); + if (Objects.equals(templateURL.getHost(), host) + && Objects.equals(templateURL.getPort(), port)) { // use templateURL if equals + return templateURL; + } + // 覆盖host、port参数 + URLBuilder clonedURLBuilder = from(templateURL) + .setHost(host) + .setPort(port); + return clonedURLBuilder.build(); + }) + .forEach(clonedExportedURLs::add); // 记录新生成的URL + }); + return clonedExportedURLs; +} + + +在 getProtocolPort() 方法中会从 ServiceInstance.metadata 集合中获取 endpoints 列表(Key 为 dubbo.endpoints),具体实现如下: + +public static Integer getProtocolPort(ServiceInstance serviceInstance, String protocol) { + Map metadata = serviceInstance.getMetadata(); + // 从metadata集合中进行查询 + String rawEndpoints = metadata.get("dubbo.endpoints"); + if (StringUtils.isNotEmpty(rawEndpoints)) { + // 将JSON格式的数据进行反序列化,这里的Endpoint是ServiceDiscoveryRegistry的内部类,只有port和protocol两个字段 + List endpoints = JSON.parseArray(rawEndpoints, Endpoint.class); + for (Endpoint endpoint : endpoints) { + // 根据Protocol获取对应的port + if (endpoint.getProtocol().equals(protocol)) { + return endpoint.getPort(); + } + } + } + return null; +} + + +在 ServiceInstance.metadata 集合中设置 Endpoint 集合的 ServiceInstanceCustomizer 接口的另一个实现—— ProtocolPortsMetadataCustomizer,主要是为了将不同 Protocol 监听的不同端口通知到 Consumer 端。ProtocolPortsMetadataCustomizer.customize() 方法的具体实现如下: + +public void customize(ServiceInstance serviceInstance) { + // 获取WritableMetadataService + String metadataStoredType = getMetadataStorageType(serviceInstance); + WritableMetadataService writableMetadataService = getExtension(metadataStoredType); + Map protocols = new HashMap<>(); + // 先获取将当前ServiceInstance发布的各种Protocol对应的URL + writableMetadataService.getExportedURLs() + .stream().map(URL::valueOf) + // 过滤掉MetadataService接口 + .filter(url -> !MetadataService.class.getName().equals(url.getServiceInterface())) + .forEach(url -> { + // 记录Protocol与port之间的映射关系 + protocols.put(url.getProtocol(), url.getPort()); + }); + // 将protocols这个Map中的映射关系转换成Endpoint对象,然后再序列化成JSON字符串,并设置到该ServiceInstance的metadata集合中 + setEndpoints(serviceInstance, protocols); +} + + +到此为止,整个 getExportedURLs() 方法的核心流程就介绍完了。 + +4. SubscribedURLsSynthesizer + +最后,我们再来看看 synthesizeSubscribedURLs() 方法的相关实现,其中使用到 SubscribedURLsSynthesizer 这个接口,具体定义如下: + +@SPI +public interface SubscribedURLsSynthesizer extends Prioritized { + // 是否支持该类型的URL + boolean supports(URL subscribedURL); + // 根据subscribedURL以及ServiceInstance的信息,合成完整subscribedURL集合 + List synthesize(URL subscribedURL, Collection serviceInstances); +} + + +目前 Dubbo 只提供了 rest 协议的实现—— RestProtocolSubscribedURLsSynthesizer,其中会根据 subscribedURL 中的服务接口以及 ServiceInstance 的 host、port、Service Name 等合成完整的 URL,具体实现如下: + +public List synthesize(URL subscribedURL, Collection serviceInstances) { + // 获取Protocol + String protocol = subscribedURL.getParameter(PROTOCOL_KEY); + return serviceInstances.stream().map(serviceInstance -> { + URLBuilder urlBuilder = new URLBuilder() + .setProtocol(protocol) + // 使用ServiceInstance的host、port + .setHost(serviceInstance.getHost()) + .setPort(serviceInstance.getPort()) + // 设置业务接口 + .setPath(subscribedURL.getServiceInterface()) + .addParameter(SIDE_KEY, PROVIDER) + // 设置Service Name + .addParameter(APPLICATION_KEY, serviceInstance.getServiceName()) + .addParameter(REGISTER_KEY, TRUE.toString()); + return urlBuilder.build(); + }).collect(Collectors.toList()); +} + + +到这里,关于整个 ServiceDiscoveryRegistry 的内容,我们就介绍完了。 + +总结 + +本课时我们重点介绍了 Dubbo 服务自省架构中服务发布、服务订阅功能与传统 Dubbo 架构中Registry 接口的兼容实现,也就是 ServiceDiscoveryRegistry 的核心实现。 + +首先我们讲解了 ServiceDiscoveryRegistry 对服务注册的核心实现,然后详细介绍了 ServiceDiscoveryRegistry 对服务订阅功能的实现,其中涉及 Service Instance 和 Service Name 的查询、MetadataService 服务调用等操作,最终得到 SubcribedURL。 + +下一课时,我们将开始介绍 Dubbo 服务自省架构中配置中心的相关内容,记得按时来听课。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/47配置中心设计与实现:集中化配置and本地化配置,我都要(上).md b/专栏/Dubbo源码解读与实战-完/47配置中心设计与实现:集中化配置and本地化配置,我都要(上).md new file mode 100644 index 0000000..ac96faf --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/47配置中心设计与实现:集中化配置and本地化配置,我都要(上).md @@ -0,0 +1,392 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 47 配置中心设计与实现:集中化配置 and 本地化配置,我都要(上) + 从 2.7.0 版本开始,Dubbo 正式支持配置中心,在服务自省架构中也依赖配置中心完成 Service ID 与 Service Name 的映射。配置中心在 Dubbo 中主要承担两个职责: + + +外部化配置; +服务治理,负责服务治理规则的存储与通知。 + + +外部化配置目的之一是实现配置的集中式管理。 目前已经有很多成熟的专业配置管理系统(例如,携程开源的 Apollo、阿里开源的 Nacos 等),Dubbo 配置中心的目的不是再“造一次轮子”,而是保证 Dubbo 能与这些成熟的配置管理系统正常工作。 + +Dubbo 可以同时支持多种配置来源。在 Dubbo 初始化过程中,会从多个来源获取配置,并按照固定的优先级将这些配置整合起来,实现高优先级的配置覆盖低优先级配置的效果。这些配置的汇总结果将会参与形成 URL,以及后续的服务发布和服务引用。 + +Dubbo 目前支持下面四种配置来源,优先级由 1 到 4 逐级降低: + + +System Properties,即 -D 参数; +外部化配置,也就是本课时要介绍的配置中心; +API 接口、注解、XML 配置等编程方式收到的配置,最终得到 ServiceConfig、ReferenceConfig 等对象; +本地 dubbo.properties 配置文件。 + + +Configuration + +Configuration 接口是 Dubbo 中所有配置的基础接口,其中定义了根据指定 Key 获取对应配置值的相关方法,如下图所示: + + + +Configuration 接口核心方法 + +从上图中我们可以看到,Configuration 针对不同的 boolean、int、String 返回值都有对应的 get() 方法,同时还提供了带有默认值的 get() 方法。这些 get*() 方法底层首先调用 getInternalProperty() 方法获取配置值,然后调用 convert() 方法将获取到的配置值转换成返回值的类型之后返回。getInternalProperty() 是一个抽象方法,由 Configuration 接口的子类具体实现。 + +下图展示了 Dubbo 中提供的 Configuration 接口实现,包括:SystemConfiguration、EnvironmentConfiguration、InmemoryConfiguration、PropertiesConfiguration、CompositeConfiguration、ConfigConfigurationAdapter 和 DynamicConfiguration。下面我们将结合具体代码逐个介绍其实现。 + + + +Configuration 继承关系图 + +SystemConfiguration & EnvironmentConfiguration + +SystemConfiguration 是从 Java Properties 配置(也就是 -D 配置参数)中获取相应的配置项,EnvironmentConfiguration 是从使用环境变量中获取相应的配置。两者的 getInternalProperty() 方法实现如下: + +public class SystemConfiguration implements Configuration { + public Object getInternalProperty(String key) { + return System.getProperty(key); // 读取-D配置参数 + } +} +public class EnvironmentConfiguration implements Configuration { + public Object getInternalProperty(String key) { + String value = System.getenv(key); + if (StringUtils.isEmpty(value)) { + // 读取环境变量中获取相应的配置 + value = System.getenv(StringUtils.toOSStyleKey(key)); + } + return value; + } +} + + +InmemoryConfiguration + +InmemoryConfiguration 会在内存中维护一个 Map 集合(store 字段),其 getInternalProperty() 方法的实现就是从 store 集合中获取对应配置值: + +public class InmemoryConfiguration implements Configuration { + private Map store = new LinkedHashMap<>(); + @Override + public Object getInternalProperty(String key) { + return store.get(key); + } + // 省略addProperty()等写入store集合的方法 +} + + +PropertiesConfiguration + +PropertiesConfiguration 涉及 OrderedPropertiesProvider,其接口的定义如下: + +@SPI +public interface OrderedPropertiesProvider { + // 用于排序 + int priority(); + // 获取Properties配置 + Properties initProperties(); +} + + +在 PropertiesConfiguration 的构造方法中,会加载 OrderedPropertiesProvider 接口的全部扩展实现,并按照 priority() 方法进行排序。然后,加载默认的 dubbo.properties.file 配置文件。最后,用 OrderedPropertiesProvider 中提供的配置覆盖 dubbo.properties.file 文件中的配置。PropertiesConfiguration 的构造方法的具体实现如下: + +public PropertiesConfiguration() { + // 获取OrderedPropertiesProvider接口的全部扩展名称 + ExtensionLoader propertiesProviderExtensionLoader = ExtensionLoader.getExtensionLoader(OrderedPropertiesProvider.class); + Set propertiesProviderNames = propertiesProviderExtensionLoader.getSupportedExtensions(); + if (propertiesProviderNames == null || propertiesProviderNames.isEmpty()) { + return; + } + // 加载OrderedPropertiesProvider接口的全部扩展实现 + List orderedPropertiesProviders = new ArrayList<>(); + for (String propertiesProviderName : propertiesProviderNames) { + orderedPropertiesProviders.add(propertiesProviderExtensionLoader.getExtension(propertiesProviderName)); + } + // 排序OrderedPropertiesProvider接口的扩展实现 + orderedPropertiesProviders.sort((OrderedPropertiesProvider a, OrderedPropertiesProvider b) -> { + return b.priority() - a.priority(); + }); + // 加载默认的dubbo.properties.file配置文件,加载后的结果记录在ConfigUtils.PROPERTIES这个static字段中 + Properties properties = ConfigUtils.getProperties(); + // 使用OrderedPropertiesProvider扩展实现,按序覆盖dubbo.properties.file配置文件中的默认配置 + for (OrderedPropertiesProvider orderedPropertiesProvider : + orderedPropertiesProviders) { + properties.putAll(orderedPropertiesProvider.initProperties()); + } + // 更新ConfigUtils.PROPERTIES字段 + ConfigUtils.setProperties(properties); +} + + +在 PropertiesConfiguration.getInternalProperty() 方法中,直接从 ConfigUtils.PROPERTIES 这个 Properties 中获取覆盖后的配置信息。 + +public Object getInternalProperty(String key) { + return ConfigUtils.getProperty(key); +} + + +CompositeConfiguration + +CompositeConfiguration 是一个复合的 Configuration 对象,其核心就是将多个 Configuration 对象组合起来,对外表现为一个 Configuration 对象。 + +CompositeConfiguration 组合的 Configuration 对象都保存在 configList 字段中(LinkedList 集合),CompositeConfiguration 提供了 addConfiguration() 方法用于向 configList 集合中添加 Configuration 对象,如下所示: + +public void addConfiguration(Configuration configuration) { + if (configList.contains(configuration)) { + return; // 不会重复添加同一个Configuration对象 + } + this.configList.add(configuration); +} + + +在 CompositeConfiguration 中维护了一个 prefix 字段和 id 字段,两者可以作为 Key 的前缀进行查询,在 getProperty() 方法中的相关代码如下: + +public Object getProperty(String key, Object defaultValue) { + Object value = null; + if (StringUtils.isNotEmpty(prefix)) { // 检查prefix + if (StringUtils.isNotEmpty(id)) { // 检查id + // prefix和id都作为前缀,然后拼接key进行查询 + value = getInternalProperty(prefix + id + "." + key); + } + if (value == null) { + // 只把prefix作为前缀,拼接key进行查询 + value = getInternalProperty(prefix + key); + } + } else { + // 若prefix为空,则直接用key进行查询 + value = getInternalProperty(key); + } + return value != null ? value : defaultValue; +} + + +在 getInternalProperty() 方法中,会按序遍历 configList 集合中的全部 Configuration 查询对应的 Key,返回第一个成功查询到的 Value 值,如下示例代码: + +public Object getInternalProperty(String key) { + Configuration firstMatchingConfiguration = null; + for (Configuration config : configList) { // 遍历所有Configuration对象 + try { + if (config.containsKey(key)) { // 得到第一个包含指定Key的Configuration对象 + firstMatchingConfiguration = config; + break; + } + } catch (Exception e) { + logger.error("..."); + } + } + if (firstMatchingConfiguration != null) { // 通过该Configuration查询Key并返回配置值 + return firstMatchingConfiguration.getProperty(key); + } else { + return null; + } +} + + +ConfigConfigurationAdapter + +Dubbo 通过 AbstractConfig 类来抽象实例对应的配置,如下图所示: + + + +AbstractConfig 继承关系图 + +这些 AbstractConfig 实现基本都对应一个固定的配置,也定义了配置对应的字段以及 getter/setter() 方法。例如,RegistryConfig 这个实现类就对应了注册中心的相关配置,其中包含了 address、protocol、port、timeout 等一系列与注册中心相关的字段以及对应的 getter/setter() 方法,来接收用户通过 XML、Annotation 或是 API 方式传入的注册中心配置。 + +ConfigConfigurationAdapter 是 AbstractConfig 与 Configuration 之间的适配器,它会将 AbstractConfig 对象转换成 Configuration 对象。在 ConfigConfigurationAdapter 的构造方法中会获取 AbstractConfig 对象的全部字段,并转换成一个 Map 集合返回,该 Map 集合将会被 ConfigConfigurationAdapter 的 metaData 字段引用。相关示例代码如下: + +public ConfigConfigurationAdapter(AbstractConfig config) { + // 获取该AbstractConfig对象中的全部字段与字段值的映射 + Map configMetadata = config.getMetaData(); + metaData = new HashMap<>(configMetadata.size()); + // 根据AbstractConfig配置的prefix和id,修改metaData集合中Key的名称 + for (Map.Entry entry : configMetadata.entrySet()) { + String prefix = config.getPrefix().endsWith(".") ? config.getPrefix() : config.getPrefix() + "."; + String id = StringUtils.isEmpty(config.getId()) ? "" : config.getId() + "."; + metaData.put(prefix + id + entry.getKey(), entry.getValue()); + } +} + + +在 ConfigConfigurationAdapter 的 getInternalProperty() 方法实现中,直接从 metaData 集合中获取配置值即可,如下所示: + +public Object getInternalProperty(String key) { + return metaData.get(key); +} + + +DynamicConfiguration + +DynamicConfiguration 是对 Dubbo 中动态配置的抽象,其核心方法有下面三类。 + + +getProperties()/ getConfig() / getProperty() 方法:从配置中心获取指定的配置,在使用时,可以指定一个超时时间。 +addListener()/ removeListener() 方法:添加或删除对指定配置的监听器。 +publishConfig() 方法:发布一条配置信息。 + + +在上述三类方法中,每个方法都用多个重载,其中,都会包含一个带有 group 参数的重载,也就是说配置中心的配置可以按照 group 进行分组。 + +与 Dubbo 中很多接口类似,DynamicConfiguration 接口本身不被 @SPI 注解修饰(即不是一个扩展接口),而是在 DynamicConfigurationFactory 上添加了 @SPI 注解,使其成为一个扩展接口。 + +在 DynamicConfiguration 中提供了 getDynamicConfiguration() 静态方法,该方法会从传入的配置中心 URL 参数中,解析出协议类型并获取对应的 DynamicConfigurationFactory 实现,如下所示: + +static DynamicConfiguration getDynamicConfiguration(URL connectionURL) { + String protocol = connectionURL.getProtocol(); + DynamicConfigurationFactory factory = getDynamicConfigurationFactory(protocol); + return factory.getDynamicConfiguration(connectionURL); +} + + +DynamicConfigurationFactory 接口的定义如下: + +@SPI("nop") +public interface DynamicConfigurationFactory { + DynamicConfiguration getDynamicConfiguration(URL url); + static DynamicConfigurationFactory getDynamicConfigurationFactory(String name) { + // 根据扩展名称获取DynamicConfigurationFactory实现 + Class factoryClass = DynamicConfigurationFactory.class; + ExtensionLoader loader = getExtensionLoader(factoryClass); + return loader.getOrDefaultExtension(name); + } +} + + +DynamicConfigurationFactory 接口的继承关系以及 DynamicConfiguration 接口对应的继承关系如下: + + + +DynamicConfigurationFactory 继承关系图 + + + +DynamicConfiguration 继承关系图 + +我们先来看 AbstractDynamicConfigurationFactory 的实现,其中会维护一个 dynamicConfigurations 集合(Map 类型),在 getDynamicConfiguration() 方法中会填充该集合,实现缓存DynamicConfiguration 对象的效果。同时,AbstractDynamicConfigurationFactory 提供了一个 createDynamicConfiguration() 方法给子类实现,来创建DynamicConfiguration 对象。 + +以 ZookeeperDynamicConfigurationFactory 实现为例,其 createDynamicConfiguration() 方法创建的就是 ZookeeperDynamicConfiguration 对象: + +protected DynamicConfiguration createDynamicConfiguration(URL url) { + // 这里创建ZookeeperDynamicConfiguration使用的ZookeeperTransporter就是前文在Transport层中针对Zookeeper的实现 + return new ZookeeperDynamicConfiguration(url, zookeeperTransporter); +} + + +接下来我们再以 ZookeeperDynamicConfiguration 为例,分析 DynamicConfiguration 接口的具体实现。 + +首先来看 ZookeeperDynamicConfiguration 的核心字段。 + + +executor(Executor 类型):用于执行监听器的线程池。 +rootPath(String 类型):以 Zookeeper 作为配置中心时,配置也是以 ZNode 形式存储的,rootPath 记录了所有配置节点的根路径。 +zkClient(ZookeeperClient 类型):与 Zookeeper 集群交互的客户端。 +initializedLatch(CountDownLatch 类型):阻塞等待 ZookeeperDynamicConfiguration 相关的监听器注册完成。 +cacheListener(CacheListener 类型):用于监听配置变化的监听器。 +url(URL 类型):配置中心对应的 URL 对象。 + + +在 ZookeeperDynamicConfiguration 的构造函数中,会初始化上述核心字段,具体实现如下: + +ZookeeperDynamicConfiguration(URL url, ZookeeperTransporter zookeeperTransporter) { + this.url = url; + // 根据URL中的config.namespace参数(默认值为dubbo),确定配置中心ZNode的根路径 + rootPath = PATH_SEPARATOR + url.getParameter(CONFIG_NAMESPACE_KEY, DEFAULT_GROUP) + "/config"; + // 初始化initializedLatch以及cacheListener, + // 在cacheListener注册成功之后,会调用cacheListener.countDown()方法 + initializedLatch = new CountDownLatch(1); + this.cacheListener = new CacheListener(rootPath, initializedLatch); + // 初始化executor字段,用于执行监听器的逻辑 + this.executor = Executors.newFixedThreadPool(1, new NamedThreadFactory(this.getClass().getSimpleName(), true)); + // 初始化Zookeeper客户端 + zkClient = zookeeperTransporter.connect(url); + // 在rootPath上添加cacheListener监听器 + zkClient.addDataListener(rootPath, cacheListener, executor); + try { + // 从URL中获取当前线程阻塞等待Zookeeper监听器注册成功的时长上限 + long timeout = url.getParameter("init.timeout", 5000); + // 阻塞当前线程,等待监听器注册完成 + boolean isCountDown = this.initializedLatch.await(timeout, TimeUnit.MILLISECONDS); + if (!isCountDown) { + throw new IllegalStateException("..."); + } + } catch (InterruptedException e) { + logger.warn("..."); + } +} + + +在上述初始化过程中,ZookeeperDynamicConfiguration 会创建 CacheListener 监听器。在前面[第 15 课时]中,我们介绍了 dubbo-remoting-zookeeper 对外提供了 StateListener、DataListener 和 ChildListener 三种类型的监听器。这里的 CacheListener 就是 DataListener 监听器的具体实现。 + +在 CacheListener 中维护了一个 Map 集合(keyListeners 字段)用于记录所有添加的 ConfigurationListener 监听器,其中 Key 是配置信息在 Zookeeper 中存储的 path,Value 为该 path 上的监听器集合。当某个配置项发生变化的时候,CacheListener 会从 keyListeners 中获取该配置对应的 ConfigurationListener 监听器集合,并逐个进行通知。该逻辑是在 CacheListener 的 dataChanged() 方法中实现的: + +public void dataChanged(String path, Object value, EventType eventType) { + if (eventType == null) { + return; + } + if (eventType == EventType.INITIALIZED) { + // 在收到INITIALIZED事件的时候,表示CacheListener已经成功注册,会释放阻塞在initializedLatch上的主线程 + initializedLatch.countDown(); + return; + } + if (path == null || (value == null && eventType != EventType.NodeDeleted)) { + return; + } + + if (path.split("/").length >= MIN_PATH_DEPTH) { // 对path层数进行过滤 + String key = pathToKey(path); // 将path中的"/"替换成"." + ConfigChangeType changeType; + switch (eventType) { // 将Zookeeper中不同的事件转换成不同的ConfigChangedEvent事件 + case NodeCreated: + changeType = ConfigChangeType.ADDED; + break; + case NodeDeleted: + changeType = ConfigChangeType.DELETED; + break; + case NodeDataChanged: + changeType = ConfigChangeType.MODIFIED; + break; + default: + return; + } + // 使用ConfigChangedEvent封装触发事件的Key、Value、配置group以及事件类型 + ConfigChangedEvent configChangeEvent = new ConfigChangedEvent(key, getGroup(path), (String) value, changeType); + // 从keyListeners集合中获取对应的ConfigurationListener集合,然后逐一进行通知 + Set listeners = keyListeners.get(path); + if (CollectionUtils.isNotEmpty(listeners)) { + listeners.forEach(listener -> listener.process(configChangeEvent)); + } + } +} + + +CacheListener 中调用的监听器都是 ConfigurationListener 接口实现,如下图所示,这里涉及[第 33 课时]介绍的 TagRouter、AppRouter 和 ServiceRouter,它们主要是监听路由配置的变化;还涉及 RegistryDirectory 和 RegistryProtocol 中的四个内部类(AbstractConfiguratorListener 的子类),它们主要监听 Provider 和 Consumer 的配置变化。 + + + +ConfigurationListener 继承关系图 + +这些 ConfigurationListener 实现在前面的课程中已经详细介绍过了,这里就不再重复。ZookeeperDynamicConfiguration 中还提供了 addListener()、removeListener() 两个方法用来增删 ConfigurationListener 监听器,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。 + +介绍完 ZookeeperDynamicConfiguration 的初始化过程之后,我们再来看 ZookeeperDynamicConfiguration 中读取配置、写入配置的相关操作。相关方法的实现如下: + +public Object getInternalProperty(String key) { + // 直接从Zookeeper中读取对应的Key + return zkClient.getContent(key); +} +public boolean publishConfig(String key, String group, String content) { + // getPathKey()方法中会添加rootPath和group两部分信息到Key中 + String path = getPathKey(group, key); + // 在Zookeeper中创建对应ZNode节点用来存储配置信息 + zkClient.create(path, content, false); + return true; +} + + +总结 + +本课时我们重点介绍了 Dubbo 配置中心中的多种配置接口。首先,我们讲解了 Configuration 这个顶层接口的核心方法,然后介绍了 Configuration 接口的相关实现,这些实现可以从环境变量、-D 启动参数、Properties文件以及其他配置文件或注解处读取配置信息。最后,我们还着重介绍了 DynamicConfiguration 这个动态配置接口的定义,并分析了以 Zookeeper 为动态配置中心的 ZookeeperDynamicConfiguration 实现。 + +下一课时,我们将深入介绍 Dubbo 动态配置中心启动的核心流程,记得按时来听课。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/48配置中心设计与实现:集中化配置and本地化配置,我都要(下).md b/专栏/Dubbo源码解读与实战-完/48配置中心设计与实现:集中化配置and本地化配置,我都要(下).md new file mode 100644 index 0000000..db5677c --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/48配置中心设计与实现:集中化配置and本地化配置,我都要(下).md @@ -0,0 +1,304 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 48 配置中心设计与实现:集中化配置 and 本地化配置,我都要(下) + 在上一课时,我们详细分析了 Configuration 接口以及 DynamicConfiguration 接口的实现,其中 DynamicConfiguration 接口实现是动态配置中心的基础。那 Dubbo 中的动态配置中心是如何启动的呢?我们将在本课时详细介绍。 + +基础配置类 + +在 DubboBootstrap 初始化的过程中,会调用 ApplicationModel.initFrameworkExts() 方法初始化所有 FrameworkExt 接口实现,继承关系如下图所示: + + + +FrameworkExt 继承关系图 + +相关代码片段如下: + +public static void initFrameworkExts() { + Set exts = ExtensionLoader.getExtensionLoader(FrameworkExt.class).getSupportedExtensionInstances(); + for (FrameworkExt ext : exts) { + ext.initialize(); + } +} + + +ConfigManager 用于管理当前 Dubbo 节点中全部 AbstractConfig 对象,其中就包括 ConfigCenterConfig 这个实现的对象,我们通过 XML、Annotation 或是 API 方式添加的配置中心的相关信息(例如,配置中心的地址、端口、协议等),会转换成 ConfigCenterConfig 对象。 + +在 Environment 中维护了上一课时介绍的多个 Configuration 对象,具体含义如下。 + + +propertiesConfiguration(PropertiesConfiguration 类型):全部 OrderedPropertiesProvider 实现提供的配置以及环境变量或是 -D 参数中指定配置文件的相关配置信息。 +systemConfiguration(SystemConfiguration 类型):-D 参数配置直接添加的配置信息。 +environmentConfiguration(EnvironmentConfiguration 类型):环境变量中直接添加的配置信息。 +externalConfiguration、appExternalConfiguration(InmemoryConfiguration 类型):使用 Spring 框架且将 include-spring-env 配置为 true 时,会自动从 Spring Environment 中读取配置。默认依次读取 key 为 dubbo.properties 和 application.dubbo.properties 到这里两个 InmemoryConfiguration 对象中。 +globalConfiguration(CompositeConfiguration 类型):用于组合上述各个配置来源。 +dynamicConfiguration(CompositeDynamicConfiguration 类型):用于组合当前全部的配置中心对应的 DynamicConfiguration。 +configCenterFirst(boolean 类型):用于标识配置中心的配置是否为最高优先级。 + + +在 Environment 的构造方法中会初始化上述 Configuration 对象,在 initialize() 方法中会将从 Spring Environment 中读取到的配置填充到 externalConfiguration 以及 appExternalConfiguration 中。相关的实现片段如下: + +public Environment() { + // 创建上述Configuration对象 + this.propertiesConfiguration = new PropertiesConfiguration(); + this.systemConfiguration = new SystemConfiguration(); + this.environmentConfiguration = new EnvironmentConfiguration(); + this.externalConfiguration = new InmemoryConfiguration(); + this.appExternalConfiguration = new InmemoryConfiguration(); +} +public void initialize() throws IllegalStateException { + // 读取对应配置,填充上述Configuration对象 + ConfigManager configManager = ApplicationModel.getConfigManager(); + Optional> defaultConfigs = configManager.getDefaultConfigCenter(); + defaultConfigs.ifPresent(configs -> { + for (ConfigCenterConfig config : configs) { + this.setExternalConfigMap(config.getExternalConfiguration()); + this.setAppExternalConfigMap(config.getAppExternalConfiguration()); + } + }); +this.externalConfiguration.setProperties(externalConfigurationMap); + this.appExternalConfiguration.setProperties(appExternalConfigurationMap); +} + + +启动配置中心 + +完成了 Environment 的初始化之后,DubboBootstrap 接下来会调用 startConfigCenter() 方法启动一个或多个配置中心客户端,核心操作有两个:一个是调用 ConfigCenterConfig.refresh() 方法刷新配置中心的相关配置;另一个是通过 prepareEnvironment() 方法根据 ConfigCenterConfig 中的配置创建 DynamicConfiguration 对象。 + +private void startConfigCenter() { + Collection configCenters = configManager.getConfigCenters(); + if (CollectionUtils.isEmpty(configCenters)) { // 未指定配置中心 + ... ... // 省略该部分逻辑 + } else { + for (ConfigCenterConfig configCenterConfig : configCenters) { // 可能配置了多个配置中心 + configCenterConfig.refresh(); // 刷新配置 + // 检查配置中心的配置是否合法 ConfigValidationUtils.validateConfigCenterConfig(configCenterConfig); + } + } + if (CollectionUtils.isNotEmpty(configCenters)) { + // 创建CompositeDynamicConfiguration对象,用于组装多个DynamicConfiguration对象 + CompositeDynamicConfiguration compositeDynamicConfiguration = new CompositeDynamicConfiguration(); + for (ConfigCenterConfig configCenter : configCenters) { + // 根据ConfigCenterConfig创建相应的DynamicConfig对象,并添加到CompositeDynamicConfiguration中 +compositeDynamicConfiguration.addConfiguration(prepareEnvironment(configCenter)); + } + // 将CompositeDynamicConfiguration记录到Environment中的dynamicConfiguration字段 + environment.setDynamicConfiguration(compositeDynamicConfiguration); + } + configManager.refreshAll(); // 刷新所有AbstractConfig配置 +} + + +1. 刷新配置中心的配置 + +首先来看 ConfigCenterConfig.refresh() 方法,该方法会组合 Environment 对象中全部已初始化的 Configuration,然后遍历 ConfigCenterConfig 中全部字段的 setter 方法,并从 Environment 中获取对应字段的最终值。具体实现如下: + +public void refresh() { + // 获取Environment对象 + Environment env = ApplicationModel.getEnvironment(); + // 将当前已初始化的所有Configuration合并返回 + CompositeConfiguration compositeConfiguration = env.getPrefixedConfiguration(this); + Method[] methods = getClass().getMethods(); + for (Method method : methods) { + if (MethodUtils.isSetter(method)) { // 获取ConfigCenterConfig中各个字段的setter方法 + // 根据配置中心的相关配置以及Environment中的各个Configuration,获取该字段的最终值 + String value = StringUtils.trim(compositeConfiguration.getString(extractPropertyName(getClass(), method))); + // 调用setter方法更新ConfigCenterConfig的相应字段 + if (StringUtils.isNotEmpty(value) && ClassUtils.isTypeMatch(method.getParameterTypes()[0], value)) { + method.invoke(this, ClassUtils.convertPrimitive(method.getParameterTypes()[0], value)); + } + } else if (isParametersSetter(method)) { // 设置parameters字段,与设置其他字段的逻辑基本类似,但是实现有所不同 + String value = StringUtils.trim(compositeConfiguration.getString(extractPropertyName(getClass(), method))); + if (StringUtils.isNotEmpty(value)) { + // 获取当前已有的parameters字段 + Map map = invokeGetParameters(getClass(), this); + map = map == null ? new HashMap<>() : map; + // 覆盖parameters集合 + map.putAll(convert(StringUtils.parseParameters(value), "")); + // 设置parameters字段 + invokeSetParameters(getClass(), this, map); + } + } + } +} + + +这里我们关注一下 Environment.getPrefixedConfiguration() 方法,该方法会将 Environment 中已有的 Configuration 对象以及当前的 ConfigCenterConfig 按照顺序合并,得到一个 CompositeConfiguration 对象,用于确定配置中心的最终配置信息。具体实现如下: + +public synchronized CompositeConfiguration getPrefixedConfiguration(AbstractConfig config) { + // 创建CompositeConfiguration对象,这里的prefix和id是根据ConfigCenterConfig确定的 + CompositeConfiguration prefixedConfiguration = new CompositeConfiguration(config.getPrefix(), config.getId()); + // 将ConfigCenterConfig封装成ConfigConfigurationAdapter + Configuration configuration = new ConfigConfigurationAdapter(config); + if (this.isConfigCenterFirst()) { // 根据配置确定ConfigCenterConfig配置的位置 + // The sequence would be: SystemConfiguration -> AppExternalConfiguration -> ExternalConfiguration -> AbstractConfig -> PropertiesConfiguration + // 按序组合已有Configuration对象以及ConfigCenterConfig + prefixedConfiguration.addConfiguration(systemConfiguration); + prefixedConfiguration.addConfiguration(environmentConfiguration); + prefixedConfiguration.addConfiguration(appExternalConfiguration); + prefixedConfiguration.addConfiguration(externalConfiguration); + prefixedConfiguration.addConfiguration(configuration); + prefixedConfiguration.addConfiguration(propertiesConfiguration); + } else { + // 配置优先级如下:SystemConfiguration -> AbstractConfig -> AppExternalConfiguration -> ExternalConfiguration -> PropertiesConfiguration + prefixedConfiguration.addConfiguration(systemConfiguration); + prefixedConfiguration.addConfiguration(environmentConfiguration); + prefixedConfiguration.addConfiguration(configuration); + prefixedConfiguration.addConfiguration(appExternalConfiguration); + prefixedConfiguration.addConfiguration(externalConfiguration); + prefixedConfiguration.addConfiguration(propertiesConfiguration); + } + return prefixedConfiguration; +} + + +2. 创建 DynamicConfiguration 对象 + +通过 ConfigCenterConfig.refresh() 方法确定了所有配置中心的最终配置之后,接下来就会对每个配置中心执行 prepareEnvironment() 方法,得到对应的 DynamicConfiguration 对象。具体实现如下: + +private DynamicConfiguration prepareEnvironment(ConfigCenterConfig configCenter) { + if (configCenter.isValid()) { // 检查ConfigCenterConfig是否合法 + if (!configCenter.checkOrUpdateInited()) { + return null; // 检查ConfigCenterConfig是否已初始化,这里不能重复初始化 + } + // 根据ConfigCenterConfig中的各个字段,拼接出配置中心的URL,创建对应的DynamicConfiguration对象 + DynamicConfiguration dynamicConfiguration = getDynamicConfiguration(configCenter.toUrl()); + // 从配置中心获取externalConfiguration和appExternalConfiguration,并进行覆盖 + String configContent = dynamicConfiguration.getProperties(configCenter.getConfigFile(), configCenter.getGroup()); + + String appGroup = getApplication().getName(); + String appConfigContent = null; + if (isNotEmpty(appGroup)) { + appConfigContent = dynamicConfiguration.getProperties + (isNotEmpty(configCenter.getAppConfigFile()) ? configCenter.getAppConfigFile() : configCenter.getConfigFile(), + appGroup + ); + } + try { + // 更新Environment + environment.setConfigCenterFirst(configCenter.isHighestPriority()); + environment.updateExternalConfigurationMap(parseProperties(configContent)); + environment.updateAppExternalConfigurationMap(parseProperties(appConfigContent)); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse configurations from Config Center.", e); + } + return dynamicConfiguration; // 返回通过该ConfigCenterConfig创建的DynamicConfiguration对象 + } + return null; +} + + +完成 DynamicConfiguration 的创建之后,DubboBootstrap 会将多个配置中心对应的 DynamicConfiguration 对象封装成一个 CompositeDynamicConfiguration 对象,并记录到 Environment.dynamicConfiguration 字段中,等待后续使用。另外,还会调用全部 AbstractConfig 的 refresh() 方法(即根据最新的配置更新各个 AbstractConfig 对象的字段)。这些逻辑都在 DubboBootstrap.startConfigCenter() 方法中,前面已经展示过了,这里不再重复。 + +配置中心初始化的后续流程 + +完成明确指定的配置中心初始化之后,DubboBootstrap 接下来会执行 useRegistryAsConfigCenterIfNecessary() 方法,检测当前 Dubbo 是否要将注册中心也作为一个配置中心使用(常见的注册中心,都可以直接作为配置中心使用,这样可以降低运维成本)。 + +private void useRegistryAsConfigCenterIfNecessary() { + if (environment.getDynamicConfiguration().isPresent()) { + return; // 如果当前配置中心已经初始化完成,则不会将注册中心作为配置中心 + } + if (CollectionUtils.isNotEmpty(configManager.getConfigCenters())) { + return; // 明确指定了配置中心的配置,哪怕配置中心初始化失败,也不会将注册中心作为配置中心 + } + // 从ConfigManager中获取注册中心的配置(即RegistryConfig),并转换成配置中心的配置(即ConfigCenterConfig) + configManager.getDefaultRegistries().stream() + .filter(registryConfig -> registryConfig.getUseAsConfigCenter() == null || registryConfig.getUseAsConfigCenter()) + .forEach(registryConfig -> { + String protocol = registryConfig.getProtocol(); + String id = "config-center-" + protocol + "-" + registryConfig.getPort(); + ConfigCenterConfig cc = new ConfigCenterConfig(); + cc.setId(id); + if (cc.getParameters() == null) { + cc.setParameters(new HashMap<>()); + } + if (registryConfig.getParameters() != null) { + cc.getParameters().putAll(registryConfig.getParameters()); + } + cc.getParameters().put(CLIENT_KEY, registryConfig.getClient()); + cc.setProtocol(registryConfig.getProtocol()); + cc.setPort(registryConfig.getPort()); + cc.setAddress(registryConfig.getAddress()); + cc.setNamespace(registryConfig.getGroup()); + cc.setUsername(registryConfig.getUsername()); + cc.setPassword(registryConfig.getPassword()); + if (registryConfig.getTimeout() != null) { + cc.setTimeout(registryConfig.getTimeout().longValue()); + } + cc.setHighestPriority(false); // 这里优先级较低 + configManager.addConfigCenter(cc); + }); + startConfigCenter(); // 重新调用startConfigCenter()方法,初始化配置中心 +} + + +完成配置中心的初始化之后,后续需要 DynamicConfiguration 的地方直接从 Environment 中获取即可,例如,DynamicConfigurationServiceNameMapping 就是依赖 DynamicConfiguration 实现 Service ID 与 Service Name 映射的管理。 + +接下来,DubboBootstrap 执行 loadRemoteConfigs() 方法,根据前文更新后的 externalConfigurationMap 和 appExternalConfigurationMap 配置信息,确定是否配置了额外的注册中心或 Protocol,如果有,则在此处转换成 RegistryConfig 和 ProtocolConfig,并记录到 ConfigManager 中,等待后续逻辑使用。 + +随后,DubboBootstrap 执行 checkGlobalConfigs() 方法完成 ProviderConfig、ConsumerConfig、MetadataReportConfig 等一系列 AbstractConfig 的检查和初始化,具体实现比较简单,这里就不再展示。 + +再紧接着,DubboBootstrap 会通过 initMetadataService() 方法初始化 MetadataReport、MetadataReportInstance 以及 MetadataService、MetadataServiceExporter,这些元数据相关的组件在前面的课时中已经深入分析过了,这里的初始化过程并不复杂,你若感兴趣的话可以参考源码进行学习。 + +在 DubboBootstrap 初始化的最后,会调用 initEventListener() 方法将 DubboBootstrap 作为 EventListener 监听器添加到 EventDispatcher 中。DubboBootstrap 继承了 GenericEventListener 抽象类,如下图所示: + + + +EventListener 继承关系图 + +GenericEventListener 是一个泛型监听器,它可以让子类监听任意关心的 Event 事件,只需定义相关的 onEvent() 方法即可。在 GenericEventListener 中维护了一个 handleEventMethods 集合,其中 Key 是 Event 的子类,即监听器关心的事件,Value 是处理该类型 Event 的相应 onEvent() 方法。 + +在 GenericEventListener 的构造方法中,通过反射将当前 GenericEventListener 实现的全部 onEvent() 方法都查找出来,并记录到 handleEventMethods 字段中。具体查找逻辑在 findHandleEventMethods() 方法中实现: + +private Map, Set> findHandleEventMethods() { + Map, Set> eventMethods = new HashMap<>(); + of(getClass().getMethods()) // 遍历当前GenericEventListener子类的全部方法 + // 过滤得到onEvent()方法,具体过滤条件在isHandleEventMethod()方法之中: + // 1.方法必须是public的 + // 2.方法参数列表只有一个参数,且该参数为Event子类 + // 3.方法返回值为void,且没有声明抛出异常 + .filter(this::isHandleEventMethod) + .forEach(method -> { + Class paramType = method.getParameterTypes()[0]; + Set methods = eventMethods.computeIfAbsent(paramType, key -> new LinkedHashSet<>()); + methods.add(method); + }); + return eventMethods; +} + + +在 GenericEventListener 的 onEvent() 方法中,会根据收到的 Event 事件的具体类型,从 handleEventMethods 集合中找到相应的 onEvent() 方法进行调用,如下所示: + +public final void onEvent(Event event) { + // 获取Event的实际类型 + Class eventClass = event.getClass(); + // 根据Event的类型获取对应的onEvent()方法并调用 + handleEventMethods.getOrDefault(eventClass, emptySet()).forEach(method -> { + ThrowableConsumer.execute(method, m -> { + m.invoke(this, event); + }); + }); +} + + +我们可以查看 DubboBootstrap 的所有方法,目前并没有发现符合 isHandleEventMethod() 条件的方法。但在 GenericEventListener 的另一个实现—— LoggingEventListener 中,可以看到多个符合 isHandleEventMethod() 条件的方法(如下图所示),在这些 onEvent() 方法重载中会输出 INFO 日志。 + + + +LoggingEventListener 中 onEvent 方法重载 + +至此,DubboBootstrap 整个初始化过程,以及该过程中与配置中心相关的逻辑就介绍完了。 + +总结 + +本课时我们重点介绍了 Dubbo 动态配置中心启动的核心流程,以及该流程涉及的重要组件类。 + +首先,我们介绍了 ConfigManager 和 Environment 这两个非常基础的配置类;然后又讲解了 DubboBootstrap 初始化动态配置中心的核心流程,以及动态配置中心启动的流程;最后,还分析了 GenericEventListener 监听器的相关内容。 + +关于这部分的内容,如果你有什么问题或者好的经验,欢迎你在留言区和我分享。 + + + + \ No newline at end of file diff --git a/专栏/Dubbo源码解读与实战-完/49结束语认真学习,缩小差距.md b/专栏/Dubbo源码解读与实战-完/49结束语认真学习,缩小差距.md new file mode 100644 index 0000000..3189df3 --- /dev/null +++ b/专栏/Dubbo源码解读与实战-完/49结束语认真学习,缩小差距.md @@ -0,0 +1,25 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 49 结束语 认真学习,缩小差距 + 你好,我是杨四正,到这里我们已经一起学习了四十多个课时,Dubbo 的核心内容也介绍差不多了,你可能也需要一段时间来回顾和消化这些内容。在最后这结束语部分,我还想和你“谈谈心”,从另一个角度来聊聊我们程序员这份工作。 + +在刚毕业的时候,我误打误撞进入一家国营企业,很多人认为这是一个“旱涝保收”的养老岗位,其实呢,也确实如此,工资没有互联网企业有竞争力,但是工作时长足以让“996”的程序员垂涎三尺。因为是第一份工作,所以总会碰到很多问题,但我发现在这个环境中很难从旁人那里得到答案,于是我就开始一边自己解决问题,一边反思与人沟通的方式。自己解决问题让我延续了学校里面的学习“惯性”,养成了持续学习的习惯;反思沟通方式,让我意识到人是有惰性的,人更喜欢用选择的方式解决问题,所以我养成了提出问题时,自带多个解决方案的习惯。这也反过来促使我在提问题之前,反复思考和打磨问题,毕竟提出一个好问题也是一种能力。 + +两年之后,我进入一家高速发展的互联网公司,在这里我经历了职业生涯里面的第一个“阵痛期”,可以说是从“闲庭信步”一步跨到“身心俱疲”,技术栈、作息规律、工作节奏等完全变了,其痛苦程度可想而知。 + +在这段时间,我体会最深的是要顺势而为,抓住行业的红利期,抓住公司的红利期,这可以更快地帮我实现薪资和职位的升级。另一个心得就是要学会适时抛弃“木桶原理”,不要补齐短板。因为我们走的是技术路线,要做的是不可替代,尽量成为一方面的专家,而不是处处稀松平常的通才,毕竟“内卷”越来越严重,“木桶”到处都有。 + +另外,还有一个非常重要的“点”就是:面对失败的态度。工作了这么多年,面试失败过,晋级失败过,也看过很多人不同的人生轨迹:有人离开奋斗多年的一线城市;有人埋头在西二旗的写字楼里,接收福报的洗礼,已经很久没见过夕阳是什么样子;有人创业失败,负债千万……这些都算是失败吗?可能不同的人有不同的答案,毕竟每个人对失败的定义不同,答案自然也会不同。 + +不管怎样,人生旅途中难免沟沟坎坎,挫折或失败似乎是人生的主旋律(注意是“似乎”,人生还是很美好的),不用纠结,每个人都会遇到,但如何面对挫折或失败会把我们分成不同的“队伍”:有的人会被击垮,从此一蹶不振;而有的人会站起来继续向前,越挫越勇,直至实现自己的人生目标和价值。所以说,真正的成长从来不是追求,而是正视自己的缺憾。 + +感谢 2020 年不断学习的你,感谢你的一路陪伴,也期待你继续“认真学习,缩小差距”。 + +当然如果你觉得我这门课程不错的话,也欢迎你推荐给身边的朋友。 + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/01认知:ElasticSearch基础概念.md b/专栏/ElasticSearch知识体系详解/01认知:ElasticSearch基础概念.md new file mode 100644 index 0000000..3b77e8f --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/01认知:ElasticSearch基础概念.md @@ -0,0 +1,140 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 认知:ElasticSearch基础概念 + 为什么需要学习ElasticSearch + + +根据DB Engine的排名 显示,ElasticSearch是最受欢迎的企业级搜索引擎。 + + +下图红色勾选的是我们前面的系列详解的,除此之外你可以看到搜索库ElasticSearch在前十名内: + + + +所以为什么要学习ElasticSearch呢? + +1、在当前软件行业中,搜索是一个软件系统或平台的基本功能, 学习ElasticSearch就可以为相应的软件打造出良好的搜索体验。 + +2、其次,ElasticSearch具备非常强的大数据分析能力。虽然Hadoop也可以做大数据分析,但是ElasticSearch的分析能力非常高,具备Hadoop不具备的能力。比如有时候用Hadoop分析一个结果,可能等待的时间比较长。 + +3、ElasticSearch可以很方便的进行使用,可以将其安装在个人的笔记本电脑,也可以在生产环境中,将其进行水平扩展。 + +4、国内比较大的互联网公司都在使用,比如小米、滴滴、携程等公司。另外,在腾讯云、阿里云的云平台上,也都有相应的ElasticSearch云产品可以使用。 + +5、在当今大数据时代,掌握近实时的搜索和分析能力,才能掌握核心竞争力,洞见未来。 + +什么是ElasticSearch + + +ElasticSearch是一款非常强大的、基于Lucene的开源搜索及分析引擎;它是一个实时的分布式搜索分析引擎,它能让你以前所未有的速度和规模,去探索你的数据。 + + +它被用作全文检索、结构化搜索、分析以及这三个功能的组合: + + +Wikipedia 使用 Elasticsearch 提供带有高亮片段的全文搜索,还有 search-as-you-type 和 did-you-mean 的建议。 +卫报 使用 Elasticsearch 将网络社交数据结合到访客日志中,为它的编辑们提供公众对于新文章的实时反馈。 +Stack Overflow 将地理位置查询融入全文检索中去,并且使用 more-like-this 接口去查找相关的问题和回答。 +GitHub 使用 Elasticsearch 对1300亿行代码进行查询。 +… + + +除了搜索,结合Kibana、Logstash、Beats开源产品,Elastic Stack(简称ELK)还被广泛运用在大数据近实时分析领域,包括:日志分析、指标监控、信息安全等。它可以帮助你探索海量结构化、非结构化数据,按需创建可视化报表,对监控数据设置报警阈值,通过使用机器学习,自动识别异常状况。 + +ElasticSearch是基于Restful WebApi,使用Java语言开发的搜索引擎库类,并作为Apache许可条款下的开放源码发布,是当前流行的企业级搜索引擎。其客户端在Java、C#、PHP、Python等许多语言中都是可用的。 + +ElasticSearch的由来 + + +ElasticSearch背后的小故事 + + +许多年前,一个刚结婚的名叫 Shay Banon 的失业开发者,跟着他的妻子去了伦敦,他的妻子在那里学习厨师。 在寻找一个赚钱的工作的时候,为了给他的妻子做一个食谱搜索引擎,他开始使用 Lucene 的一个早期版本。 + +直接使用 Lucene 是很难的,因此 Shay 开始做一个抽象层,Java 开发者使用它可以很简单的给他们的程序添加搜索功能。 他发布了他的第一个开源项目 Compass。 + +后来 Shay 获得了一份工作,主要是高性能,分布式环境下的内存数据网格。这个对于高性能,实时,分布式搜索引擎的需求尤为突出, 他决定重写 Compass,把它变为一个独立的服务并取名 Elasticsearch。 + +第一个公开版本在2010年2月发布,从此以后,Elasticsearch 已经成为了 Github 上最活跃的项目之一,他拥有超过300名 contributors(目前736名 contributors )。 一家公司已经开始围绕 Elasticsearch 提供商业服务,并开发新的特性,但是,Elasticsearch 将永远开源并对所有人可用。 + +据说,Shay 的妻子还在等着她的食谱搜索引擎… + +为什么不是直接使用Lucene + + +ElasticSearch是基于Lucene的,那么为什么不是直接使用Lucene呢? + + +Lucene 可以说是当下最先进、高性能、全功能的搜索引擎库。 + +但是 Lucene 仅仅只是一个库。为了充分发挥其功能,你需要使用 Java 并将 Lucene 直接集成到应用程序中。 更糟糕的是,您可能需要获得信息检索学位才能了解其工作原理。Lucene 非常 复杂。 + +Elasticsearch 也是使用 Java 编写的,它的内部使用 Lucene 做索引与搜索,但是它的目的是使全文检索变得简单,通过隐藏 Lucene 的复杂性,取而代之的提供一套简单一致的 RESTful API。 + +然而,Elasticsearch 不仅仅是 Lucene,并且也不仅仅只是一个全文搜索引擎。 它可以被下面这样准确的形容: + + +一个分布式的实时文档存储,每个字段 可以被索引与搜索 +一个分布式实时分析搜索引擎 +能胜任上百个服务节点的扩展,并支持 PB 级别的结构化或者非结构化数据 + + +ElasticSearch的主要功能及应用场景 + + +我们在哪些场景下可以使用ES呢? + + + +主要功能: + + +1)海量数据的分布式存储以及集群管理,达到了服务与数据的高可用以及水平扩展; + +2)近实时搜索,性能卓越。对结构化、全文、地理位置等类型数据的处理; + +3)海量数据的近实时分析(聚合功能) + + +应用场景: + + +1)网站搜索、垂直搜索、代码搜索; + +2)日志管理与分析、安全指标监控、应用性能监控、Web抓取舆情分析; + +ElasticSearch的基础概念 + + +我们还需对比结构化数据库,看看ES的基础概念,为我们后面学习作铺垫。 + + + +Near Realtime(NRT) 近实时。数据提交索引后,立马就可以搜索到。 +Cluster 集群,一个集群由一个唯一的名字标识,默认为“elasticsearch”。集群名称非常重要,具有相同集群名的节点才会组成一个集群。集群名称可以在配置文件中指定。 +Node 节点:存储集群的数据,参与集群的索引和搜索功能。像集群有名字,节点也有自己的名称,默认在启动时会以一个随机的UUID的前七个字符作为节点的名字,你可以为其指定任意的名字。通过集群名在网络中发现同伴组成集群。一个节点也可是集群。 +Index 索引: 一个索引是一个文档的集合(等同于solr中的集合)。每个索引有唯一的名字,通过这个名字来操作它。一个集群中可以有任意多个索引。 +Type 类型:指在一个索引中,可以索引不同类型的文档,如用户数据、博客数据。从6.0.0 版本起已废弃,一个索引中只存放一类数据。 +Document 文档:被索引的一条数据,索引的基本信息单元,以JSON格式来表示。 +Shard 分片:在创建一个索引时可以指定分成多少个分片来存储。每个分片本身也是一个功能完善且独立的“索引”,可以被放置在集群的任意节点上。 +Replication 备份: 一个分片可以有多个备份(副本) + + +为了方便理解,作一个ES和数据库的对比 + + + +参考文章 + + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/intro.html +https://www.elastic.co/guide/cn/elasticsearch/guide/current/getting-started.html +https://www.cnblogs.com/leeSmall/p/9189078.html + + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/02认知:ElasticStack生态和场景方案.md b/专栏/ElasticSearch知识体系详解/02认知:ElasticStack生态和场景方案.md new file mode 100644 index 0000000..d2ba1cf --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/02认知:ElasticStack生态和场景方案.md @@ -0,0 +1,173 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 认知:Elastic Stack生态和场景方案 + Elastic Stack生态 + + +Beats + Logstash + ElasticSearch + Kibana + + +如下是我从官方博客中找到图,这张图展示了ELK生态以及基于ELK的场景(最上方) + + + +由于Elastic X-Pack是面向收费的,所以我们不妨也把X-Pack放进去,看看哪些是由X-Pack带来的,在阅读官网文档时将方便你甄别重点: + + + +Beats + +Beats是一个面向轻量型采集器的平台,这些采集器可以从边缘机器向Logstash、ElasticSearch发送数据,它是由Go语言进行开发的,运行效率方面比较快。从下图中可以看出,不同Beats的套件是针对不同的数据源。 + + + +Logstash + +Logstash是动态数据收集管道,拥有可扩展的插件生态系统,支持从不同来源采集数据,转换数据,并将数据发送到不同的存储库中。其能够与ElasticSearch产生强大的协同作用,后被Elastic公司在2013年收购。 + +它具有如下特性: + +1)实时解析和转换数据; + +2)可扩展,具有200多个插件; + +3)可靠性、安全性。Logstash会通过持久化队列来保证至少将运行中的事件送达一次,同时将数据进行传输加密; + +4)监控; + +ElasticSearch + +ElasticSearch对数据进行搜索、分析和存储,其是基于JSON的分布式搜索和分析引擎,专门为实现水平可扩展性、高可靠性和管理便捷性而设计的。 + +它的实现原理主要分为以下几个步骤: + +1)首先用户将数据提交到ElasticSearch数据库中; + +2)再通过分词控制器将对应的语句分词; + +3)将分词结果及其权重一并存入,以备用户在搜索数据时,根据权重将结果排名和打分,将返回结果呈现给用户; + +Kibana + +Kibana实现数据可视化,其作用就是在ElasticSearch中进行民航。Kibana能够以图表的形式呈现数据,并且具有可扩展的用户界面,可以全方位的配置和管理ElasticSearch。 + +Kibana最早的时候是基于Logstash创建的工具,后被Elastic公司在2013年收购。 + +1)Kibana可以提供各种可视化的图表; + +2)可以通过机器学习的技术,对异常情况进行检测,用于提前发现可疑问题; + +从日志收集系统看ES Stack的发展 + + +我们看下ELK技术栈的演化,通常体现在日志收集系统中。 + + +一个典型的日志系统包括: + +(1)收集:能够采集多种来源的日志数据 + +(2)传输:能够稳定的把日志数据解析过滤并传输到存储系统 + +(3)存储:存储日志数据 + +(4)分析:支持 UI 分析 + +(5)警告:能够提供错误报告,监控机制 + +beats+elasticsearch+kibana + +Beats采集数据后,存储在ES中,有Kibana可视化的展示。 + + + +beats+logstath+elasticsearch+kibana + + + +该框架是在上面的框架的基础上引入了logstash,引入logstash带来的好处如下: + +(1)Logstash具有基于磁盘的自适应缓冲系统,该系统将吸收传入的吞吐量,从而减轻背压。 + +(2)从其他数据源(例如数据库,S3或消息传递队列)中提取。 + +(3)将数据发送到多个目的地,例如S3,HDFS或写入文件。 + +(4)使用条件数据流逻辑组成更复杂的处理管道。 + +beats结合logstash带来的优势: + +(1)水平可扩展性,高可用性和可变负载处理:beats和logstash可以实现节点之间的负载均衡,多个logstash可以实现logstash的高可用 + +(2)消息持久性与至少一次交付保证:使用beats或Winlogbeat进行日志收集时,可以保证至少一次交付。从Filebeat或Winlogbeat到Logstash以及从Logstash到Elasticsearch的两种通信协议都是同步的,并且支持确认。Logstash持久队列提供跨节点故障的保护。对于Logstash中的磁盘级弹性,确保磁盘冗余非常重要。 + +(3)具有身份验证和有线加密的端到端安全传输:从Beats到Logstash以及从 Logstash到Elasticsearch的传输都可以使用加密方式传递 。与Elasticsearch进行通讯时,有很多安全选项,包括基本身份验证,TLS,PKI,LDAP,AD和其他自定义领域 + +增加更多的数据源 比如:TCP,UDP和HTTP协议是将数据输入Logstash的常用方法 + + + +beats+MQ+logstash+elasticsearch+kibana + + + +在如上的基础上我们可以在beats和logstash中间添加一些组件redis、kafka、RabbitMQ等,添加中间件将会有如下好处: + +(1)降低对日志所在机器的影响,这些机器上一般都部署着反向代理或应用服务,本身负载就很重了,所以尽可能的在这些机器上少做事; + +(2)如果有很多台机器需要做日志收集,那么让每台机器都向Elasticsearch持续写入数据,必然会对Elasticsearch造成压力,因此需要对数据进行缓冲,同时,这样的缓冲也可以一定程度的保护数据不丢失; + +(3)将日志数据的格式化与处理放到Indexer中统一做,可以在一处修改代码、部署,避免需要到多台机器上去修改配置; + +Elastic Stack最佳实践 + + +我们再看下官方开发成员分享的最佳实践。 + + +日志收集系统 + +(PS:就是我们上面阐述的) + +基本的日志系统 + + + +增加数据源,和使用MQ + + + +Metric收集和APM性能监控 + + + +多数据中心方案 + +通过冗余实现数据高可用 + + + +两个数据采集中心(比如采集两个工厂的数据),采集数据后的汇聚 + + + +数据分散,跨集群的搜索 + + + +参考文章 + + +https://www.elastic.co/cn/elasticsearch/ +https://www.elastic.co/pdf/architecture-best-practices.pdf +https://www.elastic.co/guide/en/logstash/current/deploying-and-scaling.html +https://www.cnblogs.com/supersnowyao/p/11110703.html +https://blog.51cto.com/wutengfei/2645627 + + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/03安装:ElasticSearch和Kibana安装.md b/专栏/ElasticSearch知识体系详解/03安装:ElasticSearch和Kibana安装.md new file mode 100644 index 0000000..14198dc --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/03安装:ElasticSearch和Kibana安装.md @@ -0,0 +1,293 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 安装:ElasticSearch和Kibana安装 + 安装ElasticSearch + + +ElasticSearch 是基于Java平台的,所以先要安装Java + + + +平台确认 + + +这里我准备了一台Centos7虚拟机, 为方便选择后续安装的版本,所以需要看下系统版本信息。 + +[root@VM-0-14-centos ~]# uname -a +Linux VM-0-14-centos 3.10.0-862.el7.x86_64 #1 SMP Fri Apr 20 16:44:24 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux + + + + +安装Java + + +安装 Elasticsearch 之前,你需要先安装一个较新的版本的 Java,最好的选择是,你可以从 www.java.com 获得官方提供的最新版本的 Java。安装以后,确认是否安装成功: + +[root@VM-0-14-centos ~]# java --version +openjdk 14.0.2 2020-07-14 +OpenJDK Runtime Environment 20.3 (slowdebug build 14.0.2+12) +OpenJDK 64-Bit Server VM 20.3 (slowdebug build 14.0.2+12, mixed mode, sharing) + + + + +下载ElasticSearch + + +从这里 下载ElasticSearch + +比如可以通过curl下载 + +[root@VM-0-14-centos opt]# curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.12.0-linux-x86_64.tar.gz + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + + + + +解压 + + +[root@VM-0-14-centos opt]# tar zxvf /opt/elasticsearch-7.12.0-linux-x86_64.tar.gz +... +[root@VM-0-14-centos opt]# ll | grep elasticsearch +drwxr-xr-x 9 root root 4096 Mar 18 14:21 elasticsearch-7.12.0 +-rw-r--r-- 1 root root 327497331 Apr 5 21:05 elasticsearch-7.12.0-linux-x86_64.tar.gz + + + + +增加elasticSearch用户 + + +必须创建一个非root用户来运行ElasticSearch(ElasticSearch5及以上版本,基于安全考虑,强制规定不能以root身份运行。) + +如果你使用root用户来启动ElasticSearch,则会有如下错误信息: + +[root@VM-0-14-centos opt]# cd elasticsearch-7.12.0/ +[root@VM-0-14-centos elasticsearch-7.12.0]# ./bin/elasticsearch +[2021-04-05T21:36:46,510][ERROR][o.e.b.ElasticsearchUncaughtExceptionHandler] [VM-0-14-centos] uncaught exception in thread [main] +org.elasticsearch.bootstrap.StartupException: java.lang.RuntimeException: can not run elasticsearch as root + at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:163) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.bootstrap.Elasticsearch.execute(Elasticsearch.java:150) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.cli.EnvironmentAwareCommand.execute(EnvironmentAwareCommand.java:75) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.cli.Command.mainWithoutErrorHandling(Command.java:116) ~[elasticsearch-cli-7.12.0.jar:7.12.0] + at org.elasticsearch.cli.Command.main(Command.java:79) ~[elasticsearch-cli-7.12.0.jar:7.12.0] + at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:115) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:81) ~[elasticsearch-7.12.0.jar:7.12.0] +Caused by: java.lang.RuntimeException: can not run elasticsearch as root + at org.elasticsearch.bootstrap.Bootstrap.initializeNatives(Bootstrap.java:101) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.bootstrap.Bootstrap.setup(Bootstrap.java:168) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.bootstrap.Bootstrap.init(Bootstrap.java:397) ~[elasticsearch-7.12.0.jar:7.12.0] + at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:159) ~[elasticsearch-7.12.0.jar:7.12.0] + ... 6 more +uncaught exception in thread [main] +java.lang.RuntimeException: can not run elasticsearch as root + at org.elasticsearch.bootstrap.Bootstrap.initializeNatives(Bootstrap.java:101) + at org.elasticsearch.bootstrap.Bootstrap.setup(Bootstrap.java:168) + at org.elasticsearch.bootstrap.Bootstrap.init(Bootstrap.java:397) + at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:159) + at org.elasticsearch.bootstrap.Elasticsearch.execute(Elasticsearch.java:150) + at org.elasticsearch.cli.EnvironmentAwareCommand.execute(EnvironmentAwareCommand.java:75) + at org.elasticsearch.cli.Command.mainWithoutErrorHandling(Command.java:116) + at org.elasticsearch.cli.Command.main(Command.java:79) + at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:115) + at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:81) +For complete error details, refer to the log at /opt/elasticsearch-7.12.0/logs/elasticsearch.log +2021-04-05 13:36:46,979269 UTC [8846] INFO Main.cc@106 Parent process died - ML controller exiting + + + +所以我们增加一个独立的elasticsearch用户来运行 + +# 增加elasticsearch用户 +[root@VM-0-14-centos elasticsearch-7.12.0]# useradd elasticsearch +[root@VM-0-14-centos elasticsearch-7.12.0]# passwd elasticsearch +Changing password for user elasticsearch. +New password: +BAD PASSWORD: The password contains the user name in some form +Retype new password: +passwd: all authentication tokens updated successfully. + +# 修改目录权限至新增的elasticsearch用户 +[root@VM-0-14-centos elasticsearch-7.12.0]# chown -R elasticsearch /opt/elasticsearch-7.12.0 +# 增加data和log存放区,并赋予elasticsearch用户权限 +[root@VM-0-14-centos elasticsearch-7.12.0]# mkdir -p /data/es +[root@VM-0-14-centos elasticsearch-7.12.0]# chown -R elasticsearch /data/es +[root@VM-0-14-centos elasticsearch-7.12.0]# mkdir -p /var/log/es +[root@VM-0-14-centos elasticsearch-7.12.0]# chown -R elasticsearch /var/log/es + + + + +然后修改上述的data和log路径,vi /opt/elasticsearch-7.12.0/config/elasticsearch.yml + +# ----------------------------------- Paths ------------------------------------ +# +# Path to directory where to store the data (separate multiple locations by comma): +# +path.data: /data/es +# +# Path to log files: +# +path.logs: /var/log/es + + + + +修改Linux系统的限制配置 + + + +修改系统中允许应用最多创建多少文件等的限制权限。Linux默认来说,一般限制应用最多创建的文件是65535个。但是ES至少需要65536的文件创建权限。 +修改系统中允许用户启动的进程开启多少个线程。默认的Linux限制root用户开启的进程可以开启任意数量的线程,其他用户开启的进程可以开启1024个线程。必须修改限制数为4096+。因为ES至少需要4096的线程池预备。ES在5.x版本之后,强制要求在linux中不能使用root用户启动ES进程。所以必须使用其他用户启动ES进程才可以。 +Linux低版本内核为线程分配的内存是128K。4.x版本的内核分配的内存更大。如果虚拟机的内存是1G,最多只能开启3000+个线程数。至少为虚拟机分配1.5G以上的内存。 + + +修改如下配置 + +[root@VM-0-14-centos elasticsearch-7.12.0]# vi /etc/security/limits.conf + +elasticsearch soft nofile 65536 +elasticsearch hard nofile 65536 +elasticsearch soft nproc 4096 +elasticsearch hard nproc 4096 + + + + +启动ElasticSearch + + +[root@VM-0-14-centos elasticsearch-7.12.0]# su elasticsearch +[elasticsearch@VM-0-14-centos elasticsearch-7.12.0]$ ./bin/elasticsearch -d +[2021-04-05T22:03:38,332][INFO ][o.e.n.Node ] [VM-0-14-centos] version[7.12.0], pid[13197], build[default/tar/78722783c38caa25a70982b5b042074cde5d3b3a/2021-03-18T06:17:15.410153305Z], OS[Linux/3.10.0-862.el7.x86_64/amd64], JVM[AdoptOpenJDK/OpenJDK 64-Bit Server VM/15.0.1/15.0.1+9] +[2021-04-05T22:03:38,348][INFO ][o.e.n.Node ] [VM-0-14-centos] JVM home [/opt/elasticsearch-7.12.0/jdk], using bundled JDK [true] +[2021-04-05T22:03:38,348][INFO ][o.e.n.Node ] [VM-0-14-centos] JVM arguments [-Xshare:auto, -Des.networkaddress.cache.ttl=60, -Des.networkaddress.cache.negative.ttl=10, -XX:+AlwaysPreTouch, -Xss1m, -Djava.awt.headless=true, -Dfile.encoding=UTF-8, -Djna.nosys=true, -XX:-OmitStackTraceInFastThrow, -XX:+ShowCodeDetailsInExceptionMessages, -Dio.netty.noUnsafe=true, -Dio.netty.noKeySetOptimization=true, -Dio.netty.recycler.maxCapacityPerThread=0, -Dio.netty.allocator.numDirectArenas=0, -Dlog4j.shutdownHookEnabled=false, -Dlog4j2.disable.jmx=true, -Djava.locale.providers=SPI,COMPAT, --add-opens=java.base/java.io=ALL-UNNAMED, -XX:+UseG1GC, -Djava.io.tmpdir=/tmp/elasticsearch-17264135248464897093, -XX:+HeapDumpOnOutOfMemoryError, -XX:HeapDumpPath=data, -XX:ErrorFile=logs/hs_err_pid%p.log, -Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m, -Xms1894m, -Xmx1894m, -XX:MaxDirectMemorySize=993001472, -XX:G1HeapRegionSize=4m, -XX:InitiatingHeapOccupancyPercent=30, -XX:G1ReservePercent=15, -Des.path.home=/opt/elasticsearch-7.12.0, -Des.path.conf=/opt/elasticsearch-7.12.0/config, -Des.distribution.flavor=default, -Des.distribution.type=tar, -Des.bundled_jdk=true] + + + + +查看安装是否成功 + + +[root@VM-0-14-centos ~]# netstat -ntlp | grep 9200 +tcp6 0 0 127.0.0.1:9200 :::* LISTEN 13549/java +tcp6 0 0 ::1:9200 :::* LISTEN 13549/java +[root@VM-0-14-centos ~]# curl 127.0.0.1:9200 +{ + "name" : "VM-0-14-centos", + "cluster_name" : "elasticsearch", + "cluster_uuid" : "ihttW8b2TfWSkwf_YgPH2Q", + "version" : { + "number" : "7.12.0", + "build_flavor" : "default", + "build_type" : "tar", + "build_hash" : "78722783c38caa25a70982b5b042074cde5d3b3a", + "build_date" : "2021-03-18T06:17:15.410153305Z", + "build_snapshot" : false, + "lucene_version" : "8.8.0", + "minimum_wire_compatibility_version" : "6.8.0", + "minimum_index_compatibility_version" : "6.0.0-beta1" + }, + "tagline" : "You Know, for Search" +} + + + +安装Kibana + + +Kibana是界面化的查询数据的工具,下载时尽量下载与ElasicSearch一致的版本。 + + + +下载Kibana + + +从这里 下载Kibana + + +解压 + + +[root@VM-0-14-centos opt]# tar -vxzf kibana-7.12.0-linux-x86_64.tar.gz + + + + +使用elasticsearch用户权限 + + +[root@VM-0-14-centos opt]# chown -R elasticsearch /opt/kibana-7.12.0-linux-x86_64 +#配置Kibana的远程访问 +[root@VM-0-14-centos opt]# vi /opt/kibana-7.12.0-linux-x86_64/config/kibana.yml +server.host: 0.0.0.0 + + + + +启动 + + +需要切换至elasticsearch用户 + +[root@VM-0-14-centos opt]# su elasticsearch +[elasticsearch@VM-0-14-centos opt]$ cd /opt/kibana-7.12.0-linux-x86_64/ +[elasticsearch@VM-0-14-centos kibana-7.12.0-linux-x86_64]$ ./bin/kibana + log [22:30:22.185] [info][plugins-service] Plugin "osquery" is disabled. + log [22:30:22.283] [warning][config][deprecation] Config key [monitoring.cluster_alerts.email_notifications.email_address] will be required for email notifications to work in 8.0." + log [22:30:22.482] [info][plugins-system] Setting up [100] plugins: [taskManager,licensing,globalSearch,globalSearchProviders,banners,code,usageCollection,xpackLegacy,telemetryCollectionManager,telemetry,telemetryCollectionXpack,kibanaUsageCollection,securityOss,share,newsfeed,mapsLegacy,kibanaLegacy,translations,legacyExport,embeddable,uiActionsEnhanced,expressions,charts,esUiShared,bfetch,data,home,observability,console,consoleExtensions,apmOss,searchprofiler,painlessLab,grokdebugger,management,indexPatternManagement,advancedSettings,fileUpload,savedObjects,visualizations,visTypeVislib,visTypeVega,visTypeTimelion,features,licenseManagement,watcher,canvas,visTypeTagcloud,visTypeTable,visTypeMetric,visTypeMarkdown,tileMap,regionMap,visTypeXy,graph,timelion,dashboard,dashboardEnhanced,visualize,visTypeTimeseries,inputControlVis,discover,discoverEnhanced,savedObjectsManagement,spaces,security,savedObjectsTagging,maps,lens,reporting,lists,encryptedSavedObjects,dashboardMode,dataEnhanced,cloud,upgradeAssistant,snapshotRestore,fleet,indexManagement,rollup,remoteClusters,crossClusterReplication,indexLifecycleManagement,enterpriseSearch,beatsManagement,transform,ingestPipelines,eventLog,actions,alerts,triggersActionsUi,stackAlerts,ml,securitySolution,case,infra,monitoring,logstash,apm,uptime] + log [22:30:22.483] [info][plugins][taskManager] TaskManager is identified by the Kibana UUID: xxxxxx + ... + + + +如果是后台启动: + +[elasticsearch@VM-0-14-centos kibana-7.12.0-linux-x86_64]$ nohup ./bin/kibana & + + + + +界面访问 + + + + +可以导入simple data + + + +查看数据 + + + +配置密码访问 + + +使用基本许可证时,默认情况下禁用Elasticsearch安全功能。由于我测试环境是放在公网上的,所以需要设置下密码访问。相关文档可以参考这里 + + + +停止kibana和elasticsearch服务 +将xpack.security.enabled设置添加到ES_PATH_CONF/elasticsearch.yml文件并将值设置为true +启动elasticsearch (./bin/elasticsearch -d) +执行如下密码设置器,./bin/elasticsearch-setup-passwords interactive来设置各个组件的密码 +将elasticsearch.username设置添加到KIB_PATH_CONF/kibana.yml 文件并将值设置给elastic用户: elasticsearch.username: "elastic" +创建kibana keystore, ./bin/kibana-keystore create +在kibana keystore 中添加密码 ./bin/kibana-keystore add elasticsearch.password +重启kibana 服务即可 nohup ./bin/kibana & + + +然后就可以使用密码登录了: + + + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/04入门:查询和聚合的基础使用.md b/专栏/ElasticSearch知识体系详解/04入门:查询和聚合的基础使用.md new file mode 100644 index 0000000..258d1e9 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/04入门:查询和聚合的基础使用.md @@ -0,0 +1,359 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 入门:查询和聚合的基础使用 + 入门:从索引文档开始 + + +索引一个文档 + + +PUT /customer/_doc/1 +{ + "name": "John Doe" +} + + +为了方便测试,我们使用kibana的dev tool来进行学习测试: + + + +查询刚才插入的文档 + + + +学习准备:批量索引文档 + + +ES 还提供了批量操作,比如这里我们可以使用批量操作来插入一些数据,供我们在后面学习使用。 + + +使用批量来批处理文档操作比单独提交请求要快得多,因为它减少了网络往返。 + + +下载测试数据 + + +数据是index为bank,accounts.json 下载地址 (如果你无法下载,也可以clone ES的官方仓库 ,然后进入/docs/src/test/resources/accounts.json目录获取) + +数据的格式如下 + +{ + "account_number": 0, + "balance": 16623, + "firstname": "Bradshaw", + "lastname": "Mckenzie", + "age": 29, + "gender": "F", + "address": "244 Columbus Place", + "employer": "Euron", + "email": "[email protected]", + "city": "Hobucken", + "state": "CO" +} + + + +批量插入数据 + + +将accounts.json拷贝至指定目录,我这里放在/opt/下面, + +然后执行 + +curl -H "Content-Type: application/json" -XPOST "localhost:9200/bank/_bulk?pretty&refresh" --data-binary "@/opt/accounts.json" + + + +查看状态 + + +[elasticsearch@VM-0-14-centos root]$ curl "localhost:9200/_cat/indices?v=true" | grep bank + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed +100 1524 100 1524 0 0 119k 0 --:--:-- --:--:-- --:--:-- 124k +yellow open bank yq3eSlAWRMO2Td0Sl769rQ 1 1 1000 0 379.2kb 379.2kb +[elasticsearch@VM-0-14-centos root]$ + + +查询数据 + + +我们通过kibana来进行查询测试。 + + +查询所有 + +match_all表示查询所有的数据,sort即按照什么字段排序 + +GET /bank/_search +{ + "query": { "match_all": {} }, + "sort": [ + { "account_number": "asc" } + ] +} + + +结果 + + + +相关字段解释 + + +took – Elasticsearch运行查询所花费的时间(以毫秒为单位) +timed_out –搜索请求是否超时 +_shards - 搜索了多少个碎片,以及成功,失败或跳过了多少个碎片的细目分类。 +max_score – 找到的最相关文档的分数 +hits.total.value - 找到了多少个匹配的文档 +hits.sort - 文档的排序位置(不按相关性得分排序时) +hits._score - 文档的相关性得分(使用match_all时不适用) + + +分页查询(from+size) + +本质上就是from和size两个字段 + +GET /bank/_search +{ + "query": { "match_all": {} }, + "sort": [ + { "account_number": "asc" } + ], + "from": 10, + "size": 10 +} + + +结果 + + + +指定字段查询:match + +如果要在字段中搜索特定字词,可以使用match; 如下语句将查询address 字段中包含 mill 或者 lane的数据 + +GET /bank/_search +{ + "query": { "match": { "address": "mill lane" } } +} + + +结果 + + + +(由于ES底层是按照分词索引的,所以上述查询结果是address 字段中包含 mill 或者 lane的数据) + +查询段落匹配:match_phrase + +如果我们希望查询的条件是 address字段中包含 “mill lane”,则可以使用match_phrase + +GET /bank/_search +{ + "query": { "match_phrase": { "address": "mill lane" } } +} + + +结果 + + + +多条件查询: bool + +如果要构造更复杂的查询,可以使用bool查询来组合多个查询条件。 + +例如,以下请求在bank索引中搜索40岁客户的帐户,但不包括居住在爱达荷州(ID)的任何人 + +GET /bank/_search +{ + "query": { + "bool": { + "must": [ + { "match": { "age": "40" } } + ], + "must_not": [ + { "match": { "state": "ID" } } + ] + } + } +} + + +结果 + + + +must, should, must_not 和 filter 都是bool查询的子句。那么filter和上述query子句有啥区别呢? + +查询条件:query or filter + +先看下如下查询, 在bool查询的子句中同时具备query/must 和 filter + +GET /bank/_search +{ + "query": { + "bool": { + "must": [ + { + "match": { + "state": "ND" + } + } + ], + "filter": [ + { + "term": { + "age": "40" + } + }, + { + "range": { + "balance": { + "gte": 20000, + "lte": 30000 + } + } + } + ] + } + } +} + + +结果 + + + +两者都可以写查询条件,而且语法也类似。区别在于,query 上下文的条件是用来给文档打分的,匹配越好 _score 越高;filter 的条件只产生两种结果:符合与不符合,后者被过滤掉。 + +所以,我们进一步看只包含filter的查询 + +GET /bank/_search +{ + "query": { + "bool": { + "filter": [ + { + "term": { + "age": "40" + } + }, + { + "range": { + "balance": { + "gte": 20000, + "lte": 30000 + } + } + } + ] + } + } +} + + +结果,显然无_score + + + +聚合查询:Aggregation + + +我们知道SQL中有group by,在ES中它叫Aggregation,即聚合运算。 + + +简单聚合 + +比如我们希望计算出account每个州的统计数量, 使用aggs关键字对state字段聚合,被聚合的字段无需对分词统计,所以使用state.keyword对整个字段统计 + +GET /bank/_search +{ + "size": 0, + "aggs": { + "group_by_state": { + "terms": { + "field": "state.keyword" + } + } + } +} + + +结果 + + + +因为无需返回条件的具体数据, 所以设置size=0,返回hits为空。 + +doc_count表示bucket中每个州的数据条数。 + +嵌套聚合 + +ES还可以处理个聚合条件的嵌套。 + +比如承接上个例子, 计算每个州的平均结余。涉及到的就是在对state分组的基础上,嵌套计算avg(balance): + +GET /bank/_search +{ + "size": 0, + "aggs": { + "group_by_state": { + "terms": { + "field": "state.keyword" + }, + "aggs": { + "average_balance": { + "avg": { + "field": "balance" + } + } + } + } + } +} + + +结果 + + + +对聚合结果排序 + +可以通过在aggs中对嵌套聚合的结果进行排序 + +比如承接上个例子, 对嵌套计算出的avg(balance),这里是average_balance,进行排序 + +GET /bank/_search +{ + "size": 0, + "aggs": { + "group_by_state": { + "terms": { + "field": "state.keyword", + "order": { + "average_balance": "desc" + } + }, + "aggs": { + "average_balance": { + "avg": { + "field": "balance" + } + } + } + } + } +} + + +结果 + + + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/05索引:索引管理详解.md b/专栏/ElasticSearch知识体系详解/05索引:索引管理详解.md new file mode 100644 index 0000000..6c84e3c --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/05索引:索引管理详解.md @@ -0,0 +1,245 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 索引:索引管理详解 + 索引管理的引入 + +我们在前文中增加文档时,如下的语句会动态创建一个customer的index: + +PUT /customer/_doc/1 +{ + "name": "John Doe" +} + + +而这个index实际上已经自动创建了它里面的字段(name)的类型。我们不妨看下它自动创建的mapping: + +{ + "mappings": { + "_doc": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } +} + + +那么如果我们需要对这个建立索引的过程做更多的控制:比如想要确保这个索引有数量适中的主分片,并且在我们索引任何数据之前,分析器和映射已经被建立好。那么就会引入两点:第一个禁止自动创建索引,第二个是手动创建索引。 + + +禁止自动创建索引 + + +可以通过在 config/elasticsearch.yml 的每个节点下添加下面的配置: + +action.auto_create_index: false + + +手动创建索引就是接下来文章的内容。 + +索引的格式 + +在请求体里面传入设置或类型映射,如下所示: + +PUT /my_index +{ + "settings": { ... any settings ... }, + "mappings": { + "properties": { ... any properties ... } + } +} + + + +settings: 用来设置分片,副本等配置信息 + + + +mappings +字段映射,类型等 + + + +properties: 由于type在后续版本中会被Deprecated, 所以无需被type嵌套 + + + +索引管理操作 + + +我们通过kibana的devtool来学习索引的管理操作。 + + +创建索引 + +我们创建一个user 索引test-index-users,其中包含三个属性:name,age, remarks; 存储在一个分片一个副本上。 + +PUT /test-index-users +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 1 + }, + "mappings": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "age": { + "type": "long" + }, + "remarks": { + "type": "text" + } + } + } +} + + +执行结果 + + + + +插入测试数据 + + + + +查看数据 + + + + +我们再测试下不匹配的数据类型(age): + + +POST /test-index-users/_doc +{ + "name": "test user", + "age": "error_age", + "remarks": "hello eeee" +} + + +你可以看到无法类型不匹配的错误: + + + +修改索引 + +查看刚才的索引,curl 'localhost:9200/_cat/indices?v' | grep users + +yellow open test-index-users LSaIB57XSC6uVtGQHoPYxQ 1 1 1 0 4.4kb 4.4kb + + +我们注意到刚创建的索引的状态是yellow的,因为我测试的环境是单点环境,无法创建副本,但是在上述number_of_replicas配置中设置了副本数是1; 所以在这个时候我们需要修改索引的配置。 + +修改副本数量为0 + +PUT /test-index-users/_settings +{ + "settings": { + "number_of_replicas": 0 + } +} + + + + +再次查看状态: + +green open test-index-users LSaIB57XSC6uVtGQHoPYxQ 1 1 1 0 4.4kb 4.4kb + + +打开/关闭索引 + + +关闭索引 + + +一旦索引被关闭,那么这个索引只能显示元数据信息,不能够进行读写操作。 + + + +当关闭以后,再插入数据时: + + + + +打开索引 + + + + +打开后又可以重新写数据了 + + + +删除索引 + +最后我们将创建的test-index-users删除。 + +DELETE /test-index-users + + + + +查看索引 + +由于test-index-users被删除,所以我们看下之前bank的索引的信息 + + +mapping + + +GET /bank/_mapping + + + + + +settings + + +GET /bank/_settings + + + + +Kibana管理索引 + +在Kibana如下路径,我们可以查看和管理索引 + + + +参考文章 + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/_creating_an_index.html + +https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html + +https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html + +https://www.cnblogs.com/quanxiaoha/p/11515057.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/06索引:索引模板(IndexTemplate)详解.md b/专栏/ElasticSearch知识体系详解/06索引:索引模板(IndexTemplate)详解.md new file mode 100644 index 0000000..a6d1f2e --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/06索引:索引模板(IndexTemplate)详解.md @@ -0,0 +1,289 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 索引:索引模板(Index Template)详解 + 索引模板 + + +索引模板是一种告诉Elasticsearch在创建索引时如何配置索引的方法。 + + + +使用方式 + + +在创建索引之前可以先配置模板,这样在创建索引(手动创建索引或通过对文档建立索引)时,模板设置将用作创建索引的基础。 + +模板类型 + +模板有两种类型:索引模板和组件模板。 + + +组件模板是可重用的构建块,用于配置映射,设置和别名;它们不会直接应用于一组索引。 +索引模板可以包含组件模板的集合,也可以直接指定设置,映射和别名。 + + +索引模板中的优先级 + + +可组合模板优先于旧模板。如果没有可组合模板匹配给定索引,则旧版模板可能仍匹配并被应用。 +如果使用显式设置创建索引并且该索引也与索引模板匹配,则创建索引请求中的设置将优先于索引模板及其组件模板中指定的设置。 +如果新数据流或索引与多个索引模板匹配,则使用优先级最高的索引模板。 + + +内置索引模板 + +Elasticsearch具有内置索引模板,每个索引模板的优先级为100,适用于以下索引模式: + + +logs-*-* +metrics-*-* +synthetics-*-* + + +所以在涉及内建索引模板时,要避免索引模式冲突。更多可以参考这里 + +案例 + + +首先创建两个索引组件模板: + + +PUT _component_template/component_template1 +{ + "template": { + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + } + } + } + } +} + +PUT _component_template/runtime_component_template +{ + "template": { + "mappings": { + "runtime": { + "day_of_week": { + "type": "keyword", + "script": { + "source": "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))" + } + } + } + } + } +} + + +执行结果如下 + + + + +创建使用组件模板的索引模板 + + +PUT _index_template/template_1 +{ + "index_patterns": ["bar*"], + "template": { + "settings": { + "number_of_shards": 1 + }, + "mappings": { + "_source": { + "enabled": true + }, + "properties": { + "host_name": { + "type": "keyword" + }, + "created_at": { + "type": "date", + "format": "EEE MMM dd HH:mm:ss Z yyyy" + } + } + }, + "aliases": { + "mydata": { } + } + }, + "priority": 500, + "composed_of": ["component_template1", "runtime_component_template"], + "version": 3, + "_meta": { + "description": "my custom" + } +} + + +执行结果如下 + + + + +创建一个匹配bar*的索引bar-test + + +PUT /bar-test + + +然后获取mapping + +GET /bar-test/_mapping + + +执行结果如下 + + + +模拟多组件模板 + + +由于模板不仅可以由多个组件模板组成,还可以由索引模板自身组成;那么最终的索引设置将是什么呢?ElasticSearch设计者考虑到这个,提供了API进行模拟组合后的模板的配置。 + + +模拟某个索引结果 + +比如上面的template_1, 我们不用创建bar*的索引(这里模拟bar-pdai-test),也可以模拟计算出索引的配置: + +POST /_index_template/_simulate_index/bar-pdai-test + + +执行结果如下 + + + +模拟组件模板结果 + +当然,由于template_1模板是由两个组件模板组合的,我们也可以模拟出template_1被组合后的索引配置: + +POST /_index_template/_simulate/template_1 + + +执行结果如下: + +{ + "template" : { + "settings" : { + "index" : { + "number_of_shards" : "1" + } + }, + "mappings" : { + "runtime" : { + "day_of_week" : { + "type" : "keyword", + "script" : { + "source" : "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))", + "lang" : "painless" + } + } + }, + "properties" : { + "@timestamp" : { + "type" : "date" + }, + "created_at" : { + "type" : "date", + "format" : "EEE MMM dd HH:mm:ss Z yyyy" + }, + "host_name" : { + "type" : "keyword" + } + } + }, + "aliases" : { + "mydata" : { } + } + }, + "overlapping" : [ ] +} + + +模拟组件模板和自身模板结合后的结果 + + +新建两个模板 + + +PUT /_component_template/ct1 +{ + "template": { + "settings": { + "index.number_of_shards": 2 + } + } +} + +PUT /_component_template/ct2 +{ + "template": { + "settings": { + "index.number_of_replicas": 0 + }, + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + } + } + } + } +} + + +模拟在两个组件模板的基础上,添加自身模板的配置 + +POST /_index_template/_simulate +{ + "index_patterns": ["my*"], + "template": { + "settings" : { + "index.number_of_shards" : 3 + } + }, + "composed_of": ["ct1", "ct2"] +} + + +执行的结果如下 + +{ + "template" : { + "settings" : { + "index" : { + "number_of_shards" : "3", + "number_of_replicas" : "0" + } + }, + "mappings" : { + "properties" : { + "@timestamp" : { + "type" : "date" + } + } + }, + "aliases" : { } + }, + "overlapping" : [ ] +} + + + + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/index-templates.html + +https://www.elastic.co/guide/en/elasticsearch/reference/current/simulate-multi-component-templates.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/07查询:DSL查询之复合查询详解.md b/专栏/ElasticSearch知识体系详解/07查询:DSL查询之复合查询详解.md new file mode 100644 index 0000000..7c5fe07 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/07查询:DSL查询之复合查询详解.md @@ -0,0 +1,501 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 查询:DSL查询之复合查询详解 + 复合查询引入 + +在(前文-多条件查询-bool)中,我们使用bool查询来组合多个查询条件。 + +比如之前介绍的语句 + +GET /bank/_search +{ + "query": { + "bool": { + "must": [ + { "match": { "age": "40" } } + ], + "must_not": [ + { "match": { "state": "ID" } } + ] + } + } +} + + +这种查询就是本文要介绍的复合查询,并且bool查询只是复合查询一种。 + +bool query(布尔查询) + + +通过布尔逻辑将较小的查询组合成较大的查询。 + + +概念 + +Bool查询语法有以下特点 + + +子查询可以任意顺序出现 +可以嵌套多个查询,包括bool查询 +如果bool查询中没有must条件,should中必须至少满足一条才会返回结果。 + + +bool查询包含四种操作符,分别是must,should,must_not,filter。他们均是一种数组,数组里面是对应的判断条件。 + + +must: 必须匹配。贡献算分 +must_not:过滤子句,必须不能匹配,但不贡献算分 +should: 选择性匹配,至少满足一条。贡献算分 +filter: 过滤子句,必须匹配,但不贡献算分 + + +一些例子 + +看下官方举例 + + +例子1 + + +POST _search +{ + "query": { + "bool" : { + "must" : { + "term" : { "user.id" : "kimchy" } + }, + "filter": { + "term" : { "tags" : "production" } + }, + "must_not" : { + "range" : { + "age" : { "gte" : 10, "lte" : 20 } + } + }, + "should" : [ + { "term" : { "tags" : "env1" } }, + { "term" : { "tags" : "deployed" } } + ], + "minimum_should_match" : 1, + "boost" : 1.0 + } + } +} + + +在filter元素下指定的查询对评分没有影响 , 评分返回为0。分数仅受已指定查询的影响。 + + +例子2 + + +GET _search +{ + "query": { + "bool": { + "filter": { + "term": { + "status": "active" + } + } + } + } +} + + +这个例子查询查询为所有文档分配0分,因为没有指定评分查询。 + + +例子3 + + +GET _search +{ + "query": { + "bool": { + "must": { + "match_all": {} + }, + "filter": { + "term": { + "status": "active" + } + } + } + } +} + + +此bool查询具有match_all查询,该查询为所有文档指定1.0分。 + + +例子4 + + +GET /_search +{ + "query": { + "bool": { + "should": [ + { "match": { "name.first": { "query": "shay", "_name": "first" } } }, + { "match": { "name.last": { "query": "banon", "_name": "last" } } } + ], + "filter": { + "terms": { + "name.last": [ "banon", "kimchy" ], + "_name": "test" + } + } + } + } +} + + +每个query条件都可以有一个_name属性,用来追踪搜索出的数据到底match了哪个条件。 + +boosting query(提高查询) + + +不同于bool查询,bool查询中只要一个子查询条件不匹配那么搜索的数据就不会出现。而boosting query则是降低显示的权重/优先级(即score)。 + + +概念 + +比如搜索逻辑是 name = ‘apple’ and type =‘fruit’,对于只满足部分条件的数据,不是不显示,而是降低显示的优先级(即score) + +例子 + +首先创建数据 + +POST /test-dsl-boosting/_bulk +{ "index": { "_id": 1 }} +{ "content":"Apple Mac" } +{ "index": { "_id": 2 }} +{ "content":"Apple Fruit" } +{ "index": { "_id": 3 }} +{ "content":"Apple employee like Apple Pie and Apple Juice" } + + +对匹配pie的做降级显示处理 + +GET /test-dsl-boosting/_search +{ + "query": { + "boosting": { + "positive": { + "term": { + "content": "apple" + } + }, + "negative": { + "term": { + "content": "pie" + } + }, + "negative_boost": 0.5 + } + } +} + + +执行结果如下 + + + +constant_score(固定分数查询) + + +查询某个条件时,固定的返回指定的score;显然当不需要计算score时,只需要filter条件即可,因为filter context忽略score。 + + +例子 + +首先创建数据 + +POST /test-dsl-constant/_bulk +{ "index": { "_id": 1 }} +{ "content":"Apple Mac" } +{ "index": { "_id": 2 }} +{ "content":"Apple Fruit" } + + +查询apple + +GET /test-dsl-constant/_search +{ + "query": { + "constant_score": { + "filter": { + "term": { "content": "apple" } + }, + "boost": 1.2 + } + } +} + + +执行结果如下 + + + +dis_max(最佳匹配查询) + + +分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回 。 + + +例子 + +假设有个网站允许用户搜索博客的内容,以下面两篇博客内容文档为例: + +POST /test-dsl-dis-max/_bulk +{ "index": { "_id": 1 }} +{"title": "Quick brown rabbits","body": "Brown rabbits are commonly seen."} +{ "index": { "_id": 2 }} +{"title": "Keeping pets healthy","body": "My quick brown fox eats rabbits on a regular basis."} + + +用户输入词组 “Brown fox” 然后点击搜索按钮。事先,我们并不知道用户的搜索项是会在 title 还是在 body 字段中被找到,但是,用户很有可能是想搜索相关的词组。用肉眼判断,文档 2 的匹配度更高,因为它同时包括要查找的两个词: + +现在运行以下 bool 查询: + +GET /test-dsl-dis-max/_search +{ + "query": { + "bool": { + "should": [ + { "match": { "title": "Brown fox" }}, + { "match": { "body": "Brown fox" }} + ] + } + } +} + + + + +为了理解导致这样的原因,需要看下如何计算评分的 + + +should 条件的计算分数 + + +GET /test-dsl-dis-max/_search +{ + "query": { + "bool": { + "should": [ + { "match": { "title": "Brown fox" }}, + { "match": { "body": "Brown fox" }} + ] + } + } +} + + +要计算上述分数,首先要计算match的分数 + + +第一个match 中 brown的分数 + + +doc 1 分数 = 0.6931471 + + + + +title中没有fox,所以第一个match 中 brown fox 的分数 = brown分数 + 0 = 0.6931471 + + +doc 1 分数 = 0.6931471 + 0 = 0.6931471 + + + + +第二个 match 中 brown分数 + + +doc 1 分数 = 0.21110919 + +doc 2 分数 = 0.160443 + + + + +第二个 match 中 fox分数 + + +doc 1 分数 = 0 + +doc 2 分数 = 0.60996956 + + + + +所以第二个 match 中 brown fox分数 = brown分数 + fox分数 + + +doc 1 分数 = 0.21110919 + 0 = 0.21110919 + +doc 2 分数 = 0.160443 + 0.60996956 = 0.77041256 + + + + +所以整个语句分数, should分数 = 第一个match + 第二个match分数 + + +doc 1 分数 = 0.6931471 + 0.21110919 = 0.90425634 + +doc 2 分数 = 0 + 0.77041256 = 0.77041256 + + + + +引入了dis_max + + +不使用 bool 查询,可以使用 dis_max 即分离 最大化查询(Disjunction Max Query) 。分离(Disjunction)的意思是 或(or) ,这与可以把结合(conjunction)理解成 与(and) 相对应。分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回 : + +GET /test-dsl-dis-max/_search +{ + "query": { + "dis_max": { + "queries": [ + { "match": { "title": "Brown fox" }}, + { "match": { "body": "Brown fox" }} + ], + "tie_breaker": 0 + } + } +} + + + + +0.77041256怎么来的呢? 下文给你解释它如何计算出来的。 + + +dis_max 条件的计算分数 + + +分数 = 第一个匹配条件分数 + tie_breaker * 第二个匹配的条件的分数 … + +GET /test-dsl-dis-max/_search +{ + "query": { + "dis_max": { + "queries": [ + { "match": { "title": "Brown fox" }}, + { "match": { "body": "Brown fox" }} + ], + "tie_breaker": 0 + } + } +} + + +doc 1 分数 = 0.6931471 + 0.21110919 * 0 = 0.6931471 + +doc 2 分数 = 0.77041256 = 0.77041256 + + + +这样你就能理解通过dis_max将doc 2 置前了, 当然这里如果缺省tie_breaker字段的话默认就是0,你还可以设置它的比例(在0到1之间)来控制排名。(显然值为1时和should查询是一致的) + +function_score(函数查询) + + +简而言之就是用自定义function的方式来计算_score。 + + +可以ES有哪些自定义function呢? + + +script_score 使用自定义的脚本来完全控制分值计算逻辑。如果你需要以上预定义函数之外的功能,可以根据需要通过脚本进行实现。 +weight 对每份文档适用一个简单的提升,且该提升不会被归约:当weight为2时,结果为2 * _score。 +random_score 使用一致性随机分值计算来对每个用户采用不同的结果排序方式,对相同用户仍然使用相同的排序方式。 +field_value_factor 使用文档中某个字段的值来改变_score,比如将受欢迎程度或者投票数量考虑在内。 +衰减函数(Decay Function) - linear,exp,gauss + + +例子 + +以最简单的random_score 为例 + +GET /_search +{ + "query": { + "function_score": { + "query": { "match_all": {} }, + "boost": "5", + "random_score": {}, + "boost_mode": "multiply" + } + } +} + + +进一步的,它还可以使用上述function的组合(functions) + +GET /_search +{ + "query": { + "function_score": { + "query": { "match_all": {} }, + "boost": "5", + "functions": [ + { + "filter": { "match": { "test": "bar" } }, + "random_score": {}, + "weight": 23 + }, + { + "filter": { "match": { "test": "cat" } }, + "weight": 42 + } + ], + "max_boost": 42, + "score_mode": "max", + "boost_mode": "multiply", + "min_score": 42 + } + } +} + + +script_score 可以使用如下方式 + +GET /_search +{ + "query": { + "function_score": { + "query": { + "match": { "message": "elasticsearch" } + }, + "script_score": { + "script": { + "source": "Math.log(2 + doc['my-int'].value)" + } + } + } + } +} + + +更多相关内容,可以参考官方文档 PS: 形成体系化认知以后,具体用的时候查询下即可。 + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/compound-queries.html + +https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html + +https://www.elastic.co/guide/en/elasticsearch/reference/7.12/query-dsl-function-score-query.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/08查询:DSL查询之全文搜索详解.md b/专栏/ElasticSearch知识体系详解/08查询:DSL查询之全文搜索详解.md new file mode 100644 index 0000000..678ebe4 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/08查询:DSL查询之全文搜索详解.md @@ -0,0 +1,491 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 查询:DSL查询之全文搜索详解 + 写在前面:谈谈如何从官网学习 + +提示 + +很多读者在看官方文档学习时存在一个误区,以DSL中full text查询为例,其实内容是非常多的, 没有取舍/没重点去阅读, 要么需要花很多时间,要么头脑一片浆糊。所以这里重点谈谈我的理解。@pdai + +一些理解: + + +第一点:全局观,即我们现在学习内容在整个体系的哪个位置? + + +如下图,可以很方便的帮助你构筑这种体系 + + + + +第二点: 分类别,从上层理解,而不是本身 + + +比如Full text Query中,我们只需要把如下的那么多点分为3大类,你的体系能力会大大提升 + + + + +第三点: 知识点还是API? API类型的是可以查询的,只需要知道大致有哪些功能就可以了。 + + + + +Match类型 + + +第一类:match 类型 + + +match 查询的步骤 + +在(指定字段查询)中我们已经介绍了match查询。 + + +准备一些数据 + + +这里我们准备一些数据,通过实例看match 查询的步骤 + +PUT /test-dsl-match +{ "settings": { "number_of_shards": 1 }} + +POST /test-dsl-match/_bulk +{ "index": { "_id": 1 }} +{ "title": "The quick brown fox" } +{ "index": { "_id": 2 }} +{ "title": "The quick brown fox jumps over the lazy dog" } +{ "index": { "_id": 3 }} +{ "title": "The quick brown fox jumps over the quick dog" } +{ "index": { "_id": 4 }} +{ "title": "Brown fox brown dog" } + + + +查询数据 + + +GET /test-dsl-match/_search +{ + "query": { + "match": { + "title": "QUICK!" + } + } +} + + +Elasticsearch 执行上面这个 match 查询的步骤是: + + +检查字段类型 。 + + +标题 title 字段是一个 string 类型( analyzed )已分析的全文字段,这意味着查询字符串本身也应该被分析。 + + +分析查询字符串 。 + + +将查询的字符串 QUICK! 传入标准分析器中,输出的结果是单个项 quick 。因为只有一个单词项,所以 match 查询执行的是单个底层 term 查询。 + + +查找匹配文档 。 + + +用 term 查询在倒排索引中查找 quick 然后获取一组包含该项的文档,本例的结果是文档:1、2 和 3 。 + + +为每个文档评分 。 + + +用 term 查询计算每个文档相关度评分 _score ,这是种将词频(term frequency,即词 quick 在相关文档的 title 字段中出现的频率)和反向文档频率(inverse document frequency,即词 quick 在所有文档的 title 字段中出现的频率),以及字段的长度(即字段越短相关度越高)相结合的计算方式。 + + +验证结果 + + + + +match多个词深入 + +我们在上文中复合查询中已经使用了match多个词,比如“Quick pets”; 这里我们通过例子带你更深入理解match多个词 + + +match多个词的本质 + + +查询多个词”BROWN DOG!” + +GET /test-dsl-match/_search +{ + "query": { + "match": { + "title": "BROWN DOG" + } + } +} + + + + +因为 match 查询必须查找两个词( [“brown”,“dog”] ),它在内部实际上先执行两次 term 查询,然后将两次查询的结果合并作为最终结果输出。为了做到这点,它将两个 term 查询包入一个 bool 查询中, + +所以上述查询的结果,和如下语句查询结果是等同的 + +GET /test-dsl-match/_search +{ + "query": { + "bool": { + "should": [ + { + "term": { + "title": "brown" + } + }, + { + "term": { + "title": "dog" + } + } + ] + } + } +} + + + + + +match多个词的逻辑 + + +上面等同于should(任意一个满足),是因为 match还有一个operator参数,默认是or, 所以对应的是should。 + +所以上述查询也等同于 + +GET /test-dsl-match/_search +{ + "query": { + "match": { + "title": { + "query": "BROWN DOG", + "operator": "or" + } + } + } +} + + +那么我们如果是需要and操作呢,即同时满足呢? + +GET /test-dsl-match/_search +{ + "query": { + "match": { + "title": { + "query": "BROWN DOG", + "operator": "and" + } + } + } +} + + +等同于 + +GET /test-dsl-match/_search +{ + "query": { + "bool": { + "must": [ + { + "term": { + "title": "brown" + } + }, + { + "term": { + "title": "dog" + } + } + ] + } + } +} + + + + +控制match的匹配精度 + +如果用户给定 3 个查询词,想查找只包含其中 2 个的文档,该如何处理?将 operator 操作符参数设置成 and 或者 or 都是不合适的。 + +match 查询支持 minimum_should_match 最小匹配参数,这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量: + +GET /test-dsl-match/_search +{ + "query": { + "match": { + "title": { + "query": "quick brown dog", + "minimum_should_match": "75%" + } + } + } +} + + +当给定百分比的时候, minimum_should_match 会做合适的事情:在之前三词项的示例中, 75% 会自动被截断成 66.6% ,即三个里面两个词。无论这个值设置成什么,至少包含一个词项的文档才会被认为是匹配的。 + + + +当然也等同于 + +GET /test-dsl-match/_search +{ + "query": { + "bool": { + "should": [ + { "match": { "title": "quick" }}, + { "match": { "title": "brown" }}, + { "match": { "title": "dog" }} + ], + "minimum_should_match": 2 + } + } +} + + + + +其它match类型 + + +match_pharse + + +match_phrase在前文中我们已经有了解,我们再看下另外一个例子。 + +GET /test-dsl-match/_search +{ + "query": { + "match_phrase": { + "title": { + "query": "quick brown" + } + } + } +} + + + + +很多人对它仍然有误解的,比如如下例子: + +GET /test-dsl-match/_search +{ + "query": { + "match_phrase": { + "title": { + "query": "quick brown f" + } + } + } +} + + +这样的查询是查不出任何数据的,因为前文中我们知道了match本质上是对term组合,match_phrase本质是连续的term的查询,所以f并不是一个分词,不满足term查询,所以最终查不出任何内容了。 + + + + +match_pharse_prefix + + +那有没有可以查询出quick brown f的方式呢?ELasticSearch在match_phrase基础上提供了一种可以查最后一个词项是前缀的方法,这样就可以查询quick brown f了 + +GET /test-dsl-match/_search +{ + "query": { + "match_phrase_prefix": { + "title": { + "query": "quick brown f" + } + } + } +} + + + + +(ps: prefix的意思不是整个text的开始匹配,而是最后一个词项满足term的prefix查询而已) + + +match_bool_prefix + + +除了match_phrase_prefix,ElasticSearch还提供了match_bool_prefix查询 + +GET /test-dsl-match/_search +{ + "query": { + "match_bool_prefix": { + "title": { + "query": "quick brown f" + } + } + } +} + + + + +它们两种方式有啥区别呢?match_bool_prefix本质上可以转换为: + +GET /test-dsl-match/_search +{ + "query": { + "bool" : { + "should": [ + { "term": { "title": "quick" }}, + { "term": { "title": "brown" }}, + { "prefix": { "title": "f"}} + ] + } + } +} + + +所以这样你就能理解,match_bool_prefix查询中的quick,brown,f是无序的。 + + +multi_match + + +如果我们期望一次对多个字段查询,怎么办呢?ElasticSearch提供了multi_match查询的方式 + +{ + "query": { + "multi_match" : { + "query": "Will Smith", + "fields": [ "title", "*_name" ] + } + } +} + + +*表示前缀匹配字段。 + +query string类型 + + +第二类:query string 类型 + + +query_string + +此查询使用语法根据运算符(例如AND或)来解析和拆分提供的查询字符串NOT。然后查询在返回匹配的文档之前独立分析每个拆分的文本。 + +可以使用该query_string查询创建一个复杂的搜索,其中包括通配符,跨多个字段的搜索等等。尽管用途广泛,但查询是严格的,如果查询字符串包含任何无效语法,则返回错误。 + +例如: + +GET /test-dsl-match/_search +{ + "query": { + "query_string": { + "query": "(lazy dog) OR (brown dog)", + "default_field": "title" + } + } +} + + +这里查询结果,你需要理解本质上查询这四个分词(term)or的结果而已,所以doc 3和4也在其中 + + + +对构筑知识体系已经够了,但是它其实还有很多参数和用法,更多请参考官网 + +query_string_simple + +该查询使用一种简单的语法来解析提供的查询字符串并将其拆分为基于特殊运算符的术语。然后查询在返回匹配的文档之前独立分析每个术语。 + +尽管其语法比query_string查询更受限制 ,但simple_query_string 查询不会针对无效语法返回错误。而是,它将忽略查询字符串的任何无效部分。 + +举例: + +GET /test-dsl-match/_search +{ + "query": { + "simple_query_string" : { + "query": "\"over the\" + (lazy | quick) + dog", + "fields": ["title"], + "default_operator": "and" + } + } +} + + + + +更多请参考官网 + +Interval类型 + + +第三类:interval类型 + + +Intervals是时间间隔的意思,本质上将多个规则按照顺序匹配。 + +比如: + +GET /test-dsl-match/_search +{ + "query": { + "intervals" : { + "title" : { + "all_of" : { + "ordered" : true, + "intervals" : [ + { + "match" : { + "query" : "quick", + "max_gaps" : 0, + "ordered" : true + } + }, + { + "any_of" : { + "intervals" : [ + { "match" : { "query" : "jump over" } }, + { "match" : { "query" : "quick dog" } } + ] + } + } + ] + } + } + } + } +} + + + + +因为interval之间是可以组合的,所以它可以表现的很复杂。更多请参考官网 + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/full-text-queries.html#full-text-queries + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/match-multi-word.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/09查询:DSL查询之Term详解.md b/专栏/ElasticSearch知识体系详解/09查询:DSL查询之Term详解.md new file mode 100644 index 0000000..daeff21 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/09查询:DSL查询之Term详解.md @@ -0,0 +1,242 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 查询:DSL查询之Term详解 + Term查询引入 + +如前文所述,查询分基于文本查询和基于词项的查询: + + + +本文主要讲基于词项的查询。 + + + +Term查询 + + +很多比较常用,也不难,就是需要结合实例理解。这里综合官方文档的内容,我设计一个测试场景的数据,以覆盖所有例子。@pdai + + +准备数据 + +PUT /test-dsl-term-level +{ + "mappings": { + "properties": { + "name": { + "type": "keyword" + }, + "programming_languages": { + "type": "keyword" + }, + "required_matches": { + "type": "long" + } + } + } +} + +POST /test-dsl-term-level/_bulk +{ "index": { "_id": 1 }} +{"name": "Jane Smith", "programming_languages": [ "c++", "java" ], "required_matches": 2} +{ "index": { "_id": 2 }} +{"name": "Jason Response", "programming_languages": [ "java", "php" ], "required_matches": 2} +{ "index": { "_id": 3 }} +{"name": "Dave Pdai", "programming_languages": [ "java", "c++", "php" ], "required_matches": 3, "remarks": "hello world"} + + +字段是否存在:exist + +由于多种原因,文档字段的索引值可能不存在: + + +源JSON中的字段是null或[] +该字段已”index” : false在映射中设置 +字段值的长度超出ignore_above了映射中的设置 +字段值格式错误,并且ignore_malformed已在映射中定义 + + +所以exist表示查找是否存在字段。 + + + +id查询:ids + +ids 即对id查找 + +GET /test-dsl-term-level/_search +{ + "query": { + "ids": { + "values": [3, 1] + } + } +} + + + + +前缀:prefix + +通过前缀查找某个字段 + +GET /test-dsl-term-level/_search +{ + "query": { + "prefix": { + "name": { + "value": "Jan" + } + } + } +} + + + + +分词匹配:term + +前文最常见的根据分词查询 + +GET /test-dsl-term-level/_search +{ + "query": { + "term": { + "programming_languages": "php" + } + } +} + + + + +多个分词匹配:terms + +按照读个分词term匹配,它们是or的关系 + +GET /test-dsl-term-level/_search +{ + "query": { + "terms": { + "programming_languages": ["php","c++"] + } + } +} + + + + +按某个数字字段分词匹配:term set + +设计这种方式查询的初衷是用文档中的数字字段动态匹配查询满足term的个数 + +GET /test-dsl-term-level/_search +{ + "query": { + "terms_set": { + "programming_languages": { + "terms": [ "java", "php" ], + "minimum_should_match_field": "required_matches" + } + } + } +} + + + + +通配符:wildcard + +通配符匹配,比如* + +GET /test-dsl-term-level/_search +{ + "query": { + "wildcard": { + "name": { + "value": "D*ai", + "boost": 1.0, + "rewrite": "constant_score" + } + } + } +} + + + + +范围:range + +常常被用在数字或者日期范围的查询 + +GET /test-dsl-term-level/_search +{ + "query": { + "range": { + "required_matches": { + "gte": 3, + "lte": 4 + } + } + } +} + + + + +正则:regexp + +通过[正则表达式]查询 + +以”Jan”开头的name字段 + +GET /test-dsl-term-level/_search +{ + "query": { + "regexp": { + "name": { + "value": "Ja.*", + "case_insensitive": true + } + } + } +} + + + + +模糊匹配:fuzzy + +官方文档对模糊匹配:编辑距离是将一个术语转换为另一个术语所需的一个字符更改的次数。这些更改可以包括: + + +更改字符(box→ fox) +删除字符(black→ lack) +插入字符(sic→ sick) +转置两个相邻字符(act→ cat) + + +GET /test-dsl-term-level/_search +{ + "query": { + "fuzzy": { + "remarks": { + "value": "hell" + } + } + } +} + + + + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/term-level-queries.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/10聚合:聚合查询之Bucket聚合详解.md b/专栏/ElasticSearch知识体系详解/10聚合:聚合查询之Bucket聚合详解.md new file mode 100644 index 0000000..4df9b16 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/10聚合:聚合查询之Bucket聚合详解.md @@ -0,0 +1,602 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 聚合:聚合查询之Bucket聚合详解 + 聚合的引入 + +我们在SQL结果中常有: + +SELECT COUNT(color) +FROM table +GROUP BY color + + +ElasticSearch中桶在概念上类似于 SQL 的分组(GROUP BY),而指标则类似于 COUNT() 、 SUM() 、 MAX() 等统计方法。 + +进而引入了两个概念: + + +桶(Buckets) 满足特定条件的文档的集合 +指标(Metrics) 对桶内的文档进行统计计算 + + +所以ElasticSearch包含3种聚合(Aggregation)方式 + + +桶聚合(Bucket Aggregration) - 本文中详解 + +指标聚合(Metric Aggregration) - 下文中讲解 + +管道聚合(Pipline Aggregration) + + +- 再下一篇讲解 + + +聚合管道化,简单而言就是上一个聚合的结果成为下个聚合的输入; + + +(PS:指标聚合和桶聚合很多情况下是组合在一起使用的,其实你也可以看到,桶聚合本质上是一种特殊的指标聚合,它的聚合指标就是数据的条数count) + +如何理解Bucket聚合 + + +如果你直接去看文档,大概有几十种: + + + + +要么你需要花大量时间学习,要么你已经迷失或者即将迷失在知识点中… + +所以你需要稍微站在设计者的角度思考下,不难发现设计上大概分为三类(当然有些是第二和第三类的融合) + + + +(图中并没有全部列出内容,因为图要表达的意图我觉得还是比较清楚的,这就够了;有了这种思虑和认知,会大大提升你的认知效率。) + +按知识点学习聚合 + + +我们先按照官方权威指南中的一个例子,学习Aggregation中的知识点。 + + +准备数据 + +让我们先看一个例子。我们将会创建一些对汽车经销商有用的聚合,数据是关于汽车交易的信息:车型、制造商、售价、何时被出售等。 + +首先我们批量索引一些数据: + +POST /test-agg-cars/_bulk +{ "index": {}} +{ "price" : 10000, "color" : "red", "make" : "honda", "sold" : "2014-10-28" } +{ "index": {}} +{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" } +{ "index": {}} +{ "price" : 30000, "color" : "green", "make" : "ford", "sold" : "2014-05-18" } +{ "index": {}} +{ "price" : 15000, "color" : "blue", "make" : "toyota", "sold" : "2014-07-02" } +{ "index": {}} +{ "price" : 12000, "color" : "green", "make" : "toyota", "sold" : "2014-08-19" } +{ "index": {}} +{ "price" : 20000, "color" : "red", "make" : "honda", "sold" : "2014-11-05" } +{ "index": {}} +{ "price" : 80000, "color" : "red", "make" : "bmw", "sold" : "2014-01-01" } +{ "index": {}} +{ "price" : 25000, "color" : "blue", "make" : "ford", "sold" : "2014-02-12" } + + +标准的聚合 + +有了数据,开始构建我们的第一个聚合。汽车经销商可能会想知道哪个颜色的汽车销量最好,用聚合可以轻易得到结果,用 terms 桶操作: + +GET /test-agg-cars/_search +{ + "size" : 0, + "aggs" : { + "popular_colors" : { + "terms" : { + "field" : "color.keyword" + } + } + } +} + + + +聚合操作被置于顶层参数 aggs 之下(如果你愿意,完整形式 aggregations 同样有效)。 +然后,可以为聚合指定一个我们想要名称,本例中是: popular_colors 。 +最后,定义单个桶的类型 terms 。 + + +结果如下: + + + + +因为我们设置了 size 参数,所以不会有 hits 搜索结果返回。 +popular_colors 聚合是作为 aggregations 字段的一部分被返回的。 +每个桶的 key 都与 color 字段里找到的唯一词对应。它总会包含 doc_count 字段,告诉我们包含该词项的文档数量。 +每个桶的数量代表该颜色的文档数量。 + + +多个聚合 + +同时计算两种桶的结果:对color和对make。 + +GET /test-agg-cars/_search +{ + "size" : 0, + "aggs" : { + "popular_colors" : { + "terms" : { + "field" : "color.keyword" + } + }, + "make_by" : { + "terms" : { + "field" : "make.keyword" + } + } + } +} + + +结果如下: + + + +聚合的嵌套 + +这个新的聚合层让我们可以将 avg 度量嵌套置于 terms 桶内。实际上,这就为每个颜色生成了平均价格。 + +GET /test-agg-cars/_search +{ + "size" : 0, + "aggs": { + "colors": { + "terms": { + "field": "color.keyword" + }, + "aggs": { + "avg_price": { + "avg": { + "field": "price" + } + } + } + } + } +} + + +结果如下: + + + +正如 颜色 的例子,我们需要给度量起一个名字( avg_price )这样可以稍后根据名字获取它的值。最后,我们指定度量本身( avg )以及我们想要计算平均值的字段( price ) + +动态脚本的聚合 + +这个例子告诉你,ElasticSearch还支持一些基于脚本(生成运行时的字段)的复杂的动态聚合。 + +GET /test-agg-cars/_search +{ + "runtime_mappings": { + "make.length": { + "type": "long", + "script": "emit(doc['make.keyword'].value.length())" + } + }, + "size" : 0, + "aggs": { + "make_length": { + "histogram": { + "interval": 1, + "field": "make.length" + } + } + } +} + + +结果如下: + + + +histogram可以参考后文内容。 + +按分类学习Bucket聚合 + + +我们在具体学习时,也无需学习每一个点,基于上面图的认知,我们只需用20%的时间学习最为常用的80%功能即可,其它查查文档而已。@pdai + + +前置条件的过滤:filter + +在当前文档集上下文中定义与指定过滤器(Filter)匹配的所有文档的单个存储桶。通常,这将用于将当前聚合上下文缩小到一组特定的文档。 + +GET /test-agg-cars/_search +{ + "size": 0, + "aggs": { + "make_by": { + "filter": { "term": { "type": "honda" } }, + "aggs": { + "avg_price": { "avg": { "field": "price" } } + } + } + } +} + + +结果如下: + + + +对filter进行分组聚合:filters + +设计一个新的例子, 日志系统中,每条日志都是在文本中,包含warning/info等信息。 + +PUT /test-agg-logs/_bulk?refresh +{ "index" : { "_id" : 1 } } +{ "body" : "warning: page could not be rendered" } +{ "index" : { "_id" : 2 } } +{ "body" : "authentication error" } +{ "index" : { "_id" : 3 } } +{ "body" : "warning: connection timed out" } +{ "index" : { "_id" : 4 } } +{ "body" : "info: hello pdai" } + + +我们需要对包含不同日志类型的日志进行分组,这就需要filters: + +GET /test-agg-logs/_search +{ + "size": 0, + "aggs" : { + "messages" : { + "filters" : { + "other_bucket_key": "other_messages", + "filters" : { + "infos" : { "match" : { "body" : "info" }}, + "warnings" : { "match" : { "body" : "warning" }} + } + } + } + } +} + + +结果如下: + + + +对number类型聚合:Range + +基于多桶值源的聚合,使用户能够定义一组范围-每个范围代表一个桶。在聚合过程中,将从每个存储区范围中检查从每个文档中提取的值,并“存储”相关/匹配的文档。请注意,此聚合包括from值,但不包括to每个范围的值。 + +GET /test-agg-cars/_search +{ + "size": 0, + "aggs": { + "price_ranges": { + "range": { + "field": "price", + "ranges": [ + { "to": 20000 }, + { "from": 20000, "to": 40000 }, + { "from": 40000 } + ] + } + } + } +} + + +结果如下: + + + +对IP类型聚合:IP Range + +专用于IP值的范围聚合。 + +GET /ip_addresses/_search +{ + "size": 10, + "aggs": { + "ip_ranges": { + "ip_range": { + "field": "ip", + "ranges": [ + { "to": "10.0.0.5" }, + { "from": "10.0.0.5" } + ] + } + } + } +} + + +返回 + +{ + ... + + "aggregations": { + "ip_ranges": { + "buckets": [ + { + "key": "*-10.0.0.5", + "to": "10.0.0.5", + "doc_count": 10 + }, + { + "key": "10.0.0.5-*", + "from": "10.0.0.5", + "doc_count": 260 + } + ] + } + } +} + + + +CIDR Mask分组 + + +此外还可以用CIDR Mask分组 + +GET /ip_addresses/_search +{ + "size": 0, + "aggs": { + "ip_ranges": { + "ip_range": { + "field": "ip", + "ranges": [ + { "mask": "10.0.0.0/25" }, + { "mask": "10.0.0.127/25" } + ] + } + } + } +} + + +返回 + +{ + ... + + "aggregations": { + "ip_ranges": { + "buckets": [ + { + "key": "10.0.0.0/25", + "from": "10.0.0.0", + "to": "10.0.0.128", + "doc_count": 128 + }, + { + "key": "10.0.0.127/25", + "from": "10.0.0.0", + "to": "10.0.0.128", + "doc_count": 128 + } + ] + } + } +} + + + +增加key显示 + + +GET /ip_addresses/_search +{ + "size": 0, + "aggs": { + "ip_ranges": { + "ip_range": { + "field": "ip", + "ranges": [ + { "to": "10.0.0.5" }, + { "from": "10.0.0.5" } + ], + "keyed": true // here + } + } + } +} + + +返回 + +{ + ... + + "aggregations": { + "ip_ranges": { + "buckets": { + "*-10.0.0.5": { + "to": "10.0.0.5", + "doc_count": 10 + }, + "10.0.0.5-*": { + "from": "10.0.0.5", + "doc_count": 260 + } + } + } + } +} + + + +自定义key显示 + + +GET /ip_addresses/_search +{ + "size": 0, + "aggs": { + "ip_ranges": { + "ip_range": { + "field": "ip", + "ranges": [ + { "key": "infinity", "to": "10.0.0.5" }, + { "key": "and-beyond", "from": "10.0.0.5" } + ], + "keyed": true + } + } + } +} + + +返回 + +{ + ... + + "aggregations": { + "ip_ranges": { + "buckets": { + "infinity": { + "to": "10.0.0.5", + "doc_count": 10 + }, + "and-beyond": { + "from": "10.0.0.5", + "doc_count": 260 + } + } + } + } +} + + +对日期类型聚合:Date Range + +专用于日期值的范围聚合。 + +GET /test-agg-cars/_search +{ + "size": 0, + "aggs": { + "range": { + "date_range": { + "field": "sold", + "format": "yyyy-MM", + "ranges": [ + { "from": "2014-01-01" }, + { "to": "2014-12-31" } + ] + } + } + } +} + + +结果如下: + + + +此聚合与Range聚合之间的主要区别在于 from和to值可以在Date Math表达式 中表示,并且还可以指定日期格式,通过该日期格式将返回from and to响应字段。请注意,此聚合包括from值,但不包括to每个范围的值。 + +对柱状图功能:Histrogram + +直方图 histogram 本质上是就是为柱状图功能设计的。 + +创建直方图需要指定一个区间,如果我们要为售价创建一个直方图,可以将间隔设为 20,000。这样做将会在每个 $20,000 档创建一个新桶,然后文档会被分到对应的桶中。 + +对于仪表盘来说,我们希望知道每个售价区间内汽车的销量。我们还会想知道每个售价区间内汽车所带来的收入,可以通过对每个区间内已售汽车的售价求和得到。 + +可以用 histogram 和一个嵌套的 sum 度量得到我们想要的答案: + +GET /test-agg-cars/_search +{ + "size" : 0, + "aggs":{ + "price":{ + "histogram":{ + "field": "price.keyword", + "interval": 20000 + }, + "aggs":{ + "revenue": { + "sum": { + "field" : "price" + } + } + } + } + } +} + + + +histogram 桶要求两个参数:一个数值字段以及一个定义桶大小间隔。 +sum 度量嵌套在每个售价区间内,用来显示每个区间内的总收入。 + + +如我们所见,查询是围绕 price 聚合构建的,它包含一个 histogram 桶。它要求字段的类型必须是数值型的同时需要设定分组的间隔范围。 间隔设置为 20,000 意味着我们将会得到如 [0-19999, 20000-39999, …] 这样的区间。 + +接着,我们在直方图内定义嵌套的度量,这个 sum 度量,它会对落入某一具体售价区间的文档中 price 字段的值进行求和。 这可以为我们提供每个售价区间的收入,从而可以发现到底是普通家用车赚钱还是奢侈车赚钱。 + +响应结果如下: + + + +结果很容易理解,不过应该注意到直方图的键值是区间的下限。键 0 代表区间 0-19,999 ,键 20000 代表区间 20,000-39,999 ,等等。 + + + +当然,我们可以为任何聚合输出的分类和统计结果创建条形图,而不只是 直方图 桶。让我们以最受欢迎 10 种汽车以及它们的平均售价、标准差这些信息创建一个条形图。 我们会用到 terms 桶和 extended_stats 度量: + +GET /test-agg-cars/_search +{ + "size" : 0, + "aggs": { + "makes": { + "terms": { + "field": "make.keyword", + "size": 10 + }, + "aggs": { + "stats": { + "extended_stats": { + "field": "price" + } + } + } + } + } +} + + +上述代码会按受欢迎度返回制造商列表以及它们各自的统计信息。我们对其中的 stats.avg 、 stats.count 和 stats.std_deviation 信息特别感兴趣,并用 它们计算出标准差: + +std_err = std_deviation / count + + + + +对应报表: + + + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket.html + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/_aggregation_test_drive.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/11聚合:聚合查询之Metric聚合详解.md b/专栏/ElasticSearch知识体系详解/11聚合:聚合查询之Metric聚合详解.md new file mode 100644 index 0000000..d128832 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/11聚合:聚合查询之Metric聚合详解.md @@ -0,0 +1,928 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 聚合:聚合查询之Metric聚合详解 + 如何理解metric聚合 + + +在[bucket聚合]中,我画了一张图辅助你构筑体系,那么metric聚合又如何理解呢? + + +如果你直接去看官方文档,大概也有十几种: + + + + +那么metric聚合又如何理解呢?我认为从两个角度: + + + +从分类看:Metric聚合分析分为单值分析和多值分析两类 +从功能看:根据具体的应用场景设计了一些分析api, 比如地理位置,百分数等等 + + + +融合上述两个方面,我们可以梳理出大致的一个mind图: + + + + + +单值分析 +只输出一个分析结果 + + + +标准stat型 + + +avg 平均值 +max 最大值 +min 最小值 +sum 和 +value_count 数量 + +其它类型 + + +cardinality 基数(distinct去重) +weighted_avg 带权重的avg +median_absolute_deviation 中位值 + + + + + +多值分析 +单值之外的 + + + +stats型 + + +stats 包含avg,max,min,sum和count +matrix_stats 针对矩阵模型 +extended_stats +string_stats 针对字符串 + +百分数型 + + +percentiles 百分数范围 +percentile_ranks 百分数排行 + +地理位置型 + + +geo_bounds Geo bounds +geo_centroid Geo-centroid +geo_line Geo-Line + +Top型 + + +top_hits 分桶后的top hits +top_metrics + + + + + +通过上述列表(我就不画图了),我们构筑的体系是基于分类和功能,而不是具体的项(比如avg,percentiles…);这是不同的认知维度: 具体的项是碎片化,分类和功能这种是你需要构筑的体系。@pdai + + +单值分析: 标准stat类型 + +avg 平均值 + +计算班级的平均分 + +POST /exams/_search?size=0 +{ + "aggs": { + "avg_grade": { "avg": { "field": "grade" } } + } +} + + +返回 + +{ + ... + "aggregations": { + "avg_grade": { + "value": 75.0 + } + } +} + + +max 最大值 + +计算销售最高价 + +POST /sales/_search?size=0 +{ + "aggs": { + "max_price": { "max": { "field": "price" } } + } +} + + +返回 + +{ + ... + "aggregations": { + "max_price": { + "value": 200.0 + } + } +} + + +min 最小值 + +计算销售最低价 + +POST /sales/_search?size=0 +{ + "aggs": { + "min_price": { "min": { "field": "price" } } + } +} + + +返回 + +{ + ... + + "aggregations": { + "min_price": { + "value": 10.0 + } + } +} + + +sum 和 + +计算销售总价 + +POST /sales/_search?size=0 +{ + "query": { + "constant_score": { + "filter": { + "match": { "type": "hat" } + } + } + }, + "aggs": { + "hat_prices": { "sum": { "field": "price" } } + } +} + + +返回 + +{ + ... + "aggregations": { + "hat_prices": { + "value": 450.0 + } + } +} + + +value_count 数量 + +销售数量统计 + +POST /sales/_search?size=0 +{ + "aggs" : { + "types_count" : { "value_count" : { "field" : "type" } } + } +} + + +返回 + +{ + ... + "aggregations": { + "types_count": { + "value": 7 + } + } +} + + +单值分析: 其它类型 + +weighted_avg 带权重的avg + +POST /exams/_search +{ + "size": 0, + "aggs": { + "weighted_grade": { + "weighted_avg": { + "value": { + "field": "grade" + }, + "weight": { + "field": "weight" + } + } + } + } +} + + +返回 + +{ + ... + "aggregations": { + "weighted_grade": { + "value": 70.0 + } + } +} + + +cardinality 基数(distinct去重) + +POST /sales/_search?size=0 +{ + "aggs": { + "type_count": { + "cardinality": { + "field": "type" + } + } + } +} + + +返回 + +{ + ... + "aggregations": { + "type_count": { + "value": 3 + } + } +} + + +median_absolute_deviation 中位值 + +GET reviews/_search +{ + "size": 0, + "aggs": { + "review_average": { + "avg": { + "field": "rating" + } + }, + "review_variability": { + "median_absolute_deviation": { + "field": "rating" + } + } + } +} + + +返回 + +{ + ... + "aggregations": { + "review_average": { + "value": 3.0 + }, + "review_variability": { + "value": 2.0 + } + } +} + + +非单值分析:stats型 + +stats 包含avg,max,min,sum和count + +POST /exams/_search?size=0 +{ + "aggs": { + "grades_stats": { "stats": { "field": "grade" } } + } +} + + +返回 + +{ + ... + + "aggregations": { + "grades_stats": { + "count": 2, + "min": 50.0, + "max": 100.0, + "avg": 75.0, + "sum": 150.0 + } + } +} + + +matrix_stats 针对矩阵模型 + +以下示例说明了使用矩阵统计量来描述收入与贫困之间的关系。 + +GET /_search +{ + "aggs": { + "statistics": { + "matrix_stats": { + "fields": [ "poverty", "income" ] + } + } + } +} + + +返回 + +{ + ... + "aggregations": { + "statistics": { + "doc_count": 50, + "fields": [ { + "name": "income", + "count": 50, + "mean": 51985.1, + "variance": 7.383377037755103E7, + "skewness": 0.5595114003506483, + "kurtosis": 2.5692365287787124, + "covariance": { + "income": 7.383377037755103E7, + "poverty": -21093.65836734694 + }, + "correlation": { + "income": 1.0, + "poverty": -0.8352655256272504 + } + }, { + "name": "poverty", + "count": 50, + "mean": 12.732000000000001, + "variance": 8.637730612244896, + "skewness": 0.4516049811903419, + "kurtosis": 2.8615929677997767, + "covariance": { + "income": -21093.65836734694, + "poverty": 8.637730612244896 + }, + "correlation": { + "income": -0.8352655256272504, + "poverty": 1.0 + } + } ] + } + } +} + + +extended_stats + +根据从汇总文档中提取的数值计算统计信息。 + +GET /exams/_search +{ + "size": 0, + "aggs": { + "grades_stats": { "extended_stats": { "field": "grade" } } + } +} + + +上面的汇总计算了所有文档的成绩统计信息。聚合类型为extended_stats,并且字段设置定义将在其上计算统计信息的文档的数字字段。 + +{ + ... + + "aggregations": { + "grades_stats": { + "count": 2, + "min": 50.0, + "max": 100.0, + "avg": 75.0, + "sum": 150.0, + "sum_of_squares": 12500.0, + "variance": 625.0, + "variance_population": 625.0, + "variance_sampling": 1250.0, + "std_deviation": 25.0, + "std_deviation_population": 25.0, + "std_deviation_sampling": 35.35533905932738, + "std_deviation_bounds": { + "upper": 125.0, + "lower": 25.0, + "upper_population": 125.0, + "lower_population": 25.0, + "upper_sampling": 145.71067811865476, + "lower_sampling": 4.289321881345245 + } + } + } +} + + +string_stats 针对字符串 + +用于计算从聚合文档中提取的字符串值的统计信息。这些值可以从特定的关键字字段中检索。 + +POST /my-index-000001/_search?size=0 +{ + "aggs": { + "message_stats": { "string_stats": { "field": "message.keyword" } } + } +} + + +返回 + +{ + ... + + "aggregations": { + "message_stats": { + "count": 5, + "min_length": 24, + "max_length": 30, + "avg_length": 28.8, + "entropy": 3.94617750050791 + } + } +} + + +非单值分析:百分数型 + +percentiles 百分数范围 + +针对从聚合文档中提取的数值计算一个或多个百分位数。 + +GET latency/_search +{ + "size": 0, + "aggs": { + "load_time_outlier": { + "percentiles": { + "field": "load_time" + } + } + } +} + + +默认情况下,百分位度量标准将生成一定范围的百分位:[1,5,25,50,75,95,99]。 + +{ + ... + + "aggregations": { + "load_time_outlier": { + "values": { + "1.0": 5.0, + "5.0": 25.0, + "25.0": 165.0, + "50.0": 445.0, + "75.0": 725.0, + "95.0": 945.0, + "99.0": 985.0 + } + } + } +} + + +percentile_ranks 百分数排行 + +根据从汇总文档中提取的数值计算一个或多个百分位等级。 + +GET latency/_search +{ + "size": 0, + "aggs": { + "load_time_ranks": { + "percentile_ranks": { + "field": "load_time", + "values": [ 500, 600 ] + } + } + } +} + + +返回 + +{ + ... + + "aggregations": { + "load_time_ranks": { + "values": { + "500.0": 90.01, + "600.0": 100.0 + } + } + } +} + + +上述结果表示90.01%的页面加载在500ms内完成,而100%的页面加载在600ms内完成。 + +非单值分析:地理位置型 + +geo_bounds Geo bounds + +PUT /museums +{ + "mappings": { + "properties": { + "location": { + "type": "geo_point" + } + } + } +} + +POST /museums/_bulk?refresh +{"index":{"_id":1}} +{"location": "52.374081,4.912350", "name": "NEMO Science Museum"} +{"index":{"_id":2}} +{"location": "52.369219,4.901618", "name": "Museum Het Rembrandthuis"} +{"index":{"_id":3}} +{"location": "52.371667,4.914722", "name": "Nederlands Scheepvaartmuseum"} +{"index":{"_id":4}} +{"location": "51.222900,4.405200", "name": "Letterenhuis"} +{"index":{"_id":5}} +{"location": "48.861111,2.336389", "name": "Musée du Louvre"} +{"index":{"_id":6}} +{"location": "48.860000,2.327000", "name": "Musée d'Orsay"} + +POST /museums/_search?size=0 +{ + "query": { + "match": { "name": "musée" } + }, + "aggs": { + "viewport": { + "geo_bounds": { + "field": "location", + "wrap_longitude": true + } + } + } +} + + +上面的汇总展示了如何针对具有商店业务类型的所有文档计算位置字段的边界框 + +{ + ... + "aggregations": { + "viewport": { + "bounds": { + "top_left": { + "lat": 48.86111099738628, + "lon": 2.3269999679178 + }, + "bottom_right": { + "lat": 48.85999997612089, + "lon": 2.3363889567553997 + } + } + } + } +} + + +geo_centroid Geo-centroid + +PUT /museums +{ + "mappings": { + "properties": { + "location": { + "type": "geo_point" + } + } + } +} + +POST /museums/_bulk?refresh +{"index":{"_id":1}} +{"location": "52.374081,4.912350", "city": "Amsterdam", "name": "NEMO Science Museum"} +{"index":{"_id":2}} +{"location": "52.369219,4.901618", "city": "Amsterdam", "name": "Museum Het Rembrandthuis"} +{"index":{"_id":3}} +{"location": "52.371667,4.914722", "city": "Amsterdam", "name": "Nederlands Scheepvaartmuseum"} +{"index":{"_id":4}} +{"location": "51.222900,4.405200", "city": "Antwerp", "name": "Letterenhuis"} +{"index":{"_id":5}} +{"location": "48.861111,2.336389", "city": "Paris", "name": "Musée du Louvre"} +{"index":{"_id":6}} +{"location": "48.860000,2.327000", "city": "Paris", "name": "Musée d'Orsay"} + +POST /museums/_search?size=0 +{ + "aggs": { + "centroid": { + "geo_centroid": { + "field": "location" + } + } + } +} + + +上面的汇总显示了如何针对所有具有犯罪类型的盗窃文件计算位置字段的质心。 + +{ + ... + "aggregations": { + "centroid": { + "location": { + "lat": 51.00982965203002, + "lon": 3.9662131341174245 + }, + "count": 6 + } + } +} + + +geo_line Geo-Line + +PUT test +{ + "mappings": { + "dynamic": "strict", + "_source": { + "enabled": false + }, + "properties": { + "my_location": { + "type": "geo_point" + }, + "group": { + "type": "keyword" + }, + "@timestamp": { + "type": "date" + } + } + } +} + +POST /test/_bulk?refresh +{"index": {}} +{"my_location": {"lat":37.3450570, "lon": -122.0499820}, "@timestamp": "2013-09-06T16:00:36"} +{"index": {}} +{"my_location": {"lat": 37.3451320, "lon": -122.0499820}, "@timestamp": "2013-09-06T16:00:37Z"} +{"index": {}} +{"my_location": {"lat": 37.349283, "lon": -122.0505010}, "@timestamp": "2013-09-06T16:00:37Z"} + +POST /test/_search?filter_path=aggregations +{ + "aggs": { + "line": { + "geo_line": { + "point": {"field": "my_location"}, + "sort": {"field": "@timestamp"} + } + } + } +} + + +将存储桶中的所有geo_point值聚合到由所选排序字段排序的LineString中。 + +{ + "aggregations": { + "line": { + "type" : "Feature", + "geometry" : { + "type" : "LineString", + "coordinates" : [ + [ + -122.049982, + 37.345057 + ], + [ + -122.050501, + 37.349283 + ], + [ + -122.049982, + 37.345132 + ] + ] + }, + "properties" : { + "complete" : true + } + } + } +} + + +非单值分析:Top型 + +top_hits 分桶后的top hits + +POST /sales/_search?size=0 +{ + "aggs": { + "top_tags": { + "terms": { + "field": "type", + "size": 3 + }, + "aggs": { + "top_sales_hits": { + "top_hits": { + "sort": [ + { + "date": { + "order": "desc" + } + } + ], + "_source": { + "includes": [ "date", "price" ] + }, + "size": 1 + } + } + } + } + } +} + + +返回 + +{ + ... + "aggregations": { + "top_tags": { + "doc_count_error_upper_bound": 0, + "sum_other_doc_count": 0, + "buckets": [ + { + "key": "hat", + "doc_count": 3, + "top_sales_hits": { + "hits": { + "total" : { + "value": 3, + "relation": "eq" + }, + "max_score": null, + "hits": [ + { + "_index": "sales", + "_type": "_doc", + "_id": "AVnNBmauCQpcRyxw6ChK", + "_source": { + "date": "2015/03/01 00:00:00", + "price": 200 + }, + "sort": [ + 1425168000000 + ], + "_score": null + } + ] + } + } + }, + { + "key": "t-shirt", + "doc_count": 3, + "top_sales_hits": { + "hits": { + "total" : { + "value": 3, + "relation": "eq" + }, + "max_score": null, + "hits": [ + { + "_index": "sales", + "_type": "_doc", + "_id": "AVnNBmauCQpcRyxw6ChL", + "_source": { + "date": "2015/03/01 00:00:00", + "price": 175 + }, + "sort": [ + 1425168000000 + ], + "_score": null + } + ] + } + } + }, + { + "key": "bag", + "doc_count": 1, + "top_sales_hits": { + "hits": { + "total" : { + "value": 1, + "relation": "eq" + }, + "max_score": null, + "hits": [ + { + "_index": "sales", + "_type": "_doc", + "_id": "AVnNBmatCQpcRyxw6ChH", + "_source": { + "date": "2015/01/01 00:00:00", + "price": 150 + }, + "sort": [ + 1420070400000 + ], + "_score": null + } + ] + } + } + } + ] + } + } +} + + +top_metrics + +POST /test/_bulk?refresh +{"index": {}} +{"s": 1, "m": 3.1415} +{"index": {}} +{"s": 2, "m": 1.0} +{"index": {}} +{"s": 3, "m": 2.71828} +POST /test/_search?filter_path=aggregations +{ + "aggs": { + "tm": { + "top_metrics": { + "metrics": {"field": "m"}, + "sort": {"s": "desc"} + } + } + } +} + + +返回 + +{ + "aggregations": { + "tm": { + "top": [ {"sort": [3], "metrics": {"m": 2.718280076980591 } } ] + } + } +} + + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/12聚合:聚合查询之Pipline聚合详解.md b/专栏/ElasticSearch知识体系详解/12聚合:聚合查询之Pipline聚合详解.md new file mode 100644 index 0000000..179ab01 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/12聚合:聚合查询之Pipline聚合详解.md @@ -0,0 +1,266 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 聚合:聚合查询之Pipline聚合详解 + 如何理解pipeline聚合 + + +如何理解管道聚合呢?最重要的是要站在设计者角度看这个功能的要实现的目的:让上一步的聚合结果成为下一个聚合的输入,这就是管道。 + + +管道机制的常见场景 + + +首先回顾下,Tomcat管道机制中向你介绍的常见的管道机制设计中的应用场景。 + + +责任链模式 + +管道机制在设计模式上属于责任链模式,如果你不理解,请参看如下文章: + +责任链模式: 通过责任链模式, 你可以为某个请求创建一个对象链. 每个对象依序检查此请求并对其进行处理或者将它传给链中的下一个对象。 + +FilterChain + +在软件开发的常接触的责任链模式是FilterChain,它体现在很多软件设计中: + + +比如Spring Security框架中 + + + + + +比如HttpServletRequest处理的过滤器中 + + +当一个request过来的时候,需要对这个request做一系列的加工,使用责任链模式可以使每个加工组件化,减少耦合。也可以使用在当一个request过来的时候,需要找到合适的加工方式。当一个加工方式不适合这个request的时候,传递到下一个加工方法,该加工方式再尝试对request加工。 + +网上找了图,这里我们后文将通过Tomcat请求处理向你阐述。 + + + +ElasticSearch设计管道机制 + +简单而言:让上一步的聚合结果成为下一个聚合的输入,这就是管道。 + +接下来,无非就是对不同类型的聚合有接口的支撑,比如: + + + + +第一个维度:管道聚合有很多不同类型,每种类型都与其他聚合计算不同的信息,但是可以将这些类型分为两类: + + + +父级 父级聚合的输出提供了一组管道聚合,它可以计算新的存储桶或新的聚合以添加到现有存储桶中。 +兄弟 同级聚合的输出提供的管道聚合,并且能够计算与该同级聚合处于同一级别的新聚合。 + + + +第二个维度:根据功能设计的意图 + + +比如前置聚合可能是Bucket聚合,后置的可能是基于Metric聚合,那么它就可以成为一类管道 + +进而引出了:xxx bucket(是不是很容易理解了 @pdai) + + +Bucket聚合 -> Metric聚合 + + +: bucket聚合的结果,成为下一步metric聚合的输入 + + +Average bucket +Min bucket +Max bucket +Sum bucket +Stats bucket +Extended stats bucket + + +对构建体系而言,理解上面的已经够了,其它的类型不过是锦上添花而言。 + +一些例子 + + +这里我们通过几个简单的例子看看即可,具体如果需要使用看看文档即可。@pdai + + +Average bucket 聚合 + +POST _search +{ + "size": 0, + "aggs": { + "sales_per_month": { + "date_histogram": { + "field": "date", + "calendar_interval": "month" + }, + "aggs": { + "sales": { + "sum": { + "field": "price" + } + } + } + }, + "avg_monthly_sales": { +// tag::avg-bucket-agg-syntax[] + "avg_bucket": { + "buckets_path": "sales_per_month>sales", + "gap_policy": "skip", + "format": "#,##0.00;(#,##0.00)" + } +// end::avg-bucket-agg-syntax[] + } + } +} + + + +嵌套的bucket聚合:聚合出按月价格的直方图 +Metic聚合:对上面的聚合再求平均值。 + + +字段类型: + + +buckets_path:指定聚合的名称,支持多级嵌套聚合。 +gap_policy 当管道聚合遇到不存在的值,有点类似于term等聚合的(missing)时所采取的策略,可选择值为:skip、insert_zeros。 +skip:此选项将丢失的数据视为bucket不存在。它将跳过桶并使用下一个可用值继续计算。 +format 用于格式化聚合桶的输出(key)。 + + +输出结果如下 + +{ + "took": 11, + "timed_out": false, + "_shards": ..., + "hits": ..., + "aggregations": { + "sales_per_month": { + "buckets": [ + { + "key_as_string": "2015/01/01 00:00:00", + "key": 1420070400000, + "doc_count": 3, + "sales": { + "value": 550.0 + } + }, + { + "key_as_string": "2015/02/01 00:00:00", + "key": 1422748800000, + "doc_count": 2, + "sales": { + "value": 60.0 + } + }, + { + "key_as_string": "2015/03/01 00:00:00", + "key": 1425168000000, + "doc_count": 2, + "sales": { + "value": 375.0 + } + } + ] + }, + "avg_monthly_sales": { + "value": 328.33333333333333, + "value_as_string": "328.33" + } + } +} + + +Stats bucket 聚合 + +进一步的stat bucket也很容易理解了 + +POST /sales/_search +{ + "size": 0, + "aggs": { + "sales_per_month": { + "date_histogram": { + "field": "date", + "calendar_interval": "month" + }, + "aggs": { + "sales": { + "sum": { + "field": "price" + } + } + } + }, + "stats_monthly_sales": { + "stats_bucket": { + "buckets_path": "sales_per_month>sales" + } + } + } +} + + +返回 + +{ + "took": 11, + "timed_out": false, + "_shards": ..., + "hits": ..., + "aggregations": { + "sales_per_month": { + "buckets": [ + { + "key_as_string": "2015/01/01 00:00:00", + "key": 1420070400000, + "doc_count": 3, + "sales": { + "value": 550.0 + } + }, + { + "key_as_string": "2015/02/01 00:00:00", + "key": 1422748800000, + "doc_count": 2, + "sales": { + "value": 60.0 + } + }, + { + "key_as_string": "2015/03/01 00:00:00", + "key": 1425168000000, + "doc_count": 2, + "sales": { + "value": 375.0 + } + } + ] + }, + "stats_monthly_sales": { + "count": 3, + "min": 60.0, + "max": 550.0, + "avg": 328.3333333333333, + "sum": 985.0 + } + } +} + + +参考文章 + +https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/13原理:从图解构筑对ES原理的初步认知.md b/专栏/ElasticSearch知识体系详解/13原理:从图解构筑对ES原理的初步认知.md new file mode 100644 index 0000000..94294f9 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/13原理:从图解构筑对ES原理的初步认知.md @@ -0,0 +1,405 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 原理:从图解构筑对ES原理的初步认知 + 前言 + +本文先自上而下,后自底向上的介绍ElasticSearch的底层工作原理,试图回答以下问题: + + +为什么我的搜索 *foo-bar* 无法匹配 foo-bar ? +为什么增加更多的文件会压缩索引(Index)? +为什么ElasticSearch占用很多内存? + + +版本 + +elasticsearch版本: elasticsearch-2.2.0 + +图解ElasticSearch + + +云上的集群 + + + + + +集群里的盒子 + + +云里面的每个白色正方形的盒子代表一个节点——Node。 + + + + +节点之间 + + +在一个或者多个节点直接,多个绿色小方块组合在一起形成一个ElasticSearch的索引。 + + + + +索引里的小方块 + + +在一个索引下,分布在多个节点里的绿色小方块称为分片——Shard。 + + + + +Shard=Lucene Index + + +一个ElasticSearch的Shard本质上是一个Lucene Index。 + + + +Lucene是一个Full Text 搜索库(也有很多其他形式的搜索库),ElasticSearch是建立在Lucene之上的。接下来的故事要说的大部分内容实际上是ElasticSearch如何基于Lucene工作的。 + +图解Lucene + +Segment + + +Mini索引——segment + + +在Lucene里面有很多小的segment,我们可以把它们看成Lucene内部的mini-index。 + + + + +Segment内部 + + +(有着许多数据结构) + + +Inverted Index +Stored Fields +Document Values +Cache + + + + +Inverted Index + +最最重要的Inverted Index + + + +Inverted Index主要包括两部分: + + +一个有序的数据字典Dictionary(包括单词Term和它出现的频率)。 +与单词Term对应的Postings(即存在这个单词的文件)。 + + +当我们搜索的时候,首先将搜索的内容分解,然后在字典里找到对应Term,从而查找到与搜索相关的文件内容。 + + + + +查询“the fury” + + + + + +自动补全(AutoCompletion-Prefix) + + +如果想要查找以字母“c”开头的字母,可以简单的通过二分查找(Binary Search)在Inverted Index表中找到例如“choice”、“coming”这样的词(Term)。 + + + + +昂贵的查找 + + +如果想要查找所有包含“our”字母的单词,那么系统会扫描整个Inverted Index,这是非常昂贵的。 + + + +在此种情况下,如果想要做优化,那么我们面对的问题是如何生成合适的Term。 + + +问题的转化 + + + + +对于以上诸如此类的问题,我们可能会有几种可行的解决方案: + + +* suffix -> xiffus * + + +如果我们想以后缀作为搜索条件,可以为Term做反向处理。 + + +(60.6384, 6.5017) -> u4u8gyykk + + +对于GEO位置信息,可以将它转换为GEO Hash。 + + +123 -> {1-hundreds, 12-tens, 123} + + +对于简单的数字,可以为它生成多重形式的Term。 + + +解决拼写错误 + + +一个Python库 为单词生成了一个包含错误拼写信息的树形状态机,解决拼写错误的问题。 + + + +Stored Field字段查找 + +当我们想要查找包含某个特定标题内容的文件时,Inverted Index就不能很好的解决这个问题,所以Lucene提供了另外一种数据结构Stored Fields来解决这个问题。本质上,Stored Fields是一个简单的键值对key-value。默认情况下,ElasticSearch会存储整个文件的JSON source。 + + + +Document Values为了排序,聚合 + +即使这样,我们发现以上结构仍然无法解决诸如:排序、聚合、facet,因为我们可能会要读取大量不需要的信息。 + +所以,另一种数据结构解决了此种问题:Document Values。这种结构本质上就是一个列式的存储,它高度优化了具有相同类型的数据的存储结构。 + + + +为了提高效率,ElasticSearch可以将索引下某一个Document Value全部读取到内存中进行操作,这大大提升访问速度,但是也同时会消耗掉大量的内存空间。 + +总之,这些数据结构Inverted Index、Stored Fields、Document Values及其缓存,都在segment内部。 + +搜索发生时 + +搜索时,Lucene会搜索所有的segment然后将每个segment的搜索结果返回,最后合并呈现给客户。 + +Lucene的一些特性使得这个过程非常重要: + + +Segments是不可变的(immutable) + + +Delete? 当删除发生时,Lucene做的只是将其标志位置为删除,但是文件还是会在它原来的地方,不会发生改变 +Update? 所以对于更新来说,本质上它做的工作是:先删除,然后重新索引(Re-index) + +随处可见的压缩 + + +Lucene非常擅长压缩数据,基本上所有教科书上的压缩方式,都能在Lucene中找到。 + +缓存所有的所有 + + +Lucene也会将所有的信息做缓存,这大大提高了它的查询效率。 + + + +缓存的故事 + +当ElasticSearch索引一个文件的时候,会为文件建立相应的缓存,并且会定期(每秒)刷新这些数据,然后这些文件就可以被搜索到。 + + + +随着时间的增加,我们会有很多segments, + + + +所以ElasticSearch会将这些segment合并,在这个过程中,segment会最终被删除掉 + + + +这就是为什么增加文件可能会使索引所占空间变小,它会引起merge,从而可能会有更多的压缩。 + + +举个栗子 + + +有两个segment将会merge + + + +这两个segment最终会被删除,然后合并成一个新的segment + + + +这时这个新的segment在缓存中处于cold状态,但是大多数segment仍然保持不变,处于warm状态。 + +以上场景经常在Lucene Index内部发生的。 + + + +在Shard中搜索 + +ElasticSearch从Shard中搜索的过程与Lucene Segment中搜索的过程类似。 + + + +与在Lucene Segment中搜索不同的是,Shard可能是分布在不同Node上的,所以在搜索与返回结果时,所有的信息都会通过网络传输。 + +需要注意的是: + +1次搜索查找2个shard = 2次分别搜索shard + + + + +对于日志文件的处理 + + +当我们想搜索特定日期产生的日志时,通过根据时间戳对日志文件进行分块与索引,会极大提高搜索效率。 + +当我们想要删除旧的数据时也非常方便,只需删除老的索引即可。 + + + +在上种情况下,每个index有两个shards + + +如何Scale + + + + +shard不会进行更进一步的拆分,但是shard可能会被转移到不同节点上 + + + +所以,如果当集群节点压力增长到一定的程度,我们可能会考虑增加新的节点,这就会要求我们对所有数据进行重新索引,这是我们不太希望看到的,所以我们需要在规划的时候就考虑清楚,如何去平衡足够多的节点与不足节点之间的关系。 + + +节点分配与Shard优化 + + +为更重要的数据索引节点,分配性能更好的机器 +确保每个shard都有副本信息replica + + + + + + +路由Routing + + +每个节点,每个都存留一份路由表,所以当请求到任何一个节点时,ElasticSearch都有能力将请求转发到期望节点的shard进一步处理。 + + + +一个真实的请求 + + + + +Query + + + + +Query有一个类型filtered,以及一个multi_match的查询 + + +Aggregation + + + + +根据作者进行聚合,得到top10的hits的top10作者的信息 + + +请求分发 + + +这个请求可能被分发到集群里的任意一个节点 + + + + +上帝节点 + + + + +这时这个节点就成为当前请求的协调者(Coordinator),它决定: a) 根据索引信息,判断请求会被路由到哪个核心节点 b) 以及哪个副本是可用的 c) 等等 + + +路由 + + + + + +在真实搜索之前 + + +ElasticSearch 会将Query转换成Lucene Query + + + +然后在所有的segment中执行计算 + + + +对于Filter条件本身也会有缓存 + + + +但queries不会被缓存,所以如果相同的Query重复执行,应用程序自己需要做缓存 + + + +所以, + +a) filters可以在任何时候使用 b) query只有在需要score的时候才使用 + + +返回 + + +搜索结束之后,结果会沿着下行的路径向上逐层返回。 + + + + + + + + + + + +参考来源 + +SlideShare: Elasticsearch From the Bottom Up + +Youtube: Elasticsearch from the bottom up + +Wiki: Document-term matrix + +Wiki: Search engine indexing + +Skip list + +Standford Edu: Faster postings list intersection via skip pointers + +StackOverflow: how an search index works when querying many words? + +StackOverflow: how does lucene calculate intersection of documents so fast? + +Lucene and its magical indexes + +misspellings 2.0c: A tool to detect misspellings + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/14原理:ES原理知识点补充和整体结构.md b/专栏/ElasticSearch知识体系详解/14原理:ES原理知识点补充和整体结构.md new file mode 100644 index 0000000..30c9b87 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/14原理:ES原理知识点补充和整体结构.md @@ -0,0 +1,197 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 原理:ES原理知识点补充和整体结构 + ElasticSearch整体结构 + + +通过上文,在通过图解了解了ES整体的原理后,我们梳理下ES的整体结构 + + + + + +一个 ES Index 在集群模式下,有多个 Node (节点)组成。每个节点就是 ES 的Instance (实例)。 +每个节点上会有多个 shard (分片), P1 P2 是主分片, R1 R2 是副本分片 +每个分片上对应着就是一个 Lucene Index(底层索引文件) +Lucene Index 是一个统称 + + +由多个 Segment (段文件,就是倒排索引)组成。每个段文件存储着就是 Doc 文档。 +commit point记录了所有 segments 的信息 + + + +补充:Lucene索引结构 + + +上图中Lucene的索引结构中有哪些文件呢? + + + + +(更多文件类型可参考这里 ) + + + +文件的关系如下: + + + +补充:Lucene处理流程 + + +上文图解过程,还需要理解Lucene处理流程, 这将帮助你更好的索引文档和搜索文档。 + + + + +创建索引的过程: + + +准备待索引的原文档,数据来源可能是文件、数据库或网络 +对文档的内容进行分词组件处理,形成一系列的Term +索引组件对文档和Term处理,形成字典和倒排表 + + +搜索索引的过程: + + +对查询语句进行分词处理,形成一系列Term +根据倒排索引表查找出包含Term的文档,并进行合并形成符合结果的文档集 +比对查询语句与各个文档相关性得分,并按照得分高低返回 + + +补充:ElasticSearch分析器 + + +上图中很重要的一项是语法分析/语言处理, 所以我们还需要补充ElasticSearch分析器知识点。 + + +分析 包含下面的过程: + + +首先,将一块文本分成适合于倒排索引的独立的 词条 , +之后,将这些词条统一化为标准格式以提高它们的“可搜索性”,或者 recall + + +分析器执行上面的工作。 分析器 实际上是将三个功能封装到了一个包里: + + +字符过滤器 首先,字符串按顺序通过每个 字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉HTML,或者将 & 转化成 and。 +分词器 其次,字符串被 分词器 分为单个的词条。一个简单的分词器遇到空格和标点的时候,可能会将文本拆分成词条。 +Token 过滤器 最后,词条按顺序通过每个 token 过滤器 。这个过程可能会改变词条(例如,小写化 Quick ),删除词条(例如, 像 a, and, the 等无用词),或者增加词条(例如,像 jump 和 leap 这种同义词)。 + + +Elasticsearch提供了开箱即用的字符过滤器、分词器和token 过滤器。 这些可以组合起来形成自定义的分析器以用于不同的目的。 + +内置分析器 + +Elasticsearch还附带了可以直接使用的预包装的分析器。接下来我们会列出最重要的分析器。为了证明它们的差异,我们看看每个分析器会从下面的字符串得到哪些词条: + +"Set the shape to semi-transparent by calling set_trans(5)" + + + +标准分析器 + + +标准分析器是Elasticsearch默认使用的分析器。它是分析各种语言文本最常用的选择。它根据 Unicode 联盟 定义的 单词边界 划分文本。删除绝大部分标点。最后,将词条小写。它会产生 + +set, the, shape, to, semi, transparent, by, calling, set_trans, 5 + + + +简单分析器 + + +简单分析器在任何不是字母的地方分隔文本,将词条小写。它会产生 + +set, the, shape, to, semi, transparent, by, calling, set, trans + + + +空格分析器 + + +空格分析器在空格的地方划分文本。它会产生 + +Set, the, shape, to, semi-transparent, by, calling, set_trans(5) + + + +语言分析器 + + +特定语言分析器可用于 很多语言。它们可以考虑指定语言的特点。例如, 英语 分析器附带了一组英语无用词(常用单词,例如 and 或者 the ,它们对相关性没有多少影响),它们会被删除。 由于理解英语语法的规则,这个分词器可以提取英语单词的 词干 。 + +英语 分词器会产生下面的词条: + +set, shape, semi, transpar, call, set_tran, 5 + + +注意看 transparent、 calling 和 set_trans 已经变为词根格式。 + +什么时候使用分析器 + +当我们 索引 一个文档,它的全文域被分析成词条以用来创建倒排索引。 但是,当我们在全文域 搜索 的时候,我们需要将查询字符串通过 相同的分析过程 ,以保证我们搜索的词条格式与索引中的词条格式一致。 + +全文查询,理解每个域是如何定义的,因此它们可以做正确的事: + + +当你查询一个 全文 域时, 会对查询字符串应用相同的分析器,以产生正确的搜索词条列表。 +当你查询一个 精确值 域时,不会分析查询字符串,而是搜索你指定的精确值。 + + + +举个例子 + + +ES中每天一条数据, 按照如下方式查询: + +GET /_search?q=2014 # 12 results +GET /_search?q=2014-09-15 # 12 results ! +GET /_search?q=date:2014-09-15 # 1 result +GET /_search?q=date:2014 # 0 results ! + + +为什么返回那样的结果? + + +date 域包含一个精确值:单独的词条 2014-09-15。 +_all 域是一个全文域,所以分词进程将日期转化为三个词条: 2014, 09, 和 15。 + + +当我们在 _all 域查询 2014,它匹配所有的12条推文,因为它们都含有 2014 : + +GET /_search?q=2014 # 12 results + + +当我们在 _all 域查询 2014-09-15,它首先分析查询字符串,产生匹配 2014, 09, 或 15 中 任意 词条的查询。这也会匹配所有12条推文,因为它们都含有 2014 : + +GET /_search?q=2014-09-15 # 12 results ! + + +当我们在 date 域查询 2014-09-15,它寻找 精确 日期,只找到一个推文: + +GET /_search?q=date:2014-09-15 # 1 result + + +当我们在 date 域查询 2014,它找不到任何文档,因为没有文档含有这个精确日志: + +GET /_search?q=date:2014 # 0 results ! + + +参考文章 + +https://new.qq.com/omn/20210320/20210320A01XHF00.html + +https://juejin.cn/post/6844903473666867208 + +http://lucene.apache.org/core/7_2_1/core/org/apache/lucene/codecs/lucene70/package-summary.html#package.description + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/15原理:ES原理之索引文档流程详解.md b/专栏/ElasticSearch知识体系详解/15原理:ES原理之索引文档流程详解.md new file mode 100644 index 0000000..a96c8af --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/15原理:ES原理之索引文档流程详解.md @@ -0,0 +1,433 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 原理:ES原理之索引文档流程详解 + 文档索引步骤顺序 + +单个文档 + +新建单个文档所需要的步骤顺序: + + + + +客户端向 Node 1 发送新建、索引或者删除请求。 +节点使用文档的 _id 确定文档属于分片 0 。请求会被转发到 Node 3,因为分片 0 的主分片目前被分配在 Node 3 上。 +Node 3 在主分片上面执行请求。如果成功了,它将请求并行转发到 Node 1 和 Node 2 的副本分片上。一旦所有的副本分片都报告成功, Node 3 将向协调节点报告成功,协调节点向客户端报告成功。 + + +多个文档 + +使用 bulk 修改多个文档步骤顺序: + + + + +客户端向 Node 1 发送 bulk 请求。 +Node 1 为每个节点创建一个批量请求,并将这些请求并行转发到每个包含主分片的节点主机。 +主分片一个接一个按顺序执行每个操作。当每个操作成功时,主分片并行转发新文档(或删除)到副本分片,然后执行下一个操作。 一旦所有的副本分片报告所有操作成功,该节点将向协调节点报告成功,协调节点将这些响应收集整理并返回给客户端。 + + +文档索引过程详解 + +整体的索引流程 + + +先看下整体的索引流程 + + + + + +协调节点默认使用文档ID参与计算(也支持通过routing),以便为路由提供合适的分片。 + + +shard = hash(document_id) % (num_of_primary_shards) + + + +当分片所在的节点接收到来自协调节点的请求后,会将请求写入到Memory Buffer,然后定时(默认是每隔1秒)写入到Filesystem Cache,这个从Momery Buffer到Filesystem Cache的过程就叫做refresh; +当然在某些情况下,存在Momery Buffer和Filesystem Cache的数据可能会丢失,ES是通过translog的机制来保证数据的可靠性的。其实现机制是接收到请求后,同时也会写入到translog中,当Filesystem cache中的数据写入到磁盘中时,才会清除掉,这个过程叫做flush。 +在flush过程中,内存中的缓冲将被清除,内容被写入一个新段,段的fsync将创建一个新的提交点,并将内容刷新到磁盘,旧的translog将被删除并开始一个新的translog。 flush触发的时机是定时触发(默认30分钟)或者translog变得太大(默认为512M)时。 + + +分步骤看数据持久化过程 + + +通过分步骤看数据持久化过程:write -> refresh -> flush -> merge + + + +write 过程 + + + + +一个新文档过来,会存储在 in-memory buffer 内存缓存区中,顺便会记录 Translog(Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录)。 + +这时候数据还没到 segment ,是搜不到这个新文档的。数据只有被 refresh 后,才可以被搜索到。 + + +refresh 过程 + + + + +refresh 默认 1 秒钟,执行一次上图流程。ES 是支持修改这个值的,通过 index.refresh_interval 设置 refresh (冲刷)间隔时间。refresh 流程大致如下: + + +in-memory buffer 中的文档写入到新的 segment 中,但 segment 是存储在文件系统的缓存中。此时文档可以被搜索到 +最后清空 in-memory buffer。注意: Translog 没有被清空,为了将 segment 数据写到磁盘 +文档经过 refresh 后, segment 暂时写到文件系统缓存,这样避免了性能 IO 操作,又可以使文档搜索到。refresh 默认 1 秒执行一次,性能损耗太大。一般建议稍微延长这个 refresh 时间间隔,比如 5 s。因此,ES 其实就是准实时,达不到真正的实时。 + + + +flush 过程 + + +每隔一段时间—例如 translog 变得越来越大—索引被刷新(flush);一个新的 translog 被创建,并且一个全量提交被执行 + + + +上个过程中 segment 在文件系统缓存中,会有意外故障文档丢失。那么,为了保证文档不会丢失,需要将文档写入磁盘。那么文档从文件缓存写入磁盘的过程就是 flush。写入次怕后,清空 translog。具体过程如下: + + +所有在内存缓冲区的文档都被写入一个新的段。 +缓冲区被清空。 +一个Commit Point被写入硬盘。 +文件系统缓存通过 fsync 被刷新(flush)。 +老的 translog 被删除。 + + + +merge 过程 + + +由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。 + +Elasticsearch通过在后台进行Merge Segment来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。 + +当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。 + + + +一旦合并结束,老的段被删除: + + +新的段被刷新(flush)到了磁盘。 ** 写入一个包含新段且排除旧的和较小的段的新提交点。 +新的段被打开用来搜索。 +老的段被删除。 + + + + +合并大的段需要消耗大量的I/O和CPU资源,如果任其发展会影响搜索性能。Elasticsearch在默认情况下会对合并流程进行资源限制,所以搜索仍然 有足够的资源很好地执行。 + +深入ElasticSearch索引文档的实现机制 + +TIP + +作为选读内容。 + +写操作的关键点 + +在考虑或分析一个分布式系统的写操作时,一般需要从下面几个方面考虑: + + +可靠性:或者是持久性,数据写入系统成功后,数据不会被回滚或丢失。 +一致性:数据写入成功后,再次查询时必须能保证读取到最新版本的数据,不能读取到旧数据。 +原子性:一个写入或者更新操作,要么完全成功,要么完全失败,不允许出现中间状态。 +隔离性:多个写入操作相互不影响。 +实时性:写入后是否可以立即被查询到。 +性能:写入性能,吞吐量到底怎么样。 + + +Elasticsearch作为分布式系统,也需要在写入的时候满足上述的四个特点,我们在后面的写流程介绍中会涉及到上述四个方面。 + +接下来,我们一层一层剖析Elasticsearch内部的写机制。 + +Lucene的写 + +众所周知,Elasticsearch内部使用了Lucene完成索引创建和搜索功能,Lucene中写操作主要是通过IndexWriter类实现,IndexWriter提供三个接口: + + public long addDocument(); + public long updateDocuments(); + public long deleteDocuments(); + + +通过这三个接口可以完成单个文档的写入,更新和删除功能,包括了分词,倒排创建,正排创建等等所有搜索相关的流程。只要Doc通过IndesWriter写入后,后面就可以通过IndexSearcher搜索了,看起来功能已经完善了,但是仍然有一些问题没有解: + + +上述操作是单机的,而不是我们需要的分布式。 +文档写入Lucene后并不是立即可查询的,需要生成完整的Segment后才可被搜索,如何保证实时性? +Lucene生成的Segment是在内存中,如果机器宕机或掉电后,内存中的Segment会丢失,如何保证数据可靠性 ? +Lucene不支持部分文档更新,但是这又是一个强需求,如何支持部分更新? + + +上述问题,在Lucene中是没有解决的,那么就需要Elasticsearch中解决上述问题。 + +我们再来看Elasticsearch中的写机制。 + +Elasticsearch的写 + +Elasticsearch采用多Shard方式,通过配置routing规则将数据分成多个数据子集,每个数据子集提供独立的索引和搜索功能。当写入文档的时候,根据routing规则,将文档发送给特定Shard中建立索引。这样就能实现分布式了。 + +此外,Elasticsearch整体架构上采用了一主多副的方式: + + + +每个Index由多个Shard组成,每个Shard有一个主节点和多个副本节点,副本个数可配。但每次写入的时候,写入请求会先根据_routing规则选择发给哪个Shard,Index Request中可以设置使用哪个Filed的值作为路由参数,如果没有设置,则使用Mapping中的配置,如果mapping中也没有配置,则使用_id作为路由参数,然后通过_routing的Hash值选择出Shard(在OperationRouting类中),最后从集群的Meta中找出出该Shard的Primary节点。 + +请求接着会发送给Primary Shard,在Primary Shard上执行成功后,再从Primary Shard上将请求同时发送给多个Replica Shard,请求在多个Replica Shard上执行成功并返回给Primary Shard后,写入请求执行成功,返回结果给客户端。 + +这种模式下,写入操作的延时就等于latency = Latency(Primary Write) + Max(Replicas Write)。只要有副本在,写入延时最小也是两次单Shard的写入时延总和,写入效率会较低,但是这样的好处也很明显,避免写入后,单机或磁盘故障导致数据丢失,在数据重要性和性能方面,一般都是优先选择数据,除非一些允许丢数据的特殊场景。 + +采用多个副本后,避免了单机或磁盘故障发生时,对已经持久化后的数据造成损害,但是Elasticsearch里为了减少磁盘IO保证读写性能,一般是每隔一段时间(比如5分钟)才会把Lucene的Segment写入磁盘持久化,对于写入内存,但还未Flush到磁盘的Lucene数据,如果发生机器宕机或者掉电,那么内存中的数据也会丢失,这时候如何保证? + +对于这种问题,Elasticsearch学习了数据库中的处理方式:增加CommitLog模块,Elasticsearch中叫TransLog。 + + + +在每一个Shard中,写入流程分为两部分,先写入Lucene,再写入TransLog。 + +写入请求到达Shard后,先写Lucene文件,创建好索引,此时索引还在内存里面,接着去写TransLog,写完TransLog后,刷新TransLog数据到磁盘上,写磁盘成功后,请求返回给用户。这里有几个关键点: + + +一是和数据库不同,数据库是先写CommitLog,然后再写内存,而Elasticsearch是先写内存,最后才写TransLog,一种可能的原因是Lucene的内存写入会有很复杂的逻辑,很容易失败,比如分词,字段长度超过限制等,比较重,为了避免TransLog中有大量无效记录,减少recover的复杂度和提高速度,所以就把写Lucene放在了最前面。 +二是写Lucene内存后,并不是可被搜索的,需要通过Refresh把内存的对象转成完整的Segment后,然后再次reopen后才能被搜索,一般这个时间设置为1秒钟,导致写入Elasticsearch的文档,最快要1秒钟才可被从搜索到,所以Elasticsearch在搜索方面是NRT(Near Real Time)近实时的系统。 +三是当Elasticsearch作为NoSQL数据库时,查询方式是GetById,这种查询可以直接从TransLog中查询,这时候就成了RT(Real Time)实时系统。四是每隔一段比较长的时间,比如30分钟后,Lucene会把内存中生成的新Segment刷新到磁盘上,刷新后索引文件已经持久化了,历史的TransLog就没用了,会清空掉旧的TransLog。 + + +上面介绍了Elasticsearch在写入时的两个关键模块,Replica和TransLog,接下来,我们看一下Update流程: + + + +Lucene中不支持部分字段的Update,所以需要在Elasticsearch中实现该功能,具体流程如下: + + +收到Update请求后,从Segment或者TransLog中读取同id的完整Doc,记录版本号为V1。 +将版本V1的全量Doc和请求中的部分字段Doc合并为一个完整的Doc,同时更新内存中的VersionMap。获取到完整Doc后,Update请求就变成了Index请求。 加锁。 +再次从versionMap中读取该id的最大版本号V2,如果versionMap中没有,则从Segment或者TransLog中读取,这里基本都会从versionMap中获取到。 +检查版本是否冲突(V1==V2),如果冲突,则回退到开始的“Update doc”阶段,重新执行。如果不冲突,则执行最新的Add请求。 +在Index Doc阶段,首先将Version + 1得到V3,再将Doc加入到Lucene中去,Lucene中会先删同id下的已存在doc id,然后再增加新Doc。写入Lucene成功后,将当前V3更新到versionMap中。 +释放锁,部分更新的流程就结束了。 + + +介绍完部分更新的流程后,大家应该从整体架构上对Elasticsearch的写入有了一个初步的映象,接下来我们详细剖析下写入的详细步骤。 + +Elasticsearch写入请求类型 + + +Elasticsearch中的写入请求类型,主要包括下列几个:Index(Create),Update,Delete和Bulk,其中前3个是单文档操作,后一个Bulk是多文档操作,其中Bulk中可以包括Index(Create),Update和Delete。 + + +在6.0.0及其之后的版本中,前3个单文档操作的实现基本都和Bulk操作一致,甚至有些就是通过调用Bulk的接口实现的。估计接下来几个版本后,Index(Create),Update,Delete都会被当做Bulk的一种特例化操作被处理。这样,代码和逻辑都会更清晰一些。 + +下面,我们就以Bulk请求为例来介绍写入流程。 + + + + +红色:Client Node。 +绿色:Primary Node。 +蓝色:Replica Node。 + + +Client Node + + +Client Node 也包括了前面说过的Parse Request,这里就不再赘述了,接下来看一下其他的部分。 + + + +Ingest Pipeline + + +在这一步可以对原始文档做一些处理,比如HTML解析,自定义的处理,具体处理逻辑可以通过插件来实现。在Elasticsearch中,由于Ingest Pipeline会比较耗费CPU等资源,可以设置专门的Ingest Node,专门用来处理Ingest Pipeline逻辑。 + +如果当前Node不能执行Ingest Pipeline,则会将请求发给另一台可以执行Ingest Pipeline的Node。 + + +Auto Create Index + + +判断当前Index是否存在,如果不存在,则需要自动创建Index,这里需要和Master交互。也可以通过配置关闭自动创建Index的功能。 + + +Set Routing + + +设置路由条件,如果Request中指定了路由条件,则直接使用Request中的Routing,否则使用Mapping中配置的,如果Mapping中无配置,则使用默认的_id字段值。 + +在这一步中,如果没有指定id字段,则会自动生成一个唯一的_id字段,目前使用的是UUID。 + + +Construct BulkShardRequest + + +由于Bulk Request中会包括多个(Index/Update/Delete)请求,这些请求根据routing可能会落在多个Shard上执行,这一步会按Shard挑拣Single Write Request,同一个Shard中的请求聚集在一起,构建BulkShardRequest,每个BulkShardRequest对应一个Shard。 + + +Send Request To Primary + + +这一步会将每一个BulkShardRequest请求发送给相应Shard的Primary Node。 + +Primary Node + + +Primary 请求的入口是在PrimaryOperationTransportHandler的messageReceived,我们来看一下相关的逻辑流程。 + + + +Index or Update or Delete + + +循环执行每个Single Write Request,对于每个Request,根据操作类型(CREATE/INDEX/UPDATE/DELETE)选择不同的处理逻辑。 + +其中,Create/Index是直接新增Doc,Delete是直接根据_id删除Doc,Update会稍微复杂些,我们下面就以Update为例来介绍。 + + +Translate Update To Index or Delete + + +这一步是Update操作的特有步骤,在这里,会将Update请求转换为Index或者Delete请求。首先,会通过GetRequest查询到已经存在的同_id Doc(如果有)的完整字段和值(依赖_source字段),然后和请求中的Doc合并。同时,这里会获取到读到的Doc版本号,记做V1。 + + +Parse Doc + + +这里会解析Doc中各个字段。生成ParsedDocument对象,同时会生成uid Term。在Elasticsearch中,_uid = type # _id,对用户,_Id可见,而Elasticsearch中存储的是_uid。这一部分生成的ParsedDocument中也有Elasticsearch的系统字段,大部分会根据当前内容填充,部分未知的会在后面继续填充ParsedDocument。 + + +Update Mapping + + +Elasticsearch中有个自动更新Mapping的功能,就在这一步生效。会先挑选出Mapping中未包含的新Field,然后判断是否运行自动更新Mapping,如果允许,则更新Mapping。 + + +Get Sequence Id and Version + + +由于当前是Primary Shard,则会从SequenceNumber Service获取一个sequenceID和Version。SequenceID在Shard级别每次递增1,SequenceID在写入Doc成功后,会用来初始化LocalCheckpoint。Version则是根据当前Doc的最大Version递增1。 + + +Add Doc To Lucene + + +这一步开始的时候会给特定_uid加锁,然后判断该_uid对应的Version是否等于之前Translate Update To Index步骤里获取到的Version,如果不相等,则说明刚才读取Doc后,该Doc发生了变化,出现了版本冲突,这时候会抛出一个VersionConflict的异常,该异常会在Primary Node最开始处捕获,重新从“Translate Update To Index or Delete”开始执行。 + +如果Version相等,则继续执行,如果已经存在同id的Doc,则会调用Lucene的UpdateDocument(uid, doc)接口,先根据uid删除Doc,然后再Index新Doc。如果是首次写入,则直接调用Lucene的AddDocument接口完成Doc的Index,AddDocument也是通过UpdateDocument实现。 + +这一步中有个问题是,如何保证Delete-Then-Add的原子性,怎么避免中间状态时被Refresh?答案是在开始Delete之前,会加一个Refresh Lock,禁止被Refresh,只有等Add完后释放了Refresh Lock后才能被Refresh,这样就保证了Delete-Then-Add的原子性。 + +Lucene的UpdateDocument接口中就只是处理多个Field,会遍历每个Field逐个处理,处理顺序是invert index,store field,doc values,point dimension,后续会有文章专门介绍Lucene中的写入。 + + +Write Translog + + +写完Lucene的Segment后,会以keyvalue的形式写TransLog,Key是_id,Value是Doc内容。当查询的时候,如果请求是GetDocByID,则可以直接根据_id从TransLog中读取到,满足NoSQL场景下的实时性要去。 + +需要注意的是,这里只是写入到内存的TransLog,是否Sync到磁盘的逻辑还在后面。 + +这一步的最后,会标记当前SequenceID已经成功执行,接着会更新当前Shard的LocalCheckPoint。 + + +Renew Bulk Request + + +这里会重新构造Bulk Request,原因是前面已经将UpdateRequest翻译成了Index或Delete请求,则后续所有Replica中只需要执行Index或Delete请求就可以了,不需要再执行Update逻辑,一是保证Replica中逻辑更简单,性能更好,二是保证同一个请求在Primary和Replica中的执行结果一样。 + + +Flush Translog + + +这里会根据TransLog的策略,选择不同的执行方式,要么是立即Flush到磁盘,要么是等到以后再Flush。Flush的频率越高,可靠性越高,对写入性能影响越大。 + + +Send Requests To Replicas + + +这里会将刚才构造的新的Bulk Request并行发送给多个Replica,然后等待Replica的返回,这里需要等待所有Replica返回后(可能有成功,也有可能失败),Primary Node才会返回用户。如果某个Replica失败了,则Primary会给Master发送一个Remove Shard请求,要求Master将该Replica Shard从可用节点中移除。 + +这里,同时会将SequenceID,PrimaryTerm,GlobalCheckPoint等传递给Replica。 + +发送给Replica的请求中,Action Name等于原始ActionName + [R],这里的R表示Replica。通过这个[R]的不同,可以找到处理Replica请求的Handler。 + + +Receive Response From Replicas + + +Replica中请求都处理完后,会更新Primary Node的LocalCheckPoint。 + +Replica Node + + +Replica 请求的入口是在ReplicaOperationTransportHandler的messageReceived,我们来看一下相关的逻辑流程。 + + + +Index or Delete + + +根据请求类型是Index还是Delete,选择不同的执行逻辑。这里没有Update,是因为在Primary Node中已经将Update转换成了Index或Delete请求了。 + + +Parse Doc +Update Mapping + + +以上都和Primary Node中逻辑一致。 + + +Get Sequence Id and Version + + +Primary Node中会生成Sequence ID和Version,然后放入ReplicaRequest中,这里只需要从Request中获取到就行。 + + +Add Doc To Lucene + + +由于已经在Primary Node中将部分Update请求转换成了Index或Delete请求,这里只需要处理Index和Delete两种请求,不再需要处理Update请求了。比Primary Node会更简单一些。 + + +Write Translog +Flush Translog + + +以上都和Primary Node中逻辑一致。 + +最后 + +上面详细介绍了Elasticsearch的写入流程及其各个流程的工作机制,我们在这里再次总结下之前提出的分布式系统中的六大特性: + + +可靠性:由于Lucene的设计中不考虑可靠性,在Elasticsearch中通过Replica和TransLog两套机制保证数据的可靠性。 +一致性:Lucene中的Flush锁只保证Update接口里面Delete和Add中间不会Flush,但是Add完成后仍然有可能立即发生Flush,导致Segment可读。这样就没法保证Primary和所有其他Replica可以同一时间Flush,就会出现查询不稳定的情况,这里只能实现最终一致性。 +原子性:Add和Delete都是直接调用Lucene的接口,是原子的。当部分更新时,使用Version和锁保证更新是原子的。 +隔离性:仍然采用Version和局部锁来保证更新的是特定版本的数据。 +实时性:使用定期Refresh Segment到内存,并且Reopen Segment方式保证搜索可以在较短时间(比如1秒)内被搜索到。通过将未刷新到磁盘数据记入TransLog,保证对未提交数据可以通过ID实时访问到。 +性能:性能是一个系统性工程,所有环节都要考虑对性能的影响,在Elasticsearch中,在很多地方的设计都考虑到了性能,一是不需要所有Replica都返回后才能返回给用户,只需要返回特定数目的就行;二是生成的Segment现在内存中提供服务,等一段时间后才刷新到磁盘,Segment在内存这段时间的可靠性由TransLog保证;三是TransLog可以配置为周期性的Flush,但这个会给可靠性带来伤害;四是每个线程持有一个Segment,多线程时相互不影响,相互独立,性能更好;五是系统的写入流程对版本依赖较重,读取频率较高,因此采用了versionMap,减少热点数据的多次磁盘IO开销。Lucene中针对性能做了大量的优化。 + + +参考文档 + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-read.html + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-multi-doc.html + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/inside-a-shard.html + +https://zhuanlan.zhihu.com/p/34674517 + +https://zhuanlan.zhihu.com/p/34669354 + +https://www.cnblogs.com/yangwenbo214/p/9831479.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/16原理:ES原理之读取文档流程详解.md b/专栏/ElasticSearch知识体系详解/16原理:ES原理之读取文档流程详解.md new file mode 100644 index 0000000..d79c348 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/16原理:ES原理之读取文档流程详解.md @@ -0,0 +1,301 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 原理:ES原理之读取文档流程详解 + 文档查询步骤顺序 + + +先看下整体的查询流程 + + +单个文档 + +以下是从主分片或者副本分片检索文档的步骤顺序: + + + + +客户端向 Node 1 发送获取请求。 +节点使用文档的 _id 来确定文档属于分片 0 。分片 0 的副本分片存在于所有的三个节点上。 在这种情况下,它将请求转发到 Node 2 。 +Node 2 将文档返回给 Node 1 ,然后将文档返回给客户端。 + + +在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。 + +在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。 一旦索引请求成功返回给用户,文档在主分片和副本分片都是可用的。 + +多个文档 + +使用 mget 取回多个文档的步骤顺序: + + + +以下是使用单个 mget 请求取回多个文档所需的步骤顺序: + + +客户端向 Node 1 发送 mget 请求。 +Node 1 为每个分片构建多文档获取请求,然后并行转发这些请求到托管在每个所需的主分片或者副本分片的节点上。一旦收到所有答复, Node 1 构建响应并将其返回给客户端。 + + +文档读取过程详解 + + +所有的搜索系统一般都是两阶段查询,第一阶段查询到匹配的DocID,第二阶段再查询DocID对应的完整文档,这种在Elasticsearch中称为query_then_fetch。(这里主要介绍最常用的2阶段查询,其它方式可以参考这里 )。 + + + + + +在初始查询阶段时,查询会广播到索引中每一个分片拷贝(主分片或者副本分片)。 每个分片在本地执行搜索并构建一个匹配文档的大小为 from + size 的优先队列。PS:在2. 搜索的时候是会查询Filesystem Cache的,但是有部分数据还在Memory Buffer,所以搜索是近实时的。 +每个分片返回各自优先队列中 所有文档的 ID 和排序值 给协调节点,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。 +接下来就是 取回阶段,协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。每个分片加载并丰富文档,如果有需要的话,接着返回文档给协调节点。一旦所有的文档都被取回了,协调节点返回结果给客户端。 + + +深入ElasticSearch读取文档的实现机制 + +TIP + +作为选读内容。 + +读操作 + +一致性指的是写入成功后,下次读操作一定要能读取到最新的数据。对于搜索,这个要求会低一些,可以有一些延迟。但是对于NoSQL数据库,则一般要求最好是强一致性的。 + +结果匹配上,NoSQL作为数据库,查询过程中只有符合不符合两种情况,而搜索里面还有是否相关,类似于NoSQL的结果只能是0或1,而搜索里面可能会有0.1,0.5,0.9等部分匹配或者更相关的情况。 + +结果召回上,搜索一般只需要召回最满足条件的Top N结果即可,而NoSQL一般都需要返回满足条件的所有结果。 + +搜索系统一般都是两阶段查询,第一个阶段查询到对应的Doc ID,也就是PK;第二阶段再通过Doc ID去查询完整文档,而NoSQL数据库一般是一阶段就返回结果。在Elasticsearch中两种都支持。 + +目前NoSQL的查询,聚合、分析和统计等功能上都是要比搜索弱的。 + +Lucene的读 + +Elasticsearch使用了Lucene作为搜索引擎库,通过Lucene完成特定字段的搜索等功能,在Lucene中这个功能是通过IndexSearcher的下列接口实现的: + +public TopDocs search(Query query, int n); +public Document doc(int docID); +public int count(Query query); +......(其他) + + +第一个search接口实现搜索功能,返回最满足Query的N个结果;第二个doc接口通过doc id查询Doc内容;第三个count接口通过Query获取到命中数。 + +这三个功能是搜索中的最基本的三个功能点,对于大部分Elasticsearch中的查询都是比较复杂的,直接用这个接口是无法满足需求的,比如分布式问题。这些问题都留给了Elasticsearch解决,我们接下来看Elasticsearch中相关读功能的剖析。 + +Elasticsearch的读 + +Elasticsearch中每个Shard都会有多个Replica,主要是为了保证数据可靠性,除此之外,还可以增加读能力,因为写的时候虽然要写大部分Replica Shard,但是查询的时候只需要查询Primary和Replica中的任何一个就可以了。 + + + +在上图中,该Shard有1个Primary和2个Replica Node,当查询的时候,从三个节点中根据Request中的preference参数选择一个节点查询。preference可以设置_local,_primary,_replica以及其他选项。如果选择了primary,则每次查询都是直接查询Primary,可以保证每次查询都是最新的。如果设置了其他参数,那么可能会查询到R1或者R2,这时候就有可能查询不到最新的数据。 + +PS: 上述代码逻辑在OperationRouting.Java的searchShards方法中。 + +接下来看一下,Elasticsearch中的查询是如何支持分布式的。 + + + +Elasticsearch中通过分区实现分布式,数据写入的时候根据_routing规则将数据写入某一个Shard中,这样就能将海量数据分布在多个Shard以及多台机器上,已达到分布式的目标。这样就导致了查询的时候,潜在数据会在当前index的所有的Shard中,所以Elasticsearch查询的时候需要查询所有Shard,同一个Shard的Primary和Replica选择一个即可,查询请求会分发给所有Shard,每个Shard中都是一个独立的查询引擎,比如需要返回Top 10的结果,那么每个Shard都会查询并且返回Top 10的结果,然后在Client Node里面会接收所有Shard的结果,然后通过优先级队列二次排序,选择出Top 10的结果返回给用户。 + +这里有一个问题就是请求膨胀,用户的一个搜索请求在Elasticsearch内部会变成Shard个请求,这里有个优化点,虽然是Shard个请求,但是这个Shard个数不一定要是当前Index中的Shard个数,只要是当前查询相关的Shard即可,这个需要基于业务和请求内容优化,通过这种方式可以优化请求膨胀数。 + +Elasticsearch中的查询主要分为两类,Get请求:通过ID查询特定Doc;Search请求:通过Query查询匹配Doc。 + + + +PS:上图中内存中的Segment是指刚Refresh Segment,但是还没持久化到磁盘的新Segment,而非从磁盘加载到内存中的Segment。 + +对于Search类请求,查询的时候是一起查询内存和磁盘上的Segment,最后将结果合并后返回。这种查询是近实时(Near Real Time)的,主要是由于内存中的Index数据需要一段时间后才会刷新为Segment。 + +对于Get类请求,查询的时候是先查询内存中的TransLog,如果找到就立即返回,如果没找到再查询磁盘上的TransLog,如果还没有则再去查询磁盘上的Segment。这种查询是实时(Real Time)的。这种查询顺序可以保证查询到的Doc是最新版本的Doc,这个功能也是为了保证NoSQL场景下的实时性要求。 + + + +所有的搜索系统一般都是两阶段查询,第一阶段查询到匹配的DocID,第二阶段再查询DocID对应的完整文档,这种在Elasticsearch中称为query_then_fetch,还有一种是一阶段查询的时候就返回完整Doc,在Elasticsearch中称作query_and_fetch,一般第二种适用于只需要查询一个Shard的请求。 + +除了一阶段,两阶段外,还有一种三阶段查询的情况。搜索里面有一种算分逻辑是根据TF(Term Frequency)和DF(Document Frequency)计算基础分,但是Elasticsearch中查询的时候,是在每个Shard中独立查询的,每个Shard中的TF和DF也是独立的,虽然在写入的时候通过_routing保证Doc分布均匀,但是没法保证TF和DF均匀,那么就有会导致局部的TF和DF不准的情况出现,这个时候基于TF、DF的算分就不准。为了解决这个问题,Elasticsearch中引入了DFS查询,比如DFS_query_then_fetch,会先收集所有Shard中的TF和DF值,然后将这些值带入请求中,再次执行query_then_fetch,这样算分的时候TF和DF就是准确的,类似的有DFS_query_and_fetch。这种查询的优势是算分更加精准,但是效率会变差。另一种选择是用BM25代替TF/DF模型。 + +在新版本Elasticsearch中,用户没法指定DFS_query_and_fetch和query_and_fetch,这两种只能被Elasticsearch系统改写。 + +Elasticsearch查询流程 + +Elasticsearch中的大部分查询,以及核心功能都是Search类型查询,上面我们了解到查询分为一阶段,二阶段和三阶段,这里我们就以最常见的的二阶段查询为例来介绍查询流程。 + + + +Client Node + + +Client Node 也包括了前面说过的Parse Request,这里就不再赘述了,接下来看一下其他的部分。 + + + +Get Remove Cluster Shard + + +判断是否需要跨集群访问,如果需要,则获取到要访问的Shard列表。 + + +Get Search Shard Iterator + + +获取当前Cluster中要访问的Shard,和上一步中的Remove Cluster Shard合并,构建出最终要访问的完整Shard列表。 + +这一步中,会根据Request请求中的参数从Primary Node和多个Replica Node中选择出一个要访问的Shard。 + + +For Every Shard:Perform + + +遍历每个Shard,对每个Shard执行后面逻辑。 + + +Send Request To Query Shard + + +将查询阶段请求发送给相应的Shard。 + + +Merge Docs + + +上一步将请求发送给多个Shard后,这一步就是异步等待返回结果,然后对结果合并。这里的合并策略是维护一个Top N大小的优先级队列,每当收到一个shard的返回,就把结果放入优先级队列做一次排序,直到所有的Shard都返回。 + +翻页逻辑也是在这里,如果需要取Top 30~ Top 40的结果,这个的意思是所有Shard查询结果中的第30到40的结果,那么在每个Shard中无法确定最终的结果,每个Shard需要返回Top 40的结果给Client Node,然后Client Node中在merge docs的时候,计算出Top 40的结果,最后再去除掉Top 30,剩余的10个结果就是需要的Top 30~ Top 40的结果。 + +上述翻页逻辑有一个明显的缺点就是每次Shard返回的数据中包括了已经翻过的历史结果,如果翻页很深,则在这里需要排序的Docs会很多,比如Shard有1000,取第9990到10000的结果,那么这次查询,Shard总共需要返回1000 * 10000,也就是一千万Doc,这种情况很容易导致OOM。 + +另一种翻页方式是使用search_after,这种方式会更轻量级,如果每次只需要返回10条结构,则每个Shard只需要返回search_after之后的10个结果即可,返回的总数据量只是和Shard个数以及本次需要的个数有关,和历史已读取的个数无关。这种方式更安全一些,推荐使用这种。 + +如果有aggregate,也会在这里做聚合,但是不同的aggregate类型的merge策略不一样,具体的可以在后面的aggregate文章中再介绍。 + + +Send Request To Fetch Shard + + +选出Top N个Doc ID后发送给这些Doc ID所在的Shard执行Fetch Phase,最后会返回Top N的Doc的内容。 + +Query Phase + + +接下来我们看第一阶段查询的步骤: + + + +Create Search Context + + +创建Search Context,之后Search过程中的所有中间状态都会存在Context中,这些状态总共有50多个,具体可以查看DefaultSearchContext或者其他SearchContext的子类。 + + +Parse Query + + +解析Query的Source,将结果存入Search Context。这里会根据请求中Query类型的不同创建不同的Query对象,比如TermQuery、FuzzyQuery等,最终真正执行TermQuery、FuzzyQuery等语义的地方是在Lucene中。 + +这里包括了dfsPhase、queryPhase和fetchPhase三个阶段的preProcess部分,只有queryPhase的preProcess中有执行逻辑,其他两个都是空逻辑,执行完preProcess后,所有需要的参数都会设置完成。 + +由于Elasticsearch中有些请求之间是相互关联的,并非独立的,比如scroll请求,所以这里同时会设置Context的生命周期。 + +同时会设置lowLevelCancellation是否打开,这个参数是集群级别配置,同时也能动态开关,打开后会在后面执行时做更多的检测,检测是否需要停止后续逻辑直接返回。 + + +Get From Cache + + +判断请求是否允许被Cache,如果允许,则检查Cache中是否已经有结果,如果有则直接读取Cache,如果没有则继续执行后续步骤,执行完后,再将结果加入Cache。 + + +Add Collectors + + +Collector主要目标是收集查询结果,实现排序,对自定义结果集过滤和收集等。这一步会增加多个Collectors,多个Collector组成一个List。 + + +FilteredCollector:先判断请求中是否有Post Filter,Post Filter用于Search,Agg等结束后再次对结果做Filter,希望Filter不影响Agg结果。如果有Post Filter则创建一个FilteredCollector,加入Collector List中。 +PluginInMultiCollector:判断请求中是否制定了自定义的一些Collector,如果有,则创建后加入Collector List。 +MinimumScoreCollector:判断请求中是否制定了最小分数阈值,如果指定了,则创建MinimumScoreCollector加入Collector List中,在后续收集结果时,会过滤掉得分小于最小分数的Doc。 +EarlyTerminatingCollector:判断请求中是否提前结束Doc的Seek,如果是则创建EarlyTerminatingCollector,加入Collector List中。在后续Seek和收集Doc的过程中,当Seek的Doc数达到Early Terminating后会停止Seek后续倒排链。 +CancellableCollector:判断当前操作是否可以被中断结束,比如是否已经超时等,如果是会抛出一个TaskCancelledException异常。该功能一般用来提前结束较长的查询请求,可以用来保护系统。 +EarlyTerminatingSortingCollector:如果Index是排序的,那么可以提前结束对倒排链的Seek,相当于在一个排序递减链表上返回最大的N个值,只需要直接返回前N个值就可以了。这个Collector会加到Collector List的头部。EarlyTerminatingSorting和EarlyTerminating的区别是,EarlyTerminatingSorting是一种对结果无损伤的优化,而EarlyTerminating是有损的,人为掐断执行的优化。 +TopDocsCollector:这个是最核心的Top N结果选择器,会加入到Collector List的头部。TopScoreDocCollector和TopFieldCollector都是TopDocsCollector的子类,TopScoreDocCollector会按照固定的方式算分,排序会按照分数+doc id的方式排列,如果多个doc的分数一样,先选择doc id小的文档。而TopFieldCollector则是根据用户指定的Field的值排序。 + + + +lucene::search + + +这一步会调用Lucene中IndexSearch的search接口,执行真正的搜索逻辑。每个Shard中会有多个Segment,每个Segment对应一个LeafReaderContext,这里会遍历每个Segment,到每个Segment中去Search结果,然后计算分数。 + +搜索里面一般有两阶段算分,第一阶段是在这里算的,会对每个Seek到的Doc都计算分数,为了减少CPU消耗,一般是算一个基本分数。这一阶段完成后,会有个排序。然后在第二阶段,再对Top 的结果做一次二阶段算分,在二阶段算分的时候会考虑更多的因子。二阶段算分在后续操作中。 + +具体请求,比如TermQuery、WildcardQuery的查询逻辑都在Lucene中,后面会有专门文章介绍。 + + +rescore + + +根据Request中是否包含rescore配置决定是否进行二阶段排序,如果有则执行二阶段算分逻辑,会考虑更多的算分因子。二阶段算分也是一种计算机中常见的多层设计,是一种资源消耗和效率的折中。 + +Elasticsearch中支持配置多个Rescore,这些rescore逻辑会顺序遍历执行。每个rescore内部会先按照请求参数window选择出Top window的doc,然后对这些doc排序,排完后再合并回原有的Top 结果顺序中。 + + +suggest::execute() + + +如果有推荐请求,则在这里执行推荐请求。如果请求中只包含了推荐的部分,则很多地方可以优化。推荐不是今天的重点,这里就不介绍了,后面有机会再介绍。 + + +aggregation::execute() + + +如果含有聚合统计请求,则在这里执行。Elasticsearch中的aggregate的处理逻辑也类似于Search,通过多个Collector来实现。在Client Node中也需要对aggregation做合并。aggregate逻辑更复杂一些,就不在这里赘述了,后面有需要就再单独开文章介绍。 + +上述逻辑都执行完成后,如果当前查询请求只需要查询一个Shard,那么会直接在当前Node执行Fetch Phase。 + +Fetch Phase + +Elasticsearch作为搜索系统时,或者任何搜索系统中,除了Query阶段外,还会有一个Fetch阶段,这个Fetch阶段在数据库类系统中是没有的,是搜索系统中额外增加的阶段。搜索系统中额外增加Fetch阶段的原因是搜索系统中数据分布导致的,在搜索中,数据通过routing分Shard的时候,只能根据一个主字段值来决定,但是查询的时候可能会根据其他非主字段查询,那么这个时候所有Shard中都可能会存在相同非主字段值的Doc,所以需要查询所有Shard才能不会出现结果遗漏。同时如果查询主字段,那么这个时候就能直接定位到Shard,就只需要查询特定Shard即可,这个时候就类似于数据库系统了。另外,数据库中的二级索引又是另外一种情况,但类似于查主字段的情况,这里就不多说了。 + +基于上述原因,第一阶段查询的时候并不知道最终结果会在哪个Shard上,所以每个Shard中管都需要查询完整结果,比如需要Top 10,那么每个Shard都需要查询当前Shard的所有数据,找出当前Shard的Top 10,然后返回给Client Node。如果有100个Shard,那么就需要返回100 * 10 = 1000个结果,而Fetch Doc内容的操作比较耗费IO和CPU,如果在第一阶段就Fetch Doc,那么这个资源开销就会非常大。所以,一般是当Client Node选择出最终Top N的结果后,再对最终的Top N读取Doc内容。通过增加一点网络开销而避免大量IO和CPU操作,这个折中是非常划算的。 + +Fetch阶段的目的是通过DocID获取到用户需要的完整Doc内容。这些内容包括了DocValues,Store,Source,Script和Highlight等,具体的功能点是在SearchModule中注册的,系统默认注册的有: + + +ExplainFetchSubPhase +DocValueFieldsFetchSubPhase +ScriptFieldsFetchSubPhase +FetchSourceSubPhase +VersionFetchSubPhase +MatchedQueriesFetchSubPhase +HighlightPhase +ParentFieldSubFetchPhase + + +除了系统默认的8种外,还有通过插件的形式注册自定义的功能,这些SubPhase中最重要的是Source和Highlight,Source是加载原文,Highlight是计算高亮显示的内容片断。 + +上述多个SubPhase会针对每个Doc顺序执行,可能会产生多次的随机IO,这里会有一些优化方案,但是都是针对特定场景的,不具有通用性。 + +Fetch Phase执行完后,整个查询流程就结束了。 + +参考文档 + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-read.html + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/distrib-multi-doc.html + +https://www.elastic.co/guide/cn/elasticsearch/guide/current/inside-a-shard.html + +https://zhuanlan.zhihu.com/p/34674517 + +https://zhuanlan.zhihu.com/p/34669354 + +https://www.cnblogs.com/yangwenbo214/p/9831479.html + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/17优化:ElasticSearch性能优化详解.md b/专栏/ElasticSearch知识体系详解/17优化:ElasticSearch性能优化详解.md new file mode 100644 index 0000000..c2cef28 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/17优化:ElasticSearch性能优化详解.md @@ -0,0 +1,446 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 优化:ElasticSearch性能优化详解 + 硬件配置优化 + + +升级硬件设备配置一直都是提高服务能力最快速有效的手段,在系统层面能够影响应用性能的一般包括三个因素:CPU、内存和 IO,可以从这三方面进行 ES 的性能优化工作。 + + +CPU 配置 + +一般说来,CPU 繁忙的原因有以下几个: + + +线程中有无限空循环、无阻塞、正则匹配或者单纯的计算; +发生了频繁的 GC; +多线程的上下文切换; + + +大多数 Elasticsearch 部署往往对 CPU 要求不高。因此,相对其它资源,具体配置多少个(CPU)不是那么关键。你应该选择具有多个内核的现代处理器,常见的集群使用 2 到 8 个核的机器。如果你要在更快的 CPUs 和更多的核数之间选择,选择更多的核数更好。多个内核提供的额外并发远胜过稍微快一点点的时钟频率。 + +内存配置 + +如果有一种资源是最先被耗尽的,它可能是内存。排序和聚合都很耗内存,所以有足够的堆空间来应付它们是很重要的。即使堆空间是比较小的时候,也能为操作系统文件缓存提供额外的内存。因为 Lucene 使用的许多数据结构是基于磁盘的格式,Elasticsearch 利用操作系统缓存能产生很大效果。 + +64 GB 内存的机器是非常理想的,但是 32 GB 和 16 GB 机器也是很常见的。少于8 GB 会适得其反(你最终需要很多很多的小机器),大于 64 GB 的机器也会有问题。 + +由于 ES 构建基于 lucene,而 lucene 设计强大之处在于 lucene 能够很好的利用操作系统内存来缓存索引数据,以提供快速的查询性能。lucene 的索引文件 segements 是存储在单文件中的,并且不可变,对于 OS 来说,能够很友好地将索引文件保持在 cache 中,以便快速访问;因此,我们很有必要将一半的物理内存留给 lucene;另一半的物理内存留给 ES(JVM heap)。 + +内存分配 + +当机器内存小于 64G 时,遵循通用的原则,50% 给 ES,50% 留给 lucene。 + +当机器内存大于 64G 时,遵循以下原则: + + +如果主要的使用场景是全文检索,那么建议给 ES Heap 分配 4~32G 的内存即可;其它内存留给操作系统,供 lucene 使用(segments cache),以提供更快的查询性能。 +如果主要的使用场景是聚合或排序,并且大多数是 numerics,dates,geo_points 以及 not_analyzed 的字符类型,建议分配给 ES Heap 分配 4~32G 的内存即可,其它内存留给操作系统,供 lucene 使用,提供快速的基于文档的聚类、排序性能。 +如果使用场景是聚合或排序,并且都是基于 analyzed 字符数据,这时需要更多的 heap size,建议机器上运行多 ES 实例,每个实例保持不超过 50% 的 ES heap 设置(但不超过 32 G,堆内存设置 32 G 以下时,JVM 使用对象指标压缩技巧节省空间),50% 以上留给 lucene。 + + +禁止 swap + +禁止 swap,一旦允许内存与磁盘的交换,会引起致命的性能问题。可以通过在 elasticsearch.yml 中 bootstrap.memory_lock: true,以保持 JVM 锁定内存,保证 ES 的性能。 + +GC 设置 + +老的版本中官方文档 中推荐默认设置为:Concurrent-Mark and Sweep(CMS),给的理由是当时G1 还有很多 BUG。 + +原因是:已知JDK 8附带的HotSpot JVM的早期版本存在一些问题,当启用G1GC收集器时,这些问题可能导致索引损坏。受影响的版本早于JDK 8u40随附的HotSpot版本。来源于官方说明 + +实际上如果你使用的JDK8较高版本,或者JDK9+,我推荐你使用G1 GC; 因为我们目前的项目使用的就是G1 GC,运行效果良好,对Heap大对象优化尤为明显。修改jvm.options文件,将下面几行: + +-XX:+UseConcMarkSweepGC +-XX:CMSInitiatingOccupancyFraction=75 +-XX:+UseCMSInitiatingOccupancyOnly + + +更改为 + +-XX:+UseG1GC +-XX:MaxGCPauseMillis=50 + + +其中 -XX:MaxGCPauseMillis是控制预期的最高GC时长,默认值为200ms,如果线上业务特性对于GC停顿非常敏感,可以适当设置低一些。但是 这个值如果设置过小,可能会带来比较高的cpu消耗。 + +G1对于集群正常运作的情况下减轻G1停顿对服务时延的影响还是很有效的,但是如果是你描述的GC导致集群卡死,那么很有可能换G1也无法根本上解决问题。 通常都是集群的数据模型或者Query需要优化。 + +磁盘 + +硬盘对所有的集群都很重要,对大量写入的集群更是加倍重要(例如那些存储日志数据的)。硬盘是服务器上最慢的子系统,这意味着那些写入量很大的集群很容易让硬盘饱和,使得它成为集群的瓶颈。 + +在经济压力能承受的范围下,尽量使用固态硬盘(SSD)。固态硬盘相比于任何旋转介质(机械硬盘,磁带等),无论随机写还是顺序写,都会对 IO 有较大的提升。 + + + +如果你正在使用 SSDs,确保你的系统 I/O 调度程序是配置正确的。当你向硬盘写数据,I/O 调度程序决定何时把数据实际发送到硬盘。大多数默认 *nix 发行版下的调度程序都叫做 cfq(完全公平队列)。 +调度程序分配时间片到每个进程。并且优化这些到硬盘的众多队列的传递。但它是为旋转介质优化的:机械硬盘的固有特性意味着它写入数据到基于物理布局的硬盘会更高效。 +这对 SSD 来说是低效的,尽管这里没有涉及到机械硬盘。但是,deadline 或者 noop 应该被使用。deadline 调度程序基于写入等待时间进行优化,noop 只是一个简单的 FIFO 队列。 + + + +这个简单的更改可以带来显著的影响。仅仅是使用正确的调度程序,我们看到了 500 倍的写入能力提升。 + +如果你使用旋转介质(如机械硬盘),尝试获取尽可能快的硬盘(高性能服务器硬盘,15k RPM 驱动器)。 + +使用 RAID0 是提高硬盘速度的有效途径,对机械硬盘和 SSD 来说都是如此。没有必要使用镜像或其它 RAID 变体,因为 Elasticsearch 在自身层面通过副本,已经提供了备份的功能,所以不需要利用磁盘的备份功能,同时如果使用磁盘备份功能的话,对写入速度有较大的影响。 + +最后,避免使用网络附加存储(NAS)。人们常声称他们的 NAS 解决方案比本地驱动器更快更可靠。除却这些声称,我们从没看到 NAS 能配得上它的大肆宣传。NAS 常常很慢,显露出更大的延时和更宽的平均延时方差,而且它是单点故障的。 + +索引优化设置 + + +索引优化主要是在 Elasticsearch 的插入层面优化,Elasticsearch 本身索引速度其实还是蛮快的,具体数据,我们可以参考官方的 benchmark 数据。我们可以根据不同的需求,针对索引优化。 + + +批量提交 + +当有大量数据提交的时候,建议采用批量提交(Bulk 操作);此外使用 bulk 请求时,每个请求不超过几十M,因为太大会导致内存使用过大。 + +比如在做 ELK 过程中,Logstash indexer 提交数据到 Elasticsearch 中,batch size 就可以作为一个优化功能点。但是优化 size 大小需要根据文档大小和服务器性能而定。 + +像 Logstash 中提交文档大小超过 20MB,Logstash 会将一个批量请求切分为多个批量请求。 + +如果在提交过程中,遇到 EsRejectedExecutionException 异常的话,则说明集群的索引性能已经达到极限了。这种情况,要么提高服务器集群的资源,要么根据业务规则,减少数据收集速度,比如只收集 Warn、Error 级别以上的日志。 + +增加 Refresh 时间间隔 + +为了提高索引性能,Elasticsearch 在写入数据的时候,采用延迟写入的策略,即数据先写到内存中,当超过默认1秒(index.refresh_interval)会进行一次写入操作,就是将内存中 segment 数据刷新到磁盘中,此时我们才能将数据搜索出来,所以这就是为什么 Elasticsearch 提供的是近实时搜索功能,而不是实时搜索功能。 + +如果我们的系统对数据延迟要求不高的话,我们可以通过延长 refresh 时间间隔,可以有效地减少 segment 合并压力,提高索引速度。比如在做全链路跟踪的过程中,我们就将 index.refresh_interval 设置为30s,减少 refresh 次数。再如,在进行全量索引时,可以将 refresh 次数临时关闭,即 index.refresh_interval 设置为-1,数据导入成功后再打开到正常模式,比如30s。 + + +在加载大量数据时候可以暂时不用 refresh 和 repliccas,index.refresh_interval 设置为-1,index.number_of_replicas 设置为0。 + + +相关原理,请参考[原理:ES原理之索引文档流程详解] + +修改 index_buffer_size 的设置 + +索引缓冲的设置可以控制多少内存分配给索引进程。这是一个全局配置,会应用于一个节点上所有不同的分片上。 + +indices.memory.index_buffer_size: 10% +indices.memory.min_index_buffer_size: 48mb + + +indices.memory.index_buffer_size 接受一个百分比或者一个表示字节大小的值。默认是10%,意味着分配给节点的总内存的10%用来做索引缓冲的大小。这个数值被分到不同的分片(shards)上。如果设置的是百分比,还可以设置 min_index_buffer_size (默认 48mb)和 max_index_buffer_size(默认没有上限)。 + +修改 translog 相关的设置 + +一是控制数据从内存到硬盘的操作频率,以减少硬盘 IO。可将 sync_interval 的时间设置大一些。默认为5s。 + +index.translog.sync_interval: 5s + + +也可以控制 tranlog 数据块的大小,达到 threshold 大小时,才会 flush 到 lucene 索引文件。默认为512m。 + +index.translog.flush_threshold_size: 512mb + + +translog我们在[原理:ES原理之索引文档流程详解]也有介绍。 + +注意 _id 字段的使用 + +_id 字段的使用,应尽可能避免自定义 _id,以避免针对 ID 的版本管理;建议使用 ES 的默认 ID 生成策略或使用数字类型 ID 做为主键。 + +注意 _all 字段及 _source 字段的使用 + +_all 字段及 _source 字段的使用,应该注意场景和需要,_all 字段包含了所有的索引字段,方便做全文检索,如果无此需求,可以禁用;_source 存储了原始的 document 内容,如果没有获取原始文档数据的需求,可通过设置 includes、excludes 属性来定义放入 _source 的字段。 + +合理的配置使用 index 属性 + +合理的配置使用 index 属性,analyzed 和 not_analyzed,根据业务需求来控制字段是否分词或不分词。只有 groupby 需求的字段,配置时就设置成 not_analyzed,以提高查询或聚类的效率。 + +减少副本数量 + +Elasticsearch 默认副本数量为3个,虽然这样会提高集群的可用性,增加搜索的并发数,但是同时也会影响写入索引的效率。 + +在索引过程中,需要把更新的文档发到副本节点上,等副本节点生效后在进行返回结束。使用 Elasticsearch 做业务搜索的时候,建议副本数目还是设置为3个,但是像内部 ELK 日志系统、分布式跟踪系统中,完全可以将副本数目设置为1个。 + +查询方面优化 + + +Elasticsearch 作为业务搜索的近实时查询时,查询效率的优化显得尤为重要。 + + +路由优化 + +当我们查询文档的时候,Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?它其实是通过下面这个公式来计算出来的。 + +shard = hash(routing) % number_of_primary_shards + + +routing 默认值是文档的 id,也可以采用自定义值,比如用户 ID。 + +不带 routing 查询 + +在查询的时候因为不知道要查询的数据具体在哪个分片上,所以整个过程分为2个步骤: + + +分发:请求到达协调节点后,协调节点将查询请求分发到每个分片上。 +聚合:协调节点搜集到每个分片上查询结果,再将查询的结果进行排序,之后给用户返回结果。 + + +带 routing 查询 + +查询的时候,可以直接根据 routing 信息定位到某个分配查询,不需要查询所有的分配,经过协调节点排序。 + +向上面自定义的用户查询,如果 routing 设置为 userid 的话,就可以直接查询出数据来,效率提升很多。 + +Filter VS Query + +尽可能使用过滤器上下文(Filter)替代查询上下文(Query) + + +Query:此文档与此查询子句的匹配程度如何? +Filter:此文档和查询子句匹配吗? + + +Elasticsearch 针对 Filter 查询只需要回答「是」或者「否」,不需要像 Query 查询一样计算相关性分数,同时Filter结果可以缓存。 + +深度翻页 + +在使用 Elasticsearch 过程中,应尽量避免大翻页的出现。 + +正常翻页查询都是从 from 开始 size 条数据,这样就需要在每个分片中查询打分排名在前面的 from+size 条数据。协同节点收集每个分配的前 from+size 条数据。协同节点一共会受到 N*(from+size) 条数据,然后进行排序,再将其中 from 到 from+size 条数据返回出去。如果 from 或者 size 很大的话,导致参加排序的数量会同步扩大很多,最终会导致 CPU 资源消耗增大。 + +可以通过使用 Elasticsearch scroll 和 scroll-scan 高效滚动的方式来解决这样的问题。 + +也可以结合实际业务特点,文档 id 大小如果和文档创建时间是一致有序的,可以以文档 id 作为分页的偏移量,并将其作为分页查询的一个条件。 + +脚本(script)合理使用 + +我们知道脚本使用主要有 3 种形式,内联动态编译方式、_script 索引库中存储和文件脚本存储的形式;一般脚本的使用场景是粗排,尽量用第二种方式先将脚本存储在 _script 索引库中,起到提前编译,然后通过引用脚本 id,并结合 params 参数使用,即可以达到模型(逻辑)和数据进行了分离,同时又便于脚本模块的扩展与维护。 + +Cache的设置及使用 + + +QueryCache: ES查询的时候,使用filter查询会使用query cache, 如果业务场景中的过滤查询比较多,建议将querycache设置大一些,以提高查询速度。 + + +indices.queries.cache.size: 10%(默认),可设置成百分比,也可设置成具体值,如256mb。 + +当然也可以禁用查询缓存(默认是开启), 通过index.queries.cache.enabled:false设置。 + + +FieldDataCache: 在聚类或排序时,field data cache会使用频繁,因此,设置字段数据缓存的大小,在聚类或排序场景较多的情形下很有必要,可通过indices.fielddata.cache.size:30% 或具体值10GB来设置。但是如果场景或数据变更比较频繁,设置cache并不是好的做法,因为缓存加载的开销也是特别大的。 +ShardRequestCache: 查询请求发起后,每个分片会将结果返回给协调节点(Coordinating Node), 由协调节点将结果整合。 如果有需求,可以设置开启; 通过设置index.requests.cache.enable: true来开启。 不过,shard request cache只缓存hits.total, aggregations, suggestions类型的数据,并不会缓存hits的内容。也可以通过设置indices.requests.cache.size: 1%(默认)来控制缓存空间大小。 + + +更多查询优化经验 + + +query_string 或 multi_match的查询字段越多, 查询越慢。可以在mapping阶段,利用copy_to属性将多字段的值索引到一个新字段,multi_match时,用新的字段查询。 +日期字段的查询, 尤其是用now 的查询实际上是不存在缓存的,因此, 可以从业务的角度来考虑是否一定要用now, 毕竟利用query cache 是能够大大提高查询效率的。 +查询结果集的大小不能随意设置成大得离谱的值, 如query.setSize不能设置成 Integer.MAX_VALUE, 因为ES内部需要建立一个数据结构来放指定大小的结果集数据。 +避免层级过深的聚合查询, 层级过深的aggregation , 会导致内存、CPU消耗,建议在服务层通过程序来组装业务,也可以通过pipeline的方式来优化。 +复用预索引数据方式来提高AGG性能: + + +如通过 terms aggregations 替代 range aggregations, 如要根据年龄来分组,分组目标是: 少年(14岁以下) 青年(14-28) 中年(29-50) 老年(51以上), 可以在索引的时候设置一个age_group字段,预先将数据进行分类。从而不用按age来做range aggregations, 通过age_group字段就可以了。 + +通过开启慢查询配置定位慢查询 + +不论是数据库还是搜索引擎,对于问题的排查,开启慢查询日志是十分必要的,ES 开启慢查询的方式有多种,但是最常用的是调用模板 API 进行全局设置: + +PUT /_template/{TEMPLATE_NAME} +{ + + "template":"{INDEX_PATTERN}", + "settings" : { + "index.indexing.slowlog.level": "INFO", + "index.indexing.slowlog.threshold.index.warn": "10s", + "index.indexing.slowlog.threshold.index.info": "5s", + "index.indexing.slowlog.threshold.index.debug": "2s", + "index.indexing.slowlog.threshold.index.trace": "500ms", + "index.indexing.slowlog.source": "1000", + "index.search.slowlog.level": "INFO", + "index.search.slowlog.threshold.query.warn": "10s", + "index.search.slowlog.threshold.query.info": "5s", + "index.search.slowlog.threshold.query.debug": "2s", + "index.search.slowlog.threshold.query.trace": "500ms", + "index.search.slowlog.threshold.fetch.warn": "1s", + "index.search.slowlog.threshold.fetch.info": "800ms", + "index.search.slowlog.threshold.fetch.debug": "500ms", + "index.search.slowlog.threshold.fetch.trace": "200ms" + }, + "version" : 1 +} + +PUT {INDEX_PAATERN}/_settings +{ + "index.indexing.slowlog.level": "INFO", + "index.indexing.slowlog.threshold.index.warn": "10s", + "index.indexing.slowlog.threshold.index.info": "5s", + "index.indexing.slowlog.threshold.index.debug": "2s", + "index.indexing.slowlog.threshold.index.trace": "500ms", + "index.indexing.slowlog.source": "1000", + "index.search.slowlog.level": "INFO", + "index.search.slowlog.threshold.query.warn": "10s", + "index.search.slowlog.threshold.query.info": "5s", + "index.search.slowlog.threshold.query.debug": "2s", + "index.search.slowlog.threshold.query.trace": "500ms", + "index.search.slowlog.threshold.fetch.warn": "1s", + "index.search.slowlog.threshold.fetch.info": "800ms", + "index.search.slowlog.threshold.fetch.debug": "500ms", + "index.search.slowlog.threshold.fetch.trace": "200ms" +} + + +这样,在日志目录下的慢查询日志就会有输出记录必要的信息了。 + +{CLUSTER_NAME}_index_indexing_slowlog.log +{CLUSTER_NAME}_index_search_slowlog.log + + +数据结构优化 + + +基于 Elasticsearch 的使用场景,文档数据结构尽量和使用场景进行结合,去掉没用及不合理的数据。 + + +尽量减少不需要的字段 + +如果 Elasticsearch 用于业务搜索服务,一些不需要用于搜索的字段最好不存到 ES 中,这样即节省空间,同时在相同的数据量下,也能提高搜索性能。 + +避免使用动态值作字段,动态递增的 mapping,会导致集群崩溃;同样,也需要控制字段的数量,业务中不使用的字段,就不要索引。控制索引的字段数量、mapping 深度、索引字段的类型,对于 ES 的性能优化是重中之重。 + +以下是 ES 关于字段数、mapping 深度的一些默认设置: + +index.mapping.nested_objects.limit: 10000 +index.mapping.total_fields.limit: 1000 +index.mapping.depth.limit: 20 + + +Nested Object vs Parent/Child + +尽量避免使用 nested 或 parent/child 的字段,能不用就不用;nested query 慢,parent/child query 更慢,比 nested query 慢上百倍;因此能在 mapping 设计阶段搞定的(大宽表设计或采用比较 smart 的数据结构),就不要用父子关系的 mapping。 + +如果一定要使用 nested fields,保证 nested fields 字段不能过多,目前 ES 默认限制是 50。因为针对 1 个 document,每一个 nested field,都会生成一个独立的 document,这将使 doc 数量剧增,影响查询效率,尤其是 JOIN 的效率。 + +index.mapping.nested_fields.limit: 50 + + + + + +对比 +Nested Object +Parent/Child + + + + + +优点 +文档存储在一起,因此读取性高 +父子文档可以独立更新,互不影响 + + + +缺点 +更新父文档或子文档时需要更新整个文档 +为了维护 join 关系,需要占用部分内存,读取性能较差 + + + +场景 +子文档偶尔更新,查询频繁 +子文档更新频繁 + + + + +选择静态映射,非必需时,禁止动态映射 + +尽量避免使用动态映射,这样有可能会导致集群崩溃,此外,动态映射有可能会带来不可控制的数据类型,进而有可能导致在查询端出现相关异常,影响业务。 + +此外,Elasticsearch 作为搜索引擎时,主要承载 query 的匹配和排序的功能,那数据的存储类型基于这两种功能的用途分为两类,一是需要匹配的字段,用来建立倒排索引对 query 匹配用,另一类字段是用做粗排用到的特征字段,如 ctr、点击数、评论数等等。 + +document 模型设计 + +对于 MySQL,我们经常有一些复杂的关联查询。在 es 里该怎么玩儿,es 里面的复杂的关联查询尽量别用,一旦用了性能一般都不太好。 + +最好是先在 Java 系统里就完成关联,将关联好的数据直接写入 es 中。搜索的时候,就不需要利用 es 的搜索语法来完成 join 之类的关联搜索了。 + +document 模型设计是非常重要的,很多操作,不要在搜索的时候才想去执行各种复杂的乱七八糟的操作。es 能支持的操作就那么多,不要考虑用 es 做一些它不好操作的事情。如果真的有那种操作,尽量在 document 模型设计的时候,写入的时候就完成。另外对于一些太复杂的操作,比如 join/nested/parent-child 搜索都要尽量避免,性能都很差的。 + +集群架构设计 + + +合理的部署 Elasticsearch 有助于提高服务的整体可用性。 + + +主节点、数据节点和协调节点分离 + +Elasticsearch 集群在架构拓朴时,采用主节点、数据节点和负载均衡节点分离的架构,在 5.x 版本以后,又可将数据节点再细分为“Hot-Warm”的架构模式。 + +Elasticsearch 的配置文件中有 2 个参数,node.master 和 node.data。这两个参数搭配使用时,能够帮助提供服务器性能。 + +主(master)节点 + +配置 node.master:true 和 node.data:false,该 node 服务器只作为一个主节点,但不存储任何索引数据。我们推荐每个集群运行3 个专用的 master 节点来提供最好的弹性。使用时,你还需要将 discovery.zen.minimum_master_nodes setting 参数设置为 2,以免出现脑裂(split-brain)的情况。用 3 个专用的 master 节点,专门负责处理集群的管理以及加强状态的整体稳定性。因为这 3 个 master 节点不包含数据也不会实际参与搜索以及索引操作,在 JVM 上它们不用做相同的事,例如繁重的索引或者耗时,资源耗费很大的搜索。因此不太可能会因为垃圾回收而导致停顿。因此,master 节点的 CPU,内存以及磁盘配置可以比 data 节点少很多的。 + +数据(data)节点 + +配置 node.master:false 和 node.data:true,该 node 服务器只作为一个数据节点,只用于存储索引数据,使该 node 服务器功能单一,只用于数据存储和数据查询,降低其资源消耗率。 + +在 Elasticsearch 5.x 版本之后,data 节点又可再细分为“Hot-Warm”架构,即分为热节点(hot node)和暖节点(warm node)。 + +hot 节点: + +hot 节点主要是索引节点(写节点),同时会保存近期的一些频繁被查询的索引。由于进行索引非常耗费 CPU 和 IO,即属于 IO 和 CPU 密集型操作,建议使用 SSD 的磁盘类型,保持良好的写性能;我们推荐部署最小化的 3 个 hot 节点来保证高可用性。根据近期需要收集以及查询的数据量,可以增加服务器数量来获得想要的性能。 + +将节点设置为 hot 类型需要 elasticsearch.yml 如下配置: + +node.attr.box_type: hot + + +如果是针对指定的 index 操作,可以通过 settings 设置 index.routing.allocation.require.box_type: hot 将索引写入 hot 节点。 + +warm 节点: + +这种类型的节点是为了处理大量的,而且不经常访问的只读索引而设计的。由于这些索引是只读的,warm 节点倾向于挂载大量磁盘(普通磁盘)来替代 SSD。内存、CPU 的配置跟 hot 节点保持一致即可;节点数量一般也是大于等于 3 个。 + +将节点设置为 warm 类型需要 elasticsearch.yml 如下配置: + +node.attr.box_type: warm + + +同时,也可以在 elasticsearch.yml 中设置 index.codec:best_compression 保证 warm 节点的压缩配置。 + +当索引不再被频繁查询时,可通过 index.routing.allocation.require.box_type:warm,将索引标记为 warm,从而保证索引不写入 hot 节点,以便将 SSD 磁盘资源用在刀刃上。一旦设置这个属性,ES 会自动将索引合并到 warm 节点。 + +协调(coordinating)节点 + +协调节点用于做分布式里的协调,将各分片或节点返回的数据整合后返回。该节点不会被选作主节点,也不会存储任何索引数据。该服务器主要用于查询负载均衡。在查询的时候,通常会涉及到从多个 node 服务器上查询数据,并将请求分发到多个指定的 node 服务器,并对各个 node 服务器返回的结果进行一个汇总处理,最终返回给客户端。在 ES 集群中,所有的节点都有可能是协调节点,但是,可以通过设置 node.master、node.data、node.ingest 都为 false 来设置专门的协调节点。需要较好的 CPU 和较高的内存。 + + +node.master:false和node.data:true,该node服务器只作为一个数据节点,只用于存储索引数据,使该node服务器功能单一,只用于数据存储和数据查询,降低其资源消耗率。 +node.master:true和node.data:false,该node服务器只作为一个主节点,但不存储任何索引数据,该node服务器将使用自身空闲的资源,来协调各种创建索引请求或者查询请求,并将这些请求合理分发到相关的node服务器上。 +node.master:false和node.data:false,该node服务器即不会被选作主节点,也不会存储任何索引数据。该服务器主要用于查询负载均衡。在查询的时候,通常会涉及到从多个node服务器上查询数据,并将请求分发到多个指定的node服务器,并对各个node服务器返回的结果进行一个汇总处理,最终返回给客户端。 + + +关闭 data 节点服务器中的 http 功能 + +针对 Elasticsearch 集群中的所有数据节点,不用开启 http 服务。将其中的配置参数这样设置,http.enabled:false,同时也不要安装 head, bigdesk, marvel 等监控插件,这样保证 data 节点服务器只需处理创建/更新/删除/查询索引数据等操作。 + +http 功能可以在非数据节点服务器上开启,上述相关的监控插件也安装到这些服务器上,用于监控 Elasticsearch 集群状态等数据信息。这样做一来出于数据安全考虑,二来出于服务性能考虑。 + +一台服务器上最好只部署一个 node + +一台物理服务器上可以启动多个 node 服务器节点(通过设置不同的启动 port),但一台服务器上的 CPU、内存、硬盘等资源毕竟有限,从服务器性能考虑,不建议一台服务器上启动多个 node 节点。 + +集群分片设置 + +ES 一旦创建好索引后,就无法调整分片的设置,而在 ES 中,一个分片实际上对应一个 lucene 索引,而 lucene 索引的读写会占用很多的系统资源,因此,分片数不能设置过大;所以,在创建索引时,合理配置分片数是非常重要的。一般来说,我们遵循一些原则: + +控制每个分片占用的硬盘容量不超过 ES 的最大 JVM 的堆空间设置(一般设置不超过 32 G,参考上面的 JVM 内存设置原则),因此,如果索引的总容量在 500 G 左右,那分片大小在 16 个左右即可;当然,最好同时考虑原则 2。 考虑一下 node 数量,一般一个节点有时候就是一台物理机,如果分片数过多,大大超过了节点数,很可能会导致一个节点上存在多个分片,一旦该节点故障,即使保持了 1 个以上的副本,同样有可能会导致数据丢失,集群无法恢复。所以,一般都设置分片数不超过节点数的 3 倍。 + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/18大厂实践:腾讯万亿级Elasticsearch技术实践.md b/专栏/ElasticSearch知识体系详解/18大厂实践:腾讯万亿级Elasticsearch技术实践.md new file mode 100644 index 0000000..657c60a --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/18大厂实践:腾讯万亿级Elasticsearch技术实践.md @@ -0,0 +1,280 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 大厂实践:腾讯万亿级 Elasticsearch 技术实践 + 一、ES 在腾讯的海量规模背景 + + + +先来看看 ES 在腾讯的主要应用场景。ES 是一个实时的分布式搜索分析引擎,目前很多用户对 ES 的印象还是准实时,实际上在 6.8 版本之后官方文档已经将 near real-time 改为了 real-time: “Elasticsearch provides real-time search and analytics for all types of data.” ES 在写入完毕刷新之前,是可以通过 getById 的方式实时获取文档的,只是在刷新之前 FST 还没有构建,还不能提供搜索的能力。 目前 ES 在腾讯主要应用在三个方面: + + +搜索服务: 例如像腾讯文档基于 ES 做全文检索,我们的电商客户拼多多、蘑菇街等大量的商品搜索都是基于 ES。 +日志分析: 这个是 ES 应用最广泛的领域,支持全栈的日志分析,包括各种应用日志、数据库日志、用户行为日志、网络数据、安全数据等等。ES 拥有一套完整的日志解决方案,可以秒级实现从采集到展示。 +时序分析: 典型的场景是监控数据分析,比如云监控,整个腾讯云的监控都是基于 ES 的。此外还包括物联网场景,也有大量的时序数据。时序数据的特点是写入吞吐量特别高,ES 支持的同时也提供了丰富的多维统计分析算子。 + + +当然除了上面的场景之外,ES 本身在站内搜索、安全、APM 等领域也有广泛的应用。 + +目前 ES 在腾讯公有云、专有云以及内部云上面均有提供服务,可以广泛的满足公司内外客户的业务需求。公有云上的使用场景非常丰富,专有云主要实现标准化交付和自动化运维,腾讯内部云上的 ES 都是 PB 级的超大规模集群。 + +二、痛点与挑战 + +在这些丰富的应用场景,以及海量的规模背景下,我们也遇到了很多的痛点与挑战。主要覆盖在可用性、性能、成本以及扩展性方面。 + + + + +可用性: 最常见的问题是节点因高负载 OOM,或者整个集群因高负载而雪崩。这些痛点使我们很难保障 SLA,尤其是在搜索场景, 可用性要求 4 个 9 以上。 +性能: 搜索场景一般要求平响延时低于 20 毫秒,查询毛刺低于 100 毫秒。在分析场景,海量数据下,虽然实时性要求没那么高,但请求响应时间决定了用户体验,资源消耗决定了性能边界。 +成本: 很多用户都比较关注 ES 的存储成本,因为 ES 确实数据类型较多,压缩比比较低,存储成本比较高,但是优化的空间还是很大的。另外还包括内存成本,ES 有大量的索引数据需要加载到内存提供高性能的搜索能力。那么对于日志、监控等海量场景,成本的挑战就更大。 +扩展性: 日志、时序等场景,往往索引会按周期滚动,长周期会产生大量的索引和分片,超大规模集群甚至有几十上百万的分片、千级节点的需求。而目前原生版本 ES 只能支持到万级分片、百级节点。随着大数据领域的飞速发展,ES 最终是要突破 TB 的量级,跨越到 PB 的量级,扩展性就成为了主要的瓶颈与挑战。 + + +三、腾讯 ES 内核优化剖析 + + +ES 使用姿势、参数调优等在社区有很多的案例和经验可以借鉴,但很多的痛点和挑战是无法通过简单的调优来解决的,这个时候就需要从内核层面做深度的优化,来不断完善这个优秀的开源产品。接下来就是本次分享的核心部分,我们来看看腾讯是如何在内核层面对 ES 做优化的。 + + + + +可用性优化 + +首先介绍可用性优化部分。总体来说,原生版本在可用性层面有三个层面的问题: + + +系统健壮性不足: 高压力下集群雪崩,主要原因是内存资源不足。负载不均会导致部分节点压力过载,节点 OOM。我们在这个层面的方案主要是优化服务限流和节点均衡策略。 +容灾方案欠缺: ES 本身提供副本机制提升数据安全性,对于多可用区容灾还是需要云平台额外实现。即使有副本机制,甚至有跨集群复制(CCR),但还是不能阻挡用户误操作导致的数据删除,所以还需要额外提供低成本的备份回挡能力。 +内核 Bug: 我们修复了 Master 任务堵塞、分布式死锁、滚动重启速度慢等一系列内核可用性相关的问题,并及时提供新版本给用户升级。 + + +接下来针对用户在可用性层面常遇到的两类问题展开分析。一类是高并发请求压垮集群,另一类是单个大查询打挂节点。 + +高并发请求压垮集群 + + + +先来看第一类场景,高并发请求压垮集群。例如早期我们内部一个日志集群,写入量一天突增 5 倍,集群多个节点 Old GC 卡住脱离集群,集群 RED,写入停止,这个痛点确实有点痛。我们对挂掉的节点做了内存分析,发现大部分内存是被反序列化前后的写入请求占用。我们来看看这些写入请求是堆积在什么位置。 + + + +ES high level 的写入流程,用户的写入请求先到达其中一个数据节点,我们称之为数据节点。然后由该协调节点将请求转发给主分片所在节点进行写入,主分片写入完毕再由主分片转发给从分片写入,最后返回给客户端写入结果。右边是更细节的写入流程,而我们从堆栈中看到的写入请求堆积的位置就是在红色框中的接入层,节点挂掉的根因是协调节点的接入层内存被打爆。 + +找到了问题的原因,接下来介绍我们的优化方案。 + + + +针对这种高并发场景,我们的优化方案是服务限流。除了要能控制并发请求数量,还要能精准的控制内存资源,因为内存资源不足是主要的矛盾。另外通用性要强,能作用于各个层级实现全链限流。 + +限流方案,很多数据库使用场景会采用从业务端或者独立的 proxy 层配置相关的业务规则,做资源预估等方式进行限流。这种方式适应能力弱,运维成本高,而且业务端很难准确的预估资源消耗。 + +原生版本本身有限流策略,是基于请求数的漏桶策略,通过队列加线程池的方式实现。线程池大小决定的了处理并发度,处理不完放到队列,队列放不下则拒绝请求。但是单纯的基于请求数的限流不能控制资源使用量,而且只作用于分片级子请求的传输层,对于我们前面分析的接入层无法起到有效的保护作用。原生版本也有内存熔断策略,但是在协调节点接入层并没有做限制。 + +我们的优化方案是基于内存资源的漏桶策略。我们将节点 JVM 内存作为漏桶的资源,当内存资源足够的时候,请求可以正常处理,当内存使用量到达一定阈值的时候分区间阶梯式平滑限流。例如图中浅黄色的区间限制写入,深黄色的区间限制查询,底部红色部分作为预留 buffer,预留给处理中的请求、merge 等操作,以保证节点内存的安全性。 + + + +限流方案里面有一个挑战是,我们如何才能实现平滑限流?因为采用单一的阈值限流很容易出现请求抖动,例如请求一上来把内存打上去马上触发限流,而放开一点点请求又会涌进来把内存打上去。我们的方案是设置了高低限流阈值区间,在这个区间中,基于余弦变换实现请求数和内存资源之间的平滑限流。当内存资源足够的时候,请求通过率 100%,当内存到达限流区间逐步上升的时候,请求通过率随之逐步下降。而当内存使用量下降的时候,请求通过率也会逐步上升,不会一把放开。通过实际测试,平滑的区间限流能在高压力下保持稳定的写入性能。 + +我们基于内存资源的区间平滑限流策略是对原生版本基于请求数漏桶策略的有效补充,并且作用范围更广,覆盖协调节点、数据节点的接入层和传输层,并不会替代原生的限流方案。 + + + +单个大查询打挂节点 + +接下来介绍单个大查询打挂节点的场景。例如我们在分析场景,做多层嵌套聚合,有时候请求返回的结果集比较大,那么这个时候极有可能这一个请求就会将节点打挂。我们对聚合查询流程进行分析,请求到达协调节点之后,会拆分为分片级子查询请求给目标分片所在数据节点进行子聚合,最后协调节点收集到完整的分片结果后进行归并、聚合、排序等操作。这里的主要问题点是,协调节点大量汇聚结果反序列化后内存膨胀,以及二次聚合产生新的结果集打爆内存。 + + + +针对上面单个大查询的问题,下面介绍我们的优化方案。优化方案的要点是内存膨胀预估加流式检查。 我们先来看下原生方案,原生版本是直接限制最大返回结果桶数,默认一万,超过则请求返回异常。这种方式面临的挑战是,在分析场景结果数十万、百万是常态,默认一万往往不够,调整不灵活,调大了内存可能还是会崩掉,小了又不能满足业务需求。 + +我们的优化方案主要分为两个阶段: + + +第一阶段:在协调节点接收数据节点返回的响应结果反序列化之前做内存膨胀预估,基于接收到的网络 byte 流大小做膨胀预估,如果当前 JVM 内存使用量加上响应结果预估的使用量超过阈值则直接熔断请求。 +第二阶段:在协调节点 reduce 过程中,流式检查桶数,每增加固定数量的桶(默认 1024 个)检查一次内存,如果超限则直接熔断。流式检查的逻辑在数据节点子聚合的过程同样生效。 + + +这样用户不再需要关心最大桶数,只要内存足够就能最大化地满足业务需求。不足之处是大请求还是被拒掉了,牺牲了用户的查询体验,但是我们可以通过官方已有的 batch reduce 的方式缓解,就是当有 100 个分片子结果的时候,每收到部分就先做一次聚合,这样能降低单次聚合的内存开销。上面流式聚合的整体方案已经提交给官方并合并了,将在最近的 7.7.0 版本中发布。 + +前面介绍了两种比较典型的用户常遇到的可用性问题。接下来对整个可用性优化做一个总结。 + + + +首先我们结合自研的优化方案和原生的方案实现了系统性的全链路限流。左图中黄色部分为自研优化,其它为原生方案。覆盖执行引擎层、传输层和接入层。另外我们对内存也做了相关的优化,内存利用率优化主要是针对写入场景,例如单条文档字段数过多上千个,每个字段值在写入过程中都会申请固定大小的 buffer,字段数过多的时候内存浪费严重,优化方案主要是实现弹性的内存 buffer。内存回收策略,这里不是指 GC 策略,主要是对于有些例如读写异常的请求及时进行内存回收。JVM GC 债务管理主要是评估 JVM Old GC 时长和正常工作时长的比例来衡量 JVM 的健康情况,特殊情况会重启 JVM 以防止长时间 hang死。 + +可用性优化效果,我们将公有云的 ES 集群整体可用性提升至 4 个 9,内存利用率提升 30%,高压力场景稳定性有大幅提升,基本能保证节点不会 OOM,集群不会雪崩。 + +下面部分是我们可用性优化相关的 PR。除了前面介绍的协调节点流式检查和内存膨胀预估以外,还包括单个查询内存限制,这个也很有用,因为有些场景如果单个查询太大会影响其它所有的请求。以及滚动重启速度优化,大集群单个节点的重启时间从 10 分钟降至 1 分钟以内,这个优化在 7.5 版本已经被合并了。如果大家遇到大集群滚动重启效率问题可以关注。 + +性能优化 + +接下来介绍性能优化。 + + + +性能优化的场景主要分为写入和查询。写入的代表场景包括日志、监控等海量时序数据场景,一般能达到千万级吞吐。带 id 的写入性能衰减一倍,因为先要查询记录是否存在。查询包含搜索场景和分析场景,搜索服务主要是高并发,低延时。聚合分析主要以大查询为主,内存、CPU 开销高。 + +我们看下性能的影响面,左半部分硬件资源和系统调优一般是用户可以直接掌控的,比如资源不够扩容,参数深度调优等。右半部分存储模型和执行计划涉及到内核优化,用户一般不容易直接调整。接下来我们重点介绍一下这两部分的优化。 + + + +存储模型优化 + + + +首先是存储模型优化。我们知道 ES 底层 Lucene 是基于 LSM Tree 的数据文件。原生默认的合并策略是按文件大小相似性合并,默认一次固定合并 10 个文件,近似分层合并。这种合并方式的最大优点是合并高效,可以快速降低文件数;主要问题是数据不连续,这样会导致我们在查询的时候文件裁剪的能力很弱,比如查询最近一小时的数据,很有可能一小时的文件被分别合并到了几天前的文件中去了,导致需要遍历的文件增加了。 + +业内典型的解决数据连续性的合并策略,比如以 Cassandra、HBase 为代表的基于时间窗口的合并策略,优点是数据按时间序合并,查询高效,且可以支持表内 TTL;不足是限制只能是时序场景,而且文件大小可能不一致,从而影响合并效率。还有一类是以 LevelDB、RocksDB 为代表的分层合并,一层一组有序,每次抽取部分数据向下层合并,优点是查询高效,但是写放大比较严重,相同的数据可能会被多次合并,影响写入吞吐。 + +最后是我们的优化合并策略。我们的目标是为了提升数据连续性、收敛文件数量,提升文件的裁剪能力来提高查询性能。我们实现的策略主要是按时间序分层合并,每层文件之间按创建时间排序,除了第一层外,都按照时间序和目标大小进行合并,不固定每次合并文件数量,这样保证了合并的高效性。对于少量的未合并的文件以及冷分片文件,我们采用持续合并的策略,将超过默认五分钟不再写入的分片进行持续合并,并控制合并并发和范围,以降低合并开销。 + +通过对合并策略的优化,我们将搜索场景的查询性能提升了 40%。 + +执行引擎的优化 + +前面介绍了底层文件的存储模型优化,我们再来向上层看看执行引擎的优化。 + + + +我们拿一个典型的场景来进行分析。ES 里面有一种聚合叫 Composite 聚合大家可能都比较了解,这个功能是在 6.5 版本正式 GA 发布的。它的目的是为了支持多字段的嵌套聚合,类似 MySQL 的 group by 多个字段;另外可以支持流式聚合,即以翻页的形式分批聚合结果。用法就像左边贴的查询时聚合操作下面指定 composite 关键字,并指定一次翻页的长度,和 group by 的字段列表。那么每次拿到的聚合结果会伴随着一个 after key 返回,下一次查询拿着这个 after key 就可以查询下一页的结果。 + +那么它的实现原理是怎样的呢?我们先来看看原生的方案。比如这里有两个字段的文档,field1 和 field2,第一列是文档 id 。我们按照这两个字段进行 composite 聚合,并设定一次翻页的 size 是 3。具体实现是利用一个固定 size 的大顶堆,size 就是翻页的长度,全量遍历一把所有文档迭代构建这个基于大顶堆的聚合结果,如右图中的 1 号序列所示,最后返回这个大顶堆并将堆顶作为 after key。第二次聚合的时候,同样的全量遍历一把文档,但会加上过滤条件排除不符合 after key 的文档,如右图中 2 号序列所示。 + +很显然这里面存在性能问题,因为每次拉取结果都需要全量遍历一遍所有文档,并未实现真正的翻页。接下来我们提出优化方案。 + + + +我们的优化方案主要是利用 index sorting 实现 after key 跳转以及提前结束(early termination)。 数据有序才能实现真正的流式聚合,index sorting 也是在 6.5 版本里面引入的,可以支持文档按指定字段排序。但遗憾的是聚合查询并没有利用数据有序性。我们可以进行优化,此时大顶堆我们仍然保留,我们只需要按照文档的顺序提取指定 size 的文档数即可马上返回,因为数据有序。下一次聚合的时候,我们可以直接根据请求携带的 after key 做跳转,直接跳转到指定位置继续向后遍历指定 size 的文档数即可返回。这样避免了每次翻页全量遍历,大幅提升查询性能。这里有一个挑战点,假设数据的顺序和用户查询的顺序不一致优化还能生效吗?实际可以的,逆序场景不能实现 after key 跳转因为 lucene 底层不能支持文档反向遍历,但提前结束的优化仍然生效,仍然可以大幅提升效率。这个优化方案我们是和官方研发协作开发的,因为我们在优化的同时,官方也在优化,但我们考虑的更全面覆盖了数据顺序和请求顺序不一致的优化场景,最终我们和官方一起将方案进行了整合。该优化方案已经在 7.6 合并,大家可以试用体验。 + +前面从底层的存储模型到上层的执行引擎分别举例剖析了优化,实际上我们在性能层面还做了很多的优化。从底层的存储模型到执行引擎,到优化器,到上层的缓存策略基本都有覆盖。下图中左边是优化项,中间是优化效果,右边是有代表性的优化的 PR 列表。 + + + +这里简单再介绍一下其它的 PR 优化,中间这个 translog 刷新过程中锁的粗化优化能将整体写入性能提升 20%;这个 lucene 层面的文件裁剪优化,它能将带 id 写入场景性能提升一倍,当然查询也是,因为带 id 的写入需要先根据 id 查询文档是否存在,它的优化主要是在根据 id 准备遍历查询一个 segment 文件的时候,能快速根据这个 segment 所统计的最大最小值进行裁剪,如果不在范围则快速裁剪跳过,避免遍历文档;最下面的一个 PR 是缓存策略的优化,能避免一些开销比较大的缓存,大幅的降低查询毛刺。 + +上面这些性能优化项在我们腾讯云的 ES 版本中均有合入,大家可以试用体验。 + +成本优化 + +接下来我们再看成本优化。在日志、时序等大规模数据场景下,集群的 CPU、内存、磁盘的成本占比是 1 比 4 比 8。例如一般 16 核 64GB,2-5 TB 磁盘节点的成本占比大概是这个比例。因此成本的主要瓶颈在于磁盘和内存。 + + + +成本优化的主要目标是存储成本和内存成本。 + +存储成本 + +我们先来看下存储成本。 + + + +我们先来看一个场景,整个腾讯云监控是基于 ES 的,单个集群平均写入千万每秒,业务需要保留至少半年的数据供查询。我们按照这个吞吐来计算成本,1000 万 QPS 乘以时间乘以单条文档平均大小再乘以主从两个副本总共大约 14 PB 存储,大约需要 1500 台热机型物理机。这显然远远超出了业务成本预算,那我们如何才能既满足业务需求又能实现低成本呢? + +来看下我们的优化方案,首先我们对业务数据访问频率进行调研,发现最近的数据访问频率较高,例如最近 5 分钟的,一小时的,一天的,几天的就比较少了,超过一个月的就更少了,历史数据偏向于统计分析。 + +首先我们可以通过冷热分离,把冷数据放到 HDD 来降低成本,同时利用官方提供的索引生命周期管理来搬迁数据,冷数据盘一般比较大我们还要利用多盘策略来提高吞吐和数据容灾能力。最后将超冷的数据冷备到腾讯云的对象存储 COS 上,冷备成本非常低,1GB 一个月才一毛多。 + +上面这些我们都可以从架构层面进行优化。是否还有其它优化点呢?基于前面分析的数据访问特征,历史数据偏向统计分析,我们提出了 Rollup 方案。Rollup 的目的是对历史数据降低精度,来大幅降低存储成本。我们通过预计算来释放原始细粒度的数据,例如秒级的数据聚合成小时级,小时级聚合成天级。这样对于用户查询时间较长的跨度报表方便展示,查询几天的秒级数据太细没法看。另外可以大幅降低存储成本,同时可以提升查询性能。 + +我们在 17 年的时候就实现了 Rollup 的方案并投入给了腾讯云监控使用,当然目前官方也出了 Rollup 方案,目前功能还在体验中。 + +下面介绍一下我们最新的 Rollup 方案的要点。 + + + +总体来说 Rollup 优化方案主要是基于流式聚合加查询剪枝结合分片级并发来实现其高效性。流式聚合和查询剪枝的优化我们前面在性能优化部分已经介绍了,我们新的 Rollup 也利用了这些优化,这里不再展开。下面介绍一下分片级并发,及并发自动控制策略。 + +正常的聚合查询,需要将请求发送给每个分片进行子聚合,在到协调节点做汇聚,两次聚合多路归并。我们通过给数据添加 routing 的方式让相同的对象落到相同的分片内,这样就只需要一层聚合,因为分片数据独立,多个数据对象可以实现分片级并发。 另外我们通过对 Rollup 任务资源预估,并感知集群的负载压力来自动控制并发度,这样对集群整体的影响能控制在一定的范围。右边的图是我们的优化效果,某个统计指标 30 天的存储量,天级的只需要 13 GB,小时级的只需要 250 GB,细粒度的会多一些,总体存储量下降了将近 10 倍。单个集群 150 台左右物理机即可搞定,成本缩减 10 倍。整体写入开销 rollup 资源消耗在 10% 以下。 + +内存成本优化 + +前面是存储成本优化,下面介绍内存成本优化。 + + + +我们通过对线上集群进行分析,发现很多场景堆内内存使用率很高,而磁盘的使用率比较低。堆内存使用率为什么这么高呢?其中的 FST 即倒排索引占据了绝大部分堆内内存,而且这部分是常驻内存的。每 10 TB 的磁盘 FST 的内存消耗大概在 10 GB 到 15 GB 左右。 + +我们能不能对 FST 这种堆内占用比较大的内存做优化?我们的想法是把它移至堆外(off-heap),按需加载,提升堆内内存利用率,降低 GC 开销,提升单个节点管理磁盘的能力。 + + + +我们来看下 off-heap 相关的方案。首先原生版本目前也实现了 off-heap,方案是将 FST 对象放到 MMAP 中管理,这种方式实现简单,我们早期也采用了这种方式实现,但是由于 MMAP 属于 page cache 可能被系统回收掉,导致读盘操作,从而带来性能的 N 倍损耗,容易产生查询毛刺。 + +HBase 2.0 版本中也实现了 off-heap,在堆外建立了 cache,脱离系统缓存,但只是把数据放到堆外,索引仍然在堆内,而且淘汰策略完全依赖 LRU 策略,冷数据不能及时的清理。 + +我们的优化方案也是在堆外建立 cache,保证 FST 的空间不受系统影响,另外我们会实现更精准的淘汰策略,提高内存使用率,再加上多级 cache 的管理模式来提升性能。这种方式实现起来比较复杂但收益还是很明显的,下面我们来看一下详细的实现。 + + + +我们的方案是通过 LRU cache + 零拷贝 + 两级 cache 的方式实现的。首先 LRU cache 是建立在堆外,堆内有访问 FST 需求的时候从磁盘加载到 cache 中。由于 Lucene 默认的访问 FST 的方式是一个堆内的 buffer,前期我们采用了直接从堆外拷贝到堆内的 buffer 方式实现,压测发现查询性能损耗 20%,主要是堆外向堆内 copy 占了大头。 + +因此我们有了第二阶段优化,将 Lucene 访问 FST 的方式进行了改造,buffer 里面不直接存放 FST,而存放堆外对象的一个指针,这样实现了堆内和堆外之间的零拷贝,这里的零拷贝和我们说的 linux 中的用户态和内核态的零拷贝是两个概念。这样实现后我们压测发现查询性能还是有 7%的损耗,相较于堆内的 FST 场景。我们有没办法做到极致呢? + +我们通过分析发现,这 7% 的损耗主要是根据 key 去查找堆外对象的过程,包括计算 hash,数据校验等。我们第三阶段的优化就是利用 Java 的弱引用建立第二层轻量级缓存。弱引用指向堆外的地址,只要有请求使用,这个 key 就不会被回收可以重复利用而无需重新获取。一旦不在使用,这个 key 就会被 GC 回收掉,并回收掉堆外对象指针。问题来了,堆外对象指针回收之后我们怎么清理堆外这部分内存呢?让其 LRU 淘汰?这样显然会浪费一部分内存空间。最好的办法是在堆内对象地址回收的时候直接回收堆外对象,但是 Java 没有析构的概念。这里我们利用了弱引用的 Reference Queue,当对象要被 GC 回收的时候会将对象指向的堆外内存清理掉,这样完美解决了堆外内存析构的问题,保证了堆外内存的精准淘汰,提升内存利用率。最后通过压测我们发现性能基本和原生方案 FST 在堆内的场景持平。 + +下面是内存优化相关的效果和收益: + + + +通过我们的内存优化后,内存开销、数据管理能力、GC 优势明显,性能持平略有优势。单个 FST 堆内占用只需要 100 个 byte 左右即 cache key 的大小。单节点磁盘管理能力,32GB heap 能到 50 TB 左右,相较原生版本 5-10 TB(需要深度调优)有 10 倍的提升。利用官方提供的 esrally 进行性能压测,发现堆内存使用量有大幅的下降,GC 时长也有缩减,性能基本持平。 + +扩展性优化 + +接下来是最后一块内核优化内容,扩展性优化。 + + + +我们先来看一下场景。ES 的元数据管理模型是,master 管理元数据,其它所有节点同步元数据。我们以建索引流程为例,来看看元数据的同步流程。首先是 master 分配分片,然后产生 diff 的元数据,发送给其它节点,等待大多数 master 节点返回,master 发送元数据应用请求,其它节点开始应用元数据,并根据新旧元数据推导出各自节点的分片创建任务。 + +这里面的瓶颈点主要有以下几点: + + +Mater 节点在分配分片的时候,需要做一遍元数据的正反向转换。我们知道路由信息是由分片到节点的映射,而我们在做分片分配的时候需要节点到分片的映射,需要知道每个节点上的分片分布。分片分配完毕又需要将节点到分片的映射转换回来,因为我们元数据只发布分片到节点的映射。这个转换过程涉及多次的全量遍历,大规模分片性能存在瓶颈。 +在每次索引创建的过程中,会涉及多次的元数据同步,在大规模的节点数场景,会出现同步瓶颈,上千节点,部分节点假设有一点网络抖动或 Old GC 可能导致同步失败。 + + +基于上面的瓶颈,目前原生版本只能支持大约 3-5 万分片,性能已经到达极限,创建索引基本到达 30 秒+ 甚至分钟级。节点数只能到 500 左右基本是极限了。 + +为此,我们提出了扩展性优化方案。 + + + +主要的优化内容包括: + + +分片创建任务式定向下发: 对于创建分片导致的元数据同步瓶颈,我们采用任务下发的方式,定向下发分片创建任务,避免多次全节点元数据同步。 +元数据增量维护: 分配分片的过程中多次正反向遍历,我们采用增量化的数据结构维护的方式,避免全量的遍历。 +统计缓存策略: 统计接口的性能,我们采用缓存策略避免多次重复的统计计算,大幅降低资源开销。 最终我们将集群的分片数扩展到百万级,节点数扩展到千级,新建索引基本稳定在 5 秒以下,统计接口秒级响应。 + + +前面就是所有的内核优化的内容。ES 是一款很优秀的开源大数据产品,我们将持续的建设。我们对公司内外提供了完整的托管平台,对 ES 内核各个层面做了系统性的增强优化,助力 Elastic Stack 在大数据生态中覆盖更多的场景,发展的更好。 + + + +四、开源贡献及未来规划 + + + +在腾讯内部,我们实现了 ES 产品开源协同,共同优化完善 ES,避免不同的团队重复踩坑。同时我们也将优秀的方案积极的贡献给社区,和官方及社区的 ES 爱好者们共同推动 ES 的发展。以腾讯 ES 内核研发为代表的团队,截至目前我们共提交了二十多个 PR,其中有 70% 被合并,共有 6 位 ES/Lucene 的 contributor。 + +未来,我们将持续的优化 ES,包括可用性,性能和成本等方面。可用性方面我们会加强 ES 的故障自愈能力,朝着自动驾驶的目标发展;性能方面,搜索场景 ES 是绝对的王者,多维分析领域还有很多可优化的地方,我们希望能进一步扩展 ES 在分析领域的应用场景。成本方面,存储与计算分离正在研发中,基于腾讯自研的共享文件系统 CFS,到时会进一步缩减成本,提升性能。 + +资源链接 + +线上 PPT 的链接:https://elasticsearch.cn/slides/259 + +分享过程中相关问题的答疑:https://elasticsearch.cn/articl + +腾讯Elasticsearch海量规模背后的内核优化剖析 https://zhuanlan.zhihu.com/p/139725905 + +腾讯万亿级 Elasticsearch 内存效率提升技术解密 https://zhuanlan.zhihu.com/p/146083622 + +腾讯万亿级 Elasticsearch 技术解密 https://zhuanlan.zhihu.com/p/99184436 + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/19资料:AwesomeElasticsearch.md b/专栏/ElasticSearch知识体系详解/19资料:AwesomeElasticsearch.md new file mode 100644 index 0000000..b25e346 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/19资料:AwesomeElasticsearch.md @@ -0,0 +1,353 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 资料:Awesome Elasticsearch + General + +Elastic Stack + + +Elasticsearch official website +Logstash is a data pipeline that helps you process logs and other event data from a variety of systems +Kibana is a data analysis tool that helps to visualize your data; Kibana Manual docs +beats is the platform for building lightweight, open source data shippers for many types of data you want to enrich with Logstash, search and analyze in Elasticsearch, and visualize in Kibana. + + +Books + + +Deep Learning for Search - teaches you how to leverage neural networks, NLP, and deep learning techniques to improve search performance. (2019) +Relevant Search: with applications for Solr and Elasticsearch - demystifies relevance work. Using Elasticsearch, it teaches you how to return engaging search results to your users, helping you understand and leverage the internals of Lucene-based search engines. (2016) +Elasticsearch in Action - teaches you how to build scalable search applications using Elasticsearch (2015) + + +Related (awesome) lists + + +frutik/awesome-search I am building e-commerce search now. Below are listed some of my build blocks + + +Open-source and free products, based on Elasticsearch + + +Fess is an open source full featured Enterprise Search, with a web-crawler +Yelp/elastalert is a modular flexible rules based alerting system written in Python +etsy/411 - an Alert Management Web Application https://demo.fouroneone.io (credentials: user/user) +appbaseio/mirage is a 🔎 GUI for composing Elasticsearch queries +exceptionless/Exceptionless is an error (exceptions) collecting and reporting server with client bindings for a various programming languages +searchkit/searchkit is a UI framework based on React to build awesome search experiences with Elasticsearch +appbaseio/reactivemaps is a React based UI components library for building Airbnb / Foursquare like Maps +appbaseio/reactivesearch is a library of beautiful React UI components for Elasticsearch +appbaseio/dejavu The missing UI for Elasticsearch; landing page +Simple File Server is an Openstack Swift compatible distributed object store that can serve and securely store billions of large and small files using minimal resources. +logagent a log shipper to parse and ship logs to Elasticsearch including bulk indexing, disk buffers and log format detection. +ItemsAPI simplified search API for web and mobile (based on Elasticsearch and Express.js) +Kuzzle - An open-source backend with advanced real-time features for Web, Mobile and IoT that uses ElasticSearch as a database. (Website ) +SIAC - SIAC is an enterprise SIEM built on the ELK stack and other open-source components. +Sentinl - Sentinl is a Kibana alerting and reporting app. +Praeco - Elasticsearch alerting made simple + + +Elasticsearch developer tools and utilities + +Development and debugging + + +Sense (from Elastic) A JSON aware developer console to Elasticsearch; official and very powerful +ES-mode An Emacs major mode for interacting with Elasticsearch (similar to Sense) +Elasticsearch Cheatsheet Examples for the most used queries, API and settings for all major version of Elasticsearch +Elasticstat CLI tool displaying monitoring informations like htop +Elastic for Visual Studio Code An extension for developing Elasticsearch queries like Kibana and Sense extention in Visual Studio Code +Elastic Builder A Node.js implementation of the Elasticsearch DSL +Bodybuilder A Node.js elasticsearch query body builder +enju A Node.js elasticsearch ORM +Peek An interactive CLI in Python that works like Kibana Console with additional features + + +Import and Export + + +Knapsack plugin is an “swiss knife” export/import plugin for Elasticsearch +Elasticsearch-Exporter is a command line script to import/export data from Elasticsearch to various other storage systems +esbulk Parallel elasticsearch bulk indexing utility for the command line. +elasticdump - tools for moving and saving indices +elasticsearch-loader - Tool for loading common file types to elasticsearch including csv, json, and parquet + + +Management + + +Esctl - High-level command line interface to manage Elasticsearch clusters. +Vulcanizer - Github’s open sourced cluster management library based on Elasticsearch’s REST API. Comes with a high level CLI tool + + +Elasticsearch plugins + +Cluster + + +sscarduzio/elasticsearch-readonlyrest-plugin Safely expose Elasticsearch REST API directly to the public +mobz/elasticsearch-head is a powerful and essential plugin for managing your cluster, indices and mapping +Bigdesk - Live charts and statistics for elasticsearch cluster +Elastic HQ - Elasticsearch cluster management console with live monitoring and beautiful UI +Cerebro is an open source(MIT License) elasticsearch web admin tool. Supports ES 5.x +Kopf - Another management plugin that have REST console and manual shard allocation +Search Guard - Elasticsearch and elastic stack security and alerting for free +ee-outliers - ee-outliers is a framework to detect outliers in events stored in an Elasticsearch cluster. +Elasticsearch Comrade - Elasticsearch admin panel built for ops and monitoring +elasticsearch-admin - Web administration for Elasticsearch + + +Other + + +SIREn Join Plugin for Elasticsearch This plugin extends Elasticsearch with new search actions and a filter query parser that enables to perform a “Filter Join” between two set of documents (in the same index or in different indexes). + + +Integrations and SQL support + + +NLPchina/elasticsearch-sql - Query elasticsearch using familiar SQL syntax. You can also use ES functions in SQL. +elastic/elasticsearch-hadoop - Elasticsearch real-time search and analytics natively integrated with Hadoop (and Hive) +jprante/elasticsearch-jdbc - JDBC importer for Elasticsearch +pandasticsearch - An Elasticsearch client exposing DataFrame API +monstache - Go daemon that syncs MongoDB to Elasticsearch in near realtime + + +You know, for search + + +jprante/elasticsearch-plugin-bundle A plugin that consists of a compilation of useful Elasticsearch plugins related to indexing and searching documents + + +Kibana plugins and applications + + +elastic/timelion time-series analyses application. Overview and installation guide: Timelion: The time series composer for Kibana +Kibana Alert App for Elasticsearch - Kibana plugin with monitoring, alerting and reporting capabilities +VulnWhisperer - VulnWhisperer is a vulnerability data and report aggregator. +Wazuh Kibana App - A Kibana app for working with data generated by Wazuh . +Datasweet Formula - A real time calculated metric plugin Datasweet Formula . + + +Kibana Visualization plugins + + +nbs-system/mapster - a visualization which allows to create live event 3d maps in Kibana +Kibana Tag Cloud Plugin - tag cloud visualization plugin based on d3-cloud plugin +LogTrail - a plugin for Kibana to view, analyze, search and tail log events from multiple hosts in realtime with devops friendly interface inspired by Papertrail +Analyze API - Kibana 6 application to manipulate the _analyze API graphically +kbn_network - This is a plugin developed for Kibana that displays a network node that link two fields that have been previously selected. + + +Discussions and social media + + +/r/elasticsearch +Elasticsearch forum +Stackoverflow +Books on Amazon does not fit well into this category, but worth checking out! +TODO: Put some good twitter accounts + + +Tutorials + + +Centralized Logging with Logstash and Kibana On Ubuntu 14.04 everything you need to now when you are creating your first Elasticsearch+Logstash+Kibana instance +dwyl/learn-elasticsearch a getting started tutorial with a pack of valuable references +Make Sense of your Logs: From Zero to Hero in less than an Hour! by Britta Weber demonstrates how you can build Elasticsearch + Logstash + Kibana stack to collect and discover your data +$$ Elasticsearch 7 and Elastic Stack - liveVideo course that teaches you to search, analyze, and visualize big data on a cluster with Elasticsearch, Logstash, Beats, Kibana, and more. + + +Articles + +System configuration + + +A Useful Elasticsearch Cheat Sheet in Times of Trouble +The definitive guide for Elasticsearch on Windows Azure +Elasticsearch pre-flight checklist +9 Tips on Elasticsearch Configuration for High Performance +Best Practices in AWS +How to Secure Elasticsearch and Kibana with NGINX, LDAP and SSL 🔒 +Elasticsearch server on Webfaction using NGINX with basic authorization and HTTPS protocol +Elasticsearch Guides Useful Elasticsearch guides with best practices, troubleshooting instructions for errors, tips, examples of code snippets and more. + + +Docker and Elasticsearch + + +Running an Elasticsearch cluster with Docker + + +Java tuning + + +Elasticsearch Java Virtual Machine settings explained +Tuning Garbage Collection for Mission-Critical Java Applications +G1: One Garbage Collector To Rule Them All +Use Lucene’s MMapDirectory on 64bit platforms, please! +Black Magic cookbook +G1GC Fundamentals: Lessons from Taming Garbage Collection +JVM Garbage Collector settings investigation PDF Comparison of JVM GC +Garbage Collection Settings for Elasticsearch Master Nodes Fine tunine your garbage collector +Understanding G1 GC Log Format To tune and troubleshoot G1 GC enabled JVMs, one must have a proper understanding of G1 GC log format. This article walks through key things that one should know about the G1 GC log format. + + +How to start using G1 + +#ES_JAVA_OPTS="" +ES_JAVA_OPTS="-XX:-UseParNewGC -XX:-UseConcMarkSweepGC -XX:+UseG1GC" + + +Scalable Infrastructure and performance + + +The Authoritative Guide to Elasticsearch Performance Tuning (Part 1) Part 2 Part 3 +Tuning data ingestion performance for Elasticsearch on Azure - and not only for Azure. That’s a great article about Elasticsearch Performance testing by example +Elasticsearch Indexing Performance Cheatsheet - when you plan to index large amounts of data in Elasticsearch (by Patrick Peschlow) +Elasticsearch for Logging Elasticsearch configuration tips and tricks from Sanity +Scaling Elasticsearch to Hundreds of Developers by Joseph Lynch @yelp +10 Elasticsearch metrics to watch +Understanding Elasticsearch Performance +Our Experience of Creating Large Scale Log Search System Using Elasticsearch - topology, separate master, data and search balancers nodes +📂 Elasticsearch on Azure Guidance it is 10% on Azure and 90% of a very valuable general information, tips and tricks about Elasticsearch +How to avoid the split-brain problem in Elasticsearch +Datadog’s series about monitoring Elasticsearch performance: + + +How to monitor Elasticsearch performance +How to collect Elasticsearch metrics +How to monitor Elasticsearch with Datadog +How to solve 5 Elasticsearch performance and scaling problems + +Performance Monitoring Essentials - Elasticsearch Edition +Operator for running Elasticsearch in Kubernetes + + +Integrations + + +Apache Hive integration +Connecting Tableau to Elasticsearch (READ: How to query Elasticsearch with Hive SQL and Hadoop) +mradamlacey/elasticsearch-tableau-connector + + +Logging + + +5 Logstash Alternatives and typical use cases + + +Alerts + + +ElastAlert: Alerting At Scale With Elasticsearch, Part 1 by engineeringblog.yelp.com +ElastAlert: Alerting At Scale With Elasticsearch, Part 2 by engineeringblog.yelp.com +Elastalert: implementing rich monitoring with Elasticsearch + + +Time series + + +Elasticsearch as a Time Series Data Store by Felix Barnsteiner +Running derivatives on Voyager velocity data By Colin Goodheart-Smithe +Shewhart Control Charts via Moving Averages: Part 1 - Part 2 by Zachary Tong +Implementing a Statistical Anomaly Detector: Part 1 - Part 2 - Part 3 by Zachary Tong + + +Machine Learning + + +Classifying images into Elasticsearch with DeepDetect (forum thread with discussion ) by Emmanuel Benazera +Elasticsearch with Machine Learning (English translation ) by Kunihiko Kido +Recommender System with Mahout and Elasticsearch + + +Use cases for Elasticsearch + + +Data Infrastructure at IFTTT Elasticsearch, Kafka, Apache Spark, Redhsift, other AWS services +OFAC compliance with Elasticsearch using AWS +Building a Streaming Search Platform - Streaming Search on Tweets: Storm, Elasticsearch, and Redis + + +Other + + +LogZoom, a fast and lightweight substitute for Logstash +Graylog2/graylog2-server - Free and open source log management (based on ES) +Fluentd vs. Logstash for OpenStack Log Management +Building a Directory Map With ELK +Structured logging with ELK - part 1 +Search for 😋 Emoji with Elasticsearch 🔎 +Complete Guide to the ELK Stack +logiq - Simple WebUI Monitoring Tool for Logstash ver. 5.0 and up +ElasticSearch Report Engine - An ElasticSearch plugin to return query results as either PDF,HTML or CSV. +Elasticsearch Glossary - explanations of Elasticsearch terminology, including examples, common best practices and troubleshooting guides for various issues. + + +Videos + +Overviews + + +Elasticsearch for logs and metrics: A deep dive – Velocity 2016 by Sematext Developers +Elasticsearch in action Thijs Feryn a beginner overview +Getting Down and Dirty with ElasticSearch by Clinton Gormley +How we scaled Raygun +Getting started with Elasticsearch +Speed is a Key: Elasticsearch under the Hood introduction + basic performance optimization +$$ Pluralsight: Getting Started With Elasticsearch for .NET Developers this course will introduce users to Elasticsearch, how it works, and how to use it with .NET projects. +$$ Complete Guide to Elasticsearch Comprehensive guide to Elasticsearch, the popular search engine built on Apache Lucene +How Elasticsearch powers the Guardian’s newsroom +Elasticsearch Query Editor in Grafana +Scale Your Metrics with Elasticsearch 2019 by Philipp Krenn (Elastic) optimization tips and tricks + + +Advanced + + +#bbuzz 2015: Adrien Grand – Algorithms and data-structures that power Lucene and Elasticsearch +Rafał Kuć - Running High Performance Fault-tolerant Elasticsearch Clusters on Docker and slides +Working with Elasticsearch - Search, Aggregate, Analyze, and Scale Large Volume Datastores - O’Reilly Media +End-to-end Recommender System with Spark and Elasticsearch by Nick Pentreath & Jean-François Puget. Slide deck + + +Code, configuration file samples and other gists + + +Elasticsearch config for a write-heavy cluster - reyjrar/elasticsearch.yml +chenryn/ESPL - Elastic Search Processing Language PEG parser sample for SPL to Elasticsearch DSL +thomaspatzke/EQUEL an Elasticsearch QUEry Language, based on G4 grammar parser + + +Who is using elasticsearch? + +Yelp , IFTTT , StackExchange , Raygun , Mozilla , Spotify , CERN , NASA Zalando + +I want more! (Elasticsearch related resources) + + +Technology Explained Blog +EagerElk +Tim Roes Blog + + +Contributing + + +Make sure you are about to post a valuable resource that belongs to this list +Do NOT group ++Add and –Remove changes in same PR. Make them separate pull requests +Use spellchecker +All spelling and grammar corrections are welcome (except for the rule above) +Fork this repo, do your edits, send the pull request +Feel free to create any new sections +Do not even try to add this repo to any awesome-awesome-* lists + + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/20WrapperQuery.md b/专栏/ElasticSearch知识体系详解/20WrapperQuery.md new file mode 100644 index 0000000..88a2e80 --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/20WrapperQuery.md @@ -0,0 +1,96 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 WrapperQuery + 实现方式理论基础 + + +Wrapper Query 官网说明 + + +https://www.elastic.co/guide/en/elasticsearch/reference/6.4/query-dsl-wrapper-query.html + + +This query is more useful in the context of the Java high-level REST client or transport client to also accept queries as json formatted string. In these cases queries can be specified as a json or yaml formatted string or as a query builder (which is a available in the Java high-level REST client). + + +GET /_search +{ + "query" : { + "wrapper": { + "query" : "eyJ0ZXJtIiA6IHsgInVzZXIiIDogIktpbWNoeSIgfX0=" // Base64 encoded string: {"term" : { "user" : "Kimchy" }} + } + } +} + + + +将DSL JSON语句 转成 map + + +https://blog.csdn.net/qq_41370896/article/details/83658948 + +String dsl = ""; +Map maps = (Map)JSON.parse(dsl); +maps.get("query");// dsl query string + + + +Java 代码 + + +https://blog.csdn.net/tcyzhyx/article/details/84566734 + +https://www.jianshu.com/p/216ca70d9e62 + +StringBuffer dsl = new StringBuffer(); +dsl.append("{\"bool\": {"); +dsl.append(" \"must\": ["); +dsl.append(" {"); +dsl.append(" \"term\": {"); +dsl.append(" \"mdid.keyword\": {"); +dsl.append(" \"value\": \"2fa9d41e1af460e0d47ce36ca8a98737\""); +dsl.append(" }"); +dsl.append(" }"); +dsl.append(" }"); +dsl.append(" ]"); +dsl.append(" }"); +dsl.append("}"); +WrapperQueryBuilder wqb = QueryBuilders.wrapperQuery(dsl.toString()); +SearchResponse searchResponse = client.prepareSearch(basicsysCodeManager.getYjzxYjxxIndex()) +.setTypes(basicsysCodeManager.getYjzxYjxxType()).setQuery(wqb).setSize(10).get(); +SearchHit[] hits = searchResponse.getHits().getHits(); +for(SearchHit hit : hits){ + String content = hit.getSourceAsString(); + System.out.println(content); +} + + + +query + agg 应该怎么写 + + +http://www.itkeyword.com/doc/1009692843717298639/wrapperquerybuilder-aggs-query-throwing-query-malformed-exception + +"{\"query\":{\"match_all\": {}},\"aggs\":{\"avg1\":{\"avg\":{\"field\":\"age\"}}}}" +SearchSourceBuilder ssb = new SearchSourceBuilder(); + +// add the query part +String query ="{\"match_all\": {}}"; +WrapperQueryBuilder wrapQB = new WrapperQueryBuilder(query); +ssb.query(wrapQB); + +// add the aggregation part +AvgBuilder avgAgg = AggregationBuilders.avg("avg1").field("age"); +ssb.aggregation(avgAgg); + + +实现示例 + +略 + + + + \ No newline at end of file diff --git a/专栏/ElasticSearch知识体系详解/21备份和迁移.md b/专栏/ElasticSearch知识体系详解/21备份和迁移.md new file mode 100644 index 0000000..1001aaf --- /dev/null +++ b/专栏/ElasticSearch知识体系详解/21备份和迁移.md @@ -0,0 +1,130 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 备份和迁移 + 方案 + +离线方案 + + +Snapshot +Reindex +Logstash +ElasticSearch-dump +ElasticSearch-Exporter + + +增量备份方案 + + +logstash + + +使用快照进行备份 + +配置信息 + +注册前要注意配置文件加上: elasticsearch.yml + +path.repo: ["/opt/elasticsearch/backup"] + + +创建仓库 + + +注册一个仓库,存放快照,记住,这里不是生成快照,只是注册一个仓库 + + +curl -XPUT 'http://10.11.60.5:9200/_snapshot/repo_backup_1' -H 'Content-Type: application/json' -d '{ + "type": "fs", + "settings": { + "location": "/opt/elasticsearch/backup", + "max_snapshot_bytes_per_sec": "20mb", + "max_restore_bytes_per_sec": "20mb", + "compress": true + } +}' + + +查看仓库信息: + +curl -XGET 'http://10.11.60.5:9200/_snapshot/repo_backup_1?pretty' + + +返回内容 + +[root@STOR-ES elasticsearch]# curl -XGET 'http://10.11.60.5:9200/_snapshot/repo_backup_1?pretty' +{ + "repo_backup_1" : { + "type" : "fs", + "settings" : { + "location" : "/opt/elasticsearch/backup", + "max_restore_bytes_per_sec" : "20mb", + "compress" : "true", + "max_snapshot_bytes_per_sec" : "20mb" + } + } +} + + +创建快照 + +curl -XPUT 'http://10.11.60.5:9200/_snapshot/repo_backup_1/snapshot_1?wait_for_completion=true&pretty' -H 'Content-Type: application/json' -d '{ + "indices": "bro-2019-09-14,bro-2019-09-15,wmi-2019-09-14,wmi-2019-09-15,syslog-2019-09-14,sylog-2019-09-15", + "rename_pattern": "bro_(.+)", + "rename_replacement": "dev_bro_$1", + "ignore_unavailable": true, + "include_global_state": true +}' + + +执行 + +{ + "snapshot" : { + "snapshot" : "snapshot_1", + "version_id" : 2040399, + "version" : "2.4.3", + "indices" : [ "bro-2019-09-14", "bro-2019-09-15", "wmi-2019-09-15", "syslog-2019-09-14", "wmi-2019-09-14" ], + "state" : "SUCCESS", + "start_time" : "2019-09-18T05:58:08.860Z", + "start_time_in_millis" : 1568786288860, + "end_time" : "2019-09-18T06:02:18.037Z", + "end_time_in_millis" : 1568786538037, + "duration_in_millis" : 249177, + "failures" : [ ], + "shards" : { + "total" : 25, + "failed" : 0, + "successful" : 25 + } + } +} + + +恢复数据 + +方案使用场景 + +迁移考虑的问题 + + +版本问题,从低版本到高版本数据的迁移 +多租户的适配问题 + + + +多个工厂的数据进入不同index, 原有的数据bro-2019-09-15的数据需要进入factorycode-bro-2019-09-15 + + + +多次或者分批迁移数据 +数据在迁移时候富化 +FieldMapping 和 数据信息 分离? + + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/01前言-教程内容导读.md b/专栏/Flutter入门教程/01前言-教程内容导读.md new file mode 100644 index 0000000..d9f31fe --- /dev/null +++ b/专栏/Flutter入门教程/01前言-教程内容导读.md @@ -0,0 +1,161 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 前言-教程内容导读 + 一、 教程的是什么,不是什么 + +本系列教程是完全面向 Flutter 新手朋友,即使没有任何编程基础,也可以观看。希望本册以最有趣和通俗的方式,来 迎接 你们走到 Flutter 新手村的第一站。就像不可能在小学一年级就教学生微积分,所以本册: + + +[1]. 不会 涉及复杂的算法和数据结构 +[2]. 不会 涉及系统全面的 Dart 语法介绍 +[3]. 不会 涉及项目组织、代码管理的思想 +[4]. 不会 涉及框架原理、源码的原理剖析 +[5]. 不会 涉及跨平台适配、插件编写知识 +[6]. 不会 涉及应用性能分析和优化 + + +不过这些知识点在你的编程之路上,总会在某处等待着你。对知识而言,每个阶段有不同的渴求,很多知识需要门槛和经验积累才能领悟,需要时间来沉淀,所以并不用急于求成。入门的第一站,别给自己太大的压力,玩好就行了,如果能有些自己的体悟,那就是锦上添花了。 + + + +本教程只是一个起点,我会尽可能通过有趣的例子,让你在最初的路途中不对编程产生不适。如果能燃起你的一丝兴趣,将是本教程的荣光。学完本课程,你将会: + + +[1]. 初步认知 Flutter 框架是什么,能干什么。 +[2]. 初步了解 最基础的 Dart 语法知识。 +[3]. 学会 通过常用组件构建出简单的界面。 +[4]. 学会 在 Flutter 项目中使用别人的依赖库。 +[5]. 初步掌握 Flutter 中数据的持久化手段。 +[6]. 学会 通过界面交互完成一些简单的功能逻辑。 + + + + +二、 教程的五大模块 + +本教程共 26 章,分为如下 5 大模块: + + + +1.Flutter 基础 + +第一个模块是 Flutter 最基础的前置知识准备阶段,包括环境搭建、Dart 基础语法介绍、计数器项目解读个三部分。如果已经开发过 Flutter 项目的朋友,可以选择跳过本模块,也可以温故知新。 + + + + + +2. 猜数字模块 + +第二个模块是猜数字项目,这是我设计的一个比较简单有趣的小案例,生成随机数后,在头部输入框猜数字。其中包含着 Flutter 最基础的知识点,比如基础组件的使用、界面的布局、逻辑的控制、动画的使用等。麻雀虽小五脏俱全,非常适合新手学习。 + + + + +生成随机数 +输入比较 + + + + + + + + + + + + + + +3.电子木鱼模块 + +第三个模块是电子木鱼项目,也是一个比较简单有趣的小案例,最主要的功能是点击图片发出木鱼的音效。另外支持功德记录的查看,以及音效、图片的选择。其中包含也着 Flutter 很多的知识点,比如基础组件的使用、状态类生命周期回调、依赖库的使用、本地资源配置等。 + + + + +点击木鱼 +查看功德记录 + + + + + + + + + + + + + + +4.白板绘制模块 + +第四个模块是白板绘制项目,用户可以通过手势交互在界面上绘制线条,交互性很强,也非常有趣;支持线颜色和线宽的选择,并可以回退上一步和撤销回退。其中包含也着 Flutter 很多的知识点,比如绘制的使用、手势监听器的使用、组件封装等。 + + + + +画板绘制 +回退和撤销 + + + + + + + + + + + + + + +5.项目整合 + +最后一部分将介绍如何将之前的一个个孤零零的界面,通过导航结构整合为一个项目。并了解如何在切换界面时,保活状态数据。这部分还会介绍数据的持久化存储,这样用户的选择项和一些记录数据就可以存储到本地,不会随着应用的退出而重置。最后,会介绍对网络数据的访问,完成下面文章展示页的小案例: + + + + +下拉刷新 +加载更多 + + + + + + + + + + + + + + +三、关于本教程的源码 + +项目源码在 github 上托管,项目名是 : flutter_first_station ,寓意是 Flutter 的第一站。 + + + +另外,源码是最终版效果,中间一步步的实现过程通过提交节点来查看,在文章相关位置会有对应节点的连接地址,访问即可看到当前步骤下的源码。 + + + +比如上面是 13 章介绍电子木鱼静态界面构建的章节,点击就会进入当前节点所处的源码位置: + + + +那废话不多说,一起开始本教程的旅程吧 ~ + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/02Flutter开发环境的搭建.md b/专栏/Flutter入门教程/02Flutter开发环境的搭建.md new file mode 100644 index 0000000..ad04dd5 --- /dev/null +++ b/专栏/Flutter入门教程/02Flutter开发环境的搭建.md @@ -0,0 +1,176 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 Flutter 开发环境的搭建 + 如果你已经有了 Flutter 的开发环境,可以跳过本篇。先声明一点: Flutter 虽然可以开发 Windows、Linux、Macos、Android、iOS 、web 六大主流平台的应用程序。但作为初学者,最好先在一端上学习 Flutter 的基础知识,不用过分追逐在每个平台上都跑一遍。 + +对于编程的新手朋友,我比较建议先在 Android 平台学习,首先 Android 真机设备或模拟器的门槛比较低,只需要一个 windows 电脑就可以了,而 iOS 应用需要 Mac 笔记本才能开发;其次,安卓的应用可以直接打包分享给别人用,iOS 则比较复杂;最后,移动端要比桌面端成熟一些,并不建议新手一开始从桌面端应用来学习 Flutter 。 + +当然,如果你以前是做 iOS 开发的,或者手上有 Mac 笔记本、iOS 真机,也可以选择通过 iOS 应用来学习。对于入门级别的 Flutter 知识来说,各个平台没有什么大的差异,所以不用过于纠结。 + +对于新手而言,开发环境搭建是一个非常大的坎,特别是跨平台的技术,涉及面比较广。我以前在 bilibili 发表过几个视频,介绍 Flutter 在各个平台开发应用的环境搭建。不想看文章或者是看文章无法理解的朋友,可以根据视频中的操作来搭建开发环境,尽可能降低门槛。 + + +(主要) Flutter 全平台开发环境 | SDK 和 开发工具 +(主要) Flutter 全平台开发环境 | Android 设备运行项目 +(选看) Flutter 全平台开发环境 | Window 平台桌面应用 +(选看) Flutter 全平台开发环境 | iOS/macOS 平台应用 + + +在介绍 Flutter 开发环境之前,先打个比方:如果说编写应用程序是在做一道 麻婆豆腐;Flutter 环境本身相当于 原材料 ,提供应用中需要的素材。但只有原材料是不足以做出一道菜的,还需要厨具进行烹调,厨具就相当于 IDE (集成开发环境),也就是编辑代码和调试的工具。最后,才是盛到盘子里,给用户品尝。 + + + +1. FlutterSDK 的下载与安装 + +对于 Flutter 本身而言,最重要的是下载 FlutterSDK ,地址如下: + + +docs.flutter.dev/development… + + +根据计算机的操作系统,选择最新稳定版的文件,比如现在最新版是 3.7.10 ,点击一下版本号就下载压缩包。这里以 Windows 操作系统为例: + + + +下载完后,解压到一个文件夹下: + + + +为了可以在计算机的任何地方都可以访问 flutter 提供的可执行文件,一般都会将 flutter\bin 文件夹路径配置到 Path 环境变量中。如下所示,红框中的路径和上一步解压的路径有关: +注: 如果不会配置环境变量,可以参考上面第一个视频中的操作,或自己搜索解决。 + + + +另外,由于网络原因,可能国外网站的依赖难以下载,可以顺便在系统变量中配置官方提供的国内镜像,第一个视频中也有介绍操作方式。 + +PUB_HOSTED_URL=https://pub.flutter-io.cn +FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn + + + + +如果在命令行中执行 flutter --version,能给出结果,说明 Flutter 环境没有问题。这时烹饪的材料就已经准备好了,接下来看看怎么拿到厨具。 + + + + + +2. IDE 开发工具的准备 + +对于一道菜来说,用什么厨具并不重要,重要的是如何把菜做好。有人喜欢用大锅,有人喜欢用小锅,个人偏好没有什么值得争吵的,工具最重要的是自己用着 趁手。 对于开发工具而言,我个人比较推荐 AndroidStudio ,因为: + + + +AndroidStudio 的调试功能非常强大,也方便查阅源码 +AndroidStudio 方便管理和下载 AndroidSDK,可以创建安卓模拟器 +AndroidStudio 是 Android 的官方开发工具,对 Android 开发比较友好 + + + +如果不喜欢 AndroidStudio,也可以自己选择其他的开发工具,比如 VSCode 等 + + + +AndroidStudio 下载地址如下: + + +developer.android.google.cn/studio + + + + +下载完后运行安装包,一直下一步即可。首次安装时,会引导你下载 AndroidSDK,在接受之后进行下载: + + + + + +最后,在 Settings/Plugins 中安装 Dart 和 Flutter 插件,即可完成开发工具的准备工作。如果有什么不清楚的,可以参考上面的第一个视频。 + + + + + + + +3. 创建 Flutter 项目 + +安装完插件之后,重启 AndroidStudio,在新建项目时会有创建 Flutter 项目的选项。红框中选择 Flutter SDK 的路径位置: + + + +在下一步中,填写应用的基本信息: + + + +然后,就会创建出 Flutter 默认的计数器项目,这个小项目将在下一节进行分析。接下来看一下如何将项目运行到 Android 设备中。 + + + + + +4. Android 模拟器创建与运行项目 + +Flutter 全平台开发环境 | Android 设备运行项目 视频中介绍了 Android 模拟器的创建过程,这里简单说一下要点。首先,最好在环境变量中添加一个 ANDROID_HOME 的环境变量,值为 Android SDK 下载的目录: + + + +在 AndroidStudio 上栏图标中找到如下设备管理器,点击 Create device 创建设备: + + + +选择一个你觉得合适的手机尺寸: + + + +另外最好在 New Hardware Profile 中将 RAM 调大一些,否则可能在运行时内存不足,程序安装不上: + + + +然后选择下载镜像,一般都选最高版本: + + + +最后,模拟器创建完成,点击运行: + + + + + +要将项目运行到手机中,点击上面菜单栏的小三角按钮即可: + + + +项目运行之后,可以看到模拟器上展示了一个计数器应用,随着点击右下角的按钮,中间的数字会进行自加。这就是默认的计数器项目。 + + + + +项目运行 +点击加号 + + + + + + + + + + +到这里 Flutter 的开发环境就已经搭建完成,其他平台应用的开发环境基本上类似,如有需要可以参考视频中的操作。本篇到这里就告一段落,巧妇难为无米之炊,在介绍计数器项目之前,有必要先简单了解一下 Dart 的基础语法,你才有使用代码完成逻辑的能力。 + + + +5.本章小结 + +到这里 Flutter 的开发环境就已经搭建完成,这里主要以 Android 应用开发的视角对 Flutter 框架进行学习。开发其他平台应用的开发环境基本上类似,如有需要可以参考视频中的操作。对于初学者而言,建议在起初专注在某一个平台中,学习 Flutter 基础知识,这些基础知识是全平台通用的。 + +本篇到这里就告一段落,如果把应用开发比作烹饪一席晚宴,那么环境搭建就相当于准备炊具和食材。其中编辑器是应用开发的炊具; Flutter SDK 中提供的 Dart 语法、类型体系就是食材。正所谓巧妇难为无米之炊,在介绍计数器项目之前,有必要先简单了解一下 Dart 的基础语法,你才有使用代码完成逻辑的能力。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/03新手村基础Dart语法(上).md b/专栏/Flutter入门教程/03新手村基础Dart语法(上).md new file mode 100644 index 0000000..d84b495 --- /dev/null +++ b/专栏/Flutter入门教程/03新手村基础Dart语法(上).md @@ -0,0 +1,477 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 新手村基础 Dart 语法 (上) + 无论你处于编程的任何阶段,都应该铭记一点: + + +对于计算机编程而言,最重要的是 维护数据 的变化。 + + +比如你支付宝中的账户金额;你微信聊天记录的内容;你游戏中的资源装备;你随手拍的一张照片、录制的一段意义非凡的视频,都是数据在计算机中的不同表现形式。作为编程者要通过代码逻辑,保证数据在 用户与应用交互 过程中的准确性,来完成功能需求。 + +本教程只会介绍最最基础的 Dart 语法,能支撑后续教程的内容即可。无论是自然语言,还是编程语言,语法的学习都是非常枯燥的。对于新手而言,不必任何细节都面面俱到,正所谓贪多嚼不烂。学习应该循序渐进、细水长流,先自己能站立了,再想跑的事。如果已经有 Dart 基础的朋友,本章简单过一下就行了,也可能温故而知新哦 ~ + + + +一、基础数据类型 + +先思考一下,在现实世界中,我们如何表示不同类别的事物? 其实很简单,就是根据事物的特征进行划分,然后 取名字 进行表示。当这种表示形式在全人类中达到共识,就会成为一个认知标准,比如下面是一只猫: + + + +而提到猫,自然就会想到它体型不大,有两个眼睛,两个耳朵,四个腿,脸上有胡须,这就是类型的特征。而 猫 这个名字本身只是一个标志,用于对一类事物的指代。虽然不同的地域可能有不同的叫法,但重要的其实不是它叫什么,而是它是什么,能干什么。 + + + +在编程中,数据类型也是类似,它的存在是为了解决: + + +如何在代码中 表示数据种类 的问题。 + + +解决方法也是一样: 取名字 —- 通过名称区分不同类型数据的特点。在任何编程语言中有 内置基础类型 以供使用,下面来简单看一下 Dart 中基础数据类型变量的定义。到这里,先简单认识一下四种数据类型: +数字、文字是人们日常生活在接触最多的数据,对于任何编程语言来说都是至关重要的。布尔型本身非常简单,只能表示真或假,对逻辑判断有着重要的意义。 + + + + + +1. 数字类型 + +在日常生活在,数字 是非常必要的,它可以描述数量,也可以进行数学运算。在 Dart 语言中,数字有两大类型: 整型(整数) int 和浮点型(小数) double 。定义变量的语法是 类型名 变量名 = 值 ,下面代码中定义了两个变量: + +void main(){ + // 整型 age,表示年龄的数值 + int age = 2; + // 浮点型 weight,表示体重的数值 + double weight = 4.5; +} + + +在这里学习 Dart 基础知识时,可以在项目的 test 文件夹里,编辑代码做测试。点击三角可以运行代码: + + + + + +现在通过一个小练习,认识一下代码如何进行简单的逻辑运算: + + +定义三个浮点型变量 a,b,c,值分别是 2.3, 4.5, 2.5 ; +通过代码计算出平均值 avg ;并将结果输出到控制台。 + + +代码如下,数字量之间可以进行四则运算,求平均值就是将数字相加,除以个数。通过 print 函数可以在控制台输出量的信息: + +void main(){ + double a = 2.3; + double b = 4.5; + double c = 2.5; + + double avg = (a+b+c)/3; + print(avg); +} + + +运行后,可以看到计算的结果。比如 a,b,c 变量分别代表三只小猫的体重,那这段代码就是:解决获得平均体重问题的一个方案。代码本身的意义在于 解决问题, 脱离问题需求而写代码,就像是在和空气斗智斗勇。 + + + + + +2. 字符串类型 + +在日常生活中,除了数字之外,最重要的就是 文字,在编程中称之为 字符串。 文字可以为我们提供信息,如何通过代码来表示和操作它,是一件非常重要的事。 +在 Dart 中,通过 String 类型表示字符串,字符串值可以通过单引号 'str' 或 双引号 "str" 进行包裹,一般情况下,两者没有太大的区别,用自己喜欢的即可。字符串直接可以通过 + 号进行连接,如下所示: + +void main() { + String hello1 = 'Hello, World!'; + String hello2 = "Hello, Flutter!"; + print(hello1 + hello2); +} + +---->[输出结果]---- +Hello, World!Hello, Flutter! + + + + +通过 $变量名 可以在字符串内插入变量值。如下所示,将计算平均值的输出表述进行了完善: + +void main() { + double a = 2.3; + double b = 4.5; + double c = 2.5; + + double avg = (a + b + c) / 3; + String output = '$a,$b,$c 的平均值是$avg'; + print(output); +} + +---->[输出结果]---- +2.3,4.5,2.5 的平均值是3.1 + + +另外,也可以通过 ${表达式} 来嵌入表达式,比如: + + String output = '$a,$b,$c 的平均值是${(a + b + c) / 3}'; + + +也可以理解为变量是特殊的表达式,在插入时可以省略 {}。对于字符串来说,先了解如何定义和使用就行了,以后如有需要,可以在其他资料中系统学习。 + + + +3. 布尔型 bool + +布尔型用于表示真假,其中只有 true 和 false 两个值,一般用于判断的标识。布尔值可以直接书写,可以通过一些运算得到。比如数字的比较、布尔值的逻辑运算等。 + +void main() { + // 直接赋值 + bool enable = true; + double height = 1.18; + // 布尔值可以通过运算获得 + bool free = height < 1.2; +} + + + + +二、运算符 + +运算符可以和值进行连接,进行特定运算,产出结果;比如加减乘除,大小比较,逻辑运算等。可以说运算符是代码逻辑的半壁江山。对于初学者而言,先掌握下面的三类运算符: + + + + + +1. 算数运算符 + +算数运算符作为数学的基础运算,从小就陪伴着我们,大家应该不会感到陌生。它连接两个数字进行运算,返回数值结果: + + + +void main() { + print(1 + 2);//3 加 + print(1 - 2);//-1 减 + print(1 * 2);//2 乘 + print(1 / 2);//0.5 除 + print(10 % 3);//1 余 + print(10 ~/ 3);//3 商 +} + + + + +2.比较运算符 + +比较运算符,也是日常生活中极为常见的。它连接两个值进行运算,返回比较的 bool 值结果 : + + + +void main() { + print(1 > 2); //false 大于 + print(1 < 2); //true 小于 + print(1 == 2); //false 等于 + print(1 != 2); //true 不等 + print(10 >= 3); //true 大于等于 + print(10 <= 3); //false 小于等于 +} + + + + +3.逻辑运算符 + +逻辑运算符,用于连接 bool 值进行运算,返回 bool 值。 理解起来也很简单,&& 表示两个 bool 值都为 true,才返回 true ; || 表示两个 bool 值有一个是 true,就返回 true ; ! 之后连接一个 bool 值,返回与之相反的值。 + + + +如下代码所示,open 和 free 是两个 bool 值,表示条件。通过 && 运算得到的 bool 值表示需要同时满足这两个条件,即 免费进入 需要公园开放,并且可以免费进入: + +void main() { + // 公园是否开放 + bool open = true; + // 是否免费 + bool free = false; + + // 公园是否免费进入 + bool freeEnter = open && free; +} + + + + +三、流程控制 + +如果是运算符是代码逻辑的半壁江山,那流程控制 就是另外一半。流程控制可以分为 条件控制 和 循环控制;其中: + + +条件控制 可以通过逻辑判断的语法规则,执行特定的分支代码块。 +循环控制 可以让某个代码块执行若干次,直到符合某些条件节点结束。 + + + + +1. 条件流程 : if - else + +如果怎么样,就做什么,这种选择执行的场景,在日常生活中非常常见。而 if - else 就是处理这种选择分支的语法。 if 之后的 () 中填入布尔值,用于逻辑判断;条件成立时,执行 if 代码块;条件不成立,执行 else 代码块。如下代码根据 free 布尔值,打印是否可以免费入园: + +void main() { + double height = 1.18; + // 布尔值可以通过运算获得 + bool free = height < 1.2; + if(free){ + print("可免费入园"); + }else{ + print("请购买门票"); + } +} + + + + +2. 条件控制 : switch - case + +if - else 只能对 bool 值进行逻辑判断,进行分支处理。某些场景中需要对更多类型的值进行判断,这时就可以使用 switch - case 进行分支处理。 +如下代码所示,根据字母等级,打印对应的评级信息,其中 switch 后面的括号在是校验值, case 之后是同类型的值,当 case 后的值和校验值一致时,会触发其下的分支逻辑。 default 分支用于处理无法匹配的场合。 + +void main() { + String mark = 'A'; + switch (mark) { + case 'A': + print("优秀"); + break; + case 'B': + print("良好"); + break; + case 'C': + print("普通"); + break; + case 'D': + print("较差"); + break; + case 'E': + print("极差"); + break; + default: + print("未知等级"); + } +} + + + + +3. 循环流程 - for 循环 + +有些情况下,我们需要不断执行某一段逻辑(循环体),直到条件完成(循环条件),这就是循环控制。 +for 循环中,() 里有三个表达式,通过 ; 隔开, + + +第一个表达式是进入循环之前执行的语句,在循环过程中不会再次执行; +第二个表达式是循环条件,每次循环体执行完毕,都会校验一次。当条件满足时,会执行下次循环。 +第三个表达式在每次循环体执行完毕后,都会执行一次。 + + +如下代码的含义就是:在循环开始时定义 i 整数变量,赋值为 0, 在 i < 5 的条件下,执行循环体;每次循环体执行完毕后,让 i 增加 1 。 循环体中对 i 值进行累加,并且打印信息: + +void main() { + int sum = 0; + for (int i = 0; i < 5; i = i + 1) { + sum = sum + i; + print("第 $i 次执行,sum = $sum"); + } +} + +---->[输出结果]---- +第 0 次执行,sum = 0 +第 1 次执行,sum = 1 +第 2 次执行,sum = 3 +第 3 次执行,sum = 6 +第 4 次执行,sum = 10 + + +注: i = i + 1 可以简写为 i += 1, 其他的算数运算符也都有这种简写形式。 +另外,表示整数 i 自加 1 ,也可以简写为 i++ 或 ++i。两者的区别在于对 i 赋值的先后性, 自减 1 同理。如果初学者觉得简写难看懂,可以不用简写。 + +---->[情况1:i++]---- +int i = 3; +int a = i++; //执行赋值后i才自加,故a=3 +print('a=$a,i=$i');//a=3,i=4 + +---->[情况2:++i]---- +int i = 3; +int a = ++i; //执行赋值前i已经自加,故a=4 +print('a=$a,i=$i');//a=4,i=4 + + + + +4. 循环流程 - while 循环 + +for 循环和 while 循环并没有什么本质上的区别,只是形式上的不同,两者可以进行相互转换。下面是类比 for 循环中的三块,将上面的代码转换为 while 循环。 + + + +可能有人会问,既然 while 循环和 for 循环可以相互转化,那为什么不干掉一个呢? 就像菜刀和美工刀虽然都可以完成切割的任务,但不同的场景下会有更适合的工具。 while 只需要关注循环条件,在某些场合下更简洁,语义也很不错; for 循环的固定格式,更便于阅读,可以一眼看出循环的相关信息。 + + + +另外还有 do - while 循环,算是 while 的变式。 do 代码块中是循环体,while 后依然是条件。 do - while 循环的最大特点是: 先执行循环体,再校验条件。也就是说,它的循环体必然会被执行一次。 + +void main() { + int sum = 0; + int i = 0; + do{ + sum += i; + print("第 $i 次执行,sum = $sum"); + i = i + 1; + } while (i < 5); +} + + +根据不同的场景,可以选择不同的形式。 但千万别被形式整的晕头转向,记住一点:它们在本质上并没有区别,都是控制条件,执行循环体。而且它们之间都可以进行转换。 + + + +5. 中断控制 - break 和 continue + +在循环流程中,除了循环条件可以终止循环,还可以通过其他关键字来中断循环。 + + +break 关键字: 直接跳出循环,让循环终止。 +continue 关键字:跳出本次循环,将进入下次循环。 + + +void main() { + // ---->[break情景]---- + for (int i = 0; i < 10; i++) { + if (i % 3 == 2) { + break; //直接跳出循环 + } + print("i:$i"); //打印了 0,1 + } + + // ---->[continue情景]---- + for (int i = 0; i < 10; i++) { + if (i % 3 == 2) { + continue; //跳出本次循环,将进入下次循环 + } + print("i:$i"); //打印了 0,1,3,4,6,7,9 + } +} + + + + +四、函数的定义和使用 + +函数是一段可以有输入和输出的逻辑单元;通过函数,可以将特定的算法进行封装,简化使用。这里,我们通过一个实际的问题场景来介绍函数: + + +身体质量指数,是BMI(Body Mass Index)指数,简称体质指数,是国际上常用的衡量人体胖瘦程度以及是否健康的一个标准。 + + +对于一个人来说,只要知道自己的身高和体重,就可以通过公式 : BMI=体重÷身高^2 计算出体质指数。如果每次计算时,都要根据公式计算是非常麻烦的。 + +void main() { + double toly = 1.8 / (70 * 70); + double ls = 1.79 / (65 * 65); + double wy = 1.69 / (50 * 50); +} + + +另外,如果某天体质指数 的计算公式改变了,那散落在代码中各处的直接计算都需要修改,这无疑是非常麻烦的。 函数的价值就在于封装算法,下面来体会一下函数的价值。 + + + +1. 函数的简单定义 + +函数可以有若干个输入值,和一个输出值。比如对于计算体质指数 来说,有身高 height 和体重 weight 两个小数。输出是 体质指数 的具体值。如下所示,定义 bmi 函数:函数名前是返回值类型;之后的括号中是参数列表,通过 , 号隔开,每个参数由 参数类型 参数名 构成;{} 中是函数的具体算法逻辑: + +double bmi(double height, double wight) { + // 具体算法 + double result = wight / (height * height); + return result; +} + + + + +需要获取一个人的 体质指数 ,只要调用 bmi 函数,传入身高和体重即可。使用者不需要在意公式具体是什么,如果某天公式改变了,只需要修改 bmi 中的算法实现,其他代码都无需改动。所以,函数的价值不仅在于实现算法逻辑,也能为某个功能提供一个公共的接入口,以应对可能改变的风险。 + +void main() { + double toly = bmi(1.8, 70); + double ls = bmi(1.79, 65); + double wy = bmi(1.69, 50); + + print("===toly:$toly===ls:$ls===wy:$wy==="); +} + + + + +2. 命名参数 + +有些时候,函数的参数过多,在调用时需要记清顺序,是比较麻烦的。Dart 中支持命名参数,可以通过参数的名称来传参,不需要在意入参的顺序。通过 {} 包裹命名的参数,其中 required 关键字表示该入参必须传入; 另外,可以用 = 提供参数的默认值,使用者在调用时可以选填: + +double bmi({ + required double height, + double weight = 65, +}) { + // 具体算法 + double result = weight / (height * height); + return result; +} + + +如下所示,在使用时可以通过 weight 和 height 指定参数,参数的顺序是无所谓的;另外由于 weight 有默认值,在调用时可以不填,计算时会取默认值: + +void main() { + double toly = bmi(weight: 70, height: 1.8); + double ls = bmi(height: 1.79); + double wy = bmi(height: 1.69, weight: 50); +} + + + + +3. 位置参数 + +方括号 [] 包围参数列表,位置参数可以给默认值: + +double bmi([double height = 1.79, double weight = 65]) { + // 具体算法 + double result = weight / (height * height); + return result; +} + + +在使用时必须要按照参数顺序传入,它和普通参数列表的区别在于:在调用时,可以省略若干个参数,省略的参数使用默认值: + +void main() { + double toly = bmi(70, 1.8); + double ls = bmi(); + double wy = bmi(1.69); +} + + +函数是一个非常有用的语法功能,在一个函数中可以调用其他函数,来组织逻辑。上面的 bmi 函数其实只是将一行代码的运算进行封装,已经显现出了很大的价值,更不用说能完成某些特定功能的复杂函数。但封装也很容易造成只会使用,不懂内部实现逻辑的局面,这在即使好事(不用在意底层逻辑,专注上层使用),也是坏事(底层逻辑有问题,难以自己修复)。 + + +这里给个小作业: 找一个计算公式,通过函数来封装对它的调用 + + + + +五、本章小结 + +到这里,对于编程来说最最基础的三个模块: 基本数据类型、运算符、流程控制,就有了基本的认识。这些语法点是代码逻辑实现的基石,对烹饪来说,相当于水、火、面、米、油、盐等最基础的材料,对一顿饭来说不可或缺。 + +基本的食材可以保证能吃饱,但对于一席盛大的晚宴来说,是远远不够的:酱醋茶酒、鸡鸭鱼肉,是更高等的食物素材。函数、对象可以对代码逻辑进行封装,从而更好地维护代码结构。下一篇,将继续介绍 Dart 基础语法,了解面向对象的相关知识。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/04新手村基础Dart语法(下).md b/专栏/Flutter入门教程/04新手村基础Dart语法(下).md new file mode 100644 index 0000000..fc605f4 --- /dev/null +++ b/专栏/Flutter入门教程/04新手村基础Dart语法(下).md @@ -0,0 +1,458 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 新手村基础 Dart 语法 (下) + 一、面向对象 + +在学习的时候我们要明确一点,语言是一种工具,语法特性是为了方便我们实现需求的,不是故意制造出来刁难我们的。任何语法点的存在,都有它的价值和独特性。面向对象是 Dart 语法体系中非常重要的一支,也是一个威力无穷的工具。对于初学者而言,很难对其有深刻的认知,需要你在实践中累积与体悟。这一小结,将介绍一下面向对象最基础的语法点。 + + + +1. 自定义数据类型 + +上一篇说了,Dart 中有一些基础的数据类型。但有些场景下,只用基础的数据类型,解决问题是非常麻烦的。比如,现在要记录人身高体重数据信息。 +编程的本质是对数据的维护,而 身高、体重、姓名 三个数据是成对存在的,可以将其视为一个事物的三个属性,而在代码中如何表示有若干属性的某个事物呢? 答案就是 自定义数据类型,也就是人们常说的面向对象思想。 + +如下所示,通过 class 关键字来定义一个类型,{} 内是类的具体内容;其中可以定义若干个属性,也称之为 成员属性 。 + +class Human { + String name = ''; + double weight = 0; + double height = 0; +} + + +下面 tag1 处表示定义了一个 Human 类型的 toly 对象,将其赋值为 Human() ; 其中 Human() 表示通过构造函数来创建对象,也可以称之为 实例化 Human 对象 。 通过 对象.属性 =可以对属性进行赋值; 通过 对象.属性 可以访问属性对应的数据。 + + + +void main(){ + Human toly = Human(); // tag1 + toly.name = "捷特"; + toly.weight = 70; + toly.height = 180; + + print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}"); +} + + +这就是最简单的面向对象思想:通过一个自定义类型,定义若干个属性;通过实例化对象,来维护属性数据。 + + + +2. 构造函数 + +构造函数本身也是一个函数,它的价值在于:实例化对象时,可以对属性进行初始化。如下所示,构造函数的函数名和类名相同;参数列表就是函数的参数列表语法,不再赘述,这里是普通的参数列表传递参数;在实例化对象时,会触发函数体的逻辑,对属性进行赋值,这就是通过构造函数初始化成员属性。 + +注: 当入参名称和成员属性名称相同时,使用 this 关键字表示当前对象,对属性进行操作,从而避免歧义。 + +class Human { + String name = ''; + double weight = 0; + double height = 0; + + Human(String name,double weight,double height){ + this.name = name; + this.weight = weight; + this.height = height; + } +} + + +这样就可以在实例化对象时,传入参数,为成员属性进行赋值: + +void main(){ + Human toly = Human("捷特",70,180); + print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}"); +} + + + + +另外,构造函数中,通过 this 对象进行赋值的操作,可以进行简化书写,如下所示: + +class Human { + // 略同... + + Human(this.name,this.weight,this.height); +} + + + +小作业: 自己可以练习一下,构造方法中 命名传参{} 和 位置传参 [] + + + + +3. 成员函数(方法) + +自定义类型中,不仅可以定义成员属性,也可以定义成员函数。一般来说,在面向对象的语言中,我们习惯于称类中的函数为 方法 。姓名通过一个小例子,来体会一下成员方法的价值: + +如下所示,创建了三个 Human 对象,并且打印了他们的信息。可以看到 print 里面的信息格式基本一致,只是对象不同而已。每次都写一坨,非常繁琐。 + + + +void main(){ + Human toly = Human("捷特",70,180); + print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}"); + + Human ls = Human("龙少",65,179); + print("Human: name{${ls.name},weight:${ls.weight}kg,height:${ls.height}cm}"); + + Human wy = Human("巫缨",65,179); + print("Human: name{${wy.name},weight:${wy.weight}kg,height:${wy.height}cm}"); +} + + + + +我们可以定义一个成员方法,来处理介绍信息的获取工作:在成员方法中可以访问成员属性,这就相当于通过函数给出一个公共的访问入口,任何该类对象都可以通过 .info() 获取信息。如下所示,在使用时就会非常简洁和方便: + + + + +小作业: 为 Human 类型添加一个 bmi 方法,用于计算 体质指数 。 + + + + +4. 类的继承 + +比如要记录的信息针对于学生,需要了解学生的学校信息,同时也可以基于身高体重计算 bmi 值。在已经有 Human 类型的基础上,可以使用关键字 extends,通过继承来派生类型。 + +在 Student 类中可以定义额外的成员属性 school, 另外 super.name 语法是:在入参中为父类中的成员赋值。 + +class Student extends Human { + final String school; + + Student( + super.name, + super.weight, + super.height, { + required this.school, + }); +} + + + + +这样就可以通过 Student 来创建对象,通过继承可以访问父类的方法,如下所示,Student 对象也可以使用 bmi 方法获取 体质指数 : + +void main() { + Student toly = Student("捷特", 70, 180,school: "安徽建筑大学"); + print(toly.bmi()); +} + + + + +5. 子类覆写父类方法 + +当子类中存在和父类同名的方法时,就称 子类覆写了父类的方法 ,在对象调用方法时,会优先使用子类方法,子类没有该方法时,才会触发父类方法。比如下面的代码,子类中也定义了 info 方法,在程序运行时如下: + + + +注: 通过 super. 可调用父类方法; 一般子类覆写方法时,加 @override 注解进行示意 (非强制) + +class Student extends Human { + + // 略同... + + @override + String info() { + String info = super.info() + "school: $school "; + return info; + } + +} + +void main() { + Student toly = Student("捷特", 70, 180,school: "安徽建筑大学"); + print(toly.bmi()); + print(toly.info()); +} + + +对于初学者而言,面向对象的知识了解到这里就差不多了。这里介绍的是基础中的基础,随着知识的累计,未来肯定会接触到更多其他的知识。 + + + +二、聚合类型 + +日常生活中,还有一类数据总是批量呈现的:比如一个班级里有很多学生,一个英文字典有很多对应关系,围棋盘中有很多点位。如何对结构相似的批量数据进行维护,也是编程中非常重要的事。可以称这样的数据为 聚合类型 或 容器类型 。在 Dart 中,有三个最常用的聚合类型,分别是 列表 List、 映射 Map 和 集合 Set : + + + +对于聚合类型而言,本质上是 Dart 语言提供的内置自定义类型,也就是说他们也是通过 class 定义的,其中有成员属性,也有成员方法。我们在一开始并不能对所有的成员方法进行面面俱到的讲解,只会对元素的添加、修改、访问、删除进行介绍,了解最基础的使用。 + + + +1. 列表 List + +列表类型中可以盛放若干个同类型的对象,并且允许重复。在声明列表对象时,其中盛放的对象类型放在 <> 中,我们称之为 泛型 。如下定义 int 泛型的列表,就表示列表中只能盛放整数数据;可以通过 [] 便捷的创建列表对象,其中盛放初始的数据: + +List numList = [1,9,9,4,3,2,8]; + + +我们一般称元素在列表中的位置为 索引 , 索引从 0 开始计数。通过索引可以对索引处的值进行获取和修改的操作,代码如下 : + +List numList = [1,9,9,4,3,2,8]; +int second = numList[1]; +print(second); +numList[3] = 6; +print(numList); + +---->[控制台输出]---- +9 +[1, 9, 9, 6, 3, 2, 8] + + + + +通过 add 方法,可以在列表的末尾添加一个元素;insert 方法,可以在指定的索引处插入一个元素: + +List numList = [1,9,9,4,3,2,8]; +numList.add(10); +numList.insert(0,49); +print(numList); + +---->[控制台输出]---- +[49, 1, 9, 9, 4, 3, 2, 8, 10] + + + + +列表方法中 remove 相关方法用于移除元素,比如 removeAt 移除指定索引处的元素;remove 移除某个元素值; removeLast 移除最后元素: + +List numList = [1,9,9,4,3,2,8]; +numList.removeAt(2); +numList.remove(3); +numList.removeLast(); +print(numList); + +---->[控制台输出]---- +[1, 9, 4, 2] + + + + +对于聚合型的对象来说,还有一个比较重要的操作,就是如何遍历访问其中的元素。通过 .length 可以得到列表的长度,所以自然可以想到使用 for 循环,让索引自加,就能依次输出对应索引的值: + +List numList = [1, 9, 9, 4]; +for (int i = 0; i < numList.length; i++) { + int value = numList[i]; + print("索引:$i, 元素值:$value"); +} + +---->[控制台输出]---- +索引:0, 元素值:1 +索引:1, 元素值:9 +索引:2, 元素值:9 +索引:3, 元素值:4 + + +如果遍历过程中,不需要索引信息,也可以通过 for-in 循环的语法,方便地遍历列表中的值: + +for(int value in numList){ + print("元素值:$value"); +} + +---->[控制台输出]---- +元素值:1 +元素值:9 +元素值:9 +元素值:4 + + + + +2. 集合 Set + +集合类型也可以盛放若干个同类型的对象,它最大的区别是 不允许重复 ,它同样也支持一个泛型。如下定义 int 泛型的集合,就表示列表中只能盛放整数数据;可以通过 {} 便捷的创建集合对象,其中盛放初始的数据。 +如下所示,当创建的集合在存在重复元素,将被自动合并,在输出时只有一个 9 元素: + +Set numSet = {1, 9, 9, 4}; +print(numSet); + +---->[控制台输出]---- +{1, 9, 4} + + +集合本身是没有索引概念的,所以无法通过索引来访问和修改元素,因为集合本身在数学上的概念就是无序的。它可以通过 add 方法在集合中添加元素;以及 remove 方法移除某个元素值: + +Set numSet = {1, 9, 4}; +numSet.add(10); +print(numSet); + +---->[控制台输出]---- +{1, 4, 10} + + + + +集合最重要的特征是可以进行集合间的运算,这点 List 列表是无法做到的。两个集合间通过 difference、union 、intersection 方法可以分别计算差集、并集、交集。计算的结果也是一个集合: + +Set a = {1, 9, 4}; +Set b = {1, 9, 3}; +print(a.difference(b));// 差集 +print(a.union(b)); // 并集 +print(a.intersection(b)); // 交集 + +---->[控制台输出]---- +{4} +{1, 9, 4, 3} +{1, 9} + + + + +由于集合没有索引概念,使用无法像 List 那样通过 for 循环增加索引来访问元素;但可以通过 for-in 循环来遍历元素值: + +Set numSet = {1, 9, 4}; +for(int value in numSet){ + print("元素值:$value"); +} + +---->[控制台输出]---- +元素值:1 +元素值:9 +元素值:4 + + + + +3. 映射 Map + +地图上的一个点,和现实中的移除位置一一对应,这种就是映射关系。地图上的点可以称为 键 key ,实际位置称为 值 value ; Map 就是维护若干个键值对的数据类型。 日常生活中有很多映射关系,比如字典中的字和对应释义、书目录中的标题和对应的页数、钥匙和对应的锁等。 +应用映射中的一个元素记录着两个对象,所以 Map 类型有两个泛型,分别表示 key 的类型和 value 的类型。如下所示,定义一个 Map 的映射对象,其中维护数字和英文单词;remove 方法可以根据 key 移除元素: + +Map numMap = { + 0: 'zero', + 1: 'one', + 2: 'two', +}; +print(numMap); +numMap.remove(1); +print(numMap); + +---->[控制台输出]---- +{0: zero, 1: one, 2: two} +{0: zero, 2: two} + + + + +通过 [key] = value 语法可以向映射中添加元素,如果 key 已经存在,这个行为就是修改对应的值: + +Map numMap = { + 0: 'zero', + 1: 'one', + 2: 'two', +}; +numMap[3] = 'three'; +numMap[4] = 'four'; +print(numMap); + +---->[控制台输出]---- +{0: zero, 1: one, 2: two, 3: three, 4: four} + + + + +对于映射来说,可以通过 forEach 方法来遍历元素值: + +Map numMap = { + 0: 'zero', + 1: 'one', + 2: 'two', +}; +numMap.forEach((key, value) { + print("${key} = $value"); +}); + +---->[控制台输出]---- +0 = zero +1 = one +2 = two + + + + +三、 语言特性 + +Dart 中有一些特殊的语言特性,比如空安全、异步等知识。这里简单介绍一下,能满足本教程的使用即可。 + +1. 空安全 + +Dart 是一个空安全的语言,也就是说,你无法将一个非空类型对象值设为 null : + + + +如果希望对象可以赋值为 null ,需要在类型后加上 ? 表示可空: + + + +这样,如果一个函数中是 String 入参,那么函数体内的 word 对象就必定不为 null ,这样就可以在编码时明确对象的可空性,做到被访问对象的空安全。 + + + +如果希望在调用时可以传入 null ,入参类型就是 String? ,那么在函数体内访问可空对象时,也在编码阶段给出警告示意。如果没有空安全的支持,编码期间就很难确定 String 对象是否可空,从而 null 调用方法的异常只能在运行时暴露;有了空安全机制,在编码期间就可以杜绝一些空对象调用方法导致的异常。 + + + + + +2. 异步任务 + +关于异步是一个很大的话题,这里只简单介绍一下用法。想要更深入了解,可以研读我在掘金发表过一个专栏 《Flutter 知识进阶 - 异步编程》 + + + +异步任务可以在未完成之前,让程序继续执行其他的逻辑,在完成之后接收到通知。拿最常见的文件读取异步任务来说:如下 test 函数中,使用 readAsString 异步方法读取一个文件,通过 then 监听对调,回调中的参数就是读取的字符串。 + +此时下面的 做些其他的事 将会在读取完毕之前触发。也就是说:一个任务没有完成,第二个任务可以进行,这就是异步。就像烧水和扫地两个任务可以同时进行。 + +String path = r'E:\Projects\Flutter\flutter_first_station\pubspec.yaml'; +File file = File(path); +print("开始读取"); +file.readAsString().then((value) { + print("===读取完毕: 文字内容长度 = ${value.length}===="); +}); +print("做些其他的事"); + + + + +有些时候,需要等待异步任务完成,才能继续之后的任务。比如,只要水烧开才能去倒水,可以通过 await 关键字等待异步任务的完成,获取结果: + +Future test2() async{ + String path = r'E:\Projects\Flutter\flutter_first_station\pubspec.yaml'; + File file = File(path); + print("开始读取"); + String content = await file.readAsString(); + print("===读取完毕: 文字内容长度 = ${content.length}===="); + print("做些其他的事"); +} + + +有一点需要注意:在控制台输出时,main 函数结束后就会停止,而文件读取的结果要稍后才能完成,导致无法打印出读取结果。由于应用程序交互时一直在启动中,这个问题不会出现,也不用太在意。不过,大家可以在这里埋个小问题,在以后的生涯中尝试解决: + + +想个方法让 main 函数等一下,可以完成如下的输出效果: + + + + + + +四、本章小结 + +最基础的 Dart 语法就介绍到这里,对本教程的学习来说,这些基本上够用了。如果后面遇到其他的语法,会单独介绍一下。再次强调:这些知识只够入个门而言,Dart 还有非常丰富的语言特性。后期如果对 Flutter 感兴趣,请务必系统地学习一下 Dart 语言。 + +目前,本教程的晚宴食材已经准备就绪。下一章将分析一下 Flutter 计数器项目,了解官方提供的这道初始点心的烹饪手法,以及其中蕴含的知识点。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/05Flutter计数器项目解读.md b/专栏/Flutter入门教程/05Flutter计数器项目解读.md new file mode 100644 index 0000000..daf1e17 --- /dev/null +++ b/专栏/Flutter入门教程/05Flutter计数器项目解读.md @@ -0,0 +1,319 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 Flutter 计数器项目解读 + 通过前面两篇基础语法的学习,已经掌握了 Dart 语言最基础的逻辑控制能力。接下来我们来看一下新建时的默认计数器项目,对项目几个问题进行有一个简单的认知: + + + +界面上的文字是由代码中的何处决定的? +点击按钮时,数字自加和更新界面是如何完成的? +界面中的显示部件是如何布局的? +我们如何修改界面中展示的信息,比如颜色、字体等? + + + + + + +界面显示部件 +修改展示信息 + + + + + + + + + + + + + +一、代码定位工具 + +不管是自己写的代码,还是别人写的,随着代码量的增长,代码的可读性将面临考验。特别是新手朋友,喜欢把所有代码的实现逻辑塞在一块。如何在复杂的代码中找到关键的位置;或如何提纲挈领,将复杂的文字展示出结构性,对编程来说是非常重要的。在看代码之前,有必要先介绍几个快速定位代码的小技巧。 + + + +1. 全局搜索 + +在顶部栏 Edit/Find/Find in Files 打开搜索面板,面板中也可以看到对应的快捷键。如果快捷键没响应,很大可能是和输入法的快捷键冲突了。比如搜狗输入法,禁用其快捷键即可。 + + + + + +从视觉上可以看出,界面中有一些固定的文字,这些文字很可能在代码之中。所以全局的搜索是一个很有用的技巧,比如搜索 You have pushed 时,可以找到代码中与之对应的地方: +注: + + + +在点击之后,就可以跳转到对应的位置,此时光标会在那里闪烁,如下所示: + + + +当鼠标 悬浮 在上方的文件名上时, 会弹出文件所在的磁盘地址,这样能便于我们找到文件所在: + + + +这样就能分析出,界面上展示的信息是由 lib/main.dart 决定的。这就是一个非常基本的 逻辑推理 过程,而整个过程和并不需要用到什么编程知识。相比于推理结果,这种推理的意识更加重要,很多时候初学者都会处于: 我不知道自己该知道什么,而推理的意识就是在让自己:我要知道自己想知道什么 。 + + + + + +2. 类结构信息 + +当分析一份代码文件时,在 AndroidStudio 中可以打开 Structure 页签,其中会展示出当前文件中的所有类的结构信息,比如成员属性、成员方法等。这样,你可以快速了解这份代码有哪些东西,在点击信息时,也能立刻跳转到对应的代码处: + + + +其中 C 图标表示类,m 图标表示方法, f 图标表示成员属性。当前文件夹中定义了三个类型和一个 main 方法。每个类型中会定义若干方法和属性,其中可以清晰地看出函数的名称、入参和返回值。 + + + +3. 布局分析 + +在 Flutter Inspector 页签中,可以看出当前界面的布局结构。点击某项时,会跳转到代码对应的位置,这就是不过展示布局结构,辅助我们快速定位到对应代码位置: + + + + + +如果布局结构过于复杂,在树中寻找节点也非常麻烦。如果现在已经有了一个项目,运行起来,如何迅速找到界面中的部件,对应代码中的位置呢? Flutter Inspector 中提供了选择模式,点击下面的图标开启: + + + +选择模式下,当点击界面上的显示部件,就会自动挑战到对应的代码位置。对于定位代码来说,可谓神器。另外注意一点,点击后左下角会有个搜索按钮,如果想选择其他部件,要先点一下那个搜索按钮: + + + + +选择模式 +选择模式 + + + + + + + + + + + + + +二、计数器代码分析 + +去除注释之后,计数器项目也就 68 行 代码,算是一个非常简单的小项目,但它完成了一个基本的功能交互。可谓麻雀虽小五脏俱全。一开始是 main 方法,表示程序的入口,其中先创建 MyApp 类型对象,并将该对象作为参数传入 runApp 函数中。 + +void main() { + runApp(const MyApp()); +} + + + + +1. 初见 Widget 类型 + +MyApp 继承自 StatelessWidget 类,并覆写了其 build 方法,返回 Widget 类型对象;在方法的实现中,创建了 MaterialApp 对象并返回,其中 theme 入参表示主题, 通过 Colors.blue 可以看到蓝色主题的来源。 + +这时,你可以将 blue 改为 red 然后按 Ctrl+S 进行保存,可以看到界面中的主题变成了红色。这种在开发过程中,不重新启动就可以更新界面的能力,称之为 热重载 hot reload 。不用每次都重新编译、启动,这可以大大提升开发的时间效率。 + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + + +决定界面展示的配置信息的类型我们称之为 组件 Widget, runApp 方法的入参是 Widget 类型,而 MyApp 可以占位参数,就说明它是 Widget 的派生类;MyApp#build 方法返回的是 Widget 类型,方法实现中实际返回的是 MaterialApp 对象,就说明 MaterialApp 也是 Widget 的派生类。这也是一个简单的逻辑推理过程。 + +另外 MaterialApp 构造方法的 home 入参,也是需要传入一个 Widget 类型,所以下面的 MyHomePage 也是 Widget 的派生类。从这里可以看出, Flutter 框架中界面的展示和 Widget 一族息息相关。 + + + +2. MyHomePage 代码分析 + +从代码中可以看出 MyHomePage 继承自 StatefulWidget , 其中有一个 String 类型的成员属性 title,并在构造时进行赋值。另外,还覆写了 createState 方法,创建 _MyHomePageState 对象并返回。 + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + + +对于初学者而言,并不需要太关注覆写的方法是何时触发的,应该在意的是它能提供什么功能。就像现实生活中,你学习画画,如果纠结为什么蓝色颜料和黄色颜料混合起来会是绿色,而去研究大量的光学资料、人视觉的成像原理,是本末倒置的。 探索世界(框架)的原理固然重要,但绝不是新手阶段需要做的事,除非你是天赋异禀,或你的目的不是画画,而是科研。 + + + +3. _MyHomePageState 代码分析 + +上面的 MyApp 和 MyHomePage 两者都是 Widget 的派生类,其中的代码逻辑并不是太复杂,主要是覆写父类方法,完成特定的任务。代码中还剩下一个 _MyHomePageState 类。 从结构上来看,其中有一个整型的 _counter 成员属性;两个成员方法: + + + +从类定义可以看出, _MyHomePageState 继承自 State 类: + +class _MyHomePageState extends State { + + + + +其中 _incrementCounter 方法中,会让 _counter 变量自加;很明显 _counter 变量就是计数器中展示的数值。而点击按钮时将会触发 _incrementCounter方法完成数值自加: + +int _counter = 0; + +void _incrementCounter() { + setState(() { + _counter++; + }); +} + + +最后,就是 _MyHomePageState 中覆写的 build 方法,可以看出界面中的信息是和代码中的内容是对应的。所以,很容易理解代码如何决定界面显示内容。 + + + + +动手小实验: 大家可将 _incrementCounter 中的 setState 去掉 (如下),运行后点击按钮查看效果。将其作为一个对比实验,思考一下 setState 的作用。 + + +void _incrementCounter() { + _counter++; +} + + + + +三、修改界面展示的信息 + +到这里,我们已经 感性地 认识了代码如何决定界面的显示。接下来,通过修改界面上的内容,更切身地体验一下,通过代码控制界面展示的感觉。 + + + +一、文字的修改 + +即使没有任何编程基础,也知道代码中的哪些字对应着屏幕中的哪些字,所以修改文字的展示是最简单的。比如现在将屏幕中间的英文改成中文,只需要把字符串换一下即可: + + + +这样,在 Ctrl+S 保存之后,界面就会立刻更新: + + + + +小练习: 试着把顶部的英文标题改成 计数器 三个字。 + + + + +经常玩手机的都知道,界面上文字有着非常多样式可以配置,最常见的就是文字和字号。在计数器的案例中,下面的数字要大很多,从代码中可以看出,区别在于指定了 style 入参。现在我们来尝试修改一下上方文字的大小和颜色: + + + +实现方式就是在 Text 对象构造函数中传入 style 参数,参数类型是 TextStyle;除了文字和颜色之外,它还有其他的配置信息,以后可以慢慢了解。 + +const Text( + '你点击按钮的次数:', + style: TextStyle(color: Colors.purple, fontSize: 16), +), + + +到这里,我们知道了可以通过 Text 对象在屏幕上展示文字信息。 + + + +2.查看界面布局的技巧 + +对于布局来说,我们要清楚各个区域占据在屏幕中的哪些位置,就像古代皇帝分封土地,那片区域归谁管,是非常明确的。但初学者在不明白布局组件特性的情况下,很难知晓界面中的 “势力范围”。这时可以通过 Flutter Performance 页签中的按钮来开启 布局网格辅助 ,来快速了解界面情况: + + + +如下所示,在有辅助线的界面中,有哪些 “势力范围” 一清二楚。另外,也可以通过上面介绍的 选择模式 来快速定位哪块区域是谁的 “地盘” 。 + + + + +无辅助 +有辅助 + + + + + + + + + + + + + +如下,通过选择模式,可以很轻松地知道,中间的区域是 Column 的地盘。代码中的表现是 Column 在构造时将两个文字作为孩子; Column 单词的含义是 列 ,它的作用是将若干个子组件竖直排列。 + + + +其中 Column 在构造时传入的 mainAxisAlignment 入参可以控制子组件在它地盘内的对其方式,比如下面是修改该属性时的表现效果。这里简单了解一下即可,感兴趣的也可以自己尝试一下,就像神农尝百草,了解效力。 + + + + +MainAxisAlignment.start +MainAxisAlignment.spaceBetween + + + + + + + + + + + + + +之前说过,在选择模式下,点击树中条目,对选中对于的位置。比如下面点击 Center,就可以看到它的地盘,它的作用是让它地盘中的子组件居中对其;这也是 Column 能在中间的根本原因: + + + + +小练习: 试着在代码中去除掉 Center 组件,查看表现效果。 + + +有了 布局网格辅助 和 选择模式 两大利器,对于新手认知布局结构是非常友好的。新手应该多多使用它们,逐渐形成布局划分,领域约束的认知,对之后的工作会大有裨益。初始项目代码解读到这里就差不多了,其实这里新手需要关注的只有 mian.dart 中的代码。关于项目中的其他东西,暂时不用理会,专注于一点,更有利于新手的学习,细枝末节的东西,以后可以慢慢了解。最后,留个小练习: + + +小练习: 修改代码,使得每次在点击按钮时,数字 + 10 + + + + +四、本章小结 + +本章主要结合初始计数器项目的代码,分析一下代码与界面间的关系。同时认识一下 Flutter 最基础的组件概念,通过更改界面的呈现,感性地了解代码中的文字是如何决定界面展示的。 + +另外,也介绍了 AndroidStudio 中的 Flutter Inspector 和 Flutter Performance 两个界面布局分析的工具;以及 Structure 页签查看当前文件类结构信息。合理地使用工具,可以让你更快地理解和掌握知识。接下来,我们将正式新手村进入第一个小案例 —- 猜数字项目。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/06猜数字界面交互与需求分析.md b/专栏/Flutter入门教程/06猜数字界面交互与需求分析.md new file mode 100644 index 0000000..44f05e0 --- /dev/null +++ b/专栏/Flutter入门教程/06猜数字界面交互与需求分析.md @@ -0,0 +1,169 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 猜数字界面交互与需求分析 + 1. 界面交互介绍 + +猜数字是本教程的第一个案例,功能比较简单,非常适合新手朋友入门学习。下面是两个最基础的交互: + + +点击按钮生成 0~99 的随机数,并将随机数密文隐藏。 +头部的输入框,点击时弹出软键盘,可输入猜测的数字。 + + + + + +点击生成随机数 +可输入文字 + + + + + + + + + + + + + +如下所示,点击右上角的运行按钮,可以比较输入猜测值和生成值的大小,并在界面上通过两个色块进行提示。每次比较时,提示面板中的文字会有动画的变化,给出交互示意。 + + + + +比较结果:小了 +比较结果:大了 + + + + + + + + + + +这三个交互就是本案例的所有功能需求。你可以找几个朋友一起玩这个猜数字的小游戏,比如随机生成一个数后,每人输入一个数,最后猜中的人获取胜利。其中控制猜测的范围,使其更利于自己猜出结果,也是一点斗智斗勇。 + + + +2. 猜数字需求分析 + +现在从数据和界面的角度,来分析一下猜数字中的需求: + + +随机数的生成 + + +随机数生成的需求中,有两个需要变化的数据,其一是待猜测的数字 _value 的赋值;其二是游戏的状态 _guessing 置为 true。 + +int _value = 0; +bool _guessing = false; + + +对于界面来说,当生成随机数后,要禁用按钮。也就是说按钮的表现形式,会受到 _guessing 数据的限制。 + + + +同样,中间数字的显示也会受到 _guessing 的影响,猜测过程中为密文;猜对之后游戏结束,展示明文数字。 + + + + +游戏进行中 +游戏结束 + + + + + + + + + + + + +输入框的输入功能 + + +输入框输入需求中,需要一个数据来记录输入的内容。一般使用 TextEditingController 类型的数据和输入框进行双向绑定:也就是说用户的输入会导致控制器数值的变化,控制器数值的修改也会导致输入框内容的变化。 + +TextEditingController _guessCtrl = TextEditingController(); + + + + + +猜测需求分析 + + +猜测需求中,需要一个数据表示猜测结果;猜测结果有三种:大了、小了和相等,这里使用一个可空的 bool 类型对象 _isBig 表示三种状态: + +bool? _isBig; + +null: 相等 +true: 大了 +false: 小了 + + +对于界面来说,需要根据 _isBig 的值,给出提示信息。其中 大了和小了的展示面板叠放在主题界面之上,占据一般的高度空间: + + + + +大了 +小了 +相等 + + + + + + + + + + + +需求中的功能和数据这里简单地分析了一下,最后来说一下本案例中蕴含的知识点。 + + + +3. 猜数字中的知识点 + +首先,猜数字项目会接触到如下的常用组件,大家再完成猜数字项目的同时,也会了解这些组件的使用方式。 + + + + + +另外,会对 Flutter 中的界面相关的知识有初步的认知: + + +组件与界面的关系 +界面构建逻辑的封装 +状态数据与界面更新 +组件有无状态的差异性 + + + + +最后,在逻辑处理的过程中,是对 Dart 语法使用练习的好机会,在完成需求的过程中,会收获一些技能点。 + + +回调函数的使用 +动画控制器的使用 +随机数的使用 + + +界面交互和需求分析就到这里,下面一起开始第一个小项目的学习吧! + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/07使用组件构建静态界面.md b/专栏/Flutter入门教程/07使用组件构建静态界面.md new file mode 100644 index 0000000..60fd0ba --- /dev/null +++ b/专栏/Flutter入门教程/07使用组件构建静态界面.md @@ -0,0 +1,346 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 使用组件构建静态界面 + 1. 代码的分文件管理 + +在计数器项目中,我们知道界面中展示的内容和组件息息相关,其中其决定性作用的是 MyHomePage 组件。但所有的代码都塞在了 main.dart 文件中,随着需求的增加,把所有代码都放一块,显然是不明智的。所以首先来看一下如何分文件来管理代码。 + +如下所示,先创建一个 counter 文件夹,用于盛放计数器界面的相关代码文件;然后创建 counter_page.dart 文件,并把 MyHomePage 的相关代码放入其中: + + + +这时,可以将 main.dart 中 MyHomePage 的相关代码删除;会发现红色的波浪线,表示找不到 MyHomePage 类型: + + + +此时只需要通过 import 关键字,在 main.dart 上方导入文件即可: + +---->[main.dart]---- +import 'counter/counter_page.dart'; + +// 略同... + + +分文件管理代码就像整理书籍,分门别类地进行摆放,各个区域各司其职,自己容易检阅,别人也容易看懂。下面创建一个 guess 文件夹,用于盛放本模块 猜数字 小项目的相关代码: + + + + + +2. 创建你自己的组件 + +首先要明确一点,文件夹的名称、文件的名称、类型的名称、属性的名称、函数的名称,都是可以任意的,甚至可以使用汉字(但不建议),只要在使用时对应访问即可。但一个好名字对于阅读来非常重要,取 a1,c,b,d45,rrr 这样的名字,对阅读者而言是灾难,也许过两天,连你自己也认不得。所以一个好名字是个非常重要,不要偷懒, 最好有明确的含义。 + +比如对于猜数字这个需求来说,整体的界面可以叫 GuessPage , 这里我们可以先借用一下计数器中 MyHomePage 代码,照葫芦画瓢,改巴改巴。先把 MyHomePage 代码复制到 guess_page.dart 中。 + + +重命名小技巧: 当你想对一个类型、函数、属性名进行重命名,并且想让在它们使用使用处自动修改。可以将鼠标点在名称上,右键 -> Refactor -> Rename ; 也可以使用后面的快捷键直接操作。 + + + + +输入新名称后,点击 Refactor , 所有使用处都会同步更新: + + + + + +然后在 main.dart 中,将 GuessPage 作为 home 参数,即可展示 GuessPage 组件中的界面效果: + +---->[main.dart]---- +import 'guess/guess_page.dart'; + // 略同... + home: const GuessPage(title: '猜数字'), + + +接下来的任务是如何修改代码,来完成猜数字的功能需求。首先我们来完成一件简单的事: + + +点击按钮生成随机 0~99 之间的数字 + + + + + +初始效果 +点击生成随机数 + + + + + + + + + + + + + +3.随机数的生成与界面更新 + +计数器项目中,点击按钮之所以界面数字自加,是因为 _incrementCounter 方法中,触发了 _counter++ 。所以想要在点击时显示随机数,思路很简单:将 _counter 变量赋值为随机数即可。 + +void _incrementCounter() { + setState(() { + _counter++; + }); +} + + +所以首先需要了解一下 Dart 中如何生成随机数:在 dart 的 math 包中,通过 Random 类型可以生成随机数。这里生成的是随机整数,使用 nextInt 方法,它有一个入参,表示生成随机数的最大值(不包含)。比如传入 100 时,将返回 0~99 之间的随机整数: + +import 'dart:math'; + +Random _random = Random(); +_random.nextInt(100); + + + + +这样,点击按钮时只要将 _counter 赋值为随机数即可,如下所示: + +void _incrementCounter() { + setState(() { + _counter = _random.nextInt(100); + }); +} + + +但之前说过,名字非常重要。但这里 _counter 含义是计数器,_incrementCounter 含义是自增计数器,在当前需求的语境中并不是非常适合。起名字最好和其功能相关,比如可以将数值变量可以称之 _value ; 方法可以称之 _generateRandomValue,这样代码阅读起来就会更容易理解。 +同样,也可以使用 Refactor 重命名: + +class _GuessPageState extends State { + + int _value = 0; + + Random _random = Random(); + + void _generateRandomValue() { + setState(() { + _value = _random.nextInt(100); + }); + } + + + + +4. 头部栏 AppBar 和输入框 TextFiled 的使用 + +接下来,我们要将头部的标题栏 AppBar 改成如下的样式,中间是可以输入的输入框,右侧是一个运行的图标按钮。 + + + + + +AppBar 常用于左中右布局结构,如下所示: + +AppBar( + leading: 左侧, + actions: [右侧列表], + title: 中间部分, +) + + +也就是说在不同的参数中,可以插入不同的组件进行显示。比如这里 leading 指定为 Icon 组件,展示图标: + +leading: Icon(Icons.menu, color: Colors.black,), + + +actions 入参是一个组件列表,这里放入一个 IconButton 组件,展示图标按钮: + +actions: [ + IconButton( + splashRadius: 20, + onPressed: (){}, + icon: Icon(Icons.run_circle_outlined, color: Colors.blue,) + ) +], + + +title 入参是中间部分,使用 TextField 组件展示输入框。这里组件构造时的入参对象都是用于配置展示信息的,可以简单认识一下,不用急着背诵,以后慢慢接触,早晚都会非常熟悉。 + +TextField( + keyboardType: TextInputType.number, //键盘类型: 数字 + decoration: InputDecoration( //装饰 + filled: true, //填充 + fillColor: Color(0xffF3F6F9), //填充颜色 + constraints: BoxConstraints(maxHeight: 35), //约束信息 + border: UnderlineInputBorder( //边线信息 + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + hintText: "输入 0~99 数字", //提示字 + hintStyle: TextStyle(fontSize: 14) //提示字样式 + ), +), + + + + +最后说一下,如何去掉顶部的灰块:AppBar 组件在构造时,可以通过 systemOverlayStyle 入参控制状态类和导航栏的信息。如下代码可以使顶部状态栏变成透明色,文字图标是暗色: + + + + AppBar( + systemOverlayStyle: SystemUiOverlayStyle( + statusBarIconBrightness: Brightness.dark, + statusBarColor: Colors.transparent + ), + + + + +5. 叠放 Stack 组件和列 Column 组件的使用 + +当用户输入的数字大了,或小了。需要界面上给予提示。为了更加醒目,这里给出的设计如下所示。如果大了,上半屏亮红色,展示 大了;如果小了,下半屏亮蓝色,展示 小了: + + + + +小了提示 +大了提示 + + + + + + + + + + +可以看出此时中间的文字是浮在提示色块之上的,想实现这种多个组件层层堆叠的效果,可以使用 Stack 组件来完成。其构造函数中 children 入参传入组件列表,在显示层次上后来居上。 +如下所示,将 Stack 作为 body , 在其中放入一个红色的 Container 容器组件, 以及之前的主内容。运行后会看到:中间文字就会浮在红色容器之上。 + + + +body: Stack( + children: [ + Container(color: Colors.redAccent), + //主内容略... + ], +), + + + + +除了堆叠的效果,还有一个问题,如何实现 上下平分区域 呢? 我们前面知道 Column 组件可以让两个组件竖直排列。在 Column 中可以通过 Expanded 组件延展剩余区域,另外 Spacer() 组件相当于空白的 Expanded。当存在多个 Expanded 组件时,就可以按比例分配剩余空间,默认是 1:1 ,也可以通过 flex 入参调节占比。举个小例子,如下所示: + + + + +Expanded 红+Expanded 蓝 +Expanded 红+ Spacer + + + + + + + + + + + +---->[红+蓝]---- +Column( + children: [ + Expanded( child: Container(color: Colors.redAccent)), + Expanded( child: Container(color: Colors.blueAccent)), + ], +), + +---->[红+空]---- +Column( + children: [ + Expanded( child: Container(color: Colors.redAccent)), + Spacer() + ], +), + + +到这里,猜数字项目中需要使用的基础组件就已经会师完毕。大家可以基于现在已经掌握的知识,完成如下效果: +注: Container 组件的 child 入参可以设置内容组件。 本例参考代码见: guess_page.dart + + + + + +6.组件的简单封装 + +上面虽然完成了布局效果,但是从 guess_page.dart 中可以感觉到,随着需求的增加,各种组件全都塞到了一块。这才只是一个简单的布局,就已经有点不堪入目了;如果再加上交互的逻辑,恐怕要乱成一锅粥了。 + +其实对于新手而言,编程语言语法本身并不是什么难事,对规整代码的把握才是最大的挑战;很容易要什么,写什么,最后什么东西都塞在一块,连自己都看不下去了,从而心灰意冷,劝退放弃。小学时,老师就教导我们,遇到巨大的问题,要尝试将它分解成若干个小问题,逐一解决。 + +其实有些大问题在肢解过程中,会有某些类似的小问题,这些小问题可以通过某种相同的解决方案来处理。这种通用解决方案,就是一种封装的思想,问题的专属解决方案一旦封装完毕,输入问题,就可以解决问题,使用者不必在意处理的过程,可以大大提升解决问题的效率。回忆一下,在介绍函数时,通过 bmi 函数,封装体质指数的计算公式,就是通过函数来封装解决方案。 + + + +对于组件来说,也是一样:某些相似的结构,也可以通过 封装 进行复用。比如这里 大了 和 小了 只是颜色和文字不同,两者的结构类似。就可以通过封装来简化代码: +最简单的封装形式是通过函数封装,通过入参来提供界面中差异性的信息,如下所示 _buildResultNotice 函数接收颜色和消息,返回 Widget 组件: + +Widget _buildResultNotice(Color color, String info) { + return Expanded( + child: Container( + alignment: Alignment.center, + color: color, + child: Text( + info, + style: TextStyle( + fontSize: 54, color: Colors.white, fontWeight: FontWeight.bold), + ), + )); +} + + +如果把相似的结果写两遍,只会徒增无意义的代码。而封装之后,只需要调用方法,就可以完成任务: + +Column( + children: [ + _buildResultNotice(Colors.redAccent,'大了'), + _buildResultNotice(Colors.blueAccent,'小了'), + ], +), + + + + +如果封装体的代码非常复杂,或者需要单独维护,以便之后修改方便定位,也可以通过新组件的形式来封装组件。如下所示,新创建 result_notice.dart 文件,在其中定义 ResultNotice 组件,专门处理结果提示信息的展示任务。 + + + +一方面,专人专用,需要更改时直接在这里更改。比如你让另一个人帮忙改改某处的代码,而他对项目不熟悉,如果代码分离的得当,你告诉他这个界面由 xxx.dart 文件负责,他就可以在不了解项目的前提下,对界面进行修改。这就是 职责分离 的益处。不同人干自己擅长的事,有利于整体结构的稳定。 + +另一方面也能缓解 guess_page.dart 中的代码压力,不至于随着需求的增加代码量激增,对可读性友好。在使用时,可以将 ResultNotice 视为普通的组件,放在 Column 之中: + +Column( + children: [ + ResultNotice(color:Colors.redAccent,info:'大了'), + ResultNotice(color:Colors.blueAccent,info:'小了'), + ], +), + + + + +同样,这里 AppBar 组件的构建逻辑也是太复杂的,可以在 guess_app_bar.dart 中单独维护。这里主要是出于简化 guess_page.dart 中代码的考量,不强求可复用性。 + + + +这样 guess_page.dart 中的代码就整洁了很多,其他两个文件也在各司其职。相对与之前全塞在一块,更便于阅读,封装之后的代码见 guess_page.dart 。 + + + +7.本章小结 + +本章主要学习了如何通过 Flutter 框架提供的组件,来搭建期望的界面呈现效果。期间介绍了如何分文件来管理代码、以及通过自定义组件来封装构建逻辑。最后简单分析了一下组件封装的优势。 + +学完本章,你应该能够自己动手搭建一些简单的静态界面了。但应用程序是要和用户进行交互的,就需要界面随着用户的交互进行变化。下一篇将从用户交互的角度,通过代码来实现猜数字的具体功能。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/08状态数据与界面更新.md b/专栏/Flutter入门教程/08状态数据与界面更新.md new file mode 100644 index 0000000..4514c4d --- /dev/null +++ b/专栏/Flutter入门教程/08状态数据与界面更新.md @@ -0,0 +1,179 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 状态数据与界面更新 + 编程中的一切都是操作数据,界面上的表现都需要具体数据来支撑,决定界面展现的数据称之为 状态数据。然而: + + +有些数据中界面中是恒定不变的,比如一些固定的文字、图标、颜色等; +也有些数据会随着用户的交互发生改变,比如计数器项目中,数字会随着用户的点击而增加。 + + +如何通过代码逻辑,维护状态数据在交互过程中的正确性,就是对状态数据的管理。在一个需求中,哪些 状态数据 是可变的,需要具体问题具体分析。 + + + +1. 从按钮禁用开始说起 + +在猜数字的需求之中,点击按钮生成随机数。但在猜测的过程中,我们期望禁止点击来重新生成,否则又要重新猜测。也就是说,在一次猜数字过程中,只能生成一个随机数;同时,猜对时,需要解除禁止,进入下一次游戏。 + +对于生成随机数的需求,需要一个量来标识是否是在游戏过程中。这就是根据具体需求,来分析必要的状态数据。比如这里通过 bool 类型的 _guessing 对象标识是否在游戏过程中。界面和交互的逻辑表现在:当 _guessing 为 false 时,支持点击,按钮呈蓝色;为 true 时,禁止点击,按钮呈灰色: + + + + +标题 + + + + + + + + + + + + + + +如下所示,在 _GuessPageState 中定义 _guessing 属性,FloatingActionButton 按钮组件在创建时根据 _guessing 值控制相关属性。比如 _guessing 为 true 时 onPressed 为 null, 表示不响应点击,且背景色是灰色 Colors.grey : + +---->[guess_page.dart#_GuessPageState]---- +bool _guessing = false; + +// 略... +// 按钮组件构建逻辑 : +floatingActionButton: FloatingActionButton( + onPressed: _guessing ? null : _generateRandomValue, + backgroundColor: _guessing ? Colors.grey : Colors.blue, + tooltip: 'Increment', + child: const Icon(Icons.generating_tokens_outlined), +), + + +注: boolValue ? a : b 称三目运算符,相当于一种简写的赋值语句;boolValue 为 true 时取 a,反之取 b 。下面是一个小例子: + +int a = 5; +int b = 6; +bool boolValue = true; + +int c = boolValue ? a : b +// 上行代码等价于下面代码: +int c; +if(boolValue){ + c = a; +}else{ + c = b; +} + + + + +上面通过 _guessing 状态数据控制组件构造,从而达到控制表现的效果。写一个问题就是,如何在交互逻辑中,正确地维护 _guessing 状态数据值。这里的逻辑是:点击之后,表示游戏开始,将 _guessing 置为 true 。所以只需要在 _generateRandomValue 方法中添加一行即可: + +void _generateRandomValue() { + setState(() { + _guessing = true; // 点击按钮时,表示游戏开始 + _value = _random.nextInt(100); + }); +} + + +大家可以将当前代码自己跑一下,点击操作。体会一下状态数据变化的过程 (业务逻辑),和对界面表现的控制力(界面构建逻辑)。 + + + +2.密文的展示 + +既然是猜数字,那么随机生成的数字肯定不能明晃晃地摆在那里,而且生成之后,上方的 点击生成随机数值 的提示信息也不需要了。可以看出这些界面表现都是通过 _guessing 状态数据决定的,通过状态数据控制界面呈现,一般称之为 界面构建逻辑 。 + + + +也就是在猜数字过程中,要隐藏数字的展示;取除上方的提示字,效果如下: + + + +代码实现也比较简单,和之前一样,通过 _guessing 值,决定组件构造的内容。从这里可以看出,一个状态数据,可以控制界面中很多部件的表现形式。 + + + + +注: 在列表中可以通过 if(boolValue) 来控制是否添加某个元素。 + + +当前代码提交位置: guess_page.dart + + + +3. 回调事件的传递 + +这样我们就实现了随机数字生成的需求,现在需要做的是猜数字需求。 在输入框中输入数字,点击确定按钮,比较后想用户提示大小信息。现在首要问题是知道如何获取输入的数字,以及如何触发按钮的点击事件。 + + + +我们之前将头部栏单独封装成一个组件,独立存放。现在想处理头部栏的相关工作,直接看 guess_app_bar.dart 文件即可。先来看一下点击事件的回调: + +这里使用 IconButton ,也就是图标按钮,在 onPressed 构造入参中可以传入无参函数,用于回调。也就是说,你点击按钮就会触发一个函数(方法),如下所示,点击一下在控制台输出信息: + + + +在 GuessAppBar 类中,并没有猜数字过程中的相关数据,在这里校验大小并不是很合适。这时就可以通过回调,将事件触发的任务移交给自己的使用者。因为函数本身也可以视为一个对象,如下所示,将函数作为属性成员,通过构造函数进行赋值: + + + +这样在构造 GuessAppBar 组件时,就可以将回调事件交由 _GuessPageState 处理,而这里有我们维护的状态数据。处理如下: + + + + + +4.输入控制器的使用 + +目前,输入框可以进行输入,但如何获取到输入内容呢? 我们可以使用输入控制器 TextEditingController 它可以承载输入的内容。它会作为 TextField 的构造入参,而 TextField 在 GuessAppBar 中;又因为由于核心逻辑的维护在 _GuessPageState 中,所以控制器对象可以交由 _GuessPageState 维护,并可以通过 GuessAppBar 构造函数来传入: + +---->[guess_page.dart#GuessAppBar]---- +class GuessAppBar extends StatelessWidget implements PreferredSizeWidget { + final VoidCallback onCheck; + final TextEditingController controller; + + const GuessAppBar({ + Key? key, + required this.onCheck, + required this.controller, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AppBar( + // 略同... + title: TextField( + controller: controller, + + +这样,在 _GuessPageState 里创建 _guessCtrl 属性,作为 GuessAppBar 构造入参,就可以和输入框进行绑定。当输入文字,点击按钮后,查看控制台,就可以看到输入信息。 + + + +另外,注意一下,输入控制器有销毁的方法,需要覆写状态类的 dispose 方法,调用一下: + +@override +void dispose() { + _guessCtrl.dispose(); + super.dispose(); +} + + + + +5.本章小结 + +到这里,相关的数据和界面就准备完毕,当前代码提交位置: guess_page.dart 。本章最主要的知识是通过改变数据来修改界面的呈现效果。比如在交互过程中,按钮的禁用、文字的密文展示会发生变化,它们的表现都在构造逻辑中由数据决定。大家可以通过当前的源码好好思考一下,状态数据和界面之间的关系。 + +下一章,将继续完善功能,处理校验以及提示用户输入值大了还是小了。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/09校验结果与提示信息.md b/专栏/Flutter入门教程/09校验结果与提示信息.md new file mode 100644 index 0000000..19c2a16 --- /dev/null +++ b/专栏/Flutter入门教程/09校验结果与提示信息.md @@ -0,0 +1,156 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 校验结果与提示信息 + 1. 需求中的状态数据分析 + +在上一篇中,已经准备好了数据和界面。本篇将介绍比较结果的校验,以及展示用户输入值和随机目标值的大小信息。点击确定时,可能的结果有 3 中,如下所示: + + + + + +如下所示,是点击确定时不同情况下期望的界面效果;也就是说,界面的展现需要随着用户交互而变化。所以在当前需求之下,需要引入新的状态数据,用于控制界面的表现。 + + + + +大了 +小了 +相等 + + + + + + + + + + + +这个状态数据将用于表示校验结果,该用什么类型呢?首先要明确一点:比较结果有三种情况: 大了、小了、相等 。其实只要某种数据类型包含三个值,都可以用于表示校验状态,只要逻辑正确即可,比如: + +int 型: +0: 大了 +1: 小了 +2: 相等 + +String 型: +'big': 大了 +'small': 小了 +'equal': 相等 + + +但如果用 int 或 String 表示,首先必须提前做出规定。而对于不了解规定的人,在阅读时会增加理解的难度。如果这种类似的规定场景在一个项目里经常出现,就是一个个雷点,迟早会爆炸。其实 bool 类型的出现,就是为了避免用 0 和 1 整数来表示真假,带来语义上的难以理解。 + +能且仅能是一种语法上的约束,可以在根源上杜绝一些可能发生的错误。那问一个问题,什么类型有且仅有 3 个值?很多人会说 枚举,这确实可以,不过稍显麻烦。这里想用 bool?来表示三态,它有如下三个值: + +null: 相等 +true: 大了 +false: 小了 + + +分析完后,现在 _GuessPageState 里定义为变量 _isBig 作为状态数据,控制校验结果。默认为 null : + +bool? _isBig; + + + + +2. 校验逻辑与状态数据的维护 + +校验逻辑算是比较简单的,对状态数据的维护可以称之为 业务逻辑,校验逻辑将在 _onCheck 回调中进行。上篇说过,这里可以得到目标值和输入值,输入值可以通过 int.tryParse 吧字符串转为整型。 +有些小细节要注意一下:如果 _guessing 为 false ,表示游戏未开始;或输入的不是整数,此时应该不做响应,直接返回即可。 + +void _onCheck() { + print("=====Check:目标数值:$_value=====${_guessCtrl.text}============"); + + int? guessValue = int.tryParse(_guessCtrl.text); + // 游戏未开始,或者输入非整数,无视 + if (!_guessing || guessValue == null) return; + + //猜对了 + if (guessValue == _value) { + setState(() { + _isBig = null; + _guessing = false; + }); + return; + } + + // 猜错了 + setState(() { + _isBig = guessValue > _value; + }); +} + + +如果猜对了,表示游戏结束,将 _guessing 置为 true、_isBig 置为 null ; 如果猜错了,通过 guessValue 和 _value 的比较结果,为 _isBig 赋值。这就是对状态数据的维护过程。 + + + + + +3. 状态数据与界面构建逻辑 + +状态数据以及维护完毕,下面来看一下最后一步:使用 _isBig 状态控制界面的呈现。从最终效果,可以推断出_isBig 对界面的功效: + + + + +大了 +小了 +相等 + + + + + + + + + + + + + +背景的色框提示,只有在大了或小了时才会出现,也就是说当 _isBig != null 时才会出现,对应下面代码的 69 行 。 +大了和小了是互斥的,不会同时出现,通过 Spacer 进行占位,大了时占下半;小了时占上半。 + + + + + +注: isBig! 是空安全的语法,如果一个可空对象 100% 确定非 null。可以通过 对象名! 表示 非空对象。 由于上面 if(_isBig) 才会走下方逻辑,所以使用处 100% 非空。 + + + + +4.本章小结 + +到这里,猜数字简单版本就已经完成了。当前代码提交位置: guess_page.dart 你可以随机生成数字,在输入框中猜测数字,并校验猜测的值,给出提示。虽然是个小案例,但相比于计数器来说复杂了一些,额外维护了几个状态数据,界面布局上也更加复杂。是一个初学者进一步了解 Flutter 的好案例,把它吃透,会对你受益匪浅。 + +虽然现在可以完成猜数字需求,但是还有一点缺陷。比如当校验 小了, 下次校验还小时,由于 _isBig 的状态类没变,提示界面就没有任何变化(下左图)。如果用户的一个操作得不到反馈,体感上会觉得可能没点好;此时给予用户视觉上的反馈就会有比较好的体验,比如加一个动画效果(下左图): + + + + +无动画 +有动画 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/10动画使用与状态周期.md b/专栏/Flutter入门教程/10动画使用与状态周期.md new file mode 100644 index 0000000..e4db30a --- /dev/null +++ b/专栏/Flutter入门教程/10动画使用与状态周期.md @@ -0,0 +1,299 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 动画使用与状态周期 + 1. 什么是动画 + +接下来,我们将进入猜数字项目的最后一个模块:动画的使用。上一节末尾说了,此处使用动画的目的是 增加交互反馈效果 。动画本质上就是不断更新界面展示的内容,玩过翻页动画的感触会深一些: + + +在每页纸上绘制连续的动作,手快速翻动时,内容快速变化,形成连续运动的动画效果。 + + + + +这里根据翻页动画,先统一给定几个概念描述,方便后续的表述: + + +动画帧 : 一页纸上的内容。 +动画时长 : 从开始翻看,到结束的时间差。 +帧率 : 动画过程中,每秒钟包含动画帧的个数。 +动画控制动器: 动画进行的动力来源,比如翻页动画中的手。 + + + + +其实对于界面来说也是类似的,屏幕上展示的内容不断变化,给人视觉上的动画效果。对于界面编程来说,动画一般都是改变某些属性值;比如这里是对中间文字的大小进行动画表现: + + + + +标题 + + + + + + + + + + + + + +我们之前将提示信息的界面封装成 ResultNotice 组件进行展示,现在想要修改面板的展示效果,只要对该组件进行优化即可。可以很快定位到 result_notice.dart, 这也是各司其职的一个好处。下面就来看一下,让文字进行动画变化的流程。 + + + + + +2. 动画控制器的创建 + +要进行动画,首先要找到 驱动力, 也就是翻页的那只手怎么得到。Flutter 框架层对这只手 (Ticker) 进行了封装,给出一个更易用的 AnimationController 类型。想创该类型对象需要两步: + + +1. 一般在 State 派生类中创建 AnimationController 对象,使用这里将 ResultNotice 改为继承自 StatefulWidget : + + +class ResultNotice extends StatefulWidget { + final Color color; + final String info; + + const ResultNotice({ + Key? key, + required this.color, + required this.info, + }) : super(key: key); + + @override + State createState() => _ResultNoticeState(); +} + +class _ResultNoticeState extends State{ + //... +} + + + +1. 将状态类通过 with 关键字混入 SingleTickerProviderStateMixin, 让状态类拥有创建 Ticker 的能力。这样在 AnimationController 构造方法中 vsync 入参就可以传入当前状态类。 + 对于新手来说,这可能比较难理解,可以先记成固定的流程。不用太纠结,以后有能力时,可以在动画小册中探索更深层的原理。 + + +class _ResultNoticeState extends State with SingleTickerProviderStateMixin{ + + late AnimationController controller; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + } + + +这样动画控制器对象就在创建完毕了,在创建对象时 duration 入参用于控制动画时长。默认情况下,动画控制器的值会在指定时长内,从 0 匀速变化到 1 。 下面来看一下如何通过动画控制器,来驱动字号大小的变化。 + + + +3. 动画构造器 AnimatedBuilder 的使用 + +动画本身就决定它需要频繁地变化,但很多时候我们只需要局部一小部分进行动画,比如这里只针对于文字。对于这种频繁变化的场景,最好尽可能小地进行重新构建。这里推荐新手使用 AnimatedBuilder 组件,可以非常方便地处理局部组件的动画变化。 由于需要动画的只是文字,所步骤如下: + + +将 AnimatedBuilder 套在 Text 组件之上。 +将动画控制器作为 animation 入参。 +将需要动画变化的属性值,根据 animation.value 进行计算即可。 + + + + +刚才说过,默认情况下,动画控制器启动之后,它的值会在指定时长内,从 0 匀速变化到 1。所以,这里 fontSize 会从 0 匀速变到 54 。 + + +小思考: 通过简单的数学知识,思考一下如何让 fontSize 从 12 ~ 54 匀速变化。 + + + + +4. 状态类的生命周期回调方法 + +这里介绍一下 State 派生类常用的几个生命周期回调方法;生命周期 顾名思义就是对象从生到死的过程,回调就是生命中特定时机的触发点;回调是 Flutter 框架中触发的,派生类可以通过 覆写 的方式,来感知某个特定时机。 + +比如,我们一般在 initState 回调中处理状态类中成员对象的初始化;如下这里对 controller 对象的初始化。在创建后可以通过 controller.forward 方法,启动动画器,让数值运动: + +@override +void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + controller.forward(); +} + + + + +既然有生,就必然有死。当状态类不再需要时,其中持有的一些资源需要被释放,必然动画控制器。这时可以通过 dispose 回调监听到状态销毁的时机: + +@override +void dispose() { + controller.dispose(); + super.dispose(); +} + + + + +人除了生和死,就是工作,状态类也是一样。而状态类最重要的工作就是 build 方法构建 Widget 组件,它也是生命周期回调中的一环: + +@override +Widget build(BuildContext context) { + // 略同... +} + + +生和死都是只会触发一次,而工作是每天都要进行。所以,框架中对 State 对象的 initState 和 dispose 只会触发一次, build 方法可能触发多次。 + + + +现在一个问题,由于 controller 只会在 initState 触发一次,所以两次校验结果相同,状态类还活着,也只能进行一次动画。状态类如何监听到更新信息呢? 答案也是生命周期回调: + + +当上级状态触发 setState 时,会触发子级的 didUpdateWidget 生命回调 + + +代码中在点击按钮时,会触发 setState , 所以 _ResultNoticeState 里可以覆写 didUpdateWidget 获得点击的时机,在此触发 controller 的 forward 进行动画。 + +如果 ResultNotice 提供了动画时长的参数,如果外界需要修改动画时长,而外界无法直接访问状态类。就可以通过 didUpdateWidget 来 间接 修改动画控制器的时长。其中 oldWidget 是之前的组件配置信息,另外最新的组件信息是 widget 成员,可以比较两者时长是否不同,对动画控制器进行修改。 + +---->[result_notice.dart]---- +@override +void didUpdateWidget(covariant ResultNotice oldWidget) { + controller.forward(from: 0); + super.didUpdateWidget(oldWidget); +} + + +didUpdateWidget 可能对新手来说比较难理解,它提供了外界更新时机的回调,并根据新旧组件配置,来维护 状态类内部数据 。 在 State 的生命之中也可以被调用多次。 + + +当前代码提交位置: result_notice.dart + + +initState 、build 、didUpdateWidget、dispose 三者是最基础的 State 生命周期回调。除此之外还有几个回调,不过对于新手来说并不重要,以后有能力时可以通过渲染机制小册,从源码的角度去了解它们。 + + + +5. Statless Or Statful + +对于新手而言,面临的一个非常难的问题就是,我该选择 StatelessWidget 还是 StatefulWidget 。 这其实要取决于你对需求的理解,以及对组件的封装思路:比如这里 ResultNotice,由于想让它进行动画,而动画控制器的控制和维护需要在状态类中处理,所以就选择了 StatefulWidget 。 + +但这并不是绝对的,因为上面选择 StatefulWidget 本质上就是由于 动画控制器 对象。那 ResultNotice 直接摆烂,由构造函数传入动画控制器。这就相当于动画控制器由外界维护,此时 ResultNotice 就可以是 StatelessWidget。 + + +大家可以细品一下 StatefulWidget 变为 StatelessWidget 的过程。先自己思考一下两者的差异,后面我会进行分析。 + + +class ResultNotice extends StatelessWidget { + final Color color; + final String info; + final AnimationController controller; + + const ResultNotice({ + Key? key, + required this.color, + required this.info, + required this.controller, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + alignment: Alignment.center, + color: color, + child: AnimatedBuilder( + animation: controller, + builder: (_, child) => Text( + info, + style: TextStyle( + fontSize: 54 * (controller.value), + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } +} + + + +当前代码提交位置: result_notice.dart + + + + +俗话说,冤有头,债有主 。只要还想进行动画,控制器就需要有一个状态类做接盘侠(维护者)。由于 ResultNotice 已经甩锅了,那这里就需要上层状态 _GuessPageState 来维护: + + + +这时,在构建 ResultNotice 时,传入控制器即可: + +Column( + children: [ + if(_isBig!) + ResultNotice(color:Colors.redAccent,info:'大了',controller: controller,), + Spacer(), + if(!_isBig!) + ResultNotice(color:Colors.blueAccent,info:'小了',controller: controller,), + ], +), + + +最后在点击时触发动画器: + + + + + +现在就 ResultNotice 来分析一下 StatlessWidget 和 StatefulWidget 在使用上的差异性。 + + +ResultNotice 为 StatefulWidget 时,外界使用者无需和 AnimationController 就能进行动画。也就是说将动画控制器的逻辑封装到了内部,拿来即用,用起来简洁方便。 +ResultNotice 为 StatlessWidget 时,外界使用者需要主动维护 AnimationController 对象,使用门槛较高。另外,由于使用者主动掌握控制器,可以更灵活地操作。 +之前是静态的界面,现在想要进行动画,对于功能拓展来说,使用 StatefulWidget 来独立维护状态的变化。可以在不修改之前其他代码的前提下,完成需求。 + + +好用 和 灵活 是一组矛盾,封装度越高,使用者操心的事就越少,用起来就越好用。但同时想要修改封装体内部的细节就越麻烦,灵活性就越差。所以,没有什么真正的好与坏,只有场景的适合于不适合。在面临选择时,要想一下: + + +你是只想看电视的用户,还是以后电视开膛破肚的维修也要自己做。 + + +Flutter 中内置的很多 StatefulWidget 组件,我们就是使用者。比如点击按钮有水波纹的变化、点击 Switch 有滑动效果等,这些内部的状态变化逻辑是不用我们操心的。作为使用者可以非常轻松地完成复杂的交互效果,这就是封装的优势。但同时,如果需求的表现有一点不符合,改框架源码将会非常复杂,门槛也很高,这就是封装的劣势。对于选取 StatfulWidget 我的标准是: + + +当前组件展示区域,在交互中需要改变内容;且外界无需在意内部状态数据。 + + +对于 ResultNotice 来说, StatlessWidget 或 StatefulWidget 差别并不是非常大,只是 AnimationController 交给谁维护的问题。每种方式都有好处,也有坏处,但都可以实现需求。所以结合场景,选取你觉得更好的即可。对于新手而言,能完成需求是第一要务,至于如何更优雅,你可以在以后的路中慢慢揣摩。 + + + +6.本章小结 + +本章主要介绍了 Flutter 中使用动画的方式和步骤,并简单了解了一下状态类的生命周期回调方法。最后分析了一个对于新手而言比较重要的话题 StatlessWidget 和 StatefulWidget 的差异性。 + +到这里,我们的第一个猜数字小案例的全部功能就实现完毕了。从中可以了解很多 Flutter 的基础知识,在下一篇中将对猜数字的小案例进行一个总结,看看现在我们已经用到了哪些知识,以及当前代码还有哪些优化的空间。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/11猜数字整理与总结.md b/专栏/Flutter入门教程/11猜数字整理与总结.md new file mode 100644 index 0000000..3c8d0ef --- /dev/null +++ b/专栏/Flutter入门教程/11猜数字整理与总结.md @@ -0,0 +1,317 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 猜数字整理与总结 + 通过上面 5 章的学习,我们已经完成了一个简单的猜数字小项目。这里将对项目在的一些知识点进行整理和总结,主要从界面相关知识和获得的技能点两个方面进行总结: + + + + + +一、 界面相关的知识 + +对于新手来说,Flutter 框架最最重要的就是完成界面在设备屏幕上的展示;并让用户在交互过程中展示准确的界面信息,以完成项目的功能需求。 从计数器和猜数字两个小项目中不难发现,组件 Widget 和界面呈现息息相关。 + +1.组件与界面的关系 + +现实中的一处建筑,在地图上可以用经纬度信息进行表示;界面的呈现效果,也可以用组件的信息进行表示。通过一个东西,可以确定另一个东西,这是一种非常典型的 映射关系 。组件对象可以决定界面上的展示内容,组件在构建过程中的配置参数,就是用于控制界面呈现效果的。 + + +所以,组件(Widget) 本质上是对界面呈现的一种配置信息描述。 + + + + + + +2.界面构建逻辑的封装 + +世界会分为很多国家、国家会分为很多省、省会分成很多市、市会分成很多县、区…等等。这是一个非常典型的 树形嵌套结构 ,对于管理来说,这种结构是不可或缺却的,比如一个公司的组织架构、一本书的目录结构。 + +当个体数量非常庞大,需要进行维护管理时,树形嵌套结构 可以很有效地将整体划分为若干层级。从而各司其职,完成整体的有序运转,社会是如此,对于界面来说也是一样。对于可交互软件的开发者而言,设备的界面就是一个世界,包含着各种各样的视觉元素,用户在使用软件交互过程中的正确性,就是世界的有序运转。 + +只靠一个人管理整个社会,其他全部躺平是不现实的; 同样只靠一个组件来维护界面也不是明智之举。组件可以表示界面上的一部分,合理地划分 "屏幕世界" 的区域,交由不同的组件进行管理,是一个好的习惯。这得益于组件可以对界面构建逻辑的进行封装。 + + + +3.状态数据与界面更新 + +Widget 对象的所有属性都需要是 final 修饰的,也就是说你无法修改 Widget 对象的属性。前面说过 Widget 可以决定界面的呈现效果,也就是说对于一个 Widget 对象 而言,它对应的界面效果是无法变化的。但界面在交互过程中,一定会有界面变化的需求,比如说计数器项目,点击按钮时文字发生变化。 + +可能会有人疑惑,既然你说 Widget 对象无法修改属性,那计数器的数字为什么会变化。理解这个问题是非常重要的,所以先举个小例子: + + +现实生活中,一只狗自诞生那一刻,毛色属性就无法修改。 +有一天,你去小明家,站在一只白狗面前。 +小明对你说: “你把眼睛闭上。” +过一会你睁开眼,看到面前有一只黑狗。 +那么,你是否会惊奇的认为,自己的闭眼操作会改变一只狗的毛色。 +(其实只是小明换了一只体型一样的黑狗) + + +同理,计数器项目中,你点击按钮时,数字从 0 -> 1。并不是 Text("0") 的文字属性变成了 1,而是你面前的是一个新的 Text("1") 对象。数字需要在交互时发送变化,但不能再 Widget 类中变化,所以 Flutter 框架中,提供了一个状态数据维护的场所: State 的衍生类。它可以提供 setState触发重新构建,从而更新界面呈现。 + + + +4.组件有无状态的差异性 + +对于新手而言,自己创建 Widget 的派生类,有两个选择。其一是继承自 StatelessWidget , 其二是继承自 StatefulWidget, 两者都可以通过已有的组件完成拼装的构建逻辑。 + +StatelessWidget 派生类是很简单直接的,它在 build 方法中通过已有组件来完成构建逻辑,返回 Widget 对象。 界面上需要什么,就在构造函数中传什么。相当于一个胶水,把其他组件黏在一块,作为新的组件个体。 + + + +StatefulWidget 和 StatelessWidget 在功能上是类似的,只不过 StatefulWidget 自己不承担 build 组件的责任,将构建任务委托给 State 派生类。它会通过 createState 方法创建 State 对象: + + + +在上一点中提到,State 派生类中可以维护状态数据的变化和重新触发自己的 build 构建方法,实现界面的更新。另外,State 对象有生命周期回调,可以通过覆写方法进行感知。 + + +需要注意一点: 不要将 StatefulWidget 和 State 混为一谈,两者是不同的两个类型。可以感知生命周期、维护状态数据变化的是 State 类。 StatefulWidget 的任务是承载配置信息,和创建 State 对象。 + + + + +二、 技能点 + +虽然说界面上展示的内容都是通过 Widget 确定的,但 Flutter 中除了 Widget 还有很多其他的类型对象。它们在一起共同工作,维护界面世界的运转。 + +1. 回调函数的使用 + +函数本身可以视为一个对象,作为函数的参数进行传递,这样可以在一个类中,很方便地感知另一个对象事件的触发。比如在 _GuessPageState 类中,使用 FloatingActionButton 组件,它的入参 onPressed 是一个函数,这个函数是由框架内部在恰当的时机触发的,这个时机就是点击。 + +也就是说,点击会触发 onPressed 参数传入的函数对象,这样我们就可以方便地 监听到 事件,并处理数据变化的逻辑。这种函数,就称之为 回调函数 。既然函数要作为对象传递,那最好要有类型名,可以通过 typedef 让函数有用类名: + +比如, FloatingActionButton 的 onPressed 参数类型是 VoidCallback ,定义如下:表示一个无参的返回值为空的函数。 + +typedef VoidCallback = void Function(); + + +AnimatedBuilder 组件的 builder 参数也是一个回调函数,类型为 TransitionBuilder ,定义如下:表示一个返回值为 Widget, 两个入参分别是 BuildContext 和 Widget? 的函数。 + +typedef TransitionBuilder = Widget Function(BuildContext context, Widget? child); + + + + +2. 动画控制器的使用 + +动画控制器是界面可以进行动画变化的驱动力,使用过程分为三步: 创建动画控制器、 在合适时机启动控制器、使用动画器的值构建界面 + +1.创建动画控制器主要在 State 派生类中进行:让 State 派生类混入 SingleTickerProviderStateMixin 后,将状态自身作为 AnimationController 的 vsync 入参: + +class _GuessPageState extends State with SingleTickerProviderStateMixin{ + late AnimationController controller; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + } + + +2.在恰当的时机可以通过 AnimationController 的 forward 方法启动控制器。让它的值从 0 变化到 1 : + +controller.forward(from: 0); + + +3.根据动画控制器的值,设置需要动画变化的属性。由于动画的触发非常频繁,推荐使用 AnimatedBuilder 监听控制器,实现局部组件的更新和构建: + + + + + +3. 随机数的使用 + +Dart 内部提供了 Random 类,因此获取随机数就非常方便。只要创建 Random 对象,提供 nextInt 就可以得到 0 ~ 100 的随机整数 (不包括 100) : + +Random _random = Random(); +_random.nextInt(100); + + +另外,可以通过 nextDouble 方法获取 0 ~ 1 的随机小浮点数 (不包括 1) ;通过 nextBool 方法获取随机的 bool 值: + + + + + +三、接触的内置组件 + +最后来整理一下目前猜数字项目中用到的 Flutter 内置组件,大家可以根据下表,结合源码以及应用界面,思考一下这些组件的作用和使用方式: + + + + + +1. 基础组件 + +基础组件是常用的简单组件,功能单一,相当于积木的最小单元: + + + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +Text +文本展示 +展示相关的文字信息 + + + +TextField +输入框展示 +头部的输入框,得到用户输入 + + + +Container +一个容器 +对比较结果界面进行着色 + + + +Icon +图标展示 +作为图标按钮的内容 + + + +IconButton +图标按钮,通过 onPressed 监听点击回调 +头部运行按钮, + + + +FloatingActionButton +浮动按钮 ,通过 onPressed 监听点击回调 +右下角按钮,生成随机数 + + + + + + +2. 组合结构型 + +有些组件是比较复杂的,在构造中可以配置若干个组件,作为各个部分。比如 AppBar 可以设置左中右下四个区域,Scaffold 可是设置上中下左右,它们像躯干一样,把布局结构已经固定了,只需将组件插入卡槽中即可。 + + + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +MaterialApp +整体应用配置 +作为代码中的组件顶层,提供主题配置 + + + +Scaffold +通用界面结构 +这里通过 appBar 设置标题、 body 设置字体内容 + + + +AppBar +应用标题栏 +展示标题栏 + + + + + + +3. 布局组件 + +布局组件无法在界面上进行任何色彩的展示,它们的作用是对其他组件进行排布与定位。比如 Row 和 Column 用于水平和竖直排列组件;Stack 让若干组件叠放排布;Center 可以将子组件居中。 + + + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +Row、Column +水平、竖直摆放若干组件 +通过 Column 竖直排列文字信息和比较结果 + + + +Expanded、Spacer +延展区域 +比较结果通过 Spacer 占位 + + + +Stack +叠放若干组件 +比较结果和主体内容进行叠放 + + + +Center +居中定位 +主体内容居中 + + + + + + +4. 构建器 + +构建器是指,通过该组件回调来完成构建组件的任务。也就是说回调触发时会进行重新构建,从而让构建缩小在局部。 + + + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +AnimatedBuilder +监听动画器,通过回调局部构建组件 +比较结果中文字的大小动画 + + + +到这里,猜数字的项目就总结完了,希望大家可以好好思考和体会,下面将进入 电子木鱼 的模块。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/12电子木鱼界面交互与需求分析.md b/专栏/Flutter入门教程/12电子木鱼界面交互与需求分析.md new file mode 100644 index 0000000..fd3ad7f --- /dev/null +++ b/专栏/Flutter入门教程/12电子木鱼界面交互与需求分析.md @@ -0,0 +1,154 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 电子木鱼界面交互与需求分析 + 1. 界面交互介绍 + +电子木鱼是本教程的第二个案例,相比于猜数字项目,功能需求比较复杂一点,适合新手朋友进一步了解 Flutter 相关知识。下面是两个最基础的交互: + + +点击木鱼图片发出敲击声,并增加功德,展示增加动画。 +点击标题栏右侧按钮,近进入功德记录页面。 + + + + + +点击木鱼 +查看功德记录 + + + + + + + + + + +光是点击图片发出音效未免有些单调,这里提供了两个选择功能,让应用的表现更丰富一些。 + + +点击右上角按钮,选择木鱼音效 +点击右上角按钮,选择木鱼样式 + + + + + +切换音效 +切换样式 + + + + + + + + + + + + + +2. 电子木鱼需求分析 + +现在从数据和界面的角度,来分析一下猜数字中的需求: + + +木鱼点击发声及功德记录 + + +在这个需求中,在每次点击时,需要产生一份记录数据,并将数据添加到列表中。这份记录数据包括 增加量、记录时间、当前音频、当前图片 四个数据。所以可以将四者打包在一个类中作为数据模型,比如定义 MeritRecord 类型: + +class MeritRecord { + final String id; + final int timestamp; + final int value; + final String image; + final String audio; + + MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio); + + Map toJson() => { + "id":id, + "timestamp": timestamp, + "value": value, + "image": image, + "audio": audio, + }; +} + + +对于界面来说,当功德记录发生变化时,触发当前功德的展示动画,功德数字会进行透明度、移动、缩放的叠加动画;进入功德详情页是,功德记录列表的数据将被传入其中,作为界面构建时的数据信息。由于数据量会很多,所以视图需要支持滑动。 + + + + +音效和样式的选择 + + +这两个需求的操作流程是类似的。拿音频来说,需要的数据有:支持的音频列表,列表中的每个元素需要有名称和资源两个数据,也可以通过一个类进行维护: + +class AudioOption{ + final String name; + final String src; + + const AudioOption(this.name, this.src); +} + + +有了支持的列表数据,还需要当前激活的数据,这里维护 int 型的激活索引,即可通过列表获取到激活数据。对于木鱼样式来说也是一样,通过一个类型维护每种样式需要的数据: + + class ImageOption{ + final String name; // 名称 + final String src; // 资源 + final int min; // 每次点击时功德最小值 + final int max; // 每次点击时功德最大值 + + const ImageOption(this.name, this.src, this.min, this.max); + } + + +对于界面来说,需要处理按钮的点击事件,从底部弹出选择的面板,在选择之后隐藏。选择面板中根据数据列表展示可选项,并根据激活索引控制当前的激活状态。 + + + + + +3. 电子木鱼中的知识点 + +首先,电子木鱼项目会 额外 接触到如下的常用组件,大家再完成电子木鱼项目的同时,也会了解这些组件的使用方式。 + + + + + +另外,会对 Flutter 中的界面相关的知识有进一步的认知: + + +数据模型与界面展示 +组件的封装性 +State 状态类的生命周期回调 +组件有无状态的差异性 +界面的跳转 + + + + +最后,Flutter 中可以依赖别人的类库完成自己项目的某些需求,将在电子木鱼项目中了解依赖库的使用方式。通过多种动画变换的使用,也可以加深对动画的理解。 + + +资源配置与依赖库的使用 +多种动画变换 +短音效的播放 +唯一标识 uuid + + +界面交互和需求分析就到这里,下面一起开始第二个小项目的学习吧! + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/13电子木鱼静态界面构建.md b/专栏/Flutter入门教程/13电子木鱼静态界面构建.md new file mode 100644 index 0000000..209a3ab --- /dev/null +++ b/专栏/Flutter入门教程/13电子木鱼静态界面构建.md @@ -0,0 +1,267 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 电子木鱼静态界面构建 + 下面我们正式开始 电子木鱼 小项目的开发,在 lib 下创建一个 muyu 的文件夹,用于存放电子木鱼相关代码文件: + + + + + +1、界面布局分析 + +本篇主要完成电子木鱼的主要静态界面的构建、如下所示,分别是界面效果和布局分析效果。从右图可以很轻松地看出界面中的布局情况: + + + + +界面效果 +布局查看 + + + + + + + + + + + + +分为上中下三块,上方是标题、中间是文字和按钮、下方是图片。 +整体使用 Scaffold 组件,头部使用 AppBar 组件;主体内容上下平分,可以使用 Column + Expanded 组合。 +上半部分的两个绿色按钮,可以通过 Stack + Positioned 组合叠放在右上角。 +下半部分通过 Centent + Image 组件让图片居中对其,并通过 GestureDetector 监听点击事件。 + + + + +2. 电子木鱼整体界面 MuyuPage + +电子木鱼在功能上和计数器是非常相似的,只不过是敲击点不同,界面不同不同罢了。由于点击过程中 功德数 会进行变换,使用这里让 MuyuPage 继承自 StatefulWidget ,通过对于的状态类 _MuyuPageState 来维护数据的和界面的构建及更新。 + +class MuyuPage extends StatefulWidget { + const MuyuPage({Key? key}) : super(key: key); + + @override + State createState() => _MuyuPageState(); +} + + +在 _MuyuPageState 的 build 方法中,对整体界面进行构建。使用 Scaffold 组件提供通用结构,appBar 入参组件确实头部标题栏,一般使用 AppBar 组件。其中有很多配置属性: + + +backgroundColor: 标题栏的背景色。 +elevation:标题栏的阴影深度。 +titleTextStyle: 标题的文字样式。 +iconTheme: 标题栏的图标主题。 +actions : 标题栏右侧展示的组件列表。 + + +这里说一下 titleTextStyle 和 iconTheme 。 如果直接为 title 中的文字设置样式,直接为 actions 中的图标设置颜色,效果是一样的。但如果 actions 有很多图标按钮,一个个配置就非常麻烦,而使用主题,可以提供默认样式,减少很多重复的操作。 + + + +class _MuyuPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.white, + titleTextStyle: const TextStyle(color: Colors.black,fontSize: 16,fontWeight: FontWeight.bold),, + iconTheme: const IconThemeData(color: Colors.black), + title: const Text("电子木鱼"), + actions: [ + IconButton(onPressed: _toHistory,icon: const Icon(Icons.history)) + ], + ), + ); + } + + void _toHistory() {} +} + + + + +主体内容是上下平分区域,可以使用 Column + Expanded 组件。建议再写构建组件代码时,不要塞在一块,适当的通过函数或类进行隔离封装。比如下面代码中,两个部分组件的构建,交给两个方法完成,这样可以让组件构件的条理更清晰,易于阅读和修改。 + +Scaffold( + // 略同... + body: Column( + children: [ + Expanded(child: _buildTopContent()), + Expanded(child: _buildImage()), + ], + ), +) + + + + +3. 主体内容的构建 + +上半部分界面交由 _buildTopContent 方法构建,效果如下: 其中 功德数 文字居中显示;上角有两个按钮,分别用于切换音效和切换图片;按可以通过 ElevatedButton 组件进行展示,并通过 style 入参调节按钮样式。 + +另外,两个按钮上下排列,可以使用 Column 组件,也可以使用竖直方向的 Wrap 组件。使用 Wrap 组件可以通过 spacing 参数,控制子组件在排列方向上的间距,想比 Column 来说方便一些。 + + + +Widget _buildTopContent() { + // 按钮样式 + final ButtonStyle style = ElevatedButton.styleFrom( + minimumSize: const Size(36, 36), // 最小尺寸 + padding: EdgeInsets.zero, // 边距 + backgroundColor: Colors.green, // 背景色 + elevation: 0, // 阴影深度 + ); + + return Stack( + children: [ + Center( + child: Text( + '功德数: 0', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + + Positioned( + right: 10, + top: 10, + child: Wrap( + spacing: 8, + direction: Axis.vertical, + children: [ + ElevatedButton( + style: style, + onPressed: () {}, + child: Icon(Icons.music_note_outlined), + ), + ElevatedButton( + style: style, + onPressed: () {}, + child: Icon(Icons.image), + ) + ], + )), + ], + ); +} + + + + +下半部分界面交由 _buildImage 方法构建,效果如下:是一个居中展示的图片,在 Flutter 中,展示图片可以使用 Image 组件。这里使用 Image.asset 构造函数,从本地资源中加载图片: + + + +Widget _buildImage() { + return Center( + child: Image.asset( + 'assets/images/muyu.png', + height: 200, //图片高度 + )); +} + + +想要使用本地资源,需要进行配置。一般来说,会在项目中创建一个 assets 文件夹,用于盛放本地资源文件,比如图片、音频、文本等。这里把图片放在 images 文件夹中,如何在 pubspec.yaml 文件的 flutter 节点下配置资源文件夹,这样 images 中的资源就可以使用了。 + + + +到这里,我们就已经实现了期望的布局效果,当前代码位置 muyu_page.dart 。从静态界面的构建过程中,不难体会出:这就像通过已经存在的积木,拼组成我们期望的展示效果。 + + + + +界面效果 +布局查看 + + + + + + + + + + + + + +4. 组件的封装 + +一个自定义的 Widget ,可以封装一部分界面展示内容的构建逻辑。在开发过程中,我们应该避免让一个 Widget 干所有的事,否则代码会非常杂乱。应该有意识地合理划分结构,将部分的构建逻辑独立出去,以便之后的修改和更新。 + +当前代码中,通过两个函数来封装上下部分界面的构建逻辑。但函数仍在状态类 _MuyuPageState 中,随着需求的增加,会导致一个类代码会越来越多。我们也可以将界面某部分的构建逻辑,通过自定义 Widget 来分离出去。 + + + +这里通过 CountPanel 组件来封装上半部分界面的构建逻辑。可以分析一下,界面在构建过程中需要依赖的数据,并通过构造函数传入数据。其中的构建逻辑和上面的 _buildTopContent 方法一样的。 + +如果把这些构建逻辑比作一个人,那函数封装和组件封装,就相当于这个人穿了不同的衣服。其内在的本质上并没有太大的差异,只是外部的表现不同罢了。函数封装,在其他的类中,相当于给别人打工;组件封装,有自己的类名,正规编制,相当于自己当老板,经营构建界面逻辑。 + + + +class CountPanel extends StatelessWidget { + final int count; + final VoidCallback onTapSwitchAudio; + final VoidCallback onTapSwitchImage; + + const CountPanel({ + super.key, + required this.count, + required this.onTapSwitchAudio, + required this.onTapSwitchImage, + }); + + Widget build(BuildContext context) { + //同上 _buildTopContent 方法 + } +} + + +另外,对于组件封装而言,会遇到一些事件的触发。比如这里点击切换音效按钮该做什么,对于 CountPanel 而言是不关心的,它只需要经营好界面的构建逻辑即可。这时可以通过 回调 的方式,交由使用者来处理,其实按钮组件的 onPressed 回调入参也是这种思路: + + + + + +这样,在 _MuyuPageState 中,可以直接使用 CountPanel 完成上半部分的展示,从而减少状态类中的代码量。而 CountPanel 组件的职责也很专一,就更容易做好一件事。 + +Expanded( + child: CountPanel( + count: 0, + onTapSwitchAudio: _onTapSwitchAudio, + onTapSwitchImage: _onTapSwitchImage, + ), +), + +void _onTapSwitchAudio() {} + +void _onTapSwitchImage() {} + + + + +5.本章小结 + +本章主要对电子木鱼的静态界面进行搭建,除了学习使用基础组件布局之外,最主要的目的是了解组件对构建逻辑的封装。封装可以自责进行隔离,并在相似的场景中可以复用。 + +就像皇帝不可能一个人治理整个国家。无法良好地组织和管理各个区域,把所有事交由一个人来做,随着疆域的扩大,早晚会因为臃肿而无法前进,会阻碍社会(应用)的发展。虽然对于新手而言,关注代码的组织方式为时过早,但要有这种意识,避免出现一个掌控所有逻辑的 上帝类 。 + + +小练习: 通过 MuyuAssetsImage 组件封装下半部分的界面构建逻辑。 当前代码位置 muyu + + +到这里,基本的静态界面就搭建完成了,下一章将处理一下基本的交互逻辑。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/14计数变化与音效播放.md b/专栏/Flutter入门教程/14计数变化与音效播放.md new file mode 100644 index 0000000..4508e3a --- /dev/null +++ b/专栏/Flutter入门教程/14计数变化与音效播放.md @@ -0,0 +1,322 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 计数变化与音效播放 + 1. 组件点击事件的监听 + +计时器项目中的 FloatingActionButton 按钮,猜数字项目中的 IconButton 点击事件,都是通过构造中的 onPressed 参数传入函数,执行相关逻辑。我们称这种以函数对象作为参数的形式为 回调函数 。现在我们的需求是:让图片可以响应点击事件,每次点击时功德数随机增加 1~3 点,并发出敲击的音效。 + + + + +———————————————————— +———————————————————— + + + + + + + + +如下所示,在 MuyuAssetsImage 中使用 GestureDetector 嵌套在 Image 之上,这样图片区域内的点击事件,可以通过 onTap 回调监听到。另外,点击时功德累加的逻辑,和图片构建并没有太大关系,这里通过 onTap 入参,将事件向上级传递,交由使用者处理。 + +class MuyuAssetsImage extends StatelessWidget { + final String image; + final VoidCallback onTap; + + const MuyuAssetsImage({super.key, required this.image, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Center( + child: GestureDetector( // 使用 GestureDetector 组件监听手势回调 + onTap: onTap, + child: Image.asset( + image, + height: 200, + ), + ), + ); + } +} + + + +通过 GestureDetector 组件的 onTap 参数,可以监听到任何组件在其区域内的点击事件。 + + + + +在 _MuyuPageState#build 方法中,使用 MuyuAssetsImage 组件时,通过 onTap 参数指定逻辑处理函数,这和 FloatingActionButton的onPressed 点击事件本质上是一样的。这里通过 _onKnock 函数处理点击木鱼的逻辑: + +---->[_MuyuPageState#build]---- +Expanded( + child: MuyuAssetsImage( + image: 'assets/images/muyu.png', + onTap: _onKnock, + ), +), + + +在 _MuyuPageState 中维护界面中需要的状态数据,这里最主要的是功德计数变量,通过 _counter 进行表示。另外,点击时功德数随机增加 1~3 ,需要随机数对象。这样,目前的逻辑就比较清晰了:在 _onKnock 函数中,对 _counter 进行累加即可,每次累加值为 1 + _random.nextInt(3) : + +int _counter = 0; +final Random _random = Random(); + +void _onKnock() { + setState(() { + int addCount = 1 + _random.nextInt(3); + _counter += addCount; + }); +} + + +最后,将 _counter 作为上半界面 CountPanel 的入参即可。这样点击时,更新 _counter 后,会重新构建,从而展示最新的数据。到这里本质上和计数器没有太大的差异: + + + + +当前代码位置 muyu + + + + +2. 插件的使用和配置 + +Flutter 是一个跨平台的 UI 框架,而音效的播放是平台的功能。在开发过程中,一般使用 插件 来完成平台功能。得益于 Flutter 良好的生态环境,在 pub 中有丰富的优秀插件和包。这里使用 flame_audio 插件来实现短音效播放的功能: + + + + + +使用插件,首先要在 pubspec.yaml 的 dependencies 节点下配置依赖,在 pub 中的 installing 页签中,可以看到依赖的版本号: + + + + + +如下所示,加入依赖后,点击 pub get 获取依赖包: + + + + + +现在要播放音效,首先需要相关的音频资源,而使用本地资源,需要在 pubspec.yaml 中进行配置:这里准备了三个木鱼点击的音效,放在 audio 文件夹中。 + + + + + +3. 完成点击播放音效功能 + +点击事件的逻辑处理在 _MuyuPageState 类中,所以播放音效在该类中处理比较方便。插件的使用也比较简单,首先需要加载资源,通过 FlameAudio.createPool 方法创建 AudioPool 对象。在状态类的 initState 方法中使用 _initAudioPool 创建 pool 对象。 + +其中第一个参数是资源的名称,flame_audio 默认将本地资源放在 assets/audio 中,指定资源时直接写文件名即可。 + +---->[_MuyuPageState]---- +AudioPool? pool; + +@override +void initState() { + super.initState(); + _initAudioPool(); +} + +void _initAudioPool() async { + pool = await FlameAudio.createPool( + 'muyu_1.mp3', + maxPlayers: 4, + ); +} + + +然后,只需要在 _onKnock 方法在调用 pool 的 start 方法进行播放即可: + +void _onKnock() { + pool?.start(); + // 略同... +} + + +到这里,点击木鱼时,就可以听到敲击木鱼的音效,同时功德数也会随机增加 1~3 点。这样就完成了木鱼最基础的功能需求,下面我们将继续优化和拓展,添加一些新的视觉表现和功能。 + + +当前代码位置 muyu + + + + +4. 增加功德时的动画展示 + +如下所示,现在需要在每次点击时展示功德增加的数字,并让数值有动画效果。比如这里是 缩放、移动、透明度 三个动画的叠加,从效果的表现上来说就是文字一边上移,一边缩小、一边透明度降低。 + + + + +———————————————————— +———————————————————— + + + + + + + + + + + +首先来分析一下界面构建:界面上需要展示的信息增加了 当前增加的功德值 ,使用需要增加一个状态数据用于表示。这里使用 _cruValue 变量,该数据维护的时机也很清晰,在点击时更新 _cruValue 的值: + +---->[_MuyuPageState]---- +int _cruValue = 0; + +void _onKnock() { + pool?.start(); + setState(() { + _cruValue = 1 + _random.nextInt(3); + _counter += _cruValue; + }); +} + + +这样数据层面就搞定了,下面看一下界面构建逻辑。这里 当前功德 相当于一个浮层,可以通过 Stack 组件和下半部分进行叠放。如下所示,文字从下向上运动到下半部分的顶部: + + + +由于动画的逻辑比较复杂,这里封装 AnimateText 组件来完成动画文字的功能,其中构造函数传入需要展示的文本信息。下半部分通过 Stack 进行叠放,alignment 入参可以控制孩子们的对齐方式,比如这里 Alignment.topCenter , 将会以上方中心对齐。表现上来说,就是文字在区域的上方中间: + + + + + +现在数据和布局已经完成,就差动画效果了。在猜数字中我们简单地了解过动画的使用,通过 AnimatedBuilder 组件,监听动画控制器的变化,在构建组件的过程中让某些属性值不断变化。这里介绍一下几个 XXXTransition 动画组件的使用。 + +先拿 FadeTransition 组件来说,它的构造中需要传入 Animation 的动画器,表示透明度的动画变化。AnimationController 的数值运动是从 0 ~ 1 ,而这里透明度的变化是 1 ~ 0,此时可以使用 Tween 来得到期望的补间动画器。如下 tag1 处,创建了 1~0 变化的动画器 opacity 。在 build 中,使用 FadeTransition 传入 opacity 动画器,当动画控制器运动时,子组件就会产生透明度动画。 + + + + +———————————————————— +———————————————————— + + + + + + + + + +class AnimateText extends StatefulWidget { + final String text; + + const AnimateText({Key? key, required this.text}) : super(key: key); + + @override + State createState() => _FadTextState(); +} + +class _FadTextState extends State with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation opacity; + + @override + void initState() { + super.initState(); + controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 500)); + opacity = Tween(begin: 1.0, end: 0.0).animate(controller); // tag1 + controller.forward(); + } + + @override + void didUpdateWidget(covariant AnimateText oldWidget) { + super.didUpdateWidget(oldWidget); + controller.forward(from: 0); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: opacity, + child: Text(widget.text), + ); + } +} + + + +当前代码位置 muyu + + + + +同理,使用 ScaleTransition 可以实现缩放动画;使用 SlideTransition 可以实现移动动: + +@override +Widget build(BuildContext context) { + return ScaleTransition( + scale: scale, + child: SlideTransition( + position: position, + child: FadeTransition( + opacity: opacity, + child: Text(widget.text), + )), + ); +} + + +XXXTransition 组件都需要指定对应类型的 Animation 动画器,而这些动画器可以以 AnimationController 为动力源,通过 Tween 来生成。 缩放变换传入 scale 动画器,从 1 ~ 0.9 变化;移动变化传入 position 动画器,泛型为 Offset,从 Offset(0, 2) ~ Offset.zero 变化,对应的效果就是:在竖直方向上的偏移量,从两倍子组件高度变化到 0 。 + + + + +———————————————————— +———————————————————— + + + + + + + + + +class _FadTextState extends State with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation opacity; + late Animation position; + late Animation scale; + + @override + void initState() { + super.initState(); + controller = AnimationController(vsync: this, duration: Duration(milliseconds: 500)); + opacity = Tween(begin: 1.0, end: 0.0).animate(controller); + scale = Tween(begin: 1.0, end: 0.9).animate(controller); + position = Tween(begin: const Offset(0, 2), end: Offset.zero,).animate(controller); + controller.forward(); + } + + + + +5.本章小结 + +本章我们学习了如何在自己的项目中使用别人提供的类库,这样就可以很轻松地完成复杂的功能。比如这里点击时的音效播放,如果完全靠自己来写代码实现,将会非常困难。但使用别人提供的插件,几行代码就搞定了。所以说,对于一项技术而言,良好的生态是非常重要的。 + +你可以使用别人的代码实现功能,方便大家;也可以分享自己的代码以供别人使用,让大家一起完善,这就是开源的价值。到这里,就完成了增加功德数动画的展示效果,当前代码位置 muyu 。下一篇将继续对当前项目进行功能拓展,增加切换音效和木鱼图片的效果。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/15弹出选项与切换状态.md b/专栏/Flutter入门教程/15弹出选项与切换状态.md new file mode 100644 index 0000000..0db2f70 --- /dev/null +++ b/专栏/Flutter入门教程/15弹出选项与切换状态.md @@ -0,0 +1,419 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 弹出选项与切换状态 + 1. 选择木鱼界面分析 + +现在想要的效果如下所示,点击第二个绿色按钮时,从底部弹出木鱼图片的选项。从界面上来说,有如下几个要点: + + +需要展示木鱼样式的基本信息,包括名称、图片及每次功德数范围。 +需要将当前展示选择的木鱼边上蓝色边线,表示选择状态。 +选择切换木鱼时,同时更新主界面木鱼样式。 + + + + + +木鱼界面 +选择界面 + + + + + + + + + + +仔细分析可以看出,两个选项的布局结构是类似的,不同点在于木鱼的信息以及激活状态。所以只要封装一个组件,就可以用来构建上面的两个选择项,这就是封装的可复用性。不过在此之前先梳理一下木鱼的数据信息: +根据目前的需求设定,木鱼需要 名称、图片资源、增加功德范围 的数据,这些数据可以通过一个类型进行维护,比如下面的 ImageOption 类: + +--->[muyu/models/image_option.dart]--- +class ImageOption{ + final String name; // 名称 + final String src; // 资源 + final int min; // 每次点击时功德最小值 + final int max; // 每次点击时功德最大值 + + const ImageOption(this.name, this.src, this.min, this.max); +} + + +对于一个样式来说,依赖的数据是 ImageOption 对象和 bool 类型的激活状态。这里封装一个 ImageOptionItem 组件用于构建一种样式的界面: + + + +代码如下,红框中的单体是上中下的结果,通过 Column 组件竖向排列,另外使用装饰属性的 border ,根据是否激活,添加边线。其中的文字、图片数据都是通过 ImageOption 类型的成员确定的。 + +--->[muyu/options/select_image.dart]--- +class ImageOptionItem extends StatelessWidget { + final ImageOption option; + final bool active; + + const ImageOptionItem({ + Key? key, + required this.option, + required this.active, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + const Border activeBorder = Border.fromBorderSide(BorderSide(color: Colors.blue)); + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: !active ? null : activeBorder, + ), + child: Column( + children: [ + Text(option.name, style: TextStyle(fontWeight: FontWeight.bold)), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Image.asset(option.src), + ), + ), + Text('每次功德 +${option.min}~${option.max}', + style: const TextStyle(color: Colors.grey,fontSize: 12)), + ], + ), + ); + } +} + + + + +2.底部弹框及界面构建 + +由于有了新需求,_MuyuPageState 状态类需要添加一些状态数据来实现功能。如下,新增了 imageOptions 对象表示木鱼选项的列表;已及 _activeImageIndex 表示当前激活的木鱼索引: + +---->[_MuyuPageState]---- +final List imageOptions = const [ + ImageOption('基础版','assets/images/muyu.png',1,3), + ImageOption('尊享版','assets/images/muyu_2.png',3,6), +]; + +int _activeImageIndex = 0; + + + + +点击时的底部弹框,可以使用 showCupertinoModalPopup 方法实现。其中 builder 入参中返回底部弹框中的内容组件,这里通过自定义的 ImageOptionPanel 组件来完成弹框内界面的构建逻辑。 + +---->[_MuyuPageState]---- +void _onTapSwitchImage() { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return ImageOptionPanel( + imageOptions: imageOptions, + activeIndex: _activeImageIndex, + onSelect: _onSelectImage, + ); + }, + ); +} + + + + +很容易可以看出选择木鱼的面板中需要 ImageOption 列表、当前激活索引两个数据;由于点击选择时需要更新主界面的数据,所以这里通过 onSelect 回调,让处理逻辑交由使用者来实现。 + +--->[muyu/options/select_image.dart]--- +class ImageOptionPanel extends StatelessWidget { + final List imageOptions; + final ValueChanged onSelect; + final int activeIndex; + + const ImageOptionPanel({ + Key? key, + required this.imageOptions, + required this.activeIndex, + required this.onSelect, + }) : super(key: key); + + +界面的构建逻辑如下,整体结构并不复杂。主要是上下结构,上面是标题,下面是选项的条目。由于这里只要两个条目,使用 Row + Expanded 组件,让单体平分水平空间: + + + +@override +Widget build(BuildContext context) { + const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold); + const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8.0, vertical: 16); + return Material( + child: SizedBox( + height: 300, + child: Column( + children: [ + Container( + height: 46, + alignment: Alignment.center, + child: const Text( "选择木鱼", style: labelStyle)), + Expanded( + child: Padding( + padding: padding, + child: Row( + children: [ + Expanded(child: _buildByIndex(0)), + const SizedBox(width: 10), + Expanded(child: _buildByIndex(1)), + ], + ), + )) + ], + ), + ), + ); +} + + +在 _buildByIndex 中,简单封装一下根据索引生成条目组件的逻辑,并通过点击事件,触发 onSelect 回调,将条目对应的索引传递出去。 + +Widget _buildByIndex(int index) { + bool active = index == activeIndex; + return GestureDetector( + onTap: () => onSelect(index), + child: ImageOptionItem( + option: imageOptions[index], + active: active, + ), + ); +} + + + + +到这里,万事俱备只欠东风,在点击木鱼样式回调中,需要更新 _activeImageIndex 的值即可,这里如果点击的是当前样式,则不进行更新操作。另外,在选择时通过 Navigator.of(context).pop() 可以关闭底部弹框。 + +---->[_MuyuPageState]---- +void _onSelectImage(int value) { + Navigator.of(context).pop(); + if(value == _activeImageIndex) return; + setState(() { + _activeImageIndex = value; + }); +} + + +最后,主页面中的图片和点击时增加的值需要根据 _activeImageIndex 来确定。这里在 _MuyuPageState 中给两个 get 方法,方便通过 _activeImageIndex 和 imageOptions 获取需要的信息: + +---->[_MuyuPageState]---- +// 激活图像 +String get activeImage => imageOptions[_activeImageIndex].src; + +// 敲击是增加值 +int get knockValue { + int min = imageOptions[_activeImageIndex].min; + int max = imageOptions[_activeImageIndex].max; + return min + _random.nextInt(max+1 - min); +} + + +//... +MuyuAssetsImage( + image: activeImage, // 使用激活图像 + onTap: _onKnock, +), + +void _onKnock() { + pool?.start(); + setState(() { + _cruValue = knockValue; // 使用激活木鱼的值 + _counter += _cruValue; + }); +} + + +这样,就可以选择木鱼样式的功能,在敲击时每次功德的增加量也会不同。如下右图中,尊享版木鱼每次敲击,数字增加在 3~6 之间随机。当前代码位置 muyu + + + + +选择 +切换后敲击 + + + + + + + + + + + + +这里有个小问题,可以作为思考,这个问题将会在下一篇解决: +如上左图,在切换图片时,会看到 功德+2 的动画,这是为什么呢? 有哪些方式可以解决这个问题。 + + + + +3. 选择音效功能 + +选择音效的整体思路和上面类似,点击主界面右上角第一个按钮,从底部弹出选择界面,这里准备了三个音效以供选择。当前音效也会高亮显示,另外右侧有一个播放按钮可以试听: + + + + +主界面 +选择音效界面 + + + + + + + + + + + + + +首先还是对数据进行一下封装,对于音频选择界面而言,两个信息数据: 名称 和 资源。这里通过 AudioOption 类进行 + +--->[muyu/models/audio_option.dart]--- +class AudioOption{ + final String name; + final String src; + + const AudioOption(this.name, this.src); +} + + +在 _MuyuPageState 中,通过 audioOptions 列表记录音频选项对应的数据;_activeAudioIndex 表示当前激活的音频索引: + +---->[_MuyuPageState]---- +final List audioOptions = const [ + AudioOption('音效1', 'muyu_1.mp3'), + AudioOption('音效2', 'muyu_2.mp3'), + AudioOption('音效3', 'muyu_3.mp3'), +]; + +int _activeAudioIndex = 0; + + +如何同样,在点击按钮时,通过 showCupertinoModalPopup 弹出底部栏,使用 AudioOptionPanel 构建展示内容: + +---->[_MuyuPageState]---- +void _onTapSwitchAudio() { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return AudioOptionPanel( + audioOptions: audioOptions, + activeIndex: _activeAudioIndex, + onSelect: _onSelectAudio, + ); + }, + ); +} + + + + +目前只有三个数据,这里通过 Column 竖直排放即可,如果你想支持更多的选项,可以使用滑动列表(下篇介绍)。 另外,对于条目而言,Flutter 提供了一些通用的结构,比如这里可以使用 ListTile 组件,来构建左中右的视图。 + + + +AudioOptionPanel 界面构建逻辑如下,其中提供 List.generate 构造函数生成条目组件列表;每个条目由 _buildByIndex 方法根据 index 索引进行构建;构建的内容使用 ListTile 根据 AudioOption 数据进行展示。 + + +注: 这里 listA = [1,2,…listB] 表示在创建 listA 的过程中,将 listB 列表元素嵌入其中。 + + +--->[muyu/options/select_audio.dart]--- +class AudioOptionPanel extends StatelessWidget { + final List audioOptions; + final ValueChanged onSelect; + final int activeIndex; + + const AudioOptionPanel({ + Key? key, + required this.audioOptions, + required this.activeIndex, + required this.onSelect, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold); + return Material( + child: SizedBox( + height: 300, + child: Column( + children: [ + Container( + height: 46, + alignment: Alignment.center, + child: const Text("选择音效", style: labelStyle, )), + ...List.generate(audioOptions.length, _buildByIndex) + ], + ), + ), + ); + } + + Widget _buildByIndex(int index) { + bool active = index == activeIndex; + return ListTile( + selected: active, + onTap: () => onSelect(index), + title: Text(audioOptions[index].name), + trailing: IconButton( + splashRadius: 20, + onPressed: ()=> _tempPlay(audioOptions[index].src), + icon: const Icon( + Icons.record_voice_over_rounded, + color: Colors.blue, + ), + ), + ); + } + + void _tempPlay(String src) async{ + AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1); + pool.start(); + } +} + + + + +最后,在 _MuyuPageState 中,选择音频回调时,根据激活索引,更新 pool 对象即可。这样在点击时就可以播放对应的音效。 + +---->[_MuyuPageState]---- +String get activeAudio => audioOptions[_activeAudioIndex].src; + +void _onSelectAudio(int value) async{ + Navigator.of(context).pop(); + if (value == _activeAudioIndex) return; + _activeAudioIndex = value; + pool = await FlameAudio.createPool( + activeAudio, + maxPlayers: 1, + ); +} + + + + +4.本章小结 + +本章通过弹出框展示切换音效和木鱼样式,可以进一步理解数据和界面视图之间的关系:数据为界面提供内容信息、界面交互操作更改数据信息。 + +另外,两个选项的面板通过自定义组件进行封装隔离,面板在需要的数据通过构造函数传入、界面中的事件通过回调函数交由外界执行更新数据逻辑。也就是说,封装的 Widget 在意的是如何构建展示内容,专注于做一件事,与界面构建无关的任务,交由使用者处理。 + +到这里,本篇新增的两个功能就完成了,当前代码位置 muyu 。目前为止,木鱼项目的代码已经有一点点复杂了,大家可以结合源码,好好消化一下。下一篇将继续对木鱼项目进行功能拓展,并以此学习一下滑动列表。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/16用滑动列表展示记录.md b/专栏/Flutter入门教程/16用滑动列表展示记录.md new file mode 100644 index 0000000..8a48813 --- /dev/null +++ b/专栏/Flutter入门教程/16用滑动列表展示记录.md @@ -0,0 +1,224 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 用滑动列表展示记录 + 1. 功德记录的功能需求 + +现在想要记录每次点击时的功德数据,以便让用户了解功德增加的历史。对于一个需求而言,最重要的是分析其中需要的数据,以及如何维护这些数据。 + +效果如下,点击左上角按钮,跳转到功德记录的面板。功德记录界面是一个可以滑动的列表,每个条目展示当前功德的 木鱼图片、音效名称、时间、功德数 信息: + + + + +标题 + + + + + + + + + + + + + + +根据界面中的数据,可以封装一个类来维护,如下 MeritRecord 类。其中 id 是一条记录的身份标识,就像人的身份证编号,就可以确保他在社会中的唯一性。 + +class MeritRecord { + final String id; // 记录的唯一标识 + final int timestamp; // 记录的时间戳 + final int value; // 功德数 + final String image; // 图片资源 + final String audio; // 音效名称 + + MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio); +} + + +对于功德记录的需求而言,需要在 _MuyuPageState 中新加的数据也很明确: 对 MeritRecord 列表的维护。接下来的任务就是,在点击时,为 _records 列表添加 MeritRecord 类型的元素。 + +---->[_MuyuPageState]---- +List _records = []; + + + + +2. 列表数据的维护 + +对于生成唯一 id , 这里使用 uuid 库,目前最新版本 3.0.7。记得在 pubspec.yaml 的依赖节点配置: + +dependencies: + ... + uuid: ^3.0.7 + + +如下代码是 _onKnock 函数处理点击时的逻辑,其中创建 MeritRecord 对象,构造函数中的相关数据,在状态类中都可以轻松的到。 + +---->[_MuyuPageState]---- +final Uuid uuid = Uuid(); + +void _onKnock() { + pool?.start(); + setState(() { + _cruValue = knockValue; + _counter += _cruValue; + // 添加功德记录 + String id = uuid.v4(); + _records.add(MeritRecord( + id, + DateTime.now().millisecondsSinceEpoch, + _cruValue, + activeImage, + audioOptions[_activeAudioIndex].name, + )); + }); +} + + + + +这里来分析一下上一篇中切换木鱼样式,会导致动画触发的问题。原因非常简单,因为动画控制器会在 didUpdateWidget 中启动。而切换木鱼样式时,通过触发了 setState 更新主界面的木鱼图片。于是会连带触发动画器的启动,解决方案有很多: + + +解决方案 1 : 将动画控制器交由上层维护,仅在点击时启动动画。 +解决方案 2 :为 didUpdateWidget 回调中动画启动添加限制条件。 +解决方案 3 :切换木鱼样式时,使用局部更新图片,不重新构建整个 _MuyuPageState 。 + + + + +上面的三个方案中各有优劣,综合来看,方案 1 是最好的;方案 2 次之;方案 3 虽然可以解决当前问题,但治标不治本。就当前代码而言,使用 方案 2 处理最简单,那么动画启动的限制条件是什么呢? + + +当功德发生变化时,才需要启动动画。 + + +而现在每个功德对应一个 MeritRecord 对象,且 id 可以作为功德的身份标识。功德发生变化 ,也就意味着新旧功德 id 的不同。那么具体的方案就呼之欲出了,如下所示:让 AnimateText 持有 MeritRecord 数据 + + + +在状态类 didUpdateWidget 回调中,可以访问到旧的组件,通过对比新旧 AnimateText 组件持有的 record 记录 id ,当不同时才启动动画。这样就避免了在功德未变化的情况下,启动动画的场景。通过个问题的解决,想必大家对状态类的 didUpdateWidget 方法,有更深一点的了解。 + +@override +void didUpdateWidget(covariant AnimateText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.record.id != widget.record.id) { + controller.forward(from: 0); + } +} + + + +小练习: 自己尝试使用解决方案 1 ,解决动画误启动问题。 + + + + +3. ListView 的简单使用 + +我们已经知道,通过 Column 组件可以实现若干个组件的竖向排列。但当组件数量众多时,超越 Column 组件尺寸范围后,就无法显示;并且在 Debug 模式中会出现越界的异常(下左图)。很多场景中,需要支持视图的滑动,可以使用 ListView 组件来实现 (下右图): + + + + +Column +ListView + + + + + + + + + + +这里历史记录界面通过 RecordHistory 组件构建,构造函数中传入 MeritRecord 列表。主体内容通过 ListView.builder 构建滑动列表,通过 _buildItem 方法,根据索引值返回条目组件。 + +class RecordHistory extends StatelessWidget { + final List records; + + const RecordHistory({Key? key, required this.records}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar:_buildAppBar(), + body: ListView.builder( + itemBuilder: _buildItem, + itemCount: records.length, + ), + ); + } + + PreferredSizeWidget _buildAppBar() => + AppBar( + iconTheme: const IconThemeData(color: Colors.black), + centerTitle: true, + title: const Text( + '功德记录', style: TextStyle(color: Colors.black, fontSize: 16),), + elevation: 0, + backgroundColor: Colors.white, + ); + + +在 _buildItem 方法中,使用 ListTile 组件构建条目视图;其中: + + +leading 表示左侧组件,使用 CircleAvatar 展示圆形图形; +title 表示标题组件,展示功德数; +subtitle 表示副标题组件,展示音效名称; +trailing 表示尾部组件,展示日期。 +注: 这里使用 DateFormat 来格式化时间,需要在依赖中添加 intl: ^0.18.1 包。 + + +DateFormat format = DateFormat('yyyy年MM月dd日 HH:mm:ss'); + + Widget? _buildItem(BuildContext context, int index) { + MeritRecord merit = records[index]; + String date = format.format(DateTime.fromMillisecondsSinceEpoch(merit.timestamp)); + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue, + backgroundImage: AssetImage(merit.image), + ), + title: Text('功德 +${merit.value}'), + subtitle: Text(merit.audio), + trailing: Text( + date, style: const TextStyle(fontSize: 12, color: Colors.grey),), + ); + } +} + + + + +最后在 _MuyuPageState 中处理 _toHistory 点击事件,通过 Navigator 跳转到 RecordHistory 。 + +---->[_MuyuPageState]---- +void _toHistory() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RecordHistory( records: _records.reversed.toList()), + ), + ); +} + + + + +4.本章小结 + +本章主要介绍了如何使用可滑动列表展示非常多的内容,ListView 组件可以很便捷地帮我们让记录列表支持滑动。滑动视图使用起来并不是很复杂,但真的能用好,理解其内部的原理,还有很长的路要走。对于新手而言,目前简单能用就行了,以后可以基于小册来深入研究。 + +到这里,电子木鱼的基本功能完成了,当前代码位置 muyu 。不过现在的数据都是存储在内存中的,应用退出之后无论是选项,还是功德记录都会重置。想要数据持久化存储,在后面的 数据的持久化存储 一章中再继续完善,木鱼项目先告一段落。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/17电子木鱼整理与总结.md b/专栏/Flutter入门教程/17电子木鱼整理与总结.md new file mode 100644 index 0000000..e7f3c24 --- /dev/null +++ b/专栏/Flutter入门教程/17电子木鱼整理与总结.md @@ -0,0 +1,308 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 电子木鱼整理与总结 + 通过上面 5 章的学习,我们已经完成了一个简单的电子木鱼小项目。这里将对项目在的一些知识点进行整理和总结,主要从 界面相关知识 和 获得的技能点 两个方面进行总结: + + + + + +一、 界面相关的知识 + +电子木鱼的界面交互要比猜数字要复杂很多,比如选择切换音效和图片、跳转历史记录界面。所以需要维护的数据以及组件的构建逻辑也更复杂,其中蕴含的知识也就更多。 + +1. 数据模型与界面展示 + +在界面中往往会有一些数据相关性很强,界面构建中需要很多份这样的数据。比如每个木鱼的样式,都有名称、资源、功德范围。这些数据就像一个个豌豆粒,一个个单独维护会非常复杂,把它们封装在一个类中,就相当于让它们住进一个豌豆皮中。 + + + +所以这种对若干个数据进行封装的类型也被形象地称之为 Bean,也可以称之为 数据模型 Model 。比如这里的 ImageOption 类型就是对木鱼样式数据的封装,一个 ImageOption 对象就对应着界面展示中的一个图片选项。 + +class ImageOption{ + final String name; // 名称 + final String src; // 资源 + final int min; // 每次点击时功德最小值 + final int max; // 每次点击时功德最大值 + + const ImageOption(this.name, this.src, this.min, this.max); +} + + + + +2. 组件的封装性 + +还拿木鱼选择的界面来说,可以看出两个木鱼选项的界面结构是完全一样的,只不过是内容数据信息的不同。这时并没有必要写两份构建逻辑,通过一个组件进行封装,界面构建这依赖的数据通过构造中传入。比如这里依赖 ImageOption 和是否激活两个数据: + + + +封装数据模型类之后,在传参时也更加方便,否则就需要传入一个个的豌豆粒。在构建逻辑中就可以根据传入的数据,构建对应的组件,完成界面展示的任务。 + + + +其中 ImageOptionItem 组件也像一个豌豆,里面的豌豆粒就是构建过程中使用的组件。豌豆把豌豆粒包裹在一块,这就是一种现实中的 封装 。为 ImageOptionItem 组件提供不同的 ImageOption,就可以展示不同的界面。所以这里只需要准备份数据就行了,而不是重复写两次构建逻辑。 + + +这就是封装的一大特点: 可复用性。 + + +复用的目标就是封装的内容,对于组件来说,封装的目标就是构建逻辑。 + +ImageOptionItem(option: option1, active: true), +ImageOptionItem(option: option2, active: false), + + +可复用性往往会让使用变得简单,比如 Text 组件是源码提供的组件,它封装了文本渲染的过程。对于使用者来说,只要创建 Text 对象,并不需要理解底层的渲染原理。如果 ImageOptionItem 组件是 A 同学写的,那么把它发给 B 同学,那 B 同学只需要准备数据即可。所以,逻辑一旦封装,也会有 普适性 : 一人封装,万人可用,这也是类库生态的基石。 + + + +3. State 状态类的生命周期回调 + +State 的生命周期回调是非常重要的,但对于初学者来说,目前只能先了解这些回调的作用和使用方式。 + + +initState 回调方法会在 State 对象创建后被触发一次,可以在其中处理成员对象的初始化逻辑。比如动画控制器创建、音频播放器对象的创建等: + + + + + +dispose 回调方法会在 State 对象销毁前触发一次,用于释放一些资源,比如动画控制器、文字控制器、音频播放器等的销毁工作。 + + + + + +build 回调方法返回 Widget 对象,也就是组件的构建逻辑。该方法可能在一个 State 对象生命之中可能触发多次,通过 State#setState 方法可以导致 build 方法重新执行。 + + + + + +didUpdateWidget 会在 State 对应的组件发生变化时被触发;它在一个 State 对象生命之中也可能触发多次。最常见的场合是上层状态类触发 setState,导致组件发生变化,该回调中可以访问到旧的组件,通常会根据新旧组件的配置属性来触发事件,或更新一些状态类内部数据。 + + + + +现在只需要会用这四个回调即可,想要完全把握 State 状态类的生命周期回调,需要从源码的角度去理解回调触发的流程。希望之后,你可以在后续的路途上,通过 《Flutter 渲染机制 - 聚沙成塔》 找到满意的答案。 + + + +4. 界面的跳转 + +MaterialApp 的内部集成了一套路由体系,在代码中只通过 Navigator.of(context) 来获取 NavigatorState 对象,该对象的 push 方法实现可以跳转界面的功能。下面代码中 MaterialPageRoute 是一个默认的跳转动画界面路由,其中的 builder 方法用来构建跳转到的界面: + +Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RecordHistory( + records: _records.reversed.toList(), + ), + ), +); + + + + +二、 技能点 + +在电子木鱼项目中,涉及到了资源的引入,以及使用依赖库。依赖库的存在,极大方便了开发者。你可以轻松地获取和使用别人封装好的代码,来为自己的项目服务,比如这里的音频播放器、uuid 的获取以及日期时间的格式化。 + +1. 资源配置与依赖库的使用 + +Flutter 项目的配置文件是 pubspec.yaml ,其中依赖库配置在 dependencies 节点下。配置之后记得添加一右上角的 pub get 按钮获取依赖,或者在根目录下的命令行中输入: + + +flutter pub get + + + + +获取依赖之后,就可以导入包中的文件,然后使用其中的类或方法: + +import 'package:flame_audio/flame_audio.dart'; + + +图片、文本等本地资源需要在 flutter 节点下的 asstes 下配置,一般习惯于将资源文件放在项目根目录的 assets 文件夹中。配置时只需要写文件夹,就可以访问期内的所有文件: + + + + + +2. 多种动画变换 + +木鱼在点击时,当前功德文字会以 缩放+移动+透明度 动画变化,代码中通过多个 XXXTransition 嵌套实现的。除了这三种,还有些其他的 Transition ,在使用时都需要使用者提供相关类型的动画器: + + + +这里想强调的是 : 基于一个动画控制器,可以通过 Tween 来生成其他类型的动画器 Animation ,它们共用一个动力源。也就是说 controller 启动时,这些动画器都会进行动画变化: + + + + + +3. 短音效的播放 + +flame_audio 插件可以实现音频播放的功能,使用起来也非常简单:通过 FlameAudio.createPool 静态方法,指定资源创建 AudioPool ,执行 start 即可。 + +AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1); +pool.start(); + + +Flutter 中音频播放还有一个 audioplayers 库,其实 flame_audio 是对该库的一个上层封装,引入了缓存池,在播放短音效的场景中更好一些。我也试过 audioplayers ,感觉连续点击短音效有一点杂音,而 flame_audio 表现良好。 + + + +4. 唯一标识 uuid + +有些时候,我们需要用一个成员属性来标识对象的唯一性, 一般应用通过 uuid 获取标识符可以视为唯一的。这里使用 uuid 的目的是为每次功德的增加记录提供一个身份标识。 + +final Uuid uuid = Uuid(); +String id = uuid.v4(); + + +得益于 Flutter 的良好生态环境,像这些基础功能,都已经有了依赖库,所以两行代码就能完成需求。以后有通用的能需要求,可以在 pub 中看一下有没有依赖库。当然,你有好的功能代码,也可以创建依赖库,提交到 pub 中,供大家一起使用。 + + + +三、接触的内置组件 + +最后来整理一下目前电子木鱼项目中用到的 Flutter 内置组件,大家可以根据下表,结合源码以及应用界面,思考一下这些组件的作用和使用方式: + +1. 基础组件 + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +Image +图片展示 +展示木鱼图片 + + + +GestureDetector +手势事件监听器 +监听点击图片事件,处理敲击逻辑 + + + +ElevatedButton +升起按钮 +左上角选择音频和木鱼样式和按钮 + + + +ScaleTransition +缩放变换 +功德数字缩放动画 + + + +SlideTransition +移动变换 +功德数字移动动画 + + + +FadeTransition +透明度变换 +功德数字透明度动画 + + + +Material +材料组件 +为选择界面提供材料设计默认主题 + + + + + + +2. 组合结构型 + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +ListTile +列表条目通用结构 +音频选项以及列表条目 + + + + + + +3. 布局组件 + + + + +组件名称 +功能 +猜数字中的使用 + + + + + +Expanded +Row/Column 中延展区域 +主页面上下平分区域 + + + +Positioned +Stack 中定位组件 +左上角按钮的定位 + + + +Wrap +包裹若干组件 +左上角两个按钮的竖直包裹 + + + +Padding +设置内边距 +木鱼样式选择界面中的布局 + + + +SizedBox +尺寸设置 +选择界面的高度设置 + + + +ListView +滑动列表 +历史记录界面 + + + +最后想说一下关于木鱼项目的演变:当前项目的功能有点击发声、切换图片、数据记录。其实可以基于此完成另一个小项目,比如电子宠物饲养类型的应用,可以喂食,抚摸发出叫声,切换宠物等操作。这和点击敲木鱼是殊途同归的,有时间和兴趣的可以自己尝试一下。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/18白板绘制界面交互与需求分析.md b/专栏/Flutter入门教程/18白板绘制界面交互与需求分析.md new file mode 100644 index 0000000..d881f5e --- /dev/null +++ b/专栏/Flutter入门教程/18白板绘制界面交互与需求分析.md @@ -0,0 +1,132 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 白板绘制界面交互与需求分析 + 1. 为什么需要 Canvas + +之前我们都是通过 Flutter 已有的组件进行组合来构建界面,这种组合式的界面构建方式虽然方便易用,但是也有一些局限性。有些特殊场景的展示效果通过内置的组件拼组很难实现,比如在画板上进行绘制、特殊图案、统计图表的展示等。我们需要一种自己控制绘制逻辑,来完成视图表现的手段,这就是 Canvas 绘制体系。 + + + +任何一种界面交互的开发框架,无论是 Web、 还是 Android、iOS 、还是桌面端的界面开发,都会提供 Canvas 让使用者更灵活地控制界面展现。同时,各种平台的 Canvas 操作接口基本一致,所以这项技能一通百通。绘制相当于通过编程来画画,是一项创造性的活动,具有很大的发散空间。 + +其实,本质上来说 Widget 之所以能展示出来,底层也是依赖于 Canvas 绘制实现的,所以它也不是 Flutter 体系中的异物。掌握 Canvas 的绘制,就能实现更多复杂的界面展示需求,是一个门槛比较高的技能。本教程只是简单的认识,并不能做系统的介绍。如果对绘制感兴趣,可以研读我的小册 《Flutter 绘制指南 - 妙笔生花》 + + + +2. 界面交互介绍 + +白板绘制是本教程的第三个案例,相比于前两个项目,可操作性更强一些,也更有趣。适合新手朋友进一步了解 Flutter 绘制相关知识,体会其创造性,绘制过程中练习 Dart 语法也是个不错的选择。下面是两个最基础的交互: + + +通过监听用户的拖拽手势,让界面留下触点的线条痕迹。 +可以选择绘制时线条的颜色和粗细两个配置项。 + + + + + +画板绘制 +颜色和线宽选择 + + + + + + + + + + +对于界面绘制内容的管理,提供了两个功能: + + +当界面中存在绘制的线条时,可以回退上一步;在有回退历史时,可以撤销回退。 +右上角的清除按钮,点击时会弹出对话框确认清除,完成清空绘制的功能。 + + + + + +回退和撤销 +清除内容 + + + + + + + + + + + + + +3. 白板绘制需求分析 + +现在从数据和界面的角度,来分析一白板绘制中的需求: + + +用户手指拖拽绘制线条 + + +在这个需求中,对于数据来说:线由多个点构成,并且每条线有颜色和粗细的属性,可以通过如下 Line 类型维护点集数据。那么多条线就是 Lines 列表,列表中的元素会根据用户的拖拽操作进行更新。 + +class Line { + List points; + Color color; + double strokeWidth; + + Line({ + required this.points, + this.color = Colors.black, + this.strokeWidth = 1, + }); + +} + + +和木鱼项目选择类似,这里的颜色和线宽,也需要给出支持的选项列表,以及激活的索引值。用户在点击选项条目时更新激活索引数据,在手指拖拽开始是,添加的 Line 宽度和颜色使用激活的数据即可。 + +对于界面来说,绘制功能通过 CustomPaint 组件 + CustomPainter 画板实现,拖拽手势的监听使用 GestureDetector 组件实现。颜色和线宽的选择器,通过 Flutter 内置的组件来封装。 + + + + +回退撤销清空功能 + + +这三个功能点,都是对线列表数据的维护。回退是将线列表末尾元素移除;由于要撤销回退,需要额外维护被回退移除的元素,撤销回退时再加回线列表中。 + +对于界面来说,需要注意按钮可操作性的限制,比如当线列表为空时,无法向前回退: + + + +当界面上有内容时,才允许点击左侧按钮回退。撤销按钮同理,只有回退历史中有元素,才可以操作。 + + + +这就是白板绘制的需求分析,这个案例的核心是 CustomPaint 组件 + GestureDetector 组件的使用。在数据维护的过程中,是练习语法的好机会,另外它交互性比较强,使用趣味性上是最好的,可以让家里的小朋友随便画画 (如下小外甥女作品),下面一起开始第二个小项目的学习吧! + + + + +标题 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/19认识自定义绘制组件.md b/专栏/Flutter入门教程/19认识自定义绘制组件.md new file mode 100644 index 0000000..040c751 --- /dev/null +++ b/专栏/Flutter入门教程/19认识自定义绘制组件.md @@ -0,0 +1,233 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 认识自定义绘制组件 + 1.从绘制点开始说起 + +我们先通过绘制点来了解一下 Flutter 中绘制的使用方式。左图中是新建的 Paper 组件作为白板主界面;右图在白板的指定坐标处绘制了四个方形的点: + + + + +空白 +绘制四个点 + + + + + + + + + + +Paper 组件如下,由于之后需要进行切换画笔颜色、粗细的操作;涉及到界面中状态的变化,所以这里继承自 StatefulWidget,在状态类的 build 方法中,完成界面的构建逻辑。 PaperAppBar 是单独封装的头部标题组件,和之前类似,就不赘述了;现在主要是想让 body 处设置为可绘制的组件。 + +class Paper extends StatefulWidget { + const Paper({Key? key}) : super(key: key); + + @override + State createState() => _PaperState(); +} + +class _PaperState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PaperAppBar(onClear: _clear,), + body: //TODO + ); + } + + void _clear() {} +} + + + + +我们已经知道, Flutter 中的所有界面呈现,都和 Widget 息息相关,绘制也不例外。在 Flutter 中接触到 Canvas 最常用的方式是 CustomPaint 组件 + CustomPainter 画板组合。下面代码,将 body 设置为 CustomPaint 组件;CustomPaint 在构造时传入 painter 参数是 CustomPainter 的子类。 + +自定义绘制,就是指继承 CustomPainter 完成绘制逻辑,比如这里的 PaperPainter 画板。另外,我们希望画布的尺寸填充剩余空间,可以将 child 指定为 ConstrainedBox ,并通过 BoxConstraints.expand() 的约束。 + +body: CustomPaint( + painter: PaperPainter(), + child: ConstrainedBox(constraints: const BoxConstraints.expand()), +), + + +也就是说,对于自定义绘制来说,最重要的是 CustomPainter 子类代码的实现,这里通过 PaperPainter 来完成绘制逻辑。在 paint 回调中,可以访问到 Canvas 对象和画板的尺寸 Size 。 +在该方法中通过调用 Canvas 的相关方法就可以进行绘制。比如这里使用 drawPoints 绘制点集,其中需要传入三个参数,分别是: + + +点的模式 PointMode : 共三种模式, points、lines、polygon +点集 List : 点的坐标列表 +画笔 Paint : 绘制时的配置参数 + + +class PaperPainter extends CustomPainter{ + + @override + void paint(Canvas canvas, Size size) { + List points = const [ + Offset(100,100), + Offset(100,150), + Offset(150,150), + Offset(200,100), + ]; + + Paint paint = Paint(); + paint.strokeWidth = 10; + canvas.drawPoints(PointMode.points, points , paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; + +} + + +上面点模式是 PointMode.points ,也就是绘制一个个点。如下是另外两者模式的效果,看起来也很清晰:PointMode.lines 会将点集分为若干对,没对连接成线;PointMode.polygon 会将点依次连接; + + + + +PointMode.lines +PointMode.polygon + + + + + + + + + + +到这里,就通过 Canvas 完成了一个最基础的点集绘制案例,当前代码位置 paper.dart 。接下来,介绍一下 Paint 对象,看看你的画笔有哪些功能。 + + + +2. 简单认识画笔的属性 + +可以先回想一下,我们现实中画画时用的笔有哪些特性:很自然的可以想到: 颜色、粗细 。 这两个配置项分别对应 strokeWidth 和 color 属性,在绘制之前通过直接 paint 对象设置即可: + +Paint paint = Paint(); +paint.strokeWidth = 10; +paint.color = Colors.red; + + + + + +粗细 strokeWidth +颜色 color + + + + + + + + + + + + + +之前线间的连接很突兀,可以使用将 strokeCap 设置为 StrokeCap.round 让线编程圆头: + +paint.strokeCap = StrokeCap.round; + + + + + +StrokeCap.butt +StrokeCap.round + + + + + + + + + + +这里面向新手,对于绘制的知识也点到为止,能满足当前需求即可,不会进行非常系统的介绍。不过感兴趣的可以看我的 《Flutter 绘制指南 - 妙笔生花》 小册,其中对于绘制方方面面都介绍的比较详细。 + + + +3. 简单的基础图形绘制 + +Canvas 中提供了一些基础图形的绘制,比如 圆形、矩形、圆角矩形、椭圆、圆弧 等,这里简单了解一下。 + + +drawCircle 绘制圆形: 三个入参分别是圆心坐标 Offset、半径 double 、 画笔 Paint。 + + +另外,Paint 默认是填充样式,如下左图会填满内部;可以将 style 设置为 PaintingStyle.stroke变成线型模式,如下右图: + + + +@override +void paint(Canvas canvas, Size size) { + Paint paint = Paint(); + canvas.drawCircle(Offset(100, 100), 50, paint); + paint.style = PaintingStyle.stroke; + canvas.drawCircle(Offset(250, 100), 50, paint); +} + + + + + +drawRect 绘制矩形:两个入参分别是矩形 Rect、画笔 Paint。 +drawRRect 绘制圆角矩形:两个入参分别是矩形 RRect、画笔 Paint。 + + + + +Paint paint = Paint(); +paint.style = PaintingStyle.stroke; +paint.strokeWidth = 2; +// 绘制矩形 +Rect rect = Rect.fromCenter(center: Offset(100, 100), width: 100, height: 80); +canvas.drawRect(rect, paint); +// 绘制圆角矩形 +paint.style = PaintingStyle.fill; +RRect rrect = RRect.fromRectXY(rect.translate(150, 0), 8, 8); +canvas.drawRRect(rrect, paint); + + + + + +drawOval 绘制椭圆:两个入参分别是矩形 Rect、画笔 Paint。 +drawArc 绘制圆弧:五个入参分别是矩形 RRect、起始弧度 double、扫描弧度 double、是否闭合 bool、画笔 Paint。 + + + + +Paint paint = Paint(); +paint.strokeWidth = 2; +// 绘制椭圆 +Rect overRect = Rect.fromCenter(center: Offset(100, 100), width: 100, height: 80); +canvas.drawOval(overRect, paint); +// 绘制圆弧 +canvas.drawArc(overRect.translate(150, 0), 0, pi*1.3,true,paint); + + + + +4. 本章小结 + +本章主要介绍了如果在 Flutter 中通过 Canvas 自定义绘制内容。界面就相当于一张白纸、绘制接口方法就相当于画笔,使用已经存在的组件固然简单,但学会自己控制画笔绘制内容可以创造更多的精彩。当然,想要精通绘制也不是一朝一夕可以达成的,但凡工艺技能,都是熟能生巧。 + +这里只是简单认识了在 Flutter 中使用 Canvas 绘制的方式,接下来将结合手势和绘制,完成在手指在界面上拖拽留下痕迹的绘制效果。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/20通过手势在白板上绘制.md b/专栏/Flutter入门教程/20通过手势在白板上绘制.md new file mode 100644 index 0000000..1a88986 --- /dev/null +++ b/专栏/Flutter入门教程/20通过手势在白板上绘制.md @@ -0,0 +1,295 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 通过手势在白板上绘制 + 1. 白板绘制思路分析 + +对功能需求的分析主要从 界面数据信息、交互与数据维护、界面构建逻辑 三个方面来思考。 + + +数据信息 + + +界面上需要呈现多条线,每条线由若干个点构成,另外可以指定线的颜色和粗细。所以这里可以封装一个 Line 类维护这些数据: + +class Line { + List points; + Color color; + double strokeWidth; + + Line({ + required this.points, + this.color = Colors.black, + this.strokeWidth = 1, + }); +} + + + + +然后需要 List 线列表来表示若干条线;由于支持颜色和粗细选择,需要给出支持的颜色、粗细选项列表,以及两者的激活索引: + +List _lines = []; // 线列表 + +int _activeColorIndex = 0; // 颜色激活索引 +int _activeStorkWidthIndex = 0; // 线宽激活索引 + +// 支持的颜色 +final List supportColors = [ + Colors.black, Colors.red, Colors.orange, + Colors.yellow, Colors.green, Colors.blue, + Colors.indigo, Colors.purple, +]; + +// 支持的线粗 +final List supportStorkWidths = [1,2, 4, 6, 8, 10]; + + + + + +交互与数据维护 + + +线列表数据的维护和用户的拖拽事件息息相关: + + +用户开始拖拽开始时,需要创建 Line 对象,加入线列表。 +用户拖拽过程中,将触点添加到线列表最后一条线中。 +用户点击清除时,清空线列表。 +用户可以通过交互选择颜色,更新颜色激活索引。 +用户可以通过交互选择线宽,更新线宽激活索引。 + + + + + +界面构建逻辑 + + +在该需求中的数据和交互分析完后,就可以考虑界面的构建逻辑了。下面是一个示意简图: + + + + +使用 Scaffold + Appbar 组件构建整体结构。 +使用 CustomPaint 组件构建主体内容,作为可绘制区域。 +通过 GestureDetector 组件监听拖拽手势,维护数据变化。 +通过 Stack 组件将画笔颜色和线粗选择器叠放在绘制区上。 +画笔颜色和线粗选择器的构建将在下一篇详细介绍。 + + +到这里,主要的数据和交互,以及实现的思路就分析的差不多了,接下来就进入项目代码的编写。 + + + +2. 手势交互与数据维护 + +如下,新建一个 paper 文件夹,用于盛放白板绘制的相关代码。其中: + + +model.dart 中盛放相关的数据模型,比如 Line 类。 +paper_app_bar.dart 是抽离的头部标题组件。 +paper.dart 是白板绘制的主界面。 + + +首先在 _PaperState 中放入之前分析的数据: + + + + + +想要监听用户的拖拽手势,可以使用 GestureDetector 组件的 pan 系列回调。如下所示,在 CustomPaint 之上嵌套 GestureDetector 组件;通过 onPanStart 可以监听到用户开始拖拽那刻的事件、通过 onPanUpdate 可以监听到用户拖拽中的事件: + +body: GestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + child: CustomPaint( + //略同... + ), +) + +void _onPanStart(DragStartDetails details) {} + +void _onPanUpdate(DragUpdateDetails details) {} + + + + +回调中的逻辑处理也比较简单,在开始拖拽时为线列表添加新线;此时新线就是 _lines 的最后一个元素,在拖拽中,为新线添加点即可: + +// 拖拽开始,添加新线 +void _onPanStart(DragStartDetails details) { + _lines.add(Line(points: [details.localPosition],)); +} + +// 拖拽中,为新线添加点 +void _onPanUpdate(DragUpdateDetails details) { + _lines.last.points.add(details.localPosition); + setState(() { + + }); +} + + + + +3. 画板的绘制逻辑 + +在 PaperPainter 中处理绘制逻辑,绘制过程中需要线列表的数据,而数据在 _PaperState 中维护。可以通过构造函数,将数据传入 PaperPainter 中,以供绘制时使用: + +class PaperPainter extends CustomPainter { + PaperPainter({ + required this.lines, + }) { + _paint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + } + + late Paint _paint; + final List lines; + + @override + void paint(Canvas canvas, Size size) { + for (int i = 0; i < lines.length; i++) { + drawLine(canvas, lines[i]); + } + } + + ///根据点集绘制线 + void drawLine(Canvas canvas, Line line) { + _paint.color = line.color; + _paint.strokeWidth = line.strokeWidth; + canvas.drawPoints(PointMode.polygon, line.points, _paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; +} + + +绘制逻辑在上面的 paint 方法中,便历 Line 列表,通过 drawPoints 绘制 PointMode.polygon 类型的点集; Line 对象中的颜色和边线数据,可以在绘制前为画笔设置对应属性。最后,在 _PaperState 状态类中,当 PaperPainter 对象创建时,将 _lines 列表作为入参提供给画板即可: + + + + +———————————————————— +———————————————————— + + + + + + + + + +--->[_PaperState]---- +child: CustomPaint( + painter: PaperPainter( + lines: _lines + ), + + + + +4.弹出对话框 + +在点击清除按钮时,清空线列表。一般对于清除的操作,需要给用户一个确认的对话框,从而避免误操作。如下所示: + + + + +点击弹框 +确认清除 + + + + + + + + + + +弹出对话框可以使用框架中提供的 showDialog 方法,在 builder 回调函数中创建需要展示的组件。这里封装了 ConformDialog 组件用于展示对话框,将一些文字描述作为参数。这样其他地方想弹出类似的对话框,可以用 ConformDialog 组件,这就是封装的可复用性。 + +void _showClearDialog() { + String msg = "您的当前操作会清空绘制内容,是否确定删除!"; + showDialog( + context: context, + builder: (ctx) => ConformDialog( + title: '清空提示', + conformText: '确定', + msg: msg, + onConform: _clear, + )); +} + +// 点击清除按钮,清空线列表 +void _clear() { + _lines.clear(); + setState(() { + }); +} + + + + +对话框背景是不透明灰色,这种展示效果可以使用 Dialog 组件;弹框整体结构也比较简单,是上中下竖直排列,可以使用 Column 组件;标题、消息内容、按钮这里通过三个函数来构建: + + +小练习: 自己完成 ConformDialog 中三个构建函数的逻辑。 + + + + +class ConformDialog extends StatelessWidget { + final String title; + final String msg; + final String conformText; + final VoidCallback onConform; + + ConformDialog({ + Key? key, + required this.title, + required this.msg, + required this.onConform, + this.conformText = '删除', + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildTitle(), + _buildMessage(), + _buildButtons(context), + ], + ), + ), + ); + } + //... +} + + + + +5. 本章小结 + +本章主要完成白板绘制的基础功能,包括整体布局结构、监听拖拽事件,维护点集数据、以及绘制线集数据。其中蕴含着数据和界面展现的关系,比如,数据是如何产生的、数据在用户的交互期间有哪些变化、数据是如何呈现到界面上的,大家可以自己多多思考和理解。 + +到这里,界面上的线条会随着手指的拖动而呈现,完成了最基础的功能,当前代码位置 paper。下一篇将介绍一下,如何通过交互来修改画笔颜色和粗细,让界面的呈现更加丰富。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/21白板画笔的参数设置.md b/专栏/Flutter入门教程/21白板画笔的参数设置.md new file mode 100644 index 0000000..538fec8 --- /dev/null +++ b/专栏/Flutter入门教程/21白板画笔的参数设置.md @@ -0,0 +1,308 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 白板画笔的参数设置 + 1. 线粗选择器 + +如下所示,左下角可以选择线的粗细,激活状态通过蓝色线框表示。在选择之后,激活状态发生变化,绘制时线的宽度也会变化。这个选择器的构建逻辑相对独立,以后也有复用的可能,可以单独抽离为一个组件维护,这里创建 StorkWidthSelector 组件,并将其通过 Stack 组件叠放在画板之上: + + + + +线粗 = 1 +线粗 = 8 + + + + + + + + + + +通过选择器在界面上的展示效果,不难看出组件需要依赖的数据有: + + +支持的线宽列表 List +激活索引 int +条目的颜色 Color +点击条目时的回调 ValueChanged + + +类定义如下: + +class StorkWidthSelector extends StatelessWidget { + final List supportStorkWidths; + final int activeIndex; + final Color color; + final ValueChanged onSelect; + + + const StorkWidthSelector({ + Key? key, + required this.supportStorkWidths, + required this.activeIndex, + required this.onSelect, + required this.color, + }) : super(key: key); + + + + +在组件构建逻辑中,主要是便历 supportStorkWidths 列表,通过 _buildByIndex 方法根据索引值构建条目。其中可以通过 index == activeIndex 来确定当前条目是否被激活;再通过激活状态确定是否添加边线: + +@override +Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate( + supportStorkWidths.length, + _buildByIndex, + )), + ); +} + +Widget _buildByIndex(int index) { + bool select = index == activeIndex; + return GestureDetector( + onTap: () => onSelect(index), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 2), + width: 70, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: select ? Border.all(color: Colors.blue) : null), + child: Container( + width: 50, + color: color, + height: supportStorkWidths[index], + ), + ), + ); +} + + + + +最后通过 Stack + Positioned 组件,将 StorkWidthSelector 组件叠放在界面的右下角;并通过 _onSelectStorkWidth 作为选择的回调处理界面状态数据的变化逻辑: + + + +处理逻辑很简单,只要更新 _activeStorkWidthIndex 激活索引即可;另外在 _onPanStart 创建 Line 对象时,设置激活线宽即可: + +void _onSelectStorkWidth(int index) { + if (index != _activeStorkWidthIndex) { + setState(() { + _activeStorkWidthIndex = index; + }); + } +} + +void _onPanStart(DragStartDetails details) { + _lines.add(Line( + points: [details.localPosition], + // 使用激活线宽 + strokeWidth: supportStorkWidths[_activeStorkWidthIndex], + )); +} + + +到这里,就完成了线条宽度的选择功能,当前代码位置 paper 。 + + + +2. 颜色选择器 + +颜色选择器的原理也是一样,选择激活,在创建 Line 对象时设置颜色。只不过是条目的界面表现不同罢了,条目的构建逻辑也是通过 _buildByIndex 实现的。这里通过圆圈也表示颜色,点击时激活条目,激活状态由外圈的圆形边线进行表示: + + + + +标题 + + + + + + + + + + + + +class ColorSelector extends StatelessWidget { + final List supportColors; + final ValueChanged onSelect; + final int activeIndex; + + const ColorSelector({ + Key? key, + required this.supportColors, + required this.activeIndex, + required this.onSelect, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8), + child: Wrap( + // crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate( + supportColors.length, + _buildByIndex, + )), + ); + } + + Widget _buildByIndex(int index) { + bool select = index == activeIndex; + return GestureDetector( + onTap: () => onSelect(index), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.all(2), + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: select ? Border.all(color: Colors.blue) : null + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: supportColors[index], + ), + ), + ), + ); + } +} + + + + +同理,在 _PaperState 中,颜色选择器也通过 Stack + Positioned 叠放在画板的左下角;点击回调时处理激活颜色索引数据的更新;以及创建 Line 对象时设置激活颜色: + + + +void _onSelectColor(int index) { + if (index != _activeColorIndex) { + setState(() { + _activeColorIndex = index; + }); + } +} + +void _onPanStart(DragStartDetails details) { + _lines.add(Line( + points: [details.localPosition], + strokeWidth: supportStorkWidths[_activeStorkWidthIndex], + color: supportColors[_activeColorIndex], + )); +} + + +到这里,颜色选择和线宽选择功能就已经实现了,当前代码位置 paper 。但这里在布局上还有些问题,下面来分析处理一下。 + + + +3. 布局分析 + +这里通过 Positioned 将两块分别叠放在 Stack 的两侧,上面颜色少时没有什么问题。但如果颜色过多,可以发现这种方式的叠放会让后者把前者覆盖住 + + + + +多颜色时 +布局边界 + + + + + + + + + + +在布局树中可以发现,默认情况下 Positioned 之下的约束为无限约束,也就是子组件想要多大都可以。所以子组件在竖直方向上没有约束,颜色太多时就会溢出的原因。 + + + + + +解决方案方案有很多,其中最简单的是指定 Positioned 组件的 width 参数,在水平方向施加紧约束。如下所示,可以限制 ColorSelector 的宽度等于 240。 ColorSelector 内部通过 Wrap 进行构建,在区域之内会自动换行: + + + +Positioned( + bottom: 40, + width: 240, + child: ColorSelector( + supportColors: supportColors, + activeIndex: _activeColorIndex, + onSelect: _onSelectColor, + ), +), + + +通过布局查看器可以看出,此时 ColorSelector 受到的约束宽度就固定在 240。 + + + + + +另外,我们还可以将 Positioned 提供的约束尺寸设为屏幕宽度,通过 Row 来水平排列,其中 ColorSelector 的宽度通过 Expanded 延展成剩余宽度。当前代码位置 paper + + + +Positioned( + bottom: 0, + width: MediaQuery.of(context).size.width, + child: Row( + children: [ + Expanded( + child: ColorSelector( + supportColors: supportColors, + activeIndex: _activeColorIndex, + onSelect: _onSelectColor, + ), + ), + StorkWidthSelector( + supportStorkWidths: supportStorkWidths, + color: supportColors[_activeColorIndex], + activeIndex: _activeStorkWidthIndex, + onSelect: _onSelectStorkWidth, + ), + ], + ), +), + + +如果这里不提供 width ,而使用 Row + Expanded 组件的话,就会报错;根本原因是 Positioned 施加了无限约束,而 Row 使用了 Expanded 组件,延展无限的宽度区域是不被允许的: + + + +通过这个小问题,带大家简单认识一下布局中约束的分析。很多布局上的问题,都可以从约束的角度解决。这里点到为止,如果对约束感兴趣,或有很多布局的困扰,可以研读一下我的布局小册: Flutter 布局探索 - 薪火相传 + + + +4. 本章小结 + +本章主要任务是完成白板画笔的参数设置,为用户提供修改颜色和线宽的操作,以便于绘制更复杂多彩的图案。从中可以体会出:新增加一个需求,往往会引入相关的数据来实现功能。比如对于修改颜色的需求,需要引入支持的颜色列表和激活的颜色索引两个数据。所以对于任何功能需求而言,不要只看其表面的界面呈现,更重要的是分析其背后的用户交互过程中的数据变化情况。 + +下一章,将继续对当前的画板项目进行一些小优化,比如支持回退和撤销回退的功能;以及优化一下点集的收集策略,来尽可能地避免收录过多无用点,减小绘制的压力。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/22撤销功能与画板优化.md b/专栏/Flutter入门教程/22撤销功能与画板优化.md new file mode 100644 index 0000000..6e42f09 --- /dev/null +++ b/专栏/Flutter入门教程/22撤销功能与画板优化.md @@ -0,0 +1,210 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 撤销功能与画板优化 + 1. 回退与撤销功能需求分析 + +如下效果,在头部标题栏的左侧添加两个按钮,分别用于 向前回退 和 撤销回退 : + + +向前回退: 移除当前线列表中的最后一条线。 +撤销回退: 向当前线列表中添加上次回退的线。 + + + + + +向前回退 +撤销回退 + + + + + + + + + + +由于需要 "后悔",所以需要引入一个线列表作为 "后悔药",也就是收集向前回退过程中被抛弃的线。这里在状态类中添加 _historyLines 列表来维护: + +List _historyLines = []; + + +另外,在界面构建逻辑中,需要注意按钮可操作性的限制,比如当线列表为空时,无法向前回退: + + + +当界面上有内容时,才允许点击左侧按钮回退。撤销按钮同理,只有回退历史中有元素,才可以操作。 + + + + + +2. 回退与撤销界面构建 + +首先看一下两个按钮的构建逻辑,这里封装一个 BackUpButtons 组件进行维护。其中有两个可空的回调函数,分别用于处理两个按钮的点击事件。当函数为空时,表示当前按钮不可用,呈灰色状态示意,代码中对应的是 backColor 和 revocationColor 颜色的赋值。 + +IconButton 默认情况下是 48*48 的尺寸,看起来比较大,可以设置 constraints 参数来修改约束,从而控制尺寸。这里用了 Transform 组件,通过 scale 构造让图标按钮沿 Y 轴镜像,就可以得到与右侧对称的效果。 + +class BackUpButtons extends StatelessWidget { + final VoidCallback? onBack; + final VoidCallback? onRevocation; + + const BackUpButtons({ + Key? key, + required this.onBack, + required this.onRevocation, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + const BoxConstraints cts = BoxConstraints(minHeight: 32, minWidth: 32); + Color backColor = onBack == null?Colors.grey:Colors.black; + Color revocationColor = onRevocation == null?Colors.grey:Colors.black; + return Center( + child: Wrap( + children: [ + Transform.scale( + scaleX: -1, + child: IconButton( + splashRadius: 20, + constraints: cts, + onPressed: onBack, + icon: Icon(Icons.next_plan_outlined,color: backColor), + ), + ), + IconButton( + splashRadius: 20, + onPressed: onRevocation, + constraints: cts, + icon: Icon(Icons.next_plan_outlined, color: revocationColor), + ) + ], + ), + ); + } +} + + + + +然后将 BackUpButtons 放在恰当的位置即可,由于两个按钮属于头部标题,而头部标题栏的构建逻辑封装在 PaperAppBar 中。这里有两种思路: + + +将 BackUpButtons 封装在 PaperAppBar 内部,将两个按钮的点击事件继续向上传递。 +将左侧组件作为插槽位置,通过构造将组件传入到 PaperAppBar 里进行使用。 + + +其实两者本质上没有区别,只是形式上的差异、这里使用前者,这样对于 PaperAppBar 的使用者而言,只需要在意其中的事件,不需要关注标题中构造逻辑。代码如下: + + + +在使用 PaperAppBar 组件时,为 onBack 和 onRevocation 设置回调处理函数。当 _lines 为空,表示不可回退,onBack 设为 null 即可,这样在 BackUpButtons 组件构建时就会将左侧按钮置位灰色,onRevocation 同理。 + +appBar: PaperAppBar( + onClear: _showClearDialog, + onBack: _lines.isEmpty ? null : _back, + onRevocation: _historyLines.isEmpty ? null : _revocation, +), + + + + +3. 状态数据的维护 + +最后一步就是在 _back 方法中处理回退的逻辑;在 _revocation 中处理撤销的逻辑。在回退方法中移除 _lines 的最后一个元素,然后让 _historyLines 列表添加移除的线,再更新界面即可: + +void _back() { + Line line = _lines.removeLast(); + _historyLines.add(line); + setState(() {}); +} + + +在撤销回退方法中移除 _historyLines 的最后一个元素,然后让 _lines 列表添加移除的线,再更新界面即可: + +void _revocation() { + Line line = _historyLines.removeLast(); + _lines.add(line); + setState(() {}); +} + + +到这里, 向前回退和撤销回退的功能就已经实现了,当前代码位置 paper.dart。虽然现在画板操作时看起开没什么问题,但内部是危机四伏的。 + + + +4.拖拽更新的频繁触发 + +如下所示,在拖拽更新的回调 _onPanUpdate 中打印一下日志,会发现它的触发非常频繁。而每触发一次都会像线中添加一个点,就会导致点非常多。 + + + +特别是缓慢移动的过程中,会加入很多相近的无用点,不仅占据内存,也会造成绘制的负担。如下所示,右图通过 PointMode.points 模式展示点前的点,可以看出虽然中间点线很短,但非常密集: + + + + +PointMode.polygon +PointMode.points + + + + + + + + + + +我们可以在收集点时优化一下逻辑,根据与前一点的距离决定加不加入改点,这样可以有效降低点的数量,减缓绘制压力。处理逻辑并不复杂,如下所示,只要校验当前点和线的最后一点的距离,是否超过阈值即可。 + +void _onPanUpdate(DragUpdateDetails details) { + Offset point = details.localPosition; + double distance = (_lines.last.points.last - point).distance; + if (distance > 5) { + _lines.last.points.add(details.localPosition); + setState(() {}); + } +} + + +阈值越大,忽略的点就越多,线条越不精细,相对来说绘制压力也就越低,需要酌情处理。这里用 5 个逻辑像素,在操作体验上没什么影响,也能达到一定的优化效果。下面是缓慢移动过程中添加点集的情况,可以看出已经避免了点过于密集的问题: + + + + +PointMode.polygon +PointMode.points + + + + + + + + + + + + + +5. 本章小结 + +到这里,白板绘制的基础功能就已经完成了,当前代码位置 paper。还有些值得优化和改进的地方,比如: + + +现在的线是通过点进行连接的折线,可以通过贝塞尔曲线进行拟合,让点之间的连接更加圆滑; +可以提供一些基础图形的绘制操作,让绘制更加丰富。 +现在每次添加点都会将所有的内容绘制一边,随着绘制内容的增加,会带来频繁的复杂绘制。 +如何存储绘制的信息到本地,这样即使在退出应用后,也可以在下次开启时恢复绘制的内容。 + + +对这些问题的改进,大家可以在今后的路途中通过自己思考和理解,来尝试解决。接下来,我们将对这三个小项目进行整合,放入到一个项目中。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/23应用界面整合.md b/专栏/Flutter入门教程/23应用界面整合.md new file mode 100644 index 0000000..82408b4 --- /dev/null +++ b/专栏/Flutter入门教程/23应用界面整合.md @@ -0,0 +1,303 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 应用界面整合 + 1. 界面整合的需求分析 + +如下所示,在应用的底部添加导航栏,进行界面间的切换操作。下面从数据和界面的角度对该进行分析: + + + + +———————————————————— +———————————————————— + + + + + + + + +当前界面中需要添加的数据有: + + +底部栏的文字、图标资源列表 +底部栏的激活索引 + + +在点击底部栏的按钮时,需要更新激活索引,并进行界面的重新构建。这里定义一个 MenuData 类,用于维护标签和图标数据: + +class MenuData { + // 标签 + final String label; + + // 图标数据 + final IconData icon; + + const MenuData({ + required this.label, + required this.icon, + }); +} + + + + +对于界面构建逻辑来说,这是一个上下结构,上面是内容区域,下面是底部导航栏。所以,可以通过 Column 组件上下排列,其中内容区域通过 Expanded 组件进行延展,内容组件根据激活的索引值构建不同的界面。 + + + + + +2. 代码实现:第一版 + +Flutter 中提供了 BottomNavigationBar 组件可以展示底部栏,这里单独封装一个 AppBottomBar 组件用于维护底部栏的界面构建逻辑。其中需要传入激活索引、点击回调、菜单数据列表: + +class AppBottomBar extends StatelessWidget { + final int currentIndex; + final List menus; + final ValueChanged? onItemTap; + + const AppBottomBar({ + Key? key, + this.onItemTap, + this.currentIndex = 0, + required this.menus, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + backgroundColor: Colors.white, + onTap: onItemTap, + currentIndex: currentIndex, + elevation: 3, + type: BottomNavigationBarType.fixed, + iconSize: 22, + selectedItemColor: Theme.of(context).primaryColor, + selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold), + showUnselectedLabels: true, + showSelectedLabels: true, + items: menus.map(_buildItemByMenuMeta).toList(), + ); + } + + BottomNavigationBarItem _buildItemByMenuMeta(MenuData menu) { + return BottomNavigationBarItem( + label: menu.label, + icon: Icon(menu.icon), + ); + } +} + + + + +然后就是构建整体结构,这里创建一个 AppNavigation 组件来处理。由于激活索引数据需要在交互时改变,并重新构建界面,所以 AppNavigation 继承自 StatefulWidget,在状态类中处理界面构建和状态数据维护的逻辑。 + +class AppNavigation extends StatefulWidget { + const AppNavigation({Key? key}) : super(key: key); + + @override + State createState() => _AppNavigationState(); +} + +class _AppNavigationState extends State { + int _index = 0; + + final List menus = const [ + MenuData(label: '猜数字', icon: Icons.question_mark), + MenuData(label: '电子木鱼', icon: Icons.my_library_music_outlined), + MenuData(label: '白板绘制', icon: Icons.palette_outlined), + ]; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( child: _buildContent(_index)), + AppBottomBar( + currentIndex: _index, + onItemTap: _onChangePage, + menus: menus, + ) + ], + ); + } + + void _onChangePage(int index) { + setState(() { + _index = index; + }); + } + + +内容区域的构建使用 _buildContent 方法,根据不同的激活索引,返回创建不同的界面: + + +index = 0 时,构建猜数字界面; +index = 1 时,构建电子木鱼界面; +index = 2 时,构建白板绘制界面; + + + Widget _buildContent(int index) { + switch(index){ + case 0: + return const GuessPage(); + case 1: + return const MuyuPage(); + case 2: + return const Paper(); + default: + return const SizedBox.shrink(); + } + } +} + + +到这里就完成了点击底部导航,切换界面的功能,当前代码位置: navigation 。 但这种方式处理会有一些问题:伴随着界面的消失,状态类会被销毁;下次再到该界面时会重新初始化状态类,如下所示: + + + + +在绘制面板绘制 +切换后,状态重置 + + + + + + + + + + + + + +3. 状态类数据的保持 + +想要避免每次切换都会重置状态数据,大体上有三种解决方案: + + +1.使用 AutomaticKeepAliveClientMixin 对状态类进行保活,这种方案只能用于可滑动组件中。这里可以使用 PageView 组件来实现切页并保活的效果。 +2.将状态数据提升到上层,比如将三个界面的状态数据都交由 _AppNavigationState 状态类维护。如果直接用这种方式,很容易造成一个超级大的类,来维护很多数据。其实状态管理工具,就是基于这种思路,将数据交由上层维护,同时提供了分模块处理数据的能力。 +3.保持数据的持久性,比如将数据保存到本地文件或数据库,每次初始化时进行加载复现。这种方式处理起来比较麻烦,初始化加载数据也需要一点时间。但这种方式在界面不可见时,可以释放内存中的数据。 + + + + +这里使用 方式 1 来处理是最简单的。在 _buildContent 方法中返回 PageView 组件,并将三个内容界面作为 children 入参,通过 PageController 来控制界面的切换。注意一点:将 physics 设置设置为 NeverScrollableScrollPhysics 可以禁止 PageView 的滑动,如果想要运行滑动切页,可以去除。 + +---->[_AppNavigationState]---- +final PageController _ctrl = PageController(); + +Widget _buildContent() { + return PageView( + physics: const NeverScrollableScrollPhysics(), + controller: _ctrl, + children: const [ + GuessPage(), + MuyuPage(), + Paper(), + ], + ); +} + +void _onChangePage(int index) { + _ctrl.jumpToPage(index); + setState(() { + _index = index; + }); +} + + +另外如果期望某个状态类保活,需要让其混入 AutomaticKeepAliveClientMixin, 并覆写 wantKeepAlive 返回 true 。如下是对画板状态类的处理,其他两个同理: + + + + + + +在绘制面板绘制 +切换后,状态保活 + + + + + + + + + + +到这里,就将之前的三个小案例,集成到了一个应用中,并且在切换界面的过程中,可以保持状态数据不被重置。当前代码位置 navigation。 + +上面可以保证程序运行期间,各界面状态类的保活,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态,想要记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,下一篇将介绍数据的持久化存储。 + + + +4. 优化一些缺陷 + +如下所示,左侧是 Column 组件上下排列,当键盘顶起之后,底部会留出一块空白,高为底部导航高度。想解决这个问题,使用 Scaffold 组件即可,它有一个 bottomNavigationBar 的插槽,不会被键盘顶起。 + + + + +Column 结构 +Scaffold 结构 + + + + + + + + + + +这时,将 _AppNavigationState 的构建方法改为如下代码: + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _buildContent(), + bottomNavigationBar: AppBottomBar( + currentIndex: _index, + onItemTap: _onChangePage, + menus: menus, + ), + ); + } + + + + +下面以 AppBar 的主题介绍一下 Flutter 默认配置的能力。项目中希望所有的 AppBar 都是白色背景、状态类透明、标题居中、图标颜色、文字颜色为黑色。 + + + +如果每次使用 AppBar 组件就配置一次,那代码书写将会非常复杂。Flutter 在主题数据的功能,只要指定主题,其下节点中的对应组件,就会默认使用的配置数据。如下所示,在 MaterialApp 的 theme 入参中可以配置主题数据: + + + +这样,以前使用 AppBar 的地方就不用再配置那么多信息了。比如电子木鱼界面的 AppBar 就可以清爽多了: + + + +这里只是拿 AppBarTheme 举个例子,还有其他很多的主题可以配置,大家可以在以后慢慢了解。 + + + +5. 本章小结 + +本章我们主要将之前的三个小案例整合到了一个项目中,通过底部导航栏 + PageView 实现界面间的切换。另外也就 State 的状态保活进行了简单地认识,这里只是程序运行期间,保证各界面状态类的活性,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态。 + +想要永久记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,在程序启动时进行加载,恢复状态数据。这是应用程序非常重要的一个部分,下一篇将介绍数据的持久化存储。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/24数据的持久化存储.md b/专栏/Flutter入门教程/24数据的持久化存储.md new file mode 100644 index 0000000..0ca1170 --- /dev/null +++ b/专栏/Flutter入门教程/24数据的持久化存储.md @@ -0,0 +1,477 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 数据的持久化存储 + 一、猜数字项目的配置信息存储 + +在猜数字项目中,界面的状态数据有三个: + + + + +数据名 +类型 +含义 + + + + + +_guessing +bool +是否在猜数字游戏中 + + + +_value +int +待猜测的数字 + + + +_isBig +bool? +是否更大 + + + +现在的目的是,在退出应用后:可以继续上次的游戏进程,那么需要记录 _guessing 和 _value 两个数据。对于这种简单的配置数据,可以通过 shared_preferences 插件存储为 xml 配置文件。首先需要添加依赖: + +dependencies: + ... + shared_preferences: ^2.1.1 + + + + +1. 单例模式访问对象和存储配置 + +数据的持久化中,我们需要在很多地方对数据进行读取和写入。这里将该功能封装为一个类进行操作,并提供唯一的静态对象,方便访问。 如下所示,创建一个 SpStorage 的类,私有化构造并提供实例对象的访问途径: + +---->[lib/storage]---- +class SpStorage { + SpStorage._(); // 私有化构造 + + static SpStorage? _storage; + + // 提供实例对象的访问途径 + static SpStorage get instance { + _storage = _storage ?? SpStorage._(); + return _storage!; + } +} + + + + +2. 配置信息的存储 + +如下所示,在类中提供 saveGuessConfig 方法用于保存猜数字的配置信息。核心方法是使用 SharedPreferences 对象的 setString 方法,根据 key 值存储字符串。这里通过 json.encode 方法将 Map 对象编码成字符串: + +const String kGuessSpKey = 'guess-config'; + +class SpStorage { + SpStorage._(); + + // 略同... + + SharedPreferences? _sp; + + Future initSpWhenNull() async { + if (_sp != null) return; + _sp = _sp ?? await SharedPreferences.getInstance(); + } + + Future saveGuess({ + required bool guessing, + required int value, + }) async { + await initSpWhenNull(); + String content = json.encode({'guessing': guessing, 'value': value}); + return _sp!.setString(kGuessSpKey, content); + } +} + + + + +由于 SpStorage 提供了静态的单例对象,所以在任何类中都可以通过 SpStorage.instance 得到实例对象。比如下面在 _GuessPageState 中生成随机数时,调用 saveGuessConfig 方法来存储记录,在如下文件中可以看到存储的配置信息: + + +/data/data/com.toly1994.flutter_first_station/shared_prefs/FlutterSharedPreferences.xml + + + + +---->[_GuessPageState#_generateRandomValue]---- +void _generateRandomValue() { + setState(() { + _guessing = true; + _value = _random.nextInt(100); + SpStorage.instance.saveGuessConfig(guessing: _guessing,value: _value); + print(_value); + }); +} + + + + +3. 访问配置与恢复状态 + +光存储起来,只完成了一半,还需要读取配置,并根据配置来设置猜数字的状态数据。如下所示,在 SpStorage 类中提供 readGuessConfig 方法用于读取猜数字的配置信息。核心方法是使用 SharedPreferences 对象的 getString 方法,根据 key 值获取存储的字符串。这里通过 json.decode 方法将字符串解码成 Map 对象: + +class SpStorage { + + // 略同... + + Future> readGuessConfig() async { + await initSpWhenNull(); + String content = _sp!.getString(kGuessSpKey)??"{}"; + return json.decode(content); + } + +} + + + + +方便起见,这里在 _GuessPageState 的 initState 中读取配置文件,并为状态类赋值,完成存储数据的回显。在实际项目中,这些配置信息可以在闪屏页中提前读取。 + +class _GuessPageState extends State with SingleTickerProviderStateMixin,AutomaticKeepAliveClientMixin{ + + @override + void initState() { + // 略... + _initConfig(); + } + + void _initConfig() async{ + Map config = await SpStorage.instance.readGuessConfig(); + _guessing = config['guessing']??false; + _value = config['value']??0; + setState(() { + + }); + } + + +这样,在生成数字之后,杀死应用,然后打开应用,就可以看到仍会恢复到之前的猜数字状态中,这就是数据持久化的意义所在。当前代码位置: sp_storage.dart + + + +二、电子木鱼项目的配置信息存储 + +在电子木鱼项目中,需要存储的配置数据有: + + + + +数据名 +类型 +含义 + + + + + +counter +int +功德总数 + + + +activeImageIndex +int +激活图片索引 + + + +activeAudioIndex +int +激活音频索引 + + + + +1. 配置信息的存储 + +同样,在 SpStorage 中定义 saveMuYUConfig 方法存储木鱼配置的信息。通过 SpStorage 统一对配置信息进行操作,一方面可以集中配置读写的代码逻辑,方便使用,另一方面可以避免在每个状态类内部都获取 SharedPreferences 对象进行操作。 + +const String kMuYUSpKey = 'muyu-config'; + +class SpStorage { + // 略... + + Future saveMuYUConfig({ + required int counter, + required int activeImageIndex, + required int activeAudioIndex, + }) async { + await initSpWhenNull(); + String content = json.encode({ + 'counter': counter, + 'activeImageIndex': activeImageIndex, + 'activeAudioIndex': activeAudioIndex, + }); + return _sp!.setString(kMuYUSpKey, content); + } +} + + +然后需要在配置数据发生变化的事件中保存配置,也就是在 _MuyuPageState 类中敲击木鱼、选择音频,选择图片三个场景,这三处的代码位置大家应该非常清楚。为了方便调用,这里写一个 saveConfig 方法来触发。然后操作界面,配置文件中就会存储对应的信息: + + + +--->[_MuyuPageState]--- +void saveConfig() { + SpStorage.instance.saveMuYUConfig( + counter: _counter, + activeImageIndex: activeAudioIndex, + activeAudioIndex: _activeAudioIndex, + ); +} + + + + +2. 配置信息的读取 + +同理,在 SpStorage 中读取配置信息: + +class SpStorage { + // 略同... + Future> readMuYUConfig() async { + await initSpWhenNull(); + String content = _sp!.getString(kMuYUSpKey) ?? "{}"; + return json.decode(content); + } +} + + +并在 _MuyuPageState 初始化状态回调中,读取配置对状态数据进行设置。 + +class _MuyuPageState extends State + with AutomaticKeepAliveClientMixin { + // 略同... + @override + void initState() { + super.initState(); + _initAudioPool(); + _initConfig(); + } + + void _initConfig() async{ + Map config = await SpStorage.instance.readMuYUConfig(); + _counter = config['counter']??0; + _activeImageIndex = config['activeImageIndex']??0; + _activeAudioIndex = config['activeAudioIndex']??0; + setState(() { + }); + } + + +这样,电子木鱼的配置信息就存储和读取的功能就实现完毕了,当前代码位置: sp_storage.dart + + +小练习:自己尝试完成白板绘制中颜色、线宽配置的数据持久化。 + + + + +三、通过数据库进行存储 + +上面属于通过文件的方式来持久化数据,比较适合存储一些小的配置数据。如果想存储大量的数据,并且希望可以进行复杂的查询,最好使用数据库来存储。这里将对木鱼点击时的功德数记录,使用 sqlite 数据库进行存储。不过不会介绍的太深,会创建数据库和表,存储数据、读取数据即可。毕竟数据库的操作是另一门学问,感兴趣的可以系统地学习一下。 + + + +1. sqlite 数据库插件 + +目前来说,最完善的 sqlite 数据库插件是 sqlite , 使用前首先需要添加依赖: + +dependencies: + ... + sqflite: ^2.2.8+2 + + + + +对于数据库操作来说,全局提供一个访问对象即可,也可以通过单例模式来处理,如下定义 DbStorage 类: + +---->[storage/db_storage/db_storage.dart]---- +class DbStorage { + DbStorage._(); + + static DbStorage? _storage; + + static DbStorage get instance { + _storage = _storage ?? DbStorage._(); + return _storage!; + } +} + + + + +2. 数据库操作对象 Dao + +由于应用程序中可能存在多个数据表,一般每个表会通过一个类来单独操作。比如电子木鱼中的功德记录,是对一条条的 MeritRecord 对象进行记录,这里通过 MeritRecordDao 进行维护。在其构造函数中传入 Database 对象,以便在方法中操作数据库。 + +首先是数据库的创建语句,通过下面的 createTable 方法完成;使用 Database 的 execute 方法执行 sql 语句: + +---->[storage/db_storage/dao/merit_record_dao.dart]---- +import 'package:sqflite/sqflite.dart'; + +class MeritRecordDao { + final Database database; + + MeritRecordDao(this.database); + + static String tableName = 'merit_record'; + + static String tableSql = """ +CREATE TABLE $tableName ( +id VARCHAR(64) PRIMARY KEY, +value INTEGER, +image TEXT, +audio TEXT, +timestamp INTEGER +)"""; + + static Future createTable(Database db) async{ + return db.execute(tableSql); + } +} + + + + +然后在 DbStorage 中提供 open 方法打开数据库,如果数据库不存在的话 openDatabase 方法会创建数据库,并触发 _onCreate 回调。在其中可以使用 MeritRecordDao 执行数据表创建的逻辑。另外 DbStorage 持有 MeritRecordDao 类型对象,在数据库打开之后,初始化对象: + +---->[storage/db_storage/db_storage.dart]---- + +class DbStorage { + //略同... + + late Database _db; + + late MeritRecordDao _meritRecordDao; + MeritRecordDao get meritRecordDao => _meritRecordDao; + + void open() async { + String databasesPath = await getDatabasesPath(); + String dbPath = path.join(databasesPath, 'first_station.db'); + _db = await openDatabase(dbPath, version: 1, onCreate: _onCreate); + _meritRecordDao = MeritRecordDao(_db); + } + + void _onCreate(Database db, int version) async { + await MeritRecordDao.createTable(db); + } + +} + + +像打开数据库、加载本地资源的操作,在实际项目中可以放在闪屏页中处理。不过这里方便起见,直接程序开始时打开数据库。现在运行项目之后,就可以看到数据库已经创建了: + + + +void main() async{ + WidgetsFlutterBinding.ensureInitialized(); + await DbStorage.instance.open(); // 打开数据库 + runApp(const MyApp()); +} + + +在 AndroidStudio 的 App inspection 中,可以查看当前运行项目在的数据库情况: + + + + + +3. 数据的存储和读取方法 + +如下所示,在 MeritRecordDao 中定义 insert 方法插入记录数据;定义 query 方法读取记录列表。 + +class MeritRecordDao { + // 略同... + Future insert(MeritRecord record) { + return database.insert( + tableName, + record.toJson(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future> query() async { + List> data = await database.query( + tableName, + ); + return data + .map((e) => MeritRecord( + e['id'].toString(), + e['timestamp'] as int, + e['value'] as int, + e['image'].toString(), + e['audio'].toString(), + )) + .toList(); + } +} + + +插入时需要传入 Map 对象,这里为 MeritRecord 类提供一个 toJson 的方法,以便将对象转为 Map : + +class MeritRecord { + final String id; + final int timestamp; + final int value; + final String image; + final String audio; + + MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio); + + Map toJson() => { + "id":id, + "timestamp": timestamp, + "value": value, + "image": image, + "audio": audio, + }; +} + + + + +4.使用 Dao 完成数据读写功能 + +前面数据操作层准备完毕之后,使用起来就非常简单了。就剩两件事: + + +在 _MuyuPageState 中点击时存入数据库。 + + + + + +在 _MuyuPageState 中状态初始化时读取数据。 + + + + +然后点击木鱼后就可以看到数据表中会存储对于的数据,应用退出之后也能从数据库中加载数据。 + + + + + +四、 本章小结 + +本章主要介绍使用 shared_preferences 通过 xml 存储配置数据;以及使用 sqflite 通过 sqlite3 数据库存储数据记录。其中也涉及了对单例模式的使用,让程序中只有一个数据的访问对象,一方面可以简化使用方式,另一方面也可以避免多次连接数据库,造成无意义的浪费。 + +到这里数据的本地持久化就介绍的差不多了,当前代码位置 db_storage.dart 。对于新手而言这算比较复杂的,希望大家可以好好消化。当然这些只是最简单的 Demo 级应用,怎么简单怎么来。对实际项目来说,整体的应用结构,数据维护和传递的方式,逻辑触发的时机都需要认真的考量,本教程只在新手的指引,就不展开介绍了。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/25网络数据的访问.md b/专栏/Flutter入门教程/25网络数据的访问.md new file mode 100644 index 0000000..4228541 --- /dev/null +++ b/专栏/Flutter入门教程/25网络数据的访问.md @@ -0,0 +1,484 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 网络数据的访问 + 一、需求介绍和准备工作 + +上一章介绍了本地数据的持久化,它可以让应用退出后,仍可以在启动后通过读取数据,恢复状态数据。但如果手机丢了,或者本地数据被不小心清空了,应用就又会 "失忆" 。 + +1. 本章目的 + +现在移动互联网已经极度成熟了,将数据存储在远程的服务器中,通过网络来访问、操作数据对于现在的人已经是家常便饭了。比如微信应用中的聊天记录、支付宝应用中的余额、美团应用中的店铺信息、游戏里的资源装备、抖音里的视频评论… 现在的网络数据已经无处不在了。所以对于应用开发者来说,网络请求的技能是必不可少的。 + +但是学习网络请求有个很大的问题,一般的网络接口都是肯定不会暴露给大众使用,而自己想要搭建一个后端提供网络接口又很麻烦。所以一般会使用开放 api ,我曾建议过掘金提供一套开放 api , 以便写网络相关的教程,但目前还没什么动静。这里就选用 wanandroid 的开发 api 接口来进行测试。 + +本章目的是完成一个简单的应用场景:从网络中加载文章列表数据,展示在界面中。点击条目时,可以跳转到详情页,并通过 WebView 展示网页。 + + + + +文章列表 +文章详情 + + + + + + + + + + + + + +2. 界面准备 + +现在想在底部栏添加一个网络文章的按钮,点击时切换到网络请求测试的界面。只需要在 _AppNavigationState 的 menus 增加一个 MenuData : + + + +然后在 PageView 内增加一个 NetArticlePage 组件,用于展示网络文章的测试界面: + + + +新建一个 net_article 的文件夹用于盛放网络文章的相关代码,其中: + + +views 文件夹盛放组件视图相关的文件,比如主页面、详情页等。 +model 文件夹用于盛放数据模型,比如文章数据的封装类。 +api 文件夹盛放网络数据请求的代码,在功能上相当于上一章的 storage , 负责读取和写入数据。只不过对于网络数据再说,是存储在服务器上的,需要提供接口来操作。 + + + + +NetArticlePage 组件现在先准备一下:通过 Scaffold 构建界面结构,由于之前已经提供了 AppBar 的主题,这里直接给个 title 即可,其他配置信息会默认跟随主题。接下来最重要的任务就是对 body 主体内容的构建。 + +class NetArticlePage extends StatelessWidget { + const NetArticlePage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('网络请求测试'), + ), + body: Container(), + ); + } +} + + + + +3. 接口介绍 + +这里只使用一个获取文章列表的如下接口,其中 0 是个可以改变的参数,表示文章的页数: + + +www.wanandroid.com/article/lis… + + +通过浏览器可以直接看到接口提供的 json 数据: + + + +使用 json 美化工具可以看出如下的结构,主要的文章列表数据在 data["datas"] 中 : + + + +每条记录的数据如下,其中数据有很多,不过没有必要全都使用。这里展示文章信息,只需要标题 title 、地址 link 、 时间 niceDate 即可。 + +{ + "adminAdd": false, + "apkLink": "", + "audit": 1, + "author": "", + "canEdit": false, + "chapterId": 502, + "chapterName": "自助", + "collect": false, + "courseId": 13, + "desc": "", + "descMd": "", + "envelopePic": "", + "fresh": true, + "host": "", + "id": 26411, + "isAdminAdd": false, + "link": "https://juejin.cn/post/7233067863500849209", + "niceDate": "7小时前", + "niceShareDate": "7小时前", + "origin": "", + "prefix": "", + "projectLink": "", + "publishTime": 1684220135000, + "realSuperChapterId": 493, + "route": false, + "selfVisible": 0, + "shareDate": 1684220135000, + "shareUser": "张风捷特烈", + "superChapterId": 494, + "superChapterName": "广场Tab", + "tags": [], + "title": "Dart 3.0 语法新特性 | Records 记录类型 (元组)", + "type": 0, + "userId": 31634, + "visible": 1, + "zan": 0 +} + + + + +4.数据模型的封装 + +这样,可以写出如下的 Article 类承载数据,并通过一个 formMap 构造通过 map 数据构造 Article 对象。 + +class Article { + final String title; + final String url; + final String time; + + const Article({ + required this.title, + required this.time, + required this.url, + }); + + factory Article.formMap(dynamic map) { + return Article( + title: map['title'] ?? '未知', + url: map['link'] ?? '', + time: map['niceDate'] ?? '', + ); + } + + @override + String toString() { + return 'Article{title: $title, url: $url, time: $time}'; + } +} + + + + +二、基础功能的实现 + +俗话说巧妇难为无米之炊,如果说界面是一碗摆在台面上的饭,那数据就是生米,把生米煮成熟饭就是组件构建的过程。所以实现基础功能有两大步骤: 获取数据、构建界面。 + +1. 网络数据的请求 + +网络请求是非常通用的能力,开发者自己来写非常复杂,所以一般使用三方的依赖库。对于 Flutter 网络请求来说,最受欢迎的是 dio , 使用前先添加依赖: + +dependencies: + ... + dio: ^5.1.2 + + + + + + +下面看一下最简单的使用,如下在 ArticleApi 中持有 Dio 类型的 _client 对象,构造时可以设置 baseUrl 。然后提供 loadArticles 方法,用于加载第 page 页的数据,其中的逻辑处理,就是加载网络数据的核心。 + +使用起来也很方便,提供 Dio#get 方法就可以异步获取数据,得到之后,从结果中拿到自己想要的数据,生成 Article 列表即可。 + +class ArticleApi{ + + static const String kBaseUrl = 'https://www.wanandroid.com'; + + final Dio _client = Dio(BaseOptions(baseUrl: kBaseUrl)); + + Future> loadArticles(int page) async { + String path = '/article/list/$page/json'; + var rep = await _client.get(path); + if (rep.statusCode == 200) { + if(rep.data!=null){ + var data = rep.data['data']['datas'] as List; + return data.map(Article.formMap).toList(); + } + } + return []; + } +} + + + + +2. 文章内容界面展示 + +这里单独创建一个 ArticleContent 组件负责展示主题内容,由于需要加载网络数据,加载成功后要更新界面,使用需要使用状态类来维护数据。所以让它继承自 StatefulWidget : + +class ArticleContent extends StatefulWidget { + const ArticleContent({Key? key}) : super(key: key); + + @override + State createState() => _ArticleContentState(); +} + + +对于状态类来说,最重要数据是 Article 列表,build 构建逻辑中通过 ListView 展示可滑动列表,其中构建条目时依赖列表中的数据: + +class _ArticleContentState extends State { + List
_articles = []; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemExtent: 80, + itemCount: _articles.length, + itemBuilder: _buildItemByIndex, + ); + } + + Widget _buildItemByIndex(BuildContext context, int index) { + return ArticleItem( + article: _articles[index], + onTap: _jumpToPage, + ); + } +} + + +另外这里单独封装了 ArticleItem 组件展示条目的单体,效果如下,大家可以自己处理一下,这里就不放代码了,处理不好的话可以参考源码。 + + + +最后只要在 initState 回调中通过 ArticleApi 加载网络数据即可,加载完成后通过 setState 更新界面: + + ArticleApi api = ArticleApi(); + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() async{ + _articles = await api.loadArticles(0); + setState(() { + + }); + } + + +到这里,最基础版的网络请求数据,进行界面展示的功能就完成了。当然现在的代码还存在很大的问题,下面将逐步进行优化。 + + + + +———————————————————— +———————————————————— + + + + + + + + + + + +3. 在应用中展示 Web 界面 + +文章数据中有一个链接地址,可以通过 WebView 来展示内容。同样也是使用三方的依赖库 webview_flutter 。 使用前先添加依赖: + +dependencies: + ... + webview_flutter: ^4.2.0 + + + + + + +使用起来来非常简单,创建 WebViewController 请求地址,然后使用 WebViewWidget 组件展示即可: + +class ArticleDetailPage extends StatefulWidget { + final Article article; + + const ArticleDetailPage({Key? key, required this.article}) : super(key: key); + + @override + State createState() => _ArticleDetailPageState(); +} + +class _ArticleDetailPageState extends State { + late WebViewController controller; + @override + void initState() { + super.initState(); + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(const Color(0x00000000)) + ..loadRequest(Uri.parse(widget.article.url)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.article.title)), + body: WebViewWidget(controller: controller), + ); + } +} + + +最后,在列表界面点击时挑战到 ArticleDetailPage 即可。这样就完成了 Web 界面在应用中的展示,当前代码位置 net_article: + +void _jumpToPage(Article article) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ArticleDetailPage(article: article), + ), + ); +} + + + + + +文章1 +文章2 + + + + + + + + + + + + + +三、功能优化 + +现在有三个值得优化的地方: + + +网络加载数据的过程比较慢,加载成功之前文章列表是空的,界面展示空白页体验不好。可以在加载过程中展示 loading 界面。 +当前加载数据完后,无法在重新加载,可以增加下拉刷新功能。 +现在只能加载一页数据,可以在滑动到底部,加载下一页内容,也就是加载更多的功能。 + + + + +1. 增加 loading 状态 + +如下左图在网络上请求时没有任何处理,会有有一段时间的白页;如右图所示,在加载过程中给出一些界面示意,在体验上会好很多。 + + + + +无 loading 状态 +有 loading 状态 + + + + + + + + + + +其实处理起来也并不复杂,由于界面需要感知加载中的状态,示意需要增加一个状态数据用于控制。比如这里在状态类中提供 _loading 的布尔值来表示,该值的维修事件也很明确:加载数据前置为 true 、加载完后置为 false 。 + +bool _loading = false; + +void _loadData() async { + _loading = true; + setState(() {}); + _articles = await api.loadArticles(0); + _loading = false; + setState(() {}); +} + + + + +上面是状态数据的逻辑处理,下面来看一下界面构建逻辑。只要在 _loading 为 true 时,返回加载中对应的组件即可。如果加载中的界面比较复杂,或想要在其他地方复用,也可以单独封装成一个组件来维护。 + +@override +Widget build(BuildContext context) { + if(_loading){ + return Center( + child: Wrap( + spacing: 10, + direction: Axis.vertical, + crossAxisAlignment: WrapCrossAlignment.center, + children: const [ + CupertinoActivityIndicator(), + Text("数据加载中,请稍后...",style: TextStyle(color: Colors.grey),) + ], + ), + ); + } + return ListView.builder( + itemExtent: 80, + itemCount: _articles.length, + itemBuilder: _buildItemByIndex, + ); +} + + +这样,就完成了展示界面加载中的功能,当前代码位置 article_content.dart。 + + + +2. 下拉刷新功能 + +如下所示,在列表下拉时,头部只可以展示加载的信息,这种效果组件手写起来非常麻烦。 + + + +这是一个通用的功能,好在我们可以依赖别人的代码,使用三方库来实现,这里用的是 easy_refresh。使用前先添加依赖: + +dependencies: + ... + easy_refresh: ^3.3.1+2 + + + + + + +使用方式也非常简单,将 EasyRefresh 组件套在 ListView 上即可。在 header 中可以放入头部的配置信息,通过 onRefresh 参数设置下拉刷新的回调,也就是从网络加载数据,成功后更新界面。 + + + + + +3. 加载更多功能 + +上面只能展示一页的数据,如果需要展示多页怎么办? 一般来说应用在滑动到底部会加载更多,如下所示: + + + +实现起来也非常简单 EasyRefresh 的 onLoad 参数设置下拉回调,加载下一页数据,并加入 _articles 数据中即可。这里一页数据有 20 条,下一页也就是 _articles.length ~/ 20 : + +void _onLoad() async{ + int nextPage = _articles.length ~/ 20; + List
newArticles = await api.loadArticles(nextPage); + _articles = _articles + newArticles; + setState(() {}); +} + + + + +四、本章小结 + +本章主要介绍了如何访问网络数据,实现了文章列表的展示,以及通过 WebView 在应用中展示网页内容,完成简单的文章查看功能。并且基于插件实现了下拉刷新、加载更多的功能。 + +到这里一个最基本的网络文章数据的展示就实现完成了, 当前代码位置 article_content。也标志着本系列教程进入了尾声,还有很多值得优化的地方,希望大家再以后的路途中可以自己思考和处理。 + + + + \ No newline at end of file diff --git a/专栏/Flutter入门教程/26教程总结与展望.md b/专栏/Flutter入门教程/26教程总结与展望.md new file mode 100644 index 0000000..1142404 --- /dev/null +++ b/专栏/Flutter入门教程/26教程总结与展望.md @@ -0,0 +1,193 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 教程总结与展望 + 1. 教程小结 + +对于应用程序来说,最重要的有两方面: 数据和界面。 + + +数据的信息为界面提供构建资源 +界面的交互让数据内容发生变更 + + +学完本教程,希望大家可以意识到 Flutter 中界面由 Widget 派生类来决定;而组件实例化时的配置信息就是界面中所依赖的数据。数据和界面就像一枚硬币的两面,两者相辅相成,缺一不可。用户站在界面的面前,开发者站在数据的面前。作为开发者,我们不能只关注界面,而忽略数据关系。 + + + + +关于组件(界面) + + +组件 Widget 作为界面的决定因素,对界面来说,主要就是通过定义组件来处理 构建逻辑(Build Logic) 。在很长一段时间,你自己创建的组件只有 StatelessWidget 和 StatefulWidget 这两个族系。它们可以使用已经存在的组件进行组合,成为新的组件。这种组合形式,可以封装一些构建逻辑,以便复用或拆分结构。 + +StatefulWidget 依赖 State 状态类完成 build 构建任务,并且 State 有对应的生命周期回调,可以处理相关逻辑;也能使用 setState 方法重新触发当前状态类的构建任务,实现界面更新。 + +对 State 的认知也伴随你很长的路途,如果希望在 Flutter 有长远的发展,以后有时间和能力时,还是建议从框架底层的源码中去思考状态类的作用,才能看清 Flutter 机器的整个运转流程。 + + + + +关于数据 + + +数据是界面中依赖的信息,有的数据是死的,在程序运行期间永远不会改变,比如标题的文字、或固定的描述信息。图片、图标等;有些数据是活的,会通过函数进行传递、流动、变化,比如白板中的线列表、木鱼中的功德数、网络文章中的文章列表。 + +如何获取数据、如果保存数据、如何更改数据,是程序开发者最需要关注的事,数据的维护直接关系到程序功能的正确性。这些处理数据的逻辑可以称为 业务逻辑(Business Logic)。 + +对于 Flutter 开发者,甚至是任何和界面相关软件的开发者,都需要意识到构建逻辑和业务逻辑这两条命脉。如何合理地维护这两类逻辑,也是今后值得不断深入思考的事。 + +本教程的总结,我只想点出上面的两点,希望大家可以在以后的路途中铭记于心。 + + + +2. 关于 Flutter 组件的认知 + +Flutter 框架中提供的内置组件估计已经接近 400 个了,每个组件都有各自的特点。把它们一一背下来是不现实的,对于初学者来说,应该注重常用的组件,比如本教程四个案例中涉及到了组件,都可以深入了解一下。组件本质上只是配置信息,通过传参控制界面展示效果,当你了解玩常用组件,其余的都可以一通百通。在日常开发中逐渐接触,把组件看成可以帮里完成构建界面的朋友,而不是不得不接触的敌人。 + +教程中的四个案例中使用的组件,都没有进行非常细致的介绍。因为: + + +[1] 并不是非常必要。 + + +考虑到对组件一一介绍起来非常繁琐和无聊,不仅会占据很多的篇幅,而且学起来也很枯燥,很容易拘泥于琐碎的组件而无法对 Flutter 有整体的认知。所以我才构思了四个小项目,让大家在实践中了解它们的存在和使用方式,在交互中体验更有趣。 + + + + +[2] 已经的历史文章积累。 + + +我在掘金中为很多常用的组件写过专文介绍,大家后期对某个组件感兴趣可以各取所需去了解。专栏地址为 Flutter 组件集录 : + + + + + + +[3] 完善的组件介绍开源项目。 + + + + +关于Flutter的组件介绍,我有一个使用 Flutter 框架实现开源项目 FlutterUnit ,其中收录了 350 多个组件的介绍和使用范例,支持范例的代码查看和分享。并且支持全平台,可以在手机和电脑上安装应用程序来体验: + + + + +主页面 +详情页 + + + + + + + + + + + + +桌面端界面: + + + + + + +想了解 Flutter 里有哪些组件,或通过组件名搜索查看使用方式, FlutterUnit 都是一个很好的选择。现在项目的 star 个数已经 6000 多了,对于 Flutter 开源项目来说算是比较高了,希望大家可以多多支持。 + + + +3. 对未来发展方向的展望 + +一个侠客行走江湖,需要精进的方向有两个,其一是 修炼内力,其二是 修炼招式 。对于开发者来说,理解底层运转的机理就是内力;如何开发出应用程序就是招式。使用刚步入江湖时,修炼的方向各有不同,人各有志,也不必强求。 + +如果你并不急着打造一个软件产品,在起步时打牢基础,修炼内力,是不错的选择。这时,推荐你去研读我的七本小册,我称之为 "Flutter 七剑",助你在未来的道路上披荆斩棘: + + + + +小册名称 +发布时间 +代码仓库 +售价(RMB) + + + + + +《Flutter 绘制指南 - 妙笔生花》 +2020年11月11日 +idraw +3.28 + + + +《Flutter 手势探索 - 执掌天下》 +2021年05月13日 +itouch +3.5 + + + +《Flutter 动画探索 - 流光幻影》 +2021年07月09日 +ianim +3.5 + + + +《Flutter 滑动探索 - 珠联璧合》 +2022年02月10日 +iscroll +3.5 + + + +《Flutter 布局探索 - 薪火相传 》 +2022年03月30日 +ilayout +3.5 + + + +《Flutter 渲染机制 - 聚沙成塔 》 +2022年04月27日 +irender +3.5 + + + +《Flutter 语言基础 - 梦始之地 》 +2022年09月14日 +idream +3.5 + + + + + + +如果你迫于工作压力,或者急需上手开发项目,最好的方式是找一些开源项目来研究,学习招式。这里推荐几个项目,都是在持续维护更新的: + + +flutter_deer +gsy_github_app_flutter +FlutterUnit (我的) +RegExpo(我的) + + +另外,如果你学习编程只是业余的兴趣爱好,可以体验一下基于 flame 开发小型游戏,在游戏中学习可谓乐趣无穷。我也写过一个专栏 《Flutter&Flame 游戏专栏》: + + + +其实不管开始修炼的道路是什么,只要想在这江湖中立足,最终内力和招式都要精进。所以不用过于纠结什么先,什么后,需要什么就去修炼什么。到这里,本教程就已经完结了,感谢大家的观看,以后有缘再见 ~ + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/00开篇词为什么每一位大前端从业者都应该学习Flutter?.md b/专栏/Flutter核心技术与实战/00开篇词为什么每一位大前端从业者都应该学习Flutter?.md new file mode 100644 index 0000000..a34498a --- /dev/null +++ b/专栏/Flutter核心技术与实战/00开篇词为什么每一位大前端从业者都应该学习Flutter?.md @@ -0,0 +1,75 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 为什么每一位大前端从业者都应该学习Flutter? + 你好,我是陈航,之前在美团外卖担任商家业务大前端团队技术负责人。在接下来三个月的时间里,我将和你一起学习Flutter。 + +当下是移动互联网的时代,也是大前端技术紧密整合的时代。而移动系统与终端设备的碎片化,让我们一直头痛于在不同平台上开发和维护同一个产品的成本问题:使用原生方式来开发App,不仅要求分别针对iOS和Android平台,使用不同的语言实现同样的产品功能,还要对不同的终端设备和不同的操作系统进行功能适配,并承担由此带来的测试维护升级工作。 + +这对中小型团队而言无疑是非常大的负担,也无形中拖慢了追求“小步快跑”,以快速应对市场变化的互联网产品交付节奏。 + +为解决这一问题,各类打着“一套代码,多端运行”口号的跨平台开发方案,如雨后春笋般涌现,React Native就是其中的典型代表。 + +React Native希望开发者能够在性能、展示、交互能力和迭代交付效率之间做到平衡。它在Web容器方案的基础上,优化了加载、解析和渲染这三大过程,以相对简单的方式支持了构建移动端页面必要的Web标准,保证了便捷的前端开发体验;并且在保留基本渲染能力的基础上,用原生自带的UI组件实现代替了核心的渲染引擎,从而保证了良好的渲染性能。 + +但是,由于React Native的技术方案所限,使用原生控件承载界面渲染,在牺牲了部分Web标准灵活性的同时,固然解决了不少性能问题,但也引入了新的问题:除开通过JavaScript虚拟机进行原生接口的调用,而带来的通信低效不谈,由于框架本身不负责渲染,而是由原生代理,因此我们还需要面对大量平台相关的逻辑。 + +而随着系统版本和API的变化,我们还需要处理不同平台的原生控件渲染能力上的差异,修复各类怪异的Bug,甚至还需要在原生系统上打各类补丁。 + +这都使React Native的跨平台特性被大打折扣:要用好React Native,除了掌握这个框架外,开发者还必须同时熟悉iOS和Android系统。这,无疑给开发者提出了更多挑战,也是很多开发者们对React Native又爱又恨的原因。在这其中,也有一些团队决定放弃React Native回归原生开发,Airbnb就是一个例子。 + +备注:2018年,Airbnb团队在Medium上发布的一系列文章(React Native at Airbnb、React Native at Airbnb: The Technology、Building a Cross-Platform Mobile Team、Sunsetting React Native、What’s Next for Mobile at Airbnb)详细描述了这个过程。 + +而我们本次课程的主角Flutter,则完全不同于React Native。 + +它开辟了全新的思路,提供了一整套从底层渲染逻辑到上层开发语言的完整解决方案:视图渲染完全闭环在其框架内部,不依赖于底层操作系统提供的任何组件,从根本上保证了视图渲染在Android和iOS上的高度一致性;Flutter的开发语言Dart,是Google专门为(大)前端开发量身打造的专属语言,借助于先进的工具链和编译器,成为了少数同时支持JIT和AOT的语言之一,开发期调试效率高,发布期运行速度快、执行性能好,在代码执行效率上可以媲美原生App。而这与React Native所用的只能解释执行的JavaScript,又拉开了性能差距。 + +正是因为这些革命性的特点,Flutter在正式版发布半年多的时间里,在GitHub上的Star就已经超过了68,000,与已经发布4年多的、拥有78,000 Star的同行业领头羊React Native的差距非常小。同时,阿里闲鱼、今日头条等知名商用案例的加持,更使得Flutter的热度不断攀升。 + +现在看来,在Google的强力带动下,Flutter极有可能成为跨平台开发领域的终极解决方案。在过去的大半年时间里,我曾面试了20多位初、中、高级候选人,包括前端、Android、iOS的开发者。当问到最近想学习什么新技术时,超过80%的候选人告诉我,他会学习或正在学习Flutter。 + +不过坦白讲,相比其他跨平台技术,Flutter的学习成本相对较高。我听过很多(大)前端开发者反馈:Flutter从语言到开发框架都是全新的,技术栈的积累也要从头开始,学不动了。 + +学习成本高,这也是目前大多数开发者犹豫是否要跟进这个框架的最重要原因。对此,我感同身受。 + +但其实,大前端各个方向的工作有很多相似、相通之处。面对业务侧日益增多的需求,作为大前端团队的负责人,我曾在不同时期带领团队分别探索并大规模落地了以React Native和Flutter为代表的跨平台方案,也是美团最早落地Flutter线上大规模应用的发起者和推动者之一。 + +在探索并大规模落地Flutter的过程中,我阅读过大量关于Flutter的教程和技术博客,但我发现很多文章的学习门槛都比较高,而且过于重视应用层API各个参数的介绍或实现细节,导致很多从其他平台转来的开发者无从下手,只能依葫芦画瓢,却不知道为什么要“画瓢”,无法与自身的经验串联进而形成知识体系。这,无疑又增加了学习门槛,加长了学习周期。 + +那么,Flutter到底该怎么学?真的要从头开始么? + +虽然Flutter是全新的跨平台技术,但其背后的框架原理和底层设计思想,无论是底层渲染机制与事件处理方式,还是组件化解耦思路,亦或是工程化整体方法等,与原生Android/iOS开发并没有本质区别,甚至还从React Native那里吸收了不少优秀的设计理念。就连Flutter所采用的Dart语言,关于信息表达和处理的方式,也有诸多其他优秀编程语言的影子。 + +因此,从本质上看,Flutter并没有开创新的概念。这也就意味着,如果我们在学习Flutter时,能够深入进去搞懂它的原理、设计思路和通用理念,并与过往的开发经验相结合,建立起属于自己的知识体系抽象层次,而不是仅停留在应用层API的使用上,就摆脱了经验与平台的强绑定。 + +这样的话,即使未来老框架不断更新,或者出现新的解决方案,我们仍旧可以立于不败之地。 + +那么,Flutter框架底层有哪些关键技术?它们是如何高效运转,以支撑起可以媲美原生应用的跨平台方案的?Flutter应用开发的最佳实践是怎样的?企业需要什么样的终端技术人才?终端技术未来有哪些发展方向? + +这些问题,正是我要通过这个课程为你解答的。在这个课程里,我不仅会帮助你快速上手,能够使用Flutter开发一款企业级App,更希望帮助你将其与过往的开发经验串联起来,以建立起自己的知识体系;同时,希望你能透过现象明白Flutter框架的用法,并看到其背后的原理和设计理念。 + +为了帮助你领悟到Flutter的核心思想和关键技术,而不是陷入组件的API细节难以自拔,我会在不影响学习、理解的情况下,省去一些不影响核心功能的代码和参数讲解,着重为你剖析框架的核心知识点和背后原理,并与你分享一些常见问题的解决思路。 + +整体来说,专栏主要包括以下五大部分内容: + + +Flutter开发起步模块。我会从跨平台方案发展历史出发,与你介绍Flutter的诞生背景、基本原理,并带你体验一下Flutter代码是如何在原生系统上运行的。 +Dart基础模块。我会从Dart与其他编程语言的设计思想对比出发,与你讲述Dart设计的关键思路以及独有特性,并通过一个综合案例带你去实践一下。 +Flutter基础模块。我将通过Flutter与原生系统对应概念对比,与你讲述Flutter独有的概念和框架设计思路。学完这个模块,你就可以开发出一个简单的App了。 +Flutter进阶模块。我会与你讲述Flutter开发中的一些疑难问题、高级特性及其背后原理,帮助你在遇到问题时化被动为主动。 +Flutter综合应用模块。我将和你聊聊在企业级应用迭代的生命周期中,如何从效率和质量这两个维度出发,构建自己的Flutter开发体系。 + + +最后,我希望通过这个课程,能够帮助你快速上手Flutter开发应用,掌握其精髓,并引导你建立起属于自己的终端知识体系。 + +现在,Flutter正处于快速发展中,社区也非常活跃。站在未来看未来,尽管Flutter全平台制霸的目标已经非常清晰,但为期三个月的专栏分享未必能穷尽Flutter未来可能的技术发展方向。接下来,我会持续关注Flutter包括移动端之外的最新变化,持续更新这个专栏,第一时间与你分享Flutter的那些事儿。 + +好了,今天的内容就到这里了。如果可以的话,还请你在留言区中做个自我介绍,和我聊聊你目前的工作、学习情况,以及你在学习或者使用Flutter时遇到的问题,这样我们可以彼此了解,也方便我在后面针对性地给你讲解。 + +加油,让我们突破自己的瓶颈,保持学习、保持冷静、保持成长。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/01预习篇·从0开始搭建Flutter工程环境.md b/专栏/Flutter核心技术与实战/01预习篇·从0开始搭建Flutter工程环境.md new file mode 100644 index 0000000..22e1887 --- /dev/null +++ b/专栏/Flutter核心技术与实战/01预习篇·从0开始搭建Flutter工程环境.md @@ -0,0 +1,224 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 预习篇 · 从0开始搭建Flutter工程环境 + 你好,我是陈航。 + +俗话说,工欲善其事,必先利其器。任何一门新技术、新语言的学习,都需要从最基础的工程环境搭建开始,学习Flutter也不例外。所以,作为专栏的第一篇文章,我会与你逐一介绍Flutter的开发环境配置,并通过一个Demo为你演示Flutter项目是如何运行在Andorid和iOS的模拟器和真机上的。如果你已经掌握了这部分内容,那可以跳过这篇预习文章,直接开始后面内容的学习。 + +由于是跨平台开发,所以为了方便调试,你需要一个可以支持Android和iOS运行的操作系统,也就是macOS,因此后面的内容主要针对的是在macOS系统下如何配置Flutter开发环境。 + +如果你身边没有macOS系统的电脑也没关系,在Windows或Linux系统上配置Flutter也是类似的方法,一些关键的区别我也会重点说明。但这样的话,你就只能在Android单平台上开发调试了。 + +准备工作 + +安装Android Studio + +Android Studio是基于IntelliJ IDEA的、Google官方的Android应用集成开发环境(IDE)。 + +我们在官网上找到最新版(截止至本文定稿,最新版为3.4),下载后启动安装文件,剩下的就是按照系统提示进行SDK的安装和工程配置工作了。 + +配置完成后,我们打开AVD Manager,点击“Create Virtual Device”按钮创建一台Nexus 6P模拟器,至此Android Studio的安装配置工作就完成了。 + +安装Xcode + +Xcode是苹果公司官方的iOS和macOS应用集成开发环境(IDE)。它的安装方式非常简单,直接在macOS系统的App Store搜索Xcode,然后安装即可。 + +安装完成后,我们会在Launchpad看到Xcode图标,打开它,按照提示接受Xcode许可协议,以及安装配置组件就可以了。 + +配置完成后,我们打开Terminal,输入命令open -a Simulator打开iOS模拟器,检查 Hardware>Device 菜单项中的设置,并试着在不同的模拟器之间做切换。 + +至此,Xcode的安装配置工作也就顺利完成了。 + +安装Flutter + +Flutter源站在国内可能不太稳定,因此谷歌中国开发者社区(GDG)专门搭建了临时镜像,使得我们的Flutter 命令行工具可以到该镜像站点下载所需资源。 + +接下来,我们需要配置镜像站点的环境变量。对于macOS和Linux系统来说,我们通过文本编辑器,打开~/.bash_profile文件,在文件最后添加以下代码,来配置镜像站点的环境变量: + +export PUB_HOSTED_URL=https://pub.flutter-io.cn +export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn + + +而对于Windows系统来说,我们右键点击计算机图标,依次选择属性–>高级系统设置–>高级–>环境变量,新建用户变量PUB_HOSTED_URL,其值为https://pub.flutter-io.cn;随后新建FLUTTER_STORAGE_BASE_URL,其值为https://storage.flutter-io.cn,重启电脑即可完成配置。 + +到这里,我们就完成了镜像的配置。 + +不过,由于GDG并不是官方组织,因此Flutter团队也无法保证此服务长期可用。但是,你也不用担心,可以关注Flutter社区 Using Flutter in China,来获取其他可用的镜像资源,随时更新环境变量即可。 + +随后,我们再去Flutter官网,选择并下载最新的稳定版(截止至本文定稿,最新稳定版为1.5)。 + +接下来,我们把下载的压缩包解压到你想安装的目录,比如~/Documents或C:\src\flutter。为了可以在命令行中执行flutter命令,我们同样需要配置环境变量。 + +对于macOS与Linux系统,我们编辑~/.bash_profile文件,把以下代码添加至文件最后,将flutter命令的执行路径追加到环境变量PATH中: + +export PATH=~/Documents/flutter/bin:$PATH + + +而对于Windows系统,我们在当前用户变量下Path,以; 为分隔符,在其后追加flutter命令行的全路径C:\src\flutter\bin,重启电脑即可完成配置。 + +到这里,我们就完成了Flutter SDK的安装。 + +打开Flutter根目录,我们可以发现有一个examples文件夹,里面是一些基本的flutter示例。在今天这篇文章中,我会以hello_world示例为例,和你演示一下如何在模拟器和真机中运行Flutter项目。 + +首先,我给你介绍的是通过Flutter命令行运行的模式。进入hello_world目录,输入flutter emulators命令,查看当前可用的模拟器: + + + +图1 查看可用的flutter模拟器 + +可以看到,我们刚刚创建的两台模拟器,也就是Nexus 6P和iOS模拟器都已经在列表中了。于是,我们启动iOS模拟器,运行Flutter项目: + +flutter emulators --launch apple_ios_simulator +flutter run + + +等待10秒左右,一个熟悉的hello world大屏幕就出现在我们面前了: + + + +图2 Flutter demo + +Android模拟器的启动和运行,也与之类似,我就不再赘述了。 + +不过,使用命令行的方式来开发调试Flutter还是非常不方便,更高效的方式是配置Android和iOS的集成开发环境。 + +Flutter 提供了一个命令flutter doctor协助我们安装 Flutter的工程依赖,它会检查本地是否有Android和iOS的开发环境,如果检测到依赖缺失,就会给出对应依赖的安装方法。 + +接下来,我们试着运行下flutter doctor这条命令,得到了如下图所示的结果: + + + +图3 flutter doctor命令示意 + +可以看到,flutter doctor检测出了iOS工具链、Android Studio工程这两项配置中的问题。此外,由于我的电脑还安装了IDEA和VS Code,而它们也是Flutter官方支持的IDE,因此也一并检测出了问题。 + +接下来,我们根据运行flutter doctor命令得到的提示,来分别解决iOS工具链和Android Studio工程配置问题。 + +iOS工具链设置 + +现在,我们已经可以在iOS模拟器上开发调试Flutter应用了。但要将Flutter应用部署到真实的iOS设备上,我们还需要安装一些额外的连接控制命令工具(就像通过电脑的iTunes给手机安装应用一样),并申请一个iOS开发者账号进行Xcode签名配置。 + +依据提示,我们首先安装libimobiledevice和ideviceinstaller这两项依赖: + +brew update +brew install --HEAD usbmuxd +brew link usbmuxd +brew install --HEAD libimobiledevice +brew install ideviceinstaller + + +其中,usbmuxd是一个与iOS设备建立多路通信连接的socket守护进程,通过它,可以将USB通信抽象为TCP通信;libimobiledevice是一个与iOS设备进行通信的跨平台协议库;而ideviceinstaller则是一个使用它们在iOS设备上管理App的工具。 + +现在,你不了解它们的具体作用也没关系,只要知道安装了它们,Flutter就可以进行iOS真机的开发调试就可以了。 + +然后,进行Xcode签名配置。 + +打开hello_world项目中的ios/Runner.xcworkspace,在Xcode中,选择导航面板左侧最上方的Runner项目。 + + + +图4 Flutter Xcode签名配置 + +在General > Signing > Team 中,我们需要配置一下开发团队,也就是用你的Apple ID登录Xcode。当配置完成时,Xcode会自动创建并下载开发证书。 + +任意Apple ID都支持开发和测试,但如果想将应用发布到App Store,则必须加入Apple开发者计划。开发者计划的详细信息,你可以通过苹果官方的compare memberships了解,这里我就不再展开了。 + +最后,当我们第一次连接真机设备进行开发时,Xcode会在你的帐户中自动注册这个设备,随后自动创建和下载配置文件。我们只需要在真机设备上,按照手机提示,信任你的Mac和开发证书就可以了。 + +至此,我们就可以在iOS真机上开发调试Flutter项目了。 + +Android 工具链配置 + +相对于iOS工具链的设置,Android工具链配置就简单多了,这是因为Google官方已经在Android Studio中提供了Flutter和Dart这两个插件。因此,我们可以通过这两个工程插件,进行Flutter项目的管理以及开发调试。又因为Flutter插件本身依赖于Dart插件,所以我们只安装Flutter插件就可以了。 + + + +图5 Flutter插件安装 + +启动Android Studio,打开菜单项 Preferences > Plugins,搜索Flutter插件并点击 install进行安装。安装完毕后重启Android Studio,Flutter插件就生效了。 + +由于Android Studio本身是基于IDEA开发的,因此IDEA的环境配置与Android Studio并无不同,这里就不再赘述了。 + +对于VS Code,我们点击View->Command Palette,输入”install”,然后选择”Extensions:Install Extension”。在搜索框中输入flutter,选择安装即可。 + +至此,Android的工具链配置也完成了。 + +尽管Android Studio是Google官方的Android集成开发环境,但借助于Flutter插件的支持,Android Studio也因此具备了提供一整套Flutter开发、测试、集成打包等跨平台开发环境的能力,而插件底层通过调用Xcode提供的命令行工具,可以同时支持开发调试及部署iOS和Android应用。 + +因此,我后续的分享都会以Android Studio作为讲解Flutter开发测试的IDE。 + +运行Flutter项目 + +用Android Studio打开hello_world工程(Open an existing Android Studio Project),然后定位到工具栏: + + + +图6 Flutter工具栏 + +在Target selector中,我们可以选择一个运行该应用的设备。如果没有列出可用设备,你可以采用下面的两种方式: + + +参考我在前面讲到的方法,也就是打开AVD Manager并创建一台Android模拟器;或是通过open -a Simulator 命令,在不同的iOS模拟器之间进行切换。 +直接插入Android或iOS真机。 + + +hello_world工程稍微有点特殊,因为它提供了两个Dart启动入口:一个英文版的hello world-main.dart,和一个阿拉伯语版的hello world-arabic.dart。因此,我们可以在Config selector中进行启动入口的选择,也可以直接使用默认的main.dart。 + +在工具栏中点击 Run图标,稍等10秒钟左右,就可以在模拟器或真机上看到启动的应用程序了。 + +对于Flutter开发测试,如果每次修改代码都需要重新编译加载的话,那需要等待少则数十秒多则几分钟的时间才能查看样式效果,无疑是非常低效的。 + +正是因为Flutter在开发阶段使用了JIT编译模式,使得通过热重载(Hot Reload)这样的技术去进一步提升调试效率成为可能。简单来说,热重载就是在无需重新编译代码、重启应用程序、丢失程序执行状态的情况下,就能实时加载修改后的代码,查看改动效果。 + + +备注:我会在“02 | 预习篇 · Dart语言概览”中,与你分析Flutter使用Dart语言,同时支持AOT和JIT。 + + +就hello_world示例而言,为了体验热重载,我们还需要对代码做一些改造,将其根节点修改为StatelessWidget: + +import 'package:flutter/widgets.dart'; + +class MyAPP extends StatelessWidget { +@override + Widget build(BuildContext context) { + return const Center(child: Text('Hello World', textDirection: TextDirection.ltr)); + } +} + +void main() => runApp(new MyAPP()); + + +点击Run图标,然后试着修改一下代码,保存后仅需几百毫秒就可以看到最新的显示效果。 + + + +图7 热重载 + +是不是很Cool!但是,热重载也有一定的局限性,并不是所有的代码改动都可以通过热重载来更新。 + +对hello_world示例而言,由于Flutter并不会在热重载后重新执行main函数,而只会根据原来的根节点重新创建控件树,因此我们刚才做了一些改造之后才支持热重载。 + +关于Flutter热重载的原理以及限制原因,我会在后面“34 | Hot Reload是怎么做到的?”文章,和你详细分析。现在,你只需要知道,如果热重载不起作用的时候,我们也不需要进行漫长的重新编译加载等待,只要点击位于工程面板左下角的热重启(Hot Restart)按钮就可以以秒级的速度进行代码重编译以及程序重启了,而它与热重载的区别只是因为重启丢失了当前程序的运行状态而已,对实际调试也没什么影响。 + + + +图8 热重启 + +总结 + +通过今天的内容,相信你已经完成了Flutter开发测试环境的安装配置,对如何在安装过程中随时检测工程依赖,以及如何分别在Android和iOS真机及模拟器上运行Flutter程序有了一定的理解,并对Flutter开发调试常用工具有了初步的认知。 + +善用这些集成工具能够帮助我们能提升Flutter开发效率,而这些有关工程环境的基础知识则为Flutter的学习提供了支撑。这样,如果你后续在开发测试中遇到了环境相关的问题,也就知道应该如何去解决。 + +思考题 + +你在搭建Flutter工程环境的过程中,遇到过哪些问题,又是怎么解决的呢? + +欢迎留言告诉我,我们一起讨论。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/02预习篇·Dart语言概览.md b/专栏/Flutter核心技术与实战/02预习篇·Dart语言概览.md new file mode 100644 index 0000000..20742db --- /dev/null +++ b/专栏/Flutter核心技术与实战/02预习篇·Dart语言概览.md @@ -0,0 +1,139 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 预习篇 · Dart语言概览 + 你好,我是陈航。 + +我们知道,Flutter开发框架采用的开发语言是Dart,所以要用好这个框架,我们必须要搞清楚Dart语言。 + +关于新技术的学习,一直以来我都非常认同一个观点:千万不要直接陷入细节里,你应该先鸟瞰其全貌,这样才能从高维度理解问题。所以,为了帮助你更高效地掌握Dart,以最快的速度具备开发一款Flutter应用的能力,今天这篇文章,我会先从Flutter开发的角度,和你介绍Dart语言出现的历史背景、特性以及未来。 + +然后,我会在本专栏的“Dart基础”模块,与你详细分享它的特性、基础语法、类型变量、函数等知识,并和你分享一个使用Dart的综合案例,帮你学懂、学会这门语言。 + +如果你已经对Dart有一个初步印象了,也可以跳过这篇预习文章,直接学习后面的内容。 + +Dart是什么? + +2011年10月,在丹麦召开的GOTO大会上,Google发布了一种新的编程语言Dart。如同Kotlin和Swift的出现,分别是为了解决Java和Objective-C在编写应用程序的一些实际问题一样,Dart的诞生正是要解决JavaScript存在的、在语言本质上无法改进的缺陷。 + +那么,JavaScript到底有哪些问题和缺陷呢?JavaScript之父布兰登 · 艾克(Brendan Eich)曾在一次采访中说,JavaScript“几天就设计出来了”。 + +概括来说,他的设计思路是这样的: + + +借鉴C语言的基本语法; +借鉴Java语言的数据类型和内存管理机制; +借鉴Scheme语言,将函数提升到“第一等公民”(first class)的地位; +借鉴Self语言,使用基于原型(prototype)的继承机制。 + + +所以,JavaScript实际上是两类编程语言风格的混合产物:(简化的)函数式编程风格,与(简化的)面向对象编程风格。 + +由于设计时间太短,一些细节考虑得不够严谨,导致后来很长一段时间,使用JavaScript开发的程序混乱不堪。出于对JavaScript的不满,Google的程序员们决定自己写一个新语言来换掉它,所以Dart的最初定位也是一种运行在浏览器中的脚本语言。 + +而为了推广Dart,Google甚至将自己的Chrome浏览器内置了Dart VM,可以直接高效地运行Dart代码。而对于普通浏览器来说,Google也提供了一套能够将Dart代码编译成JavaScript代码的转换工具。这样一来,开发者们就可以毫无顾虑地使用Dart去开发了,而不必担心兼容问题。再加上出身名门,Dart在一开始就赢得了部分前端开发者的关注。 + +但,JavaScript的生命力似乎比预想的更强大。 + +原本JavaScript只能在浏览器中运行,但Node.js的出现让它开始有能力运行在服务端,很快手机应用与桌面应用也成为了JavaScript的宿主容器,一些明星项目比如React、React Native、Vue、Electron、NW(node-webkit)等框架如雨后春笋般崛起,迅速扩展了它的边界。 + +于是,JavaScript成为了前后端通吃的全栈语言,前端的开发模式也因此而改变,进入了一个新的世界。就如同Atwood定律描述的:凡是能用JavaScript写出来的系统,最终都会用JavaScript写出来(Any application that can be written in JavaScript, will eventually be written in JavaScript.)。 + +JavaScript因为Node.js焕发了第二春,而Dart就没有那么好的运气了。由于缺少顶级项目的使用,Dart始终不温不火。2015年,在听取了大量开发者的反馈后,Google决定将内置的Dart VM引擎从Chrome移除,这对Dart的发展来说是重大挫折,替代JavaScript就更无从谈起了。 + +但,Dart也借此机会开始转型:在Google内部孵化了移动开发框架Flutter,弯道超车进入了移动开发的领域;而在Google未来的操作系统Fuchsia中,Dart更是被指定为官方的开发语言。 + +与此同时,Dart的老本行,浏览器前端的发展也并未停滞。著名的前端框架Angular,除了常见的TS版本外,也在持续迭代对应的Dart版本AngularDart。(不过不得不说的是,这个项目的star一直以来只有可怜的1,100出头)。 + +也正是因为使用者不多、历史包袱少,所以在经历了这么多的故事后,Dart可以彻底转变思路,成为专注大前端与跨平台生态的语言。 + +接下来,我们就从Flutter开发的视角,聊聊Dart最重要的核心特性吧。 + +Dart的特性 + +每门语言都有各自的特点,适合自己的才是最好的。 + +作为移动端开发的后来者,Dart语言可以说是集百家之长,拥有其他优秀编程语言的诸多特性和影子,所以对于其他语言的开发者而言,学习成本无疑是非常低的。同时,Dart拥有的特点则恰到好处,在对Flutter的支持上做到了独一无二。所以,Dart成了Flutter的选择。 + +下面,我就和你详细分享下它的核心特性。 + +JIT与AOT + +借助于先进的工具链和编译器,Dart是少数同时支持JIT(Just In Time,即时编译)和AOT(Ahead of Time,运行前编译)的语言之一。那,到底什么是JIT和AOT呢? + +语言在运行之前通常都需要编译,JIT和AOT则是最常见的两种编译模式。 + + +JIT在运行时即时编译,在开发周期中使用,可以动态下发和执行代码,开发测试效率高,但运行速度和执行性能则会因为运行时即时编译受到影响。 +AOT即提前编译,可以生成被直接执行的二进制代码,运行速度快、执行性能表现好,但每次执行前都需要提前编译,开发测试效率低。 + + +总结来讲,在开发期使用JIT编译,可以缩短产品的开发周期。Flutter最受欢迎的功能之一热重载,正是基于此特性。而在发布期使用AOT,就不需要像React Native那样在跨平台JavaScript代码和原生Android、iOS代码之间建立低效的方法调用映射关系。所以说,Dart具有运行速度快、执行性能好的特点。 + +那么,如何区分一门语言究竟是AOT还是JIT呢?通常来说,看代码在执行前是否需要编译即可。如果需要编译,通常属于AOT;如果不需要,则属于JIT。 + +AOT的典型代表是C/C++,它们必须在执行前编译成机器码;而JIT的代表,则包括了如JavaScript、Python等几乎所有的脚本语言。 + +内存分配与垃圾回收 + +Dart VM的内存分配策略比较简单,创建对象时只需要在堆上移动指针,内存增长始终是线性的,省去了查找可用内存的过程。 + +在Dart中,并发是通过Isolate实现的。Isolate是类似于线程但不共享内存,独立运行的worker。这样的机制,就可以让Dart实现无锁的快速分配。 + +Dart的垃圾回收,则是采用了多生代算法。新生代在回收内存时采用“半空间”机制,触发垃圾回收时,Dart会将当前半空间中的“活跃”对象拷贝到备用空间,然后整体释放当前空间的所有内存。回收过程中,Dart只需要操作少量的“活跃”对象,没有引用的大量“死亡”对象则被忽略,这样的回收机制很适合Flutter框架中大量Widget销毁重建的场景。 + +单线程模型 + +支持并发执行线程的高级语言(比如,C++、Java、Objective-C),大都以抢占式的方式切换线程,即:每个线程都会被分配一个固定的时间片来执行,超过了时间片后线程上下文将被抢占后切换。如果这时正在更新线程间的共享资源,抢占后就可能导致数据不同步的问题。 + +解决这一问题的典型方法是,使用锁来保护共享资源,但锁本身又可能会带来性能损耗,甚至出现死锁等更严重的问题。 + +这时,Dart是单线程模型的优势就体现出来了,因为它天然不存在资源竞争和状态同步的问题。这就意味着,一旦某个函数开始执行,就将执行到这个函数结束,而不会被其他Dart代码打断。 + +所以,Dart中并没有线程,只有Isolate(隔离区)。Isolates之间不会共享内存,就像几个运行在不同进程中的worker,通过事件循环(Event Looper)在事件队列(Event Queue)上传递消息通信。 + +无需单独的声明式布局语言 + +在Flutter中,界面布局直接通过Dart编码来定义。 + +Dart声明式编程布局易于阅读和可视化,使得Flutter并不需要类似JSX或XML的声明式布局语言。所有的布局都使用同一种格式,也使得Flutter很容易提供高级工具使布局更简单。 + +开发过程也不需要可视化界面构建器,因为热重载可以让我们立即在手机上看到运行效果。 + +Dart的未来 + +那么,在这样的背景下诞生的Dart,今后发展会怎样呢? + +Dart是一个优秀而年轻的现代语言,但一种编程语言并不是搞定了引擎和开发者接口就算完成了,而是必须在这个语言得以立足的库、框架、 应用程序等“生态”都成熟起来之后,其价值才会真正开始体现。而要走到这一步,通常需要花上数年的时间。 + +目前,基于Dart语言的第三方库还很少,并且质量一般,不过值得庆幸的是,因为Flutter和Fuchsia的推动,Dart SDK更新迭代的速度快了很多,开发者的热情也急剧增长,Dart生态增速很快。 + +毕竟,在Dart社区目前最顶级的产品就是Flutter和Fuchsia了,因此Dart开发者主要以Flutter开发者居多,当然了也有用Dart开发浏览器前端的开发者,但人数并不多。所以,我觉得Dart是否能够成功,目前来看主要取决于Flutter和Fuchsia能否成功。而,Flutter是构建Fuchsia的UI开发框架,因此这个问题也变成了Flutter能否成功。 + +正如我在开篇词中提到的,Flutter正式版发布也就半年多的时间,在GitHub上Star就已经超过了68,000,仅落后React Native 10,000左右,可见热度之高。 + +现在,我们一起回到Flutter自身来看,它的出现提供了一套彻底的跨平台方案,也确实弥补了当今跨平台开发框架的短板,解决了业界痛点,极有可能成为跨平台开发领域的终极解决方案,前途光明,未来非常值得期待。 + +至此,我们已经可以清晰地看到,Google在遭受与Oracle的Java侵权案后,痛定思痛后下定决心要发展自己的语言生态的布局愿景:Dart凭借Flutter与Fuchsia的生态主攻前端和移动端,而服务端,则有借助于Docker的火热势头增长迅猛的Go语言。 + +所以说,Google的布局不仅全面,应用和影响也非常广泛,前后端均有杀手级产品用来构建语言生态。相信随着Google新系统Fuchsia的发布,Flutter和Dart会以更迅猛的速度释放它们的力量,而Google统一前后端开发技能栈的愿望也会在一定程度上得以实现。 + +总结 + +今天,我带你了解了Dart出现的历史背景,从Flutter开发者的视角详细介绍了Dart语言的各种特性,并分析了Dart的未来发展。 + +Dart是一门现代语言,集合了各种优秀语言的优点。如果你不了解Dart也无需担心,只要你有过其他编程语言,尤其是Java、JavaScript、Swift或Objective-C编程经验的话,可以很容易地在Dart身上找它们的影子,以极低的成本快速上手。 + +希望通过这篇文章,你可以先对Dart语言有个初步了解,为我们接下来的学习打好基础。在本专栏的“Dart基础”模块中,我会对照着其他编程语言的特性,和你讲述Dart与它们相似的设计理念,帮助你快速建立起构建Flutter程序的所需要的Dart知识体系。 + +思考题 + +对于学习Dart或是其他编程语言,你有什么困扰或者心得吗? + +欢迎你在评论区给我留言分享你的经历和观点,我会在下一篇文章中等你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/03深入理解跨平台方案的历史发展逻辑.md b/专栏/Flutter核心技术与实战/03深入理解跨平台方案的历史发展逻辑.md new file mode 100644 index 0000000..a6e8230 --- /dev/null +++ b/专栏/Flutter核心技术与实战/03深入理解跨平台方案的历史发展逻辑.md @@ -0,0 +1,151 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 深入理解跨平台方案的历史发展逻辑 + 你好,我是陈航。 + +今天,我会从跨平台开发方案的诞生背景、原理和发展历史的角度,和你聊聊这些常见的开发方案,以及针对不同的场景我们应该如何选择对应的方案。 + +浅述跨平台开发的背景 + +我们当下所处的移动互联网时代,以它独有的变革方式,带给我们快捷、经济、安全和方便,改变着生活的方方面面。而对于企业来说,移动应用已然成为各类手机终端上一张必备的产品名片。 + +在移动互联网的浪潮下,我们开发的应用要想取胜,开发效率和使用体验可以说是同等重要。但是,使用原生的方式来开发App,就要求我们必须针对iOS和Android这两个平台分别开发,这对于中小型团队来说就是隐患和额外的负担。 + +因为这样的话,我们不仅需要在不同的项目间尝试用不同的语言去实现同样的功能,还要承担由此带来的维护任务。如果还要继续向其他平台(比如Web、Mac或Windows)拓展的话,我们需要付出的成本和时间将成倍增长。而这,显然是难以接受的。于是,跨平台开发的概念顺势走进了我们的视野。 + +所以从本质上讲,跨平台开发是为了增加业务代码的复用率,减少因为要适配多个平台带来的工作量,从而降低开发成本。在提高业务专注度的同时,能够为用户提供一致的用户体验。用一个词来概括这些好处的话,就是“多快好省”。 + +“一次编码,到处运行”。二十多年前Java正是以跨平台特性的口号登场,击败了众多竞争对手。这个口号,意味着Java可以在任何平台上进行开发,然后编译成一段标准的字节码后,就可以运行在任何安装有Java虚拟机(JVM)的设备上。虽然现在跨平台已经不是Java的最大优势(而是它繁荣的生态),但不可否认它当年打着跨平台旗号横空出世确实势不可挡。 + +而对于移动端开发来讲,如果能实现“一套代码,多端运行”,这样的技术势必会引发新的生产力变革,在目前多终端时代的大环境下,可以为企业节省人力资源上,从而带来直接的经济效益。 + +伴随着移动端的诞生和繁荣,为了满足人们对开发效率和用户体验的不懈追求,各种跨平台的开发方案也如雨后春笋般涌现。除了React Native和Flutter之外,这几年还出现过许多其他的解决方案,接下来我将会为你一一分析这些方案。这样,你在选择适合自己的移动开发框架时,也就有章可循了。 + +在此,我特地强调一下,我在下文提到的跨平台开发方案,如果没有特殊说明的话,指的就是跨iOS和Android开发。 + +跨平台开发方案的三个时代 + +根据实现方式的不同,业内常见的观点是将主流的跨平台方案划分为三个时代。 + + +Web容器时代:基于Web相关技术通过浏览器组件来实现界面及功能,典型的框架包括Cordova(PhoneGap)、Ionic和微信小程序。 +泛Web容器时代:采用类Web标准进行开发,但在运行时把绘制和渲染交由原生系统接管的技术,代表框架有React Native、Weex和快应用,广义的还包括天猫的Virtual View等。 +自绘引擎时代:自带渲染引擎,客户端仅提供一块画布即可获得从业务逻辑到功能呈现的多端高度一致的渲染体验。Flutter,是为数不多的代表。 + + +接下来,我们先看一下目前使用最广泛的Web容器方案。 + +Web容器时代 + +Web时代的方案,主要采用的是原生应用内嵌浏览器控件WebView(iOS为UIWebView或WKWebView,Android为WebView)的方式进行HTML5页面渲染,并定义HTML5与原生代码交互协议,将部分原生系统能力暴露给HTML5,从而扩展HTML5的边界。这类交互协议,就是我们通常说的JS Bridge(桥)。 + +这种开发模式既有原生应用代码又有Web应用代码,因此又被称为Hybrid开发模式。由于HTML5代码只需要开发一次,就能同时在多个系统运行,因此大大降低了开发成本。 + +由于采用了Web开发技术,社区和资源非常丰富,开发效率也很高。但,一个完整HTML5页面的展示要经历浏览器控件的加载、解析和渲染三大过程,性能消耗要比原生开发增加N个数量级。 + +接下来,我以加载过程为例,和你说明这个过程的复杂性。 + + +浏览器控件加载HTML5页面的HTML主文档; +加载过程中遇到外部CSS文件,浏览器另外发出一个请求,来获取CSS文件; +遇到图片资源,浏览器也会另外发出一个请求,来获取图片资源。这是异步请求,并不会影响HTML文档的加载。 +加载过程中遇到JavaScript文件,由于JavaScript代码可能会修改DOM树,因此HTML文档会挂起渲染(加载解析渲染同步)的线程,直到JavaScript文件加载解析并执行完毕,才可以恢复HTML文档的渲染线程。 +JavaScript代码中有用到CSS文件中的属性样式,于是阻塞,等待CSS加载完毕才能恢复执行。 + + +而这,只是完成HTML5页面渲染的最基础的加载过程。加载、解析和渲染这三个过程在实际运行时又不是完全独立的,还会有交叉。也就是说,会存在一边加载,一边解析,一边渲染的现象。这,就使得页面的展示并不像想象中那么容易。 + +通过上面的分析你可以看出,一个HTML5页面的展示是多么得复杂!这和原生开发通过简单直接的创建控件,设置属性后即可完成页面渲染有非常大的差异。Web与原生在UI渲染与系统功能调用上各司其职,因此这个时代的框架在Web与原生系统间还有比较明显的、甚至肉眼可见的边界。 + + + +图1 Hybrid开发框架 + +我也曾碰到过很多人觉得跨平台开发不靠谱。但其实,Web容器方案是跨平台开发历史上最成功的例子。也正是因为它太成功了,以至于很多人都忽略了它也是跨平台方案之一。 + +泛Web容器时代 + +虽然Web容器方案具有生态繁荣、开发体验友好、生产效率高、跨平台兼容性强等优势,但它最大的问题在于承载着大量Web标准的Web容器过于笨重,以至于性能和体验都达不到与原生同样的水准,在复杂交互和动画上较难实现出优良的用户体验。 + +而在实际的产品功能研发中,我们通常只会用到Web标准中很小的一部分。面对这样的现实,我们很快就想到:能否对笨重的Web容器进行功能裁剪,在仅保留必要的Web标准和渲染能力的基础上,使得友好的开发体验与稳定的渲染性能保持一个平衡? + +答案当然是可以。 + +泛Web容器时代的解决方案优化了Web容器时代的加载、解析和渲染这三大过程,把影响它们独立运行的Web标准进行了裁剪,以相对简单的方式支持了构建移动端页面必要的Web标准(如Flexbox等),也保证了便捷的前端开发体验;同时,这个时代的解决方案基本上完全放弃了浏览器控件渲染,而是采用原生自带的UI组件实现代替了核心的渲染引擎,仅保持必要的基本控件渲染能力,从而使得渲染过程更加简化,也保证了良好的渲染性能。 + +也就是说,在泛Web容器时代,我们仍然采用前端友好的JavaScript进行开发,整体加载、渲染机制大大简化,并且由原生接管绘制,即将原生系统作为渲染的后端,为依托于JavaScript虚拟机的JavaScript代码提供所需要的UI控件的实体。这,也是现在绝大部分跨平台框架的思路,而React Native和Weex就是其中的佼佼者。 + + + +图2 泛Web容器框架 + +为了追求性能体验的极致,并进一步维持方案的简单可扩展性,有些轻量级的跨平台方案甚至会完全抛弃Web标准、放弃JavaScript的动态执行能力而自创一套原生DSL,如天猫的VirtualView框架。从广义上来说,这些方案也是泛Web容器类方案。 + +自绘引擎时代 + +泛Web容器时代使用原生控件承载界面渲染,固然解决了不少性能问题,但同时也带来了新的问题。抛开框架本身需要处理大量平台相关的逻辑外,随着系统版本变化和API的变化,我们还需要处理不同平台的原生控件渲染能力差异,修复各类奇奇怪怪的Bug。始终需要Follow Native的思维方式,就使得泛Web容器框架的跨平台特性被大打折扣。 + +而这一时期的代表Flutter则开辟了一种全新的思路,即从头到尾重写一套跨平台的UI框架,包括渲染逻辑,甚至是开发语言。 + + +渲染引擎依靠跨平台的Skia图形库来实现,Skia引擎会将使用Dart构建的抽象的视图结构数据加工成GPU数据,交由OpenGL最终提供给GPU渲染,至此完成渲染闭环,因此可以在最大程度上保证一款应用在不同平台、不同设备上的体验一致性。 +而开发语言选用的是同时支持JIT(Just-in-Time,即时编译)和AOT(Ahead-of-Time,预编译)的Dart,不仅保证了开发效率,更提升了执行效率(比使用JavaScript开发的泛Web容器方案要高得多)。 + + + + +图3 自绘引擎开发框架 + +通过这样的思路,Flutter可以尽可能地减少不同平台之间的差异, 同时保持和原生开发一样的高性能。所以说,Flutter成了三类跨平台移动开发方案中最灵活的那个,也成了目前最受业界关注的框架。 + +现在,我们已经弄明白了三类跨平台方案,那么我在开发应用的时候,到底应该如何选择最适合自己的框架呢? + +我该选择哪一类跨平台开发方案? + +从不同的角度来看,三个时代的跨平台框架代表们在开发效率、渲染性能、维护成本和社区生态上各有优劣,如下图所示: + + + +图 4 主流跨平台框架对比 + +我们在做技术选型时,可以参考以上维度,从开发效率、技术栈、性能表现、维护成本和社区生态来进行综合考虑。比如,是否必须支持动态化?是只解决Android、iOS的跨端问题,还是要包括Web?对性能要求如何?对多端体验的绝对一致性和维护成本是否有强诉求? + +从各个维度综合考量,React Native和Flutter无疑是最均衡的两种跨平台开发方案,而其他的方案或多或少都“偏科严重”。 + + +React Native依托于Facebook,经过4年多的发展已经成长为跨平台开发方案的实际领导者,并拥有较为丰富的第三方库和开发社区; +Flutter以挑战者姿态出现在我们的面前,可以提供更彻底的跨平台技术解决方案。虽然Flutter推出时间不长,但也有了诸多商用案例,加上清晰的产品路线图和Google的强大号召力,Flutter未来的发展非常值得期待。 + + +那么问题来了,我究竟应该选择React Native还是Flutter呢? + +在这里,我和你说一下我的建议吧。 + +对于知识学习来说,这两个应用层面的框架最好都学。学习的过程中最重要的是打好基础,深入理解框架的原理和设计思想,重点思考它们的API设计的取舍,发现它们的共性和差异。 + +Flutter作为后来者,也从React Native社区学习和借鉴了不少的优秀设计,很多概念两边都有对应,比如React Native的Component和Flutter的Widget、Flex布局思想、状态管理和函数式编程等等,这类的知识都是两个框架通用的技术。未来也许还会出现新的解决方案,老框架也会不断更新,只有掌握核心原理才能真正立于不败之地。 + +对于实际项目来说,这两个框架都已达到了大面积商业应用的标准。综合成熟度和生态,目前俩看React Native略胜Flutter。因此,如果是中短期项目的话,我建议使用React Native。但作为技术选型,我们要看得更远一些。Flutter的设计理念比较先进,解决方案也相对彻底,在渲染能力的一致性以及性能上,和React Native相比优势非常明显。 + +此外,Flutter的野心不仅仅是移动端。前段时间,Google团队已经完成了Hummingbird,即Flutter的Web的官方Demo,在桌面操作系统的探索上也取得了进展,未来大前端技术栈是否会由Flutter完成统一,值得期待。 + +小结 + +这就是今天分享的全部内容了。 + +在不同平台开发和维护同一个产品,所付出的成本一直以来一个令人头疼的问题,于是各类跨平台开发方案顺应而生。从Web容器时代到以React Native、Weex为代表的泛Web容器时代,最后再到以Flutter为代表的自绘引擎时代,这些优秀的跨平台开发框架们慢慢抹平了各个平台的差异,使得操作系统的边界变得越来越模糊。 + +与此同时,这个时代对开发者的要求也到达了一个新的阶段,拥抱大前端的时代已经向我们走来。在这个专栏里,我会假设你有一定的前端(Android、iOS或Web)开发基础。比如,你知道View是什么,路由是什么,如何实现一个基本页面布局等等。我会让希望迅速掌握Flutter开发的爱好者们,通过一种比较熟悉和友好的路径去学习Flutter相关的代码和程序,以及背后的原理和设计思想。 + +思考题 + +你有哪些跨平台开发框架的使用经历呢? + +欢迎你在评论区给我留言分享你的经历和观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/04Flutter区别于其他方案的关键技术是什么?.md b/专栏/Flutter核心技术与实战/04Flutter区别于其他方案的关键技术是什么?.md new file mode 100644 index 0000000..21734ac --- /dev/null +++ b/专栏/Flutter核心技术与实战/04Flutter区别于其他方案的关键技术是什么?.md @@ -0,0 +1,195 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 Flutter区别于其他方案的关键技术是什么? + 你好,我是陈航。 + +Flutter是什么?它出现的动机是什么,解决了哪些痛点?相比其他跨平台技术,Flutter的优势在哪里?……相信很多人在第一眼看到Flutter时,都会有类似的疑问。 + +别急,在今天的这篇文章中,我会与你介绍Flutter的历史背景和运行机制,并以界面渲染过程为例与你讲述其实现原理,让你对Flutter能够有一个全方位的认知和感受。在对Flutter有了全面了解后,这些疑问自然也就迎刃而解了。 + +接下来,我们就从Flutter出现的历史背景开始谈起吧。 + +Flutter出现的历史背景 + +为不同的操作系统开发拥有相同功能的应用程序,开发人员只有两个选择: + + +使用原生开发语言(即Java和Objective-C),针对不同平台分别进行开发。 +使用跨平台解决方案,对不同平台进行统一开发。 + + +原生开发方式的体验最好,但研发效率和研发成本相对较高;而跨平台开发方式研发虽然效率高,但为了抹平多端平台差异,各类解决方案暴露的组件和API较原生开发相比少很多,因此研发体验和产品功能并不完美。 + +所以,最成功的跨平台开发方案其实是依托于浏览器控件的Web。浏览器保证了99%的概率下Web的需求都是可以实现的,不需要业务将就“技术”。不过,Web最大的问题在于它的性能和体验与原生开发存在肉眼可感知的差异,因此并不适用于对体验要求较高的场景。 + +对于用户体验更接近于原生的React Native,对业务的支持能力却还不到浏览器的5%,仅适用于中低复杂度的低交互类页面。面对稍微复杂一点儿的交互和动画需求,开发者都需要case by case地去review,甚至还可能要通过原生代码去扩展才能实现。 + +这些因素,也就导致了虽然跨平台开发从移动端诞生之初就已经被多次提及,但到现在也没有被很好地解决。 + +带着这些问题,我们终于迎来了本次专栏的主角——Flutter。 + +Flutter是构建Google物联网操作系统Fuchsia的SDK,主打跨平台、高保真、高性能。开发者可以通过 Dart语言开发App,一套代码可以同时运行在 iOS 和 Android平台。 Flutter使用Native引擎渲染视图,并提供了丰富的组件和接口,这无疑为开发者和用户都提供了良好的体验。 + +从2017年5月,谷歌公司发布的了Alpha版本的Flutter,到2018年底Flutter Live发布的1.0版本,再到现在最新的1.5版本(截止至2019年7月1日),Flutter正在赢得越来越多的关注。 + +很多人开始感慨,跨平台技术似乎终于迎来了最佳解决方案。那么,接下来我们就从原理层面去看看,Flutter是如何解决既有跨平台开发方案问题的。 + +Flutter是怎么运转的? + +与用于构建移动应用程序的其他大多数框架不同,Flutter是重写了一整套包括底层渲染逻辑和上层开发语言的完整解决方案。这样不仅可以保证视图渲染在Android和iOS上的高度一致性(即高保真),在代码执行效率和渲染性能上也可以媲美原生App的体验(即高性能)。 + +这,就是Flutter和其他跨平台方案的本质区别: + + +React Native之类的框架,只是通过JavaScript虚拟机扩展调用系统组件,由Android和iOS系统进行组件的渲染; +Flutter则是自己完成了组件渲染的闭环。 + + +那么,Flutter是怎么完成组件渲染的呢?这需要从图像显示的基本原理说起。 + +在计算机系统中,图像的显示需要CPU、GPU和显示器一起配合完成:CPU负责图像数据计算,GPU负责图像数据渲染,而显示器则负责最终图像显示。 + +CPU把计算好的、需要显示的内容交给GPU,由GPU完成渲染后放入帧缓冲区,随后视频控制器根据垂直同步信号(VSync)以每秒60次的速度,从帧缓冲区读取帧数据交由显示器完成图像显示。 + +操作系统在呈现图像时遵循了这种机制,而Flutter作为跨平台开发框架也采用了这种底层方案。下面有一张更为详尽的示意图来解释Flutter的绘制原理。 + + + +图1 Flutter绘制原理 + +可以看到,Flutter关注如何尽可能快地在两个硬件时钟的VSync信号之间计算并合成视图数据,然后通过Skia交给GPU渲染:UI线程使用Dart来构建视图结构数据,这些数据会在GPU线程进行图层合成,随后交给Skia引擎加工成GPU数据,而这些数据会通过OpenGL最终提供给GPU渲染。 + +在进一步学习Flutter之前,我们有必要了解下构建Flutter的关键技术,即Skia和Dart。 + +Skia是什么? + +要想了解Flutter,你必须先了解它的底层图像渲染引擎Skia。因为,Flutter只关心如何向GPU提供视图数据,而Skia就是它向GPU提供视图数据的好帮手。 + +Skia是一款用C++开发的、性能彪悍的2D图像绘制引擎,其前身是一个向量绘图软件。2005年被Google公司收购后,因为其出色的绘制表现被广泛应用在Chrome和Android等核心产品上。Skia在图形转换、文字渲染、位图渲染方面都表现卓越,并提供了开发者友好的API。 + +因此,架构于Skia之上的Flutter,也因此拥有了彻底的跨平台渲染能力。通过与Skia的深度定制及优化,Flutter可以最大限度地抹平平台差异,提高渲染效率与性能。 + +底层渲染能力统一了,上层开发接口和功能体验也就随即统一了,开发者再也不用操心平台相关的渲染特性了。也就是说,Skia保证了同一套代码调用在Android和iOS平台上的渲染效果是完全一致的。 + +为什么是Dart? + +除了我们在第2篇预习文章“预习篇 · Dart语言概览”中提到的,Dart因为同时支持AOT和JIT,所以具有运行速度快、执行性能好的特点外,Flutter为什么选择了Dart,而不是前端应用的准官方语言JavaScript呢?这个问题很有意思,但也很有争议。 + +很多人说,选择Dart是Flutter推广的一大劣势,毕竟多学一门新语言就多一层障碍。想想Java对Android,JavaScript对NodeJS的推动,如果换个语言可能就不一样了。 + +但,Google公司给出的原因很简单也很直接:Dart语言开发组就在隔壁,对于Flutter需要的一些语言新特性,能够快速在语法层面落地实现;而如果选择了JavaScript,就必须经过各种委员会和浏览器提供商漫长的决议。 + +事实上,Flutter的确得到了兄弟团队的紧密支持。2018年2月发布的Dart 2.0,2018年12月发布的Dart 2.1,2019年2月发布的Dart 2.2,2019年5月发布的Dart2.3,每次发布都包含了为Flutter量身定制的诸多改造(比如,改进的AOT性能、更智能的类型隐式转换等)。 + +当然,Google公司选择使用Dart作为Flutter的开发语言,我想还有其他更有说服力的理由: + + +Dart同时支持即时编译JIT和事前编译AOT。在开发期使用JIT,开发周期异常短,调试方式颠覆常规(支持有状态的热重载);而发布期使用AOT,本地代码的执行更高效,代码性能和用户体验也更卓越。 +Dart作为一门现代化语言,集百家之长,拥有其他优秀编程语言的诸多特性(比如,完善的包管理机制)。也正是这个原因,Dart的学习成本并不高,很容易上手。 +Dart避免了抢占式调度和共享内存,可以在没有锁的情况下进行对象分配和垃圾回收,在性能方面表现相当不错。 + + +Dart是一门优秀的现代语言,最初设计也是为了取代JavaScript成为Web开发的官方语言。竞争对手如此强劲,最后的结果可想而知。这,也是为什么相比于其他热门语言,Dart的生态要冷清不少的原因。 + +而随着Flutter的发布,Dart开始转型,其自身定位也发生了变化,专注于改善构建客户端应用程序的体验,因此越来越多的开发者开始慢慢了解、学习这门语言,并共同完善它的生态。凭借着Flutter的火热势头,辅以Google强大的商业运作能力,相信转型后的Dart前景会非常光明。 + +Flutter的原理 + +在了解了Flutter的基本运作机制后,我们再来深入了解一下Flutter的实现原理。 + +首先,我们来看一下Flutter的架构图。我希望通过这张图以及对应的解读,你能在开始学习的时候就建立起对Flutter的整体印象,能够从框架设计和实现原理的高度去理解Flutter区别其他跨平台解决方案的关键所在,为后面的学习打好基础,而不是直接一上来就陷入语言和框架的功能细节“泥潭”而无法自拔。 + + + +图2 Flutter架构图 + +备注:此图引自Flutter System Overview + +Flutter架构采用分层设计,从下到上分为三层,依次为:Embedder、Engine、Framework。 + + +Embedder是操作系统适配层,实现了渲染Surface设置,线程设置,以及平台插件等平台相关特性的适配。从这里我们可以看到,Flutter平台相关特性并不多,这就使得从框架层面保持跨端一致性的成本相对较低。 +Engine层主要包含Skia、Dart和Text,实现了Flutter的渲染引擎、文字排版、事件处理和Dart运行时等功能。Skia和Text为上层接口提供了调用底层渲染和排版的能力,Dart则为Flutter提供了运行时调用Dart和渲染引擎的能力。而Engine层的作用,则是将它们组合起来,从它们生成的数据中实现视图渲染。 +Framework层则是一个用Dart实现的UI SDK,包含了动画、图形绘制和手势识别等功能。为了在绘制控件等固定样式的图形时提供更直观、更方便的接口,Flutter还基于这些基础能力,根据Material和Cupertino两种视觉设计风格封装了一套UI组件库。我们在开发Flutter的时候,可以直接使用这些组件库。 + + +接下来,我以界面渲染过程为例,和你介绍Flutter是如何工作的。 + +页面中的各界面元素(Widget)以树的形式组织,即控件树。Flutter通过控件树中的每个控件创建不同类型的渲染对象,组成渲染对象树。而渲染对象树在Flutter的展示过程分为四个阶段:布局、绘制、合成和渲染。 + +布局 + +Flutter采用深度优先机制遍历渲染对象树,决定渲染对象树中各渲染对象在屏幕上的位置和尺寸。在布局过程中,渲染对象树中的每个渲染对象都会接收父对象的布局约束参数,决定自己的大小,然后父对象按照控件逻辑决定各个子对象的位置,完成布局过程。 + + + +图3 Flutter布局过程 + +为了防止因子节点发生变化而导致整个控件树重新布局,Flutter加入了一个机制——布局边界(Relayout Boundary),可以在某些节点自动或手动地设置布局边界,当边界内的任何对象发生重新布局时,不会影响边界外的对象,反之亦然。 + + + +图4 Flutter布局边界 + +绘制 + +布局完成后,渲染对象树中的每个节点都有了明确的尺寸和位置。Flutter会把所有的渲染对象绘制到不同的图层上。与布局过程一样,绘制过程也是深度优先遍历,而且总是先绘制自身,再绘制子节点。 + +以下图为例:节点1在绘制完自身后,会再绘制节点2,然后绘制它的子节点3、4和5,最后绘制节点6。 + + + +图5 Flutter 绘制示例 + +可以看到,由于一些其他原因(比如,视图手动合并)导致2的子节点5与它的兄弟节点6处于了同一层,这样会导致当节点2需要重绘的时候,与其无关的节点6也会被重绘,带来性能损耗。 + +为了解决这一问题,Flutter提出了与布局边界对应的机制——重绘边界(Repaint Boundary)。在重绘边界内,Flutter会强制切换新的图层,这样就可以避免边界内外的互相影响,避免无关内容置于同一图层引起不必要的重绘。 + + + +图6 Flutter重绘边界 + +重绘边界的一个典型场景是Scrollview。ScrollView滚动的时候需要刷新视图内容,从而触发内容重绘。而当滚动内容重绘时,一般情况下其他内容是不需要重绘的,这时候重绘边界就派上用场了。 + +合成和渲染 + +终端设备的页面越来越复杂,因此Flutter的渲染树层级通常很多,直接交付给渲染引擎进行多图层渲染,可能会出现大量渲染内容的重复绘制,所以还需要先进行一次图层合成,即将所有的图层根据大小、层级、透明度等规则计算出最终的显示效果,将相同的图层归类合并,简化渲染树,提高渲染效率。 + +合并完成后,Flutter会将几何图层数据交由Skia引擎加工成二维图像数据,最终交由GPU进行渲染,完成界面的展示。这部分内容,我已经在前面的内容中介绍过,这里就不再赘述了。 + +接下来,我们再看看学习Flutter,都需要学习哪些知识。 + +学习Flutter需要掌握哪些知识? + +终端设备越来越碎片化,需要支持的操作系统越来越多,从研发效率和维护成本综合考虑,跨平台开发一定是未来大前端的趋势,我们应该拥抱变化。而Flutter提供了一套彻底的移动跨平台方案,也确实弥补了如今跨平台开发框架的短板,解决了业界痛点,极有可能成为跨平台开发领域的终极解决方案,前途非常光明。 + +那么,我们学习Flutter都需要掌握哪些知识呢? + +我按照App的开发流程(开发、调试测试、发布与线上运维)将Flutter的技术栈进行了划分,里面几乎包含了Flutter开发需要的所有知识点。而这些所有知识点,我会在专栏中为你一一讲解。掌握了这些知识点后,你也就具备了企业级应用开发的必要技能。 + +这些知识点,如下图所示: + + + +图7 Flutter知识体系 + +有了这张图,你是否感觉到学习Flutter的路线变得更加清晰了呢? + +小结 + +今天,我带你了解了Flutter的历史背景与运行机制,并以界面渲染过程为例,从布局、绘制、合成和渲染四个阶段讲述了Flutter的实现原理。此外,我向你介绍了构建Flutter底层的关键技术:Skia与Dart,它们是Flutter有别于其他跨平台开发方案的核心所在。 + +最后,我梳理了一张Flutter学习思维导图,围绕一个应用的迭代周期介绍了Flutter相关的知识点。我希望通过这个专栏,能和你把Flutter背后的设计原理和知识体系讲清楚,让你能对Flutter有一个整体感知。这样,在你学完这个专栏以后,就能够具备企业级应用开发的理论基础与实践。 + +思考题 + +你是如何理解Flutter的三大特点:跨平台、高保真、高性能的?你又打算怎么学习这个专栏呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/05从标准模板入手,体会Flutter代码是如何运行在原生系统上的.md b/专栏/Flutter核心技术与实战/05从标准模板入手,体会Flutter代码是如何运行在原生系统上的.md new file mode 100644 index 0000000..2d981cd --- /dev/null +++ b/专栏/Flutter核心技术与实战/05从标准模板入手,体会Flutter代码是如何运行在原生系统上的.md @@ -0,0 +1,161 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 从标准模板入手,体会Flutter代码是如何运行在原生系统上的 + 你好,我是陈航。 + +在专栏的第一篇预习文章中,我和你一起搭建了Flutter的开发环境,并且通过自带的hello_world示例,和你演示了Flutter项目是如何运行在Android和iOS模拟器以及真机上的。 + +今天,我会通过Android Studio创建的Flutter应用模板,带你去了解Flutter的项目结构,分析Flutter工程与原生Android和iOS工程有哪些联系,体验一个有着基本功能的Flutter应用是如何运转的,从而加深你对构建Flutter应用的关键概念和技术的理解。 + +如果你现在还不熟悉Dart语言也不用担心,只要能够理解基本的编程概念(比如,类型、变量、函数和面向对象),并具备一定的前端基础(比如,了解View是什么、页面基本布局等基础知识),就可以和我一起完成今天的学习。而关于Dart语言基础概念的讲述、案例分析,我会在下一个模块和你展开。 + +计数器示例工程分析 + +首先,我们打开Android Studio,创建一个Flutter工程应用flutter_app。Flutter会根据自带的应用模板,自动生成一个简单的计数器示例应用Demo。我们先运行此示例,效果如下: + + + +图1 计数器示例运行效果 + +每点击一次右下角带“+”号的悬浮按钮,就可以看到屏幕中央的数字随之+1。 + +工程结构 + +在体会了示例工程的运行效果之后,我们再来看看Flutter工程目录结构,了解Flutter工程与原生Android和iOS工程之间的关系,以及这些关系是如何确保一个Flutter程序可以最终运行在Android和iOS系统上的。 + + + +图2 Flutter工程目录结构 + +可以看到,除了Flutter本身的代码、资源、依赖和配置之外,Flutter工程还包含了Android和iOS的工程目录。 + +这也不难理解,因为Flutter虽然是跨平台开发方案,但却需要一个容器最终运行到Android和iOS平台上,所以Flutter工程实际上就是一个同时内嵌了Android和iOS原生子工程的父工程:我们在lib目录下进行Flutter代码的开发,而某些特殊场景下的原生功能,则在对应的Android和iOS工程中提供相应的代码实现,供对应的Flutter代码引用。 + +Flutter会将相关的依赖和构建产物注入这两个子工程,最终集成到各自的项目中。而我们开发的Flutter代码,最终则会以原生工程的形式运行。 + +工程代码 + +在对Flutter的工程结构有了初步印象之后,我们就可以开始学习Flutter的项目代码了。 + +Flutter自带的应用模板,也就是这个计数器示例,对初学者来说是一个极好的入门范例。在这个简单示例中,从基础的组件、布局到手势的监听,再到状态的改变,Flutter最核心的思想在这60余行代码中展现得可谓淋漓尽致。 + +为了便于你学习理解,领会构建Flutter程序的大体思路与关键技术,而不是在一开始时就陷入组件的API细节中,我删掉了与核心流程无关的组件配置代码及布局逻辑,在不影响示例功能的情况下对代码进行了改写,并将其分为两部分: + + +第一部分是应用入口、应用结构以及页面结构,可以帮助你理解构建Flutter程序的基本结构和套路; +第二部分则是页面布局、交互逻辑及状态管理,能够帮你理解Flutter页面是如何构建、如何响应交互,以及如何更新的。 + + +首先,我们来看看第一部分的代码,也就是应用的整体结构: + +import 'package:flutter/material.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialApp(home: MyHomePage(title: 'Flutter Demo Home Page')); +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key key, this.title}) : super(key: key); + final String title; + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + Widget build(BuildContext context) => {...}; +} + + +在本例中,Flutter应用为MyApp类的一个实例,而MyApp类继承自StatelessWidget类,这也就意味着应用本身也是一个Widget。事实上,在Flutter中,Widget是整个视图描述的基础,在Flutter的世界里,包括应用、视图、视图控制器、布局等在内的概念,都建立在Widget之上,Flutter的核心设计思想便是一切皆Widget。 + +Widget是组件视觉效果的封装,是UI界面的载体,因此我们还需要为它提供一个方法,来告诉Flutter框架如何构建UI界面,这个方法就是build。 + +在build方法中,我们通常通过对基础Widget进行相应的UI配置,或是组合各类基础Widget的方式进行UI的定制化。比如在MyApp中,我通过MaterialApp这个Flutter App框架设置了应用首页,即MyHomePage。当然,MaterialApp也是一个Widget。 + +MaterialApp类是对构建material设计风格应用的组件封装框架,里面还有很多可配置的属性,比如应用主题、应用名称、语言标识符、组件路由等。但是,这些配置属性并不是本次分享的重点,如果你感兴趣的话,可以参考Flutter官方的API文档,来了解MaterialApp框架的其他配置能力。 + +MyHomePage是应用的首页,继承自StatefulWidget类。这,代表着它是一个有状态的Widget(Stateful Widget),而_MyHomePageState就是它的状态。 + +如果你足够细心的话就会发现,虽然MyHomePage类也是Widget,但与MyApp类不同的是,它并没有一个build方法去返回Widget,而是多了一个createState方法返回_MyHomePageState对象,而build方法则包含在这个_MyHomePageState类当中。 + +那么,StatefulWidget与StatelessWidget的接口设计,为什么会有这样的区别呢? + +这是因为Widget需要依据数据才能完成构建,而对于StatefulWidget来说,其依赖的数据在Widget生命周期中可能会频繁地发生变化。由State创建Widget,以数据驱动视图更新,而不是直接操作UI更新视觉属性,代码表达可以更精炼,逻辑也可以更清晰。 + +在了解了计数器示例程序的整体结构以后,我们再来看看这个示例代码的第二部分,也就是页面布局及交互逻辑部分。 + +class _MyHomePageState extends State { + int _counter = 0; + void _incrementCounter() => setState(() {_counter++;}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(Widget.title)), + body: Text('You have pushed the button this many times:$_counter')), + floatingActionButton: FloatingActionButton(onPressed: _incrementCounter) + ); + } + + +_MyHomePageState中创建的Widget Scaffold,是Material库中提供的页面布局结构,它包含AppBar、Body,以及FloatingActionButton。 + + +AppBar是页面的导航栏,我们直接将MyHomePage中的title属性作为标题使用。 +body则是一个Text组件,显示了一个根据_counter属性可变的文本:‘You have pushed the button this many times:$_counter’。 +floatingActionButton,则是页面右下角的带“+”的悬浮按钮。我们将_incrementCounter作为其点击处理函数。 + + +_incrementCounter的实现很简单,使用setState方法去自增状态属性_counter。setState方法是Flutter以数据驱动视图更新的关键函数,它会通知Flutter框架:我这儿有状态发生了改变,赶紧给我刷新界面吧。而Flutter框架收到通知后,会执行Widget的build方法,根据新的状态重新构建界面。 + +这里需要注意的是:状态的更改一定要配合使用setState。通过这个方法的调用,Flutter会在底层标记Widget的状态,随后触发重建。于我们的示例而言,即使你修改了_counter,如果不调用setState,Flutter框架也不会感知到状态的变化,因此界面上也不会有任何改变(你可以动手验证一下)。 + +下面的图3,就是整个计数器示例的代码流程示意图。通过这张图,你就能够把这个实例的整个代码流程串起来了: + + + +图3 代码流程示意图 + +MyApp为Flutter应用的运行实例,通过在main函数中调用runApp函数实现程序的入口。而应用的首页则为MyHomePage,一个拥有_MyHomePageState状态的StatefulWidget。_MyHomePageState通过调用build方法,以相应的数据配置完成了包括导航栏、文本及按钮的页面视图的创建。 + +而当按钮被点击之后,其关联的控件函数_incrementCounter会触发调用。在这个函数中,通过调用setState方法,更新_counter属性的同时,也会通知Flutter框架其状态发生变化。随后,Flutter会重新调用build方法,以新的数据配置重新构建_MyHomePageState的UI,最终完成页面的重新渲染。 + +Widget只是视图的“配置信息”,是数据的映射,是“只读”的。对于StatefulWidget而言,当数据改变的时候,我们需要重新创建Widget去更新界面,这也就意味着Widget的创建销毁会非常频繁。 + +为此,Flutter对这个机制做了优化,其框架内部会通过一个中间层去收敛上层UI配置对底层真实渲染的改动,从而最大程度降低对真实渲染视图的修改,提高渲染效率,而不是上层UI配置变了就需要销毁整个渲染视图树重建。 + +这样一来,Widget仅是一个轻量级的数据配置存储结构,它的重新创建速度非常快,所以我们可以放心地重新构建任何需要更新的视图,而无需分别修改各个子Widget的特定样式。关于Widget具体的渲染过程细节,我会在后续的第9篇文章“Widget,构建Flutter界面的基石”中向你详细介绍,在这里就不再展开了。 + +总结 + +今天的这次Flutter项目初体验,我们就先进行到这里。接下来,我们一起回顾下涉及到的知识点。 + +首先,我们通过Flutter标准模板创建了计数器示例,并分析了Flutter的项目结构,以及Flutter工程与原生Android、iOS工程的联系,知道了Flutter代码是怎么运行在原生系统上的。 + +然后,我带你学习了示例项目代码,了解了Flutter应用结构及页面结构,并认识了构建Flutter的基础,也就是Widget,以及状态管理机制,知道了Flutter页面是如何构建的,StatelessWidget与StatefulWidget的区别,以及如何通过State的成员函数setState以数据驱动的方式更新状态,从而更新页面。 + +有原生Android和iOS框架开发经验的同学,可能更习惯命令式的UI编程风格:手动创建UI组件,在需要更改UI时调用其方法修改视觉属性。而Flutter采用声明式UI设计,我们只需要描述当前的UI状态(即State)即可,不同UI状态的视觉变更由Flutter在底层完成。 + +虽然命令式的UI编程风格更直观,但声明式UI编程方式的好处是,可以让我们把复杂的视图操作细节交给框架去完成,这样一来不仅可以提高我们的效率,也可以让我们专注于整个应用和页面的结构和功能。 + +所以在这里,我非常希望你能够适应这样的UI编程思维方式的转换。 + +思考题 + +最后,我给你留下一个思考题吧。 + +示例项目代码在_MyHomePageState类中,直接在build函数里以内联的方式完成了Scaffold页面元素的构建,这样做的好处是什么呢? + +在实现同样功能的情况下,如果将Scaffold页面元素的构建封装成一个新Widget类,我们该如何处理? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/06基础语法与类型变量:Dart是如何表示信息的?.md b/专栏/Flutter核心技术与实战/06基础语法与类型变量:Dart是如何表示信息的?.md new file mode 100644 index 0000000..4b47c74 --- /dev/null +++ b/专栏/Flutter核心技术与实战/06基础语法与类型变量:Dart是如何表示信息的?.md @@ -0,0 +1,192 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 基础语法与类型变量:Dart是如何表示信息的? + 你好,我是陈航。 + +在专栏的第2篇预习文章“Dart语言概览”中,我们简单地认识了Dart这门优秀的程序语言。那么,Dart与其他语言究竟有什么不同呢?在已有其他编程语言经验的基础上,我又如何快速上手呢? + +今天,我们就从编程语言中最重要的组成部分,也就是基础语法与类型变量出发,一起来学习Dart吧。 + +Dart初体验 + +为了简单地体验一下Dart,我们打开浏览器,直接在repl.it 新建一个main.dart文件就可以了(当然,你也可以在电脑安装Dart SDK,体验最新的语法)。 + +下面是一个基本的hello world示例,我声明了一个带int参数的函数,并通过字符串内嵌表达式的方式把这个参数打印出来: + +printInteger(int a) { + print('Hello world, this is $a.'); +} + +main() { + var number = 2019; + printInteger(number); +} + + +然后,在编辑器中点击“run”按钮,命令行就会输出: + +Hello world, this is 2019. + + +和绝大多数编译型语言一样,Dart要求以main函数作为执行的入口。 + +在知道了如何简单地运行Dart代码后,我们再来看一下Dart的基本变量类型。 + +Dart的变量与类型 + +在Dart中,我们可以用var或者具体的类型来声明一个变量。当使用var定义变量时,表示类型是交由编译器推断决定的,当然你也可以用静态类型去定义变量,更清楚地跟编译器表达你的意图,这样编辑器和编译器就能使用这些静态类型,向你提供代码补全或编译警告的提示了。 + +在默认情况下,未初始化的变量的值都是null,因此我们不用担心无法判定一个传递过来的、未定义变量到底是undefined,还是烫烫烫而写一堆冗长的判断语句了。 + +Dart是类型安全的语言,并且所有类型都是对象类型,都继承自顶层类型Object,因此一切变量的值都是类的实例(即对象),甚至数字、布尔值、函数和null也都是继承自Object的对象。 + +Dart内置了一些基本类型,如 num、bool、String、List和Map,在不引入其他库的情况下可以使用它们去声明变量。下面,我将逐一和你介绍。 + +num、bool与String + +作为编程语言中最常用的类型,num、bool、String这三种基本类型被我放到了一起来介绍。 + +Dart的数值类型num,只有两种子类:即64位int和符合IEEE 754标准的64位double。前者代表整数类型,而后者则是浮点数的抽象。在正常情况下,它们的精度与取值范围就足够满足我们的诉求了。 + +int x = 1; +int hex = 0xEEADBEEF; +double y = 1.1; +double exponents = 1.13e5; +int roundY = y.round(); + + +除了常见的基本运算符,比如+、-、*、/,以及位运算符外,你还能使用继承自num的 abs()、round()等方法,来实现求绝对值、取整的功能。 + +实际上,你打开官方文档或查看源码,就会发现这些常见的运算符也是继承自num: + + + +图1 num中的运算符 + +如果还有其他高级运算方法的需求num无法满足,你可以试用一下dart:math库。这个库提供了诸如三角函数、指数、对数、平方根等高级函数。 + +为了表示布尔值,Dart使用了一种名为bool的类型。在Dart里,只有两个对象具有bool类型:true和false,它们都是编译时常量。 + +Dart是类型安全的,因此我们不能使用if(nonbooleanValue) 或assert(nonbooleanValue)之类的在JavaScript可以正常工作的代码,而应该显式地检查值。 + +如下所示,检查变量是否为0,在Dart中需要显示地与0做比较: + +// 检查是否为0. +var number = 0; +assert(number == 0); +// assert(number); 错误 + + +Dart的String由UTF-16的字符串组成。和JavaScript一样,构造字符串字面量时既能使用单引号也能使用双引号,还能在字符串中嵌入变量或表达式:你可以使用 ${express} 把一个表达式的值放进字符串。而如果是一个标识符,你可以省略{}。 + +下面这段代码就是内嵌表达式的例子。我们把单词’cat’转成大写放入到变量s1的声明中: + +var s = 'cat'; +var s1 = 'this is a uppercased string: ${s.toUpperCase()}'; + + +为了获得内嵌对象的字符串,Dart会调用对象的toString()方法。而常见字符串的拼接,Dart则通过内置运算符“+”实现。比如,下面这条语句会如你所愿声明一个值为’Hello World!‘的字符串: + +var s2 = 'Hello' + ' ' + 'World!' ; + + +对于多行字符串的构建,你可以通过三个单引号或三个双引号的方式声明,这与Python是一致的: + +var s3 = """This is a +multi-line string."""; + + +List与Map + +其他编程语言中常见的数组和字典类型,在Dart中的对应实现是List和Map,统称为集合类型。它们的声明和使用很简单,和JavaScript中的用法类似。 + +接下来,我们一起看一段代码示例。 + + +在代码示例的前半部分,我们声明并初始化了两个List变量,在第二个变量中添加了一个新的元素后,调用其迭代方法依次打印出其内部元素; +在代码示例的后半部分,我们声明并初始化了两个Map变量,在第二个变量中添加了两个键值对后,同样调用其迭代方法依次打印出其内部元素。 + + +var arr1 = ["Tom", "Andy", "Jack"]; +var arr2 = List.of([1,2,3]); +arr2.add(499); +arr2.forEach((v) => print('${v}')); + +var map1 = {"name": "Tom", 'sex': 'male'}; +var map2 = new Map(); +map2['name'] = 'Tom'; +map2['sex'] = 'male'; +map2.forEach((k,v) => print('${k}: ${v}')); + + +容器里的元素也需要有类型,比如上述代码中arr2的类型是List,map2的类型则为Map。Dart会自动根据上下文进行类型推断,所以你后续往容器内添加的元素也必须遵照这一类型。 + +如果编译器自动推断的类型不符合预期,我们当然可以在声明时显式地把类型标记出来,不仅可以让代码提示更友好一些,更重要的是可以让静态分析器帮忙检查字面量中的错误,解除类型不匹配带来的安全隐患或是Bug。 + +以上述代码为例,如果往arr2集合中添加一个浮点数arr2.add(1.1),尽管语义上合法,但编译器会提示类型不匹配,从而导致编译失败。 + +和Java语言类似,在初始化集合实例对象时,你可以为它的类型添加约束,也可以用于后续判断集合类型。 + +下面的这段代码,在增加了类型约束后,语义是不是更清晰了? + +var arr1 = ['Tom', 'Andy', 'Jack']; +var arr2 = new List.of([1,2,3]); +arr2.add(499); +arr2.forEach((v) => print('${v}')); +print(arr2 is List); // true + +var map1 = {'name': 'Tom','sex': 'male',}; +var map2 = new Map(); +map2['name'] = 'Tom'; +map2['sex'] = 'male'; +map2.forEach((k,v) => print('${k}: ${v}')); +print(map2 is Map); // true + + +常量定义 + +如果你想定义不可变的变量,则需要在定义变量前加上final或const关键字: + + +const,表示变量在编译期间即能确定的值; +final则不太一样,用它定义的变量可以在运行时确定值,而一旦确定后就不可再变。 + + +声明const常量与final常量的典型例子,如下所示: + +final name = 'Andy'; +const count = 3; + +var x = 70; +var y = 30; +final z = x / y; + + +可以看到,const适用于定义编译常量(字面量固定值)的场景,而final适用于定义运行时常量的场景。 + +总结 + +通过上面的介绍,相信你已经对Dart的基本语法和类型系统有了一个初步的印象。这些初步的印象,有助于你理解Dart语言设计的基本思路,在已有编程语言经验的基础上快速上手。 + +而对于流程控制语法:如if-else、for、while、do-while、break/continue、switch-case、assert,由于与其他编程语言类似,在这里我就不做一一介绍了,更多的Dart语言特性需要你在后续的使用过程中慢慢学习。在我们使用Dart的过程中,官方文档是我们最重要的学习参考资料。 + +恭喜你!你现在已经迈出了Dart语言学习的第一步。接下来,我们简单回顾一下今天的内容,以便加深记忆与理解: + + +在Dart中,所有类型都是对象类型,都继承自顶层类型Object,因此一切变量都是对象,数字、布尔值、函数和null也概莫能外; +未初始化变量的值都是null; +为变量指定类型,这样编辑器和编译器都能更好地理解你的意图。 + + +思考题 + +对于集合类型List和Map,如何让其内部元素支持多种类型(比如,int、double)呢?又如何在遍历集合时,判断究竟是何种类型呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/07函数、类与运算符:Dart是如何处理信息的?.md b/专栏/Flutter核心技术与实战/07函数、类与运算符:Dart是如何处理信息的?.md new file mode 100644 index 0000000..97263da --- /dev/null +++ b/专栏/Flutter核心技术与实战/07函数、类与运算符:Dart是如何处理信息的?.md @@ -0,0 +1,294 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 函数、类与运算符:Dart是如何处理信息的? + 你好,我是陈航。 + +在上一篇文章中,我通过一个基本hello word的示例,带你体验了Dart的基础语法与类型变量,并与其他编程语言的特性进行对比,希望可以帮助你快速建立起对Dart的初步印象。 + +其实,编程语言虽然千差万别,但归根结底,它们的设计思想无非就是回答两个问题: + + +如何表示信息; +如何处理信息。 + + +在上一篇文章中,我们已经解决了Dart如何表示信息的问题,今天这篇文章我就着重和你分享它是如何处理信息的。 + +作为一门真正面向对象的编程语言,Dart将处理信息的过程抽象为了对象,以结构化的方式将功能分解,而函数、类与运算符就是抽象中最重要的手段。 + +接下来,我就从函数、类与运算符的角度,来进一步和你讲述Dart面向对象设计的基本思路。 + +函数 + +函数是一段用来独立地完成某个功能的代码。我在上一篇文章中和你提到,在Dart中,所有类型都是对象类型,函数也是对象,它的类型叫作Function。这意味着函数也可以被定义为变量,甚至可以被定义为参数传递给另一个函数。 + +在下面这段代码示例中,我定义了一个判断整数是否为0的isZero函数,并把它传递了给另一个printInfo函数,完成格式化打印出判断结果的功能。 + +bool isZero(int number) { //判断整数是否为0 + return number == 0; +} + +void printInfo(int number,Function check) { //用check函数来判断整数是否为0 + print("$number is Zero: ${check(number)}"); +} + +Function f = isZero; +int x = 10; +int y = 0; +printInfo(x,f); // 输出 10 is Zero: false +printInfo(y,f); // 输出 0 is Zero: true + + +如果函数体只有一行表达式,就比如上面示例中的isZero和printInfo函数,我们还可以像JavaScript语言那样用箭头函数来简化这个函数: + +bool isZero(int number) => number == 0; + +void printInfo(int number,Function check) => print("$number is Zero: ${check(number)}"); + + +有时,一个函数中可能需要传递多个参数。那么,如何让这类函数的参数声明变得更加优雅、可维护,同时降低调用者的使用成本呢? + +C++与Java的做法是,提供函数的重载,即提供同名但参数不同的函数。但Dart认为重载会导致混乱,因此从设计之初就不支持重载,而是提供了可选命名参数和可选参数。 + +具体方式是,在声明函数时: + + +给参数增加{},以paramName: value的方式指定调用参数,也就是可选命名参数; +给参数增加[],则意味着这些参数是可以忽略的,也就是可选参数。 + + +在使用这两种方式定义函数时,我们还可以在参数未传递时设置默认值。我以一个只有两个参数的简单函数为例,来和你说明这两种方式的具体用法: + +//要达到可选命名参数的用法,那就在定义函数的时候给参数加上 {} +void enable1Flags({bool bold, bool hidden}) => print("$bold , $hidden"); + +//定义可选命名参数时增加默认值 +void enable2Flags({bool bold = true, bool hidden = false}) => print("$bold ,$hidden"); + +//可忽略的参数在函数定义时用[]符号指定 +void enable3Flags(bool bold, [bool hidden]) => print("$bold ,$hidden"); + +//定义可忽略参数时增加默认值 +void enable4Flags(bool bold, [bool hidden = false]) => print("$bold ,$hidden"); + +//可选命名参数函数调用 +enable1Flags(bold: true, hidden: false); //true, false +enable1Flags(bold: true); //true, null +enable2Flags(bold: false); //false, false + +//可忽略参数函数调用 +enable3Flags(true, false); //true, false +enable3Flags(true,); //true, null +enable4Flags(true); //true, false +enable4Flags(true,true); // true, true + + +这里我要和你强调的是,在Flutter中会大量用到可选命名参数的方式,你一定要记住它的用法。 + +类 + +类是特定类型的数据和方法的集合,也是创建对象的模板。与其他语言一样,Dart为类概念提供了内置支持。 + +类的定义及初始化 + +Dart是面向对象的语言,每个对象都是一个类的实例,都继承自顶层类型Object。在Dart中,实例变量与实例方法、类变量与类方法的声明与Java类似,我就不再过多展开了。 + +值得一提的是,Dart中并没有public、protected、private这些关键字,我们只要在声明变量与方法时,在前面加上“_”即可作为private方法使用。如果不加“_”,则默认为public。不过,“_”的限制范围并不是类访问级别的,而是库访问级别。 + +接下来,我们以一个具体的案例看看Dart是如何定义和使用类的。 + +我在Point类中,定义了两个成员变量x和y,通过构造函数语法糖进行初始化,成员函数printInfo的作用是打印它们的信息;而类变量factor,则在声明时就已经赋好了默认值0,类函数printZValue会打印出它的信息。 + +class Point { + num x, y; + static num factor = 0; + //语法糖,等同于在函数体内:this.x = x;this.y = y; + Point(this.x,this.y); + void printInfo() => print('($x, $y)'); + static void printZValue() => print('$factor'); +} + +var p = new Point(100,200); // new 关键字可以省略 +p.printInfo(); // 输出(100, 200); +Point.factor = 10; +Point.printZValue(); // 输出10 + + +有时候类的实例化需要根据参数提供多种初始化方式。除了可选命名参数和可选参数之外,Dart还提供了命名构造函数的方式,使得类的实例化过程语义更清晰。 + +此外,与C++类似,Dart支持初始化列表。在构造函数的函数体真正执行之前,你还有机会给实例变量赋值,甚至重定向至另一个构造函数。 + +如下面实例所示,Point类中有两个构造函数Point.bottom与Point,其中:Point.bottom将其成员变量的初始化重定向到了Point中,而Point则在初始化列表中为z赋上了默认值0。 + +class Point { + num x, y, z; + Point(this.x, this.y) : z = 0; // 初始化变量z + Point.bottom(num x) : this(x, 0); // 重定向构造函数 + void printInfo() => print('($x,$y,$z)'); +} + +var p = Point.bottom(100); +p.printInfo(); // 输出(100,0,0) + + +复用 + +在面向对象的编程语言中,将其他类的变量与方法纳入本类中进行复用的方式一般有两种:继承父类和接口实现。当然,在Dart也不例外。 + +在Dart中,你可以对同一个父类进行继承或接口实现: + + +继承父类意味着,子类由父类派生,会自动获取父类的成员变量和方法实现,子类可以根据需要覆写构造函数及父类方法; +接口实现则意味着,子类获取到的仅仅是接口的成员变量符号和方法符号,需要重新实现成员变量,以及方法的声明和初始化,否则编译器会报错。 + + +接下来,我以一个例子和你说明在Dart中继承和接口的差别。 + +Vector通过继承Point的方式增加了成员变量,并覆写了printInfo的实现;而Coordinate,则通过接口实现的方式,覆写了Point的变量定义及函数实现: + +class Point { + num x = 0, y = 0; + void printInfo() => print('($x,$y)'); +} + +//Vector继承自Point +class Vector extends Point{ + num z = 0; + @override + void printInfo() => print('($x,$y,$z)'); //覆写了printInfo实现 +} + +//Coordinate是对Point的接口实现 +class Coordinate implements Point { + num x = 0, y = 0; //成员变量需要重新声明 + void printInfo() => print('($x,$y)'); //成员函数需要重新声明实现 +} + +var xxx = Vector(); +xxx + ..x = 1 + ..y = 2 + ..z = 3; //级联运算符,等同于xxx.x=1; xxx.y=2;xxx.z=3; +xxx.printInfo(); //输出(1,2,3) + +var yyy = Coordinate(); +yyy + ..x = 1 + ..y = 2; //级联运算符,等同于yyy.x=1; yyy.y=2; +yyy.printInfo(); //输出(1,2) +print (yyy is Point); //true +print(yyy is Coordinate); //true + + +可以看出,子类Coordinate采用接口实现的方式,仅仅是获取到了父类Point的一个“空壳子”,只能从语义层面当成接口Point来用,但并不能复用Point的原有实现。那么,我们是否能够找到方法去复用Point的对应方法实现呢? + +也许你很快就想到了,我可以让Coordinate继承Point,来复用其对应的方法。但,如果Coordinate还有其他的父类,我们又该如何处理呢? + +其实,除了继承和接口实现之外,Dart还提供了另一种机制来实现类的复用,即“混入”(Mixin)。混入鼓励代码重用,可以被视为具有实现方法的接口。这样一来,不仅可以解决Dart缺少对多重继承的支持问题,还能够避免由于多重继承可能导致的歧义(菱形问题)。 + + +备注:继承歧义,也叫菱形问题,是支持多继承的编程语言中一个相当棘手的问题。当B类和C类继承自A类,而D类继承自B类和C类时会产生歧义。如果A中有一个方法在B和C中已经覆写,而D没有覆写它,那么D继承的方法的版本是B类,还是C类的呢? + + +要使用混入,只需要with关键字即可。我们来试着改造Coordinate的实现,把类中的变量声明和函数实现全部删掉: + +class Coordinate with Point { +} + +var yyy = Coordinate(); +print (yyy is Point); //true +print(yyy is Coordinate); //true + + +可以看到,通过混入,一个类里可以以非继承的方式使用其他类中的变量与方法,效果正如你想象的那样。 + +运算符 + +Dart和绝大部分编程语言的运算符一样,所以你可以用熟悉的方式去执行程序代码运算。不过,Dart多了几个额外的运算符,用于简化处理变量实例缺失(即null)的情况。 + + +?.运算符:假设Point类有printInfo()方法,p是Point的一个可能为null的实例。那么,p调用成员方法的安全代码,可以简化为p?.printInfo() ,表示p为null的时候跳过,避免抛出异常。 +??= 运算符:如果a为null,则给a赋值value,否则跳过。这种用默认值兜底的赋值语句在Dart中我们可以用a ??= value表示。 +??运算符:如果a不为null,返回a的值,否则返回b。在Java或者C++中,我们需要通过三元表达式(a != null)? a : b来实现这种情况。而在Dart中,这类代码可以简化为a ?? b。 + + +在Dart中,一切都是对象,就连运算符也是对象成员函数的一部分。 + +对于系统的运算符,一般情况下只支持基本数据类型和标准库中提供的类型。而对于用户自定义的类,如果想支持基本操作,比如比较大小、相加相减等,则需要用户自己来定义关于这个运算符的具体实现。 + +Dart提供了类似C++的运算符覆写机制,使得我们不仅可以覆写方法,还可以覆写或者自定义运算符。 + +接下来,我们一起看一个Vector类中自定义“+”运算符和覆写”==“运算符的例子: + +class Vector { + num x, y; + Vector(this.x, this.y); + // 自定义相加运算符,实现向量相加 + Vector operator +(Vector v) => Vector(x + v.x, y + v.y); + // 覆写相等运算符,判断向量相等 + bool operator == (dynamic v) => x == v.x && y == v.y; +} + +final x = Vector(3, 3); +final y = Vector(2, 2); +final z = Vector(1, 1); +print(x == (y + z)); // 输出true + + + +operator是Dart的关键字,与运算符一起使用,表示一个类成员运算符函数。在理解时,我们应该把operator和运算符作为整体,看作是一个成员函数名。 + +总结 + +函数、类与运算符是Dart处理信息的抽象手段。从今天的学习中你可以发现,Dart面向对象的设计吸纳了其他编程语言的优点,表达和处理信息的方式既简单又简洁,但又不失强大。 + +通过这两篇文章的内容,相信你已经了解了Dart的基本设计思路,熟悉了在Flutter开发中常用的语法特性,也已经具备了快速上手实践的能力。 + +接下来,我们简单回顾一下今天的内容,以便加深记忆与理解。 + +首先,我们认识了函数。函数也是对象,可以被定义为变量,或者参数。Dart不支持函数重载,但提供了可选命名参数和可选参数的方式,从而解决了函数声明时需要传递多个参数的可维护性。 + +然后,我带你学习了类。类提供了数据和函数的抽象复用能力,可以通过继承(父类继承,接口实现)和非继承(Mixin)方式实现复用。在类的内部,关于成员变量,Dart提供了包括命名构造函数和初始化列表在内的两种初始化方式。 + +最后,需要注意的是,运算符也是对象成员函数的一部分,可以覆写或者自定义。 + +思考题 + +最后,请你思考以下两个问题。 + + +你是怎样理解父类继承,接口实现和混入的?我们应该在什么场景下使用它们? +在父类继承的场景中,父类子类之间的构造函数执行顺序是怎样的?如果父类有多个构造函数,子类也有多个构造函数,如何从代码层面确保父类子类之间构造函数的正确调用? + + +class Point { + num x, y; + Point() : this.make(0,0); + Point.left(x) : this.make(x,0); + Point.right(y) : this.make(0,y); + Point.make(this.x, this.y); + void printInfo() => print('($x,$y)'); +} + +class Vector extends Point{ + num z = 0; +/*5个构造函数 + Vector + Vector.left; + Vector.middle + Vector.right + Vector.make +*/ + @override + void printInfo() => print('($x,$y,$z)'); //覆写了printInfo实现 +} + + +欢迎将你的答案留言告诉我,我们一起讨论。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/08综合案例:掌握Dart核心特性.md b/专栏/Flutter核心技术与实战/08综合案例:掌握Dart核心特性.md new file mode 100644 index 0000000..abf476a --- /dev/null +++ b/专栏/Flutter核心技术与实战/08综合案例:掌握Dart核心特性.md @@ -0,0 +1,374 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 综合案例:掌握Dart核心特性 + 你好,我是陈航。 + +在前两篇文章中,我首先与你一起学习了Dart程序的基本结构和语法,认识了Dart语言世界的基本构成要素,也就是类型系统,以及它们是怎么表示信息的。然后,我带你学习了Dart面向对象设计的基本思路,知道了函数、类与运算符这些其他编程语言中常见的概念,在Dart中的差异及典型用法,理解了Dart是怎么处理信息的。 + +可以看到,Dart吸纳了其他编程语言的优点,在关于如何表达以及处理信息上,既简单又简洁,而且又不失强大。俗话说,纸上得来终觉浅,绝知此事要躬行。那么今天,我就用一个综合案例,把前面学习的关于Dart的零散知识串起来,希望你可以动手试验一下这个案例,借此掌握如何用Dart编程。 + +有了前面学习的知识点,再加上今天的综合案例练习,我认为你已经掌握了Dart最常用的80%的特性,可以在基本没有语言障碍的情况下去使用Flutter了。至于剩下的那20%的特性,因为使用较少,所以我不会在本专栏做重点讲解。如果你对这部分内容感兴趣的话,可以访问官方文档去做进一步了解。 + +此外,关于Dart中的异步和并发,我会在后面的第23篇文章“单线程模型怎么保证UI运行流畅?”中进行深入介绍。 + +案例介绍 + +今天,我选择的案例是,先用Dart写一段购物车程序,但先不使用Dart独有的特性。然后,我们再以这段程序为起点,逐步加入Dart语言特性,将其改造为一个符合Dart设计思想的程序。你可以在这个改造过程中,进一步体会到Dart的魅力所在。 + +首先,我们来看看在不使用任何Dart语法特性的情况下,一个有着基本功能的购物车程序长什么样子。 + +//定义商品Item类 +class Item { + double price; + String name; + Item(name, price) { + this.name = name; + this.price = price; + } +} + +//定义购物车类 +class ShoppingCart { + String name; + DateTime date; + String code; + List bookings; + + price() { + double sum = 0.0; + for(var i in bookings) { + sum += i.price; + } + return sum; + } + + ShoppingCart(name, code) { + this.name = name; + this.code = code; + this.date = DateTime.now(); + } + + getInfo() { + return '购物车信息:' + + '\n-----------------------------' + + '\n用户名: ' + name+ + '\n优惠码: ' + code + + '\n总价: ' + price().toString() + + '\n日期: ' + date.toString() + + '\n-----------------------------'; + } +} + +void main() { + ShoppingCart sc = ShoppingCart('张三', '123456'); + sc.bookings = [Item('苹果',10.0), Item('鸭梨',20.0)]; + print(sc.getInfo()); +} + + +在这段程序中,我定义了商品Item类,以及购物车ShoppingCart类。它们分别包含了一个初始化构造方法,将main函数传入的参数信息赋值给对象内部属性。而购物车的基本信息,则通过ShoppingCart类中的getInfo方法输出。在这个方法中,我采用字符串拼接的方式,将各类信息进行格式化组合后,返回给调用者。 + +运行这段程序,不出意外,购物车对象sc包括的用户名、优惠码、总价与日期在内的基本信息都会被打印到命令行中。 + +购物车信息: +----------------------------- +用户名: 张三 +优惠码: 123456 +总价: 30.0 +日期: 2019-06-01 17:17:57.004645 +----------------------------- + + +这段程序的功能非常简单:我们初始化了一个购物车对象,然后给购物车对象进行加购操作,最后打印出基本信息。可以看到,在不使用Dart语法任何特性的情况下,这段代码与Java、C++甚至JavaScript没有明显的语法差异。 + +在关于如何表达以及处理信息上,Dart保持了既简单又简洁的风格。那接下来,我们就先从表达信息入手,看看Dart是如何优化这段代码的。 + +类抽象改造 + +我们先来看看Item类与ShoppingCart类的初始化部分。它们在构造函数中的初始化工作,仅仅是将main函数传入的参数进行属性赋值。 + +在其他编程语言中,在构造函数的函数体内,将初始化参数赋值给实例变量的方式非常常见。而在Dart里,我们可以利用语法糖以及初始化列表,来简化这样的赋值过程,从而直接省去构造函数的函数体: + +class Item { + double price; + String name; + Item(this.name, this.price); +} + +class ShoppingCart { + String name; + DateTime date; + String code; + List bookings; + price() {...} + //删掉了构造函数函数体 + ShoppingCart(this.name, this.code) : date = DateTime.now(); +... +} + + +这一下就省去了7行代码!通过这次改造,我们有两个新的发现: + + +首先,Item类与ShoppingCart类中都有一个name属性,在Item中表示商品名称,在ShoppingCart中则表示用户名; +然后,Item类中有一个price属性,ShoppingCart中有一个price方法,它们都表示当前的价格。 + + +考虑到name属性与price属性(方法)的名称与类型完全一致,在信息表达上的作用也几乎一致,因此我可以在这两个类的基础上,再抽象出一个新的基类Meta,用于存放price属性与name属性。 + +同时,考虑到在ShoppingCart类中,price属性仅用做计算购物车中商品的价格(而不是像Item类那样用于数据存取),因此在继承了Meta类后,我改写了ShoppingCart类中price属性的get方法: + +class Meta { + double price; + String name; + Meta(this.name, this.price); +} +class Item extends Meta{ + Item(name, price) : super(name, price); +} + +class ShoppingCart extends Meta{ + DateTime date; + String code; + List bookings; + + double get price {...} + ShoppingCart(name, this.code) : date = DateTime.now(),super(name,0); + getInfo() {...} +} + + +通过这次类抽象改造,程序中各个类的依赖关系变得更加清晰了。不过,目前这段程序中还有两个冗长的方法显得格格不入,即ShoppingCart类中计算价格的price属性get方法,以及提供购物车基本信息的getInfo方法。接下来,我们分别来改造这两个方法。 + +方法改造 + +我们先看看price属性的get方法: + +double get price { + double sum = 0.0; + for(var i in bookings) { + sum += i.price; + } + return sum; +} + + +在这个方法里,我采用了其他语言常见的求和算法,依次遍历bookings列表中的Item对象,累积相加求和。 + +而在Dart中,这样的求和运算我们只需重载Item类的“+”运算符,并通过对列表对象进行归纳合并操作即可实现(你可以想象成,把购物车中的所有商品都合并成了一个商品套餐对象)。 + +另外,由于函数体只有一行,所以我们可以使用Dart的箭头函数来进一步简化实现函数: + +class Item extends Meta{ + ... + //重载了+运算符,合并商品为套餐商品 + Item operator+(Item item) => Item(name + item.name, price + item.price); +} + +class ShoppingCart extends Meta{ + ... + //把迭代求和改写为归纳合并 + double get price => bookings.reduce((value, element) => value + element).price; + ... + getInfo() {...} +} + + +可以看到,这段代码又简洁了很多!接下来,我们再看看getInfo方法如何优化。 + +在getInfo方法中,我们将ShoppingCart类的基本信息通过字符串拼接的方式,进行格式化组合,这在其他编程语言中非常常见。而在Dart中,我们可以通过对字符串插入变量或表达式,并使用多行字符串声明的方式,来完全抛弃不优雅的字符串拼接,实现字符串格式化组合。 + +getInfo () => ''' +购物车信息: +----------------------------- + 用户名: $name + 优惠码: $code + 总价: $price + Date: $date +----------------------------- +'''; + + +在去掉了多余的字符串转义和拼接代码后,getInfo方法看着就清晰多了。 + +在优化完了ShoppingCart类与Item类的内部实现后,我们再来看看main函数,从调用方的角度去分析程序还能在哪些方面做优化。 + +对象初始化方式的优化 + +在main函数中,我们使用 + +ShoppingCart sc = ShoppingCart('张三', '123456') ; + + +初始化了一个使用‘123456’优惠码、名为‘张三’的用户所使用的购物车对象。而这段初始化方法的调用,我们可以从两个方面优化: + + +首先,在对ShoppingCart的构造函数进行了大量简写后,我们希望能够提供给调用者更明确的初始化方法调用方式,让调用者以“参数名:参数键值对”的方式指定调用参数,让调用者明确传递的初始化参数的意义。在Dart中,这样的需求,我们在声明函数时,可以通过给参数增加{}实现。 +其次,对一个购物车对象来说,一定会有一个有用户名,但不一定有优惠码的用户。因此,对于购物车对象的初始化,我们还需要提供一个不含优惠码的初始化方法,并且需要确定多个初始化方法与父类的初始化方法之间的正确调用顺序。 + + +按照这样的思路,我们开始对ShoppingCart进行改造。 + +需要注意的是,由于优惠码可以为空,我们还需要对getInfo方法进行兼容处理。在这里,我用到了a??b运算符,这个运算符能够大量简化在其他语言中三元表达式(a != null)? a : b的写法: + +class ShoppingCart extends Meta{ + ... + //默认初始化方法,转发到withCode里 + ShoppingCart({name}) : this.withCode(name:name, code:null); + //withCode初始化方法,使用语法糖和初始化列表进行赋值,并调用父类初始化方法 + ShoppingCart.withCode({name, this.code}) : date = DateTime.now(), super(name,0); + + //??运算符表示为code不为null,则用原值,否则使用默认值"没有" + getInfo () => ''' +购物车信息: +----------------------------- + 用户名: $name + 优惠码: ${code??"没有"} + 总价: $price + Date: $date +----------------------------- +'''; +} + +void main() { + ShoppingCart sc = ShoppingCart.withCode(name:'张三', code:'123456'); + sc.bookings = [Item('苹果',10.0), Item('鸭梨',20.0)]; + print(sc.getInfo()); + + ShoppingCart sc2 = ShoppingCart(name:'李四'); + sc2.bookings = [Item('香蕉',15.0), Item('西瓜',40.0)]; + print(sc2.getInfo()); +} + + +运行这段程序,张三和李四的购物车信息都会被打印到命令行中: + +购物车信息: +----------------------------- + 用户名: 张三 + 优惠码: 123456 + 总价: 30.0 + Date: 2019-06-01 19:59:30.443817 +----------------------------- + +购物车信息: +----------------------------- + 用户名: 李四 + 优惠码: 没有 + 总价: 55.0 + Date: 2019-06-01 19:59:30.451747 +----------------------------- + + +关于购物车信息的打印,我们是通过在main函数中获取到购物车对象的信息后,使用全局的print函数打印的,我们希望把打印信息的行为封装到ShoppingCart类中。而对于打印信息的行为而言,这是一个非常通用的功能,不止ShoppingCart类需要,Item对象也可能需要。 + +因此,我们需要把打印信息的能力单独封装成一个单独的类PrintHelper。但,ShoppingCart类本身已经继承自Meta类,考虑到Dart并不支持多继承,我们怎样才能实现PrintHelper类的复用呢? + +这就用到了我在上一篇文章中提到的“混入”(Mixin),相信你还记得只要在使用时加上with关键字即可。 + +我们来试着增加PrintHelper类,并调整ShoppingCart的声明: + +abstract class PrintHelper { + printInfo() => print(getInfo()); + getInfo(); +} + +class ShoppingCart extends Meta with PrintHelper{ +... +} + + +经过Mixin的改造,我们终于把所有购物车的行为都封装到ShoppingCart内部了。而对于调用方而言,还可以使用级联运算符“..”,在同一个对象上连续调用多个函数以及访问成员变量。使用级联操作符可以避免创建临时变量,让代码看起来更流畅: + +void main() { + ShoppingCart.withCode(name:'张三', code:'123456') + ..bookings = [Item('苹果',10.0), Item('鸭梨',20.0)] + ..printInfo(); + + ShoppingCart(name:'李四') + ..bookings = [Item('香蕉',15.0), Item('西瓜',40.0)] + ..printInfo(); +} + + +很好!通过Dart独有的语法特性,我们终于把这段购物车代码改造成了简洁、直接而又强大的Dart风格程序。 + +总结 + +这就是今天分享的全部内容了。在今天,我们以一个与Java、C++甚至JavaScript没有明显语法差异的购物车雏形为起步,逐步将它改造成了一个符合Dart设计思想的程序。 + +首先,我们使用构造函数语法糖及初始化列表,简化了成员变量的赋值过程。然后,我们重载了“+”运算符,并采用归纳合并的方式实现了价格计算,并且使用多行字符串和内嵌表达式的方式,省去了无谓的字符串拼接。最后,我们重新梳理了类之间的继承关系,通过mixin、多构造函数,可选命名参数等手段,优化了对象初始化调用方式。 + +下面是今天购物车综合案例的完整代码,希望你在IDE中多多练习,体会这次的改造过程,从而对Dart那些使代码变得更简洁、直接而强大的关键语法特性产生更深刻的印象。同时,改造前后的代码,你也可以在GitHub的Dart_Sample中找到: + +class Meta { + double price; + String name; + //成员变量初始化语法糖 + Meta(this.name, this.price); +} + +class Item extends Meta{ + Item(name, price) : super(name, price); + //重载+运算符,将商品对象合并为套餐商品 + Item operator+(Item item) => Item(name + item.name, price + item.price); +} + +abstract class PrintHelper { + printInfo() => print(getInfo()); + getInfo(); +} + +//with表示以非继承的方式复用了另一个类的成员变量及函数 +class ShoppingCart extends Meta with PrintHelper{ + DateTime date; + String code; + List bookings; + //以归纳合并方式求和 + double get price => bookings.reduce((value, element) => value + element).price; + //默认初始化函数,转发至withCode函数 + ShoppingCart({name}) : this.withCode(name:name, code:null); + //withCode初始化方法,使用语法糖和初始化列表进行赋值,并调用父类初始化方法 + ShoppingCart.withCode({name, this.code}) : date = DateTime.now(), super(name,0); + + //??运算符表示为code不为null,则用原值,否则使用默认值"没有" + @override + getInfo() => ''' +购物车信息: +----------------------------- + 用户名: $name + 优惠码: ${code??"没有"} + 总价: $price + Date: $date +----------------------------- +'''; +} + +void main() { + ShoppingCart.withCode(name:'张三', code:'123456') + ..bookings = [Item('苹果',10.0), Item('鸭梨',20.0)] + ..printInfo(); + + ShoppingCart(name:'李四') + ..bookings = [Item('香蕉',15.0), Item('西瓜',40.0)] + ..printInfo(); +} + + +思考题 + +请你扩展购物车程序的实现,使得我们的购物车可以支持: + + +商品数量属性; +购物车信息增加商品列表信息(包括商品名称,数量及单价)输出,实现小票的基本功能。 + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/09Widget,构建Flutter界面的基石.md b/专栏/Flutter核心技术与实战/09Widget,构建Flutter界面的基石.md new file mode 100644 index 0000000..d4b9dfc --- /dev/null +++ b/专栏/Flutter核心技术与实战/09Widget,构建Flutter界面的基石.md @@ -0,0 +1,175 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 Widget,构建Flutter界面的基石 + 你好,我是陈航。 + +在前面的Flutter开发起步和Dart基础模块中,我和你一起学习了Flutter框架的整体架构与基本原理,分析了Flutter的项目结构和运行机制,并从Flutter开发角度介绍了Dart语言的基本设计思路,也通过和其他高级语言的类比深入认识了Dart的语法特性。 + +这些内容,是我们接下来系统学习构建Flutter应用的基础,可以帮助我们更好地掌握Flutter的核心概念和技术。 + +在第4篇文章“Flutter区别于其他方案的关键技术是什么?”中,我和你分享了一张来自Flutter官方的架构图,不难看出Widget是整个视图描述的基础。这张架构图很重要,所以我在这里又放了一次。 + + + +图1 Flutter架构图 + +备注:此图引自Flutter System Overview + +那么,Widget到底是什么呢? + +Widget是Flutter功能的抽象描述,是视图的配置信息,同样也是数据的映射,是Flutter开发框架中最基本的概念。前端框架中常见的名词,比如视图(View)、视图控制器(View Controller)、活动(Activity)、应用(Application)、布局(Layout)等,在Flutter中都是Widget。 + +事实上,Flutter的核心设计思想便是“一切皆Widget”。所以,我们学习Flutter,首先得从学会使用Widget开始。 + +那么,在今天的这篇文章中,我会带着你一起学习Widget在Flutter中的设计思路和基本原理,以帮助你深入理解Flutter的视图构建过程。 + +Widget渲染过程 + +在进行App开发时,我们往往会关注的一个问题是:如何结构化地组织视图数据,提供给渲染引擎,最终完成界面显示。 + +通常情况下,不同的UI框架中会以不同的方式去处理这一问题,但无一例外地都会用到视图树(View Tree)的概念。而Flutter将视图树的概念进行了扩展,把视图数据的组织和渲染抽象为三部分,即Widget,Element和 RenderObject。 + +这三部分之间的关系,如下所示: + + + +图2 Widget,Element与RenderObject + +Widget + +Widget是Flutter世界里对视图的一种结构化描述,你可以把它看作是前端中的“控件”或“组件”。Widget是控件实现的基本逻辑单位,里面存储的是有关视图渲染的配置信息,包括布局、渲染属性、事件响应信息等。 + +在页面渲染上,Flutter将“Simple is best”这一理念做到了极致。为什么这么说呢?Flutter将Widget设计成不可变的,所以当视图渲染的配置信息发生变化时,Flutter会选择重建Widget树的方式进行数据更新,以数据驱动UI构建的方式简单高效。 + +但,这样做的缺点是,因为涉及到大量对象的销毁和重建,所以会对垃圾回收造成压力。不过,Widget本身并不涉及实际渲染位图,所以它只是一份轻量级的数据结构,重建的成本很低。 + +另外,由于Widget的不可变性,可以以较低成本进行渲染节点复用,因此在一个真实的渲染树中可能存在不同的Widget对应同一个渲染节点的情况,这无疑又降低了重建UI的成本。 + +Element + +Element是Widget的一个实例化对象,它承载了视图构建的上下文数据,是连接结构化的配置信息到完成最终渲染的桥梁。 + +Flutter渲染过程,可以分为这么三步: + + +首先,通过Widget树生成对应的Element树; +然后,创建相应的RenderObject并关联到Element.renderObject属性上; +最后,构建成RenderObject树,以完成最终的渲染。 + + +可以看到,Element同时持有Widget和RenderObject。而无论是Widget还是Element,其实都不负责最后的渲染,只负责发号施令,真正去干活儿的只有RenderObject。那你可能会问,既然都是发号施令,那为什么需要增加中间的这层Element树呢?直接由Widget命令RenderObject去干活儿不好吗? + +答案是,可以,但这样做会极大地增加渲染带来的性能损耗。 + +因为Widget具有不可变性,但Element却是可变的。实际上,Element树这一层将Widget树的变化(类似React 虚拟DOM diff)做了抽象,可以只将真正需要修改的部分同步到真实的RenderObject树中,最大程度降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个渲染视图树重建。 + +这,就是Element树存在的意义。 + +RenderObject + +从其名字,我们就可以很直观地知道,RenderObject是主要负责实现视图渲染的对象。 + +在前面的第4篇文章“Flutter区别于其他方案的关键技术是什么?”中,我们提到,Flutter通过控件树(Widget树)中的每个控件(Widget)创建不同类型的渲染对象,组成渲染对象树。 + +而渲染对象树在Flutter的展示过程分为四个阶段,即布局、绘制、合成和渲染。 其中,布局和绘制在RenderObject中完成,Flutter采用深度优先机制遍历渲染对象树,确定树中各个对象的位置和尺寸,并把它们绘制到不同的图层上。绘制完毕后,合成和渲染的工作则交给Skia搞定。 + +Flutter通过引入Widget、Element与RenderObject这三个概念,把原本从视图数据到视图渲染的复杂构建过程拆分得更简单、直接,在易于集中治理的同时,保证了较高的渲染效率。 + +RenderObjectWidget介绍 + +通过第5篇文章“从标准模板入手,体会Flutter代码是如何运行在原生系统上的”的介绍,你应该已经知道如何使用StatelessWidget和StatefulWidget了。 + +不过,StatelessWidget和StatefulWidget只是用来组装控件的容器,并不负责组件最后的布局和绘制。在Flutter中,布局和绘制工作实际上是在Widget的另一个子类RenderObjectWidget内完成的。 + +所以,在今天这篇文章的最后,我们再来看一下RenderObjectWidget的源码,来看看如何使用Element和RenderObject完成图形渲染工作。 + +abstract class RenderObjectWidget extends Widget { + @override + RenderObjectElement createElement(); + @protected + RenderObject createRenderObject(BuildContext context); + @protected + void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { } + ... +} + + +RenderObjectWidget是一个抽象类。我们通过源码可以看到,这个类中同时拥有创建Element、RenderObject,以及更新RenderObject的方法。 + +但实际上,RenderObjectWidget本身并不负责这些对象的创建与更新。 + +对于Element的创建,Flutter会在遍历Widget树时,调用createElement去同步Widget自身配置,从而生成对应节点的Element对象。而对于RenderObject的创建与更新,其实是在RenderObjectElement类中完成的。 + +abstract class RenderObjectElement extends Element { + RenderObject _renderObject; + + @override + void mount(Element parent, dynamic newSlot) { + super.mount(parent, newSlot); + _renderObject = widget.createRenderObject(this); + attachRenderObject(newSlot); + _dirty = false; + } + + @override + void update(covariant RenderObjectWidget newWidget) { + super.update(newWidget); + widget.updateRenderObject(this, renderObject); + _dirty = false; + } + ... +} + + +在Element创建完毕后,Flutter会调用Element的mount方法。在这个方法里,会完成与之关联的RenderObject对象的创建,以及与渲染树的插入工作,插入到渲染树后的Element就可以显示到屏幕中了。 + +如果Widget的配置数据发生了改变,那么持有该Widget的Element节点也会被标记为dirty。在下一个周期的绘制时,Flutter就会触发Element树的更新,并使用最新的Widget数据更新自身以及关联的RenderObject对象,接下来便会进入Layout和Paint的流程。而真正的绘制和布局过程,则完全交由RenderObject完成: + +abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget { + ... + void layout(Constraints constraints, { bool parentUsesSize = false }) {...} + + void paint(PaintingContext context, Offset offset) { } +} + + +布局和绘制完成后,接下来的事情就交给Skia了。在VSync信号同步时直接从渲染树合成Bitmap,然后提交给GPU。这部分内容,我已经在之前的“Flutter区别于其他方案的关键技术是什么?”中与你介绍过了,这里就不再赘述了。 + +接下来,我以下面的界面示例为例,与你说明Widget、Element与RenderObject在渲染过程中的关系。在下面的例子中,一个Row容器放置了4个子Widget,左边是Image,而右边则是一个Column容器下排布的两个Text。 + + + +图3 界面示例 + +那么,在Flutter遍历完Widget树,创建了各个子Widget对应的Element的同时,也创建了与之关联的、负责实际布局和绘制的RenderObject。 + + + +图4 示例界面生成的“三棵树” + +总结 + +好了,今天关于Widget的设计思路和基本原理的介绍,我们就先进行到这里。接下来,我们一起回顾下今天的主要内容吧。 + +首先,我与你介绍了Widget渲染过程,学习了在Flutter中视图数据的组织和渲染抽象的三个核心概念,即Widget、 Element和RenderObject。 + +其中,Widget是Flutter世界里对视图的一种结构化描述,里面存储的是有关视图渲染的配置信息;Element则是Widget的一个实例化对象,将Widget树的变化做了抽象,能够做到只将真正需要修改的部分同步到真实的Render Object树中,最大程度地优化了从结构化的配置信息到完成最终渲染的过程;而RenderObject,则负责实现视图的最终呈现,通过布局、绘制完成界面的展示。 + +最后,在对Flutter Widget渲染过程有了一定认识后,我带你阅读了RenderObjectWidget的代码,理解Widget、Element与RenderObject这三个对象之间是如何互相配合,实现图形渲染工作的。 + +熟悉了Widget、Element与RenderObject这三个概念,相信你已经对组件的渲染过程有了一个清晰而完整的认识。这样,我们后续再学习常用的组件和布局时,就能够从不同的视角去思考框架设计的合理性了。 + +不过在日常开发学习中,绝大多数情况下,我们只需要了解各种Widget特性及使用方法,而无需关心Element及RenderObject。因为Flutter已经帮我们做了大量优化工作,因此我们只需要在上层代码完成各类Widget的组装配置,其他的事情完全交给Flutter就可以了。 + +思考题 + +你是如何理解Widget、Element和RenderObject这三个概念的?它们之间是一一对应的吗?你能否在Android/iOS/Web中找到对应的概念呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/10Widget中的State到底是什么?.md b/专栏/Flutter核心技术与实战/10Widget中的State到底是什么?.md new file mode 100644 index 0000000..95cf9ad --- /dev/null +++ b/专栏/Flutter核心技术与实战/10Widget中的State到底是什么?.md @@ -0,0 +1,213 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 Widget中的State到底是什么? + 你好,我是陈航。 + +通过上一篇文章,我们已经深入理解了Widget是Flutter构建界面的基石,也认识了Widget、Element、RenderObject是如何互相配合,实现图形渲染工作的。Flutter在底层做了大量的渲染优化工作,使得我们只需要通过组合、嵌套不同类型的Widget,就可以构建出任意功能、任意复杂度的界面。 + +同时,我们通过前面的学习,也已经了解到Widget有StatelessWidget和StatefulWidget两种类型。StatefulWidget应对有交互、需要动态变化视觉效果的场景,而StatelessWidget则用于处理静态的、无状态的视图展示。StatefulWidget的场景已经完全覆盖了StatelessWidget,因此我们在构建界面时,往往会大量使用StatefulWidget来处理静态的视图展示需求,看起来似乎也没什么问题。 + +那么,StatelessWidget存在的必要性在哪里?StatefulWidget是否是Flutter中的万金油?在今天这篇文章中,我将着重和你介绍这两种类型的区别,从而帮你更好地理解Widget,掌握不同类型Widget的正确使用时机。 + +UI编程范式 + +要想理解StatelessWidget与StatefulWidget的使用场景,我们首先需要了解,在Flutter中,如何调整一个控件(Widget)的展示样式,即UI编程范式。 + +如果你有过原生系统(Android、iOS)或原生JavaScript开发经验的话,应该知道视图开发是命令式的,需要精确地告诉操作系统或浏览器用何种方式去做事情。比如,如果我们想要变更界面的某个文案,则需要找到具体的文本控件并调用它的控件方法命令,才能完成文字变更。 + +下述代码分别展示了在Android、iOS及原生Javascript中,如何将一个文本控件的展示文案更改为Hello World: + +// Android设置某文本控件展示文案为Hello World +TextView textView = (TextView) findViewById(R.id.txt); +textView.setText("Hello World"); + +// iOS设置某文本控件展示文案为Hello World +UILabel *label = (UILabel *)[self.view viewWithTag:1234]; +label.text = @"Hello World"; + +// 原生JavaScript设置某文本控件展示文案为Hello World +document.querySelector("#demo").innerHTML = "Hello World!"; + + +与此不同的是,Flutter的视图开发是声明式的,其核心设计思想就是将视图和数据分离,这与React的设计思路完全一致。 + +对我们来说,如果要实现同样的需求,则要稍微麻烦点:除了设计好Widget布局方案之外,还需要提前维护一套文案数据集,并为需要变化的Widget绑定数据集中的数据,使Widget根据这个数据集完成渲染。 + +但是,当需要变更界面的文案时,我们只要改变数据集中的文案数据,并通知Flutter框架触发Widget的重新渲染即可。这样一来,开发者将无需再精确关注UI编程中的各个过程细节,只要维护好数据集即可。比起命令式的视图开发方式需要挨个设置不同组件(Widget)的视觉属性,这种方式要便捷得多。 + +总结来说,命令式编程强调精确控制过程细节;而声明式编程强调通过意图输出结果整体。对应到Flutter中,意图是绑定了组件状态的State,结果则是重新渲染后的组件。在Widget的生命周期内,应用到State中的任何更改都将强制Widget重新构建。 + +其中,对于组件完成创建后就无需变更的场景,状态的绑定是可选项。这里“可选”就区分出了Widget的两种类型,即:StatelessWidget不带绑定状态,而StatefulWidget带绑定状态。当你所要构建的用户界面不随任何状态信息的变化而变化时,需要选择使用StatelessWidget,反之则选用StatefulWidget。前者一般用于静态内容的展示,而后者则用于存在交互反馈的内容呈现中。 + +接下来,我分别和你介绍StatelessWidget和StatefulWidget,从源码分析它们的区别,并总结一些关于Widget选型的基本原则。 + +StatelessWidget + +在Flutter中,Widget采用由父到子、自顶向下的方式进行构建,父Widget控制着子Widget的显示样式,其样式配置由父Widget在构建时提供。 + +用这种方式构建出的Widget,有些(比如Text、Container、Row、Column等)在创建时,除了这些配置参数之外不依赖于任何其他信息,换句话说,它们一旦创建成功就不再关心、也不响应任何数据变化进行重绘。在Flutter中,这样的Widget被称为StatelessWidget(无状态组件)。 + +这里有一张StatelessWidget的示意图,如下所示: + + + +图1 StatelessWidget 示意图 + +接下来,我以Text的部分源码为例,和你说明StatelessWidget的构建过程。 + +class Text extends StatelessWidget { + //构造方法及属性声明部分 + const Text(this.data, { + Key key, + this.textAlign, + this.textDirection, + //其他参数 + ... + }) : assert(data != null), + textSpan = null, + super(key: key); + + final String data; + final TextAlign textAlign; + final TextDirection textDirection; + //其他属性 + ... + + @override + Widget build(BuildContext context) { + ... + Widget result = RichText( + //初始化配置 + ... + ) + ); + ... + return result; + } +} + + +可以看到,在构造方法将其属性列表赋值后,build方法随即将子组件RichText通过其属性列表(如文本data、对齐方式textAlign、文本展示方向textDirection等)初始化后返回,之后Text内部不再响应外部数据的变化。 + +那么,什么场景下应该使用StatelessWidget呢? + +这里,我有一个简单的判断规则:父Widget是否能通过初始化参数完全控制其UI展示效果?如果能,那么我们就可以使用StatelessWidget来设计构造函数接口了。 + +我准备了两个简单的小例子,来帮助你理解这个判断规则。 + +第一个小例子是,我需要创建一个自定义的弹窗控件,把使用App过程中出现的一些错误信息提示给用户。这个组件的父Widget,能够完全在子Widget初始化时将组件所需要的样式信息和错误提示信息传递给它,也就意味着父Widget通过初始化参数就能完全控制其展示效果。所以,我可以采用继承StatelessWidget的方式,来进行组件自定义。 + +第二个小例子是,我需要定义一个计数器按钮,用户每次点击按钮后,按钮颜色都会随之加深。可以看到,这个组件的父Widget只能控制子Widget初始的样式展示效果,而无法控制在交互过程中发生的颜色变化。所以,我无法通过继承StatelessWidget的方式来自定义组件。那么,这个时候就轮到StatefulWidget出场了。 + +StatefulWidget + +与StatelessWidget相对应的,有一些Widget(比如Image、Checkbox)的展示,除了父Widget初始化时传入的静态配置之外,还需要处理用户的交互(比如,用户点击按钮)或其内部数据的变化(比如,网络数据回包),并体现在UI上。 + +换句话说,这些Widget创建完成后,还需要关心和响应数据变化来进行重绘。在Flutter中,这一类Widget被称为StatefulWidget(有状态组件)。这里有一张StatefulWidget的示意图,如下所示: + + + +图2 StatefulWidget 示意图 + +看到这里,你可能有点困惑了。因为,我在上一篇文章“Widget,构建Flutter界面的基石”中和你分享到,Widget是不可变的,发生变化时需要销毁重建,所以谈不上状态。那么,这到底是怎么回事呢? + +其实,StatefulWidget是以State类代理Widget构建的设计方式实现的。接下来,我就以Image的部分源码为例,和你说明StatefulWidget的构建过程,来帮助你理解这个知识点。 + +和上面提到的Text一样,Image类的构造函数会接收要被这个类使用的属性参数。然而,不同的是,Image类并没有build方法来创建视图,而是通过createState方法创建了一个类型为_ImageState的state对象,然后由这个对象负责视图的构建。 + +这个state对象持有并处理了Image类中的状态变化,所以我就以_imageInfo属性为例来和你展开说明。 + +_imageInfo属性用来给Widget加载真实的图片,一旦State对象通过_handleImageChanged方法监听到_imageInfo属性发生了变化,就会立即调用_ImageState类的setState方法通知Flutter框架:“我这儿的数据变啦,请使用更新后的_imageInfo数据重新加载图片!”。而,Flutter框架则会标记视图状态,更新UI。 + +class Image extends StatefulWidget { + //构造方法及属性声明部分 + const Image({ + Key key, + @required this.image, + //其他参数 + }) : assert(image != null), + super(key: key); + + final ImageProvider image; + //其他属性 + ... + + @override + _ImageState createState() => _ImageState(); + ... +} + +class _ImageState extends State { + ImageInfo _imageInfo; + //其他属性 + ... + + void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) { + setState(() { + _imageInfo = imageInfo; + }); + } + ... + @override + Widget build(BuildContext context) { + final RawImage image = RawImage( + image: _imageInfo?.image, + //其他初始化配置 + ... + ); + return image; + } + ... +} + + +可以看到,在这个例子中,Image以一种动态的方式运行:监听变化,更新视图。与StatelessWidget通过父Widget完全控制UI展示不同,StatefulWidget的父Widget仅定义了它的初始化状态,而其自身视图运行的状态则需要自己处理,并根据处理情况即时更新UI展示。 + +好了,至此我们已经通过StatelessWidget与StatefulWidget的源码,理解了这两种类型的Widget。这时,你可能会问,既然StatefulWidget不仅可以响应状态变化,又能展示静态UI,那么StatelessWidget这种只能展示静态UI的Widget,还有存在的必要吗? + +StatefulWidget不是万金油,要慎用 + +对于UI框架而言,同样的展示效果一般可以通过多种控件实现。从定义来看,StatefulWidget仿佛是万能的,替代StatelessWidget看起来合情合理。于是StatefulWidget的滥用,也容易因此变得顺理成章,难以避免。 + +但事实是,StatefulWidget的滥用会直接影响Flutter应用的渲染性能。 + +接下来,在今天这篇文章的最后,我就再带你回顾一下Widget的更新机制,来帮你意识到完全使用StatefulWidget的代价: + + +Widget是不可变的,更新则意味着销毁+重建(build)。StatelessWidget是静态的,一旦创建则无需更新;而对于StatefulWidget来说,在State类中调用setState方法更新数据,会触发视图的销毁和重建,也将间接地触发其每个子Widget的销毁和重建。 + + +那么,这意味着什么呢? + +如果我们的根布局是一个StatefulWidget,在其State中每调用一次更新UI,都将是一整个页面所有Widget的销毁和重建。 + +在上一篇文章中,我们了解到,虽然Flutter内部通过Element层可以最大程度地降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个RenderObject树重建。但,大量Widget对象的销毁重建是无法避免的。如果某个子Widget的重建涉及到一些耗时操作,那页面的渲染性能将会急剧下降。 + +因此,正确评估你的视图展示需求,避免无谓的StatefulWidget使用,是提高Flutter应用渲染性能最简单也是最直接的手段。 + +在接下来的第29篇文章“为什么需要做状态管理,怎么做?”中,我会继续带你学习StatefulWidget常见的几种状态管理方法,与你更为具体地介绍在不同场景中,该选用何种Widget的基本原则。这些原则,你都可以根据实际需要应用到后续工作中。 + +总结 + +好了,今天关于StatelessWidget与StatefulWidget的介绍,我们就到这里了。我们一起来回顾下今天的主要知识点。 + +首先,我带你了解了Flutter基于声明式的UI编程范式,并通过阅读两个典型Widget(Text与Image)源码的方式,与你一起学习了StatelessWidget与StatefulWidget的基本设计思路。 + +由于Widget采用由父到子、自顶向下的方式进行构建,因此在自定义组件时,我们可以根据父Widget是否能通过初始化参数完全控制其UI展示效果的基本原则,来判断究竟是继承StatelessWidget还是StatefulWidget。 + +然后,针对StatefulWidget的“万金油”误区,我带你重新回顾了Widget的UI更新机制。尽管Flutter会通过Element层去最大程度降低对真实渲染视图的修改,但大量的Widget销毁重建无法避免,因此避免StatefulWidget的滥用,是最简单、直接地提升应用渲染性能的手段。 + +需要注意的是,除了我们主动地通过State刷新UI之外,在一些特殊场景下,Widget的build方法有可能会执行多次。因此,我们不应该在这个方法内部,放置太多有耗时的操作。而关于这个build方法在哪些场景下会执行,以及为什么会执行多次,我会在下一篇文章“提到生命周期,我们是在说什么?”中,与你一起详细分析。 + +思考题 + +Flutter工程应用模板是计数器示例应用Demo,这个Demo的根节点是一个StatelessWidget。请在保持原有功能的情况下,将这个Demo改造为根节点为StatefulWidget的App。你能通过数据打点,得出这两种方式的性能差异吗? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/11提到生命周期,我们是在说什么?.md b/专栏/Flutter核心技术与实战/11提到生命周期,我们是在说什么?.md new file mode 100644 index 0000000..1390d37 --- /dev/null +++ b/专栏/Flutter核心技术与实战/11提到生命周期,我们是在说什么?.md @@ -0,0 +1,229 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 提到生命周期,我们是在说什么? + 你好,我是陈航。今天,我要和你分享的主题是Flutter中的生命周期是什么。 + +在上一篇文章中,我们从常见的StatefulWidget的“万金油”误区出发,一起回顾了Widget的UI更新机制。 + +通过父Widget初始化时传入的静态配置,StatelessWidget就能完全控制其静态展示。而StatefulWidget,还需要借助于State对象,在特定的阶段来处理用户的交互或其内部数据的变化,并体现在UI上。这些特定的阶段,就涵盖了一个组件从加载到卸载的全过程,即生命周期。与iOS的ViewController、Android的Activity一样,Flutter中的Widget也存在生命周期,并且通过State来体现。 + +而App则是一个特殊的Widget。除了需要处理视图显示的各个阶段(即视图的生命周期)之外,还需要应对应用从启动到退出所经历的各个状态(App的生命周期)。 + +对于开发者来说,无论是普通Widget(的State)还是App,框架都给我们提供了生命周期的回调,可以让我们选择恰当的时机,做正确的事儿。所以,在对生命周期有了深入理解之后,我们可以写出更加连贯流畅、体验优良的程序。 + +那么,今天我就分别从Widget(的State)和App这两个维度,与你介绍它们的生命周期。 + +State生命周期 + +State的生命周期,指的是在用户参与的情况下,其关联的Widget所经历的,从创建到显示再到更新最后到停止,直至销毁等各个过程阶段。 + +这些不同的阶段涉及到特定的任务处理,因此为了写出一个体验和性能良好的控件,正确理解State的生命周期至关重要。 + +State的生命周期流程,如图1所示: + + + +图1 State生命周期图 + +可以看到,State的生命周期可以分为3个阶段:创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树中移除)。接下来,我们一起看看每一个阶段的具体流程。 + +创建 + +State初始化时会依次执行 :构造方法 -> initState -> didChangeDependencies -> build,随后完成页面渲染。 + +我们来看一下初始化过程中每个方法的意义。 + + +构造方法是State生命周期的起点,Flutter会通过调用StatefulWidget.createState() 来创建一个State。我们可以通过构造方法,来接收父Widget传递的初始化UI配置数据。这些配置数据,决定了Widget最初的呈现效果。 +initState,会在State对象被插入视图树的时候调用。这个函数在State的生命周期中只会被调用一次,所以我们可以在这里做一些初始化工作,比如为状态变量设定默认值。 +didChangeDependencies则用来专门处理State对象依赖关系变化,会在initState() 调用结束后,被Flutter调用。 +build,作用是构建视图。经过以上步骤,Framework认为State已经准备好了,于是调用build。我们需要在这个函数中,根据父Widget传递过来的初始化配置数据,以及State的当前状态,创建一个Widget然后返回。 + + +更新 + +Widget的状态更新,主要由3个方法触发:setState、didchangeDependencies与didUpdateWidget。 + +接下来,我和你分析下这三个方法分别会在什么场景下调用。 + + +setState:我们最熟悉的方法之一。当状态数据发生变化时,我们总是通过调用这个方法告诉Flutter:“我这儿的数据变啦,请使用更新后的数据重建UI!” +didChangeDependencies:State对象的依赖关系发生变化后,Flutter会回调这个方法,随后触发组件构建。哪些情况下State对象的依赖关系会发生变化呢?典型的场景是,系统语言Locale或应用主题改变时,系统会通知State执行didChangeDependencies回调方法。 +didUpdateWidget:当Widget的配置发生变化时,比如,父Widget触发重建(即父Widget的状态发生变化时),热重载时,系统会调用这个函数。 + + +一旦这三个方法被调用,Flutter随后就会销毁老Widget,并调用build方法重建Widget。 + +销毁 + +组件销毁相对比较简单。比如组件被移除,或是页面销毁的时候,系统会调用deactivate和dispose这两个方法,来移除或销毁组件。 + +接下来,我们一起看一下它们的具体调用机制: + + +当组件的可见状态发生变化时,deactivate函数会被调用,这时State会被暂时从视图树中移除。值得注意的是,页面切换时,由于State对象在视图树中的位置发生了变化,需要先暂时移除后再重新添加,重新触发组件构建,因此这个函数也会被调用。 +当State被永久地从视图树中移除时,Flutter会调用dispose函数。而一旦到这个阶段,组件就要被销毁了,所以我们可以在这里进行最终的资源释放、移除监听、清理环境,等等。 + + +如图2所示,左边部分展示了当父Widget状态发生变化时,父子双方共同的生命周期;而中间和右边部分则描述了页面切换时,两个关联的Widget的生命周期函数是如何响应的。 + + + +图2 几种常见场景下State生命周期图 + +我准备了一张表格,从功能,调用时机和调用次数的维度总结了这些方法,帮助你去理解、记忆。 + + + +图3 State生命周期中的方法调用对比 + +另外,我强烈建议你打开自己的IDE,在应用模板中增加以上回调函数并添加打印代码,多运行几次看看各个函数的执行顺序,从而加深对State生命周期的印象。毕竟,实践出真知。 + +App生命周期 + +视图的生命周期,定义了视图的加载到构建的全过程,其回调机制能够确保我们可以根据视图的状态选择合适的时机做恰当的事情。而App的生命周期,则定义了App从启动到退出的全过程。 + +在原生Android、iOS开发中,有时我们需要在对应的App生命周期事件中做相应处理,比如App从后台进入前台、从前台退到后台,或是在UI绘制完成后做一些处理。 + +这样的需求,在原生开发中,我们可以通过重写Activity、ViewController生命周期回调方法,或是注册应用程序的相关通知,来监听App的生命周期并做相应的处理。而在Flutter中,我们可以利用WidgetsBindingObserver类,来实现同样的需求。 + +接下来,我们就来看看具体如何实现这样的需求。 + +首先,我们来看看WidgetsBindingObserver中具体有哪些回调函数: + +abstract class WidgetsBindingObserver { + //页面pop + Future didPopRoute() => Future.value(false); + //页面push + Future didPushRoute(String route) => Future.value(false); + //系统窗口相关改变回调,如旋转 + void didChangeMetrics() { } + //文本缩放系数变化 + void didChangeTextScaleFactor() { } + //系统亮度变化 + void didChangePlatformBrightness() { } + //本地化语言变化 + void didChangeLocales(List locale) { } + //App生命周期变化 + void didChangeAppLifecycleState(AppLifecycleState state) { } + //内存警告回调 + void didHaveMemoryPressure() { } + //Accessibility相关特性回调 + void didChangeAccessibilityFeatures() {} +} + + +可以看到,WidgetsBindingObserver这个类提供的回调函数非常丰富,常见的屏幕旋转、屏幕亮度、语言变化、内存警告都可以通过这个实现进行回调。我们通过给WidgetsBinding的单例对象设置监听器,就可以监听对应的回调方法。 + +考虑到其他的回调相对简单,你可以参考官方文档,对照着进行练习。因此,我今天主要和你分享App生命周期的回调didChangeAppLifecycleState,和帧绘制回调addPostFrameCallback与addPersistentFrameCallback。 + +生命周期回调 + +didChangeAppLifecycleState回调函数中,有一个参数类型为AppLifecycleState的枚举类,这个枚举类是Flutter对App生命周期状态的封装。它的常用状态包括resumed、inactive、paused这三个。 + + +resumed:可见的,并能响应用户的输入。 +inactive:处在不活动状态,无法处理用户响应。 +paused:不可见并不能响应用户的输入,但是在后台继续活动中。 + + +这里,我来和你分享一个实际案例。 + +在下面的代码中,我们在initState时注册了监听器,在didChangeAppLifecycleState回调方法中打印了当前的App状态,最后在dispose时把监听器移除: + +class _MyHomePageState extends State with WidgetsBindingObserver{//这里你可以再回顾下,第7篇文章“函数、类与运算符:Dart是如何处理信息的?”中关于Mixin的内容 +... + @override + @mustCallSuper + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this);//注册监听器 + } + @override + @mustCallSuper + void dispose(){ + super.dispose(); + WidgetsBinding.instance.removeObserver(this);//移除监听器 + } + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + print("$state"); + if (state == AppLifecycleState.resumed) { + //do sth + } + } +} + + +我们试着切换一下前、后台,观察控制台输出的App状态,可以发现: + + +从后台切入前台,控制台打印的App生命周期变化如下: AppLifecycleState.paused->AppLifecycleState.inactive->AppLifecycleState.resumed; +从前台退回后台,控制台打印的App生命周期变化则变成了:AppLifecycleState.resumed->AppLifecycleState.inactive->AppLifecycleState.paused。 + + +可以看到,App前后台切换过程中打印出的状态是完全符合预期的。 + + + +图4 App切换前后台状态变化示意 + +帧绘制回调 + +除了需要监听App的生命周期回调做相应的处理之外,有时候我们还需要在组件渲染之后做一些与显示安全相关的操作。 + +在iOS开发中,我们可以通过dispatch_async(dispatch_get_main_queue(),^{…})方法,让操作在下一个RunLoop执行;而在Android开发中,我们可以通过View.post()插入消息队列,来保证在组件渲染后进行相关操作。 + +其实,在Flutter中实现同样的需求会更简单:依然使用万能的WidgetsBinding来实现。 + +WidgetsBinding提供了单次Frame绘制回调,以及实时Frame绘制回调两种机制,来分别满足不同的需求: + + +单次Frame绘制回调,通过addPostFrameCallback实现。它会在当前Frame绘制完成后进行进行回调,并且只会回调一次,如果要再次监听则需要再设置一次。 + + +WidgetsBinding.instance.addPostFrameCallback((_){ + print("单次Frame绘制回调");//只回调一次 + }); + + + +实时Frame绘制回调,则通过addPersistentFrameCallback实现。这个函数会在每次绘制Frame结束后进行回调,可以用做FPS监测。 + + +WidgetsBinding.instance.addPersistentFrameCallback((_){ + print("实时Frame绘制回调");//每帧都回调 +}); + + +总结 + +在今天这篇文章中,我和你介绍了State和App的生命周期,这是Flutter给我们提供的,感知Widget和应用在不同阶段状态变化的回调。 + +首先,我带你重新认识了Widget生命周期的实际承载者State。我将State的生命周期划分为了创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树种移除)这3个阶段,并为你介绍了每个阶段中涉及的关键方法,希望你能够深刻理解Flutter组件从加载到卸载的完整周期。 + +然后,通过与原生Android、iOS平台能力的对比,以及查看WidgetsBindingObserver源码的方式,我与你讲述了Flutter常用的生命周期状态切换机制。希望你能掌握Flutter的App生命周期监听方法,并理解Flutter常用的生命周期状态切换机制。 + +最后,我和你一起学习了Flutter帧绘制回调机制,理解了单次Frame绘制回调与实时Frame绘制回调的异同与使用场景。 + +为了能够精确地控制Widget,Flutter提供了很多状态回调,所以今天这一篇文章,涉及到的方法有些多。但,只要你分别记住创建、更新与销毁这三条主线的调用规则,就一定能把这些方法的调用顺序串起来,并能在实际开发中运用正确的方法去感知状态变更,写出合理的组件。 + +我把今天分享所涉及的全部知识点打包成了一个小项目,你可以下载后在工程中实际运行,并对照着今天的课程学习,体会在不同场景下这些函数的调用时机。 + +思考题 + +最后,请你思考下这两个问题: + + +构造方法与initState函数在State的生命周期中都只会被调用一次,也大都用于完成一些初始化的工作。根据我们今天的学习,你能否举出例子,比如哪些操作适合放在构造方法,哪些操作适合放在initState,而哪些操作必须放在initState。 +通过didChangeDependencies触发Widget重建时,父子Widget之间的生命周期函数调用时序是怎样的? + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/12经典控件(一):文本、图片和按钮在Flutter中怎么用?.md b/专栏/Flutter核心技术与实战/12经典控件(一):文本、图片和按钮在Flutter中怎么用?.md new file mode 100644 index 0000000..98612ae --- /dev/null +++ b/专栏/Flutter核心技术与实战/12经典控件(一):文本、图片和按钮在Flutter中怎么用?.md @@ -0,0 +1,218 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 经典控件(一):文本、图片和按钮在Flutter中怎么用? + 你好,我是陈航。 + +在上一篇文章中,我与你介绍了Widget生命周期的实际承载者State,并详细介绍了初始化、状态更新与控件销毁,这3个不同阶段所涉及的关键方法调用顺序。深入理解视图从加载到构建再到销毁的过程,可以帮助你理解如何根据视图的状态在合适的时机做恰当的事情。 + +前面几次分享我们讲了很多关于Flutter框架视图渲染的基础知识和原理。但有些同学可能会觉得这些基础知识和原理在实践中并不常用,所以在学习时会选择忽视这些内容。 + +但其实,像视图数据流转机制、底层渲染方案、视图更新策略等知识,都是构成一个UI框架的根本,看似枯燥,却往往具有最长久的生命力。新框架每年层出不穷,可是扒下那层炫酷的“外衣”,里面其实还是那些最基础的知识和原理。 + +因此,只有把这些最基础的知识弄明白了,修炼好了内功,才能触类旁通,由点及面形成自己的知识体系,也能够在框架之上思考应用层构建视图实现的合理性。 + +在对视图的基础知识有了整体印象后,我们再来学习Flutter视图系统所提供的UI控件,就会事半功倍了。而作为一个UI框架,与Android、iOS和React类似的,Flutter自然也提供了很多UI控件。而文本、图片和按钮则是这些不同的UI框架中构建视图都要用到的三个最基本的控件。因此,在今天这篇文章中,我就与你一起学习在Flutter中该如何使用它们。 + +文本控件 + +文本是视图系统中的常见控件,用来显示一段特定样式的字符串,就比如Android里的TextView、iOS中的UILabel。而在Flutter中,文本展示是通过Text控件实现的。 + +Text支持两种类型的文本展示,一个是默认的展示单一样式的文本Text,另一个是支持多种混合样式的富文本Text.rich。 + +我们先来看看如何使用单一样式的文本Text。 + +单一样式文本Text的初始化,是要传入需要展示的字符串。而这个字符串的具体展示效果,受构造函数中的其他参数控制。这些参数大致可以分为两类: + + +控制整体文本布局的参数,如文本对齐方式textAlign、文本排版方向textDirection,文本显示最大行数maxLines、文本截断规则overflow等等,这些都是构造函数中的参数; +控制文本展示样式的参数,如字体名称fontFamily、字体大小fontSize、文本颜色color、文本阴影shadows等等,这些参数被统一封装到了构造函数中的参数style中。 + + +接下来,我们以一个具体的例子来看看Text控件的使用方法。如下所示,我在代码中定义了一段居中布局、20号红色粗体展示样式的字符串: + +Text( + '文本是视图系统中的常见控件,用来显示一段特定样式的字符串,就比如Android里的TextView,或是iOS中的UILabel。', + textAlign: TextAlign.center,//居中显示 + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red),//20号红色粗体展示 +); + + +运行效果如下图所示: + + + +图1 单一样式文本Text示例 + +理解了展示单一样式的文本Text的使用方法后,我们再来看看如何在一段字符串中支持多种混合展示样式。 + +混合展示样式与单一样式的关键区别在于分片,即如何把一段字符串分为几个片段来管理,给每个片段单独设置样式。面对这样的需求,在Android中,我们使用SpannableString来实现;在iOS中,我们使用NSAttributedString来实现;而在Flutter中也有类似的概念,即TextSpan。 + +TextSpan定义了一个字符串片段该如何控制其展示样式,而将这些有着独立展示样式的字符串组装在一起,则可以支持混合样式的富文本展示。 + +如下方代码所示,我们分别定义了黑色与红色两种展示样式,随后把一段字符串分成了4个片段,并设置了不同的展示样式: + +TextStyle blackStyle = TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: Colors.black); //黑色样式 + +TextStyle redStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red); //红色样式 + +Text.rich( + TextSpan( + children: [ + TextSpan(text:'文本是视图系统中常见的控件,它用来显示一段特定样式的字符串,类似', style: redStyle), //第1个片段,红色样式 + TextSpan(text:'Android', style: blackStyle), //第1个片段,黑色样式 + TextSpan(text:'中的', style:redStyle), //第1个片段,红色样式 + TextSpan(text:'TextView', style: blackStyle) //第1个片段,黑色样式 + ]), + textAlign: TextAlign.center, +); + + +运行效果,如下图所示: + + + +图2 混合样式富文本Text.rich示例 + +接下来,我们再看看Flutter中的图片控件Image。 + +图片 + +使用Image,可以让我们向用户展示一张图片。图片的显示方式有很多,比如资源图片、网络图片、文件图片等,图片格式也各不相同,因此在Flutter中也有多种方式,用来加载不同形式、支持不同格式的图片: + + +加载本地资源图片,如Image.asset(‘images/logo.png’); +加载本地(File文件)图片,如Image.file(new File(’/storage/xxx/xxx/test.jpg’)); +加载网络图片,如Image.network('http://xxx/xxx/test.gif') 。 + + +除了可以根据图片的显示方式设置不同的图片源之外,图片的构造方法还提供了填充模式fit、拉伸模式centerSlice、重复模式repeat等属性,可以针对图片与目标区域的宽高比差异制定排版模式。 + +这,和Android中ImageView、iOS里的UIImageView的属性都是类似的。因此,我在这里就不再过多展开了。你可以参考官方文档中的Image的构造函数部分,去查看Image控件的具体使用方法。 + +关于图片展示,我还要和你分享下Flutter中的FadeInImage控件。在加载网络图片的时候,为了提升用户的等待体验,我们往往会加入占位图、加载动画等元素,但是默认的Image.network构造方法并不支持这些高级功能,这时候FadeInImage控件就派上用场了。 + +FadeInImage控件提供了图片占位的功能,并且支持在图片加载完成时淡入淡出的视觉效果。此外,由于Image支持gif格式,我们甚至还可以将一些炫酷的加载动画作为占位图。 + +下述代码展示了这样的场景。我们在加载大图片时,将一张loading的gif作为占位图展示给用户: + +FadeInImage.assetNetwork( + placeholder: 'assets/loading.gif', //gif占位 + image: 'https://xxx/xxx/xxx.jpg', + fit: BoxFit.cover, //图片拉伸模式 + width: 200, + height: 200, +) + + + + +图3 FadeInImage占位图 + +Image控件需要根据图片资源异步加载的情况,决定自身的显示效果,因此是一个StatefulWidget。图片加载过程由ImageProvider触发,而ImageProvider表示异步获取图片数据的操作,可以从资源、文件和网络等不同的渠道获取图片。 + +首先,ImageProvider根据_ImageState中传递的图片配置生成对应的图片缓存key;然后,去ImageCache中查找是否有对应的图片缓存,如果有,则通知_ImageState刷新UI;如果没有,则启动ImageStream开始异步加载,加载完毕后,更新缓存;最后,通知_ImageState刷新UI。 + +图片展示的流程,可以用以下流程图表示: + + + +图4 图片加载流程 + +值得注意的是,ImageCache使用LRU(Least Recently Used,最近最少使用)算法进行缓存更新策略,并且默认最多存储 1000张图片,最大缓存限制为100MB,当限定的空间已存满数据时,把最久没有被访问到的图片清除。图片缓存只会在运行期间生效,也就是只缓存在内存中。如果想要支持缓存到文件系统,可以使用第三方的CachedNetworkImage控件。 + +CachedNetworkImage的使用方法与Image类似,除了支持图片缓存外,还提供了比FadeInImage更为强大的加载过程占位与加载错误占位,可以支持比用图片占位更灵活的自定义控件占位。 + +在下面的代码中,我们在加载图片时,不仅给用户展示了作为占位的转圈loading,还提供了一个错误图兜底,以备图片加载出错: + +CachedNetworkImage( + imageUrl: "http://xxx/xxx/jpg", + placeholder: (context, url) => CircularProgressIndicator(), + errorWidget: (context, url, error) => Icon(Icons.error), + ) + + +最后,我们再来看看Flutter中的按钮控件。 + +按钮 + +通过按钮,我们可以响应用户的交互事件。Flutter提供了三个基本的按钮控件,即FloatingActionButton、FlatButton和RaisedButton。 + + +FloatingActionButton:一个圆形的按钮,一般出现在屏幕内容的前面,用来处理界面中最常用、最基础的用户动作。在之前的第5篇文章“从标准模板入手,体会Flutter代码是如何运行在原生系统上的”中,计数器示例的“+”悬浮按钮就是一个FloatingActionButton。 +RaisedButton:凸起的按钮,默认带有灰色背景,被点击后灰色背景会加深。 +FlatButton:扁平化的按钮,默认透明背景,被点击后会呈现灰色背景。 + + +这三个按钮控件的使用方法类似,唯一的区别只是默认样式不同而已。 + +下述代码中,我分别定义了FloatingActionButton、FlatButton与RaisedButton,它们的功能完全一样,在点击时打印一段文字: + +FloatingActionButton(onPressed: () => print('FloatingActionButton pressed'),child: Text('Btn'),); +FlatButton(onPressed: () => print('FlatButton pressed'),child: Text('Btn'),); +RaisedButton(onPressed: () => print('RaisedButton pressed'),child: Text('Btn'),); + + + + +图5 按钮控件 + +既然是按钮,因此除了控制基本样式之外,还需要响应用户点击行为。这就对应着按钮控件中的两个最重要的参数了: + + +onPressed参数用于设置点击回调,告诉Flutter在按钮被点击时通知我们。如果onPressed参数为空,则按钮会处于禁用状态,不响应用户点击。 +child参数用于设置按钮的内容,告诉Flutter控件应该长成什么样,也就是控制着按钮控件的基本样式。child可以接收任意的Widget,比如我们在上面的例子中传入的Text,除此之外我们还可以传入Image等控件。 + + +虽然我们可以通过child参数来控制按钮控件的基本样式,但是系统默认的样式还是太单调了。因此通常情况下,我们还是会进行控件样式定制。 + +与Text控件类似,按钮控件也提供了丰富的样式定制功能,比如背景颜色color、按钮形状shape、主题颜色colorBrightness,等等。 + +接下来,我就以FlatButton为例,与你介绍按钮的样式定制: + +FlatButton( + color: Colors.yellow, //设置背景色为黄色 + shape:BeveledRectangleBorder(borderRadius: BorderRadius.circular(20.0)), //设置斜角矩形边框 + colorBrightness: Brightness.light, //确保文字按钮为深色 + onPressed: () => print('FlatButton pressed'), + child: Row(children: [Icon(Icons.add), Text("Add")],) +); + + +可以看到,我们将一个加号Icon与文本组合,定义了按钮的基本外观;随后通过shape来指定其外形为一个斜角矩形边框,并将按钮的背景色设置为黄色。 + +因为按钮背景颜色是浅色的,为避免按钮文字看不清楚,我们通过设置按钮主题colorBrightness为Brightness.light,保证按钮文字颜色为深色。 + +展示效果如下: + + + +图6 按钮控件定制外观 + +总结 + +UI控件是构建一个视图的基本元素,而文本、图片和按钮则是其中最经典的控件。 + +接下来,我们简单回顾一下今天的内容,以便加深理解与记忆。 + +首先,我们认识了支持单一样式和混合样式两种类型的文本展示控件Text。其中,通过TextStyle控制字符串的展示样式,其他参数控制文本布局,可以实现单一样式的文本展示;而通过TextSpan将字符串分割为若干片段,对每个片段单独设置样式后组装,可以实现支持混合样式的富文本展示。 + +然后,我带你学习了支持多种图片源加载方式的图片控件Image。Image内部通过ImageProvider根据缓存状态,触发异步加载流程,通知_ImageState刷新UI。不过,由于图片缓存是内存缓存,因此只在运行期间生效。如果要支持缓存到文件系统,可以使用第三方的CachedNetworkImage。 + +最后,我们学习了按钮控件。Flutter提供了多种按钮控件,而它们的使用方法也都类似。其中,控件初始化的child参数用于设置按钮长什么样,而onPressed参数则用于设置点击回调。与Text类似,按钮内部也有丰富的UI定制接口,可以满足开发者的需求。 + +通过今天的学习,我们可以发现,在UI基本信息的表达上,Flutter的经典控件与原生Android、iOS系统提供的控件没有什么本质区别。但是,在自定义控件样式上,Flutter的这些经典控件提供了强大而简洁的扩展能力,使得我们可以快速开发出功能复杂、样式丰富的页面。 + +思考题 + +最后,我给你留下一道思考题吧。 + +请你打开IDE,阅读Flutter SDK中Text、Image、FadeInImage,以及按钮控件FloatingActionButton、FlatButton与RaisedButton的源码,在build函数中找出在内部真正承载其视觉功能的控件。请和我分享下,你在这一过程中发现了什么现象? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/13经典控件(二):UITableView_ListView在Flutter中是什么?.md b/专栏/Flutter核心技术与实战/13经典控件(二):UITableView_ListView在Flutter中是什么?.md new file mode 100644 index 0000000..304ea55 --- /dev/null +++ b/专栏/Flutter核心技术与实战/13经典控件(二):UITableView_ListView在Flutter中是什么?.md @@ -0,0 +1,313 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 经典控件(二):UITableView_ListView在Flutter中是什么? + 你好,我是陈航。 + +在上一篇文章中,我和你一起学习了文本、图片和按钮这3大经典组件在Flutter中的使用方法,以及如何在实际开发中根据不同的场景,去自定义展示样式。 + +文本、图片和按钮这些基本元素,需要进行排列组合,才能构成我们看到的UI视图。那么,当这些基本元素的排列布局超过屏幕显示尺寸(即超过一屏)时,我们就需要引入列表控件来展示视图的完整内容,并根据元素的多少进行自适应滚动展示。 + +这样的需求,在Android中是由ListView或RecyclerView实现的,在iOS中是用UITableView实现的;而在Flutter中,实现这种需求的则是列表控件ListView。 + +ListView + +在Flutter中,ListView可以沿一个方向(垂直或水平方向)来排列其所有子Widget,因此常被用于需要展示一组连续视图元素的场景,比如通信录、优惠券、商家列表等。 + +我们先来看看ListView怎么用。ListView提供了一个默认构造函数ListView,我们可以通过设置它的children参数,很方便地将所有的子Widget包含到ListView中。 + +不过,这种创建方式要求提前将所有子Widget一次性创建好,而不是等到它们真正在屏幕上需要显示时才创建,所以有一个很明显的缺点,就是性能不好。因此,这种方式仅适用于列表中含有少量元素的场景。 + +如下所示,我定义了一组列表项组件,并将它们放在了垂直滚动的ListView中: + +ListView( + children: [ + //设置ListTile组件的标题与图标 + ListTile(leading: Icon(Icons.map), title: Text('Map')), + ListTile(leading: Icon(Icons.mail), title: Text('Mail')), + ListTile(leading: Icon(Icons.message), title: Text('Message')), + ]); + + + +备注:ListTile是Flutter提供的用于快速构建列表项元素的一个小组件单元,用于1~3行(leading、title、subtitle)展示文本、图标等视图元素的场景,通常与ListView配合使用。- +上面这段代码中用到ListTile,是为了演示ListView的能力。关于ListTile的具体使用细节,并不是本篇文章的重点,如果你想深入了解的话,可以参考官方文档。 + + +运行效果,如下图所示: + + + +图1 ListView默认构造函数 + +除了默认的垂直方向布局外,ListView还可以通过设置scrollDirection参数支持水平方向布局。如下所示,我定义了一组不同颜色背景的组件,将它们的宽度设置为140,并包在了水平布局的ListView中,让它们可以横向滚动: + +ListView( + scrollDirection: Axis.horizontal, + itemExtent: 140, //item延展尺寸(宽度) + children: [ + Container(color: Colors.black), + Container(color: Colors.red), + Container(color: Colors.blue), + Container(color: Colors.green), + Container(color: Colors.yellow), + Container(color: Colors.orange), + ]); + + +运行效果,如下图所示: + + + +图2 水平滚动的ListView + +在这个例子中,我们一次性创建了6个子Widget。但从图2的运行效果可以看到,由于屏幕的宽高有限,同一时间用户只能看到3个Widget。也就是说,是否一次性提前构建出所有要展示的子Widget,与用户而言并没有什么视觉上的差异。 + +所以,考虑到创建子Widget产生的性能问题,更好的方法是抽象出创建子Widget的方法,交由ListView统一管理,在真正需要展示该子Widget时再去创建。 + +ListView的另一个构造函数ListView.builder,则适用于子Widget比较多的场景。这个构造函数有两个关键参数: + + +itemBuilder,是列表项的创建方法。当列表滚动到相应位置时,ListView会调用该方法创建对应的子Widget。 +itemCount,表示列表项的数量,如果为空,则表示ListView为无限列表。 + + +同样地,我通过一个案例,与你说明itemBuilder与itemCount这两个参数的具体用法。 + +我定义了一个拥有100个列表元素的ListView,在列表项的创建方法中,分别将index的值设置为ListTile的标题与子标题。比如,第一行列表项会展示title 0 body 0: + +ListView.builder( + itemCount: 100, //元素个数 + itemExtent: 50.0, //列表项高度 + itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index")) +); + + +这里需要注意的是,itemExtent并不是一个必填参数。但,对于定高的列表项元素,我强烈建议你提前设置好这个参数的值。 + +因为如果这个参数为null,ListView会动态地根据子Widget创建完成的结果,决定自身的视图高度,以及子Widget在ListView中的相对位置。在滚动发生变化而列表项又很多时,这样的计算就会非常频繁。 + +但如果提前设置好itemExtent,ListView则可以提前计算好每一个列表项元素的相对位置,以及自身的视图高度,省去了无谓的计算。 + +因此,在ListView中,指定itemExtent比让子Widget自己决定自身高度会更高效。 + +运行这个示例,效果如下所示: + + + +图3 ListView.builder构造函数 + +可能你已经发现了,我们的列表还缺少分割线。在ListView中,有两种方式支持分割线: + + +一种是,在itemBuilder中,根据index的值动态创建分割线,也就是将分割线视为列表项的一部分; +另一种是,使用ListView的另一个构造方法ListView.separated,单独设置分割线的样式。 + + +第一种方式实际上是视图的组合,之前的分享中我们已经多次提及,对你来说应该已经比较熟悉了,这里我就不再过多地介绍了。接下来,我和你演示一下如何使用ListView.separated设置分割线。 + +与ListView.builder抽离出了子Widget的构建方法类似,ListView.separated抽离出了分割线的创建方法separatorBuilder,以便根据index设置不同样式的分割线。 + +如下所示,我针对index为偶数的场景,创建了绿色的分割线,而针对index为奇数的场景,创建了红色的分割线: + +//使用ListView.separated设置分割线 +ListView.separated( + itemCount: 100, + separatorBuilder: (BuildContext context, int index) => index %2 ==0? Divider(color: Colors.green) : Divider(color: Colors.red),//index为偶数,创建绿色分割线;index为奇数,则创建红色分割线 + itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))//创建子Widget +) + + +运行效果,如下所示: + + + +图4 ListView.separated构造函数 + +好了,我已经与你分享完了ListView的常见构造函数。接下来,我准备了一张表格,总结了ListView常见的构造方法及其适用场景,供你参考,以便理解与记忆: + + + +图5 ListView常见的构造方法及其适用场景 + +CustomScrollView + +好了,ListView实现了单一视图下可滚动Widget的交互模型,同时也包含了UI显示相关的控制逻辑和布局模型。但是,对于某些特殊交互场景,比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等,还需要嵌套多个ListView来实现。这时,各自视图的滚动和布局模型就是相互独立、分离的,就很难保证整个页面统一一致的滑动效果。 + +那么,Flutter是如何解决多ListView嵌套时,页面滑动效果不一致的问题的呢? + +在Flutter中有一个专门的控件CustomScrollView,用来处理多个需要自定义滚动效果的Widget。在CustomScrollView中,这些彼此独立的、可滚动的Widget被统称为Sliver。 + +比如,ListView的Sliver实现为SliverList,AppBar的Sliver实现为SliverAppBar。这些Sliver不再维护各自的滚动状态,而是交由CustomScrollView统一管理,最终实现滑动效果的一致性。 + +接下来,我通过一个滚动视差的例子,与你演示CustomScrollView的使用方法。 + +视差滚动是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。 作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。 + +以一个有着封面头图的列表为例,我们希望封面头图和列表这两层视图的滚动联动起来,当用户滚动列表时,头图会根据用户的滚动手势,进行缩小和展开。 + +经分析得出,要实现这样的需求,我们需要两个Sliver:作为头图的SliverAppBar,与作为列表的SliverList。具体的实现思路是: + + +在创建SliverAppBar时,把flexibleSpace参数设置为悬浮头图背景。flexibleSpace可以让背景图显示在AppBar下方,高度和SliverAppBar一样; +而在创建SliverList时,通过SliverChildBuilderDelegate参数实现列表项元素的创建; +最后,将它们一并交由CustomScrollView的slivers参数统一管理。 + + +具体的示例代码如下所示: + +CustomScrollView( + slivers: [ + SliverAppBar(//SliverAppBar作为头图控件 + title: Text('CustomScrollView Demo'),//标题 + floating: true,//设置悬浮样式 + flexibleSpace: Image.network("https://xx.jpg",fit:BoxFit.cover),//设置悬浮头图背景 + expandedHeight: 300,//头图控件高度 + ), + SliverList(//SliverList作为列表控件 + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile(title: Text('Item #$index')),//列表项创建方法 + childCount: 100,//列表元素个数 + ), + ), + ]); + + +运行一下,视差滚动效果如下所示: + + + +图6 CustomScrollView示例 + +ScrollController与ScrollNotification + +现在,你应该已经知道如何实现滚动视图的视觉和交互效果了。接下来,我再与你分享一个更为复杂的问题:在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制。比如,列表是否已经滑到底(顶)了?如何快速回到列表顶部?列表滚动是否已经开始,或者是否已经停下来了? + +对于前两个问题,我们可以使用ScrollController进行滚动信息的监听,以及相应的滚动控制;而最后一个问题,则需要接收ScrollNotification通知进行滚动事件的获取。下面我将分别与你介绍。 + +在Flutter中,因为Widget并不是渲染到屏幕的最终视觉元素(RenderObject才是),所以我们无法像原生的Android或iOS系统那样,向持有的Widget对象获取或设置最终渲染相关的视觉信息,而必须通过对应的组件控制器才能实现。 + +ListView的组件控制器则是ScrollControler,我们可以通过它来获取视图的滚动信息,更新视图的滚动位置。 + +一般而言,获取视图的滚动信息往往是为了进行界面的状态控制,因此ScrollController的初始化、监听及销毁需要与StatefulWidget的状态保持同步。 + +如下代码所示,我们声明了一个有着100个元素的列表项,当滚动视图到特定位置后,用户可以点击按钮返回列表顶部: + + +首先,我们在State的初始化方法里,创建了ScrollController,并通过_controller.addListener注册了滚动监听方法回调,根据当前视图的滚动位置,判断当前是否需要展示“Top”按钮。 +随后,在视图构建方法build中,我们将ScrollController对象与ListView进行了关联,并且在RaisedButton中注册了对应的回调方法,可以在点击按钮时通过_controller.animateTo方法返回列表顶部。 +最后,在State的销毁方法中,我们对ScrollController进行了资源释放。 + + +class MyAPPState extends State { + ScrollController _controller;//ListView控制器 + bool isToTop = false;//标示目前是否需要启用"Top"按钮 + @override + void initState() { + _controller = ScrollController(); + _controller.addListener(() {//为控制器注册滚动监听方法 + if(_controller.offset > 1000) {//如果ListView已经向下滚动了1000,则启用Top按钮 + setState(() {isToTop = true;}); + } else if(_controller.offset < 300) {//如果ListView向下滚动距离不足300,则禁用Top按钮 + setState(() {isToTop = false;}); + } + }); + super.initState(); + } + + Widget build(BuildContext context) { + return MaterialApp( + ... + //顶部Top按钮,根据isToTop变量判断是否需要注册滚动到顶部的方法 + RaisedButton(onPressed: (isToTop ? () { + if(isToTop) { + _controller.animateTo(.0, + duration: Duration(milliseconds: 200), + curve: Curves.ease + );//做一个滚动到顶部的动画 + } + }:null),child: Text("Top"),) + ... + ListView.builder( + controller: _controller,//初始化传入控制器 + itemCount: 100,//列表元素总数 + itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项构造方法 + ) + ... + ); + + @override + void dispose() { + _controller.dispose(); //销毁控制器 + super.dispose(); + } +} + + +ScrollController的运行效果如下所示: + + + +图7 ScrollController示例 + +介绍完了如何通过ScrollController来监听ListView滚动信息,以及怎样进行滚动控制之后,接下来我们再看看如何获取ScrollNotification通知,从而感知ListView的各类滚动事件。 + +在Flutter中,ScrollNotification通知的获取是通过NotificationListener来实现的。与ScrollController不同的是,NotificationListener是一个Widget,为了监听滚动类型的事件,我们需要将NotificationListener添加为ListView的父容器,从而捕获ListView中的通知。而这些通知,需要通过onNotification回调函数实现监听逻辑: + +Widget build(BuildContext context) { + return MaterialApp( + title: 'ScrollController Demo', + home: Scaffold( + appBar: AppBar(title: Text('ScrollController Demo')), + body: NotificationListener(//添加NotificationListener作为父容器 + onNotification: (scrollNotification) {//注册通知回调 + if (scrollNotification is ScrollStartNotification) {//滚动开始 + print('Scroll Start'); + } else if (scrollNotification is ScrollUpdateNotification) {//滚动位置更新 + print('Scroll Update'); + } else if (scrollNotification is ScrollEndNotification) {//滚动结束 + print('Scroll End'); + } + }, + child: ListView.builder( + itemCount: 30,//列表元素个数 + itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项创建方法 + ), + ) + ) + ); +} + + +相比于ScrollController只能和具体的ListView关联后才可以监听到滚动信息;通过NotificationListener则可以监听其子Widget中的任意ListView,不仅可以得到这些ListView的当前滚动位置信息,还可以获取当前的滚动事件信息 。 + +总结 + +在处理用于展示一组连续、可滚动的视图元素的场景,Flutter提供了比原生Android、iOS系统更加强大的列表组件ListView与CustomScrollView,不仅可以支持单一视图下可滚动Widget的交互模型及UI控制模型,对于某些特殊交互,需要嵌套多重可滚动Widget的场景,也提供了统一管理的机制,最终实现体验一致的滑动效果。这些强大的组件,使得我们不仅可以开发出样式丰富的界面,更可以实现复杂的交互。 + +接下来,我们简单回顾一下今天的内容,以便加深你的理解与记忆。 + +首先,我们认识了ListView组件。它同时支持垂直方向和水平方向滚动,不仅提供了少量一次性创建子视图的默认构造方式,也提供了大量按需创建子视图的ListView.builder机制,并且支持自定义分割线。为了节省性能,对于定高的列表项视图,提前指定itemExtent比让子Widget自己决定要更高效。 + +随后,我带你学习了CustomScrollView组件。它引入了Sliver的概念,将多重嵌套的可滚动视图的交互与布局进行统一接管,使得像视差滚动这样的高级交互变得更加容易。 + +最后,我们学习了ScrollController与NotificationListener,前者与ListView绑定,进行滚动信息的监听,进行相应的滚动控制;而后者,通过将ListView纳入子Widget,实现滚动事件的获取。 + +我把今天分享讲的三个例子(视差、ScrollController、ScrollNotification)放到了GitHub上,你可以下载后在工程中实际运行,并对照着今天的知识点进行学习,体会ListView的一些高级用法。 + +思考题 + +最后,我给你留下两个小作业吧: + + +在ListView.builder方法中,ListView根据Widget是否将要出现在可视区域内,按需创建。对于一些场景,为了避免Widget渲染时间过长(比如图片下载),我们需要提前将可视区域上下一定区域内的Widget提前创建好。那么,在Flutter中,如何才能实现呢? +请你使用NotificationListener,来实现图7 ScrollController示例中同样的功能。 + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/14经典布局:如何定义子控件在父容器中排版的位置?.md b/专栏/Flutter核心技术与实战/14经典布局:如何定义子控件在父容器中排版的位置?.md new file mode 100644 index 0000000..514c3c2 --- /dev/null +++ b/专栏/Flutter核心技术与实战/14经典布局:如何定义子控件在父容器中排版的位置?.md @@ -0,0 +1,265 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 经典布局:如何定义子控件在父容器中排版的位置? + 你好,我是陈航。 + +在前面两篇文章中,我们一起学习了构建视图的基本元素:文本、图片和按钮,用于展示一组连续视图元素的ListView,以及处理多重嵌套的可滚动视图的CustomScrollView。 + +在Flutter中,一个完整的界面通常就是由这些小型、单用途的基本控件元素依据特定的布局规则堆砌而成的。那么今天,我就带你一起学习一下,在Flutter中,搭建出一个漂亮的布局,我们需要了解哪些布局规则,以及这些规则与其他平台类似概念的差别在哪里。希望这样的设计,可以帮助你站在已有经验的基础上去高效学习Flutter的布局规则。 + +我们已经知道,在Flutter中一切皆Widget,那么布局也不例外。但与基本控件元素不同,布局类的Widget并不会直接呈现视觉内容,而是作为承载其他子Widget的容器。 + +这些布局类的Widget,内部都会包含一个或多个子控件,并且都提供了摆放子控件的不同布局方式,可以实现子控件的对齐、嵌套、层叠和缩放等。而我们要做的就是,通过各种定制化的参数,将其内部的子Widget依照自己的布局规则放置在特定的位置上,最终形成一个漂亮的布局。 + +Flutter提供了31种布局Widget,对布局控件的划分非常详细,一些相同(或相似)的视觉效果可以通过多种布局控件实现,因此布局类型相比原生Android、iOS平台多了不少。比如,Android布局一般就只有FrameLayout、LinearLayout、RelativeLayout、GridLayout和TableLayout这5种,而iOS的布局更少,只有Frame布局和自动布局两种。 + +为了帮你建立起对布局类Widget的认知,了解基本布局类Widget的布局特点和用法,从而学以致用快速上手开发,在今天的这篇文章中,我特意挑选了几类在开发Flutter应用时,最常用也最有代表性的布局Widget,包括单子Widget布局、多子Widget布局、层叠Widget布局,与你展开介绍。 + +掌握了这些典型的Widget,你也就基本掌握了构建一个界面精美的App所需要的全部布局方式了。接下来,我们就先从单子Widget布局聊起吧。 + +单子Widget布局:Container、Padding与Center + +单子Widget布局类容器比较简单,一般用来对其唯一的子Widget进行样式包装,比如限制大小、添加背景色样式、内间距、旋转变换等。这一类布局Widget,包括Container、Padding与Center三种。 + +Container,是一种允许在其内部添加其他控件的控件,也是UI框架中的一个常见概念。 + +在Flutter中,Container本身可以单独作为控件存在(比如单独设置背景色、宽高),也可以作为其他控件的父级存在:Container可以定义布局过程中子Widget如何摆放,以及如何展示。与其他框架不同的是,Flutter的Container仅能包含一个子Widget。 + +所以,对于多个子Widget的布局场景,我们通常会这样处理:先用一个根Widget去包装这些子Widget,然后把这个根Widget放到Container中,再由Container设置它的对齐alignment、边距padding等基础属性和样式属性。 + +接下来,我通过一个示例,与你演示如何定义一个Container。 + +在这个示例中,我将一段较长的文字,包装在一个红色背景、圆角边框的、固定宽高的Container中,并分别设置了Container的外边距(距离其父Widget的边距)和内边距(距离其子Widget的边距): + +Container( + child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。'), + padding: EdgeInsets.all(18.0), // 内边距 + margin: EdgeInsets.all(44.0), // 外边距 + width: 180.0, + height:240, + alignment: Alignment.center, // 子Widget居中对齐 + decoration: BoxDecoration( //Container样式 + color: Colors.red, // 背景色 + borderRadius: BorderRadius.circular(10.0), // 圆角边框 + ), +) + + + + +图1 Container示例 + +如果我们只需要将子Widget设定间距,则可以使用另一个单子容器控件Padding进行内容填充: + +Padding( + padding: EdgeInsets.all(44.0), + child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。'), +); + + + + +图2 Padding示例 + +在需要设置内容间距时,我们可以通过EdgeInsets的不同构造函数,分别制定四个方向的不同补白方式,如均使用同样数值留白、只设置左留白或对称方向留白等。如果你想更深入地了解这部分内容,可以参考这个API文档。 + +接下来,我们再来看看单子Widget布局容器中另一个常用的容器Center。正如它的名字一样,Center会将其子Widget居中排列。 + +比如,我们可以把一个Text包在Center里,实现居中展示: + +Scaffold( + body: Center(child: Text("Hello")) // This trailing comma makes auto-formatting nicer for build methods. +); + + + + +图3 Center示例 + +需要注意的是,为了实现居中布局,Center所占据的空间一定要比其子Widget要大才行,这也是显而易见的:如果Center和其子Widget一样大,自然就不需要居中,也没空间居中了。因此Center通常会结合Container一起使用。 + +现在,我们结合Container,一起看看Center的具体使用方法吧。 + +Container( + child: Center(child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。')), + padding: EdgeInsets.all(18.0), // 内边距 + margin: EdgeInsets.all(44.0), // 外边距 + width: 180.0, + height:240, + decoration: BoxDecoration( //Container样式 + color: Colors.red, // 背景色 + borderRadius: BorderRadius.circular(10.0), // 圆角边框 + ), +); + + +可以看到,我们通过Center容器实现了Container容器中alignment: Alignment.center的效果。 + +事实上,为了达到这一效果,Container容器与Center容器底层都依赖了同一个容器Align,通过它实现子Widget的对齐方式。Align的使用也比较简单,如果你想深入了解的话,可以参考官方文档,这里我就不再过多介绍了。 + +接下来,我们再看看多子Widget布局的三种方式,即Row、Column与Expanded。 + +多子Widget布局:Row、Column与Expanded + +对于拥有多个子Widget的布局类容器而言,其布局行为无非就是两种规则的抽象:水平方向上应该如何布局、垂直方向上应该如何布局。 + +如同Android的LinearLayout、前端的Flex布局一样,Flutter中也有类似的概念,即将子Widget按行水平排列的Row,按列垂直排列的Column,以及负责分配这些子Widget在布局方向(行/列)中剩余空间的Expanded。 + +Row与Column的使用方法很简单,我们只需要将各个子Widget按序加入到children数组即可。在下面的代码中,我们把4个分别设置了不同的颜色和宽高的Container加到Row与Column中: + +//Row的用法示范 +Row( + children: [ + Container(color: Colors.yellow, width: 60, height: 80,), + Container(color: Colors.red, width: 100, height: 180,), + Container(color: Colors.black, width: 60, height: 80,), + Container(color: Colors.green, width: 60, height: 80,), + ], +); + +//Column的用法示范 +Column( + children: [ + Container(color: Colors.yellow, width: 60, height: 80,), + Container(color: Colors.red, width: 100, height: 180,), + Container(color: Colors.black, width: 60, height: 80,), + Container(color: Colors.green, width: 60, height: 80,), + ], +); + + + + +(a)Row示例 + + + +(b)Column示例 + +图4 Row与Column示例 + +可以看到,单纯使用Row和Column控件,在子Widget的尺寸较小时,无法将容器填满,视觉样式比较难看。对于这样的场景,我们可以通过Expanded控件,来制定分配规则填满容器的剩余空间。 + +比如,我们希望Row组件(或Column组件)中的绿色容器与黄色容器均分剩下的空间,于是就可以设置它们的弹性系数参数flex都为1,这两个Expanded会按照其flex的比例(即1:1)来分割剩余的Row横向(Column纵向)空间: + +Row( + children: [ + Expanded(flex: 1, child: Container(color: Colors.yellow, height: 60)), //设置了flex=1,因此宽度由Expanded来分配 + Container(color: Colors.red, width: 100, height: 180,), + Container(color: Colors.black, width: 60, height: 80,), + Expanded(flex: 1, child: Container(color: Colors.green,height: 60),)/设置了flex=1,因此宽度由Expanded来分配 + ], +); + + + + +图5 Expanded控件示例 + +于Row与Column而言,Flutter提供了依据坐标轴的布局对齐行为,即根据布局方向划分出主轴和纵轴:主轴,表示容器依次摆放子Widget的方向;纵轴,则是与主轴垂直的另一个方向。 + + + +图6 Row和Column控件的主轴与纵轴 + +我们可以根据主轴与纵轴,设置子Widget在这两个方向上的对齐规则mainAxisAlignment与crossAxisAlignment。比如,主轴方向start表示靠左对齐、center表示横向居中对齐、end表示靠右对齐、spaceEvenly表示按固定间距对齐;而纵轴方向start则表示靠上对齐、center表示纵向居中对齐、end表示靠下对齐。 + +下图展示了在Row中设置不同方向的对齐规则后的呈现效果: + + + +图7 Row的主轴对齐方式 + + + +图8 Row的纵轴对齐方式 + +Column的对齐方式也是类似的,我就不再过多展开了。 + +这里需要注意的是,对于主轴而言,Flutter默认是让父容器决定其长度,即尽可能大,类似Android中的match_parent。 + +在上面的例子中,Row的宽度为屏幕宽度,Column的高度为屏幕高度。主轴长度大于所有子Widget的总长度,意味着容器在主轴方向的空间比子Widget要大,这也是我们能通过主轴对齐方式设置子Widget布局效果的原因。 + +如果想让容器与子Widget在主轴上完全匹配,我们可以通过设置Row的mainAxisSize参数为MainAxisSize.min,由所有子Widget来决定主轴方向的容器长度,即主轴方向的长度尽可能小,类似Android中的wrap_content: + +Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, //由于容器与子Widget一样宽,因此这行设置排列间距的代码并未起作用 + mainAxisSize: MainAxisSize.min, //让容器宽度与所有子Widget的宽度一致 + children: [ + Container(color: Colors.yellow, width: 60, height: 80,), + Container(color: Colors.red, width: 100, height: 180,), + Container(color: Colors.black, width: 60, height: 80,), + Container(color: Colors.green, width: 60, height: 80,), + ], +) + + + + +图9 Row 的主轴大小 + +可以看到,我们设置了主轴大小为MainAxisSize.min之后,Row的宽度变得和其子Widget一样大,因此再设置主轴的对齐方式也就不起作用了。 + +层叠Widget布局:Stack与Positioned + +有些时候,我们需要让一个控件叠加在另一个控件的上面,比如在一张图片上放置一段文字,又或者是在图片的某个区域放置一个按钮。这时候,我们就需要用到层叠布局容器Stack了。 + +Stack容器与前端中的绝对定位、Android中的Frame布局非常类似,子Widget之间允许叠加,还可以根据父容器上、下、左、右四个角的位置来确定自己的位置。 + +Stack提供了层叠布局的容器,而Positioned则提供了设置子Widget位置的能力。接下来,我们就通过一个例子来看一下Stack和Positioned的具体用法吧。 + +在这个例子中,我先在Stack中放置了一块300_300的黄色画布,随后在(18,18)处放置了一个50_50的绿色控件,然后在(18,70)处放置了一个文本控件。 + +Stack( + children: [ + Container(color: Colors.yellow, width: 300, height: 300),//黄色容器 + Positioned( + left: 18.0, + top: 18.0, + child: Container(color: Colors.green, width: 50, height: 50),//叠加在黄色容器之上的绿色控件 + ), + Positioned( + left: 18.0, + top:70.0, + child: Text("Stack提供了层叠布局的容器"),//叠加在黄色容器之上的文本 + ) + ], +) + + +试着运行一下,可以看到,这三个子Widget都按照我们预定的规则叠加在一起了。 + + + +图10 Stack与Positioned容器示例 + +Stack控件允许其子Widget按照创建的先后顺序进行层叠摆放,而Positioned控件则用来控制这些子Widget的摆放位置。需要注意的是,Positioned控件只能在Stack中使用,在其他容器中使用会报错。 + +总结 + +Flutter的布局容器强大而丰富,可以将小型、单用途的基本视觉元素快速封装成控件。今天我选取了Flutter中最具代表性,也最常用的几类布局Widget,与你介绍了构建一个界面精美的App所需要的布局概念。 + +接下来,我们简单回顾一下今天的内容,以便加深理解与记忆: + +首先,我们认识了单子容器Container、Padding与Center。其中,Container内部提供了间距、背景样式等基础属性,为子Widget的摆放方式,及展现样式都提供了定制能力。而Padding与Center提供的功能,则正如其名一样简洁,就是对齐与居中。 + +然后,我们深入学习了多子Widget布局中的Row和Column,各子Widget间对齐的规则,以及容器自身扩充的规则,以及如何通过Expanded控件使用容器内部的剩余空间, + +最后,我们学习了层叠布局Stack,以及与之搭配使用的,定位子Widget位置的Positioned容器,你可以通过它们,实现多个控件堆放的布局效果。 + +通过今天的文章,相信你已经对如何搭建App的界面有了足够的知识储备,所以在下一篇文章中,我会通过一些实际的例子,带你认识在Flutter中,如何通过这些基本控件与布局规则,实现好看的界面。 + +思考题 + +最后,我给你留下一道思考题吧。 + +Row与Column自身的大小是如何决定的?当它们嵌套时,又会出现怎样的情况呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/15组合与自绘,我该选用何种方式自定义Widget?.md b/专栏/Flutter核心技术与实战/15组合与自绘,我该选用何种方式自定义Widget?.md new file mode 100644 index 0000000..a84c608 --- /dev/null +++ b/专栏/Flutter核心技术与实战/15组合与自绘,我该选用何种方式自定义Widget?.md @@ -0,0 +1,276 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 组合与自绘,我该选用何种方式自定义Widget? + 你好,我是陈航。 + +在上一次分享中,我们认识了Flutter中最常用也最经典的布局Widget,即单子容器Container、多子容器Row/Column,以及层叠容器Stack与Positioned,也学习了这些不同容器之间的摆放子Widget的布局规则,我们可以通过它们,来实现子控件的对齐、嵌套、层叠等,它们也是构建一个界面精美的App所必须的布局概念。 + +在实际开发中,我们会经常遇到一些复杂的UI需求,往往无法通过使用Flutter的基本Widget,通过设置其属性参数来满足。这个时候,我们就需要针对特定的场景自定义Widget了。 + +在Flutter中,自定义Widget与其他平台类似:可以使用基本Widget组装成一个高级别的Widget,也可以自己在画板上根据特殊需求来画界面。 + +接下来,我会分别与你介绍组合和自绘这两种自定义Widget的方式。 + +组装 + +使用组合的方式自定义Widget,即通过我们之前介绍的布局方式,摆放项目所需要的基础Widget,并在控件内部设置这些基础Widget的样式,从而组合成一个更高级的控件。 + +这种方式,对外暴露的接口比较少,减少了上层使用成本,但也因此增强了控件的复用性。在Flutter中,组合的思想始终贯穿在框架设计之中,这也是Flutter提供了如此丰富的控件库的原因之一。 + +比如,在新闻类应用中,我们经常需要将新闻Icon、标题、简介与日期组合成一个单独的控件,作为一个整体去响应用户的点击事件。面对这类需求,我们可以把现有的Image、Text及各类布局,组合成一个更高级的新闻Item控件,对外暴露设置model和点击回调的属性即可。 + +接下来,我通过一个例子为你说明如何通过组装去自定义控件。 + +下图是App Store的升级项UI示意图,图里的每一项,都有应用Icon、名称、更新日期、更新简介、应用版本、应用大小以及更新/打开按钮。可以看到,这里面的UI元素还是相对较多的,现在我们希望将升级项UI封装成一个单独的控件,节省使用成本,以及后续的维护成本。 + + + +图1 App Store 升级项UI + +在分析这个升级项UI的整体结构之前,我们先定义一个数据结构UpdateItemModel来存储升级信息。在这里为了方便讨论,我把所有的属性都定义为了字符串类型,你在实际使用中可以根据需要将属性定义得更规范(比如,将appDate定义为DateTime类型)。 + +class UpdateItemModel { + String appIcon;//App图标 + String appName;//App名称 + String appSize;//App大小 + String appDate;//App更新日期 + String appDescription;//App更新文案 + String appVersion;//App版本 + //构造函数语法糖,为属性赋值 + UpdateItemModel({this.appIcon, this.appName, this.appSize, this.appDate, this.appDescription, this.appVersion}); +} + + +接下来,我以Google Map为例,和你一起分析下这个升级项UI的整体结构。 + +按照子Widget的摆放方向,布局方式只有水平和垂直两种,因此我们也按照这两个维度对UI结构进行拆解。 + +按垂直方向,我们用绿色的框把这个UI拆解为上半部分与下半部分,如图2所示。下半部分比较简单,是两个文本控件的组合;上半部分稍微复杂一点,我们先将其包装为一个水平布局的Row控件。 + +接下来,我们再一起看看水平方向应该如何布局。 + + + +图2 升级项UI整体结构示意图 + +我们先把升级项的上半部分拆解成对应的UI元素: + + +左边的应用图标拆解为Image; +右边的按钮拆解为FlatButton; +中间部分是两个文本在垂直方向上的组合,因此拆解为Column,Column内部则是两个Text。 + + +拆解示意图,如下所示: + + + +图3 上半部分UI结构示意图 + +通过与拆解前的UI对比,你就会发现还有3个问题待解决:即控件间的边距如何设置、中间部分的伸缩(截断)规则又是怎样、图片圆角怎么实现。接下来,我们分别来看看。 + +Image、FlatButton,以及Column这三个控件,与父容器Row之间存在一定的间距,因此我们还需要在最左边的Image与最右边的FlatButton上包装一层Padding,用以留白填充。 + +另一方面,考虑到需要适配不同尺寸的屏幕,中间部分的两个文本应该是变长可伸缩的,但也不能无限制地伸缩,太长了还是需要截断的,否则就会挤压到右边按钮的固定空间了。 + +因此,我们需要在Column的外层用Expanded控件再包装一层,让Image与FlatButton之间的空间全留给Column。不过,通常情况下这两个文本并不能完全填满中间的空间,因此我们还需要设置对齐格式,按照垂直方向上居中,水平方向上居左的方式排列。 + +最后一项需要注意的是,升级项UI的App Icon是圆角的,但普通的Image并不支持圆角。这时,我们可以使用ClipRRect控件来解决这个问题。ClipRRect可以将其子Widget按照圆角矩形的规则进行裁剪,所以用ClipRRect将Image包装起来,就可以实现图片圆角的功能了。 + +下面的代码,就是控件上半部分的关键代码: + +Widget buildTopRow(BuildContext context) { + return Row(//Row控件,用来水平摆放子Widget + children: [ + Padding(//Paddng控件,用来设置Image控件边距 + padding: EdgeInsets.all(10),//上下左右边距均为10 + child: ClipRRect(//圆角矩形裁剪控件 + borderRadius: BorderRadius.circular(8.0),//圆角半径为8 + child: Image.asset(model.appIcon, width: 80,height:80)图片控件// + ) + ), + Expanded(//Expanded控件,用来拉伸中间区域 + child: Column(//Column控件,用来垂直摆放子Widget + mainAxisAlignment: MainAxisAlignment.center,//垂直方向居中对齐 + crossAxisAlignment: CrossAxisAlignment.start,//水平方向居左对齐 + children: [ + Text(model.appName,maxLines: 1),//App名字 + Text(model.appDate,maxLines: 1),//App更新日期 + ], + ), + ), + Padding(//Paddng控件,用来设置Widget间边距 + padding: EdgeInsets.fromLTRB(0,0,10,0),//右边距为10,其余均为0 + child: FlatButton(//按钮控件 + child: Text("OPEN"), + onPressed: onPressed,//点击回调 + ) + ) + ]); +} + + +升级项UI的下半部分比较简单,是两个文本控件的组合。与上半部分的拆解类似,我们用一个Column控件将它俩装起来,如图4所示: + + + +图4 下半部分UI结构示意图 + +与上半部分类似,这两个文本与父容器之间存在些间距,因此在Column的最外层还需要用Padding控件给包装起来,设置父容器间距。 + +另一方面,Column的两个文本控件间也存在间距,因此我们仍然使用Padding控件将下面的文本包装起来,单独设置这两个文本之间的间距。 + +同样地,通常情况下这两个文本并不能完全填满下部空间,因此我们还需要设置对齐格式,即按照水平方向上居左的方式对齐。 + +控件下半部分的关键代码如下所示: + +Widget buildBottomRow(BuildContext context) { + return Padding(//Padding控件用来设置整体边距 + padding: EdgeInsets.fromLTRB(15,0,15,0),//左边距和右边距为15 + child: Column(//Column控件用来垂直摆放子Widget + crossAxisAlignment: CrossAxisAlignment.start,//水平方向距左对齐 + children: [ + Text(model.appDescription),//更新文案 + Padding(//Padding控件用来设置边距 + padding: EdgeInsets.fromLTRB(0,10,0,0),//上边距为10 + child: Text("${model.appVersion} • ${model.appSize} MB") + ) + ] + )); +} + + +最后,我们将上下两部分控件通过Column包装起来,这次升级项UI定制就完成了: + +class UpdatedItem extends StatelessWidget { + final UpdatedItemModel model;//数据模型 + //构造函数语法糖,用来给model赋值 + UpdatedItem({Key key,this.model, this.onPressed}) : super(key: key); + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Column(//用Column将上下两部分合体 + children: [ + buildTopRow(context),//上半部分 + buildBottomRow(context)//下半部分 + ]); + } + Widget buildBottomRow(BuildContext context) {...} + Widget buildTopRow(BuildContext context) {...} +} + + +试着运行一下,效果如下所示: + + + +图5 升级项UI运行示例 + +搞定! + +按照从上到下、从左到右去拆解UI的布局结构,把复杂的UI分解成各个小UI元素,在以组装的方式去自定义UI中非常有用,请一定记住这样的拆解方法。 + +自绘 + +Flutter提供了非常丰富的控件和布局方式,使得我们可以通过组合去构建一个新的视图。但对于一些不规则的视图,用SDK提供的现有Widget组合可能无法实现,比如饼图,k线图等,这个时候我们就需要自己用画笔去绘制了。 + +在原生iOS和Android开发中,我们可以继承UIView/View,在drawRect/onDraw方法里进行绘制操作。其实,在Flutter中也有类似的方案,那就是CustomPaint。 + +CustomPaint是用以承接自绘控件的容器,并不负责真正的绘制。既然是绘制,那就需要用到画布与画笔。 + +在Flutter中,画布是Canvas,画笔则是Paint,而画成什么样子,则由定义了绘制逻辑的CustomPainter来控制。将CustomPainter设置给容器CustomPaint的painter属性,我们就完成了一个自绘控件的封装。 + +对于画笔Paint,我们可以配置它的各种属性,比如颜色、样式、粗细等;而画布Canvas,则提供了各种常见的绘制方法,比如画线drawLine、画矩形drawRect、画点DrawPoint、画路径drawPath、画圆drawCircle、画圆弧drawArc等。 + +这样,我们就可以在CustomPainter的paint方法里,通过Canvas与Paint的配合,实现定制化的绘制逻辑。 + +接下来,我们看一个例子。 + +在下面的代码中,我们继承了CustomPainter,在定义了绘制逻辑的paint方法中,通过Canvas的drawArc方法,用6种不同颜色的画笔依次画了6个1/6圆弧,拼成了一张饼图。最后,我们使用CustomPaint容器,将painter进行封装,就完成了饼图控件Cake的定义。 + +class WheelPainter extends CustomPainter { + // 设置画笔颜色 + Paint getColoredPaint(Color color) {//根据颜色返回不同的画笔 + Paint paint = Paint();//生成画笔 + paint.color = color;//设置画笔颜色 + return paint; + } + + @override + void paint(Canvas canvas, Size size) {//绘制逻辑 + double wheelSize = min(size.width,size.height)/2;//饼图的尺寸 + double nbElem = 6;//分成6份 + double radius = (2 * pi) / nbElem;//1/6圆 + //包裹饼图这个圆形的矩形框 + Rect boundingRect = Rect.fromCircle(center: Offset(wheelSize, wheelSize), radius: wheelSize); + // 每次画1/6个圆弧 + canvas.drawArc(boundingRect, 0, radius, true, getColoredPaint(Colors.orange)); + canvas.drawArc(boundingRect, radius, radius, true, getColoredPaint(Colors.black38)); + canvas.drawArc(boundingRect, radius * 2, radius, true, getColoredPaint(Colors.green)); + canvas.drawArc(boundingRect, radius * 3, radius, true, getColoredPaint(Colors.red)); + canvas.drawArc(boundingRect, radius * 4, radius, true, getColoredPaint(Colors.blue)); + canvas.drawArc(boundingRect, radius * 5, radius, true, getColoredPaint(Colors.pink)); + } + // 判断是否需要重绘,这里我们简单的做下比较即可 + @override + bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this; +} +//将饼图包装成一个新的控件 +class Cake extends StatelessWidget { + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size(200, 200), + painter: WheelPainter(), + ); + } +} + + +试着运行一下,效果如下所示: + + + +图6 自绘控件示例 + +可以看到,使用CustomPainter进行自绘控件并不算复杂。这里,我建议你试着用画笔和画布,去实现更丰富的功能。 + +在实现视觉需求上,自绘需要自己亲自处理绘制逻辑,而组合则是通过子Widget的拼接来实现绘制意图。因此从渲染逻辑处理上,自绘方案可以进行深度的渲染定制,从而实现少数通过组合很难实现的需求(比如饼图、k线图)。不过,当视觉效果需要调整时,采用自绘的方案可能需要大量修改绘制代码,而组合方案则相对简单:只要布局拆分设计合理,可以通过更换子Widget类型来轻松搞定。 + +总结 + +在面对一些复杂的UI视图时,Flutter提供的单一功能类控件往往不能直接满足我们的需求。于是,我们需要自定义Widget。Flutter提供了组装与自绘两种自定义Widget的方式,来满足我们对视图的自定义需求。 + +以组装的方式构建UI,我们需要将目标视图分解成各个UI小元素。通常,我们可以按照从上到下、从左到右的布局顺序去对控件层次结构进行拆解,将基本视觉元素封装到Column、Row中。对于有着固定间距的视觉元素,我们可以通过Padding对其进行包装,而对于大小伸缩可变的视觉元素,我们可以通过Expanded控件让其填充父容器的空白区域。 + +而以自绘的方式定义控件,则需要借助于CustomPaint容器,以及最终承接真实绘制逻辑的CustomPainter。CustomPainter是绘制逻辑的封装,在其paint方法中,我们可以使用不同类型的画笔Paint,利用画布Canvas提供的不同类型的绘制图形能力,实现控件自定义绘制。 + +无论是组合还是自绘,在自定义UI时,有了目标视图整体印象后,我们首先需要考虑的事情应该是如何将它化繁为简,把视觉元素拆解细分,变成自己立即可以着手去实现的一个小控件,然后再思考如何将这些小控件串联起来。把大问题拆成小问题后,实现目标也逐渐清晰,落地方案就自然浮出水面了。 + +这其实就和我们学习新知识的过程是一样的,在对整体知识概念有了初步认知之后,也需要具备将复杂的知识化繁为简的能力:先理清楚其逻辑脉络,然后再把不懂的知识拆成小点,最后逐个攻破。 + +我把今天分享讲的两个例子放到了GitHub上,你可以下载后在工程中实际运行,并对照着今天的知识点进行学习,体会在不同场景下,组合和自绘这两种自定义Widget的具体使用方法。 + +思考题 + +最后,我给你留下两道作业题吧。 + + +请扩展UpdatedItem控件,使其能自动折叠过长的更新文案,并能支持点击后展开的功能。 + + + + + +请扩展Cake控件,使其能够根据传入的double数组(最多10个元素)中数值的大小,定义饼图的圆弧大小。 + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/16从夜间模式说起,如何定制不同风格的App主题?.md b/专栏/Flutter核心技术与实战/16从夜间模式说起,如何定制不同风格的App主题?.md new file mode 100644 index 0000000..0ff3e94 --- /dev/null +++ b/专栏/Flutter核心技术与实战/16从夜间模式说起,如何定制不同风格的App主题?.md @@ -0,0 +1,200 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 从夜间模式说起,如何定制不同风格的App主题? + 你好,我是陈航。今天,我和你分享的主题是,从夜间模式说起,如何定制不同风格的App主题。 + +在上一篇文章中,我与你介绍了组装与自绘这两种自定义Widget的方式。对于组装,我们按照从上到下、从左到右的布局顺序去分解目标视图,将基本的Widget封装到Column、Row中,从而合成更高级别的Widget;而对于自绘,我们则通过承载绘制逻辑的载体CustomPainter,在其paint方法中使用画笔Paint与画布Canvas,绘制不同风格、不同类型的图形,从而实现基于自绘的自定义组件。 + +对于一个产品来说,在业务早期其实更多的是处理基本功能有和无的问题:工程师来负责实现功能,PM负责功能好用不好用。在产品的基本功能已经完善,做到了六七十分的时候,再往上的如何做增长就需要运营来介入了。 + +在这其中,如何通过用户分层去实现App的个性化是常见的增长运营手段,而主题样式更换则是实现个性化中的一项重要技术手段。 + +比如,微博、UC浏览器和电子书客户端都提供了对夜间模式的支持,而淘宝、京东这样的电商类应用,还会在特定的电商活动日自动更新主题样式,就连现在的手机操作系统也提供了系统级切换展示样式的能力。 + +那么,这些在应用内切换样式的功能是如何实现的呢?在Flutter中,在普通的应用上增加切换主题的功能又要做哪些事情呢?这些问题,我都会在今天的这篇文章中与你详细分享。 + +主题定制 + +主题,又叫皮肤、配色,一般由颜色、图片、字号、字体等组成,我们可以把它看做是视觉效果在不同场景下的可视资源,以及相应的配置集合。比如,App的按钮,无论在什么场景下都需要背景图片资源、字体颜色、字号大小等,而所谓的主题切换只是在不同主题之间更新这些资源及配置集合而已。 + +因此在App开发中,我们通常不关心资源和配置的视觉效果好不好看,只要关心资源提供的视觉功能能不能用。比如,对于图片类资源,我们并不需要关心它渲染出来的实际效果,只需要确定它渲染出来是一张固定宽高尺寸的区域,不影响页面布局,能把业务流程跑通即可。 + +视觉效果是易变的,我们将这些变化的部分抽离出来,把提供不同视觉效果的资源和配置按照主题进行归类,整合到一个统一的中间层去管理,这样我们就能实现主题的管理和切换了。 + +在iOS中,我们通常会将主题的配置信息预先写到plist文件中,通过一个单例来控制App应该使用哪种配置;而Android的配置信息则写入各个style属性值的xml中,通过activity的setTheme进行切换;前端的处理方式也类似,简单更换css就可以实现多套主题/配色之间的切换。 + +Flutter也提供了类似的能力,由ThemeData来统一管理主题的配置信息。 + +ThemeData涵盖了Material Design规范的可自定义部分样式,比如应用明暗模式brightness、应用主色调primaryColor、应用次级色调accentColor、文本字体fontFamily、输入框光标颜色cursorColor等。如果你想深入了解ThemeData的其他API参数,可以参考官方文档ThemeData。 + +通过ThemeData来自定义应用主题,我们可以实现App全局范围,或是Widget局部范围的样式切换。接下来,我便分别与你讲述这两种范围的主题切换。 + +全局统一的视觉风格定制 + +在Flutter中,应用程序类MaterialApp的初始化方法,为我们提供了设置主题的能力。我们可以通过参数theme,选择改变App的主题色、字体等,设置界面在MaterialApp下的展示样式。 + +以下代码演示了如何设置App全局范围主题。在这段代码中,我们设置了App的明暗模式brightness为暗色、主色调为青色: + +MaterialApp( + title: 'Flutter Demo',//标题 + theme: ThemeData(//设置主题 + brightness: Brightness.dark,//明暗模式为暗色 + primaryColor: Colors.cyan,//主色调为青色 + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), +); + + +试着运行一下,效果如下: + + + +图1 Flutter全局模式主题 + +可以看到,虽然我们只修改了主色调和明暗模式两个参数,但按钮、文字颜色都随之调整了。这是因为默认情况下,ThemeData中很多其他次级视觉属性,都会受到主色调与明暗模式的影响。如果我们想要精确控制它们的展示样式,需要再细化一下主题配置。 + +下面的例子中,我们将icon的颜色调整为黄色,文字颜色调整为红色,按钮颜色调整为黑色: + +MaterialApp( + title: 'Flutter Demo',//标题 + theme: ThemeData(//设置主题 + brightness: Brightness.dark,//设置明暗模式为暗色 + accentColor: Colors.black,//(按钮)Widget前景色为黑色 + primaryColor: Colors.cyan,//主色调为青色 + iconTheme:IconThemeData(color: Colors.yellow),//设置icon主题色为黄色 + textTheme: TextTheme(body1: TextStyle(color: Colors.red))//设置文本颜色为红色 + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), +); + + +运行一下,可以看到图标、文字、按钮的颜色都随之更改了。 + + + +图2 Flutter全局模式主题示例2 + +局部独立的视觉风格定制 + +为整个App提供统一的视觉呈现效果固然很有必要,但有时我们希望为某个页面、或是某个区块设置不同于App风格的展现样式。以主题切换功能为例,我们希望为不同的主题提供不同的展示预览。 + +在Flutter中,我们可以使用Theme来对App的主题进行局部覆盖。Theme是一个单子Widget容器,与MaterialApp类似的,我们可以通过设置其data属性,对其子Widget进行样式定制: + + +如果我们不想继承任何App全局的颜色或字体样式,可以直接新建一个ThemeData实例,依次设置对应的样式; +而如果我们不想在局部重写所有的样式,则可以继承App的主题,使用copyWith方法,只更新部分样式。 + + +下面的代码演示了这两种方式的用法: + +// 新建主题 +Theme( + data: ThemeData(iconTheme: IconThemeData(color: Colors.red)), + child: Icon(Icons.favorite) +); + +// 继承主题 +Theme( + data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Colors.green)), + child: Icon(Icons.feedback) +); + + + + +图3 Theme局部主题更改示例 + +对于上述例子而言,由于Theme的子Widget只有一个Icon组件,因此这两种方式都可以实现覆盖全局主题,从而更改Icon样式的需求。而像这样使用局部主题覆盖全局主题的方式,在Flutter中是一种常见的自定义子Widget展示样式的方法。 + +除了定义Material Design规范中那些可自定义部分样式外,主题的另一个重要用途是样式复用。 + +比如,如果我们想为一段文字复用Materia Design规范中的title样式,或是为某个子Widget的背景色复用App的主题色,我们就可以通过Theme.of(context)方法,取出对应的属性,应用到这段文字的样式中。 + +Theme.of(context)方法将向上查找Widget树,并返回Widget树中最近的主题Theme。如果Widget的父Widget们有一个单独的主题定义,则使用该主题。如果不是,那就使用App全局主题。 + +在下面的例子中,我们创建了一个包装了一个Text组件的Container容器。在Text组件的样式定义中,我们复用了全局的title样式,而在Container的背景色定义中,则复用了App的主题色: + +Container( + color: Theme.of(context).primaryColor,//容器背景色复用应用主题色 + child: Text( + 'Text with a background color', + style: Theme.of(context).textTheme.title,//Text组件文本样式复用应用文本样式 + )); + + + + +图4 主题复用示例 + +分平台主题定制 + +有时候,为了满足不同平台的用户需求,我们希望针对特定的平台设置不同的样式。比如,在iOS平台上设置浅色主题,在Android平台上设置深色主题。面对这样的需求,我们可以根据defaultTargetPlatform来判断当前应用所运行的平台,从而根据系统类型来设置对应的主题。 + +在下面的例子中,我们为iOS与Android分别创建了两个主题。在MaterialApp的初始化方法中,我们根据平台类型,设置了不同的主题: + +// iOS浅色主题 +final ThemeData kIOSTheme = ThemeData( + brightness: Brightness.light,//亮色主题 + accentColor: Colors.white,//(按钮)Widget前景色为白色 + primaryColor: Colors.blue,//主题色为蓝色 + iconTheme:IconThemeData(color: Colors.grey),//icon主题为灰色 + textTheme: TextTheme(body1: TextStyle(color: Colors.black))//文本主题为黑色 +); +// Android深色主题 +final ThemeData kAndroidTheme = ThemeData( + brightness: Brightness.dark,//深色主题 + accentColor: Colors.black,//(按钮)Widget前景色为黑色 + primaryColor: Colors.cyan,//主题色Wie青色 + iconTheme:IconThemeData(color: Colors.blue),//icon主题色为蓝色 + textTheme: TextTheme(body1: TextStyle(color: Colors.red))//文本主题色为红色 +); +// 应用初始化 +MaterialApp( + title: 'Flutter Demo', + theme: defaultTargetPlatform == TargetPlatform.iOS ? kIOSTheme : kAndroidTheme,//根据平台选择不同主题 + home: MyHomePage(title: 'Flutter Demo Home Page'), +); + + +试着运行一下: + + + +(a)iOS平台 + + + +(b)Android平台 + +图5 根据不同平台设置对应主题 + +当然,除了主题之外,你也可以用defaultTargetPlatform这个变量去实现一些其他需要判断平台的逻辑,比如在界面上使用更符合Android或iOS设计风格的组件。 + +总结 + +好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。 + +主题设置属于App开发的高级特性,归根结底其实是提供了一种视觉资源与视觉配置的管理机制。与其他平台类似,Flutter也提供了集中式管理主题的机制,可以在遵循Material Design规范的ThemeData中,定义那些可定制化的样式。 + +我们既可以通过设置MaterialApp全局主题实现应用整体视觉风格的统一,也可以通过Theme单子Widget容器使用局部主题覆盖全局主题,实现局部独立的视觉风格。 + +除此之外,在自定义组件过程中,我们还可以使用Theme.of方法取出主题对应的属性值,从而实现多种组件在视觉风格上的复用。 + +最后,面对常见的分平台设置主题场景,我们可以根据defaultTargetPlatform,来精确识别当前应用所处的系统,从而配置对应的主题。 + +思考题 + +最后,我给你留下一个课后小作业吧。 + +在上一篇文章中,我与你介绍了如何实现App Store升级项UI自定义组件布局。现在,请在这个自定义Widget的基础上,增加切换夜间模式的功能。 + + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/17依赖管理(一):图片、配置和字体在Flutter中怎么用?.md b/专栏/Flutter核心技术与实战/17依赖管理(一):图片、配置和字体在Flutter中怎么用?.md new file mode 100644 index 0000000..a2f6fc5 --- /dev/null +++ b/专栏/Flutter核心技术与实战/17依赖管理(一):图片、配置和字体在Flutter中怎么用?.md @@ -0,0 +1,208 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 依赖管理(一):图片、配置和字体在Flutter中怎么用? + 你好,我是陈航。 + +在上一篇文章中,我与你介绍了Flutter的主题设置,也就是将视觉资源与视觉配置进行集中管理的机制。 + +Flutter提供了遵循Material Design规范的ThemeData,可以对样式进行定制化:既可以初始化App时实现全局整体视觉风格统一,也可以在使用单子Widget容器Theme实现局部主题的覆盖,还可以在自定义组件时取出主题对应的属性值,实现视觉风格的复用。 + +一个应用程序主要由两部分内容组成:代码和资源。代码关注逻辑功能,而如图片、字符串、字体、配置文件等资源则关注视觉功能。如果说上一次文章更多的是从逻辑层面分享应该如何管理资源的配置,那今天的分享则会从物理存储入手与你介绍Flutter整体的资源管理机制。 + +资源外部化,即把代码与资源分离,是现代UI框架的主流设计理念。因为这样不仅有利于单独维护资源,还可以对特定设备提供更准确的兼容性支持,使得我们的应用程序可以自动根据实际运行环境来组织视觉功能,适应不同的屏幕大小和密度等。 + +随着各类配置各异的终端设备越来越多,资源管理也越来越重要。那么今天,我们就先看看Flutter中的图片、配置和字体的管理机制吧。 + +资源管理 + +在移动开发中,常见的资源类型包括JSON文件、配置文件、图标、图片以及字体文件等。它们都会被打包到App安装包中,而App中的代码可以在运行时访问这些资源。 + +在Android、iOS平台中,为了区分不同分辨率的手机设备,图片和其他原始资源是区别对待的: + + +iOS使用Images.xcassets来管理图片,其他的资源直接拖进工程项目即可; +Android的资源管理粒度则更为细致,使用以drawable+分辨率命名的文件夹来分别存放不同分辨率的图片,其他类型的资源也都有各自的存放方式,比如布局文件放在res/layout目录下,资源描述文件放在res/values目录下,原始文件放在assets目录下等。 + + +而在Flutter中,资源管理则简单得多:资源(assets)可以是任意类型的文件,比如JSON配置文件或是字体文件等,而不仅仅是图片。 + +而关于资源的存放位置,Flutter并没有像Android那样预先定义资源的目录结构,所以我们可以把资源存放在项目中的任意目录下,只需要使用根目录下的pubspec.yaml文件,对这些资源的所在位置进行显式声明就可以了,以帮助Flutter识别出这些资源。 + +而在指定路径名的过程中,我们既可以对每一个文件进行挨个指定,也可以采用子目录批量指定的方式。 + +接下来,我以一个示例和你说明挨个指定和批量指定这两种方式的区别。 + +如下所示,我们将资源放入assets目录下,其中,两张图片background.jpg、loading.gif与JSON文件result.json在assets根目录,而另一张图片food_icon.jpg则在assets的子目录icons下。 + +assets +├── background.jpg +├── icons +│ └── food_icon.jpg +├── loading.gif +└── result.json + + +对于上述资源文件存放的目录结构,以下代码分别演示了挨个指定和子目录批量指定这两种方式:通过单个文件声明的,我们需要完整展开资源的相对路径;而对于目录批量指定的方式,只需要在目录名后加路径分隔符就可以了: + +flutter: + assets: + - assets/background.jpg #挨个指定资源路径 + - assets/loading.gif #挨个指定资源路径 + - assets/result.json #挨个指定资源路径 + - assets/icons/ #子目录批量指定 + - assets/ #根目录也是可以批量指定的 + + +需要注意的是,目录批量指定并不递归,只有在该目录下的文件才可以被包括,如果下面还有子目录的话,需要单独声明子目录下的文件。 + +完成资源的声明后,我们就可以在代码中访问它们了。在Flutter中,对不同类型的资源文件处理方式略有差异,接下来我将分别与你介绍。 + +对于图片类资源的访问,我们可以使用Image.asset构造方法完成图片资源的加载及显示,在第12篇文章“经典控件(一):文本、图片和按钮在Flutter中怎么用?”中,你应该已经了解了具体的用法,这里我就不再赘述了。 + +而对于其他资源文件的加载,我们可以通过Flutter应用的主资源Bundle对象rootBundle,来直接访问。 + +对于字符串文件资源,我们使用loadString方法;而对于二进制文件资源,则通过load方法。 + +以下代码演示了获取result.json文件,并将其打印的过程: + +rootBundle.loadString('assets/result.json').then((msg)=>print(msg)); + + +与Android、iOS开发类似,Flutter也遵循了基于像素密度的管理方式,如1.0x、2.0x、3.0x或其他任意倍数,Flutter可以根据当前设备分辨率加载最接近设备像素比例的图片资源。而为了让Flutter更好地识别,我们的资源目录应该将1.0x、2.0x与3.0x的图片资源分开管理。 + +以background.jpg图片为例,这张图片位于assets目录下。如果想让Flutter适配不同的分辨率,我们需要将其他分辨率的图片放到对应的分辨率子目录中,如下所示: + +assets +├── background.jpg //1.0x图 +├── 2.0x +│ └── background.jpg //2.0x图 +└── 3.0x + └── background.jpg //3.0x图 + + +而在pubspec.yaml文件声明这个图片资源时,仅声明1.0x图资源即可: + +flutter: + assets: + - assets/background.jpg #1.0x图资源 + + +1.0x分辨率的图片是资源标识符,而Flutter则会根据实际屏幕像素比例加载相应分辨率的图片。这时,如果主资源缺少某个分辨率资源,Flutter会在剩余的分辨率资源中选择最接近的分辨率资源去加载。 + +举个例子,如果我们的App包只包括了2.0x资源,对于屏幕像素比为3.0的设备,则会自动降级读取2.0x的资源。不过需要注意的是,即使我们的App包没有包含1.0x资源,我们仍然需要像上面那样在pubspec.yaml中将它显示地声明出来,因为它是资源的标识符。 + +字体则是另外一类较为常用的资源。手机操作系统一般只有默认的几种字体,在大部分情况下可以满足我们的正常需求。但是,在一些特殊的情况下,我们可能需要使用自定义字体来提升视觉体验。 + +在Flutter中,使用自定义字体同样需要在pubspec.yaml文件中提前声明。需要注意的是,字体实际上是字符图形的映射。所以,除了正常字体文件外,如果你的应用需要支持粗体和斜体,同样也需要有对应的粗体和斜体字体文件。 + +在将RobotoCondensed字体摆放至assets目录下的fonts子目录后,下面的代码演示了如何将支持斜体与粗体的RobotoCondensed字体加到我们的应用中: + +fonts: + - family: RobotoCondensed #字体名字 + fonts: + - asset: assets/fonts/RobotoCondensed-Regular.ttf #普通字体 + - asset: assets/fonts/RobotoCondensed-Italic.ttf + style: italic #斜体 + - asset: assets/fonts/RobotoCondensed-Bold.ttf + weight: 700 #粗体 + + +这些声明其实都对应着TextStyle中的样式属性,如字体名family对应着 fontFamily属性、斜体italic与正常normal对应着style属性、字体粗细weight对应着fontWeight属性等。在使用时,我们只需要在TextStyle中指定对应的字体即可: + +Text("This is RobotoCondensed", style: TextStyle( + fontFamily: 'RobotoCondensed',//普通字体 +)); +Text("This is RobotoCondensed", style: TextStyle( + fontFamily: 'RobotoCondensed', + fontWeight: FontWeight.w700, //粗体 +)); +Text("This is RobotoCondensed italic", style: TextStyle( + fontFamily: 'RobotoCondensed', + fontStyle: FontStyle.italic, //斜体 +)); + + + + +图1 自定义字体 + +原生平台的资源设置 + +在前面的第5篇文章“从标准模板入手,体会Flutter代码是如何运行在原生系统上的”中,我与你介绍了Flutter应用,实际上最终会以原生工程的方式打包运行在Android和iOS平台上,因此Flutter启动时依赖的是原生Android和iOS的运行环境。 + +上面介绍的资源管理机制其实都是在Flutter应用内的,而在Flutter框架运行之前,我们是没有办法访问这些资源的。Flutter需要原生环境才能运行,但是有些资源我们需要在Flutter框架运行之前提前使用,比如要给应用添加图标,或是希望在等待Flutter框架启动时添加启动图,我们就需要在对应的原生工程中完成相应的配置,所以下面介绍的操作步骤都是在原生系统中完成的。 + +我们先看一下如何更换App启动图标。 + +对于Android平台,启动图标位于根目录android/app/src/main/res/mipmap下。我们只需要遵守对应的像素密度标准,保留原始图标名称,将图标更换为目标资源即可: + + + +图2 更换Android启动图标 + +对于iOS平台,启动图位于根目录ios/Runner/Assets.xcassets/AppIcon.appiconset下。同样地,我们只需要遵守对应的像素密度标准,将其替换为目标资源并保留原始图标名称即可: + + + +图3 更换iOS启动图标 + +然后。我们来看一下如何更换启动图。 + +对于Android平台,启动图位于根目录android/app/src/main/res/drawable下,是一个名为launch_background的XML界面描述文件。 + + + +图4 修改Android启动图描述文件 + +我们可以在这个界面描述文件中自定义启动界面,也可以换一张启动图片。在下面的例子中,我们更换了一张居中显示的启动图片: + + + + + + + + + + + + +而对于iOS平台,启动图位于根目录ios/Runner/Assets.xcassets/LaunchImage.imageset下。我们保留原始启动图名称,将图片依次按照对应像素密度标准,更换为目标启动图即可。 + + + +图5 更换iOS启动图 + +总结 + +好了,今天的分享就到这里。我们简单回顾一下今天的内容。 + +将代码与资源分离,不仅有助于单独维护资源,还可以更精确地对特定设备提供兼容性支持。在Flutter中,资源可以是任意类型的文件,可以被放到任意目录下,但需要通过pubspec.yaml文件将它们的路径进行统一地显式声明。 + +Flutter对图片提供了基于像素密度的管理方式,我们需要将1.0x,2.0x与3.0x的资源分开管理,但只需要在pubspec.yaml中声明一次。如果应用中缺少对于高像素密度设备的资源支持,Flutter会进行自动降级。 + +对于字体这种基于字符图形映射的资源文件,Flutter提供了精细的管理机制,可以支持除了正常字体外,还支持粗体、斜体等样式。 + +最后,由于Flutter启动时依赖原生系统运行环境,因此我们还需要去原生工程中,设置相应的App启动图标和启动图。 + +思考题 + +最后,我给你留下两道思考题吧。 + + +如果我们只提供了1.0x与2.0x的资源图片,对于像素密度为3.0的设备,Flutter会自动降级到哪套资源? +如果我们只提供了2.0x的资源图片,对于像素密度为1.0的设备,Flutter会如何处理呢? + + +你可以参考原生平台的经验,在模拟器或真机上实验一下。 + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/18依赖管理(二):第三方组件库在Flutter中要如何管理?.md b/专栏/Flutter核心技术与实战/18依赖管理(二):第三方组件库在Flutter中要如何管理?.md new file mode 100644 index 0000000..b8a074c --- /dev/null +++ b/专栏/Flutter核心技术与实战/18依赖管理(二):第三方组件库在Flutter中要如何管理?.md @@ -0,0 +1,160 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 依赖管理(二):第三方组件库在Flutter中要如何管理? + 你好,我是陈航。 + +在上一篇文章中,我与你介绍了Flutter工程的资源管理机制。在Flutter中,资源采用先声明后使用的机制,在pubspec.yaml显式地声明资源路径后,才可以使用。 + +对于图片,Flutter基于像素密度,设立不同分辨率的目录分开管理,但只需要在pubspec.yaml声明一次;而字体则基于样式支持,除了正常字体,还可以支持粗体、斜体等样式。最后,由于Flutter需要原生运行环境,因此对于在其启动之前所需的启动图和图标这两类特殊资源,我们还需要分别去原生工程中进行相应的设置。 + +其实,除了管理这些资源外,pubspec.yaml更为重要的作用是管理Flutter工程代码的依赖,比如第三方库、Dart运行环境、Flutter SDK版本都可以通过它来进行统一管理。所以,pubspec.yaml与iOS中的Podfile、Android中的build.gradle、前端的package.json在功能上是类似的。 + +那么,今天这篇文章,我就主要与你分享,在Flutter中如何通过配置文件来管理工程代码依赖。 + +Pub + +Dart提供了包管理工具Pub,用来管理代码和资源。从本质上说,包(package)实际上就是一个包含了pubspec.yaml文件的目录,其内部可以包含代码、资源、脚本、测试和文档等文件。包中包含了需要被外部依赖的功能抽象,也可以依赖其他包。 + +与Android中的JCenter/Maven、iOS中的CocoaPods、前端中的npm库类似,Dart提供了官方的包仓库Pub。通过Pub,我们可以很方便地查找到有用的第三方包。 + +当然,这并不意味着我们可以简单地拿别人的库来拼凑成一个应用程序。Dart提供包管理工具Pub的真正目的是,让你能够找到真正好用的、经过线上大量验证的库,复用他人的成果来缩短开发周期,提升软件质量。 + +在Dart中,库和应用都属于包。pubspec.yaml是包的配置文件,包含了包的元数据(比如,包的名称和版本)、运行环境(也就是Dart SDK与Fluter SDK版本)、外部依赖、内部配置(比如,资源管理)。 + +在下面的例子中,我们声明了一个flutter_app_example的应用配置文件,其版本为1.0,Dart运行环境支持2.1至3.0之间,依赖flutter和cupertino_icon: + +name: flutter_app_example #应用名称 +description: A new Flutter application. #应用描述 +version: 1.0.0 +#Dart运行环境区间 +environment: + sdk: ">=2.1.0 <3.0.0" +#Flutter依赖库 +dependencies: + flutter: + sdk: flutter + cupertino_icons: ">0.1.1" + + +运行环境和依赖库cupertino_icons冒号后面的部分是版本约束信息,由一组空格分隔的版本描述组成,可以支持指定版本、版本号区间,以及任意版本这三种版本约束方式。比如上面的例子中,cupertino_icons引用了大于0.1.1的版本。 + +需要注意的是,由于元数据与名称使用空格分隔,因此版本号中不能出现空格;同时又由于大于符号“>”也是YAML语法中的折叠换行符号,因此在指定版本范围的时候,必须使用引号, 比如”>=2.1.0 < 3.0.0”。 + +对于包,我们通常是指定版本区间,而很少直接指定特定版本,因为包升级变化很频繁,如果有其他的包直接或间接依赖这个包的其他版本时,就会经常发生冲突。 + +而对于运行环境,如果是团队多人协作的工程,建议将Dart与Flutter的SDK环境写死,统一团队的开发环境,避免因为跨SDK版本出现的API差异进而导致工程问题。 + +比如,在上面的示例中,我们可以将Dart SDK写死为2.3.0,Flutter SDK写死为1.2.1。 + +environment: + sdk: 2.3.0 + flutter: 1.2.1 + + +基于版本的方式引用第三方包,需要在其Pub上进行公开发布,我们可以访问https://pub.dev/来获取可用的第三方包。而对于不对外公开发布,或者目前处于开发调试阶段的包,我们需要设置数据源,使用本地路径或Git地址的方式进行包声明。 + +在下面的例子中,我们分别以路径依赖以及Git依赖的方式,声明了package1和package2这两个包: + +dependencies: + package1: + path: ../package1/ #路径依赖 + date_format: + git: + url: https://github.com/xxx/package2.git #git依赖 + + +在开发应用时,我们可以不写明具体的版本号,而是以区间的方式声明包的依赖;但对于一个程序而言,其运行时具体引用哪个版本的依赖包必须要确定下来。因此,除了管理第三方依赖,包管理工具Pub的另一个职责是,找出一组同时满足每个包版本约束的包版本。包版本一旦确定,接下来就是下载对应版本的包了。 + +对于dependencies中的不同数据源,Dart会使用不同的方式进行管理,最终会将远端的包全部下载到本地。比如,对于Git声明依赖的方式,Pub会clone Git仓库;对于版本号的方式,Pub则会从pub.dartlang.org下载包。如果包还有其他的依赖包,比如package1包还依赖package3包,Pub也会一并下载。 + +然后,在完成了所有依赖包的下载后,Pub会在应用的根目录下创建.packages文件,将依赖的包名与系统缓存中的包文件路径进行映射,方便后续维护。 + +最后,Pub会自动创建pubspec.lock文件。pubspec.lock文件的作用类似iOS的Podfile.lock或前端的package-lock.json文件,用于记录当前状态下实际安装的各个直接依赖、间接依赖的包的具体来源和版本号。 + +比较活跃的第三方包的升级通常比较频繁,因此对于多人协作的Flutter应用来说,我们需要把pubspec.lock文件也一并提交到代码版本管理中,这样团队中的所有人在使用这个应用时安装的所有依赖都是完全一样的,以避免出现库函数找不到或者其他的依赖错误。 + +除了提供功能和代码维度的依赖之外,包还可以提供资源的依赖。在依赖包中的pubspec.yaml文件已经声明了同样资源的情况下,为节省应用程序安装包大小,我们需要复用依赖包中的资源。 + +在下面的例子中,我们的应用程序依赖了一个名为package4的包,而它的目录结构是这样的: + +pubspec.yaml +└──assets + ├──2.0x + │ └── placeholder.png + └──3.0x + └── placeholder.png + + +其中,placeholder.png是可复用资源。因此,在应用程序中,我们可以通过Image和AssetImage提供的package参数,根据设备实际分辨率去加载图像。 + +Image.asset('assets/placeholder.png', package: 'package4'); + +AssetImage('assets/placeholder.png', package: 'package4'); +例子 + + +例子 + +接下来,我们通过一个日期格式化的例子,来演示如何使用第三方库。 + +在Flutter中,提供了表达日期的数据结构DateTime,这个类拥有极大的表示范围,可以表达1970-01-01 UTC时间后 100,000,000天内的任意时刻。不过,如果我们想要格式化显示日期和时间,DateTime并没有提供非常方便的方法,我们不得不自己取出年、月、日、时、分、秒,来定制显示方式。 + +值得庆幸的是,我们可以通过date_format这个第三方包来实现我们的诉求:date_format提供了若干常用的日期格式化方法,可以很方便地实现格式化日期的功能。 + +首先,我们在Pub上找到date_format这个包,确定其使用说明: + + + +图1 date_format使用说明 + +date_format包最新的版本是1.0.6,于是接下来我们把date_format添加到pubspec.yaml中: + +dependencies: + date_format: 1.0.6 + + +随后,IDE(Android Studio)监测到了配置文件的改动,提醒我们进行安装包依赖更新。于是,我们点击Get dependencies,下载date_format : + + + +图2 下载安装包依赖 + +下载完成后,我们就可以在工程中使用date_format来进行日期的格式化了: + +print(formatDate(DateTime.now(), [mm, '月', dd, '日', hh, ':', n])); +//输出2019年06月30日01:56 +print(formatDate(DateTime.now(), [m, '月第', w, '周'])); +//输出6月第5周 + + +总结 + +好了,今天的分享就到这里。我们简单回顾一下今天的内容。 + +在Flutter中,资源与工程代码依赖属于包管理范畴,采用包的配置文件pubspec.yaml进行统一管理。 + +我们可以通过pubspec.yaml设置包的元数据(比如,包的名称和版本)、运行环境(比如,Dart SDK与Fluter SDK版本)、外部依赖和内部配置。 + +对于依赖的指定,可以以区间的方式确定版本兼容范围,也可以指定本地路径、Git、Pub这三种不同的数据源,包管理工具会找出同时满足每个依赖包版本约束的包版本,然后依次下载,并通过.packages文件建立下载缓存与包名的映射,最后统一将当前状态下,实际安装的各个包的具体来源和版本号记录至pubspec.lock文件。 + +现代编程语言大都自带第依赖管理机制,其核心功能是为工程中所有直接或间接依赖的代码库找到合适的版本,但这并不容易。就比如前端的依赖管理器npm的早期版本,就曾因为不太合理的算法设计,导致计算依赖耗时过长,依赖文件夹也高速膨胀,一度被开发者们戏称为“黑洞”。而Dart使用的Pub依赖管理机制所采用的PubGrub算法则解决了这些问题,因此被称为下一代版本依赖解决算法,在2018年底被苹果公司吸纳,成为Swift所采用的依赖管理器算法。 + +当然,如果你的工程里的依赖比较多,并且依赖关系比较复杂,即使再优秀的依赖解决算法也需要花费较长的时间才能计算出合适的依赖库版本。如果我们想减少依赖管理器为你寻找代码库依赖版本所耗费的时间,一个简单的做法就是从源头抓起,在pubspec.yaml文件中固定那些依赖关系复杂的第三方库们,及它们递归依赖的第三方库的版本号。 + +思考题 + +最后,我给你留下两道思考题吧。 + + +pubspec.yaml、.packages与pubspec.lock这三个文件,在包管理中的具体作用是什么? +.packages与pubspec.lock是否需要做代码版本管理呢?为什么? + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/19用户交互事件该如何响应?.md b/专栏/Flutter核心技术与实战/19用户交互事件该如何响应?.md new file mode 100644 index 0000000..53386ba --- /dev/null +++ b/专栏/Flutter核心技术与实战/19用户交互事件该如何响应?.md @@ -0,0 +1,218 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 用户交互事件该如何响应? + 你好,我是陈航。今天,我和你分享的主题是,如何响应用户交互事件。 + +在前面两篇文章中,我和你一起学习了Flutter依赖的包管理机制。在Flutter中,包是包含了外部依赖的功能抽象。对于资源和工程代码依赖,我们采用包配置文件pubspec.yaml进行统一管理。 + +通过前面几个章节的学习,我们已经掌握了如何在Flutter中通过内部实现和外部依赖去实现自定义UI,完善业务逻辑。但除了按钮和ListView这些动态的组件之外,我们还无法响应用户交互行为。那今天的分享中,我就着重与你讲述Flutter是如何监听和响应用户的手势操作的。 + +手势操作在Flutter中分为两类: + + +第一类是原始的指针事件(Pointer Event),即原生开发中常见的触摸事件,表示屏幕上触摸(或鼠标、手写笔)行为触发的位移行为; +第二类则是手势识别(Gesture Detector),表示多个原始指针事件的组合操作,如点击、双击、长按等,是指针事件的语义化封装。 + + +接下来,我们先看一下原始的指针事件。 + +指针事件 + +指针事件表示用户交互的原始触摸数据,如手指接触屏幕PointerDownEvent、手指在屏幕上移动PointerMoveEvent、手指抬起PointerUpEvent,以及触摸取消PointerCancelEvent,这与原生系统的底层触摸事件抽象是一致的。 + +在手指接触屏幕,触摸事件发起时,Flutter会确定手指与屏幕发生接触的位置上究竟有哪些组件,并将触摸事件交给最内层的组件去响应。与浏览器中的事件冒泡机制类似,事件会从这个最内层的组件开始,沿着组件树向根节点向上冒泡分发。 + +不过Flutter无法像浏览器冒泡那样取消或者停止事件进一步分发,我们只能通过hitTestBehavior去调整组件在命中测试期内应该如何表现,比如把触摸事件交给子组件,或者交给其视图层级之下的组件去响应。 + +关于组件层面的原始指针事件的监听,Flutter提供了Listener Widget,可以监听其子Widget的原始指针事件。 + +现在,我们一起看一个Listener的案例。我定义了一个宽度为300的红色正方形Container,利用Listener监听其内部Down、Move及Up事件: + +Listener( + child: Container( + color: Colors.red,//背景色红色 + width: 300, + height: 300, + ), + onPointerDown: (event) => print("down $event"),//手势按下回调 + onPointerMove: (event) => print("move $event"),//手势移动回调 + onPointerUp: (event) => print("up $event"),//手势抬起回调 +); + + +我们试着在红色正方形区域内进行触摸点击、移动、抬起,可以看到Listener监听到了一系列原始指针事件,并打印出了这些事件的位置信息: + +I/flutter (13829): up PointerUpEvent(Offset(97.7, 287.7)) +I/flutter (13829): down PointerDownEvent(Offset(150.8, 313.4)) +I/flutter (13829): move PointerMoveEvent(Offset(152.0, 313.4)) +I/flutter (13829): move PointerMoveEvent(Offset(154.6, 313.4)) +I/flutter (13829): up PointerUpEvent(Offset(157.1, 312.3)) + + +手势识别 + +使用Listener可以直接监听指针事件。不过指针事件毕竟太原始了,如果我们想要获取更多的触摸事件细节,比如判断用户是否正在拖拽控件,直接使用指针事件的话就会非常复杂。 + +通常情况下,响应用户交互行为的话,我们会使用封装了手势语义操作的Gesture,如点击onTap、双击onDoubleTap、长按onLongPress、拖拽onPanUpdate、缩放onScaleUpdate等。另外,Gesture可以支持同时分发多个手势交互行为,意味着我们可以通过Gesture同时监听多个事件。 + +Gesture是手势语义的抽象,而如果我们想从组件层监听手势,则需要使用GestureDetector。GestureDetector是一个处理各种高级用户触摸行为的Widget,与Listener一样,也是一个功能性组件。 + +接下来,我们通过一个案例来看看GestureDetector的用法。 + +我定义了一个Stack层叠布局,使用Positioned组件将1个红色的Container放置在左上角,并同时监听点击、双击、长按和拖拽事件。在拖拽事件的回调方法中,我们更新了Container的位置: + +//红色container坐标 +double _top = 0.0; +double _left = 0.0; +Stack(//使用Stack组件去叠加视图,便于直接控制视图坐标 + children: [ + Positioned( + top: _top, + left: _left, + child: GestureDetector(//手势识别 + child: Container(color: Colors.red,width: 50,height: 50),//红色子视图 + onTap: ()=>print("Tap"),//点击回调 + onDoubleTap: ()=>print("Double Tap"),//双击回调 + onLongPress: ()=>print("Long Press"),//长按回调 + onPanUpdate: (e) {//拖动回调 + setState(() { + //更新位置 + _left += e.delta.dx; + _top += e.delta.dy; + }); + }, + ), + ) + ], +); + + +运行这段代码,并查看控制台输出,可以看到,红色的Container除了可以响应我们的拖拽行为外,还能够同时响应点击、双击、长按这些事件。 + + + +图1 GestureDetector示例 + +尽管在上面的例子中,我们对一个Widget同时监听了多个手势事件,但最终只会有一个手势能够得到本次事件的处理权。对于多个手势的识别,Flutter引入了手势竞技场(Arena)的概念,用来识别究竟哪个手势可以响应用户事件。手势竞技场会考虑用户触摸屏幕的时长、位移以及拖动方向,来确定最终手势。 + +那手势竞技场具体是怎么实现的呢? + +实际上,GestureDetector内部对每一个手势都建立了一个工厂类(Gesture Factory)。而工厂类的内部会使用手势识别类(GestureRecognizer),来确定当前处理的手势。 + +而所有手势的工厂类都会被交给RawGestureDetector类,以完成监测手势的大量工作:使用Listener监听原始指针事件,并在状态改变时把信息同步给所有的手势识别器,然后这些手势会在竞技场决定最后由谁来响应用户事件。 + +有些时候我们可能会在应用中给多个视图注册同类型的手势监听器,比如微博的信息流列表中的微博,点击不同区域会有不同的响应:点击头像会进入用户个人主页,点击图片会进入查看大图页面,点击其他部分会进入微博详情页等。 + +像这样的手势识别发生在多个存在父子关系的视图时,手势竞技场会一并检查父视图和子视图的手势,并且通常最终会确认由子视图来响应事件。而这也是合乎常理的:从视觉效果上看,子视图的视图层级位于父视图之上,相当于对其进行了遮挡,因此从事件处理上看,子视图自然是事件响应的第一责任人。 + +在下面的示例中,我定义了两个嵌套的Container容器,分别加入了点击识别事件: + +GestureDetector( + onTap: () => print('Parent tapped'),//父视图的点击回调 + child: Container( + color: Colors.pinkAccent, + child: Center( + child: GestureDetector( + onTap: () => print('Child tapped'),//子视图的点击回调 + child: Container( + color: Colors.blueAccent, + width: 200.0, + height: 200.0, + ), + ), + ), + ), +); + + +运行这段代码,然后在蓝色区域进行点击,可以发现:尽管父容器也监听了点击事件,但Flutter只响应了子容器的点击事件。 + +I/flutter (16188): Child tapped + + + + +图2 父子嵌套GestureDetector示例 + +为了让父容器也能接收到手势,我们需要同时使用RawGestureDetector和GestureFactory,来改变竞技场决定由谁来响应用户事件的结果。 + +在此之前,我们还需要自定义一个手势识别器,让这个识别器在竞技场被PK失败时,能够再把自己重新添加回来,以便接下来还能继续去响应用户事件。 + +在下面的代码中,我定义了一个继承自点击手势识别器TapGestureRecognizer的类,并重写了其rejectGesture方法,手动地把自己又复活了: + +class MultipleTapGestureRecognizer extends TapGestureRecognizer { + @override + void rejectGesture(int pointer) { + acceptGesture(pointer); + } +} + + +接下来,我们需要将手势识别器和其工厂类传递给RawGestureDetector,以便用户产生手势交互事件时能够立刻找到对应的识别方法。事实上,RawGestureDetector的初始化函数所做的配置工作,就是定义不同手势识别器和其工厂类的映射关系。 + +这里,由于我们只需要处理点击事件,所以只配置一个识别器即可。工厂类的初始化采用GestureRecognizerFactoryWithHandlers函数完成,这个函数提供了手势识别对象创建,以及对应的初始化入口。 + +在下面的代码中,我们完成了自定义手势识别器的创建,并设置了点击事件回调方法。需要注意的是,由于我们只需要在父容器监听子容器的点击事件,所以只需要将父容器用RawGestureDetector包装起来就可以了,而子容器保持不变: + +RawGestureDetector(//自己构造父Widget的手势识别映射关系 + gestures: { + //建立多手势识别器与手势识别工厂类的映射关系,从而返回可以响应该手势的recognizer + MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + MultipleTapGestureRecognizer>( + () => MultipleTapGestureRecognizer(), + (MultipleTapGestureRecognizer instance) { + instance.onTap = () => print('parent tapped ');//点击回调 + }, + ) + }, + child: Container( + color: Colors.pinkAccent, + child: Center( + child: GestureDetector(//子视图可以继续使用GestureDetector + onTap: () => print('Child tapped'), + child: Container(...), + ), + ), + ), +); + + +运行一下这段代码,我们可以看到,当点击蓝色容器时,其父容器也收到了Tap事件。 + +I/flutter (16188): Child tapped +I/flutter (16188): parent tapped + + +总结 + +好了,今天的分享就到这里。我们来简单回顾下Flutter是如何响应用户事件的。 + +首先,我们了解了Flutter底层原始指针事件,以及对应的监听方式和冒泡分发机制。 + +然后,我们学习了封装了底层指针事件手势语义的Gesture,了解了多个手势的识别方法,以及其同时支持多个手势交互的能力。 + +最后,我与你介绍了Gesture的事件处理机制:在Flutter中,尽管我们可以对一个Widget监听多个手势,或是对多个Widget监听同一个手势,但Flutter会使用手势竞技场来进行各个手势的PK,以保证最终只会有一个手势能够响应用户行为。如果我们希望同时能有多个手势去响应用户行为,需要去自定义手势,利用RawGestureDetector和手势工厂类,在竞技场PK失败时,手动把它复活。 + +在处理多个手势识别场景,很容易出现手势冲突的问题。比如,当需要对图片进行点击、长按、旋转、缩放、拖动等操作的时候,如何识别用户当前是点击还是长按,是旋转还是缩放。如果想要精确地处理复杂交互手势,我们势必需要介入手势识别过程,解决异常。 + +不过需要注意的是,冲突的只是手势的语义化识别过程,原始指针事件是不会冲突的。所以,在遇到复杂的冲突场景通过手势很难搞定时,我们也可以通过Listener直接识别原始指针事件,从而解决手势识别的冲突。 + +我把今天分享所涉及到的事件处理demo放到了GitHub上,你可以下载下来自己运行,进一步巩固学习效果。 + +思考题 + +最后,我给你留下两个思考题吧。 + + +对于一个父容器中存在按钮FlatButton的界面,在父容器使用GestureDetector监听了onTap事件的情况下,如果我们点击按钮,父容器的点击事件会被识别吗,为什么? +如果监听的是onDoubleTap事件,在按钮上双击,父容器的双击事件会被识别吗,为什么? + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/20关于跨组件传递数据,你只需要记住这三招.md b/专栏/Flutter核心技术与实战/20关于跨组件传递数据,你只需要记住这三招.md new file mode 100644 index 0000000..7e47fc5 --- /dev/null +++ b/专栏/Flutter核心技术与实战/20关于跨组件传递数据,你只需要记住这三招.md @@ -0,0 +1,308 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 关于跨组件传递数据,你只需要记住这三招 + 你好,我是陈航。 + +在上一篇文章中,我带你一起学习了在Flutter中如何响应用户交互事件(手势)。手势处理在Flutter中分为两种:原始的指针事件处理和高级的手势识别。 + +其中,指针事件以冒泡机制分发,通过Listener完成监听;而手势识别则通过Gesture处理。但需要注意的是,虽然Flutter可以同时支持多个手势(包括一个Widget监听多个手势,或是多个Widget监听同一个手势),但最终只会有一个Widget的手势能够响应用户行为。为了改变这一点,我们需要自定义手势,修改手势竞技场对于多手势优先级判断的默认行为。 + +除了需要响应外部的事件之外,UI框架的另一个重要任务是,处理好各个组件之间的数据同步关系。尤其对于Flutter这样大量依靠组合Widget的行为来实现用户界面的框架来说,如何确保数据的改变能够映射到最终的视觉效果上就显得更为重要。所以,在今天这篇文章中,我就与你介绍在Flutter中如何进行跨组件数据传递。 + +在之前的分享中,通过组合嵌套的方式,利用数据对基础Widget的样式进行视觉属性定制,我们已经实现了多种界面布局。所以,你应该已经体会到了,在Flutter中实现跨组件数据传递的标准方式是通过属性传值。 + +但是,对于稍微复杂一点的、尤其视图层级比较深的UI样式,一个属性可能需要跨越很多层才能传递给子组件,这种传递方式就会导致中间很多并不需要这个属性的组件也需要接收其子Widget的数据,不仅繁琐而且冗余。 + +所以,对于数据的跨层传递,Flutter还提供了三种方案:InheritedWidget、Notification和EventBus。接下来,我将依次为你讲解这三种方案。 + +InheritedWidget + +InheritedWidget是Flutter中的一个功能型Widget,适用于在Widget树中共享数据的场景。通过它,我们可以高效地将数据在Widget树中进行跨层传递。 + +在前面的第16篇文章“从夜间模式说起,如何定制不同风格的App主题?”中,我与你介绍了如何通过Theme去访问当前界面的样式风格,从而进行样式复用的例子,比如Theme.of(context).primaryColor。 + +Theme类是通过InheritedWidget实现的典型案例。在子Widget中通过Theme.of方法找到上层Theme的Widget,获取到其属性的同时,建立子Widget和上层父Widget的观察者关系,当上层父Widget属性修改的时候,子Widget也会触发更新。 + +接下来,我就以Flutter工程模板中的计数器为例,与你说明InheritedWidget的使用方法。 + + +首先,为了使用InheritedWidget,我们定义了一个继承自它的新类CountContainer。 +然后,我们将计数器状态count属性放到CountContainer中,并提供了一个of方法方便其子Widget在Widget树中找到它。 +最后,我们重写了updateShouldNotify方法,这个方法会在Flutter判断InheritedWidget是否需要重建,从而通知下层观察者组件更新数据时被调用到。在这里,我们直接判断count是否相等即可。 + + +class CountContainer extends InheritedWidget { + //方便其子Widget在Widget树中找到它 + static CountContainer of(BuildContext context) => context.inheritFromWidgetOfExactType(CountContainer) as CountContainer; + + final int count; + + CountContainer({ + Key key, + @required this.count, + @required Widget child, + }): super(key: key, child: child); + + // 判断是否需要更新 + @override + bool updateShouldNotify(CountContainer oldWidget) => count != oldWidget.count; +} + + +然后,我们使用CountContainer作为根节点,并用0初始化count。随后在其子Widget Counter中,我们通过InheritedCountContainer.of方法找到它,获取计数状态count并展示: + +class _MyHomePageState extends State { + @override + Widget build(BuildContext context) { + //将CountContainer作为根节点,并使用0作为初始化count + return CountContainer( + count: 0, + child: Counter() + ); + } +} + +class Counter extends StatelessWidget { + @override + Widget build(BuildContext context) { + //获取InheritedWidget节点 + CountContainer state = CountContainer.of(context); + return Scaffold( + appBar: AppBar(title: Text("InheritedWidget demo")), + body: Text( + 'You have pushed the button this many times: ${state.count}', + ), + ); +} + + +运行一下,效果如下图所示: + + + +图1 InheritedWidget使用方法 + +可以看到InheritedWidget的使用方法还是比较简单的,无论Counter在CountContainer下层什么位置,都能获取到其父Widget的计数属性count,再也不用手动传递属性了。 + +不过,InheritedWidget仅提供了数据读的能力,如果我们想要修改它的数据,则需要把它和StatefulWidget中的State配套使用。我们需要把InheritedWidget中的数据和相关的数据修改方法,全部移到StatefulWidget中的State上,而InheritedWidget只需要保留对它们的引用。 + +我们对上面的代码稍加修改,删掉CountContainer中持有的count属性,增加对数据持有者State,以及数据修改方法的引用: + +class CountContainer extends InheritedWidget { + ... + final _MyHomePageState model;//直接使用MyHomePage中的State获取数据 + final Function() increment; + + CountContainer({ + Key key, + @required this.model, + @required this.increment, + @required Widget child, + }): super(key: key, child: child); + ... +} + + +然后,我们将count数据和其对应的修改方法放在了State中,仍然使用CountContainer作为根节点,完成了数据和修改方法的初始化。 + +在其子Widget Counter中,我们还是通过InheritedCountContainer.of方法找到它,将计数状态count与UI展示同步,将按钮的点击事件与数据修改同步: + +class _MyHomePageState extends State { + int count = 0; + void _incrementCounter() => setState(() {count++;});//修改计数器 + + @override + Widget build(BuildContext context) { + return CountContainer( + model: this,//将自身作为model交给CountContainer + increment: _incrementCounter,//提供修改数据的方法 + child:Counter() + ); + } +} + +class Counter extends StatelessWidget { + @override + Widget build(BuildContext context) { + //获取InheritedWidget节点 + CountContainer state = CountContainer.of(context); + return Scaffold( + ... + body: Text( + 'You have pushed the button this many times: ${state.model.count}', //关联数据读方法 + ), + floatingActionButton: FloatingActionButton(onPressed: state.increment), //关联数据修改方法 + ); + } +} + + +运行一下,可以看到,我们已经实现InheritedWidget数据的读写了。 + + + +图2 InheritedWidget数据修改示例 + +Notification + +Notification是Flutter中进行跨层数据共享的另一个重要的机制。如果说InheritedWidget的数据流动方式是从父Widget到子Widget逐层传递,那Notificaiton则恰恰相反,数据流动方式是从子Widget向上传递至父Widget。这样的数据传递机制适用于子Widget状态变更,发送通知上报的场景。 + +在前面的第13篇文章“经典控件(二):UITableView/ListView在Flutter中是什么?”中,我与你介绍了ScrollNotification的使用方法:ListView在滚动时会分发通知,我们可以在上层使用NotificationListener监听ScrollNotification,根据其状态做出相应的处理。 + +自定义通知的监听与ScrollNotification并无不同,而如果想要实现自定义通知,我们首先需要继承Notification类。Notification类提供了dispatch方法,可以让我们沿着context对应的Element节点树向上逐层发送通知。 + +接下来,我们一起看一个具体的案例吧。在下面的代码中,我们自定义了一个通知和子Widget。子Widget是一个按钮,在点击时会发送通知: + +class CustomNotification extends Notification { + CustomNotification(this.msg); + final String msg; +} + +//抽离出一个子Widget用来发通知 +class CustomChild extends StatelessWidget { + @override + Widget build(BuildContext context) { + return RaisedButton( + //按钮点击时分发通知 + onPressed: () => CustomNotification("Hi").dispatch(context), + child: Text("Fire Notification"), + ); + } +} + + +而在子Widget的父Widget中,我们监听了这个通知,一旦收到通知,就会触发界面刷新,展示收到的通知信息: + +class _MyHomePageState extends State { + String _msg = "通知:"; + @override + Widget build(BuildContext context) { + //监听通知 + return NotificationListener( + onNotification: (notification) { + setState(() {_msg += notification.msg+" ";});//收到子Widget通知,更新msg + }, + child:Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [Text(_msg),CustomChild()],//将子Widget加入到视图树中 + ) + ); + } +} + + +运行一下代码,可以看到,我们每次点击按钮之后,界面上都会出现最新的通知信息: + + + +图3 自定义Notification + +EventBus + +无论是InheritedWidget还是Notificaiton,它们的使用场景都需要依靠Widget树,也就意味着只能在有父子关系的Widget之间进行数据共享。但是,组件间数据传递还有一种常见场景:这些组件间不存在父子关系。这时,事件总线EventBus就登场了。 + +事件总线是在Flutter中实现跨组件通信的机制。它遵循发布/订阅模式,允许订阅者订阅事件,当发布者触发事件时,订阅者和发布者之间可以通过事件进行交互。发布者和订阅者之间无需有父子关系,甚至非Widget对象也可以发布/订阅。这些特点与其他平台的事件总线机制是类似的。 + +接下来,我们通过一个跨页面通信的例子,来看一下事件总线的具体使用方法。需要注意的是,EventBus是一个第三方插件,因此我们需要在pubspec.yaml文件中声明它: + +dependencies: + event_bus: 1.1.0 + + +EventBus的使用方式灵活,可以支持任意对象的传递。所以在这里,我们传输数据的载体就选择了一个有字符串属性的自定义事件类CustomEvent: + +class CustomEvent { + String msg; + CustomEvent(this.msg); +} + + +然后,我们定义了一个全局的eventBus对象,并在第一个页面监听了CustomEvent事件,一旦收到事件,就会刷新UI。需要注意的是,千万别忘了在State被销毁时清理掉事件注册,否则你会发现State永远被EventBus持有着,无法释放,从而造成内存泄漏: + +//建立公共的event bus +EventBus eventBus = new EventBus(); +//第一个页面 +class _FirstScreenState extends State { + + String msg = "通知:"; + StreamSubscription subscription; + @override + initState() { + //监听CustomEvent事件,刷新UI + subscription = eventBus.on().listen((event) { + setState(() {msg+= event.msg;});//更新msg + }); + super.initState(); + } + dispose() { + subscription.cancel();//State销毁时,清理注册 + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + body:Text(msg), + ... + ); + } +} + + +最后,我们在第二个页面以按钮点击回调的方式,触发了CustomEvent事件: + +class SecondScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return new Scaffold( + ... + body: RaisedButton( + child: Text('Fire Event'), + // 触发CustomEvent事件 + onPressed: ()=> eventBus.fire(CustomEvent("hello")) + ), + ); + } +} + + +运行一下,多点击几下第二个页面的按钮,然后返回查看第一个页面上的消息: + + + +图4 EventBus示例 + +可以看到,EventBus的使用方法还是比较简单的,使用限制也相对最少。 + +这里我准备了一张表格,把属性传值、InheritedWidget、Notification与EventBus这四种数据共享方式的特点和使用场景做了简单总结,供你参考: + + + +图5 属性传值、InheritedWidget、Notification与EventBus数据传递方式对比 + +总结 + +好了,今天的分享就到这里。我们来简单回顾下在Flutter中,如何实现跨组件的数据共享。 + +首先,我们认识了InheritedWidget。对于视图层级比较深的UI样式,直接通过属性传值的方式会导致很多中间层增加冗余属性,而使用InheritedWidget可以实现子Widget跨层共享父Widget的属性。需要注意的是,InheritedWidget中的属性在子Widget中只能读,如果有修改的场景,我们需要把它和StatefulWidget中的State配套使用。 + +然后,我们学习了Notification,这种由下到上传递数据的跨层共享机制。我们可以使用NotificationListener,在父Widget监听来自子Widget的事件。 + +最后,我与你介绍了EventBus,这种无需发布者与订阅者之间存在父子关系的数据同步机制。 + +我把今天分享所涉及到的三种跨组件的数据共享方式demo放到了GitHub,你可以下载下来自己运行,体会它们之间的共同点和差异。 + +思考题 + +最后,我来给你留下一个思考题吧。 + +请你分别概括属性传值、InheritedWidget、Notification与EventBus的优缺点。 + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/21路由与导航,Flutter是这样实现页面切换的.md b/专栏/Flutter核心技术与实战/21路由与导航,Flutter是这样实现页面切换的.md new file mode 100644 index 0000000..4ca8b7b --- /dev/null +++ b/专栏/Flutter核心技术与实战/21路由与导航,Flutter是这样实现页面切换的.md @@ -0,0 +1,225 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 路由与导航,Flutter是这样实现页面切换的 + 你好,我是陈航。 + +在上一篇文章中,我带你一起学习了如何在Flutter中实现跨组件数据传递。其中,InheritedWidget适用于子Widget跨层共享父Widget数据的场景,如果子Widget还需要修改父Widget数据,则需要和State一起配套使用。而Notification,则适用于父Widget监听子Widget事件的场景。对于没有父子关系的通信双方,我们还可以使用EventBus实现基于订阅/发布模式的机制实现数据交互。 + +如果说UI框架的视图元素的基本单位是组件,那应用程序的基本单位就是页面了。对于拥有多个页面的应用程序而言,如何从一个页面平滑地过渡到另一个页面,我们需要有一个统一的机制来管理页面之间的跳转,通常被称为路由管理或导航管理。 + +我们首先需要知道目标页面对象,在完成目标页面初始化后,用框架提供的方式打开它。比如,在Android/iOS中我们通常会初始化一个Intent或ViewController,通过startActivity或pushViewController来打开一个新的页面;而在React中,我们使用navigation来管理所有页面,只要知道页面的名称,就可以立即导航到这个页面。 + +其实,Flutter的路由管理也借鉴了这两种设计思路。那么,今天我们就来看看,如何在一个Flutter应用中管理不同页面的命名和过渡。 + +路由管理 + +在Flutter中,页面之间的跳转是通过Route和Navigator来管理的: + + +Route是页面的抽象,主要负责创建对应的界面,接收参数,响应Navigator打开和关闭; +而Navigator则会维护一个路由栈管理Route,Route打开即入栈,Route关闭即出栈,还可以直接替换栈内的某一个Route。 + + +而根据是否需要提前注册页面标识符,Flutter中的路由管理可以分为两种方式: + + +基本路由。无需提前注册,在页面切换时需要自己构造页面实例。 +命名路由。需要提前注册页面标识符,在页面切换时通过标识符直接打开新的路由。 + + +接下来,我们先一起看看基本路由这种管理方式吧。 + +基本路由 + +在Flutter中,基本路由的使用方法和Android/iOS打开新页面的方式非常相似。要导航到一个新的页面,我们需要创建一个MaterialPageRoute的实例,调用Navigator.push方法将新页面压到堆栈的顶部。 + +其中,MaterialPageRoute是一种路由模板,定义了路由创建及切换过渡动画的相关配置,可以针对不同平台,实现与平台页面切换动画风格一致的路由切换动画。 + +而如果我们想返回上一个页面,则需要调用Navigator.pop方法从堆栈中删除这个页面。 + +下面的代码演示了基本路由的使用方法:在第一个页面的按钮事件中打开第二个页面,并在第二个页面的按钮事件中回退到第一个页面: + +class FirstScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return RaisedButton( + //打开页面 + onPressed: ()=> Navigator.push(context, MaterialPageRoute(builder: (context) => SecondScreen())); + ); + } +} + +class SecondPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return RaisedButton( + // 回退页面 + onPressed: ()=> Navigator.pop(context) + ); + } +} + + +运行一下代码,效果如下: + + + +图1 基本路由示例 + +可以看到,基本路由的使用还是比较简单的。接下来,我们再看看命名路由的使用方法。 + +命名路由 + +基本路由使用方式相对简单灵活,适用于应用中页面不多的场景。而在应用中页面比较多的情况下,再使用基本路由方式,那么每次跳转到一个新的页面,我们都要手动创建MaterialPageRoute实例,初始化页面,然后调用push方法打开它,还是比较麻烦的。 + +所以,Flutter提供了另外一种方式来简化路由管理,即命名路由。我们给页面起一个名字,然后就可以直接通过页面名字打开它了。这种方式简单直观,与React中的navigation使用方式类似。 + +要想通过名字来指定页面切换,我们必须先给应用程序MaterialApp提供一个页面名称映射规则,即路由表routes,这样Flutter才知道名字与页面Widget的对应关系。 + +路由表实际上是一个Map,其中key值对应页面名字,而value值则是一个WidgetBuilder回调函数,我们需要在这个函数中创建对应的页面。而一旦在路由表中定义好了页面名字,我们就可以使用Navigator.pushNamed来打开页面了。 + +下面的代码演示了命名路由的使用方法:在MaterialApp完成了页面的名字second_page及页面的初始化方法注册绑定,后续我们就可以在代码中以second_page这个名字打开页面了: + +MaterialApp( + ... + //注册路由 + routes:{ + "second_page":(context)=>SecondPage(), + }, +); +//使用名字打开页面 +Navigator.pushNamed(context,"second_page"); + + +可以看到,命名路由的使用也很简单。 + +不过由于路由的注册和使用都采用字符串来标识,这就会带来一个隐患:如果我们打开了一个不存在的路由会怎么办? + +也许你会想到,我们可以约定使用字符串常量去定义、使用路由,但我们无法避免通过接口数据下发的错误路由标识符场景。面对这种情况,无论是直接报错或是不响应错误路由,都不是一个用户体验良好的解决办法。 + +更好的办法是,对用户进行友好的错误提示,比如跳转到一个统一的NotFoundScreen页面,也方便我们对这类错误进行统一收集、上报。 + +在注册路由表时,Flutter提供了UnknownRoute属性,我们可以对未知的路由标识符进行统一的页面跳转处理。 + +下面的代码演示了如何注册错误路由处理。和基本路由的使用方法类似,我们只需要返回一个固定的页面即可。 + +MaterialApp( + ... + //注册路由 + routes:{ + "second_page":(context)=>SecondPage(), + }, + //错误路由处理,统一返回UnknownPage + onUnknownRoute: (RouteSettings setting) => MaterialPageRoute(builder: (context) => UnknownPage()), +); + +//使用错误名字打开页面 +Navigator.pushNamed(context,"unknown_page"); + + +运行一下代码,可以看到,我们的应用不仅可以处理正确的页面路由标识,对错误的页面路由标识符也可以统一跳转到固定的错误处理页面了。 + + + +图2 命名路由示例 + +页面参数 + +与基本路由能够精确地控制目标页面初始化方式不同,命名路由只能通过字符串名字来初始化固定目标页面。为了解决不同场景下目标页面的初始化需求,Flutter提供了路由参数的机制,可以在打开路由时传递相关参数,在目标页面通过RouteSettings来获取页面参数。 + +下面的代码演示了如何传递并获取参数:使用页面名称second_page打开页面时,传递了一个字符串参数,随后在SecondPage中,我们取出了这个参数,并将它展示在了文本中。 + +//打开页面时传递字符串参数 +Navigator.of(context).pushNamed("second_page", arguments: "Hey"); + +class SecondPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + //取出路由参数 + String msg = ModalRoute.of(context).settings.arguments as String; + return Text(msg); + } +} + + +除了页面打开时需要传递参数,对于特定的页面,在其关闭时,也需要传递参数告知页面处理结果。 + +比如在电商场景下,我们会在用户把商品加入购物车时,打开登录页面让用户登录,而在登录操作完成之后,关闭登录页面返回到当前页面时,登录页面会告诉当前页面新的用户身份,当前页面则会用新的用户身份刷新页面。 + +与Android提供的startActivityForResult方法可以监听目标页面的处理结果类似,Flutter也提供了返回参数的机制。在push目标页面时,可以设置目标页面关闭时监听函数,以获取返回参数;而目标页面可以在关闭路由时传递相关参数。 + +下面的代码演示了如何获取参数:在SecondPage页面关闭时,传递了一个字符串参数,随后在上一页监听函数中,我们取出了这个参数,并将它展示了出来。 + +class SecondPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Text('Message from first screen: $msg'), + RaisedButton( + child: Text('back'), + //页面关闭时传递参数 + onPressed: ()=> Navigator.pop(context,"Hi") + ) + ] + )); + } +} + +class _FirstPageState extends State { + String _msg=''; + @override + Widget build(BuildContext context) { + return new Scaffold( + body: Column(children: [ + RaisedButton( + child: Text('命名路由(参数&回调)'), + //打开页面,并监听页面关闭时传递的参数 + onPressed: ()=> Navigator.pushNamed(context, "third_page",arguments: "Hey").then((msg)=>setState(()=>_msg=msg)), + ), + Text('Message from Second screen: $_msg'), + + ],), + ); + } +} + + +运行一下,可以看到在关闭SecondPage,重新回到FirstPage页面时,FirstPage把接收到的msg参数展示了出来: + + + +图3 页面路由参数 + +总结 + +好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。 + +Flutter提供了基本路由和命名路由两种方式,来管理页面间的跳转。其中,基本路由需要自己手动创建页面实例,通过Navigator.push完成页面跳转;而命名路由需要提前注册页面标识符和页面创建方法,通过Navigator.pushNamed传入标识符实现页面跳转。 + +对于命名路由,如果我们需要响应错误路由标识符,还需要一并注册UnknownRoute。为了精细化控制路由切换,Flutter提供了页面打开与页面关闭的参数机制,我们可以在页面创建和目标页面关闭时,取出相应的参数。 + +可以看到,关于路由导航,Flutter综合了Android、iOS和React的特点,简洁而不失强大。 + +而在中大型应用中,我们通常会使用命名路由来管理页面间的切换。命名路由的最重要作用,就是建立了字符串标识符与各个页面之间的映射关系,使得各个页面之间完全解耦,应用内页面的切换只需要通过一个字符串标识符就可以搞定,为后期模块化打好基础。 + +我把今天分享所涉及的的知识点打包到了GitHub上,你可以下载工程到本地,多运行几次,从而加深对基本路由、命名路由以及路由参数具体用法的印象。 + +思考题 + +最后,我给你留下两个小作业吧。 + + +对于基本路由,如何传递页面参数? +请实现一个计算页面,这个页面可以对前一个页面传入的2个数值参数进行求和,并在该页面关闭时告知上一页面计算的结果。 + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/22如何构造炫酷的动画效果?.md b/专栏/Flutter核心技术与实战/22如何构造炫酷的动画效果?.md new file mode 100644 index 0000000..ab20df9 --- /dev/null +++ b/专栏/Flutter核心技术与实战/22如何构造炫酷的动画效果?.md @@ -0,0 +1,299 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 如何构造炫酷的动画效果? + 你好,我是陈航。 + +在上一篇文章中,我带你一起学习了Flutter中实现页面路由的两种方式:基本路由与命名路由,即手动创建页面进行切换,和通过前置路由注册后提供标识符进行跳转。除此之外,Flutter还在这两种路由方式的基础上,支持页面打开和页面关闭传递参数,可以更精确地控制路由切换。 + +通过前面第12、13、14和15篇文章的学习,我们已经掌握了开发一款样式精美的小型App的基本技能。但当下,用户对于终端页面的要求已经不再满足于只能实现产品功能,除了样式美观之外,还希望交互良好、有趣、自然。 + +动画就是提升用户体验的一个重要方式,一个恰当的组件动画或者页面切换动画,不仅能够缓解用户因为等待而带来的情绪问题,还会增加好感。Flutter既然完全接管了渲染层,除了静态的页面布局之外,对组件动画的支持自然也不在话下。 + +因此在今天的这篇文章中,我会向你介绍Flutter中动画的实现方法,看看如何让我们的页面动起来。 + +Animation、AnimationController与Listener + +动画就是动起来的画面,是静态的画面根据事先定义好的规律,在一定时间内不断微调,产生变化效果。而动画实现由静止到动态,主要是靠人眼的视觉残留效应。所以,对动画系统而言,为了实现动画,它需要做三件事儿: + + +确定画面变化的规律; +根据这个规律,设定动画周期,启动动画; +定期获取当前动画的值,不断地微调、重绘画面。 + + +这三件事情对应到Flutter中,就是Animation、AnimationController与Listener: + + +Animation是Flutter动画库中的核心类,会根据预定规则,在单位时间内持续输出动画的当前状态。Animation知道当前动画的状态(比如,动画是否开始、停止、前进或者后退,以及动画的当前值),但却不知道这些状态究竟应用在哪个组件对象上。换句话说,Animation仅仅是用来提供动画数据,而不负责动画的渲染。 +AnimationController用于管理Animation,可以用来设置动画的时长、启动动画、暂停动画、反转动画等。 +Listener是Animation的回调函数,用来监听动画的进度变化,我们需要在这个回调函数中,根据动画的当前值重新渲染组件,实现动画的渲染。 + + +接下来,我们看一个具体的案例:让大屏幕中间的Flutter Logo由小变大。 + +首先,我们初始化了一个动画周期为1秒的、用于管理动画的AnimationController对象,并用线性变化的Tween创建了一个变化范围从50到200的Animaiton对象。 + +然后,我们给这个Animaiton对象设置了一个进度监听器,并在进度监听器中强制界面重绘,刷新动画状态。 + +接下来,我们调用AnimationController对象的forward方法,启动动画: + +class _AnimateAppState extends State with SingleTickerProviderStateMixin { + AnimationController controller; + Animation animation; + @override + void initState() { + super.initState(); + //创建动画周期为1秒的AnimationController对象 + controller = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1000)); + // 创建从50到200线性变化的Animation对象 + animation = Tween(begin: 50.0, end: 200.0).animate(controller) + ..addListener(() { + setState(() {}); //刷新界面 + }); + controller.forward(); //启动动画 + } +... +} + + +需要注意的是,我们在创建AnimationController的时候,设置了一个vsync属性。这个属性是用来防止出现不可见动画的。vsync对象会把动画绑定到一个Widget,当Widget不显示时,动画将会暂停,当Widget再次显示时,动画会重新恢复执行,这样就可以避免动画的组件不在当前屏幕时白白消耗资源。 + +我们在一开始提到,Animation只是用于提供动画数据,并不负责动画渲染,所以我们还需要在Widget的build方法中,把当前动画状态的值读出来,用于设置Flutter Logo容器的宽和高,才能最终实现动画效果: + +@override +@override +Widget build(BuildContext context) { + return MaterialApp( + home: Center( + child: Container( + width: animation.value, // 将动画的值赋给widget的宽高 + height: animation.value, + child: FlutterLogo() + ))); +} + + +最后,别忘了在页面销毁时,要释放动画资源: + +@override +void dispose() { + controller.dispose(); // 释放资源 + super.dispose(); +} + + +我们试着运行一下,可以看到,Flutter Logo动起来了: + + + +图1 动画示例 + +我们在上面用到的Tween默认是线性变化的,但可以创建CurvedAnimation来实现非线性曲线动画。CurvedAnimation提供了很多常用的曲线,比如震荡曲线elasticOut: + +//创建动画周期为1秒的AnimationController对象 +controller = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1000)); + +//创建一条震荡曲线 +final CurvedAnimation curve = CurvedAnimation( + parent: controller, curve: Curves.elasticOut); +// 创建从50到200跟随振荡曲线变化的Animation对象 +animation = Tween(begin: 50.0, end: 200.0).animate(curve) + + +运行一下,可以看到Flutter Logo有了一个弹性动画: + + + +图2 CurvedAnimation 示例 + +现在的问题是,这些动画只能执行一次。如果想让它像心跳一样执行,有两个办法: + + +在启动动画时,使用repeat(reverse: true),让动画来回重复执行。 +监听动画状态。在动画结束时,反向执行;在动画反向执行完毕时,重新启动执行。 + + +具体的实现代码,如下所示: + +//以下两段语句等价 +//第一段 +controller.repeat(reverse: true);//让动画重复执行 + +//第二段 +animation.addStatusListener((status) { + if (status == AnimationStatus.completed) { + controller.reverse();//动画结束时反向执行 + } else if (status == AnimationStatus.dismissed) { + controller.forward();//动画反向执行完毕时,重新执行 + } +}); +controller.forward();//启动动画 + + +运行一下,可以看到,我们实现了Flutter Logo的心跳效果。 + + + +图3 Flutter Logo心跳 + +AnimatedWidget与AnimatedBuilder + +在为Widget添加动画效果的过程中我们不难发现,Animation仅提供动画的数据,因此我们还需要监听动画执行进度,并在回调中使用setState强制刷新界面才能看到动画效果。考虑到这些步骤都是固定的,Flutter提供了两个类来帮我们简化这一步骤,即AnimatedWidget与AnimatedBuilder。 + +接下来,我们分别看看这两个类如何使用。 + +在构建Widget时,AnimatedWidget会将Animation的状态与其子Widget的视觉样式绑定。要使用AnimatedWidget,我们需要一个继承自它的新类,并接收Animation对象作为其初始化参数。然后,在build方法中,读取出Animation对象的当前值,用作初始化Widget的样式。 + +下面的案例演示了Flutter Logo的AnimatedWidget版本:用AnimatedLogo继承了AnimatedWidget,并在build方法中,把动画的值与容器的宽高做了绑定: + +class AnimatedLogo extends AnimatedWidget { + //AnimatedWidget需要在初始化时传入animation对象 + AnimatedLogo({Key key, Animation animation}) + : super(key: key, listenable: animation); + + Widget build(BuildContext context) { + //取出动画对象 + final Animation animation = listenable; + return Center( + child: Container( + height: animation.value,//根据动画对象的当前状态更新宽高 + width: animation.value, + child: FlutterLogo(), + )); + } +} + + +在使用时,我们只需把Animation对象传入AnimatedLogo即可,再也不用监听动画的执行进度刷新UI了: + +MaterialApp( + home: Scaffold( + body: AnimatedLogo(animation: animation)//初始化AnimatedWidget时传入animation对象 +)); + + +在上面的例子中,在AnimatedLogo的build方法中,我们使用Animation的value作为logo的宽和高。这样做对于简单组件的动画没有任何问题,但如果动画的组件比较复杂,一个更好的解决方案是,将动画和渲染职责分离:logo作为外部参数传入,只做显示;而尺寸的变化动画则由另一个类去管理。 + +这个分离工作,我们可以借助AnimatedBuilder来完成。 + +与AnimatedWidget类似,AnimatedBuilder也会自动监听Animation对象的变化,并根据需要将该控件树标记为dirty以自动刷新UI。事实上,如果你翻看源码,就会发现AnimatedBuilder其实也是继承自AnimatedWidget。 + +我们以一个例子来演示如何使用AnimatedBuilder。在这个例子中,AnimatedBuilder的尺寸变化动画由builder函数管理,渲染则由外部传入child参数负责: + +MaterialApp( + home: Scaffold( + body: Center( + child: AnimatedBuilder( + animation: animation,//传入动画对象 + child:FlutterLogo(), + //动画构建回调 + builder: (context, child) => Container( + width: animation.value,//使用动画的当前状态更新UI + height: animation.value, + child: child, //child参数即FlutterLogo() + ) + ) + ) +)); + + +可以看到,通过使用AnimatedWidget和AnimatedBuilder,动画的生成和最终的渲染被分离开了,构建动画的工作也被大大简化了。 + +hero动画 + +现在我们已经知道了如何在一个页面上实现动画效果,那么如何实现在两个页面之间切换的过渡动画呢?比如在社交类App,在Feed流中点击小图进入查看大图页面的场景中,我们希望能够实现小图到大图页面逐步放大的动画切换效果,而当用户关闭大图时,也实现原路返回的动画。 + +这样的跨页面共享的控件动画效果有一个专门的名词,即“共享元素变换”(Shared Element Transition)。 + +对于Android开发者来说,这个概念并不陌生。Android原生提供了对这种动画效果的支持,通过几行代码,就可以实现在两个Activity共享的组件之间做出流畅的转场动画。 + +又比如,Keynote提供了的“神奇移动”(Magic Move)功能,可以实现两个Keynote页面之间的流畅过渡。 + +Flutter也有类似的概念,即Hero控件。通过Hero,我们可以在两个页面的共享元素之间,做出流畅的页面切换效果。 + +接下来,我们通过一个案例来看看Hero组件具体如何使用。 + +在下面的例子中,我定义了两个页面,其中page1有一个位于底部的小Flutter Logo,page2有一个位于中部的大Flutter Logo。在点击了page1的小logo后,会使用hero效果过渡到page2。 + +为了实现共享元素变换,我们需要将这两个组件分别用Hero包裹,并同时为它们设置相同的tag “hero”。然后,为page1添加点击手势响应,在用户点击logo时,跳转到page2: + +class Page1 extends StatelessWidget { + Widget build(BuildContext context) { + return Scaffold( + body: GestureDetector(//手势监听点击 + child: Hero( + tag: 'hero',//设置共享tag + child: Container( + width: 100, height: 100, + child: FlutterLogo())), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_)=>Page2()));//点击后打开第二个页面 + }, + ) + ); + } +} + +class Page2 extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Hero( + tag: 'hero',//设置共享tag + child: Container( + width: 300, height: 300, + child: FlutterLogo() + )) + ); + } +} + + +运行一下,可以看到,我们通过简单的两步,就可以实现元素跨页面飞行的复杂动画效果了! + + + +图4 Hero动画 + +总结 + +好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。 + +在Flutter中,动画的状态与渲染是分离的。我们通过Animation生成动画曲线,使用AnimationController控制动画时间、启动动画。而动画的渲染,则需要设置监听器获取动画进度后,重新触发组件用新的动画状态刷新后才能实现动画的更新。 + +为了简化这一步骤,Flutter提供了AnimatedWidget和AnimatedBuilder这两个组件,省去了状态监听和UI刷新的工作。而对于跨页面动画,Flutter提供了Hero组件,只要两个相同(相似)的组件有同样的tag,就能实现元素跨页面过渡的转场效果。 + +可以看到,Flutter对于动画的分层设计还是非常简单清晰的,但造成的副作用就是使用起来稍微麻烦一些。对于实际应用而言,由于动画过程涉及到页面的频繁刷新,因此我强烈建议你尽量使用AnimatedWidget或AnimatedBuilder来缩小受动画影响的组件范围,只重绘需要做动画的组件即可,要避免使用进度监听器直接刷新整个页面,让不需要做动画的组件也跟着一起销毁重建。 + +我把今天分享中所涉及的针对控件的普通动画,AnimatedBuilder和AnimatedWidget,以及针对页面的过渡动画Hero打包到了GitHub上,你可以把工程下载下来,多运行几次,体会这几种动画的具体使用方法。 + +思考题 + +最后,我给你留下两个小作业吧。 + +AnimatedBuilder( + animation: animation, + child:FlutterLogo(), + builder: (context, child) => Container( + width: animation.value, + height: animation.value, + child: child + ) +) + + + +在AnimatedBuilder的例子中,child似乎被指定了两遍(第3行的child与第7行的child),你可以解释下这么做的原因吗? +如果我把第3行的child删掉,把Flutter Logo放到第7行,动画是否能正常执行?这会有什么问题吗? + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/23单线程模型怎么保证UI运行流畅?.md b/专栏/Flutter核心技术与实战/23单线程模型怎么保证UI运行流畅?.md new file mode 100644 index 0000000..7c1babc --- /dev/null +++ b/专栏/Flutter核心技术与实战/23单线程模型怎么保证UI运行流畅?.md @@ -0,0 +1,354 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 单线程模型怎么保证UI运行流畅? + 你好,我是陈航。 + +在上一篇文章中,我带你一起学习了如何在Flutter中实现动画。对于组件动画,Flutter将动画的状态与渲染进行了分离,因此我们需要使用动画曲线生成器Animation、动画状态控制器AnimationController与动画进度监听器一起配合完成动画更新;而对于跨页面动画,Flutter提供了Hero组件,可以实现共享元素变换的页面切换效果。 + +在之前的章节里,我们介绍了很多Flutter框架出色的渲染和交互能力。支撑起这些复杂的能力背后,实际上是基于单线程模型的Dart。那么,与原生Android和iOS的多线程机制相比,单线程的Dart如何从语言设计层面和代码运行机制上保证Flutter UI的流畅性呢? + +因此今天,我会通过几个小例子,循序渐进地向你介绍Dart语言的Event Loop处理机制、异步处理和并发编程的原理和使用方法,从语言设计和实践层面理解Dart单线程模型下的代码运行本质,从而懂得后续如何在工作中使用Future与Isolate,优化我们的项目。 + +Event Loop机制 + +首先,我们需要建立这样一个概念,那就是Dart是单线程的。那单线程意味着什么呢?这意味着Dart代码是有序的,按照在main函数出现的次序一个接一个地执行,不会被其他代码中断。另外,作为支持Flutter这个UI框架的关键技术,Dart当然也支持异步。需要注意的是,单线程和异步并不冲突。 + +那为什么单线程也可以异步? + +这里有一个大前提,那就是我们的App绝大多数时间都在等待。比如,等用户点击、等网络请求返回、等文件IO结果,等等。而这些等待行为并不是阻塞的。比如说,网络请求,Socket本身提供了select模型可以异步查询;而文件IO,操作系统也提供了基于事件的回调机制。 + +所以,基于这些特点,单线程模型可以在等待的过程中做别的事情,等真正需要响应结果了,再去做对应的处理。因为等待过程并不是阻塞的,所以给我们的感觉就像是同时在做多件事情一样。但其实始终只有一个线程在处理你的事情。 + +等待这个行为是通过Event Loop驱动的。事件队列Event Queue会把其他平行世界(比如Socket)完成的,需要主线程响应的事件放入其中。像其他语言一样,Dart也有一个巨大的事件循环,在不断的轮询事件队列,取出事件(比如,键盘事件、I\O事件、网络事件等),在主线程同步执行其回调函数,如下图所示: + + + +图1 简化版Event Loop + +异步任务 + +事实上,图1的Event Loop示意图只是一个简化版。在Dart中,实际上有两个队列,一个事件队列(Event Queue),另一个则是微任务队列(Microtask Queue)。在每一次事件循环中,Dart总是先去第一个微任务队列中查询是否有可执行的任务,如果没有,才会处理后续的事件队列的流程。 + +所以,Event Loop完整版的流程图,应该如下所示: + + + +图2 Microtask Queue与Event Queue + +接下来,我们分别看一下这两个队列的特点和使用场景吧。 + +首先,我们看看微任务队列。微任务顾名思义,表示一个短时间内就会完成的异步任务。从上面的流程图可以看到,微任务队列在事件循环中的优先级是最高的,只要队列中还有任务,就可以一直霸占着事件循环。 + +微任务是由scheduleMicroTask建立的。如下所示,这段代码会在下一个事件循环中输出一段字符串: + +scheduleMicrotask(() => print('This is a microtask')); + + +不过,一般的异步任务通常也很少必须要在事件队列前完成,所以也不需要太高的优先级,因此我们通常很少会直接用到微任务队列,就连Flutter内部,也只有7处用到了而已(比如,手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景)。 + +异步任务我们用的最多的还是优先级更低的Event Queue。比如,I/O、绘制、定时器这些异步事件,都是通过事件队列驱动主线程执行的。 + +Dart为Event Queue的任务建立提供了一层封装,叫作Future。从名字上也很容易理解,它表示一个在未来时间才会完成的任务。 + +把一个函数体放入Future,就完成了从同步任务到异步任务的包装。Future还提供了链式调用的能力,可以在异步任务执行完毕后依次执行链路上的其他函数体。 + +接下来,我们看一个具体的代码示例:分别声明两个异步任务,在下一个事件循环中输出一段字符串。其中第二个任务执行完毕之后,还会继续输出另外两段字符串: + +Future(() => print('Running in Future 1'));//下一个事件循环输出字符串 + +Future(() => print(‘Running in Future 2')) + .then((_) => print('and then 1')) + .then((_) => print('and then 2’));//上一个事件循环结束后,连续输出三段字符串 + + +当然,这两个Future异步任务的执行优先级比微任务的优先级要低。 + +正常情况下,一个Future异步任务的执行是相对简单的:在我们声明一个Future时,Dart会将异步任务的函数执行体放入事件队列,然后立即返回,后续的代码继续同步执行。而当同步执行的代码执行完毕后,事件队列会按照加入事件队列的顺序(即声明顺序),依次取出事件,最后同步执行Future的函数体及后续的then。 + +这意味着,then与Future函数体共用一个事件循环。而如果Future有多个then,它们也会按照链式调用的先后顺序同步执行,同样也会共用一个事件循环。 + +如果Future执行体已经执行完毕了,但你又拿着这个Future的引用,往里面加了一个then方法体,这时Dart会如何处理呢?面对这种情况,Dart会将后续加入的then方法体放入微任务队列,尽快执行。 + +下面的代码演示了Future的执行规则,即,先加入事件队列,或者先声明的任务先执行;then在Future结束后立即执行。 + + +在第一个例子中,由于f1比f2先声明,因此会被先加入事件队列,所以f1比f2先执行; +在第二个例子中,由于Future函数体与then共用一个事件循环,因此f3执行后会立刻同步执行then 3; +最后一个例子中,Future函数体是null,这意味着它不需要也没有事件循环,因此后续的then也无法与它共享。在这种场景下,Dart会把后续的then放入微任务队列,在下一次事件循环中执行。 + + +//f1比f2先执行 +Future(() => print('f1')); +Future(() => print('f2')); + +//f3执行后会立刻同步执行then 3 +Future(() => print('f3')).then((_) => print('then 3')); + +//then 4会加入微任务队列,尽快执行 +Future(() => null).then((_) => print('then 4')); + + +说了这么多规则,可能大家并没有完全记住。那我们通过一个综合案例,来把之前介绍的各个执行规则都串起来,再集中学习一下。 + +在下面的例子中,我们依次声明了若干个异步任务Future,以及微任务。在其中的一些Future内部,我们又内嵌了Future与microtask的声明: + +Future(() => print('f1'));//声明一个匿名Future +Future fx = Future(() => null);//声明Future fx,其执行体为null + +//声明一个匿名Future,并注册了两个then。在第一个then回调里启动了一个微任务 +Future(() => print('f2')).then((_) { + print('f3'); + scheduleMicrotask(() => print('f4')); +}).then((_) => print('f5')); + +//声明了一个匿名Future,并注册了两个then。第一个then是一个Future +Future(() => print('f6')) + .then((_) => Future(() => print('f7'))) + .then((_) => print('f8')); + +//声明了一个匿名Future +Future(() => print('f9')); + +//往执行体为null的fx注册了了一个then +fx.then((_) => print('f10')); + +//启动一个微任务 +scheduleMicrotask(() => print('f11')); +print('f12'); + + +运行一下,上述各个异步任务会依次打印其内部执行结果: + +f12 +f11 +f1 +f10 +f2 +f3 +f5 +f4 +f6 +f9 +f7 +f8 + + +看到这儿,你可能已经懵了。别急,我们先来看一下这段代码执行过程中,Event Queue与Microtask Queue中的变化情况,依次分析一下它们的执行顺序为什么会是这样的: + + + +图3 Event Queue与Microtask Queue变化示例 + + +因为其他语句都是异步任务,所以先打印f12。 +剩下的异步任务中,微任务队列优先级最高,因此随后打印f11;然后按照Future声明的先后顺序,打印f1。 +随后到了fx,由于fx的执行体是null,相当于执行完毕了,Dart将fx的then放入微任务队列,由于微任务队列的优先级最高,因此fx的then还是会最先执行,打印f10。 +然后到了fx下面的f2,打印f2,然后执行then,打印f3。f4是一个微任务,要到下一个事件循环才执行,因此后续的then继续同步执行,打印f5。本次事件循环结束,下一个事件循环取出f4这个微任务,打印f4。 +然后到了f2下面的f6,打印f6,然后执行then。这里需要注意的是,这个then是一个Future异步任务,因此这个then,以及后续的then都被放入到事件队列中了。 +f6下面还有f9,打印f9。 +最后一个事件循环,打印f7,以及后续的f8。 + + +上面的代码很是烧脑,万幸我们平时开发Flutter时一般不会遇到这样奇葩的写法,所以你大可放心。你只需要记住一点:then会在Future函数体执行完毕后立刻执行,无论是共用同一个事件循环还是进入下一个微任务。 + +在深入理解Future异步任务的执行规则之后,我们再来看看怎么封装一个异步函数。 + +异步函数 + +对于一个异步函数来说,其返回时内部执行动作并未结束,因此需要返回一个Future对象,供调用者使用。调用者根据Future对象,来决定:是在这个Future对象上注册一个then,等Future的执行体结束了以后再进行异步处理;还是一直同步等待Future执行体结束。 + +对于异步函数返回的Future对象,如果调用者决定同步等待,则需要在调用处使用await关键字,并且在调用处的函数体使用async关键字。 + +在下面的例子中,异步方法延迟3秒返回了一个Hello 2019,在调用处我们使用await进行持续等待,等它返回了再打印: + +//声明了一个延迟3秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2019 +Future fetchContent() => + Future.delayed(Duration(seconds:3), () => "Hello") + .then((x) => "$x 2019"); + + main() async{ + print(await fetchContent());//等待Hello 2019的返回 + } + + +也许你已经注意到了,我们在使用await进行等待的时候,在等待语句的调用上下文函数main加上了async关键字。为什么要加这个关键字呢? + +因为Dart中的await并不是阻塞等待,而是异步等待。Dart会将调用体的函数也视作异步函数,将等待语句的上下文放入Event Queue中,一旦有了结果,Event Loop就会把它从Event Queue中取出,等待代码继续执行。 + +接下来,为了帮助你加深印象,我准备了两个具体的案例。 + +我们先来看下这段代码。第二行的then执行体f2是一个Future,为了等它完成再进行下一步操作,我们使用了await,期望打印结果为f1、f2、f3、f4: + +Future(() => print('f1')) + .then((_) async => await Future(() => print('f2'))) + .then((_) => print('f3')); +Future(() => print('f4')); + + +实际上,当你运行这段代码时就会发现,打印出来的结果其实是f1、f4、f2、f3! + +我来给你分析一下这段代码的执行顺序: + + +按照任务的声明顺序,f1和f4被先后加入事件队列。 +f1被取出并打印;然后到了then。then的执行体是个future f2,于是放入Event Queue。然后把await也放到Event Queue里。 +这个时候要注意了,Event Queue里面还有一个f4,我们的await并不能阻塞f4的执行。因此,Event Loop先取出f4,打印f4;然后才能取出并打印f2,最后把等待的await取出,开始执行后面的f3。 + + +由于await是采用事件队列的机制实现等待行为的,所以比它先在事件队列中的f4并不会被它阻塞。 + +接下来,我们再看另一个例子:在主函数调用一个异步函数去打印一段话,而在这个异步函数中,我们使用await与async同步等待了另一个异步函数返回字符串: + +//声明了一个延迟2秒返回Hello的Future,并注册了一个then返回拼接后的Hello 2019 +Future fetchContent() => + Future.delayed(Duration(seconds:2), () => "Hello") + .then((x) => "$x 2019"); +//异步函数会同步等待Hello 2019的返回,并打印 +func() async => print(await fetchContent()); + +main() { + print("func before"); + func(); + print("func after"); +} + + +运行这段代码,我们发现最终输出的顺序其实是“func before”“func after”“Hello 2019”。func函数中的等待语句似乎没起作用。这是为什么呢? + +同样,我来给你分析一下这段代码的执行顺序: + + +首先,第一句代码是同步的,因此先打印“func before”。 +然后,进入func函数,func函数调用了异步函数fetchContent,并使用await进行等待,因此我们把fetchContent、await语句的上下文函数func先后放入事件队列。 +await的上下文函数并不包含调用栈,因此func后续代码继续执行,打印“func after”。 +2秒后,fetchContent异步任务返回“Hello 2019”,于是func的await也被取出,打印“Hello 2019”。 + + +通过上述分析,你发现了什么现象?那就是await与async只对调用上下文的函数有效,并不向上传递。因此对于这个案例而言,func是在异步等待。如果我们想在main函数中也同步等待,需要在调用异步函数时也加上await,在main函数也加上async。 + +经过上面两个例子的分析,你应该已经明白await与async是如何配合,完成等待工作的了吧。 + +介绍完了异步,我们再来看在Dart中,如何通过多线程实现并发。 + +Isolate + +尽管Dart是基于单线程模型的,但为了进一步利用多核CPU,将CPU密集型运算进行隔离,Dart也提供了多线程机制,即Isolate。在Isolate中,资源隔离做得非常好,每个Isolate都有自己的Event Loop与Queue,Isolate之间不共享任何资源,只能依靠消息机制通信,因此也就没有资源抢占问题。 + +和其他语言一样,Isolate的创建非常简单,我们只要给定一个函数入口,创建时再传入一个参数,就可以启动Isolate了。如下所示,我们声明了一个Isolate的入口函数,然后在main函数中启动它,并传入了一个字符串参数: + +doSth(msg) => print(msg); + +main() { + Isolate.spawn(doSth, "Hi"); + ... +} + + +但更多情况下,我们的需求并不会这么简单,不仅希望能并发,还希望Isolate在并发执行的时候告知主Isolate当前的执行结果。 + +对于执行结果的告知,Isolate通过发送管道(SendPort)实现消息通信机制。我们可以在启动并发Isolate时将主Isolate的发送管道作为参数传给它,这样并发Isolate就可以在任务执行完毕后利用这个发送管道给我们发消息了。 + +下面我们通过一个例子来说明:在主Isolate里,我们创建了一个并发Isolate,在函数入口传入了主Isolate的发送管道,然后等待并发Isolate的回传消息。在并发Isolate中,我们用这个管道给主Isolate发了一个Hello字符串: + +Isolate isolate; + +start() async { + ReceivePort receivePort= ReceivePort();//创建管道 + //创建并发Isolate,并传入发送管道 + isolate = await Isolate.spawn(getMsg, receivePort.sendPort); + //监听管道消息 + receivePort.listen((data) { + print('Data:$data'); + receivePort.close();//关闭管道 + isolate?.kill(priority: Isolate.immediate);//杀死并发Isolate + isolate = null; + }); +} +//并发Isolate往管道发送一个字符串 +getMsg(sendPort) => sendPort.send("Hello"); + + +这里需要注意的是,在Isolate中,发送管道是单向的:我们启动了一个Isolate执行某项任务,Isolate执行完毕后,发送消息告知我们。如果Isolate执行任务时,需要依赖主Isolate给它发送参数,执行完毕后再发送执行结果给主Isolate,这样双向通信的场景我们如何实现呢?答案也很简单,让并发Isolate也回传一个发送管道即可。 + +接下来,我们以一个并发计算阶乘的例子来说明如何实现双向通信。 + +在下面的例子中,我们创建了一个异步函数计算阶乘。在这个异步函数内,创建了一个并发Isolate,传入主Isolate的发送管道;并发Isolate也回传一个发送管道;主Isolate收到回传管道后,发送参数N给并发Isolate,然后立即返回一个Future;并发Isolate用参数N,调用同步计算阶乘的函数,返回执行结果;最后,主Isolate打印了返回结果: + +//并发计算阶乘 +Future asyncFactoriali(n) async{ + final response = ReceivePort();//创建管道 + //创建并发Isolate,并传入管道 + await Isolate.spawn(_isolate,response.sendPort); + //等待Isolate回传管道 + final sendPort = await response.first as SendPort; + //创建了另一个管道answer + final answer = ReceivePort(); + //往Isolate回传的管道中发送参数,同时传入answer管道 + sendPort.send([n,answer.sendPort]); + return answer.first;//等待Isolate通过answer管道回传执行结果 +} + +//Isolate函数体,参数是主Isolate传入的管道 +_isolate(initialReplyTo) async { + final port = ReceivePort();//创建管道 + initialReplyTo.send(port.sendPort);//往主Isolate回传管道 + final message = await port.first as List;//等待主Isolate发送消息(参数和回传结果的管道) + final data = message[0] as int;//参数 + final send = message[1] as SendPort;//回传结果的管道 + send.send(syncFactorial(data));//调用同步计算阶乘的函数回传结果 +} + +//同步计算阶乘 +int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1); +main() async => print(await asyncFactoriali(4));//等待并发计算阶乘结果 + + +看完这段代码你是什么感觉呢?我们只是为了并发计算一个阶乘,这样是不是太繁琐了? + +没错,确实太繁琐了。在Flutter中,像这样执行并发计算任务我们可以采用更简单的方式。Flutter提供了支持并发计算的compute函数,其内部对Isolate的创建和双向通信进行了封装抽象,屏蔽了很多底层细节,我们在调用时只需要传入函数入口和函数参数,就能够实现并发计算和消息通知。 + +我们试着用compute函数改造一下并发计算阶乘的代码: + +//同步计算阶乘 +int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1); +//使用compute函数封装Isolate的创建和结果的返回 +main() async => print(await compute(syncFactorial, 4)); + + +可以看到,用compute函数改造以后,整个代码就变成了两行,现在并发计算阶乘的代码看起来就清爽多了。 + +总结 + +好了,今天关于Dart的异步与并发机制、实现原理的分享就到这里了,我们来简单回顾一下主要内容。 + +Dart是单线程的,但通过事件循环可以实现异步。而Future是异步任务的封装,借助于await与async,我们可以通过事件循环实现非阻塞的同步等待;Isolate是Dart中的多线程,可以实现并发,有自己的事件循环与Queue,独占资源。Isolate之间可以通过消息机制进行单向通信,这些传递的消息通过对方的事件循环驱动对方进行异步处理。 + +在UI编程过程中,异步和多线程是两个相伴相生的名词,也是很容易混淆的概念。对于异步方法调用而言,代码不需要等待结果的返回,而是通过其他手段(比如通知、回调、事件循环或多线程)在后续的某个时刻主动(或被动)地接收执行结果。 + +因此,从辩证关系上来看,异步与多线程并不是一个同等关系:异步是目的,多线程只是我们实现异步的一个手段之一。而在Flutter中,借助于UI框架提供的事件循环,我们可以不用阻塞的同时等待多个异步任务,因此并不需要开多线程。我们一定要记住这一点。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解。 + +思考题 + +最后,我给你留下两道思考题吧。 + + +在通过并发Isolate计算阶乘的例子中,我在asyncFactoriali方法里先后发给了并发Isolate两个SendPort。你能否解释下这么做的原因?可以只发一个SendPort吗? +请改造以下代码,在不改变整体异步结构的情况下,实现输出结果为f1、f2、f3、f4。 + + +Future(() => print('f1')) + .then((_) async => await Future(() => print('f2'))) + .then((_) => print('f3')); +Future(() => print('f4')); + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/24HTTP网络编程与JSON解析.md b/专栏/Flutter核心技术与实战/24HTTP网络编程与JSON解析.md new file mode 100644 index 0000000..f6593c3 --- /dev/null +++ b/专栏/Flutter核心技术与实战/24HTTP网络编程与JSON解析.md @@ -0,0 +1,397 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 HTTP网络编程与JSON解析 + 你好,我是陈航。 + +在上一篇文章中,我带你一起学习了Dart中异步与并发的机制及实现原理。与其他语言类似,Dart的异步是通过事件循环与队列实现的,我们可以使用Future来封装异步任务。而另一方面,尽管Dart是基于单线程模型的,但也提供了Isolate这样的“多线程”能力,这使得我们可以充分利用系统资源,在并发Isolate中搞定CPU密集型的任务,并通过消息机制通知主Isolate运行结果。 + +异步与并发的一个典型应用场景,就是网络编程。一个好的移动应用,不仅需要有良好的界面和易用的交互体验,也需要具备和外界进行信息交互的能力。而通过网络,信息隔离的客户端与服务端间可以建立一个双向的通信通道,从而实现资源访问、接口数据请求和提交、上传下载文件等操作。 + +为了便于我们快速实现基于网络通道的信息交换实时更新App数据,Flutter也提供了一系列的网络编程类库和工具。因此在今天的分享中,我会通过一些小例子与你讲述在Flutter应用中,如何实现与服务端的数据交互,以及如何将交互响应的数据格式化。 + +Http网络编程 + +我们在通过网络与服务端数据交互时,不可避免地需要用到三个概念:定位、传输与应用。 + +其中,定位,定义了如何准确地找到网络上的一台或者多台主机(即IP地址);传输,则主要负责在找到主机后如何高效且可靠地进行数据通信(即TCP、UDP协议);而应用,则负责识别双方通信的内容(即HTTP协议)。 + +我们在进行数据通信时,可以只使用传输层协议。但传输层传递的数据是二进制流,如果没有应用层,我们无法识别数据内容。如果想要使传输的数据有意义,则必须要用到应用层协议。移动应用通常使用HTTP协议作应用层协议,来封装HTTP信息。 + +在编程框架中,一次HTTP网络调用通常可以拆解为以下步骤: + + +创建网络调用实例client,设置通用请求行为(如超时时间); +构造URI,设置请求header、body; +发起请求, 等待响应; +解码响应的内容。 + + +当然,Flutter也不例外。在Flutter中,Http网络编程的实现方式主要分为三种:dart:io里的HttpClient实现、Dart原生http请求库实现、第三方库dio实现。接下来,我依次为你讲解这三种方式。 + +HttpClient + +HttpClient是dart:io库中提供的网络请求类,实现了基本的网络编程功能。 + +接下来,我将和你分享一个实例,对照着上面提到的网络调用步骤,来演示HttpClient如何使用。 + +在下面的代码中,我们创建了一个HttpClien网络调用实例,设置了其超时时间为5秒。随后构造了Flutter官网的URI,并设置了请求Header的user-agent为Custom-UA。然后发起请求,等待Flutter官网响应。最后在收到响应后,打印出返回结果: + +get() async { + //创建网络调用示例,设置通用请求行为(超时时间) + var httpClient = HttpClient(); + httpClient.idleTimeout = Duration(seconds: 5); + + //构造URI,设置user-agent为"Custom-UA" + var uri = Uri.parse("https://flutter.dev"); + var request = await httpClient.getUrl(uri); + request.headers.add("user-agent", "Custom-UA"); + + //发起请求,等待响应 + var response = await request.close(); + + //收到响应,打印结果 + if (response.statusCode == HttpStatus.ok) { + print(await response.transform(utf8.decoder).join()); + } else { + print('Error: \nHttp status ${response.statusCode}'); + } +} + + +可以看到,使用HttpClient来发起网络调用还是相对比较简单的。 + +这里需要注意的是,由于网络请求是异步行为,因此在Flutter中,所有网络编程框架都是以Future作为异步请求的包装,所以我们需要使用await与async进行非阻塞的等待。当然,你也可以注册then,以回调的方式进行相应的事件处理。 + +http + +HttpClient使用方式虽然简单,但其接口却暴露了不少内部实现细节。比如,异步调用拆分得过细,链接需要调用方主动关闭,请求结果是字符串但却需要手动解码等。 + +http是Dart官方提供的另一个网络请求类,相比于HttpClient,易用性提升了不少。同样,我们以一个例子来介绍http的使用方法。 + +首先,我们需要将http加入到pubspec中的依赖里: + +dependencies: + http: '>=0.11.3+12' + + +在下面的代码中,与HttpClient的例子类似的,我们也是先后构造了http网络调用实例和Flutter官网URI,在设置user-agent为Custom-UA后,发出请求,最后打印请求结果: + +httpGet() async { + //创建网络调用示例 + var client = http.Client(); + + //构造URI + var uri = Uri.parse("https://flutter.dev"); + + //设置user-agent为"Custom-UA",随后立即发出请求 + http.Response response = await client.get(uri, headers : {"user-agent" : "Custom-UA"}); + + //打印请求结果 + if(response.statusCode == HttpStatus.ok) { + print(response.body); + } else { + print("Error: ${response.statusCode}"); + } +} + + +可以看到,相比于HttpClient,http的使用方式更加简单,仅需一次异步调用就可以实现基本的网络通信。 + +dio + +HttpClient和http使用方式虽然简单,但其暴露的定制化能力都相对较弱,很多常用的功能都不支持(或者实现异常繁琐),比如取消请求、定制拦截器、Cookie管理等。因此对于复杂的网络请求行为,我推荐使用目前在Dart社区人气较高的第三方dio来发起网络请求。 + +接下来,我通过几个例子来和你介绍dio的使用方法。与http类似的,我们首先需要把dio加到pubspec中的依赖里: + +dependencies: + dio: '>2.1.3' + + +在下面的代码中,与前面HttpClient与http例子类似的,我们也是先后创建了dio网络调用实例、创建URI、设置Header、发出请求,最后等待请求结果: + +void getRequest() async { + //创建网络调用示例 + Dio dio = new Dio(); + + //设置URI及请求user-agent后发起请求 + var response = await dio.get("https://flutter.dev", options:Options(headers: {"user-agent" : "Custom-UA"})); + + //打印请求结果 + if(response.statusCode == HttpStatus.ok) { + print(response.data.toString()); + } else { + print("Error: ${response.statusCode}"); + } +} + + + +这里需要注意的是,创建URI、设置Header及发出请求的行为,都是通过dio.get方法实现的。这个方法的options参数提供了精细化控制网络请求的能力,可以支持设置Header、超时时间、Cookie、请求方法等。这部分内容不是今天分享的重点,如果你想深入理解的话,可以访问其API文档学习具体使用方法。 + + +对于常见的上传及下载文件需求,dio也提供了良好的支持:文件上传可以通过构建表单FormData实现,而文件下载则可以使用download方法搞定。 + +在下面的代码中,我们通过FormData创建了两个待上传的文件,通过post方法发送至服务端。download的使用方法则更为简单,我们直接在请求参数中,把待下载的文件地址和本地文件名提供给dio即可。如果我们需要感知下载进度,可以增加onReceiveProgress回调函数: + +//使用FormData表单构建待上传文件 +FormData formData = FormData.from({ + "file1": UploadFileInfo(File("./file1.txt"), "file1.txt"), + "file2": UploadFileInfo(File("./file2.txt"), "file1.txt"), + +}); +//通过post方法发送至服务端 +var responseY = await dio.post("https://xxx.com/upload", data: formData); +print(responseY.toString()); + +//使用download方法下载文件 +dio.download("https://xxx.com/file1", "xx1.zip"); + +//增加下载进度回调函数 +dio.download("https://xxx.com/file1", "xx2.zip", onReceiveProgress: (count, total) { + //do something +}); + + +有时,我们的页面由多个并行的请求响应结果构成,这就需要等待这些请求都返回后才能刷新界面。在dio中,我们可以结合Future.wait方法轻松实现: + +//同时发起两个并行请求 +List responseX= await Future.wait([dio.get("https://flutter.dev"),dio.get("https://pub.dev/packages/dio")]); + +//打印请求1响应结果 +print("Response1: ${responseX[0].toString()}"); +//打印请求2响应结果 +print("Response2: ${responseX[1].toString()}"); + + +此外,与Android的okHttp一样,dio还提供了请求拦截器,通过拦截器,我们可以在请求之前,或响应之后做一些特殊的操作。比如可以为请求option统一增加一个header,或是返回缓存数据,或是增加本地校验处理等等。 + +在下面的例子中,我们为dio增加了一个拦截器。在请求发送之前,不仅为每个请求头都加上了自定义的user-agent,还实现了基本的token认证信息检查功能。而对于本地已经缓存了请求uri资源的场景,我们可以直接返回缓存数据,避免再次下载: + +//增加拦截器 +dio.interceptors.add(InterceptorsWrapper( + onRequest: (RequestOptions options){ + //为每个请求头都增加user-agent + options.headers["user-agent"] = "Custom-UA"; + //检查是否有token,没有则直接报错 + if(options.headers['token'] == null) { + return dio.reject("Error:请先登录"); + } + //检查缓存是否有数据 + if(options.uri == Uri.parse('http://xxx.com/file1')) { + return dio.resolve("返回缓存数据"); + } + //放行请求 + return options; + } +)); + +//增加try catch,防止请求报错 +try { + var response = await dio.get("https://xxx.com/xxx.zip"); + print(response.data.toString()); +}catch(e) { + print(e); +} + + +需要注意的是,由于网络通信期间有可能会出现异常(比如,域名无法解析、超时等),因此我们需要使用try-catch来捕获这些未知错误,防止程序出现异常。 + +除了这些基本的用法,dio还支持请求取消、设置代理,证书校验等功能。不过,这些高级特性不属于本次分享的重点,故不再赘述,详情可以参考dio的GitHub主页了解具体用法。 + +JSON解析 + +移动应用与Web服务器建立好了连接之后,接下来的两个重要工作分别是:服务器如何结构化地去描述返回的通信信息,以及移动应用如何解析这些格式化的信息。 + +如何结构化地描述返回的通信信息? + +在如何结构化地去表达信息上,我们需要用到JSON。JSON是一种轻量级的、用于表达由属性值和字面量组成对象的数据交换语言。 + +一个简单的表示学生成绩的JSON结构,如下所示: + +String jsonString = ''' +{ + "id":"123", + "name":"张三", + "score" : 95 +} +'''; + + +需要注意的是,由于Flutter不支持运行时反射,因此并没有提供像Gson、Mantle这样自动解析JSON的库来降低解析成本。在Flutter中,JSON解析完全是手动的,开发者要做的事情多了一些,但使用起来倒也相对灵活。 + +接下来,我们就看看Flutter应用是如何解析这些格式化的信息。 + +如何解析格式化的信息? + +所谓手动解析,是指使用dart:convert库中内置的JSON解码器,将JSON字符串解析成自定义对象的过程。使用这种方式,我们需要先将JSON字符串传递给JSON.decode方法解析成一个Map,然后把这个Map传给自定义的类,进行相关属性的赋值。 + +以上面表示学生成绩的JSON结构为例,我来和你演示手动解析的使用方法。 + +首先,我们根据JSON结构定义Student类,并创建一个工厂类,来处理Student类属性成员与JSON字典对象的值之间的映射关系: + +class Student{ + //属性id,名字与成绩 + String id; + String name; + int score; + //构造方法 + Student({ + this.id, + this.name, + this.score + }); + //JSON解析工厂类,使用字典数据为对象初始化赋值 + factory Student.fromJson(Map parsedJson){ + return Student( + id: parsedJson['id'], + name : parsedJson['name'], + score : parsedJson ['score'] + ); + } +} + + +数据解析类创建好了,剩下的事情就相对简单了,我们只需要把JSON文本通过JSON.decode方法转换成Map,然后把它交给Student的工厂类fromJson方法,即可完成Student对象的解析: + +loadStudent() { + //jsonString为JSON文本 + final jsonResponse = json.decode(jsonString); + Student student = Student.fromJson(jsonResponse); + print(student.name); +} + + +在上面的例子中,JSON文本所有的属性都是基本类型,因此我们直接从JSON字典取出相应的元素为对象赋值即可。而如果JSON下面还有嵌套对象属性,比如下面的例子中,Student还有一个teacher的属性,我们又该如何解析呢? + +String jsonString = ''' +{ + "id":"123", + "name":"张三", + "score" : 95, + "teacher": { + "name": "李四", + "age" : 40 + } +} +'''; + + +这里,teacher不再是一个基本类型,而是一个对象。面对这种情况,我们需要为每一个非基本类型属性创建一个解析类。与Student类似,我们也需要为它的属性teacher创建一个解析类Teacher: + +class Teacher { + //Teacher的名字与年龄 + String name; + int age; + //构造方法 + Teacher({this.name,this.age}); + //JSON解析工厂类,使用字典数据为对象初始化赋值 + factory Teacher.fromJson(Map parsedJson){ + return Teacher( + name : parsedJson['name'], + age : parsedJson ['age'] + ); + } +} + + +然后,我们只需要在Student类中,增加teacher属性及对应的JSON映射规则即可: + +class Student{ + ... + //增加teacher属性 + Teacher teacher; + //构造函数增加teacher + Student({ + ... + this.teacher + }); + factory Student.fromJson(Map parsedJson){ + return Student( + ... + //增加映射规则 + teacher: Teacher.fromJson(parsedJson ['teacher']) + ); + } +} + + +完成了teacher属性的映射规则添加之后,我们就可以继续使用Student来解析上述的JSON文本了: + +final jsonResponse = json.decode(jsonString);//将字符串解码成Map对象 +Student student = Student.fromJson(jsonResponse);//手动解析 +print(student.teacher.name); + + +可以看到,通过这种方法,无论对象有多复杂的非基本类型属性,我们都可以创建对应的解析类进行处理。 + +不过到现在为止,我们的JSON数据解析还是在主Isolate中完成。如果JSON的数据格式比较复杂,数据量又大,这种解析方式可能会造成短期UI无法响应。对于这类CPU密集型的操作,我们可以使用上一篇文章中提到的compute函数,将解析工作放到新的Isolate中完成: + +static Student parseStudent(String content) { + final jsonResponse = json.decode(content); + Student student = Student.fromJson(jsonResponse); + return student; +} +doSth() { + ... + //用compute函数将json解析放到新Isolate + compute(parseStudent,jsonString).then((student)=>print(student.teacher.name)); +} + + +通过compute的改造,我们就不用担心JSON解析时间过长阻塞UI响应了。 + +总结 + +好了,今天的分享就到这里了,我们简单回顾一下主要内容。 + +首先,我带你学习了实现Flutter应用与服务端通信的三种方式,即HttpClient、http与dio。其中dio提供的功能更为强大,可以支持请求拦截、文件上传下载、请求合并等高级能力。因此,我推荐你在实际项目中使用dio的方式。 + +然后,我和你分享了JSON解析的相关内容。JSON解析在Flutter中相对比较简单,但由于不支持反射,所以我们只能手动解析,即:先将JSON字符串转换成Map,然后再把这个Map给到自定义类,进行相关属性的赋值。 + +如果你有原生Android、iOS开发经验的话,可能会觉得Flutter提供的JSON手动解析方案并不好用。在Flutter中,没有像原生开发那样提供了Gson或Mantle等库,用于将JSON字符串直接转换为对应的实体类。而这些能力无一例外都需要用到运行时反射,这是Flutter从设计之初就不支持的,理由如下: + + +运行时反射破坏了类的封装性和安全性,会带来安全风险。就在前段时间,Fastjson框架就爆出了一个巨大的安全漏洞。这个漏洞使得精心构造的字符串文本,可以在反序列化时让服务器执行任意代码,直接导致业务机器被远程控制、内网渗透、窃取敏感信息等操作。 +运行时反射会增加二进制文件大小。因为搞不清楚哪些代码可能会在运行时用到,因此使用反射后,会默认使用所有代码构建应用程序,这就导致编译器无法优化编译期间未使用的代码,应用安装包体积无法进一步压缩,这对于自带Dart虚拟机的Flutter应用程序是难以接受的。 + + +反射给开发者编程带来了方便,但也带来了很多难以解决的新问题,因此Flutter并不支持反射。而我们要做的就是,老老实实地手动解析JSON吧。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留两道思考题吧。 + + +请使用dio实现一个自定义拦截器,拦截器内检查header中的token:如果没有token,需要暂停本次请求,同时访问”http://xxxx.com/token“,在获取新token后继续本次请求。 +为以下Student JSON写相应的解析类: + + +String jsonString = ''' + { + "id":"123", + "name":"张三", + "score" : 95, + "teachers": [ + { + "name": "李四", + "age" : 40 + }, + { + "name": "王五", + "age" : 45 + } + ] + } + '''; + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/25本地存储与数据库的使用和优化.md b/专栏/Flutter核心技术与实战/25本地存储与数据库的使用和优化.md new file mode 100644 index 0000000..21f9235 --- /dev/null +++ b/专栏/Flutter核心技术与实战/25本地存储与数据库的使用和优化.md @@ -0,0 +1,245 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 本地存储与数据库的使用和优化 + 你好,我是陈航。 + +在上一篇文章中,我带你一起学习了Flutter的网络编程,即如何建立与Web服务器的通信连接,以实现数据交换,以及如何解析结构化后的通信信息。 + +其中,建立通信连接在Flutter中有三种基本方案,包括HttpClient、http与dio。考虑到HttpClient与http并不支持复杂的网络请求行为,因此我重点介绍了如何使用dio实现资源访问、接口数据请求与提交、上传及下载文件、网络拦截等高级操作。 + +而关于如何解析信息,由于Flutter并不支持反射,因此只提供了手动解析JSON的方式:把JSON转换成字典,然后给自定义的类属性赋值即可。 + +正因为有了网络,我们的App拥有了与外界进行信息交换的通道,也因此具备了更新数据的能力。不过,经过交换后的数据通常都保存在内存中,而应用一旦运行结束,内存就会被释放,这些数据也就随之消失了。 + +因此,我们需要把这些更新后的数据以一定的形式,通过一定的载体保存起来,这样应用下次运行时,就可以把数据从存储的载体中读出来,也就实现了数据的持久化。 + +数据持久化的应用场景有很多。比如,用户的账号登录信息需要保存,用于每次与Web服务验证身份;又比如,下载后的图片需要缓存,避免每次都要重新加载,浪费用户流量。 + +由于Flutter仅接管了渲染层,真正涉及到存储等操作系统底层行为时,还需要依托于原生Android、iOS,因此与原生开发类似的,根据需要持久化数据的大小和方式不同,Flutter提供了三种数据持久化方法,即文件、SharedPreferences与数据库。接下来,我将与你详细讲述这三种方式。 + +文件 + +文件是存储在某种介质(比如磁盘)上指定路径的、具有文件名的一组有序信息的集合。从其定义看,要想以文件的方式实现数据持久化,我们首先需要确定一件事儿:数据放在哪儿?这,就意味着要定义文件的存储路径。 + +Flutter提供了两种文件存储的目录,即临时(Temporary)目录与文档(Documents)目录: + + +临时目录是操作系统可以随时清除的目录,通常被用来存放一些不重要的临时缓存数据。这个目录在iOS上对应着NSTemporaryDirectory返回的值,而在Android上则对应着getCacheDir返回的值。 +文档目录则是只有在删除应用程序时才会被清除的目录,通常被用来存放应用产生的重要数据文件。在iOS上,这个目录对应着NSDocumentDirectory,而在Android上则对应着AppData目录。 + + +接下来,我通过一个例子与你演示如何在Flutter中实现文件读写。 + +在下面的代码中,我分别声明了三个函数,即创建文件目录函数、写文件函数与读文件函数。这里需要注意的是,由于文件读写是非常耗时的操作,所以这些操作都需要在异步环境下进行。另外,为了防止文件读取过程中出现异常,我们也需要在外层包上try-catch: + +//创建文件目录 +Future get _localFile async { + final directory = await getApplicationDocumentsDirectory(); + final path = directory.path; + return File('$path/content.txt'); +} +//将字符串写入文件 +Future writeContent(String content) async { + final file = await _localFile; + return file.writeAsString(content); +} +//从文件读出字符串 +Future readContent() async { + try { + final file = await _localFile; + String contents = await file.readAsString(); + return contents; + } catch (e) { + return ""; + } +} + + +有了文件读写函数,我们就可以在代码中对content.txt这个文件进行读写操作了。在下面的代码中,我们往这个文件写入了一段字符串后,隔了一会又把它读了出来: + +writeContent("Hello World!"); +... +readContent().then((value)=>print(value)); + + +除了字符串读写之外,Flutter还提供了二进制流的读写能力,可以支持图片、压缩包等二进制文件的读写。这些内容不是本次分享的重点,如果你想要深入研究的话,可以查阅官方文档。 + +SharedPreferences + +文件比较适合大量的、有序的数据持久化,如果我们只是需要缓存少量的键值对信息(比如记录用户是否阅读了公告,或是简单的计数),则可以使用SharedPreferences。 + +SharedPreferences会以原生平台相关的机制,为简单的键值对数据提供持久化存储,即在iOS上使用NSUserDefaults,在Android使用SharedPreferences。 + +接下来,我通过一个例子来演示在Flutter中如何通过SharedPreferences实现数据的读写。在下面的代码中,我们将计数器持久化到了SharedPreferences中,并为它分别提供了读方法和递增写入的方法。 + +这里需要注意的是,setter(setInt)方法会同步更新内存中的键值对,然后将数据保存至磁盘,因此我们无需再调用更新方法强制刷新缓存。同样地,由于涉及到耗时的文件读写,因此我们必须以异步的方式对这些操作进行包装: + +//读取SharedPreferences中key为counter的值 +Future_loadCounter() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + int counter = (prefs.getInt('counter') ?? 0); + return counter; +} + +//递增写入SharedPreferences中key为counter的值 +Future_incrementCounter() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + int counter = (prefs.getInt('counter') ?? 0) + 1; + prefs.setInt('counter', counter); +} + + +在完成了计数器存取方法的封装后,我们就可以在代码中随时更新并持久化计数器数据了。在下面的代码中,我们先是读取并打印了计数器数据,随后将其递增,并再次把它读取打印: + +//读出counter数据并打印 +_loadCounter().then((value)=>print("before:$value")); + +//递增counter数据后,再次读出并打印 +_incrementCounter().then((_) { + _loadCounter().then((value)=>print("after:$value")); +}); + + +可以看到,SharedPreferences的使用方式非常简单方便。不过需要注意的是,以键值对的方式只能存储基本类型的数据,比如int、double、bool和string。 + +数据库 + +SharedPrefernces的使用固然方便,但这种方式只适用于持久化少量数据的场景,我们并不能用它来存储大量数据,比如文件内容(文件路径是可以的)。 + +如果我们需要持久化大量格式化后的数据,并且这些数据还会以较高的频率更新,为了考虑进一步的扩展性,我们通常会选用sqlite数据库来应对这样的场景。与文件和SharedPreferences相比,数据库在数据读写上可以提供更快、更灵活的解决方案。 + +接下来,我就以一个例子分别与你介绍数据库的使用方法。 + +我们以上一篇文章中提到的Student类为例: + +class Student{ + String id; + String name; + int score; + //构造方法 + Student({this.id, this.name, this.score,}); + //用于将JSON字典转换成类对象的工厂类方法 + factory Student.fromJson(Map parsedJson){ + return Student( + id: parsedJson['id'], + name : parsedJson['name'], + score : parsedJson ['score'], + ); + } +} + + +JSON类拥有一个可以将JSON字典转换成类对象的工厂类方法,我们也可以提供将类对象反过来转换成JSON字典的实例方法。因为最终存入数据库的并不是实体类对象,而是字符串、整型等基本类型组成的字典,所以我们可以通过这两个方法,实现数据库的读写。同时,我们还分别定义了3个Student对象,用于后续插入数据库: + +class Student{ + ... + //将类对象转换成JSON字典,方便插入数据库 + Map toJson() { + return {'id': id, 'name': name, 'score': score,}; + } +} + +var student1 = Student(id: '123', name: '张三', score: 90); +var student2 = Student(id: '456', name: '李四', score: 80); +var student3 = Student(id: '789', name: '王五', score: 85); + + +有了实体类作为数据库存储的对象,接下来就需要创建数据库了。在下面的代码中,我们通过openDatabase函数,给定了一个数据库存储地址,并通过数据库表初始化语句,创建了一个用于存放Student对象的students表: + +final Future database = openDatabase( + join(await getDatabasesPath(), 'students_database.db'), + onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"), + onUpgrade: (db, oldVersion, newVersion){ + //dosth for migration + }, + version: 1, +); + + +以上代码属于通用的数据库创建模板,有三个地方需要注意: + + +在设定数据库存储地址时,使用join方法对两段地址进行拼接。join方法在拼接时会使用操作系统的路径分隔符,这样我们就无需关心路径分隔符究竟是“/”还是“\”了。 +创建数据库时,传入了一个version 1,在onCreate方法的回调里面也有一个version。这两个version是相等的。 +数据库只会创建一次,也就意味着onCreate方法在应用从安装到卸载的生命周期中只会执行一次。如果我们在版本升级过程中,想对数据库的存储字段进行改动又该如何处理呢?- +sqlite提供了onUpgrade方法,我们可以根据这个方法传入的oldVersion和newVersion确定升级策略。其中,前者代表用户手机上的数据库版本,而后者代表当前版本的数据库版本。比如,我们的应用有1.0、1.1和1.2三个版本,在1.1把数据库version升级到了2。考虑到用户的升级顺序并不总是连续的,可能会直接从1.0升级到1.2,因此我们可以在onUpgrade函数中,对数据库当前版本和用户手机上的数据库版本进行比较,制定数据库升级方案。 + + +数据库创建好了之后,接下来我们就可以把之前创建的3个Student对象插入到数据库中了。数据库的插入需要调用insert方法,在下面的代码中,我们将Student对象转换成了JSON,在指定了插入冲突策略(如果同样的对象被插入两次,则后者替换前者)和目标数据库表后,完成了Student对象的插入: + +Future insertStudent(Student std) async { + final Database db = await database; + await db.insert( + 'students', + std.toJson(), + //插入冲突策略,新的替换旧的 + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} +//插入3个Student对象 +await insertStudent(student1); +await insertStudent(student2); +await insertStudent(student3); + + +数据完成插入之后,接下来我们就可以调用query方法把它们取出来了。需要注意的是,写入的时候我们是一个接一个地有序插入,读的时候我们则采用批量读的方式(当然也可以指定查询规则读特定对象)。读出来的数据是一个JSON字典数组,因此我们还需要把它转换成Student数组。最后,别忘了把数据库资源释放掉: + +Future> students() async { + final Database db = await database; + final List> maps = await db.query('students'); + return List.generate(maps.length, (i)=>Student.fromJson(maps[i])); +} + +//读取出数据库中插入的Student对象集合 +students().then((list)=>list.forEach((s)=>print(s.name))); +//释放数据库资源 +final Database db = await database; +db.close(); + + +可以看到,在面对大量格式化的数据模型读取时,数据库提供了更快、更灵活的持久化解决方案。 + +除了基础的数据库读写操作之外,sqlite还提供了更新、删除以及事务等高级特性,这与原生Android、iOS上的SQLite或是MySQL并无不同,因此这里就不再赘述了。你可以参考sqflite插件的API文档,或是查阅SQLite教程了解具体的使用方法。 + +总结 + +好了,今天的分享就这里。我们简单回顾下今天学习的内容吧。 + +首先,我带你学习了文件,这种最常见的数据持久化方式。Flutter提供了两类目录,即临时目录与文档目录。我们可以根据实际需求,通过写入字符串或二进制流,实现数据的持久化。 + +然后,我通过一个小例子和你讲述了SharedPreferences,这种适用于持久化小型键值对的存储方案。 + +最后,我们一起学习了数据库。围绕如何将一个对象持久化到数据库,我与你介绍了数据库的创建、写入和读取方法。可以看到,使用数据库的方式虽然前期准备工作多了不少,但面对持续变更的需求,适配能力和灵活性都更强了。 + +数据持久化是CPU密集型运算,因此数据存取均会大量涉及到异步操作,所以请务必使用异步等待或注册then回调,正确处理读写操作的时序关系。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留下两道思考题吧。 + + +请你分别介绍一下文件、SharedPreferences和数据库,这三种持久化数据存储方式的适用场景。 +我们的应用经历了1.0、1.1和1.2三个版本。其中,1.0版本新建了数据库并创建了Student表,1.1版本将Student表增加了一个字段age(ALTER TABLE students ADD age INTEGER)。请你写出1.1版本及1.2版本的数据库升级代码。 + + +//1.0版本数据库创建代码 +final Future database = openDatabase( + join(await getDatabasesPath(), 'students_database.db'), + onCreate: (db, version)=>db.execute("CREATE TABLE students(id TEXT PRIMARY KEY, name TEXT, score INTEGER)"), + onUpgrade: (db, oldVersion, newVersion){ + //dosth for migration + }, + version: 1, +); + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/26如何在Dart层兼容Android_iOS平台特定实现?(一).md b/专栏/Flutter核心技术与实战/26如何在Dart层兼容Android_iOS平台特定实现?(一).md new file mode 100644 index 0000000..b90904e --- /dev/null +++ b/专栏/Flutter核心技术与实战/26如何在Dart层兼容Android_iOS平台特定实现?(一).md @@ -0,0 +1,170 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 如何在Dart层兼容Android_iOS平台特定实现?(一) + 你好,我是陈航。 + +在上一篇文章中,我与你介绍了在Flutter中实现数据持久化的三种方式,即文件、SharedPreferences与数据库。 + +其中,文件适用于字符串或者二进制流的数据持久化,我们可以根据访问频次,决定将它存在临时目录或是文档目录。而SharedPreferences则适用于存储小型键值对信息,可以应对一些轻量配置缓存的场景。数据库则适用于频繁变化的、结构化的对象存取,可以轻松应对数据的增删改查。 + +依托于与Skia的深度定制及优化,Flutter给我们提供了很多关于渲染的控制和支持,能够实现绝对的跨平台应用层渲染一致性。但对于一个应用而言,除了应用层视觉显示和对应的交互逻辑处理之外,有时还需要原生操作系统(Android、iOS)提供的底层能力支持。比如,我们前面提到的数据持久化,以及推送、摄像头硬件调用等。 + +由于Flutter只接管了应用渲染层,因此这些系统底层能力是无法在Flutter框架内提供支持的;而另一方面,Flutter还是一个相对年轻的生态,因此原生开发中一些相对成熟的Java、C++或Objective-C代码库,比如图片处理、音视频编解码等,可能在Flutter中还没有相关实现。 + +因此,为了解决调用原生系统底层能力以及相关代码库复用问题,Flutter为开发者提供了一个轻量级的解决方案,即逻辑层的方法通道(Method Channel)机制。基于方法通道,我们可以将原生代码所拥有的能力,以接口形式暴露给Dart,从而实现Dart代码与原生代码的交互,就像调用了一个普通的Dart API一样。 + +接下来,我就与你详细讲述Flutter的方法通道机制吧。 + +方法通道 + +Flutter作为一个跨平台框架,提供了一套标准化的解决方案,为开发者屏蔽了操作系统的差异。但,Flutter毕竟不是操作系统,因此在某些特定场景下(比如推送、蓝牙、摄像头硬件调用时),也需要具备直接访问系统底层原生代码的能力。为此,Flutter提供了一套灵活而轻量级的机制来实现Dart和原生代码之间的通信,即方法调用的消息传递机制,而方法通道则是用来传递通信消息的信道。 + +一次典型的方法调用过程类似网络调用,由作为客户端的Flutter,通过方法通道向作为服务端的原生代码宿主发送方法调用请求,原生代码宿主在监听到方法调用的消息后,调用平台相关的API来处理Flutter发起的请求,最后将处理完毕的结果通过方法通道回发至Flutter。调用过程如下图所示: + + + +图1 方法通道示意图 + +从上图中可以看到,方法调用请求的处理和响应,在Android中是通过FlutterView,而在iOS中则是通过FlutterViewController进行注册的。FlutterView与FlutterViewController为Flutter应用提供了一个画板,使得构建于Skia之上的Flutter通过绘制即可实现整个应用所需的视觉效果。因此,它们不仅是Flutter应用的容器,同时也是Flutter应用的入口,自然也是注册方法调用请求最合适的地方。 + +接下来,我通过一个例子来演示如何使用方法通道实现与原生代码的交互。 + +方法通道使用示例 + +在实际业务中,提示用户跳转到应用市场(iOS为App Store、Android则为各类手机应用市场)去评分是一个高频需求,考虑到Flutter并未提供这样的接口,而跳转方式在Android和iOS上各不相同,因此我们需要分别在Android和iOS上实现这样的功能,并暴露给Dart相关的接口。 + +我们先来看看作为客户端的Flutter,怎样实现一次方法调用请求。 + +Flutter如何实现一次方法调用请求? + +首先,我们需要确定一个唯一的字符串标识符,来构造一个命名通道;然后,在这个通道之上,Flutter通过指定方法名“openAppMarket”来发起一次方法调用请求。 + +可以看到,这和我们平时调用一个Dart对象的方法完全一样。因为方法调用过程是异步的,所以我们需要使用非阻塞(或者注册回调)来等待原生代码给予响应。 + +//声明MethodChannel +const platform = MethodChannel('samples.chenhang/utils'); + +//处理按钮点击 +handleButtonClick() async{ + int result; + //异常捕获 + try { + //异步等待方法通道的调用结果 + result = await platform.invokeMethod('openAppMarket'); + } + catch (e) { + result = -1; + } + print("Result:$result"); +} + + +需要注意的是,与网络调用类似,方法调用请求有可能会失败(比如,Flutter发起了原生代码不支持的API调用,或是调用过程出错等),因此我们需要把发起方法调用请求的语句用try-catch包装起来。 + +调用方的实现搞定了,接下来就需要在原生代码宿主中完成方法调用的响应实现了。由于我们需要适配Android和iOS两个平台,所以我们分别需要在两个平台上完成对应的接口实现。 + +在原生代码中完成方法调用的响应 + +首先,我们来看看Android端的实现方式。在上一小结最后我提到,在Android平台,方法调用的处理和响应是在Flutter应用的入口,也就是在MainActivity中的FlutterView里实现的,因此我们需要打开Flutter的Android宿主App,找到MainActivity.java文件,并在其中添加相关的逻辑。 + +调用方与响应方都是通过命名通道进行信息交互的,所以我们需要在onCreate方法中,创建一个与调用方Flutter所使用的通道名称一样的MethodChannel,并在其中设置方法处理回调,响应openAppMarket方法,打开应用市场的Intent。同样地,考虑到打开应用市场的过程可能会出错,我们也需要增加try-catch来捕获可能的异常: + +protected void onCreate(Bundle savedInstanceState) { + ... + //创建与调用方标识符一样的方法通道 + new MethodChannel(getFlutterView(), "samples.chenhang/utils").setMethodCallHandler( + //设置方法处理回调 + new MethodCallHandler() { + //响应方法请求 + @Override + public void onMethodCall(MethodCall call, Result result) { + //判断方法名是否支持 + if(call.method.equals("openAppMarket")) { + try { + //应用市场URI + Uri uri = Uri.parse("market://details?id=com.hangchen.example.flutter_module_page.host"); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + //打开应用市场 + activity.startActivity(intent); + //返回处理结果 + result.success(0); + } catch (Exception e) { + //打开应用市场出现异常 + result.error("UNAVAILABLE", "没有安装应用市场", null); + } + }else { + //方法名暂不支持 + result.notImplemented(); + } + } + }); +} + + +现在,方法调用响应的Android部分已经搞定,接下来我们来看一下iOS端的方法调用响应如何实现。 + +在iOS平台,方法调用的处理和响应是在Flutter应用的入口,也就是在Applegate中的rootViewController(即FlutterViewController)里实现的,因此我们需要打开Flutter的iOS宿主App,找到AppDelegate.m文件,并添加相关逻辑。 + +与Android注册方法调用响应类似,我们需要在didFinishLaunchingWithOptions:方法中,创建一个与调用方Flutter所使用的通道名称一样的MethodChannel,并在其中设置方法处理回调,响应openAppMarket方法,通过URL打开应用市场: + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + //创建命名方法通道 + FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/utils" binaryMessenger:(FlutterViewController *)self.window.rootViewController]; + //往方法通道注册方法调用处理回调 + [channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + //方法名称一致 + if ([@"openAppMarket" isEqualToString:call.method]) { + //打开App Store(本例打开微信的URL) + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"itms-apps://itunes.apple.com/xy/app/foo/id414478124"]]; + //返回方法处理结果 + result(@0); + } else { + //找不到被调用的方法 + result(FlutterMethodNotImplemented); + } + }]; + ... +} + + +这样,iOS端的方法调用响应也已经实现了。 + +接下来,我们就可以在Flutter应用里,通过调用openAppMarket方法,实现打开不同操作系统提供的应用市场功能了。 + +需要注意的是,在原生代码处理完毕后将处理结果返回给Flutter时,我们在Dart、Android和iOS分别用了三种数据类型:Android端返回的是java.lang.Integer、iOS端返回的是NSNumber、Dart端接收到返回结果时又变成了int类型。这是为什么呢? + +这是因为在使用方法通道进行方法调用时,由于涉及到跨系统数据交互,Flutter会使用StandardMessageCodec对通道中传输的信息进行类似JSON的二进制序列化,以标准化数据传输行为。这样在我们发送或者接收数据时,这些数据就会根据各自系统预定的规则自动进行序列化和反序列化。看到这里,你是不是对这样类似网络调用的方法通道技术有了更深刻的印象呢。 + +对于上面提到的例子,类型为java.lang.Integer或NSNumber的返回值,先是被序列化成了一段二进制格式的数据在通道中传输,然后当该数据传递到Flutter后,又被反序列化成了Dart语言中的int类型的数据。 + +关于Android、iOS和Dart平台间的常见数据类型转换,我总结成了下面一张表格,帮助你理解与记忆。你只要记住,像null、布尔、整型、字符串、数组和字典这些基本类型,是可以在各个平台之间以平台定义的规则去混用的,就可以了。 + + + +图2 Android、iOS和Dart平台间的常见数据类型转换 + +总结 + +好了,今天的分享就到这里,我们来总结一下主要内容吧。 + +方法通道解决了逻辑层的原生能力复用问题,使得Flutter能够通过轻量级的异步方法调用,实现与原生代码的交互。一次典型的调用过程由Flutter发起方法调用请求开始,请求经由唯一标识符指定的方法通道到达原生代码宿主,而原生代码宿主则通过注册对应方法实现、响应并处理调用请求,最后将执行结果通过消息通道,回传至Flutter。 + +需要注意的是,方法通道是非线程安全的。这意味着原生代码与Flutter之间所有接口调用必须发生在主线程。Flutter是单线程模型,因此自然可以确保方法调用请求是发生在主线程(Isolate)的;而原生代码在处理方法调用请求时,如果涉及到异步或非主线程切换,需要确保回调过程是在原生系统的UI线程(也就是Android和iOS的主线程)中执行的,否则应用可能会出现奇怪的Bug,甚至是Crash。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解。 + +思考题 + +最后,我给你留下一道思考题吧。 + +请扩展方法通道示例,让openAppMarket支持传入AppID和包名,使得我们可以跳转到任意一个App的应用市场。 + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/27如何在Dart层兼容Android_iOS平台特定实现?(二).md b/专栏/Flutter核心技术与实战/27如何在Dart层兼容Android_iOS平台特定实现?(二).md new file mode 100644 index 0000000..9d6df8b --- /dev/null +++ b/专栏/Flutter核心技术与实战/27如何在Dart层兼容Android_iOS平台特定实现?(二).md @@ -0,0 +1,419 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 如何在Dart层兼容Android_iOS平台特定实现?(二) + 你好,我是陈航。 + +在上一篇文章中,我与你介绍了方法通道,这种在Flutter中实现调用原生Android、iOS代码的轻量级解决方案。使用方法通道,我们可以把原生代码所拥有的能力,以接口形式提供给Dart。 + +这样,当发起方法调用时,Flutter应用会以类似网络异步调用的方式,将请求数据通过一个唯一标识符指定的方法通道传输至原生代码宿主;而原生代码处理完毕后,会将响应结果通过方法通道回传至Flutter,从而实现Dart代码与原生Android、iOS代码的交互。这,与调用一个本地的Dart 异步API并无太多区别。 + +通过方法通道,我们可以把原生操作系统提供的底层能力,以及现有原生开发中一些相对成熟的解决方案,以接口封装的形式在Dart层快速搞定,从而解决原生代码在Flutter上的复用问题。然后,我们可以利用Flutter本身提供的丰富控件,做好UI渲染。 + +底层能力+应用层渲染,看似我们已经搞定了搭建一个复杂App的所有内容。但,真的是这样吗? + +构建一个复杂App都需要什么? + +别急,在下结论之前,我们先按照四象限分析法,把能力和渲染分解成四个维度,分析构建一个复杂App都需要什么。 + + + +图1 四象限分析法 + +经过分析,我们终于发现,原来构建一个App需要覆盖那么多的知识点,通过Flutter和方法通道只能搞定应用层渲染、应用层能力和底层能力,对于那些涉及到底层渲染,比如浏览器、相机、地图,以及原生自定义视图的场景,自己在Flutter上重新开发一套显然不太现实。 + +在这种情况下,使用混合视图看起来是一个不错的选择。我们可以在Flutter的Widget树中提前预留一块空白区域,在Flutter的画板中(即FlutterView与FlutterViewController)嵌入一个与空白区域完全匹配的原生视图,就可以实现想要的视觉效果了。 + +但是,采用这种方案极其不优雅,因为嵌入的原生视图并不在Flutter的渲染层级中,需要同时在Flutter侧与原生侧做大量的适配工作,才能实现正常的用户交互体验。 + +幸运的是,Flutter提供了一个平台视图(Platform View)的概念。它提供了一种方法,允许开发者在Flutter里面嵌入原生系统(Android和iOS)的视图,并加入到Flutter的渲染树中,实现与Flutter一致的交互体验。 + +这样一来,通过平台视图,我们就可以将一个原生控件包装成Flutter控件,嵌入到Flutter页面中,就像使用一个普通的Widget一样。 + +接下来,我就与你详细讲述如何使用平台视图。 + +平台视图 + +如果说方法通道解决的是原生能力逻辑复用问题,那么平台视图解决的就是原生视图复用问题。Flutter提供了一种轻量级的方法,让我们可以创建原生(Android和iOS)的视图,通过一些简单的Dart层接口封装之后,就可以将它插入Widget树中,实现原生视图与Flutter视图的混用。 + +一次典型的平台视图使用过程与方法通道类似: + + +首先,由作为客户端的Flutter,通过向原生视图的Flutter封装类(在iOS和Android平台分别是UIKitView和AndroidView)传入视图标识符,用于发起原生视图的创建请求; +然后,原生代码侧将对应原生视图的创建交给平台视图工厂(PlatformViewFactory)实现; +最后,在原生代码侧将视图标识符与平台视图工厂进行关联注册,让Flutter发起的视图创建请求可以直接找到对应的视图创建工厂。 + + +至此,我们就可以像使用Widget那样,使用原生视图了。整个流程,如下图所示: + + + +图2 平台视图示例 + +接下来,我以一个具体的案例,也就是将一个红色的原生视图内嵌到Flutter中,与你演示如何使用平台视图。这部分内容主要包括两部分: + + +作为调用发起方的Flutter,如何实现原生视图的接口调用? +如何在原生(Android和iOS)系统实现接口? + + +接下来,我将分别与你讲述这两个问题。 + +Flutter如何实现原生视图的接口调用? + +在下面的代码中,我们在SampleView的内部,分别使用了原生Android、iOS视图的封装类AndroidView和UIkitView,并传入了一个唯一标识符,用于和原生视图建立关联: + +class SampleView extends StatelessWidget { + @override + Widget build(BuildContext context) { + //使用Android平台的AndroidView,传入唯一标识符sampleView + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidView(viewType: 'sampleView'); + } else { + //使用iOS平台的UIKitView,传入唯一标识符sampleView + return UiKitView(viewType: 'sampleView'); + } + } +} + + +可以看到,平台视图在Flutter侧的使用方式比较简单,与普通Widget并无明显区别。而关于普通Widget的使用方式,你可以参考第12、13篇的相关内容进行复习。 + +调用方的实现搞定了。接下来,我们需要在原生代码中完成视图创建的封装,建立相关的绑定关系。同样的,由于需要同时适配Android和iOS平台,我们需要分别在两个系统上完成对应的接口实现。 + +如何在原生系统实现接口? + +首先,我们来看看Android端的实现。在下面的代码中,我们分别创建了平台视图工厂和原生视图封装类,并通过视图工厂的create方法,将它们关联起来: + +//视图工厂类 +class SampleViewFactory extends PlatformViewFactory { + private final BinaryMessenger messenger; + //初始化方法 + public SampleViewFactory(BinaryMessenger msger) { + super(StandardMessageCodec.INSTANCE); + messenger = msger; + } + //创建原生视图封装类,完成关联 + @Override + public PlatformView create(Context context, int id, Object obj) { + return new SimpleViewControl(context, id, messenger); + } +} +//原生视图封装类 +class SimpleViewControl implements PlatformView { + private final View view;//缓存原生视图 + //初始化方法,提前创建好视图 + public SimpleViewControl(Context context, int id, BinaryMessenger messenger) { + view = new View(context); + view.setBackgroundColor(Color.rgb(255, 0, 0)); + } + + //返回原生视图 + @Override + public View getView() { + return view; + } + //原生视图销毁回调 + @Override + public void dispose() { + } +} + + +将原生视图封装类与原生视图工厂完成关联后,接下来就需要将Flutter侧的调用与视图工厂绑定起来了。与上一篇文章讲述的方法通道类似,我们仍然需要在MainActivity中进行绑定操作: + +protected void onCreate(Bundle savedInstanceState) { + ... + Registrar registrar = registrarFor("samples.chenhang/native_views");//生成注册类 + SampleViewFactory playerViewFactory = new SampleViewFactory(registrar.messenger());//生成视图工厂 + +registrar.platformViewRegistry().registerViewFactory("sampleView", playerViewFactory);//注册视图工厂 +} + + +完成绑定之后,平台视图调用响应的Android部分就搞定了。 + +接下来,我们再来看看iOS端的实现。 + +与Android类似,我们同样需要分别创建平台视图工厂和原生视图封装类,并通过视图工厂的create方法,将它们关联起来: + +//平台视图工厂 +@interface SampleViewFactory : NSObject +- (instancetype)initWithMessenger:(NSObject*)messager; +@end + +@implementation SampleViewFactory{ + NSObject*_messenger; +} + +- (instancetype)initWithMessenger:(NSObject *)messager{ + self = [super init]; + if (self) { + _messenger = messager; + } + return self; +} + +-(NSObject *)createArgsCodec{ + return [FlutterStandardMessageCodec sharedInstance]; +} + +//创建原生视图封装实例 +-(NSObject *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args{ + SampleViewControl *activity = [[SampleViewControl alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger]; + return activity; +} +@end + +//平台视图封装类 +@interface SampleViewControl : NSObject +- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject*)messenger; +@end + +@implementation SampleViewControl{ + UIView * _templcateView; +} +//创建原生视图 +- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject *)messenger{ + if ([super init]) { + _templcateView = [[UIView alloc] init]; + _templcateView.backgroundColor = [UIColor redColor]; + } + return self; +} + +-(UIView *)view{ + return _templcateView; +} + +@end + + +然后,我们同样需要把原生视图的创建与Flutter侧的调用关联起来,才可以在Flutter侧找到原生视图的实现: + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + NSObject* registrar = [self registrarForPlugin:@"samples.chenhang/native_views"];//生成注册类 + SampleViewFactory* viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];//生成视图工厂 + [registrar registerViewFactory:viewFactory withId:@"sampleView"];//注册视图工厂 + ... +} + + +需要注意的是,在iOS平台上,Flutter内嵌UIKitView目前还处于技术预览状态,因此我们还需要在Info.plist文件中增加一项配置,把内嵌原生视图的功能开关设置为true,才能打开这个隐藏功能: + + + ... + io.flutter.embedded_views_preview + + .... + + + +经过上面的封装与绑定,Android端与iOS端的平台视图功能都已经实现了。接下来,我们就可以在Flutter应用里,像使用普通Widget一样,去内嵌原生视图了: + + Scaffold( + backgroundColor: Colors.yellowAccent, + body: Container(width: 200, height:200, + child: SampleView(controller: controller) + )); + + +如下所示,我们分别在iOS和Android平台的Flutter应用上,内嵌了一个红色的原生视图: + + + +图3 内嵌原生视图示例 + +在上面的例子中,我们将原生视图封装在一个StatelessWidget中,可以有效应对静态展示的场景。如果我们需要在程序运行时动态调整原生视图的样式,又该如何处理呢? + +如何在程序运行时,动态地调整原生视图的样式? + +与基于声明式的Flutter Widget,每次变化只能以数据驱动其视图销毁重建不同,原生视图是基于命令式的,可以精确地控制视图展示样式。因此,我们可以在原生视图的封装类中,将其持有的修改视图实例相关的接口,以方法通道的方式暴露给Flutter,让Flutter也可以拥有动态调整视图视觉样式的能力。 + +接下来,我以一个具体的案例来演示如何在程序运行时动态调整内嵌原生视图的背景颜色。 + +在这个案例中,我们会用到原生视图的一个初始化属性,即onPlatformViewCreated:原生视图会在其创建完成后,以回调的形式通知视图id,因此我们可以在这个时候注册方法通道,让后续的视图修改请求通过这条通道传递给原生视图。 + +由于我们在底层直接持有了原生视图的实例,因此理论上可以直接在这个原生视图的Flutter封装类上提供视图修改方法,而不管它到底是StatelessWidget还是StatefulWidget。但为了遵照Flutter的Widget设计理念,我们还是决定将视图展示与视图控制分离,即:将原生视图封装为一个StatefulWidget专门用于展示,通过其controller初始化参数,在运行期修改原生视图的展示效果。如下所示: + +//原生视图控制器 +class NativeViewController { + MethodChannel _channel; + //原生视图完成创建后,通过id生成唯一方法通道 + onCreate(int id) { + _channel = MethodChannel('samples.chenhang/native_views_$id'); + } + //调用原生视图方法,改变背景颜色 + Future changeBackgroundColor() async { + return _channel.invokeMethod('changeBackgroundColor'); + } +} + +//原生视图Flutter侧封装,继承自StatefulWidget +class SampleView extends StatefulWidget { + const SampleView({ + Key key, + this.controller, + }) : super(key: key); + + //持有视图控制器 + final NativeViewController controller; + @override + State createState() => _SampleViewState(); +} + +class _SampleViewState extends State { + //根据平台确定返回何种平台视图 + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidView( + viewType: 'sampleView', + //原生视图创建完成后,通过onPlatformViewCreated产生回调 + onPlatformViewCreated: _onPlatformViewCreated, + ); + } else { + return UiKitView(viewType: 'sampleView', + //原生视图创建完成后,通过onPlatformViewCreated产生回调 + onPlatformViewCreated: _onPlatformViewCreated + ); + } + } + //原生视图创建完成后,调用control的onCreate方法,传入view id + _onPlatformViewCreated(int id) { + if (widget.controller == null) { + return; + } + widget.controller.onCreate(id); + } +} + + +Flutter的调用方实现搞定了,接下来我们分别看看Android和iOS端的实现。 + +程序的整体结构与之前并无不同,只是在进行原生视图初始化时,我们需要完成方法通道的注册和相关事件的处理;在响应方法调用消息时,我们需要判断方法名,如果完全匹配,则修改视图背景,否则返回异常。 + +Android端接口实现代码如下所示: + +class SimpleViewControl implements PlatformView, MethodCallHandler { + private final MethodChannel methodChannel; + ... + public SimpleViewControl(Context context, int id, BinaryMessenger messenger) { + ... + //用view id注册方法通道 + methodChannel = new MethodChannel(messenger, "samples.chenhang/native_views_" + id); + //设置方法通道回调 + methodChannel.setMethodCallHandler(this); + } + //处理方法调用消息 + @Override + public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) { + //如果方法名完全匹配 + if (methodCall.method.equals("changeBackgroundColor")) { + //修改视图背景,返回成功 + view.setBackgroundColor(Color.rgb(0, 0, 255)); + result.success(0); + }else { + //调用方发起了一个不支持的API调用 + result.notImplemented(); + } + } + ... +} + + +iOS端接口实现代码: + +@implementation SampleViewControl{ + ... + FlutterMethodChannel* _channel; +} + +- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject *)messenger{ + if ([super init]) { + ... + //使用view id完成方法通道的创建 + _channel = [FlutterMethodChannel methodChannelWithName:[NSString stringWithFormat:@"samples.chenhang/native_views_%lld", viewId] binaryMessenger:messenger]; + //设置方法通道的处理回调 + __weak __typeof__(self) weakSelf = self; + [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + [weakSelf onMethodCall:call result:result]; + }]; + } + return self; +} + +//响应方法调用消息 +- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + //如果方法名完全匹配 + if ([[call method] isEqualToString:@"changeBackgroundColor"]) { + //修改视图背景色,返回成功 + _templcateView.backgroundColor = [UIColor blueColor]; + result(@0); + } else { + //调用方发起了一个不支持的API调用 + result(FlutterMethodNotImplemented); + } +} + ... +@end + + +通过注册方法通道,以及暴露的changeBackgroundColor接口,Android端与iOS端修改平台视图背景颜色的功能都已经实现了。接下来,我们就可以在Flutter应用运行期间,修改原生视图展示样式了: + +class DefaultState extends State { + NativeViewController controller; + @override + void initState() { + controller = NativeViewController();//初始化原生View控制器 + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + ... + //内嵌原生View + body: Container(width: 200, height:200, + child: SampleView(controller: controller) + ), + //设置点击行为:改变视图颜色 + floatingActionButton: FloatingActionButton(onPressed: ()=>controller.changeBackgroundColor()) + ); + } +} + + +运行一下,效果如下所示: + + + +图4 动态修改原生视图样式 + +总结 + +好了,今天的分享就到这里。我们总结一下今天的主要内容吧。 + +平台视图解决了原生渲染能力的复用问题,使得Flutter能够通过轻量级的代码封装,把原生视图组装成一个Flutter控件。 + +Flutter提供了平台视图工厂和视图标识符两个概念,因此Dart层发起的视图创建请求可以通过标识符直接找到对应的视图创建工厂,从而实现原生视图与Flutter视图的融合复用。对于需要在运行期动态调用原生视图接口的需求,我们可以在原生视图的封装类中注册方法通道,实现精确控制原生视图展示的效果。 + +需要注意的是,由于Flutter与原生渲染方式完全不同,因此转换不同的渲染数据会有较大的性能开销。如果在一个界面上同时实例化多个原生控件,就会对性能造成非常大的影响,所以我们要避免在使用Flutter控件也能实现的情况下去使用内嵌平台视图。 + +因为这样做,一方面需要分别在Android和iOS端写大量的适配桥接代码,违背了跨平台技术的本意,也增加了后续的维护成本;另一方面毕竟除去地图、WebView、相机等涉及底层方案的特殊情况外,大部分原生代码能够实现的UI效果,完全可以用Flutter实现。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解。 + +思考题 + +最后,我给你留下一道思考题吧。 + +请你在动态调整原生视图样式的代码基础上,增加颜色参数,以实现动态变更原生视图颜色的需求。 + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/28如何在原生应用中混编Flutter工程?.md b/专栏/Flutter核心技术与实战/28如何在原生应用中混编Flutter工程?.md new file mode 100644 index 0000000..5d917d8 --- /dev/null +++ b/专栏/Flutter核心技术与实战/28如何在原生应用中混编Flutter工程?.md @@ -0,0 +1,273 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 如何在原生应用中混编Flutter工程? + 你好,我是陈航。今天,我来和你聊聊如何在原生应用中接入Flutter。 + +在前面两篇文章中,我与你分享了如何在Dart层引入Android/iOS平台特定的能力,来提升App的功能体验。 + +使用Flutter从头开始写一个App,是一件轻松惬意的事情。但,对于成熟产品来说,完全摒弃原有App的历史沉淀,而全面转向Flutter并不现实。用Flutter去统一iOS/Android技术栈,把它作为已有原生App的扩展能力,通过逐步试验有序推进从而提升终端开发效率,可能才是现阶段Flutter最具吸引力的地方。 + +那么,Flutter工程与原生工程该如何组织管理?不同平台的Flutter工程打包构建产物该如何抽取封装?封装后的产物该如何引入原生工程?原生工程又该如何使用封装后的Flutter能力? + +这些问题使得在已有原生App中接入Flutter看似并不是一件容易的事情。那接下来,我就和你介绍下如何在原生App中以最自然的方式接入Flutter。 + +准备工作 + +既然是要在原生应用中混编Flutter,相信你一定已经准备好原生应用工程来实施今天的改造了。如果你还没有准备好也没关系,我会以一个最小化的示例和你演示这个改造过程。 + +首先,我们分别用Xcode与Android Studio快速建立一个只有首页的基本工程,工程名分别为iOSDemo与AndroidDemo。 + +这时,Android工程就已经准备好了;而对于iOS工程来说,由于基本工程并不支持以组件化的方式管理项目,因此我们还需要多做一步,将其改造成使用CocoaPods管理的工程,也就是要在iOSDemo根目录下创建一个只有基本信息的Podfile文件: + +use_frameworks! +platform :ios, '8.0' +target 'iOSDemo' do +#todo +end + + +然后,在命令行输入pod install后,会自动生成一个iOSDemo.xcworkspace文件,这时我们就完成了iOS工程改造。 + +Flutter混编方案介绍 + +如果你想要在已有的原生App里嵌入一些Flutter页面,有两个办法: + + +将原生工程作为Flutter工程的子工程,由Flutter统一管理。这种模式,就是统一管理模式。 +将Flutter工程作为原生工程共用的子模块,维持原有的原生工程管理方式不变。这种模式,就是三端分离模式。 + + + + +图1 Flutter混编工程管理方式 + +由于Flutter早期提供的混编方式能力及相关资料有限,国内较早使用Flutter混合开发的团队大多使用的是统一管理模式。但是,随着功能迭代的深入,这种方案的弊端也随之显露,不仅三端(Android、iOS、Flutter)代码耦合严重,相关工具链耗时也随之大幅增长,导致开发效率降低。 + +所以,后续使用Flutter混合开发的团队陆续按照三端代码分离的模式来进行依赖治理,实现了Flutter工程的轻量级接入。 + +除了可以轻量级接入,三端代码分离模式把Flutter模块作为原生工程的子模块,还可以快速实现Flutter功能的“热插拔”,降低原生工程的改造成本。而Flutter工程通过Android Studio进行管理,无需打开原生工程,可直接进行Dart代码和原生代码的开发调试。 + +三端工程分离模式的关键是抽离Flutter工程,将不同平台的构建产物依照标准组件化的形式进行管理,即Android使用aar、iOS使用pod。换句话说,接下来介绍的混编方案会将Flutter模块打包成aar和pod,这样原生工程就可以像引用其他第三方原生组件库那样快速接入Flutter了。 + +听起来是不是很兴奋?接下来,我们就开始正式采用三端分离模式来接入Flutter模块吧。 + +集成Flutter + +我曾在前面的文章中提到,Flutter的工程结构比较特殊,包括Flutter工程和原生工程的目录(即iOS和Android两个目录)。在这种情况下,原生工程就会依赖于Flutter相关的库和资源,从而无法脱离父目录进行独立构建和运行。 + +原生工程对Flutter的依赖主要分为两部分: + + +Flutter库和引擎,也就是Flutter的Framework库和引擎库; +Flutter工程,也就是我们自己实现的Flutter模块功能,主要包括Flutter工程lib目录下的Dart代码实现的这部分功能。 + + +在已经有原生工程的情况下,我们需要在同级目录创建Flutter模块,构建iOS和Android各自的Flutter依赖库。这也很好实现,Flutter就为我们提供了这样的命令。我们只需要在原生项目的同级目录下,执行Flutter命令创建名为flutter_library的模块即可: + +Flutter create -t module flutter_library + + +这里的Flutter模块,也是Flutter工程,我们用Android Studio打开它,其目录如下图所示: + + + +图2 Flutter模块工程结构 + +可以看到,和传统的Flutter工程相比,Flutter模块工程也有内嵌的Android工程与iOS工程,因此我们可以像普通工程一样使用Android Studio进行开发调试。 + +仔细查看可以发现,Flutter模块有一个细微的变化:Android工程下多了一个Flutter目录,这个目录下的build.gradle配置就是我们构建aar的打包配置。这就是模块工程既能像Flutter传统工程一样使用Android Studio开发调试,又能打包构建aar与pod的秘密。 + +实际上,iOS工程的目录结构也有细微变化,但这个差异并不影响打包构建,因此我就不再展开了。 + +然后,我们打开main.dart文件,将其逻辑更新为以下代码逻辑,即一个写着“Hello from Flutter”的全屏红色的Flutter Widget: + +import 'package:flutter/material.dart'; +import 'dart:ui'; + +void main() => runApp(_widgetForRoute(window.defaultRouteName));//独立运行传入默认路由 + +Widget _widgetForRoute(String route) { + switch (route) { + default: + return MaterialApp( + home: Scaffold( + backgroundColor: const Color(0xFFD63031),//ARGB红色 + body: Center( + child: Text( + 'Hello from Flutter', //显示的文字 + textDirection: TextDirection.ltr, + style: TextStyle( + fontSize: 20.0, + color: Colors.blue, + ), + ), + ), + ), + ); + } +} + + +注意:我们创建的Widget实际上是包在一个switch-case语句中的。这是因为封装的Flutter模块一般会有多个页面级Widget,原生App代码则会通过传入路由标识字符串,告诉Flutter究竟应该返回何种Widget。为了简化案例,在这里我们忽略标识字符串,统一返回一个MaterialApp。 + +接下来,我们要做的事情就是把这段代码编译打包,构建出对应的Android和iOS依赖库,实现原生工程的接入。 + +现在,我们首先来看看Android工程如何接入。 + +Android模块集成 + +之前我们提到原生工程对Flutter的依赖主要分为两部分,对应到Android平台,这两部分分别是: + + +Flutter库和引擎,也就是icudtl.dat、libFlutter.so,还有一些class文件。这些文件都封装在Flutter.jar中。 +Flutter工程产物,主要包括应用程序数据段isolate_snapshot_data、应用程序指令段isolate_snapshot_instr、虚拟机数据段vm_snapshot_data、虚拟机指令段vm_snapshot_instr、资源文件Flutter_assets。 + + +搞清楚Flutter工程的Android编译产物之后,我们对Android的Flutter依赖抽取步骤如下: + +首先在Flutter_library的根目录下,执行aar打包构建命令: + +Flutter build apk --debug + + +这条命令的作用是编译工程产物,并将Flutter.jar和工程产物编译结果封装成一个aar。你很快就会想到,如果是构建release产物,只需要把debug换成release就可以了。 + +其次,打包构建的flutter-debug.aar位于.android/Flutter/build/outputs/aar/目录下,我们把它拷贝到原生Android工程AndroidDemo的app/libs目录下,并在App的打包配置build.gradle中添加对它的依赖: + +... +repositories { + flatDir { + dirs 'libs' // aar目录 + } +} +android { + ... + compileOptions { + sourceCompatibility 1.8 //Java 1.8 + targetCompatibility 1.8 //Java 1.8 + } + ... +} + +dependencies { + ... + implementation(name: 'flutter-debug', ext: 'aar')//Flutter模块aar + ... +} + + +Sync一下,Flutter模块就被添加到了Android项目中。 + +再次,我们试着改一下MainActivity.java的代码,把它的contentView改成Flutter的widget: + +protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符 + setContentView(FlutterView);//用FlutterView替代Activity的ContentView +} + + +最后点击运行,可以看到一个写着“Hello from Flutter”的全屏红色的Flutter Widget就展示出来了。至此,我们完成了Android工程的接入。 + + + +图3 Android工程接入示例 + +iOS模块集成 + +iOS工程接入的情况要稍微复杂一些。在iOS平台,原生工程对Flutter的依赖分别是: + + +Flutter库和引擎,即Flutter.framework; +Flutter工程的产物,即App.framework。 + + +iOS平台的Flutter模块抽取,实际上就是通过打包命令生成这两个产物,并将它们封装成一个pod供原生工程引用。 + +类似地,首先我们在Flutter_library的根目录下,执行iOS打包构建命令: + +Flutter build ios --debug + + +这条命令的作用是编译Flutter工程生成两个产物:Flutter.framework和App.framework。同样,把debug换成release就可以构建release产物(当然,你还需要处理一下签名问题)。 + +其次,在iOSDemo的根目录下创建一个名为FlutterEngine的目录,并把这两个framework文件拷贝进去。iOS的模块化产物工作要比Android多一个步骤,因为我们需要把这两个产物手动封装成pod。因此,我们还需要在该目录下创建FlutterEngine.podspec,即Flutter模块的组件定义: + +Pod::Spec.new do |s| + s.name = 'FlutterEngine' + s.version = '0.1.0' + s.summary = 'XXXXXXX' + s.description = <<-DESC +TODO: Add long description of the pod here. + DESC + s.homepage = 'https://github.com/xx/FlutterEngine' + s.license = { :type => 'MIT', :file => 'LICENSE' } + s.author = { 'chenhang' => '[email protected]' } + s.source = { :git => "", :tag => "#{s.version}" } + s.ios.deployment_target = '8.0' + s.ios.vendored_frameworks = 'App.framework', 'Flutter.framework' +end + + +pod lib lint一下,Flutter模块组件就已经做好了。趁热打铁,我们再修改Podfile文件把它集成到iOSDemo工程中: + +... +target 'iOSDemo' do + pod 'FlutterEngine', :path => './' +end + + +pod install一下,Flutter模块就集成进iOS原生工程中了。 + +再次,我们试着修改一下AppDelegate.m的代码,把window的rootViewController改成FlutterViewController: + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions + +{ + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + FlutterViewController *vc = [[FlutterViewController alloc]init]; + [vc setInitialRoute:@"defaultRoute"]; //路由标识符 + self.window.rootViewController = vc; + [self.window makeKeyAndVisible]; + return YES; +} + + +最后点击运行,一个写着“Hello from Flutter”的全屏红色的Flutter Widget也展示出来了。至此,iOS工程的接入我们也顺利搞定了。 + + + +图4 iOS工程接入示例 + +总结 + +通过分离Android、iOS和Flutter三端工程,抽离Flutter库和引擎及工程代码为组件库,以Android和iOS平台最常见的aar和pod形式接入原生工程,我们就可以低成本地接入Flutter模块,愉快地使用Flutter扩展原生App的边界了。 + +但,我们还可以做得更好。 + +如果每次通过构建Flutter模块工程,都是手动搬运Flutter编译产物,那很容易就会因为工程管理混乱导致Flutter组件库被覆盖,从而引发难以排查的Bug。而要解决此类问题的话,我们可以引入CI自动构建框架,把Flutter编译产物构建自动化,原生工程通过接入不同版本的构建产物,实现更优雅的三端分离模式。 + +而关于自动化构建,我会在后面的文章中和你详细介绍,这里就不再赘述了。 + +接下来,我们简单回顾一下今天的内容。 + +原生工程混编Flutter的方式有两种。一种是,将Flutter工程内嵌Android和iOS工程,由Flutter统一管理的集中模式;另一种是,将Flutter工程作为原生工程共用的子模块,由原生工程各自管理的三端工程分离模式。目前,业界采用的基本都是第二种方式。 + +而对于三端工程分离模式最主要的则是抽离Flutter工程,将不同平台的构建产物依照标准组件化的形式进行管理,即:针对Android平台打包构建生成aar,通过build.gradle进行依赖管理;针对iOS平台打包构建生成framework,将其封装成独立的pod,并通过podfile进行依赖管理。 + +我把今天分享所涉及到的知识点打包到了GitHub(flutter_module_page、iOS_demo、Android_Demo)中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你下留一个思考题吧。 + +对于有资源依赖的Flutter模块工程而言,其打包构建的产物,以及抽离Flutter组件库的过程会有什么不同吗? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/29混合开发,该用何种方案管理导航栈?.md b/专栏/Flutter核心技术与实战/29混合开发,该用何种方案管理导航栈?.md new file mode 100644 index 0000000..e33c4dd --- /dev/null +++ b/专栏/Flutter核心技术与实战/29混合开发,该用何种方案管理导航栈?.md @@ -0,0 +1,272 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 29 混合开发,该用何种方案管理导航栈? + 你好,我是陈航。 + +为了把Flutter引入到原生工程,我们需要把Flutter工程改造为原生工程的一个组件依赖,并以组件化的方式管理不同平台的Flutter构建产物,即Android平台使用aar、iOS平台使用pod进行依赖管理。这样,我们就可以在Android工程中通过FlutterView,iOS工程中通过FlutterViewController,为Flutter搭建应用入口,实现Flutter与原生的混合开发方式。 + +我在第26篇文章中提到,FlutterView与FlutterViewController是初始化Flutter的地方,也是应用的入口。可以看到,以混合开发方式接入Flutter,与开发一个纯Flutter应用在运行机制上并无任何区别,只需要原生工程为它提供一个画板容器(Android为FlutterView,iOS为FlutterViewController),Flutter就可以自己管理页面导航栈,从而实现多个复杂页面的渲染和切换。 + +关于纯Flutter应用的页面路由与导航,我已经在第21篇文章中与你介绍过了。今天这篇文章,我会为你讲述在混合开发中,应该如何管理混合导航栈。 + +对于混合开发的应用而言,通常我们只会将应用的部分模块修改成Flutter开发,其他模块继续保留原生开发,因此应用内除了Flutter的页面之外,还会有原生Android、iOS的页面。在这种情况下,Flutter页面有可能会需要跳转到原生页面,而原生页面也可能会需要跳转到Flutter页面。这就涉及到了一个新的问题:如何统一管理原生页面和Flutter页面跳转交互的混合导航栈。 + +接下来,我们就从这个问题入手,开始今天的学习吧。 + +混合导航栈 + +混合导航栈,指的是原生页面和Flutter页面相互掺杂,存在于用户视角的页面导航栈视图中。 + +以下图为例,Flutter与原生Android、iOS各自实现了一套互不相同的页面映射机制,即原生采用单容器单页面(一个ViewController/Activity对应一个原生页面)、Flutter采用单容器多页面(一个ViewController/Activity对应多个Flutter页面)的机制。Flutter在原生的导航栈之上又自建了一套Flutter导航栈,这使得Flutter页面与原生页面之间涉及页面切换时,我们需要处理跨引擎的页面切换。 + + + +图1 混合导航栈示意图 + +接下来,我们就分别看看从原生页面跳转至Flutter页面,以及从Flutter页面跳转至原生页面,应该如何处理吧。 + +从原生页面跳转至Flutter页面 + +从原生页面跳转至Flutter页面,实现起来比较简单。 + +因为Flutter本身依托于原生提供的容器(iOS为FlutterViewController,Android为Activity中的FlutterView),所以我们通过初始化Flutter容器,为其设置初始路由页面之后,就可以以原生的方式跳转至Flutter页面了。 + +如下代码所示。对于iOS,我们初始化一个FlutterViewController的实例,为其设置初始化页面路由后,将其加入原生的视图导航栈中完成跳转。 + +对于Android而言,则需要多加一步。因为Flutter页面的入口并不是原生视图导航栈的最小单位Activity,而是一个View(即FlutterView),所以我们还需要把这个View包装到Activity的contentView中。在Activity内部设置页面初始化路由之后,在外部就可以采用打开一个普通的原生视图的方式,打开Flutter页面了。 + +//iOS 跳转至Flutter页面 +FlutterViewController *vc = [[FlutterViewController alloc] init]; +[vc setInitialRoute:@"defaultPage"];//设置Flutter初始化路由页面 +[self.navigationController pushViewController:vc animated:YES];//完成页面跳转 + + +//Android 跳转至Flutter页面 + +//创建一个作为Flutter页面容器的Activity +public class FlutterHomeActivity extends AppCompatActivity { + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + //设置Flutter初始化路由页面 + View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); //传入路由标识符 + setContentView(FlutterView);//用FlutterView替代Activity的ContentView + } +} +//用FlutterPageActivity完成页面跳转 +Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class); +startActivity(intent); + + +从Flutter页面跳转至原生页面 + +从Flutter页面跳转至原生页面,则会相对麻烦些,我们需要考虑以下两种场景: + + +从Flutter页面打开新的原生页面; +从Flutter页面回退到旧的原生页面。 + + +首先,我们来看看Flutter如何打开原生页面。 + +Flutter并没有提供对原生页面操作的方法,所以不可以直接调用。我们需要通过方法通道(你可以再回顾下第26篇文章的相关内容),在Flutter和原生两端各自初始化时,提供Flutter操作原生页面的方法,并注册方法通道,在原生端收到Flutter的方法调用时,打开新的原生页面。 + +接下来,我们再看看如何从Flutter页面回退到原生页面。 + +因为Flutter容器本身属于原生导航栈的一部分,所以当Flutter容器内的根页面(即初始化路由页面)需要返回时,我们需要关闭Flutter容器,从而实现Flutter根页面的关闭。同样,Flutter并没有提供操作Flutter容器的方法,因此我们依然需要通过方法通道,在原生代码宿主为Flutter提供操作Flutter容器的方法,在页面返回时,关闭Flutter页面。 + +Flutter跳转至原生页面的两种场景,如下图所示: + + + +图2 Flutter页面跳转至原生页面示意图 + +接下来,我们一起看看这两个需要通过方法通道实现的方法,即打开原生页面openNativePage,与关闭Flutter页面closeFlutterPage,在Android和iOS平台上分别如何实现。 + +注册方法通道最合适的地方,是Flutter应用的入口,即在FlutterViewController(iOS端)和Activity中的FlutterView(Android端)这两个容器内部初始化Flutter页面前。为了将Flutter相关的行为封装到容器内部,我们需要分别继承FlutterViewController和Activity,在其viewDidLoad和onCreate初始化容器时,注册openNativePage和closeFlutterPage这两个方法。 + +iOS端的实现代码如下所示: + +@interface FlutterHomeViewController : FlutterViewController +@end + +@implementation FlutterHomeViewController +- (void)viewDidLoad { + [super viewDidLoad]; + //声明方法通道 + FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"samples.chenhang/navigation" binaryMessenger:self]; + //注册方法回调 + [channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + //如果方法名为打开新页面 + if([call.method isEqualToString:@"openNativePage"]) { + //初始化原生页面并打开 + SomeOtherNativeViewController *vc = [[SomeOtherNativeViewController alloc] init]; + [self.navigationController pushViewController:vc animated:YES]; + result(@0); + } + //如果方法名为关闭Flutter页面 + else if([call.method isEqualToString:@"closeFlutterPage"]) { + //关闭自身(FlutterHomeViewController) + [self.navigationController popViewControllerAnimated:YES]; + result(@0); + } + else { + result(FlutterMethodNotImplemented);//其他方法未实现 + } + }]; +} +@end + + +Android端的实现代码如下所示: + +//继承AppCompatActivity来作为Flutter的容器 +public class FlutterHomeActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + //初始化Flutter容器 + FlutterView flutterView = Flutter.createView(this, getLifecycle(), "defaultPage"); //传入路由标识符 + //注册方法通道 + new MethodChannel(flutterView, "samples.chenhang/navigation").setMethodCallHandler( + new MethodCallHandler() { + @Override + public void onMethodCall(MethodCall call, Result result) { + //如果方法名为打开新页面 + if(call.method.equals("openNativePage")) { + //新建Intent,打开原生页面 + Intent intent = new Intent(FlutterHomeActivity.this, SomeNativePageActivity.class); + startActivity(intent); + result.success(0); + } + //如果方法名为关闭Flutter页面 + else if(call.method.equals("closeFlutterPage")) { + //销毁自身(Flutter容器) + finish(); + result.success(0); + } + else { + //方法未实现 + result.notImplemented(); + } + } + }); + //将flutterView替换成Activity的contentView + setContentView(flutterView); + } +} + + +经过上面的方法注册,我们就可以在Flutter层分别通过openNativePage和closeFlutterPage方法,来实现Flutter页面与原生页面之间的切换了。 + +在下面的例子中,Flutter容器的根视图DefaultPage包含有两个按钮: + + +点击左上角的按钮后,可以通过closeFlutterPage返回原生页面; +点击中间的按钮后,会打开一个新的Flutter页面PageA。PageA中也有一个按钮,点击这个按钮之后会调用openNativePage来打开一个新的原生页面。 + + +void main() => runApp(_widgetForRoute(window.defaultRouteName)); +//获取方法通道 +const platform = MethodChannel('samples.chenhang/navigation'); + +//根据路由标识符返回应用入口视图 +Widget _widgetForRoute(String route) { + switch (route) { + default://返回默认视图 + return MaterialApp(home:DefaultPage()); + } +} + +class PageA extends StatelessWidget { + ... + @override + Widget build(BuildContext context) { + return Scaffold( + body: RaisedButton( + child: Text("Go PageB"), + onPressed: ()=>platform.invokeMethod('openNativePage')//打开原生页面 + )); + } +} + +class DefaultPage extends StatelessWidget { + ... + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("DefaultPage Page"), + leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')//关闭Flutter页面 + )), + body: RaisedButton( + child: Text("Go PageA"), + onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),//打开Flutter页面 PageA + )); + } +} + + +整个混合导航栈示例的代码流程,如下图所示。通过这张图,你就可以把这个示例的整个代码流程串起来了。 + + + +图3 混合导航栈示例 + +在我们的混合应用中,RootViewController与MainActivity分别是iOS和Android应用的原生页面入口,可以初始化为Flutter容器的FlutterHomeViewController(iOS端)与FlutterHomeActivity(Android端)。 + +在为其设置初始路由页面DefaultPage之后,就可以以原生的方式跳转至Flutter页面。但是,Flutter并未提供接口,来支持从Flutter的DefaultPage页面返回到原生页面,因此我们需要利用方法通道来注册关闭Flutter容器的方法,即closeFlutterPage,让Flutter容器接收到这个方法调用时关闭自身。 + +在Flutter容器内部,我们可以使用Flutter内部的页面路由机制,通过Navigator.push方法,完成从DefaultPage到PageA的页面跳转;而当我们想从Flutter的PageA页面跳转到原生页面时,因为涉及到跨引擎的页面路由,所以我们仍然需要利用方法通道来注册打开原生页面的方法,即openNativePage,让 Flutter容器接收到这个方法调用时,在原生代码宿主完成原生页面SomeOtherNativeViewController(iOS端)与SomeNativePageActivity(Android端)的初始化,并最终完成页面跳转。 + +总结 + +好了,今天的分享就到这里。我们一起总结下今天的主要内容吧。 + +对于原生Android、iOS工程混编Flutter开发,由于应用中会同时存在Android、iOS和Flutter页面,所以我们需要妥善处理跨渲染引擎的页面跳转,解决原生页面如何切换Flutter页面,以及Flutter页面如何切换到原生页面的问题。 + +在原生页面切换到Flutter页面时,我们通常会将Flutter容器封装成一个独立的ViewController(iOS端)或Activity(Android端),在为其设置好Flutter容器的页面初始化路由(即根视图)后,原生的代码就可以按照打开一个普通的原生页面的方式,来打开Flutter页面了。 + +而如果我们想在Flutter页面跳转到原生页面,则需要同时处理好打开新的原生页面,以及关闭自身回退到老的原生页面两种场景。在这两种场景下,我们都需要利用方法通道来注册相应的处理方法,从而在原生代码宿主实现新页面的打开和Flutter容器的关闭。 + +需要注意的是,与纯Flutter应用不同,原生应用混编Flutter由于涉及到原生页面与Flutter页面之间切换,因此导航栈内可能会出现多个Flutter容器的情况,即多个Flutter实例。 + +Flutter实例的初始化成本非常高昂,每启动一个Flutter实例,就会创建一套新的渲染机制,即Flutter Engine,以及底层的Isolate。而这些实例之间的内存是不互相共享的,会带来较大的系统资源消耗。 + +因此我们在实际业务开发中,应该尽量用Flutter去开发闭环的业务模块,原生只需要能够跳转到Flutter模块,剩下的业务都应该在Flutter内部完成,而尽量避免Flutter页面又跳回到原生页面,原生页面又启动新的Flutter实例的情况。 + +为了解决混编工程中Flutter多实例的问题,业界有两种解决方案: + + +以今日头条为代表的修改Flutter Engine源码,使多FlutterView实例对应的多Flutter Engine能够在底层共享Isolate; +以闲鱼为代表的共享FlutterView,即由原生层驱动Flutter层渲染内容的方案。 + + +坦白说,这两种方案各有不足: + + +前者涉及到修改Flutter源码,不仅开发维护成本高,而且增加了线程模型和内存回收出现异常的概率,稳定性不可控。 +后者涉及到跨渲染引擎的hack,包括Flutter页面的新建、缓存和内存回收等机制,因此在一些低端机或是处理页面切换动画时,容易出现渲染Bug。 +除此之外,这两种方式均与Flutter的内部实现绑定较紧,因此在处理Flutter SDK版本升级时往往需要耗费较大的适配成本。 + + +综合来说,目前这两种解决方案都不够完美。所以,在Flutter官方支持多实例单引擎之前,我们还是尽量在产品模块层面,保证应用内不要出现多个Flutter容器实例吧。 + +我把今天分享所涉及到的知识点打包到了GitHub(flutter_module_page、android_demo、iOS_demo)中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留两道思考题吧。 + + +请在openNativePage方法的基础上,增加页面id的功能,可以支持在Flutter页面打开任意的原生页面。 +混编工程中会出现两种页面过渡动画:原生页面之间的切换动画、Flutter页面之间的切换动画。请你思考下,如何能够确保这两种页面过渡动画在应用整体的效果是一致的。 + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/30为什么需要做状态管理,怎么做?.md b/专栏/Flutter核心技术与实战/30为什么需要做状态管理,怎么做?.md new file mode 100644 index 0000000..56273e4 --- /dev/null +++ b/专栏/Flutter核心技术与实战/30为什么需要做状态管理,怎么做?.md @@ -0,0 +1,290 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 为什么需要做状态管理,怎么做? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了如何在原生混编Flutter工程中管理混合导航栈,应对跨渲染引擎的页面跳转,即解决原生页面如何切换到Flutter页面,以及Flutter页面如何切换到原生页面的问题。 + +如果说跨渲染引擎页面切换的关键在于,如何确保页面跳转的渲染体验一致性,那么跨组件(页面)之间保持数据共享的关键就在于,如何清晰地维护组件共用的数据状态了。在第20篇文章“关于跨组件传递数据,你只需要记住这三招”中,我已经与你介绍了InheritedWidget、Notification和EventBus这3种数据传递机制,通过它们可以实现组件间的单向数据传递。 + +如果我们的应用足够简单,数据流动的方向和顺序是清晰的,我们只需要将数据映射成视图就可以了。作为声明式的框架,Flutter可以自动处理数据到渲染的全过程,通常并不需要状态管理。 + +但,随着产品需求迭代节奏加快,项目逐渐变得庞大时,我们往往就需要管理不同组件、不同页面之间共享的数据关系。当需要共享的数据关系达到几十上百个的时候,我们就很难保持清晰的数据流动方向和顺序了,导致应用内各种数据传递嵌套和回调满天飞。在这个时候,我们迫切需要一个解决方案,来帮助我们理清楚这些共享数据的关系,于是状态管理框架便应运而生。 + +Flutter在设计声明式UI上借鉴了不少React的设计思想,因此涌现了诸如flutter_redux、flutter_mobx 、fish_redux等基于前端设计理念的状态管理框架。但这些框架大都比较复杂,且需要对框架设计概念有一定理解,学习门槛相对较高。 + +而源自Flutter官方的状态管理框架Provider则相对简单得多,不仅容易理解,而且框架的入侵性小,还可以方便地组合和控制UI刷新粒度。因此,在Google I/O 2019大会一经面世,Provider就成为了官方推荐的状态管理方式之一。 + +那么今天,我们就来聊聊Provider到底怎么用吧。 + +Provider + +从名字就可以看出,Provider是一个用来提供数据的框架。它是InheritedWidget的语法糖,提供了依赖注入的功能,允许在Widget树中更加灵活地处理和传递数据。 + +那么,什么是依赖注入呢?通俗地说,依赖注入是一种可以让我们在需要时提取到所需资源的机制,即:预先将某种“资源”放到程序中某个我们都可以访问的位置,当需要使用这种“资源”时,直接去这个位置拿即可,而无需关心“资源”是谁放进去的。 + +所以,为了使用Provider,我们需要解决以下3个问题: + + +资源(即数据状态)如何封装? +资源放在哪儿,才都能访问得到? +具体使用时,如何取出资源? + + +接下来,我通过一个例子来与你演示如何使用Provider。 + +在下面的示例中,我们有两个独立的页面FirstPage和SecondPage,它们会共享计数器的状态:其中FirstPage负责读,SecondPage负责读和写。 + +在使用Provider之前,我们首先需要在pubspec.yaml文件中添加Provider的依赖: + +dependencies: + flutter: + sdk: flutter + provider: 3.0.0+1 #provider依赖 + + +添加好Provider的依赖后,我们就可以进行数据状态的封装了。这里,我们只有一个状态需要共享,即count。由于第二个页面还需要修改状态,因此我们还需要在数据状态的封装上包含更改数据的方法: + +//定义需要共享的数据模型,通过混入ChangeNotifier管理听众 +class CounterModel with ChangeNotifier { + int _count = 0; + //读方法 + int get counter => _count; + //写方法 + void increment() { + _count++; + notifyListeners();//通知听众刷新 + } +} + + +可以看到,我们在资源封装类中使用mixin混入了ChangeNotifier。这个类能够帮助我们管理所有依赖资源封装类的听众。当资源封装类调用notifyListeners时,它会通知所有听众进行刷新。 + +资源已经封装完毕,接下来我们就需要考虑把它放到哪儿了。 + +因为Provider实际上是InheritedWidget的语法糖,所以通过Provider传递的数据从数据流动方向来看,是由父到子(或者反过来)。这时我们就明白了,原来需要把资源放到FirstPage和SecondPage的父Widget,也就是应用程序的实例MyApp中(当然,把资源放到更高的层级也是可以的,比如放到main函数中): + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + //通过Provider组件封装数据资源 + return ChangeNotifierProvider.value( + value: CounterModel(),//需要共享的数据资源 + child: MaterialApp( + home: FirstPage(), + ) + ); + } +} + + +可以看到,既然Provider是InheritedWidget的语法糖,因此它也是一个Widget。所以,我们直接在MaterialApp的外层使用Provider进行包装,就可以把数据资源依赖注入到应用中。 + +这里需要注意的是,由于封装的数据资源不仅需要为子Widget提供读的能力,还要提供写的能力,因此我们需要使用Provider的升级版ChangeNotifierProvider。而如果只需要为子Widget提供读能力,直接使用Provider即可。 + +最后,在注入数据资源完成之后,我们就可以在FirstPage和SecondPage这两个子Widget完成数据的读写操作了。 + +关于读数据,与InheritedWidget一样,我们可以通过Provider.of方法来获取资源数据。而如果我们想写数据,则需要通过获取到的资源数据,调用其暴露的更新数据方法(本例中对应的是increment),代码如下所示: + +//第一个页面,负责读数据 +class FirstPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + //取出资源 + final _counter = Provider.of(context); + return Scaffold( + //展示资源中的数据 + body: Text('Counter: ${_counter.counter}'), + //跳转到SecondPage + floatingActionButton: FloatingActionButton( + onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => SecondPage())) + )); + } +} + +//第二个页面,负责读写数据 +class SecondPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + //取出资源 + final _counter = Provider.of(context); + return Scaffold( + //展示资源中的数据 + body: Text('Counter: ${_counter.counter}'), + //用资源更新方法来设置按钮点击回调 + floatingActionButton:FloatingActionButton( + onPressed: _counter.increment, + child: Icon(Icons.add), + )); + } +} + + +运行代码,试着多点击几次第二个界面的“+”按钮,关闭第二个界面,可以看到第一个界面也同步到了按钮的点击数。 + + + +图1 Provider使用示例 + +Consumer + +通过上面的示例可以看到,使用Provider.of获取资源,可以得到资源暴露的数据的读写接口,在实现数据的共享和同步上还是比较简单的。但是,滥用Provider.of方法也有副作用,那就是当数据更新时,页面中其他的子Widget也会跟着一起刷新。 + +为验证这一点,我们以第二个界面右下角FloatingActionButton中的子Widget “+”Icon为例做个测试。 + +首先,为了打印出Icon控件每一次刷新的情况,我们需要自定义一个控件TestIcon,并在其build方法中返回Icon实例的同时,打印一句话: + +//用于打印build方法执行情况的自定义控件 +class TestIcon extends StatelessWidget { + @override + Widget build(BuildContext context) { + print("TestIcon build"); + return Icon(Icons.add);//返回Icon实例 + } +} + + +然后,我们用TestIcon控件,替换掉SecondPage中FloatingActionButton的Icon子Widget: + +class SecondPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + //取出共享的数据资源 + final _counter = Provider.of(context); + return Scaffold( + ... + floatingActionButton:FloatingActionButton( + onPressed: _counter.increment, + child: TestIcon(),//替换掉原有的Icon(Icons.add) + )); + } + + +运行这段实例,然后在第二个页面多次点击“+”按钮,观察控制台输出: + +I/flutter (21595): TestIcon build +I/flutter (21595): TestIcon build +I/flutter (21595): TestIcon build +I/flutter (21595): TestIcon build +I/flutter (21595): TestIcon build + + +可以看到,TestIcon控件本来是一个不需要刷新的StatelessWidget,但却因为其父Widget FloatingActionButton所依赖的数据资源counter发生了变化,导致它也要跟着刷新。 + +那么,有没有办法能够在数据资源发生变化时,只刷新对资源存在依赖关系的Widget,而其他Widget保持不变呢? + +答案当然是可以的。 + +在本次分享一开始时,我曾说Provider可以精确地控制UI刷新粒度,而这一切是基于Consumer实现的。Consumer使用了Builder模式创建UI,收到更新通知就会通过builder重新构建Widget。 + +接下来,我们就看看如何使用Consumer来改造SecondPage吧。 + +在下面的例子中,我们在SecondPage中去掉了Provider.of方法来获取counter的语句,在其真正需要这个数据资源的两个子Widget,即Text和FloatingActionButton中,使用Consumer来对它们进行了一层包装: + +class SecondPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + //使用Consumer来封装counter的读取 + body: Consumer( + //builder函数可以直接获取到counter参数 + builder: (context, CounterModel counter, _) => Text('Value: ${counter.counter}')), + //使用Consumer来封装increment的读取 + floatingActionButton: Consumer( + //builder函数可以直接获取到increment参数 + builder: (context, CounterModel counter, child) => FloatingActionButton( + onPressed: counter.increment, + child: child, + ), + child: TestIcon(), + ), + ); + } +} + + +可以看到,Consumer中的builder实际上就是真正刷新UI的函数,它接收3个参数,即context、model和child。其中:context是Widget的build方法传进来的BuildContext,model是我们需要的数据资源,而child则用来构建那些与数据资源无关的部分。在数据资源发生变更时,builder会多次执行,但child不会重建。 + +运行这段代码,可以发现,不管我们点击了多少次“+”按钮,TestIcon控件始终没有发生销毁重建。 + +多状态的资源封装 + +通过上面的例子,我们学习了Provider是如何共享一个数据状态的。那么,如果有多个数据状态需要共享,我们又该如何处理呢? + +其实也不难。接下来,我就按照封装、注入和读写这3个步骤,与你介绍多个数据状态的共享。 + +在处理多个数据状态共享之前,我们需要先扩展一下上面计数器状态共享的例子,让两个页面之间展示计数器数据的Text能够共享App传递的字体大小。 + +首先,我们来看看如何封装。 + +多个数据状态与单个数据的封装并无不同,如果需要支持数据的读写,我们需要一个接一个地为每一个数据状态都封装一个单独的资源封装类;而如果数据是只读的,则可以直接传入原始的数据对象,从而省去资源封装的过程。 + +接下来,我们再看看如何实现注入。 + +在单状态的案例中,我们通过Provider的升级版ChangeNotifierProvider实现了可读写资源的注入,而如果我们想注入多个资源,则可以使用Provider的另一个升级版MultiProvider,来实现多个Provider的组合注入。 + +在下面的例子中,我们通过MultiProvider往App实例内注入了double和CounterModel这两个资源Provider: + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MultiProvider(providers: [ + Provider.value(value: 30.0),//注入字体大小 + ChangeNotifierProvider.value(value: CounterModel())//注入计数器实例 + ], + child: MaterialApp( + home: FirstPage(), + )); + } +} + + +在完成了多个资源的注入后,最后我们来看看如何获取这些资源。 + +这里,我们还是使用Provider.of方式来获取资源。相较于单状态资源的获取来说,获取多个资源时,我们只需要依次读取每一个资源即可: + +final _counter = Provider.of(context);//获取计时器实例 +final textSize = Provider.of(context);//获取字体大小 + + +而如果以Consumer的方式来获取资源的话,我们只要使用Consumer2对象(这个对象提供了读取两个数据资源的能力),就可以一次性地获取字体大小与计数器实例这两个数据资源: + +//使用Consumer2获取两个数据资源 +Consumer2( + //builder函数以参数的形式提供了数据资源 + builder: (context, CounterModel counter, double textSize, _) => Text( + 'Value: ${counter.counter}', + style: TextStyle(fontSize: textSize)) +) + + +可以看到,Consumer2与Consumer的使用方式基本一致,只不过是在builder方法中多了一个数据资源参数。事实上,如果你希望在子Widget中共享更多的数据,我们最多可以使用到Consumer6,即共享6个数据资源。 + +总结 + +好了,今天的分享就到这里,我们总结一下今天的主要内容吧。 + +我与你介绍了在Flutter中通过Provider进行状态管理的方法,Provider以InheritedWidget语法糖的方式,通过数据资源封装、数据注入和数据读写这3个步骤,为我们实现了跨组件(跨页面)之间的数据共享。 + +我们既可以用Provider来实现静态的数据读传递,也可以使用ChangeNotifierProvider来实现动态的数据读写传递,还可以通过MultiProvider来实现多个数据资源的共享。 + +在具体使用数据时,Provider.of和Consumer都可以实现数据的读取,并且Consumer还可以控制UI刷新的粒度,避免与数据无关的组件的无谓刷新。 + +可以看到,通过Provider来实现数据传递,无论在单个页面内还是在整个App之间,我们都可以很方便地实现状态管理,搞定那些通过StatefulWidget无法实现的场景,进而开发出简单、层次清晰、可扩展性高的应用。事实上,当我们使用Provider后,我们就再也不需要使用StatefulWidget了。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留一道思考题吧。 + +使用Provider可以实现2个同样类型的对象共享,你知道应该如何实现吗? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/31如何实现原生推送能力?.md b/专栏/Flutter核心技术与实战/31如何实现原生推送能力?.md new file mode 100644 index 0000000..dc83070 --- /dev/null +++ b/专栏/Flutter核心技术与实战/31如何实现原生推送能力?.md @@ -0,0 +1,540 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 31 如何实现原生推送能力? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了如何使用Provider去维护Flutter组件共用的数据状态。在Flutter中状态即数据,通过数据资源封装、注入和读写这三步,我们不仅可以实现跨组件之间的数据共享,还能精确控制UI刷新粒度,避免无关组件的刷新。 + +其实,数据共享不仅存在于客户端内部,同样也存在于服务端与客户端之间。比如,有新的微博评论,或者是发生了重大新闻,我们都需要在服务端把这些状态变更的消息实时推送到客户端,提醒用户有新的内容。有时,我们还会针对特定的用户画像,通过推送实现精准的营销信息触达。 + +可以说,消息推送是增强用户黏性,促进用户量增长的重要手段。那么,消息推送的流程是什么样的呢? + +消息推送流程 + +手机推送每天那么多,导致在我们看来这很简单啊。但其实,消息推送是一个横跨业务服务器、第三方推送服务托管厂商、操作系统长连接推送服务、用户终端、手机应用五方的复杂业务应用场景。 + +在iOS上,苹果推送服务(APNs)接管了系统所有应用的消息通知需求;而Android原生,则提供了类似Firebase的云消息传递机制(FCM),可以实现统一的推送托管服务。 + +当某应用需要发送消息通知时,这则消息会由应用的服务器先发给苹果或Google,经由APNs或FCM被发送到设备,设备操作系统在完成解析后,最终把消息转给所属应用。这个流程的示意图,如下所示。 + + + +图1 原生消息推送流程 + +不过,Google服务在大陆地区使用并不稳定,因此国行Android手机通常会把Google服务换成自己的服务,定制一套推送标准。而这对开发者来说,无疑是增大了适配负担。所以针对Android端,我们通常会使用第三方推送服务,比如极光推送、友盟推送等。 + +虽然这些第三方推送服务使用自建的长连接,无法享受操作系统底层的优化,但它们会对所有使用推送服务的App共享推送通道,只要有一个使用第三方推送服务的应用没被系统杀死,就可以让消息及时送达。 + +而另一方面,这些第三方服务简化了业务服务器与手机推送服务建立连接的操作,使得我们的业务服务器通过简单的API调用就可以完成消息推送。 + +而为了保持Android/iOS方案的统一,在iOS上我们也会使用封装了APNs通信的第三方推送服务。 + +第三方推送的服务流程,如下图所示。 + + + +图2 第三方推送服务流程 + +这些第三方推送服务厂商提供的能力和接入流程大都一致,考虑到极光的社区和生态相对活跃,所以今天我们就以极光为例,来看看在Flutter应用中如何才能引用原生的推送能力。 + +原生推送接入流程 + +要想在Flutter中接收推送消息,我们需要把原生的推送能力暴露给Flutter应用,即在原生代码宿主实现推送能力(极光SDK)的接入,并通过方法通道提供给Dart层感知推送消息的机制。 + +插件工程 + +在第26篇文章中,我们学习了如何在原生工程中的Flutter应用入口注册原生代码宿主回调,从而实现Dart层调用原生接口的方案。这种方案简单直接,适用于Dart层与原生接口之间交互代码量少、数据流动清晰的场景。 + +但对于推送这种涉及Dart与原生多方数据流转、代码量大的模块,这种与工程耦合的方案就不利于独立开发维护了。这时,我们需要使用Flutter提供的插件工程对其进行单独封装。 + +Flutter的插件工程与普通的应用工程类似,都有android和ios目录,这也是我们完成平台相关逻辑代码的地方,而Flutter工程插件的注册,则仍会在应用的入口完成。除此之外,插件工程还内嵌了一个example工程,这是一个引用了插件代码的普通Flutter应用工程。我们通过example工程,可以直接调试插件功能。 + + + +图3 插件工程目录结构 + +在了解了整体工程的目录结构之后,接下来我们需要去Dart插件代码所在的flutter_push_plugin.dart文件,实现Dart层的推送接口封装。 + +Dart接口实现 + +为了实现消息的准确触达,我们需要提供一个可以标识手机上App的地址,即token或id。一旦完成地址的上报,我们就可以等待业务服务器给我们发消息了。 + +因为我们需要使用极光这样的第三方推送服务,所以还得进行一些前置的应用信息关联绑定,以及SDK的初始化工作。可以看到,对于一个应用而言,接入推送的过程可以拆解为以下三步: + + +初始化极光SDK; +获取地址id; +注册消息通知。 + + +这三步对应着在Dart层需要封装的3个原生接口调用:setup、registrationID和setOpenNotificationHandler。 + +前两个接口是在方法通道上调用原生代码宿主提供的方法,而注册消息通知的回调函数setOpenNotificationHandler则相反,是原生代码宿主在方法通道上调用Dart层所提供的事件回调,因此我们需要在方法通道上为原生代码宿主注册反向回调方法,让原生代码宿主收到消息后可以直接通知它。 + +另外,考虑到推送是整个应用共享的能力,因此我们将FlutterPushPlugin这个类封装成了单例: + +//Flutter Push插件 +class FlutterPushPlugin { + //单例 + static final FlutterPushPlugin _instance = new FlutterPushPlugin.private(const MethodChannel('flutter_push_plugin')); + //方法通道 + final MethodChannel _channel; + //消息回调 + EventHandler _onOpenNotification; + //构造方法 + FlutterPushPlugin.private(MethodChannel channel) : _channel = channel { + //注册原生反向回调方法,让原生代码宿主可以执行onOpenNotification方法 + _channel.setMethodCallHandler(_handleMethod); + } + //初始化极光SDK + setupWithAppID(String appID) { + _channel.invokeMethod("setup", appID); + } + //注册消息通知 + setOpenNotificationHandler(EventHandler onOpenNotification) { + _onOpenNotification = onOpenNotification; + } + + //注册原生反向回调方法,让原生代码宿主可以执行onOpenNotification方法 + Future _handleMethod(MethodCall call) { + switch (call.method) { + case "onOpenNotification": + return _onOpenNotification(call.arguments); + default: + throw new UnsupportedError("Unrecognized Event"); + } + } + //获取地址id + Future get registrationID async { + final String regID = await _channel.invokeMethod('getRegistrationID'); + return regID; + } +} + + +Dart层是原生代码宿主的代理,可以看到这一层的接口设计算是简单。接下来,我们分别去接管推送的Android和iOS平台上完成相应的实现。 + +Android接口实现 + +考虑到Android平台的推送配置工作相对较少,因此我们先用Android Studio打开example下的android工程进行插件开发工作。需要注意的是,由于android子工程的运行依赖于Flutter工程编译构建产物,所以在打开android工程进行开发前,你需要确保整个工程代码至少build过一次,否则IDE会报错。 + + +备注:以下操作步骤参考极光Android SDK集成指南。 + + +首先,我们需要在插件工程下的build.gradle引入极光SDK,即jpush与jcore: + +dependencies { + implementation 'cn.jiguang.sdk:jpush:3.3.4' + implementation 'cn.jiguang.sdk:jcore:2.1.2' +} + + +然后,在原生接口FlutterPushPlugin类中,依次把Dart层封装的3个接口调用,即setup、getRegistrationID与onOpenNotification,提供极光Android SDK的实现版本。 + +需要注意的是,由于极光Android SDK的信息绑定是在应用的打包配置里设置,并不需要通过代码完成(iOS才需要),因此setup方法的Android版本是一个空实现: + +public class FlutterPushPlugin implements MethodCallHandler { + //注册器,通常为MainActivity + public final Registrar registrar; + //方法通道 + private final MethodChannel channel; + //插件实例 + public static FlutterPushPlugin instance; + //注册插件 + public static void registerWith(Registrar registrar) { + //注册方法通道 + final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_push_plugin"); + instance = new FlutterPushPlugin(registrar, channel); + channel.setMethodCallHandler(instance); + //把初始化极光SDK提前至插件注册时 + JPushInterface.setDebugMode(true); + JPushInterface.init(registrar.activity().getApplicationContext()); + } + //私有构造方法 + private FlutterPushPlugin(Registrar registrar, MethodChannel channel) { + this.registrar = registrar; + this.channel = channel; + } + //方法回调 + @Override + public void onMethodCall(MethodCall call, Result result) { + if (call.method.equals("setup")) { + //极光Android SDK的初始化工作需要在App工程中配置,因此不需要代码实现 + result.success(0); + } + else if (call.method.equals("getRegistrationID")) { + //获取极光推送地址标识符 + result.success(JPushInterface.getRegistrationID(registrar.context())); + } else { + result.notImplemented(); + } + } + + public void callbackNotificationOpened(NotificationMessage message) { + //将推送消息回调给Dart层 + channel.invokeMethod("onOpenNotification",message.notificationContent); + } +} + + +可以看到,我们的FlutterPushPlugin类中,仅提供了callbackNotificationOpened这个工具方法,用于推送消息参数回调给Dart,但这个类本身并没有去监听极光SDK的推送消息。 + +为了获取推送消息,我们分别需要继承极光SDK提供的两个类:JCommonService和JPushMessageReceiver。 + + +JCommonService是一个后台Service,实际上是极光共享长连通道的核心,可以在多手机平台上使得推送通道更稳定。 +JPushMessageReceiver则是一个BroadcastReceiver,推送消息的获取都是通过它实现的。我们可以通过覆盖其onNotifyMessageOpened方法,从而在用户点击系统推送消息时获取到通知。 + + +作为BroadcastReceiver的JPushMessageReceiver,可以长期在后台存活,监听远端推送消息,但Flutter可就不行了,操作系统会随时释放掉后台应用所占用的资源。因此,在用户点击推送时,我们在收到相应的消息回调后,需要做的第一件事情不是立刻通知Flutter,而是应该启动应用的MainActivity。在确保Flutter已经完全初始化后,才能通知Flutter有新的推送消息。 + +因此在下面的代码中,我们在打开MainActivity后,等待了1秒,才执行相应的Flutter回调通知: + +//JPushXCustomService.java +//长连通道核心,可以使推送通道更稳定 +public class JPushXCustomService extends JCommonService { +} + +//JPushXMessageReceiver.java +//获取推送消息的Receiver +public class JPushXMessageReceiver extends JPushMessageReceiver { + //用户点击推送消息回调 + @Override + public void onNotifyMessageOpened(Context context, final NotificationMessage message) { + try { + //找到MainActivity + String mainClassName = context.getApplicationContext().getPackageName() + ".MainActivity"; + Intent i = new Intent(context, Class.forName(mainClassName)); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + //启动主Activity + context.startActivity(i); + } catch (Exception e) { + Log.e("tag","找不到MainActivity"); + } + new Timer().schedule(new TimerTask() { + @Override + public void run() { + FlutterPushPlugin.instance.callbackNotificationOpened(message); + } + },1000); // 延迟1秒通知Dart + } +} + + +最后,我们还需要在插件工程的AndroidManifest.xml中,分别声明receiver JPushXMessageReceiver和service JPushXCustomService,完成对系统的注册: + +... + + + + + + + + + + + + + + + +... + + +接收消息和回调消息的功能完成后,FlutterPushPlugin插件的Android部分就搞定了。接下来,我们去开发插件的iOS部分。 + +iOS接口实现 + +与Android类似,我们需要使用Xcode打开example下的ios工程进行插件开发工作。同样,在打开ios工程前,你需要确保整个工程代码至少build过一次,否则IDE会报错。 + + +备注:以下操作步骤参考极光iOS SDK集成指南 + + +首先,我们需要在插件工程下的flutter_push_plugin.podspec文件中引入极光SDK,即jpush。这里,我们选用了不使用广告id的版本: + +Pod::Spec.new do |s| + ... + s.dependency 'JPush', '3.2.2-noidfa' +end + + +然后,在原生接口FlutterPushPlugin类中,同样依次为setup、getRegistrationID与onOpenNotification,提供极光 iOS SDK的实现版本。 + +需要注意的是,APNs的推送消息是在ApplicationDelegate中回调的,所以我们需要在注册插件时,为插件提供同名的回调函数,让极光SDK把推送消息转发到插件的回调函数中。 + +与Android类似,在极光SDK收到推送消息时,我们的应用可能处于后台,因此在用户点击了推送消息,把Flutter应用唤醒时,我们应该在确保Flutter已经完全初始化后,才能通知Flutter有新的推送消息。 + +因此在下面的代码中,我们在用户点击了推送消息后也等待了1秒,才执行相应的Flutter回调通知: + +@implementation FlutterPushPlugin +//注册插件 ++ (void)registerWithRegistrar:(NSObject*)registrar { + //注册方法通道 + FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"flutter_push_plugin" binaryMessenger:[registrar messenger]]; + //初始化插件实例,绑定方法通道 + FlutterPushPlugin* instance = [[FlutterPushPlugin alloc] init]; + instance.channel = channel; + //为插件提供ApplicationDelegate回调方法 + [registrar addApplicationDelegate:instance]; + //注册方法通道回调函数 + [registrar addMethodCallDelegate:instance channel:channel]; +} +//处理方法调用 +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if([@"setup" isEqualToString:call.method]) { + //极光SDK初始化方法 + [JPUSHService setupWithOption:self.launchOptions appKey:call.arguments channel:@"App Store" apsForProduction:YES advertisingIdentifier:nil]; + } else if ([@"getRegistrationID" isEqualToString:call.method]) { + //获取极光推送地址标识符 + [JPUSHService registrationIDCompletionHandler:^(int resCode, NSString *registrationID) { + result(registrationID); + }]; + } else { + //方法未实现 + result(FlutterMethodNotImplemented); + } +} +//应用程序启动回调 +-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + //初始化极光推送服务 + JPUSHRegisterEntity * entity = [[JPUSHRegisterEntity alloc] init]; + //设置推送权限 + entity.types = JPAuthorizationOptionAlert|JPAuthorizationOptionBadge|JPAuthorizationOptionSound; + //请求推送服务 + [JPUSHService registerForRemoteNotificationConfig:entity delegate:self]; + //存储App启动状态,用于后续初始化调用 + self.launchOptions = launchOptions; + return YES; +} +//推送token回调 +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { + ///注册DeviceToken,换取极光推送地址标识符 + [JPUSHService registerDeviceToken:deviceToken]; +} +//推送被点击回调 +- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler { + //获取推送消息 + NSDictionary * userInfo = response.notification.request.content.userInfo; + NSString *content = userInfo[@"aps"][@"alert"]; + if ([content isKindOfClass:[NSDictionary class]]) { + content = userInfo[@"aps"][@"alert"][@"body"]; + } + //延迟1秒通知Flutter,确保Flutter应用已完成初始化 + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self.channel invokeMethod:@"onOpenNotification" arguments:content]; + }); + //清除应用的小红点 + UIApplication.sharedApplication.applicationIconBadgeNumber = 0; + //通知系统,推送回调处理完毕 + completionHandler(); +} +//前台应用收到了推送消息 +- (void)jpushNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(NSInteger options))completionHandler { + //通知系统展示推送消息提示 + completionHandler(UNNotificationPresentationOptionAlert); +} +@end + + +至此,在完成了极光iOS SDK的接口封装之后,FlutterPushPlugin插件的iOS部分也搞定了。 + +FlutterPushPlugin插件为Flutter应用提供了原生推送的封装,不过要想example工程能够真正地接收到推送消息,我们还需要对exmaple工程进行最后的配置,即:为它提供应用推送证书,并关联极光应用配置。 + +应用工程配置 + +在单独为Android/iOS应用进行推送配置之前,我们首先需要去极光的官方网站,为example应用注册一个唯一标识符(即AppKey): + + + +图4 极光应用注册 + +在得到了AppKey之后,我们需要依次进行Android与iOS的配置工作。 + +Android的配置工作相对简单,整个配置过程完全是应用与极光SDK的关联工作。 + +首先,根据example的Android工程包名,完成Android工程的推送注册: + + + +图5 example Android推送注册 + +然后,通过AppKey,在app的build.gradle文件中实现极光信息的绑定: + +defaultConfig { + ... + //ndk支持架构 + ndk { + abiFilters 'armeabi', 'armeabi-v7a', 'arm64-v8a' + } + + manifestPlaceholders = [ + JPUSH_PKGNAME : applicationId, //包名 + JPUSH_APPKEY : "f861910af12a509b34e266c2", //JPush 上注册的包名对应的Appkey + JPUSH_CHANNEL : "developer-default", //填写默认值即可 + ] +} + + +至此,Android部分的所有配置工作和接口实现都已经搞定了。接下来,我们再来看看iOS的配置实现。 + +iOS的应用配置相对Android会繁琐一些,因为整个配置过程涉及应用、苹果APNs服务、极光三方之间的信息关联。 + +除了需要在应用内绑定极光信息之外(即handleMethodCall中的setup方法),还需要在苹果的开发者官网提前申请苹果的推送证书。关于申请证书,苹果提供了.p12证书和APNs Auth Key两种鉴权方式。 + +这里,我推荐使用更为简单的Auth Key方式。申请推送证书的过程,极光官网提供了详细的注册步骤,这里我就不再赘述了。需要注意的是,申请iOS的推送证书时,你只能使用付费的苹果开发者账号。 + +在拿到了APNs Auth Key之后,我们同样需要去极光官网,根据Bundle ID进行推送设置,并把Auth Key上传至极光进行托管,由它完成与苹果的鉴权工作: + + + +图6 example iOS推送注册 + +通过上面的步骤,我们已经完成了将推送证书与极光信息绑定的操作,接下来,我们回到Xcode打开的example工程,进行最后的配置工作。 + +首先,我们需要为example工程开启Application Target的Capabilities->Push Notifications选项,启动应用的推送能力支持,如下图所示: + + + +图7 example iOS推送配置 + +然后,我们需要切换到Application Target的Info面板,手动配置NSAppTransportSecurity键值对,以支持极光SDK非https域名服务: + + + +图8 example iOS支持Http配置 + +最后,在Info tab下的Bundle identifier项,把我们刚刚在极光官网注册的Bundle ID显式地更新进去: + + + +图9 Bundle ID配置 + +至此,example工程运行所需的所有原生配置工作和接口实现都已经搞定了。接下来,我们就可以在example工程中的main.dart文件中,使用FlutterPushPlugin插件来实现原生推送能力了。 + +在下面的代码中,我们在main函数的入口,使用插件单例注册了极光推送服务,随后在应用State初始化时,获取了极光推送地址,并设置了消息推送回调: + +//获取推送插件单例 +FlutterPushPlugin fpush = FlutterPushPlugin(); +void main() { + //使用AppID注册极光推送服务(仅针对iOS平台) + fpush.setupWithAppID("f861910af12a509b34e266c2"); + runApp(MyApp()); +} + +class MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State { + //极光推送地址regID + String _regID = 'Unknown'; + //接收到的推送消息 + String _notification = ""; + + @override + initState() { + super.initState(); + //注册推送消息回调 + fpush.setOpenNotificationHandler((String message) async { + //刷新界面状态,展示推送消息 + setState(() { + _notification = message; + }); + }); + //获取推送地址regID + initPlatformState(); + } + + initPlatformState() async { + //调用插件封装的regID + String regID = await fpush.registrationID; + //刷新界面状态,展示regID + setState(() { + _regID = regID; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Column( + children: [ + //展示regID,以及收到的消息 + Text('Running on: $_regID\n'), + Text('Notification Received: $_notification') + ], + ), + ), + ), + ); + } +} + + +点击运行,可以看到,我们的应用已经可以获取到极光推送地址了: + + + +图10 iOS运行示例 + + + +图11 Android运行示例 + +接下来,我们再去极光开发者服务后台发一条真实的推送消息。在服务后台选择我们的App,随后进入极光推送控制台。这时,我们就可以进行消息推送测试了。 + +在发送通知一栏,我们把通知标题改为“测试”,通知内容设置为“极光推送测试”;在目标人群一栏,由于是测试账号,我们可以直接选择“广播所有人”,如果你希望精确定位到接收方,也可以提供在应用中获取到的极光推送地址(即Registration ID): + + + +图12 极光推送后台 + +点击发送预览并确认,可以看到,我们的应用不仅可以被来自极光的推送消息唤醒,还可以在Flutter应用内收到来自原生宿主转发的消息内容: + + + +图13 iOS推送消息 + + + +图 14 Android推送消息 + +总结 + +好了,今天的分享就到这里。我们一起来小结一下吧。 + +我们以Flutter插件工程的方式,为极光SDK提供了一个Dart层的封装。插件工程同时提供了iOS和Android目录,我们可以在这两个目录下完成原生代码宿主封装,不仅可以为Dart层提供接口正向回调(比如,初始化、获取极光推送地址),还可以通过方法通道以反向回调的方式将推送消息转发给Dart。 + +今天,我和你分享了很多原生代码宿主的配置、绑定、注册的逻辑。不难发现,推送过程链路长、涉众多、配置复杂,要想在Flutter完全实现原生推送能力,工作量主要集中在原生代码宿主,Dart层能做的事情并不多。 + +我把今天分享所改造的Flutter_Push_Plugin放到了GitHub中,你可以把插件工程下载下来,多运行几次,体会插件工程与普通Flutter工程的异同,并加深对消息推送全流程的认识。其中,Flutter_Push_Plugin提供了实现原生推送功能的最小集合,你可以根据实际需求完善这个插件。 + +需要注意的是,我们今天的实际工程演示是通过内嵌的example工程示例所完成的,如果你有一个独立的Flutter工程(比如Flutter_Push_Demo)需要接入Flutter_Push_Plugin,其配置方式与example工程并无不同,唯一的区别是,需要在pubspec.yaml文件中将对插件的依赖显示地声明出来而已: + +dependencies: + flutter_push_plugin: + git: + url: https://github.com/cyndibaby905/31_flutter_push_plugin.git + + +思考题 + +在Flutter_Push_Plugin的原生实现中,用户点击了推送消息把Flutter应用唤醒时,为了确保Flutter完成初始化,我们等待了1秒才执行相应的Flutter回调通知。这段逻辑有需要优化的地方吗?为了让Flutter代码能够更快地收到推送消息,你会如何优化呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/32适配国际化,除了多语言我们还需要注意什么_.md b/专栏/Flutter核心技术与实战/32适配国际化,除了多语言我们还需要注意什么_.md new file mode 100644 index 0000000..fca38de --- /dev/null +++ b/专栏/Flutter核心技术与实战/32适配国际化,除了多语言我们还需要注意什么_.md @@ -0,0 +1,237 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 32 适配国际化,除了多语言我们还需要注意什么_ + 你好,我是陈航。今天,我们来聊聊Flutter应用的国际化。 + +借助于App Store与Google Play,我们能够把应用发布到全世界的任何一个应用商店里。应用的(潜在)使用者可能来自于不同国家、说着不同的语言。如果我们想为全世界的使用者提供统一而标准的体验,那么首先就需要让App能够支持多种语言。而这一过程,一般被称为“国际化”。 + +提起国际化,你可能会认为这等同于翻译App内所有用户可见的文本。其实,这个观点不够精确。更为准确地描述国际化的工作职责,应该是“涉及语言及地区差异的适配改造过程”。 + +比如,如果我们要显示金额,同样的面值,在中国会显示为¥100,而在美国则会显示为$100;又比如,App的引导图,在中国我们可能会选用长城作为背景,而在美国我们则可能会选择金门大桥作为背景。 + +因此,对一款App做国际化的具体过程,除了翻译文案之外,还需要将货币单位和背景图等资源也设计成可根据不同地区自适应的变量。这也就意味着,我们在设计App架构时,需要提前将语言与地区的差异部分独立出来。 + +其实,这也是在Flutter中进行国际化的整体思路,即语言差异配置抽取+国际化代码生成。而在语言差异配置抽取的过程中,文案、货币单位,以及背景图资源的处理,其实并没有本质区别。所以在今天的分享中,我会以多语言文案为主,为你讲述在Flutter中如何实现语言与地区差异的独立化,相信在学习完这部分的知识之后,对于其他类型的语言差异你也能够轻松搞定国际化了。 + +Flutter i18n + +在Flutter中,国际化的语言和地区的差异性配置,是应用程序代码的一部分。如果要在Flutter中实现文本的国际化,我们需要执行以下几步: + + +首先,实现一个LocalizationsDelegate(即翻译代理),并将所有需要翻译的文案全部声明为它的属性; +然后,依次为需要支持的语言地区进行手动翻译适配; +最后,在应用程序MaterialApp初始化时,将这个代理类设置为应用程序的翻译回调。 + + +如果我们中途想要新增或者删除某个语系或者文案,都需要修改程序代码。 + +看到这里你会发现,如果我们想要使用官方提供的国际化方案来设计App架构,不仅工作量大、繁琐,而且极易出错。所以,要开始Flutter应用的国际化道路,我们不如把官方的解决方案扔到一边,直接从Android Studio中的Flutter i18n插件开始学习。这个插件在其内部提供了不同语言地区的配置封装,能够帮助我们自动地从翻译稿生成Dart代码。 + +为了安装Flutter i18n插件,我们需要打开Android Studio的Preference选项,在左边的tab中,切换到Plugins选项,搜索这个插件,点击install即可。安装完成之后再重启Android Studio,这个插件就可以使用了。 + + + +图1 Flutter i18n插件安装 + +Flutter i18n依赖flutter_localizations插件包,所以我们还需要在pubspec.yaml文件里,声明对它的依赖,否则程序会报错: + +dependencies: + flutter_localizations: + sdk: flutter + + +这时,我们会发现在res文件夹下,多了一个values/strings_en.arb的文件。 + +arb文件是JSON格式的配置,用来存放文案标识符和文案翻译的键值对。所以,我们只要修改了res/values下的arb文件,i18n插件就会自动帮我们生成对应的代码。 + +strings_en文件,则是系统默认的英文资源配置。为了支持中文,我们还需要在values目录下再增加一个strings_zh.arb文件: + + + +图2 arb文件格式 + +试着修改一下strings_zh.arb文件,可以看到,Flutter i18n插件为我们自动生成了generated/i18n.dart。这个类中不仅以资源标识符属性的方式提供了静态文案的翻译映射,对于通过参数来实现动态文案的message_tip标识符,也自动生成了一个同名内联函数: + + + +图3 Flutter i18n插件自动生成代码 + +我们把strings_en.arb继续补全,提供英文版的文案。需要注意的是,i18n.dart是由插件自动生成的,每次arb文件有新的变更都会自动更新,所以切忌手动编辑这个文件。 + +接下来,我们以Flutter官方的工程模板,即计数器demo来演示如何在Flutter中实现国际化。 + +在下面的代码中,我们在应用程序的入口,即MaterialApp初始化时,为其设置了支持国际化的两个重要参数,即localizationsDelegates与supportedLocales。前者为应用的翻译回调,而后者则为应用所支持的语言地区属性。 + +S.delegate是Flutter i18n插件自动生成的类,包含了所支持的语言地区属性,以及对应的文案翻译映射。理论上,通过这个类就可以完全实现应用的国际化,但为什么我们在配置应用程序的翻译回调时,除了它之外,还加入了GlobalMaterialLocalizations.delegate与GlobalWidgetsLocalizations.delegate这两个回调呢? + +这是因为Flutter提供的Widget,其本身已经支持了国际化,所以我们没必要再翻译一遍,直接用官方的就可以了,而这两个类则就是官方所提供的翻译回调。事实上,我们刚才在pubspec.yaml文件中声明的flutter_localizations插件包,就是Flutter提供的翻译套装,而这两个类就是套装中的著名成员。 + +在完成了应用程序的国际化配置之后,我们就可以在程序中通过S.of(context),直接获取arb文件中翻译的文案了。 + +不过需要注意的是,提取翻译文案的代码需要在能获取到翻译上下文的前提下才能生效,也就是说只能针对MaterialApp的子Widget生效。因此,在这种配置方式下,我们是无法对MaterialApp的title属性进行国际化配置的。不过,好在MaterialApp提供了一个回调方法onGenerateTitle,来提供翻译上下文,因此我们可以通过它,实现title文案的国际化: + +//应用程序入口 +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + localizationsDelegates: const [ + S.delegate,//应用程序的翻译回调 + GlobalMaterialLocalizations.delegate,//Material组件的翻译回调 + GlobalWidgetsLocalizations.delegate,//普通Widget的翻译回调 + ], + supportedLocales: S.delegate.supportedLocales,//支持语系 + //title的国际化回调 + onGenerateTitle: (context){ + return S.of(context).app_title; + }, + home: MyHomePage(), + ); + } +} + + +应用的主界面文案的国际化,则相对简单得多了,直接通过S.of(context)方法就可以拿到arb声明的翻译文案了: + +Widget build(BuildContext context) { + return Scaffold( + //获取appBar title的翻译文案 + appBar: AppBar( + title: Text(S.of(context).main_title), + ), + body: Center( + //传入_counter参数,获取计数器动态文案 + child: Text( + S.of(context).message_tip(_counter.toString()) + ) + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter,//点击回调 + tooltip: 'Increment', + child: Icon(Icons.add), + ), + ); +} + + +在Android手机上,分别切换英文和中文系统,可以看到,计数器应用已经正确地处理了多语言的情况。 + + + +图4 计数器示例(Android英文系统) + + + +图5 计数器示例(Android中文系统) + +由于iOS应用程序有一套自建的语言环境管理机制,默认是英文。为了让iOS应用正确地支持国际化,我们还需要在原生的iOS工程中进行额外的配置。我们打开iOS原生工程,切换到工程面板。在Localization这一项配置中,我们看到iOS工程已经默认支持了英文,所以还需要点击“+”按钮,新增中文: + + + +图6 iOS工程中文配置 + +完成iOS的工程配置后,我们回到Flutter工程,选择iOS手机运行程序。可以看到,计数器的iOS版本也可以正确地支持国际化了。 + + + +图7 计数器示例(iOS英文系统) + + + +图8 计数器示例(iOS中文系统) + +原生工程配置 + +上面介绍的国际化方案,其实都是在Flutter应用内实现的。而在Flutter框架运行之前,我们是无法访问这些国际化文案的。 + +Flutter需要原生环境才能运行,但有些文案,比如应用的名称,我们需要在Flutter框架运行之前就为它提供多个语言版本(比如英文版本为computer,中文版本为计数器),这时就需要在对应的原生工程中完成相应的国际化配置了。 + +我们先去Android工程下进行应用名称的配置。 + +首先,在Android工程中,应用名称是在AndroidManifest.xml文件中application的android:label属性声明的,所以我们需要将其修改为字符串资源中的一个引用,让其能够根据语言地区自动选择合适的文案: + + + ... + + + + + + +然后,我们还需要在android/app/src/main/res文件夹中,为要支持的语言创建字符串strings.xml文件。这里由于默认文件是英文的,所以我们只需要为中文创建一个文件即可。字符串资源的文件目录结构,如下图所示: + + + +图9 strings.xml文件目录结构 + +values与values-zh文件夹下的strings.xml内容如下所示: + + + + + Computer + + + + + + + 计数器 + + + +完成Android应用标题的工程配置后,我们回到Flutter工程,选择Android手机运行程序,可以看到,计数器的Android应用标题也可以正确地支持国际化了。 + +接下来,我们再看iOS工程下如何实现应用名称的配置。 + +与Android工程类似,iOS工程中的应用名称是在Info.list文件的Bundle name属性声明的,所以我们也需要将其修改为字符串资源中的一个引用,使其能够根据语言地区自动选择文案: + + + +图10 iOS工程应用名称配置 + +由于应用名称默认是不可配置的,所以工程并没有提供英文或者中文的可配置项,这些都需要通过新建与字符串引用对应的资源文件去搞定的。 + +我们右键单击Runner文件夹,然后选择New File来添加名为InfoPlist.strings的字符串资源文件,并在工程面板的最右侧文件检查器中的Localization选项中,添加英文和中文两种语言。InfoPlist.strings的英文版和中文版内容如下所示: + +//英文版 +"CFBundleName" = "Computer"; + +//中文版 +"CFBundleName" = "计数器"; + + +至此,我们也完成了iOS应用标题的工程配置。我们回到Flutter工程,选择iOS手机运行程序,发现计数器的iOS应用标题也支持国际化了。 + +总结 + +好了,今天的分享就到这里。我们来总结下核心知识点吧。 + +在今天的分享中,我与你介绍了Flutter应用国际化的解决方案,即在代码中实现一个LocalizationsDelegate,在这个类中将所有需要翻译的文案全部声明为它的属性,然后依次进行手动翻译适配,最后将这个代理类设置为应用程序的翻译回调。 + +而为了简化手动翻译到代码转换的过程,我们通常会使用多个arb文件存储文案在不同语言地区的映射关系,并使用Flutter i18n插件来实现代码的自动转换。 + +国际化的核心就是语言差异配置抽取。在原生Android和iOS系统中进行国际化适配,我们只需为需要国际化的资源(比如,字符串文本、图片、布局等)提供不同的文件夹目录,就可以在应用层代码访问国际化资源时,自动根据语言地区进行适配。 + +但,Flutter的国际化能力就相对原始很多,不同语言和地区的国际化资源既没有存放在单独的xml或者JSON上,也没有存放在不同的语言和地区文件夹中。幸好有Flutter i18n插件的帮助,否则为一个应用提供国际化的支持将会是件极其繁琐的事情。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留下一道思考题吧。 + +在Flutter中,如何实现图片类资源的国际化呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/33如何适配不同分辨率的手机屏幕?.md b/专栏/Flutter核心技术与实战/33如何适配不同分辨率的手机屏幕?.md new file mode 100644 index 0000000..e0b0ca9 --- /dev/null +++ b/专栏/Flutter核心技术与实战/33如何适配不同分辨率的手机屏幕?.md @@ -0,0 +1,234 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 33 如何适配不同分辨率的手机屏幕? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了在Flutter中实现国际化的基本原理。与原生Android和iOS只需为国际化资源提供不同的目录,就可以在运行时自动根据语言和地区进行适配不同,Flutter的国际化是完全在代码中实现的。 + +即通过代码声明的方式,将应用中所有需要翻译的文案都声明为LocalizationsDelegate的属性,然后针对不同的语言和地区进行手动翻译适配,最后在初始化应用程序时,将这个代理设置为国际化的翻译回调。而为了简化这个过程,也为了将国际化资源与代码实现分离,我们通常会使用arb文件存储不同语言地区的映射关系,并通过Flutter i18n插件来实现代码的自动生成。 + +可以说,国际化为全世界的用户提供了统一而标准的体验。那么,为不同尺寸、不同旋转方向的手机提供统一而标准的体验,就是屏幕适配需要解决的问题了。 + +在移动应用的世界中,页面是由控件组成的。如果我们支持的设备只有普通手机,可以确保同一个页面、同一个控件,在不同的手机屏幕上的显示效果是基本一致的。但,随着平板电脑和类平板电脑等超大屏手机越来越普及,很多原本只在普通手机上运行的应用也逐渐跑在了平板上。 + +但,由于平板电脑的屏幕非常大,展示适配普通手机的界面和控件时,可能会出现UI异常的情况。比如,对于新闻类手机应用来说,通常会有新闻列表和新闻详情两个页面,如果我们把这两个页面原封不动地搬到平板电脑上,就会出现控件被拉伸、文字过小过密、图片清晰度不够、屏幕空间被浪费的异常体验。 + +而另一方面,即使对于同一台手机或平板电脑来说,屏幕的宽高配置也不是一成不变的。因为加速度传感器的存在,所以当我们旋转屏幕时,屏幕宽高配置会发生逆转,即垂直方向与水平方向的布局行为会互相交换,从而导致控件被拉伸等UI异常问题。 + +因此,为了让用户在不同的屏幕宽高配置下获得最佳的体验,我们不仅需要对平板进行屏幕适配,充分利用额外可用的屏幕空间,也需要在屏幕方向改变时重新排列控件。即,我们需要优化应用程序的界面布局,为用户提供新功能、展示新内容,以将拉伸变形的界面和控件替换为更自然的布局,将单一的视图合并为复合视图。 + +在原生Android或iOS中,这种在同一页面实现不同布局的行为,我们通常会准备多个布局文件,通过判断当前屏幕分辨率来决定应该使用哪套布局方式。在Flutter中,屏幕适配的原理也非常类似,只不过Flutter并没有布局文件的概念,我们需要准备多个布局来实现。 + +那么今天,我们就来分别来看一下如何通过多个布局,实现适配屏幕旋转与平板电脑。 + +适配屏幕旋转 + +在屏幕方向改变时,屏幕宽高配置也会发生逆转:从竖屏模式变成横屏模式,原来的宽变成了高(垂直方向上的布局空间更短了),而高则变成了宽(水平方向上的布局空间更长了)。 + +通常情况下,由于ScrollView和ListView的存在,我们基本上不需要担心垂直方向上布局空间更短的问题,大不了一屏少显示几个控件元素,用户仍然可以使用与竖屏模式同样的交互滚动视图来查看其他控件元素;但水平方向上布局空间更长,界面和控件通常已被严重拉伸,原有的布局方式和交互方式都需要做较大调整。 + +从横屏模式切回竖屏模式,也是这个道理。 + +为了适配竖屏模式与横屏模式,我们需要准备两个布局方案,一个用于纵向,一个用于横向。当设备改变方向时,Flutter会通知我们重建布局:Flutter提供的OrientationBuilder控件,可以在设备改变方向时,通过builder函数回调告知其状态。这样,我们就可以根据回调函数提供的orientation参数,来识别当前设备究竟是处于横屏(landscape)还是竖屏(portrait)状态,从而刷新界面。 + +如下所示的代码演示了OrientationBuilder的具体用法。我们在其builder回调函数中,准确地识别出了设备方向,并对横屏和竖屏两种模型加载了不同的布局方式,而_buildVerticalLayout和_buildHorizontalLayout是用于创建相应布局的方法: + +@override +Widget build(BuildContext context) { + return Scaffold( + //使用OrientationBuilder的builder模式感知屏幕旋转 + body: OrientationBuilder( + builder: (context, orientation) { + //根据屏幕旋转方向返回不同布局行为 + return orientation == Orientation.portrait + ? _buildVerticalLayout() + : _buildHorizontalLayout(); + }, + ), + ); +} + + +OrientationBuilder提供了orientation参数可以识别设备方向,而如果我们在OrientationBuilder之外,希望根据设备的旋转方向设置一些组件的初始化行为,也可以使用MediaQueryData提供的orientation方法: + +if(MediaQuery.of(context).orientation == Orientation.portrait) { + //dosth +} + + +需要注意的是,Flutter应用默认支持竖屏和横屏两种模式。如果我们的应用程序不需要提供横屏模式,也可以直接调用SystemChrome提供的setPreferredOrientations方法告诉Flutter,这样Flutter就可以固定视图的布局方向了: + +SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + + +适配平板电脑 + +当适配更大的屏幕尺寸时,我们希望App上的内容可以适应屏幕上额外的可用空间。如果我们在平板中使用与手机相同的布局,就会浪费大量的可视空间。与适配屏幕旋转类似,最直接的方法是为手机和平板电脑创建两种不同的布局。然而,考虑到平板电脑和手机为用户提供的功能并无差别,因此这种实现方式将会新增许多不必要的重复代码。 + +为解决这个问题,我们可以采用另外一种方法:将屏幕空间划分为多个窗格,即采用与原生Android、iOS类似的Fragment、ChildViewController概念,来抽象独立区块的视觉功能。 + +多窗格布局可以在平板电脑和横屏模式上,实现更好的视觉平衡效果,增强App的实用性和可读性。而,我们也可以通过独立的区块,在不同尺寸的手机屏幕上快速复用视觉功能。 + +如下图所示,分别展示了普通手机、横屏手机与平板电脑,如何使用多窗格布局来改造新闻列表和新闻详情交互: + + + +图1 多窗格布局示意图 + +首先,我们需要分别为新闻列表与新闻详情创建两个可重用的独立区块: + + +新闻列表,可以在元素被点击时通过回调函数告诉父Widget元素索引; +而新闻详情,则用于展示新闻列表中被点击的元素索引。 + + +对于手机来说,由于空间小,所以新闻列表区块和新闻详情区块都是独立的页面,可以通过点击新闻元素进行新闻详情页面的切换;而对于平板电脑(和手机横屏布局)来说,由于空间足够大,所以我们把这两个区块放置在同一个页面,可以通过点击新闻元素去刷新同一页面的新闻详情。 + +页面的实现和区块的实现是互相独立的,通过区块复用就可以减少编写两个独立布局的工作: + +//列表Widget +class ListWidget extends StatefulWidget { + final ItemSelectedCallback onItemSelected; + ListWidget( + this.onItemSelected,//列表被点击的回调函数 + ); + @override + _ListWidgetState createState() => _ListWidgetState(); +} + +class _ListWidgetState extends State { + @override + Widget build(BuildContext context) { + //创建一个20项元素的列表 + return ListView.builder( + itemCount: 20, + itemBuilder: (context, position) { + return ListTile( + title: Text(position.toString()),//标题为index + onTap:()=>widget.onItemSelected(position),//点击后回调函数 + ); + }, + ); + } +} + +//详情Widget +class DetailWidget extends StatefulWidget { + final int data; //新闻列表被点击元素索引 + DetailWidget(this.data); + @override + _DetailWidgetState createState() => _DetailWidgetState(); +} + +class _DetailWidgetState extends State { + @override + Widget build(BuildContext context) { + return Container( + color: Colors.red,//容器背景色 + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(widget.data.toString()),//居中展示列表被点击元素索引 + ], + ), + ), + ); + } +} + + +然后,我们只需要检查设备屏幕是否有足够的宽度来同时展示列表与详情部分。为了获取屏幕宽度,我们可以使用MediaQueryData提供的size方法。 + +在这里,我们将平板电脑的判断条件设置为宽度大于480。这样,屏幕中就有足够的空间可以切换到多窗格的复合布局了: + +if(MediaQuery.of(context).size.width > 480) { + //tablet +} else { + //phone +} + + +最后,如果宽度够大,我们就会使用Row控件将列表与详情包装在同一个页面中,用户可以点击左侧的列表刷新右侧的详情;如果宽度比较小,那我们就只展示列表,用户可以点击列表,导航到新的页面展示详情: + +class _MasterDetailPageState extends State { + var selectedValue = 0; + @override + Widget build(BuildContext context) { + return Scaffold( + body: OrientationBuilder(builder: (context, orientation) { + //平板或横屏手机,页面内嵌列表ListWidget与详情DetailWidget + if (MediaQuery.of(context).size.width > 480) { + return Row(children: [ + Expanded( + child: ListWidget((value) {//在列表点击回调方法中刷新右侧详情页 + setState(() {selectedValue = value;}); + }), + ), + Expanded(child: DetailWidget(selectedValue)), + ]); + + } else {//普通手机,页面内嵌列表ListWidget + return ListWidget((value) {//在列表点击回调方法中打开详情页DetailWidget + Navigator.push(context, MaterialPageRoute( + builder: (context) { + return Scaffold( + body: DetailWidget(value), + ); + }, + )); + + }); + } + }), + ); + } +} + + +运行一下代码,可以看到,我们的应用已经完全适配不同尺寸、不同方向的设备屏幕了。 + + + +图2 竖屏手机版列表详情 + + + +图3 横屏手机版列表详情 + + + +图4 竖屏平板列表详情 + + + +图5 横屏平板列表详情 + +总结 + +好了,今天的分享就到这里。我们总结一下今天的核心知识点吧。 + +在Flutter中,为了适配不同设备屏幕,我们需要提供不同的布局方式。而将独立的视觉区块进行封装,通过OrientationBuilder提供的orientation回调参数,以及MediaQueryData提供的屏幕尺寸,以多窗格布局的方式为它们提供不同的页面呈现形态,能够大大降低编写独立布局所带来的重复工作。如果你的应用不需要支持设备方向,也可以通过SystemChrome提供的setPreferredOrientations方法,强制竖屏。 + +做好应用开发,我们除了要保证产品功能正常,还需要兼容碎片化(包括设备碎片化、品牌碎片化、系统碎片化、屏幕碎片化等方面)可能带来的潜在问题,以确保良好的用户体验。 + +与其他维度碎片化可能带来功能缺失甚至Crash不同,屏幕碎片化不至于导致功能完全不可用,但控件显示尺寸却很容易在没有做好适配的情况下产生变形,让用户看到异形甚至不全的UI信息,影响产品形象,因此也需要重点关注。 + +在应用开发中,我们可以分别在不同屏幕尺寸的主流机型和模拟器上运行我们的程序,来观察UI样式和功能是否异常,从而写出更加健壮的布局代码。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留下一道思考题吧 + +setPreferredOrientations方法是全局生效的,如果你的应用程序中有两个相邻的页面,页面A仅支持竖屏,页面B同时支持竖屏和横屏,你会如何实现呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/34如何理解Flutter的编译模式?.md b/专栏/Flutter核心技术与实战/34如何理解Flutter的编译模式?.md new file mode 100644 index 0000000..bfc8708 --- /dev/null +++ b/专栏/Flutter核心技术与实战/34如何理解Flutter的编译模式?.md @@ -0,0 +1,243 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 34 如何理解Flutter的编译模式? + 你好,我是陈航。今天,我们来聊聊Flutter的编译模式吧。 + +在开发移动应用程序时,一个App的完整生命周期包括开发、测试和上线3个阶段。在每个阶段,开发者的关注点都不一样。 + +比如,在开发阶段,我们希望调试尽可能方便、快速,尽可能多地提供错误上下文信息;在测试阶段,我们希望覆盖范围尽可能全面,能够具备不同配置切换能力,可以测试和验证还没有对外发布的新功能;而在发布阶段,我们则希望能够去除一切测试代码,精简调试信息,使运行速度尽可能快,代码足够安全。 + +这就要求开发者在构建移动应用时,不仅要在工程内提前准备多份配置环境,还要利用编译器提供的编译选项,打包出符合不同阶段优化需求的App。 + +对于Flutter来说,它既支持常见的Debug、Release等工程物理层面的编译模式,也支持在工程内提供多种配置环境入口。今天,我们就来学习一下Flutter提供的编译模式,以及如何在App中引用开发环境和生产环境,使得我们在不破坏任何生产环境代码的情况下,能够测试处于开发期的新功能。 + +Flutter的编译模式 + +Flutter支持3种运行模式,包括Debug、Release和Profile。在编译时,这三种模式是完全独立的。首先,我们先来看看这3种模式的具体含义吧。 + + +Debug模式对应Dart的JIT模式,可以在真机和模拟器上同时运行。该模式会打开所有的断言(assert),以及所有的调试信息、服务扩展和调试辅助(比如Observatory)。此外,该模式为快速开发和运行做了优化,支持亚秒级有状态的Hot reload(热重载),但并没有优化代码执行速度、二进制包大小和部署。flutter run –debug命令,就是以这种模式运行的。 +Release模式对应Dart的AOT模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布,给最终的用户使用。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小,因此编译时间较长。flutter run –release命令,就是以这种模式运行的。 +Profile模式,基本与Release模式一致,只是多了对Profile模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖(比如,可以连接Observatory到进程)。该模式用于分析真实设备实际运行性能。flutter run –profile命令,就是以这种模式运行的。 + + +由于Profile与Release在编译过程上几乎无差异,因此我们今天只讨论Debug和Release模式。 + +在开发应用时,为了便于快速发现问题,我们通常会在运行时识别当前的编译模式,去改变代码的部分执行行为:在Debug模式下,我们会打印详细的日志,调用开发环境接口;而在Release模式下,我们会只记录极少的日志,调用生产环境接口。 + +在运行时识别应用的编译模式,有两种解决办法: + + +通过断言识别; +通过Dart VM所提供的编译常数识别。 + + +我们先来看看如何通过断言识别应用的编译模式。 + +通过Debug与Release模式的介绍,我们可以得出,Release与Debug模式的一个重要区别就是,Release模式关闭了所有的断言。因此,我们可以借助于断言,写出只在Debug模式下生效的代码。 + +如下所示,我们在断言里传入了一个始终返回true的匿名函数执行结果,这个匿名函数的函数体只会在Debug模式下生效: + +assert(() { + //Do sth for debug + return true; +}()); + + +需要注意的是,匿名函数声明调用结束时追加了小括号()。 这是因为断言只能检查布尔值,所以我们必须使用括号强制执行这个始终返回true的匿名函数,以确保匿名函数体的代码可以执行。 + +接下来,我们再看看如何通过编译常数识别应用的编译模式。 + +如果说通过断言只能写出在Debug模式下运行的代码,而通过Dart提供的编译常数,我们还可以写出只在Release模式下生效的代码。Dart提供了一个布尔型的常量kReleaseMode,用于反向指示当前App的编译模式。 + +如下所示,我们通过判断这个常量,可以准确地识别出当前的编译模式: + +if(kReleaseMode){ + //Do sth for release +} else { + //Do sth for debug +} + + +分离配置环境 + +通过断言和kReleaseMode常量,我们能够识别出当前App的编译环境,从而可以在运行时对某个代码功能进行局部微调。而如果我们想在整个应用层面,为不同的运行环境提供更为统一的配置(比如,对于同一个接口调用行为,开发环境会使用dev.example.com域名,而生产环境会使用api.example.com域名),则需要在应用启动入口提供可配置的初始化方式,根据特定需求为应用注入配置环境。 + +在Flutter构建App时,为应用程序提供不同的配置环境,总体可以分为抽象配置、配置多入口、读配置和编译打包4个步骤: + + +抽象出应用程序的可配置部分,并使用InheritedWidget对其进行封装; +将不同的配置环境拆解为多个应用程序入口(比如,开发环境为main-dev.dart、生产环境为main.dart),把应用程序的可配置部分固化在各个入口处; +在运行期,通过InheritedWidget提供的数据共享机制,将配置部分应用到其子Widget对应的功能中; +使用Flutter提供的编译打包选项,构建出不同配置环境的安装包。 + + +接下来,我将依次为你介绍具体的实现步骤。 + +在下面的示例中,我会把应用程序调用的接口和标题进行区分实现,即开发环境使用dev.example.com域名,应用主页标题为dev;而生产环境使用api.example.com域名,主页标题为example。 + +首先是配置抽象。根据需求可以看出,应用程序中有两个需要配置的部分,即接口apiBaseUrl和标题appName,因此我定义了一个继承自InheritedWidget的类AppConfig,对这两个配置进行封装: + +class AppConfig extends InheritedWidget { + AppConfig({ + @required this.appName, + @required this.apiBaseUrl, + @required Widget child, + }) : super(child: child); + + final String appName;//主页标题 + final String apiBaseUrl;//接口域名 + + //方便其子Widget在Widget树中找到它 + static AppConfig of(BuildContext context) { + return context.inheritFromWidgetOfExactType(AppConfig); + } + + //判断是否需要子Widget更新。由于是应用入口,无需更新 + @override + bool updateShouldNotify(InheritedWidget oldWidget) => false; +} + + +接下来,我们需要为不同的环境创建不同的应用入口。 + +在这个例子中,由于只有两个环境,即开发环境与生产环境,因此我们将文件分别命名为main_dev.dart和main.dart。在这两个文件中,我们会使用不同的配置数据来对AppConfig进行初始化,同时把应用程序实例MyApp作为其子Widget,这样整个应用内都可以获取到配置数据: + +//main_dev.dart +void main() { + var configuredApp = AppConfig( + appName: 'dev',//主页标题 + apiBaseUrl: 'http://dev.example.com/',//接口域名 + child: MyApp(), + ); + runApp(configuredApp);//启动应用入口 +} + +//main.dart +void main() { + var configuredApp = AppConfig( + appName: 'example',//主页标题 + apiBaseUrl: 'http://api.example.com/',//接口域名 + child: MyApp(), + ); + runApp(configuredApp);//启动应用入口 +} + + +完成配置环境的注入之后,接下来就可以在应用内获取配置数据,来实现定制化的功能了。由于AppConfig是整个应用程序的根节点,因此我可以通过调用AppConfig.of方法,来获取到相关的数据配置。 + +在下面的代码中,我分别获取到了应用主页的标题,以及接口域名,并显示了出来: + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + var config = AppConfig.of(context);//获取应用配置 + return MaterialApp( + title: config.appName,//应用主页标题 + home: MyHomePage(), + ); + } +} + +class MyHomePage extends StatelessWidget { + @override + Widget build(BuildContext context) { + var config = AppConfig.of(context);//获取应用配置 + return Scaffold( + appBar: AppBar( + title: Text(config.appName),//应用主页标题 + ), + body: Center( + child: Text('API host: ${config.apiBaseUrl}'),//接口域名 + ), + ); + } +} + + +现在,我们已经完成了分离配置环境的代码部分。最后,我们可以使用Flutter提供的编译选项,来构建出不同配置的安装包了。 + +如果想要在模拟器或真机上运行这段代码,我们可以在flutter run命令后面,追加–target或-t参数,来指定应用程序初始化入口: + +//运行开发环境应用程序 +flutter run -t lib/main_dev.dart + +//运行生产环境应用程序 +flutter run -t lib/main.dart + + +如果我们想在Android Studio上为应用程序创建不同的启动配置,则可以通过Flutter插件为main_dev.dart增加启动入口。 + +首先,点击工具栏上的Config Selector,选择Edit Configurations进入编辑应用程序启动选项: + + + +图1 Config Selector新增入口 + +然后,点击位于工具栏面板左侧顶部的“+”按钮,在弹出的菜单中选择Flutter选项,为应用程序新增一项启动入口: + + + +图2 选择新增类型 + +最后,在入口的编辑面板中,为main_dev选择程序的Dart入口,点击OK后,就完成了入口的新增工作: + + + +图3 编辑启动入口 + +接下来,我们就可以在Config Selector中切换不同的启动入口,从而直接在Android Studio中注入不同的配置环境了: + + + +图4 Config Selector切换启动入口 + +我们试着在不同的入口中进行切换和运行,可以看到,App已经可以识别出不同的配置环境了: + + + +图5 开发环境运行示例 + + + +图6 生产环境运行示例 + +而如果我们想要打包构建出适用于Android的APK,或是iOS的IPA安装包,则可以在flutter build 命令后面,同样追加–target或-t参数,指定应用程序初始化入口: + +//打包开发环境应用程序 +flutter build apk -t lib/main_dev.dart +flutter build ios -t lib/main_dev.dart + +//打包生产环境应用程序 +flutter build apk -t lib/main.dart +flutter build ios -t lib/main.dart + + +总结 + +好了,今天的分享就到这里。我们来总结一下今天的主要内容吧。 + +Flutter支持Debug与Release的编译模式,并且这两种模式在构建时是完全独立的。Debug模式下会打开所有的断言和调试信息,而Release模式下则会关闭这些信息,因此我们可以通过断言,写出只在Debug模式下生效的代码。而如果我们想更精准地识别出当前的编译模式,则可以利用Dart所提供的编译常数kReleaseMode,写出只在Release模式下生效的代码。 + +除此之外,Flutter对于常见的分环境配置能力也提供了支持,我们可以使用InheritedWidget为应用中可配置部分进行封装抽象,通过配置多入口的方式为应用的启动注入配置环境。 + +需要注意的是,虽然断言和kReleaseMode都能够识别出Debug编译模式,但它们对二进制包的打包构建影响是不同的。 + +采用断言的方式,其相关代码会在Release构建中被完全剔除;而如果使用kReleaseMode常量来识别Debug环境,虽然这段代码永远不会在Release环境中执行,但却会被打入到二进制包中,增大包体积。因此,如果没有特殊需求的话,一定要使用断言来实现Debug特有的逻辑,或是在发布期前将使用kReleaseMode判断的Debug逻辑完全删除。 + +我把今天分享所涉及到的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留一道思考题吧。 + +在保持生产环境代码不变的情况下,如果想在开发环境中支持不同配置的切换,我们应该如何实现? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/35HotReload是怎么做到的?.md b/专栏/Flutter核心技术与实战/35HotReload是怎么做到的?.md new file mode 100644 index 0000000..f49800f --- /dev/null +++ b/专栏/Flutter核心技术与实战/35HotReload是怎么做到的?.md @@ -0,0 +1,254 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 35 Hot Reload是怎么做到的? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了Flutter的Debug与Release编译模式,以及如何通过断言与编译常数来精准识别当前代码所运行的编译模式,从而写出只在Debug或Release模式下生效的代码。 + +另外,对于在开发期与发布期分别使用不同的配置环境,Flutter也提供了支持。我们可以将应用中可配置的部分进行封装抽象,使用配置多入口的方式,通过InheritedWidget来为应用的启动注入环境配置。 + +如果你有过原生应用的开发经历,那你一定知道在原生应用开发时,如果我们想要在硬件设备上看到调整后的运行效果,在完成了代码修改后,必须要经过漫长的重新编译,才能同步到设备上。 + +而Flutter则不然,由于Debug模式支持JIT,并且为开发期的运行和调试提供了大量优化,因此代码修改后,我们可以通过亚秒级的热重载(Hot Reload)进行增量代码的快速刷新,而无需经过全量的代码编译,从而大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。 + +比如,在开发页面的过程中,当我们点击按钮出现一个弹窗的时候,发现弹窗标题没有对齐,这时候只要修改标题的对齐样式,然后保存,在代码并没有重新编译的情况下,标题样式就发生了改变,感觉就像是在UI编辑面板中直接修改元素样式一样,非常方便。 + +那么,Flutter的热重载到底是如何实现的呢? + +热重载 + +热重载是指,在不中断App正常运行的情况下,动态注入修改后的代码片段。而这一切的背后,离不开Flutter所提供的运行时编译能力。为了更好地理解Flutter的热重载实现原理,我们先简单回顾一下Flutter编译模式背后的技术吧。 + + +JIT(Just In Time),指的是即时编译或运行时编译,在Debug模式中使用,可以动态下发和执行代码,启动速度快,但执行性能受运行时编译影响; + + + + +图1 JIT编译模式示意图 + + +AOT(Ahead Of Time),指的是提前编译或运行前编译,在Release模式中使用,可以为特定的平台生成稳定的二进制代码,执行性能好、运行速度快,但每次执行均需提前编译,开发调试效率低。 + + + + +图2 AOT编译模式示意图 + +可以看到,Flutter提供的两种编译模式中,AOT是静态编译,即编译成设备可直接执行的二进制码;而JIT则是动态编译,即将Dart代码编译成中间代码(Script Snapshot),在运行时设备需要Dart VM解释执行。 + +而热重载之所以只能在Debug模式下使用,是因为Debug模式下,Flutter采用的是JIT动态编译(而Release模式下采用的是AOT静态编译)。JIT编译器将Dart代码编译成可以运行在Dart VM上的Dart Kernel,而Dart Kernel是可以动态更新的,这就实现了代码的实时更新功能。 + + + +图3 热重载流程 + +总体来说,热重载的流程可以分为扫描工程改动、增量编译、推送更新、代码合并、Widget重建5个步骤: + + +工程改动。热重载模块会逐一扫描工程中的文件,检查是否有新增、删除或者改动,直到找到在上次编译之后,发生变化的Dart代码。 +增量编译。热重载模块会将发生变化的Dart代码,通过编译转化为增量的Dart Kernel文件。 +推送更新。热重载模块将增量的Dart Kernel文件通过HTTP端口,发送给正在移动设备上运行的Dart VM。 +代码合并。Dart VM会将收到的增量Dart Kernel文件,与原有的Dart Kernel文件进行合并,然后重新加载新的Dart Kernel文件。 +Widget重建。在确认Dart VM资源加载成功后,Flutter会将其UI线程重置,通知Flutter Framework重建Widget。 + + +可以看到,Flutter提供的热重载在收到代码变更后,并不会让App重新启动执行,而只会触发Widget树的重新绘制,因此可以保持改动前的状态,这就大大节省了调试复杂交互界面的时间。 + +比如,我们需要为一个视图栈很深的页面调整UI样式,若采用重新编译的方式,不仅需要漫长的全量编译时间,而为了恢复视图栈,也需要重复之前的多次点击交互,才能重新进入到这个页面查看改动效果。但如果是采用热重载的方式,不仅没有编译时间,而且页面的视图栈状态也得以保留,完成热重载之后马上就可以预览UI效果了,相当于局部界面刷新。 + +不支持热重载的场景 + +Flutter提供的亚秒级热重载一直是开发者的调试利器。通过热重载,我们可以快速修改UI、修复Bug,无需重启应用即可看到改动效果,从而大大提升了UI调试效率。 + +不过,Flutter的热重载也有一定的局限性。因为涉及到状态保存与恢复,所以并不是所有的代码改动都可以通过热重载来更新。 + +接下来,我就与你介绍几个不支持热重载的典型场景: + + +代码出现编译错误; +Widget状态无法兼容; +全局变量和静态属性的更改; +main方法里的更改; +initState方法里的更改; +枚举和泛类型更改。 + + +现在,我们就具体看看这几种场景的问题,应该如何解决吧。 + +代码出现编译错误 + +当代码更改导致编译错误时,热重载会提示编译错误信息。比如下面的例子中,代码中漏写了一个反括号,在使用热重载时,编译器直接报错: + +Initializing hot reload... +Syncing files to device iPhone X... + +Compiler message: +lib/main.dart:84:23: Error: Can't find ')' to match '('. + return MaterialApp( + ^ +Reloaded 1 of 462 libraries in 301ms. + + +在这种情况下,只需更正上述代码中的错误,就可以继续使用热重载。 + +Widget状态无法兼容 + +当代码更改会影响Widget的状态时,会使得热重载前后Widget所使用的数据不一致,即应用程序保留的状态与新的更改不兼容。这时,热重载也是无法使用的。 + +比如下面的代码中,我们将某个类的定义从 StatelessWidget改为StatefulWidget时,热重载就会直接报错: + +//改动前 +class MyWidget extends StatelessWidget { + Widget build(BuildContext context) { + return GestureDetector(onTap: () => print('T')); + } +} + +//改动后 +class MyWidget extends StatefulWidget { + @override + State createState() => MyWidgetState(); +} +class MyWidgetState extends State { /*...*/ } + + +当遇到这种情况时,我们需要重启应用,才能看到更新后的程序。 + +全局变量和静态属性的更改 + +在Flutter中,全局变量和静态属性都被视为状态,在第一次运行应用程序时,会将它们的值设为初始化语句的执行结果,因此在热重载期间不会重新初始化。 + +比如下面的代码中,我们修改了一个静态Text数组的初始化元素。虽然热重载并不会报错,但由于静态变量并不会在热重载之后初始化,因此这个改变并不会产生效果: + +//改动前 +final sampleText = [ + Text("T1"), + Text("T2"), + Text("T3"), + Text("T4"), +]; + +//改动后 +final sampleText = [ + Text("T1"), + Text("T2"), + Text("T3"), + Text("T10"), //改动点 +]; + + +如果我们需要更改全局变量和静态属性的初始化语句,重启应用才能查看更改效果。 + +main方法里的更改 + +在Flutter中,由于热重载之后只会根据原来的根节点重新创建控件树,因此main函数的任何改动并不会在热重载后重新执行。所以,如果我们改动了main函数体内的代码,是无法通过热重载看到更新效果的。 + +在第1篇文章“预习篇 · 从零开始搭建Flutter开发环境”中,我与你介绍了这种情况。在更新前,我们通过MyApp封装了一个展示“Hello World”的文本,在更新后,直接在main函数封装了一个展示“Hello 2019”的文本: + +//更新前 +class MyAPP extends StatelessWidget { +@override + Widget build(BuildContext context) { + return const Center(child: Text('Hello World', textDirection: TextDirection.ltr)); + } +} + +void main() => runApp(new MyAPP()); + +//更新后 +void main() => runApp(const Center(child: Text('Hello, 2019', textDirection: TextDirection.ltr))); + + +由于main函数并不会在热重载后重新执行,因此以上改动是无法通过热重载查看更新的。 + +initState方法里的更改 + +在热重载时,Flutter会保存Widget的状态,然后重建Widget。而initState方法是Widget状态的初始化方法,这个方法里的更改会与状态保存发生冲突,因此热重载后不会产生效果。 + +在下面的例子中,我们将计数器的初始值由10改为100: + +//更改前 +class _MyHomePageState extends State { + int _counter; + @override + void initState() { + _counter = 10; + super.initState(); + } + ... +} + +//更改后 +class _MyHomePageState extends State { + int _counter; + @override + void initState() { + _counter = 100; + super.initState(); + } + ... +} + + +由于这样的改动发生在initState方法中,因此无法通过热重载查看更新,我们需要重启应用,才能看到更改效果。 + +枚举和泛型类型更改 + +在Flutter中,枚举和泛型也被视为状态,因此对它们的修改也不支持热重载。比如在下面的代码中,我们将一个枚举类型改为普通类,并为其增加了一个泛型参数: + +//更改前 +enum Color { + red, + green, + blue +} + +class C { + U u; +} + +//更改后 +class Color { + Color(this.r, this.g, this.b); + final int r; + final int g; + final int b; +} + +class C { + U u; + V v; +} + + +这两类更改都会导致热重载失败,并生成对应的提示消息。同样的,我们需要重启应用,才能查看到更改效果。 + +总结 + +好了,今天的分享就到这里,我们总结一下今天的主要内容吧。 + +Flutter的热重载是基于JIT编译模式的代码增量同步。由于JIT属于动态编译,能够将Dart代码编译成生成中间代码,让Dart VM在运行时解释执行,因此可以通过动态更新中间代码实现增量同步。 + +热重载的流程可以分为5步,包括:扫描工程改动、增量编译、推送更新、代码合并、Widget重建。Flutter在接收到代码变更后,并不会让App重新启动执行,而只会触发Widget树的重新绘制,因此可以保持改动前的状态,大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。 + +而另一方面,由于涉及到状态保存与恢复,因此涉及状态兼容与状态初始化的场景,热重载是无法支持的,比如改动前后Widget状态无法兼容、全局变量与静态属性的更改、main方法里的更改、initState方法里的更改、枚举和泛型的更改等。 + +可以发现,热重载提高了调试UI的效率,非常适合写界面样式这样需要反复查看修改效果的场景。但由于其状态保存的机制所限,热重载本身也有一些无法支持的边界。 + +如果你在写业务逻辑的时候,不小心碰到了热重载无法支持的场景,也不需要进行漫长的重新编译加载等待,只要点击位于工程面板左下角的热重启(Hot Restart)按钮,就可以以秒级的速度进行代码重新编译以及程序重启了,同样也很快。 + +思考题 + +最后,我给你留下一道思考题吧。 + +你是否了解其他框架(比如React Native、Webpack)的热重载机制?它们的热重载机制与Flutter有何区别? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/36如何通过工具链优化开发调试效率?.md b/专栏/Flutter核心技术与实战/36如何通过工具链优化开发调试效率?.md new file mode 100644 index 0000000..ddf35e9 --- /dev/null +++ b/专栏/Flutter核心技术与实战/36如何通过工具链优化开发调试效率?.md @@ -0,0 +1,213 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 36 如何通过工具链优化开发调试效率? + 你好,我是陈航。今天我们来聊聊如何调试Flutter App。 + +软件开发通常是一个不断迭代、螺旋式上升的过程。在迭代的过程中,我们不可避免地会经常与Bug打交道,特别是在多人协作的项目中,我们不仅要修复自己的Bug,有时还需要帮别人解决Bug。 + +而修复Bug的过程,不仅能帮我们排除代码中的隐患,也能帮助我们更快地上手项目。因此,掌握好调试这门技能,就显得尤为重要了。 + +在Flutter中,调试代码主要分为输出日志、断点调试和布局调试3类。所以,在今天这篇文章中,我将会围绕这3个主题为你详细介绍Flutter应用的代码调试。 + +我们先来看看,如何通过输出日志调试应用代码吧。 + +输出日志 + +为了便于跟踪和记录应用的运行情况,我们在开发时通常会在一些关键步骤输出日志(Log),即使用print函数在控制台打印出相关的上下文信息。通过这些信息,我们可以定位代码中可能出现的问题。 + +在前面的很多篇文章里,我们都大量使用了print函数来输出应用执行过程中的信息。不过,由于涉及I/O操作,使用print来打印信息会消耗较多的系统资源。同时,这些输出数据很可能会暴露App的执行细节,所以我们需要在发布正式版时屏蔽掉这些输出。 + +说到操作方法,你想到的可能是在发布版本前先注释掉所有的print语句,等以后需要调试时,再取消这些注释。但,这种方法无疑是非常无聊且耗时的。那么,Flutter给我们提供了什么更好的方式吗? + +为了根据不同的运行环境来开启日志调试功能,我们可以使用Flutter提供的debugPrint来代替print。debugPrint函数同样会将消息打印至控制台,但与print不同的是,它提供了定制打印的能力。也就是说,我们可以向debugPrint函数,赋值一个函数声明来自定义打印行为。 + +比如在下面的代码中,我们将debugPrint函数定义为一个空函数体,这样就可以实现一键取消打印的功能了: + +debugPrint = (String message, {int wrapWidth}) {};//空实现 + + +在Flutter 中,我们可以使用不同的main文件来表示不同环境下的入口。比如,在第34篇文章“如何理解Flutter的编译模式?”中,我们就分别用main.dart与main-dev.dart实现了生产环境与开发环境的分离。同样,我们可以通过main.dart与main-dev.dart,去分别定义生产环境与开发环境不同的打印日志行为。 + +在下面的例子中,我们将生产环境的debugPrint定义为空实现,将开发环境的debugPrint定义为同步打印数据: + +//main.dart +void main() { + // 将debugPrint指定为空的执行体, 所以它什么也不做 + debugPrint = (String message, {int wrapWidth}) {}; + runApp(MyApp()); +} + +//main-dev.dart +void main() async { + // 将debugPrint指定为同步打印数据 + debugPrint = (String message, {int wrapWidth}) => debugPrintSynchronously(message, wrapWidth: wrapWidth); + runApp(MyApp()); +} + + +可以看到,在代码实现上,我们只要将应用内所有的print都替换成debugPrint,就可以满足开发环境下打日志的需求,也可以保证生产环境下应用的执行信息不会被意外打印。 + +断点调试 + +输出日志固然方便,但如果要想获取更为详细,或是粒度更细的上下文信息,静态调试的方式非常不方便。这时,我们需要更为灵活的动态调试方法,即断点调试。断点调试可以让代码在目标语句上暂停,让程序逐条执行后续的代码语句,来帮助我们实时关注代码执行上下文中所有变量值的详细变化过程。 + +Android Studio提供了断点调试的功能,调试Flutter应用与调试原生Android代码的方法完全一样,具体可以分为三步,即标记断点、调试应用、查看信息。 + +接下来,我们以Flutter默认的计数器应用模板为例,观察代码中_counter值的变化,体会断点调试的全过程。 + +首先是标记断点。既然我们要观察_counter值的变化,因此在界面上展示最新的_counter值时添加断点,去观察其数值变化是最理想的。因此,我们在行号右侧点击鼠标,可以把断点加载到初始化Text控件所示的位置。 + +在下图的例子中,我们为了观察_counter在等于20的时候是否正常,还特意设置了一个条件断点_counter==20,这样调试器就只会在第20次点击计数器按钮时暂停下来: + + + +图1 标记断点 + +添加断点后,对应的行号将会出现圆形的断点标记,并高亮显示整行代码。到此,断点就添加好了。当然,我们还可以同时添加多个断点,以便更好地观察代码的执行过程。 + +接下来则是调试应用了。和之前通过点击run按钮的运行方式不同,这一次我们需要点击工具栏上的虫子图标,以调试模式启动App,如下图所示: + + + +图2 调试App + +等调试器初始化好后,我们的程序就启动了。由于我们的断点设置在了_counter为20时,因此在第20次点击了“+”按钮后,代码运行到了断点位置,自动进入了Debug视图模式。 + + + +图3 Debug视图模式 + +如图所示,我把Debug视图模式划分为4个区域,即A区控制调试工具、B区步进调试工具、C区帧调试窗口、D区变量查看窗口。 + +A区的按钮,主要用来控制调试的执行情况: + + + +图4 A区按钮 + + +比如,我们可以点击继续执行按钮来让程序继续运行、点击终止执行按钮来让程序终止运行、点击重新执行按钮来让程序重新启动,或是在程序正常执行时,点击暂停执行按钮按钮来让程序暂停运行。 +又比如,我们可以点击编辑断点按钮来编辑断点信息,或是点击禁用断点按钮来取消断点。 + + +B区的按钮,主要用来控制断点的步进情况: + + + +图5 B区按钮 + + +比如,我们可以点击单步跳过按钮来让程序单步执行(但不会进入方法体内部)、点击单步进入或强制单步进入按钮让程序逐条语句执行,甚至还可以点击运行到光标处按钮让程序执行到在光标处(相当于新建临时断点)。 +比如,当我们认为断点所在的方法体已经无需执行时,则可以点击单步跳出按钮让程序立刻执行完当前进入的方法,从而返回方法调用处的下一行。 +又比如,我们可以点击表达式计算按钮来通过赋值或表达式方式修改任意变量的值。如下图所示,我们通过输入表达式_counter+=100,将计数器更新为120: + + + + +图6 Evaluate计算表达式 + +C区用来指示当前断点所包含的函数执行堆栈,D区则是其堆栈中的函数帧所对应的变量。 + +在这个例子中,我们的断点是在_MyHomePageState类中的build方法设置的,因此D区显示的也是build方法上下文所包含的变量信息(比如_counter、_widget、this、_element等)。如果我们想切换到_MyHomePageState的build方法执行堆栈中的其他函数(比如StatefulElement.build),查看相关上下文的变量信息时,只需要在C区中点击对应的方法名即可。 + + + +图7 切换函数执行堆栈 + +可以看到,Android Studio提供的Flutter调试能力很丰富,我们可以通过这些基本步骤的组合,更为灵活地调整追踪步长,观察程序的执行情况,揪出代码中的Bug。 + +布局调试 + +通过断点调试,我们在Android Studio的调试面板中,可以随时查看执行上下文有关的变量的值,根据逻辑来做进一步的判断,确定跟踪执行的步骤。不过在更多时候,我们使用Flutter的目的是实现视觉功能,而视觉功能的调试是无法简单地通过Debug视图模式面板来搞定的。 + +在上一篇文章中,我们通过Flutter提供的热重载机制,已经极大地缩短了从代码编写到界面运行所耗费的时间,可以更快地发现代码与目标界面的明显问题,但如果想要更快地发现界面中更为细小的问题,比如对齐、边距等,则需要使用Debug Painting这个界面调试工具。 + +Debug Painting能够以辅助线的方式,清晰展示每个控件元素的布局边界,因此我们可以根据辅助线快速找出布局出问题的地方。而Debug Painting的开启也比较简单,只需要将debugPaintSizeEnabled变量置为true即可。如下所示,我们在main函数中,开启了Debug Painting调试开关: + +import 'package:flutter/rendering.dart'; + +void main() { + debugPaintSizeEnabled = true; //打开Debug Painting调试开关 + runApp(new MyApp()); +} + + +运行代码后,App在iPhone X中的执行效果如下: + + + +图8 Debug Painting运行效果 + +可以看到,计数器示例中的每个控件元素都已经被标尺辅助线包围了。 + +辅助线提供了基本的Widget可视化能力。通过辅助线,我们能够感知界面中是否存在对齐或边距的问题,但却没有办法获取到布局信息,比如Widget距离父视图的边距信息、Widget宽高尺寸信息等。 + +如果我们想要获取到Widget的可视化信息(比如布局信息、渲染信息等)去解决渲染问题,就需要使用更强大的Flutter Inspector了。Flutter Inspector对控件布局详细数据提供了一种强大的可视化手段,来帮助我们诊断布局问题。 + +为了使用Flutter Inspector,我们需要回到Android Studio,通过工具栏上的“Open DevTools”按钮启动Flutter Inspector: + + + +图9 Flutter Inspector启动按钮 + +随后,Android Studio会打开浏览器,将计数器示例中的Widget树结构展示在面板中。可以看到,Flutter Inspector所展示的Widget树结构,与代码中实现的Widget层次是一一对应的。 + + + +图10 Flutter Inspector示意图 + +我们的App运行在iPhone X之上,其分辨率为375*812。接下来,我们以Column组件的布局信息为例,通过确认其水平方向为居中布局、垂直方向为充满父Widget剩余空间的过程,来说明Flutter Inspector的具体用法。 + +为了确认Column在垂直方向是充满其父Widget剩余空间的,我们首先需要确定其父Widget在垂直方向上的另一个子Widget,即AppBar的信息。我们点击Flutter Inspector面板左侧中的AppBar控件,右侧对应显示了它的具体视觉信息。 + +可以看到AppBar控件距离左边距为0,上边距也为0;宽为375,高为100: + + + +图11 Flutter Inspector之AppBar + +然后,我们将Flutter Inspector面板左侧选择的控件更新为Column,右侧也更新了它的具体视觉信息,比如排版方向、对齐模式、渲染信息,以及它的两个子Widget-Text。 + +可以看到,Column控件的距离左边距为38.5,上边距为0;宽为298,高为712: + + + +图12 Flutter Inspector之Columnn + +通过上面的数据我们可以得出: + + +Column的右边距=父Widget宽度(即iPhone X宽度375)-Column左边距(即38.5)- Column宽(即298)=38.5,即左右边距相等,因此Column是水平方向居中的; +Column的高度=父Widget的高度(即iPhone X高度812)- AppBar上边距(即0)- AppBar高度(即100) - Column上边距(即0)= 712.0,即Column在垂直方向上完全填满了父Widget除去AppBar之后的剩余空间。 + + +因此,Column的布局行为是完全符合预期的。 + +总结 + +好了,今天的分享就到这里,我们总结一下今天的主要内容吧。 + +首先,我带你学习了如何实现定制日志的输出能力。Flutter提供了debugPrint函数,这是一个可以被覆盖的打印函数。我们可以分别定义生产环境与开发环境的日志输出行为,来满足开发期打日志需求的同时,保证发布期日志执行信息不会被意外打印。 + +然后,我与你介绍了Android Studio提供的Flutter调试功能,并通过观察计数器工程的计数器变量为例,与你讲述了具体的调试方法。 + +最后,我们一起学习了Flutter的布局调试能力,即通过Debug Paiting来定义辅助线,以及通过Flutter Inspector这种可视化手段来更为准确地诊断布局问题。 + +写代码不可避免会出现Bug,出现时就需要Debug(调试)。调试代码本质上就是一个不断收敛问题发生范围的过程,因此排查问题的一个最基本思路,就是二分法。 + +所谓二分调试法,是指通过某种稳定复现的特征(比如Crash、某个变量的值、是否出现某个现象等任何明显的迹象),加上一个能把问题出现的范围划分为两半的手段(比如断点、assert、日志等),两者结合反复迭代不断将问题可能出现的范围一分为二(比如能判断出引发问题的代码出现在断点之前等)。通过二分法,我们可以快速缩小问题范围,这样一来调试的效率也就上去了。 + +思考题 + +最后,我给你留下一道思考题吧。 + +请将debugPrint在生产环境下的打印日志行为更改为写日志文件。其中,日志文件一共5个(0-4),每个日志文件不能超过2MB,但可以循环写。如果日志文件已满,则循环至下一个日志文件,清空后重新写入。 + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/37如何检测并优化FlutterApp的整体性能表现?.md b/专栏/Flutter核心技术与实战/37如何检测并优化FlutterApp的整体性能表现?.md new file mode 100644 index 0000000..fe91892 --- /dev/null +++ b/专栏/Flutter核心技术与实战/37如何检测并优化FlutterApp的整体性能表现?.md @@ -0,0 +1,232 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 37 如何检测并优化Flutter App的整体性能表现? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了调试Flutter代码的3种基本方式,即输出日志、断点调试与布局调试。 + +通过可定制打印行为的debugPrint函数,我们可以实现生产环境与开发环境不同的日志输出行为,从而保证在开发期打印的调试信息不会被发布至线上;借助于IDE(Android Studio)所提供的断点调试选项,我们可以不断调整代码执行步长和代码暂停条件,收敛问题发生范围,直至找到问题根源;而如果我们想找出代码中的布局渲染类Bug,则可以通过Debug Painting和Flutter Inspector提供的辅助线和视图可视化信息,来更为精准地定位视觉问题。 + +除了代码逻辑Bug和视觉异常这些功能层面的问题之外,移动应用另一类常见的问题是性能问题,比如滑动操作不流畅、页面出现卡顿丢帧现象等。这些问题虽然不至于让移动应用完全不可用,但也很容易引起用户反感,从而对应用质量产生质疑,甚至失去耐心。 + +那么,如果应用渲染并不流畅,出现了性能问题,我们该如何检测,又该从哪里着手处理呢? + +在Flutter中,性能问题可以分为GPU线程问题和UI线程(CPU)问题两类。这些问题的确认都需要先通过性能图层进行初步分析,而一旦确认问题存在,接下来就需要利用Flutter提供的各类分析工具来定位问题了。 + +所以在今天这篇文章中,我会与你一起学习分析Flutter应用性能问题的基本思路和工具,以及常见的优化办法。 + +如何使用性能图层? + +要解决问题,我们首先得了解如何去度量问题,性能分析也不例外。Flutter提供了度量性能问题的工具和手段,来帮助我们快速定位代码中的性能问题,而性能图层就是帮助我们确认问题影响范围的利器。 + +为了使用性能图层,我们首先需要以分析(Profile)模式启动应用。与调试代码可以通过模拟器在调试模式下找到代码逻辑Bug不同,性能问题需要在发布模式下使用真机进行检测。 + +这是因为,相比发布模式而言,调试模式增加了很多额外的检查(比如断言),这些检查可能会耗费很多资源;更重要的是,调试模式使用JIT模式运行应用,代码执行效率较低。这就使得调试模式运行的应用,无法真实反映出它的性能问题。 + +而另一方面,模拟器使用的指令集为x86,而真机使用的指令集是ARM。这两种方式的二进制代码执行行为完全不同,因此模拟器与真机的性能差异较大:一些x86指令集擅长的操作模拟器会比真机快,而另一些操作则会比真机慢。这也使得我们无法使用模拟器来评估真机才能出现的性能问题。 + +为了调试性能问题,我们需要在发布模式的基础之上,为分析工具提供少量必要的应用追踪信息,这就是分析模式。除了一些调试性能问题必须的追踪方法之外,Flutter应用的分析模式和发布模式的编译和运行是类似的,只是启动参数变成了profile而已:我们既可以在Android Studio中通过菜单栏点击Run->Profile ‘main.dart’ 选项启动应用,也可以通过命令行参数flutter run –profile运行Flutter应用。 + +分析渲染问题 + +在完成了应用启动之后,接下来我们就可以利用Flutter提供的渲染问题分析工具,即性能图层(Performance Overlay),来分析渲染问题了。 + +性能图层会在当前应用的最上层,以Flutter引擎自绘的方式展示GPU与UI线程的执行图表,而其中每一张图表都代表当前线程最近 300帧的表现,如果UI产生了卡顿(跳帧),这些图表可以帮助我们分析并找到原因。 + +下图演示了性能图层的展现样式。其中,GPU线程的性能情况在上面,UI线程的情况显示在下面,蓝色垂直的线条表示已执行的正常帧,绿色的线条代表的是当前帧: + + + +图1 性能图层 + +为了保持60Hz的刷新频率,GPU线程与UI线程中执行每一帧耗费的时间都应该小于16ms(1/60秒)。在这其中有一帧处理时间过长,就会导致界面卡顿,图表中就会展示出一个红色竖条。下图演示了应用出现渲染和绘制耗时的情况下,性能图层的展示样式: + + + +图2 渲染和绘制耗时异常 + +如果红色竖条出现在GPU线程图表,意味着渲染的图形太复杂,导致无法快速渲染;而如果是出现在了UI线程图表,则表示Dart代码消耗了大量资源,需要优化代码执行时间。 + +接下来,我们就先看看GPU问题定位吧。 + +GPU问题定位 + +GPU问题主要集中在底层渲染耗时上。有时候Widget树虽然构造起来容易,但在GPU线程下的渲染却很耗时。涉及Widget裁剪、蒙层这类多视图叠加渲染,或是由于缺少缓存导致静态图像的反复绘制,都会明显拖慢GPU的渲染速度。 + +我们可以使用性能图层提供的两项参数,即检查多视图叠加的视图渲染开关checkerboardOffscreenLayers,和检查缓存的图像开关checkerboardRasterCacheImages,来检查这两种情况。 + +checkerboardOffscreenLayers + +多视图叠加通常会用到Canvas里的savaLayer方法,这个方法在实现一些特定的效果(比如半透明)时非常有用,但由于其底层实现会在GPU渲染上涉及多图层的反复绘制,因此会带来较大的性能问题。 + +对于saveLayer方法使用情况的检查,我们只要在MaterialApp的初始化方法中,将checkerboardOffscreenLayers开关设置为true,分析工具就会自动帮我们检测多视图叠加的情况了:使用了saveLayer的Widget会自动显示为棋盘格式,并随着页面刷新而闪烁。 + +不过,saveLayer是一个较为底层的绘制方法,因此我们一般不会直接使用它,而是会通过一些功能性Widget,在涉及需要剪切或半透明蒙层的场景中间接地使用。所以一旦遇到这种情况,我们需要思考一下是否一定要这么做,能不能通过其他方式来实现呢。 + +比如下面的例子中,我们使用CupertinoPageScaffold与CupertinoNavigationBar实现了一个动态模糊的效果。 + +CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(),//动态模糊导航栏 + child: ListView.builder( + itemCount: 100, + //为列表创建100个不同颜色的RowItem + itemBuilder: (context, index)=>TabRowItem( + index: index, + lastItem: index == 100 - 1, + color: colorItems[index],//设置不同的颜色 + colorName: colorNameItems[index], + ) + ) +); + + + + +图3 动态模糊效果 + +由于视图滚动过程中频繁涉及视图蒙层效果的更新,因此checkerboardOffscreenLayers检测图层也感受到了对GPU的渲染压力,频繁的刷新闪烁。 + + + +图4 检测saveLayer使用 + +如果我们没有对动态模糊效果的特殊需求,则可以使用不带模糊效果的Scaffold和白色的AppBar实现同样的产品功能,来解决这个性能问题: + +Scaffold( + //使用普通的白色AppBar + appBar: AppBar(title: Text('Home', style: TextStyle(color:Colors.black),),backgroundColor: Colors.white), + body: ListView.builder( + itemCount: 100, + //为列表创建100个不同颜色的RowItem + itemBuilder: (context, index)=>TabRowItem( + index: index, + lastItem: index == 100 - 1, + color: colorItems[index],//设置不同的颜色 + colorName: colorNameItems[index], + ) + ), +); + + +运行一下代码,可以看到,在去掉了动态模糊效果之后,GPU的渲染压力得到了缓解,checkerboardOffscreenLayers检测图层也不再频繁闪烁了。 + + + +图5 去掉动态模糊效果 + +checkerboardRasterCacheImages + +从资源的角度看,另一类非常消耗性能的操作是,渲染图像。这是因为图像的渲染涉及I/O、GPU存储,以及不同通道的数据格式转换,因此渲染过程的构建需要消耗大量资源。为了缓解GPU的压力,Flutter提供了多层次的缓存快照,这样Widget重建时就无需重新绘制静态图像了。 + +与检查多视图叠加渲染的checkerboardOffscreenLayers参数类似,Flutter也提供了检查缓存图像的开关checkerboardRasterCacheImages,来检测在界面重绘时频繁闪烁的图像(即没有静态缓存)。 + +我们可以把需要静态缓存的图像加到RepaintBoundary中,RepaintBoundary可以确定Widget树的重绘边界,如果图像足够复杂,Flutter引擎会自动将其缓存,避免重复刷新。当然,因为缓存资源有限,如果引擎认为图像不够复杂,也可能会忽略RepaintBoundary。 + +如下代码展示了通过RepaintBoundary,将一个静态复合Widget加入缓存的具体用法。可以看到,RepaintBoundary在使用上与普通Widget并无区别: + +RepaintBoundary(//设置静态缓存图像 + child: Center( + child: Container( + color: Colors.black, + height: 10.0, + width: 10.0, + ), +)); + + +UI线程问题定位 + +如果说GPU线程问题定位的是渲染引擎底层渲染异常,那么UI线程问题发现的则是应用的性能瓶颈。比如在视图构建时,在build方法中使用了一些复杂的运算,或是在主Isolate中进行了同步的I/O操作。这些问题,都会明显增加CPU的处理时间,拖慢应用的响应速度。 + +这时,我们可以使用Flutter提供的Performance工具,来记录应用的执行轨迹。Performance是一个强大的性能分析工具,能够以时间轴的方式展示CPU的调用栈和执行时间,去检查代码中可疑的方法调用。 + +在点击了Android Studio底部工具栏中的“Open DevTools”按钮之后,系统会自动打开Dart DevTools的网页,将顶部的tab切换到Performance后,我们就可以开始分析代码中的性能问题了。 + + + +图6 打开Performance工具 + + + +图7 Performance主界面 + +接下来,我们通过一个ListView中计算MD5的例子,来演示Performance的具体分析过程。 + +考虑到在build函数中进行渲染信息的组装是一个常见的操作,为了演示这个知识点,我们故意放大了计算MD5的耗时,循环迭代计算了1万次: + +class MyHomePage extends StatelessWidget { + MyHomePage({Key key}) : super(key: key); + + String generateMd5(String data) { + //MD5固定算法 + var content = new Utf8Encoder().convert(data); + var digest = md5.convert(content); + return hex.encode(digest.bytes); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('demo')), + body: ListView.builder( + itemCount: 30,// 列表元素个数 + itemBuilder: (context, index) { + //反复迭代计算MD5 + String str = '1234567890abcdefghijklmnopqrstuvwxyz'; + for(int i = 0;i<10000;i++) { + str = generateMd5(str); + } + return ListTile(title: Text("Index : $index"), subtitle: Text(str)); + }// 列表项创建方法 + ), + ); + } +} + + +与性能图层能够自动记录应用执行情况不同,使用Performance来分析代码执行轨迹,我们需要手动点击“Record”按钮去主动触发,在完成信息的抽样采集后,点击“Stop”按钮结束录制。这时,我们就可以得到在这期间应用的执行情况了。 + +Performance记录的应用执行情况叫做CPU帧图,又被称为火焰图。火焰图是基于记录代码执行结果所产生的图片,用来展示CPU的调用栈,表示的是CPU 的繁忙程度。 + +其中,y轴表示调用栈,其每一层都是一个函数。调用栈越深,火焰就越高,底部就是正在执行的函数,上方都是它的父函数;x轴表示单位时间,一个函数在x轴占据的宽度越宽,就表示它被采样到的次数越多,即执行时间越长。 + +所以,我们要检测CPU耗时问题,皆可以查看火焰图底部的哪个函数占据的宽度最大。只要有“平顶”,就表示该函数可能存在性能问题。比如,我们这个案例的火焰图如下所示: + + + +图8 CPU帧图/火焰图 + +可以看到,_MyHomePage.generateMd5函数的执行时间最长,几乎占满了整个火焰图的宽,而这也与代码中存在的问题是一致的。 + +在找到了问题之后,我们就可以使用Isolate(或compute)将这些耗时的操作挪到并发主Isolate之外去完成了。 + +总结 + +好了,今天的分享就到这里。我们总结一下今天的主要内容吧。 + +在Flutter中,性能分析过程可以分为GPU线程问题定位和UI线程(CPU)问题定位,而它们都需要在真机上以分析模式(Profile)启动应用,并通过性能图层分析大致的渲染问题范围。一旦确认问题存在,接下来就需要利用Flutter所提供的分析工具来定位问题原因了。 + +关于GPU线程渲染问题,我们可以重点检查应用中是否存在多视图叠加渲染,或是静态图像反复刷新的现象。而UI线程渲染问题,我们则是通过Performance工具记录的火焰图(CPU帧图),分析代码耗时,找出应用执行瓶颈。 + +通常来说,由于Flutter采用基于声明式的UI设计理念,以数据驱动渲染,并采用Widget->Element->RenderObject三层结构,屏蔽了无谓的界面刷新,能够保证绝大多数情况下我们构建的应用都是高性能的,所以在使用分析工具检测出性能问题之后,通常我们并不需要做太多的细节优化工作,只需要在改造过程中避开一些常见的坑,就可以获得优异的性能。比如: + + +控制build方法耗时,将Widget拆小,避免直接返回一个巨大的Widget,这样Widget会享有更细粒度的重建和复用; +尽量不要为Widget设置半透明效果,而是考虑用图片的形式代替,这样被遮挡的Widget部分区域就不需要绘制了; +对列表采用懒加载而不是直接一次性创建所有的子Widget,这样视图的初始化时间就减少了。 + + +思考题 + +最后,我给你留下一道思考题吧。 + +请你改造ListView计算MD5的示例,在保证原有功能的情况下,使用并发Isolate(或compute)完成MD5的计算。提示:计算过程可以使用CircularProgressIndicator来展示加载动画。 + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/38如何通过自动化测试提高交付质量?.md b/专栏/Flutter核心技术与实战/38如何通过自动化测试提高交付质量?.md new file mode 100644 index 0000000..2df64c5 --- /dev/null +++ b/专栏/Flutter核心技术与实战/38如何通过自动化测试提高交付质量?.md @@ -0,0 +1,318 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 38 如何通过自动化测试提高交付质量? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了如何分析并优化Flutter应用的性能问题。通过在真机上以分析模式运行应用,我们可以借助于性能图层的帮助,找到引起性能瓶颈的两类问题,即GPU渲染问题和CPU执行耗时问题。然后,我们就可以使用Flutter提供的渲染开关和CPU帧图(火焰图),来检查应用中是否存在过度渲染或是代码执行耗时长的情况,从而去定位并着手解决应用的性能问题了。 + +在完成了应用的开发工作,并解决了代码中的逻辑问题和性能问题之后,接下来我们就需要测试验收应用的各项功能表现了。移动应用的测试工作量通常很大,这是因为为了验证真实用户的使用体验,测试往往需要跨越多个平台(Android/iOS)及不同的物理设备手动完成。 + +随着产品功能不断迭代累积,测试工作量和复杂度也随之大幅增长,手动测试变得越来越困难。那么,在为产品添加新功能,或者修改已有功能时,如何才能确保应用可以继续正常工作呢? + +答案是,通过编写自动化测试用例。 + +所谓自动化测试,是把由人驱动的测试行为改为由机器执行。具体来说就是,通过精心设计的测试用例,由机器按照执行步骤对应用进行自动测试,并输出执行结果,最后根据测试用例定义的规则确定结果是否符合预期。 + +也就是说,自动化测试将重复的、机械的人工操作变为自动化的验证步骤,极大的节省人力、时间和硬件资源,从而提高了测试效率。 + +在自动化测试用例的编写上,Flutter提供了包括单元测试和UI测试的能力。其中,单元测试可以方便地验证单个函数、方法或类的行为,而UI测试则提供了与Widget进行交互的能力,确认其功能是否符合预期。 + +接下来,我们就具体看看这两种自动化测试用例的用法吧。 + +单元测试 + +单元测试是指,对软件中的最小可测试单元进行验证的方式,并通过验证结果来确定最小单元的行为是否与预期一致。所谓最小可测试单元,一般来说,就是人为规定的、最小的被测功能模块,比如语句、函数、方法或类。 + +在Flutter中编写单元测试用例,我们可以在pubspec.yaml文件中使用test包来完成。其中,test包提供了编写单元测试用例的核心框架,即定义、执行和验证。如下代码所示,就是test包的用法: + +dev_dependencies: + test: + + + +备注:test包的声明需要在dev_dependencies下完成,在这个标签下面定义的包只会在开发模式生效。 + + +与Flutter应用通过main函数定义程序入口相同,Flutter单元测试用例也是通过main函数来定义测试入口的。不过,这两个程序入口的目录位置有些区别:应用程序的入口位于工程中的lib目录下,而测试用例的入口位于工程中的test目录下。 + +一个有着单元测试用例的Flutter工程目录结构,如下所示: + + + +图1 Flutter工程目录结构 + +接下来,我们就可以在main.dart中声明一个用来测试的类了。在下面的例子中,我们声明了一个计数器类Counter,这个类可以支持以递增或递减的方式修改计数值count: + +class Counter { + int count = 0; + void increase() => count++; + void decrease() => count--; +} + + +实现完待测试的类,我们就可以为它编写测试用例了。在Flutter中,测试用例的声明包含定义、执行和验证三个部分:定义和执行决定了被测试对象提供的、需要验证的最小可测单元;而验证则需要使用expect函数,将最小可测单元的执行结果与预期进行比较。 + +所以,在Flutter中编写一个测试用例,通常包含以下两大步骤: + + +实现一个包含定义、执行和验证步骤的测试用例; +将其包装在test内部,test是Flutter提供的测试用例封装类。 + + +在下面的例子中,我们定义了两个测试用例,其中第一个用例用来验证调用increase函数后的计数器值是否为1,而第二个用例则用来判断1+1是否等于2: + +import 'package:test/test.dart'; +import 'package:flutter_app/main.dart'; + +void main() { + //第一个用例,判断Counter对象调用increase方法后是否等于1 + test('Increase a counter value should be 1', () { + final counter = Counter(); + counter.increase(); + expect(counter.value, 1); + }); + //第二个用例,判断1+1是否等于2 + test('1+1 should be 2', () { + expect(1+1, 2); + }); +} + + +选择widget_test.dart文件,在右键弹出的菜单中选择“Run ‘tests in widget_test’”,就可以启动测试用例了。 + + + +图2 启动测试用例入口 + +稍等片刻,控制台就会输出测试用例的执行结果了。当然,这两个用例都能通过测试: + +22:05 Tests passed: 2 + + +如果测试用例的执行结果是不通过,Flutter会给我们怎样的提示呢?我们试着修改一下第一个计数器递增的用例,将它的期望结果改为2: + +test('Increase a counter value should be 1', () { + final counter = Counter(); + counter.increase(); + expect(counter.value, 2);//判断Counter对象调用increase后是否等于2 +}); + + +运行测试用例,可以看到,Flutter在执行完计数器的递增方法后,发现其结果1与预期的2不匹配,于是报错: + + + +图3 单元测试失败示意图 + +上面的示例演示了单个测试用例的编写方法,而如果有多个测试用例,它们之间是存在关联关系的,我们可以在最外层使用group将它们组合在一起。 + +在下面的例子中,我们定义了计数器递增和计数器递减两个用例,验证递增的结果是否等于1的同时判断递减的结果是否等于-1,并把它们组合在了一起: + +import 'package:test/test.dart'; +import 'package:counter_app/counter.dart'; +void main() { + //组合测试用例,判断Counter对象调用increase方法后是否等于1,并且判断Counter对象调用decrease方法后是否等于-1 + group('Counter', () { + test('Increase a counter value should be 1', () { + final counter = Counter(); + counter.increase(); + expect(counter.value, 1); + }); + + test('Decrease a counter value should be -1', () { + final counter = Counter(); + counter.decrease(); + expect(counter.value, -1); + }); + }); +} + + +同样的,这两个测试用例的执行结果也是通过。 + +在对程序的内部功能进行单元测试时,我们还可能需要从外部依赖(比如Web服务)获取需要测试的数据。比如下面的例子,Todo对象的初始化就是通过Web服务返回的JSON实现的。考虑到调用Web服务的过程中可能会出错,所以我们还处理了请求码不等于200的其他异常情况: + +import 'package:http/http.dart' as http; + +class Todo { + final String title; + Todo({this.title}); + //工厂类构造方法,将JSON转换为对象 + factory Todo.fromJson(Map json) { + return Todo( + title: json['title'], + ); + } +} + +Future fetchTodo(http.Client client) async { + final response = + await client.get('https://xxx.com/todos/1'); + + if (response.statusCode == 200) { + //请求成功,解析JSON + return Todo.fromJson(json.decode(response.body)); + } else { + //请求失败,抛出异常 + throw Exception('Failed to load post'); + } +} + + +考虑到这些外部依赖并不是我们的程序所能控制的,因此很难覆盖所有可能的成功或失败方案。比如,对于一个正常运行的Web服务来说,我们基本不可能测试出fetchTodo这个接口是如何应对403或502状态码的。因此,更好的一个办法是,在测试用例中“模拟”这些外部依赖(对应本例即为http.client),让这些外部依赖可以返回特定结果。 + +在单元测试用例中模拟外部依赖,我们需要在pubspec.yaml文件中使用mockito包,以接口实现的方式定义外部依赖的接口: + +dev_dependencies: + test: + mockito: + + +要使用mockito包来模拟fetchTodo的依赖http.client,我们首先需要定义一个继承自Mock(这个类可以模拟任何外部依赖),并以接口定义的方式实现了http.client的模拟类;然后,在测试用例的声明中,为其制定任意的接口返回。 + +在下面的例子中,我们定义了一个模拟类MockClient,这个类以接口声明的方式获取到了http.Client的外部接口。随后,我们就可以使用when语句,在其调用Web服务时,为其注入相应的数据返回了。在第一个用例中,我们为其注入了JSON结果;而在第二个用例中,我们为其注入了一个403的异常。 + +import 'package:mockito/mockito.dart'; +import 'package:http/http.dart' as http; + +class MockClient extends Mock implements http.Client {} + +void main() { + group('fetchTodo', () { + test('returns a Todo if successful', () async { + final client = MockClient(); + + //使用Mockito注入请求成功的JSON字段 + when(client.get('https://xxx.com/todos/1')) + .thenAnswer((_) async => http.Response('{"title": "Test"}', 200)); + //验证请求结果是否为Todo实例 + expect(await fetchTodo(client), isInstanceOf()); + }); + + test('throws an exception if error', () { + final client = MockClient(); + + //使用Mockito注入请求失败的Error + when(client.get('https://xxx.com/todos/1')) + .thenAnswer((_) async => http.Response('Forbidden', 403)); + //验证请求结果是否抛出异常 + expect(fetchTodo(client), throwsException); + }); +}); +} + + +运行这段测试用例,可以看到,我们在没有调用真实Web服务的情况下,成功模拟出了正常和异常两种结果,同样也是顺利通过验证了。 + +接下来,我们再看看UI测试吧。 + +UI测试 + +UI测试的目的是模仿真实用户的行为,即以真实用户的身份对应用程序执行UI交互操作,并涵盖各种用户流程。相比于单元测试,UI测试的覆盖范围更广、更关注流程和交互,可以找到单元测试期间无法找到的错误。 + +在Flutter中编写UI测试用例,我们需要在pubspec.yaml中使用flutter_test包,来提供编写UI测试的核心框架,即定义、执行和验证: + + +定义,即通过指定规则,找到UI测试用例需要验证的、特定的子Widget对象; + +执行,意味着我们要在找到的子Widget对象中,施加用户交互事件; + +验证,表示在施加了交互事件后,判断待验证的Widget对象的整体表现是否符合预期。 + + +如下代码所示,就是flutter_test包的用法: + +dev_dependencies: + flutter_test: + sdk: flutter + + + +接下来,我以Flutter默认的计时器应用模板为例,与你说明UI测试用例的编写方法。 + +在计数器应用中,有两处地方会响应外部交互事件,包括响应用户点击行为的按钮Icon,与响应渲染刷新事件的文本Text。按钮点击后,计数器会累加,文本也随之刷新。 + + + +图4 计数器示例 + +为确保程序的功能正常,我们希望编写一个UI测试用例,来验证按钮的点击行为是否与文本的刷新行为完全匹配。 + +与单元测试使用test对用例进行包装类似,UI测试使用testWidgets对用例进行包装。testWidgets提供了tester参数,我们可以使用这个实例来操作需要测试的Widget对象。 + +在下面的代码中,我们首先声明了需要验证的MyApp对象。在通过pumpWidget触发其完成渲染后,使用find.text方法分别查找了字符串文本为0和1的Text控件,目的是验证响应刷新事件的文本Text的初始化状态是否为0。 + +随后,我们通过find.byIcon方法找到了按钮控件,并通过tester.tap方法对其施加了点击行为。在完成了点击后,我们使用tester.pump方法强制触发其完成渲染刷新。最后,我们使用了与验证Text初始化状态同样的语句,判断在响应了刷新事件后的文本Text其状态是否为1: + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_app_demox/main.dart'; + +void main() { + testWidgets('Counter increments UI test', (WidgetTester tester) async { + //声明所需要验证的Widget对象(即MyApp),并触发其渲染 + await tester.pumpWidget(MyApp()); + + //查找字符串文本为'0'的Widget,验证查找成功 + expect(find.text('0'), findsOneWidget); + //查找字符串文本为'1'的Widget,验证查找失败 + expect(find.text('1'), findsNothing); + + //查找'+'按钮,施加点击行为 + await tester.tap(find.byIcon(Icons.add)); + //触发其渲染 + await tester.pump(); + + //查找字符串文本为'0'的Widget,验证查找失败 + expect(find.text('0'), findsNothing); + //查找字符串文本为'1'的Widget,验证查找成功 + expect(find.text('1'), findsOneWidget); +}); +} + + +运行这段UI测试用例代码,同样也顺利通过验证了。 + +除了点击事件之外,tester还支持其他的交互行为,比如文字输入enterText、拖动drag、长按longPress等,这里我就不再一一赘述了。如果你想深入理解这些内容,可以参考WidgetTester的官方文档进行学习。 + +总结 + +好了,今天的分享就到这里,我们总结一下今天的主要内容吧。 + +在Flutter中,自动化测试可以分为单元测试和UI测试。 + +单元测试的步骤,包括定义、执行和验证。通过单元测试用例,我们可以验证单个函数、方法或类,其行为表现是否与预期一致。而UI测试的步骤,同样是包括定义、执行和验证。我们可以通过模仿真实用户的行为,对应用进行交互操作,覆盖更广的流程。 + +如果测试对象存在像Web服务这样的外部依赖,为了让单元测试过程更为可控,我们可以使用mockito为其定制任意的数据返回,实现正常和异常两种测试用例。 + +需要注意的是,尽管UI测试扩大了应用的测试范围,可以找到单元测试期间无法找到的错误,不过相比于单元测试用例来说,UI测试用例的开发和维护代价非常高。因为一个移动应用最主要的功能其实就是UI,而UI的变化非常频繁,UI测试需要不断的维护才能保持稳定可用的状态。 + +“投入和回报”永远是考虑是否采用UI测试,以及采用何种级别的UI测试,需要最优先考虑的问题。我推荐的原则是,项目达到一定的规模,并且业务特征具有一定的延续规律性后,再考虑UI测试的必要性。 + +我把今天分享涉及的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留下一道思考题吧。 + +在下面的代码中,我们定义了SharedPreferences的更新和递增方法。请你使用mockito模拟SharedPreferences的方式,来为这两个方法实现对应的单元测试用例。 + +FutureupdateSP(SharedPreferences prefs, int counter) async { + bool result = await prefs.setInt('counter', counter); + return result; +} + +FutureincreaseSPCounter(SharedPreferences prefs) async { + int counter = (prefs.getInt('counter') ?? 0) + 1; + await updateSP(prefs, counter); + return counter; +} + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/39线上出现问题,该如何做好异常捕获与信息采集?.md b/专栏/Flutter核心技术与实战/39线上出现问题,该如何做好异常捕获与信息采集?.md new file mode 100644 index 0000000..6b9615f --- /dev/null +++ b/专栏/Flutter核心技术与实战/39线上出现问题,该如何做好异常捕获与信息采集?.md @@ -0,0 +1,548 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 39 线上出现问题,该如何做好异常捕获与信息采集? + 你好,我是陈航。 + +在上一篇文章中,我与你分享了如何为一个Flutter工程编写自动化测试用例。设计一个测试用例的基本步骤可以分为3步,即定义、执行和验证,而Flutter提供的单元测试和UI测试框架则可以帮助我们简化这些步骤。 + +其中,通过单元测试,我们可以很方便地验证单个函数、方法或类的行为,还可以利用mockito定制外部依赖返回任意数据,从而让测试更可控;而UI测试则提供了与Widget交互的能力,我们可以模仿用户行为,对应用进行相应的交互操作,确认其功能是否符合预期。 + +通过自动化测试,我们可以把重复的人工操作变成自动化的验证步骤,从而在开发阶段更及时地发现问题。但终端设备的碎片化,使得我们终究无法在应用开发期就完全模拟出真实用户的运行环境。所以,无论我们的应用写得多么完美、测试得多么全面,总是无法完全避免线上的异常问题。 + +这些异常,可能是因为不充分的机型适配、用户糟糕的网络状况;也可能是因为Flutter框架自身的Bug,甚至是操作系统底层的问题。这些异常一旦发生,Flutter应用会无法响应用户的交互事件,轻则报错,重则功能无法使用甚至闪退,这对用户来说都相当不友好,是开发者最不愿意看到的。 + +所以,我们要想办法去捕获用户的异常信息,将异常现场保存起来,并上传至服务器,这样我们就可以分析异常上下文,定位引起异常的原因,去解决此类问题了。那么今天,我们就一起来学习下Flutter异常的捕获和信息采集,以及对应的数据上报处理。 + +Flutter异常 + +Flutter异常指的是,Flutter程序中Dart代码运行时意外发生的错误事件。我们可以通过与Java类似的try-catch机制来捕获它。但与Java不同的是,Dart程序不强制要求我们必须处理异常。 + +这是因为,Dart采用事件循环的机制来运行任务,所以各个任务的运行状态是互相独立的。也就是说,即便某个任务出现了异常我们没有捕获它,Dart程序也不会退出,只会导致当前任务后续的代码不会被执行,用户仍可以继续使用其他功能。 + +Dart异常,根据来源又可以细分为App异常和Framework异常。Flutter为这两种异常提供了不同的捕获方式,接下来我们就一起看看吧。 + +App异常的捕获方式 + +App异常,就是应用代码的异常,通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序,App异常可以分为两类,即同步异常和异步异常:同步异常可以通过try-catch机制捕获,异步异常则需要采用Future提供的catchError语句捕获。 + +这两种异常的捕获方式,如下代码所示: + +//使用try-catch捕获同步异常 +try { + throw StateError('This is a Dart exception.'); +} +catch(e) { + print(e); +} + +//使用catchError捕获异步异常 +Future.delayed(Duration(seconds: 1)) + .then((e) => throw StateError('This is a Dart exception in Future.')) + .catchError((e)=>print(e)); + +//注意,以下代码无法捕获异步异常 +try { + Future.delayed(Duration(seconds: 1)) + .then((e) => throw StateError('This is a Dart exception in Future.')) +} +catch(e) { + print("This line will never be executed. "); +} + + +需要注意的是,这两种方式是不能混用的。可以看到,在上面的代码中,我们是无法使用try-catch去捕获一个异步调用所抛出的异常的。 + +同步的try-catch和异步的catchError,为我们提供了直接捕获特定异常的能力,而如果我们想集中管理代码中的所有异常,Flutter也提供了Zone.runZoned方法。 + +我们可以给代码执行对象指定一个Zone,在Dart中,Zone表示一个代码执行的环境范围,其概念类似沙盒,不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常,沙盒提供了onError回调函数,拦截那些在代码执行对象中的未捕获异常。 + +在下面的代码中,我们将可能抛出异常的语句放置在了Zone里。可以看到,在没有使用try-catch和catchError的情况下,无论是同步异常还是异步异常,都可以通过Zone直接捕获到: + +runZoned(() { + //同步抛出异常 + throw StateError('This is a Dart exception.'); +}, onError: (dynamic e, StackTrace stack) { + print('Sync error caught by zone'); +}); + +runZoned(() { + //异步抛出异常 + Future.delayed(Duration(seconds: 1)) + .then((e) => throw StateError('This is a Dart exception in Future.')); +}, onError: (dynamic e, StackTrace stack) { + print('Async error aught by zone'); +}); + + +因此,如果我们想要集中捕获Flutter应用中的未处理异常,可以把main函数中的runApp语句也放置在Zone中。这样在检测到代码中运行异常时,我们就能根据获取到的异常上下文信息,进行统一处理了: + +runZoned>(() async { + runApp(MyApp()); +}, onError: (error, stackTrace) async { + //Do sth for error +}); + + +接下来,我们再看看Framework异常应该如何捕获吧。 + +Framework异常的捕获方式 + +Framework异常,就是Flutter框架引发的异常,通常是由应用代码触发了Flutter框架底层的异常判断引起的。比如,当布局不合规范时,Flutter就会自动弹出一个触目惊心的红色错误界面,如下所示: + + + +图1 Flutter布局错误提示 + +这其实是因为,Flutter框架在调用build方法构建页面时进行了try-catch 的处理,并提供了一个ErrorWidget,用于在出现异常时进行信息提示: + +@override +void performRebuild() { + Widget built; + try { + //创建页面 + built = build(); + } catch (e, stack) { + //使用ErrorWidget创建页面 + built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack)); + ... + } + ... +} + + +这个页面反馈的信息比较丰富,适合开发期定位问题。但如果让用户看到这样一个页面,就很糟糕了。因此,我们通常会重写ErrorWidget.builder方法,将这样的错误提示页面替换成一个更加友好的页面。 + +下面的代码演示了自定义错误页面的具体方法。在这个例子中,我们直接返回了一个居中的Text控件: + +ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){ + return Scaffold( + body: Center( + child: Text("Custom Error Widget"), + ) + ); +}; + + +运行效果如下所示: + + + +图2 自定义错误提示页面 + +比起之前触目惊心的红色错误页面,白色主题的自定义页面看起来稍微友好些了。需要注意的是,ErrorWidget.builder方法提供了一个参数details用于表示当前的错误上下文,为避免用户直接看到错误信息,这里我们并没有将它展示到界面上。但是,我们不能丢弃掉这样的异常信息,需要提供统一的异常处理机制,用于后续分析异常原因。 + +为了集中处理框架异常,Flutter提供了FlutterError类,这个类的onError属性会在接收到框架异常时执行相应的回调。因此,要实现自定义捕获逻辑,我们只要为它提供一个自定义的错误处理回调即可。 + +在下面的代码中,我们使用Zone提供的handleUncaughtError语句,将Flutter框架的异常统一转发到当前的Zone中,这样我们就可以统一使用Zone去处理应用内的所有异常了: + +FlutterError.onError = (FlutterErrorDetails details) async { + //转发至Zone中 + Zone.current.handleUncaughtError(details.exception, details.stack); +}; + +runZoned>(() async { + runApp(MyApp()); +}, onError: (error, stackTrace) async { + //Do sth for error +}); + + +异常上报 + +到目前为止,我们已经捕获到了应用中所有的未处理异常。但如果只是把这些异常在控制台中打印出来还是没办法解决问题,我们还需要把它们上报到开发者能看到的地方,用于后续分析定位并解决问题。 + +关于开发者数据上报,目前市面上有很多优秀的第三方SDK服务厂商,比如友盟、Bugly,以及开源的Sentry等,而它们提供的功能和接入流程都是类似的。考虑到Bugly的社区活跃度比较高,因此我就以它为例,与你演示在抓取到异常后,如何实现自定义数据上报。 + +Dart接口实现 + +目前Bugly仅提供了原生Android/iOS的SDK,因此我们需要采用与第31篇文章“如何实现原生推送能力?”中同样的插件工程,为Bugly的数据上报提供Dart层接口。 + +与接入Push能力相比,接入数据上报要简单得多,我们只需要完成一些前置应用信息关联绑定和SDK初始化工作,就可以使用Dart层封装好的数据上报接口去上报异常了。可以看到,对于一个应用而言,接入数据上报服务的过程,总体上可以分为两个步骤: + + +初始化Bugly SDK; +使用数据上报接口。 + + +这两步对应着在Dart层需要封装的2个原生接口调用,即setup和postException,它们都是在方法通道上调用原生代码宿主提供的方法。考虑到数据上报是整个应用共享的能力,因此我们将数据上报类FlutterCrashPlugin的接口都封装成了单例: + +class FlutterCrashPlugin { + //初始化方法通道 + static const MethodChannel _channel = + const MethodChannel('flutter_crash_plugin'); + + static void setUp(appID) { + //使用app_id进行SDK注册 + _channel.invokeMethod("setUp",{'app_id':appID}); + } + static void postException(error, stack) { + //将异常和堆栈上报至Bugly + _channel.invokeMethod("postException",{'crash_message':error.toString(),'crash_detail':stack.toString()}); + } +} + + +Dart层是原生代码宿主的代理,可以看到这一层的接口设计还是比较简单的。接下来,我们分别去接管数据上报的Android和iOS平台上完成相应的实现。 + +iOS接口实现 + +考虑到iOS平台的数据上报配置工作相对较少,因此我们先用Xcode打开example下的iOS工程进行插件开发工作。需要注意的是,由于iOS子工程的运行依赖于Flutter工程编译构建产物,所以在打开iOS工程进行开发前,你需要确保整个工程代码至少build过一次,否则IDE会报错。 + + +备注:以下操作步骤参考Bugly异常上报iOS SDK接入指南。 + + +首先,我们需要在插件工程下的flutter_crash_plugin.podspec文件中引入Bugly SDK,即Bugly,这样我们就可以在原生工程中使用Bugly提供的数据上报功能了: + +Pod::Spec.new do |s| + ... + s.dependency 'Bugly' +end + + +然后,在原生接口FlutterCrashPlugin类中,依次初始化插件实例、绑定方法通道,并在方法通道中先后为setup与postException提供Bugly iOS SDK的实现版本: + +@implementation FlutterCrashPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + //注册方法通道 + FlutterMethodChannel* channel = [FlutterMethodChannel + methodChannelWithName:@"flutter_crash_plugin" + binaryMessenger:[registrar messenger]]; + //初始化插件实例,绑定方法通道 + FlutterCrashPlugin* instance = [[FlutterCrashPlugin alloc] init]; + //注册方法通道回调函数 + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if([@"setUp" isEqualToString:call.method]) { + //Bugly SDK初始化方法 + NSString *appID = call.arguments[@"app_id"]; + [Bugly startWithAppId:appID]; + } else if ([@"postException" isEqualToString:call.method]) { + //获取Bugly数据上报所需要的各个参数信息 + NSString *message = call.arguments[@"crash_message"]; + NSString *detail = call.arguments[@"crash_detail"]; + + NSArray *stack = [detail componentsSeparatedByString:@"\n"]; + //调用Bugly数据上报接口 + [Bugly reportExceptionWithCategory:4 name:message reason:stack[0] callStack:stack extraInfo:@{} terminateApp:NO]; + result(@0); + } + else { + //方法未实现 + result(FlutterMethodNotImplemented); + } +} + +@end + + +至此,在完成了Bugly iOS SDK的接口封装之后,FlutterCrashPlugin插件的iOS部分也就搞定了。接下来,我们去看看Android部分如何实现吧。 + +Android接口实现 + +与iOS类似,我们需要使用Android Studio打开example下的android工程进行插件开发工作。同样,在打开android工程前,你需要确保整个工程代码至少build过一次,否则IDE会报错。 + + +备注:以下操作步骤参考Bugly异常上报Android SDK接入指南 + + +首先,我们需要在插件工程下的build.gradle文件引入Bugly SDK,即crashreport与nativecrashreport,其中前者提供了Java和自定义异常的的数据上报能力,而后者则是JNI的异常上报封装 : + +dependencies { + implementation 'com.tencent.bugly:crashreport:latest.release' + implementation 'com.tencent.bugly:nativecrashreport:latest.release' +} + + +然后,在原生接口FlutterCrashPlugin类中,依次初始化插件实例、绑定方法通道,并在方法通道中先后为setup与postException提供Bugly Android SDK的实现版本: + +public class FlutterCrashPlugin implements MethodCallHandler { + //注册器,通常为MainActivity + public final Registrar registrar; + //注册插件 + public static void registerWith(Registrar registrar) { + //注册方法通道 + final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_crash_plugin"); + //初始化插件实例,绑定方法通道,并注册方法通道回调函数 + channel.setMethodCallHandler(new FlutterCrashPlugin(registrar)); + } + + private FlutterCrashPlugin(Registrar registrar) { + this.registrar = registrar; + } + + @Override + public void onMethodCall(MethodCall call, Result result) { + if(call.method.equals("setUp")) { + //Bugly SDK初始化方法 + String appID = call.argument("app_id"); + + CrashReport.initCrashReport(registrar.activity().getApplicationContext(), appID, true); + result.success(0); + } + else if(call.method.equals("postException")) { + //获取Bugly数据上报所需要的各个参数信息 + String message = call.argument("crash_message"); + String detail = call.argument("crash_detail"); + //调用Bugly数据上报接口 + CrashReport.postException(4,"Flutter Exception",message,detail,null); + result.success(0); + } + else { + result.notImplemented(); + } + } +} + + +在完成了Bugly Android接口的封装之后,由于Android系统的权限设置较细,考虑到Bugly还需要网络、日志读取等权限,因此我们还需要在插件工程的AndroidManifest.xml文件中,将这些权限信息显示地声明出来,完成对系统的注册: + + + + + + + + + + + + + + + +至此,在完成了极光Android SDK的接口封装和权限配置之后,FlutterCrashPlugin插件的Android部分也搞定了。 + +FlutterCrashPlugin插件为Flutter应用提供了数据上报的封装,不过要想Flutter工程能够真正地上报异常消息,我们还需要为Flutter工程关联Bugly的应用配置。 + +应用工程配置 + +在单独为Android/iOS应用进行数据上报配置之前,我们首先需要去Bugly的官方网站,为应用注册唯一标识符(即AppKey)。这里需要注意的是,在Bugly中,Android应用与iOS应用被视为不同的产品,所以我们需要分别注册: + + + +图3 Android应用Demo配置 + + + +图4 iOS应用Demo配置 + +在得到了AppKey之后,我们需要依次进行Android与iOS的配置工作。 + +iOS的配置工作相对简单,整个配置过程完全是应用与Bugly SDK的关联工作,而这些关联工作仅需要通过Dart层调用setUp接口,访问原生代码宿主所封装的Bugly API就可以完成,因此无需额外操作。 + +而Android的配置工作则相对繁琐些。由于涉及NDK和Android P网络安全的适配,我们还需要分别在build.gradle和AndroidManifest.xml文件进行相应的配置工作。 + +首先,由于Bugly SDK需要支持NDK,因此我们需要在App的build.gradle文件中为其增加NDK的架构支持: + +defaultConfig { + ndk { + // 设置支持的SO库架构 + abiFilters 'armeabi' , 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a' + } +} + + +然后,由于Android P默认限制http明文传输数据,因此我们需要为Bugly声明一项网络安全配置network_security_config.xml,允许其使用http传输数据,并在AndroidManifest.xml中新增同名网络安全配置: + +//res/xml/network_security_config.xml + + + + + + + android.bugly.qq.com + + + +//AndroidManifest/xml + + + + +至此,Flutter工程所需的原生配置工作和接口实现,就全部搞定了。 + +接下来,我们就可以在Flutter工程中的main.dart文件中,使用FlutterCrashPlugin插件来实现异常数据上报能力了。当然,我们首先还需要在pubspec.yaml文件中,将工程对它的依赖显示地声明出来: + +dependencies: + flutter_push_plugin: + git: + url: https://github.com/cyndibaby905/39_flutter_crash_plugin + + +在下面的代码中,我们在main函数里为应用的异常提供了统一的回调,并在回调函数内使用postException方法将异常上报至Bugly。 + +而在SDK的初始化方法里,由于Bugly视iOS和Android为两个独立的应用,因此我们判断了代码的运行宿主,分别使用两个不同的App ID对其进行了初始化工作。 + +此外,为了与你演示具体的异常拦截功能,我们还在两个按钮的点击事件处理中分别抛出了同步和异步两类异常: + +//上报数据至Bugly +Future _reportError(dynamic error, dynamic stackTrace) async { + FlutterCrashPlugin.postException(error, stackTrace); +} + +Future main() async { + //注册Flutter框架的异常回调 + FlutterError.onError = (FlutterErrorDetails details) async { + //转发至Zone的错误回调 + Zone.current.handleUncaughtError(details.exception, details.stack); + }; + //自定义错误提示页面 + ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){ + return Scaffold( + body: Center( + child: Text("Custom Error Widget"), + ) + ); + }; + //使用runZone方法将runApp的运行放置在Zone中,并提供统一的异常回调 + runZoned>(() async { + runApp(MyApp()); + }, onError: (error, stackTrace) async { + await _reportError(error, stackTrace); + }); +} + +class MyApp extends StatefulWidget { + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + //由于Bugly视iOS和Android为两个独立的应用,因此需要使用不同的App ID进行初始化 + if(Platform.isAndroid){ + FlutterCrashPlugin.setUp('43eed8b173'); + }else if(Platform.isIOS){ + FlutterCrashPlugin.setUp('088aebe0d5'); + } + super.initState(); + } + @override + Widget build(BuildContext context) { + return MaterialApp( + home: MyHomePage(), + ); + } +} + +class MyHomePage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Crashy'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RaisedButton( + child: Text('Dart exception'), + onPressed: () { + //触发同步异常 + throw StateError('This is a Dart exception.'); + }, + ), + RaisedButton( + child: Text('async Dart exception'), + onPressed: () { + //触发异步异常 + Future.delayed(Duration(seconds: 1)) + .then((e) => throw StateError('This is a Dart exception in Future.')); + }, + ) + ], + ), + ), + ); + } +} + + +运行这段代码,分别点击Dart exception按钮和async Dart exception按钮几次,可以看到我们的应用以及控制台并没有提示任何异常信息。 + + + +图5 异常拦截演示示例(iOS) + + + +图6 异常拦截演示示例(Android) + +然后,我们打开Bugly开发者后台,选择对应的App,切换到错误分析选项查看对应的面板信息。可以看到,Bugly已经成功接收到上报的异常上下文了。 + + + +图7 Bugly iOS错误分析上报数据查看 + + + +图8 Bugly Android错误分析上报数据查看 + +总结 + +好了,今天的分享就到这里,我们来小结下吧。 + +对于Flutter应用的异常捕获,可以分为单个异常捕获和多异常统一拦截两种情况。 + +其中,单异常捕获,使用Dart提供的同步异常try-catch,以及异步异常catchError机制即可实现。而对多个异常的统一拦截,可以细分为如下两种情况:一是App异常,我们可以将代码执行块放置到Zone中,通过onError回调进行统一处理;二是Framework异常,我们可以使用FlutterError.onError回调进行拦截。 + +在捕获到异常之后,我们需要上报异常信息,用于后续分析定位问题。考虑到Bugly的社区活跃度比较高,所以我以Bugly为例,与你讲述了以原生插件封装的形式,如何进行异常信息上报。 + +需要注意的是,Flutter提供的异常拦截只能拦截Dart层的异常,而无法拦截Engine层的异常。这是因为,Engine层的实现大部分是C++的代码,一旦出现异常,整个程序就直接Crash掉了。不过通常来说,这类异常出现的概率极低,一般都是Flutter底层的Bug,与我们在应用层的实现没太大关系,所以我们也无需过度担心。 + +如果我们想要追踪Engine层的异常(比如,给Flutter提Issue),则需要借助于原生系统提供的Crash监听机制。这,就是一个很繁琐的工作了。 + +幸运的是,我们使用的数据上报SDK Bugly就提供了这样的能力,可以自动收集原生代码的Crash。而在Bugly收集到对应的Crash之后,我们需要做的事情就是,将Flutter Engine层对应的符号表下载下来,使用Android提供的ndk-stack、iOS提供的symbolicatecrash或atos命令,对相应Crash堆栈进行解析,从而得出Engine层崩溃的具体代码。 + +关于这些步骤的详细说明,你可以参考Flutter官方文档。 + +我把今天分享涉及的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留下两道思考题吧。 + +第一个问题,请扩展_reportError和自定义错误提示页面的实现,在Debug环境下将异常数据打印至控制台,并保留原有系统错误提示页面实现。 + +//上报数据至Bugly +Future _reportError(dynamic error, dynamic stackTrace) async { + FlutterCrashPlugin.postException(error, stackTrace); +} + +//自定义错误提示页面 +ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){ + return Scaffold( + body: Center( + child: Text("Custom Error Widget"), + ) + ); +}; + + +第二个问题,并发Isolate的异常可以通过今天分享中介绍的捕获机制去拦截吗?如果不行,应该怎么做呢? + +//并发Isolate +doSth(msg) => throw ConcurrentModificationError('This is a Dart exception.'); + +//主Isolate +Isolate.spawn(doSth, "Hi"); + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/40衡量FlutterApp线上质量,我们需要关注这三个指标.md b/专栏/Flutter核心技术与实战/40衡量FlutterApp线上质量,我们需要关注这三个指标.md new file mode 100644 index 0000000..451fcec --- /dev/null +++ b/专栏/Flutter核心技术与实战/40衡量FlutterApp线上质量,我们需要关注这三个指标.md @@ -0,0 +1,251 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 40 衡量Flutter App线上质量,我们需要关注这三个指标 + 你好,我是陈航。 + +在上一篇文章中,我与你分享了如何捕获Flutter应用的未处理异常。所谓异常,指的是Dart代码在运行时意外发生的错误事件。对于单一异常来说,我们可以使用try-catch,或是catchError去处理;而如果我们想对异常进行集中的拦截治理,则需要使用Zone,并结合FlutterError进行统一管理。异常一旦被抓取,我们就可以利用第三方数据上报服务(比如Bugly),上报其上下文信息了。 + +这些线上异常的监控数据,对于开发者尽早发现线上隐患,确定问题根因至关重要。如果我们想进一步评估应用整体的稳定性的话,就需要把异常信息与页面的渲染关联起来。比如,页面渲染过程是否出现了异常,而导致功能不可用? + +而对于以“丝般顺滑”著称的Flutter应用而言,页面渲染的性能同样需要我们重点关注。比如,界面渲染是否出现会掉帧卡顿现象,或者页面加载是否会出现性能问题导致耗时过长?这些问题,虽不至于让应用完全不能使用,但也很容易引起用户对应用质量的质疑,甚至是反感。 + +通过上面的分析,可以看到,衡量线上Flutter应用整体质量的指标,可以分为以下3类: + + +页面异常率; +页面帧率; +页面加载时长。 + + +其中,页面异常率反应了页面的健康程度,页面帧率反应了视觉效果的顺滑程度,而页面加载时长则反应了整个渲染过程中点对点的延时情况。 + +这三项数据指标,是度量Flutter应用是否优秀的重要质量指标。通过梳理这些指标的统计口径,建立起Flutter应用的质量监控能力,这样一来我们不仅可以及早发现线上隐患,还可以确定质量基线,从而持续提升用户体验。 + +所以在今天的分享中,我会与你详细讲述这3项指标是如何采集的。 + +页面异常率 + +页面异常率指的是,页面渲染过程中出现异常的概率。它度量的是页面维度下功能不可用的情况,其统计公式为:页面异常率=异常发生次数/整体页面PV数。 + +在了解了页面异常率的统计口径之后,接下来我们分别来看一下这个公式中的分子与分母应该如何统计吧。 + +我们先来看看异常发生次数的统计方法。通过上一篇文章,我们已经知道了在Flutter中,未处理异常需要通过Zone与FlutterError去捕获。所以,如果我们想统计异常发生次数的话,依旧是利用这两个方法,只不过要在异常拦截的方法中,通过一个计数器进行累加,统一记录。 + +下面的例子演示了异常发生次数的具体统计方法。我们使用全局变量exceptionCount,在异常捕获的回调方法_reportError中持续地累加捕获到的异常次数: + +int exceptionCount = 0; +Future _reportError(dynamic error, dynamic stackTrace) async { + exceptionCount++; //累加异常次数 + FlutterCrashPlugin.postException(error, stackTrace); +} + +Future main() async { + FlutterError.onError = (FlutterErrorDetails details) async { + //将异常转发至Zone + Zone.current.handleUncaughtError(details.exception, details.stack); + }; + + runZoned>(() async { + runApp(MyApp()); + }, onError: (error, stackTrace) async { + //拦截异常 + await _reportError(error, stackTrace); + }); +} + + +接下来,我们再看看整体页面PV数如何统计吧。整体页面PV数,其实就是页面的打开次数。通过第21篇文章“路由与导航,Flutter是这样实现页面切换的”,我们已经知道了Flutter页面的切换需要经过Navigator来实现,所以页面切换状态也需要通过Navigator才能感知到。 + +与注册页面路由类似的,在MaterialApp中,我们可以通过NavigatorObservers属性,去监听页面的打开与关闭。下面的例子演示了NavigatorObserver的具体用法。在下面的代码中,我们定义了一个继承自NavigatorObserver的观察者,并在其didPush方法中,去统计页面的打开行为: + +int totalPV = 0; +//导航监听器 +class MyObserver extends NavigatorObserver{ + @override + void didPush(Route route, Route previousRoute) { + super.didPush(route, previousRoute); + totalPV++;//累加PV + } +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + //设置路由监听 + navigatorObservers: [ + MyObserver(), + ], + home: HomePage(), + ); + } +} + + +现在,我们已经收集到了异常发生次数和整体页面PV数这两个参数,接下来我们就可以计算出页面异常率了: + +double pageException() { + if(totalPV == 0) return 0; + return exceptionCount/totalPV; +} + + +可以看到,页面异常率的计算还是相对比较简单的。 + +页面帧率 + +页面帧率,即FPS,是图像领域中的定义,指的是画面每秒传输帧数。由于人眼的视觉暂留特质,当所见到的画面传输帧数高于一定数量的时候,就会认为是连贯性的视觉效果。因此,对于动态页面而言,每秒钟展示的帧数越多,画面就越流畅。 + +由此我们可以得出,FPS的计算口径为单位时间内渲染的帧总数。在移动设备中,FPS的推荐数值通常是60Hz,即每秒刷新页面60次。 + +为什么是60Hz,而不是更高或更低的值呢?这是因为显示过程,是由VSync信号周期性驱动的,而VSync信号的周期就是每秒60次,这也是FPS的上限。 + +CPU与GPU在接收到VSync信号后,就会计算图形图像,准备渲染内容,并将其提交到帧缓冲区,等待下一次VSync信号到来时显示到屏幕上。如果在一个VSync时间内,CPU或者GPU没有完成内容提交,这一帧就会被丢弃,等待下一次机会再显示,而这时页面会保留之前的内容不变,造成界面卡顿。因此,FPS低于60Hz时就会出现掉帧现象,而如果低于45Hz则会有比较严重的卡顿现象。 + +为方便开发者统计FPS,Flutter在全局window对象上提供了帧回调机制。我们可以在window对象上注册onReportTimings方法,将最近绘制帧耗费的时间(即FrameTiming),以回调的形式告诉我们。有了每一帧的绘制时间后,我们就可以计算FPS了。 + +需要注意的是,onReportTimings方法只有在有帧被绘制时才有数据回调,如果用户没有和App发生交互,界面状态没有变化时,是不会产生新的帧的。考虑到单个帧的绘制时间差异较大,逐帧计算可能会产生数据跳跃,所以为了让FPS的计算更加平滑,我们需要保留最近25个FrameTiming用于求和计算。 + +而另一方面,对于FPS的计算,我们并不能孤立地只考虑帧绘制时间,而应该结合VSync信号的周期,即1/60秒(即16.67毫秒)来综合评估。 + +由于帧的渲染是依靠VSync信号驱动的,如果帧绘制的时间没有超过16.67毫秒,我们也需要把它当成16.67毫秒来算,因为绘制完成的帧必须要等到下一次VSync信号来了之后才能渲染。而如果帧绘制时间超过了16.67毫秒,则会占用后续的VSync信号周期,从而打乱后续的绘制次序,产生卡顿现象。这里有两种情况: + + +如果帧绘制时间正好是16.67的整数倍,比如50,则代表它花费了3个VSync信号周期,即本来可以绘制3帧,但实际上只绘制了1帧; +如果帧绘制时间不是16.67的整数倍,比如51,那么它花费的VSync信号周期应该向上取整,即4个,这意味着本来可以绘制4帧,实际上只绘制了1帧。 + + +所以我们的FPS计算公式最终确定为:FPS=60*实际渲染的帧数/本来应该在这个时间内渲染完成的帧数。 + +下面的示例演示了如何通过onReportTimings回调函数实现FPS的计算。在下面的代码中,我们定义了一个容量为25的列表,用于存储最近的帧绘制耗时FrameTiming。在FPS的计算函数中,我们将列表中每帧绘制时间与VSync周期frameInterval进行比较,得出本来应该绘制的帧数,最后两者相除就得到了FPS指标。 + +需要注意的是,Android Studio提供的Flutter插件里展示的FPS信息,其实也来自于onReportTimings回调,所以我们在注册回调时需要保留原始回调引用,否则插件就读不到FPS信息了。 + +import 'dart:ui'; + +var orginalCallback; + +void main() { + runApp(MyApp()); + //设置帧回调函数并保存原始帧回调函数 + orginalCallback = window.onReportTimings; + window.onReportTimings = onReportTimings; +} + +//仅缓存最近25帧绘制耗时 +const maxframes = 25; +final lastFrames = List(); +//基准VSync信号周期 +const frameInterval = const Duration(microseconds: Duration.microsecondsPerSecond ~/ 60); + +void onReportTimings(List timings) { + lastFrames.addAll(timings); + //仅保留25帧 + if(lastFrames.length > maxframes) { + lastFrames.removeRange(0, lastFrames.length - maxframes); + } + //如果有原始帧回调函数,则执行 + if (orginalCallback != null) { + orginalCallback(timings); + } +} + +double get fps { + int sum = 0; + for (FrameTiming timing in lastFrames) { + //计算渲染耗时 + int duration = timing.timestampInMicroseconds(FramePhase.rasterFinish) - timing.timestampInMicroseconds(FramePhase.buildStart); + //判断耗时是否在Vsync信号周期内 + if(duration < frameInterval.inMicroseconds) { + sum += 1; + } else { + //有丢帧,向上取整 + int count = (duration/frameInterval.inMicroseconds).ceil(); + sum += count; + } + } + return lastFrames.length/sum * 60; +} + + +运行这段代码,可以看到,我们统计的FPS指标和Flutter插件展示的FPS走势是一致的。 + + + +图1 FPS指标走势 + +页面加载时长 + +页面加载时长,指的是页面从创建到可见的时间。它反应的是代码中创建页面视图是否存在过度绘制,或者绘制不合理导致创建视图时间过长的情况。 + +从定义可以看出,页面加载时长的统计口径为页面可见的时间-页面创建的时间。获取页面创建的时间比较容易,我们只需要在页面的初始化函数里记录时间即可。那么,页面可见的时间应该如何统计呢? + +在第11篇文章“提到生命周期,我们是在说什么?”中,我在介绍Widget的生命周期时,曾向你介绍过Flutter的帧回调机制。WidgetsBinding提供了单次Frame回调addPostFrameCallback方法,它会在当前Frame绘制完成之后进行回调,并且只会回调一次。一旦监听到Frame绘制完成回调后,我们就可以确认页面已经被渲染出来了,因此我们可以借助这个方法去获取页面可见的时间。 + +下面的例子演示了如何通过帧回调机制获取页面加载时长。在下面的代码中,我们在页面MyPage的初始化方法中记录了页面的创建时间startTime,然后在页面状态的初始化方法中,通过addPostFrameCallback注册了单次帧绘制回调,并在回调函数中记录了页面的渲染完成时间endTime。将这两个时间做减法,我们就得到了MyPage的页面加载时长: + +class MyHomePage extends StatefulWidget { + int startTime; + int endTime; + MyHomePage({Key key}) : super(key: key) { + //页面初始化时记录启动时间 + startTime = DateTime.now().millisecondsSinceEpoch; + } + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + @override + void initState() { + super.initState(); + //通过帧绘制回调获取渲染完成时间 + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.endTime = DateTime.now().millisecondsSinceEpoch; + int timeSpend = widget.endTime - widget.startTime; + print("Page render time:${timeSpend} ms"); + }); + } + ... +} + + +试着运行一下代码,观察命令行输出: + +flutter: Page render time:548 ms + + +可以看到,通过单次帧绘制回调统计得出的页面加载时间为548毫秒。 + +至此,我们就已经得到了页面异常率、页面帧率和页面加载时长这3个指标了。 + +总结 + +好了,今天的分享就到这里,我们来总结下主要内容吧。 + +今天我们一起学习了衡量Flutter应用线上质量的3个指标,即页面异常率、页面帧率和页面加载时长,以及分别对应的数据采集方式。 + +其中,页面异常率表示页面渲染过程中的稳定性,可以通过集中捕获未处理异常,结合NavigatorObservers观察页面PV,计算得出页面维度下功能不可用的概率。 + +页面帧率则表示了页面的流畅情况,可以利用Flutter提供的帧绘制耗时回调onReportTimings,以加权的形式计算出本应该绘制的帧数,得到更为准确的FPS。 + +而页面加载时长,反应的是渲染过程的延时情况。我们可以借助于单次帧回调机制,来获取页面渲染完成时间,从而得到整体页面的加载时长。 + +通过这3个数据指标统计方法,我们再去评估Flutter应用的性能时,就有一个具体的数字化标准了。而有了数据之后,我们不仅可以及早发现问题隐患,准确定位及修复问题,还可以根据它们去评估应用的健康程度和页面的渲染性能,从而确定后续的优化方向。 + +我把今天分享涉及的知识点打包到了GitHub中,你可以下载下来,反复运行几次,加深理解与记忆。 + +思考题 + +最后,我给你留一道思考题吧。 + +如果页面的渲染需要依赖单个或多个网络接口数据,这时的页面加载时长应该如何统计呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/41组件化和平台化,该如何组织合理稳定的Flutter工程结构?.md b/专栏/Flutter核心技术与实战/41组件化和平台化,该如何组织合理稳定的Flutter工程结构?.md new file mode 100644 index 0000000..e20b1b4 --- /dev/null +++ b/专栏/Flutter核心技术与实战/41组件化和平台化,该如何组织合理稳定的Flutter工程结构?.md @@ -0,0 +1,124 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 41 组件化和平台化,该如何组织合理稳定的Flutter工程结构? + 你好,我是陈航。今天,我们来聊一聊Flutter应用的工程架构这个话题。 + +在软件开发中,我们不仅要在代码实现中遵守常见的设计模式,更需要在架构设计中遵从基本的设计原则。而在这其中,DRY(即Don’t Repeat Yourself)原则可以算是最重要的一个。 + +通俗来讲,DRY原则就是“不要重复”。这是一个很朴素的概念,因为即使是最初级的开发者,在写了一段时间代码后,也会不自觉地把一些常用的重复代码抽取出来,放到公用的函数、类或是独立的组件库中,从而实现代码复用。 + +在软件开发中,我们通常从架构设计中就要考虑如何去管理重复性(即代码复用),即如何将功能进行分治,将大问题分解为多个较为独立的小问题。而在这其中,组件化和平台化就是客户端开发中最流行的分治手段。 + +所以今天,我们就一起来学习一下这两类分治复用方案的中心思想,这样我们在设计Flutter应用的架构时也就能做到有章可循了。 + +组件化 + +组件化又叫模块化,即基于可重用的目的,将一个大型软件系统(App)按照关注点分离的方式,拆分成多个独立的组件或模块。每个独立的组件都是一个单独的系统,可以单独维护、升级甚至直接替换,也可以依赖于别的独立组件,只要组件提供的功能不发生变化,就不会影响其他组件和软件系统的整体功能。 + + + +图1 组件化示意图 + +可以看到,组件化的中心思想是将独立的功能进行拆分,而在拆分粒度上,组件化的约束则较为松散。一个独立的组件可以是一个软件包(Package)、页面、UI控件,甚至可能是封装了一些函数的模块。 + +组件的粒度可大可小,那我们如何才能做好组件的封装重用呢?哪些代码应该被放到一个组件中?这里有一些基本原则,包括单一性原则、抽象化原则、稳定性原则和自完备性原则。 + +接下来,我们先看看这些原则具体是什么意思。 + +单一性原则指的是,每个组件仅提供一个功能。分而治之是组件化的中心思想,每个组件都有自己固定的职责和清晰的边界,专注地做一件事儿,这样这个组件才能良性发展。 + +一个反例是Common或Util组件,这类组件往往是因为在开发中出现了定义不明确、归属边界不清晰的代码:“哎呀,这段代码放哪儿好像都不合适,那就放Common(Util)吧”。久而久之,这类组件就变成了无人问津的垃圾堆。所以,再遇到不知道该放哪儿的代码时,就需要重新思考组件的设计和职责了。 + +抽象化原则指的是,组件提供的功能抽象应该尽量稳定,具有高复用度。而稳定的直观表现就是对外暴露的接口很少发生变化,要做到这一点,需要我们提升对功能的抽象总结能力,在组件封装时做好功能抽象和接口设计,将所有可能发生变化的因子都在组件内部做好适配,不要暴露给它的调用方。 + +稳定性原则指的是,不要让稳定的组件依赖不稳定的组件。比如组件1依赖了组件5,如果组件1很稳定,但是组件5经常变化,那么组件1也就会变得不稳定了,需要经常适配。如果组件5里确实有组件1不可或缺的代码,我们可以考虑把这段代码拆出来单独做成一个新的组件X,或是直接在组件1中拷贝一份依赖的代码。 + +自完备性,即组件需要尽可能地做到自给自足,尽量减少对其他底层组件的依赖,达到代码可复用的目的。比如,组件1只是依赖某个大组件5中的某个方法,这时更好的处理方法是,剥离掉组件1对组件5的依赖,直接把这个方法拷贝到组件1中。这样一来组件1就能够更好地应对后续的外部变更了。 + +在理解了组件化的基本原则之后,我们再来看看组件化的具体实施步骤,即剥离基础功能、抽象业务模块和最小化服务能力。 + +首先,我们需要剥离应用中与业务无关的基础功能,比如网络请求、组件中间件、第三方库封装、UI组件等,将它们封装为独立的基础库;然后,我们在项目里用pub进行管理。如果是第三方库,考虑到后续的维护适配成本,我们最好再封装一层,使项目不直接依赖外部代码,方便后续更新或替换。 + +基础功能已经封装成了定义更清晰的组件,接下来我们就可以按照业务维度,比如首页、详情页、搜索页等,去拆分独立的模块了。拆分的粒度可以先粗后细,只要能将大体划分清晰的业务组件进行拆分,后续就可以通过分布迭代、局部微调,最终实现整个业务项目的组件化。 + +在业务组件和基础组件都完成拆分封装后,应用的组件化架构就基本成型了,最后就可以按照刚才我们说的4个原则,去修正各个组件向下的依赖,以及最小化对外暴露的能力了。 + +平台化 + +从组件的定义可以看到,组件是个松散的广义概念,其规模取决于我们封装的功能维度大小,而各个组件之间的关系也仅靠依赖去维持。如果组件之间的依赖关系比较复杂,就会在一定程度上造成功能耦合现象。 + +如下所示的组件示意图中,组件2和组件3同时被多个业务组件和基础功能组件直接引用,甚至组件2和组件5、组件3和组件4之间还存在着循环依赖的情况。一旦这些组件的内部实现和外部接口发生变化,整个App就会陷入不稳定的状态,即所谓牵一发而动全身。 + + + +图2 循环依赖现象 + +平台化是组件化的升级,即在组件化的基础上,对它们提供的功能进行分类,统一分层划分,增加依赖治理的概念。为了对这些功能单元在概念上进行更为统一的分类,我们按照四象限分析法,把应用程序的组件按照业务和UI分解为4个维度,来分析组件可以分为哪几类。 + + + +图3 组件划分原则 + +可以看出,经过业务与UI的分解之后,这些组件可以分为4类: + + +具备UI属性的独立业务模块; +不具备UI属性的基础业务功能; +不具备业务属性的UI控件 +不具备业务属性的基础功能 + + +按照自身定义,这4类组件其实隐含着分层依赖的关系。比如,处于业务模块中的首页,依赖位于基础业务模块中的账号功能;再比如,位于UI控件模块中的轮播卡片,依赖位于基础功能模块中的存储管理等功能。我们将它们按照依赖的先后顺序从上到下进行划分,就是一个完整的App了。 + + + +图4 组件化分层 + +可以看到,平台化与组件化最大的差异在于增加了分层的概念,每一层的功能均基于同层和下层的功能之上,这使得各个组件之间既保持了独立性,同时也具有一定的弹性,在不越界的情况下按照功能划分各司其职。 + +与组件化更关注组件的独立性相比,平台化更关注的是组件之间关系的合理性,而这也是在设计平台化架构时需要重点考虑的单向依赖原则。 + +所谓单向依赖原则,指的是组件依赖的顺序应该按照应用架构的层数从上到下依赖,不要出现下层模块依赖上层模块这样循环依赖的现象。这样可以最大限度地避免复杂的耦合,减少组件化时的困难。如果我们每个组件都只是单向依赖其他组件,各个组件之间的关系都是清晰的,代码解耦也就会变得非常轻松了。 + +平台化强调依赖的顺序性,除了不允许出现下层组件依赖上层组件的情况,跨层组件和同层组件之间的依赖关系也应当严格控制,因为这样的依赖关系往往会带来架构设计上的混乱。 + +如果下层组件确实需要调用上层组件的代码怎么办? + +这时,我们可以采用增加中间层的方式,比如Event Bus、Provider或Router,以中间层转发的形式实现信息同步。比如,位于第4层的网络引擎中,会针对特定的错误码跳转到位于第1层的统一错误页,这时我们就可以利用Router提供的命名路由跳转,在不感知错误页的实现情况下来完成。又比如,位于第2层的账号组件中,会在用户登入登出时主动刷新位于第1层的首页和我的页面,这时我们就可以利用Event Bus来触发账号切换事件,在不需要获取页面实例的情况下通知它们更新界面。关于这部分内容,你可以参考第20和21篇文章中的相关内容,这里就不再赘述了。 + +平台化架构是目前应用最广的软件架构设计,其核心在于如何将离散的组件依照单向依赖的原则进行分层。而关于具体的分层逻辑,除了我们上面介绍的业务和UI四象限法则之外,你也可以使用其他的划分策略,只要整体结构层次清晰明确,不存在难以确定归属的组件就可以了。 + +比如,Flutter就采用Embedder(操作系统适配层)、Engine(渲染引擎及Dart VM层)和Framework(UI SDK层)整体三层的划分。可以看到,Flutter框架每一层的组件定义都有着明确的边界,其向上提供的功能和向下依赖的能力也非常明确。 + + + +图5 Flutter框架架构 + +备注:此图引自Flutter System Overview + +总结 + +好了,今天的分享就到这里,我们总结一下主要内容吧。 + +组件化和平台化都是软件开发中流行的分治手段,能够将App内的功能拆分成多个独立的组件或模块。 + +其中,组件化更关注如何保持组件的独立性,只要拆分的功能独立即可,约束较为松散,在中大型App中容易造成一定程度的功能耦合现象。而平台化则更强调组件之间关系的合理性,增加了分层的概念,使得组件之间既有边界,也有一定的弹性。只要满足单向依赖原则,各个组件之间的关系都是清晰的。 + +分治是一种与技术无关的架构思想,有利于降低工程的复杂性,从而提高App的可扩展和可维护性。今天这篇文章,我重点与你分享的是组件化与平台化这两种架构设计的思路,并没有讲解它们的具体实现。而关于组件化与平台化的实现细节,网络上已经有很多文章了,你可以在网上自行搜索了解。如果你还有关于组件化和平台化的其他问题,那就在评论区中给我留言吧。 + +其实,你也可以琢磨出,今天这篇文章的目的是带你领会App架构设计的核心思想。因为,理解思想之后剩下的就是去实践了,当你需要设计App架构时再回忆起这些内容,或是翻出这篇文章一定会事半功倍。 + +思考题 + +最后,我给你留一道思考题吧。 + +在App架构设计中,你会采用何种方式去管理涉及资源类的依赖呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/42如何构建高效的FlutterApp打包发布环境?.md b/专栏/Flutter核心技术与实战/42如何构建高效的FlutterApp打包发布环境?.md new file mode 100644 index 0000000..14bc2da --- /dev/null +++ b/专栏/Flutter核心技术与实战/42如何构建高效的FlutterApp打包发布环境?.md @@ -0,0 +1,306 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 42 如何构建高效的Flutter App打包发布环境? + 你好,我是陈航。今天,我们来聊一聊Flutter应用的交付这个话题。 + +软件项目的交付是一个复杂的过程,任何原因都有可能导致交付过程失败。中小型研发团队经常遇到的一个现象是,App在开发测试时没有任何异常,但一到最后的打包构建交付时就问题频出。所以,每到新版本发布时,大家不仅要等候打包结果,还经常需要加班修复临时出现的问题。如果没有很好地线上应急策略,即使打包成功,交付完成后还是非常紧张。 + +可以看到,产品交付不仅是一个令工程师头疼的过程,还是一个高风险动作。其实,失败并不可怕,可怕的是每次失败的原因都不一样。所以,为了保障可靠交付,我们需要关注从源代码到发布的整个流程,提供一种可靠的发布支撑,确保App是以一种可重复的、自动化的方式构建出来的。同时,我们还应该将打包过程提前,将构建频率加快,因为这样不仅可以尽早发现问题,修复成本也会更低,并且能更好地保证代码变更能够顺利发布上线。 + +其实,这正是持续交付的思路。 + +所谓持续交付,指的是建立一套自动监测源代码变更,并自动实施构建、测试、打包和相关操作的流程链机制,以保证软件可以持续、稳定地保持在随时可以发布的状态。 持续交付可以让软件的构建、测试与发布变得更快、更频繁,更早地暴露问题和风险,降低软件开发的成本。 + +你可能会觉得,大型软件工程里才会用到持续交付。其实不然,通过运用一些免费的工具和平台,中小型项目也能够享受到开发任务自动化的便利。而Travis CI就是这类工具之中,市场份额最大的一个。所以接下来,我就以Travis CI为例,与你分享如何为Flutter工程引入持续交付的能力。 + +Travis CI + +Travis CI 是在线托管的持续交付服务,用Travis来进行持续交付,不需要自己搭服务器,在网页上点几下就好,非常方便。 + +Travis和GitHub是一对配合默契的工作伙伴,只要你在Travis上绑定了GitHub上的项目,后续任何代码的变更都会被Travis自动抓取。然后,Travis会提供一个运行环境,执行我们预先在配置文件中定义好的测试和构建步骤,并最终把这次变更产生的构建产物归档到GitHub Release上,如下所示: + + + +图1 Travis CI持续交付流程示意图 + +可以看到,通过Travis提供的持续构建交付能力,我们可以直接看到每次代码的更新的变更结果,而不需要累积到发布前再做打包构建。这样不仅可以更早地发现错误,定位问题也会更容易。 + +要想为项目提供持续交付的能力,我们首先需要在Travis上绑定GitHub。我们打开Travis官网,使用自己的GitHub账号授权登陆就可以了。登录完成后页面中会出现一个“Activate”按钮,点击按钮会跳回到GitHub中进行项目访问权限设置。我们保留默认的设置,点击“Approve&Install”即可。 + + + +图2 激活Github集成 + + + +图3 授权Travis读取项目变更记录 + +完成授权之后,页面会跳转到Travis。Travis主页上会列出GitHub上你的所有仓库,以及你所属于的组织,如下图所示: + + + +图4 完成Github项目绑定 + +完成项目绑定后,接下来就是为项目增加Travis配置文件了。配置的方法也很简单,只要在项目的根目录下放一个名为.travis.yml的文件就可以了。 + +.travis.yml是Travis的配置文件,指定了Travis应该如何应对代码变更。代码commit上去之后,一旦Travis检测到新的变更,Travis就会去查找这个文件,根据项目类型(language)确定执行环节,然后按照依赖安装(install)、构建命令(script)和发布(deploy)这三大步骤,依次执行里面的命令。一个Travis构建任务流程如下所示: + + + +图5 Travis工作流 + +可以看到,为了更精细地控制持续构建过程,Travis还为install、script和deploy提供了对应的钩子(before_install、before_script、after_failure、after_success、before_deploy、after_deploy、after_script),可以前置或后置地执行一些特殊操作。 + +如果你的项目比较简单,没有其他的第三方依赖,也不需要发布到GitHub Release上,只是想看看构建会不会失败,那么你可以省略配置文件中的install和deploy。 + +如何为项目引入Travis? + +可以看到,一个最简单的配置文件只需要提供两个字段,即language和script,就可以让Travis帮你自动构建了。下面的例子演示了如何为一个Dart命令行项目引入Travis。在下面的配置文件中,我们将language字段设置为Dart,并在script字段中,将dart_sample.dart定义为程序入口启动运行: + +#.travis.yml +language: dart +script: + - dart dart_sample.dart + + +将这个文件提交至项目中,我们就完成了Travis的配置工作。 + +Travis会在每次代码提交时自动运行配置文件中的命令,如果所有命令都返回0,就表示验证通过,完全没有问题,你的提交记录就会被标记上一个绿色的对勾。反之,如果命令运行过程中出现了异常,则表示验证失败,你的提交记录就会被标记上一个红色的叉,这时我们就要点击红勾进入Travis构建详情,去查看失败原因并尽快修复问题了。 + + + +图6 代码变更验证 + +可以看到,为一个工程引入自动化任务的能力,只需要提炼出能够让工程自动化运行需要的命令就可以了。 + +在第38篇文章中,我与你介绍了Flutter工程运行自动化测试用例的命令,即flutter test,所以如果我们要为一个Flutter工程配置自动化测试任务,直接把这个命令放置在script字段就可以了。 + +但需要注意的是,Travis并没有内置Flutter运行环境,所以我们还需要在install字段中,为自动化任务安装Flutter SDK。下面的例子演示了如何为一个Flutter工程配置自动化测试能力。在下面的配置文件中,我们将os字段设置为osx,在install字段中clone了Flutter SDK,并将Flutter命令设置为环境变量。最后,我们在script字段中加上flutter test命令,就完成了配置工作: + +os: + - osx +install: + - git clone https://github.com/flutter/flutter.git + - export PATH="$PATH:`pwd`/flutter/bin" +script: + - flutter doctor && flutter test + + +其实,为Flutter工程的代码变更引入自动化测试能力相对比较容易,但考虑到Flutter的跨平台特性,要想在不同平台上验证工程自动化构建的能力(即iOS平台构建出ipa包、Android平台构建出apk包)又该如何处理呢? + +我们都知道Flutter打包构建的命令是flutter build,所以同样的,我们只需要把构建iOS的命令和构建Android的命令放到script字段里就可以了。但考虑到这两条构建命令执行时间相对较长,所以我们可以利用Travis提供的并发任务选项matrix,来把iOS和Android的构建拆开,分别部署在独立的机器上执行。 + +下面的例子演示了如何使用matrix分拆构建任务。在下面的代码中,我们定义了两个并发任务,即运行在Linux上的Android构建任务执行flutter build apk,和运行在OS X上的iOS构建任务flutter build ios。 + +考虑到不同平台的构建任务需要提前准备运行环境,比如Android构建任务需要设置JDK、安装Android SDK和构建工具、接受相应的开发者协议,而iOS构建任务则需要设置Xcode版本,因此我们分别在这两个并发任务中提供对应的配置选项。 + +最后需要注意的是,由于这两个任务都需要依赖Flutter环境,所以install字段并不需要拆到各自任务中进行重复设置: + +matrix: + include: + #声明Android运行环境 + - os: linux + language: android + dist: trusty + licenses: + - 'android-sdk-preview-license-.+' + - 'android-sdk-license-.+' + - 'google-gdk-license-.+' + #声明需要安装的Android组件 + android: + components: + - tools + - platform-tools + - build-tools-28.0.3 + - android-28 + - sys-img-armeabi-v7a-google_apis-28 + - extra-android-m2repository + - extra-google-m2repository + - extra-google-android-support + jdk: oraclejdk8 + sudo: false + addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - libstdc++6 + - fonts-droid + #确保sdkmanager是最新的 + before_script: + - yes | sdkmanager --update + script: + - yes | flutter doctor --android-licenses + - flutter doctor && flutter -v build apk + + #声明iOS的运行环境 + - os: osx + language: objective-c + osx_image: xcode10.2 + script: + - flutter doctor && flutter -v build ios --no-codesign +install: + - git clone https://github.com/flutter/flutter.git + - export PATH="$PATH:`pwd`/flutter/bin" + + +如何将打包好的二进制文件自动发布出来? + +在这个案例中,我们构建任务的命令是打包,那打包好的二进制文件可以自动发布出来吗? + +答案是肯定的。我们只需要为这两个构建任务增加deploy字段,设置skip_cleanup字段告诉Travis在构建完成后不要清除编译产物,然后通过file字段把要发布的文件指定出来,最后就可以通过GitHub提供的API token上传到项目主页了。 + +下面的示例演示了deploy字段的具体用法,在下面的代码中,我们获取到了script字段构建出的app-release.apk,并通过file字段将其指定为待发布的文件。考虑到并不是每次构建都需要自动发布,所以我们在下面的配置中,增加了on选项,告诉Travis仅在对应的代码更新有关联tag时,才自动发布一个release版本: + +... +#声明构建需要执行的命令 +script: + - yes | flutter doctor --android-licenses + - flutter doctor && flutter -v build apk +#声明部署的策略,即上传apk至github release +deploy: + provider: releases + api_key: xxxxx + file: + - build/app/outputs/apk/release/app-release.apk + skip_cleanup: true + on: + tags: true +... + + +需要注意的是,由于我们的项目是开源库,因此GitHub的API token不能明文放到配置文件中,需要在Travis上配置一个API token的环境变量,然后把这个环境变量设置到配置文件中。 + +我们先打开GitHub,点击页面右上角的个人头像进入Settings,随后点击Developer Settings进入开发者设置。 + + + +图7 进入开发者设置 + +在开发者设置页面中,我们点击左下角的Personal access tokens选项,生成访问token。token设置页面提供了比较丰富的访问权限控制,比如仓库限制、用户限制、读写限制等,这里我们选择只访问公共的仓库,填好token名称cd_demo,点击确认之后,GitHub会将token的内容展示在页面上。 + + + +图8 生成访问token + +需要注意的是,这个token 你只会在GitHub上看到一次,页面关了就再也找不到了,所以我们先把这个token复制下来。 + + + +图9 访问token界面 + +接下来,我们打开Travis主页,找到我们希望配置自动发布的项目,然后点击右上角的More options选择Settings打开项目配置页面。 + + + +图10 打开Travis项目设置 + +在Environment Variable里,把刚刚复制的token改名为GITHUB_TOKEN,加到环境变量即可。 + + + +图11 加入Travis环境变量 + +最后,我们只要把配置文件中的api_key替换成${GITHUB_TOKEN}就可以了。 + +... +deploy: + api_key: ${GITHUB_TOKEN} +... + + +这个案例介绍的是Android的构建产物apk发布。而对于iOS而言,我们还需要对其构建产物app稍作加工,让其变成更通用的ipa格式之后才能发布。这里我们就需要用到deploy的钩子before_deploy字段了,这个字段能够在正式发布前,执行一些特定的产物加工工作。 + +下面的例子演示了如何通过before_deploy字段加工构建产物。由于ipa格式是在app格式之上做的一层包装,所以我们把app文件拷贝到Payload后再做压缩,就完成了发布前的准备工作,接下来就可以在deploy阶段指定要发布的文件,正式进入发布环节了: + +... +#对发布前的构建产物进行预处理,打包成ipa +before_deploy: + - mkdir app && mkdir app/Payload + - cp -r build/ios/iphoneos/Runner.app app/Payload + - pushd app && zip -r -m app.ipa Payload && popd +#将ipa上传至github release +deploy: + provider: releases + api_key: ${GITHUB_TOKEN} + file: + - app/app.ipa + skip_cleanup: true + on: + tags: true +... + + +将更新后的配置文件提交至GitHub,随后打一个tag。等待Travis构建完毕后可以看到,我们的工程已经具备自动发布构建产物的能力了。 + + + +图12 Flutter App发布构建产物 + +如何为Flutter Module工程引入自动发布能力? + +这个例子介绍的是传统的Flutter App工程(即纯Flutter工程),如果我们想为Flutter Module工程(即混合开发的Flutter工程)引入自动发布能力又该如何设置呢? + +其实也并不复杂。Module工程的Android构建产物是aar,iOS构建产物是Framework。Android产物的自动发布比较简单,我们直接复用apk的发布,把file文件指定为aar文件即可;iOS的产物自动发布稍繁琐一些,需要将Framework做一些简单的加工,将它们转换成Pod格式。 + +下面的例子演示了Flutter Module的iOS产物是如何实现自动发布的。由于Pod格式本身只是在App.Framework和Flutter.Framework这两个文件的基础上做的封装,所以我们只需要把它们拷贝到统一的目录FlutterEngine下,并将声明了组件定义的FlutterEngine.podspec文件放置在最外层,最后统一压缩成zip格式即可。 + +... +#对构建产物进行预处理,压缩成zip格式的组件 +before_deploy: + - mkdir .ios/Outputs && mkdir .ios/Outputs/FlutterEngine + - cp FlutterEngine.podspec .ios/Outputs/ + - cp -r .ios/Flutter/App.framework/ .ios/Outputs/FlutterEngine/App.framework/ + - cp -r .ios/Flutter/engine/Flutter.framework/ .ios/Outputs/FlutterEngine/Flutter.framework/ + - pushd .ios/Outputs && zip -r FlutterEngine.zip ./ && popd +deploy: + provider: releases + api_key: ${GITHUB_TOKEN} + file: + - .ios/Outputs/FlutterEngine.zip + skip_cleanup: true + on: + tags: true +... + + +将这段代码提交后可以看到,Flutter Module工程也可以自动的发布原生组件了。 + + + +图13 Flutter Module工程发布构建产物 + +通过这些例子我们可以看到,任务配置的关键在于提炼出项目自动化运行需要的命令集合,并确认它们的执行顺序。只要把这些命令集合按照install、script和deploy三个阶段安置好,接下来的事情就交给Travis去完成,我们安心享受持续交付带来的便利就可以了。 + +总结 + +俗话说,“90%的故障都是由变更引起的”,这凸显了持续交付对于发布稳定性保障的价值。通过建立持续交付流程链机制,我们可以将代码变更与自动化手段关联起来,让测试和发布变得更快、更频繁,不仅可以提早暴露风险,还能让软件可以持续稳定地保持在随时可发布的状态。 + +在今天的分享中,我与你介绍了如何通过Travis CI,为我们的项目引入持续交付能力。Travis的自动化任务的工作流依靠.travis.yml配置文件驱动,我们可以在确认好构建任务需要的命令集合后,在这个配置文件中依照install、script和deploy这3个步骤拆解执行过程。完成项目的配置之后,一旦Travis检测到代码变更,就可以自动执行任务了。 + +简单清晰的发布流程是软件可靠性的前提。如果我们同时发布了100个代码变更,导致App性能恶化了,我们可能需要花费大量时间和精力,去定位究竟是哪些变更影响了App性能,以及它们是如何影响的。而如果以持续交付的方式发布App,我们能够以更小的粒度去测量和理解代码变更带来的影响,是改善还是退化,从而可以更早地找到问题,更有信心进行更快的发布。 + +需要注意的是,在今天的示例分析中,我们构建的是一个未签名的ipa文件,这意味着我们需要先完成签名之后,才能在真实的iOS设备上运行,或者发布到App Store。 + +iOS的代码签名涉及私钥和多重证书的校验,以及对应的加解密步骤,是一个相对繁琐的过程。如果我们希望在Travis上部署自动化签名操作,需要导出发布证书、私钥和描述文件,并提前将这些文件打包成一个压缩包后进行加密,上传至仓库。 + +然后,我们还需要在before_install时,将这个压缩包进行解密,并把证书导到Travis运行环境的钥匙串中,这样构建脚本就可以使用临时钥匙串对二进制文件进行签名了。完整的配置,你可以参考手机内侧服务厂商蒲公英提供的集成文档了解进一步的细节。 + +如果你不希望将发布证书、私钥暴露给Travis,也可以把未签名的ipa包下载下来,解压后通过codesign命令,分别对App.Framework、Flutter.Framework以及Runner进行重签名操作,然后重新压缩成ipa包即可。这篇文章介绍了详细的操作步骤,这里我们也不再赘述了。 + +我把今天分享涉及的Travis配置上传到了GitHub,你可以把这几个项目Dart_Sample、Module_Page、Crashy_Demo下载下来,观察它们的配置文件,并在Travis网站上查看对应的构建过程,从而加深理解与记忆。 + +思考题 + +最后,我给你留一道思考题吧。 + +在Travis配置文件中,如何选用特定的Flutter SDK版本(比如v1.5.4-hotfix.2)呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/43如何构建自己的Flutter混合开发框架(一)?.md b/专栏/Flutter核心技术与实战/43如何构建自己的Flutter混合开发框架(一)?.md new file mode 100644 index 0000000..411b2f2 --- /dev/null +++ b/专栏/Flutter核心技术与实战/43如何构建自己的Flutter混合开发框架(一)?.md @@ -0,0 +1,91 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 43 如何构建自己的Flutter混合开发框架(一)? + 你好,我是陈航。在本次课程的最后一个主题里,我来和你聊聊如何设计自己的Flutter混合开发框架。 + +所谓混合开发,是指在App的整体架构继续使用原生技术栈的基础上,将Flutter运行环境嵌入到原生App工程中:由原生开发人员为Flutter运行提供宿主容器及基础能力支撑,而Flutter开发人员则负责应用层业务及App内大部分渲染工作。 + +这种开发模式的好处十分明显。对于工程师而言,跨平台的Flutter框架减少了对底层环境的依赖,使用完整的技术栈和工具链隔离了各个终端系统的差异,无论是Android、iOS甚至是前端工程师,都可以使用统一而标准化的能力进行业务开发,从而扩充了技能栈。而对于企业而言,这种方式不仅具备了原生App良好的用户体验,以及丰富的底层能力,还同时拥有了跨平台技术开发低成本和多端体验一致性的优势,直接节省研发资源。 + +那么,在原生工程中引入Flutter混合开发能力,我们应该如何设计工程架构,原生开发与Flutter开发的工作模式又是怎样的呢? + +接下来,在今天的分享中,我会着重为你介绍这两个主题设计思路和建设方向;而在下一次分享中,我则会通过一个实际的案例,与你详细说明在业务落地中,我们需要重点考虑哪些技术细节,这样你在为自己的原生工程中设计混合开发框架时也就有迹可循了。 + +混合开发架构 + +在第41篇文章中,我与你介绍了软件功能分治的两种手段,即组件化和平台化,以及如何在满足单向依赖原则的前提下,以分层的形式将软件功能进行分类聚合的方法。这些设计思想,能够让我们在设计软件系统架构时,降低整体工程的复杂性,提高App的可扩展性和可维护性。 + +与纯Flutter工程能够以自治的方式去分拆软件功能、管理工程依赖不同,Flutter混合工程的功能分治需要原生工程与Flutter工程一起配合完成,即:在Flutter模块的视角看来,一部分与渲染相关的基础能力完全由Flutter代码实现,而另一部分涉及操作系统底层、业务通用能力部分,以及整体应用架构支撑,则需要借助于原生工程给予支持。 + +在第41篇文章中,我们通过四象限分析法,把纯Flutter应用按照业务和UI分解成4类。同样的,混合工程的功能单元也可以按照这个分治逻辑分为4个维度,即不具备业务属性的原生基础功能、不具备业务属性的原生UI控件、不具备UI属性的原生基础业务功能和带UI属性的独立业务模块。 + + + +图1 四象限分析法 + +从图中可以看到,对于前3个维度(即原生UI控件、原生基础功能、原生基础业务功能)的定义,纯Flutter工程与混合工程并无区别,只不过实现方式由Flutter变成了原生;对于第四个维度(即独立业务模块)的功能归属,考虑到业务模块的最小单元是页面,而Flutter的最终呈现形式也是独立的页面,因此我们把Flutter模块也归为此类,我们的工程可以像依赖原生业务模块一样直接依赖它,为用户提供独立的业务功能。 + +我们把这些组件及其依赖按照从上到下的方式进行划分,就是一个完整的混合开发架构了。可以看到,原生工程和Flutter工程的边界定义清晰,双方都可以保持原有的分层管理依赖的开发模式不变。 + + + +图2 Flutter混合开发架构 + +需要注意的是,作为一个内嵌在原生工程的功能组件,Flutter模块的运行环境是由原生工程提供支持的,这也就意味着在渲染交互能力之外的部分基础功能(比如网络、存储),以及和原生业务共享的业务通用能力(比如支付、账号)需要原生工程配合完成,即原生工程以分层的形式提供上层调用接口,Flutter模块以插件的形式直接访问原生代码宿主对应功能实现。 + +因此,不仅不同归属定义的原生组件之前存在着分层依赖的关系,Flutter模块与原生组件之前也隐含着分层依赖的关系。比如,Flutter模块中处于基础业务模块的账号插件,依赖位于原生基础业务模块中的账号功能;Flutter模块中处于基础业务模块的网络插件,依赖位于原生基础功能的网络引擎。 + +可以看到,在混合工程架构中,像原生工程依赖Flutter模块、Flutter模块又依赖原生工程这样跨技术栈的依赖管理行为,我们实际上是通过将双方抽象为彼此对应技术栈的依赖,从而实现分层管理的:即将原生对Flutter的依赖抽象为依赖Flutter模块所封装的原生组件,而Flutter对原生的依赖则抽象为依赖插件所封装的原生行为。 + +Flutter混合开发工作流 + +对于软件开发而言,工程师的职责涉及从需求到上线的整个生命周期,包含需求阶段->方案阶段->开发阶段->发布阶段->线上运维阶段。可以看出,这其实就是一种抽象的工作流程。 + +其中,和工程化关联最为紧密的是开发阶段和发布阶段。我们将工作流中和工程开发相关的部分抽离,定义为开发工作流,根据生命周期中关键节点和高频节点,可以将整个工作流划分为如下七个阶段,即初始化->开发/调试->构建->测试->发布->集成->原生工具链: + + + +图3 Flutter混合开发工作流 + +前6个阶段是Flutter的标准工作流,最后一个阶段是原生开发的标准工作流。 + +可以看到,在混合开发工作模式中,Flutter的开发模式与原生开发模式之间有着清晰的分工边界:Flutter模块是原生工程的上游,其最终产物是原生工程依赖。从原生工程视角看,其开发模式与普通原生应用并无区别,因此这里就不再赘述了,我们重点讨论Flutter开发模式。 + +对于Flutter标准工作流的6个阶段而言,每个阶段都会涉及业务或产品特性提出的特异性要求,技术方案的选型,各阶段工作成本可用性、可靠性的衡量,以及监控相关基础服务的接入和配置等。 + +每件事儿都是一个固定的步骤,而当开发规模随着文档、代码、需求增加时,我们会发现重复的步骤越来越多。此时,如果我们把这些步骤像抽象代码一样,抽象出一些相同操作,就可以大大提升开发效率。 + +优秀的程序员会发掘工作中的问题,从中探索提高生产力的办法,而转变思维模式就是一个不错的起点。以持续交付的指导思想来看待这些问题,我们希望整体方案能够以可重复、可配置化的形式,来保障整个工作流的开发体验、效率、稳定性和可靠性,而这些都离不开Flutter对命令行工具支持。 + +比如,对于测试阶段的Dart代码分析,我们可以使用flutter analyze命令对代码中可能存在的语法或语义问题进行检查;又比如,在发布期的package发布环节,我们可以使用flutter packages pub publish –dry-run命令对待发布的包进行发布前检查,确认无误后使用去掉dry-run参数的publish命令将包提交至Pub站点。 + +这些基本命令对各个开发节点的输入、输出以及执行过程进行了抽象,熟练掌握它们及对应的扩展参数用法,我们不仅可以在本地开发时打造一个易用便捷的工程开发环境,还可以将这些命令部署到云端,实现工程构建及部署的自动化。 + +我把这六个阶段涉及的关键命令总结为了一张表格,你可以结合这张表格,体会落实在具体实现中的Flutter标准工作流。 + +表1 Flutter标准工作流命令 + + + +总结 + +对于Flutter混合开发而言,如何处理好原生与Flutter之间的关系,需要从工程架构与工作模式上定义清晰的分工边界。 + +在架构层面,将Flutter模块定义为原生工程的独立业务层,以原生基础业务层向Flutter模块提供业务通用能力、原生基础能力层向Flutter模块提供基础功能支持这样的方式去分层管理依赖。 + +在工作模式层面,将作为原生工程上游的Flutter模块开发,抽象为原生依赖产物的工程管理,并提炼出对应的工作流,以可重复、配置化的命令行方式对各个阶段进行统一管理。 + +可以看到,在原生App工程中引入Flutter运行环境,由原生开发主做应用架构和基础能力赋能、Flutter开发主做应用层业务的混合开发协作方式,能够综合原生App与Flutter框架双方的特点和优势,不仅可以直接节省研发资源,也符合目前行业人才能力模型的发展趋势。 + +思考题 + +除了工程依赖之外,我们还需要管理Flutter SDK自身的依赖。考虑到Flutter SDK升级非常频繁,对于多人协作的团队模式中,如何保证每个人使用的Flutter SDK版本完全一致呢? + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/44如何构建自己的Flutter混合开发框架(二)?.md b/专栏/Flutter核心技术与实战/44如何构建自己的Flutter混合开发框架(二)?.md new file mode 100644 index 0000000..3c1d91b --- /dev/null +++ b/专栏/Flutter核心技术与实战/44如何构建自己的Flutter混合开发框架(二)?.md @@ -0,0 +1,415 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 44 如何构建自己的Flutter混合开发框架(二)? + 你好,我是陈航。 + +在上一篇文章中,我从工程架构与工作模式两个层面,与你介绍了设计Flutter混合框架需要关注的基本设计原则,即确定分工边界。 + +在工程架构维度,由于Flutter模块作为原生工程的一个业务依赖,其运行环境是由原生工程提供的,因此我们需要将它们各自抽象为对应技术栈的依赖管理方式,以分层依赖的方式确定二者的边界。 + +而在工作模式维度,考虑到Flutter模块开发是原生开发的上游,因此我们只需要从其构建产物的过程入手,抽象出开发过程中的关键节点和高频节点,以命令行的形式进行统一管理。构建产物是Flutter模块的输出,同时也是原生工程的输入,一旦产物完成构建,我们就可以接入原生开发的工作流了。 + +可以看到,在Flutter混合框架中,Flutter模块与原生工程是相互依存、互利共赢的关系: + + +Flutter跨平台开发效率高,渲染性能和多端体验一致性好,因此在分工上主要专注于实现应用层的独立业务(页面)的渲染闭环; +而原生开发稳定性高,精细化控制力强,底层基础能力丰富,因此在分工上主要专注于提供整体应用架构,为Flutter模块提供稳定的运行环境及对应的基础能力支持。 + + +那么,在原生工程中为Flutter模块提供基础能力支撑的过程中,面对跨技术栈的依赖管理,我们该遵循何种原则呢?对于Flutter模块及其依赖的原生插件们,我们又该如何以标准的原生工程依赖形式进行组件封装呢? + +在今天的文章中,我就通过一个典型案例,与你讲述这两个问题的解决办法。 + +原生插件依赖管理原则 + +在前面第26和31篇文章里,我与你讲述了为Flutter应用中的Dart代码提供原生能力支持的两种方式,即:在原生工程中的Flutter应用入口注册原生代码宿主回调的轻量级方案,以及使用插件工程进行独立拆分封装的工程化解耦方案。 + +无论使用哪种方式,Flutter应用工程都为我们提供了一体化的标准解决方案,能够在集成构建时自动管理原生代码宿主及其相应的原生依赖,因此我们只需要在应用层使用pubspec.yaml文件去管理Dart的依赖。 + +但对于混合工程而言,依赖关系的管理则会复杂一些。这是因为,与Flutter应用工程有着对原生组件简单清晰的单向依赖关系不同,混合工程对原生组件的依赖关系是多向的:Flutter模块工程会依赖原生组件,而原生工程的组件之间也会互相依赖。 + +如果继续让Flutter的工具链接管原生组件的依赖关系,那么整个工程就会陷入不稳定的状态之中。因此,对于混合工程的原生依赖,Flutter模块并不做介入,完全交由原生工程进行统一管理。而Flutter模块工程对原生工程的依赖,体现在依赖原生代码宿主提供的底层基础能力的原生插件上。 + +接下来,我就以网络通信这一基础能力为例,与你展开说明原生工程与Flutter模块工程之间应该如何管理依赖关系。 + +网络插件依赖管理实践 + +在第24篇文章“HTTP网络编程与JSON解析”中,我与你介绍了在Flutter中,我们可以通过HttpClient、http与dio这三种通信方式,实现与服务端的数据交换。 + +但在混合工程中,考虑到其他原生组件也需要使用网络通信能力,所以通常是由原生工程来提供网络通信功能的。因为这样不仅可以在工程架构层面实现更合理的功能分治,还可以统一整个App内数据交换的行为。比如,在网络引擎中为接口请求增加通用参数,或者是集中拦截错误等。 + +关于原生网络通信功能,目前市面上有很多优秀的第三方开源SDK,比如iOS的AFNetworking和Alamofire、Android的OkHttp和Retrofit等。考虑到AFNetworking和OkHttp在各自平台的社区活跃度相对最高,因此我就以它俩为例,与你演示混合工程的原生插件管理方法。 + +网络插件接口封装 + +要想搞清楚如何管理原生插件,我们需要先使用方法通道来建立Dart层与原生代码宿主之间的联系。 + +原生工程为Flutter模块提供原生代码能力,我们同样需要使用Flutter插件工程来进行封装。关于这部分内容,我在第31和39篇文章中,已经分别为你演示了推送插件和数据上报插件的封装方法,你也可以再回过头来复习下相关内容。所以,今天我就不再与你过多介绍通用的流程和固定的代码声明部分了,而是重点与你讲述与接口相关的实现细节。 + +首先,我们来看看Dart代码部分。 + +对于插件工程的Dart层代码而言,由于它仅仅是原生工程的代码宿主代理,所以这一层的接口设计比较简单,只需要提供一个可以接收请求URL和参数,并返回接口响应数据的方法doRequest即可: + +class FlutterPluginNetwork { + ... + static Future doRequest(url,params) async { + //使用方法通道调用原生接口doRequest,传入URL和param两个参数 + final String result = await _channel.invokeMethod('doRequest', { + "url": url, + "param": params, + }); + return result; + } +} + + +Dart层接口封装搞定了,我们再来看看接管真实网络调用的Android和iOS代码宿主如何响应Dart层的接口调用。 + +我刚刚与你提到过,原生代码宿主提供的基础通信能力是基于AFNetworking(iOS)和OkHttp(Android)做的封装,所以为了在原生代码中使用它们,我们首先需要分别在flutter_plugin_network.podspec和build.gradle文件中将工程对它们的依赖显式地声明出来: + +在flutter_plugin_network.podspec文件中,声明工程对AFNetworking的依赖: + +Pod::Spec.new do |s| + ... + s.dependency 'AFNetworking' +end + + +在build.gradle文件中,声明工程对OkHttp的依赖: + +dependencies { + implementation "com.squareup.okhttp3:okhttp:4.2.0" +} + + +然后,我们需要在原生接口FlutterPluginNetworkPlugin类中,完成例行的初始化插件实例、绑定方法通道工作。 + +最后,我们还需要在方法通道中取出对应的URL和query参数,为doRequest分别提供AFNetworking和OkHttp的实现版本。 + +对于iOS的调用而言,由于AFNetworking的网络调用对象是AFHTTPSessionManager类,所以我们需要这个类进行实例化,并定义其接口返回的序列化方式(本例中为字符串)。然后剩下的工作就是用它去发起网络请求,使用方法通道通知Dart层执行结果了: + +@implementation FlutterPluginNetworkPlugin +... +//方法通道回调 +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + //响应doRequest方法调用 + if ([@"doRequest" isEqualToString:call.method]) { + //取出query参数和URL + NSDictionary *arguments = call.arguments[@"param"]; + NSString *url = call.arguments[@"url"]; + [self doRequest:url withParams:arguments andResult:result]; + } else { + //其他方法未实现 + result(FlutterMethodNotImplemented); + } +} +//处理网络调用 +- (void)doRequest:(NSString *)url withParams:(NSDictionary *)params andResult:(FlutterResult)result { + //初始化网络调用实例 + AFHTTPSessionManager *manager = [AFHTTPSessionManager manager]; + //定义数据序列化方式为字符串 + manager.responseSerializer = [AFHTTPResponseSerializer serializer]; + NSMutableDictionary *newParams = [params mutableCopy]; + //增加自定义参数 + newParams[@"ppp"] = @"yyyy"; + //发起网络调用 + [manager GET:url parameters:params progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) { + //取出响应数据,响应Dart调用 + NSString *string = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding]; + result(string); + } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) { + //通知Dart调用失败 + result([FlutterError errorWithCode:@"Error" message:error.localizedDescription details:nil]); + }]; +} +@end + + +Android的调用也类似,OkHttp的网络调用对象是OkHttpClient类,所以我们同样需要这个类进行实例化。OkHttp的默认序列化方式已经是字符串了,所以我们什么都不用做,只需要URL参数加工成OkHttp期望的格式,然后就是用它去发起网络请求,使用方法通道通知Dart层执行结果了: + +public class FlutterPluginNetworkPlugin implements MethodCallHandler { + ... + @Override + //方法通道回调 + public void onMethodCall(MethodCall call, Result result) { + //响应doRequest方法调用 + if (call.method.equals("doRequest")) { + //取出query参数和URL + HashMap param = call.argument("param"); + String url = call.argument("url"); + doRequest(url,param,result); + } else { + //其他方法未实现 + result.notImplemented(); + } + } + //处理网络调用 + void doRequest(String url, HashMap param, final Result result) { + //初始化网络调用实例 + OkHttpClient client = new OkHttpClient(); + //加工URL及query参数 + HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); + for (String key : param.keySet()) { + String value = param.get(key); + urlBuilder.addQueryParameter(key,value); + } + //加入自定义通用参数 + urlBuilder.addQueryParameter("ppp", "yyyy"); + String requestUrl = urlBuilder.build().toString(); + + //发起网络调用 + final Request request = new Request.Builder().url(requestUrl).build(); + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, final IOException e) { + //切换至主线程,通知Dart调用失败 + registrar.activity().runOnUiThread(new Runnable() { + @Override + public void run() { + result.error("Error", e.toString(), null); + } + }); + } + + @Override + public void onResponse(Call call, final Response response) throws IOException { + //取出响应数据 + final String content = response.body().string(); + //切换至主线程,响应Dart调用 + registrar.activity().runOnUiThread(new Runnable() { + @Override + public void run() { + result.success(content); + } + }); + } + }); + } +} + + +需要注意的是,由于方法通道是非线程安全的,所以原生代码与Flutter之间所有的接口调用必须发生在主线程。而OktHtp在处理网络请求时,由于涉及非主线程切换,所以需要调用runOnUiThread方法以确保回调过程是在UI线程中执行的,否则应用可能会出现奇怪的Bug,甚至是Crash。 + +有些同学可能会比较好奇,为什么doRequest的Android实现需要手动切回UI线程,而iOS实现则不需要呢?这其实是因为doRequest的iOS实现背后依赖的AFNetworking,已经在数据回调接口时为我们主动切换了UI线程,所以我们自然不需要重复再做一次了。 + +在完成了原生接口封装之后,Flutter工程所需的网络通信功能的接口实现,就全部搞定了。 + +Flutter模块工程依赖管理 + +通过上面这些步骤,我们以插件的形式提供了原生网络功能的封装。接下来,我们就需要在Flutter模块工程中使用这个插件,并提供对应的构建产物封装,提供给原生工程使用了。这部分内容主要包括以下3大部分: + + +第一,如何使用FlutterPluginNetworkPlugin插件,也就是模块工程功能如何实现; +第二,模块工程的iOS构建产物应该如何封装,也就是原生iOS工程如何管理Flutter模块工程的依赖; +第三,模块工程的Android构建产物应该如何封装,也就是原生Android工程如何管理Flutter模块工程的依赖。 + + +接下来,我们具体看看每部分应该如何实现。 + +模块工程功能实现 + +为了使用FlutterPluginNetworkPlugin插件实现与服务端的数据交换能力,我们首先需要在pubspec.yaml文件中,将工程对它的依赖显示地声明出来: + +flutter_plugin_network: + git: + url: https://github.com/cyndibaby905/44_flutter_plugin_network.git + + +然后,我们还得在main.dart文件中为它提供一个触发入口。在下面的代码中,我们在界面上展示了一个RaisedButton按钮,并在其点击回调函数时,使用FlutterPluginNetwork插件发起了一次网络接口调用,并把网络返回的数据打印到了控制台上: + +RaisedButton( + child: Text("doRequest"), + //点击按钮发起网络请求,打印数据 + onPressed:()=>FlutterPluginNetwork.doRequest("https://jsonplaceholder.typicode.com/posts", {'userId':'2'}).then((s)=>print('Result:$s')), +) + + +运行这段代码,点击doRequest按钮,观察控制台输出,可以看到,接口返回的数据信息能够被正常打印,证明Flutter模块的功能表现是完全符合预期的。 + + + +图1 Flutter模块工程运行示例 + +构建产物应该如何封装? + +我们都知道,模块工程的Android构建产物是aar,iOS构建产物是Framework。而在第28和42篇文章中,我与你介绍了不带插件依赖的模块工程构建产物的两种封装方案,即手动封装方案与自动化封装方案。这两种封装方案,最终都会输出同样的组织形式(Android是aar,iOS则是带podspec的Framework封装组件)。 + +如果你已经不熟悉这两种封装方式的具体操作步骤了,可以再复习下这两篇文章的相关内容。接下来,我重点与你讲述的问题是:如果我们的模块工程存在插件依赖,封装过程是否有区别呢? + +答案是,对于模块工程本身而言,这个过程没有区别;但对于模块工程的插件依赖来说,我们需要主动告诉原生工程,哪些依赖是需要它去管理的。 + +由于Flutter模块工程把所有原生的依赖都交给了原生工程去管理,因此其构建产物并不会携带任何原生插件的封装实现,所以我们需要遍历模块工程所使用的原生依赖组件们,为它们逐一生成插件代码对应的原生组件封装。 + +在第18篇文章“依赖管理(二):第三方组件库在Flutter中要如何管理?”中,我与你介绍了Flutter工程管理第三方依赖的实现机制,其中.packages文件存储的是依赖的包名与系统缓存中的包文件路径。 + +类似的,插件依赖也有一个类似的文件进行统一管理,即.flutter-plugins。我们可以通过这个文件,找到对应的插件名字(本例中即为flutter_plugin_network)及缓存路径: + +flutter_plugin_network=/Users/hangchen/Documents/flutter/.pub-cache/git/44_flutter_plugin_network-9b4472aa46cf20c318b088573a30bc32c6961777/ + + +插件缓存本身也可以被视为一个Flutter模块工程,所以我们可以采用与模块工程类似的办法,为它生成对应的原生组件封装。 + +对于iOS而言,这个过程相对简单些,所以我们先来看看模块工程的iOS构建产物封装过程。 + +iOS构建产物应该如何封装? + +在插件工程的ios目录下,为我们提供了带podspec文件的源码组件,podspec文件提供了组件的声明(及其依赖),因此我们可以把这个目录下的文件拷贝出来,连同Flutter模块组件一起放到原生工程中的专用目录,并写到Podfile文件里。 + +原生工程会识别出组件本身及其依赖,并按照声明的依赖关系依次遍历,自动安装: + +#Podfile +target 'iOSDemo' do + pod 'Flutter', :path => 'Flutter' + pod 'flutter_plugin_network', :path => 'flutter_plugin_network' +end + + +然后,我们就可以像使用不带插件依赖的模块工程一样,把它引入到原生工程中,为其设置入口,在FlutterViewController中展示Flutter模块的页面了。 + +不过需要注意的是,由于FlutterViewController并不感知这个过程,因此不会主动初始化项目中的插件,所以我们还需要在入口处手动将工程里所有的插件依次声明出来: + +//AppDelegate.m: +@implementation AppDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + //初始化Flutter入口 + FlutterViewController *vc = [[FlutterViewController alloc]init]; + //初始化插件 + [FlutterPluginNetworkPlugin registerWithRegistrar:[vc registrarForPlugin:@"FlutterPluginNetworkPlugin"]]; + //设置路由标识符 + [vc setInitialRoute:@"defaultRoute"]; + self.window.rootViewController = vc; + [self.window makeKeyAndVisible]; + return YES; +} + + +在Xcode中运行这段代码,点击doRequest按钮,可以看到,接口返回的数据信息能够被正常打印,证明我们已经可以在原生iOS工程中顺利的使用Flutter模块了。 + + + +图2 原生iOS工程运行示例 + +我们再来看看模块工程的Android构建产物应该如何封装。 + +Android构建产物应该如何封装? + +与iOS的插件工程组件在ios目录类似,Android的插件工程组件在android目录。对于iOS的插件工程,我们可以直接将源码组件提供给原生工程,但对于Andriod的插件工程来说,我们只能将aar组件提供给原生工程,所以我们不仅需要像iOS操作步骤那样进入插件的组件目录,还需要借助构建命令,为插件工程生成aar: + +cd android +./gradlew flutter_plugin_network:assRel + + +命令执行完成之后,aar就生成好了。aar位于android/build/outputs/aar目录下,我们打开插件缓存对应的路径,提取出对应的aar(本例中为flutter_plugin_network-debug.aar)就可以了。 + +我们把生成的插件aar,连同Flutter模块aar一起放到原生工程的libs目录下,最后在build.gradle文件里将它显式地声明出来,就完成了插件工程的引入。 + +//build.gradle +dependencies { + ... + implementation(name: 'flutter-debug', ext: 'aar') + implementation(name: 'flutter_plugin_network-debug', ext: 'aar') + implementation "com.squareup.okhttp3:okhttp:4.2.0" + ... +} + + +然后,我们就可以在原生工程中为其设置入口,在FlutterView中展示Flutter页面,愉快地使用Flutter模块带来的高效开发和高性能渲染能力了: + +//MainActivity.java +public class MainActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); + setContentView(FlutterView); + } +} + + +不过需要注意的是,与iOS插件工程的podspec能够携带组件依赖不同,Android插件工程的封装产物aar本身不携带任何配置信息。所以,如果插件工程本身存在原生依赖(像flutter_plugin_network依赖OkHttp这样),我们是无法通过aar去告诉原生工程其所需的原生依赖的。 + +面对这种情况,我们需要在原生工程中的build.gradle文件里手动地将插件工程的依赖(即OkHttp)显示地声明出来。 + +//build.gradle +dependencies { + ... + implementation(name: 'flutter-debug', ext: 'aar') + implementation(name: 'flutter_plugin_network-debug', ext: 'aar') + implementation "com.squareup.okhttp3:okhttp:4.2.0" + ... +} + + +至此,将模块工程及其插件依赖封装成原生组件的全部工作就完成了,原生工程可以像使用一个普通的原生组件一样,去使用Flutter模块组件的功能了。 + +在Android Studio中运行这段代码,并点击doRequest按钮,可以看到,我们可以在原生Android工程中正常使用Flutter封装的页面组件了。 + + + +图3 原生Android工程运行示例 + +当然,考虑到手动封装模块工程及其构建产物的过程,繁琐且容易出错,我们可以把这些步骤抽象成命令行脚本,并把它部署到Travis上。这样在Travis检测到代码变更之后,就会自动将Flutter模块的构建产物封装成原生工程期望的组件格式了。 + +关于这部分内容,你可以参考我在flutter_module_demo里的generate_aars.sh与generate_pods.sh实现。如果关于这部分内容有任何问题,都可以直接留言给我。 + +总结 + +好了,关于Flutter混合开发框架的依赖管理部分我们就讲到这里。接下来,我们一起总结下今天的主要内容吧。 + +Flutter模块工程的原生组件封装形式是aar(Android)和Framework(Pod)。与纯Flutter应用工程能够自动管理插件的原生依赖不同,这部分工作在模块工程中是完全交给原生工程去管理的。因此,我们需要查找记录了插件名称及缓存路径映射关系的.flutter-plugins文件,提取出每个插件所对应的原生组件封装,集成到原生工程中。 + +从今天的分享可以看出,对于有着插件依赖的Android组件封装来说,由于aar本身并不携带任何配置信息,因此其操作以手工为主:我们不仅要执行构建命令依次生成插件对应的aar,还需要将插件自身的原生依赖拷贝至原生工程,其步骤相对iOS组件封装来说要繁琐一些。 + +为了解决这一问题,业界出现了一种名为fat-aar的打包手段,它能够将模块工程本身,及其相关的插件依赖统一打包成一个大的aar,从而省去了依赖遍历和依赖声明的过程,实现了更好的功能自治性。但这种解决方案存在一些较为明显的不足: + + +依赖冲突问题。如果原生工程与插件工程都引用了同样的原生依赖组件(OkHttp),则原生工程的组件引用其依赖时会产生合并冲突,因此在发布时必须手动去掉原生工程的组件依赖。 +嵌套依赖问题。fat-aar只会处理embedded关键字指向的这层一级依赖,而不会处理再下一层的依赖。因此,对于依赖关系复杂的插件支持,我们仍需要手动处理依赖问题。 +Gradle版本限制问题。fat-aar方案对Gradle插件版本有限制,且实现方式并不是官方设计考虑的点,加之Gradle API变更较快,所以存在后续难以维护的问题。 +其他未知问题。fat-aar项目已经不再维护了,最近一次更新还是2年前,在实际项目中使用“年久失修”的项目存在较大的风险。 + + +考虑到这些因素,fat-aar并不是管理插件工程依赖的好的解决方案,所以我们最好还是得老老实实地去遍历插件依赖,以持续交付的方式自动化生成aar。 + +我把今天分享涉及知识点打包上传到了GitHub中,你可以把插件工程、Flutter模块工程、原生Android和iOS工程下载下来,查看其Travis持续交付配置文件的构建执行命令,体会在混合框架中如何管理跨技术栈的组件依赖。 + +思考题 + +最后,我给你留一道思考题吧。 + +原生插件的开发是一个需要Dart层代码封装,以及原生Android、iOS代码层实现的长链路过程。如果需要支持的基础能力较多,开发插件的过程就会变得繁琐且容易出错。我们都知道Dart是不支持反射的,但是原生代码可以。我们是否可以利用原生的反射去实现插件定义的标准化呢? + +提示:在Dart层调用不存在的接口(或未实现的接口),可以通过noSuchMethod方法进行统一处理。 + +class FlutterPluginDemo { + //方法通道 + static const MethodChannel _channel = + const MethodChannel('flutter_plugin_demo'); + //当调用不存在接口时,Dart会交由该方法进行统一处理 + @override + Future noSuchMethod(Invocation invocation) { + //从字符串Symbol("methodName")中取出方法名 + String methodName = invocation.memberName.toString().substring(8, string.length - 2); + //参数 + dynamic args = invocation.positionalArguments; + print('methodName:$methodName'); + print('args:$args'); + return methodTemplate(methodName, args); + } + + //某未实现的方法 + Future someMethodNotImplemented(); + //某未实现的带参数方法 + Future someMethodNotImplementedWithParameter(param); +} + + +欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/特别放送温故而知新,与你说说专栏的那些思考题.md b/专栏/Flutter核心技术与实战/特别放送温故而知新,与你说说专栏的那些思考题.md new file mode 100644 index 0000000..f514c1b --- /dev/null +++ b/专栏/Flutter核心技术与实战/特别放送温故而知新,与你说说专栏的那些思考题.md @@ -0,0 +1,459 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 温故而知新,与你说说专栏的那些思考题 + 你好,我是陈航。专栏上线以来,我在评论区看到了很多同学写的心得、经验和建议,当然更多的还是大家提的问题。 + +为了能够让大家更好地理解我们专栏的核心知识点,我今天特意整理了每篇文章的课后思考题,并结合大家在留言区的回答情况做一次分析与扩展。 + +当然 ,我也希望你能把这篇答疑文章作为对整个专栏所讲知识点的一次复习,如果你在学习或者使用Flutter的过程中,遇到哪些问题,欢迎继续给我留言。我们一起交流,共同进步! + +需要注意的是,这些课后题并不存在标准答案。就算是同一个功能、同一个界面,不同人也会有完全不一样的实现方案,只要你的解决方案的输入和输出满足题目要求,在我看来你就已经掌握了相应的知识点。因此,在这篇文章中,我会更侧重于介绍方案、实现思路、原理和关键细节,而不是讲具体实操的方方面面。 + +接下来,我们就具体看看这些思考题的答案吧。 + +问题1:直接在build函数里以内联的方式实现Scaffold页面元素的构建,好处是什么? + +这个问题选自第5篇文章“从标准模板入手,体会Flutter代码是如何运行在原生系统上的”,你可以先回顾下这篇文章的相关知识点。 + +然后,我来说说这样做的最大好处是,各个组件之间可以直接共享页面的状态和方法,页面和组件间不再需要把状态数据传来传去、多级回调了。 + +不过这种方式也有缺点,一旦数据发生变更,Flutter会重建整个大Widget(而不是变化的那部分),所以会对性能产生些影响。 + +问题2:对于集合类型List和Map,如何让其内部元素支持多种类型? + +这个问题来自第6篇文章“基础语法与类型变量:Dart是如何表示信息的?”,你可以先回顾下这篇文章的相关知识点。 + +如果集合中多个类型之间存在共同的父类(比如double和int),可以使用父类进行容器类型声明,从而增加类型的安全校验,在取出对象时根据runtimeType转换成实际类型即可。如果容器中的类型比较多,想省掉类型转换的步骤,也可以使用动态类型dynamic为元素添加不同类型的元素。 + +而在判断元素真实类型时,我们可以使用is关键字或runtimeType。 + +问题3:继承、接口与混入的相关问题。 + +这个问题来自第7篇文章“函数、类与运算符:Dart是如何处理信息的?”,你可以先回顾下这篇文章的相关知识点。 + +第一,你是怎样理解父类继承、接口实现和混入的?我们应该在什么场景下使用它们? + +父类继承、接口实现和混入都是实现代码复用的手段,我们在代码中应该根据不同的需求去使用。其中: + + +在父类继承中,子类复用了父类的实现,适用于两个类的整体存在逻辑层次关系的场景; +在接口实现中,类复用了接口的参数、返回值和方法名,但不复用其方法实现,适用于接口和类在行为存在逻辑层次关系的场景; +而混入则可以使一个类复用多个类的实现,这些类之间无需存在父子关系,适用于多个类的局部存在逻辑层次关系的场景。 + + +第二,在父类继承的场景中,父类子类之间的构造函数执行顺序是怎样的?如果父类有多个构造函数,子类也有多个构造函数,如何从代码层面确保父类子类之间构造函数的正确调用? + +默认情况下,子类的构造函数会自动调用父类的默认构造函数,如果需要显式地调用父类的构造函数,则需要在子类构造函数的函数体开头位置调用。但,如果子类提供了初始化参数列表,则初始化参数列表会在父类构造函数之前执行。 + +构造函数之间,有时候会有一些相同的逻辑。如果把这些逻辑分别写在各个构造函数中,会有些累赘,所以构造函数之间是可以传递的,相当于填充了某个构造函数的参数,从而实现类的初始化。因此可以传递的构造函数是没有方法体的,它们只会在初始化列表中,去调用另一个构造函数。 + +如果子类与父类存在多个构造函数,通常是为了简化类的初始化代码,将部分不需要的属性设置为默认值。因此,我们只要能确保每条构造函数的初始化路径都不会有属性被遗漏即可。一个好的做法是,依照构造函数的参数个数,将参数少的构造函数转发至参数多的构造函数中,由参数最多的构造函数统一调用父类参数最多的那个构造函数。 + +问题4:扩展购物车案例的程序,使其支持商品数量属性,并输出商品列表信息(包括商品名称、数量及单价)。 + +这个问题来自第8篇文章“综合案例:掌握Dart核心特性”,你可以先回顾下这篇文章的相关知识点。 + +要实现这个扩展功能,如我所说,每个人都可能有完全不一样的解决方案。在这里,我给你的提示是,在Item类中增加数量属性,在做小票打印时,循环购物车内的商品信息即可实现。 + +需要注意的是,增加数量属性后,商品在做合并计算价格时,count需要置为1,而不能做累加。比如,五斤苹果和三盒巧克力做合并,结果是一份巧克力苹果套餐,而不是八份巧克力苹果套餐。 + +问题5:Widget、Element 和 RenderObject之间是什么关系?你能否在Android/iOS/Web中找到对应的概念呢? + +这个问题来自第9篇文章“Widget,构建Flutter界面的基石”。 + +Widget是数据配置,RenderObject负责渲染,而Element是一个介于它们之间的中间类,用于渲染资源复用。 + +Widget和Element是一一对应的,但RenderObject不是,只有实际需要布局和绘制的控件才会有RenderObject。 + +这三个概念在iOS、Android和Web开发中,对应的概念分别是: + + +在iOS中,Xib相当于Widget,UIView相当于Element,CALayer相当于renderObject; +在Android中,XML相当于Widget,View相当于Element,Canvas相当于renderObject; +在Web中,以Vue为例,Vue的模板相当于Widget,virtual DOM相当于Element,DOM相当于RenderObject。 + + +问题6:State构造函数和initState的差异是什么? + +这个问题来自第11篇文章“提到生命周期,我们是在说什么?”。 + +State构造函数调用时,Widget还未完成初始化,因此仅适用于一些与UI无关的数据初始化,比如父类传入的参数加工。 + +而initState函数调用时,StatefulWidget已经完成了Widget树的插入工作,因此与Widget相关的一些初始化工作,比如设置滚动监听器则必须放在initState。 + +问题7:Text、Image以及按钮控件,真正承载其视觉功能的控件分别是什么? + +这个问题来自第12篇文章“经典控件(一):文本、图片和按钮在Flutter中怎么用?”。 + +Text是封装了RichText的StatelessWidget,Image是封装了RawImage的StatefulWidget,而按钮则是封装了RawMaterialButton的StatelessWidget。 + +可以看到,StatelessWidget和StatefulWidget只是封装了控件的容器,并不参与实际绘制,真正负责渲染的是继承自RenderObject的视觉功能组件们,比如RichText与RawImage。 + +问题8:在ListView中,如何提前缓存子元素? + +这个问题来自第13篇文章“经典控件(二):UITableView/ListView在Flutter中是什么?”。 + +ListView构造函数中有一个cacheExtent参数,即预渲染区域长度。ListView会在其可视区域的两边留一个cacheExtent长度的区域作为预渲染区域,相当于提前缓存些元素,这样当滑动时就可以迅速呈现了。 + +问题9:Row与Column自身的大小是如何决定的?当它们嵌套时,又会出现怎样的情况呢? + +这个问题来自第14篇文章“经典布局:如何定义子控件在父容器中排版的位置?”。 + +Row与Column自身的大小由父Widget的大小、子Widget的大小,以及mainSize共同决定。 + +Row和Column只会在主轴方向占用尽可能大的空间(max:屏幕方向主轴大小或父Widget主轴方向大小;min:所有子Widget组合在一起的主轴方向大小),而纵轴的长度则取决于它们最大子元素的长度。 + +如果Row里面嵌套Row,或者Column里面嵌套Column,只有最外层的Row或Colum才会占用尽可能大的空间,里层Row或Column占用的空间为实际大小。 + +问题10:在 UpdatedItem 控件的基础上,增加切换夜间模式的功能。 + +这个问题来自第16篇文章“从夜间模式说起,如何定制不同风格的App主题?”。 + +这是一道实践题。同样地,我在这里也只提示你实现思路:你可以在ThemeData中,通过增加变量来判断当前使用何种主题,然后在State中驱动变量更新即可。 + +问题11:像素密度为3.0及1.0设备,如何根据资源图片像素进行处理? + +这个问题来自第17篇文章“依赖管理(一):图片、配置和字体在Flutter中怎么用?”。 + +设备根据资源图片像素进行适配的原则是:调整为使用最合适的分辨率资源,即像素密度为3.0的设备会选择2.0而不是1.0的资源图片;而像素密度为1.0的设备,对于像素密度大于1.0的资源图片会进行压缩。 + +问题12:.packages 与 pubspec.lock 是否需要做代码版本管理? + +这个问题来自第18篇文章“依赖管理(二):第三方组件库在Flutter中要如何管理?”。 + +pubspec.lock需要做版本管理,因为lock文件记录了Dart在计算项目依赖时,当前工程所有显式和隐私的依赖关系。我们可以直接使用这个结果去统一工程开发环境。 + +而.packages不需要版本管理,因为这个文件记录了Dart在计算项目依赖时,当前工程所有依赖的本地缓存文件。与本地环境有关,无需统一。 + +问题13:GestureDetector内嵌FlatButton后,事件是如何响应的? + +这个问题来自第19篇文章“用户交互事件该如何响应?”。 + +对于一个父容器中存在按钮FlatButton的界面,在父容器使用GestureDetector监听了onTap事件的情况下,我们点击按钮是不会被父Widget响应的。因为,手势竞技场只会同时响应一个(子Widget)。 + +如果监听的是onDoubleTap事件,在按钮上双击后,父容器的双击事件会被识别。因为,子Widget没有处理双击事件,不需要经历手势竞技场的PK过程。 + +问题14:请分别概括属性传值、InheritedWidget、Notification 与 EventBus的特点。 + +这个问题来自第20篇文章“关于跨组件传递数据,你只需要记住这三招”。 + +属性传值适合在同一个视图树中使用,传递方向由父及子,通过构造方法将值以属性的方式传递过去,简单高效。其缺点是,涉及跨层传递时,属性可能需要跨越很多层才能传递给子组件,导致中间很多并不需要这个属性的组件,也得接收其子Widget的数据,繁琐且冗余。 + +InheritedWidget适用于子Widget主动向上层拿数据的场景,传递方向由父及子,可以实现跨层的数据读共享。InheritedWidget也可以实现写共享,需要在上层封装写数据的方法供下层调用。其优点是,数据传输方便,无代码侵入即可达到逻辑和视图解耦的效果;而其缺点是,如果层次较深,刷新范围过大会影响性能。 + +Notification适用于子Widget向父Widget推送数据的场景,传递方向由子及父,可以实现跨层的数据变更共享。其优点是,多个子元素的同一事件可由父元素统一处理,多对一简单;而其缺点是,Notification的自定义过程略繁琐。 + +EventBus适用于无需存在父子关系的实体之间通信,订阅者需要显式地订阅和取消。其优点是,能够支持任意对象的传递,一对多的方式实现简单;而其缺点是,订阅管理略显繁琐。 + +问题15:实现一个计算页面,这个页面可以对前一个页面传入的 2 个数值参数进行求和,并在该页面关闭时告知上一页面计算的结果。 + +这个问题来自第21篇文章“路由与导航,Flutter是这样实现页面切换的”。 + +这是一个实践题,还需要你动手去实现。这里,我给你的提示是:基本路由可以通过构造函数属性传值的方式,或是在MaterialPageRoute中加入参数setting,来传递页面参数。 + +打开页面时,我们可以使用上述机制为基本路由传递参数(2个数值),并注册then回调监听页面的关闭事件;而页面需要关闭时,我们将2个数值参数取出,求和后调用pop函数即可。 + +问题16:AnimatedBuilder中,外层的child参数与内层builder函数中的child参数的作用分别是什么? + +AnimatedBuilder( + animation: animation, + child:FlutterLogo(), + builder: (context, child) => Container( + width: animation.value, + height: animation.value, + child: child + ) +) + + +这个问题来自第22篇文章“如何构造炫酷的动画效果?”。 + +外层的child参数定义渲染,内层builder中的child参数定义动画,实现了动画和渲染的分离。通过builder函数,限定了重建rebuild的范围,做动画时不必每次重新构建整个Widget。 + +问题17:并发 Isolate 计算阶乘例子里给并发Isolate两个SendPort的原因? + +//并发计算阶乘 +Future asyncFactoriali(n) async{ + final response = ReceivePort();//创建管道 + //创建并发Isolate,并传入管道 + await Isolate.spawn(_isolate,response.sendPort); + //等待Isolate回传管道 + final sendPort = await response.first as SendPort; + //创建了另一个管道answer + final answer = ReceivePort(); + //往Isolate回传的管道中发送参数,同时传入answer管道 + sendPort.send([n,answer.sendPort]); + return answer.first;//等待Isolate通过answer管道回传执行结果 +} + +//Isolate函数体,参数是主Isolate传入的管道 +_isolate(initialReplyTo) async { + final port = ReceivePort();//创建管道 + initialReplyTo.send(port.sendPort);//往主Isolate回传管道 + final message = await port.first as List;//等待主Isolate发送消息(参数和回传结果的管道) + final data = message[0] as int;//参数 + final send = message[1] as SendPort;//回传结果的管道 + send.send(syncFactorial(data));//调用同步计算阶乘的函数回传结果 +} + +//同步计算阶乘 +int syncFactorial(n) => n < 2 ? n : n * syncFactorial(n-1); +main() async => print(await asyncFactoriali(4));//等待并发计算阶乘结果 + + +这个问题来自第23篇文章“单线程模型怎么保证UI运行流畅?”。 + +SendPort/ReceivePort是一个单向管道,帮助我们实现并发Isolate往主Isolate回传执行结果:并发Isolate负责用SendPort发,而主Isolate负责用ReceivePort收。对于回传执行结果这个过程而言,主Isolate除了被动等待没有别的办法。 + +在这个例子中,并发Isolate用SendPort发了两次数据,意味着主Isolate也需要用SendPort对应的ReceivePort等待两次。如果并发Isolate用SenderPort发了三次数据,那主Isolate也需要用ReceivePort等待三次。 + +那么,主Isolate怎么知道自己需要等待几次呢,总不能一直等着吧? + +所以更好的办法是,只使用SendPort/ReceivePort一次,发/收完了就不用了。但,如果下次还要发/收怎么办? + +这时,我们就可以参考这个计算阶乘案例的做法,在发数据的时候把下一次用到的SendPort也当做参数传过去。 + +问题18:自定义dio拦截器,检查并刷新token。 + +这个问题来自第24篇文章“HTTP网络编程与JSON解析”。 + +这也是一个实践题,我同样只提示你关键思路:在拦截器的onRequest方法中,检查header中是否存在token,如果没有,则发起一个新的请求去获取token,更新header。考虑到可能会有多个request同时发出,token会请求多次,我们可以通过调用拦截器的 lock/unlock 方法来锁定/解锁拦截器。 + +一旦请求/响应拦截器被锁定,接下来的请求/响应将会在进入请求/响应拦截器之前排队等待,直到解锁后,这些入队的请求才会继续执行(进入拦截器)。 + +问题19:持久化存储的相关问题。 + +这个问题来自来第25篇文章“本地存储与数据库的使用和优化”。 + +首先,我们先看看文件、SharedPreferences 和数据库,这三种持久化数据存储方式的适用场景。 + + +文件比较适合大量的、有序的数据持久化; +SharedPreferences,适用于缓存少量键值对信息; +数据库,则用来存储大量格式化后的数据,并且这些数据需要以较高频率更新。 + + +接下来,我们看看如何做数据库跨版本升级? + +数据库升级,实际上就是改表结构。如果升级过程是连续的,我们只需要在每个版本执行修改表结构的语句就可以了。如果升级过程不是连续的,比如直接从1.0升到5.0,中间2.0、3.0和4.0都直接跳过的: + +1.0->2.0:执行表结构修改语句A +2.0->3.0:执行表结构修改语句B +3.0->4.0:执行表结构修改语句C +4.0->5.0:执行表结构修改语句D + + +因此,我们在5.0的数据库迁移中,不能只考虑5.0的表结构,单独执行4.0的升级逻辑D,还需要考虑2.0、3.0、4.0的表结构,把1.0升级到4.0之间的所有升级逻辑执行一遍: + +switch(oldVersion) { + case '1.0': do A; + case '2.0': do B; + case '3.0': do C; + case '4.0': do D; + default: print('done'); +} + + +这样就万无一失了。 + +不过需要注意的是,在Dart的switch里,条件判断break语句是不能省的。关于如何在Dart中写出类似C++的fallthrough switch,你可以再思考一下。 + +问题20:扩展openAppMarket的实现,使得我们可以跳转到任意一个App的应用市场。 + +这个问题来自第26篇文章“如何在Dart层兼容Android/iOS平台特定实现?(一)”。 + +对于这个问题,我给你的提示是:Dart调用invokeMethod方法时,可传入Map类型的键值对参数(包含iOS的bundleID和Android包名),然后在原生代码宿主将参数取出即可。 + +问题21:扩展内嵌原生视图的实现,实现动态变更原生视图颜色的需求。 + +这个问题来自第27篇文章“如何在Dart层兼容Android/iOS平台特定实现?(二)”。 + +对于这个问题,我给你提示与上一问题类似:Dart调用invokeMethod方法时,可传入Map类型的键值对参数(颜色的RGB信息),然后在原生代码宿主将参数取出即可。 + +问题22:对于有资源依赖的Flutter模块工程,其打包构建的产物,以及抽离组件库的过程是否有不同? + +这个问题来自第28篇文章“如何在原生应用中混编Flutter工程?”。 + +答案是没什么不同。因为Flutter模块的文件本身就包含了资源文件。 + +如果模块工程有原生插件依赖,则其抽离过程还需要借助记录了插件本地依赖缓存地址的.flutter-plugins文件,来实现组件依赖的原生部分的封装。具体细节,你可以参考第44篇文章。 + +问题23:如何确保混合工程中两种页面过渡动画在应用整体的效果一致? + +这个问题来自第29篇文章“混合开发,该用何种方案管理导航栈?” + +首先,这两种页面过渡动画分别是:原生页面之间的切换动画和Flutter页面之间的切换动画。 + +保证整体效果一致,有两种方案: + + +一是,分别定制原生工程(主要是Android)的切换动画,及Flutter的切换动画; +二是,使用类似闲鱼的共享FlutterView的机制,将页面切换统一交由原生处理,FlutterView只负责刷新界面。 + + +问题24:如何使用Provider实现2个同样类型的对象共享? + +这个问题来自第30篇文章“为什么需要做状态管理,怎么做?” + +答案很简单,你可以封装1个大对象,将2个同样类型的对象封装为其内部属性。 + +问题25:如何让Flutter代码能够更快地收到推送消息? + +这个问题来自第31篇文章“如何实现原生推送能力?”。 + +我们需要先判断当前应用是处于前台还是后台,然后再用对应的方式去处理: + + +如果应用处于前台,并且已经完成初始化,则原生代码直接调用方法通道通知Flutter;如果应用未完成初始化,则原生代码将消息存在本地,待Flutter应用初始化完成后,调用方法通道主动拉取。 +如果应用处于后台,则原生代码将消息存在本地,唤醒Flutter应用,待Flutter应用初始化完成后,调用方法通道主动拉取。 + + +问题26:如何实现图片资源的国际化? + +这个问题来自第32篇文章“适配国际化,除了多语言我们还需要注意什么?”。 + +其实,图片资源国际化与文本资源,本质上并无区别,只需要在arb文件中对不同的图片进行单独声明即可。具体的实现细节,你可以再回顾下这篇文章的相关内容。 + +问题27:相邻页面的横竖屏切换如何实现? + +这个问题来自第33篇文章“如何适配不同分辨率的手机屏幕?”。 + +这个实现方式很简单。你可以在initState中设置屏幕支持方向,在dispose时将屏幕方向还原即可。 + +问题28:在保持生产环境代码不变的情况下,如何支持不同配置的切换? + +这个问题来自第34篇文章“如何理解Flutter的编译模式?”。 + +与配置夜间模式类似,我们可以通过增加状态开关来判断当前使用何种配置,设置入口,然后在State中驱动变量更新即可。关于夜间模式的配置,你可以再回顾下第16篇文章“从夜间模式说起,如何定制不同风格的App主题?”中的相关内容。 + +问题29:将debugPrint改为循环写日志。 + +这个问题来自第36篇文章“如何通过工具链优化开发调试效率?” + +关于这个问题,我给你的提示是,用不同的main文件定义debugPrint行为:main-dev.dart定义为日志输出至控制台,而main.dart定义为输出至文件。当前操作的文件名默认为0,写满后文件名按5取模递增,同步更新至SharedPreferences中,并将文件清空,重新写入。 + +问题30:使用并发Isolate完成MD5的计算。 + +这个问题来自第37篇文章“如何检测并优化Flutter App的整体性能表现?”。 + +关于这个问题,我给你的提示是:将界面改造为StatefulWidget,把MD5的计算启动放在StatefulWidget的初始化中,使用compute去启动计算。在build函数中,判断是否存在MD5数据,如果没有,展示CircularProgressIndicator,如果有,则展示ListView。 + +问题31:如何使用mockito为SharedPreferences增加单元测试用例? + +FutureupdateSP(SharedPreferences prefs, int counter) async { + bool result = await prefs.setInt('counter', counter); + return result; +} + +FutureincreaseSPCounter(SharedPreferences prefs) async { + int counter = (prefs.getInt('counter') ?? 0) + 1; + await updateSP(prefs, counter); + return counter; +} + + +这个问题来自第38篇文章“如何通过自动化测试提高交付质量?”。 + +待测函数updateSP与increaseSPCounter,其内部依赖了SharedPreferences的setInt方法与getInt方法,其中前者是异步函数,后者是同步函数。 + +因此,我们只需要为setInt与getInt模拟对应的数据返回即可。对于setInt,我们只需要在参数为1的时候返回true: + +when(prefs.setInt('counter', 1)).thenAnswer((_) async => true); + + +对于getInt,我们只需要返回2: + +when(prefs.getInt('counter')).thenAnswer((_) => 2); + + +其他部分与普通的单元测试并无不同。 + +问题32:并发Isolate的异常如何采集? + +这个问题来自第39篇文章“线上出现问题,该如何做好异常捕获与信息采集?”。 + +并发Isolate的异常是无法通过try-catch来捕获的。并发Isolate与主Isolate通信是采用SendPort的消息机制,而异常本质上也可以视作一种消息传递机制。所以,如果主Isolate想要捕获并发Isolate中的异常消息,可以给并发Isolate传入SendPort。 + +而创建Isolate的函数spawn中就恰好有一个类型为SendPort的onError参数,因此并发Isolate可以通过往这个参数里发送消息,实现异常通知。 + +问题33:依赖单个或多个网络接口数据的页面加载时长应该如何统计? + +这个问题来自第40篇文章“衡量Flutter App线上质量,我们需要关注这三个指标”。 + +页面加载时长=页面完成渲染的时间-页面初始化的时间。所以,我们只需要在进入页面时记录启动页面初始化时间,在接口返回数据刷新界面的同时,开启单次帧绘制回调,检测到页面完成渲染后记录页面渲染完成时间,两者相减即可。如果页面的渲染涉及到多个接口也类似。 + +问题34:如何设置Travis的Flutter版本? + +这个问题来自第42篇文章“如何构建高效的Flutter App打包发布环境?”。 + +设置方式很简单。在before_install字段里,克隆Flutter SDK时,直接指定特定的分支即可: + +git clone -b 'v1.5.4-hotfix.2' --depth 1 https://github.com/flutter/flutter.git + + +问题35:如何通过反射快速实现插件定义的标准化? + +这个问题来自第44篇文章“如何构建自己的Flutter混合开发框架(二)?”。 + +在Dart层调用不存在的接口(或未实现的接口),可以通过noSuchMethod方法进行统一处理。这个方法会携带一个类型为Invocation的参数invocation,我们可以通过它得到调用的函数名及参数: + +//获取方法名 +String methodName = invocation.memberName.toString().substring(8, string.length - 2); +//获取参数 +dynamic args = invocation.positionalArguments; + + +其中,参数args是一个List类型的变量,我们可以在原生代码宿主把相关的参数依次解析出来。有了函数名和参数,我们在插件类实例上,就可以利用反射去动态地调用原生方法了。 + +与传统的方法调用相比,以反射的方式执行方法调用,其步骤相对繁琐一些,我们需要依次找到并初始化反射调用过程的类示例对象、方法对象、参数列表对象,然后执行反射调用,并根据方法声明获取执行结果。不过这些步骤都是固定的,我们依葫芦画瓢就好。 + +Android端的调用方式: + +public void onMethodCall(MethodCall call, Result result) { + ... + String method = call.argument("method"); //获取函数名 + ArrayList params = call.argument("params"); //获取参数列表 + Class c = FlutterPluginDemoPlugin.class; //反射施加对象 + Method m = c.getMethod(method, ArrayList.class); //获取方法对象 + Object ret = m.invoke(this,params); //在插件实例上调用反射方法,获取返回值 + result.success(ret); //返回执行结果 + ... +} + + +iOS端的调用方式: + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + ... + NSArray *arguments = call.arguments[@"params"]; //获取函数名 + NSString *methodName = call.arguments[@"method"]; //获取参数列表 + SEL selector = NSSelectorFromString([NSString stringWithFormat:@"%@:",methodName]); //获取函数对应的Slector + NSMethodSignature *signature = [[self class] instanceMethodSignatureForSelector:selector]; //在插件实例上获取方法签名 + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; //通过方法签名生成反射的invocation对象 + + invocation.target = self; //设置invocation的执行对象 + invocation.selector = selector; //设置invocation的selector + [invocation setArgument:&arguments atIndex:2]; //设置invocation的参数 + + [invocation invoke]; //执行反射 + + NSObject *ret = nil; + if (signature.methodReturnLength) { + void *returnValue = nil; + [invocation getReturnValue:&returnValue]; + ret = (__bridge NSObject *)returnValue; //获取反射调用结果 + } + + result(ret); //返回执行结果 + ... +} + + +以上,就是“Flutter核心技术与实战”专栏,全部思考题的答案了。你如果还有其他问题的话,欢迎给我留言,我们一起讨论。 + + + + \ No newline at end of file diff --git a/专栏/Flutter核心技术与实战/结束语勿畏难,勿轻略.md b/专栏/Flutter核心技术与实战/结束语勿畏难,勿轻略.md new file mode 100644 index 0000000..78f49df --- /dev/null +++ b/专栏/Flutter核心技术与实战/结束语勿畏难,勿轻略.md @@ -0,0 +1,40 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 勿畏难,勿轻略 + 你好,我是陈航。 + +三个多月的时间转瞬即逝,转眼间《Flutter核心技术与实战》已经走到了尾声。在这里,我要感谢你对我和这个专栏的鼓励和支持,也要向你表示祝贺:你已经完整地学习了专栏的全部课程,实现了从入门到进阶Flutter技术的目标,你的坚持一定有所收获。现在专栏课程已经结束了,但还不能松懈,我们的Flutter学习旅程并未结束,从进阶到精通还有很长的一段路需要走,希望你能保持持续学习的习惯。 + +在这三个月的时间里,我们先后扫清了Dart语言基础语法及常用特性障碍;系统学习了Flutter框架原理和核心设计思想,掌握了构建炫酷页面从底层原理到上层应用的关键技术;学习了Flutter疑难问题及高阶特性的背后原理,并通过一些围绕效率和质量典型的场景,分析了在企业级应用迭代中,如何构建自己的Flutter开发体系。 + +专栏正文虽然已经更新完毕了,但我们的交流还会继续。同时针对专栏前面的课后题及留言,我也会从中专门挑选一些有代表性的问题进行深入讲解。 + +与此同时,我也很高兴地看到,在Google针对前端和移动端的布局愿景和强力带动的形势下,Flutter的发展方向愈加清晰。 + +在2019年,Flutter有了越来越多的知名公司加持背书,其开发者生态正在日益繁荣,开发者体验越来越好,支持的终端类型越来越广,使用的项目也越来越多。在开源社区里,Flutter是目前最火的大前端技术,正在经历着从小范围验证到大面积商业应用的过程。 + +大前端的技术更新迭代快、东西多,很容易让人挑花了眼。如果仅仅停留在对应用层API的使用上,不仅容易滋生学不动的困扰,也会让人产生工程师杂而不精的观点。大前端技术都是相似相通的,我认为一名优秀的大前端工程师应该具备以下特征: + + +在技术层面应该抛开对开发框架的站队,除了应用层API之外,能够更多地关注其底层原理、设计思路和通用理念,对中短期技术发展方向有大致思路,并思考如何与过往的开发经验相结合,融汇进属于自己的知识体系抽象网络; +而在业务上应该跳出自身职能的竖井,更多关注产品交互设计层面背后的决策思考,在推进项目时,能够结合大前端直面用户的优势,将自己的专业性和影响力辐射到协作方上下游,综合提升自己统筹项目的能力。 + + +做好一件事从来都不是一蹴而就的。 + +以我写专栏的过程来说,我自认为在大前端领域摸爬滚打多年,撰写专栏应该是一件驾轻就熟的事情。但从一开始的筹备阶段,我就慢慢发现这个事情远比我想象的要困难。与之前零散的总结输出相比,专栏的组织形式和交付方式需要花费数倍的精力。 + +为了把每一个知识点讲透,我需要花费大量的时间和精力去构思文章结构、验证设计、准备素材、代码实践。期间也不乏为了确认一个知识细节,花费数天时间去查阅资料、阅读源码、验证实现。 + +就这样从初春写到深秋,整整7个月,几乎每个工作日的夜晚和周末,都用在了学习、写作和录音上,这个过程虽然很痛苦,但对我来说收获是巨大的。可以说,《Flutter核心技术与实战》这个专栏对我自己也是一个认知重塑的过程。 + +进步很难,其实是因为那些可以让人进步的事情往往都是那些让人焦虑、带来压力的。而人生的高度,可能就在于你怎么面对困难,真正能够减轻焦虑的办法就是走出舒适区,迎难而上,去搞定那些给你带来焦虑和压力的事情,这样人生的高度才能被一点点垫起来。解决问题的过程通常并不是一帆风顺的,这就需要坚持。所谓胜利者,往往是能比别人多坚持一分钟的人。 + +勿畏难,勿轻略,让我们在技术路上继续扩大自己的边界,保持学习,持续成长。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/00导读写给0基础入门的Go语言学习者.md b/专栏/Go语言核心36讲/00导读写给0基础入门的Go语言学习者.md new file mode 100644 index 0000000..4aac40e --- /dev/null +++ b/专栏/Go语言核心36讲/00导读写给0基础入门的Go语言学习者.md @@ -0,0 +1,71 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 导读 写给0基础入门的Go语言学习者 + 你好,我是郝林,今天我分享的内容是:0基础的你,如何开始入门学习Go语言。 + + +你需要遵循怎样的学习路径来学习Go语言? +———————— + + +我们发现,订阅本专栏的同学们都在非常积极的学习和讨论,这让我们非常欣慰,并且和你一样干劲十足。不过,我在留言中发现,大家的基础好像都不太一样,大致可以分为这么几类。 + + +零基础的同学:可能正准备入行或者刚刚对编程感兴趣,可以熟练操作电脑,但是对计算机、操作系统以及网络方面的知识不太了解。 +无编程经验或者编程经验较少的同学:可能正在从事其他的技术相关工作,也许可以熟练编写脚本,但是对程序设计的通用知识和技巧还不太了解。 +有其他语言编程经验的同学:可能已成为程序员或软件工程师,可以用其他的编程语言熟练编写程序,但是对Go语言还不太了解。 +有一定Go语言编程经验的同学:已有Go语言编程基础,写过一些Go语言程序,但是急需进阶却看不清途径。 + + +基于以上分类,我为大家制定了一份Go语言学习路径。不论你属于上面的哪一类,都可以按照此路径去学习深造。具体请看下面的思维导图。 + + + +(长按保存大图) + + +学习本专栏前,你需要有哪些基础知识储备? +———————— + + +在这个专栏里,我会假设你有一定的计算机基础,比如,知道操作系统是什么、环境变量怎么设置、命令行怎样使用,等等。 + +另外,我还会假定你具备一点点编程知识,比如,知道程序是什么、程序通常会以怎样的形式存在,以及程序与操作系统和计算机有哪些关系,等等。 + +对了,还有在这个早已成熟的移动互联网时代,想学编程的你,一定也应该知道那些最最基本的网络知识。 + +我在本专栏里只会讨论Go语言的代码和程序,而不会提及太多计算机体系结构或软件工程方面的事情。所以你即使没有专门学过计算机系统或者软件工程也没有关系,我会尽量连带讲一些必要的基础概念和知识。 + +从2018年开始,随着Google逐渐重回中国,Go语言的官方网站在Google中国的域名下也有了镜像,毕竟中国是Go语言爱好者最多的国家,同时也是Go语言使用最广泛的一片土地。如果你在国内,可以敲入这个网址来访问Go语言的官网。 + +这个专栏专注于Go语言的核心知识,因此我并不会深入说明所有关于语法和命令的细枝末节。如果你想去全面了解Go语言的所有语法,那么可以去Go语言官网的语言规范页面仔细查阅。 + +当然了,这里的语言规范是全英文的,如果你想看汉化的内容也是有选择的,我记得先后有几拨国内的Go语言爱好者自发组织翻译过。不过我都没有仔细看过,不知道质量如何,所以在这里就不特别推荐了。 + +对于从事计算机和软件开发相关工作的同学,我强烈建议你们要有意地训练快速阅读英文文档的能力,不论是否借助字典和翻译工具。 + +不过,如果你想专门学习一下Go命令方面的知识和技巧,那么我推荐你看看我之前写的免费开源教程《Go命令教程》。这份教程的内容虽然稍显陈旧,但是帮助你学会使用Go语言自带的常用命令和工具肯定是没问题的。 + +好了,其实即使你是个编程小白也不用过于担心,我们会一起帮助你的。至于我刚刚说的Go语言规范和Go命令教程,你也可以在学习本专栏的过程中根据实际需要去有针对性的阅读。 + +3.这里有一份基础知识列表,请查收 + +如果你阅读本专栏的第一个模块时感觉有些吃力,那可能是你还没有熟悉Go语言的一些基础概念和知识。我为你精心制作了一张Go语言基础知识的导图,里面几乎包含了入门Go语言所需的所有知识点。 + +- +(长按保存大图) + +有了这些,你是否已经感觉学习本专栏会更加轻松了呢? + +总之,教程、资料和助推就交给我和极客时间的编辑、运营们来共同负责。而你需要做的,就是保存好这一份对Go语言学习的决心,你可以自己去尝试整理一份Go语言的学习笔记,遇见不懂的地方,你也可以在文章下面留言,我们一起讨论。 + +好了,感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/00导读学习专栏的正确姿势.md b/专栏/Go语言核心36讲/00导读学习专栏的正确姿势.md new file mode 100644 index 0000000..6706b0a --- /dev/null +++ b/专栏/Go语言核心36讲/00导读学习专栏的正确姿势.md @@ -0,0 +1,68 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 导读 学习专栏的正确姿势 + 你好,我是郝林,今天我分享的主题是,学习专栏的正确姿势。 + +到了这里,专栏的全部内容已经都推送到你的面前了。如果你已经同步学习完了,那么我要给你点一个大大的赞! + +还没有看完的同学也不要着急,因为推送的速度肯定要比你们的学习速度快上不少。如果是我的话,我肯定无法用很快的速度,去认真地学习和理解专栏内容的。不过,粗读一遍的话,这个时间倒是绰绰有余的。我今天就想跟你聊聊学习专栏的正确姿势。 + +专栏应该怎样学 + +我们做互联网技术的人,应该对这种索引+摘要+详情的数据存取方案并不陌生。我希望我的专栏文章也可以达成这样的一种状态:它是你需要时,即能查阅的知识手册。 + +在第一次听音频或浏览文章的时候,你可以走马观花,并不用去细扣每一个概念和每一句话。让自己对每一个主题、每一个问题和每一个要点都有一个大概的印象就可以了。 + +如此一来,当想到或遇到某方面的疑惑的时候,你就可以有一个大致的方向,并且知道怎样从专栏里找出相应的内容。 + +这就是所谓的粗读,相当于在你的脑袋里面存了一份索引,甚至是一份摘要。利用这种快速的学习方式,你往往可以在有限的精力和无限的知识之间做出适合你的权衡。 + +极客时间可以让我们无限期地查阅专栏的全部内容。所以你完全不用心急,可以按照自己的节奏先粗读、再细读,然后再拿这个专栏当做知识手册来用。重要的是真正的理解和积极的实践,而不是阅读的速度。 + +实践的正确姿势 + +最近一段时间,有不少同学问我说:“老师,我快要学完这个专栏了,也买了你的书,那我后边怎么去实践呢?” + +问我此类问题的同学,大多数都是很少有机会在工作中使用Go语言的程序员,或者是对Go语言感兴趣的互联网领域的从业者,还有一些是在校的大学生。 + +我给大家的第一个建议一般都是“去写网络爬虫吧”。 + +互联网络的世界很庞杂,但又有一定的规律可循,是非常好的技术学习环境。你编写一个网络服务程序,即使放到了公共的网络上,也还需要考虑清楚一系列的问题,才能让你有足够多的技术磨炼机会,比如,服务的种类、功能、规则、安全、界面、受众、宣传和访问途径,以及日常的非技术性维护。 + +我认为,这已经不是纯粹的技术实践了,对于初期的技术技能增长是不利的。当然了,如果你有信心和精力去搞定这一系列问题,并乐于从中学习到各种各样的技能,那就放手去做吧。 + +我在我的书和专栏中一直都在释放这样几个信号:“并发程序”“互联网络”“客户端”“网络爬虫”。这其实就是我们实践的最佳切入点。它成本低,收效明显,既有深度又有广度。 + +有的同学还问我:“我的程序爬取了某某网站,可是只爬了两三下就好像被人家封掉了”。原因很明显,你暴力获取人家的网站内容,肯定会封你的啊。 + +我们要让程序去模拟人的行为,模拟人使用网络浏览器访问网站内容的过程,而不是用尽计算力去疯狂地霸占人家的带宽和服务,否则那不就成了网络攻击了。这是一个非常重要的自我实践的技巧,请大家记住,“利己,但不要损人”。 + +注意,正常爬取网站内容并不意味着失去了高并发的应用场景。把内容下载下来只是一个开始,后边还有不少的工作要做呢。 + +单单“模拟人”这一点就需要花一些心思。而且,你可以同时爬取成千上万的同类甚至不同类的网站。这已经足够你研究和实践很长一段时间了。我在这里还要郑重地提示一下,做这类技术研究一定不要跨越道德的底线,更不能违反法律。 + +再进一步,我们最好以结构化的形式把爬取到的网络内容存储下来。当得到足够多的数据之后,你的选择就很多了。比如,对某类数据进行整理、提取和分析,从而挖掘出更有价值的东西。这就属于数据挖掘的范畴了。 + +在如今这个数据过剩的时代,这也是一项很重要的技能。又比如,基于这些数据提供统一的访问接口,制作成搜索引擎,甚至对外提供服务。这也是一个很有深度的选择。 + +当然,技术实践的方式远不止这些。不过鉴于篇幅,我就先说这么多。 + +优秀Go项目推荐 + +最后,我再给大家推荐一些优秀的Go项目。别忘了,阅读优秀的项目源码也是一个很重要的学习途径。请看下图。 + +- +(长按保存大图查看) + +这幅图包含了我之前私藏的所有高Star,且近期依然活跃的Go项目。不得不说,在Github这个全球最大的程序员交友社区中,好东西真的是不少。 + +在这幅图的左上角,有我对图中各种符号的说明,大家在进一步读图之前需要先看一下。参看这些项目的顺序完全由你自己决定,不过我建议从“贴近你实际工作的那个方面”入手,然后可以是“你感兴趣的方面”,最后有机会再看其他的项目。千万不要贪多,要循序渐进着来。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/00开篇词跟着学,你也能成为Go语言高手.md b/专栏/Go语言核心36讲/00开篇词跟着学,你也能成为Go语言高手.md new file mode 100644 index 0000000..b458906 --- /dev/null +++ b/专栏/Go语言核心36讲/00开篇词跟着学,你也能成为Go语言高手.md @@ -0,0 +1,61 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 跟着学,你也能成为Go语言高手 + 你好,我是郝林。今天想跟你聊聊我和Go语言的故事。 + +Go语言是由Google出品的一门通用型计算机编程语言。作为在近年来快速崛起的编程语言,Go已经成功跻身主流编程语言的行列。 + +它的种种亮点都受到了广大编程爱好者的追捧。特别是一些对团队协作有较高要求的公司和技术团队,已经在有意识地大量使用Go语言编程,并且,使用的人群还在持续迅猛增长。 + +我个人很喜欢Go语言。我是从2012年底开始关注Go语言的,虽然这个日期与Go语言诞生的2009年11月10日相比并不算早,但我也算得上国内比较早期的使用者了。 + +Go程序可以在装有Windows、Linux、FreeBSD等操作系统的服务器上运行,并用于提供基础软件支撑、API服务、Web服务、网页服务等等。 + +Go语言也在移动端进行了积极的探索,现在在Android和iOS上都可以运行其程序。另外,Go语言也已经与WebAssembly强强联合,加入了WASM平台。这意味着过不了多久,互联网浏览器也可以运行Go编写的程序了。 + +从业务维度看,在云计算、微服务、大数据、区块链、物联网等领域,Go语言早已蓬勃发展。有的使用率已经非常之高,有的已有一席之地。即使是在Python为王的数据科学和人工智能领域,Go语言也在缓慢渗透,并初露头角。 + +从公司角度看,许多大厂都已经拥抱Go语言,包括以Java打天下的阿里巴巴,更别提深爱着Go语言的滴滴、今日头条、小米、奇虎360、京东等明星公司。同时,创业公司也很喜欢Go语言,主要因为其入门快、程序库多、运行迅速,很适合快速构建互联网软件产品,比如轻松筹、快手、知乎、探探、美图、猎豹移动等等。 + +我从2013年开始准备撰写《Go并发编程实战》这本书,在经历了一些艰辛和坎坷之后,本书终于在2014年底由人民邮电出版社的图灵公司正式出版。 + +时至今日,《Go并发编程实战》的第2版已经出版一年多了,也受到了广大Go语言爱好者的欢迎。同时,我也发起和维护着一个Go语言爱好者组织GoHackers,至今已有近4000人的规模。我们每年都会举办一些活动,交流技术、互通有无。当然,我们平常都会在一些线上的群组里交流。欢迎你的加入。 + +2015年初,我开始帮助公司和团队招聘Go程序员。我面试过的Go程序员应该已经有几百个了。虽然一场面试的交流内容远不止技术能力这种硬技能,更别提只限于一门编程语言。 + +但是就事论事,我在这里只说Go语言。在所有的应聘者当中,真正掌握Go语言基础知识的比例恐怕超不过50%,而真正熟悉Go语言高阶技术的比例也不超过30%。当然了,情况是明显一年比一年好的,尤其是今年。 + +我写此专栏的初衷是,让希望迅速掌握Go语言的爱好者们,通过一种比较熟悉和友好的路径去学习。我并不想事无巨细地去阐述Go语言规范的每个细节以及其标准库中的每个API,更不想写那种填鸭式的教学文章,我更想去做的是详细论述这门语言的重点和主线。 + +我会努力探究我们对新技能,尤其是编程语言的学习方式,并以这种方式一步步带领和引导你去记忆和实践。我几乎总会以一道简单的题目为引子,并以一连串相关且重要的概念和知识为主线,而后再进行扩充,以助你进行发散性的思考。 + +我希望用这种先点、后线、再面的方式,帮你占领一个个重要的阵地。别的不敢说,如果你认真地跟我一起走完这个专栏,那么基本掌握Go语言是肯定的。 + +为什么说基本掌握?因为软件技术,尤其是编程技术,必须经过很多的实践甚至历练才能完全掌握,这需要时间而不能速成。不过,本专栏一定会成为你学习Go语言最重要的敲门砖和垫脚石。 + +下面,我们一起浏览一下本专栏的主要模块,一共分成3大模块,5个章节。 + + +基础概念:我会讲述Go语言基础中的基础,包括一些基本概念和运作机制。它们都应该是你初识Go语言时必须知道的,同时也有助于你理解后面的知识。 + +数据类型和语句:Go语言中的数据类型大都是很有特色的,你只有理解了它们才能真正玩转Go语言。我将和你一起与探索它们的奥妙。另外,我也会一一揭示怎样使用各种语法和语句操纵它们。 + +Go程序的测试:很多程序员总以为测试是另一个团队的事情,其实不然。单元测试甚至接口测试其实都应该是程序员去做的,并且应该受到重视。在Go语言中怎样做好测试这件事?我会跟你说清楚、讲明白。 + +标准库的用法:虽然Go语言提供了自己的高效并发编程方式,但是同步方法依然不容忽视。这些方法集中在sync代码包及其子包中。这部分还涉及了字节和字符问题、OS操控方法和Web服务写法等,这些都是我们在日常工作中很可能会用到的。 + +Go语言拾遗:这部分将会讲述一些我们使用Go语言做软件项目的过程中很可能会遇到的问题,至少会包含两篇文章,是附赠给广大Go语言爱好者的。虽然我已经有一个计划了,但是具体会讲哪些内容我还是选择暂时保密。请你和我一起小期待一下吧。 + + +我希望本专栏能帮助或推动你去做更多的实践和思考。同时我也希望,你能通过学习本专栏感受到学习的快乐,并能够在应聘Go语言相关岗位的时候更加游刃有余。 + +所以,如果学,请深学。我不敢自称布道师,但很愿意去做推广优秀技术的事情。如果我的输出能为你的宝塔添砖加瓦,那将会是我的快乐之源。我也相信这几十篇文章可以做到这一点。 + + + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/01工作区和GOPATH.md b/专栏/Go语言核心36讲/01工作区和GOPATH.md new file mode 100644 index 0000000..6a60742 --- /dev/null +++ b/专栏/Go语言核心36讲/01工作区和GOPATH.md @@ -0,0 +1,220 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 工作区和GOPATH + +这门课中Go语言的代码比较多,建议你配合文章收听音频。 + + +你好,我是郝林。从今天开始,我将和你一起梳理Go语言的整个知识体系。 + +在过去的几年里,我与广大爱好者一起见证了Go语言的崛起。 + +从Go 1.5版本的自举(即用Go语言编写程序来实现Go语言自身),到Go 1.7版本的极速GC(也称垃圾回收器),再到2018年2月发布的Go 1.10版本对其自带工具的全面升级,以及可预见的后续版本关键特性(比如用来做程序依赖管理的go mod命令),这一切都令我们欢欣鼓舞。Go语言在一步步走向辉煌的同时,显然已经成为软件工程师们最喜爱的编程语言之一。 + +我开办这个专栏的主要目的,是要与你一起探索Go语言的奥秘,并帮助你在学习和实践的过程中获取更多。 + +我假设本专栏的读者已经具备了一定的计算机基础,比如,你要知道操作系统是什么、环境变量怎么设置、怎样正确使用命令行,等等。 + +当然了,如果你已经有了编程经验,尤其是一点点Go语言编程经验,那就更好了,毕竟我想教给你的,都是Go语言中非常核心的技术。 + +如果你对Go语言中最基本的概念和语法还不够了解,那么可能需要在学习本专栏的过程中去查阅Go语言规范文档,也可以把预习篇的基础知识图拿出来好好研究一下。 + +最后,我来说一下专栏的讲述模式。我总会以一道Go语言的面试题开始,针对它进行解答,我会告诉你为什么我要关注这道题,这道题的背后隐藏着哪些知识,并且,我会对这部分的内容,进行相关的知识扩展。 + +好了,准备就绪,我们一起开始。 + + + +我们学习Go语言时,要做的第一件事,都是根据自己电脑的计算架构(比如,是32位的计算机还是64位的计算机)以及操作系统(比如,是Windows还是Linux),从Go语言官网下载对应的二进制包,也就是可以拿来即用的安装包。 + +随后,我们会解压缩安装包、放置到某个目录、配置环境变量,并通过在命令行中输入go version来验证是否安装成功。 + +在这个过程中,我们还需要配置3个环境变量,也就是GOROOT、GOPATH和GOBIN。这里我可以简单介绍一下。 + + +GOROOT:Go语言安装根目录的路径,也就是GO语言的安装路径。 +GOPATH:若干工作区目录的路径。是我们自己定义的工作空间。 +GOBIN:GO程序生成的可执行文件(executable file)的路径。 + + +其中,GOPATH背后的概念是最多的,也是最重要的。那么,今天我们的面试问题是:你知道设置GOPATH有什么意义吗? + +关于这个问题,它的典型回答是这样的: + +你可以把GOPATH简单理解成Go语言的工作目录,它的值是一个目录的路径,也可以是多个目录路径,每个目录都代表Go语言的一个工作区(workspace)。 + +我们需要利用这些工作区,去放置Go语言的源码文件(source file),以及安装(install)后的归档文件(archive file,也就是以“.a”为扩展名的文件)和可执行文件(executable file)。 + +事实上,由于Go语言项目在其生命周期内的所有操作(编码、依赖管理、构建、测试、安装等)基本上都是围绕着GOPATH和工作区进行的。所以,它的背后至少有3个知识点,分别是: + +1. Go语言源码的组织方式是怎样的; + +2.你是否了解源码安装后的结果(只有在安装后,Go语言源码才能被我们或其他代码使用); + +3.你是否理解构建和安装Go程序的过程(这在开发程序以及查找程序问题的时候都很有用,否则你很可能会走弯路)。 + +下面我就重点来聊一聊这些内容。 + +知识扩展 + + +Go语言源码的组织方式 +————— + + +与许多编程语言一样,Go语言的源码也是以代码包为基本组织单位的。在文件系统中,这些代码包其实是与目录一一对应的。由于目录可以有子目录,所以代码包也可以有子包。 + +一个代码包中可以包含任意个以.go为扩展名的源码文件,这些源码文件都需要被声明属于同一个代码包。 + +代码包的名称一般会与源码文件所在的目录同名。如果不同名,那么在构建、安装的过程中会以代码包名称为准。 + +每个代码包都会有导入路径。代码包的导入路径是其他代码在使用该包中的程序实体时,需要引入的路径。在实际使用程序实体之前,我们必须先导入其所在的代码包。具体的方式就是import该代码包的导入路径。就像这样: + +import "github.com/labstack/echo" + + +在工作区中,一个代码包的导入路径实际上就是从src子目录,到该包的实际存储位置的相对路径。 + +所以说,Go语言源码的组织方式就是以环境变量GOPATH、工作区、src目录和代码包为主线的。一般情况下,Go语言的源码文件都需要被存放在环境变量GOPATH包含的某个工作区(目录)中的src目录下的某个代码包(目录)中。 + + +了解源码安装后的结果 +————– + + +了解了Go语言源码的组织方式后,我们很有必要知道Go语言源码在安装后会产生怎样的结果。 + +源码文件以及安装后的结果文件都会放到哪里呢?我们都知道,源码文件通常会被放在某个工作区的src子目录下。 + +那么在安装后如果产生了归档文件(以“.a”为扩展名的文件),就会放进该工作区的pkg子目录;如果产生了可执行文件,就可能会放进该工作区的bin子目录。 + +我再讲一下归档文件存放的具体位置和规则。 + +源码文件会以代码包的形式组织起来,一个代码包其实就对应一个目录。安装某个代码包而产生的归档文件是与这个代码包同名的。 + +放置它的相对目录就是该代码包的导入路径的直接父级。比如,一个已存在的代码包的导入路径是 + +github.com/labstack/echo + + +那么执行命令 + +go install github.com/labstack/echo + + +生成的归档文件的相对目录就是 github.com/labstack ,文件名为echo.a 。 + +顺便说一下,上面这个代码包导入路径还有另外一层含义,那就是:该代码包的源码文件存在于GitHub网站的labstack组的代码仓库echo中。 + +再说回来,归档文件的相对目录与pkg目录之间还有一级目录,叫做平台相关目录。平台相关目录的名称是由build(也称“构建”)的目标操作系统、下划线和目标计算架构的代号组成的。 + +比如,构建某个代码包时的目标操作系统是Linux,目标计算架构是64位的,那么对应的平台相关目录就是linux_amd64。 + +因此,上述代码包的归档文件就会被放置在当前工作区的子目录pkg/linux_amd64/github.com/labstack中。 + +- +(GOPATH与工作区) + +总之,你需要记住的是,某个工作区的src子目录下的源码文件在安装后一般会被放置到当前工作区的pkg子目录下对应的目录中,或者被直接放置到该工作区的bin子目录中。 + + +理解构建和安装Go程序的过程 +—————— + + +我们再来说说构建和安装Go程序的过程都是怎样的,以及它们的异同点。 + +构建使用命令go build,安装使用命令go install。构建和安装代码包的时候都会执行编译、打包等操作,并且,这些操作生成的任何文件都会先被保存到某个临时的目录中。 + +如果构建的是库源码文件,那么操作后产生的结果文件只会存在于临时目录中。这里的构建的主要意义在于检查和验证。 + +如果构建的是命令源码文件,那么操作的结果文件会被搬运到源码文件所在的目录中。(这里讲到的两种源码文件我在[“预习篇”的基础知识图]中提到过,在后面的文章中我也会带你详细了解。) + +安装操作会先执行构建,然后还会进行链接操作,并且把结果文件搬运到指定目录。 + +进一步说,如果安装的是库源码文件,那么结果文件会被搬运到它所在工作区的pkg目录下的某个子目录中。 + +如果安装的是命令源码文件,那么结果文件会被搬运到它所在工作区的bin目录中,或者环境变量GOBIN指向的目录中。 + +这里你需要记住的是,构建和安装的不同之处,以及执行相应命令后得到的结果文件都会出现在哪里。 + +总结 + +工作区和GOPATH的概念和含义是每个Go工程师都需要了解的。虽然它们都比较简单,但是说它们是Go程序开发的核心知识并不为过。 + +然而,我在招聘面试的过程中仍然发现有人忽略掉了它们。Go语言提供的很多工具都是在GOPATH和工作区的基础上运行的,比如上面提到的go build、go install和go get,这三个命令也是我们最常用到的。 + +思考题 + +说到Go程序中的依赖管理,其实还有很多问题值得我们探索。我在这里留下两个问题供你进一步思考。 + + +Go语言在多个工作区中查找依赖包的时候是以怎样的顺序进行的? +如果在多个工作区中都存在导入路径相同的代码包会产生冲突吗? + + +这两个问题之间其实是有一些关联的。答案并不复杂,你做几个试验几乎就可以找到它了。你也可以看一下Go语言标准库中go build包及其子包的源码。那里面的宝藏也很多,可以助你深刻理解Go程序的构建过程。 + + + +补充阅读 + +go build命令一些可选项的用途和用法 + +在运行go build命令的时候,默认不会编译目标代码包所依赖的那些代码包。当然,如果被依赖的代码包的归档文件不存在,或者源码文件有了变化,那它还是会被编译。 + +如果要强制编译它们,可以在执行命令的时候加入标记-a。此时,不但目标代码包总是会被编译,它依赖的代码包也总会被编译,即使依赖的是标准库中的代码包也是如此。 + +另外,如果不但要编译依赖的代码包,还要安装它们的归档文件,那么可以加入标记-i。 + +那么我们怎么确定哪些代码包被编译了呢?有两种方法。 + + +运行go build命令时加入标记-x,这样可以看到go build命令具体都执行了哪些操作。另外也可以加入标记-n,这样可以只查看具体操作而不执行它们。 +运行go build命令时加入标记-v,这样可以看到go build命令编译的代码包的名称。它在与-a标记搭配使用时很有用。 + + +下面再说一说与Go源码的安装联系很紧密的一个命令:go get。 + +命令go get会自动从一些主流公用代码仓库(比如GitHub)下载目标代码包,并把它们安装到环境变量GOPATH包含的第1工作区的相应目录中。如果存在环境变量GOBIN,那么仅包含命令源码文件的代码包会被安装到GOBIN指向的那个目录。 + +最常用的几个标记有下面几种。 + + +-u:下载并安装代码包,不论工作区中是否已存在它们。 +-d:只下载代码包,不安装代码包。 +-fix:在下载代码包后先运行一个用于根据当前Go语言版本修正代码的工具,然后再安装代码包。 +-t:同时下载测试所需的代码包。 +-insecure:允许通过非安全的网络协议下载和安装代码包。HTTP就是这样的协议。 + + +Go语言官方提供的go get命令是比较基础的,其中并没有提供依赖管理的功能。目前GitHub上有很多提供这类功能的第三方工具,比如glide、gb以及官方出品的dep、vgo等等,它们在内部大都会直接使用go get。 + +有时候,我们可能会出于某种目的变更存储源码的代码仓库或者代码包的相对路径。这时,为了让代码包的远程导入路径不受此类变更的影响,我们会使用自定义的代码包导入路径。 + +对代码包的远程导入路径进行自定义的方法是:在该代码包中的库源码文件的包声明语句的右边加入导入注释,像这样: + +package semaphore // import "golang.org/x/sync/semaphore" + + +这个代码包原本的完整导入路径是github.com/golang/sync/semaphore。这与实际存储它的网络地址对应的。该代码包的源码实际存在GitHub网站的golang组的sync代码仓库的semaphore目录下。而加入导入注释之后,用以下命令即可下载并安装该代码包了: + +go get golang.org/x/sync/semaphore + + +而Go语言官网golang.org下的路径/x/sync/semaphore并不是存放semaphore包的真实地址。我们称之为代码包的自定义导入路径。 + +不过,这还需要在golang.org这个域名背后的服务端程序上,添加一些支持才能使这条命令成功。 + +关于自定义代码包导入路径的完整说明可以参看这里。 + +好了,对于go build命令和go get命令的简短介绍就到这里。如果你想查阅更详细的文档,那么可以访问Go语言官方的命令文档页面,或者在命令行下输入诸如go help build这类的命令。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/02命令源码文件.md b/专栏/Go语言核心36讲/02命令源码文件.md new file mode 100644 index 0000000..9d93455 --- /dev/null +++ b/专栏/Go语言核心36讲/02命令源码文件.md @@ -0,0 +1,254 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 命令源码文件 + 我们已经知道,环境变量GOPATH指向的是一个或多个工作区,每个工作区中都会有以代码包为基本组织形式的源码文件。 + +这里的源码文件又分为三种,即:命令源码文件、库源码文件和测试源码文件,它们都有着不同的用途和编写规则。( 我在[“预习篇”的基础知识图]介绍过这三种文件的基本情况。) + + + +(长按保存大图查看) + +今天,我们就沿着命令源码文件的知识点,展开更深层级的学习。 + + + +一旦开始学习用编程语言编写程序,我们就一定希望在编码的过程中及时地得到反馈,只有这样才能清楚对错。实际上,我们的有效学习和进步,都是通过不断地接受反馈和执行修正实现的。 + +对于Go语言学习者来说,你在学习阶段中,也一定会经常编写可以直接运行的程序。这样的程序肯定会涉及命令源码文件的编写,而且,命令源码文件也可以很方便地用go run命令启动。 + +那么,我今天的问题就是:命令源码文件的用途是什么,怎样编写它? + +这里,我给出你一个参考的回答:命令源码文件是程序的运行入口,是每个可独立运行的程序必须拥有的。我们可以通过构建或安装,生成与其对应的可执行文件,后者一般会与该命令源码文件的直接父目录同名。 + +如果一个源码文件声明属于main包,并且包含一个无参数声明且无结果声明的main函数,那么它就是命令源码文件。 就像下面这段代码: + +package main + +import "fmt" + +func main() { + fmt.Println("Hello, world!") +} + + +如果你把这段代码存成demo1.go文件,那么运行go run demo1.go命令后就会在屏幕(标准输出)中看到Hello, world! + + +当需要模块化编程时,我们往往会将代码拆分到多个文件,甚至拆分到不同的代码包中。但无论怎样,对于一个独立的程序来说,命令源码文件永远只会也只能有一个。如果有与命令源码文件同包的源码文件,那么它们也应该声明属于main包。 + + +问题解析 + +命令源码文件如此重要,以至于它毫无疑问地成为了我们学习Go语言的第一助手。不过,只会打印Hello, world是远远不够的,咱们千万不要成为“Hello, world”党。既然决定学习Go语言,你就应该从每一个知识点深入下去。 + +无论是Linux还是Windows,如果你用过命令行(command line)的话,肯定就会知道几乎所有命令(command)都是可以接收参数(argument)的。通过构建或安装命令源码文件,生成的可执行文件就可以被视为“命令”,既然是命令,那么就应该具备接收参数的能力。 + +下面,我就带你深入了解一下与命令参数的接收和解析有关的一系列问题。 + +知识精讲 + +1. 命令源码文件怎样接收参数 + +我们先看一段不完整的代码: + +package main + +import ( + // 需在此处添加代码。[1] + "fmt" +) + +var name string + +func init() { + // 需在此处添加代码。[2] +} + +func main() { + // 需在此处添加代码。[3] + fmt.Printf("Hello, %s!\n", name) +} + + +如果邀请你帮助我,在注释处添加相应的代码,并让程序实现”根据运行程序时给定的参数问候某人”的功能,你会打算怎样做? + +如果你知道做法,请现在就动手实现它。如果不知道也不要着急,咱们一起来搞定。 + +首先,Go语言标准库中有一个代码包专门用于接收和解析命令参数。这个代码包的名字叫flag。 + +我之前说过,如果想要在代码中使用某个包中的程序实体,那么应该先导入这个包。因此,我们需要在[1]处添加代码"flag"。注意,这里应该在代码包导入路径的前后加上英文半角的引号。如此一来,上述代码导入了flag和fmt这两个包。 + +其次,人名肯定是由字符串代表的。所以我们要在[2]处添加调用flag包的StringVar函数的代码。就像这样: + +flag.StringVar(&name, "name", "everyone", "The greeting object.") + + +函数flag.StringVar接受4个参数。 + +第1个参数是用于存储该命令参数值的地址,具体到这里就是在前面声明的变量name的地址了,由表达式&name表示。 + +第2个参数是为了指定该命令参数的名称,这里是name。 + +第3个参数是为了指定在未追加该命令参数时的默认值,这里是everyone。 + +至于第4个函数参数,即是该命令参数的简短说明了,这在打印命令说明时会用到。 + +顺便说一下,还有一个与flag.StringVar函数类似的函数,叫flag.String。这两个函数的区别是,后者会直接返回一个已经分配好的用于存储命令参数值的地址。如果使用它的话,我们就需要把 + +var name string + + +改为 + +var name = flag.String("name", "everyone", "The greeting object.") + + +所以,如果我们使用flag.String函数就需要改动原有的代码。这样并不符合上述问题的要求。 + +再说最后一个填空。我们需要在[3]处添加代码flag.Parse()。函数flag.Parse用于真正解析命令参数,并把它们的值赋给相应的变量。 + +对该函数的调用必须在所有命令参数存储载体的声明(这里是对变量name的声明)和设置(这里是在[2]处对flag.StringVar函数的调用)之后,并且在读取任何命令参数值之前进行。 + +正因为如此,我们最好把flag.Parse()放在main函数的函数体的第一行。 + +2. 怎样在运行命令源码文件的时候传入参数,又怎样查看参数的使用说明 + +如果我们把上述代码存成名为demo2.go的文件,那么运行如下命令就可以为参数name传值: + +go run demo2.go -name="Robert" + + +运行后,打印到标准输出(stdout)的内容会是: + +Hello, Robert! + + +另外,如果想查看该命令源码文件的参数说明,可以这样做: + +$ go run demo2.go --help + + +其中的$表示我们是在命令提示符后运行go run命令的。运行后输出的内容会类似: + +Usage of /var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2: + -name string + The greeting object. (default "everyone") +exit status 2 + + +你可能不明白下面这段输出代码的意思。 + +/var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2 + + +这其实是go run命令构建上述命令源码文件时临时生成的可执行文件的完整路径。 + +如果我们先构建这个命令源码文件再运行生成的可执行文件,像这样: + +$ go build demo2.go +$ ./demo2 --help + + +那么输出就会是 + +Usage of ./demo2: + -name string + The greeting object. (default "everyone") + + +3. 怎样自定义命令源码文件的参数使用说明 + +这有很多种方式,最简单的一种方式就是对变量flag.Usage重新赋值。flag.Usage的类型是func(),即一种无参数声明且无结果声明的函数类型。 + +flag.Usage变量在声明时就已经被赋值了,所以我们才能够在运行命令go run demo2.go --help时看到正确的结果。 + +注意,对flag.Usage的赋值必须在调用flag.Parse函数之前。 + +现在,我们把demo2.go另存为demo3.go,然后在main函数体的开始处加入如下代码。 + +flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", "question") + flag.PrintDefaults() +} + + +那么当运行 + +$ go run demo3.go --help + + +后,就会看到 + +Usage of question: + -name string + The greeting object. (default "everyone") +exit status 2 + + +现在再深入一层,我们在调用flag包中的一些函数(比如StringVar、Parse等等)的时候,实际上是在调用flag.CommandLine变量的对应方法。 + +flag.CommandLine相当于默认情况下的命令参数容器。所以,通过对flag.CommandLine重新赋值,我们可以更深层次地定制当前命令源码文件的参数使用说明。 + +现在我们把main函数体中的那条对flag.Usage变量的赋值语句注销掉,然后在init函数体的开始处添加如下代码: + +flag.CommandLine = flag.NewFlagSet("", flag.ExitOnError) +flag.CommandLine.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", "question") + flag.PrintDefaults() +} + + +再运行命令go run demo3.go --help后,其输出会与上一次的输出的一致。不过后面这种定制的方法更加灵活。比如,当我们把为flag.CommandLine赋值的那条语句改为 + +flag.CommandLine = flag.NewFlagSet("", flag.PanicOnError) + + +后,再运行go run demo3.go --help命令就会产生另一种输出效果。这是由于我们在这里传给flag.NewFlagSet函数的第二个参数值是flag.PanicOnError。flag.PanicOnError和flag.ExitOnError都是预定义在flag包中的常量。 + +flag.ExitOnError的含义是,告诉命令参数容器,当命令后跟--help或者参数设置的不正确的时候,在打印命令参数使用说明后以状态码2结束当前程序。 + +状态码2代表用户错误地使用了命令,而flag.PanicOnError与之的区别是在最后抛出“运行时恐慌(panic)”。 + +上述两种情况都会在我们调用flag.Parse函数时被触发。顺便提一句,“运行时恐慌”是Go程序错误处理方面的概念。关于它的抛出和恢复方法,我在本专栏的后续部分中会讲到。 + +下面再进一步,我们索性不用全局的flag.CommandLine变量,转而自己创建一个私有的命令参数容器。我们在函数外再添加一个变量声明: + +var cmdLine = flag.NewFlagSet("question", flag.ExitOnError) + + +然后,我们把对flag.StringVar的调用替换为对cmdLine.StringVar调用,再把flag.Parse()替换为cmdLine.Parse(os.Args[1:])。 + +其中的os.Args[1:]指的就是我们给定的那些命令参数。这样做就完全脱离了flag.CommandLine。*flag.FlagSet类型的变量cmdLine拥有很多有意思的方法。你可以去探索一下。我就不在这里一一讲述了。 + +这样做的好处依然是更灵活地定制命令参数容器。但更重要的是,你的定制完全不会影响到那个全局变量flag.CommandLine。 + +总结 + +恭喜你!你现在已经走出了Go语言编程的第一步。你可以用Go编写命令,并可以让它们像众多操作系统命令那样被使用,甚至可以把它们嵌入到各种脚本中。 + +虽然我为你讲解了命令源码文件的基本编写方法,并且也谈到了为了让它接受参数而需要做的各种准备工作,但这并不是全部。 + +别担心,我在后面会经常提到它的。另外,如果你想详细了解flag包的用法,可以到这个网址查看文档。或者直接使用godoc命令在本地启动一个Go语言文档服务器。怎样使用godoc命令?你可以参看这里。 + +思考题 + +我们已经见识过为命令源码文件传入字符串类型的参数值的方法,那还可以传入别的吗?这就是今天我留下的思考题。 + + +默认情况下,我们可以让命令源码文件接受哪些类型的参数值? +我们可以把自定义的数据类型作为参数值的类型吗?如果可以,怎样做? + + +你可以通过查阅文档获得第一个问题的答案。记住,快速查看和理解文档是一项必备的技能。 + +至于第二个问题,你回答起来可能会有些困难,因为这涉及了另一个问题:“怎样声明自己的数据类型?”这个问题我在专栏的后续部分中也会讲到。如果是这样,我希望你记下它和这里说的另一问题,并在能解决后者之后再来回答前者。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/03库源码文件.md b/专栏/Go语言核心36讲/03库源码文件.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/Go语言核心36讲/04程序实体的那些事儿(上).md b/专栏/Go语言核心36讲/04程序实体的那些事儿(上).md new file mode 100644 index 0000000..b6b9b3a --- /dev/null +++ b/专栏/Go语言核心36讲/04程序实体的那些事儿(上).md @@ -0,0 +1,206 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 程序实体的那些事儿(上) + 我已经为你打开了Go语言编程之门,并向你展示了“程序从初建到拆分,再到模块化”的基本演化路径。 + +一个编程老手让程序完成基本演化,可能也就需要几十分钟甚至十几分钟,因为他们一开始就会把车开到模块化编程的道路上。我相信,等你真正理解了这个过程之后,也会驾轻就熟的。 + +上述套路是通用的,不是只适用于Go语言。但从本篇开始,我会开始向你介绍Go语言中的各种特性以及相应的编程方法和思想。 + + + +我在讲解那两种源码文件基本编写方法的时候,声明和使用了一些程序实体。你也许已经若有所觉,也许还在云里雾里。没关系,我现在就与你一起梳理这方面的重点。 + +还记得吗?Go语言中的程序实体包括变量、常量、函数、结构体和接口。 Go语言是静态类型的编程语言,所以我们在声明变量或常量的时候,都需要指定它们的类型,或者给予足够的信息,这样才可以让Go语言能够推导出它们的类型。 + + +在Go语言中,变量的类型可以是其预定义的那些类型,也可以是程序自定义的函数、结构体或接口。常量的合法类型不多,只能是那些Go语言预定义的基本类型。它的声明方式也更简单一些。 + + +好了,下面这个简单的问题你需要了解一下。 + +问题:声明变量有几种方式? + +先看段代码。 + +package main + +import ( + "flag" + "fmt" +) + +func main() { + var name string // [1] + flag.StringVar(&name, "name", "everyone", "The greeting object.") // [2] + flag.Parse() + fmt.Printf("Hello, %v!\n", name) +} + + +这是一个很简单的命令源码文件,我把它命名为demo7.go。它是demo2.go的微调版。我只是把变量name的声明和对flag.StringVar函数的调用,都移动到了main函数中,这分别对应代码中的注释[1]和[2]。 + +具体的问题是,除了var name string这种声明变量name的方式,还有其他方式吗?你可以选择性地改动注释[1]和[2]处的代码。 + +典型回答 + +这有几种做法,我在这里只说最典型的两种。 + +第一种方式需要先对注释[2]处的代码稍作改动,把被调用的函数由flag.StringVar改为flag.String,传参的列表也需要随之修改,这是为了[1]和[2]处代码合并的准备工作。 + +var name = flag.String("name", "everyone", "The greeting object.") + + +合并后的代码看起来更简洁一些。我把注释[1]处的代码中的string去掉了,右边添加了一个=,然后再拼接上经过修改的[2]处代码。 + +注意,flag.String函数返回的结果值的类型是*string而不是string。类型*string代表的是字符串的指针类型,而不是字符串类型。因此,这里的变量name代表的是一个指向字符串值的指针。 + +关于Go语言中的指针,我在后面会有专门的介绍。你在这里只需要知道,我们可以通过操作符*把这个指针指向的字符串值取出来了。因此,在这种情况下,那个被用来打印内容的函数调用就需要微调一下,把其中的参数name改为*name,即:fmt.Printf("Hello, %v!\n", *name)。 + +好了,我想你已经基本理解了这行代码中的每一个部分。 + +下面我接着说第二种方式。第二种方式与第一种方式非常类似,它基于第一种方式的代码,赋值符号=右边的代码不动,左边只留下name,再把=变成:=。 + +name := flag.String("name", "everyone", "The greeting object.") + + +问题解析 + +这个问题的基本考点有两个。一个是你要知道Go语言中的类型推断,以及它在代码中的基本体现,另一个是短变量声明的用法。 + +第一种方式中的代码在声明变量name的同时,还为它赋了值,而这时声明中并没有显式指定name的类型。 + +还记得吗?之前的变量声明语句是var name string。这里利用了Go语言自身的类型推断,而省去了对该变量的类型的声明。 + + +简单地说,类型推断是一种编程语言在编译期自动解释表达式类型的能力。什么是表达式?详细的解释你可以参看Go语言规范中的表达式和表达式语句章节。我在这里就不赘述了。 + + +你可以认为,表达式类型就是对表达式进行求值后得到结果的类型。Go语言中的类型推断是很简约的,这也是Go语言整体的风格。 + +它只能用于对变量或常量的初始化,就像上述回答中描述的那样。对flag.String函数的调用其实就是一个调用表达式,而这个表达式的类型是*string,即字符串的指针类型。 + +这也是调用flag.String函数后得到结果的类型。随后,Go语言把这个调用了flag.String函数的表达式类型,直接作为了变量name的类型,这就是“推断”一词所指代的操作了。 + +至于第二种方式所用的短变量声明,实际上就是Go语言的类型推断再加上一点点语法糖。 + +我们只能在函数体内部使用短变量声明。在编写if、for或switch语句的时候,我们经常把它安插在初始化子句中,并用来声明一些临时的变量。而相比之下,第一种方式更加通用,它可以被用在任何地方。 + + + +(变量的多种声明方式) + +短变量声明还有其他的玩法,我稍后就会讲到。 + +知识扩展 + +1. Go语言的类型推断可以带来哪些好处? + +如果面试官问你这个问题,你应该怎样回答? + +当然,在写代码时,我们通过使用Go语言的类型推断,而节省下来的键盘敲击次数几乎可以忽略不计。但它真正的好处,往往会体现在我们写代码之后的那些事情上,比如代码重构。 + +为了更好的演示,我们先要做一点准备工作。我们依然通过调用一个函数在声明name变量的同时为它赋值,但是这个函数不是flag.String,而是由我们自己定义的某个函数,比如叫getTheFlag。 + +package main + +import ( + "flag" + "fmt" +) + +func main() { + var name = getTheFlag() + flag.Parse() + fmt.Printf("Hello, %v!\n", *name) +} + +func getTheFlag() *string { + return flag.String("name", "everyone", "The greeting object.") +} + + +我们可以用getTheFlag函数包裹(或者说包装)那个对flag.String函数的调用,并把其结果直接作为getTheFlag函数的结果,结果的类型是*string。 + +这样一来,var name =右边的表达式,可以变为针对getTheFlag函数的调用表达式了。这实际上是对“声明并赋值name变量的那行代码”的重构。 + + +我们通常把不改变某个程序与外界的任何交互方式和规则,而只改变其内部实现”的代码修改方式,叫做对该程序的重构。重构的对象可以是一行代码、一个函数、一个功能模块,甚至一个软件系统。 + + +好了,在准备工作做完之后,你会发现,你可以随意改变getTheFlag函数的内部实现,及其返回结果的类型,而不用修改main函数中的任何代码。 + +这个命令源码文件依然可以通过编译,并且构建和运行也都不会有问题。也许你能感觉得到,这是一个关于程序灵活性的质变。 + +我们不显式地指定变量name的类型,使得它可以被赋予任何类型的值。也就是说,变量name的类型可以在其初始化时,由其他程序动态地确定。 + +在你改变getTheFlag函数的结果类型之后,Go语言的编译器会在你再次构建该程序的时候,自动地更新变量name的类型。如果你使用过Python或Ruby这种动态类型的编程语言的话,一定会觉得这情景似曾相识。 + +没错,通过这种类型推断,你可以体验到动态类型编程语言所带来的一部分优势,即程序灵活性的明显提升。但在那些编程语言中,这种提升可以说是用程序的可维护性和运行效率换来的。 + +Go语言是静态类型的,所以一旦在初始化变量时确定了它的类型,之后就不可能再改变。这就避免了在后面维护程序时的一些问题。另外,请记住,这种类型的确定是在编译期完成的,因此不会对程序的运行效率产生任何影响。 + +现在,你应该已经对这个问题有一个比较深刻的理解了。 + +如果只用一两句话回答这个问题的话,我想可以是这样的:Go语言的类型推断可以明显提升程序的灵活性,使得代码重构变得更加容易,同时又不会给代码的维护带来额外负担(实际上,它恰恰可以避免散弹式的代码修改),更不会损失程序的运行效率。 + +2. 变量的重声明是什么意思? + +这涉及了短变量声明。通过使用它,我们可以对同一个代码块中的变量进行重声明。 + + +既然说到了代码块,我先来解释一下它。在Go语言中,代码块一般就是一个由花括号括起来的区域,里面可以包含表达式和语句。Go语言本身以及我们编写的代码共同形成了一个非常大的代码块,也叫全域代码块。 + +这主要体现在,只要是公开的全局变量,都可以被任何代码所使用。相对小一些的代码块是代码包,一个代码包可以包含许多子代码包,所以这样的代码块也可以很大。 + +接下来,每个源码文件也都是一个代码块,每个函数也是一个代码块,每个if语句、for语句、switch语句和select语句都是一个代码块。甚至,switch或select语句中的case子句也都是独立的代码块。 + +走个极端,我就在main函数中写一对紧挨着的花括号算不算一个代码块?当然也算,这甚至还有个名词,叫“空代码块”。 + + +回到变量重声明的问题上。其含义是对已经声明过的变量再次声明。变量重声明的前提条件如下。 + + +由于变量的类型在其初始化时就已经确定了,所以对它再次声明时赋予的类型必须与其原本的类型相同,否则会产生编译错误。 + +变量的重声明只可能发生在某一个代码块中。如果与当前的变量重名的是外层代码块中的变量,那么就是另外一种含义了,我在下一篇文章中会讲到。 + +变量的重声明只有在使用短变量声明时才会发生,否则也无法通过编译。如果要在此处声明全新的变量,那么就应该使用包含关键字var的声明语句,但是这时就不能与同一个代码块中的任何变量有重名了。 + +被“声明并赋值”的变量必须是多个,并且其中至少有一个是新的变量。这时我们才可以说对其中的旧变量进行了重声明。 + + +这样来看,变量重声明其实算是一个语法糖(或者叫便利措施)。它允许我们在使用短变量声明时不用理会被赋值的多个变量中是否包含旧变量。可以想象,如果不这样会多写不少代码。 + +我把一个简单的例子写在了“Golang_Puzzlers”项目的puzzlers/article4/q3包中的demo9.go文件中,你可以去看一下。 + +这其中最重要的两行代码如下: + +var err error +n, err := io.WriteString(os.Stdout, "Hello, everyone!\n") + + +我使用短变量声明对新变量n和旧变量err进行了“声明并赋值”,这时也是对后者的重声明。 + +总结 + +在本篇中,我们聚焦于最基本的Go语言程序实体:变量。并详细解说了变量声明和赋值的基本方法,及其背后的重要概念和知识。我们使用关键字var和短变量声明,都可以实现对变量的“声明并赋值”。 + +这两种方式各有千秋,有着各自的特点和适用场景。前者可以被用在任何地方,而后者只能被用在函数或者其他更小的代码块中。 + +不过,通过前者我们无法对已有的变量进行重声明,也就是说它无法处理新旧变量混在一起的情况。不过它们也有一个很重要的共同点,即:基于类型推断,Go语言的类型推断只应用在了对变量或常量的初始化方面。 + +思考题 + +本次的思考题只有一个:如果与当前的变量重名的是外层代码块中的变量,那么这意味着什么? + +这道题对于你来说可能有些难,不过我鼓励你多做几次试验试试,你可以在代码中多写一些打印语句,然后运行它,并记录下每次试验的结果。如果有疑问也一定要写下来,答案将在下篇文章中揭晓。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/05程序实体的那些事儿(中).md b/专栏/Go语言核心36讲/05程序实体的那些事儿(中).md new file mode 100644 index 0000000..7b80253 --- /dev/null +++ b/专栏/Go语言核心36讲/05程序实体的那些事儿(中).md @@ -0,0 +1,158 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 程序实体的那些事儿(中) + 在前文中,我解释过代码块的含义。Go语言的代码块是一层套一层的,就像大圆套小圆。 + +一个代码块可以有若干个子代码块;但对于每个代码块,最多只会有一个直接包含它的代码块(后者可以简称为前者的外层代码块)。 + +这种代码块的划分,也间接地决定了程序实体的作用域。我们今天就来看看它们之间的关系。 + +我先说说作用域是什么?大家都知道,一个程序实体被创造出来,是为了让别的代码引用的。那么,哪里的代码可以引用它呢,这就涉及了它的作用域。 + +我在前面说过,程序实体的访问权限有三种:包级私有的、模块级私有的和公开的。这其实就是Go语言在语言层面,依据代码块对程序实体作用域进行的定义。 + +包级私有和模块级私有访问权限对应的都是代码包代码块,公开的访问权限对应的是全域代码块。然而,这个颗粒度是比较粗的,我们往往需要利用代码块再细化程序实体的作用域。 + +比如,我在一个函数中声明了一个变量,那么在通常情况下,这个变量是无法被这个函数以外的代码引用的。这里的函数就是一个代码块,而变量的作用域被限制在了该代码块中。当然了,还有例外的情况,这部分内容,我留到讲函数的时候再说。 + +总之,请记住,一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。对“高内聚,低耦合”这种程序设计思想的实践,恰恰可以从这里开始。 + +你应该可以通过下面的问题进一步感受代码块和作用域的魅力。 + +今天的问题是:如果一个变量与其外层代码块中的变量重名会出现什么状况? + +我把此题的代码存到了demo10.go文件中了。你可以在“Golang_Puzzlers”项目的puzzlers/article5/q1包中找到它。 + +package main + +import "fmt" + +var block = "package" + +func main() { + block := "function" + { + block := "inner" + fmt.Printf("The block is %s.\n", block) + } + fmt.Printf("The block is %s.\n", block) +} + + +这个命令源码文件中有四个代码块,它们是:全域代码块、main包代表的代码块、main函数代表的代码块,以及在main函数中的一个用花括号包起来的代码块。 + +我在后三个代码块中分别声明了一个名为block的变量,并分别把字符串值"package"、"function"和"inner"赋给了它们。此外,我在后两个代码块的最后分别尝试用fmt.Printf函数打印出“The block is %s.”。这里的“%s”只是为了占位,程序会用block变量的实际值替换掉。 + +具体的问题是:该源码文件中的代码能通过编译吗?如果不能,原因是什么?如果能,运行它后会打印出什么内容? + +典型回答 + +能通过编译。运行后打印出的内容是: + +The block is inner. +The block is function. + + +问题解析 + +初看这道题,你可能会认为它无法通过编译,因为三处代码都声明了相同名称的变量。的确,声明重名的变量是无法通过编译的,用短变量声明对已有变量进行重声明除外,但这只是对于同一个代码块而言的。 + +对于不同的代码块来说,其中的变量重名没什么大不了,照样可以通过编译。即使这些代码块有直接的嵌套关系也是如此,就像demo10.go中的main包代码块、main函数代码块和那个最内层的代码块那样。 + +这样规定显然很方便也很合理,否则我们会每天为了选择变量名而烦恼。但是这会导致另外一个问题,我引用变量时到底用的是哪一个?这也是这道题的第二个考点。 + +这其实有一个很有画面感的查找过程。这个查找过程不只针对于变量,还适用于任何程序实体。如下面所示。 + + +首先,代码引用变量的时候总会最优先查找当前代码块中的那个变量。注意,这里的“当前代码块”仅仅是引用变量的代码所在的那个代码块,并不包含任何子代码块。 +其次,如果当前代码块中没有声明以此为名的变量,那么程序会沿着代码块的嵌套关系,从直接包含当前代码块的那个代码块开始,一层一层地查找。 +一般情况下,程序会一直查到当前代码包代表的代码块。如果仍然找不到,那么Go语言的编译器就会报错了。 + + +还记得吗?如果我们在当前源码文件中导入了其他代码包,那么引用其中的程序实体时,是需要以限定符为前缀的。所以程序在找代表变量未加限定符的名字(即标识符)的时候,是不会去被导入的代码包中查找的。 + + +但有个特殊情况,如果我们把代码包导入语句写成import . "XXX"的形式(注意中间的那个“.”),那么就会让这个“XXX”包中公开的程序实体,被当前源码文件中的代码,视为当前代码包中的程序实体。 + +比如,如果有代码包导入语句import . fmt,那么我们在当前源码文件中引用fmt.Printf函数的时候直接用Printf就可以了。在这个特殊情况下,程序在查找当前源码文件后会先去查用这种方式导入的那些代码包。 + + +好了,当你明白了上述过程之后,再去看demo10.go中的代码。是不是感觉清晰了很多? + +从作用域的角度也可以说,虽然通过var block = "package"声明的变量作用域是整个main代码包,但是在main函数中,它却被那两个同名的变量“屏蔽”了。 + +相似的,虽然main函数首先声明的block的作用域,是整个main函数,但是在最内层的那个代码块中,它却是不可能被引用到的。反过来讲,最内层代码块中的block也不可能被该块之外的代码引用到,这也是打印内容的第二行是“The block is function.”的另一半原因。 + +你现在应该知道了,这道题看似简单,但是它考察以及可延展的范围并不窄。 + +知识扩展 + +不同代码块中的重名变量与变量重声明中的变量区别到底在哪儿? + +为了方便描述,我就把不同代码块中的重名变量叫做“可重名变量”吧。注意,在同一个代码块中不允许出现重名的变量,这违背了Go语言的语法。关于这两者的表象和机理,我们已经讨论得足够充分了。你现在可以说出几条区别?请想一想,然后再看下面的列表。 + + +变量重声明中的变量一定是在某一个代码块内的。注意,这里的“某一个代码块内”并不包含它的任何子代码块,否则就变成了“多个代码块之间”。而可重名变量指的正是在多个代码块之间由相同的标识符代表的变量。 +变量重声明是对同一个变量的多次声明,这里的变量只有一个。而可重名变量中涉及的变量肯定是有多个的。 +不论对变量重声明多少次,其类型必须始终一致,具体遵从它第一次被声明时给定的类型。而可重名变量之间不存在类似的限制,它们的类型可以是任意的。 +如果可重名变量所在的代码块之间,存在直接或间接的嵌套关系,那么它们之间一定会存在“屏蔽”的现象。但是这种现象绝对不会在变量重声明的场景下出现。 + + + + +当然了,我们之前谈论过,对变量进行重声明还有一些前提条件,不过在这里并不是重点。我就不再赘述了。 + +以上4大区别中的第3条需要你再注意一下。既然可重名变量的类型可以是任意的,那么当它们之间存在“屏蔽”时你就更需要注意了。 + +不同类型的值大都有着不同的特性和用法。当你在某一种类型的值上施加只有在其他类型值上才能做的操作时,Go语言编译器一定会告诉你:“这不可以”。 + +这种情况很好,甚至值得庆幸,因为你的程序存在的问题被提前发现了。如若不然,程序没准儿会在运行过程中由此引发很隐晦的问题,让你摸不着头脑。 + +相比之下,那时候排查问题的成本可就太高了。所以,我们应该尽量利用Go语言的语法、规范和命令来约束我们的程序。 + +具体到不同类型的可重名变量的问题上,让我们先来看一下puzzlers/article5/q2包中的源码文件demo11.go。它是一个很典型的例子。 + +package main + +import "fmt" + +var container = []string{"zero", "one", "two"} + +func main() { + container := map[int]string{0: "zero", 1: "one", 2: "two"} + fmt.Printf("The element is %q.\n", container[1]) +} + + +在demo11.go中,有两个都叫做container的变量,分别位于main包代码块和main函数代码块。main包代码块中的变量是切片(slice)类型的,另一个是字典(map)类型的。在main函数的最后,我试图打印出container变量的值中索引为1的那个元素。 + +如果你熟悉这两个类型肯定会知道,在它们的值上我们都可以施加索引表达式,比如container[0]。只要中括号里的整数在有效范围之内(这里是[0, 2]),它就可以把值中的某一个元素取出来。 + +如果container的类型不是数组、切片或字典类型,那么索引表达式就会引发编译错误。这正是利用Go语言语法,帮我们约束程序的一个例子;但是当我们想知道container确切类型的时候,利用索引表达式的方式就不够了。 + +当可重名变量的值被转换成某个接口类型值,或者它们的类型本身就是接口类型的时候,严格的类型检查就很有必要了。至于怎么检查,我们在下篇文章中再讨论。 + +总结 + +我们先讨论了代码块,并且也谈到了它与程序实体的作用域,以及访问权限控制之间的巧妙关系。Go语言本身对程序实体提供了相对粗粒度的访问控制。但我们自己可以利用代码块和作用域精细化控制它们。 + +如果在具有嵌套关系的不同代码块中存在重名的变量,那么我们应该特别小心,它们之间可能会发生“屏蔽”的现象。这样你在不同代码块中引用到变量很可能是不同的。具体的鉴别方式需要参考Go语言查找(代表了程序实体的)标识符的过程。 + +另外,请记住变量重声明与可重名变量之间的区别以及它们的重要特征。其中最容易产生隐晦问题的一点是,可重名变量可以各有各的类型。这时候我们往往应该在真正使用它们之前先对其类型进行检查。利用Go语言的语法、规范和命令做辅助的检查是很好的办法,但有些时候并不充分。 + +思考题 + +我们在讨论Go语言查找标识符时的范围的时候,提到过import . XXX这种导入代码包的方式。这里有个思考题: + +如果通过这种方式导入的代码包中的变量与当前代码包中的变量重名了,那么Go语言是会把它们当做“可重名变量”看待还是会报错呢? + +其实我们写个例子一试便知,但重点是为什么?请你尝试从代码块和作用域的角度解释试验得到的答案。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/06程序实体的那些事儿(下).md b/专栏/Go语言核心36讲/06程序实体的那些事儿(下).md new file mode 100644 index 0000000..34f7ad7 --- /dev/null +++ b/专栏/Go语言核心36讲/06程序实体的那些事儿(下).md @@ -0,0 +1,221 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 程序实体的那些事儿 (下) + 在上一篇文章,我们一直都在围绕着可重名变量,也就是不同代码块中的重名变量,进行了讨论。 + +还记得吗?最后我强调,如果可重名变量的类型不同,那么就需要引起我们的特别关注了,它们之间可能会存在“屏蔽”的现象。 + +必要时,我们需要严格地检查它们的类型,但是怎样检查呢?咱们现在就说。 + +我今天的问题是:怎样判断一个变量的类型? + +我们依然以在上一篇文章中展示过的demo11.go为基础。 + +package main + +import "fmt" + +var container = []string{"zero", "one", "two"} + +func main() { + container := map[int]string{0: "zero", 1: "one", 2: "two"} + fmt.Printf("The element is %q.\n", container[1]) +} + + +那么,怎样在打印其中元素之前,正确判断变量container的类型? + +典型回答 + +答案是使用“类型断言”表达式。具体怎么写呢? + +value, ok := interface{}(container).([]string) + + +这里有一条赋值语句。在赋值符号的右边,是一个类型断言表达式。 + +它包括了用来把container变量的值转换为空接口值的interface{}(container)。 + +以及一个用于判断前者的类型是否为切片类型 []string 的 .([]string)。 + +这个表达式的结果可以被赋给两个变量,在这里由value和ok代表。变量ok是布尔(bool)类型的,它将代表类型判断的结果,true或false。 + +如果是true,那么被判断的值将会被自动转换为[]string类型的值,并赋给变量value,否则value将被赋予nil(即“空”)。 + +顺便提一下,这里的ok也可以没有。也就是说,类型断言表达式的结果,可以只被赋给一个变量,在这里是value。 + +但是这样的话,当判断为否时就会引发异常。 + +这种异常在Go语言中被叫做panic,我把它翻译为运行时恐慌。因为它是一种在Go程序运行期间才会被抛出的异常,而“恐慌”二字是英文Panic的中文直译。 + +除非显式地“恢复”这种“恐慌”,否则它会使Go程序崩溃并停止。所以,在一般情况下,我们还是应该使用带ok变量的写法。 + +问题解析 + +正式说明一下,类型断言表达式的语法形式是x.(T)。其中的x代表要被判断类型的值。这个值当下的类型必须是接口类型的,不过具体是哪个接口类型其实是无所谓的。 + +所以,当这里的container变量类型不是任何的接口类型时,我们就需要先把它转成某个接口类型的值。 + +如果container是某个接口类型的,那么这个类型断言表达式就可以是container.([]string)。这样看是不是清晰一些了? + +在Go语言中,interface{}代表空接口,任何类型都是它的实现类型。我在下个模块,会再讲接口及其实现类型的问题。现在你只要知道,任何类型的值都可以很方便地被转换成空接口的值就行了。 + +这里的具体语法是interface{}(x),例如前面展示的interface{}(container)。 + +你可能会对这里的{}产生疑惑,为什么在关键字interface的右边还要加上这个东西? + +请记住,一对不包裹任何东西的花括号,除了可以代表空的代码块之外,还可以用于表示不包含任何内容的数据结构(或者说数据类型)。 + +比如你今后肯定会遇到的struct{},它就代表了不包含任何字段和方法的、空的结构体类型。 + +而空接口interface{}则代表了不包含任何方法定义的、空的接口类型。 + +当然了,对于一些集合类的数据类型来说,{}还可以用来表示其值不包含任何元素,比如空的切片值[]string{},以及空的字典值map[int]string{}。 + + + +(类型断言表达式) + +我们再向答案的最右边看。圆括号中[]string是一个类型字面量。所谓类型字面量,就是用来表示数据类型本身的若干个字符。 + +比如,string是表示字符串类型的字面量,uint8是表示8位无符号整数类型的字面量。 + +再复杂一些的就是我们刚才提到的[]string,用来表示元素类型为string的切片类型,以及map[int]string,用来表示键类型为int、值类型为string的字典类型。 + +还有更复杂的结构体类型字面量、接口类型字面量,等等。这些描述起来占用篇幅较多,我在后面再说吧。 + +针对当前的这个问题,我写了demo12.go。它是demo11.go的修改版。我在其中分别使用了两种方式来实施类型断言,一种用的是我上面讲到的方式,另一种用的是我们还没讨论过的switch语句,先供你参考。 + +可以看到,当前问题的答案可以只有一行代码。你可能会想,这一行代码解释起来也太复杂了吧? + +千万不要为此烦恼,这其中很大一部分都是一些基本语法和概念,你只要记住它们就好了。但这也正是我要告诉你的,一小段代码可以隐藏很多细节。面试官可以由此延伸到几个方向继续提问。这有点儿像泼墨,可以迅速由点及面。 + +知识扩展 + +问题1. 你认为类型转换规则中有哪些值得注意的地方? + +类型转换表达式的基本写法我已经在前面展示过了。它的语法形式是T(x)。 + +其中的x可以是一个变量,也可以是一个代表值的字面量(比如1.23和struct{}{}),还可以是一个表达式。 + +注意,如果是表达式,那么该表达式的结果只能是一个值,而不能是多个值。在这个上下文中,x可以被叫做源值,它的类型就是源类型,而那个T代表的类型就是目标类型。 + +如果从源类型到目标类型的转换是不合法的,那么就会引发一个编译错误。那怎样才算合法?具体的规则可参见Go语言规范中的转换部分。 + +我们在这里要关心的,并不是那些Go语言编译器可以检测出的问题。恰恰相反,那些在编程语言层面很难检测的东西才是我们应该关注的。 + +很多初学者所说的陷阱(或者说坑),大都源于他们需要了解但却不了解的那些知识和技巧。因此,在这些规则中,我想抛出三个我认为很常用并且非常值得注意的知识点,提前帮你标出一些“陷阱”。 + +首先,对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。 + +比如,之所以uint8(255)可以把无类型的常量255转换为uint8类型的值,是因为255在[0, 255]的范围内。 + +但需要特别注意的是,源整数类型的可表示范围较大,而目标类型的可表示范围较小的情况,比如把值的类型从int16转换为int8。请看下面这段代码: + +var srcInt = int16(-255) +dstInt := int8(srcInt) + + +变量srcInt的值是int16类型的-255,而变量dstInt的值是由前者转换而来的,类型是int8。int16类型的可表示范围可比int8类型大了不少。问题是,dstInt的值是多少? + +首先你要知道,整数在Go语言以及计算机中都是以补码的形式存储的。这主要是为了简化计算机对整数的运算过程。(负数的)补码其实就是原码各位求反再加1。 + +比如,int16类型的值-255的补码是1111111100000001。如果我们把该值转换为int8类型的值,那么Go语言会把在较高位置(或者说最左边位置)上的8位二进制数直接截掉,从而得到00000001。 + +又由于其最左边一位是0,表示它是个正整数,以及正整数的补码就等于其原码,所以dstInt的值就是1。 + +一定要记住,当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可。 + +类似的快刀斩乱麻规则还有:当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉。 + +第二,虽然直接把一个整数值转换为一个string类型的值是可行的,但值得关注的是,被转换的整数值应该可以代表一个有效的Unicode代码点,否则转换的结果将会是"�"(仅由高亮的问号组成的字符串值)。 + +字符'�'的Unicode代码点是U+FFFD。它是Unicode标准中定义的Replacement Character,专用于替换那些未知的、不被认可的以及无法展示的字符。 + +我肯定不会去问“哪个整数值转换后会得到哪个字符串”,这太变态了!但是我会写下: + +string(-1) + + +并询问会得到什么?这可是完全不同的问题啊。由于-1肯定无法代表一个有效的Unicode代码点,所以得到的总会是"�"。在实际工作中,我们在排查问题时可能会遇到�,你需要知道这可能是由于什么引起的。 + +第三个知识点是关于string类型与各种切片类型之间的互转的。 + +你先要理解的是,一个值在从string类型向[]byte类型转换时代表着以UTF-8编码的字符串会被拆分成零散、独立的字节。 + +除了与ASCII编码兼容的那部分字符集,以UTF-8编码的某个单一字节是无法代表一个字符的。 + +string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好 + + +比如,UTF-8编码的三个字节\xe4、\xbd和\xa0合在一起才能代表字符'你',而\xe5、\xa5和\xbd合在一起才能代表字符'好'。 + +其次,一个值在从string类型向[]rune类型转换时代表着字符串会被拆分成一个个Unicode字符。 + +string([]rune{'\u4F60', '\u597D'}) // 你好 + + +当你真正理解了Unicode标准及其字符集和编码方案之后,上面这些内容就会显得很容易了。什么是Unicode标准?我会首先推荐你去它的官方网站一探究竟。 + +问题2. 什么是别名类型?什么是潜在类型? + +我们可以用关键字type声明自定义的各种类型。当然了,这些类型必须在Go语言基本类型和高级类型的范畴之内。在它们当中,有一种被叫做“别名类型”的类型。我们可以像下面这样声明它: + +type MyString = string + + +这条声明语句表示,MyString是string类型的别名类型。顾名思义,别名类型与其源类型的区别恐怕只是在名称上,它们是完全相同的。 + +源类型与别名类型是一对概念,是两个对立的称呼。别名类型主要是为了代码重构而存在的。更详细的信息可参见Go语言官方的文档Proposal: Type Aliases。 + +Go语言内建的基本类型中就存在两个别名类型。byte是uint8的别名类型,而rune是int32的别名类型。 + +一定要注意,如果我这样声明: + +type MyString2 string // 注意,这里没有等号。 + + +MyString2和string就是两个不同的类型了。这里的MyString2是一个新的类型,不同于其他任何类型。 + +这种方式也可以被叫做对类型的再定义。我们刚刚把string类型再定义成了另外一个类型MyString2。 + +- +(别名类型、类型再定义与潜在类型) + +对于这里的类型再定义来说,string可以被称为MyString2的潜在类型。潜在类型的含义是,某个类型在本质上是哪个类型。 + +潜在类型相同的不同类型的值之间是可以进行类型转换的。因此,MyString2类型的值与string类型的值可以使用类型转换表达式进行互转。 + +但对于集合类的类型[]MyString2与[]string来说这样做却是不合法的,因为[]MyString2与[]string的潜在类型不同,分别是[]MyString2和[]string。另外,即使两个不同类型的潜在类型相同,它们的值之间也不能进行判等或比较,它们的变量之间也不能赋值。 + +总结 + +在本篇文章中,我们聚焦于类型。Go语言中的每个变量都是有类型的,我们可以使用类型断言表达式判断变量是哪个类型的。 + +正确使用该表达式需要一些小技巧,比如总是应该把结果赋给两个变量。另外还要保证被判断的变量是接口类型的,这可能会用到类型转换表达式。 + +我们在使用类型转换表达式对变量的类型进行转换的时候,会受到一套规则的严格约束。 + +我们必须关注这套规则中的一些细节,尤其是那些Go语言命令不会帮你检查的细节,否则就会踩进所谓的“陷阱”中。 + +此外,你还应该搞清楚别名类型声明与类型再定义之间的区别,以及由此带来的它们的值在类型转换、判等、比较和赋值操作方面的不同。 + +思考题 + +本篇文章的思考题有两个。 + + +除了上述提及的那些,你还认为类型转换规则中有哪些值得注意的地方? +你能具体说说别名类型在代码重构过程中可以起到哪些作用吗? + + +这些问题的答案都在文中提到的官方文档之中。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/07数组和切片.md b/专栏/Go语言核心36讲/07数组和切片.md new file mode 100644 index 0000000..4e8bb39 --- /dev/null +++ b/专栏/Go语言核心36讲/07数组和切片.md @@ -0,0 +1,176 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 数组和切片 + 从本篇文章开始,我们正式进入了模块2的学习。在这之前,我们已经聊了很多的Go语言和编程方面的基础知识,相信你已经对Go语言的开发环境配置、常用源码文件写法,以及程序实体(尤其是变量)及其相关的各种概念和编程技巧(比如类型推断、变量重声明、可重名变量、类型断言、类型转换、别名类型和潜在类型等)都有了一定的理解。 + +它们都是我认为的Go语言编程基础中比较重要的部分,同时也是后续文章的基石。如果你在后面的学习过程中感觉有些吃力,那可能是基础仍未牢固,可以再回去复习一下。 + + + +我们这次主要讨论Go语言的数组(array)类型和切片(slice)类型。数组和切片有时候会让初学者感到困惑。 + +它们的共同点是都属于集合类的类型,并且,它们的值也都可以用来存储某一种类型的值(或者说元素)。 + +不过,它们最重要的不同是:数组类型的值(以下简称数组)的长度是固定的,而切片类型的值(以下简称切片)是可变长的。 + +数组的长度在声明它的时候就必须给定,并且之后不会再改变。可以说,数组的长度是其类型的一部分。比如,[1]string和[2]string就是两个不同的数组类型。 + +而切片的类型字面量中只有元素的类型,而没有长度。切片的长度可以自动地随着其中元素数量的增长而增长,但不会随着元素数量的减少而减小。 + + + +(数组与切片的字面量) + +我们其实可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。 + + +也正因为如此,Go语言的切片类型属于引用类型,同属引用类型的还有字典类型、通道类型、函数类型等;而Go语言的数组类型则属于值类型,同属值类型的有基础数据类型以及结构体类型。 + +注意,Go语言里不存在像Java等编程语言中令人困惑的“传值或传引用”问题。在Go语言中,我们判断所谓的“传值”或者“传引用”只要看被传递的值的类型就好了。 + +如果传递的值是引用类型的,那么就是“传引用”。如果传递的值是值类型的,那么就是“传值”。从传递成本的角度讲,引用类型的值往往要比值类型的值低很多。 + +我们在数组和切片之上都可以应用索引表达式,得到的都会是某个元素。我们在它们之上也都可以应用切片表达式,也都会得到一个新的切片。 + + +我们通过调用内建函数len,得到数组和切片的长度。通过调用内建函数cap,我们可以得到它们的容量。 + +但要注意,数组的容量永远等于其长度,都是不可变的。切片的容量却不是这样,并且它的变化是有规律可寻的。 + +下面我们就通过一道题来了解一下。我们今天的问题就是:怎样正确估算切片的长度和容量? + +为此,我编写了一个简单的命令源码文件demo15.go。 + +package main + +import "fmt" + +func main() { + // 示例1。 + s1 := make([]int, 5) + fmt.Printf("The length of s1: %d\n", len(s1)) + fmt.Printf("The capacity of s1: %d\n", cap(s1)) + fmt.Printf("The value of s1: %d\n", s1) + s2 := make([]int, 5, 8) + fmt.Printf("The length of s2: %d\n", len(s2)) + fmt.Printf("The capacity of s2: %d\n", cap(s2)) + fmt.Printf("The value of s2: %d\n", s2) +} + + +我描述一下它所做的事情。 + +首先,我用内建函数make声明了一个[]int类型的变量s1。我传给make函数的第二个参数是5,从而指明了该切片的长度。我用几乎同样的方式声明了切片s2,只不过多传入了一个参数8以指明该切片的容量。 + +现在,具体的问题是:切片s1和s2的容量都是多少? + +这道题的典型回答:切片s1和s2的容量分别是5和8。 + +问题解析 + +解析一下这道题。s1的容量为什么是5呢?因为我在声明s1的时候把它的长度设置成了5。当我们用make函数初始化切片时,如果不指明其容量,那么它就会和长度一致。如果在初始化时指明了容量,那么切片的实际容量也就是它了。这也正是s2的容量是8的原因。 + +我们顺便通过s2再来明确下长度、容量以及它们的关系。我在初始化s2代表的切片时,同时也指定了它的长度和容量。 + +我在刚才说过,可以把切片看做是对数组的一层简单的封装,因为在每个切片的底层数据结构中,一定会包含一个数组。数组可以被叫做切片的底层数组,而切片也可以被看作是对数组的某个连续片段的引用。 + +在这种情况下,切片的容量实际上代表了它的底层数组的长度,这里是8。(注意,切片的底层数组等同于我们前面讲到的数组,其长度不可变。) + +现在你需要跟着我一起想象:有一个窗口,你可以通过这个窗口看到一个数组,但是不一定能看到该数组中的所有元素,有时候只能看到连续的一部分元素。 + +现在,这个数组就是切片s2的底层数组,而这个窗口就是切片s2本身。s2的长度实际上指明的就是这个窗口的宽度,决定了你透过s2,可以看到其底层数组中的哪几个连续的元素。 + +由于s2的长度是5,所以你可以看到底层数组中的第1个元素到第5个元素,对应的底层数组的索引范围是[0, 4]。 + +切片代表的窗口也会被划分成一个一个的小格子,就像我们家里的窗户那样。每个小格子都对应着其底层数组中的某一个元素。 + +我们继续拿s2为例,这个窗口最左边的那个小格子对应的正好是其底层数组中的第一个元素,即索引为0的那个元素。因此可以说,s2中的索引从0到4所指向的元素恰恰就是其底层数组中索引从0到4代表的那5个元素。 + +请记住,当我们用make函数或切片值字面量(比如[]int{1, 2, 3})初始化一个切片时,该窗口最左边的那个小格子总是会对应其底层数组中的第1个元素。 + +但是当我们通过切片表达式基于某个数组或切片生成新切片的时候,情况就变得复杂起来了。 + +我们再来看一个例子: + +s3 := []int{1, 2, 3, 4, 5, 6, 7, 8} +s4 := s3[3:6] +fmt.Printf("The length of s4: %d\n", len(s4)) +fmt.Printf("The capacity of s4: %d\n", cap(s4)) +fmt.Printf("The value of s4: %d\n", s4) + + +切片s3中有8个元素,分别是从1到8的整数。s3的长度和容量都是8。然后,我用切片表达式s3[3:6]初始化了切片s4。问题是,这个s4的长度和容量分别是多少? + +这并不难,用减法就可以搞定。首先你要知道,切片表达式中的方括号里的那两个整数都代表什么。我换一种表达方式你也许就清楚了,即:[3, 6)。 + +这是数学中的区间表示法,常用于表示取值范围,我其实已经在本专栏用过好几次了。由此可知,[3:6]要表达的就是透过新窗口能看到的s3中元素的索引范围是从3到5(注意,不包括6)。 + +这里的3可被称为起始索引,6可被称为结束索引。那么s4的长度就是6减去3,即3。因此可以说,s4中的索引从0到2指向的元素对应的是s3及其底层数组中索引从3到5的那3个元素。 + + + +(切片与数组的关系) + +再来看容量。我在前面说过,切片的容量代表了它的底层数组的长度,但这仅限于使用make函数或者切片值字面量初始化切片的情况。 + +更通用的规则是:一个切片的容量可以被看作是透过这个窗口最多可以看到的底层数组中元素的个数。 + +由于s4是通过在s3上施加切片操作得来的,所以s3的底层数组就是s4的底层数组。 + +又因为,在底层数组不变的情况下,切片代表的窗口可以向右扩展,直至其底层数组的末尾。 + +所以,s4的容量就是其底层数组的长度8,减去上述切片表达式中的那个起始索引3,即5。 + +注意,切片代表的窗口是无法向左扩展的。也就是说,我们永远无法透过s4看到s3中最左边的那3个元素。 + +最后,顺便提一下把切片的窗口向右扩展到最大的方法。对于s4来说,切片表达式s4[0:cap(s4)]就可以做到。我想你应该能看懂。该表达式的结果值(即一个新的切片)会是[]int{4, 5, 6, 7, 8},其长度和容量都是5。 + +知识扩展 + +问题1:怎样估算切片容量的增长? + +一旦一个切片无法容纳更多的元素,Go语言就会想办法扩容。但它并不会改变原来的切片,而是会生成一个容量更大的切片,然后将把原有的元素和新元素一并拷贝到新切片中。在一般的情况下,你可以简单地认为新切片的容量(以下简称新容量)将会是原切片容量(以下简称原容量)的2倍。 + +但是,当原切片的长度(以下简称原长度)大于或等于1024时,Go语言将会以原容量的1.25倍作为新容量的基准(以下新容量基准)。新容量基准会被调整(不断地与1.25相乘),直到结果不小于原长度与要追加的元素数量之和(以下简称新长度)。最终,新容量往往会比新长度大一些,当然,相等也是可能的。 + +另外,如果我们一次追加的元素过多,以至于使新长度比原容量的2倍还要大,那么新容量就会以新长度为基准。注意,与前面那种情况一样,最终的新容量在很多时候都要比新容量基准更大一些。更多细节可参见runtime包中slice.go文件里的growslice及相关函数的具体实现。 + +我把展示上述扩容策略的一些例子都放到了demo16.go文件中。你可以去试运行看看。 + +问题 2:切片的底层数组什么时候会被替换? + +确切地说,一个切片的底层数组永远不会被替换。为什么?虽然在扩容的时候Go语言一定会生成新的底层数组,但是它也同时生成了新的切片。 + +它只是把新的切片作为了新底层数组的窗口,而没有对原切片,及其底层数组做任何改动。 + +请记住,在无需扩容时,append函数返回的是指向原底层数组的原切片,而在需要扩容时,append函数返回的是指向新底层数组的新切片。所以,严格来讲,“扩容”这个词用在这里虽然形象但并不合适。不过鉴于这种称呼已经用得很广泛了,我们也没必要另找新词了。 + +顺便说一下,只要新长度不会超过切片的原容量,那么使用append函数对其追加元素的时候就不会引起扩容。这只会使紧邻切片窗口右边的(底层数组中的)元素被新的元素替换掉。你可以运行demo17.go文件以增强对这些知识的理解。 + +总结 + +总结一下,我们今天一起探讨了数组和切片以及它们之间的关系。切片是基于数组的,可变长的,并且非常轻快。一个切片的容量总是固定的,而且一个切片也只会与某一个底层数组绑定在一起。 + +此外,切片的容量总会是在切片长度和底层数组长度之间的某一个值,并且还与切片窗口最左边对应的元素在底层数组中的位置有关系。那两个分别用减法计算切片长度和容量的方法你一定要记住。 + +另外,如果新的长度比原有切片的容量还要大,那么底层数组就一定会是新的,而且append函数也会返回一个新的切片。还有,你其实不必太在意切片“扩容”策略中的一些细节,只要能够理解它的基本规律并可以进行近似的估算就可以了。 + +思考题 + +这里仍然是聚焦于切片的问题。 + + +如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么? +怎样沿用“扩容”的思想对切片进行“缩容”?请写出代码。 + + +这两个问题都是开放性的,你需要认真思考一下。最好在动脑的同时动动手。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/08container包中的那些容器.md b/专栏/Go语言核心36讲/08container包中的那些容器.md new file mode 100644 index 0000000..5a560db --- /dev/null +++ b/专栏/Go语言核心36讲/08container包中的那些容器.md @@ -0,0 +1,158 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 container包中的那些容器 + 我们在上次讨论了数组和切片,当我们提到数组的时候,往往会想起链表。那么Go语言的链表是什么样的呢? + +Go语言的链表实现在标准库的container/list代码包中。这个代码包中有两个公开的程序实体——List和Element,List实现了一个双向链表(以下简称链表),而Element则代表了链表中元素的结构。 + +那么,我今天的问题是:可以把自己生成的Element类型值传给链表吗? + +我们在这里用到了List的四种方法。 + +MoveBefore方法和MoveAfter方法,它们分别用于把给定的元素移动到另一个元素的前面和后面。 + +MoveToFront方法和MoveToBack方法,分别用于把给定的元素移动到链表的最前端和最后端。 + +在这些方法中,“给定的元素”都是*Element类型的,*Element类型是Element类型的指针类型,*Element的值就是元素的指针。 + +func (l *List) MoveBefore(e, mark *Element) +func (l *List) MoveAfter(e, mark *Element) + +func (l *List) MoveToFront(e *Element) +func (l *List) MoveToBack(e *Element) + + +具体问题是,如果我们自己生成这样的值,然后把它作为“给定的元素”传给链表的方法,那么会发生什么?链表会接受它吗? + +这里,给出一个典型回答:不会接受,这些方法将不会对链表做出任何改动。因为我们自己生成的Element值并不在链表中,所以也就谈不上“在链表中移动元素”。更何况链表不允许我们把自己生成的Element值插入其中。 + +问题解析 + +在List包含的方法中,用于插入新元素的那些方法都只接受interface{}类型的值。这些方法在内部会使用Element值,包装接收到的新元素。 + +这样做正是为了避免直接使用我们自己生成的元素,主要原因是避免链表的内部关联,遭到外界破坏,这对于链表本身以及我们这些使用者来说都是有益的。 + +List的方法还有下面这几种: + +Front和Back方法分别用于获取链表中最前端和最后端的元素,- +InsertBefore和InsertAfter方法分别用于在指定的元素之前和之后插入新元素,PushFront和PushBack方法则分别用于在链表的最前端和最后端插入新元素。 + +func (l *List) Front() *Element +func (l *List) Back() *Element + +func (l *List) InsertBefore(v interface{}, mark *Element) *Element +func (l *List) InsertAfter(v interface{}, mark *Element) *Element + +func (l *List) PushFront(v interface{}) *Element +func (l *List) PushBack(v interface{}) *Element + + +这些方法都会把一个Element值的指针作为结果返回,它们就是链表留给我们的安全“接口”。拿到这些内部元素的指针,我们就可以去调用前面提到的用于移动元素的方法了。 + +知识扩展 + +1. 问题:为什么链表可以做到开箱即用? + +List和Element都是结构体类型。结构体类型有一个特点,那就是它们的零值都会是拥有特定结构,但是没有任何定制化内容的值,相当于一个空壳。值中的字段也都会被分别赋予各自类型的零值。 + + +广义来讲,所谓的零值就是只做了声明,但还未做初始化的变量被给予的缺省值。每个类型的零值都会依据该类型的特性而被设定。 + +比如,经过语句var a [2]int声明的变量a的值,将会是一个包含了两个0的整数数组。又比如,经过语句var s []int声明的变量s的值将会是一个[]int类型的、值为nil的切片。 + + +那么经过语句var l list.List声明的变量l的值将会是什么呢?[1] 这个零值将会是一个长度为0的链表。这个链表持有的根元素也将会是一个空壳,其中只会包含缺省的内容。那这样的链表我们可以直接拿来使用吗? + +答案是,可以的。这被称为“开箱即用”。Go语言标准库中很多结构体类型的程序实体都做到了开箱即用。这也是在编写可供别人使用的代码包(或者说程序库)时,我们推荐遵循的最佳实践之一。那么,语句var l list.List声明的链表l可以直接使用,这是怎么做到的呢? + +关键在于它的“延迟初始化”机制。 + +所谓的延迟初始化,你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗。 + +例如,如果我们需要集中声明非常多的大容量切片的话,那么那时的CPU和内存空间的使用量肯定都会一个激增,并且只有设法让其中的切片及其底层数组被回收,内存使用量才会有所降低。 + +如果数组是可以被延迟初始化的,那么计算量和存储空间的压力就可以被分散到实际使用它们的时候。这些数组被实际使用的时间越分散,延迟初始化带来的优势就会越明显。 + + +实际上,Go语言的切片就起到了延迟初始化其底层数组的作用,你可以想一想为什么会这么说的理由。 + +延迟初始化的缺点恰恰也在于“延后”。你可以想象一下,如果我在调用链表的每个方法的时候,它们都需要先去判断链表是否已经被初始化,那这也会是一个计算量上的浪费。在这些方法被非常频繁地调用的情况下,这种浪费的影响就开始显现了,程序的性能将会降低。 + + +在这里的链表实现中,一些方法是无需对是否初始化做判断的。比如Front方法和Back方法,一旦发现链表的长度为0,直接返回nil就好了。 + +又比如,在用于删除元素、移动元素,以及一些用于插入元素的方法中,只要判断一下传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以了。 + +如果不相等,就一定说明传入的元素不是这个链表中的,后续的操作就不用做了。反之,就一定说明这个链表已经被初始化了。 + +原因在于,链表的PushFront方法、PushBack方法、PushBackList方法以及PushFrontList方法总会先判断链表的状态,并在必要时进行初始化,这就是延迟初始化。 + +而且,我们在向一个空的链表中添加新元素的时候,肯定会调用这四个方法中的一个,这时新元素中指向所属链表的指针,一定会被设定为当前链表的指针。所以,指针相等是链表已经初始化的充分必要条件。 + +明白了吗?List利用了自身以及Element在结构上的特点,巧妙地平衡了延迟初始化的优缺点,使得链表可以开箱即用,并且在性能上可以达到最优。 + +问题 2:Ring与List的区别在哪儿? + +container/ring包中的Ring类型实现的是一个循环链表,也就是我们俗称的环。其实List在内部就是一个循环链表。它的根元素永远不会持有任何实际的元素值,而该元素的存在就是为了连接这个循环链表的首尾两端。 + +所以也可以说,List的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。那么,既然Ring和List在本质上都是循环链表,那它们到底有什么不同呢? + +最主要的不同有下面几种。 + + +Ring类型的数据结构仅由它自身即可代表,而List类型则需要由它以及Element类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。 +一个Ring类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个List类型的值则代表了一个完整的链表。这是表示维度上的不同。 +在创建并初始化一个Ring值的时候,我们可以指定它包含的元素的数量,但是对于一个List值来说却不能这样做(也没有必要这样做)。循环链表一旦被创建,其长度是不可变的。这是两个代码包中的New函数在功能上的不同,也是两个类型在初始化值方面的第一个不同。 +仅通过var r ring.Ring语句声明的r将会是一个长度为1的循环链表,而List类型的零值则是一个长度为0的链表。别忘了List中的根元素不会持有实际元素值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同。 +Ring值的Len方法的算法复杂度是O(N)的,而List值的Len方法的算法复杂度则是O(1)的。这是两者在性能方面最显而易见的差别。 + + +其他的不同基本上都是方法方面的了。比如,循环链表也有用于插入、移动或删除元素的方法,不过用起来都显得更抽象一些,等等。 + +总结 + +我们今天主要讨论了container/list包中的链表实现。我们详细讲解了链表的一些主要的使用技巧和实现特点。由于此链表实现在内部就是一个循环链表,所以我们还把它与container/ring包中的循环链表实现做了一番比较,包括结构、初始化以及性能方面。 + +思考题 + + +container/ring包中的循环链表的适用场景都有哪些? +你使用过container/heap包中的堆吗?它的适用场景又有哪些呢? + + +在这里,我们先不求对它们的实现了如指掌,能用对、用好才是我们进阶之前的第一步。好了,感谢你的收听,我们下次再见。 + + + +[1]:List这个结构体类型有两个字段,一个是Element类型的字段root,另一个是int类型的字段len。顾名思义,前者代表的就是那个根元素,而后者用于存储链表的长度。注意,它们都是包级私有的,也就是说使用者无法查看和修改它们。 + +像前面那样声明的l,其字段root和len都会被赋予相应的零值。len的零值是0,正好可以表明该链表还未包含任何元素。由于root是Element类型的,所以它的零值就是该类型的空壳,用字面量表示的话就是Element{}。 + +Element类型包含了几个包级私有的字段,分别用于存储前一个元素、后一个元素以及所属链表的指针值。另外还有一个名叫Value的公开的字段,该字段的作用就是持有元素的实际值,它是interface{}类型的。在Element类型的零值中,这些字段的值都会是nil。 + +参考阅读 + +切片与数组的比较 + +切片本身有着占用内存少和创建便捷等特点,但它的本质上还是数组。切片的一大好处是可以让我们通过窗口快速地定位并获取,或者修改底层数组中的元素。 + +不过,当我们想删除切片中的元素的时候就没那么简单了。元素复制一般是免不了的,就算只删除一个元素,有时也会造成大量元素的移动。这时还要注意空出的元素槽位的“清空”,否则很可能会造成内存泄漏。 + +另一方面,在切片被频繁“扩容”的情况下,新的底层数组会不断产生,这时内存分配的量以及元素复制的次数可能就很可观了,这肯定会对程序的性能产生负面的影响。 + +尤其是当我们没有一个合理、有效的”缩容“策略的时候,旧的底层数组无法被回收,新的底层数组中也会有大量无用的元素槽位。过度的内存浪费不但会降低程序的性能,还可能会使内存溢出并导致程序崩溃。 + +由此可见,正确地使用切片是多么的重要。不过,一个更重要的事实是,任何数据结构都不是银弹。不是吗?数组的自身特点和适用场景都非常鲜明,切片也是一样。它们都是Go语言原生的数据结构,使用起来也都很方便.不过,你的集合类工具箱中不应该只有它们。这就是我们使用链表的原因。 + +不过,对比来看,一个链表所占用的内存空间,往往要比包含相同元素的数组所占内存大得多。这是由于链表的元素并不是连续存储的,所以相邻的元素之间需要互相保存对方的指针。不但如此,每个元素还要存有它所属链表的指针。 + +有了这些关联,链表的结构反倒更简单了。它只持有头部元素(或称为根元素)基本上就可以了。当然了,为了防止不必要的遍历和计算,链表的长度记录在内也是必须的。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/09字典的操作和约束.md b/专栏/Go语言核心36讲/09字典的操作和约束.md new file mode 100644 index 0000000..397cc08 --- /dev/null +++ b/专栏/Go语言核心36讲/09字典的操作和约束.md @@ -0,0 +1,162 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 字典的操作和约束 + 至今为止,我们讲过的集合类的高级数据类型都属于针对单一元素的容器。 + +它们或用连续存储,或用互存指针的方式收纳元素,这里的每个元素都代表了一个从属某一类型的独立值。 + +我们今天要讲的字典(map)却不同,它能存储的不是单一值的集合,而是键值对的集合。 + + +什么是键值对?它是从英文key-value pair直译过来的一个词。顾名思义,一个键值对就代表了一对键和值。 + +注意,一个“键”和一个“值”分别代表了一个从属于某一类型的独立值,把它们两个捆绑在一起就是一个键值对了。 + + +在Go语言规范中,应该是为了避免歧义,他们将键值对换了一种称呼,叫做:“键-元素对”。我们也沿用这个看起来更加清晰的词来讲解。 + +知识前导:为什么字典的键类型会受到约束? + +Go语言的字典类型其实是一个哈希表(hash table)的特定实现,在这个实现中,键和元素的最大不同在于,键的类型是受限的,而元素却可以是任意类型的。 + +如果要探究限制的原因,我们就先要了解哈希表中最重要的一个过程:映射。 + +你可以把键理解为元素的一个索引,我们可以在哈希表中通过键查找与它成对的那个元素。 + +键和元素的这种对应关系,在数学里就被称为“映射”,这也是“map”这个词的本意,哈希表的映射过程就存在于对键-元素对的增、删、改、查的操作之中。 + +aMap := map[string]int{ + "one": 1, + "two": 2, + "three": 3, +} +k := "two" +v, ok := aMap[k] +if ok { + fmt.Printf("The element of key %q: %d\n", k, v) +} else { + fmt.Println("Not found!") +} + + +比如,我们要在哈希表中查找与某个键值对应的那个元素值,那么我们需要先把键值作为参数传给这个哈希表。 + +哈希表会先用哈希函数(hash function)把键值转换为哈希值。哈希值通常是一个无符号的整数。一个哈希表会持有一定数量的桶(bucket),我们也可以叫它哈希桶,这些哈希桶会均匀地储存其所属哈希表收纳的键-元素对。 + +因此,哈希表会先用这个键哈希值的低几位去定位到一个哈希桶,然后再去这个哈希桶中,查找这个键。 + +由于键-元素对总是被捆绑在一起存储的,所以一旦找到了键,就一定能找到对应的元素值。随后,哈希表就会把相应的元素值作为结果返回。 + +只要这个键-元素对存在哈希表中就一定会被查找到,因为哈希表增、改、删键-元素对时的映射过程,与前文所述如出一辙。 + +现在我们知道了,映射过程的第一步就是:把键值转换为哈希值。 + +在Go语言的字典中,每一个键值都是由它的哈希值代表的。也就是说,字典不会独立存储任何键的值,但会独立存储它们的哈希值。 + +你是不是隐约感觉到了什么?我们接着往下看。 + +我们今天的问题是:字典的键类型不能是哪些类型? + +这个问题你可以在Go语言规范中找到答案,但却没那么简单。它的典型回答是:Go语言字典的键类型不可以是函数类型、字典类型和切片类型。 + +问题解析 + +我们来解析一下这个问题。 + +Go语言规范规定,在键类型的值之间必须可以施加操作符==和!=。换句话说,键类型的值必须要支持判等操作。由于函数类型、字典类型和切片类型的值并不支持判等操作,所以字典的键类型不能是这些类型。 + +另外,如果键的类型是接口类型的,那么键值的实际类型也不能是上述三种类型,否则在程序运行过程中会引发panic(即运行时恐慌)。 + +我们举个例子: + +var badMap2 = map[interface{}]int{ + "1": 1, + []int{2}: 2, // 这里会引发panic。 + 3: 3, +} + + +这里的变量badMap2的类型是键类型为interface{}、值类型为int的字典类型。这样声明并不会引起什么错误。或者说,我通过这样的声明躲过了Go语言编译器的检查。 + +注意,我用字面量在声明该字典的同时对它进行了初始化,使它包含了三个键-元素对。其中第二个键-元素对的键值是[]int{2},元素值是2。这样的键值也不会让Go语言编译器报错,因为从语法上说,这样做是可以的。 + +但是,当我们运行这段代码的时候,Go语言的运行时(runtime)系统就会发现这里的问题,它会抛出一个panic,并把根源指向字面量中定义第二个键-元素对的那一行。我们越晚发现问题,修正问题的成本就会越高,所以最好不要把字典的键类型设定为任何接口类型。如果非要这么做,请一定确保代码在可控的范围之内。 + +还要注意,如果键的类型是数组类型,那么还要确保该类型的元素类型不是函数类型、字典类型或切片类型。 + +比如,由于类型[1][]string的元素类型是[]string,所以它就不能作为字典类型的键类型。另外,如果键的类型是结构体类型,那么还要保证其中字段的类型的合法性。无论不合法的类型被埋藏得有多深,比如map[[1][2][3][]string]int,Go语言编译器都会把它揪出来。 + +你可能会有疑问,为什么键类型的值必须支持判等操作?我在前面说过,Go语言一旦定位到了某一个哈希桶,那么就会试图在这个桶中查找键值。具体是怎么找的呢? + +首先,每个哈希桶都会把自己包含的所有键的哈希值存起来。Go语言会用被查找键的哈希值与这些哈希值逐个对比,看看是否有相等的。如果一个相等的都没有,那么就说明这个桶中没有要查找的键值,这时Go语言就会立刻返回结果了。 + +如果有相等的,那就再用键值本身去对比一次。为什么还要对比?原因是,不同值的哈希值是可能相同的。这有个术语,叫做“哈希碰撞”。 + +所以,即使哈希值一样,键值也不一定一样。如果键类型的值之间无法判断相等,那么此时这个映射的过程就没办法继续下去了。最后,只有键的哈希值和键值都相等,才能说明查找到了匹配的键-元素对。 + +以上内容涉及的示例都在demo18.go中。 + +知识扩展 + +问题1:应该优先考虑哪些类型作为字典的键类型? + +你现在已经清楚了,在Go语言中,有些类型的值是支持判等的,有些是不支持的。那么在这些值支持判等的类型当中,哪些更适合作为字典的键类型呢? + +这里先抛开我们使用字典时的上下文,只从性能的角度看。在前文所述的映射过程中,“把键值转换为哈希值”以及“把要查找的键值与哈希桶中的键值做对比”, 明显是两个重要且比较耗时的操作。 + +因此,可以说,求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。 + +对于所有的基本类型、指针类型,以及数组类型、结构体类型和接口类型,Go语言都有一套算法与之对应。这套算法中就包含了哈希和判等。以求哈希的操作为例,宽度越小的类型速度通常越快。对于布尔类型、整数类型、浮点数类型、复数类型和指针类型来说都是如此。对于字符串类型,由于它的宽度是不定的,所以要看它的值的具体长度,长度越短求哈希越快。 + +类型的宽度是指它的单个值需要占用的字节数。比如,bool、int8和uint8类型的一个值需要占用的字节数都是1,因此这些类型的宽度就都是1。 + +以上说的都是基本类型,再来看高级类型。对数组类型的值求哈希实际上是依次求得它的每个元素的哈希值并进行合并,所以速度就取决于它的元素类型以及它的长度。细则同上。 + +与之类似,对结构体类型的值求哈希实际上就是对它的所有字段值求哈希并进行合并,所以关键在于它的各个字段的类型以及字段的数量。而对于接口类型,具体的哈希算法,则由值的实际类型决定。 + +我不建议你使用这些高级数据类型作为字典的键类型,不仅仅是因为对它们的值求哈希,以及判等的速度较慢,更是因为在它们的值中存在变数。 + +比如,对一个数组来说,我可以任意改变其中的元素值,但在变化前后,它却代表了两个不同的键值。 + +对于结构体类型的值情况可能会好一些,因为如果我可以控制其中各字段的访问权限的话,就可以阻止外界修改它了。把接口类型作为字典的键类型最危险。 + +还记得吗?如果在这种情况下Go运行时系统发现某个键值不支持判等操作,那么就会立即抛出一个panic。在最坏的情况下,这足以使程序崩溃。 + +那么,在那些基本类型中应该优先选择哪一个?答案是,优先选用数值类型和指针类型,通常情况下类型的宽度越小越好。如果非要选择字符串类型的话,最好对键值的长度进行额外的约束。 + +那什么是不通常的情况?笼统地说,Go语言有时会对字典的增、删、改、查操作做一些优化。 + +比如,在字典的键类型为字符串类型的情况下;又比如,在字典的键类型为宽度为4或8的整数类型的情况下。 + +问题2:在值为nil的字典上执行读操作会成功吗,那写操作呢? + +好了,为了避免烧脑太久,我们再来说一个简单些的问题。由于字典是引用类型,所以当我们仅声明而不初始化一个字典类型的变量的时候,它的值会是nil。 + +在这样一个变量上试图通过键值获取对应的元素值,或者添加键-元素对,会成功吗?这个问题虽然简单,但却是我们必须铭记于心的,因为这涉及程序运行时的稳定性。 + +我来说一下答案。除了添加键-元素对,我们在一个值为nil的字典上做任何操作都不会引起错误。当我们试图在一个值为nil的字典中添加键-元素对的时候,Go语言的运行时系统就会立即抛出一个panic。你可以运行一下demo19.go文件试试看。 + +总结 + +我们这次主要讨论了与字典类型有关的,一些容易让人困惑的问题。比如,为什么字典的键类型会受到约束?又比如,我们通常应该选取什么样的类型作为字典的键类型。 + +我以Go语言规范为起始,并以Go语言源码为依据回答了这些问题。认真看了这篇文章之后,你应该对字典中的映射过程有了一定的理解。 + +另外,对于Go语言在那些合法的键类型上所做的求哈希和判等的操作,你也应该有所了解了。 + +再次强调,永远要注意那些可能引发panic的操作,比如像一个值为nil的字典添加键-元素对。 + +思考题 + +今天的思考题是关于并发安全性的。更具体地说,在同一时间段内但在不同的goroutine(或者说go程)中对同一个值进行操作是否是安全的。这里的安全是指,该值不会因这些操作而产生混乱,或其它不可预知的问题。 + +具体的思考题是:字典类型的值是并发安全的吗?如果不是,那么在我们只在字典上添加或删除键-元素对的情况下,依然不安全吗?感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/10通道的基本操作.md b/专栏/Go语言核心36讲/10通道的基本操作.md new file mode 100644 index 0000000..21ec977 --- /dev/null +++ b/专栏/Go语言核心36讲/10通道的基本操作.md @@ -0,0 +1,179 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 通道的基本操作 + 作为Go语言最有特色的数据类型,通道(channel)完全可以与goroutine(也可称为go程)并驾齐驱,共同代表Go语言独有的并发编程模式和编程哲学。 + + +Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。) + + +这是作为Go语言的主要创造者之一的Rob Pike的至理名言,这也充分体现了Go语言最重要的编程理念。而通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个goroutine之间传递数据。 + +前导内容:通道的基础知识 + +通道类型的值本身就是并发安全的,这也是Go语言自带的、唯一一个可以满足并发安全性的类型。它使用起来十分简单,并不会徒增我们的心智负担。 + +在声明并初始化一个通道的时候,我们需要用到Go语言的内建函数make。就像用make初始化切片那样,我们传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量。 + +在声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型,这决定了我们可以通过这个通道传递什么类型的数据。 + +比如,类型字面量chan int,其中的chan是表示通道类型的关键字,而int则说明了该通道类型的元素类型。又比如,chan string代表了一个元素类型为string的通道类型。 + +在初始化通道的时候,make函数除了必须接收这样的类型字面量作为参数,还可以接收一个int类型的参数。 + +后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是int类型的,但是它是不能小于0的。 + +当容量为0时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。而当容量大于0时,我们可以称为缓冲通道,也就是带有缓冲的通道。非缓冲通道和缓冲通道有着不同的数据传递方式,这个我在后面会讲到。 + +一个通道相当于一个先进先出(FIFO)的队列。也就是说,通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。元素值的发送和接收都需要用到操作符<-。我们也可以叫它接送操作符。一个左尖括号紧接着一个减号形象地代表了元素值的传输方向。 + +package main + +import "fmt" + +func main() { + ch1 := make(chan int, 3) + ch1 <- 2 + ch1 <- 1 + ch1 <- 3 + elem1 := <-ch1 + fmt.Printf("The first element received from channel ch1: %v\n", + elem1) +} + + +在demo20.go文件中,我声明并初始化了一个元素类型为int、容量为3的通道ch1,并用三条语句,向该通道先后发送了三个元素值2、1和3。 + +这里的语句需要这样写:依次敲入通道变量的名称(比如ch1)、接送操作符<-以及想要发送的元素值(比如2),并且这三者之间最好用空格进行分割。 + +这显然表达了“这个元素值将被发送该通道”这个语义。由于该通道的容量为3,所以,我可以在通道不包含任何元素值的时候,连续地向该通道发送三个值,此时这三个值都会被缓存在通道之中。 + +当我们需要从通道接收元素值的时候,同样要用接送操作符<-,只不过,这时需要把它写在变量名的左边,用于表达“要从该通道接收一个元素值”的语义。 + +比如:<-ch1,这也可以被叫做接收表达式。在一般情况下,接收表达式的结果将会是通道中的一个元素值。 + +如果我们需要把如此得来的元素值存起来,那么在接收表达式的左边就需要依次添加赋值符号(=或:=)和用于存值的变量的名字。因此,语句elem1 := <-ch1会将最先进入ch1的元素2接收来并存入变量elem1。 + +现在我们来看一道与此有关的题目。今天的问题是:对通道的发送和接收操作都有哪些基本的特性? + +这个问题的背后隐藏着很多的知识点,我们来看一下典型回答。 + +它们的基本特性如下。 + + +对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。 +发送操作和接收操作中对元素值的处理都是不可分割的。 +发送操作在完全完成之前会被阻塞。接收操作也是如此。 + + +问题解析 + +我们先来看第一个基本特性。 在同一时刻,Go语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。 + +直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。 + +类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。 + +直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。 + +这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的goroutine之中,并有机会在同一个时间段内被执行。 + +另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。 + +这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。 + +另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,第一步是生成正在通道中的这个元素值的副本,并准备给到接收方,第二步是删除在通道中的这个元素值。 + +顺着这个细节再来看第二个基本特性。 这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。 + +例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。 + +又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。 + +这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。 + +再来说第三个基本特性。 一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。 + +在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。 + +更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的goroutine,以使它去争取继续运行代码的机会。 + +另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。 + +在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的goroutine收到了运行时系统的通知并重新获得运行机会为止。 + +说到这里,你可能已经感觉到,如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。 + +下面我来说一个关于通道操作阻塞的问题。 + +知识扩展 + +问题1:发送操作和接收操作在什么时候可能被长时间的阻塞? + +先说针对缓冲通道的情况。如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。 + +这时,通道会优先通知最早因此而等待的、那个发送操作所在的goroutine,后者会再次执行发送操作。 + +由于发送操作在这种情况下被阻塞后,它们所在的goroutine会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。 + +相对的,如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的goroutine,并使它再次执行接收操作。 + +因此而等待的、所有接收操作所在的goroutine,都会按照先后顺序被放入通道内部的接收等待队列。 + +对于非缓冲通道,情况要简单一些。无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。 + +并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。相比之下,缓冲通道则在用异步的方式传递数据。 + +在大多数情况下,缓冲通道会作为收发双方的中间件。正如前文所述,元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。 + +但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。 + +以上说的都是在正确使用通道的前提下会发生的事情。下面我特别说明一下,由于错误使用通道而造成的阻塞。 + +对于值为nil的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的goroutine中的任何代码,都不再会被执行。 + +注意,由于通道类型是引用类型,所以它的零值就是nil。换句话说,当我们只声明该类型的变量但没有用make函数对它进行初始化时,该变量的值就会是nil。我们一定不要忘记初始化通道! + +你可以去看一下demo21.go,我在里面用代码罗列了一下会造成阻塞的几种情况。 + +问题2:发送操作和接收操作在什么时候会引发panic? + +对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发panic。但是通道一旦关闭,再对它进行发送操作,就会引发panic。 + +另外,如果我们试图关闭一个已经关闭了的通道,也会引发panic。注意,接收操作是可以感知到通道的关闭的,并能够安全退出。 + +更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定bool类型。它的值如果为false就说明通道已经关闭,并且再没有元素值可取了。 + +注意,如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是true。 + +因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的。 + +由于通道的收发操作有上述特性,所以除非有特殊的保障措施,我们千万不要让接收方关闭通道,而应当让发送方做这件事。这在demo22.go中有一个简单的模式可供参考。 + +总结 + +今天我们讲到了通道的一些常规操作,包括初始化、发送、接收和关闭。通道类型是Go语言特有的,所以你一开始肯定会感到陌生,其中的一些规则和奥妙还需要你铭记于心,并细心体会。 + +首先是在初始化通道时设定其容量的意义,这有时会让通道拥有不同的行为模式。对通道的发送操作和接收操作都有哪些基本特性,也是我们必须清楚的。 + +这涉及了它们什么时候会互斥,什么时候会造成阻塞,什么时候会引起panic,以及它们收发元素值的顺序是怎样的,它们是怎样保证元素值的完整性的,元素值通常会被复制几次,等等。 + +最后别忘了,通道也是Go语言的并发编程模式中重要的一员。 + +思考题 + +我希望你能通过试验获得下述问题的答案。 + + +通道的长度代表着什么?它在什么时候会通道的容量相同? +元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢? + + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/11通道的高级玩法.md b/专栏/Go语言核心36讲/11通道的高级玩法.md new file mode 100644 index 0000000..f9843a3 --- /dev/null +++ b/专栏/Go语言核心36讲/11通道的高级玩法.md @@ -0,0 +1,233 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 通道的高级玩法 + 我们已经讨论过了通道的基本操作以及背后的规则。今天,我再来讲讲通道的高级玩法。 + +首先来说说单向通道。我们在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。 + +所谓单向通道就是,只能发不能收,或者只能收不能发的通道。一个通道是双向的,还是单向的是由它的类型字面量体现的。 + +还记得我们在上篇文章中说过的接收操作符<-吗?如果我们把它用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。 + +比如: + +var uselessChan = make(chan<- int, 1) + + +我声明并初始化了一个名叫uselessChan的变量。这个变量的类型是chan<- int,容量是1。 + +请注意紧挨在关键字chan右边的那个<-,这表示了这个通道是单向的,并且只能发而不能收。 + +类似的,如果这个操作符紧挨在chan的左边,那么就说明该通道只能收不能发。所以,前者可以被简称为发送通道,后者可以被简称为接收通道。 + +注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。 + +从上述变量的名字上你也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪儿呢? + +问题:单向通道有什么应用价值? + +你可以先自己想想,然后再接着往下看。 + +典型回答 + +概括地说,单向通道最主要的用途就是约束其他代码的行为。 + +问题解析 + +这需要从两个方面讲,都跟函数的声明有些关系。先来看下面的代码: + +func SendInt(ch chan<- int) { + ch <- rand.Intn(1000) +} + + +我用func关键字声明了一个叫做SendInt的函数。这个函数只接受一个chan<- int类型的参数。在这个函数中的代码只能向参数ch发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。 + +你可能会问,我自己写的函数自己肯定能确定操作通道的方式,为什么还要再约束?好吧,这个例子可能过于简单了。在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫Notifier的接口类型声明: + +type Notifier interface { + SendInt(ch chan<- int) +} + + +在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表。 + +一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束。 + +在这里,Notifier接口中的SendInt方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的SendInt方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。 + +顺便说一下,我们在调用SendInt函数的时候,只需要把一个元素类型匹配的双向通道传给它就行了,没必要用发送通道,因为Go语言在这种情况下会自动地把双向通道转换为函数所需的单向通道。 + +intChan1 := make(chan int, 3) +SendInt(intChan1) + + +在另一个方面,我们还可以在函数声明的结果列表中使用单向通道。如下所示: + +func getIntChan() <-chan int { + num := 5 + ch := make(chan int, num) + for i := 0; i < num; i++ { + ch <- i + } + close(ch) + return ch +} + + +函数getIntChan会返回一个<-chan int类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。这实际上就是对函数调用方的一种约束了。 + +另外,我们在Go语言中还可以声明函数类型,如果我们在函数类型中使用了单向通道,那么就相等于在约束所有实现了这个函数类型的函数。 + +我们再顺便看一下调用getIntChan的代码: + +intChan2 := getIntChan() +for elem := range intChan2 { + fmt.Printf("The element in intChan2: %v\n", elem) +} + + +我把调用getIntChan得到的结果值赋给了变量intChan2,然后用for语句循环地取出了该通道中的所有元素值,并打印出来。 + +这里的for语句也可以被称为带有range子句的for语句。它的用法我在后面讲for语句的时候专门说明。现在你只需要知道关于它的三件事: + + +上述for语句会不断地尝试从通道intChan2中取出元素值。即使intChan2已经被关闭了,它也会在取出所有剩余的元素值之后再结束执行。 +通常,当通道intChan2中没有元素值时,这条for语句会被阻塞在有for关键字的那一行,直到有新的元素值可取。不过,由于这里的getIntChan函数会事先将intChan2关闭,所以它在取出intChan2中的所有元素值之后会直接结束执行。 +倘若通道intChan2的值为nil,那么这条for语句就会被永远地阻塞在有for关键字的那一行。 + + +这就是带range子句的for语句与通道的联用方式。不过,它是一种用途比较广泛的语句,还可以被用来从其他一些类型的值中获取元素。除此之外,Go语言还有一种专门为了操作通道而存在的语句:select语句。 + +知识扩展 + +问题1:select语句与通道怎样联用,应该注意些什么? + +select语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。 + +select语句的分支分为两种,一种叫做候选分支,另一种叫做默认分支。候选分支总是以关键字case开头,后跟一个case表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中时需要执行的语句。 + +默认分支其实就是default case,因为,当且仅当没有候选分支被选中时它才会被执行,所以它以关键字default开头并直接后跟一个冒号。同样的,我们可以在default:的下一行写入要执行的语句。 + +由于select语句是专为通道而设计的,所以每个case表达式中都只能包含操作通道的表达式,比如接收表达式。 + +当然,如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明。下面展示一个简单的例子。 + +// 准备好几个通道。 +intChannels := [3]chan int{ + make(chan int, 1), + make(chan int, 1), + make(chan int, 1), +} +// 随机选择一个通道,并向它发送元素值。 +index := rand.Intn(3) +fmt.Printf("The index: %d\n", index) +intChannels[index] <- index +// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。 +select { +case <-intChannels[0]: + fmt.Println("The first candidate case is selected.") +case <-intChannels[1]: + fmt.Println("The second candidate case is selected.") +case elem := <-intChannels[2]: + fmt.Printf("The third candidate case is selected, the element is %d.\n", elem) +default: + fmt.Println("No candidate case is selected!") +} + + +我先准备好了三个类型为chan int、容量为1的通道,并把它们存入了一个叫做intChannels的数组。 + +然后,我随机选择一个范围在[0, 2]的整数,把它作为索引在上述数组中选择一个通道,并向其中发送一个元素值。 + +最后,我用一个包含了三个候选分支的select语句,分别尝试从上述三个通道中接收元素值,哪一个通道中有值,哪一个对应的候选分支就会被执行。后面还有一个默认分支,不过在这里它是不可能被选中的。 + +在使用select语句的时候,我们首先需要注意下面几个事情。 + + +如果像上述示例那样加入了默认分支,那么无论涉及通道操作的表达式是否有阻塞,select语句都不会被阻塞。如果那几个表达式都阻塞了,或者说都没有满足求值的条件,那么默认分支就会被选中并执行。 +如果没有加入默认分支,那么一旦所有的case表达式都没有满足求值条件,那么select语句就会被阻塞。直到至少有一个case表达式满足条件为止。 +还记得吗?我们可能会因为通道关闭了,而直接从通道接收到一个其元素类型的零值。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。 +select语句只能对其中的每一个case表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过在for语句中嵌入select语句的方式实现。但这时要注意,简单地在select语句的分支中使用break语句,只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。这种错误的用法可能会让这个for语句无休止地运行下去。 + + +下面是一个简单的示例。 + +intChan := make(chan int, 1) +// 一秒后关闭通道。 +time.AfterFunc(time.Second, func() { + close(intChan) +}) +select { +case _, ok := <-intChan: + if !ok { + fmt.Println("The candidate case is closed.") + break + } + fmt.Println("The candidate case is selected.") +} + + +我先声明并初始化了一个叫做intChan的通道,然后通过time包中的AfterFunc函数约定在一秒钟之后关闭该通道。 + +后面的select语句只有一个候选分支,我在其中利用接收表达式的第二个结果值对intChan通道是否已关闭做了判断,并在得到肯定结果后,通过break语句立即结束当前select语句的执行。 + +这个例子以及前面那个例子都可以在demo24.go文件中被找到。你应该运行下,看看结果如何。 + +上面这些注意事项中的一部分涉及到了select语句的分支选择规则。我觉得很有必要再专门整理和总结一下这些规则。 + +问题2:select语句的分支选择规则都有哪些? + +规则如下面所示。 + + + +对于每一个case表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果case表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的case表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。- + + +select语句包含的候选分支中的case表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在select语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。- + + +对于每一个case表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case表达式的求值就是不成功的。在这种情况下,我们可以说,这个case表达式所在的候选分支是不满足选择条件的。- + + +仅当select语句中的所有case表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么select语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,select语句(或者说它所在的goroutine)就会被唤醒,这个候选分支就会被执行。- + + +如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使select语句是在被唤醒时发现的这种情况,也会这样做。- + + +一条select语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。- + +select语句的每次执行,包括case表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的case表达式以及分支中,是否包含并发不安全的代码了。 + + +我把与以上规则相关的示例放在demo25.go文件中了。你一定要去试运行一下,然后尝试用上面的规则去解释它的输出内容。 + +总结 + +今天,我们先讲了单向通道的表示方法,操作符“<-”仍然是关键。如果只用一个词来概括单向通道存在的意义的话,那就是“约束”,也就是对代码的约束。 + +我们可以使用带range子句的for语句从通道中获取数据,也可以通过select语句操纵通道。 + +select语句是专门为通道而设计的,它可以包含若干个候选分支,每个分支中的case表达式都会包含针对某个通道的发送或接收操作。 + +当select语句被执行时,它会根据一套分支选择规则选中某一个分支并执行其中的代码。如果所有的候选分支都没有被选中,那么默认分支(如果有的话)就会被执行。注意,发送和接收操作的阻塞是分支选择规则的一个很重要的依据。 + +思考题 + +今天的思考题都由上述内容中的线索延伸而来。 + + +如果在select语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支? +在select语句与for语句联用时,怎样直接退出外层的for语句? + + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/12使用函数的正确姿势.md b/专栏/Go语言核心36讲/12使用函数的正确姿势.md new file mode 100644 index 0000000..d5aa01c --- /dev/null +++ b/专栏/Go语言核心36讲/12使用函数的正确姿势.md @@ -0,0 +1,257 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 使用函数的正确姿势 + 在前几期文章中,我们分了几次,把Go语言自身提供的,所有集合类的数据类型都讲了一遍,额外还讲了标准库的container包中的几个类型。 + +在几乎所有主流的编程语言中,集合类的数据类型都是最常用和最重要的。我希望通过这几次的讨论,能让你对它们的运用更上一层楼。 + +从今天开始,我会开始向你介绍使用Go语言进行模块化编程时,必须了解的知识,这包括几个重要的数据类型以及一些模块化编程的技巧。首先我们需要了解的是Go语言的函数以及函数类型。 + + + +前导内容:函数是一等的公民 + +在Go语言中,函数可是一等的(first-class)公民,函数类型也是一等的数据类型。这是什么意思呢? + +简单来说,这意味着函数不但可以用于封装代码、分割功能、解耦逻辑,还可以化身为普通的值,在其他函数间传递、赋予变量、做类型判断和转换等等,就像切片和字典的值那样。 + +而更深层次的含义就是:函数值可以由此成为能够被随意传播的独立逻辑组件(或者说功能模块)。 + +对于函数类型来说,它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也借此变成了可被热替换的逻辑组件。比如,我在demo26.go文件中是这样写的: + +package main + +import "fmt" + +type Printer func(contents string) (n int, err error) + +func printToStd(contents string) (bytesNum int, err error) { + return fmt.Println(contents) +} + +func main() { + var p Printer + p = printToStd + p("something") +} + + +这里,我先声明了一个函数类型,名叫Printer。 + +注意这里的写法,在类型声明的名称右边的是func关键字,我们由此就可知道这是一个函数类型的声明。 + +在func右边的就是这个函数类型的参数列表和结果列表。其中,参数列表必须由圆括号包裹,而只要结果列表中只有一个结果声明,并且没有为它命名,我们就可以省略掉外围的圆括号。 + +书写函数签名的方式与函数声明的是一致的。只是紧挨在参数列表左边的不是函数名称,而是关键字func。这里函数名称和func互换了一下位置而已。 + + +函数的签名其实就是函数的参数列表和结果列表的统称,它定义了可用来鉴别不同函数的那些特征,同时也定义了我们与函数交互的方式。 + + +注意,各个参数和结果的名称不能算作函数签名的一部分,甚至对于结果声明来说,没有名称都可以。 + +只要两个函数的参数列表和结果列表中的元素顺序及其类型是一致的,我们就可以说它们是一样的函数,或者说是实现了同一个函数类型的函数。 + +严格来说,函数的名称也不能算作函数签名的一部分,它只是我们在调用函数时,需要给定的标识符而已。 + +我在下面声明的函数printToStd的签名与Printer的是一致的,因此前者是后者的一个实现,即使它们的名称以及有的结果名称是不同的。 + +通过main函数中的代码,我们就可以证实这两者的关系了,我顺利地把printToStd函数赋给了Printer类型的变量p,并且成功地调用了它。 + +总之,“函数是一等的公民”是函数式编程(functional programming)的重要特征。Go语言在语言层面支持了函数式编程。我们下面的问题就与此有关。 + +今天的问题是:怎样编写高阶函数? + +先来说说什么是高阶函数?简单地说,高阶函数可以满足下面的两个条件: + +1. 接受其他的函数作为参数传入;- +2. 把其他的函数作为结果返回。 + +只要满足了其中任意一个特点,我们就可以说这个函数是一个高阶函数。高阶函数也是函数式编程中的重要概念和特征。 + +具体的问题是,我想通过编写calculate函数来实现两个整数间的加减乘除运算,但是希望两个整数和具体的操作都由该函数的调用方给出,那么,这样一个函数应该怎样编写呢。 + +典型回答 + +首先,我们来声明一个名叫operate的函数类型,它有两个参数和一个结果,都是int类型的。 + +type operate func(x, y int) int + + +然后,我们编写calculate函数的签名部分。这个函数除了需要两个int类型的参数之外,还应该有一个operate类型的参数。 + +该函数的结果应该有两个,一个是int类型的,代表真正的操作结果,另一个应该是error类型的,因为如果那个operate类型的参数值为nil,那么就应该直接返回一个错误。 + + +顺便说一下,函数类型属于引用类型,它的值可以为nil,而这种类型的零值恰恰就是nil。 + + +func calculate(x int, y int, op operate) (int, error) { + if op == nil { + return 0, errors.New("invalid operation") + } + return op(x, y), nil +} + + +calculate函数实现起来就很简单了。我们需要先用卫述语句检查一下参数,如果operate类型的参数op为nil,那么就直接返回0和一个代表了具体错误的error类型值。 + + +卫述语句是指被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块执行的语句。在Go语言中,if 语句常被作为卫述语句。 + + +如果检查无误,那么就调用op并把那两个操作数传给它,最后返回op返回的结果和代表没有错误发生的nil。 + +问题解析 + +其实只要你搞懂了“函数是一等的公民”这句话背后的含义,这道题就会很简单。我在上面已经讲过了,希望你已经清楚了。我在上一个例子中展示了其中一点,即:把函数作为一个普通的值赋给一个变量。 + +在这道题中,我问的其实是怎样实现另一点,即:让函数在其他函数间传递。 + +在答案中,calculate函数的其中一个参数是operate类型的,而且后者就是一个函数类型。在调用calculate函数的时候,我们需要传入一个operate类型的函数值。这个函数值应该怎么写? + +只要它的签名与operate类型的签名一致,并且实现得当就可以了。我们可以像上一个例子那样先声明好一个函数,再把它赋给一个变量,也可以直接编写一个实现了operate类型的匿名函数。 + +op := func(x, y int) int { + return x + y +} + + +calculate函数就是一个高阶函数。但是我们说高阶函数的特点有两个,而该函数只展示了其中一个特点,即:接受其他的函数作为参数传入。 + +那另一个特点,把其他的函数作为结果返回。这又是怎么玩的呢?你可以看看我在demo27.go文件中声明的函数类型calculateFunc和函数genCalculator。其中,genCalculator函数的唯一结果的类型就是calculateFunc。 + +这里先给出使用它们的代码。 + +x, y = 56, 78 +add := genCalculator(op) +result, err = add(x, y) +fmt.Printf("The result: %d (error: %v)\n", result, err) + + +你可以自己写出calculateFunc类型和genCalculator函数的实现吗?你可以动手试一试 + +知识扩展 + +问题1:如何实现闭包? + +闭包又是什么?你可以想象一下,在一个函数中存在对外来标识符的引用。所谓的外来标识符,既不代表当前函数的任何参数或结果,也不是函数内部声明的,它是直接从外边拿过来的。 + +还有个专门的术语称呼它,叫自由变量,可见它代表的肯定是个变量。实际上,如果它是个常量,那也就形成不了闭包了,因为常量是不可变的程序实体,而闭包体现的却是由“不确定”变为“确定”的一个过程。 + +我们说的这个函数(以下简称闭包函数)就是因为引用了自由变量,而呈现出了一种“不确定”的状态,也叫“开放”状态。 + +也就是说,它的内部逻辑并不是完整的,有一部分逻辑需要这个自由变量参与完成,而后者到底代表了什么在闭包函数被定义的时候却是未知的。 + +即使对于像Go语言这种静态类型的编程语言而言,我们在定义闭包函数的时候最多也只能知道自由变量的类型。 + +在我们刚刚提到的genCalculator函数内部,实际上就实现了一个闭包,而genCalculator函数也是一个高阶函数。 + +func genCalculator(op operate) calculateFunc { + return func(x int, y int) (int, error) { + if op == nil { + return 0, errors.New("invalid operation") + } + return op(x, y), nil + } +} + + +genCalculator函数只做了一件事,那就是定义一个匿名的、calculateFunc类型的函数并把它作为结果值返回。 + +而这个匿名的函数就是一个闭包函数。它里面使用的变量op既不代表它的任何参数或结果也不是它自己声明的,而是定义它的genCalculator函数的参数,所以是一个自由变量。 + +这个自由变量究竟代表了什么,这一点并不是在定义这个闭包函数的时候确定的,而是在genCalculator函数被调用的时候确定的。 + +只有给定了该函数的参数op,我们才能知道它返回给我们的闭包函数可以用于什么运算。 + +看到if op == nil {那一行了吗?Go语言编译器读到这里时会试图去寻找op所代表的东西,它会发现op代表的是genCalculator函数的参数,然后,它会把这两者联系起来。这时可以说,自由变量op被“捕获”了。 + +当程序运行到这里的时候,op就是那个参数值了。如此一来,这个闭包函数的状态就由“不确定”变为了“确定”,或者说转到了“闭合”状态,至此也就真正地形成了一个闭包。 + +看出来了吗?我们在用高阶函数实现闭包。这也是高阶函数的一大功用。 + + + +(高阶函数与闭包) + +那么,实现闭包的意义又在哪里呢?表面上看,我们只是延迟实现了一部分程序逻辑或功能而已,但实际上,我们是在动态地生成那部分程序逻辑。 + +我们可以借此在程序运行的过程中,根据需要生成功能不同的函数,继而影响后续的程序行为。这与GoF设计模式中的“模板方法”模式有着异曲同工之妙,不是吗? + +问题2:传入函数的那些参数值后来怎么样了? + +让我们把目光再次聚焦到函数本身。我们先看一个示例。 + +package main + +import "fmt" + +func main() { + array1 := [3]string{"a", "b", "c"} + fmt.Printf("The array: %v\n", array1) + array2 := modifyArray(array1) + fmt.Printf("The modified array: %v\n", array2) + fmt.Printf("The original array: %v\n", array1) +} + +func modifyArray(a [3]string) [3]string { + a[1] = "x" + return a +} + + +这个命令源码文件(也就是demo28.go)在运行之后会输出什么?这是我常出的一道考题。 + +我在main函数中声明了一个数组array1,然后把它传给了函数modify,modify对参数值稍作修改后将其作为结果值返回。main函数中的代码拿到这个结果之后打印了它(即array2),以及原来的数组array1。关键问题是,原数组会因modify函数对参数值的修改而改变吗? + +答案是:原数组不会改变。为什么呢?原因是,所有传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。 + +由于数组是值类型,所以每一次复制都会拷贝它,以及它的所有元素值。我在modify函数中修改的只是原数组的副本而已,并不会对原数组造成任何影响。 + +注意,对于引用类型,比如:切片、字典、通道,像上面那样复制它们的值,只会拷贝它们本身而已,并不会拷贝它们引用的底层数据。也就是说,这时只是浅表复制,而不是深层复制。 + +以切片值为例,如此复制的时候,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值和容量值,而它的底层数组并不会被拷贝。 + +另外还要注意,就算我们传入函数的是一个值类型的参数值,但如果这个参数值中的某个元素是引用类型的,那么我们仍然要小心。 + +比如: + +complexArray1 := [3][]string{ + []string{"d", "e", "f"}, + []string{"g", "h", "i"}, + []string{"j", "k", "l"}, +} + + +变量complexArray1是[3][]string类型的,也就是说,虽然它是一个数组,但是其中的每个元素又都是一个切片。这样一个值被传入函数的话,函数中对该参数值的修改会影响到complexArray1本身吗?我想,这可以留作今天的思考题。 + +总结 + +我们今天主要聚焦于函数的使用手法。在Go语言中,函数可是一等的(first-class)公民。它既可以被独立声明,也可以被作为普通的值来传递或赋予变量。除此之外,我们还可以在其他函数的内部声明匿名函数并把它直接赋给变量。 + +你需要记住Go语言是怎样鉴别一个函数的,函数的签名在这里起到了至关重要的作用。 + +函数是Go语言支持函数式编程的主要体现。我们可以通过“把函数传给函数”以及“让函数返回函数”来编写高阶函数,也可以用高阶函数来实现闭包,并以此做到部分程序逻辑的动态生成。 + +我们在最后还说了一下关于函数传参的一个注意事项,这很重要,可能会关系到程序的稳定和安全。 + +一个相关的原则是:既不要把你程序的细节暴露给外界,也尽量不要让外界的变动影响到你的程序。你可以想想这个原则在这里可以起到怎样的指导作用。 + +思考题 + +今天我给你留下两道思考题。 + + +complexArray1被传入函数的话,这个函数中对该参数值的修改会影响到它的原值吗? +函数真正拿到的参数值其实只是它们的副本,那么函数返回给调用方的结果值也会被复制吗? + + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/13结构体及其方法的使用法门.md b/专栏/Go语言核心36讲/13结构体及其方法的使用法门.md new file mode 100644 index 0000000..c31d431 --- /dev/null +++ b/专栏/Go语言核心36讲/13结构体及其方法的使用法门.md @@ -0,0 +1,252 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 结构体及其方法的使用法门 + 我们都知道,结构体类型表示的是实实在在的数据结构。一个结构体类型可以包含若干个字段,每个字段通常都需要有确切的名字和类型。 + +前导内容:结构体类型基础知识 + +当然了,结构体类型也可以不包含任何字段,这样并不是没有意义的,因为我们还可以为类型关联上一些方法,这里你可以把方法看做是函数的特殊版本。 + +函数是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。 + +方法却不同,它需要有名字,不能被当作值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者(receiver)声明体现出来。 + +接收者声明就是在关键字func和方法名称之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。 + +接收者的类型其实就是当前方法所属的类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值。 + +我们举个例子来看一下。 + +// AnimalCategory 代表动物分类学中的基本分类法。 +type AnimalCategory struct { + kingdom string // 界。 + phylum string // 门。 + class string // 纲。 + order string // 目。 + family string // 科。 + genus string // 属。 + species string // 种。 +} + +func (ac AnimalCategory) String() string { + return fmt.Sprintf("%s%s%s%s%s%s%s", + ac.kingdom, ac.phylum, ac.class, ac.order, + ac.family, ac.genus, ac.species) +} + + +结构体类型AnimalCategory代表了动物的基本分类法,其中有7个string类型的字段,分别表示各个等级的分类。 + +下边有个名叫String的方法,从它的接收者声明可以看出它隶属于AnimalCategory类型。 + +通过该方法的接收者名称ac,我们可以在其中引用到当前值的任何一个字段,或者调用到当前值的任何一个方法(也包括String方法自己)。 + +这个String方法的功能是提供当前值的字符串表示形式,其中的各个等级分类会按照从大到小的顺序排列。使用时,我们可以这样表示: + +category := AnimalCategory{species: "cat"} +fmt.Printf("The animal category: %s\n", category) + + +这里,我用字面量初始化了一个AnimalCategory类型的值,并把它赋给了变量category。为了不喧宾夺主,我只为其中的species字段指定了字符串值"cat",该字段代表最末级分类“种”。 + +在Go语言中,我们可以通过为一个类型编写名为String的方法,来自定义该类型的字符串表示形式。这个String方法不需要任何参数声明,但需要有一个string类型的结果声明。 + +正因为如此,我在调用fmt.Printf函数时,使用占位符%s和category值本身就可以打印出后者的字符串表示形式,而无需显式地调用它的String方法。 + +fmt.Printf函数会自己去寻找它。此时的打印内容会是The animal category: cat。显而易见,category的String方法成功地引用了当前值的所有字段。 + + +方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。 + +一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复。 + +我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。 + +Go语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。 + + +现在,让我们再把目光放到结构体类型的字段声明上。我们来看下面的代码: + +type Animal struct { + scientificName string // 学名。 + AnimalCategory // 动物基本分类。 +} + + +我声明了一个结构体类型,名叫Animal。它有两个字段。一个是string类型的字段scientificName,代表了动物的学名。而另一个字段声明中只有AnimalCategory,它正是我在前面编写的那个结构体类型的名字。这是什么意思呢? + +那么,我们今天的问题是:Animal类型中的字段声明AnimalCategory代表了什么? + +更宽泛地讲,如果结构体类型的某个字段声明中只有一个类型名,那么该字段代表了什么? + +这个问题的典型回答是:字段声明AnimalCategory代表了Animal类型的一个嵌入字段。Go语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段,也可以被称为匿名字段。我们可以通过此类型变量的名称后跟“.”,再后跟嵌入字段类型的方式引用到该字段。也就是说,嵌入字段的类型既是类型也是名称。 + +问题解析 + +说到引用结构体的嵌入字段,Animal类型有个方法叫Category,它是这么写的: + +func (a Animal) Category() string { + return a.AnimalCategory.String() +} + + +Category方法的接收者类型是Animal,接收者名称是a。在该方法中,我通过表达式a.AnimalCategory选择到了a的这个嵌入字段,然后又选择了该字段的String方法并调用了它。 + +顺便提一下,在某个代表变量的标识符的右边加“.”,再加上字段名或方法名的表达式被称为选择表达式,它用来表示选择了该变量的某个字段或者方法。 + +这是Go语言规范中的说法,与“引用结构体的某某字段”或“调用结构体的某某方法”的说法是相通的。我在以后会混用这两种说法。 + +实际上,把一个结构体类型嵌入到另一个结构体类型中的意义不止如此。嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中。例如下面这种: + +animal := Animal{ + scientificName: "American Shorthair", + AnimalCategory: category, +} +fmt.Printf("The animal: %s\n", animal) + + +我声明了一个Animal类型的变量animal并对它进行初始化。我把字符串值"American Shorthair"赋给它的字段scientificName,并把前面声明过的变量category赋给它的嵌入字段AnimalCategory。 + +我在后面使用fmt.Printf函数和%s占位符试图打印animal的字符串表示形式,相当于调用animal的String方法。虽然我们还没有为Animal类型编写String方法,但这样做是没问题的。因为在这里,嵌入字段AnimalCategory的String方法会被当做animal的方法调用。 + +那如果我也为Animal类型编写一个String方法呢?这里会调用哪一个呢? + +答案是,animal的String方法会被调用。这时,我们说,嵌入字段AnimalCategory的String方法被“屏蔽”了。注意,只要名称相同,无论这两个方法的签名是否一致,被嵌入类型的方法都会“屏蔽”掉嵌入字段的同名方法。 + +类似的,由于我们同样可以像访问被嵌入类型的字段那样,直接访问嵌入字段的字段,所以如果这两个结构体类型里存在同名的字段,那么嵌入字段中的那个字段一定会被“屏蔽”。这与我们在前面讲过的,可重名变量之间可能存在的“屏蔽”现象很相似。 + +正因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,这种“屏蔽”现象依然会存在。 + +不过,即使被屏蔽了,我们仍然可以通过链式的选择表达式,选择到嵌入字段的字段或方法,就像我在Category方法中所做的那样。这种“屏蔽”其实还带来了一些好处。我们看看下面这个Animal类型的String方法的实现: + +func (a Animal) String() string { + return fmt.Sprintf("%s (category: %s)", + a.scientificName, a.AnimalCategory) +} + + +在这里,我们把对嵌入字段的String方法的调用结果融入到了Animal类型的同名方法的结果中。这种将同名方法的结果逐层“包装”的手法是很常见和有用的,也算是一种惯用法了。 + +- +(结构体类型中的嵌入字段) + +最后,我还要提一下多层嵌入的问题。也就是说,嵌入字段本身也有嵌入字段的情况。请看我声明的Cat类型: + +type Cat struct { + name string + Animal +} + +func (cat Cat) String() string { + return fmt.Sprintf("%s (category: %s, name: %q)", + cat.scientificName, cat.Animal.AnimalCategory, cat.name) +} + + +结构体类型Cat中有一个嵌入字段Animal,而Animal类型还有一个嵌入字段AnimalCategory。 + +在这种情况下,“屏蔽”现象会以嵌入的层级为依据,嵌入层级越深的字段或方法越可能被“屏蔽”。 + +例如,当我们调用Cat类型值的String方法时,如果该类型确有String方法,那么嵌入字段Animal和AnimalCategory的String方法都会被“屏蔽”。 + +如果该类型没有String方法,那么嵌入字段Animal的String方法会被调用,而它的嵌入字段AnimalCategory的String方法仍然会被屏蔽。 + +只有当Cat类型和Animal类型都没有String方法的时候,AnimalCategory的String方法菜会被调用。 + +最后的最后,如果处于同一个层级的多个嵌入字段拥有同名的字段或方法,那么从被嵌入类型的值那里,选择此名称的时候就会引发一个编译错误,因为编译器无法确定被选择的成员到底是哪一个。 + +以上关于嵌入字段的所有示例都在demo29.go中,希望能对你有所帮助。 + +知识扩展 + +问题1:Go语言是用嵌入字段实现了继承吗? + +这里强调一下,Go语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合。这样做的具体原因和理念请见Go语言官网的FAQ中的Why is there no type inheritance?。 + +简单来说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。 + +类型之间的组合采用的是非声明的方式,我们不需要显式地声明某个类型实现了某个接口,或者一个类型继承了另一个类型。 + +同时,类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。 + +我们要做的只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。如果嵌入字段有哪里不合心意,我们还可以用“包装”或“屏蔽”的方式去调整和优化。 + +另外,类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。 + +这时候,被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。再者,组合要比继承更加简洁和清晰,Go语言可以轻而易举地通过嵌入多个字段来实现功能强大的类型,却不会有多重继承那样复杂的层次结构和可观的管理成本。 + +接口类型之间也可以组合。在Go语言中,接口类型之间的组合甚至更加常见,我们常常以此来扩展接口定义的行为或者标记接口的特征。与此有关的内容我在下一篇文章中再讲。 + +在我面试过的众多Go工程师中,有很多人都在说“Go语言用嵌入字段实现了继承”,而且深信不疑。 + +要么是他们还在用其他编程语言的视角和理念来看待Go语言,要么就是受到了某些所谓的“Go语言教程”的误导。每当这时,我都忍不住当场纠正他们,并建议他们去看看官网上的解答。 + +问题2:值方法和指针方法都是什么意思,有什么区别? + +我们都知道,方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型。所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。 + +比如,我们在前面为AnimalCategory、Animal以及Cat类型声明的那些方法都是值方法。就拿Cat来说,它的String方法的接收者类型就是Cat,一个非指针类型。那什么叫指针类型呢?请看这个方法: + +func (cat *Cat) SetName(name string) { + cat.name = name +} + + +方法SetName的接收者类型是*Cat。Cat左边再加个*代表的就是Cat类型的指针类型。 + +这时,Cat可以被叫做*Cat的基本类型。你可以认为这种指针类型的值表示的是指向某个基本类型值的指针。 + +我们可以通过把取值操作符*放在这样一个指针值的左边来组成一个取值表达式,以获取该指针值指向的基本类型值,也可以通过把取址操作符&放在一个可寻址的基本类型值的左边来组成一个取址表达式,以获取该基本类型值的指针值。 + +所谓的指针方法,就是接收者类型是上述指针类型的方法。 + +那么值方法和指针方法之间有什么不同点呢?它们的不同如下所示。 + + + +值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。- + +而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。- + + +一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。- + +严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。- + +比如,在Cat类型的变量cat之上,之所以我们可以通过cat.SetName("monster")修改猫的名字,是因为Go语言把它自动转译为了(&cat).SetName("monster"),即:先取cat的指针值,然后在该指针值上调用SetName方法。 + + +在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。- + +比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。 + + +能够体现值方法和指针方法之间差异的小例子我放在demo30.go文件里了,你可以参照一下。 + +总结 + +结构体类型的嵌入字段比较容易让Go语言新手们迷惑,所以我在本篇文章着重解释了它的编写方法、基本的特性和规则以及更深层次的含义。在理解了结构体类型及其方法的组成方式和构造套路之后,这些知识应该是你重点掌握的。 + +嵌入字段是其声明中只有类型而没有名称的字段,它可以以一种很自然的方式为被嵌入的类型带来新的属性和能力。在一般情况下,我们用简单的选择表达式就可以直接引用到它们的字段和方法。 + +不过,我们需要小心可能产生“屏蔽”现象的地方,尤其是当存在多个嵌入字段或者多层嵌入的时候。“屏蔽”现象可能会让你的实际引用与你的预期不符。 + +另外,你一定要梳理清楚值方法和指针方法的不同之处,包括这两种方法各自能做什么、不能做什么以及会影响到其所属类型的哪些方面。这涉及值的修改、方法集合和接口实现。 + +最后,再次强调,嵌入字段是实现类型间组合的一种方式,这与继承没有半点儿关系。Go语言虽然支持面向对象编程,但是根本就没有“继承”这个概念。 + +思考题 + + +我们可以在结构体类型中嵌入某个类型的指针类型吗?如果可以,有哪些注意事项? +字面量struct{}代表了什么?又有什么用处? + + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/14接口类型的合理运用.md b/专栏/Go语言核心36讲/14接口类型的合理运用.md new file mode 100644 index 0000000..bf246e4 --- /dev/null +++ b/专栏/Go语言核心36讲/14接口类型的合理运用.md @@ -0,0 +1,221 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 接口类型的合理运用 + 你好,我是郝林,今天我们来聊聊接口的相关内容。 + +前导内容:正确使用接口的基础知识 + +在Go语言的语境中,当我们在谈论“接口”的时候,一定指的是接口类型。因为接口类型与其他数据类型不同,它是没法被实例化的。 + +更具体地说,我们既不能通过调用new函数或make函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。 + +对于某一个接口类型来说,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在。 + +我已经在前面展示过,通过关键字type和interface,我们可以声明出接口类型。 + +接口类型的类型字面量与结构体类型的看起来有些相似,它们都用花括号包裹一些核心信息。只不过,结构体类型包裹的是它的字段声明,而接口类型包裹的是它的方法定义。 + +这里你要注意的是:接口类型声明中的这些方法所代表的就是该接口的方法集合。一个接口的方法集合就是它的全部特征。 + +对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征(即全部的方法),那么它就一定是这个接口的实现类型。比如下面这样: + +type Pet interface { + SetName(name string) + Name() string + Category() string +} + + +我声明了一个接口类型Pet,它包含了3个方法定义,方法名称分别为SetName、Name和Category。这3个方法共同组成了接口类型Pet的方法集合。 + +只要一个数据类型的方法集合中有这3个方法,那么它就一定是Pet接口的实现类型。这是一种无侵入式的接口实现方式。这种方式还有一个专有名词,叫“Duck typing”,中文常译作“鸭子类型”。你可以到百度的百科页面上去了解一下详情。 + +顺便说一句,怎样判定一个数据类型的某一个方法实现的就是某个接口类型中的某个方法呢? + +这有两个充分必要条件,一个是“两个方法的签名需要完全一致”,另一个是“两个方法的名称要一模一样”。显然,这比判断一个函数是否实现了某个函数类型要更加严格一些。 + +如果你查阅了上篇文章附带的最后一个示例的话,那么就一定会知道,虽然结构体类型Cat不是Pet接口的实现类型,但它的指针类型*Cat却是这个的实现类型。 + +如果你还不知道原因,那么请跟着我一起来看。我已经把Cat类型的声明搬到了demo31.go文件中,并进行了一些简化,以便你看得更清楚。对了,由于Cat和Pet的发音过于相似,我还把Cat重命名为了Dog。 + +我声明的类型Dog附带了3个方法。其中有2个值方法,分别是Name和Category,另外还有一个指针方法SetName。 + +这就意味着,Dog类型本身的方法集合中只包含了2个方法,也就是所有的值方法。而它的指针类型*Dog方法集合却包含了3个方法, + +也就是说,它拥有Dog类型附带的所有值方法和指针方法。又由于这3个方法恰恰分别是Pet接口中某个方法的实现,所以*Dog类型就成为了Pet接口的实现类型。 + +dog := Dog{"little pig"} +var pet Pet = &dog + + +正因为如此,我可以声明并初始化一个Dog类型的变量dog,然后把它的指针值赋给类型为Pet的变量pet。 + +这里有几个名词需要你先记住。对于一个接口类型的变量来说,例如上面的变量pet,我们赋给它的值可以被叫做它的实际值(也称动态值),而该值的类型可以被叫做这个变量的实际类型(也称动态类型)。 + +比如,我们把取址表达式&dog的结果值赋给了变量pet,这时这个结果值就是变量pet的动态值,而此结果值的类型*Dog就是该变量的动态类型。 + +动态类型这个叫法是相对于静态类型而言的。对于变量pet来讲,它的静态类型就是Pet,并且永远是Pet,但是它的动态类型却会随着我们赋给它的动态值而变化。 + +比如,只有我把一个*Dog类型的值赋给变量pet之后,该变量的动态类型才会是*Dog。如果还有一个Pet接口的实现类型*Fish,并且我又把一个此类型的值赋给了pet,那么它的动态类型就会变为*Fish。 + +还有,在我们给一个接口类型的变量赋予实际的值之前,它的动态类型是不存在的。 + +你需要想办法搞清楚接口类型的变量(以下简称接口变量)的动态值、动态类型和静态类型都是什么意思。因为我会在后面基于这些概念讲解更深层次的知识。 + +好了,我下面会就“怎样用好Go语言的接口”这个话题提出一系列问题,也请你跟着我一起思考这些问题。 + +那么今天的问题是:当我们为一个接口变量赋值时会发生什么? + +为了突出问题,我把Pet接口的声明简化了一下。 + +type Pet interface { + Name() string + Category() string +} + + +我从中去掉了Pet接口的那个名为SetName的方法。这样一来,Dog类型也就变成Pet接口的实现类型了。你可以在demo32.go文件中找到本问题的代码。 + +现在,我先声明并初始化了一个Dog类型的变量dog,这时它的name字段的值是"little pig"。然后,我把该变量赋给了一个Pet类型的变量pet。最后我通过调用dog的方法SetName把它的name字段的值改成了"monster"。 + +dog := Dog{"little pig"} +var pet Pet = dog +dog.SetName("monster") + + +所以,我要问的具体问题是:在以上代码执行后,pet变量的字段name的值会是什么? + +这个题目的典型回答是:pet变量的字段name的值依然是"little pig"。 + +问题解析 + +首先,由于dog的SetName方法是指针方法,所以该方法持有的接收者就是指向dog的指针值的副本,因而其中对接收者的name字段的设置就是对变量dog的改动。那么当dog.SetName("monster")执行之后,dog的name字段的值就一定是"monster"。如果你理解到了这一层,那么请小心前方的陷阱。 + +为什么dog的name字段值变了,而pet的却没有呢?这里有一条通用的规则需要你知晓:如果我们使用一个变量给另外一个变量赋值,那么真正赋给后者的,并不是前者持有的那个值,而是该值的一个副本。 + +例如,我声明并初始化了一个Dog类型的变量dog1,这时它的name是"little pig"。然后,我在把dog1赋给变量dog2之后,修改了dog1的name字段的值。这时,dog2的name字段的值是什么? + +dog1 := Dog{"little pig"} +dog2 := dog1 +dog1.name = "monster" + + +这个问题与前面那道题几乎一样,只不过这里没有涉及接口类型。这时的dog2的name仍然会是"little pig"。这就是我刚刚告诉你的那条通用规则的又一个体现。 + +当你知道了这条通用规则之后,确实可以把前面那道题做对。不过,如果当我问你为什么的时候你只说出了这一个原因,那么,我只能说你仅仅答对了一半。 + +那么另一半是什么?这就需要从接口类型值的存储方式和结构说起了。我在前面说过,接口类型本身是无法被值化的。在我们赋予它实际的值之前,它的值一定会是nil,这也是它的零值。 + +反过来讲,一旦它被赋予了某个实现类型的值,它的值就不再是nil了。不过要注意,即使我们像前面那样把dog的值赋给了pet,pet的值与dog的值也是不同的。这不仅仅是副本与原值的那种不同。 + +当我们给一个接口变量赋值的时候,该变量的动态类型会与它的动态值一起被存储在一个专用的数据结构中。 + +严格来讲,这样一个变量的值其实是这个专用数据结构的一个实例,而不是我们赋给该变量的那个实际的值。所以我才说,pet的值与dog的值肯定是不同的,无论是从它们存储的内容,还是存储的结构上来看都是如此。不过,我们可以认为,这时pet的值中包含了dog值的副本。 + +我们就把这个专用的数据结构叫做iface吧,在Go语言的runtime包中它其实就叫这个名字。 + +iface的实例会包含两个指针,一个是指向类型信息的指针,另一个是指向动态值的指针。这里的类型信息是由另一个专用数据结构的实例承载的,其中包含了动态值的类型,以及使它实现了接口的方法和调用它们的途径,等等。 + +总之,接口变量被赋予动态值的时候,存储的是包含了这个动态值的副本的一个结构更加复杂的值。你明白了吗? + +知识扩展 + +问题 1:接口变量的值在什么情况下才真正为nil? + +这个问题初看起来就不是个问题。对于一个引用类型的变量,它的值是否为nil完全取决于我们赋给它了什么,是这样吗?我们先来看一段代码: + +var dog1 *Dog +fmt.Println("The first dog is nil. [wrap1]") +dog2 := dog1 +fmt.Println("The second dog is nil. [wrap1]") +var pet Pet = dog2 +if pet == nil { + fmt.Println("The pet is nil. [wrap1]") +} else { + fmt.Println("The pet is not nil. [wrap1]") +} + + +在demo33.go文件的这段代码中,我先声明了一个*Dog类型的变量dog1,并且没有对它进行初始化。这时该变量的值是什么?显然是nil。然后我把该变量赋给了dog2,后者的值此时也必定是nil,对吗? + +现在问题来了:当我把dog2赋给Pet类型的变量pet之后,变量pet的值会是什么?答案是nil吗? + +如果你真正理解了我在上一个问题的解析中讲到的知识,尤其是接口变量赋值及其值的数据结构那部分,那么这道题就不难回答。你可以先思考一下,然后再接着往下看。 + +当我们把dog2的值赋给变量pet的时候,dog2的值会先被复制,不过由于在这里它的值是nil,所以就没必要复制了。 + +然后,Go语言会用我上面提到的那个专用数据结构iface的实例包装这个dog2的值的副本,这里是nil。 + +虽然被包装的动态值是nil,但是pet的值却不会是nil,因为这个动态值只是pet值的一部分而已。 + +顺便说一句,这时的pet的动态类型就存在了,是*Dog。我们可以通过fmt.Printf函数和占位符%T来验证这一点,另外reflect包的TypeOf函数也可以起到类似的作用。 + +换个角度来看。我们把nil赋给了pet,但是pet的值却不是nil。 + +这很奇怪对吗?其实不然。在Go语言中,我们把由字面量nil表示的值叫做无类型的nil。这是真正的nil,因为它的类型也是nil的。虽然dog2的值是真正的nil,但是当我们把这个变量赋给pet的时候,Go语言会把它的类型和值放在一起考虑。 + +也就是说,这时Go语言会识别出赋予pet的值是一个*Dog类型的nil。然后,Go语言就会用一个iface的实例包装它,包装后的产物肯定就不是nil了。 + +只要我们把一个有类型的nil赋给接口变量,那么这个变量的值就一定不会是那个真正的nil。因此,当我们使用判等符号==判断pet是否与字面量nil相等的时候,答案一定会是false。 + +那么,怎样才能让一个接口变量的值真正为nil呢?要么只声明它但不做初始化,要么直接把字面量nil赋给它。 + +问题 2:怎样实现接口之间的组合? + +接口类型间的嵌入也被称为接口的组合。我在前面讲过结构体类型的嵌入字段,这其实就是在说结构体类型间的嵌入。 + +接口类型间的嵌入要更简单一些,因为它不会涉及方法间的“屏蔽”。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名彼此不同也会是如此。因此,接口的组合根本不可能导致“屏蔽”现象的出现。 + +与结构体类型间的嵌入很相似,我们只要把一个接口类型的名称直接写到另一个接口类型的成员列表中就可以了。比如: + +type Animal interface { + ScientificName() string + Category() string +} + +type Pet interface { + Animal + Name() string +} + + +接口类型Pet包含了两个成员,一个是代表了另一个接口类型的Animal,一个是方法Name的定义。它们都被包含在Pet的类型声明的花括号中,并且都各自独占一行。此时,Animal接口包含的所有方法也就成为了Pet接口的方法。 + +Go语言团队鼓励我们声明体量较小的接口,并建议我们通过这种接口间的组合来扩展程序、增加程序的灵活性。 + +这是因为相比于包含很多方法的大接口而言,小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。 + +Go语言标准库代码包io中的ReadWriteCloser接口和ReadWriter接口就是这样的例子,它们都是由若干个小接口组合而成的。以io.ReadWriteCloser接口为例,它是由io.Reader、io.Writer和io.Closer这三个接口组成的。 + +这三个接口都只包含了一个方法,是典型的小接口。它们中的每一个都只代表了一种能力,分别是读出、写入和关闭。我们编写这几个小接口的实现类型通常都会很容易。并且,一旦我们同时实现了它们,就等于实现了它们的组合接口io.ReadWriteCloser。 + +即使我们只实现了io.Reader和io.Writer,那么也等同于实现了io.ReadWriter接口,因为后者就是前两个接口组成的。可以看到,这几个io包中的接口共同组成了一个接口矩阵。它们既相互关联又独立存在。 + +我在demo34.go文件中写了一个能够体现接口组合优势的小例子,你可以去参看一下。总之,善用接口组合和小接口可以让你的程序框架更加稳定和灵活。 + +总结 + +好了,我们来简要总结一下。 + +Go语言的接口常用于代表某种能力或某类特征。首先,我们要弄清楚的是,接口变量的动态值、动态类型和静态类型都代表了什么。这些都是正确使用接口变量的基础。当我们给接口变量赋值时,接口变量会持有被赋予值的副本,而不是它本身。 + +更重要的是,接口变量的值并不等同于这个可被称为动态值的副本。它会包含两个指针,一个指针指向动态值,一个指针指向类型信息。 + +基于此,即使我们把一个值为nil的某个实现类型的变量赋给了接口变量,后者的值也不可能是真正的nil。虽然这时它的动态值会为nil,但它的动态类型确是存在的。 + +请记住,除非我们只声明而不初始化,或者显式地赋给它nil,否则接口变量的值就不会为nil。 + +后面的一个问题相对轻松一些,它是关于程序设计方面的。用好小接口和接口组合总是有益的,我们可以以此形成接口矩阵,进而搭起灵活的程序框架。如果在实现接口时再配合运用结构体类型间的嵌入手法,那么接口组合就可以发挥更大的效用。 + +思考题 + +如果我们把一个值为nil的某个实现类型的变量赋给了接口变量,那么在这个接口变量上仍然可以调用该接口的方法吗?如果可以,有哪些注意事项?如果不可以,原因是什么? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/15关于指针的有限操作.md b/专栏/Go语言核心36讲/15关于指针的有限操作.md new file mode 100644 index 0000000..8f132b0 --- /dev/null +++ b/专栏/Go语言核心36讲/15关于指针的有限操作.md @@ -0,0 +1,227 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 关于指针的有限操作 + 在前面的文章中,我们已经提到过很多次“指针”了,你应该已经比较熟悉了。不过,我们那时大多指的是指针类型及其对应的指针值,今天我们讲的则是更为深入的内容。 + + + +让我们先来复习一下。 + +type Dog struct { + name string +} + +func (dog *Dog) SetName(name string) { + dog.name = name +} + + +对于基本类型Dog来说,*Dog就是它的指针类型。而对于一个Dog类型,值不为nil的变量dog,取址表达式&dog的结果就是该变量的值(也就是基本值)的指针值。 + +如果一个方法的接收者是*Dog类型的,那么该方法就是基本类型Dog的指针方法。 + + + +在这种情况下,这个方法的接收者,实际上就是当前的基本值的指针值。 + +我们可以通过指针值无缝地访问到基本值包含的任何字段,以及调用与之关联的任何方法。这应该就是我们在编写Go程序的过程中,用得最频繁的“指针”了。 + +从传统意义上说,指针是一个指向某个确切的内存地址的值。这个内存地址可以是任何数据或代码的起始地址,比如,某个变量、某个字段或某个函数。 + +我们刚刚只提到了其中的一种情况,在Go语言中还有其他几样东西可以代表“指针”。其中最贴近传统意义的当属uintptr类型了。该类型实际上是一个数值类型,也是Go语言内建的数据类型之一。 + +根据当前计算机的计算架构的不同,它可以存储32位或64位的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。 + +再来看Go语言标准库中的unsafe包。unsafe包中有一个类型叫做Pointer,也代表了“指针”。 + +unsafe.Pointer可以表示任何指向可寻址的值的指针,同时它也是前面提到的指针值和uintptr值之间的桥梁。也就是说,通过它,我们可以在这两种值之上进行双向的转换。这里有一个很关键的词——可寻址的(addressable)。在我们继续说unsafe.Pointer之前,需要先要搞清楚这个词的确切含义。 + +今天的问题是:你能列举出Go语言中的哪些值是不可寻址的吗? + +这道题的典型回答是以下列表中的值都是不可寻址的。 + + +常量的值。 +基本类型值的字面量。 +算术操作的结果值。 +对各种字面量的索引表达式和切片表达式的结果值。不过有一个例外,对切片字面量的索引结果值却是可寻址的。 +对字符串变量的索引表达式和切片表达式的结果值。 +对字典变量的索引表达式的结果值。 +函数字面量和方法字面量,以及对它们的调用表达式的结果值。 +结构体字面量的字段值,也就是对结构体字面量的选择表达式的结果值。 +类型转换表达式的结果值。 +类型断言表达式的结果值。 +接收表达式的结果值。 + + +问题解析 + +初看答案中的这些不可寻址的值好像并没有什么规律。不过别急,我们一起来梳理一下。你可以对照着demo35.go文件中的代码来看,这样应该会让你理解起来更容易一些。 + +常量的值总是会被存储到一个确切的内存区域中,并且这种值肯定是不可变的。基本类型值的字面量也是一样,其实它们本就可以被视为常量,只不过没有任何标识符可以代表它们罢了。 + +第一个关键词:不可变的。由于Go语言中的字符串值也是不可变的,所以对于一个字符串类型的变量来说,基于它的索引或切片的结果值也都是不可寻址的,因为即使拿到了这种值的内存地址也改变不了什么。 + +算术操作的结果值属于一种临时结果。在我们把这种结果值赋给任何变量或常量之前,即使能拿到它的内存地址也是没有任何意义的。 + +第二个关键词:临时结果。这个关键词能被用来解释很多现象。我们可以把各种对值字面量施加的表达式的求值结果都看做是临时结果。 + +我们都知道,Go语言中的表达式有很多种,其中常用的包括以下几种。 + + +用于获得某个元素的索引表达式。 +用于获得某个切片(片段)的切片表达式。 +用于访问某个字段的选择表达式。 +用于调用某个函数或方法的调用表达式。 +用于转换值的类型的类型转换表达式。 +用于判断值的类型的类型断言表达式。 +向通道发送元素值或从通道那里接收元素值的接收表达式。 + + +我们把以上这些表达式施加在某个值字面量上一般都会得到一个临时结果。比如,对数组字面量和字典字面量的索引结果值,又比如,对数组字面量和切片字面量的切片结果值。它们都属于临时结果,都是不可寻址的。 + +一个需要特别注意的例外是,对切片字面量的索引结果值是可寻址的。因为不论怎样,每个切片值都会持有一个底层数组,而这个底层数组中的每个元素值都是有一个确切的内存地址的。 + +你可能会问,那么对切片字面量的切片结果值为什么却是不可寻址的?这是因为切片表达式总会返回一个新的切片值,而这个新的切片值在被赋给变量之前属于临时结果。 + +你可能已经注意到了,我一直在说针对数组值、切片值或字典值的字面量的表达式会产生临时结果。如果针对的是数组类型或切片类型的变量,那么索引或切片的结果值就都不属于临时结果了,是可寻址的。 + +这主要因为变量的值本身就不是“临时的”。对比而言,值字面量在还没有与任何变量(或者说任何标识符)绑定之前是没有落脚点的,我们无法以任何方式引用到它们。这样的值就是“临时的”。 + +再说一个例外。我们通过对字典类型的变量施加索引表达式,得到的结果值不属于临时结果,可是,这样的值却是不可寻址的。原因是,字典中的每个键-元素对的存储位置都可能会变化,而且这种变化外界是无法感知的。 + +我们都知道,字典中总会有若干个哈希桶用于均匀地储存键-元素对。当满足一定条件时,字典可能会改变哈希桶的数量,并适时地把其中的键-元素对搬运到对应的新的哈希桶中。 + +在这种情况下,获取字典中任何元素值的指针都是无意义的,也是不安全的。我们不知道什么时候那个元素值会被搬运到何处,也不知道原先的那个内存地址上还会被存放什么别的东西。所以,这样的值就应该是不可寻址的。 + +第三个关键词:不安全的。“不安全的”操作很可能会破坏程序的一致性,引发不可预知的错误,从而严重影响程序的功能和稳定性。 + +再来看函数。函数在Go语言中是一等公民,所以我们可以把代表函数或方法的字面量或标识符赋给某个变量、传给某个函数或者从某个函数传出。但是,这样的函数和方法都是不可寻址的。一个原因是函数就是代码,是不可变的。 + +另一个原因是,拿到指向一段代码的指针是不安全的。此外,对函数或方法的调用结果值也是不可寻址的,这是因为它们都属于临时结果。 + +至于典型回答中最后列出的那几种值,由于都是针对值字面量的某种表达式的结果值,所以都属于临时结果,都不可寻址。 + +好了,说了这么多,希望你已经有所领悟了。我来总结一下。 + + +不可变的值不可寻址。常量、基本类型的值字面量、字符串变量的值、函数以及方法的字面量都是如此。其实这样规定也有安全性方面的考虑。 +绝大多数被视为临时结果的值都是不可寻址的。算术操作的结果值属于临时结果,针对值字面量的表达式结果值也属于临时结果。但有一个例外,对切片字面量的索引结果值虽然也属于临时结果,但却是可寻址的。 +若拿到某值的指针可能会破坏程序的一致性,那么就是不安全的,该值就不可寻址。由于字典的内部机制,对字典的索引结果值的取址操作都是不安全的。另外,获取由字面量或标识符代表的函数或方法的地址显然也是不安全的。 + + +最后说一句,如果我们把临时结果赋给一个变量,那么它就是可寻址的了。如此一来,取得的指针指向的就是这个变量持有的那个值了。 + +知识扩展 + +问题1:不可寻址的值在使用上有哪些限制? + +首当其冲的当然是无法使用取址操作符&获取它们的指针了。不过,对不可寻址的值施加取址操作都会使编译器报错,所以倒是不用太担心,你只要记住我在前面讲述的那几条规律,并在编码的时候提前注意一下就好了。 + +我们来看下面这个小问题。我们依然以那个结构体类型Dog为例。 + +func New(name string) Dog { + return Dog{name} +} + + +我们再为它编写一个函数New。这个函数会接受一个名为name的string类型的参数,并会用这个参数初始化一个Dog类型的值,最后返回该值。我现在要问的是:如果我调用该函数,并直接以链式的手法调用其结果值的指针方法SetName,那么可以达到预期的效果吗? + +New("little pig").SetName("monster") + + +如果你还记得我在前面讲述的内容,那么肯定会知道调用New函数所得到的结果值属于临时结果,是不可寻址的。 + +可是,那又怎样呢?别忘了,我在讲结构体类型及其方法的时候还说过,我们可以在一个基本类型的值上调用它的指针方法,这是因为Go语言会自动地帮我们转译。 + +更具体地说,对于一个Dog类型的变量dog来说,调用表达式dog.SetName("monster")会被自动地转译为(&dog).SetName("monster"),即:先取dog的指针值,再在该指针值上调用SetName方法。 + +发现问题了吗?由于New函数的调用结果值是不可寻址的,所以无法对它进行取址操作。因此,上边这行链式调用会让编译器报告两个错误,一个是果,即:不能在New("little pig")的结果值上调用指针方法。一个是因,即:不能取得New("little pig")的地址。 + +除此之外,我们都知道,Go语言中的++和--并不属于操作符,而分别是自增语句和自减语句的重要组成部分。 + +虽然Go语言规范中的语法定义是,只要在++或--的左边添加一个表达式,就可以组成一个自增语句或自减语句,但是,它还明确了一个很重要的限制,那就是这个表达式的结果值必须是可寻址的。这就使得针对值字面量的表达式几乎都无法被用在这里。 + +不过这有一个例外,虽然对字典字面量和字典变量索引表达式的结果值都是不可寻址的,但是这样的表达式却可以被用在自增语句和自减语句中。 + +与之类似的规则还有两个。一个是,在赋值语句中,赋值操作符左边的表达式的结果值必须可寻址的,但是对字典的索引结果值也是可以的。 + +另一个是,在带有range子句的for语句中,在range关键字左边的表达式的结果值也都必须是可寻址的,不过对字典的索引结果值同样可以被用在这里。以上这三条规则我们合并起来记忆就可以了。 + +与这些定死的规则相比,我刚刚讲到的那个与指针方法有关的问题,你需要好好理解一下,它涉及了两个知识点的联合运用。起码在我面试的时候,它是一个可选择的考点。 + +问题 2:怎样通过unsafe.Pointer操纵可寻址的值? + +前边的基础知识很重要。不过现在让我们再次关注指针的用法。我说过,unsafe.Pointer是像*Dog类型的值这样的指针值和uintptr值之间的桥梁,那么我们怎样利用unsafe.Pointer的中转和uintptr的底层操作来操纵像dog这样的值呢? + +首先说明,这是一项黑科技。它可以绕过Go语言的编译器和其他工具的重重检查,并达到潜入内存修改数据的目的。这并不是一种正常的编程手段,使用它会很危险,很有可能造成安全隐患。 + +我们总是应该优先使用常规代码包中提供的API去编写程序,当然也可以把像reflect以及go/ast这样的代码包作为备选项。作为上层应用的开发者,请谨慎地使用unsafe包中的任何程序实体。 + +不过既然说到这里了,我们还是要来一探究竟的。请看下面的代码: + +dog := Dog{"little pig"} +dogP := &dog +dogPtr := uintptr(unsafe.Pointer(dogP)) + + +我先声明了一个Dog类型的变量dog,然后用取址操作符&,取出了它的指针值,并把它赋给了变量dogP。 + +最后,我使用了两个类型转换,先把dogP转换成了一个unsafe.Pointer类型的值,然后紧接着又把后者转换成了一个uintptr的值,并把它赋给了变量dogPtr。这背后隐藏着一些转换规则,如下: + + +一个指针值(比如*Dog类型的值)可以被转换为一个unsafe.Pointer类型的值,反之亦然。 +一个uintptr类型的值也可以被转换为一个unsafe.Pointer类型的值,反之亦然。 +一个指针值无法被直接转换成一个uintptr类型的值,反过来也是如此。 + + +所以,对于指针值和uintptr类型值之间的转换,必须使用unsafe.Pointer类型的值作为中转。那么,我们把指针值转换成uintptr类型的值有什么意义吗? + +namePtr := dogPtr + unsafe.Offsetof(dogP.name) +nameP := (*string)(unsafe.Pointer(namePtr)) + + +这里需要与unsafe.Offsetof函数搭配使用才能看出端倪。unsafe.Offsetof函数用于获取两个值在内存中的起始存储地址之间的偏移量,以字节为单位。 + +这两个值一个是某个字段的值,另一个是该字段值所属的那个结构体值。我们在调用这个函数的时候,需要把针对字段的选择表达式传给它,比如dogP.name。 + +有了这个偏移量,又有了结构体值在内存中的起始存储地址(这里由dogPtr变量代表),把它们相加我们就可以得到dogP的name字段值的起始存储地址了。这个地址由变量namePtr代表。 + +此后,我们可以再通过两次类型转换把namePtr的值转换成一个*string类型的值,这样就得到了指向dogP的name字段值的指针值。 + +你可能会问,我直接用取址表达式&(dogP.name)不就能拿到这个指针值了吗?干嘛绕这么大一圈呢?你可以想象一下,如果我们根本就不知道这个结构体类型是什么,也拿不到dogP这个变量,那么还能去访问它的name字段吗? + +答案是,只要有namePtr就可以。它就是一个无符号整数,但同时也是一个指向了程序内部数据的内存地址。它可能会给我们带来一些好处,比如可以直接修改埋藏得很深的内部数据。 + +但是,一旦我们有意或无意地把这个内存地址泄露出去,那么其他人就能够肆意地改动dogP.name的值,以及周围的内存地址上存储的任何数据了。 + +即使他们不知道这些数据的结构也无所谓啊,改不好还改不坏吗?不正确地改动一定会给程序带来不可预知的问题,甚至造成程序崩溃。这可能还是最好的灾难性后果;所以我才说,使用这种非正常的编程手段会很危险。 + +好了,现在你知道了这种手段,也知道了它的危险性,那就谨慎对待,防患于未然吧。 + +总结 + +我们今天集中说了说与指针有关的问题。基于基本类型的指针值应该是我们最常用到的,也是我们最需要关注的,比如*Dog类型的值。怎样得到一个这样的指针值呢?这需要用到取址操作和操作符&。 + +不过这里还有个前提,那就是取址操作的操作对象必须是可寻址的。关于这方面你需要记住三个关键词:不可变的、临时结果和不安全的。只要一个值符合了这三个关键词中的任何一个,它就是不可寻址的。 + +但有一个例外,对切片字面量的索引结果值是可寻址的。那么不可寻址的值在使用上有哪些限制呢?一个最重要的限制是关于指针方法的,即:无法调用一个不可寻址值的指针方法。这涉及了两个知识点的联合运用。 + +相比于刚说到的这些,unsafe.Pointer类型和uintptr类型的重要性好像就没那么高了。它们的值同样可以代表指针,并且比前面说的指针值更贴近于底层和内存。 + +虽然我们可以利用它们去访问或修改一些内部数据,而且就灵活性而言,这种要比通用的方式高很多,但是这往往也会带来不容小觑的安全隐患。 + +因此,在很多时候,使用它们操纵数据是弊大于利的。不过,对于硬币的背面,我们也总是有必要去了解的。 + +思考题 + +今天的思考题是:引用类型的值的指针值是有意义的吗?如果没有意义,为什么?如果有意义,意义在哪里? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/16go语句及其执行规则(上).md b/专栏/Go语言核心36讲/16go语句及其执行规则(上).md new file mode 100644 index 0000000..55ac44c --- /dev/null +++ b/专栏/Go语言核心36讲/16go语句及其执行规则(上).md @@ -0,0 +1,156 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 go语句及其执行规则(上) + 你很棒,已经学完了关于Go语言数据类型的全部内容。我相信你不但已经知晓了怎样高效地使用Go语言内建的那些数据类型,还明白了怎样正确地创造自己的数据类型。 + +对于Go语言的编程知识,你确实已经知道了不少了。不过,如果你真想玩转Go语言还需要知道它的一些特色流程和语法。 + +尤其是我们将会在本篇文章中讨论的go语句,这也是Go语言的最大特色了。它足可以代表Go语言最重要的编程哲学和并发编程模式。 + +让我们再重温一下下面这句话: + + +Don’t communicate by sharing memory; share memory by communicating. + + +从Go语言编程的角度解释,这句话的意思就是:不要通过共享数据来通讯,恰恰相反,要以通讯的方式共享数据。 + +我们已经知道,通道(也就是channel)类型的值,可以被用来以通讯的方式共享数据。更具体地说,它一般被用来在不同的goroutine之间传递数据。那么goroutine到底代表着什么呢? + +简单来说,goroutine代表着并发编程模型中的用户级线程。你可能已经知道,操作系统本身提供了进程和线程,这两种并发执行程序的工具。 + +前导内容:进程与线程 + +进程,描述的就是程序的执行过程,是运行着的程序的代表。换句话说,一个进程其实就是某个程序运行时的一个产物。如果说静静地躺在那里的代码就是程序的话,那么奔跑着的、正在发挥着既有功能的代码就可以被称为进程。 + +我们的电脑为什么可以同时运行那么多应用程序?我们的手机为什么可以有那么多App同时在后台刷新?这都是因为在它们的操作系统之上有多个代表着不同应用程序或App的进程在同时运行。 + +再来说说线程。首先,线程总是在进程之内的,它可以被视为进程中运行着的控制流(或者说代码执行的流程)。 + +一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。 + +相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。 + +也就是说,主线程之外的其他线程都只能由代码显式地创建和销毁。这需要我们在编写程序的时候进行手动控制,操作系统以及进程本身并不会帮我们下达这样的指令,它们只会忠实地执行我们的指令。 + +不过,在Go程序当中,Go语言的运行时(runtime)系统会帮助我们自动地创建和销毁系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。 + +而对应的用户级线程指的是架设在系统级线程之上的,由用户(或者说我们编写的程序)完全控制的代码执行流程。用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。 + +这带来了很多优势,比如,因为它们的创建和销毁并不用通过操作系统去做,所以速度会很快,又比如,由于不用等着操作系统去调度它们的运行,所以往往会很容易控制并且可以很灵活。 + +但是,劣势也是有的,最明显也最重要的一个劣势就是复杂。如果我们只使用了系统级线程,那么我们只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好了,其他的一切具体实现都会由操作系统代劳。 + +但是,如果使用用户级线程,我们就不得不既是指令下达者,又是指令执行者。我们必须全权负责与用户级线程有关的所有具体实现。 + +操作系统不但不会帮忙,还会要求我们的具体实现必须与它正确地对接,否则用户级线程就无法被并发地,甚至正确地运行。毕竟我们编写的所有代码最终都需要通过操作系统才能在计算机上执行。这听起来就很麻烦,不是吗? + +不过别担心,Go语言不但有着独特的并发编程模型,以及用户级线程goroutine,还拥有强大的用于调度goroutine、对接系统级线程的调度器。 + +这个调度器是Go语言运行时系统的重要组成部分,它主要负责统筹调配Go并发编程模型中的三个主要元素,即:G(goroutine的缩写)、P(processor的缩写)和M(machine的缩写)。 + +其中的M指代的就是系统级线程。而P指的是一种可以承载若干个G,且能够使这些G适时地与M进行对接,并得到真正运行的中介。 + +从宏观上说,G和M由于P的存在可以呈现出多对多的关系。当一个正在与某个M对接并运行着的G,需要因某个事件(比如等待I/O或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个G与那个M分离开,以释放计算资源供那些等待运行的G使用。 + +而当一个G需要恢复运行的时候,调度器又会尽快地为它寻找空闲的计算资源(包括M)并安排运行。另外,当M不够用时,调度器会帮我们向操作系统申请新的系统级线程,而当某个M已无用时,调度器又会负责把它及时地销毁掉。 + +正因为调度器帮助我们做了很多事,所以我们的Go程序才总是能高效地利用操作系统和计算机资源。程序中的所有goroutine也都会被充分地调度,其中的代码也都会被并发地运行,即使这样的goroutine有数以十万计,也仍然可以如此。 + + + +M、P、G之间的关系(简化版) + +由于篇幅原因,关于Go语言内部的调度器和运行时系统的更多细节,我在这里就不再深入讲述了。你需要知道,Go语言实现了一套非常完善的运行时系统,保证了我们的程序在高并发的情况下依旧能够稳定、高效地运行。 + +如果你对这些具体的细节感兴趣,并还想进一步探索,那么我推荐你去看看我写的那本《Go并发编程实战》。我在这本书中用了相当大的篇幅阐释了Go语言并发编程模型的原理、运作机制,以及所有与之紧密相关的知识。 + +下面,我会从编程实践的角度出发,以go语句的用法为主线,向你介绍go语句的执行规则、最佳实践和使用禁忌。 + +我们来看一下今天的问题:什么是主goroutine,它与我们启用的其他goroutine有什么不同? + +我们具体来看一道我在面试中经常提问的编程题。 + +package main + +import "fmt" + +func main() { + for i := 0; i < 10; i++ { + go func() { + fmt.Println(i) + }() + } +} + + +在demo38.go中,我只在main函数中写了一条for语句。这条for语句中的代码会迭代运行10次,并有一个局部变量i代表着当次迭代的序号,该序号是从0开始的。 + +在这条for语句中仅有一条go语句,这条go语句中也仅有一条语句。这条最里面的语句调用了fmt.Println函数并想要打印出变量i的值。 + +这个程序很简单,三条语句逐条嵌套。我的具体问题是:这个命令源码文件被执行后会打印出什么内容? + +这道题的典型回答是:不会有任何内容被打印出来。 + +问题解析 + +与一个进程总会有一个主线程类似,每一个独立的Go程序在运行时也总会有一个主goroutine。这个主goroutine会在Go程序的运行准备工作完成后被自动地启用,并不需要我们做任何手动的操作。 + +想必你已经知道,每条go语句一般都会携带一个函数调用,这个被调用的函数常常被称为go函数。而主goroutine的go函数就是那个作为程序入口的main函数。 + +一定要注意,go函数真正被执行的时间,总会与其所属的go语句被执行的时间不同。当程序执行到一条go语句的时候,Go语言的运行时系统,会先试图从某个存放空闲的G的队列中获取一个G(也就是goroutine),它只有在找不到空闲G的情况下才会去创建一个新的G。 + +这也是为什么我总会说“启用”一个goroutine,而不说“创建”一个goroutine的原因。已存在的goroutine总是会被优先复用。 + +然而,创建G的成本也是非常低的。创建一个G并不会像新建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在Go语言的运行时系统内部就可以完全做到了,更何况一个G仅相当于为需要并发执行代码片段服务的上下文环境而已。 + +在拿到了一个空闲的G之后,Go语言运行时系统会用这个G去包装当前的那个go函数(或者说该函数中的那些代码),然后再把这个G追加到某个存放可运行的G的队列中。 + +这类队列中的G总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行。虽然这会很快,但是由于上面所说的那些准备工作还是不可避免的,所以耗时还是存在的。 + +因此,go函数的执行时间总是会明显滞后于它所属的go语句的执行时间。当然了,这里所说的“明显滞后”是对于计算机的CPU时钟和Go程序来说的。我们在大多数时候都不会有明显的感觉。 + +在说明了原理之后,我们再来看这种原理下的表象。请记住,只要go语句本身执行完毕,Go程序完全不会等待go函数的执行,它会立刻去执行后边的语句。这就是所谓的异步并发地执行。 + +这里“后边的语句”指的一般是for语句中的下一个迭代。然而,当最后一个迭代运行的时候,这个“后边的语句”是不存在的。 + +在demo38.go中的那条for语句会以很快的速度执行完毕。当它执行完毕时,那10个包装了go函数的goroutine往往还没有获得运行的机会。 + +请注意,go函数中的那个对fmt.Println函数的调用是以for语句中的变量i作为参数的。你可以想象一下,如果当for语句执行完毕的时候,这些go函数都还没有执行,那么它们引用的变量i的值将会是什么? + +它们都会是10,对吗?那么这道题的答案会是“打印出10个10”,是这样吗? + +在确定最终的答案之前,你还需要知道一个与主goroutine有关的重要特性,即:一旦主goroutine中的代码(也就是main函数中的那些代码)执行完毕,当前的Go程序就会结束运行。 + +如此一来,如果在Go程序结束的那一刻,还有goroutine未得到运行机会,那么它们就真的没有运行机会了,它们中的代码也就不会被执行了。 + +我们刚才谈论过,当for语句的最后一个迭代运行的时候,其中的那条go语句即是最后一条语句。所以,在执行完这条go语句之后,主goroutine中的代码也就执行完了,Go程序会立即结束运行。那么,如果这样的话,还会有任何内容被打印出来吗? + +严谨地讲,Go语言并不会去保证这些goroutine会以怎样的顺序运行。由于主goroutine会与我们手动启用的其他goroutine一起接受调度,又因为调度器很可能会在goroutine中的代码只执行了一部分的时候暂停,以期所有的goroutine有更公平的运行机会。 + +所以哪个goroutine先执行完、哪个goroutine后执行完往往是不可预知的,除非我们使用了某种Go语言提供的方式进行了人为干预。然而,在这段代码中,我们并没有进行任何人为干预。 + +那答案到底是什么呢?就demo38.go中如此简单的代码而言,绝大多数情况都会是“不会有任何内容被打印出来”。 + +但是为了严谨起见,无论应聘者的回答是“打印出10个10”还是“不会有任何内容被打印出来”,又或是“打印出乱序的0到9”,我都会紧接着去追问“为什么?”因为只有你知道了这背后的原理,你做出的回答才会被认为是正确的。 + +这个原理是如此的重要,以至于如果你不知道它,那么就几乎无法编写出正确的可并发执行的程序。如果你不知道此原理,那么即使你写的并发程序看起来可以正确地运行,那也肯定是运气好而已。 + +总结 + +今天,我描述了goroutine在操作系统的并发编程体系,以及在Go语言并发编程模型中的地位和作用。这些知识点会为你打下一个坚实的基础。 + +我还提到了Go语言内部的运行时系统和调度器,以及它们围绕着goroutine做的那些统筹调配和维护工作。这些内容中的每句话应该都会对你正确理解goroutine起到实质性的作用。你可以用这些知识去解释主问题中的那个程序在运行后为什么会产出那样的结果。 + +下一篇内容,我们还会继续围绕go语句以及执行规则谈一些扩展知识,今天留给你的思考题就是:用什么手段可以对goroutine的启用数量加以限制? + +感谢你的收听,我们下次再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/17go语句及其执行规则(下).md b/专栏/Go语言核心36讲/17go语句及其执行规则(下).md new file mode 100644 index 0000000..660509a --- /dev/null +++ b/专栏/Go语言核心36讲/17go语句及其执行规则(下).md @@ -0,0 +1,137 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 go语句及其执行规则(下) + 你好,我是郝林,今天我们继续分享go语句执行规则的内容。 + +在上一篇文章中,我们讲到了goroutine在操作系统的并发编程体系,以及在Go语言并发编程模型中的地位和作用等一系列内容,今天我们继续来聊一聊这个话题。 + +知识扩展 + +问题1:怎样才能让主goroutine等待其他goroutine? + +我刚才说过,一旦主goroutine中的代码执行完毕,当前的Go程序就会结束运行,无论其他的goroutine是否已经在运行了。那么,怎样才能做到等其他的goroutine运行完毕之后,再让主goroutine结束运行呢? + +其实有很多办法可以做到这一点。其中,最简单粗暴的办法就是让主goroutine“小睡”一会儿。 + +for i := 0; i < 10; i++ { + go func() { + fmt.Println(i) + }() +} +time.Sleep(time.Millisecond * 500) + + +在for语句的后边,我调用了time包的Sleep函数,并把time.Millisecond * 500的结果作为参数值传给了它。time.Sleep函数的功能就是让当前的goroutine(在这里就是主goroutine)暂停运行一段时间,直到到达指定的恢复运行时间。 + +我们可以把一个相对的时间传给该函数,就像我在这里传入的“500毫秒”那样。time.Sleep函数会在被调用时用当前的绝对时间,再加上相对时间计算出在未来的恢复运行时间。显然,一旦到达恢复运行时间,当前的goroutine就会从“睡眠”中醒来,并开始继续执行后边的代码。 + +这个办法是可行的,只要“睡眠”的时间不要太短就好。不过,问题恰恰就在这里,我们让主goroutine“睡眠”多长时间才是合适的呢?如果“睡眠”太短,则很可能不足以让其他的goroutine运行完毕,而若“睡眠”太长则纯属浪费时间,这个时间就太难把握了。 + +你可能会想到,既然不容易预估时间,那我们就让其他的goroutine在运行完毕的时候告诉我们好了。这个思路很好,但怎么做呢? + +你是否想到了通道呢?我们先创建一个通道,它的长度应该与我们手动启用的goroutine的数量一致。在每个手动启用的goroutine即将运行完毕的时候,我们都要向该通道发送一个值。 + +注意,这些发送表达式应该被放在它们的go函数体的最后面。对应的,我们还需要在main函数的最后从通道接收元素值,接收的次数也应该与手动启用的goroutine的数量保持一致。关于这些你可以到demo39.go文件中,去查看具体的写法。 + +其中有一个细节你需要注意。我在声明通道sign的时候是以chan struct{}作为其类型的。其中的类型字面量struct{}有些类似于空接口类型interface{},它代表了既不包含任何字段也不拥有任何方法的空结构体类型。 + +注意,struct{}类型值的表示法只有一个,即:struct{}{}。并且,它占用的内存空间是0字节。确切地说,这个值在整个Go程序中永远都只会存在一份。虽然我们可以无数次地使用这个值字面量,但是用到的却都是同一个值。 + +当我们仅仅把通道当作传递某种简单信号的介质的时候,用struct{}作为其元素类型是再好不过的了。顺便说一句,我在讲“结构体及其方法的使用法门”的时候留过一道与此相关的思考题,你可以返回去看一看。 + +再说回当下的问题,有没有比使用通道更好的方法?如果你知道标准库中的代码包sync的话,那么可能会想到sync.WaitGroup类型。没错,这是一个更好的答案。不过具体的使用方式我在后边讲sync包的时候再说。 + +问题2:怎样让我们启用的多个goroutine按照既定的顺序运行? + +在很多时候,当我沿着上面的主问题以及第一个扩展问题一路问下来的时候,应聘者往往会被这第二个扩展问题难住。 + +所以基于上一篇主问题中的代码,怎样做到让从0到9这几个整数按照自然数的顺序打印出来?你可能会说,我不用goroutine不就可以了嘛。没错,这样是可以,但是如果我不考虑这样做呢。你应该怎么解决这个问题? + +当然了,众多应聘者回答的其他答案也是五花八门的,有的可行,有的不可行,还有的把原来的代码改得面目全非。我下面就来说说我的思路,以及心目中的答案吧。这个答案并不一定是最佳的,也许你在看完之后还可以想到更优的答案。 + +首先,我们需要稍微改造一下for语句中的那个go函数,要让它接受一个int类型的参数,并在调用它的时候把变量i的值传进去。为了不改动这个go函数中的其他代码,我们可以把它的这个参数也命名为i。 + +for i := 0; i < 10; i++ { + go func(i int) { + fmt.Println(i) + }(i) +} + + +只有这样,Go语言才能保证每个goroutine都可以拿到一个唯一的整数。其原因与go函数的执行时机有关。 + +我在前面已经讲过了。在go语句被执行时,我们传给go函数的参数i会先被求值,如此就得到了当次迭代的序号。之后,无论go函数会在什么时候执行,这个参数值都不会变。也就是说,go函数中调用的fmt.Println函数打印的一定会是那个当次迭代的序号。 + +然后,我们在着手改造for语句中的go函数。 + +for i := uint32(0); i < 10; i++ { + go func(i uint32) { + fn := func() { + fmt.Println(i) + } + trigger(i, fn) + }(i) +} + + +我在go函数中先声明了一个匿名的函数,并把它赋给了变量fn。这个匿名函数做的事情很简单,只是调用fmt.Println函数以打印go函数的参数i的值。 + +在这之后,我调用了一个名叫trigger的函数,并把go函数的参数i和刚刚声明的变量fn作为参数传给了它。注意,for语句声明的局部变量i和go函数的参数i的类型都变了,都由int变为了uint32。至于为什么,我一会儿再说。 + +再来说trigger函数。该函数接受两个参数,一个是uint32类型的参数i, 另一个是func()类型的参数fn。你应该记得,func()代表的是既无参数声明也无结果声明的函数类型。 + +trigger := func(i uint32, fn func()) { + for { + if n := atomic.LoadUint32(&count); n == i { + fn() + atomic.AddUint32(&count, 1) + break + } + time.Sleep(time.Nanosecond) + } +} + + +trigger函数会不断地获取一个名叫count的变量的值,并判断该值是否与参数i的值相同。如果相同,那么就立即调用fn代表的函数,然后把count变量的值加1,最后显式地退出当前的循环。否则,我们就先让当前的goroutine“睡眠”一个纳秒再进入下一个迭代。 + +注意,我操作变量count的时候使用的都是原子操作。这是由于trigger函数会被多个goroutine并发地调用,所以它用到的非本地变量count,就被多个用户级线程共用了。因此,对它的操作就产生了竞态条件(race condition),破坏了程序的并发安全性。 + +所以,我们总是应该对这样的操作加以保护,在sync/atomic包中声明了很多用于原子操作的函数。 + +另外,由于我选用的原子操作函数对被操作的数值的类型有约束,所以我才对count以及相关的变量和参数的类型进行了统一的变更(由int变为了uint32)。 + +纵观count变量、trigger函数以及改造后的for语句和go函数,我要做的是,让count变量成为一个信号,它的值总是下一个可以调用打印函数的go函数的序号。 + +这个序号其实就是启用goroutine时,那个当次迭代的序号。也正因为如此,go函数实际的执行顺序才会与go语句的执行顺序完全一致。此外,这里的trigger函数实现了一种自旋(spinning)。除非发现条件已满足,否则它会不断地进行检查。 + +最后要说的是,因为我依然想让主goroutine最后一个运行完毕,所以还需要加一行代码。不过既然有了trigger函数,我就没有再使用通道。 + +trigger(10, func(){}) + + +调用trigger函数完全可以达到相同的效果。由于当所有我手动启用的goroutine都运行完毕之后,count的值一定会是10,所以我就把10作为了第一个参数值。又由于我并不想打印这个10,所以我把一个什么都不做的函数作为了第二个参数值。 + +总之,通过上述的改造,我使得异步发起的go函数得到了同步地(或者说按照既定顺序地)执行,你也可以动手自己试一试,感受一下。 + +总结 + +在本篇文章中,我们接着上一篇文章的主问题,讨论了当我们想让运行结果更加可控的时候,应该怎样去做。 + +主goroutine的运行若过早结束,那么我们的并发程序的功能就很可能无法全部完成。所以我们往往需要通过一些手段去进行干涉,比如调用time.Sleep函数或者使用通道。我们在后面的文章中还会讨论更高级的手段。 + +另外,go函数的实际执行顺序往往与其所属的go语句的执行顺序(或者说goroutine的启用顺序)不同,而且默认情况下的执行顺序是不可预知的。那怎样才能让这两个顺序一致呢?其实复杂的实现方式有不少,但是可能会把原来的代码改得面目全非。我在这里提供了一种比较简单、清晰的改造方案,供你参考。 + +总之,我希望通过上述基础知识以及三个连贯的问题帮你串起一条主线。这应该会让你更快地深入理解goroutine及其背后的并发编程模型,从而更加游刃有余地使用go语句。 + +思考题 + +1.runtime包中提供了哪些与模型三要素G、P和M相关的函数?(模型三要素内容在上一篇) + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/18if语句、for语句和switch语句.md b/专栏/Go语言核心36讲/18if语句、for语句和switch语句.md new file mode 100644 index 0000000..561fc26 --- /dev/null +++ b/专栏/Go语言核心36讲/18if语句、for语句和switch语句.md @@ -0,0 +1,249 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 if语句、for语句和switch语句 + 在上两篇文章中,我主要为你讲解了与go语句、goroutine和Go语言调度器有关的知识和技法。 + +内容很多,你不用急于完全消化,可以在编程实践过程中逐步理解和感悟,争取夯实它们。 + + + +现在,让我们暂时走下神坛,回归民间。我今天要讲的if语句、for语句和switch语句都属于Go语言的基本流程控制语句。它们的语法看起来很朴素,但实际上也会有一些使用技巧和注意事项。我在本篇文章中会以一系列面试题为线索,为你讲述它们的用法。 + +那么,今天的问题是:使用携带range子句的for语句时需要注意哪些细节? 这是一个比较笼统的问题。我还是通过编程题来讲解吧。 + + +本问题中的代码都被放在了命令源码文件demo41.go的main函数中的。为了专注问题本身,本篇文章中展示的编程题会省略掉一部分代码包声明语句、代码包导入语句和main函数本身的声明部分。 + + +numbers1 := []int{1, 2, 3, 4, 5, 6} +for i := range numbers1 { + if i == 3 { + numbers1[i] |= i + } +} +fmt.Println(numbers1) + + +我先声明了一个元素类型为int的切片类型的变量numbers1,在该切片中有6个元素值,分别是从1到6的整数。我用一条携带range子句的for语句去迭代numbers1变量中的所有元素值。 + +在这条for语句中,只有一个迭代变量i。我在每次迭代时,都会先去判断i的值是否等于3,如果结果为true,那么就让numbers1的第i个元素值与i本身做按位或的操作,再把操作结果作为numbers1的新的第i个元素值。最后我会打印出numbers1的值。 + +所以具体的问题就是,这段代码执行后会打印出什么内容? + +这里的典型回答是:打印的内容会是[1 2 3 7 5 6]。 + +问题解析 + +你心算得到的答案是这样吗?让我们一起来复现一下这个计算过程。 + +当for语句被执行的时候,在range关键字右边的numbers1会先被求值。 + +这个位置上的代码被称为range表达式。range表达式的结果值可以是数组、数组的指针、切片、字符串、字典或者允许接收操作的通道中的某一个,并且结果值只能有一个。 + +对于不同种类的range表达式结果值,for语句的迭代变量的数量可以有所不同。 + +就拿我们这里的numbers1来说,它是一个切片,那么迭代变量就可以有两个,右边的迭代变量代表当次迭代对应的某一个元素值,而左边的迭代变量则代表该元素值在切片中的索引值。 + +那么,如果像本题代码中的for语句那样,只有一个迭代变量的情况意味着什么呢?这意味着,该迭代变量只会代表当次迭代对应的元素值的索引值。 + +更宽泛地讲,当只有一个迭代变量的时候,数组、数组的指针、切片和字符串的元素值都是无处安放的,我们只能拿到按照从小到大顺序给出的一个个索引值。 + +因此,这里的迭代变量i的值会依次是从0到5的整数。当i的值等于3的时候,与之对应的是切片中的第4个元素值4。对4和3进行按位或操作得到的结果是7。这就是答案中的第4个整数是7的原因了。 + +现在,我稍稍修改一下上面的代码。我们再来估算一下打印内容。 + +numbers2 := [...]int{1, 2, 3, 4, 5, 6} +maxIndex2 := len(numbers2) - 1 +for i, e := range numbers2 { + if i == maxIndex2 { + numbers2[0] += e + } else { + numbers2[i+1] += e + } +} +fmt.Println(numbers2) + + +注意,我把迭代的对象换成了numbers2。numbers2中的元素值同样是从1到6的6个整数,并且元素类型同样是int,但它是一个数组而不是一个切片。 + +在for语句中,我总是会对紧挨在当次迭代对应的元素后边的那个元素,进行重新赋值,新的值会是这两个元素的值之和。当迭代到最后一个元素时,我会把此range表达式结果值中的第一个元素值,替换为它的原值与最后一个元素值的和,最后,我会打印出numbers2的值。 + +对于这段代码,我的问题依旧是:打印的内容会是什么?你可以先思考一下。 + +好了,我要公布答案了。打印的内容会是[7 3 5 7 9 11]。我先来重现一下计算过程。当for语句被执行的时候,在range关键字右边的numbers2会先被求值。 + +这里需要注意两点: + + +range表达式只会在for语句开始执行时被求值一次,无论后边会有多少次迭代; +range表达式的求值结果会被复制,也就是说,被迭代的对象是range表达式结果值的副本而不是原值。 + + +基于这两个规则,我们接着往下看。在第一次迭代时,我改变的是numbers2的第二个元素的值,新值为3,也就是1和2之和。 + +但是,被迭代的对象的第二个元素却没有任何改变,毕竟它与numbers2已经是毫不相关的两个数组了。因此,在第二次迭代时,我会把numbers2的第三个元素的值修改为5,即被迭代对象的第二个元素值2和第三个元素值3的和。 + +以此类推,之后的numbers2的元素值依次会是7、9和11。当迭代到最后一个元素时,我会把numbers2的第一个元素的值修改为1和6之和。 + +好了,现在该你操刀了。你需要把numbers2的值由一个数组改成一个切片,其中的元素值都不要变。为了避免混淆,你还要把这个切片值赋给变量numbers3,并且把后边代码中所有的numbers2都改为numbers3。 + +问题是不变的,执行这段修改版的代码后打印的内容会是什么呢?如果你实在估算不出来,可以先实际执行一下,然后再尝试解释看到的答案。提示一下,切片与数组是不同的,前者是引用类型的,而后者是值类型的。 + +我们可以先接着讨论后边的内容,但是我强烈建议你一定要回来,再看看我留给你的这个问题,认真地思考和计算一下。 + +知识扩展 + +问题1:switch语句中的switch表达式和case表达式之间有着怎样的联系? + +先来看一段代码。 + +value1 := [...]int8{0, 1, 2, 3, 4, 5, 6} +switch 1 + 3 { +case value1[0], value1[1]: + fmt.Println("0 or 1") +case value1[2], value1[3]: + fmt.Println("2 or 3") +case value1[4], value1[5], value1[6]: + fmt.Println("4 or 5 or 6") +} + + +我先声明了一个数组类型的变量value1,该变量的元素类型是int8。在后边的switch语句中,被夹在switch关键字和左花括号{之间的是1 + 3,这个位置上的代码被称为switch表达式。这个switch语句还包含了三个case子句,而每个case子句又各包含了一个case表达式和一条打印语句。 + +所谓的case表达式一般由case关键字和一个表达式列表组成,表达式列表中的多个表达式之间需要有英文逗号,分割,比如,上面代码中的case value1[0], value1[1]就是一个case表达式,其中的两个子表达式都是由索引表达式表示的。 + +另外的两个case表达式分别是case value1[2], value1[3]和case value1[4], value1[5], value1[6]。 + +此外,在这里的每个case子句中的那些打印语句,会分别打印出不同的内容,这些内容用于表示case子句被选中的原因,比如,打印内容0 or 1表示当前case子句被选中是因为switch表达式的结果值等于0或1中的某一个。另外两条打印语句会分别打印出2 or 3和4 or 5 or 6。 + +现在问题来了,拥有这样三个case表达式的switch语句可以成功通过编译吗?如果不可以,原因是什么?如果可以,那么该switch语句被执行后会打印出什么内容。 + +我刚才说过,只要switch表达式的结果值与某个case表达式中的任意一个子表达式的结果值相等,该case表达式所属的case子句就会被选中。 + +并且,一旦某个case子句被选中,其中的附带在case表达式后边的那些语句就会被执行。与此同时,其他的所有case子句都会被忽略。 + +当然了,如果被选中的case子句附带的语句列表中包含了fallthrough语句,那么紧挨在它下边的那个case子句附带的语句也会被执行。 + +正因为存在上述判断相等的操作(以下简称判等操作),switch语句对switch表达式的结果类型,以及各个case表达式中子表达式的结果类型都是有要求的。毕竟,在Go语言中,只有类型相同的值之间才有可能被允许进行判等操作。 + +如果switch表达式的结果值是无类型的常量,比如1 + 3的求值结果就是无类型的常量4,那么这个常量会被自动地转换为此种常量的默认类型的值,比如整数4的默认类型是int,又比如浮点数3.14的默认类型是float64。 + +因此,由于上述代码中的switch表达式的结果类型是int,而那些case表达式中子表达式的结果类型却是int8,它们的类型并不相同,所以这条switch语句是无法通过编译的。 + +再来看一段很类似的代码: + +value2 := [...]int8{0, 1, 2, 3, 4, 5, 6} +switch value2[4] { +case 0, 1: + fmt.Println("0 or 1") +case 2, 3: + fmt.Println("2 or 3") +case 4, 5, 6: + fmt.Println("4 or 5 or 6") +} + + +其中的变量value2与value1的值是完全相同的。但不同的是,我把switch表达式换成了value2[4],并把下边那三个case表达式分别换为了case 0, 1、case 2, 3和case 4, 5, 6。 + +如此一来,switch表达式的结果值是int8类型的,而那些case表达式中子表达式的结果值却是无类型的常量了。这与之前的情况恰恰相反。那么,这样的switch语句可以通过编译吗? + +答案是肯定的。因为,如果case表达式中子表达式的结果值是无类型的常量,那么它的类型会被自动地转换为switch表达式的结果类型,又由于上述那几个整数都可以被转换为int8类型的值,所以对这些表达式的结果值进行判等操作是没有问题的。 + +当然了,如果这里说的自动转换没能成功,那么switch语句照样通不过编译。 + + + +(switch语句中的自动类型转换) + +通过上面这两道题,你应该可以搞清楚switch表达式和case表达式之间的联系了。由于需要进行判等操作,所以前者和后者中的子表达式的结果类型需要相同。 + +switch语句会进行有限的类型转换,但肯定不能保证这种转换可以统一它们的类型。还要注意,如果这些表达式的结果类型有某个接口类型,那么一定要小心检查它们的动态值是否都具有可比性(或者说是否允许判等操作)。 + +因为,如果答案是否定的,虽然不会造成编译错误,但是后果会更加严重:引发panic(也就是运行时恐慌)。 + +问题2:switch语句对它的case表达式有哪些约束? + +我在上一个问题的阐述中还重点表达了一点,不知你注意到了没有,那就是:switch语句在case子句的选择上是具有唯一性的。 + +正因为如此,switch语句不允许case表达式中的子表达式结果值存在相等的情况,不论这些结果值相等的子表达式,是否存在于不同的case表达式中,都会是这样的结果。具体请看这段代码: + +value3 := [...]int8{0, 1, 2, 3, 4, 5, 6} +switch value3[4] { +case 0, 1, 2: + fmt.Println("0 or 1 or 2") +case 2, 3, 4: + fmt.Println("2 or 3 or 4") +case 4, 5, 6: + fmt.Println("4 or 5 or 6") +} + + +变量value3的值同value1,依然是由从0到6的7个整数组成的数组,元素类型是int8。switch表达式是value3[4],三个case表达式分别是case 0, 1, 2、case 2, 3, 4和case 4, 5, 6。 + +由于在这三个case表达式中存在结果值相等的子表达式,所以这个switch语句无法通过编译。不过,好在这个约束本身还有个约束,那就是只针对结果值为常量的子表达式。 + +比如,子表达式1+1和2不能同时出现,1+3和4也不能同时出现。有了这个约束的约束,我们就可以想办法绕过这个对子表达式的限制了。再看一段代码: + +value5 := [...]int8{0, 1, 2, 3, 4, 5, 6} +switch value5[4] { +case value5[0], value5[1], value5[2]: + fmt.Println("0 or 1 or 2") +case value5[2], value5[3], value5[4]: + fmt.Println("2 or 3 or 4") +case value5[4], value5[5], value5[6]: + fmt.Println("4 or 5 or 6") +} + + +变量名换成了value5,但这不是重点。重点是,我把case表达式中的常量都换成了诸如value5[0]这样的索引表达式。 + +虽然第一个case表达式和第二个case表达式都包含了value5[2],并且第二个case表达式和第三个case表达式都包含了value5[4],但这已经不是问题了。这条switch语句可以成功通过编译。 + +不过,这种绕过方式对用于类型判断的switch语句(以下简称为类型switch语句)就无效了。因为类型switch语句中的case表达式的子表达式,都必须直接由类型字面量表示,而无法通过间接的方式表示。代码如下: + +value6 := interface{}(byte(127)) +switch t := value6.(type) { +case uint8, uint16: + fmt.Println("uint8 or uint16") +case byte: + fmt.Printf("byte") +default: + fmt.Printf("unsupported type: %T", t) +} + + +变量value6的值是空接口类型的。该值包装了一个byte类型的值127。我在后面使用类型switch语句来判断value6的实际类型,并打印相应的内容。 + +这里有两个普通的case子句,还有一个default case子句。前者的case表达式分别是case uint8, uint16和case byte。你还记得吗?byte类型是uint8类型的别名类型。 + +因此,它们两个本质上是同一个类型,只是类型名称不同罢了。在这种情况下,这个类型switch语句是无法通过编译的,因为子表达式byte和uint8重复了。好了,以上说的就是case表达式的约束以及绕过方式,你学会了吗。 + +总结 + +我们今天主要讨论了for语句和switch语句,不过我并没有说明那些语法规则,因为它们太简单了。我们需要多加注意的往往是那些隐藏在Go语言规范和最佳实践里的细节。 + +这些细节其实就是我们很多技术初学者所谓的“坑”。比如,我在讲for语句的时候交代了携带range子句时只有一个迭代变量意味着什么。你必须知道在迭代数组或切片时只有一个迭代变量的话是无法迭代出其中的元素值的,否则你的程序可能就不会像你预期的那样运行了。 + +还有,range表达式的结果值是会被复制的,实际迭代时并不会使用原值。至于会影响到什么,那就要看这个结果值的类型是值类型还是引用类型了。 + +说到switch语句,你要明白其中的case表达式的所有子表达式的结果值都是要与switch表达式的结果值判等的,因此它们的类型必须相同或者能够都统一到switch表达式的结果类型。如果无法做到,那么这条switch语句就不能通过编译。 + +最后,同一条switch语句中的所有case表达式的子表达式的结果值不能重复,不过好在这只是对于由字面量直接表示的子表达式而言的。 + +请记住,普通case子句的编写顺序很重要,最上边的case子句中的子表达式总是会被最先求值,在判等的时候顺序也是这样。因此,如果某些子表达式的结果值有重复并且它们与switch表达式的结果值相等,那么位置靠上的case子句总会被选中。 + +思考题 + + +在类型switch语句中,我们怎样对被判断类型的那个值做相应的类型转换? +在if语句中,初始化子句声明的变量的作用域是什么? + + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/19错误处理(上).md b/专栏/Go语言核心36讲/19错误处理(上).md new file mode 100644 index 0000000..cbb9179 --- /dev/null +++ b/专栏/Go语言核心36讲/19错误处理(上).md @@ -0,0 +1,168 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 错误处理(上) + 提到Go语言中的错误处理,我们其实已经在前面接触过几次了。 + +比如,我们声明过error类型的变量err,也调用过errors包中的New函数。今天,我会用这篇文章为你梳理Go语言错误处理的相关知识,同时提出一些关键问题并与你一起探讨。 + +我们说过error类型其实是一个接口类型,也是一个Go语言的内建类型。在这个接口类型的声明中只包含了一个方法Error。Error方法不接受任何参数,但是会返回一个string类型的结果。它的作用是返回错误信息的字符串表示形式。 + +我们使用error类型的方式通常是,在函数声明的结果列表的最后,声明一个该类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为nil”。 + +如果这个值“不为nil”,那么就进入错误处理流程,否则就继续进行正常的流程。下面是一个例子,代码在demo44.go文件中。 + +package main + +import ( + "errors" + "fmt" +) + +func echo(request string) (response string, err error) { + if request == "" { + err = errors.New("empty request") + return + } + response = fmt.Sprintf("echo: %s", request) + return +} + +func main() { + for _, req := range []string{"", "hello!"} { + fmt.Printf("request: %s\n", req) + resp, err := echo(req) + if err != nil { + fmt.Printf("error: %s\n", err) + continue + } + fmt.Printf("response: %s\n", resp) + } +} + + +我们先看echo函数的声明。echo函数接受一个string类型的参数request,并会返回两个结果。 + +这两个结果都是有名称的,第一个结果response也是string类型的,它代表了这个函数正常执行后的结果值。 + +第二个结果err就是error类型的,它代表了函数执行出错时的结果值,同时也包含了具体的错误信息。 + +当echo函数被调用时,它会先检查参数request的值。如果该值为空字符串,那么它就会通过调用errors.New函数,为结果err赋值,然后忽略掉后边的操作并直接返回。 + +此时,结果response的值也会是一个空字符串。如果request的值并不是空字符串,那么它就为结果response赋一个适当的值,然后返回,此时结果err的值会是nil。 + +再来看main函数中的代码。我在每次调用echo函数之后,都会把它返回的结果值赋给变量resp和err,并且总是先检查err的值是否“不为nil”,如果是,就打印错误信息,否则就打印常规的响应信息。 + +这里值得注意的地方有两个。第一,在echo函数和main函数中,我都使用到了卫述语句。我在前面讲函数用法的时候也提到过卫述语句。简单地讲,它就是被用来检查后续操作的前置条件并进行相应处理的语句。 + +对于echo函数来说,它进行常规操作的前提是:传入的参数值一定要符合要求。而对于调用echo函数的程序来说,进行后续操作的前提就是echo函数的执行不能出错。 + + +我们在进行错误处理的时候经常会用到卫述语句,以至于有些人会吐槽说:“我的程序满屏都是卫述语句,简直是太难看了!” + +不过,我倒认为这有可能是程序设计上的问题。每个编程语言的理念和风格几乎都会有明显的不同,我们常常需要顺应它们的纹理去做设计,而不是用其他语言的编程思想来编写当下语言的程序。 + + +再来说第二个值得注意的地方。我在生成error类型值的时候,用到了errors.New函数。 + +这是一种最基本的生成错误值的方式。我们调用它的时候传入一个由字符串代表的错误信息,它会给返回给我们一个包含了这个错误信息的error类型值。该值的静态类型当然是error,而动态类型则是一个在errors包中的,包级私有的类型*errorString。 + +显然,errorString类型拥有的一个指针方法实现了error接口中的Error方法。这个方法在被调用后,会原封不动地返回我们之前传入的错误信息。实际上,error类型值的Error方法就相当于其他类型值的String方法。 + +我们已经知道,通过调用fmt.Printf函数,并给定占位符%s就可以打印出某个值的字符串表示形式。 + +对于其他类型的值来说,只要我们能为这个类型编写一个String方法,就可以自定义它的字符串表示形式。而对于error类型值,它的字符串表示形式则取决于它的Error方法。 + +在上述情况下,fmt.Printf函数如果发现被打印的值是一个error类型的值,那么就会去调用它的Error方法。fmt包中的这类打印函数其实都是这么做的。 + +顺便提一句,当我们想通过模板化的方式生成错误信息,并得到错误值时,可以使用fmt.Errorf函数。该函数所做的其实就是先调用fmt.Sprintf函数,得到确切的错误信息;再调用errors.New函数,得到包含该错误信息的error类型值,最后返回该值。 + +好了,我现在问一个关于对错误值做判断的问题。我们今天的问题是:对于具体错误的判断,Go语言中都有哪些惯用法? + +由于error是一个接口类型,所以即使同为error类型的错误值,它们的实际类型也可能不同。这个问题还可以换一种问法,即:怎样判断一个错误值具体代表的是哪一类错误? + +这道题的典型回答是这样的: + + +对于类型在已知范围内的一系列错误值,一般使用类型断言表达式或类型switch语句来判断; +对于已有相应变量且类型相同的一系列错误值,一般直接使用判等操作来判断; +对于没有相应变量且类型未知的一系列错误值,只能使用其错误信息的字符串表示形式来做判断。 + + +问题解析 + +如果你看过一些Go语言标准库的源代码,那么对这几种情况应该都不陌生。我下面分别对它们做个说明。 + +类型在已知范围内的错误值其实是最容易分辨的。就拿os包中的几个代表错误的类型os.PathError、os.LinkError、os.SyscallError和os/exec.Error来说,它们的指针类型都是error接口的实现类型,同时它们也都包含了一个名叫Err,类型为error接口类型的代表潜在错误的字段。 + +如果我们得到一个error类型值,并且知道该值的实际类型肯定是它们中的某一个,那么就可以用类型switch语句去做判断。例如: + +func underlyingError(err error) error { + switch err := err.(type) { + case *os.PathError: + return err.Err + case *os.LinkError: + return err.Err + case *os.SyscallError: + return err.Err + case *exec.Error: + return err.Err + } + return err +} + + +函数underlyingError的作用是:获取和返回已知的操作系统相关错误的潜在错误值。其中的类型switch语句中有若干个case子句,分别对应了上述几个错误类型。当它们被选中时,都会把函数参数err的Err字段作为结果值返回。如果它们都未被选中,那么该函数就会直接把参数值作为结果返回,即放弃获取潜在错误值。 + +只要类型不同,我们就可以如此分辨。但是在错误值类型相同的情况下,这些手段就无能为力了。在Go语言的标准库中也有不少以相同方式创建的同类型的错误值。 + +我们还拿os包来说,其中不少的错误值都是通过调用errors.New函数来初始化的,比如:os.ErrClosed、os.ErrInvalid以及os.ErrPermission,等等。 + +注意,与前面讲到的那些错误类型不同,这几个都是已经定义好的、确切的错误值。os包中的代码有时候会把它们当做潜在错误值,封装进前面那些错误类型的值中。 + +如果我们在操作文件系统的时候得到了一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那么就可以用普通的switch语句去做判断,当然了,用if语句和判等操作符也是可以的。例如: + +printError := func(i int, err error) { + if err == nil { + fmt.Println("nil error") + return + } + err = underlyingError(err) + switch err { + case os.ErrClosed: + fmt.Printf("error(closed)[%d]: %s\n", i, err) + case os.ErrInvalid: + fmt.Printf("error(invalid)[%d]: %s\n", i, err) + case os.ErrPermission: + fmt.Printf("error(permission)[%d]: %s\n", i, err) + } +} + + +这个由printError变量代表的函数会接受一个error类型的参数值。该值总会代表某个文件操作相关的错误,这是我故意地以不正确的方式操作文件后得到的。 + +虽然我不知道这些错误值的类型的范围,但却知道它们或它们的潜在错误值一定是某个已经在os包中定义的值。 + +所以,我先用underlyingError函数得到它们的潜在错误值,当然也可能只得到原错误值而已。然后,我用switch语句对错误值进行判等操作,三个case子句分别对应我刚刚提到的那三个已存在于os包中的错误值。如此一来,我就能分辨出具体错误了。 + +对于上面这两种情况,我们都有明确的方式去解决。但是,如果我们对一个错误值可能代表的含义知之甚少,那么就只能通过它拥有的错误信息去做判断了。 + +好在我们总是能通过错误值的Error方法,拿到它的错误信息。其实os包中就有做这种判断的函数,比如:os.IsExist、os.IsNotExist和os.IsPermission。命令源码文件demo45.go中包含了对它们的应用,这大致跟前面展示的代码差不太多,我就不在这里赘述了。 + +总结 + +今天我们一起初步学习了错误处理的内容。我们总结了错误类型、错误值的处理技巧和设计方式,并一起分享了Go语言中处理错误的最基本方式。由于错误处理的内容分为上下两篇,在下一次的文章中,我们会站在建造者的角度,一起来探索一下:怎样根据实际情况给予恰当的错误值。 + +思考题 + +请列举出你经常用到或者看到的3个错误类型,它们所在的错误类型体系都是怎样的?你能画出一棵树来描述它们吗? + +感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/20错误处理(下).md b/专栏/Go语言核心36讲/20错误处理(下).md new file mode 100644 index 0000000..0419e56 --- /dev/null +++ b/专栏/Go语言核心36讲/20错误处理(下).md @@ -0,0 +1,95 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 错误处理 (下) + 你好,我是郝林,今天我们继续来分享错误处理。 + +在上一篇文章中,我们主要讨论的是从使用者的角度看“怎样处理好错误值”。那么,接下来我们需要关注的,就是站在建造者的角度,去关心“怎样才能给予使用者恰当的错误值”的问题了。 + +知识扩展 + +问题:怎样根据实际情况给予恰当的错误值? + +我们已经知道,构建错误值体系的基本方式有两种,即:创建立体的错误类型体系和创建扁平的错误值列表。 + +先说错误类型体系。由于在Go语言中实现接口是非侵入式的,所以我们可以做得很灵活。比如,在标准库的net代码包中,有一个名为Error的接口类型。它算是内建接口类型error的一个扩展接口,因为error是net.Error的嵌入接口。 + +net.Error接口除了拥有error接口的Error方法之外,还有两个自己声明的方法:Timeout和Temporary。 + +net包中有很多错误类型都实现了net.Error接口,比如: + + +*net.OpError; +*net.AddrError; +net.UnknownNetworkError等等。 + + +你可以把这些错误类型想象成一棵树,内建接口error就是树的根,而net.Error接口就是一个在根上延伸的第一级非叶子节点。 + +同时,你也可以把这看做是一种多层分类的手段。当net包的使用者拿到一个错误值的时候,可以先判断它是否是net.Error类型的,也就是说该值是否代表了一个网络相关的错误。 + +如果是,那么我们还可以再进一步判断它的类型是哪一个更具体的错误类型,这样就能知道这个网络相关的错误具体是由于操作不当引起的,还是因为网络地址问题引起的,又或是由于网络协议不正确引起的。 + +当我们细看net包中的这些具体错误类型的实现时,还会发现,与os包中的一些错误类型类似,它们也都有一个名为Err、类型为error接口类型的字段,代表的也是当前错误的潜在错误。 + +所以说,这些错误类型的值之间还可以有另外一种关系,即:链式关系。比如说,使用者调用net.DialTCP之类的函数时,net包中的代码可能会返回给他一个*net.OpError类型的错误值,以表示由于他的操作不当造成了一个错误。 + +同时,这些代码还可能会把一个*net.AddrError或net.UnknownNetworkError类型的值赋给该错误值的Err字段,以表明导致这个错误的潜在原因。如果,此处的潜在错误值的Err字段也有非nil的值,那么将会指明更深层次的错误原因。如此一级又一级就像链条一样最终会指向问题的根源。 + +把以上这些内容总结成一句话就是,用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。这是Go语言标准库给予我们的优秀范本,非常有借鉴意义。 + +不过要注意,如果你不想让包外代码改动你返回的错误值的话,一定要小写其中字段的名称首字母。你可以通过暴露某些方法让包外代码有进一步获取错误信息的权限,比如编写一个可以返回包级私有的err字段值的公开方法Err。 + +相比于立体的错误类型体系,扁平的错误值列表就要简单得多了。当我们只是想预先创建一些代表已知错误的错误值时候,用这种扁平化的方式就很恰当了。 + +不过,由于error是接口类型,所以通过errors.New函数生成的错误值只能被赋给变量,而不能赋给常量,又由于这些代表错误的变量需要给包外代码使用,所以其访问权限只能是公开的。 + +这就带来了一个问题,如果有恶意代码改变了这些公开变量的值,那么程序的功能就必然会受到影响。因为在这种情况下我们往往会通过判等操作来判断拿到的错误值具体是哪一个错误,如果这些公开变量的值被改变了,那么相应的判等操作的结果也会随之改变。 + +这里有两个解决方案。第一个方案是,先私有化此类变量,也就是说,让它们的名称首字母变成小写,然后编写公开的用于获取错误值以及用于判等错误值的函数。 + +比如,对于错误值os.ErrClosed,先改写它的名称,让其变成os.errClosed,然后再编写ErrClosed函数和IsErrClosed函数。 + +当然了,这不是说让你去改动标准库中已有的代码,这样做的危害会很大,甚至是致命的。我只能说,对于你可控的代码,最好还是要尽量收紧访问权限。 + +再来说第二个方案,此方案存在于syscall包中。该包中有一个类型叫做Errno,该类型代表了系统调用时可能发生的底层错误。这个错误类型是error接口的实现类型,同时也是对内建类型uintptr的再定义类型。 + +由于uintptr可以作为常量的类型,所以syscall.Errno自然也可以。syscall包中声明有大量的Errno类型的常量,每个常量都对应一种系统调用错误。syscall包外的代码可以拿到这些代表错误的常量,但却无法改变它们。 + +我们可以仿照这种声明方式来构建我们自己的错误值列表,这样就可以保证错误值的只读特性了。 + +好了,总之,扁平的错误值列表虽然相对简单,但是你一定要知道其中的隐患以及有效的解决方案是什么。 + +总结 + +今天,我从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。我们先一起看了一下Go语言中处理错误的最基本方式,这涉及了函数结果列表设计、errors.New函数、卫述语句以及使用打印函数输出错误值。 + +接下来,我提出的第一个问题是关于错误判断的。对于一个错误值来说,我们可以获取到它的类型、值以及它携带的错误信息。 + +如果我们可以确定其类型范围或者值的范围,那么就可以使用一些明确的手段获知具体的错误种类。否则,我们就只能通过匹配其携带的错误信息来大致区分它们的种类。 + +由于底层系统给予我们的错误信息还是很有规律可循的,所以用这种方式去判断效果还比较显著。但是第三方程序给出的错误信息很可能就没那么规整了,这种情况下靠错误信息去辨识种类就会比较困难。 + +有了以上阐释,当把视角从使用者换位到建造者,我们往往就会去自觉地仔细思考程序错误体系的设计了。我在这里提出了两个在Go语言标准库中使用很广泛的方案,即:立体的错误类型体系和扁平的错误值列表。 + +之所以说错误类型体系是立体的,是因为从整体上看它往往呈现出树形的结构。通过接口间的嵌套以及接口的实现,我们就可以构建出一棵错误类型树。 + +通过这棵树,使用者就可以一步步地确定错误值的种类了。另外,为了追根溯源的需要,我们还可以在错误类型中,统一安放一个可以代表潜在错误的字段。这叫做链式的错误关联,可以帮助使用者找到错误的根源。 + +相比之下,错误值列表就比较简单了。它其实就是若干个名称不同但类型相同的错误值集合。 + +不过需要注意的是,如果它们是公开的,那就应该尽量让它们成为常量而不是变量,或者编写私有的错误值以及公开的获取和判等函数,否则就很难避免恶意的篡改。 + +这其实是“最小化访问权限”这个程序设计原则的一个具体体现。无论怎样设计程序错误体系,我们都应该把这一点考虑在内。 + +思考题 + +请列举出你经常用到或者看到的3个错误值,它们分别在哪个错误值列表里?这些错误值列表分别包含的是哪个种类的错误? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/21panic函数、recover函数以及defer语句(上).md b/专栏/Go语言核心36讲/21panic函数、recover函数以及defer语句(上).md new file mode 100644 index 0000000..12bbe3a --- /dev/null +++ b/专栏/Go语言核心36讲/21panic函数、recover函数以及defer语句(上).md @@ -0,0 +1,111 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 panic函数、recover函数以及defer语句 (上) + 我在上两篇文章中,详细地讲述了Go语言中的错误处理,并从两个视角为你总结了错误类型、错误值的处理技巧和设计方式。 + +在本篇,我要给你展示Go语言的另外一种错误处理方式。不过,严格来说,它处理的不是错误,而是异常,并且是一种在我们意料之外的程序异常。 + +前导知识:运行时恐慌panic + +这种程序异常被叫做panic,我把它翻译为运行时恐慌。其中的“恐慌”二字是由panic直译过来的,而之所以前面又加上了“运行时”三个字,是因为这种异常只会在程序运行的时候被抛出来。 + +我们举个具体的例子来看看。 + +比如说,一个Go程序里有一个切片,它的长度是5,也就是说该切片中的元素值的索引分别为0、1、2、3、4,但是,我在程序里却想通过索引5访问其中的元素值,显而易见,这样的访问是不正确的。 + +Go程序,确切地说是程序内嵌的Go语言运行时系统,会在执行到这行代码的时候抛出一个“index out of range”的panic,用以提示你索引越界了。 + +当然了,这不仅仅是个提示。当panic被抛出之后,如果我们没有在程序里添加任何保护措施的话,程序(或者说代表它的那个进程)就会在打印出panic的详细情况(以下简称panic详情)之后,终止运行。 + +现在,就让我们来看一下这样的panic详情中都有什么。 + +panic: runtime error: index out of range + +goroutine 1 [running]: +main.main() + /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q0/demo47.go:5 +0x3d +exit status 2 + + +这份详情的第一行是“panic: runtime error: index out of range”。其中的“runtime error”的含义是,这是一个runtime代码包中抛出的panic。在这个panic中,包含了一个runtime.Error接口类型的值。runtime.Error接口内嵌了error接口,并做了一点点扩展,runtime包中有不少它的实现类型。 + +实际上,此详情中的“panic:”右边的内容,正是这个panic包含的runtime.Error类型值的字符串表示形式。 + +此外,panic详情中,一般还会包含与它的引发原因有关的goroutine的代码执行信息。正如前述详情中的“goroutine 1 [running]”,它表示有一个ID为1的goroutine在此panic被引发的时候正在运行。 + +注意,这里的ID其实并不重要,因为它只是Go语言运行时系统内部给予的一个goroutine编号,我们在程序中是无法获取和更改的。 + +我们再看下一行,“main.main()”表明了这个goroutine包装的go函数就是命令源码文件中的那个main函数,也就是说这里的goroutine正是主goroutine。再下面的一行,指出的就是这个goroutine中的哪一行代码在此panic被引发时正在执行。 + +这包含了此行代码在其所属的源码文件中的行数,以及这个源码文件的绝对路径。这一行最后的+0x3d代表的是:此行代码相对于其所属函数的入口程序计数偏移量。不过,一般情况下它的用处并不大。 + +最后,“exit status 2”表明我的这个程序是以退出状态码2结束运行的。在大多数操作系统中,只要退出状态码不是0,都意味着程序运行的非正常结束。在Go语言中,因panic导致程序结束运行的退出状态码一般都会是2。 + +综上所述,我们从上边的这个panic详情可以看出,作为此panic的引发根源的代码处于demo47.go文件中的第5行,同时被包含在main包(也就是命令源码文件所在的代码包)的main函数中。 + +那么,我的第一个问题也随之而来了。我今天的问题是:从panic被引发到程序终止运行的大致过程是什么? + +这道题的典型回答是这样的。 + +我们先说一个大致的过程:某个函数中的某行代码有意或无意地引发了一个panic。这时,初始的panic详情会被建立起来,并且该程序的控制权会立即从此行代码转移至调用其所属函数的那行代码上,也就是调用栈中的上一级。 + +这也意味着,此行代码所属函数的执行随即终止。紧接着,控制权并不会在此有片刻的停留,它又会立即转移至再上一级的调用代码处。控制权如此一级一级地沿着调用栈的反方向传播至顶端,也就是我们编写的最外层函数那里。 + +这里的最外层函数指的是go函数,对于主goroutine来说就是main函数。但是控制权也不会停留在那里,而是被Go语言运行时系统收回。 + +随后,程序崩溃并终止运行,承载程序这次运行的进程也会随之死亡并消失。与此同时,在这个控制权传播的过程中,panic详情会被逐渐地积累和完善,并会在程序终止之前被打印出来。 + +问题解析 + +panic可能是我们在无意间(或者说一不小心)引发的,如前文所述的索引越界。这类panic是真正的、在我们意料之外的程序异常。不过,除此之外,我们还是可以有意地引发panic。 + +Go语言的内建函数panic是专门用于引发panic的。panic函数使程序开发者可以在程序运行期间报告异常。 + +注意,这与从函数返回错误值的意义是完全不同的。当我们的函数返回一个非nil的错误值时,函数的调用方有权选择不处理,并且不处理的后果往往是不致命的。 + +这里的“不致命”的意思是,不至于使程序无法提供任何功能(也可以说僵死)或者直接崩溃并终止运行(也就是真死)。 + +但是,当一个panic发生时,如果我们不施加任何保护措施,那么导致的直接后果就是程序崩溃,就像前面描述的那样,这显然是致命的。 + +为了更清楚地展示答案中描述的过程,我编写了demo48.go文件。你可以先查看一下其中的代码,再试着运行它,并体会它打印的内容所代表的含义。 + +我在这里再提示一点。panic详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。 + +因此,在针对某个goroutine的代码执行信息中,调用栈底端的信息会先出现,然后是上一级调用的信息,以此类推,最后才是此调用栈顶端的信息。 + +比如,main函数调用了caller1函数,而caller1函数又调用了caller2函数,那么caller2函数中代码的执行信息会先出现,然后是caller1函数中代码的执行信息,最后才是main函数的信息。 + +goroutine 1 [running]: +main.caller2() + /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:22 +0x91 +main.caller1() + /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:15 +0x66 +main.main() + /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q1/demo48.go:9 +0x66 +exit status 2 + + + + +(从panic到程序崩溃) + +好了,到这里,我相信你已经对panic被引发后的程序终止过程有一定的了解了。深入地了解此过程,以及正确地解读panic详情应该是我们的必备技能,这在调试Go程序或者为Go程序排查错误的时候非常重要。 + +总结 + +最近的两篇文章,我们是围绕着panic函数、recover函数以及defer语句进行的。今天我主要讲了panic函数。这个函数是专门被用来引发panic的。panic也可以被称为运行时恐慌,它是一种只能在程序运行期间抛出的程序异常。 + +Go语言的运行时系统可能会在程序出现严重错误时自动地抛出panic,我们在需要时也可以通过调用panic函数引发panic。但不论怎样,如果不加以处理,panic就会导致程序崩溃并终止运行。 + +思考题 + +一个函数怎样才能把panic转化为error类型值,并将其作为函数的结果值返回给调用方? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/22panic函数、recover函数以及defer语句(下).md b/专栏/Go语言核心36讲/22panic函数、recover函数以及defer语句(下).md new file mode 100644 index 0000000..ff299d2 --- /dev/null +++ b/专栏/Go语言核心36讲/22panic函数、recover函数以及defer语句(下).md @@ -0,0 +1,176 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 panic函数、recover函数以及defer语句(下) + 你好,我是郝林,今天我们继续来聊聊panic函数、recover函数以及defer语句的内容。 + +我在前一篇文章提到过这样一个说法,panic之中可以包含一个值,用于简要解释引发此panic的原因。 + +如果一个panic是我们在无意间引发的,那么其中的值只能由Go语言运行时系统给定。但是,当我们使用panic函数有意地引发一个panic的时候,却可以自行指定其包含的值。我们今天的第一个问题就是针对后一种情况提出的。 + +知识扩展 + +问题 1:怎样让panic包含一个值,以及应该让它包含什么样的值? + +这其实很简单,在调用panic函数时,把某个值作为参数传给该函数就可以了。由于panic函数的唯一一个参数是空接口(也就是interface{})类型的,所以从语法上讲,它可以接受任何类型的值。 + +但是,我们最好传入error类型的错误值,或者其他的可以被有效序列化的值。这里的“有效序列化”指的是,可以更易读地去表示形式转换。 + +还记得吗?对于fmt包下的各种打印函数来说,error类型值的Error方法与其他类型值的String方法是等价的,它们的唯一结果都是string类型的。 + +我们在通过占位符%s打印这些值的时候,它们的字符串表示形式分别都是这两种方法产出的。 + +一旦程序异常了,我们就一定要把异常的相关信息记录下来,这通常都是记到程序日志里。 + +我们在为程序排查错误的时候,首先要做的就是查看和解读程序日志;而最常用也是最方便的日志记录方式,就是记下相关值的字符串表示形式。 + +所以,如果你觉得某个值有可能会被记到日志里,那么就应该为它关联String方法。如果这个值是error类型的,那么让它的Error方法返回你为它定制的字符串表示形式就可以了。 + +对于此,你可能会想到fmt.Sprintf,以及fmt.Fprintf这类可以格式化并输出参数的函数。 + +是的,它们本身就可以被用来输出值的某种表示形式。不过,它们在功能上,肯定远不如我们自己定义的Error方法或者String方法。因此,为不同的数据类型分别编写这两种方法总是首选。 + +可是,这与传给panic函数的参数值又有什么关系呢?其实道理是相同的。至少在程序崩溃的时候,panic包含的那个值字符串表示形式会被打印出来。 + +另外,我们还可以施加某种保护措施,避免程序的崩溃。这个时候,panic包含的值会被取出,而在取出之后,它一般都会被打印出来或者记录到日志里。 + +既然说到了应对panic的保护措施,我们再来看下面一个问题。 + +问题 2:怎样施加应对panic的保护措施,从而避免程序崩溃? + +Go语言的内建函数recover专用于恢复panic,或者说平息运行时恐慌。recover函数无需任何参数,并且会返回一个空接口类型的值。 + +如果用法正确,这个值实际上就是即将恢复的panic包含的值。并且,如果这个panic是因我们调用panic函数而引发的,那么该值同时也会是我们此次调用panic函数时,传入的参数值副本。请注意,这里强调用法的正确。我们先来看看什么是不正确的用法。 + +package main + +import ( + "fmt" + "errors" +) + +func main() { + fmt.Println("Enter function main.") + // 引发panic。 + panic(errors.New("something wrong")) + p := recover() + fmt.Printf("panic: %s\n", p) + fmt.Println("Exit function main.") +} + + +在上面这个main函数中,我先通过调用panic函数引发了一个panic,紧接着想通过调用recover函数恢复这个panic。可结果呢?你一试便知,程序依然会崩溃,这个recover函数调用并不会起到任何作用,甚至都没有机会执行。 + +还记得吗?我提到过panic一旦发生,控制权就会讯速地沿着调用栈的反方向传播。所以,在panic函数调用之后的代码,根本就没有执行的机会。 + +那如果我把调用recover函数的代码提前呢?也就是说,先调用recover函数,再调用panic函数会怎么样呢? + +这显然也是不行的,因为,如果在我们调用recover函数时未发生panic,那么该函数就不会做任何事情,并且只会返回一个nil。 + +换句话说,这样做毫无意义。那么,到底什么才是正确的recover函数用法呢?这就不得不提到defer语句了。 + +顾名思义,defer语句就是被用来延迟执行代码的。延迟到什么时候呢?这要延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。 + +这与go语句有些类似,一个defer语句总是由一个defer关键字和一个调用表达式组成。 + +这里存在一些限制,有一些调用表达式是不能出现在这里的,包括:针对Go语言内建函数的调用表达式,以及针对unsafe包中的函数的调用表达式。 + +顺便说一下,对于go语句中的调用表达式,限制也是一样的。另外,在这里被调用的函数可以是有名称的,也可以是匿名的。我们可以把这里的函数叫做defer函数或者延迟函数。注意,被延迟执行的是defer函数,而不是defer语句。 + +我刚才说了,无论函数结束执行的原因是什么,其中的defer函数调用都会在它即将结束执行的那一刻执行。即使导致它执行结束的原因是一个panic也会是这样。正因为如此,我们需要联用defer语句和recover函数调用,才能够恢复一个已经发生的panic。 + +我们来看一下经过修正的代码。 + +package main + +import ( + "fmt" + "errors" +) + +func main() { + fmt.Println("Enter function main.") + defer func(){ + fmt.Println("Enter defer function.") + if p := recover(); p != nil { + fmt.Printf("panic: %s\n", p) + } + fmt.Println("Exit defer function.") + }() + // 引发panic。 + panic(errors.New("something wrong")) + fmt.Println("Exit function main.") +} + + +在这个main函数中,我先编写了一条defer语句,并在defer函数中调用了recover函数。仅当调用的结果值不为nil时,也就是说只有panic确实已发生时,我才会打印一行以“panic:”为前缀的内容。 + +紧接着,我调用了panic函数,并传入了一个error类型值。这里一定要注意,我们要尽量把defer语句写在函数体的开始处,因为在引发panic的语句之后的所有语句,都不会有任何执行机会。 + +也只有这样,defer函数中的recover函数调用才会拦截,并恢复defer语句所属的函数,及其调用的代码中发生的所有panic。 + +至此,我向你展示了两个很典型的recover函数的错误用法,以及一个基本的正确用法。 + +我希望你能够记住错误用法背后的缘由,同时也希望你能真正地理解联用defer语句和recover函数调用的真谛。 + +在命令源码文件demo50.go中,我把上述三种用法合并在了一段代码中。你可以运行该文件,并体会各种用法所产生的不同效果。 + +下面我再来多说一点关于defer语句的事情。 + +问题 3:如果一个函数中有多条defer语句,那么那几个defer函数调用的执行顺序是怎样的? + +如果只用一句话回答的话,那就是:在同一个函数中,defer函数调用的执行顺序与它们分别所属的defer语句的出现顺序(更严谨地说,是执行顺序)完全相反。 + +当一个函数即将结束执行时,其中的写在最下边的defer函数调用会最先执行,其次是写在它上边、与它的距离最近的那个defer函数调用,以此类推,最上边的defer函数调用会最后一个执行。 + +如果函数中有一条for语句,并且这条for语句中包含了一条defer语句,那么,显然这条defer语句的执行次数,就取决于for语句的迭代次数。 + +并且,同一条defer语句每被执行一次,其中的defer函数调用就会产生一次,而且,这些函数调用同样不会被立即执行。 + +那么问题来了,这条for语句中产生的多个defer函数调用,会以怎样的顺序执行呢? + +为了彻底搞清楚,我们需要弄明白defer语句执行时发生的事情。 + +其实也并不复杂,在defer语句每次执行的时候,Go语言会把它携带的defer函数及其参数值另行存储到一个链表中。 + +这个链表与该defer语句所属的函数是对应的,并且,它是先进后出(FILO)的,相当于一个栈。 + +在需要执行某个函数中的defer函数调用的时候,Go语言会先拿到对应的链表,然后从该链表中一个一个地取出defer函数及其参数值,并逐个执行调用。 + +这正是我说“defer函数调用与其所属的defer语句的执行顺序完全相反”的原因了。 + +下面该你出场了,我在demo51.go文件中编写了一个与本问题有关的示例,其中的核心代码很简单,只有几行而已。 + +我希望你先查看代码,然后思考并写下该示例被运行时,会打印出哪些内容。 + +如果你实在想不出来,那么也可以先运行示例,再试着解释打印出的内容。总之,你需要完全搞明白那几行内容为什么会以那样的顺序出现的确切原因。 + +总结 + +我们这两期的内容主要讲了两个函数和一条语句。recover函数专用于恢复panic,并且调用即恢复。 + +它在被调用时会返回一个空接口类型的结果值。如果在调用它时并没有panic发生,那么这个结果值就会是nil。 + +而如果被恢复的panic是我们通过调用panic函数引发的,那么它返回的结果值就会是我们传给panic函数参数值的副本。 + +对recover函数的调用只有在defer语句中才能真正起作用。defer语句是被用来延迟执行代码的。 + +更确切地说,它会让其携带的defer函数的调用延迟执行,并且会延迟到该defer语句所属的函数即将结束执行的那一刻。 + +在同一个函数中,延迟执行的defer函数调用,会与它们分别所属的defer语句的执行顺序完全相反。还要注意,同一条defer语句每被执行一次,就会产生一个延迟执行的defer函数调用。 + +这种情况在defer语句与for语句联用时经常出现。这时更要关注for语句中,同一条defer语句产生的多个defer函数调用的实际执行顺序。 + +以上这些,就是关于Go语言中特殊的程序异常,及其处理方式的核心知识。这里边可以衍生出很多面试题目。 + +思考题 + +我们可以在defer函数中恢复panic,那么可以在其中引发panic吗? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/23测试的基本规则和流程(上).md b/专栏/Go语言核心36讲/23测试的基本规则和流程(上).md new file mode 100644 index 0000000..35fc6b9 --- /dev/null +++ b/专栏/Go语言核心36讲/23测试的基本规则和流程(上).md @@ -0,0 +1,111 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 测试的基本规则和流程 (上) + 你好,我是郝林,今天我分享的主题是:测试的基本规则和流程(上)。 + +你很棒,已经学完了本专栏最大的一个模块!这涉及了Go语言的所有内建数据类型,以及非常有特色的那些流程和语句。 + +你已经完全可以去独立编写各种各样的Go程序了。如果忘了什么,回到之前的文章再复习一下就好了。 + +在接下来的日子里,我将带你去学习在Go语言编程进阶的道路上,必须掌握的附加知识,比如:Go程序测试、程序监测,以及Go语言标准库中各种常用代码包的正确用法。 + +从上个世纪到今日今时,程序员们,尤其是国内的程序员们,都对编写程序乐此不疲,甚至废寝忘食(比如我自己就是一个例子)。 + +因为这是我们普通人训练自我、改变生活、甚至改变世界的一种特有的途径。不过,同样是程序,我们却往往对编写用于测试的程序敬而远之。这是为什么呢? + +我个人感觉,从人的本性来讲,我们都或多或少会否定“对自我的否定”。我们不愿意看到我们编写的程序有Bug(即程序错误或缺陷),尤其是刚刚倾注心血编写的,并且信心满满交付的程序。 + +不过,我想说的是,人是否会进步以及进步得有多快,依赖的恰恰就是对自我的否定,这包括否定的深刻与否,以及否定自我的频率如何。这其实就是“不破不立”这个词表达的含义。 + +对于程序和软件来讲,尽早发现问题、修正问题其实非常重要。在这个网络互联的大背景下,我们所做的程序、工具或者软件产品往往可以被散布得更快、更远。但是,与此同时,它们的错误和缺陷也会是这样,并且可能在短时间内就会影响到成千上万甚至更多的用户。 + +你可能会说:“在开源模式下这就是优势啊,我就是要让更多的人帮我发现错误甚至修正错误,我们还可以一起协作、共同维护程序。”但这其实是两码事,协作者往往是由早期或核心的用户转换过来的,但绝对不能说程序的用户就肯定会成为协作者。 + +当有很多用户开始对程序抱怨的时候,很可能就预示着你对此的人设要崩塌了。你会发现,或者总有一天会发现,越是人们关注和喜爱的程序,它的测试(尤其是自动化的测试)做得就越充分,测试流程就越规范。 + +即使你想众人拾柴火焰高,那也得先让别人喜欢上你的程序。况且,对于优良的程序和软件来说,测试必然是非常受重视的一个环节。所以,尽快用测试为你的程序建起堡垒吧! + + + +对于程序或软件的测试也分很多种,比如:单元测试、API测试、集成测试、灰度测试,等等。我在本模块会主要针对单元测试进行讲解。 + +前导内容:go程序测试基础知识 + +我们来说一下单元测试,它又称程序员测试。顾名思义,这就是程序员们本该做的自我检查工作之一。 + +Go语言的缔造者们从一开始就非常重视程序测试,并且为Go程序的开发者们提供了丰富的API和工具。利用这些API和工具,我们可以创建测试源码文件,并为命令源码文件和库源码文件中的程序实体,编写测试用例。 + +在Go语言中,一个测试用例往往会由一个或多个测试函数来代表,不过在大多数情况下,每个测试用例仅用一个测试函数就足够了。测试函数往往用于描述和保障某个程序实体的某方面功能,比如,该功能在正常情况下会因什么样的输入,产生什么样的输出,又比如,该功能会在什么情况下报错或表现异常,等等。 + +我们可以为Go程序编写三类测试,即:功能测试(test)、基准测试(benchmark,也称性能测试),以及示例测试(example)。 + +对于前两类测试,从名称上你就应该可以猜到它们的用途。而示例测试严格来讲也是一种功能测试,只不过它更关注程序打印出来的内容。 + +一般情况下,一个测试源码文件只会针对于某个命令源码文件,或库源码文件(以下简称被测源码文件)做测试,所以我们总会(并且应该)把它们放在同一个代码包内。 + +测试源码文件的主名称应该以被测源码文件的主名称为前导,并且必须以“_test”为后缀。例如,如果被测源码文件的名称为demo52.go,那么针对它的测试源码文件的名称就应该是demo52_test.go。 + +每个测试源码文件都必须至少包含一个测试函数。并且,从语法上讲,每个测试源码文件中,都可以包含用来做任何一类测试的测试函数,即使把这三类测试函数都塞进去也没有问题。我通常就是这么做的,只要把控好测试函数的分组和数量就可以了。 + +我们可以依据这些测试函数针对的不同程序实体,把它们分成不同的逻辑组,并且,利用注释以及帮助类的变量或函数来做分割。同时,我们还可以依据被测源码文件中程序实体的先后顺序,来安排测试源码文件中测试函数的顺序。 + +此外,不仅仅对测试源码文件的名称,对于测试函数的名称和签名,Go语言也是有明文规定的。你知道这个规定的内容吗? + +所以,我们今天的问题就是:Go语言对测试函数的名称和签名都有哪些规定? + +这里我给出的典型回答是下面三个内容。 + + +对于功能测试函数来说,其名称必须以Test为前缀,并且参数列表中只应有一个*testing.T类型的参数声明。 +对于性能测试函数来说,其名称必须以Benchmark为前缀,并且唯一参数的类型必须是*testing.B类型的。 +对于示例测试函数来说,其名称必须以Example为前缀,但对函数的参数列表没有强制规定。 + + +问题解析 + +我问这个问题的目的一般有两个。 + + +第一个目的当然是考察Go程序测试的基本规则。如果你经常编写测试源码文件,那么这道题应该是很容易回答的。 + +第二个目的是作为一个引子,引出第二个问题,即:go test命令执行的主要测试流程是什么?不过在这里我就不问你了,我直接说一下答案。 + + +我们首先需要记住一点,只有测试源码文件的名称对了,测试函数的名称和签名也对了,当我们运行go test命令的时候,其中的测试代码才有可能被运行。 + +go test命令在开始运行时,会先做一些准备工作,比如,确定内部需要用到的命令,检查我们指定的代码包或源码文件的有效性,以及判断我们给予的标记是否合法,等等。 + +在准备工作顺利完成之后,go test命令就会针对每个被测代码包,依次地进行构建、执行包中符合要求的测试函数,清理临时文件,打印测试结果。这就是通常情况下的主要测试流程。 + +请注意上述的“依次”二字。对于每个被测代码包,go test命令会串行地执行测试流程中的每个步骤。 + +但是,为了加快测试速度,它通常会并发地对多个被测代码包进行功能测试,只不过,在最后打印测试结果的时候,它会依照我们给定的顺序逐个进行,这会让我们感觉到它是在完全串行地执行测试流程。 + +另一方面,由于并发的测试会让性能测试的结果存在偏差,所以性能测试一般都是串行进行的。更具体地说,只有在所有构建步骤都做完之后,go test命令才会真正地开始进行性能测试。 + +并且,下一个代码包性能测试的进行,总会等到上一个代码包性能测试的结果打印完成才会开始,而且性能测试函数的执行也都会是串行的。 + +一旦清楚了Go程序测试的具体过程,我们的一些疑惑就自然有了答案。比如,那个名叫testIntroduce的测试函数为什么没执行,又比如,为什么即使是简单的性能测试执行起来也会比功能测试慢,等等。 + +总结 + +在本篇文章的一开始,我就试图向你阐释程序测试的重要性。在我经历的公司中起码有一半都不重视程序测试,或者说没有精力去做程序测试。 + +尤其是中小型的公司,他们往往完全依靠软件质量保障团队,甚至真正的用户去帮他们测试。在这些情况下,软件错误或缺陷的发现、反馈和修复的周期通常会很长,成本也会很大,也许还会造成很不好的影响。 + +Go语言是一门很重视程序测试的编程语言,它不但自带了testing包,还有专用于程序测试的命令go test。我们要想真正用好一个工具,就需要先了解它的核心逻辑。所以,我今天问你的第一个问题就是关于go test命令的基本规则和主要流程的。在知道这些之后,也许你对Go程序测试就会进入更深层次的了解。 + +思考题 + +除了本文中提到的,你还知道或用过testing.T类型和testing.B类型的哪些方法?它们都是做什么用的?你可以给我留言,我们一起讨论。 + +感谢你的收听,我们下次再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/24测试的基本规则和流程(下).md b/专栏/Go语言核心36讲/24测试的基本规则和流程(下).md new file mode 100644 index 0000000..27643f6 --- /dev/null +++ b/专栏/Go语言核心36讲/24测试的基本规则和流程(下).md @@ -0,0 +1,161 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 测试的基本规则和流程(下) + 你好,我是郝林。今天我分享的主题是测试的基本规则和流程的(下)篇。 + +Go语言是一门很重视程序测试的编程语言,所以在上一篇中,我与你再三强调了程序测试的重要性,同时,也介绍了关于go test命令的基本规则和主要流程的内容。今天我们继续分享测试的基本规则和流程。本篇代码和指令较多,你可以点击文章查看原文。 + +知识扩展 + +问题 1:怎样解释功能测试的测试结果? + +我们先来看下面的测试命令和结果: + +$ go test puzzlers/article20/q2 +ok puzzlers/article20/q2 0.008s + + +以$符号开头表明此行展现的是我输入的命令。在这里,我输入了go test puzzlers/article20/q2,这表示我想对导入路径为puzzlers/article20/q2的代码包进行测试。代码下面一行就是此次测试的简要结果。 + +这个简要结果有三块内容。最左边的ok表示此次测试成功,也就是说没有发现测试结果不如预期的情况。 + +当然了,这里全由我们编写的测试代码决定,我们总是认定测试代码本身没有Bug,并且忠诚地落实了我们的测试意图。在测试结果的中间,显示的是被测代码包的导入路径。 + +而在最右边,展现的是此次对该代码包的测试所耗费的时间,这里显示的0.008s,即8毫秒。不过,当我们紧接着第二次运行这个命令的时候,输出的测试结果会略有不同,如下所示: + +$ go test puzzlers/article20/q2 +ok puzzlers/article20/q2 (cached) + + +可以看到,结果最右边的不再是测试耗时,而是(cached)。这表明,由于测试代码与被测代码都没有任何变动,所以go test命令直接把之前缓存测试成功的结果打印出来了。 + +go命令通常会缓存程序构建的结果,以便在将来的构建中重用。我们可以通过运行go env GOCACHE命令来查看缓存目录的路径。缓存的数据总是能够正确地反映出当时的各种源码文件、构建环境、编译器选项等等的真实情况。 + +一旦有任何变动,缓存数据就会失效,go命令就会再次真正地执行操作。所以我们并不用担心打印出的缓存数据不是实时的结果。go命令会定期地删除最近未使用的缓存数据,但是,如果你想手动删除所有的缓存数据,运行一下go clean -cache命令就好了。 + +对于测试成功的结果,go命令也是会缓存的。运行go clean -testcache将会删除所有的测试结果缓存。不过,这样做肯定不会删除任何构建结果缓存。 + + +此外,设置环境变量GODEBUG的值也可以稍稍地改变go命令的缓存行为。比如,设置值为gocacheverify=1将会导致go命令绕过任何的缓存数据,而真正地执行操作并重新生成所有结果,然后再去检查新的结果与现有的缓存数据是否一致。 + + +总之,我们并不用在意缓存数据的存在,因为它们肯定不会妨碍go test命令打印正确的测试结果。 + +你可能会问,如果测试失败,命令打印的结果将会是怎样的?如果功能测试函数的那个唯一参数被命名为t,那么当我们在其中调用t.Fail方法时,虽然当前的测试函数会继续执行下去,但是结果会显示该测试失败。如下所示: + +$ go test puzzlers/article20/q2 +--- FAIL: TestFail (0.00s) + demo53_test.go:49: Failed. +FAIL +FAIL puzzlers/article20/q2 0.007s + + +我们运行的命令与之前是相同的,但是我新增了一个功能测试函数TestFail,并在其中调用了t.Fail方法。测试结果显示,对被测代码包的测试,由于TestFail函数的测试失败而宣告失败。 + +注意,对于失败测试的结果,go test命令并不会进行缓存,所以,这种情况下的每次测试都会产生全新的结果。另外,如果测试失败了,那么go test命令将会导致:失败的测试函数中的常规测试日志一并被打印出来。 + +在这里的测试结果中,之所以显示了“demo53_test.go:49: Failed.”这一行,是因为我在TestFail函数中的调用表达式t.Fail()的下边编写了代码t.Log("Failed.")。 + +t.Log方法以及t.Logf方法的作用,就是打印常规的测试日志,只不过当测试成功的时候,go test命令就不会打印这类日志了。如果你想在测试结果中看到所有的常规测试日志,那么可以在运行go test命令的时候加入标记-v。 + + +若我们想让某个测试函数在执行的过程中立即失败,则可以在该函数中调用t.FailNow方法。 + +我在下面把TestFail函数中的t.Fail()改为t.FailNow()。 + +与t.Fail()不同,在t.FailNow()执行之后,当前函数会立即终止执行。换句话说,该行代码之后的所有代码都会失去执行机会。在这样修改之后,我再次运行上面的命令,得到的结果如下: + + +--- FAIL: TestFail (0.00s) +FAIL +FAIL puzzlers/article20/q2 0.008s + + + +显然,之前显示在结果中的常规测试日志并没有出现在这里。 + + +顺便说一下,如果你想在测试失败的同时打印失败测试日志,那么可以直接调用t.Error方法或者t.Errorf方法。 + +前者相当于t.Log方法和t.Fail方法的连续调用,而后者也与之类似,只不过它相当于先调用了t.Logf方法。 + +除此之外,还有t.Fatal方法和t.Fatalf方法,它们的作用是在打印失败错误日志之后立即终止当前测试函数的执行并宣告测试失败。更具体地说,这相当于它们在最后都调用了t.FailNow方法。 + +好了,到此为止,你是不是已经会解读功能测试的测试结果了呢? + +问题 2:怎样解释性能测试的测试结果? + +性能测试与功能测试的结果格式有很多相似的地方。我们在这里仅关注前者的特殊之处。请看下面的打印结果。 + +$ go test -bench=. -run=^$ puzzlers/article20/q3 +goos: darwin +goarch: amd64 +pkg: puzzlers/article20/q3 +BenchmarkGetPrimes-8 500000 2314 ns/op +PASS +ok puzzlers/article20/q3 1.192s + + +我在运行go test命令的时候加了两个标记。第一个标记及其值为-bench=.,只有有了这个标记,命令才会进行性能测试。该标记的值.表明需要执行任意名称的性能测试函数,当然了,函数名称还是要符合Go程序测试的基本规则的。 + +第二个标记及其值是-run=^$,这个标记用于表明需要执行哪些功能测试函数,这同样也是以函数名称为依据的。该标记的值^$意味着:只执行名称为空的功能测试函数,换句话说,不执行任何功能测试函数。 + +你可能已经看出来了,这两个标记的值都是正则表达式。实际上,它们只能以正则表达式为值。此外,如果运行go test命令的时候不加-run标记,那么就会使它执行被测代码包中的所有功能测试函数。 + +再来看测试结果,重点说一下倒数第三行的内容。BenchmarkGetPrimes-8被称为单个性能测试的名称,它表示命令执行了性能测试函数BenchmarkGetPrimes,并且当时所用的最大P数量为8。 + +最大P数量相当于可以同时运行goroutine的逻辑CPU的最大个数。这里的逻辑CPU,也可以被称为CPU核心,但它并不等同于计算机中真正的CPU核心,只是Go语言运行时系统内部的一个概念,代表着它同时运行goroutine的能力。 + +顺便说一句,一台计算机的CPU核心的个数,意味着它能在同一时刻执行多少条程序指令,代表着它并行处理程序指令的能力。 + +我们可以通过调用 runtime.GOMAXPROCS函数改变最大P数量,也可以在运行go test命令时,加入标记-cpu来设置一个最大P数量的列表,以供命令在多次测试时使用。 + +至于怎样使用这个标记,以及go test命令执行的测试流程,会因此做出怎样的改变,我们在下一篇文章中再讨论。 + +在性能测试名称右边的是,go test命令最后一次执行性能测试函数(即BenchmarkGetPrimes函数)的时候,被测函数(即GetPrimes函数)被执行的实际次数。这是什么意思呢? + +go test命令在执行性能测试函数的时候会给它一个正整数,若该测试函数的唯一参数的名称为b,则该正整数就由b.N代表。我们应该在测试函数中配合着编写代码,比如: + +for i := 0; i < b.N; i++ { + GetPrimes(1000) +} + + +我在一个会迭代b.N次的循环中调用了GetPrimes函数,并给予它参数值1000。go test命令会先尝试把b.N设置为1,然后执行测试函数。 + +如果测试函数的执行时间没有超过上限,此上限默认为1秒,那么命令就会改大b.N的值,然后再次执行测试函数,如此往复,直到这个时间大于或等于上限为止。 + +当某次执行的时间大于或等于上限时,我们就说这是命令此次对该测试函数的最后一次执行。这时的b.N的值就会被包含在测试结果中,也就是上述测试结果中的500000。 + +我们可以简称该值为执行次数,但要注意,它指的是被测函数的执行次数,而不是性能测试函数的执行次数。 + +最后再看这个执行次数的右边,2314 ns/op表明单次执行GetPrimes函数的平均耗时为2314纳秒。这其实就是通过将最后一次执行测试函数时的执行时间,除以(被测函数的)执行次数而得出的。 + + + +(性能测试结果的基本解读) + +以上这些,就是对默认情况下的性能测试结果的基本解读。你看明白了吗? + +总结 + +注意,对于功能测试和性能测试,命令执行测试流程的方式会有些不同。另外一个重要的问题是,我们在与go test命令交互时,怎样解读它提供给我们的信息。只有解读正确,你才能知道测试的成功与否,失败的具体原因以及严重程度等等。 + +除此之外,对于性能测试,你还需要关注命令输出的计算资源使用提示,以及各种性能度量。 + +这两篇的文章中,我们一起学习了不少东西,但是其实还不够。我们只是探讨了go test命令以及testing包的基本使用方式。 + +在下一篇,我们还会讨论更高级的内容。这将涉及go test命令的各种标记、testing包的更多API,以及更复杂的测试结果。 + +思考题 + +在编写示例测试函数的时候,我们怎样指定预期的打印内容? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/25更多的测试手法.md b/专栏/Go语言核心36讲/25更多的测试手法.md new file mode 100644 index 0000000..a8eb3a1 --- /dev/null +++ b/专栏/Go语言核心36讲/25更多的测试手法.md @@ -0,0 +1,209 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 更多的测试手法 + 在前面的文章中,我们一起学习了Go程序测试的基础知识和基本测试手法。这主要包括了Go程序测试的基本规则和主要流程、testing.T类型和testing.B类型的常用方法、go test命令的基本使用方式、常规测试结果的解读等等。 + +在本篇文章,我会继续为你讲解更多更高级的测试方法。这会涉及testing包中更多的API、go test命令支持的,更多标记更加复杂的测试结果,以及测试覆盖度分析等等。 + +前导内容:-cpu的功能 + +续接前文。我在前面提到了go test命令的标记-cpu,它是用来设置测试执行最大P数量的列表的。 + + +复习一下,我在讲go语句的时候说过,这里的P是processor的缩写,每个processor都是一个可以承载若干个G,且能够使这些G适时地与M进行对接并得到真正运行的中介。 + +正是由于P的存在,G和M才可以呈现出多对多的关系,并能够及时、灵活地进行组合和分离。 + +这里的G就是goroutine的缩写,可以被理解为Go语言自己实现的用户级线程。M即为machine的缩写,代表着系统级线程,或者说操作系统内核级别的线程。 + + +Go语言并发编程模型中的P,正是goroutine的数量能够数十万计的关键所在。P的数量意味着Go程序背后的运行时系统中,会有多少个用于承载可运行的G的队列存在。 + +每一个队列都相当于一条流水线,它会源源不断地把可运行的G输送给空闲的M,并使这两者对接。 + +一旦对接完成,被对接的G就真正地运行在操作系统的内核级线程之上了。每条流水线之间虽然会有联系,但都是独立运作的。 + +因此,最大P数量就代表着Go语言运行时系统同时运行goroutine的能力,也可以被视为其中逻辑CPU的最大个数。而go test命令的-cpu标记正是用于设置这个最大个数的。 + +也许你已经知道,在默认情况下,最大P数量就等于当前计算机CPU核心的实际数量。 + +当然了,前者也可以大于或者小于后者,如此可以在一定程度上模拟拥有不同的CPU核心数的计算机。 + +所以,也可以说,使用-cpu标记可以模拟:被测程序在计算能力不同计算机中的表现。 + +现在,你已经知道了-cpu标记的用途及其背后的含义。那么它的具体用法,以及对go test命令的影响你是否也清楚呢? + +我们今天的问题是:怎样设置-cpu标记的值,以及它会对测试流程产生什么样的影响? + +这里的典型回答是: + +标记-cpu的值应该是一个正整数的列表,该列表的表现形式为:以英文半角逗号分隔的多个整数字面量,比如1,2,4。 + +针对于此值中的每一个正整数,go test命令都会先设置最大P数量为该数,然后再执行测试函数。 + +如果测试函数有多个,那么go test命令会依照此方式逐个执行。 + + +以1,2,4为例,go test命令会先以1,2,4为最大P数量分别去执行第一个测试函数,之后再用同样的方式执行第二个测试函数,以此类推。 + + +问题解析 + +实际上,不论我们是否追加了-cpu标记,go test命令执行测试函数时流程都是相同的,只不过具体执行步骤会略有不同。 + +go test命令在进行准备工作的时候会读取-cpu标记的值,并把它转换为一个以int为元素类型的切片,我们也可以称它为逻辑CPU切片。 + +如果该命令发现我们并没有追加这个标记,那么就会让逻辑CPU切片只包含一个元素值,即最大P数量的默认值,也就是当前计算机CPU核心的实际数量。 + +在准备执行某个测试函数的时候,无论该函数是功能测试函数,还是性能测试函数,go test命令都会迭代逻辑CPU切片,并且在每次迭代时,先依据当前的元素值设置最大P数量,然后再去执行测试函数。 + +注意,对于性能测试函数来说,这里可能不只执行了一次。你还记得测试函数的执行时间上限,以及那个由b.N代表的被测程序的执行次数吗? + +如果你忘了,那么可以再复习一下上篇文章中的第二个扩展问题。概括来讲,go test命令每一次对性能测试函数的执行,都是一个探索的过程。它会在测试函数的执行时间上限不变的前提下,尝试找到被测程序的最大执行次数。 + +在这个过程中,性能测试函数可能会被执行多次。为了以后描述方便,我们把这样一个探索的过程称为:对性能测试函数的一次探索式执行,这其中包含了对该函数的若干次执行,当然,肯定也包括了对被测程序更多次的执行。 + +说到多次执行测试函数,我们就不得不提及另外一个标记,即-count。-count标记是专门用于重复执行测试函数的。它的值必须大于或等于0,并且默认值为1。 + +如果我们在运行go test命令的时候追加了-count 5,那么对于每一个测试函数,命令都会在预设的不同条件下(比如不同的最大P数量下)分别重复执行五次。 + +如果我们把前文所述的-cpu标记、-count标记,以及探索式执行联合起来看,就可以用一个公式来描述单个性能测试函数,在go test命令的一次运行过程中的执行次数,即: + +性能测试函数的执行次数 = `-cpu`标记的值中正整数的个数 x `-count`标记的值 x 探索式执行中测试函数的实际执行次数 + + +对于功能测试函数来说,这个公式会更加简单一些,即: + +功能测试函数的执行次数 = `-cpu`标记的值中正整数的个数 x `-count`标记的值 + + + + +(测试函数的实际执行次数) + +看完了这两个公式,我想,你也许遇到过这种情况,在对Go程序执行某种自动化测试的过程中,测试日志会显得特别多,而且好多都是重复的。 + +这时,我们首先就应该想到,上面这些导致测试函数多次执行的标记和流程。我们往往需要检查这些标记的使用是否合理、日志记录是否有必要等等,从而对测试日志进行精简。 + +比如,对于功能测试函数来说,我们通常没有必要重复执行它,即使是在不同的最大P数量下也是如此。注意,这里所说的重复执行指的是,在被测程序的输入(比如说被测函数的参数值)相同情况下的多次执行。 + +有些时候,在输入完全相同的情况下,被测程序会因其他外部环境的不同,而表现出不同的行为。这时我们需要考虑的往往应该是:这个程序在设计上是否合理,而不是通过重复执行测试来检测风险。 + +还有些时候,我们的程序会无法避免地依赖一些外部环境,比如数据库或者其他服务。这时,我们依然不应该让测试的反复执行成为检测手段,而应该在测试中通过仿造(mock)外部环境,来规避掉它们的不确定性。 + +其实,单元测试的意思就是:对单一的功能模块进行边界清晰的测试,并且不掺杂任何对外部环境的检测。这也是“单元”二字要表达的主要含义。 + +正好相反,对于性能测试函数来说,我们常常需要反复地执行,并以此试图抹平当时的计算资源调度的细微差别对被测程序性能的影响。通过-cpu标记,我们还能够模拟被测程序在计算能力不同计算机中的性能表现。 + +不过要注意,这里设置的最大P数量,最好不要超过当前计算机CPU核心的实际数量。因为一旦超出计算机实际的并行处理能力,Go程序在性能上就无法再得到显著地提升了。 + +这就像一个漏斗,不论我们怎样灌水,水的漏出速度总是有限的。更何况,为了管理过多的P,Go语言运行时系统还会耗费额外的计算资源。 + +显然,上述模拟得出的程序性能一定是不准确的。不过,这或多或少可以作为一个参考,因为,这样模拟出的性能一般都会低于程序在计算环境中的实际性能。 + +好了,关于-cpu标记,以及由此引出的-count标记和测试函数多次执行的问题,我们就先聊到这里。不过,为了让你再巩固一下前面的知识,我现在给出一段测试结果: + +pkg: puzzlers/article21/q1 +BenchmarkGetPrimesWith100-2 10000000 218 ns/op +BenchmarkGetPrimesWith100-2 10000000 215 ns/op +BenchmarkGetPrimesWith100-4 10000000 215 ns/op +BenchmarkGetPrimesWith100-4 10000000 216 ns/op +BenchmarkGetPrimesWith10000-2 50000 31523 ns/op +BenchmarkGetPrimesWith10000-2 50000 32372 ns/op +BenchmarkGetPrimesWith10000-4 50000 32065 ns/op +BenchmarkGetPrimesWith10000-4 50000 31936 ns/op +BenchmarkGetPrimesWith1000000-2 300 4085799 ns/op +BenchmarkGetPrimesWith1000000-2 300 4121975 ns/op +BenchmarkGetPrimesWith1000000-4 300 4112283 ns/op +BenchmarkGetPrimesWith1000000-4 300 4086174 ns/op + + +现在,我希望让你反推一下,我在运行go test命令时追加的-cpu标记和-count标记的值都是什么。反推之后,你可以用实验的方式进行验证。 + +知识扩展 + +问题1:-parallel标记的作用是什么? + +我们在运行go test命令的时候,可以追加标记-parallel,该标记的作用是:设置同一个被测代码包中的功能测试函数的最大并发执行数。该标记的默认值是测试运行时的最大P数量(这可以通过调用表达式runtime.GOMAXPROCS(0)获得)。 + +我在上篇文章中已经说过,对于功能测试,为了加快测试速度,命令通常会并发地测试多个被测代码包。 + +但是,在默认情况下,对于同一个被测代码包中的多个功能测试函数,命令会串行地执行它们。除非我们在一些功能测试函数中显式地调用t.Parallel方法。 + +这个时候,这些包含了t.Parallel方法调用的功能测试函数就会被go test命令并发地执行,而并发执行的最大数量正是由-parallel标记值决定的。不过要注意,同一个功能测试函数的多次执行之间一定是串行的。 + +你可以运行命令go test -v puzzlers/article21/q2或者go test -count=2 -v puzzlers/article21/q2,查看测试结果,然后仔细地体会一下。 + +最后,强调一下,-parallel标记对性能测试是无效的。当然了,对于性能测试来说,也是可以并发进行的,不过机制上会有所不同。 + +概括地讲,这涉及了b.RunParallel方法、b.SetParallelism方法和-cpu标记的联合运用。如果想进一步了解,你可以查看testing代码包的文档。 + +问题2:性能测试函数中的计时器是做什么用的? + +如果你看过testing包的文档,那么很可能会发现其中的testing.B类型有这么几个指针方法:StartTimer、StopTimer和ResetTimer。这些方法都是用于操作当前的性能测试函数专属的计时器的。 + +所谓的计时器,是一个逻辑上的概念,它其实是testing.B类型中一些字段的统称。这些字段用于记录:当前测试函数在当次执行过程中耗费的时间、分配的堆内存的字节数以及分配次数。 + +我在下面会以测试函数的执行时间为例,来说明此计时器的用法。不过,你需要知道的是,这三个方法在开始记录、停止记录或重新记录执行时间的同时,也会对堆内存分配字节数和分配次数的记录起到相同的作用。 + +实际上,go test命令本身就会用到这样的计时器。当准备执行某个性能测试函数的时候,命令会重置并启动该函数专属的计时器。一旦这个函数执行完毕,命令又会立即停止这个计时器。 + +如此一来,命令就能够准确地记录下(我们在前面多次提到的)测试函数执行时间了。然后,命令就会将这个时间与执行时间上限进行比较,并决定是否在改大b.N的值之后,再次执行测试函数。 + +还记得吗?这就是我在前面讲过的,对性能测试函数的探索式执行。显然,如果我们在测试函数中自行操作这个计时器,就一定会影响到这个探索式执行的结果。也就是说,这会让命令找到被测程序的最大执行次数有所不同。 + +请看在demo57_test.go文件中的那个性能测试函数,如下所示: + +func BenchmarkGetPrimes(b *testing.B) { + b.StopTimer() + time.Sleep(time.Millisecond * 500) // 模拟某个耗时但与被测程序关系不大的操作。 + max := 10000 + b.StartTimer() + + for i := 0; i < b.N; i++ { + GetPrimes(max) + } +} + + +需要注意的是该函数体中的前四行代码。我先停止了当前测试函数的计时器,然后通过调用time.Sleep函数,模拟了一个比较耗时的额外操作,并且在给变量max赋值之后又启动了该计时器。 + +你可以想象一下,我们需要耗费额外的时间去确定max变量的值,虽然在后面它会被传入GetPrimes函数,但是,针对GetPrimes函数本身的性能测试并不应该包含确定参数值的过程。 + +因此,我们需要把这个过程所耗费的时间,从当前测试函数的执行时间中去除掉。这样就能够避免这一过程对测试结果的不良影响了。 + +每当这个测试函数执行完毕后,go test命令拿到的执行时间都只应该包含调用GetPrimes函数所耗费的那些时间。只有依据这个时间做出的后续判断,以及找到被测程序的最大执行次数才是准确的。 + +在性能测试函数中,我们可以通过对b.StartTimer和b.StopTimer方法的联合运用,再去除掉任何一段代码的执行时间。 + +相比之下,b.ResetTimer方法的灵活性就要差一些了,它只能用于:去除在调用它之前那些代码的执行时间。不过,无论在调用它的时候,计时器是不是正在运行,它都可以起作用。 + +总结 + +在本篇文章中,我假设你已经理解了上一篇文章涉及的内容。因此,我在这里围绕着几个可以被go test命令接受的重要标记,进一步地阐释了功能测试和性能测试在不同条件下的测试流程。 + +其中,比较重要的有最大P数量的含义,-cpu标记的作用及其对测试流程的影响,针对性能测试函数的探索式执行的意义,测试函数执行时间的计算方法,以及-count标记的用途和适用场景。 + +当然了,学会怎样并发地执行多个功能测试函数也是很有必要的。这需要联合运用-parallel标记和功能测试函数中的t.Parallel方法。 + +另外,你还需要知道性能测试函数专属计时器的内涵,以及那三个方法对计时器起到的作用。通过对计时器的操作,我们可以达到精确化性能测试函数的执行时间的目的,从而帮助go test命令找到被测程序真实的最大执行次数。 + +到这里,我们对Go程序测试的讨论就要告一段落了。我们需要搞清楚的是,go test命令所执行的基本测试流程是什么,以及我们可以通过什么样的手段让测试流程产生变化,从而满足我们的测试需求并为我们提供更加充分的测试结果。 + +希望你已经从中学到了一些东西,并能够学以致用。 + +思考题 + +-benchmem标记和-benchtime标记的作用分别是什么?- +怎样在测试的时候开启测试覆盖度分析?如果开启,会有什么副作用吗? + +关于这两个问题,你都可以参考官方的go命令文档中的测试标记部分进行回答。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/26sync.Mutex与sync.RWMutex.md b/专栏/Go语言核心36讲/26sync.Mutex与sync.RWMutex.md new file mode 100644 index 0000000..4c3a428 --- /dev/null +++ b/专栏/Go语言核心36讲/26sync.Mutex与sync.RWMutex.md @@ -0,0 +1,217 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 sync.Mutex与sync.RWMutex + 我在前面用20多篇文章,为你详细地剖析了Go语言本身的一些东西,这包括了基础概念、重要语法、高级数据类型、特色语句、测试方案等等。 + +这些都是Go语言为我们提供的最核心的技术。我想,这已经足够让你对Go语言有一个比较深刻的理解了。 + +从本篇文章开始,我们将一起探讨Go语言自带标准库中一些比较核心的代码包。这会涉及这些代码包的标准用法、使用禁忌、背后原理以及周边的知识。 + + + +既然Go语言是以独特的并发编程模型傲视群雄的语言,那么我们就先来学习与并发编程关系最紧密的代码包。 + +前导内容: 竞态条件、临界区与同步工具 + +我们首先要看的就是sync包。这里的“sync”的中文意思是“同步”。我们下面就从同步讲起。 + +相比于Go语言宣扬的“用通讯的方式共享数据”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,毕竟大多数的现代编程语言,都是用后一种方式作为并发编程的解决方案的(这种方案的历史非常悠久,恐怕可以追溯到上个世纪多进程编程时代伊始了)。 + +一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破坏共享数据的一致性。 + +共享数据的一致性代表着某种约定,即:多个线程对共享数据的操作总是可以达到它们各自预期的效果。 + +如果这个一致性得不到保证,那么将会影响到一些线程中代码和流程的正确执行,甚至会造成某种不可预知的错误。这种错误一般都很难发现和定位,排查起来的成本也是非常高的,所以一定要尽量避免。 + +举个例子,同时有多个线程连续向同一个缓冲区写入数据块,如果没有一个机制去协调这些线程的写入操作的话,那么被写入的数据块就很可能会出现错乱。比如,在线程A还没有写完一个数据块的时候,线程B就开始写入另外一个数据块了。 + +显然,这两个数据块中的数据会被混在一起,并且已经很难分清了。因此,在这种情况下,我们就需要采取一些措施来协调它们对缓冲区的修改。这通常就会涉及同步。 + +概括来讲,同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。 + +由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。我们所说的同步其实就是在控制多个线程对共享资源的访问。 + +一个线程在想要访问某一个共享资源的时候,需要先申请对该资源的访问权限,并且只有在申请成功之后,访问才能真正开始。 + +而当线程对共享资源的访问结束时,它还必须归还对该资源的访问权限,若要再次访问仍需申请。 + +你可以把这里所说的访问权限想象成一块令牌,线程一旦拿到了令牌,就可以进入指定的区域,从而访问到资源,而一旦线程要离开这个区域了,就需要把令牌还回去,绝不能把令牌带走。 + +如果针对某个共享资源的访问令牌只有一块,那么在同一时刻,就最多只能有一个线程进入到那个区域,并访问到该资源。 + +这时,我们可以说,多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),也就是我刚刚说的,由于要访问到资源而必须进入的那个区域。 + +比如,在我前面举的那个例子中,实现了数据块写入操作的代码就共同组成了一个临界区。如果针对同一个共享资源,这样的代码片段有多个,那么它们就可以被称为相关临界区。 + +它们可以是一个内含了共享数据的结构体及其方法,也可以是操作同一块共享数据的多个函数。临界区总是需要受到保护的,否则就会产生竞态条件。施加保护的重要手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。 + + + +(竞态条件、临界区与同步工具) + +在Go语言中,可供我们选择的同步工具并不少。其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称mutex)。sync包中的Mutex就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。 + +一个互斥锁可以被用来保护一个临界区或者一组相关临界区。我们可以通过它来保证,在同一时刻只有一个goroutine处于该临界区之内。 + +为了兑现这个保证,每当有goroutine想进入临界区时,都需要先对它进行锁定,并且,每个goroutine离开临界区时,都要及时地对它进行解锁。 + +锁定操作可以通过调用互斥锁的Lock方法实现,而解锁操作可以调用互斥锁的Unlock方法。以下是demo58.go文件中重点代码经过简化之后的片段: + +mu.Lock() +_, err := writer.Write([]byte(data)) +if err != nil { + log.Printf("error: %s [%d]", err, id) +} +mu.Unlock() + + +你可能已经看出来了,这里的互斥锁就相当于我们前面说的那块访问令牌。那么,我们怎样才能用好这块访问令牌呢?请看下面的问题。 + +我们今天的问题是:我们使用互斥锁时有哪些注意事项? + +这里有一个典型回答。 + +使用互斥锁的注意事项如下: + + +不要重复锁定互斥锁; +不要忘记解锁互斥锁,必要时使用defer语句; +不要对尚未锁定或者已解锁的互斥锁解锁; +不要在多个函数之间直接传递互斥锁。 + + +问题解析 + +首先,你还是要把互斥锁看作是针对某一个临界区或某一组相关临界区的唯一访问令牌。 + +虽然没有任何强制规定来限制,你用同一个互斥锁保护多个无关的临界区,但是这样做,一定会让你的程序变得很复杂,并且也会明显地增加你的心智负担。 + +你要知道,对一个已经被锁定的互斥锁进行锁定,是会立即阻塞当前的goroutine的。这个goroutine所执行的流程,会一直停滞在调用该互斥锁的Lock方法的那行代码上。 + +直到该互斥锁的Unlock方法被调用,并且这里的锁定操作成功完成,后续的代码(也就是临界区中的代码)才会开始执行。这也正是互斥锁能够保护临界区的原因所在。 + +一旦,你把一个互斥锁同时用在了多个地方,就必然会有更多的goroutine争用这把锁。这不但会让你的程序变慢,还会大大增加死锁(deadlock)的可能性。 + +所谓的死锁,指的就是当前程序中的主goroutine,以及我们启用的那些goroutine都已经被阻塞。这些goroutine可以被统称为用户级的goroutine。这就相当于整个程序都已经停滞不前了。 + +Go语言运行时系统是不允许这种情况出现的,只要它发现所有的用户级goroutine都处于等待状态,就会自行抛出一个带有如下信息的panic: + +fatal error: all goroutines are asleep - deadlock! + + +注意,这种由Go语言运行时系统自行抛出的panic都属于致命错误,都是无法被恢复的,调用recover函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。 + +因此,我们一定要尽量避免这种情况的发生。而最简单、有效的方式就是让每一个互斥锁都只保护一个临界区或一组相关临界区。 + +在这个前提之下,我们还需要注意,对于同一个goroutine而言,既不要重复锁定一个互斥锁,也不要忘记对它进行解锁。 + +一个goroutine对某一个互斥锁的重复锁定,就意味着它自己锁死了自己。先不说这种做法本身就是错误的,在这种情况下,想让其他的goroutine来帮它解锁是非常难以保证其正确性的。 + +我以前就在团队代码库中见到过这样的代码。那个作者的本意是先让一个goroutine自己锁死自己,然后再让一个负责调度的goroutine定时地解锁那个互斥锁,从而让前一个goroutine周期性地去做一些事情,比如每分钟检查一次服务器状态,或者每天清理一次日志。 + +这个想法本身是没有什么问题的,但却选错了实现的工具。对于互斥锁这种需要精细化控制的同步工具而言,这样的任务并不适合它。 + +在这种情况下,即使选用通道或者time.Ticker类型,然后自行实现功能都是可以的,程序的复杂度和我们的心智负担也会小很多,更何况还有不少已经很完备的解决方案可供选择。 + +话说回来,其实我们说“不要忘记解锁互斥锁”的一个很重要的原因就是:避免重复锁定。 + +因为在一个goroutine执行的流程中,可能会出现诸如“锁定、解锁、再锁定、再解锁”的操作,所以如果我们忘记了中间的解锁操作,那就一定会造成重复锁定。 + +除此之外,忘记解锁还会使其他的goroutine无法进入到该互斥锁保护的临界区,这轻则会导致一些程序功能的失效,重则会造成死锁和程序崩溃。 + +在很多时候,一个函数执行的流程并不是单一的,流程中间可能会有分叉,也可能会被中断。 + +如果一个流程在锁定了某个互斥锁之后分叉了,或者有被中断的可能,那么就应该使用defer语句来对它进行解锁,而且这样的defer语句应该紧跟在锁定操作之后。这是最保险的一种做法。 + +忘记解锁导致的问题有时候是比较隐秘的,并不会那么快就暴露出来。这也是我们需要特别关注它的原因。相比之下,解锁未锁定的互斥锁会立即引发panic。 + +并且,与死锁导致的panic一样,它们是无法被恢复的。因此,我们总是应该保证,对于每一个锁定操作,都要有且只有一个对应的解锁操作。 + +换句话说,我们应该让它们成对出现。这也算是互斥锁的一个很重要的使用原则了。在很多时候,利用defer语句进行解锁可以更容易做到这一点。 + + + +(互斥锁的重复锁定和重复解锁) + +最后,可能你已经知道,Go语言中的互斥锁是开箱即用的。换句话说,一旦我们声明了一个sync.Mutex类型的变量,就可以直接使用它了。 + +不过要注意,该类型是一个结构体类型,属于值类型中的一种。把它传给一个函数、将它从函数中返回、把它赋给其他变量、让它进入某个通道都会导致它的副本的产生。 + +并且,原值和它的副本,以及多个副本之间都是完全独立的,它们都是不同的互斥锁。 + +如果你把一个互斥锁作为参数值传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。 + +所以,你在这样做之前,一定要考虑清楚,这种结果是你想要的吗?我想,在大多数情况下应该都不是。即使你真的希望,在这个函数中使用另外一个互斥锁也不要这样做,这主要是为了避免歧义。 + +以上这些,就是我想要告诉你的关于互斥锁的锁定、解锁,以及传递方面的知识。这其中还包括了我的一些理解。希望能够对你有用。相关的例子我已经写在demo59.go文件中了,你可以去阅读一番,并运行起来看看。 + +知识扩展 + +问题1:读写锁与互斥锁有哪些异同? + +读写锁是读/写互斥锁的简称。在Go语言中,读写锁由sync.RWMutex类型的值代表。与sync.Mutex类型一样,这个类型也是开箱即用的。 + +顾名思义,读写锁是把对共享资源的“读操作”和“写操作”区别对待了。它可以对这两种操作施加不同程度的保护。换句话说,相比于互斥锁,读写锁可以实现更加细腻的访问控制。 + +一个读写锁中实际上包含了两个锁,即:读锁和写锁。sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁,而它的RLock方法和RUnlock方法则分别用于对读锁进行锁定和解锁。 + +另外,对于同一个读写锁来说有如下规则。 + + +在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的goroutine。 +在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的goroutine。 +在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的goroutine。 +在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的goroutine。 + + +换一个角度来说,对于某个受到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。 + +当然了,只有在我们正确使用读写锁的情况下,才能达到这种效果。还是那句话,我们需要让每一个锁都只保护一个临界区,或者一组相关临界区,并以此尽量减少误用的可能性。顺便说一句,我们通常把这种不能同时进行的操作称为互斥操作。 + +再来看另一个方面。对写锁进行解锁,会唤醒“所有因试图锁定读锁,而被阻塞的goroutine”,并且,这通常会使它们都成功完成对读锁的锁定。 + +然而,对读锁进行解锁,只会在没有其他读锁锁定的前提下,唤醒“因试图锁定写锁,而被阻塞的goroutine”;并且,最终只会有一个被唤醒的goroutine能够成功完成对写锁的锁定,其他的goroutine还要在原处继续等待。至于是哪一个goroutine,那就要看谁的等待时间最长了。 + +除此之外,读写锁对写操作之间的互斥,其实是通过它内含的一个互斥锁实现的。因此,也可以说,Go语言的读写锁是互斥锁的一种扩展。 + +最后,需要强调的是,与互斥锁类似,解锁“读写锁中未被锁定的写锁”,会立即引发panic,对于其中的读锁也是如此,并且同样是不可恢复的。 + +总之,读写锁与互斥锁的不同,都源于它把对共享资源的写操作和读操作区别对待了。这也使得它实现的互斥规则要更复杂一些。 + +不过,正因为如此,我们可以使用它对共享资源的操作,实行更加细腻的控制。另外,由于这里的读写锁是互斥锁的一种扩展,所以在有些方面它还是沿用了互斥锁的行为模式。比如,在解锁未锁定的写锁或读锁时的表现,又比如,对写操作之间互斥的实现方式。 + +总结 + +我们今天讨论了很多与多线程、共享资源以及同步有关的知识。其中涉及了不少重要的并发编程概念,比如,竞态条件、临界区、互斥量、死锁等。 + +虽然Go语言是以“用通讯的方式共享数据”为亮点的,但是它依然提供了一些易用的同步工具。其中,互斥锁是我们最常用到的一个。 + +互斥锁常常被用来:保证多个goroutine并发地访问同一个共享资源时的完全串行,这是通过保护针对此共享资源的一个临界区,或一组相关临界区实现的。因此,我们可以把它看做是goroutine进入相关临界区时,必须拿到的访问令牌。 + +为了用对并且用好互斥锁,我们需要了解它实现的互斥规则,更要理解一些关于它的注意事项。 + +比如,不要重复锁定或忘记解锁,因为这会造成goroutine不必要的阻塞,甚至导致程序的死锁。 + +又比如,不要传递互斥锁,因为这会产生它的副本,从而引起歧义并可能导致互斥操作的失效。 + +再次强调,我们总是应该让每一个互斥锁都只保护一个临界区,或一组相关临界区。 + +至于读写锁,它是互斥锁的一种扩展。我们需要知道它与互斥锁的异同,尤其是互斥规则和行为模式方面的异同。一个读写锁中同时包含了读锁和写锁,由此也可以看出它对于针对共享资源的读操作和写操作是区别对待的。我们可以基于这件事,对共享资源实施更加细致的访问控制。 + +最后,需要特别注意的是,无论是互斥锁还是读写锁,我们都不要试图去解锁未锁定的锁,因为这样会引发不可恢复的panic。 + +思考题 + + +你知道互斥锁和读写锁的指针类型都实现了哪一个接口吗? +怎样获取读写锁中的读锁? + + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/27条件变量sync.Cond(上).md b/专栏/Go语言核心36讲/27条件变量sync.Cond(上).md new file mode 100644 index 0000000..67193d3 --- /dev/null +++ b/专栏/Go语言核心36讲/27条件变量sync.Cond(上).md @@ -0,0 +1,143 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 条件变量sync.Cond (上) + 在上篇文章中,我们主要说的是互斥锁,今天我和你来聊一聊条件变量(conditional variable)。 + +前导内容:条件变量与互斥锁 + +我们常常会把条件变量这个同步工具拿来与互斥锁一起讨论。实际上,条件变量是基于互斥锁的,它必须有互斥锁的支撑才能发挥作用。 + +条件变量并不是被用来保护临界区和共享资源的,它是用于协调想要访问共享资源的那些线程的。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。 + +比如说,我们两个人在共同执行一项秘密任务,这需要在不直接联系和见面的前提下进行。我需要向一个信箱里放置情报,你需要从这个信箱中获取情报。这个信箱就相当于一个共享资源,而我们就分别是进行写操作的线程和进行读操作的线程。 + +如果我在放置的时候发现信箱里还有未被取走的情报,那就不再放置,而先返回。另一方面,如果你在获取的时候发现信箱里没有情报,那也只能先回去了。这就相当于写的线程或读的线程阻塞的情况。 + +虽然我们俩都有信箱的钥匙,但是同一时刻只能有一个人插入钥匙并打开信箱,这就是锁的作用了。更何况咱们俩是不能直接见面的,所以这个信箱本身就可以被视为一个临界区。 + +尽管没有协调好,咱们俩仍然要想方设法的完成任务啊。所以,如果信箱里有情报,而你却迟迟未取走,那我就需要每过一段时间带着新情报去检查一次,若发现信箱空了,我就需要及时地把新情报放到里面。 + +另一方面,如果信箱里一直没有情报,那你也要每过一段时间去打开看看,一旦有了情报就及时地取走。这么做是可以的,但就是太危险了,很容易被敌人发现。 + +后来,我们又想了一个计策,各自雇佣了一个不起眼的小孩儿。如果早上七点有一个戴红色帽子的小孩儿从你家楼下路过,那么就意味着信箱里有了新情报。另一边,如果上午九点有一个戴蓝色帽子的小孩儿从我家楼下路过,那就说明你已经从信箱中取走了情报。 + +这样一来,咱们执行任务的隐蔽性高多了,并且效率的提升非常显著。这两个戴不同颜色帽子的小孩儿就相当于条件变量,在共享资源的状态产生变化的时候,起到了通知的作用。 + +当然了,我们是在用Go语言编写程序,而不是在执行什么秘密任务。因此,条件变量在这里的最大优势就是在效率方面的提升。当共享资源的状态不满足条件的时候,想操作它的线程再也不用循环往复地做检查了,只要等待通知就好了。 + +说到这里,想考考你知道怎么使用条件变量吗?所以,我们今天的问题就是:条件变量怎样与互斥锁配合使用? + +这道题的典型回答是:条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。 + +条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)。 + +我们在利用条件变量等待通知的时候,需要在它基于的那个互斥锁保护下进行。而在进行单发通知或广播通知的时候,却是恰恰相反的,也就是说,需要在对应的互斥锁解锁之后再做这两种操作。 + +问题解析 + +这个问题看起来很简单,但其实可以基于它,延伸出很多其他的问题。比如,每个方法的使用时机是什么?又比如,每个方法执行的内部流程是怎样的? + +下面,我们一边用代码实现前面那个例子,一边讨论条件变量的使用。 + +首先,我们先来创建如下几个变量。 + +var mailbox uint8 +var lock sync.RWMutex +sendCond := sync.NewCond(&lock) +recvCond := sync.NewCond(lock.RLocker()) + + +变量mailbox代表信箱,是uint8类型的。 若它的值为0则表示信箱中没有情报,而当它的值为1时则说明信箱中有情报。lock是一个类型为sync.RWMutex的变量,是一个读写锁,也可以被视为信箱上的那把锁。 + +另外,基于这把锁,我还创建了两个代表条件变量的变量,名字分别叫sendCond和recvCond。 它们都是*sync.Cond类型的,同时也都是由sync.NewCond函数来初始化的。 + +与sync.Mutex类型和sync.RWMutex类型不同,sync.Cond类型并不是开箱即用的。我们只能利用sync.NewCond函数创建它的指针值。这个函数需要一个sync.Locker类型的参数值。 + +还记得吗?我在前面说过,条件变量是基于互斥锁的,它必须有互斥锁的支撑才能够起作用。因此,这里的参数值是不可或缺的,它会参与到条件变量的方法实现当中。 + +sync.Locker其实是一个接口,在它的声明中只包含了两个方法定义,即:Lock()和Unlock()。sync.Mutex类型和sync.RWMutex类型都拥有Lock方法和Unlock方法,只不过它们都是指针方法。因此,这两个类型的指针类型才是sync.Locker接口的实现类型。 + +我在为sendCond变量做初始化的时候,把基于lock变量的指针值传给了sync.NewCond函数。 + +原因是,lock变量的Lock方法和Unlock方法分别用于对其中写锁的锁定和解锁,它们与sendCond变量的含义是对应的。sendCond是专门为放置情报而准备的条件变量,向信箱里放置情报,可以被视为对共享资源的写操作。 + +相应的,recvCond变量代表的是专门为获取情报而准备的条件变量。 虽然获取情报也会涉及对信箱状态的改变,但是好在做这件事的人只会有你一个,而且我们也需要借此了解一下,条件变量与读写锁中的读锁的联用方式。所以,在这里,我们暂且把获取情报看做是对共享资源的读操作。 + +因此,为了初始化recvCond这个条件变量,我们需要的是lock变量中的读锁,并且还需要是sync.Locker类型的。 + +可是,lock变量中用于对读锁进行锁定和解锁的方法却是RLock和RUnlock,它们与sync.Locker接口中定义的方法并不匹配。 + +好在sync.RWMutex类型的RLocker方法可以实现这一需求。我们只要在调用sync.NewCond函数时,传入调用表达式lock.RLocker()的结果值,就可以使该函数返回符合要求的条件变量了。 + +为什么说通过lock.RLocker()得来的值就是lock变量中的读锁呢?实际上,这个值所拥有的Lock方法和Unlock方法,在其内部会分别调用lock变量的RLock方法和RUnlock方法。也就是说,前两个方法仅仅是后两个方法的代理而已。 + +好了,我们现在有四个变量。一个是代表信箱的mailbox,一个是代表信箱上的锁的lock。还有两个是,代表了蓝帽子小孩儿的sendCond,以及代表了红帽子小孩儿的recvCond。 + + + +(互斥锁与条件变量) + +我,现在是一个goroutine(携带的go函数),想要适时地向信箱里放置情报并通知你,应该怎么做呢? + +lock.Lock() +for mailbox == 1 { + sendCond.Wait() +} +mailbox = 1 +lock.Unlock() +recvCond.Signal() + + +我肯定需要先调用lock变量的Lock方法。注意,这个Lock方法在这里意味的是:持有信箱上的锁,并且有打开信箱的权利,而不是锁上这个锁。 + +然后,我要检查mailbox变量的值是否等于1,也就是说,要看看信箱里是不是还存有情报。如果还有情报,那么我就回家去等蓝帽子小孩儿了。 + +这就是那条for语句以及其中的调用表达式sendCond.Wait()所表示的含义了。你可能会问,为什么这里是for语句而不是if语句呢?我在后面会对此进行解释的。 + +我们再往后看,如果信箱里没有情报,那么我就把新情报放进去,关上信箱、锁上锁,然后离开。用代码表达出来就是mailbox = 1和lock.Unlock()。 + +离开之后我还要做一件事,那就是让红帽子小孩儿准时去你家楼下路过。也就是说,我会及时地通知你“信箱里已经有新情报了”,我们调用recvCond的Signal方法就可以实现这一步骤。 + +另一方面,你现在是另一个goroutine,想要适时地从信箱中获取情报,然后通知我。 + +lock.RLock() +for mailbox == 0 { + recvCond.Wait() +} +mailbox = 0 +lock.RUnlock() +sendCond.Signal() + + +你跟我做的事情在流程上其实基本一致,只不过每一步操作的对象是不同的。你需要调用的是lock变量的RLock方法。因为你要进行的是读操作,并且会使用recvCond变量作为辅助。recvCond与lock变量的读锁是对应的。 + +在打开信箱后,你要关注的是信箱里是不是没有情报,也就是检查mailbox变量的值是否等于0。如果它确实等于0,那么你就需要回家去等红帽子小孩儿,也就是调用recvCond的Wait方法。这里使用的依然是for语句。 + +如果信箱里有情报,那么你就应该取走情报,关上信箱、锁上锁,然后离开。对应的代码是mailbox = 0和lock.RUnlock()。之后,你还需要让蓝帽子小孩儿准时去我家楼下路过。这样我就知道信箱中的情报已经被你获取了。 + +以上这些,就是对咱们俩要执行秘密任务的代码实现。其中的条件变量的用法需要你特别注意。 + +再强调一下,只要条件不满足,我就会通过调用sendCond变量的Wait方法,去等待你的通知,只有在收到通知之后我才会再次检查信箱。 + +另外,当我需要通知你的时候,我会调用recvCond变量的Signal方法。你使用这两个条件变量的方式正好与我相反。你可能也看出来了,利用条件变量可以实现单向的通知,而双向的通知则需要两个条件变量。这也是条件变量的基本使用规则。 + +你可以打开demo61.go文件,看到上述例子的全部实现代码。 + +总结 + +我们这两期的文章会围绕条件变量的内容展开,条件变量是基于互斥锁的一种同步工具,它必须有互斥锁的支撑才能发挥作用。 条件变量可以协调那些想要访问共享资源的线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。我在文章举了一个两人访问信箱的例子,并用代码实现了这个过程。 + +思考题 + +*sync.Cond类型的值可以被传递吗?那sync.Cond类型的值呢? + +感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/28条件变量sync.Cond(下).md b/专栏/Go语言核心36讲/28条件变量sync.Cond(下).md new file mode 100644 index 0000000..2ed30b7 --- /dev/null +++ b/专栏/Go语言核心36讲/28条件变量sync.Cond(下).md @@ -0,0 +1,99 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 条件变量sync.Cond (下) + 你好,我是郝林,今天我继续分享条件变量sync.Cond的内容。我们紧接着上一篇的内容进行知识扩展。 + +问题 1:条件变量的Wait方法做了什么? + +在了解了条件变量的使用方式之后,你可能会有这么几个疑问。 + + +为什么先要锁定条件变量基于的互斥锁,才能调用它的Wait方法? +为什么要用for语句来包裹调用其Wait方法的表达式,用if语句不行吗? + + +这些问题我在面试的时候也经常问。你需要对这个Wait方法的内部机制有所了解才能回答上来。 + +条件变量的Wait方法主要做了四件事。 + + +把调用它的goroutine(也就是当前的goroutine)加入到当前条件变量的通知队列中。 +解锁当前的条件变量基于的那个互斥锁。 +让当前的goroutine处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个goroutine就会阻塞在调用这个Wait方法的那行代码上。 +如果通知到来并且决定唤醒这个goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的goroutine就会继续执行后面的代码了。 + + +你现在知道我刚刚说的第一个疑问的答案了吗? + +因为条件变量的Wait方法在阻塞当前的goroutine之前,会解锁它基于的互斥锁,所以在调用该Wait方法之前,我们必须先锁定那个互斥锁,否则在调用这个Wait方法时,就会引发一个不可恢复的panic。 + +为什么条件变量的Wait方法要这么做呢?你可以想象一下,如果Wait方法在互斥锁已经锁定的情况下,阻塞了当前的goroutine,那么又由谁来解锁呢?别的goroutine吗? + +先不说这违背了互斥锁的重要使用原则,即:成对的锁定和解锁,就算别的goroutine可以来解锁,那万一解锁重复了怎么办?由此引发的panic可是无法恢复的。 + +如果当前的goroutine无法解锁,别的goroutine也都不来解锁,那么又由谁来进入临界区,并改变共享资源的状态呢?只要共享资源的状态不变,即使当前的goroutine因收到通知而被唤醒,也依然会再次执行这个Wait方法,并再次被阻塞。 + +所以说,如果条件变量的Wait方法不先解锁互斥锁的话,那么就只会造成两种后果:不是当前的程序因panic而崩溃,就是相关的goroutine全面阻塞。 + +再解释第二个疑问。很显然,if语句只会对共享资源的状态检查一次,而for语句却可以做多次检查,直到这个状态改变为止。那为什么要做多次检查呢? + +这主要是为了保险起见。如果一个goroutine因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的Wait方法,并继续等待下次通知的到来。 + +这种情况是很有可能发生的,具体如下面所示。 + + +有多个goroutine在等待共享资源的同一种状态。比如,它们都在等mailbox变量的值不为0的时候再把它的值变为0,这就相当于有多个人在等着我向信箱里放置情报。虽然等待的goroutine有多个,但每次成功的goroutine却只可能有一个。别忘了,条件变量的Wait方法会在当前的goroutine醒来后先重新锁定那个互斥锁。在成功的goroutine最终解锁互斥锁之后,其他的goroutine会先后进入临界区,但它们会发现共享资源的状态依然不是它们想要的。这个时候,for循环就很有必要了。 + +共享资源可能有的状态不是两个,而是更多。比如,mailbox变量的可能值不只有0和1,还有2、3、4。这种情况下,由于状态在每次改变后的结果只可能有一个,所以,在设计合理的前提下,单一的结果一定不可能满足所有goroutine的条件。那些未被满足的goroutine显然还需要继续等待和检查。 + +有一种可能,共享资源的状态只有两个,并且每种状态都只有一个goroutine在关注,就像我们在主问题当中实现的那个例子那样。不过,即使是这样,使用for语句仍然是有必要的。原因是,在一些多CPU核心的计算机系统中,即使没有收到条件变量的通知,调用其Wait方法的goroutine也是有可能被唤醒的。这是由计算机硬件层面决定的,即使是操作系统(比如Linux)本身提供的条件变量也会如此。 + + +综上所述,在包裹条件变量的Wait方法的时候,我们总是应该使用for语句。 + +好了,到这里,关于条件变量的Wait方法,我想你知道的应该已经足够多了。 + +问题 2:条件变量的Signal方法和Broadcast方法有哪些异同? + +条件变量的Signal方法和Broadcast方法都是被用来发送通知的,不同的是,前者的通知只会唤醒一个因此而等待的goroutine,而后者的通知却会唤醒所有为此等待的goroutine。 + +条件变量的Wait方法总会把当前的goroutine添加到通知队列的队尾,而它的Signal方法总会从通知队列的队首开始,查找可被唤醒的goroutine。所以,因Signal方法的通知,而被唤醒的goroutine一般都是最早等待的那一个。 + +这两个方法的行为决定了它们的适用场景。如果你确定只有一个goroutine在等待通知,或者只需唤醒任意一个goroutine就可以满足要求,那么使用条件变量的Signal方法就好了。 + +否则,使用Broadcast方法总没错,只要你设置好各个goroutine所期望的共享资源状态就可以了。 + +此外,再次强调一下,与Wait方法不同,条件变量的Signal方法和Broadcast方法并不需要在互斥锁的保护下执行。恰恰相反,我们最好在解锁条件变量基于的那个互斥锁之后,再去调用它的这两个方法。这更有利于程序的运行效率。 + +最后,请注意,条件变量的通知具有即时性。也就是说,如果发送通知的时候没有goroutine为此等待,那么该通知就会被直接丢弃。在这之后才开始等待的goroutine只可能被后面的通知唤醒。 + +你可以打开demo62.go文件,并仔细观察它与demo61.go的不同。尤其是lock变量的类型,以及发送通知的方式。 + +总结 + +我们今天主要讲了条件变量,它是基于互斥锁的一种同步工具。在Go语言中,我们需要用sync.NewCond函数来初始化一个sync.Cond类型的条件变量。 + +sync.NewCond函数需要一个sync.Locker类型的参数值。 + +*sync.Mutex类型的值以及*sync.RWMutex类型的值都可以满足这个要求。另外,后者的RLocker方法可以返回这个值中的读锁,也同样可以作为sync.NewCond函数的参数值,如此就可以生成与读写锁中的读锁对应的条件变量了。 + +条件变量的Wait方法需要在它基于的互斥锁保护下执行,否则就会引发不可恢复的panic。此外,我们最好使用for语句来检查共享资源的状态,并包裹对条件变量的Wait方法的调用。 + +不要用if语句,因为它不能重复地执行“检查状态-等待通知-被唤醒”的这个流程。重复执行这个流程的原因是,一个“因为等待通知,而被阻塞”的goroutine,可能会在共享资源的状态不满足其要求的情况下被唤醒。 + +条件变量的Signal方法只会唤醒一个因等待通知而被阻塞的goroutine,而它的Broadcast方法却可以唤醒所有为此而等待的goroutine。后者比前者的适应场景要多得多。 + +这两个方法并不需要受到互斥锁的保护,我们也最好不要在解锁互斥锁之前调用它们。还有,条件变量的通知具有即时性。当通知被发送的时候,如果没有任何goroutine需要被唤醒,那么该通知就会立即失效。 + +思考题 + +sync.Cond类型中的公开字段L是做什么用的?我们可以在使用条件变量的过程中改变这个字段的值吗? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/29原子操作(上).md b/专栏/Go语言核心36讲/29原子操作(上).md new file mode 100644 index 0000000..2378090 --- /dev/null +++ b/专栏/Go语言核心36讲/29原子操作(上).md @@ -0,0 +1,96 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 29 原子操作(上) + 我们在前两篇文章中讨论了互斥锁、读写锁以及基于它们的条件变量,先来总结一下。 + +互斥锁是一个很有用的同步工具,它可以保证每一时刻进入临界区的goroutine只有一个。读写锁对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。 + +条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁。当然了,读写锁也是一种互斥锁,前者是对后者的扩展。 + +通过对互斥锁的合理使用,我们可以使一个goroutine在执行临界区中的代码时,不被其他的goroutine打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。 + +前导内容:原子性执行与原子操作 + +我们已经知道,对于一个Go程序来说,Go语言运行时系统中的调度器会恰当地安排其中所有的goroutine的运行。不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量只会与M的数量一致,而不会随着G的增多而增长。 + +所以,为了公平起见,调度器总是会频繁地换上或换下这些goroutine。换上的意思是,让一个goroutine由非运行状态转为运行状态,并促使其中的代码在某个CPU核心上执行。 + +换下的意思正好相反,即:使一个goroutine中的代码中断执行,并让它由运行状态转为非运行状态。 + +这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都是可以的。 + +即使这些语句在临界区之内也是如此。所以,我们说,互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。 + +在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由CPU提供芯片级别的支持,所以绝对有效。即使在拥有多CPU核心,或者多CPU的计算机系统中,原子操作的保证也是不可撼动的。 + +这使得原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,它的执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。 + +更具体地说,正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速。 + +你可以想象一下,如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此,操作系统层面只对针对二进制位或整数的原子操作提供了支持。 + +Go语言的原子操作当然是基于CPU和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包sync/atomic中。 + +我一般会通过下面这道题初探一下应聘者对sync/atomic包的熟悉程度。 + +我们今天的问题是:sync/atomic包中提供了几种原子操作?可操作的数据类型又有哪些? + +这里的典型回答是: + +sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称CAS)、加载(load)、存储(store)和交换(swap)。 + +这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic包都会有一套函数给予支持。这些数据类型有:int32、int64、uint32、uint64、uintptr,以及unsafe包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。 + +此外,sync/atomic包还提供了一个名为Value的类型,它可以被用来存储任意类型的值。 + +问题解析 + +这个问题很简单,因为答案是明摆在代码包文档里的。不过如果你连文档都没看过,那也可能回答不上来,至少是无法做出全面的回答。 + +我一般会通过此问题再衍生出来几道题。下面我就来逐个说明一下。 + +第一个衍生问题 :我们都知道,传入这些原子操作函数的第一个参数值对应的都应该是那个被操作的值。比如,atomic.AddInt32函数的第一个参数,对应的一定是那个要被增大的整数。可是,这个参数的类型为什么不是int32而是*int32呢? + +回答是:因为原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。 + +所以,传入值本身没有任何意义。unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。 + +只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址。只有这样,它们才能够通过底层的指令,准确地操作这个内存地址上的数据。 + +第二个衍生问题: 用于原子加法操作的函数可以做原子减法吗?比如,atomic.AddInt32函数可以用于减小那个被操作的整数值吗? + +回答是:当然是可以的。atomic.AddInt32函数的第二个参数代表差量,它的类型是int32,是有符号的。如果我们想做原子减法,那么把这个差量设置为负整数就可以了。 + +对于atomic.AddInt64函数来说也是类似的。不过,要想用atomic.AddUint32和atomic.AddUint64函数做原子减法,就不能这么直接了,因为它们的第二个参数的类型分别是uint32和uint64,都是无符号的,不过,这也是可以做到的,就是稍微麻烦一些。 + +例如,如果想对uint32类型的被操作值18做原子减法,比如说差量是-3,那么我们可以先把这个差量转换为有符号的int32类型的值,然后再把该值的类型转换为uint32,用表达式来描述就是uint32(int32(-3))。 + +不过要注意,直接这样写会使Go语言的编译器报错,它会告诉你:“常量-3不在uint32类型可表示的范围内”,换句话说,这样做会让表达式的结果值溢出。 + +不过,如果我们先把int32(-3)的结果值赋给变量delta,再把delta的值转换为uint32类型的值,就可以绕过编译器的检查并得到正确的结果了。 + +最后,我们把这个结果作为atomic.AddUint32函数的第二个参数值,就可以达到对uint32类型的值做原子减法的目的了。 + +还有一种更加直接的方式。我们可以依据下面这个表达式来给定atomic.AddUint32函数的第二个参数值: + +^uint32(-N-1)) + + +其中的N代表由负整数表示的差量。也就是说,我们先要把差量的绝对值减去1,然后再把得到的这个无类型的整数常量,转换为uint32类型的值,最后,在这个值之上做按位异或操作,就可以获得最终的参数值了。 + +这么做的原理也并不复杂。简单来说,此表达式的结果值的补码,与使用前一种方法得到的值的补码相同,所以这两种方式是等价的。我们都知道,整数在计算机中是以补码的形式存在的,所以在这里,结果值的补码相同就意味着表达式的等价。 + +总结 + +今天,我们一起学习了sync/atomic代码包中提供的原子操作函数和原子值类型。原子操作函数使用起来都非常简单,但也有一些细节需要我们注意。我在主问题的衍生问题中对它们进行了逐一说明。 + +在下一篇文章中,我们会继续分享原子操作的衍生内容。如果你对原子操作有什么样的问题,都可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/30原子操作(下).md b/专栏/Go语言核心36讲/30原子操作(下).md new file mode 100644 index 0000000..2c5e500 --- /dev/null +++ b/专栏/Go语言核心36讲/30原子操作(下).md @@ -0,0 +1,132 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 原子操作(下) + 你好,我是郝林,今天我们继续分享原子操作的内容。 + +我们接着上一篇文章的内容继续聊,上一篇我们提到了,sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称CAS)、加载(load)、存储(store)和交换(swap)。并且以此衍生出了两个问题。 + +今天我们继续来看第三个衍生问题: 比较并交换操作与交换操作相比有什么不同?优势在哪里? + +回答是:比较并交换操作即CAS操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。 + +所谓的交换指的是,把新值赋给变量,并返回变量的旧值。 + +在进行CAS操作的时候,函数会先判断被操作变量的当前值,是否与我们预期的旧值相等。如果相等,它就把新值赋给该变量,并返回true以表明交换操作已进行;否则就忽略交换操作,并返回false。 + +可以看到,CAS操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同。正因为如此,它的用途要更广泛一些。例如,我们将它与for语句联用就可以实现一种简易的自旋锁(spinlock)。 + +for { + if atomic.CompareAndSwapInt32(&num2, 10, 0) { + fmt.Println("The second number has gone to zero.") + break + } + time.Sleep(time.Millisecond * 500) +} + + +在for语句中的CAS操作可以不停地检查某个需要满足的条件,一旦条件满足就退出for循环。这就相当于,只要条件未被满足,当前的流程就会被一直“阻塞”在这里。 + +这在效果上与互斥锁有些类似。不过,它们的适用场景是不同的。我们在使用互斥锁的时候,总是假设共享资源的状态会被其他的goroutine频繁地改变。 + +而for语句加CAS操作的假设往往是:共享资源状态的改变并不频繁,或者,它的状态总会变成期望的那样。这是一种更加乐观,或者说更加宽松的做法。 + +第四个衍生问题:假设我已经保证了对一个变量的写操作都是原子操作,比如:加或减、存储、交换等等,那我对它进行读操作的时候,还有必要使用原子操作吗? + +回答是:很有必要。其中的道理你可以对照一下读写锁。为什么在读写锁保护下的写操作和读操作之间是互斥的?这是为了防止读操作读到没有被修改完的值,对吗? + +如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。这显然破坏了值的完整性,读出来的值也是完全错误的。 + +所以,一旦你决定了要对一个共享资源进行保护,那就要做到完全的保护。不完全的保护基本上与不保护没有什么区别。 + +好了,上面的主问题以及相关的衍生问题涉及了原子操作函数的用法、原理、对比和一些最佳实践,希望你已经理解了。 + +由于这里的原子操作函数只支持非常有限的数据类型,所以在很多应用场景下,互斥锁往往是更加适合的。 + +不过,一旦我们确定了在某个场景下可以使用原子操作函数,比如:只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,那就不要再考虑互斥锁了。 + +这主要是因为原子操作函数的执行速度要比互斥锁快得多。而且,它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。当然了,在使用CAS操作的时候,我们还是要多加注意的,因为它可以被用来模仿锁,并有可能“阻塞”流程。 + +知识扩展 + +问题:怎样用好sync/atomic.Value? + +为了扩大原子操作的适用范围,Go语言在1.4版本发布的时候向sync/atomic包中添加了一个新的类型Value。此类型的值相当于一个容器,可以被用来“原子地”存储和加载任意的值。 + +atomic.Value类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有两个指针方法:Store和Load。不过,虽然简单,但还是有一些值得注意的地方的。 + +首先一点,一旦atomic.Value类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。什么叫做“真正使用”呢? + +我们只要用它来存储值了,就相当于开始真正使用了。atomic.Value类型属于结构体类型,而结构体类型属于值类型。 + +所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。 + +另外,关于用原子值来存储值,有两条强制性的使用规则。第一条规则,不能用原子值存储nil。 + +也就是说,我们不能把nil作为参数值传入原子值的Store方法,否则就会引发一个panic。 + +这里要注意,如果有一个接口类型的变量,它的动态值是nil,但动态类型却不是nil,那么它的值就不等于nil。我在前面讲接口的时候和你说明过这个问题。正因为如此,这样一个变量的值是可以被存入原子值的。 + +第二条规则,我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。 + +例如,我第一次向一个原子值存储了一个string类型的值,那我在后面就只能用该原子值来存储字符串了。如果我又想用它存储结构体,那么在调用它的Store方法的时候就会引发一个panic。这个panic会告诉我,这次存储的值的类型与之前的不一致。 + +你可能会想:我先存储一个接口类型的值,然后再存储这个接口的某个实现类型的值,这样是不是可以呢? + +很可惜,这样是不可以的,同样会引发一个panic。因为原子值内部是依据被存储值的实际类型来做判断的。所以,即使是实现了同一个接口的不同类型,它们的值也不能被先后存储到同一个原子值中。 + +遗憾的是,我们无法通过某个方法获知一个原子值是否已经被真正使用,并且,也没有办法通过常规的途径得到一个原子值可以存储值的实际类型。这使得我们误用原子值的可能性大大增加,尤其是在多个地方使用同一个原子值的时候。 + +下面,我给你几条具体的使用建议。 + + +不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。 +如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。 +如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免panic的发生。 +如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。 + + +除了上述使用建议之外,我还要再特别强调一点:尽量不要向原子值中存储引用类型的值。因为这很容易造成安全漏洞。请看下面的代码: + +var box6 atomic.Value +v6 := []int{1, 2, 3} +box6.Store(v6) +v6[1] = 4 // 注意,此处的操作不是并发安全的! + + +我把一个[]int类型的切片值v6,存入了原子值box6。注意,切片类型属于引用类型。所以,我在外面改动这个切片值,就等于修改了box6中存储的那个值。这相当于绕过了原子值而进行了非并发安全的操作。那么,应该怎样修补这个漏洞呢?可以这样做: + +store := func(v []int) { + replica := make([]int, len(v)) + copy(replica, v) + box6.Store(replica) +} +store(v6) +v6[2] = 5 // 此处的操作是安全的。 + + +我先为切片值v6创建了一个完全的副本。这个副本涉及的数据已经与原值毫不相干了。然后,我再把这个副本存入box6。如此一来,无论我再对v6的值做怎样的修改,都不会破坏box6提供的安全保护。 + +以上,就是我要告诉你的关于atomic.Value的注意事项和使用建议。你可以在demo64.go文件中看到相应的示例。 + +总结 + +我们把这两篇文章一起总结一下。相对于原子操作函数,原子值类型的优势很明显,但它的使用规则也更多一些。首先,在首次真正使用后,原子值就不应该再被复制了。 + +其次,原子值的Store方法对其参数值(也就是被存储值)有两个强制的约束。一个约束是,参数值不能为nil。另一个约束是,参数值的类型不能与首个被存储值的类型不同。也就是说,一旦一个原子值存储了某个类型的值,那它以后就只能存储这个类型的值了。 + +基于上面这几个注意事项,我提出了几条使用建议,包括:不要对外暴露原子变量、不要传递原子值及其指针值、尽量不要在原子值中存储引用类型的值,等等。与之相关的一些解决方案我也一并提出了。希望你能够受用。 + +原子操作明显比互斥锁要更加轻便,但是限制也同样明显。所以,我们在进行二选一的时候通常不会太困难。但是原子值与互斥锁之间的选择有时候就需要仔细的考量了。不过,如果你能牢记我今天讲的这些内容的话,应该会有很大的助力。 + +思考题 + +今天的思考题只有一个,那就是:如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/31sync.WaitGroup和sync.Once.md b/专栏/Go语言核心36讲/31sync.WaitGroup和sync.Once.md new file mode 100644 index 0000000..ee98d54 --- /dev/null +++ b/专栏/Go语言核心36讲/31sync.WaitGroup和sync.Once.md @@ -0,0 +1,176 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 31 sync.WaitGroup和sync.Once + 我们在前几次讲的互斥锁、条件变量和原子操作都是最基本重要的同步工具。在Go语言中,除了通道之外,它们也算是最为常用的并发安全工具了。 + +说到通道,不知道你想过没有,之前在一些场合下里,我们使用通道的方式看起来都似乎有些蹩脚。 + +比如:声明一个通道,使它的容量与我们手动启用的goroutine的数量相同,之后再利用这个通道,让主goroutine等待其他goroutine的运行结束。 + +这一步更具体地说就是:让其他的goroutine在运行结束之前,都向这个通道发送一个元素值,并且,让主goroutine在最后从这个通道中接收元素值,接收的次数需要与其他的goroutine的数量相同。 + +这就是下面的coordinateWithChan函数展示的多goroutine协作流程。 + +func coordinateWithChan() { + sign := make(chan struct{}, 2) + num := int32(0) + fmt.Printf("The number: %d [with chan struct{}]\n", num) + max := int32(10) + go addNum(&num, 1, max, func() { + sign <- struct{}{} + }) + go addNum(&num, 2, max, func() { + sign <- struct{}{} + }) + <-sign + <-sign +} + + +其中的addNum函数的声明在demo65.go文件中。addNum函数会把它接受的最后一个参数值作为其中的defer函数。 + +我手动启用的两个goroutine都会调用addNum函数,而它们传给该函数的最后一个参数值(也就是那个既无参数声明,也无结果声明的函数)都只会做一件事情,那就是向通道sign发送一个元素值。 + +看到coordinateWithChan函数中最后的那两行代码了吗?重复的两个接收表达式<-sign,是不是看起来很丑陋? + +前导内容:sync包的WaitGroup类型 + +其实,在这种应用场景下,我们可以选用另外一个同步工具,即:sync包的WaitGroup类型。它比通道更加适合实现这种一对多的goroutine协作流程。 + +sync.WaitGroup类型(以下简称WaitGroup类型)是开箱即用的,也是并发安全的。同时,与我们前面讨论的几个同步工具一样,它一旦被真正使用就不能被复制了。 + +WaitGroup类型拥有三个指针方法:Add、Done和Wait。你可以想象该类型中有一个计数器,它的默认值是0。我们可以通过调用该类型值的Add方法来增加,或者减少这个计数器的值。 + +一般情况下,我会用这个方法来记录需要等待的goroutine的数量。相对应的,这个类型的Done方法,用于对其所属值中计数器的值进行减一操作。我们可以在需要等待的goroutine中,通过defer语句调用它。 + +而此类型的Wait方法的功能是,阻塞当前的goroutine,直到其所属值中的计数器归零。如果在该方法被调用的时候,那个计数器的值就是0,那么它将不会做任何事情。 + +你可能已经看出来了,WaitGroup类型的值(以下简称WaitGroup值)完全可以被用来替换coordinateWithChan函数中的通道sign。下面的coordinateWithWaitGroup函数就是它的改造版本。 + +func coordinateWithWaitGroup() { + var wg sync.WaitGroup + wg.Add(2) + num := int32(0) + fmt.Printf("The number: %d [with sync.WaitGroup]\n", num) + max := int32(10) + go addNum(&num, 3, max, wg.Done) + go addNum(&num, 4, max, wg.Done) + wg.Wait() +} + + +很明显,整体代码少了好几行,而且看起来也更加简洁了。这里我先声明了一个WaitGroup类型的变量wg。然后,我调用了它的Add方法并传入了2,因为我会在后面启用两个需要等待的goroutine。 + +由于wg变量的Done方法本身就是一个既无参数声明,也无结果声明的函数,所以我在go语句中调用addNum函数的时候,可以直接把该方法作为最后一个参数值传进去。 + +在coordinateWithWaitGroup函数的最后,我调用了wg的Wait方法。如此一来,该函数就可以等到那两个goroutine都运行结束之后,再结束执行了。 + +以上就是WaitGroup类型最典型的应用场景了。不过不能止步于此,对于这个类型,我们还是有必要再深入了解一下的。我们一起看下面的问题。 + +问题:sync.WaitGroup类型值中计数器的值可以小于0吗? + +这里的典型回答是:不可以。 + +问题解析 + +为什么不可以呢,我们解析一下。之所以说WaitGroup值中计数器的值不能小于0,是因为这样会引发一个panic。 不适当地调用这类值的Done方法和Add方法都会如此。别忘了,我们在调用Add方法的时候是可以传入一个负数的。 + +实际上,导致WaitGroup值的方法抛出panic的原因不只这一种。 + +你需要知道,在我们声明了这样一个变量之后,应该首先根据需要等待的goroutine,或者其他事件的数量,调用它的Add方法,以使计数器的值大于0。这是确保我们能在后面正常地使用这类值的前提。 + +如果我们对它的Add方法的首次调用,与对它的Wait方法的调用是同时发起的,比如,在同时启用的两个goroutine中,分别调用这两个方法,那么就有可能会让这里的Add方法抛出一个panic。 + +这种情况不太容易复现,也正因为如此,我们更应该予以重视。所以,虽然WaitGroup值本身并不需要初始化,但是尽早地增加其计数器的值,还是非常有必要的。 + +另外,你可能已经知道,WaitGroup值是可以被复用的,但需要保证其计数周期的完整性。这里的计数周期指的是这样一个过程:该值中的计数器值由0变为了某个正整数,而后又经过一系列的变化,最终由某个正整数又变回了0。 + +也就是说,只要计数器的值始于0又归为0,就可以被视为一个计数周期。在一个此类值的生命周期中,它可以经历任意多个计数周期。但是,只有在它走完当前的计数周期之后,才能够开始下一个计数周期。 + +- +(sync.WaitGroup的计数周期) + +因此,也可以说,如果一个此类值的Wait方法在它的某个计数周期中被调用,那么就会立即阻塞当前的goroutine,直至这个计数周期完成。在这种情况下,该值的下一个计数周期,必须要等到这个Wait方法执行结束之后,才能够开始。 + +如果在一个此类值的Wait方法被执行期间,跨越了两个计数周期,那么就会引发一个panic。 + +例如,在当前的goroutine因调用此类值的Wait方法,而被阻塞的时候,另一个goroutine调用了该值的Done方法,并使其计数器的值变为了0。 + +这会唤醒当前的goroutine,并使它试图继续执行Wait方法中其余的代码。但在这时,又有一个goroutine调用了它的Add方法,并让其计数器的值又从0变为了某个正整数。此时,这里的Wait方法就会立即抛出一个panic。 + +纵观上述会引发panic的后两种情况,我们可以总结出这样一条关于WaitGroup值的使用禁忌,即:不要把增加其计数器值的操作和调用其Wait方法的代码,放在不同的goroutine中执行。换句话说,要杜绝对同一个WaitGroup值的两种操作的并发执行。 + +除了第一种情况外,我们通常需要反复地实验,才能够让WaitGroup值的方法抛出panic。再次强调,虽然这不是每次都发生,但是在长期运行的程序中,这种情况发生的概率还是不小的,我们必须要重视它们。 + +如果你对复现这些异常情况感兴趣,那么可以参看sync代码包中的waitgroup_test.go文件。其中的名称以TestWaitGroupMisuse为前缀的测试函数,很好地展示了这些异常情况的发生条件。你可以模仿这些测试函数自己写一些测试代码,执行一下试试看。 + +知识扩展 + +问题:sync.Once类型值的Do方法是怎么保证只执行参数函数一次的? + +与sync.WaitGroup类型一样,sync.Once类型(以下简称Once类型)也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex类型的字段,所以,复制该类型的值也会导致功能的失效。 + +Once类型的Do方法只接受一个参数,这个参数的类型必须是func(),即:无参数声明和结果声明的函数。 + +该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。 + +所以,如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个sync.Once类型的值(以下简称Once值)。 + +Once类型中还有一个名叫done的uint32类型的字段。它的作用是记录其所属值的Do方法被调用的次数。不过,该字段的值只可能是0或者1。一旦Do方法的首次调用完成,它的值就会从0变为1。 + +你可能会问,既然done字段的值不是0就是1,那为什么还要使用需要四个字节的uint32类型呢? + +原因很简单,因为对它的操作必须是“原子”的。Do方法在一开始就会通过调用atomic.LoadUint32函数来获取该字段的值,并且一旦发现该值为1,就会直接返回。这也初步保证了“Do方法,只会执行首次被调用时传入的函数”。 + +不过,单凭这样一个判断的保证是不够的。因为,如果有两个goroutine都调用了同一个新的Once值的Do方法,并且几乎同时执行到了其中的这个条件判断代码,那么它们就都会因判断结果为false,而继续执行Do方法中剩余的代码。 + +在这个条件判断之后,Do方法会立即锁定其所属值中的那个sync.Mutex类型的字段m。然后,它会在临界区中再次检查done字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把done的值变为1。 + +如果你熟悉GoF设计模式中的单例模式的话,那么肯定能看出来,这个Do方法的实现方式,与那个单例模式有很多相似之处。它们都会先在临界区之外,判断一次关键条件,若条件不满足则立即返回。这通常被称为“快路径”,或者叫做“快速失败路径”。 + +如果条件满足,那么到了临界区中还要再对关键条件进行一次判断,这主要是为了更加严谨。这两次条件判断常被统称为(跨临界区的)“双重检查”。 + +由于进入临界区之前,肯定要锁定保护它的互斥锁m,显然会降低代码的执行速度,所以其中的第二次条件判断,以及后续的操作就被称为“慢路径”或者“常规路径”。 + +别看Do方法中的代码不多,但它却应用了一个很经典的编程范式。我们在Go语言及其标准库中,还能看到不少这个经典范式及它衍生版本的应用案例。 + +下面我再来说说这个Do方法在功能方面的两个特点。 + +第一个特点,由于Do方法只会在参数函数执行结束之后把done字段的值变为1,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关goroutine的同时阻塞。 + +例如,有多个goroutine并发地调用了同一个Once值的Do方法,并且传入的函数都会一直执行而不结束。那么,这些goroutine就都会因调用了这个Do方法而阻塞。因为,除了那个抢先执行了参数函数的goroutine之外,其他的goroutine都会被阻塞在锁定该Once值的互斥锁m的那行代码上。 + +第二个特点,Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的。因此,不论参数函数的执行会以怎样的方式结束,done字段的值都会变为1。 + +也就是说,即使这个参数函数没有执行成功(比如引发了一个panic),我们也无法使用同一个Once值重新执行它了。所以,如果你需要为参数函数的执行设定重试机制,那么就要考虑Once值的适时替换问题。 + +在很多时候,我们需要依据Do方法的这两个特点来设计与之相关的流程,以避免不必要的程序阻塞和功能缺失。 + +总结 + +sync代码包的WaitGroup类型和Once类型都是非常易用的同步工具。它们都是开箱即用和并发安全的。 + +利用WaitGroup值,我们可以很方便地实现一对多的goroutine协作流程,即:一个分发子任务的goroutine,和多个执行子任务的goroutine,共同来完成一个较大的任务。 + +在使用WaitGroup值的时候,我们一定要注意,千万不要让其中的计数器的值小于0,否则就会引发panic。 + +另外,我们最好用“先统一Add,再并发Done,最后Wait”这种标准方式,来使用WaitGroup值。 尤其不要在调用Wait方法的同时,并发地通过调用Add方法去增加其计数器的值,因为这也有可能引发panic。 + +Once值的使用方式比WaitGroup值更加简单,它只有一个Do方法。同一个Once值的Do方法,永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎样的方式结束。 + +只要传入某个Do方法的参数函数没有结束执行,任何之后调用该方法的goroutine就都会被阻塞。只有在这个参数函数执行结束以后,那些goroutine才会逐一被唤醒。 + +Once类型使用互斥锁和原子操作实现了功能,而WaitGroup类型中只用到了原子操作。 所以可以说,它们都是更高层次的同步工具。它们都基于基本的通用工具,实现了某一种特定的功能。sync包中的其他高级同步工具,其实也都是这样的。 + +思考题 + +今天的思考题是:在使用WaitGroup值实现一对多的goroutine协作流程时,怎样才能让分发子任务的goroutine获得各个子任务的具体执行结果? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/32context.Context类型.md b/专栏/Go语言核心36讲/32context.Context类型.md new file mode 100644 index 0000000..ed59d8c --- /dev/null +++ b/专栏/Go语言核心36讲/32context.Context类型.md @@ -0,0 +1,201 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 32 context.Context类型 + 我们在上篇文章中讲到了sync.WaitGroup类型:一个可以帮我们实现一对多goroutine协作流程的同步工具。 + +在使用WaitGroup值的时候,我们最好用“先统一Add,再并发Done,最后Wait”的标准模式来构建协作流程。 + +如果在调用该值的Wait方法的同时,为了增大其计数器的值,而并发地调用该值的Add方法,那么就很可能会引发panic。 + +这就带来了一个问题,如果我们不能在一开始就确定执行子任务的goroutine的数量,那么使用WaitGroup值来协调它们和分发子任务的goroutine,就是有一定风险的。一个解决方案是:分批地启用执行子任务的goroutine。 + +前导内容:WaitGroup值补充知识 + +我们都知道,WaitGroup值是可以被复用的,但需要保证其计数周期的完整性。尤其是涉及对其Wait方法调用的时候,它的下一个计数周期必须要等到,与当前计数周期对应的那个Wait方法调用完成之后,才能够开始。 + +我在前面提到的可能会引发panic的情况,就是由于没有遵循这条规则而导致的。 + +只要我们在严格遵循上述规则的前提下,分批地启用执行子任务的goroutine,就肯定不会有问题。具体的实现方式有不少,其中最简单的方式就是使用for循环来作为辅助。这里的代码如下: + +func coordinateWithWaitGroup() { + total := 12 + stride := 3 + var num int32 + fmt.Printf("The number: %d [with sync.WaitGroup]\n", num) + var wg sync.WaitGroup + for i := 1; i <= total; i = i + stride { + wg.Add(stride) + for j := 0; j < stride; j++ { + go addNum(&num, i+j, wg.Done) + } + wg.Wait() + } + fmt.Println("End.") +} + + +这里展示的coordinateWithWaitGroup函数,就是上一篇文章中同名函数的改造版本。而其中调用的addNum函数,则是上一篇文章中同名函数的简化版本。这两个函数都已被放置在了demo67.go文件中。 + +我们可以看到,经过改造后的coordinateWithWaitGroup函数,循环地使用了由变量wg代表的WaitGroup值。它运用的依然是“先统一Add,再并发Done,最后Wait”的这种模式,只不过它利用for语句,对此进行了复用。 + +好了,至此你应该已经对WaitGroup值的运用有所了解了。不过,我现在想让你使用另一种工具来实现上面的协作流程。 + +我们今天的问题就是:怎样使用context包中的程序实体,实现一对多的goroutine协作流程? + +更具体地说,我需要你编写一个名为coordinateWithContext的函数。这个函数应该具有上面coordinateWithWaitGroup函数相同的功能。 + +显然,你不能再使用sync.WaitGroup了,而要用context包中的函数和Context类型作为实现工具。这里注意一点,是否分批启用执行子任务的goroutine其实并不重要。 + +我在这里给你一个参考答案。 + +func coordinateWithContext() { + total := 12 + var num int32 + fmt.Printf("The number: %d [with context.Context]\n", num) + cxt, cancelFunc := context.WithCancel(context.Background()) + for i := 1; i <= total; i++ { + go addNum(&num, i, func() { + if atomic.LoadInt32(&num) == int32(total) { + cancelFunc() + } + }) + } + <-cxt.Done() + fmt.Println("End.") +} + + +在这个函数体中,我先后调用了context.Background函数和context.WithCancel函数,并得到了一个可撤销的context.Context类型的值(由变量cxt代表),以及一个context.CancelFunc类型的撤销函数(由变量cancelFunc代表)。 + +在后面那条唯一的for语句中,我在每次迭代中都通过一条go语句,异步地调用addNum函数,调用的总次数只依据了total变量的值。 + +请注意我给予addNum函数的最后一个参数值。它是一个匿名函数,其中只包含了一条if语句。这条if语句会“原子地”加载num变量的值,并判断它是否等于total变量的值。 + +如果两个值相等,那么就调用cancelFunc函数。其含义是,如果所有的addNum函数都执行完毕,那么就立即通知分发子任务的goroutine。 + +这里分发子任务的goroutine,即为执行coordinateWithContext函数的goroutine。它在执行完for语句后,会立即调用cxt变量的Done函数,并试图针对该函数返回的通道,进行接收操作。 + +由于一旦cancelFunc函数被调用,针对该通道的接收操作就会马上结束,所以,这样做就可以实现“等待所有的addNum函数都执行完毕”的功能。 + +问题解析 + +context.Context类型(以下简称Context类型)是在Go 1.7发布时才被加入到标准库的。而后,标准库中的很多其他代码包都为了支持它而进行了扩展,包括:os/exec包、net包、database/sql包,以及runtime/pprof包和runtime/trace包,等等。 + +Context类型之所以受到了标准库中众多代码包的积极支持,主要是因为它是一种非常通用的同步工具。它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。 + +更具体地说,Context类型可以提供一类代表上下文的值。此类值是并发安全的,也就是说它可以被传播给多个goroutine。 + +由于Context类型实际上是一个接口类型,而context包中实现该接口的所有私有类型,都是基于某个数据类型的指针类型,所以,如此传播并不会影响该类型值的功能和安全。 + +Context类型的值(以下简称Context值)是可以繁衍的,这意味着我们可以通过一个Context值产生出任意个子值。这些子值可以携带其父值的属性和数据,也可以响应我们通过其父值传达的信号。 + +正因为如此,所有的Context值共同构成了一颗代表了上下文全貌的树形结构。这棵树的树根(或者称上下文根节点)是一个已经在context包中预定义好的Context值,它是全局唯一的。通过调用context.Background函数,我们就可以获取到它(我在coordinateWithContext函数中就是这么做的)。 + +这里注意一下,这个上下文根节点仅仅是一个最基本的支点,它不提供任何额外的功能。也就是说,它既不可以被撤销(cancel),也不能携带任何数据。 + +除此之外,context包中还包含了四个用于繁衍Context值的函数,即:WithCancel、WithDeadline、WithTimeout和WithValue。 + +这些函数的第一个参数的类型都是context.Context,而名称都为parent。顾名思义,这个位置上的参数对应的都是它们将会产生的Context值的父值。 + +WithCancel函数用于产生一个可撤销的parent的子值。在coordinateWithContext函数中,我通过调用该函数,获得了一个衍生自上下文根节点的Context值,和一个用于触发撤销信号的函数。 + +而WithDeadline函数和WithTimeout函数则都可以被用来产生一个会定时撤销的parent的子值。至于WithValue函数,我们可以通过调用它,产生一个会携带额外数据的parent的子值。 + +到这里,我们已经对context包中的函数和Context类型有了一个基本的认识了。不过这还不够,我们再来扩展一下。 + +知识扩展 + +问题1:“可撤销的”在context包中代表着什么?“撤销”一个Context值又意味着什么? + +我相信很多初识context包的Go程序开发者,都会有这样的疑问。确实,“可撤销的”(cancelable)这个词在这里是比较抽象的,很容易让人迷惑。我这里再来解释一下。 + +这需要从Context类型的声明讲起。这个接口中有两个方法与“撤销”息息相关。Done方法会返回一个元素类型为struct{}的接收通道。不过,这个接收通道的用途并不是传递元素值,而是让调用方去感知“撤销”当前Context值的那个信号。 + +一旦当前的Context值被撤销,这里的接收通道就会被立即关闭。我们都知道,对于一个未包含任何元素值的通道来说,它的关闭会使任何针对它的接收操作立即结束。 + +正因为如此,在coordinateWithContext函数中,基于调用表达式cxt.Done()的接收操作,才能够起到感知撤销信号的作用。 + +除了让Context值的使用方感知到撤销信号,让它们得到“撤销”的具体原因,有时也是很有必要的。后者即是Context类型的Err方法的作用。该方法的结果是error类型的,并且其值只可能等于context.Canceled变量的值,或者context.DeadlineExceeded变量的值。 + +前者用于表示手动撤销,而后者则代表:由于我们给定的过期时间已到,而导致的撤销。 + +你可能已经感觉到了,对于Context值来说,“撤销”这个词如果当名词讲,指的其实就是被用来表达“撤销”状态的信号;如果当动词讲,指的就是对撤销信号的传达;而“可撤销的”指的则是具有传达这种撤销信号的能力。 + +我在前面讲过,当我们通过调用context.WithCancel函数产生一个可撤销的Context值时,还会获得一个用于触发撤销信号的函数。 + +通过调用这个函数,我们就可以触发针对这个Context值的撤销信号。一旦触发,撤销信号就会立即被传达给这个Context值,并由它的Done方法的结果值(一个接收通道)表达出来。 + +撤销函数只负责触发信号,而对应的可撤销的Context值也只负责传达信号,它们都不会去管后边具体的“撤销”操作。实际上,我们的代码可以在感知到撤销信号之后,进行任意的操作,Context值对此并没有任何的约束。 + +最后,若再深究的话,这里的“撤销”最原始的含义其实就是,终止程序针对某种请求(比如HTTP请求)的响应,或者取消对某种指令(比如SQL指令)的处理。这也是Go语言团队在创建context代码包,和Context类型时的初衷。 + +如果我们去查看net包和database/sql包的API和源码的话,就可以了解它们在这方面的典型应用。 + +问题2:撤销信号是如何在上下文树中传播的? + +我在前面讲了,context包中包含了四个用于繁衍Context值的函数。其中的WithCancel、WithDeadline和WithTimeout都是被用来基于给定的Context值产生可撤销的子值的。 + +context包的WithCancel函数在被调用后会产生两个结果值。第一个结果值就是那个可撤销的Context值,而第二个结果值则是用于触发撤销信号的函数。 + +在撤销函数被调用之后,对应的Context值会先关闭它内部的接收通道,也就是它的Done方法会返回的那个通道。 + +然后,它会向它的所有子值(或者说子节点)传达撤销信号。这些子值会如法炮制,把撤销信号继续传播下去。最后,这个Context值会断开它与其父值之间的关联。 + + + +(在上下文树中传播撤销信号) + +我们通过调用context包的WithDeadline函数或者WithTimeout函数生成的Context值也是可撤销的。它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销。这里定时撤销的功能是借助它们内部的计时器来实现的。 + +当过期时间到达时,这两种Context值的行为与Context值被手动撤销时的行为是几乎一致的,只不过前者会在最后停止并释放掉其内部的计时器。 + +最后要注意,通过调用context.WithValue函数得到的Context值是不可撤销的。撤销信号在被传播时,若遇到它们则会直接跨过,并试图将信号直接传给它们的子值。 + +问题 3:怎样通过Context值携带数据?怎样从中获取数据? + +既然谈到了context包的WithValue函数,我们就来说说Context值携带数据的方式。 + +WithValue函数在产生新的Context值(以下简称含数据的Context值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。 + +原因很简单,当我们从中获取数据的时候,它需要根据给定的键来查找对应的值。不过,这种Context值并不是用字典来存储键和值的,后两者只是被简单地存储在前者的相应字段中而已。 + +Context类型的Value方法就是被用来获取数据的。在我们调用含数据的Context值的Value方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。 + +如果其父值中仍然未存储相等的键,那么该方法就会沿着上下文根节点的方向一路查找下去。 + +注意,除了含数据的Context值以外,其他几种Context值都是无法携带数据的。因此,Context值的Value方法在沿路查找的时候,会直接跨过那几种值。 + +如果我们调用的Value方法的所属值本身就是不含数据的,那么实际调用的就将会是其父辈或祖辈的Value方法。这是由于这几种Context值的实际类型,都属于结构体类型,并且它们都是通过“将其父值嵌入到自身”,来表达父子关系的。 + +最后,提醒一下,Context接口并没有提供改变数据的方法。因此,在通常情况下,我们只能通过在上下文树中添加含数据的Context值来存储新的数据,或者通过撤销此种值的父值丢弃掉相应的数据。如果你存储在这里的数据可以从外部改变,那么必须自行保证安全。 + +总结 + +我们今天主要讨论的是context包中的函数和Context类型。该包中的函数都是用于产生新的Context类型值的。Context类型是一个可以帮助我们实现多goroutine协作流程的同步工具。不但如此,我们还可以通过此类型的值传达撤销信号或传递数据。 + +Context类型的实际值大体上分为三种,即:根Context值、可撤销的Context值和含数据的Context值。所有的Context值共同构成了一颗上下文树。这棵树的作用域是全局的,而根Context值就是这棵树的根。它是全局唯一的,并且不提供任何额外的功能。 + +可撤销的Context值又分为:只可手动撤销的Context值,和可以定时撤销的Context值。 + +我们可以通过生成它们时得到的撤销函数来对其进行手动的撤销。对于后者,定时撤销的时间必须在生成时就完全确定,并且不能更改。不过,我们可以在过期时间达到之前,对其进行手动的撤销。 + +一旦撤销函数被调用,撤销信号就会立即被传达给对应的Context值,并由该值的Done方法返回的接收通道表达出来。 + +“撤销”这个操作是Context值能够协调多个goroutine的关键所在。撤销信号总是会沿着上下文树叶子节点的方向传播开来。 + +含数据的Context值可以携带数据。每个值都可以存储一对键和值。在我们调用它的Value方法的时候,它会沿着上下文树的根节点的方向逐个值的进行查找。如果发现相等的键,它就会立即返回对应的值,否则将在最后返回nil。 + +含数据的Context值不能被撤销,而可撤销的Context值又无法携带数据。但是,由于它们共同组成了一个有机的整体(即上下文树),所以在功能上要比sync.WaitGroup强大得多。 + +思考题 + +今天的思考题是:Context值在传达撤销信号的时候是广度优先的,还是深度优先的?其优势和劣势都是什么? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/33临时对象池sync.Pool.md b/专栏/Go语言核心36讲/33临时对象池sync.Pool.md new file mode 100644 index 0000000..35dca81 --- /dev/null +++ b/专栏/Go语言核心36讲/33临时对象池sync.Pool.md @@ -0,0 +1,179 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 33 临时对象池sync.Pool + 到目前为止,我们已经一起学习了Go语言标准库中最重要的那几个同步工具,这包括非常经典的互斥锁、读写锁、条件变量和原子操作,以及Go语言特有的几个同步工具: + + +sync/atomic.Value; +sync.Once; +sync.WaitGroup +context.Context。 + + +今天,我们来讲Go语言标准库中的另一个同步工具:sync.Pool。 + +sync.Pool类型可以被称为临时对象池,它的值可以被用来存储临时的对象。与Go语言的很多同步工具一样,sync.Pool类型也属于结构体类型,它的值在被真正使用之后,就不应该再被复制了。 + +这里的“临时对象”的意思是:不需要持久使用的某一类值。这类值对于程序来说可有可无,但如果有的话会明显更好。它们的创建和销毁可以在任何时候发生,并且完全不会影响到程序的功能。 + +同时,它们也应该是无需被区分的,其中的任何一个值都可以代替另一个。如果你的某类值完全满足上述条件,那么你就可以把它们存储到临时对象池中。 + +你可能已经想到了,我们可以把临时对象池当作针对某种数据的缓存来用。实际上,在我看来,临时对象池最主要的用途就在于此。 + +sync.Pool类型只有两个方法——Put和Get。Put用于在当前的池中存放临时对象,它接受一个interface{}类型的参数;而Get则被用于从当前的池中获取临时对象,它会返回一个interface{}类型的值。 + +更具体地说,这个类型的Get方法可能会从当前的池中删除掉任何一个值,然后把这个值作为结果返回。如果此时当前的池中没有任何值,那么这个方法就会使用当前池的New字段创建一个新值,并直接将其返回。 + +sync.Pool类型的New字段代表着创建临时对象的函数。它的类型是没有参数但有唯一结果的函数类型,即:func() interface{}。 + +这个函数是Get方法最后的临时对象获取手段。Get方法如果到了最后,仍然无法获取到一个值,那么就会调用该函数。该函数的结果值并不会被存入当前的临时对象池中,而是直接返回给Get方法的调用方。 + +这里的New字段的实际值需要我们在初始化临时对象池的时候就给定。否则,在我们调用它的Get方法的时候就有可能会得到nil。所以,sync.Pool类型并不是开箱即用的。不过,这个类型也就只有这么一个公开的字段,因此初始化起来也并不麻烦。 + +举个例子。标准库代码包fmt就使用到了sync.Pool类型。这个包会创建一个用于缓存某类临时对象的sync.Pool类型值,并将这个值赋给一个名为ppFree的变量。这类临时对象可以识别、格式化和暂存需要打印的内容。 + +var ppFree = sync.Pool{ + New: func() interface{} { return new(pp) }, +} + + +临时对象池ppFree的New字段在被调用的时候,总是会返回一个全新的pp类型值的指针(即临时对象)。这就保证了ppFree的Get方法总能返回一个可以包含需要打印内容的值。 + +pp类型是fmt包中的私有类型,它有很多实现了不同功能的方法。不过,这里的重点是,它的每一个值都是独立的、平等的和可重用的。 + + +更具体地说,这些对象既互不干扰,又不会受到外部状态的影响。它们几乎只针对某个需要打印内容的缓冲区而已。由于fmt包中的代码在真正使用这些临时对象之前,总是会先对其进行重置,所以它们并不在意取到的是哪一个临时对象。这就是临时对象的平等性的具体体现。 + + +另外,这些代码在使用完临时对象之后,都会先抹掉其中已缓冲的内容,然后再把它存放到ppFree中。这样就为重用这类临时对象做好了准备。 + +众所周知的fmt.Println、fmt.Printf等打印函数都是如此使用ppFree,以及其中的临时对象的。因此,在程序同时执行很多的打印函数调用的时候,ppFree可以及时地把它缓存的临时对象提供给它们,以加快执行的速度。 + +而当程序在一段时间内不再执行打印函数调用时,ppFree中的临时对象又能够被及时地清理掉,以节省内存空间。 + +显然,在这个维度上,临时对象池可以帮助程序实现可伸缩性。这就是它的最大价值。 + +我想,到了这里你已经清楚了临时对象池的基本功能、使用方式、适用场景和存在意义。我们下面来讨论一下它的一些内部机制,这样,我们就可以更好地利用它做更多的事。 + +首先,我来问你一个问题。这个问题很可能也是你想问的。今天的问题是:为什么说临时对象池中的值会被及时地清理掉? + +这里的典型回答是:因为,Go语言运行时系统中的垃圾回收器,所以在每次开始执行之前,都会对所有已创建的临时对象池中的值进行全面地清除。 + +问题解析 + +我在前面已经向你讲述了临时对象会在什么时候被创建,下面我再来详细说说它会在什么时候被销毁。 + +sync包在被初始化的时候,会向Go语言运行时系统注册一个函数,这个函数的功能就是清除所有已创建的临时对象池中的值。我们可以把它称为池清理函数。 + +一旦池清理函数被注册到了Go语言运行时系统,后者在每次即将执行垃圾回收时就都会执行前者。 + +另外,在sync包中还有一个包级私有的全局变量。这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为*sync.Pool的切片。我们可以称之为池汇总列表。 + +通常,在一个临时对象池的Put方法或Get方法第一次被调用的时候,这个池就会被添加到池汇总列表中。正因为如此,池清理函数总是能访问到所有正在被真正使用的临时对象池。 + +更具体地说,池清理函数会遍历池汇总列表。对于其中的每一个临时对象池,它都会先将池中所有的私有临时对象和共享临时对象列表都置为nil,然后再把这个池中的所有本地池列表都销毁掉。 + +最后,池清理函数会把池汇总列表重置为空的切片。如此一来,这些池中存储的临时对象就全部被清除干净了。 + +如果临时对象池以外的代码再无对它们的引用,那么在稍后的垃圾回收过程中,这些临时对象就会被当作垃圾销毁掉,它们占用的内存空间也会被回收以备他用。 + +以上,就是我对临时对象清理的进一步说明。首先需要记住的是,池清理函数和池汇总列表的含义,以及它们起到的关键作用。一旦理解了这些,那么在有人问到你这个问题的时候,你应该就可以从容地应对了。 + +不过,我们在这里还碰到了几个新的词,比如:私有临时对象、共享临时对象列表和本地池。这些都代表着什么呢?这就涉及了下面的问题。 + +知识扩展 + +问题1:临时对象池存储值所用的数据结构是怎样的? + +在临时对象池中,有一个多层的数据结构。正因为有了它的存在,临时对象池才能够非常高效地存储大量的值。 + +这个数据结构的顶层,我们可以称之为本地池列表,不过更确切地说,它是一个数组。这个列表的长度,总是与Go语言调度器中的P的数量相同。 + +还记得吗?Go语言调度器中的P是processor的缩写,它指的是一种可以承载若干个G、且能够使这些G适时地与M进行对接,并得到真正运行的中介。 + +这里的G正是goroutine的缩写,而M则是machine的缩写,后者指代的是系统级的线程。正因为有了P的存在,G和M才能够进行灵活、高效的配对,从而实现强大的并发编程模型。 + +P存在的一个很重要的原因是为了分散并发程序的执行压力,而让临时对象池中的本地池列表的长度与P的数量相同的主要原因也是分散压力。这里所说的压力包括了存储和性能两个方面。在说明它们之前,我们先来探索一下临时对象池中的那个数据结构。 + +在本地池列表中的每个本地池都包含了三个字段(或者说组件),它们是:存储私有临时对象的字段private、代表了共享临时对象列表的字段shared,以及一个sync.Mutex类型的嵌入字段。 + +- +sync.Pool中的本地池与各个G的对应关系 + +实际上,每个本地池都对应着一个P。我们都知道,一个goroutine要想真正运行就必须先与某个P产生关联。也就是说,一个正在运行的goroutine必然会关联着某个P。 + +在程序调用临时对象池的Put方法或Get方法的时候,总会先试图从该临时对象池的本地池列表中,获取与之对应的本地池,依据的就是与当前的goroutine关联的那个P的ID。 + +换句话说,一个临时对象池的Put方法或Get方法会获取到哪一个本地池,完全取决于调用它的代码所在的goroutine关联的那个P。 + +既然说到了这里,那么紧接着就会有下面这个问题。 + +问题 2:临时对象池是怎样利用内部数据结构来存取值的? + +临时对象池的Put方法总会先试图把新的临时对象,存储到对应的本地池的private字段中,以便在后面获取临时对象的时候,可以快速地拿到一个可用的值。 + +只有当这个private字段已经存有某个值时,该方法才会去访问本地池的shared字段。 + +相应的,临时对象池的Get方法,总会先试图从对应的本地池的private字段处获取一个临时对象。只有当这个private字段的值为nil时,它才会去访问本地池的shared字段。 + +一个本地池的shared字段原则上可以被任何goroutine中的代码访问到,不论这个goroutine关联的是哪一个P。这也是我把它叫做共享临时对象列表的原因。 + +相比之下,一个本地池的private字段,只可能被与之对应的那个P所关联的goroutine中的代码访问到,所以可以说,它是P级私有的。 + +以临时对象池的Put方法为例,它一旦发现对应的本地池的private字段已存有值,就会去访问这个本地池的shared字段。当然,由于shared字段是共享的,所以此时必须受到互斥锁的保护。 + +还记得本地池嵌入的那个sync.Mutex类型的字段吗?它就是这里用到的互斥锁,也就是说,本地池本身就拥有互斥锁的功能。Put方法会在互斥锁的保护下,把新的临时对象追加到共享临时对象列表的末尾。 + +相应的,临时对象池的Get方法在发现对应本地池的private字段未存有值时,也会去访问后者的shared字段。它会在互斥锁的保护下,试图把该共享临时对象列表中的最后一个元素值取出并作为结果。 + +不过,这里的共享临时对象列表也可能是空的,这可能是由于这个本地池中的所有临时对象都已经被取走了,也可能是当前的临时对象池刚被清理过。 + +无论原因是什么,Get方法都会去访问当前的临时对象池中的所有本地池,它会去逐个搜索它们的共享临时对象列表。 + +只要发现某个共享临时对象列表中包含元素值,它就会把该列表的最后一个元素值取出并作为结果返回。 + +- +从sync.Pool中获取临时对象的步骤 + +当然了,即使这样也可能无法拿到一个可用的临时对象,比如,在所有的临时对象池都刚被大清洗的情况下就会是如此。 + +这时,Get方法就会使出最后的手段——调用可创建临时对象的那个函数。还记得吗?这个函数是由临时对象池的New字段代表的,并且需要我们在初始化临时对象池的时候给定。如果这个字段的值是nil,那么Get方法此时也只能返回nil了。 + +以上,就是我对这个问题的较完整回答。 + +总结 + +今天,我们一起讨论了另一个比较有用的同步工具——sync.Pool类型,它的值被我称为临时对象池。 + +临时对象池有一个New字段,我们在初始化这个池的时候最好给定它。临时对象池还拥有两个方法,即:Put和Get,它们分别被用于向池中存放临时对象,和从池中获取临时对象。 + +临时对象池中存储的每一个值都应该是独立的、平等的和可重用的。我们应该既不用关心从池中拿到的是哪一个值,也不用在意这个值是否已经被使用过。 + +要完全做到这两点,可能会需要我们额外地写一些代码。不过,这个代码量应该是微乎其微的,就像fmt包对临时对象池的用法那样。所以,在选用临时对象池的时候,我们必须要把它将要存储的值的特性考虑在内。 + +在临时对象池的内部,有一个多层的数据结构支撑着对临时对象的存储。它的顶层是本地池列表,其中包含了与某个P对应的那些本地池,并且其长度与P的数量总是相同的。 + +在每个本地池中,都包含一个私有的临时对象和一个共享的临时对象列表。前者只能被其对应的P所关联的那个goroutine中的代码访问到,而后者却没有这个约束。从另一个角度讲,前者用于临时对象的快速存取,而后者则用于临时对象的池内共享。 + +正因为有了这样的数据结构,临时对象池才能够有效地分散存储压力和性能压力。同时,又因为临时对象池的Get方法对这个数据结构的妙用,才使得其中的临时对象能够被高效地利用。比如,该方法有时候会从其他的本地池的共享临时对象列表中,“偷取”一个临时对象。 + +这样的内部结构和存取方式,让临时对象池成为了一个特点鲜明的同步工具。它存储的临时对象都应该是拥有较长生命周期的值,并且,这些值不应该被某个goroutine中的代码长期的持有和使用。 + +因此,临时对象池非常适合用作针对某种数据的缓存。从某种角度讲,临时对象池可以帮助程序实现可伸缩性,这也正是它的最大价值。 + +思考题 + +今天的思考题是:怎样保证一个临时对象池中总有比较充足的临时对象? + +请从临时对象池的初始化和方法调用两个方面作答。必要时可以参考fmt包以及demo70.go文件中使用临时对象池的方式。 + +感谢你的收听,我们下次再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/34并发安全字典sync.Map(上).md b/专栏/Go语言核心36讲/34并发安全字典sync.Map(上).md new file mode 100644 index 0000000..8c7ea93 --- /dev/null +++ b/专栏/Go语言核心36讲/34并发安全字典sync.Map(上).md @@ -0,0 +1,131 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 34 并发安全字典sync.Map (上) + 在前面,我几乎已经把Go语言自带的同步工具全盘托出了。你是否已经听懂了会用了呢? + +无论怎样,我都希望你能够多多练习、多多使用。它们和Go语言独有的并发编程方式并不冲突,相反,配合起来使用,绝对能达到“一加一大于二”的效果。 + +当然了,至于怎样配合就是一门学问了。我在前面已经讲了不少的方法和技巧,不过,更多的东西可能就需要你在实践中逐渐领悟和总结了。 + + + +我们今天再来讲一个并发安全的高级数据结构:sync.Map。众所周知,Go语言自带的字典类型map并不是并发安全的。 + +前导知识:并发安全字典诞生史 + +换句话说,在同一时间段内,让不同goroutine中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因这些操作而产生混乱,相关的程序也可能会因此发生不可预知的问题。 + +在sync.Map出现之前,我们如果要实现并发安全的字典,就只能自行构建。不过,这其实也不是什么麻烦事,使用 sync.Mutex或sync.RWMutex,再加上原生的map就可以轻松地做到。 + +GitHub网站上已经有很多库提供了类似的数据结构。我在《Go并发编程实战》的第2版中也提供了一个比较完整的并发安全字典的实现。它的性能比同类的数据结构还要好一些,因为它在很大程度上有效地避免了对锁的依赖。 + +尽管已经有了不少的参考实现,Go语言爱好者们还是希望Go语言官方能够发布一个标准的并发安全字典。 + +经过大家多年的建议和吐槽,Go语言官方终于在2017年发布的Go 1.9中,正式加入了并发安全的字典类型sync.Map。 + +这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全。同时,它的存、取、删等操作都可以基本保证在常数时间内执行完毕。换句话说,它们的算法复杂度与map类型一样都是O(1)的。 + +在有些时候,与单纯使用原生map和互斥锁的方案相比,使用sync.Map可以显著地减少锁的争用。sync.Map本身虽然也用到了锁,但是,它其实在尽可能地避免使用锁。 + +我们都知道,使用锁就意味着要把一些并发的操作强制串行化。这往往会降低程序的性能,尤其是在计算机拥有多个CPU核心的情况下。 + +因此,我们常说,能用原子操作就不要用锁,不过这很有局限性,毕竟原子只能对一些基本的数据类型提供支持。 + +无论在何种场景下使用sync.Map,我们都需要注意,与原生map明显不同,它只是Go语言标准库中的一员,而不是语言层面的东西。也正因为这一点,Go语言的编译器并不会对它的键和值,进行特殊的类型检查。 + +如果你看过sync.Map的文档或者实际使用过它,那么就一定会知道,它所有的方法涉及的键和值的类型都是interface{},也就是空接口,这意味着可以包罗万象。所以,我们必须在程序中自行保证它的键类型和值类型的正确性。 + +好了,现在第一个问题来了。今天的问题是:并发安全字典对键的类型有要求吗? + +这道题的典型回答是:有要求。键的实际类型不能是函数类型、字典类型和切片类型。 + +解析一下这个问题。 我们都知道,Go语言的原生字典的键类型不能是函数类型、字典类型和切片类型。 + +由于并发安全字典内部使用的存储介质正是原生字典,又因为它使用的原生字典键类型也是可以包罗万象的interface{};所以,我们绝对不能带着任何实际类型为函数类型、字典类型或切片类型的键值去操作并发安全字典。 + +由于这些键值的实际类型只有在程序运行期间才能够确定,所以Go语言编译器是无法在编译期对它们进行检查的,不正确的键值实际类型肯定会引发panic。 + +因此,我们在这里首先要做的一件事就是:一定不要违反上述规则。我们应该在每次操作并发安全字典的时候,都去显式地检查键值的实际类型。无论是存、取还是删,都应该如此。 + +当然,更好的做法是,把针对同一个并发安全字典的这几种操作都集中起来,然后统一地编写检查代码。除此之外,把并发安全字典封装在一个结构体类型中,往往是一个很好的选择。 + +总之,我们必须保证键的类型是可比较的(或者说可判等的)。如果你实在拿不准,那么可以先通过调用reflect.TypeOf函数得到一个键值对应的反射类型值(即:reflect.Type类型的值),然后再调用这个值的Comparable方法,得到确切的判断结果。 + +知识扩展 + +问题1:怎样保证并发安全字典中的键和值的类型正确性?(方案一) + +简单地说,可以使用类型断言表达式或者反射操作来保证它们的类型正确性。 + +为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。 + +第一种方案是,让并发安全字典只能存储某个特定类型的键。 + +比如,指定这里的键只能是int类型的,或者只能是字符串,又或是某类结构体。一旦完全确定了键的类型,你就可以在进行存、取、删操作的时候,使用类型断言表达式去对键的类型做检查了。 + +一般情况下,这种检查并不繁琐。而且,你要是把并发安全字典封装在一个结构体类型里面,那就更加方便了。你这时完全可以让Go语言编译器帮助你做类型检查。请看下面的代码: + +type IntStrMap struct { + m sync.Map +} + +func (iMap *IntStrMap) Delete(key int) { + iMap.m.Delete(key) +} + +func (iMap *IntStrMap) Load(key int) (value string, ok bool) { + v, ok := iMap.m.Load(key) + if v != nil { + value = v.(string) + } + return +} + +func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) { + a, loaded := iMap.m.LoadOrStore(key, value) + actual = a.(string) + return +} + +func (iMap *IntStrMap) Range(f func(key int, value string) bool) { + f1 := func(key, value interface{}) bool { + return f(key.(int), value.(string)) + } + iMap.m.Range(f1) +} + +func (iMap *IntStrMap) Store(key int, value string) { + iMap.m.Store(key, value) +} + + +如上所示,我编写了一个名为IntStrMap的结构体类型,它代表了键类型为int、值类型为string的并发安全字典。在这个结构体类型中,只有一个sync.Map类型的字段m。并且,这个类型拥有的所有方法,都与sync.Map类型的方法非常类似。 + +两者对应的方法名称完全一致,方法签名也非常相似,只不过,与键和值相关的那些参数和结果的类型不同而已。在IntStrMap类型的方法签名中,明确了键的类型为int,且值的类型为string。 + +显然,这些方法在接受键和值的时候,就不用再做类型检查了。另外,这些方法在从m中取出键和值的时候,完全不用担心它们的类型会不正确,因为它的正确性在当初存入的时候,就已经由Go语言编译器保证了。 + +稍微总结一下。第一种方案适用于我们可以完全确定键和值的具体类型的情况。在这种情况下,我们可以利用Go语言编译器去做类型检查,并用类型断言表达式作为辅助,就像IntStrMap那样。 + +总结 + +我们今天讨论的是sync.Map类型,它是一种并发安全的字典。它提供了一些常用的键、值存取操作方法,并保证了这些操作的并发安全。同时,它还保证了存、取、删等操作的常数级执行时间。 + +与原生的字典相同,并发安全字典对键的类型也是有要求的。它们同样不能是函数类型、字典类型和切片类型。 + +另外,由于并发安全字典提供的方法涉及的键和值的类型都是interface{},所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。 + +这里大致有两个方案。我们今天主要提到了第一种方案,这是在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。 + +在下一次的文章中,我们会提到另外一种方案,并对比这两种方案的优劣。除此之外,我会继续探讨并发安全字典的相关问题。 + +感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/35并发安全字典sync.Map(下).md b/专栏/Go语言核心36讲/35并发安全字典sync.Map(下).md new file mode 100644 index 0000000..f68f3d7 --- /dev/null +++ b/专栏/Go语言核心36讲/35并发安全字典sync.Map(下).md @@ -0,0 +1,174 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 35 并发安全字典sync.Map (下) + 你好,我是郝林,今天我们继续来分享并发安全字典sync.Map的内容。 + +我们在上一篇文章中谈到了,由于并发安全字典提供的方法涉及的键和值的类型都是interface{},所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。 + +这里大致有两个方案。我们上一篇文章中提到了第一种方案,在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。 + +这样做很方便,不是吗?不过,虽然方便,但是却让这样的字典类型缺少了一些灵活性。 + +如果我们还需要一个键类型为uint32并发安全字典的话,那就不得不再如法炮制地写一遍代码了。因此,在需求多样化之后,工作量反而更大,甚至会产生很多雷同的代码。 + +知识扩展 + +问题1:怎样保证并发安全字典中的键和值的类型正确性?(方案二) + +那么,如果我们既想保持sync.Map类型原有的灵活性,又想约束键和值的类型,那么应该怎样做呢?这就涉及了第二个方案。 + +在第二种方案中,我们封装的结构体类型的所有方法,都可以与sync.Map类型的方法完全一致(包括方法名称和方法签名)。 + +不过,在这些方法中,我们就需要添加一些做类型检查的代码了。另外,这样并发安全字典的键类型和值类型,必须在初始化的时候就完全确定。并且,这种情况下,我们必须先要保证键的类型是可比较的。 + +所以在设计这样的结构体类型的时候,只包含sync.Map类型的字段就不够了。 + +比如: + +type ConcurrentMap struct { + m sync.Map + keyType reflect.Type + valueType reflect.Type +} + + +这里ConcurrentMap类型代表的是:可自定义键类型和值类型的并发安全字典。这个类型同样有一个sync.Map类型的字段m,代表着其内部使用的并发安全字典。 + +另外,它的字段keyType和valueType,分别用于保存键类型和值类型。这两个字段的类型都是reflect.Type,我们可称之为反射类型。 + +这个类型可以代表Go语言的任何数据类型。并且,这个类型的值也非常容易获得:通过调用reflect.TypeOf函数并把某个样本值传入即可。 + +调用表达式reflect.TypeOf(int(123))的结果值,就代表了int类型的反射类型值。 + +我们现在来看一看ConcurrentMap类型方法应该怎么写。 + +先说Load方法,这个方法接受一个interface{}类型的参数key,参数key代表了某个键的值。 + +因此,当我们根据ConcurrentMap在m字段的值中查找键值对的时候,就必须保证ConcurrentMap的类型是正确的。由于反射类型值之间可以直接使用操作符==或!=进行判等,所以这里的类型检查代码非常简单。 + +func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) { + if reflect.TypeOf(key) != cMap.keyType { + return + } + return cMap.m.Load(key) +} + + +我们把一个接口类型值传入reflect.TypeOf函数,就可以得到与这个值的实际类型对应的反射类型值。 + +因此,如果参数值的反射类型与keyType字段代表的反射类型不相等,那么我们就忽略后续操作,并直接返回。 + +这时,Load方法的第一个结果value的值为nil,而第二个结果ok的值为false。这完全符合Load方法原本的含义。 + +再来说Store方法。Store方法接受两个参数key和value,它们的类型也都是interface{}。因此,我们的类型检查应该针对它们来做。 + +func (cMap *ConcurrentMap) Store(key, value interface{}) { + if reflect.TypeOf(key) != cMap.keyType { + panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key))) + } + if reflect.TypeOf(value) != cMap.valueType { + panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value))) + } + cMap.m.Store(key, value) +} + + +这里的类型检查代码与Load方法中的代码很类似,不同的是对检查结果的处理措施。当参数key或value的实际类型不符合要求时,Store方法会立即引发panic。 + +这主要是由于Store方法没有结果声明,所以在参数值有问题的时候,它无法通过比较平和的方式告知调用方。不过,这也是符合Store方法的原本含义的。 + +如果你不想这么做,也是可以的,那么就需要为Store方法添加一个error类型的结果。 + +并且,在发现参数值类型不正确的时候,让它直接返回相应的error类型值,而不是引发panic。要知道,这里展示的只一个参考实现,你可以根据实际的应用场景去做优化和改进。 + +至于与ConcurrentMap类型相关的其他方法和函数,我在这里就不展示了。它们在类型检查方式和处理流程上并没有特别之处。你可以在demo72.go文件中看到这些代码。 + +稍微总结一下。第一种方案适用于我们可以完全确定键和值具体类型的情况。在这种情况下,我们可以利用Go语言编译器去做类型检查,并用类型断言表达式作为辅助,就像IntStrMap那样。 + +在第二种方案中,我们无需在程序运行之前就明确键和值的类型,只要在初始化并发安全字典的时候,动态地给定它们就可以了。这里主要需要用到reflect包中的函数和数据类型,外加一些简单的判等操作。 + +第一种方案存在一个很明显的缺陷,那就是无法灵活地改变字典的键和值的类型。一旦需求出现多样化,编码的工作量就会随之而来。 + +第二种方案很好地弥补了这一缺陷,但是,那些反射操作或多或少都会降低程序的性能。我们往往需要根据实际的应用场景,通过严谨且一致的测试,来获得和比较程序的各项指标,并以此作为方案选择的重要依据之一。 + +问题2:并发安全字典如何做到尽量避免使用锁? + +sync.Map类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的map作为存储介质。 + +其中一个原生map被存在了sync.Map的read字段中,该字段是sync/atomic.Value类型的。 这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的sync.Map值中包含的所有键值对。 + +为了描述方便,我们在后面简称它为只读字典。不过,只读字典虽然不会增减其中的键,但却允许变更其中的键所对应的值。所以,它并不是传统意义上的快照,它的只读特性只是对于其中键的集合而言的。 + +由read字段的类型可知,sync.Map在替换只读字典的时候根本用不着锁。另外,这个只读字典在存储键值对的时候,还在值之上封装了一层。 + +它先把值转换为了unsafe.Pointer类型的值,然后再把后者封装,并储存在其中的原生字典中。如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。 + +sync.Map中的另一个原生字典由它的dirty字段代表。 它存储键值对的方式与read字段中的原生字典一致,它的键类型也是interface{},并且同样是把值先做转换和封装后再进行储存的。我们暂且把它称为脏字典。 + +注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。 + +正如前文所述,这两个字典在存储键和值的时候都只会存入它们的某个指针,而不是基本值。 + +sync.Map在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当确定“只读字典中没有,但脏字典中可能会有这个键”的时候,它才会在锁的保护下去访问脏字典。 + +相对应的,sync.Map在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁。 + +否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。 + + + +sync.Map中的read与dirty + +顺便说一句,只有当一个键值对应该被删除,但却仍然存在于只读字典中的时候,才会被用标记为“已删除”的方式进行逻辑删除,而不会直接被物理删除。 + +这种情况会在重建脏字典以后的一段时间内出现。不过,过不了多久,它们就会被真正删除掉。在查找和遍历键值对的时候,已被逻辑删除的键值对永远会被无视。 + +对于删除键值对,sync.Map会先去检查只读字典中是否有对应的键。如果没有,脏字典中可能有,那么它就会在锁的保护下,试图从脏字典中删掉该键值对。 + +最后,sync.Map会把该键值对中指向值的那个指针置为nil,这是另一种逻辑删除的方式。 + +除此之外,还有一个细节需要注意,只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候,sync.Map会把脏字典直接作为只读字典,保存在它的read字段中,然后把代表脏字典的dirty字段的值置为nil。 + +在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读字典中已被逻辑删除的键值对过滤掉。理所当然,这些转换操作肯定都需要在锁的保护下进行。 + +- +sync.Map中read与dirty的互换 + +综上所述,sync.Map的只读字典和脏字典中的键值对集合,并不是实时同步的,它们在某些时间段内可能会有不同。 + +由于只读字典中键的集合不能被改变,所以其中的键值对有时候可能是不全的。相反,脏字典中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。 + +因此,可以看出,在读操作有很多但写操作却很少的情况下,并发安全字典的性能往往会更好。在几个写操作当中,新增键值对的操作对并发安全字典的性能影响是最大的,其次是删除操作,最后才是修改操作。 + +如果被操作的键值对已经存在于sync.Map的只读字典中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响就会很小。 + +总结 + +这两篇文章中,我们讨论了sync.Map类型,并谈到了怎样保证并发安全字典中的键和值的类型正确性。 + +为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。 + + +其中一种方案是,在编码时就完全确定键和值的类型,然后利用Go语言的编译器帮我们做检查。 + +另一种方案是,接受动态的类型设置,并在程序运行的时候通过反射操作进行检查。 + + +这两种方案各有利弊,前一种方案在扩展性方面有所欠缺,而后一种方案通常会影响到程序的性能。在实际使用的时候,我们一般都需要通过客观的测试来帮助决策。 + +另外,在有些时候,与单纯使用原生字典和互斥锁的方案相比,使用sync.Map可以显著地减少锁的争用。sync.Map本身确实也用到了锁,但是,它会尽可能地避免使用锁。 + +这就要说到sync.Map对其持有两个原生字典的巧妙使用了。这两个原生字典一个被称为只读字典,另一个被称为脏字典。通过对它们的分析,我们知道了并发安全字典的适用场景,以及每种操作对其性能的影响程度。 + +思考题 + +今天的思考题是:关于保证并发安全字典中的键和值的类型正确性,你还能想到其他的方案吗? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/36unicode与字符编码.md b/专栏/Go语言核心36讲/36unicode与字符编码.md new file mode 100644 index 0000000..8ad043d --- /dev/null +++ b/专栏/Go语言核心36讲/36unicode与字符编码.md @@ -0,0 +1,259 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 36 unicode与字符编码 + 到目前为止,我们已经一起陆陆续续地学完了Go语言中那些最重要也最有特色的概念、语法和编程方式。我对于它们非常喜爱,简直可以用如数家珍来形容了。 + +在开始今天的内容之前,我先来做一个简单的总结。 + +Go语言经典知识总结 + +基于混合线程的并发编程模型自然不必多说。 + +在数据类型方面有: + + +基于底层数组的切片; +用来传递数据的通道; +作为一等类型的函数; +可实现面向对象的结构体; +能无侵入实现的接口等。 + + +在语法方面有: + + +异步编程神器go语句; +函数的最后关卡defer语句; +可做类型判断的switch语句; +多通道操作利器select语句; +非常有特色的异常处理函数panic和recover。 + + +除了这些,我们还一起讨论了测试Go程序的主要方式。这涉及了Go语言自带的程序测试套件,相关的概念和工具包括: + + +独立的测试源码文件; +三种功用不同的测试函数; +专用的testing代码包; +功能强大的go test命令。 + + +另外,就在前不久,我还为你深入讲解了Go语言提供的那些同步工具。它们也是Go语言并发编程工具箱中不可或缺的一部分。这包括了: + + +经典的互斥锁; +读写锁; +条件变量; +原子操作。 + + +以及Go语言特有的一些数据类型,即: + + +单次执行小助手sync.Once; +临时对象池sync.Pool; +帮助我们实现多goroutine协作流程的sync.WaitGroup、context.Context; +一种高效的并发安全字典sync.Map。 + + +毫不夸张地说,如果你真正地掌握了上述这些知识,那么就已经获得了Go语言编程的精髓。 + +在这之后,你再去研读Go语言标准库和那些优秀第三方库中的代码的时候,就一定会事半功倍。同时,在使用Go语言编写软件的时候,你肯定也会如鱼得水、游刃有余的。 + +我用了大量的篇幅讲解了Go语言中最核心的知识点,真心希望你已经搞懂了这些内容。 + +在后面的日子里,我会与你一起去探究Go语言标准库中最常用的那些代码包,弄清它们的用法、了解它们的机理。当然了,我还会顺便讲一讲那些必备的周边知识。 + +前导内容1:Go语言字符编码基础 + +首先,让我们来关注字符编码方面的问题。这应该是在计算机软件领域中非常基础的一个问题了。 + +我在前面说过,Go语言中的标识符可以包含“任何Unicode编码可以表示的字母字符”。我还说过,虽然我们可以直接把一个整数值转换为一个string类型的值。 + +但是,被转换的整数值应该可以代表一个有效的Unicode代码点,否则转换的结果就将会是"�",即:一个仅由高亮的问号组成的字符串值。 + +另外,当一个string类型的值被转换为[]rune类型值的时候,其中的字符串会被拆分成一个一个的Unicode字符。 + +显然,Go语言采用的字符编码方案从属于Unicode编码规范。更确切地说,Go语言的代码正是由Unicode字符组成的。Go语言的所有源代码,都必须按照Unicode编码规范中的UTF-8编码格式进行编码。 + +换句话说,Go语言的源码文件必须使用UTF-8编码格式进行存储。如果源码文件中出现了非UTF-8编码的字符,那么在构建、安装以及运行的时候,go命令就会报告错误“illegal UTF-8 encoding”。 + +在这里,我们首先要对Unicode编码规范有所了解。不过,在讲述它之前,我先来简要地介绍一下ASCII编码。 + +前导内容 2: ASCII编码 + +ASCII是英文“American Standard Code for Information Interchange”的缩写,中文译为美国信息交换标准代码。它是由美国国家标准学会(ANSI)制定的单字节字符编码方案,可用于基于文本的数据交换。 + +它最初是美国的国家标准,后又被国际标准化组织(ISO)定为国际标准,称为ISO 646标准,并适用于所有的拉丁文字字母。 + +ASCII编码方案使用单个字节(byte)的二进制数来编码一个字符。标准的ASCII编码用一个字节的最高比特(bit)位作为奇偶校验位,而扩展的ASCII编码则将此位也用于表示字符。ASCII编码支持的可打印字符和控制字符的集合也被叫做ASCII编码集。 + +我们所说的Unicode编码规范,实际上是另一个更加通用的、针对书面字符和文本的字符编码标准。它为世界上现存的所有自然语言中的每一个字符,都设定了一个唯一的二进制编码。 + +它定义了不同自然语言的文本数据在国际间交换的统一方式,并为全球化软件创建了一个重要的基础。 + +Unicode编码规范以ASCII编码集为出发点,并突破了ASCII只能对拉丁字母进行编码的限制。它不但提供了可以对世界上超过百万的字符进行编码的能力,还支持所有已知的转义序列和控制代码。 + +我们都知道,在计算机系统的内部,抽象的字符会被编码为整数。这些整数的范围被称为代码空间。在代码空间之内,每一个特定的整数都被称为一个代码点。 + +一个受支持的抽象字符会被映射并分配给某个特定的代码点,反过来讲,一个代码点总是可以被看成一个被编码的字符。 + +Unicode编码规范通常使用十六进制表示法来表示Unicode代码点的整数值,并使用“U+”作为前缀。比如,英文字母字符“a”的Unicode代码点是U+0061。在Unicode编码规范中,一个字符能且只能由与它对应的那个代码点表示。 + +Unicode编码规范现在的最新版本是11.0,并会于2019年3月发布12.0版本。而Go语言从1.10版本开始,已经对Unicode的10.0版本提供了全面的支持。对于绝大多数的应用场景来说,这已经完全够用了。 + +Unicode编码规范提供了三种不同的编码格式,即:UTF-8、UTF-16和UTF-32。其中的UTF是UCS Transformation Format的缩写。而UCS又是Universal Character Set的缩写,但也可以代表Unicode Character Set。所以,UTF也可以被翻译为Unicode转换格式。它代表的是字符与字节序列之间的转换方式。 + +在这几种编码格式的名称中,“-”右边的整数的含义是,以多少个比特位作为一个编码单元。以UTF-8为例,它会以8个比特,也就是一个字节,作为一个编码单元。并且,它与标准的ASCII编码是完全兼容的。也就是说,在[0x00, 0x7F]的范围内,这两种编码表示的字符都是相同的。这也是UTF-8编码格式的一个巨大优势。 + +UTF-8是一种可变宽的编码方案。换句话说,它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。比如,对于一个英文字符,它仅用一个字节的二进制数就可以表示,而对于一个中文字符,它需要使用三个字节才能够表示。不论怎样,一个受支持的字符总是可以由UTF-8编码为一个字节序列。以下会简称后者为UTF-8编码值。 + +现在,在你初步地了解了这些知识之后,请认真地思考并回答下面的问题。别担心,我会在后面进一步阐述Unicode、UTF-8以及Go语言对它们的运用。 + +问题:一个string类型的值在底层是怎样被表达的? + +典型回答 是在底层,一个string类型的值是由一系列相对应的Unicode代码点的UTF-8编码值来表达的。 + +问题解析 + +在Go语言中,一个string类型的值既可以被拆分为一个包含多个字符的序列,也可以被拆分为一个包含多个字节的序列。 + +前者可以由一个以rune为元素类型的切片来表示,而后者则可以由一个以byte为元素类型的切片代表。 + +rune是Go语言特有的一个基本数据类型,它的一个值就代表一个字符,即:一个Unicode字符。 + +比如,'G'、'o'、'爱'、'好'、'者'代表的就都是一个Unicode字符。 + +我们已经知道,UTF-8编码方案会把一个Unicode字符编码为一个长度在[1, 4]范围内的字节序列。所以,一个rune类型的值也可以由一个或多个字节来代表。 + +type rune = int32 + + +根据rune类型的声明可知,它实际上就是int32类型的一个别名类型。也就是说,一个rune类型的值会由四个字节宽度的空间来存储。它的存储空间总是能够存下一个UTF-8编码值。 + +一个rune类型的值在底层其实就是一个UTF-8编码值。前者是(便于我们人类理解的)外部展现,后者是(便于计算机系统理解的)内在表达。 + +请看下面的代码: + +str := "Go爱好者" +fmt.Printf("The string: %q\n", str) +fmt.Printf(" => runes(char): %q\n", []rune(str)) +fmt.Printf(" => runes(hex): %x\n", []rune(str)) +fmt.Printf(" => bytes(hex): [% x]\n", []byte(str)) + + +字符串值"Go爱好者"如果被转换为[]rune类型的值的话,其中的每一个字符(不论是英文字符还是中文字符)就都会独立成为一个rune类型的元素值。因此,这段代码打印出的第二行内容就会如下所示: + + => runes(char): ['G' 'o' '爱' '好' '者'] + + +又由于,每个rune类型的值在底层都是由一个UTF-8编码值来表达的,所以我们可以换一种方式来展现这个字符序列: + + => runes(hex): [47 6f 7231 597d 8005] + + +可以看到,五个十六进制数与五个字符相对应。很明显,前两个十六进制数47和6f代表的整数都比较小,它们分别表示字符'G'和'o'。 + +因为它们都是英文字符,所以对应的UTF-8编码值用一个字节表达就足够了。一个字节的编码值被转换为整数之后,不会大到哪里去。 + +而后三个十六进制数7231、597d和8005都相对较大,它们分别表示中文字符'爱'、'好'和'者'。 + +这些中文字符对应的UTF-8编码值,都需要使用三个字节来表达。所以,这三个数就是把对应的三个字节的编码值,转换为整数后得到的结果。 + +我们还可以进一步地拆分,把每个字符的UTF-8编码值都拆成相应的字节序列。上述代码中的第五行就是这么做的。它会得到如下的输出: + + => bytes(hex): [47 6f e7 88 b1 e5 a5 bd e8 80 85] + + +这里得到的字节切片比前面的字符切片明显长了很多。这正是因为一个中文字符的UTF-8编码值需要用三个字节来表达。 + +这个字节切片的前两个元素值与字符切片的前两个元素值是一致的,而在这之后,前者的每三个元素值才对应字符切片中的一个元素值。 + +注意,对于一个多字节的UTF-8编码值来说,我们可以把它当做一个整体转换为单一的整数,也可以先把它拆成字节序列,再把每个字节分别转换为一个整数,从而得到多个整数。 + +这两种表示法展现出来的内容往往会很不一样。比如,对于中文字符'爱'来说,它的UTF-8编码值可以展现为单一的整数7231,也可以展现为三个整数,即:e7、88和b1。 + +- +(字符串值的底层表示) + +总之,一个string类型的值会由若干个Unicode字符组成,每个Unicode字符都可以由一个rune类型的值来承载。 + +这些字符在底层都会被转换为UTF-8编码值,而这些UTF-8编码值又会以字节序列的形式表达和存储。因此,一个string类型的值在底层就是一个能够表达若干个UTF-8编码值的字节序列。 + +知识扩展 + +问题 1:使用带有range子句的for语句遍历字符串值的时候应该注意什么? + +带有range子句的for语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个UTF-8编码值,或者说每一个Unicode字符。 + +这样的for语句可以为两个迭代变量赋值。如果存在两个迭代变量,那么赋给第一个变量的值,就将会是当前字节序列中的某个UTF-8编码值的第一个字节所对应的那个索引值。 + +而赋给第二个变量的值,则是这个UTF-8编码值代表的那个Unicode字符,其类型会是rune。 + +例如,有这么几行代码: + +str := "Go爱好者" +for i, c := range str { + fmt.Printf("%d: %q [% x]\n", i, c, []byte(string(c))) +} + + +这里被遍历的字符串值是"Go爱好者"。在每次迭代的时候,这段代码都会打印出两个迭代变量的值,以及第二个值的字节序列形式。完整的打印内容如下: + +0: 'G' [47] +1: 'o' [6f] +2: '爱' [e7 88 b1] +5: '好' [e5 a5 bd] +8: '者' [e8 80 85] + + +第一行内容中的关键信息有0、'G'和[47]。这是由于这个字符串值中的第一个Unicode字符是'G'。该字符是一个单字节字符,并且由相应的字节序列中的第一个字节表达。这个字节的十六进制表示为47。 + +第二行展示的内容与之类似,即:第二个Unicode字符是'o',由字节序列中的第二个字节表达,其十六进制表示为6f。 + +再往下看,第三行展示的是'爱',也是第三个Unicode字符。因为它是一个中文字符,所以由字节序列中的第三、四、五个字节共同表达,其十六进制表示也不再是单一的整数,而是e7、88和b1组成的序列。 + +下面要注意了,正是因为'爱'是由三个字节共同表达的,所以第四个Unicode字符'好'对应的索引值并不是3,而是2加3后得到的5。 + +这里的2代表的是'爱'对应的索引值,而3代表的则是'爱'对应的UTF-8编码值的宽度。对于这个字符串值中的最后一个字符'者'来说也是类似的,因此,它对应的索引值是8。 + +由此可以看出,这样的for语句可以逐一地迭代出字符串值里的每个Unicode字符。但是,相邻的Unicode字符的索引值并不一定是连续的。这取决于前一个Unicode字符是否为单字节字符。 + +正因为如此,如果我们想得到其中某个Unicode字符对应的UTF-8编码值的宽度,就可以用下一个字符的索引值减去当前字符的索引值。 + +初学者可能会对for语句的这种行为感到困惑,因为它给予两个迭代变量的值看起来并不总是对应的。不过,一旦我们了解了它的内在机制就会拨云见日、豁然开朗。 + +总结 + +我们今天把目光聚焦在了Unicode编码规范、UTF-8编码格式,以及Go语言对字符串和字符的相关处理方式上。 + +Go语言的代码是由Unicode字符组成的,它们都必须由Unicode编码规范中的UTF-8编码格式进行编码并存储,否则就会导致go命令的报错。 + +Unicode编码规范中的编码格式定义的是:字符与字节序列之间的转换方式。其中的UTF-8是一种可变宽的编码方案。 + +它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。一个受支持的字符,总是可以由UTF-8编码为一个字节序列,后者也可以被称为UTF-8编码值。 + +Go语言中的一个string类型值会由若干个Unicode字符组成,每个Unicode字符都可以由一个rune类型的值来承载。 + +这些字符在底层都会被转换为UTF-8编码值,而这些UTF-8编码值又会以字节序列的形式表达和存储。因此,一个string类型的值在底层就是一个能够表达若干个UTF-8编码值的字节序列。 + +初学者可能会对带有range子句的for语句遍历字符串值的行为感到困惑,因为它给予两个迭代变量的值看起来并不总是对应的。但事实并非如此。 + +这样的for语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个UTF-8编码值,或者说每一个Unicode字符。 + +相邻的Unicode字符的索引值并不一定是连续的。这取决于前一个Unicode字符是否为单字节字符。一旦我们清楚了这些内在机制就不会再困惑了。 + +对于Go语言来说,Unicode编码规范和UTF-8编码格式算是基础之一了。我们应该了解到它们对Go语言的重要性。这对于正确理解Go语言中的相关数据类型以及日后的相关程序编写都会很有好处。 + +思考题 + +今天的思考题是:判断一个Unicode字符是否为单字节字符通常有几种方式? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/37strings包与字符串操作.md b/专栏/Go语言核心36讲/37strings包与字符串操作.md new file mode 100644 index 0000000..461398e --- /dev/null +++ b/专栏/Go语言核心36讲/37strings包与字符串操作.md @@ -0,0 +1,214 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 37 strings包与字符串操作 + 在上一篇文章中,我介绍了Go语言与Unicode编码规范、UTF-8编码格式的渊源及运用。 + +Go语言不但拥有可以独立代表Unicode字符的类型rune,而且还有可以对字符串值进行Unicode字符拆分的for语句。 + +除此之外,标准库中的unicode包及其子包还提供了很多的函数和数据类型,可以帮助我们解析各种内容中的Unicode字符。 + +这些程序实体都很好用,也都很简单明了,而且有效地隐藏了Unicode编码规范中的一些复杂的细节。我就不在这里对它们进行专门的讲解了。 + +我们今天主要来说一说标准库中的strings代码包。这个代码包也用到了不少unicode包和unicode/utf8包中的程序实体。 + + +比如,strings.Builder类型的WriteRune方法。 + +又比如,strings.Reader类型的ReadRune方法,等等。 + + +下面这个问题就是针对strings.Builder类型的。我们今天的问题是:与string值相比,strings.Builder类型的值有哪些优势? + +这里的典型回答是这样的。 + +strings.Builder类型的值(以下简称Builder值)的优势有下面的三种: + + +已存在的内容不可变,但可以拼接更多的内容; +减少了内存分配和内容拷贝的次数; +可将内容重置,可重用值。 + + +问题解析 + +先来说说string类型。 我们都知道,在Go语言中,string类型的值是不可变的。 如果我们想获得一个不一样的字符串,那么就只能基于原字符串进行裁剪、拼接等操作,从而生成一个新的字符串。 + + +裁剪操作可以使用切片表达式; +拼接操作可以用操作符+实现。 + + +在底层,一个string值的内容会被存储到一块连续的内存空间中。同时,这块内存容纳的字节数量也会被记录下来,并用于表示该string值的长度。 + +你可以把这块内存的内容看成一个字节数组,而相应的string值则包含了指向字节数组头部的指针值。如此一来,我们在一个string值上应用切片表达式,就相当于在对其底层的字节数组做切片。 + +另外,我们在进行字符串拼接的时候,Go语言会把所有被拼接的字符串依次拷贝到一个崭新且足够大的连续内存空间中,并把持有相应指针值的string值作为结果返回。 + +显然,当程序中存在过多的字符串拼接操作的时候,会对内存的分配产生非常大的压力。 + +注意,虽然string值在内部持有一个指针值,但其类型仍然属于值类型。不过,由于string值的不可变,其中的指针值也为内存空间的节省做出了贡献。 + +更具体地说,一个string值会在底层与它的所有副本共用同一个字节数组。由于这里的字节数组永远不会被改变,所以这样做是绝对安全的。 + +与string值相比,Builder值的优势其实主要体现在字符串拼接方面。 + +Builder值中有一个用于承载内容的容器(以下简称内容容器)。它是一个以byte为元素类型的切片(以下简称字节切片)。 + +由于这样的字节切片的底层数组就是一个字节数组,所以我们可以说它与string值存储内容的方式是一样的。 + +实际上,它们都是通过一个unsafe.Pointer类型的字段来持有那个指向了底层字节数组的指针值的。 + +正是因为这样的内部构造,Builder值同样拥有高效利用内存的前提条件。虽然,对于字节切片本身来说,它包含的任何元素值都可以被修改,但是Builder值并不允许这样做,其中的内容只能够被拼接或者完全重置。 + +这就意味着,已存在于Builder值中的内容是不可变的。因此,我们可以利用Builder值提供的方法拼接更多的内容,而丝毫不用担心这些方法会影响到已存在的内容。 + + +这里所说的方法指的是,Builder值拥有的一系列指针方法,包括:Write、WriteByte、WriteRune和WriteString。我们可以把它们统称为拼接方法。 + + +我们可以通过调用上述方法把新的内容拼接到已存在的内容的尾部(也就是右边)。这时,如有必要,Builder值会自动地对自身的内容容器进行扩容。这里的自动扩容策略与切片的扩容策略一致。 + +换句话说,我们在向Builder值拼接内容的时候并不一定会引起扩容。只要内容容器的容量够用,扩容就不会进行,针对于此的内存分配也不会发生。同时,只要没有扩容,Builder值中已存在的内容就不会再被拷贝。 + +除了Builder值的自动扩容,我们还可以选择手动扩容,这通过调用Builder值的Grow方法就可以做到。Grow方法也可以被称为扩容方法,它接受一个int类型的参数n,该参数用于代表将要扩充的字节数量。 + +如有必要,Grow方法会把其所属值中内容容器的容量增加n个字节。更具体地讲,它会生成一个字节切片作为新的内容容器,该切片的容量会是原容器容量的二倍再加上n。之后,它会把原容器中的所有字节全部拷贝到新容器中。 + +var builder1 strings.Builder +// 省略若干代码。 +fmt.Println("Grow the builder ...") +builder1.Grow(10) +fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len()) + + +当然,Grow方法还可能什么都不做。这种情况的前提条件是:当前的内容容器中的未用容量已经够用了,即:未用容量大于或等于n。这里的前提条件与前面提到的自动扩容策略中的前提条件是类似的。 + +fmt.Println("Reset the builder ...") +builder1.Reset() +fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String()) + + +最后,Builder值是可以被重用的。通过调用它的Reset方法,我们可以让Builder值重新回到零值状态,就像它从未被使用过那样。 + +一旦被重用,Builder值中原有的内容容器会被直接丢弃。之后,它和其中的所有内容,将会被Go语言的垃圾回收器标记并回收掉。 + +知识扩展 + +问题1:strings.Builder类型在使用上有约束吗? + +答案是:有约束,概括如下: + + +在已被真正使用后就不可再被复制; +由于其内容不是完全不可变的,所以需要使用方自行解决操作冲突和并发安全问题。 + + +我们只要调用了Builder值的拼接方法或扩容方法,就意味着开始真正使用它了。显而易见,这些方法都会改变其所属值中的内容容器的状态。 + +一旦调用了它们,我们就不能再以任何的方式对其所属值进行复制了。否则,只要在任何副本上调用上述方法就都会引发panic。 + +这种panic会告诉我们,这样的使用方式是并不合法的,因为这里的Builder值是副本而不是原值。顺便说一句,这里所说的复制方式,包括但不限于在函数间传递值、通过通道传递值、把值赋予变量等等。 + +var builder1 strings.Builder +builder1.Grow(1) +builder3 := builder1 +//builder3.Grow(1) // 这里会引发panic。 +_ = builder3 + + +虽然这个约束非常严格,但是如果我们仔细思考一下的话,就会发现它还是有好处的。 + +正是由于已使用的Builder值不能再被复制,所以肯定不会出现多个Builder值中的内容容器(也就是那个字节切片)共用一个底层字节数组的情况。这样也就避免了多个同源的Builder值在拼接内容时可能产生的冲突问题。 + +不过,虽然已使用的Builder值不能再被复制,但是它的指针值却可以。无论什么时候,我们都可以通过任何方式复制这样的指针值。注意,这样的指针值指向的都会是同一个Builder值。 + +f2 := func(bp *strings.Builder) { + (*bp).Grow(1) // 这里虽然不会引发panic,但不是并发安全的。 + builder4 := *bp + //builder4.Grow(1) // 这里会引发panic。 + _ = builder4 +} +f2(&builder1) + + +正因为如此,这里就产生了一个问题,即:如果Builder值被多方同时操作,那么其中的内容就很可能会产生混乱。这就是我们所说的操作冲突和并发安全问题。 + +Builder值自己是无法解决这些问题的。所以,我们在通过传递其指针值共享Builder值的时候,一定要确保各方对它的使用是正确、有序的,并且是并发安全的;而最彻底的解决方案是,绝不共享Builder值以及它的指针值。 + +我们可以在各处分别声明一个Builder值来使用,也可以先声明一个Builder值,然后在真正使用它之前,便将它的副本传到各处。另外,我们还可以先使用再传递,只要在传递之前调用它的Reset方法即可。 + +builder1.Reset() +builder5 := builder1 +builder5.Grow(1) // 这里不会引发panic。 + + +总之,关于复制Builder值的约束是有意义的,也是很有必要的。虽然我们仍然可以通过某些方式共享Builder值,但最好还是不要以身犯险,“各自为政”是最好的解决方案。不过,对于处在零值状态的Builder值,复制不会有任何问题。 + +问题2:为什么说strings.Reader类型的值可以高效地读取字符串? + +与strings.Builder类型恰恰相反,strings.Reader类型是为了高效读取字符串而存在的。后者的高效主要体现在它对字符串的读取机制上,它封装了很多用于在string值上读取内容的最佳实践。 + +strings.Reader类型的值(以下简称Reader值)可以让我们很方便地读取一个字符串中的内容。在读取的过程中,Reader值会保存已读取的字节的计数(以下简称已读计数)。 + +已读计数也代表着下一次读取的起始索引位置。Reader值正是依靠这样一个计数,以及针对字符串值的切片表达式,从而实现快速读取。 + +此外,这个已读计数也是读取回退和位置设定时的重要依据。虽然它属于Reader值的内部结构,但我们还是可以通过该值的Len方法和Size把它计算出来的。代码如下: + +var reader1 strings.Reader +// 省略若干代码。 +readingIndex := reader1.Size() - int64(reader1.Len()) // 计算出的已读计数。 + + +Reader值拥有的大部分用于读取的方法都会及时地更新已读计数。比如,ReadByte方法会在读取成功后将这个计数的值加1。 + +又比如,ReadRune方法在读取成功之后,会把被读取的字符所占用的字节数作为计数的增量。 + +不过,ReadAt方法算是一个例外。它既不会依据已读计数进行读取,也不会在读取后更新它。正因为如此,这个方法可以自由地读取其所属的Reader值中的任何内容。 + +除此之外,Reader值的Seek方法也会更新该值的已读计数。实际上,这个Seek方法的主要作用正是设定下一次读取的起始索引位置。 + +另外,如果我们把常量io.SeekCurrent的值作为第二个参数值传给该方法,那么它还会依据当前的已读计数,以及第一个参数offset的值来计算新的计数值。 + +由于Seek方法会返回新的计数值,所以我们可以很容易地验证这一点。比如像下面这样: + +offset2 := int64(17) +expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2 +fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent) +readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent) +fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex) +fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex) + + +综上所述,Reader值实现高效读取的关键就在于它内部的已读计数。计数的值就代表着下一次读取的起始索引位置。它可以很容易地被计算出来。Reader值的Seek方法可以直接设定该值中的已读计数值。 + +总结 + +今天,我们主要讨论了strings代码包中的两个重要类型,即:Builder和Reader。前者用于构建字符串,而后者则用于读取字符串。 + +与string值相比,Builder值的优势主要体现在字符串拼接方面。它可以在保证已存在的内容不变的前提下,拼接更多的内容,并且会在拼接的过程中,尽量减少内存分配和内容拷贝的次数。 + +不过,这类值在使用上也是有约束的。它在被真正使用之后就不能再被复制了,否则就会引发panic。虽然这个约束很严格,但是也可以带来一定的好处。它可以有效地避免一些操作冲突。虽然我们可以通过一些手段(比如传递它的指针值)绕过这个约束,但这是弊大于利的。最好的解决方案就是分别声明、分开使用、互不干涉。 + +Reader值可以让我们很方便地读取一个字符串中的内容。它的高效主要体现在它对字符串的读取机制上。在读取的过程中,Reader值会保存已读取的字节的计数,也称已读计数。 + +这个计数代表着下一次读取的起始索引位置,同时也是高效读取的关键所在。我们可以利用这类值的Len方法和Size方法,计算出其中的已读计数的值。有了它,我们就可以更加灵活地进行字符串读取了。 + +我只在本文介绍了上述两个数据类型,但并不意味着strings包中有用的程序实体只有这两个。实际上,strings包还提供了大量的函数。比如: + +`Count`、`IndexRune`、`Map`、`Replace`、`SplitN`、`Trim`,等等。 + + +它们都是非常易用和高效的。你可以去看看它们的源码,也许会因此有所感悟。 + +思考题 + +今天的思考题是:*strings.Builder和*strings.Reader都分别实现了哪些接口?这样做有什么好处吗? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/38bytes包与字节串操作(上).md b/专栏/Go语言核心36讲/38bytes包与字节串操作(上).md new file mode 100644 index 0000000..49d65c0 --- /dev/null +++ b/专栏/Go语言核心36讲/38bytes包与字节串操作(上).md @@ -0,0 +1,143 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 38 bytes包与字节串操作(上) + 我相信,经过上一次的学习,你已经对strings.Builder和strings.Reader这两个类型足够熟悉了。 + +我上次还建议你去自行查阅strings代码包中的其他程序实体。如果你认真去看了,那么肯定会对我们今天要讨论的bytes代码包,有种似曾相识的感觉。 + +前导内容: bytes.Buffer基础知识 + +strings包和bytes包可以说是一对孪生兄弟,它们在API方面非常的相似。单从它们提供的函数的数量和功能上讲,差别可以说是微乎其微。 + +只不过,strings包主要面向的是Unicode字符和经过UTF-8编码的字符串,而bytes包面对的则主要是字节和字节切片。 + +我今天会主要讲bytes包中最有特色的类型Buffer。顾名思义,bytes.Buffer类型的用途主要是作为字节序列的缓冲区。 + +与strings.Builder类型一样,bytes.Buffer也是开箱即用的。 + +但不同的是,strings.Builder只能拼接和导出字符串,而bytes.Buffer不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。 + +可以说,bytes.Buffer是集读、写功能于一身的数据类型。当然了,这些也基本上都是作为一个缓冲区应该拥有的功能。 + +在内部,bytes.Buffer类型同样是使用字节切片作为内容容器的。并且,与strings.Reader类型类似,bytes.Buffer有一个int类型的字段,用于代表已读字节的计数,可以简称为已读计数。 + +不过,这里的已读计数就无法通过bytes.Buffer提供的方法计算出来了。 + +我们先来看下面的代码: + +var buffer1 bytes.Buffer +contents := "Simple byte buffer for marshaling data." +fmt.Printf("Writing contents %q ...\n", contents) +buffer1.WriteString(contents) +fmt.Printf("The length of buffer: %d\n", buffer1.Len()) +fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) + + +我先声明了一个bytes.Buffer类型的变量buffer1,并写入了一个字符串。然后,我想打印出这个bytes.Buffer类型的值(以下简称Buffer值)的长度和容量。在运行这段代码之后,我们将会看到如下的输出: + +Writing contents "Simple byte buffer for marshaling data." ... +The length of buffer: 39 +The capacity of buffer: 64 + + +乍一看这没什么问题。长度39和容量64的含义看起来与我们已知的概念是一致的。我向缓冲区中写入了一个长度为39的字符串,所以buffer1的长度就是39。 + +根据切片的自动扩容策略,64这个数字也是合理的。另外,可以想象,这时的已读计数的值应该是0,这是因为我还没有调用任何用于读取其中内容的方法。 + +可实际上,与strings.Reader类型的Len方法一样,buffer1的Len方法返回的也是内容容器中未被读取部分的长度,而不是其中已存内容的总长度(以下简称内容长度)。示例如下: + +p1 := make([]byte, 7) +n, _ := buffer1.Read(p1) +fmt.Printf("%d bytes were read. (call Read)\n", n) +fmt.Printf("The length of buffer: %d\n", buffer1.Len()) +fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) + + +当我从buffer1中读取一部分内容,并用它们填满长度为7的字节切片p1之后,buffer1的Len方法返回的结果值也会随即发生变化。如果运行这段代码,我们会发现,这个缓冲区的长度已经变为了32。 + +另外,因为我们并没有再向该缓冲区中写入任何内容,所以它的容量会保持不变,仍是64。 + +总之,在这里,你需要记住的是,Buffer值的长度是未读内容的长度,而不是已存内容的总长度。 它与在当前值之上的读操作和写操作都有关系,并会随着这两种操作的进行而改变,它可能会变得更小,也可能会变得更大。 + +而Buffer值的容量指的是它的内容容器(也就是那个字节切片)的容量,它只与在当前值之上的写操作有关,并会随着内容的写入而不断增长。 + +再说已读计数。由于strings.Reader还有一个Size方法可以给出内容长度的值,所以我们用内容长度减去未读部分的长度,就可以很方便地得到它的已读计数。 + +然而,bytes.Buffer类型却没有这样一个方法,它只有Cap方法。可是Cap方法提供的是内容容器的容量,也不是内容长度。 + +并且,这里的内容容器容量在很多时候都与内容长度不相同。因此,没有了现成的计算公式,只要遇到稍微复杂些的情况,我们就很难估算出Buffer值的已读计数。 + +一旦理解了已读计数这个概念,并且能够在读写的过程中,实时地获得已读计数和内容长度的值,我们就可以很直观地了解到当前Buffer值各种方法的行为了。不过,很可惜,这两个数字我们都无法直接拿到。 + +虽然,我们无法直接得到一个Buffer值的已读计数,并且有时候也很难估算它,但是我们绝对不能就此作罢,而应该通过研读bytes.Buffer和文档和源码,去探究已读计数在其中起到的关键作用。 + +否则,我们想用好bytes.Buffer的意愿,恐怕就不会那么容易实现了。 + +下面的这个问题,如果你认真地阅读了bytes.Buffer的源码之后,就可以很好地回答出来。 + +我们今天的问题是:bytes.Buffer类型的值记录的已读计数,在其中起到了怎样的作用? + +这道题的典型回答是这样的。 + +bytes.Buffer中的已读计数的大致功用如下所示。 + + +读取内容时,相应方法会依据已读计数找到未读部分,并在读取后更新计数。 +写入内容时,如需扩容,相应方法会根据已读计数实现扩容策略。 +截断内容时,相应方法截掉的是已读计数代表索引之后的未读部分。 +读回退时,相应方法需要用已读计数记录回退点。 +重置内容时,相应方法会把已读计数置为0。 +导出内容时,相应方法只会导出已读计数代表的索引之后的未读部分。 +获取长度时,相应方法会依据已读计数和内容容器的长度,计算未读部分的长度并返回。 + + +问题解析 + +通过上面的典型回答,我们已经能够体会到已读计数在bytes.Buffer类型,及其方法中的重要性了。没错,bytes.Buffer的绝大多数方法都用到了已读计数,而且都是非用不可。 + +在读取内容的时候,相应方法会先根据已读计数,判断一下内容容器中是否还有未读的内容。如果有,那么它就会从已读计数代表的索引处开始读取。 + +在读取完成后,它还会及时地更新已读计数。也就是说,它会记录一下又有多少个字节被读取了。这里所说的相应方法包括了所有名称以Read开头的方法,以及Next方法和WriteTo方法。 + +在写入内容的时候,绝大多数的相应方法都会先检查当前的内容容器,是否有足够的容量容纳新的内容。如果没有,那么它们就会对内容容器进行扩容。 + +在扩容的时候,方法会在必要时,依据已读计数找到未读部分,并把其中的内容拷贝到扩容后内容容器的头部位置。 + +然后,方法将会把已读计数的值置为0,以表示下一次读取需要从内容容器的第一个字节开始。用于写入内容的相应方法,包括了所有名称以Write开头的方法,以及ReadFrom方法。 + +用于截断内容的方法Truncate,会让很多对bytes.Buffer不太了解的程序开发者迷惑。 它会接受一个int类型的参数,这个参数的值代表了:在截断时需要保留头部的多少个字节。 + +不过,需要注意的是,这里说的头部指的并不是内容容器的头部,而是其中的未读部分的头部。头部的起始索引正是由已读计数的值表示的。因此,在这种情况下,已读计数的值再加上参数值后得到的和,就是内容容器新的总长度。 + +在bytes.Buffer中,用于读回退的方法有UnreadByte和UnreadRune。 这两个方法分别用于回退一个字节和回退一个Unicode字符。调用它们一般都是为了退回在上一次被读取内容末尾的那个分隔符,或者为重新读取前一个字节或字符做准备。 + +不过,退回的前提是,在调用它们之前的那一个操作必须是“读取”,并且是成功的读取,否则这些方法就只能忽略后续操作并返回一个非nil的错误值。 + +UnreadByte方法的做法比较简单,把已读计数的值减1就好了。而UnreadRune方法需要从已读计数中减去的,是上一次被读取的Unicode字符所占用的字节数。 + +这个字节数由bytes.Buffer的另一个字段负责存储,它在这里的有效取值范围是[1, 4]。只有ReadRune方法才会把这个字段的值设定在此范围之内。 + +由此可见,只有紧接在调用ReadRune方法之后,对UnreadRune方法的调用才能够成功完成。该方法明显比UnreadByte方法的适用面更窄。 + +我在前面说过,bytes.Buffer的Len方法返回的是内容容器中未读部分的长度,而不是其中已存内容的总长度(即:内容长度)。 + +而该类型的Bytes方法和String方法的行为,与Len方法是保持一致的。前两个方法只会去访问未读部分中的内容,并返回相应的结果值。 + +在我们剖析了所有的相关方法之后,可以这样来总结:在已读计数代表的索引之前的那些内容,永远都是已经被读过的,它们几乎没有机会再次被读取。 + +不过,这些已读内容所在的内存空间可能会被存入新的内容。这一般都是由于重置或者扩充内容容器导致的。这时,已读计数一定会被置为0,从而再次指向内容容器中的第一个字节。这有时候也是为了避免内存分配和重用内存空间。 + +总结 + +总结一下,bytes.Buffer是一个集读、写功能于一身的数据类型。它非常适合作为字节序列的缓冲区。我们会在下一篇文章中继续对bytes.Buffer的知识进行延展。如果你对于这部分内容有什么样问题,欢迎给我留言,我们一起讨论。 + +感谢你的收听,我们下次再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/39bytes包与字节串操作(下).md b/专栏/Go语言核心36讲/39bytes包与字节串操作(下).md new file mode 100644 index 0000000..dffc7c9 --- /dev/null +++ b/专栏/Go语言核心36讲/39bytes包与字节串操作(下).md @@ -0,0 +1,135 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 39 bytes包与字节串操作(下) + 你好,我是郝林,今天我们继续分享bytes包与字节串操作的相关内容。 + +在上一篇文章中,我们分享了bytes.Buffer中已读计数的大致功用,并围绕着这个问题做了解析,下面我们来进行相关的知识扩展。 + +知识扩展 + +问题 1:bytes.Buffer的扩容策略是怎样的? + +Buffer值既可以被手动扩容,也可以进行自动扩容。并且,这两种扩容方式的策略是基本一致的。所以,除非我们完全确定后续内容所需的字节数,否则让Buffer值自动去扩容就好了。 + +在扩容的时候,Buffer值中相应的代码(以下简称扩容代码)会先判断内容容器的剩余容量,是否可以满足调用方的要求,或者是否足够容纳新的内容。 + +如果可以,那么扩容代码会在当前的内容容器之上,进行长度扩充。 + +更具体地说,如果内容容器的容量与其长度的差,大于或等于另需的字节数,那么扩容代码就会通过切片操作对原有的内容容器的长度进行扩充,就像下面这样: + +b.buf = b.buf[:length+need] + + +反之,如果内容容器的剩余容量不够了,那么扩容代码可能就会用新的内容容器去替代原有的内容容器,从而实现扩容。 + +不过,这里还有一步优化。 + +如果当前内容容器的容量的一半,仍然大于或等于其现有长度(即未读字节数)再加上另需的字节数的和,即: + +cap(b.buf)/2 >= b.Len() + need + + +那么,扩容代码就会复用现有的内容容器,并把容器中的未读内容拷贝到它的头部位置。 + +这也意味着其中的已读内容,将会全部被未读内容和之后的新内容覆盖掉。 + +这样的复用预计可以至少节省掉一次后续的扩容所带来的内存分配,以及若干字节的拷贝。 + +若这一步优化未能达成,也就是说,当前内容容器的容量小于新长度的二倍。 + +那么,扩容代码就只能再创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的容器替换掉原有的容器。这个新容器的容量将会等于原有容量的二倍再加上另需字节数的和。 + + +新容器的容量=2*原有容量+所需字节数 + + +通过上面这些步骤,对内容容器的扩充基本上就完成了。不过,为了内部数据的一致性,以及避免原有的已读内容可能造成的数据混乱,扩容代码还会把已读计数置为0,并再对内容容器做一下切片操作,以掩盖掉原有的已读内容。 + +顺便说一下,对于处在零值状态的Buffer值来说,如果第一次扩容时的另需字节数不大于64,那么该值就会基于一个预先定义好的、长度为64的字节数组来创建内容容器。 + +在这种情况下,这个内容容器的容量就是64。这样做的目的是为了让Buffer值在刚被真正使用的时候就可以快速地做好准备。 + +问题2:bytes.Buffer中的哪些方法可能会造成内容的泄露? + +首先明确一点,什么叫内容泄露?这里所说的内容泄露是指,使用Buffer值的一方通过某种非标准的(或者说不正式的)方式,得到了本不该得到的内容。 + +比如说,我通过调用Buffer值的某个用于读取内容的方法,得到了一部分未读内容。我应该,也只应该通过这个方法的结果值,拿到在那一时刻Buffer值中的未读内容。 + +但是,在这个Buffer值又有了一些新内容之后,我却可以通过当时得到的结果值,直接获得新的内容,而不需要再次调用相应的方法。 + +这就是典型的非标准读取方式。这种读取方式是不应该存在的,即使存在,我们也不应该使用。因为它是在无意中(或者说一不小心)暴露出来的,其行为很可能是不稳定的。 + +在bytes.Buffer中,Bytes方法和Next方法都可能会造成内容的泄露。原因在于,它们都把基于内容容器的切片直接返回给了方法的调用方。 + +我们都知道,通过切片,我们可以直接访问和操纵它的底层数组。不论这个切片是基于某个数组得来的,还是通过对另一个切片做切片操作获得的,都是如此。 + +在这里,Bytes方法和Next方法返回的字节切片,都是通过对内容容器做切片操作得到的。也就是说,它们与内容容器共用了同一个底层数组,起码在一段时期之内是这样的。 + +以Bytes方法为例。它会返回在调用那一刻其所属值中的所有未读内容。示例代码如下: + +contents := "ab" +buffer1 := bytes.NewBufferString(contents) +fmt.Printf("The capacity of new buffer with contents %q: %d\n", + contents, buffer1.Cap()) // 内容容器的容量为:8。 +unreadBytes := buffer1.Bytes() +fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 未读内容为:[97 98]。 + + +我用字符串值"ab"初始化了一个Buffer值,由变量buffer1代表,并打印了当时该值的一些状态。 + +你可能会有疑惑,我只在这个Buffer值中放入了一个长度为2的字符串值,但为什么该值的容量却变为了8。 + +虽然这与我们当前的主题无关,但是我可以提示你一下:你可以去阅读runtime包中一个名叫stringtoslicebyte的函数,答案就在其中。 + +接着说buffer1。我又向该值写入了字符串值"cdefg",此时,其容量仍然是8。我在前面通过调用buffer1的Bytes方法得到的结果值unreadBytes,包含了在那时其中的所有未读内容。 + +但是,由于这个结果值与buffer1的内容容器在此时还共用着同一个底层数组,所以,我只需通过简单的再切片操作,就可以利用这个结果值拿到buffer1在此时的所有未读内容。如此一来,buffer1的新内容就被泄露出来了。 + +buffer1.WriteString("cdefg") +fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // 内容容器的容量仍为:8。 +unreadBytes = unreadBytes[:cap(unreadBytes)] +fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 基于前面获取到的结果值可得,未读内容为:[97 98 99 100 101 102 103 0]。 + + +如果我当时把unreadBytes的值传到了外界,那么外界就可以通过该值操纵buffer1的内容了,就像下面这样: + +unreadBytes[len(unreadBytes)-2] = byte('X') // 'X'的ASCII编码为88。 +fmt.Printf("The unread bytes of the buffer: %v\n", buffer1.Bytes()) // 未读内容变为了:[97 98 99 100 101 102 88]。 + + +现在,你应该能够体会到,这里的内容泄露可能造成的严重后果了吧?对于Buffer值的Next方法,也存在相同的问题。 + +不过,如果经过扩容,Buffer值的内容容器或者它的底层数组被重新设定了,那么之前的内容泄露问题就无法再进一步发展了。我在demo80.go文件中写了一个比较完整的示例,你可以去看一看,并揣摩一下。 + +总结 + +我们结合两篇内容总结一下。与strings.Builder类型不同,bytes.Buffer不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。 + +bytes.Buffer类型使用字节切片作为其内容容器,并且会用一个字段实时地记录已读字节的计数。 + +虽然我们无法直接计算出这个已读计数,但是由于它在Buffer值中起到的作用非常关键,所以我们很有必要去理解它。 + +无论是读取、写入、截断、导出还是重置,已读计数都是功能实现中的重要一环。 + +与strings.Builder类型的值一样,Buffer值既可以被手动扩容,也可以进行自动的扩容。除非我们完全确定后续内容所需的字节数,否则让Buffer值自动去扩容就好了。 + +Buffer值的扩容方法并不一定会为了获得更大的容量,替换掉现有的内容容器,而是先会本着尽量减少内存分配和内容拷贝的原则,对当前的内容容器进行重用。并且,只有在容量实在无法满足要求的时候,它才会去创建新的内容容器。 + +此外,你可能并没有想到,Buffer值的某些方法可能会造成内容的泄露。这主要是由于这些方法返回的结果值,在一段时期内会与其所属值的内容容器共用同一个底层数组。 + +如果我们有意或无意地把这些结果值传到了外界,那么外界就有可能通过它们操纵相关联Buffer值的内容。 + +这属于很严重的数据安全问题。我们一定要避免这种情况的发生。最彻底的做法是,在传出切片这类值之前要做好隔离。比如,先对它们进行深度拷贝,然后再把副本传出去。 + +思考题 + +今天的思考题是:对比strings.Builder和bytes.Buffer的String方法,并判断哪一个更高效?原因是什么? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/40io包中的接口和工具(上).md b/专栏/Go语言核心36讲/40io包中的接口和工具(上).md new file mode 100644 index 0000000..c2103b1 --- /dev/null +++ b/专栏/Go语言核心36讲/40io包中的接口和工具(上).md @@ -0,0 +1,215 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 40 io包中的接口和工具 (上) + 我们在前几篇文章中,主要讨论了strings.Builder、strings.Reader和bytes.Buffer这三个数据类型。 + +知识回顾 + +还记得吗?当时我还问过你“它们都实现了哪些接口”。在我们继续讲解io包中的接口和工具之前,我先来解答一下这个问题。 + +strings.Builder类型主要用于构建字符串,它的指针类型实现的接口有io.Writer、io.ByteWriter和fmt.Stringer。另外,它其实还实现了一个io包的包级私有接口io.stringWriter(自Go 1.12起它会更名为io.StringWriter)。 + +strings.Reader类型主要用于读取字符串,它的指针类型实现的接口比较多,包括: + + +io.Reader; +io.ReaderAt; +io.ByteReader; +io.RuneReader; +io.Seeker; +io.ByteScanner; +io.RuneScanner; +io.WriterTo; + + +共有8个,它们都是io包中的接口。 + +其中,io.ByteScanner是io.ByteReader的扩展接口,而io.RuneScanner又是io.RuneReader的扩展接口。 + +bytes.Buffer是集读、写功能于一身的数据类型,它非常适合作为字节序列的缓冲区。 它的指针类型实现的接口就更多了。 + +更具体地说,该指针类型实现的读取相关的接口有下面几个。 + + +io.Reader; +io.ByteReader; +io.RuneReader; +io.ByteScanner; +io.RuneScanner; +io.WriterTo; + + +共有6个。而其实现的写入相关的接口则有这些。 + + +io.Writer; +io.ByteWriter; +io.stringWriter; +io.ReaderFrom; + + +共4个。此外,它还实现了导出相关的接口fmt.Stringer。 + +前导内容:io包中接口的好处与优势 + +那么,这些类型实现了这么多的接口,其动机(或者说目的)究竟是什么呢? + +简单地说,这是为了提高不同程序实体之间的互操作性。远的不说,我们就以io包中的一些函数为例。 + +在io包中,有这样几个用于拷贝数据的函数,它们是: + + +io.Copy; +io.CopyBuffer; +io.CopyN。 + + +虽然这几个函数在功能上都略有差别,但是它们都首先会接受两个参数,即:用于代表数据目的地、io.Writer类型的参数dst,以及用于代表数据来源的、io.Reader类型的参数src。这些函数的功能大致上都是把数据从src拷贝到dst。 + +不论我们给予它们的第一个参数值是什么类型的,只要这个类型实现了io.Writer接口即可。 + +同样的,无论我们传给它们的第二个参数值的实际类型是什么,只要该类型实现了io.Reader接口就行。 + +一旦我们满足了这两个条件,这些函数几乎就可以正常地执行了。当然了,函数中还会对必要的参数值进行有效性的检查,如果检查不通过,它的执行也是不能够成功结束的。 + +下面来看一段示例代码: + +src := strings.NewReader( + "CopyN copies n bytes (or until an error) from src to dst. " + + "It returns the number of bytes copied and " + + "the earliest error encountered while copying.") +dst := new(strings.Builder) +written, err := io.CopyN(dst, src, 58) +if err != nil { + fmt.Printf("error: %v\n", err) +} else { + fmt.Printf("Written(%d): %q\n", written, dst.String()) +} + + +我先使用strings.NewReader创建了一个字符串读取器,并把它赋给了变量src,然后我又new了一个字符串构建器,并将其赋予了变量dst。 + +之后,我在调用io.CopyN函数的时候,把这两个变量的值都传了进去,同时把给这个函数的第三个参数值设定为了58。也就是说,我想从src中拷贝前58个字节到dst那里。 + +虽然,变量src和dst的类型分别是strings.Reader和strings.Builder,但是当它们被传到io.CopyN函数的时候,就已经分别被包装成了io.Reader类型和io.Writer类型的值。io.CopyN函数也根本不会去在意,它们的实际类型到底是什么。 + +为了优化的目的,io.CopyN函数中的代码会对参数值进行再包装,也会检测这些参数值是否还实现了别的接口,甚至还会去探求某个参数值被包装后的实际类型,是否为某个特殊的类型。 + +但是,从总体上来看,这些代码都是面向参数声明中的接口来做的。io.CopyN函数的作者通过面向接口编程,极大地拓展了它的适用范围和应用场景。 + +换个角度看,正因为strings.Reader类型和strings.Builder类型都实现了不少接口,所以它们的值才能够被使用在更广阔的场景中。 + +换句话说,如此一来,Go语言的各种库中,能够操作它们的函数和数据类型明显多了很多。 + +这就是我想要告诉你的,strings包和bytes包中的数据类型在实现了若干接口之后得到的最大好处。 + +也可以说,这就是面向接口编程带来的最大优势。这些数据类型和函数的做法,也是非常值得我们在编程的过程中去效仿的。 + +可以看到,前文所述的几个类型实现的大都是io代码包中的接口。实际上,io包中的接口,对于Go语言的标准库和很多第三方库而言,都起着举足轻重的作用。它们非常基础也非常重要。 + +就拿io.Reader和io.Writer这两个最核心的接口来说,它们是很多接口的扩展对象和设计源泉。同时,单从Go语言的标准库中统计,实现了它们的数据类型都(各自)有上百个,而引用它们的代码更是都(各自)有400多处。 + +很多数据类型实现了io.Reader接口,是因为它们提供了从某处读取数据的功能。类似的,许多能够把数据写入某处的数据类型,也都会去实现io.Writer接口。 + +其实,有不少类型的设计初衷都是:实现这两个核心接口的某个,或某些扩展接口,以提供比单纯的字节序列读取或写入,更加丰富的功能,就像前面讲到的那几个strings包和bytes包中的数据类型那样。 + +在Go语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,这也常被叫做接口的组合。 + +我在讲接口的时候也提到过,Go语言提倡使用小接口加接口组合的方式,来扩展程序的行为以及增加程序的灵活性。io代码包恰恰就可以作为这样的一个标杆,它可以成为我们运用这种技巧时的一个参考标准。 + +下面,我就以io.Reader接口为对象提出一个与接口扩展和实现有关的问题。如果你研究过这个核心接口以及相关的数据类型的话,这个问题回答起来就并不困难。 + +我们今天的问题是:在io包中,io.Reader的扩展接口和实现类型都有哪些?它们分别都有什么功用? + +这道题的典型回答是这样的。在io包中,io.Reader的扩展接口有下面几种。 + + +io.ReadWriter:此接口既是io.Reader的扩展接口,也是io.Writer的扩展接口。换句话说,该接口定义了一组行为,包含且仅包含了基本的字节序列读取方法Read,和字节序列写入方法Write。 +io.ReadCloser:此接口除了包含基本的字节序列读取方法之外,还拥有一个基本的关闭方法Close。后者一般用于关闭数据读写的通路。这个接口其实是io.Reader接口和io.Closer接口的组合。 +io.ReadWriteCloser:很明显,此接口是io.Reader、io.Writer和io.Closer这三个接口的组合。 +io.ReadSeeker:此接口的特点是拥有一个用于寻找读写位置的基本方法Seek。更具体地说,该方法可以根据给定的偏移量基于数据的起始位置、末尾位置,或者当前读写位置去寻找新的读写位置。这个新的读写位置用于表明下一次读或写时的起始索引。Seek是io.Seeker接口唯一拥有的方法。 +io.ReadWriteSeeker:显然,此接口是另一个三合一的扩展接口,它是io.Reader、io.Writer和io.Seeker的组合。 + + +再来说说io包中的io.Reader接口的实现类型,它们包括下面几项内容。 + + +*io.LimitedReader:此类型的基本类型会包装io.Reader类型的值,并提供一个额外的受限读取的功能。所谓的受限读取指的是,此类型的读取方法Read返回的总数据量会受到限制,无论该方法被调用多少次。这个限制由该类型的字段N指明,单位是字节。 + + +*io.SectionReader:此类型的基本类型可以包装io.ReaderAt类型的值,并且会限制它的Read方法,只能够读取原始数据中的某一个部分(或者说某一段)。- + +这个数据段的起始位置和末尾位置,需要在它被初始化的时候就指明,并且之后无法变更。该类型值的行为与切片有些类似,它只会对外暴露在其窗口之中的那些数据。 + + +*io.teeReader:此类型是一个包级私有的数据类型,也是io.TeeReader函数结果值的实际类型。这个函数接受两个参数r和w,类型分别是io.Reader和io.Writer。- + +其结果值的Read方法会把r中的数据经过作为方法参数的字节切片p写入到w。可以说,这个值就是r和w之间的数据桥梁,而那个参数p就是这座桥上的数据搬运者。 + + +*io.multiReader:此类型也是一个包级私有的数据类型。类似的,io包中有一个名为MultiReader的函数,它可以接受若干个io.Reader类型的参数值,并返回一个实际类型为io.multiReader的结果值。- + +当这个结果值的Read方法被调用时,它会顺序地从前面那些io.Reader类型的参数值中读取数据。因此,我们也可以称之为多对象读取器。 + + +*io.pipe:此类型为一个包级私有的数据类型,它比上述类型都要复杂得多。它不但实现了io.Reader接口,而且还实现了io.Writer接口。- + +实际上,io.PipeReader类型和io.PipeWriter类型拥有的所有指针方法都是以它为基础的。这些方法都只是代理了io.pipe类型值所拥有的某一个方法而已。- + +又因为io.Pipe函数会返回这两个类型的指针值并分别把它们作为其生成的同步内存管道的两端,所以可以说,*io.pipe类型就是io包提供的同步内存管道的核心实现。 + +*io.PipeReader:此类型可以被视为io.pipe类型的代理类型。它代理了后者的一部分功能,并基于后者实现了io.ReadCloser接口。同时,它还定义了同步内存管道的读取端。 + + +注意,我在这里忽略掉了测试源码文件中的实现类型,以及不会以任何形式直接对外暴露的那些实现类型。 + +问题解析 + +我问这个问题的目的主要是评估你对io包的熟悉程度。这个代码包是Go语言标准库中所有I/O相关API的根基,所以,我们必须对其中的每一个程序实体都有所了解。 + +然而,由于该包包含的内容众多,因此这里的问题是以io.Reader接口作为切入点的。通过io.Reader接口,我们应该能够梳理出基于它的类型树,并知晓其中每一个类型的功用。 + +io.Reader可谓是io包乃至是整个Go语言标准库中的核心接口,所以我们可以从它那里牵扯出很多扩展接口和实现类型。 + +我在本问题的典型回答中,为你罗列和介绍了io包范围内的相关数据类型。 + +这些类型中的每一个都值得你认真去理解,尤其是那几个实现了io.Reader接口的类型。它们实现的功能在细节上都各有不同。 + +在很多时候,我们可以根据实际需求将它们搭配起来使用。 + +例如,对施加在原始数据之上的(由Read方法提供的)读取功能进行多层次的包装(比如受限读取和多对象读取等),以满足较为复杂的读取需求。 + +在实际的面试中,只要应聘者能够从某一个方面出发,说出io.Reader的扩展接口及其存在意义,或者说清楚该接口的三五个实现类型,那么就可以算是基本回答正确了。 + +比如,从读取、写入、关闭这一系列的基本功能出发,描述清楚: + + +io.ReadWriter; +io.ReadCloser; +io.ReadWriteCloser; + + +这几个接口。 + +又比如,说明白io.LimitedReader和io.SectionReader这两个类型之间的异同点。 + +再比如,阐述*io.SectionReader类型实现io.ReadSeeker接口的具体方式,等等。不过,这只是合格的门槛,应聘者回答得越全面越好。 + +我在示例文件demo82.go中写了一些代码,以展示上述类型的一些基本用法,供你参考。 + +总结 + +我们今天一直在讨论和梳理io代码包中的程序实体,尤其是那些重要的接口及其实现类型。 + +io包中的接口对于Go语言的标准库和很多第三方库而言,都起着举足轻重的作用。其中最核心的io.Reader接口和io.Writer接口,是很多接口的扩展对象或设计源泉。我们下一节会继续讲解io包中的接口内容。 + +你用过哪些io包中的接口和工具呢,又有哪些收获和感受呢,你可以给我留言,我们一起讨论。感谢你的收听,我们下次再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/41io包中的接口和工具(下).md b/专栏/Go语言核心36讲/41io包中的接口和工具(下).md new file mode 100644 index 0000000..0fd8df3 --- /dev/null +++ b/专栏/Go语言核心36讲/41io包中的接口和工具(下).md @@ -0,0 +1,109 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 41 io包中的接口和工具 (下) + 上一篇文章中,我主要讲到了io.Reader的扩展接口和实现类型。当然,io代码包中的核心接口不止io.Reader一个。 + +我们基于它引出的一条主线,只是io包类型体系中的一部分。我们很有必要再从另一个角度去探索一下,以求对io包有更加全面的了解。 + +下面的一个问题就与此有关。 + +知识扩展 + +问题:io包中的接口都有哪些?它们之间都有着怎样的关系? + +我们可以把没有嵌入其他接口并且只定义了一个方法的接口叫做简单接口。在io包中,这样的接口一共有11个。 + +在它们之中,有的接口有着众多的扩展接口和实现类型,我们可以称之为核心接口。io包中的核心接口只有3个,它们是:io.Reader、io.Writer和io.Closer。 + +我们还可以把io包中的简单接口分为四大类。这四大类接口分别针对于四种操作,即:读取、写入、关闭和读写位置设定。前三种操作属于基本的I/O操作。 + +关于读取操作,我们在前面已经重点讨论过核心接口io.Reader。它在io包中有5个扩展接口,并有6个实现类型。除了它,这个包中针对读取操作的接口还有不少。我们下面就来梳理一下。 + +首先来看io.ByteReader和io.RuneReader这两个简单接口。它们分别定义了一个读取方法,即:ReadByte和ReadRune。 + +但与io.Reader接口中Read方法不同的是,这两个读取方法分别只能够读取下一个单一的字节和Unicode字符。 + +我们之前讲过的数据类型strings.Reader和bytes.Buffer都是io.ByteReader和io.RuneReader的实现类型。 + +不仅如此,这两个类型还都实现了io.ByteScanner接口和io.RuneScanner接口。 + +io.ByteScanner接口内嵌了简单接口io.ByteReader,并定义了额外的UnreadByte方法。如此一来,它就抽象出了一个能够读取和读回退单个字节的功能集。 + +与之类似,io.RuneScanner内嵌了简单接口io.RuneReader,并定义了额外的UnreadRune方法。它抽象的是可以读取和读回退单个Unicode字符的功能集。 + +再来看io.ReaderAt接口。它也是一个简单接口,其中只定义了一个方法ReadAt。与我们在前面说过的读取方法都不同,ReadAt是一个纯粹的只读方法。 + +它只去读取其所属值中包含的字节,而不对这个值进行任何的改动,比如,它绝对不能去修改已读计数的值。这也是io.ReaderAt接口与其实现类型之间最重要的一个约定。 + +因此,如果仅仅并发地调用某一个值的ReadAt方法,那么安全性应该是可以得到保障的。 + +另外,还有一个读取操作相关的接口我们没有介绍过,它就是io.WriterTo。这个接口定义了一个名为WriteTo的方法。 + +千万不要被它的名字迷惑,这个WriteTo方法其实是一个读取方法。它会接受一个io.Writer类型的参数值,并会把其所属值中的数据读出并写入到这个参数值中。 + +与之相对应的是io.ReaderFrom接口。它定义了一个名叫ReadFrom的写入方法。该方法会接受一个io.Reader类型的参数值,并会从该参数值中读出数据,并写入到其所属值中。 + +值得一提的是,我们在前面用到过的io.CopyN函数,在复制数据的时候会先检测其参数src的值,是否实现了io.WriterTo接口。如果是,那么它就直接利用该值的WriteTo方法,把其中的数据拷贝给参数dst代表的值。 + +类似的,这个函数还会检测dst的值是否实现了io.ReaderFrom接口。如果是,那么它就会利用这个值的ReadFrom方法,直接从src那里把数据拷贝进该值。 + +实际上,对于io.Copy函数和io.CopyBuffer函数来说也是如此,因为它们在内部做数据复制的时候用的都是同一套代码。 + +你也看到了,io.ReaderFrom接口与io.WriterTo接口对应得很规整。实际上,在io包中,与写入操作有关的接口都与读取操作的相关接口有着一定的对应关系。下面,我们就来说说写入操作相关的接口。 + +首先当然是核心接口io.Writer。基于它的扩展接口除了有我们已知的io.ReadWriter、io.ReadWriteCloser和io.ReadWriteSeeker之外,还有io.WriteCloser和io.WriteSeeker。 + +我们之前提及的*io.pipe就是io.ReadWriter接口的实现类型。然而,在io包中并没有io.ReadWriteCloser接口的实现,它的实现类型主要集中在net包中。 + +除此之外,写入操作相关的简单接口还有io.ByteWriter和io.WriterAt。可惜,io包中也没有它们的实现类型。不过,有一个数据类型值得在这里提一句,那就是*os.File。 + +这个类型不但是io.WriterAt接口的实现类型,还同时实现了io.ReadWriteCloser接口和io.ReadWriteSeeker接口。也就是说,该类型支持的I/O操作非常的丰富。 + +io.Seeker接口作为一个读写位置设定相关的简单接口,也仅仅定义了一个方法,名叫Seek。 + +我在讲strings.Reader类型的时候还专门说过这个Seek方法,当时还给出了一个与已读计数估算有关的例子。该方法主要用于寻找并设定下一次读取或写入时的起始索引位置。 + +io包中有几个基于io.Seeker的扩展接口,包括前面讲过的io.ReadSeeker和io.ReadWriteSeeker,以及还未曾提过的io.WriteSeeker。io.WriteSeeker是基于io.Writer和io.Seeker的扩展接口。 + +我们之前多次提到的两个指针类型strings.Reader和io.SectionReader都实现了io.Seeker接口。顺便说一句,这两个类型也都是io.ReaderAt接口的实现类型。 + +最后,关闭操作相关的接口io.Closer非常通用,它的扩展接口和实现类型都不少。我们单从名称上就能够一眼看出io包中的哪些接口是它的扩展接口。至于它的实现类型,io包中只有io.PipeReader和io.PipeWriter。 + +总结 + +我们来总结一下这两篇的内容。在Go语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,这也常被叫做接口的组合。而io代码包恰恰就可以作为接口扩展的一个标杆,它可以成为我们运用这种技巧时的一个参考标准。 + +在本文中,我根据接口定义的方法的数量以及是否有接口嵌入,把io包中的接口分为了简单接口和扩展接口。 + +同时,我又根据这些简单接口的扩展接口和实现类型的数量级,把它们分为了核心接口和非核心接口。 + +在io包中,称得上核心接口的简单接口只有3个,即:io.Reader、io.Writer和io.Closer。这些核心接口在Go语言标准库中的实现类型都在200个以上。 + +另外,根据针对的I/O操作的不同,我还把简单接口分为了四大类。这四大类接口针对的操作分别是:读取、写入、关闭和读写位置设定。 + +其中,前三种操作属于基本的I/O操作。基于此,我带你梳理了每个类别的简单接口,并讲解了它们在io包中的扩展接口,以及具有代表性的实现类型。 + + + +( io包中的接口体系) + +除此之外,我还从多个维度为你描述了一些重要程序实体的功用和机理,比如:数据段读取器io.SectionReader、作为同步内存管道核心实现的io.pipe类型,以及用于数据拷贝的io.CopyN函数,等等。 + +我如此详尽且多角度的阐释,正是为了让你能够记牢io代码包中有着网状关系的接口和数据类型。我希望这个目的已经达到了,最起码,本文可以作为你深刻记忆它们的开始。 + +最后再强调一下,io包中的简单接口共有11个。其中,读取操作相关的接口有5个,写入操作相关的接口有4个,而与关闭操作有关的接口只有1个,另外还有一个读写位置设定相关的接口。 + +此外,io包还包含了9个基于这些简单接口的扩展接口。你需要在今后思考和实践的是,你在什么时候应该编写哪些数据类型实现io包中的哪些接口,并以此得到最大的好处。 + +思考题 + +今天的思考题是:io包中的同步内存管道的运作机制是什么? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/42bufio包中的数据类型(上).md b/专栏/Go语言核心36讲/42bufio包中的数据类型(上).md new file mode 100644 index 0000000..47abcc0 --- /dev/null +++ b/专栏/Go语言核心36讲/42bufio包中的数据类型(上).md @@ -0,0 +1,132 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 42 bufio包中的数据类型 (上) + 今天,我们来讲另一个与I/O操作强相关的代码包bufio。bufio是“buffered I/O”的缩写。顾名思义,这个代码包中的程序实体实现的I/O操作都内置了缓冲区。 + +bufio包中的数据类型主要有: + + +Reader; +Scanner; +Writer和ReadWriter。 + + +与io包中的数据类型类似,这些类型的值也都需要在初始化的时候,包装一个或多个简单I/O接口类型的值。(这里的简单I/O接口类型指的就是io包中的那些简单接口。) + +下面,我们将通过一系列问题对bufio.Reader类型和bufio.Writer类型进行讨论(以前者为主)。今天我的问题是:bufio.Reader类型值中的缓冲区起着怎样的作用? + +这道题的典型回答是这样的。 + +bufio.Reader类型的值(以下简称Reader值)内的缓冲区,其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。所谓的底层读取器,就是在初始化此类值的时候传入的io.Reader类型的参数值。 + +Reader值的读取方法一般都会先从其所属值的缓冲区中读取数据。同时,在必要的时候,它们还会预先从底层读取器那里读出一部分数据,并暂存于缓冲区之中以备后用。 + +有这样一个缓冲区的好处是,可以在大多数的时候降低读取方法的执行时间。虽然,读取方法有时还要负责填充缓冲区,但从总体来看,读取方法的平均执行时间一般都会因此有大幅度的缩短。 + +问题解析 + +bufio.Reader类型并不是开箱即用的,因为它包含了一些需要显式初始化的字段。为了让你能在后面更好地理解它的读取方法的内部流程,我先在这里简要地解释一下这些字段,如下所示。 + + +buf:[]byte类型的字段,即字节切片,代表缓冲区。虽然它是切片类型的,但是其长度却会在初始化的时候指定,并在之后保持不变。 +rd:io.Reader类型的字段,代表底层读取器。缓冲区中的数据就是从这里拷贝来的。 +r:int类型的字段,代表对缓冲区进行下一次读取时的开始索引。我们可以称它为已读计数。 +w:int类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。 +err:error类型的字段。它的值用于表示在从底层读取器获得数据时发生的错误。这里的值在被读取或忽略之后,该字段会被置为nil。 +lastByte:int类型的字段,用于记录缓冲区中最后一个被读取的字节。读回退时会用到它的值。 +lastRuneSize:int类型的字段,用于记录缓冲区中最后一个被读取的Unicode字符所占用的字节数。读回退的时候会用到它的值。这个字段只会在其所属值的ReadRune方法中才会被赋予有意义的值。在其他情况下,它都会被置为-1。 + + +bufio包为我们提供了两个用于初始化Reader值的函数,分别叫: + + +NewReader; + +NewReaderSize; + + +它们都会返回一个*bufio.Reader类型的值。 + +NewReader函数初始化的Reader值会拥有一个默认尺寸的缓冲区。这个默认尺寸是4096个字节,即:4 KB。而NewReaderSize函数则将缓冲区尺寸的决定权抛给了使用方。 + +由于这里的缓冲区在一个Reader值的生命周期内其尺寸不可变,所以在有些时候是需要做一些权衡的。NewReaderSize函数就提供了这样一个途径。 + +在bufio.Reader类型拥有的读取方法中,Peek方法和ReadSlice方法都会调用该类型一个名为fill的包级私有方法。fill方法的作用是填充内部缓冲区。我们在这里就先重点说说它。 + +fill方法会先检查其所属值的已读计数。如果这个计数不大于0,那么有两种可能。 + +一种可能是其缓冲区中的字节都是全新的,也就是说它们都没有被读取过,另一种可能是缓冲区刚被压缩过。 + +对缓冲区的压缩包括两个步骤。第一步,把缓冲区中在[已读计数, 已写计数)范围之内的所有元素值(或者说字节)都依次拷贝到缓冲区的头部。 + +比如,把缓冲区中与已读计数代表的索引对应字节拷贝到索引0的位置,并把紧挨在它后边的字节拷贝到索引1的位置,以此类推。 + +这一步之所以不会有任何副作用,是因为它基于两个事实。 + +第一事实,已读计数之前的字节都已经被读取过,并且肯定不会再被读取了,因此把它们覆盖掉是安全的。 + +第二个事实,在压缩缓冲区之后,已写计数之后的字节只可能是已被读取过的字节,或者是已被拷贝到缓冲区头部的未读字节,又或者是代表未曾被填入数据的零值0x00。所以,后续的新字节是可以被写到这些位置上的。 + +在压缩缓冲区的第二步中,fill方法会把已写计数的新值设定为原已写计数与原已读计数的差。这个差所代表的索引,就是压缩后第一次写入字节时的开始索引。 + +另外,该方法还会把已读计数的值置为0。显而易见,在压缩之后,再读取字节就肯定要从缓冲区的头部开始读了。 + + + +(bufio.Reader中的缓冲区压缩) + +实际上,fill方法只要在开始时发现其所属值的已读计数大于0,就会对缓冲区进行一次压缩。之后,如果缓冲区中还有可写的位置,那么该方法就会对其进行填充。 + +在填充缓冲区的时候,fill方法会试图从底层读取器那里,读取足够多的字节,并尽量把从已写计数代表的索引位置到缓冲区末尾之间的空间都填满。 + +在这个过程中,fill方法会及时地更新已写计数,以保证填充的正确性和顺序性。另外,它还会判断从底层读取器读取数据的时候,是否有错误发生。如果有,那么它就会把错误值赋给其所属值的err字段,并终止填充流程。 + +好了,到这里,我们暂告一个段落。在本题中,我对bufio.Reader类型的基本结构,以及相关的一些函数和方法进行了概括介绍,并且重点阐述了该类型的fill方法。 + +后者是我们在后面要说明的一些读取流程的重要组成部分。你起码要记住的是:这个fill方法大致都做了些什么。 + +知识扩展 + +问题1:bufio.Writer类型值中缓冲的数据什么时候会被写到它的底层写入器? + +我们先来看一下bufio.Writer类型都有哪些字段: + + +err:error类型的字段。它的值用于表示在向底层写入器写数据时发生的错误。 +buf:[]byte类型的字段,代表缓冲区。在初始化之后,它的长度会保持不变。 +n:int类型的字段,代表对缓冲区进行下一次写入时的开始索引。我们可以称之为已写计数。 +wr:io.Writer类型的字段,代表底层写入器。 + + +bufio.Writer类型有一个名为Flush的方法,它的主要功能是把相应缓冲区中暂存的所有数据,都写到底层写入器中。数据一旦被写进底层写入器,该方法就会把它们从缓冲区中删除掉。 + +不过,这里的删除有时候只是逻辑上的删除而已。不论是否成功地写入了所有的暂存数据,Flush方法都会妥当处置,并保证不会出现重写和漏写的情况。该类型的字段n在此会起到很重要的作用。 + +bufio.Writer类型值(以下简称Writer值)拥有的所有数据写入方法都会在必要的时候调用它的Flush方法。 + +比如,Write方法有时候会在把数据写进缓冲区之后,调用Flush方法,以便为后续的新数据腾出空间。WriteString方法的行为与之类似。 + +又比如,WriteByte方法和WriteRune方法,都会在发现缓冲区中的可写空间不足以容纳新的字节,或Unicode字符的时候,调用Flush方法。 + +此外,如果Write方法发现需要写入的字节太多,同时缓冲区已空,那么它就会跨过缓冲区,并直接把这些数据写到底层写入器中。 + +而ReadFrom方法,则会在发现底层写入器的类型是io.ReaderFrom接口的实现之后,直接调用其ReadFrom方法把参数值持有的数据写进去。 + +总之,在通常情况下,只要缓冲区中的可写空间无法容纳需要写入的新数据,Flush方法就一定会被调用。并且,bufio.Writer类型的一些方法有时候还会试图走捷径,跨过缓冲区而直接对接数据供需的双方。 + +你可以在理解了这些内部机制之后,有的放矢地编写你的代码。不过,在你把所有的数据都写入Writer值之后,再调用一下它的Flush方法,显然是最稳妥的。 + +总结 + +今天我们从“bufio.Reader类型值中的缓冲区起着怎样的作用”这道问题入手,介绍了一部分bufio包中的数据类型,在下一次的分享中,我会沿着这个问题继续展开。 + +你对今天的内容有什么样的思考,可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/43bufio包中的数据类型(下).md b/专栏/Go语言核心36讲/43bufio包中的数据类型(下).md new file mode 100644 index 0000000..0143597 --- /dev/null +++ b/专栏/Go语言核心36讲/43bufio包中的数据类型(下).md @@ -0,0 +1,130 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 43 bufio包中的数据类型(下) + 你好,我是郝林,我今天继续分享bufio包中的数据类型。 + +在上一篇文章中,我提到了bufio包中的数据类型主要有Reader、Scanner、Writer和ReadWriter。并着重讲到了bufio.Reader类型与bufio.Writer类型,今天,我们继续专注bufio.Reader的内容来进行学习。 + +知识扩展 + +问题 :bufio.Reader类型读取方法有哪些不同? + +bufio.Reader类型拥有很多用于读取数据的指针方法,这里面有4个方法可以作为不同读取流程的代表,它们是:Peek、Read、ReadSlice和ReadBytes。 + +Reader值的Peek方法的功能是:读取并返回其缓冲区中的n个未读字节,并且它会从已读计数代表的索引位置开始读。 + +在缓冲区未被填满,并且其中的未读字节的数量小于n的时候,该方法就会调用fill方法,以启动缓冲区填充流程。但是,如果它发现上次填充缓冲区的时候有错误,那就不会再次填充。 + +如果调用方给定的n比缓冲区的长度还要大,或者缓冲区中未读字节的数量小于n,那么Peek方法就会把“所有未读字节组成的序列”作为第一个结果值返回。 + +同时,它通常还把“bufio.ErrBufferFull变量的值(以下简称缓冲区已满的错误)”- +作为第二个结果值返回,用来表示:虽然缓冲区被压缩和填满了,但是仍然满足不了要求。 + +只有在上述的情况都没有出现时,Peek方法才能返回:“以已读计数为起始的n个字节”和“表示未发生任何错误的nil”。 + +bufio.Reader类型的Peek方法有一个鲜明的特点,那就是:即使它读取了缓冲区中的数据,也不会更改已读计数的值。 + +这个类型的其他读取方法并不是这样。就拿该类型的Read方法来说,它有时会把缓冲区中的未读字节,依次拷贝到其参数p代表的字节切片中,并立即根据实际拷贝的字节数增加已读计数的值。 + + +在缓冲区中还有未读字节的情况下,该方法的做法就是如此。不过,在另一些时候,其所属值的已读计数会等于已写计数,这表明:此时的缓冲区中已经没有任何未读的字节了。 + +当缓冲区中已无未读字节时,Read方法会先检查参数p的长度是否大于或等于缓冲区的长度。如果是,那么Read方法会索性放弃向缓冲区中填充数据,转而直接从其底层读取器中读出数据并拷贝到p中。这意味着它完全跨过了缓冲区,并直连了数据供需的双方。 + + +需要注意的是,Peek方法在遇到类似情况时的做法与这里的区别(这两种做法孰优孰劣还要看具体的使用场景)。 + +Peek方法会在条件满足时填充缓冲区,并在发现参数n的值比缓冲区的长度更大时,直接返回缓冲区中的所有未读字节。 + +如果我们当初设定的缓冲区长度很大,那么在这种情况下的方法执行耗时,就有可能会比较长。最主要的原因是填充缓冲区需要花费较长的时间。 + +由fill方法执行的流程可知,它会尽量填满缓冲区中的可写空间。然而,Read方法在大多数的情况下,是不会向缓冲区中写入数据的,尤其是在前面描述的那种情况下,即:缓冲区中已无未读字节,且参数p的长度大于或等于缓冲区的长度。 + +此时,该方法会直接从底层读取器那里读出数据,所以数据的读出速度就成为了这种情况下方法执行耗时的决定性因素。 + +当然了,我在这里说的只是耗时操作在某些情况下更可能出现在哪里,一切的结论还是要以性能测试的客观结果为准。 + +说回Read方法的内部流程。如果缓冲区中已无未读字节,但其长度比参数p的长度更大,那么该方法会先把已读计数和已写计数的值都重置为0,然后再尝试着使用从底层读取器那里获取的数据,对缓冲区进行一次从头至尾的填充。 + +不过要注意,这里的尝试只会进行一次。无论在这一时刻是否能够获取到数据,也无论获取时是否有错误发生,都会是如此。而fill方法的做法与此不同,只要没有发生错误,它就会进行多次尝试,因此它真正获取到一些数据的可能性更大。 + +不过,这两个方法有一点是相同,那就是:只要它们把获取到的数据写入缓冲区,就会及时地更新已写计数的值。 + +再来说ReadSlice方法和ReadBytes方法。 这两个方法的功能总体上来说,都是持续地读取数据,直至遇到调用方给定的分隔符为止。 + +ReadSlice方法会先在其缓冲区的未读部分中寻找分隔符。如果未能找到,并且缓冲区未满,那么该方法会先通过调用fill方法对缓冲区进行填充,然后再次寻找,如此往复。 + +如果在填充的过程中发生了错误,那么它会把缓冲区中的未读部分作为结果返回,同时返回相应的错误值。 + +注意,在这个过程中有可能会出现虽然缓冲区已被填满,但仍然没能找到分隔符的情况。 + +这时,ReadSlice方法会把整个缓冲区(也就是buf字段代表的字节切片)作为第一个结果值,并把缓冲区已满的错误(即bufio.ErrBufferFull变量的值)作为第二个结果值。 + +经过fill方法填满的缓冲区肯定从头至尾都只包含了未读的字节,所以这样做是合理的。 + +当然了,一旦ReadSlice方法找到了分隔符,它就会在缓冲区上切出相应的、包含分隔符的字节切片,并把该切片作为结果值返回。无论分隔符找到与否,该方法都会正确地设置已读计数的值。 + +比如,在返回缓冲区中的所有未读字节,或者代表全部缓冲区的字节切片之前,它会把已写计数的值赋给已读计数,以表明缓冲区中已无未读字节。 + +如果说ReadSlice是一个容易半途而废的方法的话,那么可以说ReadBytes方法算得上是相当的执着。 + +ReadBytes方法会通过调用ReadSlice方法一次又一次地从缓冲区中读取数据,直至找到分隔符为止。 + +在这个过程中,ReadSlice方法可能会因缓冲区已满而返回所有已读到的字节和相应的错误值,但ReadBytes方法总是会忽略掉这样的错误,并再次调用ReadSlice方法,这使得后者会继续填充缓冲区并在其中寻找分隔符。 + +除非ReadSlice方法返回的错误值并不代表缓冲区已满的错误,或者它找到了分隔符,否则这一过程永远不会结束。 + +如果寻找的过程结束了,不管是不是因为找到了分隔符,ReadBytes方法都会把在这个过程中读到的所有字节,按照读取的先后顺序组装成一个字节切片,并把它作为第一个结果值。如果过程结束是因为出现错误,那么它还会把拿到的错误值作为第二个结果值。 + +在bufio.Reader类型的众多读取方法中,依赖ReadSlice方法的除了ReadBytes方法,还有ReadLine方法。不过后者在读取流程上并没有什么特别之处,我就不在这里赘述了。 + +另外,该类型的ReadString方法完全依赖于ReadBytes方法,前者只是在后者返回的结果值之上做了一个简单的类型转换而已。 + +最后,我还要提醒你一下,有个安全性方面的问题需要你注意。bufio.Reader类型的Peek方法、ReadSlice方法和ReadLine方法都有可能会造成内容泄露。 + +这主要是因为它们在正常的情况下都会返回直接基于缓冲区的字节切片。我在讲bytes.Buffer类型的时候解释过什么叫内容泄露。你可以返回查看。 + +调用方可以通过这些方法返回的结果值访问到缓冲区的其他部分,甚至修改缓冲区中的内容。这通常都是很危险的。 + +总结 + +我们用比较长的篇幅介绍了bufio包中的数据类型,其中的重点是bufio.Reader类型。 + +bufio.Reader类型代表的是携带缓冲区的读取器。它的值在被初始化的时候需要接受一个底层的读取器,后者的类型必须是io.Reader接口的实现。 + +Reader值中的缓冲区其实就是一个数据存储中介,它介于底层读取器与读取方法及其调用方之间。此类值的读取方法一般都会先从该值的缓冲区中读取数据,同时在必要的时候预先从其底层读取器那里读出一部分数据,并填充到缓冲区中以备后用。填充缓冲区的操作通常会由该值的fill方法执行。在填充的过程中,fill方法有时还会对缓冲区进行压缩。 + +在Reader值拥有的众多读取方法中,有4个方法可以作为不同读取流程的代表,它们是:Peek、Read、ReadSlice和ReadBytes。 + +Peek方法的特点是即使读取了缓冲区中的数据,也不会更改已读计数的值。而Read方法会在参数值的长度过大,且缓冲区中已无未读字节时,跨过缓冲区并直接向底层读取器索要数据。 + +ReadSlice方法会在缓冲区的未读部分中寻找给定的分隔符,并在必要时对缓冲区进行填充。 + +如果在填满缓冲区之后仍然未能找到分隔符,那么该方法就会把整个缓冲区作为第一个结果值返回,同时返回缓冲区已满的错误。 + +ReadBytes方法会通过调用ReadSlice方法,一次又一次地填充缓冲区,并在其中寻找分隔符。除非发生了未预料到的错误或者找到了分隔符,否则这一过程将会一直进行下去。 + +Reader值的ReadLine方法会依赖于它的ReadSlice方法,而其ReadString方法则完全依赖于ReadBytes方法。 + +另外,值得我们特别注意的是,Reader值的Peek方法、ReadSlice方法和ReadLine方法都可能会造成其缓冲区中的内容的泄露。 + +最后再说一下bufio.Writer类型。把该类值的缓冲区中暂存的数据写进其底层写入器的功能,主要是由它的Flush方法实现的。 + +此类值的所有数据写入方法都会在必要的时候调用它的Flush方法。一般情况下,这些写入方法都会先把数据写进其所属值的缓冲区,然后再增加该值中的已写计数。但是,在有些时候,Write方法和ReadFrom方法也会跨过缓冲区,并直接把数据写进其底层写入器。 + +请记住,虽然这些写入方法都会不时地调用Flush方法,但是在写入所有的数据之后再显式地调用一下这个方法总是最稳妥的。 + +思考题 + +今天的思考题是:bufio.Scanner类型的主要功用是什么?它有哪些特点? + +感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/44使用os包中的API(上).md b/专栏/Go语言核心36讲/44使用os包中的API(上).md new file mode 100644 index 0000000..cab484c --- /dev/null +++ b/专栏/Go语言核心36讲/44使用os包中的API(上).md @@ -0,0 +1,132 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 44 使用os包中的API (上) + 我们今天要讲的是os代码包中的API。这个代码包可以让我们拥有操控计算机操作系统的能力。 + +前导内容:os包中的API + +这个代码包提供的都是平台不相关的API。那么说,什么叫平台不相关的API呢? + +它的意思是:这些API基于(或者说抽象自)操作系统,为我们使用操作系统的功能提供高层次的支持,但是,它们并不依赖于具体的操作系统。 + +不论是Linux、macOS、Windows,还是FreeBSD、OpenBSD、Plan9,os代码包都可以为之提供统一的使用接口。这使得我们可以用同样的方式,来操纵不同的操作系统,并得到相似的结果。 + +os包中的API主要可以帮助我们使用操作系统中的文件系统、权限系统、环境变量、系统进程以及系统信号。 + +其中,操纵文件系统的API最为丰富。我们不但可以利用这些API创建和删除文件以及目录,还可以获取到它们的各种信息、修改它们的内容、改变它们的访问权限,等等。 + +说到这里,就不得不提及一个非常常用的数据类型:os.File。 + +从字面上来看,os.File类型代表了操作系统中的文件。但实际上,它可以代表的远不止于此。或许你已经知道,对于类Unix的操作系统(包括Linux、macOS、FreeBSD等),其中的一切都可以被看做是文件。 + +除了文本文件、二进制文件、压缩文件、目录这些常见的形式之外,还有符号链接、各种物理设备(包括内置或外接的面向块或者字符的设备)、命名管道,以及套接字(也就是socket),等等。 + +因此,可以说,我们能够利用os.File类型操纵的东西太多了。不过,为了聚焦于os.File本身,同时也为了让本文讲述的内容更加通用,我们在这里主要把os.File类型应用于常规的文件。 + +下面这个问题,就是以os.File类型代表的最基本内容入手。我们今天的问题是:os.File类型都实现了哪些io包中的接口? + +这道题的典型回答是这样的。 + +os.File类型拥有的都是指针方法,所以除了空接口之外,它本身没有实现任何接口。而它的指针类型则实现了很多io代码包中的接口。 + +首先,对于io包中最核心的3个简单接口io.Reader、io.Writer和io.Closer,*os.File类型都实现了它们。 + +其次,该类型还实现了另外的3个简单接口,即:io.ReaderAt、io.Seeker和io.WriterAt。 + +正是因为*os.File类型实现了这些简单接口,所以它也顺便实现了io包的9个扩展接口中的7个。 + +然而,由于它并没有实现简单接口io.ByteReader和io.RuneReader,所以它没有实现分别作为这两者的扩展接口的io.ByteScanner和io.RuneScanner。 + +总之,os.File类型及其指针类型的值,不但可以通过各种方式读取和写入某个文件中的内容,还可以寻找并设定下一次读取或写入时的起始索引位置,另外还可以随时对文件进行关闭。 + +但是,它们并不能专门地读取文件中的下一个字节,或者下一个Unicode字符,也不能进行任何的读回退操作。 + +不过,单独读取下一个字节或字符的功能也可以通过其他方式来实现,比如,调用它的Read方法并传入适当的参数值就可以做到这一点。 + +问题解析 + +这个问题其实在间接地问“os.File类型能够以何种方式操作文件?”我在前面的典型回答中也给出了简要的答案。 + +在我进一步地说明一些细节之前,我们先来看看,怎样才能获得一个os.File类型的指针值(以下简称File值)。 + +在os包中,有这样几个函数,即:Create、NewFile、Open和OpenFile。 + +os.Create函数用于根据给定的路径创建一个新的文件。 它会返回一个File值和一个错误值。我们可以在该函数返回的File值之上,对相应的文件进行读操作和写操作。 + +不但如此,我们使用这个函数创建的文件,对于操作系统中的所有用户来说,都是可以读和写的。 + +换句话说,一旦这样的文件被创建出来,任何能够登录其所属的操作系统的用户,都可以在任意时刻读取该文件中的内容,或者向该文件写入内容。 + +注意,如果在我们给予os.Create函数的路径之上,已经存在了一个文件,那么该函数会先清空现有文件中的全部内容,然后再把它作为第一个结果值返回。 + +另外,os.Create函数是有可能返回非nil的错误值的。 + +比如,如果我们给定的路径上的某一级父目录并不存在,那么该函数就会返回一个*os.PathError类型的错误值,以表示“不存在的文件或目录”。 + +再来看os.NewFile函数。 该函数在被调用的时候,需要接受一个代表文件描述符的、uintptr类型的值,以及一个用于表示文件名的字符串值。 + +如果我们给定的文件描述符并不是有效的,那么这个函数将会返回nil,否则,它将会返回一个代表了相应文件的File值。 + +注意,不要被这个函数的名称误导了,它的功能并不是创建一个新的文件,而是依据一个已经存在的文件的描述符,来新建一个包装了该文件的File值。 + +例如,我们可以像这样拿到一个包装了标准错误输出的File值: + +file3 := os.NewFile(uintptr(syscall.Stderr), "/dev/stderr") + + +然后,通过这个File值向标准错误输出上写入一些内容: + +if file3 != nil { + defer file3.Close() + file3.WriteString( + "The Go language program writes the contents into stderr.\n") +} + + +os.Open函数会打开一个文件并返回包装了该文件的File值。 然而,该函数只能以只读模式打开文件。换句话说,我们只能从该函数返回的File值中读取内容,而不能向它写入任何内容。 + +如果我们调用了这个File值的任何一个写入方法,那么都将会得到一个表示了“坏的文件描述符”的错误值。实际上,我们刚刚说的只读模式,正是应用在File值所持有的文件描述符之上的。 + +所谓的文件描述符,是由通常很小的非负整数代表的。它一般会由I/O相关的系统调用返回,并作为某个文件的一个标识存在。 + +从操作系统的层面看,针对任何文件的I/O操作都需要用到这个文件描述符。只不过,Go语言中的一些数据类型,为我们隐匿掉了这个描述符,如此一来我们就无需时刻关注和辨别它了(就像os.File类型这样)。 + +实际上,我们在调用前文所述的os.Create函数、os.Open函数以及将会提到的os.OpenFile函数的时候,它们都会执行同一个系统调用,并且在成功之后得到这样一个文件描述符。这个文件描述符将会被储存在它们返回的File值中。 + +os.File类型有一个指针方法,名叫Fd。它在被调用之后将会返回一个uintptr类型的值。这个值就代表了当前的File值所持有的那个文件描述符。 + +不过,在os包中,除了NewFile函数需要用到它,它也没有什么别的用武之地了。所以,如果你操作的只是常规的文件或者目录,那么就无需特别地在意它了。 + +最后,再说一下os.OpenFile函数。 这个函数其实是os.Create函数和os.Open函数的底层支持,它最为灵活。 + +这个函数有3个参数,分别名为name、flag和perm。其中的name指代的就是文件的路径。而flag参数指的则是需要施加在文件描述符之上的模式,我在前面提到的只读模式就是这里的一个可选项。 + +在Go语言中,这个只读模式由常量os.O_RDONLY代表,它是int类型的。当然了,这里除了只读模式之外,还有几个别的模式可选,我们稍后再细说。 + +os.OpenFile函数的参数perm代表的也是模式,它的类型是os.FileMode,此类型是一个基于uint32类型的再定义类型。 + +为了加以区别,我们把参数flag指代的模式叫做操作模式,而把参数perm指代的模式叫做权限模式。可以这么说,操作模式限定了操作文件的方式,而权限模式则可以控制文件的访问权限。关于权限模式的更多细节我们将在后面讨论。 + +- +(获得os.File类型的指针值的几种方式) + +到这里,你需要记住的是,通过os.File类型的值,我们不但可以对文件进行读取、写入、关闭等操作,还可以设定下一次读取或写入时的起始索引位置。 + +此外,os包中还有用于创建全新文件的Create函数,用于包装现存文件的NewFile函数,以及可被用来打开已存在的文件的Open函数和OpenFile函数。 + +总结 + +我们今天讲的是os代码包以及其中的程序实体。我们首先讨论了os包存在的意义,和它的主要用途。代码包中所包含的API,都是对操作系统的某方面功能的高层次抽象,这使得我们可以通过它以统一的方式,操纵不同的操作系统,并得到相似的结果。 + +在这个代码包中,操纵文件系统的API最为丰富,最有代表性的就是数据类型os.File。os.File类型不但可以代表操作系统中的文件,还可以代表很多其他的东西。尤其是在类Unix的操作系统中,它几乎可以代表一切可以操纵的软件和硬件。 + +在下一期的文章中,我会继续讲解os包中的API的内容。如果你对这部分的知识有什么问题,可以给我留言,感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/45使用os包中的API(下).md b/专栏/Go语言核心36讲/45使用os包中的API(下).md new file mode 100644 index 0000000..6dcb430 --- /dev/null +++ b/专栏/Go语言核心36讲/45使用os包中的API(下).md @@ -0,0 +1,112 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 45 使用os包中的API (下) + 你好,我是郝林,今天我们继续分享使用os包中的API。 + +我们在上一篇文章中。从“os.File类型都实现了哪些io包中的接口”这一问题出发,介绍了一系列的相关内容。今天我们继续围绕这一知识点进行扩展。 + +知识扩展 + +问题1:可应用于File值的操作模式都有哪些? + +针对File值的操作模式主要有只读模式、只写模式和读写模式。 + +这些模式分别由常量os.O_RDONLY、os.O_WRONLY和os.O_RDWR代表。在我们新建或打开一个文件的时候,必须把这三个模式中的一个设定为此文件的操作模式。 + +除此之外,我们还可以为这里的文件设置额外的操作模式,可选项如下所示。 + + +os.O_APPEND:当向文件中写入内容时,把新内容追加到现有内容的后边。 +os.O_CREATE:当给定路径上的文件不存在时,创建一个新文件。 +os.O_EXCL:需要与os.O_CREATE一同使用,表示在给定的路径上不能有已存在的文件。 +os.O_SYNC:在打开的文件之上实施同步I/O。它会保证读写的内容总会与硬盘上的数据保持同步。 +os.O_TRUNC:如果文件已存在,并且是常规的文件,那么就先清空其中已经存在的任何内容。 + + +对于以上操作模式的使用,os.Create函数和os.Open函数都是现成的例子。 + +func Create(name string) (*File, error) { + return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666) +} + + +os.Create函数在调用os.OpenFile函数的时候,给予的操作模式是os.O_RDWR、os.O_CREATE和os.O_TRUNC的组合。 + +这就基本上决定了前者的行为,即:如果参数name代表路径之上的文件不存在,那么就新建一个,否则,先清空现存文件中的全部内容。 + +并且,它返回的File值的读取方法和写入方法都是可用的。这里需要注意,多个操作模式是通过按位或操作符|组合起来的。 + +func Open(name string) (*File, error) {- +return OpenFile(name, O_RDONLY, 0)- +} + +我在前面说过,os.Open函数的功能是:以只读模式打开已经存在的文件。其根源就是它在调用os.OpenFile函数的时候,只提供了一个单一的操作模式os.O_RDONLY。 + +以上,就是我对可应用于File值的操作模式的简单解释。在demo88.go文件中还有少许示例,可供你参考。 + +问题2:怎样设定常规文件的访问权限? + +我们已经知道,os.OpenFile函数的第三个参数perm代表的是权限模式,其类型是os.FileMode。但实际上,os.FileMode类型能够代表的,可远不只权限模式,它还可以代表文件模式(也可以称之为文件种类)。 + +由于os.FileMode是基于uint32类型的再定义类型,所以它的每个值都包含了32个比特位。在这32个比特位当中,每个比特位都有其特定的含义。 + +比如,如果在其最高比特位上的二进制数是1,那么该值表示的文件模式就等同于os.ModeDir,也就是说,相应的文件代表的是一个目录。 + +又比如,如果其中的第26个比特位上的是1,那么相应的值表示的文件模式就等同于os.ModeNamedPipe,也就是说,那个文件代表的是一个命名管道。 + +实际上,在一个os.FileMode类型的值(以下简称FileMode值)中,只有最低的9个比特位才用于表示文件的权限。当我们拿到一个此类型的值时,可以把它和os.ModePerm常量的值做按位与操作。 + +这个常量的值是0777,是一个八进制的无符号整数,其最低的9个比特位上都是1,而更高的23个比特位上都是0。 + +所以,经过这样的按位与操作之后,我们即可得到这个FileMode值中所有用于表示文件权限的比特位,也就是该值所表示的权限模式。这将会与我们调用FileMode值的Perm方法所得到的结果值是一致。 + +在这9个用于表示文件权限的比特位中,每3个比特位为一组,共可分为3组。 + +从高到低,这3组分别表示的是文件所有者(也就是创建这个文件的那个用户)、文件所有者所属的用户组,以及其他用户对该文件的访问权限。而对于每个组,其中的3个比特位从高到低分别表示读权限、写权限和执行权限。 + +如果在其中的某个比特位上的是1,那么就意味着相应的权限开启,否则,就表示相应的权限关闭。 + +因此,八进制整数0777就表示:操作系统中的所有用户都对当前的文件有读、写和执行的权限,而八进制整数0666则表示:所有用户都对当前文件有读和写的权限,但都没有执行的权限。 + +我们在调用os.OpenFile函数的时候,可以根据以上说明设置它的第三个参数。但要注意,只有在新建文件的时候,这里的第三个参数值才是有效的。在其他情况下,即使我们设置了此参数,也不会对目标文件产生任何的影响。 + +总结 + +为了聚焦于os.File类型本身,我在这两篇文章中主要讲述了怎样把os.File类型应用于常规的文件。该类型的指针类型实现了很多io包中的接口,因此它的具体功用也就可以不言自明了。 + +通过该类型的值,我们不但可以对文件进行各种读取、写入、关闭等操作,还可以设定下一次读取或写入时的起始索引位置。 + +在使用这个类型的值之前,我们必须先要创建它。所以,我为你重点介绍了几个可以创建,并获得此类型值的函数。 + +包括:os.Create、os.NewFile、os.Open和os.OpenFile。我们用什么样的方式创建File值,就决定了我们可以使用它来做什么。 + +利用os.Create函数,我们可以在操作系统中创建一个全新的文件,或者清空一个现存文件中的全部内容并重用它。 + +在相应的File值之上,我们可以对该文件进行任何的读写操作。虽然os.NewFile函数并不是被用来创建新文件的,但是它能够基于一个有效的文件描述符包装出一个可用的File值。 + +os.Open函数的功能是打开一个已经存在的文件。但是,我们只能通过它返回的File值对相应的文件进行读操作。 + +os.OpenFile是这些函数中最为灵活的一个,通过它,我们可以设定被打开文件的操作模式和权限模式。实际上,os.Create函数和os.Open函数都只是对它的简单封装而已。 + +在使用os.OpenFile函数的时候,我们必须要搞清楚操作模式和权限模式所代表的真正含义,以及设定它们的正确方式。 + +我在本文的扩展问题中分别对它们进行了较为详细的解释。同时,我在对应的示例文件中也编写了一些代码。 + +你需要认真地阅读和理解这些代码,并在运行它们的过程当中悟出这两种模式的真谛。 + +我在本文中讲述的东西对于os包来说,只是海面上的那部分冰山而已。这个代码包囊括的知识众多,而且延展性都很强。 + +如果你想完全理解它们,可能还需要去参看操作系统等方面的文档和教程。由于篇幅原因,我在这里只是做了一个引导,帮助你初识该包中的一些重要的程序实体,并给予你一个可以深入下去的切入点,希望你已经在路上了。 + +思考题 + +今天的思考题是:怎样通过os包中的API创建和操纵一个系统进程? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/46访问网络服务.md b/专栏/Go语言核心36讲/46访问网络服务.md new file mode 100644 index 0000000..7728631 --- /dev/null +++ b/专栏/Go语言核心36讲/46访问网络服务.md @@ -0,0 +1,178 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 46 访问网络服务 + 你真的很棒,已经跟着我一起从最开始初识Go语言,一步一步地走到了这里。 + +在这之前的几十篇文章中,我向你一点一点地介绍了很多Go语言的核心知识,以及一些最最基础的标准库代码包。我想,你已经完全有能力独立去做一些事情了。 + +为了激发你更多的兴趣,我还打算用几篇文章来说说Go语言的网络编程。不过,关于网络编程这个事情,恐怕早已庞大到用一两本专著都无法对它进行完整论述的地步了。 + +所以,我在这里说的东西只能算是个引子。只要这样能让你产生想去尝试的冲动,我就很开心了。 + +前导内容:socket与IPC + +人们常常会使用Go语言去编写网络程序(当然了,这方面也是Go语言最为擅长的事情)。说到网络编程,我们就不得不提及socket。 + +socket,常被翻译为套接字,它应该算是网络编程世界中最为核心的知识之一了。关于socket,我们可以讨论的东西太多了,因此,我在这里只围绕着Go语言向你介绍一些关于它的基础知识。 + +所谓socket,是一种IPC方法。IPC是Inter-Process Communication的缩写,可以被翻译为进程间通信。顾名思义,IPC这个概念(或者说规范)主要定义的是多个进程之间,相互通信的方法。 + +这些方法主要包括:系统信号(signal)、管道(pipe)、套接字 (socket)、文件锁(file lock)、消息队列(message queue)、信号灯(semaphore,有的地方也称之为信号量)等。现存的主流操作系统大都对IPC提供了强有力的支持,尤其是socket。 + +你可能已经知道,Go语言对IPC也提供了一定的支持。 + +比如,在os代码包和os/signal代码包中就有针对系统信号的API。 + +又比如,os.Pipe函数可以创建命名管道,而os/exec代码包则对另一类管道(匿名管道)提供了支持。对于socket,Go语言与之相应的程序实体都在其标准库的net代码包中。 + +毫不夸张地说,在众多的IPC方法中,socket是最为通用和灵活的一种。与其他的IPC方法不同,利用socket进行通信的进程,可以不局限在同一台计算机当中。 + +实际上,通信的双方无论存在于世界上的哪个角落,只要能够通过计算机的网卡端口以及网络进行互联,就可以使用socket。 + +支持socket的操作系统一般都会对外提供一套API。跑在它们之上的应用程序利用这套API,就可以与互联网上的另一台计算机中的程序、同一台计算机中的其他程序,甚至同一个程序中的其他线程进行通信。 + +例如,在Linux操作系统中,用于创建socket实例的API,就是由一个名为socket的系统调用代表的。这个系统调用是Linux内核的一部分。 + + +所谓的系统调用,你可以理解为特殊的C语言函数。它们是连接应用程序和操作系统内核的桥梁,也是应用程序使用操作系统功能的唯一渠道。 + + +在Go语言标准库的syscall代码包中,有一个与这个socket系统调用相对应的函数。这两者的函数签名是基本一致的,它们都会接受三个int类型的参数,并会返回一个可以代表文件描述符的结果。 + +但不同的是,syscall包中的Socket函数本身是平台不相关的。在其底层,Go语言为它支持的每个操作系统都做了适配,这才使得这个函数无论在哪个平台上,总是有效的。 + +Go语言的net代码包中的很多程序实体,都会直接或间接地使用到syscall.Socket函数。 + +比如,我们在调用net.Dial函数的时候,会为它的两个参数设定值。其中的第一个参数名为network,它决定着Go程序在底层会创建什么样的socket实例,并使用什么样的协议与其他程序通信。 + +下面,我们就通过一个简单的问题来看看怎样正确地调用net.Dial函数。 + +今天的问题是:net.Dial函数的第一个参数network有哪些可选值? + +这道题的典型回答是这样的。 + +net.Dial函数会接受两个参数,分别名为network和address,都是string类型的。 + +参数network常用的可选值一共有9个。这些值分别代表了程序底层创建的socket实例可使用的不同通信协议,罗列如下。 + + +"tcp":代表TCP协议,其基于的IP协议的版本根据参数address的值自适应。 +"tcp4":代表基于IP协议第四版的TCP协议。 +"tcp6":代表基于IP协议第六版的TCP协议。 +"udp":代表UDP协议,其基于的IP协议的版本根据参数address的值自适应。 +"udp4":代表基于IP协议第四版的UDP协议。 +"udp6":代表基于IP协议第六版的UDP协议。 +"unix":代表Unix通信域下的一种内部socket协议,以SOCK_STREAM为socket类型。 +"unixgram":代表Unix通信域下的一种内部socket协议,以SOCK_DGRAM为socket类型。 +"unixpacket":代表Unix通信域下的一种内部socket协议,以SOCK_SEQPACKET为socket类型。 + + +问题解析 + +为了更好地理解这些可选值的深层含义,我们需要了解一下syscall.Socket函数接受的那三个参数。 + +我在前面说了,这个函数接受的三个参数都是int类型的。这些参数所代表的分别是想要创建的socket实例通信域、类型以及使用的协议。 + +Socket的通信域主要有这样几个可选项:IPv4域、IPv6域和Unix域。 + +我想你应该能够猜出IPv4域、IPv6域的含义,它们对应的分别是基于IP协议第四版的网络,和基于IP协议第六版的网络。 + +现在的计算机网络大都是基于IP协议第四版的,但是由于现有IP地址的逐渐枯竭,网络世界也在逐步地支持IP协议第六版。 + +Unix域,指的是一种类Unix操作系统中特有的通信域。在装有此类操作系统的同一台计算机中,应用程序可以基于此域建立socket连接。 + +以上三种通信域分别可以由syscall代码包中的常量AF_INET、AF_INET6和AF_UNIX表示。 + +Socket的类型一共有4种,分别是:SOCK_DGRAM、SOCK_STREAM、SOCK_SEQPACKET以及SOCK_RAW。syscall代码包中也都有同名的常量与之对应。前两者更加常用一些。 + +SOCK_DGRAM中的“DGRAM”代表的是datagram,即数据报文。它是一种有消息边界,但没有逻辑连接的非可靠socket类型,我们熟知的基于UDP协议的网络通信就属于此类。 + +有消息边界的意思是,与socket相关的操作系统内核中的程序(以下简称内核程序)在发送或接收数据的时候是以消息为单位的。 + +你可以把消息理解为带有固定边界的一段数据。内核程序可以自动地识别和维护这种边界,并在必要的时候,把数据切割成一个一个的消息,或者把多个消息串接成连续的数据。如此一来,应用程序只需要面向消息进行处理就可以了。 + +所谓的有逻辑连接是指,通信双方在收发数据之前必须先建立网络连接。待连接建立好之后,双方就可以一对一地进行数据传输了。显然,基于UDP协议的网络通信并不需要这样,它是没有逻辑连接的。 + +只要应用程序指定好对方的网络地址,内核程序就可以立即把数据报文发送出去。这有优势,也有劣势。 + +优势是发送速度快,不长期占用网络资源,并且每次发送都可以指定不同的网络地址。 + +当然了,最后一个优势有时候也是劣势,因为这会使数据报文更长一些。其他的劣势有,无法保证传输的可靠性,不能实现数据的有序性,以及数据只能单向进行传输。 + +而SOCK_STREAM这个socket类型,恰恰与SOCK_DGRAM相反。它没有消息边界,但有逻辑连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输。众所周知的基于TCP协议的网络通信就属于此类。 + + +这样的网络通信传输数据的形式是字节流,而不是数据报文。字节流是以字节为单位的。内核程序无法感知一段字节流中包含了多少个消息,以及这些消息是否完整,这完全需要应用程序自己去把控。 + +不过,此类网络通信中的一端,总是会忠实地按照另一端发送数据时的字节排列顺序,接收和缓存它们。所以,应用程序需要根据双方的约定去数据中查找消息边界,并按照边界切割数据,仅此而已。 + + +syscall.Socket函数的第三个参数用于表示socket实例所使用的协议。 + +通常,只要明确指定了前两个参数的值,我们就无需再去确定第三个参数值了,一般把它置为0就可以了。这时,内核程序会自行选择最合适的协议。 + +比如,当前两个参数值分别为syscall.AF_INET和syscall.SOCK_DGRAM的时候,内核程序会选择UDP作为协议。 + +又比如,在前两个参数值分别为syscall.AF_INET6和syscall.SOCK_STREAM时,内核程序可能会选择TCP作为协议。 + +- +(syscall.Socket函数一瞥) + +不过,你也看到了,在使用net包中的高层次API的时候,我们连那前两个参数值都无需给定,只需要把前面罗列的那些字符串字面量的其中一个,作为network参数的值就好了。 + +当然,如果你在使用这些API的时候,能够想到我在上面说的这些基础知识的话,那么一定会对你做出正确的判断和选择有所帮助。 + +知识扩展 + +问题1:调用net.DialTimeout函数时给定的超时时间意味着什么? + +简单来说,这里的超时时间,代表着函数为网络连接建立完成而等待的最长时间。这是一个相对的时间。它会由这个函数的参数timeout的值表示。 + +开始的时间点几乎是我们调用net.DialTimeout函数的那一刻。在这之后,时间会主要花费在“解析参数network和address的值”,以及“创建socket实例并建立网络连接”这两件事情上。 + +不论执行到哪一步,只要在绝对的超时时间达到的那一刻,网络连接还没有建立完成,该函数就会返回一个代表了I/O操作超时的错误值。 + +值得注意的是,在解析address的值的时候,函数会确定网络服务的IP地址、端口号等必要信息,并在需要时访问DNS服务。 + +另外,如果解析出的IP地址有多个,那么函数会串行或并发地尝试建立连接。但无论用什么样的方式尝试,函数总会以最先建立成功的那个连接为准。 + +同时,它还会根据超时前的剩余时间,去设定针对每次连接尝试的超时时间,以便让它们都有适当的时间执行。 + +再多说一点。在net包中还有一个名为Dialer的结构体类型。该类型有一个名叫Timeout的字段,它与上述的timeout参数的含义是完全一致的。实际上,net.DialTimeout函数正是利用了这个类型的值才得以实现功能的。 + +net.Dialer类型值得你好好学习一下,尤其是它的每个字段的功用以及它的DialContext方法。 + +总结 + +我们今天提及了使用Go语言进行网络编程这个主题。作为引子,我先向你介绍了关于socket的一些基础知识。socket常被翻译为套接字,它是一种IPC方法。IPC可以被翻译为进程间通信,它主要定义了多个进程之间相互通信的方法。 + +Socket是IPC方法中最为通用和灵活的一种。与其他的方法不同,利用socket进行通信的进程可以不局限在同一台计算机当中。 + +只要通信的双方能够通过计算机的网卡端口,以及网络进行互联就可以使用socket,无论它们存在于世界上的哪个角落。 + +支持socket的操作系统一般都会对外提供一套API。Go语言的syscall代码包中也有与之对应的程序实体。其中最重要的一个就是syscall.Socket函数。 + +不过,syscall包中的这些程序实体,对于普通的Go程序来说都属于底层的东西了,我们通常很少会用到。一般情况下,我们都会使用net代码包及其子包中的API去编写网络程序。 + +net包中一个很常用的函数,名为Dial。这个函数主要用于连接网络服务。它会接受两个参数,你需要搞明白这两个参数的值都应该怎么去设定。 + +尤其是network参数,它有很多的可选值,其中最常用的有9个。这些可选值的背后都代表着相应的socket属性,包括通信域、类型以及使用的协议。一旦你理解了这些socket属性,就一定会帮助你做出正确的判断和选择。 + +与此相关的一个函数是net.DialTimeout。我们在调用它的时候需要设定一个超时时间。这个超时时间的含义你是需要搞清楚的。 + +通过它,我们可以牵扯出这个函数的一大堆实现细节。另外,还有一个叫做net.Dialer的结构体类型。这个类型其实是前述两个函数的底层实现,值得你好好地学习一番。 + +以上,就是我今天讲的主要内容,它们都是关于怎样访问网络服务的。你可以从这里入手,进入Go语言的网络编程世界。 + +思考题 + +今天的思考题也与超时时间有关。在你调用了net.Dial等函数之后,如果成功就会得到一个代表了网络连接的net.Conn接口类型的值。我的问题是:怎样在net.Conn类型的值上正确地设定针对读操作和写操作的超时时间? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/47基于HTTP协议的网络服务.md b/专栏/Go语言核心36讲/47基于HTTP协议的网络服务.md new file mode 100644 index 0000000..a8ac69d --- /dev/null +++ b/专栏/Go语言核心36讲/47基于HTTP协议的网络服务.md @@ -0,0 +1,182 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 47 基于HTTP协议的网络服务 + 我们在上一篇文章中简单地讨论了网络编程和socket,并由此提及了Go语言标准库中的syscall代码包和net代码包。 + +我还重点讲述了net.Dial函数和syscall.Socket函数的参数含义。前者间接地调用了后者,所以正确理解后者,会对用好前者有很大裨益。 + +之后,我们把视线转移到了net.DialTimeout函数以及它对操作超时的处理上,这又涉及了net.Dialer类型。实际上,这个类型正是net包中这两个“拨号”函数的底层实现。 + +我们像上一篇文章的示例代码那样用net.Dial或net.DialTimeout函数来访问基于HTTP协议的网络服务是完全没有问题的。HTTP协议是基于TCP/IP协议栈的,并且它也是一个面向普通文本的协议。 + +原则上,我们使用任何一个文本编辑器,都可以轻易地写出一个完整的HTTP请求报文。只要你搞清楚了请求报文的头部(header)和主体(body)应该包含的内容,这样做就会很容易。所以,在这种情况下,即便直接使用net.Dial函数,你应该也不会感觉到困难。 + +不过,不困难并不意味着很方便。如果我们只是访问基于HTTP协议的网络服务的话,那么使用net/http代码包中的程序实体来做,显然会更加便捷。 + +其中,最便捷的是使用http.Get函数。我们在调用它的时候只需要传给它一个URL就可以了,比如像下面这样: + +url1 := "http://google.cn" +fmt.Printf("Send request to %q with method GET ...\n", url1) +resp1, err := http.Get(url1) +if err != nil { + fmt.Printf("request sending error: %v\n", err) +} +defer resp1.Body.Close() +line1 := resp1.Proto + " " + resp1.Status +fmt.Printf("The first line of response:\n%s\n", line1) + + +http.Get函数会返回两个结果值。第一个结果值的类型是*http.Response,它是网络服务给我们传回来的响应内容的结构化表示。 + +第二个结果值是error类型的,它代表了在创建和发送HTTP请求,以及接收和解析HTTP响应的过程中可能发生的错误。 + +http.Get函数会在内部使用缺省的HTTP客户端,并且调用它的Get方法以完成功能。这个缺省的HTTP客户端是由net/http包中的公开变量DefaultClient代表的,其类型是*http.Client。它的基本类型也是可以被拿来使用的,甚至它还是开箱即用的。下面的这两行代码: + +var httpClient1 http.Client +resp2, err := httpClient1.Get(url1) + + +与前面的这一行代码 + +resp1, err := http.Get(url1) + + +是等价的。 + +http.Client是一个结构体类型,并且它包含的字段都是公开的。之所以该类型的零值仍然可用,是因为它的这些字段要么存在着相应的缺省值,要么其零值直接就可以使用,且代表着特定的含义。 + +现在,我问你一个问题,是关于这个类型中的最重要的一个字段的。 + +今天的问题是:http.Client类型中的Transport字段代表着什么? + +这道题的典型回答是这样的。 + +http.Client类型中的Transport字段代表着:向网络服务发送HTTP请求,并从网络服务接收HTTP响应的操作过程。也就是说,该字段的方法RoundTrip应该实现单次HTTP事务(或者说基于HTTP协议的单次交互)需要的所有步骤。 + +这个字段是http.RoundTripper接口类型的,它有一个由http.DefaultTransport变量代表的缺省值(以下简称DefaultTransport)。当我们在初始化一个http.Client类型的值(以下简称Client值)的时候,如果没有显式地为该字段赋值,那么这个Client值就会直接使用DefaultTransport。 + +顺便说一下,http.Client类型的Timeout字段,代表的正是前面所说的单次HTTP事务的超时时间,它是time.Duration类型的。它的零值是可用的,用于表示没有设置超时时间。 + +问题解析 + +下面,我们再通过该字段的缺省值DefaultTransport,来深入地了解一下这个Transport字段。 + +DefaultTransport的实际类型是*http.Transport,后者即为http.RoundTripper接口的默认实现。这个类型是可以被复用的,也推荐被复用,同时,它也是并发安全的。正因为如此,http.Client类型也拥有着同样的特质。 + +http.Transport类型,会在内部使用一个net.Dialer类型的值(以下简称Dialer值),并且,它会把该值的Timeout字段的值,设定为30秒。 + +也就是说,这个Dialer值如果在30秒内还没有建立好网络连接,那么就会被判定为操作超时。在DefaultTransport的值被初始化的时候,这样的Dialer值的DialContext方法会被赋给前者的DialContext字段。 + +http.Transport类型还包含了很多其他的字段,其中有一些字段是关于操作超时的。 + + +IdleConnTimeout:含义是空闲的连接在多久之后就应该被关闭。 +DefaultTransport会把该字段的值设定为90秒。如果该值为0,那么就表示不关闭空闲的连接。注意,这样很可能会造成资源的泄露。 +ResponseHeaderTimeout:含义是,从客户端把请求完全递交给操作系统到从操作系统那里接收到响应报文头的最大时长。DefaultTransport并没有设定该字段的值。 +ExpectContinueTimeout:含义是,在客户端递交了请求报文头之后,等待接收第一个响应报文头的最长时间。在客户端想要使用HTTP的“POST”方法把一个很大的报文体发送给服务端的时候,它可以先通过发送一个包含了“Expect: 100-continue”的请求报文头,来询问服务端是否愿意接收这个大报文体。这个字段就是用于设定在这种情况下的超时时间的。注意,如果该字段的值不大于0,那么无论多大的请求报文体都将会被立即发送出去。这样可能会造成网络资源的浪费。DefaultTransport把该字段的值设定为了1秒。 +TLSHandshakeTimeout:TLS是Transport Layer Security的缩写,可以被翻译为传输层安全。这个字段代表了基于TLS协议的连接在被建立时的握手阶段的超时时间。若该值为0,则表示对这个时间不设限。DefaultTransport把该字段的值设定为了10秒。 + + +此外,还有一些与IdleConnTimeout相关的字段值得我们关注,即:MaxIdleConns、MaxIdleConnsPerHost以及MaxConnsPerHost。 + +无论当前的http.Transport类型的值(以下简称Transport值)访问了多少个网络服务,MaxIdleConns字段都只会对空闲连接的总数做出限定。而MaxIdleConnsPerHost字段限定的则是,该Transport值访问的每一个网络服务的最大空闲连接数。 + +每一个网络服务都会有自己的网络地址,可能会使用不同的网络协议,对于一些HTTP请求也可能会用到代理。Transport值正是通过这三个方面的具体情况,来鉴别不同的网络服务的。 + +MaxIdleConnsPerHost字段的缺省值,由http.DefaultMaxIdleConnsPerHost变量代表,值为2。也就是说,在默认情况下,对于某一个Transport值访问的每一个网络服务,它的空闲连接数都最多只能有两个。 + +与MaxIdleConnsPerHost字段的含义相似的,是MaxConnsPerHost字段。不过,后者限制的是,针对某一个Transport值访问的每一个网络服务的最大连接数,不论这些连接是否是空闲的。并且,该字段没有相应的缺省值,它的零值表示不对此设限。 + +DefaultTransport并没有显式地为MaxIdleConnsPerHost和MaxConnsPerHost这两个字段赋值,但是它却把MaxIdleConns字段的值设定为了100。 + +换句话说,在默认情况下,空闲连接的总数最大为100,而针对每个网络服务的最大空闲连接数为2。注意,上述两个与空闲连接数有关的字段的值应该是联动的,所以,你有时候需要根据实际情况来定制它们。 + +当然了,这首先需要我们在初始化Client值的时候,定制它的Transport字段的值。定制这个值的方式,可以参看DefaultTransport变量的声明。 + +最后,我简单说一下为什么会出现空闲的连接。我们都知道,HTTP协议有一个请求报文头叫做“Connection”。在HTTP协议的1.1版本中,这个报文头的值默认是“keep-alive”。 + +在这种情况下的网络连接都是持久连接,它们会在当前的HTTP事务完成后仍然保持着连通性,因此是可以被复用的。 + +既然连接可以被复用,那么就会有两种可能。一种可能是,针对于同一个网络服务,有新的HTTP请求被递交,该连接被再次使用。另一种可能是,不再有对该网络服务的HTTP请求,该连接被闲置。 + +显然,后一种可能就产生了空闲的连接。另外,如果分配给某一个网络服务的连接过多的话,也可能会导致空闲连接的产生,因为每一个新递交的HTTP请求,都只会征用一个空闲的连接。所以,为空闲连接设定限制,在大多数情况下都是很有必要的,也是需要斟酌的。 + +如果我们想彻底地杜绝空闲连接的产生,那么可以在初始化Transport值的时候把它的DisableKeepAlives字段的值设定为true。这时,HTTP请求的“Connection”报文头的值就会被设置为“close”。这会告诉网络服务,这个网络连接不必保持,当前的HTTP事务完成后就可以断开它了。 + +如此一来,每当一个HTTP请求被递交时,就都会产生一个新的网络连接。这样做会明显地加重网络服务以及客户端的负载,并会让每个HTTP事务都耗费更多的时间。所以,在一般情况下,我们都不要去设置这个DisableKeepAlives字段。 + +顺便说一句,在net.Dialer类型中,也有一个看起来很相似的字段KeepAlive。不过,它与前面所说的HTTP持久连接并不是一个概念,KeepAlive是直接作用在底层的socket上的。 + +它的背后是一种针对网络连接(更确切地说,是TCP连接)的存活探测机制。它的值用于表示每间隔多长时间发送一次探测包。当该值不大于0时,则表示不开启这种机制。DefaultTransport会把这个字段的值设定为30秒。 + +好了,以上这些内容阐述的就是,http.Client类型中的Transport字段的含义,以及它的值的定制方式。这涉及了http.RoundTripper接口、http.DefaultTransport变量、http.Transport类型,以及net.Dialer类型。 + +知识扩展 + +问题:http.Server类型的ListenAndServe方法都做了哪些事情? + +http.Server类型与http.Client是相对应的。http.Server代表的是基于HTTP协议的服务端,或者说网络服务。 + +http.Server类型的ListenAndServe方法的功能是:监听一个基于TCP协议的网络地址,并对接收到的HTTP请求进行处理。这个方法会默认开启针对网络连接的存活探测机制,以保证连接是持久的。同时,该方法会一直执行,直到有严重的错误发生或者被外界关掉。当被外界关掉时,它会返回一个由http.ErrServerClosed变量代表的错误值。 + +对于本问题,典型回答可以像下面这样。 + +这个ListenAndServe方法主要会做下面这几件事情。 + + +检查当前的http.Server类型的值(以下简称当前值)的Addr字段。该字段的值代表了当前的网络服务需要使用的网络地址,即:IP地址和端口号. 如果这个字段的值为空字符串,那么就用":http"代替。也就是说,使用任何可以代表本机的域名和IP地址,并且端口号为80。 +通过调用net.Listen函数在已确定的网络地址上启动基于TCP协议的监听。 +检查net.Listen函数返回的错误值。如果该错误值不为nil,那么就直接返回该值。否则,通过调用当前值的Serve方法准备接受和处理将要到来的HTTP请求。 + + +可以从当前问题直接衍生出的问题一般有两个,一个是“net.Listen函数都做了哪些事情”,另一个是“http.Server类型的Serve方法是怎样接受和处理HTTP请求的”。 + +对于第一个直接的衍生问题,如果概括地说,回答可以是: + + +解析参数值中包含的网络地址隐含的IP地址和端口号; +根据给定的网络协议,确定监听的方法,并开始进行监听。 + + +从这里的第二个步骤出发,我们还可以继续提出一些间接的衍生问题。这往往会涉及net.socket函数以及相关的socket知识。 + +对于第二个直接的衍生问题,我们可以这样回答: + +在一个for循环中,网络监听器的Accept方法会被不断地调用,该方法会返回两个结果值;第一个结果值是net.Conn类型的,它会代表包含了新到来的HTTP请求的网络连接;第二个结果值是代表了可能发生的错误的error类型值。 + +如果这个错误值不为nil,除非它代表了一个暂时性的错误,否则循环都会被终止。如果是暂时性的错误,那么循环的下一次迭代将会在一段时间之后开始执行。 + +如果这里的Accept方法没有返回非nil的错误值,那么这里的程序将会先把它的第一个结果值包装成一个*http.conn类型的值(以下简称conn值),然后通过在新的goroutine中调用这个conn值的serve方法,来对当前的HTTP请求进行处理。 + +这个处理的细节还是很多的,所以我们依然可以找出不少的间接的衍生问题。比如,这个conn值的状态有几种,分别代表着处理的哪个阶段?又比如,处理过程中会用到哪些读取器和写入器,它们的作用分别是什么?再比如,这里的程序是怎样调用我们自定义的处理函数的,等等。 + +诸如此类的问题很多,我就不在这里一一列举和说明了。你只需要记住一句话:“源码之前了无秘密”。上面这些问题的答案都可以在Go语言标准库的源码中找到。如果你想对本问题进行深入的探索,那么一定要去看net/http代码包的源码。 + +总结 + +今天,我们主要讲的是基于HTTP协议的网络服务,侧重点仍然在客户端。 + +我们在讨论了http.Get函数和http.Client类型的简单使用方式之后,把目光聚焦在了后者的Transport字段。 + +这个字段代表着单次HTTP事务的操作过程。它是http.RoundTripper接口类型的。它的缺省值由http.DefaultTransport变量代表,其实际类型是*http.Transport。 + +http.Transport包含的字段非常多。我们先讲了DefaultTransport中的DialContext字段会被赋予什么样的值,又详细说明了一些关于操作超时的字段。 + +比如IdleConnTimeout和ExpectContinueTimeout,以及相关的MaxIdleConns和MaxIdleConnsPerHost等等。之后,我又简单地解释了出现空闲连接的原因,以及相关的定制方式。 + +最后,作为扩展,我还为你简要地梳理了http.Server类型的ListenAndServe方法,执行的主要流程。不过,由于篇幅原因,我没有做深入讲述。但是,这并不意味着没有必要深入下去。相反,这个方法很重要,值得我们认真地去探索一番。 + +在你需要或者有兴趣的时候,我希望你能去好好地看一看net/http包中的相关源码。一切秘密都在其中。 + +思考题 + +我今天留给你的思考题比较简单,即:怎样优雅地停止基于HTTP协议的网络服务程序? + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/48程序性能分析基础(上).md b/专栏/Go语言核心36讲/48程序性能分析基础(上).md new file mode 100644 index 0000000..baf6abf --- /dev/null +++ b/专栏/Go语言核心36讲/48程序性能分析基础(上).md @@ -0,0 +1,119 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 48 程序性能分析基础(上) + 作为拾遗的部分,今天我们来讲讲与Go程序性能分析有关的基础知识。 + +Go语言为程序开发者们提供了丰富的性能分析API,和非常好用的标准工具。这些API主要存在于: + + +runtime/pprof; +net/http/pprof; +runtime/trace; + + +这三个代码包中。 + +另外,runtime代码包中还包含了一些更底层的API。它们可以被用来收集或输出Go程序运行过程中的一些关键指标,并帮助我们生成相应的概要文件以供后续分析时使用。 + +至于标准工具,主要有go tool pprof和go tool trace这两个。它们可以解析概要文件中的信息,并以人类易读的方式把这些信息展示出来。 + +此外,go test命令也可以在程序测试完成后生成概要文件。如此一来,我们就可以很方便地使用前面那两个工具读取概要文件,并对被测程序的性能加以分析。这无疑会让程序性能测试的一手资料更加丰富,结果更加精确和可信。 + +在Go语言中,用于分析程序性能的概要文件有三种,分别是:CPU概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。 + +这些概要文件中包含的都是:在某一段时间内,对Go程序的相关指标进行多次采样后得到的概要信息。 + +对于CPU概要文件来说,其中的每一段独立的概要信息都记录着,在进行某一次采样的那个时刻,CPU上正在执行的Go代码。 + +而对于内存概要文件,其中的每一段概要信息都记载着,在某个采样时刻,正在执行的Go代码以及堆内存的使用情况,这里包含已分配和已释放的字节数量和对象数量。至于阻塞概要文件,其中的每一段概要信息,都代表着Go程序中的一个goroutine阻塞事件。 + +注意,在默认情况下,这些概要文件中的信息并不是普通的文本,它们都是以二进制的形式展现的。如果你使用一个常规的文本编辑器查看它们的话,那么肯定会看到一堆“乱码”。 + +这时就可以显现出go tool pprof这个工具的作用了。我们可以通过它进入一个基于命令行的交互式界面,并对指定的概要文件进行查阅。就像下面这样: + +$ go tool pprof cpuprofile.out +Type: cpu +Time: Nov 9, 2018 at 4:31pm (CST) +Duration: 7.96s, Total samples = 6.88s (86.38%) +Entering interactive mode (type "help" for commands, "o" for options) +(pprof) + + +关于这个工具的具体用法,我就不在这里赘述了。在进入这个工具的交互式界面之后,我们只要输入指令help并按下回车键,就可以看到很详细的帮助文档。 + +我们现在来说说怎样生成概要文件。 + +你可能会问,既然在概要文件中的信息不是普通的文本,那么它们到底是什么格式的呢?一个对广大的程序开发者而言,并不那么重要的事实是,它们是通过protocol buffers生成的二进制数据流,或者说字节流。 + +概括来讲,protocol buffers是一种数据序列化协议,同时也是一个序列化工具。它可以把一个值,比如一个结构体或者一个字典,转换成一段字节流。 + +也可以反过来,把经过它生成的字节流反向转换为程序中的一个值。前者就被叫做序列化,而后者则被称为反序列化。 + +换句话说,protocol buffers定义和实现了一种“可以让数据在结构形态和扁平形态之间互相转换”的方式。 + +Protocol buffers的优势有不少。比如,它可以在序列化数据的同时对数据进行压缩,所以它生成的字节流,通常都要比相同数据的其他格式(例如XML和JSON)占用的空间明显小很多。 + +又比如,它既能让我们自己去定义数据序列化和结构化的格式,也允许我们在保证向后兼容的前提下去更新这种格式。 + +正因为这些优势,Go语言从1.8版本开始,把所有profile相关的信息生成工作都交给protocol buffers来做了。这也是我们在上述概要文件中,看不到普通文本的根本原因了。 + +Protocol buffers的用途非常广泛,并且在诸如数据存储、数据传输等任务中有着很高的使用率。不过,关于它,我暂时就介绍到这里。你目前知道这些也就足够了。你并不用关心runtime/pprof包以及runtime包中的程序是如何序列化这些概要信息的。 + +继续回到怎样生成概要文件的话题,我们依然通过具体的问题来讲述。 + +我们今天的问题是:怎样让程序对CPU概要信息进行采样? + +这道题的典型回答是这样的。 + +这需要用到runtime/pprof包中的API。更具体地说,在我们想让程序开始对CPU概要信息进行采样的时候,需要调用这个代码包中的StartCPUProfile函数,而在停止采样的时候则需要调用该包中的StopCPUProfile函数。 + +问题解析 + +runtime/pprof.StartCPUProfile函数(以下简称StartCPUProfile函数)在被调用的时候,先会去设定CPU概要信息的采样频率,并会在单独的goroutine中进行CPU概要信息的收集和输出。 + +注意,StartCPUProfile函数设定的采样频率总是固定的,即:100赫兹。也就是说,每秒采样100次,或者说每10毫秒采样一次。 + +赫兹,也称Hz,是从英文单词“Hertz”(一个英文姓氏)音译过来的一个中文词。它是CPU主频的基本单位。 + +CPU的主频指的是,CPU内核工作的时钟频率,也常被称为CPU clock speed。这个时钟频率的倒数即为时钟周期(clock cycle),也就是一个CPU内核执行一条运算指令所需的时间,单位是秒。 + +例如,主频为1000Hz的CPU,它的单个内核执行一条运算指令所需的时间为0.001秒,即1毫秒。又例如,我们现在常用的3.2GHz的多核CPU,其单个内核在1个纳秒的时间里就可以至少执行三条运算指令。 + +StartCPUProfile函数设定的CPU概要信息采样频率,相对于现代的CPU主频来说是非常低的。这主要有两个方面的原因。 + +一方面,过高的采样频率会对Go程序的运行效率造成很明显的负面影响。因此,runtime包中SetCPUProfileRate函数在被调用的时候,会保证采样频率不超过1MHz(兆赫),也就是说,它只允许每1微秒最多采样一次。StartCPUProfile函数正是通过调用这个函数来设定CPU概要信息的采样频率的。 + +另一方面,经过大量的实验,Go语言团队发现100Hz是一个比较合适的设定。因为这样做既可以得到足够多、足够有用的概要信息,又不至于让程序的运行出现停滞。另外,操作系统对高频采样的处理能力也是有限的,一般情况下,超过500Hz就很可能得不到及时的响应了。 + +在StartCPUProfile函数执行之后,一个新启用的goroutine将会负责执行CPU概要信息的收集和输出,直到runtime/pprof包中的StopCPUProfile函数被成功调用。 + +StopCPUProfile函数也会调用runtime.SetCPUProfileRate函数,并把参数值(也就是采样频率)设为0。这会让针对CPU概要信息的采样工作停止。 + +同时,它也会给负责收集CPU概要信息的代码一个“信号”,以告知收集工作也需要停止了。 + +在接到这样的“信号”之后,那部分程序将会把这段时间内收集到的所有CPU概要信息,全部写入到我们在调用StartCPUProfile函数的时候指定的写入器中。只有在上述操作全部完成之后,StopCPUProfile函数才会返回。 + +好了,经过这一番解释,你应该已经对CPU概要信息的采样工作有一定的认识了。你可以去看看demo96.go文件中的代码,并运行几次试试。这样会有助于你加深对这个问题的理解。 + +总结 + +我们这两篇内容讲的是Go程序的性能分析,这其中的内容都是你从事这项任务必备的一些知识和技巧。 + +首先,我们需要知道,与程序性能分析有关的API主要存在于runtime、runtime/pprof和net/http/pprof这几个代码包中。它们可以帮助我们收集相应的性能概要信息,并把这些信息输出到我们指定的地方。 + +Go语言的运行时系统会根据要求对程序的相关指标进行多次采样,并对采样的结果进行组织和整理,最后形成一份完整的性能分析报告。这份报告就是我们一直在说的概要信息的汇总。 + +一般情况下,我们会把概要信息输出到文件。根据概要信息的不同,概要文件的种类主要有三个,分别是:CPU概要文件(CPU Profile)、内存概要文件(Mem Profile)和阻塞概要文件(Block Profile)。 + +在本文中,我提出了一道与上述几种概要信息有关的问题。在下一篇文章中,我们会继续对这部分问题的探究。 + +你对今天的内容有什么样的思考与疑惑,可以给我留言,感谢你的收听,我们下次再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/49程序性能分析基础(下).md b/专栏/Go语言核心36讲/49程序性能分析基础(下).md new file mode 100644 index 0000000..682379b --- /dev/null +++ b/专栏/Go语言核心36讲/49程序性能分析基础(下).md @@ -0,0 +1,166 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 49 程序性能分析基础(下) + 你好,我是郝林,今天我们继续分享程序性能分析基础的内容。 + +在上一篇文章中,我们围绕着“怎样让程序对CPU概要信息进行采样”这一问题进行了探讨,今天,我们再来一起看看它的拓展问题。 + +知识扩展 + +问题1:怎样设定内存概要信息的采样频率? + +针对内存概要信息的采样会按照一定比例收集Go程序在运行期间的堆内存使用情况。设定内存概要信息采样频率的方法很简单,只要为runtime.MemProfileRate变量赋值即可。 + +这个变量的含义是,平均每分配多少个字节,就对堆内存的使用情况进行一次采样。如果把该变量的值设为0,那么,Go语言运行时系统就会完全停止对内存概要信息的采样。该变量的缺省值是512 KB,也就是512千字节。 + +注意,如果你要设定这个采样频率,那么越早设定越好,并且只应该设定一次,否则就可能会对Go语言运行时系统的采样工作,造成不良影响。比如,只在main函数的开始处设定一次。 + +在这之后,当我们想获取内存概要信息的时候,还需要调用runtime/pprof包中的WriteHeapProfile函数。该函数会把收集好的内存概要信息,写到我们指定的写入器中。 + +注意,我们通过WriteHeapProfile函数得到的内存概要信息并不是实时的,它是一个快照,是在最近一次的内存垃圾收集工作完成时产生的。如果你想要实时的信息,那么可以调用runtime.ReadMemStats函数。不过要特别注意,该函数会引起Go语言调度器的短暂停顿。 + +以上,就是关于内存概要信息的采样频率设定问题的简要回答。 + +问题2:怎样获取到阻塞概要信息? + +我们调用runtime包中的SetBlockProfileRate函数,即可对阻塞概要信息的采样频率进行设定。该函数有一个名叫rate的参数,它是int类型的。 + +这个参数的含义是,只要发现一个阻塞事件的持续时间达到了多少个纳秒,就可以对其进行采样。如果这个参数的值小于或等于0,那么就意味着Go语言运行时系统将会完全停止对阻塞概要信息的采样。 + +在runtime包中,还有一个名叫blockprofilerate的包级私有变量,它是uint64类型的。这个变量的含义是,只要发现一个阻塞事件的持续时间跨越了多少个CPU时钟周期,就可以对其进行采样。它的含义与我们刚刚提到的rate参数的含义非常相似,不是吗? + +实际上,这两者的区别仅仅在于单位不同。runtime.SetBlockProfileRate函数会先对参数rate的值进行单位换算和必要的类型转换,然后,它会把换算结果用原子操作赋给blockprofilerate变量。由于此变量的缺省值是0,所以Go语言运行时系统在默认情况下并不会记录任何在程序中发生的阻塞事件。 + +另一方面,当我们需要获取阻塞概要信息的时候,需要先调用runtime/pprof包中的Lookup函数并传入参数值"block",从而得到一个*runtime/pprof.Profile类型的值(以下简称Profile值)。在这之后,我们还需要调用这个Profile值的WriteTo方法,以驱使它把概要信息写进我们指定的写入器中。 + +这个WriteTo方法有两个参数,一个参数就是我们刚刚提到的写入器,它是io.Writer类型的。而另一个参数则是代表了概要信息详细程度的int类型参数debug。 + +debug参数主要的可选值有两个,即:0和1。当debug的值为0时,通过WriteTo方法写进写入器的概要信息仅会包含go tool pprof工具所需的内存地址,这些内存地址会以十六进制的形式展现出来。 + +当该值为1时,相应的包名、函数名、源码文件路径、代码行号等信息就都会作为注释被加入进去。另外,debug为0时的概要信息,会经由protocol buffers转换为字节流。而在debug为1的时候,WriteTo方法输出的这些概要信息就是我们可以读懂的普通文本了。 + +除此之外,debug的值也可以是2。这时,被输出的概要信息也会是普通的文本,并且通常会包含更多的细节。至于这些细节都包含了哪些内容,那就要看我们调用runtime/pprof.Lookup函数的时候传入的是什么样的参数值了。下面,我们就来一起看一下这个函数。 + +问题 3:runtime/pprof.Lookup函数的正确调用方式是什么? + +runtime/pprof.Lookup函数(以下简称Lookup函数)的功能是,提供与给定的名称相对应的概要信息。这个概要信息会由一个Profile值代表。如果该函数返回了一个nil,那么就说明不存在与给定名称对应的概要信息。 + +runtime/pprof包已经为我们预先定义了6个概要名称。它们对应的概要信息收集方法和输出方法也都已经准备好了。我们直接拿来使用就可以了。它们是:goroutine、heap、allocs、threadcreate、block和mutex。 + +当我们把"goroutine"传入Lookup函数的时候,该函数会利用相应的方法,收集到当前正在使用的所有goroutine的堆栈跟踪信息。注意,这样的收集会引起Go语言调度器的短暂停顿。 + +当调用该函数返回的Profile值的WriteTo方法时,如果参数debug的值大于或等于2,那么该方法就会输出所有goroutine的堆栈跟踪信息。这些信息可能会非常多。如果它们占用的空间超过了64 MB(也就是64兆字节),那么相应的方法就会将超出的部分截掉。 + +如果Lookup函数接到的参数值是"heap",那么它就会收集与堆内存的分配和释放有关的采样信息。这实际上就是我们在前面讨论过的内存概要信息。在我们传入"allocs"的时候,后续的操作会与之非常的相似。 + +在这两种情况下,Lookup函数返回的Profile值也会极其相像。只不过,在这两种Profile值的WriteTo方法被调用时,它们输出的概要信息会有细微的差别,而且这仅仅体现在参数debug等于0的时候。 + +"heap"会使得被输出的内存概要信息默认以“在用空间”(inuse_space)的视角呈现,而"allocs"对应的默认视角则是“已分配空间”(alloc_space)。 + +“在用空间”是指,已经被分配但还未被释放的内存空间。在这个视角下,go tool pprof工具并不会去理会与已释放空间有关的那部分信息。而在“已分配空间”的视角下,所有的内存分配信息都会被展现出来,无论这些内存空间在采样时是否已被释放。 + +此外,无论是"heap"还是"allocs",在我们调用Profile值的WriteTo方法的时候,只要赋予debug参数的值大于0,那么该方法输出内容的规格就会是相同的。 + +参数值"threadcreate"会使Lookup函数去收集一些堆栈跟踪信息。这些堆栈跟踪信息中的每一个都会描绘出一个代码调用链,这些调用链上的代码都导致新的操作系统线程产生。这样的Profile值的输出规格也只有两种,取决于我们传给其WriteTo方法的参数值是否大于0。 + +再说"block"和"mutex"。"block"代表的是,因争用同步原语而被阻塞的那些代码的堆栈跟踪信息。还记得吗?这就是我们在前面讲过的阻塞概要信息。 + +与之相对应,"mutex"代表的是,曾经作为同步原语持有者的那些代码,它们的堆栈跟踪信息。它们的输出规格也都只有两种,取决于debug是否大于0。 + +这里所说的同步原语,指的是存在于Go语言运行时系统内部的一种底层的同步工具,或者说一种同步机制。 + +它是直接面向内存地址的,并以异步信号量和原子操作作为实现手段。我们已经熟知的通道、互斥锁、条件变量、”WaitGroup“,以及Go语言运行时系统本身,都会利用它来实现自己的功能。 + + + +好了,关于这个问题,我们已经谈了不少了。我相信,你已经对Lookup函数的调用方式及其背后的含义有了比较深刻的理解了。demo99.go文件中包含了一些示例代码,可供你参考。 + +问题4:如何为基于HTTP协议的网络服务添加性能分析接口? + +这个问题说起来还是很简单的。这是因为我们在一般情况下只要在程序中导入net/http/pprof代码包就可以了,就像这样: + +import _ "net/http/pprof" + + +然后,启动网络服务并开始监听,比如: + +log.Println(http.ListenAndServe("localhost:8082", nil)) + + +在运行这个程序之后,我们就可以通过在网络浏览器中访问http://localhost:8082/debug/pprof这个地址看到一个简约的网页。如果你认真地看了上一个问题的话,那么肯定可以快速搞明白这个网页中各个部分的含义。 + +在/debug/pprof/这个URL路径下还有很多可用的子路径,这一点你通过点选网页中的链接就可以了解到。像allocs、block、goroutine、heap、mutex、threadcreate这6个子路径,在底层其实都是通过Lookup函数来处理的。关于这个函数,你应该已经很熟悉了。 + +这些子路径都可以接受查询参数debug。它用于控制概要信息的格式和详细程度。至于它的可选值,我就不再赘述了。它的缺省值是0。另外,还有一个名叫gc的查询参数。它用于控制是否在获取概要信息之前强制地执行一次垃圾回收。只要它的值大于0,程序就会这样做。不过,这个参数仅在/debug/pprof/heap路径下有效。 + +一旦/debug/pprof/profile路径被访问,程序就会去执行对CPU概要信息的采样。它接受一个名为seconds的查询参数。该参数的含义是,采样工作需要持续多少秒。如果这个参数未被显式地指定,那么采样工作会持续30秒。注意,在这个路径下,程序只会响应经protocol buffers转换的字节流。我们可以通过go tool pprof工具直接读取这样的HTTP响应,例如: + +go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60 + + +除此之外,还有一个值得我们关注的路径,即:/debug/pprof/trace。在这个路径下,程序主要会利用runtime/trace代码包中的API来处理我们的请求。 + +更具体地说,程序会先调用trace.Start函数,然后在查询参数seconds指定的持续时间之后再调用trace.Stop函数。这里的seconds的缺省值是1秒。至于runtime/trace代码包的功用,我就留给你自己去查阅和探索吧。 + +前面说的这些URL路径都是固定不变的。这是默认情况下的访问规则。我们还可以对它们进行定制,就像这样: + +mux := http.NewServeMux() +pathPrefix := "/d/pprof/" +mux.HandleFunc(pathPrefix, + func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, pathPrefix) + if name != "" { + pprof.Handler(name).ServeHTTP(w, r) + return + } + pprof.Index(w, r) + }) +mux.HandleFunc(pathPrefix+"cmdline", pprof.Cmdline) +mux.HandleFunc(pathPrefix+"profile", pprof.Profile) +mux.HandleFunc(pathPrefix+"symbol", pprof.Symbol) +mux.HandleFunc(pathPrefix+"trace", pprof.Trace) + +server := http.Server{ + Addr: "localhost:8083", + Handler: mux, +} + + +可以看到,我们几乎只使用了net/http/pprof代码包中的几个程序实体,就完成了这样的定制。这在我们使用第三方的网络服务开发框架时尤其有用。 + +我们自定义的HTTP请求多路复用器mux所包含的访问规则与默认的规则很相似,只不过URL路径的前缀更短了一些而已。 + +我们定制mux的过程与net/http/pprof包中的init函数所做的事情也是类似的。这个init函数的存在,其实就是我们在前面仅仅导入”net/http/pprof”代码包就能够访问相关路径的原因。 + +在我们编写网络服务程序的时候,使用net/http/pprof包要比直接使用runtime/pprof包方便和实用很多。通过合理运用,这个代码包可以为网络服务的监测提供有力的支撑。关于这个包的知识,我就先介绍到这里。 + +总结 + +这两篇文章中,我们主要讲了Go程序的性能分析,提到的很多内容都是你必备的知识和技巧。这些有助于你真正地理解以采样、收集、输出为代表的一系列操作步骤。 + +我提到的几种概要信息有关的问题。你需要记住的是,每一种概要信息都代表了什么,它们分别都包含了什么样的内容。 + +你还需要知道获取它们的正确方式,包括怎样启动和停止采样、怎样设定采样频率,以及怎样控制输出内容的格式和详细程度。 + +此外,runtime/pprof包中的Lookup函数的正确调用方式也很重要。对于除了CPU概要信息之外的其他概要信息,我们都可以通过调用这个函数获取到。 + +除此之外,我还提及了一个上层的应用,即:为基于HTTP协议的网络服务,添加性能分析接口。这也是很实用的一个部分。 + +虽然net/http/pprof包提供的程序实体并不多,但是它却能够让我们用不同的方式,实现性能分析接口的嵌入。这些方式有的是极简的、开箱即用的,而有的则用于满足各种定制需求。 + +以上这些,就是我今天为你讲述的Go语言知识,它们是程序性能分析的基础。如果你把Go语言程序运用于生产环境,那么肯定会涉及它们。对于这里提到的所有内容和问题,我都希望你能够认真地去思考和领会。这样才能够让你在真正使用它们的时候信手拈来。 + +思考题 + +我今天留给你的思考题其实在前面已经透露了,那就是:runtime/trace代码包的功用是什么? + +感谢你的收听,我们下期再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/尾声愿你披荆斩棘,所向无敌.md b/专栏/Go语言核心36讲/尾声愿你披荆斩棘,所向无敌.md new file mode 100644 index 0000000..009383c --- /dev/null +++ b/专栏/Go语言核心36讲/尾声愿你披荆斩棘,所向无敌.md @@ -0,0 +1,73 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 尾声 愿你披荆斩棘,所向无敌 + 你好,我是郝林。 + +专栏到这里,就要结束了。 + +差不多在半年以前(2018年的第二个季度),极客时间的总编辑郭蕾找到我,说想让我写一个关于Go语言的技术专栏。 + +我那时候还在轻松筹担任大数据负责人,管理着四个技术团队,每天都非常非常忙碌,看起来并没有多余的精力去写这么一个在时间和质量上都有着严格要求的专栏。 + +我们俩也是老相识了,所以,我当时斩钉截铁地说:“写不了,没时间”。当然了,要是连续熬夜的话或许可以写得出来,我写《Go并发编程实战》那本书的时候就是这么干的。 + +可是,我在2017年年末已经因为急性胰腺炎惊心动魄过一回了,需要非常注意休息,所以我想了想还是决定小心为妙。 + +也许是凑巧,也许是注定,在2018年的6月份,我的胰腺炎复发了。我当时还在面试,意念上已经疼得直不起腰了,但还是坚持着完成了面试。 + +后来在医院等待确诊结果的时候,我的第三个念头竟然就是“也许我可以有时间去写那个专栏了”。现在回忆起来,当初的想法还是太简单了。 + +不过,专栏这件事情终归还是向着合作的方向发展了。因为郭蕾的坚持和帮助,也因为极客时间的慷慨解囊和多次扶持,在经过了不少的艰难困苦之后,这个专栏如今终于写作完成了。我对此感到非常的高兴和欣慰。 + +专栏是如何进行写作的 + +我在写这个专栏的时候,已经尽我所能地让其中的每一句话都准确无误,并且尽量地加入我最新的研究成果和个人理解。 + +所以,即使是对于我自己,这个专栏的价值和意义也是很大的。我通过这个专栏的写作又倒逼我自己仔细地阅读了一遍Go语言最新版本的源码。 + +我当初给自己定下了一个关于文章质量的目标。我要保证的是,专栏中的每一篇文章的质量都绝对不能低于这个目标。 + +没错,这里只有目标,没有底线。对于我个人而言,只要是边界明确的事情,我就不喜欢设置底线。因为只要有了底线,作为更高要求的目标往往就很难达成了。这样的双重标准会让目标形同虚设。 + +为了达成目标,我在写每一篇文章的时候都差不多要查阅不少的Go语言源码,确定每一个细节。每一个版本的Go语言,其内部的源码都会有一些变化,所以以前的经验只能作为参考,并不能完全依赖。 + +我需要先深入理解(或者修正理解)、再有侧重点地记录和思考,最后再进行贯穿式的解读。在做完这些之后,我才会把精华写入文章之中。 + +我觉得,人的成就不论大小都需要经过努力和苦难才能达成。和我共事过的很多人都知道,我是一个不会轻易给出承诺的人。不过,一旦做出承诺,我就会去拼命完成。 + +大多数时候,我并不觉得在拼命,但是别人(尤其是我的家人)却告诉我“这就是在拼命”。现在想想,这种完全靠爆发力取胜的做事方式是不对的,做工作还是应该顺滑一些,毕竟“润物”需得“细无声”。 + +专栏仍有瑕疵 + +虽然这个专栏的文章已经全部完成了,但是由于我的精力问题,专栏在呈现形式上还有一些瑕疵。 + +比如,没有配图,没有给出思考题的答案等。我在极客时间App的留言区里已经多次跟大家解释过这件事了。 + +但是为了保证大家都能够知晓,我在这里再说一遍:我会再利用几个月的时间为这个专栏补充配图,并简要地给出所有思考题的答案。 + +我已经开始绘制一些图片了,绘制完成就会同步更新到文章中,你也可以返回去重新阅读一遍。 + + + +(目前正在绘制的图样) + +我补充的顺序是,配图在先,思考题答案再后。因为我的精力实在有限,我会争取在明年春节之前完成补充。还希望大家能够理解。 + +前方的路 + +每个人的路都是不同的,即便他们在做着一模一样的事。前方的路只有你自己能够开创,但是我希望本专栏能够作为你的一盏指路明灯。我个人认为,至少对于大部分读者而言,我的这个愿望已经达成了。你觉得呢?是否已经有了足够的收获呢? + +无论如何,只要你还想继续走在Go语言编程的康庄大道上,积极地加入到有活力、有情怀的技术社区当中准没错。我想,极客时间就将是这样一个社区。当然,我们的“GoHackers”社群也是。 + +在最后的最后,我想去表达一些感谢,我要由衷地感谢我的家人!如果不是他们,别说写专栏了,我坐在电脑前面打字写文章可能都是奢望,我还要感谢所有帮助过我的人。还有在阅读这篇文章的你们,也是我最大写作动力。 + +好了,我就先说到这里吧。后面有的是机会。最后,祝你学习顺利,在成为技术大神的道路上披荆斩棘,所向无敌! + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言核心36讲/新年彩蛋完整版思考题答案.md b/专栏/Go语言核心36讲/新年彩蛋完整版思考题答案.md new file mode 100644 index 0000000..12fb6f2 --- /dev/null +++ b/专栏/Go语言核心36讲/新年彩蛋完整版思考题答案.md @@ -0,0 +1,350 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 新年彩蛋 完整版思考题答案 + 你好,我是郝林。 + +在2019年的春节来临之际,我恰好也更新完了专栏所有的配图和思考题答案。希望这些可以帮助到你,在新的一年中,祝你新年快乐,Go语言学习之路更加顺利。 + +基础概念篇 + +1. Go语言在多个工作区中查找依赖包的时候是以怎样的顺序进行的? + +答:你设置的环境变量GOPATH的值决定了这个顺序。如果你在GOPATH中设置了多个工作区,那么这种查找会以从左到右的顺序在这些工作区中进行。 + +你可以通过试验来确定这个问题的答案。例如:先在一个源码文件中导入一个在你的机器上并不存在的代码包,然后编译这个代码文件。最后,将输出的编译错误信息与GOPATH的值进行对比。 + +2. 如果在多个工作区中都存在导入路径相同的代码包会产生冲突吗? + +答:不会产生冲突。因为代码包的查找是按照已给定的顺序逐一地在多个工作区中进行的。 + +3. 默认情况下,我们可以让命令源码文件接受哪些类型的参数值? + +答:这个问题通过查看flag代码包的文档就可以回答了。概括来讲,有布尔类型、整数类型、浮点数类型、字符串类型,以及time.Duration类型。 + +4. 我们可以把自定义的数据类型作为参数值的类型吗?如果可以,怎样做? + +答:狭义上讲是不可以的,但是广义上讲是可以的。这需要一些定制化的工作,并且被给定的参数值只能是序列化的。具体可参见flag代码包文档中的例子。 + +5. 如果你需要导入两个代码包,而这两个代码包的导入路径的最后一级是相同的,比如:dep/lib/flag和flag,那么会产生冲突吗? + +答:这会产生冲突。因为代表两个代码包的标识符重复了,都是flag。 + +6. 如果会产生冲突,那么怎样解决这种冲突?有几种方式? + +答:接上一个问题。很简单,导入代码包的时候给它起一个别名就可以了,比如: import libflag "dep/lib/flag"。或者,以本地化的方式导入代码包,如:import . "dep/lib/flag"。 + +7. 如果与当前的变量重名的是外层代码块中的变量,那么意味着什么? + +答:这意味着这两个变量成为了“可重名变量”。在内层的变量所处的那个代码块以及更深层次的代码块中,这个变量会“屏蔽”掉外层代码块中的那个变量。 + +8. 如果通过import . XXX这种方式导入的代码包中的变量与当前代码包中的变量重名了,那么Go语言是会把它们当做“可重名变量”看待还是会报错呢? + +答:这两个变量会成为“可重名变量”。虽然这两个变量在这种情况下的作用域都是当前代码包的当前文件,但是它们所处的代码块是不同的。 + +当前文件中的变量处在该文件所代表的代码块中,而被导入的代码包中的变量却处在声明它的那个文件所代表的代码块中。当然,我们也可以说被导入的代码包所代表的代码块包含了这个变量。 + +在当前文件中,本地的变量会“屏蔽”掉被导入的变量。 + +9. 除了《程序实体的那些事儿3》一文中提及的那些,你还认为类型转换规则中有哪些值得注意的地方? + +答:简单来说,我们在进行类型转换的时候需要注意各种符号的优先级。具体可参见Go语言规范中的转换部分。 + +10. 你能具体说说别名类型在代码重构过程中可以起到的哪些作用吗? + +答:简单来说,我们可以通过别名类型实现外界无感知的代码重构。具体可参见Go语言官方的文档Proposal: Type Aliases。 + +数据类型和语句篇 + +11. 如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么? + +答:我们需要特别注意的是,当操作其中一个切片的时候是否会影响到其他指向同一个底层数组的切片。 + +如果是,那么问一下自己,这是你想要的结果吗?无论如何,通过这种方式来组织或共享数据是不正确的。你需要做的是,要么彻底切断这些切片的底层联系,要么立即为所有的相关操作加锁。 + +12. 怎样沿用“扩容”的思想对切片进行“缩容”? + +答:关于切片的“缩容”,可参看官方的相关wiki。不过,如果你需要频繁的“缩容”,那么就可能需要考虑其他的数据结构了,比如:container/list代码包中的List。 + +13. container/ring包中的循环链表的适用场景都有哪些? + +答:比如:可重用的资源(缓存等)的存储,或者需要灵活组织的资源池,等等。 + +14. container/heap包中的堆的适用场景又有哪些呢? + +答:它最重要的用途就是构建优先级队列,并且这里的“优先级”可以很灵活。所以,想象空间很大。 + +15. 字典类型的值是并发安全的吗?如果不是,那么在我们只在字典上添加或删除键-元素对的情况下,依然不安全吗? + +答:字典类型的值不是并发安全的,即使我们只是增减其中的键值对也是如此。其根本原因是,字典值内部有时候会根据需要进行存储方面的调整。 + +16. 通道的长度代表着什么?它在什么时候会通道的容量相同? + +通道的长度代表它当前包含的元素值的个数。当通道已满时,其长度会与容量相同。 + +17. 元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢? + +答:浅表复制。实际上,在Go语言中并不存在深层次的复制,除非我们自己来做。 + +18. 如果在select语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支? + +答:很简单,把nil赋给代表了这个通道的变量就可以了。如此一来,对于这个通道(那个变量)的发送操作和接收操作就会永远被阻塞。 + +19. 在select语句与for语句联用时,怎样直接退出外层的for语句? + +答:这一般会用到goto语句和标签(label),具体请参看Go语言规范的这部分。 + +20. complexArray1被传入函数的话,这个函数中对该参数值的修改会影响到它的原值吗? + +答:文中complexArray1变量的声明如下: + +complexArray1 := [3][]string{ + []string{"d", "e", "f"}, + []string{"g", "h", "i"}, + []string{"j", "k", "l"}, +} + + +这要看怎样修改了。虽然complexArray1本身是一个数组,但是其中的元素却都是切片。如果对complexArray1中的元素进行增减,那么原值就不会受到影响。但若要修改它已有的元素值,那么原值也会跟着改变。 + +21. 函数真正拿到的参数值其实只是它们的副本,那么函数返回给调用方的结果值也会被复制吗? + +答:函数返回给调用方的结果值也会被复制。不过,在一般情况下,我们不用太在意。但如果函数在返回结果值之后依然保持执行并会对结果值进行修改,那么我们就需要注意了。 + +22. 我们可以在结构体类型中嵌入某个类型的指针类型吗?如果可以,有哪些注意事项? + +答:当然可以。在这时,我们依然需要注意各种“屏蔽”现象。由于某个类型的指针类型会包含与前者有关联的所有方法,所以我们更要注意。 + +另外,我们在嵌入和引用这样的字段的时候还需要注意一些冲突方面的问题,具体请参看Go语言规范的这一部分。 + +23. 字面量struct{}代表了什么?又有什么用处? + +答:字面量struct{}代表了空的结构体类型。这样的类型既不包含任何字段也没有任何方法。该类型的值所需的存储空间几乎可以忽略不计。 + +因此,我们可以把这样的值作为占位值来使用。比如:在同一个应用场景下,map[int]struct{} 类型的值会比 map[int]bool 类型的值占用更少的存储空间。 + +24. 如果我们把一个值为nil的某个实现类型的变量赋给了接口变量,那么在这个接口变量上仍然可以调用该接口的方法吗?如果可以,有哪些注意事项?如果不可以,原因是什么? + +答:可以调用。但是请注意,这个被调用的方法在此时所持有的接收者的值是nil。因此,如果该方法引用了其接收者的某个字段,那么就会引发panic! + +25. 引用类型的值的指针值是有意义的吗?如果没有意义,为什么?如果有意义,意义在哪里? + +答:从存储和传递的角度看,没有意义。因为引用类型的值已经相当于指向某个底层数据结构的指针了。当然,引用类型的值不只是指针那么简单。 + +26. 用什么手段可以对goroutine的启用数量加以限制? + +答:一个很简单且很常用的方法是,使用一个通道保存一些令牌。只有先拿到一个令牌,才能启用一个goroutine。另外在go函数即将执行结束的时候还需要把令牌及时归还给那个通道。 + +更高级的手段就需要比较完整的设计了。比如,任务分发器+任务管道(单层的通道)+固定个数的goroutine。又比如,动态任务池(多层的通道)+动态goroutine池(可由前述的那个令牌方案演化而来)。等等。 + +27. runtime包中提供了哪些与模型三要素G、P和M相关的函数? + +答:关于这个问题,我相信你一查文档便知。不过光知道还不够,还要会用。 + +28. 在类型switch语句中,我们怎样对被判断类型的那个值做相应的类型转换? + +答:其实这个事情可以让Go语言自己来做,例如: + +switch t := x.(type) { +// cases +} + + +当流程进入到某个case子句的时候,变量t的值就已经被自动地转换为相应类型的值了。 + +29. 在if语句中,初始化子句声明的变量的作用域是什么? + +答:如果这个变量是新的变量,那么它的作用域就是当前if语句所代表的代码块。注意,后续的else if子句和else子句也包含在当前的if语句代表的代码块之内。 + +30. 请列举出你经常用到或者看到的3个错误类型,它们所在的错误类型体系都是怎样的?你能画出一棵树来描述它们吗? + +答:略。这需要你自己去做,我代替不了你。 + +31. 请列举出你经常用到或者看到的3个错误值,它们分别在哪个错误值列表里?这些错误值列表分别包含的是哪个种类的错误? + +答:略。这需要你自己去做,我代替不了你。 + +32. 一个函数怎样才能把panic转化为error类型值,并将其作为函数的结果值返回给调用方? + +答:可以这样编写: + +func doSomething() (err error) { + defer func() { + p := recover() + err = fmt.Errorf("FATAL ERROR: %s", p) + }() + panic("Oops!!") +} + + +注意结果声明的写法。这是一个带有名称的结果声明。 + +33. 我们可以在defer函数中恢复panic,那么可以在其中引发panic吗? + +答:当然可以。这样做可以把原先的panic包装一下再抛出去。 + +Go程序的测试 + +34. 除了本文中提到的,你还知道或用过testing.T类型和testing.B类型的哪些方法?它们都是做什么用的? + +答:略。这需要你自己去做,我代替不了你。 + +35. 在编写示例测试函数的时候,我们怎样指定预期的打印内容? + +答:这个问题的答案就在testing代码包的文档中。 + +36. -benchmem标记和-benchtime标记的作用分别是什么? + +答:-benchmem标记的作用是在性能测试完成后打印内存分配统计信息。-benchtime标记的作用是设定测试函数的执行时间上限。 + +具体请看这里的文档。 + +37. 怎样在测试的时候开启测试覆盖度分析?如果开启,会有什么副作用吗? + +答:go test命令可以接受-cover标记。该标记的作用就是开启测试覆盖度分析。不过,由于覆盖度分析开启之后go test命令可能会在程序被编译之前注释掉一部分源代码,所以,若程序编译或测试失败,那么错误报告可能会记录下与原始的源代码不对应的行号。 + +标准库的用法 + +38. 你知道互斥锁和读写锁的指针类型都实现了哪一个接口吗? + +答:它们都实现了sync.Locker接口。 + +39. 怎样获取读写锁中的读锁? + +答:sync.RWMutex类型有一个名为RLocker的指针方法可以获取其读锁。 + +40. *sync.Cond类型的值可以被传递吗?那sync.Cond类型的值呢? + +答:sync.Cond类型的值一旦被使用就不应该再被传递了,传递往往意味着拷贝。拷贝一个已经被使用过的sync.Cond值是很危险的,因为在这份拷贝上调用任何方法都会立即引发 panic。但是它的指针值是可以被拷贝的。 + +41. sync.Cond类型中的公开字段L是做什么用的?我们可以在使用条件变量的过程中改变这个字段的值吗? + +答:这个字段代表的是当前的sync.Cond值所持有的那个锁。我们可以在使用条件变量的过程中改变该字段的值,但是在改变之前一定要搞清楚这样做的影响。 + +42. 如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么? + +答:我觉得首先需要考虑下面几个问题。 + + +被保护的数据是什么类型的?是值类型的还是引用类型的? +操作被保护数据的方式是怎样的?是简单的读和写还是更复杂的操作? +操作被保护数据的代码是集中的还是分散的?如果是分散的,是否可以变为集中的? + + +在搞清楚上述问题(以及你关注的其他问题)之后,优先使用原子值。 + +43. 在使用WaitGroup值实现一对多的goroutine协作流程时,怎样才能让分发子任务的goroutine获得各个子任务的具体执行结果? + +答:可以考虑使用锁+容器(数组、切片或字典等),也可以考虑使用通道。另外,你或许也可以用上golang.org/x/sync/errgroup代码包中的程序实体,相应的文档在这里。 + +44. Context值在传达撤销信号的时候是广度优先的还是深度优先的?其优势和劣势都是什么? + +答:它是深度优先的。其优势和劣势都是:直接分支的产生时间越早,其中的所有子节点就会越先接收到信号。至于什么时候是优势、什么时候是劣势还要看具体的应用场景。 + +例如,如果子节点的存续时间与资源的消耗是正相关的,那么这可能就是一个优势。但是,如果每个分支中的子节点都很多,而且各个分支中的子节点的产生顺序并不依从于分支的产生顺序,那么这种优势就很可能会变成劣势。最终的定论还是要看测试的结果。 + +45. 怎样保证一个临时对象池中总有比较充足的临时对象? + +答:首先,我们应该事先向临时对象池中放入足够多的临时对象。其次,在用完临时对象之后,我们需要及时地把它归还给临时对象池。 + +最后,我们应该保证它的New字段所代表的值是可用的。虽然New函数返回的临时对象并不会被放入池中,但是起码能够保证池的Get方法总能返回一个临时对象。 + +46. 关于保证并发安全字典中的键和值的类型正确性,你还能想到其他的方案吗? + +答:这是一道开放的问题,需要你自己去思考。其实怎样做完全取决于你的应用场景。不过,我们应该尽量避免使用反射,因为它对程序性能还是有一定的影响的。 + +47. 判断一个Unicode字符是否为单字节字符通常有几种方式? + +答:unicode/utf8代码包中有几个可以做此判断的函数,比如:RuneLen函数、EncodeRune函数等。我们需要根据输入的不同来选择和使用它们。具体可以查看该代码包的文档。 + +48. strings.Builder和strings.Reader都分别实现了哪些接口?这样做有什么好处吗? + +答:strings.Builder类型实现了3个接口,分别是:fmt.Stringer、io.Writer和io.ByteWriter。 + +而strings.Reader类型则实现了8个接口,即:io.Reader、io.ReaderAt、io.ByteReader、io.RuneReader、io.Seeker、io.ByteScanner、io.RuneScanner和io.WriterTo。 + +好处是显而易见的。实现的接口越多,它们的用途就越广。它们会适用于那些要求参数的类型为这些接口类型的地方。 + +49. 对比strings.Builder和bytes.Buffer的String方法,并判断哪一个更高效?原因是什么? + +答:strings.Builder的String方法更高效。因为该方法只对其所属值的内容容器(那个字节切片)做了简单的类型转换,并且直接使用了底层的值(或者说内存空间)。它的源码如下: + +// String returns the accumulated string. +func (b *Builder) String() string { + return *(*string)(unsafe.Pointer(&b.buf)) +} + + +数组值和字符串值在底层的存储方式其实是一样的。所以从切片值到字符串值的指针值的转换可以是直截了当的。又由于字符串值是不可变的,所以这样做也是安全的。 + +不过,由于一些历史、结构和功能方面的原因,bytes.Buffer的String方法却不能这样做。 + +50. io包中的同步内存管道的运作机制是什么? + +答:我们实际上已经在正文中做了基本的说明。 + +io.Pipe函数会返回一个io.PipeReader类型的值和一个io.PipeWriter类型的值,并将它们分别作为管道的两端。而这两个值在底层其实只是代理了同一个*io.pipe类型值的功能而已。 + +io.pipe类型通过无缓冲的通道实现了读操作与写操作之间的同步,并且通过互斥锁实现了写操作之间的串行化。另外,它还使用原子值来处理错误。这些共同保证了这个同步内存管道的并发安全性。 + +51. bufio.Scanner类型的主要功用是什么?它有哪些特点? + +答:bufio.Scanner类型俗称带缓存的扫描器。它的功能还是比较强大的。 + +比如,我们可以自定义每次扫描的边界,或者说内容的分段方法。我们在调用它的Scan方法对目标进行扫描之前,可以先调用其Split方法并传入一个函数来自定义分段方法。 + +在默认情况下,扫描器会以行为单位对目标内容进行扫描。bufio代码包提供了一些现成的分段方法。实际上,扫描器在默认情况下会使用bufio.ScanLines函数作为分段方法。 + +又比如,我们还可以在扫描之前自定义缓存的载体和缓存的最大容量,这需要调用它的Buffer方法。在默认情况下,扫描器内部设定的最大缓存容量是64K个字节。 + +换句话说,目标内容中的每一段都不能超过64K个字节。否则,扫描器就会使它的Scan方法返回false,并通过其Err方法给予我们一个表示“token too long”的错误值。这里的“token”代表的就是一段内容。 + +关于bufio.Scanner类型的更多特点和使用注意事项,你可以通过它的文档获得。 + +52. 怎样通过os包中的API创建和操纵一个系统进程? + +答:你可以从os包的FindProcess函数和StartProcess函数开始。前者用于通过进程ID(pid)查找进程,后者用来基于某个程序启动一个进程。 + +这两者都会返回一个*os.Process类型的值。该类型提供了一些方法,比如,用于杀掉当前进程的Kill方法,又比如,可以给当前进程发送系统信号的Signal方法,以及会等待当前进程结束的Wait方法。 + +与此相关的还有os.ProcAttr类型、os.ProcessState类型、os.Signal类型,等等。你可以通过积极的实践去探索更多的玩法。 + +53. 怎样在net.Conn类型的值上正确地设定针对读操作和写操作的超时时间? + +答:net.Conn类型有3个可用于设置超时时间的方法,分别是:SetDeadline、SetReadDeadline和SetWriteDeadline。 + +这三个方法的签名是一模一样的,只是名称不同罢了。它们都接受一个time.Time类型的参数,并都会返回一个error类型的结果。其中的SetDeadline方法是用来同时设置读操作超时和写操作超时的。 + +有一点需要特别注意,这三个方法都会针对任何正在进行以及未来将要进行的相应操作进行超时设定。 + +因此,如果你要在一个循环中进行读操作或写操作的话,最好在每次迭代中都进行一次超时设定。 + +否则,靠后的操作就有可能因触达超时时间而直接失败。另外,如果有必要,你应该再次调用它们并传入time.Time类型的零值来表达不再限定超时时间。 + +54. 怎样优雅地停止基于HTTP协议的网络服务程序? + +答:net/http.Server类型有一个名为Shutdown的指针方法可以实现“优雅的停止”。也就是说,它可以在不中断任何正处在活动状态的连接的情况下平滑地关闭当前的服务器。 + +它会先关闭所有的空闲连接,并一直等待。只有活动的连接变为空闲之后,它才会关闭它们。当所有的连接都被平滑地关闭之后,它会关闭当前的服务器并返回。当有错误发生时,它还会把相应的错误值返回。 + +另外,你还可以通过调用Server值的RegisterOnShutdown方法来注册可以在服务器即将关闭时被自动调用的函数。 + +更确切地说,当前服务器的Shutdown方法会以异步的方式调用如此注册的所有函数。我们可以利用这样的函数来通知长连接的客户端“连接即将关闭”。 + +55. runtime/trace代码包的功用是什么? + +答:简单来说,这个代码包是用来帮助Go程序实现内部跟踪操作的。其中的程序实体可以帮助我们记录程序中各个goroutine的状态、各种系统调用的状态,与GC有关的各种事件,以及内存相关和CPU相关的变化,等等。 + +通过它们生成的跟踪记录可以通过go tool trace命令来查看。更具体的说明可以参看runtime/trace代码包的文档。 + +有了runtime/trace代码包,我们就可以为Go程序加装上可以满足个性化需求的跟踪器了。Go语言标准库中有的代码包正是通过使用该包实现了自身的功能,例如net/http/pprof包。 + +好了,全部的思考题答案已经更新完了,你如果还有疑问,可以给我留言。祝你新春快乐,学习愉快。再见。 + +戳此查看Go语言专栏文章配套详细代码。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/00开篇词从0开始搭建一个企业级Go应用.md b/专栏/Go语言项目开发实战/00开篇词从0开始搭建一个企业级Go应用.md new file mode 100644 index 0000000..6c5b8fd --- /dev/null +++ b/专栏/Go语言项目开发实战/00开篇词从0开始搭建一个企业级Go应用.md @@ -0,0 +1,90 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 从 0 开始搭建一个企业级 Go 应用 + 你好,我是孔令飞,很高兴能在这里和你聊聊如何用 Go 构建企业级应用。 + +在过去的 5 年里,我一直在腾讯使用 Go 做大型企业级项目。比如说,腾讯云云函数 SCF、腾讯游戏容器平台 TenC、腾讯游戏微服务中台等。目前,我在腾讯云负责容器服务 TKE 的相关研发工作,专注于云原生混合云领域的基础架构开发。 + +“云”是大势所趋,而Go是云时代的语言 + +最近几年,我发现腾讯很多团队的开发语言都在转 Go。其实,不光腾讯,像阿里、华为和百度这类国内一线大厂也都在积极转 Go。甚至不少团队,所有项目都是用 Go 构建的。伴随而来的,就是各个公司对Go研发工程师的需求越来越旺盛。那么, Go 为什么会变得这么火热呢?我认为,原因主要有两个方面。 + +一方面,Go 是一门非常优秀的语言,它具有很多核心优势,例如:语言简单、语言层面支持并发编程、跨平台编译和自带垃圾回收机制等,这些优势是这些团队选择 Go 最根本的原因。 + +另一方面,也因为 Go 是云时代的语言。为什么这么说呢?下面,我来详细说说。 + +随着云计算平台的逐渐成熟,应用上云已经成为一个不可逆转的趋势了,很多公司都选择将基础架构/业务架构云化,例如阿里、腾讯都在将公司内部业务全面云化。可以说,全面云化已经是公司层面的核心 KPI了,我们甚至可以理解为以后所有的技术都会围绕着云来构建。 + +而云目前是朝着云原生架构的方向演进的,云原生架构中具有统治力(影响力)的项目绝大部分又是用 Go 构建的。我们从下面这张云原生技术栈语言组成图中可以看到,有 63% 的具有统治力的云原生项目都是用 Go 来构建的。 + + + + +完整的云原生技术栈可参考云原生技术图谱 + + +因此,想要把基础架构/业务架构云化,离不开对这些云原生开源项目的学习、改造。而一个团队为了节省成本,技术栈最好统一。既然我们一定要会 Go,而且 Go 这么优秀,那最好的方式就是将整个团队的语言技术栈 all in Go,这也是 Go 为什么重要的另一个原因了。 + +那么,我们用 Go 做什么呢,当然是项目开发。但很多开发者在用 Go 进行项目开发时会面临一系列问题。 + +学习 Go 项目开发面临哪些问题? + +我带过不少刚接触 Go 语言的开发者,他们为了学习 Go 项目开发,会上网搜很多 Go 相关的技术文章,也确实花了很多时间去学习。但是,当我做 Code Review 时,发现他们开发的代码仍然存在很多问题。 + +比如说,有个开发者写的代码依赖数据库连接,没法写单元测试。细问之后,我发现他参考的文章没有将数据库层跟业务层通过接口解耦。 + +再比如说,还有一些开发者开发的项目很难维护,项目中出现了大量的 common、util、const 这类 Go 包。只看包名,我完全不知道包所实现的功能,问了之后才发现他是参考了一个带有 dao、model、controller、service 目录的、不符合 Go 设计哲学的项目。 + +而这些问题其实只是冰山一角,总的来说,我们在学习 Go 项目开发时会面临以下4大类问题。 + + +知识盲区:Go 项目开发会涉及很多知识点,但自己对这些知识点却一无所知。想要学习,却发现网上很多文章结构混乱、讲解不透彻。想要搜索一遍优秀的文章,又要花费很多时间,劳神劳力。 +学不到最佳实践,能力提升有限:网上有很多文章会介绍 Go 项目的构建方法,但很多都不是最佳实践,学完之后不能在能力和认知上带来最佳提升,还要自己花时间整理学习,事倍功半。 +不知道如何完整地开发一个 Go 项目:学了很多 Go 开发相关的知识点、构建方法,但都不体系、不全面、不深入。学完之后,自己并不能把它们有机结合成一个 Go 项目研发体系,真正开发的时候还是一团乱,效率也很低。 +缺乏一线项目练手,很难检验学习效果:为了避免闭门造车,我们肯定想学习一线大厂的大型项目构建和研发经验,来检验自己的学习成果,但自己平时又很难接触到,没有这样的学习途径。 + + +为了解决这些问题,我设计了《Go 语言项目开发实战》这个专栏,希望帮助你成为一名优秀的Go开发者,在职场中建立自己的核心竞争力。 + +这个专栏是如何设计的? + +《Go 语言项目开发实战》这个专栏又是如何解决上述问题的呢?在这个专栏里,我会围绕一个可部署、可运行的企业应用源码,为你详细讲解实际开发流程中会涉及的技能点,让你彻底学会如何构建企业级 Go 应用,并解决 Go 项目开发所面临的各类问题。 + +一方面,你能够从比较高的视野俯瞰整个 Go 企业应用开发流程,不仅知道一个优秀的企业应用涉及的技能点和开发工作,还能知道如何高效地完成每个阶段的开发工作。另一方面,你能够深入到每个技能点,掌握它们的具体构建方法、业界的最佳实践和一线开发经验。 + +最后我还想强调一点,除了以上内容,专栏最终还会交付给你一套优秀、可运行的企业应用代码。这套代码能够满足绝大部分的企业应用开发场景,你可以基于它做二次开发,快速构建起你的企业应用。 + +说了这么多,我们到底能学到哪些技能点呢?我按照开发顺序把它们总结在下面这张图中,图中包含了Go项目开发中大部分技能点。 + + + +除此之外,专栏中的每个技能点我都会尽可能朝着“最佳实践”的方向去设计。例如,我使用的 Go 包都是业界采纳度最高的包,而且设计时,我也会尽可能遵循 Go 设计模式、Go 开发规范、Go 最佳实践、go clean architecture 等。同时,我也会尽量把我自己做一线Go项目研发的经验,融合到讲解的过程中,给你最靠谱的建议,这些经验和建议可以让你在构建应用的过程中,少走很多弯路。 + +为了让你更好地学习这门课程,我把整个专栏划分为了6个模块。其中,第1个模块是实战环境准备,第2到第6个模块我会带着你按照研发的流程来实际构建一个应用。 + +实战准备:我会先手把手带你准备一个实验环境,再带你部署我们的实战项目。加深你对实战项目的理解的同时,给你讲解一些部署的技能点,包括如何准备开发环境、制作CA证书,安装和配置用到的数据库、应用,以及Shell脚本编写技巧等。 + +实战第 1 站:规范设计:我会详细介绍开发中常见的10大规范,例如目录规范、日志规范、错误码规范、Commit规范等。通过本模块,你能够学会如何设计常见的规范,为高效开发一个高质量、易阅读、易维护的 Go 应用打好基础。 + +实战第 2 站:基础功能设计或开发:我会教你设计或开发一些Go应用开发中的基础功能,这些功能会影响整个应用的构建方式,例如日志包、错误包、错误码等。 + +实战第 3 站:服务开发:我会带你一起解析一个企业级的Go项目代码,让你学会如何开发Go应用。在解析的过程中,我也会详细讲解Go开发阶段的各个技能点,例如怎么设计和开发API服务、Go SDK、客户端工具等。 + +实战第 4 站:服务测试:我会围绕实战项目来讲解进行单元测试、功能测试、性能分析和性能调优的方法,最终让你交付一个性能和稳定性都经过充分测试的、生产级可用的服务。 + +实战第 5 站:服务部署:本模块通过实战项目的部署,来告诉你如何部署一个高可用、安全、具备容灾能力,又可以轻松水平扩展的企业应用。这里,我会重点介绍 2 种部署方式:传统部署方式和容器化部署方式,每种方式在部署方法、复杂度和能力上都有所不同。 + +最后,关于怎么学习这个专栏,我还想给你一些建议。 + +第一,我建议你先学习这个专栏的图文内容,再详细去读源码。学习过程中如果产生一些想法可以通过修改代码,并查看运行结果的方式来加以验证。这个专栏的代码,我都放在GitHub上,你可以点击这个链接查看。 + +第二,在专栏中,我不会详细去介绍每行代码,只会挑选一些核心代码来讲。一些没有讲到的地方,如果有疑问,你一定要在评论区留言,因为这个专栏我就是要带你攻克开发过程中的所有难题,千万不要让小问题积攒成大难题,那真的得不偿失。我可以承诺的是,留言回复可能会迟到,但绝不会缺席。 + +好啦,从现在开始,让我们一起开启这场充满挑战的Go项目实战旅途,为真正开发出一个优秀的企业级Go应用,成为一个Go资深开发者,一起努力吧! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/01IAM系统概述:我们要实现什么样的Go项目?.md b/专栏/Go语言项目开发实战/01IAM系统概述:我们要实现什么样的Go项目?.md new file mode 100644 index 0000000..d0da115 --- /dev/null +++ b/专栏/Go语言项目开发实战/01IAM系统概述:我们要实现什么样的Go项目?.md @@ -0,0 +1,165 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 IAM系统概述:我们要实现什么样的 Go 项目? + 你好,我是孔令飞。从今天开始我们进入课前准备阶段,我会用3讲的时间给你讲清楚,我们要实现的实战项目 IAM 应用长啥样、它能干什么,以及怎么把它部署到 Linux 服务器上。先和我一起扫除基础的障碍,你就能够更轻松地学习后面的课程了。 + +今天这一讲,我先来说说为什么选择 IAM 应用,它能实现什么功能,以及它的架构和使用流程。 + +项目背景:为什么选择 IAM 系统作为实战项目? + +我们在做 Go 项目开发时,绕不开的一个话题是安全,如何保证 Go 应用的安全,是每个开发者都要解决的问题。虽然 Go 应用的安全包含很多方面,但大体可分为如下 2 类: + + +服务自身的安全:为了保证服务的安全,需要禁止非法用户访问服务。这可以通过服务器层面和软件层面来解决。服务器层面可以通过物理隔离、网络隔离、防火墙等技术从底层保证服务的安全性,软件层面可以通过 HTTPS、用户认证等手段来加强服务的安全性。服务器层面一般由运维团队来保障,软件层面则需要开发者来保障。 +服务资源的安全:服务内有很多资源,为了避免非法访问,开发者要避免 UserA 访问到 UserB 的资源,也即需要对资源进行授权。通常,我们可以通过资源授权系统来对资源进行授权。 + + +总的来说,为了保障Go应用的安全,我们需要对访问进行认证,对资源进行授权。那么,我们要如何实现访问认证和资源授权呢? + +认证功能不复杂,我们可以通过 JWT (JSON Web Token)认证来实现。授权功能比较复杂,授权功能的复杂性使得它可以囊括很多 Go 开发技能点。因此,在这个专栏中,我将认证和授权的功能实现升级为 IAM 系统,通过讲解它的构建过程,给你讲清楚 Go 项目开发的全部流程。 + +IAM 系统是什么? + +IAM(Identity and Access Management,身份识别与访问管理)系统是用 Go 语言编写的一个 Web 服务,用于给第三方用户提供访问控制服务。 + +IAM 系统可以帮用户解决的问题是:在特定的条件下,谁能够/不能够对哪些资源做哪些操作(Who is able to do what on something given some context),也即完成资源授权功能。 + + +提示:以后我们在提到 IAM 系统或者 IAM 时都是指代 IAM 应用。 + + +那么,IAM 系统是如何进行资源授权的呢?下面,我们通过 IAM 系统的资源授权的流程,来看下它是如何工作的,整个过程可以分为 4 步。 + + + + +用户需要提供昵称、密码、邮箱、电话等信息注册并登录到 IAM 系统,这里是以用户名和密码作为唯一的身份标识来访问 IAM 系统,并且完成认证。 +因为访问 IAM 的资源授权接口是通过密钥(secretID/secretKey)的方式进行认证的,所以用户需要在 IAM 中创建属于自己的密钥资源。 +因为 IAM 通过授权策略完成授权,所以用户需要在 IAM 中创建授权策略。 +请求 IAM 提供的授权接口,IAM 会根据用户的请求内容和授权策略来决定一个授权请求是否被允许。 + + +我们可以看到,在上面的流程中,IAM 使用到了 3 种系统资源:用户(User)、密钥(Secret)和策略(Policy),它们映射到程序设计中就是 3 种 RESTful 资源: + + +用户(User):实现对用户的增、删、改、查、修改密码、批量修改等操作。 +密钥(Secret):实现对密钥的增、删、改、查操作。 +策略(Policy):实现对策略的增、删、改、查、批量删除操作。 + + +IAM 系统的架构长啥样? + +知道了 IAM 的功能之后,我们再来详细说说 IAM 系统的架构,架构图如下: + + + +总的来说,IAM 架构中包括 9 大组件和 3 大数据库。我将这些组件和功能都总结在下面的表格中。这里面,我们主要记住5个核心组件,包括iam-apiserver、iam-authz-server、iam-pump、marmotedu-sdk-go和iamctl的功能,还有3个数据库Redis、MySQL和MongoDB的功能。 + + + +此外,IAM 系统为存储数据使用到的 3 种数据库的说明如下所示。- + + +通过使用流程理解架构 + +只看到这样的系统架构图和核心功能讲解,你可能还不清楚整个系统是如何协作,来最终完成资源授权的。所以接下来,我们通过详细讲解 IAM 系统的使用流程及其实现细节,来进一步加深你对 IAM 架构的理解。总的来说,我们可以通过 4 步去使用 IAM 系统的核心功能。 + +第1步,创建平台资源。 + +用户通过 iam-webconsole(RESTful API)或 iamctl(sdk marmotedu-sdk-go)客户端请求 iam-apiserver 提供的 RESTful API 接口完成用户、密钥、授权策略的增删改查,iam-apiserver 会将这些资源数据持久化存储在 MySQL 数据库中。而且,为了确保通信安全,客户端访问服务端都是通过 HTTPS 协议来访问的。 + +第2步,请求 API 完成资源授权。 + +用户可以通过请求 iam-authz-server 提供的 /v1/authz 接口进行资源授权,请求 /v1/authz 接口需要通过密钥认证,认证通过后 /v1/authz 接口会查询授权策略,从而决定资源请求是否被允许。 + +为了提高 /v1/authz 接口的性能,iam-authz-server 将密钥和策略信息缓存在内存中,以便实现快速查询。那密钥和策略信息是如何实现缓存的呢? + +首先,iam-authz-server 通过调用 iam-apiserver 提供的 gRPC 接口,将密钥和授权策略信息缓存到内存中。同时,为了使内存中的缓存信息和 iam-apiserver 中的信息保持一致,当 iam-apiserver 中有密钥或策略被更新时,iam-apiserver 会往特定的 Redis Channel(iam-authz-server 也会订阅该 Channel)中发送 PolicyChanged 和 SecretChanged 消息。这样一来,当 iam-authz-server 监听到有新消息时就会获取并解析消息,根据消息内容判断是否需要重新调用 gRPC 接来获取密钥和授权策略信息,再更新到内存中。 + +第3步,授权日志数据分析。 + +iam-authz-server 会将授权日志上报到 Redis 高速缓存中,然后 iam-pump 组件会异步消费这些授权日志,再把清理后的数据保存在 MongoDB 中,供运营系统 iam-operating-system 查询。 + +这里还有一点你要注意:iam-authz-server 将授权日志保存在 Redis 高性能 key-value 数据库中,可以最大化减少写入延时。不保存在内存中是因为授权日志量我们没法预测,当授权日志量很大时,很可能会将内存耗尽,造成服务中断。 + +第4步,运营平台授权数据展示。 + +iam-operating-system 是 IAM 的运营系统,它可以通过查询 MongoDB 获取并展示运营数据,比如某个用户的授权/失败次数、授权失败时的授权信息等。此外,我们也可以通过 iam-operating-system 调用 iam-apiserver 服务来做些运营管理工作。比如,以上帝视角查看某个用户的授权策略供排障使用,或者调整用户可创建密钥的最大个数,再或者通过白名单的方式,让某个用户不受密钥个数限制的影响等等。 + +IAM 软件架构模式 + +在设计软件时,我们首先要做的就是选择一种软件架构模式,它对软件后续的开发方式、软件维护成本都有比较大的影响。因此,这里我也会和你简单聊聊 2 种最常用的软件架构模式,分别是前后端分离架构和 MVC 架构。 + +前后端分离架构 + +因为IAM 系统采用的就是前后端分离的架构,所以我们就以 IAM 的运营系统 iam-operating-system 为例来详细说说这个架构。一般来说,运营系统的功能可多可少,对于一些具有复杂功能的运营系统,我们可以采用前后端分离的架构。其中,前端负责页面的展示以及数据的加载和渲染,后端只负责返回前端需要的数据。 + +iam-operating-system 前后端分离架构如下图所示。 + + + +采用了前后端分离架构之后,当你通过浏览器请求前端 ops-webconsole 时,ops-webconsole 会先请求静态文件服务器加载静态文件,比如 HTML、CSS 和 JavaScript,然后它会执行 JavaScript,通过负载均衡请求后端数据,最后把后端返回的数据渲染到前端页面中。 + +采用前后端分离的架构,让前后端通过 RESTful API 通信,会带来以下5点好处: + + +可以让前、后端人员各自专注在自己业务的功能开发上,让专业的人做专业的事,来提高代码质量和开发效率 +前后端可并行开发和发布,这也能提高开发和发布效率,加快产品迭代速度 +前后端组件、代码分开,职责分明,可以增加代码的维护性和可读性,减少代码改动引起的 Bug 概率,同时也能快速定位 Bug +前端 JavaScript 可以处理后台的数据,减少对后台服务器的压力 +可根据需要选择性水平扩容前端或者后端来节约成本 + + +MVC 架构 + +但是,如果运营系统功能比较少,采用前后端分离框架的弊反而大于利,比如前后端分离要同时维护 2 个组件会导致部署更复杂,并且前后端分离将人员也分开了,这会增加一定程度的沟通成本。同时,因为代码中也需要实现前后端交互的逻辑,所以会引入一定的开发量。 + +这个时候,我们可以尝试直接采用 MVC 软件架构,MVC 架构如下图所示。 + + + +MVC 的全名是 Model View Controller,它是一种架构模式,分为 Model、View、Controller 三层,每一层的功能如下: + + +View(视图):提供给用户的操作界面,用来处理数据的显示。 +Controller(控制器):根据用户从 View 层输入的指令,选取 Model 层中的数据,然后对其进行相应的操作,产生最终结果。 +Model(模型):应用程序中用于处理数据逻辑的部分。 + + +MVC 架构的好处是通过控制器层将视图层和模型层分离之后,当更改视图层代码后时,我们就不需要重新编译控制器层和模型层的代码了。同样,如果业务流程发生改变也只需要变更模型层的代码就可以。在实际开发中为了更好的 UI 效果,视图层需要经常变更,但是通过 MVC 架构,在变更视图层时,我们根本不需要对业务逻辑层的代码做任何变化,这不仅减少了风险还能提高代码变更和发布的效率。 + +除此之外,还有一种跟 MVC 比较相似的软件开发架构叫三层架构,它包括UI 层、BLL 层和DAL 层。其中,UI 层表示用户界面,BLL 层表示业务逻辑,DAL 层表示数据访问。在实际开发中很多人将 MVC 当成三层架构在用,比如说,很多人喜欢把软件的业务逻辑放在 Controller 层里,将数据库访问操作的代码放在 Model 层里,软件最终的代码放在 View 层里,就这样硬生生将 MVC 架构变成了伪三层架构。 + +这种代码不仅不伦不类,同时也失去了三层架构和 MVC 架构的核心优势,也就是:通过 Controller层将 Model层和 View层解耦,从而使代码更容易维护和扩展。因此在实际开发中,我们也要注意遵循 MVC 架构的开发规范,发挥 MVC 的核心价值。 + +总结 + +一个好的 Go 应用必须要保证应用的安全性,这可以通过认证和授权来保障。也因此认证和授权是开发一个 Go 项目必须要实现的功能。为了帮助你实现这 2 个功能,并借此机会学习 Go 项目开发,我将这 2 个功能升级为一个 IAM系统。通过讲解如何开发 IAM 系统,来教你如何开发 Go 项目。 + +因为后面的学习都是围绕 IAM 系统展开的,所以这一讲我们要重点掌握 IAM 的功能、架构和使用流程,我们可以通过 4 步使用流程来了解。 + +首先,用户通过调用 iam-apiserver 提供的 RESTful API 接口完成注册和登录系统,再调用接口创建密钥和授权策略。 + +创建完密钥对和授权策略之后,IAM 可以通过调用 iam-authz-server 的授权接口完成资源的授权。具体来说,iam-authz-server 通过 gRPC 接口获取 iam-apiserver 中存储的密钥和授权策略信息,通过 JWT 完成认证之后,再通过 ory/ladon 包完成资源的授权。 + +接着,iam-pump 组件异步消费 Redis 中的数据,并持久化存储在 MongoDB 中,供 iam-operating-system 运营平台展示。 + +最后,IAM 相关的产品、研发人员可以通过 IAM 的运营系统 iam-operating-system 来查看 IAM 系统的使用情况,进行运营分析。例如某个用户的授权/失败次数、授权失败时的授权信息等。 + +另外,为了提高开发和访问效率,IAM 分别提供了 marmotedu-sdk-go SDK 和 iamctl 命令行工具,二者通过 HTTPS 协议访问 IAM 提供的 RESTful 接口。 + +课后练习 + + +你在做 Go 项目开发时经常用到哪些技能点?有些技能点是 IAM 没有包含的? +在你所接触的项目中,哪些是前后端分离架构,哪些是 MVC 架构呢?你觉得项目采用的架构是否合理呢? + + +期待在留言区看到你的思考和分享,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/02环境准备:如何安装和配置一个基本的Go开发环境?.md b/专栏/Go语言项目开发实战/02环境准备:如何安装和配置一个基本的Go开发环境?.md new file mode 100644 index 0000000..717bf9f --- /dev/null +++ b/专栏/Go语言项目开发实战/02环境准备:如何安装和配置一个基本的Go开发环境?.md @@ -0,0 +1,313 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 环境准备:如何安装和配置一个基本的 Go 开发环境? + 你好,我是孔令飞。 + +上一讲我们讲了 IAM 系统的功能和架构,接下来的两讲,我们就将它部署到你的服务器上。不过,在正式部署之前,我们还需要准备一个 Go 开发环境,这是因为我们是通过编译源码来获取部署需要的二进制文件的。 + +因此,今天这一讲,我先手把手带你配置好一个 Go 的开发环境,供你以后开发、编译用,下一讲再带你部署 IAM 系统。 + +想要配置一个 Go 开发环境,我们可以通过以下 4 步实现: + + +Linux 服务器申请和配置 +依赖安装和配置 +Go 编译环境安装和配置 +Go 开发 IDE 安装和配置 + + +Linux 服务器申请和配置 + +毫无疑问,要安装一个 Go 开发环境,你首先需要有一个 Linux 服务器。Linux 服务器有很多操作系统可供选择,例如:CentOS、Ubuntu、RHEL、Debian 等,但目前生产环境用得最多的还是 CentOS 系统,为了跟生产环境保持一致,我们选择当前最新的 CentOS 版本:CentOS 8.2。 + +因为本专栏的所有操作都是在 CentOS 8.2 系统上进行的,为了避免环境不一致导致的操作失败,我建议你也使用 CentOS 8.2。安装一个 Linux 服务器需要两步:服务器申请和配置。 + +Linux 服务器申请 + +我们可以通过以下 3 种方式来安装一个 CentOS 8.2 系统。 + + +在物理机上安装一个 CentOS 8.2 系统。 +在 Windows/MacBook 上安装虚拟机管理软件,用虚拟机管理软件创建 CentOS 8.2 虚拟机。其中,Windows 建议用 VMWare Workstation 来创建虚拟机,MacBook 建议用 VirtualBox 来创建虚拟机。 +在诸如腾讯云、阿里云、华为云等平台上购买一个虚拟机,并预装 CentOS 8.2 系统。 + + +Linux 服务器配置 + +申请完 Linux 服务之后,我们需要通过 SecureCRT 或 Xshell 等工具登录 Linux 服务器,并对服务器做一些简单必要的配置,包括创建普通用户、添加 sudoers、配置 $HOME/.bashrc 文件。接下来,我们一一来说。 + +第一步,用Root 用户登录Linux 系统,并创建普通用户。 + +一般来说,一个项目会由多个开发人员协作完成,为了节省企业成本,公司不会给每个开发人员都配备一台服务器,而是让所有开发人员共用一个开发机,通过普通用户登录开发机进行开发。因此,为了模拟真实的企业开发环境,我们也通过一个普通用户的身份来进行项目的开发,创建方法如下: + +# useradd going # 创建 going 用户,通过 going 用户登录开发机进行开发 +# passwd going # 设置密码 +Changing password for user going. +New password: +Retype new password: +passwd: all authentication tokens updated successfully. + + +不仅如此,使用普通用户登录和操作开发机也可以保证系统的安全性,这是一个比较好的习惯,所以我们在日常开发中也要尽量避免使用 Root 用户。 + +第二步,添加 sudoers。 + +我们知道很多时候,普通用户也要用到 Root 的一些权限,但 Root 用户的密码一般是由系统管理员维护并定期更改的,每次都向管理员询问密码又很麻烦。因此,我建议你将普通用户加入到 sudoers 中,这样普通用户就可以通过 sudo 命令来暂时获取 Root 的权限。具体来说,你可以执行如下命令添加: + +# sed -i '/^root.*ALL=(ALL).*ALL/a\going\tALL=(ALL) \tALL' /etc/sudoers + + +第三步,替换 CentOS 8.4 系统中自带的 Yum 源。 + +由于 Red Hat 提前宣布 CentOS 8 于 2021 年 12 月 31 日停止维护,官方的 Yum 源已不可使用,所以需要切换官方的 Yum 源,这里选择阿里提供的 Yum 源。切换命令如下: + +# mv /etc/yum.repos.d /etc/yum.repos.d.bak # 先备份原有的 Yum 源 +# mkdir /etc/yum.repos.d +# wget -O /etc/yum.repos.d/CentOS-Base.repo https://mirrors.aliyun.com/repo/Centos-vault-8.5.2111.repo +# yum clean all && yum makecache + + +第四步,用新的用户名(going)和密码登录Linux 服务器。这一步也可以验证普通用户是否创建成功。 + +第五步,配置 $HOME/.bashrc 文件。 + +我们登录新服务器后的第一步就是配置 \(HOME/.bashrc 文件,以使 Linux 登录 shell 更加易用,例如配置 `LANG` 解决中文乱码,配置 `PS1` 可以避免整行都是文件路径,并将 `\)HOME/bin加入到PATH` 路径中。配置后的内容如下: + +# .bashrc + +# User specific aliases and functions + +alias rm='rm -i' +alias cp='cp -i' +alias mv='mv -i' + +# Source global definitions +if [ -f /etc/bashrc ]; then + . /etc/bashrc +fi + +# User specific environment +# Basic envs +export LANG="en_US.UTF-8" # 设置系统语言为 en_US.UTF-8,避免终端出现中文乱码 +export PS1='[\u@dev \W]\$ ' # 默认的 PS1 设置会展示全部的路径,为了防止过长,这里只展示:"用户名@dev 最后的目录名" +export WORKSPACE="$HOME/workspace" # 设置工作目录 +export PATH=$HOME/bin:$PATH # 将 $HOME/bin 目录加入到 PATH 变量中 + +# Default entry folder +cd $WORKSPACE # 登录系统,默认进入 workspace 目录 + + +有一点需要我们注意,在 export PATH 时,最好把 $PATH 放到最后,因为我们添加到目录中的命令是期望被优先搜索并使用的。配置完 $HOME/.bashrc 后,我们还需要创建工作目录 workspace。将工作文件统一放在 $HOME/workspace 目录中,有几点好处。 + + +可以使我们的$HOME目录保持整洁,便于以后的文件查找和分类。 +如果哪一天 /分区空间不足,可以将整个 workspace 目录 mv 到另一个分区中,并在 /分区中保留软连接,例如:/home/going/workspace -> /data/workspace/。 +如果哪天想备份所有的工作文件,可以直接备份 workspace。 + + +具体的操作指令是$ mkdir -p $HOME/workspace。配置好 $HOME/.bashrc 文件后,我们就可以执行 bash 命令将配置加载到当前 shell 中了。 + +至此,我们就完成了 Linux 开发机环境的申请及初步配置。 + +依赖安装和配置 + +在 Linux 系统上安装 IAM 系统会依赖一些 RPM 包和工具,有些是直接依赖,有些是间接依赖。为了避免后续的操作出现依赖错误,例如,因为包不存在而导致的编译、命令执行错误等,我们先统一依赖安装和配置。安装和配置步骤如下。 + +第一步,安装依赖。 + +首先,我们在 CentOS 系统上通过 yum 命令来安装所需工具的依赖,安装命令如下: + +$ sudo yum -y install make autoconf automake cmake perl-CPAN libcurl-devel libtool gcc gcc-c++ glibc-headers zlib-devel git-lfs telnet lrzsz jq expat-devel openssl-devel + + +虽然有些 CentOS 8.2 系统已经默认安装这些依赖了,但是为了确保它们都能被安装,我仍然会尝试安装一遍。如果系统提示 Package xxx is already installed.,说明已经安装好了,你直接忽略即可。 + +第二步,安装 Git。 + +因为安装 IAM 系统、执行 go get 命令、安装 protobuf 工具等都是通过 Git 来操作的,所以接下来我们还需要安装 Git。由于低版本的 Git 不支持--unshallow 参数,而 go get 在安装 Go 包时会用到 git fetch --unshallow 命令,因此我们要确保安装一个高版本的 Git,具体的安装方法如下: + +$ cd /tmp +$ wget --no-check-certificate https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.36.1.tar.gz +$ tar -xvzf git-2.36.1.tar.gz +$ cd git-2.36.1/ +$ ./configure +$ make +$ sudo make install +$ git --version # 输出 git 版本号,说明安装成功 +git version 2.36.1 + + +注意啦,按照上面的步骤安装好之后,我们要把 Git 的二进制目录添加到 PATH 路径中,不然 Git 可能会因为找不到一些命令而报错。你可以通过执行以下命令添加目录: + +tee -a $HOME/.bashrc <<'EOF' +# Configure for git +export PATH=/usr/local/libexec/git-core:$PATH +EOF + + +第三步,配置 Git。我们直接执行如下命令配置 Git: + +$ git config --global user.name "Lingfei Kong" # 用户名改成自己的 +$ git config --global user.email "[email protected]" # 邮箱改成自己的 +$ git config --global credential.helper store # 设置 Git,保存用户名和密码 +$ git config --global core.longpaths true # 解决 Git 中 'Filename too long' 的错误 + + +除了按照上述步骤配置 Git 之外,我们还有几点需要注意。 + +首先,在 Git 中,我们会把非 ASCII 字符叫做 Unusual 字符。这类字符在 Git 输出到终端的时候默认是用 8 进制转义字符输出的(以防乱码),但现在的终端多数都支持直接显示非 ASCII 字符,所以我们可以关闭掉这个特性,具体的命令如下: + +$ git config --global core.quotepath off + + +其次,GitHub 限制最大只能克隆 100M 的单个文件,为了能够克隆大于 100M 的文件,我们还需要安装 Git Large File Storage,安装方式如下: + +$ git lfs install --skip-repo + + +好啦,现在我们就完成了依赖的安装和配置。 + +Go 编译环境安装和配置 + +我们知道,Go 是一门编译型语言,所以在部署 IAM 系统之前,我们需要将代码编译成可执行的二进制文件。因此我们需要安装 Go 编译环境。 + +除了 Go,我们也会用 gRPC 框架展示 RPC 通信协议的用法,所以我们也需要将 ProtoBuf 的.proto 文件编译成 Go 语言的接口。因此,我们也需要安装 ProtoBuf 的编译环境。 + +Go 编译环境安装和配置 + +安装 Go 语言相对来说比较简单,我们只需要下载源码包、设置相应的环境变量即可。 + +首先,我们从 Go 语言官方网站下载对应的 Go 安装包以及源码包,这里我下载的是 go1.18.3 版本: + +$ wget -P /tmp/ https://golang.google.cn/dl/go1.18.3.linux-amd64.tar.gz + + +接着,我们完成解压和安装,命令如下: + +$ mkdir -p $HOME/go +$ tar -xvzf /tmp/go1.18.3.linux-amd64.tar.gz -C $HOME/go +$ mv $HOME/go/go $HOME/go/go1.18.3 + + +接着,我们执行以下命令,将下列环境变量追加到$HOME/.bashrc 文件中。 + +$ tee -a $HOME/.bashrc <<'EOF' +# Go envs +export GOVERSION=go1.18.3 # Go 版本设置 +export GO_INSTALL_DIR=$HOME/go # Go 安装目录 +export GOROOT=$GO_INSTALL_DIR/$GOVERSION # GOROOT 设置 +export GOPATH=$WORKSPACE/golang # GOPATH 设置 +export PATH=$GOROOT/bin:$GOPATH/bin:$PATH # 将 Go 语言自带的和通过 go install 安装的二进制文件加入到 PATH 路径中 +export GO111MODULE="on" # 开启 Go moudles 特性 +export GOPROXY=https://goproxy.cn,direct # 安装 Go 模块时,代理服务器设置 +export GOPRIVATE= +export GOSUMDB=off # 关闭校验 Go 依赖包的哈希值 +EOF + + +为什么要增加这么多环境变量呢?这是因为,Go 语言是通过一系列的环境变量来控制 Go 编译器行为的。因此,我们一定要理解每一个环境变量的含义。 + + + +因为 Go 以后会用 Go modules 来管理依赖,所以我建议你将 GO111MODULE 设置为 on。 + +在使用模块的时候,$GOPATH 是无意义的,不过它还是会把下载的依赖储存在 $GOPATH/pkg/mod 目录中,也会把 go install 的二进制文件存放在 $GOPATH/bin 目录中。 + +另外,我们还要将$GOPATH/bin、$GOROOT/bin 加入到 Linux 可执行文件搜索路径中。这样一来,我们就可以直接在 bash shell 中执行 go 自带的命令,以及通过 go install 安装的命令。 + +然后就是进行测试了,如果我们执行 go version 命令可以成功输出 Go 的版本,就说明 Go 编译环境安装成功。具体的命令如下: + +$ bash +$ go version +go version go1.18.3 linux/amd64 + + +最后,初始化工作区。 + +本专栏使用的 Go 版本为 go1.18.3,go1.18.3 支持多模块工作区,所以这里也需要初始化工作区。初始化命令如下: + +$ mkdir -p $GOPATH && cd $GOPATH +$ go work init +$ go env GOWORK # 执行此命令,查看 go.work 工作区文件路径 +/home/going/workspace/golang/go.work + + +ProtoBuf 编译环境安装 + +接着,我们再来安装 protobuf 的编译器 protoc。protoc 需要 protoc-gen-go 来完成 Go 语言的代码转换,因此我们需要安装 protoc 和 protoc-gen-go 这 2 个工具。它们的安装方法比较简单,你直接看我下面给出的代码和操作注释就可以了。 + +# 第一步:安装 protobuf +$ cd /tmp/ +$ git clone -b v3.21.1 --depth=1 https://github.com/protocolbuffers/protobuf +$ cd protobuf +$ ./autogen.sh +$ ./configure +$ make +$ sudo make install +$ protoc --version # 查看 protoc 版本,成功输出版本号,说明安装成功 +libprotoc 3.21.1 + +# 第二步:安装 protoc-gen-go +$ go install github.com/golang/protobuf/[email protected] + + +当你第一次执行 go install 命令的时候,因为本地无缓存,所以需要下载所有的依赖模块。因此安装速度会比较慢,请你耐心等待。 + +Go 开发 IDE 安装和配置 + +编译环境准备完之后,你还需要一个代码编辑器才能开始 Go 项目开发。为了提高开发效率,你还需要将这个编辑器配置成 Go IDE。 + +目前,GoLand、VSCode 这些 IDE 都很优秀,但它们都是 Windows 系统下的 IDE。在 Linux 系统下我们可以选择将 Vim 配置成 Go IDE。熟练 Vim IDE 操作之后,开发效率不输 GoLand 和 VSCode。有多种方法可以配置一个Vim IDE,这里我选择使用 vim-go 将 Vim 配置成一个 Go IDE。vim-go 是社区比较受欢迎的 Vim Go 开发插件,可以用来方便地将一个 Vim 配置成 Vim IDE。 + +Vim IDE 的安装和配置分为以下2步。 + +第一步,安装 vim-go。 + +安装命令如下: + +$ rm -f $HOME/.vim; mkdir -p ~/.vim/pack/plugins/start/ +$ git clone --depth=1 https://github.com/fatih/vim-go.git ~/.vim/pack/plugins/start/vim-go + + +第二步,Go 工具安装。 + +vim-go 会用到一些 Go 工具,比如在函数跳转时会用到 guru、godef 工具,在格式化时会用到 goimports,所以你也需要安装这些工具。安装方式如下:执行 vi /tmp/test.go,然后输入 :GoInstallBinaries 安装 vim-go 需要的工具。 + +安装后的 Go IDE 常用操作的按键映射如下表所示: + + + +总结 + +这一讲,我们一起安装和配置了一个 Go 开发环境,为了方便你回顾,我将安装和配置过程绘制成了一个流程图,如下所示。 + + + +有了这个开发环境,接下来我们就可以在学习的过程中随时进行编码,来熟悉和验证知识点了,所以你一定要在学习后面的课程之前先完成这一讲的部署。 + +课后练习 + + +试着编写一个 main.go,在 main 函数中打印 Hello World,并执行 go run main.go 运行代码,测试 Go 开发环境。 +试着编写一个 main.go,代码如下: + + +package main + +import "fmt" + +func main() { + fmt.Println("Hello World") +} + + +将鼠标放在 Println 上,键入 Enter 键跳转到函数定义处,键入 Ctrl + I 返回到跳转点。 + +期待在留言区看到你的思考和答案,也欢迎和我一起探讨环境安装过程中的问题,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/03项目部署:如何快速部署IAM系统?.md b/专栏/Go语言项目开发实战/03项目部署:如何快速部署IAM系统?.md new file mode 100644 index 0000000..a2f75ee --- /dev/null +++ b/专栏/Go语言项目开发实战/03项目部署:如何快速部署IAM系统?.md @@ -0,0 +1,937 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 项目部署:如何快速部署 IAM 系统? + 你好,我是孔令飞。 + +上一讲,我们一起安装和配置了一个基本的 Go 开发环境。这一讲,我就来教你怎么在它的基础上,快速部署好 IAM 系统。 + +因为我们要通过一个 IAM 项目来讲解怎么开发企业级 Go 项目,所以你要对 IAM 项目有比较好的了解,了解 IAM 项目一个最直接有效的方式就是去部署和使用它。 + +这不仅能让你了解到 IAM 系统中各个组件功能之间的联系,加深你对 IAM 系统的理解,还可以协助你排障,尤其是跟部署相关的故障。此外,部署好 IAM 系统也能给你后面的学习准备好实验环境,边学、边练,从而提高你的学习效率。 + +所以,今天我们专门花一讲的时间来说说怎么部署和使用 IAM 系统。同时,因为 IAM 系统是一个企业级的项目,有一定的复杂度,我建议你严格按照我说的步骤去操作,否则可能会安装失败。 + +总的来说,我把部署过程分成 2 大步。 + + +安装和配置数据库:我们需要安装和配置 MariaDB、Redis和MongoDB。 +安装和配置 IAM 服务:我们需要安装和配置 iam-apiserver、iam-authz-server、iam-pump、iamctl和man 文件。 + + +当然啦,如果你实在不想这么麻烦地去安装,我也在这一讲的最后给出了一键部署 IAM 系统的方法,但我还是希望你能按照我今天讲的详细步骤来操作。 + +那话不多说,我们直接开始操作吧! + +下载 iam 项目代码 + +因为 IAM 的安装脚本存放在 iam 代码仓库中,安装需要的二进制文件也需要通过 iam 代码构建,所以在安装之前,我们需要先下载 iam 代码: + +$ mkdir -p $WORKSPACE/golang/src/github.com/marmotedu +$ cd $WORKSPACE/golang/src/github.com/marmotedu +$ git clone --depth=1 https://github.com/marmotedu/iam +$ go work use ./iam + + +其中,marmotedu 和 marmotedu/iam 目录存放了本实战项目的代码,在学习过程中,你需要频繁访问这 2 个目录,为了访问方便,我们可以追加如下 2 个环境变量和 2 个 alias 到$HOME/.bashrc 文件中: + +$ tee -a $HOME/.bashrc << 'EOF' +# Alias for quick access +export GOSRC="$WORKSPACE/golang/src" +export IAM_ROOT="$GOSRC/github.com/marmotedu/iam" +alias mm="cd $GOSRC/github.com/marmotedu" +alias i="cd $GOSRC/github.com/marmotedu/iam" +EOF +$ bash + + +之后,你就可以先通过执行 alias 命令 mm 访问 $GOSRC/github.com/marmotedu 目录;通过执行 alias 命令 i 访问 $GOSRC/github.com/marmotedu/iam 目录。 + +这里我也建议你善用 alias,将常用操作配置成 alias,方便以后操作。 + +在安装配置之前需要执行以下命令export going用户的密码,这里假设密码是 iam59!z$: + +export LINUX_PASSWORD='iam59!z$' + + + +安装和配置数据库 + +因为 IAM 系统用到了 MariaDB、Redis、MongoDB 数据库来存储数据,而 IAM 服务在启动时会先尝试连接这些数据库,所以为了避免启动时连接数据库失败,这里我们先来安装需要的数据库。 + +安装和配置 MariaDB + +IAM 会把 REST 资源的定义信息存储在关系型数据库中,关系型数据库我选择了 MariaDB。为啥选择 MariaDB,而不是 MySQL呢?。选择 MariaDB 一方面是因为它是发展最快的 MySQL 分支,相比 MySQL,它加入了很多新的特性,并且它能够完全兼容 MySQL,包括 API 和命令行。另一方面是因为 MariaDB 是开源的,而且迭代速度很快。 + +首先,我们可以通过以下命令安装和配置 MariaDB,并将 Root 密码设置为 iam59!z$: + +$ cd $IAM_ROOT +$ ./scripts/install/mariadb.sh iam::mariadb::install + + +然后,我们可以通过以下命令,来测试 MariaDB 是否安装成功: + +$ mysql -h127.0.0.1 -uroot -p'iam59!z$' +MariaDB [(none)]> + + +安装和配置 Redis + +在 IAM 系统中,由于 iam-authz-server 是从 iam-apiserver 拉取并缓存用户的密钥/策略信息的,因此同一份密钥/策略数据会分别存在 2 个服务中,这可能会出现数据不一致的情况。数据不一致会带来一些问题,例如当我们通过 iam-apiserver 创建了一对密钥,但是这对密钥还没有被 iam-authz-server 缓存,这时候通过这对密钥访问 iam-authz-server 就会访问失败。 + +为了保证数据的一致性,我们可以使用 Redis 的发布订阅(pub/sub)功能进行消息通知。同时,iam-authz-server 也会将授权审计日志缓存到 Redis 中,所以也需要安装 Redis key-value 数据库。我们可以通过以下命令来安装和配置 Redis,并将 Redis 的初始密码设置为 iam59!z$ : + +$ cd $IAM_ROOT +$ ./scripts/install/redis.sh iam::redis::install + + +这里我们要注意,scripts/install/redis.sh 脚本中 iam::redis::install 函数对 Redis 做了一些配置,例如修改 Redis 使其以守护进程的方式运行、修改 Redis 的密码为 iam59!z$等,详细配置可参考函数 iam::redis::install 函数。 + +安装完成后,我们可以通过以下命令,来测试 Redis 是否安装成功: + + $ redis-cli -h 127.0.0.1 -p 6379 -a 'iam59!z$' # 连接 Redis,-h 指定主机,-p 指定监听端口,-a 指定登录密码 + + + +安装和配置 MongoDB + +因为 iam-pump 会将 iam-authz-server 产生的数据处理后存储在 MongoDB 中,所以我们也需要安装 MongoDB 数据库。主要分两步安装:首先安装 MongoDB,然后再创建 MongoDB 账号。 + +第 1 步,安装 MongoDB + +首先,我们可以通过以下 4 步来安装 MongoDB。 + + +配置 MongoDB yum 源,并安装 MongoDB。 + + +CentOS8.x 系统默认没有配置安装 MongoDB 需要的 yum 源,所以我们需要先配置好 yum 源再安装: + +$ sudo tee /etc/yum.repos.d/mongodb-org-5.0.repo<<'EOF' +[mongodb-org-5.0] +name=MongoDB Repository +baseurl=https://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/5.0/x86_64/ +gpgcheck=1 +enabled=1 +gpgkey=https://www.mongodb.org/static/pgp/server-5.0.asc +EOF + +$ sudo yum install -y mongodb-org + + + +关闭 SELinux。 + + +在安装的过程中,SELinux 有可能会阻止 MongoDB 访问/sys/fs/cgroup,所以我们还需要关闭 SELinux: + +$ sudo setenforce 0 +$ sudo sed -i 's/^SELINUX=.*$/SELINUX=disabled/' /etc/selinux/config # 永久关闭 SELINUX + + + +开启外网访问权限和登录验证。 + + +MongoDB 安装完之后,默认情况下是不会开启外网访问权限和登录验证,为了方便使用,我建议你先开启这些功能,执行如下命令开启: + +$ sudo sed -i '/bindIp/{s/127.0.0.1/0.0.0.0/}' /etc/mongod.conf +$ sudo sed -i '/^#security/a\security:\n authorization: enabled' /etc/mongod.conf + + + +启动 MongoDB。 + + +配置完 MongoDB 之后,我们就可以启动它了,具体的命令如下: + +$ sudo systemctl start mongod +$ sudo systemctl enable mongod # 设置开机启动 +$ sudo systemctl status mongod # 查看 mongod 运行状态,如果输出中包含 active (running)字样说明 mongod 成功启动 + + + +安装完 MongoDB 后,我们就可以通过 mongo 命令登录 MongoDB Shell。如果没有报错,就说明 MongoDB 被成功安装了。 + +$ mongosh --quiet "mongodb://127.0.0.1:27017" +test> + + +第 2 步,创建 MongoDB 账号 + +安装完 MongoDB 之后,默认是没有用户账号的,为了方便 IAM 服务使用,我们需要先创建好管理员账号,通过管理员账户登录 MongoDB,我们可以执行创建普通用户、数据库等操作。 + + +创建管理员账户。 + + +首先,我们通过 use admin 指令切换到 admin 数据库,再通过 db.auth("用户名","用户密码") 验证用户登录权限。如果返回 1 表示验证成功;如果返回 0 表示验证失败。具体的命令如下: + +$ mongosh --quiet "mongodb://127.0.0.1:27017" +test> use admin +switched to db admin +admin> db.createUser({user:"root",pwd:"iam59!z$",roles:["root"]}) +{ ok: 1 } +admin> db.auth("root", "iam59!z$") +{ ok: 1 } + + +此外,如果想删除用户,可以使用 db.dropUser("用户名") 命令。 + +db.createUser 用到了以下 3 个参数。 + + +user: 用户名。 +pwd: 用户密码。 +roles: 用来设置用户的权限,比如读、读写、写等。 + + +因为 admin 用户具有 MongoDB 的 Root 权限,权限过大安全性会降低。为了提高安全性,我们还需要创建一个 iam 普通用户来连接和操作 MongoDB。 + + +创建 iam 用户,命令如下: + + +$ mongosh --quiet mongodb://root:'iam59!z$'@127.0.0.1:27017/iam_analytics?authSource=admin # 用管理员账户连接 MongoDB +iam_analytics> db.createUser({user:"iam",pwd:"iam59!z$",roles:["dbOwner"]}) +{ ok: 1 } +iam_analytics> db.auth("iam", "iam59!z$") +{ ok: 1 } + + +创建完 iam 普通用户后,我们就可以通过 iam 用户登录 MongoDB 了: + +$ mongosh --quiet mongodb://iam:'iam59!z$'@127.0.0.1:27017/iam_analytics?authSource=iam_analytics + + +至此,我们成功安装了 IAM 系统需要的数据库 MariaDB、Redis 和 MongoDB。 + +安装和配置 IAM 系统 + +要想完成 IAM 系统的安装,我们还需要安装和配置 iam-apiserver、iam-authz-server、iam-pump 和 iamctl。这些组件的功能我们在第1讲详细讲过,如果不记得你可以翻回去看看。 + + +提示:IAM 项目我会长期维护、定期更新,欢迎你 Star & Contributing。 + + +准备工作 + +在开始安装之前,我们需要先做一些准备工作,主要有 5 步。 + + +初始化 MariaDB 数据库,创建 iam 数据库。 +配置 scripts/install/environment.sh。 +创建需要的目录。 +创建 CA 根证书和密钥。 +配置 hosts。 + + +第 1 步,初始化 MariaDB 数据库,创建 iam 数据库。 + +安装完 MariaDB 数据库之后,我们需要在 MariaDB 数据库中创建 IAM 系统需要的数据库、表和存储过程,以及创建 SQL 语句保存在 IAM 代码仓库中的 configs/iam.sql 文件中。具体的创建步骤如下。 + + +登录数据库并创建 iam 用户。 + + +$ cd $IAM_ROOT +$ mysql -h127.0.0.1 -P3306 -uroot -p'iam59!z$' # 连接 MariaDB,-h 指定主机,-P 指定监听端口,-u 指定登录用户,-p 指定登录密码 +MariaDB [(none)]> grant all on iam.* TO [email protected] identified by 'iam59!z$'; +Query OK, 0 rows affected (0.000 sec) +MariaDB [(none)]> flush privileges; +Query OK, 0 rows affected (0.000 sec) + + + +用 iam 用户登录 MariaDB,执行 iam.sql 文件,创建 iam 数据库。 + + +$ mysql -h127.0.0.1 -P3306 -uiam -p'iam59!z$' +MariaDB [(none)]> source configs/iam.sql; +MariaDB [iam]> show databases; ++--------------------+ +| Database | ++--------------------+ +| iam | +| information_schema | ++--------------------+ +2 rows in set (0.000 sec) + + +上面的命令会创建 iam 数据库,并创建以下数据库资源。 + + +表:user 是用户表,用来存放用户信息;secret 是密钥表,用来存放密钥信息;policy 是策略表,用来存放授权策略信息;policy_audit 是策略历史表,被删除的策略会被转存到该表。 +admin 用户:在 user 表中,我们需要创建一个管理员用户,用户名是 admin,密码是 Admin@2021。 +存储过程:删除用户时会自动删除该用户所属的密钥和策略信息。 + + +第 2 步,配置 scripts/install/environment.sh。 + +IAM 组件的安装配置都是通过环境变量文件 scripts/install/environment.sh 进行配置的,所以我们要先配置好 scripts/install/environment.sh 文件。这里,你可以直接使用默认值,提高你的安装效率。 + +第 3 步,创建需要的目录。 + +在安装和运行 IAM 系统的时候,我们需要将配置、二进制文件和数据文件存放到指定的目录。所以我们需要先创建好这些目录,创建步骤如下。 + +$ cd $IAM_ROOT +$ source scripts/install/environment.sh +$ sudo mkdir -p ${IAM_DATA_DIR}/{iam-apiserver,iam-authz-server,iam-pump} # 创建 Systemd WorkingDirectory 目录 +$ sudo mkdir -p ${IAM_INSTALL_DIR}/bin #创建 IAM 系统安装目录 +$ sudo mkdir -p ${IAM_CONFIG_DIR}/cert # 创建 IAM 系统配置文件存放目录 +$ sudo mkdir -p ${IAM_LOG_DIR} # 创建 IAM 日志文件存放目录 + + +第 4 步, 创建 CA 根证书和密钥。 + +为了确保安全,IAM 系统各组件需要使用 x509 证书对通信进行加密和认证。所以,这里我们需要先创建 CA 证书。CA 根证书是所有组件共享的,只需要创建一个 CA 证书,后续创建的所有证书都由它签名。 + +我们可以使用 CloudFlare 的 PKI 工具集 cfssl 来创建所有的证书。 + + +安装 cfssl 工具集。 + + +我们可以直接安装 cfssl 已经编译好的二进制文件,cfssl 工具集中包含很多工具,这里我们需要安装 cfssl、cfssljson、cfssl-certinfo,功能如下。 + + +cfssl:证书签发工具。 +cfssljson:将 cfssl 生成的证书(json 格式)变为文件承载式证书。 + + +这两个工具的安装方法如下: + +$ cd $IAM_ROOT +$ ./scripts/install/install.sh iam::install::install_cfssl + + + +创建配置文件。 + + +CA 配置文件是用来配置根证书的使用场景 (profile) 和具体参数 (usage、过期时间、服务端认证、客户端认证、加密等),可以在签名其它证书时用来指定特定场景: + +$ cd $IAM_ROOT +$ tee ca-config.json << EOF +{ + "signing": { + "default": { + "expiry": "87600h" + }, + "profiles": { + "iam": { + "usages": [ + "signing", + "key encipherment", + "server auth", + "client auth" + ], + "expiry": "876000h" + } + } + } +} +EOF + + +上面的 JSON 配置中,有一些字段解释如下。 + + +signing:表示该证书可用于签名其它证书(生成的 ca.pem 证书中 CA=TRUE)。 +server auth:表示 client 可以用该证书对 server 提供的证书进行验证。 +client auth:表示 server 可以用该证书对 client 提供的证书进行验证。 +expiry:876000h,证书有效期设置为 100 年。 + + + +创建证书签名请求文件。 + + +我们创建用来生成 CA 证书签名请求(CSR)的 JSON 配置文件: + +$ cd $IAM_ROOT +$ tee ca-csr.json << EOF +{ + "CN": "iam-ca", + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "CN", + "ST": "BeiJing", + "L": "BeiJing", + "O": "marmotedu", + "OU": "iam" + } + ], + "ca": { + "expiry": "876000h" + } +} +EOF + + +上面的 JSON 配置中,有一些字段解释如下。 + + +C:Country,国家。 +ST:State,省份。 +L:Locality (L) or City,城市。 +CN:Common Name,iam-apiserver 从证书中提取该字段作为请求的用户名 (User Name) ,浏览器使用该字段验证网站是否合法。 +O:Organization,iam-apiserver 从证书中提取该字段作为请求用户所属的组 (Group)。 +OU:Company division (or Organization Unit – OU),部门/单位。 + + +除此之外,还有两点需要我们注意。 + + +不同证书 csr 文件的 CN、C、ST、L、O、OU 组合必须不同,否则可能出现 PEER'S CERTIFICATE HAS AN INVALID SIGNATURE 错误。 +后续创建证书的 csr 文件时,CN、OU都不相同(C、ST、L、O相同),以达到区分的目的。 + + + +创建 CA 证书和私钥 + + +首先,我们通过 cfssl gencert 命令来创建: + +$ cd $IAM_ROOT +$ source scripts/install/environment.sh +$ cfssl gencert -initca ca-csr.json | cfssljson -bare ca +$ ls ca* +ca-config.json ca.csr ca-csr.json ca-key.pem ca.pem +$ sudo mv ca* ${IAM_CONFIG_DIR}/cert # 需要将证书文件拷贝到指定文件夹下(分发证书),方便各组件引用 + + +上述命令会创建运行 CA 所必需的文件 ca-key.pem(私钥)和 ca.pem(证书),还会生成 ca.csr(证书签名请求),用于交叉签名或重新签名。 + +创建完之后,我们可以通过 cfssl certinfo 命名查看 cert 和 csr 信息: + +$ cfssl certinfo -cert ${IAM_CONFIG_DIR}/cert/ca.pem # 查看 cert(证书信息) +$ cfssl certinfo -csr ${IAM_CONFIG_DIR}/cert/ca.csr # 查看 CSR(证书签名请求)信息 + + +第 5 步,配置 hosts。 + +iam 通过域名访问 API 接口,因为这些域名没有注册过,还不能在互联网上解析,所以需要配置 hosts,具体的操作如下: + +$ sudo tee -a /etc/hosts < iam-apiserver.yaml +$ sudo mv iam-apiserver.yaml ${IAM_CONFIG_DIR} + + + +创建并安装 iam-apiserver systemd unit 文件: + + +$ ./scripts/genconfig.sh scripts/install/environment.sh init/iam-apiserver.service > iam-apiserver.service +$ sudo mv iam-apiserver.service /etc/systemd/system/ + + + +启动 iam-apiserver 服务: + + +$ sudo systemctl daemon-reload +$ sudo systemctl enable iam-apiserver +$ sudo systemctl restart iam-apiserver +$ systemctl status iam-apiserver # 查看 iam-apiserver 运行状态,如果输出中包含 active (running)字样说明 iam-apiserver 成功启动 + + +第 3 步,测试 iam-apiserver 是否成功安装。 + +测试 iam-apiserver 主要是测试 RESTful 资源的 CURD:用户 CURD、密钥 CURD、授权策略 CURD。 + +首先,我们需要获取访问 iam-apiserver 的 Token,请求如下 API 访问: + +$ curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login | jq -r .token +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA + + +代码中下面的 HTTP 请求通过-H'Authorization: Bearer ' 指定认证头信息,将上面请求的 Token 替换 。 + +用户 CURD + +创建用户、列出用户、获取用户详细信息、修改用户、删除单个用户、批量删除用户,请求方法如下: + +# 创建用户 +$ curl -s -XPOST -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' -d'{"password":"User@2021","metadata":{"name":"colin"},"nickname":"colin","email":"[email protected]","phone":"1812884xxxx"}' http://127.0.0.1:8080/v1/users + +# 列出用户 +$ curl -s -XGET -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' 'http://127.0.0.1:8080/v1/users?offset=0&limit=10' + +# 获取 colin 用户的详细信息 +$ curl -s -XGET -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/users/colin + +# 修改 colin 用户 +$ curl -s -XPUT -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' -d'{"nickname":"colin","email":"[email protected]","phone":"1812884xxxx"}' http://127.0.0.1:8080/v1/users/colin + +# 删除 colin 用户 +$ curl -s -XDELETE -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/users/colin + +# 批量删除用户 +$ curl -s -XDELETE -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' 'http://127.0.0.1:8080/v1/users?name=colin&name=mark&name=john' + + +密钥 CURD + +创建密钥、列出密钥、获取密钥详细信息、修改密钥、删除密钥请求方法如下: + +# 创建 secret0 密钥 +$ curl -s -XPOST -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' -d'{"metadata":{"name":"secret0"},"expires":0,"description":"admin secret"}' http://127.0.0.1:8080/v1/secrets + +# 列出所有密钥 +$ curl -s -XGET -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/secrets + +# 获取 secret0 密钥的详细信息 +$ curl -s -XGET -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/secrets/secret0 + +# 修改 secret0 密钥 +$ curl -s -XPUT -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' -d'{"metadata":{"name":"secret0"},"expires":0,"description":"admin secret(modified)"}' http://127.0.0.1:8080/v1/secrets/secret0 + +# 删除 secret0 密钥 +$ curl -s -XDELETE -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/secrets/secret0 + + +这里我们要注意,因为密钥属于重要资源,被删除会导致所有的访问请求失败,所以密钥不支持批量删除。 + +授权策略 CURD + +创建策略、列出策略、获取策略详细信息、修改策略、删除策略请求方法如下: + +# 创建策略 +$ curl -s -XPOST -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' -d'{"metadata":{"name":"policy0"},"policy":{"description":"One policy to rule them all.","subjects":["users:","users:maria","groups:admins"],"actions":["delete",""],"effect":"allow","resources":["resources:articles:<.*>","resources:printer"],"conditions":{"remoteIPAddress":{"type":"CIDRCondition","options":{"cidr":"192.168.0.1/16"}}}}}' http://127.0.0.1:8080/v1/policies + +# 列出所有策略 +$ curl -s -XGET -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/policies + +# 获取 policy0 策略的详细信息 +$ curl -s -XGET -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/policies/policy0 + +# 修改 policy0 策略 +$ curl -s -XPUT -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' -d'{"metadata":{"name":"policy0"},"policy":{"description":"One policy to rule them all(modified).","subjects":["users:","users:maria","groups:admins"],"actions":["delete",""],"effect":"allow","resources":["resources:articles:<.*>","resources:printer"],"conditions":{"remoteIPAddress":{"type":"CIDRCondition","options":{"cidr":"192.168.0.1/16"}}}}}' http://127.0.0.1:8080/v1/policies/policy0 + +# 删除 policy0 策略 +$ curl -s -XDELETE -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MTc5MjI4OTQsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MTc4MzY0OTQsInN1YiI6ImFkbWluIn0.9qztVJseQ9XwqOFVUHNOtG96-KUovndz0SSr_QBsxAA' http://127.0.0.1:8080/v1/policies/policy0 + + + +安装 iamctl + +上面,我们安装了 iam 系统的 API 服务。但是想要访问 iam 服务,我们还需要安装客户端工具 iamctl。具体来说,我们可以通过 3 步完成 iamctl 的安装和配置。 + +第 1 步,创建 iamctl 证书和私钥。 + +iamctl 使用 https 协议与 iam-apiserver 进行安全通信,iam-apiserver 对 iamctl 请求包含的证书进行认证和授权。iamctl 后续用于 iam 系统访问和管理,所以这里创建具有最高权限的 admin 证书。 + + +创建证书签名请求。 + + +下面创建的证书只会被 iamctl 当作 client 证书使用,所以 hosts 字段为空。代码如下: + +$ cd $IAM_ROOT +$ source scripts/install/environment.sh +$ cat > admin-csr.json < iamctl.yaml +$ mkdir -p $HOME/.iam +$ mv iamctl.yaml $HOME/.iam + + +因为 iamctl 是一个客户端工具,可能会在多台机器上运行。为了简化部署 iamctl 工具的复杂度,我们可以把 config 配置文件中跟 CA 认证相关的 CA 文件内容用 base64 加密后,放置在 config 配置文件中。具体的思路就是把 config 文件中的配置项 client-certificate、client-key、certificate-authority 分别用如下配置项替换 client-certificate-data、client-key-data、certificate-authority-data。这些配置项的值可以通过对 CA 文件使用 base64 加密获得。 + +假如,certificate-authority 值为/etc/iam/cert/ca.pem,则 certificate-authority-data 的值为 cat "/etc/iam/cert/ca.pem" | base64 | tr -d '\r\n',其它-data 变量的值类似。这样当我们再部署 iamctl 工具时,只需要拷贝 iamctl 和配置文件,而不用再拷贝 CA 文件了。 + +第 3 步,测试 iamctl 是否成功安装。 + +执行 iamctl user list 可以列出预创建的 admin 用户,如下图所示: + + + +安装和配置 iam-authz-server + +接下来,我们需要安装另外一个核心组件:iam-authz-server,可以通过以下 3 步来安装。 + +第 1 步,创建 iam-authz-server 证书和私钥。 + + +创建证书签名请求: + + +$ cd $IAM_ROOT +$ source scripts/install/environment.sh +$ tee iam-authz-server-csr.json < iam-authz-server.yaml +$ sudo mv iam-authz-server.yaml ${IAM_CONFIG_DIR} + + + +创建并安装 iam-authz-server systemd unit 文件: + + +$ ./scripts/genconfig.sh scripts/install/environment.sh init/iam-authz-server.service > iam-authz-server.service +$ sudo mv iam-authz-server.service /etc/systemd/system/ + + + +启动 iam-authz-server 服务: + + +$ sudo systemctl daemon-reload +$ sudo systemctl enable iam-authz-server +$ sudo systemctl restart iam-authz-server +$ systemctl status iam-authz-server # 查看 iam-authz-server 运行状态,如果输出中包含 active (running)字样说明 iam-authz-server 成功启动。 + + +第 3 步,测试 iam-authz-server 是否成功安装。 + + +重新登陆系统,并获取访问令牌 + + +$ token=`curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login | jq -r .token` + + + +创建授权策略 + + +$ curl -s -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer $token" -d'{"metadata":{"name":"authztest"},"policy":{"description":"One policy to rule them all.","subjects":["users:","users:maria","groups:admins"],"actions":["delete",""],"effect":"allow","resources":["resources:articles:<.*>","resources:printer"],"conditions":{"remoteIPAddress":{"type":"CIDRCondition","options":{"cidr":"192.168.0.1/16"}}}}}' http://127.0.0.1:8080/v1/policies + + + +创建密钥,并从命令的输出中提取secretID 和 secretKey + + +$ curl -s -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer $token" -d'{"metadata":{"name":"authztest"},"expires":0,"description":"admin secret"}' http://127.0.0.1:8080/v1/secrets +{"metadata":{"id":23,"name":"authztest","createdAt":"2021-04-08T07:24:50.071671422+08:00","updatedAt":"2021-04-08T07:24:50.071671422+08:00"},"username":"admin","secretID":"ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox","secretKey":"7Sfa5EfAPIwcTLGCfSvqLf0zZGCjF3l8","expires":0,"description":"admin secret"} + + + +生成访问 iam-authz-server 的 token + + +iamctl 提供了 jwt sigin 命令,可以根据 secretID 和 secretKey 签发 Token,方便你使用。 + +$ iamctl jwt sign ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox 7Sfa5EfAPIwcTLGCfSvqLf0zZGCjF3l8 # iamctl jwt sign $secretID $secretKey,替换成上一步创建的密钥对 +eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ + + +如果你的开发过程中有些重复性的操作,为了方便使用,也可以将这些操作以iamctl子命令的方式集成到iamctl命令行中。 + + +测试资源授权是否通过 + + +我们可以通过请求 /v1/authz 来完成资源授权: + +$ curl -s -XPOST -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ' -d'{"subject":"users:maria","action":"delete","resource":"resources:articles:ladon-introduction","context":{"remoteIPAddress":"192.168.0.5"}}' http://127.0.0.1:9090/v1/authz +{"allowed":true} + + +如果授权通过会返回:{"allowed":true} 。 + +安装和配置 iam-pump + +安装 iam-pump 步骤和安装 iam-apiserver、iam-authz-server 步骤基本一样,具体步骤如下。 + +第 1 步,安装 iam-pump 可执行程序。 + +$ cd $IAM_ROOT +$ source scripts/install/environment.sh +$ make build BINS=iam-pump +$ sudo cp _output/platforms/linux/amd64/iam-pump ${IAM_INSTALL_DIR}/bin + + +第 2 步,生成并安装 iam-pump 的配置文件(iam-pump.yaml)。 + +$ ./scripts/genconfig.sh scripts/install/environment.sh configs/iam-pump.yaml > iam-pump.yaml +$ sudo mv iam-pump.yaml ${IAM_CONFIG_DIR} + + +第 3 步,创建并安装 iam-pump systemd unit 文件。 + +$ ./scripts/genconfig.sh scripts/install/environment.sh init/iam-pump.service > iam-pump.service +$ sudo mv iam-pump.service /etc/systemd/system/ + + +第 4 步,启动 iam-pump 服务。 + +$ sudo systemctl daemon-reload +$ sudo systemctl enable iam-pump +$ sudo systemctl restart iam-pump +$ systemctl status iam-pump # 查看 iam-pump 运行状态,如果输出中包含 active (running)字样说明 iam-pump 成功启动。 + + +第 5 步,测试 iam-pump 是否成功安装。 + +$ curl http://127.0.0.1:7070/healthz +{"status": "ok"} + + +经过上面这 5 个步骤,如果返回 {“status”: “ok”} 就说明 iam-pump 服务健康。 + +安装 man 文件 + +IAM 系统通过组合调用包:github.com/cpuguy83/go-md2man/v2/md2man 和 github.com/spf13/cobra 的相关函数生成了各个组件的 man1 文件,主要分 3 步实现。 + +第 1 步,生成各个组件的 man1 文件。 + +$ cd $IAM_ROOT +$ ./scripts/update-generated-docs.sh + + +第 2 步,安装生成的 man1 文件。 + +$ sudo cp docs/man/man1/* /usr/share/man/man1/ + + +第 3 步,检查是否成功安装 man1 文件。 + +$ man iam-apiserver + + +执行 man iam-apiserver 命令后,会弹出 man 文档界面,如下图所示: + + + +至此,IAM 系统所有组件都已经安装成功了,你可以通过 iamctl version 查看客户端和服务端版本,代码如下: + +$ iamctl version -o yaml +clientVersion: + buildDate: "2021-04-08T01:56:20Z" + compiler: gc + gitCommit: 1d682b0317396347b568a3ef366c1c54b3b0186b + gitTreeState: dirty + gitVersion: v0.6.1-5-g1d682b0 + goVersion: go1.16.2 + platform: linux/amd64 +serverVersion: + buildDate: "2021-04-07T22:30:53Z" + compiler: gc + gitCommit: bde163964b8c004ebb20ca4abd8a2ac0cd1f71ad + gitTreeState: dirty + gitVersion: bde1639 + goVersion: go1.16.2 + platform: linux/amd64 + + + +总结 + +这一讲,我带你一步一步安装了 IAM 应用,完成安装的同时,也希望能加深你对 IAM 应用的理解,并为后面的实战准备好环境。为了更清晰地展示安装流程,这里我把整个安装步骤梳理成了一张脑图,你可以看看。 + + + +此外,我还有一点想提醒你,我们今天讲到的所有组件设置的密码都是 iam59!z$,你一定要记住啦。 + +课后练习 + +请你试着调用 iam-apiserver 提供的 API 接口创建一个用户:xuezhang,并在该用户下创建 policy 和 secret 资源。最后调用 iam-authz-server 提供的/v1/authz 接口进行资源鉴权。如果有什么有趣的发现,记得分享出来。 + +期待在留言区看到你的尝试,我们下一讲见! + + + +彩蛋:一键安装 + +如果学完了第02讲,你可以直接执行如下脚本,来完成 IAM 系统的安装: + +$ export LINUX_PASSWORD='iam59!z$' # 重要:这里要 export going 用户的密码 +$ version=latest && curl https://marmotedu-1254073058.cos.ap-beijing.myqcloud.com/iam-release/${version}/iam.tar.gz | tar -xz -C / tmp/ +$ cd /tmp/iam/ && ./scripts/install/install.sh iam::install::install + + + +此外,你也可以参考 IAM 部署指南 教程进行安装,这个安装手册可以让你在创建完普通用户后,一键部署整个 IAM 系统,包括实战环境和 IAM 服务。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/04规范设计(上):项目开发杂乱无章,如何规范?.md b/专栏/Go语言项目开发实战/04规范设计(上):项目开发杂乱无章,如何规范?.md new file mode 100644 index 0000000..b3502a6 --- /dev/null +++ b/专栏/Go语言项目开发实战/04规范设计(上):项目开发杂乱无章,如何规范?.md @@ -0,0 +1,346 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 规范设计(上):项目开发杂乱无章,如何规范? + 你好,我是孔令飞。今天,我们来聊聊开发应用中需要用到的那些规范。 + +无规矩不成方圆,生活如此,软件开发也是如此。一个应用基本都是多人协作开发的,但不同人的开发习惯、方式都不同。如果没有一个统一的规范,就会造成非常多的问题,比如: + + +代码风格不一:代码仓库中有多种代码风格,读/改他人的代码都是一件痛苦的事情,整个代码库也会看起来很乱。 +目录杂乱无章:相同的功能被放在不同的目录,或者一个目录你根本不知道它要完成什么功能,新开发的代码你也不知道放在哪个目录或文件。这些都会严重降低代码的可维护性。 +接口不统一:对外提供的 API 接口不统一,例如修改用户接口为/v1/users/colin,但是修改密钥接口为/v1/secret?name=secret0,难以理解和记忆。 +错误码不规范:错误码会直接暴露给用户,主要用于展示错误类型,以定位错误问题。错误码不规范会导致难以辨别错误类型,或者同类错误拥有不同错误码,增加理解难度。 + + +因此,在设计阶段、编码之前,我们需要一个好的规范来约束开发者,以确保大家开发的是“一个应用”。一个好的规范不仅可以提高软件质量,还可以提高软件的开发效率,降低维护成本,甚至能减少 Bug 数,也可以使你的开发体验如行云流水一般顺畅。所以,在编码之前,有必要花一些时间和团队成员一起讨论并制定规范。 + +那么,有哪些地方需要制定规范,这些规范又该如何制定呢? + +有哪些地方需要制定规范? + +一个 Go 项目会涉及很多方面,所以也会有多种规范,同类规范也会因为团队差异而有所不同。所以,在这门课中我只给你讲一些开发中常用的规范。为了便于你记忆,根据是否跟代码相关,我将它们分为非编码类规范和编码类规范: + + +非编码类规范,主要包括开源规范、文档规范、版本规范、Commit 规范和发布规范。 +编码类规范,则主要包括目录规范、代码规范、接口规范、日志规范和错误码规范。 + + +为了便于你记忆,我将这些规范整理成了下面一张图: + + + +这一讲,我们先来说说开源规范、文档规范和版本规范,因为 Commit 规范比较多,我们放到下一讲。至于其他规范,会在后面内容中介绍。例如日志规范,因为和日志设计结合比较紧密,我会放在日志包设计中一起讲。 + +开源规范 + +首先,我们来介绍下开源规范。 + +其实业界并没有一个官方的开源规范,实际开发中,也很少有人提这个。那么,我们为什么一定要知道开源规范呢? + +原因主要有两方面:一是,开源项目在代码质量、代码规范、文档等方面,要比非开源项目要求更高,在项目开发中按照开源项目的要求来规范自己的项目,可以更好地驱动项目质量的提高;二是,一些大公司为了不重复造轮子,会要求公司团队能够将自己的项目开源,所以提前按开源标准来驱动 Go 项目开发,也会为我们日后代码开源省去不少麻烦。 + +一个开源项目一定需要一个开源协议,开源协议规定了你在使用开源软件时的权利和责任,也就是规定了你可以做什么,不可以做什么。所以,开源规范的第一条规范就是选择一个合适的开源协议。那么有哪些开源协议,如何选择呢?接下来,我来详细介绍下。 + +开源协议概述 + +首先要说明的是,只有开源项目才会用到开源协议,如果你的项目不准备开源,就用不到开源协议。但先了解一下总是没错的,以后总能用得上。 + +业界有上百种开源协议,每种开源协议的要求不一样,有的协议对使用条件要求比较苛刻,有的则相对比较宽松。我们没必要全都记住,只需要知道经常使用的 6 种开源协议,也就是 GPL、MPL、LGPL、Apache、BSD 和 MIT 就可以了。至于它们的介绍,你可以参考 开源协议介绍 。 + +那具体如何选择适合自己的开源协议呢?你可以参考乌克兰程序员 Paul Bagwell 画的这张图: + + + +在上图中,右边的协议比左边的协议宽松,在选择时,你可以根据菱形框中的选择项从上到下进行选择。为了使你能够毫无负担地使用 IAM 项目提供的源码,我选择了最宽松的 MIT 协议。 + +另外,因为 Apache 是对商业应用友好的协议,使用者也可以在需要的时候修改代码来满足需要,并作为开源或商业产品发布/销售,所以大型公司的开源项目通常会采用 Apache 2.0 开源协议。 + +开源规范具有哪些特点? + +那我们在参与开源项目,或者按照开源项目的要求来规范代码时,需要关注哪些方面的规范呢? + +其实,在我看来,一切能让项目变得更优秀的规范,都应该属于开源规范。 + +开源项目的代码,除了要遵守上面所说的编码类规范和非编码类规范之外,还要遵守下面几个规范。 + +第一,开源项目,应该有一个高的单元覆盖率。这样,一方面可以确保第三方开发者在开发完代码之后,能够很方便地对整个项目做详细的单元测试,另一方面也能保证提交代码的质量。 + +第二,要确保整个代码库和提交记录中,不能出现内部 IP、内部域名、密码、密钥这类信息。否则,就会造成敏感信息外漏,可能会对我们的内部业务造成安全隐患。 + +第三,当我们的开源项目被别的开发者提交 pull request、issue、评论时,要及时处理,一方面可以确保项目不断被更新,另一方面也可以激发其他开发者贡献代码的积极性。 + +第四,好的开源项目,应该能够持续地更新功能,修复 Bug。对于一些已经结项、不维护的开源项目,需要及时地对项目进行归档,并在项目描述中加以说明。 + +在我看来,上面这些,是开源规范中比较重要的几点。如果你想了解详细的开源规范包括哪些内容,可以看我放在 GitHub 上的 这份资料 。 + +最后提醒你两件事:第一件,如果有条件,你可以宣传、运营开源项目,让更多的人知道、使用、贡献代码。比如,你可以在掘金、简书等平台发表文章,也可以创建 QQ、微信交流群等,都是不错的方式。第二件,如果你英文好、有时间,文档最好有中英文 2 份,优先使用英文,让来自全球的开发者都能了解、使用和参与你的项目。 + +文档规范 + +工作中我发现,很多开发者非常注重代码产出,但不注重文档产出。他们觉得,即使没有软件文档也没太大关系,不影响软件交付。我要说的是,这种看法是错误的!因为文档属于软件交付的一个重要组成部分,没有文档的项目很难理解、部署和使用。 + +因此,编写文档是一个必不可少的开发工作。那么一个项目需要编写哪些文档,又该如何编写呢?我认为项目中最需要的 3 类文档是 README文档、项目文档和 API 接口文档。 + +下面,我们一一来说它们的编写规范。 + +README 规范 + +README文档是项目的门面,它是开发者学习项目时第一个阅读的文档,会放在项目的根目录下。因为它主要是用来介绍项目的功能、安装、部署和使用的,所以它是可以规范化的。 + +下面,我们直接通过一个 README模板,来看一下 README 规范中的内容: + +# 项目名称 + + + +## 功能特性 + + + +## 软件架构(可选) + + + +## 快速开始 + +### 依赖检查 + + + +### 构建 + + + +### 运行 + + + +## 使用指南 + + + +## 如何贡献 + + + +## 社区(可选) + + + +## 关于作者 + + + +## 谁在用(可选) + + + +## 许可证 + + + + +更具体的示例,你可以参考 IAM 系统的 README.md 文件 。 + +这里,有个在线的README生成工具,你也可以参考下:readme.so。 + +项目文档规范 + +项目文档包括一切需要文档化的内容,它们通常集中放在/docs 目录下。当我们在创建团队的项目文档时,通常会预先规划并创建好一些目录,用来存放不同的文档。因此,在开始 Go 项目开发之前,我们也要制定一个软件文档规范。好的文档规范有 2 个优点:易读和可以快速定位文档。 + +不同项目有不同的文档需求,在制定文档规范时,你可以考虑包含两类文档。 + + +开发文档:用来说明项目的开发流程,比如如何搭建开发环境、构建二进制文件、测试、部署等。 +用户文档:软件的使用文档,对象一般是软件的使用者,内容可根据需要添加。比如,可以包括 API 文档、SDK 文档、安装文档、功能介绍文档、最佳实践、操作指南、常见问题等。 + + +为了方便全球开发者和用户使用,开发文档和用户文档,可以预先规划好英文和中文 2 个版本。 + +为了加深你的理解,这里我们来看下实战项目的文档目录结构: + +docs +├── devel # 开发文档,可以提前规划好,英文版文档和中文版文档 +│ ├── en-US/ # 英文版文档,可以根据需要组织文件结构 +│ └── zh-CN # 中文版文档,可以根据需要组织文件结构 +│ └── development.md # 开发手册,可以说明如何编译、构建、运行项目 +├── guide # 用户文档 +│ ├── en-US/ # 英文版文档,可以根据需要组织文件结构 +│ └── zh-CN # 中文版文档,可以根据需要组织文件结构 +│ ├── api/ # API文档 +│ ├── best-practice # 最佳实践,存放一些比较重要的实践文章 +│ │ └── authorization.md +│ ├── faq # 常见问题 +│ │ ├── iam-apiserver +│ │ └── installation +│ ├── installation # 安装文档 +│ │ └── installation.md +│ ├── introduction/ # 产品介绍文档 +│ ├── operation-guide # 操作指南,里面可以根据RESTful资源再划分为更细的子目录,用来存放系统核心/全部功能的操作手册 +│ │ ├── policy.md +│ │ ├── secret.md +│ │ └── user.md +│ ├── quickstart # 快速入门 +│ │ └── quickstart.md +│ ├── README.md # 用户文档入口文件 +│ └── sdk # SDK文档 +│ └── golang.md +└── images # 图片存放目录 + └── 部署架构v1.png + + +API 接口文档规范 + +接口文档又称为 API 文档,一般由后台开发人员编写,用来描述组件提供的 API 接口,以及如何调用这些 API 接口。 + +在项目初期,接口文档可以解耦前后端,让前后端并行开发:前端只需要按照接口文档实现调用逻辑,后端只需要按照接口文档提供功能。 + +当前后端都开发完成之后,我们就可以直接进行联调,提高开发效率。在项目后期,接口文档可以提供给使用者,不仅可以降低组件的使用门槛,还能够减少沟通成本。 + +显然,一个有固定格式、结构清晰、内容完善的接口文档,就非常重要了。那么我们该如何编写接口文档,它又有什么规范呢? + +接口文档有四种编写方式,包括编写 Word 格式文档、借助工具编写、通过注释生成和编写 Markdown 格式文档。具体的实现方式见下表: + + + +其中,通过注释生成和编写 Markdown 格式文档这 2 种方式用得最多。在这个专栏,我采用编写 Markdown 格式文档的方式,原因如下: + + +相比通过注释生成的方式,编写 Markdown 格式的接口文档,能表达更丰富的内容和格式,不需要在代码中添加大量注释。 +相比 Word 格式的文档,Markdown 格式文档占用的空间更小,能够跟随代码仓库一起发布,方便 API 文档的分发和查找。 +相比在线 API 文档编写工具,Markdown 格式的文档免去了第三方平台依赖和网络的限制。 + + +API 接口文档又要遵循哪些规范呢?其实,一个规范的 API 接口文档,通常需要包含一个完整的 API 接口介绍文档、API 接口变更历史文档、通用说明、数据结构说明、错误码描述和 API 接口使用文档。API 接口使用文档中需要包含接口描述、请求方法、请求参数、输出参数和请求示例。 + +当然,根据不同的项目需求,API 接口文档会有不同的格式和内容。我以这门课的实战项目采用的 API 接口文档规范为例,和你解释下。 + +接口文档拆分为以下几个 Markdown 文件,并存放在目录 docs/guide/zh-CN/api 中: + + +README.md :API 接口介绍文档,会分类介绍 IAM 支持的 API 接口,并会存放相关 API 接口文档的链接,方便开发者查看。 +CHANGELOG.md :API 接口文档变更历史,方便进行历史回溯,也可以使调用者决定是否进行功能更新和版本更新。 +generic.md :用来说明通用的请求参数、返回参数、认证方法和请求方法等。 +struct.md :用来列出接口文档中使用的数据结构。这些数据结构可能被多个 API 接口使用,会在 user.md、secret.md、policy.md 文件中被引用。 +user.md 、 secret.md 、 policy.md :API 接口文档,相同 REST 资源的接口会存放在一个文件中,以 REST 资源名命名文档名。 +error_code.md :错误码描述,通过程序自动生成。 + + +这里我拿 user.md 接口文档为例,和你解释下接口文档是如何写的。user.md 文件记录了用户相关的接口,每个接口按顺序排列,包含如下 5 部分。 + + +接口描述:描述接口实现了什么功能。 +请求方法:接口的请求方法,格式为 HTTP 方法 请求路径,例如 POST /v1/users。在 通用说明中的请求方法部分,会说明接口的请求协议和请求地址。 +输入参数:接口的输入字段,它又分为 Header 参数、Query 参数、Body 参数、Path 参数。每个字段通过:参数名称、必选、类型 和 描述 4 个属性来描述。如果参数有限制或者默认值,可以在描述部分注明。 +输出参数:接口的返回字段,每个字段通过 参数名称、类型 和 描述 3 个属性来描述。 +请求示例:一个真实的 API 接口请求和返回示例。 + + +如果掌握了这些内容之后,你还想了解更详细的 API 接口文档规范,可以参考这个 链接 。 + +版本规范 + +在做 Go 项目开发时,我建议你把所有组件都加入版本机制。原因主要有两个:一是通过版本号,我们可以很明确地知道组件是哪个版本,从而定位到该组件的功能和代码,方便我们定位问题。二是发布组件时携带版本号,可以让使用者知道目前的项目进度,以及使用版本和上一个版本的功能差别等。 + +目前业界主流的版本规范是语义化版本规范,也是 IAM 系统采用的版本规范。那什么是语义化版本规范呢? + +什么是语义化版本规范(SemVer)? + +语义化版本规范(SemVer,Semantic Versioning)是 GitHub 起草的一个具有指导意义的、统一的版本号表示规范。它规定了版本号的表示、增加和比较方式,以及不同版本号代表的含义。 + +在这套规范下,版本号及其更新方式包含了相邻版本间的底层代码和修改内容的信息。语义化版本格式为:主版本号.次版本号.修订号(X.Y.Z),其中 X、Y 和 Z 为非负的整数,且禁止在数字前方补零。 + +版本号可按以下规则递增: + + +主版本号(MAJOR):当做了不兼容的 API 修改。 +次版本号(MINOR):当做了向下兼容的功能性新增及修改。这里有个不成文的约定需要你注意,偶数为稳定版本,奇数为开发版本。 +修订号(PATCH):当做了向下兼容的问题修正。 + + +例如,v1.2.3 是一个语义化版本号,版本号中每个数字的具体含义见下图: + + + +你可能还看过这么一种版本号:v1.2.3-alpha。这其实是把先行版本号(Pre-release)和版本编译元数据,作为延伸加到了主版本号.次版本号.修订号的后面,格式为 X.Y.Z[-先行版本号][+版本编译元数据],如下图所示: + + + +我们来分别看下先行版本号和版本编译元数据是什么意思。 + +先行版本号意味着,该版本不稳定,可能存在兼容性问题,格式为:X.Y.Z-[一连串以句点分隔的标识符] ,比如下面这几个例子: + +1.0.0-alpha +1.0.0-alpha.1 +1.0.0-0.3.7 +1.0.0-x.7.z.92 + + +编译版本号,一般是编译器在编译过程中自动生成的,我们只定义其格式,并不进行人为控制。下面是一些编译版本号的示例: + +1.0.0-alpha+001 +1.0.0+20130313144700 +1.0.0-beta+exp.sha.5114f85 + + +注意,先行版本号和编译版本号只能是字母、数字,且不可以有空格。 + +语义化版本控制规范 + +语义化版本控制规范比较多,这里我给你介绍几个比较重要的。如果你需要了解更详细的规范,可以参考 这个链接 的内容。 + + +标记版本号的软件发行后,禁止改变该版本软件的内容,任何修改都必须以新版本发行。 +主版本号为零(0.y.z)的软件处于开发初始阶段,一切都可能随时被改变,这样的公共 API 不应该被视为稳定版。1.0.0 的版本号被界定为第一个稳定版本,之后的所有版本号更新都基于该版本进行修改。 +修订号 Z(x.y.Z | x > 0)必须在只做了向下兼容的修正时才递增,这里的修正其实就是 Bug 修复。 +次版本号 Y(x.Y.z | x > 0)必须在有向下兼容的新功能出现时递增,在任何公共 API 的功能被标记为弃用时也必须递增,当有改进时也可以递增。其中可以包括修订级别的改变。每当次版本号递增时,修订号必须归零。 +主版本号 X(X.y.z | X > 0)必须在有任何不兼容的修改被加入公共 API 时递增。其中可以包括次版本号及修订级别的改变。每当主版本号递增时,次版本号和修订号必须归零。 + + +如何确定版本号? + +说了这么多,我们到底该如何确定版本号呢? + +这里我给你总结了这么几个经验: + +第一,在实际开发的时候,我建议你使用 0.1.0 作为第一个开发版本号,并在后续的每次发行时递增次版本号。 + +第二,当我们的版本是一个稳定的版本,并且第一次对外发布时,版本号可以定为 1.0.0。 + +第三,当我们严格按照 Angular commit message 规范提交代码时,版本号可以这么来确定: + + +fix 类型的 commit 可以将修订号+1。 +feat 类型的 commit 可以将次版本号+1。 +带有 BREAKING CHANGE 的 commit 可以将主版本号+1。 + + +总结 + +一套好的规范,就是一个项目开发的“规矩”,它可以确保整个项目的可维护性、可阅读性,减少 Bug 数等。 + +一个项目的规范设计主要包括编码类和非编码类这两类规范。今天我们一起学习了开源规范、文档规范和版本规范,现在我们回顾一下重点内容吧。 + + +新开发的项目最好按照开源标准来规范,以驱动其成为一个高质量的项目。 +开发之前,最好提前规范好文档目录,并选择一种合适的方式来编写 API 文档。在这门课的实战项目中,我采用的是 Markdown 格式,也推荐你使用这种方式。 +项目要遵循版本规范,目前业界主流的版本规范是语义化版本规范,也是我推荐的版本规范。 + + +今天示范用到的项目规范示例,我把详细版放在这里,方便你随时查看:开源规范 、 README规范 、 API接口文档规范 。 + +课后练习 + + +除了今天我们介绍的这些非编码类规范之外,你在开发中还用到过哪些规范? +试着用这一讲介绍的API文档规范,书写一份你当前项目的API接口。 + + +期待在留言区看到你的思考和答案,也欢迎和我一起探讨关于规范设计的问题,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/05规范设计(下):commit信息风格迥异、难以阅读,如何规范?.md b/专栏/Go语言项目开发实战/05规范设计(下):commit信息风格迥异、难以阅读,如何规范?.md new file mode 100644 index 0000000..c711278 --- /dev/null +++ b/专栏/Go语言项目开发实战/05规范设计(下):commit信息风格迥异、难以阅读,如何规范?.md @@ -0,0 +1,545 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 规范设计(下):commit 信息风格迥异、难以阅读,如何规范? + 你好,我是孔令飞。今天,我们继续学习非编码类规范中的 Commit 规范。 + +我们在做代码开发时,经常需要提交代码,提交代码时需要填写 Commit Message(提交说明),否则就不允许提交。 + +而在实际开发中,我发现每个研发人员提交 Commit Message 的格式可以说是五花八门,有用中文的、有用英文的,甚至有的直接填写“11111”。这样的 Commit Message,时间久了可能连提交者自己都看不懂所表述的修改内容,更别说给别人看了。 + +所以在 Go 项目开发时,一个好的 Commit Message 至关重要: + + +可以使自己或者其他开发人员能够清晰地知道每个 commit 的变更内容,方便快速浏览变更历史,比如可以直接略过文档类型或者格式化类型的代码变更。 +可以基于这些 Commit Message 进行过滤查找,比如只查找某个版本新增的功能:git log --oneline --grep "^feat|^fix|^perf"。 +可以基于规范化的 Commit Message 生成 Change Log。 +可以依据某些类型的 Commit Message 触发构建或者发布流程,比如当 type 类型为 feat、fix 时我们才触发 CI 流程。 +确定语义化版本的版本号。比如 fix 类型可以映射为 PATCH 版本,feat 类型可以映射为 MINOR 版本。带有 BREAKING CHANGE 的 commit,可以映射为 MAJOR 版本。在这门课里,我就是通过这种方式来自动生成版本号。 + + +总结来说,一个好的 Commit Message 规范可以使 Commit Message 的可读性更好,并且可以实现自动化。那究竟如何写一个易读的 Commit Message 呢? + +接下来,我们来看下如何规范 Commit Message。另外,除了 Commit Message 之外,我还会介绍跟 Commit 相关的 3 个重点,以及如何通过自动化流程来保证 Commit Message 的规范化。 + +Commit Message 的规范有哪些? + +毫无疑问,我们可以根据需要自己来制定 Commit Message 规范,但是我更建议你采用开源社区中比较成熟的规范。一方面,可以避免重复造轮子,提高工作效率。另一方面,这些规范是经过大量开发者验证的,是科学、合理的。 + +目前,社区有多种 Commit Message 的规范,例如 jQuery、Angular 等。我将这些规范及其格式绘制成下面一张图片,供你参考: + + + +在这些规范中,Angular 规范在功能上能够满足开发者 commit 需求,在格式上清晰易读,目前也是用得最多的。 + +Angular 规范其实是一种语义化的提交规范(Semantic Commit Messages),所谓语义化的提交规范包含以下内容: + + +Commit Message 是语义化的:Commit Message 都会被归为一个有意义的类型,用来说明本次 commit 的类型。 +Commit Message 是规范化的:Commit Message 遵循预先定义好的规范,比如 Commit Message 格式固定、都属于某个类型,这些规范不仅可被开发者识别也可以被工具识别。 + + +为了方便你理解 Angular 规范,我们直接看一个遵循 Angular 规范的 commit 历史记录,见下图: + + + +再来看一个完整的符合 Angular 规范的 Commit Message,如下图所示: + + + +通过上面 2 张图,我们可以看到符合 Angular Commit Message 规范的 commit 都是有一定格式,有一定语义的。 + +那我们该怎么写出符合 Angular 规范的 Commit Message 呢? + +在 Angular 规范中,Commit Message 包含三个部分,分别是 Header、Body 和 Footer,格式如下: + +[optional scope]: +// 空行 +[optional body] +// 空行 +[optional footer(s)] + + +其中,Header是必需的,Body和Footer可以省略。在以上规范中,必须用括号 () 括起来, [] 后必须紧跟冒号 ,冒号后必须紧跟空格,2 个空行也是必需的。 + +在实际开发中,为了使 Commit Message 在 GitHub 或者其他 Git 工具上更加易读,我们往往会限制每行 message 的长度。根据需要,可以限制为 50/72/100 个字符,这里我将长度限制在 72 个字符以内(也有一些开发者会将长度限制为 100,你可根据需要自行选择)。 + +以下是一个符合 Angular 规范的 Commit Message: + +fix($compile): couple of unit tests for IE9 +# Please enter the Commit Message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# On branch master +# Changes to be committed: +# ... + +Older IEs serialize html uppercased, but IE9 does not... +Would be better to expect case insensitive, unfortunately jasmine does +not allow to user regexps for throw expectations. + +Closes #392 +Breaks foo.bar api, foo.baz should be used instead + + +接下来,我们详细看看 Angular 规范中 Commit Message 的三个部分。 + +Header + +Header 部分只有一行,包括三个字段:type(必选)、scope(可选)和 subject(必选)。 + +我们先来说 type,它用来说明 commit 的类型。为了方便记忆,我把这些类型做了归纳,它们主要可以归为 Development 和 Production 共两类。它们的含义是: + + +Development:这类修改一般是项目管理类的变更,不会影响最终用户和生产环境的代码,比如 CI 流程、构建方式等的修改。遇到这类修改,通常也意味着可以免测发布。 +Production:这类修改会影响最终的用户和生产环境的代码。所以对于这种改动,我们一定要慎重,并在提交前做好充分的测试。 + + +我在这里列出了 Angular 规范中的常见 type 和它们所属的类别,你在提交 Commit Message 的时候,一定要注意区分它的类别。举个例子,我们在做 Code Review 时,如果遇到 Production 类型的代码,一定要认真 Review,因为这种类型,会影响到现网用户的使用和现网应用的功能。 + + + +有这么多 type,我们该如何确定一个 commit 所属的 type 呢?这里我们可以通过下面这张图来确定。 + + + +如果我们变更了应用代码,比如某个 Go 函数代码,那这次修改属于代码类。在代码类中,有 4 种具有明确变更意图的类型:feat、fix、perf 和 style;如果我们的代码变更不属于这 4 类,那就全都归为 refactor 类,也就是优化代码。 + +如果我们变更了非应用代码,例如更改了文档,那它属于非代码类。在非代码类中,有 3 种具有明确变更意图的类型:test、ci、docs;如果我们的非代码变更不属于这 3 类,那就全部归入到 chore 类。 + +Angular 的 Commit Message 规范提供了大部分的 type,在实际开发中,我们可以使用部分 type,或者扩展添加我们自己的 type。但无论选择哪种方式,我们一定要保证一个项目中的 type 类型一致。 + +接下来,我们说说 Header 的第二个字段 scope。 + +scope 是用来说明 commit 的影响范围的,它必须是名词。显然,不同项目会有不同的 scope。在项目初期,我们可以设置一些粒度比较大的 scope,比如可以按组件名或者功能来设置 scope;后续,如果项目有变动或者有新功能,我们可以再用追加的方式添加新的 scope。 + +我们这门课采用的 scope,主要是根据组件名和功能来设置的。例如,支持 apiserver、authzserver、user 这些 scope。 + +这里想强调的是,scope 不适合设置太具体的值。太具体的话,一方面会导致项目有太多的 scope,难以维护。另一方面,开发者也难以确定 commit 属于哪个具体的 scope,导致错放 scope,反而会使 scope 失去了分类的意义。 + +当然了,在指定 scope 时,也需要遵循我们预先规划的 scope,所以我们要将 scope 文档化,放在类似 devel 这类文档中。这一点你可以参考下 IAM 项目的 scope 文档: IAM commit message scope 。 + +最后,我们再说说 subject。 + +subject 是 commit 的简短描述,必须以动词开头、使用现在时。比如,我们可以用 change,却不能用 changed 或 changes,而且这个动词的第一个字母必须是小写。通过这个动词,我们可以明确地知道 commit 所执行的操作。此外我们还要注意,subject 的结尾不能加英文句号。 + +Body + +Header 对 commit 做了高度概括,可以方便我们查看 Commit Message。那我们如何知道具体做了哪些变更呢?答案就是,可以通过 Body 部分,它是对本次 commit 的更详细描述,是可选的。 + +Body 部分可以分成多行,而且格式也比较自由。不过,和 Header 里的 一样,它也要以动词开头,使用现在时。此外,它还必须 要包括修改的动机,以及 和跟上一版本相比的改动点。 + +我在下面给出了一个范例,你可以看看: + +The body is mandatory for all commits except for those of scope "docs". When the body is required it must be at least 20 characters long. + + +Footer + +Footer 部分不是必选的,可以根据需要来选择,主要用来说明本次 commit 导致的后果。在实际应用中,Footer 通常用来说明不兼容的改动和关闭的 Issue 列表,格式如下: + +BREAKING CHANGE: +// 空行 + +// 空行 +// 空行 +Fixes # + + +接下来,我给你详细说明下这两种情况: + + +不兼容的改动:如果当前代码跟上一个版本不兼容,需要在 Footer 部分,以 BREAKING CHANG: 开头,后面跟上不兼容改动的摘要。Footer 的其他部分需要说明变动的描述、变动的理由和迁移方法,例如: + + +BREAKING CHANGE: isolate scope bindings definition has changed and + the inject option for the directive controller injection was removed. + + To migrate the code follow the example below: + + Before: + + scope: { + myAttr: 'attribute', + } + + After: + + scope: { + myAttr: '@', + } + The removed `inject` wasn't generaly useful for directives so there should be no code using it. + + + +关闭的 Issue 列表:关闭的 Bug 需要在 Footer 部分新建一行,并以 Closes 开头列出,例如:Closes #123。如果关闭了多个 Issue,可以这样列出:Closes #123, #432, #886。例如: + + + Change pause version value to a constant for image + + Closes #1137 + + +Revert Commit + +除了 Header、Body 和 Footer 这 3 个部分,Commit Message 还有一种特殊情况:如果当前 commit 还原了先前的 commit,则应以 revert: 开头,后跟还原的 commit 的 Header。而且,在 Body 中必须写成 This reverts commit ,其中 hash 是要还原的 commit 的 SHA 标识。例如: + +revert: feat(iam-apiserver): add 'Host' option + +This reverts commit 079360c7cfc830ea8a6e13f4c8b8114febc9b48a. + + +为了更好地遵循 Angular 规范,建议你在提交代码时养成不用 git commit -m,即不用-m 选项的习惯,而是直接用 git commit 或者 git commit -a 进入交互界面编辑 Commit Message。这样可以更好地格式化 Commit Message。 + +但是除了 Commit Message 规范之外,在代码提交时,我们还需要关注 3 个重点内容:提交频率、合并提交和 Commit Message 修改。 + +Commit 相关的 3 个重要内容 + +我们先来看下提交频率。 + +提交频率 + +在实际项目开发中,如果是个人项目,随意 commit 可能影响不大,但如果是多人开发的项目,随意 commit 不仅会让 Commit Message 变得难以理解,还会让其他研发同事觉得你不专业。因此,我们要规定 commit 的提交频率。 + +那到底什么时候进行 commit 最好呢? + +我认为主要可以分成两种情况。一种情况是,只要我对项目进行了修改,一通过测试就立即 commit。比如修复完一个 bug、开发完一个小功能,或者开发完一个完整的功能,测试通过后就提交。另一种情况是,我们规定一个时间,定期提交。这里我建议代码下班前固定提交一次,并且要确保本地未提交的代码,延期不超过 1 天。这样,如果本地代码丢失,可以尽可能减少丢失的代码量。 + +按照上面 2 种方式提交代码,你可能会觉得代码 commit 比较多,看起来比较随意。或者说,我们想等开发完一个完整的功能之后,放在一个 commit 中一起提交。这时候,我们可以在最后合并代码或者提交 Pull Request 前,执行 git rebase -i 合并之前的所有 commit。 + +那么如何合并 commit 呢?接下来,我来详细说说。 + +合并提交 + +合并提交,就是将多个 commit 合并为一个 commit 提交。这里,我建议你把新的 commit 合并到主干时,只保留 2~3 个 commit 记录。那具体怎么做呢? + +在 Git 中,我们主要使用 git rebase 命令来合并。git rebase 也是我们日后开发需要经常使用的一个命令,所以我们一定要掌握好它的使用方法。 + +git rebase 命令介绍 + +git rebase 的最大作用是它可以重写历史。 + +我们通常会通过 git rebase -i 使用 git rebase 命令,-i 参数表示交互(interactive),该命令会进入到一个交互界面中,其实就是 Vim 编辑器。在该界面中,我们可以对里面的 commit 做一些操作,交互界面如图所示: + + + +这个交互界面会首先列出给定之前(不包括 ,越下面越新)的所有 commit,每个 commit 前面有一个操作命令,默认是 pick。我们可以选择不同的 commit,并修改 commit 前面的命令,来对该 commit 执行不同的变更操作。 + +git rebase 支持的变更操作如下: + + + +在上面的 7 个命令中,squash 和 fixup 可以用来合并 commit。例如用 squash 来合并,我们只需要把要合并的 commit 前面的动词,改成 squash(或者 s)即可。你可以看看下面的示例: + +pick 07c5abd Introduce OpenPGP and teach basic usage +s de9b1eb Fix PostChecker::Post#urls +s 3e7ee36 Hey kids, stop all the highlighting +pick fa20af3 git interactive rebase, squash, amend + + +rebase 后,第 2 行和第 3 行的 commit 都会合并到第 1 行的 commit。这个时候,我们提交的信息会同时包含这三个 commit 的提交信息: + +# This is a combination of 3 commits. +# The first commit's message is: +Introduce OpenPGP and teach basic usage + +# This is the 2ndCommit Message: +Fix PostChecker::Post#urls + +# This is the 3rdCommit Message: +Hey kids, stop all the highlighting + + +如果我们将第 3 行的 squash 命令改成 fixup 命令: + +pick 07c5abd Introduce OpenPGP and teach basic usage +s de9b1eb Fix PostChecker::Post#urls +f 3e7ee36 Hey kids, stop all the highlighting +pick fa20af3 git interactive rebase, squash, amend + + +rebase 后,还是会生成两个 commit,第 2 行和第 3 行的 commit,都合并到第 1 行的 commit。但是,新的提交信息里面,第 3 行 commit 的提交信息会被注释掉: + +# This is a combination of 3 commits. +# The first commit's message is: +Introduce OpenPGP and teach basic usage + +# This is the 2ndCommit Message: +Fix PostChecker::Post#urls + +# This is the 3rdCommit Message: +# Hey kids, stop all the highlighting + + +除此之外,我们在使用 git rebase 进行操作的时候,还需要注意以下几点: + + +删除某个 commit 行,则该 commit 会丢失掉。 +删除所有的 commit 行,则 rebase 会被终止掉。 +可以对 commits 进行排序,git 会从上到下进行合并。 + + +为了加深你的理解,我给你完整演示一遍合并提交。 + +合并提交操作示例 + +假设我们需要研发一个新的模块:user,用来在平台里进行用户的注册、登录、注销等操作,当模块完成开发和测试后,需要合并到主干分支,具体步骤如下。 + +首先,我们新建一个分支。我们需要先基于 master 分支新建并切换到 feature 分支: + +$ git checkout -b feature/user +Switched to a new branch 'feature/user' + + +这是我们的所有 commit 历史: + +$ git log --oneline +7157e9e docs(docs): append test line 'update3' to README.md +5a26aa2 docs(docs): append test line 'update2' to README.md +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +接着,我们在 feature/user分支进行功能的开发和测试,并遵循规范提交 commit,功能开发并测试完成后,Git 仓库的 commit 记录如下: + +$ git log --oneline +4ee51d6 docs(user): update user/README.md +176ba5d docs(user): update user/README.md +5e829f8 docs(user): add README.md for user +f40929f feat(user): add delete user function +fc70a21 feat(user): add create user function +7157e9e docs(docs): append test line 'update3' to README.md +5a26aa2 docs(docs): append test line 'update2' to README.md +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +可以看到我们提交了 5 个 commit。接下来,我们需要将 feature/user分支的改动合并到 master 分支,但是 5 个 commit 太多了,我们想将这些 commit 合并后再提交到 master 分支。 + +接着,我们合并所有 commit。在上一步中,我们知道 fc70a21是 feature/user分支的第一个 commit ID,其父 commit ID 是 7157e9e,我们需要将7157e9e之前的所有分支 进行合并,这时我们可以执行: + +$ git rebase -i 7157e9e + + +执行命令后,我们会进入到一个交互界面,在该界面中,我们可以将需要合并的 4 个 commit,都执行 squash 操作,如下图所示: + + + +修改完成后执行:wq 保存,会跳转到一个新的交互页面,在该页面,我们可以编辑 Commit Message,编辑后的内容如下图所示: + + + +#开头的行是 git 的注释,我们可以忽略掉,在 rebase 后,这些行将会消失掉。修改完成后执行:wq 保存,就完成了合并提交操作。 + +除此之外,这里有 2 个点需要我们注意: + + +git rebase -i 这里的 一定要是需要合并 commit 中最旧 commit 的父 commit ID。 +我们希望将 feature/user 分支的 5 个 commit 合并到一个 commit,在 git rebase 时,需要保证其中最新的一个 commit 是 pick 状态,这样我们才可以将其他 4 个 commit 合并进去。 + + +然后,我们用如下命令来检查 commits 是否成功合并。可以看到,我们成功将 5 个 commit 合并成为了一个 commit:d6b17e0。 + +$ git log --oneline +d6b17e0 feat(user): add user module with all function implements +7157e9e docs(docs): append test line 'update3' to README.md +5a26aa2 docs(docs): append test line 'update2' to README.md +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +最后,我们就可以将 feature 分支 feature/user 的改动合并到主干分支,从而完成新功能的开发。 + +$ git checkout master +$ git merge feature/user +$ git log --oneline +d6b17e0 feat(user): add user module with all function implements +7157e9e docs(docs): append test line 'update3' to README.md +5a26aa2 docs(docs): append test line 'update2' to README.md +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +这里给你一个小提示,如果你有太多的 commit 需要合并,那么可以试试这种方式:先撤销过去的 commit,然后再建一个新的。 + +$ git reset HEAD~3 +$ git add . +$ git commit -am "feat(user): add user resource" + + +需要说明一点:除了 commit 实在太多的时候,一般情况下我不建议用这种方法,有点粗暴,而且之前提交的 Commit Message 都要重新整理一遍。 + +修改 Commit Message + +即使我们有了 Commit Message 规范,但仍然可能会遇到提交的 Commit Message 不符合规范的情况,这个时候就需要我们能够修改之前某次 commit 的 Commit Message。 + +具体来说,我们有两种修改方法,分别对应两种不同情况: + + +git commit –amend:修改最近一次 commit 的 message; +git rebase -i:修改某次 commit 的 message。 + + +接下来,我们分别来说这两种方法。 + +git commit –amend:修改最近一次 commit 的 message + +有时候,我们刚提交完一个 commit,但是发现 commit 的描述不符合规范或者需要纠正,这时候,我们可以通过 git commit --amend 命令来修改刚刚提交 commit 的 Commit Message。具体修改步骤如下: + + +查看当前分支的日志记录。 + + +$ git log –oneline +418bd4 docs(docs): append test line 'update$i' to README.md +89651d4 docs(doc): add README.md + + +可以看到,最近一次的 Commit Message 是 docs(docs): append test line 'update$i' to README.md,其中 update$i 正常应该是 update1。 + + +更新最近一次提交的 Commit Message + + +在当前 Git 仓库下执行命令:git commit --amend,后会进入一个交互界面,在交互界面中,修改最近一次的 Commit Message,如下图所示: + + + +修改完成后执行:wq 保存,退出编辑器之后,会在命令行显示,该 commit 的 message 的更新结果如下: + +[master 55892fa] docs(docs): append test line 'update1' to README.md + Date: Fri Sep 18 13:40:42 2020 +0800 + 1 file changed, 1 insertion(+) + + + +查看最近一次的 Commit Message 是否被更新 + + +$ git log --oneline +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +可以看到最近一次 commit 的 message 成功被修改为期望的内容。 + +git rebase -i:修改某次 commit 的 message + +如果我们想修改的 Commit Message 不是最近一次的 Commit Message,可以通过 git rebase -i <父 commit ID>命令来修改。这个命令在实际开发中使用频率比较高,我们一定要掌握。具体来说,使用它主要分为 4 步。 + + +查看当前分支的日志记录。 + + +$ git log --oneline +1d6289f docs(docs): append test line 'update3' to README.md +a38f808 docs(docs): append test line 'update$i' to README.md +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +可以看到倒数第 3 次提交的 Commit Message 是:docs(docs): append test line 'update$i' to README.md,其中 update$i 正常应该是 update2。 + + +修改倒数第 3 次提交 commit 的 message。 + + +在 Git 仓库下直接执行命令 git rebase -i 55892fa,然后会进入一个交互界面。在交互界面中,修改最近一次的 Commit Message。这里我们使用 reword 或者 r,保留倒数第3次的变更信息,但是修改其 message,如下图所示: + + + +修改完成后执行:wq 保存,还会跳转到一个新的交互页面,如下图所示: + + + +修改完成后执行:wq 保存,退出编辑器之后,会在命令行显示该 commit 的 message 的更新结果: + +[detached HEAD 5a26aa2] docs(docs): append test line 'update2' to README.md + Date: Fri Sep 18 13:45:54 2020 +0800 + 1 file changed, 1 insertion(+) +Successfully rebased and updated refs/heads/master. + + +Successfully rebased and updated refs/heads/master.说明 rebase 成功,其实这里完成了两个步骤:更新 message,更新该 commit 的 HEAD 指针。 + +注意:这里一定要传入想要变更 Commit Message 的父 commit ID:git rebase -i <父 commit ID>。 + + +查看倒数第 3 次 commit 的 message 是否被更新。 + + +$ git log --oneline +7157e9e docs(docs): append test line 'update3' to README.md +5a26aa2 docs(docs): append test line 'update2' to README.md +55892fa docs(docs): append test line 'update1' to README.md +89651d4 docs(doc): add README.md + + +可以看到,倒数第 3 次 commit 的 message 成功被修改为期望的内容。 + +这里有两点需要你注意: + + +Commit Message 是 commit 数据结构中的一个属性,如果 Commit Message 有变更,则 commit ID 一定会变,git commit --amend 只会变更最近一次的 commit ID,但是 git rebase -i 会变更父 commit ID 之后所有提交的 commit ID。 +如果当前分支有未 commit 的代码,需要先执行 git stash 将工作状态进行暂存,当修改完成后再执行 git stash pop 恢复之前的工作状态。 + + +Commit Message 规范自动化 + +其实,到这里我们也就意识到了一点:Commit Message 规范如果靠文档去约束,就会严重依赖开发者的代码素养,并不能真正保证提交的 commit 是符合规范的。 + +那么,有没有一种方式可以确保我们提交的 Commit Message 一定是符合规范的呢?有的,我们可以通过一些工具,来自动化地生成和检查 Commit Message 是否符合规范。 + +另外,既然 Commit Message 是规范的,那么我们能不能利用这些规范来实现一些更酷的功能呢?答案是有的,我将可以围绕着 Commit Message 实现的一些自动化功能绘制成了下面一张图。 + + + +这些自动化功能可以分为以下 2 类: + + +Commit Message 生成和检查功能:生成符合 Angular 规范的 Commit Message、Commit Message 提交前检查、历史 Commit Message 检查。 +基于 Commit Message 自动生成 CHANGELOG 和 SemVer 的工具。 + + +我们可以通过下面这 5 个工具自动的完成上面的功能: + + +commitizen-go:使你进入交互模式,并根据提示生成 Commit Message,然后提交。 +commit-msg:githooks,在 commit-msg 中,指定检查的规则,commit-msg 是个脚本,可以根据需要自己写脚本实现。这门课的 commit-msg 调用了 go-gitlint 来进行检查。 +go-gitlint:检查历史提交的 Commit Message 是否符合 Angular 规范,可以将该工具添加在 CI 流程中,确保 Commit Message 都是符合规范的。 +gsemver:语义化版本自动生成工具。 +git-chglog:根据 Commit Message 生成 CHANGELOG。 + + +这些工具你先有个印象就好了,在后面的课程内容中,我会带你通过实际使用来熟悉它们的用法。 + +总结 + +今天我向你介绍了 Commit Message 规范,主要讲了业界使用最多的 Angular 规范。 + +Angular 规范中,Commit Message 包含三个部分:Header、Body 和 Footer。Header 对 commit 做了高度概括,Body 部分是对本次 commit 的更详细描述,Footer 部分主要用来说明本次 commit 导致的后果。格式如下: + +[optional scope]: +// 空行 +[optional body] +// 空行 +[optional footer(s)] + + +另外,我们也需要控制 commit 的提交频率,比如可以在开发完一个功能、修复完一个 bug、下班前提交 commit。 + +最后,我们也需要掌握一些常见的提交操作,例如通过 git rebase -i 来合并提交 commit,通过 git commit --amend 或 git rebase -i 来修改 commit message。 + +课后练习 + + +新建一个 git repository,提交 4 个符合 Angular 规范的 Commit Message,并合并前 2 次提交。 +使用 git-chglog 工具来生成 CHANGEOG,使用 gsemver 工具来生成语义化版本号。 + + +期待在留言区看到你的思考和答案,也欢迎和我一起探讨关于规范设计的问题,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/06目录结构设计:如何组织一个可维护、可扩展的代码目录?.md b/专栏/Go语言项目开发实战/06目录结构设计:如何组织一个可维护、可扩展的代码目录?.md new file mode 100644 index 0000000..2fb5dbd --- /dev/null +++ b/专栏/Go语言项目开发实战/06目录结构设计:如何组织一个可维护、可扩展的代码目录?.md @@ -0,0 +1,523 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 目录结构设计:如何组织一个可维护、可扩展的代码目录? + 你好,我是孔令飞。今天我们来聊聊如何设计代码的目录结构。 + +目录结构是一个项目的门面。很多时候,根据目录结构就能看出开发者对这门语言的掌握程度。所以,在我看来,遵循一个好的目录规范,把代码目录设计得可维护、可扩展,甚至比文档规范、Commit 规范来得更加重要。 + +那具体怎么组织一个好的代码目录呢?在今天这一讲,我会从 2 个维度来解答这个问题。 + +首先,我会介绍组织目录的一些基本原则,这些原则可以指导你去组织一个好的代码目录。然后,我会向你介绍一些具体的、优秀的目录结构。你可以通过学习它们,提炼总结出你自己的目录结构设计方法,或者你也可以直接用它们作为你的目录结构规范,也就是说结构即规范。 + +如何规范目录? + +想设计好一个目录结构,我们首先要知道一个好的目录长什么样,也就是目录规范中包含哪些内容。 + +目录规范,通常是指我们的项目由哪些目录组成,每个目录下存放什么文件、实现什么功能,以及各个目录间的依赖关系是什么等。在我看来,一个好的目录结构至少要满足以下几个要求。 + + +命名清晰:目录命名要清晰、简洁,不要太长,也不要太短,目录名要能清晰地表达出该目录实现的功能,并且目录名最好用单数。一方面是因为单数足以说明这个目录的功能,另一方面可以统一规范,避免单复混用的情况。 +功能明确:一个目录所要实现的功能应该是明确的、并且在整个项目目录中具有很高的辨识度。也就是说,当需要新增一个功能时,我们能够非常清楚地知道把这个功能放在哪个目录下。 +全面性:目录结构应该尽可能全面地包含研发过程中需要的功能,例如文档、脚本、源码管理、API 实现、工具、第三方包、测试、编译产物等。 +可观测性:项目规模一定是从小到大的,所以一个好的目录结构应该能够在项目变大时,仍然保持之前的目录结构。 +可扩展性:每个目录下存放了同类的功能,在项目变大时,这些目录应该可以存放更多同类功能。举个例子,有如下目录结构: + + +$ ls internal/ +app pkg README.md + + +internal 目录用来实现内部代码,app 和 pkg 目录下的所有文件都属于内部代码。如果 internal 目录不管项目大小,永远只有 2 个文件 app 和 pkg,那么就说明 internal 目录是不可扩展的。 + +相反,如果 internal 目录下直接存放每个组件的源码目录(一个项目可以由一个或多个组件组成),当项目变大、组件增多时,可以将新增加的组件代码存放到 internal 目录,这时 internal 目录就是可扩展的。例如: + +$ ls internal/ +apiserver authzserver iamctl pkg pump watcher + + +刚才我讲了目录结构的总体规范,现在来看 2 个具体的、可以作为目录规范的目录结构。 + +通常,根据功能,我们可以将目录结构分为结构化目录结构和平铺式目录结构两种。结构化目录结构主要用在 Go 应用中,相对来说比较复杂;而平铺式目录结构主要用在 Go 包中,相对来说比较简单。 + +因为平铺式目录结构比较简单,所以接下来先介绍它。 + +平铺式目录结构 + +一个 Go 项目可以是一个应用,也可以是一个代码框架/库,当项目是代码框架/库时,比较适合采用平铺式目录结构。 + +平铺方式就是在项目的根目录下存放项目的代码,整个目录结构看起来更像是一层的,这种方式在很多框架/库中存在,使用这种方式的好处是引用路径长度明显减少,比如 github.com/marmotedu/log/pkg/options,可缩短为 github.com/marmotedu/log/options。例如 log 包 github.com/golang/glog 就是平铺式的,目录如下: + +$ ls glog/ +glog_file.go glog.go glog_test.go LICENSE README + + +接下来,我们来学习结构化目录结构,它比较适合 Go 应用,也比较复杂。 + +结构化目录结构 + +当前 Go 社区比较推荐的结构化目录结构是 project-layout 。虽然它并不是官方和社区的规范,但因为组织方式比较合理,被很多 Go 开发人员接受。所以,我们可以把它当作是一个事实上的规范。 + +首先,我们来看下在开发一个 Go 项目时,通常应该包含的功能。这些功能内容比较多,我放在了 GitHub 的 Go项目通常包含的功能 里,我们设计的目录结构应该能够包含这些功能。 + +我结合 project-layout,以及上面列出的 Go 项目常见功能,总结出了一套 Go 的代码结构组织方式,也就是 IAM 项目使用的目录结构。这种方式保留了 project-layout 优势的同时,还加入了一些我个人的理解,希望为你提供一个拿来即用的目录结构规范。 + +接下来,我们一起看看这门课的实战项目所采用的 Go 目录结构。因为实战项目目录比较多,这里只列出了一些重要的目录和文件,你可以快速浏览以加深理解。 + +├── api +│ ├── openapi +│ └── swagger +├── build +│ ├── ci +│ ├── docker +│ │ ├── iam-apiserver +│ │ ├── iam-authz-server +│ │ └── iam-pump +│ ├── package +├── CHANGELOG +├── cmd +│ ├── iam-apiserver +│ │ └── apiserver.go +│ ├── iam-authz-server +│ │ └── authzserver.go +│ ├── iamctl +│ │ └── iamctl.go +│ └── iam-pump +│ └── pump.go +├── configs +├── CONTRIBUTING.md +├── deployments +├── docs +│ ├── devel +│ │ ├── en-US +│ │ └── zh-CN +│ ├── guide +│ │ ├── en-US +│ │ └── zh-CN +│ ├── images +│ └── README.md +├── examples +├── githooks +├── go.mod +├── go.sum +├── init +├── internal +│ ├── apiserver +│ │ ├── api +│ │ │ └── v1 +│ │ │ └── user +│ │ ├── apiserver.go +│ │ ├── options +│ │ ├── service +│ │ ├── store +│ │ │ ├── mysql +│ │ │ ├── fake +│ │ └── testing +│ ├── authzserver +│ │ ├── api +│ │ │ └── v1 +│ │ │ └── authorize +│ │ ├── options +│ │ ├── store +│ │ └── testing +│ ├── iamctl +│ │ ├── cmd +│ │ │ ├── completion +│ │ │ ├── user +│ │ └── util +│ ├── pkg +│ │ ├── code +│ │ ├── options +│ │ ├── server +│ │ ├── util +│ │ └── validation +├── LICENSE +├── Makefile +├── _output +│ ├── platforms +│ │ └── linux +│ │ └── amd64 +├── pkg +│ ├── util +│ │ └── genutil +├── README.md +├── scripts +│ ├── lib +│ ├── make-rules +├── test +│ ├── testdata +├── third_party +│ └── forked +└── tools + + +看到这一长串目录是不是有些晕?没关系,这里我们一起给这个大目录分下类,然后再具体看看每一类目录的作用,你就清楚了。 + +在我看来,一个 Go 项目包含 3 大部分:Go 应用 、项目管理和文档。所以,我们的项目目录也可以分为这 3 大类。同时,Go 应用又贯穿开发阶段、测试阶段和部署阶段,相应的应用类的目录,又可以按开发流程分为更小的子类。当然了,这些是我建议的目录,Go 项目目录中还有一些不建议的目录。所以整体来看,我们的目录结构可以按下图所示的方式来分类: + + + +接下来你就先专心跟着我走一遍每个目录、每个文件的作用,等你下次组织代码目录的时候,可以再回过头来看看,那时你一定会理解得更深刻。 + +Go 应用 :主要存放前后端代码 + +首先,我们来说说开发阶段所涉及到的目录。我们开发的代码包含前端代码和后端代码,可以分别存放在前端目录和后端目录中。 + + +/web + + +前端代码存放目录,主要用来存放Web静态资源,服务端模板和单页应用(SPAs)。 + + +/cmd + + +一个项目有很多组件,可以把组件 main 函数所在的文件夹统一放在/cmd 目录下,例如: + +$ ls cmd/ +gendocs geniamdocs genman genswaggertypedocs genyaml iam-apiserver iam-authz-server iamctl iam-pump + +$ ls cmd/iam-apiserver/ +apiserver.go + + +每个组件的目录名应该跟你期望的可执行文件名是一致的。这里要保证 /cmd/<组件名> 目录下不要存放太多的代码,如果你认为代码可以导入并在其他项目中使用,那么它应该位于 /pkg 目录中。如果代码不是可重用的,或者你不希望其他人重用它,请将该代码放到 /internal 目录中。 + + +/internal + + +存放私有应用和库代码。如果一些代码,你不希望在其他应用和库中被导入,可以将这部分代码放在/internal 目录下。 + +在引入其它项目 internal 下的包时,Go 语言会在编译时报错: + +An import of a path containing the element “internal” is disallowed +if the importing code is outside the tree rooted at the parent of the +"internal" directory. + + +可以通过 Go 语言本身的机制来约束其他项目 import 项目内部的包。/internal 目录建议包含如下目录: + + +/internal/apiserver:该目录中存放真实的应用代码。这些应用的共享代码存放在/internal/pkg 目录下。 +/internal/pkg:存放项目内可共享,项目外不共享的包。这些包提供了比较基础、通用的功能,例如工具、错误码、用户验证等功能。 + + +我的建议是,一开始将所有的共享代码存放在/internal/pkg 目录下,当该共享代码做好了对外开发的准备后,再转存到/pkg目录下。 + +下面,我详细介绍下 IAM 项目的 internal目录 ,来加深你对 internal 的理解,目录结构如下: + +├── apiserver +│ ├── api +│ │ └── v1 +│ │ └── user +│ ├── options +│ ├── config +│ ├── service +│ │ └── user.go +│ ├── store +│ │ ├── mysql +│ │ │ └── user.go +│ │ ├── fake +│ └── testing +├── authzserver +│ ├── api +│ │ └── v1 +│ ├── options +│ ├── store +│ └── testing +├── iamctl +│ ├── cmd +│ │ ├── cmd.go +│ │ ├── info +└── pkg + ├── code + ├── middleware + ├── options + └── validation + + +/internal 目录大概分为 3 类子目录: + + +/internal/pkg:内部共享包存放的目录。 +/internal/authzserver、/internal/apiserver、/internal/pump、/internal/iamctl:应用目录,里面包含应用程序的实现代码。 +/internal/iamctl:对于一些大型项目,可能还会需要一个客户端工具。 + + +在每个应用程序内部,也会有一些目录结构,这些目录结构主要根据功能来划分: + + +/internal/apiserver/api/v1:HTTP API 接口的具体实现,主要用来做 HTTP 请求的解包、参数校验、业务逻辑处理、返回。注意这里的业务逻辑处理应该是轻量级的,如果业务逻辑比较复杂,代码量比较多,建议放到 /internal/apiserver/service 目录下。该源码文件主要用来串流程。 +/internal/apiserver/options:应用的 command flag。 +/internal/apiserver/config:根据命令行参数创建应用配置。 +/internal/apiserver/service:存放应用复杂业务处理代码。 +/internal/apiserver/store/mysql:一个应用可能要持久化的存储一些数据,这里主要存放跟数据库交互的代码,比如 Create、Update、Delete、Get、List 等。 + + +/internal/pkg 目录存放项目内可共享的包,通常可以包含如下目录: + + +/internal/pkg/code:项目业务 Code 码。 +/internal/pkg/validation:一些通用的验证函数。 +/internal/pkg/middleware:HTTP 处理链。 + + + +/pkg + + +/pkg 目录是 Go 语言项目中非常常见的目录,我们几乎能够在所有知名的开源项目(非框架)中找到它的身影,例如 Kubernetes、Prometheus、Moby、Knative 等。 + +该目录中存放可以被外部应用使用的代码库,其他项目可以直接通过 import 导入这里的代码。所以,我们在将代码库放入该目录时一定要慎重。 + + +/vendor + + +项目依赖,可通过 go mod vendor 创建。需要注意的是,如果是一个 Go 库,不要提交 vendor 依赖包。 + + +/third_party + + +外部帮助工具,分支代码或其他第三方应用(例如Swagger UI)。比如我们 fork 了一个第三方 go 包,并做了一些小的改动,我们可以放在目录/third_party/forked 下。一方面可以很清楚的知道该包是 fork 第三方的,另一方面又能够方便地和 upstream 同步。 + +Go 应用:主要存放测试相关的文件和代码 + +接着,我们再来看下测试阶段相关的目录,它可以存放测试相关的文件。 + + +/test + + +用于存放其他外部测试应用和测试数据。/test 目录的构建方式比较灵活:对于大的项目,有一个数据子目录是有意义的。例如,如果需要 Go 忽略该目录中的内容,可以使用/test/data 或/test/testdata 目录。 + +需要注意的是,Go 也会忽略以“.”或 “_” 开头的目录或文件。这样在命名测试数据目录方面,可以具有更大的灵活性。 + +Go 应用:存放跟应用部署相关的文件 + +接着,我们再来看下与部署阶段相关的目录,这些目录可以存放部署相关的文件。 + + +/configs + + +这个目录用来配置文件模板或默认配置。例如,可以在这里存放 confd 或 consul-template 模板文件。这里有一点要注意,配置中不能携带敏感信息,这些敏感信息,我们可以用占位符来替代,例如: + +apiVersion: v1 +user: + username: ${CONFIG_USER_USERNAME} # iam 用户名 + password: ${CONFIG_USER_PASSWORD} # iam 密码 + + + +/deployments + + +用来存放 Iaas、PaaS 系统和容器编排部署配置和模板(Docker-Compose,Kubernetes/Helm,Mesos,Terraform,Bosh)。在一些项目,特别是用 Kubernetes 部署的项目中,这个目录可能命名为 deploy。 + +为什么要将这类跟 Kubernetes 相关的目录放到目录结构中呢?主要是因为当前软件部署基本都在朝着容器化的部署方式去演进。 + + +/init + + +存放初始化系统(systemd,upstart,sysv)和进程管理配置文件(runit,supervisord)。比如 sysemd 的 unit 文件。这类文件,在非容器化部署的项目中会用到。 + +项目管理:存放用来管理 Go 项目的各类文件 + +在做项目开发时,还有些目录用来存放项目管理相关的文件,这里我们一起来看下。 + + +/Makefile + + +虽然 Makefile 是一个很老的项目管理工具,但它仍然是最优秀的项目管理工具。所以,一个 Go 项目在其根目录下应该有一个 Makefile 工具,用来对项目进行管理,Makefile 通常用来执行静态代码检查、单元测试、编译等功能。其他常见功能,你可以参考这里: Makefile常见管理内容 。 + +我还有一条建议:直接执行 make 时,执行如下各项 format -> lint -> test -> build,如果是有代码生成的操作,还可能需要首先生成代码 gen -> format -> lint -> test -> build。 + +在实际开发中,我们可以将一些重复性的工作自动化,并添加到 Makefile 文件中统一管理。 + + +/scripts + + +该目录主要用来存放脚本文件,实现构建、安装、分析等不同功能。不同项目,里面可能存放不同的文件,但通常可以考虑包含以下 3 个目录: + + +/scripts/make-rules:用来存放 makefile 文件,实现/Makefile 文件中的各个功能。Makefile 有很多功能,为了保持它的简洁,我建议你将各个功能的具体实现放在/scripts/make-rules 文件夹下。 +/scripts/lib:shell 库,用来存放 shell 脚本。一个大型项目中有很多自动化任务,比如发布、更新文档、生成代码等,所以要写很多 shell 脚本,这些 shell 脚本会有一些通用功能,可以抽象成库,存放在/scripts/lib 目录下,比如 logging.sh,util.sh 等。 +/scripts/install:如果项目支持自动化部署,可以将自动化部署脚本放在此目录下。如果部署脚本简单,也可以直接放在/scripts 目录下。 + + +另外,shell 脚本中的函数名,建议采用语义化的命名方式,例如 iam::log::info 这种语义化的命名方式,可以使调用者轻松的辨别出函数的功能类别,便于函数的管理和引用。在Kubernetes 的脚本中,就大量采用了这种命名方式。 + + +/build + + +这里存放安装包和持续集成相关的文件。这个目录下有 3 个大概率会使用到的目录,在设计目录结构时可以考虑进去。 + + +/build/package:存放容器(Docker)、系统(deb, rpm, pkg)的包配置和脚本。 +/build/ci:存放 CI(travis,circle,drone)的配置文件和脚本。 +/build/docker:存放子项目各个组件的 Dockerfile 文件。 + + + +/tools + + +存放这个项目的支持工具。这些工具可导入来自/pkg 和/internal 目录的代码。 + + +/githooks + + +Git 钩子。比如,我们可以将 commit-msg 存放在该目录。 + + +/assets + + +项目使用的其他资源(图片、CSS、JavaScript 等)。 + + +/website + + +如果你不使用 GitHub 页面,那么可以在这里放置项目网站相关的数据。 + +文档:主要存放项目的各类文档 + +一个项目,也包含一些文档,这些文档有很多类别,也需要一些目录来存放这些文档,这里我们也一起来看下。 + + +/README.md + + +项目的 README 文件一般包含了项目的介绍、功能、快速安装和使用指引、详细的文档链接以及开发指引等。有时候 README 文档会比较长,为了能够快速定位到所需内容,需要添加 markdown toc 索引,可以借助工具 tocenize 来完成索引的添加。 + +这里还有个建议,前面我们也介绍过 README 是可以规范化的,所以这个 README 文档,可以通过脚本或工具来自动生成。 + + +/docs + + +存放设计文档、开发文档和用户文档等(除了 godoc 生成的文档)。推荐存放以下几个子目录: + + +/docs/devel/{en-US,zh-CN}:存放开发文档、hack 文档等。 +/docs/guide/{en-US,zh-CN}: 存放用户手册,安装、quickstart、产品文档等,分为中文文档和英文文档。 +/docs/images:存放图片文件。 + + + +/CONTRIBUTING.md + + +如果是一个开源就绪的项目,最好还要有一个 CONTRIBUTING.md 文件,用来说明如何贡献代码,如何开源协同等等。CONTRIBUTING.md 不仅能够规范协同流程,还能降低第三方开发者贡献代码的难度。 + + +/api + + +/api 目录中存放的是当前项目对外提供的各种不同类型的 API 接口定义文件,其中可能包含类似 /api/protobuf-spec、/api/thrift-spec、/api/http-spec、openapi、swagger 的目录,这些目录包含了当前项目对外提供和依赖的所有 API 文件。例如,如下是 IAM 项目的/api 目录: + +├── openapi/ +│ └── README.md +└── swagger/ + ├── docs/ + ├── README.md + └── swagger.yaml + + +二级目录的主要作用,就是在一个项目同时提供了多种不同的访问方式时,可以分类存放。用这种方式可以避免潜在的冲突,也能让项目结构更加清晰。 + + +/LICENSE + + +版权文件可以是私有的,也可以是开源的。常用的开源协议有:Apache 2.0、MIT、BSD、GPL、Mozilla、LGPL。有时候,公有云产品为了打造品牌影响力,会对外发布一个本产品的开源版本,所以在项目规划初期最好就能规划下未来产品的走向,选择合适的 LICENSE。 + +为了声明版权,你可能会需要将 LICENSE 头添加到源码文件或者其他文件中,这部分工作可以通过工具实现自动化,推荐工具: addlicense 。 + +当代码中引用了其它开源代码时,需要在 LICENSE 中说明对其它源码的引用,这就需要知道代码引用了哪些源码,以及这些源码的开源协议,可以借助工具来进行检查,推荐工具: glice 。至于如何说明对其它源码的引用,大家可以参考下 IAM 项目的 LICENSE 文件。 + + +/CHANGELOG + + +当项目有更新时,为了方便了解当前版本的更新内容或者历史更新内容,需要将更新记录存放到 CHANGELOG 目录。编写 CHANGELOG 是一个复杂、繁琐的工作,我们可以结合 Angular规范 和 git-chglog 来自动生成 CHANGELOG。 + + +/examples + + +存放应用程序或者公共包的示例代码。这些示例代码可以降低使用者的上手门槛。 + +不建议的目录 + +除了上面这些我们建议的目录,在 Go 项目中,还有一些目录是不建议包含的,这些目录不符合 Go 的设计哲学。 + + +/src/ + + +一些开发语言,例如 Java 项目中会有 src 目录。在 Java 项目中, src 目录是一种常见的模式,但在 Go 项目中,不建议使用 src 目录。 + +其中一个重要的原因是:在默认情况下,Go 语言的项目都会被放置到$GOPATH/src 目录下。这个目录中存放着所有代码,如果我们在自己的项目中使用/src 目录,这个包的导入路径中就会出现两个 src,例如: + +$GOPATH/src/github.com/marmotedu/project/src/main.go + + +这样的目录结构看起来非常怪。 + + +xxs/ + + +在 Go 项目中,要避免使用带复数的目录或者包。建议统一使用单数。 + +一些建议 + +上面介绍的目录结构包含很多目录,但一个小型项目用不到这么多目录。对于小型项目,可以考虑先包含 cmd、pkg、internal 3 个目录,其他目录后面按需创建,例如: + +$ tree --noreport -L 2 tms +tms +├── cmd +├── internal +├── pkg +└── README.md + + +另外,在设计目录结构时,一些空目录无法提交到 Git 仓库中,但我们又想将这个空目录上传到 Git 仓库中,以保留目录结构。这时候,可以在空目录下加一个 .keep 文件,例如: + +$ ls -A build/ci/ +.keep + + +总结 + +今天我们主要学习了怎么设计代码的目录结构。先讲了目录结构的设计思路:在设计目录结构时,要确保目录名是清晰的,功能是明确的,并且设计的目录结构是可扩展的。 + +然后,我们一起学习了 2 种具体的目录结构:结构化目录结构和平铺式目录结构。结构化目录结构比较适合 Go 应用,平铺式目录结构比较适合框架/库。因为这2种目录结构组织比较合理,可以把它们作为目录规范来使用。 + +你还可以结合实战项目的例子,来加深对这两种目录结构的理解。对于结构化目录结构,你可以参考这门课 IAM 实战项目的目录结构;对于平铺式的目录结构,你可以参考这门课实战部分设计的 log 包。 + +课后练习 + + +试着用本节课描述的目录规范,重构下你当前的项目,并看下有啥优缺点。 +思考下你工作中遇到过哪些比较好的目录结构,它们有什么优点和可以改进的地方。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/07工作流设计:如何设计合理的多人开发模式?.md b/专栏/Go语言项目开发实战/07工作流设计:如何设计合理的多人开发模式?.md new file mode 100644 index 0000000..8f8ed5d --- /dev/null +++ b/专栏/Go语言项目开发实战/07工作流设计:如何设计合理的多人开发模式?.md @@ -0,0 +1,397 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 工作流设计:如何设计合理的多人开发模式? + 你好,我是孔令飞。今天我们来聊聊如何设计合理的开发模式。 + +一个企业级项目是由多人合作完成的,不同开发者在本地开发完代码之后,可能提交到同一个代码仓库,同一个开发者也可能同时开发几个功能特性。这种多人合作开发、多功能并行开发的特性如果处理不好,就会带来诸如丢失代码、合错代码、代码冲突等问题。 + +所以,在编码之前,我们需要设计一个合理的开发模式。又因为目前开发者基本都是基于 Git 进行开发的,所以本节课,我会教你怎么基于 Git 设计出一个合理的开发模式。 + +那么如何设计工作流呢?你可以根据需要,自己设计工作流,也可以采用业界沉淀下来的、设计好的、受欢迎的工作流。一方面,这些工作流经过长时间的实践,被证明是合理的;另一方面,采用一种被大家熟知且业界通用的工作流,会减少团队内部磨合的时间。在这一讲中,我会为你介绍4种受欢迎的工作流,你可以选择其中一种作为你的工作流设计。 + +在使用 Git 开发时,有4种常用的工作流,也叫开发模式,按演进顺序分为集中式工作流、功能分支工作流、Git Flow 工作流和Forking 工作流。接下来,我会按演进顺序分别介绍这 4 种工作流。 + +集中式工作流 + +我们先来看看集中式工作流,它是最简单的一种开发方式。集中式工作流的工作模式如下图所示: + + + +A、B、C 为 3 位开发者,每位开发者都在本地有一份远程仓库的拷贝:本地仓库。A、B、C 在本地的 master 分支开发完代码之后,将修改后的代码commit到远程仓库,如果有冲突就先解决本地的冲突再提交。在进行了一段时间的开发之后,远程仓库 master 分支的日志可能如下图所示: + + + +集中式工作流是最简单的开发模式,但它的缺点也很明显:不同开发人员的提交日志混杂在一起,难以定位问题。如果同时开发多个功能,不同功能同时往 master 分支合并,代码之间也会相互影响,从而产生代码冲突。 + +和其他工作流相比,集中式工作流程的代码管理较混乱,容易出问题,因此适合用在团队人数少、开发不频繁、不需要同时维护多个版本的小项目中。当我们想要并行开发多个功能时,这种工作流就不适用了,这时候怎么办呢?我们接下来看功能分支工作流。 + +功能分支工作流 + +功能分支工作流基于集中式工作流演进而来。在开发新功能时,基于 master 分支新建一个功能分支,在功能分支上进行开发,而不是直接在本地的 master 分支开发,开发完成之后合并到 master 分支,如下图所示: + + + +相较于集中式工作流,这种工作流让不同功能在不同的分支进行开发,只在最后一步合并到master分支,不仅可以避免不同功能之间的相互影响,还可以使提交历史看起来更加简洁。 + +还有,在合并到 master 分支时,需要提交 PR(pull request),而不是直接将代码 merge 到 master 分支。PR 流程不仅可以把分支代码提供给团队其他开发人员进行 CR(Code Review),还可以在 PR 页面讨论代码。通过 CR ,我们可以确保合并到 master 的代码是健壮的;通过 PR 页面的讨论,可以使开发者充分参与到代码的讨论中,有助于提高代码的质量,并且提供了一个代码变更的历史回顾途径。 + +那么,功能分支工作流具体的开发流程是什么呢?我们一起来看下。 + + +基于 master 分支新建一个功能分支,功能分支可以取一些有意义的名字,便于理解,例如feature/rate-limiting。 + + +$ git checkout -b feature/rate-limiting + + + +在功能分支上进行代码开发,开发完成后 commit 到功能分支。 + + +$ git add limit.go +$ git commit -m "add rate limiting" + + + +将本地功能分支代码 push 到远程仓库。 + + +$ git push origin feature/rate-limiting + + + +在远程仓库上创建 PR(例如:GitHub)。 + + +进入 GitHub 平台上的项目主页,点击 Compare & pull request 提交 PR,如下图所示。 + + + +点击 Compare & pull request 后会进入 PR 页面,在该页面中可以根据需要填写评论,最后点击 Create pull request 提交 PR。 + + +代码管理员收到 PR 后,可以 CR 代码,CR 通过后,再点击 Merge pull request 将 PR 合并到 master,如下图所示。 + + + + +图中的“Merge pull request” 提供了 3 种 merge 方法: + + +Create a merge commit:GitHub 的底层操作是 git merge --no-ff。feature 分支上所有的 commit 都会加到 master 分支上,并且会生成一个 merge commit。这种方式可以让我们清晰地知道是谁做了提交,做了哪些提交,回溯历史的时候也会更加方便。 +Squash and merge:GitHub 的底层操作是 git merge --squash。Squash and merge会使该 pull request 上的所有 commit 都合并成一个commit ,然后加到master分支上,但原来的 commit 历史会丢失。如果开发人员在 feature 分支上提交的 commit 非常随意,没有规范,那么我们可以选择这种方法来丢弃无意义的 commit。但是在大型项目中,每个开发人员都应该是遵循 commit 规范的,因此我不建议你在团队开发中使用 Squash and merge。 +Rebase and merge:GitHub 的底层操作是 git rebase。这种方式会将 pull request 上的所有提交历史按照原有顺序依次添加到 master 分支的头部(HEAD)。因为git rebase 有风险,在你不完全熟悉 Git 工作流时,我不建议merge时选择这个。 + + +通过分析每个方法的优缺点,在实际的项目开发中,我比较推荐你使用 Create a merge commit 方式。 + +从刚才讲完的具体开发流程中,我们可以感受到,功能分支工作流上手比较简单,不仅能使你并行开发多个功能,还可以添加code review,从而保障代码质量。当然它也有缺点,就是无法给分支分配明确的目的,不利于团队配合。它适合用在开发团队相对固定、规模较小的项目中。接下来我们要讲的Git Flow 工作流以功能分支工作流为基础,较好地解决了上述问题。 + +Git Flow 工作流 + +Git Flow 工作流是一个非常成熟的方案,也是非开源项目中最常用到的工作流。它定义了一个围绕项目发布的严格分支模型,通过为代码开发、发布和维护分配独立的分支来让项目的迭代流程更加顺畅,比较适合大型的项目或者迭代速度快的项目。接下来,我会通过介绍Git Flow的5种分支和工作流程,来给你讲解GIt Flow是如何工作的。 + +Git Flow 的5种分支 + +Git Flow 中定义了 5 种分支,分别是 master、develop、feature、release和 hotfix。其中,master 和 develop 为常驻分支,其他为非常驻分支,不同的研发阶段会用到不同的分支。这5种分支的详细介绍见下表: + + + +Git Flow 开发流程 + +这里我们用一个实际的例子来演示下Git Flow 的开发流程。场景如下: + +a. 当前版本为:0.9.0。 + +b. 需要新开发一个功能,使程序执行时向标准输出输出“hello world”字符串。 + +c. 在开发阶段,线上代码有 Bug 需要紧急修复。 + +假设我们的 Git 项目名为 gitflow-demo,项目目录下有 2 个文件,分别是 README.md 和 main.go,内容如下。 + +package main + +import "fmt" + +func main() { + fmt.Println("callmainfunction") +} + + +具体的开发流程有 12 步,你可以跟着以下步骤操作练习。 + + +创建一个常驻的分支:develop。 + + +$ git checkout -b develop master + + + +基于 develop 分支,新建一个功能分支:feature/print-hello-world。 + + +$ git checkout -b feature/print-hello-world develop + + + +feature/print-hello-world 分支中,在 main.go 文件中添加一行代码fmt.Println("Hello"),添加后的代码如下。 + + +package main + +import "fmt" + +func main() { + fmt.Println("callmainfunction") + fmt.Println("Hello") +} + + + +紧急修复 Bug。 + + +我们正处在新功能的开发中(只完成了 fmt.Println("Hello")而非 fmt.Println("Hello World"))突然线上代码发现了一个 Bug,我们要立即停止手上的工作,修复线上的 Bug,步骤如下。 + +$ git stash # 1. 开发工作只完成了一半,还不想提交,可以临时保存修改至堆栈区 +$ git checkout -b hotfix/print-error master # 2. 从 master 建立 hotfix 分支 +$ vi main.go # 3. 修复 bug,callmainfunction -> call main function +$ git commit -a -m 'fix print message error bug' # 4. 提交修复 +$ git checkout develop # 5. 切换到 develop 分支 +$ git merge --no-ff hotfix/print-error # 6. 把 hotfix 分支合并到 develop 分支 +$ git checkout master # 7. 切换到 master 分支 +$ git merge --no-ff hotfix/print-error # 8. 把 hotfix 分支合并到 master +$ git tag -a v0.9.1 -m "fix log bug" # 9. master 分支打 tag +$ go build -v . # 10. 编译代码,并将编译好的二进制更新到生产环境 +$ git branch -d hotfix/print-error # 11. 修复好后,删除 hotfix/xxx 分支 +$ git checkout feature/print-hello-world # 12. 切换到开发分支下 +$ git merge --no-ff develop # 13. 因为 develop 有更新,这里最好同步更新下 +$ git stash pop # 14. 恢复到修复前的工作状态 + + + +继续开发。 + + +在 main.go 中加入 fmt.Println("Hello World")。 + + +提交代码到 feature/print-hello-world 分支。 + + +$ git commit -a -m "print 'hello world'" + + + +在 feature/print-hello-world 分支上做 code review。 + + +首先,我们需要将 feature/print-hello-world push 到代码托管平台,例如 GitHub 上。 + +$ git push origin feature/print-hello-world + + +然后,我们在 GitHub 上,基于 feature/print-hello-world 创建 pull request,如下图所示。 + + + +创建完 pull request 之后,我们就可以指定 Reviewers 进行 code review,如下图所示。 + + + + +code review 通过后,由代码仓库 matainer 将功能分支合并到 develop 分支。 + + +$ git checkout develop +$ git merge --no-ff feature/print-hello-world + + + +基于 develop 分支,创建 release 分支,测试代码。 + + +$ git checkout -b release/1.0.0 develop +$ go build -v . # 构建后,部署二进制文件,并测试 + + + +测试失败,因为我们要求打印“hello world”,但打印的是“Hello World”,修复的时候, + + +我们直接在 release/1.0.0 分支修改代码,修改完成后,提交并编译部署。 + +$ git commit -a -m "fix bug" +$ go build -v . + + + +测试通过后,将功能分支合并到 master 分支和 develop 分支。 + + +$ git checkout develop +$ git merge --no-ff release/1.0.0 +$ git checkout master +$ git merge --no-ff release/1.0.0 +$ git tag -a v1.0.0 -m "add print hello world" # master 分支打 tag + + + +删除 feature/print-hello-world 分支,也可以选择性删除 release/1.0.0 分支。 + + +$ git branch -d feature/print-hello-world + + +亲自操作一遍之后,你应该会更了解这种模式的优缺点。它的缺点,就是你刚才已经体会到的,它有一定的上手难度。不过Git Flow工作流还是有很多优点的:Git Flow工作流的每个分支分工明确,这可以最大程度减少它们之间的相互影响。因为可以创建多个分支,所以也可以并行开发多个功能。另外,和功能分支工作流一样,它也可以添加code review,保障代码质量。 + +因此,Git Flow工作流比较适合开发团队相对固定,规模较大的项目。 + +Forking 工作流 + +上面讲的Git Flow 是非开源项目中最常用的,而在开源项目中,最常用到的是Forking 工作流,例如 Kubernetes、Docker 等项目用的就是这种工作流。这里,我们先来了解下 fork 操作。 + +fork 操作是在个人远程仓库新建一份目标远程仓库的副本,比如在 GitHub 上操作时,在项目的主页点击 fork 按钮(页面右上角),即可拷贝该目标远程仓库。Forking 工作流的流程如下图所示。 + + + +假设开发者 A 拥有一个远程仓库,如果开发者 B 也想参与 A 项目的开发,B 可以 fork 一份 A 的远程仓库到自己的 GitHub 账号下。后续 B 可以在自己的项目进行开发,开发完成后,B 可以给 A 提交一个 PR。这时候 A 会收到通知,得知有新的 PR 被提交,A 会去查看 PR 并 code review。如果有问题,A 会直接在 PR 页面提交评论,B 看到评论后会做进一步的修改。最后 A 通过 B 的 PR 请求,将代码合并进了 A 的仓库。这样就完成了 A 代码仓库新特性的开发。如果有其他开发者想给 A 贡献代码,也会执行相同的操作。 + +GitHub中的 Forking 工作流详细步骤共有6步(假设目标仓库为 gitflow-demo),你可以跟着以下步骤操作练习。 + + +Fork 远程仓库到自己的账号下。 + + +访问https://github.com/marmotedu/gitflow-demo ,点击fork按钮。fork 后的仓库地址为:https://github.com/colin404fork/gitflow-demo 。- + + +克隆 fork 的仓库到本地。 + + +$ git clone https://github.com/colin404fork/gitflow-demo +$ cd gitflow-demo +$ git remote add upstream https://github.com/marmotedu/gitflow-demo +$ git remote set-url --push upstream no_push # Never push to upstream master +$ git remote -v # Confirm that your remotes make sense +origin https://github.com/colin404fork/gitflow-demo (fetch) +origin https://github.com/colin404fork/gitflow-demo (push) +upstream https://github.com/marmotedu/gitflow-demo (fetch) +upstream https://github.com/marmotedu/gitflow-demo (push) + + + +创建功能分支。 + + +首先,要同步本地仓库的 master 分支为最新的状态(跟 upstream master 分支一致)。 + +$ git fetch upstream +$ git checkout master +$ git rebase upstream/master + + +然后,创建功能分支。 + +$ git checkout -b feature/add-function + + + +提交 commit。 + + +在 feature/add-function 分支上开发代码,开发完代码后,提交 commit。 + +$ git fetch upstream # commit 前需要再次同步 feature 跟 upstream/master +$ git rebase upstream/master +$ git add +$ git status +$ git commit + + +分支开发完成后,可能会有一堆 commit,但是合并到主干时,我们往往希望只有一个(或最多两三个)commit,这可以使功能修改都放在一个或几个commit中,便于后面的阅读和维护。这个时候,我们可以用 git rebase 来合并和修改我们的 commit,操作如下: + +$ git rebase -i origin/master + + +第5讲已经介绍过了git rebase -i 的使用方法 ,如果你有疑问可以再去看看,这里不再说明。 + +还有另外一种合并 commit 的简便方法,就是先撤销过去 5 个 commit,然后再建一个新的: + +$ git reset HEAD~5 +$ git add . +$ git commit -am "Here's the bug fix that closes #28" +$ git push --force + + +squash 和 fixup 命令,还可以当作命令行参数使用,自动合并 commit。 + +$ git commit --fixup +$ git rebase -i --autosquash + + + +push 功能分支到个人远程仓库。 + + +在完成了开发,并 commit 后,需要将功能分支 push 到个人远程代码仓库,代码如下: + +$ git push -f origin feature/add-function + + + +在个人远程仓库页面创建 pull request。 + + +提交到远程仓库以后,我们就可以创建 pull request,然后请求reviewers进行代码 review,确认后合并到 master。这里要注意,创建pull request时,base通常选择目标远程仓库的master分支。 + +我们已经讲完了 Forking 工作流的具体步骤,你觉得它有什么优缺点呢? + +结合操作特点,我们来看看它的优点:Forking工作流中,项目远程仓库和开发者远程仓库完全独立,开发者通过提交 Pull Request 的方式给远程仓库贡献代码,项目维护者选择性地接受任何开发者的提交,通过这种方式,可以避免授予开发者项目远程仓库的权限,从而提高项目远程仓库的安全性,这也使得任意开发者都可以参与项目的开发。 + +但Forking工作流也有局限性,就是对于职能分工明确且不对外开源的项目优势不大。 + +Forking工作流比较适用于以下三种场景:(1)开源项目中;(2)开发者有衍生出自己的衍生版的需求;(3)开发者不固定,可能是任意一个能访问到项目的开发者。 + +总结 + +这一讲中,我基于 Git 向你介绍了 4 种开发模式,现在跟我回顾一下吧。 + + +集中式工作流:开发者直接在本地 master 分支开发代码,开发完成后 push 到远端仓库 master 分支。 +功能分支工作流:开发者基于 master 分支创建一个新分支,在新分支进行开发,开发完成后合并到远端仓库 master 分支。 +Git Flow 工作流:Git Flow 工作流为不同的分支分配一个明确的角色,并定义分支之间什么时候、如何进行交互,比较适合大型项目的开发。 +Forking 工作流:开发者先 fork 项目到个人仓库,在个人仓库完成开发后,提交 pull request 到目标远程仓库,远程仓库 review 后,合并 pull request 到 master 分支。 + + +集中式工作流是最早的Git工作流,功能分支工作流以集中式工作流为基础,Git Flow 工作流又是以功能分支工作流为基础,Forking工作流在Git Flow 工作流基础上,解耦了个人远端仓库和项目远端仓库。 + +每种开发模式各有优缺点,适用于不同的场景,我总结在下表中: + + + +总的来说,在选择工作流时,我的推荐如下: + + +非开源项目采用 Git Flow 工作流。 +开源项目采用 Forking 工作流。 + + +因为这门课的实战项目对于项目开发者来说是一个偏大型的非开源项目,所以采用了Git Flow工作流。 + +课后练习 + + +请你新建立一个项目,并参考Git Flow开发流程,自己操作一遍,观察每一步的操作结果。 +请你思考下,在 Git Flow 工作流中,如果要临时解决一个 Bug,该如何操作代码仓库。 + + +期待在留言区看到你的思考和分享,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/08研发流程设计(上):如何设计Go项目的开发流程?.md b/专栏/Go语言项目开发实战/08研发流程设计(上):如何设计Go项目的开发流程?.md new file mode 100644 index 0000000..b6c4863 --- /dev/null +++ b/专栏/Go语言项目开发实战/08研发流程设计(上):如何设计Go项目的开发流程?.md @@ -0,0 +1,202 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 研发流程设计(上):如何设计 Go 项目的开发流程? + 你好,我是孔令飞。今天我们来聊聊如何设计研发流程。 + +在Go 项目开发中,我们不仅要完成产品功能的开发,还要确保整个过程是高效的,代码是高质量的。这就离不开一套设计合理的研发流程了。 + +而一个不合理的研发流程会带来很多问题,例如: + + +代码管理混乱。合并代码时出现合错、合丢、代码冲突等问题。 +研发效率低。编译、测试、静态代码检查等全靠手动操作,效率低下。甚至,因为没有标准的流程,一些开发者会漏掉测试、静态代码检查等环节。 +发布效率低。发布周期长,以及发布不规范造成的现网问题频发。 + + +所以,Go 项目开发一定要设计一个合理的研发流程,来提高开发效率、减少软件维护成本。研发流程会因为项目、团队和开发模式等的不同而有所不同,但不同的研发流程依然会有一些相似点。 + +那么如何设计研发流程呢?这也是你看到题目中“设计”两个字后,会直接想要问的。看到这俩字,你第一时间可能会觉得我是通过一系列的方法论,来告诉你怎么进行流程设计。但实际情况是,项目研发流程会因为团队、项目、需求等的不同而不同,很难概括出一个方法论让你去设计研发流程。 + +所以在这一讲中,我会介绍一种业界已经设计好的、相对标准的研发流程,来告诉你怎么设计研发流程。通过学习它,你不仅能够了解到项目研发的通用流程,而且还可以基于这个流程来优化、定制,满足你自己的流程需求。 + +在设计研发流程时,需要关注哪些点? + +在看具体的研发流程之前,我们需要先思考一个问题:你觉得,一个好的流程应该是什么样子的? + +虽然我们刚才说了,不同团队、项目、需求的研发流程不会一成不变,但为了最大限度地提高研发效能,这些不同的流程都会遵循下面这几个原则。 + + +发布效率高:研发流程应该能提高发布效率,减少发布时间和人工介入的工作量。 +发布质量高:研发流程应该能够提高发布质量,确保发布出去的代码是经过充分测试的,并且完全避免人为因素造成的故障。 +迭代速度快:整个研发流程要能支持快速迭代,产品迭代速度越快,意味着产品的竞争力越强,在互联网时代越能把握先机。 +明确性:整个研发流程中角色的职责、使用的工具、方法和流程都应该是明确的,这可以增强流程的可执行性。 +流程合理:研发流程最终是供产品、开发、测试、运维等人员使用的,所以整个流程设计不能是反人类的,要能够被各类参与人员接受并执行。 +柔性扩展:研发流程应该是柔性且可扩展的,能够灵活变通,并适应各类场景。 +输入输出:研发流程中的每个阶段都应该有明确的输入和输出,这些输入和输出标志着上一个阶段的完成,下一个阶段的开始。 + + +明确了这些关注点,我们就有了设计、优化研发流程的抓手了。接下来,我们就可以一起去学习一套业界相对标准的研发流程了。在学习的过程中,你也能更好地理解我对各个流程的一些经验和建议了。 + +业界相对标准的研发流程,长啥样? + +一个项目从立项到结项,中间会经历很多阶段。业界相对标准的划分,是把研发流程分为六个阶段,分别是需求阶段、设计阶段、开发阶段、测试阶段、发布阶段、运营阶段。其中,开发人员需要参与的阶段有4个:设计阶段、开发阶段、测试阶段和发布阶段。下图就是业界相对比较标准的流程: + + + +每个阶段结束时,都需要有一个最终的产出物,可以是文档、代码或者部署组件等。这个产出物既是当前阶段的结束里程碑,又是下一阶段的输入。所以说,各个阶段不是割裂的,而是密切联系的整体。每个阶段又细分为很多步骤,这些步骤是需要不同的参与者去完成的工作任务。在完成任务的过程中,可能需要经过多轮的讨论、修改,最终形成定稿。 + +这里有个点我们一定要注意:研发流程也是一种规范,很难靠开发者的自觉性去遵守。为了让项目参与人员尽可能地遵守规范,需要借助一些工具、系统来对他们进行强约束。所以,在我们设计完整个研发流程之后,需要认真思考下,有哪些地方可以实现自动化,有哪些地方可以靠工具、系统来保障规范的执行。这些自动化工具会在第 16 讲 中详细介绍。 + +接下来,咱们就具体看看研发的各个阶段,以及每个阶段的具体内容。 + +需求阶段 + +需求阶段是将一个抽象的产品思路具化成一个可实施产品的阶段。在这个阶段,产品人员会讨论产品思路、调研市场需求,并对需求进行分析,整理出一个比较完善的需求文档。最后,产品人员会组织相关人员对需求进行评审,如果评审通过,就会进入设计阶段。 + +需求阶段,一般不需要研发人员参与。但这里,我还是建议你积极参与产品需求的讨论。虽然我们是研发,但我们的视野和对团队的贡献,可以不仅仅局限在研发领域。 + +这里有个点需要提醒你,如果你们团队有测试人员,这个阶段也需要拉测试人员旁听下。因为了解产品设计,对测试阶段测试用例的编写和功能测试等都很有帮助。 + +需求阶段的产出物是一个通过评审的详细的需求文档。 + +设计阶段 + +设计阶段,是整个产品研发过程中非常重要的阶段,包括的内容也比较多,你可以看一下这张表: + + + +这里的每一个设计项都应该经过反复的讨论、打磨,最终在团队内达成共识。这样可以确保设计是合理的,并减少返工的概率。这里想提醒你的是,技术方案和实现都要经过认真讨论,并获得一致通过,否则后面因为技术方案设计不当,需要返工,你要承担大部分责任。 + +对于后端开发人员,在设计技术方案之前,要做好充足的调研。一个技术方案,不仅要调研业界优秀的实现,还要了解友商相同技术的实现。只有这样,才可以确保我们的技术用最佳的方式实现。 + +除此之外,在这个阶段一些设计项可以并行,以缩短设计阶段的耗时。例如,产品设计和技术设计可以并行展开。另外,如果你们团队有测试人员,研发阶段最好也拉上测试人员旁听下,有利于后面的测试。 + +该阶段的产出物是一系列的设计文档,这些文档会指导后面的整个研发流程。 + +开发阶段 + +开发阶段,从它的名字你就知道了,这是开发人员的主战场,同时它可能也是持续时间最长的阶段。在这一阶段,开发人员根据技术设计文档,编码实现产品需求。 + +开发阶段是整个项目的核心阶段,包含很多工作内容,而且每一个 Go 项目具体的步骤是不同的。我把开发阶段的常见步骤总结在了下图中,帮助你对它进行整体把握。 + + + +让我们来详细看下这张图里呈现的步骤。开发阶段又可以分为“开发”和“构建”两部分,我们先来看开发。 + +首先,我们需要制定一个所有研发人员共同遵循的 Git 工作流规范。最常使用的是 Git Flow 工作流或者 Forking 工作流。 + +为了提高开发效率,越来越多的开发者采用生成代码的方式来生成一部分代码,所以在真正编译之前可能还需要先生成代码,比如生成.pb.go 文件、API 文档、测试用例、错误码等。我的建议是,在项目开发中,你要思考怎么尽可能自动生成代码。这样不仅能提高研发效率,还能减少错误。 + +对于一个开源项目,我们可能还需要检查新增的文件是否有版权信息。此外,根据项目不同,开发阶段还可能有其它不同的步骤。在流程的最后,通常会进行静态代码检查、单元测试和编译。编译之后,我们就可以启动服务,并进行自测了。 + +自测之后,我们可以遵循 Git Flow 工作流,将开发分支 push 到代码托管平台进行 code review。code review 通过之后,我们就可以将代码 merge 到 develop 分支上。 + +接下来进入构建阶段。这一阶段最好借助 CI/CD 平台实现自动化,提高构建效率。 + +合并到 develop 分支的代码同样需要进行代码扫描、单元测试,并编译打包。最后,我们需要进行归档,也就是将编译后的二进制文件或 Docker 镜像上传到制品库或镜像仓库。 + +我刚刚带着你完整走了一遍开发阶段的常见步骤。可以看到,整个开发阶段步骤很多,而且都是高频的操作。那怎么提高效率呢?这里我推荐你两种方法: + + +将开发阶段的步骤通过 Makefile 实现集中管理; +将构建阶段的步骤通过 CI/CD 平台实现自动化。 + + +你还需要特别注意这一点:在最终合并代码到 master 之前,要确保代码是经过充分测试的。这就要求我们一定要借助代码管理平台提供的 Webhook 能力,在代码提交时触发 CI/CD 作业,对代码进行扫描、测试,最终编译打包,并以整个作业的成功执行作为合并代码的先决条件。 + +开发阶段的产出物是满足需求的源代码、开发文档,以及编译后的归档文件。 + +测试阶段 + +测试阶段由测试工程师(也叫质量工程师)负责,这个阶段的主要流程是:测试工程师根据需求文档创建测试计划、编写测试用例,并拉研发同学一起评审测试计划和用例。评审通过后,测试工程师就会根据测试计划和测试用例对服务进行测试。 + +为了提高整个研发效率,测试计划的创建和测试用例的编写可以跟开发阶段并行。 + +研发人员在交付给测试时,要提供自测报告、自测用例和安装部署文档。这里我要强调的是:在测试阶段,为了不阻塞测试,确保项目按时发布,研发人员应该优先解决测试同学的Bug,至少是阻塞类的Bug。为了减少不必要的沟通和排障,安装部署文档要尽可能详尽和准确。 + +另外,你也可以及时跟进测试,了解测试同学当前遇到的卡点。因为实际工作中,一些测试同学在遇到卡点时,不善于或者不会及时地跟你同步卡点,往往研发1分钟就可以解决的问题,可能要花测试同学几个小时或者更久的时间去解决。 + +当然,测试用例几乎不可能涵盖整个变更分支,所以对于一些难测,隐藏的测试,需要研发人员自己加强测试。 + +最后,一个大特性测试完,请测试同学吃个饭吧,大家唠唠家常,联络联络感情,下次合作会更顺畅。 + +测试阶段的产出物是满足产品需求、达到发布条件的源代码,以及编译后的归档文件。 + +发布阶段 + +发布阶段主要是将软件部署上线,为了保证发布的效率和质量,我们需要遵循一定的发布流程,如下图所示: + + + +发布阶段按照时间线排序又分为代码发布、发布审批和服务发布3个子阶段。接下来,我详细给你介绍下这3个子阶段。我们先来看一下代码发布。 + +首先,开发人员首先需要将经过测试后的代码合并到主干,通常是 master 分支,并生成版本号,然后给最新的 commit 打上版本标签。之后,可以将代码 push 到代码托管平台,并触发 CI 流程,CI流程一般会执行代码扫描、单元测试、编译,最后将构建产物发布到制品库。CI流程中,我们可以根据需要添加任意功能。 + +接着,进入到发布审批阶段。首先需要申请资源,资源申请周期可能会比较久,所以申请得越早越好,甚至资源申请可以在测试阶段发起。在资源申请阶段,可以申请诸如服务器、MySQL、Redis、Kafka 之类资源。 + +资源申请通常是开发人员向运维人员提需求,由运维人员根据需求,在指定的时间前准备好各类资源。如果是物理机通常申请周期会比较久,但当前越来越多的项目选择容器化部署,这可以极大地缩短资源的申请周期。如果在像腾讯云弹性容器这类Serverless容器平台上部署业务,甚至可以秒申请资源。所以这里,我也建议优先采用容器化部署。 + +发布之前需要创建发布计划,里面需要详细描述本次的变更详情,例如变更范围、发布方案、测试结果、验证和回滚方案等。这里需要你注意,在创建发布计划时,一定要全面梳理这次变更的影响点。例如,是否有不兼容的变更,是否需要变更配置,是否需要变更数据库等。任何一个遗漏,都可能造成现网故障,影响产品声誉和用户使用。 + +接下来,需要创建发布单,在发布单中可以附上发布计划,并根据团队需求填写其它发布内容,发布计划需要跟相关参与者对齐流程、明确职责。发布单最终提交给审批人(通常是技术 leader)对本次发布进行审批,审批通过后,才可以进行部署。 + +最后,就可以进入到服务发布阶段,将服务发布到现网。在正式部署的时候,应用需要先部署到预发环境。在预发环境,产品人员、测试人员和研发人员会分别对产品进行验证。其中,产品人员主要验证产品功能的体验是否流畅,开发和测试人员主要验证产品是否有 Bug。预发环境验证通过,产品才能正式发布到现网。 + +这里,我强烈建议,编写一些自动化的测试用例,在服务发布到现网之后,对现网服务做一次比较充分的回归测试。通过这个自动化测试,可以以最小的代价,最快速地验证现网功能,从而保障发布质量。 + +另外,我们还要注意,现网可能有多个地域,每个地域发布完成之后都要进行现网验证。 + +发布阶段的产出物是正式上线的软件。 + +运营阶段 + +研发流程的最后一个阶段是运营阶段,该阶段主要分为产品运营和运维两个部分。 + + +产品运营:通过一系列的运营活动,比如线下的技术沙龙、线上的免费公开课、提高关键词排名或者输出一些技术推广文章等方式,来推高整个产品的知名度,提高产品的用户数量,并提高月活和日活。 +运维:由运维工程师负责,核心目标是确保系统稳定的运行,如果系统异常,能够及时发现并修复问题。长期目标是通过技术手段或者流程来完善整个系统架构,减少人力投入、提高运维效率,并提高系统的健壮性和恢复能力。 + + +从上面可以看到,运维属于技术类,运营属于产品类,这二者不要搞混。为了加深你的理解和记忆,我将这些内容,总结在了下面一张图中。 + + + +在运营阶段,研发人员的主要职责就是协助运维解决现网Bug,优化部署架构。当然,研发人员可能也需要配合运营人员开发一些运营接口,供运营人员使用。 + +到这里,业界相对标准的这套研发流程,我们就学完了。在学习过程中,你肯定也发现了,整个研发流程会涉及很多角色,不同角色参与不同的阶段,负责不同的任务。这里我再给你额外扩展一个点,就是这些核心角色和分工是啥样的。 + +这些扩展内容,我放在了一张图和一张表里。这些角色和分工比较好理解,也不需要你背下来,只要先有一个大概的印象就可以了。 + + + +具体分工如下表所示。 + + + +总结 + +在开发Go项目时,掌握项目的研发流程很重要。掌握研发流程,会让项目研发对我们更加白盒,并且有利于我们制定详细的工作任务。 + +那么如何设计项目研发流程呢?你可以根据需要自行设计。自行设计时有些点是一定要关注的,例如我们的流程需要支持高的发布效率和发布质量,支持快速迭代,流程是合理、可扩展的,等等。 + +如果你不想自己设计,也可以。在这一讲中,我介绍了一套相对通用、标准的研发流程,如果合适可以直接拿来作为自己设计的研发流程。 + +这套研发流程包含6个阶段:需求阶段、设计阶段、开发阶段、测试阶段、发布阶段和运营阶段。这里我将这些流程和每个流程的核心点总结在下面一张图中。 + + + +课后练习 + + +回忆下研发阶段具体包括哪些工作内容,如果你觉得这些工作内容满足不了研发阶段的需求,还需要补充什么呢? +思考、调研下有哪些工具,可以帮助实现整个流程,以及流程中任务的自动化,看下它们是如何提高我们的研发效率的。 + + +研发流程会因团队、项目、需求的不同而不同,如果你有更好的流程方案,欢迎你在留言区与我交流讨论。我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/09研发流程设计(下):如何管理应用的生命周期?.md b/专栏/Go语言项目开发实战/09研发流程设计(下):如何管理应用的生命周期?.md new file mode 100644 index 0000000..1880828 --- /dev/null +++ b/专栏/Go语言项目开发实战/09研发流程设计(下):如何管理应用的生命周期?.md @@ -0,0 +1,238 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 研发流程设计(下):如何管理应用的生命周期? + 你好,我是孔令飞。今天我们来聊聊如何管理应用生命周期。 + +上一讲,我们介绍了一个相对标准的研发流程,这个研发流程可以确保我们高效地开发出一个优秀的Go项目。这一讲,我们再来看下,如何管理我们的Go项目,也就是说如何对应用的生命周期进行管理。 + +那应用的生命周期管理,怎么理解呢?其实,就是指采用一些好的工具或方法在应用的整个生命周期中对应用进行管理,以提高应用的研发效率和质量。 + +那么,如何设计一套优秀的应用生命周期管理手段呢?这就跟研发流程“设计”的思路一样,你可以自己设计,也可以采用业界沉淀下来的优秀管理手段。同样地,我更建议你采用已有的最佳实践,因为重复造轮子、造一个好轮子太难了。 + +所以,这一讲我们就一起学习下,业界在不同时期沉淀下来的优秀管理手段,以及我对这些管理手段的经验和建议,帮助你选到一个最合适的。 + +应用生命周期管理技术有哪些? + +那么,有哪些应用生命周期管理技术呢? + +在这里我先整体介绍一下,你先有个大致的印象,一会我们再一个个细讲。我们可以从两个维度来理解应用生命周期管理技术。 + +第一个维度是演进维度。应用生命周期,最开始主要是通过研发模式来管理的,按时间线先后出现了瀑布模式、迭代模式、敏捷模式。接着,为了解决研发模式中的一些痛点出现了另一种管理技术,也就是CI/CD技术。随着CI/CD技术的成熟,又催生了另一种更高级的管理技术DevOps。 + +第二个维度是管理技术的类别。应用生命周期管理技术可以分为两类: + + +研发模式,用来确保整个研发流程是高效的。 +DevOps,主要通过协调各个部门之间的合作,来提高软件的发布效率和质量。DevOps中又包含了很多种技术,主要包括CI/CD和多种Ops,例如AIOps、ChatOps、GitOps、NoOps等。其中,CI/CD技术提高了软件的发布效率和质量,而Ops技术则提高了软件的运维和运营效率。 + + +尽管这些应用生命周期管理技术有很多不同,但是它们彼此支持、相互联系。研发模式专注于开发过程,DevOps技术里的CI/CD 专注于流程,Ops则专注于实战。 + +为了帮助你理解,我总结出了下面这张图供你参考。 + + + +这两个维度涉及的管理技术虽然不少,但一共就是那几类。所以,为了能够逻辑清晰地给你讲解明白这些技术,我会从演进维度来展开,也就是按照这样的顺序:研发模式(瀑布模式 -> 迭代模式 -> 敏捷模式) -> CI/CD -> DevOps。 + +你可能会问了,既然是演进,那这些技术肯定有优劣之分,我应该怎么选择呢,一定是选择后面出现的技术吗? + +为了解决你的这个问题,这里,对于研发模式和DevOps这两类技术的选择,我提前给出我的建议:研发模式建议选择敏捷模式,因为它更能胜任互联网时代快速迭代的诉求。DevOps则要优先确保落地CI/CD技术,接着尝试落地ChatOps技术,如果有条件可以积极探索AIOps和GitOps。 + +接下来,我们就详细说说这些应用生命周期的管理方法,先来看专注于开发过程的研发模式部分。 + +研发模式 + +研发模式主要有三种,演进顺序为瀑布模式->迭代模式->敏捷模式,现在我们逐一看下。 + +瀑布模式 + +在早期阶段,软件研发普遍采用的是瀑布模式,像我们熟知的RHEL、Fedora等系统就是采用瀑布模式。 + +瀑布模式按照预先规划好的研发阶段来推进研发进度。比如,按照需求阶段、设计阶段、开发阶段、测试阶段、发布阶段、运营阶段的顺序串行执行开发任务。每个阶段完美完成之后,才会进入到下一阶段,阶段之间通过文档进行交付。整个过程如下图所示。 + + + +瀑布模式最大的优点是简单。它严格按照研发阶段来推进研发进度,流程清晰,适合按项目交付的应用。 + +但它的缺点也很明显,最突出的就是这两个: + + +只有在项目研发的最后阶段才会交付给客户。交付后,如果客户发现问题,变更就会非常困难,代价很大。 +研发周期比较长,很难适应互联网时代对产品快速迭代的诉求。 + + +为了解决这两个问题,迭代式研发模式诞生了。 + +迭代模式 + +迭代模式,是一种与瀑布式模式完全相反的开发过程:研发任务被切分为一系列轮次,每一个轮次都是一个迭代,每一次迭代都是一个从设计到实现的完整过程。它不要求每一个阶段的任务都做到最完美,而是先把主要功能搭建起来,然后再通过客户的反馈信息不断完善。 + +迭代开发可以帮助产品改进和把控进度,它的灵活性极大地提升了适应需求变化的能力,克服了高风险、难变更、复用性低的特点。 + +但是,迭代模式的问题在于比较专注于开发过程,很少从项目管理的视角去加速和优化项目开发过程。接下来要讲的敏捷模式,就弥补了这个缺点。 + +敏捷模式 + +敏捷模式把一个大的需求分成多个、可分阶段完成的小迭代,每个迭代交付的都是一个可使用的软件。在开发过程中,软件要一直处于可使用状态。 + +敏捷模式中具有代表性的开发模式,是Scrum开发模型。Scrum开发模型网上有很多介绍,你可以去看看。 + +在敏捷模式中,我们会把一个大的需求拆分成很多小的迭代,这意味着开发过程中会有很多个开发、构建、测试、发布和部署的流程。这种高频度的操作会给研发、运维和测试人员带来很大的工作量,降低了工作效率。为了解决这个问题,CI/CD技术诞生了。 + +CI/CD:自动化构建和部署应用 + +CI/CD技术通过自动化的手段,来快速执行代码检查、测试、构建、部署等任务,从而提高研发效率,解决敏捷模式带来的弊端。 + +CI/CD包含了3个核心概念。 + + +CI:Continuous Integration,持续集成。 +CD:Continuous Delivery,持续交付。 +CD:Continuous Deployment,持续部署。 + + +CI容易理解,但两个CD很多开发者区分不开。这里,我来详细说说这3个核心概念。 + +首先是持续集成。它的含义为:频繁地(一天多次)将开发者的代码合并到主干上。它的流程为:在开发人员完成代码开发,并push到Git仓库后,CI工具可以立即对代码进行扫描、(单元)测试和构建,并将结果反馈给开发者。持续集成通过后,会将代码合并到主干。 + +CI流程可以使应用软件的问题在开发阶段就暴露出来,这会让开发人员交付代码时更有信心。因为CI流程内容比较多,而且执行比较频繁,所以CI流程需要有自动化工具来支撑。 + +其次是持续交付,它指的是一种能够使软件在较短的循环中可靠发布的软件方法。 + +持续交付在持续集成的基础上,将构建后的产物自动部署在目标环境中。这里的目标环境,可以是测试环境、预发环境或者现网环境。 + +通常来说,持续部署可以自动地将服务部署到测试环境或者预发环境。因为部署到现网环境存在一定的风险,所以如果部署到现网环境,需要手工操作。手工操作的好处是,可以使相关人员评估发布风险,确保发布的正确性。 + +最后是持续部署,持续部署在持续交付的基础上,将经过充分测试的代码自动部署到生产环境,整个流程不再需要相关人员的审核。持续部署强调的是自动化部署,是交付的最高阶段。 + +我们可以借助下面这张图,来了解持续集成、持续交付、持续部署的关系。 + + + +持续集成、持续交付和持续部署强调的是持续性,也就是能够支持频繁的集成、交付和部署,这离不开自动化工具的支持,离开了这些工具,CI/CD就不再具有可实施性。持续集成的核心点在代码,持续交付的核心点在可交付的产物,持续部署的核心点在自动部署。 + +DevOps:研发运维一体化 + +CI/CD技术的成熟,加速了DevOps这种应用生命周期管理技术的成熟和落地。 + +DevOps(Development和Operations的组合)是一组过程、方法与系统的统称,用于促进开发(应用程序/软件工程)、技术运营和质量保障(QA)部门之间的沟通、协作与整合。这3个部门的相互协作,可以提高软件质量、快速发布软件。如下图所示: + + + +要实现DevOps,需要一些工具或者流程的支持,CI/CD可以很好地支持DevOps这种软件开发模式,如果没有CI/CD自动化的工具和流程,DevOps就是没有意义的,CI/CD使得DevOps变得可行。 + +听到这里是不是有些晕?你可能想问,DevOps跟CI/CD到底是啥区别呢?其实,这也是困扰很多开发者的问题。这里,我们可以这么理解:DevOps != CI/CD。DevOps是一组过程、方法和系统的统称,而CI/CD只是一种软件构建和发布的技术。 + +DevOps技术之前一直有,但是落地不好,因为没有一个好的工具来实现DevOps的理念。但是随着容器、CI/CD技术的诞生和成熟,DevOps变得更加容易落地。也就是说,这几年越来越多的人采用DevOps手段来提高研发效能。 + +随着技术的发展,目前已经诞生了很多Ops手段,来实现运维和运营的高度自动化。下面,我们就来看看DevOps中的四个Ops手段:AIOps、ChatOps、GitOps、NoOps。 + +AIOps:智能运维 + +在2016年,Gartner提出利用AI技术的新一代IT运维,即AIOps(智能运维)。通过AI手段,来智能化地运维IT系统。AIOps通过搜集海量的运维数据,并利用机器学习算法,智能地定位并修复故障。 + +也就是说,AIOps在自动化的基础上,增加了智能化,从而进一步推动了IT运维自动化,减少了人力成本。 + +随着IT基础设施规模和复杂度的倍数增长,企业应用规模、数量的指数级增长,传统的人工/自动化运维,已经无法胜任愈加沉重的运维工作,而AIOps提供了一个解决方案。在腾讯、阿里等大厂很多团队已经在尝试和使用AIOps,并享受到了AIOps带来的红利。例如,故障告警更加灵敏、准确,一些常见的故障,可以自动修复,无须运维人员介入等。 + +ChatOps:聊着天就把事情给办了 + +随着企业微信、钉钉等企业内通讯工具的兴起,最近几年出现了一个新的概念ChatOps。 + +简单来说,ChatOps就是在一个聊天工具中,发送一条命令给 ChatBot 机器人,然后 ChatBot会执行预定义的操作。这些操作可以是执行某个工具、调用某个接口等,并返回执行结果。 + +这种新型智能工作方式的优势是什么呢?它可以利用 ChatBot 机器人让团队成员和各项辅助工具连接在一起,以沟通驱动的方式完成工作。ChatOps可以解决人与人、人与工具、工具与工具之间的信息孤岛,从而提高协作体验和工作效率。 + +ChatOps的工作流程如下图所示(网图): + + + +开发/运维/测试人员通过@聊天窗口中的机器人Bot来触发任务,机器人后端会通过API接口调用等方式对接不同的系统,完成不同的任务,例如持续集成、测试、发布等工作。机器人可以是我们自己研发的,也可以是开源的。目前,业界有很多流行的机器人可供选择,常用的有Hubot、Lita、Errbot、StackStorm等。 + +使用ChatOps可以带来以下几点好处。 + + +友好、便捷:所有的操作均在同一个聊天界面中,通过@机器人以聊天的方式发送命令,免去了打开不同系统,执行不同操作的繁琐操作,方式更加友好和便捷。 +信息透明:在同一个聊天界面中的所有同事都能够看到其他同事发送的命令,以及命令执行的结果,可以消除沟通壁垒,工作历史有迹可循,团队合作更加顺畅。 +移动友好:可以在移动端向机器人发送命令、执行任务,让移动办公变为可能。 +DevOps 文化打造:通过与机器人对话,可以降低项目开发中,各参与人员的理解和使用成本,从而使DevOps更容易落地和推广。 + + +GitOps: 一种实现云原生的持续交付模型 + +GitOps是一种持续交付的方式。它的核心思想是将应用系统的声明性基础架构(YAML)和应用程序存放在Git版本库中。将Git作为交付流水线的核心,每个开发人员都可以提交拉取请求(Pull Request),并使用Git来加速和简化Kubernetes的应用程序部署和运维任务。 + +通过Git这样的工具,开发人员可以将精力聚焦在功能开发,而不是软件运维上,以此提高软件的开发效率和迭代速度。 + +使用GitOps可以带来很多优点,其中最核心的是:当使用Git变更代码时,GitOps可以自动将这些变更应用到程序的基础架构上。因为整个流程都是自动化的,所以部署时间更短;又因为Git代码是可追溯的,所以我们部署的应用也能够稳定且可重现地回滚。 + +我们可以从概念和流程上来理解GitOps,它有3个关键概念。 + + +声明性容器编排:通过Kubernetes YAML格式的资源定义文件,来定义如何部署应用。 +不可变基础设施:基础设施中的每个组件都可以自动的部署,组件在部署完成后,不能发生变更。如果需要变更,则需要重新部署一个新的组件。例如,Kubernetes中的Pod就是一个不可变基础设施。 +连续同步:不断地查看Git存储库,将任何状态更改反映到Kubernetes集群中。 + + + + +GitOps的工作流程如下: + +首先,开发人员开发完代码后推送到Git仓库,触发CI流程,CI流程通过编译构建出Docker镜像,并将镜像push到Docker镜像仓库中。Push动作会触发一个push事件,通过webhook的形式通知到Config Updater服务,Config Updater服务会从 webhook 请求中获取最新 push 的镜像名,并更新Git仓库中的Kubernetes YAML文件。 + +然后,GitOps的Deploy Operator服务,检测到YAML文件的变动,会重新从Git仓库中提取变更的文件,并将镜像部署到Kubernetes集群中。Config Updater 和 Deploy Operator 两个组件需要开发人员设计开发。 + +NoOps:无运维 + +NoOps即无运维,完全自动化的运维。在NoOps中不再需要开发人员、运营运维人员的协同,把微服务、低代码、无服务全都结合了起来,开发者在软件生命周期中只需要聚焦业务开发即可,所有的维护都交由云厂商来完成。 + +毫无疑问,NoOps是运维的终极形态,在我看来它像DevOps一样,更多的是一种理念,需要很多的技术和手段来支撑。当前整个运维技术的发展,也是朝着NoOps的方向去演进的,例如GitOps、AIOps可以使我们尽可能减少运维,Serverless技术甚至可以使我们免运维。相信未来NoOps会像现在的Serverless一样,成为一种流行的、可落地的理念。 + +如何选择合适的应用生命周期管理技术? + +好了,到这里我们就把主要的应用生命周期管理技术,学得差不多了。那在实际开发中,如何选择适合自己的呢?在我看来,你可以从这么几个方面考虑。 + +首先,根据团队、项目选择一个合适的研发模式。如果项目比较大,需求变更频繁、要求快速迭代,建议选择敏捷开发模式。敏捷开发模式,也是很多大公司选择的研发模式,在互联网时代很受欢迎。 + +接着,要建立自己的CI/CD流程。任何变更代码在合并到master分支时,一定要通过CI/CD的流程的验证。我建议,你在CI/CD流程中设置质量红线,确保合并代码的质量。 + +接着,除了建立CI/CD系统,我还建议将ChatOps带入工作中,尽可能地将可以自动化的工作实现自动化,并通过ChatOps来触发自动化流程。随着企业微信、钉钉等企业聊天软件成熟和发展,ChatOps变得流行和完善。 + +最后,GitOps、AIOps可以将部署和运维自动化做到极致,在团队有人力的情况下,值得探索。 + +到这里你可能会问了,大厂是如何管理应用生命周期的? + +大厂普遍采用敏捷开发的模式,来适应互联网对应用快速迭代的诉求。例如,腾讯的TAPD、Coding的Scrum敏捷管理就是一个敏捷开发平台。CI/CD强制落地,ChatOps已经广泛使用,AIOps也有很多落地案例,GitOps目前还在探索阶段,NoOps还处在理论阶段。 + +总结 + +这一讲,我从技术演进的维度介绍了应用生命周期管理技术,这些技术可以提高应用的研发效率和质量。 + +应用生命周期管理最开始是通过研发模式来管理的。在研发模式中,我按时间线分别介绍了瀑布模式、迭代模式和敏捷模式,其中的敏捷模式适应了互联网时代对应用快速迭代的诉求,所以用得越来越多。 + +在敏捷模式中,我们需要频繁构建和发布我们的应用,这就给开发人员带来了额外的工作量,为了解决这个问题,出现了CI/CD技术。CI/CD可以将代码的检查、测试、构建和部署等工作自动化,不仅提高了研发效率,还从一定程度上保障了代码的质量。另外,CI/CD技术使得DevOps变得可行,当前越来越多的团队采用DevOps来管理应用的生命周期。 + +另外,这一讲中我也介绍了几个大家容易搞混的概念。 + + +持续交付和持续部署。二者都是持续地部署应用,但是持续部署整个过程是自动化的,而持续交付中,应用在发布到现网前需要人工审批是否允许发布。 +CI/CD和DevOps。DevOps是一组过程、方法与系统的统称,其中也包含了CI/CD技术。而CI/CD是一种自动化的技术,DevOps理念的落地需要CI/CD技术的支持。 + + +最后,关于如何管理应用的生命周期,我给出了一些建议:研发模式建议选择敏捷模式,因为它更能胜任互联网时代快速迭代的诉求。DevOps则要优先确保落地CI/CD技术,接着尝试落地ChatOps技术,如果有条件可以积极探索AIOps和GitOps。 + +课后练习 + + +学习并使用GitHub Actions,通过Github Actions完成提交代码后自动进行静态代码检查的任务。 +尝试添加一个能够每天自动打印“hello world”的企业微信机器人,并思考下,哪些自动化工作可以通过该机器人来实现。 + + +期待在留言区看到你的思考和答案,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/10设计方法:怎么写出优雅的Go项目?.md b/专栏/Go语言项目开发实战/10设计方法:怎么写出优雅的Go项目?.md new file mode 100644 index 0000000..7b4fdb6 --- /dev/null +++ b/专栏/Go语言项目开发实战/10设计方法:怎么写出优雅的Go项目?.md @@ -0,0 +1,611 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 设计方法:怎么写出优雅的 Go 项目? + 你好,我是孔令飞,今天我们来聊聊如何写出优雅的 Go 项目。 + +Go语言简单易学,对于大部分开发者来说,编写可运行的代码并不是一件难事,但如果想真正成为Go编程高手,你需要花很多精力去研究Go的编程哲学。 + +在我的Go开发生涯中,我见过各种各样的代码问题,例如:代码不规范,难以阅读;函数共享性差,代码重复率高;不是面向接口编程,代码扩展性差,代码不可测;代码质量低下。究其原因,是因为这些代码的开发者很少花时间去认真研究如何开发一个优雅的Go项目,更多时间是埋头在需求开发中。 + +如果你也遇到过以上问题,那么是时候花点时间来研究下如何开发一个优雅的Go项目了。只有这样,你才能区别于绝大部分的Go开发者,从而在职场上建立自己的核心竞争力,并最终脱颖而出。 + +其实,我们之前所学的各种规范设计,也都是为了写出一个优雅的Go项目。在这一讲,我又补充了一些内容,从而形成了一套“写出优雅Go项目”的方法论。这一讲内容比较多,但很重要,希望你能花点精力认真掌握,掌握之后,能够确保你开发出一个优秀的Go项目。 + +如何写出优雅的Go项目? + +那么,如何写出一个优雅的Go项目呢?在回答这个问题之前,我们先来看另外两个问题: + + +为什么是Go项目,而不是Go应用? +一个优雅的Go项目具有哪些特点? + + +先来看第一个问题。Go项目是一个偏工程化的概念,不仅包含了Go应用,还包含了项目管理和项目文档: + + + +这就来到了第二个问题,一个优雅的Go项目,不仅要求我们的Go应用是优雅的,还要确保我们的项目管理和文档也是优雅的。这样,我们根据前面几讲学到的Go设计规范,很容易就能总结出一个优雅的Go应用需要具备的特点: + + +符合Go编码规范和最佳实践; +易阅读、易理解,易维护; +易测试、易扩展; +代码质量高。 + + +解决了这两个问题,让我们回到这一讲的核心问题:如何写出优雅的Go项目? + +写出一个优雅的Go项目,在我看来,就是用“最佳实践”的方式去实现Go项目中的Go应用、项目管理和项目文档。具体来说,就是编写高质量的Go应用、高效管理项目、编写高质量的项目文档。 + +为了协助你理解,我将这些逻辑绘制成了下面一张图。 + + + +接下来,我们就看看如何根据前面几讲学习的Go项目设计规范,实现一个优雅的Go项目。我们先从编写高质量的Go应用看起。 + +编写高质量的Go应用 + +基于我的研发经验,要编写一个高质量的Go应用,其实可以归纳为5个方面:代码结构、代码规范、代码质量、编程哲学和软件设计方法,见下图。 + + + +接下来,我们详细说说这些内容。 + +代码结构 + +为什么先说代码结构呢?因为组织合理的代码结构是一个项目的门面。我们可以通过两个手段来组织代码结构。 + +第一个手段是,组织一个好的目录结构。关于如何组合一个好的目录结构,你可以回顾 06讲 的内容。 + +第二个手段是,选择一个好的模块拆分方法。做好模块拆分,可以使项目内模块职责分明,做到低耦合高内聚。 + +那么Go项目开发中,如何拆分模块呢?目前业界有两种拆分方法,分别是按层拆分和按功能拆分。 + +首先,我们看下按层拆分,最典型的是MVC架构中的模块拆分方式。在MVC架构中,我们将服务中的不同组件按访问顺序,拆分成了Model、View和Controller三层。 + + + +每层完成不同的功能: + + +View(视图)是提供给用户的操作界面,用来处理数据的显示。 +Controller(控制器),负责根据用户从 View 层输入的指令,选取 Model 层中的数据,然后对其进行相应的操作,产生最终结果。 +Model(模型),是应用程序中用于处理数据逻辑的部分。 + + +我们看一个典型的按层拆分的目录结构: + +$ tree --noreport -L 2 layers +layers +├── controllers +│ ├── billing +│ ├── order +│ └── user +├── models +│ ├── billing.go +│ ├── order.go +│ └── user.go +└── views + └── layouts + + +在Go项目中,按层拆分会带来很多问题。最大的问题是循环引用:相同功能可能在不同层被使用到,而这些功能又分散在不同的层中,很容易造成循环引用。 + +所以,你只要大概知道按层拆分是什么意思就够了,在Go项目中我建议你使用的是按功能拆分的方法,这也是Go项目中最常见的拆分方法。 + +那什么是按功能拆分呢?我给你看一个例子你就明白了。比如,一个订单系统,我们可以根据不同功能将其拆分成用户(user)、订单(order)和计费(billing)3个模块,每一个模块提供独立的功能,功能更单一: + + + +下面是该订单系统的代码目录结构: + +$ tree pkg +$ tree --noreport -L 2 pkg +pkg +├── billing +├── order +│ └── order.go +└── user + + +相较于按层拆分,按功能拆分模块带来的好处也很好理解: + + +不同模块,功能单一,可以实现高内聚低耦合的设计哲学。 +因为所有的功能只需要实现一次,引用逻辑清晰,会大大减少出现循环引用的概率。 + + +所以,有很多优秀的Go项目采用的都是按功能拆分的模块拆分方式,例如 Kubernetes、Docker、Helm、Prometheus等。 + +除了组织合理的代码结构这种方式外,编写高质量Go应用的另外一个行之有效的方法,是遵循Go语言代码规范来编写代码。在我看来,这也是最容易出效果的方式。 + +代码规范 + +那我们要遵循哪些代码规范来编写Go应用呢?在我看来,其实就两类:编码规范和最佳实践。 + +首先,我们的代码要符合Go编码规范,这是最容易实现的途径。Go社区有很多这类规范可供参考,其中,比较受欢迎的是Uber Go 语言编码规范。 + +阅读这些规范确实有用,也确实花时间、花精力。所以,我在参考了已有的很多规范后,结合自己写Go代码的经验,特地为你整理了一篇Go编码规范作为加餐,也就是“特别放送 | 给你一份清晰、可直接套用的Go编码规范”。 + +有了可以参考的编码规范之后,我们需要扩展到团队、部门甚至公司层面。只有大家一起参与、遵守,规范才会变得有意义。其实,我们都清楚,要开发者靠自觉来遵守所有的编码规范,不是一件容易的事儿。这时候,我们可以使用静态代码检查工具,来约束开发者的行为。 + +有了静态代码检查工具后,不仅可以确保开发者写出的每一行代码都是符合Go编码规范的,还可以将静态代码检查集成到CI/CD流程中。这样,在代码提交后自动地检查代码,就保证了只有符合编码规范的代码,才会被合入主干。 + +Go语言的静态代码检查工具有很多,目前用的最多的是golangci-lint,这也是我极力推荐你使用的一个工具。关于这个工具的使用,我会在第15讲和你详细介绍。 + +除了遵循编码规范,要想成为Go编程高手,你还得学习并遵循一些最佳实践。“最佳实践”是社区经过多年探索沉淀下来的、符合Go语言特色的经验和共识,它可以帮助你开发出一个高质量的代码。 + +这里我给你推荐几篇介绍Go语言最佳实践的文章,供你参考: + + +Effective Go:高效Go编程,由Golang官方编写,里面包含了编写Go代码的一些建议,也可以理解为最佳实践。 +Go Code Review Comments:Golang官方编写的Go最佳实践,作为Effective Go的补充。 +Style guideline for Go packages:包含了如何组织Go包、如何命名Go包、如何写Go包文档的一些建议。 + + +代码质量 + +有了组织合理的代码结构、符合Go语言代码规范的Go应用代码之后,我们还需要通过一些手段来确保我们开发出的是一个高质量的代码,这可以通过单元测试和Code Review来实现。 + +单元测试非常重要。我们开发完一段代码后,第一个执行的测试就是单元测试。它可以保证我们的代码是符合预期的,一些异常变动能够被及时感知到。进行单元测试,不仅需要编写单元测试用例,还需要我们确保代码是可测试的,以及具有一个高的单元测试覆盖率。 + +接下来,我就来介绍下如何编写一个可测试的代码。 + +如果我们要对函数A进行测试,并且A中的所有代码均能够在单元测试环境下按预期被执行,那么函数A的代码块就是可测试的。我们来看下一般的单元测试环境有什么特点: + + +可能无法连接数据库。 +可能无法访问第三方服务。 + + +如果函数A依赖数据库连接、第三方服务,那么在单元测试环境下执行单元测试就会失败,函数就没法测试,函数是不可测的。 + +解决方法也很简单:将依赖的数据库、第三方服务等抽象成接口,在被测代码中调用接口的方法,在测试时传入mock类型,从而将数据库、第三方服务等依赖从具体的被测函数中解耦出去。如下图所示: + + + +为了提高代码的可测性,降低单元测试的复杂度,对function和mock的要求是: + + +要尽可能减少function中的依赖,让function只依赖必要的模块。编写一个功能单一、职责分明的函数,会有利于减少依赖。 +依赖模块应该是易Mock的。 + + +为了协助你理解,我们先来看一段不可测试的代码: + +package post + +import "google.golang.org/grpc" + +type Post struct { + Name string + Address string +} + +func ListPosts(client *grpc.ClientConn) ([]*Post, error) { + return client.ListPosts() +} + + +这段代码中的ListPosts函数是不可测试的。因为ListPosts函数中调用了client.ListPosts()方法,该方法依赖于一个gRPC连接。而我们在做单元测试时,可能因为没有配置gRPC服务的地址、网络隔离等原因,导致没法建立gRPC连接,从而导致ListPosts函数执行失败。 + +下面,我们把这段代码改成可测试的,如下: + +package main + +type Post struct { + Name string + Address string +} + +type Service interface { + ListPosts() ([]*Post, error) +} + +func ListPosts(svc Service) ([]*Post, error) { + return svc.ListPosts() +} + + +上面代码中,ListPosts函数入参为Service接口类型,只要我们传入一个实现了Service接口类型的实例,ListPosts函数即可成功运行。因此,我们可以在单元测试中可以实现一个不依赖任何第三方服务的fake实例,并传给ListPosts。上述可测代码的单元测试代码如下: + +package main + +import "testing" + +type fakeService struct { +} + +func NewFakeService() Service { + return &fakeService{} +} + +func (s *fakeService) ListPosts() ([]*Post, error) { + posts := make([]*Post, 0) + posts = append(posts, &Post{ + Name: "colin", + Address: "Shenzhen", + }) + posts = append(posts, &Post{ + Name: "alex", + Address: "Beijing", + }) + return posts, nil +} + +func TestListPosts(t *testing.T) { + fake := NewFakeService() + if _, err := ListPosts(fake); err != nil { + t.Fatal("list posts failed") + } +} + + +当我们的代码可测之后,就可以借助一些工具来Mock需要的接口了。常用的Mock工具,有这么几个: + + +golang/mock,是官方提供的Mock框架。它实现了基于interface的Mock功能,能够与Golang内置的testing包做很好的集成,是最常用的Mock工具。golang/mock提供了mockgen工具用来生成interface对应的Mock源文件。 +sqlmock,可以用来模拟数据库连接。数据库是项目中比较常见的依赖,在遇到数据库依赖时都可以用它。 +httpmock,可以用来Mock HTTP请求。 +bouk/monkey,猴子补丁,能够通过替换函数指针的方式来修改任意函数的实现。如果golang/mock、sqlmock和httpmock这几种方法都不能满足我们的需求,我们可以尝试通过猴子补丁的方式来Mock依赖。可以这么说,猴子补丁提供了单元测试 Mock 依赖的最终解决方案。 + + +接下来,我们再一起看看如何提高我们的单元测试覆盖率。 + +当我们编写了可测试的代码之后,接下来就需要编写足够的测试用例,用来提高项目的单元测试覆盖率。这里我有以下两个建议供你参考: + + +使用gotests工具自动生成单元测试代码,减少编写单元测试用例的工作量,将你从重复的劳动中解放出来。 +定期检查单元测试覆盖率。你可以通过以下方法来检查: + + +$ go test -race -cover -coverprofile=./coverage.out -timeout=10m -short -v ./... +$ go tool cover -func ./coverage.out + + +执行结果如下: + + + +在提高项目的单元测试覆盖率时,我们可以先提高单元测试覆盖率低的函数,之后再检查项目的单元测试覆盖率;如果项目的单元测试覆盖率仍然低于期望的值,可以再次提高单元测试覆盖率低的函数的覆盖率,然后再检查。以此循环,最终将项目的单元测试覆盖率优化到预期的值为止。 + +这里要注意,对于一些可能经常会变动的函数单元测试,覆盖率要达到100%。 + +说完了单元测试,我们再看看如何通过Code Review来保证代码质量。 + +Code Review可以提高代码质量、交叉排查缺陷,并且促进团队内知识共享,是保障代码质量非常有效的手段。在我们的项目开发中,一定要建立一套持久可行的Code Review机制。 + +但在我的研发生涯中,发现很多团队没有建立有效的Code Review机制。这些团队都认可Code Review机制带来的好处,但是因为流程难以遵守,慢慢地Code Review就变成了形式主义,最终不了了之。其实,建立Code Review机制很简单,主要有3点: + + +首先,确保我们使用的代码托管平台有Code Review的功能。比如,GitHub、GitLab这类代码托管平台都具备这种能力。 +接着,建立一套Code Review规范,规定如何进行Code Review。 +最后,也是最重要的,每次代码变更,相关开发人员都要去落实Code Review机制,并形成习惯,直到最后形成团队文化。 + + +到这里我们可以小结一下:组织一个合理的代码结构、编写符合Go代码规范的代码、保证代码质量,在我看来都是编写高质量Go代码的外功。那内功是什么呢?就是编程哲学和软件设计方法。 + +编程哲学 + +那编程哲学是什么意思呢?在我看来,编程哲学,其实就是要编写符合Go语言设计哲学的代码。Go语言有很多设计哲学,对代码质量影响比较大的,我认为有两个:面向接口编程和面向“对象”编程。 + +我们先来看下面向接口编程。 + +Go 接口是一组方法的集合。任何类型,只要实现了该接口中的方法集,那么就属于这个类型,也称为实现了该接口。 + +接口的作用,其实就是为不同层级的模块提供一个定义好的中间层。这样,上游不再需要依赖下游的具体实现,充分地对上下游进行了解耦。很多流行的Go设计模式,就是通过面向接口编程的思想来实现的。 + +我们看一个面向接口编程的例子。下面这段代码定义了一个Bird接口,Canary和Crow类型均实现了Bird接口。 + +package main + +import "fmt" + +// 定义了一个鸟类 +type Bird interface { + Fly() + Type() string +} + +// 鸟类:金丝雀 +type Canary struct { + Name string +} + +func (c *Canary) Fly() { + fmt.Printf("我是%s,用黄色的翅膀飞\n", c.Name) +} +func (c *Canary) Type() string { + return c.Name +} + +// 鸟类:乌鸦 +type Crow struct { + Name string +} + +func (c *Crow) Fly() { + fmt.Printf("我是%s,我用黑色的翅膀飞\n", c.Name) +} + +func (c *Crow) Type() string { + return c.Name +} + +// 让鸟类飞一下 +func LetItFly(bird Bird) { + fmt.Printf("Let %s Fly!\n", bird.Type()) + bird.Fly() +} + +func main() { + LetItFly(&Canary{"金丝雀"}) + LetItFly(&Crow{"乌鸦"}) +} + + +这段代码中,因为Crow和Canary都实现了Bird接口声明的Fly、Type方法,所以可以说Crow、Canary实现了Bird接口,属于Bird类型。在函数调用时,可以传入Bird类型,并在函数内部调用Bird接口提供的方法,以此来解耦Bird的具体实现。 + +好了,我们总结下使用接口的好处吧: + + +代码扩展性更强了。例如,同样的Bird,可以有不同的实现。在开发中用的更多的是,将数据库的CURD操作抽象成接口,从而可以实现同一份代码对接不同数据库的目的。 +可以解耦上下游的实现。例如,LetItFly不用关注Bird是如何Fly的,只需要调用Bird提供的方法即可。 +提高了代码的可测性。因为接口可以解耦上下游实现,我们在单元测试需要依赖第三方系统/数据库的代码时,可以利用接口将具体实现解耦,实现fake类型。 +代码更健壮、更稳定了。例如,如果要更改Fly的方式,只需要更改相关类型的Fly方法即可,完全影响不到LetItFly函数。 + + +所以,我建议你,在Go项目开发中,一定要多思考,那些可能有多种实现的地方,要考虑使用接口。 + +接下来,我们再来看下面向“对象”编程。 + +面向对象编程(OOP)有很多优点,例如可以使我们的代码变得易维护、易扩展,并能提高开发效率等,所以一个高质量的Go应用在需要时,也应该采用面向对象的方法去编程。那什么叫“在需要时”呢?就是我们在开发代码时,如果一个功能可以通过接近于日常生活和自然的思考方式来实现,这时候就应该考虑使用面向对象的编程方法。 + +Go语言不支持面向对象编程,但是却可以通过一些语言级的特性来实现类似的效果。 + +面向对象编程中,有几个核心特性:类、实例、抽象,封装、继承、多态、构造函数、析构函数、方法重载、this指针。在Go中可以通过以下几个方式来实现类似的效果: + + +类、抽象、封装通过结构体来实现。 +实例通过结构体变量来实现。 +继承通过组合来实现。这里解释下什么叫组合:一个结构体嵌到另一个结构体,称作组合。例如一个结构体包含了一个匿名结构体,就说这个结构体组合了该匿名结构体。 +多态通过接口来实现。 + + +至于构造函数、析构函数、方法重载和this指针等,Go为了保持语言的简洁性去掉了这些特性。 + +Go中面向对象编程方法,见下图: + + + +我们通过一个示例,来具体看下Go是如何实现面向对象编程中的类、抽象、封装、继承和多态的。代码如下: + +package main + +import "fmt" + +// 基类:Bird +type Bird struct { + Type string +} + +// 鸟的类别 +func (bird *Bird) Class() string { + return bird.Type +} + +// 定义了一个鸟类 +type Birds interface { + Name() string + Class() string +} + +// 鸟类:金丝雀 +type Canary struct { + Bird + name string +} + +func (c *Canary) Name() string { + return c.name +} + +// 鸟类:乌鸦 +type Crow struct { + Bird + name string +} + +func (c *Crow) Name() string { + return c.name +} + +func NewCrow(name string) *Crow { + return &Crow{ + Bird: Bird{ + Type: "Crow", + }, + name: name, + } +} + +func NewCanary(name string) *Canary { + return &Canary{ + Bird: Bird{ + Type: "Canary", + }, + name: name, + } +} + +func BirdInfo(birds Birds) { + fmt.Printf("I'm %s, I belong to %s bird class!\n", birds.Name(), birds.Class()) +} + +func main() { + canary := NewCanary("CanaryA") + crow := NewCrow("CrowA") + BirdInfo(canary) + BirdInfo(crow) +} + + +将上述代码保存在oop.go文件中,执行以下代码输出如下: + +$ go run oop.go +I'm CanaryA, I belong to Canary bird class! +I'm CrowA, I belong to Crow bird class! + + +在上面的例子中,分别通过Canary和Crow结构体定义了金丝雀和乌鸦两种类别的鸟,其中分别封装了name属性和Name方法。也就是说通过结构体实现了类,该类抽象了鸟类,并封装了该鸟类的属性和方法。 + +在Canary和Crow结构体中,都有一个Bird匿名字段,Bird字段为Canary和Crow类的父类,Canary和Crow继承了Bird类的Class属性和方法。也就是说通过匿名字段实现了继承。 + +在main函数中,通过NewCanary创建了Canary鸟类实例,并将其传给BirdInfo函数。也就是说通过结构体变量实现实例。 + +在BirdInfo函数中,将Birds接口类型作为参数传入,并在函数中调用了birds.Name,birds.Class方法,这两个方法会根据birds类别的不同而返回不同的名字和类别,也就是说通过接口实现了多态。 + +软件设计方法 + +接下来,我们继续学习编写高质量Go代码的第二项内功,也就是让编写的代码遵循一些业界沉淀下来的,优秀的软件设计方法。 + +优秀的软件设计方法有很多,其中有两类方法对我们代码质量的提升特别有帮助,分别是设计模式(Design pattern)和SOLID原则。 + +在我看来,设计模式可以理解为业界针对一些特定的场景总结出来的最佳实现方式。它的特点是解决的场景比较具体,实施起来会比较简单;而SOLID原则更侧重设计原则,需要我们彻底理解,并在编写代码时多思考和落地。 + +关于设计模式和SOLID原则,我是这么安排的:在第11讲,我会带你学习Go项目常用的设计模式;至于SOLID原则,网上已经有很多高质量的文章了,所以我会简单告诉你这个原则是啥,然后给你推荐一篇介绍文章。 + +我们先了解下有哪些设计模式。 + +在软件领域,沉淀了一些比较优秀的设计模式,其中最受欢迎的是GOF设计模式。GOF设计模式中包含了3大类(创建型模式、结构型模式、行为型模式),共25种经典的、可以解决常见软件设计问题的设计方案。这25种设计方案同样也适用于Go语言开发的项目。 + +这里,我将这25种设计模式总结成了一张图,你可以先看看,有个大概的印象,对于一些在Go项目开发中常用的设计模式,我会在第11讲详细介绍。 + + + +如果说设计模式解决的是具体的场景,那么SOLID原则就是我们设计应用代码时的指导方针。 + +SOLID原则,是由罗伯特·C·马丁在21世纪早期引入的,包括了面向对象编程和面向对象设计的五个基本原则: + + + +遵循SOLID原则可以确保我们设计的代码是易维护、易扩展、易阅读的。SOLID原则同样也适用于Go程序设计。 + +如果你需要更详细地了解SOLID原则,可以参考下SOLID原则介绍这篇文章。 + +到这里,我们就学完了“编写高质量的Go应用”这部分内容。接下来,我们再来学习下如何高效管理Go项目,以及如何编写高质量的项目文档。这里面的大部分内容,之前我们都有学习过,因为它们是“如何写出优雅的Go项目”的重要组成部分,所以,这里我仍然会简单介绍下它们。 + +高效管理项目 + +一个优雅的Go项目,还需要具备高效的项目管理特性。那么如何高效管理我们的项目呢? + +不同团队、不同项目会采用不同的方法来管理项目,在我看来比较重要的有3点,分别是制定一个高效的开发流程、使用Makefile管理项目和将项目管理自动化。我们可以通过自动生成代码、借助工具、对接CI/CD系统等方法来将项目管理自动化。具体见下图: + + + +高效的开发流程 + +高效管理项目的第一步,就是要有一个高效的开发流程,这可以提高开发效率、减少软件维护成本。你可以回想一下设计开发流程的知识,如果印象比较模糊了,一定要回去复习下08讲的内容,因为这部分很重要 。 + +使用Makefile管理项目 + +为了更好地管理项目,除了一个高效的开发流程之外,使用Makefile也很重要。Makefile可以将项目管理的工作通过Makefile依赖的方式实现自动化,除了可以提高管理效率之外,还能够减少人为操作带来的失误,并统一操作方式,使项目更加规范。 + +IAM项目的所有操作均是通过Makefile来完成的,具体Makefile完成了如下操作: + + build Build source code for host platform. + build.multiarch Build source code for multiple platforms. See option PLATFORMS. + image Build docker images for host arch. + image.multiarch Build docker images for multiple platforms. See option PLATFORMS. + push Build docker images for host arch and push images to registry. + push.multiarch Build docker images for multiple platforms and push images to registry. + deploy Deploy updated components to development env. + clean Remove all files that are created by building. + lint Check syntax and styling of go sources. + test Run unit test. + cover Run unit test and get test coverage. + release Release iam + format Gofmt (reformat) package sources (exclude vendor dir if existed). + verify-copyright Verify the boilerplate headers for all files. + add-copyright Ensures source code files have copyright license headers. + gen Generate all necessary files, such as error code files. + ca Generate CA files for all iam components. + install Install iam system with all its components. + swagger Generate swagger document. + serve-swagger Serve swagger spec and docs. + dependencies Install necessary dependencies. + tools install dependent tools. + check-updates Check outdated dependencies of the go projects. + help Show this help info. + + +自动生成代码 + +低代码的理念现在越来越流行。虽然低代码有很多缺点,但确实有很多优点,例如: + + +自动化生成代码,减少工作量,提高工作效率。 +代码有既定的生成规则,相比人工编写代码,准确性更高、更规范。 + + +目前来看,自动生成代码现在已经成为趋势,比如 Kubernetes项目有很多代码都是自动生成的。我认为,想写出一个优雅的Go项目,你也应该认真思考哪些地方的代码可以自动生成。在这门课的IAM项目中,就有大量的代码是自动生成的,我放在这里供你参考: + + +错误码、错误码说明文档。 +自动生成缺失的doc.go文件。 +利用gotests工具,自动生成单元测试用例。 +使用Swagger工具,自动生成Swagger文档。 +使用Mock工具,自动生成接口的Mock实例。 + + +善于借助工具 + +在开发Go项目的过程中,我们也要善于借助工具,来帮助我们完成一部分工作。利用工具可以带来很多好处: + + +解放双手,提高工作效率。 +利用工具的确定性,可以确保执行结果的一致性。例如,使用golangci-lint对代码进行检查,可以确保不同开发者开发的代码至少都遵循golangci-lint的代码检查规范。 +有利于实现自动化,可以将工具集成到CI/CD流程中,触发流水线自动执行。 + + +那么,Go项目中,有哪些工具可以为我们所用呢?这里,我给你整理了一些有用的工具: + + + +所有这些工具都可以通过下面的方式安装。 + +$ cd $IAM_ROOT +$ make tools.install + + +IAM项目使用了上面这些工具的绝大部分,用来尽可能提高整个项目的自动化程度,提高项目维护效率。 + +对接CI/CD + +代码在合并入主干时,应该有一套CI/CD流程来自动化地对代码进行检查、编译、单元测试等,只有通过后的代码才可以并入主干。通过CI/CD流程来保证代码的质量。当前比较流行的CI/CD工具有Jenkins、GitLab、Argo、Github Actions、JenkinsX等。在第51讲 和 第52讲中,我会详细介绍CI/CD的原理和实战。 + +编写高质量的项目文档 + +最后,一个优雅的项目,还应该有完善的文档。例如 README.md、安装文档、开发文档、使用文档、API接口文档、设计文档等等。这些内容在第04讲的文档规范部分有详细介绍,你可以去复习下。 + +总结 + +使用Go语言做项目开发,核心目的其实就是开发一个优雅的Go项目。那么如何开发一个优雅的Go项目呢?Go项目包含三大内容,即 Go应用、项目管理、项目文档,因此开发一个优雅的Go项目,其实就是编写高质量的Go应用、高效管理项目和编写高质量的项目文档。针对每一项,我都给出了一些实现方式,这些方式详见下图: + + + +课后练习 + + +在工作中,你还有哪些方法,来帮助你开发一个优雅的Go项目呢? +在你的当前项目中有哪些可以接口化的代码呢?找到它们,并尝试用面向接口的编程哲学去重写这部分代码吧。 + + +期待在留言区看到你的思考和答案,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/11设计模式:Go常用设计模式概述.md b/专栏/Go语言项目开发实战/11设计模式:Go常用设计模式概述.md new file mode 100644 index 0000000..fde4570 --- /dev/null +++ b/专栏/Go语言项目开发实战/11设计模式:Go常用设计模式概述.md @@ -0,0 +1,692 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 设计模式:Go常用设计模式概述 + 你好,我是孔令飞,今天我们来聊聊Go项目开发中常用的设计模式。 + +在软件开发中,经常会遇到各种各样的编码场景,这些场景往往重复发生,因此具有典型性。针对这些典型场景,我们可以自己编码解决,也可以采取更为省时省力的方式:直接采用设计模式。 + +设计模式是啥呢?简单来说,就是将软件开发中需要重复性解决的编码场景,按最佳实践的方式抽象成一个模型,模型描述的解决方法就是设计模式。使用设计模式,可以使代码更易于理解,保证代码的重用性和可靠性。 + +在软件领域,GoF(四人帮,全拼 Gang of Four)首次系统化提出了3大类、共25种可复用的经典设计方案,来解决常见的软件设计问题,为可复用软件设计奠定了一定的理论基础。 + +从总体上说,这些设计模式可以分为创建型模式、结构型模式、行为型模式3大类,用来完成不同的场景。这一讲,我会介绍几个在Go项目开发中比较常用的设计模式,帮助你用更加简单快捷的方法应对不同的编码场景。其中,简单工厂模式、抽象工厂模式和工厂方法模式都属于工厂模式,我会把它们放在一起讲解。 + + + +创建型模式 + +首先来看创建型模式(Creational Patterns),它提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。 + +这种类型的设计模式里,单例模式和工厂模式(具体包括简单工厂模式、抽象工厂模式和工厂方法模式三种)在Go项目开发中比较常用。我们先来看单例模式。 + +单例模式 + +单例模式(Singleton Pattern),是最简单的一个模式。在Go中,单例模式指的是全局只有一个实例,并且它负责创建自己的对象。单例模式不仅有利于减少内存开支,还有减少系统性能开销、防止多个实例产生冲突等优点。 + +因为单例模式保证了实例的全局唯一性,而且只被初始化一次,所以比较适合全局共享一个实例,且只需要被初始化一次的场景,例如数据库实例、全局配置、全局任务池等。 + +单例模式又分为饿汉方式和懒汉方式。饿汉方式指全局的单例实例在包被加载时创建,而懒汉方式指全局的单例实例在第一次被使用时创建。你可以看到,这种命名方式非常形象地体现了它们不同的特点。 + +接下来,我就来分别介绍下这两种方式。先来看饿汉方式。 + +下面是一个饿汉方式的单例模式代码: + +package singleton + +type singleton struct { +} + +var ins *singleton = &singleton{} + +func GetInsOr() *singleton { + return ins +} + + +你需要注意,因为实例是在包被导入时初始化的,所以如果初始化耗时,会导致程序加载时间比较长。 + +懒汉方式是开源项目中使用最多的,但它的缺点是非并发安全,在实际使用时需要加锁。以下是懒汉方式不加锁的一个实现: + +package singleton + +type singleton struct { +} + +var ins *singleton + +func GetInsOr() *singleton { + if ins == nil { + ins = &singleton{} + } + + return ins +} + + +可以看到,在创建ins时,如果 ins==nil,就会再创建一个ins实例,这时候单例就会有多个实例。 + +为了解决懒汉方式非并发安全的问题,需要对实例进行加锁,下面是带检查锁的一个实现: + +import "sync" + +type singleton struct { +} + +var ins *singleton +var mu sync.Mutex + +func GetIns() *singleton { + if ins == nil { + mu.Lock() + if ins == nil { + ins = &singleton{} + } + mu.Unlock() + } + return ins +} + + +上述代码只有在创建时才会加锁,既提高了代码效率,又保证了并发安全。 + +除了饿汉方式和懒汉方式,在Go开发中,还有一种更优雅的实现方式,我建议你采用这种方式,代码如下: + +package singleton + +import ( + "sync" +) + +type singleton struct { +} + +var ins *singleton +var once sync.Once + +func GetInsOr() *singleton { + once.Do(func() { + ins = &singleton{} + }) + return ins +} + + +使用once.Do可以确保ins实例全局只被创建一次,once.Do函数还可以确保当同时有多个创建动作时,只有一个创建动作在被执行。 + +另外,IAM应用中大量使用了单例模式,如果你想了解更多单例模式的使用方式,可以直接查看IAM项目代码。IAM中单例模式有 GetStoreInsOr、GetEtcdFactoryOr、GetMySQLFactoryOr、GetCacheInsOr等。 + +工厂模式 + +工厂模式(Factory Pattern)是面向对象编程中的常用模式。在Go项目开发中,你可以通过使用多种不同的工厂模式,来使代码更简洁明了。Go中的结构体,可以理解为面向对象编程中的类,例如 Person结构体(类)实现了Greet方法。 + +type Person struct { + Name string + Age int +} + +func (p Person) Greet() { + fmt.Printf("Hi! My name is %s", p.Name) +} + + +有了Person“类”,就可以创建Person实例。我们可以通过简单工厂模式、抽象工厂模式、工厂方法模式这三种方式,来创建一个Person实例。 + +这三种工厂模式中,简单工厂模式是最常用、最简单的。它就是一个接受一些参数,然后返回Person实例的函数: + +type Person struct { + Name string + Age int +} + +func (p Person) Greet() { + fmt.Printf("Hi! My name is %s", p.Name) +} + +func NewPerson(name string, age int) *Person { + return &Person{ + Name: name, + Age: age, + } +} + + +和p:=&Person {}这种创建实例的方式相比,简单工厂模式可以确保我们创建的实例具有需要的参数,进而保证实例的方法可以按预期执行。例如,通过NewPerson创建Person实例时,可以确保实例的name和age属性被设置。 + +再来看抽象工厂模式,它和简单工厂模式的唯一区别,就是它返回的是接口而不是结构体。 + +通过返回接口,可以在你不公开内部实现的情况下,让调用者使用你提供的各种功能,例如: + +type Person interface { + Greet() +} + +type person struct { + name string + age int +} + +func (p person) Greet() { + fmt.Printf("Hi! My name is %s", p.name) +} + +// Here, NewPerson returns an interface, and not the person struct itself +func NewPerson(name string, age int) Person { + return person{ + name: name, + age: age, + } +} + + +上面这个代码,定义了一个不可导出的结构体person,在通过NewPerson创建实例的时候返回的是接口,而不是结构体。 + +通过返回接口,我们还可以实现多个工厂函数,来返回不同的接口实现,例如: + +// We define a Doer interface, that has the method signature +// of the `http.Client` structs `Do` method +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +// This gives us a regular HTTP client from the `net/http` package +func NewHTTPClient() Doer { + return &http.Client{} +} + +type mockHTTPClient struct{} + +func (*mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + // The `NewRecorder` method of the httptest package gives us + // a new mock request generator + res := httptest.NewRecorder() + + // calling the `Result` method gives us + // the default empty *http.Response object + return res.Result(), nil +} + +// This gives us a mock HTTP client, which returns +// an empty response for any request sent to it +func NewMockHTTPClient() Doer { + return &mockHTTPClient{} +} + + +NewHTTPClient和NewMockHTTPClient都返回了同一个接口类型Doer,这使得二者可以互换使用。当你想测试一段调用了Doer接口Do方法的代码时,这一点特别有用。因为你可以使用一个Mock的HTTP客户端,从而避免了调用真实外部接口可能带来的失败。 + +来看个例子,假设我们想测试下面这段代码: + +func QueryUser(doer Doer) error { + req, err := http.NewRequest("Get", "http://iam.api.marmotedu.com:8080/v1/secrets", nil) + if err != nil { + return err + } + + _, err := doer.Do(req) + if err != nil { + return err + } + + return nil +} + + +其测试用例为: + +func TestQueryUser(t *testing.T) { + doer := NewMockHTTPClient() + if err := QueryUser(doer); err != nil { + t.Errorf("QueryUser failed, err: %v", err) + } +} + + +另外,在使用简单工厂模式和抽象工厂模式返回实例对象时,都可以返回指针。例如,简单工厂模式可以这样返回实例对象: + +return &Person{ + Name: name, + Age: age +} + + +抽象工厂模式可以这样返回实例对象: + +return &person{ + name: name, + age: age +} + + +在实际开发中,我建议返回非指针的实例,因为我们主要是想通过创建实例,调用其提供的方法,而不是对实例做更改。如果需要对实例做更改,可以实现SetXXX的方法。通过返回非指针的实例,可以确保实例的属性,避免属性被意外/任意修改。 + +在简单工厂模式中,依赖于唯一的工厂对象,如果我们需要实例化一个产品,就要向工厂中传入一个参数,获取对应的对象;如果要增加一种产品,就要在工厂中修改创建产品的函数。这会导致耦合性过高,这时我们就可以使用工厂方法模式。 + +在工厂方法模式中,依赖工厂函数,我们可以通过实现工厂函数来创建多种工厂,将对象创建从由一个对象负责所有具体类的实例化,变成由一群子类来负责对具体类的实例化,从而将过程解耦。 + +下面是工厂方法模式的一个代码实现: + +type Person struct { + name string + age int +} + +func NewPersonFactory(age int) func(name string) Person { + return func(name string) Person { + return Person{ + name: name, + age: age, + } + } +} + + +然后,我们可以使用此功能来创建具有默认年龄的工厂: + +newBaby := NewPersonFactory(1) +baby := newBaby("john") + +newTeenager := NewPersonFactory(16) +teen := newTeenager("jill") + + +结构型模式 + +我已经向你介绍了单例模式、工厂模式这两种创建型模式,接下来我们来看结构型模式(Structural Patterns),它的特点是关注类和对象的组合。这一类型里,我想详细讲讲策略模式和模板模式。 + +策略模式 + +策略模式(Strategy Pattern)定义一组算法,将每个算法都封装起来,并且使它们之间可以互换。 + +在什么时候,我们需要用到策略模式呢? + +在项目开发中,我们经常要根据不同的场景,采取不同的措施,也就是不同的策略。比如,假设我们需要对a、b 这两个整数进行计算,根据条件的不同,需要执行不同的计算方式。我们可以把所有的操作都封装在同一个函数中,然后通过 if ... else ... 的形式来调用不同的计算方式,这种方式称之为硬编码。 + +在实际应用中,随着功能和体验的不断增长,我们需要经常添加/修改策略,这样就需要不断修改已有代码,不仅会让这个函数越来越难维护,还可能因为修改带来一些bug。所以为了解耦,需要使用策略模式,定义一些独立的类来封装不同的算法,每一个类封装一个具体的算法(即策略)。 + +下面是一个实现策略模式的代码: + +package strategy + +// 策略模式 + +// 定义一个策略类 +type IStrategy interface { + do(int, int) int +} + +// 策略实现:加 +type add struct{} + +func (*add) do(a, b int) int { + return a + b +} + +// 策略实现:减 +type reduce struct{} + +func (*reduce) do(a, b int) int { + return a - b +} + +// 具体策略的执行者 +type Operator struct { + strategy IStrategy +} + +// 设置策略 +func (operator *Operator) setStrategy(strategy IStrategy) { + operator.strategy = strategy +} + +// 调用策略中的方法 +func (operator *Operator) calculate(a, b int) int { + return operator.strategy.do(a, b) +} + + +在上述代码中,我们定义了策略接口 IStrategy,还定义了 add 和 reduce 两种策略。最后定义了一个策略执行者,可以设置不同的策略,并执行,例如: + +func TestStrategy(t *testing.T) { + operator := Operator{} + + operator.setStrategy(&add{}) + result := operator.calculate(1, 2) + fmt.Println("add:", result) + + operator.setStrategy(&reduce{}) + result = operator.calculate(2, 1) + fmt.Println("reduce:", result) +} + + +可以看到,我们可以随意更换策略,而不影响Operator的所有实现。 + +模板模式 + +模板模式 (Template Pattern)定义一个操作中算法的骨架,而将一些步骤延迟到子类中。这种方法让子类在不改变一个算法结构的情况下,就能重新定义该算法的某些特定步骤。 + +简单来说,模板模式就是将一个类中能够公共使用的方法放置在抽象类中实现,将不能公共使用的方法作为抽象方法,强制子类去实现,这样就做到了将一个类作为一个模板,让开发者去填充需要填充的地方。 + +以下是模板模式的一个实现: + +package template + +import "fmt" + +type Cooker interface { + fire() + cooke() + outfire() +} + +// 类似于一个抽象类 +type CookMenu struct { +} + +func (CookMenu) fire() { + fmt.Println("开火") +} + +// 做菜,交给具体的子类实现 +func (CookMenu) cooke() { +} + +func (CookMenu) outfire() { + fmt.Println("关火") +} + +// 封装具体步骤 +func doCook(cook Cooker) { + cook.fire() + cook.cooke() + cook.outfire() +} + +type XiHongShi struct { + CookMenu +} + +func (*XiHongShi) cooke() { + fmt.Println("做西红柿") +} + +type ChaoJiDan struct { + CookMenu +} + +func (ChaoJiDan) cooke() { + fmt.Println("做炒鸡蛋") +} + + +这里来看下测试用例: + +func TestTemplate(t *testing.T) { + // 做西红柿 + xihongshi := &XiHongShi{} + doCook(xihongshi) + + fmt.Println("\n=====> 做另外一道菜") + // 做炒鸡蛋 + chaojidan := &ChaoJiDan{} + doCook(chaojidan) + +} + + +行为型模式 + +然后,让我们来看最后一个类别,行为型模式(Behavioral Patterns),它的特点是关注对象之间的通信。这一类别的设计模式中,我们会讲到代理模式和选项模式。 + +代理模式 + +代理模式 (Proxy Pattern),可以为另一个对象提供一个替身或者占位符,以控制对这个对象的访问。 + +以下代码是一个代理模式的实现: + +package proxy + +import "fmt" + +type Seller interface { + sell(name string) +} + +// 火车站 +type Station struct { + stock int //库存 +} + +func (station *Station) sell(name string) { + if station.stock > 0 { + station.stock-- + fmt.Printf("代理点中:%s买了一张票,剩余:%d \n", name, station.stock) + } else { + fmt.Println("票已售空") + } + +} + +// 火车代理点 +type StationProxy struct { + station *Station // 持有一个火车站对象 +} + +func (proxy *StationProxy) sell(name string) { + if proxy.station.stock > 0 { + proxy.station.stock-- + fmt.Printf("代理点中:%s买了一张票,剩余:%d \n", name, proxy.station.stock) + } else { + fmt.Println("票已售空") + } +} + + +上述代码中,StationProxy代理了Station,代理类中持有被代理类对象,并且和被代理类对象实现了同一接口。 + +选项模式 + +选项模式(Options Pattern)也是Go项目开发中经常使用到的模式,例如,grpc/grpc-go的NewServer函数,uber-go/zap包的New函数都用到了选项模式。使用选项模式,我们可以创建一个带有默认值的struct变量,并选择性地修改其中一些参数的值。 + +在Python语言中,创建一个对象时,可以给参数设置默认值,这样在不传入任何参数时,可以返回携带默认值的对象,并在需要时修改对象的属性。这种特性可以大大简化开发者创建一个对象的成本,尤其是在对象拥有众多属性时。 + +而在Go语言中,因为不支持给参数设置默认值,为了既能够创建带默认值的实例,又能够创建自定义参数的实例,不少开发者会通过以下两种方法来实现: + +第一种方法,我们要分别开发两个用来创建实例的函数,一个可以创建带默认值的实例,一个可以定制化创建实例。 + +package options + +import ( + "time" +) + +const ( + defaultTimeout = 10 + defaultCaching = false +) + +type Connection struct { + addr string + cache bool + timeout time.Duration +} + +// NewConnect creates a connection. +func NewConnect(addr string) (*Connection, error) { + return &Connection{ + addr: addr, + cache: defaultCaching, + timeout: defaultTimeout, + }, nil +} + +// NewConnectWithOptions creates a connection with options. +func NewConnectWithOptions(addr string, cache bool, timeout time.Duration) (*Connection, error) { + return &Connection{ + addr: addr, + cache: cache, + timeout: timeout, + }, nil +} + + +使用这种方式,创建同一个Connection实例,却要实现两个不同的函数,实现方式很不优雅。 + +另外一种方法相对优雅些。我们需要创建一个带默认值的选项,并用该选项创建实例: + +package options + +import ( + "time" +) + +const ( + defaultTimeout = 10 + defaultCaching = false +) + +type Connection struct { + addr string + cache bool + timeout time.Duration +} + +type ConnectionOptions struct { + Caching bool + Timeout time.Duration +} + +func NewDefaultOptions() *ConnectionOptions { + return &ConnectionOptions{ + Caching: defaultCaching, + Timeout: defaultTimeout, + } +} + +// NewConnect creates a connection with options. +func NewConnect(addr string, opts *ConnectionOptions) (*Connection, error) { + return &Connection{ + addr: addr, + cache: opts.Caching, + timeout: opts.Timeout, + }, nil +} + + +使用这种方式,虽然只需要实现一个函数来创建实例,但是也有缺点:为了创建Connection实例,每次我们都要创建ConnectionOptions,操作起来比较麻烦。 + +那么有没有更优雅的解决方法呢?答案当然是有的,就是使用选项模式来创建实例。以下代码通过选项模式实现上述功能: + +package options + +import ( + "time" +) + +type Connection struct { + addr string + cache bool + timeout time.Duration +} + +const ( + defaultTimeout = 10 + defaultCaching = false +) + +type options struct { + timeout time.Duration + caching bool +} + +// Option overrides behavior of Connect. +type Option interface { + apply(*options) +} + +type optionFunc func(*options) + +func (f optionFunc) apply(o *options) { + f(o) +} + +func WithTimeout(t time.Duration) Option { + return optionFunc(func(o *options) { + o.timeout = t + }) +} + +func WithCaching(cache bool) Option { + return optionFunc(func(o *options) { + o.caching = cache + }) +} + +// Connect creates a connection. +func NewConnect(addr string, opts ...Option) (*Connection, error) { + options := options{ + timeout: defaultTimeout, + caching: defaultCaching, + } + + for _, o := range opts { + o.apply(&options) + } + + return &Connection{ + addr: addr, + cache: options.caching, + timeout: options.timeout, + }, nil +} + + +在上面的代码中,首先我们定义了options结构体,它携带了timeout、caching两个属性。接下来,我们通过NewConnect创建了一个连接,NewConnect函数中先创建了一个带有默认值的options结构体变量,并通过调用 + +for _, o := range opts { + o.apply(&options) +} + + +来修改所创建的options结构体变量。 + +需要修改的属性,是在NewConnect时,通过Option类型的选项参数传递进来的。可以通过WithXXX函数来创建Option类型的选项参数:WithTimeout、WithCaching。 + +Option类型的选项参数需要实现apply(*options)函数,结合WithTimeout、WithCaching函数的返回值和optionFunc的apply方法实现,可以知道o.apply(&options)其实就是把WithTimeout、WithCaching传入的参数赋值给options结构体变量,以此动态地设置options结构体变量的属性。 + +这里还有一个好处:我们可以在apply函数中自定义赋值逻辑,例如o.timeout = 100 * t。通过这种方式,我们会有更大的灵活性来设置结构体的属性。 + +选项模式有很多优点,例如:支持传递多个参数,并且在参数发生变化时保持兼容性;支持任意顺序传递参数;支持默认值;方便扩展;通过WithXXX的函数命名,可以使参数意义更加明确,等等。 + +不过,为了实现选项模式,我们增加了很多代码,所以在开发中,要根据实际场景选择是否使用选项模式。选项模式通常适用于以下场景: + + +结构体参数很多,创建结构体时,我们期望创建一个携带默认值的结构体变量,并选择性修改其中一些参数的值。 +结构体参数经常变动,变动时我们又不想修改创建实例的函数。例如:结构体新增一个retry参数,但是又不想在NewConnect入参列表中添加retry int这样的参数声明。 + + +如果结构体参数比较少,可以慎重考虑要不要采用选项模式。 + +总结 + +设计模式,是业界沉淀下来的针对特定场景的最佳解决方案。在软件领域,GoF首次系统化提出了3大类设计模式:创建型模式、结构型模式、行为型模式。 + +这一讲,我介绍了Go项目开发中6种常用的设计模式。每种设计模式解决某一类场景,我给你总结成了一张表格,你可以根据自己的需要进行选择。 + + + +课后练习 + + +你当前开发的项目中,哪些可以用单例模式、工厂模式、选项模式来重新实现呢?如果有的话,我建议你试着重写下这部分代码。 +除了这一讲我们学习的 6 种设计模式之外,你还用过其他的设计模式吗?欢迎你在留言区和我分享下你的经验,或者你踩过的坑。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/12API风格(上):如何设计RESTfulAPI?.md b/专栏/Go语言项目开发实战/12API风格(上):如何设计RESTfulAPI?.md new file mode 100644 index 0000000..c57105b --- /dev/null +++ b/专栏/Go语言项目开发实战/12API风格(上):如何设计RESTfulAPI?.md @@ -0,0 +1,233 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 API 风格(上):如何设计RESTful API? + 你好,我是孔令飞。从今天开始,我们就要进入实战第二站,开始学习如何设计和开发Go项目开发中的基础功能了。接下来两讲,我们一起来看下如何设计应用的API风格。 + +绝大部分的Go后端服务需要编写API接口,对外提供服务。所以在开发之前,我们需要确定一种API风格。API风格也可以理解为API类型,目前业界常用的API风格有三种:REST、RPC和GraphQL。我们需要根据项目需求,并结合API风格的特点,确定使用哪种API风格,这对以后的编码实现、通信方式和通信效率都有很大的影响。 + +在Go项目开发中,用得最多的是REST和RPC,我们在IAM实战项目中也使用了REST和RPC来构建示例项目。接下来的两讲,我会详细介绍下REST和RPC这两种风格,如果你对GraphQL感兴趣,GraphQL中文官网有很多文档和代码示例,你可以自行学习。 + +这一讲,我们先来看下RESTful API风格设计,下一讲再介绍下RPC API风格。 + +RESTful API介绍 + +在回答“RESTful API是什么”之前,我们先来看下REST是什么意思:REST代表的是表现层状态转移(REpresentational State Transfer),由Roy Fielding在他的论文《Architectural Styles and the Design of Network-based Software Architectures》里提出。REST本身并没有创造新的技术、组件或服务,它只是一种软件架构风格,是一组架构约束条件和原则,而不是技术框架。 + +REST有一系列规范,满足这些规范的API均可称为RESTful API。REST规范把所有内容都视为资源,也就是说网络上一切皆资源。REST架构对资源的操作包括获取、创建、修改和删除,这些操作正好对应HTTP协议提供的GET、POST、PUT和DELETE方法。HTTP动词与 REST风格CRUD的对应关系见下表: + + + +REST风格虽然适用于很多传输协议,但在实际开发中,由于REST天生和HTTP协议相辅相成,因此HTTP协议已经成了实现RESTful API事实上的标准。所以,REST具有以下核心特点: + + +以资源(resource)为中心,所有的东西都抽象成资源,所有的行为都应该是在资源上的CRUD操作。 + + +资源对应着面向对象范式里的对象,面向对象范式以对象为中心。 +资源使用URI标识,每个资源实例都有一个唯一的URI标识。例如,如果我们有一个用户,用户名是admin,那么它的URI标识就可以是/users/admin。 + + +资源是有状态的,使用JSON/XML等在HTTP Body里表征资源的状态。 + +客户端通过四个HTTP动词,对服务器端资源进行操作,实现“表现层状态转化”。 + +无状态,这里的无状态是指每个RESTful API请求都包含了所有足够完成本次操作的信息,服务器端无须保持session。无状态对于服务端的弹性扩容是很重要的。 + + +因为怕你弄混概念,这里强调下REST和RESTful API的区别:REST是一种规范,而RESTful API则是满足这种规范的API接口。 + +RESTful API设计原则 + +上面我们说了,RESTful API就是满足REST规范的API,由此看来,RESTful API的核心是规范,那么具体有哪些规范呢? + +接下来,我就从URI设计、API版本管理等七个方面,给你详细介绍下RESTful API的设计原则,然后再通过一个示例来帮助你快速启动一个RESTful API服务。希望你学完这一讲之后,对如何设计RESTful API有一个清楚的认知。 + +URI设计 + +资源都是使用URI标识的,我们应该按照一定的规范来设计URI,通过规范化可以使我们的API接口更加易读、易用。以下是URI设计时,应该遵循的一些规范: + + +资源名使用名词而不是动词,并且用名词复数表示。资源分为Collection和Member两种。 + + +Collection:一堆资源的集合。例如我们系统里有很多用户(User),这些用户的集合就是Collection。Collection的URI标识应该是 域名/资源名复数, 例如https:// iam.api.marmotedu.com/users。 +Member:单个特定资源。例如系统中特定名字的用户,就是Collection里的一个Member。Member的URI标识应该是 域名/资源名复数/资源名称, 例如https:// iam.api.marmotedu/users/admin。 + + +URI结尾不应包含/。 + +URI中不能出现下划线 _,必须用中杠线 -代替(有些人推荐用 _,有些人推荐用 -,统一使用一种格式即可,我比较推荐用 -)。 + +URI路径用小写,不要用大写。 + +避免层级过深的URI。超过2层的资源嵌套会很乱,建议将其他资源转化为?参数,比如: + + +/schools/tsinghua/classes/rooma/students/zhang # 不推荐 +/students?school=qinghua&class=rooma # 推荐 + + +这里有个地方需要注意:在实际的API开发中,可能你会发现有些操作不能很好地映射为一个REST资源,这时候,你可以参考下面的做法。 + + +将一个操作变成资源的一个属性,比如想在系统中暂时禁用某个用户,可以这么设计URI:/users/zhangsan?active=false。 +将操作当作是一个资源的嵌套资源,比如一个GitHub的加星操作: + + +PUT /gists/:id/star # github star action +DELETE /gists/:id/star # github unstar action + + + +如果以上都不能解决问题,有时可以打破这类规范。比如登录操作,登录不属于任何一个资源,URI可以设计为:/login。 + + +在设计URI时,如果你遇到一些不确定的地方,推荐你参考 GitHub标准RESTful API。 + +REST资源操作映射为HTTP方法 + +基本上RESTful API都是使用HTTP协议原生的GET、PUT、POST、DELETE来标识对资源的CRUD操作的,形成的规范如下表所示: + + + +对资源的操作应该满足安全性和幂等性: + + +安全性:不会改变资源状态,可以理解为只读的。 +幂等性:执行1次和执行N次,对资源状态改变的效果是等价的。 + + +使用不同HTTP方法时,资源操作的安全性和幂等性对照见下表: + + + +在使用HTTP方法的时候,有以下两点需要你注意: + + +GET返回的结果,要尽量可用于PUT、POST操作中。例如,用GET方法获得了一个user的信息,调用者修改user的邮件,然后将此结果再用PUT方法更新。这要求GET、PUT、POST操作的资源属性是一致的。 +如果对资源进行状态/属性变更,要用PUT方法,POST方法仅用来创建或者批量删除这两种场景。 + + +在设计API时,经常会有批量删除的需求,需要在请求中携带多个需要删除的资源名,但是HTTP的DELETE方法不能携带多个资源名,这时候可以通过下面三种方式来解决: + + +发起多个DELETE请求。 +操作路径中带多个id,id之间用分隔符分隔, 例如:DELETE /users?ids=1,2,3 。 +直接使用POST方式来批量删除,body中传入需要删除的资源列表。 + + +其中,第二种是我最推荐的方式,因为使用了匹配的DELETE动词,并且不需要发送多次DELETE请求。 + +你需要注意的是,这三种方式都有各自的使用场景,你可以根据需要自行选择。如果选择了某一种方式,那么整个项目都需要统一用这种方式。 + +统一的返回格式 + +一般来说,一个系统的RESTful API会向外界开放多个资源的接口,每个接口的返回格式要保持一致。另外,每个接口都会返回成功和失败两种消息,这两种消息的格式也要保持一致。不然,客户端代码要适配不同接口的返回格式,每个返回格式又要适配成功和失败两种消息格式,会大大增加用户的学习和使用成本。 + +返回的格式没有强制的标准,你可以根据实际的业务需要返回不同的格式。本专栏 第19讲 中会推荐一种返回格式,它也是业界最常用和推荐的返回格式。 + +API 版本管理 + +随着时间的推移、需求的变更,一个API往往满足不了现有的需求,这时候就需要对API进行修改。对API进行修改时,不能影响其他调用系统的正常使用,这就要求API变更做到向下兼容,也就是新老版本共存。 + +但在实际场景中,很可能会出现同一个API无法向下兼容的情况。这时候最好的解决办法是从一开始就引入API版本机制,当不能向下兼容时,就引入一个新的版本,老的版本则保留原样。这样既能保证服务的可用性和安全性,同时也能满足新需求。 + +API版本有不同的标识方法,在RESTful API开发中,通常将版本标识放在如下3个位置: + + +URL中,比如/v1/users。 +HTTP Header中,比如Accept: vnd.example-com.foo+json; version=1.0。 +Form参数中,比如/users?version=v1。 + + +我们这门课中的版本标识是放在URL中的,比如/v1/users,这样做的好处是很直观,GitHub、Kubernetes、Etcd等很多优秀的API均采用这种方式。 + +这里要注意,有些开发人员不建议将版本放在URL中,因为他们觉得不同的版本可以理解成同一种资源的不同表现形式,所以应该采用同一个URI。对于这一点,没有严格的标准,根据项目实际需要选择一种方式即可。 + +API命名 + +API通常的命名方式有三种,分别是驼峰命名法(serverAddress)、蛇形命名法(server_address)和脊柱命名法(server-address)。 + +驼峰命名法和蛇形命名法都需要切换输入法,会增加操作的复杂性,也容易出错,所以这里建议用脊柱命名法。GitHub API用的就是脊柱命名法,例如 selected-actions。 + +统一分页/过滤/排序/搜索功能 + +REST资源的查询接口,通常情况下都需要实现分页、过滤、排序、搜索功能,因为这些功能是每个REST资源都能用到的,所以可以实现为一个公共的API组件。下面来介绍下这些功能。 + + +分页:在列出一个Collection下所有的Member时,应该提供分页功能,例如/users?offset=0&limit=20(limit,指定返回记录的数量;offset,指定返回记录的开始位置)。引入分页功能可以减少API响应的延时,同时可以避免返回太多条目,导致服务器/客户端响应特别慢,甚至导致服务器/客户端crash的情况。 +过滤:如果用户不需要一个资源的全部状态属性,可以在URI参数里指定返回哪些属性,例如/users?fields=email,username,address。 +排序:用户很多时候会根据创建时间或者其他因素,列出一个Collection中前100个Member,这时可以在URI参数中指明排序参数,例如/users?sort=age,desc。 +搜索:当一个资源的Member太多时,用户可能想通过搜索,快速找到所需要的Member,或着想搜下有没有名字为xxx的某类资源,这时候就需要提供搜索功能。搜索建议按模糊匹配来搜索。 + + +域名 + +API的域名设置主要有两种方式: + + +https://marmotedu.com/api,这种方式适合API将来不会有进一步扩展的情况,比如刚开始marmotedu.com域名下只有一套API系统,未来也只有这一套API系统。 +https://iam.api.marmotedu.com,如果marmotedu.com域名下未来会新增另一个系统API,这时候最好的方式是每个系统的API拥有专有的API域名,比如:storage.api.marmotedu.com,network.api.marmotedu.com。腾讯云的域名就是采用这种方式。 + + +到这里,我们就将REST设计原则中的核心原则讲完了,这里有个需要注意的点:不同公司、不同团队、不同项目可能采取不同的REST设计原则,以上所列的基本上都是大家公认的原则。 + +REST设计原则中,还有一些原则因为内容比较多,并且可以独立成模块,所以放在后面来讲。比如 RESTful API安全性、状态返回码和认证等。 + +REST示例 + +上面介绍了一些概念和原则,这里我们通过一个“Hello World”程序,来教你用Go快速启动一个RESTful API服务,示例代码存放在gopractise-demo/apistyle/ping/main.go。 + +package main + +import ( + "log" + "net/http" +) + +func main() { + http.HandleFunc("/ping", pong) + log.Println("Starting http server ...") + log.Fatal(http.ListenAndServe(":50052", nil)) +} + +func pong(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("pong")) +} + + +在上面的代码中,我们通过http.HandleFunc,向HTTP服务注册了一个pong handler,在pong handler中,我们编写了真实的业务代码:返回pong字符串。 + +创建完main.go文件后,在当前目录下执行go run main.go启动HTTP服务,在一个新的Linux终端下发送HTTP请求,进行使用curl命令测试: + +$ curl http://127.0.0.1:50052/ping +pong + + +总结 + +这一讲,我介绍了两种常用API风格中的一种,RESTful API。REST是一种API规范,而RESTful API则是满足这种规范的API接口,RESTful API的核心是规范。 + +在REST规范中,资源通过URI来标识,资源名使用名词而不是动词,并且用名词复数表示,资源都是分为Collection和Member两种。RESTful API中,分别使用POST、DELETE、PUT、GET来表示REST资源的增删改查,HTTP方法、Collection、Member不同组合会产生不同的操作,具体的映射你可以看下 REST资源操作映射为HTTP方法 部分的表格。 + +为了方便用户使用和理解,每个RESTful API的返回格式、错误和正确消息的返回格式,都应该保持一致。RESTful API需要支持API版本,并且版本应该能够向前兼容,我们可以将版本号放在URL中、HTTP Header中、Form参数中,但这里我建议将版本号放在URL中,例如 /v1/users,这种形式比较直观。 + +另外,我们可以通过脊柱命名法来命名API接口名。对于一个REST资源,其查询接口还应该支持分页/过滤/排序/搜索功能,这些功能可以用同一套机制来实现。 API的域名可以采用 https://marmotedu.com/api 和 https://iam.api.marmotedu.com 两种格式。 + +最后,在Go中我们可以使用net/http包来快速启动一个RESTful API服务。 + +课后练习 + + +使用net/http包,快速实现一个RESTful API服务,并实现/hello接口,该接口会返回“Hello World”字符串。 +思考一下,RESTful API这种API风格是否能够满足你当前的项目需要,如果不满足,原因是什么? + + +期待在留言区看到你的思考和答案,也欢迎和我一起探讨关于RESTful API相关的问题,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/13API风格(下):RPCAPI介绍.md b/专栏/Go语言项目开发实战/13API风格(下):RPCAPI介绍.md new file mode 100644 index 0000000..805a763 --- /dev/null +++ b/专栏/Go语言项目开发实战/13API风格(下):RPCAPI介绍.md @@ -0,0 +1,479 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 API 风格(下):RPC API介绍 + 你好,我是孔令飞。这一讲,我们继续来看下如何设计应用的API风格。 + +上一讲,我介绍了REST API风格,这一讲我来介绍下另外一种常用的API风格,RPC。在Go项目开发中,如果业务对性能要求比较高,并且需要提供给多种编程语言调用,这时候就可以考虑使用RPC API接口。RPC在Go项目开发中用得也非常多,需要我们认真掌握。 + +RPC介绍 + +根据维基百科的定义,RPC(Remote Procedure Call),即远程过程调用,是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员不用额外地为这个交互作用编程。 + +通俗来讲,就是服务端实现了一个函数,客户端使用RPC框架提供的接口,像调用本地函数一样调用这个函数,并获取返回值。RPC屏蔽了底层的网络通信细节,使得开发人员无需关注网络编程的细节,可以将更多的时间和精力放在业务逻辑本身的实现上,从而提高开发效率。 + +RPC的调用过程如下图所示: + + + +RPC调用具体流程如下: + + +Client通过本地调用,调用Client Stub。 +Client Stub将参数打包(也叫Marshalling)成一个消息,然后发送这个消息。 +Client所在的OS将消息发送给Server。 +Server端接收到消息后,将消息传递给Server Stub。 +Server Stub将消息解包(也叫 Unmarshalling)得到参数。 +Server Stub调用服务端的子程序(函数),处理完后,将最终结果按照相反的步骤返回给 Client。 + + +这里需要注意,Stub负责调用参数和返回值的流化(serialization)、参数的打包和解包,以及网络层的通信。Client端一般叫Stub,Server端一般叫Skeleton。 + +目前,业界有很多优秀的RPC协议,例如腾讯的Tars、阿里的Dubbo、微博的Motan、Facebook的Thrift、RPCX,等等。但使用最多的还是gRPC,这也是本专栏所采用的RPC框架,所以接下来我会重点介绍gRPC框架。 + +gRPC介绍 + +gRPC是由Google开发的高性能、开源、跨多种编程语言的通用RPC框架,基于HTTP 2.0协议开发,默认采用Protocol Buffers数据序列化协议。gRPC具有如下特性: + + +支持多种语言,例如 Go、Java、C、C++、C#、Node.js、PHP、Python、Ruby等。 +基于IDL(Interface Definition Language)文件定义服务,通过proto3工具生成指定语言的数据结构、服务端接口以及客户端Stub。通过这种方式,也可以将服务端和客户端解耦,使客户端和服务端可以并行开发。 +通信协议基于标准的HTTP/2设计,支持双向流、消息头压缩、单TCP的多路复用、服务端推送等特性。 +支持Protobuf和JSON序列化数据格式。Protobuf是一种语言无关的高性能序列化框架,可以减少网络传输流量,提高通信效率。 + + +这里要注意的是,gRPC的全称不是golang Remote Procedure Call,而是google Remote Procedure Call。 + +gRPC的调用如下图所示: + + + +在gRPC中,客户端可以直接调用部署在不同机器上的gRPC服务所提供的方法,调用远端的gRPC方法就像调用本地的方法一样,非常简单方便,通过gRPC调用,我们可以非常容易地构建出一个分布式应用。 + +像很多其他的RPC服务一样,gRPC也是通过IDL语言,预先定义好接口(接口的名字、传入参数和返回参数等)。在服务端,gRPC服务实现我们所定义的接口。在客户端,gRPC存根提供了跟服务端相同的方法。 + +gRPC支持多种语言,比如我们可以用Go语言实现gRPC服务,并通过Java语言客户端调用gRPC服务所提供的方法。通过多语言支持,我们编写的gRPC服务能满足客户端多语言的需求。 + +gRPC API接口通常使用的数据传输格式是Protocol Buffers。接下来,我们就一起了解下Protocol Buffers。 + +Protocol Buffers介绍 + +Protocol Buffers(ProtocolBuffer/ protobuf)是Google开发的一套对数据结构进行序列化的方法,可用作(数据)通信协议、数据存储格式等,也是一种更加灵活、高效的数据格式,与XML、JSON类似。它的传输性能非常好,所以常被用在一些对数据传输性能要求比较高的系统中,作为数据传输格式。Protocol Buffers的主要特性有下面这几个。 + + +更快的数据传输速度:protobuf在传输时,会将数据序列化为二进制数据,和XML、JSON的文本传输格式相比,这可以节省大量的IO操作,从而提高数据传输速度。 +跨平台多语言:protobuf自带的编译工具 protoc 可以基于protobuf定义文件,编译出不同语言的客户端或者服务端,供程序直接调用,因此可以满足多语言需求的场景。 +具有非常好的扩展性和兼容性,可以更新已有的数据结构,而不破坏和影响原有的程序。 +基于IDL文件定义服务,通过proto3工具生成指定语言的数据结构、服务端和客户端接口。 + + +在gRPC的框架中,Protocol Buffers主要有三个作用。 + +第一,可以用来定义数据结构。举个例子,下面的代码定义了一个SecretInfo数据结构: + +// SecretInfo contains secret details. +message SecretInfo { + string name = 1; + string secret_id = 2; + string username = 3; + string secret_key = 4; + int64 expires = 5; + string description = 6; + string created_at = 7; + string updated_at = 8; +} + + +第二,可以用来定义服务接口。下面的代码定义了一个Cache服务,服务包含了ListSecrets和ListPolicies 两个API接口。 + +// Cache implements a cache rpc service. +service Cache{ + rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse) {} + rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse) {} +} + + +第三,可以通过protobuf序列化和反序列化,提升传输效率。 + +gRPC示例 + +我们已经对gRPC这一通用RPC框架有了一定的了解,但是你可能还不清楚怎么使用gRPC编写API接口。接下来,我就通过gRPC官方的一个示例来快速给大家展示下。运行本示例需要在Linux服务器上安装Go编译器、Protocol buffer编译器(protoc,v3)和 protoc 的Go语言插件,在 02讲 中我们已经安装过,这里不再讲具体的安装方法。 + +这个示例分为下面几个步骤: + + +定义gRPC服务。 +生成客户端和服务器代码。 +实现gRPC服务。 +实现gRPC客户端。 + + +示例代码存放在gopractise-demo/apistyle/greeter目录下。代码结构如下: + +$ tree +├── client +│ └── main.go +├── helloworld +│ ├── helloworld.pb.go +│ └── helloworld.proto +└── server + └── main.go + + +client目录存放Client端的代码,helloworld目录用来存放服务的IDL定义,server目录用来存放Server端的代码。 + +下面我具体介绍下这个示例的四个步骤。 + + +定义gRPC服务。 + + +首先,需要定义我们的服务。进入helloworld目录,新建文件helloworld.proto: + +$ cd helloworld +$ vi helloworld.proto + + +内容如下: + +syntax = "proto3"; + +option go_package = "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} + + +在helloworld.proto定义文件中,option关键字用来对.proto文件进行一些设置,其中go_package是必需的设置,而且go_package的值必须是包导入的路径。package关键字指定生成的.pb.go文件所在的包名。我们通过service关键字定义服务,然后再指定该服务拥有的RPC方法,并定义方法的请求和返回的结构体类型: + +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + + +gRPC支持定义4种类型的服务方法,分别是简单模式、服务端数据流模式、客户端数据流模式和双向数据流模式。 + + +简单模式(Simple RPC):是最简单的gRPC模式。客户端发起一次请求,服务端响应一个数据。定义格式为rpc SayHello (HelloRequest) returns (HelloReply) {}。 + +服务端数据流模式(Server-side streaming RPC):客户端发送一个请求,服务器返回数据流响应,客户端从流中读取数据直到为空。定义格式为rpc SayHello (HelloRequest) returns (stream HelloReply) {}。 + +客户端数据流模式(Client-side streaming RPC):客户端将消息以流的方式发送给服务器,服务器全部处理完成之后返回一次响应。定义格式为rpc SayHello (stream HelloRequest) returns (HelloReply) {}。 + +双向数据流模式(Bidirectional streaming RPC):客户端和服务端都可以向对方发送数据流,这个时候双方的数据可以同时互相发送,也就是可以实现实时交互RPC框架原理。定义格式为rpc SayHello (stream HelloRequest) returns (stream HelloReply) {}。 + + +本示例使用了简单模式。.proto文件也包含了Protocol Buffers 消息的定义,包括请求消息和返回消息。例如请求消息: + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + + + +生成客户端和服务器代码。 + + +接下来,我们需要根据.proto服务定义生成gRPC客户端和服务器接口。我们可以使用protoc编译工具,并指定使用其Go语言插件来生成: + +$ protoc -I. --go_out=plugins=grpc:$GOPATH/src helloworld.proto +$ ls +helloworld.pb.go helloworld.proto + + +你可以看到,新增了一个helloworld.pb.go文件。 + + +实现gRPC服务。 + + +接着,我们就可以实现gRPC服务了。进入server目录,新建main.go文件: + +$ cd ../server +$ vi main.go + + +main.go内容如下: + +// Package main implements a server for Greeter service. +package main + +import ( + "context" + "log" + "net" + + pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld" + "google.golang.org/grpc" +) + +const ( + port = ":50051" +) + +// server is used to implement helloworld.GreeterServer. +type server struct { + pb.UnimplementedGreeterServer +} + +// SayHello implements helloworld.GreeterServer +func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) { + log.Printf("Received: %v", in.GetName()) + return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil +} + +func main() { + lis, err := net.Listen("tcp", port) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + s := grpc.NewServer() + pb.RegisterGreeterServer(s, &server{}) + if err := s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } +} + + +上面的代码实现了我们上一步根据服务定义生成的Go接口。 + +我们先定义了一个Go结构体server,并为server结构体添加SayHello(context.Context, pb.HelloRequest) (pb.HelloReply, error)方法,也就是说server是GreeterServer接口(位于helloworld.pb.go文件中)的一个实现。 + +在我们实现了gRPC服务所定义的方法之后,就可以通过 net.Listen(...) 指定监听客户端请求的端口;接着,通过 grpc.NewServer() 创建一个gRPC Server实例,并通过 pb.RegisterGreeterServer(s, &server{}) 将该服务注册到gRPC框架中;最后,通过 s.Serve(lis) 启动gRPC服务。 + +创建完main.go文件后,在当前目录下执行 go run main.go ,启动gRPC服务。 + + +实现gRPC客户端。 + + +打开一个新的Linux终端,进入client目录,新建main.go文件: + +$ cd ../client +$ vi main.go + + +main.go内容如下: + +// Package main implements a client for Greeter service. +package main + +import ( + "context" + "log" + "os" + "time" + + pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld" + "google.golang.org/grpc" +) + +const ( + address = "localhost:50051" + defaultName = "world" +) + +func main() { + // Set up a connection to the server. + conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock()) + if err != nil { + log.Fatalf("did not connect: %v", err) + } + defer conn.Close() + c := pb.NewGreeterClient(conn) + + // Contact the server and print out its response. + name := defaultName + if len(os.Args) > 1 { + name = os.Args[1] + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name}) + if err != nil { + log.Fatalf("could not greet: %v", err) + } + log.Printf("Greeting: %s", r.Message) +} + + +在上面的代码中,我们通过如下代码创建了一个gRPC连接,用来跟服务端进行通信: + +// Set up a connection to the server. +conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock()) +if err != nil { + log.Fatalf("did not connect: %v", err) +} +defer conn.Close() + + +在创建连接时,我们可以指定不同的选项,用来控制创建连接的方式,例如grpc.WithInsecure()、grpc.WithBlock()等。gRPC支持很多选项,更多的选项可以参考grpc仓库下dialoptions.go文件中以With开头的函数。 + +连接建立起来之后,我们需要创建一个客户端stub,用来执行RPC请求c := pb.NewGreeterClient(conn)。创建完成之后,我们就可以像调用本地函数一样,调用远程的方法了。例如,下面一段代码通过 c.SayHello 这种本地式调用方式调用了远端的SayHello接口: + +r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name}) +if err != nil { + log.Fatalf("could not greet: %v", err) +} +log.Printf("Greeting: %s", r.Message) + + +从上面的调用格式中,我们可以看到RPC调用具有下面两个特点。 + + +调用方便:RPC屏蔽了底层的网络通信细节,使得调用RPC就像调用本地方法一样方便,调用方式跟大家所熟知的调用类的方法一致:ClassName.ClassFuc(params)。 +不需要打包和解包:RPC调用的入参和返回的结果都是Go的结构体,不需要对传入参数进行打包操作,也不需要对返回参数进行解包操作,简化了调用步骤。 + + +最后,创建完main.go文件后,在当前目录下,执行go run main.go发起RPC调用: + +$ go run main.go +2020/10/17 07:55:00 Greeting: Hello world + + +至此,我们用四个步骤,创建并调用了一个gRPC服务。接下来我再给大家讲解一个在具体场景中的注意事项。 + +在做服务开发时,我们经常会遇到一种场景:定义一个接口,接口会通过判断是否传入某个参数,决定接口行为。例如,我们想提供一个GetUser接口,期望GetUser接口在传入username参数时,根据username查询用户的信息,如果没有传入username,则默认根据userId查询用户信息。 + +这时候,我们需要判断客户端有没有传入username参数。我们不能根据username是否为空值来判断,因为我们不能区分客户端传的是空值,还是没有传username参数。这是由Go语言的语法特性决定的:如果客户端没有传入username参数,Go会默认赋值为所在类型的零值,而字符串类型的零值就是空字符串。 + +那我们怎么判断客户端有没有传入username参数呢?最好的方法是通过指针来判断,如果是nil指针就说明没有传入,非nil指针就说明传入,具体实现步骤如下: + + +编写protobuf定义文件。 + + +新建user.proto文件,内容如下: + +syntax = "proto3"; + +package proto; +option go_package = "github.com/marmotedu/gopractise-demo/protobuf/user"; + +//go:generate protoc -I. --experimental_allow_proto3_optional --go_out=plugins=grpc:. + +service User { + rpc GetUser(GetUserRequest) returns (GetUserResponse) {} +} + +message GetUserRequest { + string class = 1; + optional string username = 2; + optional string user_id = 3; +} + +message GetUserResponse { + string class = 1; + string user_id = 2; + string username = 3; + string address = 4; + string sex = 5; + string phone = 6; +} + + +你需要注意,这里我们在需要设置为可选字段的前面添加了optional标识。 + + +使用protoc工具编译protobuf文件。 + + +在执行protoc命令时,需要传入--experimental_allow_proto3_optional参数以打开optional选项,编译命令如下: + +$ protoc --experimental_allow_proto3_optional --go_out=plugins=grpc:. user.proto + + +上述编译命令会生成user.pb.go文件,其中的GetUserRequest结构体定义如下: + +type GetUserRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Class string `protobuf:"bytes,1,opt,name=class,proto3" json:"class,omitempty"` + Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + UserId *string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3,oneof" json:"user_id,omitempty"` +} + + +通过 optional + --experimental_allow_proto3_optional 组合,我们可以将一个字段编译为指针类型。 + + +编写gRPC接口实现。 + + +新建一个user.go文件,内容如下: + +package user + +import ( + "context" + + pb "github.com/marmotedu/api/proto/apiserver/v1" + + "github.com/marmotedu/iam/internal/apiserver/store" +) + +type User struct { +} + +func (c *User) GetUser(ctx context.Context, r *pb.GetUserRequest) (*pb.GetUserResponse, error) { + if r.Username != nil { + return store.Client().Users().GetUserByName(r.Class, r.Username) + } + + return store.Client().Users().GetUserByID(r.Class, r.UserId) +} + + +总之,在GetUser方法中,我们可以通过判断r.Username是否为nil,来判断客户端是否传入了Username参数。 + +RESTful VS gRPC + +到这里,今天我们已经介绍完了gRPC API。回想一下我们昨天学习的RESTful API,你可能想问:这两种API风格分别有什么优缺点,适用于什么场景呢?我把这个问题的答案放在了下面这张表中,你可以对照着它,根据自己的需求在实际应用时进行选择。 + + + +当然,更多的时候,RESTful API 和gRPC API是一种合作的关系,对内业务使用gRPC API,对外业务使用RESTful API,如下图所示: + + + +总结 + +在Go项目开发中,我们可以选择使用 RESTful API 风格和 RPC API 风格,这两种服务都用得很多。其中,RESTful API风格因为规范、易理解、易用,所以适合用在需要对外提供API接口的场景中。而RPC API因为性能比较高、调用方便,更适合用在内部业务中。 + +RESTful API使用的是HTTP协议,而RPC API使用的是RPC协议。目前,有很多RPC协议可供你选择,而我推荐你使用gRPC,因为它很轻量,同时性能很高、很稳定,是一个优秀的RPC框架。所以目前业界用的最多的还是gRPC协议,腾讯、阿里等大厂内部很多核心的线上服务用的就是gRPC。 + +除了使用gRPC协议,在进行Go项目开发前,你也可以了解业界一些其他的优秀Go RPC框架,比如腾讯的tars-go、阿里的dubbo-go、Facebook的thrift、rpcx等,你可以在项目开发之前一并调研,根据实际情况进行选择。 + +课后练习 + + +使用gRPC包,快速实现一个RPC API服务,并实现PrintHello接口,该接口会返回“Hello World”字符串。 +请你思考这个场景:你有一个gRPC服务,但是却希望该服务同时也能提供RESTful API接口,这该如何实现? + + +期待在留言区看到你的思考和答案,也欢迎和我一起探讨关于RPC API相关的问题,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/14项目管理:如何编写高质量的Makefile?.md b/专栏/Go语言项目开发实战/14项目管理:如何编写高质量的Makefile?.md new file mode 100644 index 0000000..d98e34d --- /dev/null +++ b/专栏/Go语言项目开发实战/14项目管理:如何编写高质量的Makefile?.md @@ -0,0 +1,476 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 项目管理:如何编写高质量的Makefile? + 你好,我是孔令飞。今天我们来聊聊如何编写高质量的Makefile。 + +我们在 第10讲 学习过,要写出一个优雅的Go项目,不仅仅是要开发一个优秀的Go应用,而且还要能够高效地管理项目。有效手段之一,就是通过Makefile来管理我们的项目,这就要求我们要为项目编写Makefile文件。 + +在和其他开发同学交流时,我发现大家都认可Makefile强大的项目管理能力,也会自己编写Makefile。但是其中的一些人项目管理做得并不好,我和他们进一步交流后发现,这些同学在用Makefile简单的语法重复编写一些低质量Makefile文件,根本没有把Makefile的功能充分发挥出来。 + +下面给你举个例子,你就会理解低质量的Makefile文件是什么样的了。 + +build: clean vet + @mkdir -p ./Role + @export GOOS=linux && go build -v . + +vet: + go vet ./... + +fmt: + go fmt ./... + +clean: + rm -rf dashboard + + +上面这个Makefile存在不少问题。例如:功能简单,只能完成最基本的编译、格式化等操作,像构建镜像、自动生成代码等一些高阶的功能都没有;扩展性差,没法编译出可在Mac下运行的二进制文件;没有Help功能,使用难度高;单Makefile文件,结构单一,不适合添加一些复杂的管理功能。 + +所以,我们不光要编写Makefile,还要编写高质量的Makefile。那么如何编写一个高质量的Makefile呢?我觉得,可以通过以下4个方法来实现: + + +打好基础,也就是熟练掌握Makefile的语法。 +做好准备工作,也就是提前规划Makefile要实现的功能。 +进行规划,设计一个合理的Makefile结构。 +掌握方法,用好Makefile的编写技巧。 + + +那么接下来,我们就详细看看这些方法。 + +熟练掌握Makefile语法 + +工欲善其事,必先利其器。编写高质量Makefile的第一步,便是熟练掌握Makefile的核心语法。 + +因为Makefile的语法比较多,我把一些建议你重点掌握的语法放在了近期会更新的特别放送中,包括Makefile规则语法、伪目标、变量赋值、条件语句和Makefile常用函数等等。 + +如果你想更深入、全面地学习Makefile的语法,我推荐你学习陈皓老师编写的《跟我一起写 Makefile》 (PDF 重制版)。 + +规划Makefile要实现的功能 + +接着,我们需要规划Makefile要实现的功能。提前规划好功能,有利于你设计Makefile的整体结构和实现方法。 + +不同项目拥有不同的Makefile功能,这些功能中一小部分是通过目标文件来实现的,但更多的功能是通过伪目标来实现的。对于Go项目来说,虽然不同项目集成的功能不一样,但绝大部分项目都需要实现一些通用的功能。接下来,我们就来看看,在一个大型Go项目中Makefile通常可以实现的功能。 + +下面是IAM项目的Makefile所集成的功能,希望会对你日后设计Makefile有一些帮助。 + +$ make help + +Usage: make ... + +Targets: + # 代码生成类命令 + gen Generate all necessary files, such as error code files. + + # 格式化类命令 + format Gofmt (reformat) package sources (exclude vendor dir if existed). + + # 静态代码检查 + lint Check syntax and styling of go sources. + + # 测试类命令 + test Run unit test. + cover Run unit test and get test coverage. + + # 构建类命令 + build Build source code for host platform. + build.multiarch Build source code for multiple platforms. See option PLATFORMS. + + # Docker镜像打包类命令 + image Build docker images for host arch. + image.multiarch Build docker images for multiple platforms. See option PLATFORMS. + push Build docker images for host arch and push images to registry. + push.multiarch Build docker images for multiple platforms and push images to registry. + + # 部署类命令 + deploy Deploy updated components to development env. + + # 清理类命令 + clean Remove all files that are created by building. + + # 其他命令,不同项目会有区别 + release Release iam + verify-copyright Verify the boilerplate headers for all files. + ca Generate CA files for all iam components. + install Install iam system with all its components. + swagger Generate swagger document. + tools install dependent tools. + + # 帮助命令 + help Show this help info. + +# 选项 +Options: + DEBUG Whether to generate debug symbols. Default is 0. + BINS The binaries to build. Default is all of cmd. + This option is available when using: make build/build.multiarch + Example: make build BINS="iam-apiserver iam-authz-server" + ... + + +更详细的命令,你可以在IAM项目仓库根目录下执行make help查看。 + +通常而言,Go项目的Makefile应该实现以下功能:格式化代码、静态代码检查、单元测试、代码构建、文件清理、帮助等等。如果通过docker部署,还需要有docker镜像打包功能。因为Go是跨平台的语言,所以构建和docker打包命令,还要能够支持不同的CPU架构和平台。为了能够更好地控制Makefile命令的行为,还需要支持Options。 + +为了方便查看Makefile集成了哪些功能,我们需要支持help命令。help命令最好通过解析Makefile文件来输出集成的功能,例如: + +## help: Show this help info. +.PHONY: help +help: Makefile + @echo -e "\nUsage: make ...\n\nTargets:" + @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' + @echo "$$USAGE_OPTIONS" + + +上面的help命令,通过解析Makefile文件中的##注释,获取支持的命令。通过这种方式,我们以后新加命令时,就不用再对help命令进行修改了。 + +你可以参考上面的Makefile管理功能,结合自己项目的需求,整理出一个Makefile要实现的功能列表,并初步确定实现思路和方法。做完这些,你的编写前准备工作就基本完成了。 + +设计合理的Makefile结构 + +设计完Makefile需要实现的功能,接下来我们就进入Makefile编写阶段。编写阶段的第一步,就是设计一个合理的Makefile结构。 + +对于大型项目来说,需要管理的内容很多,所有管理功能都集成在一个Makefile中,可能会导致Makefile很大,难以阅读和维护,所以建议采用分层的设计方法,根目录下的Makefile聚合所有的Makefile命令,具体实现则按功能分类,放在另外的Makefile中。 + +我们经常会在Makefile命令中集成shell脚本,但如果shell脚本过于复杂,也会导致Makefile内容过多,难以阅读和维护。并且在Makefile中集成复杂的shell脚本,编写体验也很差。对于这种情况,可以将复杂的shell命令封装在shell脚本中,供Makefile直接调用,而一些简单的命令则可以直接集成在Makefile中。 + +所以,最终我推荐的Makefile结构如下: + + + +在上面的Makefile组织方式中,根目录下的Makefile聚合了项目所有的管理功能,这些管理功能通过Makefile伪目标的方式实现。同时,还将这些伪目标进行分类,把相同类别的伪目标放在同一个Makefile中,这样可以使得Makefile更容易维护。对于复杂的命令,则编写成独立的shell脚本,并在Makefile命令中调用这些shell脚本。 + +举个例子,下面是IAM项目的Makefile组织结构: + +├── Makefile +├── scripts +│ ├── gendoc.sh +│ ├── make-rules +│ │ ├── gen.mk +│ │ ├── golang.mk +│ │ ├── image.mk +│ │ └── ... + └── ... + + +我们将相同类别的操作统一放在scripts/make-rules目录下的Makefile文件中。Makefile的文件名参考分类命名,例如 golang.mk。最后,在/Makefile 中 include 这些 Makefile。 + +为了跟Makefile的层级相匹配,golang.mk中的所有目标都按go.xxx这种方式命名。通过这种命名方式,我们可以很容易分辨出某个目标完成什么功能,放在什么文件里,这在复杂的Makefile中尤其有用。以下是IAM项目根目录下,Makefile的内容摘录,你可以看一看,作为参考: + +include scripts/make-rules/golang.mk +include scripts/make-rules/image.mk +include scripts/make-rules/gen.mk +include scripts/make-rules/... + +## build: Build source code for host platform. +.PHONY: build +build: + @$(MAKE) go.build + +## build.multiarch: Build source code for multiple platforms. See option PLATFORMS. +.PHONY: build.multiarch +build.multiarch: + @$(MAKE) go.build.multiarch + +## image: Build docker images for host arch. +.PHONY: image +image: + @$(MAKE) image.build + +## push: Build docker images for host arch and push images to registry. +.PHONY: push +push: + @$(MAKE) image.push + +## ca: Generate CA files for all iam components. +.PHONY: ca +ca: + @$(MAKE) gen.ca + + +另外,一个合理的Makefile结构应该具有前瞻性。也就是说,要在不改变现有结构的情况下,接纳后面的新功能。这就需要你整理好Makefile当前要实现的功能、即将要实现的功能和未来可能会实现的功能,然后基于这些功能,利用Makefile编程技巧,编写可扩展的Makefile。 + +这里需要你注意:上面的Makefile通过 .PHONY 标识定义了大量的伪目标,定义伪目标一定要加 .PHONY 标识,否则当有同名的文件时,伪目标可能不会被执行。 + +掌握Makefile编写技巧 + +最后,在编写过程中,你还需要掌握一些Makefile的编写技巧,这些技巧可以使你编写的Makefile扩展性更强,功能更强大。 + +接下来,我会把自己长期开发过程中积累的一些Makefile编写经验分享给你。这些技巧,你需要在实际编写中多加练习,并形成编写习惯。 + +技巧1:善用通配符和自动变量 + +Makefile允许对目标进行类似正则运算的匹配,主要用到的通配符是%。通过使用通配符,可以使不同的目标使用相同的规则,从而使Makefile扩展性更强,也更简洁。 + +我们的IAM实战项目中,就大量使用了通配符%,例如:go.build.%、ca.gen.%、deploy.run.%、tools.verify.%、tools.install.%等。 + +这里,我们来看一个具体的例子,tools.verify.%(位于scripts/make-rules/tools.mk文件中)定义如下: + +tools.verify.%: + @if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi + + +make tools.verify.swagger, make tools.verify.mockgen等均可以使用上面定义的规则,%分别代表了swagger和mockgen。 + +如果不使用%,则我们需要分别为tools.verify.swagger和tools.verify.mockgen定义规则,很麻烦,后面修改也困难。 + +另外,这里也能看出tools.verify.%这种命名方式的好处:tools说明依赖的定义位于scripts/make-rules/tools.mk Makefile中;verify说明tools.verify.%伪目标属于verify分类,主要用来验证工具是否安装。通过这种命名方式,你可以很容易地知道目标位于哪个Makefile文件中,以及想要完成的功能。 + +另外,上面的定义中还用到了自动变量$*,用来指代被匹配的值swagger、mockgen。 + +技巧2:善用函数 + +Makefile自带的函数能够帮助我们实现很多强大的功能。所以,在我们编写Makefile的过程中,如果有功能需求,可以优先使用这些函数。我把常用的函数以及它们实现的功能整理在了 Makefile常用函数列表 中,你可以参考下。 + +IAM的Makefile文件中大量使用了上述函数,如果你想查看这些函数的具体使用方法和场景,可以参考IAM项目的Makefile文件 make-rules。 + +技巧3:依赖需要用到的工具 + +如果Makefile某个目标的命令中用到了某个工具,可以将该工具放在目标的依赖中。这样,当执行该目标时,就可以指定检查系统是否安装该工具,如果没有安装则自动安装,从而实现更高程度的自动化。例如,/Makefile文件中,format伪目标,定义如下: + +.PHONY: format +format: tools.verify.golines tools.verify.goimports + @echo "===========> Formating codes" + @$(FIND) -type f -name '*.go' | $(XARGS) gofmt -s -w + @$(FIND) -type f -name '*.go' | $(XARGS) goimports -w -local $(ROOT_PACKAGE) + @$(FIND) -type f -name '*.go' | $(XARGS) golines -w --max-len=120 --reformat-tags --shorten-comments --ignore-generated . + + +你可以看到,format依赖tools.verify.golines tools.verify.goimports。我们再来看下tools.verify.golines的定义: + +tools.verify.%: + @if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi + + +再来看下tools.install.$*规则: + +.PHONY: install.golines +install.golines: + @$(GO) get -u github.com/segmentio/golines + + +通过tools.verify.%规则定义,我们可以知道,tools.verify.%会先检查工具是否安装,如果没有安装,就会执行tools.install.$*来安装。如此一来,当我们执行tools.verify.%目标时,如果系统没有安装golines命令,就会自动调用go get安装,提高了Makefile的自动化程度。 + +技巧4:把常用功能放在/Makefile中,不常用的放在分类Makefile中 + +一个项目,尤其是大型项目,有很多需要管理的地方,其中大部分都可以通过Makefile实现自动化操作。不过,为了保持/Makefile文件的整洁性,我们不能把所有的命令都添加在/Makefile文件中。 + +一个比较好的建议是,将常用功能放在/Makefile中,不常用的放在分类Makefile中,并在/Makefile中include这些分类Makefile。 + +例如,IAM项目的/Makefile集成了format、lint、test、build等常用命令,而将gen.errcode.code、gen.errcode.doc这类不常用的功能放在scripts/make-rules/gen.mk文件中。当然,我们也可以直接执行 make gen.errcode.code来执行gen.errcode.code伪目标。通过这种方式,既可以保证/Makefile的简洁、易维护,又可以通过make命令来运行伪目标,更加灵活。 + +技巧5:编写可扩展的Makefile + +什么叫可扩展的Makefile呢?在我看来,可扩展的Makefile包含两层含义: + + +可以在不改变Makefile结构的情况下添加新功能。 +扩展项目时,新功能可以自动纳入到Makefile现有逻辑中。 + + +其中的第一点,我们可以通过设计合理的Makefile结构来实现。要实现第二点,就需要我们在编写Makefile时采用一定的技巧,例如多用通配符、自动变量、函数等。这里我们来看一个例子,可以让你更好地理解。 + +在我们IAM实战项目的golang.mk中,执行 make go.build 时能够构建cmd/目录下的所有组件,也就是说,当有新组件添加时, make go.build 仍然能够构建新增的组件,这就实现了上面说的第二点。 + +具体实现方法如下: + +COMMANDS ?= $(filter-out %.md, $(wildcard ${ROOT_DIR}/cmd/*)) +BINS ?= $(foreach cmd,${COMMANDS},$(notdir ${cmd})) + +.PHONY: go.build +go.build: go.build.verify $(addprefix go.build., $(addprefix $(PLATFORM)., $(BINS))) +.PHONY: go.build.% + +go.build.%: + $(eval COMMAND := $(word 2,$(subst ., ,$*))) + $(eval PLATFORM := $(word 1,$(subst ., ,$*))) + $(eval OS := $(word 1,$(subst _, ,$(PLATFORM)))) + $(eval ARCH := $(word 2,$(subst _, ,$(PLATFORM)))) + @echo "===========> Building binary $(COMMAND) $(VERSION) for $(OS) $(ARCH)" + @mkdir -p $(OUTPUT_DIR)/platforms/$(OS)/$(ARCH) + @CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) $(GO) build $(GO_BUILD_FLAGS) -o $(OUTPUT_DIR)/platforms/$(OS)/$(ARCH)/$(COMMAND)$(GO_OUT_EXT) $(ROOT_PACKAGE)/cmd/$(COMMAND) + + +当执行make go.build 时,会执行go.build的依赖 $(addprefix go.build., $(addprefix $(PLATFORM)., $(BINS))) ,addprefix函数最终返回字符串 go.build.linux_amd64.iamctl go.build.linux_amd64.iam-authz-server go.build.linux_amd64.iam-apiserver ... ,这时候就会执行 go.build.% 伪目标。 + +在 go.build.% 伪目标中,通过eval、word、subst函数组合,算出了COMMAND的值 iamctl/iam-apiserver/iam-authz-server/...,最终通过 $(ROOT_PACKAGE)/cmd/$(COMMAND) 定位到需要构建的组件的main函数所在目录。 + +上述实现中有两个技巧,你可以注意下。首先,通过 + +COMMANDS ?= $(filter-out %.md, $(wildcard ${ROOT_DIR}/cmd/*)) +BINS ?= $(foreach cmd,${COMMANDS},$(notdir ${cmd})) + + +获取到了cmd/目录下的所有组件名。 + +接着,通过使用通配符和自动变量,自动匹配到go.build.linux_amd64.iam-authz-server 这类伪目标并构建。 + +可以看到,想要编写一个可扩展的Makefile,熟练掌握Makefile的用法是基础,更多的是需要我们动脑思考如何去编写Makefile。 + +技巧6:将所有输出存放在一个目录下,方便清理和查找 + +在执行Makefile的过程中,会输出各种各样的文件,例如 Go 编译后的二进制文件、测试覆盖率数据等,我建议你把这些文件统一放在一个目录下,方便后期的清理和查找。通常我们可以把它们放在_output这类目录下,这样清理时就很方便,只需要清理_output文件夹就可以,例如: + +.PHONY: go.clean +go.clean: + @echo "===========> Cleaning all build output" + @-rm -vrf $(OUTPUT_DIR) + + +这里要注意,要用-rm,而不是rm,防止在没有_output目录时,执行make go.clean报错。 + +技巧7:使用带层级的命名方式 + +通过使用带层级的命名方式,例如tools.verify.swagger ,我们可以实现目标分组管理。这样做的好处有很多。首先,当Makefile有大量目标时,通过分组,我们可以更好地管理这些目标。其次,分组也能方便理解,可以通过组名一眼识别出该目标的功能类别。最后,这样做还可以大大减小目标重名的概率。 + +例如,IAM项目的Makefile就大量采用了下面这种命名方式。 + +.PHONY: gen.run +gen.run: gen.clean gen.errcode gen.docgo + +.PHONY: gen.errcode +gen.errcode: gen.errcode.code gen.errcode.doc + +.PHONY: gen.errcode.code +gen.errcode.code: tools.verify.codegen + ... +.PHONY: gen.errcode.doc +gen.errcode.doc: tools.verify.codegen + ... + + +技巧8:做好目标拆分 + +还有一个比较实用的技巧:我们要合理地拆分目标。比如,我们可以将安装工具拆分成两个目标:验证工具是否已安装和安装工具。通过这种方式,可以给我们的Makefile带来更大的灵活性。例如:我们可以根据需要选择性地执行其中一个操作,也可以两个操作一起执行。 + +这里来看一个例子: + +gen.errcode.code: tools.verify.codegen + +tools.verify.%: + @if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi + +.PHONY: install.codegen +install.codegen: + @$(GO) install ${ROOT_DIR}/tools/codegen/codegen.go + + +上面的Makefile中,gen.errcode.code依赖了tools.verify.codegen,tools.verify.codegen会先检查codegen命令是否存在,如果不存在,再调用install.codegen来安装codegen工具。 + +如果我们的Makefile设计是: + +gen.errcode.code: install.codegen + + +那每次执行gen.errcode.code都要重新安装codegen命令,这种操作是不必要的,还会导致 make gen.errcode.code 执行很慢。 + +技巧9:设置OPTIONS + +编写Makefile时,我们还需要把一些可变的功能通过OPTIONS来控制。为了帮助你理解,这里还是拿IAM项目的Makefile来举例。 + +假设我们需要通过一个选项 V ,来控制是否需要在执行Makefile时打印详细的信息。这可以通过下面的步骤来实现。 + +首先,在/Makefile中定义 USAGE_OPTIONS 。定义 USAGE_OPTIONS 可以使开发者在执行 make help 后感知到此OPTION,并根据需要进行设置。 + +define USAGE_OPTIONS + +Options: + ... + BINS The binaries to build. Default is all of cmd. + ... + ... + V Set to 1 enable verbose build. Default is 0. +endef +export USAGE_OPTIONS + + +接着,在scripts/make-rules/common.mk文件中,我们通过判断有没有设置V选项,来选择不同的行为: + +ifndef V +MAKEFLAGS += --no-print-directory +endif + + +当然,我们还可以通过下面的方法来使用 V : + +ifeq ($(origin V), undefined) +MAKEFLAGS += --no-print-directory +endif + + +上面,我介绍了 V OPTION,我们在Makefile中通过判断有没有定义 V ,来执行不同的操作。其实还有一种OPTION,这种OPTION的值我们在Makefile中是直接使用的,例如BINS。针对这种OPTION,我们可以通过以下方式来使用: + +BINS ?= $(foreach cmd,${COMMANDS},$(notdir ${cmd})) +... +go.build: go.build.verify $(addprefix go.build., $(addprefix $(PLATFORM)., $(BINS))) + + +也就是说,通过 ?= 来判断 BINS 变量有没有被赋值,如果没有,则赋予等号后的值。接下来,就可以在Makefile规则中使用它。 + +技巧10:定义环境变量 + +我们可以在Makefile中定义一些环境变量,例如: + +GO := go +GO_SUPPORTED_VERSIONS ?= 1.13|1.14|1.15|1.16|1.17 +GO_LDFLAGS += -X $(VERSION_PACKAGE).GitVersion=$(VERSION) \ + -X $(VERSION_PACKAGE).GitCommit=$(GIT_COMMIT) \ + -X $(VERSION_PACKAGE).GitTreeState=$(GIT_TREE_STATE) \ + -X $(VERSION_PACKAGE).BuildDate=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ') +ifneq ($(DLV),) + GO_BUILD_FLAGS += -gcflags "all=-N -l" + LDFLAGS = "" +endif +GO_BUILD_FLAGS += -tags=jsoniter -ldflags "$(GO_LDFLAGS)" +... +FIND := find . ! -path './third_party/*' ! -path './vendor/*' +XARGS := xargs --no-run-if-empty + + +这些环境变量和编程中使用宏定义的作用是一样的:只要修改一处,就可以使很多地方同时生效,避免了重复的工作。 + +通常,我们可以将GO、GO_BUILD_FLAGS、FIND这类变量定义为环境变量。 + +技巧11:自己调用自己 + +在编写Makefile的过程中,你可能会遇到这样一种情况:A-Target目标命令中,需要完成操作B-Action,而操作B-Action我们已经通过伪目标B-Target实现过。为了达到最大的代码复用度,这时候最好的方式是在A-Target的命令中执行B-Target。方法如下: + +tools.verify.%: + @if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi + + +这里,我们通过 $(MAKE) 调用了伪目标 tools.install.$* 。要注意的是,默认情况下,Makefile在切换目录时会输出以下信息: + +$ make tools.install.codegen +===========> Installing codegen +make[1]: Entering directory `/home/colin/workspace/golang/src/github.com/marmotedu/iam' +make[1]: Leaving directory `/home/colin/workspace/golang/src/github.com/marmotedu/iam' + + +如果觉得Entering directory这类信息很烦人,可以通过设置 MAKEFLAGS += --no-print-directory 来禁止Makefile打印这些信息。 + +总结 + +如果你想要高效管理项目,使用Makefile来管理是目前的最佳实践。我们可以通过下面的几个方法,来编写一个高质量的Makefile。 + +首先,你需要熟练掌握Makefile的语法。我建议你重点掌握以下语法:Makefile规则语法、伪目标、变量赋值、特殊变量、自动化变量。 + +接着,我们需要提前规划Makefile要实现的功能。一个大型Go项目通常需要实现以下功能:代码生成类命令、格式化类命令、静态代码检查、 测试类命令、构建类命令、Docker镜像打包类命令、部署类命令、清理类命令,等等。 + +然后,我们还需要通过Makefile功能分类、文件分层、复杂命令脚本化等方式,来设计一个合理的Makefile结构。 + +最后,我们还需要掌握一些Makefile编写技巧,例如:善用通配符、自动变量和函数;编写可扩展的Makefile;使用带层级的命名方式,等等。通过这些技巧,可以进一步保证我们编写出一个高质量的Makefile。 + +课后练习 + + +走读IAM项目的Makefile实现,看下IAM项目是如何通过 make tools.install 一键安装所有功能,通过 make tools.install.xxx 来指定安装 xxx 工具的。 +你编写Makefile的时候,还用到过哪些编写技巧呢?欢迎和我分享你的经验,或者你踩过的坑。 + + +期待在留言区看到你的思考和答案,也欢迎和我一起探讨关于Makefile的问题,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/15研发流程实战:IAM项目是如何进行研发流程管理的?.md b/专栏/Go语言项目开发实战/15研发流程实战:IAM项目是如何进行研发流程管理的?.md new file mode 100644 index 0000000..0945b4f --- /dev/null +++ b/专栏/Go语言项目开发实战/15研发流程实战:IAM项目是如何进行研发流程管理的?.md @@ -0,0 +1,461 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 研发流程实战:IAM项目是如何进行研发流程管理的? + 你好,我是孔令飞。 + +在 08讲 和 14讲 ,我分别介绍了如何设计研发流程,和如何基于 Makefile 高效地管理项目。那么今天,我们就以研发流程为主线,来看下IAM项目是如何通过Makefile来高效管理项目的。学完这一讲,你不仅能更加深刻地理解 08讲 和 14讲 所介绍的内容,还能得到很多可以直接用在实际操作中的经验、技巧。 + +研发流程有很多阶段,其中的开发阶段和测试阶段是需要开发者深度参与的。所以在这一讲中,我会重点介绍这两个阶段中的Makefile项目管理功能,并且穿插一些我的Makefile的设计思路。 + +为了向你演示流程,这里先假设一个场景。我们有一个需求:给IAM客户端工具iamctl增加一个helloworld命令,该命令向终端打印hello world。 + +接下来,我们就来看下如何具体去执行研发流程中的每一步。首先,我们进入开发阶段。 + +开发阶段 + +开发阶段是开发者的主战场,完全由开发者来主导,它又可分为代码开发和代码提交两个子阶段。我们先来看下代码开发阶段。 + +代码开发 + +拿到需求之后,首先需要开发代码。这时,我们就需要选择一个适合团队和项目的Git工作流。因为Git Flow工作流比较适合大型的非开源项目,所以这里我们选择Git Flow工作流。代码开发的具体步骤如下: + +第一步,基于develop分支,新建一个功能分支 feature/helloworld。 + +$ git checkout -b feature/helloworld develop + + +这里需要注意:新建的branch名要符合Git Flow工作流中的分支命名规则。否则,在git commit阶段,会因为branch不规范导致commit失败。IAM项目的分支命令规则具体如下图所示: + + + +IAM项目通过pre-commit githooks来确保分支名是符合规范的。在IAM项目根目录下执行git commit 命令,git会自动执行pre-commit脚本,该脚本会检查当前branch的名字是否符合规范。 + +这里还有一个地方需要你注意:git不会提交 .git/hooks 目录下的githooks脚本,所以我们需要通过以下手段,确保开发者clone仓库之后,仍然能安装我们指定的githooks脚本到 .git/hooks 目录: + +# Copy githook scripts when execute makefile +COPY_GITHOOK:=$(shell cp -f githooks/* .git/hooks/) + + +上述代码放在scripts/make-rules/common.mk文件中,每次执行make命令时都会执行,可以确保githooks都安装到 .git/hooks 目录下。 + +第二步,在feature/helloworld分支中,完成helloworld命令的添加。 + +首先,通过 iamctl new helloworld 命令创建helloworld命令模板: + +$ iamctl new helloworld -d internal/iamctl/cmd/helloworld +Command file generated: internal/iamctl/cmd/helloworld/helloworld.go + + +接着,编辑internal/iamctl/cmd/cmd.go文件,在源码文件中添加helloworld.NewCmdHelloworld(f, ioStreams),,加载helloworld命令。这里将helloworld命令设置为Troubleshooting and Debugging Commands命令分组: + +import ( + "github.com/marmotedu/iam/internal/iamctl/cmd/helloworld" +) + ... + { + Message: "Troubleshooting and Debugging Commands:", + Commands: []*cobra.Command{ + validate.NewCmdValidate(f, ioStreams), + helloworld.NewCmdHelloworld(f, ioStreams), + }, + }, + + +这些操作中包含了low code的思想。在第 10讲 中我就强调过,要尽可能使用代码自动生成这一技术。这样做有两个好处:一方面能够提高我们的代码开发效率;另一方面也能够保证规范,减少手动操作可能带来的错误。所以这里,我将iamctl的命令也模板化,并通过 iamctl new 自动生成。 + +第三步,生成代码。 + +$ make gen + + +如果改动不涉及代码生成,可以不执行make gen操作。 make gen 执行的其实是gen.run伪目标: + +gen.run: gen.clean gen.errcode gen.docgo.doc + + +可以看到,当执行 make gen.run 时,其实会先清理之前生成的文件,再分别自动生成error code和doc.go文件。 + +这里需要注意,通过make gen 生成的存量代码要具有幂等性。只有这样,才能确保每次生成的代码是一样的,避免不一致带来的问题。 + +我们可以将更多的与自动生成代码相关的功能放在 gen.mk Makefile 中。例如: + + +gen.docgo.doc,代表自动生成doc.go文件。 +gen.ca.%,代表自动生成iamctl、iam-apiserver、iam-authz-server证书文件。 + + +第四步,版权检查。 + +如果有新文件添加,我们还需要执行 make verify-copyright ,来检查新文件有没有添加版权头信息。 + +$ make verify-copyright + + +如果版权检查失败,可以执行make add-copyright自动添加版权头。添加版权信息只针对开源软件,如果你的软件不需要添加,就可以略过这一步。 + +这里还有个Makefile编写技巧:如果Makefile的command需要某个命令,就可以使该目标依赖类似tools.verify.addlicense这种目标,tools.verify.addlicense会检查该工具是否已安装,如果没有就先安装。 + +.PHONY: copyright.verify +copyright.verify: tools.verify.addlicense + ... +tools.verify.%: + @if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi +.PHONY: install.addlicense +install.addlicense: + @$(GO) get -u github.com/marmotedu/addlicense + + +通过这种方式,可以使 make copyright.verify 尽可能自动化,减少手动介入的概率。 + +第五步,代码格式化。 + +$ make format + + +执行make format会依次执行以下格式化操作: + + +调用gofmt格式化你的代码。 +调用goimports工具,自动增删依赖的包,并将依赖包按字母序排序并分类。 +调用golines工具,把超过120行的代码按golines规则,格式化成 +调用 go mod edit -fmt 格式化go.mod文件。 + + +第六步,静态代码检查。 + +$ make lint + + +关于静态代码检查,在这里你可以先了解代码开发阶段有这个步骤,至于如何操作,我会在下一讲给你详细介绍。 + +第七步,单元测试。 + +$ make test + + +这里要注意,并不是所有的包都需要执行单元测试。你可以通过如下命令,排除掉不需要单元测试的包: + +go test `go list ./...|egrep -v $(subst $(SPACE),'|',$(sort $(EXCLUDE_TESTS)))` + + +在go.test的command中,我们还运行了以下命令: + +sed -i '/mock_.*.go/d' $(OUTPUT_DIR)/coverage.out + + +运行该命令的目的,是把mock_.* .go文件中的函数单元测试信息从coverage.out中删除。mock_.*.go文件中的函数是不需要单元测试的,如果不删除,就会影响后面的单元测试覆盖率的计算。 + +如果想检查单元测试覆盖率,请执行: + +$ make cover + + +默认测试覆盖率至少为60%,也可以在命令行指定覆盖率阈值为其他值,例如: + +$ make cover COVERAGE=90 + + +如果测试覆盖率不满足要求,就会返回以下错误信息: + +test coverage is 62.1% +test coverage does not meet expectations: 90%, please add test cases! +make[1]: *** [go.test.cover] Error 1 +make: *** [cover] Error 2 + + +这里make命令的退出码为1。 + +如果单元测试覆盖率达不到设置的阈值,就需要补充测试用例,否则禁止合并到develop和master分支。IAM项目配置了GitHub Actions CI自动化流水线,CI流水线会自动运行,检查单元测试覆盖率是否达到要求。 + +第八步,构建。 + +最后,我们执行make build命令,构建出cmd/目录下所有的二进制安装文件。 + +$ make build + + +make build 会自动构建 cmd/ 目录下的所有组件,如果只想构建其中的一个或多个组件,可以传入 BINS选项,组件之间用空格隔开,并用双引号引起来: + +$ make build BINS="iam-apiserver iamctl" + + +到这里,我们就完成了代码开发阶段的全部操作。 + +如果你觉得手动执行的make命令比较多,可以直接执行make命令: + +$ make +===========> Generating iam error code go source files +===========> Generating error code markdown documentation +===========> Generating missing doc.go for go packages +===========> Verifying the boilerplate headers for all files +===========> Formating codes +===========> Run golangci to lint source codes +===========> Run unit test +... +===========> Building binary iam-pump v0.7.2-24-g5814e7b for linux amd64 +===========> Building binary iamctl v0.7.2-24-g5814e7b for linux amd64 +... + + +直接执行make会执行伪目标all所依赖的伪目标 all: tidy gen add-copyright format lint cover build,也即执行以下操作:依赖包添加/删除、生成代码、自动添加版权头、代码格式化、静态代码检查、覆盖率测试、构建。 + +这里你需要注意一点:all中依赖cover,cover实际执行的是 go.test.cover ,而 go.test.cover 又依赖 go.test ,所以cover实际上是先执行单元测试,再检查单元测试覆盖率是否满足预设的阈值。 + +最后补充一点,在开发阶段我们可以根据需要随时执行 make gen 、 make format 、 make lint 、 make cover 等操作,为的是能够提前发现问题并改正。 + +代码提交 + +代码开发完成之后,我们就需要将代码提交到远程仓库,整个流程分为以下几个步骤。 + +第一步,开发完后,将代码提交到feature/helloworld分支,并push到远端仓库。 + +$ git add internal/iamctl/cmd/helloworld internal/iamctl/cmd/cmd.go +$ git commit -m "feat: add new iamctl command 'helloworld'" +$ git push origin feature/helloworld + + +这里我建议你只添加跟feature/helloworld相关的改动,这样就知道一个commit做了哪些变更,方便以后追溯。所以,我不建议直接执行git add .这类方式提交改动。 + +在提交commit时,commit-msg githooks会检查commit message是否符合Angular Commit Message规范,如果不符合会报错。commit-msage调用了go-gitlint来检查commit message。go-gitlint会读取 .gitlint 中配置的commit message格式: + +--subject-regex=^((Merge branch.*of.*)|((revert: )?(feat|fix|perf|style|refactor|test|ci|docs|chore)(\(.+\))?: [^A-Z].*[^.]$)) +--subject-maxlen=72 +--body-regex=^([^\r\n]{0,72}(\r?\n|$))*$ + + +IAM项目配置了GitHub Actions,当有代码被push后,会触发CI流水线,流水线会执行make all目标。GitHub Actions CI流程执行记录如下图: + + + +如果CI不通过,就需要修改代码,直到CI流水线通过为止。 + +这里,我们来看下GitHub Actions的配置: + +name: IamCI + +on: + push: + branchs: + - '*' + pull_request: + types: [opened, reopened] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.16 + + - name: all + run: make + + +可以看到,GitHub Actions实际上执行了3步:拉取代码、设置Go编译环境、执行make命令(也就是执行 make all 目标)。 + +GitHub Actions也执行了 make all 目标,和手动操作执行的 make all 目标保持一致,这样做是为了让线上的CI流程和本地的CI流程完全保持一致。这样,当我们在本地执行make命令通过后,在线上也会通过。保持一个一致的执行流程和执行结果很重要。否则,本地执行make通过,但是线上却不通过,岂不很让人头疼? + +第二步,提交pull request。 + +登陆GitHub,基于feature/helloworld创建pull request,并指定Reviewers进行code review。具体操作如下图: + + + +当有新的pull request被创建后,也会触发CI流水线。 + +第三步,创建完pull request后,就可以通知reviewers 来 review代码,GitHub也会发站内信。 + +第四步,Reviewers 对代码进行review。 + +Reviewer通过review github diff后的内容,并结合CI流程是否通过添加评论,并选择Comment(仅评论)、Approve(通过)、Request Changes(不通过,需要修改),如下图所示: + + + +如果review不通过,feature开发者可以直接在feature/helloworld分支修正代码,并push到远端的feature/helloworld分支,然后通知reviewers再次review。因为有push事件发生,所以会触发GitHub Actions CI流水线。 + +第五步,code review通过后,maintainer就可以将新的代码合并到develop分支。 + +使用Create a merge commit的方式,将pull request合并到develop分支,如下图所示: + + + +Create a merge commit的实际操作是 git merge --no-ff,feature/helloworld分支上所有的 commit 都会加到 develop 分支上,并且会生成一个 merge commit。使用这种方式,可以清晰地知道是谁做了哪些提交,回溯历史的时候也会更加方便。 + +第六步,合并到develop分支后,触发CI流程。 + +到这里,开发阶段的操作就全部完成了,整体流程如下: + + + +合并到develop分支之后,我们就可以进入开发阶段的下一阶段,也就是测试阶段了。 + +测试阶段 + +在测试阶段,开发人员主要负责提供测试包和修复测试期间发现的bug,这个过程中也可能会发现一些新的需求或变动点,所以需要合理评估这些新的需求或变动点是否要放在当前迭代修改。 + +测试阶段的操作流程如下。 + +第一步,基于develop分支,创建release分支,测试代码。 + +$ git checkout -b release/1.0.0 develop +$ make + + +第二步,提交测试。 + +将release/1.0.0分支的代码提交给测试同学进行测试。这里假设一个测试失败的场景:我们要求打印“hello world”,但打印的是“Hello World”,需要修复。那具体应该怎么操作呢? + +你可以直接在release/1.0.0分支修改代码,修改完成后,本地构建并提交代码: + +$ make +$ git add internal/iamctl/cmd/helloworld/ +$ git commit -m "fix: fix helloworld print bug" +$ git push origin release/1.0.0 + + +push到release/1.0.0后,GitHub Actions会执行CI流水线。如果流水线执行成功,就将代码提供给测试;如果测试不成功,再重新修改,直到流水线执行成功。 + +测试同学会对release/1.0.0分支的代码进行充分的测试,例如功能测试、性能测试、集成测试、系统测试等。 + +第三步,测试通过后,将功能分支合并到master分支和develop分支。 + +$ git checkout develop +$ git merge --no-ff release/1.0.0 +$ git checkout master +$ git merge --no-ff release/1.0.0 +$ git tag -a v1.0.0 -m "add print hello world" # master分支打tag + + +到这里,测试阶段的操作就基本完成了。测试阶段的产物是master/develop分支的代码。 + +第四步,删除feature/helloworld分支,也可以选择性删除release/1.0.0分支。 + +我们的代码都合并入master/develop分支后,feature开发者可以选择是否要保留feature。不过,如果没有特别的原因,我建议删掉,因为feature分支太多的话,不仅看起来很乱,还会影响性能,删除操作如下: + +$ git branch -d feature/helloworld + + +IAM项目的Makefile项目管理技巧 + +在上面的内容中,我们以研发流程为主线,亲身体验了IAM项目的Makefile项目管理功能。这些是你最应该掌握的核心功能,但IAM项目的Makefile还有很多功能和设计技巧。接下来,我会给你分享一些很有价值的Makefile项目管理技巧。 + +help自动解析 + +因为随着项目的扩展,Makefile大概率会不断加入新的管理功能,这些管理功能也需要加入到 make help 输出中。但如果每添加一个目标,都要修改 make help 命令,就比较麻烦,还容易出错。所以这里,我通过自动解析的方式,来生成make help输出: + +## help: Show this help info. +.PHONY: help +help: Makefile + @echo -e "\nUsage: make ...\n\nTargets:" + @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' + @echo "$$USAGE_OPTIONS" + + +目标help的命令中,通过 sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' 命令,自动解析Makefile中 ## 开头的注释行,从而自动生成 make help 输出。 + +Options中指定变量值 + +通过以下赋值方式,变量可以在Makefile options中被指定: + +ifeq ($(origin COVERAGE),undefined) +COVERAGE := 60 +endif + + +例如,如果我们执行make ,则COVERAGE设置为默认值60;如果我们执行make COVERAGE=90 ,则COVERAGE值为90。通过这种方式,我们可以更灵活地控制Makefile的行为。 + +自动生成CHANGELOG + +一个项目最好有CHANGELOG用来展示每个版本之间的变更内容,作为Release Note的一部分。但是,如果每次都要手动编写CHANGELOG,会很麻烦,也不容易坚持,所以这里我们可以借助git-chglog工具来自动生成。 + +IAM项目的git-chglog工具的配置文件放在.chglog目录下,在学习git-chglog工具时,你可以参考下。 + +自动生成版本号 + +一个项目也需要有一个版本号,当前用得比较多的是语义化版本号规范。但如果靠开发者手动打版本号,工作效率低不说,经常还会出现漏打、打的版本号不规范等问题。所以最好的办法是,版本号也通过工具自动生成。在IAM项目中,采用了gsemver工具来自动生成版本号。 + +整个IAM项目的版本号,都是通过scripts/ensure_tag.sh脚本来生成的: + +version=v`gsemver bump` +if [ -z "`git tag -l $version`" ];then + git tag -a -m "release version $version" $version +fi + + +在scripts/ensure_tag.sh脚本中,通过 gsemver bump 命令来自动化生成语义化的版本号,并执行 git tag -a 给仓库打上版本号标签,gsemver 命令会根据Commit Message自动生成版本号。 + +之后,Makefile和Shell脚本用到的所有版本号均统一使用scripts/make-rules/common.mk文件中的VERSION变量: + +VERSION := $(shell git describe --tags --always --match='v*') + + +上述的Shell命令通过 git describe 来获取离当前提交最近的tag(版本号)。 + +在执行 git describe 时,如果符合条件的tag指向最新提交,则只显示tag的名字,否则会有相关的后缀,来描述该tag之后有多少次提交,以及最新的提交commit id。例如: + +$ git describe --tags --always --match='v*' +v1.0.0-3-g1909e47 + + +这里解释下版本号中各字符的含义: + + +3:表示自打tag v1.0.0以来有3次提交。 +g1909e47:g 为git的缩写,在多种管理工具并存的环境中很有用处。 +1909e47:7位字符表示为最新提交的commit id 前7位。 + + +最后解释下参数: + + +–tags,使用所有的标签,而不是只使用带注释的标签(annotated tag)。git tag 生成一个 unannotated tag,git tag -a -m '' 生成一个 annotated tag。 +–always,如果仓库没有可用的标签,那么使用commit缩写来替代标签。 +–match ,只考虑与给定模式相匹配的标签。 + + +保持行为一致 + +上面我们介绍了一些管理功能,例如检查Commit Message是否符合规范、自动生成CHANGELOG、自动生成版本号。这些可以通过Makefile来操作,我们也可以手动执行。例如,通过以下命令,检查IAM的所有Commit是否符合Angular Commit Message规范: + +$ go-gitlint +b62db1f: subject does not match regex [^(revert: )?(feat|fix|perf|style|refactor|test|ci|docs|chore)(\(.+\))?: [^A-Z].*[^.]$] + + +也可以通过以下命令,手动来生成CHANGELOG: + +$ git-chglog v1.0.0 CHANGELOG/CHANGELOG-1.0.0.md + + +还可以执行gsemver来生成版本号: + +$ gsemver bump +1.0.1 + + +这里要强调的是,我们要保证不管使用手动操作,还是通过Makefile操作,都要确保git commit message规范检查结果、生成的CHANGELOG、生成的版本号是一致的。这需要我们采用同一种操作方式。 + +总结 + +在整个研发流程中,需要开发人员深度参与的阶段有两个,分别是开发阶段和测试阶段。在开发阶段,开发者完成代码开发之后,通常需要执行生成代码、版权检查、代码格式化、静态代码检查、单元测试、构建等操作。我们可以将这些操作集成在Makefile中,来提高效率,并借此统一操作。 + +另外,IAM项目在编写Makefile时也采用了一些技巧,例如make help 命令中,help信息是通过解析Makefile文件的注释来完成的;可以通过git-chglog自动生成CHANGELOG;通过gsemver自动生成语义化的版本号等。 + +课后练习 + + +看下IAM项目的 make dependencies 是如何实现的,这样实现有什么好处? +IAM项目中使用 了gofmt 、goimports 、golines 3种格式化工具,思考下,还有没有其他格式化工具值得集成在 make format 目标的命令中? + + +欢迎你在留言区分享你的见解,和我一起交流讨论,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/16代码检查:如何进行静态代码检查?.md b/专栏/Go语言项目开发实战/16代码检查:如何进行静态代码检查?.md new file mode 100644 index 0000000..95eaea2 --- /dev/null +++ b/专栏/Go语言项目开发实战/16代码检查:如何进行静态代码检查?.md @@ -0,0 +1,439 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 代码检查:如何进行静态代码检查? + 你好,我是孔令飞。上一讲中,我在讲代码开发的具体步骤时,提到了静态代码检查,今天我就来详细讲讲如何执行静态代码检查。 + +在做Go项目开发的过程中,我们肯定需要对Go代码做静态代码检查。虽然Go命令提供了go vet和go tool vet,但是它们检查的内容还不够全面,我们需要一种更加强大的静态代码检查工具。 + +其实,Go生态中有很多这样的工具,也不乏一些比较优秀的。今天我想给你介绍的golangci-lint,是目前使用最多,也最受欢迎的静态代码检查工具,我们的IAM实战项目也用到了它。 + +接下来,我就从golangci-lint的优点、golangci-lint提供的命令和选项、golangci-lint的配置这三个方面来向你介绍下它。在你了解这些基础知识后,我会带着你使用golangci-lint进行静态代码检查,让你熟悉操作,在这个基础上,再把我使用golangci-lint时总结的一些经验技巧分享给你。 + +为什么选择golangci-lint做静态代码检查? + +选择golangci-lint,是因为它具有其他静态代码检查工具不具备的一些优点。在我看来,它的核心优点至少有这些: + + +速度非常快:golangci-lint是基于gometalinter开发的,但是平均速度要比gometalinter快5倍。golangci-lint速度快的原因有三个:可以并行检查代码;可以复用go build缓存;会缓存分析结果。 +可配置:支持YAML格式的配置文件,让检查更灵活,更可控。 +IDE集成:可以集成进多个主流的IDE,例如 VS Code、GNU Emacs、Sublime Text、Goland等。 +linter聚合器:1.41.1版本的golangci-lint集成了76个linter,不需要再单独安装这76个linter。并且golangci-lint还支持自定义linter。 +最小的误报数:golangci-lint调整了所集成linter的默认设置,大幅度减少了误报。 +良好的输出:输出的结果带有颜色、代码行号和linter标识,易于查看和定位。 + + +下图是一个golangci-lint的检查结果: + + + +你可以看到,输出的检查结果中包括如下信息: + + +检查出问题的源码文件、行号和错误行内容。 +出问题的原因,也就是打印出不符合检查规则的原因。 +报错的linter。 + + +通过查看golangci-lint的输出结果,可以准确地定位到报错的位置,快速弄明白报错的原因,方便开发者修复。 + +除了上述优点之外,在我看来golangci-lint还有一个非常大的优点:当前更新迭代速度很快,不断有新的linter被集成到golangci-lint中。有这么全的linter为你的代码保驾护航,你在交付代码时肯定会更有自信。 + +目前,有很多公司/项目使用了golangci-lint工具作为静态代码检查工具,例如 Google、Facebook、Istio、Red Hat OpenShift等。 + +golangci-lint提供了哪些命令和选项? + +在使用之前,首先需要安装golangci-lint。golangci-lint的安装方法也很简单,你只需要执行以下命令,就可以安装了。 + +$ go get github.com/golangci/golangci-lint/cmd/[email protected] +$ golangci-lint version # 输出 golangci-lint 版本号,说明安装成功 +golangci-lint has version v1.39.0 built from (unknown, mod sum: "h1:aAUjdBxARwkGLd5PU0vKuym281f2rFOyqh3GB4nXcq8=") on (unknown) + + +这里注意,为了避免安装失败,强烈建议你安装golangci-lint releases page中的指定版本,例如 v1.41.1。 + +另外,还建议你定期更新 golangci-lint 的版本,因为该项目正在被积极开发并不断改进。 + +安装之后,就可以使用了。我们可以通过执行 golangci-lint -h 查看其用法,golangci-lint支持的子命令见下表: + + + +此外,golangci-lint还支持一些全局选项。全局选项是指适用于所有子命令的选项,golangci-lint支持的全局选项如下: + + + +接下来,我就详细介绍下golangci-lint支持的核心子命令:run、cache、completion、config、linters。 + +run命令 + +run命令执行golangci-lint,对代码进行检查,是golangci-lint最为核心的一个命令。run没有子命令,但有很多选项。run命令的具体使用方法,我会在讲解如何执行静态代码检查的时候详细介绍。 + +cache命令 + +cache命令用来进行缓存控制,并打印缓存的信息。它包含两个子命令: + + +clean用来清除cache,当我们觉得cache的内容异常,或者cache占用空间过大时,可以通过golangci-lint cache clean清除cache。 +status用来打印cache的状态,比如cache的存放目录和cache的大小,例如: + + +$ golangci-lint cache status +Dir: /home/colin/.cache/golangci-lint +Size: 773.4KiB + + +completion命令 + +completion命令包含4个子命令bash、fish、powershell和zsh,分别用来输出bash、fish、powershell和zsh的自动补全脚本。 + +下面是一个配置bash自动补全的示例: + +$ golangci-lint completion bash > ~/.golangci-lint.bash +$ echo "source '$HOME/.golangci-lint.bash'" >> ~/.bashrc +$ source ~/.bashrc + + +执行完上面的命令,键入如下命令,即可自动补全子命令: + +$ golangci-lint comp + + +上面的命令行会自动补全为golangci-lint completion 。 + +config命令 + +config命令可以打印golangci-lint当前使用的配置文件路径,例如: + +$ golangci-lint config path +.golangci.yaml + + +linters命令 + +linters命令可以打印出golangci-lint所支持的linter,并将这些linter分成两类,分别是配置为启用的linter和配置为禁用的linter,例如: + +$ golangci-lint linters +Enabled by your configuration linters: +... +deadcode: Finds unused code [fast: true, auto-fix: false] +... +Disabled by your configuration linters: +exportloopref: checks for pointers to enclosing loop variables [fast: true, auto-fix: false] +... + + +上面我介绍了golangci-lint提供的命令,接下来,我们再来看下golangci-lint的配置。 + +golangci-lint配置 + +和其他linter相比,golangci-lint一个非常大的优点是使用起来非常灵活,这要得益于它对自定义配置的支持。 + +golangci-lint支持两种配置方式,分别是命令行选项和配置文件。如果bool/string/int的选项同时在命令行选项和配置文件中被指定,命令行的选项就会覆盖配置文件中的选项。如果是slice类型的选项,则命令行和配置中的配置会进行合并。 + +golangci-lint run 支持很多命令行选项,可通过golangci-lint run -h查看,这里选择一些比较重要的选项进行介绍,见下表: + + + +此外,我们还可以通过golangci-lint配置文件进行配置,默认的配置文件名为.golangci.yaml、.golangci.toml、.golangci.json,可以通过-c选项指定配置文件名。通过配置文件,可以实现下面几类功能: + + +golangci-lint本身的一些选项,比如超时、并发,是否检查*_test.go文件等。 +配置需要忽略的文件和文件夹。 +配置启用哪些linter,禁用哪些linter。 +配置输出格式。 +golangci-lint支持很多linter,其中有些linter支持一些配置项,这些配置项可以在配置文件中配置。 +配置符合指定正则规则的文件可以忽略的linter。 +设置错误严重级别,像日志一样,检查错误也是有严重级别的。 + + +更详细的配置内容,你可以参考Configuration。另外,你也可以参考IAM项目的golangci-lint配置.golangci.yaml。.golangci.yaml里面的一些配置,我建议你一定要设置,具体如下: + +run: + skip-dirs: # 设置要忽略的目录 + - util + - .*~ + - api/swagger/docs + skip-files: # 设置不需要检查的go源码文件,支持正则匹配,这里建议包括:_test.go + - ".*\\.my\\.go$" + - _test.go +linters-settings: + errcheck: + check-type-assertions: true # 这里建议设置为true,如果确实不需要检查,可以写成`num, _ := strconv.Atoi(numStr)` + check-blank: false + gci: + # 将以`github.com/marmotedu/iam`开头的包放在第三方包后面 + local-prefixes: github.com/marmotedu/iam + godox: + keywords: # 建议设置为BUG、FIXME、OPTIMIZE、HACK + - BUG + - FIXME + - OPTIMIZE + - HACK + goimports: + # 设置哪些包放在第三方包后面,可以设置多个包,逗号隔开 + local-prefixes: github.com/marmotedu/iam + gomoddirectives: # 设置允许在go.mod中replace的包 + replace-local: true + replace-allow-list: + - github.com/coreos/etcd + - google.golang.org/grpc + - github.com/marmotedu/api + - github.com/marmotedu/component-base + - github.com/marmotedu/marmotedu-sdk-go + gomodguard: # 下面是根据需要选择可以使用的包和版本,建议设置 + allowed: + modules: + - gorm.io/gorm + - gorm.io/driver/mysql + - k8s.io/klog + domains: # List of allowed module domains + - google.golang.org + - gopkg.in + - golang.org + - github.com + - go.uber.org + blocked: + modules: + - github.com/pkg/errors: + recommendations: + - github.com/marmotedu/errors + reason: "`github.com/marmotedu/errors` is the log package used by marmotedu projects." + versions: + - github.com/MakeNowJust/heredoc: + version: "> 2.0.9" + reason: "use the latest version" + local_replace_directives: false + lll: + line-length: 240 # 这里可以设置为240,240一般是够用的 + importas: # 设置包的alias,根据需要设置 + jwt: github.com/appleboy/gin-jwt/v2 + metav1: github.com/marmotedu/component-base/pkg/meta/v1 + + +需要注意的是,golangci-lint不建议使用 enable-all: true 选项,为了尽可能使用最全的linters,我们可以使用以下配置: + +linters: + disable-all: true + enable: # enable下列出 <期望的所有linters> + - typecheck + - ... + + +<期望的所有linters> = - <不期望执行的linters>,我们可以通过执行以下命令来获取: + +$ ./scripts/print_enable_linters.sh + - asciicheck + - bodyclose + - cyclop + - deadcode + - ... + + +将以上输出结果替换掉.golangci.yaml配置文件中的 linters.enable 部分即可。 + +上面我们介绍了与golangci-lint相关的一些基础知识,接下来我就给你详细展示下,如何使用golangci-lint进行静态代码检查。 + +如何使用golangci-lint进行静态代码检查? + +要对代码进行静态检查,只需要执行 golangci-lint run 命令即可。接下来,我会先给你介绍5种常见的golangci-lint使用方法。 + + +对当前目录及子目录下的所有Go文件进行静态代码检查: + + +$ golangci-lint run + + +命令等效于golangci-lint run ./...。 + + +对指定的Go文件或者指定目录下的Go文件进行静态代码检查: + + +$ golangci-lint run dir1 dir2/... dir3/file1.go + + +这里需要你注意:上述命令不会检查dir1下子目录的Go文件,如果想递归地检查一个目录,需要在目录后面追加/...,例如:dir2/...。 + + +根据指定配置文件,进行静态代码检查: + + +$ golangci-lint run -c .golangci.yaml ./... + + + +运行指定的linter: + + +golangci-lint可以在不指定任何配置文件的情况下运行,这会运行默认启用的linter,你可以通过golangci-lint help linters查看它。 + +你可以传入参数-E/--enable来使某个linter可用,也可以使用-D/--disable参数来使某个linter不可用。下面的示例仅仅启用了errcheck linter: + +$ golangci-lint run --no-config --disable-all -E errcheck ./... + + +这里你需要注意,默认情况下,golangci-lint会从当前目录一层层往上寻找配置文件名.golangci.yaml、.golangci.toml、.golangci.json直到根(/)目录。如果找到,就以找到的配置文件作为本次运行的配置文件,所以为了防止读取到未知的配置文件,可以用 --no-config 参数使golangci-lint不读取任何配置文件。 + + +禁止运行指定的liner: + + +如果我们想禁用某些linter,可以使用-D选项。 + +$ golangci-lint run --no-config -D godot,errcheck + + +在使用golangci-lint进行代码检查时,可能会有很多误报。所谓的误报,其实是我们希望golangci-lint的一些linter能够容忍某些issue。那么如何尽可能减少误报呢?golangci-lint也提供了一些途径,我建议你使用下面这三种: + + +在命令行中添加-e参数,或者在配置文件的issues.exclude部分设置要排除的检查错误。你也可以使用issues.exclude-rules来配置哪些文件忽略哪些linter。 +通过run.skip-dirs、run.skip-files或者issues.exclude-rules配置项,来忽略指定目录下的所有Go文件,或者指定的Go文件。 +通过在Go源码文件中添加//nolint注释,来忽略指定的代码行。 + + +因为golangci-lint设置了很多linters,对于一个大型项目,启用所有的linter会检查出很多问题,并且每个项目对linter检查的粒度要求也不一样,所以glangci-lint使用nolint标记来开关某些检查项,不同位置的nolint标记效果也会不一样。接下来我想向你介绍nolint的几种用法。 + + +忽略某一行所有linter的检查 + + +var bad_name int //nolint + + + +忽略某一行指定linter的检查,可以指定多个linter,用逗号 , 隔开。 + + +var bad_name int //nolint:golint,unused + + + +忽略某个代码块的检查。 + + +//nolint +func allIssuesInThisFunctionAreExcluded() *string { + // ... +} + +//nolint:govet +var ( + a int + b int +) + + + +忽略某个文件的指定linter检查。 + + +在package xx 上面一行添加//nolint注释。 + +//nolint:unparam +package pkg +... + + +在使用nolint的过程中,有3个地方需要你注意。 + +首先,如果启用了nolintlint,你就需要在//nolint后面添加nolint的原因// xxxx。 + +其次,你使用的应该是//nolint而不是// nolint。因为根据Go的规范,需要程序读取的注释//后面不应该有空格。 + +最后,如果要忽略所有linter,可以用//nolint;如果要忽略某个指定的linter,可以用//nolint:,。 + +golangci-lint使用技巧 + +我在使用golangci-lint时,总结了一些经验技巧,放在这里供你参考,希望能够帮助你更好地使用golangci-lint。 + +技巧1:第一次修改,可以按目录修改。 + +如果你第一次使用golangci-lint检查你的代码,一定会有很多错误。为了减轻修改的压力,可以按目录检查代码并修改。这样可以有效减少失败条数,减轻修改压力。 + +当然,如果错误太多,一时半会儿改不完,想以后慢慢修改或者干脆不修复存量的issues,那么你可以使用golangci-lint的 --new-from-rev 选项,只检查新增的code,例如: + +$ golangci-lint run --new-from-rev=HEAD~1 + + +技巧2:按文件修改,减少文件切换次数,提高修改效率。 + +如果有很多检查错误,涉及很多文件,建议先修改一个文件,这样就不用来回切换文件。可以通过grep过滤出某个文件的检查失败项,例如: + +$ golangci-lint run ./...|grep pkg/storage/redis_cluster.go +pkg/storage/redis_cluster.go:16:2: "github.com/go-redis/redis/v7" imported but not used (typecheck) +pkg/storage/redis_cluster.go:82:28: undeclared name: `redis` (typecheck) +pkg/storage/redis_cluster.go:86:14: undeclared name: `redis` (typecheck) +... + + +技巧3:把linters-setting.lll.line-length设置得大一些。 + +在Go项目开发中,为了易于阅读代码,通常会将变量名/函数/常量等命名得有意义,这样很可能导致每行的代码长度过长,很容易超过lll linter设置的默认最大长度80。这里建议将linters-setting.lll.line-length设置为120/240。 + +技巧4:尽可能多地使用golangci-lint提供的linter。 + +golangci-lint集成了很多linters,可以通过如下命令查看: + +$ golangci-lint linters +Enabled by your configuration linters: +deadcode: Finds unused code [fast: true, auto-fix: false] +... +varcheck: Finds unused global variables and constants [fast: true, auto-fix: false] + +Disabled by your configuration linters: +asciicheck: Simple linter to check that your code does not contain non-ASCII identifiers [fast: true, auto-fix: false] +... +wsl: Whitespace Linter - Forces you to use empty lines! [fast: true, auto-fix: false] + + +这些linter分为两类,一类是默认启用的,另一类是默认禁用的。每个linter都有两个属性: + + +fast:true/false,如果为true,说明该linter可以缓存类型信息,支持快速检查。因为第一次缓存了这些信息,所以后续的运行会非常快。 +auto-fix:true/false,如果为true说明该linter支持自动修复发现的错误;如果为false说明不支持自动修复。 + + +如果配置了golangci-lint配置文件,则可以通过命令golangci-lint help linters查看在当前配置下启用和禁用了哪些linter。golangci-lint也支持自定义linter插件,具体你可以参考:New linters。 + +在使用golangci-lint的时候,我们要尽可能多的使用linter。使用的linter越多,说明检查越严格,意味着代码越规范,质量越高。如果时间和精力允许,建议打开golangci-lint提供的所有linter。 + +技巧5:每次修改代码后,都要执行golangci-lint。 + +每次修改完代码后都要执行golangci-lint,一方面可以及时修改不规范的地方,另一方面可以减少错误堆积,减轻后面的修改压力。 + +技巧6:建议在根目录下放一个通用的golangci-lint配置文件。 + +在/目录下存放通用的golangci-lint配置文件,可以让你不用为每一个项目都配置golangci-lint。当你需要为某个项目单独配置golangci-lint时,只需在该项目根目录下增加一个项目级别的golangci-lint配置文件即可。 + +总结 + +Go项目开发中,对代码进行静态代码检查是必要的操作。当前有很多优秀的静态代码检查工具,但golangci-lint因为具有检查速度快、可配置、少误报、内置了大量linter等优点,成为了目前最受欢迎的静态代码检查工具。 + +golangci-lint功能非常强大,支持诸如run、cache、completion、linters等命令。其中最常用的是run命令,run命令可以通过以下方式来进行静态代码检查: + +$ golangci-lint run # 对当前目录及子目录下的所有Go文件进行静态代码检查 +$ golangci-lint run dir1 dir2/... dir3/file1.go # 对指定的Go文件或者指定目录下的Go文件进行静态代码检查 +$ golangci-lint run -c .golangci.yaml ./... # 根据指定配置文件,进行静态代码检查 +$ golangci-lint run --no-config --disable-all -E errcheck ./... # 运行指定的 errcheck linter +$ golangci-lint run --no-config -D godot,errcheck # 禁止运行指定的godot,errcheck liner + + +此外,golangci-lint还支持 //nolint 、//nolint:golint,unused 等方式来减少误报。 + +最后,我分享了一些自己使用golangci-lint时总结的经验。例如:第一次修改,可以按目录修改;按文件修改,减少文件切换次数,提高修改效率;尽可能多地使用golangci-lint提供的linter。希望这些建议对你使用golangci-lint有一定帮助。 + +课后练习 + + +执行golangci-lint linters命令,查看golangci-lint支持哪些linter,以及这些linter的作用。 +思考下,如何在golangci-lint中集成自定义的linter。 + + +如果遇到任何疑问,欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/17API文档:如何生成SwaggerAPI文档?.md b/专栏/Go语言项目开发实战/17API文档:如何生成SwaggerAPI文档?.md new file mode 100644 index 0000000..d263a27 --- /dev/null +++ b/专栏/Go语言项目开发实战/17API文档:如何生成SwaggerAPI文档?.md @@ -0,0 +1,469 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 API 文档:如何生成 Swagger API 文档 ? + 你好,我是孔令飞。 + +作为一名开发者,我们通常讨厌编写文档,因为这是一件重复和缺乏乐趣的事情。但是在开发过程中,又有一些文档是我们必须要编写的,比如API文档。 + +一个企业级的Go后端项目,通常也会有个配套的前端。为了加快研发进度,通常是后端和前端并行开发,这就需要后端开发者在开发后端代码之前,先设计好API接口,提供给前端。所以在设计阶段,我们就需要生成API接口文档。 + +一个好的API文档,可以减少用户上手的复杂度,也意味着更容易留住用户。好的API文档也可以减少沟通成本,帮助开发者更好地理解API的调用方式,从而节省时间,提高开发效率。这时候,我们一定希望有一个工具能够帮我们自动生成API文档,解放我们的双手。Swagger就是这么一个工具,可以帮助我们生成易于共享且具有足够描述性的API文档。 + +接下来,我们就来看下,如何使用Swagger生成API文档。 + +Swagger介绍 + +Swagger是一套围绕OpenAPI规范构建的开源工具,可以设计、构建、编写和使用REST API。Swagger包含很多工具,其中主要的Swagger工具包括: + + +Swagger编辑器:基于浏览器的编辑器,可以在其中编写OpenAPI规范,并实时预览API文档。https://editor.swagger.io 就是一个Swagger编辑器,你可以尝试在其中编辑和预览API文档。 +Swagger UI:将OpenAPI 规范呈现为交互式API文档,并可以在浏览器中尝试API调用。 +Swagger Codegen:根据OpenAPI规范,生成服务器存根和客户端代码库,目前已涵盖了40多种语言。 + + +Swagger和OpenAPI的区别 + +我们在谈到Swagger时,也经常会谈到OpenAPI。那么二者有什么区别呢? + +OpenAPI是一个API规范,它的前身叫Swagger规范,通过定义一种用来描述API格式或API定义的语言,来规范RESTful服务开发过程,目前最新的OpenAPI规范是OpenAPI 3.0(也就是Swagger 2.0规范)。 + +OpenAPI规范规定了一个API必须包含的基本信息,这些信息包括: + + +对API的描述,介绍API可以实现的功能。 +每个API上可用的路径(/users)和操作(GET /users,POST /users)。 +每个API的输入/返回的参数。 +验证方法。 +联系信息、许可证、使用条款和其他信息。 + + +所以,你可以简单地这么理解:OpenAPI是一个API规范,Swagger则是实现规范的工具。 + +另外,要编写Swagger文档,首先要会使用Swagger文档编写语法,因为语法比较多,这里就不多介绍了,你可以参考Swagger官方提供的OpenAPI Specification来学习。 + +用go-swagger来生成Swagger API文档 + +在Go项目开发中,我们可以通过下面两种方法来生成Swagger API文档: + +第一,如果你熟悉Swagger语法的话,可以直接编写JSON/YAML格式的Swagger文档。建议选择YAML格式,因为它比JSON格式更简洁直观。 + +第二,通过工具生成Swagger文档,目前可以通过swag和go-swagger两个工具来生成。 + +对比这两种方法,直接编写Swagger文档,不比编写Markdown格式的API文档工作量小,我觉得不符合程序员“偷懒”的习惯。所以,本专栏我们就使用go-swagger工具,基于代码注释来自动生成Swagger文档。为什么选go-swagger呢?有这么几个原因: + + +go-swagger比swag功能更强大:go-swagger提供了更灵活、更多的功能来描述我们的API。 +使我们的代码更易读:如果使用swag,我们每一个API都需要有一个冗长的注释,有时候代码注释比代码还要长,但是通过go-swagger我们可以将代码和注释分开编写,一方面可以使我们的代码保持简洁,清晰易读,另一方面我们可以在另外一个包中,统一管理这些Swagger API文档定义。 +更好的社区支持:go-swagger目前有非常多的Github star数,出现Bug的概率很小,并且处在一个频繁更新的活跃状态。 + + +你已经知道了,go-swagger是一个功能强大的、高性能的、可以根据代码注释生成Swagger API文档的工具。除此之外,go-swagger还有很多其他特性: + + +根据Swagger定义文件生成服务端代码。 +根据Swagger定义文件生成客户端代码。 +校验Swagger定义文件是否正确。 +启动一个HTTP服务器,使我们可以通过浏览器访问API文档。 +根据Swagger文档定义的参数生成Go model结构体定义。 + + +可以看到,使用go-swagger生成Swagger文档,可以帮助我们减少编写文档的时间,提高开发效率,并能保证文档的及时性和准确性。 + +这里需要注意,如果我们要对外提供API的Go SDK,可以考虑使用go-swagger来生成客户端代码。但是我觉得go-swagger生成的服务端代码不够优雅,所以建议你自行编写服务端代码。 + +目前,有很多知名公司和组织的项目都使用了go-swagger,例如 Moby、CoreOS、Kubernetes、Cilium等。 + +安装Swagger工具 + +go-swagger通过swagger命令行工具来完成其功能,swagger安装方法如下: + +$ go get -u github.com/go-swagger/go-swagger/cmd/swagger +$ swagger version +dev + + +swagger命令行工具介绍 + +swagger命令格式为swagger [OPTIONS] 。可以通过swagger -h查看swagger使用帮助。swagger提供的子命令及功能见下表: + + + +如何使用swagger命令生成Swagger文档? + +go-swagger通过解析源码中的注释来生成Swagger文档,go-swagger的详细注释语法可参考官方文档。常用的有如下几类注释语法: + + + +解析注释生成Swagger文档 + +swagger generate命令会找到main函数,然后遍历所有源码文件,解析源码中与Swagger相关的注释,然后自动生成swagger.json/swagger.yaml文件。 + +这一过程的示例代码为gopractise-demo/swagger。目录下有一个main.go文件,定义了如下API接口: + +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/marmotedu/gopractise-demo/swagger/api" + // This line is necessary for go-swagger to find your docs! + _ "github.com/marmotedu/gopractise-demo/swagger/docs" +) + +var users []*api.User + +func main() { + r := gin.Default() + r.POST("/users", Create) + r.GET("/users/:name", Get) + + log.Fatal(r.Run(":5555")) +} + +// Create create a user in memory. +func Create(c *gin.Context) { + var user api.User + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error(), "code": 10001}) + return + } + + for _, u := range users { + if u.Name == user.Name { + c.JSON(http.StatusBadRequest, gin.H{"message": fmt.Sprintf("user %s already exist", user.Name), "code": 10001}) + return + } + } + + users = append(users, &user) + c.JSON(http.StatusOK, user) +} + +// Get return the detail information for a user. +func Get(c *gin.Context) { + username := c.Param("name") + for _, u := range users { + if u.Name == username { + c.JSON(http.StatusOK, u) + return + } + } + + c.JSON(http.StatusBadRequest, gin.H{"message": fmt.Sprintf("user %s not exist", username), "code": 10002}) +} + + +main包中引入的User struct位于gopractise-demo/swagger/api目录下的user.go文件: + +// Package api defines the user model. +package api + +// User represents body of User request and response. +type User struct { + // User's name. + // Required: true + Name string `json:"name"` + + // User's nickname. + // Required: true + Nickname string `json:"nickname"` + + // User's address. + Address string `json:"address"` + + // User's email. + Email string `json:"email"` +} + + +// Required: true说明字段是必须的,生成Swagger文档时,也会在文档中声明该字段是必须字段。 + +为了使代码保持简洁,我们在另外一个Go包中编写带go-swagger注释的API文档。假设该Go包名字为docs,在开始编写Go API注释之前,需要在main.go文件中导入docs包: + +_ "github.com/marmotedu/gopractise-demo/swagger/docs" + + +通过导入docs包,可以使go-swagger在递归解析main包的依赖包时,找到docs包,并解析包中的注释。 + +在gopractise-demo/swagger目录下,创建docs文件夹: + +$ mkdir docs +$ cd docs + + +在docs目录下,创建一个doc.go文件,在该文件中提供API接口的基本信息: + +// Package docs awesome. +// +// Documentation of our awesome API. +// +// Schemes: http, https +// BasePath: / +// Version: 0.1.0 +// Host: some-url.com +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Security: +// - basic +// +// SecurityDefinitions: +// basic: +// type: basic +// +// swagger:meta +package docs + + +Package docs后面的字符串 awesome 代表我们的HTTP服务名。Documentation of our awesome API是我们API的描述。其他都是go-swagger可识别的注释,代表一定的意义。最后以swagger:meta注释结束。 + +编写完doc.go文件后,进入gopractise-demo/swagger目录,执行如下命令,生成Swagger API文档,并启动HTTP服务,在浏览器查看Swagger: + +$ swagger generate spec -o swagger.yaml +$ swagger serve --no-open -F=swagger --port 36666 swagger.yaml + +2020/10/20 23:16:47 serving docs at http://localhost:36666/docs + + + +-o:指定要输出的文件名。swagger会根据文件名后缀.yaml或者.json,决定生成的文件格式为YAML或JSON。 +–no-open:因为是在Linux服务器下执行命令,没有安装浏览器,所以使–no-open禁止调用浏览器打开URL。 +-F:指定文档的风格,可选swagger和redoc。我选用了redoc,因为觉得redoc格式更加易读和清晰。 +–port:指定启动的HTTP服务监听端口。 + + +打开浏览器,访问http://localhost:36666/docs ,如下图所示: + + + +如果我们想要JSON格式的Swagger文档,可执行如下命令,将生成的swagger.yaml转换为swagger.json: + +$ swagger generate spec -i ./swagger.yaml -o ./swagger.json + + +接下来,我们就可以编写API接口的定义文件(位于gopractise-demo/swagger/docs/user.go文件中): + +package docs + +import ( + "github.com/marmotedu/gopractise-demo/swagger/api" +) + +// swagger:route POST /users user createUserRequest +// Create a user in memory. +// responses: +// 200: createUserResponse +// default: errResponse + +// swagger:route GET /users/{name} user getUserRequest +// Get a user from memory. +// responses: +// 200: getUserResponse +// default: errResponse + +// swagger:parameters createUserRequest +type userParamsWrapper struct { + // This text will appear as description of your request body. + // in:body + Body api.User +} + +// This text will appear as description of your request url path. +// swagger:parameters getUserRequest +type getUserParamsWrapper struct { + // in:path + Name string `json:"name"` +} + +// This text will appear as description of your response body. +// swagger:response createUserResponse +type createUserResponseWrapper struct { + // in:body + Body api.User +} + +// This text will appear as description of your response body. +// swagger:response getUserResponse +type getUserResponseWrapper struct { + // in:body + Body api.User +} + +// This text will appear as description of your error response body. +// swagger:response errResponse +type errResponseWrapper struct { + // Error code. + Code int `json:"code"` + + // Error message. + Message string `json:"message"` +} + + +user.go文件说明: + + +swagger:route:swagger:route代表API接口描述的开始,后面的字符串格式为HTTP方法 URL Tag ID。可以填写多个tag,相同tag的API接口在Swagger文档中会被分为一组。ID是一个标识符,swagger:parameters是具有相同ID的swagger:route的请求参数。swagger:route下面的一行是该API接口的描述,需要以英文点号为结尾。responses:定义了API接口的返回参数,例如当HTTP状态码是200时,返回createUserResponse,createUserResponse会跟swagger:response进行匹配,匹配成功的swagger:response就是该API接口返回200状态码时的返回。 +swagger:response:swagger:response定义了API接口的返回,例如getUserResponseWrapper,关于名字,我们可以根据需要自由命名,并不会带来任何不同。getUserResponseWrapper中有一个Body字段,其注释为// in:body,说明该参数是在HTTP Body中返回。swagger:response之上的注释会被解析为返回参数的描述。api.User自动被go-swagger解析为Example Value和Model。我们不用再去编写重复的返回字段,只需要引用已有的Go结构体即可,这也是通过工具生成Swagger文档的魅力所在。 +swagger:parameters:swagger:parameters定义了API接口的请求参数,例如userParamsWrapper。userParamsWrapper之上的注释会被解析为请求参数的描述,// in:body代表该参数是位于HTTP Body中。同样,userParamsWrapper结构体名我们也可以随意命名,不会带来任何不同。swagger:parameters之后的createUserRequest会跟swagger:route的ID进行匹配,匹配成功则说明是该ID所在API接口的请求参数。 + + +进入gopractise-demo/swagger目录,执行如下命令,生成Swagger API文档,并启动HTTP服务,在浏览器查看Swagger: + +$ swagger generate spec -o swagger.yaml +$ swagger serve --no-open -F=swagger --port 36666 swagger.yaml +2020/10/20 23:28:30 serving docs at http://localhost:36666/docs + + +打开浏览器,访问 http://localhost:36666/docs ,如下图所示: + + + +上面我们生成了swagger风格的UI界面,我们也可以使用redoc风格的UI界面,如下图所示: + + + +go-swagger其他常用功能介绍 + +上面,我介绍了swagger最常用的generate、serve命令,关于swagger其他有用的命令,这里也简单介绍一下。 + + +对比Swagger文档 + + +$ swagger diff -d change.log swagger.new.yaml swagger.old.yaml +$ cat change.log + +BREAKING CHANGES: +================= +/users:post Request - Body.Body.nickname.address.email.name.Body : User - Deleted property +compatibility test FAILED: 1 breaking changes detected + + + +生成服务端代码 + + +我们也可以先定义Swagger接口文档,再用swagger命令,基于Swagger接口文档生成服务端代码。假设我们的应用名为go-user,进入gopractise-demo/swagger目录,创建go-user目录,并生成服务端代码: + +$ mkdir go-user +$ cd go-user +$ swagger generate server -f ../swagger.yaml -A go-user + + +上述命令会在当前目录生成cmd、restapi、models文件夹,可执行如下命令查看server组件启动方式: + +$ go run cmd/go-user-server/main.go -h + + + +生成客户端代码 + + +在go-user目录下执行如下命令: + +$ swagger generate client -f ../swagger.yaml -A go-user + + +上述命令会在当前目录生成client,包含了API接口的调用函数,也就是API接口的Go SDK。 + + +验证Swagger文档是否合法 + + +$ swagger validate swagger.yaml +2020/10/21 09:53:18 +The swagger spec at "swagger.yaml" is valid against swagger specification 2.0 + + + +合并Swagger文档 + + +$ swagger mixin swagger_part1.yaml swagger_part2.yaml + + +IAM Swagger文档 + +IAM的Swagger文档定义在iam/api/swagger/docs目录下,遵循go-swagger规范进行定义。 + +iam/api/swagger/docs/doc.go文件定义了更多Swagger文档的基本信息,比如开源协议、联系方式、安全认证等。 + +更详细的定义,你可以直接查看iam/api/swagger/docs目录下的Go源码文件。 + +为了便于生成文档和启动HTTP服务查看Swagger文档,该操作被放在Makefile中执行(位于iam/scripts/make-rules/swagger.mk文件中): + +.PHONY: swagger.run +swagger.run: tools.verify.swagger + @echo "===========> Generating swagger API docs" + @swagger generate spec --scan-models -w $(ROOT_DIR)/cmd/genswaggertypedocs -o $(ROOT_DIR)/api/swagger/swagger.yaml + +.PHONY: swagger.serve +swagger.serve: tools.verify.swagger + @swagger serve -F=redoc --no-open --port 36666 $(ROOT_DIR)/api/swagger/swagger.yaml + + +Makefile文件说明: + + +tools.verify.swagger:检查Linux系统是否安装了go-swagger的命令行工具swagger,如果没有安装则运行go get安装。 +swagger.run:运行 swagger generate spec 命令生成Swagger文档swagger.yaml,运行前会检查swagger是否安装。 --scan-models 指定生成的文档中包含带有swagger:model 注释的Go Models。-w 指定swagger命令运行的目录。 +swagger.serve:运行 swagger serve 命令打开Swagger文档swagger.yaml,运行前会检查swagger是否安装。 + + +在iam源码根目录下执行如下命令,即可生成并启动HTTP服务查看Swagger文档: + +$ make swagger +$ make serve-swagger +2020/10/21 06:45:03 serving docs at http://localhost:36666/docs + + +打开浏览器,打开http://x.x.x.x:36666/docs查看Swagger文档,x.x.x.x是服务器的IP地址,如下图所示: + + + +IAM的Swagger文档,还可以通过在iam源码根目录下执行go generate ./...命令生成,为此,我们需要在iam/cmd/genswaggertypedocs/swagger_type_docs.go文件中,添加//go:generate注释。如下图所示: + + + +总结 + +在做Go服务开发时,我们要向前端或用户提供API文档,手动编写API文档工作量大,也难以维护。所以,现在很多项目都是自动生成Swagger格式的API文档。提到Swagger,很多开发者不清楚其和OpenAPI的区别,所以我也给你总结了:OpenAPI是一个API规范,Swagger则是实现规范的工具。 + +在Go中,用得最多的是利用go-swagger来生成Swagger格式的API文档。go-swagger包含了很多语法,我们可以访问Swagger 2.0进行学习。学习完Swagger 2.0的语法之后,就可以编写swagger注释了,之后可以通过 + +$ swagger generate spec -o swagger.yaml + + +来生成swagger文档 swagger.yaml。通过 + +$ swagger serve --no-open -F=swagger --port 36666 swagger.yaml + + +来提供一个前端界面,供我们访问swagger文档。 + +为了方便管理,我们可以将 swagger generate spec 和 swagger serve 命令加入到Makefile文件中,通过Makefile来生成Swagger文档,并提供给前端界面。 + +课后练习 + + +尝试将你当前项目的一个API接口,用go-swagger生成swagger格式的API文档,如果中间遇到问题,欢迎在留言区与我讨论。 +思考下,为什么IAM项目的swagger定义文档会放在iam/api/swagger/docs目录下,这样做有什么好处? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/18错误处理(上):如何设计一套科学的错误码?.md b/专栏/Go语言项目开发实战/18错误处理(上):如何设计一套科学的错误码?.md new file mode 100644 index 0000000..57839a6 --- /dev/null +++ b/专栏/Go语言项目开发实战/18错误处理(上):如何设计一套科学的错误码?.md @@ -0,0 +1,248 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 错误处理(上):如何设计一套科学的错误码? + 你好,我是孔令飞。今天我们来聊聊如何设计业务的错误码。 + +现代的软件架构,很多都是对外暴露RESTful API接口,内部系统通信采用RPC协议。因为RESTful API接口有一些天生的优势,比如规范、调试友好、易懂,所以通常作为直接面向用户的通信规范。 + +既然是直接面向用户,那么首先就要求消息返回格式是规范的;其次,如果接口报错,还要能给用户提供一些有用的报错信息,通常需要包含Code码(用来唯一定位一次错误)和Message(用来展示出错的信息)。这就需要我们设计一套规范的、科学的错误码。 + +这一讲,我就来详细介绍下,如何设计一套规范的、科学的错误码。下一讲,我还会介绍如何提供一个errors包来支持我们设计的错误码。 + +期望错误码实现的功能 + +要想设计一套错误码,首先就得弄清我们的需求。 + +RESTful API是基于HTTP协议的一系列API开发规范,HTTP请求结束后,无论API请求成功或失败,都需要让客户端感知到,以便客户端决定下一步该如何处理。 + +为了让用户拥有最好的体验,需要有一个比较好的错误码实现方式。这里我介绍下在设计错误码时,期望能够实现的功能。 + +第一个功能是有业务Code码标识。 + +因为HTTP Code码有限,并且都是跟HTTP Transport层相关的Code码,所以我们希望能有自己的错误Code码。一方面,可以根据需要自行扩展,另一方面也能够精准地定位到具体是哪个错误。同时,因为Code码通常是对计算机友好的10进制整数,基于Code码,计算机也可以很方便地进行一些分支处理。当然了,业务码也要有一定规则,可以通过业务码迅速定位出是哪类错误。 + +第二个功能,考虑到安全,希望能够对外对内分别展示不同的错误信息。 + +当开发一个对外的系统,业务出错时,需要一些机制告诉用户出了什么错误,如果能够提供一些帮助文档会更好。但是,我们不可能把所有的错误都暴露给外部用户,这不仅没必要,也不安全。所以也需要能让我们获取到更详细的内部错误信息的机制,这些内部错误信息可能包含一些敏感的数据,不宜对外展示,但可以协助我们进行问题定位。 + +所以,我们需要设计的错误码应该是规范的,能方便客户端感知到HTTP是否请求成功,并带有业务码和出错信息。 + +常见的错误码设计方式 + +在业务中,大致有三种错误码实现方式。我用一次因为用户账号没有找到而请求失败的例子,分别给你解释一下: + +第一种方式,不论请求成功或失败,始终返回200 http status code,在HTTP Body中包含用户账号没有找到的错误信息。 + +例如Facebook API的错误Code设计,始终返回200 http status code: + +{ + "error": { + "message": "Syntax error \"Field picture specified more than once. This is only possible before version 2.1\" at character 23: id,name,picture,picture", + "type": "OAuthException", + "code": 2500, + "fbtrace_id": "xxxxxxxxxxx" + } +} + + +采用固定返回200 http status code的方式,有其合理性。比如,HTTP Code通常代表HTTP Transport层的状态信息。当我们收到HTTP请求,并返回时,HTTP Transport层是成功的,所以从这个层面上来看,HTTP Status固定为200也是合理的。 + +但是这个方式的缺点也很明显:对于每一次请求,我们都要去解析HTTP Body,从中解析出错误码和错误信息。实际上,大部分情况下,我们对于成功的请求,要么直接转发,要么直接解析到某个结构体中;对于失败的请求,我们也希望能够更直接地感知到请求失败。这种方式对性能会有一定的影响,对客户端不友好。所以我不建议你使用这种方式。 + +第二种方式,返回http 404 Not Found错误码,并在Body中返回简单的错误信息。 + +例如:Twitter API的错误设计,会根据错误类型,返回合适的HTTP Code,并在Body中返回错误信息和自定义业务Code。 + +HTTP/1.1 400 Bad Request +x-connection-hash: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +set-cookie: guest_id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +Date: Thu, 01 Jun 2017 03:04:23 GMT +Content-Length: 62 +x-response-time: 5 +strict-transport-security: max-age=631138519 +Connection: keep-alive +Content-Type: application/json; charset=utf-8 +Server: tsa_b + +{"errors":[{"code":215,"message":"Bad Authentication data."}]} + + +这种方式比第一种要好一些,通过http status code可以使客户端非常直接地感知到请求失败,并且提供给客户端一些错误信息供参考。但是仅仅靠这些信息,还不能准确地定位和解决问题。 + +第三种方式,返回http 404 Not Found错误码,并在Body中返回详细的错误信息。 + +例如:微软Bing API的错误设计,会根据错误类型,返回合适的HTTP Code,并在Body中返回详尽的错误信息。 + +HTTP/1.1 400 +Date: Thu, 01 Jun 2017 03:40:55 GMT +Content-Length: 276 +Connection: keep-alive +Content-Type: application/json; charset=utf-8 +Server: Microsoft-IIS/10.0 +X-Content-Type-Options: nosniff + +{"SearchResponse":{"Version":"2.2","Query":{"SearchTerms":"api error codes"},"Errors":[{"Code":1001,"Message":"Required parameter is missing.","Parameter":"SearchRequest.AppId","HelpUrl":"http\u003a\u002f\u002fmsdn.microsoft.com\u002fen-us\u002flibrary\u002fdd251042.aspx"}]}} + + +这是我比较推荐的一种方式,既能通过http status code使客户端方便地知道请求出错,又可以使用户根据返回的信息知道哪里出错,以及如何解决问题。同时,返回了机器友好的业务Code码,可以在有需要时让程序进一步判断处理。 + +错误码设计建议 + +综合刚才讲到的,我们可以总结出一套优秀的错误码设计思路: + + +有区别于http status code的业务码,业务码需要有一定规则,可以通过业务码判断出是哪类错误。 +请求出错时,可以通过http status code直接感知到请求出错。 +需要在请求出错时,返回详细的信息,通常包括3类信息:业务Code码、错误信息和参考文档(可选)。 +返回的错误信息,需要是可以直接展示给用户的安全信息,也就是说不能包含敏感信息;同时也要有内部更详细的错误信息,方便debug。 +返回的数据格式应该是固定的、规范的。 +错误信息要保持简洁,并且提供有用的信息。 + + +这里其实还有两个功能点需要我们实现:业务Code码设计,以及请求出错时,如何设置http status code。 + +接下来,我会详细介绍下如何实现这两个功能点。 + +业务Code码设计 + +要解决业务Code码如何设计这个问题,我们先来看下为什么要引入业务Code码。 + +在实际开发中,引入业务Code码有下面几个好处: + + +可以非常方便地定位问题和定位代码行(看到错误码知道什么意思、grep错误码可以定位到错误码所在行、某个错误类型的唯一标识)。 +错误码包含一定的信息,通过错误码可以判断出错误级别、错误模块和具体错误信息。 +Go中的HTTP服务器开发都是引用net/http包,该包中只有60个错误码,基本都是跟HTTP请求相关的错误码,在一个大型系统中,这些错误码完全不够用,而且这些错误码跟业务没有任何关联,满足不了业务的需求。引入业务的Code码,则可以解决这些问题。 +业务开发过程中,可能需要判断错误是哪种类型,以便做相应的逻辑处理,通过定制的错误可以很容易做到这点,例如: + + +if err == code.ErrBind { + ... +} + + +这里要注意,业务Code码可以是一个整数,也可以是一个整型字符串,还可以是一个字符型字符串,它是错误的唯一标识。 + +通过研究腾讯云、阿里云、新浪的开放API,我发现新浪的API Code码设计更合理些。所以,我参考新浪的Code码设计,总结出了我推荐的Code码设计规范:纯数字表示,不同部位代表不同的服务,不同的模块。 + +错误代码说明:100101 + + +10: 服务。 +01: 某个服务下的某个模块。 +01: 模块下的错误码序号,每个模块可以注册100个错误。 + + +通过100101可以知道这个错误是服务 A,数据库模块下的记录没有找到错误。 + +你可能会问:按这种设计,每个模块下最多能注册100个错误,是不是有点少?其实在我看来,如果每个模块的错误码超过100个,要么说明这个模块太大了,建议拆分;要么说明错误码设计得不合理,共享性差,需要重新设计。 + +如何设置HTTP Status Code + +Go net/http包提供了60个错误码,大致分为如下5类: + + +1XX - (指示信息)表示请求已接收,继续处理。 +2XX - (请求成功)表示成功处理了请求的状态代码。 +3XX - (请求被重定向)表示要完成请求,需要进一步操作。通常,这些状态代码用来重定向。 +4XX - (请求错误)这些状态代码表示请求可能出错,妨碍了服务器的处理,通常是客户端出错,需要客户端做进一步的处理。 +5XX - (服务器错误)这些状态代码表示服务器在尝试处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是客户端的问题。 + + +可以看到HTTP Code有很多种,如果每个Code都做错误映射,会面临很多问题。比如,研发同学不太好判断错误属于哪种http status code,到最后很可能会导致错误或者http status code不匹配,变成一种形式。而且,客户端也难以应对这么多的HTTP错误码。 + +所以,这里建议http status code不要太多,基本上只需要这3个HTTP Code: + + +200 - 表示请求成功执行。 +400 - 表示客户端出问题。 +500 - 表示服务端出问题。 + + +如果觉得这3个错误码不够用,最多可以加如下3个错误码: + + +401 - 表示认证失败。 +403 - 表示授权失败。 +404 - 表示资源找不到,这里的资源可以是URL或者RESTful资源。 + + +将错误码控制在适当的数目内,客户端比较容易处理和判断,开发也比较容易进行错误码映射。 + +IAM项目错误码设计规范 + +接下来,我们来看下IAM项目的错误码是如何设计的。 + +Code 设计规范 + +先来看下IAM项目业务的Code码设计规范,具体实现可参考internal/pkg/code目录。IAM项目的错误码设计规范符合上面介绍的错误码设计思路和规范,具体规范见下。 + +Code 代码从 100001 开始,1000 以下为 github.com/marmotedu/errors 保留 code。 + +错误代码说明:100001 + + + +服务和模块说明 + +- +通用:说明所有服务都适用的错误,提高复用性,避免重复造轮子。 + +错误信息规范说明 + + +对外暴露的错误,统一大写开头,结尾不要加.。 +对外暴露的错误要简洁,并能准确说明问题。 +对外暴露的错误说明,应该是 该怎么做 而不是 哪里错了。 + + +这里你需要注意,错误信息是直接暴露给用户的,不能包含敏感信息。 + +IAM API接口返回值说明 + +如果返回结果中存在 code 字段,则表示调用 API 接口失败。例如: + +{ + "code": 100101, + "message": "Database error", + "reference": "https://github.com/marmotedu/iam/tree/master/docs/guide/zh-CN/faq/iam-apiserver" +} + + +上述返回中 code 表示错误码,message 表示该错误的具体信息。每个错误同时也对应一个 HTTP 状态码。比如上述错误码对应了 HTTP 状态码 500(Internal Server Error)。另外,在出错时,也返回了reference字段,该字段包含了可以解决这个错误的文档链接地址。 + +关于IAM 系统支持的错误码,我给你列了一个表格,你可以看看: + + + +总结 + +对外暴露的API接口需要有一套规范的、科学的错误码。目前业界的错误码大概有3种设计方式,我用一次因为用户账号没有找到而请求失败的例子,给你做了解释: + + +不论请求成功失败,始终返回200 http status code,在HTTP Body中包含用户账号没有找到的错误信息。 +返回http 404 Not Found错误码,并在Body中返回简单的错误信息。 +返回http 404 Not Found错误码,并在Body中返回详细的错误信息。 + + +这一讲,我参考这3个错误码设计,给出了自己的错误码设计建议:错误码包含HTTP Code和业务Code,并且业务Code会映射为一个HTTP Code。错误码也会对外暴露两种错误信息,一种是直接暴露给用户的,不包含敏感信息的信息;另一种是供内部开发查看,定位问题的错误信息。该错误码还支持返回参考文档,用于在出错时展示给用户,供用户查看解决问题。 + +建议你重点关注我总结的Code码设计规范:纯数字表示,不同部位代表不同的服务,不同的模块。 + +比如错误代码100101,其中10代表服务;中间的01代表某个服务下的某个模块;最后的01代表模块下的错误码序号,每个模块可以注册100个错误。 + +课后练习 + + +既然错误码是符合规范的,请思考下:有没有一种Low Code的方式,根据错误码规范,自动生成错误码文档呢? +思考下你还遇到过哪些更科学的错误码设计。如果有,也欢迎在留言区分享交流。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/19错误处理(下):如何设计错误包?.md b/专栏/Go语言项目开发实战/19错误处理(下):如何设计错误包?.md new file mode 100644 index 0000000..2044561 --- /dev/null +++ b/专栏/Go语言项目开发实战/19错误处理(下):如何设计错误包?.md @@ -0,0 +1,618 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 错误处理(下):如何设计错误包? + 你好,我是孔令飞。 + +在Go项目开发中,错误是我们必须要处理的一个事项。除了我们上一讲学习过的错误码,处理错误也离不开错误包。 + +业界有很多优秀的、开源的错误包可供选择,例如Go标准库自带的errors包、github.com/pkg/errors包。但是这些包目前还不支持业务错误码,很难满足生产级应用的需求。所以,在实际开发中,我们有必要开发出适合自己错误码设计的错误包。当然,我们也没必要自己从0开发,可以基于一些优秀的包来进行二次封装。 + +这一讲里,我们就来一起看看,如何设计一个错误包来适配上一讲我们设计的错误码,以及一个错误码的具体实现。 + +错误包需要具有哪些功能? + +要想设计一个优秀的错误包,我们首先得知道一个优秀的错误包需要具备哪些功能。在我看来,至少需要有下面这六个功能: + +首先,应该能支持错误堆栈。我们来看下面一段代码,假设保存在bad.go文件中: + +package main + +import ( + "fmt" + "log" +) + +func main() { + if err := funcA(); err != nil { + log.Fatalf("call func got failed: %v", err) + return + } + + log.Println("call func success") +} + +func funcA() error { + if err := funcB(); err != nil { + return err + } + + return fmt.Errorf("func called error") +} + +func funcB() error { + return fmt.Errorf("func called error") +} + + +执行上面的代码: + +$ go run bad.go +2021/07/02 08:06:55 call func got failed: func called error +exit status 1 + + +这时我们想定位问题,但不知道具体是哪行代码报的错误,只能靠猜,还不一定能猜到。为了解决这个问题,我们可以加一些Debug信息,来协助我们定位问题。这样做在测试环境是没问题的,但是在线上环境,一方面修改、发布都比较麻烦,另一方面问题可能比较难重现。这时候我们会想,要是能打印错误的堆栈就好了。例如: + +2021/07/02 14:17:03 call func got failed: func called error +main.funcB + /home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/good.go:27 +main.funcA + /home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/good.go:19 +main.main + /home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/good.go:10 +runtime.main + /home/colin/go/go1.16.2/src/runtime/proc.go:225 +runtime.goexit + /home/colin/go/go1.16.2/src/runtime/asm_amd64.s:1371 +exit status 1 + + +通过上面的错误输出,我们可以很容易地知道是哪行代码报的错,从而极大提高问题定位的效率,降低定位的难度。所以,在我看来,一个优秀的errors包,首先需要支持错误堆栈。 + +其次,能够支持不同的打印格式。例如%+v、%v、%s等格式,可以根据需要打印不同丰富度的错误信息。 + +再次,能支持Wrap/Unwrap功能,也就是在已有的错误上,追加一些新的信息。例如errors.Wrap(err, "open file failed") 。Wrap通常用在调用函数中,调用函数可以基于被调函数报错时的错误Wrap一些自己的信息,丰富报错信息,方便后期的错误定位,例如: + +func funcA() error { + if err := funcB(); err != nil { + return errors.Wrap(err, "call funcB failed") + } + + return errors.New("func called error") +} + +func funcB() error { + return errors.New("func called error") +} + + +这里要注意,不同的错误类型,Wrap函数的逻辑也可以不同。另外,在调用Wrap时,也会生成一个错误堆栈节点。我们既然能够嵌套error,那有时候还可能需要获取被嵌套的error,这时就需要错误包提供Unwrap函数。 + +还有,错误包应该有Is方法。在实际开发中,我们经常需要判断某个error是否是指定的error。在Go 1.13之前,也就是没有wrapping error的时候,我们要判断error是不是同一个,可以使用如下方法: + +if err == os.ErrNotExist { + // normal code +} + + +但是现在,因为有了wrapping error,这样判断就会有问题。因为你根本不知道返回的err是不是一个嵌套的error,嵌套了几层。这种情况下,我们的错误包就需要提供Is函数: + +func Is(err, target error) bool + + +当err和target是同一个,或者err是一个wrapping error的时候,如果target也包含在这个嵌套error链中,返回true,否则返回fasle。 + +另外,错误包应该支持 As 函数。 + +在Go 1.13之前,没有wrapping error的时候,我们要把error转为另外一个error,一般都是使用type assertion或者type switch,也就是类型断言。例如: + +if perr, ok := err.(*os.PathError); ok { + fmt.Println(perr.Path) +} + + +但是现在,返回的err可能是嵌套的error,甚至好几层嵌套,这种方式就不能用了。所以,我们可以通过实现 As 函数来完成这种功能。现在我们把上面的例子,用 As 函数实现一下: + +var perr *os.PathError +if errors.As(err, &perr) { + fmt.Println(perr.Path) +} + + +这样就可以完全实现类型断言的功能,而且还更强大,因为它可以处理wrapping error。 + +最后,能够支持两种错误创建方式:非格式化创建和格式化创建。例如: + +errors.New("file not found") +errors.Errorf("file %s not found", "iam-apiserver") + + +上面,我们介绍了一个优秀的错误包应该具备的功能。一个好消息是,Github上有不少实现了这些功能的错误包,其中github.com/pkg/errors包最受欢迎。所以,我基于github.com/pkg/errors包进行了二次封装,用来支持上一讲所介绍的错误码。 + +错误包实现 + +明确优秀的错误包应该具备的功能后,我们来看下错误包的实现。实现的源码存放在github.com/marmotedu/errors。 + +我通过在文件github.com/pkg/errors/errors.go中增加新的withCode结构体,来引入一种新的错误类型,该错误类型可以记录错误码、stack、cause和具体的错误信息。 + +type withCode struct { + err error // error 错误 + code int // 业务错误码 + cause error // cause error + *stack // 错误堆栈 +} + + +下面,我们通过一个示例,来了解下github.com/marmotedu/errors所提供的功能。假设下述代码保存在errors.go文件中: + +package main + +import ( + "fmt" + + "github.com/marmotedu/errors" + code "github.com/marmotedu/sample-code" +) + +func main() { + if err := bindUser(); err != nil { + // %s: Returns the user-safe error string mapped to the error code or the error message if none is specified. + fmt.Println("====================> %s <====================") + fmt.Printf("%s\n\n", err) + + // %v: Alias for %s. + fmt.Println("====================> %v <====================") + fmt.Printf("%v\n\n", err) + + // %-v: Output caller details, useful for troubleshooting. + fmt.Println("====================> %-v <====================") + fmt.Printf("%-v\n\n", err) + + // %+v: Output full error stack details, useful for debugging. + fmt.Println("====================> %+v <====================") + fmt.Printf("%+v\n\n", err) + + // %#-v: Output caller details, useful for troubleshooting with JSON formatted output. + fmt.Println("====================> %#-v <====================") + fmt.Printf("%#-v\n\n", err) + + // %#+v: Output full error stack details, useful for debugging with JSON formatted output. + fmt.Println("====================> %#+v <====================") + fmt.Printf("%#+v\n\n", err) + + // do some business process based on the error type + if errors.IsCode(err, code.ErrEncodingFailed) { + fmt.Println("this is a ErrEncodingFailed error") + } + + if errors.IsCode(err, code.ErrDatabase) { + fmt.Println("this is a ErrDatabase error") + } + + // we can also find the cause error + fmt.Println(errors.Cause(err)) + } +} + +func bindUser() error { + if err := getUser(); err != nil { + // Step3: Wrap the error with a new error message and a new error code if needed. + return errors.WrapC(err, code.ErrEncodingFailed, "encoding user 'Lingfei Kong' failed.") + } + + return nil +} + +func getUser() error { + if err := queryDatabase(); err != nil { + // Step2: Wrap the error with a new error message. + return errors.Wrap(err, "get user failed.") + } + + return nil +} + +func queryDatabase() error { + // Step1. Create error with specified error code. + return errors.WithCode(code.ErrDatabase, "user 'Lingfei Kong' not found.") +} + + +上述代码中,通过WithCode函数来创建新的withCode类型的错误;通过WrapC来将一个error封装成一个withCode类型的错误;通过IsCode来判断一个error链中是否包含指定的code。 + +withCode错误实现了一个func (w *withCode) Format(state fmt.State, verb rune)方法,该方法用来打印不同格式的错误信息,见下表: + + + +例如,%+v会打印以下错误信息: + +get user failed. - #1 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:19 (main.getUser)] (100101) Database error; user 'Lingfei Kong' not found. - #0 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:26 (main.queryDatabase)] (100101) Database error + + +那么你可能会问,这些错误信息中的100101错误码,还有Database error这种对外展示的报错信息等等,是从哪里获取的?这里我简单解释一下。 + +首先, withCode 中包含了int类型的错误码,例如100101。 + +其次,当使用github.com/marmotedu/errors包的时候,需要调用Register或者MustRegister,将一个Coder注册到github.com/marmotedu/errors开辟的内存中,数据结构为: + +var codes = map[int]Coder{} + + +Coder是一个接口,定义为: + +type Coder interface { + // HTTP status that should be used for the associated error code. + HTTPStatus() int + + // External (user) facing error text. + String() string + + // Reference returns the detail documents for user. + Reference() string + + // Code returns the code of the coder + Code() int +} + + +这样 withCode 的Format方法,就能够通过 withCode 中的code字段获取到对应的Coder,并通过Coder提供的HTTPStatus、String、Reference、Code函数,来获取 withCode 中code的详细信息,最后格式化打印。 + +这里要注意,我们实现了两个注册函数:Register和MustRegister,二者唯一区别是:当重复定义同一个错误Code时,MustRegister会panic,这样可以防止后面注册的错误覆盖掉之前注册的错误。在实际开发中,建议使用MustRegister。 + +XXX()和MustXXX()的函数命名方式,是一种Go代码设计技巧,在Go代码中经常使用,例如Go标准库中regexp包提供的Compile和MustCompile函数。和XXX相比,MustXXX 会在某种情况不满足时panic。因此使用MustXXX的开发者看到函数名就会有一个心理预期:使用不当,会造成程序panic。 + +最后,我还有一个建议:在实际的生产环境中,我们可以使用JSON格式打印日志,JSON格式的日志可以非常方便的供日志系统解析。我们可以根据需要,选择%#-v或%#+v两种格式。 + +错误包在代码中,经常被调用,所以我们要保证错误包一定要是高性能的,否则很可能会影响接口的性能。这里,我们再来看下github.com/marmotedu/errors包的性能。 + +在这里,我们把这个错误包跟go标准库的 errors 包,以及 github.com/pkg/errors 包进行对比,来看看它们的性能: + +$ go test -test.bench=BenchmarkErrors -benchtime="3s" +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/errors +BenchmarkErrors/errors-stack-10-8 57658672 61.8 ns/op 16 B/op 1 allocs/op +BenchmarkErrors/pkg/errors-stack-10-8 2265558 1547 ns/op 320 B/op 3 allocs/op +BenchmarkErrors/marmot/errors-stack-10-8 1903532 1772 ns/op 360 B/op 5 allocs/op +BenchmarkErrors/errors-stack-100-8 4883659 734 ns/op 16 B/op 1 allocs/op +BenchmarkErrors/pkg/errors-stack-100-8 1202797 2881 ns/op 320 B/op 3 allocs/op +BenchmarkErrors/marmot/errors-stack-100-8 1000000 3116 ns/op 360 B/op 5 allocs/op +BenchmarkErrors/errors-stack-1000-8 505636 7159 ns/op 16 B/op 1 allocs/op +BenchmarkErrors/pkg/errors-stack-1000-8 327681 10646 ns/op 320 B/op 3 allocs/op +BenchmarkErrors/marmot/errors-stack-1000-8 304160 11896 ns/op 360 B/op 5 allocs/op +PASS +ok github.com/marmotedu/errors 39.200s + + +可以看到github.com/marmotedu/errors和github.com/pkg/errors包的性能基本持平。在对比性能时,重点关注ns/op,也即每次error操作耗费的纳秒数。另外,我们还需要测试不同error嵌套深度下的error操作性能,嵌套越深,性能越差。例如:在嵌套深度为10的时候, github.com/pkg/errors 包ns/op值为1547, github.com/marmotedu/errors 包ns/op值为1772。可以看到,二者性能基本保持一致。 + +具体性能数据对比见下表: + + + +我们是通过BenchmarkErrors测试函数来测试error包性能的,你感兴趣可以打开链接看看。 + +如何记录错误? + +上面,我们一起看了怎么设计一个优秀的错误包,那如何用我们设计的错误包来记录错误呢? + +根据我的开发经验,我推荐两种记录错误的方式,可以帮你快速定位问题。 + +方式一:通过github.com/marmotedu/errors包提供的错误堆栈能力,来跟踪错误。 + +具体你可以看看下面的代码示例。以下代码保存在errortrack_errors.go中。 + +package main + +import ( + "fmt" + + "github.com/marmotedu/errors" + + code "github.com/marmotedu/sample-code" +) + +func main() { + if err := getUser(); err != nil { + fmt.Printf("%+v\n", err) + } +} + +func getUser() error { + if err := queryDatabase(); err != nil { + return errors.Wrap(err, "get user failed.") + } + + return nil +} + +func queryDatabase() error { + return errors.WithCode(code.ErrDatabase, "user 'Lingfei Kong' not found.") +} + + +执行上述的代码: + +$ go run errortrack_errors.go +get user failed. - #1 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:19 (main.getUser)] (100101) Database error; user 'Lingfei Kong' not found. - #0 [/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/errors/errortrack_errors.go:26 (main.queryDatabase)] (100101) Database error + + +可以看到,打印的日志中打印出了详细的错误堆栈,包括错误发生的函数、文件名、行号和错误信息,通过这些错误堆栈,我们可以很方便地定位问题。 + +你使用这种方法时,我推荐的用法是,在错误最开始处使用 errors.WithCode() 创建一个 withCode类型的错误。上层在处理底层返回的错误时,可以根据需要,使用Wrap函数基于该错误封装新的错误信息。如果要包装的error不是用github.com/marmotedu/errors包创建的,建议用 errors.WithCode() 新建一个error。 + +方式二:在错误产生的最原始位置调用日志包记录函数,打印错误信息,其他位置直接返回(当然,也可以选择性的追加一些错误信息,方便故障定位)。示例代码(保存在errortrack_log.go)如下: + +package main + +import ( + "fmt" + + "github.com/marmotedu/errors" + "github.com/marmotedu/log" + + code "github.com/marmotedu/sample-code" +) + +func main() { + if err := getUser(); err != nil { + fmt.Printf("%v\n", err) + } +} + +func getUser() error { + if err := queryDatabase(); err != nil { + return err + } + + return nil +} + +func queryDatabase() error { + opts := &log.Options{ + Level: "info", + Format: "console", + EnableColor: true, + EnableCaller: true, + OutputPaths: []string{"test.log", "stdout"}, + ErrorOutputPaths: []string{}, + } + + log.Init(opts) + defer log.Flush() + + err := errors.WithCode(code.ErrDatabase, "user 'Lingfei Kong' not found.") + if err != nil { + log.Errorf("%v", err) + } + return err +} + + +执行以上代码: + +$ go run errortrack_log.go +2021-07-03 14:37:31.597 ERROR errors/errortrack_log.go:41 Database error +Database error + + +当错误发生时,调用log包打印错误。通过log包的caller功能,可以定位到log语句的位置,也就是定位到错误发生的位置。你使用这种方式来打印日志时,我有两个建议。 + + +只在错误产生的最初位置打印日志,其他地方直接返回错误,一般不需要再对错误进行封装。 +当代码调用第三方包的函数时,第三方包函数出错时打印错误信息。比如: + + +if err := os.Chdir("/root"); err != nil { + log.Errorf("change dir failed: %v", err) +} + + +一个错误码的具体实现 + +接下来,我们看一个依据上一讲介绍的错误码规范的具体错误码实现github.com/marmotedu/sample-code。 + +sample-code实现了两类错误码,分别是通用错误码(sample-code/base.go)和业务模块相关的错误码(sample-code/apiserver.go)。 + +首先,我们来看通用错误码的定义: + +// 通用: 基本错误 +// Code must start with 1xxxxx +const ( + // ErrSuccess - 200: OK. + ErrSuccess int = iota + 100001 + + // ErrUnknown - 500: Internal server error. + ErrUnknown + + // ErrBind - 400: Error occurred while binding the request body to the struct. + ErrBind + + // ErrValidation - 400: Validation failed. + ErrValidation + + // ErrTokenInvalid - 401: Token invalid. + ErrTokenInvalid +) + + +在代码中,我们通常使用整型常量(ErrSuccess)来代替整型错误码(100001),因为使用ErrSuccess时,一看就知道它代表的错误类型,可以方便开发者使用。 + +错误码用来指代一个错误类型,该错误类型需要包含一些有用的信息,例如对应的HTTP Status Code、对外展示的Message,以及跟该错误匹配的帮助文档。所以,我们还需要实现一个Coder来承载这些信息。这里,我们定义了一个实现了github.com/marmotedu/errors.Coder接口的ErrCode结构体: + +// ErrCode implements `github.com/marmotedu/errors`.Coder interface. +type ErrCode struct { + // C refers to the code of the ErrCode. + C int + + // HTTP status that should be used for the associated error code. + HTTP int + + // External (user) facing error text. + Ext string + + // Ref specify the reference document. + Ref string +} + + +可以看到ErrCode结构体包含了以下信息: + + +int类型的业务码。 +对应的HTTP Status Code。 +暴露给外部用户的消息。 +错误的参考文档。 + + +下面是一个具体的Coder示例: + +coder := &ErrCode{ + C: 100001, + HTTP: 200, + Ext: "OK", + Ref: "https://github.com/marmotedu/sample-code/blob/master/README.md", +} + + +接下来,我们就可以调用github.com/marmotedu/errors包提供的Register或者MustRegister函数,将Coder注册到github.com/marmotedu/errors包维护的内存中。 + +一个项目有很多个错误码,如果每个错误码都手动调用MustRegister函数会很麻烦,这里我们通过代码自动生成的方法,来生成register函数调用: + +//go:generate codegen -type=int +//go:generate codegen -type=int -doc -output ./error_code_generated.md + + +//go:generate codegen -type=int 会调用codegen工具,生成sample_code_generated.go源码文件: + +func init() { + register(ErrSuccess, 200, "OK") + register(ErrUnknown, 500, "Internal server error") + register(ErrBind, 400, "Error occurred while binding the request body to the struct") + register(ErrValidation, 400, "Validation failed") + // other register function call +} + + +这些register调用放在init函数中,在加载程序的时候被初始化。 + +这里要注意,在注册的时候,我们会检查HTTP Status Code,只允许定义200、400、401、403、404、500这6个HTTP错误码。这里通过程序保证了错误码是符合HTTP Status Code使用要求的。 + +//go:generate codegen -type=int -doc -output ./error_code_generated.md会生成错误码描述文档 error_code_generated.md。当我们提供API文档时,也需要记着提供一份错误码描述文档,这样客户端才可以根据错误码,知道请求是否成功,以及具体发生哪类错误,好针对性地做一些逻辑处理。 + +codegen工具会根据错误码注释生成sample_code_generated.go和error_code_generated.md文件: + +// ErrSuccess - 200: OK. + ErrSuccess int = iota + 100001 + + +codegen工具之所以能够生成sample_code_generated.go和error_code_generated.md,是因为我们的错误码注释是有规定格式的:// <错误码整型常量> - <对应的HTTP Status Code>: .。 + +codegen工具可以在IAM项目根目录下,执行以下命令来安装: + +$ make tools.install.codegen + + +安装完 codegen 工具后,可以在 github.com/marmotedu/sample-code 包根目录下执行 go generate 命令,来生成sample_code_generated.go和error_code_generated.md。这里有个技巧需要你注意:生成的文件建议统一用 xxxx_generated.xx 来命名,这样通过 generated ,我们就知道这个文件是代码自动生成的,有助于我们理解和使用。 + +在实际的开发中,我们可以将错误码独立成一个包,放在 internal/pkg/code/目录下,这样可以方便整个应用调用。例如IAM的错误码就放在IAM项目根目录下的internal/pkg/code/目录下。 + +我们的错误码是分服务和模块的,所以这里建议你把相同的服务放在同一个Go源文件中,例如IAM的错误码存放文件: + +$ ls base.go apiserver.go authzserver.go +apiserver.go authzserver.go base.go + + +一个应用中会有多个服务,例如IAM应用中,就包含了iam-apiserver、iam-authz-server、iam-pump三个服务。这些服务有一些通用的错误码,为了便于维护,可以将这些通用的错误码统一放在base.go源码文件中。其他的错误码,我们可以按服务分别放在不同的文件中:iam-apiserver服务的错误码统一放在apiserver.go文件中;iam-authz-server的错误码统一存放在authzserver.go文件中。其他服务以此类推。 + +另外,同一个服务中不同模块的错误码,可以按以下格式来组织:相同模块的错误码放在同一个const代码块中,不同模块的错误码放在不同的const代码块中。每个const代码块的开头注释就是该模块的错误码定义。例如: + +// iam-apiserver: user errors. +const ( + // ErrUserNotFound - 404: User not found. + ErrUserNotFound int = iota + 110001 + + // ErrUserAlreadyExist - 400: User already exist. + ErrUserAlreadyExist +) + +// iam-apiserver: secret errors. +const ( + // ErrEncrypt - 400: Secret reach the max count. + ErrReachMaxCount int = iota + 110101 + + // ErrSecretNotFound - 404: Secret not found. + ErrSecretNotFound +) + + +最后,我们还需要将错误码定义记录在项目的文件中,供开发者查阅、遵守和使用,例如IAM项目的错误码定义记录文档为code_specification.md。这个文档中记录了错误码说明、错误描述规范和错误记录规范等。 + +错误码实际使用方法示例 + +上面,我讲解了错误包和错误码的实现方式,那你一定想知道在实际开发中我们是如何使用的。这里,我就举一个在gin web框架中使用该错误码的例子: + +// Response defines project response format which in marmotedu organization. +type Response struct { + Code errors.Code `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Reference string `json:"reference,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// WriteResponse used to write an error and JSON data into response. +func WriteResponse(c *gin.Context, err error, data interface{}) { + if err != nil { + coder := errors.ParseCoder(err) + + c.JSON(coder.HTTPStatus(), Response{ + Code: coder.Code(), + Message: coder.String(), + Reference: coder.Reference(), + Data: data, + }) + } + + c.JSON(http.StatusOK, Response{Data: data}) +} + +func GetUser(c *gin.Context) { + log.Info("get user function called.", "X-Request-Id", requestid.Get(c)) + // Get the user by the `username` from the database. + user, err := store.Client().Users().Get(c.Param("username"), metav1.GetOptions{}) + if err != nil { + core.WriteResponse(c, errors.WithCode(code.ErrUserNotFound, err.Error()), nil) + return + } + + core.WriteResponse(c, nil, user) +} + + +上述代码中,通过WriteResponse统一处理错误。在 WriteResponse 函数中,如果err != nil,则从error中解析出Coder,并调用Coder提供的方法,获取错误相关的Http Status Code、int类型的业务码、暴露给用户的信息、错误的参考文档链接,并返回JSON格式的信息。如果 err == nil 则返回200和数据。 + +总结 + +记录错误是应用程序必须要做的一件事情,在实际开发中,我们通常会封装自己的错误包。一个优秀的错误包,应该能够支持错误堆栈、不同的打印格式、Wrap/Unwrap/Is/As等函数,并能够支持格式化创建error。 + +根据这些错误包设计要点,我基于 github.com/pkg/errors 包设计了IAM项目的错误包 github.com/marmotedu/errors ,该包符合我们上一讲设计的错误码规范。 + +另外,本讲也给出了一个具体的错误码实现 sample-code , sample-code 支持业务Code码、HTTP Status Code、错误参考文档、可以对内对外展示不同的错误信息。 + +最后,因为错误码注释是有固定格式的,所以我们可以通过codegen工具解析错误码的注释,生成register函数调用和错误码文档。这种做法也体现了我一直强调的low code思想,可以提高开发效率,减少人为失误。 + +课后练习 + + +在这门课里,我们定义了base、iam-apiserver服务的错误码,请试着定义iam-authz-server服务的错误码,并生成错误码文档。 +思考下,这门课的错误包和错误码设计能否满足你当前的项目需求,如果觉得不能满足,可以在留言区分享你的看法。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/20日志处理(上):如何设计日志包并记录日志?.md b/专栏/Go语言项目开发实战/20日志处理(上):如何设计日志包并记录日志?.md new file mode 100644 index 0000000..2280911 --- /dev/null +++ b/专栏/Go语言项目开发实战/20日志处理(上):如何设计日志包并记录日志?.md @@ -0,0 +1,427 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 日志处理(上):如何设计日志包并记录日志? + 你好,我是孔令飞,接下来的两讲,我们来聊聊如何设计和开发日志包。 + +在做Go项目开发时,除了处理错误之外,我们必须要做的另外一件事是记录日志。通过记录日志,可以完成一些基本功能,比如开发、测试期间的Debug,故障排除,数据分析,监控告警,以及记录发生的事件等。 + +要实现这些功能,首先我们需要一个优秀的日志包。另外,我还发现不少Go项目开发者记录日志很随意,输出的日志并不能有效定位到问题。所以,我们还需要知道怎么更好地记录日志,这就需要一个日志记录规范。 + +有了优秀的日志包和日志记录规范,我们就能很快地定位到问题,获取足够的信息,并完成后期的数据分析和监控告警,也可以很方便地进行调试了。这一讲,我就来详细介绍下,如何设计日志包和日志记录规范。 + +首先,我们来看下如何设计日志包。 + +如何设计日志包 + +目前,虽然有很多优秀的开源日志包可供我们选择,但在一个大型系统中,这些开源日志包很可能无法满足我们定制化的需求,这时候我们就需要自己开发日志包。 + +这些日志包可能是基于某个,或某几个开源的日志包改造而来,也可能是全新开发的日志包。那么在开发日志包时,我们需要实现哪些功能,又如何实现呢?接下来,我们就来详细聊聊。 + +先来看下日志包需要具备哪些功能。根据功能的重要性,我将日志包需要实现的功能分为基础功能、高级功能和可选功能。基础功能是一个日志包必须要具备的功能;高级功能、可选功能都是在特定场景下可增加的功能。我们先来说基础功能。 + +基础功能 + +基础功能,是优秀日志包必备的功能,能够满足绝大部分的使用场景,适合一些中小型的项目。一个日志包应该具备以下4个基础功能。 + + +支持基本的日志信息 + + +日志包需要支持基本的日志信息,包括时间戳、文件名、行号、日志级别和日志信息。 + +时间戳可以记录日志发生的时间。在定位问题时,我们往往需要根据时间戳,来复原请求过程,核对相同时间戳下的上下文,从而定位出问题。 + +文件名和行号,可以使我们更快速定位到打印日志的位置,找到问题代码。一个日志库如果不支持文件名和行号,排查故障就会变得非常困难,基本只能靠grep和记忆来定位代码。对于企业级的服务,需要保证服务在故障后能够快速恢复,恢复的时间越久,造成的损失就越大,影响就越大。这就要求研发人员能够快速定位并解决问题。通过文件名和行号,我们可以精准定位到问题代码,尽快地修复问题并恢复服务。 + +通过日志级别,可以知道日志的错误类型,最通常的用法是:直接过滤出 Error 级别的日志,这样就可以直接定位出问题出错点,然后再结合其他日志定位出出错的原因。如果不支持日志级别,在定位问题时,可能要查看一大堆无用的日志。在大型系统中,一次请求的日志量很多,这会大大延长我们定位问题的时间。 + +而通过日志信息,我们可以知道错误发生的具体原因。 + + +支持不同的日志级别 + + +不同的日志级别代表不同的日志类型,例如:Error级别的日志,说明日志是错误类型,在排障时,会首先查看错误级别的日志。Warn级别日志说明出现异常,但还不至于影响程序运行,如果程序执行的结果不符合预期,则可以参考Warn级别的日志,定位出异常所在。Info级别的日志,可以协助我们Debug,并记录一些有用的信息,供后期进行分析。 + +通常一个日志包至少要实现6个级别,我给你提供了一张表格,按优先级从低到高排列如下: + + + +有些日志包,例如logrus,还支持Trace日志级别。Trace级别比Debug级别还低,能够打印更细粒度的日志信息。在我看来,Trace级别不是必须的,你可以根据需要自行选择。 + +打印日志时,一个日志调用其实具有两个属性: + + +输出级别:打印日志时,我们期望日志的输出级别。例如,我们调用 glog.Info("This is info message") 打印一条日志,则输出日志级别为Info。 +开关级别:启动应用程序时,期望哪些输出级别的日志被打印。例如,使用glog时 -v=4 ,说明了只有日志级别高于4的日志才会被打印。 + + +如果开关级别设置为 L ,只有输出级别 >=L 时,日志才会被打印。例如,开关级别为Warn,则只会记录Warn、Error 、Panic 和Fatal级别的日志。具体的输出关系如下图所示: + + + + +支持自定义配置 + + +不同的运行环境,需要不同的日志输出配置,例如:开发测试环境为了能够方便地Debug,需要设置日志级别为Debug级别;现网环境为了提高应用程序的性能,则需要设置日志级别为Info级别。又比如,现网环境为了方便日志采集,通常会输出JSON格式的日志;开发测试环境为了方便查看日志,会输出TEXT格式的日志。 + +所以,我们的日志包需要能够被配置,还要不同环境采用不同的配置。通过配置,可以在不重新编译代码的情况下,改变记录日志的行为。 + + +支持输出到标准输出和文件 + + +日志总是要被读的,要么输出到标准输出,供开发者实时读取,要么保存到文件,供开发者日后查看。输出到标准输出和保存到文件是一个日志包最基本的功能。 + +高级功能 + +除了上面提到的这些基本功能外,在一些大型系统中,通常还会要求日志包具备一些高级功能。这些高级功能可以帮我们更好地记录日志,并实现更丰富的功能,例如日志告警。那么一个日志包可以具备哪些高级功能呢? + + +支持多种日志格式 + + +日志格式也是我们要考虑的一个点,一个好的日志格式,不仅方便查看日志,还能方便一些日志采集组件采集日志,并对接类似Elasticsearch这样的日志搜索引擎。 + +一个日志包至少需要提供以下两种格式: + + +TEXT格式:TEXT格式的日志具有良好的可读性,可以方便我们在开发联调阶段查看日志,例如: + + +2020-12-02T01:16:18+08:00 INFO example.go:11 std log + +2020-12-02T01:16:18+08:00 DEBUG example.go:13 change std log to debug level + + +JSON格式:JSON格式的日志可以记录更详细的信息,日志中包含一些通用的或自定义的字段,可供日后的查询、分析使用,而且可以很方便地供filebeat、logstash这类日志采集工具采集并上报。下面是JSON格式的日志: + + +{"level":"DEBUG","time":"2020-12-02T01:16:18+08:00","file":"example.go:15","func":"main.main","message":"log in json format"} +{"level":"INFO","time":"2020-12-02T01:16:18+08:00","file":"example.go:16","func":"main.main","message":"another log in json format"} + + +我建议在开发联调阶段使用TEXT格式的日志,在现网环境使用JSON格式的日志。一个优秀的日志库,例如logrus,除了提供基本的输出格式外,还应该允许开发者自定义日志输出格式。 + + +能够按级别分类输出 + + +为了能够快速定位到需要的日志,一个比较好的做法是将日志按级别分类输出,至少错误级别的日志可以输出到独立的文件中。这样,出现问题时,可以直接查找错误文件定位问题。例如,glog就支持分类输出,如下图所示: + + + + +支持结构化日志 + + +结构化日志(Structured Logging),就是使用JSON或者其他编码方式使日志结构化,这样可以方便后续使用Filebeat、Logstash Shipper等各种工具,对日志进行采集、过滤、分析和查找。就像下面这个案例,使用zap进行日志打印: + +package main + +import ( + "time" + + "go.uber.org/zap" +) + +func main() { + logger, _ := zap.NewProduction() + defer logger.Sync() // flushes buffer, if any + url := "http://marmotedu.com" + // 结构化日志打印 + logger.Sugar().Infow("failed to fetch URL", "url", url, "attempt", 3, "backoff", time.Second) + + // 非结构化日志打印 + logger.Sugar().Infof("failed to fetch URL: %s", url) +} + + +上述代码输出为: + +{"level":"info","ts":1607303966.9903321,"caller":"zap/structured_log.go:14","msg":"failed to fetch URL","url":"http://marmotedu.com","attempt":3,"backoff":1} +{"level":"info","ts":1607303966.9904354,"caller":"zap/structured_log.go:17","msg":"failed to fetch URL: http://marmotedu.com"} + + + +支持日志轮转 + + +在一个大型项目中,一天可能会产生几十个G的日志。为了防止日志把磁盘空间占满,导致服务器或者程序异常,就需要确保日志大小达到一定量级时,对日志进行切割、压缩,并转存。 + +如何切割呢?你可以按照日志大小进行切割,也可以按日期切割。日志的切割、压缩和转存功能可以基于GitHub上一些优秀的开源包来封装,例如:lumberjack可以支持按大小和日期归档日志,file-rotatelogs支持按小时数进行日志切割。 + +对于日志轮转功能,其实我不建议在日志包中添加,因为这会增加日志包的复杂度,我更建议的做法是借助其他的工具来实现日志轮转。例如,在Linux系统中可以使用Logrotate来轮转日志。Logrotate功能强大,是一个专业的日志轮转工具。 + + +具备Hook能力 + + +Hook能力可以使我们对日志内容进行自定义处理。例如,当某个级别的日志产生时,发送邮件或者调用告警接口进行告警。很多优秀的开源日志包提供了Hook能力,例如logrus和zap。 + +在一个大型系统中,日志告警是非常重要的功能,但更好的实现方式是将告警能力做成旁路功能。通过旁路功能,可以保证日志包功能聚焦、简洁。例如:可以将日志收集到Elasticsearch,并通过ElastAlert进行日志告警。 + +可选功能 + +除了基础功能和高级功能外,还有一些功能。这些功能不会影响到日志包的核心功能,但是如果具有这些功能,会使日志包更加易用。比如下面的这三个功能。 + + +支持颜色输出 + + +在开发、测试时开启颜色输出,不同级别的日志会被不同颜色标识,这样我们可以很轻松地发现一些Error、Warn级别的日志,方便开发调试。发布到生产环境时,可以关闭颜色输出,以提高性能。 + + +兼容标准库log包 + + +一些早期的Go项目大量使用了标准库log包,如果我们的日志库能够兼容标准库log包,我们就可以很容易地替换掉标准库log包。例如,logrus就兼容标准库log包。这里,我们来看一个使用了标准库log包的代码: + +package main + +import ( + "log" +) + +func main() { + log.Print("call Print: line1") + log.Println("call Println: line2") +} + + +只需要使用log "github.com/sirupsen/logrus"替换"log"就可以完成标准库log包的切换: + +package main + +import ( + log "github.com/sirupsen/logrus" +) + +func main() { + log.Print("call Print: line1") + log.Println("call Println: line2") +} + + + +支持输出到不同的位置 + + +在分布式系统中,一个服务会被部署在多台机器上,这时候如果我们要查看日志,就需要分别登录不同的机器查看,非常麻烦。我们更希望将日志统一投递到Elasticsearch上,在Elasticsearch上查看日志。 + +我们还可能需要从日志中分析某个接口的调用次数、某个用户的请求次数等信息,这就需要我们能够对日志进行处理。一般的做法是将日志投递到Kafka,数据处理服务消费Kafka中保存的日志,从而分析出调用次数等信息。 + +以上两种场景,分别需要把日志投递到Elasticsearch、Kafka等组件,如果我们的日志包支持将日志投递到不同的目的端,那会是一项非常让人期待的功能: + + + +如果日志不支持投递到不同的下游组件,例如Elasticsearch、Kafka、Fluentd、Logstash等位置,也可以通过Filebeat采集磁盘上的日志文件,进而投递到下游组件。 + +设计日志包时需要关注的点 + +上面,我们介绍了日志包具备的功能,这些功能可以指导我们完成日志包设计。这里,我们再来看下设计日志包时,我们还需要关注的几个层面: + + +高性能:因为我们要在代码中频繁调用日志包,记录日志,所以日志包的性能是首先要考虑的点,一个性能很差的日志包必然会导致整个应用性能很差。 +并发安全:Go应用程序会大量使用Go语言的并发特性,也就意味着需要并发地记录日志,这就需要日志包是并发安全的。 +插件化能力:日志包应该能提供一些插件化的能力,比如允许开发者自定义输出格式,自定义存储位置,自定义错误发生时的行为(例如 告警、发邮件等)。插件化的能力不是必需的,因为日志自身的特性就能满足绝大部分的使用需求,例如:输出格式支持JSON和TEXT,存储位置支持标准输出和文件,日志监控可以通过一些旁路系统来实现。 +日志参数控制:日志包应该能够灵活地进行配置,初始化时配置或者程序运行时配置。例如:初始化配置可以通过 Init 函数完成,运行时配置可以通过 SetOptions / SetLevel 等函数来完成。 + + +如何记录日志? + +前面我们介绍了在设计日志包时,要包含的一些功能、实现方法和注意事项。但在这个过程中,还有一项重要工作需要注意,那就是日志记录问题。 + +日志并不是越多越好,在实际开发中,经常会遇到一大堆无用的日志,却没有我们需要的日志;或者有效的日志被大量无用的日志淹没,查找起来非常困难。 + +一个优秀的日志包可以协助我们更好地记录、查看和分析日志,但是如何记录日志决定了我们能否获取到有用的信息。日志包是工具,日志记录才是灵魂。这里,我就来详细讲讲如何记录日志。 + +想要更好地记录日志,我们需要解决以下几个问题: + + +在何处打印日志? +在哪个日志级别打印日志? +如何记录日志内容? + + +在何处打印日志? + +日志主要是用来定位问题的,所以整体来说,我们要在有需要的地方打印日志。那么具体是哪些地方呢?我给你几个建议。 + + +在分支语句处打印日志。在分支语句处打印日志,可以判断出代码走了哪个分支,有助于判断请求的下一跳,继而继续排查问题。 +写操作必须打印日志。写操作最可能会引起比较严重的业务故障,写操作打印日志,可以在出问题时找到关键信息。 +在循环中打印日志要慎重。如果循环次数过多,会导致打印大量的日志,严重拖累代码的性能,建议的办法是在循环中记录要点,在循环外面总结打印出来。 +在错误产生的最原始位置打印日志。对于嵌套的Error,可在Error产生的最初位置打印Error日志,上层如果不需要添加必要的信息,可以直接返回下层的Error。我给你举个例子: + + +package main + +import ( + "flag" + "fmt" + + "github.com/golang/glog" +) + +func main() { + flag.Parse() + defer glog.Flush() + + if err := loadConfig(); err != nil { + glog.Error(err) + } +} + +func loadConfig() error { + return decodeConfig() // 直接返回 +} + +func decodeConfig() error { + if err := readConfig(); err != nil { + return fmt.Errorf("could not decode configuration data for user %s: %v", "colin", err) // 添加必要的信息,用户名称 + } + + return nil +} + +func readConfig() error { + glog.Errorf("read: end of input.") + return fmt.Errorf("read: end of input") +} + + +通过在最初产生错误的位置打印日志,我们可以很方便地追踪到日志的根源,进而在上层追加一些必要的信息。这可以让我们了解到该错误产生的影响,有助于排障。另外,直接返回下层日志,还可以减少重复的日志打印。 + +当代码调用第三方包的函数,且第三方包函数出错时,会打印错误信息。比如: + +if err := os.Chdir("/root"); err != nil { + log.Errorf("change dir failed: %v", err) +} + + +在哪个日志级别打印日志? + +不同级别的日志,具有不同的意义,能实现不同的功能,在开发中,我们应该根据目的,在合适的级别记录日志,这里我同样给你一些建议。 + + +Debug级别 + + +为了获取足够的信息进行Debug,通常会在Debug级别打印很多日志。例如,可以打印整个HTTP请求的请求Body或者响应Body。 + +Debug级别需要打印大量的日志,这会严重拖累程序的性能。并且,Debug级别的日志,主要是为了能在开发测试阶段更好地Debug,多是一些不影响现网业务的日志信息。所以,对于Debug级别的日志,在服务上线时我们一定要禁止掉。否则,就可能会因为大量的日志导致硬盘空间快速用完,从而造成服务宕机,也可能会影响服务的性能和产品体验。 + +Debug这个级别的日志可以随意输出,任何你觉得有助于开发、测试阶段调试的日志,都可以在这个级别打印。 + + +Info级别 + + +Info级别的日志可以记录一些有用的信息,供以后的运营分析,所以Info级别的日志不是越多越好,也不是越少越好,应以满足需求为主要目标。一些关键日志,可以在Info级别记录,但如果日志量大、输出频度过高,则要考虑在Debug级别记录。 + +现网的日志级别一般是Info级别,为了不使日志文件占满整个磁盘空间,在记录日志时,要注意避免产生过多的Info级别的日志。例如,在for循环中,就要慎用Info级别的日志。 + + +Warn级别 + + +一些警告类的日志可以记录在Warn级别,Warn级别的日志往往说明程序运行异常,不符合预期,但又不影响程序的继续运行,或者是暂时影响,但后续会恢复。像这些日志,就需要你关注起来。Warn更多的是业务级别的警告日志。 + + +Error级别 + + +Error级别的日志告诉我们程序执行出错,这些错误肯定会影响到程序的执行结果,例如请求失败、创建资源失败等。要记录每一个发生错误的日志,避免日后排障过程中这些错误被忽略掉。大部分的错误可以归在Error级别。 + + +Panic级别 + + +Panic级别的日志在实际开发中很少用,通常只在需要错误堆栈,或者不想因为发生严重错误导致程序退出,而采用defer处理错误时使用。 + + +Fatal级别 + + +Fatal是最高级别的日志,这个级别的日志说明问题已经相当严重,严重到程序无法继续运行,通常是系统级的错误。在开发中也很少使用,除非我们觉得某个错误发生时,整个程序无法继续运行。 + +这里用一张图来总结下,如何选择Debug、Info、Warn、Error、Panic、Fatal这几种日志级别。 + + + +如何记录日志内容? + +关于如何记录日志内容,我有几条建议: + + +在记录日志时,不要输出一些敏感信息,例如密码、密钥等。 +为了方便调试,通常会在Debug级别记录一些临时日志,这些日志内容可以用一些特殊的字符开头,例如 log.Debugf("XXXXXXXXXXXX-1:Input key was: %s", setKeyName) 。这样,在完成调试后,可以通过查找 XXXXXXXXXXXX 字符串,找到这些临时日志,在commit前删除。 +日志内容应该小写字母开头,以英文点号 . 结尾,例如 log.Info("update user function called.") 。 +为了提高性能,尽可能使用明确的类型,例如使用 log.Warnf("init datastore: %s", err.Error()) 而非 log.Warnf("init datastore: %v", err) 。 +根据需要,日志最好包含两个信息。一个是请求ID(RequestID),是每次请求的唯一ID,便于从海量日志中过滤出某次请求的日志,可以将请求ID放在请求的通用日志字段中。另一个是用户和行为,用于标识谁做了什么。 +不要将日志记录在错误的日志级别上。例如,我在项目开发中,经常会发现有同事将正常的日志信息打印在Error级别,将错误的日志信息打印在Info级别。 + + +记录日志的“最佳”实践总结 + +关于日志记录问题,我从以上三个层面给你讲解了。综合来说,对于日志记录的最佳实践,你在平时都可以注意或进行尝试,我把这些重点放在这里,方便你后续查阅。 + + +开发调试、现网故障排障时,不要遗忘一件事情:根据排障的过程优化日志打印。好的日志,可能不是一次就可以写好的,可以在实际开发测试,还有现网定位问题时,不断优化。但这需要你重视日志,而不是把日志仅仅当成记录信息的一种方式,甚至不知道为什么打印一条Info日志。 +打印日志要“不多不少”,避免打印没有作用的日志,也不要遗漏关键的日志信息。最好的信息是,仅凭借这些关键的日志就能定位到问题。 +支持动态日志输出,方便线上问题定位。 +总是将日志记录在本地文件:通过将日志记录在本地文件,可以和日志中心化平台进行解耦,这样当网络不可用,或者日志中心化平台故障时,仍然能够正常的记录日志。 +集中化日志存储处理:因为应用可能包含多个服务,一个服务包含多个实例,为了查看日志方便,最好将这些日志统一存储在同一个日志平台上,例如Elasticsearch,方便集中管理和查看日志。 +结构化日志记录:添加一些默认通用的字段到每行日志,方便日志查询和分析。 +支持RequestID:使用RequestID串联一次请求的所有日志,这些日志可能分布在不同的组件,不同的机器上。支持RequestID可以大大提高排障的效率,降低排障难度。在一些大型分布式系统中,没有RequestID排障简直就是灾难。 +支持动态开关Debug日志:对于定位一些隐藏得比较深的问题,可能需要更多的信息,这时候可能需要打印Debug日志。但现网的日志级别会设置为Info级别,为了获取Debug日志,我们可能会修改日志级别为Debug级别并重启服务,定位完问题后,再修改日志级别为Info级别,然后再重启服务,这种方式不仅麻烦而且还可能会对现网业务造成影响,最好的办法是能够在请求中通过 debug=true 这类参数动态控制某次请求是否开启Debug日志。 + + +拓展内容:分布式日志解决方案(EFK/ELK) + +前面我们介绍了设计日志包和记录日志的规范,除此之外,还有一个问题你应该了解,那就是:我们记录的日志如何收集、处理和展示。 + +在实际Go项目开发中,为了实现高可用,同一个服务至少需要部署两个实例,通过轮询的负载均衡策略转发请求。另外,一个应用又可能包含多个服务。假设我们的应用包含两个服务,每个服务部署两个实例,如果应用出故障,我们可能需要登陆4(2 x 2)台服务器查看本地的日志文件,定位问题,非常麻烦,会增加故障恢复时间。所以在真实的企业场景中,我们会将这些日志统一收集并展示。 + +在业界,日志的收集、处理和展示,早已经有了一套十分流行的日志解决方案:EFK(Elasticsearch + Filebeat + Kibana)或者ELK(Elasticsearch + Logstash + Kibana),EFK可以理解为ELK的演进版,把日志收集组件从Logstash替换成了Filebeat。用Filebeat替换Logstash,主要原因是Filebeat更轻量级,占用的资源更少。关于日志处理架构,你可以参考这张图。 + + + +通过log包将日志记录在本地文件中(*.log文件),再通过Shipper收集到Kafka中。Shipper可以根据需要灵活选择,常见的Shipper有Logstash Shipper、Flume、Fluentd、Filebeat。其中Filebeat和Logstash Shipper用得最多。Shipper没有直接将日志投递到Logstash indexer,或者Elasticsearch,是因为Kafka能够支持更大的吞吐量,起到削峰填谷的作用。 + +Kafka中的日志消息会被Logstash indexer消费,处理后投递到Elasticsearch中存储起来。Elasticsearch是实时全文搜索和分析引擎,提供搜集、分析、存储数据三大功能。Elasticsearch中存储的日志,可以通过Kibana提供的图形界面来展示。Kibana是一个基于Web的图形界面,用于搜索、分析和可视化存储在 Elasticsearch中的日志数据。 + +Logstash负责采集、转换和过滤日志。它支持几乎任何类型的日志,包括系统日志、错误日志和自定义应用程序日志。Logstash又分为Logstash Shipper和Logstash indexer。其中,Logstash Shipper监控并收集日志,并将日志内容发送到Logstash indexer,然后Logstash indexer过滤日志,并将日志提交给Elasticsearch。 + +总结 + +记录日志,是应用程序必备的功能。记录日志最大的作用是排障,如果想更好地排障,我们需要一个优秀的工具,日志包。那么如何设计日志包呢?首先我们需要知道日志包的功能,在我看来日志包需要具备以下功能: + + +基础功能:支持基本的日志信息、支持不同的日志级别、支持自定义配置、支持输出到标准输出和文件。 +高级功能:支持多种日志格式、能够按级别分类输出、支持结构化日志、支持日志轮转、具备Hook能力。 +可选功能:支持颜色输出、兼容标准库log包、支持输出到不同的位置。 + + +另外,一个日志包还需要支持不同级别的日志,按日志级别优先级从低到高分别是:Trace < Debug < Info < Warn/Warning < Error < Panic < Fatal。其中Debug、Info、Warn、Error、Fatal是比较基础的级别,建议在开发一个日志包时包含这些级别。Trace、Panic是可选的级别。 + +在我们掌握了日志包的功能之后,就可以设计、开发日志包了。但我们在开发过程中,还需要确保我们的日志包具有比较高的性能、并发安全、支持插件化的能力,并支持日志参数控制。 + +有了日志包,我们还需要知道如何更好地使用日志包,也就是如何记录日志。在文中,我给出了一些记录建议,内容比较多,你可以返回文中查看。 + +最后,我还给出了分布式日志解决方案:EFK/ELK。EFK是ELK的升级版,在实际项目开发中,我们可以直接选择EFK。在EFK方案中,通过Filebeat将日志上传到Kafka,Logstash indexer消费Kafka中的日志,并投递到Elasticsearch中存储起来,最后通过Kibana图形界面来查看日志。 + +课后练习 + +思考一下,你的项目中,日志包还需要哪些功能,如何设计?你的日常开发中,如果有比较好的日志记录规范,也欢迎在留言区分享讨论。 + +期待你的思考和看法,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/21日志处理(下):手把手教你从0编写一个日志包.md b/专栏/Go语言项目开发实战/21日志处理(下):手把手教你从0编写一个日志包.md new file mode 100644 index 0000000..606bab4 --- /dev/null +++ b/专栏/Go语言项目开发实战/21日志处理(下):手把手教你从0编写一个日志包.md @@ -0,0 +1,604 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 日志处理(下):手把手教你从 0 编写一个日志包 + 你好,我是孔令飞。 + +上一讲我介绍了如何设计日志包,今天是实战环节,我会手把手教你从0编写一个日志包。 + +在实际开发中,我们可以选择一些优秀的开源日志包,不加修改直接拿来使用。但更多时候,是基于一个或某几个优秀的开源日志包进行二次开发。想要开发或者二次开发一个日志包,就要掌握日志包的实现方式。那么这一讲中,我来带你从0到1,实现一个具备基本功能的日志包,让你从中一窥日志包的实现原理和实现方法。 + +在开始实战之前,我们先来看下目前业界有哪些优秀的开源日志包。 + +有哪些优秀的开源日志包? + +在Go项目开发中,我们可以通过修改一些优秀的开源日志包,来实现项目的日志包。Go生态中有很多优秀的开源日志包,例如标准库log包、glog、logrus、zap、seelog、zerolog、log15、apex/log、go-logging等。其中,用得比较多的是标准库log包、glog、logrus和zap。 + +为了使你了解开源日志包的现状,接下来我会简单介绍下这几个常用的日志包。至于它们的具体使用方法,你可以参考我整理的一篇文章:优秀开源日志包使用教程。 + +标准库log包 + +标准库log包的功能非常简单,只提供了Print、Panic和Fatal三类函数用于日志输出。因为是标准库自带的,所以不需要我们下载安装,使用起来非常方便。 + +标准库log包只有不到400行的代码量,如果你想研究如何实现一个日志包,阅读标准库log包是一个不错的开始。Go的标准库大量使用了log包,例如net/http 、 net/rpc 等。 + +glog + +glog是Google推出的日志包,跟标准库log包一样,它是一个轻量级的日志包,使用起来简单方便。但glog比标准库log包提供了更多的功能,它具有如下特性: + + +支持4种日志级别:Info、Warning、Error、Fatal。 +支持命令行选项,例如-alsologtostderr、-log_backtrace_at、-log_dir、-logtostderr、-v等,每个参数实现某种功能。 +支持根据文件大小切割日志文件。 +支持日志按级别分类输出。 +支持V level。V level特性可以使开发者自定义日志级别。 +支持vmodule。vmodule可以使开发者对不同的文件使用不同的日志级别。 +支持traceLocation。traceLocation可以打印出指定位置的栈信息。 + + +Kubernetes项目就使用了基于glog封装的klog,作为其日志库。 + +logrus + +logrus是目前GitHub上star数量最多的日志包,它的优点是功能强大、性能高效、高度灵活,还提供了自定义插件的功能。很多优秀的开源项目,例如Docker、Prometheus等,都使用了logrus。除了具有日志的基本功能外,logrus还具有如下特性: + + +支持常用的日志级别。logrus支持Debug、Info、Warn、Error、Fatal和Panic这些日志级别。 +可扩展。logrus的Hook机制允许使用者通过Hook的方式,将日志分发到任意地方,例如本地文件、标准输出、Elasticsearch、Logstash、Kafka等。 +支持自定义日志格式。logrus内置了JSONFormatter和TextFormatter两种格式。除此之外,logrus还允许使用者通过实现Formatter接口,来自定义日志格式。 +结构化日志记录。logrus的Field机制允许使用者自定义日志字段,而不是通过冗长的消息来记录日志。 +预设日志字段。logrus的Default Fields机制,可以给一部分或者全部日志统一添加共同的日志字段,例如给某次HTTP请求的所有日志添加X-Request-ID字段。 +Fatal handlers。logrus允许注册一个或多个handler,当产生Fatal级别的日志时调用。当我们的程序需要优雅关闭时,这个特性会非常有用。 + + +zap + +zap是uber开源的日志包,以高性能著称,很多公司的日志包都是基于zap改造而来。除了具有日志基本的功能之外,zap还具有很多强大的特性: + + +支持常用的日志级别,例如:Debug、Info、Warn、Error、DPanic、Panic、Fatal。 +性能非常高。zap具有非常高的性能,适合对性能要求比较高的场景。 +支持针对特定的日志级别,输出调用堆栈。 +像logrus一样,zap也支持结构化的目录日志、预设日志字段,也因为支持Hook而具有可扩展性。 + + +开源日志包选择 + +上面我介绍了很多日志包,每种日志包使用的场景不同,你可以根据自己的需求,结合日志包的特性进行选择: + + +标准库log包: 标准库log包不支持日志级别、日志分割、日志格式等功能,所以在大型项目中很少直接使用,通常用于一些短小的程序,比如用于生成JWT Token的main.go文件中。标准库日志包也很适合一些简短的代码,用于快速调试和验证。 +glog: glog实现了日志包的基本功能,非常适合一些对日志功能要求不多的小型项目。 +logrus: logrus功能强大,不仅实现了日志包的基本功能,还有很多高级特性,适合一些大型项目,尤其是需要结构化日志记录的项目。 +zap: zap提供了很强大的日志功能,性能高,内存分配次数少,适合对日志性能要求很高的项目。另外,zap包中的子包zapcore,提供了很多底层的日志接口,适合用来做二次封装。 + + +举个我自己选择日志包来进行二次开发的例子:我在做容器云平台开发时,发现Kubernetes源码中大量使用了glog,这时就需要日志包能够兼容glog。于是,我基于zap和zapcore封装了github.com/marmotedu/iam/pkg/log日志包,这个日志包可以很好地兼容glog。 + +在实际项目开发中,你可以根据项目需要,从上面几个日志包中进行选择,直接使用,但更多时候,你还需要基于这些包来进行定制开发。为了使你更深入地掌握日志包的设计和开发,接下来,我会从0到1带你开发一个日志包。 + +从0编写一个日志包 + +接下来,我会向你展示如何快速编写一个具备基本功能的日志包,让你通过这个简短的日志包实现掌握日志包的核心设计思路。该日志包主要实现以下几个功能: + + +支持自定义配置。 +支持文件名和行号。 +支持日志级别 Debug、Info、Warn、Error、Panic、Fatal。 +支持输出到本地文件和标准输出。 +支持JSON和TEXT格式的日志输出,支持自定义日志格式。 +支持选项模式。 + + +日志包名称为cuslog,示例项目完整代码存放在 cuslog。 + +具体实现分为以下四个步骤: + + +定义:定义日志级别和日志选项。 +创建:创建Logger及各级别日志打印方法。 +写入:将日志输出到支持的输出中。 +自定义:自定义日志输出格式。 + + +定义日志级别和日志选项 + +一个基本的日志包,首先需要定义好日志级别和日志选项。本示例将定义代码保存在options.go文件中。 + +可以通过如下方式定义日志级别: + +type Level uint8 + +const ( + DebugLevel Level = iota + InfoLevel + WarnLevel + ErrorLevel + PanicLevel + FatalLevel +) + +var LevelNameMapping = map[Level]string{ + DebugLevel: "DEBUG", + InfoLevel: "INFO", + WarnLevel: "WARN", + ErrorLevel: "ERROR", + PanicLevel: "PANIC", + FatalLevel: "FATAL", +} + + +在日志输出时,要通过对比开关级别和输出级别的大小,来决定是否输出,所以日志级别Level要定义成方便比较的数值类型。几乎所有的日志包都是用常量计数器iota来定义日志级别。 + +另外,因为要在日志输出中,输出可读的日志级别(例如输出INFO而不是1),所以需要有Level到Level Name的映射LevelNameMapping,LevelNameMapping会在格式化时用到。 + +接下来看定义日志选项。日志需要是可配置的,方便开发者根据不同的环境设置不同的日志行为,比较常见的配置选项为: + + +日志级别。 +输出位置,例如标准输出或者文件。 +输出格式,例如JSON或者Text。 +是否开启文件名和行号。 + + +本示例的日志选项定义如下: + +type options struct { + output io.Writer + level Level + stdLevel Level + formatter Formatter + disableCaller bool +} + + +为了灵活地设置日志的选项,你可以通过选项模式,来对日志选项进行设置: + +type Option func(*options) + +func initOptions(opts ...Option) (o *options) { + o = &options{} + for _, opt := range opts { + opt(o) + } + + if o.output == nil { + o.output = os.Stderr + } + + if o.formatter == nil { + o.formatter = &TextFormatter{} + } + + return +} + +func WithLevel(level Level) Option { + return func(o *options) { + o.level = level + } +} +... +func SetOptions(opts ...Option) { + std.SetOptions(opts...) +} + +func (l *logger) SetOptions(opts ...Option) { + l.mu.Lock() + defer l.mu.Unlock() + + for _, opt := range opts { + opt(l.opt) + } +} + + +具有选项模式的日志包,可通过以下方式,来动态地修改日志的选项: + +cuslog.SetOptions(cuslog.WithLevel(cuslog.DebugLevel)) + + +你可以根据需要,对每一个日志选项创建设置函数 WithXXXX 。这个示例日志包支持如下选项设置函数: + + +WithOutput(output io.Writer):设置输出位置。 +WithLevel(level Level):设置输出级别。 +WithFormatter(formatter Formatter):设置输出格式。 +WithDisableCaller(caller bool):设置是否打印文件名和行号。 + + +创建Logger及各级别日志打印方法 + +为了打印日志,我们需要根据日志配置,创建一个Logger,然后通过调用Logger的日志打印方法,完成各级别日志的输出。本示例将创建代码保存在logger.go文件中。 + +可以通过如下方式创建Logger: + +var std = New() + +type logger struct { + opt *options + mu sync.Mutex + entryPool *sync.Pool +} + +func New(opts ...Option) *logger { + logger := &logger{opt: initOptions(opts...)} + logger.entryPool = &sync.Pool{New: func() interface{} { return entry(logger) }} + return logger +} + + +上述代码中,定义了一个Logger,并实现了创建Logger的New函数。日志包都会有一个默认的全局Logger,本示例通过 var std = New() 创建了一个全局的默认Logger。cuslog.Debug、cuslog.Info和cuslog.Warnf等函数,则是通过调用std Logger所提供的方法来打印日志的。 + +定义了一个Logger之后,还需要给该Logger添加最核心的日志打印方法,要提供所有支持级别的日志打印方法。 + +如果日志级别是Xyz,则通常需要提供两类方法,分别是非格式化方法Xyz(args ...interface{})和格式化方法Xyzf(format string, args ...interface{}),例如: + +func (l *logger) Debug(args ...interface{}) { + l.entry().write(DebugLevel, FmtEmptySeparate, args...) +} +func (l *logger) Debugf(format string, args ...interface{}) { + l.entry().write(DebugLevel, format, args...) +} + + +本示例实现了如下方法:Debug、Debugf、Info、Infof、Warn、Warnf、Error、Errorf、Panic、Panicf、Fatal、Fatalf。更详细的实现,你可以参考 cuslog/logger.go。 + +这里要注意,Panic、Panicf要调用panic()函数,Fatal、Fatalf函数要调用 os.Exit(1) 函数。 + +将日志输出到支持的输出中 + +调用日志打印函数之后,还需要将这些日志输出到支持的输出中,所以需要实现write函数,它的写入逻辑保存在entry.go文件中。实现方式如下: + +type Entry struct { + logger *logger + Buffer *bytes.Buffer + Map map[string]interface{} + Level Level + Time time.Time + File string + Line int + Func string + Format string + Args []interface{} +} + +func (e *Entry) write(level Level, format string, args ...interface{}) { + if e.logger.opt.level > level { + return + } + e.Time = time.Now() + e.Level = level + e.Format = format + e.Args = args + if !e.logger.opt.disableCaller { + if pc, file, line, ok := runtime.Caller(2); !ok { + e.File = "???" + e.Func = "???" + } else { + e.File, e.Line, e.Func = file, line, runtime.FuncForPC(pc).Name() + e.Func = e.Func[strings.LastIndex(e.Func, "/")+1:] + } + } + e.format() + e.writer() + e.release() +} + +func (e *Entry) format() { + _ = e.logger.opt.formatter.Format(e) +} + +func (e *Entry) writer() { + e.logger.mu.Lock() + _, _ = e.logger.opt.output.Write(e.Buffer.Bytes()) + e.logger.mu.Unlock() +} + +func (e *Entry) release() { + e.Args, e.Line, e.File, e.Format, e.Func = nil, 0, "", "", "" + e.Buffer.Reset() + e.logger.entryPool.Put(e) +} + + +上述代码,首先定义了一个Entry结构体类型,该类型用来保存所有的日志信息,即日志配置和日志内容。写入逻辑都是围绕Entry类型的实例来完成的。 + +用Entry的write方法来完成日志的写入,在write方法中,会首先判断日志的输出级别和开关级别,如果输出级别小于开关级别,则直接返回,不做任何记录。 + +在write中,还会判断是否需要记录文件名和行号,如果需要则调用 runtime.Caller() 来获取文件名和行号,调用 runtime.Caller() 时,要注意传入正确的栈深度。 + +write函数中调用 e.format 来格式化日志,调用 e.writer 来写入日志,在创建Logger传入的日志配置中,指定了输出位置 output io.Writer ,output类型为 io.Writer ,示例如下: + +type Writer interface { + Write(p []byte) (n int, err error) +} + + +io.Writer实现了Write方法可供写入,所以只需要调用e.logger.opt.output.Write(e.Buffer.Bytes())即可将日志写入到指定的位置中。最后,会调用release()方法来清空缓存和对象池。至此,我们就完成了日志的记录和写入。 + +自定义日志输出格式 + +cuslog包支持自定义输出格式,并且内置了JSON和Text格式的Formatter。Formatter接口定义为: + +type Formatter interface { + Format(entry *Entry) error +} + + +cuslog内置的Formatter有两个:JSON和TEXT。 + +测试日志包 + +cuslog日志包开发完成之后,可以编写测试代码,调用cuslog包来测试cuslog包,代码如下: + +package main + +import ( + "log" + "os" + + "github.com/marmotedu/gopractise-demo/log/cuslog" +) + +func main() { + cuslog.Info("std log") + cuslog.SetOptions(cuslog.WithLevel(cuslog.DebugLevel)) + cuslog.Debug("change std log to debug level") + cuslog.SetOptions(cuslog.WithFormatter(&cuslog.JsonFormatter{IgnoreBasicFields: false})) + cuslog.Debug("log in json format") + cuslog.Info("another log in json format") + + // 输出到文件 + fd, err := os.OpenFile("test.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalln("create file test.log failed") + } + defer fd.Close() + + l := cuslog.New(cuslog.WithLevel(cuslog.InfoLevel), + cuslog.WithOutput(fd), + cuslog.WithFormatter(&cuslog.JsonFormatter{IgnoreBasicFields: false}), + ) + l.Info("custom log with json formatter") +} + + +将上述代码保存在main.go文件中,运行: + +$ go run example.go +2020-12-04T10:32:12+08:00 INFO example.go:11 std log +2020-12-04T10:32:12+08:00 DEBUG example.go:13 change std log to debug level +{"file":"/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/log/cuslog/example/example.go:15","func":"main.main","message":"log in json format","level":"DEBUG","time":"2020-12-04T10:32:12+08:00"} +{"level":"INFO","time":"2020-12-04T10:32:12+08:00","file":"/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/log/cuslog/example/example.go:16","func":"main.main","message":"another log in json format"} + + +到这里日志包就开发完成了,完整包见 log/cuslog。 + +IAM项目日志包设计 + +这一讲的最后,我们再来看下我们的IAM项目中,日志包是怎么设计的。 + +先来看一下IAM项目log包的存放位置:pkg/log。放在这个位置,主要有两个原因:第一个,log包属于IAM项目,有定制开发的内容;第二个,log包功能完备、成熟,外部项目也可以使用。 + +该log包是基于 go.uber.org/zap 包封装而来的,根据需要添加了更丰富的功能。接下来,我们通过log包的Options,来看下log包所实现的功能: + +type Options struct { + OutputPaths []string `json:"output-paths" mapstructure:"output-paths"` + ErrorOutputPaths []string `json:"error-output-paths" mapstructure:"error-output-paths"` + Level string `json:"level" mapstructure:"level"` + Format string `json:"format" mapstructure:"format"` + DisableCaller bool `json:"disable-caller" mapstructure:"disable-caller"` + DisableStacktrace bool `json:"disable-stacktrace" mapstructure:"disable-stacktrace"` + EnableColor bool `json:"enable-color" mapstructure:"enable-color"` + Development bool `json:"development" mapstructure:"development"` + Name string `json:"name" mapstructure:"name"` +} + + +Options各配置项含义如下: + + +development:是否是开发模式。如果是开发模式,会对DPanicLevel进行堆栈跟踪。 +name:Logger的名字。 +disable-caller:是否开启 caller,如果开启会在日志中显示调用日志所在的文件、函数和行号。 +disable-stacktrace:是否在Panic及以上级别禁止打印堆栈信息。 +enable-color:是否开启颜色输出,true,是;false,否。 +level:日志级别,优先级从低到高依次为:Debug, Info, Warn, Error, Dpanic, Panic, Fatal。 +format:支持的日志输出格式,目前支持Console和JSON两种。Console其实就是Text格式。 +output-paths:支持输出到多个输出,用逗号分开。支持输出到标准输出(stdout)和文件。 +error-output-paths:zap内部(非业务)错误日志输出路径,多个输出,用逗号分开。 + + +log包的Options结构体支持以下3个方法: + + +Build方法。Build方法可以根据Options构建一个全局的Logger。 +AddFlags方法。AddFlags方法可以将Options的各个字段追加到传入的pflag.FlagSet变量中。 +String方法。String方法可以将Options的值以JSON格式字符串返回。 + + +log包实现了以下3种日志记录方法: + +log.Info("This is a info message", log.Int32("int_key", 10)) +log.Infof("This is a formatted %s message", "info") +log.Infow("Message printed with Infow", "X-Request-ID", "fbf54504-64da-4088-9b86-67824a7fb508") + + +Info 使用指定的key/value记录日志。Infof 格式化记录日志。 Infow 也是使用指定的key/value记录日志,跟 Info 的区别是:使用 Info 需要指定值的类型,通过指定值的日志类型,日志库底层不需要进行反射操作,所以使用 Info 记录日志性能最高。 + +log包支持非常丰富的类型,具体你可以参考 types.go。 + +上述日志输出为: + +2021-07-06 14:02:07.070 INFO This is a info message {"int_key": 10} +2021-07-06 14:02:07.071 INFO This is a formatted info message +2021-07-06 14:02:07.071 INFO Message printed with Infow {"X-Request-ID": "fbf54504-64da-4088-9b86-67824a7fb508"} + + +log包为每种级别的日志都提供了3种日志记录方式,举个例子:假设日志格式为 Xyz ,则分别提供了 Xyz(msg string, fields ...Field) ,Xyzf(format string, v ...interface{}) ,Xyzw(msg string, keysAndValues ...interface{}) 3种日志记录方法。 + +另外,log包相较于一般的日志包,还提供了众多记录日志的方法。 + +第一个方法, log包支持V Level,可以通过整型数值来灵活指定日志级别,数值越大,优先级越低。例如: + +// V level使用 +log.V(1).Info("This is a V level message") +log.V(1).Infof("This is a %s V level message", "formatted") +log.V(1).Infow("This is a V level message with fields", "X-Request-ID", "7a7b9f24-4cae-4b2a-9464-69088b45b904") + + +这里要注意,Log.V只支持 Info 、Infof 、Infow三种日志记录方法。 + +第二个方法, log包支持WithValues函数,例如: + +// WithValues使用 +lv := log.WithValues("X-Request-ID", "7a7b9f24-4cae-4b2a-9464-69088b45b904") +lv.Infow("Info message printed with [WithValues] logger") +lv.Infow("Debug message printed with [WithValues] logger") + + +上述日志输出如下: + +2021-07-06 14:15:28.555 INFO Info message printed with [WithValues] logger {"X-Request-ID": "7a7b9f24-4cae-4b2a-9464-69088b45b904"} +2021-07-06 14:15:28.556 INFO Debug message printed with [WithValues] logger {"X-Request-ID": "7a7b9f24-4cae-4b2a-9464-69088b45b904"} + + +WithValues 可以返回一个携带指定key-value的Logger,供后面使用。 + +第三个方法, log包提供 WithContext 和 FromContext 用来将指定的Logger添加到某个Context中,以及从某个Context中获取Logger,例如: + +// Context使用 +ctx := lv.WithContext(context.Background()) +lc := log.FromContext(ctx) +lc.Info("Message printed with [WithContext] logger") + + +WithContext和FromContext非常适合用在以context.Context传递的函数中,例如: + +func main() { + + ... + + // WithValues使用 + lv := log.WithValues("X-Request-ID", "7a7b9f24-4cae-4b2a-9464-69088b45b904") + + // Context使用 + lv.Infof("Start to call pirntString") + ctx := lv.WithContext(context.Background()) + pirntString(ctx, "World") +} + +func pirntString(ctx context.Context, str string) { + lc := log.FromContext(ctx) + lc.Infof("Hello %s", str) +} + + +上述代码输出如下: + +2021-07-06 14:38:02.050 INFO Start to call pirntString {"X-Request-ID": "7a7b9f24-4cae-4b2a-9464-69088b45b904"} +2021-07-06 14:38:02.050 INFO Hello World {"X-Request-ID": "7a7b9f24-4cae-4b2a-9464-69088b45b904"} + + +将Logger添加到Context中,并通过Context在不同函数间传递,可以使key-value在不同函数间传递。例如上述代码中, X-Request-ID 在main函数、printString函数中的日志输出中均有记录,从而实现了一种调用链的效果。 + +第四个方法, 可以很方便地从Context中提取出指定的key-value,作为上下文添加到日志输出中,例如 internal/apiserver/api/v1/user/create.go文件中的日志调用: + +log.L(c).Info("user create function called.") + + +通过调用 Log.L() 函数,实现如下: + +// L method output with specified context value. +func L(ctx context.Context) *zapLogger { + return std.L(ctx) +} + +func (l *zapLogger) L(ctx context.Context) *zapLogger { + lg := l.clone() + + requestID, _ := ctx.Value(KeyRequestID).(string) + username, _ := ctx.Value(KeyUsername).(string) + lg.zapLogger = lg.zapLogger.With(zap.String(KeyRequestID, requestID), zap.String(KeyUsername, username)) + + return lg +} + + +L() 方法会从传入的Context中提取出 requestID 和 username ,追加到Logger中,并返回Logger。这时候调用该Logger的Info、Infof、Infow等方法记录日志,输出的日志中均包含 requestID 和 username 字段,例如: + +2021-07-06 14:46:00.743 INFO apiserver secret/create.go:23 create secret function called. {"requestID": "73144bed-534d-4f68-8e8d-dc8a8ed48507", "username": "admin"} + + +通过将Context在函数间传递,很容易就能实现调用链效果,例如: + +// Create add new secret key pairs to the storage. +func (s *SecretHandler) Create(c *gin.Context) { + log.L(c).Info("create secret function called.") + + ... + + secrets, err := s.srv.Secrets().List(c, username, metav1.ListOptions{ + Offset: pointer.ToInt64(0), + Limit: pointer.ToInt64(-1), + }) + + ... + + if err := s.srv.Secrets().Create(c, &r, metav1.CreateOptions{}); err != nil { + core.WriteResponse(c, err, nil) + + return + } + + core.WriteResponse(c, nil, r) +} + + +上述代码输出为: + +2021-07-06 14:46:00.743 INFO apiserver secret/create.go:23 create secret function called. {"requestID": "73144bed-534d-4f68-8e8d-dc8a8ed48507", "username": "admin"} +2021-07-06 14:46:00.744 INFO apiserver secret/create.go:23 list secret from storage. {"requestID": "73144bed-534d-4f68-8e8d-dc8a8ed48507", "username": "admin"} +2021-07-06 14:46:00.745 INFO apiserver secret/create.go:23 insert secret to storage. {"requestID": "73144bed-534d-4f68-8e8d-dc8a8ed48507", "username": "admin"} + + +这里要注意, log.L 函数默认会从Context中取 requestID 和 username 键,这跟IAM项目有耦合度,但这不影响log包供第三方项目使用。这也是我建议你自己封装日志包的原因。 + +总结 + +开发一个日志包,我们很多时候需要基于一些业界优秀的开源日志包进行二次开发。当前很多项目的日志包都是基于zap日志包来封装的,如果你有封装的需要,我建议你优先选择zap日志包。 + +这一讲中,我先给你介绍了标准库log包、glog、logrus和zap这四种常用的日志包,然后向你展现了开发一个日志包的四个步骤,步骤如下: + + +定义日志级别和日志选项。 +创建Logger及各级别日志打印方法。 +将日志输出到支持的输出中。 +自定义日志输出格式。 + + +最后,我介绍了IAM项目封装的log包的设计和使用方式。log包基于 go.uber.org/zap封装,并提供了以下强大特性: + + +log包支持V Level,可以灵活的通过整型数值来指定日志级别。 +log包支持 WithValues 函数, WithValues 可以返回一个携带指定key-value对的Logger,供后面使用。 +log包提供 WithContext 和 FromContext 用来将指定的Logger添加到某个Context中和从某个Context中获取Logger。 +log包提供了 Log.L() 函数,可以很方便的从Context中提取出指定的key-value对,作为上下文添加到日志输出中。 + + +课后练习 + + +尝试实现一个新的Formatter,可以使不同日志级别以不同颜色输出(例如:Error级别的日志输出中 Error 字符串用红色字体输出, Info 字符串用白色字体输出)。 + +尝试将runtime.Caller(2)函数调用中的 2 改成 1 ,看看日志输出是否跟修改前有差异,如果有差异,思考差异产生的原因。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/22应用构建三剑客:Pflag、Viper、Cobra核心功能介绍.md b/专栏/Go语言项目开发实战/22应用构建三剑客:Pflag、Viper、Cobra核心功能介绍.md new file mode 100644 index 0000000..df1f0c9 --- /dev/null +++ b/专栏/Go语言项目开发实战/22应用构建三剑客:Pflag、Viper、Cobra核心功能介绍.md @@ -0,0 +1,888 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 应用构建三剑客:Pflag、Viper、Cobra 核心功能介绍 + 你好,我是孔令飞。这一讲我们来聊聊构建应用时常用的Go包。 + +因为IAM项目使用了Pflag、Viper和Cobra包来构建IAM的应用框架,为了让你后面学习更加容易,这里简单介绍下这3个包的核心功能和使用方式。其实如果单独讲每个包的话,还是有很多功能可讲的,但我们这一讲的目的是减小你后面学习IAM源码的难度,所以我会主要介绍跟IAM相关的功能。 + +在正式介绍这三个包之前,我们先来看下如何构建应用的框架。 + +如何构建应用框架 + +想知道如何构建应用框架,首先你要明白,一个应用框架包含哪些部分。在我看来,一个应用框架需要包含以下3个部分: + + +命令行参数解析:主要用来解析命令行参数,这些命令行参数可以影响命令的运行效果。 +配置文件解析:一个大型应用,通常具有很多参数,为了便于管理和配置这些参数,通常会将这些参数放在一个配置文件中,供程序读取并解析。 +应用的命令行框架:应用最终是通过命令来启动的。这里有3个需求点,一是命令需要具备Help功能,这样才能告诉使用者如何去使用;二是命令需要能够解析命令行参数和配置文件;三是命令需要能够初始化业务代码,并最终启动业务进程。也就是说,我们的命令需要具备框架的能力,来纳管这3个部分。 + + +这3个部分的功能,你可以自己开发,也可以借助业界已有的成熟实现。跟之前的想法一样,我不建议你自己开发,更建议你采用业界已有的成熟实现。命令行参数可以通过Pflag来解析,配置文件可以通过Viper来解析,应用的命令行框架则可以通过Cobra来实现。这3个包目前也是最受欢迎的包,并且这3个包不是割裂的,而是有联系的,我们可以有机地组合这3个包,从而实现一个非常强大、优秀的应用命令行框架。 + +接下来,我们就来详细看下,这3个包在Go项目开发中是如何使用的。 + +命令行参数解析工具:Pflag使用介绍 + +Go服务开发中,经常需要给开发的组件加上各种启动参数来配置服务进程,影响服务的行为。像kube-apiserver就有多达200多个启动参数,而且这些参数的类型各不相同(例如:string、int、ip类型等),使用方式也不相同(例如:需要支持--长选项,-短选项等),所以我们需要一个强大的命令行参数解析工具。 + +虽然Go源码中提供了一个标准库Flag包,用来对命令行参数进行解析,但在大型项目中应用更广泛的是另外一个包:Pflag。Pflag提供了很多强大的特性,非常适合用来构建大型项目,一些耳熟能详的开源项目都是用Pflag来进行命令行参数解析的,例如:Kubernetes、Istio、Helm、Docker、Etcd等。 + +接下来,我们就来介绍下如何使用Pflag。Pflag主要是通过创建Flag和FlagSet来使用的。我们先来看下Flag。 + +Pflag包Flag定义 + +Pflag可以对命令行参数进行处理,一个命令行参数在Pflag包中会解析为一个Flag类型的变量。Flag是一个结构体,定义如下: + +type Flag struct { + Name string // flag长选项的名称 + Shorthand string // flag短选项的名称,一个缩写的字符 + Usage string // flag的使用文本 + Value Value // flag的值 + DefValue string // flag的默认值 + Changed bool // 记录flag的值是否有被设置过 + NoOptDefVal string // 当flag出现在命令行,但是没有指定选项值时的默认值 + Deprecated string // 记录该flag是否被放弃 + Hidden bool // 如果值为true,则从help/usage输出信息中隐藏该flag + ShorthandDeprecated string // 如果flag的短选项被废弃,当使用flag的短选项时打印该信息 + Annotations map[string][]string // 给flag设置注解 +} + + +Flag的值是一个Value类型的接口,Value定义如下: + +type Value interface { + String() string // 将flag类型的值转换为string类型的值,并返回string的内容 + Set(string) error // 将string类型的值转换为flag类型的值,转换失败报错 + Type() string // 返回flag的类型,例如:string、int、ip等 +} + + +通过将Flag的值抽象成一个interface接口,我们就可以自定义Flag的类型了。只要实现了Value接口的结构体,就是一个新类型。 + +Pflag包FlagSet定义 + +Pflag除了支持单个的Flag之外,还支持FlagSet。FlagSet是一些预先定义好的Flag的集合,几乎所有的Pflag操作,都需要借助FlagSet提供的方法来完成。在实际开发中,我们可以使用两种方法来获取并使用FlagSet: + + +方法一,调用NewFlagSet创建一个FlagSet。 +方法二,使用Pflag包定义的全局FlagSet:CommandLine。实际上CommandLine也是由NewFlagSet函数创建的。 + + +先来看下第一种方法,自定义FlagSet。下面是一个自定义FlagSet的示例: + +var version bool +flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) +flagSet.BoolVar(&version, "version", true, "Print version information and quit.") + + +我们可以通过定义一个新的FlagSet来定义命令及其子命令的Flag。 + +再来看下第二种方法,使用全局FlagSet。下面是一个使用全局FlagSet的示例: + +import ( + "github.com/spf13/pflag" +) + +pflag.BoolVarP(&version, "version", "v", true, "Print version information and quit.") + + +这其中,pflag.BoolVarP函数定义如下: + +func BoolVarP(p *bool, name, shorthand string, value bool, usage string) { + flag := CommandLine.VarPF(newBoolValue(value, p), name, shorthand, usage) + flag.NoOptDefVal = "true" +} + + +可以看到pflag.BoolVarP最终调用了CommandLine,CommandLine是一个包级别的变量,定义为: + +// CommandLine is the default set of command-line flags, parsed from os.Args. +var CommandLine = NewFlagSet(os.Args[0], ExitOnError) + + +在一些不需要定义子命令的命令行工具中,我们可以直接使用全局的FlagSet,更加简单方便。 + +Pflag使用方法 + +上面,我们介绍了使用Pflag包的两个核心结构体。接下来,我来详细介绍下Pflag的常见使用方法。Pflag有很多强大的功能,我这里介绍7个常见的使用方法。 + + +支持多种命令行参数定义方式。 + + +Pflag支持以下4种命令行参数定义方式: + + +支持长选项、默认值和使用文本,并将标志的值存储在指针中。 + + +var name = pflag.String("name", "colin", "Input Your Name") + + + +支持长选项、短选项、默认值和使用文本,并将标志的值存储在指针中。 + + +var name = pflag.StringP("name", "n", "colin", "Input Your Name") + + + +支持长选项、默认值和使用文本,并将标志的值绑定到变量。 + + +var name string +pflag.StringVar(&name, "name", "colin", "Input Your Name") + + + +支持长选项、短选项、默认值和使用文本,并将标志的值绑定到变量。 + + +var name string +pflag.StringVarP(&name, "name", "n","colin", "Input Your Name") + + +上面的函数命名是有规则的: + + +函数名带Var说明是将标志的值绑定到变量,否则是将标志的值存储在指针中。 +函数名带P说明支持短选项,否则不支持短选项。 + + + +使用Get获取参数的值。 + + +可以使用Get来获取标志的值,代表Pflag所支持的类型。例如:有一个pflag.FlagSet,带有一个名为flagname的int类型的标志,可以使用GetInt()来获取int值。需要注意flagname必须存在且必须是int,例如: + +i, err := flagset.GetInt("flagname") + + + +获取非选项参数。 + + +代码示例如下: + +package main + +import ( + "fmt" + + "github.com/spf13/pflag" +) + +var ( + flagvar = pflag.Int("flagname", 1234, "help message for flagname") +) + +func main() { + pflag.Parse() + + fmt.Printf("argument number is: %v\n", pflag.NArg()) + fmt.Printf("argument list is: %v\n", pflag.Args()) + fmt.Printf("the first argument is: %v\n", pflag.Arg(0)) +} + + +执行上述代码,输出如下: + +$ go run example1.go arg1 arg2 +argument number is: 2 +argument list is: [arg1 arg2] +the first argument is: arg1 + + +在定义完标志之后,可以调用pflag.Parse()来解析定义的标志。解析后,可通过pflag.Args()返回所有的非选项参数,通过pflag.Arg(i)返回第i个非选项参数。参数下标0到pflag.NArg() - 1。 + + +指定了选项但是没有指定选项值时的默认值。 + + +创建一个Flag后,可以为这个Flag设置pflag.NoOptDefVal。如果一个Flag具有NoOptDefVal,并且该Flag在命令行上没有设置这个Flag的值,则该标志将设置为NoOptDefVal指定的值。例如: + +var ip = pflag.IntP("flagname", "f", 1234, "help message") +pflag.Lookup("flagname").NoOptDefVal = "4321" + + +上面的代码会产生结果,具体你可以参照下表: + + + + +弃用标志或者标志的简写。 + + +Pflag可以弃用标志或者标志的简写。弃用的标志或标志简写在帮助文本中会被隐藏,并在使用不推荐的标志或简写时打印正确的用法提示。例如,弃用名为logmode的标志,并告知用户应该使用哪个标志代替: + +// deprecate a flag by specifying its name and a usage message +pflag.CommandLine.MarkDeprecated("logmode", "please use --log-mode instead") + + +这样隐藏了帮助文本中的logmode,并且当使用logmode时,打印了Flag --logmode has been deprecated, please use --log-mode instead。 + + +保留名为port的标志,但是弃用它的简写形式。 + + +pflag.IntVarP(&port, "port", "P", 3306, "MySQL service host port.") + +// deprecate a flag shorthand by specifying its flag name and a usage message +pflag.CommandLine.MarkShorthandDeprecated("port", "please use --port only") + + +这样隐藏了帮助文本中的简写P,并且当使用简写P时,打印了Flag shorthand -P has been deprecated, please use --port only。usage message在此处必不可少,并且不应为空。 + + +隐藏标志。 + + +可以将Flag标记为隐藏的,这意味着它仍将正常运行,但不会显示在usage/help文本中。例如:隐藏名为secretFlag的标志,只在内部使用,并且不希望它显示在帮助文本或者使用文本中。代码如下: + +// hide a flag by specifying its name +pflag.CommandLine.MarkHidden("secretFlag") + + +至此,我们介绍了Pflag包的重要用法。接下来,我们再来看下如何解析配置文件。 + +配置解析神器:Viper使用介绍 + +几乎所有的后端服务,都需要一些配置项来配置我们的服务,一些小型的项目,配置不是很多,可以选择只通过命令行参数来传递配置。但是大型项目配置很多,通过命令行参数传递就变得很麻烦,不好维护。标准的解决方案是将这些配置信息保存在配置文件中,由程序启动时加载和解析。Go生态中有很多包可以加载并解析配置文件,目前最受欢迎的是Viper包。 + +Viper是Go应用程序现代化的、完整的解决方案,能够处理不同格式的配置文件,让我们在构建现代应用程序时,不必担心配置文件格式。Viper也能够满足我们对应用配置的各种需求。 + +Viper可以从不同的位置读取配置,不同位置的配置具有不同的优先级,高优先级的配置会覆盖低优先级相同的配置,按优先级从高到低排列如下: + + +通过viper.Set函数显示设置的配置 +命令行参数 +环境变量 +配置文件 +Key/Value存储 +默认值 + + +这里需要注意,Viper配置键不区分大小写。 + +Viper有很多功能,最重要的两类功能是读入配置和读取配置,Viper提供不同的方式来实现这两类功能。接下来,我们就来详细介绍下Viper如何读入配置和读取配置。 + +读入配置 + +读入配置,就是将配置读入到Viper中,有如下读入方式: + + +设置默认的配置文件名。 +读取配置文件。 +监听和重新读取配置文件。 +从io.Reader读取配置。 +从环境变量读取。 +从命令行标志读取。 +从远程Key/Value存储读取。 + + +这几个方法的具体读入方式,你可以看下面的展示。 + + +设置默认值。 + + +一个好的配置系统应该支持默认值。Viper支持对key设置默认值,当没有通过配置文件、环境变量、远程配置或命令行标志设置key时,设置默认值通常是很有用的,可以让程序在没有明确指定配置时也能够正常运行。例如: + +viper.SetDefault("ContentDir", "content") +viper.SetDefault("LayoutDir", "layouts") +viper.SetDefault("Taxonomies", map[string]string{"tag": "tags", "category": "categories"}) + + + +读取配置文件。 + + +Viper可以读取配置文件来解析配置,支持JSON、TOML、YAML、YML、Properties、Props、Prop、HCL、Dotenv、Env格式的配置文件。Viper 支持搜索多个路径,并且默认不配置任何搜索路径,将默认决策留给应用程序。 + +以下是如何使用 Viper 搜索和读取配置文件的示例: + +package main + +import ( + "fmt" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +var ( + cfg = pflag.StringP("config", "c", "", "Configuration file.") + help = pflag.BoolP("help", "h", false, "Show this help message.") +) + +func main() { + pflag.Parse() + if *help { + pflag.Usage() + return + } + + // 从配置文件中读取配置 + if *cfg != "" { + viper.SetConfigFile(*cfg) // 指定配置文件名 + viper.SetConfigType("yaml") // 如果配置文件名中没有文件扩展名,则需要指定配置文件的格式,告诉viper以何种格式解析文件 + } else { + viper.AddConfigPath(".") // 把当前目录加入到配置文件的搜索路径中 + viper.AddConfigPath("$HOME/.iam") // 配置文件搜索路径,可以设置多个配置文件搜索路径 + viper.SetConfigName("config") // 配置文件名称(没有文件扩展名) + } + + if err := viper.ReadInConfig(); err != nil { // 读取配置文件。如果指定了配置文件名,则使用指定的配置文件,否则在注册的搜索路径中搜索 + panic(fmt.Errorf("Fatal error config file: %s \n", err)) + } + + fmt.Printf("Used configuration file is: %s\n", viper.ConfigFileUsed()) +} + + +Viper支持设置多个配置文件搜索路径,需要注意添加搜索路径的顺序,Viper会根据添加的路径顺序搜索配置文件,如果找到则停止搜索。如果调用SetConfigFile直接指定了配置文件名,并且配置文件名没有文件扩展名时,需要显式指定配置文件的格式,以使Viper能够正确解析配置文件。 + +如果通过搜索的方式查找配置文件,则需要注意,SetConfigName设置的配置文件名是不带扩展名的,在搜索时Viper会在文件名之后追加文件扩展名,并尝试搜索所有支持的扩展类型。 + + +监听和重新读取配置文件。 + + +Viper支持在运行时让应用程序实时读取配置文件,也就是热加载配置。可以通过WatchConfig函数热加载配置。在调用WatchConfig函数之前,需要确保已经添加了配置文件的搜索路径。另外,还可以为Viper提供一个回调函数,以便在每次发生更改时运行。这里我也给你个示例: + +viper.WatchConfig() +viper.OnConfigChange(func(e fsnotify.Event) { + // 配置文件发生变更之后会调用的回调函数 + fmt.Println("Config file changed:", e.Name) +}) + + +我不建议在实际开发中使用热加载功能,因为即使配置热加载了,程序中的代码也不一定会热加载。例如:修改了服务监听端口,但是服务没有重启,这时候服务还是监听在老的端口上,会造成不一致。- + + +设置配置值。 + + +我们可以通过viper.Set()函数来显式设置配置: + +viper.Set("user.username", "colin") + + + +使用环境变量。 + + +Viper还支持环境变量,通过如下5个函数来支持环境变量: + + +AutomaticEnv() +BindEnv(input …string) error +SetEnvPrefix(in string) +SetEnvKeyReplacer(r *strings.Replacer) +AllowEmptyEnv(allowEmptyEnv bool) + + +这里要注意:Viper读取环境变量是区分大小写的。Viper提供了一种机制来确保Env变量是唯一的。通过使用SetEnvPrefix,可以告诉Viper在读取环境变量时使用前缀。BindEnv和AutomaticEnv都将使用此前缀。比如,我们设置了viper.SetEnvPrefix(“VIPER”),当使用viper.Get(“apiversion”)时,实际读取的环境变量是VIPER_APIVERSION。 + +BindEnv需要一个或两个参数。第一个参数是键名,第二个是环境变量的名称,环境变量的名称区分大小写。如果未提供Env变量名,则Viper将假定Env变量名为:环境变量前缀_键名全大写。例如:前缀为VIPER,key为username,则Env变量名为VIPER_USERNAME。当显示提供Env变量名(第二个参数)时,它不会自动添加前缀。例如,如果第二个参数是ID,Viper将查找环境变量ID。 + +在使用Env变量时,需要注意的一件重要事情是:每次访问该值时都将读取它。Viper在调用BindEnv时不固定该值。 + +还有一个魔法函数SetEnvKeyReplacer,SetEnvKeyReplacer允许你使用strings.Replacer对象来重写Env键。如果你想在Get()调用中使用-或者.,但希望你的环境变量使用_分隔符,可以通过SetEnvKeyReplacer来实现。比如,我们设置了环境变量USER_SECRET_KEY=bVix2WBv0VPfrDrvlLWrhEdzjLpPCNYb,但我们想用viper.Get("user.secret-key"),那我们就调用函数: + +viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + + +上面的代码,在调用viper.Get()函数时,会用_替换.和-。默认情况下,空环境变量被认为是未设置的,并将返回到下一个配置源。若要将空环境变量视为已设置,可以使用AllowEmptyEnv方法。使用环境变量示例如下: + +// 使用环境变量 +os.Setenv("VIPER_USER_SECRET_ID", "QLdywI2MrmDVjSSv6e95weNRvmteRjfKAuNV") +os.Setenv("VIPER_USER_SECRET_KEY", "bVix2WBv0VPfrDrvlLWrhEdzjLpPCNYb") + +viper.AutomaticEnv() // 读取环境变量 +viper.SetEnvPrefix("VIPER") // 设置环境变量前缀:VIPER_,如果是viper,将自动转变为大写。 +viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) // 将viper.Get(key) key字符串中'.'和'-'替换为'_' +viper.BindEnv("user.secret-key") +viper.BindEnv("user.secret-id", "USER_SECRET_ID") // 绑定环境变量名到key + + + +使用标志。 + + +Viper支持Pflag包,能够绑定key到Flag。与BindEnv类似,在调用绑定方法时,不会设置该值,但在访问它时会设置。对于单个标志,可以调用BindPFlag()进行绑定: + +viper.BindPFlag("token", pflag.Lookup("token")) // 绑定单个标志 + + +还可以绑定一组现有的pflags(pflag.FlagSet): + +viper.BindPFlags(pflag.CommandLine) //绑定标志集 + + +读取配置 + +Viper提供了如下方法来读取配置: + + +Get(key string) interface{} +Get(key string) +AllSettings() map[string]interface{} +IsSet(key string) : bool + + +每一个Get方法在找不到值的时候都会返回零值。为了检查给定的键是否存在,可以使用IsSet()方法。可以是Viper支持的类型,首字母大写:Bool、Float64、Int、IntSlice、String、StringMap、StringMapString、StringSlice、Time、Duration。例如:GetInt()。 + +常见的读取配置方法有以下几种。 + + +访问嵌套的键。 + + +例如,加载下面的JSON文件: + +{ + "host": { + "address": "localhost", + "port": 5799 + }, + "datastore": { + "metric": { + "host": "127.0.0.1", + "port": 3099 + }, + "warehouse": { + "host": "198.0.0.1", + "port": 2112 + } + } +} + + +Viper可以通过传入.分隔的路径来访问嵌套字段: + +viper.GetString("datastore.metric.host") // (返回 "127.0.0.1") + + +如果datastore.metric被直接赋值覆盖(被Flag、环境变量、set()方法等等),那么datastore.metric的所有子键都将变为未定义状态,它们被高优先级配置级别覆盖了。 + +如果存在与分隔的键路径匹配的键,则直接返回其值。例如: + +{ + "datastore.metric.host": "0.0.0.0", + "host": { + "address": "localhost", + "port": 5799 + }, + "datastore": { + "metric": { + "host": "127.0.0.1", + "port": 3099 + }, + "warehouse": { + "host": "198.0.0.1", + "port": 2112 + } + } +} + + +通过viper.GetString获取值: + +viper.GetString("datastore.metric.host") // 返回 "0.0.0.0" + + + +反序列化。 + + +Viper可以支持将所有或特定的值解析到结构体、map等。可以通过两个函数来实现: + + +Unmarshal(rawVal interface{}) error +UnmarshalKey(key string, rawVal interface{}) error + + +一个示例: + +type config struct { + Port int + Name string + PathMap string `mapstructure:"path_map"` +} + +var C config + +err := viper.Unmarshal(&C) +if err != nil { + t.Fatalf("unable to decode into struct, %v", err) +} + + +如果想要解析那些键本身就包含.(默认的键分隔符)的配置,则需要修改分隔符: + +v := viper.NewWithOptions(viper.KeyDelimiter("::")) + +v.SetDefault("chart::values", map[string]interface{}{ + "ingress": map[string]interface{}{ + "annotations": map[string]interface{}{ + "traefik.frontend.rule.type": "PathPrefix", + "traefik.ingress.kubernetes.io/ssl-redirect": "true", + }, + }, +}) + +type config struct { + Chart struct{ + Values map[string]interface{} + } +} + +var C config + +v.Unmarshal(&C) + + +Viper在后台使用github.com/mitchellh/mapstructure来解析值,其默认情况下使用mapstructure tags。当我们需要将Viper读取的配置反序列到我们定义的结构体变量中时,一定要使用mapstructure tags。 + + +序列化成字符串。 + + +有时候我们需要将Viper中保存的所有设置序列化到一个字符串中,而不是将它们写入到一个文件中,示例如下: + +import ( + yaml "gopkg.in/yaml.v2" + // ... +) + +func yamlStringSettings() string { + c := viper.AllSettings() + bs, err := yaml.Marshal(c) + if err != nil { + log.Fatalf("unable to marshal config to YAML: %v", err) + } + return string(bs) +} + + +现代化的命令行框架:Cobra全解 + +Cobra既是一个可以创建强大的现代CLI应用程序的库,也是一个可以生成应用和命令文件的程序。有许多大型项目都是用Cobra来构建应用程序的,例如 Kubernetes、Docker、etcd、Rkt、Hugo等。 + +Cobra建立在commands、arguments和flags结构之上。commands代表命令,arguments代表非选项参数,flags代表选项参数(也叫标志)。一个好的应用程序应该是易懂的,用户可以清晰地知道如何去使用这个应用程序。应用程序通常遵循如下模式:APPNAME VERB NOUN --ADJECTIVE或者APPNAME COMMAND ARG --FLAG,例如: + +git clone URL --bare # clone 是一个命令,URL是一个非选项参数,bare是一个选项参数 + + +这里,VERB代表动词,NOUN代表名词,ADJECTIVE代表形容词。 + +Cobra提供了两种方式来创建命令:Cobra命令和Cobra库。Cobra命令可以生成一个Cobra命令模板,而命令模板也是通过引用Cobra库来构建命令的。所以,这里我直接介绍如何使用Cobra库来创建命令。 + +使用Cobra库创建命令 + +如果要用Cobra库编码实现一个应用程序,需要首先创建一个空的main.go文件和一个rootCmd文件,之后可以根据需要添加其他命令。具体步骤如下: + + +创建rootCmd。 + + +$ mkdir -p newApp2 && cd newApp2 + + +通常情况下,我们会将rootCmd放在文件cmd/root.go中。 + +var rootCmd = &cobra.Command{ + Use: "hugo", + Short: "Hugo is a very fast static site generator", + Long: `A Fast and Flexible Static Site Generator built with + love by spf13 and friends in Go. + Complete documentation is available at http://hugo.spf13.com`, + Run: func(cmd *cobra.Command, args []string) { + // Do Stuff Here + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + + +还可以在init()函数中定义标志和处理配置,例如cmd/root.go。 + +import ( + "fmt" + "os" + + homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + cfgFile string + projectBase string + userLicense string +) + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cobra.yaml)") + rootCmd.PersistentFlags().StringVarP(&projectBase, "projectbase", "b", "", "base project directory eg. github.com/spf13/") + rootCmd.PersistentFlags().StringP("author", "a", "YOUR NAME", "Author name for copyright attribution") + rootCmd.PersistentFlags().StringVarP(&userLicense, "license", "l", "", "Name of license for the project (can provide `licensetext` in config)") + rootCmd.PersistentFlags().Bool("viper", true, "Use Viper for configuration") + viper.BindPFlag("author", rootCmd.PersistentFlags().Lookup("author")) + viper.BindPFlag("projectbase", rootCmd.PersistentFlags().Lookup("projectbase")) + viper.BindPFlag("useViper", rootCmd.PersistentFlags().Lookup("viper")) + viper.SetDefault("author", "NAME HERE ") + viper.SetDefault("license", "apache") +} + +func initConfig() { + // Don't forget to read config either from cfgFile or from home directory! + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Find home directory. + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Search config in home directory with name ".cobra" (without extension). + viper.AddConfigPath(home) + viper.SetConfigName(".cobra") + } + + if err := viper.ReadInConfig(); err != nil { + fmt.Println("Can't read config:", err) + os.Exit(1) + } +} + + + +创建main.go。 + + +我们还需要一个main函数来调用rootCmd,通常我们会创建一个main.go文件,在main.go中调用rootCmd.Execute()来执行命令: + +package main + +import ( + "{pathToYourApp}/cmd" +) + +func main() { + cmd.Execute() +} + + +需要注意,main.go中不建议放很多代码,通常只需要调用cmd.Execute()即可。 + + +添加命令。 + + +除了rootCmd,我们还可以调用AddCommand添加其他命令,通常情况下,我们会把其他命令的源码文件放在cmd/目录下,例如,我们添加一个version命令,可以创建cmd/version.go文件,内容为: + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(versionCmd) +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of Hugo", + Long: `All software has versions. This is Hugo's`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Hugo Static Site Generator v0.9 -- HEAD") + }, +} + + +本示例中,我们通过调用rootCmd.AddCommand(versionCmd)给rootCmd命令添加了一个versionCmd命令。 + + +编译并运行。 + + +将main.go中{pathToYourApp}替换为对应的路径,例如本示例中pathToYourApp为github.com/marmotedu/gopractise-demo/cobra/newApp2。 + +$ go mod init github.com/marmotedu/gopractise-demo/cobra/newApp2 +$ go build -v . +$ ./newApp2 -h +A Fast and Flexible Static Site Generator built with +love by spf13 and friends in Go. +Complete documentation is available at http://hugo.spf13.com + +Usage: +hugo [flags] +hugo [command] + +Available Commands: +help Help about any command +version Print the version number of Hugo + +Flags: +-a, --author string Author name for copyright attribution (default "YOUR NAME") +--config string config file (default is $HOME/.cobra.yaml) +-h, --help help for hugo +-l, --license licensetext Name of license for the project (can provide licensetext in config) +-b, --projectbase string base project directory eg. github.com/spf13/ +--viper Use Viper for configuration (default true) + +Use "hugo [command] --help" for more information about a command. + + +通过步骤一、步骤二、步骤三,我们就成功创建和添加了Cobra应用程序及其命令。 + +接下来,我再来详细介绍下Cobra的核心特性。 + +使用标志 + +Cobra可以跟Pflag结合使用,实现强大的标志功能。使用步骤如下: + + +使用持久化的标志。 + + +标志可以是“持久的”,这意味着该标志可用于它所分配的命令以及该命令下的每个子命令。可以在rootCmd上定义持久标志: + +rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "verbose output") + + + +使用本地标志。 + + +也可以分配一个本地标志,本地标志只能在它所绑定的命令上使用: + +rootCmd.Flags().StringVarP(&Source, "source", "s", "", "Source directory to read from") + + +--source标志只能在rootCmd上引用,而不能在rootCmd的子命令上引用。 + + +将标志绑定到Viper。 + + +我们可以将标志绑定到Viper,这样就可以使用viper.Get()获取标志的值。 + +var author string + +func init() { + rootCmd.PersistentFlags().StringVar(&author, "author", "YOUR NAME", "Author name for copyright attribution") + viper.BindPFlag("author", rootCmd.PersistentFlags().Lookup("author")) +} + + + +设置标志为必选。 + + +默认情况下,标志是可选的,我们也可以设置标志为必选,当设置标志为必选,但是没有提供标志时,Cobra会报错。 + +rootCmd.Flags().StringVarP(&Region, "region", "r", "", "AWS region (required)") +rootCmd.MarkFlagRequired("region") + + +非选项参数验证 + +在命令的过程中,经常会传入非选项参数,并且需要对这些非选项参数进行验证,Cobra提供了机制来对非选项参数进行验证。可以使用Command的Args字段来验证非选项参数。Cobra也内置了一些验证函数: + + +NoArgs:如果存在任何非选项参数,该命令将报错。 +ArbitraryArgs:该命令将接受任何非选项参数。 +OnlyValidArgs:如果有任何非选项参数不在Command的ValidArgs字段中,该命令将报错。 +MinimumNArgs(int):如果没有至少N个非选项参数,该命令将报错。 +MaximumNArgs(int):如果有多于N个非选项参数,该命令将报错。 +ExactArgs(int):如果非选项参数个数不为N,该命令将报错。 +ExactValidArgs(int):如果非选项参数的个数不为N,或者非选项参数不在Command的ValidArgs字段中,该命令将报错。 +RangeArgs(min, max):如果非选项参数的个数不在min和max之间,该命令将报错。 + + +使用预定义验证函数,示例如下: + +var cmd = &cobra.Command{ + Short: "hello", + Args: cobra.MinimumNArgs(1), // 使用内置的验证函数 + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Hello, World!") + }, +} + + +当然你也可以自定义验证函数,示例如下: + +var cmd = &cobra.Command{ + Short: "hello", + // Args: cobra.MinimumNArgs(10), // 使用内置的验证函数 + Args: func(cmd *cobra.Command, args []string) error { // 自定义验证函数 + if len(args) < 1 { + return errors.New("requires at least one arg") + } + if myapp.IsValidColor(args[0]) { + return nil + } + return fmt.Errorf("invalid color specified: %s", args[0]) + }, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Hello, World!") + }, +} + + +PreRun and PostRun Hooks + +在运行Run函数时,我们可以运行一些钩子函数,比如PersistentPreRun和PreRun函数在Run函数之前执行,PersistentPostRun和PostRun在Run函数之后执行。如果子命令没有指定Persistent*Run函数,则子命令将会继承父命令的Persistent*Run函数。这些函数的运行顺序如下: + + +PersistentPreRun +PreRun +Run +PostRun +PersistentPostRun + + +注意,父级的PreRun只会在父级命令运行时调用,子命令是不会调用的。 + +Cobra还支持很多其他有用的特性,比如:自定义Help命令;可以自动添加--version标志,输出程序版本信息;当用户提供无效标志或无效命令时,Cobra可以打印出usage信息;当我们输入的命令有误时,Cobra会根据注册的命令,推算出可能的命令,等等。 + +总结 + +在开发Go项目时,我们可以通过Pflag来解析命令行参数,通过Viper来解析配置文件,用Cobra来实现命令行框架。你可以通过pflag.String()、 pflag.StringP()、pflag.StringVar()、pflag.StringVarP()方法来设置命令行参数,并使用Get来获取参数的值。 + +同时,你也可以使用Viper从命令行参数、环境变量、配置文件等位置读取配置项。最常用的是从配置文件中读取,可以通过viper.AddConfigPath来设置配置文件搜索路径,通过viper.SetConfigFile和viper.SetConfigType来设置配置文件名,通过viper.ReadInConfig来读取配置文件。读取完配置文件,然后在程序中使用Get/Get来读取配置项的值。 + +最后,你可以使用Cobra来构建一个命令行框架,Cobra可以很好地集成Pflag和Viper。 + +课后练习 + + +研究下Cobra的代码,看下Cobra是如何跟Pflag和Viper进行集成的。 + +思考下,除了Pflag、Viper、Cobra,你在开发过程中还遇到哪些优秀的包,来处理命令行参数、配置文件和启动命令行框架的呢?欢迎在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/23应用构建实战:如何构建一个优秀的企业应用框架?.md b/专栏/Go语言项目开发实战/23应用构建实战:如何构建一个优秀的企业应用框架?.md new file mode 100644 index 0000000..5f8c09a --- /dev/null +++ b/专栏/Go语言项目开发实战/23应用构建实战:如何构建一个优秀的企业应用框架?.md @@ -0,0 +1,504 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 应用构建实战:如何构建一个优秀的企业应用框架? + 你好,我是孔令飞。今天我们来聊聊开发应用必须要做的那些事儿。 + +应用开发是软件开发工程师最核心的工作。在我这 7 年的 Go 开发生涯中,我构建了大大小小不下 50 个后端应用,深谙其中的痛点,比如: + + +重复造轮子。同样的功能却每次都要重新开发,浪费非常多的时间和精力不说,每次实现的代码质量更是参差不齐。 +理解成本高。相同的功能,有 N 个服务对应着 N 种不同的实现方式,如果功能升级,或者有新成员加入,都可能得重新理解 N 次。 +功能升级的开发工作量大。一个应用由 N 个服务组成,如果要升级其中的某个功能,你需要同时更新 N 个服务的代码。 + + +想要解决上面这些问题,一个比较好的思路是:找出相同的功能,然后用一种优雅的方式去实现它,并通过 Go 包的形式,供所有的服务使用。 + +如果你也面临这些问题,并且正在寻找解决方法,那么你可以认真学习今天这一讲。我会带你找出服务的通用功能,并给出优雅的构建方式,帮助你一劳永逸地解决这些问题。在提高开发效率的同时,也能提高你的代码质量。 + +接下来,我们先来分析并找出 Go 服务通用的功能。 + +构建应用的基础:应用的三大基本功能 + +我们目前见到的 Go 后端服务,基本上可以分为 API 服务和非 API 服务两类。 + + +API 服务:通过对外提供 HTTP/RPC 接口来完成指定的功能。比如订单服务,通过调用创建订单的 API 接口,来创建商品订单。 +非 API 服务:通过监听、定时运行等方式,而不是通过 API 调用来完成某些任务。比如数据处理服务,定时从 Redis 中获取数据,处理后存入后端存储中。再比如消息处理服务,监听消息队列(如 NSQ/Kafka/RabbitMQ),收到消息后进行处理。 + + +对于 API 服务和非 API 服务来说,它们的启动流程基本一致,都可以分为三步: + + +应用框架的构建,这是最基础的一步。 +应用初始化。 +服务启动。 + + +如下图所示: + + + +图中,命令行程序、命令行参数解析和配置文件解析,是所有服务都需要具备的功能,这些功能有机结合到一起,共同构成了应用框架。 + +所以,我们要构建的任何一个应用程序,至少要具备命令行程序、命令行参数解析和配置文件解析这 3 种功能。 + + +命令行程序:用来启动一个应用。命令行程序需要实现诸如应用描述、help、参数校验等功能。根据需要,还可以实现命令自动补全、打印命令行参数等高级功能。 +命令行参数解析:用来在启动时指定应用程序的命令行参数,以控制应用的行为。 +配置文件解析:用来解析不同格式的配置文件。 + + +另外,上述 3 类功能跟业务关系不大,可以抽象成一个统一的框架。应用初始化、创建 API/非 API 服务、启动服务,跟业务联系比较紧密,难以抽象成一个统一的框架。 + +iam-apiserver 是如何构建应用框架的? + +这里,我通过讲解 iam-apiserver 的应用构建方式,来给你讲解下如何构建应用。iam-apiserver 程序的 main 函数位于 apiserver.go 文件中,其构建代码可以简化为: + +import ( + ... + "github.com/marmotedu/iam/internal/apiserver" + "github.com/marmotedu/iam/pkg/app" +) + +func main() { + ... + apiserver.NewApp("iam-apiserver").Run() +} + +const commandDesc = `The IAM API server validates and configures data ...` + +// NewApp creates a App object with default parameters. +func NewApp(basename string) *app.App { + opts := options.NewOptions() + application := app.NewApp("IAM API Server", + basename, + app.WithOptions(opts), + app.WithDescription(commandDesc), + app.WithDefaultValidArgs(), + app.WithRunFunc(run(opts)), + ) + + return application +} + +func run(opts *options.Options) app.RunFunc { + return func(basename string) error { + log.Init(opts.Log) + defer log.Flush() + + cfg, err := config.CreateConfigFromOptions(opts) + if err != nil { + return err + } + + return Run(cfg) + } +} + + +可以看到,我们是通过调用包 github.com/marmotedu/iam/pkg/app 来构建应用的。也就是说,我们将构建应用的功能抽象成了一个 Go 包,通过 Go 包可以提高代码的封装性和复用性。iam-authz-server 和 iam-pump 组件也都是通过 github.com/marmotedu/iam/pkg/app 来构建应用的。 + +构建应用的流程也很简单,只需要创建一个 application 实例即可: + +opts := options.NewOptions() +application := app.NewApp("IAM API Server", + basename, + app.WithOptions(opts), + app.WithDescription(commandDesc), + app.WithDefaultValidArgs(), + app.WithRunFunc(run(opts)), +) + + +在创建应用实例时,我传入了下面这些参数。 + + +IAM API Server:应用的简短描述。 +basename:应用的二进制文件名。 +opts:应用的命令行选项。 +commandDesc:应用的详细描述。 +run(opts):应用的启动函数,初始化应用,并最终启动 HTTP 和 GRPC Web 服务。 + + +创建应用时,你还可以根据需要来配置应用实例,比如 iam-apiserver 组件在创建应用时,指定了 WithDefaultValidArgs 来校验命令行非选项参数的默认校验逻辑。 + +可以看到,iam-apiserver 通过简单的几行代码,就创建出了一个应用。之所以这么方便,是因为应用框架的构建代码都封装在了 github.com/marmotedu/iam/pkg/app 包中。接下来,我们来重点看下 github.com/marmotedu/iam/pkg/app 包是如何实现的。为了方便描述,我在下文中统称为 App 包。 + +App 包设计和实现 + +我们先来看下 App 包目录下的文件: + +[colin@dev iam]$ ls pkg/app/ +app.go cmd.go config.go doc.go flag.go help.go options.go + + +pkg/app 目录下的 5 个主要文件是 app.go、cmd.go、config.go、flag.go、options.go,分别实现了应用程序框架中的应用、命令行程序、命令行参数解析、配置文件解析和命令行选项 5 个部分,具体关系如下图所示: + + + +我再来解释下这张图。应用由命令行程序、命令行参数解析、配置文件解析三部分组成,命令行参数解析功能通过命令行选项来构建,二者通过接口解耦合: + +type CliOptions interface { + // AddFlags adds flags to the specified FlagSet object. + // AddFlags(fs *pflag.FlagSet) + Flags() (fss cliflag.NamedFlagSets) + Validate() []error +} + + +通过接口,应用可以定制自己独有的命令行参数。接下来,我们再来看下如何具体构建应用的每一部分。 + +第 1 步:构建应用 + +APP 包提供了 NewApp 函数来创建一个应用: + +func NewApp(name string, basename string, opts ...Option) *App { + a := &App{ + name: name, + basename: basename, + } + + for _, o := range opts { + o(a) + } + + a.buildCommand() + + return a +} + + +NewApp 中使用了设计模式中的选项模式,来动态地配置 APP,支持 WithRunFunc、WithDescription、WithValidArgs 等选项。 + +第 2 步:命令行程序构建 + +这一步,我们会使用 Cobra 包来构建应用的命令行程序。 + +NewApp 最终会调用 buildCommand 方法来创建 Cobra Command 类型的命令,命令的功能通过指定 Cobra Command 类型的各个字段来实现。通常可以指定:Use、Short、Long、SilenceUsage、SilenceErrors、RunE、Args 等字段。 + +在 buildCommand 函数中,也会根据应用的设置添加不同的命令行参数,例如: + +if !a.noConfig { + addConfigFlag(a.basename, namedFlagSets.FlagSet("global")) +} + + +上述代码的意思是:如果我们设置了 noConfig=false,那么就会在命令行参数 global 分组中添加以下命令行选项: + +-c, --config FILE Read configuration from specified FILE, support JSON, TOML, YAML, HCL, or Java properties formats. + + +为了更加易用和人性化,命令还具有如下 3 个功能。 + + +帮助信息:执行 -h/--help 时,输出的帮助信息。通过 cmd.SetHelpFunc 函数可以指定帮助信息。 +使用信息(可选):当用户提供无效的标志或命令时,向用户显示“使用信息”。通过 cmd.SetUsageFunc 函数,可以指定使用信息。如果不想每次输错命令打印一大堆 usage 信息,你可以通过设置 SilenceUsage: true 来关闭掉 usage。 +版本信息:打印应用的版本。知道应用的版本号,对故障排查非常有帮助。通过 verflag.AddFlags 可以指定版本信息。例如,App 包通过 github.com/marmotedu/component-base/pkg/version 指定了以下版本信息: + + +$ ./iam-apiserver --version + gitVersion: v0.3.0 + gitCommit: ccc31e292f66e6bad94efb1406b5ced84e64675c +gitTreeState: dirty + buildDate: 2020-12-17T12:24:37Z + goVersion: go1.15.1 + compiler: gc + platform: linux/amd64 +$ ./iam-apiserver --version=raw +version.Info{GitVersion:"v0.3.0", GitCommit:"ccc31e292f66e6bad94efb1406b5ced84e64675c", GitTreeState:"dirty", BuildDate:"2020-12-17T12:24:37Z", GoVersion:"go1.15.1", Compiler:"gc", Platform:"linux/amd64"} + + +接下来,再来看下应用需要实现的另外一个重要功能,也就是命令行参数解析。 + +第 3 步:命令行参数解析 + +App 包在构建应用和执行应用两个阶段来实现命令行参数解析。 + +我们先看构建应用这个阶段。App 包在 buildCommand 方法中通过以下代码段,给应用添加了命令行参数: + +var namedFlagSets cliflag.NamedFlagSets +if a.options != nil { + namedFlagSets = a.options.Flags() + fs := cmd.Flags() + for _, f := range namedFlagSets.FlagSets { + fs.AddFlagSet(f) + } + + ... +} + +if !a.noVersion { + verflag.AddFlags(namedFlagSets.FlagSet("global")) +} +if !a.noConfig { + addConfigFlag(a.basename, namedFlagSets.FlagSet("global")) +} +globalflag.AddGlobalFlags(namedFlagSets.FlagSet("global"), cmd.Name()) + + +namedFlagSets 中引用了 Pflag 包,上述代码先通过 a.options.Flags() 创建并返回了一批 FlagSet,a.options.Flags() 函数会将 FlagSet 进行分组。通过一个 for 循环,将 namedFlagSets 中保存的 FlagSet 添加到 Cobra 应用框架中的 FlagSet 中。 + +buildCommand 还会根据应用的配置,选择性添加一些 flag。例如,在 global 分组下添加 --version 和 --config 选项。 + +执行 -h 打印命令行参数如下: + +.. + +Usage: + iam-apiserver [flags] + +Generic flags: + + --server.healthz Add self readiness check and install /healthz router. (default true) + --server.max-ping-count int The max number of ping attempts when server failed to startup. (default 3) + +... + +Global flags: + + -h, --help help for iam-apiserver + --version version[=true] Print version information and quit. + + +这里有两个技巧,你可以借鉴。 + +第一个技巧,将 flag 分组。 + +一个大型系统,可能会有很多个 flag,例如 kube-apiserver 就有 200 多个 flag,这时对 flag 分组就很有必要了。通过分组,我们可以很快地定位到需要的分组及该分组具有的标志。例如,我们想了解 MySQL 有哪些标志,可以找到 MySQL 分组: + +Mysql flags: + + --mysql.database string + Database name for the server to use. + --mysql.host string + MySQL service host address. If left blank, the following related mysql options will be ignored. (default "127.0.0.1:3306") + --mysql.log-mode int + Specify gorm log level. (default 1) + ... + + +第二个技巧,flag 的名字带有层级关系。这样不仅可以知道该 flag 属于哪个分组,而且能够避免重名。例如: + +$ ./iam-apiserver -h |grep host + --mysql.host string MySQL service host address. If left blank, the following related mysql options will be ignored. (default "127.0.0.1:3306") + --redis.host string Hostname of your Redis server. (default "127.0.0.1") + + +对于 MySQL 和 Redis, 都可以指定相同的 host 标志,通过 --mysql.host 也可以知道该 flag 隶属于 mysql 分组,代表的是 MySQL 的 host。 + +我们再看应用执行阶段。这时会通过 viper.Unmarshal,将配置 Unmarshal 到 Options 变量中。这样我们就可以使用 Options 变量中的值,来执行后面的业务逻辑。 + +我们传入的 Options 是一个实现了 CliOptions 接口的结构体变量,CliOptions 接口定义为: + +type CliOptions interface { + Flags() (fss cliflag.NamedFlagSets) + Validate() []error +} + + +因为 Options 实现了 Validate 方法,所以我们就可以在应用框架中调用 Validate 方法来校验参数是否合法。另外,我们还可以通过以下代码,来判断选项是否可补全和打印:如果可以补全,则补全选项;如果可以打印,则打印选项的内容。实现代码如下: + +func (a *App) applyOptionRules() error { + if completeableOptions, ok := a.options.(CompleteableOptions); ok { + if err := completeableOptions.Complete(); err != nil { + return err + } + } + + if errs := a.options.Validate(); len(errs) != 0 { + return errors.NewAggregate(errs) + } + + if printableOptions, ok := a.options.(PrintableOptions); ok && !a.silence { + log.Infof("%v Config: `%s`", progressMessage, printableOptions.String()) + } + + return nil +} + + +通过配置补全,可以确保一些重要的配置项具有默认值,当这些配置项没有被配置时,程序也仍然能够正常启动。一个大型项目,有很多配置项,我们不可能对每一个配置项都进行配置。所以,给重要配置项设置默认值,就显得很重要了。 + +这里,我们来看下 iam-apiserver 提供的 Validate 方法: + +func (s *ServerRunOptions) Validate() []error { + var errs []error + + errs = append(errs, s.GenericServerRunOptions.Validate()...) + errs = append(errs, s.GrpcOptions.Validate()...) + errs = append(errs, s.InsecureServing.Validate()...) + errs = append(errs, s.SecureServing.Validate()...) + errs = append(errs, s.MySQLOptions.Validate()...) + errs = append(errs, s.RedisOptions.Validate()...) + errs = append(errs, s.JwtOptions.Validate()...) + errs = append(errs, s.Log.Validate()...) + errs = append(errs, s.FeatureOptions.Validate()...) + + return errs +} + + +可以看到,每个配置分组,都实现了 Validate() 函数,对自己负责的配置进行校验。通过这种方式,程序会更加清晰。因为只有配置提供者才更清楚如何校验自己的配置项,所以最好的做法是将配置的校验放权给配置提供者(分组)。 + +第 4 步:配置文件解析 + +在 buildCommand 函数中,通过addConfigFlag调用,添加了 -c, --config FILE 命令行参数,用来指定配置文件: + +addConfigFlag(a.basename, namedFlagSets.FlagSet("global")) + + +addConfigFlag函数代码如下: + +func addConfigFlag(basename string, fs *pflag.FlagSet) { + fs.AddFlag(pflag.Lookup(configFlagName)) + + viper.AutomaticEnv() + viper.SetEnvPrefix(strings.Replace(strings.ToUpper(basename), "-", "_", -1)) + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + + cobra.OnInitialize(func() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.AddConfigPath(".") + + if names := strings.Split(basename, "-"); len(names) > 1 { + viper.AddConfigPath(filepath.Join(homedir.HomeDir(), "."+names[0])) + } + + viper.SetConfigName(basename) + } + + if err := viper.ReadInConfig(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: failed to read configuration file(%s): %v\n", cfgFile, err) + os.Exit(1) + } + }) +} + + +addConfigFlag 函数中,指定了 Cobra Command 在执行命令之前,需要做的初始化工作: + +func() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.AddConfigPath(".") + + if names := strings.Split(basename, "-"); len(names) > 1 { + viper.AddConfigPath(filepath.Join(homedir.HomeDir(), "."+names[0])) + } + + viper.SetConfigName(basename) + } + + if err := viper.ReadInConfig(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: failed to read configuration file(%s): %v\n", cfgFile, err) + os.Exit(1) + } +} + + +上述代码实现了以下功能: + + +如果命令行参数中没有指定配置文件的路径,则加载默认路径下的配置文件,通过 viper.AddConfigPath、viper.SetConfigName 来设置配置文件搜索路径和配置文件名。通过设置默认的配置文件,可以使我们不用携带任何命令行参数,即可运行程序。 +支持环境变量,通过 viper.SetEnvPrefix 来设置环境变量前缀,避免跟系统中的环境变量重名。通过 viper.SetEnvKeyReplacer 重写了 Env 键。 + + +上面,我们给应用添加了配置文件的命令行参数,并设置在命令执行前,读取配置文件。在命令执行时,会将配置文件中的配置项和命令行参数绑定,并将 Viper 的配置 Unmarshal 到传入的 Options 中: + +if !a.noConfig { + if err := viper.BindPFlags(cmd.Flags()); err != nil { + return err + } + + if err := viper.Unmarshal(a.options); err != nil { + return err + } +} + + +Viper 的配置是命令行参数和配置文件配置 merge 后的配置。如果在配置文件中指定了 MySQL 的 host 配置,并且也同时指定了 --mysql.host 参数,则会优先取命令行参数设置的值。这里需要注意的是,不同于 YAML 格式的分级方式,配置项是通过点号 . 来分级的。 + +至此,我们已经成功构建了一个优秀的应用框架,接下来我们看下这个应用框架具有哪些优点吧。 + +这样构建的应用程序,有哪些优秀特性? + +借助 Cobra 自带的能力,构建出的应用天然具备帮助信息、使用信息、子命令、子命令自动补全、非选项参数校验、命令别名、PreRun、PostRun 等功能,这些功能对于一个应用来说是非常有用的。 + +Cobra 可以集成 Pflag,通过将创建的 Pflag FlagSet 绑定到 Cobra 命令的 FlagSet 中,使得 Pflag 支持的标志能直接集成到 Cobra 命令中。集成到命令中有很多好处,例如:cobra -h 可以打印出所有设置的 flag,Cobra Command 命令提供的 GenBashCompletion 方法,可以实现命令行选项的自动补全。 + +通过 viper.BindPFlags 和 viper.ReadInConfig 函数,可以统一配置文件、命令行参数的配置项,使得应用的配置项更加清晰好记。面对不同场景可以选择不同的配置方式,使配置更加灵活。例如:配置 HTTPS 的绑定端口,可以通过 --secure.bind-port 配置,也可以通过配置文件配置(命令行参数优先于配置文件): + +secure: + bind-port: 8080 + + +可以通过 viper.GetString("secure.bind-port") 这类方式获取应用的配置,获取方式更加灵活,而且全局可用。 + +将应用框架的构建方法实现成了一个 Go 包,通过 Go 包可以提高应用构建代码的封装性和复用性。 + +如果你想自己构建应用,需要注意些什么? + +当然,你也可以使用其他方式构建你的应用程序。比如,我就见过很多开发者使用如下方式来构建应用:直接在 main.go 文件中通过 gopkg.in/yaml.v3 包解析配置,通过 Go 标准库的 flag 包简单地添加一些命令行参数,例如--help、--config、--version。 + +但是,在你自己独立构建应用程序时,很可能会踩这么 3 个坑: + + +构建的应用功能简单,扩展性差,导致后期扩展复杂。 +构建的应用没有帮助信息和使用信息,或者信息格式杂乱,增加应用的使用难度。 +命令行选项和配置文件支持的配置项相互独立,导致配合应用程序的时候,不知道该使用哪种方式来配置。 + + +在我看来,对于小的应用,自己根据需要构建没什么问题,但是对于一个大型项目的话,还是在应用开发之初,就采用一些功能多、扩展性强的优秀包。这样,以后随着应用的迭代,可以零成本地进行功能添加和扩展,同时也能体现我们的专业性和技术深度,提高代码质量。 + +如果你有特殊需求,一定要自己构建应用框架,那么我有以下几个建议: + + +应用框架应该清晰易读、扩展性强。 +应用程序应该至少支持如下命令行选项:-h 打印帮助信息;-v 打印应用程序的版本;-c 支持指定配置文件的路径。 +如果你的应用有很多命令行选项,那么建议支持 --secure.bind-port 这样的长选项,通过选项名字,就可以知道选项的作用。 +配置文件使用 yaml 格式,yaml 格式的配置文件,能支持复杂的配置,还清晰易读。 +如果你有多个服务,那么要保持所有服务的应用构建方式是一致的。 + + +总结 + +一个应用框架由命令、命令行参数解析、配置文件解析 3 部分功能组成,我们可以通过 Cobra 来构建命令,通过 Pflag 来解析命令行参数,通过 Viper 来解析配置文件。一个项目,可能包含多个应用,这些应用都需要通过 Cobra、Viper、Pflag 来构建。为了不重复造轮子,简化应用的构建,我们可以将这些功能实现为一个 Go 包,方便直接调用构建应用。 + +IAM 项目的应用都是通过 github.com/marmotedu/iam/pkg/app 包来构建的,在构建时,调用 App 包提供的 NewApp 函数,来构建一个应用: + +func NewApp(basename string) *app.App { + opts := options.NewOptions() + application := app.NewApp("IAM API Server", + basename, + app.WithOptions(opts), + app.WithDescription(commandDesc), + app.WithDefaultValidArgs(), + app.WithRunFunc(run(opts)), + ) + + return application +} + + +在构建应用时,只需要提供应用简短/详细描述、应用二进制文件名称和命令行选项即可。App 包会根据 Options 提供的 Flags() 方法,来给应用添加命令行选项。命令行选项中提供了 -c, --config 选项来指定配置文件,App 包也会加载并解析这个配置文件,并将配置文件和命令行选项相同配置项进行 Merge,最终将配置项的值保存在传入的 Options 变量中,供业务代码使用。 + +最后,如果你想自己构建应用,我给出了一些我的建议:设计一个清晰易读、易扩展的应用框架;支持一些常见的选项,例如 -h, -v, -c 等;如果应用的命令行选项比较多,建议使用 --secure.bind-port 这样的长选项。 + +课后练习 + + +除了 Cobra、Viper、Pflag 之外,你还遇到过哪些比较优秀的包或者工具,可以用来构建应用框架?欢迎在留言区分享。 +研究下 iam-apiserver 的命令行选项 Options 是如何通过 Options 的 Flags()方法来实现 Flag 分组的,并思考下这样做有什么好处。 + + +欢迎你在留言区与我交流讨论。当然了,你也可以把这一讲分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/24Web服务:Web服务核心功能有哪些,如何实现?.md b/专栏/Go语言项目开发实战/24Web服务:Web服务核心功能有哪些,如何实现?.md new file mode 100644 index 0000000..08d1331 --- /dev/null +++ b/专栏/Go语言项目开发实战/24Web服务:Web服务核心功能有哪些,如何实现?.md @@ -0,0 +1,756 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 Web 服务:Web 服务核心功能有哪些,如何实现? + 你好,我是孔令飞。从今天开始,我们进入实战第三站:服务开发。在这个部分,我会讲解 IAM项目各个服务的构建方式,帮助你掌握Go 开发阶段的各个技能点。 + +在Go项目开发中,绝大部分情况下,我们是在写能提供某种功能的后端服务,这些功能以RPC API 接口或者RESTful API接口的形式对外提供,能提供这两种API接口的服务也统称为Web服务。今天这一讲,我就通过介绍RESTful API风格的Web服务,来给你介绍下如何实现Web服务的核心功能。 + +那今天我们就来看下,Web服务的核心功能有哪些,以及如何开发这些功能。 + +Web服务的核心功能 + +Web服务有很多功能,为了便于你理解,我将这些功能分成了基础功能和高级功能两大类,并总结在了下面这张图中: + + + +下面,我就按图中的顺序,来串讲下这些功能。 + +要实现一个Web服务,首先我们要选择通信协议和通信格式。在Go项目开发中,有HTTP+JSON 和 gRPC+Protobuf两种组合可选。因为iam-apiserver主要提供的是REST风格的API接口,所以选择的是HTTP+JSON组合。 + +Web服务最核心的功能是路由匹配。路由匹配其实就是根据(HTTP方法, 请求路径)匹配到处理这个请求的函数,最终由该函数处理这次请求,并返回结果,过程如下图所示: + + + +一次HTTP请求经过路由匹配,最终将请求交由Delete(c *gin.Context)函数来处理。变量c中存放了这次请求的参数,在Delete函数中,我们可以进行参数解析、参数校验、逻辑处理,最终返回结果。 + +对于大型系统,可能会有很多个API接口,API接口随着需求的更新迭代,可能会有多个版本,为了便于管理,我们需要对路由进行分组。 + +有时候,我们需要在一个服务进程中,同时开启HTTP服务的80端口和HTTPS的443端口,这样我们就可以做到:对内的服务,访问80端口,简化服务访问复杂度;对外的服务,访问更为安全的HTTPS服务。显然,我们没必要为相同功能启动多个服务进程,所以这时候就需要Web服务能够支持一进程多服务的功能。 + +我们开发Web服务最核心的诉求是:输入一些参数,校验通过后,进行业务逻辑处理,然后返回结果。所以Web服务还应该能够进行参数解析、参数校验、逻辑处理、返回结果。这些都是Web服务的业务处理功能。 + +上面这些是Web服务的基本功能,此外,我们还需要支持一些高级功能。 + +在进行HTTP请求时,经常需要针对每一次请求都设置一些通用的操作,比如添加Header、添加RequestID、统计请求次数等,这就要求我们的Web服务能够支持中间件特性。 + +为了保证系统安全,对于每一个请求,我们都需要进行认证。Web服务中,通常有两种认证方式,一种是基于用户名和密码,一种是基于Token。认证通过之后,就可以继续处理请求了。 + +为了方便定位和跟踪某一次请求,需要支持RequestID,定位和跟踪RequestID主要是为了排障。 + +最后,当前的软件架构中,很多采用了前后端分离的架构。在前后端分离的架构中,前端访问地址和后端访问地址往往是不同的,浏览器为了安全,会针对这种情况设置跨域请求,所以Web服务需要能够处理浏览器的跨域请求。 + +到这里,我就把Web服务的基础功能和高级功能串讲了一遍。当然,上面只介绍了Web服务的核心功能,还有很多其他的功能,你可以通过学习Gin的官方文档来了解。 + +你可以看到,Web服务有很多核心功能,这些功能我们可以基于net/http包自己封装。但在实际的项目开发中, 我们更多会选择使用基于net/http包进行封装的优秀开源Web框架。本实战项目选择了Gin框架。 + +接下来,我们主要看下Gin框架是如何实现以上核心功能的,这些功能我们在实际的开发中可以直接拿来使用。 + +为什么选择Gin框架? + +优秀的Web框架有很多,我们为什么要选择Gin呢?在回答这个问题之前,我们先来看下选择Web框架时的关注点。 + +在选择Web框架时,我们可以关注如下几点: + + +路由功能; +是否具备middleware/filter能力; +HTTP 参数(path、query、form、header、body)解析和返回; +性能和稳定性; +使用复杂度; +社区活跃度。 + + +按 GitHub Star 数来排名,当前比较火的 Go Web 框架有 Gin、Beego、Echo、Revel 、Martini。经过调研,我从中选择了Gin框架,原因是Gin具有如下特性: + + +轻量级,代码质量高,性能比较高; +项目目前很活跃,并有很多可用的 Middleware; +作为一个 Web 框架,功能齐全,使用起来简单。 + + +那接下来,我就先详细介绍下Gin框架。 + +Gin是用Go语言编写的Web框架,功能完善,使用简单,性能很高。Gin核心的路由功能是通过一个定制版的HttpRouter来实现的,具有很高的路由性能。 + +Gin有很多功能,这里我给你列出了它的一些核心功能: + + +支持HTTP方法:GET、POST、PUT、PATCH、DELETE、OPTIONS。 +支持不同位置的HTTP参数:路径参数(path)、查询字符串参数(query)、表单参数(form)、HTTP头参数(header)、消息体参数(body)。 +支持HTTP路由和路由分组。 +支持middleware和自定义middleware。 +支持自定义Log。 +支持binding和validation,支持自定义validator。可以bind如下参数:query、path、body、header、form。 +支持重定向。 +支持basic auth middleware。 +支持自定义HTTP配置。 +支持优雅关闭。 +支持HTTP2。 +支持设置和获取cookie。 + + +Gin是如何支持Web服务基础功能的? + +接下来,我们先通过一个具体的例子,看下Gin是如何支持Web服务基础功能的,后面再详细介绍这些功能的用法。 + +我们创建一个webfeature目录,用来存放示例代码。因为要演示HTTPS的用法,所以需要创建证书文件。具体可以分为两步。 + +第一步,执行以下命令创建证书: + +cat << 'EOF' > ca.pem +-----BEGIN CERTIFICATE----- +MIICSjCCAbOgAwIBAgIJAJHGGR4dGioHMA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQxDzANBgNVBAMTBnRlc3RjYTAeFw0xNDExMTEyMjMxMjla +Fw0yNDExMDgyMjMxMjlaMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0 +YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMT +BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwEDfBV5MYdlHVHJ7 ++L4nxrZy7mBfAVXpOc5vMYztssUI7mL2/iYujiIXM+weZYNTEpLdjyJdu7R5gGUu +g1jSVK/EPHfc74O7AyZU34PNIP4Sh33N+/A5YexrNgJlPY+E3GdVYi4ldWJjgkAd +Qah2PH5ACLrIIC6tRka9hcaBlIECAwEAAaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADgYEAHzC7jdYlzAVmddi/gdAeKPau +sPBG/C2HCWqHzpCUHcKuvMzDVkY/MP2o6JIW2DBbY64bO/FceExhjcykgaYtCH/m +oIU63+CFOTtR7otyQAWHqXa7q4SbCDlG7DyRFxqG0txPtGvy12lgldA2+RgcigQG +Dfcog5wrJytaQ6UA0wE= +-----END CERTIFICATE----- +EOF + +cat << 'EOF' > server.key +-----BEGIN PRIVATE KEY----- +MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOHDFScoLCVJpYDD +M4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1BgzkWF+slf +3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd9N8YwbBY +AckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAECgYAn7qGnM2vbjJNBm0VZCkOkTIWm +V10okw7EPJrdL2mkre9NasghNXbE1y5zDshx5Nt3KsazKOxTT8d0Jwh/3KbaN+YY +tTCbKGW0pXDRBhwUHRcuRzScjli8Rih5UOCiZkhefUTcRb6xIhZJuQy71tjaSy0p +dHZRmYyBYO2YEQ8xoQJBAPrJPhMBkzmEYFtyIEqAxQ/o/A6E+E4w8i+KM7nQCK7q +K4JXzyXVAjLfyBZWHGM2uro/fjqPggGD6QH1qXCkI4MCQQDmdKeb2TrKRh5BY1LR +81aJGKcJ2XbcDu6wMZK4oqWbTX2KiYn9GB0woM6nSr/Y6iy1u145YzYxEV/iMwff +DJULAkB8B2MnyzOg0pNFJqBJuH29bKCcHa8gHJzqXhNO5lAlEbMK95p/P2Wi+4Hd +aiEIAF1BF326QJcvYKmwSmrORp85AkAlSNxRJ50OWrfMZnBgzVjDx3xG6KsFQVk2 +ol6VhqL6dFgKUORFUWBvnKSyhjJxurlPEahV6oo6+A+mPhFY8eUvAkAZQyTdupP3 +XEFQKctGz+9+gKkemDp7LBBMEMBXrGTLPhpEfcjv/7KPdnFHYmhYeBTBnuVmTVWe +F98XJ7tIFfJq +-----END PRIVATE KEY----- +EOF + +cat << 'EOF' > server.pem +-----BEGIN CERTIFICATE----- +MIICnDCCAgWgAwIBAgIBBzANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJBVTET +MBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQ +dHkgTHRkMQ8wDQYDVQQDEwZ0ZXN0Y2EwHhcNMTUxMTA0MDIyMDI0WhcNMjUxMTAx +MDIyMDI0WjBlMQswCQYDVQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNV +BAcTB0NoaWNhZ28xFTATBgNVBAoTDEV4YW1wbGUsIENvLjEaMBgGA1UEAxQRKi50 +ZXN0Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOHDFSco +LCVJpYDDM4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1Bg +zkWF+slf3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd +9N8YwbBYAckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAGjazBpMAkGA1UdEwQCMAAw +CwYDVR0PBAQDAgXgME8GA1UdEQRIMEaCECoudGVzdC5nb29nbGUuZnKCGHdhdGVy +em9vaS50ZXN0Lmdvb2dsZS5iZYISKi50ZXN0LnlvdXR1YmUuY29thwTAqAEDMA0G +CSqGSIb3DQEBCwUAA4GBAJFXVifQNub1LUP4JlnX5lXNlo8FxZ2a12AFQs+bzoJ6 +hM044EDjqyxUqSbVePK0ni3w1fHQB5rY9yYC5f8G7aqqTY1QOhoUk8ZTSTRpnkTh +y4jjdvTZeLDVBlueZUTDRmy2feY5aZIU18vFDK08dTG0A87pppuv1LNIR3loveU8 +-----END CERTIFICATE----- +EOF + + +第二步,创建main.go文件: + +package main + +import ( + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +type Product struct { + Username string `json:"username" binding:"required"` + Name string `json:"name" binding:"required"` + Category string `json:"category" binding:"required"` + Price int `json:"price" binding:"gte=0"` + Description string `json:"description"` + CreatedAt time.Time `json:"createdAt"` +} + +type productHandler struct { + sync.RWMutex + products map[string]Product +} + +func newProductHandler() *productHandler { + return &productHandler{ + products: make(map[string]Product), + } +} + +func (u *productHandler) Create(c *gin.Context) { + u.Lock() + defer u.Unlock() + + // 1. 参数解析 + var product Product + if err := c.ShouldBindJSON(&product); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 2. 参数校验 + if _, ok := u.products[product.Name]; ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("product %s already exist", product.Name)}) + return + } + product.CreatedAt = time.Now() + + // 3. 逻辑处理 + u.products[product.Name] = product + log.Printf("Register product %s success", product.Name) + + // 4. 返回结果 + c.JSON(http.StatusOK, product) +} + +func (u *productHandler) Get(c *gin.Context) { + u.Lock() + defer u.Unlock() + + product, ok := u.products[c.Param("name")] + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Errorf("can not found product %s", c.Param("name"))}) + return + } + + c.JSON(http.StatusOK, product) +} + +func router() http.Handler { + router := gin.Default() + productHandler := newProductHandler() + // 路由分组、中间件、认证 + v1 := router.Group("/v1") + { + productv1 := v1.Group("/products") + { + // 路由匹配 + productv1.POST("", productHandler.Create) + productv1.GET(":name", productHandler.Get) + } + } + + return router +} + +func main() { + var eg errgroup.Group + + // 一进程多端口 + insecureServer := &http.Server{ + Addr: ":8080", + Handler: router(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + secureServer := &http.Server{ + Addr: ":8443", + Handler: router(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + eg.Go(func() error { + err := insecureServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + return err + }) + + eg.Go(func() error { + err := secureServer.ListenAndServeTLS("server.pem", "server.key") + if err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + return err + }) + + if err := eg.Wait(); err != nil { + log.Fatal(err) + } +} + + +运行以上代码: + +$ go run main.go + + +打开另外一个终端,请求HTTP接口: + +# 创建产品 +$ curl -XPOST -H"Content-Type: application/json" -d'{"username":"colin","name":"iphone12","category":"phone","price":8000,"description":"cannot afford"}' http://127.0.0.1:8080/v1/products +{"username":"colin","name":"iphone12","category":"phone","price":8000,"description":"cannot afford","createdAt":"2021-06-20T11:17:03.818065988+08:00"} + +# 获取产品信息 +$ curl -XGET http://127.0.0.1:8080/v1/products/iphone12 +{"username":"colin","name":"iphone12","category":"phone","price":8000,"description":"cannot afford","createdAt":"2021-06-20T11:17:03.818065988+08:00"} + + +示例代码存放地址为webfeature。 + +另外,Gin项目仓库中也包含了很多使用示例,如果你想详细了解,可以参考 gin examples。 + +下面,我来详细介绍下Gin是如何支持Web服务基础功能的。 + +HTTP/HTTPS支持 + +因为Gin是基于net/http包封装的一个Web框架,所以它天然就支持HTTP/HTTPS。在上述代码中,通过以下方式开启一个HTTP服务: + +insecureServer := &http.Server{ + Addr: ":8080", + Handler: router(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, +} +... +err := insecureServer.ListenAndServe() + + +通过以下方式开启一个HTTPS服务: + +secureServer := &http.Server{ + Addr: ":8443", + Handler: router(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, +} +... +err := secureServer.ListenAndServeTLS("server.pem", "server.key") + + +JSON数据格式支持 + +Gin支持多种数据通信格式,例如application/json、application/xml。可以通过c.ShouldBindJSON函数,将Body中的JSON格式数据解析到指定的Struct中,通过c.JSON函数返回JSON格式的数据。 + +路由匹配 + +Gin支持两种路由匹配规则。 + +第一种匹配规则是精确匹配。例如,路由为/products/:name,匹配情况如下表所示: + + + +第二种匹配规则是模糊匹配。例如,路由为/products/*name,匹配情况如下表所示: + + + +路由分组 + +Gin通过Group函数实现了路由分组的功能。路由分组是一个非常常用的功能,可以将相同版本的路由分为一组,也可以将相同RESTful资源的路由分为一组。例如: + +v1 := router.Group("/v1", gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) +{ + productv1 := v1.Group("/products") + { + // 路由匹配 + productv1.POST("", productHandler.Create) + productv1.GET(":name", productHandler.Get) + } + + orderv1 := v1.Group("/orders") + { + // 路由匹配 + orderv1.POST("", orderHandler.Create) + orderv1.GET(":name", orderHandler.Get) + } +} + +v2 := router.Group("/v2", gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) +{ + productv2 := v2.Group("/products") + { + // 路由匹配 + productv2.POST("", productHandler.Create) + productv2.GET(":name", productHandler.Get) + } +} + + +通过将路由分组,可以对相同分组的路由做统一处理。比如上面那个例子,我们可以通过代码 + +v1 := router.Group("/v1", gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) + + +给所有属于v1分组的路由都添加gin.BasicAuth中间件,以实现认证功能。中间件和认证,这里你先不用深究,下面讲高级功能的时候会介绍到。 + +一进程多服务 + +我们可以通过以下方式实现一进程多服务: + +var eg errgroup.Group +insecureServer := &http.Server{...} +secureServer := &http.Server{...} + +eg.Go(func() error { + err := insecureServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + return err +}) +eg.Go(func() error { + err := secureServer.ListenAndServeTLS("server.pem", "server.key") + if err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + return err +} + +if err := eg.Wait(); err != nil { + log.Fatal(err) +}) + + +上述代码实现了两个相同的服务,分别监听在不同的端口。这里需要注意的是,为了不阻塞启动第二个服务,我们需要把ListenAndServe函数放在goroutine中执行,并且调用eg.Wait()来阻塞程序进程,从而让两个HTTP服务在goroutine中持续监听端口,并提供服务。 + +参数解析、参数校验、逻辑处理、返回结果 + +此外,Web服务还应该具有参数解析、参数校验、逻辑处理、返回结果4类功能,因为这些功能联系紧密,我们放在一起来说。 + +在productHandler的Create方法中,我们通过c.ShouldBindJSON来解析参数,接下来自己编写校验代码,然后将product信息保存在内存中(也就是业务逻辑处理),最后通过c.JSON返回创建的product信息。代码如下: + +func (u *productHandler) Create(c *gin.Context) { + u.Lock() + defer u.Unlock() + + // 1. 参数解析 + var product Product + if err := c.ShouldBindJSON(&product); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 2. 参数校验 + if _, ok := u.products[product.Name]; ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("product %s already exist", product.Name)}) + return + } + product.CreatedAt = time.Now() + + // 3. 逻辑处理 + u.products[product.Name] = product + log.Printf("Register product %s success", product.Name) + + // 4. 返回结果 + c.JSON(http.StatusOK, product) +} + + +那这个时候,你可能会问:HTTP的请求参数可以存在不同的位置,Gin是如何解析的呢?这里,我们先来看下HTTP有哪些参数类型。HTTP具有以下5种参数类型: + + +路径参数(path)。例如gin.Default().GET("/user/:name", nil), name就是路径参数。 +查询字符串参数(query)。例如/welcome?firstname=Lingfei&lastname=Kong,firstname和lastname就是查询字符串参数。 +表单参数(form)。例如curl -X POST -F 'username=colin' -F 'password=colin1234' http://mydomain.com/login,username和password就是表单参数。 +HTTP头参数(header)。例如curl -X POST -H 'Content-Type: application/json' -d '{"username":"colin","password":"colin1234"}' http://mydomain.com/login,Content-Type就是HTTP头参数。 +消息体参数(body)。例如curl -X POST -H 'Content-Type: application/json' -d '{"username":"colin","password":"colin1234"}' http://mydomain.com/login,username和password就是消息体参数。 + + +Gin提供了一些函数,来分别读取这些HTTP参数,每种类别会提供两种函数,一种函数可以直接读取某个参数的值,另外一种函数会把同类HTTP参数绑定到一个Go结构体中。比如,有如下路径参数: + +gin.Default().GET("/:name/:id", nil) + + +我们可以直接读取每个参数: + +name := c.Param("name") +action := c.Param("action") + + +也可以将所有的路径参数,绑定到结构体中: + +type Person struct { + ID string `uri:"id" binding:"required,uuid"` + Name string `uri:"name" binding:"required"` +} + +if err := c.ShouldBindUri(&person); err != nil { + // normal code + return +} + + +Gin在绑定参数时,是通过结构体的tag来判断要绑定哪类参数到结构体中的。这里要注意,不同的HTTP参数有不同的结构体tag。 + + +路径参数:uri。 +查询字符串参数:form。 +表单参数:form。 +HTTP头参数:header。 +消息体参数:会根据Content-Type,自动选择使用json或者xml,也可以调用ShouldBindJSON或者ShouldBindXML直接指定使用哪个tag。 + + +针对每种参数类型,Gin都有对应的函数来获取和绑定这些参数。这些函数都是基于如下两个函数进行封装的: + + +ShouldBindWith(obj interface{}, b binding.Binding) error + + +非常重要的一个函数,很多ShouldBindXXX函数底层都是调用ShouldBindWith函数来完成参数绑定的。该函数会根据传入的绑定引擎,将参数绑定到传入的结构体指针中,如果绑定失败,只返回错误内容,但不终止HTTP请求。ShouldBindWith支持多种绑定引擎,例如 binding.JSON、binding.Query、binding.Uri、binding.Header等,更详细的信息你可以参考 binding.go。 + + +MustBindWith(obj interface{}, b binding.Binding) error + + +这是另一个非常重要的函数,很多BindXXX函数底层都是调用MustBindWith函数来完成参数绑定的。该函数会根据传入的绑定引擎,将参数绑定到传入的结构体指针中,如果绑定失败,返回错误并终止请求,返回HTTP 400错误。MustBindWith所支持的绑定引擎跟ShouldBindWith函数一样。 + +Gin基于ShouldBindWith和MustBindWith这两个函数,又衍生出很多新的Bind函数。这些函数可以满足不同场景下获取HTTP参数的需求。Gin提供的函数可以获取5个类别的HTTP参数。 + + +路径参数:ShouldBindUri、BindUri; +查询字符串参数:ShouldBindQuery、BindQuery; +表单参数:ShouldBind; +HTTP头参数:ShouldBindHeader、BindHeader; +消息体参数:ShouldBindJSON、BindJSON等。 + + +每个类别的Bind函数,详细信息你可以参考Gin提供的Bind函数。 + +这里要注意,Gin并没有提供类似ShouldBindForm、BindForm这类函数来绑定表单参数,但我们可以通过ShouldBind来绑定表单参数。当HTTP方法为GET时,ShouldBind只绑定Query类型的参数;当HTTP方法为POST时,会先检查content-type是否是json或者xml,如果不是,则绑定Form类型的参数。 + +所以,ShouldBind可以绑定Form类型的参数,但前提是HTTP方法是POST,并且content-type不是application/json、application/xml。 + +在Go项目开发中,我建议使用ShouldBindXXX,这样可以确保我们设置的HTTP Chain(Chain可以理解为一个HTTP请求的一系列处理插件)能够继续被执行。 + +Gin是如何支持Web服务高级功能的? + +上面介绍了Web服务的基础功能,这里我再来介绍下高级功能。Web服务可以具备多个高级功能,但比较核心的高级功能是中间件、认证、RequestID、跨域和优雅关停。 + +中间件 + +Gin支持中间件,HTTP请求在转发到实际的处理函数之前,会被一系列加载的中间件进行处理。在中间件中,可以解析HTTP请求做一些逻辑处理,例如:跨域处理或者生成X-Request-ID并保存在context中,以便追踪某个请求。处理完之后,可以选择中断并返回这次请求,也可以选择将请求继续转交给下一个中间件处理。当所有的中间件都处理完之后,请求才会转给路由函数进行处理。具体流程如下图: + + + +通过中间件,可以实现对所有请求都做统一的处理,提高开发效率,并使我们的代码更简洁。但是,因为所有的请求都需要经过中间件的处理,可能会增加请求延时。对于中间件特性,我有如下建议: + + +中间件做成可加载的,通过配置文件指定程序启动时加载哪些中间件。 +只将一些通用的、必要的功能做成中间件。 +在编写中间件时,一定要保证中间件的代码质量和性能。 + + +在Gin中,可以通过gin.Engine的Use方法来加载中间件。中间件可以加载到不同的位置上,而且不同的位置作用范围也不同,例如: + +router := gin.New() +router.Use(gin.Logger(), gin.Recovery()) // 中间件作用于所有的HTTP请求 +v1 := router.Group("/v1").Use(gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) // 中间件作用于v1 group +v1.POST("/login", Login).Use(gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) //中间件只作用于/v1/login API接口 + + +Gin框架本身支持了一些中间件。 + + +gin.Logger():Logger中间件会将日志写到gin.DefaultWriter,gin.DefaultWriter默认为 os.Stdout。 +gin.Recovery():Recovery中间件可以从任何panic恢复,并且写入一个500状态码。 +gin.CustomRecovery(handle gin.RecoveryFunc):类似Recovery中间件,但是在恢复时还会调用传入的handle方法进行处理。 +gin.BasicAuth():HTTP请求基本认证(使用用户名和密码进行认证)。 + + +另外,Gin还支持自定义中间件。中间件其实是一个函数,函数类型为gin.HandlerFunc,HandlerFunc底层类型为func(*Context)。如下是一个Logger中间件的实现: + +package main + +import ( + "log" + "time" + + "github.com/gin-gonic/gin" +) + +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + t := time.Now() + + // 设置变量example + c.Set("example", "12345") + + // 请求之前 + + c.Next() + + // 请求之后 + latency := time.Since(t) + log.Print(latency) + + // 访问我们发送的状态 + status := c.Writer.Status() + log.Println(status) + } +} + +func main() { + r := gin.New() + r.Use(Logger()) + + r.GET("/test", func(c *gin.Context) { + example := c.MustGet("example").(string) + + // it would print: "12345" + log.Println(example) + }) + + // Listen and serve on 0.0.0.0:8080 + r.Run(":8080") +} + + +另外,还有很多开源的中间件可供我们选择,我把一些常用的总结在了表格里: + + + +认证、RequestID、跨域 + +认证、RequestID、跨域这三个高级功能,都可以通过Gin的中间件来实现,例如: + +router := gin.New() + +// 认证 +router.Use(gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) + +// RequestID +router.Use(requestid.New(requestid.Config{ + Generator: func() string { + return "test" + }, +})) + +// 跨域 +// CORS for https://foo.com and https://github.com origins, allowing: +// - PUT and PATCH methods +// - Origin header +// - Credentials share +// - Preflight requests cached for 12 hours +router.Use(cors.New(cors.Config{ + AllowOrigins: []string{"https://foo.com"}, + AllowMethods: []string{"PUT", "PATCH"}, + AllowHeaders: []string{"Origin"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + AllowOriginFunc: func(origin string) bool { + return origin == "https://github.com" + }, + MaxAge: 12 * time.Hour, +})) + + +优雅关停 + +Go项目上线后,我们还需要不断迭代来丰富项目功能、修复Bug等,这也就意味着,我们要不断地重启Go服务。对于HTTP服务来说,如果访问量大,重启服务的时候可能还有很多连接没有断开,请求没有完成。如果这时候直接关闭服务,这些连接会直接断掉,请求异常终止,这就会对用户体验和产品口碑造成很大影响。因此,这种关闭方式不是一种优雅的关闭方式。 + +这时候,我们期望HTTP服务可以在处理完所有请求后,正常地关闭这些连接,也就是优雅地关闭服务。我们有两种方法来优雅关闭HTTP服务,分别是借助第三方的Go包和自己编码实现。 + +方法一:借助第三方的Go包 + +如果使用第三方的Go包来实现优雅关闭,目前用得比较多的包是fvbock/endless。我们可以使用fvbock/endless来替换掉net/http的ListenAndServe方法,例如: + +router := gin.Default() +router.GET("/", handler) +// [...] +endless.ListenAndServe(":4242", router) + + +方法二:编码实现 + +借助第三方包的好处是可以稍微减少一些编码工作量,但缺点是引入了一个新的依赖包,因此我更倾向于自己编码实现。Go 1.8版本或者更新的版本,http.Server内置的Shutdown方法,已经实现了优雅关闭。下面是一个示例: + +// +build go1.8 + +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + router.GET("/", func(c *gin.Context) { + time.Sleep(5 * time.Second) + c.String(http.StatusOK, "Welcome Gin Server") + }) + + srv := &http.Server{ + Addr: ":8080", + Handler: router, + } + + // Initializing the server in a goroutine so that + // it won't block the graceful shutdown handling below + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + + // Wait for interrupt signal to gracefully shutdown the server with + // a timeout of 5 seconds. + quit := make(chan os.Signal, 1) + // kill (no param) default send syscall.SIGTERM + // kill -2 is syscall.SIGINT + // kill -9 is syscall.SIGKILL but can't be catch, so don't need add it + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + // The context is used to inform the server it has 5 seconds to finish + // the request it is currently handling + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exiting") +} + + +上面的示例中,需要把srv.ListenAndServe放在goroutine中执行,这样才不会阻塞到srv.Shutdown函数。因为我们把srv.ListenAndServe放在了goroutine中,所以需要一种可以让整个进程常驻的机制。 + +这里,我们借助了有缓冲channel,并且调用signal.Notify函数将该channel绑定到SIGINT、SIGTERM信号上。这样,收到SIGINT、SIGTERM信号后,quilt通道会被写入值,从而结束阻塞状态,程序继续运行,执行srv.Shutdown(ctx),优雅关停HTTP服务。 + +总结 + +今天我们主要学习了Web服务的核心功能,以及如何开发这些功能。在实际的项目开发中, 我们一般会使用基于net/http包进行封装的优秀开源Web框架。 + +当前比较火的Go Web框架有 Gin、Beego、Echo、Revel、Martini。你可以根据需要进行选择。我比较推荐Gin,Gin也是目前比较受欢迎的Web框架。Gin Web框架支持Web服务的很多基础功能,例如 HTTP/HTTPS、JSON格式的数据、路由分组和匹配、一进程多服务等。 + +另外,Gin还支持Web服务的一些高级功能,例如 中间件、认证、RequestID、跨域和优雅关停等。 + +课后练习 + + +使用 Gin 框架编写一个简单的Web服务,要求该Web服务可以解析参数、校验参数,并进行一些简单的业务逻辑处理,最终返回处理结果。欢迎在留言区分享你的成果,或者遇到的问题。 +思考下,如何给iam-apiserver的/healthz接口添加一个限流中间件,用来限制请求/healthz的频率。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/25认证机制:应用程序如何进行访问认证?.md b/专栏/Go语言项目开发实战/25认证机制:应用程序如何进行访问认证?.md new file mode 100644 index 0000000..c229311 --- /dev/null +++ b/专栏/Go语言项目开发实战/25认证机制:应用程序如何进行访问认证?.md @@ -0,0 +1,298 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 25 认证机制:应用程序如何进行访问认证? + 你好,我是孔令飞,今天我们来聊聊如何进行访问认证。 + +保证应用的安全是软件开发的最基本要求,我们有多种途径来保障应用的安全,例如网络隔离、设置防火墙、设置IP黑白名单等。不过在我看来,这些更多是从运维角度来解决应用的安全问题。作为开发者,我们也可以从软件层面来保证应用的安全,这可以通过认证来实现。 + +这一讲,我以HTTP服务为例,来给你介绍下当前常见的四种认证方法:Basic、Digest、OAuth、Bearer。还有很多基于这四种方法的变种,这里就不再介绍了。 + +IAM项目使用了Basic、Bearer两种认证方法。这一讲,我先来介绍下这四种认证方法,下一讲,我会给你介绍下IAM项目是如何设计和实现访问认证功能的。 + +认证和授权有什么区别? + +在介绍四种基本的认证方法之前,我想先带你区分下认证和授权,这是很多开发者都容易搞混的两个概念。 + + +认证(Authentication,英文缩写authn):用来验证某个用户是否具有访问系统的权限。如果认证通过,该用户就可以访问系统,从而创建、修改、删除、查询平台支持的资源。 +授权(Authorization,英文缩写authz):用来验证某个用户是否具有访问某个资源的权限,如果授权通过,该用户就能对资源做增删改查等操作。 + + +这里,我通过下面的图片,来让你明白二者的区别: + + + +图中,我们有一个仓库系统,用户 james、colin、aaron分别创建了Product-A、Product-B、Product-C。现在用户colin通过用户名和密码(认证)成功登陆到仓库系统中,但他尝试访问Product-A、Product-C失败,因为这两个产品不属于他(授权失败),但他可以成功访问自己创建的资源Product-B(授权成功)。由此可见:认证证明了你是谁,授权决定了你能做什么。 + +上面,我们介绍了认证和授权的区别。那么接下来,我们就回到这一讲的重心:应用程序如何进行访问认证。 + +四种基本的认证方式 + +常见的认证方式有四种,分别是 Basic、Digest、OAuth 和 Bearer。先来看下Basic认证。 + +Basic + +Basic认证(基础认证),是最简单的认证方式。它简单地将用户名:密码进行base64编码后,放到HTTP Authorization Header中。HTTP请求到达后端服务后,后端服务会解析出Authorization Header中的base64字符串,解码获取用户名和密码,并将用户名和密码跟数据库中记录的值进行比较,如果匹配则认证通过。例如: + +$ basic=`echo -n 'admin:Admin@2021'|base64` +$ curl -XPOST -H"Authorization: Basic ${basic}" http://127.0.0.1:8080/login + + +通过base64编码,可以将密码以非明文的方式传输,增加一定的安全性。但是,base64不是加密技术,入侵者仍然可以截获base64字符串,并反编码获取用户名和密码。另外,即使Basic认证中密码被加密,入侵者仍可通过加密后的用户名和密码进行重放攻击。 + +所以,Basic认证虽然简单,但极不安全。使用Basic认证的唯一方式就是将它和SSL配合使用,来确保整个认证过程是安全的。 + +IAM项目中,为了支持前端通过用户名和密码登录,仍然使用了Basic认证,但前后端使用HTTPS来通信,保证了认证的安全性。 + +这里需要注意,在设计系统时,要遵循一个通用的原则:不要在请求参数中使用明文密码,也不要在任何存储中保存明文密码。 + +Digest + +Digest认证(摘要认证),是另一种 HTTP 认证协议,它与基本认证兼容,但修复了基本认证的严重缺陷。Digest具有如下特点: + + +绝不会用明文方式在网络上发送密码。 +可以有效防止恶意用户进行重放攻击。 +可以有选择地防止对报文内容的篡改。 + + +摘要认证的过程见下图: + + + +在上图中,完成摘要认证需要下面这四步: + + +客户端请求服务端的资源。 +在客户端能够证明它知道密码从而确认其身份之前,服务端认证失败,返回401 Unauthorized,并返回WWW-Authenticate头,里面包含认证需要的信息。 +客户端根据WWW-Authenticate头中的信息,选择加密算法,并使用密码随机数nonce,计算出密码摘要response,并再次请求服务端。 +服务器将客户端提供的密码摘要与服务器内部计算出的摘要进行对比。如果匹配,就说明客户端知道密码,认证通过,并返回一些与授权会话相关的附加信息,放在Authorization-Info中。 + + +WWW-Authenticate头中包含的信息见下表: + + + +虽然使用摘要可以避免密码以明文方式发送,一定程度上保护了密码的安全性,但是仅仅隐藏密码并不能保证请求是安全的。因为请求(包括密码摘要)仍然可以被截获,这样就可以重放给服务器,带来安全问题。 + +为了防止重放攻击,服务器向客户端发送了密码随机数nonce,nonce每次请求都会变化。客户端会根据nonce生成密码摘要,这种方式,可以使摘要随着随机数的变化而变化。服务端收到的密码摘要只对特定的随机数有效,而没有密码的话,攻击者就无法计算出正确的摘要,这样我们就可以防止重放攻击。 + +摘要认证可以保护密码,比基本认证安全很多。但摘要认证并不能保护内容,所以仍然要与HTTPS配合使用,来确保通信的安全。 + +OAuth + +OAuth(开放授权)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一Web服务上存储的私密资源(例如照片、视频、音频等),而无需将用户名和密码提供给第三方应用。OAuth目前的版本是2.0版。 + +OAuth2.0一共分为四种授权方式,分别为密码式、隐藏式、凭借式和授权码模式。接下来,我们就具体介绍下每一种授权方式。 + +第一种,密码式。密码式的授权方式,就是用户把用户名和密码直接告诉给第三方应用,然后第三方应用使用用户名和密码换取令牌。所以,使用此授权方式的前提是无法采用其他授权方式,并且用户高度信任某应用。 + +认证流程如下: + + +网站A向用户发出获取用户名和密码的请求; +用户同意后,网站A凭借用户名和密码向网站B换取令牌; +网站B验证用户身份后,给出网站A令牌,网站A凭借令牌可以访问网站B对应权限的资源。 + + +第二种,隐藏式。这种方式适用于前端应用。认证流程如下: + + +A网站提供一个跳转到B网站的链接,用户点击后跳转至B网站,并向用户请求授权; +用户登录B网站,同意授权后,跳转回A网站指定的重定向redirect_url地址,并携带B网站返回的令牌,用户在B网站的数据给A网站使用。 + + +这个授权方式存在着“中间人攻击”的风险,因此只能用于一些安全性要求不高的场景,并且令牌的有效时间要非常短。 + +第三种,凭借式。这种方式是在命令行中请求授权,适用于没有前端的命令行应用。认证流程如下: + + +应用A在命令行向应用B请求授权,此时应用A需要携带应用B提前颁发的secretID和secretKey,其中secretKey出于安全性考虑,需在后端发送; +应用B接收到secretID和secretKey,并进行身份验证,验证通过后返回给应用A令牌。 + + +第四种,授权码模式。这种方式就是第三方应用先提前申请一个授权码,然后再使用授权码来获取令牌。相对来说,这种方式安全性更高,前端传送授权码,后端存储令牌,与资源的通信都是在后端,可以避免令牌的泄露导致的安全问题。认证流程如下: + + + + +A网站提供一个跳转到B网站的链接+redirect_url,用户点击后跳转至B网站; +用户携带向B网站提前申请的client_id,向B网站发起身份验证请求; +用户登录B网站,通过验证,授予A网站权限,此时网站跳转回redirect_url,其中会有B网站通过验证后的授权码附在该url后; +网站A携带授权码向网站B请求令牌,网站B验证授权码后,返回令牌即access_token。 + + +Bearer + +Bearer认证,也称为令牌认证,是一种 HTTP 身份验证方法。Bearer认证的核心是bearer token。bearer token是一个加密字符串,通常由服务端根据密钥生成。客户端在请求服务端时,必须在请求头中包含Authorization: Bearer 。服务端收到请求后,解析出 ,并校验 的合法性,如果校验通过,则认证通过。跟基本认证一样,Bearer认证需要配合HTTPS一起使用,来保证认证安全性。 + +当前最流行的token编码方式是JSON Web Token(JWT,音同 jot,详见 JWT RFC 7519)。接下来,我通过讲解JWT认证来帮助你了解Bearer认证的原理。 + +基于JWT的Token认证机制实现 + +在典型业务场景中,为了区分用户和保证安全,必须对 API 请求进行鉴权,但是不能要求每一个请求都进行登录操作。合理做法是,在第一次登录之后产生一个有一定有效期的 token,并将它存储在浏览器的 Cookie 或 LocalStorage 之中。之后的请求都携带这个 token ,请求到达服务器端后,服务器端用这个 token 对请求进行认证。在第一次登录之后,服务器会将这个 token 用文件、数据库或缓存服务器等方法存下来,用于之后请求中的比对。 + +或者也可以采用更简单的方法:直接用密钥来签发Token。这样,就可以省下额外的存储,也可以减少每一次请求时对数据库的查询压力。这种方法在业界已经有一种标准的实现方式,就是JWT。 + +接下来,我就来具体介绍下JWT。 + +JWT简介 + +JWT是Bearer Token的一个具体实现,由JSON数据格式组成,通过HASH散列算法生成一个字符串。该字符串可以用来进行授权和信息交换。 + +使用JWT Token进行认证有很多优点,比如说无需在服务端存储用户数据,可以减轻服务端压力;而且采用JSON数据格式,比较易读。除此之外,使用JWT Token还有跨语言、轻量级等优点。 + +JWT认证流程 + +使用JWT Token进行认证的流程如下图: + + + +具体可以分为四步: + + +客户端使用用户名和密码请求登录。 + +服务端收到请求后,会去验证用户名和密码。如果用户名和密码跟数据库记录不一致,则验证失败;如果一致则验证通过,服务端会签发一个Token返回给客户端。 + +客户端收到请求后会将Token缓存起来,比如放在浏览器Cookie中或者LocalStorage中,之后每次请求都会携带该Token。 + +服务端收到请求后,会验证请求中的Token,验证通过则进行业务逻辑处理,处理完后返回处理后的结果。 + + +JWT格式 + +JWT由三部分组成,分别是Header、Payload 和 Signature,它们之间用圆点.连接,例如: + +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2NDI4NTY2MzcsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MzUwODA2MzcsInN1YiI6ImFkbWluIn0.Shw27RKENE_2MVBq7-c8OmgYdF92UmdwS8xE-Fts2FM + + +JWT中,每部分包含的信息见下图: + + + +下面我来具体介绍下这三部分,以及它们包含的信息。 + + +Header + + +JWT Token的Header中,包含两部分信息:一是Token的类型,二是Token所使用的加密算法。 + +例如: + +{ + "typ": "JWT", + "alg": "HS256" +} + + +参数说明: + + +typ:说明Token类型是JWT。 +alg:说明Token的加密算法,这里是HS256(alg算法可以有多种)。 + + +这里,我们将Header进行base64编码: + +$ echo -n '{"typ":"JWT","alg":"HS256"}'|base64 +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 + + +在某些场景下,可能还会有kid选项,用来标识一个密钥ID,例如: + +{ + "alg": "HS256", + "kid": "XhbY3aCrfjdYcP1OFJRu9xcno8JzSbUIvGE2", + "typ": "JWT" +} + + + +Payload(载荷) + + +Payload中携带Token的具体内容由三部分组成:JWT标准中注册的声明(可选)、公共的声明、私有的声明。下面来分别看下。 + +JWT标准中注册的声明部分,有以下标准字段: + + + +本例中的payload内容为: + +{ + "aud": "iam.authz.marmotedu.com", + "exp": 1604158987, + "iat": 1604151787, + "iss": "iamctl", + "nbf": 1604151787 +} + + +这里,我们将Payload 进行base64编码: + +$ echo -n '{"aud":"iam.authz.marmotedu.com","exp":1604158987,"iat":1604151787,"iss":"iamctl","nbf":1604151787}'|base64 +eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYwNDE1ODk4NywiaWF0Ijox +NjA0MTUxNzg3LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MDQxNTE3ODd9 + + +除此之外,还有公共的声明和私有的声明。公共的声明可以添加任何的需要的信息,一般添加用户的相关信息或其他业务需要的信息,注意不要添加敏感信息;私有声明是客户端和服务端所共同定义的声明,因为base64是对称解密的,所以一般不建议存放敏感信息。 + + +Signature(签名) + + +Signature是Token的签名部分,通过如下方式生成:将Header和Payload分别base64编码后,用 . 连接。然后再使用Header中声明的加密方式,利用secretKey对连接后的字符串进行加密,加密后的字符串即为最终的Signature。 + +secretKey是密钥,保存在服务器中,一般通过配置文件来保存,例如: + + + +这里要注意,密钥一定不能泄露。密钥泄露后,入侵者可以使用该密钥来签发JWT Token,从而入侵系统。 + +最后生成的Token如下: + +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYwNDE1ODk4NywiaWF0IjoxNjA0MTUxNzg3LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MDQxNTE3ODd9.LjxrK9DuAwAzUD8-9v43NzWBN7HXsSLfebw92DKd1JQ + + +签名后服务端会返回生成的 Token,客户端下次请求会携带该 Token。服务端收到 Token 后会解析出 header.payload,然后用相同的加密算法和密钥对 header.payload 再进行一次加密,得到 Signature。并且,对比加密后的 Signature 和收到的 Signature 是否相同,如果相同则验证通过,不相同则返回 HTTP 401 Unauthorized 的错误。 + +最后,关于JWT的使用,我还有两点建议: + + +不要存放敏感信息在Token里; +Payload中的exp值不要设置得太大,一般开发版本 7 天,线上版本 2 小时。当然,你也可以根据需要自行设置。 + + +总结 + +在开发Go应用时,我们需要通过认证来保障应用的安全。认证,用来验证某个用户是否具有访问系统的权限,如果认证通过,该用户就可以访问系统,从而创建、修改、删除、查询平台支持的资源。业界目前有四种常用的认证方式:Basic、Digest、OAuth、Bearer。其中Basic和Bearer用得最多。 + +Basic认证通过用户名和密码来进行认证,主要用在用户登录场景;Bearer认证通过Token来进行认证,通常用在API调用场景。不管是Basic认证还是Bearer认证,都需要结合HTTPS来使用,来最大程度地保证请求的安全性。 + +Basic认证简单易懂,但是Bearer认证有一定的复杂度,所以这一讲的后半部分通过JWT Token,讲解了Bearer Token认证的原理。 + +JWT Token是Bearer认证的一种比较好的实现,主要包含了3个部分: + + +Header:包含了Token的类型、Token使用的加密算法。在某些场景下,你还可以添加kid字段,用来标识一个密钥ID。 +Payload:Payload中携带Token的具体内容,由JWT标准中注册的声明、公共的声明和私有的声明三部分组成。 +Signature:Signature是Token的签名部分,程序通过验证Signature是否合法,来决定认证是否通过。 + + +课后练习 + + +思考下:使用JWT作为登录凭证,如何解决token注销问题? +思考下:Token是存放在LocalStorage中好,还是存放在Cookie中好? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/26IAM项目是如何设计和实现访问认证功能的?.md b/专栏/Go语言项目开发实战/26IAM项目是如何设计和实现访问认证功能的?.md new file mode 100644 index 0000000..b168eda --- /dev/null +++ b/专栏/Go语言项目开发实战/26IAM项目是如何设计和实现访问认证功能的?.md @@ -0,0 +1,703 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 26 IAM项目是如何设计和实现访问认证功能的? + 你好,我是孔令飞。 + +上一讲,我们学习了应用认证常用的四种方式:Basic、Digest、OAuth、Bearer。这一讲,我们再来看下IAM项目是如何设计和实现认证功能的。 + +IAM项目用到了Basic认证和Bearer认证。其中,Basic认证用在前端登陆的场景,Bearer认证用在调用后端API服务的场景下。 + +接下来,我们先来看下IAM项目认证功能的整体设计思路。 + +如何设计IAM项目的认证功能? + +在认证功能开发之前,我们要根据需求,认真考虑下如何设计认证功能,并在设计阶段通过技术评审。那么我们先来看下,如何设计IAM项目的认证功能。 + +首先,我们要梳理清楚认证功能的使用场景和需求。 + + +IAM项目的iam-apiserver服务,提供了IAM系统的管理流功能接口,它的客户端可以是前端(这里也叫控制台),也可以是App端。 +为了方便用户在Linux系统下调用,IAM项目还提供了iamctl命令行工具。 +为了支持在第三方代码中调用iam-apiserver提供的API接口,还支持了API调用。 +为了提高用户在代码中调用API接口的效率,IAM项目提供了Go SDK。 + + +可以看到,iam-apiserver有很多客户端,每种客户端适用的认证方式是有区别的。 + +控制台、App端需要登录系统,所以需要使用用户名:密码这种认证方式,也即Basic认证。iamctl、API调用、Go SDK因为可以不用登录系统,所以可以采用更安全的认证方式:Bearer认证。同时,Basic认证作为iam-apiserver已经集成的认证方式,仍然可以供iamctl、API调用、Go SDK使用。 + +这里有个地方需要注意:如果iam-apiserver采用Bearer Token的认证方式,目前最受欢迎的Token格式是JWT Token。而JWT Token需要密钥(后面统一用secretKey来指代),因此需要在iam-apiserver服务中为每个用户维护一个密钥,这样会增加开发和维护成本。 + +业界有一个更好的实现方式:将iam-apiserver提供的API接口注册到API网关中,通过API网关中的Token认证功能,来实现对iam-apiserver API接口的认证。有很多API网关可供选择,例如腾讯云API网关、Tyk、Kong等。 + +这里需要你注意:通过iam-apiserver创建的密钥对是提供给iam-authz-server使用的。 + +另外,我们还需要调用iam-authz-server提供的RESTful API接口:/v1/authz,来进行资源授权。API调用比较适合采用的认证方式是Bearer认证。 + +当然,/v1/authz也可以直接注册到API网关中。在实际的Go项目开发中,也是我推荐的一种方式。但在这里,为了展示实现Bearer认证的过程,iam-authz-server自己实现了Bearer认证。讲到iam-authz-server Bearer认证实现的时候,我会详细介绍这一点。 + +Basic认证需要用户名和密码,Bearer认证则需要密钥,所以iam-apiserver需要将用户名/密码、密钥等信息保存在后端的MySQL中,持久存储起来。 + +在进行认证的时候,需要获取密码或密钥进行反加密,这就需要查询密码或密钥。查询密码或密钥有两种方式。一种是在请求到达时查询数据库。因为数据库的查询操作延时高,会导致API接口延时较高,所以不太适合用在数据流组件中。另外一种是将密码或密钥缓存在内存中,这样请求到来时,就可以直接从内存中查询,从而提升查询速度,提高接口性能。 + +但是,将密码或密钥缓存在内存中时,就要考虑内存和数据库的数据一致性,这会增加代码实现的复杂度。因为管控流组件对性能延时要求不那么敏感,而数据流组件则一定要实现非常高的接口性能,所以iam-apiserver在请求到来时查询数据库,而iam-authz-server则将密钥信息缓存在内存中。 + +那在这里,可以总结出一张IAM项目的认证设计图: + + + +另外,为了将控制流和数据流区分开来,密钥的CURD操作也放在了iam-apiserver中,但是iam-authz-server需要用到这些密钥信息。为了解决这个问题,目前的做法是: + + +iam-authz-server通过gRPC API请求iam-apiserver,获取所有的密钥信息; +当iam-apiserver有密钥更新时,会Pub一条消息到Redis Channel中。因为iam-authz-server订阅了同一个Redis Channel,iam-authz-searver监听到channel有新消息时,会获取、解析消息,并更新它缓存的密钥信息。这样,我们就能确保iam-authz-server内存中缓存的密钥和iam-apiserver中的密钥保持一致。 + + +学到这里,你可能会问:将所有密钥都缓存在iam-authz-server中,那岂不是要占用很大的内存?别担心,这个问题我也想过,并且替你计算好了:8G的内存大概能保存约8千万个密钥信息,完全够用。后期不够用的话,可以加大内存。 + +不过这里还是有个小缺陷:如果Redis down掉,或者出现网络抖动,可能会造成iam-apiserver中和iam-authz-server内存中保存的密钥数据不一致,但这不妨碍我们学习认证功能的设计和实现。至于如何保证缓存系统的数据一致性,我会在新一期的特别放送里专门介绍下。 + +最后注意一点:Basic 认证请求和 Bearer 认证请求都可能被截获并重放。所以,为了确保Basic认证和Bearer认证的安全性,和服务端通信时都需要配合使用HTTPS协议。 + +IAM项目是如何实现Basic认证的? + +我们已经知道,IAM项目中主要用了Basic 和 Bearer 这两种认证方式。我们要支持Basic认证和Bearer认证,并根据需要选择不同的认证方式,这很容易让我们想到使用设计模式中的策略模式来实现。所以,在IAM项目中,我将每一种认证方式都视作一个策略,通过选择不同的策略,来使用不同的认证方法。 + +IAM项目实现了如下策略: + + +auto策略:该策略会根据HTTP头Authorization: Basic XX.YY.ZZ和Authorization: Bearer XX.YY.ZZ自动选择使用Basic认证还是Bearer认证。 +basic策略:该策略实现了Basic认证。 +jwt策略:该策略实现了Bearer认证,JWT是Bearer认证的具体实现。 +cache策略:该策略其实是一个Bearer认证的实现,Token采用了JWT格式,因为Token中的密钥ID是从内存中获取的,所以叫Cache认证。这一点后面会详细介绍。 + + +iam-apiserver通过创建需要的认证策略,并加载到需要认证的API路由上,来实现API认证。具体代码如下: + +jwtStrategy, _ := newJWTAuth().(auth.JWTStrategy) +g.POST("/login", jwtStrategy.LoginHandler) +g.POST("/logout", jwtStrategy.LogoutHandler) +// Refresh time can be longer than token timeout +g.POST("/refresh", jwtStrategy.RefreshHandler) + + +上述代码中,我们通过newJWTAuth函数创建了auth.JWTStrategy类型的变量,该变量包含了一些认证相关函数。 + + +LoginHandler:实现了Basic认证,完成登陆认证。 +RefreshHandler:重新刷新Token的过期时间。 +LogoutHandler:用户注销时调用。登陆成功后,如果在Cookie中设置了认证相关的信息,执行LogoutHandler则会清空这些信息。 + + +下面,我来分别介绍下LoginHandler、RefreshHandler和LogoutHandler。 + + +LoginHandler + + +这里,我们来看下LoginHandler Gin中间件,该函数定义位于github.com/appleboy/gin-jwt包的auth_jwt.go文件中。 + +func (mw *GinJWTMiddleware) LoginHandler(c *gin.Context) { + if mw.Authenticator == nil { + mw.unauthorized(c, http.StatusInternalServerError, mw.HTTPStatusMessageFunc(ErrMissingAuthenticatorFunc, c)) + return + } + + data, err := mw.Authenticator(c) + + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) + return + } + + // Create the token + token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm)) + claims := token.Claims.(jwt.MapClaims) + + if mw.PayloadFunc != nil { + for key, value := range mw.PayloadFunc(data) { + claims[key] = value + } + } + + expire := mw.TimeFunc().Add(mw.Timeout) + claims["exp"] = expire.Unix() + claims["orig_iat"] = mw.TimeFunc().Unix() + tokenString, err := mw.signedString(token) + + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrFailedTokenCreation, c)) + return + } + + // set cookie + if mw.SendCookie { + expireCookie := mw.TimeFunc().Add(mw.CookieMaxAge) + maxage := int(expireCookie.Unix() - mw.TimeFunc().Unix()) + + if mw.CookieSameSite != 0 { + c.SetSameSite(mw.CookieSameSite) + } + + c.SetCookie( + mw.CookieName, + tokenString, + maxage, + "/", + mw.CookieDomain, + mw.SecureCookie, + mw.CookieHTTPOnly, + ) + } + + mw.LoginResponse(c, http.StatusOK, tokenString, expire) +} + + +从LoginHandler函数的代码实现中,我们可以知道,LoginHandler函数会执行Authenticator函数,来完成Basic认证。如果认证通过,则会签发JWT Token,并执行 PayloadFunc函数设置Token Payload。如果我们设置了 SendCookie=true ,还会在Cookie中添加认证相关的信息,例如 Token、Token的生命周期等,最后执行 LoginResponse 方法返回Token和Token的过期时间。 + +Authenticator、PayloadFunc、LoginResponse这三个函数,是我们在创建JWT认证策略时指定的。下面我来分别介绍下。 + +先来看下Authenticator函数。Authenticator函数从HTTP Authorization Header中获取用户名和密码,并校验密码是否合法。 + +func authenticator() func(c *gin.Context) (interface{}, error) { + return func(c *gin.Context) (interface{}, error) { + var login loginInfo + var err error + + // support header and body both + if c.Request.Header.Get("Authorization") != "" { + login, err = parseWithHeader(c) + } else { + login, err = parseWithBody(c) + } + if err != nil { + return "", jwt.ErrFailedAuthentication + } + + // Get the user information by the login username. + user, err := store.Client().Users().Get(c, login.Username, metav1.GetOptions{}) + if err != nil { + log.Errorf("get user information failed: %s", err.Error()) + + return "", jwt.ErrFailedAuthentication + } + + // Compare the login password with the user password. + if err := user.Compare(login.Password); err != nil { + return "", jwt.ErrFailedAuthentication + } + + return user, nil + } +} + + +Authenticator函数需要获取用户名和密码。它首先会判断是否有Authorization请求头,如果有,则调用parseWithHeader函数获取用户名和密码,否则调用parseWithBody从Body中获取用户名和密码。如果都获取失败,则返回认证失败错误。 + +所以,IAM项目的Basic支持以下两种请求方式: + +$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login # 用户名:密码通过base64加码后,通过HTTP Authorization Header进行传递,因为密码非明文,建议使用这种方式。 +$ curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login # 用户名和密码在HTTP Body中传递,因为密码是明文,所以这里不建议实际开发中,使用这种方式。 + + +这里,我们来看下 parseWithHeader 是如何获取用户名和密码的。假设我们的请求为: + +$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login + + +其中,YWRtaW46QWRtaW5AMjAyMQ==值由以下命令生成: + +$ echo -n 'admin:Admin@2021'|base64 +YWRtaW46QWRtaW5AMjAyMQ== + + +parseWithHeader实际上执行的是上述命令的逆向步骤: + + +获取Authorization头的值,并调用strings.SplitN函数,获取一个切片变量auth,其值为 ["Basic","YWRtaW46QWRtaW5AMjAyMQ=="] 。 +将YWRtaW46QWRtaW5AMjAyMQ==进行base64解码,得到admin:Admin@2021。 +调用strings.SplitN函数获取 admin:Admin@2021 ,得到用户名为admin,密码为Admin@2021。 + + +parseWithBody则是调用了Gin的ShouldBindJSON函数,来从Body中解析出用户名和密码。 + +获取到用户名和密码之后,程序会从数据库中查询出该用户对应的加密后的密码,这里我们假设是xxxx。最后authenticator函数调用user.Compare来判断 xxxx 是否和通过user.Compare加密后的字符串相匹配,如果匹配则认证成功,否则返回认证失败。 + +再来看下PayloadFunc函数: + +func payloadFunc() func(data interface{}) jwt.MapClaims { + return func(data interface{}) jwt.MapClaims { + claims := jwt.MapClaims{ + "iss": APIServerIssuer, + "aud": APIServerAudience, + } + if u, ok := data.(*v1.User); ok { + claims[jwt.IdentityKey] = u.Name + claims["sub"] = u.Name + } + + return claims + } +} + + +PayloadFunc函数会设置JWT Token中Payload部分的 iss、aud、sub、identity字段,供后面使用。 + +再来看下我们刚才说的第三个函数,LoginResponse函数: + +func loginResponse() func(c *gin.Context, code int, token string, expire time.Time) { + return func(c *gin.Context, code int, token string, expire time.Time) { + c.JSON(http.StatusOK, gin.H{ + "token": token, + "expire": expire.Format(time.RFC3339), + }) + } +} + + +该函数用来在Basic认证成功之后,返回Token和Token的过期时间给调用者: + +$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login +{"expire":"2021-09-29T01:38:49+08:00","token":"XX.YY.ZZ"} + + +登陆成功后,iam-apiserver会返回Token和Token的过期时间,前端可以将这些信息缓存在Cookie中或LocalStorage中,之后的请求都可以使用Token来进行认证。使用Token进行认证,不仅能够提高认证的安全性,还能够避免查询数据库,从而提高认证效率。 + + +RefreshHandler + + +RefreshHandler函数会先执行Bearer认证,如果认证通过,则会重新签发Token。 + + +LogoutHandler + + +最后,来看下LogoutHandler函数: + +func (mw *GinJWTMiddleware) LogoutHandler(c *gin.Context) { + // delete auth cookie + if mw.SendCookie { + if mw.CookieSameSite != 0 { + c.SetSameSite(mw.CookieSameSite) + } + + c.SetCookie( + mw.CookieName, + "", + -1, + "/", + mw.CookieDomain, + mw.SecureCookie, + mw.CookieHTTPOnly, + ) + } + + mw.LogoutResponse(c, http.StatusOK) +} + + +可以看到,LogoutHandler其实是用来清空Cookie中Bearer认证相关信息的。 + +最后,我们来做个总结:Basic认证通过用户名和密码来进行认证,通常用在登陆接口/login中。用户登陆成功后,会返回JWT Token,前端会保存该JWT Token在浏览器的Cookie或LocalStorage中,供后续请求使用。 + +后续请求时,均会携带该Token,以完成Bearer认证。另外,有了登陆接口,一般还会配套/logout接口和/refresh接口,分别用来进行注销和刷新Token。 + +这里你可能会问,为什么要刷新Token?因为通过登陆接口签发的Token有过期时间,有了刷新接口,前端就可以根据需要,自行刷新Token的过期时间。过期时间可以通过iam-apiserver配置文件的jwt.timeout配置项来指定。登陆后签发Token时,使用的密钥(secretKey)由jwt.key配置项来指定。 + +IAM项目是如何实现Bearer认证的? + +上面我们介绍了Basic认证。这里,我再来介绍下IAM项目中Bearer认证的实现方式。 + +IAM项目中有两个地方实现了Bearer认证,分别是 iam-apiserver 和 iam-authz-server。下面我来分别介绍下它们是如何实现Bearer认证的。 + +iam-authz-server Bearer认证实现 + +先来看下iam-authz-server是如何实现Bearer认证的。 + +iam-authz-server通过在 /v1 路由分组中加载cache认证中间件来使用cache认证策略: + +auth := newCacheAuth() +apiv1 := g.Group("/v1", auth.AuthFunc()) + + +来看下newCacheAuth函数: + +func newCacheAuth() middleware.AuthStrategy { + return auth.NewCacheStrategy(getSecretFunc()) +} + +func getSecretFunc() func(string) (auth.Secret, error) { + return func(kid string) (auth.Secret, error) { + cli, err := store.GetStoreInsOr(nil) + if err != nil { + return auth.Secret{}, errors.Wrap(err, "get store instance failed") + } + + secret, err := cli.GetSecret(kid) + if err != nil { + return auth.Secret{}, err + } + + return auth.Secret{ + Username: secret.Username, + ID: secret.SecretId, + Key: secret.SecretKey, + Expires: secret.Expires, + }, nil + } +} + + +newCacheAuth函数调用auth.NewCacheStrategy创建了一个cache认证策略,创建时传入了getSecretFunc函数,该函数会返回密钥的信息。密钥信息包含了以下字段: + +type Secret struct { + Username string + ID string + Key string + Expires int64 +} + + +再来看下cache认证策略实现的AuthFunc方法: + +func (cache CacheStrategy) AuthFunc() gin.HandlerFunc { + return func(c *gin.Context) { + header := c.Request.Header.Get("Authorization") + if len(header) == 0 { + core.WriteResponse(c, errors.WithCode(code.ErrMissingHeader, "Authorization header cannot be empty."), nil) + c.Abort() + + return + } + + var rawJWT string + // Parse the header to get the token part. + fmt.Sscanf(header, "Bearer %s", &rawJWT) + + // Use own validation logic, see below + var secret Secret + + claims := &jwt.MapClaims{} + // Verify the token + parsedT, err := jwt.ParseWithClaims(rawJWT, claims, func(token *jwt.Token) (interface{}, error) { + // Validate the alg is HMAC signature + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, ErrMissingKID + } + + var err error + secret, err = cache.get(kid) + if err != nil { + return nil, ErrMissingSecret + } + + return []byte(secret.Key), nil + }, jwt.WithAudience(AuthzAudience)) + if err != nil || !parsedT.Valid { + core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, err.Error()), nil) + c.Abort() + + return + } + + if KeyExpired(secret.Expires) { + tm := time.Unix(secret.Expires, 0).Format("2006-01-02 15:04:05") + core.WriteResponse(c, errors.WithCode(code.ErrExpired, "expired at: %s", tm), nil) + c.Abort() + + return + } + + c.Set(CtxUsername, secret.Username) + c.Next() + } +} + +// KeyExpired checks if a key has expired, if the value of user.SessionState.Expires is 0, it will be ignored. +func KeyExpired(expires int64) bool { + if expires >= 1 { + return time.Now().After(time.Unix(expires, 0)) + } + + return false +} + + +AuthFunc函数依次执行了以下四大步来完成JWT认证,每一步中又有一些小步骤,下面我们来一起看看。 + +第一步,从Authorization: Bearer XX.YY.ZZ请求头中获取XX.YY.ZZ,XX.YY.ZZ即为JWT Token。 + +第二步,调用github.com/dgrijalva/jwt-go包提供的ParseWithClaims函数,该函数会依次执行下面四步操作。 + +调用ParseUnverified函数,依次执行以下操作: + +从Token中获取第一段XX,base64解码后得到JWT Token的Header{“alg”:“HS256”,“kid”:“a45yPqUnQ8gljH43jAGQdRo0bXzNLjlU0hxa”,“typ”:“JWT”}。 + +从Token中获取第二段YY,base64解码后得到JWT Token的Payload{“aud”:“iam.authz.marmotedu.com”,“exp”:1625104314,“iat”:1625097114,“iss”:“iamctl”,“nbf”:1625097114}。 + +根据Token Header中的alg字段,获取Token加密函数。 + +最终ParseUnverified函数会返回Token类型的变量,Token类型包含 Method、Header、Claims、Valid这些重要字段,这些字段会用于后续的认证步骤中。 + +调用传入的keyFunc获取密钥,这里来看下keyFunc的实现: + +func(token *jwt.Token) (interface{}, error) { + // Validate the alg is HMAC signature + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, ErrMissingKID + } + + var err error + secret, err = cache.get(kid) + if err != nil { + return nil, ErrMissingSecret + } + + return []byte(secret.Key), nil +} + + +可以看到,keyFunc接受 *Token 类型的变量,并获取Token Header中的kid,kid即为密钥ID:secretID。接着,调用cache.get(kid)获取密钥secretKey。cache.get函数即为getSecretFunc,getSecretFunc函数会根据kid,从内存中查找密钥信息,密钥信息中包含了secretKey。 + + +从Token中获取Signature签名字符串ZZ,也即Token的第三段。 +获取到secretKey之后,token.Method.Verify验证Signature签名字符串ZZ,也即Token的第三段是否合法。token.Method.Verify实际上是使用了相同的加密算法和相同的secretKey加密XX.YY字符串。假设加密之后的字符串为WW,接下来会用WW和ZZ base64解码后的字符串进行比较,如果相等则认证通过,如果不相等则认证失败。 + + +第三步,调用KeyExpired,验证secret是否过期。secret信息中包含过期时间,你只需要拿该过期时间和当前时间对比就行。 + +第四步,设置HTTP Headerusername: colin。 + +到这里,iam-authz-server的Bearer认证分析就完成了。 + +我们来做个总结:iam-authz-server通过加载Gin中间件的方式,在请求/v1/authz接口时进行访问认证。因为Bearer认证具有过期时间,而且可以在认证字符串中携带更多有用信息,还具有不可逆加密等优点,所以/v1/authz采用了Bearer认证,Token格式采用了JWT格式,这也是业界在API认证中最受欢迎的认证方式。 + +Bearer认证需要secretID和secretKey,这些信息会通过gRPC接口调用,从iam-apisaerver中获取,并缓存在iam-authz-server的内存中供认证时查询使用。 + +当请求来临时,iam-authz-server Bearer认证中间件从JWT Token中解析出Header,并从Header的kid字段中获取到secretID,根据secretID查找到secretKey,最后使用secretKey加密JWT Token的Header和Payload,并与Signature部分进行对比。如果相等,则认证通过;如果不等,则认证失败。 + +iam-apiserver Bearer认证实现 + +再来看下 iam-apiserver的Bearer认证。 + +iam-apiserver的Bearer认证通过以下代码(位于router.go文件中)指定使用了auto认证策略: + +v1.Use(auto.AuthFunc()) + + +我们来看下auto.AuthFunc()的实现: + +func (a AutoStrategy) AuthFunc() gin.HandlerFunc { + return func(c *gin.Context) { + operator := middleware.AuthOperator{} + authHeader := strings.SplitN(c.Request.Header.Get("Authorization"), " ", 2) + + if len(authHeader) != authHeaderCount { + core.WriteResponse( + c, + errors.WithCode(code.ErrInvalidAuthHeader, "Authorization header format is wrong."), + nil, + ) + c.Abort() + + return + } + + switch authHeader[0] { + case "Basic": + operator.SetStrategy(a.basic) + case "Bearer": + operator.SetStrategy(a.jwt) + // a.JWT.MiddlewareFunc()(c) + default: + core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, "unrecognized Authorization header."), nil) + c.Abort() + + return + } + + operator.AuthFunc()(c) + + c.Next() + } +} + + +从上面代码中可以看到,AuthFunc函数会从Authorization Header中解析出认证方式是Basic还是Bearer。如果是Bearer,就会使用JWT认证策略;如果是Basic,就会使用Basic认证策略。 + +我们再来看下JWT认证策略的AuthFunc函数实现: + +func (j JWTStrategy) AuthFunc() gin.HandlerFunc { + return j.MiddlewareFunc() +} + + +我们跟随代码,可以定位到MiddlewareFunc函数最终调用了github.com/appleboy/gin-jwt包GinJWTMiddleware结构体的middlewareImpl方法: + +func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) { + claims, err := mw.GetClaimsFromJWT(c) + if err != nil { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c)) + return + } + + if claims["exp"] == nil { + mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c)) + return + } + + if _, ok := claims["exp"].(float64); !ok { + mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c)) + return + } + + if int64(claims["exp"].(float64)) < mw.TimeFunc().Unix() { + mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c)) + return + } + + c.Set("JWT_PAYLOAD", claims) + identity := mw.IdentityHandler(c) + + if identity != nil { + c.Set(mw.IdentityKey, identity) + } + + if !mw.Authorizator(identity, c) { + mw.unauthorized(c, http.StatusForbidden, mw.HTTPStatusMessageFunc(ErrForbidden, c)) + return + } + + c.Next() +} + + +分析上面的代码,我们可以知道,middlewareImpl的Bearer认证流程为: + +第一步:调用GetClaimsFromJWT函数,从HTTP请求中获取Authorization Header,并解析出Token字符串,进行认证,最后返回Token Payload。 + +第二步:校验Payload中的exp是否超过当前时间,如果超过就说明Token过期,校验不通过。 + +第三步:给gin.Context中添加JWT_PAYLOAD键,供后续程序使用(当然也可能用不到)。 + +第四步:通过以下代码,在gin.Context中添加IdentityKey键,IdentityKey键可以在创建GinJWTMiddleware结构体时指定,这里我们设置为middleware.UsernameKey,也就是username。 + +identity := mw.IdentityHandler(c) + +if identity != nil { + c.Set(mw.IdentityKey, identity) +} + + +IdentityKey键的值由IdentityHandler函数返回,IdentityHandler函数为: + +func(c *gin.Context) interface{} { + claims := jwt.ExtractClaims(c) + + return claims[jwt.IdentityKey] +} + + +上述函数会从Token的Payload中获取identity域的值,identity域的值是在签发Token时指定的,它的值其实是用户名,你可以查看payloadFunc函数了解。 + +第五步:接下来,会调用Authorizator方法,Authorizator是一个callback函数,成功时必须返回真,失败时必须返回假。Authorizator也是在创建GinJWTMiddleware时指定的,例如: + +func authorizator() func(data interface{}, c *gin.Context) bool { + return func(data interface{}, c *gin.Context) bool { + if v, ok := data.(string); ok { + log.L(c).Infof("user `%s` is authenticated.", v) + + return true + } + + return false + } +} + + +authorizator函数返回了一个匿名函数,匿名函数在认证成功后,会打印一条认证成功日志。 + +IAM项目认证功能设计技巧 + +我在设计IAM项目的认证功能时,也运用了一些技巧,这里分享给你。 + +技巧1:面向接口编程 + +在使用NewAutoStrategy函数创建auto认证策略时,传入了middleware.AuthStrategy接口类型的参数,这意味着Basic认证和Bearer认证都可以有不同的实现,这样后期可以根据需要扩展新的认证方式。 + +技巧2:使用抽象工厂模式 + +auth.go文件中,通过newBasicAuth、newJWTAuth、newAutoAuth创建认证策略时,返回的都是接口。通过返回接口,可以在不公开内部实现的情况下,让调用者使用你提供的各种认证功能。 + +技巧3:使用策略模式 + +在auto认证策略中,我们会根据HTTP 请求头Authorization: XXX X.Y.X中的XXX来选择并设置认证策略(Basic 或 Bearer)。具体可以查看AutoStrategy的AuthFunc函数: + +func (a AutoStrategy) AuthFunc() gin.HandlerFunc { + return func(c *gin.Context) { + operator := middleware.AuthOperator{} + authHeader := strings.SplitN(c.Request.Header.Get("Authorization"), " ", 2) + ... + switch authHeader[0] { + case "Basic": + operator.SetStrategy(a.basic) + case "Bearer": + operator.SetStrategy(a.jwt) + // a.JWT.MiddlewareFunc()(c) + default: + core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, "unrecognized Authorization header."), nil) + c.Abort() + + return + } + + operator.AuthFunc()(c) + + c.Next() + } +} + + +上述代码中,如果是Basic,则设置为Basic认证方法operator.SetStrategy(a.basic);如果是Bearer,则设置为Bearer认证方法operator.SetStrategy(a.jwt)。 SetStrategy方法的入参是AuthStrategy类型的接口,都实现了AuthFunc() gin.HandlerFunc函数,用来进行认证,所以最后我们调用operator.AuthFunc()(c)即可完成认证。 + +总结 + +在IAM项目中,iam-apiserver实现了Basic认证和Bearer认证,iam-authz-server实现了Bearer认证。这一讲重点介绍了iam-apiserver的认证实现。 + +用户要访问iam-apiserver,首先需要通过Basic认证,认证通过之后,会返回JWT Token和JWT Token的过期时间。前端将Token缓存在LocalStorage或Cookie中,后续的请求都通过Token来认证。 + +执行Basic认证时,iam-apiserver会从HTTP Authorization Header中解析出用户名和密码,将密码再加密,并和数据库中保存的值进行对比。如果不匹配,则认证失败,否则认证成功。认证成功之后,会返回Token,并在Token的Payload部分设置用户名,Key为 username 。 + +执行Bearer认证时,iam-apiserver会从JWT Token中解析出Header和Payload,并从Header中获取加密算法。接着,用获取到的加密算法和从配置文件中获取到的密钥对Header.Payload进行再加密,得到Signature,并对比两次的Signature是否相等。如果不相等,则返回 HTTP 401 Unauthorized 错误;如果相等,接下来会判断Token是否过期,如果过期则返回认证不通过,否则认证通过。认证通过之后,会将Payload中的username添加到gin.Context类型的变量中,供后面的业务逻辑使用。 + +我绘制了整个流程的示意图,你可以对照着再回顾一遍。 + + + +课后练习 + + +走读github.com/appleboy/gin-jwt包的GinJWTMiddleware结构体的GetClaimsFromJWT方法,分析一下:GetClaimsFromJWT方法是如何从gin.Context中解析出Token,并进行认证的? +思考下,iam-apiserver和iam-authzserver是否可以使用同一个认证策略?如果可以,又该如何实现? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/27权限模型:5大权限模型是如何进行资源授权的?.md b/专栏/Go语言项目开发实战/27权限模型:5大权限模型是如何进行资源授权的?.md new file mode 100644 index 0000000..f7f42ca --- /dev/null +++ b/专栏/Go语言项目开发实战/27权限模型:5大权限模型是如何进行资源授权的?.md @@ -0,0 +1,345 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 27 权限模型:5大权限模型是如何进行资源授权的? + 你好,我是孔令飞。在开始讲解如何开发服务之前,我先来介绍一个比较重要的背景知识:权限模型。 + +在你的研发生涯中,应该会遇到这样一种恐怖的操作:张三因为误操作删除了李四的资源。你在刷新闻时,也可能会刷到这么一个爆款新闻:某某程序员删库跑路。操作之所以恐怖,新闻之所以爆款,是因为这些行为往往会带来很大的损失。 + +那么如何避免这些风险呢?答案就是对资源做好权限管控,这也是项目开发中绕不开的话题。腾讯云会强制要求所有的云产品都对接 访问管理(CAM) 服务(阿里云也有这种要求),之所以这么做,是因为保证资源的安全是一件非常非常重要的事情。 + +可以说,保证应用的资源安全,已经成为一个应用的必备能力。作为开发人员,你也一定要知道如何保障应用的资源安全。那么如何才能保障资源的安全呢?我认为你至少需要掌握下面这两点: + + +权限模型:你需要了解业界成熟的权限模型,以及这些模型的适用场景。只有具备足够宽广的知识面和视野,我们才能避免闭门造车,设计出优秀的资源授权方案。 +编码实现:选择或设计出了优秀的资源授权方案后,你就要编写代码实现该方案。这门课的 IAM 应用,就是一个资源授权方案的落地项目。你可以通过对 IAM 应用的学习,来掌握如何实现一个资源授权系统。 + + +无论是第一点还是第二点,都需要你掌握基本的权限模型知识。那么这一讲,我就来介绍下业界优秀的权限模型,以及这些模型的适用场景,以使你今后设计出更好的资源授权系统。 + +权限相关术语介绍 + +在介绍业界常见的权限模型前,我们先来看下在权限模型中出现的术语。我把常见的术语总结在了下面的表格里: + + + +为了方便你理解,这一讲我分别用用户、操作和资源来替代 Subject、Action 和 Object。 + +权限模型介绍 + +接下来,我就详细介绍下一些常见的权限模型,让你今后在设计权限系统时,能够根据需求选择合适的权限模型。 + +不同的权限模型具有不同的特点,可以满足不同的需求。常见的权限模型有下面这 5 种: + + +权限控制列表(ACL,Access Control List)。 +自主访问控制(DAC,Discretionary Access Control)。 +强制访问控制(MAC,Mandatory Access Control)。 +基于角色的访问控制(RBAC,Role-Based Access Control)。 +基于属性的权限验证(ABAC,Attribute-Based Access Control)。 + + +这里先简单介绍下这 5 种权限模型。ACL 是一种简单的权限模型;DAC 基于 ACL,将权限下放给具有此权限的主题;但 DAC 因为权限下放,导致它对权限的控制过于分散,为了弥补 DAC 的这个缺陷,诞生了 MAC 权限模型。 + +DAC 和 MAC 都是基于 ACL 的权限模型。ACL 及其衍生的权限模型可以算是旧时代的权限模型,灵活性和功能性都满足不了现代应用的权限需求,所以诞生了 RBAC。RBAC 也是迄今为止最为普及的权限模型。 + +但是,随着组织和应用规模的增长,所需的角色数量越来越多,变得难以管理,进而导致角色爆炸和职责分离(SoD)失败。最后,引入了一种新的、更动态的访问控制形式,称为基于属性的访问控制,也就是 ABAC。ABAC 被一些人看作是权限系统设计的未来。腾讯云的 CAM、AWS 的 IAM、阿里云的 RAM 都是 ABAC 类型的权限访问服务。 + +接下来,我会详细介绍这些权限模型的基本概念。 + +简单的权限模型:权限控制列表(ACL) + +ACL(Access Control List,权限控制列表),用来判断用户是否可以对资源做特定的操作。例如,允许 Colin 创建文章的 ACL 策略为: + +Subject: Colin +Action: Create +Object: Article + + +在 ACL 权限模型下,权限管理是围绕资源 Object 来设定的,ACL 权限模型也是比较简单的一种模型。 + +基于 ACL 下放权限的权限模型:自主访问控制(DAC) + +DAC (Discretionary Access Control,自主访问控制),是 ACL 的扩展模型,灵活性更强。使用这种模型,不仅可以判断 Subject 是否可以对 Object 做 Action 操作,同时也能让 Subject 将 Object、Action 的相同权限授权给其他的 Subject。例如,Colin 可以创建文章: + +Subject: Colin +Action: Create +Object: Article + + +因为 Colin 具有创建文章的权限,所以 Colin 也可以授予 James 创建文章的权限: + +Subject: James +Action: Create +Object: Article + + +经典的 ACL 模型权限集中在同一个 Subject 上,缺乏灵活性,为了加强灵活性,在 ACL 的基础上,DAC 模型将权限下放,允许拥有权限的 Subject 自主地将权限授予其他 Subject。 + +基于 ACL 且安全性更高的权限模型:强制访问控制(MAC) + +MAC (Mandatory Access Control,强制访问控制),是 ACL 的扩展模型,安全性更高。MAC 权限模型下,Subject 和 Object 同时具有安全属性。在做授权时,需要同时满足两点才能授权通过: + + +Subject 可以对 Object 做 Action 操作。 +Object 可以被 Subject 做 Action 操作。 + + +例如,我们设定了“Colin 和 James 可以创建文章”这个 MAC 策略: + +Subject: Colin +Action: Create +Object: Article + +Subject: James +Action: Create +Object: Article + + +我们还有另外一个 MAC 策略“文章可以被 Colin 创建”: + +Subject: Article +Action: Create +Object: Colin + + +在上述策略中,Colin 可以创建文章,但是 James 不能创建文章,因为第二条要求没有满足。 + +这里你需要注意,在 ACL 及其扩展模型中,Subject 可以是用户,也可以是组或群组。 + +ACL、DAC 和 MAC 是旧时代的权限控制模型,无法满足现代应用对权限控制的需求,于是诞生了新时代的权限模型:RBAC 和 ABAC。 + +最普及的权限模型:基于角色的访问控制(RBAC) + +RBAC (Role-Based Access Control,基于角色的访问控制),引入了 Role(角色)的概念,并且将权限与角色进行关联。用户通过扮演某种角色,具有该角色的所有权限。具体如下图所示: + + + +如图所示,每个用户关联一个或多个角色,每个角色关联一个或多个权限,每个权限又包含了一个或者多个操作,操作包含了对资源的操作集合。通过用户和权限解耦,可以实现非常灵活的权限管理。例如,可以满足以下两个权限场景: + +第一,可以通过角色批量给一个用户授权。例如,公司新来了一位同事,需要授权虚拟机的生产、销毁、重启和登录权限。这时候,我们可以将这些权限抽象成一个运维角色。如果再有新同事来,就可以通过授权运维角色,直接批量授权这些权限,不用一个个地给用户授权这些权限。 + +第二,可以批量修改用户的权限。例如,我们有很多用户,同属于运维角色,这时候对运维角色的任何权限变更,就相当于对运维角色关联的所有用户的权限变更,不用一个个去修改这些用户的权限。 + +RBAC 又分为 RBAC0、RBAC1、RBAC2、RBAC3。RBAC0 是 RBAC 的核心思想,RBAC1 是基于 RBAC 的角色分层模型,RBAC2 增加了 RBAC 的约束模型。而 RBAC3,其实相当于 RBAC1 + RBAC2。 + +下面我来详细介绍下这四种 RBAC。 + +RBAC0:基础模型,只包含核心的四要素,也就是用户(User)、角色(Role)、权限(Permission:Objects-Operations)、会话(Session)。用户和角色可以是多对多的关系,权限和角色也是多对多的关系。 + +RBAC1:包括了 RBAC0,并且添加了角色继承。角色继承,即角色可以继承自其他角色,在拥有其他角色权限的同时,还可以关联额外的权限。 + +RBAC2:包括 RBAC0,并且添加了约束。具有以下核心特性: + + +互斥约束:包括互斥用户、互斥角色、互斥权限。同一个用户不能拥有相互排斥的角色,两个互斥角色不能分配一样的权限集,互斥的权限不能分配给同一个角色,在 Session 中,同一个角色不能拥有互斥权限。 +基数约束:一个角色被分配的用户数量受限,它指的是有多少用户能拥有这个角色。例如,一个角色是专门为公司 CEO 创建的,那这个角色的数量就是有限的。 +先决条件角色:指要想获得较高的权限,要首先拥有低一级的权限。例如,先有副总经理权限,才能有总经理权限。 +静态职责分离(Static Separation of Duty):用户无法同时被赋予有冲突的角色。 +动态职责分离(Dynamic Separation of Duty):用户会话中,无法同时激活有冲突的角色。 + + +RBAC3:全功能的 RBAC,合并了 RBAC0、RBAC1、RBAC2。 + +此外,RBAC 也可以很方便地模拟出 DAC 和 MAC 的效果。 + +这里举个例子,来协助你理解 RBAC。例如,我们有 write article 和 manage article 的权限: + +Permission: + - Name: write_article + - Effect: "allow" + - Action: ["Create", "Update", "Read"] + - Object: ["Article"] + - Name: manage_article + - Effect: "allow" + - Action: ["Delete", "Read"] + - Object: ["Article"] + + +同时,我们也有 Writer、Manager和 CEO 3个角色,Writer 具有 write_article 权限,Manager 具有 manage_article 权限,CEO 具有所有权限: + +Role: + - Name: Writer + Permissions: + - write_article + - Name: Manager + Permissions: + - manage_article + - Name: CEO + Permissions: + - write_article + - manage_article + + +接下来,我们对 Colin 用户授予 Writer 角色: + +Subject: Colin +Roles: + - Writer + + +那么现在 Colin 就具有 Writer 角色的所有权限 write_article,write_article 权限可以创建文章。 + +接下来,再对 James 用户授予 Writer 和 Manager 角色: + +Subject: James +Roles: + - Writer + - Manager + + +那么现在 James 就具有 Writer 角色和 Manager 角色的所有权限:write_article、manage_article,这些权限允许 James 创建和删除文章。 + +最强大的权限模型:基于属性的权限验证(ABAC) + +ABAC (Attribute-Based Access Control,基于属性的权限验证),规定了哪些属性的用户可以对哪些属性的资源在哪些限制条件下进行哪些操作。跟 RBAC 相比,ABAC 对权限的控制粒度更细,主要规定了下面这四类属性: + + +用户属性,例如性别、年龄、工作等。 +资源属性,例如创建时间、所属位置等。 +操作属性,例如创建、修改等。 +环境属性,例如来源 IP、当前时间等。 + + +下面是一个 ABAC 策略: + +Subject: + Name: Colin + Department: Product + Role: Writer +Action: + - create + - update +Resource: + Type: Article + Tag: + - technology + - software + Mode: + - draft +Contextual: + IP: 10.0.0.10 + + +上面权限策略描述的意思是,产品部门的 Colin 作为一个 Writer 角色,可以通过来源 IP 是 10.0.0.10 的客户端,创建和更新带有 technology 和 software 标签的草稿文章。 + +这里提示一点:ABAC 有时也被称为 PBAC(Policy-Based Access Control)或 CBAC(Claims-Based Access Control)。 + +这里,我通过现实中的 ABAC 授权策略,帮你理解 ABAC 权限模型。下面是一个腾讯云的 CAM 策略,也是一种 ABAC 授权模式: + +{ + "version": "2.0", + "statement": [ + { + "effect": "allow", + "action": [ + "cos:List*", + "cos:Get*", + "cos:Head*", + "cos:OptionsObject" + ], + "resource": "qcs::cos:ap-shanghai:uid/1250000000:Bucket1-1250000000/dir1/*", + "condition": { + "ip_equal": { + "qcs:ip": [ + "10.217.182.3/24", + "111.21.33.72/24" + ] + } + } + } + ] +} + + +上面的授权策略表示:用户必须在 10.217.182.3⁄24 或者 111.21.33.72⁄24 网段才能调用云 API(cos:List*、cos:Get*、cos:Head*、cos:OptionsObject),对 1250000000 用户下的 dir1 目录下的文件进行读取操作。 + +这里,ABAC 规定的四类属性分别是: + + +用户属性:用户为 1250000000。 +资源属性:dir1 目录下的文件。 +操作属性:读取(cos:List*、cos:Get*、cos:Head*、cos:OptionsObject 都是读取 API)。 +环境属性:10.217.182.3⁄24 或者 111.21.33.72⁄24 网段。 + + +相关开源项目 + +上面我介绍了权限模型的相关知识,但是现在如果让你真正去实现一个权限系统,你可能还是不知从何入手。 + +在这里,我列出了一些 GitHub 上比较优秀的开源项目,你可以学习这些项目是如何落地一个权限模型的,也可以基于这些项目进行二次开发,开发一个满足业务需求的权限系统。 + +Casbin + +Casbin 是一个用 Go 语言编写的访问控制框架,功能强大,支持 ACL、RBAC、ABAC 等访问模型,很多优秀的权限管理系统都是基于 Casbin 来构建的。Casbin 的核心功能都是围绕着访问控制来构建的,不负责身份认证。如果以后老板让你实现一个权限管理系统,Casbin 是一定要好好研究的开源项目。 + +keto + +keto 是一个云原生权限控制服务,通过提供 REST API 进行授权,支持 RBAC、ABAC、ACL、AWS IAM 策略、Kubernetes Roles 等权限模型,可以解决下面这些问题: + + +是否允许某些用户修改此博客文章? +是否允许某个服务打印该文档? +是否允许 ACME 组织的成员修改其租户中的数据? +是否允许在星期一的下午 4 点到下午 5 点,从 IP 10.0.0.2 发出的请求执行某个 Job? + + +go-admin + +go-admin 是一个基于 Gin + Vue + Element UI 的前后端分离权限管理系统脚手架,它的访问控制模型采用了 Casbin 的 RBAC 访问控制模型,功能强大,包含了如下功能: + + +基础用户管理功能; +JWT 鉴权; +代码生成器; +RBAC 权限控制; +表单构建; +…… + + +该项目还支持 RESTful API 设计规范、Swagger 文档、GORM 库等。go-admin 不仅是一个优秀的权限管理系统,也是一个优秀的、功能齐全的 Go 开源项目。你在做项目开发时,也可以参考该项目的构建思路。go-admin 管理系统自带前端,如下图所示。 + + + +LyricTian/gin-admin + +gin-admin 类似于 go-admin,是一个基于 Gin+Gorm+Casbin+Wire 实现的权限管理脚手架,并自带前端,在做权限管理系统调研时,也非常值得参考。 + +gin-admin 大量采用了 Go 后端开发常用的技术,比如 Gin、GORM、JWT 认证、RESTful API、Logrus 日志包、Swagger 文档等。因此,你在做 Go 后端服务开发时,也可以学习该项目的构建方法。 + +gin-vue-admin + +gin-vue-admin 是一个基于 Gin 和 Vue 开发的全栈前后端分离的后台管理系统,集成了 JWT 鉴权、动态路由、动态菜单、Casbin 鉴权、表单生成器、代码生成器等功能。gin-vue-admin 集成了 RBAC 权限管理模型,界面如下图所示: + + + +选择建议 + +介绍了那么多优秀的开源项目,最后我想给你一些选择建议。如果你想研究 ACL、RBAC、ABAC 等权限模型如何落地,我强烈建议你学习 Casbin 项目,Casbin 目前有近万的 GitHub star 数,处于活跃的开发状态。有很多项目在使用 Casbin,例如 go-admin、 gin-admin 、 gin-vue-admin 等。 + +keto 类似于 Casbin,主要通过 Go 包的方式,对外提供授权能力。keto 也是一个非常优秀的权限类项目,当你研究完 Casbin 后,如果还想再研究下其他授权类项目,建议你读下 keto 的源码。 + +go-admin、gin-vue-admin、gin-admin 这 3 个都是基于 Casbin 的 Go 项目。其中,gin-vue-admin 是后台管理系统框架,里面包含了 RBAC 权限管理模块;go-admin 和 gin-admin 都是 RBAC 权限管理脚手架。所以,如果你想找一个比较完整的 RBAC 授权系统(自带前后端),建议你优先研究下 go-admin,如果还有精力,可以再研究下 gin-admin、gin-vue-admin。 + +总结 + +这一讲,我介绍了 5 种常见的权限模型。其中,ACL 最简单,ABAC 最复杂,但是功能最强大,也最灵活。RBAC 则介于二者之间。对于一些云计算厂商来说,因为它们面临的授权场景复杂多样,需要一个非常强大的授权模型,所以腾讯云、阿里云和 AWS 等云厂商普遍采用了 ABAC 模型。 + +如果你的资源授权需求不复杂,可以考虑 RBAC;如果你需要一个能满足复杂场景的资源授权系统,建议选择 ABAC,ABAC 的设计思路可以参考下腾讯云的 CAM、阿里云的 RAM 和 AWS 的 IAM。 + +另外,如果你想深入了解权限模型如何具体落地,建议你阅读 Casbin 源码。 + +课后练习 + + +思考一下,如果公司需要你实现一个授权中台系统,应该选用哪种权限模型来构建,来满足不同业务的不同需求? +思考一下,如何将授权流程集成进统一接入层,例如 API 网关? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/28控制流(上):通过iam-apiserver设计,看Web服务的构建.md b/专栏/Go语言项目开发实战/28控制流(上):通过iam-apiserver设计,看Web服务的构建.md new file mode 100644 index 0000000..2b09699 --- /dev/null +++ b/专栏/Go语言项目开发实战/28控制流(上):通过iam-apiserver设计,看Web服务的构建.md @@ -0,0 +1,677 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 控制流(上):通过iam-apiserver设计,看Web服务的构建 + 你好,我是孔令飞。 + +前面我们讲了很多关于应用构建的内容,你一定迫不及待地想看下IAM项目的应用是如何构建的。那么接下来,我就讲解下IAM应用的源码。 + +在讲解过程中,我不会去讲解具体如何Code,但会讲解一些构建过程中的重点、难点,以及Code背后的设计思路、想法。我相信这是对你更有帮助的。 + +IAM项目有很多组件,这一讲,我先来介绍下IAM项目的门面服务:iam-apiserver(管理流服务)。我会先给你介绍下iam-apiserver的功能和使用方法,再介绍下iam-apiserver的代码实现。 + +iam-apiserver服务介绍 + +iam-apiserver是一个Web服务,通过一个名为iam-apiserver的进程,对外提供RESTful API接口,完成用户、密钥、策略三种REST资源的增删改查。接下来,我从功能和使用方法两个方面来具体介绍下。 + +iam-apiserver功能介绍 + +这里,我们可以通过iam-apiserver提供的RESTful API接口,来看下iam-apiserver具体提供的功能。iam-apiserver提供的RESTful API接口可以分为四类,具体如下: + +认证相关接口 + + + +用户相关接口 + + + +密钥相关接口 + + + +策略相关接口 + + + +iam-apiserver使用方法介绍 + +上面我介绍了iam-apiserver的功能,接下来就介绍下如何使用这些功能。 + +我们可以通过不同的客户端来访问iam-apiserver,例如前端、API调用、SDK、iamctl等。这些客户端最终都会执行HTTP请求,调用iam-apiserver提供的RESTful API接口。所以,我们首先需要有一个顺手的REST API客户端工具来执行HTTP请求,完成开发测试。 + +因为不同的开发者执行HTTP请求的方式、习惯不同,为了方便讲解,这里我统一通过cURL工具来执行HTTP请求。接下来先介绍下cURL工具。 + +标准的Linux发行版都安装了cURL工具。cURL可以很方便地完成RESTful API的调用场景,比如设置Header、指定HTTP请求方法、指定HTTP消息体、指定权限认证信息等。通过-v选项,也能输出REST请求的所有返回信息。cURL功能很强大,有很多参数,这里列出cURL工具常用的参数: + +-X/--request [GET|POST|PUT|DELETE|…] 指定请求的 HTTP 方法 +-H/--header 指定请求的 HTTP Header +-d/--data 指定请求的 HTTP 消息体(Body) +-v/--verbose 输出详细的返回信息 +-u/--user 指定账号、密码 +-b/--cookie 读取 cookie + + +此外,如果你想使用带UI界面的工具,这里我推荐你使用 Insomnia 。 + +Insomnia是一个跨平台的REST API客户端,与Postman、Apifox是一类工具,用于接口管理、测试。Insomnia功能强大,支持以下功能: + + +发送HTTP请求; +创建工作区或文件夹; +导入和导出数据; +导出cURL格式的HTTP请求命令; +支持编写swagger文档; +快速切换请求; +URL编码和解码。 +… + + +Insomnia界面如下图所示: + + + +当然了,也有很多其他优秀的带UI界面的REST API客户端,例如 Postman、Apifox等,你可以根据需要自行选择。 + +接下来,我用对secret资源的CURD操作,来给你演示下如何使用iam-apiserver的功能。你需要执行6步操作。 + + +登录iam-apiserver,获取token。 +创建一个名为secret0的secret。 +获取secret0的详细信息。 +更新secret0的描述。 +获取secret列表。 +删除secret0。 + + +具体操作如下: + + +登录iam-apiserver,获取token: + + +$ curl -s -XPOST -H"Authorization: Basic `echo -n 'admin:Admin@2021'|base64`" http://127.0.0.1:8080/login | jq -r .token +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MzUwNTk4NDIsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MjcyODM4NDIsInN1YiI6ImFkbWluIn0.gTS0n-7njLtpCJ7mvSnct2p3TxNTUQaduNXxqqLwGfI + + +这里,为了便于使用,我们将token设置为环境变量: + +TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MzUwNTk4NDIsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MjcyODM4NDIsInN1YiI6ImFkbWluIn0.gTS0n-7njLtpCJ7mvSnct2p3TxNTUQaduNXxqqLwGfI + + + +创建一个名为secret0的secret: + + +$ curl -v -XPOST -H "Content-Type: application/json" -H"Authorization: Bearer ${TOKEN}" -d'{"metadata":{"name":"secret0"},"expires":0,"description":"admin secret"}' http://iam.api.marmotedu.com:8080/v1/secrets +* About to connect() to iam.api.marmotedu.com port 8080 (#0) +* Trying 127.0.0.1... +* Connected to iam.api.marmotedu.com (127.0.0.1) port 8080 (#0) +> POST /v1/secrets HTTP/1.1 +> User-Agent: curl/7.29.0 +> Host: iam.api.marmotedu.com:8080 +> Accept: */* +> Content-Type: application/json +> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXBpLm1hcm1vdGVkdS5jb20iLCJleHAiOjE2MzUwNTk4NDIsImlkZW50aXR5IjoiYWRtaW4iLCJpc3MiOiJpYW0tYXBpc2VydmVyIiwib3JpZ19pYXQiOjE2MjcyODM4NDIsInN1YiI6ImFkbWluIn0.gTS0n-7njLtpCJ7mvSnct2p3TxNTUQaduNXxqqLwGfI +> Content-Length: 72 +> +* upload completely sent off: 72 out of 72 bytes +< HTTP/1.1 200 OK +< Content-Type: application/json; charset=utf-8 +< X-Request-Id: ff825bea-53de-4020-8e68-4e87574bd1ba +< Date: Mon, 26 Jul 2021 07:20:26 GMT +< Content-Length: 313 +< +* Connection #0 to host iam.api.marmotedu.com left intact +{"metadata":{"id":60,"instanceID":"secret-jedr3e","name":"secret0","createdAt":"2021-07-26T15:20:26.885+08:00","updatedAt":"2021-07-26T15:20:26.907+08:00"},"username":"admin","secretID":"U6CxKs0YVWyOp5GrluychYIRxDmMDFd1mOOD","secretKey":"fubNIn8jLA55ktuuTpXM8Iw5ogdR2mlf","expires":0,"description":"admin secret"} + + +可以看到,请求返回头中返回了X-Request-Id Header,X-Request-Id唯一标识这次请求。如果这次请求失败,就可以将X-Request-Id提供给运维或者开发,通过X-Request-Id定位出失败的请求,进行排障。另外X-Request-Id在微服务场景中,也可以透传给其他服务,从而实现请求调用链。 + + +获取secret0的详细信息: + + +$ curl -XGET -H"Authorization: Bearer ${TOKEN}" http://iam.api.marmotedu.com:8080/v1/secrets/secret0 +{"metadata":{"id":60,"instanceID":"secret-jedr3e","name":"secret0","createdAt":"2021-07-26T15:20:26+08:00","updatedAt":"2021-07-26T15:20:26+08:00"},"username":"admin","secretID":"U6CxKs0YVWyOp5GrluychYIRxDmMDFd1mOOD","secretKey":"fubNIn8jLA55ktuuTpXM8Iw5ogdR2mlf","expires":0,"description":"admin secret"} + + + +更新secret0的描述: + + +$ curl -XPUT -H"Authorization: Bearer ${TOKEN}" -d'{"metadata":{"name":"secret"},"expires":0,"description":"admin secret(modify)"}' http://iam.api.marmotedu.com:8080/v1/secrets/secret0 +{"metadata":{"id":60,"instanceID":"secret-jedr3e","name":"secret0","createdAt":"2021-07-26T15:20:26+08:00","updatedAt":"2021-07-26T15:23:35.878+08:00"},"username":"admin","secretID":"U6CxKs0YVWyOp5GrluychYIRxDmMDFd1mOOD","secretKey":"fubNIn8jLA55ktuuTpXM8Iw5ogdR2mlf","expires":0,"description":"admin secret(modify)"} + + + +获取secret列表: + + +$ curl -XGET -H"Authorization: Bearer ${TOKEN}" http://iam.api.marmotedu.com:8080/v1/secrets +{"totalCount":1,"items":[{"metadata":{"id":60,"instanceID":"secret-jedr3e","name":"secret0","createdAt":"2021-07-26T15:20:26+08:00","updatedAt":"2021-07-26T15:23:35+08:00"},"username":"admin","secretID":"U6CxKs0YVWyOp5GrluychYIRxDmMDFd1mOOD","secretKey":"fubNIn8jLA55ktuuTpXM8Iw5ogdR2mlf","expires":0,"description":"admin secret(modify)"}]} + + + +删除secret0: + + +$ curl -XDELETE -H"Authorization: Bearer ${TOKEN}" http://iam.api.marmotedu.com:8080/v1/secrets/secret0 +null + + +上面,我给你演示了密钥的使用方法。用户和策略资源类型的使用方法跟密钥类似。详细的使用方法你可以参考 test.sh 脚本,该脚本是用来测试IAM应用的,里面包含了各个接口的请求方法。 + +这里,我还想顺便介绍下如何测试IAM应用中的各个部分。确保iam-apiserver、iam-authz-server、iam-pump等服务正常运行后,进入到IAM项目的根目录,执行以下命令: + +$ ./scripts/install/test.sh iam::test::test # 测试整个IAM应用是否正常运行 +$ ./scripts/install/test.sh iam::test::login # 测试登陆接口是否可以正常访问 +$ ./scripts/install/test.sh iam::test::user # 测试用户接口是否可以正常访问 +$ ./scripts/install/test.sh iam::test::secret # 测试密钥接口是否可以正常访问 +$ ./scripts/install/test.sh iam::test::policy # 测试策略接口是否可以正常访问 +$ ./scripts/install/test.sh iam::test::apiserver # 测试iam-apiserver服务是否正常运行 +$ ./scripts/install/test.sh iam::test::authz # 测试authz接口是否可以正常访问 +$ ./scripts/install/test.sh iam::test::authzserver # 测试iam-authz-server服务是否正常运行 +$ ./scripts/install/test.sh iam::test::pump # 测试iam-pump是否正常运行 +$ ./scripts/install/test.sh iam::test::iamctl # 测试iamctl工具是否可以正常使用 +$ ./scripts/install/test.sh iam::test::man # 测试man文件是否正确安装 + + +所以,每次发布完iam-apiserver后,你可以执行以下命令来完成iam-apiserver的冒烟测试: + +$ export IAM_APISERVER_HOST=127.0.0.1 # iam-apiserver部署服务器的IP地址 +$ export IAM_APISERVER_INSECURE_BIND_PORT=8080 # iam-apiserver HTTP服务的监听端口 +$ ./scripts/install/test.sh iam::test::apiserver + + +iam-apiserver代码实现 + +上面,我介绍了iam-apiserver的功能和使用方法,这里我们再来看下iam-apiserver具体的代码实现。我会从配置处理、启动流程、请求处理流程、代码架构4个方面来讲解。 + +iam-apiserver配置处理 + +iam-apiserver服务的main函数位于apiserver.go文件中,你可以跟读代码,了解iam-apiserver的代码实现。这里,我来介绍下iam-apiserver服务的一些设计思想。 + +首先,来看下iam-apiserver中的3种配置:Options配置、应用配置和 HTTP/GRPC服务配置。 + + +Options配置:用来构建命令行参数,它的值来自于命令行选项或者配置文件(也可能是二者Merge后的配置)。Options可以用来构建应用框架,Options配置也是应用配置的输入。 +应用配置:iam-apiserver组件中需要的一切配置。有很多地方需要配置,例如,启动HTTP/GRPC需要配置监听地址和端口,初始化数据库需要配置数据库地址、用户名、密码等。 +HTTP/GRPC服务配置:启动HTTP服务或者GRPC服务需要的配置。 + + +这三种配置的关系如下图: + + + +Options配置接管命令行选项,应用配置接管整个应用的配置,HTTP/GRPC服务配置接管跟HTTP/GRPC服务相关的配置。这3种配置独立开来,可以解耦命令行选项、应用和应用内的服务,使得这3个部分可以独立扩展,又不相互影响。 + +iam-apiserver根据Options配置来构建命令行参数和应用配置。 + +我们通过github.com/marmotedu/iam/pkg/app包的buildCommand方法来构建命令行参数。这里的核心是,通过NewApp函数构建Application实例时,传入的Options实现了Flags() (fss cliflag.NamedFlagSets)方法,通过buildCommand方法中的以下代码,将option的Flag添加到cobra实例的FlagSet中: + + if a.options != nil { + namedFlagSets = a.options.Flags() + fs := cmd.Flags() + for _, f := range namedFlagSets.FlagSets { + fs.AddFlagSet(f) + } + + ... + } + + +通过CreateConfigFromOptions函数来构建应用配置: + + cfg, err := config.CreateConfigFromOptions(opts) + if err != nil { + return err + } + + +根据应用配置来构建HTTP/GRPC服务配置。例如,以下代码根据应用配置,构建了HTTP服务器的Address参数: + +func (s *InsecureServingOptions) ApplyTo(c *server.Config) error { + c.InsecureServing = &server.InsecureServingInfo{ + Address: net.JoinHostPort(s.BindAddress, strconv.Itoa(s.BindPort)), + } + + return nil +} + + +其中,c *server.Config是HTTP服务器的配置,s *InsecureServingOptions是应用配置。 + +iam-apiserver启动流程设计 + +接下来,我们来详细看下iam-apiserver的启动流程设计。启动流程如下图所示: + + + +首先,通过opts := options.NewOptions()创建带有默认值的Options类型变量opts。opts变量作为github.com/marmotedu/iam/pkg/app包的NewApp函数的输入参数,最终在App框架中,被来自于命令行参数或配置文件的配置(也可能是二者Merge后的配置)所填充,opts变量中各个字段的值会用来创建应用配置。 + +接着,会注册run函数到App框架中。run函数是iam-apiserver的启动函数,里面封装了我们自定义的启动逻辑。run函数中,首先会初始化日志包,这样我们就可以根据需要,在后面的代码中随时记录日志了。 + +然后,会创建应用配置。应用配置和Options配置其实是完全独立的,二者可能完全不同,但在iam-apiserver中,二者配置项是相同的。 + +之后,根据应用配置,创建HTTP/GRPC服务器所使用的配置。在创建配置后,会先分别进行配置补全,再使用补全后的配置创建Web服务实例,例如: + +genericServer, err := genericConfig.Complete().New() +if err != nil { + return nil, err +} +extraServer, err := extraConfig.complete().New() +if err != nil { + return nil, err +} +... +func (c *ExtraConfig) complete() *completedExtraConfig { + if c.Addr == "" { + c.Addr = "127.0.0.1:8081" + } + + return &completedExtraConfig{c} +} + + +上面的代码中,首先调用Complete/complete函数补全配置,再基于补全后的配置,New一个HTTP/GRPC服务实例。 + +这里有个设计技巧:complete函数返回的是一个*completedExtraConfig类型的实例,在创建GRPC实例时,是调用completedExtraConfig结构体提供的New方法,这种设计方法可以确保我们创建的GRPC实例一定是基于complete之后的配置(completed)。 + +在实际的Go项目开发中,我们需要提供一种机制来处理或补全配置,这在Go项目开发中是一个非常有用的步骤。 + +最后,调用PrepareRun方法,进行HTTP/GRPC服务器启动前的准备。在准备函数中,我们可以做各种初始化操作,例如初始化数据库,安装业务相关的Gin中间件、RESTful API路由等。 + +完成HTTP/GRPC服务器启动前的准备之后,调用Run方法启动HTTP/GRPC服务。在Run方法中,分别启动了GRPC和HTTP服务。 + +可以看到,整个iam-apiserver的软件框架是比较清晰的。 + +服务启动后,就可以处理请求了。所以接下来,我们再来看下iam-apiserver的RESTAPI请求处理流程。 + +iam-apiserver 的REST API请求处理流程 + +iam-apiserver的请求处理流程也是清晰、规范的,具体流程如下图所示: + + + +结合上面这张图,我们来看下iam-apiserver 的REST API请求处理流程,来帮你更好地理解iam-apiserver是如何处理HTTP请求的。 + +首先,我们通过API调用( + )请求iam-apiserver提供的RESTful API接口。 + +接着,Gin Web框架接收到HTTP请求之后,会通过认证中间件完成请求的认证,iam-apiserver提供了Basic认证和Bearer认证两种认证方式。 + +认证通过后,请求会被我们加载的一系列中间件所处理,例如跨域、RequestID、Dump等中间件。 + +最后,根据 + 进行路由匹配。 + +举个例子,假设我们请求的RESTful API是POST + /v1/secrets,Gin Web框架会根据HTTP Method和HTTP Request Path,查找注册的Controllers,最终匹配到secretController.CreateController。在Create Controller中,我们会依次执行请求参数解析、请求参数校验、调用业务层的方法创建Secret、处理业务层的返回结果,最后返回最终的HTTP请求结果。 + +iam-apiserver代码架构 + +iam-apiserver代码设计遵循简洁架构设计,一个简洁架构具有以下5个特性: + + +独立于框架:该架构不会依赖于某些功能强大的软件库存在。这可以让你使用这样的框架作为工具,而不是让你的系统陷入到框架的约束中。 +可测试性:业务规则可以在没有UI、数据库、Web服务或其他外部元素的情况下进行测试,在实际的开发中,我们通过Mock来解耦这些依赖。 +独立于UI :在无需改变系统其他部分的情况下,UI可以轻松地改变。例如,在没有改变业务规则的情况下,Web UI可以替换为控制台UI。 +独立于数据库:你可以用Mongo、Oracle、Etcd或者其他数据库来替换MariaDB,你的业务规则不要绑定到数据库。 +独立于外部媒介:实际上,你的业务规则可以简单到根本不去了解外部世界。 + + +所以,基于这些约束,每一层都必须是独立的和可测试的。iam-apiserver代码架构分为4层:模型层(Models)、控制层(Controller)、业务层 (Service)、仓库层(Repository)。从控制层、业务层到仓库层,从左到右层级依次加深。模型层独立于其他层,可供其他层引用。如下图所示: + + + +层与层之间导入包时,都有严格的导入关系,这可以防止包的循环导入问题。导入关系如下: + + +模型层的包可以被仓库层、业务层和控制层导入; +控制层能够导入业务层和仓库层的包。这里需要注意,如果没有特殊需求,控制层要避免导入仓库层的包,控制层需要完成的业务功能都通过业务层来完成。这样可以使代码逻辑更加清晰、规范。 +业务层能够导入仓库层的包。 + + +接下来,我们就来详细看下每一层所完成的功能,以及其中的一些注意点。 + + +模型层(Models) + + +模型层在有些软件架构中也叫做实体层(Entities),模型会在每一层中使用,在这一层中存储对象的结构和它的方法。IAM项目模型层中的模型存放在github.com/marmotedu/api/apiserver/v1目录下,定义了User、UserList、Secret、SecretList、Policy、PolicyList、AuthzPolicy模型及其方法。例如: + +type Secret struct { + // May add TypeMeta in the future. + // metav1.TypeMeta `json:",inline"` + + // Standard object's metadata. + metav1.ObjectMeta ` json:"metadata,omitempty"` + Username string `json:"username" gorm:"column:username" validate:"omitempty"` + SecretID string `json:"secretID" gorm:"column:secretID" validate:"omitempty"` + SecretKey string `json:"secretKey" gorm:"column:secretKey" validate:"omitempty"` + + // Required: true + Expires int64 `json:"expires" gorm:"column:expires" validate:"omitempty"` + Description string `json:"description" gorm:"column:description" validate:"description"` +} + + +之所以将模型层的模型存放在github.com/marmotedu/api项目中,而不是github.com/marmotedu/iam项目中,是为了让这些模型能够被其他项目使用。例如,iam的模型可以被github.com/marmotedu/shippy应用导入。同样,shippy应用的模型也可以被iam项目导入,导入关系如下图所示: + + + +上面的依赖关系都是单向的,依赖关系清晰,不存在循环依赖的情况。 + +要增加shippy的模型定义,只需要在api目录下创建新的目录即可。例如,shippy应用中有一个vessel服务,其模型所在的包可以为github.com/marmotedu/api/vessel。 + +另外,这里的模型既可以作为数据库模型,又可以作为API接口的请求模型(入参、出参)。如果我们能够确保创建资源时的属性、资源保存在数据库中的属性、返回资源的属性三者一致,就可以使用同一个模型。通过使用同一个模型,可以使我们的代码更加简洁、易维护,并能提高开发效率。如果这三个属性有差异,你可以另外新建模型来适配。 + + +仓库层(Repository) + + +仓库层用来跟数据库/第三方服务进行CURD交互,作为应用程序的数据引擎进行应用数据的输入和输出。这里需要注意,仓库层仅对数据库/第三方服务执行CRUD操作,不封装任何业务逻辑。 + +仓库层也负责选择应用中将要使用什么样的数据库,可以是MySQL、MongoDB、MariaDB、Etcd等。无论使用哪种数据库,都要在这层决定。仓库层依赖于连接数据库或其他第三方服务(如果存在的话)。 + +这一层也会起到数据转换的作用:将从数据库/微服务中获取的数据转换为控制层、业务层能识别的数据结构,将控制层、业务层的数据格式转换为数据库或微服务能识别的数据格式。 + +iam-apiserver的仓库层位于internal/apiserver/store/mysql目录下,里面的方法用来跟MariaDB进行交互,完成CURD操作,例如,从数据库中获取密钥: + +func (s *secrets) Get(ctx context.Context, username, name string, opts metav1.GetOptions) (*v1.Secret, error) { + secret := &v1.Secret{} + err := s.db.Where("username = ? and name= ?", username, name).First(&secret).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.WithCode(code.ErrSecretNotFound, err.Error()) + } + + return nil, errors.WithCode(code.ErrDatabase, err.Error()) + } + + return secret, nil +} + + + +业务层 (Service) + + +业务层主要用来完成业务逻辑处理,我们可以把所有的业务逻辑处理代码放在业务层。业务层会处理来自控制层的请求,并根据需要请求仓库层完成数据的CURD操作。业务层功能如下图所示: + + + +iam-apiserver的业务层位于internal/apiserver/service目录下。下面是iam-apiserver业务层中,用来创建密钥的函数: + +func (s *secretService) Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) error { + if err := s.store.Secrets().Create(ctx, secret, opts); err != nil { + return errors.WithCode(code.ErrDatabase, err.Error()) + } + + return nil +} + + +可以看到,业务层最终请求仓库层的s.store的Create方法,将密钥信息保存在MariaDB数据库中。 + + +控制层(Controller) + + +控制层接收HTTP请求,并进行参数解析、参数校验、逻辑分发处理、请求返回这些操作。控制层会将逻辑分发给业务层,业务层处理后返回,返回数据在控制层中被整合再加工,最终返回给请求方。控制层相当于实现了业务路由的功能。具体流程如下图所示: + + + +这里我有个建议,不要在控制层写复杂的代码,如果需要,请将这些代码分发到业务层或其他包中。 + +iam-apiserver的控制层位于internal/apiserver/controller目录下。下面是iam-apiserver控制层中创建密钥的代码: + +func (s *SecretHandler) Create(c *gin.Context) { + log.L(c).Info("create secret function called.") + + var r v1.Secret + + if err := c.ShouldBindJSON(&r); err != nil { + core.WriteResponse(c, errors.WithCode(code.ErrBind, err.Error()), nil) + + return + } + + if errs := r.Validate(); len(errs) != 0 { + core.WriteResponse(c, errors.WithCode(code.ErrValidation, errs.ToAggregate().Error()), nil) + + return + } + + username := c.GetString(middleware.UsernameKey) + + secrets, err := s.srv.Secrets().List(c, username, metav1.ListOptions{ + Offset: pointer.ToInt64(0), + Limit: pointer.ToInt64(-1), + }) + if err != nil { + core.WriteResponse(c, errors.WithCode(code.ErrDatabase, err.Error()), nil) + + return + } + + if secrets.TotalCount >= maxSecretCount { + core.WriteResponse(c, errors.WithCode(code.ErrReachMaxCount, "secret count: %d", secrets.TotalCount), nil) + + return + } + + // must reassign username + r.Username = username + + if err := s.srv.Secrets().Create(c, &r, metav1.CreateOptions{}); err != nil { + core.WriteResponse(c, err, nil) + + return + } + + core.WriteResponse(c, nil, r) +} + + +上面的代码完成了以下操作: + + +解析HTTP请求参数。 +进行参数验证,这里可以添加一些业务性质的参数校验,例如:secrets.TotalCount >= maxSecretCount。 +调用业务层s.srv的Create方法,完成密钥的创建。 +返回HTTP请求参数。 + + +上面,我们介绍了iam-apiserver采用的4层结构,接下来我们再看看每一层之间是如何通信的。 + +除了模型层,控制层、业务层、仓库层之间都是通过接口进行通信的。通过接口通信,一方面可以使相同的功能支持不同的实现(也就是说具有插件化能力),另一方面也使得每一层的代码变得可测试。 + +这里,我用创建密钥API请求的例子,来给你讲解下层与层之间是如何进行通信的。 + +首先,来看下控制层如何跟业务层进行通信。 + +对密钥的请求处理都是通过SecretController提供的方法来处理的,创建密钥调用的是它的Create方法: + +func (s *SecretController) Create(c *gin.Context) { + ... + if err := s.srv.Secrets().Create(c, &r, metav1.CreateOptions{}); err != nil { + core.WriteResponse(c, err, nil) + + return + } + ... +} + + +在Create方法中,调用了s.srv.Secrets().Create()来创建密钥,s.srv是一个接口类型,定义如下: + +type Service interface { + Users() UserSrv + Secrets() SecretSrv + Policies() PolicySrv +} + +type SecretSrv interface { + Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) error + Update(ctx context.Context, secret *v1.Secret, opts metav1.UpdateOptions) error + Delete(ctx context.Context, username, secretID string, opts metav1.DeleteOptions) error + DeleteCollection(ctx context.Context, username string, secretIDs []string, opts metav1.DeleteOptions) error + Get(ctx context.Context, username, secretID string, opts metav1.GetOptions) (*v1.Secret, error) + List(ctx context.Context, username string, opts metav1.ListOptions) (*v1.SecretList, error) +} + + +可以看到,控制层通过业务层提供的Service接口类型,剥离了业务层的具体实现。业务层的Service接口类型提供了Secrets()方法,该方法返回了一个实现了SecretSrv接口的实例。在控制层中,通过调用该实例的Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) error方法来完成密钥的创建。至于业务层是如何创建密钥的,控制层不需要知道,也就是说创建密钥可以有多种实现。 + +这里使用到了设计模式中的工厂方法模式。Service是工厂接口,里面包含了一系列创建具体业务层对象的工厂函数:Users()、Secrets()、Policies()。通过工厂方法模式,不仅隐藏了业务层对象的创建细节,而且还可以很方便地在Service工厂接口实现方法中添加新的业务层对象。 + +例如,我们想新增一个Template业务层对象,用来在iam-apiserver中预置一些策略模板,可以这么来加: + +type Service interface { + Users() UserSrv + Secrets() SecretSrv + Policies() PolicySrv + Templates() TemplateSrv +} + +func (s *service) Templates() TemplateSrv { + return newTemplates(s) +} + + +接下来,新建一个template.go文件: + +type TemplateSrv interface { + Create(ctx context.Context, template *v1.Template, opts metav1.CreateOptions) error + // Other methods +} + +type templateService struct { + store store.Factory +} + +var _ TemplateSrv = (*templateService)(nil) + +func newTemplates(srv *service) *TemplateService { + // more create logic + return &templateService{store: srv.store} +} + +func (u *templateService) Create(ctx context.Context, template *v1.Template, opts metav1.CreateOptions) error { + // normal code + + return nil +} + + +可以看到,我们通过以下三步新增了一个业务层对象: + + +在Service接口定义中,新增了一个入口:Templates() TemplateSrv。 +在service.go文件中,新增了一个函数:Templates()。 +新建了template.go文件,在template.go中定义了templateService结构体,并为它实现了TemplateSrv接口。 + + +可以看到,我们新增的Template业务对象的代码几乎都闭环在template.go文件中。对已有的Service工厂接口的创建方法,除了新增一个工厂方法Templates() TemplateSrv外,没有其他任何入侵。这样做可以避免影响已有业务。 + +在实际项目开发中,你也有可能会想到下面这种错误的创建方式: + +// 错误方法一 +type Service interface { + UserSrv + SecretSrv + PolicySrv + TemplateSrv +} + + +上面的创建方式中,我们如果想创建User和Secret,那只能定义两个不同的方法:CreateUser和 CreateSecret,远没有在User和Secret各自的域中提供同名的Create方法来得优雅。 + +IAM项目中还有其他地方也使用了工厂方法模式,例如Factory工厂接口。 + +再来看下业务层和仓库层是如何通信的。 + +业务层和仓库层也是通过接口来通信的。例如,在业务层中创建密钥的代码如下: + +func (s *secretService) Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) error { + if err := s.store.Secrets().Create(ctx, secret, opts); err != nil { + return errors.WithCode(code.ErrDatabase, err.Error()) + } + + return nil +} + + +Create方法中调用了s.store.Secrets().Create()方法来将密钥保存到数据库中。s.store是一个接口类型,定义如下: + +type Factory interface { + Users() UserStore + Secrets() SecretStore + Policies() PolicyStore + Close() error +} + + +业务层与仓库层的通信实现,和控制层与业务层的通信实现类似,所以这里不再详细介绍。 + +到这里我们知道了,控制层、业务层和仓库层之间是通过接口来通信的。通过接口通信有一个好处,就是可以让各层变得可测。那接下来,我们就来看下如何测试各层的代码。因为第38讲和第39讲会详细介绍如何测试Go代码,所以这里只介绍下测试思路。 + + +模型层 + + +因为模型层不依赖其他任何层,我们只需要测试其中定义的结构及其函数和方法即可。 + + +控制层 + + +控制层依赖于业务层,意味着该层需要业务层来支持测试。你可以通过golang/mock来mock业务层,测试用例可参考TestUserController_Create。 + + +业务层 + + +因为该层依赖于仓库层,意味着该层需要仓库层来支持测试。我们有两种方法来模拟仓库层: + + +通过golang/mock来mock仓库层。 +自己开发一个fake仓库层。 + + +使用golang/mock的测试用例,你可以参考Test_secretService_Create。 + +fake的仓库层可以参考fake,使用该fake仓库层进行测试的测试用例为 Test_userService_List。 + + +仓库层 + + +仓库层依赖于数据库,如果调用了其他微服务,那还会依赖第三方服务。我们可以通过sqlmock来模拟数据库连接,通过httpmock来模拟HTTP请求。 + +总结 + +这一讲,我主要介绍了iam-apiserver的功能和使用方法,以及它的代码实现。iam-apiserver是一个Web服务,提供了REST API来完成用户、密钥、策略三种REST资源的增删改查。我们可以通过cURL、Insomnia等工具,来完成REST API请求。 + +iam-apiserver包含了3种配置:Options配置、应用配置、HTTP/GRPC服务配置。这三种配置分别用来构建命令行参数、应用和HTTP/GRPC服务。 + +iam-apiserver在启动时,会先构建应用框架,接着会设置应用选项,然后对应用进行初始化,最后创建HTTP/GRPC服务的配置和实例,最终启动HTTP/GRPC服务。 + +服务启动之后,就可以接收HTTP请求了。一个HTTP请求会先进行认证,接着会被注册的中间件处理,然后,会根据(HTTP Method, HTTP Request Path)匹配到处理函数。在处理函数中,会解析请求参数、校验参数、调用业务逻辑处理函数,最终返回请求结果。 + +iam-apiserver采用了简洁架构,整个应用分为4层:模型层、控制层、业务层和仓库层。模型层存储对象的结构和它的方法;仓库层用来跟数据库/第三方服务进行CURD交互;业务层主要用来完成业务逻辑处理;控制层接收HTTP请求,并进行参数解析、参数校验、逻辑分发处理、请求返回操作。控制层、业务层、仓库层之间通过接口通信,通过接口通信可以使相同的功能支持不同的实现,并使每一层的代码变得可测试。 + +课后练习 + + +iam-apiserver和iam-authz-server都提供了REST API服务,阅读它们的源码,看看iam-apiserver和iam-authz-server是如何共享REST API相关代码的。 + +思考一下,iam-apiserver的服务构建方式,能够再次抽象成一个模板(Go包)吗?如果能,该如何抽象? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/29控制流(下):iam-apiserver服务核心功能实现讲解.md b/专栏/Go语言项目开发实战/29控制流(下):iam-apiserver服务核心功能实现讲解.md new file mode 100644 index 0000000..99e7d99 --- /dev/null +++ b/专栏/Go语言项目开发实战/29控制流(下):iam-apiserver服务核心功能实现讲解.md @@ -0,0 +1,862 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 29 控制流(下):iam-apiserver服务核心功能实现讲解 + 你好,我是孔令飞。 + +上一讲,我介绍了 iam-apiserver 是如何构建 Web 服务的。这一讲,我们再来看下 iam-apiserver 中的核心功能实现。在对这些核心功能的讲解中,我会向你传达我的程序设计思路。 + +iam-apiserver 中包含了很多优秀的设计思想和实现,这些点可能比较零碎,但我觉得很值得分享给你。我将这些关键代码设计分为 3 类,分别是应用框架相关的特性、编程规范相关的特性和其他特性。接下来,我们就来详细看看这些设计点,以及它们背后的设计思想。 + +应用框架相关的特性 + +应用框架相关的特性包括三个,分别是优雅关停、健康检查和插件化加载中间件。 + +优雅关停 + +在讲优雅关停之前,先来看看不优雅的停止服务方式是什么样的。 + +当我们需要重启服务时,首先需要停止服务,这时可以通过两种方式来停止我们的服务: + + +在 Linux 终端键入 Ctrl + C(其实是发送 SIGINT 信号)。 +发送 SIGTERM 信号,例如 kill 或者 systemctl stop 等。 + + +当我们使用以上两种方式停止服务时,都会产生下面两个问题: + + +有些请求正在处理,如果服务端直接退出,会造成客户端连接中断,请求失败。 +我们的程序可能需要做一些清理工作,比如等待进程内任务队列的任务执行完成,或者拒绝接受新的消息等。 + + +这些问题都会对业务造成影响,所以我们需要一种优雅的方式来关停我们的应用。在 Go 开发中,通常通过拦截 SIGINT 和 SIGTERM 信号,来实现优雅关停。当收到这两个信号时,应用进程会做一些清理工作,然后结束阻塞状态,继续执行余下的代码,最后自然退出进程。 + +先来看一个简单的优雅关停的示例代码: + +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "time" + + "github.com/gin-gonic/gin" +) + +func main() { + router := gin.Default() + router.GET("/", func(c *gin.Context) { + time.Sleep(5 * time.Second) + c.String(http.StatusOK, "Welcome Gin Server") + }) + + srv := &http.Server{ + Addr: ":8080", + Handler: router, + } + + go func() { + // 将服务在 goroutine 中启动 + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + + quit := make(chan os.Signal) + signal.Notify(quit, os.Interrupt) + <-quit // 阻塞等待接收 channel 数据 + log.Println("Shutdown Server ...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 5s 缓冲时间处理已有请求 + defer cancel() + if err := srv.Shutdown(ctx); err != nil { // 调用 net/http 包提供的优雅关闭函数:Shutdown + log.Fatal("Server Shutdown:", err) + } + log.Println("Server exiting") +} + + +上面的代码实现优雅关停的思路如下: + + +将 HTTP 服务放在 goroutine 中运行,程序不阻塞,继续执行。 +创建一个无缓冲的 channel quit,调用 signal.Notify(quit, os.Interrupt)。通过 signal.Notify 函数调用,可以将进程收到的 os.Interrupt(SIGINT)信号,发送给 channel quit。 +<-quit 阻塞当前 goroutine(也就是 main 函数所在的 goroutine),等待从 channel quit 接收关停信号。通过以上步骤,我们成功启动了 HTTP 服务,并且 main 函数阻塞,防止启动 HTTP 服务的 goroutine 退出。当我们键入 Ctrl + C时,进程会收到 SIGINT 信号,并将该信号发送到 channel quit 中,这时候<-quit收到了 channel 另一端传来的数据,结束阻塞状态,程序继续执行。这里,<-quit唯一目的是阻塞当前的 goroutine,所以对收到的数据直接丢弃。 +打印退出消息,提示准备退出当前服务。 +调用 net/http 包提供的 Shutdown 方法,Shutdown 方法会在指定的时间内处理完现有请求,并返回。 +最后,程序执行完 log.Println("Server exiting") 代码后,退出 main 函数。 + + +iam-apiserver 也实现了优雅关停,优雅关停思路跟上面的代码类似。具体可以分为三个步骤,流程如下: + +第一步,创建 channel 用来接收 os.Interrupt(SIGINT)和 syscall.SIGTERM(SIGKILL)信号。 + +代码见 internal/pkg/server/signal.go 。 + +var onlyOneSignalHandler = make(chan struct{}) + +var shutdownHandler chan os.Signal + +func SetupSignalHandler() <-chan struct{} { + close(onlyOneSignalHandler) // panics when called twice + + shutdownHandler = make(chan os.Signal, 2) + + stop := make(chan struct{}) + + signal.Notify(shutdownHandler, shutdownSignals...) + + go func() { + <-shutdownHandler + close(stop) + <-shutdownHandler + os.Exit(1) // second signal. Exit directly. + }() + + return stop +} + + +SetupSignalHandler 函数中,通过 close(onlyOneSignalHandler)来确保 iam-apiserver 组件的代码只调用一次 SetupSignalHandler 函数。否则,可能会因为信号传给了不同的 shutdownHandler,而造成信号丢失。 + +SetupSignalHandler 函数还实现了一个功能:收到一次 SIGINT/ SIGTERM 信号,程序优雅关闭。收到两次 SIGINT/ SIGTERM 信号,程序强制关闭。实现代码如下: + +go func() { + <-shutdownHandler + close(stop) + <-shutdownHandler + os.Exit(1) // second signal. Exit directly. +}() + + +这里要注意:signal.Notify(c chan<- os.Signal, sig ...os.Signal)函数不会为了向 c 发送信息而阻塞。也就是说,如果发送时 c 阻塞了,signal 包会直接丢弃信号。为了不丢失信号,我们创建了有缓冲的 channel shutdownHandler。 + +最后,SetupSignalHandler 函数会返回 stop,后面的代码可以通过关闭 stop 来结束代码的阻塞状态。 + +第二步,将 channel stop 传递给启动 HTTP(S)、gRPC 服务的函数,在函数中以 goroutine 的方式启动 HTTP(S)、gRPC 服务,然后执行 <-stop 阻塞 goroutine。 + +第三步,当 iam-apiserver 进程收到 SIGINT/SIGTERM 信号后,关闭 stop channel,继续执行 <-stop 后的代码,在后面的代码中,我们可以执行一些清理逻辑,或者调用 google.golang.org/grpc和 net/http包提供的优雅关停函数 GracefulStop 和 Shutdown。例如下面这个代码(位于 internal/apiserver/grpc.go 文件中): + +func (s *grpcAPIServer) Run(stopCh <-chan struct{}) { + listen, err := net.Listen("tcp", s.address) + if err != nil { + log.Fatalf("failed to listen: %s", err.Error()) + } + + log.Infof("Start grpc server at %s", s.address) + + go func() { + if err := s.Serve(listen); err != nil { + log.Fatalf("failed to start grpc server: %s", err.Error()) + } + }() + + <-stopCh + + log.Infof("Grpc server on %s stopped", s.address) + s.GracefulStop() +} + + +除了上面说的方法,iam-apiserver 还通过 github.com/marmotedu/iam/pkg/shutdown 包,实现了另外一种优雅关停方法,这个方法更加友好、更加灵活。实现代码见 PrepareRun 函数。 + +github.com/marmotedu/iam/pkg/shutdown 包的使用方法如下: + +package main +import ( + "fmt" + "time" + "github.com/marmotedu/iam/pkg/shutdown" + "github.com/marmotedu/iam/pkg/shutdown/shutdownmanagers/posixsignal" +) +func main() { + // initialize shutdown + gs := shutdown.New() + // add posix shutdown manager + gs.AddShutdownManager(posixsignal.NewPosixSignalManager()) + // add your tasks that implement ShutdownCallback + gs.AddShutdownCallback(shutdown.ShutdownFunc(func(string) error { + fmt.Println("Shutdown callback start") + time.Sleep(time.Second) + fmt.Println("Shutdown callback finished") + return nil + })) + // start shutdown managers + if err := gs.Start(); err != nil { + fmt.Println("Start:", err) + return + } + // do other stuff + time.Sleep(time.Hour) +} + + +上面的代码中,通过 gs := shutdown.New() 创建 shutdown 实例;通过 AddShutdownManager 方法添加监听的信号;通过 AddShutdownCallback 方法设置监听到指定信号时,需要执行的回调函数。这些回调函数可以执行一些清理工作。最后,通过 Start 方法启动 shutdown 实例。 + +健康检查 + +通常,我们会根据进程是否存在来判定 iam-apiserver 是否健康,例如执行 ps -ef|grep iam-apiserver。在实际开发中,我发现有时候服务进程仍然存在,但是 HTTP 服务却不能接收和处理请求,所以更加靠谱的检查方法是,直接请求 iam-apiserver 的健康检查接口。 + +我们可以在启动 iam-apiserver 进程后,手动调用 iam-apiserver 健康检查接口进行检查。但还有更方便的方法:启动服务后自动调用健康检查接口。这个方法的具体实现,你可以查看 GenericAPIServer 提供的 ping 方法。在 ping 方法中,你需要注意函数中的如下代码: + +url := fmt.Sprintf("http://%s/healthz", s.InsecureServingInfo.Address) +if strings.Contains(s.InsecureServingInfo.Address, "0.0.0.0") { + url = fmt.Sprintf("http://127.0.0.1:%s/healthz", strings.Split(s.InsecureServingInfo.Address, ":")[1]) +} + + +当 HTTP 服务监听在所有网卡时,请求 IP 为 127.0.0.1;当 HTTP 服务监听在指定网卡时,我们需要请求该网卡的 IP 地址。 + +插件化加载中间件 + +iam-apiserver 支持插件化地加载 Gin 中间件,通过这种插件机制,我们可以根据需要选择中间件。 + +那么,为什么要将中间件做成一种插件化的机制呢?一方面,每个中间件都完成某种功能,这些功能不是所有情况下都需要的;另一方面,中间件是追加在 HTTP 请求链路上的一个处理函数,会影响 API 接口的性能。为了保证 API 接口的性能,我们也需要选择性地加载中间件。 + +例如,在测试环境中为了方便 Debug,可以选择加载 dump 中间件。dump 中间件可以打印请求包和返回包信息,这些信息可以协助我们 Debug。但是在现网环境中,我们不需要 dump 中间件来协助 Debug,而且如果加载了 dump 中间件,请求时会打印大量的请求信息,严重影响 API 接口的性能。这时候,我们就期望中间件能够按需加载。 + +iam-apiserver 通过 InstallMiddlewares 函数来安装 Gin 中间件,函数代码如下: + +func (s *GenericAPIServer) InstallMiddlewares() { + // necessary middlewares + s.Use(middleware.RequestID()) + s.Use(middleware.Context()) + + // install custom middlewares + for _, m := range s.middlewares { + mw, ok := middleware.Middlewares[m] + if !ok { + log.Warnf("can not find middleware: %s", m) + + continue + } + + log.Infof("install middleware: %s", m) + s.Use(mw) + } +} + + +可以看到,安装中间件时,我们不仅安装了一些必备的中间件,还安装了一些可配置的中间件。 + +上述代码安装了两个默认的中间件: RequestID 和 Context 。 + +RequestID 中间件,主要用来在 HTTP 请求头和返回头中设置 X-Request-ID Header。如果 HTTP 请求头中没有 X-Request-ID HTTP 头,则创建 64 位的 UUID,如果有就复用。UUID 是调用 github.com/satori/go.uuid包提供的 NewV4().String()方法来生成的: + +rid = uuid.NewV4().String() + + +另外,这里有个 Go 常量的设计规范需要你注意:常量要跟该常量相关的功能包放在一起,不要将一个项目的常量都集中放在 const 这类包中。例如, requestid.go 文件中,我们定义了 XRequestIDKey = "X-Request-ID"常量,其他地方如果需要使用 XRequestIDKey,只需要引入 XRequestIDKey所在的包,并使用即可。 + +Context 中间件,用来在 gin.Context 中设置 requestID和 username键,在打印日志时,将 gin.Context 类型的变量传递给 log.L() 函数,log.L() 函数会在日志输出中输出 requestID和 username域: + +2021-07-09 13:33:21.362 DEBUG apiserver v1/user.go:106 get 2 users from backend storage. {"requestID": "f8477cf5-4592-4e47-bdcf-82f7bde2e2d0", "username": "admin"} + + +requestID和 username字段可以方便我们后期过滤并查看日志。 + +除了默认的中间件,iam-apiserver 还支持一些可配置的中间件,我们可以通过配置 iam-apiserver 配置文件中的 server.middlewares 配置项,来配置这些这些中间件。 + +可配置以下中间件: + + +recovery:捕获任何 panic,并恢复。 +secure:添加一些安全和资源访问相关的 HTTP 头。 +nocache:禁止客户端缓存 HTTP 请求的返回结果。 +cors:HTTP 请求跨域中间件。 +dump:打印出 HTTP 请求包和返回包的内容,方便 debug。注意,生产环境禁止加载该中间件。 + + +当然,你还可以根据需要,添加更多的中间件。方法很简单,只需要编写中间件,并将中间件添加到一个 map[string]gin.HandlerFunc 类型的变量中即可: + +func defaultMiddlewares() map[string]gin.HandlerFunc { + return map[string]gin.HandlerFunc{ + "recovery": gin.Recovery(), + "secure": Secure, + "options": Options, + "nocache": NoCache, + "cors": Cors(), + "requestid": RequestID(), + "logger": Logger(), + "dump": gindump.Dump(), + } +} + + +上述代码位于 internal/pkg/middleware/middleware.go 文件中。 + +编程规范相关的特性 + +编程规范相关的特性有四个,分别是 API 版本、统一的资源元数据、统一的返回、并发处理模板。 + +API 版本 + +RESTful API 为了方便以后扩展,都需要支持 API 版本。在 12 讲 中,我们介绍了 API 版本号的 3 种标识方法,iam-apiserver 选择了将 API 版本号放在 URL 中,例如/v1/secrets。放在 URL 中的好处是很直观,看 API 路径就知道版本号。另外,API 的路径也可以很好地跟控制层、业务层、模型层的代码路径相映射。例如,密钥资源相关的代码存放位置如下: + +internal/apiserver/controller/v1/secret/ # 控制几层代码存放位置 +internal/apiserver/service/v1/secret.go # 业务层代码存放位置 +github.com/marmotedu/api/apiserver/v1/secret.go # 模型层代码存放位置 + + +关于代码存放路径,我还有一些地方想跟你分享。对于 Secret 资源,通常我们需要提供 CRUD 接口。 + + +C:Create(创建 Secret)。 +R:Get(获取详情)、List(获取 Secret 资源列表)。 +U:Update(更新 Secret)。 +D:Delete(删除指定的 Secret)、DeleteCollection(批量删除 Secret)。 + + +每个接口相互独立,为了减少更新 A 接口代码时因为误操作影响到 B 接口代码的情况,这里建议 CRUD 接口每个接口一个文件,从物理上将不同接口的代码隔离开。这种接口还可以方便我们查找 A 接口的代码所在位置。例如,Secret 控制层相关代码的存放方式如下: + +$ ls internal/apiserver/controller/v1/secret/ +create.go delete_collection.go delete.go doc.go get.go list.go secret.go update.go + + +业务层和模型层的代码也可以这么组织。iam-apiserver 中,因为 Secret 的业务层和模型层代码比较少,所以我放在了 internal/apiserver/service/v1/secret.go和 github.com/marmotedu/api/apiserver/v1/secret.go文件中。如果后期 Secret 业务代码增多,我们也可以修改成下面这种方式: + + $ ls internal/apiserver/service/v1/secret/ + create.go delete_collection.go delete.go doc.go get.go list.go secret.go update.go + + +这里再说个题外话:/v1/secret/和/secret/v1/这两种目录组织方式都可以,你选择一个自己喜欢的就行。 + +当我们需要升级 API 版本时,相关代码可以直接放在 v2 目录下,例如: + +internal/apiserver/controller/v2/secret/ # v2 版本控制几层代码存放位置 +internal/apiserver/service/v2/secret.go # v2 版本业务层代码存放位置 +github.com/marmotedu/api/apiserver/v2/secret.go # v2 版本模型层代码存放位置 + + +这样既能够跟 v1 版本的代码物理隔离开,互不影响,又方便查找 v2 版本的代码。 + +统一的资源元数据 + +iam-apiserver 设计的一大亮点是,像Kubernetes REST 资源一样,支持统一的资源元数据。 + +iam-apiserver 中所有的资源都是 REST 资源,iam-apiserver 将 REST 资源的属性也进一步规范化了,这里的规范化是指所有的 REST 资源均支持两种属性: + + +公共属性。 +资源自有的属性。 + + +例如,Secret 资源的定义方式如下: + +type Secret struct { + // May add TypeMeta in the future. + // metav1.TypeMeta `json:",inline"` + + // Standard object's metadata. + metav1.ObjectMeta ` json:"metadata,omitempty"` + Username string `json:"username" gorm:"column:username" validate:"omitempty"` + SecretID string `json:"secretID" gorm:"column:secretID" validate:"omitempty"` + SecretKey string `json:"secretKey" gorm:"column:secretKey" validate:"omitempty"` + + // Required: true + Expires int64 `json:"expires" gorm:"column:expires" validate:"omitempty"` + Description string `json:"description" gorm:"column:description" validate:"description"` +} + + +资源自有的属性,会因资源不同而不同。这里,我们来重点看一下公共属性 ObjectMeta ,它的定义如下: + +type ObjectMeta struct { + ID uint64 `json:"id,omitempty" gorm:"primary_key;AUTO_INCREMENT;column:id"` + InstanceID string `json:"instanceID,omitempty" gorm:"unique;column:instanceID;type:varchar(32);not null"` + Name string `json:"name,omitempty" gorm:"column:name;type:varchar(64);not null" validate:"name"` + Extend Extend `json:"extend,omitempty" gorm:"-" validate:"omitempty"` + ExtendShadow string `json:"-" gorm:"column:extendShadow" validate:"omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty" gorm:"column:createdAt"` + UpdatedAt time.Time `json:"updatedAt,omitempty" gorm:"column:updatedAt"` +} + + +接下来,我来详细介绍公共属性中每个字段的含义及作用。 + + +ID + + +这里的 ID,映射为 MariaDB 数据库中的 id 字段。id 字段在一些应用中,会作为资源的唯一标识。但 iam-apiserver 中没有使用 ID 作为资源的唯一标识,而是使用了 InstanceID。iam-apiserver 中 ID 唯一的作用是跟数据库 id 字段进行映射,代码中并没有使用到 ID。 + + +InstanceID + + +InstanceID 是资源的唯一标识,格式为-xxxxxx。其中,是资源的英文标识符号,xxxxxx是随机字符串。字符集合为 abcdefghijklmnopqrstuvwxyz1234567890,长度>=6,例如 secret-yj8m30、user-j4lz3g、policy-3v18jq。 + +腾讯云、阿里云、华为云也都是采用这种格式的字符串作为资源唯一标识的。 + +InstanceID 的生成和更新都是自动化的,通过 gorm 提供的 AfterCreate Hooks 在记录插入数据库之后,生成并更新到数据库的 instanceID字段: + +func (s *Secret) AfterCreate(tx *gorm.DB) (err error) { + s.InstanceID = idutil.GetInstanceID(s.ID, "secret-") + + return tx.Save(s).Error +} + + +上面的代码,在 Secret 记录插入到 iam 数据库的 secret 表之后,调用 idutil.GetInstanceID生成 InstanceID,并通过 tx.Save(s)更新到数据库 secret 表的 instanceID字段。 + +因为通常情况下,应用中的 REST 资源只会保存到数据库中的一张表里,这样就能保证应用中每个资源的数据库 ID 是唯一的。所以 GetInstanceID(uid uint64, prefix string) string函数使用 github.com/speps/go-hashids包提供的方法,对这个数据库 ID 进行哈希,最终得到一个数据库级别的唯一的字符串(例如:3v18jq),并根据传入的 prefix,得到资源的 InstanceID。 + +使用这种方式生成资源的唯一标识,有下面这两个优点: + + +数据库级别唯一。 +InstanceID 是长度可控的字符串,长度最小是 6 个字符,但会根据表中的记录个数动态变长。根据我的测试,2176782336 条记录以内生成的 InstanceID 长度都在 6 个字符以内。长度可控的另外一个好处是方便记忆和传播。 + + +这里需要你注意:如果同一个资源分别存放在不同的表中,那在使用这种方式时,生成的 InstanceID 可能相同,不过概率很小,几乎为零。这时候,我们就需要使用分布式 ID 生成技术。这又是另外一个话题了,这里不再扩展讲解。 + +在实际的开发中,不少开发者会使用数据库数字段 ID(例如 121)和 36⁄64 位的 UUID(例如 20cd59d4-08c6-4e86-a9d4-a0e51c420a04 )来作为资源的唯一标识。相较于这两种资源标识方式,使用-xxxxxx这种标识方式具有以下优点: + + +看标识名就知道是什么类型的资源,例如:secret-yj8m30说明该资源是 secret 类型的资源。在实际的排障过程中,能够有效减少误操作。 +长度可控,占用数据库空间小。iam-apiserver 的资源标识长度基本可以认为是 12 个字符(secret/policy 是 6 个字符,再加 6 位随机字符)。 +如果使用 121 这类数值作为资源唯一标识,相当于间接向友商透漏系统的规模,是一定要禁止的。 + + +另外,还有一些系统如 Kubernetes 中,使用资源名作为资源唯一标识。这种方式有个弊端,就是当系统中同类资源太多时,创建资源很容易重名,你自己想要的名字往往填不了,所以 iam-apiserver 不采用这种设计方式。 + +我们使用 instanceID 来作为资源的唯一标识,在代码中,就经常需要根据 instanceID 来查询资源。所以,在数据库中要设置该字段为唯一索引,一方面可以防止 instanceID 不唯一,另一方面也能加快查询速度。 + + +Name + + +Name 即资源的名字,我们可以通过名字很容易地辨别一个资源。 + + +Extend、ExtendShadow + + +Extend 和 ExtendShadow 是 iam-apiserver 设计的又一大亮点。 + +在实际开发中,我们经常会遇到这个问题:随着业务发展,某个资源需要增加一些属性,这时,我们可能会选择在数据库中新增一个数据库字段。但是,随着业务系统的演进,数据库中的字段越来越多,我们的 Code 也要做适配,最后就会越来越难维护。 + +我们还可能遇到这种情况:我们将上面说的字段保存在数据库中叫 meta的字段中,数据库中 meta字段的数据格式是{"disable":true,"tag":"colin"}。但是,我们如果想在代码中使用这些字段,需要 Unmarshal 到一个结构体中,例如: + +metaData := `{"disable":true,"tag":"colin"}` +meta := make(map[string]interface{}) +if err := json.Unmarshal([]byte(metaData), &meta); err != nil { + return err +} + + +再存入数据中时,又要 Marshal 成 JSON 格式的字符串,例如: + +meta := map[string]interface{}{"disable": true, "tag": "colin"} +data, err := json.Marshal(meta) +if err != nil { + return err +} + + +你可以看到,这种 Unmarshal 和 Marshal 操作有点繁琐。 + +因为每个资源都可能需要用到扩展字段,那么有没有一种通用的解决方案呢?iam-apiserver 就通过 Extend 和 ExtendShadow 解决了这个问题。 + +Extend 是 Extend 类型的字段,Extend 类型其实是 map[string]interface{}的类型别名。在程序中,我们可以很方便地引用 Extend 包含的属性,也就是 map 的 key。Extend 字段在保存到数据库中时,会自动 Marshal 成字符串,保存在 ExtendShadow 字段中。 + +ExtendShadow 是 Extend 在数据库中的影子。同样,当从数据库查询数据时,ExtendShadow 的值会自动 Unmarshal 到 Extend 类型的变量中,供程序使用。 + +具体实现方式如下: + + +借助 gorm 提供的 BeforeCreate、BeforeUpdate Hooks,在插入记录、更新记录时,将 Extend 的值转换成字符串,保存在 ExtendShadow 字段中,并最终保存在数据库的 ExtendShadow 字段中。 +借助 gorm 提供的 AfterFind Hooks,在查询数据后,将 ExtendShadow 的值 Unmarshal 到 Extend 字段中,之后程序就可以通过 Extend 字段来使用其中的属性。 + + + +CreatedAt + + +资源的创建时间。每个资源在创建时,我们都应该记录资源的创建时间,可以帮助后期进行排障、分析等。 + + +UpdatedAt + + +资源的更新时间。每个资源在更新时,我们都应该记录资源的更新时间。资源更新时,该字段由 gorm 自动更新。 + +可以看到,ObjectMeta 结构体包含了很多字段,每个字段都完成了很酷的功能。那么,如果把 ObjectMeta 作为所有资源的公共属性,这些资源就会自带这些能力。 + +当然,有些开发者可能会说,User 资源其实是不需要 user-xxxxxx这种资源标识的,所以 InstanceID 这个字段其实是无用的字段。但是在我看来,和功能冗余相比,功能规范化、不重复造轮子,以及 ObjectMeta 的其他功能更加重要。所以,也建议所有的 REST 资源都使用统一的资源元数据。 + +统一的返回 + +在18 讲 中,我们介绍过 API 的接口返回格式应该是统一的。要想返回一个固定格式的消息,最好的方式就是使用同一个返回函数。因为 API 接口都是通过同一个函数来返回的,其返回格式自然是统一的。 + +IAM 项目通过 github.com/marmotedu/component-base/pkg/core 包提供的 WriteResponse 函数来返回结果。WriteResponse 函数定义如下: + +func WriteResponse(c *gin.Context, err error, data interface{}) { + if err != nil { + log.Errorf("%#+v", err) + coder := errors.ParseCoder(err) + c.JSON(coder.HTTPStatus(), ErrResponse{ + Code: coder.Code(), + Message: coder.String(), + Reference: coder.Reference(), + }) + + return + } + + c.JSON(http.StatusOK, data) +} + + +可以看到,WriteResponse 函数会判断 err 是否为 nil。如果不为 nil,则将 err 解析为 github.com/marmotedu/errors包中定义的 Coder 类型的错误,并调用 Coder 接口提供的 Code() 、String() 、Reference() 方法,获取该错误的业务码、对外展示的错误信息和排障文档。如果 err 为 nil,则调用 c.JSON返回 JSON 格式的数据。 + +并发处理模板 + +在 Go 项目开发中,经常会遇到这样一种场景:查询列表接口时,查询出了多条记录,但是需要针对每一条记录做一些其他逻辑处理。因为是多条记录,比如 100 条,处理每条记录延时如果为 X 毫秒,串行处理完 100 条记录,整体延时就是 100 * X 毫秒。如果 X 比较大,那整体处理完的延时是非常高的,会严重影响 API 接口的性能。 + +这时候,我们自然就会想到利用 CPU 的多核能力,并发来处理这 100 条记录。这种场景我们在实际开发中经常遇到,有必要抽象成一个并发处理模板,这样以后在查询时,就可以使用这个模板了。 + +例如,iam-apiserver 中,查询用户列表接口 List ,还需要返回每个用户所拥有的策略个数。这就用到了并发处理。这里,我试着将其抽象成一个模板,模板如下: + +func (u *userService) List(ctx context.Context, opts metav1.ListOptions) (*v1.UserList, error) { + users, err := u.store.Users().List(ctx, opts) + if err != nil { + log.L(ctx).Errorf("list users from storage failed: %s", err.Error()) + + return nil, errors.WithCode(code.ErrDatabase, err.Error()) + } + + wg := sync.WaitGroup{} + errChan := make(chan error, 1) + finished := make(chan bool, 1) + + var m sync.Map + + // Improve query efficiency in parallel + for _, user := range users.Items { + wg.Add(1) + + go func(user *v1.User) { + defer wg.Done() + + // some cost time process + policies, err := u.store.Policies().List(ctx, user.Name, metav1.ListOptions{}) + if err != nil { + errChan <- errors.WithCode(code.ErrDatabase, err.Error()) + + return + } + + m.Store(user.ID, &v1.User{ + ... + Phone: user.Phone, + TotalPolicy: policies.TotalCount, + }) + }(user) + } + + go func() { + wg.Wait() + close(finished) + }() + + select { + case <-finished: + case err := <-errChan: + return nil, err + } + + // infos := make([]*v1.User, 0) + infos := make([]*v1.User, 0, len(users.Items)) + for _, user := range users.Items { + info, _ := m.Load(user.ID) + infos = append(infos, info.(*v1.User)) + } + + log.L(ctx).Debugf("get %d users from backend storage.", len(infos)) + + return &v1.UserList{ListMeta: users.ListMeta, Items: infos}, nil +} + + +在上面的并发模板中,我实现了并发处理查询结果中的三个功能: + +第一个功能,goroutine 报错即返回。goroutine 中代码段报错时,会将错误信息写入 errChan中。我们通过 List 函数中的 select 语句,实现只要有一个 goroutine 发生错误,即返回: + +select { +case <-finished: +case err := <-errChan: + return nil, err +} + + +第二个功能,保持查询顺序。我们从数据库查询出的列表是有顺序的,比如默认按数据库 ID 字段升序排列,或者我们指定的其他排序方法。在并发处理中,这些顺序会被打断。但为了确保最终返回的结果跟我们预期的排序效果一样,在并发模板中,我们还需要保证最终返回结果跟查询结果保持一致的排序。 + +上面的模板中,我们将处理后的记录保存在 map 中,map 的 key 为数据库 ID。并且,在最后按照查询的 ID 顺序,依次从 map 中取出 ID 的记录,例如: + + var m sync.Map + for _, user := range users.Items { + ... + go func(user *v1.User) { + ... + m.Store(user.ID, &v1.User{}) + }(user) + } + ... + infos := make([]*v1.User, 0, len(users.Items)) + for _, user := range users.Items { + info, _ := m.Load(user.ID) + infos = append(infos, info.(*v1.User)) + } + + +通过上面这种方式,可以确保最终返回的结果跟从数据库中查询的结果保持一致的排序。 + +第三个功能,并发安全。Go 语言中的 map 不是并发安全的,要想实现并发安全,需要自己实现(如加锁),或者使用 sync.Map。上面的模板使用了 sync.Map。 + +当然了,如果期望 List 接口能在期望时间内返回,还可以添加超时机制,例如: + + select { + case <-finished: + case err := <-errChan: + return nil, err + case <-time.After(time.Duration(30 * time.Second)): + return nil, fmt.Errorf("list users timeout after 30 seconds") + + } + + +goroutine 虽然很轻量,但还是会消耗资源,如果我们需要处理几百上千的并发,就需要用协程池来复用协程,达到节省资源的目的。有很多优秀的协程包可供我们直接使用,比如 ants 、 tunny 等。 + +其他特性 + +除了上面那两大类,这里我还想给你介绍下关键代码设计中的其他特性,包括插件化选择 JSON 库、调用链实现、数据一致性。 + +插件化选择 JSON 库 + +Golang 提供的标准 JSON 解析库 encoding/json,在开发高性能、高并发的网络服务时会产生性能问题。所以很多开发者在实际的开发中,往往会选用第三方的高性能 JSON 解析库,例如 jsoniter 、 easyjson 、 jsonparser 等。 + +我见过的很多开发者选择了 jsoniter,也有一些开发者使用了 easyjson。jsoniter 的性能略高于 encoding/json。但随着 go 版本的迭代,encoding/json 库的性能也越来越高,jsoniter 的性能优势也越来越有限。所以,IAM 项目使用了 jsoniter 库,并准备随时切回 encoding/json 库。 + +为了方便切换不同的 JSON 包,iam-apiserver 采用了一种插件化的机制来使用不同的 JSON 包。具体是通过使用 go 的标签编译选择运行的解析库来实现的。 + +标签编译就是在源代码里添加标注,通常称之为编译标签(build tag)。编译标签通过注释的方式在靠近源代码文件顶部的地方添加。go build 在构建一个包的时候,会读取这个包里的每个源文件并且分析编译便签,这些标签决定了这个源文件是否参与本次编译。例如: + +// +build jsoniter + +package json + +import jsoniter "github.com/json-iterator/go" + + ++build jsoniter就是编译标签。这里要注意,一个源文件可以有多个编译标签,多个编译标签之间是逻辑“与”的关系;一个编译标签可以包括由空格分割的多个标签,这些标签是逻辑“或”的关系。例如: + +// +build linux darwin +// +build 386 + + +这里要注意,编译标签和包的声明之间应该使用空行隔开,否则编译标签会被当作包声明的注释,而不是编译标签。 + +那具体来说,我们是如何实现插件化选择 JSON 库的呢? + +首先,我自定义了一个 github.com/marmotedu/component-base/pkg/json json 包,来适配 encoding/json 和 json-iterator。github.com/marmotedu/component-base/pkg/json 包中有两个文件: + + +json.go:映射了 encoding/json 包的 Marshal、Unmarshal、MarshalIndent、NewDecoder、NewEncoder 方法。 +jsoniter.go:映射了 github.com/json-iterator/go 包的 Marshal、Unmarshal、MarshalIndent、NewDecoder、NewEncoder。 + + +json.go 和 jsoniter.go 通过编译标签,让 Go 编译器在构建代码时选择使用哪一个 json 文件。 + +接着,通过在执行 go build时指定 -tags 参数,来选择编译哪个 json 文件。 + +json/json.go、json/jsoniter.go 这两个 Go 文件的顶部,都有一行注释: + +// +build !jsoniter + +// +build jsoniter + + +// +build !jsoniter表示,tags 不是 jsoniter 的时候编译这个 Go 文件。// +build jsoniter表示,tags 是 jsoniter 的时候编译这个 Go 文件。也就是说,这两种条件是互斥的,只有当 tags=jsoniter 的时候,才会使用 json-iterator,其他情况使用 encoding/json。 + +例如,如果我们想使用包,可以这么编译项目: + +$ go build -tags=jsoniter + + +在实际开发中,我们需要根据场景来选择合适的JSON 库。这里我给你一些建议。 + +场景一:结构体序列化和反序列化场景 + +在这个场景中,我个人首推的是官方的 JSON 库。可能你会比较意外,那我就来说说我的理由: + +首先,虽然 easyjson 的性能压倒了其他所有开源项目,但它有一个最大的缺陷,那就是需要额外使用工具来生成这段代码,而对额外工具的版本控制就增加了运维成本。当然,如果你的团队已经能够很好地处理 protobuf 了,也是可以用同样的思路来管理 easyjson 的。 + +其次,虽然 Go 1.8 之前,官方 JSON 库的性能总是被大家吐槽,但现在(1.16.3)官方 JSON 库的性能已不可同日而语。此外,作为使用最为广泛,而且没有之一的 JSON 库,官方库的 bug 是最少的,兼容性也是最好的 + +最后,jsoniter 的性能虽然依然优于官方,但没有达到逆天的程度。如果你追求的是极致的性能,那么你应该选择 easyjson 而不是 jsoniter。jsoniter 近年已经不活跃了,比如说,我前段时间提了一个 issue 没人回复,于是就上去看了下 issue 列表,发现居然还遗留着一些 2018 年的 issue。 + +场景二:非结构化数据的序列化和反序列化场景 + +这个场景下,我们要分高数据利用率和低数据利用率两种情况来看。你可能对数据利用率的高低没啥概念,那我举个例子:JSON 数据的正文中,如果说超过四分之一的数据都是业务需要关注和处理的,那就算是高数据利用率。 + +在高数据利用率的情况下,我推荐使用 jsonvalue。 + +至于低数据利用率的情况,还可以根据 JSON 数据是否需要重新序列化,分成两种情况。 + +如果无需重新序列化,这个时候选择 jsonparser 就行了,因为它的性能实在是耀眼。 + +如果需要重新序列化,这种情况下你有两种选择:如果对性能要求相对较低,可以使用 jsonvalue;如果对性能的要求高,并且只需要往二进制序列中插入一条数据,那么可以采用 jsoniter 的 Set 方法。 + +实际操作中,超大 JSON 数据量,并且同时需要重新序列化的情况非常少,往往是在代理服务器、网关、overlay 中继服务等,同时又需要往原数据中注入额外信息的时候。换句话说,jsoniter 的适用场景比较有限。 + +下面是从 10%到 60%数据覆盖率下,不同库的操作效率对比(纵坐标单位:μs/op): + + + +可以看到,当 jsoniter 的数据利用率达到 25% 时,和 jsonvalue、jsonparser 相比就已经没有任何优势;至于 jsonvalue,由于对数据做了一次性的全解析,因此解析后的数据存取耗时极少,因此在不同数据覆盖率下的耗时都很稳定。 + +调用链实现 + +调用链对查日志、排障帮助非常大。所以,在 iam-apiserver 中也实现了调用链,通过 requestID来串联整个调用链。 + +具体是通过以下两步来实现的: + +第一步,将 ctx context.Context 类型的变量作为函数的第一个参数,在函数调用时传递。 + +第二步,不同函数中,通过 log.L(ctx context.Context)来记录日志。 + +在请求到来时,请求会通过 Context 中间件处理: + +func Context() gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(log.KeyRequestID, c.GetString(XRequestIDKey)) + c.Set(log.KeyUsername, c.GetString(UsernameKey)) + c.Next() + } +} + + +在 Context 中间件中,会在 gin.Context 类型的变量中设置 log.KeyRequestID键,其值为 36 位的 UUID。UUID 通过 RequestID 中间件来生成,并设置在 gin 请求的 Context 中。 + +RequestID 中间件在 Context 中间件之前被加载,所以在 Context 中间件被执行时,能够获取到 RequestID 生成的 UUID。 + +log.L(ctx context.Context)函数在记录日志时,会从头 ctx 中获取到 log.KeyRequestID,并作为一个附加字段随日志打印。 + +通过以上方式,我们最终可以形成 iam-apiserver 的请求调用链,日志示例如下: + +2021-07-19 19:41:33.472 INFO apiserver apiserver/auth.go:205 user `admin` is authenticated. {"requestID": "b6c56cd3-d095-4fd5-a928-291a2e33077f", "username": "admin"} +2021-07-19 19:41:33.472 INFO apiserver policy/create.go:22 create policy function called. {"requestID": "b6c56cd3-d095-4fd5-a928-291a2e33077f", "username": "admin"} +... + + +另外,ctx context.Context作为函数/方法的第一个参数,还有一个好处是方便后期扩展。例如,如果我们有以下调用关系: + +package main + +import "fmt" + +func B(name, address string) string { + return fmt.Sprintf("name: %s, address: %s", name, address) +} + +func A() string { + return B("colin", "sz") +} + +func main() { + fmt.Println(A()) +} + + +上面的代码最终调用 B函数打印出用户名及其地址。如果随着业务的发展,希望 A 调用 B 时,传入用户的电话,B 中打印出用户的电话号码。这时候,我们可能会考虑给 B 函数增加一个电话号参数,例如: + +func B(name, address, phone string) string { + return fmt.Sprintf("name: %s, address: %s, phone: %s", name, address) +} + + +如果我们后面还要增加年龄、性别等属性呢?按这种方式不断增加 B 函数的参数,不仅麻烦,而且还要改动所有调用 B 的函数,工作量也很大。这时候,可以考虑通过 ctx context.Context 来传递这些扩展参数,实现如下: + +package main + +import ( + "context" + "fmt" +) + +func B(ctx context.Context, name, address string) string { + return fmt.Sprintf("name: %s, address: %s, phone: %v", name, address, ctx.Value("phone")) +} + +func A() string { + ctx := context.WithValue(context.TODO(), "phone", "1812884xxxx") + return B(ctx, "colin", "sz") +} + +func main() { + fmt.Println(A()) +} + + +这样,我们下次需要新增参数的话,只需要调用 context 的 WithValue 方法: + +ctx = context.WithValue(ctx, "sex", "male") + + +在 B 函数中,通过 context.Context 类型的变量提供的 Value 方法,从 context 中获取 sex key 即可: + +return fmt.Sprintf("name: %s, address: %s, phone: %v, sex: %v", name, address, ctx.Value("phone"), ctx.Value("sex")) + + +数据一致性 + +为了提高 iam-authz-server 的响应性能,我将密钥和授权策略信息缓存在 iam-authz-server 部署机器的内存中。同时,为了实现高可用,我们需要保证 iam-authz-server 启动的实例个数至少为两个。这时候,我们会面临数据一致性的问题:所有 iam-authz-server 缓存的数据要一致,并且跟 iam-apiserver 数据库中保存的一致。iam-apiserver 通过如下方式来实现数据一致性: + + + +具体流程如下: + +第一步,iam-authz-server 启动时,会通过 grpc 调用 iam-apiserver 的 GetSecrets 和 GetPolicies 接口,获取所有的密钥和授权策略信息。 + +第二步,当我们通过控制台调用 iam-apiserver 密钥/授权策略的写接口(POST、PUT、DELETE)时,会向 Redis 的 iam.cluster.notifications通道发送 SecretChanged/PolicyChanged 消息。 + +第三步,iam-authz-server 会订阅 iam.cluster.notifications通道,当监听到有 SecretChanged/PolicyChanged 消息时,会请求 iam-apiserver 拉取所有的密钥/授权策略。 + +通过 Redis 的 Sub/Pub 机制,保证每个 iam-authz-server 节点的缓存数据跟 iam-apiserver 数据库中保存的数据一致。所有节点都调用 iam-apiserver 的同一个接口来拉取数据,通过这种方式保证所有 iam-authz-server 节点的数据是一致的。 + +总结 + +今天,我和你分享了 iam-apiserver 的一些关键功能实现,并介绍了我的设计思路。这里我再简要梳理下。 + + +为了保证进程关停时,HTTP 请求执行完后再断开连接,进程中的任务正常完成,iam-apiserver 实现了优雅关停功能。 +为了避免进程存在,但服务没成功启动的异常场景,iam-apiserver 实现了健康检查机制。 +Gin 中间件可通过配置文件配置,从而实现按需加载的特性。 +为了能够直接辨别出 API 的版本,iam-apiserver 将 API 的版本标识放在 URL 路径中,例如 /v1/secrets。 +为了能够最大化地共享功能代码,iam-apiserver 抽象出了统一的元数据,每个 REST 资源都具有这些元数据。 +因为 API 接口都是通过同一个函数来返回的,其返回格式自然是统一的。 +因为程序中经常需要处理并发逻辑,iam-apiserver 抽象出了一个通用的并发模板。 +为了方便根据需要切换 JSON 库,我们实现了插件化选择 JSON 库的功能。 +为了实现调用链功能,iam-apiserver 不同函数之间通过 ctx context.Context 来传递 RequestID。 +iam-apiserver 通过 Redis 的 Sub/Pub 机制来保证数据一致性。 + + +课后练习 + + +思考一下,在你的项目开发中,使用过哪些更好的并发处理方式,欢迎你在留言区分享。 +试着给 iam-apiserver 增加一个新的、可配置的 Gin 中间件,用来实现 API 限流的效果。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/30ORM:CURD神器GORM包介绍及实战.md b/专栏/Go语言项目开发实战/30ORM:CURD神器GORM包介绍及实战.md new file mode 100644 index 0000000..303def8 --- /dev/null +++ b/专栏/Go语言项目开发实战/30ORM:CURD神器GORM包介绍及实战.md @@ -0,0 +1,824 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 30 ORM:CURD 神器 GORM 包介绍及实战 + 你好,我是孔令飞。 + +在用Go开发项目时,我们免不了要和数据库打交道。每种语言都有优秀的ORM可供选择,在Go中也不例外,比如gorm、xorm、gorose等。目前,GitHub上 star数最多的是GORM,它也是当前Go项目中使用最多的ORM。 + +IAM项目也使用了GORM。这一讲,我就来详细讲解下GORM的基础知识,并介绍iam-apiserver是如何使用GORM,对数据进行CURD操作的。 + +GORM基础知识介绍 + +GORM是Go语言的ORM包,功能强大,调用方便。像腾讯、华为、阿里这样的大厂,都在使用GORM来构建企业级的应用。GORM有很多特性,开发中常用的核心特性如下: + + +功能全。使用ORM操作数据库的接口,GORM都有,可以满足我们开发中对数据库调用的各类需求。 +支持钩子方法。这些钩子方法可以应用在Create、Save、Update、Delete、Find方法中。 +开发者友好,调用方便。 +支持Auto Migration。 +支持关联查询。 +支持多种关系数据库,例如MySQL、Postgres、SQLite、SQLServer等。 + + +GORM有两个版本,V1和V2。遵循用新不用旧的原则,IAM项目使用了最新的V2版本。 + +通过示例学习GORM + +接下来,我们先快速看一个使用GORM的示例,通过该示例来学习GORM。示例代码存放在marmotedu/gopractise-demo/gorm/main.go文件中。因为代码比较长,你可以使用以下命令克隆到本地查看: + +$ mkdir -p $GOPATH/src/github.com/marmotedu +$ cd $GOPATH/src/github.com/marmotedu +$ git clone https://github.com/marmotedu/gopractise-demo +$ cd gopractise-demo/gorm/ + + +假设我们有一个MySQL数据库,连接地址和端口为 127.0.0.1:3306 ,用户名为 iam ,密码为 iam1234 。创建完main.go文件后,执行以下命令来运行: + +$ go run main.go -H 127.0.0.1:3306 -u iam -p iam1234 -d test +2020/10/17 15:15:50 totalcount: 1 +2020/10/17 15:15:50 code: D42, price: 100 +2020/10/17 15:15:51 totalcount: 1 +2020/10/17 15:15:51 code: D42, price: 200 +2020/10/17 15:15:51 totalcount: 0 + + +在企业级Go项目开发中,使用GORM库主要用来完成以下数据库操作: + + +连接和关闭数据库。连接数据库时,可能需要设置一些参数,比如最大连接数、最大空闲连接数、最大连接时长等。 +插入表记录。可以插入一条记录,也可以批量插入记录。 +更新表记录。可以更新某一个字段,也可以更新多个字段。 +查看表记录。可以查看某一条记录,也可以查看符合条件的记录列表。 +删除表记录。可以删除某一个记录,也可以批量删除。删除还支持永久删除和软删除。 +在一些小型项目中,还会用到GORM的表结构自动迁移功能。 + + +GORM功能强大,上面的示例代码展示的是比较通用的一种操作方式。 + +上述代码中,首先定义了一个GORM模型(Models),Models是标准的Go struct,用来代表数据库中的一个表结构。我们可以给 Models 添加 TableName 方法,来告诉 GORM 该Models映射到数据库中的哪张表。Models定义如下: + +type Product struct { + gorm.Model + Code string `gorm:"column:code"` + Price uint `gorm:"column:price"` +} + +// TableName maps to mysql table name. +func (p *Product) TableName() string { + return "product" +} + + +如果没有指定表名,则GORM使用结构体名的蛇形复数作为表名。例如:结构体名为 DockerInstance ,则表名为 dockerInstances 。 + +在之后的代码中,使用Pflag来解析命令行的参数,通过命令行参数指定数据库的地址、用户名、密码和数据库名。之后,使用这些参数生成建立 MySQL 连接需要的配置文件,并调用 gorm.Open 建立数据库连接: + +var ( + host = pflag.StringP("host", "H", "127.0.0.1:3306", "MySQL service host address") + username = pflag.StringP("username", "u", "root", "Username for access to mysql service") + password = pflag.StringP("password", "p", "root", "Password for access to mysql, should be used pair with password") + database = pflag.StringP("database", "d", "test", "Database name to use") + help = pflag.BoolP("help", "h", false, "Print this help message") +) + +func main() { + // Parse command line flags + pflag.CommandLine.SortFlags = false + pflag.Usage = func() { + pflag.PrintDefaults() + } + pflag.Parse() + if *help { + pflag.Usage() + return + } + + dsn := fmt.Sprintf(`%s:%s@tcp(%s)/%s?charset=utf8&parseTime=%t&loc=%s`, + *username, + *password, + *host, + *database, + true, + "Local") + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + panic("failed to connect database") + } +} + + +创建完数据库连接之后,会返回数据库实例 db ,之后就可以调用db实例中的方法,完成数据库的CURD操作。具体操作如下,一共可以分为六个操作: + +第一个操作,自动迁移表结构。 + +// 1. Auto migration for given models +db.AutoMigrate(&Product{}) + + +我不建议你在正式的代码中自动迁移表结构。因为变更现网数据库是一个高危操作,现网数据库字段的添加、类型变更等,都需要经过严格的评估才能实施。这里将变更隐藏在代码中,在组件发布时很难被研发人员感知到,如果组件启动,就可能会自动修改现网表结构,也可能会因此引起重大的现网事故。 + +GORM的AutoMigrate方法,只对新增的字段或索引进行变更,理论上是没有风险的。在实际的Go项目开发中,也有很多人使用AutoMigrate方法自动同步表结构。但我更倾向于规范化、可感知的操作方式,所以我在实际开发中,都是手动变更表结构的。当然,具体使用哪种方法,你可以根据需要自行选择。 + +第二个操作,插入表记录。 + +// 2. Insert the value into database +if err := db.Create(&Product{Code: "D42", Price: 100}).Error; err != nil { + log.Fatalf("Create error: %v", err) +} +PrintProducts(db) + + +通过 db.Create 方法创建了一条记录。插入记录后,通过调用 PrintProducts 方法打印当前表中的所有数据记录,来测试是否成功插入。 + +第三个操作,获取符合条件的记录。 + +// 3. Find first record that match given conditions +product := &Product{} +if err := db.Where("code= ?", "D42").First(&product).Error; err != nil { + log.Fatalf("Get product error: %v", err) +} + + +First方法只会返回符合条件的记录列表中的第一条,你可以使用First方法来获取某个资源的详细信息。 + +第四个操作,更新表记录。 + +// 4. Update value in database, if the value doesn't have primary key, will insert it +product.Price = 200 +if err := db.Save(product).Error; err != nil { + log.Fatalf("Update product error: %v", err) +} +PrintProducts(db) + + +通过Save方法,可以把 product 变量中所有跟数据库不一致的字段更新到数据库中。具体操作是:先获取某个资源的详细信息,再通过 product.Price = 200 这类赋值语句,对其中的一些字段重新赋值。最后,调用 Save 方法更新这些字段。你可以将这些操作看作一种更新数据库的更新模式。 + +第五个操作,删除表记录。 + +通过 Delete 方法删除表记录,代码如下: + +// 5. Delete value match given conditions +if err := db.Where("code = ?", "D42").Delete(&Product{}).Error; err != nil { + log.Fatalf("Delete product error: %v", err) +} +PrintProducts(db) + + +这里需要注意,因为 Product 中有 gorm.DeletedAt 字段,所以,上述删除操作不会真正把记录从数据库表中删除掉,而是通过设置数据库 product 表 deletedAt 字段为当前时间的方法来删除。 + +第六个操作,获取表记录列表。 + +products := make([]*Product, 0) +var count int64 +d := db.Where("code like ?", "%D%").Offset(0).Limit(2).Order("id desc").Find(&products).Offset(-1).Limit(-1).Count(&count) +if d.Error != nil { + log.Fatalf("List products error: %v", d.Error) +} + + +在PrintProducts函数中,会打印当前的所有记录,你可以根据输出,判断数据库操作是否成功。 + +GORM常用操作讲解 + +看完上面的示例,我想你已经初步掌握了GORM的使用方法。接下来,我再来给你详细介绍下GORM所支持的数据库操作。 + +模型定义 + +GORM使用模型(Models)来映射一个数据库表。默认情况下,使用ID作为主键,使用结构体名的 snake_cases 作为表名,使用字段名的 snake_case 作为列名,并使用 CreatedAt、UpdatedAt、DeletedAt字段追踪创建、更新和删除时间。 + +使用GORM的默认规则,可以减少代码量,但我更喜欢的方式是直接指明字段名和表名。例如,有以下模型: + +type Animal struct { + AnimalID int64 // 列名 `animal_id` + Birthday time.Time // 列名 `birthday` + Age int64 // 列名 `age` +} + + +上述模型对应的表名为 animals ,列名分别为 animal_id 、 birthday 和 age 。我们可以通过以下方式来重命名表名和列名,并将 AnimalID 设置为表的主键: + +type Animal struct { + AnimalID int64 `gorm:"column:animalID;primarykey"` // 将列名设为 `animalID` + Birthday time.Time `gorm:"column:birthday"` // 将列名设为 `birthday` + Age int64 `gorm:"column:age"` // 将列名设为 `age` +} + +func (a *Animal) TableName() string { + return "animal" +} + + +上面的代码中,通过 primaryKey 标签指定主键,使用 column 标签指定列名,通过给Models添加 TableName 方法指定表名。 + +数据库表通常会包含4个字段。 + + +ID:自增字段,也作为主键。 +CreatedAt:记录创建时间。 +UpdatedAt:记录更新时间。 +DeletedAt:记录删除时间(软删除时有用)。 + + +GORM也预定义了包含这4个字段的Models,在我们定义自己的Models时,可以直接内嵌到结构体内,例如: + +type Animal struct { + gorm.Model + AnimalID int64 `gorm:"column:animalID"` // 将列名设为 `animalID` + Birthday time.Time `gorm:"column:birthday"` // 将列名设为 `birthday` + Age int64 `gorm:"column:age"` // 将列名设为 `age` +} + + +Models中的字段能支持很多GORM标签,但如果我们不使用GORM自动创建表和迁移表结构的功能,很多标签我们实际上是用不到的。在开发中,用得最多的是 column 标签。 + +连接数据库 + +在进行数据库的CURD操作之前,我们首先需要连接数据库。你可以通过以下代码连接MySQL数据库: + +import ( + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func main() { + // 参考 https://github.com/go-sql-driver/mysql#dsn-data-source-name 获取详情 + dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) +} + + +如果需要GORM正确地处理 time.Time 类型,在连接数据库时需要带上 parseTime 参数。如果要支持完整的UTF-8编码,可将charset=utf8更改为charset=utf8mb4。 + +GORM支持连接池,底层是用 database/sql 包来维护连接池的,连接池设置如下: + +sqlDB, err := db.DB() +sqlDB.SetMaxIdleConns(100) // 设置MySQL的最大空闲连接数(推荐100) +sqlDB.SetMaxOpenConns(100) // 设置MySQL的最大连接数(推荐100) +sqlDB.SetConnMaxLifetime(time.Hour) // 设置MySQL的空闲连接最大存活时间(推荐10s) + + +上面这些设置,也可以应用在大型后端项目中。 + +创建记录 + +我们可以通过 db.Create 方法来创建一条记录: + +type User struct { + gorm.Model + Name string + Age uint8 + Birthday *time.Time +} +user := User{Name: "Jinzhu", Age: 18, Birthday: time.Now()} +result := db.Create(&user) // 通过数据的指针来创建 + + +db.Create函数会返回如下3个值: + + +user.ID:返回插入数据的主键,这个是直接赋值给user变量。 +result.Error:返回error。 +result.RowsAffected:返回插入记录的条数。 + + +当需要插入的数据量比较大时,可以批量插入,以提高插入性能: + +var users = []User{{Name: "jinzhu1"}, {Name: "jinzhu2"}, {Name: "jinzhu3"}} +DB.Create(&users) + +for _, user := range users { + user.ID // 1,2,3 +} + + +删除记录 + +我们可以通过Delete方法删除记录: + +// DELETE from users where id = 10 AND name = "jinzhu"; +db.Where("name = ?", "jinzhu").Delete(&user) + + +GORM也支持根据主键进行删除,例如: + +// DELETE FROM users WHERE id = 10; +db.Delete(&User{}, 10) + + +不过,我更喜欢使用db.Where的方式进行删除,这种方式有两个优点。 + +第一个优点是删除方式更通用。使用db.Where不仅可以根据主键删除,还能够随意组合条件进行删除。 + +第二个优点是删除方式更显式,这意味着更易读。如果使用db.Delete(&User{}, 10),你还需要确认User的主键,如果记错了主键,还可能会引入Bug。 + +此外,GORM也支持批量删除: + +db.Where("name in (?)", []string{"jinzhu", "colin"}).Delete(&User{}) + + +GORM支持两种删除方法:软删除和永久删除。下面我来分别介绍下。 + + +软删除 + + +软删除是指执行Delete时,记录不会被从数据库中真正删除。GORM会将 DeletedAt 设置为当前时间,并且不能通过正常的方式查询到该记录。如果模型包含了一个 gorm.DeletedAt 字段,GORM在执行删除操作时,会软删除该记录。 + +下面的删除方法就是一个软删除: + +// UPDATE users SET deleted_at="2013-10-29 10:23" WHERE age = 20; +db.Where("age = ?", 20).Delete(&User{}) + +// SELECT * FROM users WHERE age = 20 AND deleted_at IS NULL; +db.Where("age = 20").Find(&user) + + +可以看到,GORM并没有真正把记录从数据库删除掉,而是只更新了 deleted_at 字段。在查询时,GORM查询条件中新增了AND deleted_at IS NULL条件,所以这些被设置过 deleted_at 字段的记录不会被查询到。对于一些比较重要的数据,我们可以通过软删除的方式删除记录,软删除可以使这些重要的数据后期能够被恢复,并且便于以后的排障。 + +我们可以通过下面的方式查找被软删除的记录: + +// SELECT * FROM users WHERE age = 20; +db.Unscoped().Where("age = 20").Find(&users) + + + +永久删除 + + +如果想永久删除一条记录,可以使用Unscoped: + +// DELETE FROM orders WHERE id=10; +db.Unscoped().Delete(&order) + + +或者,你也可以在模型中去掉gorm.DeletedAt。 + +更新记录 + +GORM中,最常用的更新方法如下: + +db.First(&user) + +user.Name = "jinzhu 2" +user.Age = 100 +// UPDATE users SET name='jinzhu 2', age=100, birthday='2016-01-01', updated_at = '2013-11-17 21:34:10' WHERE id=111; +db.Save(&user) + + +上述方法会保留所有字段,所以执行Save时,需要先执行First,获取某个记录的所有列的值,然后再对需要更新的字段设置值。 + +还可以指定更新单个列: + +// UPDATE users SET age=200, updated_at='2013-11-17 21:34:10' WHERE name='colin'; +db.Model(&User{}).Where("name = ?", "colin").Update("age", 200) + + +也可以指定更新多个列: + +// UPDATE users SET name='hello', age=18, updated_at = '2013-11-17 21:34:10' WHERE name = 'colin'; +db.Model(&user).Where("name", "colin").Updates(User{Name: "hello", Age: 18, Active: false}) + + +这里要注意,这个方法只会更新非零值的字段。 + +查询数据 + +GORM支持不同的查询方法,下面我来讲解三种在开发中经常用到的查询方式,分别是检索单个记录、查询所有符合条件的记录和智能选择字段。 + + +检索单个记录 + + +下面是检索单个记录的示例代码: + +// 获取第一条记录(主键升序) +// SELECT * FROM users ORDER BY id LIMIT 1; +db.First(&user) + +// 获取最后一条记录(主键降序) +// SELECT * FROM users ORDER BY id DESC LIMIT 1; +db.Last(&user) +result := db.First(&user) +result.RowsAffected // 返回找到的记录数 +result.Error // returns error + +// 检查 ErrRecordNotFound 错误 +errors.Is(result.Error, gorm.ErrRecordNotFound) + + +如果model类型没有定义主键,则按第一个字段排序。 + + +查询所有符合条件的记录 + + +示例代码如下: + +users := make([]*User, 0) + +// SELECT * FROM users WHERE name <> 'jinzhu'; +db.Where("name <> ?", "jinzhu").Find(&users) + + + +智能选择字段 + + +你可以通过Select方法,选择特定的字段。我们可以定义一个较小的结构体来接受选定的字段: + +type APIUser struct { + ID uint + Name string +} + +// SELECT `id`, `name` FROM `users` LIMIT 10; +db.Model(&User{}).Limit(10).Find(&APIUser{}) + + +除了上面讲的三种常用的基本查询方法,GORM还支持高级查询,下面我来介绍下。 + +高级查询 + +GORM支持很多高级查询功能,这里我主要介绍4种。 + + +指定检索记录时的排序方式 + + +示例代码如下: + +// SELECT * FROM users ORDER BY age desc, name; +db.Order("age desc, name").Find(&users) + + + +Limit & Offset + + +Offset指定从第几条记录开始查询,Limit指定返回的最大记录数。Offset和Limit值为-1时,消除Offset和Limit条件。另外,Limit和Offset位置不同,效果也不同。 + +// SELECT * FROM users OFFSET 5 LIMIT 10; +db.Limit(10).Offset(5).Find(&users) + + + +Distinct + + +Distinct可以从数据库记录中选择不同的值。 + +db.Distinct("name", "age").Order("name, age desc").Find(&results) + + + +Count + + +Count可以获取匹配的条数。 + +var count int64 +// SELECT count(1) FROM users WHERE name = 'jinzhu'; (count) +db.Model(&User{}).Where("name = ?", "jinzhu").Count(&count) + + +GORM还支持很多高级查询功能,比如内联条件、Not 条件、Or 条件、Group & Having、Joins、Group、FirstOrInit、FirstOrCreate、迭代、FindInBatches等。因为IAM项目中没有用到这些高级特性,我在这里就不展开介绍了。你如果感兴趣,可以看下GORM的官方文档。 + +原生SQL + +GORM支持原生查询SQL和执行SQL。原生查询SQL用法如下: + +type Result struct { + ID int + Name string + Age int +} + +var result Result +db.Raw("SELECT id, name, age FROM users WHERE name = ?", 3).Scan(&result) + + +原生执行SQL用法如下; + +db.Exec("DROP TABLE users") +db.Exec("UPDATE orders SET shipped_at=? WHERE id IN ?", time.Now(), []int64{1,2,3}) + + +GORM钩子 + +GORM支持钩子功能,例如下面这个在插入记录前执行的钩子: + +func (u *User) BeforeCreate(tx *gorm.DB) (err error) { + u.UUID = uuid.New() + + if u.Name == "admin" { + return errors.New("invalid name") + } + return +} + + +GORM支持的钩子见下表: + + + +iam-apiserver中的CURD操作实战 + +接下来,我来介绍下iam-apiserver是如何使用GORM,对数据进行CURD操作的。 + +首先,我们需要配置连接MySQL的各类参数。iam-apiserver通过NewMySQLOptions函数创建了一个带有默认值的MySQLOptions类型的变量,将该变量传给NewApp函数。在App框架中,最终会调用MySQLOptions提供的AddFlags方法,将MySQLOptions提供的命令行参数添加到Cobra命令行中。 + +接着,在PrepareRun函数中,调用GetMySQLFactoryOr函数,初始化并获取仓库层的实例mysqlFactory。实现了仓库层store.Factory接口: + +type Factory interface { + Users() UserStore + Secrets() SecretStore + Policies() PolicyStore + Close() error +} + + +GetMySQLFactoryOr函数采用了我们在 11讲 中提过的单例模式,确保iam-apiserver进程中只有一个仓库层的实例,这样可以减少内存开支和系统的性能开销。 + +GetMySQLFactoryOr函数中,使用github.com/marmotedu/iam/pkg/db包提供的New函数,创建了MySQL实例。New函数代码如下: + +func New(opts *Options) (*gorm.DB, error) { + dsn := fmt.Sprintf(`%s:%s@tcp(%s)/%s?charset=utf8&parseTime=%t&loc=%s`, + opts.Username, + opts.Password, + opts.Host, + opts.Database, + true, + "Local") + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.New(opts.LogLevel), + }) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + // SetMaxOpenConns sets the maximum number of open connections to the database. + sqlDB.SetMaxOpenConns(opts.MaxOpenConnections) + + // SetConnMaxLifetime sets the maximum amount of time a connection may be reused. + sqlDB.SetConnMaxLifetime(opts.MaxConnectionLifeTime) + + // SetMaxIdleConns sets the maximum number of connections in the idle connection pool. + sqlDB.SetMaxIdleConns(opts.MaxIdleConnections) + + return db, nil +} + + +上述代码中,我们先创建了一个 *gorm.DB 类型的实例,并对该实例进行了如下设置: + + +通过SetMaxOpenConns方法,设置了MySQL的最大连接数(推荐100)。 +通过SetConnMaxLifetime方法,设置了MySQL的空闲连接最大存活时间(推荐10s)。 +通过SetMaxIdleConns方法,设置了MySQL的最大空闲连接数(推荐100)。 + + +GetMySQLFactoryOr函数最后创建了datastore类型的变量mysqlFactory,该变量是仓库层的变量。mysqlFactory变量中,又包含了 *gorm.DB 类型的字段 db 。 + +最终,我们通过仓库层的变量mysqlFactory,调用其 db 字段提供的方法来完成数据库的CURD操作。例如,创建密钥、更新密钥、删除密钥、获取密钥详情、查询密钥列表,具体代码如下(代码位于secret.go文件中): + +// Create creates a new secret. +func (s *secrets) Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) error { + return s.db.Create(&secret).Error +} + +// Update updates an secret information by the secret identifier. +func (s *secrets) Update(ctx context.Context, secret *v1.Secret, opts metav1.UpdateOptions) error { + return s.db.Save(secret).Error +} + +// Delete deletes the secret by the secret identifier. +func (s *secrets) Delete(ctx context.Context, username, name string, opts metav1.DeleteOptions) error { + if opts.Unscoped { + s.db = s.db.Unscoped() + } + + err := s.db.Where("username = ? and name = ?", username, name).Delete(&v1.Secret{}).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return errors.WithCode(code.ErrDatabase, err.Error()) + } + + return nil +} + +// Get return an secret by the secret identifier. +func (s *secrets) Get(ctx context.Context, username, name string, opts metav1.GetOptions) (*v1.Secret, error) { + secret := &v1.Secret{} + err := s.db.Where("username = ? and name= ?", username, name).First(&secret).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.WithCode(code.ErrSecretNotFound, err.Error()) + } + + return nil, errors.WithCode(code.ErrDatabase, err.Error()) + } + + return secret, nil +} + +// List return all secrets. +func (s *secrets) List(ctx context.Context, username string, opts metav1.ListOptions) (*v1.SecretList, error) { + ret := &v1.SecretList{} + ol := gormutil.Unpointer(opts.Offset, opts.Limit) + + if username != "" { + s.db = s.db.Where("username = ?", username) + } + + selector, _ := fields.ParseSelector(opts.FieldSelector) + name, _ := selector.RequiresExactMatch("name") + + d := s.db.Where(" name like ?", "%"+name+"%"). + Offset(ol.Offset). + Limit(ol.Limit). + Order("id desc"). + Find(&ret.Items). + Offset(-1). + Limit(-1). + Count(&ret.TotalCount) + + return ret, d.Error +} + + +上面的代码中, s.db 就是 *gorm.DB 类型的字段。 + +上面的代码段执行了以下操作: + + +通过 s.db.Save 来更新数据库表的各字段; +通过 s.db.Unscoped 来永久性从表中删除一行记录。对于支持软删除的资源,我们还可以通过 opts.Unscoped 选项来控制是否永久删除记录。 true 永久删除, false 软删除,默认软删除。 +通过 errors.Is(err, gorm.ErrRecordNotFound) 来判断GORM返回的错误是否是没有找到记录的错误类型。 +通过下面两行代码,来获取查询条件name的值: + + +selector, _ := fields.ParseSelector(opts.FieldSelector) +name, _ := selector.RequiresExactMatch("name") + + +我们的整个调用链是:控制层 -> 业务层 -> 仓库层。这里你可能要问:我们是如何调用到仓库层的实例mysqlFactory的呢? + +这是因为我们的控制层实例包含了业务层的实例。在创建控制层实例时,我们传入了业务层的实例: + +type UserController struct { + srv srvv1.Service +} + +// NewUserController creates a user handler. +func NewUserController(store store.Factory) *UserController { + return &UserController{ + srv: srvv1.NewService(store), + } +} + + +业务层的实例包含了仓库层的实例。在创建业务层实例时,传入了仓库层的实例: + +type service struct { + store store.Factory +} + +// NewService returns Service interface. +func NewService(store store.Factory) Service { + return &service{ + store: store, + } +} + + +通过这种包含关系,我们在控制层可以调用业务层的实例,在业务层又可以调用仓库层的实例。这样,我们最终通过仓库层实例的 db 字段(*gorm.DB 类型)完成数据库的CURD操作。 + +总结 + +在Go项目中,我们需要使用ORM来进行数据库的CURD操作。在Go生态中,当前最受欢迎的ORM是GORM,IAM项目也使用了GORM。GORM有很多功能,常用的功能有模型定义、连接数据库、创建记录、删除记录、更新记录和查询数据。这些常用功能的常见使用方式如下: + +package main + +import ( + "fmt" + "log" + + "github.com/spf13/pflag" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type Product struct { + gorm.Model + Code string `gorm:"column:code"` + Price uint `gorm:"column:price"` +} + +// TableName maps to mysql table name. +func (p *Product) TableName() string { + return "product" +} + +var ( + host = pflag.StringP("host", "H", "127.0.0.1:3306", "MySQL service host address") + username = pflag.StringP("username", "u", "root", "Username for access to mysql service") + password = pflag.StringP("password", "p", "root", "Password for access to mysql, should be used pair with password") + database = pflag.StringP("database", "d", "test", "Database name to use") + help = pflag.BoolP("help", "h", false, "Print this help message") +) + +func main() { + // Parse command line flags + pflag.CommandLine.SortFlags = false + pflag.Usage = func() { + pflag.PrintDefaults() + } + pflag.Parse() + if *help { + pflag.Usage() + return + } + + dsn := fmt.Sprintf(`%s:%s@tcp(%s)/%s?charset=utf8&parseTime=%t&loc=%s`, + *username, + *password, + *host, + *database, + true, + "Local") + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + panic("failed to connect database") + } + + // 1. Auto migration for given models + db.AutoMigrate(&Product{}) + + // 2. Insert the value into database + if err := db.Create(&Product{Code: "D42", Price: 100}).Error; err != nil { + log.Fatalf("Create error: %v", err) + } + PrintProducts(db) + + // 3. Find first record that match given conditions + product := &Product{} + if err := db.Where("code= ?", "D42").First(&product).Error; err != nil { + log.Fatalf("Get product error: %v", err) + } + + // 4. Update value in database, if the value doesn't have primary key, will insert it + product.Price = 200 + if err := db.Save(product).Error; err != nil { + log.Fatalf("Update product error: %v", err) + } + PrintProducts(db) + + // 5. Delete value match given conditions + if err := db.Where("code = ?", "D42").Delete(&Product{}).Error; err != nil { + log.Fatalf("Delete product error: %v", err) + } + PrintProducts(db) +} + +// List products +func PrintProducts(db *gorm.DB) { + products := make([]*Product, 0) + var count int64 + d := db.Where("code like ?", "%D%").Offset(0).Limit(2).Order("id desc").Find(&products).Offset(-1).Limit(-1).Count(&count) + if d.Error != nil { + log.Fatalf("List products error: %v", d.Error) + } + + log.Printf("totalcount: %d", count) + for _, product := range products { + log.Printf("\tcode: %s, price: %d\n", product.Code, product.Price) + } +} + + +此外,GORM还支持原生查询SQL和原生执行SQL,可以满足更加复杂的SQL场景。 + +GORM中,还有一个非常有用的功能是支持Hooks。Hooks可以在执行某个CURD操作前被调用。在Hook中,可以添加一些非常有用的功能,例如生成唯一ID。目前,GORM支持 BeforeXXX 、 AfterXXX 和 AfterFind Hook,其中 XXX 可以是 Save、Create、Delete、Update。 + +最后,我还介绍了IAM项目的GORM实战,具体使用方式跟总结中的示例代码大体保持一致,你可以返回文稿查看。 + +课后练习 + + +GORM支持AutoMigrate功能,思考下,你的生产环境是否可以使用AutoMigrate功能,为什么? +查看GORM官方文档,看下如何用GORM实现事务回滚功能。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/31数据流:通过iam-authz-server设计,看数据流服务的设计.md b/专栏/Go语言项目开发实战/31数据流:通过iam-authz-server设计,看数据流服务的设计.md new file mode 100644 index 0000000..36d62de --- /dev/null +++ b/专栏/Go语言项目开发实战/31数据流:通过iam-authz-server设计,看数据流服务的设计.md @@ -0,0 +1,686 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 31 数据流:通过iam-authz-server设计,看数据流服务的设计 + 你好,我是孔令飞。 + +在 28讲 和 29讲 ,我介绍了IAM的控制流服务iam-apiserver的设计和实现。这一讲,我们再来看下IAM数据流服务iam-authz-server的设计和实现。 + +因为iam-authz-server是数据流服务,对性能要求较高,所以采用了一些机制来最大化API接口的性能。另外,为了提高开发效率,避免重复造轮子,iam-authz-server和iam-apiserver共享了大部分的功能代码。接下来,我们就来看下,iam-authz-server是如何跟iam-apiserver共享代码的,以及iam-authz-server是如何保证API接口性能的。 + +iam-authz-server的功能介绍 + +iam-authz-server目前的唯一功能,是通过提供 /v1/authz RESTful API接口完成资源授权。 /v1/authz 接口是通过github.com/ory/ladon来完成资源授权的。 + +因为iam-authz-server承载了数据流的请求,需要确保API接口具有较高的性能。为了保证API接口的性能,iam-authz-server在设计上使用了大量的缓存技术。 + +github.com/ory/ladon包介绍 + +因为iam-authz-server资源授权是通过 github.com/ory/ladon 来完成的,为了让你更好地理解iam-authz-server的授权策略,在这里我先介绍下 github.com/ory/ladon 包。 + +Ladon是用Go语言编写的用于实现访问控制策略的库,类似于RBAC(基于角色的访问控制系统,Role Based Access Control)和ACL(访问控制列表,Access Control Lists)。但是与RBAC和ACL相比,Ladon可以实现更细粒度的访问控制,并且能够在更为复杂的环境中(例如多租户、分布式应用程序和大型组织)工作。 + +Ladon解决了这个问题:在特定的条件下,谁能够/不能够对哪些资源做哪些操作。为了解决这个问题,Ladon引入了授权策略。授权策略是一个有语法规范的文档,这个文档描述了谁在什么条件下能够对哪些资源做哪些操作。Ladon可以用请求的上下文,去匹配设置的授权策略,最终判断出当前授权请求是否通过。下面是一个Ladon的授权策略样例: + +{ + "description": "One policy to rule them all.", + "subjects": ["users:", "users:maria", "groups:admins"], + "actions" : ["delete", ""], + "effect": "allow", + "resources": [ + "resources:articles:<.*>", + "resources:printer" + ], + "conditions": { + "remoteIP": { + "type": "CIDRCondition", + "options": { + "cidr": "192.168.0.1/16" + } + } + } +} + + +策略(Policy)由若干元素构成,用来描述授权的具体信息,你可以把它们看成一组规则。核心元素包括主题(Subject)、操作(Action)、效力(Effect)、资源(Resource)以及生效条件(Condition)。元素保留字仅支持小写,它们在描述上没有顺序要求。对于没有特定约束条件的策略,Condition元素是可选项。一条策略包含下面6个元素: + + +主题(Subject),主题名是唯一的,代表一个授权主题。例如,“ken” or “printer-service.mydomain.com”。 +操作(Action),描述允许或拒绝的操作。 +效力(Effect),描述策略产生的结果是“允许”还是“拒绝”,包括 allow(允许)和 deny(拒绝)。 +资源(Resource),描述授权的具体数据。 +生效条件(Condition),描述策略生效的约束条件。 +描述(Description),策略的描述。 + + +有了授权策略,我们就可以传入请求上下文,由Ladon来决定请求是否能通过授权。下面是一个请求示例: + +{ + "subject": "users:peter", + "action" : "delete", + "resource": "resources:articles:ladon-introduction", + "context": { + "remoteIP": "192.168.0.5" + } +} + + +可以看到,在 remoteIP="192.168.0.5" 生效条件(Condition)下,针对主题(Subject) users:peter 对资源(Resource) resources:articles:ladon-introduction 的 delete 操作(Action),授权策略的效力(Effect)是 allow 的。所以Ladon会返回如下结果: + +{ + "allowed": true +} + + +Ladon支持很多Condition,具体见下表: + + + +至于如何使用这些Condition,你可以参考 Ladon Condition使用示例。此外,Ladon还支持自定义Condition。 + +另外,Ladon还支持授权审计,用来记录授权历史。我们可以通过在ladon.Ladon中附加一个ladon.AuditLogger来实现: + +import "github.com/ory/ladon" +import manager "github.com/ory/ladon/manager/memory" + +func main() { + + warden := ladon.Ladon{ + Manager: manager.NewMemoryManager(), + AuditLogger: &ladon.AuditLoggerInfo{} + } + + // ... +} + + +在上面的示例中,我们提供了ladon.AuditLoggerInfo,该AuditLogger会在授权时打印调用的策略到标准错误。AuditLogger是一个interface: + +// AuditLogger tracks denied and granted authorizations. +type AuditLogger interface { + LogRejectedAccessRequest(request *Request, pool Policies, deciders Policies) + LogGrantedAccessRequest(request *Request, pool Policies, deciders Policies) +} + + +要实现一个新的AuditLogger,你只需要实现AuditLogger接口就可以了。比如,我们可以实现一个AuditLogger,将授权日志保存到Redis或者MySQL中。 + +Ladon支持跟踪一些授权指标,比如 deny、allow、not match、error。你可以通过实现ladon.Metric接口,来对这些指标进行处理。ladon.Metric接口定义如下: + +// Metric is used to expose metrics about authz +type Metric interface { + // RequestDeniedBy is called when we get explicit deny by policy + RequestDeniedBy(Request, Policy) + // RequestAllowedBy is called when a matching policy has been found. + RequestAllowedBy(Request, Policies) + // RequestNoMatch is called when no policy has matched our request + RequestNoMatch(Request) + // RequestProcessingError is called when unexpected error occured + RequestProcessingError(Request, Policy, error) +} + + +例如,你可以通过下面的示例,将这些指标暴露给prometheus: + +type prometheusMetrics struct{} + +func (mtr *prometheusMetrics) RequestDeniedBy(r ladon.Request, p ladon.Policy) {} +func (mtr *prometheusMetrics) RequestAllowedBy(r ladon.Request, policies ladon.Policies) {} +func (mtr *prometheusMetrics) RequestNoMatch(r ladon.Request) {} +func (mtr *prometheusMetrics) RequestProcessingError(r ladon.Request, err error) {} + +func main() { + + warden := ladon.Ladon{ + Manager: manager.NewMemoryManager(), + Metric: &prometheusMetrics{}, + } + + // ... +} + + +在使用Ladon的过程中,有两个地方需要你注意: + + +所有检查都区分大小写,因为主题值可能是区分大小写的ID。 +如果ladon.Ladon无法将策略与请求匹配,会默认授权结果为拒绝,并返回错误。 + + +iam-authz-server使用方法介绍 + +上面,我介绍了iam-authz-server的资源授权功能,这里介绍下如何使用iam-authz-server,也就是如何调用 /v1/authz 接口完成资源授权。你可以通过下面的3大步骤,来完成资源授权请求。 + +第一步,登陆iam-apiserver,创建授权策略和密钥。 + +这一步又分为3个小步骤。 + + +登陆iam-apiserver系统,获取访问令牌: + + +$ token=`curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login | jq -r .token` + + + +创建授权策略: + + +$ curl -s -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer $token" -d'{"metadata":{"name":"authztest"},"policy":{"description":"One policy to rule them all.","subjects":["users:","users:maria","groups:admins"],"actions":["delete",""],"effect":"allow","resources":["resources:articles:<.*>","resources:printer"],"conditions":{"remoteIP":{"type":"CIDRCondition","options":{"cidr":"192.168.0.1/16"}}}}}' http://127.0.0.1:8080/v1/policies + + + +创建密钥,并从请求结果中提取secretID 和 secretKey: + + +$ curl -s -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer $token" -d'{"metadata":{"name":"authztest"},"expires":0,"description":"admin secret"}' http://127.0.0.1:8080/v1/secrets +{"metadata":{"id":23,"name":"authztest","createdAt":"2021-04-08T07:24:50.071671422+08:00","updatedAt":"2021-04-08T07:24:50.071671422+08:00"},"username":"admin","secretID":"ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox","secretKey":"7Sfa5EfAPIwcTLGCfSvqLf0zZGCjF3l8","expires":0,"description":"admin secret"} + + +第二步,生成访问 iam-authz-server的 token。 + +iamctl 提供了 jwt sigin 子命令,可以根据 secretID 和 secretKey 签发 Token,方便使用。 + +$ iamctl jwt sign ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox 7Sfa5EfAPIwcTLGCfSvqLf0zZGCjF3l8 # iamctl jwt sign $secretID $secretKey +eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ + + +你可以通过 iamctl jwt show 来查看Token的内容: + +$ iamctl jwt show eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ +Header: +{ + "alg": "HS256", + "kid": "ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox", + "typ": "JWT" +} +Claims: +{ + "aud": "iam.authz.marmotedu.com", + "exp": 1617845195, + "iat": 1617837995, + "iss": "iamctl", + "nbf": 1617837995 +} + + +我们生成的Token包含了下面这些信息。 + +Header + + +alg:生成签名的算法。 +kid:密钥ID。 +typ:Token的类型,这里是JWT。 + + +Claims + + +aud:JWT Token的接受者。 +exp:JWT Token的过期时间(UNIX时间格式)。 +iat:JWT Token的签发时间(UNIX时间格式)。 +iss:签发者,因为我们是用 iamctl 工具签发的,所以这里的签发者是 iamctl。 +nbf:JWT Token的生效时间(UNIX时间格式),默认是签发时间。 + + +第三步,调用/v1/authz接口,完成资源授权请求。 + +请求方法如下: + +$ curl -s -XPOST -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ' -d'{"subject":"users:maria","action":"delete","resource":"resources:articles:ladon-introduction","context":{"remoteIP":"192.168.0.5"}}' http://127.0.0.1:9090/v1/authz +{"allowed":true} + + +如果授权通过,会返回:{"allowed":true} 。 如果授权失败,则返回: + +{"allowed":false,"denied":true,"reason":"Request was denied by default"} + + +iam-authz-server的代码实现 + +接下来,我们来看下iam-authz-server的具体实现,我会从配置处理、启动流程、请求处理流程和代码架构4个方面来讲解。 + +iam-authz-server的配置处理 + +iam-authz-server服务的main函数位于authzserver.go文件中,你可以跟读代码,了解iam-authz-server的代码实现。iam-authz-server的服务框架设计跟iam-apiserver的服务框架设计保持一致,也是有3种配置:Options配置、组件配置和HTTP服务配置。 + +Options配置见options.go文件: + +type Options struct { + RPCServer string + ClientCA string + GenericServerRunOptions *genericoptions.ServerRunOptions + InsecureServing *genericoptions.InsecureServingOptions + SecureServing *genericoptions.SecureServingOptions + RedisOptions *genericoptions.RedisOptions + FeatureOptions *genericoptions.FeatureOptions + Log *log.Options + AnalyticsOptions *analytics.AnalyticsOptions +} + + +和iam-apiserver相比,iam-authz-server多了 AnalyticsOptions,用来配置iam-authz-server内的Analytics服务,Analytics服务会将授权日志异步写入到Redis中。 + +iam-apiserver和iam-authz-server共用了GenericServerRunOptions、InsecureServing、SecureServing、FeatureOptions、RedisOptions、Log这些配置。所以,我们只需要用简单的几行代码,就可以将很多配置项都引入到iam-authz-server的命令行参数中,这也是命令行参数分组带来的好处:批量共享。 + +iam-authz-server启动流程设计 + +接下来,我们来详细看下iam-authz-server的启动流程。 + +iam-authz-server的启动流程也和iam-apiserver基本保持一致。二者比较大的不同在于Options参数配置和应用初始化内容。另外,和iam-apiserver相比,iam-authz-server只提供了REST API服务。启动流程如下图所示: + + + +iam-authz-server 的 RESTful API请求处理流程 + +iam-authz-server的请求处理流程也是清晰、规范的,具体流程如下图所示: + + + +首先,我们通过API调用( + )请求iam-authz-server提供的RESTful API接口 POST /v1/authz 。 + +接着,Gin Web框架接收到HTTP请求之后,会通过认证中间件完成请求的认证,iam-authz-server采用了Bearer认证方式。 + +然后,请求会被我们加载的一系列中间件所处理,例如跨域、RequestID、Dump等中间件。 + +最后,根据 + 进行路由匹配。 + +比如,我们请求的RESTful API是POST /v1/authz,Gin Web框架会根据 HTTP Method 和 HTTP Request Path,查找注册的Controllers,最终匹配到 authzController.Authorize Controller。在 Authorize Controller中,会先解析请求参数,接着校验请求参数、调用业务层的方法进行资源授权,最后处理业务层的返回结果,返回最终的 HTTP 请求结果。 + +iam-authz-server的代码架构 + +iam-authz-server的代码设计和iam-apiserver一样,遵循简洁架构设计。 + +iam-authz-server的代码架构也分为4层,分别是模型层(Models)、控制层(Controller)、业务层 (Service)和仓库层(Repository)。从控制层、业务层到仓库层,从左到右层级依次加深。模型层独立于其他层,可供其他层引用。如下图所示: + + + +iam-authz-server 和 iam-apiserver 的代码架构有这三点不同: + + +iam-authz-server客户端不支持前端和命令行。 +iam-authz-server仓库层对接的是iam-apiserver微服务,而非数据库。 +iam-authz-server业务层的代码存放在目录authorization中。 + + +iam-authz-server关键代码分析 + +和 iam-apiserver 一样,iam-authz-server也包含了一些优秀的设计思路和关键代码,这里我来一一介绍下。 + +资源授权 + +先来看下,iam-authz-server是如何实现资源授权的。 + +我们可以调用iam-authz-server的 /v1/authz API接口,实现资源的访问授权。 /v1/authz 对应的controller方法是Authorize: + +func (a *AuthzController) Authorize(c *gin.Context) { + var r ladon.Request + if err := c.ShouldBind(&r); err != nil { + core.WriteResponse(c, errors.WithCode(code.ErrBind, err.Error()), nil) + + return + } + + auth := authorization.NewAuthorizer(authorizer.NewAuthorization(a.store)) + if r.Context == nil { + r.Context = ladon.Context{} + } + + r.Context["username"] = c.GetString("username") + rsp := auth.Authorize(&r) + + core.WriteResponse(c, nil, rsp) +} + + +该函数使用 github.com/ory/ladon 包进行资源访问授权,授权流程如下图所示: + + + +具体分为以下几个步骤: + +第一步,在Authorize方法中调用 c.ShouldBind(&r) ,将API请求参数解析到 ladon.Request 类型的结构体变量中。 + +第二步,调用authorization.NewAuthorizer函数,该函数会创建并返回包含Manager和AuditLogger字段的Authorizer类型的变量。 + +Manager包含一些函数,比如 Create、Update和FindRequestCandidates等,用来对授权策略进行增删改查。AuditLogger包含 LogRejectedAccessRequest 和 LogGrantedAccessRequest 函数,分别用来记录被拒绝的授权请求和被允许的授权请求,将其作为审计数据使用。 + +第三步,调用auth.Authorize函数,对请求进行访问授权。auth.Authorize函数内容如下: + +func (a *Authorizer) Authorize(request *ladon.Request) *authzv1.Response { + log.Debug("authorize request", log.Any("request", request)) + + if err := a.warden.IsAllowed(request); err != nil { + return &authzv1.Response{ + Denied: true, + Reason: err.Error(), + } + } + + return &authzv1.Response{ + Allowed: true, + } +} + + +该函数会调用 a.warden.IsAllowed(request) 完成资源访问授权。IsAllowed函数会调用 FindRequestCandidates(r) 查询所有的策略列表,这里要注意,我们只需要查询请求用户的policy列表。在Authorize函数中,我们将username存入ladon Request的context中: + +r.Context["username"] = c.GetHeader("username") + + +在FindRequestCandidates函数中,我们可以从Request中取出username,并根据username查询缓存中的policy列表,FindRequestCandidates实现如下: + +func (m *PolicyManager) FindRequestCandidates(r *ladon.Request) (ladon.Policies, error) { + username := "" + + if user, ok := r.Context["username"].(string); ok { + username = user + } + + policies, err := m.client.List(username) + if err != nil { + return nil, errors.Wrap(err, "list policies failed") + } + + ret := make([]ladon.Policy, 0, len(policies)) + for _, policy := range policies { + ret = append(ret, policy) + } + + return ret, nil + } + + +IsAllowed函数代码如下: + +func (l *Ladon) IsAllowed(r *Request) (err error) { + policies, err := l.Manager.FindRequestCandidates(r) + if err != nil { + go l.metric().RequestProcessingError(*r, nil, err) + return err + } + + return l.DoPoliciesAllow(r, policies) +} + + +IsAllowed会调用 DoPoliciesAllow(r, policies) 函数进行权限校验。如果权限校验不通过(请求在指定条件下不能够对资源做指定操作),就调用 LogRejectedAccessRequest 函数记录拒绝的请求,并返回值为非nil的error,error中记录了授权失败的错误信息。如果权限校验通过,则调用 LogGrantedAccessRequest 函数记录允许的请求,并返回值为nil的error。 + +为了降低请求延时,LogRejectedAccessRequest和LogGrantedAccessRequest会将授权记录存储在Redis中,之后由iam-pump进程读取Redis,并将授权记录持久化存储在MongoDB中。 + +缓存设计 + +iam-authz-server主要用来做资源访问授权,属于数据流的组件,对接口访问性能有比较高的要求,所以该组件采用了缓存的机制。如下图所示: + + + +iam-authz-server组件通过缓存密钥和授权策略信息到内存中,加快密钥和授权策略的查询速度。通过缓存授权记录到内存中,提高了授权数据的写入速度,从而大大降低了授权请求接口的延时。 + +上面的缓存机制用到了Redis key-value存储,所以在iam-authz-server初始化阶段,需要先建立Redis连接(位于initialize函数中): + +go storage.ConnectToRedis(ctx, s.buildStorageConfig()) + + +这个代码会维护一个Redis连接,如果Redis连接断掉,会尝试重连。这种方式可以使我们在调用Redis接口进行数据读写时,不用考虑连接断开的问题。 + +接下来,我们就来详细看看,iam-authz-server是如何实现缓存机制的。 + +先来看下密钥和策略缓存。 + +iam-authz-server通过load包来完成密钥和策略的缓存。 + +在iam-authz-server进程启动时,会创建并启动一个Load服务(位于initialize函数中): + +load.NewLoader(ctx, cacheIns).Start() + + +先来看创建Load服务。创建Load服务时,传入了cacheIns参数,cacheIns是一个实现了Loader接口的实例: + +type Loader interface { + Reload() error +} + + +然后看启动Load服务。通过Load实例的 Start 方法来启动Load服务: + +func (l *Load) Start() { + go startPubSubLoop() + go l.reloadQueueLoop() + go l.reloadLoop() + + l.DoReload() +} + + +Start函数先启动了3个协程,再调用 l.DoReload() 完成一次密钥和策略的同步: + +func (l *Load) DoReload() { + l.lock.Lock() + defer l.lock.Unlock() + + if err := l.loader.Reload(); err != nil { + log.Errorf("faild to refresh target storage: %s", err.Error()) + } + + log.Debug("refresh target storage succ") +} + + +上面我们说了,创建Load服务时,传入的cacheIns实例是一个实现了Loader接口的实例,所以在DoReload方法中,可以直接调用Reload方法。cacheIns的Reload方法会从iam-apiserver中同步密钥和策略信息到iam-authz-server缓存中。 + +我们再来看下,startPubSubLoop、reloadQueueLoop、reloadLoop 这3个Go协程分别完成了什么功能。 + + +startPubSubLoop协程 + + +startPubSubLoop函数通过StartPubSubHandler函数,订阅Redis的 iam.cluster.notifications channel,并注册一个回调函数: + +func(v interface{}) { + handleRedisEvent(v, nil, nil) +} + + +handleRedisEvent函数中,会将消息解析为Notification类型的消息,并判断Command的值。如果是NoticePolicyChanged或NoticeSecretChanged,就会向 reloadQueue channel中写入一个回调函数。因为我们不需要用回调函数做任何事情,所以这里回调函数是nil。 reloadQueue 主要用来告诉程序,需要完成一次密钥和策略的同步。 + + +reloadQueueLoop协程 + + +reloadQueueLoop函数会监听 reloadQueue ,当发现有新的消息(这里是回调函数)写入时,会实时将消息缓存到 requeue 切片中,代码如下: + +func (l *Load) reloadQueueLoop(cb ...func()) { + for { + select { + case <-l.ctx.Done(): + return + case fn := <-reloadQueue: + requeueLock.Lock() + requeue = append(requeue, fn) + requeueLock.Unlock() + log.Info("Reload queued") + if len(cb) != 0 { + cb[0]() + } + } + } + } + + + +reloadLoop协程 + + +通过reloadLoop函数启动一个timer定时器,每隔1秒会检查 requeue 切片是否为空,如果不为空,则调用 l.DoReload 方法,从iam-apiserver中拉取密钥和策略,并缓存在内存中。 + +密钥和策略的缓存模型如下图所示: + + + +密钥和策略缓存的具体流程如下: + +接收上游消息(这里是从Redis中接收),将消息缓存到切片或者带缓冲的channel中,并启动一个消费协程去消费这些消息。这里的消费协程是reloadLoop,reloadLoop会每隔1s判断 requeue 切片是否长度为0,如果不为0,则执行 l.DoReload() 缓存密钥和策略。 + +讲完了密钥和策略缓存,再来看下授权日志缓存。 + +在启动iam-authz-server时,还会启动一个Analytics服务,代码如下(位于internal/authzserver/server.go文件中): + + if s.analyticsOptions.Enable { + analyticsStore := storage.RedisCluster{KeyPrefix: RedisKeyPrefix} + analyticsIns := analytics.NewAnalytics(s.analyticsOptions, &analyticsStore) + analyticsIns.Start() + s.gs.AddShutdownCallback(shutdown.ShutdownFunc(func(string) error { + analyticsIns.Stop() + + return nil + })) + } + + +NewAnalytics函数会根据配置,创建一个Analytics实例: + +func NewAnalytics(options *AnalyticsOptions, store storage.AnalyticsHandler) *Analytics { + ps := options.PoolSize + recordsBufferSize := options.RecordsBufferSize + workerBufferSize := recordsBufferSize / uint64(ps) + log.Debug("Analytics pool worker buffer size", log.Uint64("workerBufferSize", workerBufferSize)) + + recordsChan := make(chan *AnalyticsRecord, recordsBufferSize) + + return &Analytics{ + store: store, + poolSize: ps, + recordsChan: recordsChan, + workerBufferSize: workerBufferSize, + recordsBufferFlushInterval: options.FlushInterval, + } + } + + +上面的代码创建了一个带缓冲的 recordsChan : + +recordsChan := make(chan *AnalyticsRecord, recordsBufferSize) + + +recordsChan 存放的数据类型为AnalyticsRecord,缓冲区的大小为 recordsBufferSize (通过 --analytics.records-buffer-size 选项指定)。可以通过RecordHit函数,向recordsChan 中写入 AnalyticsRecord 类型的数据: + +func (r *Analytics) RecordHit(record *AnalyticsRecord) error { + // check if we should stop sending records 1st + if atomic.LoadUint32(&r.shouldStop) > 0 { + return nil + } + + // just send record to channel consumed by pool of workers + // leave all data crunching and Redis I/O work for pool workers + r.recordsChan <- record + + return nil +} + + +iam-authz-server是通过调用 LogGrantedAccessRequest 和 LogRejectedAccessRequest 函数来记录授权日志的。在记录授权日志时,会将授权日志写入 recordsChan channel中。LogGrantedAccessRequest函数代码如下: + +func (auth *Authorization) LogGrantedAccessRequest(r *ladon.Request, p ladon.Policies, d ladon.Policies) { + conclusion := fmt.Sprintf("policies %s allow access", joinPoliciesNames(d)) + rstring, pstring, dstring := convertToString(r, p, d) + record := analytics.AnalyticsRecord{ + TimeStamp: time.Now().Unix(), + Username: r.Context["username"].(string), + Effect: ladon.AllowAccess, + Conclusion: conclusion, + Request: rstring, + Policies: pstring, + Deciders: dstring, + } + + record.SetExpiry(0) + _ = analytics.GetAnalytics().RecordHit(&record) +} + + +上面的代码,会创建AnalyticsRecord类型的结构体变量,并调用RecordHit将变量的值写入 recordsChan channel中。将授权日志写入 recordsChan channel中,而不是直接写入Redis中,这可以大大减少写入延时,减少接口的响应延时。 + +还有一个worker进程从recordsChan中读取数据,并在数据达到一定阈值之后,批量写入Redis中。在Start函数中,我们创建了一批worker,worker个数可以通过 --analytics.pool-size 来指定 。Start函数内容如下: + +func (r *Analytics) Start() { + analytics = r + r.store.Connect() + + // start worker pool + atomic.SwapUint32(&r.shouldStop, 0) + for i := 0; i < r.poolSize; i++ { + r.poolWg.Add(1) + go r.recordWorker() + } + + // stop analytics workers + go r.Stop() + } + + +上面的代码通过 go r.recordWorker() 创建了 由poolSize 指定个数的recordWorker(worker),recordWorker函数会从 recordsChan 中读取授权日志并存入recordsBuffer中,recordsBuffer的大小为workerBufferSize,workerBufferSize计算公式为: + +ps := options.PoolSize +recordsBufferSize := options.RecordsBufferSize +workerBufferSize := recordsBufferSize / uint64(ps) + + +其中,options.PoolSize由命令行参数 --analytics.pool-size 指定,代表worker 的个数,默认 50;options.RecordsBufferSize由命令行参数 --analytics.records-buffer-size 指定,代表缓存的授权日志消息数。也就是说,我们把缓存的记录平均分配给所有的worker。 + +当recordsBuffer存满或者达到投递最大时间后,调用 r.Store.AppendToSetPipelined(analyticsKeyName, recordsBuffer) 将记录批量发送给Redis,为了提高传输速率,这里将日志内容编码为msgpack格式后再传输。 + +上面的缓存方法可以抽象成一个缓存模型,满足实际开发中的大部分需要异步转存的场景,如下图所示: + + + +Producer将数据投递到带缓冲的channel中,后端有多个worker消费channel中的数据,并进行批量投递。你可以设置批量投递的条件,一般至少包含最大投递日志数和最大投递时间间隔这两个。 + +通过以上缓冲模型,你可以将日志转存的时延降到最低。 + +数据一致性 + +上面介绍了 iam-authz-server的 /v1/authz 接口,为了最大化地提高性能,采用了大量的缓存设计。因为数据会分别在持久化存储和内存中都存储一份,就可能会出现数据不一致的情况。所以,我们也要确保缓存中的数据和数据库中的数据是一致的。数据一致性架构如下图所示: + + + +密钥和策略同步流程如下: + + +通过iam-webconsole请求iam-apiserver创建(或更新、删除)密钥(或策略)。 +iam-apiserver收到“写”请求后,会向Redis iam.cluster.notifications channel发送PolicyChanged或SecretChanged消息。 +Loader收到消息后,会触发cache loader实例执行 Reload 方法,重新从iam-apiserver中同步密钥和策略信息。 + + +Loader不会关心 Reload 方法的具体实现,只会在收到指定消息时,执行 Reload 方法。通过这种方式,我们可以实现不同的缓存策略。 + +在cache实例的 Reload 方法中,我们其实是调用仓库层Secret和Policy的List方法来获取密钥和策略列表。仓库层又是通过执行gRPC请求,从iam-apiserver中获取密钥和策略列表。 + +cache的Reload方法,会将获取到的密钥和策略列表缓存在ristretto类型的Cache中,供业务层调用。业务层代码位于internal/authzserver/authorization目录下。 + +总结 + +这一讲中,我介绍了IAM数据流服务iam-authz-server的设计和实现。iam-authz-server提供了 /v1/authz RESTful API接口,供第三方用户完成资源授权功能,具体是使用Ladon包来完成资源授权的。Ladon包解决了“在特定的条件下,谁能够/不能够对哪些资源做哪些操作”的问题。 + +iam-authz-server的配置处理、启动流程和请求处理流程跟iam-apiserver保持一致。此外,iam-authz-server也实现了简洁架构。 + +iam-authz-server通过缓存密钥和策略信息、缓存授权日志来提高 /v1/authz 接口的性能。 + +在缓存密钥和策略信息时,为了和iam-apiserver中的密钥和策略信息保持一致,使用了Redis Pub/Sub机制。当iam-apiserver有密钥/策略变更时,会往指定的Redis channel Pub一条消息。iam-authz-server订阅相同的channel,在收到新消息时,会解析消息,并重新从iam-apiserver中获取密钥和策略信息,缓存在内存中。 + +iam-authz-server执行完资源授权之后,会将授权日志存放在一个带缓冲的channel中。后端有多个worker消费channel中的数据,并进行批量投递。可以设置批量投递的条件,例如最大投递日志数和最大投递时间间隔。 + +课后练习 + + +iam-authz-server和iam-apiserver共用了应用框架(包括一些配置项)和HTTP服务框架层的代码,请阅读iam-authz-server代码,看下IAM项目是如何实现代码复用的。 +iam-authz-server使用了ristretto来缓存密钥和策略信息,请调研下业界还有哪些优秀的缓存包可供使用,欢迎在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/32数据处理:如何高效处理应用程序产生的数据?.md b/专栏/Go语言项目开发实战/32数据处理:如何高效处理应用程序产生的数据?.md new file mode 100644 index 0000000..a645a17 --- /dev/null +++ b/专栏/Go语言项目开发实战/32数据处理:如何高效处理应用程序产生的数据?.md @@ -0,0 +1,537 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 32 数据处理:如何高效处理应用程序产生的数据? + 你好,我是孔令飞。今天我们来聊聊,如何更好地进行异步数据处理。 + +一个大型应用为了后期的排障、运营等,会将一些请求数据保存在存储系统中,供日后使用。例如:应用将请求日志保存到 Elasticsearch 中,方便排障;网关将 API 请求次数、请求消息体等数据保存在数据库中,供控制台查询展示。 + +为了满足这些需求,我们需要进行数据采集,数据采集在大型应用中很常见,但我发现不少开发者设计的数据采集服务,通常会存在下面这些问题: + + +采集服务只针对某个采集需求开发,如果采集需求有变,需要修改主代码逻辑,代码改动势必会带来潜在的 Bug,增加开发测试工作量。 +数据采集服务会导致已有的服务请求延时变高。 +采集数据性能差,需要较长时间才能采集完一批数据。 +启停服务时,会导致采集的数据丢失。 + + +这一讲,我就来详细教你如何设计和落地一个数据采集服务,解决上面这些问题。 + +数据采集方式的分类 + +首先,你需要知道当前数据采集有哪些方式,以便更好地理解异步数据处理方案。 + +目前,数据采集主要有两种方式,分别是同步采集和异步采集。二者的概念和优缺点如下表所示: + + + +现代应用对性能的要求越来越高,而异步采集对应用程序的性能影响更小,因此异步采集更受开发者欢迎,得到了大规模的应用。接下来,我要介绍的 IAM Pump Server 服务,采用的就是异步采集的方式。 + +数据采集系统设计 + +这一讲,我采用理论+实战的方式来展示如何设计一个数据采集服务,这里先来介绍下关于数据采集的理论知识,后面会有具体的实战案例。 + +在过往的项目开发中,我发现很多开发人员添加了数据采集功能后,因为同步上报数据、单线程、上报逻辑不对等原因,让整个应用程序的性能受到了严重影响。那么,如何在采集过程中不影响程序的性能? + +答案就是让数据采集模型化。通过模型化,可以使设计出来的采集系统功能更加通用,能够满足未来的很多同类需求,我们也就不需要重复开发相同的系统了。 + +我今天就来给你详细介绍下,如何将数据采集功能模型化,以及该模型是如何解决上面说的的各种问题的。 + +设计数据采集系统时需要解决的核心问题 + +采集系统首先需要一个数据源 Input,Input 可以是一个或者多个,Input 中的数据来自于应用程序上报。采集后的数据通常需要经过处理,比如格式化、增删字段、过滤无用的数据等,然后将处理后的数据存储到下游系统(Output)中,如下图所示: + + + +这里,我们需要解决这 3 个核心问题: + + +进行数据采集,就需要在正常流程中多加一个上报数据环节,这势必会影响程序的性能。那么,如何让程序的性能损失最小化? +如果 Input 产生数据的速度大于 Output 的消费能力,产生数据堆积怎么办? +数据采集后需要存储到下游系统。在存储之前,我们需要对数据进行不同的处理,并可能会存储到不同的下游系统,这种可变的需求如何满足? + + +对于让程序性能损失最小化这一点,最好的方法是异步上报。如果是异步,我们需要先把数据缓存在内存中,然后再异步上报到目标系统中。当然,为了提高上报的效率,可以采用批量上报的方式。 + +对于数据堆积这个问题,比较好的解决方法是,将采集的数据先上报到一些具有高吞吐量、可以存储大量数据的中间组件,比如 Kafka、Redis 中。这种方式也是业界标准的处理方式。 + +对于采集需求多样化这个问题,我们可以将采集程序做成插件化、可扩展的,满足可变的需求。 + +要解决这 3 个问题,其实就涉及到了数据采集系统中的两个功能点的设计,它们分别是数据上报功能和数据采集功能。接下来我们就来看下,如何设计这两个功能点。 + +数据上报功能设计 + +为了提高异步上报的吞吐量,你可以将数据缓存在内存中(Go 中可以使用有缓冲 channel),并使用多个 worker 去消费内存中的数据。使用多个 worker ,可以充分发挥 CPU 的多核能力。另外,上报给下游系统时,你也可以采用批量上报的方式。 + +数据采集功能设计 + +现代应用程序越来越讲究插件化、扩展性,在设计采集系统时,也应该考虑到未来的需求。比如,未来你可能需要将数据从上报到 MongoDB 切换到 HBase 中,或者同时将数据上报到 MongoDB 和 HBase 中。因此,上报给下游的程序逻辑要具有插件化的能力,并能通过配置选择需要的插件。 + +为了提高程序性能,会先把数据缓存在内存中。但是这样有个缺点:在关停程序时,内存中的数据就会丢失。所以,在程序结束之前,我们需要确保内存中的数据能够上报成功,也就是说采集程序需要实现优雅关停功能。优雅关停不仅要确保缓存中的数据被成功上报,还要确保正在处理的数据被成功上报。 + +当然了,既然是数据采集,还要能够配置采集的频率。最后,因为采集程序通常是非 API 类型的,所以还需要对外暴露一个特殊的 API,用来返回采集程序的健康状态。 + +数据采集应用模型 + +通过上面的分析和设计,可以绘制出下面这个采集模型: + + + +异步上报需要额外的异步逻辑,会增加开发工作量和程序复杂度,所以,对于一些 Input 数据生产速度小于 Output 消费速度,并且 Output 具有高吞吐量、低延时特性的场景,也可以采用同步上报,例如同步上报给 Redis。 + +数据采集系统落地项目:iam-authz-server + iam-pump + +上面,我介绍了数据采集系统的架构,但是只有模型和理论,肯定还不足以解决你对数据采集程序的开发需求。所以,接下来我来介绍下如何落地上面的数据采集架构。整个架构包括两个部分,分别由不同的服务实现: + + +iam-authz-server:实现数据上报功能。 +iam-pump:实现数据采集功能。 + + +整个采集系统的架构,跟上面描述的数据采集架构完全一致,这里就不重复说明了。 + +iam-authz-server:数据上报 + +数据上报的最大难点,就是如何减少上报逻辑对应用性能的影响。对此,我们主要的解决思路就是异步上报数据。 + +接下来我会介绍 iam-authz-server 的数据上报设计。这是一个非常成熟的设计,在我所开发和了解的项目中被大量采用,有些项目可以承载十亿级/天的请求量。通过介绍这个设计,我们来看看异步上报的具体方法,以及上报过程中要考虑的因素。 + +iam-authz-server 的数据上报架构如下图所示: + + + +iam-authz-server 服务中的数据上报功能可以选择性开启,开启代码见 internal/authzserver/server.go ,代码如下: + + if s.analyticsOptions.Enable { + analyticsStore := storage.RedisCluster{KeyPrefix: RedisKeyPrefix} + analyticsIns := analytics.NewAnalytics(s.analyticsOptions, &analyticsStore) + analyticsIns.Start() + s.gs.AddShutdownCallback(shutdown.ShutdownFunc(func(string) error { + analyticsIns.Stop() + + return nil + })) + } + + +上面的代码中,当 s.analyticsOptions.Enable 为 true 时,开启数据上报功能。因为数据上报会影响程序的性能,而且在未来可能会存在禁掉数据上报功能的场景,所以在设计 iam-authz-server 时,就把数据上报功能做成了可配置的,也就是说可以通过配置文件来启用/禁用数据上报功能。配置方式也很简单:将 iam-authz-server.yaml 的 analytics.enable 设置为 true,代表开启数据上报功能;设置为 false ,则代表关闭数据上报功能。 + +这里,我建议你在设计程序时,将未来的可能变量考虑进去,并将这些变量做成可配置的。这样,如果哪天需求变化,我们就能通过修改配置文件,而不是修改代码的方式来满足需求。这种方式可以将应用程序的变动局限在配置文件中,从而大大减小现网服务出现故障的概率,做到只变更配置文件就可以缩短发布变更的周期。 + +在上面的代码中,通过 NewAnalytics 创建一个数据上报服务,代码如下: + +func NewAnalytics(options *AnalyticsOptions, store storage.AnalyticsHandler) *Analytics { + ps := options.PoolSize + recordsBufferSize := options.RecordsBufferSize + workerBufferSize := recordsBufferSize / uint64(ps) + log.Debug("Analytics pool worker buffer size", log.Uint64("workerBufferSize", workerBufferSize)) + + recordsChan := make(chan *AnalyticsRecord, recordsBufferSize) + + return &Analytics{ + store: store, + poolSize: ps, + recordsChan: recordsChan, + workerBufferSize: workerBufferSize, + recordsBufferFlushInterval: options.FlushInterval, + } +} + + +这里的代码根据传入的参数,创建 Analytics 类型的变量并返回,变量中有 5 个字段需要你关注: + + +store: storage.AnalyticsHandler 接口类型,提供了 Connect() bool和 AppendToSetPipelined(string, byte)函数,分别用来连接 storage 和上报数据给 storage。iam-authz-server 用了 redis storage。 +recordsChan:授权日志会缓存在 recordsChan 带缓冲 channel 中,其长度可以通过 iam-authz-server.yaml 配置文件中的 analytics.records-buffer-size 配置。 +poolSize:指定开启 worker 的个数,也就是开启多少个 Go 协程来消费 recordsChan 中的消息。 +workerBufferSize:批量投递给下游系统的的消息数。通过批量投递,可以进一步提高消费能力、减少 CPU 消耗。 +recordsBufferFlushInterval:设置最迟多久投递一次,也就是投递数据的超时时间。 + + +analytics.ecords-buffer-size 和 analytics.pool-size 建议根据部署机器的 CPU 和内存来配置。在应用真正上线前,我建议你通过压力和负载测试,来配置一个合适的值。 + +Analytics提供了 3 种方法: + + +Start(),用来启动数据上报服务。 +Stop(),用来关停数据上报服务。主程序在收到系统的终止命令后,调用 Stop 方法优雅关停数据上报服务,确保缓存中的数据都上报成功。 +RecordHit(record *AnalyticsRecord) error,用来记录 AnalyticsRecord 的数据。 + + +通过 NewXxx (NewAnalytics)返回一个 Xxx (Analytics)类型的结构体,Xxx(Analytics) 类型带有一些方法,如下: + +func NewAnalytics(options) *Analytics { + ... +} + +func (r *Analytics) Start() { + ... +} +func (r *Analytics) Stop() { + ... +} +func (r *Analytics) RecordHit(record *AnalyticsRecord) error { + ... +} + + +其实,上述代码段是一种常见的 Go 代码编写方式/设计模式。你在以后的开发生涯中,会经常遇到这种设计方式。使用上述代码设计方式有下面两个好处。 + + +功能模块化:将数据上报的功能封装成一个服务模块,数据和方法都围绕着 Xxx 结构体来展开。这和 C++、Java、Python 的类有相似的地方,你可以这么理解:Xxx 相当于类,NewXxx 相当于初始化一个类实例,Start、Stop、RecordHit 是这个类提供的方法。功能模块化可以使程序逻辑更加清晰,功能更独立、更好维护,也可以供其他应用使用。 +方便数据传递:可以将数据存放在 Xxx 结构体字段中,供不同的方法共享使用,如果有并发,数据共享时,注意要给非并发安全的类型加锁,例如recordsChan。 + + +接下来,我会介绍 iam-authz-server 服务中跟数据上报相关的 3 部分核心代码,分别是启动数据上报服务、异步上报授权日志和优雅关停数据上报。 + +启动服务:启动数据上报服务 + +在服务启动时,首先要启动数据上报功能模块。我们通过调用 analyticsIns.Start() 启动数据上报服务。Start 代码如下: + +func (r *Analytics) Start() { + analytics = r + r.store.Connect() + + // start worker pool + atomic.SwapUint32(&r.shouldStop, 0) + for i := 0; i < r.poolSize; i++ { + r.poolWg.Add(1) + go r.recordWorker() + } + + // stop analytics workers + go r.Stop() +} + + +这里有一点需要你注意,数据上报和数据采集都大量应用了 Go 协程来并发地执行操作,为了防止潜在的并发读写引起的Bug,建议你的测试程序编译时加上 -race,例如go build -race cmd/iam-authz-server/authzserver.go。然后,在测试过程中,观察程序日志,看有无并发问题出现。 + +Start 中会开启 poolSize 个数的 worker 协程,这些协程共同消费 recordsChan 中的消息,消费逻辑见 recordWorker() ,代码如下: + +func (r *Analytics) recordWorker() { + defer r.poolWg.Done() + + // this is buffer to send one pipelined command to redis + // use r.recordsBufferSize as cap to reduce slice re-allocations + recordsBuffer := make([][]byte, 0, r.workerBufferSize) + + // read records from channel and process + lastSentTS := time.Now() + for { + readyToSend := false + select { + case record, ok := <-r.recordsChan: + // check if channel was closed and it is time to exit from worker + if !ok { + // send what is left in buffer + r.store.AppendToSetPipelined(analyticsKeyName, recordsBuffer) + return + } + + // we have new record - prepare it and add to buffer + + if encoded, err := msgpack.Marshal(record); err != nil { + log.Errorf("Error encoding analytics data: %s", err.Error()) + } else { + recordsBuffer = append(recordsBuffer, encoded) + } + + // identify that buffer is ready to be sent + readyToSend = uint64(len(recordsBuffer)) == r.workerBufferSize + + case <-time.After(r.recordsBufferFlushInterval): + // nothing was received for that period of time + // anyways send whatever we have, don't hold data too long in buffer + readyToSend = true + } + + // send data to Redis and reset buffer + if len(recordsBuffer) > 0 && (readyToSend || time.Since(lastSentTS) >= recordsBufferForcedFlushInterval) { + r.store.AppendToSetPipelined(analyticsKeyName, recordsBuffer) + recordsBuffer = recordsBuffer[:0] + lastSentTS = time.Now() + } + } +} + + +recordWorker 函数会将接收到的授权日志保存在 recordsBuffer 切片中,当数组内元素个数为 workerBufferSize ,或者距离上一次投递时间间隔为 recordsBufferFlushInterval 时,就会将 recordsBuffer 数组中的数据上报给目标系统(Input)。- +recordWorker()中有些设计技巧,很值得你参考。 + + +使用 msgpack 序列化消息:msgpack 是一个高效的二进制序列化格式。它像 JSON 一样,让你可以在各种语言之间交换数据。但是它比 JSON 更快、更小。 +支持 Batch Windows:当 worker 的消息数达到指定阈值时,会批量投递消息给 Redis,阈值判断代码为readyToSend = uint64(len(recordsBuffer)) == r.workerBufferSize。 +超时投递:为了避免因为产生消息太慢,一直达不到 Batch Windows,无法投递消息这种情况,投递逻辑也支持超时投递,通过 case <-time.After(r.recordsBufferFlushInterval)代码段实现。 +支持优雅关停:当 recordsChan 关闭时,将 recordsBuffer 中的消息批量投递给 Redis,之后退出 worker 协程。 + + +这里有个注意事项:投递完成后,你需要重置 recordsBuffer 和计时器,否则会重复投递数据: + +recordsBuffer = recordsBuffer[:0] +lastSentTS = time.Now() + + +这里还设置了一个最大的超时时间 recordsBufferForcedFlushInterval,确保消息最迟被投递的时间间隔。也就是说, iam-authz-server 强制要求最大投递间隔为 recordsBufferForcedFlushInterval 秒,这是为了防止配置文件将 recordsBufferFlushInterval 设得过大。 + +运行服务:异步上报授权日志 + +开启了数据上报服务后,当有授权日志产生时,程序就会自动上报数据。接下来,我会详细介绍下如何高效上报数据。 + +iam-authz-server 会在授权成功时调用 LogGrantedAccessRequest 函数,在授权失败时调用 LogRejectedAccessRequest 函数。并且,在这两个函数中,调用 RecordHit 函数,记录授权日志。 + +iam-authz-server 通过调用 RecordHit(record *AnalyticsRecord) error 函数,异步缓存授权日志。调用 RecordHit 后,会将 AnalyticsRecord 类型的消息存放到 recordsChan 有缓冲 channel 中。 + +这里要注意:在缓存前,需要判断上报服务是否在优雅关停中,如果在关停中,则丢弃该消息: + +if atomic.LoadUint32(&r.shouldStop) > 0 { + return nil +} + + +通过将授权日志缓写入 recordsChan 有缓冲 channel 中,LogGrantedAccessRequest 和 LogRejectedAccessRequest 函数可以不用等待授权日志上报成功就返回,这样就使得整个授权请求的性能损耗几乎为零。 + +关停服务:优雅关停数据上报 + +完成数据上报之后的下一步,就是要优雅地将数据上报关停。为了确保在应用关停时,缓存中的数据和正在投递中的数据都能够投递到 Redis,iam-authz-server 实现了数据上报关停功能,代码如下: + +gs.AddShutdownCallback(shutdown.ShutdownFunc(func(string) error { + analyticsIns.Stop() + return nil +})) + + +当收到 os.Interrupt 和 syscall.SIGTERM 系统信号后,调用 analyticsIns.Stop()函数,关停数据上报服务, Stop 函数会停止接收新的授权日志,并等待正在上报的数据上报完成。 + +上面我介绍了数据上报部分的功能设计,接下来,我来介绍下数据采集部分的功能设计。 + +iam-pump:数据采集 + +iam-authz-server 将数据上报到 Redis,iam-pump 消费 Redis 中的数据,并保存在 MongoDB 中做持久化存储。 + +iam-pump 的设计要点是:插件化、可配置地将 Redis 中的数据处理后存储到下游系统中,并且实现优雅关停功能,这些也是设计数据采集程序的要点和难点所在。下面,我们就来看下 iam-pump 是如何插件化地实现一个数据采集程序的。这个数据采集程序的设计思路,在我开发的大型企业应用中有实际的落地验证,你可以放心使用。 + +iam-pump 数据采集架构如下图所示: + + + +在iam-pump服务启动时,要启动数据采集功能,启动代码见 internal/pump/server.go。 + +接下来,我会介绍下 iam-pump 服务中的 5 部分核心代码: + + +数据采集插件定义。 +初始化数据采集插件。 +健康检查。 +启动 Loop 周期性消费 Redis 数据。 +优雅关停数据采集服务。 + + +初始化服务:数据采集插件定义 + +数据采集组件设计的核心是插件化,这里我将需要上报的系统抽象成一个个的 pump,那么如何定义 pump 接口呢?接口定义需要参考实际的采集需求,通常来说,至少需要下面这几个函数。 + + +New:创建一个 pump。 +Init:初始化一个 pump,例如,可以在 Init 中创建下游系统的网络连接。 +WriteData:往下游系统写入数据。为了提高性能,最好支持批量写入。 +SetFilters:设置是否过滤某条数据,这也是一个非常常见的需求,因为不是所有的数据都是需要的。 +SetTimeout:设置超时时间。我就在开发过程中遇到过一个坑,连接 Kafka 超时,导致整个采集程序超时。所以这里需要有超时处理,通过超时处理,可以保证整个采集框架正常运行。 + + +我之前开发过公有云的网关服务,网关服务需要把网关的请求数据转存到 MongoDB 中。我们的网关服务曾经遇到一个比较大的坑:有些用户会通过网关上传非常大的文件(百 M 级别),这些数据转存到 MongoDB 中,快速消耗了 MongoDB 的存储空间(500G 存储空间)。为了避免这个问题,在转存数据时,需要过滤掉一些比较详细的数据,所以 iam-pump 添加了 SetOmitDetailedRecording 来过滤掉详细的数据。 + +所以,最后 iam-pump 的插件接口定义为 internal/pump/pumps/pump.go : + +type Pump interface { + GetName() string + New() Pump + Init(interface{}) error + WriteData(context.Context, []interface{}) error + SetFilters(analytics.AnalyticsFilters) + GetFilters() analytics.AnalyticsFilters + SetTimeout(timeout int) + GetTimeout() int + SetOmitDetailedRecording(bool) + GetOmitDetailedRecording() bool +} + + +你在实际开发中,如果有更多的需求,可以在 Pump interface 定义中继续添加需要的处理函数。 + +初始化服务:初始化数据采集插件 + +定义好插件之后,需要初始化插件。在 initialize 函数中初始化 pumps: + +func (s *pumpServer) initialize() { + pmps = make([]pumps.Pump, len(s.pumps)) + i := 0 + for key, pmp := range s.pumps { + pumpTypeName := pmp.Type + if pumpTypeName == "" { + pumpTypeName = key + } + + pmpType, err := pumps.GetPumpByName(pumpTypeName) + if err != nil { + log.Errorf("Pump load error (skipping): %s", err.Error()) + } else { + pmpIns := pmpType.New() + initErr := pmpIns.Init(pmp.Meta) + if initErr != nil { + log.Errorf("Pump init error (skipping): %s", initErr.Error()) + } else { + log.Infof("Init Pump: %s", pmpIns.GetName()) + pmpIns.SetFilters(pmp.Filters) + pmpIns.SetTimeout(pmp.Timeout) + pmpIns.SetOmitDetailedRecording(pmp.OmitDetailedRecording) + pmps[i] = pmpIns + } + } + i++ + } +} + + +initialize 会创建、初始化,并调用 SetFilters、SetTimeout、SetOmitDetailedRecording 来设置这些 pump。Filters、Timeout、OmitDetailedRecording 等信息在 pump 的配置文件中指定。 + +这里有个技巧你也可以注意下:pump 配置文件支持通用的配置,也支持自定义的配置,配置结构为 PumpConfig : + +type PumpConfig struct { + Type string + Filters analytics.AnalyticsFilters + Timeout int + OmitDetailedRecording bool + Meta map[string]interface{} +} + + +pump 自定义的配置可以存放在 map 类型的变量 Meta 中。通用配置可以使配置共享,减少开发和维护工作量,自定义配置可以适配不同pump的差异化配置。 + +初始化服务:健康检查 + +因为 iam-pump 是一个非 API 服务,为了监控其运行状态,这里也设置了一个健康检查接口。iam-pump 组件通过调用 server.ServeHealthCheck 函数启动一个 HTTP 服务,ServeHealthCheck 函数代码如下: + +func ServeHealthCheck(healthPath string, healthAddress string) { + http.HandleFunc("/"+healthPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status": "ok"}`)) + }) + + if err := http.ListenAndServe(healthAddress, nil); err != nil { + log.Fatalf("Error serving health check endpoint: %s", err.Error()) + } +} + + +该函数启动了一个 HTTP 服务,服务监听地址通过 health-check-address 配置,健康检查路径通过 health-check-path 配置。如果请求 http:///返回{"status": "ok"},说明 iam-pump 可以正常工作。 + +这里的健康检查只是简单返回了一个字符串,实际开发中,可以封装更复杂的逻辑。比如,检查进程是否可以成功 ping 通数据库,进程内的工作进程是否处于 worker 状态等。 + +iam-pump 默认的健康检查请求地址为http://127.0.0.1:7070/healthz 。 + +运行服务:启动 Loop 周期性消费 Redis 数据 + +初始化 pumps 之后,就可以通过 Run 函数启动消费逻辑了。在 Run 函数中,会定期(通过配置 purge-delay 设置轮训时间)从 Redis 中获取所有数据,经过 msgpack.Unmarshal 解压后,传给 writeToPumps 处理: + +func (s preparedPumpServer) Run(stopCh <-chan struct{}) error { + ticker := time.NewTicker(time.Duration(s.secInterval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + analyticsValues := s.analyticsStore.GetAndDeleteSet(storage.AnalyticsKeyName) + if len(analyticsValues) > 0 { + // Convert to something clean + keys := make([]interface{}, len(analyticsValues)) + + for i, v := range analyticsValues { + decoded := analytics.AnalyticsRecord{} + err := msgpack.Unmarshal([]byte(v.(string)), &decoded) + log.Debugf("Decoded Record: %v", decoded) + if err != nil { + log.Errorf("Couldn't unmarshal analytics data: %s", err.Error()) + } else { + if s.omitDetails { + decoded.Policies = "" + decoded.Deciders = "" + } + keys[i] = interface{}(decoded) + } + } + + // Send to pumps + writeToPumps(keys, s.secInterval) + } + // exit consumption cycle when receive SIGINT and SIGTERM signal + case <-stopCh: + log.Info("stop purge loop") + + return nil + } + } +} + + +writeToPumps 函数通过调用 execPumpWriting 函数,异步调用 pump 的 WriteData 函数写入数据。execPumpWriting 函数中有一些设计技巧,你可以注意下这两个: + + +将一些通用的处理,例如 Filters、Timeout、OmitDetailedRecording 放在 pump 之外处理,这样可以减少 pump 中代码的重复性。 +优雅关停。通过如下代码实现优雅关停功能: + + +select { + case <-stopCh: + log.Info("stop purge loop") + return + default: +} + + +上面的代码需要放在 writeToPumps 之后,这样可以确保所有数据都成功写入 pumps 之后,再停止采集逻辑。 + +关停服务:优雅关停数据采集服务 + +在关停服务时,为了确保正在处理的数据被成功存储,还需要提供优雅关停功能。iam-pump 通过 channel 传递 SIGINT 和 SIGTERM 信号,当消费逻辑收到这两个信号后,会退出消费循环,见 Run 函数。代码如下: + +func (s preparedPumpServer) Run(stopCh <-chan struct{}) error { + ticker := time.NewTicker(time.Duration(s.secInterval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // 消费逻辑 + ... + // exit consumption cycle when receive SIGINT and SIGTERM signal + case <-stopCh: + log.Info("stop purge loop") + + return nil + } + } +} + + + +总结 + +这一讲,我主要介绍了如何将数据采集需求转化成一个数据采集模型,并从这个模型出发,设计出一个可扩展、高性能的数据采集服务,并通过 iam-pump 组件来落地该采集模型。 + +最后,我还想给你一个建议:在开发中,你也可以将一些功能抽象成一些通用的模型,并为该模型实现基本框架(引擎),然后将一些需要定制化的部分插件化。通过这种方式,可以设计出一个高扩展的服务,使得服务不仅能够满足现在的需求,还能够满足未来的需求。 + +课后练习 + + +思考下,如何设计一个数据上报和数据采集应用,设计时有哪些点需要注意? +动手练习下,启动 iam-authz-server 和 iam-pump 服务,验证整个流程。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/33SDK设计(上):如何设计出一个优秀的GoSDK?.md b/专栏/Go语言项目开发实战/33SDK设计(上):如何设计出一个优秀的GoSDK?.md new file mode 100644 index 0000000..0be3f7f --- /dev/null +++ b/专栏/Go语言项目开发实战/33SDK设计(上):如何设计出一个优秀的GoSDK?.md @@ -0,0 +1,404 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 33 SDK 设计(上):如何设计出一个优秀的 Go SDK? + 33 SDK 设计(上):如何设计出一个优秀的 Go SDK? + +你好,我是孔令飞。接下来的两讲,我们来看下如何设计和实现一个优秀的Go SDK。 + +后端服务通过API接口对外提供应用的功能,但是用户直接调用API接口,需要编写API接口调用的逻辑,并且需要构造入参和解析返回的数据包,使用起来效率低,而且有一定的开发工作量。 + +在实际的项目开发中,通常会提供对开发者更友好的SDK包,供客户端调用。很多大型服务在发布时都会伴随着SDK的发布,例如腾讯云很多产品都提供了SDK: + + + +既然SDK如此重要,那么如何设计一个优秀的Go SDK呢?这一讲我就来详细介绍一下。 + +什么是SDK? + +首先,我们来看下什么是SDK。 + +对于SDK(Software Development Kit,软件开发工具包),不同场景下有不同的解释。但是对于一个Go后端服务来说,SDK通常是指封装了Go后端服务API接口的软件包,里面通常包含了跟软件相关的库、文档、使用示例、封装好的API接口和工具。 + +调用SDK跟调用本地函数没有太大的区别,所以可以极大地提升开发者的开发效率和体验。SDK可以由服务提供者提供,也可以由其他组织或个人提供。为了鼓励开发者使用其系统或语言,SDK通常都是免费提供的。 + +通常,服务提供者会提供不同语言的SDK,比如针对Python开发者会提供Python版的SDK,针对Go开发者会提供Go版的SDK。一些比较专业的团队还会有SDK自动生成工具,可以根据API接口定义,自动生成不同语言的SDK。例如,Protocol Buffers的编译工具protoc,就可以基于Protobuf文件生成C++、Python、Java、JavaScript、PHP等语言版本的SDK。阿里云、腾讯云这些一线大厂,也可以基于API定义,生成不同编程语言的SDK。 + +SDK设计方法 + +那么,我们如何才能设计一个好的SDK呢?对于SDK,不同团队会有不同的设计方式,我调研了一些优秀SDK的实现,发现这些SDK有一些共同点。根据我的调研结果,结合我在实际开发中的经验,我总结出了一套SDK设计方法,接下来就分享给你。 + +如何给SDK命名? + +在讲设计方法之前,我先来介绍两个重要的知识点:SDK的命名方式和SDK的目录结构。 + +SDK的名字目前没有统一的规范,但比较常见的命名方式是 xxx-sdk-go / xxx-sdk-python / xxx-sdk-java 。其中, xxx 可以是项目名或者组织名,例如腾讯云在GitHub上的组织名为tencentcloud,那它的SDK命名如下图所示: + + + +SDK的目录结构 + +不同项目SDK的目录结构也不相同,但一般需要包含下面这些文件或目录。目录名可能会有所不同,但目录功能是类似的。 + + +README.md:SDK的帮助文档,里面包含了安装、配置和使用SDK的方法。 +examples/sample/:SDK的使用示例。 +sdk/:SDK共享的包,里面封装了最基础的通信功能。如果是HTTP服务,基本都是基于 net/http 包进行封装。 +api:如果 xxx-sdk-go 只是为某一个服务提供SDK,就可以把该服务的所有API接口封装代码存放在api目录下。 +services/{iam, tms} :如果 xxx-sdk-go 中, xxx 是一个组织,那么这个SDK很可能会集成该组织中很多服务的API,就可以把某类服务的API接口封装代码存放在 services/<服务名>下,例如AWS的Go SDK。 + + +一个典型的目录结构如下: + +├── examples # 示例代码存放目录 +│ └── authz.go +├── README.md # SDK使用文档 +├── sdk # 公共包,封装了SDK配置、API请求、认证等代码 +│ ├── client.go +│ ├── config.go +│ ├── credential.go +│ └── ... +└── services # API封装 + ├── common + │ └── model + ├── iam # iam服务的API接口 + │ ├── authz.go + │ ├── client.go + │ └── ... + └── tms # tms服务的API接口 + + +SDK设计方法 + +SDK的设计方法如下图所示: + + + +我们可以通过Config配置创建客户端Client,例如 func NewClient(config sdk.Config) (Client, error),配置中可以指定下面的信息。 + + +服务的后端地址:服务的后端地址可以通过配置文件来配置,也可以直接固化在SDK中,推荐后端服务地址可通过配置文件配置。 +认证信息:最常用的认证方式是通过密钥认证,也有一些是通过用户名和密码认证。 +其他配置:例如超时时间、重试次数、缓存时间等。 + + +创建的Client是一个结构体或者Go interface。这里我建议你使用interface类型,这样可以将定义和具体实现解耦。Client具有一些方法,例如 CreateUser、DeleteUser等,每一个方法对应一个API接口,下面是一个Client定义: + +type Client struct { + client *sdk.Request +} + +func (c *Client) CreateUser(req *CreateUserRequest) (*CreateUserResponse, error) { + // normal code + resp := &CreateUserResponse{} + err := c.client.Send(req, resp) + return resp, err +} + + +调用 client.CreateUser(req) 会执行HTTP请求,在 req 中可以指定HTTP请求的方法Method、路径Path和请求Body。 CreateUser 函数中,会调用 c.client.Send(req) 执行具体的HTTP请求。 + +c.client 是 *Request 类型的变量, *Request 类型的变量具有一些方法,可以根据传入的请求参数 req 和 config 配置构造出请求路径、认证头和请求Body,并调用 net/http 包完成最终的HTTP请求,最后将返回结果Unmarshal到传入的 resp 结构体中。 + +根据我的调研,目前有两种SDK设计方式可供参考,一种是各大公有云厂商采用的SDK设计方式,一种是Kubernetes client-go的设计方式。IAM项目分别实现了这两种SDK设计方式,但我还是更倾向于对外提供client-go方式的SDK,我会在下一讲详细介绍它。这两种设计方式的设计思路跟上面介绍的是一致的。 + +公有云厂商采用的SDK设计方式 + +这里,我先来简单介绍下公有云厂商采用的SDK设计模式。SDK架构如下图所示: + + + +SDK框架分为两层,分别是API层和基础层。API层主要用来构建客户端实例,并调用客户端实例提供的方法来完成API请求,每一个方法对应一个API接口。API层最终会调用基础层提供的能力,来完成REST API请求。基础层通过依次执行构建请求参数(Builder)、签发并添加认证头(Signer)、执行HTTP请求(Request)三大步骤,来完成具体的REST API请求。 + +为了让你更好地理解公有云SDK的设计方式,接下来我会结合一些真实的代码,给你讲解API层和基础层的具体设计,SDK代码见medu-sdk-go。 + +API层:创建客户端实例 + +客户端在使用服务A的SDK时,首先需要根据Config配置创建一个服务A的客户端Client,Client实际上是一个struct,定义如下: + +type Client struct { + sdk.Client +} + + +在创建客户端时,需要传入认证(例如密钥、用户名/密码)、后端服务地址等配置信息。例如,可以通过NewClientWithSecret方法来构建一个带密钥对的客户端: + +func NewClientWithSecret(secretID, secretKey string) (client *Client, err error) { + client = &Client{} + config := sdk.NewConfig().WithEndpoint(defaultEndpoint) + client.Init(serviceName).WithSecret(secretID, secretKey).WithConfig(config) + return +} + + +这里要注意,上面创建客户端时,传入的密钥对最终会在基础层中被使用,用来签发JWT Token。 + +Client有多个方法(Sender),例如 Authz等,每个方法代表一个API接口。Sender方法会接收AuthzRequest等结构体类型的指针作为输入参数。我们可以调用 client.Authz(req) 来执行REST API调用。可以在 client.Authz 方法中添加一些业务逻辑处理。client.Authz 代码如下: + +type AuthzRequest struct { + *request.BaseRequest + Resource *string `json:"resource"` + Action *string `json:"action"` + Subject *string `json:"subject"` + Context *ladon.Context +} + +func (c *Client) Authz(req *AuthzRequest) (resp *AuthzResponse, err error) { + if req == nil { + req = NewAuthzRequest() + } + + resp = NewAuthzResponse() + err = c.Send(req, resp) + return +} + + +请求结构体中的字段都是指针类型的,使用指针的好处是可以判断入参是否有被指定,如果req.Subject == nil 就说明传参中没有Subject参数,如果req.Subject != nil就说明参数中有传Subject参数。根据某个参数是否被传入,执行不同的业务逻辑,这在Go API接口开发中非常常见。 + +另外,因为Client通过匿名的方式继承了基础层中的Client: + +type Client struct { + sdk.Client +} + + +所以,API层创建的Client最终可以直接调用基础层中的Client提供的Send(req, resp) 方法,来执行RESTful API调用,并将结果保存在 resp 中。 + +为了方便和API层的Client进行区分,我下面统一将基础层中的Client称为sdk.Client。 + +最后,一个完整的客户端调用示例代码如下: + +package main + +import ( + "fmt" + + "github.com/ory/ladon" + + "github.com/marmotedu/medu-sdk-go/sdk" + iam "github.com/marmotedu/medu-sdk-go/services/iam/authz" +) + +func main() { + client, _ := iam.NewClientWithSecret("XhbY3aCrfjdYcP1OFJRu9xcno8JzSbUIvGE2", "bfJRvlFwsoW9L30DlG87BBW0arJamSeK") + + req := iam.NewAuthzRequest() + req.Resource = sdk.String("resources:articles:ladon-introduction") + req.Action = sdk.String("delete") + req.Subject = sdk.String("users:peter") + ctx := ladon.Context(map[string]interface{}{"remoteIPAddress": "192.168.0.5"}) + req.Context = &ctx + + resp, err := client.Authz(req) + if err != nil { + fmt.Println("err1", err) + return + } + fmt.Printf("get response body: `%s`\n", resp.String()) + fmt.Printf("allowed: %v\n", resp.Allowed) +} + + +基础层:构建并执行HTTP请求 + +上面我们创建了客户端实例,并调用了它的 Send 方法来完成最终的HTTP请求。这里,我们来看下Send方法具体是如何构建HTTP请求的。 + +sdk.Client通过Send方法,完成最终的API调用,代码如下: + +func (c *Client) Send(req request.Request, resp response.Response) error { + method := req.GetMethod() + builder := GetParameterBuilder(method, c.Logger) + jsonReq, _ := json.Marshal(req) + encodedUrl, err := builder.BuildURL(req.GetURL(), jsonReq) + if err != nil { + return err + } + + endPoint := c.Config.Endpoint + if endPoint == "" { + endPoint = fmt.Sprintf("%s/%s", defaultEndpoint, c.ServiceName) + } + reqUrl := fmt.Sprintf("%s://%s/%s%s", c.Config.Scheme, endPoint, req.GetVersion(), encodedUrl) + + body, err := builder.BuildBody(jsonReq) + if err != nil { + return err + } + + sign := func(r *http.Request) error { + signer := NewSigner(c.signMethod, c.Credential, c.Logger) + _ = signer.Sign(c.ServiceName, r, strings.NewReader(body)) + return err + } + + rawResponse, err := c.doSend(method, reqUrl, body, req.GetHeaders(), sign) + if err != nil { + return err + } + + return response.ParseFromHttpResponse(rawResponse, resp) +} + + +上面的代码大体上可以分为四个步骤。 + +第一步,Builder:构建请求参数。 + +根据传入的AuthzRequest和客户端配置Config,构造HTTP请求参数,包括请求路径和请求Body。 + +接下来,我们来看下如何构造HTTP请求参数。 + + +HTTP请求路径构建 + + +在创建客户端时,我们通过NewAuthzRequest函数创建了 /v1/authz REST API接口请求结构体AuthzRequest,代码如下: + +func NewAuthzRequest() (req *AuthzRequest) { + req = &AuthzRequest{ + BaseRequest: &request.BaseRequest{ + URL: "/authz", + Method: "POST", + Header: nil, + Version: "v1", + }, + } + return +} + + +可以看到,我们创建的 req 中包含了API版本(Version)、API路径(URL)和请求方法(Method)。这样,我们就可以在Send方法中,构建出请求路径: + +endPoint := c.Config.Endpoint +if endPoint == "" { + endPoint = fmt.Sprintf("%s/%s", defaultEndpoint, c.ServiceName) +} +reqUrl := fmt.Sprintf("%s://%s/%s%s", c.Config.Scheme, endPoint, req.GetVersion(), encodedUrl) + + +上述代码中,c.Config.Scheme=http/https、endPoint=iam.api.marmotedu.com:8080、req.GetVersion()=v1和encodedUrl,我们可以认为它们等于/authz。所以,最终构建出的请求路径为http://iam.api.marmotedu.com:8080/v1/authz 。 + + +HTTP请求Body构建 + + +在BuildBody方法中构建请求Body。BuildBody会将 req Marshal成JSON格式的string。HTTP请求会以该字符串作为Body参数。 + +第二步,Signer:签发并添加认证头。 + +访问IAM的API接口需要进行认证,所以在发送HTTP请求之前,还需要给HTTP请求添加认证Header。 + +medu-sdk-go 代码提供了JWT和HMAC两种认证方式,最终采用了JWT认证方式。JWT认证签发方法为Sign,代码如下: + +func (v1 SignatureV1) Sign(serviceName string, r *http.Request, body io.ReadSeeker) http.Header { + tokenString := auth.Sign(v1.Credentials.SecretID, v1.Credentials.SecretKey, "medu-sdk-go", serviceName+".marmotedu.com") + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tokenString)) + return r.Header + +} + + +auth.Sign 方法根据SecretID和SecretKey签发JWT Token。 + +接下来,我们就可以调用doSend方法来执行HTTP请求了。调用代码如下: + +rawResponse, err := c.doSend(method, reqUrl, body, req.GetHeaders(), sign) +if err != nil { + return err +} + + +可以看到,我们传入了HTTP请求方法 method 、HTTP请求URL reqUrl 、HTTP请求Body body,以及用来签发JWT Token的 sign 方法。我们在调用 NewAuthzRequest 创建 req 时,指定了HTTP Method,所以这里的 method := req.GetMethod() 、reqUrl和请求Body都是通过Builder来构建的。 + +第三步,Request:执行HTTP请求。 + +调用doSend方法执行HTTP请求,doSend通过调用 net/http 包提供的 http.NewRequest 方法来发送HTTP请求,执行完HTTP请求后,会返回 *http.Response 类型的Response。代码如下: + +func (c *Client) doSend(method, url, data string, header map[string]string, sign SignFunc) (*http.Response, error) { + client := &http.Client{Timeout: c.Config.Timeout} + + req, err := http.NewRequest(method, url, strings.NewReader(data)) + if err != nil { + c.Logger.Errorf("%s", err.Error()) + return nil, err + } + + c.setHeader(req, header) + + err = sign(req) + if err != nil { + return nil, err + } + + return client.Do(req) +} + + +第四步,处理HTTP请求返回结果。 + +调用doSend方法返回 *http.Response 类型的Response后,Send方法会调用ParseFromHttpResponse函数来处理HTTP Response,ParseFromHttpResponse函数代码如下: + +func ParseFromHttpResponse(rawResponse *http.Response, response Response) error { + defer rawResponse.Body.Close() + body, err := ioutil.ReadAll(rawResponse.Body) + if err != nil { + return err + } + if rawResponse.StatusCode != 200 { + return fmt.Errorf("request fail with status: %s, with body: %s", rawResponse.Status, body) + } + + if err := response.ParseErrorFromHTTPResponse(body); err != nil { + return err + } + + return json.Unmarshal(body, &response) +} + + +可以看到,在ParseFromHttpResponse函数中,会先判断HTTP Response中的StatusCode是否为200,如果不是200,则会报错。如果是200,会调用传入的resp变量提供的ParseErrorFromHTTPResponse方法,来将HTTP Response的Body Unmarshal到resp变量中。- +通过以上四步,SDK调用方调用了API,并获得了API的返回结果 resp 。 + +下面这些公有云厂商的SDK采用了此设计模式: + + +腾讯云SDK:tencentcloud-sdk-go。 +AWS SDK:aws-sdk-go。 +阿里云SDK:alibaba-cloud-sdk-go。 +京东云SDK:jdcloud-sdk-go。 +Ucloud SDK:ucloud-sdk-go。 + + +IAM公有云方式的SDK实现为 medu-sdk-go。 + +此外,IAM还设计并实现了Kubernetes client-go方式的Go SDK:marmotedu-sdk-go,marmotedu-sdk-go也是IAM Go SDK所采用的SDK。下一讲中,我会具体介绍marmotedu-sdk-go的设计和实现。 + +总结 + +这一讲,我主要介绍了如何设计一个优秀的Go SDK。通过提供SDK,可以提高API调用效率,减少API调用难度,所以大型应用通常都会提供SDK。不同团队有不同的SDK设计方法,但目前比较好的实现是公有云厂商采用的SDK设计方式。 + +公有云厂商的SDK设计方式中,SDK按调用顺序从上到下可以分为3个模块,如下图所示: + + + +Client构造SDK客户端,在构造客户端时,会创建请求参数 req , req 中会指定API版本、HTTP请求方法、API请求路径等信息。 + +Client会请求Builder和Signer来构建HTTP请求的各项参数:HTTP请求方法、HTTP请求路径、HTTP认证头、HTTP请求Body。Builder和Signer是根据 req 配置来构造这些HTTP请求参数的。 + +构造完成之后,会请求Request模块,Request模块通过调用 net/http 包,来执行HTTP请求,并返回请求结果。 + +课后练习 + + +思考下,如何实现可以支持多个API版本的SDK包,代码如何实现? +这一讲介绍了一种SDK实现方式,在你的Go开发生涯中,还有没有一些更好的SDK实现方法?欢迎在留言区分享。 + + +期待你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/34SDK设计(下):IAM项目GoSDK设计和实现.md b/专栏/Go语言项目开发实战/34SDK设计(下):IAM项目GoSDK设计和实现.md new file mode 100644 index 0000000..1a22651 --- /dev/null +++ b/专栏/Go语言项目开发实战/34SDK设计(下):IAM项目GoSDK设计和实现.md @@ -0,0 +1,644 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 34 SDK 设计(下):IAM项目Go SDK设计和实现 + 你好,我是孔令飞。 + +上一讲,我介绍了公有云厂商普遍采用的SDK设计方式。其实,还有一些比较优秀的SDK设计方式,比如 Kubernetes的 client-go SDK设计方式。IAM项目参考client-go,也实现了client-go风格的SDK:marmotedu-sdk-go。 + +和 33讲 介绍的SDK设计方式相比,client-go风格的SDK具有以下优点: + + +大量使用了Go interface特性,将接口的定义和实现解耦,可以支持多种实现方式。 +接口调用层级跟资源的层级相匹配,调用方式更加友好。 +多版本共存。 + + +所以,我更推荐你使用marmotedu-sdk-go。接下来,我们就来看下marmotedu-sdk-go是如何设计和实现的。 + +marmotedu-sdk-go设计 + +和medu-sdk-go相比,marmotedu-sdk-go的设计和实现要复杂一些,但功能更强大,使用体验也更好。 + +这里,我们先来看一个使用SDK调用iam-authz-server /v1/authz 接口的示例,代码保存在 marmotedu-sdk-go/examples/authz_clientset/main.go文件中: + +package main + +import ( + "context" + "flag" + "fmt" + "path/filepath" + + "github.com/ory/ladon" + + metav1 "github.com/marmotedu/component-base/pkg/meta/v1" + "github.com/marmotedu/component-base/pkg/util/homedir" + + "github.com/marmotedu/marmotedu-sdk-go/marmotedu" + "github.com/marmotedu/marmotedu-sdk-go/tools/clientcmd" +) + +func main() { + var iamconfig *string + if home := homedir.HomeDir(); home != "" { + iamconfig = flag.String( + "iamconfig", + filepath.Join(home, ".iam", "config"), + "(optional) absolute path to the iamconfig file", + ) + } else { + iamconfig = flag.String("iamconfig", "", "absolute path to the iamconfig file") + } + flag.Parse() + + // use the current context in iamconfig + config, err := clientcmd.BuildConfigFromFlags("", *iamconfig) + if err != nil { + panic(err.Error()) + } + + // create the clientset + clientset, err := marmotedu.NewForConfig(config) + if err != nil { + panic(err.Error()) + } + + request := &ladon.Request{ + Resource: "resources:articles:ladon-introduction", + Action: "delete", + Subject: "users:peter", + Context: ladon.Context{ + "remoteIP": "192.168.0.5", + }, + } + + // Authorize the request + fmt.Println("Authorize request...") + ret, err := clientset.Iam().AuthzV1().Authz().Authorize(context.TODO(), request, metav1.AuthorizeOptions{}) + if err != nil { + panic(err.Error()) + } + + fmt.Printf("Authorize response: %s.\n", ret.ToString()) +} + + +在上面的代码示例中,包含了下面的操作。 + + +首先,调用 BuildConfigFromFlags 函数,创建出SDK的配置实例config; +接着,调用 marmotedu.NewForConfig(config) 创建了IAM项目的客户端 clientset ; +最后,调用以下代码请求 /v1/authz 接口执行资源授权请求: + + +ret, err := clientset.Iam().AuthzV1().Authz().Authorize(context.TODO(), request, metav1.AuthorizeOptions{}) +if err != nil { + panic(err.Error()) +} + +fmt.Printf("Authorize response: %s.\n", ret.ToString()) + + +调用格式为项目客户端.应用客户端.服务客户端.资源名.接口 。 + +所以,上面的代码通过创建项目级别的客户端、应用级别的客户端和服务级别的客户端,来调用资源的API接口。接下来,我们来看下如何创建这些客户端。 + +marmotedu-sdk-go客户端设计 + +在讲客户端创建之前,我们先来看下客户端的设计思路。 + +Go项目的组织方式是有层级的:Project -> Application -> Service。marmotedu-sdk-go很好地体现了这种层级关系,使得SDK的调用更加易懂、易用。marmotedu-sdk-go的层级关系如下图所示: + + + +marmotedu-sdk-go定义了3类接口,分别代表了项目、应用和服务级别的API接口: + +// 项目级别的接口 +type Interface interface { + Iam() iam.IamInterface + Tms() tms.TmsInterface +} + +// 应用级别的接口 +type IamInterface interface { + APIV1() apiv1.APIV1Interface + AuthzV1() authzv1.AuthzV1Interface +} + +// 服务级别的接口 +type APIV1Interface interface { + RESTClient() rest.Interface + SecretsGetter + UsersGetter + PoliciesGetter +} + +// 资源级别的客户端 +type SecretsGetter interface { + Secrets() SecretInterface +} + +// 资源的接口定义 +type SecretInterface interface { + Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) (*v1.Secret, error) + Update(ctx context.Context, secret *v1.Secret, opts metav1.UpdateOptions) (*v1.Secret, error) + Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error + Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1.Secret, error) + List(ctx context.Context, opts metav1.ListOptions) (*v1.SecretList, error) + SecretExpansion +} + + +Interface 代表了项目级别的接口,里面包含了 Iam 和 Tms 两个应用; IamInterface 代表了应用级别的接口,里面包含了api(iam-apiserver)和authz(iam-authz-server)两个服务级别的接口。api和authz服务中,又包含了各自服务中REST资源的CURD接口。 + +marmotedu-sdk-go通过 XxxV1 这种命名方式来支持不同版本的API接口,好处是可以在程序中同时调用同一个API接口的不同版本,例如: + +clientset.Iam().AuthzV1().Authz().Authorize() 、clientset.Iam().AuthzV2().Authz().Authorize() 分别调用了 /v1/authz 和 /v2/authz 两个版本的API接口。 + +上述关系也可以从目录结构中反映出来,marmotedu-sdk-go目录设计如下(只列出了一些重要的文件): + +├── examples # 存放SDK的使用示例 +├── Makefile # 管理SDK源码,静态代码检查、代码格式化、测试、添加版权信息等 +├── marmotedu +│ ├── clientset.go # clientset实现,clientset中包含多个应用,多个服务的API接口 +│ ├── fake # clientset的fake实现,主要用于单元测试 +│ └── service # 按应用进行分类,存放应用中各服务API接口的具体实现 +│ ├── iam # iam应用的API接口实现,包含多个服务 +│ │ ├── apiserver # iam应用中,apiserver服务的API接口,包含多个版本 +│ │ │ └── v1 # apiserver v1版本API接口 +│ │ ├── authz # iam应用中,authz服务的API接口 +│ │ │ └── v1 # authz服务v1版本接口 +│ │ └── iam_client.go # iam应用的客户端,包含了apiserver和authz 2个服务的客户端 +│ └── tms # tms应用的API接口实现 +├── pkg # 存放一些共享包,可对外暴露 +├── rest # HTTP请求的底层实现 +├── third_party # 存放修改过的第三方包,例如:gorequest +└── tools + └── clientcmd # 一些函数用来帮助创建rest.Config配置 + + +每种类型的客户端,都可以通过以下相似的方式来创建: + +config, err := clientcmd.BuildConfigFromFlags("", "/root/.iam/config") +clientset, err := xxxx.NewForConfig(config) + + +/root/.iam/config 为配置文件,里面包含了服务的地址和认证信息。BuildConfigFromFlags 函数加载配置文件,创建并返回 rest.Config 类型的配置变量,并通过 xxxx.NewForConfig 函数创建需要的客户端。xxxx 是所在层级的client包,例如 iam、tms。 + +marmotedu-sdk-go客户端定义了3类接口,这可以带来两个好处。 + +第一,API接口调用格式规范,层次清晰,可以使API接口调用更加清晰易记。 + +第二,可以根据需要,自行选择客户端类型,调用灵活。举个例子,在A服务中需要同时用到iam-apiserver 和 iam-authz-server提供的接口,就可以创建应用级别的客户端IamClient,然后通过 iamclient.APIV1() 和 iamclient.AuthzV1() ,来切换调用不同服务的API接口。 + +接下来,我们来看看如何创建三个不同级别的客户端。 + +项目级别客户端创建 + +Interface 对应的客户端实现为Clientset,所在的包为 marmotedu-sdk-go/marmotedu,Clientset客户端的创建方式为: + +config, err := clientcmd.BuildConfigFromFlags("", "/root/.iam/config") +clientset, err := marmotedu.NewForConfig(config) + + +调用方式为 clientset.应用.服务.资源名.接口 ,例如: + +rsp, err := clientset.Iam().AuthzV1().Authz().Authorize() + + +参考示例为 marmotedu-sdk-go/examples/authz_clientset/main.go。 + +应用级别客户端创建 + +IamInterface 对应的客户端实现为IamClient,所在的包为 marmotedu-sdk-go/marmotedu/service/iam,IamClient客户端的创建方式为: + +config, err := clientcmd.BuildConfigFromFlags("", "/root/.iam/config") +iamclient,, err := iam.NewForConfig(config) + + +调用方式为 iamclient.服务.资源名.接口 ,例如: + +rsp, err := iamclient.AuthzV1().Authz().Authorize() + + +参考示例为 marmotedu-sdk-go/examples/authz_iam/main.go。 + +服务级别客户端创建 + +AuthzV1Interface 对应的客户端实现为AuthzV1Client,所在的包为 marmotedu-sdk-go/marmotedu/service/iam/authz/v1,AuthzV1Client客户端的创建方式为: + +config, err := clientcmd.BuildConfigFromFlags("", "/root/.iam/config") +client, err := v1.NewForConfig(config) + + +调用方式为 client.资源名.接口 ,例如: + +rsp, err := client.Authz().Authorize() + + +参考示例为 marmotedu-sdk-go/examples/authz/main.go。 + +上面我介绍了marmotedu-sdk-go的客户端创建方法,接下来我们再来看下,这些客户端具体是如何执行REST API请求的。 + +marmotedu-sdk-go的实现 + +marmotedu-sdk-go的实现和medu-sdk-go一样,也是采用分层结构,分为API层和基础层。如下图所示: + + + +RESTClient是整个SDK的核心,RESTClient向下通过调用Request模块,来完成HTTP请求方法、请求路径、请求体、认证信息的构建。Request模块最终通过调用gorequest包提供的方法,完成HTTP的POST、PUT、GET、DELETE等请求,获取HTTP返回结果,并解析到指定的结构体中。RESTClient向上提供 Post() 、 Put() 、 Get() 、 Delete() 等方法来供客户端完成HTTP请求。 + +marmotedu-sdk-go提供了两类客户端,分别是RESTClient客户端和基于RESTClient封装的客户端。 + + +RESTClient:Raw类型的客户端,可以通过指定HTTP的请求方法、请求路径、请求参数等信息,直接发送HTTP请求,例如 client.Get().AbsPath("/version").Do().Into() 。 +基于RESTClient封装的客户端:例如AuthzV1Client、APIV1Client等,执行特定REST资源、特定API接口的请求,方便开发者调用。 + + +接下来,我们具体看下如何创建RESTClient客户端,以及Request模块的实现。 + +RESTClient客户端实现 + +我通过下面两个步骤,实现了RESTClient客户端。 + +第一步,创建rest.Config类型的变量。 + +BuildConfigFromFlags函数通过加载yaml格式的配置文件,来创建 rest.Config 类型的变量,加载的yaml格式配置文件内容为: + +apiVersion: v1 +user: + #token: # JWT Token + username: admin # iam 用户名 + password: Admin@2020 # iam 密码 + #secret-id: # 密钥 ID + #secret-key: # 密钥 Key + client-certificate: /home/colin/.iam/cert/admin.pem # 用于 TLS 的客户端证书文件路径 + client-key: /home/colin/.iam/cert/admin-key.pem # 用于 TLS 的客户端 key 文件路径 + #client-certificate-data: + #client-key-data: + +server: + address: https://127.0.0.1:8443 # iam api-server 地址 + timeout: 10s # 请求 api-server 超时时间 + #max-retries: # 最大重试次数,默认为 0 + #retry-interval: # 重试间隔,默认为 1s + #tls-server-name: # TLS 服务器名称 + #insecure-skip-tls-verify: # 设置为 true 表示跳过 TLS 安全验证模式,将使得 HTTPS 连接不安全 + certificate-authority: /home/colin/.iam/cert/ca.pem # 用于 CA 授权的 cert 文件路径 + #certificate-authority-data: + + +在配置文件中,我们可以指定服务的地址、用户名/密码、密钥、TLS证书、超时时间、重试次数等信息。 + +创建方法如下: + +config, err := clientcmd.BuildConfigFromFlags("", *iamconfig) +if err != nil { + panic(err.Error()) +} + + +这里的代码中,*iamconfig 是yaml格式的配置文件路径。BuildConfigFromFlags 函数中,调用LoadFromFile函数来解析yaml配置文件。LoadFromFile最终是通过 yaml.Unmarshal 的方式来解析yaml格式的配置文件的。 + +第二步,根据rest.Config类型的变量,创建RESTClient客户端。 + +通过RESTClientFor函数来创建RESTClient客户端: + +func RESTClientFor(config *Config) (*RESTClient, error) { + ... + baseURL, versionedAPIPath, err := defaultServerURLFor(config) + if err != nil { + return nil, err + } + + // Get the TLS options for this client config + tlsConfig, err := TLSConfigFor(config) + if err != nil { + return nil, err + } + + // Only retry when get a server side error. + client := gorequest.New().TLSClientConfig(tlsConfig).Timeout(config.Timeout). + Retry(config.MaxRetries, config.RetryInterval, http.StatusInternalServerError) + // NOTICE: must set DoNotClearSuperAgent to true, or the client will clean header befor http.Do + client.DoNotClearSuperAgent = true + + ... + + clientContent := ClientContentConfig{ + Username: config.Username, + Password: config.Password, + SecretID: config.SecretID, + SecretKey: config.SecretKey, + ... + } + + return NewRESTClient(baseURL, versionedAPIPath, clientContent, client) +} + + +RESTClientFor函数调用defaultServerURLFor(config)生成基本的HTTP请求路径:baseURL=http://127.0.0.1:8080,versionedAPIPath=/v1。然后,通过[TLSConfigFor](https://github.com/marmotedu/marmotedu-sdk-go/blob/v1.0.2/rest/config.go#L241-L298)函数生成TLS配置,并调用 gorequest.New() 创建gorequest客户端,将客户端配置信息保存在变量中。最后,调用NewRESTClient函数创建RESTClient客户端。 + +RESTClient客户端提供了以下方法,来供调用者完成HTTP请求: + +func (c *RESTClient) APIVersion() scheme.GroupVersion +func (c *RESTClient) Delete() *Request +func (c *RESTClient) Get() *Request +func (c *RESTClient) Post() *Request +func (c *RESTClient) Put() *Request +func (c *RESTClient) Verb(verb string) *Request + + +可以看到,RESTClient提供了 Delete 、 Get 、 Post 、 Put 方法,分别用来执行HTTP的DELETE、GET、POST、PUT方法,提供的 Verb 方法可以灵活地指定HTTP方法。这些方法都返回了 Request 类型的变量。Request 类型的变量提供了一些方法,用来完成具体的HTTP请求,例如: + + type Response struct { + Allowed bool `json:"allowed"` + Denied bool `json:"denied,omitempty"` + Reason string `json:"reason,omitempty"` + Error string `json:"error,omitempty"` +} + +func (c *authz) Authorize(ctx context.Context, request *ladon.Request, opts metav1.AuthorizeOptions) (result *Response, err error) { + result = &Response{} + err = c.client.Post(). + Resource("authz"). + VersionedParams(opts). + Body(request). + Do(ctx). + Into(result) + + return +} + + +上面的代码中, c.client 是RESTClient客户端,通过调用RESTClient客户端的 Post 方法,返回了 *Request 类型的变量。 + +*Request 类型的变量提供了 Resource 和 VersionedParams 方法,来构建请求HTTP URL中的路径 /v1/authz ;通过 Body 方法,指定了HTTP请求的Body。 + +到这里,我们分别构建了HTTP请求需要的参数:HTTP Method、请求URL、请求Body。所以,之后就可以调用 Do 方法来执行HTTP请求,并将返回结果通过 Into 方法保存在传入的result变量中。 + +Request模块实现 + +RESTClient客户端的方法会返回Request类型的变量,Request类型的变量提供了一系列的方法用来构建HTTP请求参数,并执行HTTP请求。 + +所以,Request模块可以理解为最底层的通信层,我们来看下Request模块具体是如何完成HTTP请求的。 + +我们先来看下Request结构体的定义: + +type RESTClient struct { + // base is the root URL for all invocations of the client + base *url.URL + // group stand for the client group, eg: iam.api, iam.authz + group string + // versionedAPIPath is a path segment connecting the base URL to the resource root + versionedAPIPath string + // content describes how a RESTClient encodes and decodes responses. + content ClientContentConfig + Client *gorequest.SuperAgent +} + +type Request struct { + c *RESTClient + + timeout time.Duration + + // generic components accessible via method setters + verb string + pathPrefix string + subpath string + params url.Values + headers http.Header + + // structural elements of the request that are part of the IAM API conventions + // namespace string + // namespaceSet bool + resource string + resourceName string + subresource string + + // output + err error + body interface{} +} + + +再来看下Request结构体提供的方法: + +func (r *Request) AbsPath(segments ...string) *Request +func (r *Request) Body(obj interface{}) *Request +func (r *Request) Do(ctx context.Context) Result +func (r *Request) Name(resourceName string) *Request +func (r *Request) Param(paramName, s string) *Request +func (r *Request) Prefix(segments ...string) *Request +func (r *Request) RequestURI(uri string) *Request +func (r *Request) Resource(resource string) *Request +func (r *Request) SetHeader(key string, values ...string) *Request +func (r *Request) SubResource(subresources ...string) *Request +func (r *Request) Suffix(segments ...string) *Request +func (r *Request) Timeout(d time.Duration) *Request +func (r *Request) URL() *url.URL +func (r *Request) Verb(verb string) *Request +func (r *Request) VersionedParams(v interface{}) *Request + + +通过Request结构体的定义和使用方法,我们不难猜测出:Request模块通过 Name 、 Resource 、 Body 、 SetHeader 等方法来设置Request结构体中的各个字段。这些字段最终用来构建出一个HTTP请求,并通过 Do 方法来执行HTTP请求。 + +那么,如何构建并执行一个HTTP请求呢?我们可以通过以下5步,来构建并执行HTTP请求: + + +构建HTTP URL; +构建HTTP Method; +构建HTTP Body; +执行HTTP请求; +保存HTTP返回结果。 + + +接下来,我们就来具体看下Request模块是如何构建这些请求参数,并发送HTTP请求的。 + +第一步,构建HTTP URL。 + +首先,通过defaultServerURLFor函数返回了http://iam.api.marmotedu.com:8080 和 /v1 ,并将二者分别保存在了Request类型结构体变量中 c 字段的 base 字段和 versionedAPIPath 字段中。 + +通过 Do 方法执行HTTP时,会调用r.URL()方法来构建请求URL。 r.URL 方法中,通过以下代码段构建了HTTP请求URL: + +func (r *Request) URL() *url.URL { + p := r.pathPrefix + if len(r.resource) != 0 { + p = path.Join(p, strings.ToLower(r.resource)) + } + + if len(r.resourceName) != 0 || len(r.subpath) != 0 || len(r.subresource) != 0 { + p = path.Join(p, r.resourceName, r.subresource, r.subpath) + } + + finalURL := &url.URL{} + if r.c.base != nil { + *finalURL = *r.c.bas + } + + finalURL.Path = p + ... +} + + +p := r.pathPrefix 和 r.c.base ,是通过 defaultServerURLFor 调用返回的 v1 和 http://iam.api.marmotedu.com:8080 来构建的。 + +resourceName 通过 func (r *Request) Resource(resource string) *Request 来指定,例如 authz 。 + +所以,最终我们构建的请求URL为 http://iam.api.marmotedu.com:8080/v1/authz 。 + +第二步,构建HTTP Method。 + +HTTP Method通过RESTClient提供的 Post 、Delete 、Get 等方法来设置,例如: + +func (c *RESTClient) Post() *Request { + return c.Verb("POST") +} + +func (c *RESTClient) Verb(verb string) *Request { + return NewRequest(c).Verb(verb) +} + + +NewRequest(c).Verb(verb) 最终设置了Request结构体的 verb 字段,供 Do 方法使用。 + +第三步,构建HTTP Body。 + +HTTP Body通过Request结构体提供的Body方法来指定: + +func (r *Request) Body(obj interface{}) *Request { + if v := reflect.ValueOf(obj); v.Kind() == reflect.Struct { + r.SetHeader("Content-Type", r.c.content.ContentType) + } + + r.body = obj + + return r +} + + +第四步,执行HTTP请求。 + +通过Request结构体提供的Do方法来执行具体的HTTP请求,代码如下: + +func (r *Request) Do(ctx context.Context) Result { + client := r.c.Client + client.Header = r.headers + + if r.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.timeout) + + defer cancel() + } + + client.WithContext(ctx) + + resp, body, errs := client.CustomMethod(r.verb, r.URL().String()).Send(r.body).EndBytes() + if err := combineErr(resp, body, errs); err != nil { + return Result{ + response: &resp, + err: err, + body: body, + } + } + + decoder, err := r.c.content.Negotiator.Decoder() + if err != nil { + return Result{ + response: &resp, + err: err, + body: body, + decoder: decoder, + } + } + + return Result{ + response: &resp, + body: body, + decoder: decoder, + } +} + + +在Do方法中,使用了Request结构体变量中各个字段的值,通过 client.CustomMethod 来执行HTTP请求。 client 是 *gorequest.SuperAgent 类型的客户端。 + +第五步,保存HTTP返回结果。 + +通过Request结构体的 Into 方法来保存HTTP返回结果: + +func (r Result) Into(v interface{}) error { + if r.err != nil { + return r.Error() + } + + if r.decoder == nil { + return fmt.Errorf("serializer doesn't exist") + } + + if err := r.decoder.Decode(r.body, &v); err != nil { + return err + } + + return nil +} + + +r.body 是在Do方法中,执行完HTTP请求后设置的,它的值为HTTP请求返回的Body。 + +请求认证 + +接下来,我再来介绍下marmotedu-sdk-go另外一个比较核心的功能:请求认证。 + +marmotedu-sdk-go支持两种认证方式: + + +Basic认证:通过给请求添加 Authorization: Basic xxxx 来实现。 +Bearer认证:通过给请求添加 Authorization: Bearer xxxx 来实现。这种方式又支持直接指定JWT Token,或者通过指定密钥对由SDK自动生成JWT Token。 + + +Basic认证和Bearer认证,我在 25讲介绍过,你可以返回查看下。 + +认证头是RESTClient客户端发送HTTP请求时指定的,具体实现位于NewRequest函数中: + +switch { + case c.content.HasTokenAuth(): + r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", c.content.BearerToken)) + case c.content.HasKeyAuth(): + tokenString := auth.Sign(c.content.SecretID, c.content.SecretKey, "marmotedu-sdk-go", c.group+".marmotedu.com") + r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", tokenString)) + case c.content.HasBasicAuth(): + // TODO: get token and set header + r.SetHeader("Authorization", "Basic "+basicAuth(c.content.Username, c.content.Password)) +} + + +上面的代码会根据配置信息,自动判断使用哪种认证方式。 + +总结 + +这一讲中,我介绍了Kubernetes client-go风格的SDK实现方式。和公有云厂商的SDK设计相比,client-go风格的SDK设计有很多优点。 + +marmotedu-sdk-go在设计时,通过接口实现了3类客户端,分别是项目级别的客户端、应用级别的客户端和服务级别的客户端。开发人员可以根据需要,自行创建客户端类型。 + +marmotedu-sdk-go通过RESTClientFor,创建了RESTClient类型的客户端,RESTClient向下通过调用Request模块,来完成HTTP请求方法、请求路径、请求体、认证信息的构建。Request模块最终通过调用gorequest包提供的方法,完成HTTP的POST、PUT、GET、DELETE等请求,获取HTTP返回结果,并解析到指定的结构体中。RESTClient向上提供 Post() 、 Put() 、 Get() 、 Delete() 等方法,来供客户端完成HTTP请求。 + +课后练习 + + +阅读defaultServerURLFor源码,思考下defaultServerURLFor是如何构建请求地址 http://iam.api.marmotedu.com:8080 和API版本 /v1 的。 +使用gorequest包,编写一个可以执行以下HTTP请求的示例: + + +curl -XPOST http://example.com/v1/user -d '{"username":"colin","address":"shenzhen"}' + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/35效率神器:如何设计和实现一个命令行客户端工具?.md b/专栏/Go语言项目开发实战/35效率神器:如何设计和实现一个命令行客户端工具?.md new file mode 100644 index 0000000..939356c --- /dev/null +++ b/专栏/Go语言项目开发实战/35效率神器:如何设计和实现一个命令行客户端工具?.md @@ -0,0 +1,448 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 35 效率神器:如何设计和实现一个命令行客户端工具? + 你好,我是孔令飞。今天我们来聊聊,如何实现一个命令行客户端工具。 + +如果你用过Kubernetes、Istio、etcd,那你一定用过这些开源项目所提供的命令行工具:kubectl、istioctl、etcdctl。一个 xxx 项目,伴随着一个 xxxctl 命令行工具,这似乎已经成为一种趋势,在一些大型系统中更是常见。提供 xxxctl 命令行工具有这两个好处: + + +实现自动化:可以通过在脚本中调用 xxxctl 工具,实现自动化。 +提高效率:通过将应用的功能封装成命令和参数,方便运维、开发人员在Linux服务器上调用。 + + +其中,kubectl命令设计的功能最为复杂,也是非常优秀的命令行工具,IAM项目的iamctl客户端工具就是仿照kubectl来实现的。这一讲,我就通过剖析iamctl命令行工具的实现,来介绍下如何实现一个优秀的客户端工具。 + +常见客户端介绍 + +在介绍iamctl命令行工具的实现之前,我们先来看下常见的客户端。 + +客户端又叫用户端,与后端服务相对应,安装在客户机上,用户可以使用这些客户端访问后端服务。不同的客户端面向的人群不同,所能提供的访问能力也有差异。常见的客户端有下面这几种: + + +前端,包括浏览器、手机应用; +SDK; +命令行工具; +其他终端。 + + +接下来,我就来分别介绍下。 + +浏览器和手机应用提供一个交互界面供用户访问后端服务,使用体验最好,面向的人群是最终的用户。这两类客户端也称为前端。前端由前端开发人员进行开发,并通过API接口,调用后端的服务。后端开发人员不需要关注这两类客户端,只需要关注如何提供API接口即可。 + +SDK(Software Development Kit)也是一个客户端,供开发者调用。开发者调用API时,如果是通过HTTP协议,需要编写HTTP的调用代码、HTTP请求包的封装和返回包的解封,还要处理HTTP的状态码,使用起来不是很方便。SDK其实是封装了API接口的一系列函数集合,开发者通过调用SDK中的函数调用API接口,提供SDK主要是方便开发者调用,减少工作量。 + +命令行工具是可以在操作系统上执行的一个二进制程序,提供了一种比SDK和API接口更方便快捷的访问后端服务的途径,供运维或者开发人员在服务器上直接执行使用,或者在自动化脚本中调用。 + +还有其他各类客户端,这里我列举一些常见的。 + + +终端设备:POS机、学习机、智能音箱等。 +第三方应用程序:通过调用API接口或者SDK,调用我们提供的后端服务,从而实现自身的功能。 +脚本:脚本中通过API接口或者命令行工具,调用我们提供的后端服务,实现自动化。 + + +这些其他的各类客户端,都是通过调用API接口使用后端服务的,它们跟前端一样,也不需要后台开发人员开发。 + +需要后台开发人员投入工作量进行研发的客户端是SDK和命令行工具。这两类客户端工具有个调用和被调用的顺序,如下图所示: + + + +你可以看到,命令行工具和SDK最终都是通过API接口调用后端服务的,通过这种方式可以保证服务的一致性,并减少为适配多个客户端所带来的额外开发工作量。 + +大型系统客户端(xxxctl)的特点 + +通过学习kubectl、istioctl、etcdctl这些优秀的命令行工具,可以发现一个大型系统的命令行工具,通常具有下面这些特点: + + +支持命令和子命令,命令/子命名有自己独有的命令行参数。 +支持一些特殊的命令。比如支持completion命令,completion命令可以输出bash/zsh自动补全脚本,实现命令行及参数的自动补全。还支持 version命令,version命令不仅可以输出客户端的版本,还可以输出服务端的版本(如果有需要)。 +支持全局option,全局option可以作为所有命令及子命令的命令行参数。 +支持-h/help,-h/help可以打印xxxctl的帮助信息,例如: + + +$ iamctl -h +iamctl controls the iam platform, is the client side tool for iam platform. + + Find more information at: +https://github.com/marmotedu/iam/blob/master/docs/guide/en-US/cmd/iamctl/iamctl.md + +Basic Commands: + info Print the host information + color Print colors supported by the current terminal + new Generate demo command code + jwt JWT command-line tool + +Identity and Access Management Commands: + user Manage users on iam platform + secret Manage secrets on iam platform + policy Manage authorization policies on iam platform + +Troubleshooting and Debugging Commands: + validate Validate the basic environment for iamctl to run + +Settings Commands: + set Set specific features on objects + completion Output shell completion code for the specified shell (bash or zsh) + +Other Commands: + version Print the client and server version information + +Usage: + iamctl [flags] [options] + +Use "iamctl --help" for more information about a given command. +Use "iamctl options" for a list of global command-line options (applies to all commands). + + + +支持 xxxctl help [command | command subcommand] [command | command subcommand] -h ,打印命令/子命令的帮助信息,格式通常为 命令描述 + 使用方法 。例如: + + +$ istioctl help register +Registers a service instance (e.g. VM) joining the mesh + +Usage: + istioctl register [name1:]port1 [name2:]port2 ... [flags] + + +除此之外,一个大型系统的命令行工具还可以支持一些更高阶的功能,例如:支持命令分组,支持配置文件,支持命令的使用example,等等。 + +在Go生态中,如果我们要找一个符合上面所有特点的命令行工具,那非kubectl莫属。因为我今天要重点讲的iamctl客户端工具,就是仿照它来实现的,所以这里就不展开介绍kubectl了,不过还是建议你认真研究下kubectl的实现。 + +iamctl的核心实现 + +接下来,我就来介绍IAM系统自带的iamctl客户端工具,它是仿照kubectl来实现的,能够满足一个大型系统客户端工具的需求。我会从iamctl的功能、代码结构、命令行选项和配置文件解析4个方面来介绍。 + +iamctl的功能 + +iamctl将命令进行了分类。这里,我也建议你对命令进行分类,因为通过分类,不仅可以协助你理解命令的用途,还能帮你快速定位某类命令。另外,当命令很多时,分类也可以使命令看起来更规整。 + +iamctl实现的命令如下: + + + +更详细的功能,你可以参考 iamctl -h 。我建议你在实现xxxctl工具时,考虑实现下面这几个功能。 + + +API功能:平台具有的API功能,都能通过xxxctl方便地进行调用。 +工具:一些使用IAM系统时有用的功能,比如签发JWT Token。 +version、completion、validate命令。 + + +代码结构 + +iamctl工具的main函数位于iamctl.go文件中。命令的实现存放在internal/iamctl/cmd/cmd.go文件中。iamctl的命令统一存放在internal/iamctl/cmd目录下,每个命令都是一个Go包,包名即为命令名,具体实现存放在 internal/iamctl/cmd/<命令>/<命令>.go 文件中。如果命令有子命令,则子命令的实现存放在 internal/iamctl/cmd/<命令>/<命令>_<子命令>.go 文件中。 + +使用这种代码组织方式,即使是在命令很多的情况下,也能让代码井然有序,方便定位和维护代码。 + +命令行选项 + +添加命令行选项的代码在NewIAMCtlCommand函数中,核心代码为: + +flags := cmds.PersistentFlags() +... +iamConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag().WithDeprecatedSecretFlag() +iamConfigFlags.AddFlags(flags) +matchVersionIAMConfigFlags := cmdutil.NewMatchVersionFlags(iamConfigFlags) +matchVersionIAMConfigFlags.AddFlags(cmds.PersistentFlags()) + + +NewConfigFlags(true) 返回带有默认值的参数,并通过 iamConfigFlags.AddFlags(flags) 添加到cobra的命令行flag中。 + +NewConfigFlags(true) 返回结构体类型的值都是指针类型,这样做的好处是:程序可以判断出是否指定了某个参数,从而可以根据需要添加参数。例如:可以通过 WithDeprecatedPasswordFlag() 和 WithDeprecatedSecretFlag() 添加密码和密钥认证参数。 + +NewMatchVersionFlags 指定是否需要服务端版本和客户端版本一致。如果不一致,在调用服务接口时会报错。 + +配置文件解析 + +iamctl需要连接iam-apiserver,来完成用户、策略和密钥的增删改查,并且需要进行认证。要完成这些功能,需要有比较多的配置项。这些配置项如果每次都在命令行选项指定,会很麻烦,也容易出错。 + +最好的方式是保存到配置文件中,并加载配置文件。加载配置文件的代码位于NewIAMCtlCommand函数中,代码如下: + +_ = viper.BindPFlags(cmds.PersistentFlags()) +cobra.OnInitialize(func() { + genericapiserver.LoadConfig(viper.GetString(genericclioptions.FlagIAMConfig), "iamctl") +}) + + + +iamctl会按以下优先级加载配置文件: + + +命令行参 --iamconfig 指定的配置文件。 +当前目录下的iamctl.yaml文件。 +$HOME/.iam/iamctl.yaml 文件。 + + +这种加载方式具有两个好处。首先是可以手动指定不同的配置文件,这在多环境、多配置下尤为重要。其次是方便使用,可以把配置存放在默认的加载路径中,在执行命令时,就不用再指定 --iamconfig 参数。 + +加载完配置文件之后,就可以通过 viper.Get() 函数来获取配置。例如,iamctl使用了以下 viper.Get 方法: + + + +iamctl中子命令是如何构建的? + +讲完了iamctl命令行工具的核心实现,我们再来看看iamctl命令行工具中,子命令是如何构建的。 + +命令行工具的核心是命令,有很多种方法可以构建一个命令,但还是有一些比较好的构建方法,值得我们去参考。接下来,我来介绍下如何用比较好的方式去构建命令。 + +命令构建 + +命令行工具的核心能力是提供各类命令,来完成不同功能,每个命令构建的方式可以完全不同,但最好能按相同的方式去构建,并抽象成一个模型。如下图所示: + + + +你可以将一个命令行工具提供的命令进行分组。每个分组包含多个命令,每个命令又可以具有多个子命令,子命令和父命令在构建方式上完全一致。 + +每个命令可以按下面的四种方式构建。具体代码你可以参考internal/iamctl/cmd/user/user_update.go。 + + +通过 NewCmdXyz 函数创建命令框架。 NewCmdXyz 函数通过创建一个 cobra.Command 类型的变量来创建命令;通过指定 cobra.Command 结构体类型的Short、Long、Example字段,来指定该命令的使用文档iamctl -h 、详细使用文档iamctl xyz -h 和使用示例。 +通过 cmd.Flags().XxxxVar 来给该命令添加命令行选项。 +为了在不指定命令行参数时,能够按照默认的方式执行命令,可以通过 NewXyzOptions 函数返回一个设置了默认选项的 XyzOptions 类型的变量。 +XyzOptions 选项具有 Complete 、Validate 和 Run 三个方法,分别完成选项补全、选项验证和命令执行。命令的执行逻辑可以在 func (o *XyzOptions) Run(args []string) error 函数中编写。 + + +按相同的方式去构建命令,抽象成一个通用模型,这种方式有下面四个好处。 + + +减少理解成本:理解一个命令的构建方式,就可以理解其他命令的构建方式。 +提高新命令的开发效率:可以复用其他命令的开发框架,新命令只需填写业务逻辑即可。 +自动生成命令:可以按照规定的命令模型,自动生成新的命令。 +易维护:因为所有的命令都来自于同一个命令模型,所以可以保持一致的代码风格,方便后期维护。 + + +自动生成命令 + +上面讲到,自动生成命令模型的好处之一是可以自动生成命令,下面让我们来具体看下。 + +iamctl自带了命令生成工具,下面我们看看生成方法,一共可以分成5步。这里假设生成 xyz 命令。 + +第一步,新建一个 xyz 目录,用来存放 xyz 命令源码: + +$ mkdir internal/iamctl/cmd/xyz + + +第二步,在xyz目录下,使用 iamctl new 命令生成 xyz 命令源码: + +$ cd internal/iamctl/cmd/xyz/ +$ iamctl new xyz +Command file generated: xyz.go + + +第三步,将 xyz 命令添加到root命令中,假设 xyz 属于 Settings Commands 命令分组。 + +在 NewIAMCtlCommand 函数中,找到 Settings Commands 分组,将 NewCmdXyz 追加到Commands数组后面: + + { + Message: "Settings Commands:", + Commands: []*cobra.Command{ + set.NewCmdSet(f, ioStreams), + completion.NewCmdCompletion(ioStreams.Out, ""), + xyz.NewCmdXyz(f, ioStreams), + }, + }, + + +第四步,编译iamctl: + +$ make build BINS=iamctl + + +第五步,测试: + +$ iamctl xyz -h +A longer description that spans multiple lines and likely contains examples and usage of using your command. For +example: + + Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to +quickly create a Cobra application. + +Examples: + # Print all option values for xyz + iamctl xyz marmotedu marmotedupass + +Options: + -b, --bool=false: Bool option. + -i, --int=0: Int option. + --slice=[]: String slice option. + --string='default': String option. + +Usage: + iamctl xyz USERNAME PASSWORD [options] + +Use "iamctl options" for a list of global command-line options (applies to all commands). +$ iamctl xyz marmotedu marmotedupass +The following is option values: +==> --string: default(complete) +==> --slice: [] +==> --int: 0 +==> --bool: false + +The following is args values: +==> username: marmotedu +==> password: marmotedupass + + +你可以看到,经过短短的几步,就添加了一个新的命令 xyz 。 iamctl new 命令不仅可以生成不带子命令的命令,还可以生成带有子命令的命令,生成方式如下: + +$ iamctl new -g xyz +Command file generated: xyz.go +Command file generated: xyz_subcmd1.go +Command file generated: xyz_subcmd2.go + + +命令自动补全 + +cobra会根据注册的命令自动生成补全脚本,可以补全父命令、子命令和选项参数。在bash下,可以按下面的方式配置自动补全功能。 + +第一步,生成自动补全脚本: + +$ iamctl completion bash > ~/.iam/completion.bash.inc + + +第二步,登陆时加载bash,自动补全脚本: + +$ echo "source '$HOME/.iam/completion.bash.inc'" >> $HOME/.bash_profile +$ source $HOME/.bash_profile + + +第三步,测试自动补全功能: + +$ iamctl xy # 按TAB键,自动补全为:iamctl xyz +$ iamctl xyz --b # 按TAB键,自动补全为:iamctl xyz --bool + + +更友好的输出 + +在开发命令时,可以通过一些技巧来提高使用体验。我经常会在输出中打印一些彩色输出,或者将一些输出以表格的形式输出,如下图所示: + + + +这里,使用 github.com/olekukonko/tablewriter 包来实现表格功能,使用 github.com/fatih/color 包来打印带色彩的字符串。具体使用方法,你可以参考internal/iamctl/cmd/validate/validate.go文件。 + +github.com/fatih/color 包可以给字符串标示颜色,字符串和颜色的对应关系可通过 iamctl color 来查看,如下图所示: + + + +iamctl是如何进行API调用的? + +上面我介绍了iamctl命令的构建方式,那么这里我们再来看下iamctl是如何请求服务端API接口的。 + +Go后端服务的功能通常通过API接口来对外暴露,一个后端服务可能供很多个终端使用,比如浏览器、命令行工具、手机等。为了保持功能的一致性,这些终端都会调用同一套API来完成相同的功能,如下图所示: + + + +如果命令行工具需要用到后端服务的功能,也需要通过API调用的方式。理想情况下,Go后端服务对外暴露的所有API功能,都可以通过命令行工具来完成。一个API接口对应一个命令,API接口的参数映射到命令的参数。 + +要调用服务端的API接口,最便捷的方法是通过SDK来调用,对于一些没有实现SDK的接口,也可以直接调用。所以,在命令行工具中,需要支持以下两种调用方式: + + +通过SDK调用服务端 API 接口。 +直接调用服务端的API接口(本专栏是REST API接口)。 + + +iamctl通过cmdutil.NewFactory创建一个 Factory 类型的变量 f , Factory 定义为: + +type Factory interface { + genericclioptions.RESTClientGetter + IAMClientSet() (*marmotedu.Clientset, error) + RESTClient() (*restclient.RESTClient, error) +} + + +将变量 f 传入到命令中,在命令中使用Factory接口提供的 RESTClient() 和 IAMClientSet() 方法,分别返回RESTful API客户端和SDK客户端,从而使用客户端提供的接口函数。代码可参考internal/iamctl/cmd/version/version.go。 + +客户端配置文件 + +如果要创建RESTful API客户端和SDK的客户端,需要调用 f.ToRESTConfig() 函数返回 *github.com/marmotedu/marmotedu-sdk-go/rest.Config 类型的配置变量,然后再基于 rest.Config 类型的配置变量创建客户端。 + +f.ToRESTConfig 函数最终是调用toRawIAMConfigLoader函数来生成配置的,代码如下: + +func (f *ConfigFlags) toRawIAMConfigLoader() clientcmd.ClientConfig { + config := clientcmd.NewConfig() + if err := viper.Unmarshal(&config); err != nil { + panic(err) + } + + return clientcmd.NewClientConfigFromConfig(config) +} + + +toRawIAMConfigLoader 返回 clientcmd.ClientConfig 类型的变量, clientcmd.ClientConfig 类型提供了 ClientConfig 方法,用来返回*rest.Config类型的变量。 + +在 toRawIAMConfigLoader 函数内部,通过 viper.Unmarshal 将viper中存储的配置解析到 clientcmd.Config 类型的结构体变量中。viper中存储的配置,是在cobra命令启动时通过LoadConfig函数加载的,代码如下(位于 NewIAMCtlCommand 函数中): + +cobra.OnInitialize(func() { + genericapiserver.LoadConfig(viper.GetString(genericclioptions.FlagIAMConfig), "config") +}) + + +你可以通过 --config 选项,指定配置文件的路径。 + +SDK调用 + +通过IAMClient返回SDK客户端,代码如下: + +func (f *factoryImpl) IAMClient() (*iam.IamClient, error) { + clientConfig, err := f.ToRESTConfig() + if err != nil { + return nil, err + } + return iam.NewForConfig(clientConfig) +} + + +marmotedu.Clientset 提供了iam-apiserver的所有接口。 + +REST API调用 + +通过RESTClient()返回RESTful API客户端,代码如下: + +func (f *factoryImpl) RESTClient() (*restclient.RESTClient, error) { + clientConfig, err := f.ToRESTConfig() + if err != nil { + return nil, err + } + setIAMDefaults(clientConfig) + return restclient.RESTClientFor(clientConfig) +} + + +可以通过下面的方式访问RESTful API接口: + +serverVersion *version.Info + +client, _ := f.RESTClient() +if err := client.Get().AbsPath("/version").Do(context.TODO()).Into(&serverVersion); err != nil { + return err +} + + +上面的代码请求了iam-apiserver的/version接口,并将返回结果保存在 serverVersion 变量中。 + +总结 + +这一讲,我主要剖析了iamctl命令行工具的实现,进而向你介绍了如何实现一个优秀的客户端工具。 + +对于一个大型系统 xxx 来说,通常需要有一个 xxxctl 命令行工具, xxxctl 命令行工具可以方便开发、运维使用系统功能,并能实现功能自动化。 + +IAM项目参考kubectl,实现了命令行工具 iamctl。iamctl集成了很多功能,我们可以通过iamctl子命令来使用这些功能。例如,我们可以通过iamctl对用户、密钥和策略进行CURD操作;可以设置iamctl自动补全脚本;可以查看IAM系统的版本信息。甚至,你还可以使用 iamctl new 命令,快速创建一个iamctl子命令模板。 + +iamctl使用了cobra、pflag、viper包来构建,每个子命令又包含了一些基本的功能,例如短描述、长描述、使用示例、命令行选项、选项校验等。iamctl命令可以加载不同的配置文件,来连接不同的客户端。iamctl通过SDK调用、REST API调用两种方式来调用服务端API接口。 + +课后练习 + + +尝试在 iamctl 中添加一个 cliprint 子命令,该子命令会读取并打印命令行选项。 +思考下,还有哪些好的命令行工具构建方式,欢迎在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/36代码测试(上):如何编写Go语言单元测试和性能测试用例?.md b/专栏/Go语言项目开发实战/36代码测试(上):如何编写Go语言单元测试和性能测试用例?.md new file mode 100644 index 0000000..040dc20 --- /dev/null +++ b/专栏/Go语言项目开发实战/36代码测试(上):如何编写Go语言单元测试和性能测试用例?.md @@ -0,0 +1,505 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 36 代码测试(上):如何编写 Go 语言单元测试和性能测试用例? + 你好,我是孔令飞。 + +从今天开始,我们就进入了服务测试模块,这一模块主要介绍如何测试我们的Go项目。 + +在Go项目开发中,我们不仅要开发功能,更重要的是确保这些功能稳定可靠,并且拥有一个不错的性能。要确保这些,就要对代码进行测试。开发人员通常会进行单元测试和性能测试,分别用来测试代码的功能是否正常和代码的性能是否满足需求。 + +每种语言通常都有自己的测试包/模块,Go语言也不例外。在Go中,我们可以通过testing包对代码进行单元测试和性能测试。这一讲,我会用一些示例来讲解如何编写单元测试和性能测试用例,下一讲则会介绍如何编写其他的测试类型,并介绍 IAM 项目的测试用例。 + +如何测试 Go 代码? + +Go语言有自带的测试框架testing,可以用来实现单元测试(T类型)和性能测试(B类型),通过go test命令来执行单元测试和性能测试。 + +go test 执行测试用例时,是以go包为单位进行测试的。执行时需要指定包名,比如go test 包名,如果没有指定包名,默认会选择执行命令时所在的包。go test在执行时,会遍历以_test.go结尾的源码文件,执行其中以Test、Benchmark、Example开头的测试函数。 + +为了演示如何编写测试用例,我预先编写了4个函数。假设这些函数保存在test目录下的math.go文件中,包名为test,math.go代码如下: + +package test + +import ( + "fmt" + "math" + "math/rand" +) + +// Abs returns the absolute value of x. +func Abs(x float64) float64 { + return math.Abs(x) +} + +// Max returns the larger of x or y. +func Max(x, y float64) float64 { + return math.Max(x, y) +} + +// Min returns the smaller of x or y. +func Min(x, y float64) float64 { + return math.Min(x, y) +} + +// RandInt returns a non-negative pseudo-random int from the default Source. +func RandInt() int { + return rand.Int() +} + + +在这一讲后面的内容中,我会演示如何编写测试用例,来对这些函数进行单元测试和性能测试。下面让我们先来看下测试命名规范。 + +测试命名规范 + +在我们对Go代码进行测试时,需要编写测试文件、测试函数、测试变量,它们都需要遵循一定的规范。这些规范有些来自于官方,有些则来自于社区。这里,我分别来介绍下测试文件、包、测试函数和测试变量的命名规范。 + +测试文件的命名规范 + +Go的测试文件名必须以_test.go结尾。例如,如果我们有一个名为person.go的文件,那它的测试文件必须命名为person_test.go。这样做是因为,Go需要区分哪些文件是测试文件。这些测试文件可以被go test命令行工具加载,用来测试我们编写的代码,但会被Go的构建程序忽略掉,因为Go程序的运行不需要这些测试代码。 + +包的命名规范 + +Go的测试可以分为白盒测试和黑盒测试。 + + +白盒测试:将测试和生产代码放在同一个Go包中,这使我们可以同时测试Go包中可导出和不可导出的标识符。当我们编写的单元测试需要访问Go包中不可导出的变量、函数和方法时,就需要编写白盒测试用例。 +黑盒测试:将测试和生产代码放在不同的Go包中。这时,我们仅可以测试Go包的可导出标识符。这意味着我们的测试包将无法访问生产代码中的任何内部函数、变量或常量。 + + +在白盒测试中,Go的测试包名称需要跟被测试的包名保持一致,例如:person.go定义了一个person包,则person_test.go的包名也要为person,这也意味着person.go和person_test.go都要在同一个目录中。 + +在黑盒测试中,Go的测试包名称需要跟被测试的包名不同,但仍然可以存放在同一个目录下。比如,person.go定义了一个person包,则person_test.go的包名需要跟person不同,通常我们命名为person_test。 + +如果不是需要使用黑盒测试,我们在做单元测试时要尽量使用白盒测试。一方面,这是go test工具的默认行为;另一方面,使用白盒测试,我们可以测试和使用不可导出的标识符。 + +测试文件和包的命名规范,由Go语言及go test工具来强制约束。 + +函数的命名规范 + +测试用例函数必须以Test、Benchmark、Example开头,例如TestXxx、BenchmarkXxx、ExampleXxx,Xxx部分为任意字母数字的组合,首字母大写。这是由Go语言和go test工具来进行约束的,Xxx一般是需要测试的函数名。 + +除此之外,还有一些社区的约束,这些约束不是强制的,但是遵循这些约束会让我们的测试函数名更加易懂。例如,我们有以下函数: + +package main + +type Person struct { + age int64 +} + +func (p *Person) older(other *Person) bool { + return p.age > other.age +} + + +很显然,我们可以把测试函数命名为TestOlder,这个名称可以很清晰地说明它是Older函数的测试用例。但是,如果我们想用多个测试用例来测试TestOlder函数,这些测试用例该如何命名呢?也许你会说,我们命名为TestOlder1、TestOlder2不就行了? + +其实,还有其他更好的命名方法。比如,这种情况下,我们可以将函数命名为TestOlderXxx,其中Xxx代表Older函数的某个场景描述。例如,strings.Compare函数有如下测试函数:TestCompare、TestCompareIdenticalString、TestCompareStrings。 + +变量的命名规范 + +Go语言和go test没有对变量的命名做任何约束。但是,在编写单元测试用例时,还是有一些规范值得我们去遵守。 + +单元测试用例通常会有一个实际的输出,在单元测试中,我们会将预期的输出跟实际的输出进行对比,来判断单元测试是否通过。为了清晰地表达函数的实际输出和预期输出,可以将这两类输出命名为expected/actual,或者got/want。例如: + +if c.expected != actual { + t.Fatalf("Expected User-Agent '%s' does not match '%s'", c.expected, actual) +} + + +或者: + +if got, want := diags[3].Description().Summary, undeclPlural; got != want { + t.Errorf("wrong summary for diagnostic 3\ngot: %s\nwant: %s", got, want) +} + + +其他的变量命名,我们可以遵循Go语言推荐的变量命名方法,例如: + + +Go中的变量名应该短而不是长,对于范围有限的局部变量来说尤其如此。 +变量离声明越远,对名称的描述性要求越高。 +像循环、索引之类的变量,名称可以是单个字母(i)。如果是不常见的变量和全局变量,变量名就需要具有更多的描述性。 + + +上面,我介绍了Go测试的一些基础知识。接下来,我们来看看如何编写单元测试用例和性能测试用例。 + +单元测试 + +单元测试用例函数以 Test 开头,例如 TestXxx 或 Test_xxx ( Xxx 部分为任意字母数字组合,首字母大写)。函数参数必须是 *testing.T,可以使用该类型来记录错误或测试状态。 + +我们可以调用 testing.T 的 Error 、Errorf 、FailNow 、Fatal 、FatalIf 方法,来说明测试不通过;调用 Log 、Logf 方法来记录测试信息。函数列表和相关描述如下表所示: + + + +下面的代码是两个简单的单元测试函数(函数位于文件math_test.go中): + +func TestAbs(t *testing.T) { + got := Abs(-1) + if got != 1 { + t.Errorf("Abs(-1) = %f; want 1", got) + } +} + +func TestMax(t *testing.T) { + got := Max(1, 2) + if got != 2 { + t.Errorf("Max(1, 2) = %f; want 2", got) + } +} + + +执行go test命令来执行如上单元测试用例: + +$ go test +PASS +ok github.com/marmotedu/gopractise-demo/31/test 0.002s + + +go test命令自动搜集所有的测试文件,也就是格式为*_test.go的文件,从中提取全部测试函数并执行。- +go test还支持下面三个参数。 + + +-v,显示所有测试函数的运行细节: + + +$ go test -v +=== RUN TestAbs +--- PASS: TestAbs (0.00s) +=== RUN TestMax +--- PASS: TestMax (0.00s) +PASS +ok github.com/marmotedu/gopractise-demo/31/test 0.002s + + + +-run < regexp>,指定要执行的测试函数: + + +$ go test -v -run='TestA.*' +=== RUN TestAbs +--- PASS: TestAbs (0.00s) +PASS +ok github.com/marmotedu/gopractise-demo/31/test 0.001s + + +上面的例子中,我们只运行了以TestA开头的测试函数。 + + +-count N,指定执行测试函数的次数: + + +$ go test -v -run='TestA.*' -count=2 +=== RUN TestAbs +--- PASS: TestAbs (0.00s) +=== RUN TestAbs +--- PASS: TestAbs (0.00s) +PASS +ok github.com/marmotedu/gopractise-demo/31/test 0.002s + + +多个输入的测试用例 + +前面介绍的单元测试用例只有一个输入,但是很多时候,我们需要测试一个函数在多种不同输入下是否能正常返回。这时候,我们可以编写一个稍微复杂点的测试用例,用来支持多输入下的用例测试。例如,我们可以将TestAbs改造成如下函数: + +func TestAbs_2(t *testing.T) { + tests := []struct { + x float64 + want float64 + }{ + {-0.3, 0.3}, + {-2, 2}, + {-3.1, 3.1}, + {5, 5}, + } + + for _, tt := range tests { + if got := Abs(tt.x); got != tt.want { + t.Errorf("Abs() = %f, want %v", got, tt.want) + } + } +} + + +上述测试用例函数中,我们定义了一个结构体数组,数组中的每一个元素代表一次测试用例。数组元素的的值包含输入和预期的返回值: + +tests := []struct { + x float64 + want float64 +}{ + {-0.3, 0.3}, + {-2, 2}, + {-3.1, 3.1}, + {5, 5}, +} + + +上述测试用例,将被测函数放在for循环中执行: + + for _, tt := range tests { + if got := Abs(tt.x); got != tt.want { + t.Errorf("Abs() = %f, want %v", got, tt.want) + } + } + + +上面的代码将输入传递给被测函数,并将被测函数的返回值跟预期的返回值进行比较。如果相等,则说明此次测试通过,如果不相等则说明此次测试不通过。通过这种方式,我们就可以在一个测试用例中,测试不同的输入和输出,也就是不同的测试用例。如果要新增一个测试用例,根据需要添加输入和预期的返回值就可以了,这些测试用例都共享其余的测试代码。 + +上面的测试用例中,我们通过got != tt.want来对比实际返回结果和预期返回结果。我们也可以使用github.com/stretchr/testify/assert包中提供的函数来做结果对比,例如: + +func TestAbs_3(t *testing.T) { + tests := []struct { + x float64 + want float64 + }{ + {-0.3, 0.3}, + {-2, 2}, + {-3.1, 3.1}, + {5, 5}, + } + + for _, tt := range tests { + got := Abs(tt.x) + assert.Equal(t, got, tt.want) + } +} + + +使用assert来对比结果,有下面这些好处: + + +友好的输出结果,易于阅读。 +因为少了if got := Xxx(); got != tt.wang {}的判断,代码变得更加简洁。 +可以针对每次断言,添加额外的消息说明,例如assert.Equal(t, got, tt.want, "Abs test")。 + + +assert包还提供了很多其他函数,供开发者进行结果对比,例如Zero、NotZero、Equal、NotEqual、Less、True、Nil、NotNil等。如果想了解更多函数,你可以参考go doc github.com/stretchr/testify/assert。 + +自动生成单元测试用例 + +通过上面的学习,你也许可以发现,测试用例其实可以抽象成下面的模型: + + + +用代码可表示为: + +func TestXxx(t *testing.T) { + type args struct { + // TODO: Add function input parameter definition. + } + + type want struct { + // TODO: Add function return parameter definition. + } + tests := []struct { + name string + args args + want want + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Xxx(tt.args); got != tt.want { + t.Errorf("Xxx() = %v, want %v", got, tt.want) + } + }) + } +} + + +既然测试用例可以抽象成一些模型,那么我们就可以基于这些模型来自动生成测试代码。Go社区中有一些优秀的工具可以自动生成测试代码,我推荐你使用gotests工具。 + +下面,我来讲讲gotests工具的使用方法,可以分成三个步骤。 + +第一步,安装gotests工具: + +$ go get -u github.com/cweill/gotests/... + + +gotests命令执行格式为:gotests [options] [PATH] [FILE] ...。gotests可以为PATH下的所有Go源码文件中的函数生成测试代码,也可以只为某个FILE中的函数生成测试代码。 + +第二步,进入测试代码目录,执行gotests生成测试用例: + +$ gotests -all -w . + + +上面的命令会为当前目录下所有Go源码文件中的函数生成测试代码。 + +第三步,添加测试用例: + +生成完测试用例,你只需要添加需要测试的输入和预期的输出就可以了。下面的测试用例是通过gotests生成的: + +func TestUnpointer(t *testing.T) { + type args struct { + offset *int64 + limit *int64 + } + tests := []struct { + name string + args args + want *LimitAndOffset + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Unpointer(tt.args.offset, tt.args.limit); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Unpointer() = %v, want %v", got, tt.want) + } + }) + } +} + + +我们只需要补全TODO位置的测试数据即可,补全后的测试用例见gorm_test.go文件。 + +性能测试 + +上面,我讲了用来测试代码的功能是否正常的单元测试,接下来我们来看下性能测试,它是用来测试代码的性能是否满足需求的。 + +性能测试的用例函数必须以Benchmark开头,例如BenchmarkXxx或Benchmark_Xxx( Xxx 部分为任意字母数字组合,首字母大写)。 + +函数参数必须是*testing.B,函数内以b.N作为循环次数,其中N会在运行时动态调整,直到性能测试函数可以持续足够长的时间,以便能够可靠地计时。下面的代码是一个简单的性能测试函数(函数位于文件math_test.go中): + +func BenchmarkRandInt(b *testing.B) { + for i := 0; i < b.N; i++ { + RandInt() + } +} + + +go test命令默认不会执行性能测试函数,需要通过指定参数-bench 来运行性能测试函数。-bench后可以跟正则表达式,选择需要执行的性能测试函数,例如go test -bench=".*"表示执行所有的压力测试函数。执行go test -bench=".*"后输出如下: + +$ go test -bench=".*" +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/gopractise-demo/31/test +BenchmarkRandInt-4 97384827 12.4 ns/op +PASS +ok github.com/marmotedu/gopractise-demo/31/test 1.223s + + +上面的结果只显示了性能测试函数的执行结果。BenchmarkRandInt性能测试函数的执行结果如下: + +BenchmarkRandInt-4 90848414 12.8 ns/op + + +每个函数的性能执行结果一共有3列,分别代表不同的意思,这里用上面的函数举例子: + + +BenchmarkRandInt-4,BenchmarkRandInt表示所测试的测试函数名,4表示有4个CPU线程参与了此次测试,默认是GOMAXPROCS的值。 +90848414 ,说明函数中的循环执行了90848414次。 +12.8 ns/op,说明每次循环的执行平均耗时是 12.8 纳秒,该值越小,说明代码性能越高。 + + +如果我们的性能测试函数在执行循环前,需要做一些耗时的准备工作,我们就需要重置性能测试时间计数,例如: + +func BenchmarkBigLen(b *testing.B) { + big := NewBig() + b.ResetTimer() + for i := 0; i < b.N; i++ { + big.Len() + } +} + + +当然,我们也可以先停止性能测试的时间计数,然后再开始时间计数,例如: + +func BenchmarkBigLen(b *testing.B) { + b.StopTimer() // 调用该函数停止压力测试的时间计数 + big := NewBig() + b.StartTimer() // 重新开始时间 + for i := 0; i < b.N; i++ { + big.Len() + } +} + + +B类型的性能测试还支持下面 4 个参数。 + + +benchmem,输出内存分配统计: + + +$ go test -bench=".*" -benchmem +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/gopractise-demo/31/test +BenchmarkRandInt-4 96776823 12.8 ns/op 0 B/op 0 allocs/op +PASS +ok github.com/marmotedu/gopractise-demo/31/test 1.255s + + +指定了-benchmem参数后,执行结果中又多了两列: 0 B/op,表示每次执行分配了多少内存(字节),该值越小,说明代码内存占用越小;0 allocs/op,表示每次执行分配了多少次内存,该值越小,说明分配内存次数越少,意味着代码性能越高。 + + +benchtime,指定测试时间和循环执行次数(格式需要为Nx,例如100x): + + +$ go test -bench=".*" -benchtime=10s # 指定测试时间 +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/gopractise-demo/31/test +BenchmarkRandInt-4 910328618 13.1 ns/op +PASS +ok github.com/marmotedu/gopractise-demo/31/test 13.260s +$ go test -bench=".*" -benchtime=100x # 指定循环执行次数 +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/gopractise-demo/31/test +BenchmarkRandInt-4 100 16.9 ns/op +PASS +ok github.com/marmotedu/gopractise-demo/31/test 0.003s + + + +cpu,指定GOMAXPROCS。 +timeout,指定测试函数执行的超时时间: + + +$ go test -bench=".*" -timeout=10s +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/gopractise-demo/31/test +BenchmarkRandInt-4 97375881 12.4 ns/op +PASS +ok github.com/marmotedu/gopractise-demo/31/test 1.224s + + +总结 + +代码开发完成之后,我们需要为代码编写单元测试用例,并根据需要,给一些函数编写性能测试用例。Go语言提供了 testing 包,供我们编写测试用例,并通过 go test 命令来执行这些测试用例。 + +go test在执行测试用例时,会查找具有固定格式的Go源码文件名,并执行其中具有固定格式的函数,这些函数就是测试用例。这就要求我们的测试文件名、函数名要符合 go test 工具的要求:Go的测试文件名必须以 _test.go 结尾;测试用例函数必须以 Test 、 Benchmark 、 Example 开头。此外,我们在编写测试用例时,还要注意包和变量的命名规范。 + +Go项目开发中,编写得最多的是单元测试用例。单元测试用例函数以 Test 开头,例如 TestXxx 或 Test_xxx (Xxx 部分为任意字母数字组合,首字母大写)。函数参数必须是 *testing.T ,可以使用该类型来记录错误或测试状态。我们可以调用 testing.T 的 Error 、Errorf 、FailNow 、Fatal 、FatalIf 方法,来说明测试不通过;调用 Log 、Logf 方法来记录测试信息。 + +下面是一个简单的单元测试函数: + +func TestAbs(t *testing.T) { + got := Abs(-1) + if got != 1 { + t.Errorf("Abs(-1) = %f; want 1", got) + } +} + + +编写完测试用例之后,可以使用 go test 命令行工具来执行这些测试用例。- +此外,我们还可以使用gotests工具,来自动地生成单元测试用例,从而减少编写测试用例的工作量。 + +我们在Go项目开发中,还经常需要编写性能测试用例。性能测试用例函数必须以Benchmark开头,以*testing.B 作为函数入参,通过 go test -bench 运行。 + +课后练习 + + +编写一个 PrintHello 函数,该函数会返回 Hello World 字符串,并编写单元测试用例,对 PrintHello 函数进行测试。 +思考一下,哪些场景下采用白盒测试,哪些场景下采用黑盒测试? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/37代码测试(下):Go语言其他测试类型及IAM测试介绍.md b/专栏/Go语言项目开发实战/37代码测试(下):Go语言其他测试类型及IAM测试介绍.md new file mode 100644 index 0000000..a9f64c2 --- /dev/null +++ b/专栏/Go语言项目开发实战/37代码测试(下):Go语言其他测试类型及IAM测试介绍.md @@ -0,0 +1,744 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 37 代码测试(下):Go 语言其他测试类型及 IAM 测试介绍 + 你好,我是孔令飞。 + +上一讲,我介绍了Go中的两类测试:单元测试和性能测试。在Go中,还有一些其他的测试类型和测试方法,值得我们去了解和掌握。此外,IAM项目也编写了大量测试用例,这些测试用例使用了不同的编写方法,你可以通过学习IAM的测试用例来验证你学到的测试知识。 + +今天,我就来介绍下Go 语言中的其他测试类型:示例测试、TestMain函数、Mock测试、Fake测试等,并且介绍下IAM项目是如何编写和运行测试用例的。 + +示例测试 + +示例测试以Example开头,没有输入和返回参数,通常保存在example_test.go文件中。示例测试可能包含以Output:或者Unordered output:开头的注释,这些注释放在函数的结尾部分。Unordered output:开头的注释会忽略输出行的顺序。 + +执行go test命令时,会执行这些示例测试,并且go test会将示例测试输出到标准输出的内容,跟注释作对比(比较时将忽略行前后的空格)。如果相等,则示例测试通过测试;如果不相等,则示例测试不通过测试。下面是一个示例测试(位于example_test.go文件中): + +func ExampleMax() { + fmt.Println(Max(1, 2)) + // Output: + // 2 +} + + +执行go test命令,测试ExampleMax示例测试: + +$ go test -v -run='Example.*' +=== RUN ExampleMax +--- PASS: ExampleMax (0.00s) +PASS +ok github.com/marmotedu/gopractise-demo/31/test 0.004s + + +可以看到ExampleMax测试通过。这里测试通过是因为fmt.Println(Max(1, 2))向标准输出输出了2,跟// Output:后面的2一致。 + +当示例测试不包含Output:或者Unordered output:注释时,执行go test只会编译这些函数,但不会执行这些函数。 + +示例测试命名规范 + +示例测试需要遵循一些命名规范,因为只有这样,Godoc才能将示例测试和包级别的标识符进行关联。例如,有以下示例测试(位于example_test.go文件中): + +package stringutil_test + +import ( + "fmt" + + "github.com/golang/example/stringutil" +) + +func ExampleReverse() { + fmt.Println(stringutil.Reverse("hello")) + // Output: olleh +} + + +Godoc将在Reverse函数的文档旁边提供此示例,如下图所示: + + + +示例测试名以Example开头,后面可以不跟任何字符串,也可以跟函数名、类型名或者类型_方法名,中间用下划线_连接,例如: + +func Example() { ... } // 代表了整个包的示例 +func ExampleF() { ... } // 函数F的示例 +func ExampleT() { ... } // 类型T的示例 +func ExampleT_M() { ... } // 方法T_M的示例 + + +当某个函数/类型/方法有多个示例测试时,可以通过后缀来区分,后缀必须以小写字母开头,例如: + +func ExampleReverse() +func ExampleReverse_second() +func ExampleReverse_third() + + +大型示例 + +有时候,我们需要编写一个大型的示例测试,这时候我们可以编写一个整文件的示例(whole file example),它有这几个特点:文件名以_test.go结尾;只包含一个示例测试,文件中没有单元测试函数和性能测试函数;至少包含一个包级别的声明;当展示这类示例测试时,godoc会直接展示整个文件。例如: + +package sort_test + +import ( + "fmt" + "sort" +) + +type Person struct { + Name string + Age int +} + +func (p Person) String() string { + return fmt.Sprintf("%s: %d", p.Name, p.Age) +} + +// ByAge implements sort.Interface for []Person based on +// the Age field. +type ByAge []Person + +func (a ByAge) Len() int { return len(a) } +func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age } + +func Example() { + people := []Person{ + {"Bob", 31}, + {"John", 42}, + {"Michael", 17}, + {"Jenny", 26}, + } + + fmt.Println(people) + sort.Sort(ByAge(people)) + fmt.Println(people) + + // Output: + // [Bob: 31 John: 42 Michael: 17 Jenny: 26] + // [Michael: 17 Jenny: 26 Bob: 31 John: 42] +} + + +一个包可以包含多个whole file example,一个示例一个文件,例如example_interface_test.go、example_keys_test.go、example_search_test.go等。 + +TestMain函数 + +有时候,我们在做测试的时候,可能会在测试之前做些准备工作,例如创建数据库连接等;在测试之后做些清理工作,例如关闭数据库连接、清理测试文件等。这时,我们可以在_test.go文件中添加TestMain函数,其入参为*testing.M。 + +TestMain是一个特殊的函数(相当于main函数),测试用例在执行时,会先执行TestMain函数,然后可以在TestMain中调用m.Run()函数执行普通的测试函数。在m.Run()函数前面我们可以编写准备逻辑,在m.Run()后面我们可以编写清理逻辑。 + +我们在示例测试文件math_test.go中添加如下TestMain函数: + +func TestMain(m *testing.M) { + fmt.Println("do some setup") + m.Run() + fmt.Println("do some cleanup") +} + + +执行go test,输出如下: + +$ go test -v +do some setup +=== RUN TestAbs +--- PASS: TestAbs (0.00s) +... +=== RUN ExampleMax +--- PASS: ExampleMax (0.00s) +PASS +do some cleanup +ok github.com/marmotedu/gopractise-demo/31/test 0.006s + + +在执行测试用例之前,打印了do some setup,在测试用例运行完成之后,打印了do some cleanup。 + +IAM项目的测试用例中,使用TestMain函数在执行测试用例前连接了一个fake数据库,代码如下(位于internal/apiserver/service/v1/user_test.go文件中): + +func TestMain(m *testing.M) { + fakeStore, _ := fake.NewFakeStore() + store.SetClient(fakeStore) + os.Exit(m.Run()) +} + + +单元测试、性能测试、示例测试、TestMain函数是go test支持的测试类型。此外,为了测试在函数内使用了Go Interface的函数,我们还延伸出了Mock测试和Fake测试两种测试类型。 + +Mock测试 + +一般来说,单元测试中是不允许有外部依赖的,那么也就是说,这些外部依赖都需要被模拟。在Go中,一般会借助各类Mock工具来模拟一些依赖。 + +GoMock是由Golang官方开发维护的测试框架,实现了较为完整的基于interface的Mock功能,能够与Golang内置的testing包良好集成,也能用于其他的测试环境中。GoMock测试框架包含了GoMock包和mockgen工具两部分,其中GoMock包用来完成对象生命周期的管理,mockgen工具用来生成interface对应的Mock类源文件。下面,我来分别详细介绍下GoMock包和mockgen工具,以及它们的使用方法。 + +安装GoMock + +要使用GoMock,首先需要安装GoMock包和mockgen工具,安装方法如下: + +$ go get github.com/golang/mock/gomock +$ go install github.com/golang/mock/mockgen + + +下面,我通过一个获取当前Golang最新版本的例子,来给你演示下如何使用GoMock。示例代码目录结构如下(目录下的代码见gomock): + +tree . +. +├── go_version.go +├── main.go +└── spider + └── spider.go + + +spider.go文件中定义了一个Spider接口,spider.go代码如下: + +package spider + +type Spider interface { + GetBody() string +} + + +Spider接口中的GetBody方法可以抓取https://golang.org首页的Build version字段,来获取Golang的最新版本。 + +我们在go_version.go文件中,调用Spider接口的GetBody方法,go_version.go代码如下: + +package gomock + +import ( + "github.com/marmotedu/gopractise-demo/gomock/spider" +) + +func GetGoVersion(s spider.Spider) string { + body := s.GetBody() + return body +} + + +GetGoVersion函数直接返回表示版本的字符串。正常情况下,我们会写出如下的单元测试代码: + +func TestGetGoVersion(t *testing.T) { + v := GetGoVersion(spider.CreateGoVersionSpider()) + if v != "go1.8.3" { + t.Error("Get wrong version %s", v) + } +} + + +上面的测试代码,依赖spider.CreateGoVersionSpider()返回一个实现了Spider接口的实例(爬虫)。但很多时候,spider.CreateGoVersionSpider()爬虫可能还没有实现,或者在单元测试环境下不能运行(比如,在单元测试环境中连接数据库),这时候TestGetGoVersion测试用例就无法执行。 + +那么,如何才能在这种情况下运行TestGetGoVersion测试用例呢?这时候,我们就可以通过Mock工具,Mock一个爬虫实例。接下来我讲讲具体操作。 + +首先,用 GoMock 提供的mockgen工具,生成要 Mock 的接口的实现,我们在gomock目录下执行以下命令: + +$ mockgen -destination spider/mock/mock_spider.go -package spider github.com/marmotedu/gopractise-demo/gomock/spider Spider + + +上面的命令会在spider/mock目录下生成mock_spider.go文件: + +$ tree . +. +├── go_version.go +├── go_version_test.go +├── go_version_test_traditional_method.go~ +└── spider + ├── mock + │ └── mock_spider.go + └── spider.go + + +mock_spider.go文件中,定义了一些函数/方法,可以支持我们编写TestGetGoVersion测试函数。这时候,我们的单元测试代码如下(见go_version_test.go文件): + +package gomock + +import ( + "testing" + + "github.com/golang/mock/gomock" + + spider "github.com/marmotedu/gopractise-demo/gomock/spider/mock" +) + +func TestGetGoVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockSpider := spider.NewMockSpider(ctrl) + mockSpider.EXPECT().GetBody().Return("go1.8.3") + goVer := GetGoVersion(mockSpider) + + if goVer != "go1.8.3" { + t.Errorf("Get wrong version %s", goVer) + } +} + + +这一版本的TestGetGoVersion通过GoMock, Mock了一个Spider接口,而不用去实现一个Spider接口。这就大大降低了单元测试用例编写的复杂度。通过Mock,很多不能测试的函数也变得可测试了。 + +通过上面的测试用例,我们可以看到,GoMock 和上一讲介绍的testing单元测试框架可以紧密地结合起来工作。 + +mockgen工具介绍 + +上面,我介绍了如何使用 GoMock 编写单元测试用例。其中,我们使用到了mockgen工具来生成 Mock代码,mockgen工具提供了很多有用的功能,这里我来详细介绍下。 + +mockgen工具是 GoMock 提供的,用来Mock一个Go接口。它可以根据给定的接口,来自动生成Mock代码。这里,有两种模式可以生成Mock代码,分别是源码模式和反射模式。 + + +源码模式 + + +如果有接口文件,则可以通过以下命令来生成Mock代码: + +$ mockgen -destination spider/mock/mock_spider.go -package spider -source spider/spider.go + + +上面的命令,Mock了spider/spider.go文件中定义的Spider接口,并将Mock代码保存在spider/mock/mock_spider.go文件中,文件的包名为spider。 + +mockgen工具的参数说明见下表: + + + + +反射模式 + + +此外,mockgen工具还支持通过使用反射程序来生成 Mock 代码。它通过传递两个非标志参数,即导入路径和逗号分隔的接口列表来启用,其他参数和源码模式共用,例如: + +$ mockgen -destination spider/mock/mock_spider.go -package spider github.com/marmotedu/gopractise-demo/gomock/spider Spider + + +通过注释使用mockgen + +如果有多个文件,并且分散在不同的位置,那么我们要生成Mock文件的时候,需要对每个文件执行多次mockgen命令(这里假设包名不相同)。这种操作还是比较繁琐的,mockgen还提供了一种通过注释生成Mock文件的方式,此时需要借助go generate工具。 + +在接口文件的代码中,添加以下注释(具体代码见spider.go文件): + +//go:generate mockgen -destination mock_spider.go -package spider github.com/cz-it/blog/blog/Go/testing/gomock/example/spider Spider + + +这时候,我们只需要在gomock目录下,执行以下命令,就可以自动生成Mock代码: + +$ go generate ./... + + +使用Mock代码编写单元测试用例 + +生成了Mock代码之后,我们就可以使用它们了。这里我们结合testing来编写一个使用了Mock代码的单元测试用例。 + +首先,需要在单元测试代码里创建一个Mock控制器: + +ctrl := gomock.NewController(t) + + +将*testing.T传递给GoMock ,生成一个Controller对象,该对象控制了整个Mock的过程。在操作完后,还需要进行回收,所以一般会在NewController后面defer一个Finish,代码如下: + +defer ctrl.Finish() + + +然后,就可以调用Mock的对象了: + +mockSpider := spider.NewMockSpider(ctrl) + + +这里的spider是mockgen命令里面传递的包名,后面是NewMockXxxx格式的对象创建函数,Xxx是接口名。这里,我们需要传递控制器对象进去,返回一个Mock实例。 + +接着,有了Mock实例,我们就可以调用其断言方法EXPECT()了。 + +gomock采用了链式调用法,通过.连接函数调用,可以像链条一样连接下去。例如: + +mockSpider.EXPECT().GetBody().Return("go1.8.3") + + +Mock一个接口的方法,我们需要Mock该方法的入参和返回值。我们可以通过参数匹配来Mock入参,通过Mock实例的 Return 方法来Mock返回值。下面,我们来分别看下如何指定入参和返回值。 + +先来看如何指定入参。如果函数有参数,我们可以使用参数匹配来指代函数的参数,例如: + +mockSpider.EXPECT().GetBody(gomock.Any(), gomock.Eq("admin")).Return("go1.8.3") + + +gomock支持以下参数匹配: + + +gomock.Any(),可以用来表示任意的入参。 +gomock.Eq(value),用来表示与 value 等价的值。 +gomock.Not(value),用来表示非 value 以外的值。 +gomock.Nil(),用来表示 None 值。 + + +接下来,我们看如何指定返回值。 + +EXPECT()得到Mock的实例,然后调用Mock实例的方法,该方法返回第一个Call对象,然后可以对其进行条件约束,比如使用Mock实例的 Return 方法约束其返回值。Call对象还提供了以下方法来约束Mock实例: + +func (c *Call) After(preReq *Call) *Call // After声明调用在preReq完成后执行 +func (c *Call) AnyTimes() *Call // 允许调用次数为 0 次或更多次 +func (c *Call) Do(f interface{}) *Call // 声明在匹配时要运行的操作 +func (c *Call) MaxTimes(n int) *Call // 设置最大的调用次数为 n 次 +func (c *Call) MinTimes(n int) *Call // 设置最小的调用次数为 n 次 +func (c *Call) Return(rets ...interface{}) *Call // // 声明模拟函数调用返回的值 +func (c *Call) SetArg(n int, value interface{}) *Call // 声明使用指针设置第 n 个参数的值 +func (c *Call) Times(n int) *Call // 设置调用次数为 n 次 + + +上面列出了多个 Call 对象提供的约束方法,接下来我会介绍3个常用的约束方法:指定返回值、指定执行次数和指定执行顺序。 + + +指定返回值 + + +我们可以提供调用Call的Return函数,来指定接口的返回值,例如: + +mockSpider.EXPECT().GetBody().Return("go1.8.3") + + + +指定执行次数 + + +有时候,我们需要指定函数执行多少次,例如:对于接受网络请求的函数,计算其执行了多少次。我们可以通过Call的Times函数来指定执行次数: + +mockSpider.EXPECT().Recv().Return(nil).Times(3) + + +上述代码,执行了三次Recv函数,这里gomock还支持其他的执行次数限制: + + +AnyTimes(),表示执行0到多次。 +MaxTimes(n int),表示如果没有设置,最多执行n次。 +MinTimes(n int),表示如果没有设置,最少执行n次。 + + + +指定执行顺序 + + +有时候,我们还要指定执行顺序,比如要先执行 Init 操作,然后才能执行Recv操作: + +initCall := mockSpider.EXPECT().Init() +mockSpider.EXPECT().Recv().After(initCall) + + +最后,我们可以使用go test来测试使用了Mock代码的单元测试代码: + +$ go test -v +=== RUN TestGetGoVersion +--- PASS: TestGetGoVersion (0.00s) +PASS +ok github.com/marmotedu/gopractise-demo/gomock 0.002s + + +Fake测试 + +在Go项目开发中,对于比较复杂的接口,我们还可以Fake一个接口实现,来进行测试。所谓Fake测试,其实就是针对接口实现一个假(fake)的实例。至于如何实现Fake实例,需要你根据业务自行实现。例如:IAM项目中iam-apiserver组件就实现了一个fake store,代码见fake目录。因为这一讲后面的IAM项目测试实战部分有介绍,所以这里不再展开讲解。 + +何时编写和执行单元测试用例? + +上面,我介绍了Go代码测试的基础知识,这里我再来分享下在做测试时一个比较重要的知识点:何时编写和执行单元测试用例。 + +编码前:TDD + + + +Test-Driven Development,也就是测试驱动开发,是敏捷开发的⼀项核心实践和技术,也是⼀种设计方法论。简单来说,TDD原理就是:开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。这样做的好处在于,通过测试的执行代码肯定满足需求,而且有助于面向接口编程,降低代码耦合,也极大降低了bug的出现几率。 + +然而,TDD的坏处也显而易见:由于测试用例是在进行代码设计之前写的,很有可能限制开发者对代码的整体设计;并且,由于TDD对开发⼈员要求非常高,体现的思想跟传统开发思维也不⼀样,因此实施起来比较困难;此外,因为要先编写测试用例,TDD也可能会影响项目的研发进度。所以,在客观情况不满足的情况下,不应该盲目追求对业务代码使用TDD的开发模式。 + +与编码同步进行:增量 + +及时为增量代码写单测是一种良好的习惯。一方面是因为,此时我们对需求有一定的理解,能够更好地写出单元测试来验证正确性。并且,在单测阶段就发现问题,而不是等到联调测试中才发现,修复的成本也是最小的。 + +另一方面,在写单测的过程中,我们也能够反思业务代码的正确性、合理性,推动我们在实现的过程中更好地反思代码的设计,并及时调整。 + +编码后:存量 + +在完成业务需求后,我们可能会遇到这种情况:因为上线时间比较紧张、没有单测相关规划,开发阶段只手动测试了代码是否符合功能。 + +如果这部分存量代码出现较大的新需求,或者维护已经成为问题,需要大规模重构,这正是推动补全单测的好时机。为存量代码补充上单测,一方面能够推进重构者进一步理解原先的逻辑,另一方面也能够增强重构者重构代码后的信心,降低风险。 + +但是,补充存量单测可能需要再次回忆理解需求和逻辑设计等细节,而有时写单测的人并不是原编码的设计者,所以编码后编写和执行单元测试用例也有一定的不足。 + +测试覆盖率 + +我们写单元测试的时候应该想得很全面,能够覆盖到所有的测试用例,但有时也会漏过一些 case,Go提供了cover工具来统计测试覆盖率。具体可以分为两大步骤。 + +第一步,生成测试覆盖率数据: + +$ go test -coverprofile=coverage.out +do some setup +PASS +coverage: 40.0% of statements +do some cleanup +ok github.com/marmotedu/gopractise-demo/test 0.003s + + +上面的命令在当前目录下生成了coverage.out覆盖率数据文件。 + + + +第二步,分析覆盖率文件: + +$ go tool cover -func=coverage.out +do some setup +PASS +coverage: 40.0% of statements +do some cleanup +ok github.com/marmotedu/gopractise-demo/test 0.003s +[colin@dev test]$ go tool cover -func=coverage.out +github.com/marmotedu/gopractise-demo/test/math.go:9: Abs 100.0% +github.com/marmotedu/gopractise-demo/test/math.go:14: Max 100.0% +github.com/marmotedu/gopractise-demo/test/math.go:19: Min 0.0% +github.com/marmotedu/gopractise-demo/test/math.go:24: RandInt 0.0% +github.com/marmotedu/gopractise-demo/test/math.go:29: Floor 0.0% +total: (statements) 40.0% + + +在上述命令的输出中,我们可以查看到哪些函数没有测试,哪些函数内部的分支没有测试完全。cover工具会根据被执行代码的行数与总行数的比例计算出覆盖率。可以看到,Abs和Max函数的测试覆盖率为100%,Min和RandInt的测试覆盖率为0。 + +我们还可以使用go tool cover -html生成HTML格式的分析文件,可以更加清晰地展示代码的测试情况: + +$ go tool cover -html=coverage.out -o coverage.html + + +上述命令会在当前目录下生成一个coverage.html文件,用浏览器打开coverage.html文件,可以更加清晰地看到代码的测试情况,如下图所示: + + + +通过上图,我们可以知道红色部分的代码没有被测试到,可以让我们接下来有针对性地添加测试用例,而不是一头雾水,不知道需要为哪些代码编写测试用例。 + +在Go项目开发中,我们往往会把测试覆盖率作为代码合并的一个强制要求,所以需要在进行代码测试时,同时生成代码覆盖率数据文件。在进行代码测试时,可以通过分析该文件,来判断我们的代码测试覆盖率是否满足要求,如果不满足则代码测试失败。 + +IAM项目测试实战 + +接下来,我来介绍下IAM项目是如何编写和运行测试用例的,你可以通过IAM项目的测试用例,加深对上面内容的理解。 + +IAM项目是如何运行测试用例的? + +首先,我们来看下IAM项目是如何执行测试用例的。 + +在IAM项目的源码根目录下,可以通过运行make test执行测试用例,make test会执行iam/scripts/make-rules/golang.mk文件中的go.test伪目标,规则如下: + +.PHONY: go.test +go.test: tools.verify.go-junit-report + @echo "===========> Run unit test" + @set -o pipefail;$(GO) test -race -cover -coverprofile=$(OUTPUT_DIR)/coverage.out \\ + -timeout=10m -short -v `go list ./...|\ + egrep -v $(subst $(SPACE),'|',$(sort $(EXCLUDE_TESTS)))` 2>&1 | \\ + tee >(go-junit-report --set-exit-code >$(OUTPUT_DIR)/report.xml) + @sed -i '/mock_.*.go/d' $(OUTPUT_DIR)/coverage.out # remove mock_.*.go files from test coverage + @$(GO) tool cover -html=$(OUTPUT_DIR)/coverage.out -o $(OUTPUT_DIR)/coverage.html + + +在上述规则中,我们执行go test时设置了超时时间、竞态检查,开启了代码覆盖率检查,覆盖率测试数据保存在了coverage.out文件中。在Go项目开发中,并不是所有的包都需要单元测试,所以上面的命令还过滤掉了一些不需要测试的包,这些包配置在EXCLUDE_TESTS变量中: + +EXCLUDE_TESTS=github.com/marmotedu/iam/test github.com/marmotedu/iam/pkg/log github.com/marmotedu/iam/third_party github.com/marmotedu/iam/internal/pump/storage github.com/marmotedu/iam/internal/pump github.com/marmotedu/iam/internal/pkg/logger + + +同时,也调用了go-junit-report将go test的结果转化成了xml格式的报告文件,该报告文件会被一些CI系统,例如Jenkins拿来解析并展示结果。上述代码也同时生成了coverage.html文件,该文件可以存放在制品库中,供我们后期分析查看。 + +这里需要注意,Mock的代码是不需要编写测试用例的,为了避免影响项目的单元测试覆盖率,需要将Mock代码的单元测试覆盖率数据从coverage.out文件中删除掉,go.test规则通过以下命令删除这些无用的数据: + +sed -i '/mock_.*.go/d' $(OUTPUT_DIR)/coverage.out # remove mock_.*.go files from test coverage + + +另外,还可以通过make cover来进行单元测试覆盖率测试,make cover会执行iam/scripts/make-rules/golang.mk文件中的go.test.cover伪目标,规则如下: + +.PHONY: go.test.cover +go.test.cover: go.test + @$(GO) tool cover -func=$(OUTPUT_DIR)/coverage.out | \\ + awk -v target=$(COVERAGE) -f $(ROOT_DIR)/scripts/coverage.awk + + +上述目标依赖go.test,也就是说执行单元测试覆盖率目标之前,会先进行单元测试,然后使用单元测试产生的覆盖率数据coverage.out计算出总的单元测试覆盖率,这里是通过coverage.awk脚本来计算的。 + +如果单元测试覆盖率不达标,Makefile会报错并退出。可以通过Makefile的COVERAGE变量来设置单元测试覆盖率阈值。 + +COVERAGE的默认值为60,我们也可以在命令行手动指定,例如: + +$ make cover COVERAGE=80 + + +为了确保项目的单元测试覆盖率达标,需要设置单元测试覆盖率质量红线。一般来说,这些红线很难靠开发者的自觉性去保障,所以好的方法是将质量红线加入到CICD流程中。 + +所以,在Makefile文件中,我将cover放在all目标的依赖中,并且位于build之前,也就是all: gen add-copyright format lint cover build。这样每次当我们执行make时,会自动进行代码测试,并计算单元测试覆盖率,如果覆盖率不达标,则停止构建;如果达标,继续进入下一步的构建流程。 + +IAM项目测试案例分享 + +接下来,我会给你展示一些IAM项目的测试案例,因为这些测试案例的实现方法,我在36讲 和这一讲的前半部分已有详细介绍,所以这里,我只列出具体的实现代码,不会再介绍这些代码的实现方法。 + + +单元测试案例 + + +我们可以手动编写单元测试代码,也可以使用gotests工具生成单元测试代码。 + +先来看手动编写测试代码的案例。这里单元测试代码见Test_Option,代码如下: + +func Test_Option(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ExitOnError) + opt := log.NewOptions() + opt.AddFlags(fs) + + args := []string{"--log.level=debug"} + err := fs.Parse(args) + assert.Nil(t, err) + + assert.Equal(t, "debug", opt.Level) +} + + +上述代码中,使用了github.com/stretchr/testify/assert包来对比结果。 + +再来看使用gotests工具生成单元测试代码的案例(Table-Driven 的测试模式)。出于效率上的考虑,IAM项目的单元测试用例,基本都是使用gotests工具生成测试用例模板代码,并基于这些模板代码填充测试Case的。代码见service_test.go文件。 + + +性能测试案例 + + +IAM项目的性能测试用例,见BenchmarkListUser测试函数。代码如下: + +func BenchmarkListUser(b *testing.B) { + opts := metav1.ListOptions{ + Offset: pointer.ToInt64(0), + Limit: pointer.ToInt64(50), + } + storeIns, _ := fake.GetFakeFactoryOr() + u := &userService{ + store: storeIns, + } + + for i := 0; i < b.N; i++ { + _, _ = u.List(context.TODO(), opts) + } +} + + + +示例测试案例 + + +IAM项目的示例测试用例见example_test.go文件。example_test.go中的一个示例测试代码如下: + +func ExampleNew() { + err := New("whoops") + fmt.Println(err) + + // Output: whoops +} + + + +TestMain测试案例 + + +IAM项目的TestMain测试案例,见user_test.go文件中的TestMain函数: + +func TestMain(m *testing.M) { + _, _ = fake.GetFakeFactoryOr() + os.Exit(m.Run()) +} + + +TestMain函数初始化了fake Factory,然后调用m.Run执行测试用例。 + + +Mock测试案例 + + +Mock代码见internal/apiserver/service/v1/mock_service.go,使用Mock的测试用例见internal/apiserver/controller/v1/user/create_test.go文件。因为代码比较多,这里建议你打开链接,查看测试用例的具体实现。 + +我们可以在IAM项目的根目录下执行以下命令,来自动生成所有的Mock文件: + +$ go generate ./... + + + +Fake测试案例 + + +fake store代码实现位于internal/apiserver/store/fake目录下。fake store的使用方式,见user_test.go文件: + +func TestMain(m *testing.M) { + _, _ = fake.GetFakeFactoryOr() + os.Exit(m.Run()) +} + +func BenchmarkListUser(b *testing.B) { + opts := metav1.ListOptions{ + Offset: pointer.ToInt64(0), + Limit: pointer.ToInt64(50), + } + storeIns, _ := fake.GetFakeFactoryOr() + u := &userService{ + store: storeIns, + } + + for i := 0; i < b.N; i++ { + _, _ = u.List(context.TODO(), opts) + } +} + + +上述代码通过TestMain初始化fake实例(store.Factory接口类型): + +func GetFakeFactoryOr() (store.Factory, error) { + once.Do(func() { + fakeFactory = &datastore{ + users: FakeUsers(ResourceCount), + secrets: FakeSecrets(ResourceCount), + policies: FakePolicies(ResourceCount), + } + }) + + if fakeFactory == nil { + return nil, fmt.Errorf("failed to get mysql store fatory, mysqlFactory: %+v", fakeFactory) + } + + return fakeFactory, nil +} + + +GetFakeFactoryOr函数,创建了一些fake users、secrets、policies,并保存在了fakeFactory变量中,供后面的测试用例使用,例如BenchmarkListUser、Test_newUsers等。 + +其他测试工具/包 + +最后,我再来分享下Go项目测试中常用的工具/包,因为内容较多,我就不详细介绍了,如果感兴趣你可以点进链接自行学习。我将这些测试工具/包分为了两类,分别是测试框架和Mock工具。 + +测试框架 + + +Testify框架:Testify是Go test的预判工具,它能让你的测试代码变得更优雅和高效,测试结果也变得更详细。 +GoConvey框架:GoConvey是一款针对Golang的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,并支持很多 Web 界面特性。 + + +Mock工具 + +这一讲里,我介绍了Go官方提供的Mock框架GoMock,不过还有一些其他的优秀Mock工具可供我们使用。这些Mock工具分别用在不同的Mock场景中,我在 10讲中已经介绍过。不过,为了使我们这一讲的测试知识体系更加完整,这里我还是再提一次,你可以复习一遍。 + + +sqlmock:可以用来模拟数据库连接。数据库是项目中比较常见的依赖,在遇到数据库依赖时都可以用它。 +httpmock:可以用来Mock HTTP请求。 +bouk/monkey:猴子补丁,能够通过替换函数指针的方式来修改任意函数的实现。如果golang/mock、sqlmock和httpmock这几种方法都不能满足我们的需求,我们可以尝试用猴子补丁的方式来Mock依赖。可以这么说,猴子补丁提供了单元测试 Mock 依赖的最终解决方案。 + + +总结 + +这一讲,我介绍了除单元测试和性能测试之外的另一些测试方法。 + +除了示例测试和TestMain函数,我还详细介绍了Mock测试,也就是如何使用GoMock来测试一些在单元测试环境下不好实现的接口。绝大部分情况下,可以使用GoMock来Mock接口,但是对于一些业务逻辑比较复杂的接口,我们可以通过Fake一个接口实现,来对代码进行测试,这也称为Fake测试。 + +此外,我还介绍了何时编写和执行测试用例。我们可以根据需要,选择在编写代码前、编写代码中、编写代码后编写测试用例。 + +为了保证单元测试覆盖率,我们还应该为整个项目设置单元测试覆盖率质量红线,并将该质量红线加入到CICD流程中。我们可以通过 go test -coverprofile=coverage.out 命令来生成测试覆盖率数据,通过go tool cover -func=coverage.out 命令来分析覆盖率文件。 + +IAM项目中使用了大量的测试方法和技巧来测试代码,为了加深你对测试知识的理解,我也列举了一些测试案例,供你参考、学习和验证。具体的测试案例,你可以返回前面查看下。 + +除此之外,我们还可以使用其他一些测试框架,例如Testify框架和GoConvey框架。在Go代码测试中,我们最常使用的是Go官方提供的Mock框架GoMock,但仍然有其他优秀的Mock工具,可供我们在不同场景下使用,例如sqlmock、httpmock、bouk/monkey等。 + +课后习题 + + +请使用 sqlmock 来Mock一个GORM数据库实例,并完成GORM的CURD单元测试用例编写。 +思考下,在Go项目开发中,还有哪些优秀的测试框架、测试工具、Mock工具以及测试技巧?欢迎你在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/38性能分析(上):如何分析Go语言代码的性能?.md b/专栏/Go语言项目开发实战/38性能分析(上):如何分析Go语言代码的性能?.md new file mode 100644 index 0000000..c45459c --- /dev/null +++ b/专栏/Go语言项目开发实战/38性能分析(上):如何分析Go语言代码的性能?.md @@ -0,0 +1,514 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 38 性能分析(上):如何分析 Go 语言代码的性能? + 你好,我是孔令飞。 + +作为开发人员,我们一般都局限在功能上的单元测试中,对一些性能上的细节往往不会太关注。但是,如果我们在上线的时候对项目的整体性能没有一个全面的了解,随着请求量越来越大,可能会出现各种各样的问题,比如CPU占用高、内存使用率高、请求延时高等。为了避免这些性能瓶颈,我们在开发的过程中需要通过一定的手段,来对程序进行性能分析。 + +Go语言已经为开发者内置了很多性能调优、监控的工具和方法,这大大提升了我们profile分析的效率,借助这些工具,我们可以很方便地对Go程序进行性能分析。在Go语言开发中,开发者基本都是通过内置的pprof工具包来进行性能分析的。 + +在进行性能分析时,我们会先借助一些工具和包,生成性能数据文件,然后再通过pprof工具分析性能数据文件,从而分析代码的性能。那么接下来,我们就分别来看下如何执行这两步操作。 + +生成性能数据文件 + +要查看性能数据,需要先生成性能数据文件。生成性能数据文件有三种方法,分别是通过命令行、通过代码和通过net/http/pprof包。这些工具和包会分别生成CPU和内存性能数据。 + +接下来,我们就来看下这三种方法分别是如何生成性能数据文件的。 + +通过命令行生成性能数据文件 + +我们可以使用go test -cpuprofile来生成性能测试数据。进入internal/apiserver/service/v1目录,执行以下命令: + +$ go test -bench=".*" -cpuprofile cpu.profile -memprofile mem.profile +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/iam/internal/apiserver/service/v1 +cpu: AMD EPYC Processor +BenchmarkListUser-8 280 4283077 ns/op +PASS +ok github.com/marmotedu/iam/internal/apiserver/service/v1 1.798s + + +上面的命令会在当前目录下生成3个文件: + + +v1.test,测试生成的二进制文件,进行性能分析时可以用来解析各种符号。 +cpu.profile,CPU性能数据文件。 +mem.profile,内存性能数据文件。 + + +通过代码生成性能数据文件 + +我们还可以使用代码来生成性能数据文件,例如pprof.go文件: + +package main + +import ( + "os" + "runtime/pprof" +) + +func main() { + cpuOut, _ := os.Create("cpu.out") + defer cpuOut.Close() + pprof.StartCPUProfile(cpuOut) + defer pprof.StopCPUProfile() + + memOut, _ := os.Create("mem.out") + defer memOut.Close() + defer pprof.WriteHeapProfile(memOut) + + Sum(3, 5) + +} + +func Sum(a, b int) int { + return a + b +} + + +运行pprof.go文件: + +$ go run pprof.go + + +运行pprof.go文件后,会在当前目录生成cpu.profile和mem.profile性能数据文件。 + +通过net/http/pprof生成性能数据文件 + +如果要分析HTTP Server的性能,我们可以使用net/http/pprof包来生成性能数据文件。 + +IAM项目使用Gin框架作为HTTP引擎,所以IAM项目使用了github.com/gin-contrib/pprof包来启用HTTP性能分析。github.com/gin-contrib/pprof包是net/http/pprof的一个简单封装,通过封装使pprof的功能变成了一个Gin中间件,这样可以根据需要加载pprof中间件。 + +github.com/gin-contrib/pprof包中的pprof.go文件中有以下代码: + +func Register(r *gin.Engine, prefixOptions ...string) { + prefix := getPrefix(prefixOptions...) + + prefixRouter := r.Group(prefix) + { + ... + prefixRouter.GET("/profile", pprofHandler(pprof.Profile)) + ... + } +} + +func pprofHandler(h http.HandlerFunc) gin.HandlerFunc { + handler := http.HandlerFunc(h) + return func(c *gin.Context) { + handler.ServeHTTP(c.Writer, c.Request) + } +} + + +通过上面的代码,你可以看到github.com/gin-contrib/pprof包将net/http/pprof.Profile转换成了gin.HandlerFunc,也就是Gin中间件。 + +要开启HTTP性能分析,只需要在代码中注册pprof提供的HTTP Handler即可(位于internal/pkg/server/genericapiserver.go文件中): + +// install pprof handler +if s.enableProfiling { + pprof.Register(s.Engine) +} + + +上面的代码根据配置--feature.profiling来判断是否开启HTTP性能分析功能。我们开启完HTTP性能分析,启动HTTP服务iam-apiserver后,即可访问http:// x.x.x.x:8080/debug/pprof(x.x.x.x是Linux服务器的地址)来查看profiles信息。profiles信息如下图所示: + + + +我们可以通过以下命令,来获取CPU性能数据文件: + +$ curl http://127.0.0.1:8080/debug/pprof/profile -o cpu.profile + + +执行完上面的命令后,需要等待30s,pprof会采集这30s内的性能数据,我们需要在这段时间内向服务器连续发送多次请求,请求的频度可以根据我们的场景来决定。30s之后,/debug/pprof/profile接口会生成CPU profile文件,被curl命令保存在当前目录下的cpu.profile文件中。 + +同样的,我们可以执行以下命令来生成内存性能数据文件: + +$ curl http://127.0.0.1:8080/debug/pprof/heap -o mem.profile + + +上面的命令会自动下载heap文件,并被curl命令保存在当前目录下的mem.profile文件中。 + +我们可以使用go tool pprof [mem|cpu].profile命令来分析HTTP接口的CPU和内存性能。我们也可以使用命令go tool pprof http://127.0.0.1:8080/debug/pprof/profile,或者go tool pprof http://127.0.0.1:8080/debug/pprof/heap,来直接进入pprof工具的交互Shell中。go tool pprof会首先下载并保存CPU和内存性能数据文件,然后再分析这些文件。 + +通过上面的三种方法,我们生成了cpu.profile和mem.profile,接下来我们就可以使用go tool pprof来分析这两个性能数据文件,进而分析我们程序的CPU和内存性能了。下面,我来具体讲讲性能分析的过程。 + +性能分析 + +使用go tool pprof,来对性能进行分析的流程,你可以参考下图: + + + +接下来,我先给你介绍下pprof工具,再介绍下如何生成性能数据,最后再分别介绍下CPU和内存性能分析方法。 + +pprof工具介绍 + +pprof是一个Go程序性能分析工具,用它可以访问并分析性能数据文件,它还会根据我们的要求,提供高可读性的输出信息。Go在语言层面上集成了profile采样工具,只需在代码中简单地引入runtime/pprof或者net/http/pprof包,即可获取程序的profile文件,并通过profile文件来进行性能分析。 + +net/http/pprof基于runtime/pprof包进行封装,并在 HTTP 端口上暴露出来。 + +生成性能数据 + +我们在做性能分析时,主要是对内存和CPU性能进行分析。为了分析内存和CPU的性能,我们需要先生成性能数据文件。在 IAM 源码中,也有包含性能测试的用例,下面我会借助 IAM 源码中的性能测试用例,来介绍如何分析程序的性能。 + +进入internal/apiserver/service/v1目录,user_test.go文件包含了性能测试函数 BenchmarkListUser,执行以下命令来生成性能数据文件: + +$ go test -benchtime=30s -benchmem -bench=".*" -cpuprofile cpu.profile -memprofile mem.profile +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/iam/internal/apiserver/service/v1 +cpu: AMD EPYC Processor +BenchmarkListUser-8 175 204523677 ns/op 15331 B/op 268 allocs/op +PASS +ok github.com/marmotedu/iam/internal/apiserver/service/v1 56.514s + + +上面的命令会在当前目录下产生cpu.profile、mem.profile性能数据文件,以及v1.test二进制文件。接下来,我们基于cpu.profile、mem.profile、v1.test文件来分析代码的CPU和内存性能。为了获取足够的采样数据,我们将benchmark时间设置为30s。 + +在做性能分析时,我们可以采取不同的手段来分析性能,比如分析采样图、分析火焰图,还可以使用go tool pprof交互模式,查看函数CPU和内存消耗数据。下面我会运用这些方法,来分析CPU性能和内存性能。 + +CPU性能分析 + +在默认情况下,Go语言的运行时系统会以100 Hz的的频率对CPU使用情况进行采样,也就是说每秒采样100次,每10毫秒采样一次。每次采样时,会记录正在运行的函数,并统计其运行时间,从而生成CPU性能数据。 + +上面我们已经生成了CPU性能数据文件cpu.profile,接下来会运用上面提到的三种方法来分析该性能文件,优化性能。 + +方法一:分析采样图 + +要分析性能,最直观的方式当然是看图,所以首先我们需要生成采样图,生成过程可以分为两个步骤。 + +第一步,确保系统安装了graphviz: + +$ sudo yum -y install graphviz.x86_64 + + +第二步,执行go tool pprof生成调用图: + +$ go tool pprof -svg cpu.profile > cpu.svg # svg 格式 +$ go tool pprof -pdf cpu.profile > cpu.pdf # pdf 格式 +$ go tool pprof -png cpu.profile > cpu.png # png 格式 + + +以上命令会生成cpu.pdf、cpu.svg和cpu.png文件,文件中绘制了函数调用关系以及其他采样数据。如下图所示: + + + +这张图片由有向线段和矩形组成。我们先来看有向线段的含义。 + +有向线段描述了函数的调用关系,矩形包含了CPU采样数据。从图中,我们看到没箭头的一端调用了有箭头的一端,可以知道v1.(*userService).List函数调用了fake.(*policies).List。 + +线段旁边的数字90ms则说明,v1.(*userService).List调用fake.(*policies).List函数,在采样周期内,一共耗用了90ms。通过函数调用关系,我们可以知道某个函数调用了哪些函数,并且调用这些函数耗时多久。 + +这里,我们再次解读下图中调用关系中的重要信息: + + + +runtime.schedule的累积采样时间(140ms)中,有10ms来自于runtime.goschedImpl函数的直接调用,有70ms来自于runtime.park_m函数的直接调用。这些数据可以说明runtime.schedule函数分别被哪些函数调用,并且调用频率有多大。也因为这个原因,函数runtime.goschedImpl对函数runtime.schedule的调用时间必定小于等于函数runtime.schedule的累积采样时间。 + +我们再来看下矩形里的采样数据。这些矩形基本都包含了3类信息: + + +函数名/方法名,该类信息包含了包名、结构体名、函数名/方法名,方便我们快速定位到函数/方法,例如fake(*policies)List说明是fake包,policies结构体的List方法。 +本地采样时间,以及它在采样总数中所占的比例。本地采样时间是指采样点落在该函数中的总时间。 +累积采样时间,以及它在采样总数中所占的比例。累积采样时间是指采样点落在该函数,以及被它直接或者间接调用的函数中的总时间。 + + +我们可以通过OutDir函数来解释本地采样时间和累积采样时间这两个概念。OutDir函数如下图所示: + + + +整个函数的执行耗时,我们可以认为是累积采样时间,包含了白色部分的代码耗时和红色部分的函数调用耗时。白色部分的代码耗时,可以认为是本地采样时间。 + +通过累积采样时间,我们可以知道函数的总调用时间,累积采样时间越大,说明调用它所花费的CPU时间越多。但你要注意,这并不一定说明这个函数本身是有问题的,也有可能是函数所调用的函数性能有瓶颈,这时候我们应该根据函数调用关系顺藤摸瓜,去寻找这个函数直接或间接调用的函数中最耗费CPU时间的那些。 + +如果函数的本地采样时间很大,就说明这个函数自身耗时(除去调用其他函数的耗时)很大,这时候需要我们分析这个函数自身的代码,而不是这个函数直接或者间接调用函数的代码。 + +采样图中,矩形框面积越大,说明这个函数的累积采样时间越大。那么,如果一个函数分析采样图中的矩形框面积很大,这时候我们就要认真分析了,因为很可能这个函数就有需要优化性能的地方。 + +方法二:分析火焰图 + +上面介绍的采样图,其实在分析性能的时候还不太直观,这里我们可以通过生成火焰图,来更直观地查看性能瓶颈。火焰图是由Brendan Gregg大师发明的专门用来把采样到的堆栈轨迹(Stack Trace)转化为直观图片显示的工具,因整张图看起来像一团跳动的火焰而得名。 + +go tool pprof提供了-http参数,可以使我们通过浏览器浏览采样图和火焰图。执行以下命令: + +$ go tool pprof -http="0.0.0.0:8081" v1.test cpu.profile + + +然后访问http://x.x.x.x:8081/(x.x.x.x是执行go tool pprof命令所在服务器的IP地址),则会在浏览器显示各类采样视图数据,如下图所示: + + + +上面的UI页面提供了不同的采样数据视图: + + +Top,类似于 linux top 的形式,从高到低排序。 +Graph,默认弹出来的就是该模式,也就是上一个图的那种带有调用关系的图。 +Flame Graph:pprof 火焰图。 +Peek:类似于 Top 也是从高到底的排序。 +Source:和交互命令式的那种一样,带有源码标注。 +Disassemble:显示所有的总量。 + + +接下来,我们主要来分析火焰图。在UI界面选择Flame Graph(VIEW -> Flame Graph),就会展示火焰图,如下图所示: + + + +火焰图主要有下面这几个特征: + + +每一列代表一个调用栈,每一个格子代表一个函数。 +纵轴展示了栈的深度,按照调用关系从上到下排列。最下面的格子代表采样时,正在占用CPU的函数。 +调用栈在横向会按照字母排序,并且同样的调用栈会做合并,所以一个格子的宽度越大,说明这个函数越可能是瓶颈。 +火焰图格子的颜色是随机的暖色调,方便区分各个调用信息。 + + +查看火焰图时,格子越宽的函数,就越可能存在性能问题,这时候,我们就可以分析该函数的代码,找出问题所在。 + +方法三:用go tool pprof交互模式查看详细数据 + +我们可以执行go tool pprof命令,来查看CPU的性能数据文件: + +$ go tool pprof v1.test cpu.profile +File: v1.test +Type: cpu +Time: Aug 17, 2021 at 2:17pm (CST) +Duration: 56.48s, Total samples = 440ms ( 0.78%) +Entering interactive mode (type "help" for commands, "o" for options) +(pprof) + + +go tool pprof输出了很多信息: + + +File,二进制可执行文件名称。 +Type,采样文件的类型,例如cpu、mem等。 +Time,生成采样文件的时间。 +Duration,程序执行时间。上面的例子中,程序总执行时间为37.43s,采样时间为42.37s。采样程序在采样时,会自动分配采样任务给多个核心,所以总采样时间可能会大于总执行时间。 +(pprof),命令行提示,表示当前在go tool的pprof工具命令行中,go tool还包括cgo、doc、pprof、trace等多种命令。 + + +执行go tool pprof命令后,会进入一个交互shell。在这个交互shell中,我们可以执行多个命令,最常用的命令有三个,如下表所示: + + + +我们在交互界面中执行top命令,可以查看性能样本数据: + +(pprof) top +Showing nodes accounting for 350ms, 79.55% of 440ms total +Showing top 10 nodes out of 47 + flat flat% sum% cum cum% + 110ms 25.00% 25.00% 110ms 25.00% runtime.futex + 70ms 15.91% 40.91% 90ms 20.45% github.com/marmotedu/iam/internal/apiserver/store/fake.(*policies).List + 40ms 9.09% 50.00% 40ms 9.09% runtime.epollwait + 40ms 9.09% 59.09% 180ms 40.91% runtime.findrunnable + 30ms 6.82% 65.91% 30ms 6.82% runtime.write1 + 20ms 4.55% 70.45% 30ms 6.82% runtime.notesleep + 10ms 2.27% 72.73% 100ms 22.73% github.com/marmotedu/iam/internal/apiserver/service/v1.(*userService).List + 10ms 2.27% 75.00% 10ms 2.27% runtime.checkTimers + 10ms 2.27% 77.27% 10ms 2.27% runtime.doaddtimer + 10ms 2.27% 79.55% 10ms 2.27% runtime.mallocgc + + +上面的输出中,每一行表示一个函数的信息。pprof程序中最重要的命令就是topN,这个命令用来显示profile文件中最靠前的N个样本(sample),top命令会输出多行信息,每一行代表一个函数的采样数据,默认按flat%排序。输出中,各列含义如下: + + +flat:采样点落在该函数中的总时间。 +flat%:采样点落在该函数中时间的百分比。 +sum%:前面所有行的flat%的累加值,也就是上一项的累积百分比。 +cum:采样点落在该函数中的,以及被它调用的函数中的总时间。 +cum%:采样点落在该函数中的,以及被它调用的函数中的总次数百分比。 +函数名。 + + +上面这些信息,可以告诉我们函数执行的时间和耗时排名,我们可以根据这些信息,来判断哪些函数可能有性能问题,或者哪些函数的性能可以进一步优化。 + +这里想提示下,如果执行的是go tool pprof mem.profile,那么上面的各字段意义是类似的,只不过这次不是时间而是内存分配大小(字节)。 + +执行top命令默认是按flat%排序的,在做性能分析时,我们需要先按照cum来排序,通过cum,我们可以直观地看到哪个函数总耗时最多,然后再参考该函数的本地采样时间和调用关系,来判断是该函数性能耗时多,还是它调用的函数耗时多。 + +执行top -cum输出如下: + +(pprof) top20 -cum +Showing nodes accounting for 280ms, 63.64% of 440ms total +Showing top 20 nodes out of 47 + flat flat% sum% cum cum% + 0 0% 0% 320ms 72.73% runtime.mcall + 0 0% 0% 320ms 72.73% runtime.park_m + 0 0% 0% 280ms 63.64% runtime.schedule + 40ms 9.09% 9.09% 180ms 40.91% runtime.findrunnable + 110ms 25.00% 34.09% 110ms 25.00% runtime.futex + 10ms 2.27% 36.36% 100ms 22.73% github.com/marmotedu/iam/internal/apiserver/service/v1.(*userService).List + 0 0% 36.36% 100ms 22.73% github.com/marmotedu/iam/internal/apiserver/service/v1.BenchmarkListUser + 0 0% 36.36% 100ms 22.73% runtime.futexwakeup + 0 0% 36.36% 100ms 22.73% runtime.notewakeup + 0 0% 36.36% 100ms 22.73% runtime.resetspinning + 0 0% 36.36% 100ms 22.73% runtime.startm + 0 0% 36.36% 100ms 22.73% runtime.wakep + 0 0% 36.36% 100ms 22.73% testing.(*B).launch + 0 0% 36.36% 100ms 22.73% testing.(*B).runN + 70ms 15.91% 52.27% 90ms 20.45% github.com/marmotedu/iam/internal/apiserver/store/fake.(*policies).List + 10ms 2.27% 54.55% 50ms 11.36% runtime.netpoll + 40ms 9.09% 63.64% 40ms 9.09% runtime.epollwait + 0 0% 63.64% 40ms 9.09% runtime.modtimer + 0 0% 63.64% 40ms 9.09% runtime.resetForSleep + 0 0% 63.64% 40ms 9.09% runtime.resettimer (inline) + + +从上面的输出可知,v1.BenchmarkListUser、testing.(*B).launch、testing.(*B).runN的本地采样时间占比分别为0%、0%、0%,但是三者的累积采样时间占比却比较高,分别为22.73%、22.73%、22.73%。 + +本地采样时间占比很小,但是累积采样时间占比很高,说明这3个函数耗时多是因为调用了其他函数,它们自身几乎没有耗时。根据采样图,我们可以看到函数的调用关系,具体如下图所示: + + + +从采样图中,可以知道最终v1.BenchmarkListUser调用了v1.(*userService).List函数。v1.(*userService).List函数是我们编写的函数,该函数的本地采样时间占比为2.27%,但是累积采样时间占比却高达22.73%,说明v1.(*userService).List调用其他函数耗用了大量的CPU时间。 + +再观察采样图,可以看出v1.(*userService).List耗时久是因为调用了fake.(*policies).List函数。我们也可以通过list命令查看函数内部的耗时情况: + + + +list userService.*List会列出userService结构体List方法内部代码的耗时情况,从上图也可以看到,u.store.Policies().List耗时最多。fake.(*policies).List的本地采样时间占比为15.91%,说明fake.(*policies).List函数本身可能存在瓶颈。走读fake.(*policies).List代码可知,该函数是查询数据库的函数,查询数据库会有延时。继续查看v1.(*userService).List代码,我们可以发现以下调用逻辑: + +func (u *userService) ListWithBadPerformance(ctx context.Context, opts metav1.ListOptions) (*v1.UserList, error) { + ... + for _, user := range users.Items { + policies, err := u.store.Policies().List(ctx, user.Name, metav1.ListOptions{}) + ... + }) + } + ... +} + + +我们在for循环中,串行调用了fake.(*policies).List函数,每一次循环都会调用有延时的fake.(*policies).List函数。多次调用,v1.(*userService).List函数的耗时自然会累加起来。 + +现在问题找到了,那我们怎么优化呢?你可以利用CPU多核特性,开启多个goroutine,这样我们的查询耗时就不是串行累加的,而是取决于最慢一次的fake.(*policies).List调用。优化后的v1.(*userService).List函数代码见internal/apiserver/service/v1/user.go。用同样的性能测试用例,测试优化后的函数,结果如下: + +$ go test -benchtime=30s -benchmem -bench=".*" -cpuprofile cpu.profile -memprofile mem.profile +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/iam/internal/apiserver/service/v1 +cpu: AMD EPYC Processor +BenchmarkListUser-8 8330 4271131 ns/op 26390 B/op 484 allocs/op +PASS +ok github.com/marmotedu/iam/internal/apiserver/service/v1 36.179s + + +上面的代码中,ns/op为4271131 ns/op,可以看到和第一次的测试结果204523677 ns/op相比,性能提升了97.91%。 + +这里注意下,为了方便你对照,我将优化前的v1.(*userService).List函数重命名为v1.(*userService).ListWithBadPerformance。 + +内存性能分析 + +Go语言运行时,系统会对程序运行期间的所有堆内存分配进行记录。不管在采样的哪一时刻,也不管堆内存已用字节数是否有增长,只要有字节被分配且数量足够,分析器就会对它进行采样。 + +内存性能分析方法和CPU性能分析方法比较类似,这里就不再重复介绍了。你可以借助前面生成的内存性能数据文件mem.profile自行分析。 + +接下来,给你展示下内存优化前和优化后的效果。在v1.(*userService).List函数(位于internal/apiserver/service/v1/user.go文件中)中,有以下代码: + +infos := make([]*v1.User, 0) +for _, user := range users.Items { + info, _ := m.Load(user.ID) + infos = append(infos, info.(*v1.User)) +} + + +此时,我们运行go test命令,测试下内存性能,作为优化后的性能数据,进行对比: + +$ go test -benchmem -bench=".*" -cpuprofile cpu.profile -memprofile mem.profile +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/iam/internal/apiserver/service/v1 +cpu: AMD EPYC Processor +BenchmarkListUser-8 278 4284660 ns/op 27101 B/op 491 allocs/op +PASS +ok github.com/marmotedu/iam/internal/apiserver/service/v1 1.779s + + +B/op和allocs/op分别为27101 B/op和491 allocs/op。 + +我们通过分析代码,发现可以将infos := make([]*v1.User, 0)优化为infos := make([]*v1.User, 0, len(users.Items)),来减少Go切片的内存重新分配的次数。优化后的代码为: + +//infos := make([]*v1.User, 0) +infos := make([]*v1.User, 0, len(users.Items)) +for _, user := range users.Items { + info, _ := m.Load(user.ID) + infos = append(infos, info.(*v1.User)) +} + + +再执行go test测试下性能: + +$ go test -benchmem -bench=".*" -cpuprofile cpu.profile -memprofile mem.profile +goos: linux +goarch: amd64 +pkg: github.com/marmotedu/iam/internal/apiserver/service/v1 +cpu: AMD EPYC Processor +BenchmarkListUser-8 276 4318472 ns/op 26457 B/op 484 allocs/op +PASS +ok github.com/marmotedu/iam/internal/apiserver/service/v1 1.856s + + +优化后的B/op和allocs/op分别为26457 B/op和484 allocs/op。跟第一次的27101 B/op和491 allocs/op相比,内存分配次数更少,每次分配的内存也更少。 + +我们可以执行go tool pprof命令,来查看CPU的性能数据文件: + +$ go tool pprof v1.test mem.profile +File: v1.test +Type: alloc_space +Time: Aug 17, 2021 at 8:33pm (CST) +Entering interactive mode (type "help" for commands, "o" for options) +(pprof) + + +该命令会进入一个交互界面,在交互界面中执行top命令,可以查看性能样本数据,例如: + +(pprof) top +Showing nodes accounting for 10347.32kB, 95.28% of 10859.34kB total +Showing top 10 nodes out of 52 + flat flat% sum% cum cum% + 3072.56kB 28.29% 28.29% 4096.64kB 37.72% github.com/marmotedu/iam/internal/apiserver/service/v1.(*userService).List.func1 + 1762.94kB 16.23% 44.53% 1762.94kB 16.23% runtime/pprof.StartCPUProfile + 1024.52kB 9.43% 53.96% 1024.52kB 9.43% go.uber.org/zap/buffer.NewPool.func1 + 1024.08kB 9.43% 63.39% 1024.08kB 9.43% time.Sleep + 902.59kB 8.31% 71.70% 902.59kB 8.31% compress/flate.NewWriter + 512.20kB 4.72% 76.42% 1536.72kB 14.15% github.com/marmotedu/iam/internal/apiserver/service/v1.(*userService).List + 512.19kB 4.72% 81.14% 512.19kB 4.72% runtime.malg + 512.12kB 4.72% 85.85% 512.12kB 4.72% regexp.makeOnePass + 512.09kB 4.72% 90.57% 512.09kB 4.72% github.com/marmotedu/iam/internal/apiserver/store/fake.FakeUsers + 512.04kB 4.72% 95.28% 512.04kB 4.72% runtime/pprof.allFrames + + +上面的内存性能数据,各字段的含义依次是: + + +flat,采样点落在该函数中的总内存消耗。 +flat% ,采样点落在该函数中的百分比。 +sum% ,上一项的累积百分比。 +cum ,采样点落在该函数,以及被它调用的函数中的总内存消耗。 +cum%,采样点落在该函数,以及被它调用的函数中的总次数百分比。 +函数名。 + + +总结 + +在Go项目开发中,程序性能低下时,我们需要分析出问题所在的代码。Go语言提供的 go tool pprof 工具可以支持我们分析代码的性能。我们可以通过两步来分析代码的性能,分别是生成性能数据文件和分析性能数据文件。 + +Go中可以用来生成性能数据文件的方式有三种:通过命令行生成性能数据文件、通过代码生成性能数据文件、通过 net/http/pprof 生成性能数据文件。 + +生成性能数据文件之后,就可以使用 go tool pprof 工具来分析性能数据文件了。我们可以分别获取到CPU和内存的性能数据,通过分析就可以找到性能瓶颈。有3种分析性能数据文件的方式,分别是分析采样图、分析火焰图和用 go tool pprof 交互模式查看详细数据。因为火焰图直观高效,所以我建议你多使用火焰图来分析性能。 + +课后练习 + + +思考下,为什么“函数runtime.goschedImpl对函数runtime.schedule的调用时间必定小于等于函数runtime.schedule的累积采样时间”? +你在Go项目开发中,还有哪些比较好的性能分析思路和方法?欢迎在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/39性能分析(下):APIServer性能测试和调优实战.md b/专栏/Go语言项目开发实战/39性能分析(下):APIServer性能测试和调优实战.md new file mode 100644 index 0000000..51816a2 --- /dev/null +++ b/专栏/Go语言项目开发实战/39性能分析(下):APIServer性能测试和调优实战.md @@ -0,0 +1,468 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 39 性能分析(下):API Server性能测试和调优实战 + 你好,我是孔令飞。 + +上一讲,我们学习了如何分析Go代码的性能。掌握了性能分析的基本知识之后,这一讲,我们再来看下如何分析API接口的性能。 + +在API上线之前,我们需要知道API的性能,以便知道API服务器所能承载的最大请求量、性能瓶颈,再根据业务对性能的要求,来对API进行性能调优或者扩缩容。通过这些,可以使API稳定地对外提供服务,并且让请求在合理的时间内返回。这一讲,我就介绍如何用wrk工具来测试API Server接口的性能,并给出分析方法和结果。 + +API性能测试指标 + +API性能测试,往大了说其实包括API框架的性能和指定API的性能。不过,因为指定API的性能跟该API具体的实现(比如有无数据库连接,有无复杂的逻辑处理等)有关,我认为脱离了具体实现来探讨单个API的性能是毫无意义的,所以这一讲只探讨API框架的性能。 + +用来衡量API性能的指标主要有3个: + + +并发数(Concurrent):并发数是指某个时间范围内,同时在使用系统的用户个数。广义上的并发数是指同时使用系统的用户个数,这些用户可能调用不同的API;严格意义上的并发数是指同时请求同一个API的用户个数。这一讲我们讨论的并发数是严格意义上的并发数。 +每秒查询数(QPS):每秒查询数QPS是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。QPS = 并发数 / 平均请求响应时间。 +请求响应时间(TTLB):请求响应时间指的是从客户端发出请求到得到响应的整个时间。这个过程从客户端发起的一个请求开始,到客户端收到服务器端的响应结束。在一些工具中,请求响应时间通常会被称为TTLB(Time to last byte,意思是从发送一个请求开始,到客户端收到最后一个字节的响应为止所消费的时间)。请求响应时间的单位一般为“秒”或“毫秒”。 + + +这三个指标中,衡量API性能的最主要指标是QPS,但是在说明QPS时,需要指明是多少并发数下的QPS,否则毫无意义,因为不同并发数下的QPS是不同的。举个例子,单用户100 QPS和100用户100 QPS是两个不同的概念,前者说明API可以在一秒内串行执行100个请求,而后者说明在并发数为100的情况下,API可以在一秒内处理100个请求。当QPS相同时,并发数越大,说明API性能越好,并发处理能力越强。 + +在并发数设置过大时,API同时要处理很多请求,会频繁切换上下文,而真正用于处理请求的时间变少,反而使得QPS会降低。并发数设置过大时,请求响应时间也会变长。API会有一个合适的并发数,在该并发数下,API的QPS可以达到最大,但该并发数不一定是最佳并发数,还要参考该并发数下的平均请求响应时间。 + +此外,在有些API接口中,也会测试API接口的TPS(Transactions Per Second,每秒事务数)。一个事务是指客户端向服务器发送请求,然后服务器做出反应的过程。客户端在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数。 + +那么,TPS和QPS有什么区别呢?如果是对一个查询接口(单场景)压测,且这个接口内部不会再去请求其他接口,那么TPS=QPS,否则,TPS≠QPS。如果是对多个接口(混合场景)压测,假设N个接口都是查询接口,且这个接口内部不会再去请求其他接口,QPS=N*TPS。 + +API性能测试方法 + +Linux下有很多Web性能测试工具,常用的有Jmeter、AB、Webbench和wrk。每个工具都有自己的特点,IAM项目使用wrk来对API进行性能测试。wrk非常简单,安装方便,测试结果也相对专业,并且可以支持Lua脚本来创建更复杂的测试场景。下面,我来介绍下wrk的安装方法和使用方法。 + +wrk安装方法 + +wrk的安装很简单,一共可分为两步。 + +第一步,Clone wrk repo: + +$ git clone https://github.com/wg/wrk + + +第二步,编译并安装: + +$ cd wrk +$ make +$ sudo cp ./wrk /usr/bin + + +wrk使用简介 + +这里我们来看下wrk的使用方法。wrk使用起来不复杂,执行wrk --help可以看到wrk的所有运行参数: + +$ wrk --help +Usage: wrk + Options: + -c, --connections Connections to keep open + -d, --duration Duration of test + -t, --threads Number of threads to use + + -s, --script Load Lua script file + -H, --header Add header to request + --latency Print latency statistics + --timeout Socket/request timeout + -v, --version Print version details + + Numeric arguments may include a SI unit (1k, 1M, 1G) + Time arguments may include a time unit (2s, 2m, 2h) + + +常用的参数有下面这些: + + +-t,线程数(线程数不要太多,是核数的2到4倍就行,多了反而会因为线程切换过多造成效率降低)。 +-c,并发数。 +-d,测试的持续时间,默认为10s。 +-T,请求超时时间。 +-H,指定请求的HTTP Header,有些API需要传入一些Header,可通过wrk的-H参数来传入。 +–latency,打印响应时间分布。 +-s,指定Lua脚本,Lua脚本可以实现更复杂的请求。 + + +然后,我们来看一个wrk的测试结果,并对结果进行解析。 + +一个简单的测试如下(确保iam-apiserver已经启动,并且开启了健康检查): + +$ wrk -t144 -c30000 -d30s -T30s --latency http://10.0.4.57:8080/healthz +Running 30s test @ http://10.0.4.57:8080/healthz + 144 threads and 30000 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 508.77ms 604.01ms 9.27s 81.59% + Req/Sec 772.48 0.94k 10.45k 86.82% + Latency Distribution + 50% 413.35ms + 75% 948.99ms + 90% 1.33s + 99% 2.44s + 2276265 requests in 30.10s, 412.45MB read + Socket errors: connect 1754, read 40, write 0, timeout 0 +Requests/sec: 75613.16 +Transfer/sec: 13.70MB + + +下面是对测试结果的解析。 + + +144 threads and 30000 connections:用144个线程模拟20000个连接,分别对应-t和-c参数。 +Thread Stats是线程统计,包括Latency和Req/Sec。 + + +Latency:响应时间,有平均值、标准偏差、最大值、正负一个标准差占比。 +Req/Sec:每个线程每秒完成的请求数, 同样有平均值、标准偏差、最大值、正负一个标准差占比。 + +Latency Distribution是响应时间分布。 + + +50%:50%的响应时间为413.35ms。 +75%:75%的响应时间为948.99ms。 +90%:90%的响应时间为1.33s。 +99%:99%的响应时间为2.44s。 + +2276265 requests in 30.10s, 412.45MB read:30.10s完成的总请求数(2276265)和数据读取量(412.45MB)。 +Socket errors: connect 1754, read 40, write 0, timeout 0:错误统计,会统计connect连接失败请求个数(1754)、读失败请求个数、写失败请求个数、超时请求个数。 +Requests/sec:QPS。 +Transfer/sec:平均每秒读取13.70MB数据(吞吐量)。 + + +API Server性能测试实践 + +接下来,我们就来测试下API Server的性能。影响API Server性能的因素有很多,除了iam-apiserver自身的原因之外,服务器的硬件和配置、测试方法、网络环境等都会影响。为了方便你对照性能测试结果,我给出了我的测试环境配置,你可以参考下。 + + +客户端硬件配置:1核4G。 +客户端软件配置:干净的CentOS Linux release 8.2.2004 (Core)。 +服务端硬件配置:2核8G。 +服务端软件配置:干净的CentOS Linux release 8.2.2004 (Core)。 +测试网络环境:腾讯云VPC内访问,除了性能测试程序外,没有其他资源消耗型业务程序。 + + +测试架构如下图所示: + + + +性能测试脚本介绍 + +在做API Server的性能测试时,需要先执行wrk,生成性能测试数据。为了能够更直观地查看性能数据,我们还需要以图表的方式展示这些性能数据。这一讲,我使用 gnuplot 工具来自动化地绘制这些性能图,为此我们需要确保Linux服务器已经安装了 gnuplot 工具。你可以通过以下方式安装: + +$ sudo yum -y install gnuplot + + +在这一讲的测试中,我会绘制下面这两张图,通过它们来观测和分析API Server的性能。 + + +QPS & TTLB图:X轴为并发数(Concurrent),Y轴为每秒查询数(QPS)和请求响应时间(TTLB)。 +成功率图:X轴为并发数(Concurrent),Y轴为请求成功率。 + + +为了方便你测试API接口性能,我将性能测试和绘图逻辑封装在scripts/wrktest.sh脚本中,你可以在iam源码根目录下执行如下命令,生成性能测试数据和性能图表: + +$ scripts/wrktest.sh http://10.0.4.57:8080/healthz + + +上面的命令会执行性能测试,记录性能测试数据,并根据这些性能测试数据绘制出QPS和成功率图。 + +接下来,我再来介绍下wrktest.sh性能测试脚本,并给出一个使用示例。 + +wrktest.sh性能测试脚本,用来测试API Server的性能,记录测试的性能数据,并根据性能数据使用gnuplot绘制性能图。 + +wrktest.sh也可以对比前后两次的性能测试结果,并将对比结果通过图表展示出来。wrktest.sh会根据CPU的核数自动计算出适合的wrk启动线程数(-t):CPU核数 * 3。 + +wrktest.sh默认会测试多个并发下的API性能,默认测试的并发数为200 500 1000 3000 5000 10000 15000 20000 25000 50000。你需要根据自己的服务器配置选择测试的最大并发数,我因为服务器配置不高(主要是8G内存在高并发下,很容易就耗尽),最大并发数选择了50000。如果你的服务器配置够高,可以再依次尝试下测试 100000 、200000 、500000 、1000000 并发下的API性能。 + +wrktest.sh的使用方法如下: + +$ scripts/wrktest.sh -h + +Usage: scripts/wrktest.sh [OPTION] [diff] URL +Performance automation test script. + + URL HTTP request url, like: http://10.0.4.57:8080/healthz + diff Compare two performance test results + +OPTIONS: + -h Usage information + -n Performance test task name, default: apiserver + -d Directory used to store performance data and gnuplot graphic, default: _output/wrk + +Reprot bugs to <[email protected]>. + + +wrktest.sh提供的命令行参数介绍如下。 + + +URL:需要测试的API接口。 +diff:如果比较两次测试的结果,需要执行wrktest.sh diff 。 +-n:本次测试的任务名,wrktest.sh会根据任务名命名生成的文件。 +-d:输出文件存放目录。 +-h:打印帮助信息。 + + +下面,我来展示一个wrktest.sh使用示例。 + +wrktest.sh的主要功能有两个,分别是运行性能测试并获取结果和对比性能测试结果。下面我就分别介绍下它们的具体使用方法。 + + +运行性能测试并获取结果 + + +执行如下命令: + +$ scripts/wrktest.sh http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 200 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 500 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 1000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 3000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 5000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 10000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 15000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 20000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 25000 http://10.0.4.57:8080/healthz +Running wrk command: wrk -t3 -d300s -T30s --latency -c 50000 http://10.0.4.57:8080/healthz + +Now plot according to /home/colin/_output/wrk/apiserver.dat +QPS graphic file is: /home/colin/_output/wrk/apiserver_qps_ttlb.png +Success rate graphic file is: /home/colin/_output/wrk/apiserver_successrate.pngz + + +上面的命令默认会在_output/wrk/目录下生成3个文件: + + +apiserver.dat,wrk性能测试结果,每列含义分别为并发数、QPS 平均响应时间、成功率。 +apiserver_qps_ttlb.png,QPS&TTLB图。 +apiserver_successrate.png,成功率图。 + + +这里要注意,请求URL中的IP地址应该是腾讯云VPC内网地址,因为通过内网访问,不仅网络延时最低,而且还最安全,所以真实的业务通常都是内网访问的。 + + +对比性能测试结果 + + +假设我们还有另外一次API性能测试,测试数据保存在 _output/wrk/http.dat 文件中。 + +执行如下命令,对比两次测试结果: + +$ scripts/wrktest.sh diff _output/wrk/apiserver.dat _output/wrk/http.dat + + +apiserver.dat和http.dat是两个需要对比的Wrk性能数据文件。上述命令默认会在_output/wrk目录下生成下面这两个文件: + + +apiserver_http.qps.ttlb.diff.png,QPS & TTLB对比图。 +apiserver_http.success_rate.diff.png,成功率对比图。 + + +关闭Debug配置选项 + +在测试之前,我们需要关闭一些Debug选项,以免影响性能测试。 + +执行下面这两步操作,修改iam-apiserver的配置文件: + + +将server.mode设置为release,server.middlewares去掉dump、logger中间件。 +将log.level设置为info,log.output-paths去掉stdout。 + + +因为我们要在执行压力测试时分析程序的性能,所以需要设置feature.profiling为true,以开启性能分析。修改完之后,重新启动iam-apiserver。 + +使用wrktest.sh测试IAM API接口性能 + +关闭Debug配置选项之后,就可以执行wrktest.sh命令测试API性能了(默认测试的并发数为200 500 1000 3000 5000 10000 15000 20000 25000 50000): + +$ scripts/wrktest.sh http://10.0.4.57:8080/healthz + + +生成的QPS & TTLB图和成功率图分别如下图所示: + + + +上图中,X轴为并发数(Concurrent),Y轴为每秒查询数(QPS)和请求响应时间(TTLB)。 + + + +上图中,X轴为并发数(Concurrent),Y轴为请求成功率。 + +通过上面两张图,你可以看到,API Server在并发数为200时,QPS最大;并发数为500,平均响应时间为56.33ms,成功率为 100.00% 。在并发数达到1000时,成功率开始下降。一些详细数据从图里看不到,你可以直接查看apiserver.dat文件,里面记录了每个并发下具体的QPS、TTLB和成功率数据。 + +现在我们有了API Server的性能数据,那么该API Server的QPS处于什么水平呢?一方面,你可以根据自己的业务需要来对比;另一方面,可以和性能更好的Web框架进行对比,总之需要有个参照。 + +这里用net/http构建最简单的HTTP服务器,使用相同的测试工具和测试服务器,测试性能并作对比。HTTP服务源码为(位于文件tools/httptest/main.go中): + +package main + +import ( + "fmt" + "log" + "net/http" +) + +func main() { + http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + message := `{"status":"ok"}` + fmt.Fprint(w, message) + }) + + addr := ":6667" + fmt.Printf("Serving http service on %s\n", addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} + + +我们将上述HTTP服务的请求路径设置为/healthz,并且返回{"status":"ok"},跟API Server的接口返回数据完全一样。通过这种方式,你可以排除因为返回数据大小不同而造成的性能差异。 + +可以看到,该HTTP服务器很简单,只是利用net/http包最原生的功能,在Go中几乎所有的Web框架都是基于net/http包封装的。既然是封装,肯定比不上原生的性能,所以我们要把它跟用net/http直接启动的HTTP服务接口的性能进行对比,来衡量我们的API Server性能。 + +我们需要执行相同的wrk测试,并将结果跟API Server的测试结果进行对比,将对比结果绘制成对比图。具体对比过程可以分为3步。 + +第一步,启动HTTP服务。 + +在iam源码根目录下执行如下命令: + +$ go run tools/httptest/main.go + + +第二步,执行wrktest.sh脚本,测试该HTTP服务的性能: + +$ scripts/wrktest.sh -n http http://10.0.4.57:6667/healthz + + +上述命令会生成 _output/wrk/http.dat 文件。 + +第三步,对比两次性能测试数据: + +$ scripts/wrktest.sh diff _output/wrk/apiserver.dat _output/wrk/http.dat + + +生成的两张对比图表,如下所示: + + + + + +通过上面两张对比图,我们可以看出,API Server在QPS、响应时间和成功率上都不如原生的HTTP Server,特别是QPS,最大QPS只有原生HTTP Server 最大QPS的13.68%,性能需要调优。 + +API Server性能分析 + +上面,我们测试了API接口的性能,如果性能不合预期,我们还需要分析性能数据,并优化性能。 + +在分析前我们需要对API Server加压,在加压的情况下,API接口的性能才更可能暴露出来,所以继续执行如下命令: + +$ scripts/wrktest.sh http://10.0.4.57:8080/healthz + + +在上述命令执行压力测试期间,可以打开另外一个Linux终端,使用go tool pprof工具分析HTTP的profile文件: + +$ go tool pprof http://10.0.4.57:8080/debug/pprof/profile + + +执行完go tool pprof后,因为需要采集性能数据,所以该命令会阻塞30s。 + +在pprof交互shell中,执行top -cum查看累积采样时间,我们执行top30 -cum,多观察一些函数: + +(pprof) top20 -cum +Showing nodes accounting for 32.12s, 39.62% of 81.07s total +Dropped 473 nodes (cum <= 0.41s) +Showing top 20 nodes out of 167 +(pprof) top30 -cum +Showing nodes accounting for 11.82s, 20.32% of 58.16s total +Dropped 632 nodes (cum <= 0.29s) +Showing top 30 nodes out of 239 + flat flat% sum% cum cum% + 0.10s 0.17% 0.17% 51.59s 88.70% net/http.(*conn).serve + 0.01s 0.017% 0.19% 42.86s 73.69% net/http.serverHandler.ServeHTTP + 0.04s 0.069% 0.26% 42.83s 73.64% github.com/gin-gonic/gin.(*Engine).ServeHTTP + 0.01s 0.017% 0.28% 42.67s 73.37% github.com/gin-gonic/gin.(*Engine).handleHTTPRequest + 0.08s 0.14% 0.41% 42.59s 73.23% github.com/gin-gonic/gin.(*Context).Next (inline) + 0.03s 0.052% 0.46% 42.58s 73.21% .../internal/pkg/middleware.RequestID.func1 + 0 0% 0.46% 41.02s 70.53% .../internal/pkg/middleware.Context.func1 + 0.01s 0.017% 0.48% 40.97s 70.44% github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1 + 0.03s 0.052% 0.53% 40.95s 70.41% .../internal/pkg/middleware.LoggerWithConfig.func1 + 0.01s 0.017% 0.55% 33.46s 57.53% .../internal/pkg/middleware.NoCache + 0.08s 0.14% 0.69% 32.58s 56.02% github.com/tpkeeper/gin-dump.DumpWithOptions.func1 + 0.03s 0.052% 0.74% 24.73s 42.52% github.com/tpkeeper/gin-dump.FormatToBeautifulJson + 0.02s 0.034% 0.77% 22.73s 39.08% github.com/tpkeeper/gin-dump.BeautifyJsonBytes + 0.08s 0.14% 0.91% 16.39s 28.18% github.com/tpkeeper/gin-dump.format + 0.21s 0.36% 1.27% 16.38s 28.16% github.com/tpkeeper/gin-dump.formatMap + 3.75s 6.45% 7.72% 13.71s 23.57% runtime.mallocgc + ... + + +因为top30内容过多,这里只粘贴了耗时最多的一些关联函数。从上面的列表中,可以看到有ServeHTTP类的函数,这些函数是gin/http自带的函数,我们无需对此进行优化。 + +还有这样一些函数: + +.../gin.(*Context).Next (inline) +.../internal/pkg/middleware.RequestID.func1 +.../internal/pkg/middleware.Context.func1 +github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1 +.../internal/pkg/middleware.LoggerWithConfig.func1 +.../internal/pkg/middleware.NoCache +github.com/tpkeeper/gin-dump.DumpWithOptions.func1 + + +可以看到,middleware.RequestID.func1、middleware.Context.func1、gin.CustomRecoveryWithWriter.func1、middleware.LoggerWithConfig.func1等,这些耗时较久的函数都是我们加载的Gin中间件。这些中间件消耗了大量的CPU时间,所以我们可以选择性加载这些中间件,删除一些不需要的中间件,来优化API Server的性能。- +假如我们暂时不需要这些中间件,也可以通过配置iam-apiserver的配置文件,将server.middlewares设置为空或者注释掉,然后重启iam-apiserver。重启后,再次执行wrktest.sh测试性能,并跟原生的HTTP Server性能进行对比,对比结果如下面2张图所示: + + + + + +可以看到,删除无用的Gin中间件后,API Server的性能有了很大的提升,并发数为200时性能最好,此时QPS为47812,响应时间为4.33ms,成功率为100.00%。在并发数为50000的时候,其QPS是原生HTTP Server的75.02%。 + +API接口性能参考 + +不同团队对API接口的性能要求不同,同一团队对每个API接口的性能要求也不同,所以并没有一个统一的数值标准来衡量API接口的性能,但可以肯定的是,性能越高越好。我根据自己的研发经验,在这里给出一个参考值(并发数可根据需要选择),如下表所示: + + + +API Server性能测试注意事项 + +在进行API Server性能测试时,要考虑到API Server的性能影响因素。影响API Server性能的因素很多,大致可以分为两类,分别是Web框架的性能和API接口的性能。另外,在做性能测试时,还需要确保测试环境是一致的,最好是一个干净的测试环境。 + +Web框架性能 + +Web框架的性能至关重要,因为它会影响我们的每一个API接口的性能。 + +在设计阶段,我们会确定所使用的Web框架,这时候我们需要对Web框架有个初步的测试,确保我们选择的Web框架在性能和稳定性上都足够优秀。当整个Go后端服务开发完成之后,在上线之前,我们还需要对Web框架再次进行测试,确保按照我们最终的使用方式,Web框架仍然能够保持优秀的性能和稳定性。 + +我们通常会通过API接口来测试Web框架的性能,例如健康检查接口/healthz。我们需要保证该API接口足够简单,API接口里面不应该掺杂任何逻辑,只需要象征性地返回一个很小的返回内容即可。比如,这一讲中我们通过/healthz接口来测试Web框架的性能: + +s.GET("/healthz", func(c *gin.Context) { + core.WriteResponse(c, nil, map[string]string{"status": "ok"}) +}) + + +接口中只调用了core.WriteResponse函数,返回了{"status":"ok"}。这里使用core.WriteResponse函数返回请求数据,而不是直接返回ok字符串,这样做是为了保持API接口返回格式统一。 + +API接口性能 + +除了测试Web框架的性能,我们还可能需要测试某些重要的API接口,甚至所有API接口的性能。为了测试API接口在真实场景下的接口性能,我们会使用wrk这类HTTP压力测试工具,来模拟多个API请求,进而分析API的性能。 + +因为会模拟大量的请求,这时候测试写类接口,例如Create、Update、Delete等会存在一些问题,比如可能在数据库中插入了很多数据,导致磁盘空间被写满或者数据库被压爆。所以,针对写类接口,我们可以借助单元测试,来测试其性能。根据我的开发经验,写类接口通常不会有性能问题,反而读类接口更可能遇到性能问题。针对读类接口,我们可以使用wrk这类HTTP压力测试工具来进行测试。 + +测试环境 + +在做性能/压力测试时,为了不影响生产环境,要确保在测试环境进行压测,并且测试环境的网络不能影响到生产环境的网络。另外,为了更好地进行性能对比和分析,也要保证我们的测试方法和测试环境是一致的。这就要求我们最好将性能测试自动化,并且每次在同一个测试环境进行测试。 + +总结 + +在项目上线前,我们需要对API接口进行性能测试。通常API接口的性能延时要小于 500ms ,如果大于这个值,需要考虑优化性能。在进行性能测试时,需要确保每次测试都有一个一致的测试环境,这样不同测试之间的数据才具有可对比性。这一讲中,我推荐了一个比较优秀的性能测试工具 wrk ,我们可以编写shell脚本,将wrk的性能测试数据自动绘制成图,方便我们查看、对比性能。 + +如果发现API接口的性能不符合预期,我们可以借助 go tool pprof 工具来分析性能。在 go tool pprof 交互界面,执行 top -cum 命令查看累积采样时间,根据累积采样时间确定影响性能的代码,并优化代码。优化后,再进行测试,如果不满足,继续分析API接口的性能。如此往复,直到API接口的性能满足预期为止。 + +课后练习 + + +选择一个项目,并使用wrktest.sh脚本测试其API接口,分析并优化API接口的性能。 +思考下,在你的工作中,还有没有其他好的API接口性能分析方法,欢迎在留言区分享讨论。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/40软件部署实战(上):部署方案及负载均衡、高可用组件介绍.md b/专栏/Go语言项目开发实战/40软件部署实战(上):部署方案及负载均衡、高可用组件介绍.md new file mode 100644 index 0000000..334bd21 --- /dev/null +++ b/专栏/Go语言项目开发实战/40软件部署实战(上):部署方案及负载均衡、高可用组件介绍.md @@ -0,0 +1,294 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 40 软件部署实战(上):部署方案及负载均衡、高可用组件介绍 + 你好,我是孔令飞。 + +接下来,我们就进入到这门课的最后一个模块,服务部署部分的学习。在这一模块中,我会带着你一步一步地部署一个生产级可用的IAM应用。 + +在 03讲 中,我们快速在单机上部署了IAM系统,但这样的系统缺少高可用、弹性扩容等能力,是很脆弱的,遇到流量波峰、发布变更很容易出问题。在系统真正上线前,我们需要重新调整部署架构,来保证我们的系统具有负载均衡、高可用、弹性伸缩等核心运维能力。 + +考虑到你手中的系统资源有限,这一模块会尽量简单地展示如何部署一个相对高可用的IAM系统。按照我讲的部署方法,基本上可以上线一个中小型的系统。 + +在这一模块中,我会介绍两种部署方式。 + +第一种是传统的部署方式,基于物理机/虚拟机来部署,容灾、弹性伸缩能力要部署人员自己实现。第二种是容器化部署方式,基于Docker、Kubernetes来部署,容灾、弹性伸缩等能力,可以借助Kubernetes自带的能力来实现。 + +接下来的三讲,我们先来看下传统的部署方式,也就是如何基于虚拟机来部署IAM应用。今天我主要讲跟IAM部署相关的两个组件,Nginx + Keepalived的相关功能。 + +部署方案 + +先来整体看下我们的部署方案。 + +这里,我采用Nginx + Keepalived来部署一个高可用的架构,同时将组件都部署在内网,来保证服务的安全和性能。 + +部署需要两台物理机/虚拟机,组件之间通过内网访问。所需的服务器如下表所示: + + + +两台服务器均为腾讯云CVM,VIP(Virtual IP,虚拟IP)为10.0.4.99。部署架构如下图所示: + + + +这里我来具体介绍下图中的部署架构。部署采用的这两台CVM服务器,一主一备,它们共享同一个VIP。同一时刻,VIP只在一台主设备上生效,当主服务器出现故障时,备用服务器会自动接管VIP,继续提供服务。 + +主服务器上部署了iam-apiserver、iam-authz-server、iam-pump和数据库mongodb、redis、mysql。备服务器部署了iam-apiserver、iam-authz-server和iam-pump。备服务器中的组件通过内网10.0.4.20访问主服务器中的数据库组件。 + +主备服务器同时安装了Keepalived和Nginx,通过Nginx的反向代理功能和负载均衡功能,实现后端服务iam-apiserver和iam-authz-server的高可用,通过Keepalived实现Nginx的高可用。 + +我们通过给虚拟IP绑定腾讯云弹性公网IP,从而使客户端可以通过外网IP访问内网的Nginx服务器(443端口),如果想通过域名访问内网,还可以申请域名指向该弹性公网IP。 + +通过以上部署方案,我们可以实现一个具有较高可用性的IAM系统,它主要具备下面这几个能力。 + + +高性能:可以通过Nginx的负载均衡功能,水平扩容IAM服务,从而实现高性能。 +具备容灾能力:通过Nginx实现IAM服务的高可用,通过Keepalived实现Nginx的高可用,从而实现核心组件的高可用。 +具备水平扩容能力:通过Nginx的负载均衡功能,实现IAM服务的水平扩容。 +高安全性:将所有组件部署在内网,客户端只能通过VIP:443端口访问Nginx服务,并且通过开启TLS认证和JWT认证,保障服务有一个比较高的安全性。因为是腾讯云CVM,所以也可以借助腾讯云的能力再次提高服务器的安全性,比如安全组、DDoS防护、主机安全防护、云监控、云防火墙等。 + + +这里说明下,为了简化IAM应用的安装配置过程,方便你上手实操,有些能力,例如数据库高可用、进程监控和告警、自动伸缩等能力的构建方式,这里没有涉及到。这些能力的构建方式,你可以在日后的工作中慢慢学习和掌握。 + +接下来,我们看下这个部署方案中用到的两个核心组件,Nginx和Keepalived。我会介绍下它们的安装和配置方法,为你下一讲的学习做准备。 + +Nginx安装和配置 + +Nginx功能简介 + +这里先简单介绍下Nginx。Nginx是一个轻量级、高性能、开源的HTTP服务器和反向代理服务器。IAM系统使用了Nginx反向代理和负载均衡的功能,下面我就来分别介绍下。 + +为什么需要反向代理呢?在实际的生产环境中,服务部署的网络(内网)跟外部网络(外网)通常是不通的,这就需要一台既能够访问内网又能够访问外网的服务器来做中转,这种服务器就是反向代理服务器。Nginx作为反向代理服务器,简单的配置如下: + +server { + listen 80; + server_name iam.marmotedu.com; + client_max_body_size 1024M; + + location / { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8080/; + client_max_body_size 100m; + } +} + + +Nginx的反向代理功能,能够根据不同的配置规则转发到不同的后端服务器上。假如我们在IP为x.x.x.x的服务器上,用上面说的Nginx配置启动Nginx,当我们访问http://x.x.x.x:80/时,会将请求转发到http://127.0.0.1:8080/。listen 80指定了Nginx服务器的监听端口,proxy_pass http://127.0.0.1:8080/则指定了转发路径。 + +Nginx另一个常用的功能是七层负载均衡。所谓的负载均衡,就是指当Nginx收到一个HTTP请求后,会根据负载策略将请求转发到不同的后端服务器上。比如iam-apiserver部署在两台服务器A和B上,当请求到达Nginx后,Nginx会根据A和B服务器上的负载情况,将请求转发到负载较小的那台服务器上。 + +这里要求iam-apiserver是无状态的服务。Nginx有多种负载均衡策略,可以满足不同场景下的负载均衡需求。 + +Nginx安装步骤 + +接下来,我就来介绍下如何安装和配置Nginx。 + +我们分别在10.0.4.20和10.0.4.21服务器上执行如下步骤,安装Nginx。 + +在CentOS 8.x系统上,我们可以使用yum命令来安装,具体安装过程可以分为下面4个步骤。 + +第一步,安装Nginx: + +$ sudo yum -y install nginx + + +第二步,确认Nginx安装成功: + +$ nginx -v +nginx version: nginx/1.14.1 + + +第三步,启动Nginx,并设置开机启动: + +$ sudo systemctl start nginx +$ sudo systemctl enable nginx + + +Nginx默认监听80端口,启动Nginx前要确保80端口没有被占用。当然,你也可以通过修改Nginx配置文件/etc/nginx/nginx.conf修改Nginx监听端口。 + +第四步,查看Nginx启动状态: + +$ systemctl status nginx + + +输出中有active (running)字符串,说明成功启动。如果Nginx启动失败,你可以查看/var/log/nginx/error.log日志文件,定位错误原因。 + +Keepalived安装和配置 + +Nginx自带负载均衡功能,并且当Nginx后端某个服务器故障后,Nginx会自动剔除该服务器,将请求转发到可用的服务器,通过这种方式实现后端API服务的高可用。但是 Nginx是单点的,如果Nginx挂了,后端的所有服务器就都不能访问,所以在实际生产环境中,也需要对Nginx做高可用。 + +业界最普遍采用的方法是通过Keepalived对前端Nginx实现高可用。Keepalived + Nginx的高可用方案具有服务功能强大、维护简单等特点。 + +接下来,我们来看下如何安装和配置Keepalived。 + +Keepalived安装步骤 + +我们分别在10.0.4.20和10.0.4.21服务器上执行下面5个步骤,安装Keepalived。 + +第一步,下载Keepalived的最新版本(这门课安装了当前的最新版本 2.1.5): + +$ wget https://www.keepalived.org/software/keepalived-2.1.5.tar.gz + + +第二步,安装Keepalived: + +$ sudo yum -y install openssl-devel # keepalived依赖OpenSSL,先安装依赖 +$ tar -xvzf keepalived-2.1.5.tar.gz +$ cd keepalived-2.1.5 +$ ./configure --prefix=/usr/local/keepalived +$ make +$ sudo make install + + +第三步,配置Keepalived: + +$ sudo mkdir /etc/keepalived # 安装后,默认没有创建/etc/keepalived目录 +$ sudo cp /usr/local/keepalived/etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf +$ sudo cp /usr/local/keepalived/etc/sysconfig/keepalived /etc/sysconfig/keepalived + + +Keepalived的systemd uint配置,默认使用了/usr/local/keepalived/etc/sysconfig/keepalived作为其EnvironmentFile,我们还需要把它修改为/etc/sysconfig/keepalived文件。编辑/lib/systemd/system/keepalived.service文件,设置EnvironmentFile,值如下: + +EnvironmentFile=-/etc/sysconfig/keepalived + + +第四步,启动Keepalived,并设置开机启动: + +$ sudo systemctl start keepalived +$ sudo systemctl enable keepalived + + +这里要注意,Keepalived启动时不会校验配置文件是否正确,所以我们要小心修改配置,防止出现意想不到的问题。 + +第五步,查看Keepalived的启动状态: + +$ systemctl status keepalived + + +输出中有active (running)字符串,说明成功启动。Keepalived的日志保存在/var/log/messages中,你有需要的话可以查看。 + +Keepalived配置文件解析 + +Keepalived的默认配置文件为/etc/keepalived/keepalived.conf,下面是一个Keepalived配置: + +# 全局定义,定义全局的配置选项 +global_defs { +# 指定keepalived在发生切换操作时发送email,发送给哪些email +# 建议在keepalived_notify.sh中发送邮件 + notification_email { + [email protected] + } + notification_email_from [email protected] # 发送email时邮件源地址 + smtp_server 192.168.200.1 # 发送email时smtp服务器地址 + smtp_connect_timeout 30 # 连接smtp的超时时间 + router_id VM-4-21-centos # 机器标识,通常可以设置为hostname + vrrp_skip_check_adv_addr # 如果接收到的报文和上一个报文来自同一个路由器,则不执行检查。默认是跳过检查 + vrrp_garp_interval 0 # 单位秒,在一个网卡上每组gratuitous arp消息之间的延迟时间,默认为0 + vrrp_gna_interval 0 # 单位秒,在一个网卡上每组na消息之间的延迟时间,默认为0 +} +# 检测脚本配置 +vrrp_script checkhaproxy +{ + script "/etc/keepalived/check_nginx.sh" # 检测脚本路径 + interval 5 # 检测时间间隔(秒) + weight 0 # 根据该权重改变priority,当值为0时,不改变实例的优先级 +} +# VRRP实例配置 +vrrp_instance VI_1 { + state BACKUP # 设置初始状态为'备份' + interface eth0 # 设置绑定VIP的网卡,例如eth0 + virtual_router_id 51 # 配置集群VRID,互为主备的VRID需要是相同的值 + nopreempt # 设置非抢占模式,只能设置在state为backup的节点上 + priority 50 # 设置优先级,值范围0~254,值越大优先级越高,最高的为master + advert_int 1 # 组播信息发送时间间隔,两个节点必须设置一样,默认为1秒 +# 验证信息,两个节点必须一致 + authentication { + auth_type PASS # 认证方式,可以是PASS或AH两种认证方式 + auth_pass 1111 # 认证密码 + } + unicast_src_ip 10.0.4.21 # 设置本机内网IP地址 + unicast_peer { + 10.0.4.20 # 对端设备的IP地址 + } +# VIP,当state为master时添加,当state为backup时删除 + virtual_ipaddress { + 10.0.4.99 # 设置高可用虚拟VIP,如果是腾讯云的CVM,需要填写控制台申请到的HAVIP地址。 + } + notify_master "/etc/keepalived/keepalived_notify.sh MASTER" # 当切换到master状态时执行脚本 + notify_backup "/etc/keepalived/keepalived_notify.sh BACKUP" # 当切换到backup状态时执行脚本 + notify_fault "/etc/keepalived/keepalived_notify.sh FAULT" # 当切换到fault状态时执行脚本 + notify_stop "/etc/keepalived/keepalived_notify.sh STOP" # 当切换到stop状态时执行脚本 + garp_master_delay 1 # 设置当切为主状态后多久更新ARP缓存 + garp_master_refresh 5 # 设置主节点发送ARP报文的时间间隔 + # 跟踪接口,里面任意一块网卡出现问题,都会进入故障(FAULT)状态 + track_interface { + eth0 + } + # 要执行的检查脚本 + track_script { + checkhaproxy + } +} + + +这里解析下配置文件,大致分为下面4个部分。 + + +global_defs:全局定义,定义全局的配置选项。 +vrrp_script checkhaproxy:检测脚本配置。 +vrrp_instance VI_1:VRRP实例配置。 +virtual_server:LVS配置。如果没有配置LVS+Keepalived,就不用设置这个选项。这门课中,我们使用Nginx代替LVS,所以无需配置virtual_server(配置示例中不再展示)。 + + +只有在网络故障或者自身出问题时,Keepalived才会进行VIP切换。但实际生产环境中,我们往往使用Keepalived来监控其他进程,当业务进程出故障时切换VIP,从而保障业务进程的高可用。 + +为了让Keepalived感知到Nginx的运行状况,我们需要指定vrrp_script脚本,vrrp_script脚本可以根据退出码,判断Nginx进程是否正常,0正常,非0不正常。当不正常时,Keepalived会进行VIP切换。为了实现业务进程的监控,我们需要设置vrrp_script和track_script: + +vrrp_script checkhaproxy +{ + script "/etc/keepalived/check_nginx.sh" + interval 3 + weight -20 +} + +vrrp_instance test +{ + ... + track_script + { + checkhaproxy + } + ... +} + + +这里,我介绍下上面配置中的一些配置项。 + + +script:指定脚本路径。 +interval:表示Keepalived执行脚本的时间间隔(秒)。 +weight:检测权重,可以改变priority的值。例如,-20表示检测失败时,优先级-20,成功时不变。20表示检测成功时,优先级+20,失败时不变。 + + +总结 + +今天我主要讲了跟IAM部署相关的两个组件,Nginx + Keepalived的相关功能。 + +我们可以基于物理机/虚拟机来部署IAM应用,在部署IAM应用时,需要确保整个应用具备高可用和弹性扩缩容能力。你可以通过Nginx的反向代理功能和负载均衡功能实现后端服务iam-apiserver和iam-authz-server的高可用,通过Keepalived实现Nginx的高可用,通过Nginx + Keepalived组合,来实现IAM应用的高可用和弹性伸缩能力。 + +课后练习 + + +Keepalived的主备服务器要接在同一个交换机上。思考下,如果交换机故障,如何实现整个系统的高可用? +iam-pump是有状态的服务,思考下,如何实现iam-pump的高可用? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/41软件部署实战(中):IAM系统生产环境部署实战.md b/专栏/Go语言项目开发实战/41软件部署实战(中):IAM系统生产环境部署实战.md new file mode 100644 index 0000000..28ecd1a --- /dev/null +++ b/专栏/Go语言项目开发实战/41软件部署实战(中):IAM系统生产环境部署实战.md @@ -0,0 +1,665 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 41 软件部署实战(中):IAM 系统生产环境部署实战 + 你好,我是孔令飞。 + +上一讲,我介绍了IAM部署用到的两个核心组件,Nginx和Keepalived。那么这一讲,我们就来看下,如何使用Nginx和Keepalived来部署一个高可用的IAM应用。下一讲,我再介绍下IAM应用安全和弹性伸缩能力的构建方式。 + +这一讲,我们会通过下面四个步骤来部署IAM应用: + + +在服务器上部署IAM应用中的服务。 +配置Nginx,实现反向代理功能。通过反向代理,我们可以通过Nginx来访问部署在内网的IAM服务。 +配置Nginx,实现负载均衡功能。通过负载均衡,我们可以实现服务的水平扩缩容,使IAM应用具备高可用能力。 +配置Keepalived,实现Nginx的高可用。通过Nginx + Keepalived的组合,可以实现整个应用架构的高可用。 + + +部署IAM应用 + +部署一个高可用的IAM应用,需要至少两个节点。所以,我们按照先后顺序,分别在10.0.4.20和10.0.4.21服务器上部署IAM应用。 + +在10.0.4.20服务器上部署IAM应用 + +首先,我来介绍下如何在10.0.4.20服务器上部署IAM应用。 + +我们要在这个服务器上部署如下组件: + + +iam-apiserver +iam-authz-server +iam-pump +MariaDB +Redis +MongoDB + + +这些组件的部署方式,03讲 有介绍,这里就不再说明。 + +此外,我们还需要设置MariaDB,给来自于10.0.4.21服务器的数据库连接授权,授权命令如下: + +$ mysql -hlocalhost -P3306 -uroot -proot # 先以root用户登陆数据库 +MariaDB [(none)]> grant all on iam.* TO [email protected] identified by 'iam1234'; +Query OK, 0 rows affected (0.000 sec) + +MariaDB [(none)]> flush privileges; +Query OK, 0 rows affected (0.000 sec) + + +在10.0.4.21服务器上部署IAM应用 + +然后,在10.0.4.21服务器上安装好iam-apiserver、iam-authz-server 和 iam-pump。这些组件通过10.0.4.20 IP地址,连接10.0.4.20服务器上的MariaDB、Redis和MongoDB。 + +配置Nginx作为反向代理 + +假定要访问的API Server和IAM Authorization Server的域名分别为iam.api.marmotedu.com和iam.authz.marmotedu.com,我们需要分别为iam-apiserver和iam-authz-server配置Nginx反向代理。整个配置过程可以分为5步(在10.0.4.20服务器上操作)。 + +第一步,配置iam-apiserver。 + +新建Nginx配置文件/etc/nginx/conf.d/iam-apiserver.conf,内容如下: + +server { + listen 80; + server_name iam.api.marmotedu.com; + root /usr/share/nginx/html; + location / { + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8080/; + client_max_body_size 5m; + } + + error_page 404 /404.html; + location = /40x.html { + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + } +} + + +有几点你在配置时需要注意,这里说明下。 + + +server_name需要为iam.api.marmotedu.com,我们通过iam.api.marmotedu.com访问iam-apiserver。 +iam-apiserver默认启动的端口为8080。 +由于Nginx默认允许客户端请求的最大单文件字节数为1MB,实际生产环境中可能太小,所以这里将此限制改为5MB(client_max_body_size 5m)。如果需要上传图片之类的,可能需要设置成更大的值,比如50m。 +server_name用来说明访问Nginx服务器的域名,例如curl -H 'Host: iam.api.marmotedu.com' http://x.x.x.x:80/healthz,x.x.x.x为Nginx服务器的IP地址。 +proxy_pass表示反向代理的路径。因为这里是本机的iam-apiserver服务,所以IP为127.0.0.1。端口要和API服务端口一致,为8080。 + + +最后还要提醒下,因为 Nginx 配置选项比较多,跟实际需求和环境有关,所以这里的配置是基础的、未经优化的配置,在实际生产环境中需要你再做调节。 + +第二步,配置iam-authz-server。 + +新建Nginx配置文件/etc/nginx/conf.d/iam-authz-server.conf,内容如下: + +server { + listen 80; + server_name iam.authz.marmotedu.com; + root /usr/share/nginx/html; + location / { + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:9090/; + client_max_body_size 5m; + } + + error_page 404 /404.html; + location = /40x.html { + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + } +} + + +下面是一些配置说明。 + + +server_name需要为iam.authz.marmotedu.com,我们通过iam.authz.marmotedu.com访问iam-authz-server。 +iam-authz-server默认启动的端口为9090。 +其他配置跟/etc/nginx/conf.d/iam-apiserver.conf一致。 + + +第三步,配置完Nginx后,重启Nginx: + +$ sudo systemctl restart nginx + + +第四步,在/etc/hosts中追加下面两行: + +127.0.0.1 iam.api.marmotedu.com +127.0.0.1 iam.authz.marmotedu.com + + +第五步,发送HTTP请求: + +$ curl http://iam.api.marmotedu.com/healthz +{"status":"ok"} +$ curl http://iam.authz.marmotedu.com/healthz +{"status":"ok"} + + +我们分别请求iam-apiserver和iam-authz-server的健康检查接口,输出了{"status":"ok"},说明我们可以成功通过代理访问后端的API服务。 + +在用curl请求http://iam.api.marmotedu.com/healthz后,后端的请求流程实际上是这样的: + + +因为在/etc/hosts中配置了127.0.0.1 iam.api.marmotedu.com,所以请求http://iam.api.marmotedu.com/healthz实际上是请求本机的Nginx端口(127.0.0.1:80)。 +Nginx在收到请求后,会解析请求,得到请求域名为iam.api.marmotedu.com。根据请求域名去匹配 Nginx的server配置,匹配到server_name iam.api.marmotedu.com;配置。 +匹配到server后,把请求转发到该server的proxy_pass路径。 +等待API服务器返回结果,并返回客户端。 + + +配置Nginx作为负载均衡 + +这门课采用Nginx轮询的负载均衡策略转发请求。负载均衡需要至少两台服务器,所以会分别在10.0.4.20和10.0.4.21服务器上执行相同的操作。下面我分别来介绍下如何配置这两台服务器,并验证配置是否成功。 + +10.0.4.20服务器配置 + +登陆10.0.4.20服务器,在/etc/nginx/nginx.conf中添加upstream配置,配置过程可以分为3步。 + +第一步,在/etc/nginx/nginx.conf中添加upstream: + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; + upstream iam.api.marmotedu.com { + server 127.0.0.1:8080 + server 10.0.4.21:8080 + } + upstream iam.authz.marmotedu.com { + server 127.0.0.1:9090 + server 10.0.4.21:9090 + } +} + + +配置说明: + + +upstream是配置在/etc/nginx/nginx.conf文件中的http{ … }部分的。 +因为我们要分别为iam-apiserver和iam-authz-server配置负载均衡,所以我们创建了两个upstream,分别是iam.api.marmotedu.com和iam.authz.marmotedu.com。为了便于识别,upstream名称和域名最好保持一致。 +在upstream中,我们需要分别添加所有的iam-apiserver和iam-authz-server的后端(ip:port),本机的后端为了访问更快,可以使用127.0.0.1:,其他机器的后端,需要使用<内网>:port,例如10.0.4.21:8080、10.0.4.21:9090。 + + +第二步,修改proxy_pass。 + +修改/etc/nginx/conf.d/iam-apiserver.conf文件,将proxy_pass修改为: + +proxy_pass http://iam.api.marmotedu.com/; + + +修改/etc/nginx/conf.d/iam-authz-server.conf文件,将proxy_pass修改为: + +proxy_pass http://iam.authz.marmotedu.com/; + + +当Nginx转发到http://iam.api.marmotedu.com/域名时,会从iam.api.marmotedu.com upstream配置的后端列表中,根据负载均衡策略选取一个后端,并将请求转发过去。转发http://iam.authz.marmotedu.com/域名的逻辑也一样。 + +第三步,配置完Nginx后,重启Nginx: + +$ sudo systemctl restart nginx + + +最终配置好的配置文件,你可以参考下面这些(保存在configs/ha/10.0.4.20目录下): + + +nginx.conf:configs/ha/10.0.4.20/nginx.conf。 +iam-apiserver.conf:configs/ha/10.0.4.20/iam-apiserver.conf。 +iam-authz-server.conf:configs/ha/10.0.4.20/iam-authz-server.conf。 + + +10.0.4.21服务器配置 + +登陆10.0.4.21服务器,在/etc/nginx/nginx.conf中添加upstream配置。配置过程可以分为下面4步。 + +第一步,在/etc/nginx/nginx.conf中添加upstream: + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; + upstream iam.api.marmotedu.com { + server 127.0.0.1:8080 + server 10.0.4.20:8080 + } + upstream iam.authz.marmotedu.com { + server 127.0.0.1:9090 + server 10.0.4.20:9090 + } +} + + +upstream中,需要配置10.0.4.20服务器上的iam-apiserver和iam-authz-server的后端,例如10.0.4.20:8080、10.0.4.20:9090。 + +第二步,创建/etc/nginx/conf.d/iam-apiserver.conf文件(iam-apiserver的反向代理+负载均衡配置),内容如下: + +server { + listen 80; + server_name iam.api.marmotedu.com; + root /usr/share/nginx/html; + location / { + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://iam.api.marmotedu.com/; + client_max_body_size 5m; + } + + error_page 404 /404.html; + location = /40x.html { + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + } +} + + +第三步,创建/etc/nginx/conf.d/iam-authz-server文件(iam-authz-server的反向代理+负载均衡配置),内容如下: + +server { + listen 80; + server_name iam.authz.marmotedu.com; + root /usr/share/nginx/html; + location / { + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://iam.authz.marmotedu.com/; + client_max_body_size 5m; + } + + error_page 404 /404.html; + location = /40x.html { + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + } +} + + + +第四步,配置完Nginx后,重启Nginx: + +$ sudo systemctl restart nginx + + +最终配置好的配置文件,你可以参考下面这些(保存在configs/ha/10.0.4.21目录下): + + +nginx.conf:configs/ha/10.0.4.21/nginx.conf。 +iam-apiserver.conf:configs/ha/10.0.4.21/iam-apiserver.conf。 +iam-authz-server.conf:configs/ha/10.0.4.21/iam-authz-server.conf。 + + +测试负载均衡 + +上面,我们配置了Nginx负载均衡器,这里我们还需要测试下是否配置成功。 + +第一步,执行测试脚本(test/nginx/loadbalance.sh): + +#!/usr/bin/env bash + +for domain in iam.api.marmotedu.com iam.authz.marmotedu.com +do + for n in $(seq 1 1 10) + do + echo $domain + nohup curl http://${domain}/healthz &>/dev/null & + done +done + + +第二步,分别查看iam-apiserver和iam-authz-server的日志。 + +这里我展示下iam-apiserver的日志(iam-authz-server的日志你可自行查看)。 + +10.0.4.20服务器的iam-apiserver日志如下图所示: + + + +10.0.4.21服务器的iam-apiserver日志如下图所示: + + + +通过上面两张图,你可以看到10.0.4.20和10.0.4.21各收到5个/healthz请求,说明负载均衡配置成功。 + +配置Keepalived + +在 40讲,我们分别在10.0.4.20和10.0.4.21服务器上安装了Keepalived。这里,我来介绍下如何配置Keepalived,实现Nginx的高可用。为了避免故障恢复时,VIP切换造成的服务延时,这一讲采用Keepalived的非抢占模式。 + +配置Keepalived的流程比较复杂,分为创建腾讯云HAVIP、主服务器配置、备服务器配置、测试Keepalived、VIP绑定公网IP和测试公网访问六大步,每一步中都有很多小步骤,下面我们来一步步地看下。 + +第一步:创建腾讯云HAVIP + +公有云厂商的普通内网IP,出于安全考虑(如避免ARP欺骗等),不支持主机通过ARP宣告IP 。如果用户直接在keepalived.conf文件中指定一个普通内网IP为virtual IP,当Keepalived将virtual IP从MASTER机器切换到BACKUP机器时,将无法更新IP和MAC地址的映射,而需要调API来进行IP切换。所以,这里的VIP需要申请腾讯云的HAVIP。 + +申请的流程可以分为下面4步: + + +登录私有网络控制台。 +在左侧导航栏中,选择【IP与网卡】>【高可用虚拟IP】。 +在HAVIP管理页面,选择所在地域,单击【申请】。 +在弹出的【申请高可用虚拟IP】对话框中输入名称,选择HAVIP所在的私有网络和子网等信息,单击【确定】即可。 + + +这里选择的私有网络和子网,需要和10.0.4.20、10.0.4.21相同。HAVIP 的 IP 地址可以自动分配,也可以手动填写,这里我们手动填写为10.0.4.99。申请页面如下图所示: + + + +第二步:主服务器配置 + +进行主服务器配置,可以分为两步。 + +首先,修改Keepalived配置文件。 + +登陆服务器10.0.4.20,编辑/etc/keepalived/keepalived.conf,修改配置,修改后配置内容如下(参考:configs/ha/10.0.4.20/keepalived.conf): + +# 全局定义,定义全局的配置选项 +global_defs { +# 指定keepalived在发生切换操作时发送email,发送给哪些email +# 建议在keepalived_notify.sh中发送邮件 + notification_email { + [email protected] + } + notification_email_from [email protected] # 发送email时邮件源地址 + smtp_server 192.168.200.1 # 发送email时smtp服务器地址 + smtp_connect_timeout 30 # 连接smtp的超时时间 + router_id VM-4-20-centos # 机器标识,通常可以设置为hostname + vrrp_skip_check_adv_addr # 如果接收到的报文和上一个报文来自同一个路由器,则不执行检查。默认是跳过检查 + vrrp_garp_interval 0 # 单位秒,在一个网卡上每组gratuitous arp消息之间的延迟时间,默认为0 + vrrp_gna_interval 0 # 单位秒,在一个网卡上每组na消息之间的延迟时间,默认为0 +} +# 检测脚本配置 +vrrp_script checkhaproxy +{ + script "/etc/keepalived/check_nginx.sh" # 检测脚本路径 + interval 5 # 检测时间间隔(秒) + weight 0 # 根据该权重改变priority,当值为0时,不改变实例的优先级 +} +# VRRP实例配置 +vrrp_instance VI_1 { + state BACKUP # 设置初始状态为'备份' + interface eth0 # 设置绑定VIP的网卡,例如eth0 + virtual_router_id 51 # 配置集群VRID,互为主备的VRID需要是相同的值 + nopreempt # 设置非抢占模式,只能设置在state为backup的节点上 + priority 100 # 设置优先级,值范围0~254,值越大优先级越高,最高的为master + advert_int 1 # 组播信息发送时间间隔,两个节点必须设置一样,默认为1秒 +# 验证信息,两个节点必须一致 + authentication { + auth_type PASS # 认证方式,可以是PASS或AH两种认证方式 + auth_pass 1111 # 认证密码 + } + unicast_src_ip 10.0.4.20 # 设置本机内网IP地址 + unicast_peer { + 10.0.4.21 # 对端设备的IP地址 + } +# VIP,当state为master时添加,当state为backup时删除 + virtual_ipaddress { + 10.0.4.99 # 设置高可用虚拟VIP,如果是腾讯云的CVM,需要填写控制台申请到的HAVIP地址。 + } + notify_master "/etc/keepalived/keepalived_notify.sh MASTER" # 当切换到master状态时执行脚本 + notify_backup "/etc/keepalived/keepalived_notify.sh BACKUP" # 当切换到backup状态时执行脚本 + notify_fault "/etc/keepalived/keepalived_notify.sh FAULT" # 当切换到fault状态时执行脚本 + notify_stop "/etc/keepalived/keepalived_notify.sh STOP" # 当切换到stop状态时执行脚本 + garp_master_delay 1 # 设置当切为主状态后多久更新ARP缓存 + garp_master_refresh 5 # 设置主节点发送ARP报文的时间间隔 + # 跟踪接口,里面任意一块网卡出现问题,都会进入故障(FAULT)状态 + track_interface { + eth0 + } + # 要执行的检查脚本 + track_script { + checkhaproxy + } +} + + +这里有几个注意事项: + + +确保已经配置了garp相关参数。因为Keepalived依赖ARP报文更新IP信息,如果缺少这些参数,会导致某些场景下主设备不发送ARP,进而导致通信异常。garp相关参数配置如下: + + +garp_master_delay 1 +garp_master_refresh 5 + + + +确定没有采用 strict 模式,即需要删除vrrp_strict配置。 +配置中的/etc/keepalived/check_nginx.sh和/etc/keepalived/keepalived_notify.sh脚本文件,可分别拷贝自scripts/check_nginx.sh和scripts/keepalived_notify.sh。 + + +然后,重启Keepalived: + +$ sudo systemctl restart keepalived + + +第三步:备服务器配置 + +进行备服务器配置也分为两步。 + +首先,修改Keepalived配置文件。 + +登陆服务器10.0.4.21,编辑/etc/keepalived/keepalived.conf,修改配置,修改后配置内容如下(参考:configs/ha/10.0.4.21/keepalived.conf): + +# 全局定义,定义全局的配置选项 +global_defs { +# 指定keepalived在发生切换操作时发送email,发送给哪些email +# 建议在keepalived_notify.sh中发送邮件 + notification_email { + [email protected] + } + notification_email_from [email protected] # 发送email时邮件源地址 + smtp_server 192.168.200.1 # 发送email时smtp服务器地址 + smtp_connect_timeout 30 # 连接smtp的超时时间 + router_id VM-4-21-centos # 机器标识,通常可以设置为hostname + vrrp_skip_check_adv_addr # 如果接收到的报文和上一个报文来自同一个路由器,则不执行检查。默认是跳过检查 + vrrp_garp_interval 0 # 单位秒,在一个网卡上每组gratuitous arp消息之间的延迟时间,默认为0 + vrrp_gna_interval 0 # 单位秒,在一个网卡上每组na消息之间的延迟时间,默认为0 +} +# 检测脚本配置 +vrrp_script checkhaproxy +{ + script "/etc/keepalived/check_nginx.sh" # 检测脚本路径 + interval 5 # 检测时间间隔(秒) + weight 0 # 根据该权重改变priority,当值为0时,不改变实例的优先级 +} +# VRRP实例配置 +vrrp_instance VI_1 { + state BACKUP # 设置初始状态为'备份' + interface eth0 # 设置绑定VIP的网卡,例如eth0 + virtual_router_id 51 # 配置集群VRID,互为主备的VRID需要是相同的值 + nopreempt # 设置非抢占模式,只能设置在state为backup的节点上 + priority 50 # 设置优先级,值范围0~254,值越大优先级越高,最高的为master + advert_int 1 # 组播信息发送时间间隔,两个节点必须设置一样,默认为1秒 +# 验证信息,两个节点必须一致 + authentication { + auth_type PASS # 认证方式,可以是PASS或AH两种认证方式 + auth_pass 1111 # 认证密码 + } + unicast_src_ip 10.0.4.21 # 设置本机内网IP地址 + unicast_peer { + 10.0.4.20 # 对端设备的IP地址 + } +# VIP,当state为master时添加,当state为backup时删除 + virtual_ipaddress { + 10.0.4.99 # 设置高可用虚拟VIP,如果是腾讯云的CVM,需要填写控制台申请到的HAVIP地址。 + } + notify_master "/etc/keepalived/keepalived_notify.sh MASTER" # 当切换到master状态时执行脚本 + notify_backup "/etc/keepalived/keepalived_notify.sh BACKUP" # 当切换到backup状态时执行脚本 + notify_fault "/etc/keepalived/keepalived_notify.sh FAULT" # 当切换到fault状态时执行脚本 + notify_stop "/etc/keepalived/keepalived_notify.sh STOP" # 当切换到stop状态时执行脚本 + garp_master_delay 1 # 设置当切为主状态后多久更新ARP缓存 + garp_master_refresh 5 # 设置主节点发送ARP报文的时间间隔 + # 跟踪接口,里面任意一块网卡出现问题,都会进入故障(FAULT)状态 + track_interface { + eth0 + } + # 要执行的检查脚本 + track_script { + checkhaproxy + } +} + + +然后,重启Keepalived: + +$ sudo systemctl restart keepalived + + +第四步:测试Keepalived + +上面的配置中,10.0.4.20的优先级更高,所以正常情况下10.0.4.20将被选择为主节点,如下图所示: + + + +接下来,我们分别模拟一些故障场景,来看下配置是否生效。 + +场景1:Keepalived故障 + +在10.0.4.20服务器上执行sudo systemctl stop keepalived模拟Keepalived故障,查看VIP,如下图所示: + + + +可以看到,VIP从10.0.4.20服务器上,漂移到了10.0.4.21服务器上。查看/var/log/keepalived.log,可以看到10.0.4.20服务器新增如下一行日志: + +[2020-10-14 14:01:51] notify_stop + + +10.0.4.21服务器新增如下日志: + +[2020-10-14 14:01:52] notify_master + + +场景2:Nginx故障 + +在10.0.4.20和10.0.4.21服务器上分别执行sudo systemctl restart keepalived,让VIP漂移到10.0.4.20服务器上。 + +在10.0.4.20服务器上,执行 sudo systemctl stop nginx 模拟Nginx故障,查看VIP,如下图所示: + + + +可以看到,VIP从10.0.4.20服务器上,漂移到了10.0.4.21服务器上。查看/var/log/keepalived.log,可以看到10.0.4.20服务器新增如下一行日志: + +[2020-10-14 14:02:34] notify_fault + + +10.0.4.21 服务器新增如下日志: + +[2020-10-14 14:02:35] notify_master + + +场景3:Nginx恢复 + +基于场景2,在10.0.4.20服务器上执行sudo systemctl start nginx恢复Nginx,查看VIP,如下图所示: + + + +可以看到,VIP仍然在10.0.4.21服务器上,没有被10.0.4.20抢占。查看/var/log/keepalived.log,可以看到10.0.4.20服务器新增如下一行日志: + +[2020-10-14 14:03:44] notify_backup + + +10.0.4.21服务器没有新增日志。 + +第五步:VIP绑定公网IP + +到这里,我们已经成功配置了Keepalived + Nginx的高可用方案。但是,我们的VIP是内网,还不能通过外网访问。这时候,我们需要将VIP绑定一个外网IP,供外网访问。在腾讯云上,可通过绑定弹性公网IP来实现外网访问,需要先申请公网IP,然后将VIP绑定弹性公网IP。下面我来讲讲具体步骤。 + +申请公网IP: + + +登录私有网络控制台。 +在左侧导航栏中,选择【IP与网卡】>【弹性公网IP】。 +在弹性公网IP管理页面,选择所在地域,单击【申请】。 + + +将VIP绑定弹性公网IP: + + +登录私有网络控制台。 +在左侧导航栏中,选择【IP与网卡】>【高可用虚拟】。 +单击需要绑定的HAVIP所在行的【绑定】。 +在弹出界面中,选择需要绑定的公网IP即可,如下图所示: + + + + +绑定的弹性公网IP是106.52.252.139。 + +这里提示下,腾讯云平台中,如果HAVIP没有绑定实例,绑定HAVIP的EIP会处于闲置状态,按¥0.2/小时 收取闲置费用。所以,你需要正确配置高可用应用,确保绑定成功。 + +第六步:测试公网访问 + +最后,你可以通过执行如下命令来测试: + +$ curl -H"Host: iam.api.marmotedu.com" http://106.52.252.139/healthz -H"iam.api.marmotedu.com" +{"status":"ok"} + + +可以看到,我们可以成功通过公网访问后端的高可用服务。到这里,我们成功部署了一个可用性很高的IAM应用。 + +总结 + +今天,我主要讲了如何使用Nginx和Keepalived,来部署一个高可用的IAM应用。 + +为了部署一个高可用的IAM应用,我们至少需要两台服务器,并且部署相同的服务iam-apiserver、iam-authz-server、iam-pump。而且,选择其中一台服务器部署数据库服务:MariaDB、Redis、MongoDB。 + +为了安全和性能,iam-apiserver、iam-authz-server、iam-pump服务都是通过内网来访问数据库服务的。这一讲,我还介绍了如何配置Nginx来实现负载均衡,如何配置Keepalived来实现Nginx的高可用。 + +课后练习 + + +思考下,当前部署架构下如果iam-apiserver需要扩容,可以怎么扩容? +思考下,当VIP切换时,如何实现告警功能,给系统运维人员告警? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/42软件部署实战(下):IAM系统安全加固、水平扩缩容实战.md b/专栏/Go语言项目开发实战/42软件部署实战(下):IAM系统安全加固、水平扩缩容实战.md new file mode 100644 index 0000000..f6c7bc1 --- /dev/null +++ b/专栏/Go语言项目开发实战/42软件部署实战(下):IAM系统安全加固、水平扩缩容实战.md @@ -0,0 +1,534 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 42 软件部署实战(下):IAM系统安全加固、水平扩缩容实战 + 你好,我是孔令飞。 + +这一讲和前面两讲,都是介绍如何基于物理机/虚拟机来部署IAM的。在前面两讲,我们了解了如何部署一个高可用的 IAM 应用,今天就再来看看IAM 应用安全和弹性伸缩能力的构建方式。在这一讲中,我会带你加固IAM应用的安全性,并介绍如何具体执行扩缩容步骤。 + +接下来,我们先来看下如何加固IAM应用的安全性。 + +IAM应用安全性加固 + +iam-apiserver、iam-authz-server、MariaDB、Redis和MongoDB这些服务,都提供了绑定监听网卡的功能。我们可以将这些服务绑定到内网网卡上,从而只接收来自于内网的请求,通过这种方式,可以加固我们的系统。 + +我们也可以通过iptables来实现类似的功能,通过将安全问题统一收敛到iptables规则,可以使我们更容易地维护安全类设置。 + +这门课通过iptables来加固系统,使系统变得更加安全。下面,我先来对iptables工具进行一些简单的介绍。 + +iptables简介 + +iptables是Linux下最优秀的防火墙工具,也是Linux内核中netfilter网络子系统用户态的工具。 + +netfilter提供了一系列的接口,在一个到达本机的数据包,或者经本机转发的数据包流程中添加了一些可供用户操作的点,这些点被称为HOOK点。通过在HOOK点注册数据包处理函数,可以实现数据包转发、数据包过滤、地址转换等功能。 + +用户通过iptables工具定义各种规则,这些规则通过iptables传给内核中的netfilter。最终,netfilter会根据规则对网络包进行过滤。Linux系统一般会默认安装iptables软件。防火墙根据iptables里的规则,对收到的网络数据包进行处理。 + +iptables里的数据组织结构分为表、链、规则。 + + +表(tables):表可以提供特定的功能,每个表里包含多个链。iptables里面一共有5个表,分别是filter、nat、mangle、raw、security。这些表,分别用来实现包过滤、网络地址转换、包重构、数据追踪处理和SELinux标记设置。 +链(chains):链是数据包传播的路径,每一条链中可以有一个或多个规则。当一个数据包到达一个链时,iptables会从链中第一条规则开始,检查该数据包是否满足规则所定义的条件。如果满足,就会根据该条规则所定义的方法,处理该数据包。否则,就继续检查下一条规则。如果该数据包不符合链中任一条规则,iptables就会根据该链预先定义的默认策略来处理数据包。 +规则(rules):规则存储在内核空间的信息包过滤表中,用来描述“如果数据包满足所描述的条件,就按照要求处理这个数据包,如果不满足,就判断下一条规则”。 + + +其中,iptables中表和链的种类及其功能,如下表所示: + + + +上面的表格中,五张表的处理是有顺序的。当数据包到达某一条链时,会按照RAW、MANGLE、NAT、FILTER、SECURITY的顺序进行处理。 + +到这里,我介绍了关于iptables的一些基础知识,但这还远远不够。要想使用iptables来加固你的系统,你还需要掌握iptables工具的使用方法。接下来,我先来介绍下iptables是如何处理网络数据包的。 + +网络数据包处理流程 + +网络数据包的处理流程如下图所示: + + + +具体可以分为两个步骤。 + +第一步,当数据包进入网卡后,它首先进入PREROUTING链,根据目的IP判断是否转发出去。 + +第二步分为两种情况:如果数据包目的地是本机,它会到达INPUT链。到达后,任何进程都会收到它。本机上的程序可以发送数据包,这些数据包会经过OUTPUT链,然后经POSTROUTING链输出;如果数据包是要转发出去,并且内核允许转发,那么数据包会经过FORWARD链,最后从POSTROUTING链输出。 + +iptables工具使用方式介绍 + +iptables的功能强大,所以使用方法也非常多样。这里,我来介绍下iptables工具的使用方式,并给出一些使用示例。 + + +命令格式 + + +iptables的语法格式为: + +iptables [-t 表名] 命令选项 [链名] [条件匹配] [-j 目标动作或跳转] + + +下面是一个iptables的使用示例: + +iptables -t nat -I PREROUTING -p tcp --dport 8080 -j DNAT --to 10.0.4.88 + + +这里对上面涉及到的一些参数进行说明。 + + +表名/链名:指定iptables命令所操作的表/链。 +命令选项:指定处理iptables规则的方式,例如插入、增加、删除、查看等。 +条件匹配:指定对符合条件的数据包进行处理。 +目标动作或跳转:防火墙处理数据包的方式。 + + +iptables的命令选项又分为管理控制选项和通用选项。 + +管理控制选项如下: + + + +通用选项如下: + + + +处理数据包的方式(目标动作或跳转)有多种,具体如下表所示: + + + +上面,我介绍了iptables工具的使用方式。因为内容有点多,你可能仍然不知道如何使用iptables工具。没关系,接下来你可以结合我举的一些例子来看下。 + + +命令示例 + + +下面的命令示例,默认使用了 FILTER 表,也即规则存放在 FILTER 表中,相当于每一条iptables命令都添加了-t filter 参数。 + + +拒绝进入防火墙的所有ICMP协议数据包: + + +$ iptables -I INPUT -p icmp -j REJECT + + + +允许防火墙转发除ICMP协议以外的所有数据包: + + +$ iptables -A FORWARD -p ! icmp -j ACCEPT + + + +拒绝转发来自192.168.1.10主机的数据,允许转发来自192.168.0.0/24网段的数据: + + +$ iptables -A FORWARD -s 192.168.1.11 -j REJECT +$ iptables -A FORWARD -s 192.168.0.0/24 -j ACCEPT + + + +丢弃从外网接口(eth1)进入防火墙本机的源地址为私网地址的数据包: + + +$ iptables -A INPUT -i eth1 -s 192.168.0.0/16 -j DROP +$ iptables -A INPUT -i eth1 -s 172.16.0.0/12 -j DROP +$ iptables -A INPUT -i eth1 -s 10.0.0.0/8 -j DROP + + + +只允许管理员从202.13.0.0/16网段使用SSH远程登录防火墙主机: + + +$ iptables -A INPUT -p tcp --dport 22 -s 202.13.0.0/16 -j ACCEPT +$ iptables -A INPUT -p tcp --dport 22 -j DROP + + + +允许本机开放从TCP端口20-1024提供的应用服务: + + +$ iptables -A INPUT -p tcp --dport 20:1024 -j ACCEPT +$ iptables -A OUTPUT -p tcp --sport 20:1024 -j ACCEPT + + + +允许转发来自192.168.0.0/24局域网段的DNS解析请求数据包: + + +$ iptables -A FORWARD -s 192.168.0.0/24 -p udp --dport 53 -j ACCEPT +$ iptables -A FORWARD -d 192.168.0.0/24 -p udp --sport 53 -j ACCEPT + + + +禁止其他主机ping防火墙主机,但是允许从防火墙上ping其他主机: + + +$ iptables -I INPUT -p icmp --icmp-type Echo-Request -j DROP +$ iptables -I INPUT -p icmp --icmp-type Echo-Reply -j ACCEPT +$ iptables -I INPUT -p icmp --icmp-type destination-Unreachable -j ACCEPT + + + +禁止转发来自MAC地址为00:0C:29:27:55:3F的数据包和主机的数据包: + + +$ iptables -A FORWARD -m mac --mac-source 00:0c:29:27:55:3F -j DROP + + + +对外开放TCP端口20、21、25、110,以及被动模式FTP端口1250-1280: + + +$ iptables -A INPUT -p tcp -m multiport --dport 20,21,25,110,1250:1280 -j ACCEPT + + + +禁止转发源IP地址为192.168.1.20-192.168.1.99的TCP数据包: + + +$ iptables -A FORWARD -p tcp -m iprange --src-range 192.168.1.20-192.168.1.99 -j DROP + + + +禁止转发与正常TCP连接无关的非syn请求数据包: + + +$ iptables -A FORWARD -m state --state NEW -p tcp ! --syn -j DROP + + + +拒绝访问防火墙的新数据包,但允许响应连接或与已有连接相关的数据包: + + +$ iptables -A INPUT -p tcp -m state --state NEW -j DROP +$ iptables -A INPUT -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT + + + +只开放本机的web服务(80)、FTP(20、21、20450-20480),放行外部主机发往服务器其他端口的应答数据包,将其他入站数据包都进行丢弃处理: + + +$ iptables -I INPUT -p tcp -m multiport --dport 20,21,80 -j ACCEPT +$ iptables -I INPUT -p tcp --dport 20450:20480 -j ACCEPT +$ iptables -I INPUT -p tcp -m state --state ESTABLISHED -j ACCEPT +$ iptables -P INPUT DROP + + +到这里,我们已经了解了iptables的功能,下面来看看如何使用iptables来加固IAM应用。我把它分成内网不安全和内网安全两种情况。 + +IAM安全加固(内网不安全) + +在设置iptables规则之前,我们需要先梳理系统的访问关系,然后根据这些访问关系设置iptables规则。访问关系如下图所示: + + + +你可以看到,IAM系统服务互访关系分为下面这4种: + + +允许公网客户端访问Nginx的80和443端口。 +Keepalived服务之间能够互发VRRP协议包。 +Nginx访问各节点上iam-apiserver、iam-authz-server和iam-pump组件开启的HTTP/HTTPS/GRPC服务。 +iam服务可以从各节点访问Redis、MariaDB、MongoDB数据库。 + + +这里,我们假定IAM系统部署在一个非常大的内网中,该内网部署了很多其他团队的服务,有很多其他团队的研发、测试等人员在内网中执行各种操作。也就是说,我们处在一个不安全的内网中。这时候,如果要加固我们的系统,最安全的方式是屏蔽掉未知的来源IP。 + +内网不安全的情况下,加固系统可以分为3大步骤,每个步骤中又有一些小步骤。另外,需要新增节点或者删除节点时,也需要进行一些变更操作。下面我们来具体看下。 + +第一步,设置防火墙规则。 + +基于上面说到的几种互访关系,我们可以在各个节点上设置iptables规则来加固系统。我将这些规则设置编写成了go工具,用来自动生成设置这些规则的shell脚本。 + +具体设置的过程可以分为5步。 + + +进入iam项目源码根目录。 +配置accesss.yaml(工具根据此配置,自动生成iptables设置脚本),内容如下(位于configs/access.yaml文件): + + +# 允许登录SSH节点的来源IP,可以是固定IP(例如10.0.4.2),也可以是个网段,0.0.0.0/0代表不限制来源IP +ssh-source: 10.0.4.0/24 + +# IAM应用节点列表(来源IP) +hosts: + - 10.0.4.20 + - 10.0.4.21 + +# 来源IP可以访问的应用端口列表(iam-apiserver, iam-authz-server, iam-pump对外暴露的的端口) +ports: + - 8080 + - 8443 + - 9090 + - 9443 + - 7070 + +# 来源IP可以访问的数据库端口列表(Redis, MariaDB, MongoDB) +dbports: + - 3306 + - 6379 + - 27017 + + +上面的配置中,我们指定了允许登陆机器的子网、Nginx需要访问的端口列表和各节点需要访问的数据库端口列表。 + + +生成iptables初始化脚本: + + +$ go run tools/geniptables/main.go -c access.yaml -t app -a -o firewall.sh +$ ls firewall.sh +firewall.sh + + +你可以打开firewall.sh文件,查看该脚本设置的规则。- +4. 将firewall.sh脚本拷贝到10.0.4.20和10.0.4.21节点执行: + +$ scp firewall.sh [email protected]:/tmp/ +$ scp firewall.sh [email protected]:/tmp/ + + +登陆10.0.4.20和10.0.4.21机器,执行/tmp/firewall.sh。 + + +在10.0.4.20(数据库节点)节点上,设置iptables规则,以允许各节点访问: + + +因为数据库节点也位于10.0.4.20节点,所以只需要添加新的rule,并将iptables -A INPUT -j DROP规则放到最后执行即可。 + +$ go run tools/geniptables/main.go -c access.yaml -t db -o addrules.sh + + +然后,将addrules.sh脚本拷贝到10.0.4.20节点执行。 + +注意,因为iptables是按顺序进行规则过滤的,所以需要将iptables -A INPUT -j DROP规则放在新设置规则的后面,否则执行不到新设置的规则。你可以在设置完iptables规则之后,执行下面的命令来将DROP放到最后: + +iptables -A INPUT -j LOG --log-level 7 --log-prefix "Default Deny" +iptables -A INPUT -j DROP + + +生成的addrules.sh脚本加入以上设置。 + +第二步,设置重启自动加载iptables规则。 + +前面我们在各个节点设置了iptables规则,但是这些规则在系统重启后会丢失。为了使系统重启后自动重新设置这些规则,我们需要将当前的iptables规则保存起来,让系统重启时自动加载。需要进行下面两个步骤。 + + +保存现有的规则: + + +$ sudo iptables-save > /etc/sysconfig/iptables + + + +添加下面的命令行到/etc/rc.d/rc.local文件中: + + +$ iptables-restore < /etc/sysconfig/iptables + + +第三步,自动化。 + +在上面的步骤中,我们自动生成了iptables规则,并手动登陆到节点进行设置。你肯定也发现了,整个流程手动操作过多,容易出错,效率还低。你可以参考设置过程,将这些设置工作自动化,比如编写脚本,一键刷新所有节点的iptables规则。 + +另外,我们再来看下在新增节点和删除节点两种场景下,如何设置iptables规则。 + +场景1:新增节点 + +如果我们要扩容一个节点,也需要在新节点设置防火墙规则,并在数据库节点设置防火墙规则允许来自新节点的访问。 + +假如我们新增一个10.0.4.22节点,这里要设置防火墙规则,需要下面的4个步骤。 + + +编辑access.yaml,在hosts列表下新增10.0.4.22节点IP。编辑后内容如下: + + +# 允许登录SSH节点的来源IP,可以是固定IP(例如10.0.4.2),也可以是个网段,0.0.0.0/0代表不限制来源IP +ssh-source: 10.0.4.0/24 + +# IAM应用节点列表(来源IP) +hosts: + - 10.0.4.20 + - 10.0.4.21 + - 10.0.4.22 + +# 来源IP可以访问的应用端口列表(iam-apiserver, iam-authz-server, iam-pump对外暴露的的端口) +ports: + - 8080 + - 8443 + - 9090 + - 9443 + - 7070 + +# 来源IP可以访问的数据库端口列表(Redis, MariaDB, MongoDB) +dbports: + - 3306 + - 6379 + - 27017 + + + +在10.0.4.22节点设置iptables规则: + + +$ go run tools/geniptables/main.go -c access.yaml -t app -a -o firewall.sh + + +将firewall.sh脚本拷贝到10.0.4.22节点,并执行。 + + +在已有节点新增规则,允许来自10.0.4.22的 Nginx服务的访问: + + +$ go run tools/geniptables/main.go -c access.yaml -t app 10.0.4.22 -o addrules.sh + + +将addrules.sh脚本拷贝到存量节点,并执行。 + + +在数据库节点新增iptables规则,以允许来自新节点的访问: + + +$ go run tools/geniptables/main.go -c access.yaml -t db 10.0.4.22 -o addrules.sh + + +将addrules.sh脚本拷贝到10.0.4.20节点执行即可。 + +场景2:删除节点。 + +如果我们要删除一个节点,需要在保留的节点和数据库节点中,将该节点的访问权限删除。假如我们要删除10.0.4.22节点,设置防火墙规则需要下面3个步骤。 + + +在保留节点删除10.0.4.22节点访问权限: + + +$ go run tools/geniptables/main.go -c access.yaml -t app --delete 10.0.4.22 -o delete.sh + + +将delete.sh脚本拷贝到保留节点(10.0.4.20,10.0.4.21),并执行。 + + +在数据库节点删除10.0.4.22节点访问权限: + + +$ go run tools/geniptables/main.go -c access.yaml -t db --delete 10.0.4.22 -o delete.sh + + +将delete.sh脚本拷贝到10.0.4.20节点执行即可。 + + +将下线的节点从access.yaml文件中的hosts部分删除。 + + +IAM安全加固(内网安全) + +这里,我们来看第二种情况:假定我们系统部署在一个安全的内网环境中,这时候加固系统就会变得异常简单,只需要允许来源IP为内网IP的客户端访问我们提供的各类端口即可。在我们设置完iptables规则之后,后续再新增或者删除节点,就不需要再做变更了。 + +具体可以分为5个步骤。 + +第一步,进入iam项目源码根目录。 + +第二步,配置accesss.yaml(工具根据此配置,自动生成iptables设置脚本),内容如下(configs/access.yaml文件): + +# 允许登录SSH节点的来源IP,可以是固定IP(例如10.0.4.2),也可以是个网段,0.0.0.0/0代表不限制来源IP +ssh-source: 10.0.4.0/24 + +# 来源IP可以访问的应用端口列表(iam-apiserver, iam-authz-server, iam-pump对外暴露的的端口) +ports: + - 8080 + - 8443 + - 9090 + - 9443 + - 7070 + +# 来源IP可以访问的数据库端口列表(Redis, MariaDB, MongoDB) +dbports: + - 3306 + - 6379 + - 27017 + + +上面配置中,我们仅仅指定了IAM服务端口和数据库端口。 + +第三步,生成iptables初始化脚本: + +$ go run tools/geniptables/main.go -c access.yaml -t app --cidr=10.0.4.0/24 -a -o firewall.sh +$ ls firewall.sh +firewall.sh + + +第四步,将firewall.sh脚本拷贝到10.0.4.20和10.0.4.21节点执行: + +$ scp firewall.sh [email protected]:/tmp/ +$ scp firewall.sh [email protected]:/tmp/ + + +登陆10.0.4.20和10.0.4.21机器执行 /tmp/firewall.sh 。 + +第五步,在10.0.4.20(数据库节点)节点上,设置iptables规则,以允许各节点访问。 + +因为数据库节点也位于10.0.4.20节点,所以只需要添加新的rule,并将 iptables -A INPUT -j DROP 规则放到最后执行即可。 + +$ go run tools/geniptables/main.go -c access.yaml -t db --cidr=10.0.4.0/24 -o addrules.sh + + +然后,将 addrules.sh 脚本拷贝到10.0.4.20节点执行。 + +如果要增加节点,你只需要重新执行第三步,生成firewall.sh脚本,并将firewall.sh脚本拷贝到新节点上执行即可。删除节点,则不需要做任何操作。 + +接下来,我们再来看下如何对IAM应用进行弹性伸缩操作。 + +弹性伸缩 + +弹性伸缩包括扩容和缩容。扩容是指当业务量越来越大时,能够很容易地增加计算节点,来分散工作负载,从而实现计算等能力的扩展。缩容是指当业务量变小时,能够很容易地减少计算节点,从而减小成本。 + +在系统上线初期,通常业务量不会很大,但是随着产品的迭代,用户量的增多,系统承载的请求量会越来越多,系统承载的压力也会越来越大。这时,就需要我们的系统架构有能力进行水平扩容,以满足业务需求,同时避免因为系统负载过高造成系统雪崩。 + +一些电商系统,在双11这类促销活动之前会提前扩容计算节点,以应对即将到来的流量高峰。但是活动过后,流量会逐渐下降,这时就需要我们的系统有能力进行缩容,以减少计算节点,从而节省成本。 + +一个可伸缩的系统架构,是我们在进行系统设计时必须要保证的。如果系统不具有伸缩性,那么当我们后期需要扩缩容时,就需要对代码进行大改,不仅会增加额外的工作量,还会拖累产品的迭代速度。而且你想想,改完之后还要测试,发布之后,还可能因为代码变更引入Bug。总之,不具伸缩性的系统架构可以说是后患无穷。 + +IAM系统在设计之初就考虑到了系统的伸缩能力,我们可以很容易地对系统进行扩缩容。下面,我来分别介绍下如何对系统进行扩容和缩容。 + +系统扩容 + +系统扩容的步骤很简单,你只需要进行下面这5步: + + +根据需要申请计算节点,如无特殊需求,计算节点的配置、操作系统等要跟已有的节点保持一致。 +在新的节点上部署iam-apiserver、iam-authz-server、iam-pump,部署方式跟部署其他节点一样。 +在新节点部署Nginx,并将新节点的IP加入到已有所有节点的Nginx upstream配置中,重启Nginx。 +在新节点部署Keepalived,并将新节点的IP加入到已有所有节点的unicast_peer配置中,重启Keepalived。 +修改iptables规则,并刷新所有机器的iptables。 + + +系统缩容 + +系统缩容是系统扩容的逆向操作,也是5个步骤: + + +根据需要,确定要删除的节点。 +关闭待删除节点的iam-apiserver、iam-authz-server、iam-pump服务。 +从所有保留节点的Nginx upstream配置中,删除待删除节点的IP地址, 重启Nginx。 +从所有保留节点的Keepalived unicast_peer配置中,删除待删除节点的IP地址, 重启Keepalived。 +修改iptables规则,并刷新所有保留机器的iptables。 + + +总结 + +安全对于应用软件来说至关重要,在部署应用时,也一定要评估应用的安全性,并采取一定的措施来保证安全性。 + +在进行软件部署时,保证应用安全性最简单有效的方式是使用iptables规则来加固系统。实现思路也很简单,就是使用iptables规则,只允许特定来源的IP访问特定的端口。 + +在业务正式上线之后,可能会遇到业务高峰期或低峰期。业务高峰期,可能需要添加机器,提高系统的吞吐量,可以在新机器上安装需要扩容的服务组件,并安装和配置好Nginx和Keepalived,之后将该服务器添加到Nginx的upstream中。在业务低峰期时,可以将服务器从Nginx的upstream列表中移除,并关停IAM应用的服务。 + +课后练习 + + +请根据这一讲学习的内容,再增扩容一台机器。 +思考下,你在应用部署时,还有哪些比较好的应用安全加固方法,欢迎在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/43技术演进(上):虚拟化技术演进之路.md b/专栏/Go语言项目开发实战/43技术演进(上):虚拟化技术演进之路.md new file mode 100644 index 0000000..4c820a5 --- /dev/null +++ b/专栏/Go语言项目开发实战/43技术演进(上):虚拟化技术演进之路.md @@ -0,0 +1,385 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 43 技术演进(上):虚拟化技术演进之路 + 你好,我是孔令飞。 + +在前面的三讲中,我介绍了传统应用的部署方式。但是,随着软件架构进入云原生时代,我们越来越多地使用云原生架构来构建和部署我们的应用。为了给你演示如何使用云原生化的方式来部署IAM应用,接下来我会介绍如何基于Kubernetes来部署IAM应用。 + +在Kubernetes集群中部署IAM应用,会涉及到一些重要的云原生技术,例如Docker、Kubernetes、微服务等。另外,云原生架构中还包含了很多其他的技术。为了让你提前了解后面部署需要的相关技术,同时比较通透地了解当前最火热的云原生架构,这一讲我就采用技术演进的思路,来详细讲解下云原生技术栈的演进中的虚拟化技术演进部分。 + +因为这一讲涉及的技术栈很多,所以我会把重点放在演进过程上,不会详细介绍每种技术的具体实现原理和使用方法。如果你感兴趣,可以自行学习,也可以参考我为你整理的这个资料:awesome-books。 + +在讲这个演进过程之前,我们先来看下这个问题:我们为什么使用云? + +我们为什么使用云? + +使用云的原因其实很简单,我们只是想在云上部署一个能够对外稳定输出业务能力的服务,这个服务以应用的形态部署在云上。为了启动一个应用,我们还需要申请系统资源。此外,我们还需要确保应用能够快速迭代和发布,出故障后能够快速恢复等,这就需要我们对应用进行生命周期管理。 + +应用、系统资源、应用生命周期管理这3个维度就构成了我们对云的所有诉求,如下图所示: + + + +接下来的两讲,我就围绕着这3个维度,来给你详细介绍下每个维度的技术演进。这一讲,我会先介绍下系统资源维度的技术演进。在44讲,我会再介绍下应用维度和应用生命周期管理维度的技术演进。 + +当前有3种系统资源形态,分别是物理机、虚拟机和容器,这3种系统资源形态都是围绕着虚拟化来演进的。所以,介绍系统资源技术的演进,其实就是介绍虚拟化技术的演进。 + +接下来,我们就来看下虚拟化技术是如何演进的。 + +虚拟化技术的演进 + +虚拟化这个概念,其实在20世纪60年代就已经出现了。但因为技术、场景等限制,虚拟化技术曾沉寂过一段时间,直到21世纪虚拟机出现,虚拟化技术又迎来了一波爆发期,并逐渐走向成熟。 + +那么,什么是虚拟化技术呢?简单来讲,就是把计算机上的硬件、系统资源划分为逻辑组的技术,由此生成的仅仅是一个逻辑角度的视图。通过虚拟化技术,我们可以在一台计算机上运行多个虚拟机进程,进而发挥计算机硬件的最大利用率。 + +虚拟化分为很多种,例如操作系统虚拟化、存储虚拟化、网络虚拟化、桌面虚拟化等。其中,最重要的是操作系统虚拟化,支撑操作系统虚拟化的是底层CPU、内存、存储、网络等的虚拟化,这些资源我们统称为计算资源。 + +因为计算资源的虚拟化在虚拟化领域占主导地位,所以很多时候我们说虚拟化技术演进,其实就是在说计算资源技术的演进。在我看来,虚拟化技术的演进过程如下:物理机阶段 -> 虚拟机阶段 -> 容器阶段(Docker + Kubernetes) -> Serverless阶段。 + +物理机阶段 + +上面我提到虚拟化技术包含很多方面,但是整个虚拟化技术是围绕着CPU虚拟化技术来演进的。这是因为,内存虚拟化、I/O虚拟化的正确实现,都依赖于对内存、I/O中一些敏感指令的正确处理,这就涉及到CPU虚拟化,所以CPU虚拟化是虚拟化技术的核心。因此,这一讲我会围绕着CPU虚拟化的演进,来讲解虚拟化技术的演进。这里,我先来介绍一下物理机阶段CPU的相关知识。 + +CPU是由一系列指令集构成的,这些指令集主要分为两种,分别是特权指令集和非特权指令集。特权指令集是指那些可以改变系统状态的指令集,非特权指令集是指那些不会影响系统状态的指令集。我举个例子你就明白了:写内存是特权指令集,因为它可以改变系统的状态;读内存是非特权指令集,因为它不会影响系统的状态。 + +因为非特权指令集可能会影响整个系统,所以芯片厂商在x86架构上又设计了一种新模式,保护模式,这个模式可以避免非特权指令集非法访问系统资源。 + +保护模式是通过Ring来实现的。在x86架构上,一共有4个Ring,不同的Ring有不同的权限级别:Ring 0有最高的权限,可以操作所有的系统资源,Ring 3的权限级别最低。Kernel运行在Ring 0上,Application运行在Ring 3上。Ring 3的Application如果想请求系统资源,需要通过system call调用Ring 0的内核功能,来申请系统资源。 + +这种方式有个好处:可以避免Applicaiton直接请求系统资源,影响系统稳定性。通过具有更高权限级的Kernel统一调度、统一分配资源,可以使整个系统更高效,更安全。 + +x86架构的Ring和调用关系如下图所示: + + + +在物理机阶段,对外提供物理资源,这种资源提供方式面临很多问题,例如成本高,维护麻烦、需要建机房、安装制冷设备、服务器不方便创建、销毁等等。所以在云时代,和物理机相比,我们用得更多的是虚拟机。下面我们就来看虚拟机阶段。 + +虚拟机阶段 + +这里,在讲虚拟化技术之前,我想先介绍下x86的虚拟化漏洞,CPU虚拟化技术的演进也主要是围绕着解决这个漏洞来演进的。 + +虚拟化漏洞 + +一个虚拟化环境分为三个部分,分别是硬件、虚拟机监控器(又叫VMM,Virtual Machine Manager),还有虚拟机。 + +你可以把虚拟机看作物理机的一种高效隔离的复制,它具有三个特性:同质、高效、资源受控。这三个特点决定了不是所有体系都可以虚拟化,比如目前我们用得最多的x86架构,就不是一个可虚拟化的架构,我们称之为虚拟化漏洞。 + +在虚拟化技术产生后,诞生了一个新的概念:敏感指令。敏感指令是指可以操作特权资源的指令,比如修改虚拟机运行模式、物理机状态,读写敏感寄存器/内存等。显然,所有的特权指令都是敏感指令,但不是所有的敏感指令都是特权指令。特权指令和敏感指令的关系,可以简单地用这张图来表示: + + + +在一个可虚拟化的架构中,所有的敏感指令应该都是特权指令。x86架构中有些敏感指令不是特权指令,最简单的例子是企图访问或修改虚拟机模式的指令。所以,x86架构是有虚拟化漏洞的。 + +Hypervisor技术的演进 + +为了解决x86架构的虚拟化漏洞,衍生出了一系列的虚拟化技术,这些虚拟化技术中最核心的是Hypervisor技术。所以接下来,我就介绍下Hypervisor技术的演进。 + +Hypervisor,也称为虚拟机监控器 VMM,可用于创建和运行虚拟机 (VM)。它是一种中间软件层,运行在基础物理服务器和操作系统之间,可允许多个操作系统和应用共享硬件。通过让Hypervisor以虚拟化的方式共享系统资源(如内存、CPU资源),一台主机计算机可以支持多台客户机虚拟机。 + +Hypervisor、物理机和虚拟机的关系如下图: + + + +按时间顺序,Hypervisor技术的发展依次经历了下面3个阶段: + + +软件辅助的完全虚拟化(Software-assisted full virtualization):该虚拟化技术在1999年出现,里面又包含了解释执行(如Bochs)、扫描与修补(如VirtualBox)、二进制代码翻译(如Vmware、Qemu)三种技术。 +半虚拟化(Para-virtualization):该虚拟化技术在2003年出现,也叫类虚拟化技术,典型的Hypervisor代表是Xen。 +硬件辅助的完全虚拟化(Hardware-assistant full virtualization ):该虚拟化技术在2006年出现,典型的Hypervisor代表是KVM。当前普遍使用的主流虚拟化技术,就是以KVM为代表的硬件辅助的完全虚拟化。 + + +下面,我就来简单介绍下这三个阶段。 + +先来看第一个阶段,软件辅助的完全虚拟化,它又分为解释执行、扫描与修补、二进制代码翻译三个演进阶段。 + + +解释执行 + + +简单地说,解释执行的过程就是取一条指令,模拟出这条指令的执行效果,再取下一条指令。这种技术因为思路比较简单,所以容易实现,复杂度低。执行时,编译好的二进制代码是不会被载入到物理CPU直接运行的,而是由解释器逐条解码,再调入对应的函数来模拟指令的功能。解释过程如下图所示: + + + +因为每一条指令都要模拟,所以就解决了虚拟化漏洞,同时也可以模拟出一个异构的CPU结构,比如在x86架构上模拟出一个ARM架构的虚拟机。也正是因为每一条指令都需要模拟,不区别对待,导致这种技术的性能很低。 + + +扫描与修补 + + +由于解释执行性能损失很大,再加上虚拟机中模拟的虚拟CPU和物理CPU的体系结构相同(同质),这样大多数指令可以直接在物理CPU上运行。因此,CPU虚拟化过程中,可以采用更优化的模拟技术来弥补虚拟化漏洞。 + +扫描与修补技术就是通过这种方式,让大多数指令直接在物理CPU上运行,而把操作系统中的敏感指令替换为跳转指令,或者会陷入到VMM中去的指令。这样,VMM一旦运行到敏感指令,控制流就会进入VMM中,由VMM代为模拟执行。过程如下图所示: + + + +使用这种方式,因为大部分指令不需要模拟,可以直接在CPU上运行,所以性能损失相对较小,实现起来比较简单。 + + +二进制代码翻译 + + +这个算是软件辅助的完全虚拟化的主流方式了,早期的VMware用的就是这个技术。二进制代码翻译会在VMM中开辟一段缓存,将翻译好的代码放在缓存中。在执行到某条指令的时候,直接从内存中找到这条指令对应的翻译后的指令,然后在CPU上执行。 + +在性能上,二进制代码翻译跟扫描与修补技术各有长短,但是实现方式最为复杂。它的过程如下图所示: + + + +看到这里,你可能会对模拟和翻译这两个概念有疑惑,我在这里解释下模拟和翻译的区别:模拟是将A动作模拟成B动作,而翻译是将A指令翻译成B指令,二者是有本质不同的。 + +然后,我们来看Hypervisor技术发展的第二个阶段,Para-virtualization。 + +软件辅助的完全虚拟化对x86的指令做了翻译或者模拟,在性能上,多多少少都会有些损失,而这些性能损失在一些生产级的场景是不可接受的。所以,在2003年出现了Para-virtualization技术,也叫半虚拟化/类虚拟化。和之前的虚拟化技术相比,Para-virtualization在性能上有了大幅度的提升,甚至接近于原生的物理机。 + +Para-virtualization的大概原理是这样的:Hypervisor运行在Ring 0中,修改客户机操作系统内核,将其中的敏感指令换成hypercall。hypercall是一个可以直接跟VMM通信的函数,这样就绕过了虚拟化的漏洞(相当于所有敏感指令都被VMM捕获了),同时不存在模拟和翻译的过程,所以性能是最高的。这个过程如下图所示: + + + +因为要修改操作系统,所以不能模拟一些闭源的操作系统,比如Windows系列。另外,修改客户机操作系统内核还是有些开发和维护工作量的。所以,随着硬件辅助完全虚拟化技术的成熟,Para-virtualization也逐渐被替换掉了。 + +然后,我们来看Hypervisor技术发展的第三个阶段,硬件辅助的完全虚拟化。 + +在2006年,Intel和AMD分别在硬件层面支持了虚拟化,比如Intel的VT-X技术和AMD的SVM。它们的核心思想都是引入新运行模式,可以理解为增加了一个新的CPU Ring -1,权限比Ring 0 还高,使VMM运行在Ring -1下,客户机内核运行在Ring 0下。 + +通常情况下,客户机的核心指令可以直接下达到计算机系统硬件执行,不需要经过VMM。当客户机执行到敏感指令的时候,CPU会从硬件层面截获这部分敏感指令,并切换到VMM,让VMM来处理这部分敏感指令,从而绕开虚拟化漏洞。具体如下图所示: + + + +因为CPU是从硬件层面支持虚拟化的,性能要比软件模拟更高,同时硬件虚拟化可以不用去修改操作系统。所以,即使是现在,硬件辅助的完全虚拟化也是主流的虚拟化方式。 + +接下来我们来看虚拟化技术演进的第三阶段,容器阶段。 + +容器阶段 + +2005年,诞生了一种新的虚拟化技术,容器技术。容器是一种轻量级的虚拟化技术,能够在单一主机上提供多个隔离的操作系统环境,通过一系列的命名空间隔离进程,每个容器都有唯一的可写文件系统和资源配额。 + +容器引擎Docker + +容器技术的的代表项目就是Docker,Docker是Docker公司在2013年推出的容器项目,因为轻量、易用的特点,迅速得到了大规模的使用。Docker的大规模应用使得系统资源的形态由虚拟机阶段进入到了容器阶段。 + +基于Docker容器化技术,开发者可以打包他们的应用以及依赖和配置到一个可移植的容器中,然后发布到任何流行的 Linux/Windows 机器上。开发者无需关注底层系统、环境依赖,这使得容器成为部署单个微服务的最理想的工具。 + +Docker通过Linux Namespace技术来进行资源隔离,通过Cgroup技术来进行资源分配,具有更高的资源利用率。Docker跟宿主机共用一个内核,不需要模拟整个操作系统,所以具有更快的启动时间。在Docker镜像中,已经打包了所有的依赖和配置,这样就可以在不同环境有一个一致的运行环境,所以能够支持更快速的迁移。另外,Docker的这些特性也促进了DevOps技术的发展。 + +我这里拿Docker和虚拟机来做个对比,让你感受下Docker的强大。二者的架构对比如下图所示: + + + +可以看到,Container相比于虚拟机,不用模拟出一个完整的操作系统,非常轻量。因此,和虚拟机相比,容器具有下面这些优势: + + + +从这张表格里你可以看到,在启动时间、硬盘占用量、性能、系统支持量、资源使用率、环境配置这些方面,Docker和虚拟机相比具有巨大的优势。这些优势,使得Docker成为比虚拟机更流行的应用部署媒介。 + +也许这时你想问了:Docker就这么好,一点缺点都没有吗?显然不是的,Docker也有自己的局限性。 + +我们先来看一下生产环境的Docker容器是什么样的:一个生产环境的容器数量可能极其庞大,关系错综复杂,并且生产环境的应用可能天生就是集群化的,具备高可用、负载均衡等能力。Docker更多是用来解决单个服务的部署问题,无法解决生产环境中的这些问题。并且,不同节点间的Docker容器无法相互通信。 + +不过,这些问题都可以通过容器编排技术来解决。业界目前也有很多优秀的容器编排技术,比较受欢迎的有Kubernetes、Mesos、Docker Swarm、Rancher等。这两年,随着Kubernetes的发展壮大,Kubernetes已经成为容器编排的事实标准。 + +容器编排技术Kubernetes + +因为我们后面会基于Kubernetes来部署IAM应用,所以这里我会详细介绍下Kubernetes服务编排技术。 + +Kubernetes是Google开源的一个容器编排技术(编排也可以简单理解为调度、管理),用于容器化应用的自动化部署、扩展和管理。它的前身是Google内部的Borg项目。Kubernetes的主要特性有网络通信、服务发现与负载均衡、滚动更新 & 回滚、自愈、安全配置管理、资源管理、自动伸缩、监控、服务健康检查等。 + +Kubernetes通过这些特性,解决了生产环境中Docker存在的问题。Kubernetes和Docker相辅相成,Kubernetes的成功也使Docker有了更大规模的使用,最终使得 Docker 成为比虚拟机更流行的计算资源提供方式。 + +接下来,我围绕着下面这张架构图来介绍K8S(Kubernetes)的基本概念: + + + +Kubernetes采用的是Master-Worker架构模式。其中,Master节点是Kubernetes最重要的节点,里面部署了Kubernetes的核心组件,这些核心组件共同构成了Kubernetes的Control Plane(控制面板)。而Worker,也就是图中的Node Cluster,就是节点集群。其中,每一个Node就是具体的计算资源,它既可以是一台物理服务器,也可以是虚拟机。 + +我们先来介绍下Master节点上的组件。 + + +Kube API Server:提供了资源操作的唯一入口,并提供认证、授权、访问控制、API 注册和发现等机制。 +Kube Scheduler:负责资源的调度,按照预定的调度策略将 Pod 调度到相应的机器上。 +Kube Controller Manager:负责维护集群的状态,比如故障检测、自动扩展、滚动更新等。 +Cloud Controller Manager:这个组件是在Kubernetes 1.6版本加入的与基础云提供商交互的控制器。 +Etcd:分布式的K-V存储,独立于Kubernetes的开源组件。主要存储关键的元数据,支持水平扩容保障元数据的高可用性。基于Raft算法实现强一致性,独特的watch机制是Kubernetes设计的关键。 + + +介绍完了Master,再看看每一个Kubernetes Node需要有哪些组件。 + + +Kubelet:负责维持容器的生命周期,同时也负责 volume(CVI)和网络(CNI)的管理。 +kube-proxy:kube-proxy是集群中每个节点上运行的网络代理,维护节点上的网络规则,它允许从集群的内部或外部网络与Pod进行网络通信,并负责为 Service 提供集群内部的服务发现和负载均衡。 +Container Runtime:负责镜像管理以及 Pod 和容器的真正运行(CRI),默认的容器运行时为 Docker。 + + +上面那张架构图里的Service、Deployment、Pod等,都不算是组件,而是属于Kubernetes资源对象,我们稍后再做介绍。这里我先简单介绍下架构图的UI dashboard和 kubectl。 + + +UI dashboard是Kubernetes官方提供的web控制面板,可以对集群进行各种控制,直接与API Server进行交互,其实就是API Server暴露出来的可视化接口。在这里可以直观地创建Kubernetes对象、查看Pod运行状态等。UI dashboard界面如下图所示: + + + + + +kubectl是Kubernetes的客户端工具,提供了非常多的命令、子命令、命令行选项,支持开发或运维人员在命令行快速操作Kubernetes集群,例如对各类Kubernetes资源进行增删改查操作,给资源打标签,等等。下面是执行kubectl describe service iam-pump命令获取iam-pump详细信息的命令行截图: + + + + +Kubernetes有多种多样的Objects,如果要查看所有Objects的Kind,可以使用命令kubectl api-resources。我们通过这些Objects来完成Kubernetes各类资源的创建、删除等操作。因为我们这一讲的核心目的是介绍云原生技术的演进,所以不会详细介绍Kubernetes资源对象的使用方式。你如果感兴趣,可以查看Kubernetes官方文档。 + +我这里简单介绍一下Kubernetes对象的一些基本信息,以及在架构图中出现的Deployment、Pod、Service三种对象。 + +下面是一个典型的Kubernetes对象YAML描述文件: + +apiVersion: v1 +kind: Pod +metadata: + name: nginx + labels: + name: nginx +spec: + # ... + + +在这个描述文件中,apiVersion和kind共同决定了当前YAML配置文件应该由谁来处理,前者表示描述文件使用的 API 组,后者表示一个 API 组中的一个资源类型。这里的 v1 和 Pod 表示的就是核心 API 组 api/v1 中的 Pod 类型对象。 + +metadata则是一些关于该对象的元数据,其中主要有name、namespace、labels、annotations。其中, name 需要在 namespace 下唯一,成为这个对象的唯一标识。label和annotations分别是这个对象的一些标签和一些注解,前者用于筛选,后者主要用来标注提示性的信息。 + +接下来,我再介绍下Pod、Deployment、Service 这3种对象。 + + +Pod + + +Pod是Kubernetes中运行的最小的、最简单的计算单元,我觉得Pod也是Kubernetes最核心的对象。Pod中可以指定运行多个Containers,可以挂载volume来实现部署有状态的服务,这些都在spec中被指定。 + +对于任意类型的对象,spec都是用来描述开发人员或运维人员对这个对象所期望的状态的,对于不同类型的对象,spec有不同的子属性。 + +下面是一个Pod示例,我们在YAML描述文件里指定了期望Pod运行的Docker镜像和命令: + +apiVersion: v1 +kind: Pod +metadata: + name: busybox + labels: + app: busybox +spec: + containers: + - image: busybox + command: + - sleep + - "3600" + imagePullPolicy: IfNotPresent + name: busybox + restartPolicy: Always + + + +Deployment + + +一般来说,我们不会直接部署Pod,而是部署一个Deployment或者StatefulSet之类的Kubernetes对象。Deployment一般是无状态服务;StatefulSet一般是有状态服务,会使用volume来持久化数据。下面是一个部署两个Pod的示例: + +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: my-nginx +spec: + selector: + matchLabels: + run: my-nginx + replicas: 2 + template: + metadata: + labels: + run: my-nginx + spec: + containers: + - name: my-nginx + image: nginx + ports: + - containerPort: 80 + + + +Service + + +Service是Kubernetes中另一个常见的对象,它的作用是作为一组Pod的负载均衡器,利用selector将Service和Pod关联起来。 + +下面这个示例里,使用的是run: my-nginx这个label。这个Service绑定的就是上面那个Deployment部署的nginx服务器: + +apiVersion: v1 +kind: Service +metadata: + name: my-nginx + labels: + run: my-nginx +spec: + ports: + - port: 80 + protocol: TCP + selector: + run: my-nginx + + +最后我还想介绍下基于Kubernetes的容器云平台。各大公有云厂商,都有基于Kubernetes的容器管理平台,目前国内容器服务平台做得比较好的有腾讯云容器服务TKE、阿里云容器服务ACK。 + +TKE基于原生 Kubernetes ,提供以容器为核心的解决方案,解决用户开发、测试及运维过程的环境问题,帮助用户降低成本、提高效率。腾讯云容器服务 TKE 完全兼容原生 Kubernetes API,并扩展了腾讯云的云硬盘、负载均衡等 Kubernetes 插件,同时以腾讯云私有网络为基础,实现了高可靠、高性能的网络方案。 + +Serverless阶段 + +容器阶段之后,虚拟化技术的演进方向是什么呢?我们接着来看下Serverless阶段。 + +在2014年的时候,AWS推出了Lambda服务,这是一个Serverless服务。从此,Serverless越来越引人注意,成为了这几年最受关注的技术。我先介绍下什么是Serverless。 + +Serverless直译过来就是无服务器,无服务器并不代表Serverless真的不需要服务器,只不过服务器的管理,以及资源的分配部分对用户不可见,而是由平台开发商维护。Serverless不是具体的一个编程框架、类库或者工具,它是一种软件系统架构思想和方法。它的核心思想是:用户无需关注支撑应用服务运行的底层资源,比如CPU、内存和数据库等,只需要关注自己的业务开发就行了。 + +Serverless具有很多特点,核心特点主要有下面这几个。 + + +无穷弹性计算能力:根据请求,自动水平扩容实例,拥有近乎无限的扩容能力。 +“零”运维:不需要申请和运维服务器。 +极致的伸缩能力:能够根据CPU、内存、请求量等指标敏感地弹性伸缩,并支持缩容到 0。 +按量计费:真正按使用量去计费。 + + +在我看来,Serverless有3种技术形态,分别是云函数、Serverless容器、BaaS(Backend as a Service),如下图: + + + +这3种Serverless技术形态中,Serverless容器是核心,云函数和BaaS起辅助作用。Serverless容器可以承载业务的核心架构,云函数则可以很好地适配触发器场景,BaaS则可以满足我们对各种其他Serverless组件的需求,例如Serverless数据库、Serverless存储等。 + +这3种技术形态,各大公用云厂商都早已有相应的产品,其中比较优秀的产品是腾讯云推出的Serverless产品,SCF、EKS和TDSQL-C。下面我分别介绍下。 + + +EKS:弹性容器服务(Elastic Kubernetes Service)是腾讯云容器服务推出的无需用户购买节点即可部署工作负载的服务模式。EKS 完全兼容原生 Kubernetes,支持使用原生方式购买及管理资源,按照容器真实使用的资源量计费。 +SCF:云函数(Serverless Cloud Function)是腾讯云为企业和开发者们提供的无服务器执行环境,帮助你在无需购买和管理服务器的情况下运行代码。你只需使用平台支持的语言编写核心代码,并设置代码运行的条件,就能在腾讯云基础设施上弹性、安全地运行代码。 +TDSQL-C:云原生数据库(Cloud Native Database TDSQL-C)是腾讯云自研的新一代高性能高可用的企业级分布式云数据库,具有高吞吐量、高可靠性等优点。 + + +我们开始的时候提到,应用、系统资源、应用生命周期管理这3个维度构成了我们对云的所有诉求。那么到这里,系统资源维度的技术演进我就介绍完了。下一讲,我会介绍应用维度和应用生命周期管理维度的技术演进。 + +总结 + +这一讲,我主要通过虚拟化技术的演进,介绍了系统资源维度的技术演进。 + +虚拟化技术的演进流程为:物理机阶段 -> 虚拟机阶段 -> 容器阶段 -> Serverless阶段。其中,物理机到虚拟机阶段的演进技术,主要是为了解决x86架构的虚拟化漏洞。要虚拟CPU、内存和I/O,就需要捕获其中的敏感指令,防止这些敏感指令修改系统状态,影响系统的稳定性。x86架构有些敏感指令不是特权指令,导致这些指令可以从客户机中直接在物理CPU上执行,从而可能会影响系统状态。所以,我们说x86架构是有虚拟化漏洞的。 + +在虚拟机阶段,又诞生了3种不同的虚拟化技术,分别是软件辅助的完全虚拟化、半虚拟化和硬件辅助的完全虚拟化。因为硬件辅助的完全虚拟化技术不需要修改客户机内核,并且有着接近物理机的性能,所以成为当前的虚拟化主流技术,并以KVM为事实技术标准。 + +因为容器技术比虚拟机更加轻量,再加上Docker、Kubernetes项目的诞生,使得大规模使用容器技术变得可行,所以这几年系统资源的提供形态已经由虚拟机转变成了容器。 + +系统资源的最终形态,我认为会是Serverless。Serverless技术中,又分为3种技术形态:云函数、Serverless容器和BaaS。在业务架构Serverless化的过程中,整个部署架构会以Serverless容器为主,云函数为辅。 + +课后练习 + + +Docker的隔离性比虚拟机弱一些,思考下,有没有一种更好的方式,既可以快速启动一个轻量级的容器,又拥有比Docker更好的隔离性? +思考下,如何将一个普通的Kubernetes集群,转变为Serverless化的Kubernetes集群。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/44技术演进(下):软件架构和应用生命周期技术演进之路.md b/专栏/Go语言项目开发实战/44技术演进(下):软件架构和应用生命周期技术演进之路.md new file mode 100644 index 0000000..b462e53 --- /dev/null +++ b/专栏/Go语言项目开发实战/44技术演进(下):软件架构和应用生命周期技术演进之路.md @@ -0,0 +1,314 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 44 技术演进(下):软件架构和应用生命周期技术演进之路 + 你好,我是孔令飞。 + +应用、系统资源、应用生命周期管理这 3 个维度,构成了我们对云的所有诉求。上一讲,我从系统资源维度,介绍了虚拟化技术的演进之路。这一讲,我会介绍下应用维度和应用生命周期管理维度的技术演进。 + +应用软件架构是用来构建应用的,不同的软件架构,构建应用的方式、效率,以及所构建应用的可维护度、性能都是不同的。随着技术的不断更新迭代,应用软件架构也在不断往前演进。这一讲我们就来看看,应用软件架构都有哪些,这些软件架构都有什么特点,以及它们之间是如何演进的。 + +至于应用生命周期管理维度,我在 09讲 中已经介绍了应用生命周期管理技术的演进,这一讲也会再补充一些核心的技术,比如日志、监控告警、调用链等。 + +接下来,我们就先来看下软件架构的演进之路。 + +软件架构的演进 + +软件架构技术演进如下图所示: + + + +最开始,我们使用单体架构来构建应用,后面逐渐演进为SOA架构。不管是单体架构,还是SOA架构,都很难满足互联网时代应用快速迭代的诉求。所以,在互联网时代,应用软件架构又演进成了微服务架构。当前我们正处在微服务架构阶段,也有很多团队的业务正在尝试使用Service Mesh替代微服务架构中的一些功能。 + +随着Serverless云函数的诞生,也诞生了一种新的软件架构,FaaS架构。这里我先简单介绍下它,后面再详细讲。FaaS架构因为限制多、使用场景局限,目前还仅仅适用于云函数这种系统资源形态,我个人认为它不会成为未来主流的软件架构。还要说明下,业界目前并没有FaaS软件架构这个说法,大家说到FaaS,一般指的都是云函数这种技术形态。这里为了方便描述,我们先这样表达。 + +接下来,我仍然以技术演进的思路,来介绍下这些软件架构。首先来看下最早的单体架构。 + +单体架构 + +在最早的时候,我们用的软件架构是单体架构。在单体架构中,我们会将应用程序的所有功能都存放在一个代码仓库中,并且发布时,也是发布整个代码仓库的代码和功能。 + +在单体架构中,应用软件一般会包含四层,分别是表示层、业务逻辑层、数据访问层、数据库,如下图所示: + + + +这里简单介绍下每层的功能。 + + +表示层:用于直接和用户交互,通常是网页、UI界面。 +业务逻辑层:用来进行业务逻辑处理。使用表示层传来的参数,进行业务逻辑处理,并将结果返回给表示层。 +数据访问层:用来操作数据库,通常包括数据的CURD操作。例如,从数据库中查询用户信息,或者往数据库增加一条用户记录。 +数据库:存储数据的物理介质。我们通过数据访问层来访问数据库中的数据。 + + +单体架构的优点是应用开发简单,技术单一,测试、部署相对简单明了。因此它比较适合用户访问量较小的应用服务端。但它的缺陷也是非常明显的。随着业务的发展,项目越来越大,单体架构会带来开发效率低、发布周期长、维护困难、稳定性差、扩展性差等问题。另外,单体架构的技术栈也不易扩展,只能在原有的基础上,不断地进行局部优化。 + +SOA架构 + +为了解决单体架构在业务代码变大时带来的各种问题,SOA架构出现了。 + +SOA架构是面向服务的软件架构,它的核心理念是:基于SOA的架构思想,将重复共用的功能抽取为组件,以服务的方式给各系统提供服务,服务之间通过ESB企业服务总线进行通信。如下图所示: + + + +SOA架构中,主要有两个角色,分别是服务提供者和服务消费者。服务消费者可以通过发送消息来调用购买商品、申请售后的服务,这些消息由ESB总线转换后,发送给对应的服务,实现 SOA 服务之间的交互通信。 + +SOA架构主要适用于大型软件服务企业对外提供服务的场景,至于一般业务场景就并不适用了。这是因为,SOA服务的定义、注册和调用都需要繁琐的编码或者配置来实现,并且ESB总线也容易导致系统的单点风险,并拖累整体性能。 + +微服务架构 + +在互联网时代,越来越多的企业推出了面向普通大众的网站和应用。这些企业没有能力,也没有必要构建和维护ESB企业服务总线。于是,基于SOA架构,又演进出了微服务架构。 + +微服务架构由Matrin Fowler在2014年提出,它的理念是将业务系统彻底地组件化和服务化,形成多个可以独立开发、部署和维护的服务或应用的集合。微服务之间采用RESTful等轻量的传输协议,来应对更快的需求变更和更短的开发迭代周期。如下图所示: + + + +微服务架构提出得比较早,但在这几年才逐渐流行起来。这是什么原因呢?一方面,微服务架构基于自身的特点,确实能够解决其他软件架构中存在的一些问题;另一方面,Docker + Kubernetes等云原生技术这几年也发展了起来,能够很好地支撑微服务的部署和生命周期管理。 + +总体来说,微服务架构有下面这几个特点: + + +微服务遵循单一原则,每个微服务负责一个独立的上下文边界; +微服务架构提供的服务之间采用 RESTful 等轻量协议传输,比 ESB 更轻量; +每个服务都有自己独立的业务开发活动和周期; +微服务一般使用容器技术独立部署,运行在自己的独立进程中,合理分配其所需的系统资源。这样,开发者就可以更加方便地制定每个服务的优化方案,提高系统可维护性。 + + +微服务架构有很多优点,但也存在着问题。因为一个应用被拆分成一个个的微服务,随着微服务的增多,就会引入一些问题,比如微服务过多导致服务部署复杂。微服务的分布式特点也带来了一些复杂度,比如需要提供服务发现能力、调用链难以追踪、测试困难,等等。服务之间相互依赖,有可能形成复杂的依赖链路,往往单个服务异常,其他服务都会受到影响,出现服务雪崩效应。 + +目前业界针对这些问题也有一些标准的解决方案,比如,可以通过Kubernetes、Helm和CI/CD技术,解决微服务部署复杂的问题。至于微服务的分布式特点所带来的复杂性,可以通过一些微服务开发框架来解决。一些业界比较知名的微服务开发框架,比如Spring Cloud和Dubbo,已经很好地解决了上面的问题。另外,云原生相关的技术也可以解决微服务调用链跟踪复杂、故障排障困难等问题。 + +另外,在我的日常开发中,经常会有开发者把SOA架构和微服务架构给搞混,所以我在这里再来介绍下二者的相同点和不同点。 + +微服务架构是SOA架构设计思想的另一种实现方式,这是二者相同的地方。至于区别,主要有三个。理解了下面这三点,以后你在开发中就很容易区分它们了。 + + +SOA中的服务,其实只能属于某个应用的服务之一,微服务中的服务则是一个独立的服务,可以被多个应用共用。 +SOA强调尽可能多地共享,而微服务强调尽可能少地共享。 +SOA架构中,服务之间通过ESB来通信,而微服务中,服务之间通过轻量化机制,比如RESTful来实现通信。 + + +Service Mesh + +在讲微服务的时候,我提到微服务架构的一些问题可以通过一些微服务开发框架来解决,比如Spring Cloud和Dubbo。但这里也有个问题:这些框架通常是侵入式的,比如语言只能限制在Java,并且开发的时候要按框架的指定方式来开发。这个理念跟微服务的独立技术栈也是相反的。 + +2017年底Service Mesh(服务网格)的出现解决了这个问题,它是一种非侵入式技术,可以提供服务之间的网络调用、限流、熔断和服务监控等功能。Service Mesh类似于TCP/IP协议,无需应用层感知,开发者只需要开发应用程序即可。所以,Service Mesh是致力于解决服务间通讯的基础设施层,它具有下面这几个特点: + + +应用程序间通讯的中间层。 +轻量级网络代理。 +非侵入式,应用程序无感知。 +可以将服务治理功能,例如重试、超时、监控、链路追踪、服务发现等功能,以及服务本身解耦。 + + +Service Mesh目前的发展比较火热,社区有很多优秀的Service Mesh开源项目,例如 Istio 、Linkerd 等。当前最受欢迎的开源项目是Istio。 + +Istio是一个完全开源的服务网格,作为透明的一层接入到现有的分布式应用程序里,提供服务治理等功能。它也是一个平台,拥有可以集成任何日志、遥测和策略系统的 API 接口。 + +Istio的大概实现原理是:每个服务都会被注入一个Sidecar(边车)组件,服务之间通信是先通过Sidecar,然后Sidecar再将流量转发给另一个服务。因为所有流量都经过一个Sidecar,所以可以通过Sidecar实现很多功能,比如认证、限流、调用链等。同时还有一个控制面,控制面通过配置Sidecar来实现各种服务治理功能。 + +目前Istio的最新版本是1.8,1.8版本的Istio架构图如下: + + + +从图中你可以看到,Istio主要包含两大平面。一个是数据平面(Data plane),由Envoy Proxy充当的Sidecar组成。另一个是控制平面(Control plane),主要由三大核心组件Pilot、Citadel、Galley组成。下面,我来分别介绍下这三大核心组件的功能。 + + +Pilot:主要用来管理部署在Istio服务网格中的Envoy代理实例,为它们提供服务发现、流量管理以及弹性功能,比如A/B测试、金丝雀发布、超时、重试、熔断等。 +Citadel:Istio的核心安全组件,负责服务的密钥和数字证书管理,用于提供自动生成、分发、轮换及撤销密钥和数据证书的功能。 +Galley:负责向Istio的其他组件提供支撑功能,可以理解为Istio的配置中心,它用于校验进入网络配置信息的格式内容正确性,并将这些配置信息提供给Pilot。 + + +FaaS架构 + +这几年,以云函数为代表的Serverless技术异常火爆。伴随着Serverless技术的发展,一个新的软件开发模式也诞生了,这就是FaaS架构。 + +FaaS架构提供了一种比微服务更加服务碎片化的软件架构模式。简单来说,FaaS架构就是把之前一个完整的业务拆分成一个个Function来部署,通过事件来触发底层Function的执行。 + +Function里可能会调用第三方组件,比如数据库、消息队列服务等,这些第三方组件在Serverless架构中,统称为BaaS(Backend as a Serivce)。BaaS把这些后端的服务能力抽象成API让用户调用,用户不需要关注这些后端组件的高可用、扩缩容等运维层面的点,只需要去使用就可以了。 + +下面是FaaS架构的示意图: + + + +从这张图里你可以看到,用户通过浏览器、手机、小程序等客户端请求触发器服务,例如API网关、COS对象存储、CLS日志等。这些触发器服务在收到来自用户的请求之后,会触发它们所绑定的云函数,云函数会根据请求量等数据,实时启动多个并发实例。在触发云函数时,也会传递参数给云函数,并在云函数中使用这些参数,进行一些业务逻辑处理。例如,调用第三方的服务,将处理结果保存在后端数据库中。 + +在我看来,FaaS架构未来不会成为主流,更多的是存在于云函数的场景中。我这么说是因为,如果将应用拆分成一个个Function,这些Function的部署、维护,以及之间的通信会是一个巨大的挑战,从目前来看,还不存在解决这种挑战的技术和条件。另外,FaaS架构也不适合承载一些较重的业务逻辑,比如还没法大规模迁移企业的应用系统。 + +应用生命周期管理技术:监控告警、日志、调用链 + +在这门课的 09讲 中,我已经详细介绍了应用生命周期管理技术的演进。这里我们可以再回顾一下:应用生命周期,最开始主要是通过研发模式来管理的,按时间线先后出现了瀑布模式、迭代模式、敏捷模式。接着,为了解决研发模式中的一些痛点,出现了另一种管理技术,也就是 CI/CD 技术。随着 CI/CD 技术的成熟,又催生了另一种更高级的管理技术 DevOps。 + +其他的细节内容,如果有遗忘,你可以返回 09讲 再复习一下,这里就不再重复介绍了。接下来,对于应用生命周期管理技术,我会补充一些之前没有讲到的重要技术,包括下面这三个: + + +监控告警组件,Prometheus; +统一日志管理框架,EFK; +调用链跟踪组件,Jaeger。 + + +需要说明的是,这些技术之间不存在演进关系,而是平级的,共同作为应用生命周期管理技术的补充。 + +监控告警组件:Prometheus + +对于应用来说,监控告警功能是必不可少的一项功能,能够让开发者或运维人员及时感知到程序异常,并及时修复。另外,监控也能够收集一些有用的数据,供后面的运营分析使用。云原生技术栈中,也有很多开源的优秀监控告警项目,例如 Zabbix、Prometheus等,其中最受欢迎的是Prometheus。 + +Prometheus是一款开源的、自带时序数据库的监控告警系统。目前,Prometheus已经成为Kubernetes集群中监控告警系统的标配。它具有下面这几个特点: + + +强大的多维度数据模型; +在多维度上灵活地查询语言; +不依赖分布式存储,单主节点工作; +通过基于HTTP的pull方式,采集时序数据; +可以通过Push Gateway进行时序列数据推送; +可以通过服务发现或者静态配置,去获取要采集的目标服务器; +多种可视化图表及仪表盘支持(Grafana)。 + + +Prometheus的架构如下图所示: + + + +从上图可以看出,Prometheus 的主要模块包括Prometheus Server、Exporters、Pushgateway、Alertmanager 以及Grafana图形界面。这些模块,有些是可选的,有些是必选的,大部分组件使用Golang编写。下面我来分别介绍下。 + + +Prometheus Server(必选):Prometheus的核心服务,会定期从Jobs/exporters或者Pushgateway中拉取监控数据,并将时间序列(time-series)数据保存TSDB中,TSDB是一个时间序列数据库。 +Client Library(必选): Prometheus的客户端,应用程序使用Client Library,可以很方便地生成metrics,并暴露一个API接口,供Prometheus server从中拉取(pull)metrics数据。 +Pushgateway(可选): 接收短期的Jobs(Short-lived)推送(push)过来的metrics数据并缓存,供Prometheus server定期来pull这些监控数据。 +Exporters(可选): 以agent的形式运行在需要采集监控数据的应用服务器上,收集应用程序监控数据,并提供API接口,供Prometheus server 来 pull metrics数据。 +Alertmanager(可选): Prometheus的告警组件,接收来自于Prometheus server的alerts,将这些alerts去重、分组,并往配置的接收目的地发送告警。 +Grafana(可选):Grafana是一款跨平台、开源的可视化数据展示工具,可以用来统计和展示Prometheus监控数据,并带有告警功能,采用Go语言开发。 + + +Prometheus大致的工作流程是: + + +Prometheus Server 定期从配置好的 jobs 或者 Exporters 中拉 metrics,或者接收来自 Pushgateway 的 metrics,再或者从其他的 Prometheus Server 中拉 metrics。 +Prometheus Server 在本地存储收集到的 metrics,并运行已经定义好的 alert.rules,记录新的时间序列,或者向 Alertmanager 推送警报。 +Alertmanager 根据配置文件,对接收到的警报进行处理,发出告警。 +Grafana在图形界面中,可视化地展示采集数据。 + + +Prometheus会将所有采集到的样本数据以时间序列的方式保存在内存数据库中,并且定时保存到硬盘上。time-series是按照时间戳和值的序列顺序存放的。每条time-series通过指标名称(metrics name)和一组标签集(labelset)命名,如下所示: + +<--------------- metric ---------------------><-timestamp -><-value-> +http_request_total{status="200", method="GET"}@1434417560938 => 94355 +http_request_total{status="200", method="GET"}@1434417561287 => 94334 + +http_request_total{status="404", method="GET"}@1434417560938 => 38473 +http_request_total{status="404", method="GET"}@1434417561287 => 38544 + +http_request_total{status="200", method="POST"}@1434417560938 => 4748 +http_request_total{status="200", method="POST"}@1434417561287 => 4785 + + +在time-series中的每一个点,我们称为一个样本(sample)。样本由下面三个部分组成。 + + +指标(metric):metric name和描述当前样本特征的labelsets。 +时间戳(timestamp):一个精确到毫秒的时间戳。 +样本值(value): 一个folat64的浮点型数据,表示当前样本的值。 + + +统一日志管理框架:EFK + +我们通过监控告警服务感知到程序异常,这时候需要开发者或者运维人员介入排障。排障最有效的手段,是查看日志。所以,对于一个应用来说,一个优秀的日志系统也是必不可少的功能。 + +在一个大型的分布式系统中,有很多组件,这些组件分别部署在不同的服务器上。如果系统出故障,需要查看日志排障。这时候,你可能需要登陆不同的服务器,查看不同组件的日志,这个过程是非常繁琐、低效的,也会导致排障时间变长。故障时间越久,意味着给客户带来的损失越大。 + +所以,在一个大型系统中,传统的日志查看手段已经满足不了我们的需求了。这时候,我们需要有一个针对分布式系统的日志解决方案。当前,业界有不少成熟的分布式日志解决方案,其中使用最多的是EFK日志解决方案。甚至可以说,EFK已经成为分布式日志解决方案的事实标准。 + +EFK中包含三个开源的软件,分别是Elasticsearch、FlieBeat、Kibana。下面,我来介绍下这三个开源软件: + + +Elasticsearch:简称ES,是一个实时的、分布式的搜索引擎,通常用来索引和搜索大规模的日志数据,并支持全文、结构化的搜索。 +FlieBeat:轻量的数据采集组件,以agent的方式运行在需要采集日志的服务器上。FlieBeat采集指定的文件,并上报给ES。如果采集日志量大,也可以上报给Kafka,再由其他组件消费Kafka中的日志并转储到ES中。 +Kibana:用于展示ES中存储的日志数据,支持通过图表进行高级数据分析及展示。 + + +EFK的架构图如下: + + + +通过Filebeat采集所在服务器上各服务组件的日志,并上传到Kafka中。Logstash消费Kafka中的日志,过滤后上报给Elasticsearch进行存储。最后,通过Kibana可视化平台来检索这些日志。Kibana是通过调用Elasticsearch提供的API接口,来检索日志数据的。 + +当Filebeat的日志生产速度和Logstash的日志消费速度不匹配时,中间的Kafka服务,会起到削峰填谷的作用。 + +调用链跟踪组件:Jaeger + +在云原生架构中,应用普遍采用微服务。一个应用包含多个微服务,微服务之间会相互调用,这会给排障带来很大的挑战。比如,当我们通过前端访问应用报错时,我们根本不知道具体哪个服务、哪个步骤出问题了。所以这时候,应用就需要有分布式链路追踪能力。目前,业界也有多种分布式链路追踪系统,但用得最多的是Jaeger。 + +Jaeger是Uber推出的一款开源分布式追踪系统,兼容OpenTracing API。这里我们先来介绍两个概念: + + +OpenTracing:它是一套开源的调用链追踪标准,通过提供厂商无关、平台无关的API,来支持开发人员方便地添加/更换追踪系统的实现。 +分布式追踪系统:用于记录请求范围内的信息,是我们排查系统问题和系统性能的利器。分布式追踪系统种类繁多,但核心步骤都有三个,分别是代码埋点、数据存储和查询展示。 + + +Jaeger架构图如下: + + + +Jaeger中有7个关键组件,下面我来具体介绍下。 + + +instrument:将应用程序与jaeger-client装载起来,从而使应用程序可以上报调用链数据到Jaeger。 +jaeger-client:Jaeger的客户端SDK,负责收集并发送应用程序的调用链数据到jaeger-agent。 +jaeger-agent:接收并汇聚Span数据,并将这些数据上报给jaeger-collector。 +jaeger-collector:从jaeger-agent收集traces信息,并通过处理管道处理这些信息,最后写入后端存储。jaeger-collector是无状态的组件,可以根据需要水平扩缩容。 +Data Store:Jaeger的后端存储组件。目前,支持cassandra、elasticsearch。 +jaeger-ui:jaeger的前端界面,用于展示调用链等信息。 +jaeger-query:用于从存储中检索trace,并提供给jaeger-ui。 + + +下面,我通过一个Jaeger官方提供的All in One教程来让你更好地理解Jaeger。具体可以分成两个操作步骤。 + +第一步,使用jaeger-all-in-one安装Jaeger服务: + +$ wget https://github.com/jaegertracing/jaeger/releases/download/v1.25.0/jaeger-1.25.0-linux-amd64.tar.gz +$ tar -xvzf jaeger-1.25.0-linux-amd64.tar.gz +$ mv jaeger-1.25.0-linux-amd64/* $HOME/bin +$ jaeger-all-in-one --collector.zipkin.host-port=:9411 + + +第二步,启动一个HotROD示例应用,产生调用链: + +$ example-hotrod all # 第 1) 我们已经安装了 example-hotrod 命令 + + +访问http://$IP:16686/search可以查找调用链(IP是Jaeger部署的服务器IP地址),如下图所示: + + + +查询到调用链列表后,可以点击任意一个调用链,查看其详细的调用过程,如下图所示: + + + +具体如何使用Jaeger来记录调用链,你可以参考Jaeger官方给出的hotrod示例。 + +总结 + +最后,我们通过下面这张图,来对整个云技术的演进之路做个整体性的回顾: + + + +通过这张图你可以看到,每种技术并不是孤立存在的,而是相互促进的。在物理机阶段,我们用的是瀑布开发模式和单体架构;在虚拟机阶段,用得比较多的是敏捷开发模式和SOA架构;在容器这个阶段,则使用CI/CD的开发模式和微服务架构。 + +在Serverless阶段,软件架构仍然采用微服务,不过在一些触发器场景,也可能会编写一些FaaS架构的函数,部署在类似腾讯云云函数这样的FaaS平台上;底层系统资源主要使用Serverless容器,并配合Kubernetes资源编排技术。在一些触发器场景中,也可能会使用云函数。应用程序中的第三方服务(BaaS),也都是越来越Serverless化的服务。应用生命周期管理技术也会演进为CI/CD/CO这种模式,其中CI/CD更加智能化,自动化程度更高。 + +这张图里,阴影部分是我们当前所处的阶段:容器技术得到了大规模普及,业界也在积极探索Serverless技术,并取得了卓有成效的结果。 + +课后练习 + + +了解下Kubernetes的声明式API机制,并思考下,微服务架构之后的软件架构可能是什么样的? +动手搭建一个Prometheus服务,产生一些数据,并配置Grafana,最终可视化地展示这些监控数据。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/45基于Kubernetes的云原生架构设计.md b/专栏/Go语言项目开发实战/45基于Kubernetes的云原生架构设计.md new file mode 100644 index 0000000..1740164 --- /dev/null +++ b/专栏/Go语言项目开发实战/45基于Kubernetes的云原生架构设计.md @@ -0,0 +1,323 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 45 基于Kubernetes的云原生架构设计 + 你好,我是孔令飞。 + +前面两讲,我们一起看了云技术的演进之路。软件架构已经进入了云原生时代,云原生架构是当下最流行的软件部署架构。那么这一讲,我就和你聊聊什么是云原生,以及如何设计一种基于Kubernetes的云原生部署架构。 + +云原生简介 + +云原生包含的概念很多,对于一个应用开发者来说,主要关注点是如何开发应用,以及如何部署应用。所以,这里我在介绍云原生架构的时候,会主要介绍应用层的云原生架构设计和系统资源层的云原生架构设计。 + +在设计云原生架构时,应用生命周期管理层的云原生技术,我们主要侧重在使用层面,所以这里我就不详细介绍应用生命周期管理层的云原生架构了。后面的云原生架构鸟瞰图中会提到它,你可以看看。 + +另外,在介绍云原生时,也总是绕不开云原生计算基金会。接下来,我们就先来简单了解下CNCF基金会。 + +CNCF(云原生计算基金会)简介 + +CNCF(Cloud Native Computing Foundation,云原生计算基金会),2015年由谷歌牵头成立,目前已有一百多个企业与机构作为成员,包括亚马逊、微软、思科、红帽等巨头。CNCF致力于培育和维护一个厂商中立的开源社区生态,用以推广云原生技术。 + +CNCF目前托管了非常多的开源项目,其中有很多我们耳熟能详的项目,例如 Kubernetes、Prometheus、Envoy、Istio、etcd等。更多的项目,你可以参考CNCF公布的Cloud Native Landscape,它给出了云原生生态的参考体系,如下图所示: + + + +什么是云原生? + +CNCF官方在2018年发布了云原生v1.0,并给出了定义: + + +“云原生技术有利于各组织在公有云、私有云和混合云等新型动态环境中,构建和运行可弹性扩展的应用。云原生的代表技术包括容器、服务网格、微服务、不可变基础设施和声明式API。 这些技术能够构建容错性好、易于管理和便于观察的松耦合系统。结合可靠的自动化手段,云原生技术使工程师能够轻松地对系统作出频繁和可预测的重大变更。” + + +简单点说,云原生(Cloud Native)是一种构建和运行应用程序的方法,是一套技术体系和方法论。云原生中包含了3个概念,分别是技术体系、方法论和云原生应用。整个云原生技术栈是围绕着Kubernetes来构建的,具体包括了以下核心技术栈: + + + +这里来介绍下这些核心技术栈的基本内容。 + + +容器:Kubernetes的底层计算引擎,提供容器化的计算资源。 +微服务:一种软件架构思想,用来构建云原生应用。 +服务网格:建立在Kubernetes之上,作为服务间通信的底座,提供强大的服务治理功能。 +声明式 API :一种新的软件开发模式,通过描述期望的应用状态,来使系统更加健壮。 +不可变基础设施:一种新的软件部署模式,应用实例一旦被创建,便只能重建不能更新,是现代运维的基础。 + + +在 43讲 和 44讲 中,我介绍了容器、服务网格和微服务,这里再补充介绍下不可变基础设施和声明式API。 + +不可变基础设施(Immutable Infrastructure)的构想,是由Chad Fowler 于 2013 年提出的。具体来说就是:一个应用程序的实例,一旦被创建,就会进入只读的状态,后面如果想变更这个应用程序的实例,只能重新创建一个新的实例。通过这种模式,可以确保应用程序实例的一致性,这使得落地DevOps更加容易,并可以有效减少运维人员管理配置的负担。 + +声明式API是指我们通过工具描述期望的应用状态,并由工具保障应用一直处在我们期望的状态。 + +Kubernetes的API设计,就是一种典型的声明式API。例如,我们在创建Deployment时,在Kubernetes YAML文件中声明应用的副本数为2,即设置replicas: 2,Deployment Controller就会确保应用的副本数一直为2。也就是说,如果当前副本数大于2,Deployment Controller会删除多余的副本;如果当前副本数小于2,会创建新的副本。 + +声明式设计是一种设计理念,同时也是一种工作模式,它使得你的系统更加健壮。分布式系统环境可能会出现各种不确定的故障,面对这些组件故障,如果使用声明式 API ,你只需要查看对应组件的 API 服务器状态,再确定需要执行的操作即可。 + +什么是云原生应用? + +上面,我介绍了什么是云原生,接下来再介绍下什么是云原生应用。 + +整体来看,云原生应用是指生而为云的应用,应用程序从设计之初就考虑到了云的环境,可以在云上以最佳姿势运行,充分利用和发挥云平台提供的各种能力。具体来看,云原生应用具有以下三大特点: + + +从应用生命周期管理维度来看,使用DevOps和CI/CD的方式,进行开发和交付。 +从应用维度来看,以微服务原则进行划分设计。 +从系统资源维度来看,采用Docker + Kubernetes的方式来部署。 + + +看完上面的介绍,你应该已经对云原生和云原生应用有了一定的理解,接下来我就介绍一种云原生架构实现。因为云原生内容很多,所以这里的介绍只是起到抛砖引玉的作用,让你对云原生架构有初步的理解。至于在具体业务中如何设计云原生架构,你还需要根据业务、团队和技术栈等因素综合考虑。 + +云原生架构包含很多内容,如何学习? + +云原生架构中包含了很多概念、技术,那么我们到底如何学习呢?在前面的两讲中,我分别从系统资源层、应用层、应用生命周期管理层介绍了云技术。这3个层次基本上构成了整个云计算的技术栈。 + +今天,我仍然会从这三个层次入手,来对整个云原生架构设计进行相对完整的介绍。每个层次涉及到的技术很多,这一讲我只介绍每一层的核心技术,通过这些核心技术来看每一层的构建方法。 + +另外,因为应用生命周期管理层涉及到的技术栈非常多,所以今天不会详细讲解每种生命周期管理技术的实现原理,但会介绍它们提供的能力。 + +除了功能层面的架构设计之外,我们还要考虑部署层面的架构设计。对于云原生架构的部署,通常我们需要关注以下两点: + + +容灾能力:容灾能力是指应用程序遇到故障时的恢复能力。在互联网时代,对应用的容灾能力有比较高的要求。理想情况是系统在出现故障时,能够无缝切换到另外一个可用的实例上,继续提供服务,并做到用户无感知。但在实际开发中,无缝切换在技术上比较难以实现,所以也可以退而求其次,允许系统在一定时间内不可用。通常这个时间需要控制在秒级,例如5s。容灾能力可以通过负载均衡、健康检查来实现。 +扩缩容能力:扩缩容能力指的是系统能够根据需要扩缩容,可以手动扩缩容,也可以自动扩缩容。互联网时代对扩缩容能力的要求也比较高,需要实现自动扩缩容。我们可以基于一些自定义指标,例如CPU使用率、内存使用率等来自动扩缩容。扩容也意味着能够承载更多的请求,提高系统的吞吐量;缩容,意味着能够节省成本。扩缩容能力的实现,需要借助于负载均衡和监控告警能力。 + + +容灾能力和扩缩容能力都属于高可用能力。也就是说,在部署层面,需要我们的架构具备高可用能力。 + +接下来,我就重点介绍下系统资源层和应用层的云原生架构设计,并简单介绍下应用生命周期管理层的核心功能构建。在介绍完架构设计之后,我还会介绍下这些层面的高可用架构设计。 + +系统资源层的云原生架构设计 + +先来看系统资源层面的云原生架构设计。对于一个系统来说,系统资源的架构是需要优先考虑的。在云原生架构中,当前的业界标准是通过Docker提供系统资源(例如CPU、内存等),通过Kubernetes来编排Docker容器。Docker和Kubernetes的架构,我在43讲中介绍过,这里我主要介绍下系统资源层面的高可用架构设计。 + +基于Docker+Kubernetes的方案,高可用架构是通过Kubernetes高可用架构来实现的。要实现整个Kubernetes集群的高可用,我们需要分别实现以下两类高可用: + + +Kubernetes集群的高可用。 +Kubernetes集群中所部署应用的高可用。 + + +我们来分别看下这两个高可用方案。 + +Kubernetes集群高可用方案设计 + +通过43讲的学习,我们知道Kubernetes由kube-apiserver、kube-controller-manager、kube-scheduler、cloud-controller-manager、etcd、kubelet、kube-proxy、container runtime 8大核心组件组成。 + +其中,kube-apiserver、kube-controller-manager、kube-scheduler、cloud-controller-manager、etcd通常部署在master节点,kubelet、kube-proxy、container runtime 部署在Node节点上。实现Kubernetes集群的高可用,需要分别实现这8大核心组件的高可用。 + +Kubernetes集群的高可用架构图如下: + + + +上面图片展示的方案中,所有管理节点都部署了kube-apiserver、kube-controller-manager、kube-scheduler、etcd等组件。kube-apiserver均与本地的etcd进行通信,etcd在三个节点间同步数据;而kube-controller-manager、kube-scheduler和cloud-controller-manager,也只与本地的kube-apiserver进行通信,或者通过负载均衡访问。 + +一个Kubernetes集群中有多个Node节点,当一个Node节点故障时,Kubernetes的调度组件kube-controller-manager会将Pod调度到其他节点上,并将故障节点的Pod在其他可用节点上重建。也就是说,只要集群中有两个以上的节点,当其中一个Node故障时,整个集群仍然能够正常提供服务。换句话说,集群的kubelet、kube-proxy、container runtime组件可以是单点的,不用实现这些组件的高可用。 + +接下来,我们来看下Master节点各组件是如何实现高可用的。先来说下kube-apiserver组件的高可用方案设计。 + +因为kube-apiserver是一个无状态的服务,所以可以通过部署多个kube-apiserver实例,其上挂载负载均衡的方式来实现。其他所有需要访问kube-apiserver的组件,都通过负载均衡来访问,以此实现kube-apiserver的高可用。 + +kube-controller-manager、cloud-controller-manager和kube-scheduler因为是有状态的服务,所以它们的高可用能力不能通过负载均衡来实现。kube-controller-manager/kube-scheduler/cloud-controller-manager通过–leader-elect=true参数开启分布式锁机制,来进行leader election。 + +你可以创建多个kube-controller-manager/kube-scheduler/cloud-controller-manager实例,同一时刻只有一个实例能够获取到锁,成为leader,提供服务。如果当前leader故障,其他实例感知到leader故障之后会自动抢锁,成为leader继续提供服务。通过这种方式,我们实现了kube-controller-manager/kube-scheduler/cloud-controller-manager组件的高可用。 + +当kube-apiserver、kube-controller-manager、kube-scheduler、cloud-controller-manager故障时,我们期望这些组件能够自动恢复,这时候可以将这些组件以Static Pod的方式进行部署,这样当Pod故障时,上述实例就能够自动被拉起。 + +etcd的高可用方案有下面这3种思路: + + +使用独立的etcd集群,独立的etcd集群自带高可用能力。 +在每个Master节点上,使用Static Pod来部署etcd,多个节点上的etcd实例数据相互同步。每个kube-apiserver只与本Master节点的etcd通信。 +使用CoreOS提出的self-hosted方案,将etcd集群部署在kubernetes集群中,通过kubernetes集群自身的容灾能力来实现etcd的高可用。 + + +这三种思路,需要你根据实际需要进行选择,在实际生产环境中,第二种思路用得最多。 + +到这里,我们就实现了整个Kubernetes集群的高可用。接下来,我们来看下Kubernetes集群中,应用的高可用是如何实现的。 + +Kubernetes应用的高可用 + +Kubernetes自带了应用高可用能力。在Kubernetes中,应用以Pod的形式运行。你可以通过Deployment/StatefulSet来创建应用,并在Deployment/StatefulSet中指定多副本和Pod的健康检查方式。当Pod健康检查失败时,Deployment/StatefulSet的控制器(ReplicaSet)会自动销毁故障Pod,并创建一个新的Pod,替换故障的Pod。 + +你可能会问:当Pod故障时,怎么才能避免请求被调度到已故障的Pod上,造成请求失败?这里我也详细介绍下。 + +在Kubernetes中,我们可以通过Kubernetes Service或者负载均衡来访问这些Pod。当通过负载均衡来访问Pod时,负载均衡后端的RS(Real Server)实例其实就是Pod。我们创建了多个Pod,负载均衡可以自动根据Pod的健康状况来进行负载。 + +接下来,我们主要看下这个问题:当通过Kubernetes Service访问Pod时,如何实现高可用? + +高可用原理如下图所示: + + + +在Kubernetes中,我们可以给每个Pod打上标签(Label),标签是一个key-value对,例如label: app=Nginx。当我们访问Service时,Service会根据它配置的Label Selector,匹配具有相同Label的Pod,并将这些Pod的endpoint地址作为其后端RS。 + +举个例子,你可以看看上面的图片:Service的Label Selector是Labelsapp=Nginx,这样就会选择我们创建的具有label: app=Nginx的3个Pod实例。这时候,Service会根据其负载均衡策略,选取一个Pod将请求流量转发过去。当其中一个Pod故障时,Kubernetes会自动将故障Pod的endpoint从Service后端对应的RS列表中剔除。 + +由Deployment创建的ReplicaSet,这时候也会发现有一个Pod故障,健康的Pod实例数变为2,这时候跟其期望的值3不匹配,就会自动创建一个新的健康Pod,替换掉故障的Pod。因为新Pod满足Service的Label Selector,所以新Pod的endpoint会被Kubernetes自动添加到Service对应的endpoint列表中。 + +通过上面这些操作,Service后端的RS中,故障的Pod IP被新的、健康的Pod IP所替换,通过Service访问的后端Pod就都是健康的。这样,就通过Service实现了应用的高可用。 + +从上面的原理分析中,我们也可以发现,Service本质上是一个负载均衡器。 + +Kubernetes还提供了滚动更新(RollingUpdate)机制,来确保在发布时服务正常可用。这个机制的大致原理是:在更新时,先创建一个Pod,再销毁一个Pod,依次循环,直到所有的Pod都更新完成。在更新时,我们还可以控制每次更新的Pod数,以及最小可用的Pod数。 + +接下来,我们再来看下应用层的云原生架构设计和高可用设计。 + +应用层的云原生架构设计 + +在云原生架构中,我们采用微服务架构来构建应用。所以,这里我主要围绕着微服务架构的构建方式来介绍。先和你谈谈我对微服务的理解。 + +从本质上来说,微服务是一个轻量级的Web服务,只不过在微服务场景下,我们通常考虑的不是单个微服务,而是更多地考虑由多个微服务组成的应用。也就是说,一个应用由多个微服务组成,多个微服务就带来了一些单个Web服务不会面临的问题,例如部署复杂、排障困难、服务依赖复杂、通信链路长,等等。 + +在微服务场景下,除了编写单个微服务(轻量级的Web服务)之外,我们更多是要专注于解决应用微服务化所带来的挑战。所以,在我看来,微服务架构设计包括两个重要内容: + + +单个微服务的构建方式; +解决应用微服务化带来的挑战。 + + +微服务实现 + +我们可以通过两种方式来构建微服务: + + +采用Gin、Echo等轻量级Web框架。 +采用微服务框架,例如 go-chassis、go-micro、go-kit等。 + + +如果要解决应用微服务化带来的挑战,我们需要采用多种技术和手段,每种技术和手段会解决一个或一部分挑战。 + +综上,在我看来,微服务本质上是一个轻量级的Web服务,但又包含一系列的技术和手段,用来解决应用微服务化带来的挑战。微服务的技术栈如下图所示: + + + +不同的技术栈可以由不同的方式来实现,并解决不同的问题: + + +监控告警、日志、CI/CD、分布式调度,可以由Kubernetes平台提供的能力来实现。 +服务网关、权限验证、负载均衡、限流/熔断/降级,可以由网关来实现,例如Tyk网关。 +进程间通信、REST/RPC序列化,可以借助Web框架来实现,例如Gin、Go Chassis、gRPC、Sprint Cloud。 +分布式追踪可以由Jaeger来实现。 +统一配置管理可以由Apollo配置中心来实现。 +消息队列可以由NSQ、Kafka、RabbitMQ来实现。 + + +上面的服务注册/服务发现,有3种实现方式: + + +通过Kubernetes Service来进行服务注册/服务发现,Kubernetes自带服务注册/服务发现功能。使用此方式,我们不需要额外的开发。 +通过服务中心来实现服务注册/服务发现功能。采用这种方式,需要我们开发并部署服务中心,服务中心通常可以使用etcd/consul/mgmet来实现,使用etcd的较多。 +通过网关,来进行服务注册/服务发现。这种情况下,可以将服务信息直接上报给网关服务,也可以将服务信息上报到一个服务中心,例如etcd中,再由网关从服务中心中获取。 + + +这里要注意,原生的Kubernetes集群是不支持监控告警、日志、CI/CD等功能的。我们在使用Kubernetes集群时,通常会使用一个基于Kubernetes开发而来的Kubernetes平台,例如腾讯云容器服务TKE。 + +在Kubernetes平台中,通常会基于一些优秀的开源项目,进行二次开发,来实现平台的监控告警、日志、CI/CD等功能。 + + +监控告警:基于Prometheus来实现。 +日志:基于EFK日志解决方案来实现。 +CI/CD:可以自己研发,也可以基于优秀的开源项目来实现,例如 drone。 + + +微服务架构设计 + +上面我介绍了如何实现微服务,这里我再来具体讲讲,上面提到的各个组件/功能是如何有机组合在一起,共同构建一个微服务应用的。下面是微服务的架构图: + + + +在上图中,我们将微服务应用层部署在Kubernetes集群中,在Kubernetes集群之上,可以构建微服务需要的其他功能,例如监控告警、CI/CD、日志、调用链等。这些功能共同完成应用的生命周期管理。 + +我们在微服务的最上面挂载负载均衡。客户端,例如移动端应用、Web应用、API调用等,都通过负载均衡来访问微服务。 + +微服务在启动时会将自己的endpoint信息(通常是ip:port格式)上报到服务中心。微服务也会定时上报自己的心跳到服务中心。在服务中心中,我们可以监控微服务的状态,剔除不健康的微服务,获取微服务之间的访问数据,等等。如果要通过网关调用微服务,或者需要使用网关做负载均衡,那我们还需要网关从服务中心中获取微服务的endpoint信息。 + +微服务高可用架构设计 + +我们再来看下如何设计微服务应用的高可用能力。 + +我们可以把所有微服务组件以Deployment/StatefulSet的形式部署在Kubernetes集群中,副本数至少设置为两个,更新方式为滚动更新,设置服务的监控检查,并通过Kubernetes Service或者负载均衡的方式访问服务。这样,我们就可以不用做任何改造,直接使用Kubernetes自有的容灾能力,实现微服务架构的高可用。 + +云原生架构鸟瞰图 + +上面,我介绍了系统资源层和应用层的云原生架构设计,但还不能构成整个云原生架构设计。这里,我通过一张云原生架构鸟瞰图,来整体介绍下云原生架构的设计方案。 + + + +上图的云原生架构分为4层,除了前面提到的系统资源层、应用层、应用生命周期管理层之外,又加了统一接入层。接下来,我来介绍下这些层在云原生架构中的作用。 + +在最下面的系统资源层,我们除了提供传统的计算资源(物理机、虚拟机)之外,还可以提供容器化的计算资源和高可用的存储服务。其中,容器化的计算资源是基于传统的物理机/虚拟机来构建的。 + +在云原生架构中,我们更应该使用容器化的计算资源,通过Docker容器技术来隔离并对外提供计算资源,通过Kubernetes来编排容器。Docker + Kubernetes的组合使用,可以构建出一个非常优秀的系统资源层。这个系统资源层,自带了资源管理、容器调度、自动伸缩、网络通信、服务发现、健康检查等企业应用需要的核心能力。 + +在云原生时代,这些系统资源除了具有容器化、轻量化的特点之外,还越来越倾向于朝着Serverless化的方向去构建:系统资源免申请、免运维,按需计费,具备极致的弹性伸缩能力,并能够缩容到0。Serverless化的系统资源,可以使开发者只聚焦在应用层的应用功能开发上,而不用再把时间浪费在系统层的运维工作上。 + +在系统资源层之上,就可以构建我们的应用层了。云原生架构中,应用的构建方式,基本上都是采用的微服务架构。开发一个微服务应用,我们可以使用微服务框架,也可以不使用。二者的区别是,微服务框架替我们完成了服务治理相关功能,让我们不需要再开发这些功能。 + +在我看来,这一点有利有弊。好处当然是节省了开发工作量。至于坏处,主要有两方面:一方面,在实现方式和实现思路上,微服务框架所集成的服务治理功能并不一定是最适合我们的方案。另一方面,使用微服务框架还意味着我们的应用会跟微服务框架耦合,不能自由选择服务治理技术和方式。所以,在实际开发中,你应该根据需要,自行选择微服务的构建方式。 + +一般来说,一个微服务框架中,至少集成了这些服务治理功能:配置中心、调用链追踪、日志系统、监控、服务注册/服务发现。 + +再往上,我们就实现了统一接入层。统一接入层中包含了负载均衡和网关两个组件,其中负载均衡作为服务的唯一入口,供API、Web浏览器、手机终端等客户端访问。通过负载均衡,可以使我们的应用在故障时,能够自动切换实例,在负载过高时能够水平扩容。负载均衡下面还对接了网关,网关提供了一些通用能力,例如安全策略、路由策略、流量策略、统计分析、API管理等能力。 + +最后,我们还可以构建一系列的应用生命周期管理技术,例如服务编排、配置管理、日志、存储、审计、监控告警、消息队列、分布式链路追踪。这些技术中,一些可以基于Kubernetes,集成在我们的Kubernetes平台中,另一些则可以单独构建,供所有产品接入。 + +公有云版云原生架构 + +上面我们提到,云原生架构涉及到很多的技术栈。如果公司有能力,可以选择自己开发;如果觉得人力不够、成本太高,也可以使用公有云厂商已经开发好的云原生基础设施。使用云厂商的云原生基础设施,好处很明显:这些基础设施专业、稳定、免开发、免运维。 + +为了补全云原生架构设计版图,这里我也介绍一个公用云版的云原生架构设计。那么,公有云厂商会提供哪些云原生基础设施呢?这里我介绍下腾讯云提供的云原生解决方案。解决方案全景如下图所示: + + + +可以看到,腾讯云提供了全栈的云原生能力。 + +腾讯云基于底层的云原生能力,提供了一系列的云原生解决方案。这些解决方案,是已经设计好的云原生架构构建方案,可以帮助企业快速落地云原生架构,例如混合云解决方案、AI解决方案、IoT解决方案等。 + +那么,腾讯云底层提供了哪些云原生能力呢?我们一起来看下。 + +在应用层,通过TSF微服务平台,我们可以实现微服务的构建,以及微服务的服务治理能力。另外,还提供了更多的应用构建架构,例如: + + +Serverless Framework,可以构建Serverless应用。 +CloudBase,云原生一体化应用开发平台,可以快速构建小程序、Web、移动应用。 +… + + +在系统资源层,腾讯云提供了多种计算资源提供形态。例如:通过TKE,可以创建原生的Kubernetes集群;通过EKS,可以创建Serverless化的Kubernetes集群;通过TKE-Edge,可以创建能够纳管边缘节点的Kubernetes集群。此外,还提供了开源容器服务平台TKEStack,TKEStack是一个非常优秀的容器云平台,在代码质量、稳定性、平台功能等方面,都在开源的容器云平台中处于龙头地位,也欢迎你Star。 + +在应用生命周期管理这一层,提供了云原生的etcd、Prometheus服务。此外,还提供了CLS日志系统,供你保存并查询应用日志;提供了云监控,供你监控自己的应用程序;提供了容器镜像服务(TCR),用来保存Docker镜像;提供了CODING DevOps平台,用来支持应用的CI/CD;提供了调用链跟踪服务(TDW),用来展示微服务的调用链。 + +在统一接入层,腾讯云提供了功能强大的API网关。此外,还提供了多种Serverless化的中间件服务,例如消息队列TDMQ、云原生数据库TDSQL等。 + +所有这些云原生基础设施,都有共同的特点,就是免部署、免运维。换句话说,在腾讯云,你可以只专注于使用编程语言编写你的业务逻辑,其他的一切都交给腾讯云来搞定。 + +总结 + +云原生架构设计,包含了系统资源层、应用层、统一接入层和应用生命周期管理层4层。 + +在系统资源层,可以采用Docker + Kubernetes的方式来提供计算资源。我们所有的应用和应用生命周期管理相关的服务,都可以部署在Kubernetes集群中,利用Kubernetes集群的能力实现服务发现/服务注册、弹性伸缩、资源调度等核心能力。 + +在应用层,可以采用微服务架构,来构建我们的应用。具体构建时,我们可以根据需要,采用类似Gin这种轻量级的Web框架来构建应用,然后再实现旁路的服务治理功能;也可以采用集成了很多服务治理功能的微服务框架,例如 go-chassis、go-micro等。 + +因为我们采用了微服务架构,为了能够将微服务的一些功能,例如:认证授权、限流等功能最大化的复用,我们又提供了统一接入层。可以通过API网关、负载均衡、服务网格等技术来构建统一接入层。 + +在应用生命周期管理这一层,我们可以实现一些云原生的管理平台,例如 DevOps、监控告警、日志、配置中心等,并使我们的应用以云原生化的方式接入这些平台,使用这些平台提供的能力。 + +最后,我还介绍了腾讯云的云原生基础设施。通过腾讯云提供的云原生能力,你可以专注于使用编程语言编写你的业务逻辑,其他的各种云原生能力,都可以交给云厂商来帮你实现。 + +课后练习 + + +思考下,服务注册/服务发现的3种实现方式中,哪种方法适用于你的项目,为什么? +思考下,在设计云原生架构时,还需要考虑哪些重要的点?欢迎你在留言区分享。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/46如何制作Docker镜像?.md b/专栏/Go语言项目开发实战/46如何制作Docker镜像?.md new file mode 100644 index 0000000..fde7dce --- /dev/null +++ b/专栏/Go语言项目开发实战/46如何制作Docker镜像?.md @@ -0,0 +1,305 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 46 如何制作Docker镜像? + 你好,我是孔令飞。 + +要落地云原生架构,其中的一个核心点是通过容器来部署我们的应用。如果要使用容器来部署应用,那么制作应用的Docker镜像就是我们绕不开的关键一步。今天,我就来详细介绍下如何制作Docker镜像。 + +在这一讲中,我会先讲解下Docker镜像的构建原理和方式,然后介绍Dockerfile的指令,以及如何编写Dockerfile文件。最后,介绍下编写Dockerfile文件时要遵循的一些最佳实践。 + +Docker镜像的构建原理和方式 + +首先,我们来看下Docker镜像构建的原理和方式。 + +我们可以用多种方式来构建一个Docker镜像,最常用的有两种: + + +通过docker commit命令,基于一个已存在的容器构建出镜像。 +编写Dockerfile文件,并使用docker build命令来构建镜像。 + + +上面这两种方法中,镜像构建的底层原理是相同的,都是通过下面3个步骤来构建镜像: + + +基于原镜像,启动一个Docker容器。 +在容器中进行一些操作,例如执行命令、安装文件等。由这些操作产生的文件变更都会被记录在容器的存储层中。 +将容器存储层的变更commit到新的镜像层中,并添加到原镜像上。 + + +下面,我们来具体讲解这两种构建Docker镜像的方式。 + +通过docker commit命令构建镜像 + +我们可以通过docker commit来构建一个镜像,命令的格式为docker commit [选项] [<仓库名>[:<标签>]]。 + +下图中,我们通过4个步骤构建了Docker镜像ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64:test: + + + +具体步骤如下: + + +执行docker ps获取需要构建镜像的容器ID 48d1dbb89a7f。 +执行docker pause 48d1dbb89a7f暂停48d1dbb89a7f容器的运行。 +执行docker commit 48d1dbb89a7f ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64:test,基于容器ID 48d1dbb89a7f构建Docker镜像。 +执行docker images ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64:test,查看镜像是否成功构建。 + + +这种镜像构建方式通常用在下面两个场景中: + + +构建临时的测试镜像; +容器被入侵后,使用docker commit,基于被入侵的容器构建镜像,从而保留现场,方便以后追溯。 + + +除了这两种场景,我不建议你使用docker commit来构建生产现网环境的镜像。我这么说的主要原因有两个: + + +使用docker commit构建的镜像包含了编译构建、安装软件,以及程序运行产生的大量无用文件,这会导致镜像体积很大,非常臃肿。 +使用docker commit构建的镜像会丢失掉所有对该镜像的操作历史,无法还原镜像的构建过程,不利于镜像的维护。 + + +下面,我们再来看看如何使用Dockerfile来构建镜像。 + +通过Dockerfile来构建镜像 + +在实际开发中,使用Dockerfile来构建是最常用,也最标准的镜像构建方法。Dockerfile是Docker用来构建镜像的文本文件,里面包含了一系列用来构建镜像的指令。 + +docker build命令会读取Dockerfile的内容,并将Dockerfile的内容发送给Docker引擎,最终Docker引擎会解析Dockerfile中的每一条指令,构建出需要的镜像。 + +docker build的命令格式为docker build [OPTIONS] PATH | URL | -。PATH、URL、-指出了构建镜像的上下文(context),context中包含了构建镜像需要的Dockerfile文件和其他文件。默认情况下,Docker构建引擎会查找context中名为Dockerfile的文件,但你可以通过-f, --file选项,手动指定Dockerfile文件。例如: + + $ docker build -f Dockerfile -t ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64:test . + + +使用Dockerfile构建镜像,本质上也是通过镜像创建容器,并在容器中执行相应的指令,然后停止容器,提交存储层的文件变更。和用docker commit构建镜像的方式相比,它有三个好处: + + +Dockerfile 包含了镜像制作的完整操作流程,其他开发者可以通过 Dockerfile 了解并复现制作过程。 +Dockerfile 中的每一条指令都会创建新的镜像层,这些镜像可以被 Docker Daemnon 缓存。再次制作镜像时,Docker 会尽量复用缓存的镜像层(using cache),而不是重新逐层构建,这样可以节省时间和磁盘空间。 +Dockerfile 的操作流程可以通过docker image history [镜像名称]查询,方便开发者查看变更记录。 + + +这里,我们通过一个示例,来详细介绍下通过Dockerfile构建镜像的流程。 + +首先,我们需要编写一个Dockerfile文件。下面是iam-apiserver的Dockerfile文件内容: + +FROM centos:centos8 +LABEL maintainer="<[email protected]>" + +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime +RUN echo "Asia/Shanghai" > /etc/timezone + +WORKDIR /opt/iam +COPY iam-apiserver /opt/iam/bin/ + +ENTRYPOINT ["/opt/iam/bin/iam-apiserver"] + + +这里选择centos:centos8作为基础镜像,是因为centos:centos8镜像中包含了基本的排障工具,例如vi、cat、curl、mkdir、cp等工具。 + +接着,执行docker build命令来构建镜像: + +$ docker build -f Dockerfile -t ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64:test . + + +执行docker build后的构建流程为: + +第一步,docker build会将context中的文件打包传给Docker daemon。如果context中有.dockerignore文件,则会从上传列表中删除满足.dockerignore规则的文件。 + +这里有个例外,如果.dockerignore文件中有.dockerignore或者Dockerfile,docker build命令在排除文件时会忽略掉这两个文件。如果指定了镜像的tag,还会对repository和tag进行验证。 + +第二步,docker build命令向Docker server发送HTTP请求,请求Docker server构建镜像,请求中包含了需要的context信息。 + +第三步,Docker server接收到构建请求之后,会执行以下流程来构建镜像: + + +创建一个临时目录,并将context中的文件解压到该目录下。 +读取并解析Dockerfile,遍历其中的指令,根据命令类型分发到不同的模块去执行。 +Docker构建引擎为每一条指令创建一个临时容器,在临时容器中执行指令,然后commit容器,生成一个新的镜像层。 +最后,将所有指令构建出的镜像层合并,形成build的最后结果。最后一次commit生成的镜像ID就是最终的镜像ID。 + + +为了提高构建效率,docker build默认会缓存已有的镜像层。如果构建镜像时发现某个镜像层已经被缓存,就会直接使用该缓存镜像,而不用重新构建。如果不希望使用缓存的镜像,可以在执行docker build命令时,指定--no-cache=true参数。 + +Docker匹配缓存镜像的规则为:遍历缓存中的基础镜像及其子镜像,检查这些镜像的构建指令是否和当前指令完全一致,如果不一样,则说明缓存不匹配。对于ADD、COPY指令,还会根据文件的校验和(checksum)来判断添加到镜像中的文件是否相同,如果不相同,则说明缓存不匹配。 + +这里要注意,缓存匹配检查不会检查容器中的文件。比如,当使用RUN apt-get -y update命令更新了容器中的文件时,缓存策略并不会检查这些文件,来判断缓存是否匹配。 + +最后,我们可以通过docker history命令来查看镜像的构建历史,如下图所示: + + + +其他制作镜像方式 + +上面介绍的是两种最常用的镜像构建方式,还有一些其他的镜像创建方式,这里我简单介绍两种。 + + +通过docker save和docker load命令构建 + + +docker save用来将镜像保存为一个tar文件,docker load用来将tar格式的镜像文件加载到当前机器上,例如: + +# 在 A 机器上执行,并将 nginx-v1.0.0.tar.gz 复制到 B 机器 +$ docker save nginx | gzip > nginx-v1.0.0.tar.gz + +# 在 B 机器上执行 +$ docker load -i nginx-v1.0.0.tar.gz + + +通过上面的命令,我们就在机器B上创建了nginx镜像。 + + +通过docker export和docker import命令构建 + + +我们先通过docker export 保存镜像,再通过docker import 加载镜像,具体命令如下: + +# 在 A 机器上执行,并将 nginx-v1.0.0.tar.gz 复制到 B 机器 +$ docker export nginx > nginx-v1.0.0.tar.gz + +# 在 B 机器上执行 +$ docker import - nginx:v1.0.0 nginx-v1.0.0.tar.gz + + +通过docker export导出的镜像和通过docker save保存的镜像相比,会丢失掉所有的镜像构建历史。在实际生产环境中,我不建议你通过docker save和docker export这两种方式来创建镜像。我比较推荐的方式是:在A机器上将镜像push到镜像仓库,在B机器上从镜像仓库pull该镜像。 + +Dockerfile指令介绍 + +上面,我介绍了一些与Docker镜像构建有关的基础知识。在实际生产环境中,我们标准的做法是通过Dockerfile来构建镜像,这就要求你会编写Dockerfile文件。接下来,我就详细介绍下如何编写Dockerfile文件。 + +Dockerfile指令的基本格式如下: + +# Comment +INSTRUCTION arguments + + +INSTRUCTION是指令,不区分大小写,但我的建议是指令都大写,这样可以与参数进行区分。Dockerfile中,以 # 开头的行是注释,而在其他位置出现的 # 会被当成参数,例如: + +# Comment +RUN echo 'hello world # dockerfile' + + +一个Dockerfile文件中包含了多条指令,这些指令可以分为5类。 + + +定义基础镜像的指令:FROM; +定义镜像维护者的指令:MAINTAINER(可选); +定义镜像构建过程的指令:COPY、ADD、RUN、USER、WORKDIR、ARG、ENV、VOLUME、ONBUILD; +定义容器启动时执行命令的指令:CMD、ENTRYPOINT; +其他指令:EXPOSE、HEALTHCHECK、STOPSIGNAL。 + + +其中,加粗的指令是编写Dockerfile时经常用到的指令,需要你重点了解下。我把这些常用Dockerfile指令的介绍放在了GitHub上,你可以看看这个Dockerfile指令详解。 + +下面是一个Dockerfile示例: + +# 第一行必须指定构建该镜像所基于的容器镜像 +FROM centos:centos8 + +# 维护者信息 +MAINTAINER Lingfei Kong <[email protected]> + +# 镜像的操作指令 +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime +RUN echo "Asia/Shanghai" > /etc/timezone +WORKDIR /opt/iam +COPY iam-apiserver /opt/iam/bin/ + +# 容器启动时执行指令 +ENTRYPOINT ["/opt/iam/bin/iam-apiserver"] + + +Docker会顺序解释并执行Dockerfile中的指令,并且第一条指令必须是FROM,FROM 用来指定构建镜像的基础镜像。接下来,一般会指定镜像维护者的信息。后面是镜像操作的指令,最后会通过CMD或者ENTRYPOINT来指定容器启动的命令和参数。 + +Dockerfile最佳实践 + +上面我介绍了Dockerfile的指令,但在编写Dockerfile时,只知道这些指令是不够的,还不能编写一个合格的Dockerfile。我们还需要遵循一些编写 Dockerfile的最佳实践。这里,我总结了一份编写 Dockerfile的最佳实践清单,你可以参考。 + + +建议所有的Dockerfile指令大写,这样做可以很好地跟在镜像内执行的指令区分开来。 + +在选择基础镜像时,尽量选择官方的镜像,并在满足要求的情况下,尽量选择体积小的镜像。目前,Linux镜像大小有以下关系:busybox < debian < centos < ubuntu。最好确保同一个项目中使用一个统一的基础镜像。如无特殊需求,可以选择使用debian:jessie或者alpine。 + +在构建镜像时,删除不需要的文件,只安装需要的文件,保持镜像干净、轻量。 + +使用更少的层,把相关的内容放到一个层,并使用换行符进行分割。这样可以进一步减小镜像的体积,也方便查看镜像历史。 + +不要在Dockerfile中修改文件的权限。因为如果修改文件的权限,Docker在构建时会重新复制一份,这会导致镜像体积越来越大。 + +给镜像打上标签,标签可以帮助你理解镜像的功能,例如:docker build -t="nginx:3.0-onbuild"。 + +FROM指令应该包含tag,例如使用FROM debian:jessie,而不是FROM debian。 + +充分利用缓存。Docker构建引擎会顺序执行Dockerfile中的指令,而且一旦缓存失效,后续命令将不能使用缓存。为了有效地利用缓存,需要尽量将所有的Dockerfile文件中相同的部分都放在前面,而将不同的部分放在后面。 + +优先使用COPY而非ADD指令。和ADD相比,COPY 功能简单,而且也够用。ADD可变的行为会导致该指令的行为不清晰,不利于后期维护和理解。 + +推荐将CMD和ENTRYPOINT指令结合使用,使用execl格式的ENTRYPOINT指令设置固定的默认命令和参数,然后使用CMD指令设置可变的参数。 + +尽量使用Dockerfile共享镜像。通过共享Dockerfile,可以使开发者明确知道Docker镜像的构建过程,并且可以将Dockerfile文件加入版本控制,跟踪起来。 + +使用.dockerignore忽略构建镜像时非必需的文件。忽略无用的文件,可以提高构建速度。 + +使用多阶段构建。多阶段构建可以大幅减小最终镜像的体积。例如,COPY指令中可能包含一些安装包,安装完成之后这些内容就废弃掉。下面是一个简单的多阶段构建示例: + + +FROM golang:1.11-alpine AS build + +# 安装依赖包 +RUN go get github.com/golang/mock/mockgen + +# 复制源码并执行build,此处当文件有变化会产生新的一层镜像层 +COPY . /go/src/iam/ +RUN go build -o /bin/iam + +# 缩小到一层镜像 +FROM busybox +COPY --from=build /bin/iam /bin/iam +ENTRYPOINT ["/bin/iam"] +CMD ["--help"] + + +总结 + +如果你想使用Docker容器来部署应用,那么就需要制作Docker镜像。今天,我介绍了如何制作Docker镜像。 + +你可以使用这两种方式来构建Docker镜像: + + +通过 docker commit 命令,基于一个已存在的容器构建出镜像。 +通过编写Dockerfile文件,并使用 docker build 命令来构建镜像。 + + +这两种方法中,镜像构建的底层原理是相同的: + + +基于原镜像启动一个Docker容器。 + +在容器中进行一些操作,例如执行命令、安装文件等,由这些操作产生的文件变更都会被记录在容器的存储层中。 + +将容器存储层的变更commit到新的镜像层中,并添加到原镜像上。 + + +此外,我们还可以使用 docker save / docker load 和 docker export / docker import 来复制Docker镜像。 + +在实际生产环境中,我们标准的做法是通过Dockerfile来构建镜像。使用Dockerfile构建镜像,就需要你编写Dockerfile文件。Dockerfile支持多个指令,这些指令可以分为5类,对指令的具体介绍你可以再返回复习一遍。 + +另外,我们在构建Docker镜像时,也要遵循一些最佳实践,具体你可以参考我给你总结的最佳实践清单。 + +课后练习 + + +思考下,为什么在编写Dockerfile时,“把相关的内容放到一个层,使用换行符 \ 进行分割”可以减小镜像的体积? + +尝试一下,为你正在开发的应用编写Dockerfile文件,并成功构建出Docker镜像。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/47如何编写Kubernetes资源定义文件?.md b/专栏/Go语言项目开发实战/47如何编写Kubernetes资源定义文件?.md new file mode 100644 index 0000000..6da4c33 --- /dev/null +++ b/专栏/Go语言项目开发实战/47如何编写Kubernetes资源定义文件?.md @@ -0,0 +1,618 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 47 如何编写Kubernetes资源定义文件? + 你好,我是孔令飞。 + +在接下来的48讲,我会介绍如何基于腾讯云EKS来部署IAM应用。EKS其实是一个标准的Kubernetes集群,在Kubernetes集群中部署应用,需要编写Kubernetes资源的YAML(Yet Another Markup Language)定义文件,例如Service、Deployment、ConfigMap、Secret、StatefulSet等。 + +这些YAML定义文件里面有很多配置项需要我们去配置,其中一些也比较难理解。为了你在学习下一讲时更轻松,这一讲我们先学习下如何编写Kubernetes YAML文件。 + +为什么选择YAML格式来定义Kubernetes资源? + +首先解释一下,我们为什么使用YAML格式来定义Kubernetes的各类资源呢?这是因为YAML格式和其他格式(例如XML、JSON等)相比,不仅能够支持丰富的数据,而且结构清晰、层次分明、表达性极强、易于维护,非常适合拿来供开发者配置和管理Kubernetes资源。 + +其实Kubernetes支持YAML和JSON两种格式,JSON格式通常用来作为接口之间消息传递的数据格式,YAML格式则用于资源的配置和管理。YAML和JSON这两种格式是可以相互转换的,你可以通过在线工具json2yaml,来自动转换YAML和JSON数据格式。 + +例如,下面是一个YAML文件中的内容: + +apiVersion: v1 +kind: Service +metadata: + name: iam-apiserver +spec: + clusterIP: 192.168.0.231 + externalTrafficPolicy: Cluster + ports: + - name: https + nodePort: 30443 + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + app: iam-apiserver + sessionAffinity: None + type: NodePort + + +它对应的JSON格式的文件内容为: + +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "iam-apiserver" + }, + "spec": { + "clusterIP": "192.168.0.231", + "externalTrafficPolicy": "Cluster", + "ports": [ + { + "name": "https", + "nodePort": 30443, + "port": 8443, + "protocol": "TCP", + "targetPort": 8443 + } + ], + "selector": { + "app": "iam-apiserver" + }, + "sessionAffinity": "None", + "type": "NodePort" + } +} + + +我就是通过json2yaml在线工具,来转换YAML和JSON的,如下图所示: + + + +在编写Kubernetes资源定义文件的过程中,如果因为YAML格式文件中的配置项缩进太深,导致不容易判断配置项的层级,那么,你就可以将其转换成JSON格式,通过JSON格式来判断配置型的层级。 + +如果想学习更多关于YAML的知识,你可以参考YAML 1.2 (3rd Edition)。这里,可以先看看我整理的YAML基本语法: + + +属性和值都是大小写敏感的。 +使用缩进表示层级关系。 +禁止使用Tab键缩进,只允许使用空格,建议两个空格作为一个层级的缩进。元素左对齐,就说明对齐的两个元素属于同一个级别。 +使用 # 进行注释,直到行尾。 +key: value格式的定义中,冒号后要有一个空格。 +短横线表示列表项,使用一个短横线加一个空格;多个项使用同样的缩进级别作为同一列表。 +使用 --- 表示一个新的YAML文件开始。 + + +现在你知道了,Kubernetes支持YAML和JSON两种格式,它们是可以相互转换的。但鉴于YAML格式的各项优点,我建议你使用YAML格式来定义Kubernetes的各类资源。 + +Kubernetes 资源定义概述 + +Kubernetes中有很多内置的资源,常用的资源有Deployment、StatefulSet、ConfigMap、Service、Secret、Nodes、Pods、Events、Jobs、DaemonSets等。除此之外,Kubernetes还有其他一些资源。如果你觉得Kubernetes内置的资源满足不了需求,还可以自定义资源。 + +Kubernetes的资源清单可以通过执行以下命令来查看: + +$ kubectl api-resources +NAME SHORTNAMES APIVERSION NAMESPACED KIND +bindings v1 true Binding +componentstatuses cs v1 false ComponentStatus +configmaps cm v1 true ConfigMap +endpoints ep v1 true Endpoints +events ev v1 true Event + + +上述输出中,各列的含义如下。 + + +NAME:资源名称。 +SHORTNAMES:资源名称简写。 +APIVERSION:资源的API版本,也称为group。 +NAMESPACED:资源是否具有Namespace属性。 +KIND:资源类别。 + + +这些资源有一些共同的配置,也有一些特有的配置。这里,我们先来看下这些资源共同的配置。 + +下面这些配置是Kubernetes各类资源都具备的: + +--- +apiVersion: # string类型,指定group的名称,默认为core。可以使用 `kubectl api-versions` 命令,来获取当前kubernetes版本支持的所有group。 +kind: # string类型,资源类别。 +metadata: # 资源的元数据。 + name: # string类型,资源名称。 + namespace: # string类型,资源所属的命名空间。 + lables: < map[string]string> # map类型,资源的标签。 + annotations: < map[string]string> # map类型,资源的标注。 + selfLink: # 资源的 REST API路径,格式为:/api//namespaces///。例如:/api/v1/namespaces/default/services/iam-apiserver +spec: # 定义用户期望的资源状态(disired state)。 +status: # 资源当前的状态,以只读的方式显示资源的最近状态。这个字段由kubernetes维护,用户无法定义。 + + +你可以通过kubectl explain 命令来查看Object资源对象介绍,并通过kubectl explain .来查看的子对象的资源介绍,例如: + +$ kubectl explain service +$ kubectl explain service.spec +$ kubectl explain service.spec.ports + + +Kubernetes资源定义YAML文件,支持以下数据类型: + + +string,表示字符串类型。 +object,表示一个对象,需要嵌套多层字段。 +map[string]string,表示由key:value组成的映射。 +[]string,表示字串列表。 +[]object,表示对象列表。 +boolean,表示布尔类型。 +integer,表示整型。 + + +常用的Kubernetes资源定义 + +上面说了,Kubernetes中有很多资源,其中Pod、Deployment、Service、ConfigMap这4类是比较常用的资源,我来一个个介绍下。 + +Pod资源定义 + +下面是一个Pod的YAML定义: + +apiVersion: v1 # 必须 版本号, 常用v1 apps/v1 +kind: Pod # 必须 +metadata: # 必须,元数据 + name: string # 必须,名称 + namespace: string # 必须,命名空间,默认上default,生产环境为了安全性建议新建命名空间分类存放 + labels: # 非必须,标签,列表值 + - name: string + annotations: # 非必须,注解,列表值 + - name: string +spec: # 必须,容器的详细定义 + containers: #必须,容器列表, + - name: string   #必须,容器1的名称 + image: string #必须,容器1所用的镜像 + imagePullPolicy: [Always|Never|IfNotPresent] #非必须,镜像拉取策略,默认是Always + command: [string] # 非必须 列表值,如果不指定,则是一镜像打包时使用的启动命令 + args: [string] # 非必须,启动参数 + workingDir: string # 非必须,容器内的工作目录 + volumeMounts: # 非必须,挂载到容器内的存储卷配置 + - name: string # 非必须,存储卷名字,需与【@1】处定义的名字一致 + readOnly: boolean #非必须,定义读写模式,默认是读写 + ports: # 非必须,需要暴露的端口 + - name: string # 非必须 端口名称 + containerPort: int # 非必须 端口号 + hostPort: int # 非必须 宿主机需要监听的端口号,设置此值时,同一台宿主机不能存在同一端口号的pod, 建议不要设置此值 + proctocol: [tcp|udp] # 非必须 端口使用的协议,默认是tcp + env: # 非必须 环境变量 + - name: string # 非必须 ,环境变量名称 + value: string # 非必须,环境变量键值对 + resources: # 非必须,资源限制 + limits: # 非必须,限制的容器使用资源的最大值,超过此值容器会推出 + cpu: string # 非必须,cpu资源,单位是core,从0.1开始 + memory: string 内存限制,单位为MiB,GiB + requests: # 非必须,启动时分配的资源 + cpu: string + memory: string + livenessProbe: # 非必须,容器健康检查的探针探测方式 + exec: # 探测命令 + command: [string] # 探测命令或者脚本 + httpGet: # httpGet方式 + path: string # 探测路径,例如 http://ip:port/path + port: number + host: string + scheme: string + httpHeaders: + - name: string + value: string + tcpSocket: # tcpSocket方式,检查端口是否存在 + port: number + initialDelaySeconds: 0 #容器启动完成多少秒后的再进行首次探测,单位为s + timeoutSeconds: 0 #探测响应超时的时间,默认是1s,如果失败,则认为容器不健康,会重启该容器 + periodSeconds: 0 # 探测间隔时间,默认是10s + successThreshold: 0 # + failureThreshold: 0 + securityContext: + privileged: false + restartPolicy: [Always|Never|OnFailure] # 容器重启的策略, + nodeSelector: object # 指定运行的宿主机 + imagePullSecrets: # 容器下载时使用的Secrets名称,需要与valumes.secret中定义的一致 + - name: string + hostNetwork: false + volumes: ## 挂载的共享存储卷类型 + - name: string # 非必须,【@1】 + emptyDir: {} + hostPath: + path: string + secret: # 类型为secret的存储卷,使用内部的secret内的items值作为环境变量 + secrectName: string + items: + - key: string + path: string + configMap: ## 类型为configMap的存储卷 + name: string + items: + - key: string + path: string + + +Pod是Kubernetes中最重要的资源,我们可以通过Pod YAML定义来创建一个Pod,也可以通过DaemonSet、Deployment、ReplicaSet、StatefulSet、Job、CronJob来创建Pod。 + +Deployment资源定义 + +Deployment资源定义YAML文件如下: + +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: # 设定资源的标签 + app: iam-apiserver + name: iam-apiserver + namespace: default +spec: + progressDeadlineSeconds: 10 # 指定多少时间内不能完成滚动升级就视为失败,滚动升级自动取消 + replicas: 1 # 声明副本数,建议 >= 2 + revisionHistoryLimit: 5 # 设置保留的历史版本个数,默认是10 + selector: # 选择器 + matchLabels: # 匹配标签 + app: iam-apiserver # 标签格式为key: value对 + strategy: # 指定部署策略 + rollingUpdate: + maxSurge: 1 # 最大额外可以存在的副本数,可以为百分比,也可以为整数 + maxUnavailable: 1 # 表示在更新过程中能够进入不可用状态的 Pod 的最大值,可以为百分比,也可以为整数 + type: RollingUpdate # 更新策略,包括:重建(Recreate)、RollingUpdate(滚动更新) + template: # 指定Pod创建模板。注意:以下定义为Pod的资源定义 + metadata: # 指定Pod的元数据 + labels: # 指定Pod的标签 + app: iam-apiserver + spec: + affinity: + podAntiAffinity: # Pod反亲和性,尽量避免同一个应用调度到相同Node + preferredDuringSchedulingIgnoredDuringExecution: # 软需求 + - podAffinityTerm: + labelSelector: + matchExpressions: # 有多个选项,只有同时满足这些条件的节点才能运行 Pod + - key: app + operator: In # 设定标签键与一组值的关系,In、NotIn、Exists、DoesNotExist + values: + - iam-apiserver + topologyKey: kubernetes.io/hostname + weight: 100 # weight 字段值的范围是1-100。 + containers: + - command: # 指定运行命令 + - /opt/iam/bin/iam-apiserver # 运行参数 + - --config=/etc/iam/iam-apiserver.yaml + image: ccr.ccs.tencentyun.com/lkccc/iam-apiserver-amd64:v1.0.6 # 镜像名,遵守镜像命名规范 + imagePullPolicy: Always # 镜像拉取策略。IfNotPresent:优先使用本地镜像;Never:使用本地镜像,本地镜像不存在,则报错;Always:默认值,每次都重新拉取镜像 + # lifecycle: # kubernetes支持postStart和preStop事件。当一个容器启动后,Kubernetes将立即发送postStart事件;在容器被终结之前,Kubernetes将发送一个preStop事件 + name: iam-apiserver # 容器名称,与应用名称保持一致 + ports: # 端口设置 + - containerPort: 8443 # 容器暴露的端口 + name: secure # 端口名称 + protocol: TCP # 协议,TCP和UDP + livenessProbe: # 存活检查,检查容器是否正常,不正常则重启实例 + httpGet: # HTTP请求检查方法 + path: /healthz # 请求路径 + port: 8080 # 检查端口 + scheme: HTTP # 检查协议 + initialDelaySeconds: 5 # 启动延时,容器延时启动健康检查的时间 + periodSeconds: 10 # 间隔时间,进行健康检查的时间间隔 + successThreshold: 1 # 健康阈值,表示后端容器从失败到成功的连续健康检查成功次数 + failureThreshold: 1 # 不健康阈值,表示后端容器从成功到失败的连续健康检查成功次数 + timeoutSeconds: 3 # 响应超时,每次健康检查响应的最大超时时间 + readinessProbe: # 就绪检查,检查容器是否就绪,不就绪则停止转发流量到当前实例 + httpGet: # HTTP请求检查方法 + path: /healthz # 请求路径 + port: 8080 # 检查端口 + scheme: HTTP # 检查协议 + initialDelaySeconds: 5 # 启动延时,容器延时启动健康检查的时间 + periodSeconds: 10 # 间隔时间,进行健康检查的时间间隔 + successThreshold: 1 # 健康阈值,表示后端容器从失败到成功的连续健康检查成功次数 + failureThreshold: 1 # 不健康阈值,表示后端容器从成功到失败的连续健康检查成功次数 + timeoutSeconds: 3 # 响应超时,每次健康检查响应的最大超时时间 + startupProbe: # 启动探针,可以知道应用程序容器什么时候启动了 + failureThreshold: 10 + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 3 + resources: # 资源管理 + limits: # limits用于设置容器使用资源的最大上限,避免异常情况下节点资源消耗过多 + cpu: "1" # 设置cpu limit,1核心 = 1000m + memory: 1Gi # 设置memory limit,1G = 1024Mi + requests: # requests用于预分配资源,当集群中的节点没有request所要求的资源数量时,容器会创建失败 + cpu: 250m # 设置cpu request + memory: 500Mi # 设置memory request + terminationMessagePath: /dev/termination-log # 容器终止时消息保存路径 + terminationMessagePolicy: File # 仅从终止消息文件中检索终止消息 + volumeMounts: # 挂载日志卷 + - mountPath: /etc/iam/iam-apiserver.yaml # 容器内挂载镜像路径 + name: iam # 引用的卷名称 + subPath: iam-apiserver.yaml # 指定所引用的卷内的子路径,而不是其根路径。 + - mountPath: /etc/iam/cert + name: iam-cert + dnsPolicy: ClusterFirst + restartPolicy: Always # 重启策略,Always、OnFailure、Never + schedulerName: default-scheduler # 指定调度器的名字 + imagePullSecrets: # 在Pod中设置ImagePullSecrets只有提供自己密钥的Pod才能访问私有仓库 + - name: ccr-registry # 镜像仓库的Secrets需要在集群中手动创建 + securityContext: {} # 指定安全上下文 + terminationGracePeriodSeconds: 5 # 优雅关闭时间,这个时间内优雅关闭未结束,k8s 强制 kill + volumes: # 配置数据卷,类型详见https://kubernetes.io/zh/docs/concepts/storage/volumes + - configMap: # configMap 类型的数据卷 + defaultMode: 420 #权限设置0~0777,默认0664 + items: + - key: iam-apiserver.yaml + path: iam-apiserver.yaml + name: iam # configmap名称 + name: iam # 设置卷名称,与volumeMounts名称对应 + - configMap: + defaultMode: 420 + name: iam-cert + name: iam-cert + + +在部署时,你可以根据需要来配置相应的字段,常见的需要配置的字段为:labels、name、namespace、replicas、command、imagePullPolicy、container.name、livenessProbe、readinessProbe、resources、volumeMounts、volumes、imagePullSecrets等。 + +另外,在部署应用时,经常需要提供配置文件,供容器内的进程加载使用。最常用的方法是挂载ConfigMap到应用容器中。那么,如何挂载ConfigMap到容器中呢? + +引用 ConfigMap 对象时,你可以在 volume 中通过它的名称来引用。你可以自定义 ConfigMap 中特定条目所要使用的路径。下面的配置就显示了如何将名为 log-config 的 ConfigMap 挂载到名为 configmap-pod 的 Pod 中: + +apiVersion: v1 +kind: Pod +metadata: + name: configmap-pod +spec: + containers: + - name: test + image: busybox + volumeMounts: + - name: config-vol + mountPath: /etc/config + volumes: + - name: config-vol + configMap: + name: log-config + items: + - key: log_level + path: log_level + + +log-config ConfigMap 以卷的形式挂载,并且存储在 log_level 条目中的所有内容都被挂载到 Pod 的/etc/config/log_level 路径下。 请注意,这个路径来源于卷的 mountPath 和 log_level 键对应的path。 + +这里需要注意,在使用 ConfigMap 之前,你首先要创建它。接下来,我们来看下ConfigMap定义。 + +ConfigMap资源定义 + +下面是一个ConfigMap YAML示例: + +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config4 +data: # 存储配置内容 + db.host: 172.168.10.1 # 存储格式为key: value + db.port: 3306 + + +可以看到,ConfigMap的YAML定义相对简单些。假设我们将上述YAML文件保存在了iam-configmap.yaml文件中,我们可以执行以下命令,来创建ConfigMap: + +$ kubectl create -f iam-configmap.yaml + + +除此之外,kubectl命令行工具还提供了3种创建ConfigMap的方式。我来分别介绍下。 + +1)通过--from-literal参数创建 + +创建命令如下: + +$ kubectl create configmap iam-configmap --from-literal=db.host=172.168.10.1 --from-literal=db.port='3306' + + +2)通过--from-file=<文件>参数创建 + +创建命令如下: + +$ echo -n 172.168.10.1 > ./db.host +$ echo -n 3306 > ./db.port +$ kubectl create cm iam-configmap --from-file=./db.host --from-file=./db.port + + +--from-file的值也可以是一个目录。当值是目录时,目录中的文件名为key,目录的内容为value。 + +3)通过--from-env-file参数创建 + +创建命令如下: + +$ cat << EOF > env.txt +db.host=172.168.10.1 +db.port=3306 +EOF +$ kubectl create cm iam-configmap --from-env-file=env.txt + + +Service资源定义 + +Service 是 Kubernetes 另一个核心资源。通过创建 Service,可以为一组具有相同功能的容器应用提供一个统一的入口地址,并且将请求负载到后端的各个容器上。Service资源定义YAML文件如下: + +apiVersion: v1 +kind: Service +metadata: + labels: + app: iam-apiserver + name: iam-apiserver + namespace: default +spec: + clusterIP: 192.168.0.231 # 虚拟服务地址 + externalTrafficPolicy: Cluster # 表示此服务是否希望将外部流量路由到节点本地或集群范围的端点 + ports: # service需要暴露的端口列表 + - name: https #端口名称 + nodePort: 30443 # 当type = NodePort时,指定映射到物理机的端口号 + port: 8443 # 服务监听的端口号 + protocol: TCP # 端口协议,支持TCP和UDP,默认TCP + targetPort: 8443 # 需要转发到后端Pod的端口号 + selector: # label selector配置,将选择具有label标签的Pod作为其后端RS + app: iam-apiserver + sessionAffinity: None # 是否支持session + type: NodePort # service的类型,指定service的访问方式,默认为clusterIp + + +上面,我介绍了常用的Kubernetes YAML的内容。我们在部署应用的时候,是需要手动编写这些文件的。接下来,我就讲解一些在编写过程中常用的编写技巧。 + +YAML文件编写技巧 + +这里我主要介绍三个技巧。 + +1)使用在线的工具来自动生成模板YAML文件。 + +YAML文件很复杂,完全从0开始编写一个YAML定义文件,工作量大、容易出错,也没必要。我比较推荐的方式是,使用一些工具来自动生成所需的YAML。 + +这里我推荐使用k8syaml工具。k8syaml是一个在线的YAML生成工具,当前能够生成Deployment、StatefulSet、DaemonSet类型的YAML文件。k8syaml具有默认值,并且有对各字段详细的说明,可以供我们填参时参考。 + +2)使用kubectl run命令获取YAML模板: + +$ kubectl run --dry-run=client --image=nginx nginx -o yaml > my-nginx.yaml +$ cat my-nginx.yaml +apiVersion: v1 +kind: Pod +metadata: + creationTimestamp: null + labels: + run: nginx + name: nginx +spec: + containers: + - image: nginx + name: nginx + resources: {} + dnsPolicy: ClusterFirst + restartPolicy: Always +status: {} + + +然后,我们可以基于这个模板,来修改配置,形成最终的YAML文件。 + +3)导出集群中已有的资源描述。 + +有时候,如果我们想创建一个Kubernetes资源,并且发现该资源跟集群中已经创建的资源描述相近或者一致的时候,可以选择导出集群中已经创建资源的YAML描述,并基于导出的YAML文件进行修改,获得所需的YAML。例如: + +$ kubectl get deployment iam-apiserver -o yaml > iam-authz-server.yaml + + +接着,修改iam-authz-server.yaml。通常,我们需要删除Kubernetes自动添加的字段,例如kubectl.kubernetes.io/last-applied-configuration、deployment.kubernetes.io/revision、creationTimestamp、generation、resourceVersion、selfLink、uid、status。 + +这些技巧可以帮助我们更好地编写和使用Kubernetes YAML。 + +使用Kubernetes YAML时的一些推荐工具 + +接下来,我再介绍一些比较流行的工具,你可以根据自己的需要进行选择。 + +kubeval + +kubeval可以用来验证Kubernetes YAML是否符合Kubernetes API模式。 + +安装方法如下: + +$ wget https://github.com/instrumenta/kubeval/releases/latest/download/kubeval-linux-amd64.tar.gz +$ tar xf kubeval-linux-amd64.tar.gz +$ mv kubeval $HOME/bin + + +安装完成后,我们对Kubernetes YAML文件进行验证: + +$ kubeval deployments/iam.invalid.yaml +ERR - iam/templates/iam-configmap.yaml: Duplicate 'ConfigMap' resource 'iam' in namespace '' + + +根据提示,查看iam.yaml,发现在iam.yaml文件中,我们定义了两个同名的iam ConfigMap: + +apiVersion: v1 +kind: ConfigMap +metadata: + name: iam +data: + {} +--- +# Source: iam/templates/iam-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: iam +data: + iam-: "" + iam-apiserver.yaml: | + ... + + +可以看到,使用kubeval之类的工具,能让我们在部署的早期,不用访问集群就能发现YAML文件的错误。 + +kube-score + +kube-score能够对Kubernetes YAML进行分析,并根据内置的检查对其评分,这些检查是根据安全建议和最佳实践而选择的,例如: + + +以非Root用户启动容器。 +为Pods设置健康检查。 +定义资源请求和限制。 + + +你可以按照这个方法安装: + +$ go get github.com/zegl/kube-score/cmd/kube-score + + +然后,我们对Kubernetes YAML进行评分: + +$ kube-score score -o ci deployments/iam.invalid.yaml +[OK] iam-apiserver apps/v1/Deployment +[OK] iam-apiserver apps/v1/Deployment +[OK] iam-apiserver apps/v1/Deployment +[OK] iam-apiserver apps/v1/Deployment +[CRITICAL] iam-apiserver apps/v1/Deployment: The pod does not have a matching NetworkPolicy +[CRITICAL] iam-apiserver apps/v1/Deployment: Container has the same readiness and liveness probe +[CRITICAL] iam-apiserver apps/v1/Deployment: (iam-apiserver) The pod has a container with a writable root filesystem +[CRITICAL] iam-apiserver apps/v1/Deployment: (iam-apiserver) The container is running with a low user ID +[CRITICAL] iam-apiserver apps/v1/Deployment: (iam-apiserver) The container running with a low group ID +[OK] iam-apiserver apps/v1/Deployment +... + + +检查的结果有OK、SKIPPED、WARNING和CRITICAL。CRITICAL是需要你修复的;WARNING是需要你关注的;SKIPPED是因为某些原因略过的检查;OK是验证通过的。 + +如果你想查看详细的错误原因和解决方案,可以使用-o human选项,例如: + +$ kube-score score -o human deployments/iam.invalid.yaml + + +上述命令会检查YAML资源定义文件,如果有不合规的地方会报告级别、类别以及错误详情,如下图所示: + + + +当然,除了kubeval、kube-score这两个工具,业界还有其他一些Kubernetes检查工具,例如config-lint、copper、conftest、polaris等。 + +这些工具,我推荐你这么来选择:首先,使用kubeval工具做最基本的YAML文件验证。验证通过之后,我们就可以进行更多的测试。如果你没有特别复杂的YAML验证要求,只需要用到一些最常见的检查策略,这时候可以使用kube-score。如果你有复杂的验证要求,并且希望能够自定义验证策略,则可以考虑使用copper。当然,polaris、config-lint、copper也值得你去尝试下。 + +总结 + +今天,我主要讲了如何编写Kubernetes YAML文件。 + +YAML格式具有丰富的数据表达能力、清晰的结构和层次,因此被用于Kubernetes资源的定义文件中。如果你要把应用部署在Kubernetes集群中,就要创建多个关联的K8s资源,如果要创建K8s资源,目前比较多的方式还是编写YAML格式的定义文件。 + +这一讲我介绍了K8s中最常用的四种资源(Pod、Deployment、Service、ConfigMap)的YAML定义的写法,你可以常来温习。 + +另外,在编写YAML文件时,也有一些技巧。比如,可以通过在线工具k8syaml来自动生成初版的YAML文件,再基于此YAML文件进行二次修改,从而形成终版。 + +最后,我还给你分享了编写和使用Kubernetes YAML时,社区提供的多种工具。比如,kubeval可以校验YAML,kube-score可以给YAML文件打分。了解了如何编写Kubernetes YAML文件,下一讲的学习相信你会进行得更顺利。 + +课后练习 + + +思考一下,如何将ConfigMap中的Key挂载到同一个目录中,文件名为Key名? +使用kubeval检查你正在或之前从事过的项目的K8s YAML定义文件,查看报错,并修改和优化。 + + +欢迎你在留言区和我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/48IAM容器化部署实战.md b/专栏/Go语言项目开发实战/48IAM容器化部署实战.md new file mode 100644 index 0000000..dd623e2 --- /dev/null +++ b/专栏/Go语言项目开发实战/48IAM容器化部署实战.md @@ -0,0 +1,616 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 48 IAM 容器化部署实战 + 你好,我是孔令飞。 + +在 45讲中,我介绍了一种基于Kubernetes的云原生架构设计方案。在云原生架构中,我们是通过Docker + Kubernetes来部署云原生应用的。那么这一讲,我就手把手教你如何在Kubernetes集群中部署好IAM应用。因为步骤比较多,所以希望你能跟着我完成每一个操作步骤。相信在实操的过程中,你也会学到更多的知识。 + +准备工作 + +在部署IAM应用之前,我们需要做以下准备工作: + + +开通腾讯云容器服务镜像仓库。 +安装并配置Docker。 +准备一个Kubernetes集群。 + + +开通腾讯云容器服务镜像仓库 + +在Kubernetes集群中部署IAM应用,需要从镜像仓库下载指定的IAM镜像,所以首先需要有一个镜像仓库来托管IAM的镜像。我们可以选择将IAM镜像托管到DockerHub上,这也是docker运行时默认获取镜像的地址。 + +但因为DockerHub服务部署在国外,国内访问速度很慢。所以,我建议将IAM镜像托管在国内的镜像仓库中。这里我们可以选择腾讯云提供的镜像仓库服务,访问地址为容器镜像服务个人版。 + +如果你已经有腾讯云的镜像仓库,可以忽略腾讯云镜像仓库开通步骤。 + +在开通腾讯云镜像仓库之前,你需要注册腾讯云账号,并完成实名认证。 + +开通腾讯云镜像仓库的具体步骤如下: + +第一步,开通个人版镜像仓库。 + + +登录容器服务控制台,选择左侧导航栏中的【镜像仓库】>【个人版】。 + +根据以下提示,填写相关信息,并单击【开通】进行初始化。如下图所示: + + + + + +用户名:默认是当前用户的账号ID,是你登录到腾讯云Docker镜像仓库的身份,可在 账号信息 页面获取。 +密码:是你登录到腾讯云 Docker 镜像仓库的凭证。 + + +这里需要你记录用户名及密码,用于推送及拉取镜像。假如我们开通的镜像仓库,用户名为10000099xxxx,密码为iam59!z$。 + +这里要注意,10000099xxxx要替换成你镜像仓库的用户名。 + +第二步,登录到腾讯云Registry(镜像仓库)。 + +在我们开通完Registry,就可以登录Registry了。可以通过以下命令来登录腾讯云Registry: + +$ docker login --username=[username] ccr.ccs.tencentyun.com + + +这里的username是腾讯云账号 ID,开通时已注册,可在 账号信息 页面获取。docker命令会在后面安装。- +第三步,新建镜像仓库命名空间。 + +如果想使用镜像仓库,那么你首先需要创建一个用来创建镜像的命名空间。上一步,我们开通了镜像仓库,就可以在“命名空间”页签新建命名空间了,如下图所示: + + + +上图中,我们创建了一个叫marmotedu的命名空间。 + +这里,镜像仓库服务、命名空间、镜像仓库、标签这几个概念你可能弄不清楚。接下来,我详细介绍下四者的关系,关系如下图所示: + + + +先来看下我们使用镜像仓库的格式:<镜像仓库服务地址>/<命名空间>/<镜像仓库>:<标签>,例如ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64:v1.1.0。 + +如果想使用一个Docker镜像,我们首先需要开通一个镜像仓库服务(Registry),镜像仓库服务都会对外提供一个固定的地址供你访问。在Registry中,我们(User)可以创建一个或多个命名空间(Namespace),命名空间也可以简单理解为镜像仓库逻辑上的一个分组。 + +接下来,就可以在Namespace中创建一个或多个镜像仓库,例如iam-apiserver-amd64、iam-authz-server-amd64、iam-pump-amd64等。针对每一个镜像仓库,又可以创建多个标签(Tag),例如v1.0.1、v1.0.2等。 + +<镜像仓库>:<标签>又称为镜像。镜像又分为私有镜像和公有镜像,公有镜像可供所有能访问Registry的用户下载使用,私有镜像只提供给通过授权的用户使用。 + +安装Docker + +开通完镜像仓库之后,我们还需要安装Docker,用来构建和测试Docker镜像。下面我来讲解下具体的安装步骤。 + +第一步,安装Docker前置条件检查。 + +需要确保CentOS系统启用了centos-extras yum源,默认情况下已经启用,检查方式如下: + +$ cat /etc/yum.repos.d/CentOS-Extras.repo +# Qcloud-Extras.repo + +[extras] +name=Qcloud-$releasever - Extras +baseurl=http://mirrors.tencentyun.com/centos/$releasever/extras/$basearch/os/ +gpgcheck=1 +enabled=1 +gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Qcloud-8 + + +如果/etc/yum.repos.d/CentOS-Extras.repo文件存在,且文件中extras部分的enabled配置项值为1,说明已经启用了centos-extras yum源。如果/etc/yum.repos.d/CentOS-Extras.repo 文件不存在,或者enabled 不为1,则需要创建/etc/yum.repos.d/CentOS-Extras.repo 文件,并将上述内容复制进去。 + +第二步,安装docker。 + +Docker官方文档 Install Docker Engine on CentOS提供了3种安装方法: + + +通过Yum源安装。 +通过RPM包安装 +通过脚本安装。 + + +这里,我们选择最简单的安装方式:通过Yum源安装。它具体又分为下面3个步骤。 + + +安装docker。 + + +$ sudo yum install -y yum-utils # 1. 安装 `yum-utils` 包,该包提供了 `yum-config-manager` 工具 +$ sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo # 2. 安装 `docker-ce.repo` yum 源 +$ sudo yum-config-manager --enable docker-ce-nightly docker-ce-test # 3. 启用 `nightly` 和 `test` yum 源 +$ sudo yum install -y docker-ce docker-ce-cli containerd.io # 4. 安装最新版本的 docker 引擎和 containerd + + + +启动docker。 + + +可以通过以下命令来启动 docker: + +$ sudo systemctl start docker + + +docker的配置文件是 /etc/docker/daemon.json ,这个配置文件默认是没有的,需要我们手动创建: + +$ sudo tee /etc/docker/daemon.json << EOF +{ + "bip": "172.16.0.1/24", + "registry-mirrors": [], + "graph": "/data/lib/docker" +} +EOF + + +这里,我来解释下常用的配置参数。 + + +registry-mirrors:仓库地址,可以根据需要修改为指定的地址。 +graph:镜像、容器的存储路径,默认是/var/lib/docker。如果你的 / 目录存储空间满足不了需求,需要设置graph为更大的目录。 +bip:指定容器的IP网段。 + + +配置完成后,需要重启Docker: + +$ sudo systemctl restart docker + + + +测试Docker是否安装成功。 + + +$ sudo docker run hello-world +Unable to find image 'hello-world:latest' locally +latest: Pulling from library/hello-world +b8dfde127a29: Pull complete +Digest: sha256:0fe98d7debd9049c50b597ef1f85b7c1e8cc81f59c8d623fcb2250e8bec85b38 +Status: Downloaded newer image for hello-world:latest +... +Hello from Docker! +This message shows that your installation appears to be working correctly. +.... + + +docker run hello-world命令会下载hello-world镜像,并启动容器,打印安装成功提示信息后退出。 + +这里注意,如果你通过Yum源安装失败,可以尝试Docker官方文档 Install Docker Engine on CentOS提供的其他方式安装。 + +第三步,安装后配置。 + +安装成功后,我们还需要做一些其他配置。主要有两个,一个是配置docker,使其可通过non-root用户使用,另一个是配置docker开机启动。 + + +使用non-root用户操作Docker + + +我们在Linux系统上操作,为了安全,需要以普通用户的身份登录系统并执行操作。所以,我们需要配置docker,使它可以被non-root用户使用。具体配置方法如下: + +$ sudo groupadd docker # 1. 创建`docker`用户组 +$ sudo usermod -aG docker $USER # 2. 将当前用户添加到`docker`用户组下 +$ newgrp docker # 3. 重新加载组成员身份 +$ docker run hello-world # 4. 确认能够以普通用户使用docker + + +如果在执行 sudo groupadd docker 时报 groupadd: group 'docker' already exists 错误,说明 docker 组已经存在了,可以忽略这个报错。 + +如果你在将用户添加到 docker 组之前,使用sudo运行过docker命令,你可能会看到以下错误: + +WARNING: Error loading config file: /home/user/.docker/config.json - +stat /home/user/.docker/config.json: permission denied + + +这个错误,我们可以通过删除~/.docker/目录来解决,或者通过以下命令更改~/.docker/目录的所有者和权限: + +$ sudo chown "$USER":"$USER" /home/"$USER"/.docker -R +$ sudo chmod g+rwx "$HOME/.docker" -R + + + +配置docker开机启动 + + +大部分Linux发行版(RHEL、CentOS、Fedora、Debian、Ubuntu 16.04及更高版本)使用systemd来管理服务,包括指定开启时启动的服务。在Debian和Ubuntu上,Docker默认配置为开机启动。 + +在其他系统,我们需要手动配置Docker开机启动,配置方式如下(分别需要配置docker和containerd服务): + +要在引导时为其他发行版自动启动 Docker 和 Containerd,你可以使用以下命令: + +$ sudo systemctl enable docker.service # 设置 docker 开机启动 +$ sudo systemctl enable containerd.service # 设置 containerd 开机启动 + + +如果要禁止docker、containerd开启启动,可以用这个命令: + +$ sudo systemctl disable docker.service # 禁止 docker 开机启动 +$ sudo systemctl disable containerd.service # 禁止 containerd 开机启动 + + +准备一个Kubernetes集群 + +安装完Docker之后,还需要一个Kubernetes集群,来调度Docker容器。安装Kubernetes集群极其复杂,这里选择一种最简单的方式来准备一个Kubernetes集群:购买一个腾讯云Serverless 集群。 + +腾讯云Serverless 集群是腾讯云容器服务推出的无须用户购买节点即可部署工作负载的集群类型。你可以把它理解为一个标准的Kubernetes集群,不同的是Serverless集群是由腾讯云容器服务团队创建和维护,你只需要访问集群,部署你的资源,并按照容器真实的资源使用量支付费用即可。你可以登录腾讯云容器服务控制台(https://console.cloud.tencent.com/tke2)购买Serverless集群。 + +如果你想自己搭建Kubernetes集群,这里建议你购买3台腾讯云CVM机器,并参照follow-me-install-kubernetes-cluster教程来一步步搭建Kubernetes集群,CVM机器建议的最小配置如下:- + + +EKS简介 + +我先简单介绍一下EKS是什么。EKS(Elastic Kubernetes Service)即腾讯云弹性容器服务,是腾讯云容器服务推出的无须用户购买节点即可部署工作负载的服务模式。它完全兼容原生的 Kubernetes,支持使用原生方式创建及管理资源,按照容器真实的资源使用量计费。弹性容器服务 EKS 还扩展支持腾讯云的存储及网络等产品,同时确保用户容器的安全隔离,开箱即用。 + +EKS费用 + +那它是如何收费呢?EKS 是全托管的Serverless Kubernetes 服务,不会收取托管的 Master、etcd 等资源的费用。弹性集群内运行的工作负载采用后付费的按量计费模式,费用根据实际配置的资源量按使用时间计算。也就是说:Kubernetes集群本身是免费的,只有运行工作负载,消耗节点资源时收费。 + +EKS有3种计费模式:预留券、按量计费、竞价模式,这里我建议选择按量计费。按量计费,支持按秒计费,按小时结算,随时购买随时释放,从专栏学习的角度来说,费用是最低的。EKS 会根据工作负载申请的 CPU、内存数值以及工作负载的运行时间来核算费用,具体定价,你可以参考:定价|弹性容器服务。 + +这里我通过例子来说明一下费用问题,IAM应用会部署4个Deployment,每个Deployment一个副本: + + +iam-apiserver:IAM REST API服务,提供用户、密钥、策略资源的CURD功能的API接口。 +iam-authz-server:IAM资源授权服务,对外提供资源授权接口。 +iam-pump:IAM数据清洗服务,从Redis中获取授权日志,处理后保存在MongoDB中。 +iamctl:IAM应用的测试服务,登陆iamctl Pod可以执行iamctl命令和smoke测试脚本,完成对IAM应用的运维和测试。 + + +上述4个Deployment中的Pod配置均为0.25核、512Mi内存。 + +这里,我们根据EKS的费用计算公式 费用 = 相关计费项配置 × 资源单位时间价格 × 运行时间 计算IAM部署一天的费用: + +总费用 = (4 x 1) x (0.25 x 0.12 + 0.5 x 0.05) x 24 = 4.8 元 + + +也就是按最低配置部署IAM应用,运行一天的费用是4.8元(一瓶水的钱,就能学到如何将IAM应用部署在Kubernetes平台上,很值!)。你可能想这个计算公式里每个数值都代表什么呢?我来解释一下,其中: + + +(4 x 1):Kubernetes Pod总个数(一共是4个Deployment,每个Pod 1个副本)。 +0.25 x 0.12:连续运行1小时的CPU配置费用。 +0.5 x 0.05:连续运行1小时的内存配置费用。 +24:24小时,也即一天。 + + +这里需要注意,为了帮助你节省费用,上述配置都是最低配置。在实际生产环境中,建议的配置如下:- +- +因为iam-pump组件是有状态的,并且目前没有实现抢占机制,所以副本数需要设置为1。 + +另外,Intel按量计费的配置费用见下图: + + + +在这里有个很重要的事情提醒你:学完本节课,销毁这些Deployment,避免被继续扣费。建议腾讯云账户余额不要超过50元。 + +申请EKS集群 + +了解了EKS以及费用相关的问题,接下来我们看看如何申请EKS集群。你可以通过以下5步来申请EKS集群。在正式申请前,请先确保腾讯云账户有大于 10 元的账户余额,否则在创建和使用EKS集群的过程中可能会因为费用不足而报错。 + + +创建腾讯云弹性集群 + + +具体步骤如下: + +首先,登录容器服务控制台,选择左侧导航栏中的【弹性集群】。- +然后,在页面上方选择需创建弹性集群的地域,并单击【新建】。在“创建弹性集群”页面,根据以下提示设置集群信息。如下图所示: + + + +页面中各选择项的意思,我来给你解释一下: + + +集群名称:创建的弹性集群名称,不超过60个字符。 +Kubernetes版本:弹性集群支持1.12以上的多个 Kubernetes 版本选择,建议选择最新的版本。 +所在地域:建议你根据所在地理位置选择靠近的地域,可降低访问延迟,提高下载速度。 +集群网络:已创建的弹性集群 VPC 网络,你可以选择私有网络中的子网用于弹性集群的容器网络,详情请见 私有网络(VPC) 。 +容器网络:为集群内容器分配在容器网络地址范围内的 IP 地址。弹性集群的 Pod 会直接占用 VPC 子网 IP,请尽量选择 IP 数量充足且与其他产品使用无冲突的子网。 +Service CIDR:集群的 ClusterIP Service 默认分配在所选 VPC 子网中,请尽量选择 IP 数量充足且与其他产品使用无冲突的子网。 +集群描述:创建集群的相关信息,该信息将显示在“集群信息”页面。 + + +设置完成后,单击【完成】即可开始创建,可在“弹性集群”列表页面查看集群的创建进度。 + +等待弹性集群创建完成,创建完成后的弹性集群页面如下图所示: + + + +我们创建的弹性集群ID为cls-dc6sdos4。 + + +开启外网访问 + + +如果想访问EKS集群,需要先开启EKS的外网访问能力,开启方法如下: + +登录容器服务控制台 -> 选择左侧导航栏中的【弹性集群】 -> 进入cls-dc6sdos4 集群的详情页中 -> 选择【基本信息】 -> 点击【外网访问】按钮。如下图所示: + + + +这里要注意,开启外网访问时,为了安全,需要设置允许访问kube-apiserver的IP段。为了避免不必要的错误,外网访问地址我们设置为0.0.0.0/0 。如下图所示: + + + +注意,只有测试时才可这么设置为 0.0.0.0/0 ,如果是生产环境,建议严格限制可以访问kube-apiserver的来源IP。 + + +安装kubectl命令行工具 + + +如果要访问EKS(标准的Kubernetes集群),比较高效的方式是通过Kubernetes提供的命令行工具kubectl来访问。所以,还需要安装kubectl工具。 + +安装方式如下: + +$ curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +$ mkdir -p $HOME/bin +$ mv kubectl $HOME/bin +$ chmod +x $HOME/bin/kubectl + + +具体可参考安装和设置 kubectl。- +你可以通过以下命令来配置kubectl的bash自动补全: + +$ kubectl completion bash > $HOME/.kube-completion.bash +$ echo 'source $HOME/.kube-completion.bash' >> ~/.bashrc +$ bash + + + +下载并安装kubeconfig + + +安装完kubectl工具之后,需要配置kubectl所读取的配置文件。 + +这里注意,在上一步,我们开启了外网访问,开启后EKS会生成一个kubeconfig配置( kubeconfig 即为kubectl的配置文件)。我们可以从页面下载并安装。 + +在弹性集群的基本信息页面,点击【复制】按钮,复制kubeconfig文件内容,如下图所示: + + + +复制后,将粘贴板的内容保存在$HOME/.kube/config文件中。需要先执行mkdir -p $HOME/.kube创建.kube目录,再将粘贴版中的内容写到 config 文件中。 + +你可以通过以下命令,来测试kubectl工具是否成功安装和配置: + +$ kubectl get nodes +NAME STATUS ROLES AGE VERSION +eklet-subnet-lowt256k Ready 2d1h v2.5.21 + + +如果输出了Kubernetes的eklet节点,并且节点状态为Ready,说明Kubernetes集群运行正常,并且kubectl安装和配置正确。 + + +EKS集群开通集群内服务访问外网能力 + + +因为IAM应用中的数据库:MariaDB、Redis、MongoDB可能需要通过外网访问,所以还需要开通EKS中Pod访问外网的能力。 + +EKS支持通过配置 NAT 网关 和 路由表 来实现集群内服务访问外网。具体开启步骤,需要你查看腾讯云官方文档:通过 NAT 网关访问外网。 + +在开通过程中有以下两点需要你注意: + + +在创建指向 NAT 网关的路由表步骤中,目的端要选择:0.0.0.0/0。 +在关联子网至路由表步骤中,只关联创建EKS集群时选择的子网。 + + +如果你的数据库需要通过外网访问,这里一定要确保EKS集群成功开通集群内服务访问外网能力,否则部署IAM应用时会因为访问不了数据库而失败。 + +安装IAM应用 + +上面,我们开通了镜像仓库、安装了Docker引擎、安装和配置了Kubernetes集群,那么接下来,我们就来看下如何将IAM应用部署到Kubernetes集群中。 + +假设IAM项目仓库根目录路径为 $IAM_ROOT,具体安装步骤如下: + + +配置scripts/install/environment.sh + + +scripts/install/environment.sh文件中包含了各类自定义配置。你可能需要配置跟数据库相关的配置(当然,也可以都使用默认值): + + +MariaDB配置:environment.sh文件中以MARIADB_开头的变量。 +Redis配置:environment.sh文件中以REDIS_开头的变量。 +MongoDB配置:environment.sh文件中以MONGO_开头的变量。 + + +其他配置,使用默认值。 + + +创建IAM应用的配置文件 + + +$ cd ${IAM_ROOT} +$ make gen.defaultconfigs # 生成iam-apiserver、iam-authz-server、iam-pump、iamctl组件的默认配置文件 +$ make gen.ca # 生成 CA 证书 + + +上述命令会将IAM的配置文件存放在这个${IAM_ROOT}/_output/configs/目录下。 + + +创建IAM命名空间 + + +我们将IAM应用涉及到的各类资源,都创建在iam命名空间中。将IAM资源创建在独立的命名空间中,不仅方便维护,还可以有效避免影响其他Kubernetes资源。 + +$ kubectl create namespace iam + + + +将IAM各服务的配置文件,以ConfigMap资源的形式保存在Kubernetes集群中 + + +$ kubectl -n iam create configmap iam --from-file=${IAM_ROOT}/_output/configs/ +$ kubectl -n iam get configmap iam +NAME DATA AGE +iam 4 13s + + +执行kubectl -n iam get configmap iam命令,可以成功获取创建的iam configmap。 + +如果你觉得每次执行kubectl命令都要指定-n iam选项很繁琐,你可以使用以下命令,将kubectl上下文环境中的命名空间指定为iam。设置后,执行kubectl命令,默认在iam命名空间下执行: + +$ kubectl config set-context `kubectl config current-context` --namespace=iam + + + +将IAM各服务使用的证书文件,以ConfigMap资源的形式创建在Kubernetes集群中 + + +$ kubectl -n iam create configmap iam-cert --from-file=${IAM_ROOT}/_output/cert +$ kubectl -n iam get configmap iam-cert +NAME DATA AGE +iam-cert 14 12s + + +执行kubectl -n iam get configmap iam-cert命令,可以成功获取创建的iam-cert configmap。 + + +创建镜像仓库访问密钥 + + +在准备阶段,我们开通了腾讯云镜像仓库服务(访问地址为ccr.ccs.tencentyun.com),并创建了用户10000099xxxx,其密码为iam59!z$。 + +接下来,我们就可以创建docker-registry secret。Kubernetes在下载Docker镜像时,需要docker-registry secret来进行认证。创建命令如下: + +$ kubectl -n iam create secret docker-registry ccr-registry --docker-server=ccr.ccs.tencentyun.com --docker-username=10000099xxxx --docker-password='iam59!z$' + + + +创建Docker镜像,并Push到镜像仓库 + + +将镜像Push到CCR镜像仓库,需要确保你已经登录到腾讯云CCR镜像仓库,如果没登录,可以执行以下命令来登录: + +$ docker login --username=[username] ccr.ccs.tencentyun.com + + +执行 make push 命令构建镜像,并将镜像Push到CCR镜像仓库: + +$ make push REGISTRY_PREFIX=ccr.ccs.tencentyun.com/marmotedu VERSION=v1.1.0 + + +上述命令,会构建iam-apiserver-amd64、iam-authz-server-amd64、iam-pump-amd64、iamctl-amd64 四个镜像,并将这些镜像Push到腾讯云镜像仓库的marmotedu命名空间下。 + +构建的镜像如下: + +$ docker images|grep marmotedu +ccr.ccs.tencentyun.com/marmotedu/iam-pump-amd64 v1.1.0 e078d340e3fb 10 seconds ago 244MB +ccr.ccs.tencentyun.com/marmotedu/iam-apiserver-amd64 v1.1.0 5e90b67cc949 2 minutes ago 239MB +ccr.ccs.tencentyun.com/marmotedu/iam-authz-server-amd64 v1.1.0 6796b02be68c 2 minutes ago 238MB +ccr.ccs.tencentyun.com/marmotedu/iamctl-amd64 v1.1.0 320a77d525e3 2 minutes ago 235MB + + + +修改 ${IAM_ROOT}/deployments/iam.yaml 配置 + + +这里请你注意,如果在上一个步骤中,你构建的镜像tag不是 v1.1.0 ,那么你需要修改 ${IAM_ROOT}/deployments/iam.yaml 文件,并将iam-apiserver-amd64、 iam-authz-server-amd64、 iam-pump-amd64、iamctl-amd64 镜像的tag修改成你构建镜像时指定的tag。 + + +部署IAM应用 + + +$ kubectl -n iam apply -f ${IAM_ROOT}/deployments/iam.yaml + + +执行上述命令,会在iam命令空间下,创建一系列Kubernetes资源,可以使用以下命令,来获取这些资源的状态: + +$ kubectl -n iam get all +NAME READY STATUS RESTARTS AGE +pod/iam-apiserver-d8dc48596-wkhpl 1/1 Running 0 94m +pod/iam-authz-server-6bc899c747-fbpbk 1/1 Running 0 94m +pod/iam-pump-7dcbfd4f59-2w9vk 1/1 Running 0 94m +pod/iamctl-6fc46b8ccb-gs62l 1/1 Running 1 98m + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/iam-apiserver ClusterIP 192.168.0.174 8443/TCP,8080/TCP,8081/TCP 101m +service/iam-authz-server ClusterIP 192.168.0.76 9443/TCP,9090/TCP 101m +service/iam-pump ClusterIP 192.168.0.155 7070/TCP 101m + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/iam-apiserver 1/1 1 1 101m +deployment.apps/iam-authz-server 1/1 1 1 101m +deployment.apps/iam-pump 1/1 1 1 101m +deployment.apps/iamctl 1/1 1 1 101m + +NAME DESIRED CURRENT READY AGE +replicaset.apps/iam-apiserver-d8dc48596 1 1 1 101m +replicaset.apps/iam-authz-server-6bc899c747 1 1 1 101m +replicaset.apps/iam-pump-7dcbfd4f59 1 1 1 101m +replicaset.apps/iamctl-6fc46b8ccb 1 1 1 101m + + +我们看到pod/iam-apiserver-d8dc48596-wkhpl、pod/iam-authz-server-6bc899c747-fbpbk、pod/iam-pump-7dcbfd4f59-2w9vk、pod/iamctl-6fc46b8ccb-gs62l 4个Pod都处在Running状态,说明服务都成功启动。 + +测试IAM应用 + +我们在iam命令空间下创建了一个测试Deployment iamctl。你可以登陆iamctl Deployment所创建出来的Pod,执行一些运维操作和冒烟测试。登陆命令如下: + +$ kubectl -n iam exec -it `kubectl -n iam get pods -l app=iamctl | awk '/iamctl/{print $1}'` -- bash + + +登陆到iamctl-xxxxxxxxxx-xxxxx Pod中后,就可以执行运维操作和冒烟测试了。 + + +运维操作 + + +在iamctl容器中,你可以使用iamctl工具提供的各类功能,iamctl以子命令的方式对外提供功能。命令执行效果见下图: + + + + +冒烟测试 + + +# cd /opt/iam/scripts/install +# ./test.sh iam::test::smoke + + +如果./test.sh iam::test::smoke命令,打印的输出中,最后一行为congratulations, smoke test passed!字符串,说明IAM应用安装成功。如下图所示: + + + +销毁Serverless集群及其资源 + +好了,到这里,你已经成功在Serverless集群中部署了IAM应用,Serverless的使命也就完成了。接下来,为避免账户被持续扣费,需要删除Serverless内的资源和集群。 + + +删除Serverless内创建的IAM资源 + + +$ kubectl delete namespace iam + + +因为删除Namespace会删除Namespace下的所有资源,所以上述命令执行时间会久点。 + + +删除Serverless集群 + + +登录腾讯云容器服务控制台,选择所创建的Serverless集群,删除即可。 + +总结 + +云原生架构设计中,需要将IAM应用部署到Kubernetes集群中。所以,首先需要你准备一个Kubernetes集群。你可以自己购买腾讯云CVM机器搭建Kubernetes集群,但这种方式费用高、操作复杂。所以,我建议你直接申请一个Serverless集群来部署IAM应用。 + +Serverless集群是一个标准的Kubernetes集群,可以快速申请,并免运维。Serverless集群只收取实际的资源使用费用。在专栏学习过程中,部署IAM应用期间产生的资源使用费用其实是很低的,所以推荐使用这种方式来部署IAM应用。 + +有了Kubernetes集群,就可以直接通过以下命令来部署整个IAM应用: + +$ kubectl -n iam apply -f ${IAM_ROOT}/deployments/iam.yaml + + +应用部署起来之后,我们可以登陆到 iamctl-xxxxxxxxxx-xxxxxPod,并执行以下命令来测试整个IAM应用是否被成功部署: + +# cd /opt/iam/scripts/install +# ./test.sh iam::test::smoke + + +课后练习 + + +思考下,如何将MariaDB、MongoDB、Redis实现容器化? +思考下,如何更相信IAM应用中的iam-apiserver服务,试着更新这个服务。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/49服务编排(上):Helm服务编排基础知识.md b/专栏/Go语言项目开发实战/49服务编排(上):Helm服务编排基础知识.md new file mode 100644 index 0000000..64a28d6 --- /dev/null +++ b/专栏/Go语言项目开发实战/49服务编排(上):Helm服务编排基础知识.md @@ -0,0 +1,407 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 49 服务编排(上):Helm服务编排基础知识 + 你好,我是孔令飞。 + +我们将应用部署在Kubernetes时,可能需要创建多个服务。我就见过一个包含了40多个微服务的超大型应用,每个服务又包含了多个Kubernetes资源,比如 Service、Deployment、StatefulSet、ConfigMap等。相同的应用又要部署在不同的环境中,例如测试环境、预发环境、现网环境等,也就是说应用的配置也不同。 + +对于一个大型的应用,如果基于YAML文件一个一个地部署Kubernetes资源,是非常繁琐、低效的,而且这些YAML文件维护起来极其复杂,还容易出错。那么,有没有一种更加高效的方式?比如,像Docker镜像一样,将应用需要的Kubernetes资源文件全部打包在一起,通过这个包来整体部署和管理应用,从而降低应用部署和维护的复杂度。 + +答案是有。我们可以通过Helm Chart包来管理这些Kubernetes文件,并通过helm命令,基于Chart包来创建和管理应用。 + +接下来,我就来介绍下Helm的基础知识,并给你演示下如何基于Helm部署IAM应用。 + +Helm基础知识介绍 + +Helm目前是Kubernetes服务编排事实上的标准。Helm提供了多种功能来支持Kubernetes的服务编排,例如 helm 命令行工具、Chart包、Chart仓库等。下面,我就来详细介绍下。 + +Helm是什么? + +Helm是Kubernetes的包管理器,类似于Python的 pip ,centos的 yum 。Helm主要用来管理Chart包。Helm Chart包中包含一系列YAML格式的Kubernetes资源定义文件,以及这些资源的配置,可以通过Helm Chart包来整体维护这些资源。 + +Helm也提供了一个helm命令行工具,该工具可以基于Chart包一键创建应用,在创建应用时,可以自定义Chart配置。应用发布者可以通过Helm打包应用、管理应用依赖关系、管理应用版本,并发布应用到软件仓库;对于使用者来说,使用Helm后不需要编写复杂的应用部署文件,可以非常方便地在Kubernetes上查找、安装、升级、回滚、卸载应用程序。 + +Helm最新的版本是v3,Helm3以Helm2的核心功能为基础,对Chart repo、发行版管理、安全性和library Charts进行了改进。和Helm2比起来,Helm3最明显的变化是删除了Tiller(Helm2 是一种 Client-Server 结构,客户端称为 Helm,服务器称为 Tiller)。Helm3还新增了一些功能,并废弃或重构了Helm2的部分功能,与Helm2不再兼容。此外,Helm3还引入了一些新的实验功能,包括OCI支持。 + +Helm3架构图如下: + + + +上面的架构图中,核心是Helm Client(helm命令)和Helm Chart包。helm命令可以从Chart Repository中下载Helm Chart包,读取kubeconfig文件,并构建kube-apiserver REST API接口的HTTP请求。通过调用Kubernetes提供的REST API接口,将Chart包中包含的所有以YAML格式定义的Kubernetes资源,在Kubernetes集群中创建。 + +这些资源以Release的形式存在于Kubernetes集群中,每个Release又包含多个Kubernetes资源,例如Deployment、Pod、Service等。 + +Helm中的三大基本概念 + +要学习和使用Helm,一定要了解Helm中的三大基本概念,Helm的所有操作基本都是围绕着这些概念来进行的。下面我来介绍下Helm的三大基本概念。 + + +Chart: 代表一个Helm包。它包含了在Kubernetes集群中运行应用程序、工具或服务所需的所有YAML格式的资源定义文件。 +Repository(仓库): 它是用来存放和共享 Helm Chart的地方,类似于存放源码的GitHub的Repository,以及存放镜像的Docker的Repository。 +Release:它是运行在 Kubernetes 集群中的 Chart 的实例。一个Chart通常可以在同一个集群中安装多次。每一次安装都会创建一个新的 Release。 + + +我们为什么要使用Helm? + +现在你对Helm已经有了一定了解,这里我再来详细介绍下为什么要使用Helm。 + +先来看下传统的应用部署模式: + + + +我们有测试环境、预发环境、现网环境三个环境,每个环境中部署一个应用A,应用A中包含了多个服务,每个服务又包含了自己的配置,不同服务之间的配置有些是共享的,例如配置A。 + +每个服务由一个复杂的Kubernetes YAML格式的文件来定义并创建,可以看到如果靠传统的方式,去维护这些YAML格式文件,并在不同环境下使用不同的配置去创建应用,是一件非常复杂的工作,并且后期YAML文件和Kubernetes集群中部署应用的维护都很复杂。随着微服务规模越来越大,会面临以下挑战: + + +微服务化服务数量急剧增多,给服务管理带来了极大的挑战。 +服务数量急剧增多,增加了管理难度,对运维部署是一种挑战。 +服务数量的增多,对服务配置管理也提出了更高的要求。 +随着服务数量增加,服务依赖关系也变得更加复杂,服务依赖关系的管理难度增大。 +在环境信息管理方面,在新环境快速部署一个复杂应用变得更加困难。 + + +所以,我们需要一种更好的方式,来维护和管理这些YAML文件和Kubernetes中部署的应用。Helm可以帮我们解决上面这些问题。 + +接下来,我们来看下Helm是如何解决这些问题的。 + +在Helm中,可以理解为主要包含两类文件:模板文件和配置文件。模板文件通常有多个,配置文件通常有一个。Helm的模板文件基于text/template模板文件,提供了更加强大的模板渲染能力。Helm可以将配置文件中的值渲染进模板文件中,最终生成一个可以部署的Kubernetes YAML格式的资源定义文件,如下图所示: + + + +上图中,我们将以下配置渲染进了模板中,生成了Kubernetes YAML文件: + +replicas: 2 +tag: latest +common: + username: colin + password: iam1234 + + +所以在Helm中,部署一个应用可以简化为Chart模板(多个服务) + Chart配置 -> 应用,如下图所示: + + + +Chart模板一个应用只用编写一次,可以重复使用。在部署时,可以指定不同的配置,从而将应用部署在不同的环境中,或者在同一环境中部署不同配置的应用。 + +Helm 基本操作实战 + +上面,我介绍了Helm的一些基础知识,这里我们再来学习下如何使用Helm对应用进行生命周期管理。 + +前置条件 + +在开始之前,你需要确保你有一个可以使用的Kubernetes集群。目前最方便快捷、最经济的方式是申请一个腾讯云EKS集群。至于如何申请和访问,你可以参考 48讲 “准备一个Kubernetes集群”部分的教程。这里再提醒下,用完集群后,记得删除集群资源,免得被持续扣费。 + +安装Helm + +Helm提供了多种安装方式,在能连通外网的情况下,可以通过脚本来安装,安装命令如下: + +$ mkdir -p $HOME/bin +$ wget https://get.helm.sh/helm-v3.6.3-linux-amd64.tar.gz +$ tar -xvzf helm-v3.6.3-linux-amd64.tar.gz +$ mv linux-amd64/helm $HOME/bin +$ chmod +x $HOME/bin/helm +$ helm version +version.BuildInfo{Version:"v3.6.3", GitCommit:"d506314abfb5d21419df8c7e7e68012379db2354", GitTreeState:"clean", GoVersion:"go1.16.5"} + + +如果执行helm version可以成功打印出 helm 命令的版本号,说明Helm安装成功。 + +Helm各版本安装包地址见 Helm Releases。 + +安装完helm命令后,可以安装helm命令的自动补全脚本。假如你用的shell是bash,安装方法如下: + +$ helm completion bash > $HOME/.helm-completion.bash +$ echo 'source $HOME/.helm-completion.bash' >> ~/.bashrc +$ bash + + +执行 helm comp,就会自动补全为helm completion。 + +Helm快速入门 + +你可以通过以下六个步骤,来快速创建一个Chart应用。 + +第一步,初始化一个Helm Chart仓库。 + +安装完Helm之后,就可以使用 helm 命令添加一个Chart仓库。类似于用来托管Docker镜像的DockerHub、用来托管代码的GitHub,Chart包也有一个托管平台,当前比较流行的Chart包托管平台是Artifact Hub。 + +Artifact Hub上有很多Chart仓库,我们可以添加需要的Chart仓库,这里我们添加BitNami提供的Chart仓库: + +$ helm repo add bitnami https://charts.bitnami.com/bitnami # 添加 Chart Repository +$ helm repo list # 查看添加的 Repository 列表 + + +添加完成后,我们可以通过helm search命令,来查询需要的Chart包。helm search支持两种不同的查询方式,这里我来介绍下。 + + +helm search repo:从你使用 helm repo add 添加到本地 Helm 客户端中的仓库里查找。该命令基于本地数据进行搜索,无需连接外网。 +helm search hub:从 Artifact Hub 中查找并列出 Helm Charts。 Artifact Hub中存放了大量的仓库。 + + +Helm 搜索使用模糊字符串匹配算法,所以你可以只输入名字的一部分。下面是一个helm search的示例: + +$ helm search repo bitnami +NAME CHART VERSION APP VERSION DESCRIPTION +bitnami/bitnami-common 0.0.9 0.0.9 DEPRECATED Chart with custom templates used in ... +bitnami/airflow 10.2.8 2.1.2 Apache Airflow is a platform to programmaticall... +bitnami/apache 8.6.1 2.4.48 Chart for Apache HTTP Server +bitnami/argo-cd 1.0.2 2.0.5 Declarative, GitOps continuous delivery tool fo... +bitnami/aspnet-core 1.3.14 3.1.18 ASP.NET Core is an open-source framework create... +bitnami/cassandra 8.0.2 4.0.0 Apache Cassandra is a free and open-source dist... +bitnami/cert-manager 0.1.15 1.5.1 Cert Manager is a Kubernetes add-on to automate... +# ... and many more + + +第二步,安装一个示例Chart。 + +查询到自己需要的Helm Chart后,就可以通过helm install命令来安装一个Chart。helm install支持从多种源进行安装: + + +Chart的Repository。 +本地的Chart Archive,例如helm install foo foo-1.0.0.tgz。 +一个未打包的Chart路径,例如helm install foo path/to/foo。 +一个完整的URL,例如helm install foo https://example.com/charts/foo-1.0.0.tgz。 + + +这里,我们选择通过bitnami/mysql Chart包来安装一个MySQL应用。你可以执行 helm show chart bitnami/mysql 命令,来简单了解这个Chart的基本信息。 或者,你也可以执行 helm show all bitnami/mysql,获取关于该Chart的所有信息。 + +接下来,就可以使用helm install命令来安装这个Chart包了。安装命令如下: + +$ helm repo update # Make sure we get the latest list of charts +$ helm install bitnami/mysql --generate-name +NAME: mysql-1629528555 +LAST DEPLOYED: Sat Aug 21 14:49:19 2021 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None +NOTES: ... + + +在上面的例子中,我们通过安装bitnami/mysql这个Chart,创建了一个mysql-1629528555 Release。--generate-name参数告诉Helm自动为这个Release命名。 + +在安装过程中,Helm 客户端会打印一些有用的信息,包括哪些资源已经被创建,Release当前的状态,以及你是否还需要执行额外的配置步骤。例如,从上述例子的输出中,你可以获取到数据库的Root密码、登陆方式、更新方式等信息。 + +安装完之后,你可以使用 helm status 来追踪Release 的状态。 + +每当你执行 helm install 的时候,都会创建一个新的发布版本。所以一个Chart在同一个集群里面可以被安装多次,每一个都可以被独立地管理和升级。 + +helm install命令会将templates渲染成最终的Kubernetes能够识别的YAML格式,然后安装到Kubernetes集群中。 + +helm install 功能非常强大,想了解更多功能,你可以参考这个指南:使用 Helm。 + +第三步,安装前自定义 Chart。 + +上一步中的安装方式只会使用 Chart 的默认配置选项,很多时候我们需要自定义 Chart 来指定我们想要的配置。使用 helm show values 可以查看 Chart 中的可配置选项: + +$ helm show values bitnami/mysql # 为了方便展示,我删除了 `helm show values`输出中的`#`注释 +# ... and many more +architecture: standalone +auth: + rootPassword: "" + database: my_database + username: "" + password: "" + replicationUser: replicator + replicationPassword: "" + existingSecret: "" + forcePassword: false + usePasswordFiles: false + customPasswordFiles: {} +initdbScripts: {} +# ... and many more + + +然后,你可以使用 YAML 格式的文件,覆盖上述任意配置项,并在安装过程中使用该文件。 + +$ echo '{auth.database: iam, auth.username: iam, auth.password: iam59!z$}' > values.yaml +$ helm install bitnami/mysql -f values.yaml --generate-name + + +上述命令将为 MySQL 创建一个名称为 iam 的默认用户,密码为iam59!z$,并且授予该用户访问新建的 iam 数据库的权限。Chart 中的其他默认配置保持不变。 + +安装过程中,有两种传递配置数据的方式。 + + +-f, --values:使用 YAML 文件覆盖配置。可以指定多次,优先使用最右边的文件。 +--set:通过命令行的方式对指定配置项进行覆盖。 + + +如果同时使用两种方式,则 --set 中的值会被合并到 --values 中,但是 --set 中的值优先级更高。在--set中覆盖的内容会被保存在 ConfigMap 中。你可以通过 helm get values 来查看指定 Release 中 --set 设置的值,也可以通过运行 helm upgrade 并指定 --reset-values 字段,来清除 --set中设置的值。 + +这里我讲解下--set的格式和限制。 + +--set 选项使用0或多个key-value 对。最简单的用法类似于--set name=value,等价于下面这个 YAML 格式: + +name: value + + +多个值之间使用逗号分割,因此--set a=b,c=d 的 YAML 表示是: + +a: b +c: d + + +--set还支持更复杂的表达式。例如,--set outer.inner=value 被转换成了: + +outer: + inner: value + + +列表使用花括号{}来表示。例如,--set name={a, b, c} 被转换成了: + +name: + - a + - b + - c + + +从 2.5.0 版本开始,我们可以使用数组下标的语法来访问列表中的元素了。例如 --set servers[0].port=80 就变成了: + +servers: + - port: 80 + + +多个值也可以通过这种方式来设置。--set servers[0] [0].host=marmotedu 变成了: + +servers: + - port: 80 + host: marmotedu + + +如果需要在 --set 中使用特殊字符,你可以使用反斜线来进行转义,比如--set name=value1\,value2 就变成了: + +name: "value1,value2" + + +如果是深层嵌套的数据结构,可能很难用--set 来表达,更多内容你可以参考 Values 文件。 + +第四步,查看当前集群安装了哪些Release。 + +通过helm list可以查看当前集群、当前Namespace下安装的Release列表: + +$ helm list +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +mysql-1629528555 default 1 2021-08-21 14:49:19.101935218 +0800 CST deployed mysql-8.8.4 8.0.26 +mysql-1629529348 default 1 2021-08-21 15:02:32.079969128 +0800 CST deployed mysql-8.8.4 8.0.26 + + +可以看到,我们创建了两个Release,这些Release位于default命名空间中。上述命令,也列出了Release的更新时间、状态、Chart的版本等。 + +第五步,升级 Release,并且在失败时恢复。 + +部署完应用之后,后续还可能升级应用,可以通过helm upgrade命令来升级应用。升级操作会基于已有的Release,根据提供的信息进行升级。Helm在更新时,只会变更有更改的内容。 + +例如,这里我们升级mysql-1629528555,变更它的Root密码: + +$ helm upgrade mysql-1629528555 bitnami/mysql --set auth.rootPassword='iam59!z$' + + +在上面的例子中,mysql-1629528555 这个 Release 使用相同的 Chart 进行升级,但使用了一个新的rootPassword配置。 + +我们可以使用 helm get values 命令,来看看配置值是否真的生效了: + +$ helm get values mysql-1629528555 +USER-SUPPLIED VALUES: +auth: + rootPassword: iam59!z$ + + +可以看到rootPassword 的新值已经被部署到集群中了。 + +假如发布失败,我们也很容易通过 helm rollback [RELEASE] [REVISION] 命令,回滚到之前的发布版本。 + +$ helm rollback mysql-1629528555 1 + + +上面这条命令将我们的mysql-1629528555回滚到了它最初的版本。Release 版本其实是一个增量修订(revision)。 每当发生了一次安装、升级或回滚操作,revision 的值就会加1。第一次 revision 的值永远是1。 + +我们可以使用 helm history [RELEASE] 命令来查看一个特定 Release 的修订版本号: + +$ helm history mysql-1629528555 +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Sat Aug 21 14:49:19 2021 superseded mysql-8.8.4 8.0.26 Install complete +2 Sat Aug 21 15:14:45 2021 deployed mysql-8.8.4 8.0.26 Upgrade complete + + +你还可以指定一些其他的选项,来自定义 Helm 在安装、升级、回滚期间的行为。这里,我介绍一些常用的参数,供你参考。 + + +--timeout:一个 Go duration 类型的值,用来表示等待 Kubernetes 命令完成的超时时间,默认值为 5m0s。 +--no-hooks:不运行当前命令的钩子。 +--wait:表示必须要等到所有的 Pods 都处于 ready 状态、PVC 都被绑定、Deployments处在 ready 状态的Pods 个数达到最小值(Desired减去 maxUnavailable),才会标记该 Release 为成功。最长等待时间由 --timeout 值指定。如果达到超时时间,Release 将被标记为 FAILED。 + + +这里需要注意,当 Deployment 的 replicas 被设置为1,但其滚动升级策略中的maxUnavailable 没有被设置为0时,--wait 将返回就绪,因为已经满足了最小 ready Pod 数。 + +第六步,卸载Release。 + +你可以使用helm uninstall命令卸载一个Release: + +$ helm uninstall mysql-1629528555 + + +上述命令会从Kubernetes卸载 mysql-1629528555, 它将删除和该版本关联的所有资源(Service、Deployment、Pod、ConfigMap等),包括该Release的所有版本历史。 + +如果你在执行 helm uninstall 的时候提供--keep-history 选项, Helm将会保存版本历史。 你可以通过helm status命令查看该版本的信息: + +$ helm status mysql-1629528555 +Status: UNINSTALLED +... + + +因为 --keep-history 选项会让Helm跟踪你的版本(即使你卸载了它们),所以你可以审计集群历史,甚至使用 helm rollback 回滚版本。 + +Helm命令 + +上面我介绍了Helm的一些命令的用法,如果你想查看Helm提供的所有命令,可以执行helm help。或者,你也可以执行helm -h来查看某个子命令的用法,例如: + +$ helm get -h + +This command consists of multiple subcommands which can be used to +get extended information about the release, including: + +- The values used to generate the release +- The generated manifest file +- The notes provided by the chart of the release +- The hooks associated with the release + +Usage: + helm get [command] +# ... and many more + + +我整理了一份命令列表,供你参考: + + + +上面这些命令中,有些提供了子命令和命令行参数,具体你可以执行helm -h来查看。 + +总结 + +今天,我介绍了Helm的基础知识,并给你演示了如何基于Helm部署IAM应用。 + +当一个应用包含了很多微服务时,手动在Kubernetes集群中部署、升级、回滚这些微服务是一件非常复杂的工作。这时候,我们就需要一个服务编排方案来编排这些服务,从而提高服务部署和维护的效率。 + +目前业界提供了多种服务编排方案,其中最流行的是Helm,Helm已经成为一个事实上的Kubernetes服务编排标准。 + +在Helm中,有Chart、Repository和Release三大基本概念。Chart 代表一个Helm包,里面包含了运行Kubernetes应用需要的所有资源定义YAML文件;Repository是Chart仓库,用来存放和共享 Helm Chart;Release是运行在 Kubernetes 集群中的 Chart 的实例。 + +我们可以通过 helm install [NAME] [CHART] [flags] 来安装一个Chart包;通过 helm upgrade [RELEASE] [CHART] [flags] 来更新一个Helm Release;通过 helm uninstall RELEASE_NAME [...] [flags] 来卸载一个Helm Release。另外,helm 命令行工具还提供了其他的功能,你可以再回顾一遍。 + +课后练习 + + +思考下,如果使用Helm创建服务,是否会存在先启动服务,再创建服务配置,从而导致服务启动时加载配置失败的问题?如果有,Helm可以怎样解决这个问题? +尝试将IAM应用制作成一个Chart包,并通过Helm安装。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/50服务编排(下):基于Helm的服务编排部署实战.md b/专栏/Go语言项目开发实战/50服务编排(下):基于Helm的服务编排部署实战.md new file mode 100644 index 0000000..d83d59a --- /dev/null +++ b/专栏/Go语言项目开发实战/50服务编排(下):基于Helm的服务编排部署实战.md @@ -0,0 +1,345 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 50 服务编排(下):基于Helm的服务编排部署实战 + 你好,我是孔令飞。 + +上一讲,我介绍了 Helm 的基础知识,并带着你部署了一个简单的应用。掌握Helm的基础知识之后,今天我们就来实战下,一起通过Helm部署一个IAM应用。 + +通过Helm部署IAM应用,首先需要制作IAM Chart包,然后通过Chart包来一键部署IAM应用。在实际开发中,我们需要将应用部署在不同的环境中,所以我也会给你演示下如何在多环境中部署IAM应用。 + +制作IAM Chart包 + +在部署IAM应用之前,我们首先需要制作一个IAM Chart包。 + +我们假设IAM项目源码根目录为${IAM_ROOT},进入 ${IAM_ROOT}/deployments目录,在该目录下创建Chart包。具体创建流程分为四个步骤,下面我来详细介绍下。 + +第一步,创建一个模板Chart。 + +Chart是一个组织在文件目录中的集合,目录名称就是Chart名称(没有版本信息)。你可以看看这个 Chart 开发指南 ,它介绍了如何开发你自己的Chart。 + +不过,这里你也可以使用 helm create 命令来快速创建一个模板Chart,并基于该Chart进行修改,得到你自己的Chart。创建命令如下: + +$ helm create iam + + +helm create iam会在当前目录下生成一个iam目录,里面存放的就是Chart文件。Chart目录结构及文件如下: + +$ tree -FC iam/ +├── charts/ # [可选]: 该目录中放置当前Chart依赖的其他Chart +├── Chart.yaml # YAML文件,用于描述Chart的基本信息,包括名称版本等 +├── templates/ # [可选]: 部署文件模版目录,模版使用的值来自values.yaml和由Tiller提供的值 +│ ├── deployment.yaml # Kubernetes Deployment object +│ ├── _helpers.tpl # 用于修改Kubernetes objcet配置的模板 +│ ├── hpa.yaml # Kubernetes HPA object +│ ├── ingress.yaml # Kubernetes Ingress object +│ ├── NOTES.txt # [可选]: 放置Chart的使用指南 +│ ├── serviceaccount.yaml +│ ├── service.yaml +│ └── tests/ # 定义了一些测试资源 +│ └── test-connection.yaml +└── values.yaml # Chart的默认配置文件 + + +上面的目录中,有两个比较重要的文件: + + +Chart.yaml 文件 +templates目录 + + +下面我来详细介绍下这两个文件。我们先来看Chart.yaml 文件。 + +Chart.yaml用来描述Chart的基本信息,包括名称、版本等,内容如下: + +apiVersion: Chart API 版本 (必需) +name: Chart名称 (必需) +version: 语义化版本(必需) +kubeVersion: 兼容Kubernetes版本的语义化版本(可选) +description: 对这个项目的一句话描述(可选) +type: Chart类型 (可选) +keywords: + - 关于项目的一组关键字(可选) +home: 项目home页面的URL (可选) +sources: + - 项目源码的URL列表(可选) +dependencies: # chart 必要条件列表 (可选) + - name: Chart名称 (nginx) + version: Chart版本 ("1.2.3") + repository: (可选)仓库URL ("https://example.com/charts") 或别名 ("@repo-name") + condition: (可选) 解析为布尔值的YAML路径,用于启用/禁用Chart(e.g. subchart1.enabled ) + tags: # (可选) + - 用于一次启用/禁用 一组Chart的tag + import-values: # (可选) + - ImportValue 保存源值到导入父键的映射。每项可以是字符串或者一对子/父列表项 + alias: (可选) Chart中使用的别名。当你要多次添加相同的Chart时会很有用 +maintainers: # (可选) + - name: 维护者名字 (每个维护者都需要) + email: 维护者邮箱 (每个维护者可选) + url: 维护者URL (每个维护者可选) +icon: 用作icon的SVG或PNG图片URL (可选) +appVersion: 包含的应用版本(可选)。不需要是语义化,建议使用引号 +deprecated: 不被推荐的Chart(可选,布尔值) +annotations: + example: 按名称输入的批注列表 (可选). + + +我们再来看下templates目录这个文件。 + +templates目录中包含了应用中各个Kubernetes资源的YAML格式资源定义模板,例如: + +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ .Values.pump.name }} + name: {{ .Values.pump.name }} +spec: + ports: + - name: http + protocol: TCP + {{- toYaml .Values.pump.service.http| nindent 4 }} + selector: + app: {{ .Values.pump.name }} + sessionAffinity: None + type: {{ .Values.serviceType }} + + +{{ .Values.pump.name }}会被deployments/iam/values.yaml文件中pump.name的值替换。上面的模版语法扩展了 Go text/template包的语法: + +# 这种方式定义的模版,会去除test模版尾部所有的空行 +{{- define "test"}} +模版内容 +{{- end}} + +# 去除test模版头部的第一个空行 +{{- template "test" }} + + +下面是用于YAML文件前置空格的语法: + +# 这种方式定义的模版,会去除test模版头部和尾部所有的空行 +{{- define "test" -}} +模版内容 +{{- end -}} + +# 可以在test模版每一行的头部增加4个空格,用于YAML文件的对齐 +{{ include "test" | indent 4}} + + +最后,这里有三点需要你注意: + + +Chart名称必须是小写字母和数字,单词之间可以使用横杠-分隔,Chart名称中不能用大写字母,也不能用下划线,.号也不行。 +尽可能使用SemVer 2来表示版本号。 +YAML 文件应该按照双空格的形式缩进(一定不要使用tab键)。 + + +第二步,编辑 iam 目录下的Chart文件。 + +我们可以基于helm create生成的模板Chart来构建自己的Chart包。这里我们添加了创建iam-apiserver、iam-authz-server、iam-pump、iamctl服务需要的YAML格式的Kubernetes资源文件模板: + +$ ls -1 iam/templates/*.yaml +iam/templates/hpa.yaml # Kubernetes HPA模板文件 +iam/templates/iam-apiserver-deployment.yaml # iam-apiserver服务deployment模板文件 +iam/templates/iam-apiserver-service.yaml # iam-apiserver服务service模板文件 +iam/templates/iam-authz-server-deployment.yaml # iam-authz-server服务deployment模板文件 +iam/templates/iam-authz-server-service.yaml # iam-authz-server服务service模板文件 +iam/templates/iamctl-deployment.yaml # iamctl服务deployment模板文件 +iam/templates/iam-pump-deployment.yaml # iam-pump服务deployment模板文件 +iam/templates/iam-pump-service.yaml # iam-pump服务service模板文件 + + +模板的具体内容,你可以查看deployments/iam/templates/。 + +在编辑 Chart 时,我们可以通过 helm lint 验证格式是否正确,例如: + +$ helm lint iam +==> Linting iam + +1 chart(s) linted, 0 chart(s) failed + + +0 chart(s) failed 说明当前Iam Chart包是通过校验的。 + +第三步,修改Chart的配置文件,添加自定义配置。 + +我们可以编辑deployments/iam/values.yaml文件,定制自己的配置。具体配置你可以参考deployments/iam/values.yaml。 + +在修改 values.yaml 文件时,你可以参考下面这些最佳实践。 + + +变量名称以小写字母开头,单词按驼峰区分,例如chickenNoodleSoup。 +给所有字符串类型的值加上引号。 +为了避免整数转换问题,将整型存储为字符串更好,并用 {{ int $value }} 在模板中将字符串转回整型。 +values.yaml中定义的每个属性都应该文档化。文档字符串应该以它要描述的属性开头,并至少给出一句描述。例如: + + +# serverHost is the host name for the webserver +serverHost: example +# serverPort is the HTTP listener port for the webserver +serverPort: 9191 + + +这里需要注意,所有的Helm内置变量都以大写字母开头,以便与用户定义的value进行区分,例如.Release.Name、.Capabilities.KubeVersion。 + +为了安全,values.yaml中只配置Kubernetes资源相关的配置项,例如Deployment副本数、Service端口等。至于iam-apiserver、iam-authz-server、iam-pump、iamctl组件的配置文件,我们创建单独的ConfigMap,并在Deployment中引用。 + +第四步,打包Chart,并上传到Chart仓库中。 + +这是一个可选步骤,可以根据你的实际需要来选择。如果想了解具体操作,你可以查看 Helm chart 仓库获取更多信息。 + +最后,IAM应用的Chart包见deployments/iam。 + +IAM Chart部署 + +上面,我们制作了IAM应用的Chart包,接下来我们就使用这个Chart包来一键创建IAM应用。IAM Chart部署一共分为10个步骤,你可以跟着我一步步操作下。 + +第一步,配置scripts/install/environment.sh。 + +scripts/install/environment.sh文件中包含了各类自定义配置,你主要配置下面这些跟数据库相关的就可以,其他配置使用默认值。 + + +MariaDB配置:environment.sh文件中以MARIADB_开头的变量。 +Redis配置:environment.sh文件中以REDIS_开头的变量。 +MongoDB配置:environment.sh文件中以MONGO_开头的变量。 + + +第二步,创建IAM应用的配置文件。 + +$ cd ${IAM_ROOT} +$ make gen.defaultconfigs # 生成iam-apiserver、iam-authz-server、iam-pump、iamctl组件的默认配置文件 +$ make gen.ca # 生成 CA 证书 + + +上面的命令会将IAM的配置文件存放在目录${IAM_ROOT}/_output/configs/下。 + +第三步,创建 iam 命名空间。 + +我们将IAM应用涉及到的各类资源都创建在iam命名空间中。将IAM资源创建在独立的命名空间中,不仅方便维护,还可以有效避免影响其他Kubernetes资源。 + +$ kubectl create namespace iam + + +第四步,将IAM各服务的配置文件,以ConfigMap资源的形式保存在Kubernetes集群中。 + +$ kubectl -n iam create configmap iam --from-file=${IAM_ROOT}/_output/configs/ +$ kubectl -n iam get configmap iam +NAME DATA AGE +iam 4 13s + + +第五步,将IAM各服务使用的证书文件,以ConfigMap资源的形式保存在Kubernetes集群中。 + +$ kubectl -n iam create configmap iam-cert --from-file=${IAM_ROOT}/_output/cert +$ kubectl -n iam get configmap iam-cert +NAME DATA AGE +iam-cert 14 12s + + +第六步,创建镜像仓库访问密钥。 + +在准备阶段,我们开通了腾讯云镜像仓库服务,并创建了用户10000099xxxx,密码为iam59!z$。 + +接下来,我们就可以创建docker-registry secret了。Kubernetes在下载Docker镜像时,需要docker-registry secret来进行认证。创建命令如下: + +$ kubectl -n iam create secret docker-registry ccr-registry --docker-server=ccr.ccs.tencentyun.com --docker-username=10000099xxxx --docker-password='iam59!z$' + + +第七步,创建Docker镜像,并Push到镜像仓库。 + +$ make push REGISTRY_PREFIX=ccr.ccs.tencentyun.com/marmotedu VERSION=v1.1.0 + + +第八步,安装IAM Chart包。 + +在49讲里,我介绍了4种安装Chart包的方法。这里,我们通过未打包的IAM Chart路径来安装,安装方法如下: + +$ cd ${IAM_ROOT} +$ helm -n iam install iam deployments/iam +NAME: iam +LAST DEPLOYED: Sat Aug 21 17:46:56 2021 +NAMESPACE: iam +STATUS: deployed +REVISION: 1 +TEST SUITE: None + + +执行 helm install 后,Kubernetes会自动部署应用,等到IAM应用的Pod都处在 Running 状态时,说明IAM应用已经成功安装: + +$ kubectl -n iam get pods|grep iam +iam-apiserver-cb4ff955-hs827 1/1 Running 0 66s +iam-authz-server-7fccc7db8d-chwnn 1/1 Running 0 66s +iam-pump-78b57b4464-rrlbf 1/1 Running 0 66s +iamctl-59fdc4995-xrzhn 1/1 Running 0 66s + + +第九步,测试IAM应用。 + +我们通过helm install在iam命令空间下创建了一个测试Deployment iamctl。你可以登陆iamctl Deployment所创建出来的Pod,执行一些运维操作和冒烟测试。登陆命令如下: + +$ kubectl -n iam exec -it `kubectl -n iam get pods -l app=iamctl | awk '/iamctl/{print $1}'` -- bash + + +登陆到iamctl-xxxxxxxxxx-xxxxx Pod中后,你就可以执行运维操作和冒烟测试了。 + +先来看运维操作。iamctl工具以子命令的方式对外提供功能,你可以使用它提供的各类功能,如下图所示: + + + +再来看冒烟测试: + +# cd /opt/iam/scripts/install +# ./test.sh iam::test::smoke + + +如果./test.sh iam::test::smoke命令打印的输出中,最后一行为congratulations, smoke test passed!字符串,就说明IAM应用安装成功。如下图所示: + + + +第十步,销毁EKS集群的资源。 + +$ kubectl delete namespace iam + + +你可以根据需要选择是否删除EKS集群,如果不需要了就可以选择删除。 + +IAM应用多环境部署 + +在实际的项目开发中,我们需要将IAM应用部署到不同的环境中,不同环境的配置文件是不同的,那么IAM项目是如何进行多环境部署的呢? + +IAM项目在configs目录下创建了多个Helm values文件(格式为values-{envName}-env.yaml): + + +values-test-env.yaml,测试环境Helm values文件。 +values-pre-env.yaml,预发环境Helm values文件。 +values-prod-env.yaml,生产环境Helm values文件。 + + +在部署IAM应用时,我们在命令行指定-f参数,例如: + +$ helm -n iam install -f configs/values-test-env.yaml iam deployments/iam # 安装到测试环境。 + + +总结 + +这一讲,我们通过 helm create iam 创建了一个模板Chart,并基于这个模板Chart包进行了二次开发,最终创建了IAM应用的Helm Chart包:deployments/iam。 + +有了Helm Chart包,我们就可以通过 helm -n iam install iam deployments/iam 命令来一键部署好整个IAM应用。当IAM应用中的所有Pod都处在 Running 状态后,说明IAM应用被成功部署。 + +最后,我们可以登录iamctl容器,执行 test.sh iam::test::smoke 命令,来对IAM应用进行冒烟测试。 + +课后练习 + + +试着在Helm Chart中加入MariaDB、MongoDB、Redis模板,通过Helm一键部署好整个IAM应用。 +试着通过 helm 命令升级、回滚和删除IAM应用。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/51基于GitHubActions的CI实战.md b/专栏/Go语言项目开发实战/51基于GitHubActions的CI实战.md new file mode 100644 index 0000000..70df468 --- /dev/null +++ b/专栏/Go语言项目开发实战/51基于GitHubActions的CI实战.md @@ -0,0 +1,566 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 51 基于 GitHub Actions 的 CI 实战 + 你好,我是孔令飞。这是本专栏正文的最后一讲了,恭喜你坚持到了最后! + +在Go项目开发中,我们要频繁地执行静态代码检查、测试、编译、构建等操作。如果每一步我们都手动执行,效率低不说,还容易出错。所以,我们通常借助CI系统来自动化执行这些操作。 + +当前业界有很多优秀的CI系统可供选择,例如 CircleCI、TravisCI、Jenkins、CODING、GitHub Actions 等。这些系统在设计上大同小异,为了减少你的学习成本,我选择了相对来说容易实践的GitHub Actions,来给你展示如何通过CI来让工作自动化。 + +这一讲,我会先介绍下GitHub Actions及其用法,再向你展示一个CI示例,最后给你演示下IAM是如何构建CI任务的。 + +GitHub Actions的基本用法 + +GitHub Actions是GitHub为托管在github.com站点的项目提供的持续集成服务,于2018年10月推出。 + +GitHub Actions具有以下功能特性: + + +提供原子的actions配置和组合actions的workflow配置两种能力。 +全局配置基于YAML配置,兼容主流CI/CD工具配置。 +Actions/Workflows基于事件触发,包括Event restrictions、Webhook events、Scheduled events、External events。 +提供可供运行的托管容器服务,包括Docker、VM,可运行Linux、macOS、Windows主流系统。 +提供主流语言的支持,包括Node.js、Python、Java、Ruby、PHP、Go、Rust、.NET。 +提供实时日志流程,方便调试。 +提供平台内置的Actions与第三方提供的Actions,开箱即用。 + + +GitHub Actions的基本概念 + +在构建持续集成任务时,我们会在任务中心完成各种操作,比如克隆代码、编译代码、运行单元测试、构建和发布镜像等。GitHub把这些操作称为Actions。 + +Actions在很多项目中是可以共享的,GitHub允许开发者将这些可共享的Actions上传到GitHub的官方Actions市场,开发者在Actions市场中可以搜索到他人提交的 Actions。另外,还有一个 awesome actions 的仓库,里面也有不少的Action可供开发者使用。如果你需要某个 Action,不必自己写复杂的脚本,直接引用他人写好的 Action 即可。整个持续集成过程,就变成了一个 Actions 的组合。 + +Action其实是一个独立的脚本,可以将Action存放在GitHub代码仓库中,通过/的语法引用 Action。例如,actions/checkout@v2表示https://github.com/actions/checkout这个仓库,tag是v2。actions/checkout@v2也代表一个 Action,作用是安装 Go编译环境。GitHub 官方的 Actions 都放在 github.com/actions 里面。 + +GitHub Actions 有一些自己的术语,下面我来介绍下。 + + +workflow(工作流程):一个 .yml 文件对应一个 workflow,也就是一次持续集成。一个 GitHub 仓库可以包含多个 workflow,只要是在 .github/workflow 目录下的 .yml 文件都会被 GitHub 执行。 +job(任务):一个 workflow 由一个或多个 job 构成,每个 job 代表一个持续集成任务。 +step(步骤):每个 job 由多个 step 构成,一步步完成。 +action(动作):每个 step 可以依次执行一个或多个命令(action)。 +on:一个 workflow 的触发条件,决定了当前的 workflow 在什么时候被执行。 + + +workflow文件介绍 + +GitHub Actions 配置文件存放在代码仓库的.github/workflows目录下,文件后缀为.yml,支持创建多个文件,文件名可以任意取,比如iam.yml。GitHub 只要发现.github/workflows目录里面有.yml文件,就会自动运行该文件,如果运行过程中存在问题,会以邮件的形式通知到你。 + +workflow 文件的配置字段非常多,如果你想详细了解,可以查看官方文档。这里,我来介绍一些基本的配置字段。 + + +name + + +name字段是 workflow 的名称。如果省略该字段,默认为当前 workflow 的文件名。 + +name: GitHub Actions Demo + + + +on + + +on字段指定触发 workflow 的条件,通常是某些事件。 + +on: push + + +上面的配置意思是,push事件触发 workflow。on字段也可以是事件的数组,例如: + +on: [push, pull_request] + + +上面的配置意思是,push事件或pull_request事件都可以触发 workflow。 + +想了解完整的事件列表,你可以查看官方文档。除了代码库事件,GitHub Actions 也支持外部事件触发,或者定时运行。 + + +on.. + + +指定触发事件时,我们可以限定分支或标签。 + +on: + push: + branches: + - master + + +上面的配置指定,只有master分支发生push事件时,才会触发 workflow。 + + +jobs..name + + +workflow 文件的主体是jobs字段,表示要执行的一项或多项任务。 + +jobs字段里面,需要写出每一项任务的job_id,具体名称自定义。job_id里面的name字段是任务的说明。 + +jobs: + my_first_job: + name: My first job + my_second_job: + name: My second job + + +上面的代码中,jobs字段包含两项任务,job_id分别是my_first_job和my_second_job。 + + +jobs..needs + + +needs字段指定当前任务的依赖关系,即运行顺序。 + +jobs: + job1: + job2: + needs: job1 + job3: + needs: [job1, job2] + + +上面的代码中,job1必须先于job2完成,而job3等待job1和job2完成后才能运行。因此,这个 workflow 的运行顺序为:job1、job2、job3。 + + +jobs..runs-on + + +runs-on字段指定运行所需要的虚拟机环境,它是必填字段。目前可用的虚拟机如下: + + +ubuntu-latest、ubuntu-18.04或ubuntu-16.04。 +windows-latest、windows-2019或windows-2016。 +macOS-latest或macOS-10.14。 + + +下面的配置指定虚拟机环境为ubuntu-18.04。 + +runs-on: ubuntu-18.04 + + + +jobs..steps + + +steps字段指定每个 Job 的运行步骤,可以包含一个或多个步骤。每个步骤都可以指定下面三个字段。 + + +jobs..steps.name:步骤名称。 +jobs..steps.run:该步骤运行的命令或者 action。 +jobs..steps.env:该步骤所需的环境变量。 + + +下面是一个完整的 workflow 文件的范例: + +name: Greeting from Mona +on: push + +jobs: + my-job: + name: My Job + runs-on: ubuntu-latest + steps: + - name: Print a greeting + env: + MY_VAR: Hello! My name is + FIRST_NAME: Lingfei + LAST_NAME: Kong + run: | + echo $MY_VAR $FIRST_NAME $LAST_NAME. + + +上面的代码中,steps字段只包括一个步骤。该步骤先注入三个环境变量,然后执行一条 Bash 命令。 + + +uses + + +uses 可以引用别人已经创建的 actions,就是上面说的 actions 市场中的 actions。引用格式为userName/repoName@verison,例如uses: actions/setup-go@v1。 + + +with + + +with 指定actions的输入参数。每个输入参数都是一个键/值对。输入参数被设置为环境变量,该变量的前缀为 INPUT_,并转换为大写。 + +这里举个例子:我们定义 hello_world 操作所定义的三个输入参数(first_name、middle_name 和 last_name),这些输入变量将被 hello-world 操作作为 INPUT_FIRST_NAME、INPUT_MIDDLE_NAME 和 INPUT_LAST_NAME 环境变量使用。 + +jobs: + my_first_job: + steps: + - name: My first step + uses: actions/hello_world@master + with: + first_name: Lingfei + middle_name: Go + last_name: Kong + + + +run + + +run指定执行的命令。可以有多个命令,例如: + +- name: Build + run: | + go mod tidy + go build -v -o helloci . + + + +id + + +id是step的唯一标识。 + +GitHub Actions的进阶用法 + +上面,我介绍了GitHub Actions的一些基本知识,这里我再介绍下GitHub Actions的进阶用法。 + +为工作流加一个Badge + +在action的面板中,点击Create status badge就可以复制Badge的Markdown内容到README.md中。 + +之后,我们就可以直接在README.md中看到当前的构建结果: + + + +使用构建矩阵 + +如果我们想在多个系统或者多个语言版本上测试构建,就需要设置构建矩阵。例如,我们想在多个操作系统、多个Go版本下跑测试,可以使用如下workflow配置: + +name: Go Test + +on: [push, pull_request] + +jobs: + + helloci-build: + name: Test with go ${{ matrix.go_version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + matrix: + go_version: [1.15, 1.16] + os: [ubuntu-latest, macOS-latest] + + steps: + + - name: Set up Go ${{ matrix.go_version }} + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go_version }} + id: go + + +上面的workflow配置,通过strategy.matrix配置了该工作流程运行的环境矩阵(格式为go_version.os):ubuntu-latest.1.15、ubuntu-latest.1.16、macOS-latest.1.15、macOS-latest.1.16。也就是说,会在4台不同配置的服务器上执行该workflow。 + +使用Secrets + +在构建过程中,我们可能需要用到ssh或者token等敏感数据,而我们不希望这些数据直接暴露在仓库中,此时就可以使用secrets。 + +我们在对应项目中选择Settings-> Secrets,就可以创建secret,如下图所示: + + + +配置文件中的使用方法如下: + +name: Go Test +on: [push, pull_request] +jobs: + helloci-build: + name: Test with go + runs-on: [ubuntu-latest] + environment: + name: helloci + steps: + - name: use secrets + env: + super_secret: ${{ secrets.YourSecrets }} + + +secret name不区分大小写,所以如果新建secret的名字是name,使用时用 secrets.name 或者 secrets.Name 都是可以的。而且,就算此时直接使用 echo 打印 secret , 控制台也只会打印出*来保护secret。- +这里要注意,你的secret是属于某一个环境变量的,所以要指明环境的名字:environment.name。上面的workflow配置中的secrets.YourSecrets属于helloci环境。 + +使用Artifact保存构建产物 + +在构建过程中,我们可能需要输出一些构建产物,比如日志文件、测试结果等。这些产物可以使用Github Actions Artifact 来存储。你可以使用action/upload-artifact 和 download-artifact 进行构建参数的相关操作。 + +这里我以输出Jest测试报告为例来演示下如何保存Artifact产物。Jest测试后的测试产物是coverage: + +steps: + - run: npm ci + - run: npm test + + - name: Collect Test Coverage File + uses: actions/[email protected] + with: + name: coverage-output + path: coverage + + +执行成功后,我们就能在对应action面板看到生成的Artifact: + + + +GitHub Actions实战 + +上面,我介绍了GitHub Actions的用法,接下来我们就来实战下,看下使用GitHub Actions的6个具体步骤。 + +第一步,创建一个测试仓库。 + +登陆GitHub官网,点击New repository创建,如下图所示: + + + +这里,我们创建了一个叫helloci的测试项目。 + +第二步,将新的仓库 clone 下来,并添加一些文件: + +$ git clone https://github.com/marmotedu/helloci + + +你可以克隆marmotedu/helloci,并将里面的文件拷贝到你创建的项目仓库中。 + +第三步,创建GitHub Actions workflow配置目录: + +$ mkdir -p .github/workflows + + +第四步,创建GitHub Actions workflow配置。 + +在.github/workflows目录下新建helloci.yml文件,内容如下: + +name: Go Test + +on: [push, pull_request] + +jobs: + + helloci-build: + name: Test with go ${{ matrix.go_version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + environment: + name: helloci + + strategy: + matrix: + go_version: [1.16] + os: [ubuntu-latest] + + steps: + + - name: Set up Go ${{ matrix.go_version }} + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go_version }} + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Tidy + run: | + go mod tidy + + - name: Build + run: | + go build -v -o helloci . + + - name: Collect main.go file + uses: actions/[email protected] + with: + name: main-output + path: main.go + + - name: Publish to Registry + uses: elgohr/Publish-Docker-GitHub-Action@master + with: + name: ccr.ccs.tencentyun.com/marmotedu/helloci:beta # docker image 的名字 + username: ${{ secrets.DOCKER_USERNAME}} # 用户名 + password: ${{ secrets.DOCKER_PASSWORD }} # 密码 + registry: ccr.ccs.tencentyun.com # 腾讯云Registry + dockerfile: Dockerfile # 指定 Dockerfile 的位置 + tag_names: true # 是否将 release 的 tag 作为 docker image 的 tag + + +上面的workflow文件定义了当GitHub仓库有push、pull_request事件发生时,会触发GitHub Actions工作流程,流程中定义了一个任务(Job)helloci-build,Job中包含了多个步骤(Step),每个步骤又包含一些动作(Action)。 + +上面的workflow配置会按顺序执行下面的6个步骤。 + + +准备一个Go编译环境。 +从marmotedu/helloci下载源码。 +添加或删除缺失的依赖包。 +编译Go源码。 +上传构建产物。 +构建镜像,并将镜像push到ccr.ccs.tencentyun.com/marmotedu/helloci:beta。 + + +第五步,在push代码之前,我们需要先创建DOCKER_USERNAME和DOCKER_PASSWORD secret。 + +其中,DOCKER_USERNAME保存腾讯云镜像服务(CCR)的用户名,DOCKER_PASSWORD保存CCR的密码。我们将这两个secret保存在helloci Environments中,如下图所示: + + + +第六步,将项目push到GitHub,触发workflow工作流: + +$ git add . +$ git push origin master + + +打开我们的仓库 Actions 标签页,可以发现GitHub Actions workflow正在执行: + + + +等workflow执行完,点击 Go Test 进入构建详情页面,在详情页面能够看到我们的构建历史: + + + +然后,选择其中一个构建记录,查看其运行详情(具体可参考chore: update step name Go Test #10): + + + +你可以看到,Go Test工作流程执行了6个Job,每个Job执行了下面这些自定义Step: + + +Set up Go 1.16。 +Check out code into the Go module directory。 +Tidy。 +Build。 +Collect main.go file。 +Publish to Registry。 + + +其他步骤是GitHub Actions自己添加的步骤:Setup Job、Post Check out code into the Go module directory、Complete job。点击每一个步骤,你都能看到它们的详细输出。 + +IAM GitHub Actions实战 + +接下来,我们再来看下IAM项目的GitHub Actions实战。 + +假设IAM项目根目录为 ${IAM_ROOT},它的workflow配置文件为: + +$ cat ${IAM_ROOT}/.github/workflows/iamci.yaml +name: IamCI + +on: + push: + branchs: + - '*' + pull_request: + types: [opened, reopened] + +jobs: + + iamci: + name: Test with go ${{ matrix.go_version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + environment: + name: iamci + + strategy: + matrix: + go_version: [1.16] + os: [ubuntu-latest] + + steps: + + - name: Set up Go ${{ matrix.go_version }} + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go_version }} + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Run go modules Tidy + run: | + make tidy + + - name: Generate all necessary files, such as error code files + run: | + make gen + + - name: Check syntax and styling of go sources + run: | + make lint + + - name: Run unit test and get test coverage + run: | + make cover + + - name: Build source code for host platform + run: | + make build + + - name: Collect Test Coverage File + uses: actions/[email protected] + with: + name: main-output + path: _output/coverage.out + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build docker images for host arch and push images to registry + run: | + make push + + +上面的workflow依次执行了以下步骤: + + +设置Go编译环境。 +下载IAM项目源码。 +添加/删除不需要的Go包。 +生成所有的代码文件。 +对IAM源码进行静态代码检查。 +运行单元测试用例,并计算单元测试覆盖率是否达标。 +编译代码。 +收集构建产物_output/coverage.out。 +配置Docker构建环境。 +登陆DockerHub。 +构建Docker镜像,并push到DockerHub。 + + +IamCI workflow运行历史如下图所示: + + + +IamCI workflow的其中一次工作流程运行结果如下图所示: + + + +总结 + +在Go项目开发中,我们需要通过CI任务来将需要频繁操作的任务自动化,这不仅可以提高开发效率,还能减少手动操作带来的失误。这一讲,我选择了最易实践的GitHub Actions,来给你演示如何构建CI任务。 + +GitHub Actions支持通过push事件来触发CI流程。一个CI流程其实就是一个workflow,workflow中包含多个任务,这些任务是可以并行执行的。一个任务又包含多个步骤,每一步又由多个动作组成。动作(Action)其实是一个命令/脚本,用来完成我们指定的任务,如编译等。 + +因为GitHub Actions内容比较多,这一讲只介绍了一些核心的知识,更详细的GitHub Actions教程,你可以参考 官方中文文档。 + +课后练习 + + +使用CODING实现IAM的CI任务,并思考下:GitHub Actions和CODING在CI任务构建上,有没有本质的差异? +这一讲,我们借助GitHub Actions实现了CI,请你结合前面所学的知识,实现IAM的CD功能。欢迎提交Pull Request。 + + +这是我们这门课的最后一次练习题了,欢迎把你的思考和想法分享在留言区,也欢迎把课程分享给你的同事、朋友,我们一起交流,一起进步。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/特别放送GoModules依赖包管理全讲.md b/专栏/Go语言项目开发实战/特别放送GoModules依赖包管理全讲.md new file mode 100644 index 0000000..87c6619 --- /dev/null +++ b/专栏/Go语言项目开发实战/特别放送GoModules依赖包管理全讲.md @@ -0,0 +1,413 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 Go Modules依赖包管理全讲 + 你好,我是孔令飞。今天我们更新一期特别放送作为加餐。 + +在Go项目开发中,依赖包管理是一个非常重要的内容,依赖包处理不好,就会导致编译失败。而且Go的依赖包管理有一定的复杂度,所以,我们有必要系统学习下Go的依赖包管理工具。 + +这一讲,我会首先介绍下Go依赖包管理工具的历史,并详细介绍下目前官方推荐的依赖包管理方案Go Modules。Go Modules主要包括了 go mod 命令行工具、模块下载机制,以及两个核心文件go.mod和go.sum。另外,Go Modules也提供了一些环境变量,用来控制Go Modules的行为。这一讲,我会分别介绍下这些内容。 + +在正式开始讲解这些内容之前,我们先来对Go Modules有个基本的了解。 + +Go Modules简介 + +Go Modules是Go官方推出的一个Go包管理方案,基于vgo演进而来,具有下面这几个特性: + + +可以使包的管理更加简单。 +支持版本管理。 +允许同一个模块多个版本共存。 +可以校验依赖包的哈希值,确保包的一致性,增加安全性。 +内置在几乎所有的go命令中,包括go get、go build、go install、go run、go test、go list等命令。 +具有Global Caching特性,不同项目的相同模块版本,只会在服务器上缓存一份。 + + +在Go1.14版本以及之后的版本,Go官方建议在生产环境中使用Go Modules。因此,以后的Go包管理方案会逐渐统一到Go Modules。与Go Modules相关的概念很多,我在这里把它们总结为“6-2-2-1-1”,这一讲后面还会详细介绍每个概念。 + + +六个环境变量:GO111MODULE、GOPROXY、GONOPROXY、GOSUMDB、GONOSUMDB、GOPRIVATE。 +两个概念:Go module proxy和Go checksum database。 +两个主要文件:go.mod和go.sum。 +一个主要管理命令:go mod。 +一个build flag。 + + +Go包管理的历史 + +在具体讲解Go Modules之前,我们先看一下Go包管理的历史。从Go推出之后,因为没有一个统一的官方方案,所以出现了很多种Go包管理方案,比较混乱,也没有彻底解决Go包管理的一些问题。Go包管理的历史如下图所示: + + + +这张图展示了Go依赖包管理工具经历的几个发展阶段,接下来我会按时间顺序重点介绍下其中的五个阶段。 + +Go1.5版本前:GOPATH + +在Go1.5版本之前,没有版本控制,所有的依赖包都放在GOPATH下。采用这种方式,无法实现包的多版本管理,并且包的位置只能局限在GOPATH目录下。如果A项目和B项目用到了同一个Go包的不同版本,这时候只能给每个项目设置一个GOPATH,将对应版本的包放在各自的GOPATH目录下,切换项目目录时也需要切换GOPATH。这些都增加了开发和实现的复杂度。 + +Go1.5版本:Vendoring + +Go1.5推出了vendor机制,并在Go1.6中默认启用。在这个机制中,每个项目的根目录都可以有一个vendor目录,里面存放了该项目的Go依赖包。在编译Go源码时,Go优先从项目根目录的vendor目录查找依赖;如果没有找到,再去GOPATH下的vendor目录下找;如果还没有找到,就去GOPATH下找。 + +这种方式解决了多GOPATH的问题,但是随着项目依赖的增多,vendor目录会越来越大,造成整个项目仓库越来越大。在vendor机制下,一个中型项目的vendor目录有几百M的大小一点也不奇怪。 + +“百花齐放”:多种Go依赖包管理工具出现 + +这个阶段,社区也出现了很多Go依赖包管理的工具,这里我介绍三个比较有名的。 + + +Godep:解决包依赖的管理工具,Docker、Kubernetes、CoreOS等Go项目都曾用过godep来管理其依赖。 +Govendor:它的功能比Godep多一些,通过vendor目录下的vendor.json文件来记录依赖包的版本。 +Glide:相对完善的包管理工具,通过glide.yaml记录依赖信息,通过glide.lock追踪每个包的具体修改。 + + +Govendor、Glide都是在Go支持vendor之后推出的工具,Godep在Go支持vendor之前也可以使用。Go支持vendor之后,Godep也改用了vendor模式。 + +Go1.9版本:Dep + +对于从0构建项目的新用户来说,Glide功能足够,是个不错的选择。不过,Golang 依赖管理工具混乱的局面最终由官方来终结了:Golang官方接纳了由社区组织合作开发的Dep,作为official experiment。在相当长的一段时间里,Dep作为标准,成为了事实上的官方包管理工具。 + +因为Dep已经成为了official experiment的过去时,现在我们就不必再去深究了,让我们直接去了解谁才是未来的official experiment吧。 + +Go1.11版本之后:Go Modules + +Go1.11版本推出了Go Modules机制,Go Modules基于vgo演变而来,是Golang官方的包管理工具。在Go1.13版本,Go语言将Go Modules设置为默认的Go管理工具;在Go1.14版本,Go语言官方正式推荐在生产环境使用Go Modules,并且鼓励所有用户从其他的依赖管理工具迁移过来。至此,Go终于有了一个稳定的、官方的Go包管理工具。 + +到这里,我介绍了Go依赖包管理工具的历史,下面再来介绍下Go Modules的使用方法。 + +包(package)和模块(module) + +Go程序被组织到Go包中,Go包是同一目录中一起编译的Go源文件的集合。在一个源文件中定义的函数、类型、变量和常量,对于同一包中的所有其他源文件可见。 + +模块是存储在文件树中的Go包的集合,并且文件树根目录有go.mod文件。go.mod文件定义了模块的名称及其依赖包,每个依赖包都需要指定导入路径和语义化版本(Semantic Versioning),通过导入路径和语义化版本准确地描述一个依赖。 + +这里要注意,"module" != "package",模块和包的关系更像是集合和元素的关系,包属于模块,一个模块是零个或者多个包的集合。下面的代码段,引用了一些包: + +import ( + // Go 标准包 + "fmt" + + // 第三方包 + "github.com/spf13/pflag" + + // 匿名包 + _ "github.com/jinzhu/gorm/dialects/mysql" + + // 内部包 + "github.com/marmotedu/iam/internal/apiserver" +) + + +这里的fmt、github.com/spf13/pflag和github.com/marmotedu/iam/internal/apiserver都是Go包。Go中有4种类型的包,下面我来分别介绍下。 + + +Go标准包:在Go源码目录下,随Go一起发布的包。 +第三方包:第三方提供的包,比如来自于github.com的包。 +匿名包:只导入而不使用的包。通常情况下,我们只是想使用导入包产生的副作用,即引用包级别的变量、常量、结构体、接口等,以及执行导入包的init()函数。 +内部包:项目内部的包,位于项目目录下。 + + +下面的目录定义了一个模块: + +$ ls hello/ +go.mod go.sum hello.go hello_test.go world + + +hello目录下有一个go.mod文件,说明了这是一个模块,该模块包含了hello包和一个子包world。该目录中也包含了一个go.sum文件,该文件供Go命令在构建时判断依赖包是否合法。这里你先简单了解下,我会在下面讲go.sum文件的时候详细介绍。 + +Go Modules 命令 + +Go Modules的管理命令为go mod,go mod有很多子命令,你可以通过go help mod来获取所有的命令。下面我来具体介绍下这些命令。 + + +download:下载go.mod文件中记录的所有依赖包。 +edit:编辑go.mod文件。 +graph:查看现有的依赖结构。 +init:把当前目录初始化为一个新模块。 +tidy:添加丢失的模块,并移除无用的模块。默认情况下,Go不会移除go.mod文件中的无用依赖。当依赖包不再使用了,可以使用go mod tidy命令来清除它。 +vendor:将所有依赖包存到当前目录下的vendor目录下。 +verify:检查当前模块的依赖是否已经存储在本地下载的源代码缓存中,以及检查下载后是否有修改。 +why:查看为什么需要依赖某模块。 + + +Go Modules开关 + +如果要使用Go Modules,在Go1.14中仍然需要确保Go Modules特性处在打开状态。你可以通过环境变量GO111MODULE来打开或者关闭。GO111MODULE有3个值,我来分别介绍下。 + + +auto:在Go1.14版本中是默认值,在$GOPATH/src下,且没有包含go.mod时则关闭Go Modules,其他情况下都开启Go Modules。 +on:启用Go Modules,Go1.14版本推荐打开,未来版本会设为默认值。 +off:关闭Go Modules,不推荐。 + + +所以,如果要打开Go Modules,可以设置环境变量export GO111MODULE=on或者export GO111MODULE=auto,建议直接设置export GO111MODULE=on。 + +Go Modules使用语义化的版本号,我们开发的模块在发布版本打tag的时候,要注意遵循语义化的版本要求,不遵循语义化版本规范的版本号都是无法拉取的。 + +模块下载 + +在执行 go get 等命令时,会自动下载模块。接下来,我会介绍下go命令是如何下载模块的。主要有三种下载方式: + + +通过代理下载; +指定版本号下载; +按最小版本下载。 + + +通过代理来下载模块 + +默认情况下,Go命令从VCS(Version Control System,版本控制系统)直接下载模块,例如 GitHub、Bitbucket、Bazaar、Mercurial或者SVN。 + +在Go 1.13版本,引入了一个新的环境变量GOPROXY,用于设置Go模块代理(Go module proxy)。模块代理可以使Go命令直接从代理服务器下载模块。GOPROXY默认值为https://proxy.golang.org,direct,代理服务器可以指定多个,中间用逗号隔开,例如GOPROXY=https://proxy.golang.org,https://goproxy.cn,direct。当下载模块时,会优先从指定的代理服务器上下载。如果下载失败,比如代理服务器不可访问,或者HTTP返回码为404或410,Go命令会尝试从下一个代理服务器下载。 + +direct是一个特殊指示符,用来指示Go回源到模块的源地址(比如GitHub等)去抓取 ,当值列表中上一个Go module proxy返回404或410,Go会自动尝试列表中的下一个,遇见direct时回源,遇见EOF时终止,并抛出类似invalid version: unknown revision...的错误。 如果GOPROXY=off,则Go命令不会尝试从代理服务器下载模块。 + +引入Go module proxy会带来很多好处,比如: + + +国内开发者无法访问像golang.org、gopkg.in、go.uber.org这类域名,可以设置GOPROXY为国内可以访问的代理服务器,解决依赖包下载失败的问题。 +Go模块代理会永久缓存和存储所有的依赖,并且这些依赖一经缓存,不可更改,这也意味着我们不需要再维护一个vendor目录,也可以避免因为维护vendor目录所带来的存储空间占用。 +因为依赖永久存在于代理服务器,这样即使模块从互联网上被删除,也仍然可以通过代理服务器获取到。 +一旦将Go模块存储在Go代理服务器中,就无法覆盖或删除它,这可以保护开发者免受可能注入相同版本恶意代码所带来的攻击。 +我们不再需要VCS工具来下载依赖,因为所有的依赖都是通过HTTP的方式从代理服务器下载。 +因为Go代理通过HTTP独立提供了源代码(.zip存档)和go.mod,所以下载和构建Go模块的速度更快。因为可以独立获取go.mod(而之前必须获取整个仓库),所以解决依赖也更快。 +当然,开发者也可以设置自己的Go模块代理,这样开发者可以对依赖包有更多的控制,并可以预防VCS停机所带来的下载失败。 + + +在实际开发中,我们的很多模块可能需要从私有仓库拉取,通过代理服务器访问会报错,这时候我们需要将这些模块添加到环境变量GONOPROXY中,这些私有模块的哈希值也不会在checksum database中存在,需要将这些模块添加到GONOSUMDB中。一般来说,我建议直接设置GOPRIVATE环境变量,它的值将作为GONOPROXY和GONOSUMDB的默认值。 + +GONOPROXY、GONOSUMDB和GOPRIVATE都支持通配符,多个域名用逗号隔开,例如*.example.com,github.com。 + +对于国内的Go开发者来说,目前有3个常用的GOPROXY可供选择,分别是官方、七牛和阿里云。 + +官方的GOPROXY,国内用户可能访问不到,所以我更推荐使用七牛的goproxy.cn,goproxy.cn是七牛云推出的非营利性项目,它的目标是为中国和世界上其他地方的Go开发者提供一个免费、可靠、持续在线,且经过 CDN 加速的模块代理。 + +指定版本号下载 + +通常,我们通过go get来下载模块,下载命令格式为go get ,如下表所示: + + + +你可以使用go get -u更新package到latest版本,也可以使用go get -u=patch只更新小版本,例如从v1.2.4到v1.2.5。 + +按最小版本下载 + +一个模块往往会依赖许多其他模块,并且不同的模块也可能会依赖同一个模块的不同版本,如下图所示: + + + +在上述依赖中,模块A依赖了模块B和模块C,模块B依赖了模块D,模块C依赖了模块D和模块F,模块D又依赖了模块E。并且,同模块的不同版本还依赖了对应模块的不同版本。 + +那么Go Modules是如何选择版本的呢?Go Modules 会把每个模块的依赖版本清单都整理出来,最终得到一个构建清单,如下图所示: + + + +上图中,rough list和final list的区别在于重复引用的模块 D(v1.3、v1.4),最终清单选用了D的v1.4版本。 + +这样做的主要原因有两个。第一个是语义化版本的控制。因为模块D的v1.3和v1.4版本变更都属于次版本号的变更,而在语义化版本的约束下,v1.4必须要向下兼容v1.3,因此我们要选择高版本的v1.4。 + +第二个是模块导入路径的规范。主版本号不同,模块的导入路径就不一样。所以,如果出现不兼容的情况,主版本号会改变,例如从v1变为v2,模块的导入路径也就改变了,因此不会影响v1版本。 + +go.mod和go.sum介绍 + +在Go Modules中,go.mod和go.sum是两个非常重要的文件,下面我就来详细介绍这两个文件。 + +go.mod文件介绍 + +go.mod文件是Go Modules的核心文件。下面是一个go.mod文件示例: + +module github.com/marmotedu/iam + +go 1.14 + +require ( + github.com/AlekSi/pointer v1.1.0 + github.com/appleboy/gin-jwt/v2 v2.6.3 + github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 + github.com/gin-gonic/gin v1.6.3 + github.com/golangci/golangci-lint v1.30.0 // indirect + github.com/google/uuid v1.0.0 + github.com/blang/semver v3.5.0+incompatible + golang.org/x/text v0.3.2 +) + +replace ( + github.com/gin-gonic/gin => /home/colin/gin + golang.org/x/text v0.3.2 => github.com/golang/text v0.3.2 +) + +exclude ( + github.com/google/uuid v1.1.0 +) + + +接下来,我会从go.mod语句、go.mod版本号、go.mod文件修改方法三个方面来介绍go.mod。 + + +go.mod语句 + + +go.mod文件中包含了4个语句,分别是module、require、replace 和 exclude。下面我来介绍下它们的功能。 + + +module:用来定义当前项目的模块路径。 +go:用来设置预期的Go版本,目前只是起标识作用。 +require:用来设置一个特定的模块版本,格式为<导入包路径> <版本> [// indirect]。 +exclude:用来从使用中排除一个特定的模块版本,如果我们知道模块的某个版本有严重的问题,就可以使用exclude将该版本排除掉。 +replace:用来将一个模块版本替换为另外一个模块版本。格式为 $module => $newmodule ,$newmodule可以是本地磁盘的相对路径,例如github.com/gin-gonic/gin => ./gin。也可以是本地磁盘的绝对路径,例如github.com/gin-gonic/gin => /home/lk/gin。还可以是网络路径,例如golang.org/x/text v0.3.2 => github.com/golang/text v0.3.2。 + + +这里需要注意,虽然我们用$newmodule替换了$module,但是在代码中的导入路径仍然为$module。replace在实际开发中经常用到,下面的场景可能需要用到replace: + + +在开启Go Modules后,缓存的依赖包是只读的,但在日常开发调试中,我们可能需要修改依赖包的代码来进行调试,这时可以将依赖包另存到一个新的位置,并在go.mod中替换这个包。 +如果一些依赖包在Go命令运行时无法下载,就可以通过其他途径下载该依赖包,上传到开发构建机,并在go.mod中替换为这个包。 +在项目开发初期,A项目依赖B项目的包,但B项目因为种种原因没有push到仓库,这时也可以在go.mod中把依赖包替换为B项目的本地磁盘路径。 +在国内访问golang.org/x的各个包都需要翻墙,可以在go.mod中使用replace,替换成GitHub上对应的库,例如golang.org/x/text v0.3.0 => github.com/golang/text v0.3.0。 + + +有一点要注意,exclude和replace只作用于当前主模块,不影响主模块所依赖的其他模块。 + + +go.mod版本号 + + +go.mod文件中有很多版本号格式,我知道在平时使用中,有很多开发者对此感到困惑。这里,我来详细说明一下。 + + +如果模块具有符合语义化版本格式的tag,会直接展示tag的值,例如 github.com/AlekSi/pointer v1.1.0 。 +除了v0和v1外,主版本号必须显试地出现在模块路径的尾部,例如github.com/appleboy/gin-jwt/v2 v2.6.3。 +对于没有tag的模块,Go命令会选择master分支上最新的commit,并根据commit时间和哈希值生成一个符合语义化版本的版本号,例如github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535。 +如果模块名字跟版本不符合规范,例如模块的名字为github.com/blang/semver,但是版本为 v3.5.0(正常应该是github.com/blang/semver/v3),go会在go.mod的版本号后加+incompatible表示。 +如果go.mod中的包是间接依赖,则会添加// indirect注释,例如github.com/golangci/golangci-lint v1.30.0 // indirect。 + + +这里要注意,Go Modules要求模块的版本号格式为v..,如果版本号大于1,它的版本号还要体现在模块名字中,例如模块github.com/blang/semver版本号增长到v3.x.x,则模块名应为github.com/blang/semver/v3。 + +这里再详细介绍下出现// indirect的情况。原则上go.mod中出现的都是直接依赖,但是下面的两种情况只要出现一种,就会在go.mod中添加间接依赖。 + + +直接依赖未启用Go Modules:如果模块A依赖模块B,模块B依赖B1和B2,但是B没有go.mod文件,则B1和B2会记录到A的go.mod文件中,并在最后加上// indirect。 +直接依赖go.mod文件中缺失部分依赖:如果模块A依赖模块B,模块B依赖B1和B2,B有go.mod文件,但是只有B1被记录在B的go.mod文件中,这时候B2会被记录到A的go.mod文件中,并在最后加上// indirect。 + + + +go.mod文件修改方法 + + +要修改go.mod文件,我们可以采用下面这三种方法: + + +Go命令在运行时自动修改。 +手动编辑go.mod文件,编辑之后可以执行go mod edit -fmt格式化go.mod文件。 +执行go mod子命令修改。 + + +在实际使用中,我建议你采用第三种修改方法,和其他两种相比不太容易出错。使用方式如下: + +go mod edit -fmt # go.mod 格式化 +go mod edit -require=golang.org/x/[email protected] # 添加一个依赖 +go mod edit -droprequire=golang.org/x/text # require的反向操作,移除一个依赖 +go mod edit -replace=github.com/gin-gonic/gin=/home/colin/gin # 替换模块版本 +go mod edit -dropreplace=github.com/gin-gonic/gin # replace的反向操作 +go mod edit -exclude=golang.org/x/[email protected] # 排除一个特定的模块版本 +go mod edit -dropexclude=golang.org/x/[email protected] # exclude的反向操作 + + +go.sum文件介绍 + +Go会根据go.mod文件中记载的依赖包及其版本下载包源码,但是下载的包可能被篡改,缓存在本地的包也可能被篡改。单单一个go.mod文件,不能保证包的一致性。为了解决这个潜在的安全问题,Go Modules引入了go.sum文件。 + +go.sum文件用来记录每个依赖包的hash值,在构建时,如果本地的依赖包hash值与go.sum文件中记录的不一致,则会拒绝构建。go.sum中记录的依赖包是所有的依赖包,包括间接和直接的依赖包。 + +这里提示下,为了避免已缓存的模块被更改,$GOPATH/pkg/mod下缓存的包是只读的,不允许修改。 + +接下来我从go.sum文件内容、go.sum文件生成、校验三个方面来介绍go.sum。 + + +go.sum文件内容 + + +下面是一个go.sum文件的内容: + +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qgOY6WgZOaTkIIMiVjBQcw93ERBE4m30iBm00nkL0i8= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y= +rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0= +rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= + + +go.sum文件中,每行记录由模块名、版本、哈希算法和哈希值组成,如 [/go.mod] :。目前,从Go1.11到Go1.14版本,只有一个算法SHA-256,用h1表示。 + +正常情况下,每个依赖包会包含两条记录,分别是依赖包所有文件的哈希值和该依赖包go.mod的哈希值,例如: + +rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y= +rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0= + + +但是,如果一个依赖包没有go.mod文件,就只记录依赖包所有文件的哈希值,也就是只有第一条记录。额外记录go.mod的哈希值,主要是为了在计算依赖树时不必下载完整的依赖包版本,只根据go.mod即可计算依赖树。 + + +go.sum文件生成 + + +在Go Modules开启时,如果我们的项目需要引入一个新的包,通常会执行go get命令,例如: + +$ go get rsc.io/quote + + +当执行go get rsc.io/quote命令后,go get命令会先将依赖包下载到$GOPATH/pkg/mod/cache/download,下载的依赖包文件名格式为$version.zip,例如v1.5.2.zip。 + +下载完成之后,go get会对该zip包做哈希运算,并将结果存在$version.ziphash文件中,例如v1.5.2.ziphash。如果在项目根目录下执行go get命令,则go get会同时更新go.mod和go.sum文件。例如,go.mod新增一行require rsc.io/quote v1.5.2,go.sum新增两行: + +rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y= +rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0= + + + +校验 + + +在我们执行构建时,go命令会从本地缓存中查找所有的依赖包,并计算这些依赖包的哈希值,然后与go.sum中记录的哈希值进行对比。如果哈希值不一致,则校验失败,停止构建。 + +校验失败可能是因为本地指定版本的依赖包被修改过,也可能是go.sum中记录的哈希值是错误的。但是Go命令倾向于相信依赖包被修改过,因为当我们在go get依赖包时,包的哈希值会经过校验和数据库(checksum database)进行校验,校验通过才会被加入到go.sum文件中。也就是说,go.sum文件中记录的哈希值是可信的。 + +校验和数据库可以通过环境变量GOSUMDB指定,GOSUMDB的值是一个web服务器,默认值是sum.golang.org。该服务可以用来查询依赖包指定版本的哈希值,保证拉取到的模块版本数据没有经过篡改。 + +如果设置GOSUMDB为off,或者使用go get的时候启用了-insecure参数,Go就不会去对下载的依赖包做安全校验,这存在一定的安全隐患,所以我建议你开启校验和数据库。如果对安全性要求很高,同时又访问不了sum.golang.org,你也可以搭建自己的校验和数据库。 + +值得注意的是,Go checksum database可以被Go module proxy代理,所以当我们设置了GOPROXY后,通常情况下不用再设置GOSUMDB。还要注意的是,go.sum文件也应该提交到你的 Git 仓库中去。 + +模块下载流程 + +上面,我介绍了模块下载的整体流程,还介绍了go.mod和go.sum这两个文件。因为内容比较多,这里用一张图片来做个总结: + + + +最后还想介绍下Go modules的全局缓存。Go modules中,相同版本的模块只会缓存一份,其他所有模块公用。目前,所有模块版本数据都缓存在 $GOPATH/pkg/mod 和 $GOPATH/pkg/sum 下,未来有可能移到 $GOCACHE/mod 和 $GOCACHE/sum 下,我认为这可能发生在 GOPATH 被淘汰后。你可以使用 go clean -modcache 清除所有的缓存。 + +总结 + +Go依赖包管理是Go语言中一个重点的功能。在Go1.11版本之前,并没有官方的依赖包管理工具,业界虽然存在多个Go依赖包管理方案,但效果都不理想。直到Go1.11版本,Go才推出了官方的依赖包管理工具,Go Modules。这也是我建议你在进行Go项目开发时选择的依赖包管理工具。 + +Go Modules提供了 go mod 命令,来管理Go的依赖包。 go mod 有很多子命令,这些子命令可以完成不同的功能。例如,初始化当前目录为一个新模块,添加丢失的模块,移除无用的模块,等等。 + +在Go Modules中,有两个非常重要的文件:go.mod和go.sum。go.mod文件是Go Modules的核心文件,Go会根据go.mod文件中记载的依赖包及其版本下载包源码。go.sum文件用来记录每个依赖包的hash值,在构建时,如果本地的依赖包hash值与go.sum文件中记录的不一致,就会拒绝构建。 + +Go在下载依赖包时,可以通过代理来下载,也可以指定版本号下载。如果不指定版本号,Go Modules会根据自定义的规则,选择最小版本来下载。 + +课后练习 + + +思考下,如果不提交go.sum,会有什么风险? +找一个没有使用Go Modules管理依赖包的Go项目,把它的依赖包管理方式切换为Go Modules。 + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/特别放送GoModules实战.md b/专栏/Go语言项目开发实战/特别放送GoModules实战.md new file mode 100644 index 0000000..cdb2b3f --- /dev/null +++ b/专栏/Go语言项目开发实战/特别放送GoModules实战.md @@ -0,0 +1,403 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 Go Modules实战 + 你好,我是孔令飞。 + +今天我们更新一期特别放送作为加餐。在 特别放送 | Go Modules依赖包管理全讲中,我介绍了Go Modules的知识,里面内容比较多,你可能还不知道具体怎么使用Go Modules来为你的项目管理Go依赖包。 + +这一讲,我就通过一个具体的案例,带你一步步学习Go Modules的常见用法以及操作方法,具体包含以下内容: + + +准备一个演示项目。 +配置Go Modules。 +初始化Go包为Go模块。 +Go包依赖管理。 + + +准备一个演示项目 + +为了演示Go Modules的用法,我们首先需要一个Demo项目。假设我们有一个hello的项目,里面有两个文件,分别是hello.go和hello_test.go,所在目录为/home/lk/workspace/golang/src/github.com/marmotedu/gopractise-demo/modules/hello。 + +hello.go文件内容为: + +package hello + +func Hello() string { + return "Hello, world." +} + + +hello_test.go文件内容为: + +package hello + +import "testing" + +func TestHello(t *testing.T) { + want := "Hello, world." + if got := Hello(); got != want { + t.Errorf("Hello() = %q, want %q", got, want) + } +} + + +这时候,该目录包含了一个Go包,但还不是Go模块,因为没有go.mod件。接下来,我就给你演示下,如何将这个包变成一个Go模块,并执行Go依赖包的管理操作。这些操作共有10个步骤,下面我们来一步步看下。 + +配置Go Modules + + +打开Go Modules + + +确保Go版本>=go1.11,并开启Go Modules,可以通过设置环境变量export GO111MODULE=on开启。如果你觉得每次都设置比较繁琐,可以将export GO111MODULE=on追加到文件$HOME/.bashrc中,并执行 bash 命令加载到当前shell环境中。 + + +设置环境变量 + + +对于国内的开发者来说,需要设置export GOPROXY=https://goproxy.cn,direct,这样一些被墙的包可以通过国内的镜像源安装。如果我们有一些模块存放在私有仓库中,也需要设置GOPRIVATE环境变量。 + +因为Go Modules会请求Go Checksum Database,Checksum Database国内也可能会访问失败,可以设置export GOSUMDB=off来关闭Checksum校验。对于一些模块,如果你希望不通过代理服务器,或者不校验checksum,也可以根据需要设置GONOPROXY和GONOSUMDB。 + +初始化Go包为Go模块 + + +创建一个新模块 + + +你可以通过go mod init命令,初始化项目为Go Modules。 init 命令会在当前目录初始化并创建一个新的go.mod文件,也代表着创建了一个以项目根目录为根的Go Modules。如果当前目录已经存在go.mod文件,则会初始化失败。 + +在初始化Go Modules时,需要告知go mod init要初始化的模块名,可以指定模块名,例如go mod init github.com/marmotedu/gopractise-demo/modules/hello。也可以不指定模块名,让init自己推导。下面我来介绍下推导规则。 + + +如果有导入路径注释,则使用注释作为模块名,比如: + + +package hello // import "github.com/marmotedu/gopractise-demo/modules/hello" + + +则模块名为github.com/marmotedu/gopractise-demo/modules/hello。 + + +如果没有导入路径注释,并且项目位于GOPATH路径下,则模块名为绝对路径去掉$GOPATH/src后的路径名,例如GOPATH=/home/lk/workspace/golang,项目绝对路径为/home/colin/workspace/golang/src/github.com/marmotedu/gopractise-demo/modules/hello,则模块名为github.com/marmotedu/gopractise-demo/modules/hello。 + + +初始化完成之后,会在当前目录生成一个go.mod文件: + +$ cat go.mod +module github.com/marmotedu/gopractise-demo/modules/hello + +go 1.14 + + +文件内容表明,当前模块的导入路径为github.com/marmotedu/gopractise-demo/modules/hello,使用的Go版本是go 1.14。 + +如果要新增子目录创建新的package,则package的导入路径自动为 模块名/子目录名 :github.com/marmotedu/gopractise-demo/modules/hello/,不需要在子目录中再次执行go mod init。 + +比如,我们在hello目录下又创建了一个world包world/world.go,则world包的导入路径为github.com/marmotedu/gopractise-demo/modules/hello/world。 + +Go包依赖管理 + + +增加一个依赖 + + +Go Modules主要是用来对包依赖进行管理的,所以这里我们来给hello包增加一个依赖rsc.io/quote: + +package hello + +import "rsc.io/quote" + +func Hello() string { + return quote.Hello() +} + + +运行go test: + +$ go test +go: finding module for package rsc.io/quote +go: downloading rsc.io/quote v1.5.2 +go: found rsc.io/quote in rsc.io/quote v1.5.2 +go: downloading rsc.io/sampler v1.3.0 +PASS +ok github.com/google/addlicense/golang/src/github.com/marmotedu/gopractise-demo/modules/hello 0.003s + + +当go命令在解析源码时,遇到需要导入一个模块的情况,就会去go.mod文件中查询该模块的版本,如果有指定版本,就导入指定的版本。 + +如果没有查询到该模块,go命令会自动根据模块的导入路径安装模块,并将模块和其最新的版本写入go.mod文件中。在我们的示例中,go test将模块rsc.io/quote解析为rsc.io/quote v1.5.2,并且同时还下载了rsc.io/quote模块的两个依赖模块:rsc.io/quote和rsc.io/sampler。只有直接依赖才会被记录到go.mod文件中。 + +查看go.mod文件: + +module github.com/marmotedu/gopractise-demo/modules/hello + +go 1.14 + +require rsc.io/quote v1.5.2 + + +再次执行go test: + +$ go test +PASS +ok github.com/marmotedu/gopractise-demo/modules/hello 0.003s + + +当我们再次执行go test时,不会再下载并记录需要的模块,因为go.mod目前是最新的,并且需要的模块已经缓存到了本地的$GOPATH/pkg/mod目录下。可以看到,在当前目录还新生成了一个go.sum文件: + +$ cat go.sum +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qgOY6WgZOaTkIIMiVjBQcw93ERBE4m30iBm00nkL0i8= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y= +rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0= +rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= + + +go test在执行时,还可以添加-mod选项,比如go test -mod=vendor。-mod有3个值,我来分别介绍下。 + + +readonly:不更新go.mod,任何可能会导致go.mod变更的操作都会失败。通常用来检查go.mod文件是否需要更新,例如用在CI或者测试场景。 +vendor:从项目顶层目录下的vendor中导入包,而不是从模块缓存中导入包,需要确保vendor包完整准确。 +mod:从模块缓存中导入包,即使项目根目录下有vendor目录。 + + +如果go test执行时没有-mod选项,并且项目根目录存在vendor目录,go.mod中记录的go版本大于等于1.14,此时go test执行效果等效于go test -mod=vendor。-mod标志同样适用于go build、go install、go run、go test、go list、go vet命令。 + + +查看所有依赖模块 + + +我们可以通过go list -m all命令查看所有依赖模块: + +$ go list -m all +github.com/marmotedu/gopractise-demo/modules/hello +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c +rsc.io/quote v1.5.2 +rsc.io/sampler v1.3.0 + + +可以看出,除了rsc.io/quote v1.5.2外,还间接依赖了其他模块。 + + +更新依赖 + + +通过go list -m all,我们可以看到模块依赖的golang.org/x/text模块版本是v0.0.0,我们可以通过go get命令,将其更新到最新版本,并观察测试是否通过: + +$ go get golang.org/x/text +go: golang.org/x/text upgrade => v0.3.3 +$ go test +PASS +ok github.com/marmotedu/gopractise-demo/modules/hello 0.003s + + +go test命令执行后输出PASS说明升级成功,再次看下go list -m all和go.mod文件: + +$ go list -m all +github.com/marmotedu/gopractise-demo/modules/hello +golang.org/x/text v0.3.3 +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e +rsc.io/quote v1.5.2 +rsc.io/sampler v1.3.0 +$ cat go.mod +module github.com/marmotedu/gopractise-demo/modules/hello + +go 1.14 + +require ( + golang.org/x/text v0.3.3 // indirect + rsc.io/quote v1.5.2 +) + + +可以看到,golang.org/x/text包被更新到最新的tag版本(v0.3.3),并且同时更新了go.mod文件。// indirect说明golang.org/x/text是间接依赖。现在我们再尝试更新rsc.io/sampler并测试: + +$ go get rsc.io/sampler +go: rsc.io/sampler upgrade => v1.99.99 +go: downloading rsc.io/sampler v1.99.99 +$ go test +--- FAIL: TestHello (0.00s) + hello_test.go:8: Hello() = "99 bottles of beer on the wall, 99 bottles of beer, ...", want "Hello, world." +FAIL +exit status 1 +FAIL github.com/marmotedu/gopractise-demo/modules/hello 0.004s + + +测试失败,说明最新的版本v1.99.99与我们当前的模块不兼容,我们可以列出rsc.io/sampler所有可用的版本,并尝试更新到其他版本: + +$ go list -m -versions rsc.io/sampler +rsc.io/sampler v1.0.0 v1.2.0 v1.2.1 v1.3.0 v1.3.1 v1.99.99 + +# 我们尝试选择一个次新的版本v1.3.1 +$ go get rsc.io/[email protected] +go: downloading rsc.io/sampler v1.3.1 +$ go test +PASS +ok github.com/marmotedu/gopractise-demo/modules/hello 0.004s + + +可以看到,更新到v1.3.1版本,测试是通过的。go get还支持多种参数,如下表所示: + + + + +添加一个新的major版本依赖 + + +我们尝试添加一个新的函数func Proverb,该函数通过调用rsc.io/quote/v3的quote.Concurrency函数实现。 + +首先,我们在hello.go文件中添加新函数: + +package hello + +import ( + "rsc.io/quote" + quoteV3 "rsc.io/quote/v3" +) + +func Hello() string { + return quote.Hello() +} + +func Proverb() string { + return quoteV3.Concurrency() +} + + +在hello_test.go中添加该函数的测试用例: + +func TestProverb(t *testing.T) { + want := "Concurrency is not parallelism." + if got := Proverb(); got != want { + t.Errorf("Proverb() = %q, want %q", got, want) + } +} + + +然后执行测试: + +$ go test +go: finding module for package rsc.io/quote/v3 +go: found rsc.io/quote/v3 in rsc.io/quote/v3 v3.1.0 +PASS +ok github.com/marmotedu/gopractise-demo/modules/hello 0.003s + + +测试通过,可以看到当前模块同时依赖了同一个模块的不同版本rsc.io/quote和rsc.io/quote/v3: + +$ go list -m rsc.io/q... +rsc.io/quote v1.5.2 +rsc.io/quote/v3 v3.1.0 + + + +升级到不兼容的版本 + + +在上一步中,我们使用rsc.io/quote v1版本的Hello()函数。按照语义化版本规则,如果我们想升级major版本,可能面临接口不兼容的问题,需要我们变更代码。我们来看下rsc.io/quote/v3的函数: + +$ go doc rsc.io/quote/v3 +package quote // import "github.com/google/addlicense/golang/pkg/mod/rsc.io/quote/[email protected]" + +Package quote collects pithy sayings. + +func Concurrency() string +func GlassV3() string +func GoV3() string +func HelloV3() string +func OptV3() string + + +可以看到,Hello()函数变成了HelloV3(),这就需要我们变更代码做适配。因为我们都统一模块到一个版本了,这时候就不需要再为了避免重名而重命名模块,所以此时hello.go内容为: + +package hello + +import ( + "rsc.io/quote/v3" +) + +func Hello() string { + return quote.HelloV3() +} + +func Proverb() string { + return quote.Concurrency() +} + + +执行go test: + +$ go test +PASS +ok github.com/marmotedu/gopractise-demo/modules/hello 0.003s + + +可以看到测试成功。 + + +删除不使用的依赖 + + +在上一步中,我们移除了rsc.io/quote包,但是它仍然存在于go list -m all和go.mod中,这时候我们要执行go mod tidy清理不再使用的依赖: + +$ go mod tidy +[colin@dev hello]$ cat go.mod +module github.com/marmotedu/gopractise-demo/modules/hello + +go 1.14 + +require ( + golang.org/x/text v0.3.3 // indirect + rsc.io/quote/v3 v3.1.0 + rsc.io/sampler v1.3.1 // indirect +) + + + +使用vendor + + +如果我们想把所有依赖都保存起来,在Go命令执行时不再下载,可以执行go mod vendor,该命令会把当前项目的所有依赖都保存在项目根目录的vendor目录下,也会创建vendor/modules.txt文件,来记录包和模块的版本信息: + +$ go mod vendor +$ ls +go.mod go.sum hello.go hello_test.go vendor world + + +到这里,我就讲完了Go依赖包管理常用的10个操作。 + +总结 + +这一讲中,我详细介绍了如何使用Go Modules来管理依赖,它包括以下Go Modules操作: + + +打开Go Modules; +设置环境变量; +创建一个新模块; +增加一个依赖; +查看所有依赖模块; +更新依赖; +添加一个新的major版本依赖; +升级到不兼容的版本; +删除不使用的依赖。 +使用vendor。 + + +课后练习 + + +思考下,如何更新项目的所有依赖到最新的版本? + +思考下,如果我们的编译机器访问不了外网,如何通过Go Modules下载Go依赖包? + + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/特别放送IAM排障指南.md b/专栏/Go语言项目开发实战/特别放送IAM排障指南.md new file mode 100644 index 0000000..769583b --- /dev/null +++ b/专栏/Go语言项目开发实战/特别放送IAM排障指南.md @@ -0,0 +1,510 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 IAM排障指南 + 你好,我是孔令飞。 + +今天我们更新一期特别放送作为加餐。在部署和使用IAM的过程中,难免会出现一些异常(也称为故障、问题)。这时候,就需要我们能够定位故障,并修复故障。这里,我总结了一些IAM的排障方法,以及一些常见故障的解决方法,供你参考。 + +如何排障? + +首先,我们需要发现问题,然后定位问题。我们可能需要经过多轮分析排查才能定位到问题的根因,最后去解决问题。排障流程如下图所示: + + + +如果想排查问题并解决问题,你还需要具备这两个基本能力:能够理解错误日志的内容;根据错误日志,找出解决方案。 + +我们举个例子来说吧。有以下错误: + +[going@dev iam]$ mysql -h127.0.0.1 -uroot -p'iam59!z$' +bash: /usr/bin/mysql: 没有那个文件或目录 +[going@dev iam]$ + + +对于这个错误,我们首先来理解错误内容:mysql命令没有找到,说明没有安装mysql,或者安装mysql失败。 + +那么,我们的解决方案就是重新执行 03讲 中安装MariaDB的步骤: + +$ cd $IAM_ROOT +$ ./scripts/install/mariadb.sh iam::mariadb::install + + +接下来,我会以iam-apiserver服务为例,给你演示下具体如何排障并解决问题。 + +发现问题 + +要排障,首先我们需要发现问题。我们通常用下面这几种方式来发现问题。 + + +检查服务状态:启动iam-apiserver服务后,执行systemctl status iam-apiserver 发现iam-apiserver启动失败,即Active的值不为active (running)。 +功能异常:访问iam-apiserver服务,功能异常或者报错,例如接口返回值跟预期不一样等。 +日志报错:在iam-apiserver的日志中发现一些WARN、ERROR、PANIC、FATAL等级别的错误日志。 + + +定位问题 + +发现问题之后,就需要我们定位出问题的根本原因。我们可以通过下面这三种方式来定位问题。 + + +查看日志,它是最简单的排障方式。 +使用Go调试工具Delve来定位问题。 +添加Debug日志,从程序入口处跟读代码,在关键位置处打印Debug日志,来定位问题。 + + +在定位问题的过程中,我们可以采用“顺藤摸瓜”的思路去排查问题。比如,我们的程序执行流程是:A -> B -> … -> N。其中A、B、N都可以理解为一个排查点。所谓的排查点,就是需要在该处定位问题的点,这些点可能是导致问题的根因所在。 + +在排障过程中,你可以根据最上层的日志报错,找到下一个排查点B。如果经过定位,发现B没有问题,那继续根据程序执行流程,找下一个排查点排查问题。如此反复,直到找到最终的排查点,也就是出问题的根因N,N即为Bug点。执行流程如下图所示: + + + +下面,我们来具体看看这三种定位问题的方法。 + +查看日志定位问题 + +我们首先应该通过日志来定位问题,这是最简单高效的方式。要通过日志来定位问题,你不仅要会看日志,还要能读懂日志,也就是理解日志报错的原因。 + +下面我来具体讲解用这种方法定位问题的步骤。 + +第一步,确保服务运行正常。 + +你可以通过执行 systemctl status 命令来查看服务的运行状况: + +$ systemctl status iam-apiserver +● iam-apiserver.service - IAM APIServer + Loaded: loaded (/etc/systemd/system/iam-apiserver.service; enabled; vendor preset: disabled) + Active: activating (auto-restart) (Result: exit-code) since Thu 2021-09-09 13:47:56 CST; 2s ago + Docs: https://github.com/marmotedu/iam/blob/master/init/README.md + Process: 119463 ExecStart=/opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml (code=exited, status=1/FAILURE) + Process: 119461 ExecStartPre=/usr/bin/mkdir -p /var/log/iam (code=exited, status=0/SUCCESS) + Process: 119460 ExecStartPre=/usr/bin/mkdir -p /data/iam/iam-apiserver (code=exited, status=0/SUCCESS) + Main PID: 119463 (code=exited, status=1/FAILURE) + + +可以看到,Active不是active (running),说明iam-apiserver服务没有正常运行。从上面输出中的Process: 119463 ExecStart=/opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml (code=exited, status=1/FAILURE)信息中,我们可以获取以下信息: + + +iam-apiserver服务启动命令为/opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml。 +/opt/iam/bin/iam-apiserver加载的配置文件为/etc/iam/iam-apiserver.yaml。 +/opt/iam/bin/iam-apiserver命令执行失败,退出码为1,其进程ID为119463。 + + +这里注意,systemctl status会将超过一定长度的行的后半部分用省略号替代,如果想查看完整的信息,可以追加-l参数,也就是systemctl status -l来查看。 + +既然iam-apiserver命令启动失败,那我们就需要查看iam-apiserver启动时的日志,看看有没有一些报错日志。 + +接下来,就进入第二步,查看iam-apiserver运行日志。 + +这里提一句,如果你对systemd不了解,也可以趁机恶补一波。你可以参考阮一峰大佬的两篇博客:Systemd 入门教程:命令篇和Systemd 入门教程:实战篇。 + +那么如何查看呢?我们有3种查看方式,我在下面按优先级顺序排列了下。你在定位问题和查看日志时,按优先级3选1即可,1 > 2 > 3。 + + +通过journalctl -u iam-apiserver查看。 +通过iam-apiserver日志文件查看。 +通过console查看。 + + +下面我来分别介绍下这三种查看方式。 + +先来看优先级最高的方式,通过journalctl -u iam-apiserver查看。 + +systemd 提供了自己的日志系统,称为 journal。我们可以使用journalctl命令来读取journal日志。journalctl提供了-u选项来查看某个 Unit 的日志,提供了_PID来查看指定进程ID的日志。在第一步中,我们知道服务启动失败的进程ID为119463。执行以下命令来查看这次启动的日志: + +$ sudo journalctl _PID=119463 +-- Logs begin at Thu 2021-09-09 09:12:25 CST, end at Thu 2021-09-09 14:40:48 CST. -- +... +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: 2021-09-09 13:47:56.727 INFO apiserver [email protected]/gorm.go:202 mysql/mysql.go:75[error] faile> +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: 2021-09-09 13:47:56.727 FATAL apiserver apiserver/server.go:139 Failed to get cache instance: g> +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/marmotedu/iam/internal/apiserver.(*completedExtraConfig).New +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/server.go:139 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/marmotedu/iam/internal/apiserver.createAPIServer +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/server.go:66 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/marmotedu/iam/internal/apiserver.Run +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/run.go:11 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/marmotedu/iam/internal/apiserver.run.func1 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/app.go:46 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/marmotedu/iam/pkg/app.(*App).runCommand +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/pkg/app/app.go:278 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/spf13/cobra.(*Command).execute +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:856 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/spf13/cobra.(*Command).ExecuteC +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:974 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/spf13/cobra.(*Command).Execute +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:902 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: github.com/marmotedu/iam/pkg/app.(*App).Run +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/pkg/app/app.go:233 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: main.main +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/workspace/golang/src/github.com/marmotedu/iam/cmd/iam-apiserver/apiserver.go:24 +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: runtime.main +Sep 09 13:47:56 VM-200-70-centos iam-apiserver[119463]: /home/going/go/go1.16.2/src/runtime/proc.go:225 +lines 10-54/54 (END) + + +从上面的日志中,我们找到了服务启动失败的原因:iam-apiserver启动时,发生了FATAL级别的错误。到这里,你已经初步定位到问题原因了。 + +我们再来看通过iam-apiserver日志文件查看的方式。 + +作为一个企业级的实战项目,iam-apiserver的日志当然是会记录到日志文件中的。在第一步中,我们通过systemctl status iam-apiserver输出的信息,知道了iam-apiserver启动时加载的配置文件为/etc/iam/iam-apiserver.yaml。所以,我们可以通过iam-apiserver的配置文件iam-apiserver.yaml中的log.output-paths配置项,查看记录日志文件的位置: + +log: + name: apiserver # Logger的名字 + development: true # 是否是开发模式。如果是开发模式,会对DPanicLevel进行堆栈跟踪。 + level: debug # 日志级别,优先级从低到高依次为:debug, info, warn, error, dpanic, panic, fatal。 + format: console # 支持的日志输出格式,目前支持console和json两种。console其实就是text格式。 + enable-color: true # 是否开启颜色输出,true:是,false:否 + disable-caller: false # 是否开启 caller,如果开启会在日志中显示调用日志所在的文件、函数和行号 + disable-stacktrace: false # 是否在panic及以上级别禁止打印堆栈信息 + output-paths: /var/log/iam/iam-apiserver.log,stdout # 支持输出到多个输出,逗号分开。支持输出到标准输出(stdout)和文件。 + error-output-paths: /var/log/iam/iam-apiserver.error.log # zap内部(非业务)错误日志输出路径,多个输出,逗号分开 + + +可以看到,iam-apiserver将日志分别记录到了/var/log/iam/iam-apiserver.log和stdout中。所以,我们可以通过查看/var/log/iam/iam-apiserver.log日志文件,来查看报错信息: + +$ tail -25 /var/log/iam/iam-apiserver.log +... +2021-09-09 15:42:35.231 INFO apiserver server/genericapiserver.go:88 GET /version --> github.com/marmotedu/iam/internal/pkg/server.(*GenericAPIServer).InstallAPIs.func2 (10 handlers) +2021-09-09 15:42:35.232 INFO apiserver [email protected]/gorm.go:202 mysql/mysql.go:75[error] failed to initialize database, got error dial tcp 127.0.0.1:3309: connect: connection refused +2021-09-09 15:42:35.232 FATAL apiserver apiserver/server.go:139 Failed to get cache instance: got nil cache server +github.com/marmotedu/iam/internal/apiserver.(*completedExtraConfig).New + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/server.go:139 +github.com/marmotedu/iam/internal/apiserver.createAPIServer + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/server.go:66 +github.com/marmotedu/iam/internal/apiserver.Run + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/run.go:11 +github.com/marmotedu/iam/internal/apiserver.run.func1 + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/app.go:46 +github.com/marmotedu/iam/pkg/app.(*App).runCommand + /home/going/workspace/golang/src/github.com/marmotedu/iam/pkg/app/app.go:278 +github.com/spf13/cobra.(*Command).execute + /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:856 +github.com/spf13/cobra.(*Command).ExecuteC + /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:974 +github.com/spf13/cobra.(*Command).Execute + /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:902 +github.com/marmotedu/iam/pkg/app.(*App).Run + /home/going/workspace/golang/src/github.com/marmotedu/iam/pkg/app/app.go:233 +main.main + /home/going/workspace/golang/src/github.com/marmotedu/iam/cmd/iam-apiserver/apiserver.go:24 +runtime.main + /home/going/go/go1.16.2/src/runtime/proc.go:225 + + +我们再来看最后一种查看方式,通过console查看。 + +当然,我们也可以直接通过console来看日志,这就需要我们在Linux终端前台运行iam-apiserver(在第一步中,我们已经知道了启动命令): + +$ sudo /opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml +... +2021-09-09 15:47:00.660 INFO apiserver server/genericapiserver.go:88 GET /debug/pprof/mutex --> github.com/gin-contrib/pprof.pprofHandler.func1 (10 handlers) +2021-09-09 15:47:00.660 INFO apiserver server/genericapiserver.go:88 GET /debug/pprof/threadcreate --> github.com/gin-contrib/pprof.pprofHandler.func1 (10 handlers) +2021-09-09 15:47:00.660 INFO apiserver server/genericapiserver.go:88 GET /version --> github.com/marmotedu/iam/internal/pkg/server.(*GenericAPIServer).InstallAPIs.func2 (10 handlers) +2021-09-09 15:47:00.661 INFO apiserver [email protected]/gorm.go:202 mysql/mysql.go:75[error] failed to initialize database, got error dial tcp 127.0.0.1:3309: connect: connection refused +2021-09-09 15:47:00.661 FATAL apiserver apiserver/server.go:139 Failed to get cache instance: got nil cache server +github.com/marmotedu/iam/internal/apiserver.(*completedExtraConfig).New + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/server.go:139 +github.com/marmotedu/iam/internal/apiserver.createAPIServer + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/server.go:66 +github.com/marmotedu/iam/internal/apiserver.Run + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/run.go:11 +github.com/marmotedu/iam/internal/apiserver.run.func1 + /home/going/workspace/golang/src/github.com/marmotedu/iam/internal/apiserver/app.go:46 +github.com/marmotedu/iam/pkg/app.(*App).runCommand + /home/going/workspace/golang/src/github.com/marmotedu/iam/pkg/app/app.go:278 +github.com/spf13/cobra.(*Command).execute + /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:856 +github.com/spf13/cobra.(*Command).ExecuteC + /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:974 +github.com/spf13/cobra.(*Command).Execute + /home/going/workspace/golang/pkg/mod/github.com/spf13/[email protected]/command.go:902 +github.com/marmotedu/iam/pkg/app.(*App).Run + /home/going/workspace/golang/src/github.com/marmotedu/iam/pkg/app/app.go:233 +main.main + /home/going/workspace/golang/src/github.com/marmotedu/iam/cmd/iam-apiserver/apiserver.go:24 +runtime.main + /home/going/go/go1.16.2/src/runtime/proc.go:225 + + +通过上面这3种查看方式,我们均能初步定位到服务异常的原因。 + +使用Go调试工具Delve来定位问题 + +查看日志是最简单的排障方式,通过查看日志,我们可能定位出问题的根本原因,这种情况下问题就能得到快速的解决。但有些情况下,我们通过日志并不一定能定位出问题,例如: + + +程序异常,但是没有错误日志。 +日志有报错,但只能判断问题的面,还不能精准找到问题的根因。 + + +遇到上面这两种情况,我们都需要再进一步地定位问题。这时候,我们可以使用Delve调试工具来尝试定位问题。Delve工具的用法你可以参考 Delve使用详解。 + +添加Debug日志定位问题 + +如果使用 Delve 工具仍然没有定位出问题,接下来你可以尝试最原始的方法:添加Debug日志来定位问题。这种方法具体可以分为两个步骤。 + +第一步,在关键代码段添加Debug日志。 + +你需要根据自己对代码的理解来决定关键代码段。如果不确定哪段代码出问题,可以从请求入口处添加Debug日志,然后跟着代码流程一步步往下排查,并在需要的地方添加Debug日志。 + +例如,通过排查日志,我们定位到internal/apiserver/server.go:139位置的代码导致程序FATAL,FATAL原因是Failed to get cache instance: got nil cache server。cache server是nil,说明cache server没有被初始化。查看cache server初始化函数: + +func GetCacheInsOr(store store.Factory) (*Cache, error) { + if store != nil { + once.Do(func() { + cacheServer = &Cache{store} + }) + } + + if cacheServer == nil { + return nil, fmt.Errorf("got nil cache server") + } + + return cacheServer, nil +} + + +我们不难分析出,是store == nil导致cacheServer没有被初始化。再来看下store的初始化代码,并加一些Debug日志,如下图所示: + + + +我们添加完Debug代码后,就可以重新编译并运行程序了。 + +这里有个小技巧:可以在错误返回的位置添加Debug日志,这样能大概率帮助你定位到出错的位置,例如: + +if err != nil { + log.Debugf("DEBUG POINT - 1: %v", err) + return err +} + + +第二步,重新编译源码,并启动。 + +这里为了调试、看日志方便,我们直接在Linux终端的前端运行iam-apiserver: + +$ sudo /opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml + + +查看我们添加的Debug日志打印的内容,如下图所示: + + + +从Debug日志中,可以看到用来创建MySQL实例的端口是错误的,正确的端口应该是3306,而不是3309。MySQL服务器的端口是在iam-apiserver.yaml中配置的。修改iam-apiserver.yaml为正确的配置,并启动: + +$ sudo /opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml + + +再次查看console日志,如下图所示: + + + +可以看到问题已经修复,dbIns不为nil,程序正常运行: + +$ systemctl status iam-apiserver +● iam-apiserver.service - IAM APIServer + Loaded: loaded (/etc/systemd/system/iam-apiserver.service; enabled; vendor preset: disabled) + Active: active (running) since Thu 2021-09-09 20:48:18 CST; 17s ago + Docs: https://github.com/marmotedu/iam/blob/master/init/README.md + Process: 255648 ExecStartPre=/usr/bin/mkdir -p /var/log/iam (code=exited, status=0/SUCCESS) + Process: 255647 ExecStartPre=/usr/bin/mkdir -p /data/iam/iam-apiserver (code=exited, status=0/SUCCESS) + Main PID: 255650 (iam-apiserver) + Tasks: 5 (limit: 23724) + Memory: 7.3M + CGroup: /system.slice/iam-apiserver.service + └─255650 /opt/iam/bin/iam-apiserver --config=/etc/iam/iam-apiserver.yaml + + +在这里,Active为active (running)状态。 + +因为这些Debug日志能够协助你定位问题,从侧面说明这些日志是有用的,所以你可以保留这些Debug日志调用代码。 + +解决问题 + +在定位问题阶段,我们已经找到了问题的原因,接下来就可以根据自己对业务、底层代码实现的掌握和理解,修复这个问题了。至于怎么修复,你需要结合具体情况来判断,并没有统一的流程和方法论,这里就不多介绍了。 + +上面,我介绍了排查问题的思路和方法。接下来,我来向你展示9个在部署和使用IAM系统时容易遇到的问题,并提供解决方法。这些问题基本上都是由服务器环境引起的。 + +IAM常见故障及解决办法 + +问题一:安装neovim,报 No match for argument: neovim 错误。 + +解决方法是安装 EPEL 源: + +$ sudo yum install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm + + +问题二:安装protoc-gen-go失败(超时、报错等)。 + +这个故障出现,可能是因为你当前服务器所在的网络环境无法访问github.com,或者访问github.com速度太慢。 + +解决方法是手动编译安装,方法如下: + +$ git clone --depth 1 https://github.com/golang/protobuf $GOPATH/src/github.com/golang/protobuf +$ cd $GOPATH/src/github.com/golang/protobuf/protoc-gen-go +$ go install -v . + + +问题三:遇到xxx: permission denied这类的错误。 + +出现这种错误,是因为你没有权限执行当前的操作。解决方法是排查自己是否有权限执行当前操作。如果没有权限,需要你切换到有权限的用户,或者放弃执行当前操作。 + +为了说明问题,这里我举一个错误例子,并给出排查思路。例子的错误日志如下: + +[going@VM-8-9-centos /]$ go get -u github.com/golang/protobuf/protoc-gen-go +go: could not create module cache: mkdir /golang: permission denied +[going@VM-8-9-centos /]$ sudo go get -u github.com/golang/protobuf/protoc-gen-go +sudo: go: command not found + + +上述错误中, 一共报了两个错误,分别是mkdir /golang: permission denied和sudo: go: command not found。我们先来看mkdir /golang: permission denied错误。 + +通过命令行提示符$可以知道,当前登陆用户是普通用户;通过报错mkdir /golang: permission denied可以知道go get -u github.com/golang/protobuf/protoc-gen-go命令底层执行了mkdir /golang,因为普通用户没有写/ 目录的权限,所以会报权限错误。解决方法是切换到用户的目录下,执行go get -u命令。 + +我们再来看下sudo: go: command not found错误。sudo命令会将命令执行的环境切换到root用户,root用户显然是没有安装go命令的,所以会导致command not found错误。解决方式是去掉 sudo ,直接执行 $ go get -u xxx 。 + +问题四:VimIDE使用过程中,报各类错误。 + +这里的报错原因跟环境有关系,安装VimIDE时的系统环境、包的版本等等,都可能会导致使用VimIDE报错。因为错误类型太多,没法一一说明,所以我建议你忽略这些错误,其实完全不影响后面的学习。 + +问题五:访问iam-authz-server的/v1/authz接口报{"code":100202,"message":"Signature is invalid"}。 + +这时可能是签发的Token有问题,建议重新执行以下5个步骤: + + +重新登陆系统,并获取访问令牌: + + +$ token=`curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login | jq -r .token` + + +如果没有安装jq命令,可以执行sudo yum -y install jq命令来安装。 + + +创建授权策略: + + +$ curl -s -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer $token" -d'{"metadata":{"name":"authztest"},"policy":{"description":"One policy to rule them all.","subjects":["users:","users:maria","groups:admins"],"actions":["delete",""],"effect":"allow","resources":["resources:articles:<.*>","resources:printer"],"conditions":{"remoteIPAddress":{"type":"CIDRCondition","options":{"cidr":"192.168.0.1/16"}}}}}' http://127.0.0.1:8080/v1/policies + + + +创建密钥,并从命令的输出中提取secretID 和 secretKey: + + +$ curl -s -XPOST -H"Content-Type: application/json" -H"Authorization: Bearer $token" -d'{"metadata":{"name":"authztest"},"expires":0,"description":"admin secret"}' http://127.0.0.1:8080/v1/secrets +{"metadata":{"id":23,"name":"authztest","createdAt":"2021-04-08T07:24:50.071671422+08:00","updatedAt":"2021-04-08T07:24:50.071671422+08:00"},"username":"admin","secretID":"ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox","secretKey":"7Sfa5EfAPIwcTLGCfSvqLf0zZGCjF3l8","expires":0,"description":"admin secret"} + + + +生成访问 iam-authz-server 的 Token + + +iamctl 提供了 jwt sigin 命令,你可以根据 secretID 和 secretKey 签发 Token,方便你使用。签发Token的具体命令如下: + +$ iamctl jwt sign ZuxvXNfG08BdEMqkTaP41L2DLArlE6Jpqoox 7Sfa5EfAPIwcTLGCfSvqLf0zZGCjF3l8 # iamctl jwt sign $secretID $secretKey +eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ + + + +测试资源授权是否通过: + + +$ curl -s -XPOST -H'Content-Type: application/json' -H'Authorization: Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6Ilp1eHZYTmZHMDhCZEVNcWtUYVA0MUwyRExBcmxFNkpwcW9veCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpYW0uYXV0aHoubWFybW90ZWR1LmNvbSIsImV4cCI6MTYxNzg0NTE5NSwiaWF0IjoxNjE3ODM3OTk1LCJpc3MiOiJpYW1jdGwiLCJuYmYiOjE2MTc4Mzc5OTV9.za9yLM7lHVabPAlVQLCqXEaf8sTU6sodAsMXnmpXjMQ' -d'{"subject":"users:maria","action":"delete","resource":"resources:articles:ladon-introduction","context":{"remoteIPAddress":"192.168.0.5"}}' http://127.0.0.1:9090/v1/authz +{"allowed":true} + + +问题六:执行iamctl user list报error: {"code":100207,"message":"Permission denied"}。 + +出现这种情况,可能是密码没有配置正确。 + +你可以看下$HOME/.iam/iamctl.yaml配置文件中的用户名和密码配置的是不是admin,以及admin的密码是否是Admin@2021。 + +问题七:在创建用户时报{"code":100101,"message":"Database error"}错误。 + +出现这种情况,可能是用户名重了,建议换个新的用户名再次创建。 + +问题八:报No such file or directory、command not found、permission denied错误。 + +遇到这类错误,要根据提示排查和解决问题。 + + +No such file or directory:确认文件是否存在,不存在的原因是什么。 +command not found:确认命令是否存在,如果不存在,可以重新安装命令。 +permission denied:确认是否有操作权限,如果没有,要切换到有权限的用户或者目录。 + + +问题九:报iam-apiserver.service、/opt/iam/bin/iam-apiserver、/etc/iam/iam-apiserver.yaml文件不存在。 + +我来介绍下这些文件的作用。 + + +/etc/systemd/system/iam-apiserver.service:iam-apiserver的sysmted Unit文件。 +/opt/iam/bin/iam-apiserver:iam-apiserver的二进制启动命令。 +/etc/iam/iam-apiserver.yaml:iam-apiserver的配置文件。 + + +如果某个文件不存在,那就需要你重新安装这些文件。我来分别介绍这三个文件的安装方法。 + +/etc/systemd/system/iam-apiserver.service安装方法: + +$ cd $IAM_ROOT +$ ./scripts/genconfig.sh scripts/install/environment.sh init/iam-apiserver.service > iam-apiserver.service +$ sudo mv iam-apiserver.service /etc/systemd/system/ + + +/opt/iam/bin/iam-apiserver安装方法: + +$ cd $IAM_ROOT +$ source scripts/install/environment.sh +$ make build BINS=iam-apiserver +$ sudo cp _output/platforms/linux/amd64/iam-apiserver ${IAM_INSTALL_DIR}/bin + + +/etc/iam/iam-apiserver.yaml安装方法: + +$ cd $IAM_ROOT +$ ./scripts/genconfig.sh scripts/install/environment.sh configs/iam-apiserver.yaml > iam-apiserver.yaml +$ sudo mv iam-apiserver.yaml ${IAM_CONFIG_DIR} + + +总结 + +这一讲,我以iam-apiserver服务为例,向你介绍了排障的基本流程:发现问题 -> 定位问题 -> 解决问题。 + +你可以通过三种方式来发现问题。 + + +检查服务状态:启动iam-apiserver服务后,执行systemctl status iam-apiserver 发现iam-apiserver启动失败,即Active的值不为active (running)。 +功能异常:访问iam-apiserver服务,功能异常或者报错,例如接口返回值跟预期不一样;接口报错。 +日志报错:在iam-apiserver的日志中发现一些WARN、ERROR、PANIC、FATAL等高级别的错误日志。 + + +发现问题之后,你可以通过查看日志、使用Go调试工具Delve和添加Debug日志这三种方式来定位问题。 + + +查看日志:查看日志是最简单的排障方式。 +使用Go调试工具Delve来定位问题。 +添加Debug日志:从程序入口处跟读代码,在关键位置处打印Debug日志,来定位问题。 + + +找到问题根因之后,就要解决问题。你需要根据自己对业务、底层代码实现的掌握和理解,解决这个问题。 + +最后,我向你展示了9个在部署和使用IAM系统时容易遇到的问题,并提供了解决方法,希望能给你一些切实的帮助。 + +课后练习 + + +思考下,如何查找iam-apiserver的systemd Unit文件的路径? +执行以下命令: + + +$ token=`curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login | jq -r .token` +$ echo $token + + +可以获取token,但发现token值为空。请给出你的排障流程和方法。 + +欢迎你在留言区与我交流讨论,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/特别放送分布式作业系统设计和实现.md b/专栏/Go语言项目开发实战/特别放送分布式作业系统设计和实现.md new file mode 100644 index 0000000..4219ea2 --- /dev/null +++ b/专栏/Go语言项目开发实战/特别放送分布式作业系统设计和实现.md @@ -0,0 +1,525 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 分布式作业系统设计和实现 + 你好,我是孔令飞,我们又见面了。结课并不意味着结束,我非常高兴能持续把好的内容分享给你,也希望你能继续在留言区与我保持交流,分享你的学习心得和实践经验。 + +今天这一讲,我们来聊聊如何设计分布式作业系统。在实际的Go项目开发中,我们经常会遇到下面这两个功能需求: + + +想定时执行某个任务,例如在每天上午10:00清理数据库中的无用数据。 +轮训数据库表的某个字段,根据字段的状态,进行一些异步的业务逻辑处理。比如,监听到 table_xxx.status = 'pending' 时,执行异步的初始化流程,完成之后设置 table_xxx.status='normal' 。 + + +这两个在Go项目开发中非常常见、基础的功能需求,通常可以通过作业系统来实现。IAM为了解决这种常见的功能需求,也开发了自己的作业系统。今天这一讲,我们就来看下IAM是如何实现作业系统的。 + +任务分类 + +在介绍作业系统之前,这里先来看下任务的分类。理解任务的分类,有助于我们理解作业系统执行的任务类型,进而有助于我们设计作业系统。 + +在我看来,任务可以分为下面3类。 + + +定时任务:定时任务会在指定的时间点固定执行。只要到达执行任务的时间点,就会执行任务,而不管上一次任务是否完成。 +间隔任务:上一次任务执行完,间隔一段时间(如5秒、5分钟),再继续执行下一次任务。 +间隔性定时任务:间隔任务的变种,从上一次任务开始执行时计时,只要间隔时间一到,便执行下一次任务,而不管上一次任务是否完成。 + + +定时任务好理解,但间隔任务和间隔性定时任务不太好区分,它们的区别是:间隔任务会等待上一次任务执行完,间隔一段时间再执行下一次任务。而间隔性定时任务不会等待上一次任务执行完,只要间隔时间一到,便执行下一次任务。 + +三者的区别如下图所示: + + + +在实际的项目开发中,我们经常会遇到这3类任务的需求。 + +作业系统的常见实现 + +在开始介绍IAM作业系统实现之前,有必要先介绍一下如何执行一个间隔/定时任务。只有了解了这些,才能更好地设计IAM的作业系统。通常来说,我们可以通过以下4种方式,来执行一个间隔/定时任务: + + +基于time 包提供的方法(例如time.Sleep、time.Ticker等 )自己开发执行间隔/定时任务的服务。 +一些Go包支持执行间隔/定时任务,可以直接使用这些Go包来执行间隔/定时任务,免去了自己开发作业调度部分的代码,例如github.com/robfig/cron 。 +借助Linux的crontab执行定时任务。 +使用开源的作业系统,并通过作业系统来执行间隔/定时任务,例如 distribworks/dkron。 + + +上述4种方法,每一种都有自己的优缺点。采用第一种方法的话,因为一切都要从0开始实现,开发工作量大、开发效率低。我认为,因为已经有很多优秀的cron包可供使用了,没必要自己从0开发,可以直接使用这些cron包来执行周期/定时任务。IAM项目便采用了这种方法。 + +接下来,我先介绍下第三种和第四种方法:使用Linux crontab和使用开源的Go作业系统。然后,我们再来重点看看IAM项目采用的第二种方法。 + +Linux crontab + +crontab是Linux系统自带的定时执行工具,可以在无需人工干预的情况下运行作业。crontab通过crond进程来提供服务,crond进程每分钟会定期检查是否有要执行的任务,如果有,则自动执行该任务。crond进程通过读取crontab配置,来判断是否有任务执行,以及何时执行。 + +crond进程会在下面这3个位置查找crontab配置文件。 + + +/var/spool/cron/:该目录存放用户(包括root)的crontab任务,每个任务以登录名命名,比如 colin 用户创建的crontab任务对应的文件就是/var/spool/cron/colin。 +/etc/crontab:该目录存放由系统管理员创建并维护的crontab任务。 +/etc/cron.d/:该目录存放任何要执行的crontab任务。cron进程执行时,会自动扫描该目录下的所有文件,按照文件中的时间设定执行后面的命令。 + + +可以看到,如果想执行一个crontab任务,就需要确保crond运行,并配置crontab任务。具体分为以下两步: + +第一步,确保crond进程正在运行。 + +执行以下命令,查看crond进程运行状态: + +$ systemctl status crond +● crond.service - Command Scheduler + Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled; vendor preset: enabled) + Active: active (running) since Wed 2021-11-17 07:11:27 CST; 2 days ago + Main PID: 9182 (crond) + Tasks: 1 + Memory: 728.0K + CGroup: /system.slice/crond.service + └─9182 /usr/sbin/crond -n + + +Active: active (running)说明crond进程正在运行,否则可以执行systemctl start crond启动crond进程。 + +第二步,配置crontab任务。 + +可以通过crontab -e来编辑配置文件,例如执行crontab -e后进入vi交互界面,并配置以下crontab任务: + +# 每分钟输出时间到文件 /tmp/test.txt +* * * * * echo `date` >> /tmp/test.txt + +# 每隔 2 分钟同步一次互联网时间 +*/2 * * * * /usr/bin/ntpstat time.windows.com > /dev/null 2>&1 + + +编辑后的配置文件保存在/var/spool/cron/$USER文件中。你可以通过crontab -l或者sudo cat /var/spool/cron/$USER来查看,例如: + +$ crontab -l +# 每分钟输出时间到文件/tmp/test.txt +* * * * * echo `date` >> /tmp/test.txt + +# 每隔 2 分钟同步一次互联网时间 +*/2 * * * * /usr/bin/ntpstat time.windows.com > /dev/null 2>&1 + + +如果想删除所有的crontab任务,你可以执行crontab -r命令。 + +配置的crontab任务需要遵循crontab的时间格式,格式如下: + +.---------------- minute (0 - 59) +| .------------- hour (0 - 23) +| | .---------- day of month (1 - 31) +| | | .------- month (1 - 12) OR jan,feb,mar,apr ... +| | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat +| | | | | +* * * * * + + +可以看到,crontab只能精确到分钟,不能精确到秒。 + +下面是一些常用的crontab时间格式,你可以参考,来加深理解: + +# 每分钟执行一次 +* * * * * # * 代表所有可能的值 + +# 每隔一小时执行一次 +* */1 * * * # / 表示频率 + +# 每小时的 15 和 30 分各执行一次 +15,45 * * * * # , 表示并列 + +# 在每天上午 8- 11 时中间每小时 15,45 分各执行一次 +15,45 8-11 * * * # - 表示范围 + +# 每个星期一的上午 8 点到 11 点的第 3 和第 15 分钟执行一次 +3,15 8-11 * * 1 + +# 每隔两天的上午 8 点到 11 点的第 3 和第 15 分钟执行一次 +3,15 8-11 */2 * * + + +使用crontab执行周期/定时任务的优点是不用做任何开发,只需要配置crontab任务即可。至于缺点也很明显,主要有下面这几个: + + +不能精确到秒。 +需要手动编写可执行命令。这些可执行命令跟项目分离,没办法复用项目提供的包、函数等能力。如果想执行跟项目关系紧密的作业,开发起来不方便。 +单点,如果crond进程异常,周期/定时任务就没法继续执行。你可能想说:可以在两台机器上配置并执行相同的周期/定时任务。但是这样做会有问题,因为两台机器同时执行相同的任务,可能会彼此造成冲突或状态不一致。 +没办法实现间隔任务和间隔性定时任务。 + + +使用开源的作业系统 + +除了使用Linux系统自带的crontab之外,我们还可以使用一些业界优秀的开源作业系统。这里,我列出了一些比较受欢迎的Go语言开发的作业系统。之所以只选择Go语言开发的项目,一方面是想丰富你的Go语言生态,另一方面,同种语言也有助于你学习、改造这些项目。 + + +distribworks/dkron。dkron是一个分布式、启动迅速、带容错机制的定时作业系统,支持crontab表达式。它具有下面这些核心特性。 + + +易用:可以通过易操作、漂亮的Web界面来管理作业。 +可靠:具备容错机制,一个节点不可用,其他节点可继续执行作业。 +高可扩展性:能够处理大量的计划作业和数千个节点。 + +ouqiang/gocron。gocron是国人开发的轻量级定时任务集中调度和管理系统, 用于替代Linux-crontab。它具有下面这些核心特性。 + + +具有Web界面管理定时任务。 +支持crontab时间格式,并精确到秒。 +支持shell命令和HTTP请求两种任务格式。 +具有任务超时机制、任务依赖机制、任务执行失败可重试机制。 +支持查看任务执行日志,并支持用邮件、Slack、Webhook等方式通知任务执行结果。 + +shunfei/cronsun。cronsun 是一个分布式作业系统,单个节点同 crontab 近似。它具有下面这些核心特性。 + + +具有Web界面,方便对多台服务器上的定时任务进行集中式管理。 +任务调度时间粒度支持到秒级别。 +任务执行失败可重试。 +任务可靠性保障(从N个节点里面挑一个可用节点来执行任务)。 +任务日志查看。 +任务失败邮件告警(也支持自定义http告警接口)。 + + + +那么,这么多的开源项目该如何选择呢?这里建议你选择 distribworks/dkron 。原因是 distribworks/dkron Star数很多,而且功能齐全易用、文档丰富。当然,在实际开发中,你最好也对其他开源项目进行调研,根据需要选择一个最适合自己的开源项目。 + +使用这些作业系统的优点是不用开发、功能比crontab更强大,有些还是分布式的作业系统,具备容灾能力。但缺点也很明显: + + +这些作业系统支持的任务种类有限,比如一般会支持通过shell脚本及发送HTTP请求的方式来执行任务。不管哪种方式,实现都跟项目分离,在开发跟项目结合紧密的任务插件时不是很简单、高效。 +很多时候我们只会使用其中一部分能力,或者仅有一到两个项目会使用到这类系统,但我们还要部署并维护这些作业系统,工作量大,收益小。 +没办法实现间隔任务。 + + +使用Linux的crontab和使用开源的Go作业系统,这两种方法的缺点都很明显。鉴于这些缺点,IAM系统选择使用现有的cron库封装自己的任务框架,并基于这个框架开发任务。IAM项目选择了robfig/cron库,原因是cron库Star数最多,且功能丰富、使用简单。另外IAM还使用github.com/go-redsync/redsync实现了基于Redis的分布式互斥锁。所以,在开始介绍IAM作业系统实现前,我先来简单介绍下如何使用这两个包。 + +github.com/robfig/cron使用介绍 + +github.com/robfig/cron是一个可以实现类似Linux crontab定时任务的cron包,但是cron包支持到秒。 + +cron包支持的时间格式 + +cron包支持crontab格式和固定间隔格式这两种时间格式,下面我来分别介绍下。 + +crontab格式的时间格式,支持的匹配符跟crontab保持一致。时间格式如下: + + ┌─────────────second 范围 (0 - 60) + │ ┌───────────── min (0 - 59) + │ │ ┌────────────── hour (0 - 23) + │ │ │ ┌─────────────── day of month (1 - 31) + │ │ │ │ ┌──────────────── month (1 - 12) + │ │ │ │ │ ┌───────────────── day of week (0 - 6) (0 to 6 are Sunday to + │ │ │ │ │ │ Saturday) + │ │ │ │ │ │ + │ │ │ │ │ │ + * * * * * * + + +第二种是固定间隔格式,例如@every 。duration是一个可以被time.ParseDuration解析的字符串,例如@every 1h30m10s表示任务每隔1小时30分10秒会被执行。这里要注意,间隔不考虑任务的运行时间。例如,如果任务需要3分钟运行,并且计划每5分钟运行一次,则每次运行之间只有2分钟的空闲时间。 + +cron包使用示例 + +cron包的使用方法也很简单,下面是一个简单的使用示例: + +package main + +import ( + "fmt" + + "github.com/robfig/cron/v3" +) + +func helloCron() { + fmt.Println("hello cron") +} + +func main() { + fmt.Println("starting go cron...") + + // 创建一个cron实例 + cron := cron.New(cron.WithSeconds(), cron.WithChain(cron.SkipIfStillRunning(nil), cron.Recover(nil))) + + // 添加一个定时任务 + cron.AddFunc("* * * * * *", helloCron) + + // 启动计划任务 + cron.Start() + + // 关闭着计划任务, 但是不能关闭已经在执行中的任务. + defer cron.Stop() + + select {} // 查询语句,保持程序运行,在这里等同于for{} +} + + +在上面的代码中,通过 cron.New 函数调用创建了一个 cron 实例;接下来通过 cron 实例的 AddFunc 方法,给 cron 实例添加了一个定时任务:每分钟执行一次 helloCron 函数;最后通过 cron 实例的 Start 方法启动定时任务。在程序退出时,还执行了 cron.Stop() 关闭定时任务。 + +拦截器 + +cron包还支持安装一些拦截器,这些拦截器可以实现以下功能: + + +从任务的panic中恢复(cron.Recover())。 +如果上一次任务尚未完成,则延迟下一次任务的执行(cron.DelayIfStillRunning())。 +如果上一次任务尚未完成,则跳过下一次任务的执行(cron.SkipIfStillRunning())。 +记录每个任务的调用(cron.WithLogger())。 +任务完成时通知。 + + +如果想使用这些拦截器,只需要在创建cron实例时,传入相应的Option即可,例如: + +cron := cron.New(cron.WithSeconds(), cron.WithChain(cron.SkipIfStillRunning(nil), cron.Recover(nil))) + + +github.com/go-redsync/redsync使用介绍 + +redsync可以实现基于Redis的分布式锁,使用起来也比较简单,我们直接来看一个使用示例: + +package main + +import ( + goredislib "github.com/go-redis/redis/v8" + "github.com/go-redsync/redsync/v4" + "github.com/go-redsync/redsync/v4/redis/goredis/v8" +) + +func main() { + // Create a pool with go-redis (or redigo) which is the pool redisync will + // use while communicating with Redis. This can also be any pool that + // implements the `redis.Pool` interface. + client := goredislib.NewClient(&goredislib.Options{ + Addr: "localhost:6379", + }) + pool := goredis.NewPool(client) // or, pool := redigo.NewPool(...) + + // Create an instance of redisync to be used to obtain a mutual exclusion + // lock. + rs := redsync.New(pool) + + // Obtain a new mutex by using the same name for all instances wanting the + // same lock. + mutexname := "my-global-mutex" + mutex := rs.NewMutex(mutexname) + + // Obtain a lock for our given mutex. After this is successful, no one else + // can obtain the same lock (the same mutex name) until we unlock it. + if err := mutex.Lock(); err != nil { + panic(err) + } + + // Do your work that requires the lock. + + // Release the lock so other processes or threads can obtain a lock. + if ok, err := mutex.Unlock(); !ok || err != nil { + panic("unlock failed") + } +} + + +上面的代码,创建了一个 redsync.Redsync 实例,并使用 redsync.Redsync 提供的 NewMutex 方法,创建了一个分布式锁实例 mutex。通过 mutex.Lock() 加锁,通过 mutex.Unlock() 释放锁。 + +IAM作业系统特点 + +在开发IAM的作业系统之前,我们需要先梳理好IAM要实现的任务。IAM需要实现以下两个间隔任务: + + +每隔一段时间从 policy_audit 表中清理超过指定天数的授权策略。 +每隔一段时间禁用超过指定天数没有登录的用户。 + + +结合上面提到的作业系统的缺点,这里将我们需要设计的作业系统的特点总结如下: + + +分布式的作业系统,当有多个实例时,确保同一时刻只有1个实例在工作。 +跟项目契合紧密,能够方便地复用项目提供的包、函数等能力,提高开发效率。 +能够执行定时任务、间隔任务、间隔性定时任务这3种类型的任务。 +可插件化地加入新的周期/定时任务。 + + +IAM作业系统实现 + +介绍完IAM作业系统使用到的两个Go包和IAM作业系统的特点,下面我来正式讲解IAM作业系统的实现。 + +IAM的作业系统服务名叫iam-watcher。watcher是观察者的意思,里面的任务主要是感知一些状态,并执行相应的任务,所以叫watcher。iam-watcher main函数位于cmd/iam-watcher/watcher.go文件中。应用框架跟iam-apiserver、iam-authz-server、iam-pump保持高度一致,这里就不再介绍了。 + +整个iam-watcher服务的核心实现位于internal/watcher/server.go文件中,在server.go文件中调用了newWatchJob,创建了一个github.com/robfig/cron.Cron类型的cron实例,newWatchJob 代码如下: + +func newWatchJob(redisOptions *genericoptions.RedisOptions, watcherOptions *options.WatcherOptions) *watchJob { + logger := cronlog.NewLogger(log.SugaredLogger()) + + client := goredislib.NewClient(&goredislib.Options{ + Addr: fmt.Sprintf("%s:%d", redisOptions.Host, redisOptions.Port), + Username: redisOptions.Username, + Password: redisOptions.Password, + }) + + pool := goredis.NewPool(client) + rs := redsync.New(pool) + + cron := cron.New( + cron.WithSeconds(), + cron.WithChain(cron.SkipIfStillRunning(logger), cron.Recover(logger)), + ) + + return &watchJob{ + Cron: cron, + config: watcherOptions, + rs: rs, + } +} + + +上述代码创建了以下两种类型的实例。 + + +github.com/robfig/cron.Cron:基于github.com/robfig/cron包实现的作业系统,可以支持定时任务、间隔任务、间隔性定时任务 3种类型的任务。 +github.com/go-redsync/redsync.Redsync:基于Redis的分布式互斥锁。 + + +这里需要注意,创建cron实例时需要增加cron.SkipIfStillRunning() Option,SkipIfStillRunning可以使cron任务在上一个任务还没执行完时,跳过下一个任务的执行,以此实现间隔任务的效果。 + +创建实例后,通过addWatchers()来注册cron任务。addWatchers 函数代码如下: + +func (w *watchJob) addWatchers() *watchJob { + for name, watcher := range watcher.ListWatchers() { + // log with `{"watcher": "counter"}` key-value to distinguish which watcher the log comes from. + ctx := context.WithValue(context.Background(), log.KeyWatcherName, name) + + if err := watcher.Init(ctx, w.rs.NewMutex(name, redsync.WithExpiry(2*time.Hour)), w.config); err != nil { + log.Panicf("construct watcher %s failed: %s", name, err.Error()) + } + + _, _ = w.AddJob(watcher.Spec(), watcher) + } + + return w +} + + +上述函数会调用watcher.ListWatchers()列出所有的watcher,并在for循环中将这些watcher添加到cron调度引擎中。watcher定义如下: + +type IWatcher interface { + Init(ctx context.Context, rs *redsync.Mutex, config interface{}) error + Spec() string + cron.Job +} + +type Job interface { + Run() +} + + +也就是说,一个watcher是实现了以下3个方法的结构体: + + +Init(),用来初始化wacther。 +Spec(),用来返回Cron实例的时间格式,支持Linux crontab时间格式和@every 1d类型的时间格式。 +Run(),用来运行任务。 + + +IAM实现了两个watcher: + + +task:禁用超过X天还没有登录过的用户,X可由iam-watcher.yaml配置文件中的watcher.task.max-inactive-days配置项来配置。 +clean:清除policy_audit表中超过X天数后的授权策略,X可由iam-watcher.yaml配置文件中的watcher.clean.max-reserve-days配置项来配置。- +创建完cron实例后,就可以在Run函数中启动cron任务。Run函数代码如下: + + +func (s preparedWatcherServer) Run() error { + stopCh := make(chan struct{}) + s.gs.AddShutdownCallback(shutdown.ShutdownFunc(func(string) error { + // wait for running jobs to complete. + ctx := s.cron.Stop() + select { + case <-ctx.Done(): + log.Info("cron jobs stopped.") + case <-time.After(3 * time.Minute): + log.Error("context was not done after 3 minutes.") + } + stopCh <- struct{}{} + + return nil + })) + + // start shutdown managers + if err := s.gs.Start(); err != nil { + log.Fatalf("start shutdown manager failed: %s", err.Error()) + } + + log.Info("star to run cron jobs.") + s.cron.Start() + + // blocking here via channel to prevents the process exit. + <-stopCh + + return nil +} + + +上述代码,通过s.cron.Start()代码调用来启动cron实例,执行cron任务。 + +这里需要注意,我们还需要实现优雅关停功能,也就是当程序结束时,等待正在执行的作业都结束后,再终止进程。s.cron.Stop()会返回context.Context类型的变量,用来告知调用者cron任务何时结束,以使调用者终止进程。在cron任务都执行完毕或者超时3分钟后,会往 stopCh 通道中写入一条message,<-stopCh 会结束阻塞状态,进而退出iam-watcher进程。 + +task watcher实现解读 + +task watcher的实现位于internal/watcher/watcher/task/watcher.go文件中,该文件定义了一个taskWatcher结构体: + +type taskWatcher struct { + ctx context.Context + mutex *redsync.Mutex + maxInactiveDays int +} + + +taskWatcher实现了IWatcher接口。在程序启动时,通过 init 函数将taskWatcher注册到internal/watcher/watcher/registry.go中定义的全局变量registry中,通过func ListWatchers() map[string]IWatcher函数返回所有注册的watcher。 + +这里需要注意,所有的watcher在internal/watcher/watcher/all/all.go文件中以匿名包的形式被导入,从而触发watcher所在包的init函数的执行。init函数通过调用watcher.Register("clean", &cleanWatcher{})将watcher注册到registry变量中。all.go文件中导入匿名包代码如下: + +import ( + _ "github.com/marmotedu/iam/internal/watcher/watcher/clean" + _ "github.com/marmotedu/iam/internal/watcher/watcher/task" +) + + +这样做的好处是,不需要修改任何iam-watcher的框架代码,就可以插件化地注册一个新的watcher。不改动iam-watcher的主体代码,能够使我们以最小的改动添加一个新的watcher。例如,我们需要新增一个 cleansecret watcher,只需要执行以下两步即可: + + +在internal/watcher/watcher目录下新建一个cleansecret目录,并实现cleanSecretWatcher。 +在internal/watcher/watcher/all/all.go文件中以匿名的形式导入github.com/marmotedu/iam/internal/watcher/watcher/cleansecret包。- +在taskWatcher的Run()方法中,我们通过以下代码,来确保即使有多个iam-watcher实例,也只有一个task watcher在执行: + + + if err := tw.mutex.Lock(); err != nil { + log.L(tw.ctx).Info("taskWatcher already run.") + + return + } + defer func() { + if _, err := tw.mutex.Unlock(); err != nil { + log.L(tw.ctx).Errorf("could not release taskWatcher lock. err: %v", err) + + return + } + }() + + +我们在taskWatcher的Run()方法中,查询出所有的用户,并对比loginedAt字段中记录的时间和当前时间,来判断是否需要禁止用户。loginedAt字段记录了用户最后一次登录的时间。 + +通过task watcher的实现,可以看到:在task watcher中,我们使用了IAM项目提供的mysql.GetMySQLFactoryOr函数、log包,以及Options配置,这使我们可以很方便地开发一个跟项目紧密相关的任务。 + +总结 + +在Go项目开发中,我们经常会需要执行一些间隔/定时任务,这时我们就需要一个作业系统。我们可以使用Linux提供的crontab执行定时任务,还可以自己搭建一个作业系统,并在上面执行我们的间隔/定时任务。但这些方法都有一些缺点,比如跟项目独立、无法执行间隔任务等。所以,这时候比较好的方式是基于开源的优秀cron包,来实现一个作业系统,并基于这个作业系统开发任务插件。 + +IAM基于github.com/robfig/cron包和github.com/go-redsync/redsync包,实现了自己的分布式作业系统iam-watcher。iam-watcher可以插件化地添加定时任务、间隔任务、间隔性定时任务。至于它的具体实现,你可以跟读iam-watcher服务的代码,其main函数位于cmd/iam-watcher/watcher.go文件中。 + +课后练习 + + +思考一下:在日常工作中,除了定时任务、间隔任务、间隔性定时任务外,还有没有其他类型的任务需求?欢迎在评论区分享。 +尝试实现一个新的watcher,用来从secret表中删除过期的secret。- +欢迎你在留言区与我交流讨论。如果这一讲对你有帮助,也欢迎分享给你身边的朋友。 + + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/特别放送给你一份Go项目中最常用的Makefile核心语法.md b/专栏/Go语言项目开发实战/特别放送给你一份Go项目中最常用的Makefile核心语法.md new file mode 100644 index 0000000..d8582b3 --- /dev/null +++ b/专栏/Go语言项目开发实战/特别放送给你一份Go项目中最常用的Makefile核心语法.md @@ -0,0 +1,507 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 给你一份Go项目中最常用的Makefile核心语法 + 你好,我是孔令飞。今天,我们更新一期特别放送作为“加餐”,希望日常催更的朋友们食用愉快。 + +在第 14讲 里,我强调了熟练掌握Makefile语法的重要性,还推荐你去学习陈皓老师编写的《跟我一起写 Makefile》 (PDF 重制版)。也许你已经点开了链接,看到那么多Makefile语法,是不是有点被“劝退”的感觉? + +其实在我看来,虽然Makefile有很多语法,但不是所有的语法都需要你熟练掌握,有些语法在Go项目中是很少用到的。要编写一个高质量的Makefile,首先应该掌握一些核心的、最常用的语法知识。这一讲我就来具体介绍下Go项目中常用的Makefile语法和规则,帮助你快速打好最重要的基础。 + +Makefile文件由三个部分组成,分别是Makefile规则、Makefile语法和Makefile命令(这些命令可以是Linux命令,也可以是可执行的脚本文件)。在这一讲里,我会介绍下Makefile规则和Makefile语法里的一些核心语法知识。在介绍这些语法知识之前,我们先来看下如何使用Makefile脚本。 + +Makefile的使用方法 + +在实际使用过程中,我们一般是先编写一个Makefile文件,指定整个项目的编译规则,然后通过Linux make命令来解析该Makefile文件,实现项目编译、管理的自动化。 + +默认情况下,make命令会在当前目录下,按照GNUmakefile、makefile、Makefile文件的顺序查找Makefile文件,一旦找到,就开始读取这个文件并执行。 + +大多数的make都支持“makefile”和“Makefile”这两种文件名,但我建议使用“Makefile”。因为这个文件名第一个字符大写,会很明显,容易辨别。make也支持 -f 和 --file 参数来指定其他文件名,比如 make -f golang.mk 或者 make --file golang.mk 。 + +Makefile规则介绍 + +学习Makefile,最核心的就是学习Makefile的规则。规则是Makefile中的重要概念,它一般由目标、依赖和命令组成,用来指定源文件编译的先后顺序。Makefile之所以受欢迎,核心原因就是Makefile规则,因为Makefile规则可以自动判断是否需要重新编译某个目标,从而确保目标仅在需要时编译。 + +这一讲我们主要来看Makefile规则里的规则语法、伪目标和order-only依赖。 + +规则语法 + +Makefile的规则语法,主要包括target、prerequisites和command,示例如下: + +target ...: prerequisites ... + command + ... + ... + + +target,可以是一个object file(目标文件),也可以是一个执行文件,还可以是一个标签(label)。target可使用通配符,当有多个目标时,目标之间用空格分隔。 + +prerequisites,代表生成该target所需要的依赖项。当有多个依赖项时,依赖项之间用空格分隔。 + +command,代表该target要执行的命令(可以是任意的shell命令)。 + + +在执行command之前,默认会先打印出该命令,然后再输出命令的结果;如果不想打印出命令,可在各个command前加上@。 +command可以为多条,也可以分行写,但每行都要以tab键开始。另外,如果后一条命令依赖前一条命令,则这两条命令需要写在同一行,并用分号进行分隔。 +如果要忽略命令的出错,需要在各个command之前加上减号-。 + + +只要targets不存在,或prerequisites中有一个以上的文件比targets文件新,那么command所定义的命令就会被执行,从而产生我们需要的文件,或执行我们期望的操作。 + +我们直接通过一个例子来理解下Makefile的规则吧。 + +第一步,先编写一个hello.c文件。 + +#include +int main() +{ + printf("Hello World!\n"); + return 0; +} + + +第二步,在当前目录下,编写Makefile文件。 + +hello: hello.o + gcc -o hello hello.o + +hello.o: hello.c + gcc -c hello.c + +clean: + rm hello.o + + +第三步,执行make,产生可执行文件。 + +$ make +gcc -c hello.c +gcc -o hello hello.o +$ ls +hello hello.c hello.o Makefile + + +上面的示例Makefile文件有两个target,分别是hello和hello.o,每个target都指定了构建command。当执行make命令时,发现hello、hello.o文件不存在,就会执行command命令生成target。 + +第四步,不更新任何文件,再次执行make。 + +$ make +make: 'hello' is up to date. + + +当target存在,并且prerequisites都不比target新时,不会执行对应的command。 + +第五步,更新hello.c,并再次执行make。 + +$ touch hello.c +$ make +gcc -c hello.c +gcc -o hello hello.o + + +当target存在,但 prerequisites 比 target 新时,会重新执行对应的command。 + +第六步,清理编译中间文件。 + +Makefile一般都会有一个clean伪目标,用来清理编译中间产物,或者对源码目录做一些定制化的清理: + +$ make clean +rm hello.o + + +我们可以在规则中使用通配符,make 支持三个通配符:*,?和~,例如: + +objects = *.o +print: *.c + rm *.c + + +伪目标 + +接下来我们介绍下Makefile中的伪目标。Makefile的管理能力基本上都是通过伪目标来实现的。 + +在上面的Makefile示例中,我们定义了一个clean目标,这其实是一个伪目标,也就是说我们不会为该目标生成任何文件。因为伪目标不是文件,make 无法生成它的依赖关系,也无法决定是否要执行它。 + +通常情况下,我们需要显式地标识这个目标为伪目标。在Makefile中可以使用.PHONY来标识一个目标为伪目标: + +.PHONY: clean +clean: + rm hello.o + + +伪目标可以有依赖文件,也可以作为“默认目标”,例如: + +.PHONY: all +all: lint test build + + +因为伪目标总是会被执行,所以其依赖总是会被决议。通过这种方式,可以达到同时执行所有依赖项的目的。 + +order-only依赖 + +在上面介绍的规则中,只要prerequisites中有任何文件发生改变,就会重新构造target。但是有时候,我们希望只有当prerequisites中的部分文件改变时,才重新构造target。这时,你可以通过order-only prerequisites实现。 + +order-only prerequisites的形式如下: + +targets : normal-prerequisites | order-only-prerequisites + command + ... + ... + + +在上面的规则中,只有第一次构造targets时,才会使用order-only-prerequisites。后面即使order-only-prerequisites发生改变,也不会重新构造targets。 + +只有normal-prerequisites中的文件发生改变时,才会重新构造targets。这里,符号“ | ”后面的prerequisites就是order-only-prerequisites。 + +到这里,我们就介绍了Makefile的规则。接下来,我们再来看下Makefile中的一些核心语法知识。 + +Makefile语法概览 + +因为Makefile的语法比较多,这一讲只介绍Makefile的核心语法,以及 IAM项目的Makefile用到的语法,包括命令、变量、条件语句和函数。因为Makefile没有太多复杂的语法,你掌握了这些知识点之后,再在实践中多加运用,融会贯通,就可以写出非常复杂、功能强大的Makefile文件了。 + +命令 + +Makefile支持Linux命令,调用方式跟在Linux系统下调用命令的方式基本一致。默认情况下,make会把正在执行的命令输出到当前屏幕上。但我们可以通过在命令前加@符号的方式,禁止make输出当前正在执行的命令。 + +我们看一个例子。现在有这么一个Makefile: + +.PHONY: test +test: + echo "hello world" + + +执行make命令: + +$ make test +echo "hello world" +hello world + + +可以看到,make输出了执行的命令。很多时候,我们不需要这样的提示,因为我们更想看的是命令产生的日志,而不是执行的命令。这时就可以在命令行前加@,禁止make输出所执行的命令: + +.PHONY: test +test: + @echo "hello world" + + +再次执行make命令: + +$ make test +hello world + + +可以看到,make只是执行了命令,而没有打印命令本身。这样make输出就清晰了很多。 + +这里,我建议在命令前都加@符号,禁止打印命令本身,以保证你的Makefile输出易于阅读的、有用的信息。 + +默认情况下,每条命令执行完make就会检查其返回码。如果返回成功(返回码为0),make就执行下一条指令;如果返回失败(返回码非0),make就会终止当前命令。很多时候,命令出错(比如删除了一个不存在的文件)时,我们并不想终止,这时就可以在命令行前加 - 符号,来让make忽略命令的出错,以继续执行下一条命令,比如: + +clean: + -rm hello.o + + +变量 + +变量,可能是Makefile中使用最频繁的语法了,Makefile支持变量赋值、多行变量和环境变量。另外,Makefile还内置了一些特殊变量和自动化变量。 + +我们先来看下最基本的变量赋值功能。 + +Makefile也可以像其他语言一样支持变量。在使用变量时,会像shell变量一样原地展开,然后再执行替换后的内容。 + +Makefile可以通过变量声明来声明一个变量,变量在声明时需要赋予一个初值,比如ROOT_PACKAGE=github.com/marmotedu/iam。 + +引用变量时可以通过$()或者${}方式引用。我的建议是,用$()方式引用变量,例如$(ROOT_PACKAGE),也建议整个makefile的变量引用方式保持一致。 + +变量会像bash变量一样,在使用它的地方展开。比如: + +GO=go +build: + $(GO) build -v . + + +展开后为: + +GO=go +build: + go build -v . + + +接下来,我给你介绍下Makefile中的4种变量赋值方法。 + + += 最基本的赋值方法。 + + +例如: + +BASE_IMAGE = alpine:3.10 + + +使用 = 进行赋值时,要注意下面这样的情况: + +A = a +B = $(A) b +A = c + + +B最后的值为 c b,而不是a b。也就是说,在用变量给变量赋值时,右边变量的取值,取的是最终的变量值。 + + +:=直接赋值,赋予当前位置的值。 + + +例如: + +A = a +B := $(A) b +A = c + + +B最后的值为 a b。通过 := 的赋值方式,可以避免 = 赋值带来的潜在的不一致。 + + +?= 表示如果该变量没有被赋值,则赋予等号后的值。 + + +例如: + +PLATFORMS ?= linux_amd64 linux_arm64 + + + ++=表示将等号后面的值添加到前面的变量上。 + + +例如: + +MAKEFLAGS += --no-print-directory + + +Makefile还支持多行变量。可以通过define关键字设置多行变量,变量中允许换行。定义方式为: + +define 变量名 +变量内容 +... +endef + + +变量的内容可以包含函数、命令、文字或是其他变量。例如,我们可以定义一个USAGE_OPTIONS变量: + +define USAGE_OPTIONS + +Options: + DEBUG Whether to generate debug symbols. Default is 0. + BINS The binaries to build. Default is all of cmd. + ... + V Set to 1 enable verbose build. Default is 0. +endef + + +Makefile还支持环境变量。在Makefile中,有两种环境变量,分别是Makefile预定义的环境变量和自定义的环境变量。 + +其中,自定义的环境变量可以覆盖Makefile预定义的环境变量。默认情况下,Makefile中定义的环境变量只在当前Makefile有效,如果想向下层传递(Makefile中调用另一个Makefile),需要使用export关键字来声明。 + +下面的例子声明了一个环境变量,并可以在下层Makefile中使用: + +... +export USAGE_OPTIONS +... + + +此外,Makefile还支持两种内置的变量:特殊变量和自动化变量。 + +特殊变量是make提前定义好的,可以在makefile中直接引用。特殊变量列表如下: + + + +Makefile还支持自动化变量。自动化变量可以提高我们编写Makefile的效率和质量。 + +在Makefile的模式规则中,目标和依赖文件都是一系列的文件,那么我们如何书写一个命令,来完成从不同的依赖文件生成相对应的目标呢? + +这时就可以用到自动化变量。所谓自动化变量,就是这种变量会把模式中所定义的一系列的文件自动地挨个取出,一直到所有符合模式的文件都取完为止。这种自动化变量只应出现在规则的命令中。Makefile中支持的自动化变量见下表。 + + + +上面这些自动化变量中,$*是用得最多的。$* 对于构造有关联的文件名是比较有效的。如果目标中没有模式的定义,那么 $* 也就不能被推导出。但是,如果目标文件的后缀是make所识别的,那么 $* 就是除了后缀的那一部分。例如:如果目标是foo.c ,因为.c是make所能识别的后缀名,所以 $* 的值就是foo。 + +条件语句 + +Makefile也支持条件语句。这里先看一个示例。 + +下面的例子判断变量ROOT_PACKAGE是否为空,如果为空,则输出错误信息,不为空则打印变量值: + +ifeq ($(ROOT_PACKAGE),) +$(error the variable ROOT_PACKAGE must be set prior to including golang.mk) +else +$(info the value of ROOT_PACKAGE is $(ROOT_PACKAGE)) +endif + + +条件语句的语法为: + +# if ... + + +endif +# if ... else ... + + +else + +endif + + +例如,判断两个值是否相等: + +ifeq 条件表达式 +... +else +... +endif + + + +ifeq表示条件语句的开始,并指定一个条件表达式。表达式包含两个参数,参数之间用逗号分隔,并且表达式用圆括号括起来。 +else表示条件表达式为假的情况。 +endif表示一个条件语句的结束,任何一个条件表达式都应该以endif结束。 +表示条件关键字,有4个关键字:ifeq、ifneq、ifdef、ifndef。 + + +为了加深你的理解,我们分别来看下这4个关键字的例子。 + + +ifeq:条件判断,判断是否相等。 + + +例如: + +ifeq (, ) +ifeq '' '' +ifeq "" "" +ifeq "" '' +ifeq '' "" + + +比较arg1和arg2的值是否相同,如果相同则为真。也可以用make函数/变量替代arg1或arg2,例如 ifeq ($(origin ROOT_DIR),undefined) 或 ifeq ($(ROOT_PACKAGE),) 。origin函数会在之后专门讲函数的一讲中介绍到。 + + +ifneq:条件判断,判断是否不相等。 + + +ifneq (, ) +ifneq '' '' +ifneq "" "" +ifneq "" '' +ifneq '' "" + + +比较arg1和arg2的值是否不同,如果不同则为真。 + + +ifdef:条件判断,判断变量是否已定义。 + + +ifdef + + +如果 值非空,则表达式为真,否则为假。 也可以是函数的返回值。 + + +ifndef:条件判断,判断变量是否未定义。 + + +ifndef + + +如果 值为空,则表达式为真,否则为假。 也可以是函数的返回值。 + +函数 + +Makefile同样也支持函数,函数语法包括定义语法和调用语法。 + +我们先来看下自定义函数。 make解释器提供了一系列的函数供Makefile调用,这些函数是Makefile的预定义函数。我们可以通过define关键字来自定义一个函数。自定义函数的语法为: + +define 函数名 +函数体 +endef + + +例如,下面这个自定义函数: + +define Foo + @echo "my name is $(0)" + @echo "param is $(1)" +endef + + +define本质上是定义一个多行变量,可以在call的作用下当作函数来使用,在其他位置使用只能作为多行变量来使用,例如: + +var := $(call Foo) +new := $(Foo) + + +自定义函数是一种过程调用,没有任何的返回值。可以使用自定义函数来定义命令的集合,并应用在规则中。 + +再来看下预定义函数。 刚才提到,make编译器也定义了很多函数,这些函数叫作预定义函数,调用语法和变量类似,语法为: + +$( ) + + +或者 + +${ } + + +是函数名,是函数参数,参数间用逗号分割。函数的参数也可以是变量。 + +我们来看一个例子: + +PLATFORM = linux_amd64 +GOOS := $(word 1, $(subst _, ,$(PLATFORM))) + + +上面的例子用到了两个函数:word和subst。word函数有两个参数,1和subst函数的输出。subst函数将PLATFORM变量值中的_替换成空格(替换后的PLATFORM值为linux amd64)。word函数取linux amd64字符串中的第一个单词。所以最后GOOS的值为linux。 + +Makefile预定义函数能够帮助我们实现很多强大的功能,在编写Makefile的过程中,如果有功能需求,可以优先使用这些函数。如果你想使用这些函数,那就需要知道有哪些函数,以及它们实现的功能。 + +常用的函数包括下面这些,你需要先有个印象,以后用到时再来查看。 + + + +引入其他Makefile + +除了Makefile规则、Makefile语法之外,Makefile还有很多特性,比如可以引入其他Makefile、自动生成依赖关系、文件搜索等等。这里我再介绍一个IAM项目的Makefile用到的重点特性:引入其他Makefile。 + +在 14讲 中,我们介绍过Makefile要结构化、层次化,这一点可以通过在项目根目录下的Makefile中引入其他Makefile来实现。 + +在Makefile中,我们可以通过关键字include,把别的makefile包含进来,类似于C语言的#include,被包含的文件会插入在当前的位置。include用法为include ,示例如下: + +include scripts/make-rules/common.mk +include scripts/make-rules/golang.mk + + +include也可以包含通配符include scripts/make-rules/*。make命令会按下面的顺序查找makefile文件: + + +如果是绝对或相对路径,就直接根据路径include进来。 +如果make执行时,有-I或--include-dir参数,那么make就会在这个参数所指定的目录下去找。 +如果目录/include(一般是/usr/local/bin或/usr/include)存在的话,make也会去找。 + + +如果有文件没有找到,make会生成一条警告信息,但不会马上出现致命错误,而是继续载入其他的文件。一旦完成makefile的读取,make会再重试这些没有找到或是不能读取的文件。如果还是不行,make才会出现一条致命错误信息。如果你想让make忽略那些无法读取的文件继续执行,可以在include前加一个减号-,如-include 。 + +总结 + +在这一讲里,为了帮助你编写一个高质量的Makefile,我重点介绍了Makefile规则和Makefile语法里的一些核心语法知识。 + +在讲Makefile规则时,我们主要学习了规则语法、伪目标和order-only依赖。掌握了这些Makefile规则,你就掌握了Makefile中最核心的内容。 + +在介绍Makefile的语法时,我只介绍了Makefile的核心语法,以及 IAM项目的Makefile用到的语法,包括命令、变量、条件语句和函数。你可能会觉得这些语法学习起来比较枯燥,但还是那句话,工欲善其事,必先利其器。希望你能熟练掌握Makefile的核心语法,为编写高质量的Makefile打好基础。 + +今天的内容就到这里啦,欢迎你在下面的留言区谈谈自己的看法,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/特别放送给你一份清晰、可直接套用的Go编码规范.md b/专栏/Go语言项目开发实战/特别放送给你一份清晰、可直接套用的Go编码规范.md new file mode 100644 index 0000000..c9f34c0 --- /dev/null +++ b/专栏/Go语言项目开发实战/特别放送给你一份清晰、可直接套用的Go编码规范.md @@ -0,0 +1,1039 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 特别放送 给你一份清晰、可直接套用的Go编码规范 + 你好,我是孔令飞。 + +我们在上一讲学习了“写出优雅Go项目的方法论”,那一讲内容很丰富,是我多年Go项目开发的经验沉淀,需要你多花一些时间好好消化吸收。吃完大餐之后,咱们今天来一期特别放送,就是上一讲我提到过的编码规范。这一讲里,为了帮你节省时间和精力,我会给你一份清晰、可直接套用的 Go 编码规范,帮助你编写一个高质量的 Go 应用。 + +这份规范,是我参考了Go官方提供的编码规范,以及Go社区沉淀的一些比较合理的规范之后,加入自己的理解总结出的,它比很多公司内部的规范更全面,你掌握了,以后在面试大厂的时候,或者在大厂里写代码的时候,都会让人高看你一眼,觉得你code很专业。 + +这份编码规范中包含代码风格、命名规范、注释规范、类型、控制结构、函数、GOPATH 设置规范、依赖管理和最佳实践九类规范。如果你觉得这些规范内容太多了,看完一遍也记不住,这完全没关系。你可以多看几遍,也可以在用到时把它翻出来,在实际应用中掌握。这篇特别放送的内容,更多是作为写代码时候的一个参考手册。 + +1. 代码风格 + +1.1 代码格式 + + +代码都必须用 gofmt 进行格式化。 + +运算符和操作数之间要留空格。 + +建议一行代码不超过120个字符,超过部分,请采用合适的换行方式换行。但也有些例外场景,例如import行、工具自动生成的代码、带tag的struct字段。 + +文件长度不能超过800行。 + +函数长度不能超过80行。 + +import规范 + + +代码都必须用 goimports进行格式化(建议将代码Go代码编辑器设置为:保存时运行 goimports)。- +- 不要使用相对路径引入包,例如 import …/util/net 。- +- 包名称与导入路径的最后一个目录名不匹配时,或者多个相同包名冲突时,则必须使用导入别名。 + + + +// bad + "github.com/dgrijalva/jwt-go/v4" + + //good + jwt "github.com/dgrijalva/jwt-go/v4" + + + +* 导入的包建议进行分组,匿名包的引用使用一个新的分组,并对匿名包引用进行说明。 + + + import ( + // go 标准包 + "fmt" + + // 第三方包 + "github.com/jinzhu/gorm" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + // 匿名包单独分组,并对匿名包引用进行说明 + // import mysql driver + _ "github.com/jinzhu/gorm/dialects/mysql" + + // 内部包 + v1 "github.com/marmotedu/api/apiserver/v1" + metav1 "github.com/marmotedu/apimachinery/pkg/meta/v1" + "github.com/marmotedu/iam/pkg/cli/genericclioptions" + ) + + +1.2 声明、初始化和定义 + + +当函数中需要使用到多个变量时,可以在函数开始处使用var声明。在函数外部声明必须使用 var ,不要采用 := ,容易踩到变量的作用域的问题。 + + +var ( + Width int + Height int +) + + + +在初始化结构引用时,请使用&T{}代替new(T),以使其与结构体初始化一致。 + + +// bad +sptr := new(T) +sptr.Name = "bar" + +// good +sptr := &T{Name: "bar"} + + + +struct 声明和初始化格式采用多行,定义如下。 + + +type User struct{ + Username string + Email string +} + +user := User{ + Username: "colin", + Email: "[email protected]", +} + + + +相似的声明放在一组,同样适用于常量、变量和类型声明。 + + +// bad +import "a" +import "b" + +// good +import ( + "a" + "b" +) + + + +尽可能指定容器容量,以便为容器预先分配内存,例如: + + +v := make(map[int]string, 4) +v := make([]string, 0, 4) + + + +在顶层,使用标准var关键字。请勿指定类型,除非它与表达式的类型不同。 + + +// bad +var _s string = F() + +func F() string { return "A" } + +// good +var _s = F() +// 由于 F 已经明确了返回一个字符串类型,因此我们没有必要显式指定_s 的类型 +// 还是那种类型 + +func F() string { return "A" } + + + +对于未导出的顶层常量和变量,使用_作为前缀。 + + +// bad +const ( + defaultHost = "127.0.0.1" + defaultPort = 8080 +) + +// good +const ( + _defaultHost = "127.0.0.1" + _defaultPort = 8080 +) + + + +嵌入式类型(例如 mutex)应位于结构体内的字段列表的顶部,并且必须有一个空行将嵌入式字段与常规字段分隔开。 + + +// bad +type Client struct { + version int + http.Client +} + +// good +type Client struct { + http.Client + + version int +} + + +1.3 错误处理 + + +error作为函数的值返回,必须对error进行处理,或将返回值赋值给明确忽略。对于defer xx.Close()可以不用显式处理。 + + +func load() error { + // normal code +} + +// bad +load() + +// good + _ = load() + + + +error作为函数的值返回且有多个返回值的时候,error必须是最后一个参数。 + + +// bad +func load() (error, int) { + // normal code +} + +// good +func load() (int, error) { + // normal code +} + + + +尽早进行错误处理,并尽早返回,减少嵌套。 + + +// bad +if err != nil { + // error code +} else { + // normal code +} + +// good +if err != nil { + // error handling + return err +} +// normal code + + + +如果需要在 if 之外使用函数调用的结果,则应采用下面的方式。 + + +// bad +if v, err := foo(); err != nil { + // error handling +} + +// good +v, err := foo() +if err != nil { + // error handling +} + + + +错误要单独判断,不与其他逻辑组合判断。 + + +// bad +v, err := foo() +if err != nil || v == nil { + // error handling + return err +} + +// good +v, err := foo() +if err != nil { + // error handling + return err +} + +if v == nil { + // error handling + return errors.New("invalid value v") +} + + + +如果返回值需要初始化,则采用下面的方式。 + + +v, err := f() +if err != nil { + // error handling + return // or continue. +} +// use v + + + +错误描述建议 + + +告诉用户他们可以做什么,而不是告诉他们不能做什么。 +当声明一个需求时,用must 而不是should。例如,must be greater than 0、must match regex ‘[a-z]+’。 +当声明一个格式不对时,用must not。例如,must not contain。 +当声明一个动作时用may not。例如,may not be specified when otherField is empty、only name may be specified。 +引用文字字符串值时,请在单引号中指示文字。例如,ust not contain ‘…’。 +当引用另一个字段名称时,请在反引号中指定该名称。例如,must be greater than request。 +指定不等时,请使用单词而不是符号。例如,must be less than 256、must be greater than or equal to 0 (不要用 larger than、bigger than、more than、higher than)。 +指定数字范围时,请尽可能使用包含范围。 +建议 Go 1.13 以上,error 生成方式为 fmt.Errorf("module xxx: %w", err)。 +错误描述用小写字母开头,结尾不要加标点符号,例如: + + + + // bad + errors.New("Redis connection failed") + errors.New("redis connection failed.") + + // good + errors.New("redis connection failed") + + +1.4 panic处理 + + +在业务逻辑处理中禁止使用panic。 +在main包中,只有当程序完全不可运行时使用panic,例如无法打开文件、无法连接数据库导致程序无法正常运行。 +在main包中,使用 log.Fatal 来记录错误,这样就可以由log来结束程序,或者将panic抛出的异常记录到日志文件中,方便排查问题。 +可导出的接口一定不能有panic。 +包内建议采用error而不是panic来传递错误。 + + +1.5 单元测试 + + +单元测试文件名命名规范为 example_test.go。 +每个重要的可导出函数都要编写测试用例。 +因为单元测试文件内的函数都是不对外的,所以可导出的结构体、函数等可以不带注释。 +如果存在 func (b *Bar) Foo ,单测函数可以为 func TestBar_Foo。 + + +1.6 类型断言失败处理 + +type assertion 的单个返回值针对不正确的类型将产生 panic。请始终使用 “comma ok”的惯用法。 + +// bad +t := n.(int) + +// good +t, ok := n.(int) +if !ok { + // error handling +} +// normal code + + +2. 命名规范 + +命名规范是代码规范中非常重要的一部分,一个统一的、短小的、精确的命名规范可以大大提高代码的可读性,也可以借此规避一些不必要的Bug。 + +2.1 包命名 + + +包名必须和目录名一致,尽量采取有意义、简短的包名,不要和标准库冲突。 +包名全部小写,没有大写或下划线,使用多级目录来划分层级。 +项目名可以通过中划线来连接多个单词。 +包名以及包所在的目录名,不要使用复数,例如,是net/url,而不是net/urls。 +不要用 common、util、shared 或者 lib 这类宽泛的、无意义的包名。 +包名要简单明了,例如 net、time、log。 + + +2.2 函数命名 + + +函数名采用驼峰式,首字母根据访问控制决定使用大写或小写,例如:MixedCaps或者mixedCaps。 +代码生成工具自动生成的代码(如xxxx.pb.go)和为了对相关测试用例进行分组,而采用的下划线(如TestMyFunction_WhatIsBeingTested)排除此规则。 + + +2.3 文件命名 + + +文件名要简短有意义。 +文件名应小写,并使用下划线分割单词。 + + +2.4 结构体命名 + + +采用驼峰命名方式,首字母根据访问控制决定使用大写或小写,例如MixedCaps或者mixedCaps。 +结构体名不应该是动词,应该是名词,比如 Node、NodeSpec。 +避免使用Data、Info这类无意义的结构体名。 +结构体的声明和初始化应采用多行,例如: + + +// User 多行声明 +type User struct { + Name string + Email string +} + +// 多行初始化 +u := User{ + UserName: "colin", + Email: "[email protected]", +} + + +2.5 接口命名 + + +接口命名的规则,基本和结构体命名规则保持一致: + + +单个函数的接口名以 “er””作为后缀(例如Reader,Writer),有时候可能导致蹩脚的英文,但是没关系。 +两个函数的接口名以两个函数名命名,例如ReadWriter。 +三个以上函数的接口名,类似于结构体名。 + + + +例如: + + // Seeking to an offset before the start of the file is an error. + // Seeking to any positive offset is legal, but the behavior of subsequent + // I/O operations on the underlying object is implementation-dependent. + type Seeker interface { + Seek(offset int64, whence int) (int64, error) + } + + // ReadWriter is the interface that groups the basic Read and Write methods. + type ReadWriter interface { + Reader + Writer + } + + +2.6 变量命名 + + +变量名必须遵循驼峰式,首字母根据访问控制决定使用大写或小写。 + +在相对简单(对象数量少、针对性强)的环境中,可以将一些名称由完整单词简写为单个字母,例如: + + +user 可以简写为 u; +userID 可以简写 uid。 + + +特有名词时,需要遵循以下规则: + + +如果变量为私有,且特有名词为首个单词,则使用小写,如 apiClient。 +其他情况都应当使用该名词原有的写法,如 APIClient、repoID、UserID。 + + + +下面列举了一些常见的特有名词。 + +// A GonicMapper that contains a list of common initialisms taken from golang/lint +var LintGonicMapper = GonicMapper{ + "API": true, + "ASCII": true, + "CPU": true, + "CSS": true, + "DNS": true, + "EOF": true, + "GUID": true, + "HTML": true, + "HTTP": true, + "HTTPS": true, + "ID": true, + "IP": true, + "JSON": true, + "LHS": true, + "QPS": true, + "RAM": true, + "RHS": true, + "RPC": true, + "SLA": true, + "SMTP": true, + "SSH": true, + "TLS": true, + "TTL": true, + "UI": true, + "UID": true, + "UUID": true, + "URI": true, + "URL": true, + "UTF8": true, + "VM": true, + "XML": true, + "XSRF": true, + "XSS": true, +} + + + +若变量类型为bool类型,则名称应以Has,Is,Can或Allow开头,例如: + + +var hasConflict bool +var isExist bool +var canManage bool +var allowGitHook bool + + + +局部变量应当尽可能短小,比如使用buf指代buffer,使用i指代index。 +代码生成工具自动生成的代码可排除此规则(如xxx.pb.go里面的Id) + + +2.7 常量命名 + + +常量名必须遵循驼峰式,首字母根据访问控制决定使用大写或小写。 +如果是枚举类型的常量,需要先创建相应类型: + + +// Code defines an error code type. +type Code int + +// Internal errors. +const ( + // ErrUnknown - 0: An unknown error occurred. + ErrUnknown Code = iota + // ErrFatal - 1: An fatal error occurred. + ErrFatal +) + + +2.8 Error的命名 + + +Error类型应该写成FooError的形式。 + + +type ExitError struct { + // .... +} + + + +Error变量写成ErrFoo的形式。 + + +var ErrFormat = errors.New("unknown format") + + +3. 注释规范 + + +每个可导出的名字都要有注释,该注释对导出的变量、函数、结构体、接口等进行简要介绍。 +全部使用单行注释,禁止使用多行注释。 +和代码的规范一样,单行注释不要过长,禁止超过 120 字符,超过的请使用换行展示,尽量保持格式优雅。 +注释必须是完整的句子,以需要注释的内容作为开头,句点作为结尾,格式为 // 名称 描述. 。例如: + + +// bad +// logs the flags in the flagset. +func PrintFlags(flags *pflag.FlagSet) { + // normal code +} + +// good +// PrintFlags logs the flags in the flagset. +func PrintFlags(flags *pflag.FlagSet) { + // normal code +} + + + +所有注释掉的代码在提交code review前都应该被删除,否则应该说明为什么不删除,并给出后续处理建议。 + +在多段注释之间可以使用空行分隔加以区分,如下所示: + + +// Package superman implements methods for saving the world. +// +// Experience has shown that a small number of procedures can prove +// helpful when attempting to save the world. +package superman + + +3.1 包注释 + + +每个包都有且仅有一个包级别的注释。 +包注释统一用 // 进行注释,格式为 // Package 包名 包描述 ,例如: + + +// Package genericclioptions contains flags which can be added to you command, bound, completed, and produce +// useful helper functions. +package genericclioptions + + +3.2 变量/常量注释 + + +每个可导出的变量/常量都必须有注释说明,格式为// 变量名 变量描述,例如: + + +// ErrSigningMethod defines invalid signing method error. +var ErrSigningMethod = errors.New("Invalid signing method") + + + +出现大块常量或变量定义时,可在前面注释一个总的说明,然后在每一行常量的前一行或末尾详细注释该常量的定义,例如: + + +// Code must start with 1xxxxx. +const ( + // ErrSuccess - 200: OK. + ErrSuccess int = iota + 100001 + + // ErrUnknown - 500: Internal server error. + ErrUnknown + + // ErrBind - 400: Error occurred while binding the request body to the struct. + ErrBind + + // ErrValidation - 400: Validation failed. + ErrValidation +) + + +3.3 结构体注释 + + +每个需要导出的结构体或者接口都必须有注释说明,格式为 // 结构体名 结构体描述.。 +结构体内的可导出成员变量名,如果意义不明确,必须要给出注释,放在成员变量的前一行或同一行的末尾。例如: + + +// User represents a user restful resource. It is also used as gorm model. +type User struct { + // Standard object's metadata. + metav1.ObjectMeta `json:"metadata,omitempty"` + + Nickname string `json:"nickname" gorm:"column:nickname"` + Password string `json:"password" gorm:"column:password"` + Email string `json:"email" gorm:"column:email"` + Phone string `json:"phone" gorm:"column:phone"` + IsAdmin int `json:"isAdmin,omitempty" gorm:"column:isAdmin"` +} + + +3.4 方法注释 + + +每个需要导出的函数或者方法都必须有注释,格式为// 函数名 函数描述.,例如: + + +// BeforeUpdate run before update database record. +func (p *Policy) BeforeUpdate() (err error) { + // normal code + return nil +} + + +3.5 类型注释 + + +每个需要导出的类型定义和类型别名都必须有注释说明,格式为 // 类型名 类型描述. ,例如: + + +// Code defines an error code type. +type Code int + + +4. 类型 + +4.1 字符串 + + +空字符串判断。 + + +// bad +if s == "" { + // normal code +} + +// good +if len(s) == 0 { + // normal code +} + + + +[]byte/string相等比较。 + + +// bad +var s1 []byte +var s2 []byte +... +bytes.Equal(s1, s2) == 0 +bytes.Equal(s1, s2) != 0 + +// good +var s1 []byte +var s2 []byte +... +bytes.Compare(s1, s2) == 0 +bytes.Compare(s1, s2) != 0 + + + +复杂字符串使用raw字符串避免字符转义。 + + +// bad +regexp.MustCompile("\\.") + +// good +regexp.MustCompile(`\.`) + + +4.2 切片 + + +空slice判断。 + + +// bad +if len(slice) = 0 { + // normal code +} + +// good +if slice != nil && len(slice) == 0 { + // normal code +} + + +上面判断同样适用于map、channel。 + + +声明slice。 + + +// bad +s := []string{} +s := make([]string, 0) + +// good +var s []string + + + +slice复制。 + + +// bad +var b1, b2 []byte +for i, v := range b1 { + b2[i] = v +} +for i := range b1 { + b2[i] = b1[i] +} + +// good +copy(b2, b1) + + + +slice新增。 + + +// bad +var a, b []int +for _, v := range a { + b = append(b, v) +} + +// good +var a, b []int +b = append(b, a...) + + +4.3 结构体 + + +struct初始化。 + + +struct以多行格式初始化。 + +type user struct { + Id int64 + Name string +} + +u1 := user{100, "Colin"} + +u2 := user{ + Id: 200, + Name: "Lex", +} + + +5. 控制结构 + +5.1 if + + +if 接受初始化语句,约定如下方式建立局部变量。 + + +if err := loadConfig(); err != nil { + // error handling + return err +} + + + +if 对于bool类型的变量,应直接进行真假判断。 + + +var isAllow bool +if isAllow { + // normal code +} + + +5.2 for + + +采用短声明建立局部变量。 + + +sum := 0 +for i := 0; i < 10; i++ { + sum += 1 +} + + + +不要在 for 循环里面使用 defer,defer只有在函数退出时才会执行。 + + +// bad +for file := range files { + fd, err := os.Open(file) + if err != nil { + return err + } + defer fd.Close() + // normal code +} + +// good +for file := range files { + func() { + fd, err := os.Open(file) + if err != nil { + return err + } + defer fd.Close() + // normal code + }() +} + + +5.3 range + + +如果只需要第一项(key),就丢弃第二个。 + + +for key := range keys { +// normal code +} + + + +如果只需要第二项,则把第一项置为下划线。 + + +sum := 0 +for _, value := range array { + sum += value +} + + +5.4 switch + + +必须要有default。 + + +switch os := runtime.GOOS; os { + case "linux": + fmt.Println("Linux.") + case "darwin": + fmt.Println("OS X.") + default: + fmt.Printf("%s.\n", os) +} + + +5.5 goto + + +业务代码禁止使用 goto 。 +框架或其他底层源码尽量不用。 + + +6. 函数 + + +传入变量和返回变量以小写字母开头。 + +函数参数个数不能超过5个。 + +函数分组与顺序 + + +函数应按粗略的调用顺序排序。 +同一文件中的函数应按接收者分组。 + + +尽量采用值传递,而非指针传递。 + +传入参数是 map、slice、chan、interface ,不要传递指针。 + + +6.1 函数参数 + + +如果函数返回相同类型的两个或三个参数,或者如果从上下文中不清楚结果的含义,使用命名返回,其他情况不建议使用命名返回,例如: + + +func coordinate() (x, y float64, err error) { + // normal code +} + + + +传入变量和返回变量都以小写字母开头。 +尽量用值传递,非指针传递。 +参数数量均不能超过5个。 +多返回值最多返回三个,超过三个请使用 struct。 + + +6.2 defer + + +当存在资源创建时,应紧跟defer释放资源(可以大胆使用defer,defer在Go1.14版本中,性能大幅提升,defer的性能损耗即使在性能敏感型的业务中,也可以忽略)。 +先判断是否错误,再defer释放资源,例如: + + +rep, err := http.Get(url) +if err != nil { + return err +} + +defer resp.Body.Close() + + +6.3 方法的接收器 + + +推荐以类名第一个英文首字母的小写作为接收器的命名。 +接收器的命名在函数超过20行的时候不要用单字符。 +接收器的命名不能采用me、this、self这类易混淆名称。 + + +6.4 嵌套 + + +嵌套深度不能超过4层。 + + +6.5 变量命名 + + +变量声明尽量放在变量第一次使用的前面,遵循就近原则。 +如果魔法数字出现超过两次,则禁止使用,改用一个常量代替,例如: + + +// PI ... +const Prise = 3.14 + +func getAppleCost(n float64) float64 { + return Prise * n +} + +func getOrangeCost(n float64) float64 { + return Prise * n +} + + +7. GOPATH 设置规范 + + +Go 1.11 之后,弱化了 GOPATH 规则,已有代码(很多库肯定是在1.11之前建立的)肯定符合这个规则,建议保留 GOPATH 规则,便于维护代码。 +建议只使用一个 GOPATH,不建议使用多个 GOPATH。如果使用多个GOPATH,编译生效的 bin 目录是在第一个 GOPATH 下。 + + +8. 依赖管理 + + +Go 1.11 以上必须使用 Go Modules。 +使用Go Modules作为依赖管理的项目时,不建议提交vendor目录。 +使用Go Modules作为依赖管理的项目时,必须提交go.sum文件。 + + +9. 最佳实践 + + +尽量少用全局变量,而是通过参数传递,使每个函数都是“无状态”的。这样可以减少耦合,也方便分工和单元测试。 +在编译时验证接口的符合性,例如: + + +type LogHandler struct { + h http.Handler + log *zap.Logger +} +var _ http.Handler = LogHandler{} + + + +服务器处理请求时,应该创建一个context,保存该请求的相关信息(如requestID),并在函数调用链中传递。 + + +9.1 性能 + + +string 表示的是不可变的字符串变量,对 string 的修改是比较重的操作,基本上都需要重新申请内存。所以,如果没有特殊需要,需要修改时多使用 []byte。 +优先使用 strconv 而不是 fmt。 + + +9.2 注意事项 + + +append 要小心自动分配内存,append 返回的可能是新分配的地址。 +如果要直接修改 map 的 value 值,则 value 只能是指针,否则要覆盖原来的值。 +map 在并发中需要加锁。 +编译过程无法检查 interface{} 的转换,只能在运行时检查,小心引起 panic。 + + +总结 + +这一讲,我向你介绍了九类常用的编码规范。但今天的最后,我要在这里提醒你一句:规范是人定的,你也可以根据需要,制定符合你项目的规范。这也是我在之前的课程里一直强调的思路。但同时我也建议你采纳这些业界沉淀下来的规范,并通过工具来确保规范的执行。 + +今天的内容就到这里啦,欢迎你在下面的留言区谈谈自己的看法,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/直播加餐如何从小白进阶成Go语言专家?.md b/专栏/Go语言项目开发实战/直播加餐如何从小白进阶成Go语言专家?.md new file mode 100644 index 0000000..b737ab1 --- /dev/null +++ b/专栏/Go语言项目开发实战/直播加餐如何从小白进阶成Go语言专家?.md @@ -0,0 +1,17 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 直播加餐 如何从小白进阶成 Go 语言专家? + 你好,我是孔令飞,我们又见面了。 + +关于 Go 语言的学习经验和项目实战,我最近又有了一些新的思考和总结。因此,3月31号晚上,我在极客时间做了一场直播,主题是“如何从小白进阶成 Go 语言专家”。直播的回放记录和 PPT 在这里,你可以自行下载查看、回顾。 + +在这场直播中,除了我们这门课里已经涉及的内容,我还分享了从小白到 Go 专家的完整学习路径,以及基于声明式编程范式的软件架构。作为一名具有多年Go项目开发经验的工程师,我将 Go 语言能力由低到高划分为初级、中级、高级、资深、专家 5 个级别。在这次直播中,我分享了每个阶段对应的高效学习方法,帮助你在Go进阶之路上加速前进。并且,我还分享了一种基于声明式编程范式的软件架构,这种软件架构随着Kubernetes的流行,也越来越受欢迎。 + +结课并不意味着结束,我非常高兴能持续把好的内容分享给你,也希望你能继续在留言区与我保持交流,分享你的学习心得和实践经验。 + + + + \ No newline at end of file diff --git a/专栏/Go语言项目开发实战/结束语如何让自己的Go研发之路走得更远?.md b/专栏/Go语言项目开发实战/结束语如何让自己的Go研发之路走得更远?.md new file mode 100644 index 0000000..a648c9b --- /dev/null +++ b/专栏/Go语言项目开发实战/结束语如何让自己的Go研发之路走得更远?.md @@ -0,0 +1,238 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 结束语 如何让自己的 Go 研发之路走得更远? + 你好,我是孔令飞。不知不觉,我们的这段Go学习之旅就要走到终点了。 + +首先,恭喜你耐心看完了整个专栏,相信你一定学到了不少知识。还要特别感谢那些常常向我反馈问题、提出改进建议的同学。因为有你们,这门课才能越来越完善,和你们的交流也让我获益良多。 + +先和你分享下我做这门课的一些“心路历程”。在过去的5年里,我不断学习,从一个Go新手一步步向Go老鸟迈进。在学习的过程中,我遇到了很多困难,也收获了一些心得。因为我是一个热爱分享的人,也特别想把这些困难的解决方法,以及沉淀下来的学习心得分享给更多的人,所以才有了这门课。 + +俗话说,活到老学到老,在Go研发的道路上,我跟你一样仍然是个学生。既然是学生,就有知识盲区,在写专栏的过程中,我很怕因为自己水平不够误导大家。所以,在前期我做足了调研,几乎把专栏中每一个大知识块儿的业内实现都翻了个底朝天,就是为了构建一个专业的知识体系。这门课最终的质量基本符合当初的预期,也算是没辜负我熬的那些夜和掉的无数根头发了。 + +这里就用一张图片来概括这个历时一年的过程吧: + + + +希望这些呈现给你的具体数据,可以让你对自己所学知识的专业度抱有更多的信心。 + +陪你走完这一程Go学习之旅是我的荣幸,这门课虽然结束了,但你的学习和成长之路才刚刚开始。那么,如何在Go研发之路上走得更远呢? + +在我看来,一个Go开发者的完整职业生涯,会经历开发者阶段、架构师阶段、创业者阶段: + + + +处于这三个不同的阶段,你要扮演的角色也是不同的。 + + +开发者阶段:在这个阶段,你可以使用Go语言,完成产品经理、领导分配的各种开发任务,中间会掺杂一些架构设计的工作,但这些架构设计通常局限在跟开发任务相关的范围内。 +架构师阶段:在这个阶段,你已经熟练或者精通Go语言的基本语法,并能够驾轻就熟地使用Go语言开发出一个大型的应用。除此之外,你还能从系统资源层面、应用层面、应用生命周期管理层面来设计整个系统架构,最终构建出一个高性能、高可靠、可维护、可扩展的能够满足产品需求的应用。 +创业者阶段:在开发者阶段和架构师阶段,你本质上还是一个技术导向的从业人员。但在创业者这个阶段,你的重心已经从技术转向产品,成为整个业务线的负责人。这不是因为技术不重要,而是因为你已经有了深厚的技术积累。这时候,技术对你来说更多是一种用来创造优秀产品的工具。 + + +其实,在开发者阶段和架构师阶段,我们的最终目的也是开发一个产品,但和创业者阶段的侧重点有所不同。在开发者阶段,我们主要是完成技术Leader分配的任务;在架构师阶段,我们则更多地扮演一个技术Leader的角色,是底层技术的主导者和决策者,拥有更大的话语权和职责。在这两个阶段,我们还应该通过学习,不断地补全自己的Go技术生态,最终尽可能地将整个Go技术生态中的点和面装入脑海中。 + +要完成开发者 -> 架构师 -> 创业者的角色转变,你就需要在日常的工作中不断学习、思考和实践。而学习,是最基础,也最重要的一个环节。因为这门课是一个技术专栏,所以接下来我们就重点看三个阶段中的前两个,聊聊如何学习,才能使自己成为一名优秀的开发者,然后成功晋级到一位优秀的架构师。 + +开发者阶段 + +作为一名Go开发者,首先你会进入开发者阶段。在开发者阶段,我们可以通过三个步骤,来成为一名优秀的Go开发工程师。需要说明下,这三步虽然是由浅入深的,但是可以并行。你不用学完所有基础语法知识才研究优秀项目、上手实战,可以根据需要穿插进行。 + +第一步:基础语法学习 + +我们可以精读一到两本经典的Go语言基础教程,这里我向你推荐两本书:《Go程序设计语言》和《Go语言编程》。 + +如果你还有时间和精力,还可以再看两本关于场景化编程的书籍:《Go 并发编程实战》(第2版)和《Go Web编程》。 + + + +你可以先通读《Go程序设计语言》这本书,掌握Go的基础语法。在学习的过程中,你可能会遇到一些知识点不太理解,或者看了就忘的情况,没关系,先坚持学习完。如果你还有精力,可以选择继续读《Go语言编程》这本书。这两本书啃下来,你就有了充足的Go基础语法储备,为下一步的研究优秀项目打下了坚实的基础。 + +第二步:认真研究一个优秀的项目 + +有了一定的Go基础语法储备后,就该认真研究一个优秀的项目了。你不仅要学习项目中包含的知识,还要学习它的构建思路。我们来看下工作年限和开发能力的关系: + + + +如上图所示,工作年限和开发能力之间是一个抛物线关系:刚开始的时候,随着工作年限的增长,开发能力会提升得很快;但是,当工作年限增长到一定程度,开发能力的增长就放缓了。这是因为,到达一定工作年限后,我们更多是去反复使用已有的知识和经验积累,所以开发能力提升有限。 + +那么如何才能提高开发能力的天花板呢?在我看来,这时应该去认真研究下如何构建一个优秀的项目,来扩充自己的知识和经验库。一次学习,整个研发生涯都会受益。《Go 语言项目开发实战》就是一门带你研究优秀项目的课程,只要你充分消化吸收了这门课的知识,相信你的Go项目研发能力已经得到了极大的提升。 + +第三步:项目实战 + +对优秀项目有了一定研究之后,你应该以需求为驱动,通过实践来加深对Go基础语法的掌握和理解。 + +在实践的过程中,用需求来驱动学习,不仅效率是最高的,而且学习的过程也是工作产出的过程,可以说是一箭双雕。这里又有3个问题: + + +需求从哪里来? +如何查找优秀的开源项目? +如何进行二次开发? + + +接下来,我们就分别看下这3个问题。 + +问题一:需求从哪里来? + +在我看来,需求来源于工作。这些需求可以是产品经理交给你的某一个具体产品需求,也可以是能够帮助团队提高工作效率的工具,还可以是能够提高自己工作效率的工具。 + +总之,如果有明确的工作需求最好,如果没有明确的需求,我们就要创造需求。我们可以思考工作中的痛点、难点,并将它们转化成需求。比如,团队发布版本,每次都是人工发布,需要登陆到不同的服务器,部署不同的组件和配置。这样效率低不说,还容易因为人为失误造成现网故障。这时候,你就可以将这些痛点抽象成一个需求:开发一个版本发布系统。 + +有了需求,接下来我们就要完成它,也就是进入到实践环节。那么如何实践呢?在我看来精髓在于两个字:“抄”和“改”。 + +上面,我们抽象出了一个需求:开发一个版本发布系统。如果自己从0开发出一套版本发布系统,工作量无疑是巨大的。而且,以我们这个阶段的水平,即使花费了很多时间开发出一个版本发布系统,这个系统在功能和代码质量上也无法跟一些优秀的开源版本发布系统相比。 + +所以,这时候最好的方法就是在GitHub上找到一个优秀的版本发布系统,并基于这个系统进行二次开发。这样,你不仅能学习到一个优秀开源项目的设计和实现,还能够以最快的速度完成版本发布系统的开发。 + +问题二:如何查找优秀的开源项目? + +那么,就到了我们刚才说的第二个问题:如何查找优秀的开源项目?放在这里,就是如何在GitHub上找到优秀的版本发布系统。 + +下面,我把我自己的方法分享给你。我主要通过5个步骤来搜索,如下图所示: + + + +这里我结合图片,具体说明下这5个步骤。 + + +在GitHub搜索栏中按语言搜索:language:go 版本发布中,language:go说明我们要搜索语言类型为Go语言的项目;版本发布是我们搜索项目时的关键词。这个关键词对搜索结果影响很大,需要你合理填写。这里有个技巧,如果搜索版本发布 ,搜索出的项目很少,那么可以减少关键词再次搜索,比如搜索发布。 +GitHub搜索页面的 Sort options 选择 Most stars : 因为GitHub项目很多,我们不可能看完所有搜索出来的项目,所以这里我们要选择性地去查看。你可以通过Most stars进行排序,一般来说Star数越多说明项目越受欢迎,而受欢迎的原因很可能是整个项目在同类项目中比较优秀。根据我之前的搜索经验,一些Star数少的项目也可能很优秀,最终还是需要你根据自己的理解去判断。 +看描述:因为项目比较多,我们不可能认真去研究每个项目,所以要快速了解项目,最简单的方式是先看描述。如果描述符合预期,那么可以将这个项目在新的浏览器Tab页打开,或者将项目地址保存起来,等初步筛选完所有项目后,再详细查看这个项目的README以及代码。 +看项目名字:一些优秀的项目可能没有描述,这时候可以通过项目的名字来判断。 +根据Code做筛选:如果我们搜索的项目很冷门,搜索GitHub后只有寥寥几个搜索结果,而且搜索出的项目也不是我们期望的。那么这时候,你可以从Code中来筛选。 + + +通过上面这5步,我们初步搜索出了匹配的项目,并知道了如何对这些项目做初步的筛选。接下来,你就需要按页来筛选页面中的开源项目,然后从第1页一直筛选到第100页。GitHub一页默认会展示10个开源项目,所以,如果按照这种方法,最终你可能需要调研10 * 100 = 1000个开源项目。当然,也不一定每次都要从第1页一直看到第100页,如果后面的项目明显都跟预期的需求不匹配,可以不用再继续看了。 + +研究完GitHub上的开源项目,这时候我还建议你通过libs.garden,再查找一些开源项目。libs.garden的主要功能是库(在 Go 中叫包)和应用的评分网站,是按不同维度去评分的,例如增长速度(根据新增 Star 数排序)、受欢迎程度(按 Star 数排序)、活跃度等。 + +libs.garden 包含了很多编程语言的评分,包括 Go 语言,地址为 https://libs.garden/go。你可以通过以下3步查找需要的开源项目: + + +打开 https://libs.garden/go; +根据我们需要的功能判断其类别,Go 的所有类别可以参考这个链接 。例如,配置文件解析应该属于 Config 类; +打开所属类别,根据 Popular 进行排序,如下图所示: + + + + +执行完这三步,我们就从上图的第 1 行开始,根据 Repository 的描述判断当前 Repository 有没有可能是我们要找的包。如果有,就打开 Repository,阅读它的README.md 来进一步判断。如果判断出可能是我们要找的包,并且各方面都还可以,就 clone 下来,根据其 README.md 中的帮助文档,编写代码并测试其功能。 + +研究完上一个 Repository 之后,我们继续根据排序,以相同的方法研究第 2 个 Repository,并以此类推,直到找到满意的包,或者GitHub Star 数小于某个预期值为止。用这样的方法,我们应该可以找到符合要求的优秀开源包,而且该开源包极有可能是“最”优秀的包。 + +此外,GitHub 上的 awesome-go 项目也根据分类记录了很多包和工具,你也可以在这个项目中寻找。我的建议是优先从GitHub上找,再在 libs.garden 上找,最后再参考 awesome-go项目。 + +到这里,我们已经通过自己的调研,找到了一堆GitHub上的开源项目。为什么我们要找这么多开源项目呢?主要目的有两个: + + +确保自己基于一个最优的开源项目来进行二次开发,一开始便站上至高点。 +填充自己脑海中的Go生态图。 + + +不过,这些开源项目只是经过了初步筛选,里面有很多是不满足我们需求的,甚至可能跟我们的需求完全不一致。所以,我们还需要进行二次筛选,可以通过精读开源项目的README来筛选。如果有必要,并且项目部署简单,你也可以部署这个开源项目,亲自体验一下。 + +经过第二次的筛选,我们已经筛选出了一些能够满足要求的优秀开源项目。这时候,我们还需要再经过一轮筛选。这轮筛选,我们要从各方面来对比这些开源项目,并从中选出一个最合适的开源项目,来进行二次开发。这个开源项目,你可以自信地跟你老板说它是一个最优解。 + +问题三:如何进行二次开发? + +接下来,你就可以基于这个项目进行二次开发,最终出色地完成设定的需求。那么如何对选定的项目进行二次开发呢?我总结了5个步骤: + + +手动编译、部署这个开源项目。 +阅读项目的README文档,跟着README文档使用这个开源项目,至少运行一遍核心功能。 +阅读核心逻辑源码,在不清楚的地方,可以添加一些 fmt.Printf 函数,来协助你理解代码。 +在你理解了项目的核心逻辑或者架构之后,就可以尝试添加/修改一些匹配自己项目需求的功能,添加后编译、部署,并调试。 +二次开发完之后,你还需要思考下后续要不要同步社区的代码,如果需要,如何同步代码。 + + +在你通过“抄”和“改”完成需求之后,记得还要编写文档,并找个合适的时机在团队中分享你的收获和产出。这点很重要,可以将你的学习输入变成工作产出。 + +看到这里,你可能想说:我开发一个项目而已,调研这么多项目,花这么多时间,值得吗?我觉得是值得的,因为这种学习方式会带来下面这几个好处。 + + +最优解:你可以很有底气地跟老板说,这个方案在这个类别就是业界No.1。 +高效:基于已有项目进行二次开发,可以提高开发和学习效率。 +产出:在学习的过程中,也有工作产出。个人成长、工作贡献可以一起获得。 +知识积累:为今后的开发生涯积累项目库和代码库。GitHub就是一个大的代码仓库,里面几乎囊括了你开发过程中需要的所有技术实现。你需要做的其实就是找到其中的最优实现,并升级成自己的实现。这是一个从量变到质变的过程,最终,你的研发模式会变成Ctrl + C + Ctrl + V。这首先意味着你的开发工作会越来越轻松;另外,你Ctrl + C的是一个优秀的开源项目或代码,Ctrl + V的是经过你改进后的代码,这就意味着,你基于这个开源项目或代码二次开发后的实现一定是 (你, GitHub最优解) 二元组中最好的一个实现。 + + +到这里,我就完整讲述了开发者阶段的“三步走”学习法,这三步分别是基础语法学习、研究一个优秀项目和进行项目实战。用这种方法进行学习,你不仅能非常高效地开发出一个优秀的功能,而且也能得到老板的认可,最终使你在年底绩效考核时顺利拿到优秀员工称号。 + +架构师阶段 + +在开发者阶段,你通过自己的努力成为一名优秀的Go开发工程师之后,可能会遇到职业瓶颈。这时候,你突破瓶颈的最好方式就是转型架构师(需要说明下,这里的架构师是技术架构师,而不是售前架构师)。架构师有很多方向,在云原生技术时代,转型为云原生架构师对学Go语言的我们来说是一个不错的选择。现在的我也在这个道路上前进,期待和你一起成长。 + +要成为云原生架构师,首先要学习云原生技术。云原生技术有很多,我推荐的学习路线如下图所示: + + + +通过学习微服务、Docker、Kubernetes、Knative、Prometheus、Jaeger、EFK、DevOps这些技术,你可以掌握云原生中的核心技术栈;通过学习KVM、Istio、Kafka、Etcd、Tyk,你可以补全你的云原生核心技术栈。如果你还有精力,还可以再学习下TKEStask、Consul、Cilium、OpenShift这些项目。下面,我给你介绍一些不错的参考资料。 + + +微服务:《微服务设计》 +Docker:《Docker技术入门与实战》(第3版)、《Docker ——容器与容器云》(第2版) +Kubernetes : 《Kubernetes权威指南:从Docker到Kubernetes实践全接触》(第4版)、《基于Kubernetes的容器云平台实战》 +Knative:Knative Documentation +Prometheus:Prometheus Documentation +Jaeger :Jaeger Documentation +KVM:《KVM虚拟化技术 : 实战与原理解析》 +Istio:《云原生服务网格Istio:原理、实践、架构与源码解析》 +Kafka:《Apache Kafka实战》、《Apache Kafka源码剖析》 +Etcd:etcd实战课 +Tyk:Tyk Open Source +TKEStask:TKEStack Documentation +Consul:Consul Documentation +Cilium:Cilium Documentation +OpenShift :《开源容器云OpenShift:构建基于Kubernetes的企业应用云平台》 + + +如果需要的话,你还可以参考我整理的awesome-books,里面有更全面的参考资料。 + +学习了上面的技术之后,你其实已经具备了一名云原生架构师需要的技术基础,能够比较全面地构建整个云原生技术栈。接下来你需要做的,就是在工作中积极主动地承担更多的架构工作,你在团队中的角色会慢慢地从一名开发者转变成一名架构师。 + +在架构师阶段,你应该继续学习,但这时候学习的侧重点不再是具体深入的技术细节。这不是因为细节不重要,而是因为以你当前的技术能力,只要简单了解,你就能知道具体是怎么构建的。这个阶段,你学习的重点是增强自己的架构能力。你可以通过很多方式来使自己拥有这些能力,我推荐下面这几种: + + +调研竞品,了解竞品的架构设计和实现方式。 +参加技术峰会,学习其他企业的优秀架构设计,例如ArchSummit全球架构师峰会、QCon等。 +参加公司内外组织的技术分享,了解最前沿的技术、架构和解决方案。 +关注一些优秀的技术公众号,学习其中高质量的技术文章。 +作为一名创造者,通过积极思考,设计出符合当前业务的优秀架构。 + + +需要注意,在架构师阶段你仍然是一名技术开发者,一定不能脱离代码。你可以通过下面这几个方法,让自己保持Code能力: + + +以Coder的身份参与一些核心代码的研发。 +以Reviewer的身份Review成员提交的PR。 +工作之余,阅读项目其他成员开发的源码。 +关注一些优秀的开源项目,调研、部署并试用。 + + +创业者阶段 + +成为了一名优秀的架构师,就意味着你已经,或者将要触碰到技术的天花板了,那这时候如何突破呢? + +在我看来,一个比较好的方向是突破技术的圈子,把技术当成一种工具,用这个工具来创造一个优秀的产品。这时候,你其实已经从一个技术人员转型成为一个创业者。这个时候的天花板,对你来说可以是无限高的:你可以通过努力,成为公司的经理、总裁甚至CEO,也可以成为行业TOP公司的创始人。 + +至于如何扮演好一个创业者角色,内容太多,变化也太多,而且跟本专栏的技术性质不匹配,所以这里就索性留一些空白,等着你来填充。总之,那时候就是:技术在手,天下我有! + +写在最后 + +感谢你陪我走完了这段历时4个月的Go学习之旅。不过,站在终点的你可以看到,你的Go研发之路才刚刚开始。希望我今天分享的这些学习和工作心得,能帮你在这条路上走得更远。 + +看到这里,你可能觉得我们之间的关系就这样结束了?不,没有结束,你可以通过下面这两步继续跟我保持联系,继续探讨如何开发Go项目,以及课程中的一些疑难问题。 + + +步骤一:请给这门课的实战项目 点个Star。IAM项目会持续升级维护,这个Star绝对不亏! +步骤二:如果还有关于课程的问题需要咨询,或者想了解我熬夜写完专栏,发际线依然坚挺的秘诀(不点Star不分享,哈哈),可以加我微信,WeChat:echo bmlnaHRza29uZw==|base64 -d 。 + + +最后,我还给你准备了一个调查问卷。题目不多,大概两分钟就可以填完,主要是想听一下你对这门课的看法和建议。期待你的反馈!- + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/01阅读此专栏的正确姿势.md b/专栏/JVM核心技术32讲(完)/01阅读此专栏的正确姿势.md new file mode 100644 index 0000000..07a707b --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/01阅读此专栏的正确姿势.md @@ -0,0 +1,130 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 阅读此专栏的正确姿势 + 课程背景 + +近些年来,无论是使用规模、开发者人数,还是技术生态成熟度、相关工具的丰富程度,Java 都当之无愧是后端开发语言中不可撼动的王者,也是开发各类业务系统的首选语言。 + +时至今日,整个 IT 招聘市场上,Java 开发工程师依然是缺口最大,需求最多的热门职位。另外,从整个市场环境看,传统企业的信息化,传统 IT 系统的互联网化,都还有非常大的发展空间,由此推断未来 Java 开发的市场前景广阔,从业人员的行业红利还可以持续很长时间。 + +从权威的 TIOBE 编程语言排行榜 2019 年 11 月数据来看,Java 的流行程度也是稳居第一。 + + + + + +拉勾网 2019 年 9 月统计的招聘岗位比例,也可以看到 Java 和 JavaScript 是最高的,不过 Java 的求职难度只有 JavaScript 的 1/7。 + + + +Java 平均一个岗位有 4 个人竞争,而 JavaScript 则是 28 个,Perl 最夸张,超过 30 个。 + + + +而通过职友网的数据统计,北京、上海、杭州、深圳的 Java 程序员平均薪酬在 16-21K 之间,在广州、成都、苏州、南京等城市也有 11K-13K 的平均收入,远超一般行业的收入水平。 + + + +所以学习 Java 目前还是一个非常有优势的职业发展选择。 + +而了解 JVM 则是深入学习 Java 必不可少的一环,也是 Java 开发人员迈向更高水平的一个阶梯。我们不仅要会用 Java 写代码做系统,更要懂得如何理解和分析 Java 程序运行起来以后内部发生了什么,然后可以怎么让它运行的更好。 + +就像我们要想多年开车的老司机,仅仅会开车肯定不能当一个好司机。车开多了,总会有一些多多少少大大小小的故障毛病。老司机需要知道什么现象说明有了什么毛病,需要怎么处理,不然就会导致经常抛锚,影响我们的行程。 + +本课程就是用来教会我们怎么能够去了解 JVM 这辆优秀跑车的一些原理和怎么去用各种工具分析修理它。 + +课程特点 + +市面上各类 JVM 相关的资料虽多,但是明显存在两个极端:过于生涩难懂,或者流于某个技巧点而不系统化。同时各大公司也都越来越重视推动和发展 JVM 相关技术,一线大厂技术面试现在 JVM 知识也是必考科目。 + +在这个背景下,我们全面梳理了系统化学习 JVM 的知识和经验,包括 JVM 的技术和内存模型,JVM 参数和内置工具,GC 算法,GC 日志、内存和线程等相关问题排查分析,以及常见的面试问题深度剖析等高级的进阶方法与实战,既满足大家快速系统化学习和全面掌握知识的需求,又兼顾大家的面试经验辅导。 + + +通过体系化的学习,了解一般原理,知其然知其所以然; +熟悉工具和方案,知道从何下手,工作中如何分析和解决问题; +随着课程的演示和练习,加深理解,不管大家之前的基础如何,都能够融会贯通; +面试题的解析部分,会根据大家的反馈进行持续更新,长期助力于大家的学习和进步。 + + +本课程的特点可以总结为 16 个字: + + +体系完整、层次分明、深入浅出、实践为要 + + +为什么做这门课 + +最近有人问我,程序员多以高深技术为尊,为什么你要做 JVM 的一个偏向于基础和实际应用的专栏,而不是一个讲 JVM 内部实现的各种底层原理,或者是高深的各种算法原理之类的内容。 + +我在此想说一下我对这个问题的想法: + +我个人一直认为,技术应该有两方面,有一小部分人去做高精尖的,以理论为主,更多的人以把技术应用到实际工作、改进效率、提高生产力,以实用为主。这也契合了技术大牛史海峰老师经常说的一句话,架构师应该是一个胸怀理想的实用主义者。 + +所以,我们再这个课程里,只给大家呈现那些对大家的工作和其他方面,应该会有用的东西,脚踏实地的东西,不管是技术点,还是经验之谈,虽有少量的前瞻性介绍和展望,但是主线一定是偏向于基础和实际应用的。 + +前一阵在网上听樊登老师的演讲,他提到的一个东西方教学的差异。国人教学、传授知识,喜欢按孔子、老子的这一套,讲究悟性,说一句话就很高深,让人摸不着头脑,然后你要是有悟性,就能悟到真理,悟不到就说明还需要加倍努力。 + +而西方从苏格拉底、柏拉图、亚里士多德起,就喜欢用逻辑,第一步是这样,第二步是那样,第三步要是发现第一步不完善,那么 OK,我们就可以去改善第一步,然后继续第二步,第三步……这样我们的知识体系就会慢慢的越来越完善,厚实,接近真理,并且这个方法是可以复制的。 + +所以我们公司技术委员会就组织了一些一线的技术人员,在我们的研发团队实验了几期 4~6 课时,每次 2 小时的“知识+实践”课程,并且受到了良好的效果和积极的反馈。 + +恰好当时内部培训的时候,《JVM 基础入门》这门课是我和富飞一起组织的,富飞在以往的工作经历中,翻译和撰写了不少 JVM 相关的技术文章和博客,在 JVM 方面积累了大量的一手经验和技巧。 + +知识这种东西,独乐乐不如众乐乐,一个人会了它的价值就有限,我们在公司内部做了培训也还是只影响了参加培训的百八十个人。如果把 JVM 的内容进行更加完整的整理加工,再融合目前行业里大家最关心的各类问题,变成一个公开的课程,那么就可以影响到更多的人,产生更大的价值,对大家都有益,这是一个多赢的事情(这也是史老师那句话的前半句里的“胸怀理想”吧)。 + +基于这些原因,大家一拍即合,于是就有了这个课程跟大家见面。我们相信这门课程,一定不会让大家失望。 + +课程内容 + +本课程分为两部分,基础知识篇主要介绍 JVM 的基础知识、JDK 相关的各种工具用法,深入分析篇讲解各种 GC 算法、如何进行 JVM 的 GC 日志、线程、内存等各类指标进行分析和问题诊断,再结合作者的实际分析调优经验,以及对于常见的 JVM 面试问题进行分析和解答,为学习者梳理清楚 JVM 的整体知识脉络,带来最全面的 JVM 一线经验和实用技巧。 + +本次分享您将了解以下内容(22 课时): + + + +基础知识篇 + + +环境准备:千里之行,始于足下 +常用性能指标:没有量化,就没有改进 +JVM 基础知识:不积跬步,无以至千里 +Java 字节码技术:不积细流,无以成江河 +JVM 类加载器:山不辞土,故能成其高 +JVM 内存模型:海不辞水,故能成其深 +JVM 启动参数详解:博观而约取、厚积而薄发 +JDK 内置命令行工具介绍:工欲善其事,必先利其器 +JDK 内置图形界面工具介绍:海阔凭鱼跃,天高任鸟飞 +JDWP 简介:十步杀一人,千里不留行 +JMX 与相关工具:山高月小,水落石出 + + +深入分析篇 + + +常见的 GC 算法介绍(Parallel/CMS/G1):温故而知新 +Java11 ZGC 和 Java12 Shenandoah 介绍:苟日新、日日新、又日新 +Oracle Graalvm 介绍:会当凌绝顶、一览众山小 +GC 日志解读与分析:千淘万漉虽辛苦,吹尽狂沙始到金 +JVM 的线程堆栈等数据分析:操千曲而后晓声、观千剑而后识器 +内存 dump 和内存分析工具:万里赴戎机、关山度若飞 +fastthread 相关的工具介绍:欲穷千里目,更上一层楼 +面临复杂问题时的几个高级工具:它山之石,可以攻玉 +JVM 问题排查分析调优经验:纸上得来终觉浅,绝知此事要躬行 +JVM 相关的常见面试问题汇总:运筹策帷帐之中,决胜于千里之外 +应对容器时代面临的挑战:长风破浪会有时、直挂云帆济沧海 + + +送给大家的话 + +俗话说,“活到老、学到老”。IT 行业的技术发展和创新速度太快,新的知识很快成为老知识,新的技巧很快成为旧把式,只有终身学习才能适应技术本身的发展。同时现在随着网络的发展,特别是各类新的内容平台和媒体的涌现,信息不是太少了,而是太多了。 + +信息爆炸带来了甄别有用信息的过程成本增加,这时候选择好的学习途径、学习内容就跟学习方法一样重要,为大家系统化的总结经验和传播知识也同样变得很重要。 + +让我们一起在 GitChat 平台不断学习,跟志同道合的同学们一起努力,共同进步。 + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/02环境准备:千里之行,始于足下.md b/专栏/JVM核心技术32讲(完)/02环境准备:千里之行,始于足下.md new file mode 100644 index 0000000..23ba5fb --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/02环境准备:千里之行,始于足下.md @@ -0,0 +1,356 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 环境准备:千里之行,始于足下 + Java 语言编写代码非常简单,也很容易入门,非常适合开发各种企业级应用和业务系统。一个众所周知的事实是: 用起来越简单的系统, 其背后的原理和实现就越复杂。道理很容易理解, 系统的内部实现考虑了各种极端的情况,对用户屏蔽了各种复杂性。作为支撑庞大的 Java 生态系统的基石, JVM 内部实现是非常复杂的。据统计,OpenJDK 的实现代码已经超过 1000 万行。 + +JVM 难不难? 自然是 “难者不会,会者不难”。万丈高楼平地起, 没有掌握一定的基础知识, 学过的各种原理,了解相关技巧,也就会出现转眼即忘,书到用时方恨少的情况。 + +掌握好基础知识,学而时习之,经常使用各种工具并熟练运用,自然就能深入掌握一门技能。理论结合实践,掌握 JVM 相关知识,熟练各种工具的使用,是 Java 工程师职业进阶中不可或缺的。学就要学会理论,掌握实现原理。 理解了 Java 标准平台的 JVM,举一反三,稍微变通一下,碰到 Android 的 ART, Go 的虚拟机,以及各种语言的垃圾收集实现,都会很容易理解。 + +1.1 JDK、JRE、JVM 的关系 + +JDK + +JDK(Java Development Kit) 是用于开发 Java 应用程序的软件开发工具集合,包括了 Java 运行时的环境(JRE)、解释器(Java)、编译器(javac)、Java 归档(jar)、文档生成器(Javadoc)等工具。简单的说我们要开发 Java 程序,就需要安装某个版本的 JDK 工具包。 + +JRE + +JRE(Java Runtime Enviroment )提供 Java 应用程序执行时所需的环境,由 Java 虚拟机(JVM)、核心类、支持文件等组成。简单的说,我们要是想在某个机器上运行 Java 程序,可以安装 JDK,也可以只安装 JRE,后者体积比较小。 + +JVM + +Java Virtual Machine(Java 虚拟机)有三层含义,分别是: + + +JVM规范要求; +满足 JVM 规范要求的一种具体实现(一种计算机程序); +一个 JVM 运行实例,在命令提示符下编写 Java 命令以运行 Java 类时,都会创建一个 JVM 实例,我们下面如果只记到 JVM 则指的是这个含义;如果我们带上了某种 JVM 的名称,比如说是 Zing JVM,则表示上面第二种含义。 + + +JDK 与 JRE、JVM 之间的关系 + +就范围来说,JDK > JRE > JVM: + + +JDK = JRE + 开发工具 +JRE = JVM + 类库 + + + + +三者在开发运行 Java 程序时的交互关系: + +简单的说,就是通过 JDK 开发的程序,编译以后,可以打包分发给其他装有 JRE 的机器上去运行。而运行的程序,则是通过 Java 命令启动的一个 JVM 实例,代码逻辑的执行都运行在这个 JVM 实例上。 + + + +Java 程序的开发运行过程为: + +我们利用 JDK (调用 Java API)开发 Java 程序,编译成字节码或者打包程序。然后可以用 JRE 则启动一个 JVM 实例,加载、验证、执行 Java 字节码以及依赖库,运行 Java 程序。而 JVM 将程序和依赖库的 Java 字节码解析并变成本地代码执行,产生结果。 + +1.2 JDK 的发展过程与版本变迁 + +说了这么多 JDK 相关的概念,我们再来看一下 JDK 的发展过程。 JDK 版本列表 + + + + +JDK版本 +发布时间 +代号 +备注 + + + + + +1 +1996年1月23日 +Oak(橡树) +初代版本,伟大的一个里程碑,但是是纯解释运行,使用JIT,性能比较差,速度慢 + + + +1.1 +1997年2月19日 +Sparkler(宝石) +JDBC、支持内部类、RMI、反射等等 + + + +1.2 +1998年12月8日 +Playground(操场) +集合框架、JIT等等 + + + +1.3 +2000年5月8日 +Kestrel(红隼) +对Java的各个方面都做了大量优化和增强 + + + +1.4 +2004年2月6日 +Merlin(隼) +XML处理、支持IPV6、正则表达式,引入nio和CMS垃圾回收器 + + + +5 +2004年9月30日 +Tiger(老虎) +泛型、增强for语句、自动拆装箱、可变参数、静态导入、注解 + + + +6 +2006年12月11日 +Mustang(野马) +支持脚本语言、JDBC4.0 + + + +7 +2011年7月28日 +Dolphin(海豚) +switch支持String类型、泛型推断、nio 2.0开发包、数值类型可以用二进制字符串表示 + + + +8 +2014年3月18日 +Spider(蜘蛛) +Lambda 表达式、接口默认方法、Stream API、新的日期API、Nashorn引擎 jjs,引入G1垃圾回收器 + + + +9 +2017年9月22日 +Modularity (模块化) +模块系统、HTTP 2 客户端、多版本兼容 JAR 包、私有接口方法、改进Stream API、响应式流(Reactive Streams) API + + + +10 +2018年3月21日 + +引入关键字 var 局部变量类型推断、统一的垃圾回收接口 + + + +11 +2018年9月25日 + +HTTP客户端(标准)、无操作垃圾收集器,支持ZGC垃圾回收器,首个LTS版本 + + + +12 +2019年3月19日 + +新增一个名为 Shenandoah 的垃圾回收器、扩展switch语句的功能、改进 G1 垃圾回收器 + + + +13 +2019年9月17日 + +改进了CDS内存共享,ZGC归还系统内存,SocketAPI和switch语句以及文本块表示 + + + +14 +开发中 + +继续对ZGC、G1改进,标记 ParallelScavenge + SerialOld组合为过时的 ,移除CMS垃圾回收器 + + + +Java 大事记 + + +1995 年 5 月 23 日,Java 语言诞生 +1996 年 1 月,第一个 JDK-JDK1.0 诞生 +1997 年 2 月 18 日,JDK1.1 发布 +1997 年 4 月 2 日,JavaOne 会议召开,参与者逾一万人,创当时全球同类会议规模之纪录 +1997 年 9 月,Java 开发者社区成员超过十万 +1998 年 2 月,JDK1.1 被下载超过 200 万次 +1998 年 12 月 8 日,JAVA2 企业平台 J2EE 发布 +1999 年 6 月,Sun 公司发布 Java 的三个版本:标准版、企业版和微型版(J2SE、J2EE、J2ME) +2000 年 5 月 8 日,JDK1.3 发布 +2000 年 5 月 29 日,JDK1.4 发布 +2002 年 2 月 26 日,J2SE1.4 发布,自此 Java 的计算能力有了大幅提升 +2004 年 9 月 30 日,J2SE1.5 发布,是 Java 语言的发展史上的又一里程碑事件,Java 并发包 JUC 也是这个版本引入的。为了表示这个版本的重要性,J2SE1.5 更名为 J2SE5.0 +2005 年 6 月,发布 Java SE 6,这也是一个比较长期使用的版本 +2006 年 11 月 13 日,Sun 公司宣布 Java 全线采纳 GNU General Public License Version 2,从而公开了 Java 的源代码 +2009 年 04 月 20 日,Oracle 公司 74 亿美元收购 Sun。取得 Java 的版权 +2011 年 7 月 28 日,Oracle 公司发布 Java SE7.0 的正式版 +2014 年 3 月 18 日,Oracle 公司发布 Java SE 8,这个版本是目前最广泛使用的版本 +2017 年 9 月 22 日,JDK9 发布,API 有了较大的调整,添加了对 WebSocket 和 HTTP/2 的支持,此后每半年发布一个大版本 +2018 年 3 月 21 日,JDK10 发布,最大的变化就是引入了 var,如果你熟悉 C# 或 JavaScript/NodeJS 就会知道它的作用 +2018 年 9 月 25 日,JDK11 发布,引入 ZGC,这个也是第一个公布的长期维护版本 LTS +2019 年 3 月 19 日,JDK12 发布,引入毫秒级停顿的 Shenandoah GC +2019 年 9 月 17 日,JDK13 发布,改进了 CDS 内存共享,ZGC 归还系统内 + + +我们可以看到 JDK 发展的越来越多,越来越复杂,特别是被 Oracle 收购以后,近 2 年以来版本号快速膨胀,GC 算法也有了更快速的发展。目前最新的 JDK 是 JDK13,同时 JDK14 正在开发中,预计 2020 年 3 月份发布。很多朋友直呼,“不要再升级了,还在用 JDK8,已经学不过来了”。但是正是由于 Java 不断的发展和改进,才会持续具有生命力。 + + +常规的 JDK,一般指 OpenJDK 或者 Oracle JDK,当然 Oracle 还有一个新的 JVM 叫 GraalVM,也非常有意思。除了 Sun/Oracle 的 JDK 以外,原 BEA 公司(已被 Oracle 收购)的 JRockit,IBM 公司的 J9,Azul 公司的 Zing JVM,阿里巴巴公司的分支版本 DragonWell 等等。 + + +1.3 安装 JDK + +JDK 通常是从 Oracle 官网下载, 打开页面翻到底部,找 Java for Developers 或者 Developers, 进入 Java 相应的页面 或者 Java SE 相应的页面, 查找 Download, 接受许可协议,下载对应的 x64 版本即可。 + +建议安装比较新的 JDK8 版本, 如 JDK8u231。 + + +注意:从 Oracle 官方安装 JDK 需要注册和登录 Oracle 账号。现在流行将下载链接放到页面底部,很多工具都这样。当前推荐下载 JDK8。 今后 JDK11 可能成为主流版本,因为 Java11 是 LTS 长期支持版本,但可能还需要一些时间才会普及,而且 JDK11 的文件目录结构与之前不同, 很多工具可能不兼容其 JDK 文件的目录结构。 + + +有的操作系统提供了自动安装工具,直接使用也可以,比如 yum, brew, apt 等等。例如在 MacBook 上,执行: + + +brew cask install java8 + + +而使用如下命令,会默认安装最新的 JDK13: + + +brew cask install java + + +如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): + +如果网络不好,可以从我的百度网盘共享获取: + + +https://pan.baidu.com/s/16WmRDZSiBD7a2PMjhSiGJw + +提取码: e77s + + +1.4 设置环境变量 + +如果找不到命令,需要设置环境变量: JAVA_HOME 和 PATH 。 + + +JAVA_HOME 环境变量表示 JDK 的安装目录,通过修改 JAVA_HOME ,可以快速切换 JDK 版本 。很多工具依赖此环境变量。 + +另外, 建议不要设置 CLASS_PATH 环境变量,新手没必要设置,容易造成一些困扰。 + + +Windows 系统, 系统属性 - 高级 - 设置系统环境变量。 如果没权限也可以只设置用户环境变量。 + +Linux 和 MacOSX 系统, 需要配置脚本。 例如: + + +$ cat ~/.bash_profile + + +# JAVA ENV +export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_162.jdk/Contents/Home +export PATH=$PATH:$JAVA_HOME/bin + + + +让环境配置立即生效: + + +$ source ~/.bash_profile + + +查看环境变量: + +echo $PATH +echo $JAVA_HOME + + + +一般来说,.bash_profile 之类的脚本只用于设置环境变量。 不设置随机器自启动的程序。 + +如果不知道自动安装/别人安装的 JDK 在哪个目录怎么办? + + +最简单/最麻烦的查询方式是询问相关人员。 + + +查找的方式很多,比如,可以使用 which, whereis, ls -l 跟踪软连接, 或者 find 命令全局查找(可能需要 sudo 权限), 例如: + +jps -v +whereis javac +ls -l /usr/bin/javac +find / -name javac + + + +找到满足 $JAVA_HOME/bin/javac 的路径即可。 + +Windows 系统,安装在哪就是哪,默认在C:\Program Files (x86)\Java下。通过任务管理器也可以查看某个程序的路径,注意 JAVA_HOME 不可能是 C:\Windows\System32 目录。 + +然后我们就可以在 JDK 安装路径下看到很多 JVM 工具,例如在 Mac 上: + + 在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 JVM 情况。 + +1.4 验证 JDK 安装完成 + +安装完成后,Java 环境一般来说就可以使用了。 验证的脚本命令为: + +$ java -version + + + +可以看到输出类似于以下内容,既证明成功完成安装: + + +java version “1.8.0*65” Java™ SE Runtime Environment (build 1.8.0*65-b17) Java HotSpot™ 64-Bit Server VM (build 25.65-b01, mixed mode) + + +然后我们就可以写个最简单的 Java 程序了,新建一个文本文件,输入以下内容: + +public class Hello { + public static void main(String[] args){ + System.out.println("Hello, JVM!"); + } +} + + + +然后把文件名改成Hello.java,在命令行下执行: + + +$ javac Hello.java + + +然后使用如下命令运行它: + + +$ java Hello Hello, JVM! + + +即证明运行成功,我们的 JDK 环境可以用来开发了。 + +参考材料 + + +https://www.jianshu.com/p/7b99bd132470 +https://blog.csdn.net/Phoenix_smf/article/details/79709592 +https://www.iteye.com/blog/dasheng-727156 +https://blog.csdn.net/lc11535/article/details/99776597 +https://blog.csdn.net/damin112/article/details/84634041 +https://blog.csdn.net/KamRoseLee/article/details/79440425 +https://blog.csdn.net/j3T9Z7H/article/details/94592958 +http://openjdk.java.net/projects/jdk/ +http://openjdk.java.net/projects/jdk/13/ + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/03常用性能指标:没有量化,就没有改进.md b/专栏/JVM核心技术32讲(完)/03常用性能指标:没有量化,就没有改进.md new file mode 100644 index 0000000..e2473f7 --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/03常用性能指标:没有量化,就没有改进.md @@ -0,0 +1,106 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 常用性能指标:没有量化,就没有改进 + +前面一节课阐述了 JDK 的发展过程,以及怎么安装一个 JDK,在正式开始进行 JVM 的内容之前,我们先了解一下性能相关的一些基本概念和原则。 + + + + +如果要问目前最火热的 JVM 知识是什么? 很多同学的答案可能是 “JVM 调优” 或者 “JVM 性能优化”。但是具体需要从哪儿入手,怎么去做呢? + +其实“调优”是一个诊断和处理手段,我们最终的目标是让系统的处理能力,也就是“性能”达到最优化,这个过程我们就像是一个医生,诊断和治疗“应用系统”这位病人。我们以作为医生给系统看病作为对比,“性能优化”就是实现“把身体的大小毛病治好,身体达到最佳健康状态”的目标。 + +那么去医院看病,医生会是怎么一个处理流程呢?先简单的询问和了解基本情况,发烧了没有,咳嗽几天了,最近吃了什么,有没有拉肚子一类的,然后给患者开了一系列的检查化验单子:去查个血、拍个胸透、验个尿之类的。然后就会有医生使用各项仪器工具,依次把去做这些项目的检查,检查的结果就是很多标准化的具体指标(这里就是我们对 JVM 进行信息收集,变成各项指标)。 + +然后拿过来给医生诊断用,医生根据这些指标数据判断哪些是异常的,哪些是正常的,这些异常指标说明了什么问题(对系统问题进行分析排查),比如是白细胞增多(系统延迟和抖动增加,偶尔宕机),说明可能有炎症(比如 JVM 配置不合理)。最后要“对症下药”,开出一些阿莫西林或者头孢(对 JVM 配置进行调整),叮嘱怎么频率,什么时间点服药,如果问题比较严重,是不是要住院做手术(系统重构和调整),同时告知一些注意事项(对日常运维的要求和建议),最后经过一段时间治疗,逐渐好转,最终痊愈(系统延迟降低,不在抖动,不再宕机)。通过了解 JVM 去让我们具有分析和诊断能力,是本课程的核心主题。 + +2.1 量化性能相关指标 + + + +“没有量化就没有改进”,所以我们需要先了解和度量性能指标,就像在医院检查以后得到的检验报告单一样。因为人的主观感觉是不靠谱的,个人经验本身也是无法复制的,而定义了量化的指标,就意味着我们有了一个客观度量体系。哪怕我们最开始定义的指标不是特别精确,我们也可以在使用过程中,随着真实的场景去验证指标有效性,进而替换或者调整指标,逐渐的完善这个量化的指标体系,成为一个可以复制和复用的有效工具。就像是上图的血常规检查报告单,一旦成为这种标准化的指标,那么使用它得到的结果,也就是这个报告单,给任何一个医生看,都是有效的,一般也能得到一致的判断结果。 + +那么系统性能的诊断要做些什么指标呢?我们先来考虑,进行要做诊断,那么程序或 JVM 可能出现了问题,而我们排查程序运行中出现的问题,比如排查程序 BUG 的时候,要优先保证正确性,这时候就不仅仅是 JVM 本身的问题,例如死锁等等,程序跑在 JVM 里,现象出现在 JVM 上,很多时候还要深入分析业务代码和逻辑确定 Java 程序哪里有问题。 + + +分析系统性能问题: 比如是不是达到了我们预期性能指标,判断资源层面有没有问题,JVM 层面有没有问题,系统的关键处理流程有没有问题,业务流程是否需要优化; +通过工具收集系统的状态,日志,包括打点做内部的指标收集,监控并得出关键性能指标数据,也包括进行压测,得到一些相关的压测数据和性能内部分析数据; +根据分析结果和性能指标,进行资源配置调整,并持续进行监控和分析,以优化性能,直到满足系统要求,达到系统的最佳性能状态。 + + +计算机系统中,性能相关的资源主要分为这几类: + + +CPU:CPU 是系统最关键的计算资源,在单位时间内有限,也是比较容易由于业务逻辑处理不合理而出现瓶颈的地方,浪费了 CPU 资源和过渡消耗 CPU 资源都不是理想状态,我们需要监控相关指标; +内存:内存则对应程序运行时直接可使用的数据快速暂存空间,也是有限的,使用过程随着时间的不断的申请内存又释放内存,好在 JVM 的 GC 帮我们处理了这些事情,但是如果 GC 配置的不合理,一样会在一定的时间后,产生包括 OOM 宕机之类的各种问题,所以内存指标也需要关注; +IO(存储+网络):CPU 在内存中把业务逻辑计算以后,为了长期保存,就必须通过磁盘存储介质持久化,如果多机环境、分布式部署、对外提供网络服务能力,那么很多功能还需要直接使用网络,这两块的 IO 都会比 CPU 和内存速度更慢,所以也是我们关注的重点。 + + +其他各种更细节的指标,将会在工具和命令的使用章节详细介绍。 + +2.2 性能优化中常见的套路 + +性能优化一般要存在瓶颈问题,而瓶颈问题都遵循 80⁄20 原则。既我们把所有的整个处理过程中比较慢的因素都列一个清单,并按照对性能的影响排序,那么前 20% 的瓶颈问题,至少会对性能的影响占到 80% 比重。换句话说,我们优先解决了最重要的几个问题,那么性能就能好一大半。 + +我们一般先排查基础资源是否成为瓶颈。看资源够不够,只要成本允许,加配置可能是最快速的解决方案,还可能是最划算,最有效的解决方案。 与 JVM 有关的系统资源,主要是 CPU 和 内存 这两部分。 如果发生资源告警/不足, 就需要评估系统容量,分析原因。 + + +至于 GPU 、主板、芯片组之类的资源则不太好衡量,通用计算系统很少涉及。 + + +一般衡量系统性能的维度有 3 个: + + +延迟(Latency): 一般衡量的是响应时间(Response Time),比如平均响应时间。但是有时候响应时间抖动的特别厉害,也就是说有部分用户的响应时间特别高,这时我们一般假设我们要保障 95% 的用户在可接受的范围内响应,从而提供绝大多数用户具有良好的用户体验,这就是延迟的95线(P95,平均 100 个用户请求中 95 个已经响应的时间),同理还有99线,最大响应时间等(95 线和 99 线比较常用;用户访问量大的时候,对网络有任何抖动都可能会导致最大响应时间变得非常大,最大响应时间这个指标不可控,一般不用)。 +吞吐量(Throughput): 一般对于交易类的系统我们使用每秒处理的事务数(TPS)来衡量吞吐能力,对于查询搜索类的系统我们也可以使用每秒处理的请求数(QPS)。 +系统容量(Capacity): 也叫做设计容量,可以理解为硬件配置,成本约束。 + + +这 3 个维度互相关联,相互制约。只要系统架构允许,增加硬件配置一般都能提升性能指标。但随着摩尔定律的失效,增加硬件配置到一定的程度并不能提供性能的线性扩展,比如说已经比较高配置的机器,CPU 核数或频率、内存扩大一倍,一方面并不能带来一倍的性能提升,另一方面带来的成本不止一倍,性价比急速下降,而且到了一定程度想加都加不上去了。作为云厂商的领头羊 AWS 今年才开始尝试提供 256 核的机器,而阿里云目前最高支持 104 核。所以目前来说,整体上使用分布式的解决办法,以及局部上对每个系统进行分析调优,是性价比最高的选择。 + +性能指标还可分为两类: + + +业务需求指标:如吞吐量(QPS、TPS)、响应时间(RT)、并发数、业务成功率等。 +资源约束指标:如 CPU、内存、I/O 等资源的消耗情况。 + + + + + +详情可参考: 性能测试中服务器关键性能指标浅析 + + +每类系统关注的重点还不一样。 批处理/流处理 系统更关注吞吐量, 延迟可以适当放宽。一般来说大部分系统的硬件资源不会太差,但也不是无限的。高可用 Web 系统,既关注高并发情况下的系统响应时间,也关注吞吐量。 + + +例如: “配置 2 核 4GB 的节点,每秒响应 200 个请求,95% 线是 20ms,最大响应时间 40ms。” 从中可以解读出基本的性能信息: 响应时间(RT + + +我们可采用的手段和方式包括: + + +使用 JDWP 或开发工具做本地/远程调试 +系统和 JVM 的状态监控,收集分析指标 +性能分析: CPU 使用情况/内存分配分析 +内存分析: Dump 分析/GC 日志分析 +调整 JVM 启动参数,GC 策略等等 + + +2.3 性能调优总结 + + + +性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 TPS 和 QPS,就是极限值。知道了极限值,我们就可以按业务发展测算流量和系统压力,以此做容量规划,准备机器资源和预期的扩容计划。最后在系统的日常运行过程中,持续观察,逐步重做和调整以上步骤,长期改善改进系统性能。 + +我们经常说“脱离场景谈性能都是耍流氓”,实际的性能分析调优过程中,我们需要根据具体的业务场景,综合考虑成本和性能,使用最合适的办法去处理。系统的性能优化到 3000TPS 如果已经可以在成本可以承受的范围内满足业务发展的需求,那么再花几个人月优化到 3100TPS 就没有什么意义,同样地如果花一倍成本去优化到 5000TPS 也没有意义。 + +Donald Knuth 曾说过“过早的优化是万恶之源”,我们需要考虑在恰当的时机去优化系统。在业务发展的早期,量不大,性能没那么重要。我们做一个新系统,先考虑整体设计是不是 OK,功能实现是不是 OK,然后基本的功能都做得差不多的时候(当然整体的框架是不是满足性能基准,可能需要在做项目的准备阶段就通过 POC(概念证明)阶段验证。),最后再考虑性能的优化工作。因为如果一开始就考虑优化,就可能要想太多导致过度设计了。而且主体框架和功能完成之前,可能会有比较大的改动,一旦提前做了优化,可能这些改动导致原来的优化都失效了,又要重新优化,多做了很多无用功。 + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/04JVM基础知识:不积跬步,无以至千里.md b/专栏/JVM核心技术32讲(完)/04JVM基础知识:不积跬步,无以至千里.md new file mode 100644 index 0000000..c8a5009 --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/04JVM基础知识:不积跬步,无以至千里.md @@ -0,0 +1,163 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 JVM 基础知识:不积跬步,无以至千里 + 前面的章节我们介绍了 JDK 和 JVM 的关系以及环境准备等,本节我们来探讨一下 JVM 的基础知识,包括以下内容: + + +常见的编程语言类型 +关于跨平台、运行时(Runtime)与虚拟机(VM) +关于内存管理和垃圾回收(GC) + + +3.1 常见的编程语言类型 + +我们都知道 Java 是一种基于虚拟机的静态类型编译语言。那么常见的语言可以怎么分类呢? + +1)编程语言分类 + +首先,我们可以把形形色色的编程从底向上划分为最基本的三大类:机器语言、汇编语言、高级语言。 + + + +按《计算机编程语言的发展与应用》一文里的定义:计算机编程语言能够实现人与机器之间的交流和沟通,而计算机编程语言主要包括汇编语言、机器语言以及高级语言,具体内容如下: + + +机器语言:这种语言主要是利用二进制编码进行指令的发送,能够被计算机快速地识别,其灵活性相对较高,且执行速度较为可观,机器语言与汇编语言之间的相似性较高,但由于具有局限性,所以在使用上存在一定的约束性。 +汇编语言:该语言主要是以缩写英文作为标符进行编写的,运用汇编语言进行编写的一般都是较为简练的小程序,其在执行方面较为便利,但汇编语言在程序方面较为冗长,所以具有较高的出错率。 +高级语言:所谓的高级语言,其实是由多种编程语言结合之后的总称,其可以对多条指令进行整合,将其变为单条指令完成输送,其在操作细节指令以及中间过程等方面都得到了适当的简化,所以,整个程序更为简便,具有较强的操作性,而这种编码方式的简化,使得计算机编程对于相关工作人员的专业水平要求不断放宽。 + + +简言之:机器语言是直接给机器执行的二进制指令,每种 CPU 平台都有对应的机器语言。 + +而汇编语言则相当于是给机器执行的指令,按照人可以理解的助记符表示,这样代码就非常长,但是性能也很好。 + +高级语言则是为了方便人来理解,进而快速设计和实现程序代码,一般跟机器语言和汇编语言的指令已经完全没有关系了,代码编写完成后通过编译或解释,转换成汇编码或机器码,之后再传递给计算机去执行。 + +所以机器语言和汇编语言都是跟目标机器的 CPU 架构有直接联系,而高级语言一般就没有关系了,高级语言高级就高级在,一份代码往往是可以跨不同的目标机器的 CPU 架构的,不管是 x86 还是其他 CPU,尽管不同 CPU 支持的指令集略有不同,但是都在编译或解释过程之后,变成实际平台的目标代码,进而代码的开发者很大程度上不需要关心目标平台的差异性。这一点非常重要,因为现代计算机软件系统的开发,往往开发者、测试者、部署运维者,并不是一拨人,特别是随着公有云的快速发展,我们甚至都不清楚自己的软件系统在容器下到底是什么物理架构。 + +2)高级语言分类 + +如果按照有没有虚拟机来划分,高级编程语言可分为两类: + + +有虚拟机:Java,Lua,Ruby,部分 JavaScript 的实现等等 +无虚拟机:C,C++,C#,Golang,以及大部分常见的编程语言 + + +很奇怪的一件事儿,C#、Golang 有 GC(垃圾回收),也有运行时(Runtime),但是没有虚拟机(VM),为什么会这样设计呢? 下文会详细讨论这个事情。 + +如果按照变量是不是有确定的类型,还是类型可以随意变化来划分,高级编程语言可以分为: + + +静态类型:Java,C,C++ 等等 +动态类型:所有脚本类型的语言 + + +如果按照是编译执行,还是解释执行,可以分为: + + +编译执行:C,C++,Golang,Rust,C#,Java,Scala,Clojure,Kotlin,Swift 等等 +解释执行:JavaScript 的部分实现和 NodeJS,Python,Perl,Ruby 等等 + + +这里面,C# 和 Java 都是编译后生成了一种中间类型的目标代码(类似汇编),但不是汇编或机器码,在C#中称为 微软中间语言(MSIL),在 Java 里叫做 Java 字节码(Java bytecode)。 + +虽然一般把 JavaScript 当做解释执行语言,但如今不少实现引擎都支持编译,比如 Google V8 和 Oracle Nashorn。 + +此外,我们还可以按照语言特点分类: + + +面向过程:C,Basic,Pascal,Fortran 等等; +面向对象:C++,Java,Ruby,Smalltalk 等等; +函数式编程:LISP、Haskell、Erlang、OCaml、Clojure、F# 等等。 + + +有的甚至可以划分为纯面向对象语言,例如 Ruby,所有的东西都是对象(Java 不是所有东西都是对象,比如基本类型 int、long 等等,就不是对象,但是它们的包装类 Integer、Long 则是对象)。 还有既可以当做编译语言又可以当做脚本语言的,例如 Groovy 等语言。 + +3.2 关于跨平台 + +现在我们聊聊跨平台,为什么要跨平台,因为我们希望所编写的代码和程序,在源代码级别或者编译后,可以运行在多种不同的系统平台上,而不需要为了各个平台的不同点而去实现两套代码。典型地,我们编写一个 web 程序,自然希望可以把它部署到 Windows 平台上,也可以部署到 Linux 平台上,甚至是 MacOS 系统上。 + +这就是跨平台的能力,极大地节省了开发和维护成本,赢得了商业市场上的一致好评。 + +这样来看,一般来说解释型语言都是跨平台的,同一份脚本代码,可以由不同平台上的解释器解释执行。但是对于编译型语言,存在两种级别的跨平台: 源码跨平台和二进制跨平台。 + +1、典型的源码跨平台(C++): + +2、典型的二进制跨平台(Java 字节码): + +可以看到,C++ 里我们需要把一份源码,在不同平台上分别编译,生成这个平台相关的二进制可执行文件,然后才能在相应的平台上运行。 这样就需要在各个平台都有开发工具和编译器,而且在各个平台所依赖的开发库都需要是一致或兼容的。 这一点在过去的年代里非常痛苦,被戏称为 “依赖地狱”。 + +C++ 的口号是“一次编写,到处(不同平台)编译”,但实际情况上是一编译就报错,变成了 “一次编写,到处调试,到处找依赖、改配置”。 大家可以想象,你编译一份代码,发现缺了几十个依赖,到处找还找不到,或者找到了又跟本地已有的版本不兼容,这是一件怎样令人绝望的事情。 + +而 Java 语言通过虚拟机技术率先解决了这个难题。 源码只需要编译一次,然后把编译后的 class 文件或 jar 包,部署到不同平台,就可以直接通过安装在这些系统中的 JVM 上面执行。 同时可以把依赖库(jar 文件)一起复制到目标机器,慢慢地又有了可以在各个平台都直接使用的 Maven 中央库(类似于 linux 里的 yum 或 apt-get 源,macos 里的 homebrew,现代的各种编程语言一般都有了这种包依赖管理机制:python 的 pip,dotnet 的 nuget,NodeJS 的 npm,golang 的 dep,rust 的 cargo 等等)。这样就实现了让同一个应用程序在不同的平台上直接运行的能力。 + +总结一下跨平台: + + +脚本语言直接使用不同平台的解释器执行,称之为脚本跨平台,平台间的差异由不同平台上的解释器去解决。这样的话代码很通用,但是需要解释和翻译,效率较低。 +编译型语言的代码跨平台,同一份代码,需要被不同平台的编译器编译成相应的二进制文件,然后再去分发和执行,不同平台间的差异由编译器去解决。编译产生的文件是直接针对平台的可执行指令,运行效率很高。但是在不同平台上编译复杂软件,依赖配置可能会产生很多环境方面问题,导致开发和维护的成本较高。 +编译型语言的二进制跨平台,同一份代码,先编译成一份通用的二进制文件,然后分发到不同平台,由虚拟机运行时来加载和执行,这样就会综合另外两种跨平台语言的优势,方便快捷地运行于各种平台,虽然运行效率可能比起本地编译类型语言要稍低一点。 而这些优缺点也是 Java 虚拟机的优缺点。 + + + +现代商业应用最宝贵的是时间和人力, 对大部分系统来说,机器相对来说就不是那么值钱了。 + + +3.3 关于运行时(Runtime)与虚拟机(VM) + +我们前面提到了很多次 Java 运行时和JVM 虚拟机,简单的说 JRE 就是 Java 的运行时,包括虚拟机和相关的库等资源。 + +可以说运行时提供了程序运行的基本环境,JVM 在启动时需要加载所有运行时的核心库等资源,然后再加载我们的应用程序字节码,才能让应用程序字节码运行在 JVM 这个容器里。 + +但也有一些语言是没有虚拟机的,编译打包时就把依赖的核心库和其他特性支持,一起静态打包或动态链接到程序中,比如 Golang 和 Rust,C# 等。 + +这样运行时就和程序指令组合在一起,成为了一个完整的应用程序,好处就是不需要虚拟机环境,坏处是编译后的二进制文件没法直接跨平台了。 + +3.4 关于内存管理和垃圾回收(GC) + +自从编程语言诞生以来,内存管理一直都是个非常重要的话题。因为内存资源总是有限而又宝贵的,只占用不释放,很快就会用完了。程序得不到可用内存就会崩溃(想想 C++ 里动不动就出现的野指针)。 + +内存管理就是内存的生命周期管理,包括内存的申请、压缩、回收等操作。 Java 的内存管理就是 GC,JVM 的 GC 模块不仅管理内存的回收,也负责内存的分配和压缩整理。 + +我们从前面的内容可以知道,Java 程序的指令都运行在 JVM 上,而且我们的程序代码并不需要去分配内存和释放内存(例如 C/C++ 里需要使用的 malloc/free),那么这些操作自然是由JVM帮我们搞定的。 + +JVM 在我们创建 Java 对象的时候去分配新内存,并使用 GC 算法,根据对象的存活时间,在对象不使用之后,自动执行对象的内存回收操作。 + +对于 Golang 和 Rust 这些语言来说,其实也是存在垃圾回收的,但是它们没有虚拟机,又是怎么实现的呢? + +诀窍就在于运行时(Runtime),编译打包的时候,可以把内存使用分析的模块一起打包到应用程序中,在运行期间有专门的线程来分析内存使用情况,进而决定什么时候执行 GC,把不再使用的内存回收掉。 这样就算是没有虚拟机,也可以实现 GC。 + +而 Rust 语言则更进一步,直接在语言规范层面限制了所有变量的生命周期,如果超出了一个明确的范围,就会不可用,这样在编译期就能直接知道每个对象在什么时候应该分配内存,什么时候应该销毁并回收内存,做到了很精确并且很安全的内存管理。 + + +C/C++ 完全相信而且惯着程序员,让大家自行管理内存,所以可以编写很自由的代码,但一个不小心就会造成内存泄漏等问题导致程序崩溃。 +Java/Golang 完全不相信程序员,但也惯着程序员。所有的内存生命周期都由 JVM 运行时统一管理。 在绝大部分场景下,你可以非常自由的写代码,而且不用关心内存到底是什么情况。 内存使用有问题的时候,我们可以通过 JVM 来信息相关的分析诊断和调整。 这也是本课程的目标。 +Rust 语言选择既不相信程序员,也不惯着程序员。 让你在写代码的时候,必须清楚明白的用 Rust 的规则管理好你的变量,好让机器能明白高效地分析和管理内存。 但是这样会导致代码不利于人的理解,写代码很不自由,学习成本也很高。 + + +最后拿知乎上一个朋友左之了对这几种语言的评价来结尾: + + +首先,Rust 是有点反人类,否则不会一直都不火。然后,Rust 之所以反人类,是因为人类这玩意既愚蠢,又自大,破事还贼多。 你看 C++ 就很相信人类,它要求人类自己把自己 new 出来的东西给 delete 掉。 C++:“这点小事我相信你可以的!” 人类:“没问题!包在我身上!” 然后呢,内存泄漏、double free、野指针满世界飘…… C++:“……” + +Java 选择不相信人类,但替人类把事办好。 Java:“别动,让我来,我有gc!” 人类:“你怎么做事这么慢呀?你怎么还 stop the world 了呀?你是不是不爱我了呀?” Java:“……” + +Rust 发现唯一的办法就是既不相信人类,也不惯着人类。 Rust:“按老子说的做,不做就不编译!” 人类:“你反人类!” Rust:“滚!” + + +参考材料 + + +计算机编程语言的发展与应用:http://g.wanfangdata.com.cn/details/detail.do?_type=perio&id=dnbcjqywh201904012 +JavaScript引擎:https://hllvm-group.iteye.com/group/topic/37596 +GC 和虚拟机是两个一定要放在一起的概念吗?:https://www.zhihu.com/question/45910460/answer/100056649 +Rust 语言是否反人类?:https://www.zhihu.com/question/328066906/answer/708085473 + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/05Java字节码技术:不积细流,无以成江河.md b/专栏/JVM核心技术32讲(完)/05Java字节码技术:不积细流,无以成江河.md new file mode 100644 index 0000000..d06f29c --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/05Java字节码技术:不积细流,无以成江河.md @@ -0,0 +1,917 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 Java 字节码技术:不积细流,无以成江河 + Java 中的字节码,英文名为 bytecode, 是 Java 代码编译后的中间代码格式。JVM 需要读取并解析字节码才能执行相应的任务。 + +从技术人员的角度看,Java 字节码是 JVM 的指令集。JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行。 简单说字节码就是我们编写的 Java 应用程序大厦的每一块砖,如果没有字节码的支撑,大家编写的代码也就没有了用武之地,无法运行。也可以说,Java 字节码就是 JVM 执行的指令格式。 + +那么我们为什么需要掌握它呢? + +不管用什么编程语言,对于卓越而有追求的程序员,都能深入去探索一些技术细节,在需要的时候,可以在代码被执行前解读和理解中间形式的代码。对于 Java 来说,中间代码格式就是 Java 字节码。 了解字节码及其工作原理,对于编写高性能代码至关重要,对于深入分析和排查问题也有一定作用,所以我们要想深入了解 JVM 来说,了解字节码也是夯实基础的一项基本功。同时对于我们开发人员来时,不了解平台的底层原理和实现细节,想要职业进阶绝对不是长久之计,毕竟我们都希望成为更好的程序员, 对吧? + +任何有实际经验的开发者都知道,业务系统总不可能没有 BUG,了解字节码以及 Java 编译器会生成什么样的字节码,才能说具备扎实的 JVM 功底,会在排查问题和分析错误时非常有用,也能更好地解决问题。 + +而对于工具领域和程序分析来说, 字节码就是必不可少的基础知识了,通过修改字节码来调整程序的行为是司空见惯的事情。想了解分析器(Profiler),Mock 框架,AOP 等工具和技术这一类工具,则必须完全了解 Java 字节码。 + +4.1 Java 字节码简介 + +有一件有趣的事情,就如名称所示, Java bytecode 由单字节(byte)的指令组成,理论上最多支持 256 个操作码(opcode)。实际上 Java 只使用了 200 左右的操作码, 还有一些操作码则保留给调试操作。 + +操作码, 下面称为 指令, 主要由类型前缀和操作名称两部分组成。 + + +例如,’i’ 前缀代表 ‘integer’,所以,’iadd’ 很容易理解, 表示对整数执行加法运算。 + + +根据指令的性质,主要分为四个大类: + + +栈操作指令,包括与局部变量交互的指令 +程序流程控制指令 +对象操作指令,包括方法调用指令 +算术运算以及类型转换指令 + + +此外还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等。下文会对这些指令进行详细的讲解。 + +4.2 获取字节码清单 + +可以用 javap 工具来获取 class 文件中的指令清单。 javap 是标准 JDK 内置的一款工具, 专门用于反编译 class 文件。 + +让我们从头开始, 先创建一个简单的类,后面再慢慢扩充。 + +package demo.jvm0104; + +public class HelloByteCode { + public static void main(String[] args) { + HelloByteCode obj = new HelloByteCode(); + } +} + + + +代码很简单, main 方法中 new 了一个对象而已。然后我们编译这个类: + +javac demo/jvm0104/HelloByteCode.java + + + +使用 javac 编译 ,或者在 IDEA 或者 Eclipse 等集成开发工具自动编译,基本上是等效的。只要能找到对应的 class 即可。 + + +javac 不指定 -d 参数编译后生成的 .class 文件默认和源代码在同一个目录。 + +注意: javac 工具默认开启了优化功能, 生成的字节码中没有局部变量表(LocalVariableTable),相当于局部变量名称被擦除。如果需要这些调试信息, 在编译时请加上 -g 选项。有兴趣的同学可以试试两种方式的区别,并对比结果。 + +JDK 自带工具的详细用法, 请使用: javac -help 或者 javap -help 来查看; 其他类似。 + + +然后使用 javap 工具来执行反编译, 获取字节码清单: + +javap -c demo.jvm0104.HelloByteCode +# 或者: +javap -c demo/jvm0104/HelloByteCode +javap -c demo/jvm0104/HelloByteCode.class + + + +javap 还是比较聪明的, 使用包名或者相对路径都可以反编译成功, 反编译后的结果如下所示: + +Compiled from "HelloByteCode.java" +public class demo.jvm0104.HelloByteCode { + public demo.jvm0104.HelloByteCode(); + Code: + 0: aload_0 + 1: invokespecial #1 // Method java/lang/Object."":()V + 4: return + + public static void main(java.lang.String[]); + Code: + 0: new #2 // class demo/jvm0104/HelloByteCode + 3: dup + 4: invokespecial #3 // Method "":()V + 7: astore_1 + 8: return +} + + + +OK,我们成功获取到了字节码清单, 下面进行简单的解读。 + +4.3 解读字节码清单 + +可以看到,反编译后的代码清单中, 有一个默认的构造函数 public demo.jvm0104.HelloByteCode(), 以及 main 方法。 + +刚学 Java 时我们就知道, 如果不定义任何构造函数,就会有一个默认的无参构造函数,这里再次验证了这个知识点。好吧,这比较容易理解!我们通过查看编译后的 class 文件证实了其中存在默认构造函数,所以这是 Java 编译器生成的, 而不是运行时JVM自动生成的。 + +自动生成的构造函数,其方法体应该是空的,但这里看到里面有一些指令。为什么呢? + +再次回顾 Java 知识, 每个构造函数中都会先调用 super 类的构造函数对吧? 但这不是 JVM 自动执行的, 而是由程序指令控制,所以默认构造函数中也就有一些字节码指令来干这个事情。 + +基本上,这几条指令就是执行 super() 调用; + + public demo.jvm0104.HelloByteCode(); + Code: + 0: aload_0 + 1: invokespecial #1 // Method java/lang/Object."":()V + 4: return + + + +至于其中解析的 java/lang/Object 不用说, 默认继承了 Object 类。这里再次验证了这个知识点,而且这是在编译期间就确定了的。 + +继续往下看 c, + + public static void main(java.lang.String[]); + Code: + 0: new #2 // class demo/jvm0104/HelloByteCode + 3: dup + 4: invokespecial #3 // Method "":()V + 7: astore_1 + 8: return + + + +main 方法中创建了该类的一个实例, 然后就 return 了, 关于里面的几个指令, 稍后讲解。 + +4.4 查看 class 文件中的常量池信息 + +常量池 大家应该都听说过, 英文是 Constant pool。这里做一个强调: 大多数时候指的是 运行时常量池。但运行时常量池里面的常量是从哪里来的呢? 主要就是由 class 文件中的 常量池结构体 组成的。 + +要查看常量池信息, 我们得加一点魔法参数: + +javap -c -verbose demo.jvm0104.HelloByteCode + + + +在反编译 class 时,指定 -verbose 选项, 则会 输出附加信息。 + +结果如下所示: + +Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class + Last modified 2019-11-28; size 301 bytes + MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308 + Compiled from "HelloByteCode.java" +public class demo.jvm0104.HelloByteCode + minor version: 0 + major version: 52 + flags: ACC_PUBLIC, ACC_SUPER +Constant pool: + #1 = Methodref #4.#13 // java/lang/Object."":()V + #2 = Class #14 // demo/jvm0104/HelloByteCode + #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."":()V + #4 = Class #15 // java/lang/Object + #5 = Utf8 + #6 = Utf8 ()V + #7 = Utf8 Code + #8 = Utf8 LineNumberTable + #9 = Utf8 main + #10 = Utf8 ([Ljava/lang/String;)V + #11 = Utf8 SourceFile + #12 = Utf8 HelloByteCode.java + #13 = NameAndType #5:#6 // "":()V + #14 = Utf8 demo/jvm0104/HelloByteCode + #15 = Utf8 java/lang/Object +{ + public demo.jvm0104.HelloByteCode(); + descriptor: ()V + flags: ACC_PUBLIC + Code: + stack=1, locals=1, args_size=1 + 0: aload_0 + 1: invokespecial #1 // Method java/lang/Object."":()V + 4: return + LineNumberTable: + line 3: 0 + + public static void main(java.lang.String[]); + descriptor: ([Ljava/lang/String;)V + flags: ACC_PUBLIC, ACC_STATIC + Code: + stack=2, locals=2, args_size=1 + 0: new #2 // class demo/jvm0104/HelloByteCode + 3: dup + 4: invokespecial #3 // Method "":()V + 7: astore_1 + 8: return + LineNumberTable: + line 5: 0 + line 6: 8 +} +SourceFile: "HelloByteCode.java" + + + +其中显示了很多关于 class 文件信息: 编译时间, MD5 校验和, 从哪个 .java 源文件编译得来,符合哪个版本的 Java 语言规范等等。 + +还可以看到 ACC_PUBLIC 和 ACC_SUPER 访问标志符。 ACC_PUBLIC 标志很容易理解:这个类是 public 类,因此用这个标志来表示。 + +但 ACC_SUPER 标志是怎么回事呢? 这就是历史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER 标志来修正 invokespecial 指令调用 super 类方法的问题,从 Java 1.1 开始, 编译器一般都会自动生成ACC_SUPER 标志。 + +有些同学可能注意到了, 好多指令后面使用了 #1, #2, #3 这样的编号。 + +这就是对常量池的引用。 那常量池里面有些什么呢? + +Constant pool: + #1 = Methodref #4.#13 // java/lang/Object."":()V + #2 = Class #14 // demo/jvm0104/HelloByteCode + #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."":()V + #4 = Class #15 // java/lang/Object + #5 = Utf8 +...... + + + +这是摘取的一部分内容, 可以看到常量池中的常量定义。还可以进行组合, 一个常量的定义中可以引用其他常量。 + +比如第一行: #1 = Methodref #4.#13 // java/lang/Object."":()V, 解读如下: + + +#1 常量编号, 该文件中其他地方可以引用。 += 等号就是分隔符. +Methodref 表明这个常量指向的是一个方法;具体是哪个类的哪个方法呢? 类指向的 #4, 方法签名指向的 #13; 当然双斜线注释后面已经解析出来可读性比较好的说明了。 + + +同学们可以试着解析其他的常量定义。 自己实践加上知识回顾,能有效增加个人的记忆和理解。 + +总结一下,常量池就是一个常量的大字典,使用编号的方式把程序里用到的各类常量统一管理起来,这样在字节码操作里,只需要引用编号即可。 + +4.5 查看方法信息 + +在 javap 命令中使用 -verbose 选项时, 还显示了其他的一些信息。 例如, 关于 main 方法的更多信息被打印出来: + + public static void main(java.lang.String[]); + descriptor: ([Ljava/lang/String;)V + flags: ACC_PUBLIC, ACC_STATIC + Code: + stack=2, locals=2, args_size=1 + + + +可以看到方法描述: ([Ljava/lang/String;)V: + + +其中小括号内是入参信息/形参信息; +左方括号表述数组; +L 表示对象; +后面的java/lang/String就是类名称; +小括号后面的 V 则表示这个方法的返回值是 void; +方法的访问标志也很容易理解 flags: ACC_PUBLIC, ACC_STATIC,表示 public 和 static。 + + +还可以看到执行该方法时需要的栈(stack)深度是多少,需要在局部变量表中保留多少个槽位, 还有方法的参数个数: stack=2, locals=2, args_size=1。把上面这些整合起来其实就是一个方法: + + +public static void main(java.lang.String[]); + +注:实际上我们一般把一个方法的修饰符+名称+参数类型清单+返回值类型,合在一起叫“方法签名”,即这些信息可以完整的表示一个方法。 + + +稍微往回一点点,看编译器自动生成的无参构造函数字节码: + + public demo.jvm0104.HelloByteCode(); + descriptor: ()V + flags: ACC_PUBLIC + Code: + stack=1, locals=1, args_size=1 + 0: aload_0 + 1: invokespecial #1 // Method java/lang/Object."":()V + 4: return + + + +你会发现一个奇怪的地方, 无参构造函数的参数个数居然不是 0: stack=1, locals=1, args_size=1。 这是因为在 Java 中, 如果是静态方法则没有 this 引用。 对于非静态方法, this 将被分配到局部变量表的第 0 号槽位中, 关于局部变量表的细节,下面再进行介绍。 + + +有反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2); + + +4.6 线程栈与字节码执行模型 + +想要深入了解字节码技术,我们需要先对字节码的执行模型有所了解。 + +JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧(Frame)。每一次方法调用,JVM都会自动创建一个栈帧。栈帧 由 操作数栈, 局部变量数组 以及一个class 引用组成。class 引用 指向当前方法在运行时常量池中对应的 class)。 + +我们在前面反编译的代码中已经看到过这些内容。 + + + +局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。 + +有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。 + +4.7 方法体中的字节码解读 + +看过前面的示例,细心的同学可能会猜测,方法体中那些字节码指令前面的数字是什么意思,说是序号吧但又不太像,因为他们之间的间隔不相等。看看 main 方法体对应的字节码: + + 0: new #2 // class demo/jvm0104/HelloByteCode + 3: dup + 4: invokespecial #3 // Method "":()V + 7: astore_1 + 8: return + + + +间隔不相等的原因是, 有一部分操作码会附带有操作数, 也会占用字节码数组中的空间。 + +例如, new 就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。 + +因此,下一条指令 dup 的索引从 3 开始。 + +如果将这个方法体变成可视化数组,那么看起来应该是这样的: + + + +每个操作码/指令都有对应的十六进制(HEX)表示形式, 如果换成十六进制来表示,则方法体可表示为HEX字符串。例如上面的方法体百世成十六进制如下所示: + + + +甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串: + + (此图由开源文本编辑软件Atom的hex-view插件生成) + +粗暴一点,我们可以通过 HEX 编辑器直接修改字节码,尽管这样做会有风险, 但如果只修改一个数值的话应该会很有趣。 + +其实要使用编程的方式,方便和安全地实现字节码编辑和修改还有更好的办法,那就是使用 ASM 和 Javassist 之类的字节码操作工具,也可以在类加载器和 Agent 上面做文章,下一节课程会讨论 类加载器,其他主题则留待以后探讨。 + +4.8 对象初始化指令:new 指令, init 以及 clinit 简介 + +我们都知道 new是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码: + + 0: new #2 // class demo/jvm0104/HelloByteCode + 3: dup + 4: invokespecial #3 // Method "":()V + + + +当你同时看到 new, dup 和 invokespecial 指令在一起时,那么一定是在创建类的实例对象! + +为什么是三条指令而不是一条呢?这是因为: + + +new 指令只是创建对象,但没有调用构造函数。 +invokespecial 指令用来调用某些特殊方法的, 当然这里调用的是构造函数。 +dup 指令用于复制栈顶的值。 + + +由于构造函数调用不会返回值,所以如果没有 dup 指令, 在对象上调用方法并初始化之后,操作数栈就会是空的,在初始化之后就会出问题, 接下来的代码就无法对其进行处理。 + +这就是为什么要事先复制引用的原因,为的是在构造函数返回之后,可以将对象实例赋值给局部变量或某个字段。因此,接下来的那条指令一般是以下几种: + + +astore {N} or astore_{N} – 赋值给局部变量,其中 {N} 是局部变量表中的位置。 +putfield – 将值赋给实例字段 +putstatic – 将值赋给静态字段 + + +在调用构造函数的时候,其实还会执行另一个类似的方法 ,甚至在执行构造函数之前就执行了。 + +还有一个可能执行的方法是该类的静态初始化方法 , 但 并不能被直接调用,而是由这些指令触发的: new, getstatic, putstatic or invokestatic。 + +也就是说,如果创建某个类的新实例, 访问静态字段或者调用静态方法,就会触发该类的静态初始化方法【如果尚未初始化】。 + +实际上,还有一些情况会触发静态初始化, 详情请参考 JVM 规范: [http://docs.oracle.com/javase/specs/jvms/se8/html/] + +4.9 栈内存操作指令 + +有很多指令可以操作方法栈。 前面也提到过一些基本的栈操作指令: 他们将值压入栈,或者从栈中获取值。 除了这些基础操作之外也还有一些指令可以操作栈内存; 比如 swap 指令用来交换栈顶两个元素的值。下面是一些示例: + +最基础的是 dup 和 pop 指令。 + + +dup 指令复制栈顶元素的值。 +pop 指令则从栈中删除最顶部的值。 + + +还有复杂一点的指令:比如,swap, dup_x1 和 dup2_x1。 + + +顾名思义,swap 指令可交换栈顶两个元素的值,例如A和B交换位置(图中示例4); +dup_x1 将复制栈顶元素的值,并在栈顶插入两次(图中示例5); +dup2_x1 则复制栈顶两个元素的值,并插入第三个值(图中示例6)。 + + + + +dup_x1 和 dup2_x1 指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值? + +请看一个实际案例:怎样交换 2 个 double 类型的值? + +需要注意的是,一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。 + +要执行交换,你可能想到了 swap 指令,但问题是 swap 只适用于单字(one-word, 单字一般指 32 位 4 个字节,64 位则是双字),所以不能处理 double 类型,但 Java 中又没有 swap2 指令。 + +怎么办呢? 解决方法就是使用 dup2_x2 指令,将操作数栈顶部的 double 值,复制到栈底 double 值的下方, 然后再使用 pop2 指令弹出栈顶的 double 值。结果就是交换了两个 double 值。 示意图如下图所示: + + + +dup、dup_x1、dup2_x1 指令补充说明 + +指令的详细说明可参考 JVM 规范: + +dup 指令 + +官方说明是:复制栈顶的值,并将复制的值压入栈。 + +操作数栈的值变化情况(方括号标识新插入的值): + +..., value → +..., value [,value] + + + +dup_x1 指令 + +官方说明是:复制栈顶的值,并将复制的值插入到最上面 2 个值的下方。 + +操作数栈的值变化情况(方括号标识新插入的值): + +..., value2, value1 → +..., [value1,] value2, value1 + + + +dup2_x1 指令 + +官方说明是:复制栈顶 1 个 64 位/或 2 个 32 位的值, 并将复制的值按照原始顺序,插入原始值下面一个 32 位值的下方。 + +操作数栈的值变化情况(方括号标识新插入的值): + +# 情景 1: value1, value2, and value3 都是分组 1 的值(32 位元素) +..., value3, value2, value1 → +..., [value2, value1,] value3, value2, value1 + +# 情景 2: value1 是分组 2 的值(64 位,long 或double), value2 是分组 1 的值(32 位元素) +..., value2, value1 → +..., [value1,] value2, value1 + + + + +Table 2.11.1-B 实际类型与 JVM 计算类型映射和分组 + + + + + +实际类型 +JVM 计算类型 +类型分组 + + + + + +boolean +int +1 + + + +byte +int +1 + + + +char +int +1 + + + +short +int +1 + + + +int +int +1 + + + +float +float +1 + + + +reference +reference +1 + + + +returnAddress +returnAddress +1 + + + +long +long +2 + + + +double +double +2 + + + + +4.10 局部变量表 + +stack 主要用于执行指令,而局部变量则用来保存中间结果,两者之间可以直接交互。 + +让我们编写一个复杂点的示例: + +第一步,先编写一个计算移动平均数的类: + +package demo.jvm0104; +//移动平均数 +public class MovingAverage { + private int count = 0; + private double sum = 0.0D; + public void submit(double value){ + this.count ++; + this.sum += value; + } + public double getAvg(){ + if(0 == this.count){ return sum;} + return this.sum/this.count; + } +} + + + +第二步,然后写一个类来调用: + +package demo.jvm0104; +public class LocalVariableTest { + public static void main(String[] args) { + MovingAverage ma = new MovingAverage(); + int num1 = 1; + int num2 = 2; + ma.submit(num1); + ma.submit(num2); + double avg = ma.getAvg(); + } +} + + + +其中 main 方法中向 MovingAverage 类的实例提交了两个数值,并要求其计算当前的平均值。 + +然后我们需要编译(还记得前面提到, 生成调试信息的 -g 参数吗)。 + +javac -g demo/jvm0104/*.java + + + +然后使用 javap 反编译: + +javap -c -verbose demo/jvm0104/LocalVariableTest + + + +看 main 方法对应的字节码: + + public static void main(java.lang.String[]); + descriptor: ([Ljava/lang/String;)V + flags: ACC_PUBLIC, ACC_STATIC + Code: + stack=3, locals=6, args_size=1 + 0: new #2 // class demo/jvm0104/MovingAverage + 3: dup + 4: invokespecial #3 // Method demo/jvm0104/MovingAverage."":()V + 7: astore_1 + 8: iconst_1 + 9: istore_2 + 10: iconst_2 + 11: istore_3 + 12: aload_1 + 13: iload_2 + 14: i2d + 15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V + 18: aload_1 + 19: iload_3 + 20: i2d + 21: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V + 24: aload_1 + 25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D + 28: dstore 4 + 30: return + LineNumberTable: + line 5: 0 + line 6: 8 + line 7: 10 + line 8: 12 + line 9: 18 + line 10: 24 + line 11: 30 + LocalVariableTable: + Start Length Slot Name Signature + 0 31 0 args [Ljava/lang/String; + 8 23 1 ma Ldemo/jvm0104/MovingAverage; + 10 21 2 num1 I + 12 19 3 num2 I + 30 1 4 avg D + + + + + + +编号 0 的字节码 new, 创建 MovingAverage 类的对象; +编号 3 的字节码 dup 复制栈顶引用值。 +编号 4 的字节码 invokespecial 执行对象初始化。 +编号 7 开始, 使用 astore_1 指令将引用地址值(addr.)存储(store)到编号为1的局部变量中: astore_1 中的 1 指代 LocalVariableTable 中ma对应的槽位编号, +编号8开始的指令: iconst_1 和 iconst_2 用来将常量值1和2加载到栈里面, 并分别由指令 istore_2 和 istore_3 将它们存储到在 LocalVariableTable 的槽位 2 和槽位 3 中。 + + + 8: iconst_1 + 9: istore_2 + 10: iconst_2 + 11: istore_3 + + + +请注意,store 之类的指令调用实际上从栈顶删除了一个值。 这就是为什么再次使用相同值时,必须再加载(load)一次的原因。 + +例如在上面的字节码中,调用 submit 方法之前, 必须再次将参数值加载到栈中: + + 12: aload_1 + 13: iload_2 + 14: i2d + 15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V + + + +调用 getAvg() 方法后,返回的结果位于栈顶,然后使用 dstore 将 double 值保存到本地变量4号槽位,这里的d表示目标变量的类型为double。 + + 24: aload_1 + 25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D + 28: dstore 4 + + + +关于 LocalVariableTable 有个有意思的事情,就是最前面的槽位会被方法参数占用。 + +在这里,因为 main 是静态方法,所以槽位0中并没有设置为 this 引用的地址。 但是对于非静态方法来说, this 会将分配到第 0 号槽位中。 + + +再次提醒: 有过反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2); + + +理解这些字节码的诀窍在于: + +给局部变量赋值时,需要使用相应的指令来进行 store,如 astore_1。store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。 + +4.11 流程控制指令 + +流程控制指令主要是分支和循环在用, 根据检查条件来控制程序的执行流程。 + +一般是 If-Then-Else 这种三元运算符(ternary operator), Java中的各种循环,甚至异常处的理操作码都可归属于 程序流程控制。 + +然后,我们再增加一个示例,用循环来提交给 MovingAverage 类一定数量的值: + +package demo.jvm0104; +public class ForLoopTest { + private static int[] numbers = {1, 6, 8}; + public static void main(String[] args) { + MovingAverage ma = new MovingAverage(); + for (int number : numbers) { + ma.submit(number); + } + double avg = ma.getAvg(); + } +} + + + +同样执行编译和反编译: + +javac -g demo/jvm0104/*.java +javap -c -verbose demo/jvm0104/ForLoopTest + + + +因为 numbers 是本类中的 static 属性, 所以对应的字节码如下所示: + + 0: new #2 // class demo/jvm0104/MovingAverage + 3: dup + 4: invokespecial #3 // Method demo/jvm0104/MovingAverage."":()V + 7: astore_1 + 8: getstatic #4 // Field numbers:[I + 11: astore_2 + 12: aload_2 + 13: arraylength + 14: istore_3 + 15: iconst_0 + 16: istore 4 + 18: iload 4 + 20: iload_3 + 21: if_icmpge 43 + 24: aload_2 + 25: iload 4 + 27: iaload + 28: istore 5 + 30: aload_1 + 31: iload 5 + 33: i2d + 34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V + 37: iinc 4, 1 + 40: goto 18 + 43: aload_1 + 44: invokevirtual #6 // Method demo/jvm0104/MovingAverage.getAvg:()D + 47: dstore_2 + 48: return + LocalVariableTable: + Start Length Slot Name Signature + 30 7 5 number I + 0 49 0 args [Ljava/lang/String; + 8 41 1 ma Ldemo/jvm0104/MovingAverage; + 48 1 2 avg D + + + +位置 [8~16] 的指令用于循环控制。 我们从代码的声明从上往下看, 在最后面的LocalVariableTable 中: + + +0 号槽位被 main 方法的参数 args 占据了。 +1 号槽位被 ma 占用了。 +5 号槽位被 number 占用了。 +2 号槽位是for循环之后才被 avg 占用的。 + + +那么中间的 2,3,4 号槽位是谁霸占了呢? 通过分析字节码指令可以看出,在 2,3,4 槽位有 3 个匿名的局部变量(astore_2, istore_3, istore 4等指令)。 + + +2号槽位的变量保存了 numbers 的引用值,占据了 2号槽位。 +3号槽位的变量, 由 arraylength 指令使用, 得出循环的长度。 +4号槽位的变量, 是循环计数器, 每次迭代后使用 iinc 指令来递增。 + + + +如果我们的 JDK 版本再老一点, 则会在 2,3,4 槽位发现三个源码中没有出现的变量: arr$, len$, i$, 也就是循环变量。 + + +循环体中的第一条指令用于执行 循环计数器与数组长度 的比较: + + 18: iload 4 + 20: iload_3 + 21: if_icmpge 43 + + + +这段指令将局部变量表中 4号槽位 和 3号槽位的值加载到栈中,并调用 if_icmpge 指令来比较他们的值。 + +【if_icmpge 解读: if, integer, compare, great equal】, 如果一个数的值大于或等于另一个值,则程序执行流程跳转到pc=43的地方继续执行。 + +在这个例子中就是, 如果4号槽位的值 大于或等于 3号槽位的值, 循环就结束了,这里 43 位置对于的是循环后面的代码。如果条件不成立,则循环进行下一次迭代。 + +在循环体执行完,它的循环计数器加 1,然后循环跳回到起点以再次验证循环条件: + + 37: iinc 4, 1 // 4号槽位的值加1 + 40: goto 18 // 跳到循环开始的地方 + + + +4.12 算术运算指令与类型转换指令 + +Java 字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型(int, long, double, float),都有加,减,乘,除,取反的指令。 + +那么 byte 和 char, boolean 呢? JVM 是当做 int 来处理的。另外还有部分指令用于数据类型之间的转换。 + + +算术操作码和类型 + + +当我们想将 int 类型的值赋值给 long 类型的变量时,就会发生类型转换。 + + +类型转换操作码 + + +在前面的示例中, 将 int 值作为参数传递给实际上接收 double 的 submit() 方法时,可以看到, 在实际调用该方法之前,使用了类型转换的操作码: + + 31: iload 5 + 33: i2d + 34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V + + + +也就是说, 将一个 int 类型局部变量的值, 作为整数加载到栈中,然后用 i2d 指令将其转换为 double 值,以便将其作为参数传给submit方法。 + +唯一不需要将数值load到操作数栈的指令是 iinc,它可以直接对 LocalVariableTable 中的值进行运算。 其他的所有操作均使用栈来执行。 + +4.13 方法调用指令和参数传递 + +前面部分稍微提了一下方法调用: 比如构造函数是通过 invokespecial 指令调用的。 + +这里列举了各种用于方法调用的指令: + + +invokestatic,顾名思义,这个指令用于调用某个类的静态方法,这也是方法调用指令中最快的一个。 +invokespecial, 我们已经学过了, invokespecial 指令用来调用构造函数,但也可以用于调用同一个类中的 private 方法, 以及可见的超类方法。 +invokevirtual,如果是具体类型的目标对象,invokevirtual用于调用公共,受保护和打包私有方法。 +invokeinterface,当要调用的方法属于某个接口时,将使用 invokeinterface 指令。 + + + +那么 invokevirtual 和 invokeinterface 有什么区别呢?这确实是个好问题。 为什么需要 invokevirtual 和 invokeinterface 这两种指令呢? 毕竟所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了吗? + + +这么做是源于对方法调用的优化。JVM 必须先解析该方法,然后才能调用它。 + + +使用 invokestatic 指令,JVM 就确切地知道要调用的是哪个方法:因为调用的是静态方法,只能属于一个类。 +使用 invokespecial 时, 查找的数量也很少, 解析也更加容易, 那么运行时就能更快地找到所需的方法。 + + +使用 invokevirtual 和 invokeinterface 的区别不是那么明显。想象一下,类定义中包含一个方法定义表, 所有方法都有位置编号。下面的示例中:A 类包含 method1 和 method2 方法; 子类B继承A,继承了 method1,覆写了 method2,并声明了方法 method3。 + + +请注意,method1 和 method2 方法在类 A 和类 B 中处于相同的索引位置。 + + +class A + 1: method1 + 2: method2 +class B extends A + 1: method1 + 2: method2 + 3: method3 + + + +那么,在运行时只要调用 method2,一定是在位置 2 处找到它。 + +现在我们来解释invokevirtual 和 invokeinterface 之间的本质区别。 + +假设有一个接口 X 声明了 methodX 方法, 让 B 类在上面的基础上实现接口 X: + +class B extends A implements X + 1: method1 + 2: method2 + 3: method3 + 4: methodX + + + +新方法 methodX 位于索引 4 处,在这种情况下,它看起来与 method3 没什么不同。 + +但如果还有另一个类 C 也实现了 X 接口,但不继承 A,也不继承 B: + +class C implements X + 1: methodC + 2: methodX + + + +类 C 中的接口方法位置与类 B 的不同,这就是为什么运行时在 invokinterface 方面受到更多限制的原因。 与 invokinterface 相比, invokevirtual 针对具体的类型方法表是固定的,所以每次都可以精确查找,效率更高(具体的分析讨论可以参见参考材料的第一个链接)。 + +4.14 JDK7 新增的方法调用指令 invokedynamic + +Java 虚拟机的字节码指令集在 JDK7 之前一直就只有前面提到的 4 种指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。随着 JDK 7 的发布,字节码指令集新增了invokedynamic指令。这条新增加的指令是实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,同时也是 JDK 8 以后支持的 lambda 表达式的实现基础。 + +为什么要新增加一个指令呢? + +我们知道在不改变字节码的情况下,我们在 Java 语言层面想调用一个类 A 的方法 m,只有两个办法: + + +使用A a=new A(); a.m(),拿到一个 A 类型的实例,然后直接调用方法; +通过反射,通过 A.class.getMethod 拿到一个 Method,然后再调用这个Method.invoke反射调用; + + +这两个方法都需要显式的把方法 m 和类型 A 直接关联起来,假设有一个类型 B,也有一个一模一样的方法签名的 m 方法,怎么来用这个方法在运行期指定调用 A 或者 B 的 m 方法呢?这个操作在 JavaScript 这种基于原型的语言里或者是 C# 这种有函数指针/方法委托的语言里非常常见,Java 里是没有直接办法的。Java 里我们一般建议使用一个 A 和 B 公有的接口 IC,然后 IC 里定义方法 m,A 和 B 都实现接口 IC,这样就可以在运行时把 A 和 B 都当做 IC 类型来操作,就同时有了方法 m,这样的“强约束”带来了很多额外的操作。 + +而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用来描述一个跟类型 A 无关的方法 m 的签名,甚至不包括方法名称,这样就可以做到我们使用方法 m 的签名,但是直接执行的时候调用的是相同签名的另一个方法 b),可以在运行时再决定由哪个类来接收被调用的方法。在此之前,只能使用反射来实现类似的功能。该指令使得可以出现基于 JVM 的动态语言,让 jvm 更加强大。而且在 JVM 上实现动态调用机制,不会破坏原有的调用机制。这样既很好的支持了 Scala、Clojure 这些 JVM 上的动态语言,又可以支持代码里的动态 lambda 表达式。 + +RednaxelaFX 评论说: + + +简单来说就是以前设计某些功能的时候把做法写死在了字节码里,后来想改也改不了了。 所以这次给 lambda 语法设计翻译到字节码的策略是就用 invokedynamic 来作个弊,把实际的翻译策略隐藏在 JDK 的库的实现里(metafactory)可以随时改,而在外部的标准上大家只看到一个固定的 invokedynamic。 + + +参考材料 + + +Why Should I Know About Java Bytecode: https://jrebel.com/rebellabs/rebel-labs-report-mastering-java-bytecode-at-the-core-of-the-jvm/ +轻松看懂Java字节码: https://juejin.im/post/5aca2c366fb9a028c97a5609 +invokedynamic指令:https://www.cnblogs.com/wade-luffy/p/6058087.html +Java 8的Lambda表达式为什么要基于invokedynamic?:https://www.zhihu.com/question/39462935 +Invokedynamic:https://www.jianshu.com/p/ad7d572196a8 +JVM之动态方法调用:invokedynamic: https://ifeve.com/jvm%E4%B9%8B%E5%8A%A8%E6%80%81%E6%96%B9%E6%B3%95%E8%B0%83%E7%94%A8%EF%BC%9Ainvokedynamic/ + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/06Java类加载器:山不辞土,故能成其高.md b/专栏/JVM核心技术32讲(完)/06Java类加载器:山不辞土,故能成其高.md new file mode 100644 index 0000000..9d91a05 --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/06Java类加载器:山不辞土,故能成其高.md @@ -0,0 +1,455 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 Java 类加载器:山不辞土,故能成其高 + 前面我们学习了 Java 字节码,写好的代码经过编译变成了字节码,并且可以打包成 Jar 文件。 + +然后就可以让 JVM 去加载需要的字节码,变成持久代/元数据区上的 Class 对象,接着才会执行我们的程序逻辑。 + +我们可以用 Java 命令指定主启动类,或者是 Jar 包,通过约定好的机制,JVM 就会自动去加载对应的字节码(可能是 class 文件,也可能是 Jar 包)。 + +我们知道 Jar 包打开后实际上就等价于一个文件夹,里面有很多 class 文件和资源文件,但是为了方便就打包成 zip 格式。 当然解压了之后照样可以直接用 java 命令来执行。 + +$ java Hello + + + +或者把 Hello.class 和依赖的其他文件一起打包成 jar 文件: + + +示例 1: 将 class 文件和 java 源文件归档到一个名为 hello.jar 的档案中: jar cvf hello.jar Hello.class Hello.java 示例 2: 归档的同时,通过 e 选项指定 jar 的启动类 Hello: jar cvfe hello.jar Hello Hello.class Hello.java + + +然后通过 -jar 选项来执行jar包: + +$ java -jar hello.jar + + + +当然我们回过头来还可以把 jar 解压了,再用上面的 java 命令来运行。 + +运行 java 程序的第一步就是加载 class 文件/或输入流里面包含的字节码。 + + +类的生命周期和加载过程 +类加载时机 +类加载机制 +自定义类加载器示例 +一些实用技巧 + + + +如何排查找不到 Jar 包的问题? +如何排查类的方法不一致的问题? +怎么看到加载了哪些类,以及加载顺序? +怎么调整或修改 ext 和本地加载路径? +怎么运行期加载额外的 jar 包或者 class 呢? + + +按照 Java 语言规范和 Java 虚拟机规范的定义, 我们用 “类加载(Class Loading)” 来表示: 将 class/interface 名称映射为 Class 对象的一整个过程。 这个过程还可以划分为更具体的阶段: 加载,链接和初始化(loading, linking and initializing)。 + +那么加载 class 的过程中到底发生了些什么呢?我们来详细看看。 + +5.1 类的生命周期和加载过程 + + + +一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。 + +其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载,下面我们就分别来说一下这五个过程。 + +1)加载 加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流,简单点说就是找到文件系统中/jar 包中/或存在于任何地方的“class 文件”。 如果找不到二进制表示形式,则会抛出 NoClassDefFound 错误。 + +装载阶段并不会检查 classfile 的语法和格式。 类加载的整个过程主要由 JVM 和 Java 的类加载系统共同完成, 当然具体到 loading 阶段则是由 JVM 与具体的某一个类加载器(java.lang.classLoader)协作完成的。 + +2)校验 链接过程的第一个阶段是 校验,确保 class 文件里的字节流信息符合当前虚拟机的要求,不会危害虚拟机的安全。 + +校验过程检查 classfile 的语义,判断常量池中的符号,并执行类型检查, 主要目的是判断字节码的合法性,比如 magic number, 对版本号进行验证。 这些检查过程中可能会抛出 VerifyError, ClassFormatError 或 UnsupportedClassVersionError。 + +因为 classfile 的验证属是链接阶段的一部分,所以这个过程中可能需要加载其他类,在某个类的加载过程中,JVM 必须加载其所有的超类和接口。 + +如果类层次结构有问题(例如,该类是自己的超类或接口,死循环了),则 JVM 将抛出 ClassCircularityError。 而如果实现的接口并不是一个 interface,或者声明的超类是一个 interface,也会抛出 IncompatibleClassChangeError。 + +3)准备 + +然后进入准备阶段,这个阶段将会创建静态字段, 并将其初始化为标准默认值(比如null或者0 值),并分配方法表,即在方法区中分配这些变量所使用的内存空间。 + +请注意,准备阶段并未执行任何 Java 代码。 + +例如: + + +public static int i = 1; + + +在准备阶段i的值会被初始化为 0,后面在类初始化阶段才会执行赋值为 1;但是下面如果使用 final 作为静态常量,某些 JVM 的行为就不一样了: + + +public static final int i = 1; 对应常量 i,在准备阶段就会被赋值 1,其实这样还是比较 puzzle,例如其他语言(C#)有直接的常量关键字 const,让告诉编译器在编译阶段就替换成常量,类似于宏指令,更简单。 + + +4)解析 然后进入可选的解析符号引用阶段。 也就是解析常量池,主要有以下四种:类或接口的解析、字段解析、类方法解析、接口方法解析。 + +简单的来说就是我们编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class 文件中是以符号引用来存储的(相当于做了一个索引记录)。 + +在解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直接引用,那引用的目标必定在堆中存在。 + +加载一个 class 时, 需要加载所有的 super 类和 super 接口。 + +5)初始化 JVM 规范明确规定, 必须在类的首次“主动使用”时才能执行类初始化。 + +初始化的过程包括执行: + + +类构造器方法 +static 静态变量赋值语句 +static 静态代码块 + + +如果是一个子类进行初始化会先对其父类进行初始化,保证其父类在子类之前进行初始化。所以其实在 java 中初始化一个类,那么必然先初始化过 java.lang.Object 类,因为所有的 java 类都继承自 java.lang.Object。 + + +只要我们尊重语言的语义,在执行下一步操作之前完成 装载,链接和初始化这些步骤,如果出错就按照规定抛出相应的错误,类加载系统完全可以根据自己的策略,灵活地进行符号解析等链接过程。 为了提高性能,HotSpot JVM 通常要等到类初始化时才去装载和链接类。 因此,如果 A 类引用了 B 类,那么加载 A 类并不一定会去加载 B 类(除非需要进行验证)。 主动对 B 类执行第一条指令时才会导致 B 类的初始化,这就需要先完成对 B 类的装载和链接。 + + +5.2 类加载时机 + +了解了类的加载过程,我们再看看类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况: + + +当虚拟机启动时,初始化用户指定的主类,就是启动执行的 main 方法所在的类; +当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类,就是 new 一个类的时候要初始化; +当遇到调用静态方法的指令时,初始化该静态方法所在的类; +当遇到访问静态字段的指令时,初始化该静态字段所在的类; +子类的初始化会触发父类的初始化; +如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化; +使用反射 API 对某个类进行反射调用时,初始化这个类,其实跟前面一样,反射调用要么是已经有实例了,要么是静态方法,都需要初始化; +当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。 + + +同时以下几种情况不会执行类初始化: + + +通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。 +定义对象数组,不会触发该类的初始化。 +常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。 +通过类名获取 Class 对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化。 +通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。Class.forName(“jvm.Hello”)默认会加载 Hello 类。 +通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作(加载了,但是不初始化)。 + + +示例: 诸如 Class.forName(), classLoader.loadClass() 等 Java API, 反射API, 以及 JNI_FindClass 都可以启动类加载。 JVM 本身也会进行类加载。 比如在 JVM 启动时加载核心类,java.lang.Object, java.lang.Thread 等等。 + +5.3 类加载器机制 + +类加载过程可以描述为“通过一个类的全限定名 a.b.c.XXClass 来获取描述此类的 Class 对象”,这个过程由“类加载器(ClassLoader)”来完成。这样的好处在于,子类加载器可以复用父加载器加载的类。系统自带的类加载器分为三种: + + +启动类加载器(BootstrapClassLoader) +扩展类加载器(ExtClassLoader) +应用类加载器(AppClassLoader) + + +一般启动类加载器是由 JVM 内部实现的,在 Java 的 API 里无法拿到,但是我们可以侧面看到和影响它(后面的内容会演示)。后 2 种类加载器在 Oracle Hotspot JVM 里,都是在中sun.misc.Launcher定义的,扩展类加载器和应用类加载器一般都继承自URLClassLoader类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。 + + + + +启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib/rt.jar里所有的class)。它可以看做是 JVM 自带的,我们再代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 null。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。 +扩展类加载器(extensions class loader):它负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。 +应用类加载器(app class loader):它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。 + + +此外还可以自定义类加载器。如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器。应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。 + + + +类加载机制有三个特点: + + +双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。 +负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。 +缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。 + + +5.4 自定义类加载器示例 + +同时我们可以自行实现类加载器来加载其他格式的类,对加载方式、加载数据的格式进行自定义处理,只要能通过 classloader 返回一个 Class 实例即可。这就大大增强了加载器灵活性。比如我们试着实现一个可以用来处理简单加密的字节码的类加载器,用来保护我们的 class 字节码文件不被使用者直接拿来破解。 + +我们先来看看我们希望加载的一个 Hello 类: + +package jvm; + +public class Hello { + static { + System.out.println("Hello Class Initialized!"); + } +} + + + +这个 Hello 类非常简单,就是在自己被初始化的时候,打印出来一句“Hello Class Initialized!”。假设这个类的内容非常重要,我们不想把编译到得到的 Hello.class 给别人,但是我们还是想别人可以调用或执行这个类,应该怎么办呢?一个简单的思路是,我们把这个类的 class 文件二进制作为字节流先加密一下,然后尝试通过自定义的类加载器来加载加密后的数据。为了演示简单,我们使用 jdk 自带的 Base64 算法,把字节码加密成一个文本。在下面这个例子里,我们实现一个 HelloClassLoader,它继承自 ClassLoader 类,但是我们希望它通过我们提供的一段 Base64 字符串,来还原出来,并执行我们的 Hello 类里的打印一串字符串的逻辑。 + +package jvm; + +import java.util.Base64; + +public class HelloClassLoader extends ClassLoader { + + public static void main(String[] args) { + try { + new HelloClassLoader().findClass("jvm.Hello").newInstance(); // 加载并初始化Hello类 + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InstantiationException e) { + e.printStackTrace(); + } + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + + String helloBase64 = "yv66vgAAADQAHwoABgARCQASABMIABQKABUAFgcAFwcAGAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2N" + + "hbFZhcmlhYmxlVGFibGUBAAR0aGlzAQALTGp2bS9IZWxsbzsBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhDAAHAAgHABkMABoAGwEAGEhlb" + + "GxvIENsYXNzIEluaXRpYWxpemVkIQcAHAwAHQAeAQAJanZtL0hlbGxvAQAQamF2YS9sYW5nL09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2" + + "YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAgABAAcACA" + + "ABAAkAAAAvAAEAAQAAAAUqtwABsQAAAAIACgAAAAYAAQAAAAMACwAAAAwAAQAAAAUADAANAAAACAAOAAgAAQAJAAAAJQACAAAAAAAJsgACEgO2AASxAAAAAQAK" + + "AAAACgACAAAABgAIAAcAAQAPAAAAAgAQ"; + + byte[] bytes = decode(helloBase64); + return defineClass(name,bytes,0,bytes.length); + } + + public byte[] decode(String base64){ + return Base64.getDecoder().decode(base64); + } + +} + + + +直接执行这个类: + + +$ java jvm.HelloClassLoader Hello Class Initialized! + + +可以看到达到了我们的目的,成功执行了Hello类的代码,但是完全不需要有Hello这个类的class文件。此外,需要说明的是两个没有关系的自定义类加载器之间加载的类是不共享的(只共享父类加载器,兄弟之间不共享),这样就可以实现不同的类型沙箱的隔离性,我们可以用多个类加载器,各自加载同一个类的不同版本,大家可以相互之间不影响彼此,从而在这个基础上可以实现类的动态加载卸载,热插拔的插件机制等,具体信息大家可以参考OSGi等模块化技术。 + +5.5 一些实用技巧 + +1)如何排查找不到Jar包的问题? + +有时候我们会面临明明已经把某个jar加入到了环境里,可以运行的时候还是找不到。那么我们有没有一种方法,可以直接看到各个类加载器加载了哪些jar,以及把哪些路径加到了classpath里?答案是肯定的,代码如下: + +package jvm; + +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; + +public class JvmClassLoaderPrintPath { + + public static void main(String[] args) { + + // 启动类加载器 + URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs(); + System.out.println("启动类加载器"); + for(URL url : urls) { + System.out.println(" ==> " +url.toExternalForm()); + } + + // 扩展类加载器 + printClassLoader("扩展类加载器", JvmClassLoaderPrintPath.class.getClassLoader().getParent()); + + // 应用类加载器 + printClassLoader("应用类加载器", JvmClassLoaderPrintPath.class.getClassLoader()); + + } + + public static void printClassLoader(String name, ClassLoader CL){ + if(CL != null) { + System.out.println(name + " ClassLoader -> " + CL.toString()); + printURLForClassLoader(CL); + }else{ + System.out.println(name + " ClassLoader -> null"); + } + } + + public static void printURLForClassLoader(ClassLoader CL){ + + Object ucp = insightField(CL,"ucp"); + Object path = insightField(ucp,"path"); + ArrayList ps = (ArrayList) path; + for (Object p : ps){ + System.out.println(" ==> " + p.toString()); + } + } + + private static Object insightField(Object obj, String fName) { + try { + Field f = null; + if(obj instanceof URLClassLoader){ + f = URLClassLoader.class.getDeclaredField(fName); + }else{ + f = obj.getClass().getDeclaredField(fName); + } + f.setAccessible(true); + return f.get(obj); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } +} + + + +代码执行结果如下: + +启动类加载器 + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/resources.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/rt.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/sunrsasign.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/jsse.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/jce.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/charsets.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/jfr.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/classes + +扩展类加载器 ClassLoader -> sun.misc.Launcher$ExtClassLoader@15db9742 + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/access-bridge-64.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/cldrdata.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/dnsns.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/jaccess.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/jfxrt.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/localedata.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/nashorn.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunec.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunjce_provider.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunmscapi.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunpkcs11.jar + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/zipfs.jar + +应用类加载器 ClassLoader -> sun.misc.Launcher$AppClassLoader@73d16e93 + ==> file:/D:/git/studyjava/build/classes/java/main/ + ==> file:/D:/git/studyjava/build/resources/main + + + +从打印结果,我们可以看到三种类加载器各自默认加载了哪些 jar 包和包含了哪些 classpath 的路径。 + +2)如何排查类的方法不一致的问题? + +假如我们确定一个 jar 或者 class 已经在 classpath 里了,但是却总是提示java.lang.NoSuchMethodError,这是怎么回事呢?很可能是加载了错误的或者重复加载了不同版本的 jar 包。这时候,用前面的方法就可以先排查一下,加载了具体什么 jar,然后是不是不同路径下有重复的 class 文件,但是版本不一样。 + +3)怎么看到加载了哪些类,以及加载顺序? + +还是针对上一个问题,假如有两个地方有 Hello.class,一个是新版本,一个是旧的,怎么才能直观地看到他们的加载顺序呢?也没有问题,我们可以直接打印加载的类清单和加载顺序。 + +只需要在类的启动命令行参数加上-XX:+TraceClassLoading 或者 -verbose 即可,注意需要加载 Java 命令之后,要执行的类名之前,不然不起作用。例如: + +$ java -XX:+TraceClassLoading jvm.HelloClassLoader +[Opened D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.Object from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.io.Serializable from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.Comparable from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.CharSequence from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.String from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.reflect.AnnotatedElement from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.reflect.GenericDeclaration from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.reflect.Type from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.Class from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.Cloneable from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.ClassLoader from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.System from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +// ....... 此处省略了100多条类加载信息 +[Loaded jvm.Hello from __JVM_DefineClass__] +[Loaded java.util.concurrent.ConcurrentHashMap$ForwardingNode from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +Hello Class Initialized! +[Loaded java.lang.Shutdown from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] +[Loaded java.lang.Shutdown$Lock from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar] + + + +上面的信息,可以很清楚的看到类的加载先后顺序,以及是从哪个 jar 里加载的,这样排查类加载的问题非常方便。 + +4)怎么调整或修改 ext 和本地加载路径? + +从前面的例子我们可以看到,假如什么都不设置,直接执行 java 命令,默认也会加载非常多的 jar 包,怎么可以自定义加载哪些 jar 包呢?比如我的代码很简单,只加载 rt.jar 行不行?答案是肯定的。 + +$ java -Dsun.boot.class.path="D:\Program Files\Java\jre1.8.0_231\lib\rt.jar" -Djava.ext.dirs= jvm.JvmClassLoaderPrintPath + +启动类加载器 + ==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/rt.jar +扩展类加载器 ClassLoader -> sun.misc.Launcher$ExtClassLoader@15db9742 +应用类加载器 ClassLoader -> sun.misc.Launcher$AppClassLoader@73d16e93 + ==> file:/D:/git/studyjava/build/classes/java/main/ + ==> file:/D:/git/studyjava/build/resources/main + + + +我们看到启动类加载器只加载了 rt.jar,而扩展类加载器什么都没加载,这就达到了我们的目的。 + +其中命令行参数-Dsun.boot.class.path表示我们要指定启动类加载器加载什么,最基础的东西都在 rt.jar 这个包了里,所以一般配置它就够了。需要注意的是因为在 windows 系统默认 JDK 安装路径有个空格,所以需要把整个路径用双引号括起来,如果路径没有空格,或是 Linux/Mac 系统,就不需要双引号了。 + +参数-Djava.ext.dirs表示扩展类加载器要加载什么,一般情况下不需要的话可以直接配置为空即可。 + +5)怎么运行期加载额外的 jar 包或者 class 呢? + +有时候我们在程序已经运行了以后,还是想要再额外的去加载一些 jar 或类,需要怎么做呢? + +简单说就是不使用命令行参数的情况下,怎么用代码来运行时改变加载类的路径和方式。假如说,在d:/app/jvm路径下,有我们刚才使用过的 Hello.class 文件,怎么在代码里能加载这个 Hello 类呢? + +两个办法,一个是前面提到的自定义 ClassLoader 的方式,还有一个就是直接在当前的应用类加载器里,使用 URLClassLoader 类的方法 addURL,不过这个方法是 protected 的,需要反射处理一下,然后又因为程序在启动时并没有显示加载 Hello 类,所以在添加完了 classpath 以后,没法直接显式初始化,需要使用 Class.forName 的方式来拿到已经加载的Hello类(Class.forName(“jvm.Hello”)默认会初始化并执行静态代码块)。代码如下: + +package jvm; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; + +public class JvmAppClassLoaderAddURL { + + public static void main(String[] args) { + + String appPath = "file:/d:/app/"; + URLClassLoader urlClassLoader = (URLClassLoader) JvmAppClassLoaderAddURL.class.getClassLoader(); + try { + Method addURL = URLClassLoader.class.getDeclaredMethod("addURL", URL.class); + addURL.setAccessible(true); + URL url = new URL(appPath); + addURL.invoke(urlClassLoader, url); + Class.forName("jvm.Hello"); // 效果跟Class.forName("jvm.Hello").newInstance()一样 + } catch (Exception e) { + e.printStackTrace(); + } + } +} + + + +执行以下,结果如下: + + +$ java JvmAppClassLoaderAddURL Hello Class Initialized! + + +结果显示 Hello 类被加载,成功的初始化并执行了其中的代码逻辑。 + +参考链接 + + +HotSpot虚拟机运行时系统 + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/07Java内存模型:海不辞水,故能成其深.md b/专栏/JVM核心技术32讲(完)/07Java内存模型:海不辞水,故能成其深.md new file mode 100644 index 0000000..e301624 --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/07Java内存模型:海不辞水,故能成其深.md @@ -0,0 +1,239 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 Java 内存模型:海不辞水,故能成其深 + 了解计算机历史的同学应该知道,计算机刚刚发明的时候,是没有内存这个概念的,速度慢到无法忍受。 直到冯诺依曼提出了一个天才的设计才解决了这个问题,没错,这个设计就是加了内存,所以现代的电子计算机又叫做“冯诺依曼机”。 + +JVM 是一个完整的计算机模型,所以自然就需要有对应的内存模型,这个模型被称为 “Java 内存模型”,对应的英文是“Java Memory Model”,简称 JMM。 + +Java 内存模型规定了 JVM 应该如何使用计算机内存(RAM)。 广义来讲, Java 内存模型分为两个部分: + + +JVM 内存结构 +JMM 与线程规范 + + +其中,JVM 内存结构是底层实现,也是我们理解和认识 JMM 的基础。 大家熟知的堆内存、栈内存等运行时数据区的划分就可以归为 JVM 内存结构。 + +就像很多神书讲 JVM 开篇就讲怎么编译 JVM 一样,讲 JMM 一上来就引入 CPU 寄存器的同步机制。虽然看起来高大上、显得高深莫测,但是大家很难理解。 + +所以我们这节课先从基础讲起,避开生涩的一些过于底层的术语,学习基本的 JVM 内存结构。理解了这些基本的知识点,然后再来学习 JMM 和线程相关的知识。 + +6.1 JVM 内存结构 + +我们先来看看 JVM 整体的内存概念图: + +JVM 内部使用的 Java 内存模型, 在逻辑上将内存划分为 线程栈(thread stacks)和堆内存 (heap)两个部分。 如下图所示: + + + +JVM 中,每个正在运行的线程,都有自己的线程栈。 线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息。 + +所以线程栈又被称为“方法栈”或“调用栈”(call stack)。线程在执行代码时,调用栈中的信息会一直在变化。 + +线程栈里面保存了调用链上正在执行的所有方法中的局部变量。 + + +每个线程都只能访问自己的线程栈。 +每个线程都不能访问(看不见)其他线程的局部变量。 + + +即使两个线程正在执行完全相同的代码,但每个线程都会在自己的线程栈内创建对应代码中声明的局部变量。 所以每个线程都有一份自己的局部变量副本。 + + +所有原生类型的局部变量都存储在线程栈中,因此对其他线程是不可见的。 +线程可以将一个原生变量值的副本传给另一个线程,但不能共享原生局部变量本身。 +堆内存中包含了 Java 代码中创建的所有对象,不管是哪个线程创建的。 其中也涵盖了包装类型(例如Byte,Integer,Long等)。 +不管是创建一个对象并将其赋值给局部变量, 还是赋值给另一个对象的成员变量, 创建的对象都会被保存到堆内存中。 + + +下图演示了线程栈上的调用栈和局部变量,以及存储在堆内存中的对象: + + + + +如果是原生数据类型的局部变量,那么它的内容就全部保留在线程栈上。 +如果是对象引用,则栈中的局部变量槽位中保存着对象的引用地址,而实际的对象内容保存在堆中。 +对象的成员变量与对象本身一起存储在堆上, 不管成员变量的类型是原生数值,还是对象引用。 +类的静态变量则和类定义一样都保存在堆中。 + + +总结一下:原始数据类型和对象引用地址在栈上;对象、对象成员与类定义、静态变量在堆上。 + +堆内存又称为“共享堆”,堆中的所有对象,可以被所有线程访问, 只要他们能拿到对象的引用地址。 + + +如果一个线程可以访问某个对象时,也就可以访问该对象的成员变量。 +如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。 + + +示意图如下所示: + + + +总结一下:虽然各个线程自己使用的局部变量都在自己的栈上,但是大家可以共享堆上的对象,特别地各个不同线程访问同一个对象实例的基础类型的成员变量,会给每个线程一个变量的副本。 + +6.2 栈内存的结构 + +根据以上内容和对 JVM 内存划分的理解,制作了几张逻辑概念图供大家参考。 + +先看看栈内存(Stack)的大体结构: + + + +每启动一个线程,JVM 就会在栈空间栈分配对应的线程栈, 比如 1MB 的空间(-Xss1m)。 + +线程栈也叫做 Java 方法栈。 如果使用了 JNI 方法,则会分配一个单独的本地方法栈(Native Stack)。 + +线程执行过程中,一般会有多个方法组成调用栈(Stack Trace), 比如 A 调用 B,B 调用 C……每执行到一个方法,就会创建对应的栈帧(Frame)。 + + + +栈帧是一个逻辑上的概念,具体的大小在一个方法编写完成后基本上就能确定。 + +比如 返回值 需要有一个空间存放吧,每个局部变量都需要对应的地址空间,此外还有给指令使用的 操作数栈,以及 class 指针(标识这个栈帧对应的是哪个类的方法, 指向非堆里面的 Class 对象)。 + +6.3 堆内存的结构 + +Java 程序除了栈内存之外,最主要的内存区域就是堆内存了。 + + + +堆内存是所有线程共用的内存空间,理论上大家都可以访问里面的内容。 + +但 JVM 的具体实现一般会有各种优化。比如将逻辑上的 Java 堆,划分为堆(Heap)和非堆(Non-Heap)两个部分.。这种划分的依据在于,我们编写的 Java 代码,基本上只能使用 Heap 这部分空间,发生内存分配和回收的主要区域也在这部分,所以有一种说法,这里的 Heap 也叫 GC 管理的堆(GC Heap)。 + +GC 理论中有一个重要的思想,叫做分代。 经过研究发现,程序中分配的对象,要么用过就扔,要么就能存活很久很久。 + +因此,JVM 将 Heap 内存分为年轻代(Young generation)和老年代(Old generation, 也叫 Tenured)两部分。 + +年轻代还划分为 3 个内存池,新生代(Eden space)和存活区(Survivor space), 在大部分 GC 算法中有 2 个存活区(S0, S1),在我们可以观察到的任何时刻,S0 和 S1 总有一个是空的, 但一般较小,也不浪费多少空间。 + +具体实现对新生代还有优化,那就是 TLAB(Thread Local Allocation Buffer), 给每个线程先划定一小片空间,你创建的对象先在这里分配,满了再换。这能极大降低并发资源锁定的开销。 + +Non-Heap 本质上还是 Heap,只是一般不归 GC 管理,里面划分为 3 个内存池。 + + +Metaspace, 以前叫持久代(永久代, Permanent generation), Java8 换了个名字叫 Metaspace. Java8 将方法区移动到了 Meta 区里面,而方法又是class的一部分和 CCS 交叉了? +CCS, Compressed Class Space, 存放 class 信息的,和 Metaspace 有交叉。 +Code Cache, 存放 JIT 编译器编译后的本地机器代码。 + + +JVM 的内存结构大致如此。 掌握了这些基础知识,我们再来看看 JMM。 + +6.4 CPU 指令 + +我们知道,计算机按支持的指令大致可以分为两类: + + +精简指令集计算机(RISC), 代表是如今大家熟知的 ARM 芯片,功耗低,运算能力相对较弱。 +复杂指令集计算机(CISC), 代表作是 Intel 的 X86 芯片系列,比如奔腾,酷睿,至强,以及 AMD 的 CPU。特点是性能强劲,功耗高。(实际上从奔腾 4 架构开始,对外是复杂指令集,内部实现则是精简指令集,所以主频才能大幅度提高) + + +写过程序的人都知道,同样的计算,可以有不同的实现方式。 硬件指令设计同样如此,比如说我们的系统需要实现某种功能,那么复杂点的办法就是在 CPU 中封装一个逻辑运算单元来实现这种的运算,对外暴露一个专用指令。 + +当然也可以偷懒,不实现这个指令,而是由程序编译器想办法用原有的那些基础的,通用指令来模拟和拼凑出这个功能。那么随着时间的推移,实现专用指令的 CPU 指令集就会越来越复杂, ,被称为复杂指令集。 而偷懒的 CPU 指令集相对来说就会少很多,甚至砍掉了很多指令,所以叫精简指令集计算机。 + +不管哪一种指令集,CPU 的实现都是采用流水线的方式。如果 CPU 一条指令一条指令地执行,那么很多流水线实际上是闲置的。简单理解,可以类比一个 KFC 的取餐窗口就是一条流水线。于是硬件设计人员就想出了一个好办法: “指令乱序”。 CPU 完全可以根据需要,通过内部调度把这些指令打乱了执行,充分利用流水线资源,只要最终结果是等价的,那么程序的正确性就没有问题。但这在如今多 CPU 内核的时代,随着复杂度的提升,并发执行的程序面临了很多问题。 + + + +CPU 是多个核心一起执行,同时 JVM 中还有多个线程在并发执行,这种多对多让局面变得异常复杂,稍微控制不好,程序的执行结果可能就是错误的。 + +6.5 JMM 背景 + +目前的 JMM 规范对应的是 “JSR-133. Java Memory Model and Thread Specification” ,这个规范的部分内容润色之后就成为了《Java语言规范》的 $17.4. Memory Model章节。可以看到,JSR133 的最终版修订时间是在 2014 年,这是因为之前的 Java 内存模型有些坑,所以在 Java 1.5 版本的时候进行了重新设计,并一直沿用到今天。 + +JMM 规范明确定义了不同的线程之间,通过哪些方式,在什么时候可以看见其他线程保存到共享变量中的值;以及在必要时,如何对共享变量的访问进行同步。这样的好处是屏蔽各种硬件平台和操作系统之间的内存访问差异,实现了 Java 并发程序真正的跨平台。 + +随着 Java 在 Web 领域的大规模应用,为了充分利用多核的计算能力,多线程编程越来越受欢迎。这时候就出现很多线程安全方面的问题。想要真正掌握并发程序设计,则必须要理解 Java 内存模型。可以说,我们在 JVM 内存结构中学过的堆内存、栈内存等知识,以及 Java 中的同步、锁、线程等等术语都和JMM 有非常大的关系。 + +6.6 JMM 简介 + +JVM 支持程序多线程执行,每个线程是一个 Thread,如果不指定明确的同步措施,那么多个线程在访问同一个共享变量时,就看会发生一些奇怪的问题,比如 A 线程读取了一个变量 a=10,想要做一个只要大于9就减2的操作,同时 B 线程先在 A 线程操作前设置 a=8,其实这时候已经不满足 A 线程的操作条件了,但是 A 线程不知道,依然执行了 a-2,最终 a=6;实际上 a 的正确值应该是 8,这个没有同步的机制在多线程下导致了错误的最终结果。 + +这样一来,就需要 JMM 定义多线程执行环境下的一些语义问题,也就是定义了哪些方式是允许的。 + +下面我们简要介绍一下 JMM 规范里有些什么内容。 + + +给定一个程序和该程序的一串执行轨迹,内存模型描述了该执行轨迹是否是该程序的一次合法执行。对于 Java,内存模型检查执行轨迹中的每次读操作,然后根据特定规则,检验该读操作观察到的写是否合法。 内存模型描述了某个程序的可能行为。JVM 实现可以自由地生成想要的代码,只要该程序所有最终执行产生的结果能通过内存模型进行预测。这为大量的代码转换提供了充分的自由,包括动作(action)的重排序以及非必要的同步移除。 内存模型的一个高级、非正式的表述”显示其是一组规则,规定了一个线程的写操作何时会对另一个线程可见”。通俗地说,读操作 r 通常能看到任何写操作 w 写入的值,意味着 w 不是在 r 之后发生,且 w 看起来没有被另一个写操作 w’ 覆盖掉(从 r 的角度看)。 + + +JMM 定义了一些术语和规定,大家略有了解即可。 + + +能被多个线程共享使用的内存称为“共享内存”或“堆内存”。 +所有的对象(包括内部的实例成员变量),static 变量,以及数组,都必须存放到堆内存中。 +局部变量,方法的形参/入参,异常处理语句的入参不允许在线程之间共享,所以不受内存模型的影响。 +多个线程同时对一个变量访问时【读取/写入】,这时候只要有某个线程执行的是写操作,那么这种现象就称之为“冲突”。 +可以被其他线程影响或感知的操作,称为线程间的交互行为, 可分为: 读取、写入、同步操作、外部操作等等。 其中同步操作包括:对 volatile 变量的读写,对管程(monitor)的锁定与解锁,线程的起始操作与结尾操作,线程启动和结束等等。 外部操作则是指对线程执行环境之外的操作,比如停止其他线程等等。 + + +JMM 规范的是线程间的交互操作,而不管线程内部对局部变量进行的操作。 + + +有兴趣的同学可参阅: ifeve 翻译的: JSR133 中文版.pdf + + +6.7 内存屏障简介 + +前面提到了CPU会在合适的时机,按需要对将要进行的操作重新排序,但是有时候这个重排机会导致我们的代码跟预期不一致。 + +怎么办呢?JMM 引入了内存屏障机制。 + +内存屏障可分为读屏障和写屏障,用于控制可见性。 常见的 内存屏障 包括: + +#LoadLoad +#StoreStore +#LoadStore +#StoreLoad + + + +这些屏障的主要目的,是用来短暂屏蔽 CPU 的指令重排序功能。 和 CPU 约定好,看见这些指令时,就要保证这个指令前后的相应操作不会被打乱。 + + +比如看见 #LoadLoad, 那么屏障前面的 Load 指令就一定要先执行完,才能执行屏障后面的 Load 指令。 +比如我要先把 a 值写到 A 字段中,然后再将 b 值写到 B 字段对应的内存地址。如果要严格保障这个顺序,那么就可以在这两个 Store 指令之间加入一个 #StoreStore 屏障。 +遇到 #LoadStore 屏障时, CPU 自废武功,短暂屏蔽掉指令重排序功能。 +#StoreLoad 屏障, 能确保屏障之前执行的所有 store 操作,都对其他处理器可见; 在屏障后面执行的 load 指令, 都能取得到最新的值。换句话说, 有效阻止屏障之前的 store 指令,与屏障之后的 load 指令乱序 、即使是多核心处理器,在执行这些操作时的顺序也是一致的。 + + +代价最高的是 #StoreLoad 屏障, 它同时具有其他几类屏障的效果,可以用来代替另外三种内存屏障。 + +如何理解呢? + +就是只要有一个 CPU 内核收到这类指令,就会做一些操作,同时发出一条广播, 给某个内存地址打个标记,其他 CPU 内核与自己的缓存交互时,就知道这个缓存不是最新的,需要从主内存重新进行加载处理。 + +小结 + +本节我们讲解了JMM的一系列知识,让大家能够了解Java的内存模型,包括: + + +JVM 的内存区域分为: 堆内存 和 栈内存; +堆内存的实现可分为两部分: 堆(Heap) 和 非堆(Non-Heap); +堆主要由 GC 负责管理,按分代的方式一般分为: 老年代+年轻代;年轻代=新生代+存活区; +CPU 有一个性能提升的利器: 指令重排序; +JMM 规范对应的是 JSR133, 现在由 Java 语言规范和 JVM 规范来维护; +内存屏障的分类与作用。 + + +参考链接 + + +JSR-133. Java Memory Model and Thread Specification +The Java Memory Model +memoryModel-CurrentDraftSpec.pdf +The JSR-133 Cookbook for Compiler Writers +类比版本控制系统来理解内存屏障 +Java Language Specification, Chapter 17. Threads and Locks +JVM内部结构详解 +Metaspace解密 + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/08JVM启动参数详解:博观而约取、厚积而薄发.md b/专栏/JVM核心技术32讲(完)/08JVM启动参数详解:博观而约取、厚积而薄发.md new file mode 100644 index 0000000..51953eb --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/08JVM启动参数详解:博观而约取、厚积而薄发.md @@ -0,0 +1,368 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 JVM 启动参数详解:博观而约取、厚积而薄发 + JVM 作为一个通用的虚拟机,我们可以通过启动 Java 命令时指定不同的 JVM 参数,让 JVM 调整自己的运行状态和行为,内存管理和垃圾回收的 GC 算法,添加和处理调试和诊断信息等等。本节概括地讲讲 JVM 参数,对于 GC 相关的详细参数将在后续的 GC 章节说明和分析。 + +直接通过命令行启动 Java 程序的格式为: + +java [options] classname [args] + +java [options] -jar filename [args] + + + +其中: + + +[options] 部分称为 “JVM 选项”,对应 IDE 中的 VM options, 可用 jps -v 查看。 +[args] 部分是指 “传给main函数的参数”, 对应 IDE 中的 Program arguments, 可用 jps -m 查看。 + + +如果是使用 Tomcat 之类自带 startup.sh 等启动脚本的程序,我们一般把相关参数都放到一个脚本定义的 JAVA_OPTS 环境变量中,最后脚本启动 JVM 时会把 JAVA_OPTS 变量里的所有参数都加到命令的合适位置。 + +如果是在 IDEA 之类的 IDE 里运行的话,则可以在“Run/Debug Configurations”里看到 VM 选项和程序参数两个可以输入参数的地方,直接输入即可。 + + + +上图输入了两个 VM 参数,都是环境变量,一个是指定文件编码使用 UTF-8,一个是设置了环境变量 a 的值为 1。 + +Java 和 JDK 内置的工具,指定参数时都是一个 -,不管是长参数还是短参数。有时候,JVM 启动参数和 Java 程序启动参数,并没必要严格区分,大致知道都是一个概念即可。 + +JVM 的启动参数, 从形式上可以简单分为: + + +以-开头为标准参数,所有的 JVM 都要实现这些参数,并且向后兼容。 +以-X开头为非标准参数, 基本都是传给 JVM 的,默认 JVM 实现这些参数的功能,但是并不保证所有 JVM 实现都满足,且不保证向后兼容。 +以-XX:开头为非稳定参数, 专门用于控制 JVM 的行为,跟具体的 JVM 实现有关,随时可能会在下个版本取消。 +-XX:+-Flags 形式, +- 是对布尔值进行开关。 +-XX:key=value 形式, 指定某个选项的值。 + + +实际上,直接在命令行输入 java,然后回车,就会看到 java 命令可以其使用的参数列表说明: + +$ java +用法: java [-options] class [args...] + (执行类) + 或 java [-options] -jar jarfile [args...] + (执行 jar 文件) +其中选项包括: + -d32 使用 32 位数据模型 (如果可用) + -d64 使用 64 位数据模型 (如果可用) + -server 选择 "server" VM + 默认 VM 是 server, + 因为您是在服务器类计算机上运行。 + -cp <目录和 zip/jar 文件的类搜索路径> + -classpath <目录和 zip/jar 文件的类搜索路径> + 用 : 分隔的目录, JAR 档案 + 和 ZIP 档案列表, 用于搜索类文件。 + -D<名称>=<值> + 设置系统属性 + -verbose:[class|gc|jni] + 启用详细输出 + -version 输出产品版本并退出 + -version:<值> + 警告: 此功能已过时, 将在 + 未来发行版中删除。 + 需要指定的版本才能运行 + -showversion 输出产品版本并继续 + -jre-restrict-search | -no-jre-restrict-search + 警告: 此功能已过时, 将在 + 未来发行版中删除。 + 在版本搜索中包括/排除用户专用 JRE + -? -help 输出此帮助消息 + -X 输出非标准选项的帮助 + -ea[:...|:] + -enableassertions[:...|:] + 按指定的粒度启用断言 + -da[:...|:] + -disableassertions[:...|:] + 禁用具有指定粒度的断言 + -esa | -enablesystemassertions + 启用系统断言 + -dsa | -disablesystemassertions + 禁用系统断言 + -agentlib:[=<选项>] + 加载本机代理库 , 例如 -agentlib:hprof + 另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help + -agentpath:[=<选项>] + 按完整路径名加载本机代理库 + -javaagent:[=<选项>] + 加载 Java 编程语言代理, 请参阅 java.lang.instrument + -splash: + 使用指定的图像显示启动屏幕 +有关详细信息, 请参阅 http://www.oracle.com/technetwork/java/javase/documentation/index.html。 + + + +7.1 设置系统属性 + +当我们给一个 Java 程序传递参数,最常用的方法有两种: + + +系统属性,有时候也叫环境变量,例如直接给 JVM 传递指定的系统属性参数,需要使用 -Dkey=value 这种形式,此时如果系统的环境变量里不管有没有指定这个参数,都会以这里的为准。 +命令行参数,直接通过命令后面添加的参数,比如运行 Hello 类,同时传递 2 个参数 kimm、king:java Hello kimm king,然后在Hello类的 main 方法的参数里可以拿到一个字符串的参数数组,有两个字符串,kimm 和 king。 + + +比如我们常见的设置 $JAVA_HOME 就是一个环境变量,只要在当前命令执行的上下文里有这个环境变量,就可以在启动的任意程序里,通过相关 API 拿到这个参数,比如 Java 里: + +System.getProperty("key")来获取这个变量的值,这样就可以做到多个不同的应用进程可以共享这些变量,不用每个都重复设置,也可以实现简化 Java 命令行的长度(想想要是配置了 50 个参数多恐怖,放到环境变量里,可以简化启动输入的字符)。此外,由于环境变量的 key-value 的形式,所以不管是环境上下文里配置的,还是通过运行时-D来指定,都可以不在意参数的顺序,而命令行参数就必须要注意顺序,顺序错误就会导致程序错误。 + +例如指定随机数熵源(Entropy Source),示例: + +JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom" + + + +此外还有一些常见设置: + + -Duser.timezone=GMT+08 // 设置用户的时区为东八区 + -Dfile.encoding=UTF-8 // 设置默认的文件编码为UTF-8 + + + +查看默认的所有系统属性,可以使用命令: + +$ java -XshowSettings:properties -version +Property settings: + awt.toolkit = sun.lwawt.macosx.LWCToolkit + file.encoding = UTF-8 + file.encoding.pkg = sun.io + file.separator = / + gopherProxySet = false + java.awt.graphicsenv = sun.awt.CGraphicsEnvironment + java.awt.printerjob = sun.lwawt.macosx.CPrinterJob + java.class.path = . + java.class.version = 52.0 +...... 省略了几十行 + + + +同样可以查看 VM 设置: + +$ java -XshowSettings:vm -version +VM settings: + Max. Heap Size (Estimated): 1.78G + Ergonomics Machine Class: server + Using VM: Java HotSpot(TM) 64-Bit Server VM +...... + + + +查看当前 JDK/JRE 的默认显示语言设置: + +java -XshowSettings:locale -version +Locale settings: + default locale = 中文 + default display locale = 中文 (中国) + default format locale = 英文 (中国) + + available locales = , ar, ar_AE, ar_BH, ar_DZ, ar_EG, ar_IQ, ar_JO, + ar_KW, ar_LB, ar_LY, ar_MA, ar_OM, ar_QA, ar_SA, ar_SD, + ...... + + + +还有常见的,我们使用 mvn 脚本去执行编译的同时,如果不想编译和执行单元测试代码: + + +$ mvn package -Djava.test.skip=true + + +或者 + + +$ mvn package -DskipTests + + +等等,很多地方会用设置系统属性的方式去传递数据给Java程序,而不是直接用程序参数的方式。 + +7.2 Agent 相关的选项 + +Agent 是 JVM 中的一项黑科技, 可以通过无侵入方式来做很多事情,比如注入 AOP 代码,执行统计等等,权限非常大。这里简单介绍一下配置选项,详细功能在后续章节会详细讲。 + +设置 agent 的语法如下: + + +-agentlib:libname[=options] 启用native方式的agent, 参考 LD_LIBRARY_PATH 路径。 +-agentpath:pathname[=options] 启用native方式的agent。 +-javaagent:jarpath[=options] 启用外部的agent库, 比如 pinpoint.jar 等等。 +-Xnoagent 则是禁用所有 agent。 + + +以下示例开启 CPU 使用时间抽样分析: + +JAVA_OPTS="-agentlib:hprof=cpu=samples,file=cpu.samples.log" + + + +其中 hprof 是 JDK 内置的一个性能分析器。cpu=samples 会抽样在各个方法消耗的时间占比, Java 进程退出后会将分析结果输出到文件。 + +7.3 JVM 运行模式 + +JVM 有两种运行模式: + + +-server:设置 jvm 使 server 模式,特点是启动速度比较慢,但运行时性能和内存管理效率很高,适用于生产环境。在具有 64 位能力的 jdk 环境下将默认启用该模式,而忽略 -client 参数。 +-client :JDK1.7 之前在 32 位的 x86 机器上的默认值是 -client 选项。设置 jvm 使用 client 模式,特点是启动速度比较快,但运行时性能和内存管理效率不高,通常用于客户端应用程序或者PC应用开发和调试。 + + +此外,我们知道 JVM 加载字节码后,可以解释执行,也可以编译成本地代码再执行,所以可以配置 JVM 对字节码的处理模式: + + +-Xint:在解释模式(interpreted mode)下,-Xint 标记会强制 JVM 解释执行所有的字节码,这当然会降低运行速度,通常低 10 倍或更多。 +-Xcomp:-Xcomp 参数与 -Xint 正好相反,JVM 在第一次使用时会把所有的字节码编译成本地代码,从而带来最大程度的优化。 +-Xmixed:-Xmixed 是混合模式,将解释模式和变异模式进行混合使用,有 JVM 自己决定,这是 JVM 的默认模式,也是推荐模式。 我们使用 java -version 可以看到 mixed mode 等信息。 + + +示例: + +JAVA_OPTS="-server" + + + +7.4 设置堆内存 + +JVM 的内存设置是最重要的参数设置,也是 GC 分析和调优的重点。 + + +JVM 总内存=堆+栈+非堆+堆外内存。 + + +相关的参数: + + +-Xmx, 指定最大堆内存。 如 -Xmx4g. 这只是限制了 Heap 部分的最大值为 4g。这个内存不包括栈内存,也不包括堆外使用的内存。 +-Xms, 指定堆内存空间的初始大小。 如 -Xms4g。 而且指定的内存大小,并不是操作系统实际分配的初始值,而是 GC 先规划好,用到才分配。 专用服务器上需要保持 -Xms和-Xmx一致,否则应用刚启动可能就有好几个 FullGC。当两者配置不一致时,堆内存扩容可能会导致性能抖动。 +-Xmn, 等价于 -XX:NewSize,使用 G1 垃圾收集器 不应该 设置该选项,在其他的某些业务场景下可以设置。官方建议设置为 -Xmx 的 1/2 ~ 1/4。 +-XX:MaxPermSize=size, 这是 JDK1.7 之前使用的。Java8 默认允许的 Meta 空间无限大,此参数无效。 +-XX:MaxMetaspaceSize=size, Java8 默认不限制 Meta 空间, 一般不允许设置该选项。 +XX:MaxDirectMemorySize=size,系统可以使用的最大堆外内存,这个参数跟-Dsun.nio.MaxDirectMemorySize效果相同。 +-Xss, 设置每个线程栈的字节数。 例如 -Xss1m 指定线程栈为 1MB,与-XX:ThreadStackSize=1m等价 + + +这里要特别说一下堆外内存,也就是说不在堆上的内存,我们可以通过jconsole,jvisualvm 等工具查看。 + +RednaxelaFX 提到: + + +一个 Java 进程里面,可以分配 native memory 的东西有很多,特别是使用第三方 native 库的程序更是如此。 + + +但在这里面除了 + + +GC heap = Java heap + Perm Gen(JDK <= 7) +Java thread stack = Java thread count * Xss +other thread stack = other thread count * stack size +CodeCache 等东西之外 + + +还有诸如 HotSpot VM 自己的 StringTable、SymbolTable、SystemDictionary、CardTable、HandleArea、JNIHandleBlock 等许多数据结构是常驻内存的,外加诸如 JIT 编译器、GC 等在工作的时候都会额外临时分配一些 native memory,这些都是 HotSpot VM自己所分配的 native memory;在 JDK 类库实现中也有可能有些功能分配长期存活或者临时的 native memory。 + +然后就是各种第三方库的 native 部分分配的 native memory。 + +“Direct Memory”,一般来说是 Java NIO 使用的 Direct-X-Buffer(例如 DirectByteBuffer)所分配的 native memory,这个地方如果我们使用 netty 之类的框架,会产生大量的堆外内存。 + +示例: + +JAVA_OPTS="-Xms28g -Xmx28g" + + + +最佳实践 + +配置多少 xmx 合适 + +从上面的分析可以看到,系统有大量的地方使用堆外内存,远比我们常说的 xmx 和 xms 包括的范围要广。所以我们需要在设置内存的时候留有余地。 + +实际上,我个人比较推荐配置系统或容器里可用内存的 70-80% 最好。比如说系统有 8G 物理内存,系统自己可能会用掉一点,大概还有 7.5G 可以用,那么建议配置 + + +-Xmx6g 说明:xmx : 7.5G*0.8 = 6G,如果知道系统里有明确使用堆外内存的地方,还需要进一步降低这个值。 + + +举个具体例子,我在过去的几个不同规模,不同发展时期,不同研发成熟度的公司研发团队,都发现过一个共同的 JVM 问题,就是线上经常有JVM实例突然崩溃,这个过程也许是三天,也可能是 2 周,异常信息也很明确,就是内存溢出 OOM。 + +运维人员不断加大堆内存或者云主机的物理内存,也无济于事,顶多让这个过程延缓。 + +大家怀疑内存泄露,但是看 GC 日志其实一直还挺正常,系统在性能测试环境也没什么问题,开发和运维还因此不断地发生矛盾和冲突。 + +其中有个运维同事为了缓解问题,通过一个多月的观察,持续地把一个没什么压力的服务器从 2 台逐渐扩展了 15 台,因为每天都有几台随机崩溃,他需要在系统通知到他去处理的这段时间,保证其他机器可以持续提供服务。 + +大家付出了很多努力,做了一些技术上的探索,还想了不少的歪招,但是没有解决问题,也就是说没有创造价值。 + +后来我去深入了解一下,几分钟就解决了问题,创造了技术的价值,把服务器又压缩回 2 台就可以保证系统稳定运行,业务持续可用了,降低成本带来的价值,也得到业务方和客户认可。 + +那么实际问题出在哪儿呢?一台云主机 4G 或 8G 内存,为了让 JVM 最大化的使用内存,服务部署的同事直接配置了xmx4g 或 xmx8g。因为他不知道 xmx 配置的内存和 JVM 可能使用的最大内存是不相等的。我让他把 8G 内存的云主机,设置 xmx6g,再也没出过问题,而且让他观察看到在 Java 进程最多的时候 JVM 进程使用了 7G 出头的内存(堆最多用 6g, java 进程自身、堆外空间都需要使用内存,这些内存不在 xmx 的范围内),而不包含 xmx 设置的 6g 内存内。 + +xmx 和 xms 是不是要配置成一致的 + +一般情况下,我们的服务器是专用的,就是一个机器(也可能是云主机或 docker 容器)只部署一个 Java 应用,这样的时候建议配置成一样的,好处是不会再动态去分配,如果内存不足(像上面的情况)上来就知道。 + +7.5 GC 日志相关的参数 + +在生产环境或性能压测环境里,我们用来分析和判断问题的重要数据来源之一就是 GC 日志,JVM 启动参数为我们提供了一些用于控制 GC 日志输出的选项。 + + +-verbose:gc :和其他 GC 参数组合使用, 在 GC 日志中输出详细的GC信息。 包括每次 GC 前后各个内存池的大小,堆内存的大小,提升到老年代的大小,以及消耗的时间。此参数支持在运行过程中动态开关。比如使用 jcmd, jinfo, 以及使用 JMX 技术的其他客户端。 +-XX:+PrintGCDetails 和 -XX:+PrintGCTimeStamps:打印 GC 细节与发生时间。请关注我们后续的 GC 课程章节。 +-Xloggc:file:与-verbose:gc功能类似,只是将每次 GC 事件的相关情况记录到一个文件中,文件的位置最好在本地,以避免网络的潜在问题。若与 verbose:gc 命令同时出现在命令行中,则以 -Xloggc 为准。 + + +示例: + +export JAVA_OPTS="-Xms28g -Xmx28g -Xss1m \ +-verbosegc -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \ +-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/" + + + +7.6 指定垃圾收集器相关参数 + +垃圾回收器是 JVM 性能分析和调优的核心内容之一,也是近几个 JDK 版本大力发展和改进的地方。通过不同的 GC 算法和参数组合,配合其他调优手段,我们可以把系统精确校验到性能最佳状态。 + +以下参数指定具体的垃圾收集器,详细情况会在第二部分讲解: + + +-XX:+UseG1GC:使用 G1 垃圾回收器 +-XX:+UseConcMarkSweepGC:使用 CMS 垃圾回收器 +-XX:+UseSerialGC:使用串行垃圾回收器 +-XX:+UseParallelGC:使用并行垃圾回收器 + + +7.7 特殊情况执行脚本的参数 + +除了上面介绍的一些 JVM 参数,还有一些用于出现问题时提供诊断信息之类的参数。 + + +-XX:+-HeapDumpOnOutOfMemoryError 选项, 当 OutOfMemoryError 产生,即内存溢出(堆内存或持久代)时,自动 Dump 堆内存。 因为在运行时并没有什么开销, 所以在生产机器上是可以使用的。 示例用法: java -XX:+HeapDumpOnOutOfMemoryError -Xmx256m ConsumeHeap + + +java.lang.OutOfMemoryError: Java heap space +Dumping heap to java_pid2262.hprof ... +...... + + + + +-XX:HeapDumpPath 选项, 与HeapDumpOnOutOfMemoryError搭配使用, 指定内存溢出时 Dump 文件的目录。 如果没有指定则默认为启动 Java 程序的工作目录。 示例用法: java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/ ConsumeHeap 自动 Dump 的 hprof 文件会存储到 /usr/local/ 目录下。 +-XX:OnError 选项, 发生致命错误时(fatal error)执行的脚本。 例如, 写一个脚本来记录出错时间, 执行一些命令, 或者 curl 一下某个在线报警的url. 示例用法: java -XX:OnError="gdb - %p" MyApp 可以发现有一个 %p 的格式化字符串,表示进程 PID。 +-XX:OnOutOfMemoryError 选项, 抛出 OutOfMemoryError 错误时执行的脚本。 +-XX:ErrorFile=filename 选项, 致命错误的日志文件名,绝对路径或者相对路径。 + + +本节只简要的介绍一下 JVM 参数,其实还有大量的参数跟 GC 垃圾收集器有关系,将会在第二部分进行详细的解释和分析。 + +参考资料 + + +如何比较准确地估算一个Java进程到底申请了多大的Direct Memory?:https://www.zhihu.com/question/55033583/answer/142577881 +最全的官方JVM参数清单:https://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html + + + + + \ No newline at end of file diff --git a/专栏/JVM核心技术32讲(完)/09JDK内置命令行工具:工欲善其事,必先利其器.md b/专栏/JVM核心技术32讲(完)/09JDK内置命令行工具:工欲善其事,必先利其器.md new file mode 100644 index 0000000..2648e3c --- /dev/null +++ b/专栏/JVM核心技术32讲(完)/09JDK内置命令行工具:工欲善其事,必先利其器.md @@ -0,0 +1,887 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 JDK 内置命令行工具:工欲善其事,必先利其器 + 很多情况下,JVM 运行环境中并没有趁手的工具,所以掌握基本的内置工具是一项基本功。 + +JDK 自带的工具和程序可以分为 2 大类型: + + +开发工具 +诊断分析工具 + + +JDK 内置的开发工具 + +写过 Java 程序的同学,对 JDK 中的开发工具应该比较熟悉。 下面列举常用的部分: + + + + +工具 +简介 + + + + + +java +Java 应用的启动程序 + + + +javac +JDK 内置的编译工具 + + + +javap +反编译 class 文件的工具 + + + +javadoc +根据 Java 代码和标准注释,自动生成相关的 API 说明文档 + + + +javah +JNI 开发时,根据 Java 代码生成需要的 .h 文件。 + + + +extcheck +检查某个 jar 文件和运行时扩展 jar 有没有版本冲突,很少使用 + + + +jdb +Java Debugger 可以调试本地和远端程序,属于 JPDA 中的一个 Demo 实现,供其他调试器参考。开发时很少使用 + + + +jdeps +探测 class 或 jar 包需要的依赖 + + + +jar +打包工具,可以将文件和目录打包成为 .jar 文件;.jar 文件本质上就是 zip 文件,只是后缀不同。使用时按顺序对应好选项和参数即可。 + + + +keytool +安全证书和密钥的管理工具(支持生成、导入、导出等操作) + + + +jarsigner +jar 文件签名和验证工具 + + + +policytool +实际上这是一款图形界面工具,管理本机的 Java 安全策略 + + + +开发工具此处不做详细介绍,有兴趣的同学请参考文末的链接。 + +下面介绍诊断和分析工具。 + +命令行诊断和分析工具 + +JDK 内置了各种命令行工具,条件受限时我们可以先用命令行工具快速查看 JVM 实例的基本情况。 + + +macOS X、Windows 系统的某些账户权限不够,有些工具可能会报错/失败,假如出问题了请排除这个因素。 + + +JPS 工具简介 + +我们知道,操作系统提供一个工具叫做 ps,用于显示进程状态(Process Status)。 + +Java也 提供了类似的命令行工具,叫做 JPS,用于展示 Java 进程信息(列表)。 + +需要注意的是,JPS 展示的是当前用户可看见的 Java 进程,如果看不见某些进程可能需要 sudo、su 之类的命令来切换权限。 + +查看帮助信息: + + +$ jps -help + + +usage: jps [-help] + jps [-q] [-mlvV] [] +Definitions: + : [:] + + + +可以看到, 这些参数分为了多个组,-help、-q、-mlvV, 同一组可以共用一个 -。 + +常用参数是小写的 -v,显示传递给 JVM 的启动参数。 + + +$ jps -v + + +15883 Jps -Dapplication.home=/usr/local/jdk1.8.0_74 -Xms8m +6446 Jstatd -Dapplication.home=/usr/local/jdk1.8.0_74 -Xms8m + -Djava.security.policy=/etc/java/jstatd.all.policy +32383 Bootstrap -Xmx4096m -XX:+UseG1GC -verbose:gc + -XX:+PrintGCDateStamps -XX:+PrintGCDetails + -Xloggc:/xxx-tomcat/logs/gc.log + -Dcatalina.base=/xxx-tomcat -Dcatalina.home=/data/tomcat + + + +看看输出的内容,其中最重要的信息是前面的进程 ID(PID)。 + +其他参数不太常用: + + +-q:只显示进程号。 +-m:显示传给 main 方法的参数信息 +-l:显示启动 class 的完整类名,或者启动 jar 的完整路径 +-V:大写的 V,这个参数有问题,相当于没传一样。官方说的跟 -q 差不多。 +:部分是远程主机的标识符,需要远程主机启动 jstatd 服务器支持。 + + +可以看到,格式为 [:],不能用 IP,示例:jps -v sample.com:1099。 + +知道 JVM 进程的 PID 之后,就可以使用其他工具来进行诊断了。 + +jstat 工具简介 + +jstat 用来监控 JVM 内置的各种统计信息,主要是内存和 GC 相关的信息。 + +查看 jstat 的帮助信息,大致如下: + + +$ jstat -help + + +Usage: jstat -help|-options + jstat -