理解 RabbitMQ Exchange

AMQP 简介

在了解 RabbitMQ 的 Exchange(交换机)的概念之前,我们首先要对 RabbitMQ 相关的概念和名词有一个大概的了解,RabbitMQ 是 AMQP(高级消息队列协议)的标准实现:

从 AMQP 协议可以看出,Queue、Exchange 和 Binding 构成了 AMQP 协议的核心

  • Producer:消息生产者,即投递消息的程序。
  • Broker:消息队列服务器实体。
    • Exchange:消息交换机,它指定消息按什么规则,路由到哪个队列。
    • Binding:绑定,它的作用就是把 Exchange 和 Queue 按照路由规则绑定起来。
    • Queue:消息队列载体,每个消息都会被投入到一个或多个队列。
  • Consumer:消息消费者,即接受消息的程序。

Exchange 简介

那么为什么我们需要 Exchange 而不是直接将消息发送至队列呢?

AMQP 协议中的核心思想就是生产者和消费者的解耦,生产者从不直接将消息发送给队列。生产者通常不知道是否一个消息会被发送到队列中,只是将消息发送到一个交换机。先由 Exchange 来接收,然后 Exchange 按照特定的策略转发到 Queue 进行存储。Exchange 就类似于一个交换机,将各个消息分发到相应的队列中。

qq

在实际应用中我们只需要定义好 Exchange 的路由策略,而生产者则不需要关心消息会发送到哪个 Queue 或被哪些 Consumer 消费。在这种模式下生产者只面向 Exchange 发布消息,消费者只面向 Queue 消费消息,Exchange 定义了消息路由到 Queue 的规则,将各个层面的消息传递隔离开,使每一层只需要关心自己面向的下一层,降低了整体的耦合度。

理解 Exchange

Exchange 收到消息时,他是如何知道需要发送至哪些 Queue 呢?这里就需要了解 Binding 和 RoutingKey 的概念:

Binding 表示 Exchange 与 Queue 之间的关系,我们也可以简单的认为队列对该交换机上的消息感兴趣,绑定可以附带一个额外的参数 RoutingKey。Exchange 就是根据这个 RoutingKey 和当前 Exchange 所有绑定的 Binding 做匹配,如果满足匹配,就往 Exchange 所绑定的 Queue 发送消息,这样就解决了我们向 RabbitMQ 发送一次消息,可以分发到不同的 Queue。RoutingKey 的意义依赖于交换机的类型。

下面就来了解一下 Exchange 的三种主要类型:FanoutDirectTopic

Fanout Exchange

5

Fanout Exchange 会忽略 RoutingKey 的设置,直接将 Message 广播到所有绑定的 Queue 中。

应用场景

以日志系统为例:假设我们定义了一个 Exchange 来接收日志消息,同时定义了两个 Queue 来存储消息:一个记录将被打印到控制台的日志消息;另一个记录将被写入磁盘文件的日志消息。我们希望 Exchange 接收到的每一条消息都会同时被转发到两个 Queue,这种场景下就可以使用 Fanout Exchange 来广播消息到所有绑定的 Queue。

Direct Exchange

4

Direct Exchange 是 RabbitMQ 默认的 Exchange,完全根据 RoutingKey 来路由消息。设置 Exchange 和 Queue 的 Binding 时需指定 RoutingKey(一般为 Queue Name),发消息时也指定一样的 RoutingKey,消息就会被路由到对应的Queue。

应用场景

现在我们考虑只把重要的日志消息写入磁盘文件,例如只把 Error 级别的日志发送给负责记录写入磁盘文件的 Queue。这种场景下我们可以使用指定的 RoutingKey(例如 error)将写入磁盘文件的 Queue 绑定到 Direct Exchange 上。

Topic Exchange

6

Topic Exchange 和 Direct Exchange 类似,也需要通过 RoutingKey 来路由消息,区别在于Direct Exchange 对 RoutingKey 是精确匹配,而 Topic Exchange 支持模糊匹配。分别支持*#通配符,*表示匹配一个单词,#则表示匹配没有或者多个单词。

应用场景

假设我们的消息路由规则除了需要根据日志级别来分发之外还需要根据消息来源分发,可以将 RoutingKey 定义为 消息来源.级别order.infouser.error 等。处理所有来源为 user 的 Queue 就可以通过 user.* 绑定到 Topic Exchange 上,而处理所有日志级别为 info 的 Queue 可以通过 *.info 绑定到 Exchange上。

两种特殊的 Exchange

Headers Exchange

Headers Exchange 会忽略 RoutingKey 而根据消息中的 Headers 和创建绑定关系时指定的 Arguments 来匹配决定路由到哪些 Queue。

Headers Exchange 的性能比较差,而且 Direct Exchange 完全可以代替它,所以不建议使用。

Default Exchange

Default Exchange 是一种特殊的 Direct Exchange。当你手动创建一个队列时,后台会自动将这个队列绑定到一个名称为空的 Direct Exchange 上,绑定 RoutingKey 与队列名称相同。有了这个默认的交换机和绑定,使我们只关心队列这一层即可,这个比较适合做一些简单的应用。

总结

在 Exchange 的基础上我们可以通过比较简单的配置绑定关系来灵活的使用消息路由,在简单的应用中也可以直接使用 RabbitMQ 提供的 Default Exchange 而无需关心 Exchange 和绑定关系。Direct Exchange、Topic Exchange、Fanout Exchange 三种类型的交换机的使用方式也很简单,容易掌握。

0

iOS 下的图片处理与性能优化

移动开发中我们经常和多媒体数据打交道,对这些数据的解析往往需要耗费大量资源,属于常见的性能瓶颈。

本文针对多媒体数据的一种———图片,介绍下图片的常见格式,它们如何在移动平台上被传输、存储和展示,以及优化图片显示性能的一种方法:强制子线程解码。

图片在计算机世界中怎样被存储和表示?

图片和其他所有资源一样,在内存中本质上都是0和1的二进制数据,计算机需要将这些原始内容渲染成人眼能观察的图片,反过来,也需要将图片以合适的形式保存在存储器或者在网络上传送。

下面是一张图片在硬盘中的原始十六进制表示:

一张图片在计算机中的原始数据

这种将图片以某种规则进行二进制编码的方式,就是图片的格式。

常见的图片格式

图片的格式有很多种,除了我们熟知的 JPG、PNG、GIF,还有Webp,BMP,TIFF,CDR 等等几十种,用于不同的场景或平台。

图片的常见格式

这些格式可以分为两大类:有损压缩无损压缩

有损压缩:相较于颜色,人眼对光线亮度信息更为敏感,基于此,通过合并图片中的颜色信息,保留亮度信息,可以在尽量不影响图片观感的前提下减少存储体积。顾名思义,这样压缩后的图片将会永久损失一些细节。最典型的有损压缩格式是 jpg。

不同压缩比例的jpg图片

无损压缩:和有损压缩不同,无损压缩不会损失图片细节。它降低图片体积的方式是通过索引,对图片中不同的颜色特征建立索引表,减少了重复的颜色数据,从而达到压缩的效果。常见的无损压缩格式是 png,gif。

除了上述提到的格式,有必要再简单介绍下 webpbitmap这两种格式:

Webp:jpg 作为主流的网络图片标准可以向上追溯到九十年代初期,已经十分古老了。所以谷歌公司推出了Webp标准意图替代陈旧的jpg,以加快网络图片的加载速度,提高图片压缩质量。

webp 同时支持有损和无损两种压缩方式,压缩率也很高,无损压缩后的 webp 比 png 少了45%的体积,相同质量的 webp 和 jpg,前者也能节省一半的流量。同时 webp 还支持动图,可谓图片压缩格式的集大成者。

webp的体积对比

webp 的缺点是浏览器和移动端支持还不是很完善,我们需要引入谷歌的 libwebp 框架,编解码也会消耗相对更多的资源。

bitmap:bitmap 又叫位图文件,它是一种非压缩的图片格式,所以体积非常大。所谓的非压缩,就是图片每个像素的原始信息在存储器中依次排列,一张典型的1920*1080像素的 bitmap 图片,每个像素由 RGBA 四个字节表示颜色,那么它的体积就是 1920 * 1080 * 4 = 1012.5kb。

由于 bitmap 简单顺序存储图片的像素信息,它可以不经过解码就直接被渲染到 UI 上。实际上,其它格式的图片一般都需要先被首先解码为 bitmap,然后才能渲染到界面上。

如何判断图片的格式?

在一些场景中,我们需要手动去判断图片数据的格式,以进行不同的处理。一般来说,只要拿到原始的二进制数据,根据不同压缩格式的编码特征,就可以进行简单的分类了。以下是一些图片框架的常用实现,可以复制使用:

+ (XRImageFormat)imageFormatForImageData:(nullable NSData *)data {
    if (!data) {
        return XRImageFormatUndefined;
    }

    uint8_t c;
    [data getBytes:&c length:1];
    switch (c) {
        case 0xFF:
            return XRImageFormatJPEG;
        case 0x89:
            return XRImageFormatPNG;
        case 0x47:
            return XRImageFormatGIF;
        case 0x49:
        case 0x4D:
            return XRImageFormatTIFF;
        case 0x52:

            if (data.length < 12) {
                return XRImageFormatUndefined;
            }

            NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
            if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
                return XRImageFormatWebP;
            }
    }
    return XRImageFormatUndefined;
}

UIImageView 的性能瓶颈

如上文所说,大部分格式的图片,都需要被首先解码为bitmap,然后才能渲染到UI上。

UIImageView 显示图片,也有类似的过程。实际上,一张图片从在文件系统中,到被显示到 UIImageView,会经历以下几个步骤:

  1. 分配内存缓冲区和其它资源。
  2. 从磁盘拷贝数据到内核缓冲区
  3. 从内核缓冲区复制数据到用户空间
  4. 生成UIImageView,把图像数据赋值给UIImageView
  5. 将压缩的图片数据,解码为位图数据(bitmap),如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
  6. CATransaction捕获到UIImageView layer树的变化,主线程Runloop提交CATransaction,开始进行图像渲染
  7. GPU处理位图数据,进行渲染。

由于 UIKit 的封装性,这些细节不会直接对开发者展示。实际上,当我们调用[UIImage imageNamed:@"xxx"]后,UIImage 中存储的是未解码的图片,而调用 [UIImageView setImage:image]后,会在主线程进行图片的解码工作并且将图片显示到 UI 上,这时候,UIImage 中存储的是解码后的 bitmap 数据。

而图片的解压缩是一个非常消耗 CPU 资源的工作,如果我们有大量的图片需要展示到列表中,将会大大拖慢系统的响应速度,降低运行帧率。这就是 UIImageView 的一个性能瓶颈。

解决性能瓶颈:强制解码

如果 UIImage 中存储的是已经解码后的数据,速度就会快很多,所以优化的思路就是:在子线程中对图片原始数据进行强制解码,再将解码后的图片抛回主线程继续使用,从而提高主线程的响应速度。

我们需要使用的工具是 Core Graphics 框架的 CGBitmapContextCreate 方法和相关的绘制函数。总体的步骤是:

A. 创建一个指定大小和格式的 bitmap context。
B. 将未解码图片写入到这个 context 中,这个过程包含了强制解码
C. 从这个 context 中创建新的 UIImage 对象,返回。

下面是 SDWebImage 实现的核心代码,编号对应的解析在下文中:

// 1.
CGImageRef imageRef = image.CGImage;

