本文共 12767 字,大约阅读时间需要 42 分钟。
慢慢的完善下阅读了Tomcat源码后遗留下的问题,今天主要解决Socket。
在讲Socket之前,我们需要了解一些知识
我们大学的时候都学过计算机网络(惭愧,那门课的知识基本都还给老师了),当时课本就讲述了两种主流的计算机网络分层结构,其中就包括我们现在要说的TCP/IP五层结构,另一个就是OSI七层结构,我们今天先不说。
物理层
中继器、集线器、还有我们通常说的双绞线位于物理层。
在物理层上数据传输的单位是比特。
物理层的任务就是透明地传送比特流,确定连接电缆插头的定义及连接法。
数据链路层
网桥(现已很少使用)、以太网交换机(二层交换机)、网卡(其实网卡是一半工作在物理层、一半工作在数据链路层)在数据链路层
数据链路层的任务是在两个相邻结点间的线路上无差错地传送以帧(frame)为单位的数据。每一帧包括数据和必要的控制信息。
网络层
路由器、三层交换机位于网络层
网络层的任务就是要选择合适的路由,使发送站的传输层所传下来的分组能够正确无误地按照地址找到目的站,并交付给目的站的传输层。
网络层有几个著名的协议:ARP IP协议等
传输层
四层交换机、也有工作在四层的路由器位于传输层
传输层的任务是向上一层的进行通信的两个进程之间提供一个可靠的端到端服务,使它们看不见运输层以下的数据通信的细节。
传输层的相关协议:TCP UDP
应用层
无网络设备
应用层直接为用户的应用进程提供服务。
相关协议:POP3,SMTP,HTTP,SNMP,DNS等等
在介绍了五层结构及各层的协议之后,我们接着看下怎么利用TCP/IP协议族来进行通信。
利用TCP/IP协议族通信
我们以客户端在应用层发出一个想看某个WEB页面的HTTP请求来举例。
发送请求时,处于应用层的HTTP协议负责生产针对WEB服务器的HTTP请求报文。
接着,请求来到了传输层,这时候就需要启用强大的TCP协议了。这个时候就会开始出现经典的三次握手(三次握手单独讲,这里三次握手完成,成功建立连接了,这里直接开始传输HTTP请求报文数据了,其实三次握手也是传输数据嘛)。TCP协议为了方便通信传输,它会将HTTP请求报文分割成若干报文段(按序号分)。
然后,来到了网络层,这个时候IP协议就要起作用了,他负责搜索对方的地址,增加作为通信目的地的MAC地址后转发给链路层(一般需要经过多次路由转发)。由IP地址得到MAC地址的行为由ARP协议完成。
接收端的服务器在链路层收到数据,往上层网络层发送,然后到达传输层,TCP协议负责将从客户端发出的若干报文段重组成有序的请求报文,然后上传到应用层,此时HTTP协议对WEB服务请求的内容进行处理。找到相应服务器相应资源。
请求的结果以同样的方式回传。
端口
在 Internet上,各主机间通过TCP/IP协议发送和接收数据报,各个数据报根据其目的主机的ip地址来进行互联网络中的路由选择。可见,把数据报顺 利的传送到目的主机是没有问题的。问题出在哪里呢?我们知道大多数操作系统都支持多程序(进程)同时运行,那么目的主机应该把接收到的数据报传送给众多同 时运行的进程中的哪一个呢?显然这个问题有待解决,端口机制便由此被引入进来。
本地操作系统会给那些有需求的进程分配协议端口 (protocal port,即我们常说的端口),每个协议端口由一个正整数标识,如:80,139,445等等。当目的主机接收到数据报后,将根据报文首部的目的端口号,把数据发送到相应端口,而与此端口相对应的那个进程将会领取数据并等待下一组数据的到来。说到这里,端口的概念似乎仍然抽象,那么继续跟我来,别走开。
端口其实就是队列,操作系统为各个进程分配了不同的队列,数据报按照目的端口被推入相应的队列中,等待被进程取用,在极特殊的情况下,这个队列也是有可能溢出的,不过操作系统允许各进程指定和调整自己的队的大小。Windows默认应该是50个。
不光接受数据报的进程需要开启它自己的端口,发送数据报的进程也需要开启端口,这样,数据报中将会标识有源端口,以便接受方能顺利的回传数据报到这个端口。简单点说,端口用来区分一台主机的多个不同应用程序,端口号范围为0-65535
其中0-1023位为系统保留,也称为固定端口。
使用集中式管理机制,即服从一个管理机构对端口的指派,这个机构负责发布这些指派。由于这些端口紧绑于一些服务,所以我们会经常扫描这些端口来判断对方 是否开启了这些服务,如TCP的21(ftp),80(http),139(netbios),UDP的7(echo),69(tftp)等等一些大家熟 知的端口;
动态端口(1024-49151)
这些端口并不被固定的捆绑于某一服务,操作系统将这些端口动态的分配给各个进程, 同一进程两次分配有可能分配到不同的端口。不过一些应用程序并不愿意使用操作系统分配的动态端口,他们有其自己的‘商标性’端口,如oicq客户端的 4000端口,Tomcat的8080端口等都是固定而出名的。
是不是还少了49152-65335的端口,看到有说明说这部分属于私有端口,我们这里就不介绍了。
在讲解Socket之前,我们带着以下几个问题去看:
1.Socket是啥?
2.他到底是用来干嘛的,他有什么能耐?
3.我们在Java中怎么操作他?
啥是Socket
IP地址+端口号组成了所谓的Socket,Socket是网络上运行的程序之间双向通信链路的终结点,是TCP和UDP的基础
Socket套接字:
网络上具有唯一标识的IP地址和端口组合在一起才能构成唯一能识别的标识符套接字。
socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。我的理解就是Socket就是该模式的一个实现,socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)
Socket用来干啥
其实在说明他是什么的时候已经暴露了他是用来干什么的。
一句话,他就是用于网络通信的。
也可以这样说,网络通信实际就是Socket之间的通信。
网络层的“ip地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。
使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰),来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用socket,而现在又是网络时代,网络中进程通信是无处不在,这就是我为什么说“一切皆socket”。
数据在两个Socket间通过IO传输
我们怎么在Java中操作Socket呢,或者说我们在Java中进行网络通信呢。
针对网络通信的不同层次,Java提供了不同的API,主要有以下几个:
InetAddress:用于标识网络上的硬件资源,主要是IP地址
URL:统一资源定位符,通过URL可以直接读取或写入网络上的数据
Socket:使用TCP协议实现的网络通信Socket相关的类
Datagram:使用UDP协议,将数据保存在用户数据报中,通过网络进行通信。
直接来看一个例子
public class InetAddressTest { @Testpublic void test() throws UnknownHostException { InetAddress address=InetAddress.getLocalHost();//获取本机的InetAddress实例 System.out.println(address.getHostName());//得到计算机名 System.out.println(address.getHostAddress());//得到IP地址}}
我们如果看InetAddress的源码,你会发现他是没有显式的构造方法的,而我们常用的API方法都是他的静态方法,他的内部有一个InetAddressHolder静态类,具体的实现都是由这个静态内部类来完成的。
我们看上面的代码是获取了本机的IP相关信息,那么我们如果想要获取其他机器上的相关信息呢?
也为我们提供了相关的API。
getByName(String host)
我们需要提供目标机器的主机名信息或者IP信息,这样我们就可以锁定对应的机器,得到相关信息。
URL(Uniform Resource Locator)统一资源定位符,表示Internet上某一资源的地址,协议名:资源名称
先来看一个例子
@Test public void test() throws MalformedURLException { //创建一个URL的实例 URL baidu =new URL("http://www.baidu.com"); URL url =new URL(baidu,"/index.html?username=tom#test");//?表示参数,#表示锚点 url.getProtocol();//获取协议 url.getHost();//获取主机 url.getPort();//如果没有指定端口号,根据协议不同使用默认端口。此时getPort()方法的返回值为 -1 url.getPath();//获取文件路径 url.getFile();//文件名,包括文件路径+参数 url.getRef();//相对路径,就是锚点,即#号后面的内容 url.getQuery();//查询字符串,即参数 InputStream is = url.openStream();//通过openStream方法获取资源的字节输入流 }
我们很明显的看到我们使用URL能够获得很多信息。为什么能获得这么多信息呢?
因为URL是统一资源定位符,也就是说我们能够通过URL来定位都互联网上的某一资源的位置,你确定了一个资源在哪,你获取他就不难了把,这个就像我们操作文件的时候,我们可以通过文件的位置获取到文件的内容。
然而既然说到了URL,我就不得不多说两句了,也是把当时看图解HTTP的疑惑了断一下吧。
URL和URI
URI:统一资源标志符
他能在某一规则下把一个资源独一无二地标识出来
比如我们在现实世界中如果要确定一个人:隔壁老王
我们用的是什么呢?很明显用的是身份证
URL:统一资源定位符
那如果我现在要用资源定位符的方式来确定隔壁老王呢?那么就可以用以下方式:
人类住址协议://地球/中国/上海市/青浦区/某公寓/11楼/1103主卧
可以看到,他也达到了确定一个人的目的,所以我们很容易得到URI是URL的一个子集。而且我们能够知道,URL是以描述人的位置来唯一确定一个人的。
那现在我们可以粗略的说一下为什么现在我们都管网址叫URL了。因为他提供了资源的位置。我们能够定位他,能够获取他。而如果有一天网址变成了URI形式,也就是直接给出一个ID CARD来标识一个互联网资源,我们就得把网页叫做URI了,而不能叫URL了。但是你觉得会出现这个情况吗?显然是不大可能的,因为虽然我用一个ID CARD能标识一个资源,但是我还得花功夫定位他呀,这不是给自己找不痛快吗?
最后再插一句,上述的ID CARD就是URN的方法来标识资源,URN被称为统一资源名称。
URI包含两个子集,分别就是URL和URN,但是目前已经很少使用URN了,原因上面已经说了,因为没有给出资源的位置,现在用的都是URL。
Socket
Socket,ServerSocket是Java中基于TCP协议实现网络通信的类
我们来看一个使用Socket通信的实例
客户端
“`java
package com.wangcc.MyJavaSE.socket;import java.io.BufferedReader;
import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintWriter; import java.net.Socket; import java.util.Date;public class SocketClient {
public static void main(String []args) {try { //0.创建客户端Socket,指定服务器地址和端口号,向服务端发送请求信息 //一旦这个构造方法返回,就代表连接成功,但是不一定代表就可以处理了,因为可能在队列中等待 Socket socket=new Socket("localhost",5209); System.out.println("客户端成功启动"); //1.获取输出字节流,向服务器端发送消息 OutputStream os = socket.getOutputStream(); //2、将字节输出流包装为字符打印流 PrintWriter pw = new PrintWriter(os); //3.向服务器端发送请求信息 StringBuffer bf = new StringBuffer(); bf.append("用户名:").append("admin"); bf.append("密码:").append("123"); pw.write(bf.toString()); //刷新缓存 pw.flush(); //4.关闭Socket的输出流 socket.shutdownOutput(); //5.获取输入字节流,读取服务器端的响应信息 InputStream is = socket.getInputStream(); //6.封装并接收服务器端的响应信息 InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr); String data = null; while(null != (data=br.readLine())){ System.out.println(new Date()); System.out.println("我是客户端,服务器端说:"+data); } //7.关闭资源 br.close(); isr.close(); is.close(); pw.close(); os.close(); socket.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); }}
}
“`
在构造客户端Socket的时候,是一定要指明服务器地址和端口号。
当Socket构造方法成功返回后,就证明已经成功的连接上了服务器,但是不代表会马上被处理,很可能这个socket位于等待队列中。
那连上服务器之后,我们怎么通过socket来向服务器发送数据呢。Java给我们提供了getOutputStream方法来得到向服务器传输数据的流。我们可以将我们要传输给服务器的数据写在这个流中,或者写在底层流是这个流的包装流中。
而客户端想要得到来自服务器端传过来的数据,Java也为我们提供了相应的方法:getInputStream,调用socket.getInputStream()方法可以得到包含服务器端数据的字节流,通过这个字节流,我们能够读取到服务器端传过来的数据,然后进行相应的处理。
服务器端:
package com.wangcc.MyJavaSE.socket;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader;import java.io.OutputStream;import java.io.PrintWriter;import java.net.ServerSocket;import java.net.Socket;import java.util.Date;public class SocketServer { public static void main(String[] args) { try { // 0.创建一个服务端Socket,即ServerSocket对象,指定绑定的端口,并侦听该端口 /* * * public ServerSocket(int port) throws IOException { this(port, 50, null); } 可以看出默认的backlog等待队列的长度是50 我们也可以显式的指定backlog队列的长度,但是不能超过50,如果超过50,只会按50算 * * * */ ServerSocket serverSocket = new ServerSocket(5209); System.out.println("===================服务器即将启动,等待客户端的连接==============="); // 1.调用accept()方法开始侦听客户端请求,创建Socket,等待客户端的连接 Socket socket = serverSocket.accept(); // 2.获取输入字节流,读取客户端请求信息 InputStream is = socket.getInputStream(); InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr); String data = null; while(null != (data = br.readLine())){ System.out.println(new Date()); System.out.println("我是服务器端,客户端说:"+data); } socket.shutdownInput(); OutputStream os = socket.getOutputStream(); PrintWriter pw = new PrintWriter(os); pw.write("用户名和密码输入正确"); pw.flush(); //关闭输出流 socket.shutdownOutput(); pw.close(); os.close(); br.close(); isr.close(); is.close(); socket.close(); serverSocket.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } }}
创建ServerSocket对象时,如果没有指定backlog阻塞队列长度,那么就使用默认的50,如果在构造方法中有显式的设定长度但是大于50,那么依旧是使用默认的50。而且当端口port指定为0时,表示在所有空闲端口上创建套接字。
调用serverSocket.accept()方法侦听并接受到此套接字的连接。此方法在连接传入之前一直阻塞。 该方法返回的是一个Socket对象,利用这个Socket对象来获取客户端传递过来的数据,并且将数据封装好后传递给客户端。
上面的Socket服务器端使用的是单线程模式,也就是必须是对一个请求处理完后再处理下一个请求。
多线程模式:
package com.wangcc.MyJavaSE.socket;import java.io.IOException;import java.net.ServerSocket;import java.net.Socket;public class SocketServerMutiThread { public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(5209); System.out.println("服务器已经启动,等待客户端的连接...."); int count = 0;// 侦听到的客户端的数量 Socket socket = null;// 服务器端Socket // 死循环,让服务端循环侦听 while (true) { // 服务端开始侦听客户端的连接 socket = server.accept(); // 启动线程,与客户端进行通信 Thread serverThread = new Thread(new ServerThread(socket)); serverThread.start(); // 计数,统计客户端连接数 count++; System.out.println("当前链接的客户端的数量为:" + count + "个...."); } }}
ServerThread
package com.wangcc.MyJavaSE.socket;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader;import java.io.OutputStream;import java.io.PrintWriter;import java.net.Socket;public class ServerThread implements Runnable { //每当侦听到一个新的客户端的时,服务端这边都要有一个Socket与之进行通信 public Socket socket = null; //默认的构造方法,保留 public ServerThread(){} //带参构造方法 public ServerThread(Socket socket){ this.socket = socket; } @Override public void run(){ //获取输入字节流 InputStream in = null; //将输入字节流包装成输入字符流 InputStreamReader isr = null; //为字符输入流添加缓冲 BufferedReader br = null; //收到信息之后,向客户端响应信息,获取输出字节流 OutputStream out = null; //将字节输出流包装成字符打印输出流 PrintWriter pw = null; try { in = socket.getInputStream(); isr = new InputStreamReader(in); br = new BufferedReader(isr); //读取字符输入流中的数据 String data = null; while((data = br.readLine()) != null){ System.out.println("我是服务器,客户端说:"+data); } //调用shutDown方法关闭Socket输入流 socket.shutdownInput(); out = socket.getOutputStream(); pw = new PrintWriter(out); pw.write("用户名和密码正确"); pw.flush(); //调用shutDown方法关闭Socket输出流 socket.shutdownOutput(); } catch (IOException e) { e.printStackTrace(); }finally{ //关闭资源 try { if(null != pw) pw.close(); if(null != out) out.close(); if(null != br) br.close(); if(null != isr) isr.close(); if(null != in) in.close(); if(socket!=null) socket.close(); } catch (IOException e) { e.printStackTrace(); } } } }
我们看到,在多线程模式中,接受请求(连接)的线程依然只有一个,但是每处理一个请求都会启用一个单独的线程去完成,Tomcat处理请求的模式正是这样的。那么为什么接受请求的线程只需要一个呢?
因为socket.accept()方法是阻塞的。
阻塞调用是指调用结果返回之前,当前线程会被挂起。函数只有在得到结果之后才会返回。有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同 步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回而已。当socket工作在阻塞模式的时候, 如果没有数据的情况下调用该函数,则当前线程就会被挂起,直到有数据为止。
所以也就是说,我们在接受数据的时候使用多线程是没有效果的,因为只要没有数据他就会阻塞在那里。但是只要有了数据进来了,我们就不用管了,可以交给另一个线程来处理数据了,然后再回到accept方法来监听是否有数据再次进来,这样就不需要阻塞的进行数据的处理了。
我们已经简单的介绍了Socket处理数据的多线程版本,但是他依旧不是一种高效的处理方式,随着Java NIO的出现,也诞生了更有效率的对Socket的处理方式NIOSocket。
转载地址:http://qdmvb.baihongyu.com/