微信协议简单调研笔记
前言
微信可调研点很多,这里仅仅从协议角度进行调研,会涉及到微信协议交换、消息收发等。所谓“弱水三千,只取一瓢”吧。
杂七杂八的,有些长,可直接拉到最后看结论好了。
一。微信协议概览
微信传输协议,官方公布甚少,在微信技术总监所透漏PPT《微信之道—至简》文档中,有所体现。
纯个人理解:
因张小龙做邮箱Foxmail起家,继而又做了QQ Mail等,QQ Mail是国内第一个支持Exchange ActiveSync协议的免费邮箱,基于其从业背景,微信从一开始就采取基于ActiveSync的修改版状态同步协议Sync,也就再自然不过了。
一句话:增量式、按序、可靠的状态同步传输的微信协议。
大致交换简图如下:
如何获取新数据呢:
- 服务器端通知,客户端获取
- 客户端携带最新的SyncKey,发起数据请求
- 服务器端生成最新的SyncKey连同最新数据发送给客户端
- 基于版本号机制同步协议,可确保数据增量、有序传输
- SyncKey,由服务器端序列号生成器生成,一旦有新消息产生,将会产生最新的SyncKey。类似于版本号
服务器端通知有状态更新,客户端主动获取自从上次更新之后有变动的状态数据,增量式,顺序式。
二。微信Web端简单调试
在线版本微信:
https://webpush.weixin.qq.com/
通过Firefox + Firebug组合调试,也能证实了微信大致通过交换SyncKey方式获取新数据的论述。
1. 发起GET长连接检测是否存在新的需要同步的数据
会携带上最新SyncKey
https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/synccheck?callback=jQuery18306073923335455973_1393208247730&r=1393209241862&sid=s7c%2FsxpGRSihgZAA&uin=937355&deviceid=e542565508353877&synckey=1_620943725%7C2_620943769%7C3_620943770%7C11_620942796%7C201_1393208420%7C202_1393209127%7C1000_1393203219&_=1393209241865
返回内容:
window.synccheck={retcode:"0",selector:"2"}
selector值大于0,表示有新的消息需要同步。
据目测,心跳周期为27秒左右。
2. 一旦有新数据,客户端POST请求主动获取同步的数据
https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/webwxsync?sid=s7c%2FsxpGRSihgZAA&r=1393208447375
携带消息体:
{"BaseRequest":{"Uin":937355,"Sid":"s7c/sxpGRSihgZAA"},"SyncKey":{"Count":6,"List":[{"Key":1,"Val":620943725},{"Key":2,"Val":620943767},{"Key":3,"Val":620943760},{"Key":11,"Val":620942796},{"Key":201,"Val":1393208365},{"Key":1000,"Val":1393203219}]},"rr":1393208447374}
会携带上最新的SyncKey,会返回复杂结构体JSON内容。
但浏览端收取到消息之后,如何通知服务器端已确认收到了?Web版本微信,没有去做。
在以往使用过程中,曾发现WEB端有丢失消息的现象,但属于偶尔现象。但Android微信客户端(只要登陆连接上来之后)貌似就没有丢失过。
3. 发送消息流程
发起一个POST提交,用于提交用户需要发送的消息
https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/webwxsendmsg?sid=lQ95vHR52DiaLVqo&r=1393988414386
发送内容:
{"BaseRequest":{"Uin":937355,"Sid":"lQ95vHR52DiaLVqo","Skey":"A6A1ECC6A7DE59DEFF6A05F226AA334DECBA457887B25BC6","DeviceID":"e937227863752975"},"Msg":{"FromUserName":"yongboy","ToUserName":"hehe057854","Type":1,"Content":"hello","ClientMsgId":1393988414380,"LocalID":1393988414380}}
相应内容:
{
"BaseResponse": {
"Ret": 0,
"ErrMsg": "" } , "MsgID": 1020944348, "LocalID": "1393988414380" }
再次发起一个POST请求,用于申请最新SyncKey
https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/webwxsync?sid=lQ95vHR52DiaLVqo&r=1393988414756
发送内容:
{"BaseRequest":{"Uin":937355,"Sid":"lQ95vHR52DiaLVqo"},"SyncKey":{"Count":6,"List":[{"Key":1,"Val":620944310},{"Key":2,"Val":620944346},{"Key":3,"Val":620944344},{"Key":11,"Val":620942796},{"Key":201,"Val":1393988357},{"Key":1000,"Val":1393930108}]},"rr":1393988414756}
响应的(部分)内容:
"SKey": "8F8C6A03489E85E9FDF727ACB95C93C2CDCE9FB9532FC15B"
终止GET长连接,使用最新SyncKey再次发起一个新的GET长连接
https://webpush.weixin.qq.com/cgi-bin/mmwebwx-bin/synccheck?callback=jQuery1830245810089652082181393988305564&r=1393988415015&sid=lQ95vHR52DiaLVqo&uin=937355&deviceid=e937227863752975&synckey=1620944310%7C2620944348%7C3620944344%7C11620942796%7C2011393988357%7C10001393930108&=1393988415016
三。微信Android简单分析
Windows桌面端Android虚拟机中运行最新版微信(5.2),通过tcpdump/Wireshark组合封包分析,以下为分析结果。
0. 初始连接记录
简单记录微信启动之后请求:
11:20:35 dns查询
dns.weixin.qq.com
返回一组IP地址11:20:35 DNS查询 long.weixin.qq.com 返回一组IP地址,本次通信中,微信使用了最后一个IP作为TCP长连接的连接地址。 11:20:35 http://dns.weixin.qq.com/cgi-bin/micromsg-bin/newgetdns?uin=0&clientversion=620888113&scene=0&net=1 用于请求服务器获得最优IP路径。服务器通过结算返回一个xml定义了域名:IP对应列表。仔细阅读,可看到微信已经开始了国际化的步伐:香港、加拿大、韩国等。 具体文本,请参考:https://gist.github.com/yongboy/9341884 11:20:35 获取到long.weixin.qq.com最优IP,然后建立到101.227.131.105的TCP长连接 11:21:25 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/getprofile HTTP/1.1 (application/octet-stream) 返回一个名为“micromsgresp.dat”的附件,估计是未阅读的离线消息 11:21:31 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/whatsnews HTTP/1.1 (application/octet-stream) 大概是资讯、订阅更新等 中间进行一些资源请求等,类似于 GET http://wx.qlogo.cn/mmhead/Q3auHgzwzM7NR4TYFcoNjbxZpfO9aiaE7RU5lXGUw13SMicL6iacWIf2A/96 图片等一些静态资源都会被分配到wx.qlogo.cn域名下面 不明白做什么用途 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/downloadpackage HTTP/1.1 (application/octet-stream) 输出为micromsgresp.dat文件 11:21:47 GET http://support.weixin.qq.com/cgi-bin/mmsupport-bin/reportdevice?channel=34&deviceid=A952001f7a840c2a&clientversion=620888113&platform=0&lang=zh_CN&installtype=0 HTTP/1.1 返回chunked分块数据 11:21:49 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/reportstrategy HTTP/1.1 (application/octet-stream)
1. 心跳频率约为5分钟
上次使用Wireshark分析有误(得出18分钟结论),再次重新分析,心跳频率在5分钟左右。
2. 登陆之后,会建立一个长连接,端口号为8080
简单目测为HTTP,初始以为是双通道HTTP,难道是自定义的用于双通道通信的HTTP协议吗,网络上可见资料都是模棱两可、语焉不详。
具体查看长连接初始数据通信,没有发现任何包含"HTTP"字样的数据,以为是微信自定义的TCP/HTTP通信格式。据分析,用于可能用于获取数据、心跳交换消息等用途吧。这个后面会详谈微信是如何做到的。
2.0 初始消息传输
个人资料、离线未阅读消息部分等通过 POST HTTP短连接单独获取。
2.1 二进制简单分析
抽取微信某次HTTP协议方式通信数据,16进制表示,每两个靠近的数字为一个byte字节:
微信协议可能如下:
一个消息包 = 消息头 + 消息体
消息头固定16字节长度,消息包长度定义在消息头前4个字节中。
单纯摘取第0000行为例,共16个字节的头部:
00 00 00 10 00 10 00 01 00 00 00 06 00 00 00 0f
16进制表示,每两个紧挨着数字代表一个byte字节。
微信消息包格式: 1. 前4字节表示数据包长度,可变 值为16时,意味着一个仅仅包含头部的完整的数据包(可能表示着预先定义好的业务意义),后面可能还有会别的消息包 2. 2个字节表示头部长度,固定值,0x10 = 16 3. 2个字节表示谢意版本,固定值,0x01 = 1 4. 4个字节操作说明数字,可变 5. 序列号,可变 6. 头部后面紧跟着消息体,非明文,加密形式 7. 一个消息包,最小16 byte字节
通过上图(以及其它数据多次采样)分析:
- 0000 - 0040为单独的数据包
- 0050行为下一个数据包的头部,前四个字节值为0xca = 202,表示包含了从0050-0110共202个字节数据
- 一次数据发送,可能包含若干子数据包
- 换行符\n,16进制表示为0x0a,在00f0行,包含了两个换行符号
- 一个数据体换行符号用于更细粒度的业务数据分割 是否蒙对,需要问问做微信协议的同学
- 所有被标记为HTTP协议通信所发送数据都包含换行符号
2.2 动手试试猜想,模拟微信TCP长连接
开始很不解为什么会出现如此怪异的HTTP双通道长连接请求,难道基于TCP通信,然后做了一些手脚?很常规的TCP长连接,传输数据时(不是所有数据传输),被wireshark误认为HTTP长连接。这个需要做一个实验证实一下自己想法,设想如下:
写一个Ping-Pong客户端、服务器端程序,然后使用Wireshark看一下结果,是否符合判断。
Java版本的请求端,默认请求8080端口:
/** | |
* Ping Client | |
* @author nieyong | |
*/ | |
package com.learn; | |
import io.netty.bootstrap.Bootstrap; | |
import io.netty.buffer.ByteBuf; | |
import io.netty.buffer.PooledByteBufAllocator; | |
import io.netty.channel.ChannelFuture; | |
import io.netty.channel.ChannelHandlerContext; | |
import io.netty.channel.ChannelInboundHandlerAdapter; | |
import io.netty.channel.ChannelInitializer; | |
import io.netty.channel.ChannelOption; | |
import io.netty.channel.EventLoopGroup; | |
import io.netty.channel.nio.NioEventLoopGroup; | |
import io.netty.channel.socket.SocketChannel; | |
import io.netty.channel.socket.nio.NioSocketChannel; | |
import java.util.concurrent.TimeUnit; | |
class PingClientHandler extends ChannelInboundHandlerAdapter { | |
private final ByteBuf firstMessage; | |
public PingClientHandler() { | |
firstMessage = PooledByteBufAllocator.DEFAULT.buffer(22); | |
// weixin 16 byte's header | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(16); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(16); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(1); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(6); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(0); | |
firstMessage.writeByte(1); | |
// just for /n | |
firstMessage.writeByte('\n'); // 1 byte | |
// footer 16 byte | |
String welcome = "hello"; // 5 byte | |
firstMessage.writeBytes(welcome.getBytes()); | |
} | |
@Override | |
public void channelActive(ChannelHandlerContext ctx) { | |
ctx.writeAndFlush(firstMessage); | |
} | |
@Override | |
public void channelRead(final ChannelHandlerContext ctx, final Object msg) | |
throws Exception { | |
ctx.executor().schedule(new Runnable() { | |
@Override | |
public void run() { | |
ctx.channel().writeAndFlush(msg); | |
} | |
}, 1, TimeUnit.SECONDS); | |
} | |
@Override | |
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { | |
ctx.flush(); | |
} | |
@Override | |
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { | |
System.err.println("Unexpected exception from downstream :" | |
+ cause.getMessage()); | |
ctx.close(); | |
} | |
} | |
public class PingClient { | |
private final String host; | |
private final int port; | |
public PingClient(String host, int port) { | |
this.host = host; | |
this.port = port; | |
} | |
public void run() throws Exception { | |
EventLoopGroup group = new NioEventLoopGroup(); | |
try { | |
Bootstrap b = new Bootstrap(); | |
b.group(group).channel(NioSocketChannel.class) | |
.option(ChannelOption.TCP_NODELAY, true) | |
.handler(new ChannelInitializer<SocketChannel>() { | |
@Override | |
public void initChannel(SocketChannel ch) | |
throws Exception { | |
ch.pipeline().addLast(new PingClientHandler()); | |
} | |
}); | |
ChannelFuture f = b.connect(host, port).sync(); | |
f.channel().closeFuture().sync(); | |
} finally { | |
// Shut down the event loop to terminate all threads. | |
group.shutdownGracefully(); | |
} | |
} | |
public static void main(String[] args) throws Exception { | |
String host = "127.0.0.1"; | |
int port = 8080; | |
if (args.length == 3) { | |
host = args[0]; | |
port = Integer.parseInt(args[1]); | |
} | |
new PingClient(host, port).run(); | |
} | |
} |
C语言版本的服务器程序,收到什么发送什么,没有任何逻辑,默认绑定8080端口:
/** | |
* nieyong@youku.com | |
* how to compile it: | |
* gcc pong_server.c -o pong_server /usr/local/lib/libev.a -lm | |
*/ | |
#include <arpa/inet.h> | |
#include <stdlib.h> | |
#include <stdio.h> | |
#include <string.h> | |
#include <fcntl.h> | |
#include <errno.h> | |
#include <err.h> | |
#include <unistd.h> | |
#include "../include/ev.h" | |
static int server_port = 8080; | |
struct ev_loop *loop; | |
typedef struct { | |
int fd; | |
ev_io ev_read; | |
} client_t; | |
ev_io ev_accept; | |
static void free_res(struct ev_loop *loop, ev_io *ws); | |
int setnonblock(int fd) { | |
int flags = fcntl(fd, F_GETFL); | |
if (flags < 0) | |
return flags; | |
flags |= O_NONBLOCK; | |
if (fcntl(fd, F_SETFL, flags) < 0) | |
return -1; | |
return 0; | |
} | |
static void read_cb(struct ev_loop *loop, ev_io *w, int revents) { | |
client_t *client = w->data; | |
int r = 0; | |
char rbuff[1024]; | |
if (revents & EV_READ) { | |
r = read(client->fd, &rbuff, 1024); | |
} | |
if (EV_ERROR & revents) { | |
fprintf(stderr, "error event in read\n"); | |
free_res(loop, w); | |
return ; | |
} | |
if (r < 0) { | |
fprintf(stderr, "read error\n"); | |
ev_io_stop(EV_A_ w); | |
free_res(loop, w); | |
return; | |
} | |
if (r == 0) { | |
fprintf(stderr, "client disconnected.\n"); | |
ev_io_stop(EV_A_ w); | |
free_res(loop, w); | |
return; | |
} | |
send(client->fd, rbuff, r, 0); | |
} | |
static void accept_cb(struct ev_loop *loop, ev_io *w, int revents) { | |
struct sockaddr_in client_addr; | |
socklen_t client_len = sizeof(client_addr); | |
int client_fd = accept(w->fd, (struct sockaddr *) &client_addr, &client_len); | |
if (client_fd == -1) { | |
fprintf(stderr, "the client_fd is NULL !\n"); | |
return; | |
} | |
client_t *client = malloc(sizeof(client_t)); | |
client->fd = client_fd; | |
if (setnonblock(client->fd) < 0) | |
err(1, "failed to set client socket to non-blocking"); | |
client->ev_read.data = client; | |
ev_io_init(&client->ev_read, read_cb, client->fd, EV_READ); | |
ev_io_start(loop, &client->ev_read); | |
} | |
int main(int argc, char const *argv[]) { | |
int ch; | |
while ((ch = getopt(argc, argv, "p:")) != -1) { | |
switch (ch) { | |
case 'p': | |
server_port = atoi(optarg); | |
break; | |
} | |
} | |
loop = ev_default_loop(0); | |
struct sockaddr_in listen_addr; | |
int reuseaddr_on = 1; | |
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); | |
if (listen_fd < 0) | |
err(1, "listen failed"); | |
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuseaddr_on, sizeof(reuseaddr_on)) == -1) | |
err(1, "setsockopt failed"); | |
memset(&listen_addr, 0, sizeof(listen_addr)); | |
listen_addr.sin_family = AF_INET; | |
listen_addr.sin_addr.s_addr = INADDR_ANY; | |
listen_addr.sin_port = htons(server_port); | |
if (bind(listen_fd, (struct sockaddr *) &listen_addr, sizeof(listen_addr)) < 0) | |
err(1, "bind failed"); | |
if (listen(listen_fd, 5) < 0) | |
err(1, "listen failed"); | |
if (setnonblock(listen_fd) < 0) | |
err(1, "failed to set server socket to non-blocking"); | |
ev_io_init(&ev_accept, accept_cb, listen_fd, EV_READ); | |
ev_io_start(loop, &ev_accept); | |
ev_loop(loop, 0); | |
return 0; | |
} | |
static void free_res(struct ev_loop *loop, ev_io *w) { | |
client_t *client = w->data; | |
if (client == NULL) { | |
fprintf(stderr, "the client is NULL !!!!!!"); | |
return; | |
} | |
ev_io_stop(loop, &client->ev_read); | |
close(client->fd); | |
free(client); | |
} |
这里有一个现场图:
可以尝试稍微改变输出内容,去除换行符“\n”,把端口换成9000,试试看,就会发现Wireshark输出不同的结果来。
2.3 结论是什么呢?
若使用原始TCP进行双向通信,则需要满足以下条件,可以被类似于Wireshark协议拦截器误认为是HTTP长连接:
- 使用80/8080端口(81/3128/8000经测试无效) 也许8080一般被作为WEB代理服务端口,微信才会享用这个红利吧。
- 输出的内容中,一定要包含换行字符"\n"
因此,可以定性为微信使用了基于8080端口TCP长连接,一旦数据包中含有换行"\n"符号,就会被Wireshark误认为HTTP协议。可能微信是无心为之吧。
3. 新消息获取方式
- TCP长连接接收到服务器通知有新消息需要获取
- APP发起一个HTTP POST请求获取新状态消息,会带上当前SyncKey 地址为:http://short.weixin.qq.com/cgi-bin/micromsg-bin/reportstrategy HTTP/1.1,看不到明文
- APP获取到新的消息,会再次发起一次HTTP POST请求,告诉服务器已确认收到,同时获取最新SyncKey 地址为:http://short.weixin.qq.com/cgi-bin/micromsg-bin/kvreport,看不到明文
- 接受一个消息,TCP长连接至少交互两次,客户端发起两次HTTP POST请求
具体每次交互内容是什么,有些模糊 - 服务器需要支持:状态消息获取标记,状态消息确认收取标记。只有被确认收到,此状态消息才算是被正确消费掉
- 多个不同设备同一账号同时使用微信,同一个状态消息会会被同时分发到多个设备上
此时消息请求截图如下:
4. 发送消息方式
发送消息走已经建立的TCP长连接通道,发送消息到服务器,然后接受确认信息等,产生一次交互。
小伙伴接收到信息阅读也都会收到服务器端通知,产生一次交互等。
可以确定,微信发送消息走TCP长连接方式,因为不对自身状态数据产生影响,应该不交换SyncKey。
- 在低速网络下,大概会看到消息发送中的提示,属于消息重发机制
- 网络不好有时客户端会出现发送失败的红色感叹号
- 已发送到服务器但未收到确认的消息,客户端显示红色感叹号,再次重发,服务器作为重复消息处理,反馈确认
- 上传图片,会根据图片大小,分割成若干部分(大概1.5K被划分为一部分),同一时间点,客户端会发起若干次POST请求,各自上传成功之后,服务器大概会合并成一个完整图片,返回一个缩略图,显示在APP聊天窗口内。APP作为常规的文字消息发送到服务器端
- 上传音频,则单独走TCP通道,一个两秒的录制音频,客户端录制完毕,分为两块传输,一块最大1.5K左右,服务端响应一条数据通知确认收到。共三次数据传输。
音频和纯文字信息一致,都是走TCP长连接,客户端发送,服务器端确认。
四。微信协议小结
- 发布的消息对应一个ID(只要单个方向唯一即可,服务器端可能会根ID判断重复接收),消息重传机制确保有限次的重试,重试失败给予用户提示,发送成功会反馈确认,客户端只有收到确认信息才知道发送成功。发送消息可能不会产生新SyncKey。
- 基于版本号(SynKey)的状态消息同步机制,增量、有序传输需求水到渠成。长连接通知/短连接获取、确认等,交互方式简单,确保了消息可靠谱、准确无误到达。
- 客户端/服务器端都会存储消息ID处理记录,避免被重复消费客户端获取最新消息,但未确认,服务器端不会认为该消息被消费掉。下次客户端会重新获取,会查询当前消息是否被处理过。根据一些现象猜测。
- 总体上看,微信协议跨平台(TCP或HTPP都可呈现,处理方式可统一),通过“握手”同步,很可靠,无论哪一个平台都可以支持的很好
- 微信协议最小成本为16字节,大部分时间若干个消息包和在一起,批量传输。微信协议说不上最简洁,也不是最节省流量,但是非常成功的。
- 若服务器检测到一些不确定因素,可能会导致微启用安全套接层SSL协议进行常规的TCP长连接传输。短连接都没有发生变化
以上,根据有限资料和数据拦截观察总结得出,啰啰嗦嗦,勉强凑成一篇,会存在一些不正确之处,欢迎给予纠正。在多次
五。附录
Microsoft Exchange Active Sync协议,简称EAS,分为folderrsync(同步文件夹目录,即邮箱内有哪几个文件夹)和sync(每个文件夹内有哪些文档)两部分。
某网友总结的协议一次回话大致示范:
Client: synckey=0 //第一次key为0
Server: newsynckey=1235434 //第一次返回新key
Client: synckey=1235434 //使用新key查询
Server: newsynckey=1647645,data=*****//第一次查询,得到新key和数据
Client: synckey=1647645 Server: newsynckey=5637535,data=null //第二次查询,无新消息 Client: synckey=5637535 Server: newsynckey=8654542, data=****//第三次查询,增量同步
- 上页中的相邻请求都是隔固定时间的,如两分钟
- 客户端每次使用旧key标记自己的状态,服务端每次将新key和增量数据一起返回。
- key是递增的,但不要求连续
- 请求的某个参数决定服务器是否立即返回
http://www.blogjava.net/yongboy/archive/2015/11/05/410636.html
https://github.com/fritx/awesome-wechat
微信协议简单调研笔记相关推荐
- [行业调研]区块链技术与行业发展简单调研笔记
##################### 区块链 (Blockchain) ##################### 区块链是一种按照时间顺序将数据区块以顺序相连的方式组合成的一 ...
- 实战 | 一键导出微信阅读记录和笔记
点击上方"逆锋起笔",关注领取视频教程 ☞ 程序员进阶必备资源免费送「各种技术!」 ☜ 作者:云外孤鸟 出处:https://www.cnblogs.com/cloudbird/p ...
- 微信协议pc微信协议
最近在做个微信机器人,所以研究了网页版的微信协议及相关接口,在这里简单总结一下. 从表面上看,对于网页版微信我们的使用流程是这样的: 很简单,只有四步,但如果细化到内里细节的话,上面这简单四步的背后其 ...
- 云开发(微信-小程序)笔记(十四)---- 收藏,点赞(上)
云开发(微信-小程序)笔记(十三)---- 注册登陆 1.简介 点赞,收藏等都是程序的最简单的功能,在现实的应用中也很常见.这里我就来给大家介绍一下小程序的这个功能. 图标下载地址 先去网站上http ...
- 微信小程序开发笔记 进阶篇④——getPhoneNumber 获取用户手机号码(小程序云)
文章目录 一.前言 二.前端代码wxml 三.前端代码js 四.云函数 五.程序流程 一.前言 微信小程序开发笔记--导读 大部分微信小程序开发者都会有这样的需求:获取小程序用户的手机号码. 但是,因 ...
- 微信小程序开发笔记 进阶篇⑤——getPhoneNumber 获取用户手机号码(基础库 2.21.2 之前)
文章目录 一.前言 二.前端代码wxml 三.前端代码js 四.后端java 五.程序流程 六.参考 一.前言 微信小程序开发笔记--导读 大部分微信小程序开发者都会有这样的需求:获取小程序用户的手机 ...
- CTFHUB http协议题目 学习笔记 详细步骤 请求方式 302跳转 cookie 基础认证 响应源代码
CTFHUB http协议题目 学习笔记 详细步骤 请求方式 302跳转 cookie 基础认证 响应源代码 WEB-HTTP协议 1-请求方式 2-302跳转 3.cookie 4.基础认证 5.响 ...
- 微信小程序开发笔记 进阶篇③——onfire.js事件订阅和发布在微信小程序中的使用
文章目录 一.前言 二.onfire.js介绍 三.API介绍 四.实例应用 五.onfire源码 六.实例源码 一.前言 微信小程序开发笔记--导读 二.onfire.js介绍 一个简单实用的事件订 ...
- 微信小程序开发笔记 进阶篇⑥——getPhoneNumber 获取用户手机号码(基础库 2.21.2 之后)
文章目录 一.前言 二.前端代码wxml 三.前端代码js 四.后端java 五.程序流程 六.参考 一.前言 微信小程序开发笔记--导读 大部分微信小程序开发者都会有这样的需求:获取小程序用户的手机 ...
最新文章
- OSPF身份验证配置实例
- who is the one who actually know the essential things in life?
- guava 并发_Google Guava并发– ListenableFuture
- [BZOJ2152]聪聪可可(点分治)
- python不变的数据结构是_Python cookbook(数据结构与算法)从序列中移除重复项且保持元素间顺序不变的方法...
- DBA_实践指南系列7_Oracle Erp R12监控OAM(案例)
- C# 获得当前运行程序所在的目录的代码
- Js、jquery学习笔记
- 大气辐射示意简单图_地理笔记 | N21 自然地理——大气的组成与垂直分层
- 1.3.1 互联网的边缘部分(资源子网)
- c语言写按键控制蜂鸣器,单片机按键控制蜂鸣器演奏音乐
- android 发短信 oppo,OPPO R11短信发送失败怎么办?OPPO手机短信发送失败的解决方法...
- 热更新原理及实践注意
- 常用的sql语句,sql使用大全
- git push错误failed to push some refs to的解决
- 拼多多商品详情,产品竞价,关键词搜索接口
- kaggle比赛记录——ieee_fraud_detection问题
- Font\隶书\“ is not available to the JVM See the Javados for more details
- java ftp命令_Java实现FTP
- keystone提示“......The client is assumed to be in error. (HTTP 400) (Request-ID: req-7d2a33be-..)“错误
热门文章
- 重要更新 | 谷歌发布 TensorFlow 1.4,迁移Keras,支持分布式训练
- SpringBoot 项目瘦身指南,瘦到不可思议!
- 为什么 HashMap 加载因子一定是0.75?而不是0.8,0.6?
- MySQL:left join 避坑指南
- JDK 13 的 12 个新特性,真心涨姿势了
- Java并发编程:JMM和volatile关键字
- 五年之内博士会开始内卷吗?
- AAAI 2021 最「严」一届发榜,1692 篇论文中选,录取率仅为 21%
- 竞赛比完,代码、模型怎么处理?Kaggle 大神:别删,这都是宝藏
- 计算机视觉工程师一天都大致在做些什么?