网络编程详解

网络通信的要素

  1. 通信双方的地址

    • IP地址
    • 端口号
  2. 通信协议

    • TCP/IP 参考模型

      • 应用层

        HTTP、FTP、SMTP

      • 传输层

        TCP、UDP

      • 网络层

        IP

      • 数据链路层

Java 中的网络支持:

  • InetAddress:用于表示网络上的硬件资源,即 IP 地址;
  • URL:统一资源定位符;
  • Sockets:使用 TCP 协议实现网络通信;
  • Datagram:使用 UDP 协议实现网络通信。

IP

IP地址的类:InetAddress

  • IP地址能唯一定位一台网络上的计算机

  • 127.0.0.1:本机 localhost

  • IP地址的分类

    • IPV4/IPV6

    • 公网/局域网

      192.168.xx.xx(专门给组织内部使用)

  • 域名

InetAddress没有公有的构造函数,只能通过静态方法来创建实例

InetAddress.getByName(String host);
InetAddress.getByAddress(byte[] address);
//测试IP
public class TestIP {
    public static void main(String[] args) {
        try {
            //查询本机地址
            InetAddress inetAddress1 = InetAddress.getByName("localhost");
            System.out.println(inetAddress1);
            InetAddress inetAddress2 = InetAddress.getLocalHost();
            System.out.println(inetAddress2);

            //查询网站ip地址
            InetAddress inetAddress3 = InetAddress.getByName("www.baidu.com");
            System.out.println(inetAddress3);

            //常用方法
            System.out.println(inetAddress3.getCanonicalHostName()); //规范的主机名
            System.out.println(inetAddress3.getHostAddress()); // IP
            System.out.println(inetAddress3.getHostName()); // 域名或自己的计算机名
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
    }
}

端口

端口表示计算机上一个程序的对外的接口

  • 不同的进程有不同的端口号,用来区分软件

  • 规定端口号范围:0-65535

  • 相同协议下,端口号不能冲突(不同协议,可以使用相同端口号)

  • 端口分类:

    • 公有端口:0-1023

      一般会被内置的进程、服务器使用,尽量不要使用

      • HTTP端口: 80
      • HTTPS端口: 443
      • FTP端口: 21
      • SSH: 22
      • Telent: 23
    • 程序注册端口:1024-49151

      分配给用户或程序

      • Tomcat: 8080
      • MySQL: 3306
      • Oracle: 1521
      • SQL Server:1433
    • 动态、私有端口:49152-65535

      netstat -ano #查看端口命令
      netstat -ano|findstr "5900" #查看指定的端口
      tasklist|findstr "1584" #查看指定端口的进程
public class TestSocketAddress {
    public static void main(String[] args) {
        InetSocketAddress socketAddress = new InetSocketAddress("localhost", 8080);

        System.out.println(socketAddress);
        System.out.println(socketAddress.getAddress());
        System.out.println(socketAddress.getHostName());
        System.out.println(socketAddress.getPort()); //获取端口
    }
}

URL

URL(统一资源定位符)用于定位网络资源,可以直接从URL中读取字节流数据

public static void main(String[] args) throws IOException {

    URL url = new URL("http://www.baidu.com");

    /* 字节流 */
    InputStream is = url.openStream();
    BufferedReader br = new BufferedReader(new InputStreamReader(is, "utf-8"));
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }

    br.close();
}

下载网络资源

URL url = new URL("https://pic3.zhimg.com/v2-77fd9b17781556ee25be582af6a9cd4c_r.jpg?source=1940ef5c"); // 资源地址(图片)

// 连接到该资源
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();

InputStream is = urlConnection.getInputStream();
BufferedInputStream bis = new BufferedInputStream(is); // 图片、音频、视频等类型使用缓冲字节流,含中文的文档类资源使用缓冲字符流


