多渠道打包工具Walle源码分析

摘要:
既然,我们的渠道包打包流程是在我们出包之后,那么我们则必须去了解Android的签名机制,也就是我们平时签名所勾选的v1、v2和新出的v3签名。通过magic值,可以快速确定“中央目录”前方可能是“APK签名分块”。从walle的commit记录来看,我们了解到walle目前已经支持v3签名写渠道。

一、背景

首先了解多渠道打包工具Walle之前,我们需要先明确一个概念,什么是渠道包。

我们要知道在国内有无数大大小小的APP Store,每一个APP Store就是一个渠道。当我们把APP上传到APP Store上的时候,我们如何知道用户在那个渠道下载我们的APP呢?如果单凭渠道供应商自己给的话,那无疑会带来不可知的损失,当然除了这个原因,我们还有别的等等。

所以通俗的来说,我们需要一种方法来对我们的APK在不改变功能的情况下进行标记,来达到区分的目的。

二、如何给APK打标记

google官方为我们提供了注入meta-data、flavor等方法进行区分,但无疑我们每次去获取不同渠道的APK都面临一个重新打apk的问题。当渠道多的时候,这样大量重复无用的工作无疑是耗时且繁琐的。所以我们需要一种方法,让我们只打一个包,并在这个包的基础上进行区分,来达到获取不同渠道包的功能。

我们都知道编译获取APK后,会进行签名的操作,一旦我们在签名后进行修改apk包内容的修改,那么无疑会破坏签名,导致apk无法安装。所以我们需要一个折中的办法。

三、渠道打包原理分析

通过上面的分析,我们知道打渠道包,需要做到如下的要素。避免重新打包、避免重新签名。第一条是必须去避免的,因为太过耗时。第二条签名过程在渠道包操作较多的时候也是一笔耗时操作,但不属于必须优化项。

既然,我们的渠道包打包流程是在我们出包之后,那么我们则必须去了解Android的签名机制,也就是我们平时签名所勾选的v1、v2和新出的v3签名。

传统的v1签名是这样的:

我们的APK在签名后,通过解压,我们能够发现在APK中出现了一个META-INF的文件夹,它包含了三个文件MANIFEST.MF、CERT.RSA、CERT.SF三个文件,这三个文件就包含我们v1签名的签名信息。
我们本节的重点不是在签名上,所以我就简单的来说一下,这三个文件的作用是什么。

MANIFEST.MF:查看文件内容,我们可以看到这个文件记录的是对每一个文件内容做一次SHA1算法,就是计算出文件的摘要信息,然后用Base64进行编码。

多渠道打包工具Walle源码分析第1张

CERT.SF:对MANIFEST.MF的每一个条目进行一次相同的操作

多渠道打包工具Walle源码分析第2张

CERT.RSA:这个文件是个二进制文件,也就是用我们的签名文件对CERT.SF进行签名。
所以我们可以发现,上述三个文件保存了我们所有的签名信息。那么我们可以发现,他却没有验证META-INF文件夹中的信息,所以我们完全可以通过在META-INF文件夹中添加不同的文件,然后在APP中读取,来进行区分。这样避免了重复签名。当然,在v2签名出来之后,v2签名对整个apk,进行了签名。因为我们一般会
同时v1、v2签名,所以自然META-INF也需要验证。再用相同的方法,必然会报错。除非删除签名信息后,重新签名。

v2签名:

先放一张v2签名经典的原理图。

多渠道打包工具Walle源码分析第3张

我们可以知道v2签名在原APK的基础上添加了APK SIgning Block区域用来保护其他三跨块区域,所以我们可以很明显的知道,如果我们在这块区域中进行修改,是不会进行相关的签名校验的。Walle正是利用这种方式来进行的相关修改

所以在解析 APK 时,首先要通过以下方法找到“ZIP 中央目录”的起始位置:在文件末尾找到“ZIP 中央目录结尾”记录,然后从该记录中读取“中央目录”的起始偏移量。通过magic值,可以快速确定“中央目录”前方可能是“APK 签名分块”。然后,通过size of block值,可以高效地找到该分块在文件中的起始位置。

多渠道打包工具Walle源码分析第4张

图1

v3 签名

