聊聊分布式架构04——RPC通信原理
目录
RPC通信的基本原理
RPC结构
手撸简陋版RPC
知识点梳理
1.Socket套接字通信机制
2.通信过程的序列化与反序列化
3.动态代理
4.反射
思维流程梳理
码起来
服务端时序图
服务端—Api与Provider模块
客户端时序图

RPC通信的基本原理
RPC(Remote Procedure Call)是一种远程过程调用协议,用于在分布式系统中进行远程通信,允许一个计算机程序调用另一个地址空间(通常是在不同的机器上)的函数或过程,就像调用本地函数一样。下面是RPC通信的基本原理:
-
客户端调用:远程客户端(调用方)希望调用远程服务器(提供方)上的一个或多个远程过程(函数)。客户端在本地创建一个请求,并指定要调用的远程过程的名称以及传递给该过程的参数。
-
参数序列化:在将请求发送到远程服务器之前,客户端需要将参数序列化为字节流或其他适合传输的格式。序列化是将数据转换为可以在网络上传输的形式的过程。
-
网络传输:客户端通过网络将请求发送到远程服务器。通常,这涉及到将请求数据打包成网络消息,然后通过网络协议(如HTTP、TCP/IP)将消息发送到服务器的地址。
-
服务器接收:远程服务器接收到客户端的请求消息,通常通过网络协议(如HTTP服务器、Socket服务器)监听特定的端口。
-
参数反序列化:服务器从请求消息中提取参数数据,并将其反序列化为本地数据结构,以便将其传递给远程过程。
-
远程过程调用:服务器调用相应的远程过程,将参数传递给该过程并执行相应的操作。远程过程可以位于服务器的本地代码中或远程服务器上的远程服务中。
-
结果序列化:远程过程执行完毕后,服务器将结果序列化为字节流或其他适合传输的格式。
-
结果传输:服务器通过网络将结果数据打包成响应消息,并将其发送回客户端。
-
客户端接收:客户端接收到服务器的响应消息。
-
结果反序列化:客户端从响应消息中提取结果数据,并将其反序列化为本地数据结构。
-
客户端处理:客户端可以根据远程过程的执行结果采取相应的行动,可能是继续执行本地代码或返回结果给调用方。
-
通信完成:一次RPC调用完成后,客户端和服务器之间的通信过程结束。
RPC结构

