手写SpringMVC框架的简单实现

大家好,今天抽出一点时间方便复习一下SpringMVC,并写一个简易的Demo让大家明白SpringMVC是怎么通过代码去实现的

主要是为了巩固知识!

首先,SpringMVC简单来说就是一个前端控制器,通过RequestMapping和Controller注解去完成一个控制层的编写,再和我们的DAO层和Service层进行交互完成页面的展示和数据的持久化

首先说一下SpringMVC的运行流程

  1. 向服务器发送HTTP请求,请求被前端控制器 DispatcherServlet 捕获。
  2. DispatcherServlet 根据 <servlet-name>-servlet.xml 中的配置对请求的URL进行解析,得到请求资源标识符(URI)。 然后根据该URI,调用 HandlerMapping 获得该Handler配置的所有相关的对象(包括Handler对象以及Handler对象对应的拦截器),最后以 HandlerExecutionChain 对象的形式返回。
  3. DispatcherServlet 根据获得的Handler,选择一个合适的 HandlerAdapter。(附注:如果成功获得HandlerAdapter后,此时将开始执行拦截器的preHandler()方法)。
  4. 提取Request中的模型数据,填充Handler入参,开始执行HandlerController) 在填充Handler的入参过程中,根据你的配置,Spring将帮你做一些额外的工作:

            HttpMessageConveter: 将请求消息(如Json、xml等数据)转换成一个对象,将对象转换为指定的响应信息。

            数据转换:对请求消息进行数据转换。如String转换成Integer、Double等。

            数据根式化:对请求消息进行数据格式化。 如将字符串转换成格式化数字或格式化日期等。 

            数据验证: 验证数据的有效性(长度、格式等),验证结果存储到BindingResult或Error中。

           5.Handler(Controller)执行完成后,向 DispatcherServlet 返回一个 ModelAndView 对象;

           6.根据返回的ModelAndView,选择一个适合的 ViewResolver(必须是已经注册到Spring容器中的ViewResolver)返回给 DispatcherServlet。

           7.ViewResolver 结合Model和View,来渲染视图。

           8.视图负责将渲染结果返回给客户端。

今天我们手写的Demo就是一个简单的MVC框架,即有我们的DAO 和 service 和Controller和完成我们方法和路径的映射

(requestmapping和方法的映射),并通过反射执行contoller里面的方法进行页面的渲染返回

既然框架里面有我们的DAO 和Service层和Controller,为了方便我们的开发,我们肯定会用到我们的注解,还有必不可少的IOC(控制反转)和DI(依赖注入) 因为我们这个Demo需要用到IOC 和DI,我们就对IOC 和 DI 进行手写

在写之前我再给大家缕一缕思路

为什么要使用我们的IOC??说简单点其实就是为了解除我们多次进行实例化对象的局限性把实例化好的对象放入容器中

需要我们就去获取,这样也做到了解耦

比如一类调用A类的hello方法必须实例A对象如果我们有100个类都要调用这个方法必须实例化100次

A类实例局限于当前使用类而已,当前类计数器0,GC就会进行回收 ,这样做会大大提高我们的局限性

这时候我们的IOC就诞生了!IOC:把本类创建某个依赖对象的过程交给外部对象来创建以及维护实例生命周期过程

说一下如何手写我们的IOC的思路

 我们一般都是通过配置文件的方法去扫描包路径的于是我就自定义了一个xml文件叫springmvc.xml文件

然后我们就要扫描包下面的所有类,这时候我们需要SAXReader这个类去读取我们的配置文件这时候就需要我们的dom4j去

进行读取,下面上代码

package com.MVCUtils.readerxml;

import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import java.io.InputStream;

/**
 * @author longhai
 * @date 2018/10/31 - 19:18
 */
public class BeanXMLReaderUtil {

    private static BeanXMLReaderUtil reader =new BeanXMLReaderUtil();

    private BeanXMLReaderUtil() {

    }

