引言html
随着公司业务的须要,原来的推送,不能知足现有的要求,好比,有的公司须要局域网部署平台服务,不能访问外网,意味着,经过自带的 APNS远程推送服务,基本上就挂掉了;再好比,公司的某一个业务,须要实时刷新数据,如股票价格,报警实时监测数据等这种场景。这时候,就须要另辟蹊径;固然,咱们有不少种选择,好比,RabbitMQ-实现了高级消息队列协议的开源消息代理软件,为啥要用这个呢,由于Android已经用这个作好了,因此,iOS 也没有其余选择了;ios
在集成客户端的时候,咱们有一些概念须要知道,如消息队列,生产者,消费者,交换器,队列,通道等;git
RabbitMQ 本质上是 MB(Message Broker) 消息代理,能够这么认为,RabbitMQ就像一个 “邮局”,负责收发存储邮包(消息),惟一与邮局不一样的是,它不会处理消息内容github
AMQP,即Advanced Message Queuing Protocol,高级消息队列协议,是应用层协议的一个开放标准;相似Http,https,STMP这种,咱们只须要知道,他是一个协议,实际开发中会遇到,底层实现咱们不用管;web
RabbitMQ 是一个由 Erlang 语言开发的 AMQP 的开源实现。面试
支持多种客户端,如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP,Object-C, Swift等,支持AJAX。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。json
RabbitMQ 使用一些机制来保证可靠性,如持久化、传输确认、发布确认。浏览器
在消息进入队列以前,经过 Exchange 来路由消息的。对于典型的路由功能,RabbitMQ 已经提供了一些内置的 Exchange 来实现。针对更复杂的路由功能,能够将多个 Exchange 绑定在一块儿,也经过插件机制实现本身的 Exchange 。bash
多个 RabbitMQ 服务器能够组成一个集群,造成一个逻辑 Broker 。服务器
队列能够在集群中的机器上进行镜像,使得在部分节点出问题的状况下队列仍然可用。
RabbitMQ 支持多种消息队列协议,好比 STOMP、MQTT 等等。
RabbitMQ 几乎支持全部经常使用语言,好比 Java、.NET、Ruby, Object-C等等。
RabbitMQ 提供了一个易用的用户界面,使得用户能够监控和管理消息 Broker 的许多方面。
若是消息异常,RabbitMQ 提供了消息跟踪机制,使用者能够找出发生了什么。
RabbitMQ 提供了许多插件,来从多方面进行扩展,也能够编写本身的插件。
全部 MQ 产品从模型抽象上来讲都是同样的过程:
消费者(consumer)订阅某个队列。生产者(producer)建立消息,而后发布到队列(queue)中,最后将消息发送到监听的消费者。
上面介绍RabbitMQ是AMQP的实现,因此其内部也是AMQP的概念
发送消息的程序或者代码就是生产者,通常是 服务端
交换器,用来接收生产者发送的消息并将这些消息路由给服务器中的队列.生产者把消息发布到 Exchange 上,消息最终到达队列并被消费者接收,而 Binding 决定交换器的消息应该发送到那个队列。
生产者在将消息发送给Exchange的时候,通常会指定一个routing key,来指定这个消息的路由规则,而这个routing key须要与Exchange Type及binding key联合使用才能最终生效。
在Exchange Type与binding key固定的状况下(在正常使用时通常这些内容都是固定配置好的),咱们的生产者就能够在发送消息给Exchange时,经过指定routing key来决定消息流向哪里。
RabbitMQ为routing key设定的长度限制为255 bytes。
此外,这个 Key 值,通常是服务端跟客户端约定好的,能够是组织ID,也能够用ID
绑定,用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列链接起来的路由规则,因此能够将交换器理解成一个由绑定构成的路由表。
消息队列,用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者链接到这个队列将其取走。
网络链接,好比一个TCP链接,RabbitMQ的OC CLient就是封装了 开源的TCP实现CocoaAsyncSocket
信道,多路复用链接中的一条独立的双向数据流通道。信道是创建在真实的TCP链接内地虚拟链接,AMQP 命令都是经过信道发出去的,不论是发布消息、订阅队列仍是接收消息,这些动做都是经过信道完成。由于对于操做系统来讲创建和销毁 TCP 都是很是昂贵的开销,因此引入了信道的概念,以复用一条 TCP 链接。
订阅某个队列,消息的接受者
生产者把消息发布到 Exchange 上,消息最终到达队列并被消费者接收,而 Binding 决定交换器的消息应该发送到那个队列。
在绑定(Binding)Exchange与Queue的同时,通常会指定一个binding key;生产者将消息发送给Exchange时,通常会指定一个routing key;当binding key与routing key相匹配时,消息将会被路由到对应的Queue中。
在绑定多个Queue到同一个Exchange的时候,这些Binding容许使用相同的binding key。
binding key 并非在全部状况下都生效,它依赖于Exchange Type,好比fanout类型的Exchange就会无视binding key,而是将消息路由到全部绑定到该Exchange的Queue。
RabbitMQ经常使用的Exchange Type有==fanout==、==direct==、==topic==、==headers==这四种(AMQP规范里还提到两种Exchange Type,分别为system与自定义,这里不予以描述),headers 匹配 AMQP 消息的 header 而不是路由键,此外 headers 交换器和 direct 交换器彻底一致,但性能差不少,目前几乎用不到了,因此直接看另外三种类型:
direct类型的Exchange路由规则也很简单,它会把消息路由到那些binding key与routing key彻底匹配的Queue中。
相关代码以下:
/** 建立信道 */
id<RMQChannel> ch = [_conn createChannel];
/** 建立交换器,direct方法,这个须要跟后台保持一致,包括交换器的名称,也要跟后台约定好,还有配置选项,是否持久化等 */
RMQExchange *x = [ch direct:@"Mobile_Alarm" options:(RMQExchangeDeclareNoOptions)];
/** 建立队列,若是不指定队列名称,MQ会默认自动建立一个队列,前缀以 `rmq-objc-client.gen-` 开头 */
RMQQueue *q = [ch queue:@"" options:RMQQueueDeclareExclusive | RMQQueueDeclareAutoDelete];
/** 队列绑定交换器,并指定 rountKey,须要跟后台指定相同的规则,能够是用户ID等 */
[q bind:x routingKey:@"5"];
复制代码
fanout
这种类型的Exchange,就是群发,此exchange的路由规则很简单直接将消息路由到全部绑定的队列中,无须对消息的routingkey进行匹配操做。
/** 须要注意:<RMQConnectionDelegate> 若是建立链接,指定代理是 当前class,那么当前class须要遵照链接的代理协议,并实现相关代理方法*/
/** 建立链接 */
RMQConnection *conn = [[RMQConnection alloc] initWithUri:url5 delegate:self];
[conn start];
/** 建立信道 */
id<RMQChannel> ch = [conn createChannel];
/** 建立交换器 */
RMQExchange *x = [ch fanout:@"Alarm"];
RMQQueue *q = [ch queue:@"" options:RMQQueueDeclareExclusive];
/** 绑定交换器 */
[q bind:x];
/** 订阅消息 */
[q subscribe:^(RMQMessage * _Nonnull message) {
NSLog(@"Received %@", [[NSString alloc] initWithData:message.body encoding:NSUTF8StringEncoding]);
}];
///socket 链接失败回调,超时或者地址有误
- (void)connection:(RMQConnection *)connection failedToConnectWithError:(NSError *)error;
/// 没有链接成功回调
- (void)connection:(RMQConnection *)connection disconnectedWithError:(NSError *)error;
/// 自动恢复链接调用
- (void)willStartRecoveryWithConnection:(RMQConnection *)connection;
/// 正在开始恢复链接
- (void)startingRecoveryWithConnection:(RMQConnection *)connection;
/// 已经恢复链接的时候,调用
- (void)recoveredConnection:(RMQConnection *)connection;
/// 链接过程当中,信道异常调用
- (void)channel:(id<RMQChannel>)channel error:(NSError *)error;
复制代码
前面讲到direct类型的Exchange路由规则是彻底匹配binding key与routing key,但这种严格的匹配方式在不少状况下不能知足实际业务需求。topic类型的Exchange在匹配规则上进行了扩展,它与direct类型的Exchage类似,也是将消息路由到binding key与routing key相匹配的Queue中,但这里的匹配规则有些不一样,它约定:
routing key为一个句点号“. ”分隔的字符串(咱们将被句点号“. ”分隔开的每一段独立的字符串称为一个单词),如“device.userId”、“alarm.type”,
binding key与routing key同样也是句点号“. ”分隔的字符串
binding key中能够存在两种特殊字符“”与“#”,用于作模糊匹配,其中“”用于匹配一个单词,“#”用于匹配多个单词(能够是零个)
RMQConnection * connection = [[RMQConnection alloc] initWithUri:url delegate:[RMQConnectionDelegateLogger new]];
[connection start];
id<RMQChannel>channel = [connection createChannel];
RMQExchange * exchange = [channel topic:@"topic_logs" options:RMQExchangeDeclarePassive];
[exchange publish:finalData routingKey:[NSString stringWithFormat:@"device.%@",didStr]];
[connection close];
复制代码
以上图中的配置为例,routingKey=”dev.alarm.device”的消息会同时路由到QA与QB,routingKey=”dev.alarm.type”的消息会路由到QA,routingKey=”lazy.brown.fox”的消息会路由到Q2,routingKey=”water.type.ID”的消息会路由到QB;routingKey=”device.user.water”、routingKey=”alarmtype”的消息将会被丢弃,由于它们没有匹配任何bindingKey。
pod 'RMQClient'
复制代码
注意:
RabbitMQ pod 内部有 CocoaAsyncSocket,若是项目中,已经集成了,须要删除多余的
考虑到客户端架构的特殊性,须要将MQ消息订阅封装成一个工具,并配置为 单例 模式。
导入头文件 #import <RMQClient.h>
LXCustomRabbitMQManger.h
//开始订阅消息
- (void)startScribeMessage;
//关闭链接
- (void)closeConnection;
复制代码
LXCustomRabbitMQManger.m
@interface LXCustomRabbitMQManger()<RMQConnectionDelegate>
@property (nonatomic,strong) RMQConnection *conn;
@property (nonatomic,strong) LXCustomAlarmModel *alarmModel;
@end
- (void)startScribeMessage {
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(activeNotification:) name:UIApplicationDidBecomeActiveNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(backgroundNotification:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}
- (void)closeConnection {
if (_conn) {
[self.conn close];
self.conn = nil;
}
}
//建立本地推送
- (void)registerNotification:(NSInteger )alerTime {
kweakSelf;
kStrongSelf;
// 使用 UNUserNotificationCenter 来管理通知
if (@available(iOS 10.0, *)) {
// 使用 UNUserNotificationCenter 来管理通知
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
//需建立一个包含待通知内容的 UNMutableNotificationContent 对象,注意不是 UNNotificationContent ,此对象为不可变对象。
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.title = [NSString localizedUserNotificationStringForKey:self.alarmModel.alarm_type arguments:nil];
content.body = [NSString localizedUserNotificationStringForKey:self.alarmModel.geography
arguments:nil];
content.sound = [UNNotificationSound defaultSound];
// 在 alertTime 后推送本地推送
UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger
triggerWithTimeInterval:alerTime repeats:NO];
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"FiveSecond"
content:content trigger:trigger];
//添加推送成功后的处理!
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
/* DQAlertView *reservationAlert = [[DQAlertView alloc] initWithTitle:self.alarmModel.alarm_type message:self.alarmModel.geography delegate:self cancelButtonTitle:@"取消" otherButtonTitles:@"肯定"]; reservationAlert.shouldDimBackgroundWhenShowInView = YES; reservationAlert.shouldDismissOnOutsideTapped = YES; [reservationAlert show]; [reservationAlert actionWithBlocksCancelButtonHandler:^{ } otherButtonHandler:^{ //跳转 LXDeviceAlarmModel *alarmIDModel = [[LXDeviceAlarmModel alloc] init]; alarmIDModel.ID = strongSelf.alarmModel.ID; LXDeviceDetailController *deviceDetailVC = [[LXDeviceDetailController alloc] init]; deviceDetailVC.deviceDealType = KearlyNoDealType; deviceDetailVC.deviceAlarm = alarmIDModel; [[self getCurrentVC].navigationController pushViewController:deviceDetailVC animated:YES]; }]; */
UIAlertController *alert = [UIAlertController alertControllerWithTitle:self.alarmModel.alarm_type message:self.alarmModel.geography preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:nil];
UIAlertAction *confirmAction = [UIAlertAction actionWithTitle:@"肯定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
LXDeviceAlarmModel *alarmIDModel = [[LXDeviceAlarmModel alloc] init];
alarmIDModel.ID = strongSelf.alarmModel.ID;
LXDeviceDetailController *deviceDetailVC = [[LXDeviceDetailController alloc] init];
deviceDetailVC.deviceDealType = KearlyNoDealType;
deviceDetailVC.deviceAlarm = alarmIDModel;
[[self getCurrentVC].navigationController pushViewController:deviceDetailVC animated:YES];
}];
[alert addAction:cancelAction];
[alert addAction:confirmAction];
[[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:alert animated:YES completion:nil];
}];
}
else {
UILocalNotification *notification = [[UILocalNotification alloc] init];
// 设置触发通知的时间
NSDate *fireDate = [NSDate dateWithTimeIntervalSinceNow:1];
NSLog(@"fireDate=%@",fireDate);
notification.fireDate = fireDate;
// 时区
notification.timeZone = [NSTimeZone defaultTimeZone];
// 设置重复的间隔
notification.repeatInterval = kCFCalendarUnitSecond;
// 通知内容
notification.alertBody = self.alarmModel.alarm_type;
notification.applicationIconBadgeNumber = 1;
// 通知被触发时播放的声音
notification.soundName = UILocalNotificationDefaultSoundName;
// 通知参数
NSDictionary *userDict = [NSDictionary dictionaryWithObject:self.alarmModel.geography forKey:@"key"];
notification.userInfo = userDict;
// ios8后,须要添加这个注册,才能获得受权
if ([[UIApplication sharedApplication] respondsToSelector:@selector(registerUserNotificationSettings:)]) {
UIUserNotificationType type = UIUserNotificationTypeAlert | UIUserNotificationTypeBadge | UIUserNotificationTypeSound;
UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:type
categories:nil];
[[UIApplication sharedApplication] registerUserNotificationSettings:settings];
// 通知重复提示的单位,能够是天、周、月
notification.repeatInterval = NSCalendarUnitDay;
} else {
// 通知重复提示的单位,能够是天、周、月
notification.repeatInterval = NSDayCalendarUnit;
}
// 执行通知注册
[[UIApplication sharedApplication] scheduleLocalNotification:notification];
}
}
- (void)receiveRabbitServicerMessage {
// NSString *url5 = @"amqp://web_user:123@192.178.0.2:5672/device_log_2";
DebugLog(@"MQ服务器地址:%@",url5);
if (_conn == nil) {
_conn = [[RMQConnection alloc] initWithUri:url5 delegate:self];
}
[_conn start];
id<RMQChannel> ch = [_conn createChannel];
RMQExchange *x = [ch direct:@"Mobile_Electric_Alarm" options:(RMQExchangeDeclareNoOptions)];
RMQQueue *q = [ch queue:@"" options:RMQQueueDeclareExclusive | RMQQueueDeclareAutoDelete];
[q bind:x routingKey:kUserInfo.company_id];
kweakSelf;
[q subscribe:^(RMQMessage * _Nonnull message) {
id result = [[NSString alloc] initWithData:message.body encoding:NSUTF8StringEncoding];
NSDictionary *dict = [self dictionaryWithJsonString:result];
if ([dict isKindOfClass:[NSDictionary class]]) {
weakSelf.alarmModel = [LXCustomAlarmModel mj_objectWithKeyValues:dict];
[weakSelf registerNotification:1];
}
}];
}
- (void)emitLogDirect:(NSString *)msg severity:(NSString *)severity {
// NSString *url5 = @"amqp://web_user:123@192.178.0.2:5672/device_log_2";
DebugLog(@"MQ发送服务器地址:%@",url5);
RMQConnection *conn = [[RMQConnection alloc] initWithUri:url5 delegate:self];
self.conn = conn;
[conn start];
id<RMQChannel> ch = [conn createChannel];
RMQExchange *x = [ch direct:@"Mobile_Electric_Alarm" options:(RMQExchangeDeclareNoOptions)];
RMQQueue *q = [ch queue:@"" options:RMQQueueDeclareExclusive | RMQQueueDeclareAutoDelete];
[q bind:x routingKey:kUserInfo.company_id];
[x publish:[msg dataUsingEncoding:NSUTF8StringEncoding] routingKey:severity];
NSLog(@"Sent '%@'", msg);
[conn close];
}
#pragma mark - 系统的通知监听
- (void)activeNotification:(NSNotification *)notification{
if (_conn == nil) {
//登陆成功
if ([[LXUserInfoManger shareLXUserInfoManger].currentUserInfo.is_cloud isEqualToString:@"0"]) {
//MQ推送
[self receiveRabbitServicerMessage];
}
}
}
- (void)backgroundNotification:(NSNotification *)notification{
[self closeConnection];
}
- (void)connection:(RMQConnection *)connection failedToConnectWithError:(NSError *)error {
if (error) {
NSLog(@"%@",error);
NSLog(@"链接超时");
[self closeConnection];
}else{
}
}
- (void)connection:(RMQConnection *)connection disconnectedWithError:(NSError *)error {
if (error) {
NSLog(@"%@",error);
}
else{
NSLog(@"链接成功");
}
}
- (void)willStartRecoveryWithConnection:(RMQConnection *)connection {
DebugLog(@"将要开始恢复连接");
}
- (void)startingRecoveryWithConnection:(RMQConnection *)connection {
DebugLog(@"开始恢复连接");
}
- (void)recoveredConnection:(RMQConnection *)connection {
DebugLog(@"恢复连接");
}
- (void)channel:(id<RMQChannel>)channel error:(NSError *)error {
if (error) {
NSLog(@"%@",error);
[self closeConnection];
}
}
//获取当前屏幕显示的viewcontroller,当接收到推送须要跳转VC
- (UIViewController *)getCurrentVC {
UIViewController *rootViewController = [UIApplication sharedApplication].keyWindow.rootViewController;
UIViewController *currentVC = [self getCurrentVCFrom:rootViewController];
return currentVC;
}
- (UIViewController *)getCurrentVCFrom:(UIViewController *)rootVC {
UIViewController *currentVC;
if ([rootVC presentedViewController]) {
// 视图是被presented出来的
rootVC = [rootVC presentedViewController];
}
if ([rootVC isKindOfClass:[UITabBarController class]]) {
// 根视图为UITabBarController
currentVC = [self getCurrentVCFrom:[(UITabBarController *)rootVC selectedViewController]];
} else if ([rootVC isKindOfClass:[UINavigationController class]]){
// 根视图为UINavigationController
currentVC = [self getCurrentVCFrom:[(UINavigationController *)rootVC visibleViewController]];
} else {
// 根视图为非导航类
currentVC = rootVC;
}
return currentVC;
}
//接收到推送,json格式字符串转字典:由于MQ推过来的消息是 String 类型的
- (NSDictionary *)dictionaryWithJsonString:(NSString *)jsonString {
if (jsonString == nil) {
return nil;
}
NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
NSError *err;
NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:jsonData
options:NSJSONReadingMutableContainers
error:&err];
if(err) {
NSLog(@"json解析失败:%@",err);
return nil;
}
return dic;
}
复制代码
若是电脑安装 brew 软件包工具的话,执行并启动
brew install rabbitmq
sudo ./rabbitmq-server
复制代码
浏览器访问:localhost:15672,默认帐号为:guest 密码: guest
Received connection: <RMQConnection: 0x103fa1fc0> disconnectedWithError: Error Domain=GCDAsyncSocketErrorDomain Code=7 "Socket closed by remote peer" UserInfo={NSLocalizedDescription=Socket closed by remote peer}
解决办法:MQ的鉴权,O-C跟Java的格式不太同样,Java是经过Name,Host,Ip等,iOS的是直接设置地址
说明:
Error Domain=com.rabbitmq.rabbitmq-objc-client Code=406 "PRECONDITION_FAILED - inequivalent arg 'durable' for queue 'Mobile_Electric_Alarm' in vhost 'device_log_2': received 'false' but current is 'true'" UserInfo={NSLocalizedDescription=PRECONDITION_FAILED - inequivalent arg 'durable' for queue 'Mobile_Electric_Alarm' in vhost 'device_log_2': received 'false' but current is 'true'}
看3.3解决办法
Error Domain=com.rabbitmq.rabbitmq-objc-client Code=404 "NOT_FOUND - no queue 'rmq-objc-client.gen-3B6E0E14-06E6-4AFC-B6E1-42A7F8FCD218-46927-00002C8AE36AB350' in vhost 'device_log_2'" UserInfo={NSLocalizedDescription=NOT_FOUND - no queue 'rmq-objc-client.gen-3B6E0E14-06E6-4AFC-B6E1-42A7F8FCD218-46927-00002C8AE36AB350' in vhost 'device_log_2'}
解决办法:
Exchange 交换器的配置参数,跟后台约定的不一致,有时候后台也不知道本身配置的啥,就须要尝试了;
/** typedef NS_OPTIONS(NSUInteger, RMQExchangeDeclareOptions) { RMQExchangeDeclareNoOptions = 0, /// 被动声明 RMQExchangeDeclarePassive = 1 << 0, ///交换器持久化 RMQExchangeDeclareDurable = 1 << 1, /// 自动销毁交换器,当全部的消息队列都被使用 RMQExchangeDeclareAutoDelete = 1 << 2, /// 配置的交换器内部构造对应用发布者不可见 RMQExchangeDeclareInternal = 1 << 3, /// @brief RMQExchangeDeclareNoWait = 1 << 4, };*/
RMQExchange *x = [ch direct:@"Mobile_Electric_Alarm" options:(RMQExchangeDeclareNoOptions)];
/** * 队列的配置可选参数 * typedef NS_OPTIONS(NSUInteger, RMQQueueDeclareOptions) { RMQQueueDeclareNoOptions = 0, /// 被动声明 RMQQueueDeclarePassive = 1 << 0, /// 队列持久化 RMQQueueDeclareDurable = 1 << 1, /// 只能被当前的链接受权访问 RMQQueueDeclareExclusive = 1 << 2, /// 自动删除 RMQQueueDeclareAutoDelete = 1 << 3, /// RMQQueueDeclareNoWait = 1 << 4, }; */
RMQQueue *q = [ch queue:@"" options:RMQQueueDeclareExclusive | RMQQueueDeclareAutoDelete];
复制代码
若是链接成功后,那么在MQ的管理台,就能够看到当前链接的消费者了
[图片上传失败...(image-97aaad-1534667228188)]
[参考文章]
1> RabbbitMQ官网
RabbitMQ在iOS中的使用资料不多,若是有问题的话,欢迎留言探讨。
另外,若是你以为个人文章对你有必定的帮助,很是感谢可以点赞,谢谢!
做为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个个人iOS交流群:810733363。微信交流群能够加小编微信(18173184023 )。无论你是小白仍是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 你们一块儿交流学习成长!但愿帮助开发者少走弯路。
来源:本文为第三方转载,若有侵权请联系小编删除。