Telegram 之 MTProtoKit 架构分析

上回我们对TeamTalk做了一个比较详细的架构分析,并得到了蘑菇街的官方回复,这种态度还是值得很多公司技术运营学习的。本打算对Telegram的Mac客户端也做一次架构分析的,但发现它的源码比较杂乱,里面混合很多有用没用的文件,另外它的抽象方式也是非常奇怪,继承体系也不是很合理,大致看了下,我就决定放弃了。而无意间却让我点开了它里面所依赖的一个Sub Module:MTProtoKit,我发现这个项目和Mac客户端项目完全是两个水准,所以,我决定单独把它拿出来做次分析。

首先,你应该要知道什么是Telegram,它和TeamTalk一样,都是开源的IM,百度搜索一下会有一些关于它的介绍。以下是和本篇博客有关的地址链接:

我说过,要想提高自身的设计能力,阅读开源项目是很好的途径。在分析别人源码的过程中,我们可以汲取到一些其它的思想,架构设计这条路是没有止境的,我们唯有不断学习,才能成为更好的自己!那么,我们开始本次的分析之旅吧!

MTProto 简介

既然我们要对这个MTProtoKit进行分析,我们首先应该要清楚,这个MTProto到底是个什么东西?在官网上不难看到,官方给出了以下定义:

The protocol is designed for access to a server API from applications running on mobile devices. It must be emphasized that a web browser is not such an application.

也就是说,MTProto是一个Telegram自定义的通讯协议,用于移动端App与服务器交互数据使用。这里还特别指出了,一个浏览器不能算是移动端App,所以这个协议不适用于HTML5之类的网页客户端。

现在我们大体知道了,MTProto是一种协议,那么MTProtoKit就是对该协议实现的封装,使得我们能够更方便的使用这样的协议。了解了这些,对我们接下来的分析会有很好的帮助。

宏观分析

拿到这样一个项目,如果一开始就拘泥于细节实现,那么很难快速的了解它的一些核心信息。任何软件都是从无到有,从细小变得庞大,所有复杂的架构都会有一个简单的核心原型,而后进行增量迭代。所以,我们只要找到它的核心原型,再站在设计者得角度进行考虑、分析,任何复杂的软件体系,都可以顺藤摸瓜的理清一条思路。

MTProtoKit复杂么?其实它并不复杂,但我觉得依然需要使用从整体到细节这样的方式来分析。首先,我们看看它的一个核心原型类图:

核心原型图

这个核心原型还是相当简单的,主要有以下几个抽象:

  • MTProto:这可能是最核心的一个类了,所以设计者给了它这样一个看起来就非常重要的名字(虽然我并不推荐这样命名)。它主要负责管理消息服务(MTMessageService),给予消息服务适当的运行上下文,并且负责传输由消息服务所构建的消息事务(MTMessageTransaction)。实话说,这个类设计得有点臃肿,以至于一眼看上去,你都不知道它是干什么的。
  • MTMessageService:消息服务,这是另外一个非常重要的抽象,它是一个协议,也就是大多数高级语言里面的接口。这个协议是MTProto的一个Observer,也就是说,它会监听MTProto所有发布的事件,以此来控制自身实现逻辑。
  • MTMessageTransaction:消息事务,这是非常高级的一个特性,它内部保持了一系列需要发送的消息,并提供了一个所有消息发送完毕的回调。

最核心的应该也就这三个类了,MTMessageService是暴露给最终使用者的,使用者用它发送消息,而它在内部将要发送的消息按照服务的特定实现构建成MTMessageTransaction,然后请求MTProto将这个MTMessageTransaction进行传输,并且监听MTProto收到消息的回调,来进一步完成一整个消息处理流程。下面是一个通用消息处理序列图:

消息处理序列图

通过这样一个大体的分析,我们应该能够清楚怎样继续来寻根究底了。那么,我们接下来就将这个原型展开,进行一个比较全面的分析。

消息服务实现

