Java基础知识-05-C-2020-08-04

日志编号说明
C-2020-08-04第一次创建

写在前头

这篇博客里涉及到网络方面的知识,在此只是进行了非常非常浅显的描述性引用,毕竟这部分内容与Java基础知识中相关的部分接触甚少。如果有感兴趣的朋友,这部分支持需要自己学习。
在做网络编程中有三个核心点,分别是IP地址,端口号,通信协议(TCP/UDP)。这三个不论是写练手demo或者是大型项目里都会用到,贯穿始终。

网络模型

常见的网络模型包括OSI参考模式,TCP/IP参考模型。这部分内容不做太多介绍,网上非常专业的解释也很多。
百度百科:OSI参考模型
百度百科:TCP/IP参考模型

TCP

什么是TCP

TCP(Transmission Control Protocol)传输控制协议,是为了在互联网上提供可靠地端到端字节流传输而专门设计的一个协议。

TCP旨在适应支持多网络应用的分层协议层次结构。 连接到不同但互连的计算机通信网络的主计算机中的成对进程之间依靠TCP提供可靠的通信服务。
互联网络与单个网络有很大的不同,因为互联网络的不同部分可能有截然不同的拓扑结构、带宽、延迟、数据包大小和其他参数。TCP的设计目标是能够动态地适应互联网络的这些特性,而且具备面对各种故障时的健壮性。1

UDP

什么是UDP

Internet 协议集支持一个无连接的传输协议,该协议称为用户数据报协议(UDP,User Datagram Protocol)。UDP 为应用程序提供了一种无需建立连接就可以发送封装的 IP 数据包的方法。
Internet 的传输层有两个主要协议,互为补充。无连接的是 UDP,它除了给应用程序发送数据包功能并允许它们在所需的层次上架构自己的协议之外,几乎没有做什么特别的事情。面向连接的是 TCP,该协议几乎做了所有的事情。2

其他常见协议

  1. IP

IP是Internet Protocol(网际互连协议)的缩写,是TCP/IP体系中的网络层协议。设计IP的目的是提高网络的可扩展性:一是解决互联网问题,实现大规模、异构网络的互联互通;二是分割顶层网络应用和底层网络技术之间的耦合关系,以利于两者的独立发展。根据端到端的设计原则,IP只为主机提供一种无连接、不可靠的、尽力而为的数据报传输服务。
IP是整个TCP/IP协议族的核心,也是构成互联网的基础。IP位于TCP/IP模型的网络层(相当于OSI模型的网络层),对上可载送传输层各种协议的信息,例如TCP、UDP等;对下可将IP信息包放到链路层,通过以太网、令牌环网络等各种技术来传送。 为了能适应异构网络,IP强调适应性、简洁性和可操作性,并在可靠性做了一定的牺牲。IP不保证分组的交付时限和可靠性,所传送分组有可能出现丢失、重复、延迟或乱序等问题。3

  1. TCP/IP

TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/网际协议)是指能够在多个不同网络间实现信息传输的协议簇。TCP/IP协议不仅仅指的是TCP 和IP两个协议,而是指一个由FTP、SMTP、TCP、UDP、IP等协议构成的协议簇, 只是因为在TCP/IP协议中TCP协议和IP协议最具代表性,所以被称为TCP/IP协议。
TCP/IP传输协议,即传输控制/网络协议,也叫作网络通讯协议。它是在网络的使用中的最基本的通信协议。TCP/IP传输协议对互联网中各部分进行通信的标准和方法进行了规定。并且,TCP/IP传输协议是保证网络数据信息及时、完整传输的两个重要的协议。TCP/IP传输协议是严格来说是一个四层的体系结构,应用层、传输层、网络层和数据链路层都包含其中。
TCP/IP协议是Internet最基本的协议,其中应用层的主要协议有Telnet、FTP、SMTP等,是用来接收来自传输层的数据或者按不同应用要求与方式将数据传输至传输层;传输层的主要协议有UDP、TCP,是使用者使用平台和计算机信息网内部数据结合的通道,可以实现数据传输与数据共享;网络层的主要协议有ICMP、IP、IGMP,主要负责网络中数据包的传送等;而网络访问层,也叫网路接口层或数据链路层,主要协议有ARP、RARP,主要功能是提供链路管理错误检测、对不同通信媒介有关信息细节问题进行有效处理等。4

  1. HTTP

http是一个简单的请求-响应协议,它通常运行在TCP之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。请求和响应消息的头以ASCII码形式给出;而消息内容则具有一个类似MIME的格式。这个简单模型是早期Web成功的有功之臣,因为它使得开发和部署是那么的直截了当。
在1990年,HTTP就成为WWW的支撑协议。当时由其创始人WWW之父蒂姆·贝纳斯·李(TimBerners—Lee)提出,随后WWW联盟(WWW Consortium)成立,组织了IETF(Internet Engineering Task Force)小组进一步完善和发布HTTP协议。
HTTP是应用层协议,同其他应用层协议一样,是为了实现某一类具体应用的协议,并由某一运行在用户空间的应用程序来实现其功能。HTTP是一种协议规范,这种规范记录在文档上,为真正通过HTTP协议进行通信的HTTP的实现程序。
HTTP协议是基于C/S架构进行通信的,而HTTP协议的服务器端实现程序有httpd、nginx等,其客户端的实现程序主要是Web浏览器,例如Firefox、InternetExplorer、Google chrome、Safari、Opera等,此外,客户端的命令行工具还有elink、curl等。Web服务是基于TCP的,因此为了能够随时响应客户端的请求,Web服务器需要监听在80/TCP端口。这客户端浏览器和Web服务器之间就可以通过HTTP协议进行通信了。5

  1. HTTPS

HTTPS (全称:Hyper Text Transfer Protocol over SecureSocket Layer),是以安全为目标的 HTTP 通道,在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性 [1] 。HTTPS 在HTTP 的基础下加入SSL 层,HTTPS 的安全基础是 SSL,因此加密的详细内容就需要 SSL。 HTTPS 存在不同于 HTTP 的默认端口及一个加密/身份验证层(在 HTTP与 TCP 之间)。这个系统提供了身份验证与加密通讯方法。它被广泛用于万维网上安全敏感的通讯,例如交易支付等方面。6

  1. FTP

