Dubbo教程-04-生撸一个RPC

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_31922571/article/details/84891141

为什么要生撸?

这个问题就好比
我自己已经有老婆了
为什么还要自己做饭 ?
好像这个例子 举的不是很恰当
主要是 为了 理解下 RPC 的一个具体编程模型
和他实现的一些细节
其实就是一个编程模型的 理解 和 实践 过程

基于netty框架

我们之前 学过netty框架的一个 编程模型
server client
基于事件驱动的模式。
上一个例子我们 把 数据传递到服务器
然后 服务器给我们返回数据
中间通过 netty的网络连接 实现打通
那么我们就会想 是否可以 把 传递过去的数据
变成一个抽象,
然后 服务器端的 数据获取 及处理 编程 具体的 实现类
客户端的 模拟调用
变成面向接口 ?
简单画了个图片给大家理解下

说白了就是 客户端 现在 要利用 service 接口
动态生成代理对象
而动态代理的实现细节中加入 和 网络交互
把 具体的代码实现放到了远程服务器。
远程服务器把结果通过网络返回给客户端
客户端再交由代理对象返回
于是我们感知到的就是
真的返回

看起来很你牛逼的样子
不错确实很牛逼

代码给我看看

Service.java

package cn.bywind.rpc.v_one;

public interface Service {

    String sayHelloWithName(String name);
}

这个就是一个服务接口咯
没啥可说的
他接收一个参数
然后返回一个字符串。

ServiceProvider.java

package cn.bywind.rpc.v_one;

public class ServiceProvider implements Service {
    @Override
    public String sayHelloWithName(String name) {
        return "hello "+name;
    }
}

这个是 service 的具体实现
没啥可说的
就是拿到了 入参 然后 稍微处理下
加了个hello 然后返回了
我们这里只是模拟下,不要以为我不会写代码啊

ServiceServer.java

package cn.bywind.rpc.v_one;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class ServiceServer {

    private int port = 0;

    public ServiceServer(int port) {
        this.port = port;
    }

    NioEventLoopGroup boss = new NioEventLoopGroup();
    NioEventLoopGroup worker = new NioEventLoopGroup();
    ServerBootstrap bootstrap = new ServerBootstrap();
    public void run(){
        System.out.println("running on port :"+port);
        try {
            bootstrap.group(boss,worker);
            bootstrap.channel(NioServerSocketChannel.class);
            bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline p = ch.pipeline();
                    p.addLast(new StringDecoder());
                    p.addLast(new StringEncoder());
                    p.addLast(new ServiceProviderHandler());
                }
            });

            ChannelFuture channelFuture = bootstrap.bind("127.0.0.1", port).sync();
            channelFuture.channel().closeFuture().sync();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            worker.shutdownGracefully();
            boss.shutdownGracefully();
        }
    }



    public static void main(String[] args) {
        ServiceServer serviceServer = new ServiceServer(9999);
        serviceServer.run();
    }


}

这个就是一个编程模型
netty作为网络服务器
这个类就是一个 server端的代码
万年不变的哦
都是这么写的
大家可以在网上找到很多类似代码
不过我这个是 参照 netty官网 规范写的啊
大家可以借鉴下
server端 我们定有两个 NioEventLoopGroup
一个是boss 一个是 worker
boss 接收请求
worker 处理请求
别的没啥可说的了 照着写

ServiceProviderHandler.java

package cn.bywind.rpc.v_one;

import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;

public class ServiceProviderHandler extends ChannelHandlerAdapter {

    private static final Service SERVICE = new ServiceProvider();


    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        String s = msg.toString();
        System.out.println("get str from client:"+s);
        ctx.writeAndFlush(SERVICE.sayHelloWithName(s));
    }
}