消息服务,它提供了处理和消息相关的一组方法,比如我们发送一个RPC(远程过程调用)请求。这里使用MessageService来作为抽象名称,我觉得是因为比较容易进行泛化,特定性约束不强,只要和消息相关的操作,都可以以此来展开实现。因为MTProto的定义是一个非常强大的类,它能给所有消息相关操作提供它们想要的任何支持,这样的定义好坏是显而易见的。在目前的MTProtoKit中,大致提供了以下几个消息服务:

MTTimeSyncMessageService - 时间同步

在telegram的协议中,每个消息标识都附带了系统时间信息,收到的消息标识符中是服务端时间,而发送的消息标识符中是客户端时间。客户端生成消息标识符的算法,在MTSessionInfo中如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (int64_t)generateClientMessageId:(bool *)monotonityViolated
{
int64_t messageId = (int64_t)([_context globalTime] * 4294967296);

if (messageId < _lastClientMessageId)
{
if (monotonityViolated != NULL)
*monotonityViolated = true;
}

if (messageId == _lastClientMessageId)
messageId = _lastClientMessageId + 1;

while (messageId % 4 != 0)
messageId++;

_lastClientMessageId = messageId;
return messageId;
}

当服务端意识到和客户端时间相差较大,则会忽略掉客户端发送来的消息,而这个服务便是用来和服务端时间进行校准。实现逻辑也比较简单,它会主动向服务端发送若干个消息进行时间采样,最终去除相差最小和相差最大的两个采样来求平均值。

这个时间同步服务,是直接由MTProto调用的(requestTimeResync),所以这里的逻辑依赖关系有点紊乱,我们使用类图梳理一下:

同步消息类图

这里有个明显的互相依赖,MTTimeSyncMessageServiceMTProto的观察者,并使用了它的相应方法;MTProto亦是MTTimeSyncMessageService的观察者,也使用了它的相应方法。从类设计的角度来说,这样的耦合状态是我们不愿意看到的,因为它提高了以后的维护成本。这里之所以这么设计,是因为同步服务必须依赖于MTProto提供的强有力后盾,但MTProto又必须要确保消息时间的准确性,于是乎就造成了这样的格局,但,肯定是可以通过改变结构来解除这样的紊乱。

MTRequestMessageService - RPC 请求和响应

这是一个使用非常频繁的服务,主要是用来向服务端发送RPC请求,并负责处理超时、错误、回执等。从它这么多的职责中,就可以看出它会是一个比较巨大的类,一般人是难以Hold住的,但将职责拆分细化又会增加结构上的复杂度,所以这是我们在进行设计的时候需要权衡的。

这里完整消息处理的复杂度还是蛮高的,因为有很多处理细节,要想完整阐述清楚也不容易。所以,还是很佩服telegram这样严谨的协议设计,使得MTProto能适应很多不同场合,相比之下,我所使用或设计过的协议,大多都逊色很多。这里,我只列出一些比较关键的处理点,更多细节,有兴趣的自己去深挖吧!

  1. 消息打包

    这是一个比较有用的特性,每一个MTRequest都会携带一个它需要发送的消息数据,然后添加到RPC服务(MTRequestMessageService)中,此时RPC服务会请求MTProto进行事务传输,但MTProto需要进行一些另外的准备和检验操作,所以可能会晚点才能向RPC服务要求构建事务,这时候RPC服务中可能会积累多个MTRequest,于是在构建事务的时候,事务的payload里就会有多个消息。同理,MTProto在请求真正的向外传输时,又有可能会积累多个需要传输的事务,因为底层传输支持也需要做一些其他额外的处理。

    针对上诉情况,telegram的MTProto中有一个消息容器的概念,它可以将多个消息放置到一个容器里,一同发送到服务器,服务器亦会对消息容器里需要响应的消息进行打包响应。这样就减少了网络传输的次数,也提高了响应的及时性(减少了排队请求的可能性)。

  2. 依赖处理

    针对上诉的打包特性,它隐性的引入了一个问题,也就是时序问题,有些消息是必须在某些消息前得到处理的。所以,telegram增加了消息依赖的特性,它可以指定某个消息必须在另外一个消息前得到执行,这会对并发处理的服务端有很好的提示,但必然的增加了客户端实现的复杂度。

  3. 超时管理

    由于消息可能会被打包处理,所以在超时管理上亦会跟一般超时处理不同,首先会在真的进入发送阶段前进行检测,其次是在收到响应时再做检测。值得一提的是,这里超时时钟使用的是MTAbsoluteTime,它是一个取CPU频率计算的高精度时钟。

  4. 错误处理

    除了响应的RPCError之外,MTProto在对消息进行标识符编码的时候,还会检查标识符的唯一性,因为标识符和系统时间息息相关,所以如果小于上个消息标识符,则说明唯一性被破坏了,亦说明了系统时间有问题。发生这样的情况,MTProto会重置当前的Session,并进行时间同步,也就是使用了MTTimeSyncMessageService。而这样的消息,会在本次传输中被抛弃掉,切换完Session后,才会继续发送。