// 2.
CGColorSpaceRef colorspaceRef = [UIImage colorSpaceForImageRef:imageRef];

size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);

// 3.
size_t bytesPerRow = 4 * width;

// 4.
CGContextRef context = CGBitmapContextCreate(NULL,
                                             width,
                                             height,
                                             kBitsPerComponent,
                                             bytesPerRow,
                                             colorspaceRef,
                                             kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
if (context == NULL) {
    return image;
}

// 5.
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);

// 6.
CGImageRef newImageRef = CGBitmapContextCreateImage(context);

// 7.
UIImage *newImage = [UIImage imageWithCGImage:newImageRef
                                        scale:image.scale
                                  orientation:image.imageOrientation];

CGContextRelease(context);
CGImageRelease(newImageRef);

return newImage;

对上述代码的解析:

1、从 UIImage 对象中获取 CGImageRef 的引用。这两个结构是苹果在不同层级上对图片的表示方式,UIImage 属于 UIKit,是 UI 层级图片的抽象,用于图片的展示;CGImageRef 是 QuartzCore 中的一个结构体指针,用C语言编写,用来创建像素位图,可以通过操作存储的像素位来编辑图片。这两种结构可以方便的互转:

// CGImageRef 转换成 UIImage
CGImageRef imageRef = CGBitmapContextCreateImage(context);
UIImage *image = [UIImage imageWithCGImage:imageRef];

// UIImage 转换成 CGImageRef
UIImage *image=[UIImage imageNamed:@"xxx"];
CGImageRef imageRef=loadImage.CGImage;

2、调用 UIImage 的 +colorSpaceForImageRef: 方法来获取原始图片的颜色空间参数。

什么叫颜色空间呢,就是对相同颜色数值的解释方式,比如说一个像素的数据是(FF0000FF),在 RGBA 颜色空间中,会被解释为红色,而在 BGRA 颜色空间中,则会被解释为蓝色。所以我们需要提取出这个参数,保证解码前后的图片颜色空间一致。

颜色空间的对比

CoreGraphic中支持的颜色空间类型:

颜色空间

3、计算图片解码后每行需要的比特数,由两个参数相乘得到:每行的像素数 width,和存储一个像素需要的比特数4。

这里的4,其实是由每张图片的像素格式像素组合来决定的,下表是苹果平台支持的像素组合方式。

像素组合

表中的bpp,表示每个像素需要多少位;bpc表示颜色的每个分量,需要多少位。具体的解释方式,可以看下面这张图:

像素组合

我们解码后的图片,默认采用 kCGImageAlphaNoneSkipLast RGB 的像素组合,没有 alpha 通道,每个像素32位4个字节,前三个字节代表红绿蓝三个通道,最后一个字节废弃不被解释。

4、最关键的函数:调用 CGBitmapContextCreate() 方法,生成一个空白的图片绘制上下文,我们传入了上述的一些参数,指定了图片的大小、颜色空间、像素排列等等属性。

5、调用 CGContextDrawImage() 方法,将未解码的 imageRef 指针内容,写入到我们创建的上下文中,这个步骤,完成了隐式的解码工作。

6、从 context 上下文中创建一个新的 imageRef,这是解码后的图片了。

7、从 imageRef 生成供UI层使用的 UIImage 对象,同时指定图片的 scaleorientation 两个参数。

scale 指的是图片被渲染时需要被压缩的倍数,为什么会存在这个参数呢,因为苹果为了节省安装包体积,允许开发者为同一张图片上传不同分辨率的版本,也就是我们熟悉的@2x,@3x后缀图片。不同屏幕素质的设备,会获取到对应的资源。为了绘制图片时统一,这些图片会被set自己的scale属性,比如@2x图片,scale 值就是2,虽然和1x图片的绘制宽高一样,但是实际的长是width * scale

orientation 很好理解,就是图片的旋转属性,告诉设备,以哪个方向作为图片的默认方向来渲染。

通过以上的步骤,我们成功在子线程中对图片进行了强制转码,回调给主线程使用,从而大大提高了图片的渲染效率。这也是现在主流 App 和大量三方库的最佳实践。

总结

总结一下本文内容:

  • 图片在计算机世界中被按照不同的封装格式进行压缩,以便存储和传输。
  • 手机会在主线程中将压缩的图片解压为可以进行渲染的位图格式,这个过程会消耗大量资源,影响App性能。
  • 我们使用 Core Graphics 的绘制方法,强制在子线程中先对 UIImage 进行转码工作,减少主线程的负担,从而提升App的响应速度。

和 UIImageView 类似,UIKit 隐藏了很多技术细节,降低开发者的学习门槛,但另一方面,却也限制了我们对一些底层技术的探究。文中提到的强制解码方法,其实也是 CGBitmapContextCreate 方法的一个『副作用』,属于比较hack方式,这也是iOS平台的一个局限:苹果过于封闭了。

用户对软件性能(帧率、响应速度、闪退率等等)其实非常敏感,作为开发者,必须不断探究性能瓶颈背后的原理,并且尝试解决,移动端开发的性能优化永无止境。

0

不懂产品的研发,不是好 CTO

在做产品经理之前,我是传说中第三种人类:程序媛。曾经,被产品经理虐过,吐槽过产品经理;现在,也虐过研发同学,也被研发同学吐槽过。因为做过研发也做过产品,可谓知己知彼,深刻的体会到双方沟通中的痛点,希望本文能够帮助研发同学更好的和产品经理沟(si)通(bi)。

什么是产品?

产品是指能够供给市场,被人们使用和消费,并能满足人们某种需求的任何东西,包括有形的物品、无形的服务、组织、观念或它们的组合。

比如,手机满足人们通讯需求,是有形的硬件产品;微信满足人们社交需求,是无形的软件产品;家政服务满足人们清洁需求,是无形的服务产品……

其实,简单来说,产品就是能满足人们某种需求的东西。

什么是好产品?

从以下三组图片中,选择你认为好的产品。

幻灯片02

幻灯片03

幻灯片04

第一组,相信大家都会选择小米遥控器。因为相比传统的遥控器,小米遥控器设计简洁,按键功能一目了然,不用看说明书就知道如何使用。

第二组,每个人的选择都不一样。对于普通用户来说,美图秀秀是个好产品,好用、容易上手,而 PS 完全不知道从何下手。但对于专业设计师来说,美图秀秀就是个渣渣,根本满足不了工作需求。

第三组,大家可能没怎么接触过,通过这两款产品可以实时查询公交的位置,还有几分钟到达相应的站点。仅凭一个界面,有些人会觉得车来了是好产品,因为界面干净、整洁、交互也特别好,而上海公交查询体验特别差。但事实上,我把车来了这个 App 卸载了,却一直在使用上海公交查询,因为它提供的数据非常准确。对我来说,这两个产品都算不上是好产品。

所以,好的产品必须同时具有以下三点:

  • 有用
  • 能用
  • 好用

image

人人都是产品经理?伪命题!

每次看到这句话我就特别想翻白眼,甚至想学罗胖来一句,我呸!

这是大家眼中的产品经理:

幻灯片07

你是想卖一辈子糖水,还是跟着我们改变世界

在每个产品经理内心深处都有一个改变世界的梦想。

在父母眼中,产品经理就是公司的核心,不可或缺。

在研发眼中,产品经理就是一个打杂小妹,随叫随到。

在设计眼中,产品经理就是山顶洞人,完全没有审美眼光。

逻辑不如研发,文案不如运营,审美不如设计,视野不如老板。

为什么需要产品经理?

存在即是合理。

老板表示需要一个背锅的:遇到不好用的 App,大家第一反应都是什么 SB 功能,SB 产品经理,但是从来没有人骂 SB 老板;用户吐槽产品很难用,设计可以说这锅我不背,产品经理设计的流程就是这样的;项目进度延迟,研发可以说这锅我不背,产品经理需求变更频繁……

任何人都可以甩锅,唯独产品经理不可以,任何与产品相关的锅产品经理都要背,这些都是产品经理的责任,不可推卸。

研发、设计表示需要一个打杂的:缺文案,产品经理帮你写;缺数据,产品经理帮你整理;需求开发好了,产品经理还要帮忙验收测试……

市场、用户表示需要一个翻译:例如,如何向用户解释什么是 2G、3G、4G 网络。如果直接告诉用户 2G 是数字网络、3G 是高速 IP 数据网络、4G 是全 IP 数据网络,用户肯定一脸懵逼:什么鬼?但如果告诉用户 2G 可以看文字、3G 可以看图片、4G 可以看小电影,用户立马就明白了。

怎样才能成为产品经理?

在大部分人眼里,只要会吐槽就可以做产品经理。

吐槽吐的专业,可以成为评论家;吐槽吐的幽默,可以成为段子手;吐槽吐的群情激奋,可以成为意见领袖;但发现问题仅仅只是产品经理工作中的一小部分。一个优秀的产品经理,必须会分析并解决问题。

我们来看一下产品经理的关键词:

幻灯片11

是不是有种产品经理从入门到放弃的感觉?其实,将这些关键词整理一下,可以分成以下几个维度:

1.人

幻灯片13

实现一个产品功能,产品经理需要全程跟进,在这个过程中,就免不了在多个部门之间沟通协调。

例如,老板想要增加某个功能,那么产品经理首先需要和老板沟通,了解增加该功能的原因,然后简单评估一下需求的优先级,并给出一个合理的解决方案。找设计沟通UI设计及交互逻辑;拿着做好的设计稿找到研发,确定开发计划及上线时间;功能上线后,向市场、客服部门的同事做培训,推广产品功能;产品功能推向市场后,收集用户反馈,处理用户遇到的问题;功能稳定后,采集数据,分析数据,持续运营产品;最后,还需要向老板及相关部门汇报产品数据,复盘总结。

2.能力

幻灯片14

在图中列出的产品经理相关能力中,最核心的能力是学习能力、独立思考、沟通协调能力。这里的沟通能力,并不是指性格开朗、热情大方、幽默风趣这些一般人理解的沟通能力,而是把细节说清楚,专业。把细节描述清楚,没有歧义,双方能达成共识;工作态度专业认真,不过于情绪化。而协调指的是协调所有可用资源,让事情朝着你想要的方向前进。

3.工具&方法&输出

幻灯片16

工具、方法、输出都是手段,不是目的。学以致用才是学习的目的,学了不会用等于白学。

而在实际工作中,光会这些还不够,必须把所有这些都揉碎、重组、融入到工作的每个细节中。

左边是大家看到的路径,右边是产品经理考虑的路径。

幻灯片23

左边是大家看到的需求,右边是产品经理面对的需求。

幻灯片24

左边是大家的工作流程,右边是产品经理的工作流程。

幻灯片25

以杏仁医生App药品登记需求为例,下图是用户和研发看到的。

幻灯片26

而实际上,下图是我考虑的内容。

幻灯片27

看到这里,你还认为产品经理门槛很低吗?人人都是产品经理吗?

产品经理不生产需求,只是需求的搬运工

介绍完产品经理,我们要介绍产品的另一个关键词:需求。产品经理不生产需求,只是需求的搬运工。有人可能要说反对意见,乔布斯不就生产了需求吗?其实乔布斯也没有生产需求。

需求就像煤矿一样,有的随意散落在地表,大家都能看见;可绝大部分有价值的需求就像埋在地底下的煤矿一样,需要专业的人员去挖掘,去采集,去清洗。而产品经理就是这个挖煤的矿工,优秀的产品经理能挖掘到人性最深层的需求。