// 判断文件夹是否存在
File file = new File("C:\\Users\\Hunter\\Downloads");
if (!file.exists()) {
    file.mkdirs();
}
FileOutputStream fos = new FileOutputStream(file + File.separator + "Hunter" + System.currentTimeMillis() + new Random().nextInt(999999) + ".jpg");

BufferedOutputStream bos = new BufferedOutputStream(fos);

byte[] bytes = new byte[4 * 1024]; // 存储每次读取的数据
int len = 0;
while ((len = bis.read(bytes)) != -1) {
    bos.write(bytes, 0, len);
}
bos.close();
bis.close();
urlConnection.disconnect();

通信协议

TCP/IP协议簇

  • TCP(Transmission Control Protocol) 传输控制协议

  • UDP(User Datagram Protocol) 用户数据报协议

  • IP(Internet Protocol) 网络互连协议

TCP 和 UDP 的对比

TCP(类比:打电话)

  • 需要连接,稳定

  • 三次握手四次挥手

    // 至少需要三次,保证稳定连接
    C:我喜欢你
    S:我也喜欢你
    C:我们在一起吧
    
    // 四次挥手
    C:我要走了
    S:我知道你要走了
    S:你已经走了吗?
    C:我已经走了
  • 有明确的客户端和服务端角色

  • 传输完成后释放连接,效率低

UDP(类比:发短信)

  • 不需要连接,不稳定

TCP

Socket:套接字,指两台设备之间通讯的端点,是包含了IP地址和端口号的网络单位

  • java.net.ServerSocket

    服务器端类,相当于开启一个服务,并等待客户端的连接

  • java.net.Socket

    客户端类,向服务器发出连接请求,服务端响应请求后,两者建立连接,开始通信(三次握手)


服务端

java.net.ServerSocket

构造方法

ServerSocket(int port):创建绑定到指定端口的服务端套接字


成员方法

Socket accept()侦听并接收客户端的连接


实现步骤

  1. 创建服务器端对象ServerSocket,指定端口
  2. 等待用户的连接,通过accept方法获取请求的客户端对象
  3. 使用获取到的客户端对象的getInputStream方法获取网络字节输入流InputStream对象
  4. 使用网络字节输入流对象的read方法读取客户端发来的数据
  5. 使用获取到的客户端对象的getOutputStream获取网络字节输出流OutputStream对象
  6. 使用网络字节输出流对象的write方法给客户端发送数据传输结束标记
  7. 释放资源

服务器没有IO流,而是通过获取请求的客户端对象Socket,使用Socket提供的IO流和客户端进行交互

  • 服务器使用客户端的字节输入流InputStream读取客户端发送的数据
  • 服务器使用客户端的字节输出流OutputStream向客户端写回数据
ServerSocket serverSocket = new ServerSocket(2000);
while (true) { // 服务端的持久运行
    // 2. 获取请求的客户端对象
    Socket socket = serverSocket.accept();

    // 3. 读取客户端消息
    InputStream is = socket.getInputStream();
    BufferedReader br = new BufferedReader(new InputStreamReader(is));
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }

    // 向客户端发送数据
    OutputStream os = socket.getOutputStream();
    BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
    bw.write("你在教我做事?");
    bw.newLine();
    bw.flush(); // 重要

    // 网络流的传输不同于本地,不会传输结束的标记
    socket.shutdownOutput(); // 禁用套接字的输出流,同时会传输一个结束标记

    br.close();
    bw.close();
    socket.close();
}

客户端

java.net.Socket

构造方法

Socket(String host, int port):创建一个套接字,并连接到指定主机上指定的端口号

  • String host:服务器主机的名称/服务器的IP地址

成员方法

  • OutputStream getOutputStream():返回套接字的输出流
  • InputStream getInputStream():返回套接字的输入流
  • void close():关闭套接字

实现步骤

  1. 创建一个客户端对象Socket
  2. 使用getOutputStream方法获取网络字节输出流OutputStream对象
  3. 使用网络字节输出流对象的write方法给服务器发送数据传输结束标记
  4. 使用getInputStream获取网络字节输入流InputStream对象
  5. 使用网络字节输入流对象的read方法读取服务器传回的数据
  6. 释放资源