这个类正式 如他所说的 handler 或者是 adapter
其实 netty也看到了这个 地方的不妥
所以他们现在都叫 adapter了
你可以去看最新的 netty代码 之前 这部分 是 handler 现在都叫 adapter
这个地方就是 让 具体实现 接收网络传输数据
然后 自己 逻辑处理下
然后 通过网络传出 结果数据
这个地方 我们称为 adapter

ServiceClient.java

package cn.bywind.rpc.v_one;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ServiceClient {

    private ServiceConsumerHandler handler ;
    private static ExecutorService executor = Executors
            .newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public Object createProxy(final Class<?> serviceClass){

        Object o = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{serviceClass}, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                if(handler == null){
                    startClient();
                }
                handler.setParams(args[0].toString());
                return executor.submit(handler).get();
            }
        });
        return o;
    }


    public void startClient(){
        handler = new ServiceConsumerHandler();
        try {
            NioEventLoopGroup worker = new NioEventLoopGroup();
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(worker);
            bootstrap.channel(NioSocketChannel.class).option(ChannelOption.TCP_NODELAY,true);
            bootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline p = ch.pipeline();
                    p.addLast(new StringDecoder());
                    p.addLast(new StringEncoder());
                    p.addLast(handler);
                }
            });
            bootstrap.connect("127.0.0.1", 9999).sync();
        }catch (Exception e){
            e.printStackTrace();
        }


    }


}

这个类就是一个netty的客户端
给他一个 server的IP 端口 让他建立连接
连接以后 他就需要 动态代理了
在动态代理的过程中
我们调用 网络通信
然后得到 server给我们的结果
然后返回给 代理对象

ServiceConsumerHandler.java

package cn.bywind.rpc.v_one;

import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;

import java.util.concurrent.Callable;

public class ServiceConsumerHandler extends ChannelHandlerAdapter implements Callable{

    private ChannelHandlerContext context;
    private String result;
    private String params;

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
       context = ctx;

    }

    @Override
    public synchronized void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        result = msg.toString();
        notify();
    }

    @Override
    public synchronized Object call() throws Exception {
        context.writeAndFlush(params);
        wait();
        return result;
    }


    public String getParams() {
        return params;
    }

    public void setParams(String params) {
        this.params = params;
    }
}

跟上面的那个 ServiceProviderHandler 类似
都是处理 netty 网络数据
他主要实现两个功能
1. 拿到入参 向netty中写数据
2. 拿到服务端返回值 返回给程序
我们让这个类实现 callable 接口
可以被线程池调用
因为我们会模拟多个客户端的情形
我们需要我们的 数据获取 和 数据写入
是线程安全的
这块的代码大家可以 详细看下

ServiceConsumer.java

package cn.bywind.rpc.v_one;

public class ServiceConsumer {

    public static void main(String[] args) throws Exception{
        ServiceClient client = new ServiceClient();
        Service proxy = (Service) client.createProxy(Service.class);
        int i = 0;
        for (;;){
            Thread.sleep(1000);
            String result = proxy.sayHelloWithName("bywind"+i);
            System.out.println("from rpc server:"+result);
            i++;
        }

    }
}

这个类就是一个客户端吧
具体的也没啥可说的

运行我们的程序

imagepng

首先我们运行我们的服务器端代码

imagepng

接下来我们运行我们的客户端代码

imagepng

多线程的哦, 并且我们启动两个客户端
我们正好看下 服务器是否会返回错乱,或者活 客户端是否会错乱
准确的说是 客户端是否会错乱
因为我们的客户端 实际处理代码是 一个线程模型的
imagepng

我们加了 线程安全的模型哦

好的现在我们看下效果

imagepng

获取服务端的数据完全没有问题
紧接着我么启动第二个客户端

imagepng

我们发现他的序号也是从 0 开始
这就实现了不同线程中间互补干扰

以上就是我们 1.0 版本的 一个 远程RPC调用
仿佛不是很过瘾
这种只能给一个 service 服务了
我们需要的是程序的通用性
可以适配更多
并且在传递 参数 和返回参数的时候
我们不想只有 String 了
我们需要 Object 这样 就更完美了
这也就是 框架的意义所在的

