开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

浅谈移动端图片压缩(iOS & Android)

发表于 2019-02-07

在 App 中,如果分享、发布、上传功能涉及到图片,必不可少会对图片进行一定程度的压缩。笔者最近在公司项目中恰好重构了双端(iOS&Android)的图片压缩模块。本文会非常基础的讲解一些图片压缩的方式和思路。

图片格式基础

点阵图&矢量图

  • 点阵图:也叫位图。用像素为单位,像素保存颜色信息,排列像素实现显示。
  • 矢量图:记录元素形状和颜色的算法,显示时展示算法运算的结果。

颜色

表示颜色时,有两种形式,一种为索引色(Index Color),一种为直接色(Direct Color)

  • 索引色:用一个数字索引代表一种颜色,在图像信息中存储数字到颜色的映射关系表(调色盘 Palette)。每个像素保存该像素颜色对应的数字索引。一般调色盘只能存储有限种类的颜色,通常为 256 种。所以每个像素的数字占用 1 字节(8 bit)大小。
  • 直接色:用四个数字来代表一种颜色,数字分别对应颜色中红色,绿色,蓝色,透明度(RGBA)。每个像素保存这四个纬度的信息来代表该像素的颜色。根据色彩深度(每个像素存储颜色信息的 bit 数不同),最多可以支持的颜色种类也不同,常见的有 8 位(R3+G3+B2)、16 位(R5+G6+B5)、24 位(R8+G8+B8)、32 位(A8+R8+G8+B8)。所以每个像素占用 1~4 字节大小。

移动端常用图片格式

图片格式中一般分为静态图和动态图

静态图
  • JPG:是支持 JPEG( 一种有损压缩方法)标准中最常用的图片格式。采用点阵图。常见的是使用 24 位的颜色深度的直接色(不支持透明)。
  • PNG:是支持无损压缩的图片格式。采用点阵图。PNG 有 5 种颜色选项:索引色、灰度、灰度透明、真彩色(24 位直接色)、真彩色透明(32 位直接色)。
  • WebP:是同时支持有损压缩和无所压缩的的图片格式。采用点阵图。支持 32 位直接色。移动端支持情况如下:
系统 原生 WebView 浏览器
iOS 第三方库支持 不支持 不支持
Android 4.3 后支持完整功能 支持 支持
动态图
  • GIF:是支持无损压缩的图片格式。采用点阵图。使用索引色,并有 1 位透明度通道(透明与否)。
  • APNG:基于 PNG 格式扩展的格式,加入动态图支持。采用点阵图。使用 32 位直接色。但没有被官方 PNG 接纳。移动端支持情况如下:
系统 原生 WebView 浏览器
iOS 支持 支持 支持
Android 第三方库支持 不支持 不支持
  • Animated Webp:Webp 的动图形式,实际上是文件中打包了多个单帧 Webp,在 libwebp 0.4 后开始支持。移动端支持情况如下:
系统 原生 WebView 系统浏览器
iOS 第三方库支持 不支持 不支持
Android 第三方库支持 不支持 不支持

而由于一般项目需要兼容三端(iOS、Android、Web 的关系),最简单就是支持 JPG、PNG、GIF 这三种通用的格式。所以本文暂不讨论其余图片格式的压缩。

移动端系统图片处理架构

根据我的了解,画了一下 iOS&Android 图片处理架构。iOS 这边,也是可以直接调用底层一点的框架的。

屏幕快照 2019-01-13 下午9.37.00

iOS 的 ImageIO

本文 iOS 端处理图片主要用 ImageIO 框架,使用的原因主要是静态图动态图 API 调用保持一致,且不会因为 UIImage 转换时会丢失一部分数据的信息。

ImageIO 主要提供了图片编解码功能,封装了一套 C 语言接口。在 Swift 中不需要对 C 对象进行内存管理,会比 Objective-C 中使用方便不少,但 api 结果返回都是 Optional(实际上非空),需要用 guard/if,或者 !进行转换。

解码

1. 创建 CGImageSource

CGImageSource 相当于 ImageIO 数据来源的抽象类。通用的使用方式 CGImageSourceCreateWithDataProvider: 需要提供一个 DataProvider,可以指定文件、URL、Data 等输入。也有通过传入 CFData 来进行创建的便捷方法 CGImageSourceCreateWithData:。方法的第二个参数 options 传入一个字典进行配置。根据 Apple 在 WWDC 2018 上的 Image and Graphics Best Practices 上的例子,当不需要解码仅需要创建 CGImageSource 的时候,应该将 kCGImageSourceShouldCache 设为 false。

11994763-6f25c32bd4d3b427

2. 解码得到 CGImage

用 CGImageSourceCreateImageAtIndex: 或者 CGImageSourceCreateThumbnailAtIndex: 来获取生成的 CGImage,这里参数的 Index 就是第几帧图片,静态图传入 0 即可。

编码

1. 创建 CGImageDestination

CGImageDestination 相当于 ImageIO 数据输出的抽象类。通用的使用方式 CGImageDestinationCreateWithDataConsumer: 需要提供一个 DataConsumer,可以置顶 URL、Data 等输入。也有通过传入 CFData 来进行创建的便捷方法 CGImageDestinationCreateWithData:,输出会写入到传入的 Data 中。方法还需要提供图片类型,图片帧数。

2. 添加 CGImage

添加 CGImage 使用 CGImageDestinationAddImage: 方法,动图的话,按顺序多次调用就行了。

而且还有一个特别的 CGImageDestinationAddImageFromSource: 方法,添加的其实是一个 CGImageSource,有什么用呢,通过 options 参数,达到改变图像设置的作用。比如改变 JPG 的压缩参数,用上这个功能后,就不需要转换成更顶层的对象(比如 UIImage),减少了转换时的编解码的损耗,达到性能更优的目的。

3. 进行编码

调用 CGImageDestinationFinalize: ,表示开始编码,完成后会返回一个 Bool 值,并将数据写入 CGImageDestination 提供的 DataConsumer 中。

压缩思路分析

位图占用的空间大小,其实就是像素数量x单像素占用空间x帧数。所以减小图片空间大小,其实就从这三个方向下手。其中单像素占用空间,在直接色的情况下,主要和色彩深度相关。在实际项目中,改变色彩深度会导致图片颜色和原图没有保持完全一致,笔者并不建议对色彩深度进行更改。而像素数量就是平时非常常用的图片分辨率缩放。除此之外,JPG 格式还有特有的通过指定压缩系数来进行有损压缩。

  • JPG:压缩系数 + 分辨率缩放 + 色彩深度降低
  • PNG: 分辨率缩放 + 降低色彩深度
  • GIF:减少帧数 + 每帧分辨率缩放 + 减小调色盘

判断图片格式

后缀扩展名来判断其实并不保险,真实的判断方式应该是通过文件头里的信息进行判断。

JPG PNG GIF
开头:FF D8 + 结尾:FF D9 89 50 4E 47 0D 0A 1A 0A 47 49 46 38 39/37 61

简单判断用前三个字节来判断

iOS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码extension Data{   
enum ImageFormat {
case jpg, png, gif, unknown
}

var imageFormat:ImageFormat {
var headerData = [UInt8](repeating: 0, count: 3)
self.copyBytes(to: &headerData, from:(0..<3))
let hexString = headerData.reduce("") { $0 + String(($1&0xFF), radix:16) }.uppercased()
var imageFormat = ImageFormat.unknown
switch hexString {
case "FFD8FF": imageFormat = .jpg
case "89504E": imageFormat = .png
case "474946": imageFormat = .gif
default:break
}
return imageFormat
}
}

iOS 中除了可以用文件头信息以外,还可以将 Data 转成 CGImageSource,然后用 CGImageSourceGetType 这个 API,这样会获取到 ImageIO 框架支持的图片格式的的 UTI 标识的字符串。对应的标识符常量定义在 MobileCoreServices 框架下的 UTCoreTypes 中。

字符串常量 UTI 格式(字符串原始值)
kUTTypePNG public.png
kUTTypeJPEG public.jpeg
kUTTypeGIF com.compuserve.gif
Andorid
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码enum class ImageFormat{
JPG, PNG, GIF, UNKNOWN
}

fun ByteArray.imageFormat(): ImageFormat {
val headerData = this.slice(0..2)
val hexString = headerData.fold(StringBuilder("")) { result, byte -> result.append( (byte.toInt() and 0xFF).toString(16) ) }.toString().toUpperCase()
var imageFormat = ImageFormat.UNKNOWN
when (hexString) {
"FFD8FF" -> {
imageFormat = ImageFormat.JPG
}
"89504E" -> {
imageFormat = ImageFormat.PNG
}
"474946" -> {
imageFormat = ImageFormat.GIF
}
}
return imageFormat
}

色彩深度改变

实际上,减少深度一般也就是从 32 位减少至 16 位,但颜色的改变并一定能让产品、用户、设计接受,所以笔者在压缩过程并没有实际使用改变色彩深度的方法,仅仅研究了做法。

iOS

在 iOS 中,改变色彩深度,原生的 CGImage 库中,没有简单的方法。需要自己设置参数,重新生成 CGImage。

1
复制代码public init?(width: Int, height: Int, bitsPerComponent: Int, bitsPerPixel: Int, bytesPerRow: Int, space: CGColorSpace, bitmapInfo: CGBitmapInfo, provider: CGDataProvider, decode: UnsafePointer<CGFloat>?, shouldInterpolate: Bool, intent: CGColorRenderingIntent)
  • bitsPerComponent 每个通道占用位数
  • bitsPerPixel 每个像素占用位数,相当于所有通道加起来的位数,也就是色彩深度
  • bytesPerRow 传入 0 即可,系统会自动计算
  • space 色彩空间
  • bitmapInfo 这个是一个很重要的东西,其中常用的信息有 CGImageAlphaInfo,代表是否有透明通道,透明通道在前还是后面(ARGB 还是 RGBA),是否有浮点数(floatComponents),CGImageByteOrderInfo,代表字节顺序,采用大端还是小端,以及数据单位宽度,iOS 一般采用 32 位小端模式,一般用 orderDefault 就好。

那么对于常用的色彩深度,就可以用这些参数的组合来完成。同时笔者在查看更底层的 vImage 框架的 vImage_CGImageFormat 结构体时(CGImage 底层也是使用 vImage,具体可查看 Accelerate 框架 vImage 库的 vImage_Utilities 文件),发现了 Apple 的注释,里面也包含了常用的色彩深度用的参数。

屏幕快照 2019-01-15 下午9.16.40

这一块为了和 Android 保持一致,笔者封装了 Android 常用的色彩深度参数对应的枚举值。

1
2
3
4
5
6
7
复制代码public enum ColorConfig{
case alpha8
case rgb565
case argb8888
case rgbaF16
case unknown // 其余色彩配置
}

CGBitmapInfo 由于是 Optional Set,可以封装用到的属性的便捷方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码extension CGBitmapInfo {
init(_ alphaInfo:CGImageAlphaInfo, _ isFloatComponents:Bool = false) {
var array = [
CGBitmapInfo(rawValue: alphaInfo.rawValue),
CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue)
]

if isFloatComponents {
array.append(.floatComponents)
}

self.init(array)
}
}

那么 ColorConfig 对应的 CGImage 参数也可以对应起来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码extension ColorConfig{
struct CGImageConfig{
let bitsPerComponent:Int
let bitsPerPixel:Int
let bitmapInfo: CGBitmapInfo
}

var imageConfig:CGImageConfig?{
switch self {
case .alpha8:
return CGImageConfig(bitsPerComponent: 8, bitsPerPixel: 8, bitmapInfo: CGBitmapInfo(.alphaOnly))
case .rgb565:
return CGImageConfig(bitsPerComponent: 5, bitsPerPixel: 16, bitmapInfo: CGBitmapInfo(.noneSkipFirst))
case .argb8888:
return CGImageConfig(bitsPerComponent: 8, bitsPerPixel: 32, bitmapInfo: CGBitmapInfo(.premultipliedFirst))
case .rgbaF16:
return CGImageConfig(bitsPerComponent: 16, bitsPerPixel: 64, bitmapInfo: CGBitmapInfo(.premultipliedLast, true))
case .unknown:
return nil
}
}
}

反过来,判断 CGImage 的 ColorConfig 的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
复制代码extension CGImage{
var colorConfig:ColorConfig{
if isColorConfig(.alpha8) {
return .alpha8
} else if isColorConfig(.rgb565) {
return .rgb565
} else if isColorConfig(.argb8888) {
return .argb8888
} else if isColorConfig(.rgbaF16) {
return .rgbaF16
} else {
return .unknown
}
}

func isColorConfig(_ colorConfig:ColorConfig) -> Bool{
guard let imageConfig = colorConfig.imageConfig else {
return false
}

if bitsPerComponent == imageConfig.bitsPerComponent &&
bitsPerPixel == imageConfig.bitsPerPixel &&
imageConfig.bitmapInfo.contains(CGBitmapInfo(alphaInfo)) &&
imageConfig.bitmapInfo.contains(.floatComponents) {
return true
} else {
return false
}
}
}

