Mqtt实战项目

  • 一个基于Mqtt的小项目,服务器采用mosquitto,客户端有Python,C,Android三种,涉及SSL加密,传输内容:文字图片。
  • 时间推移,难免忘记当时学习配置的细节,已经没有再做了,有问题的请参考一下其他资料。
  • 环境:Ubuntu 16.04

目录

一.Mqtt相关

MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是IBM开发的一个即时通讯协议,有可能成为物联网的重要组成部分。该协议支持所有平台,几乎可以把所有联网物品和外部连接起来,被用来当做传感器和制动器(比如通过Twitter让房屋联网)的通信协议。
###1.1 延伸阅读
推荐一些背景补充:

  • 协议详细内容,我肯定说得不如协议内容中文版,建议大家先扫一下,对一些名词有印象,后续再查看。
    其中,比较重要的部分,也是代码里需要设置的可变头部部分,

  • 推荐几个比较好的学习地方:

1.2 协议特点

  • Mqtt使用发布/订阅的消息模式,提供一对多消息分发.
  • 对传输消息有三种服务质量(QoS):
    • 最多一次,这一级别会发生消息丢失或重复,消息发布依赖于底层TCP/IP网络。即:<=1
    • 至多一次,这一级别会确保消息到达,但消息可能会重复。即:>=1
    • 只有一次,确保消息只有一次到达。即:=1。在一些要求比较严格的计费系统中,可以使用此级别

订阅和发布以及代理服务器的理解示意图:

工作流:
服务器先启动,然后客户端订阅相关的Topic。Client A 和C发布主题为:QuestionWhat's the temperature?。Client B因为订阅了Question这个Topic,所以可以收到信息,Client B收到信息做判断后发布答案Topic: Temperture出去,订阅了相关Topic的Client A 和Client C能接收到37°。

  • 实现MQTT协议需要:客户端和服务器端
  • MQTT协议中有三种身份:发布者(Publish)、代理(Broker)(服务器)、订阅者(Subscribe)。其中,消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者。
  • MQTT传输的消息分为:主题(Topic)和负载(payload)两部分
    Topic,可以理解为消息的主题,订阅者订阅(Subscribe)后,就会收到该主题的消息内容(payload)
    payload,可以理解为消息的内容,是指订阅者具体要使用的内容

代理服务器,是用于服务客户端的,目前很多公司都有相关的服务: 列表
里面我只选用过Mosquitto,也就不做分析.

二.服务器

2.1 Mosquitto的安装与使用

  • Mosquitto加入库并更新:
sudo apt-add-repository ppa:mosquitto-dev/mosquitto-ppa
sudo apt-get update
  • 安装:
sudo apt-get install mosquitto

安装后位于:/etc/mosquitto,里面可以看到默认配置文件mosquitto.conf

  • 查看状态
sudo service mosquitto status
  • 开启和停止Mosquitto服务:
sudo service mosquitto start
sudo service mosquitto stop (常用,在测试SSL的时候)
  • Mosquittof服务器启动:
    上面的指令是加载默认参数,大多时候喜欢自己启动,也不麻烦
mosquitto -v (加载默认配置)
mosquitto -v -c xx/xx/mosquitto.conf -p 8883 (加载指定配置)

避免麻烦,建议在使用SSL新建一个配置文件,这样不用一直改来改去。-c加载配置文件,-p端口。

TCP端口8883和1883已在IANA注册,分别用于MQTT的TLS和非TLS通信。

2.2 Mosquitto-clients

上一步只是安装了Mosquitto服务器,不包括客户端,安装这个是用于调试,可以在命令行测试证书、ip等,很方便。

  • 安装
sudo apt-get install mosquitto-clients
  • 订阅
mosquitto_sub -t temperature 
  • 发布
mosquitto_pub -t temperature -m 37°

结果:
这里写图片描述

三.Python客户端

