手写一个RPC框架,我(小白)行,你(大佬)肯定行
最近在看八股文的时候看到一个场景题:你会如何设计一个RPC框架?
他的解答是:RPC框架的基本作用是远程调用,调用的过程就是网络通信,通信就需要通信协议吧,通信的过程中参数如何传递,就需要序列化技术了吧,我们在使用RPC的时候肯定需要注册中心吧,那么就需要服务发现服务调用吧,分布式微服务中肯定不止一个服务提供者,如何挑选其一,就需要负载均衡了吧,如何优雅的发起请求让调用方像本地调用一样,就需要动态代理了吧,当我们把问题一个个拆解出来之后,每一个不就是我们熟悉的八股文了嘛
对啊,这不就是八股文吗,我相信绝大部分人肯定都背过这些八股文,但是,八股文背会了就真的能写出来一个RPC框架吗,于是,理论存在,实践开始!
首先远程调用,总得有一个服务提供者和服务调用者吧,于是:
因为服务提供者和服务调用者都需要依赖CalculateService接口,于是创建一个公共的Common类来存放这个接口,里面就两个简单的方法
至此预备工作就绪
接下来就是如何让他们进行远程调用,也就是RPC框架的编写
首先得回忆一下我们是怎么使用RPC框架的,拿Dubbo举例,我们会先在服务的调用方所需要的服务上面加上一个@DubboReference注解,在服务的提供方的接口实现类上面加上@DubboService注解,最后在两边的配置文件里加上注册中心的信息,然后就可以调用了
我们可以画出他具体的调用流程图
接下来梳理整体的大需求:
-
我们需要创建一个rpc框架包
-
使用了@Reference注解的接口,我们需要让他调用远程方法
-
使用了@Service注解的类,我们需要将他注册到注册中心
-
实现一个简易的注册中心,向外提供注册服务和获取服务信息的功能
一步一步来解决
首先得把大需求整理成小需求
—使用了@Reference注解的接口,我们需要让他调用远程方法,如下
public class Consumer {
@AdagioReference
private CalculateService calculateService;
public static void main(String[] args) {
Consumer consumer = new Consumer();
Object test = consumer.test();
System.out.println(test);
}
public Object test(){
return calculateService.add(1,2);
}
}
-
我们需要有一个@Reference,那么注解里需要什么属性呢,那就要看这个注解到底需要实现点什么功能
-
我们需要根据注解里的信息找到具体的服务提供方,最基本的,服务的名字,也可以有服务的版本
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface AdagioReference { String version() default "1.0"; String name() default ""; //后续可以拓展其他属性,比如负载均衡策略,序列化策略,容错策略等等 }
-
rpc框架需要根据这个注解找到哪些接口是要远程调用的
问题就是怎么找到,我们可以回想一下Spring里面的@ComponentScan注解,我们可以在该注解内传递一个扫描根路径,然后Spring会自动帮助我们去逐个扫描文件里面的类,类上面的属性、方法,那么有了这些,我们得知哪个接口有注解就很轻松了
于是我们可以写一个类似于@ComponentScan的工具类:
public class ClassUtils { public static String rootPath = new File("").getAbsolutePath(); public static String classPath = Thread.currentThread().getContextClassLoader().getResource("").getPath(); public static List<Class<?>> getClasses(File file,List<Class<?>> classList) throws ClassNotFoundException { if(file.isDirectory()){ File[] files = file.listFiles(); if(files != null){ for(File f:files){ getClasses(f,classList); } } }else { String name = file.getName(); if(name.endsWith(".class")){ String classname = file.getPath().replace(".class","").replace(classPath.replace("/","\\").substring(1),"").replace("\\","."); Class<?> clazz = Class.forName(classname); classList.add(clazz); } } return classList; } public static List<Class<?>> getClasses() throws ClassNotFoundException{ return getClasses(new File(classPath),new ArrayList<>()); } public static List<Class<?>> getClasses(String path) throws ClassNotFoundException{ return getClasses(new File(path),new ArrayList<>()); } }
在上述工具类中我们可以指定扫描路径,去把该路径下的所有类扫描出来,之后我们只要根据需要去遍历这些类,找到里面带注解属性或方法,对其进行处理就行,此处我们是需要对所有加了@AdagioReference注解的属性,让他调用方法的时候,都是去调用远程方法
那么现在的问题就是,我们怎么样让这个属性去调用远程方法而不调用自己的方法,还有一个点,我们的属性都还没有初始化,执行方法肯定会报空指针。当我们用Dubbo的时候是不是完全不用考虑这种问题,因为框架都已经帮我们处理好了。那么现在我们作为框架的开发者,就需要我们自己来处理这些问题。
第一个问题:我们怎么样让这个属性去调用远程方法而不调用自己的方法,很显然这可以用动态代理来实现,并且我们都是对接口进行的代理,所以直接用JDK动态代理即可,但是不排除有人就喜欢用实现类来代理,这里就有一个拓展点,我们可以使用一个代理工厂,判断需要代理的到底是类还是接口,Dubbo内部就是使用这种方法(我自己还没做完这个拓展点,就不放上来了)
public class JdkDynamicProxy extends AbstractDynamicProxy{ public JdkDynamicProxy(Class<?> interfaceClass) { super(interfaceClass); } @Override public Object getProxy() { return Proxy.newProxyInstance( interfaceClass.getClassLoader(), new Class<?>[]{interfaceClass}, (proxy,method,args)->{ //增强逻辑 } ); } }
第二个问题:属性没有被初始化,Spring中两个重要思想之一的IOC就是用来解决这个问题的,但我们没有Spring,就手动的写一个简单Bean容器凑合一下
public class BeanContainer { public static final ConcurrentHashMap<String,Object> BEAN_CONTAINER = new ConcurrentHashMap<>(); public static Object getBean(String name){ return BEAN_CONTAINER.get(name); } static { //初始化方法,去添加Bean } }
现在整体的思路就非常的明确了,Consumer引入rpc框架使用@AdagioReference注解,当Consumer启动时,会自动扫描找到所有的带@AdagioReference的属性,给他们创建动态代理然后放到Bean容器里去。
那么我们就先编写BeanContainer的初始化方法
static { try { //上述的工具类 List<Class<?>> classes = ClassUtils.getClasses(); //遍历类对象里的属性对象,找到带注解的属性进行代理创建 for (Class<?> clazz: classList) { Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { AdagioReference annotation = field.getAnnotation(AdagioReference.class); //创建代理对象 String serverName = "".equals(annotation.name())?field.getName(): annotation.name(); //todo 判断到底要以什么接口创建代理,这里就可以尝试用代理工厂了 JdkDynamicProxy jdkDynamicProxy = new JdkDynamicProxy(field.getType()); Object proxy = jdkDynamicProxy.getProxy(); map.put(serverName,proxy); } } } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) { throw new RuntimeException(e); } }
上述方法有一个缺点就是:他是懒加载的,所以当我们第一次执行rpc调用的话会比较慢,所以此处可以参考Spring来解决(主要是我自己都还没看过Spring怎么解决的TAT)
接下来就可以编写代理对象的增强方法了,我们的目标是让他去调用远程的方法,可以按照以下步骤走
- 根据注解里的信息向注册中心发起请求得到服务地址
- 获取到服务地址就可以发起请求
那么就逐一编写
—根据注解里的信息向注册中心发起请求得到服务地址
注解里的信息?我们刚刚似乎只找到了注解所在位置,但没有去获取注解里面的信息吧,所以我们还得提前把信息保存下来,这个步骤可以在BeanContainer的初始化里写,因为他们都循环遍历了所有的类对象(不建议,还是得让他们各司其职),我写了一个工具类来实现注解信息导入
/** * 用来缓存服务信息 */ @Data public class RpcCache { public static final ConcurrentHashMap<String,RpcConfig> map; static { map = new ConcurrentHashMap<>(); try { List<Class<?>> classList = ClassUtils.getClasses(); //工具类,用于执行注解信息导入 AnnotationUtils.rpcConfigExecute(map,classList); }catch (ClassNotFoundException e) { throw new RuntimeException(e); } } }
RpcConfig就是用于承载单个服务的注解里面的信息,和@AdagioReference里的属性对应就行,我在这里加了一个序列化格式,用于后面选定序列化方法
/** * 服务信息 */ @Data public class RpcConfig { /** * 服务名称 */ private String name; /** * 服务版本 */ private String version; /** * 服务序列化格式 */ private String serializeType; }
此外我们还缺少注册中心,这个放在后面写,先假设我们有一个帮我们发请求的工具包
最后就是获取到目标地址该发请求了,告诉他我们需要调用哪一个接口的哪一个方法。哪一个接口?这直接传一个接口的名字就可以了;哪一个方法,这直接传一个名字可以吗?我们知道在java里是有方法重载的,如何区分重载的方法,是根据参数类型来区分的,那么是不是还需要传递一个参数类型的列表。这样我们就可以确定下来具体调用的是哪个接口的哪个方法了,但是我们有了方法没有参数还是不能调用的,所以还需要一个参数列表,综上,我们得到了调用方法的基本信息,在Dubbo中,这种信息有一个实体类来承载,叫Invoker,所以我们也定义一个自己的Invoker
/** * 承载方法调用的信息的对象 */ @Data @AllArgsConstructor @NoArgsConstructor public class Invoker implements Serializable { private static final long serialVersionUID = -1L; private String interfaceName; private String methodName; private Class<?>[] paramsTypes; private Object[] params; }
有了可以发送的信息之后,我们是不是得让我们的rpc框架去帮助我们发送请求,那么发请求该怎么发,又有很多种选择,我们可以使用原生的URL,也可以使用Netty框架(Dubbo底层用的就是Netty),因为是手写rpc所以我们就简单一点,就发普通的http请求就行了(没错,主要是因为我不会Netty…)
这里我就用HttpClient来发请求了
发请求,发什么,怎么发,发给谁
发什么,我们上面已经知道了,要调用接口方法就发它对应的Invoker就行了
怎么发,我们传输的是一个对象,网络传输对象肯定需要将对象序列化,我们可以选择jdk自带的序列化,也可以用其他开源的更好的序列化工具,我这里就选用Dubbo底层也使用的Hessian2
这里同样使用工厂模式打造一个序列化工厂,目前仅包含一个Hessain序列化器
/** * 序列化器工厂 */ public class SerializerFactory { private static final Map<String, AbstractSerializer> SERIALIZER_MAP = new HashMap<>(); public static AbstractSerializer getSerializer(String name){ AbstractSerializer serializer = SERIALIZER_MAP.get(name); if (serializer == null) { serializer = createSerializer(name); SERIALIZER_MAP.put(name,serializer); } return serializer; } private static AbstractSerializer createSerializer(String name){ if("Hessian".equals(name)){ return new HessianSerializer(); } throw new IllegalArgumentException("不含有对应的序列化器"); } }
public class HessianSerializer implements AbstractSerializer { @Override public <T> byte[] serialize(T obj) { byte[] result; ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Hessian2Output hessian2Output = new Hessian2Output(byteArrayOutputStream); try { hessian2Output.startMessage(); hessian2Output.writeObject(obj); hessian2Output.flush(); hessian2Output.completeMessage(); result = byteArrayOutputStream.toByteArray(); return result; }catch (IOException e) { e.printStackTrace(); }finally { try { hessian2Output.close(); byteArrayOutputStream.close(); } catch (IOException e) { e.printStackTrace(); } } return new byte[0]; } @Override public <T> T deserialize(byte[] bytes) { T result; ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Hessian2Input hessian2Input = new Hessian2Input(byteArrayInputStream); try { hessian2Input.startMessage(); result = (T) hessian2Input.readObject(); hessian2Input.completeMessage(); return result; } catch (IOException e) { e.printStackTrace(); }finally { try { hessian2Input.close(); byteArrayInputStream.close(); } catch (IOException e) { e.printStackTrace(); } } return null; } }
发给谁,肯定发给已经获取到地址的某一个服务了
至此,所有消费端发请求的步骤准备好了
接下来就是实现发请求功能
(proxy,method,args)->{ //Invoker创建 String name = interfaceClass.getSimpleName(); Invoker invoker = new Invoker(); RpcConfig rpcConfig = RpcCache.map.get(自己定义的key); invoker.setMethodName(method.getName()); invoker.setParams(args); invoker.setSerializeType(rpcConfig.getSerializeType()); invoker.setInterfaceName(name); invoker.setParamsTypes(method.getParameterTypes()); //去注册中心里获取服务信息 //此处的Register为自己编写的一个注册中心的starter RegisterClient registerClient = new RegisterClient("localhost","8081"); RegisterData registerData = registerClient.getRegisterData(rpcConfig.getName(),rpcConfig.getVersion()); //发送请求 //可以做负载均衡,目前还没实现 Set<String> paths = registerData.getPath(); String path = new ArrayList<>(paths).get(0); //自定义的发请求工具类 HttpClient client = new HttpClient(); return client.doPost(path, invoker); }
以下是client的doPost方法
public Object doPost(String path,Invoker invoker){ CloseableHttpClient client = HttpClients.createDefault(); //基本信息配置 //todo path不够优雅 HttpPost httpPost = new HttpPost("http://"+path); httpPost.setHeader("serializeType",invoker.getSerializeType()); //序列化工厂获取对应序列化器 AbstractSerializer serializer = SerializerFactory.getSerializer(invoker.getSerializeType()); //序列化invoker byte[] bytes = serializer.serialize(invoker); httpPost.setEntity(new ByteArrayEntity(bytes)); try { //发送请求 HttpEntity entity = client.execute(httpPost).getEntity(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); entity.writeTo(outputStream); byte[] byteArray = outputStream.toByteArray(); return serializer.deserialize(byteArray); } catch (IOException e) { e.printStackTrace(); }finally { try { client.close(); } catch (IOException e) { e.printStackTrace(); } } return null; }
至此,消费者端的全部流程走完
-
-
使用了@AdagioService注解的类,我们需要将他注册到注册中心
首先这个注解里需要有什么方法,那肯定和@AdagioReference里面几乎是对应的了,消费者方需要根据服务名称和版本号来查找到服务,所以服务方肯定得定义自己的名称和版本号,所以这两个是最基本的
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface AdagioService { String name() default ""; String version() default "1.0"; }
如何查找并注册呢,这个就和@AdagioReference的查找和注册异曲同工了,这里就不放代码了,注册到注册中心也调用了自己编写的一个starter,发起一个请求到注册中心就行
作为服务的提供者,肯定需要能接受请求吧,那么如何接受请求呢,可以用netty(如果我会的话),这里还是用Tomcat吧
public class HttpServer { public void start(String hostname,Integer port){ Tomcat tomcat = new Tomcat(); Server server = tomcat.getServer(); Service service = server.findService("Tomcat"); Connector connector = new Connector(); connector.setPort(port); StandardEngine engine = new StandardEngine(); engine.setDefaultHost(hostname); StandardHost host = new StandardHost(); host.setName(hostname); String contextPath = ""; StandardContext context = new StandardContext(); context.setPath(contextPath); context.addLifecycleListener(new Tomcat.FixContextListener()); host.addChild(context); engine.addChild(host); service.setContainer(engine); service.addConnector(connector); tomcat.addServlet(contextPath,"dispatcher",new DispatcherServlet()); context.addServletMappingDecoded("/*","dispatcher"); try { //用于实现@AdagioService的类遍历及class对象缓存 List<Class<?>> classList = ClassUtils.getClasses(); AnnotationUtils.serverExecute(classList); tomcat.start(); tomcat.getServer().await(); }catch (LifecycleException | ClassNotFoundException e){ e.printStackTrace(); } } }
以上代码启动了一个tomcat,并且配置了一个dispatcherServlet进行消息转发
如下是dispatcherServlet代码
public class DispatcherServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { new HttpServerHandler().handler(req,resp); } }
其中HttpServerHandler才是最终处理请求的部分,才是重点
分析一下,我们接收到请求应该做什么
第一,我们接收到的是一个Invoker对象,肯定得先反序列化
第二,Invoker对象里包含了接口名,方法名,方法参数,参数类型,所以得用反射调用
所以,如下就是HttpServerHandler的代码
public class HttpServerHandler extends AbstractHandler{ @Override public void handler(HttpServletRequest req, HttpServletResponse resp) { try { //todo 目前为可选的序列化方式,后续可添加自定义序列化方式 //获取输入流并把输入流内容读到byte数组 ByteArrayOutputStream bos = new ByteArrayOutputStream(); IOUtils.copy(req.getInputStream(),bos); byte[] bytes = bos.toByteArray(); String serializeType = req.getHeader("serializeType"); AbstractSerializer serializer = SerializerFactory.getSerializer(serializeType); //反序列化 Invoker invoker = serializer.deserialize(bytes); String interfaceName = invoker.getInterfaceName(); Object[] params = invoker.getParams(); String methodName = invoker.getMethodName(); Class<?>[] types = invoker.getParamsTypes(); //反射调用 Class<?> specificClass = LocalRegister.getSpecificClass(interfaceName); Method method = specificClass.getMethod(methodName, types); Object obj = method.invoke(specificClass.newInstance(), params); //写入response中 IOUtils.write(serializer.serialize(obj), resp.getOutputStream()); } catch (IOException | NoSuchMethodException | InvocationTargetException | IllegalAccessException | InstantiationException e) { throw new RuntimeException(e); } } }
至此,服务调用端和服务提供段的代码均处理完毕,注册中心的代码就不放上来了,写的有点烂TAT
那么就来调用试下吧
-
启动注册中心
-
启动服务提供者
@AdagioService(name = "CalculateService") public class CalculateServiceImpl implements CalculateService { @Override public Integer add(Integer a, Integer b) { return a+b; } @Override public Integer minus(Integer a, Integer b) { return a-b; } }
public class Provider { public static void main(String[] args) { HttpServer server = new HttpServer(); server.start("localhost",8080); } }
-
启动服务消费者
public class Consumer { @AdagioReference private CalculateService calculateService = (CalculateService) BeanContainer.getBean("calculateService"); public static void main(String[] args) { Consumer consumer = new Consumer(); Object test = consumer.test(); System.out.println("运行结果为:"+test); } public Object test(){ return calculateService.add(1,2); } }
这里因为没有实现Bean的自动注入,所以就手动取Bean了
我们此时调用的是add方法,结果应该返回3
最终我们实现了远程调用
-
做个小小的总结,写了一个简易的rpc框架,其实我感觉一点也不简单,要考虑的点非常的多,真的需要全局的思维,并且还要给后续开发留下余地,让新功能的接入不影响老功能,有时考虑的太多反而不知道如何下手了,左右为难。
从看到这篇八股文,到我写出一个能实现基本功能的小框架,也花了一个多月的时间,收获肯定有,最起码的是我对Dubbo的执行过程更加的清楚了。还有写代码,打通思路很重要,在写的时候我经常会写着写着就不知道在写点什么东西了,包括类与类之间的调用,接下来该去写什么,都会突然间迷糊住,这次写一篇总结,也算把整体的流程梳理了一遍,印象应该很深刻了
然后再总结一下这里面还要修改的点和可以提高的点,还蛮多的
1.收发请求还是得用netty
2.动态代理工厂还没实现
3.负载均衡策略,可以用个策略模式
4.注册中心太不完善
5.可以添加服务的心跳检测
6.日志处理不够好,还没百分百还原类似于本地调用的感觉
7.还得手动注入Bean
8.有些值还是写死在代码里的,得动态获取
9.有些代码还是太臃肿,可以考虑复用
10…很多很多值得修改