Socket socket = new Socket("127.0.0.1", 2000);

OutputStream os = socket.getOutputStream();
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
bw.write("教练,我想打篮球");
bw.newLine();
bw.flush(); // 重要

// 网络流的传输不同于本地,不会传输结束的标记
socket.shutdownOutput(); // 禁用套接字的输出流,同时会传输一个结束标记

// 3. 读取服务端消息
InputStream is = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String line;
while ((line = br.readLine()) != null) {
    System.out.println(line);
}

bw.close();
br.close();
socket.close();

文件上传

注意事项

  1. 为保证服务器安全,上传文件应该放在外界无法直接访问的目录下,比如WEB-INF目录下。
  2. 为防止文件覆盖的现象发生,要为上传文件产生一个唯一的文件名(时间戳 + uuid + md5加密)
  3. 限制上传文件的最大值。
  4. 限制上传文件的类型,判断后缀名是否合法。

服务端

  1. 创建服务器ServerSocket对象
  2. 使用ServerSocket对象中的accept方法,获取到请求的客户端Socket对象
  3. 使用Socket对象的getInputStream方法,获取到网络字节输入流
  4. 判断指定存储的文件夹是否存在,不存在则创建
  5. 创建一个本地字节输出流FileOutputStream对象
  6. 读取客户端上传的文件,写入至服务器中指定的文件夹下
  7. 获取网络字节输出流对象,返回上传成功的消息(传输结束标记
  8. 释放资源
ServerSocket serverSocket = new ServerSocket(8888);

while (true) {
    Socket socket = serverSocket.accept();
    InputStream is = socket.getInputStream();
    BufferedReader br = new BufferedReader(new InputStreamReader(is));
    // 判断文件夹是否存在
    File file = new File("C:\\Users\\Hunter\\Downloads");
    if (!file.exists()) {
        file.mkdirs();
    }

    // 使用【域名 + 系统时间 + 随机数】用于命名
    FileOutputStream fos = new FileOutputStream(file + File.separator + "Hunter" + System.currentTimeMills() + new Random().nextInt(999999) + ".md");
    BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(fos));
    // 将本地上传的文件内容输出至指定文件
    String line;
    while ((line = br.readLine()) != null) { // 客户端socket.shutdownOutput()的操作,会传来文件的结束标记
        bw.write(line);
        bw.newLine(); // 换行(根据不同的操作系统,获取不同的行分隔符)
    }
    bw.close();

    // 返回上传成功的消息
    OutputStream os = socket.getOutputStream();
    BufferedWriter bw2 = new BufferedWriter(new OutputStreamWriter(os));
    bw2.write("上传成功");

    bw2.flush();
    socket.shutdownOutput(); // 禁用套接字的输出流,同时会传输一个结束标记

    // 释放资源
    bw2.close();
    br.close();
    socket.close();
}