按照马斯洛需求层次理论,饿了么满足了用户的生理需求,在家也能吃上全国各地的饭菜;支付宝满足了用户的安全需求,给用户提供了资金安全保障;微信满足了用户的社交需求,让用户低成本的与朋友、亲人、同事交流;知乎满足了用户的尊重需求,让用户展示自己出众的知识技能,得到大家的赞赏;得到满足了用户自我实现需求,营造一种充实感,让用户感觉自己每天都在进步……

image__1_

需求需要符合这四个条件:

  • 特定的人
  • 特定的情况下
  • 特定的问题
  • 可以被解决

概括下来就两个字:场景

之前有同事提出,杏仁医生 App 中的药品登记模块需要增加一个批量登记的功能。咋一看是不是很合理?但仔细一想,什么场景下医生会需要批量登记呢?

事实上,并没有这样的场景。医生开药的场景大部分都是先搜索药品,如果没有搜索到合适的药品,就顺手登记一下。医生并不会用小本本记下缺哪些药品,然后批量登记。因此,批量登记功能是一个伪需求。

把问题放到一个具体的场景中可以过滤掉非常多的伪需求、不合理的需求。

需求从哪里来?

1.公司外部

公司外部的需求主要来自用户、合作伙伴、竞争对手。用户经常抱怨某个功能不好用,用户反馈其实就是需求;合作伙伴在和我们合作的过程中,肯定会产生一些新的想法,这些也是需求;竞争对手推出了一些好用的功能抢走了很多用户,为了抢夺市场,也会产生需求。

2.公司内部

公司内部的需求主要来自公司战略、产品规划、内部人员。每年公司都会制定公司一整年的战略计划,产品总监会根据这些战略制定每个季度的产品规划,产品经理再将这些产品规划分解成多个产品功能。在产品迭代的过程中,老板、市场、运营会提各种各样的需求,研发、产品经理也会从技术、产品层面提出需求。

怎么定优先级?

1.刚性

指这个需求是否迫切,实现了这个需求,是不是能立马解决困扰用户很久的问题。

2.广度&频率

广度指这个需求会影响多少用户,多少核心用户。频率指用户使用这个功能的频率。

3.替代方案

指解决这个需求,是否有替代方案。

按照以上原则,规划电商模块功能。第一个版本必须要实现商品展示、商品详情、购物车、下单结算、支付功能,因为这是用户购买过程中最核心的功能。而售后退款相关流程可以放到后续版本,因为在初期售后退款功能并不是很迫切,只影响部分用户,且存在替代方案——人工处理的方式。当用户规模达到一定程度后,再做相关功能的开发。

满足需求的方法?

相信大家都去海底捞吃过火锅,每次去总是人满为患,假设你是海底捞的店长,你怎么解决海底捞排队的问题?满足用户快速吃饭的需求。

1.改变现状

我们能想到最直接的办法,就是扩大门店,增加更多的桌子和服务员。但性价比很低,一是扩张需要成本,需要额外负担装修成本、服务员工资;二是在非吃饭高峰期造成资源浪费,大面积的区域没有使用,部分服务员无事可做。

2.降低期望

在海底捞排队取号时,通常服务员都会告诉你前面还有多少桌,并告诉你大概的排队时间。但这个预估的排队时间通常都比实际等待时间要长一倍到两倍,虽然会吓跑一部分顾客,但留下来排队的顾客体验会好很多。本来以为要排 1 个小时,实际上只排了半个小时,内心会有小小的惊喜:海底捞效率还挺高的。

3.转移需求

现在海底捞的做法就是在排队区域准备一些小零食、水果和小游戏,让顾客一边吃东西玩游戏,一边排队。一方面通过零食和小游戏分散顾客的注意力,缓解顾客排队等候的暴躁情绪;另一方面通过零食和小游戏将大部分顾客牢牢的锁在海底捞,提高订单转化率。

研发与产品的爱恨情仇,本是同根生相煎何太急

经常会在网上看到程序员吐槽产品经理的各种段子、漫画和文章,平时工作中也遇到不少。分析下来,矛盾的根源在于:

  • 频繁变更需求
  • 低估研发成本
  • 需求变更没有通知到位

产品经理防“群殴”指南

image__2_

需求频繁变更,其实说明产品经理在分析需求时缺乏思考,没有多想几个为什么。

不要用战术上的勤奋掩饰战略上的懒惰。

经常有朋友问我:做产品经理和做研发有什么区别?

最大的区别在于研发 80% 的时间用于执行,而产品 80% 的时间用于思考和沟通。

1.考虑未来三个月内的扩展性

在做一些比较大的功能模块时,不能只考虑第一个版本的规划,还需要考虑未来两到三个版本的功能。可以将版本规划提前告知研发团队,让他们了解未来产品要做成什么样子,哪些功能必须要做,哪些功能可能要优化。这样研发同学就可以提前做相关技术调研,规划技术架构,提高代码的可扩展性。

2.考虑常见的特殊情况

最常见的情况,调后台接口,展示列表数据。此时就要考虑很多种特殊情况:

  1. 没有网络
  2. 网络条件不好
  3. WiFi网络环境和非WiFi网络环境
  4. 没有数据
  5. 少量数据
  6. 大量数据

再比如,电商业务中最常见的订单,需要梳理整个过程中的状态,并画出各个状态之间的转化示意图。只有把所有状态都列清楚,才能确认逻辑是否完善,让研发能更好的实现订单流程。

3.不要想满足所有人的需求

相信大家也知道夫妻骑驴的故事,在这个故事中,无论这对夫妻采用何种方案,总会有批评的声音。

image__3_

做产品也是同样的道理。比如,针对患教资料是否显示原创医生的个人信息。部分医生认为应该显示,理由一:这是医生花费大量时间和精力编写的文章,要尊重医生的知识产权;理由二:通过分享患教资料扩大知名度,让更多的陌生患者添加自己;另一部分医生却强烈要求隐藏原创医生的个人信息,这部分医生认为自己分享了其他医生写的患教资料,相当于向自己的患者介绍其他医生,为其他医生做推广。

无论是否显示原创医生的个人信息,总会有部分医生不满。你不可能满足所有用户的需求,在这种情况,坚持做自己认为对的事情就好。

4.MVP( Minimal Viable Product 最小可行性产品)

产品迭代路径应该按照 MVP 原则,先向用户提供能用的产品,再根据用户需求,逐步提供更好用的产品。而不是一上来,就规划一个非常完美的方案,然后花上好几个月甚至一两年来实现,说不定等你实现所谓“完美”的产品,用户需求早已发生翻天覆地的变化。

image__4_

5.适当了解一些技术,适当看一些技术文档

在做患者端小程序前,先查看小程序接口文档,了解以下问题:

  1. 小程序能实现是否支持语音、视频、扫一扫功能
  2. 小程序和公众号如何相互跳转
  3. 小程序通知下发机制
  4. 如何配置 webview 业务域名
  5. webview 组件如何实现微信支付

这样才能规划小程序做哪些功能;如何实现小程序和公众号之间相互引流;如何通知用户;遇到非业务域名如何处理;如何在 webview 中调用小程序微信支付。

之前听过一句玩笑话。

产品经理懂技术等于流氓会武功?

流氓会武功的话,对付一般的小警察还是很有优势的;但如果流氓因为自己会武功,而无法无天,自以为是;等遇到狙击手的时候,直接一枪就被放到了好吗。

同样的道理,产品经理不能仗着自己懂一点技术,就对研发同学指手画脚。懂技术是为了排除需求中的隐患,更好的和研发沟通,提升工作效率。了解一些基础技术架构即可,不要沉迷于技术实现,否则,就是本末倒置。

6.重视文档更新

这个不解释。

研发防“入坑”指南

每次版本总结,总是会有研发表示被产品经理“坑”惨了:需求变更太频繁,需求不靠谱,需求解读错误,需求不明确……除去市场、产品本身存在的问题,大部分问题其实是沟通问题。

1.正确看待需求变更

一提到需求变更,一些研发会皱着眉头改代码,心里 mmp ,怎么又 TM 改需求啊;一些研发会让产品经理签字画押,作为日后打脸的呈堂证供;还有一些研发会直接拒绝,要改你来改啊,老子就不改……但事实上,业务稍微复杂一点的产品都会有需求变更,要求产品没有需求变更,相当于要求研发写的代码没有 bug 。我相信没有一个研发敢摸着良心保证自己的代码没有一个 bug 。

从另一个角度思考,需求变更其实是一件好事。正因为有需求变更,才能体现研发的价值:如果没有需求变更,80% 的程序员都要失业了。正因为有需求变更,研发的工作更具有挑战:如何设计更合理的架构,覆盖更多的业务场景,支持更复杂的业务流程。

2.先考虑需求是不是合理,是不是有更好的解决方案

看到这里,有部分研发同学认为,我为什么要考虑这些,这是产品经理的工作,不是研发的工作。这的确不是研发的工作,但实现这些需求却是研发的工作。为了避免自己被“坑”,如果有更好的解决方案,完全可以提出来,和产品经理一起讨论。

也许你们会讨论出一套性价比更高的方案:一方面满足产品需求,一方面减少自身的开发工作量。也许你们讨论过程中发现遗漏了某个分支的处理流程:一方面发现了产品设计的漏洞,一方面减少了 bug 数量提高代码质量。

3.有疑问,有困难一定要尽早提出,不要自己一个人默默的解决

例如,某个功能实现起来有技术难度,此时,研发和产品经理可以沟通讨论出更好的方案;某个模块功能太多研发时间太长,此时,产品经理可以根据实际项目情况,砍掉部分优先级低的需求,或者申请适当延长研发周期。千万不要自己一个人死扛,默默的处理遇到的所有问题,最终产品验收时,发现偏离了产品需求,吃力不讨好。

在每个版本开发前,产品经理都会先和各组研发 leader 进行小范围的需求评审,之后和后台研发同事进行第二轮需求评审,最后和所有相关研发同事进行最终需求评审。在这个过程中,产品经理逐步完善产品设计。前期沟通的越清楚,后期沟通的成本就越小,产品需求变更的情况也越少。

4.产品经理做的不好的地方,可以当面指出

在产品研发的过程中,产品经理接触最多的就是研发,听到最多的就是研发同事对产品经理的吐槽与抱怨。吐槽需求不靠谱,吐槽产品原型有漏洞,吐槽产品经理低估研发成本……吐槽产品经理也成为研发日常工作的一部分,我也相信这些槽点三天三夜也吐不完。

面对这些吐槽与抱怨,任何一个有职业素养的产品经理都会认真的听取意见。毕竟产品经理的部分工作就是收集用户建议、跟踪用户反馈、持续提高用户体验,而研发其实是产品经理最重要的用户。

没有什么事情,是一顿火锅解决不了的。如果有,那就两顿!

相信大家都知道瞎子和跛子的故事:一个瞎子和一个跛子,被困在火场中,眼看着要被烧死了,瞎子背起跛子,跛子指路,终于从大火中死里逃生。

如果说创业就像在火场中逃生,九死一生。那么产品经理就是一个跛子,能看见前进的方向,但没有行动能力;研发是一个瞎子,有行动能力,但看不见前进的方向;如果研发和产品经理之间相互猜忌,埋怨,最终可能是死路一条。只有两者相互信任,才有可能活着走出火场。

