七种轮询介绍(后附实践链接)
我有一个朋友~
做了一个小破站,现在要实现一个站内信web消息推送的功能,对,就是下图这个小红点,一个很常用的功能。
不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。
案例下载,记得Star 哦
什么是消息推送(push)
推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。
消息推送(push
)通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备APP进行的主动消息推送。
消息推送一般又分为web端消息推送
和移动端消息推送
。
上边的这种属于移动端消息推送,web端消息推送常见的诸如站内信、未读邮件数量、监控报警数量等,应用的也非常广泛。
在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),web页面的通知小红点就会实时的+1
就可以了。
通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。
消息推送无非是推(push
)和拉(pull
)两种形式,下边我们逐个了解下。
短轮询
轮询(polling
)应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询
和长轮询
。
短轮询很好理解,指定的时间间隔,由浏览器向服务器发出HTTP
请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。
一个简单的JS定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。
setInterval(() => {// 方法请求messageCount().then((res) => {if (res.code === 200) {this.messageCount = res.data}})
}, 1000);
效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。
长轮询
长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如Nacos
和apollo
配置中心,消息队列kafka
、RocketMQ
中都有用到长轮询。
Nacos配置中心交互模型是push还是pull?一文中我详细介绍过Nacos
长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。
这次我使用apollo
配置中心实现长轮询的方式,应用了一个类DeferredResult
,它是在servelet3.0
后经过Spring封装提供的一种异步请求机制,直意就是延迟结果。
DeferredResult
可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)
提交响应结果。
下边我们用长轮询来实现消息推送。
因为一个ID可能会被多个长轮询请求监听,所以我采用了guava
包提供的Multimap
结构存放长轮询,一个key可以对应多个value。一旦监听到key发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。
@Controller
@RequestMapping("/polling")
public class PollingController {
<span class="token comment">// 存放监听某个Id的长轮询集合</span>
<span class="token comment">// 线程同步结构</span>
<span class="token keyword">public</span> <span class="token keyword">static</span> <span class="token class-name">Multimap</span><span class="token generics"><span class="token punctuation"><</span><span class="token class-name">String</span><span class="token punctuation">,</span> <span class="token class-name">DeferredResult</span><span class="token punctuation"><</span><span class="token class-name">String</span><span class="token punctuation">></span><span class="token punctuation">></span></span> watchRequests <span class="token operator">=</span> <span class="token class-name">Multimaps</span><span class="token punctuation">.</span><span class="token function">synchronizedMultimap</span><span class="token punctuation">(</span><span class="token class-name">HashMultimap</span><span class="token punctuation">.</span><span class="token function">create</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token comment">/*** 公众号:程序员小富* 设置监听*/</span>
<span class="token annotation punctuation">@GetMapping</span><span class="token punctuation">(</span>path <span class="token operator">=</span> <span class="token string">"watch/{id}"</span><span class="token punctuation">)</span>
<span class="token annotation punctuation">@ResponseBody</span>
<span class="token keyword">public</span> <span class="token class-name">DeferredResult</span><span class="token generics"><span class="token punctuation"><</span><span class="token class-name">String</span><span class="token punctuation">></span></span> <span class="token function">watch</span><span class="token punctuation">(</span><span class="token annotation punctuation">@PathVariable</span> <span class="token class-name">String</span> id<span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span><span class="token comment">// 延迟对象设置超时时间</span><span class="token class-name">DeferredResult</span><span class="token generics"><span class="token punctuation"><</span><span class="token class-name">String</span><span class="token punctuation">></span></span> deferredResult <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">DeferredResult</span><span class="token generics"><span class="token punctuation"><</span><span class="token punctuation">></span></span><span class="token punctuation">(</span>TIME_OUT<span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token comment">// 异步请求完成时移除 key,防止内存溢出</span>deferredResult<span class="token punctuation">.</span><span class="token function">onCompletion</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">-></span> <span class="token punctuation">{<!-- --></span>watchRequests<span class="token punctuation">.</span><span class="token function">remove</span><span class="token punctuation">(</span>id<span class="token punctuation">,</span> deferredResult<span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token comment">// 注册长轮询请求</span>watchRequests<span class="token punctuation">.</span><span class="token function">put</span><span class="token punctuation">(</span>id<span class="token punctuation">,</span> deferredResult<span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token keyword">return</span> deferredResult<span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token comment">/*** 公众号:程序员小富* 变更数据*/</span>
<span class="token annotation punctuation">@GetMapping</span><span class="token punctuation">(</span>path <span class="token operator">=</span> <span class="token string">"publish/{id}"</span><span class="token punctuation">)</span>
<span class="token annotation punctuation">@ResponseBody</span>
<span class="token keyword">public</span> <span class="token class-name">String</span> <span class="token function">publish</span><span class="token punctuation">(</span><span class="token annotation punctuation">@PathVariable</span> <span class="token class-name">String</span> id<span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span><span class="token comment">// 数据变更 取出监听ID的所有长轮询请求,并一一响应处理</span><span class="token keyword">if</span> <span class="token punctuation">(</span>watchRequests<span class="token punctuation">.</span><span class="token function">containsKey</span><span class="token punctuation">(</span>id<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span><span class="token class-name">Collection</span><span class="token generics"><span class="token punctuation"><</span><span class="token class-name">DeferredResult</span><span class="token punctuation"><</span><span class="token class-name">String</span><span class="token punctuation">></span><span class="token punctuation">></span></span> deferredResults <span class="token operator">=</span> watchRequests<span class="token punctuation">.</span><span class="token function">get</span><span class="token punctuation">(</span>id<span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token class-name">DeferredResult</span><span class="token generics"><span class="token punctuation"><</span><span class="token class-name">String</span><span class="token punctuation">></span></span> deferredResult <span class="token operator">:</span> deferredResults<span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span>deferredResult<span class="token punctuation">.</span><span class="token function">setResult</span><span class="token punctuation">(</span><span class="token string">"我更新了"</span> <span class="token operator">+</span> <span class="token keyword">new</span> <span class="token class-name">Date</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token punctuation">}</span><span class="token punctuation">}</span><span class="token keyword">return</span> <span class="token string">"success"</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException
异常,这里直接用@ControllerAdvice
全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。
@ControllerAdvice
public class AsyncRequestTimeoutHandler {
@ResponseStatus(HttpStatus.NOT_MODIFIED)
@ResponseBody
@ExceptionHandler(AsyncRequestTimeoutException.class)
public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) {System.out.println("异步请求超时");return "304";
}
}
我们来测试一下,首先页面发起长轮询请求/polling/watch/10086
监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086
,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。
长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。
iframe流
iframe流就是在页面中插入一个隐藏的<iframe>
标签,通过在src
中请求消息数量API接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe
传输数据。
传输的数据通常是
HTML
、或是内嵌的javascript
脚本,来达到实时更新页面的效果。
这种方式实现简单,前端只要一个<iframe>
标签搞定了
<iframe src="/iframe/message" style="display:none"></iframe>
服务端直接组装html、js脚本数据向response
写入就行了
@Controller
@RequestMapping("/iframe")
public class IframeController {@GetMapping(path = "message")public void message(HttpServletResponse response) throws IOException, InterruptedException {while (true) {response.setHeader("Pragma", "no-cache");response.setDateHeader("Expires", 0);response.setHeader("Cache-Control", "no-cache,no-store");response.setStatus(HttpServletResponse.SC_OK);response.getWriter().print(" <script type=\"text/javascript\">\n" +"parent.document.getElementById('clock').innerHTML = \"" + count.get() + "\";" +"parent.document.getElementById('count').innerHTML = \"" + count.get() + "\";" +"</script>");}}
}
但我个人不推荐,因为它在浏览器上会显示请求未加载完,图标会不停旋转,简直是强迫症杀手。
SSE (我的方式)
很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket
这种耳熟能详的机制外,还有一种服务器发送事件(Server-sent events
),简称SSE
。
SSE
它是基于HTTP
协议的,我们知道一般意义上的HTTP协议是无法做到服务端主动向客户端推送消息的,但SSE是个例外,它变换了一种思路。
SSE在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream
类型的数据流信息,在有数据变更时从服务器流式传输到客户端。
整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。
SSE
与WebSocket
作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:
- SSE 是基于HTTP协议的,它们不需要特殊的协议或服务器实现即可工作;
WebSocket
需单独服务器来处理协议。 - SSE 单向通信,只能由服务端向客户端单向通信;webSocket全双工通信,即通信的双方可以同时发送和接受信息。
- SSE 实现简单开发成本低,无需引入其他组件;WebSocket传输数据需做二次解析,开发门槛高一些。
- SSE 默认支持断线重连;WebSocket则需要自己实现。
- SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket默认支持传送二进制数据。
SSE 与 WebSocket 该如何选择?
技术并没有好坏之分,只有哪个更合适
SSE好像一直不被大家所熟知,一部分原因是出现了WebSockets,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。
但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE
不管是从实现的难易和成本上都更加有优势。此外,SSE 具有WebSockets
在设计上缺乏的多种功能,例如:自动重新连接
、事件ID
和发送任意事件
的能力。
前端只需进行一次HTTP请求,带上唯一ID,打开事件流,监听服务端推送的事件就可以了
<script>let source = null;let userId = 7777if (window.EventSource) {// 建立连接source = new EventSource('http://localhost:7777/sse/sub/'+userId);setMessageInnerHTML("连接用户=" + userId);/*** 连接一旦建立,就会触发open事件* 另一种写法:source.onopen = function (event) {}*/source.addEventListener('open', function (e) {setMessageInnerHTML("建立连接。。。");}, false);/*** 客户端收到服务器发来的数据* 另一种写法:source.onmessage = function (event) {}*/source.addEventListener('message', function (e) {setMessageInnerHTML(e.data);});} else {setMessageInnerHTML("你的浏览器不支持SSE");}
</script>
服务端的实现更简单,创建一个SseEmitter
对象放入sseEmitterMap
进行管理
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
/**
- 创建连接
- @date: 2022/7/12 14:51
- @auther: 公众号:程序员小富
*/
public static SseEmitter connect(String userId) {
try {
// 设置超时时间,0表示不过期。默认30秒
SseEmitter sseEmitter = new SseEmitter(0L);
// 注册回调
sseEmitter.onCompletion(completionCallBack(userId));
sseEmitter.onError(errorCallBack(userId));
sseEmitter.onTimeout(timeoutCallBack(userId));
sseEmitterMap.put(userId, sseEmitter);
count.getAndIncrement();
return sseEmitter;
} catch (Exception e) {
log.info(“创建新的sse连接异常,当前用户:{}”, userId);
}
return null;
}
/**
给指定用户发送消息
@date: 2022/7/12 14:51
@auther: 公众号:程序员小富
*/
public static void sendMessage(String userId, String message) {if (sseEmitterMap.containsKey(userId)) {
try {
sseEmitterMap.get(userId).send(message);
} catch (IOException e) {
log.error(“用户[{}]推送异常:{}”, userId, e.getMessage());
removeUser(userId);
}
}
}
我们模拟服务端推送消息,看下客户端收到了消息,和我们预期的效果一致。
注意: SSE不支持IE
浏览器,对其他主流浏览器兼容性做的还不错。
MQTT
什么是 MQTT协议?
MQTT
全称(Message Queue Telemetry Transport):一种基于发布/订阅(publish
/subscribe
)模式的轻量级
通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing
)中的一个标准传输协议。
该协议将消息的发布者(publisher
)与订阅者(subscriber
)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的MQ有点类似。
TCP
协议位于传输层,MQTT
协议位于应用层,MQTT
协议构建于TCP/IP
协议上,也就是说只要支持TCP/IP
协议栈的地方,都可以使用MQTT
协议。
为什么要用 MQTT协议?
MQTT
协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP
协议呢?
首先
HTTP
协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合IOT
应用程序。HTTP
是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。通常需要将一条命令或者消息,发送到网络上的所有设备上。
HTTP
要实现这样的功能不但很困难,而且成本极高。
具体的MQTT协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。
MQTT协议的介绍
我也没想到 springboot + rabbitmq 做智能家居,会这么简单
MQTT实现消息推送
未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~
Websocket
websocket
应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲SSE的时候也和websocket进行过比较。
WebSocket是一种在TCP
连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
springboot整合websocket,先引入websocket
相关的工具包,和SSE相比额外的开发成本。
<!-- 引入websocket -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
服务端使用@ServerEndpoint
注解标注当前类为一个websocket服务器,客户端可以通过ws://localhost:7777/webSocket/10086
来连接到WebSocket服务器端。
@Component
@Slf4j
@ServerEndpoint("/websocket/{userId}")
public class WebSocketServer {//与某个客户端的连接会话,需要通过它来给客户端发送数据private Session session;private static final CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();// 用来存在线连接数private static final Map<String, Session> sessionPool = new HashMap<String, Session>();/*** 公众号:程序员小富* 链接成功调用的方法*/@OnOpenpublic void onOpen(Session session, @PathParam(value = "userId") String userId) {try {this.session = session;webSockets.add(this);sessionPool.put(userId, session);log.info("websocket消息: 有新的连接,总数为:" + webSockets.size());} catch (Exception e) {}}/*** 公众号:程序员小富* 收到客户端消息后调用的方法*/@OnMessagepublic void onMessage(String message) {log.info("websocket消息: 收到客户端消息:" + message);}/*** 公众号:程序员小富* 此为单点消息*/public void sendOneMessage(String userId, String message) {Session session = sessionPool.get(userId);if (session != null && session.isOpen()) {try {log.info("websocket消: 单点消息:" + message);session.getAsyncRemote().sendText(message);} catch (Exception e) {e.printStackTrace();}}}
}
前端初始化打开WebSocket连接,并监听连接状态,接收服务端数据或向服务端发送数据。
<script>var ws = new WebSocket('ws://localhost:7777/webSocket/10086');// 获取连接状态console.log('ws连接状态:' + ws.readyState);//监听是否连接成功ws.onopen = function () {console.log('ws连接状态:' + ws.readyState);//连接成功则发送一个数据ws.send('test1');}// 接听服务器发回的信息并处理展示ws.onmessage = function (data) {console.log('接收到来自服务器的消息:');console.log(data);//完成通信后关闭WebSocket连接ws.close();}// 监听连接关闭事件ws.onclose = function () {// 监听整个过程中websocket的状态console.log('ws连接状态:' + ws.readyState);}// 监听并处理error事件ws.onerror = function (error) {console.log(error);}function sendMessage() {var content = $("#message").val();$.ajax({url: '/socket/publish?userId=10086&message=' + content,type: 'GET',data: { "id": "7777", "content": content },success: function (data) {console.log(data)}})}
</script>
页面初始化建立websocket连接,之后就可以进行双向通信了,效果还不错
自定义推送
上边我们给我出了6种方案的原理和代码实现,但在实际业务开发过程中,不能盲目的直接拿过来用,还是要结合自身系统业务的特点和实际场景来选择合适的方案。
推送最直接的方式就是使用第三推送平台,毕竟钱能解决的需求都不是问题,无需复杂的开发运维,直接可以使用,省时、省力、省心,像goEasy、极光推送都是很不错的三方服务商。
一般大型公司都有自研的消息推送平台,像我们本次实现的web站内信只是平台上的一个触点而已,短信、邮件、微信公众号、小程序凡是可以触达到用户的渠道都可以接入进来。
消息推送系统内部是相当复杂的,诸如消息内容的维护审核、圈定推送人群、触达过滤拦截(推送的规则频次、时段、数量、黑白名单、关键词等等)、推送失败补偿非常多的模块,技术上涉及到大数据量、高并发的场景也很多。所以我们今天的实现方式在这个庞大的系统面前只是小打小闹。
Github地址
文中所提到的案例我都一一的做了实现,整理放在了Github
上,觉得有用就 Star 一下吧!
传送门:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-realtime-data
无论你是刚入行、还是已经有几年经验的程序员,相信这份面试提纲都会给你不少助力,长按二维码关注 『 程序员小富 』 ,回复 『 offer 』 自行领取,祝大家 offer 拿到手软!
七种轮询介绍(后附实践链接)相关推荐
- MySQL索引的概念以及七种索引类型介绍
MySQL索引的概念以及七种索引类型介绍. 文章目录 1 索引的概念 2 索引的类型 1 索引的概念 索引(在MySQL中也叫做"键(key)")是存储引擎用于快速找到记录的一种数 ...
- Python开发工具的七种“工具”的介绍
原文地址为: Python开发工具的七种"工具"的介绍 本文主要是对Python开发工具的七种介绍,其中包括, IDLE. BlackAdder.PythonWorks.Wing ...
- RabbitMQ七种队列模式介绍与应用场景(通俗易懂)
七种模式介绍与应用场景 简单模式(Hello World) 做最简单的事情,一个生产者对应一个消费者,RabbitMQ相当于一个消息代理,负责将A的消息转发给B 应用场景:将发送的电子邮件放到消息队列 ...
- 七种MYSQL插入数据后返回自增主键ID的方法
我们都知道,mysql中的insert插入之后会有返回值,返回的是影响的行数,也就是说,成功插入一条数据之后返回的是1,失败则返回0.那么,很多时候我们都想要得到最后插入的id值,下面七种方法均可,结 ...
- 浅谈常见的七种加密算法及实现(附代码)
1. 前言 数字签名.信息加密 是前后端开发都经常需要使用到的技术,应用场景包括了用户登入.交易.信息通讯.oauth 等等,不同的应用场景也会需要使用到不同的签名加密算法,或者需要搭配不一样的 签名 ...
- celeba数据集_人脸识别常用数据集介绍(附下载链接)及常用评估指标
为什么要聊到数据集这个话题..因为数据集的noise对训练效果的影响很大!很长一段时间MegaFace的效果都上不去,就是因为数据集噪声的原因.而且自己在训练人脸的时候,如果不对数据集的噪声和属性有一 ...
- nfc writer教程安卓_关于nfc模拟加密门禁卡详细教程(后附软件链接)
本帖最后由 huafans01323560337 于 2019-10-29 09:47 编辑 由于之前帖子描述的不是很清楚,在此重开一帖(帖子搬运自酷安,仅供参考,不保证一定成功) 之前我是按照这个 ...
- python官方下载链接_Python下载、安装及简单操作介绍(附下载链接)
Python下载 官网:https://www.python.org/ #打开及下载速度非常慢,不建议 网盘: 3.5版本(链接:https://pan.baidu.com/s/1lB6jWjWG_o ...
- 用了这么久配置中心,还不知道长轮询是什么?
前言 传统的静态配置方式想要修改某个配置时,必须重新启动一次应用,如果是数据库连接串的变更,那可能还容易接受一些,但如果变更的是一些运行时实时感知的配置,如某个功能项的开关,重启应用就显得有点大动干戈 ...
最新文章
- webpack学习之路
- Python学习之路—2018/6/20
- 支持向量回归 svr
- 直播预告丨爆款独立站如何利用数据提升经营效率?
- sklearn自学指南(part7)--使用手册的目录
- STM32之GPIO浮空输入例程
- PageRank算法原理与实现
- C# 海康DVR客户端开发系列(2)—— 封装API (1)
- matlab画图,想让子图使用不同的色标
- 仅需10道题轻松掌握Python字符串方法 | Python技能树征题
- 扑克牌图片一张一张_扑克牌玩法 | 简单易上手的扑克游戏,重点是你没玩过!...
- 小程序按钮调用扫一扫_他在一个小程序“按钮”上动了个手脚,生意大火,赢得美人归!...
- uniapp自定义字体图标、使用阿里矢量图标库
- 弘辽科技:拼多多直通车测款是怎样操作的
- C#生成与识别条形码、二维码示例 zxing、 barcodelib生成条形码 code128B等 无白边 自动宽度
- java环境配好后jar文件打开闪退,无打开方式,无反应
- 所见即所得的 Markdown 编辑器
- 英伟达最新驱动打开3d vision功能
- 《金山词霸2009 牛津版》插件工具加载
- 电脑提醒没有权限在此位置保存文件怎么办?