服务器我们借助mosquitto软件来试下,那么客户端我们当然不会自己去写一个协议.显然已经有很多先驱写了,我们只需要导入就好了.这里采用比较出名的Eclipse Paho库,它包含的各种语言,或者库列表.

看图就明白了:
这里写图片描述

3.1 导入库

pip3 install paho-mqtt

3.2 Code

  • 关键指令
#导入包
import paho.mqtt.client as mqtt

#创建client对象
client = mqtt.Client(id)

#连接
client.connect(host,post)

#订阅
client.subscribe(topic)
client.on_message = func #接收到信息后的处理函数

#发布
client.publish(topic, payload)
  • 完整Code
import paho.mqtt.client as mqtt
import sys

#改成自己的ip,命令ifconfig可以查看
host = "xx.xxx.xxx.xxx"
topic_sub = "Question"
topic_pub = "temperature"

def on_connect(client, userdata, flags, rc):
    print("Connected with result code " + str(rc))
    client.subscribe(topic_sub)

def on_message(client, userdata, msg):
    print(msg.payload) 
    client.publish(topic_pub, "37°")

def main(argv = None):
	#声明客户端
	client = mqtt.Client()
	#连接
	client.connect(host, 1883, 60)
	#两个回调函数,用于执行连接成功和接收到信息要做的事
	client.on_connect = on_connect
	client.on_message = on_message
	client.loop_forever()

if __name__ == "__main__":
	sys.exit(main())

运行python客户端,然后在终端发布一条消息

mosquitto_pub -t Question -m 123
  • 结果:
    这里写图片描述
    可以看到我发布一条消息,然后上述python客户端接到后发送了一条37° 信息出来,被订阅了temperature的终端接收了.

  • 如果你不想知道图片怎么发送和接收,跳过这一小段:

#发送端
fp = open("7.jpg", "rb")
payload = fp.read()
client.publish(topic_pub, payload)

#接收端
def on_message(client, userdata, msg):
	new_filename = "new_img.jpg"
	fp = open(new_filename, 'wb')
	fp.write(msg.payload)
	fp.close()

这个可以推广到传输其他文件.

四.C客户端

4.1 导入库

从源码编译,这个稍微比较麻烦,安装过程如下,注意文件整理:

git clone https://github.com/eclipse/paho.mqtt.c.git
cd paho.mqtt.c
make
sudo make install

在使用的过程中也要注意编译的方式,这里提供一个Makefile做参考:

test:test.cpp cmqtt.cpp cmqtt.h
	g++ -o test test.cpp cmqtt.cpp -lpaho-mqtt3c \
	-I ../../paho.mqtt.c/src \
	-L ../../paho.mqtt.c/build \
	-pthread -Imqtt \
	-std=c++11 

4.2 Code

C代码方面我提供关键部分供初学者容易上手,我自己进行过一次c++的封装,比较复杂.有时间的话另开一帖.

  • 首先,C代码方面我们肯定不会是只做一件事情,所以我们需要开两个线程两个客户端来进行订阅和发布.一个同时发布和订阅,也可以.
//mqttclient.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "MQTTClient.h"
#include <unistd.h>
#include <sys/stat.h>


#define NUM_THREADS 2
#define ADDRESS "tcp://xx.xxx.xx.xxx:1883"
#define CLIENTID "ExampleClient_pub"
#define SUB_CLIENTID    "ExampleClient_sub" //更改此处客户端ID
#define TOPICPUB    "Question"  //更改发送的话题
#define TOPICSUB    "temperature"
#define QOS         1
#define TIMEOUT     10000L
#define DISCONNECT  "out"

int CONNECT = 1;
volatile MQTTClient_deliveryToken deliverytoken;
long PAYLOADLEN;
char* PAYLOAD;

void delivered(void *context, MQTTClient_deliveryToken dt)
{
  printf("Message with token value %d delivery confirmed\n", dt);
  deliverytoken = dt;
}