最后,希望研发同学和产品经理能够相互信任、和谐沟通,一起愉快的玩耍。

0

技术选型的艺术

什么是技术选型

技术选型对于广大程序员,特别是互联网公司的技术负责人或者架构师来说,一定不陌生。小到日常开发中的一个工具库的选择,大到整个系统语言、架构层面的选择,都是技术选型的范围。今天我们就简单聊聊技术选型。

一般而已,我们会碰到的技术选型,可以分为以下几类:

  • 基础设施选型:云平台或IDC、编程语言、数据库等。
  • 框架和库的选型:前后端的开发框架、核心类库等。
  • 中间件选型:负载平衡、消息中间件、缓存中间件等。
  • 第三方服务选择:第三方的推送、短信等。

实际上,我们常常面临的不是单个技术的选型,而是对于一个项目所涉及的一整套的技术、方案、规范或者产品的选型。这就需要我们更仔细的去权衡各种技术、各种组合的利弊,作出取舍。

技术选型,其实本质上就是一种架构决策。《系统架构》这本书里将架构决策总结成了六种模式,包括选项、筛选、指派、分区、排列和连接。而技术选型明显属于其中的选项(Decision-Option)模式,也即每个决策都是一套离散选项,从中选择一个合适的。

影响技术选型的因素

项目因素

首先考虑的是项目。

我们要明确项目本身的性质,包括项目的规模、重要程度、时间要求等等。如果是一个小规模的实验性项目,那么尝试一些新技术也未尝不可。如果时间要求非常紧,那可以考虑基于开源或商用的程序修改。淘宝当年上线时候,就是购买了一个商用程序后修改的。另外,项目的成本和预算也是必须要考虑的。如果预算足够,我们也可以考虑购买商用程序或者第三方服务。

项目的需求也会影响甚至限制技术的选型。特别要注意的是那些非功能性的需求,例如对并发性、实时性、可用性、数据一致性、安全性等方面的需求,往往对技术方案和选型有很大影响。例如支付相关的应用,对数据的一致性有非常高的要求,那核心的支付数据的存储,就会倾向选择 Oracle、PostgreSQL 这种强一致性的数据库,而不会去选 MongoDB(虽然据说马上也要支持事务了)。

团队因素

其次要考虑的是团队,也就是说人的因素。

技术选型一定要考虑当前团队人员的技术组成。对于一些比较基础的技术的选型,比如语言和框架、数据库等等,往往最合适的选择就是团队最熟悉的技术。如果打算选择不同的技术的话,那就要考虑下团队是否有人能够 Hold 住了。另一个必须要考虑的是招聘,如果使用的是比较小众的技术,那么当需要扩充团队的时候,招聘人员会比较困难。我们杏仁在这方面就取了一个巧,最开始我们用的是基于 JVM 的 Scala 语言,但是我们招聘的时候,都是打的 Java 工程师的幌子。

团队的发展时期对于技术选型也会有一定影响。对于早期团队,大多数员工都是喜欢创新、愿意承担风险,那么可以选择一些相对较新、有挑战的技术;团队发展到一定阶段,那就要开始考虑团队效率、开发规范等因素,此时往往会选择一些比较大众的、经过验证的技术。

我加入现在的公司时,我们正好开始转型。原有的产品是基于 Web 的网站,我们打算开发一个移动 APP。我们原先后端应用使用的是 Scala 语言和 Play 框架,当时面临的一个问题是,新的产品后端应该选择什么技术?虽然也考虑过 Java,但因为时间很紧,我们还是选择了团队最熟悉的 Scala。但后面我们开始服务化的时候,就选择了 Java。

还有一点就是,虽然技术选型需要考虑团队人员的喜好,但千万不要因为某几个人的个人喜好,来决定技术的选型。还是通过细致的分析和实验来进行选型。而决策者也需要看的更长远一些,推动团队技术向前发展。

技术因素

技术选型当然也必须考虑技术因素,例如技术特性(易用性、可维护性、可扩展性、性能等)、技术成熟度、社区活跃度、架构匹配和演化等等。

技术特性往往是大家都会去关注的,相信不必多说。但是注意不要只是浏览网上看到的各种分析,对于重要的特性,还是需要去亲自实验一下或者做一些测试,有第一手的感觉。另外,这些特性往往不可兼得,所以我们需要对照项目因素和团队因素来进行取舍。

技术的发展有其内在趋势,Gartner 每年都会发布一个 Hyper Cycle,标记各种技术的发展阶段;另外一个很好的参考是 ThoughtWorks 的技术雷达。我们可以根据项目、团队等因素去选择不同成熟度的技术和产品。但是对于核心项目,最好还是选择足够成熟的技术,切忌盲目追求新技术。

对于比较重要的应用,我们一般会选择比较大众的技术,因为使用的人多了,也从某种程度上说明这个产品是比较优秀的,并且招聘也会更容易些。这方面可以参考的有 Github 的 star 数、Google Trends、百度指数等等。社区活跃度也是必须要考虑的因素,你一定不会想用一个没有人维护,提了问题也没人回复的技术产品吧。

最后还需要考虑技术产品和当前架构的匹配程度。一个团队的技术栈太过散乱,对开发和运维会是很大的压力,甚至影响系统的稳定性。例如我们当前的数据库使用的是 MySQL,而一个技术产品如果要求必须引入 MongoDB,那我一定会多想一下,我们多维护一个 MongDB 是不是值得。另外,如果大家对自己架构的演化有一定规划,那也要考虑引入的新的技术产品和未来的架构是否能够匹配。

其他因素

最后,可能还会有一些额外的因素要考虑。比如许可协议的问题,前段时间闹得沸沸扬扬的 React Native 许可协议事件,相信大家记忆犹新。还有一些公司,可能对于使用第三方的程序或者服务,有一些原则甚至规范,那也一定要注意参考。另外,在稍大一些的公司里可能还会涉及一些派系之争,这里就不多说了。

如何进行技术选型

我们上面已经列出了很多技术选型需要考虑的因素,那么到底如何进行技术选型呢?大致上可以分为以下几个步骤:

  1. 首先要明确选型的需求和目的,最好能列出必须要考虑的各种因素以及评判标准。
  2. 然后就可以开始寻找候选技术或产品了。这时范围可以尽量广一些,搜集尽可能多的候选技术和产品。
  3. 其次就可以进行初步筛选。把一些由于各种限制无法选择的、或者明显不可能的技术或产品排除,筛选出 3 个左右备选方案。
  4. 再然后就要做一些详细的调查和分析了。可以列一个表格,把备选方案、以及需要考虑的因素放到表格的横向和纵向中去,一个个进行评估的分析。此时可能会需要做一些小 Demo 验证可行性,或者做一些性能测试、压力测试等等。必要的话可以在表格里给每一个因素打分。
  5. 最后,对分析结果进行评审,作出最后决定。

技术选型分析表,每一格里可以有具体的打分,也可以有优势劣势的评价。

候选A 候选B 候选C
团队
技术成熟度
性能
架构一致性
...

当然,小的不太重要的技术选型也不一定要这么麻烦,而重要的技术选型则可能要反复各个步骤多次。

技术选型的几个注意点

  • 一定要进行可行性分析,如果不太确定,做个 Demo 验证一下。如果项目进行到一半,发现原来设想的某个方案不可行,那会是非常痛苦和浪费时间的事情。
  • 不要有思维定势,习惯性的使用某些技术,比如服务化就用 Dubbo、缓存就用 Redis,具体问题要具体分析。也不要赶时髦,在重要的项目上使用太新的不够成熟的技术。
  • 随着业务发展,很多架构需要不断升级,所以一定要考虑未来如果要替换某项技术,是否会很麻烦。可以选择符合一些标准的技术或产品,或者在应用中部署一个适配层,方便未来适配其他技术。
  • 架构应该尽可能统一,一个领域避免引入太多相同功能的技术产品。

总结

最后,大家其实会发现,技术选型既是一种科学、又是一种艺术,有时候并没有对错之分。最后面临两难选择的时候,还是需要决策人拿出勇气,拍板决定,坚定的去推进。

0

服务网格:微服务进入2.0时代

微服务自2014年3月由Martin Fowler首次提出以来,在Spring CloudDubbo等各类微服务框架的帮助下,以燎原之势席卷了整个IT技术界,成为了最主流的分布式应用解决方案。但仍然还有很多问题没有得到根本性的解决,比如技术门槛高、多语言支持不足、代码侵入性强等。如何应对这些挑战成为了下一代微服务首要回答的问题。直到服务网格(Service Mesh)被提出,这一切都有了答案。

1 微服务之殇

时光回到2017年初,那时所有主流的微服务框架,不管是类库性质的FinagleHystrix,还是框架性质的Spring Cloud、Dubbo,本质上都归于应用内解决方案,都存在以下三个问题:

  • 技术门槛高:随着微服务实施水平的不断深化,除了基础的服务发现配置中心授权管理之外,团队将不可避免的在服务治理层面面临各类新的挑战,包括但不限于分布式跟踪、熔断降级、灰度发布、故障切换等,这对团队提出了非常高的技术要求。

service-governance

图片出处:Service Mesh:下一代微服务

  • 多语言支持不足:对于稍具规模的团队,尤其在高速成长的互联网创业公司,多语言的技术栈是常态,跨语言的服务调用也是常态,但目前开源社区上并没有一套统一的、跨语言的微服务技术栈。
  • 代码侵入性强:主流的微服务框架(比如Spring Cloud、Dubbo)或多或少都对业务代码有一定的侵入性,框架替换成本高,导致业务团队配合意愿低,微服务落地困难。

这些问题加起来导致的结果就是,在实施微服务的过程中,小团队Hold不住,大公司推不动。

2 另辟蹊径

如何解决上述三个问题呢?最容易想到的是代理模式,在LB层(比如NginxApache HTTP Server)处理所有的服务调用,以及部分服务治理问题(比如分布式跟踪、熔断降级)。但这个方案有两个显著的缺点,第一,中心化架构,代理端自身的性能和可用性将是整个系统的瓶颈;第二,运维复杂度高,业务团队笑了,运维团队哭了。

难道这就是桃园吗?

服务网格(Service Mesh)应运而生!自2016年9月Linkerd第一次公开使用之后,伴随着LinkerdEnvoyIstioNGINX Application PlatformConduit等新框架如雨后春笋般不断涌现,在微服务之后,服务网格和它的边车(Sidecar)模式引领了IT技术界2017一整年的走向。

3 服务网格

3.1 元定义

首先,我们来看一下服务网格的提出者William Morgan是如何描述它的。

A service mesh is a dedicated infrastructure layer for handling service-to-service communication. Consists of a control plane and data plane (service proxies act as "mesh"). - William Morgan, What's a Service Mesh? And Why Do I Need One?

上面这段话非常清晰的指明了服务网格的职责,即处理服务间通讯,这正是服务治理的核心所在。而a dedicated infrastructure layer这几个单词将服务网格和之前所有的微服务框架(framework)划清了界限,也即服务网格独立于具体的服务而存在,这从根本上解决了前文提到的老的微服务框架在多语言支持和代码侵入方面存在的问题。并且,由于服务网格的独立性,业务团队不再需要操心服务治理相关的复杂度,全权交给服务网格处理即可。

那你可能会问,这不跟之前提到的代理模式差不多吗?区别在于服务网格独创的边车模式。针对每一个服务实例,服务网格都会在同一主机上一对一并行部署一个边车进程,接管该服务实例所有对外的网络通讯(参见下图)。这样就去除了代理模式下中心化架构的瓶颈。同时,借助于良好的框架封装,运维成本也可以得到有效的控制。