Android 9 支持APK 密钥轮转,这使应用能够在 APK 更新过程中更改其签名密钥。为了实现轮转,APK 必须指示新旧签名密钥之间的信任级别。为了支持密钥轮转,我们将APK 签名方案从 v2 更新为 v3,以允许使用新旧密钥。v3 在 APK 签名分块中添加了有关受支持的 SDK 版本和 proof-of-rotation 结构的信息。

v3签名格式与v2类似。APK 的 v3 签名会存储为一个“ID-值”对,其中 ID 为 0xf05368c0。

从walle的commit记录来看,我们了解到walle目前已经支持v3签名写渠道。可以看到代码中添加了generateApkSigningBlock(https://android.googlesource.com/platform/tools/apksig/+/master/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtils.java)这一部分的代码为什么这么做能够兼容v3,还需要再去研究

四、源码分析

下面的代码是walle中读取渠道信息所用的比较重要的地方。

final class AplUtil { 
private ApkUtil(){
super
(); } /*** APK Signing Block Magic Code: magic “APK Sig Block 42” (16 bytes) * "APK Sig Block 42" : 41 50 4B 20 53 69 67 20 42 6C 6F 63 6B 20 34 32 */ public static final long APK_SIG_BLOCK_MAGIC_HI = 0x3234206b636f6c42L; //LITTLE_ENDIAN, High public static final long APK_SIG_BLOCK_MAGIC_LO = 0x20676953204b5041L; //LITTLE_ENDIAN, Low private static final int APK_SIG_BLOCK_MIN_SIZE = 32; /*The v2 signature of the APK is stored as an ID-value pair with ID 0x7109871a (https://source.android.com/security/apksigning/v2.html#apk-signing-block) */ public static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a; /*** The padding in APK SIG BLOCK (V3 scheme introduced) * See https://android.googlesource.com/platform/tools/apksig/+/master/src/main/java/com/android/apksig/internal/apk/ApkSigningBlockUtils.java */ public static final int VERITY_PADDING_BLOCK_ID = 0x42726577; public static final int ANDROID_COMMON_PAGE_ALIGNMENT_BYTES = 4096; //Our Channel Block ID 签名校验区的值是通过ID-value的键值对写进去的,这里walle的渠道key就是下面的值 public static final int APK_CHANNEL_BLOCK_ID = 0x71777777; public static final String DEFAULT_CHARSET = "UTF-8"; private static final int ZIP_EOCD_REC_MIN_SIZE = 22; private static final int ZIP_EOCD_REC_SIG = 0x06054b50; private static final int UINT16_MAX_VALUE = 0xffff; private static final int ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET = 20;   
//EOCD获取长度,下面的函数就是获取我们所需要的EOCD区域,这里面包含了Central Dir的偏移量,所以很好计算 第四步
//第 4 部分(ZIP 中央目录结尾)包含“ZIP 中央目录”的偏移量
public static long getCommentLength(final FileChannel fileChannel) throwsIOException {
//这里的注释将EOCD结构描述的很详细
//End of central directory record (EOCD) //Offset Bytes Description[23] //0 4 End of central directory signature = 0x06054b50 //4 2 Number of this disk //6 2 Disk where central directory starts //8 2 Number of central directory records on this disk //10 2 Total number of central directory records //12 4 Size of central directory (bytes) //16 4 Offset of start of central directory, relative to start of archive //这里包含了Central Dir的偏移量 //20 2 Comment length (n) //22 n Comment //For a zip with no archive comment, the //end-of-central-directory record will be 22 bytes long, so //we expect to find the EOCD marker 22 bytes from the end. final long archiveSize =fileChannel.size(); if (archiveSize <ZIP_EOCD_REC_MIN_SIZE) { throw new IOException("APK too small for ZIP End of Central Directory (EOCD) record"); }
//EOCD位于apk的最后方,他的起始为一个magic魔数,所以我们只要找到这个魔数,就可以确定位置了
//ZIP End of Central Directory (EOCD) record is located at the very end of the ZIP archive. //The record can be identified by its 4-byte signature/magic which is located at the very //beginning of the record. A complication is that the record is variable-length because of //the comment field.
//这里解释了下面计算EOCD区域的大小
//The algorithm for locating the ZIP EOCD record is as follows. We search backwards from //end of the buffer for the EOCD record signature. Whenever we find a signature, we check //the candidate record's comment length is such that the remainder of the record takes up //exactly the remaining bytes in the buffer. The search is bounded because the maximum //size of the comment field is 65535 bytes because the field is an unsigned 16-bit number.
//最大不超过16bit 这个没太懂是从哪里得到的
final long maxCommentLength = Math.min(archiveSize -ZIP_EOCD_REC_MIN_SIZE, UINT16_MAX_VALUE); final long eocdWithEmptyCommentStartPosition = archiveSize -ZIP_EOCD_REC_MIN_SIZE; for (int expectedCommentLength = 0; expectedCommentLength <=maxCommentLength; expectedCommentLength++) { final long eocdStartPos = eocdWithEmptyCommentStartPosition -expectedCommentLength; final ByteBuffer byteBuffer = ByteBuffer.allocate(4); fileChannel.position(eocdStartPos); fileChannel.read(byteBuffer); byteBuffer.order(ByteOrder.LITTLE_ENDIAN);        //这个循环就很简单了,0x06054b50不断去找EOCD的魔术,找到了他的位置就是EOCD的起始位置 if (byteBuffer.getInt(0) ==ZIP_EOCD_REC_SIG) { final ByteBuffer commentLengthByteBuffer = ByteBuffer.allocate(2); fileChannel.position(eocdStartPos +ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET); fileChannel.read(commentLengthByteBuffer); commentLengthByteBuffer.order(ByteOrder.LITTLE_ENDIAN);           //这里找到起始位置后,就可以知道我们 EOCD的实际大小了 根据上面那个记录 final int actualCommentLength = commentLengthByteBuffer.getShort(0); if (actualCommentLength ==expectedCommentLength) { returnactualCommentLength; } } } throw new IOException("ZIP End of Central Directory (EOCD) record not found"); }   //找到CentralDir 的起始位置,第二步 public static long findCentralDirStartOffset(final FileChannel fileChannel) throwsIOException {
//这里需要获取ECOD区域的大小,通过apk大小减去这一部分的大小
returnfindCentralDirStartOffset(fileChannel, getCommentLength(fileChannel)); }
//这里是通过获取到的EOCD区域的大小去计算获取CentralDir的偏移量,第五步
public static long findCentralDirStartOffset(final FileChannel fileChannel, final long commentLength) throwsIOException { //End of central directory record (EOCD) //Offset Bytes Description[23] //0 4 End of central directory signature = 0x06054b50 //4 2 Number of this disk //6 2 Disk where central directory starts //8 2 Number of central directory records on this disk //10 2 Total number of central directory records //12 4 Size of central directory (bytes) //16 4 Offset of start of central directory, relative to start of archive //20 2 Comment length (n) //22 n Comment //For a zip with no archive comment, the //end-of-central-directory record will be 22 bytes long, so //we expect to find the EOCD marker 22 bytes from the end. final ByteBuffer zipCentralDirectoryStart = ByteBuffer.allocate(4); zipCentralDirectoryStart.order(ByteOrder.LITTLE_ENDIAN);
//这块就很清楚了 apk大小减去comment大小,commenlength大小,CDIR偏移量大小,就是偏移量的起始位置,读一下就可以了。 fileChannel.position(fileChannel.size()
- commentLength - 6); //6 = 2 (Comment length) + 4 (Offset of start of central directory, relative to start of archive) fileChannel.read(zipCentralDirectoryStart); final long centralDirStartOffset = zipCentralDirectoryStart.getInt(0); returncentralDirStartOffset; }   //我们要找到我们的签名块 这是第一步 public static Pair<ByteBuffer, Long>findApkSigningBlock( final FileChannel fileChannel) throwsIOException, SignatureNotFoundException { final long centralDirOffset =findCentralDirStartOffset(fileChannel); returnfindApkSigningBlock(fileChannel, centralDirOffset); }   //第六步,通过获取到的Central Dir偏移地址去找签名块 public static Pair<ByteBuffer, Long>findApkSigningBlock( final FileChannel fileChannel, final long centralDirOffset) throwsIOException, SignatureNotFoundException {      //CDIR的结构 //Find the APK Signing Block. The block immediately precedes the Central Directory. //FORMAT: //OFFSET DATA TYPE DESCRIPTION //* @+0 bytes uint64: size in bytes (excluding this field) //* @+8 bytes payload //* @-24 bytes uint64: size in bytes (same as the one above) //* @-16 bytes uint128: magic if (centralDirOffset <APK_SIG_BLOCK_MIN_SIZE) { throw newSignatureNotFoundException( "APK too small for APK Signing Block. ZIP Central Directory offset: " +centralDirOffset); } //Read the magic and offset in file from the footer section of the block: //* uint64: size of block //* 16 bytes: magic
//看图一
fileChannel.position(centralDirOffset - 24); final ByteBuffer footer = ByteBuffer.allocate(24); fileChannel.read(footer); footer.order(ByteOrder.LITTLE_ENDIAN); if ((footer.getLong(8) !=APK_SIG_BLOCK_MAGIC_LO) || (footer.getLong(16) !=APK_SIG_BLOCK_MAGIC_HI)) { throw newSignatureNotFoundException( "No APK Signing Block before ZIP Central Directory"); } //Read and compare size fields final long apkSigBlockSizeInFooter = footer.getLong(0); if ((apkSigBlockSizeInFooter <footer.capacity()) || (apkSigBlockSizeInFooter > Integer.MAX_VALUE - 8)) { throw newSignatureNotFoundException( "APK Signing Block size out of range: " +apkSigBlockSizeInFooter); } final int totalSize = (int) (apkSigBlockSizeInFooter + 8);
//这是计算 签名块 前两处的 末尾偏移量
final long apkSigBlockOffset = centralDirOffset -totalSize; if (apkSigBlockOffset < 0) { throw newSignatureNotFoundException( "APK Signing Block offset out of range: " +apkSigBlockOffset); } fileChannel.position(apkSigBlockOffset);
//这块不是很懂 为什么能通过后两部分的大小计算出 签名块 前两个区域的内容 ,猜测是通过大小段 ,上面的注释应该是提示
final ByteBuffer apkSigBlock =ByteBuffer.allocate(totalSize); fileChannel.read(apkSigBlock); apkSigBlock.order(ByteOrder.LITTLE_ENDIAN); final long apkSigBlockSizeInHeader = apkSigBlock.getLong(0); if (apkSigBlockSizeInHeader !=apkSigBlockSizeInFooter) { throw newSignatureNotFoundException( "APK Signing Block sizes in header and footer do not match: " + apkSigBlockSizeInHeader + " vs " +apkSigBlockSizeInFooter); }
//这块就返回了存储有签名信息,渠道信息的块给前面去读
returnPair.of(apkSigBlock, apkSigBlockOffset); }
//这里就是获取签名块中的key和对应的value,基本上获取了这些,读取渠道信息基本没有任何问题了
public static Map<Integer, ByteBuffer> findIdValues(final ByteBuffer apkSigningBlock) throwsSignatureNotFoundException { checkByteOrderLittleEndian(apkSigningBlock); //FORMAT: //OFFSET DATA TYPE DESCRIPTION //* @+0 bytes uint64: size in bytes (excluding this field) //* @+8 bytes pairs //* @-24 bytes uint64: size in bytes (same as the one above) //* @-16 bytes uint128: magic
//这里是过滤apk签名块的中存储签名信息的key,value,起点在8----大小-24
final ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24); final Map<Integer, ByteBuffer> idValues = new LinkedHashMap<Integer, ByteBuffer>(); //keep order int entryCount = 0; while(pairs.hasRemaining()) { entryCount++; if (pairs.remaining() < 8) { throw newSignatureNotFoundException( "Insufficient data to read size of APK Signing Block entry #" +entryCount); }
//循环读 每次8个字节
final long lenLong =pairs.getLong(); if ((lenLong < 4) || (lenLong >Integer.MAX_VALUE)) { throw newSignatureNotFoundException( "APK Signing Block entry #" +entryCount + " size out of range: " +lenLong); } final int len = (int) lenLong; final int nextEntryPos = pairs.position() +len; if (len >pairs.remaining()) { throw newSignatureNotFoundException( "APK Signing Block entry #" + entryCount + " size out of range: " +len + ", available: " +pairs.remaining()); } final int id =pairs.getInt();
//4个字节的id和变长的value getByteBuffer需要根据调整大小 idValues.put(id, getByteBuffer(pairs, len
- 4)); pairs.position(nextEntryPos); }      //返回所有的id和value returnidValues; } /*** Returns new byte buffer whose content is a shared subsequence of this buffer's content * between the specified start (inclusive) and end (exclusive) positions. As opposed to * {@linkByteBuffer#slice()}, the returned buffer's byte order is the same as the source * buffer's byte order. */ private static ByteBuffer sliceFromTo(final ByteBuffer source, final int start, final intend) { if (start < 0) { throw new IllegalArgumentException("start: " +start); } if (end <start) { throw new IllegalArgumentException("end < start: " + end + " < " +start); } final int capacity =source.capacity(); if (end >source.capacity()) { throw new IllegalArgumentException("end > capacity: " + end + " > " +capacity); } final int originalLimit =source.limit(); final int originalPosition =source.position(); try{ source.position(0); source.limit(end); source.position(start); final ByteBuffer result =source.slice(); result.order(source.order()); returnresult; } finally{ source.position(0); source.limit(originalLimit); source.position(originalPosition); } } /*** Relative <em>get</em> method for reading {@codesize} number of bytes from the current * position of this buffer. * <p> * <p>This method reads the next {@codesize} bytes at this buffer's current position, * returning them as a {@codeByteBuffer} with start set to 0, limit and capacity set to * {@codesize}, byte order set to this buffer's byte order; and then increments the position by * {@codesize}. */ private static ByteBuffer getByteBuffer(final ByteBuffer source, final intsize) throwsBufferUnderflowException { if (size < 0) { throw new IllegalArgumentException("size: " +size); } final int originalLimit =source.limit(); final int position =source.position(); final int limit = position +size; if ((limit < position) || (limit >originalLimit)) { throw newBufferUnderflowException(); } source.limit(limit); try{ final ByteBuffer result =source.slice(); result.order(source.order()); source.position(limit); returnresult; } finally{ source.limit(originalLimit); } } private static void checkByteOrderLittleEndian(finalByteBuffer buffer) { if (buffer.order() !=ByteOrder.LITTLE_ENDIAN) { throw new IllegalArgumentException("ByteBuffer byte order must be little endian"); } } }
/*** https://source.android.com/security/apksigning/v2.html* https://en.wikipedia.org/wiki/Zip_(file_format)
 */