对外封装的 Api,也就是直接介绍的 ImageIO 的使用步骤,只是参数不一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
复制代码    /// 改变图片到指定的色彩配置
///
/// - Parameters:
/// - rawData: 原始图片数据
/// - config: 色彩配置
/// - Returns: 处理后数据
public static func changeColorWithImageData(_ rawData:Data, config:ColorConfig) -> Data?{
guard let imageConfig = config.imageConfig else {
return rawData
}

guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource),
let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil),
let rawDataProvider = CGDataProvider(data: rawData as CFData),
let imageFrame = CGImage(width: Int(rawData.imageSize.width),
height: Int(rawData.imageSize.height),
bitsPerComponent: imageConfig.bitsPerComponent,
bitsPerPixel: imageConfig.bitsPerPixel,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: imageConfig.bitmapInfo,
provider: rawDataProvider,
decode: nil,
shouldInterpolate: true,
intent: .defaultIntent) else {
return nil
}
CGImageDestinationAddImage(imageDestination, imageFrame, nil)
guard CGImageDestinationFinalize(imageDestination) else {
return nil
}
return writeData as Data
}


/// 获取图片的色彩配置
///
/// - Parameter rawData: 原始图片数据
/// - Returns: 色彩配置
public static func getColorConfigWithImageData(_ rawData:Data) -> ColorConfig{
guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let imageFrame = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
return .unknown
}
return imageFrame.colorConfig
}
Android

对于 Android 来说,其原生的 Bitmap 库有相当方便的转换色彩深度的方法,只需要传入 Config 就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public Bitmap copy(Config config, boolean isMutable) {
checkRecycled("Can't copy a recycled bitmap");
if (config == Config.HARDWARE && isMutable) {
throw new IllegalArgumentException("Hardware bitmaps are always immutable");
}
noteHardwareBitmapSlowCall();
Bitmap b = nativeCopy(mNativePtr, config.nativeInt, isMutable);
if (b != null) {
b.setPremultiplied(mRequestPremultiplied);
b.mDensity = mDensity;
}
return b;
}

iOS 的 CGImage 参数和 Android 的 Bitmap.Config 以及色彩深度对应关系如下表:

色彩深度 iOS Android
8 位灰度(只有透明度) bitsPerComponent: 8 bitsPerPixel: 8 bitmapInfo: CGImageAlphaInfo.alphaOnly Bitmap.Config.ALPHA_8
16 位色(R5+G6+R5) bitsPerComponent: 5 bitsPerPixel: 16 bitmapInfo: CGImageAlphaInfo.noneSkipFirst Bitmap.Config.RGB_565
32 位色(A8+R8+G8+B8) bitsPerComponent: 8 bitsPerPixel: 32 bitmapInfo: CGImageAlphaInfo.premultipliedFirst Bitmap.Config.ARGB_8888
64 位色(R16+G16+B16+A16 但使用半精度减少一半储存空间)用于宽色域或HDR bitsPerComponent: 16 bitsPerPixel: 64 bitmapInfo: CGImageAlphaInfo.premultipliedLast + .floatComponents Bitmap.Config.RGBA_F16

JPG 的压缩系数改变

JPG 的压缩算法相当复杂,以至于主流使用均是用 libjpeg 这个广泛的库进行编解码(在 Android 7.0 上开始使用性能更好的 libjpeg-turbo,iOS 则是用 Apple 自己开发未开源的 AppleJPEG)。而在 iOS 和 Android 上,都有 Api 输入压缩系数,来压缩 JPG。但具体压缩系数如何影响压缩大小,笔者并未深究。这里只能简单给出使用方法。

iOS

iOS 里面压缩系数为 0-1 之间的数值,据说 iOS 相册中采用的压缩系数是 0.9。同时,png 不支持有损压缩,所以 kCGImageDestinationLossyCompressionQuality 这个参数是无效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码static func compressImageData(_ rawData:Data, compression:Double) -> Data?{
guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource),
let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil) else {
return nil
}

let frameProperties = [kCGImageDestinationLossyCompressionQuality: compression] as CFDictionary
CGImageDestinationAddImageFromSource(imageDestination, imageSource, 0, frameProperties)
guard CGImageDestinationFinalize(imageDestination) else {
return nil
}
return writeData as Data
}
Andoid

Andoird 用 Bitmap 自带的接口,并输出到流中。压缩系数是 0-100 之间的数值。这里的参数虽然可以填 Bitmap.CompressFormat.PNG,但当然也是无效的。

1
2
3
4
复制代码val outputStream = ByteArrayOutputStream()
val image = BitmapFactory.decodeByteArray(rawData,0,rawData.count())
image.compress(Bitmap.CompressFormat.JPEG, compression, outputStream)
resultData = outputStream.toByteArray()

GIF 的压缩

GIF 压缩上有很多种思路。参考开源项目 gifsicle 和 ImageMagick 中的做法,大概有以下几种。

  1. 由于 GIF 支持全局调色盘和局部调色盘,在没有局部调色盘的时候会用放在文件头中的全局调色盘。所以对于颜色变化不大的 GIF,可以将颜色放入全局调色盘中,去除局部调色盘。
  2. 对于颜色较少的 GIF,将调色盘大小减少,比如从 256 种减少到 128 种等。

1490353055438_2367_1490353055781

1490353098026_7360_1490353098210
3. 对于背景一致,画面中有一部分元素在变化的 GIF,可以将多个元素和背景分开存储,然后加上如何还原的信息

b522ac7896b320b4a9ee1eed1034e4fe_articlex

9e9fe93459fe7117909eb27771bdc182_articlex

433b41c29c6a70e64631a3d4c363e468_articlex
4. 对于背景一致,画面中有一部分元素在动的 GIF,可以和前面一帧比较,将不动的部分透明化

d3c7444d59eed11d98abbb7c4e1da7ec_articlex

e50b7f75feebb9bd056bb8dca9964873_articlex

704d70c65d22fb240cb5f6f7be5bbf86_articlex
5. 对于帧数很多的 GIF,可以抽取中间部分的帧,减少帧数
6. 对于每帧分辨率很高的 GIF,将每帧的分辨率减小

对于动画的 GIF,3、4 是很实用的,因为背景一般是不变的,但对于拍摄的视频转成的 GIF,就没那么实用了,因为存在轻微抖动,很难做到背景不变。但在移动端,除非将 ImageMagick 或者 gifsicle 移植到 iOS&Android 上,要实现前面 4 个方法是比较困难的。笔者这里只实现了抽帧,和每帧分辨率压缩。

至于抽帧的间隔,参考了文章中的数值。

帧数 每 x 帧使用 1 帧
<9 x = 2
9 - 20 x = 3
21 - 30 x = 4
31 - 40 x = 5
>40 x = 6

这里还有一个问题,抽帧的时候,原来的帧可能使用了 3、4 的方法进行压缩过,但还原的时候需要还原成完整的图像帧,再重新编码时,就没有办法再用 3、4 进行优化了。虽然帧减少了,但实际上会将帧还原成未做 3、4 优化的状态,一增一减,压缩的效果就没那么好了(所以这种压缩还是尽量在服务器做)。抽帧后记得将中间被抽取的帧的时间累加在剩下的帧的时间上,不然帧速度就变快了,而且不要用抽取数x帧时间偷懒来计算,因为不一定所有帧的时间是一样的。

iOS

iOS 上的实现比较简单,用 ImageIO 的函数即可实现,性能也比较好。

先定义从 ImageSource 获取每帧的时间的便捷扩展方法,帧时长会存在 kCGImagePropertyGIFUnclampedDelayTime 或者 kCGImagePropertyGIFDelayTime 中,两个 key 不同之处在于后者有最小值的限制,正确的获取方法参考苹果在 WebKit 中的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码extension CGImageSource {
func frameDurationAtIndex(_ index: Int) -> Double{
var frameDuration = Double(0.1)
guard let frameProperties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) as? [AnyHashable:Any], let gifProperties = frameProperties[kCGImagePropertyGIFDictionary] as? [AnyHashable:Any] else {
return frameDuration
}

if let unclampedDuration = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? NSNumber {
frameDuration = unclampedDuration.doubleValue
} else {
if let clampedDuration = gifProperties[kCGImagePropertyGIFDelayTime] as? NSNumber {
frameDuration = clampedDuration.doubleValue
}
}

if frameDuration < 0.011 {
frameDuration = 0.1
}

return frameDuration
}

var frameDurations:[Double]{
let frameCount = CGImageSourceGetCount(self)
return (0..<frameCount).map{ self.frameDurationAtIndex($0) }
}
}

先去掉不要的帧,合并帧的时间,再重新生成帧就完成了。注意帧不要被拖得太长,不然体验不好,我这里给的最大值是 200ms。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
复制代码    /// 同步压缩图片抽取帧数,仅支持 GIF
///
/// - Parameters:
/// - rawData: 原始图片数据
/// - sampleCount: 采样频率,比如 3 则每三张用第一张,然后延长时间
/// - Returns: 处理后数据
static func compressImageData(_ rawData:Data, sampleCount:Int) -> Data?{
guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource) else {
return nil
}

// 计算帧的间隔
let frameDurations = imageSource.frameDurations

// 合并帧的时间,最长不可高于 200ms
let mergeFrameDurations = (0..<frameDurations.count).filter{ $0 % sampleCount == 0 }.map{ min(frameDurations[$0..<min($0 + sampleCount, frameDurations.count)].reduce(0.0) { $0 + $1 }, 0.2) }

// 抽取帧 每 n 帧使用 1 帧
let sampleImageFrames = (0..<frameDurations.count).filter{ $0 % sampleCount == 0 }.compactMap{ CGImageSourceCreateImageAtIndex(imageSource, $0, nil) }

guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, sampleImageFrames.count, nil) else{
return nil
}

// 每一帧图片都进行重新编码
zip(sampleImageFrames, mergeFrameDurations).forEach{
// 设置帧间隔
let frameProperties = [kCGImagePropertyGIFDictionary : [kCGImagePropertyGIFDelayTime: $1, kCGImagePropertyGIFUnclampedDelayTime: $1]]
CGImageDestinationAddImage(imageDestination, $0, frameProperties as CFDictionary)
}

guard CGImageDestinationFinalize(imageDestination) else {
return nil
}

return writeData as Data
}

压缩分辨率也是类似的,每帧按分辨率压缩再重新编码就好。

Android

Android 原生对于 GIF 的支持就不怎么友好了,由于笔者 Android 研究不深,暂时先用 Glide 中的 GIF 编解码组件来完成。编码的性能比较一般,比不上 iOS,但除非换用更底层 C++ 库实现的编码库,Java 写的性能都很普通。先用 Gradle 导入 Glide,注意解码器是默认的,但编码器需要另外导入。

1
2
复制代码api 'com.github.bumptech.glide:glide:4.8.0'
api 'com.github.bumptech.glide:gifencoder-integration:4.8.0'

抽帧思路和 iOS 一样,只是 Glide 的这个 GIF 解码器没办法按指定的 index 取读取某一帧,只能一帧帧读取,调用 advance 方法往后读取。先从 GIF 读出头部信息,然后在读真正的帧信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
复制代码	/**
* 返回同步压缩 gif 图片 Byte 数据 [rawData] 的按 [sampleCount] 采样后的 Byte 数据
*/
private fun compressGifDataWithSampleCount(context: Context, rawData: ByteArray, sampleCount: Int): ByteArray? {
if (sampleCount <= 1) {
return rawData
}
val gifDecoder = StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool))
val headerParser = GifHeaderParser()
headerParser.setData(rawData)
val header = headerParser.parseHeader()
gifDecoder.setData(header, rawData)

val frameCount = gifDecoder.frameCount

// 计算帧的间隔
val frameDurations = (0 until frameCount).map { gifDecoder.getDelay(it) }

// 合并帧的时间,最长不可高于 200ms
val mergeFrameDurations = (0 until frameCount).filter { it % sampleCount == 0 }.map {
min(
frameDurations.subList(
it,
min(it + sampleCount, frameCount)
).fold(0) { acc, duration -> acc + duration }, 200
)
}

// 抽取帧
val sampleImageFrames = (0 until frameCount).mapNotNull {
gifDecoder.advance()
var imageFrame: Bitmap? = null
if (it % sampleCount == 0) {
imageFrame = gifDecoder.nextFrame
}
imageFrame
}

val gifEncoder = AnimatedGifEncoder()

var resultData: ByteArray? = null

try {
val outputStream = ByteArrayOutputStream()
gifEncoder.start(outputStream)
gifEncoder.setRepeat(0)

// 每一帧图片都进行重新编码
sampleImageFrames.zip(mergeFrameDurations).forEach {
// 设置帧间隔
gifEncoder.setDelay(it.second)
gifEncoder.addFrame(it.first)
it.first.recycle()
}
gifEncoder.finish()

resultData = outputStream.toByteArray()
outputStream.close()
} catch (e: IOException) {
e.printStackTrace()
}

return resultData
}

压缩分辨率的时候要注意,分辨率太大编码容易出现 Crash(应该是 OOM),这里设置为 512。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
复制代码    /**
* 返回同步压缩 gif 图片 Byte 数据 [rawData] 每一帧长边到 [limitLongWidth] 后的 Byte 数据
*/
private fun compressGifDataWithLongWidth(context: Context, rawData: ByteArray, limitLongWidth: Int): ByteArray? {
val gifDecoder = StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool))
val headerParser = GifHeaderParser()
headerParser.setData(rawData)
val header = headerParser.parseHeader()
gifDecoder.setData(header, rawData)
val frameCount = gifDecoder.frameCount

// 计算帧的间隔
val frameDurations = (0..(frameCount - 1)).map { gifDecoder.getDelay(it) }

