对于现代人来说,代理模式非常好理解,毕竟天朝网民不知道网络代理的估计是少数,事实上《Head First 设计模式》中这一章节的大部分内容都在介绍一些代理模式的应用和变种,代理模式本身的描述篇幅相对不多。
这里我会遵循原书的结构,并且因为原书介绍的相关代理模式应用都是Java语言直接相关的例子,并不能用Python示例代替,所以这里也全部采用原书的Java示例进行说明。
就像前面所说的,代理模式的概念并不复杂,所以一开始我就阐述一下代理模式的概念。
代理模式
简单地说,代理模式就是真正的服务对象提供一种替代品,让这个替代品来代替真是的对象处理所有的客户端请求。
我们之前所提到的网络代理就是这种东西,通过在电脑或者浏览器上设置网络代理,原本直接对目标web服务器的http请求就会转发给代理服务器,经由代理服务器转发,而web服务器的返回信息也是经由代理服务器转发回我们的计算机,这就是一个经典的代理案例。
当然,除了网络代理,代理模式可以实现多种用途,但其核心都是对真正的服务对象的一种“代理”,即对客户端和真正服务对象进行解耦,在中间起一个桥梁和访问控制的作用。而通过不同的控制实现细节,就可以实现不同用途的代理模式,这点在之后我们会用大量篇幅举例说明。
代理模式的UML很简单,因为代理类Proxy
必须要能代替真正的服务RealSubject
对外提供服务,所以两者必然具有相同的接口Subject
。原本客户端程序Client
需要直接调用RealSubject
,但现在所有对RealSubject
的调用都通过Proxy
,因为现在Proxy
代理了所有对RealSubject
的请求。
此外,一般来说代理类都会持有一个其代理的目标对象的引用,就像UML中的Proxy
持有的subject
,这是为了在必要时候直接调用目标对象的相应方法,甚至在有些情况下目标对象也是由代理类直接创建的,这点在之后会看到相关示例。
下面我们以Java中的远程代理为例说明代理模式的应用。
远程代理
Java中的远程代理是RMI机制(Remote Mehod Invocation 远程方法调用)的一部分,即通过远程代理,我们可以像使用本地JVM中的对象那样使用远程服务器上的JVM的对象。
其UML表示如下:
这其中RealSubjectStub
(目标桩)和RealSubjectSkeleton
(目标骨架)是通过RMI组件自动生成的,不需要用户编写。目标桩除了可以在本地作为目标服务的代理,还肩负着连接本地和远程服务器的用途,因为客户端程序对目标桩的所有调用,都必须通过网络连接传递到服务器,然后由服务器上的RealSubject
进行响应,然后再将结果传回。
需要说明的是,RealSubjectStub
是和Client
一起部署在客户端电脑的,而RealSubjectSkeleton
和RealSubject
是部署在服务端的(事实上服务端也会部署一份RealSubjectStub
,用于注册到远程对象列表,这点之后会说明)。
这就涉及到将Java对象通过网络传递的问题。我们知道,Java中是可以将对象序列化后存储或者通过网络传递的,所以理论上我们是可以编写服务端和客户端程序来传递可序列化的Java对象的,而这里的“桩”和“骨架”就是起到这样的作用,它们可以“自动”地序列化和反序列化地传递对象作为调用参数或者返回值。
这其中地调用步骤是这样的:
-
Client
调用RealSubjectStub
的某个方法,RealSubjectStub
序列化该方法的参数后将调用的方法名和参数通过网络传递给RealSubjectSkeleton
。 -
RealSubjectSkeleton
将接收到的参数进行反序列化,然后调用RealSubject
的相应方法并传入参数。 -
RealSubject
方法响应后将返回值返回给RealSubjectSkeleton
,RealSubjectSkeleton
进行序列化,并通过网络传递给RealSubjectStub
。 -
RealSubjectStub
接收到RealSubjectSkeleton
通过网络传递的返回值,进行反序列化,然后传递给Client
。 -
Client
接收到返回值,完成对远程代理RealSubjectStub
的一次调用。
实现
在说明如何实现具体代码之前,有几个我遇到的坑需要说明:
-
我本来是想使用树莓派作为服务器进行部署测试的,结果在调试时候提示“代码中包含JDK16的最新特性,无法运行”,然后我搜索了一下JDK的安装包,尝试给树莓派安装JDK16,结果发现OpenJDK16刚完成发布没多久,只提供了Linux/X64版本,而我的树莓派上装的是32位的Dibian系统,So...我尽力了。所以下面的实现和调试都是在我的笔记本上完成的,感兴趣的童鞋可以自行折腾。OpenJDK的Release页面是。
-
因为这里需要在客户端和服务端(因为上面的原因这里都是我的笔记本)分别运行Java程序,方便起见最好单独打一个jar包用于执行,所以需要单独使用IDE创建一个项目,不再像之前的示例那样直接在IDE下进行调试。如果你和我一样使用的是VSCode的话,可以使用JAVA PROJECT工具:
下面我们来看具体如何实现。
-
创建Java项目并创建相关必要的包路径,代码结构可以参考我的。
-
创建一个用于约束远程服务的统一接口,这个接口必须要继承
Remote
接口,以表示用于RMI机制。我这里设计了一个用于远程切换电视节目的接口,假设目标服务器会提供一个切换电视节目的服务,切换结束后会返回
Boolean
告诉客户端是否成功。此外这里接口的常量RMI_NAME
是用来注册远程代理时候作为名称使用的。package xyz.icexmoon.remote_proxy2; import java.rmi.Remote; import java.rmi.RemoteException; public interface RaspbianTVInter extends Remote { public static final String RMI_NAME = "RaspbianTV"; public Boolean chooseVhannel(String channelName) throws RemoteException; }
-
创建服务端的可以远程执行的类,这个类必须实现刚创建的接口,此外我们需要利用这个类来实现前面说的远程代理所需的桩和骨架,最方便的方式是将这个类直接继承
UnicastRemoteObject
,利用继承的构造函数即可动态生成对应的桩和骨架组件,因为桩和骨架会用于远程代理时的网络通信,所以这里继承的构造函数需要抛出一个RemoteException
异常。chooseVhannel
中的代码是具体的切换电视节目的逻辑。runServer
负责提供给入口文件,实现将远程代理进行注册并起到一个类似web服务的作用,会等待客户端程序通过代理桩进行远程调用。在这个架构中桩和骨架都遵循同一个接口,所以这里进行注册的时候registry.rebind(RaspbianTVInter.RMI_NAME, raspbianTV);
,rebind
的参数除了名称以外,是一个RaspbianTVInter
类型的对象。package xyz.icexmoon.remote_proxy2.server; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.rmi.server.UnicastRemoteObject; import xyz.icexmoon.remote_proxy2.RaspbianTVInter; public class RaspbianTV extends UnicastRemoteObject implements RaspbianTVInter { protected RaspbianTV() throws RemoteException { super(); } public Boolean chooseVhannel(String channelName) { System.out.println("switch to " + channelName + "channel"); return false; } public static void runServer(){ try { RaspbianTVInter raspbianTV = new RaspbianTV(); Registry registry = LocateRegistry.createRegistry(2002); registry.rebind(RaspbianTVInter.RMI_NAME, raspbianTV); System.out.println("server is ready"); } catch (RemoteException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static void main(String[] args) { RaspbianTV.runServer(); } }
-
编写客户端代码,客户端代码相对简单,获取注册机后通过注册机查找远程代理,然后通过远程代理可以像使用本地代码那样进行调用。需要注意的是因为是远程调用,所以需要使用
try/catch
捕获相关异常。package xyz.icexmoon.remote_proxy2.client; import java.rmi.NotBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import xyz.icexmoon.remote_proxy2.RaspbianTVInter; public class Client { public static void runClient(String channelName, String remoteIP) { try { System.out.println("choose TV channel " + channelName + " on remote TV " + remoteIP); Registry registry = LocateRegistry.getRegistry(remoteIP, 2002); RaspbianTVInter raspbianTVInter = (RaspbianTVInter) registry.lookup(RaspbianTVInter.RMI_NAME); boolean result = raspbianTVInter.chooseVhannel(channelName); if (result) { System.out.println("switch channel success"); } } catch (RemoteException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (NotBoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static void main(String[] args) { if (args.length <= 0) { System.out.println("need channel name"); return; } String channelName = args[0]; String remoteIP = "192.168.1.1"; Client.runClient(channelName, remoteIP); } }
-
编写项目的入口文件。为了方便起见,这里通过命令行参数来确定执行服务端程序还是客户端程序,否则需要打两个Jar包。
package xyz.icexmoon.remote_proxy2; import xyz.icexmoon.remote_proxy2.client.Client; import xyz.icexmoon.remote_proxy2.server.RaspbianTV; public class Main { public static void main(String[] args) { if (args.length == 0) { System.out.println("Need input run mode(client/servere)."); return; } String runMode = args[0]; switch (runMode) { case "client": if (args.length < 3) { System.out.println("You need input 3 parameters: mode,ip,channel_name"); return; } String remoteIP = args[1]; String ChannelName = args[2]; Client.runClient(ChannelName, remoteIP); break; case "server": RaspbianTV.main(args); break; default: System.out.println("The run mode must be client or server."); return; } } }
-
创建jar包,我这里使用的是VSC中的JAVA PROJECT工具,生成的时候指定Main.java作为入口文件即可。
整个项目的完整代码见,我创建的jar包是,可以下载并执行我的jar包。
测试
因为之前已经说过的原因,这里在同一台电脑上测试客户端和服务端代码。
先打开一个cmd执行服务端程序。
java -jar --enable-preview remote_proxy2.jar server
不知道什么原因,我使用power shell执行java时候打印的报错信息都是乱码,但是本身打印其它UTF-8编写的程序输出的中文都是没问题的,只能猜测java抽风使用的是非UTF-8中文编码,弄了半天没弄好我放弃了,改用cmd。
添加参数
--enable-preview
可以输出相关提示,如果程序有错误的话。如果出现
(class file version 60.65535) was compiled with preview features
的字样,大概率是你的Java版本过低,请安装OpenJDK16,或者使用自己当前的JDK尝试重写我的整个示例代码,以避开某些JDK16的新特性(我现在也没搞清楚是哪些,我只是个Java门外汉)。
如果一切正常,会显示server is ready
的字样,并且不会有光标等待输入,这表明服务进程已经正常执行了。
现在可以再打开一个cmd执行客户端程序了。
java -jar --enable-preview remote_proxy2.jar client 127.0.0.1 CCTV10
需要注意的事项和服务端程序是一样的,如果执行成功会显示choose TV channel CCTV10 on remote TV 127.0.0.1
。
并且此时切换到服务端进程所在的cmd会发现出现switch to CCTV10channel
的信息,表示客户端确实调用了服务端的相关代码。
好了,远程代理的介绍告一段落,对RMI机制有兴趣的可以自行前往Java官方文档进行学习,我们下面介绍另一种代理:虚拟代理。
《Head First 设计模式》这本书的确挺老的,对于出现类似的问题我有所预料,但是没想到的确挺折腾人的,希望下面的部分不要太过折腾吧。
虚拟代理
虚拟代理的概念本身也很好理解,就是其会充当一个需要消耗一定资源才能创建的实体的临时替身,即在真正的服务实体创建完成前,会由虚拟代理来承担其所有的服务工作,在这期间所有对实体的请求都会由代理来响应,并且会“假装”自己就是目标实体,而真正的服务实体一旦完成创建,虚拟代理就会将相关响应委托给实体,只起一个“桥接”的作用,“不再参与”响应。这种代理的特点是,被委托的响应实体一般是由委托类所创建的。
下面我们看一个具体示例。
我们的目标程序是一个使用swing框架的图形化界面,长这样:
最上方是一个菜单栏(JMenuBar
),菜单栏包含一个菜单(JMenu
)images,images下包含5个菜单项(JMenuItem),每个菜单项都有自己的名字,比如image1
之类的,点击每个菜单项都会将下方的图片控件(ImageComponent)中的图片替换为相应的图片。
如果不使用虚拟代理,可能每次加载图片前我们都需要将图片控件先设置为默认图片,然后尝试从网络下载图片,如果获取到图片后再替换图片,但如果我们使用了虚拟代理,这一切都可以交给代理去处理,在没有获取到图片的时候,代理会告诉图片控件如何显示一个等待内容,一旦图片获取到了,代理就会让控件重新绘制,以展示完整的图片内容。
下面我们看着是如何做到的:
先创建一个图片控件,这是为了方便在JFrame
中管理图片。
package xyz.icexmoon.virtual_proxy;
import java.awt.Graphics;
import javax.swing.Icon;
import javax.swing.JComponent;
public class ImageComponent extends JComponent {
private Icon icon;
public ImageComponent(Icon icon) {
this.icon = icon;
}
public void setIcon(Icon icon) {
this.icon = icon;
}
protected void paintComponent(Graphics g) {
super.paintComponent(g);
int width = icon.getIconWidth();
int height = icon.getIconHeight();
int x = (ImageProxy.DEFAULT_WIDTH - width) / 2;
int y = (ImageProxy.DEFAULT_HEIGHT - height) / 2;
icon.paintIcon(this, g, x, y);
}
}
创建图片的虚拟代理,因为虚拟代理要表现的和图片一样,所以很自然地实现了Icon
接口:
package xyz.icexmoon.virtual_proxy;
import java.awt.Component;
import java.awt.Graphics;
import java.net.URL;
import javax.swing.Icon;
import javax.swing.ImageIcon;
public class ImageProxy implements Icon {
private ImageIcon imageIcon;
private boolean initImage = false;
private URL imageURL;
public static final int DEFAULT_WIDTH = 800;
public static final int DEFAULT_HEIGHT = 600;
public ImageProxy(URL imageURL) {
this.imageURL = imageURL;
}
public int getIconHeight() {
if (imageIcon == null) {
return DEFAULT_HEIGHT;
} else {
return imageIcon.getIconHeight();
}
}
public int getIconWidth() {
if (imageIcon == null) {
return DEFAULT_WIDTH;
} else {
return imageIcon.getIconWidth();
}
}
public void paintIcon(Component c, Graphics g, int x, int y) {
if (imageIcon != null) {
imageIcon.paintIcon(c, g, x, y);
} else {
g.drawString("Loading image, please wait...", x + 300, y + 190);
if (!initImage) {
initImage = true;
Thread initImageThread = new Thread(new Runnable() {
public void run() {
try {
imageIcon = new ImageIcon(imageURL, "image");
c.repaint();
} catch (Exception e) {
e.printStackTrace();
}
}
});
initImageThread.start();
}
}
}
}
虚拟代理相当简单,如果没有加载好图片,就显示一段文字,如果加载好了,就显示图片。
需要注意的是,加载图片需要占用一段时间,所以需要新开一个线程执行,以避免堵塞UI线程。
UI入口文件:
package xyz.icexmoon.virtual_proxy;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Enumeration;
import java.util.Hashtable;
import javax.swing.JFrame;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
public class Main {
private JFrame frame;
private ImageComponent imageComponent;
public static void main(String[] args) {
Main main = new Main();
main.initJFrame();
}
private void initJFrame(){
Hashtable<String,String> urls = new Hashtable<String,String>();
urls.put("image1", "https://icexmoon-public-1305291391.cos.ap-shanghai.myqcloud.com/image/5fa398772c60be4020d1436289df4f91f1dd7d58_5fa398774f632bb5ae534ec09829cb6c0e631f47.jpg");
urls.put("image2", "https://icexmoon-public-1305291391.cos.ap-shanghai.myqcloud.com/image/5fb0ceb56b992a683cb84457b0261a2d5f2cbf73_5fb0ceb5210ca7cc093a4f9ba36fd5c4ce63f688.jpg");
urls.put("image3", "https://icexmoon-public-1305291391.cos.ap-shanghai.myqcloud.com/image/5fb2655575eaa787d2d443fb991df7ba62f4f1a7_5fb2655561092198c80549bf9b7f493bc9efd81e.jpg");
urls.put("image4", "https://icexmoon-public-1305291391.cos.ap-shanghai.myqcloud.com/image/5fb27ed1a64404709dda47d38357afbfd786589f_5fb27ed1429793c8cdf64720819132e873b84396.jpg");
urls.put("image5", "https://icexmoon-public-1305291391.cos.ap-shanghai.myqcloud.com/image/5fb3cea357bd79a6e33e4f429aa35ef0c6843fe4_5fb3cea3d7b9059638444161a5a847480bddbd6f.jpg");
frame = new JFrame("Web Image Viewer");
JMenuBar menubar = new JMenuBar();
JMenu menu = new JMenu("images");
menubar.add(menu);
frame.setJMenuBar(menubar);
URL defaultImageURL = null;
try {
defaultImageURL = new URL(urls.get("image1"));
} catch (MalformedURLException e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}
imageComponent = new ImageComponent(new ImageProxy(defaultImageURL));
frame.getContentPane().add(imageComponent);
for (Enumeration<String> e = urls.keys();e.hasMoreElements();) {
String name = e.nextElement();
URL url = getURLbyName(urls, name);
JMenuItem menuItem = new JMenuItem(name);
menu.add(menuItem);
menuItem.addActionListener(new ActionListener(){
@Override
public void actionPerformed(ActionEvent e) {
imageComponent.setIcon(new ImageProxy(url));
frame.repaint();
}
});
}
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(ImageProxy.DEFAULT_WIDTH, ImageProxy.DEFAULT_HEIGHT);
frame.setVisible(true);
}
public static URL getURLbyName(Hashtable<String, String> urls, String name) {
URL url = null;
try {
url = new URL(urls.get(name));
} catch (MalformedURLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return url;
}
}
代码很多,但大部分都是组织菜单和UI控件的代码,关键的代码在监听器中使用imageComponent.setIcon(new ImageProxy(url));
来实现图片切换,我们只需要像使用普通的Icon
那样使用虚拟代理ImageProxy
即可。
完整代码见。
保护代理
保护代理的用途是对已有的类型进行访问控制,通过使用这个代理,我们可以实现特定的访问控制逻辑。
这有点像Python中的属性描述符,或者说属性描述符本身就是一种保护代理。
关于Python中的属性描述符可以阅读。
Java中保护代理的UML结构是这样的:
其中Proxy
是在Java运行时使用Java组件进行动态创建的,它的对外表现会和接口Subject
一致。内建接口InvocationHandler
起到一个钩子的用途,即自动创建的Proxy
只能“呆板”地“扮演”Subject
,并不知道如何处理具体的相关调用,这时候我们就需要创建一个类,并实现InvocationHandler
接口,去处理真正的代理逻辑,自然地,为了代理RealSubject
,需要持有一个RealSubject
引用。
下面我们看具体实现,这里使用一个社交系统作为示例,假设这个系统中的个人资料具有下面的接口:
package xyz.icexmoon.protect_proxy;
public interface PersonBean {
public int getRating();
public String getName();
public String getGender();
public void setName(String name);
public void setGender(String gender);
public void setRating(int rating);
}
这些方法包括:
-
getRating
,获取评价 -
getName
,获取姓名 -
getGender
,获取性别 -
setName
,设置姓名 -
setGender
,设置性别 -
setRating
,设置评分
具体类的实现为:
package xyz.icexmoon.protect_proxy;
public class PersonBeanImpl implements PersonBean {
private String name;
private String gender;
private int rating = 0;
private int ratingCount = 0;
@Override
public int getRating() {
if (ratingCount == 0) {
return 0;
}
return rating / ratingCount;
}
@Override
public String getName() {
return this.name;
}
@Override
public String getGender() {
return this.gender;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public void setGender(String gender) {
this.gender = gender;
}
@Override
public void setRating(int rating) {
this.rating += rating;
this.ratingCount++;
}
}
这里有一个隐含逻辑,即在我们的系统中,自己应当是不能给自己打分的,其它操作都可以。别人可以给自己打分,但别人不能修改自己的个人资料。
当然这种逻辑有很多方式可以实现,并且一般都不会用到保护代理,但我们这里作为展示,使用保护代理来实现这种访问控制逻辑。
我们这里需要两个保护代理,一个用于自己访问自己,另一个用于别人访问自己。
我们之前说过了,保护代理主要使用Java的标准组件创建,而我们的任务主要是创建InvocationHandler
的实现类:
package xyz.icexmoon.protect_proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class OwnInvocationHander implements InvocationHandler{
private PersonBean personBean;
public OwnInvocationHander(PersonBean personBean) {
this.personBean = personBean;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("setRating")){
throw new IllegalAccessError();
}
else{
return method.invoke(personBean, args);
}
}
}
package xyz.icexmoon.protect_proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class OtherInvocationHander implements InvocationHandler {
private PersonBean personBean;
public OtherInvocationHander(PersonBean personBean) {
this.personBean = personBean;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(method.getName().equals("setRating")){
return method.invoke(personBean, args);
}
else if(method.getName().startsWith("get")){
return method.invoke(personBean, args);
}
else{
throw new IllegalAccessError();
}
}
}
InvocationHandler
的实现类并不困难,invoke
方法中的method
对象表示代理被调用的方法,而我们可以通过这个方法的方法名等属性进行区分处理,完成保护控制逻辑。对于不希望的请求我们可以使用throw new IllegalAccessError()
抛出异常,对于允许的访问,我们可以使用method.invoke(personBean, args)
的方式转发给被代理的真正处理响应的对象。可以看到,这种将方法作为参数传入并进行处理的逻辑颇有Python的风格。事实上保护代理使用的都是Java中的反射模块(reflect)。
在使用保护代理的时候我们需要动态创建代理,为了方便起见这里创建一个代理的简单工厂:
package xyz.icexmoon.protect_proxy;
import java.lang.reflect.Proxy;
public class PersonProxyFactory {
public static PersonBean getOwnPersonProxy(PersonBean personBean) {
ClassLoader loader = personBean.getClass().getClassLoader();
Class<?>[] interfaces = personBean.getClass().getInterfaces();
return (PersonBean) Proxy.newProxyInstance(loader, interfaces, new OwnInvocationHander(personBean));
}
public static PersonBean getOtherPersonProxy(PersonBean personBean){
ClassLoader loader = personBean.getClass().getClassLoader();
Class<?>[] interfaces = personBean.getClass().getInterfaces();
return (PersonBean) Proxy.newProxyInstance(loader, interfaces, new OtherInvocationHander(personBean));
}
}
最后测试一下:
import xyz.icexmoon.protect_proxy.PersonBean;
import xyz.icexmoon.protect_proxy.PersonBeanImpl;
import xyz.icexmoon.protect_proxy.PersonProxyFactory;
public class App {
public static void main(String[] args) throws Exception {
System.out.println("Hello, World!");
PersonBeanImpl Lilei = new PersonBeanImpl();
PersonBean LileiSelf = PersonProxyFactory.getOwnPersonProxy(Lilei);
LileiSelf.setName("Li lei");
LileiSelf.setGender("female");
System.out.println(LileiSelf.getName());
System.out.println(LileiSelf.getGender());
// LileiSelf.setRating(10);
PersonBean LileiOther = PersonProxyFactory.getOtherPersonProxy(Lilei);
System.out.println(LileiOther.getName());
System.out.println(LileiOther.getGender());
LileiOther.setRating(10);
System.out.println(LileiOther.getRating());
System.out.println(LileiSelf.getRating());
// LileiOther.setName("HanMeimei");
}
}
完整代码见。
注释部分的代码会触发异常,这其中LileiSelf
用于李磊自己对自己的请求,LileiOther
用于别人对李磊的请求。
最后再次强调,在这个示例中,使用保护代理难以编写和理解,仅用于演示,类似的功能有很多更容易实现和理解的方式。
好了,虽然代理还有很多类型,但精力有限,就介绍到这里了,谢谢阅读。
参考资料:
文章评论