客户端

  1. 创建一个本地字节输入流FileInputStream对象,读取本地文件
  2. 创建客户端Socket对象
  3. 使用Socket对象的getOutputStream方法,获取到网络字节输出流
  4. 通过网络字节输出流传输本地文件内容至服务器(传输结束标记
  5. 读取服务端返回的消息
  6. 释放资源
// 创建本地字节输入流
FileInputStream fis = new FileInputStream("C:\\Users\\Hunter\\Downloads\\xx.md");
BufferedReader br = new BufferedReader(new InputStreamReader(fis));

// 创建客户端Socket对象
Socket socket = new Socket("127.0.0.1", 8888);
// 获取网络字节输出流
OutputStream os = socket.getOutputStream();
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
// 将本地的文件上传至网络字节输出流
String line;
while ((line = br.readLine()) != null) {
    bw.write(line);
    bw.newLine(); // 换行(根据不同的操作系统,获取不同的行分隔符)
}
bw.flush();
socket.shutdownOutput(); // 上传完文件, 给服务端一个结束标记,否则服务端将阻塞

// 获取上传结果
InputStream is = socket.getInputStream();
BufferedReader br2 = new BufferedReader(new InputStreamReader(is));
while ((line = br2.readLine()) != null) {
    System.out.println(line);
}

// 释放资源
br.close();
br2.close();
socket.close();

文件上传优化 —— 多线程

ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
    Socket socket = serverSocket.accept();
    /*
    * 使用多线程,提高程序效率
    * 有一个客户端上传文件,就开启一个线程,完成文件的上传
    * */
    new Thread(() -> {
        try {
            InputStream is = socket.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(is));
            // 判断文件加是否存在
            File file = new File("C:\\Users\\Hunter\\Downloads");
            if (!file.exists()) {
                file.mkdirs();
            }

            // 使用【域名 + 系统时间 + 随机数】用于命名
            FileOutputStream fos = new FileOutputStream(file + File.separator + "Hunter" + System.currentTimeMillis() + new Random().nextInt(999999) + ".md");
            BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(fos));
            // 将本地上传的文件内容输出至指定文件
            String line;
            while ((line = br.readLine()) != null) { // 客户端socket.shutdownOutput()的操作,会传来文件的结束标记
                bw.write(line);
                bw.newLine(); // 换行(根据不同的操作系统,获取不同的行分隔符)
            }
            bw.close();

            // 返回上传成功的消息
            OutputStream os = socket.getOutputStream();
            BufferedWriter bw2 = new BufferedWriter(new OutputStreamWriter(os));
            bw2.write("上传成功");

            bw2.flush();
            socket.shutdownOutput(); // 禁用套接字的输出流,同时会传输一个结束标记

            // 释放资源
            bw2.close();
            br.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }).start();
}

进阶

对于文件上传,浏览器将文件以流的形式提交到服务器,使用原生态的文件上传流request.getInputStream()获取十分麻烦,一般采用apache的开源工具commons-fileupload,其依赖于commons-io

ServletFileUpload类

ServletFileUpload负责处理上传的文件数据,使用其parseRequest(HttpServletRequest)方法,会将表单中每个HTML标签封装成一个FileItem对象,以List的形式返回

package com.hunter.servlet;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;