MTResendMessageService - 消息重传

这算是MTProto比较有特性的另一个服务,所以,telegram对这个协议的确是花费了不少心思,也不枉费我把这个周末的时间花在了这上面。这里的消息重传,并不是指客户端发送消息出现错误而进行后续的重新请求,而是指当客户端向服务器发出RPC请求后,服务端检测到这是一个重复的请求(消息标识符相同),如果响应内容较小,服务端会直接返回结果,而如果响应内容较大,此时服务端会回馈一个MTMsgDetailedResponseInfoMessage,如果想要取得相应结果,则需要使用该服务,将请求消息标识符重新发送到服务器。

这个服务和时间同步服务一样,是由MTProto直接使用的,涉及到的核心代码如下:

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
- (void)_processIncomingMessage:(MTIncomingMessage *)incomingMessage withTransactionId:(id)transactionId {
// ... 略
if (shouldRequest) {
[self requestMessageWithId:detailedInfoMessage.responseMessageId];
MTLog(@"[MTProto#%p will request message %" PRId64 "", self, detailedInfoMessage.responseMessageId);
} else {
[_sessionInfo scheduleMessageConfirmation:detailedInfoMessage.responseMessageId size:(NSInteger)detailedInfoMessage.responseLength];
[self requestTransportTransaction];
}
// ... 略
}

- (void)requestMessageWithId:(int64_t)messageId {
bool alreadyRequestingThisMessage = false;

for (id<MTMessageService> messageService in _messageServices) {
if ([messageService isKindOfClass:[MTResendMessageService class]]) {
if (((MTResendMessageService *)messageService).messageId == messageId) {
alreadyRequestingThisMessage = true;

break;
}
}
}

if (!alreadyRequestingThisMessage && ![_sessionInfo messageProcessed:messageId]) {
MTResendMessageService *resendService = [[MTResendMessageService alloc] initWithMessageId:messageId];
resendService.delegate = self;
[self addMessageService:resendService];
}
}

MTDatacenterAuthMessageService - 数据中心授权

这也是一个非常重要的服务,它和用户授权息息相关,首先我们要清楚什么是DataCenter,也就是数据中心。可以简单的把一个数据中心就当成一台完整的服务器,我们可以对它进行发送任何合理的请求。telegram的数据中心遍布在全球各地,而它们之间的数据同步是对客户端透明的,客户端要做的就是选择一个最适合自身的数据中心。数据中心地址的查找,在MTProtoKit中被封装在了MTDiscoverDatacenterAddressAction中,而后由全局上下文MTContext进行调用。

那么这个授权服务,它所做的便是向特定的数据中心发出授权请求,完成一个授权的全过程。整个授权的加密过程都在这个服务中体现出来,telegram号称它是非常安全的IM,从这里的源码中可以看出,它的确没有撒谎。它采用的是基于nonce的一个认证体质,在安全领域中,nonce是指在一个特定的上下文中,仅仅只被使用一次的数。通过使用nonce,我们可以防御Replay attack(回放攻击)和Chosen-Plaintext attack(选择明文攻击)。关于安全相关的知识,我这里就不展开了,有兴趣的同学可以自己去查阅相关资料。telegram同时使用了客户端nonce和服务端nonce,并且加入了DH值校验,所以安全程度是非常高的。大体流程如下图:

数据中心认证大体时序

在这个服务类的具体实现里,很容易可以看来,它是一个状态机,随着授权环节的推进,当前状态进行相应的推进。而使用这个服务的,是另一个封装类MTDatacenterAuthAction,和上面说过的那个数据中心查找类类似,它们都采用了Command模式进行设计,也都是由全局上下文进行管理、调用。

MTTransport - 数据传输

这是最后一个实现了MTService的类,也是所有数据传输的基础服务,它的主要职责即是传输和接受数据,并且还监听网络可用性变化。这算得上是一个抽象类,它有两个子类实现MTTcpTransportMTHttpTransport,很显然,是基于特定协议的实现。

MTTransport的设计也稍显复杂,虽然它是由MTProto直接使用的,但却是由全局上下文进行统一管理。在MTTransport之上还有另一个更高层级的抽象MTTransportScheme,这个类是用来描述一种特定的传输格式,并且可以根据这个特定的格式构建出合适的MTTransport。而确定这种传输格式,是通过另一Command类MTDiscoverTransportSchemeAction,它可以发现某个数据中心支持的传输格式,并可以挑选出最优的结果。

这里的实现细节,就不一一展开了,大体清楚了他们的意图,我觉得也就够了。

内置消息简析

在MTProtoKit中,有很多内置的消息,定义在Serialization目录下,这些基本都是和协议相关的PONSO。除了这些消息之外,还有和解析这些消息相关的类,比如MTBufferReaderMTInternalMessageParser,这些都是用来对这些内置消息进行解析用的。除了内置消息,还应该有很多业务相关性的消息,而这些消息都不在MTProtoKit考虑之中,MTProtoKit将其它消息的序列化由MTSerialization协议委托给了使用者去实现,这样做还是很合理的,因为MTProto是一个非常动态的协议,扩展性非常强。

内置消息其实就像编程语言给我提供的标准库一样,它是框架,也是基础。下面我简单选取一些消息做个介绍:

全局上下文

所谓的全局上下文,也就是MTContext类,这是一个使用相当频繁的类,它的主要意图是用来给MTProtoKit中,其它类提供一个公共的运行上下文,也相当于是整个MTProtoKit的入口点。所以,一些公共的状态和方法都会被提升到这个类中,它大体记录了以下信息:

  1. 客户端运行环境,即MTApiEnvironment
  2. 非内置消息的序列化器实现,即MTSerialization协议的实现。
  3. 客户端时间,并允许设定偏差来校准。
  4. 当前用户的授权相关信息和操作。
  5. 数据中心的相关信息和操作。
  6. 传输格式(MTTransportScheme)的相关信息和操作。

这个全局上下文的实现,倒是没有太多可以展开的,有兴趣深入研究的,可以自行去观看。

其它细节

现在,我们再来看看一些比较有意思的细节处理。首先是MTBuffer类中,字节对齐的算法实现:

1
2
3
4
static inline int roundUp(int numToRound, int multiple)
{
return multiple == 0 ? numToRound : ((numToRound % multiple) == 0 ? numToRound : (numToRound + multiple - (numToRound % multiple)));
}

很简单的算法,但我觉得很有意思,使用roundUp(17, 4),则会得到17按照4向上对齐的结果,也就是20。那么,怎么实现roundDown呢?这么简单的问题,大家自己动手去实现吧!