从1.0 升级到 2.0

还是先贴代码吧

GoodByeService.java

package cn.bywind.rpc.v_two;

public interface GoodByeService {

    String sayGoodbye(Person person);
}

GoodByeServiceImpl.java

package cn.bywind.rpc.v_two;

public class GoodByeServiceImpl implements GoodByeService {
    @Override
    public String sayGoodbye(Person person) {
        return "GoodBye :"+person;
    }
}

HelloService.java

package cn.bywind.rpc.v_two;

public interface HelloService {

    String sayHelloWithName(String name);
}

HelloServiceImpl.java

package cn.bywind.rpc.v_two;

public class HelloServiceImpl implements HelloService {
    @Override
    public String sayHelloWithName(String name) {
        return "hello "+name;
    }
}

Person.java

package cn.bywind.rpc.v_two;

public class Person {

    private String name;
    private Integer age;

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

RPCDecoder.java

package cn.bywind.rpc.v_two;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;

import java.util.List;

public class RPCDecoder extends ByteToMessageDecoder {
    private Class<?> genericClass;

    public RPCDecoder(Class<?> genericClass) {
        this.genericClass = genericClass;
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        final int length = in.readableBytes();
        final byte[] bytes = new byte[length];
        in.readBytes(bytes, 0, length);
        Object obj = SerializationUtil.deserialize(bytes, genericClass);
        out.add(obj);
    }

}

RPCEncoder.java

package cn.bywind.rpc.v_two;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class RPCEncoder extends MessageToByteEncoder {

    private Class<?> genericClass;

    public RPCEncoder(Class<?> genericClass) {
        this.genericClass = genericClass;
    }

    @Override
    public void encode(ChannelHandlerContext ctx, Object in, ByteBuf out) throws Exception {
        if (genericClass.isInstance(in)) {
            byte[] data = SerializationUtil.serialize(in);
            out.writeBytes(data);
        }
    }
}

RpcRequest.java

package cn.bywind.rpc.v_two;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.Arrays;

public class RpcRequest implements Serializable {
    private String className;
    private String methodName;
    private Class<?>[] parameterTypes;
    private Object [] args;
    private String requestId;


    public 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 Class<?>[] getParameterTypes() {
        return parameterTypes;
    }

    public void setParameterTypes(Class<?>[] parameterTypes) {
        this.parameterTypes = parameterTypes;
    }

    public Object[] getArgs() {
        return args;
    }

    public void setArgs(Object[] args) {
        this.args = args;
    }

    public String getRequestId() {
        return requestId;
    }

    public void setRequestId(String requestId) {
        this.requestId = requestId;
    }

    @Override
    public String toString() {
        return "RpcRequest{" +
                "className='" + className + '\'' +
                ", methodName='" + methodName + '\'' +
                ", parameterTypes=" + Arrays.toString(parameterTypes) +
                ", args=" + Arrays.toString(args) +
                ", requestId='" + requestId + '\'' +
                '}';
    }
}

RpcResponse.java

package cn.bywind.rpc.v_two;

import java.io.Serializable;

public class RpcResponse implements Serializable {
    private String requestId;
    private Object result;

    public RpcResponse(){

    }
    public RpcResponse(String requestId, Object result) {
        this.requestId = requestId;
        this.result = result;
    }

    public String getRequestId() {
        return requestId;
    }

    public void setRequestId(String requestId) {
        this.requestId = requestId;
    }

    public Object getResult() {
        return result;
    }