int msgarrvd(void *context, char *topicName, int topicLen, MQTTClient_message *message)
{
  int i;
  char* payloadptr;

  printf("Message arrived\n");
  printf("    topic: %s\n", topicName);
  printf("  message: \n");

  payloadptr = message->payload;
  if (strcmp(payloadptr, DISCONNECT) == 0) {
    printf("\n out!!");
    CONNECT = 0;
  }

  for (i = 0; i < message->payloadlen; i++) {
    putchar(*payloadptr++);
  }
  printf("\n");

  MQTTClient_freeMessage(&message);
  MQTTClient_free(topicName);
  return 1;
}

void connlost(void *context, char *cause)
{
  printf("\nConnection lost\n");
  printf("     cause: %s\n", cause);
}

void *pubClient(void *threadid) {
  long tid;
  tid = (long)threadid;
  int count = 0;
  printf("Hello World! It's me, thread #%ld!\n", tid);
  //声明一个MQTTClient
  MQTTClient client;
  //初始化MQTT Client选项
  MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
  //#define MQTTClient_message_initializer { {'M', 'Q', 'T', 'M'}, 0, 0, NULL, 0, 0, 0, 0 }
  MQTTClient_message pubmsg = MQTTClient_message_initializer;
  //声明消息token
  MQTTClient_deliveryToken token;
  int rc;
  //使用参数创建一个client,并将其赋值给之前声明的client
  MQTTClient_create(&client, ADDRESS, CLIENTID,
                    MQTTCLIENT_PERSISTENCE_NONE, NULL);
  conn_opts.keepAliveInterval = 20;
  conn_opts.cleansession = 1;
  //使用MQTTClient_connect将client连接到服务器,使用指定的连接选项。成功则返回MQTTCLIENT_SUCCESS
  if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
  {
    printf("Failed to connect, return code %d\n", rc);
    exit(EXIT_FAILURE);
  }
  PAYLOAD = "What's the temperature";
  // printf("%s\n", PAYLOAD);
  pubmsg.payload = PAYLOAD;
  pubmsg.payloadlen = (int)strlen(PAYLOAD);
  pubmsg.qos = QOS;
  pubmsg.retained = 0;
  //循环发布
  while (CONNECT) {
    MQTTClient_publishMessage(client, TOPICPUB, &pubmsg, &token);
    printf("Waiting for up to %d seconds for publication of %s\n"
             "on topic %s for client with ClientID: %s\n",
             (int)(TIMEOUT/1000), PAYLOAD, TOPICPUB, CLIENTID);
    rc = MQTTClient_waitForCompletion(client, token, TIMEOUT);
    printf("Message with delivery token %d delivered\n", token);
    // thread sleep
    usleep(2000000L);
  }

  MQTTClient_disconnect(client, 10000);
  MQTTClient_destroy(&client);
}

void *subClient(void *threadid) {
  long tid;
  tid = (long)threadid;
  printf("Hello World! It's me, thread #%ld!\n", tid);

  MQTTClient client;
  MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
  int rc;
  int ch;

  MQTTClient_create(&client, ADDRESS, SUB_CLIENTID,
                    MQTTCLIENT_PERSISTENCE_NONE, NULL);
  conn_opts.keepAliveInterval = 20;
  conn_opts.cleansession = 1;
  //设置回调函数
  MQTTClient_setCallbacks(client, NULL, connlost, msgarrvd, delivered);

  if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
  {
    printf("Failed to connect, return code %d\n", rc);
    exit(EXIT_FAILURE);
  }
  printf("Subscribing to topic %s\nfor client %s using QoS%d\n\n"
         "Press Q<Enter> to quit\n\n", TOPICSUB, SUB_CLIENTID, QOS);
  MQTTClient_subscribe(client, TOPICSUB, QOS);

  do
  {
    ch = getchar();
  } while (ch != 'Q' && ch != 'q');
  //quit
  MQTTClient_unsubscribe(client, TOPICSUB);
  MQTTClient_disconnect(client, 10000);
  MQTTClient_destroy(&client);

  pthread_exit(NULL);
}