// 计算调整后大小
val longSideWidth = max(header.width, header.height)
val ratio = limitLongWidth.toFloat() / longSideWidth.toFloat()
val resizeWidth = (header.width.toFloat() * ratio).toInt()
val resizeHeight = (header.height.toFloat() * ratio).toInt()

// 每一帧进行缩放
val resizeImageFrames = (0 until frameCount).mapNotNull {
gifDecoder.advance()
var imageFrame = gifDecoder.nextFrame
if (imageFrame != null) {
imageFrame = Bitmap.createScaledBitmap(imageFrame, resizeWidth, resizeHeight, true)
}
imageFrame
}

val gifEncoder = AnimatedGifEncoder()
var resultData: ByteArray? = null

try {
val outputStream = ByteArrayOutputStream()
gifEncoder.start(outputStream)
gifEncoder.setRepeat(0)

// 每一帧都进行重新编码
resizeImageFrames.zip(frameDurations).forEach {
// 设置帧间隔
gifEncoder.setDelay(it.second)
gifEncoder.addFrame(it.first)
it.first.recycle()
}

gifEncoder.finish()

resultData = outputStream.toByteArray()
outputStream.close()
return resultData
} catch (e: IOException) {
e.printStackTrace()
}
return resultData
}

分辨率压缩

这个是最常用的,而且也比较简单。

iOS

iOS 的 ImageIO 提供了 CGImageSourceCreateThumbnailAtIndex 的 API 来创建缩放的缩略图。在 options 中添加需要缩放的长边参数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
复制代码    /// 同步压缩图片数据长边到指定数值
///
/// - Parameters:
/// - rawData: 原始图片数据
/// - limitLongWidth: 长边限制
/// - Returns: 处理后数据
public static func compressImageData(_ rawData:Data, limitLongWidth:CGFloat) -> Data?{
guard max(rawData.imageSize.height, rawData.imageSize.width) > limitLongWidth else {
return rawData
}

guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource) else {
return nil
}


let frameCount = CGImageSourceGetCount(imageSource)

guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, frameCount, nil) else{
return nil
}

// 设置缩略图参数,kCGImageSourceThumbnailMaxPixelSize 为生成缩略图的大小。当设置为 800,如果图片本身大于 800*600,则生成后图片大小为 800*600,如果源图片为 700*500,则生成图片为 800*500
let options = [kCGImageSourceThumbnailMaxPixelSize: limitLongWidth, kCGImageSourceCreateThumbnailWithTransform:true, kCGImageSourceCreateThumbnailFromImageIfAbsent:true] as CFDictionary

if frameCount > 1 {
// 计算帧的间隔
let frameDurations = imageSource.frameDurations

// 每一帧都进行缩放
let resizedImageFrames = (0..<frameCount).compactMap{ CGImageSourceCreateThumbnailAtIndex(imageSource, $0, options) }

// 每一帧都进行重新编码
zip(resizedImageFrames, frameDurations).forEach {
// 设置帧间隔
let frameProperties = [kCGImagePropertyGIFDictionary : [kCGImagePropertyGIFDelayTime: $1, kCGImagePropertyGIFUnclampedDelayTime: $1]]
CGImageDestinationAddImage(imageDestination, $0, frameProperties as CFDictionary)
}
} else {
guard let resizedImageFrame = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) else {
return nil
}
CGImageDestinationAddImage(imageDestination, resizedImageFrame, nil)
}

guard CGImageDestinationFinalize(imageDestination) else {
return nil
}

return writeData as Data
}
Android

Android 静态图用 Bitmap 里面的 createScaleBitmap API 就好了,GIF 上文已经讲了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
复制代码   /**
* 返回同步压缩图片 Byte 数据 [rawData] 的长边到 [limitLongWidth] 后的 Byte 数据,Gif 目标长边最大压缩到 512,超过用 512
*/
fun compressImageDataWithLongWidth(context: Context, rawData: ByteArray, limitLongWidth: Int): ByteArray? {
val format = rawData.imageFormat()
if (format == ImageFormat.UNKNOWN) {
return null
}

val (imageWidth, imageHeight) = rawData.imageSize()
val longSideWidth = max(imageWidth, imageHeight)

if (longSideWidth <= limitLongWidth) {
return rawData
}

if (format == ImageFormat.GIF) {
// 压缩 Gif 分辨率太大编码时容易崩溃
return compressGifDataWithLongWidth(context, rawData, max(512, longSideWidth))
} else {
val image = BitmapFactory.decodeByteArray(rawData, 0, rawData.size)
val ratio = limitLongWidth.toDouble() / longSideWidth.toDouble()
val resizeImageFrame = Bitmap.createScaledBitmap(
image,
(image.width.toDouble() * ratio).toInt(),
(image.height.toDouble() * ratio).toInt(),
true
)
image.recycle()
var resultData: ByteArray? = null
when (format) {
ImageFormat.PNG -> {
resultData = resizeImageFrame.toByteArray(Bitmap.CompressFormat.PNG)
}
ImageFormat.JPG -> {
resultData = resizeImageFrame.toByteArray(Bitmap.CompressFormat.JPEG)
}
else -> {
}
}
resizeImageFrame.recycle()
return resultData
}
}

限制大小的压缩方式

也就是将前面讲的方法综合起来,笔者这边给出一种方案,没有对色彩进行改变,JPG 先用二分法减少最多 6 次的压缩系数,GIF 先抽帧,抽帧间隔参考前文,最后采用逼近目标大小缩小分辨率。

iOS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
复制代码    /// 同步压缩图片到指定文件大小
///
/// - Parameters:
/// - rawData: 原始图片数据
/// - limitDataSize: 限制文件大小,单位字节
/// - Returns: 处理后数据
public static func compressImageData(_ rawData:Data, limitDataSize:Int) -> Data?{
guard rawData.count > limitDataSize else {
return rawData
}

var resultData = rawData

// 若是 JPG,先用压缩系数压缩 6 次,二分法
if resultData.imageFormat == .jpg {
var compression: Double = 1
var maxCompression: Double = 1
var minCompression: Double = 0
for _ in 0..<6 {
compression = (maxCompression + minCompression) / 2
if let data = compressImageData(resultData, compression: compression){
resultData = data
} else {
return nil
}
if resultData.count < Int(CGFloat(limitDataSize) * 0.9) {
minCompression = compression
} else if resultData.count > limitDataSize {
maxCompression = compression
} else {
break
}
}
if resultData.count <= limitDataSize {
return resultData
}
}

// 若是 GIF,先用抽帧减少大小
if resultData.imageFormat == .gif {
let sampleCount = resultData.fitSampleCount
if let data = compressImageData(resultData, sampleCount: sampleCount){
resultData = data
} else {
return nil
}
if resultData.count <= limitDataSize {
return resultData
}
}

var longSideWidth = max(resultData.imageSize.height, resultData.imageSize.width)
// 图片尺寸按比率缩小,比率按字节比例逼近
while resultData.count > limitDataSize{
let ratio = sqrt(CGFloat(limitDataSize) / CGFloat(resultData.count))
longSideWidth *= ratio
if let data = compressImageData(resultData, limitLongWidth: longSideWidth) {
resultData = data
} else {
return nil
}
}
return resultData
}

Android

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
复制代码    /**
* 返回同步压缩图片 Byte 数据 [rawData] 的数据大小到 [limitDataSize] 后的 Byte 数据
*/
fun compressImageDataWithSize(context: Context, rawData: ByteArray, limitDataSize: Int): ByteArray? {
if (rawData.size <= limitDataSize) {
return rawData
}

val format = rawData.imageFormat()
if (format == ImageFormat.UNKNOWN) {
return null
}

var resultData = rawData

// 若是 JPG,先用压缩系数压缩 6 次,二分法
if (format == ImageFormat.JPG) {
var compression = 100
var maxCompression = 100
var minCompression = 0

try {
val outputStream = ByteArrayOutputStream()
for (index in 0..6) {
compression = (maxCompression + minCompression) / 2
outputStream.reset()
val image = BitmapFactory.decodeByteArray(rawData, 0, rawData.size)
image.compress(Bitmap.CompressFormat.JPEG, compression, outputStream)
image.recycle()
resultData = outputStream.toByteArray()
if (resultData.size < (limitDataSize.toDouble() * 0.9).toInt()) {
minCompression = compression
} else if (resultData.size > limitDataSize) {
maxCompression = compression
} else {
break
}
}
outputStream.close()
} catch (e: IOException) {
e.printStackTrace()
}

if (resultData.size <= limitDataSize) {
return resultData
}
}

// 若是 GIF,先用抽帧减少大小
if (format == ImageFormat.GIF) {
val sampleCount = resultData.fitSampleCount()
val data = compressGifDataWithSampleCount(context, resultData, sampleCount)
if (data != null) {
resultData = data
} else {
return null
}

if (resultData.size <= limitDataSize) {
return resultData
}
}


val (imageWidth, imageHeight) = resultData.imageSize()
var longSideWidth = max(imageWidth, imageHeight)

// 图片尺寸按比率缩小,比率按字节比例逼近
while (resultData.size > limitDataSize) {
val ratio = Math.sqrt(limitDataSize.toDouble() / resultData.size.toDouble())
longSideWidth = (longSideWidth.toDouble() * ratio).toInt()
val data = compressImageDataWithLongWidth(context, resultData, longSideWidth)
if (data != null) {
resultData = data
} else {
return null
}
}

return resultData
}

注意在异步线程中使用,毕竟是耗时操作。

最后

所有代码均封装成文件在 iOS 和 Android 中了,如有错误和建议,欢迎指出。

Reference

  • 无损压缩 vs 有损压缩 vs 损多少
  • 图片格式 jpg、png、gif各有什么优缺点?什么情况下用什么格式的图片呢?
  • 也谈图片压缩
  • 移动端图片格式调研
  • 浓缩的才是精华:浅析 GIF 格式图片的存储和压缩
  • 压缩gif的正确姿势
  • 谈谈 iOS 中图片的解压缩
  • iOS平台图片编解码入门教程(Image/IO篇)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java中抽象类和接口的区别

发表于 2019-02-07

对于面向对象编程来说,抽象是它的一大特征之一。在Java中,可以通过两种形式来体现OOP的抽象:接口和抽象类。这两者有太多相似的地方,又有太多不同的地方。很多人在初学的时候会以为它们可以随意互换使用,但是实际则不然。今天我们就一起来学习一下Java中的接口和抽象类。

一、抽象类

在了解抽象类之前,先来了解一下抽象方法。抽象方法是一种特殊的方法:它只有声明,而没有具体的实现。抽象方法的声明格式为:

1
复制代码abstract void fun();

抽象方法必须用abstract关键字进行修饰。如果一个类含有抽象方法,则称这个类为抽象类,抽象类必须在类前用abstract关键字修饰。因为抽象类中含有无具体实现的方法,所以不能用抽象类创建对象。

下面要注意一个问题:在《JAVA编程思想》一书中,将抽象类定义为“包含抽象方法的类”,但是后面发现如果一个类不包含抽象方法,只是用abstract修饰的话也是抽象类。也就是说抽象类不一定必须含有抽象方法。个人觉得这个属于钻牛角尖的问题吧,因为如果一个抽象类不包含任何抽象方法,为何还要设计为抽象类?所以暂且记住这个概念吧,不必去深究为什么。

1
2
3
4
5
复制代码[public] abstract class ClassName {

abstract void fun();

}

从这里可以看出,抽象类就是为了继承而存在的,如果你定义了一个抽象类,却不去继承它,那么等于白白创建了这个抽象类,因为你不能用它来做任何事情。对于一个父类,如果它的某个方法在父类中实现出来没有任何意义,必须根据子类的实际需求来进行不同的实现,那么就可以将这个方法声明为abstract方法,此时这个类也就成为abstract类了。

包含抽象方法的类称为抽象类,但并不意味着抽象类中只能有抽象方法,它和普通类一样,同样可以拥有成员变量和普通的成员方法。注意,抽象类和普通类的主要有三点区别:

  • 抽象方法必须为public或者protected(因为如果为private,则不能被子类继承,子类便无法实现该方法),缺省情况下默认为public。
  • 抽象类不能用来创建对象;
  • 如果一个类继承于一个抽象类,则子类必须实现父类的抽象方法。如果子类没有实现父类的抽象方法,则必须将子类也定义为为abstract类。

在其他方面,抽象类和普通的类并没有区别。

二、接口

接口,英文称作interface,在软件工程中,接口泛指供别人调用的方法或者函数。从这里,我们可以体会到Java语言设计者的初衷,它是对行为的抽象。在Java中,定一个接口的形式如下:

1
2
3
4
5
复制代码[public] interface InterfaceName {



}

接口中可以含有 变量和方法。但是要注意,接口中的变量会被隐式地指定为public static final变量(并且只能是public static final变量,用private修饰会报编译错误),而方法会被隐式地指定为public abstract方法且只能是public abstract方法(用其他关键字,比如private、protected、static、 final等修饰会报编译错误),并且接口中所有的方法不能有具体的实现,也就是说,接口中的方法必须都是抽象方法。从这里可以隐约看出接口和抽象类的区别,接口是一种极度抽象的类型,它比抽象类更加“抽象”,并且一般情况下不在接口中定义变量。

要让一个类遵循某组特地的接口需要使用implements关键字,具体格式如下:

1
2
3
复制代码class ClassName implements Interface1,Interface2,[....]{

}