    public String getScanPackage(String ConfigLocation) throws DocumentException {
        //xml文件转化为输出流
        InputStream inputStream=this.getClass().getClassLoader().getResourceAsStream(ConfigLocation);
        SAXReader saxReader=new SAXReader();
        Document document=saxReader.read(inputStream);
        // 获取根元素
        Element rootElement =document.getRootElement();
        // 解析component-scan
        Element element=rootElement.element("compoent-scan");
        return element.attributeValue("base-package");


    }
    public static BeanXMLReaderUtil getInstance() {
        return reader;
    }
}

然后我们就可以通过BeanXMLReaderUtil这个getScanpackage的方法去获取需要扫描的包路径了,然后将扫描扫描包下所有.class文件然后我们将这些文件保存到一个线程安全的集合中,是为了下一步做准备

各位朋友们,还记得我们的@Autowired @Controller @Repository @Service @RequestMapping 这些注解吗?这时候就该手写一下注解了

@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Autowired {
	String value() default "";
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Repository {
	String value() default "";
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
	String value() default "";
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Service {
	String value() default "";
}

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
	String value() default ""; // 存储请求的路径
}

 MVC中最核心的类其实就是我们的DispatcherServlet啦,为了完成我们的MVC必须构建这个类哦!

同时这个类必须要实现我们的HttpServlet,但是为了让Tomcat启动的时候像Servlet一样去加载我们的这个类

需要在web.xml中做一些配置哦!

<?xml version="1.0" encoding="UTF-8"?>
<web-app id="WebApp_ID" version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
	<!-- 配置前端控制器 -->
	<servlet>
		<servlet-name>mvc</servlet-name>
		<servlet-class>com.processor.DispatcherServlet</servlet-class>
		<load-on-startup>0</load-on-startup>
	</servlet>
	
	<!-- 配置Servlet的映射 -->
	<servlet-mapping>
		<servlet-name>mvc</servlet-name>
		<url-pattern>/</url-pattern>
	</servlet-mapping>
</web-app>

然后看我们的Dispatcher这个类的主方法,也就是我们的init方法,也就是一个servlet的初始化方法,这个方法是在第一访问加载这个servlet的时候执行初始化,大家看看这些方法的步骤以及思路

@Override
	public void init() throws ServletException {
		// 扫描需要容器管理的类对应Class对象到集合中
		scanPackages( BeanXMLReaderUtil.getInstance().getScanPackage("springmvc.xml"));
		// 把需要管理的对象实例创建并保存到Map集合中
		// key -> 如果有别名使用别名,如果没有别名使用类名称首字母小写作为key
		hanlderAliasToBeanMapping();
		// 把每一个对象所需要依赖装配对应属性中
		hanldlerBeanIOC();
		// 完成访问路径与方法关联映射
		hanlderUrlToMethodMapping();

 给大家看一下我在DispatcherServlet这个类中建了好几个集合,分别是存储扫描到的class文件,存储注解别名和实例的MAP集合(如果类上面的注解没有别名就默认按照类名的首字母小写),存储Controller中方法和映射路径的关联集合

public class DispatcherServlet extends HttpServlet {
	/**
	 * 
	 */
	private static final long serialVersionUID = 1L;
	// 定义一个集合来存储扫描的类文件Class对象
	List<Class<?>> classList = Collections.synchronizedList(new ArrayList<>());
	// 创建建立映射 别名 - 类的实例 关系的容器
	Map<String, Object> beanContainer = Collections.synchronizedMap(new HashMap<>());
	// 创建一个Controller中方法与映射路径的关联集合
	Map<String, Object> urlMappingContext = Collections.synchronizedMap(new HashMap<>());

 下面看第一个scanpackage方法

private void scanPackages(String basePackage) {
		// 获取当前根包的路径
		URL url = this.getClass().getClassLoader().getResource(basePackage.replaceAll("\\.", "/"));
		// 创建一个文件
		try {
			File baseFile = new File(url.toURI());
			// 获取当前这个目录下面所有文件
			baseFile.listFiles(new FileFilter() {

				@Override
				public boolean accept(File childFile) {
					// 使用递归方式来获取文件
					if (childFile.isDirectory()) {
						// 递归查找文件
						scanPackages(basePackage + "." + childFile.getName());
					} else {
						// 把获取的.class文件转换为类对象存储到classList集合中
						if (childFile.getName().endsWith(".class")) {
							String classPath = basePackage + "." + childFile.getName().replaceAll("\\.class", "");

							try {
								// 把一个类路径转换为一个类对象
								Class<?> clazz = this.getClass().getClassLoader().loadClass(classPath);
								// System.out.println(clazz);

								// 把带有@Repository,@Service,@Controller注解类扫描到容器中
								if (clazz.isAnnotationPresent(Repository.class)
										|| clazz.isAnnotationPresent(Service.class)
										|| clazz.isAnnotationPresent(Controller.class)) {
									// 装入到集合中
									classList.add(clazz);
								}

							} catch (ClassNotFoundException e) {
								e.printStackTrace();
							}
						}
					}

					return false;
				}
			});
		} catch (Exception e) {
			e.printStackTrace();
		}

	}

上图就是将扫描到的class文件装入线程安全的集合当中,注释很详细我就不必多说 

接下来看下一个方法把带有controller repository service的类进行实例化,并把它们的别名和实例一一对应放入线程安全的Map集合中,接下来看代码

/**
	 * 用于处理别名-对象的映射关联
	 */
	private void hanlderAliasToBeanMapping() {
		//判断装class的集合是否为空如果为空就不执行
		if (classList.size() == 0) {
			return;
		}

		try {

			for (Class<?> cl : classList) {
				// 通过类获取对应key
				String key = BeanUtil.getkeyForClass(cl);
				// 把key与对应对象进行关联存储
				beanContainer.put(key, cl.newInstance());
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

由于判断别名的那个方法在多处用到所以把他抽出来做了个方法叫getkeyForClass,下面看看这个方法

判断类上面是否有这些注解然后如果注解有别名就按照别名装配如果没有就默认首字母小写喽

/**
		 * 根据类上面的注解获取对应别名
		 * 
		 * @param cl
		 * @return
		 */
		private static String getkeyForClass(Class<?> cl) {
			// 默认为类名称的首字母小写
			String alias = getLowerName(cl.getSimpleName());

			if (cl.isAnnotationPresent(Repository.class)) {
				Repository rp = cl.getAnnotation(Repository.class);
				if (!"".equals(rp.value())) {
					alias = rp.value();
				}
			}

			if (cl.isAnnotationPresent(Service.class)) {
				Service rp = cl.getAnnotation(Service.class);
				if (!"".equals(rp.value())) {
					alias = rp.value();
				}
			}

			if (cl.isAnnotationPresent(Controller.class)) {
				Controller rp = cl.getAnnotation(Controller.class);
				if (!"".equals(rp.value())) {
					alias = rp.value();
				}
			}

			return alias;
		}

由于我们默认是按照类名首字母小写进行装配的,所以我就写了个方法让首字母转化为小写喽!也就是下面这个方法

/**
		 * 定义把一个类名称首字母小写的方法
		 */
		private static String getLowerName(String className) {
			String cpation = className.substring(0, 1);
			String body = className.substring(1);

			return cpation.toLowerCase() + body;
		}

 接下来就进行我们的依赖注入喽,把对象所需的依赖装配到我们对应的属性中,下面看代码

/**
	 * 定义处理对象装配依赖对象的方法
	 */
	private void hanldlerBeanIOC() {
		if (classList.size() == 0) {
			return;
		}

		for (Class<?> cl : classList) {
			// 通过类获取对应key[需要通过别名在beanContainer集合获取对应实例]
			String key = BeanUtil.getkeyForClass(cl);
			// 获取当前类中所有字段
			Field[] fields = cl.getDeclaredFields();

			if (fields != null && fields.length > 0) {
				try {
					for (Field field : fields) {
						// 获取的所需的依赖对象
						Object injectOject = BeanUtil.getInjectionObject(beanContainer, field);
						// 通过别名获取需要装配对象
						Object targetObject = beanContainer.get(key);
						// 设置权限
						field.setAccessible(true);
						// 装配依赖对象
						field.set(targetObject, injectOject);
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		}

	}

大家可能纳闷BeanUtil是哪里来的,这是我写的一个内部类,是私有化的,为了保护里面的实现方法所做的

我把里面的所有方法给大家看看,里面都有注释,相信应该不难理解

private static class BeanUtil {
		/**
		 * 定义根据方法对象获取对应的访问路径
		 */
		private static void getUrlForMethod(Map<String, Object> urlMappingContext, Method method,
				String baseUrlMapping) {
			RequestMapping methodMapping = method.getAnnotation(RequestMapping.class);
			if (methodMapping != null && !"".equals(methodMapping.value())) {
				String url = methodMapping.value();
				String urlMapping = BeanUtil.hanlderUrl(url);
				// 拿到方法上面对应请求映射路径
				urlMapping = baseUrlMapping + urlMapping;
				// 关联访问路径与方法
				urlMappingContext.put(urlMapping, method);
			}
		}

		/**
		 * 处理映射路径的方法
		 * 
		 * @param url
		 * @return
		 */
		private static String hanlderUrl(String url) {
			String urlMapping = "";
			if (url.startsWith("/")) {
				urlMapping = url;
			} else {
				urlMapping = "/" + url;
			}

			return urlMapping;
		}

		/**
		 * 获取该类下面所需要的依赖对象
		 * 
		 * @param beanContainer
		 * @param field
		 * @return
		 */
		private static Object getInjectionObject(Map<String, Object> beanContainer, Field field) {
			// 定义存储类需要依赖对象
			Object injectOject = null;
			// 判断该字段上面是否存在Autowired注解
			if (field.isAnnotationPresent(Autowired.class)) {
				Autowired alias = field.getAnnotation(Autowired.class);
				// 是否定义别名,按照别名装配
				if (!"".equals(alias.value())) {
					String beanAlias = alias.value();
					// 通过别名在容器中把对应对象获取出来
					injectOject = beanContainer.get(beanAlias);
				} else {
					// 按照类型装配
					// 获取字段Class类型
					Class<?> fieldClass = field.getType();
					// 获取容器中所有对象实例集合
					Collection<Object> beans = beanContainer.values();
					// 判断里面对象是否有与字段类型的派生
					for (Object obj : beans) {
						if (fieldClass.isAssignableFrom(obj.getClass())) {
							injectOject = obj;
						}
					}
				}
			}

			return injectOject;
		}

		/**
		 * 根据类上面的注解获取对应别名
		 * 
		 * @param cl
		 * @return
		 */
		private static String getkeyForClass(Class<?> cl) {
			// 默认为类名称的首字母小写
			String alias = getLowerName(cl.getSimpleName());

			if (cl.isAnnotationPresent(Repository.class)) {
				Repository rp = cl.getAnnotation(Repository.class);
				if (!"".equals(rp.value())) {
					alias = rp.value();
				}
			}

			if (cl.isAnnotationPresent(Service.class)) {
				Service rp = cl.getAnnotation(Service.class);
				if (!"".equals(rp.value())) {
					alias = rp.value();
				}
			}

			if (cl.isAnnotationPresent(Controller.class)) {
				Controller rp = cl.getAnnotation(Controller.class);
				if (!"".equals(rp.value())) {
					alias = rp.value();
				}
			}

			return alias;
		}

		/**
		 * 定义把一个类名称首字母小写的方法
		 */
		private static String getLowerName(String className) {
			String cpation = className.substring(0, 1);
			String body = className.substring(1);

			return cpation.toLowerCase() + body;
		}

	}

 DI和依赖注入操作完成

接下来我们进行Controller里面RequestMapping的路径和方法名的映射关系的处理,如何处理呢?

这时候我们就要将Requestmapping里面的路径和方法进行映射起来放入我们的线程安全的MAP集合中哦!

/**
	 * 处理访问路径和方法的关联
	 */
	private void hanlderUrlToMethodMapping() {
		// 判断是否存储映射处理
		if (classList.size() == 0) {
			return;
		}

		for (Class<?> cl : classList) {
			// 判断是否为Controller类
			if (cl.isAnnotationPresent(Controller.class)) {
				// 定义一个接受映射路径的变量
				String baseUrlMapping = "";
				// 判断上面是否有RequestMapping注解
				RequestMapping mapping = cl.getAnnotation(RequestMapping.class);

				if (mapping != null && !"".equals(mapping.value())) {
					String url = mapping.value();
					baseUrlMapping = BeanUtil.hanlderUrl(url);
				}

				// 获取当前类中所有的方法
				Method[] methods = cl.getDeclaredMethods();

				if (methods != null && methods.length > 0) {
					for (Method method : methods) {
						// 判断该方法必须是public的并且有RequestMapping注解的
						if (method.getModifiers() == Modifier.PUBLIC
								&& method.isAnnotationPresent(RequestMapping.class)) {
							// 调用方法处理
							BeanUtil.getUrlForMethod(urlMappingContext, method, baseUrlMapping);
						}
					}
				}
			}
		}
	}

handlerurl其实是处理我们映射路径的一个方法他的作用就是不管路径有没有以“/”开头都是会被加上的

/**
		 * 处理映射路径的方法
		 * 
		 * @param url
		 * @return
		 */
		private static String hanlderUrl(String url) {
			String urlMapping = "";
			if (url.startsWith("/")) {
				urlMapping = url;
			} else {
				urlMapping = "/" + url;
			}

			return urlMapping;
		}

 而geturlformethod是防止controller上加了别名,这样路径就得把controller的别名加上,比如一个方法RequestMapping的路径名

为/add 但是Controller的名字叫coo,那么这个方法就得用/coo/add来访问,这个方法就是处理这个情况的

/**
		 * 定义根据方法对象获取对应的访问路径
		 */
		private static void getUrlForMethod(Map<String, Object> urlMappingContext, Method method,
				String baseUrlMapping) {
			RequestMapping methodMapping = method.getAnnotation(RequestMapping.class);
			if (methodMapping != null && !"".equals(methodMapping.value())) {
				String url = methodMapping.value();
				String urlMapping = BeanUtil.hanlderUrl(url);
				// 拿到方法上面对应请求映射路径
				urlMapping = baseUrlMapping + urlMapping;
				// 关联访问路径与方法
				urlMappingContext.put(urlMapping, method);
			}
		}

把关联好的路径和方法名放入那个线程安全的集合中(urlmappingcontext)

路径和方法名都获取到了,接下来就是真正处理我们的请求的方法了,学习过servlet的都知道,用户每次访问servlet时都会执行service方法,我们就在Dispatcherservlet这个类中重写service方法去完成用户请求的方法

/**
	 * 用户请求的处理操作方法
	 */

	protected void service(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		// 定义一个用于存储形式参数获取的数据组成的集合
		List<Object> paramValues = new ArrayList<>();
 		// 获取用发出请求路径
		String url = request.getRequestURI();
		// 获取用户请求上下文路径
		String contextPath = request.getContextPath();//controller里面的别名
		System.out.println(contextPath + "---" + url);

		// 处理请求路径获取到映射路径

		if (url.startsWith(contextPath)) {//将controller的别名设为空 通过Requestmapping里的路径去拿到对应的方法
			url = url.replace(contextPath, "");
		}

		// 通过请求处理好的映射路径在urlMappingContext对象中获取到对应映射方法
		Method method = (Method) urlMappingContext.get(url);
		// 获取到该方法对应类
		Class<?> methodForClass = method.getDeclaringClass();
		// 通过类名称获取对应别名
		String controllerKey = BeanUtil.getkeyForClass(methodForClass);
		// 在通过别名在对象Map集合中通过可以获取到对应实例
		Object targetObject = beanContainer.get(controllerKey);
		
		try {
			// 获取到方法参数类数组
			Class<?>[] parameterTypes = method.getParameterTypes();
			// 调用方法获取到所有方法形参组成的数组
			String[] paramaterNames = MethodUtil.getAllParamaterName(method);
			
			if (paramaterNames != null && paramaterNames.length > 0
					&& parameterTypes != null && parameterTypes.length > 0) {
				// 变量形式参数数组
				for (int i = 0; i < paramaterNames.length; i ++) {
					// 获取参数名对应结果
					String obj = request.getParameter(paramaterNames[i]);
					// 获取参数对应类型
					Class<?> paramType = parameterTypes[i];
					
					// 处理类型的转换
					if (paramType == Integer.class) {
						paramValues.add(Integer.valueOf(obj));
					} else if (paramType == int.class) {
						paramValues.add(Integer.parseInt(obj));
					} else if (paramType == String.class) {
						paramValues.add(obj);
					}
					
				}
			}
		} catch (NotFoundException e) {
			e.printStackTrace();
		}

		
		try {
			// 通过反射调用对象中的方法
			Object returnValue = method.invoke(targetObject, paramValues.toArray());
			// 转发到对应页面
			request.getRequestDispatcher(String.valueOf(returnValue)).forward(request, response);
		} catch (Exception e) {
			e.printStackTrace();
		}

	}

大家有没有看懂上面这个方法,是这样的,因为执行所对应的方法就应该拿出那个方法,用通过map.get(requestmapping的路径),所以要把路径前controller的别名给去掉,然后想要执行这个方法就要拿到这个方法的类,参数和实例等等,最后再执行方法就可以了 ,获取形参数组我是通过javassist-3.22.0-GA.jar这个包实现的 通过下图的方法获取形参数组 然后处理参数的类型,将他们转化为所需的类型,最后通过反射调用方法 并通过request.getrequestdispatcher方法转到相对应的页面即可(因为一般controller里面的方法的返回值就是要跳转页面的路径)

package com.processor;
import java.lang.reflect.Method;

import javassist.ClassClassPath;
import javassist.ClassPool;  
import javassist.CtClass;  
import javassist.CtMethod;  
import javassist.Modifier;  
import javassist.NotFoundException;  
import javassist.bytecode.CodeAttribute;  
import javassist.bytecode.LocalVariableAttribute;  
import javassist.bytecode.MethodInfo;  
  
/**
 * 使用javassist的方法工具
 * @author gerry
 * @date 2018-04-16
 */
public class MethodUtil {  
  
	/**
	 * 通过该方法获取到一个Method对象形参名称组成的数组
	 * @param method
	 * @return
	 * @throws NotFoundException
	 */
    public static String[] getAllParamaterName(Method method)  
        throws NotFoundException {  
	    Class<?> clazz = method.getDeclaringClass();  
	    ClassPool pool = ClassPool.getDefault();  
	    ClassClassPath classPath = new ClassClassPath(MethodUtil.class);  
	    pool.insertClassPath(classPath);  
	    CtClass clz = pool.get(clazz.getName());  
	    CtClass[] params = new CtClass[method.getParameterTypes().length];  
	    for (int i = 0; i < method.getParameterTypes().length; i++) {  
	        params[i] = pool.getCtClass(method.getParameterTypes()[i].getName());  
	    }  
	    CtMethod cm = clz.getDeclaredMethod(method.getName(), params);  
	    MethodInfo methodInfo = cm.getMethodInfo();  
	    CodeAttribute codeAttribute = methodInfo.getCodeAttribute();  
	    LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute  
	        .getAttribute(LocalVariableAttribute.tag);  
	    int pos = Modifier.isStatic(cm.getModifiers()) ? 0 : 1;  
	    String[] paramNames = new String[cm.getParameterTypes().length];  
	    for (int i = 0; i < paramNames.length; i++) {  
	        paramNames[i] = attr.variableName(i + pos);  
	    }  
	    return paramNames;  
    }  
  
}  

上面这个 方法不需要具体的去理解,只要知道他是干什么的就行了,他就是获取形参数组的方法

接下来写好我们测试用的代码启动我们的tomcat就可以进行测试我们手写的MVC框架啦!

好了,这就是我大致的简易springmvc构建的流程,你们理解了多少?这些都是纯干货,注释加的也特别多,就是希望大家能够看懂,相信有一定水平的,也不会感觉到困难吧?

这次的分享就到这里,有问题就多多交流:QQ936997192

成功的路上并不孤单,有你有我,一起成长!

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值