-
客户端模块代理所有远程方法的调用
-
将目标服务、目标方法、调用目标方法的参数等必要信息序列化
-
序列化之后的数据包进一步压缩,压缩后的数据包通过网络通信传输到目标服务节点
-
服务节点将接受到的数据包进行解压
-
解压后的数据包反序列化成目标服务、目标方法、目标方法的调用参数
-
通过服务端代理调用目标方法获取结果,结果同样需要序列化、压缩然后回传给客户端
手撸简陋版RPC
总觉得文字描述太干了,有点咽不下去,还是手撸下试试吧。毕竟艾瑞莉娅的奶奶总说:
纸上得来终觉浅,绝知此事要躬行。
知识点梳理
1.Socket套接字通信机制
在Java中,Socket是用于网络通信的基础类之一,它提供了一种机制,通过该机制,计算机程序可以在网络上建立连接、发送数据、接收数据和关闭连接。
-
服务器套接字(ServerSocket):
-
服务器套接字用于在服务器端监听并接受客户端的连接请求。
-
使用以下步骤创建和使用服务器套接字:
-
创建
ServerSocket对象,并绑定到一个特定的端口号。 -
使用
ServerSocket对象的accept()方法来等待客户端的连接请求,并接受连接。 -
一旦接受连接,可以创建新的
Socket对象来处理客户端的通信。 -
完成通信后,关闭
Socket和ServerSocket连接。
-
ServerSocket serverSocket = new ServerSocket(port_number); Socket clientSocket = serverSocket.accept(); // 等待连接 InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream(); // 使用输入流和输出流进行数据读写 clientSocket.close(); serverSocket.close();
-
-
客户端套接字(Socket):
-
客户端套接字用于连接到远程服务器,发送请求并接收响应。
-
使用以下步骤创建和使用客户端套接字:
-
创建
Socket对象,指定远程服务器的主机名或IP地址以及端口号。 -
使用
Socket对象的输入流和输出流来进行数据的读取和写入。 -
完成通信后,关闭
Socket连接。
-
Socket socket = new Socket("server_hostname", port_number); InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream(); // 使用输入流和输出流进行数据读写 socket.close(); -
2.通信过程的序列化与反序列化
在Java中通过 JDK 提供了 Java 对象的序列化方式实现对象序列化传输,主要通过输出流java.io.ObjectOutputStream和输入流java.io.ObjectInputStream来实现;
java.io.ObjectOutputStream:表示对象输出流 , 它的 writeObject(Object obj)方法可以对参数指定的 obj 对象进行序列化,把得到的字节序列写到一个目标输出流中;
java.io.ObjectInputStream:表示对象输入流 ,它的 readObject()方法源输入流中读取字节序列,再把它们反序列化成为一个对象,并将其返回;
3.动态代理
动态代理是在运行时动态生成代理类的方式。Java提供了java.lang.reflect.Proxy类和java.lang.reflect.InvocationHandler接口来实现动态代理。JDK中提供了基于接口动态代理的方法:
// 创建代理对象MyInterface proxyObject = (MyInterface) Proxy.newProxyInstance(MyInterface.class.getClassLoader(),new Class[]{MyInterface.class},new MyInvocationHandler(realObject));
使用动态代理的目的:
-
透明远程调用: 动态代理可以隐藏底层的远程调用细节,使得远程过程调用看起来像本地方法调用一样简单。客户端无需关心网络通信、数据序列化和反序列化等细节,因为这些都由代理对象处理。
-
减少重复性代码: 使用动态代理可以减少编写和维护远程调用代码的工作量。代理类负责处理通用的远程调用逻辑,开发者只需关注具体的业务逻辑。
-
集中管理远程调用逻辑: 动态代理将远程调用逻辑集中在一个地方,这样可以更容易地管理和维护,例如添加统一的错误处理、日志记录或性能监控等功能。
4.反射
在RPC(Remote Procedure Call,远程过程调用)中使用反射的主要目的是实现透明的远程调用,即使在客户端和服务器之间存在远程分离,也可以像调用本地方法一样调用远程服务。反射在RPC中的具体目的和用途如下:
-
动态代理生成代理对象: 反射机制允许在运行时生成代理对象,这些代理对象可以代替实际的远程服务对象执行方法调用。这样,客户端代码不需要提前知道要调用的具体远程方法和对象,而可以动态生成代理并执行方法。
-
动态识别方法和参数: 反射允许在运行时识别远程方法和方法参数的名称、类型和数量。这对于将方法调用信息打包成请求并传递给远程服务器非常有用,服务器可以根据这些信息正确地解析请求并调用相应的方法。
-
动态序列化和反序列化: 反射可以用于动态地序列化请求和响应数据。在RPC中,请求和响应数据通常需要以某种格式进行序列化和反序列化,以便在网络上进行传输。反射可以帮助动态识别数据类型,将数据转换为适当的格式,并在服务器端进行反序列化。
思维流程梳理

码起来
假设需求是客户端想要访问服务端(ip:prot/helloService/sayHello)上的helloService调用sayHello()。先定义服务端的实现。
服务端时序图

服务端—Api与Provider模块
服务端作为服务提供者,自然需要具备常规的业务模块:Api与Provider模块