public class FileServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 判断上传的文件是否普通表单
        if (!ServletFileUpload.isMultipartContent(req)) {
            return;
        }

        // 创建上传文件的保存路径,建议在WEB-INF路径下,安全,用户无法直接访问上传的文件
        String uploadPath = this.getServletContext().getRealPath("/WEB-INF/upload");
        File uploadFile = new File(uploadPath);
        if (!uploadFile.exists()) {
            uploadFile.mkdir();
        }

        // 缓存临时文件
        //  临时路径,假如文件超过了预期的大小,就放到临时文件中,超过设定的时间后自动删除,或提醒用户转存为永久
        String tmpUploadPath = this.getServletContext().getRealPath("/WEB-INF/tmp");
        File tmpUploadFile = new File(tmpUploadPath);
        if (!tmpUploadFile.exists()) {
            tmpUploadFile.mkdir();
        }

        /*
         * ServletFileUpload负责处理上传的文件数据,使用其parseRequest(HttpServletRequest)方法,
         * 会将表单中每个HTML标签封装成一个FileItem对象,以List的形式返回。
         * 而使用ServletFileUpload对象解析请求时,需要DiskFileItemFactory对象。
         */
        try {
            // 1. 创建DiskFileItemFactory对象,处理文件上传 路径或大小限制
            DiskFileItemFactory diskFileItemFactory = getDiskFileItemFactory(tmpUploadFile);
            // 2. 获取ServletFileUpload
            ServletFileUpload servletFileUpload = getServletFileUpload(diskFileItemFactory);
            // 3. 处理上传的文件
            String msg = uploadParseRequest(req, uploadPath, servletFileUpload);

            req.setAttribute("msg", msg);
            req.getRequestDispatcher("info.jsp").forward(req, resp);
        } catch (FileUploadException e) {
            e.printStackTrace();
        }
    }

    private String uploadParseRequest(HttpServletRequest request, String uploadPath, ServletFileUpload servletFileUpload)
            throws FileUploadException, IOException {
        String msg = "";
        // parseRequest(HttpServletRequest)方法,会将表单中每个HTML标签封装成一个FileItem对象,以List的形式返回。
        List<FileItem> fileItems = servletFileUpload.parseRequest(request);
        for (FileItem fileItem : fileItems) {
            // 是否普通表单
            if (fileItem.isFormField()) {
                // 前端表单控件的name
                String fieldName = fileItem.getFieldName();
                // UTF-8 处理乱码
                String value = fileItem.getString("UTF-8");
                System.out.println(fieldName + ": " + value);
            } else {
                String uploadFileName = fileItem.getName();
                // 文件名可能不合法
                if (uploadFileName == null || uploadFileName.trim().equals("")) {
                    continue;
                }
                // 获得文件名
                String fileName = uploadFileName.substring(uploadFileName.lastIndexOf("/") + 1);
                System.out.println("文件信息【文件名:" + fileName + "】");

                // 使用UUID,保证文件名唯一。网络传输的东西,都需要序列化。
                String uuidPath = UUID.randomUUID().toString();
                String realPath = uploadPath + "/" + uuidPath;
                // 给每个文件创建一个对应的文件夹
                File realPathFile = new File(realPath);
                if (!realPathFile.exists()) {
                    realPathFile.mkdir();
                }

                // 获得文件上传的流
                InputStream inputStream = fileItem.getInputStream();

                // 创建一个文件输出流
                FileOutputStream fileOutputStream = new FileOutputStream(realPath + "/" + fileName);
                // 创建一个缓冲区
                byte[] buffer = new byte[1024 * 1024];
                int len = 0;
                while ((len = inputStream.read(buffer)) > 0) {
                    fileOutputStream.write(buffer, 0, len);
                }

                // 关闭流
                fileOutputStream.close();
                inputStream.close();
                msg = "文件上传成功";
                // 清除临时文件
                fileItem.delete();
            }
        }
        return msg;
    }

    /**
     * 获取ServletFileUpload
     *
     * @param diskFileItemFactory diskFileItemFactory
     * @return ServletFileUpload
     */
    private ServletFileUpload getServletFileUpload(DiskFileItemFactory diskFileItemFactory) {
        ServletFileUpload servletFileUpload = new ServletFileUpload(diskFileItemFactory);

        // 监听文件上传进度
        servletFileUpload.setProgressListener(
                (pBytesRead, pContentLength, pItems) ->
                        System.out.println("总大小:" + pContentLength + " B,已上传:" + pBytesRead + " B。"));

        // 处理乱码问题
        servletFileUpload.setHeaderEncoding("UTF-8");
        // 设置单个文件的最大值,10MB
        servletFileUpload.setFileCountMax(1024 * 1024 * 10);
        // 设置总共能上传文件的大小,10MB
        servletFileUpload.setSizeMax(1024 * 1024 * 10);
        return servletFileUpload;
    }

    /**
     * 创建DiskFileItemFactory对象,处理文件上传 路径或大小限制
     *
     * @param tmpUploadFile tmpUploadFile
     * @return DiskFileItemFactory
     */
    private DiskFileItemFactory getDiskFileItemFactory(File tmpUploadFile) {
        DiskFileItemFactory diskFileItemFactory = new DiskFileItemFactory();

        // 通过这个工厂类,设置一个大小为1MB的缓冲区,当上传文件大于这个缓冲区时,放到临时文件中
        diskFileItemFactory.setSizeThreshold(1024 * 1024);
        diskFileItemFactory.setRepository(tmpUploadFile);
        return diskFileItemFactory;
    }
}