int main(int argc, char* argv[])
{
  pthread_t threads[NUM_THREADS];
  pthread_create(&threads[0], NULL, subClient, (void *)0);
  pthread_create(&threads[1], NULL, pubClient, (void *)1);
  pthread_exit(NULL);
}
  • 结果:
    这里写图片描述

五.Android客户端

5.1 导入库

Android客户端同样需要一些配置来导入库,官网教程不是很好看,HIVEMQ的不错:

  • app内的build.gradle
//和android\dependencies同级
repositories {
    maven {
        url "https://repo.eclipse.org/content/repositories/paho-snapshots/"
    }
}
dependencies {
    ......
    implementation('org.eclipse.paho:org.eclipse.paho.android.service:1.0.2') {
        exclude module: 'support-v4'
    }
    ......
}
  • 权限设置AndrroidManifest.xml
// 放在manifest下一级
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />

// 放在<application>下一级
<service android:name="org.eclipse.paho.android.service.MqttService" >
</service>

###5.2 Code

  • Android代码比较简陋:
public class MainActivity extends AppCompatActivity {

    private static final String TAG = "LQH";
    Button bt_connect;
    Button bt_sub;
    Button bt_pub;
    TextView textView;
    String log;
    MqttAndroidClient client;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        bt_connect = findViewById(R.id.bt_connect);
        bt_pub = findViewById(R.id.bt_pub);
        bt_sub = findViewById(R.id.bt_sub);
        textView = findViewById(R.id.textView);
        log = "Log:\n\n";
        textView.setText(log);
        bt_connect.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String clientId = MqttClient.generateClientId();
                //创建客户端
                client = new MqttAndroidClient(MainActivity.this, "tcp://xx.xxx.xx.xxx:1883",
                                clientId);
		//连接
                try {
                    IMqttToken token = client.connect();
                    token.setActionCallback(new IMqttActionListener() {
                    //两个响应函数
                        @Override
                        public void onSuccess(IMqttToken asyncActionToken) {
                            // We are connected
                            Log.d(TAG, "onSuccess");
                            Toast.makeText(MainActivity.this, "connect successed", Toast.LENGTH_SHORT).show();
                            log += "Connect successed!\n\n";
                            textView.setText(log);
                        }
                        @Override
                        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
                            // Something went wrong e.g. connection timeout or firewall problems
                            Log.d(TAG, "onFailure");
                            Toast.makeText(MainActivity.this, "not connect", Toast.LENGTH_SHORT).show();
                            log += "Connect failed!\n\n";
                            textView.setText(log);
                        }
                    });
                } catch (MqttException e) {
                    e.printStackTrace();
                }
                //设置几个回调函数
                client.setCallback(new MqttCallback() {
                
                    //连接断开
                    @Override
                    public void connectionLost(Throwable cause) {
                        Toast.makeText(MainActivity.this, "connectionLost", Toast.LENGTH_SHORT).show();
                    }
                    //接收信息
                    @Override
                    public void messageArrived(String topic, MqttMessage message) throws Exception {
                        log = log + "Recevied msg: " + new String(message.getPayload()) + "\n\n";
                        textView.setText(log);
                    }
                    //发布信息成功
                    @Override
                    public void deliveryComplete(IMqttDeliveryToken token) {
                        Toast.makeText(MainActivity.this, "published", Toast.LENGTH_SHORT).show();
                        log = log + "Published\n\n";
                        textView.setText(log);
                    }
                });
            }
        });

        bt_sub.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                final String topic = "temperature";
                int qos = 1;
                try {
                //订阅
                    IMqttToken subToken = client.subscribe(topic, qos);
                    subToken.setActionCallback(new IMqttActionListener() {
                        @Override
                        public void onSuccess(IMqttToken asyncActionToken) {
                            // The message was published
                            Toast.makeText(MainActivity.this, "subscribe successed", Toast.LENGTH_SHORT).show();
                            log = log + "Subscribe topic: " + topic + " successed!\n\n";
                            textView.setText(log);
                        }

                        @Override
                        public void onFailure(IMqttToken asyncActionToken,
                                              Throwable exception) {
                            // The subscription could not be performed, maybe the user was not
                            // authorized to subscribe on the specified topic e.g. using wildcards
                            Toast.makeText(MainActivity.this, "subscribe failure", Toast.LENGTH_SHORT).show();
                        }
                    });
                } catch (MqttException e) {
                    e.printStackTrace();
                }
            }
        });

        bt_pub.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String topic = "Question";
                String payload = "What's the temperature?";
                try {
                    MqttMessage message = new MqttMessage(payload.getBytes());
                    //发布
                    client.publish(topic, message);
                    log = log + "Publish:\n" + "  topic:" + topic + "\n  payload:" + payload + "\n\n";
                    textView.setText(log);
                } catch (MqttException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}
  • 结果:

好吧,就是这么简陋的,例如你没连接就发布,程序会崩溃.hhh

六.SSL背景知识

这边有两个帖子,一个系列,写得实在好:

我也稍作解释:

对称加密
钥匙A加密,再用钥匙A解密,但密钥传输过程中如果被截获就泄密了,所以产生非对称加密
非对称加密
钥匙A加密,只能用钥匙B加密
公钥(yue)和私钥
非对称加密用户拥有公钥和私钥,公钥加密只能用私钥解密,反之亦然。
公钥发给别人,而私钥自己妥善保管。
想象一种情况,A,B,C三人,A给B发信息捎上了自己的公钥,结果C中途拦截了A公钥,把C公钥发给B,B拿到后用公钥C加密发布信息,C是不是可以获得B发布的信息,C还可以把信息用公钥A加密发给A,不声不响截获数据,所以有了CA(授权中心).
CA(证书授权中心)
承担公钥合法性检验的责任。授权中心会发行一个个的证书,每个证书本质上包含:实体或个人的名字以及对应的公钥。为了保证证书的安全性,授权中心用自己的私钥对证书进行加密,证书接受者用授权中心的公钥对该证书进行解密,从而实现证书的数字签名,如图:
图片来自: [**怎么生成证书**](https://mcuoneclipse.com/2017/04/14/enable-secure-communication-with-tls-and-the-mosquitto-broker/)
  • 证书的生成过程参考上述的 怎么生成证书
    • 有一点要注意的,生成证书和密钥要用同一个CA
    • Common Name要为电脑ip,并且尽量不重复,可以用127.0.0.1, xx.xxx.xx.xxx,如果有线无线都有,那么可以生成ca, server, client.测试双向认证。建议:
      • ca.crt用wifi的ip,server.crt用有线ip,因为WiFi,Android或者C客户端可以连接,检验证书.
单向认证:
指的是只有一个对象校验对端的证书合法性。
通常都是Client来校验Server的合法性。那么client需要一个ca.crt,服务器需要server.crt,server.key。
双向认证
指的是相互校验,服务器需要校验每个client,client也需要校验服务器。
Server 需要 server.key 、server.crt 、ca.crt
Client 需要 client.key 、client.crt 、ca.crt

经常采用单向认证,双向虽然更安全,但是每个客户还要求生成证书会很麻烦。后面代码也基于此。

七.客户端添加SSL模块

7.1 Mosquitto

  • 修改配置文件
    既然我们想要采用ssl认证,那么我们自然需要改配置,稍微一思考,我们需要改的内容也不多,指定ca.crt, server.crt, server.key三个文件的路径,指定单向认证或者双向认证。

  • mqtt_tls.conf

# 这是我的路径,要改
# CA证书,pem格式,
cafile /xxx/ca/ca.crt
# 服务器证书
certfile /xxx/server/server.crt
# 服务器密钥
keyfile /xxx/server/server.key
# false->单向认证, true->双向认证
require_certificate false
# 如果require_certificate为true,则可以将use_identity_as_username设置为true以将客户端证书中的CN值用作用户名。 如果true,则不会将password_file选项用于此侦听器。
use_identity_as_username false
  • 运行broker
mosquitto -v -c xx/xx/mqtt_tls.conf -p 8883
  • 可以用两个终端来测试一下:
mosquitto_sub -t temperture -h xx.xxx.xx.xxx -p 8883 --cafile /xxx/xxx/ca.crt
mosquitto_pub -t temperture -m 37°  -h xx.xxx.xx.xxx -p 8883 --cafile /xx/xxx/ca.crt

这样就实现了mosquitto的配置测试,

7.2 Python

python代码的改动比较的简单:

client.tls_set("/xx/xxx/ca.crt", tls_version=ssl.PROTOCOL_TLSv1_2)
# client.connect(host, 1883, 60)
client.connect(host, 8883, 60)

简单加载证书,后面的版本指定本来可以没有,但我这边后来软件变动出现问题,所以加上这个。

###7.3 C
C的代码改动也不复杂,就是有个坑

// 1
#define ADDRESS "ssl://xx.xxx.xx.xxx:8883"

// 2
MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;

MQTTClient_SSLOptions ssl = MQTTClient_SSLOptions_initializer;
ssl.trustStore = "/xx/xxx/ca.pem";

conn_opts.ssl = &ssl;

// 3,巨坑
//编译加载的库不一样,要 +s !!! -lpaho-mqtt3cs
test:test.cpp cmqtt.cpp cmqtt.h
	g++ -o test test.cpp cmqtt.cpp -lpaho-mqtt3cs \
	-I ../../paho.mqtt.c/src \
	-L ../../paho.mqtt.c/build \
	-pthread -Imqtt \
	-std=c++11 

7.4 Android

这块和前面两个有点不一样,因为之前的ca.crt在这里是不能用的,Android能加载的证书需要是bks格式的,所以这里需要先生成bks,然后把ca.crt添加进去。再加载。

java -version

根据上面指令查看jdk版本,然后下载合适的bcprov,–>bcprov-ext-jdk15on-160.jar,然后放到(jdk_home)/jre/lib/ext
这里可以用下面指令找到放置的文件夹:

locate jaccess

jaccess只是本来存在(jdk_home)/jre/lib/ext文件夹下的另一个文件,如果安装了jdk和android-studio,那么可以找到三条位置,选择/xxx/android-studio/jre/jre/lib/ext/,修改这里比较简单,/usr/下的往往还涉及权限。

  • 生成bks
    在随意位置运行
keytool -importcert -keystore test_ca.bks -file /xxx/ca.crt -storetype BKS -provider org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath "/xxx/android-studio/jre/jre/lib/ext/bcprov-ext-jdk15on-160.jar"

输入六位密码,yes,就可以看到生成的test_ca.bks
test_ca.bks放到android项目下的/res/raw,没有就新建

  • Code
// 1
client = new MqttAndroidClient(MainActivity.this, "ssl://xx/xxx/xx/xxx:8883",
                clientId);

// 2.配置MqttConnectOptions
MqttConnectOptions options = new MqttConnectOptions();
SSLContext sslContext = SSLContext.getInstance("TLS");
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("X509");
KeyStore keyStore = KeyStore.getInstance("BKS");
// 刚才生成的文件加载,“123456”对应密码
keyStore.load(this.getResources().openRawResource(R.raw.test_ca),"123456".toCharArray());
trustManagerFactory.init(keyStore);
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
SocketFactory factory = sslContext.getSocketFactory();
options.setSocketFactory(factory);

然后Alt+Enter解决各种红色波浪。基本都是异常处理。

  • 代码有不明白的不妨百度,一行一行看过去是最快的学习路线。

结束语

  • 至此基本完结,第一个比较长的帖子,难免出现问题,希望大家可以指出,尽量改正,谢谢。
  • 后面会考虑整理代码上传github
  • 如果有用希望能给个赞鼓励一下
  • 13
    点赞
  • 61
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值