zoukankan      html  css  js  c++  java
  • TCP Socket通信详细过程

      下面这篇文章是参考"骏马金龙"博客中
        不可不知的socket和TCP连接过程 https://www.cnblogs.com/f-ck-need-u/p/7623252.html
      这篇博文对我的启发很大,但文中比较核心一些东西说明的不是非常详细,导致整片文章对于初学者还是
     难度太大,这篇文章引用了部分该文中的内容,主要是为了能够让整篇文章能连贯,一便让更多想要深入计算机
     原理的人,有更多参考,能更快明了一个Client发送一个访问Web服务器的请求,到底WebServer如何处理接收
     到请求报文,并最终完成整个通信过程,这篇文章将深入说明,但由于本人能力有限,很多理解是自己通过对CPU
     内存,网络的理解推演出来的,没有很确凿的证据证明,也希望大牛路过,多给予指教,不要让我误人子弟了。
     非常感谢!同时也非常感谢认真的学习道友"骏马金龙",让我能将积累的知识连贯起来,用最简单的语言尽最大
     可能将这个复杂过程,说的尽可能清楚。

      说明:

        此篇是TCP深入刨析的上篇,核心点是说明Clinet和Server如何完成TCP三次握手。
        TCP深入刨析是下篇,核心点是说明Client和Server是如何完成数据传输的。

      首先来一张TCP socket通信过程图

      

     socket()函数:
        就是生成一个用于通信的套接字文件描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。
      这个套接字描述符可以作为稍后bind()函数的绑定对象。
        socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); 这是socket函数在内核源码中定义的函数,从这个结构中可看出来,
      socket函数仅创建了一个socket结构文件,但并没有关联任何IP和端口,其中AF_INET是AddressFamily(地址族)是Inernet
      网络地址,SOCK_STREAM,是指定socket接受数据格式,共有两种,一种是stream(流,),另一种是dgram(数据报),最后
      一个是协议TCP/UDP.很多时候,内核其实根据AF_INET和SOCK_STREAM就可以推演出应该使用TCP,因此
      IPPROTO_TCP/UDP可省略,但这并不是好习惯。

    bind()函数:
        服务程序通过分析配置文件,从中解析出想要监听的地址和端口,再加上可以通过socket()函数生成的套接字sockfd,
      就可以使用bind()函数将这个套接字绑定到要监听的地址和端口组合"addr:port"上。绑定了端口的套接字可以作为listen()
      函数的监听对象。绑定了地址和端口的套接字就有了源地址和源端口(对服务器自身来说是源),再加上通过配置文件中
      指定的协议类型,五元组中就有了其中3个元组。即:{protocal,src_addr,src_port} 但是,常见到有些服务程序可以
      配置监听多个地址、端口实现多实例。这实际上就是通过多次socket()+bind()系统调用生成并绑定多个套接字实现的。
        所谓五元组,即 {protocal, Server_src_addr, Server_src_port, Client_dest_addr, Client_dest_port}
        当还处于监听状态时,套接字称为监听套接字,此时它只包含三元组。
        TCP报文结构:

          

          当客户端与服务器通过三次握手建链,同步了TCP保障会话的状态序列号(Sequence Number),
       窗口大小(Window Size)以及Client的源IP和源端口,这是服务器端的监听套接字就可以构建成完整的
       专用连接套接字,即五个关键元素组成了新的socket。当然Client也会生成本次于Server通信的专用
       连接套接字。注意:是专用连接套接字

      补充说明:
        TCP之所以称为可靠连接就是由于其包含Sequence number和Acknowledgment number,
       TCP通过它们来实现数据传输的确认机制,简单理解如下:

        #这里为方便说明,将汉字,单词 和 点直接简单认为是1个数据.
        #ack=1,是假设Server三次握手最后一次传递的seq=0,我要对它发给我的数据做确认
        Client----[我想获取index.html, seq=7,ack=1]----------------->Server

        Server---[index.html{欢迎来到我们的网站.},seq=12,ack=8]--->Client
        Server---[{学习园地.},seq=17,ack=8]--------------------------->Client

        这里为了方便说明,但并非完全准确

        假设Server要传递给Client的数据量很大,被拆分成了多个包,这里仅以两个为例说明,
        第一个报文总长度为12个字,假设已经满了,然后又生成了第二个数据包长度5个字,
       这时它们的序列号需要注意,你会发现其实序列号就是传输数据量的说明,即我这次给你传了
         多少数据,假设网络状况不是很好,第一个包在传输过程中丢失了,Client收到了第二个报文,
         Client解包后,发现这个序列号是13-17,然后,Client就会给Server发送一个ACK报文,这个报文
         仅对13-17段数据做确认,Server收到后,过了最大RTT(数据包从Server到Client直接最大往返时间)
         发现Client还是没有对1~12这段数据做确认,于是知道第一个报文在网络中传输时丢包了,然后
         就会仅将1~12这段数据再次发送给Client,当Server在RTT之前收到Client对这段数据的ACK,
        则认为通信完成,等待Client后续的请求,若长连接超时,Client没有再次发起请求,则Server将
        主动断开连接,然后进入TCP的四次挥手阶段。
        注意:这里确认13~17这段数据时是这样的:
          Client--[seq=9,ack=18]------------------------------------>Server
          #Server是发送数据者,它知道自己发了那些数据,Server看到18,对比发送列表,1~12,13~17,
            这个ACK包确认的一定是第二个包,因为第一个包的ACK seq应该是13,而不是17.

      说明:
        实际上一个数据包的大小是由网卡上的MTU值决定,默认MTU是1500个字节,去掉TCP/IP协议栈
        封装头部大概是1446个字节,然后还要去掉上层不同协议封装的头部字节数,剩下才是这个数据包实际
        能装多少数据,但这个还是额定数据量,正式往数据包中装数据还要看窗口大小(window size),它是
      Client和Server之间协商出来的,因为Server或Client都可能因为某些原因接收不了很多数据,因此为了
      能通信,在通信前是必须互相告知自己一次最多能接受多少数据的。
      MTU(最大传输单元): 现在网络中所有设备都默认是1500字节,一个数据包在网络中传输最大必须
          是1500字节,只要超过就会被网络设备切片后,重新封装再发生,但前提是IP包中允许分片
          位是1,即允许分片,否则该数据包将被丢弃。

    listen()函数:
      int listen(int sockfd, int backlog); 这是内核源码中listen函数的定义,sockfd 就是bind函数关联后的套接字文件描述符。
      backlog:
        Linux Kernel2.2以前,backlog 用于设置上图中未完成和已完成队列的最大总长度(实际上是只有一个队列,
        但分为两种状态),实际目前这种是BSD衍生的一种套接字类型,它采用了一个队列,在这单个队列中存放
        3次握手过程中的所有连接,但是队列中的每个连接分为两种状态:syn-recv和established。

      Linux Kernel2.2开始,这个参数只表示已完成队列(accept queue)的最大长度,而/proc/sys/net/ipv4/tcp_max_syn_backlog
      则用于设置未完成队列(syn queue/syn backlog)的最大长度。/proc/sys/net/core/somaxconn则是硬限制已完成队列
      的最大长度,默认为128,如果backlog参数大于somaxconn,则backlog会被截短为该硬限制值。
      参考下图: 此图来自https://www.cnblogs.com/love-yh/p/7518552.html

        

         说明: 图中提到的分片,实际应该称为IP报文,不要与IP分片混淆。


      下面说明listen函数,已httpd为参考来说明,这样更便于说明问题;另外下面说明并不一定完全正确,多数是根据我的理解
     推演出来的,所以下面说明仅供参考,希望大牛路过,能给予指点,不要让我误人子弟了。
        当httpd进程被启动后,它通过读取配置文件,获取到要监听的地址和端口,完成socket和bind后,就进入到listen函数了,
       listen系统调用会向内核管理的socket set(集合)中注册自己的监听套接字,然后返回,此时作为httpd的perforce模型的话,
       它将调用select()函数,此函数会发起系统调用来获取内核管理的socket set,并检查其中自己所关心的socket是否处于就绪态,
       即是否变为可读,可写,异常; 若非就绪态,就继续过会儿在检查,这期间httpd将处于阻塞状态的,直到httpd所分配的CPU时间片
       都耗尽,CPU会将httpd从CPU上转入内存处于睡眠,等待下次被调度到CPU上执行;
       假设现在有用户发起访问,则大致过程如下:
       首先,服务器网卡接收到数据流后,会立刻给CPU发送中断信号,CPU收到后,会立即将手头正在处理的事务
      全部挂起,并立即检测网卡上是否有DMA芯片,若有就直接发送指令告诉DMA芯片,你将数据流复制到指定的DMA_ZONE的
      核心内存区中的指定地址段中,然后DMA芯片就开始复制,CPU重新恢复挂起的进程,继续处理,当网卡DMA芯片再次发送
      中断告诉CPU我复制完了,此时CPU将立即挂载正在处理的进程,并激活内核,内核获得CPU控制权后,启动TCP/IP协议栈
      驱动处理接收到的数据包,解封装后,发现是一个要访问本机80套接字的SYN请求,于是内核检查socket set发现有这样的监听
      套接字,于是内核将SYN数据复制到Kernel buffer中,进行进一步处理(如判断SYN是否合理),然后准备SYN+ACK数据完成
      后经过TCP/IP协议栈驱动封装IP头,链路层帧头,最终这个数据被写入到send buffer中,并立即被复制到网卡传送出去,同时
      内核还会在连接未完成队列(syn queue)中为这个连接创建一个新项目,并设置为SYN_RECV状态。
      接着内核进入睡眠,CPU检查被挂起的进程的时间片是否耗尽,没有就将其调度到CPU上继续执行,否则继续将其它用户空间
      的进程调度到CPU上执行,当Client收到Server的响应后,回应了ACK报文,Server的网卡收到后,又会继续上面的动作,CPU
      会再次挂起正在处理的进程,并将数据接收进来,复制到指定的kernel buffer中(注:DMA_Zone也是Kernel buffer的一部分,
      这里不严格区分它们的区别) 接着CPU会唤醒内核,又它调度TCP/IP协议栈驱动处理收到的数据包,当解封装后,发现是一个
      ACK报文,并且数据段大小为0,这时内核会去检查未完成连接队列,若找到与该ClientInfo(客户端信息)一致的连接信息,则
      将该连接从未完成连接列表中删除,然后在已完成连接队列中插入该连接信息,并标记状态为ESTABLISHED,接着将内核中
      维护的socket set中80监听套接字的状态更为为可读,随后内核让出CPU,进入睡眠,CPU继续将挂起任务载入CPU上执行,
      若CPU时间片用完,则将其转入内存进入睡眠,继续下一个用户空间的进程,假设此时调度httpd进程到CPU上执行,它依然
      是发起select系统调用,此时内核被唤醒,httpd被挂起,内核将根据select的要求,返回内核中socket set的全部状态集,
      然后,内核进入睡眠,CPU将httpd调入CPU上执行,此时select开始遍历获取到的socket set集合,当找到自己监听的
      socket状态为可读时,它将立即解除阻塞,并调用accept()系统调用,此时内核再次被唤醒,然后根据accept的要求,将
      已完成连接队列中与自己建立连接的ClientInfo信息取出来,并删除队列中的信息,然后根据监听套接字,生成一个新的
      专用连接套接字,接着将该套接字注册到内核管理的socket set中,最后将该专用连接套接字返回,接着内核进入睡眠,
      CPU再次调度httpd进入CPU上执行,httpd获取到专用连接套接字的文件描述符后,将其分配其中一个子进程,由子进程
      来完成与该用户的后续数据交互,主进程则继续监控监听套接字。
      若此时httpd的CPU时间片用完了,CPU将会把httpd转入内核睡眠,然后继续其它用户空间的进程;假如此刻Client
      请求网站主页的数据包到达Server网卡了,网卡依然会采用上面的动作,内核依然会被唤醒,然后内核会调度TCP/IP协议
      处理数据包,当解封装后,发现这个数据包是ACK报文,并且数据段大于0,此时内核知道这是一个已经完成的连接请求数据包,
      于是根据请求报文中的 {源IP,源Port,目标IP,目标Port,协议} 去遍历查找socket set,若找到对应的专用socket,则将数据
      拷贝到socket buffer中,并将专用socket的状态设置为可读,然后,内核进入睡眠,CPU继续挂起的任务,当httpd的子进程
      被调到到CPU上执行时,它通过select系统调用去检查自己监听的专用套接字时,发现自己关心的套接字为可读,于是立即
      解除阻塞,调用recvform系统调用,读取数据,此时内核会被唤醒,完成将socket buffer中的数据拷贝到该进程的的内存
      空间中,然后内核进入睡眠,CPU将httpd子进程调度到CPU上继续执行,httpd子进程读取数据,分析后知道用户要请求
      网站主页资源,于是再次发起系统调用,获取磁盘中存储的主页数据,此时内核被唤醒,然后内核调度磁盘驱动,若该磁盘
      上面有DMA芯片,则内核会直接告诉DMA芯片,你将磁盘中指定柱面,指定扇区,指定磁道上的数据复制到指定的DMA_ZOME
      中指定的内存区中,然后,内核进入睡眠,CPU继续调度其他进程到CPU上执行。 但是若磁盘上没有DMA芯片,那么内核
      将自行调度磁盘驱动读取磁盘数据,并等待磁盘驱动完成数据从磁盘拷贝到内核kernel buffer中,再这期间kernel将被阻塞,
      直到数据拷贝完成,然后,内核进入睡眠,CPU再将httpd子进程调度到CPU上执行,若时间片用完,则再将其调度到内存
      睡眠,否则就继续让httpd子进程执行,假如此时时间片没有耗尽,httpd子进程将会再次发起系统调用,让内核将kernel buffer
      中的数据拷贝到自己的内存空间中,于是CPU再次将其挂起,内核完成拷贝后,再次进入睡眠,httpd子进程再次被调到
      CPU上执行,然后httpd子进程开始将数据封装上http首部,构建成响应报文后,再次发起系统调用,让内核将数据复制到
      send buffer中,此时httpd子进程被挂起,内核开始将进程内存空间中的数据拷贝到内核内存空间中的send buffer中,准备
      调度TCP/IP协议栈驱动对报文做TCP首部,IP首部,链路层帧头等封装,最终这个数据包构建完成,被内核发往网卡的缓冲区
      中等待发送,当然若网卡上有DMA芯片,内核依然可让DMA芯片来复制数据,完成发生,自己就去睡眠。若没有就只能自己干。
      以上描述就是HTTP通信的一个缩影。【文末有两段代码,可合起来看,基本就是类似httpd的代码实现,摘录仅为了方便理解
      不至于太空洞,而感觉没有依据似的。】
      注:
      上面描述了select()系统函数,这是最简单的一种多路复用I/O调用,还有poll和epoll,这两个系统调用,也属于多路复用I/O
     模型,但poll和select基本类似,由于能力有限,对它们之间的区别理解很浅薄,仅知道它们在获取socket set时,似乎select
     是用列表方式,而poll是链表方式,似乎于此有关,导致poll没有最大1024的限制,而select因为在kernel编译时,就设置其
     最大值为1024,即只能同时接收1024个连接,但具体理解不深,若有大牛知道,也希望能分享博文。
      下图是select模型系统调用说明图:

        

             关于epoll我的理解也不是很深刻,仅做以下说明,但理解poll和epoll的前提,还是要先理解select.
      下图是epoll这种基于事件驱动的I/O模型工作示意图:

        

        简图:

          

     更高级的网络I/O模型是AIO,其实现原理很便于说明,但细节我理解不深:
      异步非阻塞I/O模型
       相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接
      返回,不会阻塞用户进程,然后用户进程可以继续接受新的连接请求。等到socket数据准备好了,内核会直接复制
      socket buffer中的数据复制到用户进程空间后,内核才会找到用户进程留下的联系方式(即:通知信号)向进程发送通知。
      可以看到IO的两个阶段,进程都是非阻塞的。 Linux 内核提供了AIO库函数的实现,但是用的很少。目前有很多开源
      的异步IO库,例如libevent、libev、libuv。异步过程如下图所示:
      【注意:这里方式实现起来极其复杂,但是Nginx是完全支持这个方式的。】

        

        简图:

          

    另外说明:
      1. 助理:即DMA机制
        就拿硬盘来说,CPU需要读磁盘中的一段数据时,它发现磁盘支持DMA,则CPU会授权给DMA允许访问系统总线,
      并告诉它将磁盘中的那部分数据放到内存中指定的地方,接着CPU就不管了,由DMA来完成数据搬运,并在完成时,
      向CPU发中断 报告完成。
       这里需要注意:
        1. CPU通过32根线才完成了访问4G的内存空间,那DMA要访问内存也需要32根线吗?
          当然不是,DMA的总线是很窄的,因此为了让DMA可访问内存,系统在设计时,就将RAM中低地址中
         的一段空间预留个DMA使用,它通常是16M;其中RAM的起始区中第一个1M区域是固定给BIOS使用的,
         因为CPU在制造时,就设定了只要开机,CPU首先去读取RAM中0地址开始的连续的1M区域,来完成
         处理BIOS映射到里面的指令,实现开机自检。DMA设计时也是会去访问内存中固定的地址区域,实现
         高效传输。但需要注意的是,每次系统要DMA工作前,都会事先腾出DMA将访问的内存区域。

        2.当前httpd, Nginx等Web服务器都已经有更先进的技术,如sendfile,mmap,这些机制,可让上面繁琐
        磁盘数据拷贝过程变得更加高效,
        这里不展开说明,仅简单说明如下:

        Nginx支持Sendfile方式响应静态网页:Linux中支持 sendfile 和 Sendfile64
        正常情况下:当用户发来请求后,内核收到网卡中断处理数据流,判断为http数据流,告知将该数据流将给监听在
          80 Socket的应用程序这时,Nginx的Master进程监听到连接请求,并将该连接请求将给Worker进程,来建立
          Http会话响应用户,通过解析请求发现用户请求的是静态网页,接着Worker请求向内核发起I/O请求,内核为
          该请求准备Buffer,并向磁盘请求数据,通常由DMA(直接内存访问)控制芯片接收内核请求(请求中通常会包含
          让DMA将数据Copy到那段Buffer空间),并从磁盘中读取数据,并Copy到Buffer中,完成后,向内核发送中断信号,
          通知内核数据准备完成,接着内核将数据copy到Nginx的进程内存空间(注:Nginx默认采用epoll,信号驱动I/O
          模型)完成后,通知Worker进程Worker进程对数据做处理后,接着又向内核发起请求,要求内核将处理后的
          数据封装http头,TCP头, IP头, 并最终发给用户。

       Sendfile方式:在这种方式下,当网卡接收到数据流后,发送中断给内核,内核处理后通知网卡将数据发给80 Socket
          上监听的应用程序接着,Nginx的Master进程监听到连接请求,并负责向该请求分配Worker进程,来建立
          HTTP会话连接,Worker进程分析该请求后,是要请求静态网页数据,接着向内核发起I/O请求,并告知
          内核这是请求静态页面的,你直接将数据封装HTTP包响应用户即可,不需在把数据给我了。接着内核向
          磁盘请求数据,得到数据后,直接将用户请求的数据封装HTTP头,TCP头,IP头,数据连接层头,
          完成后,直接响应用户。

      对比两种方式不难发现,Sendfile方式更高效,因为它去掉了I/O请求中两次COPY的过程,在高并发的场景中是非常高效的。
      注意:sendfile:仅支持很小的文件直接在内核封装并响应用户,而sendfile64则支持更大的文件在内核中直接封装并响应用户。
      【 注意:内核任何时候与进程交换并传递数据时,采用的方式都时Copy,除非指定使用共享内存。】

        

      

    mmap和常规文件操作的区别

      这段是摘自参考文章,这篇博文详细介绍了MMAP:
        https://www.cnblogs.com/wanghuaijun/p/7624564.html
        对linux文件系统不了解的朋友,请参阅我之前写的博文《从内核文件系统看文件读写过程》,我们首先简单的回顾一下
      常规文件系统操作(调用read/fread等类函数)中,函数的调用过程:
        1、进程发起读文件请求。
        2、内核通过查找进程文件符表,定位到内核已打开文件集上的文件信息,从而找到此文件的inode。
        3、inode在address_space上查找要请求的文件页是否已经缓存在页缓存中。如果存在,则直接
          返回这片文件页的内容。
        4、如果不存在,则通过inode定位到文件磁盘地址,将数据从磁盘复制到页缓存。之后再次发起
          读页面过程,进而将页缓存中的数据发给用户进程。
        总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要
       先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将
       页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对
          文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核
         空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。

      下面是我的理解,仅供参考:
        而使用mmap操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有
         任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常的过程(即:从物理内存的缓冲区
         中查找是否有需要的已打开文件,若无),则会通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘
       中将数据传入内存的用户空间中,供进程使用。
        总而言之,常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只需要
       从磁盘到用户主存的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间和内核空间的数据直接
       交互而省去了空间不同,数据不通的繁琐过程。因此mmap效率更高。


    下面这部分代码是我从网上摘录的,仅为方便理解socket,bind,listen和select
    #include <sys/socket.h>
    #include <stdio.h>
    #include <string.h>
    #include <netinet/in.h>
    #include <stdlib.h>
    #include <arpa/inet.h>

    int main(int argc,char** argv)
    {
    int ret;
    int listenfd = socket(AF_INET,SOCK_STREAM,0);
    if (listenfd == -1)
    {
    printf("socket error ");
    return -1;
    }

    struct sockaddr_in serveraddr;
    memset(&serveraddr,0,sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons((unsigned short)(atoi(argv[1])));

    ret = bind(listenfd,(const sockaddr*)&serveraddr,sizeof(serveraddr));
    if (ret == -1)
    {
      printf("bind error,ret = %d ",ret);
      return -1;
    }

    int backlog = atoi(argv[2]);
    ret = listen(listenfd, backlog);
    printf("backlog = %d,ret =%d ",backlog,ret);
    if (ret == -1)
    {
      printf("listen error,ret = %d ",ret);
      return -1;
    }

    for(;;)
    {}    #这部分,应该可填充下面这段代码,所以可参考下面的代码。【我并不是很懂】
    return 0;
    }
    ============这是另一篇中的部分代码摘录=============
    /* 假定已经建立UDP连接,具体过程不写,简单,当然TCP也同理,主机ip和port都已经给定,要写的文件已经打开
    sock=socket(...);
    bind(...);
    fp=fopen(...); */
    while(1)
    {
      FD_ZERO(&fds); //每次循环都要清空集合,否则不能检测描述符变化
      FD_SET(sock,&fds); //添加描述符
      FD_SET(fp,&fds); //同上
      maxfdp=sock>fp?sock+1:fp+1; //描述符最大值加1
      switch(select(maxfdp,&fds,&fds,NULL,&timeout)) //select使用
      {
        case -1: exit(-1);break; //select错误,退出程序
        case 0:break; //再次轮询
        default:
        if(FD_ISSET(sock,&fds)) //测试sock是否可读,即是否网络上有数据
        {
          recvfrom(sock,buffer,256,.....);//接受网络数据
          if(FD_ISSET(fp,&fds)) //测试文件是否可写
          fwrite(fp,buffer...);//写入文件
          buffer清空;
        }// end if break;
      }// end switch
     }//end while
    }//end main

    仔细结合上面对listen,select函数的说明,在参考这个两段代码,再去细细思考整个TCP通信过程,
    相信你会有自己的理解。
    本人对C编程并不熟悉,但大概能看明白,因此不做说明,请自行选看。





  • 相关阅读:
    Qt double转换成Qstring
    QT 控件ComboBox
    前端web项目打包(二)
    前端Web打包成可执行程序
    div中下拉框无法点击展开跟选中
    div下多个table并排排列
    VS将数据保存在excel表格中
    关于windows下任务栏应用程序标签消失问题的解决办法
    Java8中map()和flatMap()的区别
    Java对象的深拷贝
  • 原文地址:https://www.cnblogs.com/wn1m/p/10983172.html
Copyright © 2011-2022 走看看