    public void setResult(Object result) {
        this.result = result;
    }
}

SerializationUtil.java

package cn.bywind.rpc.v_two;

import io.protostuff.LinkedBuffer;
import io.protostuff.ProtostuffIOUtil;
import io.protostuff.Schema;
import io.protostuff.runtime.RuntimeSchema;
import org.objenesis.Objenesis;
import org.objenesis.ObjenesisStd;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class SerializationUtil {

    private static Map<Class<?>, Schema<?>> cachedSchema = new ConcurrentHashMap<Class<?>, Schema<?>>();

    private static Objenesis objenesis = new ObjenesisStd(true);

    private SerializationUtil() {
    }

    @SuppressWarnings("unchecked")
    private static <T> Schema<T> getSchema(Class<T> cls) {
        Schema<T> schema = (Schema<T>) cachedSchema.get(cls);
        if (schema == null) {
            schema = RuntimeSchema.createFrom(cls);
            if (schema != null) {
                cachedSchema.put(cls, schema);
            }
        }
        return schema;
    }

    @SuppressWarnings("unchecked")
    public static <T> byte[] serialize(T obj) {
        Class<T> cls = (Class<T>) obj.getClass();
        LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
        try {
            Schema<T> schema = getSchema(cls);
            return ProtostuffIOUtil.toByteArray(obj, schema, buffer);
        } catch (Exception e) {
            throw new IllegalStateException(e.getMessage(), e);
        } finally {
            buffer.clear();
        }
    }

    public static <T> T deserialize(byte[] data, Class<T> cls) {
        try {
            T message = (T) objenesis.newInstance(cls);
            Schema<T> schema = getSchema(cls);
            ProtostuffIOUtil.mergeFrom(data, message, schema);
            return message;
        } catch (Exception e) {
            throw new IllegalStateException(e.getMessage(), e);
        }
    }
}

ServiceClient.java

package cn.bywind.rpc.v_two;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ServiceClient {

    private ServiceConsumerHandler handler ;
    private static ExecutorService executor = Executors
            .newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public Object createProxy(final Class<?> serviceClass){

        Object o = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{serviceClass}, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                if(handler == null){
                    startClient();
                }
                RpcRequest request = new RpcRequest(); // 创建并初始化 RPC 请求
                request.setRequestId(UUID.randomUUID().toString());
                request.setClassName(method.getDeclaringClass().getName());
                request.setMethodName(method.getName());
                request.setParameterTypes(method.getParameterTypes());
                request.setArgs(args);
                handler.setParams(request);
                return executor.submit(handler).get();
            }
        });
        return o;
    }


    public void startClient(){
        handler = new ServiceConsumerHandler();
        try {
            NioEventLoopGroup worker = new NioEventLoopGroup();
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(worker);
            bootstrap.channel(NioSocketChannel.class).option(ChannelOption.TCP_NODELAY,true);
            bootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline p = ch.pipeline();
                    p.addLast(new RPCEncoder(RpcRequest.class));
                    p.addLast(new RPCDecoder(RpcResponse.class));
                    p.addLast(handler);
                }
            });
            bootstrap.connect("127.0.0.1", 9999).sync();
        }catch (Exception e){
            e.printStackTrace();
        }


    }


}

ServiceConsumer.java

package cn.bywind.rpc.v_two;


public class ServiceConsumer {

    public static void main(String[] args) throws Exception{
        ServiceClient client = new ServiceClient();
        HelloService helloService = (HelloService) client.createProxy(HelloService.class);
        String bywind = helloService.sayHelloWithName("bywind");
        System.out.println("helloService :"+bywind);

        GoodByeService goodByeService = (GoodByeService) client.createProxy(GoodByeService.class);
        String bywind1 = goodByeService.sayGoodbye(new Person("bywind", 28));
        System.out.println("goodByeService : "+bywind1);
    }
}

ServiceConsumerHandler.java

package cn.bywind.rpc.v_two;

import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;

import java.util.concurrent.Callable;

public class ServiceConsumerHandler extends ChannelHandlerAdapter implements Callable{