UDP

java.net.DatagramPacket:数据报包,包括目的地址和传递的数据

java.net.DatagramSocket:数据包套接字,通过send方法发送数据报包DatagramPacket对象


发送端

// 1. 建立一个socket
DatagramSocket socket = new DatagramSocket();

// 2. 建一个包
String msg = "教练,我想打球";
InetAddress localhost = InetAddress.getByName("localhost");
DatagramPacket packet = new DatagramPacket(msg.getBytes(), 0, msg.getBytes().length, localhost, 9090);

// 3. 发送包
socket.send(packet);

socket.close();

接收端

// 开放端口
DatagramSocket socket = new DatagramSocket(9090);

// 接收数据包
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, 0, buffer.length);

// 3. 接收包
socket.receive(packet);

// 获取发送端的相关信息
System.out.println(packet.getAddress().getHostAddress());
System.out.println(new String(packet.getData(), 0, packet.getLength())); // 实际接收到的数据长度

// 4. 关闭连接
socket.close();

互相聊天

实现了Runnable接口的接收类和发送类:

public class TalkSend implements Runnable {

    DatagramSocket socket = null;
    BufferedReader reader = null;

    private int fromPort;
    private String toIp;
    private int toPort;

    public TalkSend(int fromPort, String toIp, int toPort) {
        this.fromPort = fromPort;
        this.toIp = toIp;
        this.toPort = toPort;

        try {
            // 1. 建立一个socket
            socket = new DatagramSocket(this.fromPort);
            // 控制台读取System.in
            reader = new BufferedReader(new InputStreamReader(System.in));
        } catch (SocketException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 2. 建一个包
                byte[] msg = reader.readLine().getBytes();
                DatagramPacket packet = new DatagramPacket(msg, 0, msg.length,
                        new InetSocketAddress(this.toIp, this.toPort));

                // 3. 发送包
                socket.send(packet);
                if (new String(msg).equals("bye")) {
                    break;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        // 4. 关闭连接
        socket.close();
    }
}
public class TalkReceiver implements Runnable {

    DatagramSocket socket = null;
    private int port;
    private String msgFrom;

    public TalkReceiver(int port, String msgFrom) {
        this.port = port;
        this.msgFrom = msgFrom;

        try {
            // 开放端口
            socket = new DatagramSocket(this.port);
        } catch (SocketException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 接收数据包
                byte[] buffer = new byte[1024];
                DatagramPacket packet = new DatagramPacket(buffer, 0, buffer.length);
                socket.receive(packet);

                // 判断是否断开连接
                String receiveData = new String(packet.getData(), 0, packet.getLength());
                System.out.println(msgFrom + ": " + receiveData);
                if (receiveData.equals("bye")) {
                    break;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

创建有发送和接收信息功能的对象

public class TalkTeacher {
    public static void main(String[] args) {
        // 发送进程一个端口,接收进程一个端口;发送的目的端口和接收的端口要与聊天的对象对应起来
        new Thread(new TalkSend(5555, "localhost", 8888)).start();
        new Thread(new TalkReceiver(9999,"student")).start();
    }
}

public class TalkStudent {
    public static void main(String[] args) {
        // 开启两个线程,发送进程一个端口,接收进程一个端口;发送的目的端口和接收的端口要与聊天的对象对应起来
        new Thread(new TalkSend(7777, "localhost", 9999)).start();
        new Thread(new TalkReceiver(8888, "teacher")).start();
    }
}

SMTP与POP3协议

  • SMTP

    发送邮件。

  • POP3

    接收邮件。

  • MIME

    附件。


电子邮件

要在网络上实现邮件功能,必须有专门的邮件服务器

SMTP服务器地址一般是smtp.xx.com

image-20230422174908282


要发送邮件,需要获得协议和支持,开启服务POP3/SMTP

例如网易邮箱:

image-20230422224516310