可以看出,允许一个类遵循多个特定的接口。如果一个非抽象类遵循了某个接口,就必须实现该接口中的所有方法。对于遵循某个接口的抽象类,可以不实现该接口中的抽象方法。

三、抽象类和接口的区别

1.语法层面上的区别

  • 抽象类可以提供成员方法的实现细节,而接口中只能存在public abstract 方法;
  • 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final类型的;
  • 接口中不能含有静态代码块以及静态方法,而抽象类可以有静态代码块和静态方法;
  • 一个类只能继承一个抽象类,而一个类却可以实现多个接口。

2.设计层面上的区别

1)抽象类是对一种事物的抽象,即对类抽象,而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类局部(行为)进行抽象。举个简单的例子,飞机和鸟是不同类的事物,但是它们都有一个共性,就是都会飞。那么在设计的时候,可以将飞机设计为一个类Airplane,将鸟设计为一个类Bird,但是不能将 飞行 这个特性也设计为类,因此它只是一个行为特性,并不是对一类事物的抽象描述。此时可以将 飞行 设计为一个接口Fly,包含方法fly( ),然后Airplane和Bird分别根据自己的需要实现Fly这个接口。然后至于有不同种类的飞机,比如战斗机、民用飞机等直接继承Airplane即可,对于鸟也是类似的,不同种类的鸟直接继承Bird类即可。从这里可以看出,继承是一个 “是不是”的关系,而 接口 实现则是 “有没有”的关系。如果一个类继承了某个抽象类,则子类必定是抽象类的种类,而接口实现则是有没有、具备不具备的关系,比如鸟是否能飞(或者是否具备飞行这个特点),能飞行则可以实现这个接口,不能飞行就不实现这个接口。

2)设计层面不同,抽象类作为很多子类的父类,它是一种模板式设计。而接口是一种行为规范,它是一种辐射式设计。什么是模板式设计?最简单例子,大家都用过ppt里面的模板,如果用模板A设计了ppt B和ppt C,ppt B和ppt C公共的部分就是模板A了,如果它们的公共部分需要改动,则只需要改动模板A就可以了,不需要重新对ppt B和ppt C进行改动。而辐射式设计,比如某个电梯都装了某种报警器,一旦要更新报警器,就必须全部更新。也就是说对于抽象类,如果需要添加新的方法,可以直接在抽象类中添加具体的实现,子类可以不进行变更;而对于接口则不行,如果接口进行了变更,则所有实现这个接口的类都必须进行相应的改动。

下面看一个网上流传最广泛的例子:门和警报的例子:门都有open( )和close( )两个动作,此时我们可以定义通过抽象类和接口来定义这个抽象概念:

1
2
3
4
5
6
7
复制代码abstract class Door {

public abstract void open();

public abstract void close();

}

或者:

1
2
3
4
5
6
7
复制代码interface Door {

public abstract void open();

public abstract void close();

}

但是现在如果我们需要门具有报警alarm( )的功能,那么该如何实现?下面提供两种思路:

1)将这三个功能都放在抽象类里面,但是这样一来所有继承于这个抽象类的子类都具备了报警功能,但是有的门并不一定具备报警功能;

2)将这三个功能都放在接口里面,需要用到报警功能的类就需要实现这个接口中的open( )和close( ),也许这个类根本就不具备open( )和close( )这两个功能,比如火灾报警器。

从这里可以看出, Door的open() 、close()和alarm()根本就属于两个不同范畴内的行为,open()和close()属于门本身固有的行为特性,而alarm()属于延伸的附加行为。因此最好的解决办法是单独将报警设计为一个接口,包含alarm()行为,Door设计为单独的一个抽象类,包含open和close两种行为。再设计一个报警门继承Door类和实现Alarm接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
复制代码interface Alram {

void alarm();

}



abstract class Door {

void open();

void close();

}



class AlarmDoor extends Door implements Alarm {

void oepn() {

//....

}

void close() {

//....

}

void alarm() {

//....

}

}

最后再用一个表格的形式来总结一下抽象类和接口的区别:

参数 抽象类 接口
默认的方法实现 它可以有默认的方法实现 接口完全是抽象的。它根本不存在方法的实现
实现 子类使用extends关键字来继承抽象类。如果子类不是抽象类的话,它需要提供抽象类中所有声明的方法的实现。 子类使用关键字implements来实现接口。它需要提供接口中所有声明的方法的实现
构造器 抽象类可以有构造器 接口不能有构造器
与正常Java类的区别 除了你不能实例化抽象类之外,它和普通Java类没有任何区别 接口是完全不同的类型
访问修饰符 抽象方法可以有public、protected和default这些修饰符 接口方法默认修饰符是public。你不可以使用其它修饰符。
main方法 抽象方法可以有main方法并且我们可以运行它 接口没有main方法,因此我们不能运行它。
多继承 抽象方法可以继承一个类和实现多个接口 接口只可以继承一个或多个其它接口
速度 它比接口速度要快 接口是稍微有点慢的,因为它需要时间去寻找在类中实现的方法。
添加新方法 如果你往抽象类中添加新的方法,你可以给它提供默认的实现。因此你不需要改变你现在的代码。 如果你往接口中添加方法,那么你必须改变实现该接口的类。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

请不要以python思维对待django ORM

发表于 2019-02-05

如果一个web请求需要花费几秒,99%是因为数据库没用好。 当使用ORM的时候,很自然地会想要用python的思维方式来处理数据查询,但是这种思维方式会杀死你的性能。改用子查询(subqueries)和annotations,以sql的思维思考,可以大幅度提高你的web性能。

有一天你打开Datadog,看到一张这样的图:

红色的区域表示进行了数据库请求。这一次web请求进行了644次数据库请求!只有18.6%的时间在做真正有用的事。单次的数据库请求是很快的,但是这么多请求加起来就会严重拖慢web请求速度。
在django这个上下文下,每一次数据库请求,都需要分配内存,model和数据库映射时,还需要序列化和反序列化,然后还要通过网络传输数据。
对于一次web请求,数据库分配到的工作越多,数据库请求次数越少,效率越高。

如果将这644次数据库请求转换成一次,响应速度可以提高将近40倍。

数据库查询性能清单

  • 无论数据大小,请求次数是不是都是常数?
  • 你是否只从数据库取真正需要的数据?
  • 这个问题只能使用Python循环解决吗?

打破Python思维模式

有一个City model,其中有一个计算城市人口密度的方法density。

1
2
3
4
5
6
7
复制代码class City(models.Model):
state = models.ForeignKey(State, related_name='cities')
name = models.TextField()
population = models.DecimalField()
land_area_km = models.DecimalField()
def density(self):
return self.population / self.land_area_km

想要计算一个城市的人口密度,下面这种方式是很自然就能想到的:

1
2
3
4
5
6
7
8
9
复制代码>>> illinois = State.objects.get(name='Illinois')
>>> chicago = City.objects.create(
name="Chicago",
state=illinois,
population=2695598,
land_area_km=588.81
)
>>> chicago.density()
4578.04...

问题出在当我们想要查询出所有拥挤(密度大于4000)的城市时:

1
2
3
4
5
6
7
8
复制代码class City(models.Model):
...
@classmethod
def dense_cities(cls):
return [
city for city in City.objects.all()
if city.density() > 4000
]

如果只有5%的城市是拥挤的,那么将会有95%的数据最终会被丢弃。**在数据中过滤,一定是比将数据导入内存,然后让Python过滤效率要高的!**对于不需要的数据,django都需要花时间完成额外、无意义的操作:将数据转换成model实例。对于数据量小的应用到没什么,但是一旦数据库一大,对性能照成的影响是巨大的。

使用annotate

objects = CitySet.as_manager()这一行表示对City这一model使用自定义的ModelManager,这里不展开讲了,有兴趣可以自己搜索一下。
关于annotate的使用,请参考今天一起发的另一篇文章:Django annotation,减少IO次数利器。

1
2
3
4
5
6
7
8
9
10
11
复制代码class CitySet(models.QuerySet):
def add_density(self):
return self.annotate(
density=F('population') / F('land_area_km')
)
def dense_cities(self):
self.add_density().filter(density__gt=4000)

class City(models.Model):
...
objects = CitySet.as_manager()

annotate(density=F(‘population’) / F(‘land_area_km’))中的F aggregate函数表示获取population和land_area_km的值。

1
2
3
复制代码self.annotate(
density=F('population') / F('land_area_km')
)

表示对于一个queryset,给他其中的每一项object,加上一个density字段,值为population /land_area_km。

1
2
3
4
5
6
复制代码>>> City.objects.dense_cities().values_list('name', 'density')
<QuerySet [("New York City", Decimal('10890.23')), ...]>

# Reverse descriptor
>>> illinois.city.dense_cities().values_list('name', 'density')
<QuerySet [("Chicago", Decimal('4578.04')), ...]>

解释一下:

1
复制代码City.objects.dense_cities().values_list('name', 'density')

这个查询语句的queryset是所有的city object,应该是直接用City这个model调用objects。先调用annotate(density=F(‘population’) / F(‘land_area_km’)),给每个object加上density这个字段,最后筛选出density大于4000的。

1
复制代码illinois.city.dense_cities().values_list('name', 'density')

这个查询语句的queryset是illinois州的所有城市。

这种方法比前面循环的方法效率高多了,因为IO只有一次。

使用subquery

一次查询效率比多次查询高。
杀死django性能最简单的方式就是在for循环中使用query。

要筛选出所有存在dense城市的州:

1
2
3
4
复制代码[
state for state in State.objects.all()
if state.cities.dense_cities().exists()
]

类似这种,exists()会进行一次额外的查询,这会累计很多次毫秒级的查询。加起来的时间也是很可观的。可以用subquery解决这个问题。

最基本的使用方法:

1
2
3
4
复制代码state_ids = City.objects.dense_cities().values('state_id') 
State.objects.filter(id__in=Subquery(state_ids))
// 或者也可以把Subquery省略掉
State.objects.filter(id__in=state_ids)

这样就把很多次的exists查询降低到了一次。

更进一步,和前面说过的annotate结合起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码class StateSet(models.QuerySet):
def add_dense_cities(self):
return self.annotate(
has_dense_cities=Exists(
City
.objects
.filter(state=OuterRef('id'))
.dense_cities()
)
)

class State(models.Model):
...
objects = StateSet.as_manager()

filter(state=OuterRef(‘id’))就是筛选出 state object的所有city,然后调用dense_cities筛选dense城市,然后调用Exists聚合函数,返回True或False。add_dense_cities就给state queryset里的每一个object加上了一个has_dense_cities字段。

最后使用这个查询:

1
复制代码State.objects.add_dense_cities().filter(has_dense_cities=True)

总结

提高数据库查询效率的一个重要原则就是降低IO查询次数,尽量避免使用for循环,试试annotate和subquery吧!

关注我的微信公众号

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

解决Android Studio版本大于32找不到Andr

发表于 2019-02-02

升级到 AndroidStudio 最新版本( >3.2 )朋友们都会遇到一个问题,找不到 DDMS [Android Device Monitor], 只能从 SDK 目录下找到 monitor 启动 DDMS [Android Device Monitor],所以写了一个插件快速启动 Android Device Monitor

源码及使用方式

开发工具:IntelliJ IDEA

Github地址:https://github.com/hi-dhl/DeviceMonitorPlugin

插件下载地址:https://github.com/hi-dhl/DeviceMonitorPlugin/releases/download/1.0/DeviceMonitorPlugin.jar

安装方式:

  • 打开 AndroidStudio
  • 选择 Preference -> Plugins-> install plugin from disk
  • 选择下载好的插件 [DeviceMonitorPlugin.jar] -> 重启 AndroidStudio

如何启动:

  • 打开 AndroidStudio
  • 菜单栏 tools -> 单击 DeviceMonitor

PS: Google 虽然删除了 AdnroidStudio 启动入口,但是本地 SDK 中还是存在,插件通过动态获取本地 SDK 路径启动 AndroidDeviceMonitor, 由于电脑性能不同,启动速度会有不同

Google 为什么弃用 Android Device Monitor

Android Developers官网上的原文链接

Google

Android Device Monitor 是一个 Android 应用调试和分析工具提供了一个 UI 工具,但是大部分组件在 Android Studio 3.1 已经弃用了, 并且会在 Android Studio 3.2 中移除,将会用新的工具帮助开发人员调试和分析 Android 应用 详情戳这里