//写渠道与读类似
classApkSigningBlock { //The format of the APK Signing Block is as follows (all numeric fields are little-endian): //.size of block in bytes (excluding this field) (uint64) //.Sequence of uint64-length-prefixed ID-value pairs: //*ID (uint32) //*value (variable-length: length of the pair - 4 bytes) //.size of block in bytes—same as the very first field (uint64) //.magic “APK Sig Block 42” (16 bytes) //FORMAT: //OFFSET DATA TYPE DESCRIPTION //* @+0 bytes uint64: size in bytes (excluding this field) //* @+8 bytes payload //* @-24 bytes uint64: size in bytes (same as the one above) //* @-16 bytes uint128: magic //payload 有 8字节的大小,4字节的ID,还有payload的内容组成 private final List<ApkSigningPayload>payloads; ApkSigningBlock() { super(); payloads = new ArrayList<ApkSigningPayload>(); } public final List<ApkSigningPayload>getPayloads() { returnpayloads; } public void addPayload(finalApkSigningPayload payload) { payloads.add(payload); }   //写渠道信息,这里的DataOutput是apk,这个输入流已经 定位到了 签名块的偏移位置 这块还是不明白为什么传的是apksign区域的末尾偏移地址 public long writeApkSigningBlock(final DataOutput dataOutput) throwsIOException { long length = 24; //24 = 8(size of block in bytes—same as the very first field (uint64)) + 16 (magic “APK Sig Block 42” (16 bytes))
     //这里计算你要写入的信息的大小 for (int index = 0; index < payloads.size(); ++index) { final ApkSigningPayload payload =payloads.get(index); final byte[] bytes =payload.getByteBuffer(); length += 12 + bytes.length; //12 = 8(uint64-length-prefixed) + 4 (ID (uint32)) } ByteBuffer byteBuffer = ByteBuffer.allocate(8); //Long.BYTES byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putLong(length); byteBuffer.flip(); dataOutput.write(byteBuffer.array()); //这个就是不断写入渠道所需要的信息了 for (int index = 0; index < payloads.size(); ++index) { final ApkSigningPayload payload =payloads.get(index);
//写value
final byte[] bytes =payload.getByteBuffer();        byteBuffer = ByteBuffer.allocate(8); //Long.BYTES byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putLong(bytes.length + (8 - 4)); //Long.BYTES - Integer.BYTES byteBuffer.flip(); dataOutput.write(byteBuffer.array());        //写key byteBuffer = ByteBuffer.allocate(4); //Integer.BYTES byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putInt(payload.getId()); byteBuffer.flip(); dataOutput.write(byteBuffer.array()); dataOutput.write(bytes); }     //这块是所有的信息写完后,你需要写大小和魔数 byteBuffer = ByteBuffer.allocate(8); //Long.BYTES byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putLong(length); byteBuffer.flip(); dataOutput.write(byteBuffer.array());      //写签名魔数 16个字节 byteBuffer = ByteBuffer.allocate(8); //Long.BYTES byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putLong(ApkUtil.APK_SIG_BLOCK_MAGIC_LO); byteBuffer.flip(); dataOutput.write(byteBuffer.array()); byteBuffer = ByteBuffer.allocate(8); //Long.BYTES byteBuffer.order(ByteOrder.LITTLE_ENDIAN); byteBuffer.putLong(ApkUtil.APK_SIG_BLOCK_MAGIC_HI); byteBuffer.flip(); dataOutput.write(byteBuffer.array()); returnlength; } }

以上是我对walle的个人分析,还有一些不懂的需要接下来再去深入了解

参考资料:

免责声明:文章转载自《多渠道打包工具Walle源码分析》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇CSS页面渲染优化属性will-changeLinux 安装 erlang 和 rabbitmq下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

C#使用BouncyCastle来实现私钥加密公钥解密的方法与java互通的RSA加解密和签名(转)

因为C#的RSA加密解密只有公钥加密,私钥解密,没有私钥加密,公钥解密。在网上查了很久也没有很好的实现。BouncyCastle的文档少之又少。很多人可能会说,C#也是可以的,通过Biginteger开源类来实现,不过那个是有一个文章,不过他加密出来的是16进制结果的。根本不能和JAVA互通。连加密出来的都不和C#原生的加密出来的结果格式一样。所以还是没有...

html 网页源码解析:bs4中BeautifulSoup

from bs4 importBeautifulSoup result=requests.request("get","http://www.baidu.com")result.encoding="utf-8"print(result.text)         #获取源码soup=BeautifulSoup(result.text,"html.parse...

Android Studio生成apk

1、菜单Build->Generate Signed APK 2、生成android.keystore,能够依据弹框去Create new一个,也可使用命令来生成android.keystore文件 如今就介绍下命令: 先进入Bin文件夹: cd /Applications/Android Studio.app/Contents/bin 执行例如以...

Django——Session源码分析

首先我们导入django.contrib.sessions.middleware这个中间件,查看里面的Session源码 from django.contrib.sessions.middleware import SessionMiddleware 我们可以看到一个类,可以把他分为3部分: class SessionMiddleware(Middlewa...

HashMap源码和并发异常问题分析

要点源码分析 HashMap允许键值对为null;HashTable则不允许,会报空指针异常; HashMap<String, String> map= new HashMap<>(2); map.put(null,null); map.put("1",null); Hash...

Kafka消费组(consumer group)

一、 误区澄清与概念明确 1 Kafka的版本 很多人在Kafka中国社区(替群主做个宣传,QQ号:162272557)提问时的开头经常是这样的:“我使用的kafka版本是2.10/2.11, 现在碰到一个奇怪的问题。。。。” 无意冒犯,但这里的2.10/2.11不是kafka的版本,而是编译kafka的Scala版本。Kafka的server端代码是由S...