linkerd-service-mesh-diagram

图片出处:What's a Service Mesh? And Why Do I Need One?

3.2 演化史

追本溯源,服务网格从无到有可分为三个演化阶段(参见下图)。第一个阶段,每个服务各显神通,自行处理对外通讯。第二个阶段,所有服务使用统一的类库进行通讯。第三个阶段,服务不再关心通讯细节,统统交给边车进程,就像在TCP/IP协议中,应用层只需把要传输的内容告诉TCP层,由TCP层负责将所有内容原封不动的送达目的端,整个过程中应用层并不需要关心实际传输过程中的任何细节。

pattern-network

pattern-library

pattern-sidecar

图片出处:Pattern: Service Mesh

3.3 时间线

最后,再来回看一下服务网格年轻的历史。虽然服务网格的正式提出是在2016年9月,但其实早在2013年,Airbnb就提出了类似的想法——SmartStack,只不过SmartStack局限于服务发现,并没有引起太多关注,类似的还有Netflix的Prana和唯品会的OSP Local Proxy。2016年服务网格提出之后,以Linkerd和Envoy为代表的框架开始崭露头角,并于2017年先后加入CNCF基金(Cloud Native Computing Foundation),最终促使了一代新贵Istio的诞生。2018年,Istio将发布1.0版本,这也许意味着微服务开始进入2.0时代。

history

图片出处:Service Mesh:下一代微服务

4 参考

0

Common Pool2 对象池应用浅析

我们系统中一般都会存在很多可重用并长期使用的对象,比如线程、TCP 连接、数据库连接等。虽然我们可以简单的在使用这些对象时进行创建、使用结束后销毁,但初始化和销毁对象的操作会造成一些资源消耗。我们可以使用对象池将这些对象集中管理,减少对象初始化和销毁的次数以节约资源消耗。

顾名思义,对象池简单来说就是存放对象的池子,可以存放任何对象,并对这些对象进行管理。它的优点就是可以复用池中的对象,避免了分配内存和创建堆中对象的开销;避免了释放内存和销毁堆中对象的开销,进而减少垃圾收集器的负担;避免内存抖动,不必重复初始化对象状态。对于构造和销毁比较耗时的对象来说非常合适。

当然,我们可以自己去实现一个对象池,不过要实现的比较完善还是要花上不少精力的。所幸的是, Apache 提供了一个通用的对象池技术的实现: Common Pool2,可以很方便的实现自己需要的对象池。Jedis 的内部对象池就是基于 Common Pool2 实现的。

核心接口

Common Pool2 的核心部分比较简单,围绕着三个基础接口和相关的实现类来实现:

  • ObjectPool:对象池,持有对象并提供取/还等方法。
  • PooledObjectFactory:对象工厂,提供对象的创建、初始化、销毁等操作,由 Pool 调用。一般需要使用者自己实现这些操作。
  • PooledObject:池化对象,对池中对象的封装,封装对象的状态和一些其他信息。

Common Pool2 提供的最基本的实现就是由 Factory 创建对象并使用 PooledObject 封装对象放入 Pool 中。

对象池实现

对象池有两个基础的接口 ObjectPoolKeyedObjectPool, 持有的对象都是由 PooledObject 封装的池化对象。 KeyedObjectPool 的区别在于其是用键值对的方式维护对象。

ObjectPoolKeyedObjectPool 分别有一个默认的实现类 GenericObjectPoolGenericKeyedObjectPool 可以直接使用,他们的公共部分和配置被抽取到了 BaseGenericObjectPool 中。

SoftReferenceObjectPool 是一个比较特殊的实现,在这个对象池实现中,每个对象都会被包装到一个SoftReference中。SoftReference允许垃圾回收机制在需要释放内存时回收对象池中的对象,可以避免一些内存泄露的问题。

ObjectPool

下面简单介绍一下 ObjectPool 接口的核心方法,KeyedObjectPoolObjectPool 类似,区别在于方法多了个参数: K key

public interface ObjectPool<T> {

    // 从池中获取一个对象,客户端在使用完对象后必须使用 returnObject 方法返还获取的对象
    T borrowObject() throws Exception, NoSuchElementException,
            IllegalStateException;

    // 将对象返还到池中。对象必须是从 borrowObject 方法获取到的
    void returnObject(T obj) throws Exception;

    // 使池中的对象失效,当获取到的对象被确定无效时(由于异常或其他问题),应该调用该方法
    void invalidateObject(T obj) throws Exception;

    // 池中当前闲置的对象数量
    int getNumIdle();

    // 当前从池中借出的对象的数量
    int getNumActive();

    // 清除池中闲置的对象
    void clear() throws Exception, UnsupportedOperationException;

    // 关闭这个池,并释放与之相关的资源
    void close();

    ...
}

PooledObjectFactory

对象工厂,负责对象的创建、初始化、销毁和验证等工作。Factory 对象由ObjectPool持有并使用。

public interface PooledObjectFactory<T> {

    // 创建一个池对象
    PooledObject<T> makeObject() throws Exception;

    // 销毁对象
    void destroyObject(PooledObject<T> p) throws Exception;

    // 验证对象是否可用
    boolean validateObject(PooledObject<T> p);

    // 激活对象,从池中取对象时会调用此方法
    void activateObject(PooledObject<T> p) throws Exception;

    // 钝化对象,向池中返还对象时会调用此方法
    void passivateObject(PooledObject<T> p) throws Exception;
}

Common Pool2 并没有提供 PooledObjectFactory 可以直接使用的子类实现,因为对象的创建、初始化、销毁和验证的工作无法通用化,需要由使用方自己实现。不过它提供了一个抽象子类 BasePooledObjectFactory,实现自己的工厂时可以继承 BasePooledObjectFactory,就只需要实现 createwrap 两个方法了。

PooledObject

PooledObject 有两个实现类,DefaultPooledObject 是普通通用的实现,PooledSoftReference 使用 SoftReference 封装了对象,供 SoftReferenceObjectPool 使用。

下面是 PooledObject 接口的一些核心方法:

public interface PooledObject<T> extends Comparable<PooledObject<T>> {

    // 获取封装的对象
    T getObject();

    // 对象创建的时间
    long getCreateTime();

    // 对象上次处于活动状态的时间
    long getActiveTimeMillis();

    // 对象上次处于空闲状态的时间
    long getIdleTimeMillis();

    // 对象上次被借出的时间
    long getLastBorrowTime();

    // 对象上次返还的时间
    long getLastReturnTime();

    // 对象上次使用的时间
    long getLastUsedTime();

    // 将状态置为 PooledObjectState.INVALID
    void invalidate();

    // 更新 lastUseTime
    void use();

    // 获取对象状态
    PooledObjectState getState();

    // 将状态置为 PooledObjectState.ABANDONED
    void markAbandoned();

    // 将状态置为 PooledObjectState.RETURNING
    void markReturning();
}

对象池配置

对象池配置提供了对象池初始化所需要的参数,Common Pool2 中的基础配置类是 BaseObjectPoolConfig。其有两个实现类分别为 GenericObjectPoolConfigGenericKeyedObjectPoolConfig,分别为 GenericObjectPoolGenericKeyedObjectPool 所使用。

下面是一些重要的配置项:

  • lifo 连接池放池对象的方式,true:放在空闲队列最前面,false:放在空闲队列最后面,默认为 true
  • fairness 从池中获取/返还对象时是否使用公平锁机制,默认为 false
  • maxWaitMillis 获取资源的等待时间。blockWhenExhausted 为 true 时有效。-1 代表无时间限制,一直阻塞直到有可用的资源
  • minEvictableIdleTimeMillis 对象空闲的最小时间,达到此值后空闲对象将可能会被移除。-1 表示不移除;默认 30 分钟
  • softMinEvictableIdleTimeMillis 同上,额外的条件是池中至少保留有 minIdle 所指定的个数的对象
  • numTestsPerEvictionRun 资源回收线程执行一次回收操作,回收资源的数量。默认 3
  • evictionPolicyClassName 资源回收策略,默认值 org.apache.commons.pool2.impl.DefaultEvictionPolicy
  • testOnCreate 创建对象时是否调用 factory.validateObject 方法,默认 false
  • testOnBorrow 取对象时是否调用 factory.validateObject 方法,默认 false
  • testOnReturn 返还对象时是否调用 factory.validateObject 方法,默认 false
  • testWhileIdle 池中的闲置对象是否由逐出器验证。无法验证的对象将从池中删除销毁。默认 false
  • timeBetweenEvictionRunsMillis 回收资源线程的执行周期,默认 -1 表示不启用回收资源线程
  • blockWhenExhausted 资源耗尽时,是否阻塞等待获取资源,默认 true

池化对象的状态

池化对象的状态定义在 PooledObjectState 枚举中,有以下值:

  • IDLE 在池中,处于空闲状态
  • ALLOCATED 被使用中
  • EVICTION 正在被逐出器验证
  • VALIDATION 正在验证
  • INVALID 驱逐测试或验证失败并将被销毁
  • ABANDONED 对象被客户端拿出后,长时间未返回池中,或没有调用 use 方法,即被标记为抛弃的

这些状态的转换逻辑大致如下图:

状态流转图

Demo

最后,我们来实现一个简单的 Demo 来上手 Common Pool2 的使用,这是一个 StringBuffer 的对象池的使用。

首先要实现工厂的创建、封装和销毁操作。对象池和池化对象封装使用默认实现就可以了。

public class StringBufferFactory extends BasePooledObjectFactory<StringBuffer> {
    // 创建一个新的对象
    @Override
    public StringBuffer create() {
        return new StringBuffer();
    }

    // 封装为池化对象
    @Override
    public PooledObject<StringBuffer> wrap(StringBuffer buffer) {
        return new DefaultPooledObject<>(buffer);
    }

    // 使用完返还对象时将 StringBuffer 清空
    @Override
    public void passivateObject(PooledObject<StringBuffer> pooledObject) {
        pooledObject.getObject().setLength(0);
    }
}

然后就可以使用对象池了,基本的操作就是获取、返还和标记失效等。

// 创建对象池配置
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
// 创建对象工厂
PooledObjectFactory factory = new StringBufferFactory();
// 创建对象池
ObjectPool<StringBuffer> pool = new GenericObjectPool<>(factory, config);

StringReader in = new StringReader("abcdefg");

StringBuffer buf = null;
try {
    // 从池中获取对象
    buf = pool.borrowObject();

    // 使用对象
    for (int c = in.read(); c != -1; c = in.read()) {
        buf.append((char) c);
    }
    return buf.toString();
} catch (Exception e) {
    try {
        // 出现错误将对象置为失效
        pool.invalidateObject(buf);
        // 避免 invalidate 之后再 return 抛异常
        buf = null; 
    } catch (Exception ex) {
        // ignored
    }

    throw e;
} finally {
    try {
        in.close();
    } catch (Exception e) {
        // ignored
    }

    try {
        if (null != buf) {
            // 使用完后必须 returnObject
            pool.returnObject(buf);
        }
    } catch (Exception e) {
        // ignored
    }
}

总结

Common Pool2 的应用非常广泛,在日常的开发工作中也有很多使用场景。它的整体架构也并不复杂,可以将其简单划分为 3 个角色和相关的配置、状态,掌握起来比较简单。而且 Common Pool2 官方也提供了一些通用的实现,有特殊的开发需求时也可以简单的扩展其提供的抽象类,可以满足大部分的日常开发需求。