插件核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码public class Monitor extends AnAction {

@Override
public void actionPerformed(AnActionEvent anActionEvent) {

try {

Project project = anActionEvent.getData(PlatformDataKeys.PROJECT);

String os = AndroidUtils.getPlatformName();
String sdkPath = AndroidUtils.getApkLocalProperties(project);
if (os.toLowerCase().startsWith("win")) {
sdkPath += File.separator + "tools" + File.separator + "monitor.bat";
} else {
sdkPath += File.separator + "tools" + File.separator + "monitor";
}

Runtime.getRuntime().exec(sdkPath);
} catch (Exception e) {

}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码/**
* 动态获取本地Android SDK的路径
*
* @param project
* @return
*/
public static String getApkLocalProperties(Project project) {

String sdkPath = "";

try {

String path = project.getBasePath() + File.separator + "local.properties";

Properties properties = new Properties();
InputStream inputStream = new FileInputStream(path);
properties.load(inputStream);

sdkPath = properties.getProperty("sdk.dir");
} catch (Exception e) {

}
return sdkPath;
}

Github地址:https://github.com/hi-dhl/DeviceMonitorPlugin

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,如果你喜欢这篇文章欢迎 Star 一起来学习,期待与你一起成长

文章列表

Android 10 源码系列

  • 0xA01 Android 10 源码分析:APK 是如何生成的
  • 0xA02 Android 10 源码分析:APK 的安装流程
  • 0xA03 Android 10 源码分析:APK 加载流程之资源加载
  • 0xA04 Android 10 源码分析:APK 加载流程之资源加载(二)
  • 0xA05 Android 10 源码分析:Dialog 加载绘制流程以及在 Kotlin、DataBinding 中的使用
  • 0xA06 Android 10 源码分析:WindowManager 视图绑定以及体系结构

Android 应用系列

  • 如何高效获取视频截图
  • 如何在项目中封装 Kotlin + Android Databinding
  • [译][Google工程师] 刚刚发布了 Fragment 的新特性 “Fragment 间传递数据的新方式” 以及源码分析
  • [译][2.4K Start] 放弃 Dagger 拥抱 Koin
  • [译][5k+] Kotlin 的性能优化那些事
  • [译][Google工程师] 详解 FragmentFactory 如何优雅使用 Koin 以及源码分析
  • [译] 解密 RxJava 的异常处理机制

工具系列

  • 为数不多的人知道的 AndroidStudio 快捷键(一)
  • 为数不多的人知道的 AndroidStudio 快捷键(二)
  • 关于 adb 命令你所需要知道的
  • 10分钟入门 Shell 脚本编程

逆向系列

  • 基于 Smali 文件 Android Studio 动态调试 APP
  • 解决在 Android Studio 3.2 找不到 Android Device Monitor 工具

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

利用策略模式优化过多 if else 代码 前言 实现 总结

发表于 2019-01-30

前言

不出意外,这应该是年前最后一次分享,本次来一点实际开发中会用到的小技巧。

比如平时大家是否都会写类似这样的代码:

1
2
3
4
5
6
7
8
9
复制代码if(a){
//dosomething
}else if(b){
//doshomething
}else if(c){
//doshomething
} else{
////doshomething
}

条件少还好,一旦 else if 过多这里的逻辑将会比较混乱,并很容易出错。

比如这样:

摘自 cim 中的一个客户端命令的判断条件。

刚开始条件较少,也就没管那么多直接写的;现在功能多了导致每次新增一个 else 条件我都得仔细核对,生怕影响之前的逻辑。

这次终于忍无可忍就把他重构了,重构之后这里的结构如下:

最后直接变为两行代码,简洁了许多。

而之前所有的实现逻辑都单独抽取到其他实现类中。

这样每当我需要新增一个 else 逻辑,只需要新增一个类实现同一个接口便可完成。每个处理逻辑都互相独立互不干扰。

实现

按照目前的实现画了一个草图。

整体思路如下:

  • 定义一个 InnerCommand 接口,其中有一个 process 函数交给具体的业务实现。
  • 根据自己的业务,会有多个类实现 InnerCommand 接口;这些实现类都会注册到 Spring Bean 容器中供之后使用。
  • 通过客户端输入命令,从 Spring Bean 容器中获取一个 InnerCommand 实例。
  • 执行最终的 process 函数。

主要想实现的目的就是不在有多个判断条件,只需要根据当前客户端的状态动态的获取 InnerCommand 实例。

从源码上来看最主要的就是 InnerCommandContext 类,他会根据当前客户端命令动态获取 InnerCommand 实例。

  • 第一步是获取所有的 InnerCommand 实例列表。
  • 根据客户端输入的命令从第一步的实例列表中获取类类型。
  • 根据类类型从 Spring 容器中获取具体实例对象。

因此首先第一步需要维护各个命令所对应的类类型。

所以在之前的枚举中就维护了命令和类类型的关系,只需要知道命令就能知道他的类类型。

这样才能满足只需要两行代码就能替换以前复杂的 if else,同时也能灵活扩展。

1
2
复制代码InnerCommand instance = innerCommandContext.getInstance(msg);
instance.process(msg) ;

总结

当然还可以做的更灵活一些,比如都不需要显式的维护命令和类类型的对应关系。

只需要在应用启动时扫描所有实现了 InnerCommand 接口的类即可,在 cicada 中有类似实现,感兴趣的可以自行查看。

这样一些小技巧希望对你有所帮助。

以上所有源码可以在这里查看:

github.com/crossoverJi…

你的点赞与分享是对我最大的支持

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MySql如何使用索引(一)

发表于 2019-01-27

我们都知道在 MySql 中使用索引可以提高查询效率,但有时候真正执行Sql查询的时候却没有按照我们的预想使用索引,而是全表扫描,导致有慢Sql影响了整个网站的效率,甚至导致网站崩溃,所以我们需要了解Mysql是如何选择使用索引的,以便建立合适的索引 (本文基于MySql5.7,InnoDB引擎)

前提:建立一张测试表

假设有一张用户表

1
2
3
4
5
6
7
8
9
10
11
复制代码CREATE TABLE `test_user` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(20) NOT NULL DEFAULT '',
`birthday` date NOT NULL,
`sex` tinyint(1) DEFAULT NULL,
`height` int(11) NOT NULL,
`weight` int(11) NOT NULL,
PRIMARY KEY (`user_id`),
KEY `idx_name_height_weight` (`name`,`height`,`weight`),
KEY `idx_height` (`height`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

我们建立了name,height,weight 的联合索引,插入测试数据

1
2
3
4
5
6
7
复制代码INSERT INTO `test_user` (`user_id`, `name`, `birthday`, `sex`, `height`, `weight`)
VALUES
(1, 'Tony', '1991-01-01', 1, 176, 65),
(2, 'Mary', '1989-12-19', 2, 160, 50),
(3, 'Tom', '1996-05-29', 1, 180, 70),
(4, 'Kiven', '1994-08-09', 1, 190, 80),
(5, 'John', '1992-11-12', 1, 182, 75);

执行什么操作的时候Mysql会使用索引

查找与WHERE子句匹配的行

1
复制代码select * from test_user where name='mary'

查看执行计划

可见使用了idx_name_height_weight索引

从表中删除一行数据

1
复制代码    DELETE from test_user where name='mary';

查看执行计划

查找索引列的MIN()或MAX()的值

1
复制代码    SELECT MIN(height)  FROM `test_user`

这个和之前的执行计划不太一样,Select tables optimized away(选择要优化的表)实际就是优化到不能再优化的意思,在这种情况下,MySql把每个Key的MIN() 和 MAX()值替换成一个常量,如果查到了这个常量就立即返回,然后看下面的例子,分别在索引列上和非索引列上使用MIN()函数的执行计划:

1
复制代码    SELECT MIN(height)  from `test_user` where height>=176 AND height<=190;

1
复制代码    SELECT MIN(height)  from test_user where sex=1;

在索引列上执行 SORT 或 ORDER BY 操作

1
复制代码    SELECT name,height from test_user ORDER BY `name` DESC;

注意SELECT的字段能匹配索引列,比如:

将会出现Using filesort,Using filesort 是Mysql里一种速度比较慢的外部排序,应当尽量避免

本文讲述了MySql什么时候会使用索引,下章说明什么时候MySql不能使用索引,敬请期待

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Cloud Alibaba基础教程:支持的几种

发表于 2019-01-26

通过《Spring Cloud Alibaba基础教程:使用Nacos实现服务注册与发现》一文的学习,我们已经学会如何使用Nacos来实现服务的注册与发现,同时也介绍如何通过LoadBalancerClient接口来获取某个服务的具体实例,并根据实例信息来发起服务接口消费请求。但是这样的做法需要我们手工的去编写服务选取、链接拼接等繁琐的工作,对于开发人员来说非常的不友好。所以接下来,我们再来看看除此之外,还支持哪些其他的服务消费方式。

使用RestTemplate

在之前的例子中,已经使用过RestTemplate来向服务的某个具体实例发起HTTP请求,但是具体的请求路径是通过拼接完成的,对于开发体验并不好。但是,实际上,在Spring Cloud中对RestTemplate做了增强,只需要稍加配置,就能简化之前的调用方式。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
复制代码@EnableDiscoveryClient
@SpringBootApplication
public class TestApplication {

public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}

@Slf4j
@RestController
static class TestController {

@Autowired
RestTemplate restTemplate;

@GetMapping("/test")
public String test() {
String result = restTemplate.getForObject("http://alibaba-nacos-discovery-server/hello?name=didi", String.class);
return "Return : " + result;
}
}

@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}

}

可以看到,在定义RestTemplate的时候,增加了@LoadBalanced注解,而在真正调用服务接口的时候,原来host部分是通过手工拼接ip和端口的,直接采用服务名的时候来写请求路径即可。在真正调用的时候,Spring Cloud会将请求拦截下来,然后通过负载均衡器选出节点,并替换服务名部分为具体的ip和端口,从而实现基于服务名的负载均衡调用。

关于这种方式,可在文末仓库查看完整代码示例。而对于这种方式的实现原理,可以参考我之前写的这篇文章的前半部分:Spring Cloud源码分析(二)Ribbon

使用WebClient

WebClient是Spring 5中最新引入的,可以将其理解为reactive版的RestTemplate。下面举个具体的例子,它将实现与上面RestTemplate一样的请求调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码@EnableDiscoveryClient
@SpringBootApplication
public class TestApplication {

public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}

@Slf4j
@RestController
static class TestController {

@Autowired
private WebClient.Builder webClientBuilder;

@GetMapping("/test")
public Mono<String> test() {
Mono<String> result = webClientBuilder.build()
.get()
.uri("http://alibaba-nacos-discovery-server/hello?name=didi")
.retrieve()
.bodyToMono(String.class);
return result;
}
}

@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
return WebClient.builder();
}

}

可以看到,在定义WebClient.Builder的时候,也增加了@LoadBalanced注解,其原理与之前的RestTemplate时一样的。关于WebClient的完整例子也可以通过在文末的仓库中查看。

使用Feign

上面介绍的RestTemplate和WebClient都是Spring自己封装的工具,下面介绍一个Netflix OSS中的成员,通过它可以更方便的定义和使用服务消费客户端。下面也举一个具体的例子,其实现内容与上面两种方式结果一致:

第一步:在pom.xml中增加openfeign的依赖:

1
2
3
4
复制代码<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

第二步:定义Feign客户端和使用Feign客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码@EnableDiscoveryClient
@SpringBootApplication
@EnableFeignClients
public class TestApplication {

public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}

@Slf4j
@RestController
static class TestController {

@Autowired
Client client;

@GetMapping("/test")
public String test() {
String result = client.hello("didi");
return "Return : " + result;
}
}


@FeignClient("alibaba-nacos-discovery-server")
interface Client {

@GetMapping("/hello")
String hello(@RequestParam(name = "name") String name);

}

}

这里主要先通过@EnableFeignClients注解开启扫描Spring Cloud Feign客户端的功能;然后又创建一个Feign的客户端接口定义。使用@FeignClient注解来指定这个接口所要调用的服务名称,接口中定义的各个函数使用Spring MVC的注解就可以来绑定服务提供方的REST接口,比如下面就是绑定alibaba-nacos-discovery-server服务的/hello接口的例子。最后,在Controller中,注入了Client接口的实现,并调用hello方法来触发对服务提供方的调用。关于使用Feign的完整例子也可以通过在文末的仓库中查看。

深入思考

如果之前已经用过Spring Cloud的读者,肯定会这样的感受:不论我用的是RestTempalte也好、还是用的WebClient也好,还是用的Feign也好,似乎跟我用不用Nacos没啥关系?我们在之前介绍Eureka和Consul的时候,也都是用同样的方法来实现服务调用的,不是吗?

确实是这样,对于Spring Cloud老手来说,就算我们更换了Nacos作为新的服务注册中心,其实对于我们应用层面的代码是没有影响的。那么为什么Spring Cloud可以带给我们这样的完美编码体验呢?实际上,这完全归功于Spring Cloud Common的封装,由于在服务注册与发现、客户端负载均衡等方面都做了很好的抽象,而上层应用方面依赖的都是这些抽象接口,而非针对某个具体中间件的实现。所以,在Spring Cloud中,我们可以很方便的去切换服务治理方面的中间件。

代码示例

本文示例读者可以通过查看下面仓库:

  • Github:github.com/dyc87112/Sp…
  • Gitee:gitee.com/didispace/S…

其中,本文的几种示例可查看下面的几个项目:

  • alibaba-nacos-discovery-server:服务提供者,必须启动
  • alibaba-nacos-discovery-client-resttemplate:使用RestTemplate消费
  • alibaba-nacos-discovery-client-webclient:使用WebClient消费
  • alibaba-nacos-discovery-client-feign:使用Feign消费

如果您对这些感兴趣,欢迎star、follow、收藏、转发给予支持!

以下专题教程也许您会有兴趣

  • Spring Boot基础教程
  • Spring Cloud基础教程

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

朱晔的互联网架构实践心得S2E3:品味Kubernetes的

发表于 2019-01-24

Kubernetes(k8s)是一款开源的优秀的容器编排调度系统,其本身也是一款分布式应用程序。虽然本系列文章讨论的是互联网架构,但是k8s的一些设计理念非常值得深思和借鉴,本人并非运维专家,本文尝试从自己看到的一些k8s的架构理念结合自己的理解来分析 k8s在稳定性、简单、可扩展性三个方面做的一些架构设计的考量。

  • 稳定性:考虑的是系统本身足够稳定,用户使用系统做的一些动作能够稳定落地,系统本身容错性足够强可以应对网络问题,系统本身有足够的高可用等等。
  • 简单:考虑的是系统本身的设计足够简单,组件之间没有太多耦合,组件职责单一等等。
  • 可扩展性:考虑的是系统的各个模块有层次,模块对内对外一视同仁,外部可以轻易实现扩展模块插入到系统(插件),模块实现统一的接口便于替换切换具体实现等等。