文件传输协议(File Transfer Protocol,FTP)是用于在网络上进行文件传输的一套标准协议,它工作在 OSI 模型的第七层, TCP 模型的第四层, 即应用层, 使用 TCP 传输而不是 UDP, 客户在和服务器建立连接前要经过一个“三次握手”的过程, 保证客户与服务器之间的连接是可靠的, 而且是面向连接, 为数据传输提供可靠保证。
FTP允许用户以文件操作的方式(如文件的增、删、改、查、传送等)与另一主机相互通信。然而, 用户并不真正登录到自己想要存取的计算机上面而成为完全用户, 可用FTP程序访问远程资源, 实现用户往返传输文件、目录管理以及访问电子邮件等等, 即使双方计算机可能配有不同的操作系统和文件存储方式。7

Java基于TCP,UDP编程

TCP编程经常使用到的类

在进行基于TCP的数据传输开发时,核心涉及这两个类,一个是socket,另一个是serverSocket。基于TCP的协议,ServerSocket是服务端,Socket是客户端。两个端之间是通过I/O进行数据传输。
下面的例子我想实现的是客户端和服务端建立连接之后,客户端把数据发送给服务端,服务端收到之后再返回给客户端。直到收到客户端发来的,表示终止传输的特征内容exit,两边的连接就会正常断开,整个传输结束。
整个过程,以及内部的逻辑和写法比较长,不适合用问题进行描述。这个例子,我就直接把代码扔在这里,大家可以把例子拷下去,自行体会。
客户端代码:

package com.phl.demoone.inet.socket;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class ClientDemo {
    public static void main(String[] args) {
        try {
            //在TCP协议下,端口号都写服务端的端口号,在实际发送数据的时候,程序会给客户端申请另一个发送数据的端口号
            Socket client = new Socket("localhost", 10059);
            Scanner sc = new Scanner(System.in, "UTF-8");
            OutputStream outputStream = client.getOutputStream();
            InputStream inputStream = client.getInputStream();
            byte[] re = new byte[50];
            String next = "";
            while (client.isConnected()) {
                /*
                 * 原来的代码中有!client.isInputShutdown() && !client.isOutputShutdown(),但是在实际中发现
                 * client.isConnected()的标志就是!client.isInputShutdown() || !client.isOutputShutdown()
                 * 即,输入流或者输出流有一个打开的时候,就是连接上
                 * */
                //if (!client.isInputShutdown() && !client.isOutputShutdown()) {
                /*
                 * 经过实际代码测试,在要这样进行关闭socket的时候,需要先从客户端发送关闭标志,
                 * 然后从服务端进行返回。
                 * 返回的内容并不影响是否会进行关闭操作。
                 * 从运行结果看,在发送关闭标志的时候,连接并没有直接断开,而是等到服务器进行一次返回后,就会断开。
                 * 因此,不需要对服务器返回的数据进行特殊操作。
                 *
                 * TODO 经过多种实验之后发现,客户端只能在发起请求地方进行socket停止。
                 *  服务端只能在返回请求的地方,进行socket停止
                 *
                 * */
                if (next.equals("exit")) {
                    //TODO 针对流,shutdown需要在close之前进行执行,否则会跳socket的异常
                    client.shutdownInput();
                    client.shutdownOutput();
                    inputStream.close();
                    outputStream.close();
                    client.close();
                    break;
                }
                /*
                 * TODO 经过多种实验之后发现,客户端只能在发起请求地方进行socket停止。
                 *  服务端只能在返回请求的地方,进行socket停止
                 * */
                /*if (new String(re).equals("exit")) {
                    client.shutdownInput();
                    client.shutdownOutput();
                    inputStream.close();
                    outputStream.close();
                    client.close();
                    break;
                }*/
                if (sc.hasNext()) {
                    next = sc.next();
                    outputStream.write(next.getBytes());
                    outputStream.flush();
                }
                /*
                *
                * 虽然从代码上看,感觉会是client一旦发送exit,就会停止。但是从实际运行中发现,
                * 在client进行各种各样的close,shutdown操作的时候,当次从客服端发往服务器的链接还是维持着的,
                * 直到收到服务器返回的内容,才正式进行关闭。
                * 因此,在有返回的时候,下面这段代码还是会执行
                * */
                if (inputStream.read(re) != -1) {
                    System.out.println(new String(re));
                }
            }
            //}
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服务端代码如下:

package com.phl.demoone.inet.socket;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;

public class ServerDemo {

    public static void main(String[] args) {
        try {
            //在TCP协议下,端口号都写服务端的端口号,在实际发送数据的时候,程序会给客户端申请另一个发送数据的端口号
            ServerSocket serverSocket = new ServerSocket(10059);
            Socket accept = serverSocket.accept();
            InputStream clientInput = accept.getInputStream();
            OutputStream serverBack = accept.getOutputStream();
            byte[] bytes = new byte[50];
            Scanner sc = new Scanner(System.in);
            String next = "";
            while (!serverSocket.isClosed()) {
                //if ((!accept.isOutputShutdown() && !accept.isInputShutdown()) || !serverSocket.isClosed()) {
                if (clientInput.read(bytes) != -1) {
                    System.out.println(new String(bytes));
                    /*
                    *
                    * 经过运行测试,当服务器收到退出标识符的时候,无法停止服务器的操作。
                    * 只能在服务器针对某个请求,发起返回的时候,才能停止服务器。
                    *
                    * TODO 也就是说,服务端,只有在返回客户端的时候,才可以停止。
                    * */
                    /*if (new String(bytes).equals("exit")) {
                        accept.shutdownInput();
                        accept.shutdownOutput();
                        clientInput.close();
                        serverBack.close();
                        //accept.close();
                        serverSocket.close();
                    }*/
                }
                if (sc.hasNext()) {
                    next = sc.next();
                    serverBack.write(next.getBytes());
                    serverBack.flush();
                    if (next.equals("exit")) {
                        accept.shutdownInput();
                        accept.shutdownOutput();
                        clientInput.close();
                        serverBack.close();
                        //accept.close();
                        serverSocket.close();
                    }
                }
            }
            //}
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

UDP编程经常使用的类

基于UDP协议的开发里,与TCP中最大的区别就在于,UDP中并没有专门当做服务端或者客户端的类,用的都是DatagramSocket
不论是初始化的时候,不论是服务端还是客户端都需要初始化自己的端口号,并且这两个端口号与TCP中的那种不一样。TCP上的点对点传送要求用相同的端口号进行数据传输,但是在UDP中,服务端与客户端的端口号可以不一致,这也是因为他们本身在进行数据传递过程里协议不同所致。作为UDP的客户端,他声明了自己的端口号之后,需要在抓取数据的时候提供服务的端口号。而不是TCP那样一开始就连接上,再传递数据。
客户端代码如下:

package com.phl.demoone.inet.udpsocket;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class UdpClient {
    public static void main(String[] args) {
        DatagramSocket client = null;
        try {
            //这里指定的端口号,是用来发送数据的端口号
            //创建socket,指明了客户端端口号
            client = new DatagramSocket(10059);
            InetAddress localHost = InetAddress.getLocalHost();
            int serverPort = 10060;
            //创建数据包,传入byte数组,长度,服务端Inet对象,服务端端口号
            DatagramPacket packet = new DatagramPacket("test hello".getBytes(),"test hello".getBytes().length,localHost,serverPort);
            //发送数据
            client.send(packet);
            //关闭
            client.close();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            client.close();
        }
    }
}

服务端代码如下:

package com.phl.demoone.inet.udpsocket;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class UdpServer {

    public static void main(String[] args) {
        DatagramSocket server = null;
        try {
            //这里指定的端口号,是用来发送数据的端口号
            //创建socket,指明了服务端端口号
            server = new DatagramSocket(10060);
            byte[] bytes = new byte[1024];
            int lenght = 1024;
            //创建数据包,传入byte数组,长度,服务端Inet对象,服务端端口号
            DatagramPacket packet = new DatagramPacket(bytes,lenght);
            //发送数据
            server.receive(packet);
            System.out.println(new String(packet.getData()));
            //关闭
            server.close();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            server.close();
        }
    }
}

Java8重要特性

在Java8中提出了多个新特性,其中最具特色的分别是lambda表达式和streamAPI。
针对其他特性,如果下面的内容中有涉及到会提一下,其他的并在这里进行讲解。
为了避免那种一上来就讲lambda表达式写法,streamAPI的使用,这样讲太生硬,不仅对纯小白没帮助,对非小白也没有帮助。与其来我这里看我的讲解,还不如直接看JavaAPI更好更准确。
因此在讲解Java8重要特性时,我会按照下面的讲解顺序进行讲解。

编程范式

这个概念可能对大家来说比较陌生,但是这里面出现的内容却一点都不难理解。所谓的编程范式,是一种游离于具体的编程语言,而是专注于程序本身的分类。

常见的编程范式有三种,分别是命令式编程,声明式编程和函数式编程。

  1. 命令式编程
    命令式编程很常见,我们熟悉的Java就是命令式编程。命令式编程的核心思想就是关注计算机的执行步骤,即一步一步的告诉计算机要去执行什么。仔细想想在进行Java开发的时候,我们就是这样一步一步去把自己的逻辑写进去。
  2. 声明式编程
    声明式编程也很常见,对于一个Java程序员而言,最常用到声明式编程的地方,就是写SQL。声明式编程是以数据结构的形式来表达程序执行的逻辑。声明式编程与命令式编程最大的区别就在于,声明式编程只是告诉计算机需要做什么,但是不用体现出具体怎么做。
    仔细想一想平时写的SQL。我们只是告诉计算机,需要从哪个表里抓取出来符合哪些条件的数据,但是具体怎么抓,一步一步是什么过程,我们重来都不关注。这样的编程就称之为声明式编程。
    除过SQL,类似于前端用到的CSS也是这种情况。
  3. 函数式编程
    函数式编程就有意思了,为什么有意思,主要原因在于对于一个Java程序员而言,在Java8之前,是不存在函数式编程的。因此这个函数式编程对于大多数程序员来说是一个非常陌生的概念。但是,这个陌生的概念恰好就是支撑lambda表达式,streamAPI的核心思想。因此在很多资料中,如果是直接去讲解lambda表达式或者streamAPI的时候,就会变得非常难理解。究其原因就在于Java程序员不了解什么叫做函数式编程。
    **函数式编程与声明式编程是有所关联的。**因为它们有同样的一个思想:它们只关注与要做什么,而不是怎么去做。但是,函数式编程并不仅仅局限于声明式编程。
    在很多讲解函数式编程的文献或者资料中,都有一句话“函数式编程中,函数是第一等公民(first class)”。
    这里面需要先讲一下这个第一等公民具体是要做什么。所谓第一等公民,可以理解成数据类型。以Java为例,在Java中,一个int可以当做一个方法的入参,可以当做方法的返回值。而函数式中提到的函数是第一等公民,也就意味着函数能出现在任何地方,即这个函数可以当做方法的入参,当做方法的返回值。在很多其他的编程语言中都提供了对函数式编程的支持。Java则是在Java8的时候做出来对这部分的支持,即Stream API。
    因为函数式编程本身离Java太远,所以这里暂不举例,而是会在后续的内容中不断深入进行讲解。

函数式接口

Java中为了对函数式编程进行支持,因此在Java8中出现了一个接口Stream,这个Stream并不是指流,而是java.util.Stream。在Stream提供的方法中,大量使用了函数式接口,同时也体现了对函数式编程的支持,即函数可以当做方法的入参,返回值。
比如这个常见的方法:

void forEach(Consumer<? super T> action);

他的入参就是一个函数,确切说他的入参就是一个函数式接口,代码如下:

函数式接口在Java里有专门的注解,@FunctionalInterface

FunctionalInterface注解是Java8才有的,这个注解用于修饰接口(interface)。从概念上讲,函数式接口有且只有一个抽象方法。从Java8开始,接口中可以的抽象方法可以用default关键字来描述,被default修饰的方法有具体实现,因此它们不是抽象的。如果接口声明一个抽象方法来重写 java.lang.Object 的public方法,这也不计入接口的抽象方法计数,因为接口的任何实现类都将具有来自 java.lang.Object 或其他地方的实现(对于这个抽象方法而言)。
现在用Consumer接口为例,Consumer的代码如下:

package java.util.function;

import java.util.Objects;

/**
 * Represents an operation that accepts a single input argument and returns no
 * result. Unlike most other functional interfaces, {@code Consumer} is expected
 * to operate via side-effects.
 *
 * <p>This is a <a href="package-summary.html">functional interface</a>
 * whose functional method is {@link #accept(Object)}.
 *
 * @param <T> the type of the input to the operation
 *
 * @since 1.8
 */
@FunctionalInterface
public interface Consumer<T> {

    /**
     * Performs this operation on the given argument.
     *
     * @param t the input argument
     */
    void accept(T t);

    /**
     * Returns a composed {@code Consumer} that performs, in sequence, this
     * operation followed by the {@code after} operation. If performing either
     * operation throws an exception, it is relayed to the caller of the
     * composed operation.  If performing this operation throws an exception,
     * the {@code after} operation will not be performed.
     *
     * @param after the operation to perform after this operation
     * @return a composed {@code Consumer} that performs in sequence this
     * operation followed by the {@code after} operation
     * @throws NullPointerException if {@code after} is null
     */
    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

这个接口中,有且只有一个抽象方法accept(T t)。下面的andThen方法由于被default关键字修饰了,因此这个默认方法是有具体实现的。

由此可见,函数式接口的定义就是有且仅有一个抽象方法的接口,满足这个条件的接口,就是一个函数式接口。
,是否被FunctionalInterface注解,并不影响它是一个函数式接口。但是添加了这个注解,那么当在出现不满足函数式接口的情况时编译器会进行错误提示。

lambda表达式

什么是lambda表达式

随着讲解的深入,知识点在逐步递进,范围越来越小。先介绍了什么叫做函数式编程,后来讲了在Java中有什么样的东西体现了函数式编程的思想,也就是在Java里什么是函数,那就是上面说的函数式接口。现在有了函数式接口的定义了,可是怎么用还没有说清楚。这个具体的用法就是用函数式接口当做方法的入参、出参。(这里写入参出参是想说明函数式接口既可以做入参,又可以做出参)。

回顾一下函数式编程,这里面要求函数式第一类公民,可以当做方法的入参和出参。
下面用Runnable接口进行演示。
在Java8之前,要用Runnable接口当做方法的入参,出参的话,实际情况都是用了Runnable接口的实现类去完成上述过程。最常见的例子如下:

public static void main(String[] args) {
        //Java8之前的常见写法
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("通过匿名内部类对象创建线程");
            }
        }).start();
        //Java8之后的新写法。
        new Thread(() -> System.out.println("通过lambda表达式创建线程")).start();
    }

在阅读上述例子的时候,暂且把lambda表达式的语法忽略,而是专注这两者之间的异同。
我们都知道,Thread类的诸多构造方法中,有一个构造方法是传入Runnable接口类型的参数,这种写法从什么时候开始就已经存在我并不清楚,但是我可以肯定的是,在我接触过的最早的版本JDK1.4中就存在。由此可见,这个过程实际上存在很久。即便在当时Java中并没有专门作用于函数式编程的写法时,这种设计思想就已经融入到Java中。
那个时候(Java8之前)的统一做法无非是做专门的实现类,或者匿名内部类,或者继承Thread类。
由此,我们一直都能够理解的一个写法就是,当一个方法的入参是接口类型时,实际上就是传入了这个接口的实现类。
这种思想在函数式接口 FunctionalInterface同样适用,唯一的区别在于下面一段官方解释。这段解释是FunctionalInterface注解上官方解释中的一段话。

Note that instances of functional interfaces can be created with lambda expressions, method references, or constructor references.

翻译:请注意,可以使用 lambda 表达式方法引用构造函数引用去创建函数式接口的实例。

由此可见lambda表达式只是针对函数式接口在进行实例化时的一种写法,其本质上与接口的实现类,匿名内部类没有任何区别。说了这么多,就属这句话最值钱。通过上面的各种铺垫,在介绍lambda表达式的具体语法之前,先把结论放在这里了。lambda表达式只是针对函数式接口在进行实例化时的一种写法,除此之外没有任何特殊的
我不会其他的编程语言,因此也说不上来其他语言中的函数式编程是如何体现的。不过,基于上述的描述而看,Java对于函数式编程的支持是引入了一个更简洁的实例化方式,通过这种实例化方式生成接口的实例,然后整个过程就回到了贯穿Java始终的把一个接口的实现类作为参数或者返回值。

lambda表达式语法

lambda表达式最基本的语法骨架如下:

接口 变量名 = (参数) -> {方法体};

左侧()内是函数是接口中唯一的那个抽象方法的参数列表, -> 是一个固定写法,右侧的{}内是这个抽象方法的具体实现。
上面的写法是最基础的,最完全的写法。一个简单的demo:

package com.phl;

public class BaseLambda {
    Show lambdaImpl;
    {
        //lambda表达式是用来进行函数是接口实例化的
        /*
         * 如果大家问,能不能只进行实例化,不接收实例化之后的对象?
         * 不行。因为在lambda表达式中,就是通过左侧对象来确定具体要对那个接口进行实例化。
         *
         * (int a )就是接口里的唯一的抽象方法的参数列表
         *
         * {},大括号内的内容就是抽象方法的具体实现。
         */
        lambdaImpl = (int a) -> {
            System.out.println("最基础,最完整的写法。" + a);
        };
    }

    public BaseLambda() {
        lambdaImpl.printInteger(666);
    }

    public static void main(String[] args) {
        new BaseLambda();
    }

}

@FunctionalInterface
interface Show {
    void printInteger(int a);
}

对上面的最基本,最完全的写法进行熟悉之后,下面会提出各种各样的简洁变形。

  1. ()内的参数类型不用写,只需要写形参(如果抽象方法有入参的话)
  2. 如果抽象方法只有一个入参,表示参数的()可以不写,只用写形参
  3. 如果抽象方法的具体实现只有一个语句,那么表示方法体的{}可以不写,直接在 -> 后面写语句即可
  4. 如果抽象方法的具体实现只有一个一句,并且这个语句恰好是用于return的语句,此时{}不可以省略,但是如果要return的是一个常量,那么此时{}可以省略,并且return 关键字也可以省略,直接在 ->后面写要返回的常量即可

差不多上述就是lambda表达式在使用过程里的各种变形。我个人的话,一般都不会用这些变形,主要原因是这些变形,我没觉得有多方便,并且在实际使用时往往得注意各种变形的规则。

java.util.function包

为了丰富和提升lambda表达式的可用性,不用每次想用的时候还需要专门定义一些接口,在Java8中,java.util.function包下面提供了很多常见的基础型接口。这样的接口为我们的日常使用提供了非常大的便利。下面把常用到的接口进行列举。这些接口都在function包下, 更全面的可以查看源码。

接口抽象方法方法意义
Consumer< T >void accept(T t);这个接口表示一个输入
DoubleConsumervoid accept(double value);这个接口表示一个double型的输入
IntConsumeraccept(int value);这个接口表示一个int型的输入
LongConsumervoid accept(long value);这个接口表示一个long型的输入
---
BiConsumer<T, U>void accept(T t, U u);这个接口表示两个输入,分别是T和R
ObjDoubleConsumer< T >void accept(T t, double value);这个接口表示两个输入,第一个是指定的泛型T,第二个是double
ObjIntConsumer< T >void accept(T t, int value);这个接口表示两个输入,第一个是指定的泛型T,第二个是int
ObjLongConsumer< T >void accept(T t, long value);这个接口表示两个输入,第一个是指定的泛型T,第二个是long
---
Supplier < T >T get();这个接口表示一个输出
BooleanSupplierboolean getAsBoolean();这个接口表示一个boolean型的输出
DoubleSupplierdouble getAsDouble();这个接口表示一个double型的输出
IntSupplierint getAsInt();这个接口表示一个int型的输出
LongSupplierlong getAsLong();这个接口表示一个long型的输出
---
Function<T, R>R apply(T t);这个接口代表一个输入,一个输出。这里的输入和输出类型可以不一样
DoubleFunction< R >R apply(double value);这个表示一个double型输入,一个定义的泛型R输出
DoubleToIntFunctionint applyAsInt(double value);这个表示一个double输入,一个int输出
DoubleToLongFunctionlong applyAsLong(double value);这个表示一个double输入,一个long输出
IntFunction< R >R apply(int value);这个表示一个int型输入,一个定义的泛型R输出
IntToDoubleFunctiondouble applyAsDouble(int value);这个表示一个int型输入,一个double输出
IntToLongFunctionlong applyAsLong(int value);这个表示一个int型输入,一个long输出
LongFunction< R >R apply(long value);这个表示一个long型输入,一个定义的泛型R输出
LongToDoubleFunctiondouble applyAsDouble(long value);这个表示一个long型输入,一个double输出
LongToIntFunctionint applyAsInt(long value);这个表示一个long型输入,一个int输出
---
Predicate< T >boolean test(T t);表示对一个对象进行判断,返回boolean值
IntPredicateboolean test(int value);传入一个int值,返回boolean
LongPredicateboolean test(long value);传入一个long值,返回boolean
DoublePredicateboolean test(double value);传入一个double值,返回boolean
BiPredicate<T, U>boolean test(T t, U u);传入两个对象,返回Boolean
---
UnaryOperator< T > extends Function<T, T>-这个接口有点特殊,他继承自Function接口,就是我们上面说的那个function。他没有定义自己的抽象方法,所以实际用的时候,调用的还是Function提供的apply。唯一的区别是,最基本的Function里有两个泛型,一个当做输入,一个当做输出,输入输出可以是两个不同的类型。但是在UnaryOperator里只有一个类型,也就说这个接口的基本型是一个输入,一个输出,并且这两个类型一致
DoubleUnaryOperatordouble applyAsDouble(double operand);表示一个double输入,一个double输出
IntUnaryOperatorint applyAsInt(int operand);表示一个int输入,一个int输出
LongUnaryOperatorlong applyAsLong(long operand);表示一个long输入,一个long输出

方法引用

在上述介绍lambda表达式的时候,有一句话是,函数式接口可以通过lambda表达式,方法引用,构造方法引用来进行实例化。之前已经用了大段介绍了lambda表达式,现在简要介绍一下方法引用和构造器引用。
先扔一个例子,根据这个例子,我们就能很好的理解方法引用具体是个什么。

package com.phl;

import java.util.function.BiFunction;
import java.util.function.Supplier;

public class UserBean {

    private String userName;
    private String gender;

    public UserBean() {
    }

    public UserBean(String userName, String gender) {
        this.userName = userName;
        this.gender = gender;
    }

    @Override
    public String toString() {
        return "UserBean{" +
                "userName='" + userName + '\'' +
                ", gender='" + gender + '\'' +
                '}';
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public String getGender() {
        return gender;
    }

    public void setGender(String gender) {
        this.gender = gender;
    }

    public static void main(String[] args) {
        //最基础的创建方法
        UserBean userBean = new UserBean("张三", "男");
        //通过构造方法引用的方式
        /*
         * 无参构造方法。因为构造方法在执行之后会返回一个对象,因此用了一个有返回值
         * 并且无参的函数式接口。
         */
        Supplier<UserBean> supplier =  UserBean :: new;//这一步实际是实例化了一个泛型是UserBean类型的函数式接口
        UserBean userBean1 = supplier.get();//通过调用函数式接口中的抽象方法,得到了泛型类的实例。
        //有参构造方法
        /*
         * 调用有参构造方法时,除了要有返回的对象,还需要有对应的入参。
         * 如果Java提供的函数式接口的参数不够匹配当前对象(假如构造方法里有10个属性),只需要自己定一个函数式接口即可。
         */
        BiFunction<String ,String ,UserBean> biFunction = UserBean::new;
        UserBean userBean2 = biFunction.apply("李四", "男");//通过调用函数式接口中的抽象方法,得到了泛型类的实例。

    }
}

上面的例子中采用了构造方法引用的形式。比较通俗的理解是,使用构造方法医用的时候,第一步用构造方法引用(UserBean :: new)实例化一个合适的函数式接口。第二步,通过这个函数式接口中的抽象方法得到目标UserBean的实例。总体上,可以想象成我要把这个即将被实例化的类放到那个函数式接口中,之后只需要调用函数式接口的抽象方法,就可以实例化一个对象出来。这个感觉就很类似与工厂模式。

首先需要明确的,lambda表达式,方法引用,构造方法引用这三者都是作用于函数式接口的,也就是用来实例化函数式接口的,对于其他的就不能这么使用。在此在说明一个,方法引用是一种新的写法,这种写法中如果引用了构造方法,那就是构造方法引用,具体的语法如下:

//方法引用
类名/实例名 :: 方法名
//构造方法引用
类名 :: new
//上述写法里出现的 :: ,就类似于lambda表达式里的 -> 一样,是一个操作符。

方法引用总体语法就是如上所示。需要注意的是,在日常使用中,我们很多的方法,构造方法都是有参数的,但是在里的语法中却没有任何一个进行传参的地方。

在方法引用中,如果被引用的方法或者构造方法是有参数的,那么这个参数一定是通过方法引用或者构造方法引用之后得到的函数式接口的抽象方法进行传递,详见这个小节里第一个例子中通过BiFunction的形式。

方法引用的第二个常用方式。如果一个方法的参数,当这个方法的入参是通过函数式接口进行传入的时候,那么此时,就可以用方法引用的形式进行接收。
代码示例如下:

public static void main(String[] args) {
        //最基础的创建方法
        UserBean userBean = new UserBean("张三", "男");
        //通过构造方法引用的方式
        /*
         * 无参构造方法。因为构造方法在执行之后会返回一个对象,因此用了一个有返回值
         * 并且无参的函数式接口。
         */
        Supplier<UserBean> supplier =  UserBean :: new;//这一步实际是实例化了一个泛型是UserBean类型的函数式接口
        UserBean userBean1 = supplier.get();//通过调用函数式接口中的抽象方法,得到了泛型类的实例。
        //有参构造方法
        /*
         * 调用有参构造方法时,除了要有返回的对象,还需要有对应的入参。
         * 如果Java提供的函数式接口的参数不够匹配当前对象(假如构造方法里有10个属性),只需要自己定一个函数式接口即可。
         */
        BiFunction<String ,String ,UserBean> biFunction = UserBean::new;
        UserBean userBean2 = biFunction.apply("李四", "男");//通过调用函数式接口中的抽象方法,得到了泛型类的实例。

        List<UserBean> userBeanList = new ArrayList<>();
        userBeanList.add(userBean);
        userBeanList.add(userBean1);
        userBeanList.add(userBean2);
        //注意,输出结果中第二个对象的属性都是bull,是因为第二个对象是通过无参构造方法创建了之后没有进行过赋值。
        System.out.println("---------------for-----------------");
        for (UserBean bean : userBeanList) {
            System.out.println(bean.toString());
        }
        System.out.println("---------------lambda--------------");
        //lambda表达式
        userBeanList.forEach((a) -> System.out.println(a) );
        //方法引用
        System.out.println("---------------method  reference---");
        userBeanList.forEach(System.out::println);
    }

这段代码是上面那个例子里面的main方法,我在main方法后面多了三种循环输出。第一个是增强for循环,这个大家都熟悉。第二个是lambda表达式,第三个是方法引用。现在针对第二个和第三个方式进行讲解。

第二个,第三个里面存在一个问题。
我之前一直在强调lambda表达式,方法引用,构造器引用只能用于函数式接口,但是这个里面没有出现函数式接口的影子。
其实不然,forEach方法代码如下:

default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }

这个方法是Iterable接口中的一个方法。这个方法并没有返回值,但是有一个Consumer的入参action。从代码中可以出来,他只是执行了传入的Consumer接口中的accept方法,并且accept的入参,是调用了forEach方法的对象中的各个元素。
而在之前讲解lambda表达式的时候说到过,lambda表达式的写法应该如下:

接口 变量名 = (参数) ->{方法体};

但是在这个例子中,因为已经确定了forEach里面要求的是一个Consumer类型的接口,因此前半部分就可以做成省略,省略的原因就是这里接口的类型已经通过forEach进行了指明。
其实,在demo中可以写成一个更复杂,但是更直观的方法,如下只是粘贴的对比的代码片段,同样是这个类的main方法中的一个片段。

		//lambda表达式
        userBeanList.forEach((a) -> System.out.println(a) );
        //更复杂,但是更直观的lambda
        //实例化函数式接口
        Consumer<UserBean> consumer = (a)->{
            System.out.println(a);
        };
        //把实例化之后的函数式接口传入到forEach中
        userBeanList.forEach(consumer);

通过这段比较的写法,希望大家能明白,上述简单的写法,其实就是最常见的匿名对象。
同样,方法引用的例子其实也只是做了对应的匿名对象(Consumer consumer)的简化而已。

核心强调一点,不论是lambda表达式还是方法引用,构造方法引用针对函数式接口的,也就是说不论什么时候,他们三者返回的对象都是一个函数式接口的实例。函数式接口之所以特殊,是因为函数式接口在作为方法的返回值或者入参的时候,实际传递的使他们内部的运算逻辑,而不是某个具体的值。

Stream API

lambda表达式那个小节详细介绍了它的语法以及在Java8中官方提供的函数是接口。但是对于常规使用者而言,那些函数式接口距离真实使用还是有一步之遥。为了更便捷使用其中的特性,在Java8中,提出了新的一个工具包:java.util.stream。而我们总说的Stream API就是指这个包下的一个接口,声明代码如下:

public interface Stream<T> extends BaseStream<T, Stream<T>>

当然啦,从源码中可以看出来这个Stream类似于上面提到的那些函数式接口一样,提供了各种基础数据类型的变形体Stream,比如有IntStream,DoubleStream,LongStream等。这里并不会展开去进行讲解,而是基于这个Stream去进行讲解。

什么是Stream

首先,Stream并不是一种数据结构,不能用来保存数据。它是有关算法和计算的,实际使用中Stream更像是一个高级版的Iterator。普通的Iterator在使用时,程序员只能显示地一个一个遍历元素并对其执行某些操作;在Stream中,程序员只需要给出对其包含的元素执行什么操作,比如“获取每个字符串的首字母”等,Stream会隐式地在内部进行遍历,做出相应的数据转换。
Stream有些地方很像Iterator,单向,不可往复,数据只能便利一次,遍历过一次后即用尽了。
Stream又有和Iterator不同的地方,Stream可以并行化操作(内部基于Fork/Join框架,始于 Java7),迭代器只能命令式地、串行化操作。Stream采用了延迟计算,并且Stream中支持过滤,查找,转换,汇总,聚合等操作。

Stream的构成

当我们使用Stream API的时候,通常包括如下三个基本步骤:
获取一个数据源(source) ----- 数据转换(也称为中间操作) ----- 执行操作获取想要的结果(也称为最终操作)。
这里需要讲到Stream中的一个特性,也是上面提到的延迟计算。延迟计算的效果是,在使用Stream API的时候,如果只有获取数据源,数据转换这两个过程,那么在实际执行时,这两部分是不会被执行的。只有当提供了最终操作了之后,整个过程才会被执行。
所谓Stream的构成,说的就是这三个基本步骤。这三个步骤在实际操作里起到了不同的作用。下面会逐个讲解这三个步骤。

获取一个数据源(source)

因为Stream并不是一个数据结构,因此在使用Stream API之前需要从一个数据源里抓取数据到Stream中。注意一点,这里说的数据抓取,其实更应该解释做包装,或者管道。这里提到的管道的意义,就像是在讲I/O的时候提到的那种,节点流,处理流的关系一样。真实的,原本就用来装载数据的集合就是节点流,提供了实际的数据,而Stream接口更像是上层的处理流,提供了更多更简便的方法供程序员使用。
有如下方法把数据云绑定到一个Stream上,按照实际工作中的使用频次从高频往低频进行介绍。

把Collection和数组绑定到Stream上

  1. Collection.Stream();
    Collection接口在Java中经常会用到,这个接口继承自Iterable接口。它常见的实现类或者子接口包括各种各样的Set,Map,List,Vector等,不夸张的说,日常工作里那些用到了的集合都有一个stream()方法,通过这个方法可以把数据绑定到Stream接口上。
  2. Collection.parallelStream();
    这个方法的底层调用与Collection.Stream()调用了同样的方法,但是是否支持并行的这个boolean值上,Collection.Stream()传的是fales,Collection.parallelStream()传递的是true。这样的区别导致通过Collection.parallelStream()绑定的Stream是只是Fork/Join框架的,可以并行处理。
  3. Arrays.stream(T arrays);
    Arrays类里面提供的静态方法,通过这个方法可以把数组类型的数据绑定到Stream上。
  4. Stream.of(T… values);
    这个和下面的方法是Stream接口里提供的方法。
    这个方法里传入的是把传入的不定长元素绑定到一个Stream上。
  5. Stream.of(T t);
    这个方法的将传入的这个单一元素绑定到Stream上。

把BufferedReader读取的数据绑定到Stream上

  1. java.io.BufferedReader.lines();
    这个lines()方法返回的就是一个Stream< String >这样一个接口对象。

通过静态工厂
这里只列举一个例子,上述说过,针对Stream接口在Java中还有各种变形,其中一个就是IntStream。这个接口中提供了静态工厂方法去创建一个指定范围的IntStream。

java.nio.file.Files.walk()
nio中的Files类中的walk方法。注意,Files下的walk方法有两套不同的参数列表,不论是哪个方法,返回的都是Stream< path>这个接口。

自己创建
在Stream接口的源代码里,很多创建的过程使用到了java.util.Spliterator这个类。如果上述所说的都不满足你的需求,可以仿造Stream中源码的样子自己进行创建。

除去上述所说,还有如下几个方式
由于下面的这几个方式在日常使用中的频率都低的可怕了,我就放在这里,大家要是用的话,可以自行查看其中的意义。

  • Random.ints()
  • BitSet.stream()
  • Pattern.splitAsStream(java.lang.CharSequence)
  • JarFile.stream()

数据转换(中间操作)

这类操作在官方解释中定义为Intermediate,一个Stream可以进行零个或者N个intermediate操作。Intermediate操作的目的就是在通过Stream对绑定上来的数据进行数据映射、过滤。每一个Intermediate操作都是一次映射、过滤,然后返回一个新的Stream,交给下一轮操作。这些操作都是惰性化的(lazy),只调用Intermediate操作的话,并没有真正开始Stream的遍历。
Intermediate操作包括:map(mapToInt,flatMap等)、filter、distinct、sorted、peek、limit、skip、parallel、sequential、unordered
下面介绍一些比较有特色或者相对复杂的功能。

map/flatMap
这个方法的作用就是把函数式接口中输入的元素进行特殊的转换,并且将转换的结果构成了一个新的Stream并进行返回。其中map是应对单个元素的方法,而flatMap是应对多个元素的情况。

map方法是应对单个元素的,flatMap是应对多个元素的。具体例子如下

public static void main(String[] args) {
        String[] strs = {"a", "b", "c", "d"};
        Stream<String> stream = Arrays.stream(strs);
        //map的功能是将stream中的每个元素按照指定的规则映射到新的stream对象中。
        stream.map(String :: toUpperCase).forEach(System.out :: println);
        //flatMap是应对这种集合形的数据而言。
        Stream<List<Integer>> inputStream = Stream.of(
                Arrays.asList(1),
                Arrays.asList(2, 3),
                Arrays.asList(4, 5, 6)
        );
        inputStream.flatMap((childList) -> childList.stream()).forEach(System.out :: println);
    }

filter,过滤。在filter方法中定义过滤规则,通过这个规则的元素会返回到新的Stream中。这个比较好理解,我这里就不举例了。
peek,这个方法遍历元素,并且什么都不做。这里说的什么多不做是指,就算有了操作,这里面的结果并不会返回一个新数据组成的Stream,从最终效果上看,就真的是什么都不做了。
造成这三者(map,filter,peek)的不同之处就在于他们内部是基于不同的接口进行操作的。

  • map中的接口是Function<? super T, ? extends R>,表示一个输入一个输出,因此他可以把元素的更改返回到新的Stream上。
  • filter中的接口是Predicate<? super T>,这个接口是断言,也就是上面提到的那个返回boolean的接口,因此可以通过这个接口挑选出符合逻辑的元素,把符合的元素统一返回到一个新的Stream中。
  • peek中的接口是Consumer<? super T>,这个接口会提供一个入参,但是没有返回值,也就是说这个值输入进来之后不论做了什么操作也不会返回一个新的Stream对象出去。

因此,如果日后在使用这些提供的方法存在什么疑问的时候,不妨从这些方法内部的函数式接口作为切入点,不同接口的特性限制了这些方法能做的事情。

limit/skip,这两个方法都是限制返回的Stream。limit是返回 Stream 的前面 n 个元素;skip 则是扔掉前 n 个元素(它是由一个叫 subStream 的方法改名而来)。
注意,有一种情况下limit和skip无法达到 short-circuiting 目的的,就是把它们放在 Stream 的排序操作后,原因跟 sorted 这个 intermediate 操作有关:此时系统并不知道 Stream 排序后的次序如何,所以 sorted 中的操作看上去就像完全没有被 limit 或者 skip 一样。
sorted,排序。对 Stream 的排序通过 sorted 进行,它比数组的排序更强之处在于你可以首先对 Stream 进行各类 map、filter、limit、skip 甚至 distinct 来减少元素数量后,再排序,这能帮助程序明显缩短执行时间。
distinct,去重。这个方法的用法就是把Stream中的内容进行去重,并将去重之后的结果返回成一个新的Stream。

执行操作获取想要的结果(也称为最终操作)

这类操作在官方解释中称为Terminal,一个Stream只能有一个terminal操作,当这个操作执行之后,Stream就被使用“光”了,无法再被操作。所以这必定是Stream的最后一个操作。Terminal操作的执行才会真正开始Stream的遍历,并且会生成一个结果,或者一个 side effect。
Terminal操作包括:forEach、forEachOrdered、toArray、reduce、collect、min、max、count、anyMatch、allMatch、noneMatch、findFirst、findAny、Iterator。
介绍一些常用的Terminal操作。
forEach,这个方法很常用,并且在很多demo中我都用它来当做Terminal操作。这个方法就是循环Stream中的每一个元素,并且执行forEach方法里的逻辑。注意,forEach方法无法被break,return这样的关键字提前结束循环。
注意,因为forEach是Terminal方法,所以执行了之后这个Stream就不能再给调用。如果只是想单纯的输出一下内部内容,而不是用这个当做最终操作的话,可以通过peek()进行实现。但是如果只是把forEach换成peek的话,整体不会执行,因为没有Terminal操作。
findFirst是兼具Terminal和short-circuiting的方法。这个方法意思是返回Stream中的第一个元素,或者返回空。
这里的重点在于在于这个方法本身,代码如下:

Optional<T> findFirst();

返回值类型Optional。这是一个模仿 Scala 语言中的概念,作为一个容器,它可能含有某值,或者不包含。使用它的目的是尽可能避免 NullPointerException。当我们拿到Optional类型的返回值之后,可以通过Optional类中提供的方法进行各式各样的判空操作。类似如下的形式:

// Java 8
 Optional.ofNullable(text).ifPresent(System.out::println);
 // Java 8 之前的做法
 if (text != null) {
 System.out.println(text);
 }

reduce,这个方法很有特点。在做大数据的时候有一个叫MapReduce的东西,他的名字里也有Reduce。从英文意思上看,reduce的意思是减少,这里所谓的减少,主要作用是把 Stream 元素组合起来。它提供一个起始值(种子),然后依照运算规则(BinaryOperator),与前面 Stream 的第一个、第二个、第 n 个元素组合。从这个意义上说,字符串拼接、数值的 sum、min、max、average 都是特殊的 reduce。
例如 Stream 的 sum 就相当于:

Integer sum = integers.reduce(0, Integer::sum);//0是起始值(种子),后面的内容就是运算规则,这里用了Integer里的sum方法

max、min,最大值,最小值。min 和 max 的功能也可以通过对 Stream 元素先排序,再 findFirst 来实现,但前者的性能会更好,为 O(n),而 sorted 的成本是 O(n log n)。同时,也可以在reduce中通过元素本身提供的比较方法或者其他方法实现同样的逻辑。还是用Integer为例,如:

Integer min = integers.reduce(0,Integer :: min);

match,匹配,返回false。在Stream中提供了三个匹配的方法

  • allmatch,Stream 中全部元素符合传入的 predicate,返回 true
  • anyMatch,Stream 中只要有一个元素符合传入的 predicate,返回 true
  • noneMatch,Stream 中没有一个元素符合传入的 predicate,返回 true

还有一种操作的分类

这种操作的分类,在官方解释中称为short-circuiting 。这是一个逻辑划分,而不是说它游离于IntermediateTerminal 操作之外的另一种操作。

  • 对于一个Intermediate操作,如果它接受的是一个无限大(infinite/unbounded)的Stream,但返回一个有限的新Stream。
  • 对于一个Terminal操作,如果它接受的

short-circuiting操作包括:anyMatch、allMatch、noneMatch、findFirst、findAny、limit

一个值得关注的问题

在对于一个Stream进行多次Intermediate操作,每次都对Stream的每个元素进行转化,并且是执行多次,这样时间复杂度就是N(转换次数)个for循环里把所有操作做掉的总和吗?
其实不是这样的。转换都是lazy的,多个操作只会在Terminal操作的时候融合起来,一次循环完成。我们可以这样简单的理解,Stream 里有个操作函数的集合,每次转换操作就是把转换函数放入这个集合中,在 Terminal 操作的时候循环 Stream 对应的集合,然后对每个元素执行所有的函数。

还有一些更进阶的操作,但是在基础知识部分不打算做更多解释,如果有需要的话,可以点击这个链接。Stream API 详解
上述对于Stream API解释中部分专业性文字描述也出自这个链接,在此表示感谢。

参考文献


  1. 摘抄自百度百科:TCP ↩︎

  2. 摘抄自百度百科:UDP ↩︎

  3. 摘抄自百度百科:IP ↩︎

  4. 摘抄自百度百科:TCP/IP ↩︎

  5. 摘抄自百度百科:HTTP ↩︎

  6. 摘抄自百度百科:HTTPS ↩︎

  7. 摘抄自百度百科:FTP ↩︎

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值