0

简单聊聊各种语言的函数扩展

背景

最近有同事反应,我们运营后台下载的 CSV 文件出现错乱的情况。问题的原因是原始数据中有 CSV 中非法的字符,比如说姓名字段,因为是用户填写的,内容有可能包含了 ," 等字符,会导致 CSV 文件内容错乱。

于是我就想用一个简单的方式来解决这个问题。一个简单粗暴的解决方案就是导出时对字符串进行处理,将一些特殊字符替换掉,或者前后用"包起来。但是这样的话,需要所有下载 CSV 的地方都要改写,会比较麻烦。如果我们可以简单的给 String 增加一个方法(如 String.csv())直接就把字符串处理成 CSV 兼容的格式,就会方便很多。我们的运营后台是使用 Scala 语言开发的,所幸的是,Scala 里提供了一个非常强大的功能,可以满足我们的需求,那就是隐式转换。

Scala 的隐式转换

在 Scala 里可以通过 implicit 隐式转换来实现函数扩展。

编译器在碰到类型不匹配或是调用一个不存在的方法的时候,会去搜索符合条件的隐式类型转换,如果找不到合适的隐式转换方法则会报错。

下面是处理 CSV 下载字符串的代码:

trait CsvHelper {
  implicit def stringToCsvString(s: String) = new CsvString(s)
}
class CsvString(val s: String){
  def csv = s"""${s.replaceAll(",", " ").replaceAll("\"", "'")}"""
}

class Controller extends CsvHelper {
    def dowload(){
        ...
        ",foo,".csv //foo
    }
}

Controller 中我调用 String.csv 方法,但是 String 没有 csv 方法。这时候编译器就会去找 Controller 中有没有隐式转换的方法,发现在其父类 CsvHelper 中有方法把 String 转换成 CsvString,而 CsvString 中实现了 csv 方法。所以编译器最终会调用到 CsvString.csv 这个方法。

隐式转换是一个很强大,但是也很容易误用的功能。Scala 里隐式转换有一些基本规则:

  • 优先规则:如果存在两个或者多个符合条件的隐式转换,如果编译器不能选择一条最优的隐式转换,则提示错误。具体的规则是:当前类中的隐式转换优先级大于父类中的隐式转换;多个隐式转换返回的类型有父子关系的时候,子类优先级大于父类。
  • 隐式转换只会隐式的调用一次,编译器不会调用多个隐式方法,不会产生调用链。
  • 如果当期代码已经是合法的,不需要隐式转换则不会使用隐式转换。

Java 的动态扩展

我们再来看看我们熟悉的 Java 语言。Java 是一门静态语言,本身没有直接提供动态扩展的方法,但是我们可以通过 AOP 动态代理的方式来修改一个方法,从而间接的实现方法的动态扩展。

下面就是一个我们就用 AspectJ 来实现一个动态扩展,用于分页查询后获取数据的总条数。

@Aspect
@Component
public class PaginationAspect {
    @AfterReturning(
        pointcut = "execution(* com.xingren..*.*ByPage(..))",
        returning = "result"
    )
    public void afterByPage(JoinPoint joinPoint, Object result) {
        //根据result获取sql信息,再查询总条数封装到result中。
    }
}

其中 AfterReturning 注解表明在被注解方法返回后的一些后续动作。pointcut 定义切点的表达式,可以用通配符 * 表示;returning 指定返回的参数名。然后就可以对返回的结果进行处理。这样就可以达到动态的修改原始函数功能。

当然除了 AspectJ 也可以使用 CGLib 来代理来实现简单的 AOP。

public class FooService {
    public Page findByPage(){
        return new Page();
    }
    public Page findPage(){
        return new Page();
    }
}
@Data
public class Page {
    private String sql = "";
    private List<Object> content = new ArrayList();
    private Integer size = 0;
    private Integer page = 0;
    private Integer total = 0;
}

创建一个对象 FooService 用来模拟查询分页方法。

public class CGLibProxyFactory implements MethodInterceptor {

    private Object object;

    public CGLibProxyFactory(Object object){
        this.object = object;
    }

    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("before method! do something...");

        Object result = methodProxy.invoke(object, objects);
        //进行方法判断,是否需要处理
        if (method.getName().contains("ByPage")) {
            if (result instanceof Page) {
                System.out.println("after method! do something...");
                ((Page) result).setTotal(100);
            }
        }
        return result;
    }
}

创建一个代理类实现 MethodInterceptor 接口,手动调用 invoke 方法,用来动态的修改被代理的实现方法。可以在执行之前做一些参数校验,或者一些参数的预处理。也可以获取修改执行的结果,或者干脆不调用 invoke 方法,自定义实现。也可以在调用后做一些后续动作。

public class ObjectFactoryUtils {
    public static <T> Optional<T> getProxyObject(Class<T> clazz) {
        try {
            T obj = clazz.newInstance();
            CGLibProxyFactory factory = new CGLibProxyFactory(obj);
            Enhancer enhancer=new Enhancer();//利用`Enhancer`来创建被代理类的代理实例
            enhancer.setSuperclass(clazz);//设置目标class
            enhancer.setCallback(factory);//设置回调代理类
            return Optional.of((T)enhancer.create());
        } catch (InstantiationException | IllegalAccessException e) {
            e.printStackTrace();
        }
        return Optional.empty();
    }
}

public static void main(String[] args) {
        Optional<FooService> proxyObject = ObjectFactoryUtils.getProxyObject(FooService.class);
        if(proxyObject.isPresent()) {
            FooService foo = proxyObject.get();
            System.out.println("findByPage:");
            System.out.println(foo.findByPage().getTotal());
            System.out.println("findPage:");
            System.out.println(foo.findPage().getTotal());
        }
}

最后打印的输出是:

findByPage:
before method! do something...
after method! do something...
100
findPage:
before method! do something...
0

当然除了 CGLIB 代理也可以使用 Proxy 动态代理,同样的逻辑也可以达到动态的修改原始方法的目的,从而间接的实现函数扩展。不过 Proxy 动态代理是基于接口的代理。

其它语言的函数扩展

其实除了 Scala 的隐式转换和 Java 的动态代理,其他很多语言也能支持各种不同的函数扩展。

Swift

在 Swift 中可以通过关键词 extension 对已有的类进行扩展,可以扩展方法、属性、下标、构造器等等。

extension Int {
    func times(task: () -> Void) {
        for _ in 0..<self {
            task()
        } 
    }
}

比如说我给 Int 增加一个 times 方法。即执行任务的次数。就可以如下使用:

2.times({
    print("Hello!")
})

上面的代码会执行 2 次打印方法。

Go

在 Go 中可以通过在方法名前面加上一个变量,这个附加的参数会将该函数附加到这种类型上。即给一个方法加上接收器。

func (s string) toUpper() string {
    return strings.ToUpper(s)
}

"aaaaa".toUpper //输出 AAAAA

Kotlin

Kotlin 的函数扩展非常简单,就是定义的时候,函数名写成 接收器 + . + 方法名 就行了。

class C {

}
fun C.foo() { println("extension") }

C().foo() //输出extension

注意当给一个类扩展已有的方法的时候,默认使用的是类自带的成员函数。如下:

class C {
    fun foo() { println("member") }
}

fun C.foo() { println("extension") }

C().foo() //输出member

可以通过函数重载的方式区分成员函数(fun C.foo(i:Int) { println("extension") }),在调用的地方显示的区分。

JavaScript

在 JavaScript 中也可以很方便的给一个对象扩展函数。写法就是 对象 + . + 函数名

var date = new Date();
date.format = function() {
    return this.toISOString().slice(0, 10);
}
date.format(); //"2017-11-29"

也可以给一个 Object 进行扩展:

Date.prototype.format = function() {
     return this.toISOString().slice(0, 10);
}
new Date().format(); //"2017-11-29"

总结

其实了解不同语言对于函数扩展的实现挺有意思的,本文只是粗略的介绍了一下。合理的使用这些语言的扩展,可以帮助我们提高代码质量和工作效率。我们还可以通过函数扩展来对第三方类库进行修改或者扩展,从而更灵活的调用第三方类库。

0

Linux 的 IO 通信 以及 Reactor 线程模型浅析

目录

随着计算机硬件性能不断提高,服务器 CPU 的核数越来越越多,为了充分利用多核 CPU 的处理能力,提升系统的处理效率和并发性能,多线程并发编程越来越显得重要。无论是 C++ 还是 Java 编写的网络框架,大多数都是基于 Reactor 模式进行设计和开发,Reactor 模式基于事件驱动,特别适合处理海量的 I/O 事件,今天我们就简单聊聊 Reactor 线程模型,主要内容分为以下几个部分:

  • 经典的 I/O 通信模型;
  • Reactor 线程模型详述;
  • Reactor 线程模型几种模式;
  • Netty Reactor 线程模型的实践;

IO 通信模型

我们先要来谈谈 I/O 通信。说到 I/O 通信,往往会提到同步(synchronous)I/O 、异步(asynchronous)I/O、阻塞(blocking)I/O 和非阻塞(non-blocking)I/O 四种。有关同步、异步、阻塞和非阻塞的区别很多时候解释不清楚,不同的人知识背景不同,对概念很难达成共识。本文讨论的背景是 Linux 环境下的 Network I/O。

一次 I/O 过程分析

对于一次 Network I/O (以 read 举例),它会涉及到两个系统对象,一个是调用这个 I/O 的进程或线程,另一个就是系统内核 (kernel)。当一个 read 操作发生时,会经历两个阶段(记住这两个阶段很重要,因为不同 I/O 模型的区别就是在两个阶段上各有不同的处理):

  • 第一个阶段:等待数据准备 (Waiting for the data to be ready);
  • 第二个阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process);

五种 I/O 模型

Richard Stevens 的《UNIX® Network Programming Volume》提到了 5 种 I/O 模型:

  1. Blocking I/O (同步阻塞 I/O)
  2. Nonblocking I/O(同步非阻塞 I/O)
  3. I/O multiplexing(多路复用 I/O)
  4. Signal driven I/O(信号驱动 I/O,实际很少用,Java 不支持)
  5. Asynchronous I/O (异步 I/O)

接下来我们对这 5 种 I/O 模型进行说明和对比。

Blocking I/O

在 Linux 中,默认情况下所有的 Socket 都是 blocking 的,也就是阻塞的。一个典型的读操作时,流程如图:

当用户进程调用了 recvfrom 这个系统调用, 这次 I/O 调用经历如下 2 个阶段:
1. 准备数据: 对于网络请求来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP 包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。
2. 数据返回:kernel 一但等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

Nonblocking IO

Linux 下,可以通过设置 socket 使其变为 non-blocking,也就是非阻塞。当对一个 non-blocking socket 执行读操作时,流程如图:

当用户进程发出 read 操作具体过程分为如下 3 个过程:
1. 开始准备数据:如果 Kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。
2. 数据准备中: 从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作(重复轮训)。
2. 一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

I/O multiplexing

这种 I/O 方式也可称为 event driven I/O。Linux select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 I/O。它的基本原理就是 select/epoll 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。流程如图:

当用户进程调用了 select:
1. 整个进程会被 block,与此同时kernel 会 “监视” 所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。
2. 户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。这时和 blocking I/O 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking I/O 只调用了一个 system call (recvfrom)。
3. 在 I/O multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket I/O 给 block。