下面,针对这三方面我们都会来看一些k8s设计的例子,在看k8s是怎么做的同时我们可以自己思考一下,如果我们需要研发的一款产品就是类似于k8s这样的需要高可靠的资源状态管理协调系统,我们会怎么来设计呢?

1、稳定:声明式应用程序管理

我们知道,k8s定义了许多资源(比如Pod、Service、Deployment、ReplicaSet、StatefulSet、Job、CronJob等),在管理资源的时候我们使用声明式的配置(JSON、YAML等)来对资源进行增删改查操作。我们提供的这些配置就是描述我们希望这些资源最终达成的一个目标状态,叫做Spec,k8s会对观察资源得到资源的状态,叫做Status,当Spec!=Status的时候,k8s的各种控制管理程序就会起作用,进行各种操作使得资源最终可以达到我们期望的Spec。这种声明式的管理方式和命令式管理方式相比,虽然没有后者这么直接,但是容错性会很强,后面一节会进一步详细提到这点。而且,这种管理方式非常的简洁,只要用户提供合适的Spec定义即可,并不需要对外暴露几十个几百个不同的API来实现对资源的各个方面做改变。当然,我们也可以灵活的对一些重要的动作单独开辟管理API(比如扩容,比如修改镜像),这些API底层做的操作就是修改Spec,底层是统一的。

在之前第一季的系列文章S1E2中,我分享过任务表的设计,其实这里的声明式对象管理就是类似这样的思想,我们在数据库中保存的是我们要的结果,然后由不同的任务Job来进行处理最终实现这样的结果(同时也会保存组件当前的状态到数据库),即使任务执行失败也无妨,后续的任务会继续重试,这种方式是可靠性最高的。

2、稳定:边缘触发 vs 水平触发

K8s使用的是声明式的管理方式,也就是水平触发。另一种做法是叫做命令式的管理,也就是边缘触发。比如我们在做支付系统,用户充值100元,提现100元然后又充值100元,对于命令式管理就是三条命令。如果提现请求丢失了,用户账户的余额就出错了,这肯定是不能接受的,命令式管理或边缘触发一定需要配合补偿。而声明式的管理就是告诉系统,用户在进行了三次操作后的余额分别是100、0和100,最终就是100,即使提现请求丢失了,最终用户的余额就是100。

来看下下图的例子,在网络良好的情况下,边缘触发没任何问题。我们进行了开、关、开三次操作,最后的状态是0。

在网络出现问题的时候,丢失了关这个操作,对于边缘触发,最终停留在了2这个错误的状态。对于水平触发没有这个问题,虽然当中有一段时间网络不好,状态错误停留在了1,但是网络恢复后我们马上可以感知到当前的状态应该是0,状态又能回到0,最终状态也能回到正确的1。试想一下,如果我们对我们的Pod进行扩容缩容,如果每次告知k8s应该增加或减少多少个Pod(的这种命令式方式),最终很可能因为网络问题,Pod的状态不是我们期望的。更好的做法是告诉k8s我们希望的状态,不管现在网络是否有问题,某个管理组件是否有问题,pod是否有问题,最终我们期望k8s帮我们调整到我们期望的状态,宁可慢也不要错。

(图来自这里)
3、稳定:高可用设计


我们知道etcd是基于Raft协议的分布式键值数据库/协调系统,本身推荐使用3、5、7这样奇数节点构成集群实现高可用。对于Master节点,我们可以在每一个节点都部署一个etcd,这样节点上的API Server可以和本地的etcd直接通讯,而API Server因为是轻(无)状态的,所以可以在之前使用负载均衡器做代理,不管是Node节点也好还是客户端也好都可以由负载均衡分发请求到合适的API Server上。对于类似于Job的Controller Manager以及Scheduler,显然不适合多个节点同时运行,所以它们都会采用抢占方式选举Leader,只有Leader能承担工作任务,Follower都处于待机状态。整体结构如下图所示:

我们可以想一下其它一些分布式系统的高可用方案,以及我们自己设计的系统的高可用方案,无非就是这三种大模式:

  • 无状态多节点 + 负载均衡
  • 有状态的主节点 + 从(或备份)节点
  • 对称同步的有状态多节点

4、简单:基于list-watch的发布订阅

通过前面的介绍我们大概知道了k8s的一个设计原则是etcd会处于API Server之后,集群内的各种组件是无法直接和数据库对话的,不仅仅因为把数据库直接暴露给各组件会特别混乱,更重要的是谁都可以直接读写etcd会非常不安全,需要统一经过API Server做身份认证和鉴权等安全控制(后面我们会提到API Server的插件链)。

对于k8s集群内的各种资源,k8s的控制管理器和调度器需要感知到各种资源的状态变化(比如创建),然后根据变化事件履行自己的管理职责。考虑到解耦,显然这里有MQ的需求,各种管理组件可以监听各种资源的状态变化事件,不需要相互感知到对方的存在,自己做自己的事情即可。如果k8s还依赖一些消息中间件实现这个功能,那么整体的复杂度会上升,而且还需要对消息中间件进行一些安全方面的定制。

K8s给出的实现方式是仍然使用API Server来充当简单的消息总线的角色,所有的组件通过watch机制建立HTTP长链接来随时获悉自己感兴趣的资源的变化事件,完成自己的功能后还是调用API Server来写入我们组件新的Spec,这份Spec会被其它管理程序感知到并且进行处理。Watch的机制是推的机制,可以实时对变化进行处理,但是我们知道考虑到网络等各种因素,事件可能丢失,组件可能重启,这个时候我们需要推拉结合进行补偿,因此API Server还提供了List接口,用于在watch出现错误的时候或是组件重启的时候同步一次最新状态。通过推拉结合的list-watch机制满足了时效性需求和可靠性需求。

我们来看一下这个图,这个图展示了客户端创建一个Deployment后k8s大概的工作过程:
组件初始化阶段:

  • Deployment Controller订阅Deployment创建事件
  • ReplicaSet Controller订阅ReplicaSet创建事件
  • Scheduler订阅未绑定Node的Pod创建事件
  • 所有Kubelet订阅自己节点的Node和Pod绑定事件

集群资源变更操作:

  1. 客户端调用API Server创建Deployment Spec
  2. Deployment Controller收到消息需要处理新的Deployment
  3. Deployment Controller调用API Server创建ReplicaSet
  4. ReplicaSet Controller收到消息需要处理新的ReplicaSet
  5. ReplicaSet Controller调用API Server创建Pod
  6. Scheduler收到消息,需要处理的新的Pod
  7. Scheduler经过处理后决定把这个Pod绑定到Node1,调用API Server写入绑定
  8. Node1上的Kubelet收到事消息需要处理Pod的部署
  9. Node1上的Kubelet根据Pod的Spec进行Pod部署

可以看到基于list-watch的API Server实现了简单可靠的消息总线的功能,基于资源消息的事件链,解耦了各组件之间的耦合,配合之前提到的基于声明式的对象管理又确保了管理稳定性。从层次上来说,master的组件都是控制面的组件,用来控制管理集群的状态,node的组件是执行面的组件,kubelet是一个无脑执行者的角色,它们的交流桥梁是API Server的各种事件,kubelet是无法感知到控制器的存在的。

5、简单:API Sever收敛资源管理入口

如下图所示,API Server实现了基于插件+过滤器链的方式(比如我们熟知的Spring MVC的拦截器链)来实现资源管理操作的前置校验(身份认证、授权、准入等等)。

整个流程会有哪些环节呢:

  • 身份认证,根据各种插件确定来者是谁
  • 授权,根据各种插件确定用户是否有资格可以操作请求的资源
  • 默认值和转换,资源默认值设置,客户端到etcd版本号转换
  • 管理控制,根据各种插件执行资源的验证或修改操作,先修改后验证
  • 验证,根据各种验证规则验证每一个字段有效性
  • 幂等和并发控制,使用乐观并发方式(版本号方式)验证资源尚未被并发修改
  • 审计,记录所有资源变更日志

如果是删除资源,还会有额外的一些环节:

  • 优雅关闭
  • 终接器钩子,可以配置一些终接器,在这个时候回调
  • 垃圾回收,级联删除没有引用根的资源

对于复杂的流程式的操作,采用职责链+处理链+插件的方式来实现是很常见的做法。你可能会说这个API Server的设计总体上就不简单,怎么有这么多环节,其实这才是最简单的做法,每一个环节都有独立的插件来运作(插件可以独立更新升级,也可以根据需求动态插拔配置),每一个插件只是做自己应该做的事情,如果没有这样的设计,恐怕会出现1万行代码的一个大方法。

6、简单:Scheduler的设计

如图所示,类似于API Server的链式设计,Scheduler在做Pod调度算法的时候也采用了链式设计:

  • 待调度的Pod本身有一个优先级的概念,优先级高的先调度
  • 先找出所有的可用节点
  • 使用predicate(过滤器)筛选节点
  • 使用priority(排序器)对节点进行排序
  • 选择最大优先级的节点调度给Pod

常见的predicate算法有:

  • 端口冲突监测
  • 资源是否满足
  • 亲和性考量
  • ……

常见的priority算法有:

  • 网络拓扑临近
  • 平衡资源使用
  • 资源较多节点优先
  • 已使用的节点优先
  • 已缓存镜像节点优先
  • ……

比如我们在做类似路由系统这种业务系统的时候可以借鉴这种设计模式。简单一词在于每一个小组件简单,它们可以组合起来构成复杂的规则系统,这种设计比把所有逻辑堆在一起简单的多。

7、扩展:分层架构

K8s的设计理念是类似Linux的分层架构:

  • 核心层:Kubernetes 最核心的功能,对外提供 API 构建高层的应用,对内提供插件式应用执行环境
  • 应用层:部署(无状态应用、有状态应用、批处理任务、集群应用等)和路由(服务发现、DNS 解析等)
  • 管理层:系统度量(如基础设施、容器和网络的度量),自动化(如自动扩展、动态 Provision 等)以及策略管理(RBAC、Quota、PSP、NetworkPolicy 等)
  • 接口层:kubectl 命令行工具、客户端 SDK 以及集群联邦

之前介绍的一些组件大多数位于核心层和应用层。在更上层的管理层和接口层,我们往往会做更多的一些二次开发。在之前的文章中我也介绍过,对于复杂的微服务互联网系统,我们也应该把微服务进行分层,从下到上分为基础服务、业务服务、聚合业务服务等,每一层的服务聚合下层实现一些业务逻辑,不但可以做到服务重用,而且上层多变的业务服务的变动可以不影响下层基础设施的搭建。
8、扩展:接口化和插件


除了k8s大量内部组件的实现使用了插件的架构,k8s在整体设计上就把核心和外部的一些资源和服务抽象为了统一的接口,可以插件方式插入具体的实现,如下图所示:

  • 容器方面,容器运行时插件(Container Runtime Interface,简称 CRI)是 k8s v1.5 引入的容器运行时接口,它将 Kubelet 与容器运行时解耦,将原来完全面向 Pod 级别的内部接口拆分成面向 Sandbox 和 Container 的 gRPC 接口,并将镜像管理和容器管理分离到不同的服务。
  • 网络方面,k8s支持两种插件:
    • kubenet:这是一个基于 CNI bridge 的网络插件(在 bridge 插件的基础上扩展了 port mapping 和 traffic shaping ),是目前推荐的默认插件
    • CNI:CNI 网络插件,Container Network Interface (CNI) 最早是由CoreOS发起的容器网络规范,是Kubernetes网络插件的基础。
  • 存储方面,Container Storage Interface (CSI) 是从 k8s v1.9 引入的容器存储接口,用于扩展 Kubernetes 的存储生态。实际上,CSI 是整个容器生态的标准存储接口,同样适用于 Mesos、Cloud Foundry 等其他的容器集群调度系统
    我们看下下面这个图,k8s使用CRI插件来管理容器,为容器配置网络的时候又走了CNI插件:

CNI、CSI、CRI我们比较熟悉了,其它更多的抽象接口这里就不描述了,k8s就像一个大主板,主板上有各种内存、CPU、IO、网络方面的接口,具体的实现k8s本身并不关心,用户和社区甚至可以根据的需要实现自己的插件。
我觉得这点是最了不起的最困难的,很多时候我们在设计一个系统的时候一开始是无法定义出抽象接口的,因为我们不知道将来会面对什么样的实现,只有到实现越来越多后我们才能抽象出接口才能制定标准。
此外,由于在kubernetes中一切皆资源,k8s 1.7之后,提供了CRD(CustomResourceDefinitions)的自定义资源二次开发能力来扩展k8s API,通过此扩展,可以向k8s API中增加新类型,会比修改k8s的源代码或者是创建自定义的API server来的更加的简洁和容易,并且不会随着k8s内核版本的升级,而出现需要代码重新合并的需要,以及兼容性方面的问题。这一功能特性的提供大大提升了k8s的扩展能力。

9、扩展:PV & PVC & StorageClass

K8s在存储方面的解耦设计特别值得一提。如下图所示,我们来看一下k8s在存储这块的解耦设计:

(图引自Kubernetes in Action一书)
我们要做的事情很明确,Pod需要绑定存储资源:

  • 首先,我们肯定需要有卷这种抽象,来抽象出存储方式。但是,如果每次都让k8s的使用者(不管是运维还是开发)在部署Pod的时候设置需要的卷显然耦合太强了(比如NFS卷,每次都要设置地址,用于无需也无法关注到底层的这些细节)。卷V描述的是底层存储能力。
  • 于是,k8s抽象出持久卷PV和和持久卷声明PVC的概念,管理员可以先设置配置PV映射到卷,用户只需要创建PVC来关联PV,然后在创建Pod的时候引用PVC即可,PVC并不关注卷的一些具体细节,只关注容量需求和操作权限。PV这层抽象描述的是运维能提供出来的全局卷的资源,PVC这层描述的是用户希望为Pod申请的存储资源请求。
  • 但是总是需要运维先创建PV还是不方便,k8s还提供了StorageClass这层抽象,通过把PVC关联到指定的(或默认的)StorageClass来动态创建PV。

K8s中除了存储抽象的V、PV、PVC、SC,还有其它的一些组件也有类似层次的抽象以及动态绑定的理念。

我们在使用OO语言进行编程的时候,很自然知道我们需要先定义类,然后再实例化类来创建对象,如果类特别复杂(有不同的实现)的话,我们可能会使用工厂模式(或反射,外层传入目标类型名称)来创建对象。可以和k8s存储抽象比较一下,是不是这个意思,这其实就是一种解耦的方式,在架构设计中,甚至表结构设计中,我们完全可以引入类和实例的概念。比如工作流系统的工作流可以认为是一个类模板,每一次发起的工作流就是这个工作流的实例。

总结

好了,本文大概窥探了一下k8s的架构,不知道你是否感受到了k8s的精良设计,对内考虑了高可用以及高可靠,对外考虑到了高可扩展性。几乎任何操作都允许失败,最终实现一致的状态,几乎任何组件都允许扩展和替换,让用户实现自己的定制需求。

如果你的业务系统也是一套复杂的资源协调系统(k8s抽象的是运维相关的资源,我们的业务系统可以抽象的是其它资源),那么k8s的设计理念有相当多的点可以借鉴。举一个例子,我们在做一套很复杂的流程引擎,我们就可以考虑:

  • 流程的执行者抽象出接口,插件方式插入系统
  • 流程涉及到的资源我们可以先梳理清楚列出来
  • 流程的管理可以把期望结果声明式方式存储到数据库
  • 流程的管控组件可以都对着统一的API服务读写&订阅变化
  • 流程的管控组件本身可以采用插件链、职责链方式执行
  • 流程的入口可以由统一的网关收口做认证和鉴权等
  • ……

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

没有文档,没有老员工讲解,悲催的新人如何快速熟悉一个新项目?

发表于 2019-01-24

本文来自闪客Sun的投稿,博客地址:

https://www.cnblogs.com/flashsun/p/9450066.html

看完你就会明白,虽然有一定的方法论,但是不下功夫,没有耐心,还是万万不行的。

很多新人进入一家新公司后,最头疼的就是如何快速了解公司的业务和项目架构。

因为文档很少,没有文档,或者是文档严重落伍, 根本没法看;如果你碰到一个特别热心的老员工,事无巨细地给你讲,随时在你身边答疑解惑, 那简直是天大的好运气, 现实是大家都很忙,没人给你讲解。

很快就要深入项目做开发了,怎么办呢?

我在加入新公司后,就遇到了悲催的情况。但是在一个多月时间里,我靠自己的力量熟悉了大概十个项目,总结了一些方法,分享给大家。

这里强调一点,我的策略是大体了解整个业务线上的所有项目,大概摸清楚每个项目都是干嘛的,他们之间的关系如何,以便以后具体项目时不至于找不到方向,具体到细节的业务,当然还需要花时间,但相比对整体上的一头雾水,还是简单许多的。

一. 必要条件

这里说的必要条件不是“项目面对的客户是谁”,“项目用的框架是什么”这种,而是真真正正的必要条件,就好比用几条数学公理能推出整个数学体系一样。这里我总结的真正的必要条件只有这两点:

源码位置(gitlab或svn)

部署环境(dev/test/online)

所谓项目,其实就是一堆代码放在了一堆机器上而已,所以这些就足够了。当然,为了更加节约时间,也要获得wiki、jenkins、页面访问路径、 数据库地址等相关信息。

我之所以说那两个必要条件,是想说其实项目本质上就是这么简单的一个事,你千万不要想的太复杂。

它的业务可以无限复杂,但它的本质却逃不出这些,你千万不可以糊涂。当你无从下手或者什么都不清楚的时候,那么就主要把源码和环境弄清楚吧,其它的都是附属品。

二. 从页面到数据库

有了上面的必要条件后,我们就开始了解项目了。由于不只是一个项目,所以千万不能深入具体代码,否则你就越来越烦,怀疑人生,很快放弃。

对某个具体项目的了解,一定要建立在对整体了解的基础上。这时我们首先为各个项目画出一条线,并标明每一个节点的信息,就像下面的样子:

页面访问路径–前端项目–后台服务–数据库地址

这里的一个前端项目可能对应多个后台服务,所以最终的图应该差不多是这样:

这个整理的过程,主要是让自己梳理清楚,一共有哪些项目,哪些是前端可视的,哪些是后台提供服务的。

了解前端项目分别调用了哪些后台服务,通过后台服务和数据库的名称,我们能从本质上了解到这条业务线提供了什么功能,从前端项目和页面路径,我们能了解到我们需要给用户展示什么。

注意,这个阶段我们只是见名知意,即使点开页面,连接上数据库看看,也千万别花过多的时间,这个阶段的重点就是仅仅知道,这条业务线提的整体内容。

在此基础之上,这个图可以不断细化,比如项目部署的机器,我们可以标注在项目旁边,或者保存在xshell里。此外所有非业务相关的,能查到的尽量都记录下来,这个真的为以后找各种东西方便太多了,否则别看你现在节约了时间,把以后查找相关东西的时间加起来,将会是天文数字了。

这里关于整理项目部署的机器还有个小插曲,跟大家分享一下。由于这部分的信息没人会一个一个地告诉你,就算有也不可能说的特别全。所以我是借助jenkins来整理的。项目部署都需要用到jenkins,只要查看jenkins配置的命令,就可以把部署环境一一整理出来,这个我认为是最全而且最新的。

不要和我说查wiki,如果公司wiki都写的这么全,我估计就没这篇文章什么事了。当时我的jenkins权限特别少,只能看一部分项目,后来费了很大的劲,想了很多办法才看到项目的配置,整理出了部署的机器。

三. 了解项目间的关系

这部分如果有老员工愿意和你说说,那最好还是了解一下。如果没有也没关系,先跳过这段,以后慢慢了解也是可以的。

四. 整理数据库表

我们上面都是整理项目的大体框架,还没有涉及到具体的项目细节。这一部分,仍然不去涉及。

如果说站在整个业务的本质上看,业务无非就是一堆代码运行在一堆机器上。那么站在单个项目来看,一个项目无非就是对数据库的增删改查操作而已,或者从使用者的角度看,一个项目就是 输入一些参数得到一些返回结果。

所以接下来我们要做两件事,一个是整理数据库表,一个是整理Controller层的所有接口。

这里首先要选择一个核心项目去看,众多项目中一定有一个是核心项目,先从这个开始看起。

如果数据库的表比较少,那我们拿工具导出来表结构,一个个看就行了,这个不难。但如果数据库表特别多,我们首先要将表名全部导出,筛选出那些核心的表。

这里导出表名、筛选表以及后面的分析表字段,不妨给自己做个工具,我在遇到一些很麻烦的或者感觉以后还可以通用的事情时,就会做成一个小工具,放在一个我给自己起名为javamate的程序中,这些小工具逐渐积累起来你会发现今后有意想不到的方便。

话说回来,如何判断哪些是核心表呢,不要着急,我们首先排除掉一些没用的。拿我在公司分析的系统来说,一共150多个表,其中有好多copy结尾的是备份,flow结尾的是流水,rel结尾的是中间关联表,statistics结尾的是数据统计表,log结尾的是日志表,config结尾的是配置表。等等。

排除掉这些对核心业务理解无影响的表之后,所剩的也就20来张表,再根据他们的名字,可以看出好多表是属于一类的,比如order表就有各种order,按类别再分出来也就四五类,再分析起来就不难了。当然如果是更大的体系结构,那就要再不断做拆解。

再具体分析这些核心表字段之前,还要做一件事就是找出表中间的关系。如果表b中有个字段叫比如a.id,那么b和 a就是一对多的关系,如果两个表有rel中间表,那二者就是多对多的关系,起码从逻辑上讲是这样的。这个分析过程我也是做了个小工具,通过程序来判断的。

到此,你就对整体的数据库结构有所了解了。根据表名也能对表的大致内容有所了解,接下来就是针对具体的表,看里面具体的字段和前人给出的备注,这个过程就没有技巧了,要耐心,要慢慢熬。

五. 深入代码层

当你对数据库表做了以上到了解后,你基本上对这个系统能提供什么服务了解到差不多了。这个不论你的代码长什么样子,数据库摆在那里,其实能提供的服务就已经差不多出来了,对于有经验的人来讲,代码的业务逻辑也大致能猜到个八九分。

我认为一个业务相关的项目代码只分三个部分:

  1. 通过交互对自身数据库进行增删改查操作
  1. 通过定时任务或服务器脚本对自身数据库进行增删改查操作
  1. 调用或通知其他服务做一些事情

如果只是单一项目,无非就是通过各种途径去玩自己的数据库而已,前两点足够了。而如果是微服务部署,那么加一个第三点足矣。我们将代码逻辑分成这三个部分看,快速了解一个项目就不成问题,甚至在你没有看过某一项目而突然有一个bug要你解决时,你也可以按照这种方式去快速定问问题。

通过交互对自身数据库进行增删改查操作

这个无非是最简单的一部分,即使复杂也是代码较长,表较多而已。所谓的交互,或许是Controller暴露给前端用户的接口,或许是开一个rpc端口暴露给其他微服务的接口,总之是第三方去触发的。

这里我也给自己做了个小工具,扫描出所有的暴露服务的接口,展示出方法名,路径名,参数列表和返回值等。

和数据库一样,如果接口很少那么一个个看,如果特别多,还是先找出比较核心的几个方法研究。这里我用的是postman,把要研究的接口访问保存起来,并且添加访问成功和失败的Example。

这里我推荐自己开发的时候也把postman用起来,越详细越好,postman不只是可以简简单单访问你的接口,还能做批量测试,还可以生成api文档用于和前端交互。这样你不但测试了自己的接口,还省的写文档了。而且postman还有个好处就是可以给自己的接口mock一个服务,这样即使你的接口挂了,或者你的接口根本就没写好,你可以让前端人员先访问你的mock,完全不影响前端边测试边开发,这才是真正的前后端分离嘛。

整理出所有接口后,肯定大部分是很简单的,一看就懂,一层一层点进去直到数据库层的sql语句,该接口最本质的东西就出来了。

如果是复杂的,那就一步一步debug,花时间总是可以分析的。如果再复杂的,你可以画流程图(这里我比较推荐用processon)。甚至几个接口围绕一个功能的,你可以画状态流转图。比如我之前看我们公司处理订单业务这块,逻辑确实比较复杂,我就画了类似如下的图:

状态流转图:横轴代表order_status字段的状态,纵轴代表当order_status是以上状态时,触发该接口操作会使该字段发生什么变化)

接口对表的影响图:这里你可以把所有涉及到的表以及表中的关键字段列举出来,然后看分别调用接口后对各个表字段的影响,变化的就用红色标出

有了这两种维度的视角,我相信再复杂的业务都能很理清楚,也能发现某些bug最本质的问题。

我正是通过这样的方式,把一个本来不属于我的项目短时间内了解清楚,快速准确地修复了好多顽固的bug。

虽然项目很烂,业务逻辑十分混乱,但正是这样一段时间锻炼了我深入代码理清逻辑的能力,也有了自己独特的一套方法。

定时任务或服务器脚本

这个和第一种类型一样,只不过换了个入口。比如定时任务,或者启动的时候就开启的一些线程。

寻找这些入口的确不是特别容易,比较头疼,但也只是入口比较隐蔽而已。找到他,记下来,具体分析过程还是按照上述方法去分析,就可以了。

调用或通知其他服务做一些事情

代码中可能有通过mq给其他服务发消息,或者直接调用其他服务的接口,或者调用类似云推送的接口让它去帮忙像mq发消息。

这部分代码可能更加隐蔽,但数量少,逻辑也简单,你需要做的仍然只是找到它们。这部分也是为了解项目之间的关系打下伏笔。

这三种类型的代码研究清楚后,对于一个业务型的项目来说,已经基本足够了。

对于一些基础服务和中间件类型的服务,还是得慢慢积累技术深度才行。由于本篇文章是快速了解一个业务型的项目,所以就不展开叙述了。

六. 重新理清项目间的关系