Api模块定义简单的HelloService接口,包含两个方法sayHello(String content)和saveUSer(User user)
public interface IHelloService {String sayHello(String content);String saveUSer(User user);
}
定义一个简单对象User类
public class User {private String name;private int age;
// getter和setterpublic String getName() {return name;}
public void setName(String name) {this.name = name;}
public int getAge() {return age;}
public void setAge(int age) {this.age = age;}
@Overridepublic String toString() {return "User{" +"name='" + name + '\'' +", age=" + age +'}';}
}
Provider模块定义HelloService接口的实现类HelloServiceImpl(rpc-server-provider模块的pom中需要添加rpc-server-api模块的依赖)
public class HelloServiceImpl implements IHelloService {@Overridepublic String sayHello(String content) {System.out.println("request in sayHello:" + content);return "Say hello:" + content;}
@Overridepublic String saveUSer(User user) {System.out.println("request in saveUser:" + user);return "Save user success";}
}
定义RpcServerProxy通过代理的方式使用Socket对外暴露服务接口
public class RpcServerProxy {private ExecutorService executorService = Executors.newCachedThreadPool();
public void publisher(Object service, int port) {ServerSocket serverSocket = null;try {serverSocket = new ServerSocket(port);while (true) {Socket socket = serverSocket.accept(); // 使用accept()等待客户端连接,接收请求executorService.execute(new ProcessorHandler(socket, service)); // 每一个socket交给一个processorHandler处理}} catch (IOException e) {e.printStackTrace();} finally {// 关闭资源,jdk1.7后提供了try-with可以自动关闭if (serverSocket != null) {try {serverSocket.close();} catch (IOException e) {e.printStackTrace();}}}}
}
需要定义一个线程ProcessorHandler对每次的socket请求进行处理
public class ProcessorHandler implements Runnable{
private Socket socket;private Object service;
public ProcessorHandler(Socket socket, Object service) {this.socket = socket;this.service = service;}
@Overridepublic void run() {// 使用ObjectOutputStream和ObjectInputStream配合socket的输入流输出流进行序列化和反序列化ObjectOutputStream objectOutputStream = null;ObjectInputStream objectInputStream = null;try {objectInputStream = new ObjectInputStream(socket.getInputStream());
// 客户端传过来的信息:请求哪个类,哪个方法,方法的参数 ——> 封装为RpcRequest类RpcRequest rpcRequest = (RpcRequest) objectInputStream.readObject(); // 拿到客户端通信传递的请求类Object result = invoke(rpcRequest); // 反射调用本地服务
objectOutputStream = new ObjectOutputStream(socket.getOutputStream());objectOutputStream.writeObject(result);objectOutputStream.flush(); // 手动将缓冲区中的数据强制刷新到输出流中,以确保数据被立即写入底层的输出流。
} catch (IOException e) {e.printStackTrace();} catch (ClassNotFoundException e) {e.printStackTrace();} catch (InvocationTargetException e) {e.printStackTrace();} catch (NoSuchMethodException e) {e.printStackTrace();} catch (IllegalAccessException e) {e.printStackTrace();} finally {// 关闭关联资源,jdk1.7后提供了try-with可以自动关闭if (objectInputStream != null) {try {objectInputStream.close();} catch (IOException e) {throw new RuntimeException(e);}}if (objectOutputStream != null) {try {objectOutputStream.close();} catch (IOException e) {throw new RuntimeException(e);}}}}
private Object invoke(RpcRequest rpcRequest) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {// 反射调用Object[] args = rpcRequest.getParameters(); // 获取客户端传递的RpcRequest中的参数Class<?>[] types = new Class[args.length]; // 获得每个参数的类型for (int i = 0; i < args.length; i++) {types[i] = args.getClass();}Class clazz = Class.forName(rpcRequest.getClassName()); // 根据请求的类名反射加载类Method method = clazz.getMethod(rpcRequest.getMethodName(), types); // 获取类中的方法Object result = method.invoke(service, args); // 进行反射调用方法return result;}
}
定义一个通用的RpcRequest类参与通信过程中的请求处理,这里是需要序列化的
public class RpcRequest implements Serializable {private String className;private String methodName;private Object[] parameters;// getter and setterpublic String getClassName() {return className;}
public void setClassName(String className) {this.className = className;}
public String getMethodName() {return methodName;}
public void setMethodName(String methodName) {this.methodName = methodName;}
public Object[] getParameters() {return parameters;}
public void setParameters(Object[] parameters) {this.parameters = parameters;}
}
Provider的app中发布下服务,运行没有报错,服务端OK
public class App
{public static void main( String[] args ){IHelloService helloService = new HelloServiceImpl();RpcServerProxy rpcServerProxy = new RpcServerProxy();rpcServerProxy.publisher(helloService, 8080); // 把服务发布出去}
}

客户端时序图

定义动态代理RpcClientProxy,JDK的接口代理方式就是一句话:
public class RpcClientProxy {public <T> T clientProxy(final Class<T> interfaceCls, final String host, final int port) {return (T) Proxy.newProxyInstance(interfaceCls.getClassLoader(), new Class<?>[]{interfaceCls}, new RemoteInvocationHandler(host, port));}
}
定义被代理接口RemoteInvocationHandler
public class RemoteInvocationHandler implements InvocationHandler {private String host;private int port;
public RemoteInvocationHandler(String host, int port) {this.host = host;this.port = port;}
@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {// 客户端请求会进入这里进行包装RpcRequest rpcRequest = new RpcRequest();rpcRequest.setClassName(method.getDeclaringClass().getName());rpcRequest.setMethodName(method.getName());rpcRequest.setParameters(args);// 远程通信交给RpcNetTransportRpcNetTransport rpcNetTransport = new RpcNetTransport(host, port);Object result = rpcNetTransport.send(rpcRequest);return result;}
}
定义通信传输类RpcNetTransport
public class RpcNetTransport {private String host;private int port;
public RpcNetTransport(String host, int port) {this.host = host;this.port = port;}
public Object send(RpcRequest rpcRequest) {Socket socket = null;Object result = null;ObjectInputStream objectInputStream = null;ObjectOutputStream objectOutputStream = null;try {socket = new Socket(host, port); // 建立连接
objectOutputStream = new ObjectOutputStream(socket.getOutputStream()); // 客户端通信写入objectOutputStream.writeObject(rpcRequest);objectOutputStream.flush();
objectInputStream = new ObjectInputStream(socket.getInputStream()); // 客户端通信输出result = objectInputStream.readObject();return result;} catch (UnknownHostException e) {e.printStackTrace();} catch (IOException e) {e.printStackTrace();} catch (ClassNotFoundException e) {e.printStackTrace();} finally {// 关闭关联资源if (objectInputStream != null) {try {objectInputStream.close();} catch (IOException e) {throw new RuntimeException(e);}}if (objectOutputStream != null) {try {objectOutputStream.close();} catch (IOException e) {throw new RuntimeException(e);}}}return result;}
}
客户端请求远程服务
public class App
{public static void main( String[] args ){RpcClientProxy rpcClientProxy = new RpcClientProxy();IHelloService helloService = rpcClientProxy.clientProxy(IHelloService.class, "localhost", 8080);String result = helloService.sayHello("Elaine");System.out.println(result);}
}
请求成功,客户端获得返回结果

服务端打印了请求日志

到这里,一个简陋且粗糙的RPC通信版本就好了。
别灰心,要记住艾瑞莉娅的奶奶总说
路漫漫其修选兮,吾将上下而求索。
相关文章:
聊聊分布式架构04——RPC通信原理
目录 RPC通信的基本原理 RPC结构 手撸简陋版RPC 知识点梳理 1.Socket套接字通信机制 2.通信过程的序列化与反序列化 3.动态代理 4.反射 思维流程梳理 码起来 服务端时序图 服务端—Api与Provider模块 客户端时序图 RPC通信的基本原理 RPC(Remote Proc…...
维吉尼亚密码
维吉尼亚密码属于多表代换密码 其中A<–>0,B<–>1,…,Z<–>25,则每个密钥K相当于一个长度为m的字母串,称为密钥字。维吉尼亚密码一次加密m个明文字母。 示例:设m6,密钥字为…...
ubuntu20.04挂载拓展盘保姆级流程
背景:跑模型玩时,发现机子硬盘太小了,搞个1t固态作为挂载盘。以下为操作全流程记录 1、开始root权限操作 sudo su若进不去,考虑是否给root设置过密码,新系统第一次进入需要设置密码。 进入成功: rooty:…...
顶顶通电话机器人接口对接开源ASR(语音识别)
前景介绍 目前大部分用户使用的都是在线ASR按照分钟或者按次付费,之前开源ASR效果太差不具备商用的条件,随着 阿里达摩院发布了大量开源数据集或者海量工业数据训练的模型,识别效果已经和商用ASR差距非常小,完全具备了很多场景代…...
windows消息机制
windows开发比较简单,首先要理解的就是消息机制。 Windows消息机制是指Windows操作系统中的消息传递机制。在Windows中,应用程序通过消息进行通信和交互。消息是一种轻量级的通信方式,用于在不同的窗口、线程或进程之间传递信息。 在Windows…...
整数划分——DP
用 j j j 个数表示 i i i 的方案数,考虑dp 转移考虑最小值是否为1 无限制 若为1,则转移到 f ( i 1 , j 1 ) f(i1, j1) f(i1,j1)不为1,则全部1,转移到 f ( i j , j ) f(ij, j) f(ij,j) 数之间不能重复 那么相当于每次整…...
Git切换用户常用命令
1、查看 查看用户名 : git config user.name查看密码: git config user.password查看邮箱: git config user.email查看配置信息(包含上面的信息): $ git config --list2、新增、切换 修改用户名 git…...
一般香港服务器带宽选多大够用?(带宽计算方法)
在海外IDC市场份额中,香港服务器依托自身优越的服务器资源条件,在各个行业中发挥的重要作用。但是,不同业务对网络带宽的要求各不相同,弄清楚如何计算带宽需求对于确保业务平稳运行至关重要,最好从一开始就使用正…...
vue中使用ali-oss上传文件到阿里云上
1.使用 npm 安装ali-oss npm install ali-oss --save2.写ali-oss.js // 引入ali-oss let OSS require(ali-oss) let client new OSS({region: oss-cn-xxx, // bucket所在的区域, 默认oss-cn-hangzhousecure: true, // secure: 配合region使用,如果指…...
php实战案例记录(17)计算时间的函数及其示例说明
在PHP中,有许多函数可以用于计算和处理时间。以下是一些常用的计算时间的函数及其示例说明: time():获取当前时间的Unix时间戳。 $currentTimestamp time(); echo $currentTimestamp;date():将Unix时间戳格式化为指定的日期和时…...
基于Keil a51汇编 —— MPL 宏定义
MPL 宏 Ax51汇编程序支持的宏处理语言(MPL)是一种字符串替换工具,使您能够编写可修复的代码块(宏)并将其插入源文本中的一个或多个位置。 宏处理器查看源文件的方式与汇编程序不同。 对于汇编程序来说,源…...
Android 13 骁龙相机点击拍照流程分析(二)——点击拍照到存入相册
一.前言 本篇是在Android 13 骁龙相机点击拍照流程分析(一)——点击拍照到更新到左下角缩略图文章的基础上进行延申的,前面的预览、点击拍照的过程参考第一篇:Android 13 骁龙相机点击拍照流程分析(一)——点击拍照到更新到左下角缩略图-CSDN博客 二.生成图片并保存 从第…...
常见算法-巴斯卡三角形(Pascal)
常见算法-巴斯卡三角形(Pascal) 1、说明 巴斯卡(Pascal)三角形基本上就是在解 nCr,因为三角形上的每一个数字各对应一个nCr,其中 n 为 row,而 r 为 column,如下: 0C0 1…...
AI:09-基于深度学习的图像场景分类
图像场景分类是计算机视觉领域的重要任务之一,它涉及将图像分为不同的场景类别,如城市街景、山脉风景、海滩等。本文将介绍基于深度学习的图像场景分类方法,并提供相应的代码实例,展示了深度学习在图像场景分类中的技术深度和应用前景。 图像场景分类是计算机视觉中的一项…...
uni-app:引入echarts(使用renderjs)
效果 代码 <template><view click"echarts.onClick" :prop"option" :change:prop"echarts.updateEcharts" id"echarts" class"echarts"></view> </template><script>export default {data()…...
使用wireshark解析ipsec esp包
Ipsec esp包就是ipsec通过ike协议协商好后建立的通信隧道使用的加密包,该加密包里面就是用户的数据,比如通过的语音等。 那么如何将抓出来的esp包解析出来看呢? 获取相关的esp的key信息. 打开wireshark -> edit->preferences 找到pr…...
linux如何删除最近操作的日志
Linux系统下,不同的应用程序可能会生成不同的日志文件。如果你想要删除最近一段时间内的操作日志,可以使用一些命令来完成。 以下是一些常见的命令: dmesg:该命令用于显示内核环境下的运行信息和警告信息。如果你需要清空dmesg缓…...
android端MifareClassicTool
github地址 GitHub - ikarus23/MifareClassicTool: An Android NFC app for reading, writing, analyzing, etc. MIFARE Classic RFID tags. 开源项目 下载: https://www.icaria.de/mct/releases/ 功能 1、读取Mifare Classic卡片 2、编辑并保存卡片的数据 3、写入Mifare C…...
设计模式 - 迭代器模式
目录 一. 前言 二. 实现 三. 优缺点 一. 前言 迭代器模式是一种行为型设计模式,它提供了一种统一的方式来访问集合对象中的元素,而不暴露集合内部的表示方式。简单地说,就是将遍历集合的责任封装到一个单独的对象中,我们可以按…...
Docker之Dockerfile搭建lnmp
目录 一、搭建nginx 编辑 二、搭建Mysql(简略版) 三、搭建PHP 五、补充 主机名ip地址主要软件mysql2192.168.11.22Docker 代码示例 systemctl stop firewalld systemctl disable firewalld setenforce 0docker network create --subnet172.18.…...
ESP32 I2S音频总线学习笔记(四): INMP441采集音频并实时播放
简介 前面两期文章我们介绍了I2S的读取和写入,一个是通过INMP441麦克风模块采集音频,一个是通过PCM5102A模块播放音频,那如果我们将两者结合起来,将麦克风采集到的音频通过PCM5102A播放,是不是就可以做一个扩音器了呢…...
HBuilderX安装(uni-app和小程序开发)
下载HBuilderX 访问官方网站:https://www.dcloud.io/hbuilderx.html 根据您的操作系统选择合适版本: Windows版(推荐下载标准版) Windows系统安装步骤 运行安装程序: 双击下载的.exe安装文件 如果出现安全提示&…...
linux 下常用变更-8
1、删除普通用户 查询用户初始UID和GIDls -l /home/ ###家目录中查看UID cat /etc/group ###此文件查看GID删除用户1.编辑文件 /etc/passwd 找到对应的行,YW343:x:0:0::/home/YW343:/bin/bash 2.将标红的位置修改为用户对应初始UID和GID: YW3…...
相机Camera日志分析之三十一:高通Camx HAL十种流程基础分析关键字汇总(后续持续更新中)
【关注我,后续持续新增专题博文,谢谢!!!】 上一篇我们讲了:有对最普通的场景进行各个日志注释讲解,但相机场景太多,日志差异也巨大。后面将展示各种场景下的日志。 通过notepad++打开场景下的日志,通过下列分类关键字搜索,即可清晰的分析不同场景的相机运行流程差异…...
vue3+vite项目中使用.env文件环境变量方法
vue3vite项目中使用.env文件环境变量方法 .env文件作用命名规则常用的配置项示例使用方法注意事项在vite.config.js文件中读取环境变量方法 .env文件作用 .env 文件用于定义环境变量,这些变量可以在项目中通过 import.meta.env 进行访问。Vite 会自动加载这些环境变…...
大学生职业发展与就业创业指导教学评价
这里是引用 作为软工2203/2204班的学生,我们非常感谢您在《大学生职业发展与就业创业指导》课程中的悉心教导。这门课程对我们即将面临实习和就业的工科学生来说至关重要,而您认真负责的教学态度,让课程的每一部分都充满了实用价值。 尤其让我…...
有限自动机到正规文法转换器v1.0
1 项目简介 这是一个功能强大的有限自动机(Finite Automaton, FA)到正规文法(Regular Grammar)转换器,它配备了一个直观且完整的图形用户界面,使用户能够轻松地进行操作和观察。该程序基于编译原理中的经典…...
【C++特殊工具与技术】优化内存分配(一):C++中的内存分配
目录 一、C 内存的基本概念 1.1 内存的物理与逻辑结构 1.2 C 程序的内存区域划分 二、栈内存分配 2.1 栈内存的特点 2.2 栈内存分配示例 三、堆内存分配 3.1 new和delete操作符 4.2 内存泄漏与悬空指针问题 4.3 new和delete的重载 四、智能指针…...
Redis:现代应用开发的高效内存数据存储利器
一、Redis的起源与发展 Redis最初由意大利程序员Salvatore Sanfilippo在2009年开发,其初衷是为了满足他自己的一个项目需求,即需要一个高性能的键值存储系统来解决传统数据库在高并发场景下的性能瓶颈。随着项目的开源,Redis凭借其简单易用、…...
PHP 8.5 即将发布:管道操作符、强力调试
前不久,PHP宣布了即将在 2025 年 11 月 20 日 正式发布的 PHP 8.5!作为 PHP 语言的又一次重要迭代,PHP 8.5 承诺带来一系列旨在提升代码可读性、健壮性以及开发者效率的改进。而更令人兴奋的是,借助强大的本地开发环境 ServBay&am…...