Asynchronous IO

Linux 下的 asynchronous I/O,即异步 I/O,其实用得很少(需要高版本系统支持)。它的流程如图:

当用户进程发出 read 操作具体过程:
1. 用户进程发起 read 操作之后,并不需要等待,而是马上就得到了一个结果,立刻就可以开始去做其它的事。
2. 从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个 signal,告诉它 read 操作完成了。


通过以上 4 种 I/O 通信模型的说明,总结一下它们各自的特点:

  • Blocking I/O 的特点就是在 I/O 执行的两个阶段都被 block 了。
  • Non-blocking I/O 特点是如果 kernel 数据没准备好不需要阻塞。
  • I/O multiplexing 的优势在于它用 select 可以同时处理多个 connection。(如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
  • Asynchronous IO 的特点在于整个调用过程客户端没有任何 block 状态,但是需要高版本的系统支持。

生活中通信模型

以上五种 I/0 模型的介绍,比如枯燥,其实在生活中也存在类似的 “通信模型”,为了帮助理解,我们用生活中约妹纸吃饭这个不是很恰当的例子来说明这几个 I/O Model(假设我现在要用微信叫几个妹纸吃饭):

  • 发个微信问第一个妹纸好了没,妹子没回复就一直等,直到回复在发第二个 (blocking I/O)。
  • 发个微信问第一个妹纸好了没,妹子没回复先不管,发给第二个,但是过会要继续问之前 没有回复的妹纸有没有好(nonblocking I/O)。
  • 将所有妹纸拉一个微信群,过会在群里问一次,谁好了回复一下(I/O multiplexing)。
  • 直接告诉妹纸吃饭的时间地址,好了自己去就行(Asynchronous I/O)。

Reactor 线程模型

Reactor 是什么?

Reactor 是一种处理模式。 Reactor 模式是处理并发 I/O 比较常见的一种模式,用于同步 I/O,中心思想是将所有要处理的IO事件注册到一个中心 I/O 多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有 I/O 事件到来或是准备就绪(文件描述符或 socket 可读、写),多路复用器返回并将事先注册的相应 I/O 事件分发到对应的处理器中。

Reactor 也是一种实现机制。 Reactor 利用事件驱动机制实现,和普通函数调用的不同之处在于:应用程序不是主动的调用某个 API 完成处理,而是恰恰相反,Reactor 逆置了事件处理流程,应用程序需要提供相应的接口并注册到 Reactor 上,如果相应的事件发生,Reactor 将主动调用应用程序注册的接口,这些接口又称为 “回调函数”。用 “好莱坞原则” 来形容 Reactor 再合适不过了:不要打电话给我们,我们会打电话通知你。

为什么要使用 Reactor?

一般来说通过 I/O 复用,epoll 模式已经可以使服务器并发几十万连接的同时,维持极高 TPS,为什么还需要 Reactor 模式?原因是原生的 I/O 复用编程复杂性比较高。

一个个网络请求可能涉及到多个 I/O 请求,相比传统的单线程完整处理请求生命期的方法,I/O 复用在人的大脑思维中并不自然,因为,程序员编程中,处理请求 A 的时候,假定 A 请求必须经过多个 I/O 操作 A1-An(两次 IO 间可能间隔很长时间),每经过一次 I/O 操作,再调用 I/O 复用时,I/O 复用的调用返回里,非常可能不再有 A,而是返回了请求 B。即请求 A 会经常被请求 B 打断,处理请求 B 时,又被 C 打断。这种思维下,编程容易出错。

Reactor 线程模型

Reactor 有三种线程模型,用户能够更加自己的环境选择适当的模型。

  1. 单线程模型
  2. 多线程模型(单 Reactor)
  3. 多线程模型(多 Reactor)

单线程模式

单线程模式是最简单的 Reactor 模型。Reactor 线程是个多面手,负责多路分离套接字,Accept 新连接,并分派请求到处理器链中。该模型适用于处理器链中业务处理组件能快速完成的场景。不过这种单线程模型不能充分利用多核资源,所以实际使用的不多。

多线程模式(单 Reactor)

该模型在事件处理器(Handler)链部分采用了多线程(线程池),也是后端程序常用的模型。

多线程模式(多 Reactor)

比起多线程单 Rector 模型,它是将 Reactor 分成两部分,mainReactor 负责监听并 Accept新连接,然后将建立的 socket 通过多路复用器(Acceptor)分派给subReactor。subReactor 负责多路分离已连接的 socket,读写网络数据;业务处理功能,其交给 worker 线程池完成。通常,subReactor 个数上可与 CPU 个数等同。

Reactor 使用

软件领域很多开源的产品使用了 Ractor 模型,比如 Netty。

Netty Reactor 实践

服务端线程模型

服务端监听线程和 I/O 线程分离,类似于 Reactor 的多线程模型,它的工作原理图如下:

服务端用户线程创建

  • 创建服务端的时候实例化了 2 个 EventLoopGroup。bossGroup 线程组实际就是 Acceptor 线程池,负责处理客户端的 TCP 连接请求。workerGroup 是真正负责 I/O 读写操作的线程组。通过这里能够知道 Netty 是多 Reactor 模型。
  • ServerBootstrap 类是 Netty 用于启动 NIO 的辅助类,能够方便开发。通过 group 方法将线程组传递到 ServerBootstrap 中,设置 Channel 为 NioServerSocketChannel,接着设置 NioServerSocketChannel 的 TCP 参数,最后绑定 I/O 事件处理类 ChildChannelHandler。
  • 辅助类完成配置之后调用 bind 方法绑定监听端口,Netty 返回 ChannelFuture,
    f.channel().closeFuture().sync() 对同步阻塞的获取结果。
  • 调用线程组 shutdownGracefully 优雅推出,释放资源。
public class TimeServer {
    public void bind(int port) {
        // 配置服务端的NIO线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workGroup).channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childHandler(new ChildChannelHandler());
            // 绑定端口,同步等待成功
            ChannelFuture f = b.bind(port).sync();
            // 等待服务端监听端口关闭
            f.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放线程池资源
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }

    private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
        @Override
        protected void initChannel(SocketChannel ch) throws Exception {
            ch.pipeline().addLast(new TimeServerHandler());
        }
    }

服务端 I/O 线程处理(TimeServerHandler)

  • exceptionCaught 方法: 当 I/O 处理发生异常时被调用,关闭 ChannelHandlerContext,释放资源。
  • channelRead 方法: 是真正处理读写数据的方法,通过 buf.readBytes 读取请求数据。通过 ctx.write(resp) 将相应报文发送给客户端。
  • channelReadComplete 方法: 为了提高性能,Netty write 是将数据先写到缓冲数组,通过 flush 方法可以将缓冲数组的所有消息发送到 SocketChannel 中。
public class TimeServerHandler extends ChannelHandlerAdapter {

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
            throws Exception {
        ctx.close();
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception {
        // msg转Buf
        ByteBuf buf = (ByteBuf) msg;
        // 创建缓冲中字节数的字节数组
        byte[] req = new byte[buf.readableBytes()];
        // 写入数组
        buf.readBytes(req);
        String body = new String(req, "UTF-8");
        String currenTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(
                System.currentTimeMillis()).toString() : "BAD ORDER";
        // 将要返回的信息写入Buffer
        ByteBuf resp = Unpooled.copiedBuffer(currenTime.getBytes());
        // buffer写入通道
        ctx.write(resp);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        // write读入缓冲数组后通过invoke flush写入通道
        ctx.flush();
    }
}

总结

通过以上大概了解 Reactor 相关知识。最后做个总结一下使用 Reactor 模型的优缺点。

  • 优点
    • 响应快,虽然 Reactor 本身依然是同步的,不必为单个同步时间所阻塞。
    • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销。
    • 可扩展性,通过并发编程的方式增加 Reactor 个数来充分利用 CPU 资源。
    • 可复用性,Reactor 框架本身与具体事件处理逻辑无关,具有很高的复用性。
  • 缺点
    • 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,调试相对复杂。
    • Reactor 模式需要底层的 Synchronous Event Demultiplexer 支持,例如 Java 中的 Selector,操作系统的 select 系统调用支持。
    • 单线程 Reactor 模式在 I/O 读写数据时还是在同一个线程中实现的,即使使用多 Reactor 机制的情况下,共享一个 Reactor 的 Channel 如果出现一个长时间的数据读写,会影响这个 Reactor 中其他 Channel 的相应时间,比如在大文件传输时,I/O 操作就会影响其他 Client 的相应时间,因而对这种操作,使用传统的 Thread-Per-Connection 或许是一个更好的选择,或则此时使用 Proactor 模式。
0

浅谈 BI 与数据分析的可视化

一、名词解释

估计这个标题对大家来说不是很好理解,在开始正题之前,我们先对标题中的「BI 报表可视化」和「分析报表可视化」做个名词解释。

BI 报表可视化

  • 广义 BI:商务智能的一套整体解决方案,包括数据仓库、报表查询、数据分析、数据挖掘、数据可视化等;
  • 狭义 BI:敏捷 BI 产品,一款可视化产品,如永洪 BI、Tableau 等。

通过敏捷 BI 产品设计制作的,提供给业务部门日常使用的可视化报表,就是今天要说的 BI 报表可视化。

分析报表可视化

对某块业务某些具体现象或问题的洞察,应该就是咱们所说的分析。在业务分析结果报告中使用的可视化图表(请留意是结果报告),就是分析报表可视化。

二、说说共同点

他们的共性主要体现在三个方面。

1.都通过可视化来展示信息

同属于可视化,都是通过图表形式展现数据,帮助用户快速、准确理解信息,向用户揭示数据背后的规律。

2.具备可视化共有的基础特性

  • 准确、快速表达信息;
  • 好看、高大上但不复杂。

3.图表类型通用

可视化的各种图表类型,诸如:柱形图、散点图、瀑布图等,在分析报表中可用,在 BI 报表中同样可用。

下面举三个示例来对「图表类型通用」做个说明

示例1:假设你是某个班的班主任,你想要分析自己班上所有学生的成绩情况。你制作了这张箱线图,分析结果告诉你雷军成绩最好,马云成绩最不佳,李彦宏成绩最稳定,王小川最可能超常发挥。那么,如果是教导主任呢?他如果希望是教务系统中有一个可以直接查看全校学生成绩的页面,那么就可以将这个箱线图制作成一个 BI 视图。

image

示例2:某家公司做了一次运营策略调整,运营重点从获客转向流失用户的唤回。运营总监想知道下面的几位运营经理,是否将这个策略执行到位了。好,如果总监看到的结果不是如图所示的运营策略的调整已经被执行到位,那么总监可能很生气。他完全有理由让你制作一份常规报表放到 BI 系统中去,因为他需要每周、每月查看这个报表来检查各部门是否执行到位。

image

示例3:某家公司每年收入近两千万,Boss 希望你帮他分析 2017 年公司的营收都花在哪些模块了,最终毛利润是多少,那么你帮 Boss 制作了这张图作为分析结果呈现。Boss 看完后,认为分析的非常好,这个模式应该被推广被复用,他说你帮我做到 BI 系统里去,Boss 们每月、每季度都需要看到。

image

以上三个事例中,同一类型图表在分析报表可视化中 BI 报表可视化中都是可用的!

三、谈谈不同之处

「分析报表可视化」和「BI 报表可视化」的关键差异在于它们的出发点是不同的。分析报表可视化的核心是输出结论;BI 报表可视化是某业务主题下灵活的动态分析。

分析报表可视化:核心是结论

每一次的业务分析结论与建议都会被要求是明确的,所以我们会把图表里所有通用的模块都调整为很精细化的,紧紧地围绕本次分析展开。

分析报表的核心点是结论,一切都是围绕结论展开的。分析报表中的可视化图表是报告的组成部分,它必须是围绕分析结论,服务于结论表达的。你的分析结论越明确,越多的通用部分就会被抹去。

BI 报表可视化:业务主题下灵活地动态分析

BI 报表同样具备一定的主题性,但它不是这个主题下某个非常明确的结论,它更多地是一种分析模式,是某个主题下的动态分析。所以 BI 报表需要支持该主题下的各种场景、各种洞察需求。比如我们刚才提到的瀑布图,其中是一种技术分析模式,我们可以在瀑布图的基础上,按业务线、产品线的不同,结合相应的图表,搭建一个完整的仪表盘(Dashboard)。

一般来说,对于分析报表而言,老板看完,就代表这件事结束了;而 BI 则是可以被反复使用的。新上线一个 BI 报表,意味着这个 BI 报表的维护、优化才刚刚开始,需要根据战略目标、业务需求不断地进行调整,所以 BI 报表的核心需求是灵活地支持动态分析。

下面,我们举个简单的例子来说明:

image

假设有家公司的业务是做一个医院挂号的网站。老板提了一个需求,想知道今年患者挂号的科室分布情况,你可能会做左边这样的图,把很多小的科室合并了,因为老板更关心排名在前的科室。这样做可以突出重点。但是如果这个报表是存在于 BI 系统中的,那么用左边这张图就不行了。因为它当某些同事想要知道「眼科」患者挂号情况,那左边图就没有相关信息。

我们的分析报表只要给老板展示 Top N 的重点,而在 BI 报表里面,就需要考虑到信息的全面性,覆盖到不同业务部门各式各样的需求。

四、灵活地支持动态分析

我们将支持动态分析行为分为基础和进阶两个层次。
- 基础的就是维度下钻和上卷,能够进行维度的深入和聚合,以及界面的联动,有时候联动在层次太多的情况下不适用,就需要提供筛选器进行筛选。
- 进阶的是维度切换、子视图展示、视图动作跳转、下载数据格式自定义。

image

接下来重点说明其中的「维度下钻、上卷与切换」、「子视图展示」及「视图动作跳转」。其它的支持动态分析的技巧,如:界面联动、维度筛选器等都是市面上 BI 工具常见的功能,这里就不赘述了。

1.维度下钻、上卷与切换

某家中药销售公司,当前与五家合作方合作。运营人员经常会有需要分析:不同厂家的销售排名、不同剂型的销售排名,不同厂家下的剂型销售排名等。这里我们通过维度下钻、上卷与切换,使之在一个图表中实现。

通过维度下钻,我们在厂家销售排名基础上细分观察剂型销售排名;

通过维度上卷,我们隐藏了剂型信息,只观察厂家销售排名;

通过维度切换,我们实现「剂型-厂家」的细分维度,此时上卷后则就是观察剂型的销售排名。

image

2.子视图展示

下图,展示了某家公司在全美的销售分布。如果此时,我们想要观察每个州的类目成交分布,常规的做法往往是另做一个 BI 视图页,在这个视图中展示详细类目成交。但其实,我们可以将比较简单的分类信息在子视图中展示,当用户悬停在某个州时就显示类目成交。这可以极大的方便用户查阅数据。

image

3.视图动作跳转

子视图只适合信息量较小的附加,如果是大面积的详细信息添加,那么建议使用:视图跳转。下图是,某家公司一级类目、二级类目的销售情况,如果还想看到单个产品的销售利润情况,可以在这里制作一个子视图,提升查看商品详情,就可以跳转到每个产品的销售利润视图。

image

以上,就是个人关于「BI 报表可视化」和「分析报表可视化」异同的看法。欢迎讨论。

0

iOS 照片涂鸦功能的实现

上个版本项目图片编辑需求中有一项涂鸦涂鸦就是选取颜色后根据手指滑动路径在图片上生成对应路径,并最终生成一张新图片。接到项目,Google、Github 搜索一番,看到不少实现思路和 Demo,心想:so easy!然而,具体实现的时候却发现了这些 Demo 存在严重问题...

初版实现

gif_Memory03

实现了我们要的功能,是不是很完美?看效果是 OK,在讨论遇到的问题前,我们先来看看核心代码的实现。

初版核心代码

  1. 开始绘制的时候,获取初始坐标信息写入 CGPath 对象:
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);
}
  1. 随着手指的移动,触发 touchesMoved 方法,添加路径信息到 CGPath 对象,主动调用 setNeedsDisplay 使 view 得以执行 drawRect
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    //移动->重新绘图
    [self setNeedsDisplay];
}
  1. 按步撤销:
(void)revoke
{
    [_pathArray removeLastObject];
    [self setNeedsDisplay];
}
  1. 最后就是绘制核心方法:
(void)drawRect:(CGRect)rect 
{
    for (PathModal *modal in _pathArray)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        [modal.color setStroke];
        CGContextSetLineWidth(context, modal.width);
        CGContextAddPath(context, modal.path);
        CGContextDrawPath(context, kCGPathStroke);
    }

    if (_path != nil)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextAddPath(context, _path);
        [[XRPhotoEditTheme themeManager].color setStroke];
        CGContextSetLineWidth(context, _drawWidth);
        CGContextDrawPath(context, kCGPathStroke);  
    }    
}

性能问题?

上面的代码,虽然实现了需求,但是从代码里也能看到,不断的进行数组操作、调用 drawRect,连撤销操作都不是移除最后一条路径而是先清空页面绘制将余下的路径重新绘制。看到这里,就算对 drawRect 使用场景及注意事项不甚了解你也会疑问:这操作会有性能问题吧?

光靠代码逻辑的复杂程度毙掉此方案,略失严谨。但是通过真机体验可以感受到路径多了后,开始卡顿,然后收到 didReceiveMemoryWarning,之后大概率的会崩溃。通过 XCode 运行信息也可以看到内存的暴涨:

gif_Memory02

为了确认内存暴涨是因为不断的对象初始化操作还是 drawRect 绘制的,首先我们只保留了 setNeedsDisplay 调用和 drawRect 绘制所必需得代码,结果随着不断的绘制内存还是不断升高。

随后只保留手势、对象操作,注释掉绘制逻辑。在注释掉 drawRect 之后内存立刻恢复正常,我们终于抓到了消耗内存的元凶:不断的执行 drawRect 方法。

最终我们将内存暴增的原因找了出来,那为什么不断的调用 drawRect 绘制会收到内存警告而崩溃?有没有更好的解决方案呢?

重写

在 iOS 系统中所有显示的视图都是从基类 UIView 继承而来的,同时 UIView 负责接收用户交互。

但是实际上你所看到的视图内容,包括图形等,都是由 UIView 的一个实例图层属性来绘制和渲染的,那就是 CALayer,可以说
CALayer 是 UIView 的内部实现。而且基于 UIViewdrawRect 绘制方式存在内存暴涨的风险,我们综合研究后认为画板这种需要实时反馈在UI上的绘制需求应直接用专有图层 CAShapeLayer。至于为甚么是 CAShapeLayer,我们稍后讲。

  1. 初始化路径
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);

    CAShapeLayer * slayer = [CAShapeLayer layer];
    slayer.path = _path;
    // 略:设置 slyer 参数
    [self.layer addSublayer:slayer];
    _shapeLayer = slayer;

    [_pathArray addObject:slayer];
   }
  1. 路径绘制
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    //略:point有效值判断

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    _shapeLayer.path = _path;
}
  1. 按步撤销
(void)revoke
{
    [_pathArray.lastObject removeFromSuperlayer];
    [_pathArray removeLastObject];
}
  1. 检验

gif_Memory01

以上代码用 CAShapeLayer 实现了路径的绘制,当然还有优化的空间:比如公用一个 CAShapeLayer 对象来实现;但不是本文的重点。
毕竟现在的代码运行起来发现内存和 CPU 的波动微乎其微,内存消耗也维持在较低的水平。

反思

重写 drawRect 方法为何会导致内存大量上涨?

要弄明白这个问题,我们先来回顾一下 iOS 中图像显示原理:

上文已经讲到 CALayerUIView 的内部实现,在每一个 UIView 实例当中,都有一个默认的图层 CALayerUIView 负责创建并且管理这个图层。而实际上这个 CALayer 图层并不是真正用来在屏幕上显示的。但为什么我们能看到 CALayer 的内容呢?是因为 CALayer 内部有一个 contents 属性。contents 默认可以传一个 id 类型的对象,但是只有你传 CGImage 对象的时候,它才能够正常显示在屏幕上。 所以最终图形渲染落脚点落在了 contents 上面。

contentsdrawRect 又有什么关系呢?

查阅文档发现 drawRect 方法没有默认的实现,因为对 UIView 来说,寄宿图并不是必须的,UIView 不关心绘制的内容。但如果 UIView 检测到 drawRect 方法被调用了,它就会为视图分配一个 contents (寄宿图)。这个寄宿图就是我们问题的关键:寄宿图的像素尺寸等于视图大小乘以 contentsScale (这个属性与屏幕分辨率有关,我们的画板程序在不同模拟器下呈现的内存用量不同也是因为它)的值。

回到我们的涂鸦程序,当画板从屏幕上出现的时候,因为重写了 drawRect 方法,drawRect 方法就会自动调用。 生成一张寄宿图 后,绘制方法里面的代码利用 Core Graphics 去绘制路径,然后内容会缓存起来,等待下次调用 setNeedsDisplay 时再次调用 drawRect 方法更新到寄宿图。

理解到这里,我们可以知道:调用一次 drawRect 方法,图层就创建了一个绘制寄宿图上下文,而这个上下文所需要的内存可从这个公式得出:图层宽*图层高*4 字节,宽高的单位均为像素。那么一张 4000*3001 的高清图创建一个上下文占用多少内存呢:4000*3001*4,相当于 46MB 内存。而涂鸦要求随着手指的移动立马在图层上展示出路径,使用 drawRect 方法重绘则必须不停的去 setNeedsDisplay 从而频繁触发 drawRect不断的创建寄宿图。

这就是我们画板程序内存暴增的真正原因

为什么是 CAShapeLayer

CAShapeLayer 是一个通过矢量图形而不是 bitmap 来绘制的图层子类。用 CGPath 来定义想要绘制的图形,CAShapeLayer 会自动渲染,而且它采用了硬件加速处理速度非常快而且内存占用更低。它可以完美替代我们的直接使用 Core Graphics 绘制 layer

总结

最终我们决定放弃 drawRect 而选用图层 CAShapeLayerCAShapeLayer 不仅在功能上满足了我们的需求,对比之下 CAShapeLayer 在性能方面表现也非常出色。

另外,因为模拟器内存是分配的 Mac 系统的内存可用内存远高于真机,这种性能问题很难在使用模拟器的开发阶段被关注到,所以开发过程中就尽量使用真机来测试吧。

参考

http://www.cnblogs.com/yaokang/archive/2012/06/02/2532257.html
https://www.cnblogs.com/chrisbin/p/6391933.html?utm_source=itdadao&utm_medium=referral

0