好了,这时候每个项目你已经大致了解,最起码调用的效果,数据库所能提供的服务,甚至某些关键部分的本质逻辑,你是清楚的。这个时候,要重新整理下项目之间的关系。

  1. 根据之前的接口名称,详细了解下项目间的调用关系。理不清的部分去问老员工,这时候你带着自己的了解问,他们也能给出更多的信息。
  1. 看看每个项目中用到的中间件,主要是mq服务,看看谁是生产者,谁是消费者,以此来了解关系
  1. 这时你应该已经开了好几轮的周会了,接下来的周会你应该能听懂部分内容。根据每个人的描述和最新的几组需求,逐渐摸清楚现在项目面临的问题,以及哪个项目是核心,哪个项目是辅助,哪个项目是以稳定安全为主的

到此为止,整条业务线你就有了大致的了解,接下来就要结合你具体负责的内容,领导安排你做的方向,去看具体的业务代码了。深入其中,事无巨细地了解。

但此时,你通过前面的努力,你已经可以站在一定的高度看每一个项目了,虽然你细节上还是不了解,但这是完全不同的。

在研究具体业务代码的同时,不断地跳出来看整条业务线的框架,修正之前由于不了解具体业务而理解错误的架构。长此以往,你一定会在某个项目中脱颖而出,让大家认识到你的全局视野,这也是走出老是写增删改查代码怪圈的一个途径。

慢慢会有人意识到,你对项目的理解总能站在全局的视野,很多需要跨项目去做的业务,也会自然而然想到你,慢慢地,你会接触到更为核心的东西,成为架构师,或者去转向产品,转向管理。

这就是我总结的了解项目的过程,希望大佬们多多留言指点,提出问题,共同进步。

往期精彩回顾

阅读源码的三重境界

我是一个线程

关于老刘和码农翻身

我是一个Java Class

面向对象圣经

TCP/IP之大明邮差

CPU阿甘

我是一个网卡

我是一个路由器

一个故事讲完HTTPs

编程语言的巅峰
Java:一个帝国的诞生

JavaScript:一个屌丝的逆袭

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于属性描述符PropertyDescriptor

发表于 2019-01-19

本文首发于本博客 猫叔的博客,转载请申明出处

前言

感谢GY丶L粉丝的提问:属性描述器PropertyDescriptor是干嘛用的?

本来我也没有仔细了解过描述符这一块的知识,不过粉丝问了,我就抽周末的时间看看,顺便学习一下,粉丝问的刚好是PropertyDescriptor这个属性描述符,我看了下源码。

1
2
3
4
5
6
7
复制代码/**
* A PropertyDescriptor describes one property that a Java Bean
* exports via a pair of accessor methods.
*/
public class PropertyDescriptor extends FeatureDescriptor {
//...
}

emmmm,假装自己英语能厉害的说,属性描述符描述了一个属性,即Java Bean 通过一对访问器方法来导出。(没错,他确实是存在于java.beans包下的)

描述符

通过类关系图,可以知道,我们应该提前了解一下FeatureDescriptor才行了。很好,起码目前还没有设计抽象类或者接口。

FeatureDescriptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码/**
* The FeatureDescriptor class is the common baseclass for PropertyDescriptor,
* EventSetDescriptor, and MethodDescriptor, etc.
* <p>
* It supports some common information that can be set and retrieved for
* any of the introspection descriptors.
* <p>
* In addition it provides an extension mechanism so that arbitrary
* attribute/value pairs can be associated with a design feature.
*/

public class FeatureDescriptor {
//...
}

okay,这是很合理的设计方式,FeatureDescriptor为类似PropertyDescriptor、EvebtSetDescriptor、MethodDescriptor的描述符提供了一些共用的常量信息。同时它也提供一个扩展功能,方便任意属性或键值对可以于设计功能相关联。

这里简单的说下,在我大致看了一下源码后(可能不够详细,最近有点忙,时间较赶),FeatureDescriptor主要是针对一下属性的一些get/set,同时这些属性都是基本通用于PropertyDescriptor、EvebtSetDescriptor、MethodDescriptor。

1
2
3
4
5
6
7
复制代码    private boolean expert; // 专有
private boolean hidden; // 隐藏
private boolean preferred; // 首选
private String shortDescription; //简单说明
private String name; // 编程名称
private String displayName; //本地名称
private Hashtable<String, Object> table; // 属性表

其实该类还有另外几个方法,比如深奥的构造函数等等,这里就不深入探讨了。

PropertyDescriptor

那么我们大致知道了FeatureDescriptor,接下来就可以来深入了解看看这个属性描述符PropertyDescriptor。

说到属性,大家一定会想到的就是get/set这个些基础的东西,当我打开PropertyDescriptor源码的时候,我也看到了一开始猜想的点。

1
2
3
4
复制代码    private final MethodRef readMethodRef = new MethodRef();
private final MethodRef writeMethodRef = new MethodRef();
private String writeMethodName;
private String readMethodName;

这里的代码是我从源码中抽离的一部分,起码我们这样看可以大致理解,是分为写和读的步骤,那么就和我们初学java的get/set是一致的。

同时我还看到了,这个,及其注释。

1
2
3
复制代码    // The base name of the method name which will be prefixed with the
// read and write method. If name == "foo" then the baseName is "Foo"
private String baseName;

这好像可以解释,为什么我们的属性在生成get/set的时候,第一个字母变成大写?!注释好像确实是这样写的。

由于可能需要一个Bean对象,所以我以前在案例中先创建了一个Cat类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
复制代码public class Cat {

private String name;

private String describe;

private int age;

private int weight;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getDescribe() {
return describe;
}

public void setDescribe(String describe) {
this.describe = describe;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public int getWeight() {
return weight;
}

public void setWeight(int weight) {
this.weight = weight;
}
}

构造函数

起码目前,我还不知道我应该怎么使用它,那么我们就一步一步来吧,我看到它有好几个构造函数,这是一个有趣而且有难度的事情,我们先试着创建一个PropertyDescriptor吧。

  • 第一种构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    /**
* Constructs a PropertyDescriptor for a property that follows
* the standard Java convention by having getFoo and setFoo
* accessor methods. Thus if the argument name is "fred", it will
* assume that the writer method is "setFred" and the reader method
* is "getFred" (or "isFred" for a boolean property). Note that the
* property name should start with a lower case character, which will
* be capitalized in the method names.
*
* @param propertyName The programmatic name of the property.
* @param beanClass The Class object for the target bean. For
* example sun.beans.OurButton.class.
* @exception IntrospectionException if an exception occurs during
* introspection.
*/
public PropertyDescriptor(String propertyName, Class<?> beanClass)
throws IntrospectionException {
this(propertyName, beanClass,
Introspector.IS_PREFIX + NameGenerator.capitalize(propertyName),
Introspector.SET_PREFIX + NameGenerator.capitalize(propertyName));
}

这个好像是参数最少的,它只需要我们传入一个属性字符串,还有对应的类就好了,其实它也是调用了另一个构造函数,只是它会帮我们默认生成读方法和写方法。方法中的Introspector.IS_PREFIX + NameGenerator.capitalize(propertyName)其实就是自己拼出一个默认的get/set方法,大家有兴趣可以去看看源码。

那么对应的实现内容,我想大家应该都想到了。

1
2
3
4
5
6
7
复制代码    public static void main(String[] args) throws Exception {
PropertyDescriptor CatPropertyOfName = new PropertyDescriptor("name", Cat.class);
System.out.println(CatPropertyOfName.getPropertyType());
System.out.println(CatPropertyOfName.getPropertyEditorClass());
System.out.println(CatPropertyOfName.getReadMethod());
System.out.println(CatPropertyOfName.getWriteMethod());
}
  • 第二种构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
复制代码/**
* This constructor takes the name of a simple property, and method
* names for reading and writing the property.
*
* @param propertyName The programmatic name of the property.
* @param beanClass The Class object for the target bean. For
* example sun.beans.OurButton.class.
* @param readMethodName The name of the method used for reading the property
* value. May be null if the property is write-only.
* @param writeMethodName The name of the method used for writing the property
* value. May be null if the property is read-only.
* @exception IntrospectionException if an exception occurs during
* introspection.
*/
public PropertyDescriptor(String propertyName, Class<?> beanClass,
String readMethodName, String writeMethodName)
throws IntrospectionException {
if (beanClass == null) {
throw new IntrospectionException("Target Bean class is null");
}
if (propertyName == null || propertyName.length() == 0) {
throw new IntrospectionException("bad property name");
}
if ("".equals(readMethodName) || "".equals(writeMethodName)) {
throw new IntrospectionException("read or write method name should not be the empty string");
}
setName(propertyName);
setClass0(beanClass);

this.readMethodName = readMethodName;
if (readMethodName != null && getReadMethod() == null) {
throw new IntrospectionException("Method not found: " + readMethodName);
}
this.writeMethodName = writeMethodName;
if (writeMethodName != null && getWriteMethod() == null) {
throw new IntrospectionException("Method not found: " + writeMethodName);
}
// If this class or one of its base classes allow PropertyChangeListener,
// then we assume that any properties we discover are "bound".
// See Introspector.getTargetPropertyInfo() method.
Class[] args = { PropertyChangeListener.class };
this.bound = null != Introspector.findMethod(beanClass, "addPropertyChangeListener", args.length, args);
}

没错,这个构造函数就是第一种构造函数内部二次调用的,所需要的参数很简单,同时我也希望大家可以借鉴这个方法中的一些检测方式。这次的实现方式也是同样的形式。

1
2
3
4
5
6
7
复制代码    public static void main(String[] args) throws Exception {
PropertyDescriptor CatPropertyOfName = new PropertyDescriptor("name", Cat.class,"getName","setName");
System.out.println(CatPropertyOfName.getPropertyType());
System.out.println(CatPropertyOfName.getPropertyEditorClass());
System.out.println(CatPropertyOfName.getReadMethod());
System.out.println(CatPropertyOfName.getWriteMethod());
}
  • 第三种构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    /**
* This constructor takes the name of a simple property, and Method
* objects for reading and writing the property.
*
* @param propertyName The programmatic name of the property.
* @param readMethod The method used for reading the property value.
* May be null if the property is write-only.
* @param writeMethod The method used for writing the property value.
* May be null if the property is read-only.
* @exception IntrospectionException if an exception occurs during
* introspection.
*/
public PropertyDescriptor(String propertyName, Method readMethod, Method writeMethod)
throws IntrospectionException {
if (propertyName == null || propertyName.length() == 0) {
throw new IntrospectionException("bad property name");
}
setName(propertyName);
setReadMethod(readMethod);
setWriteMethod(writeMethod);
}

这个不用传类,因为你需要传递两个实际的方法进来,所以主要三个对应属性的参数既可。看看大致的实现内容

1
2
3
4
5
6
7
8
9
10
复制代码    public static void main(String[] args) throws Exception {
Class<?> classType = Cat.class;
Method CatNameOfRead = classType.getMethod("getName");
Method CatNameOfWrite = classType.getMethod("setName", String.class);
PropertyDescriptor CatPropertyOfName = new PropertyDescriptor("name", CatNameOfRead,CatNameOfWrite);
System.out.println(CatPropertyOfName.getPropertyType());
System.out.println(CatPropertyOfName.getPropertyEditorClass());
System.out.println(CatPropertyOfName.getReadMethod());
System.out.println(CatPropertyOfName.getWriteMethod());
}

好了,大致介绍了几种构造函数与实现方式,起码我们现在知道它需要什么。

一些使用方式

其实在我上面写一些构造函数的时候,我想大家应该已经感受到与反射相关了,起码我感觉上是这样的,所以我一开始想到这样的案例形式,通过反射与这个属性描述类去赋予我的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码    public static void main(String[] args) throws Exception {
//获取类
Class classType = Class.forName("com.example.demo.beans.Cat");
Object catObj = classType.newInstance();
//获取Name属性
PropertyDescriptor catPropertyOfName = new PropertyDescriptor("name",classType);
//得到对应的写方法
Method writeOfName = catPropertyOfName.getWriteMethod();
//将值赋进这个类中
writeOfName.invoke(catObj,"river");
Cat cat = (Cat)catObj;
System.out.println(cat.toString());
}

运行结果还是顺利的。

Cat{name=’river’, describe=’null’, age=0, weight=0}

可以看到,我们确实得到了一个理想中的对象。

那么我是不是可以改变一个已经创建的对象呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码    public static void main(String[] args) throws Exception {
//一开始的默认对象
Cat cat = new Cat("river","黑猫",2,4);
//获取name属性
PropertyDescriptor catPropertyOfName = new PropertyDescriptor("name",Cat.class);
//得到读方法
Method readMethod = catPropertyOfName.getReadMethod();
//获取属性值
String name = (String) readMethod.invoke(cat);
System.out.println("默认:" + name);
//得到写方法
Method writeMethod = catPropertyOfName.getWriteMethod();
//修改值
writeMethod.invoke(cat,"copy");
System.out.println("修改后:" + cat);
}

上面的demo是,我先创建了一个对象,然后通过属性描述符读取name值,再进行修改值,最后输出的对象的值也确实改变了。

默认:river
修改后:Cat{name=’copy’, describe=’黑猫’, age=2, weight=4}

收尾

这是一个有趣的API,我想另外两个(EvebtSetDescriptor、MethodDescriptor)应该也差不多,大家可以再通过此方法去探究,只有自己尝试一次才能学到这里面的一些东西,还有一些项目场景的使用方式,不过一般的业务场景应该很少使用到这个API。那么这个东西究竟可以干什么呢?我想你试着敲一次也许有一些答案了。

公众号:Java猫说

现架构设计(码农)兼创业技术顾问,不羁平庸,热爱开源,杂谈程序人生与不定期干货。

Image Text

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…879880881…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%