MTBuffer中,还有一个方法,也就是追加TL字节,我们来看一下:

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
- (void)appendTLBytes:(NSData *)bytes {
int32_t length = (int32_t)bytes.length;

if (bytes == nil || length == 0) {
[self appendInt32:0];
return;
}

int paddingBytes = 0;

if (length >= 254) {
uint8_t tmp = 254;
[self appendBytes:&tmp length:1];

[self appendBytes:(const uint8_t *)&length length:3];

paddingBytes = roundUp(length, 4) - length;
} else {
[self appendBytes:(const uint8_t *)&length length:1];
paddingBytes = roundUp(length + 1, 4) - (length + 1);
}

[self appendBytes:bytes.bytes length:length];

uint8_t tmp = 0;
for (int i = 0; i < paddingBytes; i++)
[self appendBytes:&tmp length:1];
}

当字节长度大于或等于254时,这样的处理是不是有点让人困惑呢?其实是这样的,当这个块的长度小于254时,第一个字节就是用来标识内容的长度;而当这个块的长度大于或等于254时,第一个字节只是一个标志,后面3个字节才是真正的长度(考虑一下大小端的问题哦),所以,每个块的最大长度是24位值,而不是32位。这样做,长度值所占用的字节就可以被压缩了,大家再考虑下,为什么要按4字节进行对齐呢?优化内存布局?

再来看一个MTInternalMessageParser中的decompressGZip方法,因为消息是可以放置在gzip容器中进行传输的,所以客户端需要解压字节流。

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
+ (NSData *)decompressGZip:(NSData *)data {
const int kMemoryChunkSize = 1024;

NSUInteger length = [data length];
int windowBits = 15 + 32; //Default + gzip header instead of zlib header
int retCode;
unsigned char output[kMemoryChunkSize];
uInt gotBack;
NSMutableData *result;
z_stream stream;

if ((length == 0) || (length > UINT_MAX)) //FIXME: Support 64 bit inputs
return nil;

bzero(&stream, sizeof(z_stream));
stream.avail_in = (uInt)length;
stream.next_in = (unsigned char*)[data bytes];

retCode = inflateInit2(&stream, windowBits);
if(retCode != Z_OK) {
NSLog(@"%s: inflateInit2() failed with error %i", __PRETTY_FUNCTION__, retCode);
return nil;
}

result = [NSMutableData dataWithCapacity:(length * 4)];
do {
stream.avail_out = kMemoryChunkSize;
stream.next_out = output;
retCode = inflate(&stream, Z_NO_FLUSH);
if ((retCode != Z_OK) && (retCode != Z_STREAM_END))
{
NSLog(@"%s: inflate() failed with error %i", __PRETTY_FUNCTION__, retCode);
inflateEnd(&stream);
return nil;
}
gotBack = kMemoryChunkSize - stream.avail_out;
if (gotBack > 0)
[result appendBytes:output length:gotBack];
} while( retCode == Z_OK);
inflateEnd(&stream);

return (retCode == Z_STREAM_END ? result : nil);
}

再看精确时钟的实现:

1
2
3
4
5
6
7
8
MTAbsoluteTime MTAbsoluteSystemTime()
{
static mach_timebase_info_data_t s_timebase_info;
if (s_timebase_info.denom == 0)
mach_timebase_info(&s_timebase_info);

return ((MTAbsoluteTime)(mach_absolute_time() * s_timebase_info.numer)) / (s_timebase_info.denom * NSEC_PER_SEC);
}

好了,这些都是可以在我们设计网络通讯协议的时候拿来借鉴的,他山之石,可以攻玉啊!学习,才能成为更好的自己啊!

总结

本次分析,只能算是比较粗略的将核心内容过了一遍,由于时间和精力问题,并没有分析的非常透彻,但,收获依然很多。telegram在协议设计上面,还是做到了非常专业,细节处理得也都非常完善,这些都是值得我们去深思和探讨的,毕竟网络通讯中异常杂多,而细节终会决定成败。

MTProto协议里,还有一块非常重要的部分,也就是用来描述该协议的TL Language,这也是非常有意思的设计。如果不出意外,下一篇博文中,我会来简略的谈谈这个TL Language,借此,我们可以看一看,这样一个可扩展的协议它的元数据是如何描述的,并更能体会到这个Protocol的强大!

那么,拭目以待吧!