    private ChannelHandlerContext context;
    private RpcResponse rpcResponse;
    private Object result;
    private RpcRequest params;

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
       context = ctx;

    }

    @Override
    public synchronized void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        rpcResponse = (RpcResponse) msg;
        result = rpcResponse.getResult();
        notify();
    }

    @Override
    public synchronized Object call() throws Exception {
        context.writeAndFlush(params);
        wait();
        return result;
    }


    public RpcRequest getParams() {
        return params;
    }

    public void setParams(RpcRequest params) {
        this.params = params;
    }
}

ServiceProviderHandler.java

package cn.bywind.rpc.v_two;

import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
import net.sf.cglib.reflect.FastClass;
import net.sf.cglib.reflect.FastMethod;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;

public class ServiceProviderHandler extends ChannelHandlerAdapter {

    private static final HashMap<String,Object> SERVICE = new HashMap<String, Object>();

    static {
        SERVICE.put(GoodByeService.class.getName(),new GoodByeServiceImpl());
        SERVICE.put(HelloService.class.getName(),new HelloServiceImpl());
    }


    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("get obj from client:"+msg);
        RpcRequest request = (RpcRequest) msg;
        Object result = handle(request);
        RpcResponse response = new RpcResponse();
        response.setRequestId(request.getRequestId());
        response.setResult(result);
        ctx.writeAndFlush(response);
    }

    public Object handle(RpcRequest request){
        String className = request.getClassName();
        Object object = SERVICE.get(className);
        Class<?>[] parameterTypes = request.getParameterTypes();
        String methodName = request.getMethodName();
        Object[] args = request.getArgs();

        Class<?> targetClass = object.getClass();

        FastClass fastClass = FastClass.create(targetClass);
        FastMethod method = fastClass.getMethod(methodName, parameterTypes);
        Object invoke = null;
        try {
            invoke = method.invoke(object, args);
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        return invoke;
    }
}

ServiceServer.java

package cn.bywind.rpc.v_two;


import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.serialization.ClassResolver;
import io.netty.handler.codec.serialization.ObjectDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class ServiceServer {

    private int port = 0;

    public ServiceServer(int port) {
        this.port = port;
    }

    NioEventLoopGroup boss = new NioEventLoopGroup();
    NioEventLoopGroup worker = new NioEventLoopGroup();
    ServerBootstrap bootstrap = new ServerBootstrap();
    public void run(){
        System.out.println("running on port :"+port);
        try {
            bootstrap.group(boss,worker);
            bootstrap.channel(NioServerSocketChannel.class);
            bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline p = ch.pipeline();
                    p.addLast(new RPCDecoder(RpcRequest.class));
                    p.addLast(new RPCEncoder(RpcResponse.class));
                    p.addLast(new ServiceProviderHandler());
                }
            });

            ChannelFuture channelFuture = bootstrap.bind("127.0.0.1", port).sync();
            channelFuture.channel().closeFuture().sync();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            worker.shutdownGracefully();
            boss.shutdownGracefully();
        }
    }



    public static void main(String[] args) {
        ServiceServer serviceServer = new ServiceServer(9999);
        serviceServer.run();
    }


}

我们来按下第二版的 运行效果吧
还是一样的 运行 服务端
imagepng

然后
运行我们的 client
imagepng

我们看下结果

客户端结果 输出
imagepng

服务端接收参数 输出:
imagepng

大家如果想要自己实际运行下代码的化
可以去看我的github https://github.com/ibywind/dubbo-learn

希望大家可以先看一遍 代码 然后把 动作记下来
自己去实际 生撸 一个 RPC框架

我学到了什么

说句实在话
这个东西网上例子很多的
我之所以 想自己尝试着写下代码
主要为了 防止自己 被 频繁的 拷贝 粘贴
变成老年痴呆

首先分析 原理
这个东西 就是个 代理模式
另外 熟悉了 netty的编程模型

其实做起来还是很简单的.
这个代码我上传到 github https://github.com/ibywind/dubbo-learn
大家可以下载下来继续学习和指正哦

猜你喜欢

转载自blog.csdn.net/qq_31922571/article/details/84891141