JDK动态代理详解-逐步解析

一、背景

    目前常用的代理有静态代理与动态代理,静态代理的方式以继承、聚合方式,动态代理以JDK动态代理、Cglib代理为主,直接剖析JDK动态代理有些突兀,本文由简入繁,通过自己实现的代理方式解释代理方式的一个变化过程。最终与JDK动态代理源码对比,大家肯定就能明白JDK动态代理的设计者为什么这么做了。

二、静态代理

    本文的静态代理以聚合方式进行阐述,而本文给的例子以装饰者模式,大家可能很奇怪,其实两者本质没太多区别,为了让全文更加通俗易懂,描述变化过程,因此以装饰者模式来实现“代理”:

    聚合方式是代理类与真正的实现类实现的是同一个接口,如以下代码所示:

    UserDao接口:

public interface UserDao {

    public void query()throws Exception;

}

    UserDaoImpl实现类:

   

public class UserDaoImpl implements UserDao {

    public void query() {
        System.out.println("Dao query");
    }
}

    ProxyDao代理类:

    以下可以看到   输出“代理实现”   就是我们代理类的增强措施

   

public class ProxyDao implements UserDao {

    private UserDao dao;
    public ProxyDao(UserDao dao){
        this.dao = dao;
    }

    public void query() throws Exception{
        System.out.println("代理实现");
        dao.query();
    }
}

    测试类:

public class Test {
    public static void main(String[] args) throws Exception{
        //聚合方式
        UserDao userDao = new ProxyDao(new UserDaoImpl());
        userDao.query();
    }
}

     以上运行出来的结果相信大家都知道:

代理实现
Dao query

   以上聚合方式的代理存在很大的缺点,如果需要代理的目标类有很多,那么我们需要写很多代理类,最终产生类爆炸,那我们下一步该如何做呢?如果我们的ProxyDao不需要每次都重写该多好呢,如果有一个类专门帮我们生成这个ProxyDao该多好呢。。。

二、自定义动态代理(代理类的方法固定)

    大家可能好奇括号里面的是什么意思,比如第一步的静态代理中的代理类增强的措施是输出“代理实现”,而我们这次的自定义动态代理只是为了防止类爆炸,先不解决如何动态设置增强措施。带着第一节的疑问,如何动态生成这个ProxyDao,我们可以想到自己用代码生成一个动态类,那这个动态类如何来,我们正常的类是通过.java文件编译成.class文件,然后加载到jvm中,那么我们是否也可以?当然可以!!!

    接口类、接口实现类不动,我们来写一个能够动态生成代理类的类:

    

public class ProxyUtil {

    public static Object newInstance(Object object) throws Exception {
        String line = "\n";
        String tab = "\t";
        //拿到目标类的接口
        Class targetClazz = object.getClass().getInterfaces()[0];
        Method[] methods = targetClazz.getDeclaredMethods();
        String context="";
        //import
        String importContext = "import "+targetClazz.getName()+";"+line;
        //第一行
        String firstContext = "public class $Proxy implements "+ targetClazz.getSimpleName()+" {"+line;
        //字段
        String filedContext = tab+"private "+targetClazz.getSimpleName()+" target;"+line;
        //构造方法
        String constructContext = tab+"public $Proxy("+targetClazz.getSimpleName()+" target){"+line
                +tab+tab+"this.target = target;"+line
                +tab+"}"+line;

        String methodContext = "";
        for (Method method : methods){
            //判断是否有方法参数
            Class<?>[] classes = method.getParameterTypes();
            String paramaterContext = "";
            String paraContext = "";
            if(classes.length>0){
                for (int i=0;i<classes.length;i++){
                    paramaterContext+=classes[i].getSimpleName()+" v"+i+",";
                    paraContext+=("v"+i+",");
                }
                paramaterContext = paramaterContext.substring(0,paramaterContext.lastIndexOf(","));
                paraContext = paraContext.substring(0,paraContext.lastIndexOf(","));
            }
            //这边没有写方法返回,在下一节会写,而且可以看到代理的增强措施被固定了
            methodContext+= tab+"public void "+method.getName()+"("+paramaterContext+"){"+line+
                    tab+tab+"System.out.println(\"自定义动态代理实现\");"+line+
                    tab+tab+"target."+method.getName()+"("+paraContext+");"+line+
                    tab+"}"+line;
        }
        context = importContext+firstContext+filedContext+constructContext+methodContext+"}";

        //设置编译参数
        ArrayList<String> ops = new ArrayList<String>();
        ops.add("-Xlint:unchecked");
        //编译代码,返回class
        ClassUtil.compile("/Users/Desktop/测试文件路径/proxy/$Proxy.java",context,ops);
        FileClassLoader loader = new FileClassLoader("/Users/Desktop/测试文件路径/proxy/");
        Class<?> clazz = loader.findClass("$Proxy");
        Constructor<?> constructor = clazz.getConstructor(targetClazz);
        return constructor.newInstance(object);
    }
}

    这边有两个类一个是ClassUtil,是负责生成.java文件并编译的,FileClassLoader是负责加载的:

    ClassUtil:

public class ClassUtil {
    private static final Log logger = LogFactory.getLog(ClassUtil.class);

    private static JavaCompiler compiler;
    static{
        compiler = ToolProvider.getSystemJavaCompiler();
    }
    /**
     * 获取java文件路径
     * @param file
     * @return
     */
    private static String getFilePath(String file){
        int last1 = file.lastIndexOf('/');
        int last2 = file.lastIndexOf('\\');
        return file.substring(0, last1>last2?last1:last2)+File.separatorChar;
    }
    /**
     * 编译java文件
     * @param ops 编译参数
     * @param files 编译文件
     */
    public static void javac(List<String> ops, String... files){
        StandardJavaFileManager manager = null;
        try{
            manager = compiler.getStandardFileManager(null, null, null);
            Iterable<? extends JavaFileObject> it = manager.getJavaFileObjects(files);
            JavaCompiler.CompilationTask task = compiler.getTask(null, manager, null, ops, null, it);
            task.call();
            if(logger.isDebugEnabled()){
                for (String file:files)
                    logger.debug("Compile Java File:" + file);
            }
        }
        catch(Exception e){
            logger.error(e);
        }
        finally{
            if(manager!=null){
                try {
                    manager.close();
                }
                catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    /**
     * 生成java文件
     * @param file 文件名
     * @param source java代码
     * @throws Exception
     */
    private static void writeJavaFile(String file,String source)throws Exception{
        if(logger.isDebugEnabled()){
            logger.debug("Write Java Source Code to:"+file);
        }
        BufferedWriter bw = null;
        try{
            File dir = new File(getFilePath(file));
            if(!dir.exists())
                dir.mkdirs();
            bw = new BufferedWriter(new FileWriter(file));
            bw.write(source);
            bw.flush();
        }
        catch(Exception e){
            throw e;
        }
        finally{
            if(bw!=null){
                bw.close();
            }
        }
    }
    /**
     * 加载类
     * @param name 类名
     * @return
     */
    private static Class<?> load(String name){
        Class<?> cls = null;
        ClassLoader classLoader = null;
        try{
            classLoader = ClassUtil.class.getClassLoader();
            cls = classLoader.loadClass(name);
            if(logger.isDebugEnabled()){
                logger.debug("Load Class["+name+"] by "+classLoader);
            }
        }
        catch(Exception e){
            logger.error(e);
        }
        return cls;
    }
    /**
     * 编译代码并加载类
     * @param filePath java代码路径
     * @param source java代码
     * @param ops 编译参数
     * @return
     */
    public static void compile(String filePath,String source,List<String> ops){
        try {
            writeJavaFile(filePath,source);
            javac(ops,filePath);
        }
        catch (Exception e) {
            logger.error(e);
        }
    }
    /**
     * 调用类方法
     * @param cls 类
     * @param methodName 方法名
     * @param paramsCls 方法参数类型
     * @param params 方法参数
     * @return
     */
    public static Object invoke(Class<?> cls,String methodName,Class<?>[] paramsCls,Object[] params){
        Object result = null;
        try {
            Method method = cls.getDeclaredMethod(methodName, paramsCls);
            Object obj = cls.newInstance();
            result = method.invoke(obj, params);
        }
        catch (Exception e) {
            logger.error(e);
        }
        return result;
    }
}

     FileClassLoader:

   

public class FileClassLoader extends ClassLoader {

    private String rootDir;

    public FileClassLoader(String rootDir){
        this.rootDir = rootDir;
    }

    /**
     * 重新编写findClass方法
     * loadClass因为是从缓存中查询是否加载过,因此只能加载一次
     * @param name
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    public Class<?> findClass(String name) throws ClassNotFoundException {
        //获取类的class文件字节数组
        byte[] classData = getClassData(name);
        if(null==classData){
            throw new ClassNotFoundException();
        }else {
            //调用defineClass直接生成对象
            return defineClass(name,classData,0,classData.length);
        }
    }

    /**
     * 编写获取class文件并转化为字节码流
     * @param className
     * @return
     */
    private byte[] getClassData(String className){
        //读取类文件的字节
        String path = classNameToPath(className);
        InputStream is = null;
        try {
            is = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            //读取类文件的字节码
            while ((bytesNumRead = is.read(buffer))!=-1){
                baos.write(buffer,0,bytesNumRead);
            }
            return baos.toByteArray();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * 类文件的完全路径
     * @param className
     * @return
     */
    private String classNameToPath(String className){
        return rootDir+ File.separatorChar
                +className.replace('.',File.separatorChar)+".class";
    }
    
}

    写完这个ProxyUtil,测试运行下:

   

public class Test {
    public static void main(String[] args) throws Exception{
        //普通代理模式
        UserDao userDao = new ProxyDao(new UserDaoImpl());
        userDao.query();

        //自定义动态代理(增强方法固定)
        UserDao userDao1 = (UserDao) ProxyUtil.newInstance(new UserDaoImpl());
        userDao1.query();

    }
}

    我们可以看到输出:

   

代理实现
Dao query
自定义动态代理实现
Dao query

     还以看到生成的文件$Proxy.java,这个就是我们动态生成的文件:

    

import com.wolf.test.spring.proxy.dao.UserDao;
public class $Proxy implements UserDao {
	private UserDao target;
	public $Proxy(UserDao target){
		this.target = target;
	}
	public void query(){
		System.out.println("自定义动态代理实现");
		target.query();
	}
}

四、自定义动态代理

    上文我们可以看到虽然能够对任意的实现类实现代理,但是我们代理类中的增强措施是固定的,那么我们如何做到增强措施也由我们自定义呢?我们可以自己定义一个增强类,将代理类与增强的类隔离开,以后一切都执行增强类中的方法,可能有点模糊不清,没关系,直接上代码(我们为了验证下代理带返回参数的方法,因此重新写了一个接口):

    IndexDao接口:

    

public interface IndexDao {
    public String query() throws Exception;
}

   IndexDaoImpl实现类:

     

public class IndexDaoImpl implements IndexDao {
    public String query() throws Exception{
        System.out.println("query");
        return "index Dao";
    }
}

   自定义增强类接口:

   

public interface CustomHandler {

    public Object invoke(Method method, Object...args) throws InvocationTargetException, IllegalAccessException;
}

   增强类实现:

   

public class ProxyCustomHandler implements CustomHandler {

    private Object object;

    public ProxyCustomHandler(Object object){
        this.object = object;
    }

    public Object invoke(Method method, Object... args) throws InvocationTargetException, IllegalAccessException {
        System.out.println("代理");
        return method.invoke(object,args);
    }
}

     细心的同学可能发现,变化很大,ProxyCustomHandler中为什么需要object,那是因为invoke方法中最后通过反射执行。

    ProxyWithHandlerUtil:

    

public class ProxyWithHandlerUtil {

    public static Object newInstance(Class targetClazz, CustomHandler h) throws Exception{
        Object proxy = null;
        String line = "\n";
        String tab = "\t";
        //拿到目标类的接口
        Method[] methods = targetClazz.getDeclaredMethods();
        String context="";
        //import
        String importContext = "import "+targetClazz.getName()+";"+line;
        importContext+="import java.lang.reflect.Method;"+line;
        importContext+="import com.wolf.test.spring.proxy.CustomHandler;"+line;
        //第一行
        String firstContext = "public class $Proxy implements "+ targetClazz.getSimpleName()+" {"+line;
        //字段
        String filedContext = tab+"private CustomHandler target;"+line;
        //构造方法
        String constructContext = tab+"public $Proxy(CustomHandler target){"+line
                +tab+tab+"this.target = target;"+line
                +tab+"}"+line;

        String methodContext = "";
        for (Method method : methods){
            Class<?>[] classes = method.getParameterTypes();
            Class returnType = method.getReturnType();
            String paramaterContext = "";
            String paraContext = "";
            for (int i=0;i<classes.length;i++){
                paramaterContext+=classes[i].getSimpleName()+" v"+i+",";
                paraContext+=("v"+i+",");
            }
            if(method.getParameterTypes().length>0){
                paramaterContext = paramaterContext.substring(0,paramaterContext.lastIndexOf(","));
                paraContext = paraContext.substring(0,paraContext.lastIndexOf(","));
            }
            String methodCon = "";
            //这个地方为什么用Class.forName,因为源码当中也是这样,而且源码当中加了一个classLoader
            methodCon += tab+tab+"Method method = Class.forName(\""+targetClazz.getName()+"\").getDeclaredMethod(\""+method.getName()+"\");"+line;
            methodContext+= tab+"public "+returnType.getSimpleName()+" "+method.getName()+"("+paramaterContext+") throws Exception{"+line+
                    methodCon+
                    tab+tab+"return ("+returnType.getSimpleName()+")target.invoke(method);"+line+
                    tab+"}"+line;
        }
        context = importContext+firstContext+filedContext+constructContext+methodContext+"}";
        
        //设置编译参数
        ArrayList<String> ops = new ArrayList<String>();
        ops.add("-Xlint:unchecked");
        //编译代码,返回class
        ClassUtil.compile("/Users/Desktop/测试文件路径/proxy/$Proxy.java",context,ops);
        FileClassLoader loader = new FileClassLoader("/Users/Desktop/测试文件路径/proxy/");
        Class<?> clazz = loader.findClass("$Proxy");
        Constructor<?> constructor = clazz.getConstructor(CustomHandler.class);
        return constructor.newInstance(h);
    }
}

     Test类:

   

public class Test {
    public static void main(String[] args) throws Exception{
        //普通代理模式
        UserDao userDao = new ProxyDao(new UserDaoImpl());
        userDao.query();
        System.out.println("---------------------");
        //自定义动态代理(增强方法固定)
        UserDao userDao1 = (UserDao) ProxyUtil.newInstance(new UserDaoImpl());
        userDao1.query();
        System.out.println("---------------------");
        //自定义动态代理
        try {
            IndexDao indexDao = (IndexDao) ProxyWithHandlerUtil.newInstance(IndexDao.class,new ProxyCustomHandler(new IndexDaoImpl()));
            System.out.println(indexDao.query());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

    生成的.java文件:

 

import com.wolf.test.spring.proxy.dao.IndexDao;
import java.lang.reflect.Method;
import com.wolf.test.spring.proxy.CustomHandler;
public class $Proxy implements IndexDao {
	private CustomHandler target;
	public $Proxy(CustomHandler target){
		this.target = target;
	}
	public String query() throws Exception{
		Method method = Class.forName("com.wolf.test.spring.proxy.dao.IndexDao").getDeclaredMethod("query");
		return (String)target.invoke(method);
	}
}

 

    最终结果:

代理实现
Dao query
---------------------
自定义动态代理实现
Dao query
---------------------
代理
query
index Dao

     以上写法有不足之处,比如异常处理等,这个希望大家自己实现!!!

五、与JDK动态代理对比

    JDK动态代理源码:

    我们主要关注如何生成生成instance的:

    

public static Object newProxyInstance(ClassLoader loader,
                                          Class<?>[] interfaces,
                                          InvocationHandler h)
        throws IllegalArgumentException
    {
        Objects.requireNonNull(h);

        final Class<?>[] intfs = interfaces.clone();
        final SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            checkProxyAccess(Reflection.getCallerClass(), loader, intfs);
        }

        /*
         * Look up or generate the designated proxy class.
         */

        //核心之处,如何生成这个class对象
        Class<?> cl = getProxyClass0(loader, intfs);

        /*
         * Invoke its constructor with the designated invocation handler.
         */
        try {
            if (sm != null) {
                checkNewProxyPermission(Reflection.getCallerClass(), cl);
            }

            final Constructor<?> cons = cl.getConstructor(constructorParams);
            final InvocationHandler ih = h;
            if (!Modifier.isPublic(cl.getModifiers())) {
                AccessController.doPrivileged(new PrivilegedAction<Void>() {
                    public Void run() {
                        cons.setAccessible(true);
                        return null;
                    }
                });
            }
            return cons.newInstance(new Object[]{h});
        } catch (IllegalAccessException|InstantiationException e) {
            throw new InternalError(e.toString(), e);
        } catch (InvocationTargetException e) {
            Throwable t = e.getCause();
            if (t instanceof RuntimeException) {
                throw (RuntimeException) t;
            } else {
                throw new InternalError(t.toString(), t);
            }
        } catch (NoSuchMethodException e) {
            throw new InternalError(e.toString(), e);
        }
    }

    我们发现他的方法与我们自己实现的多了一个加载器,继续跟进源码可以发现这个classLoader的作用,本文自己实现的代理没有传递这个classLoader是因为本文主要以测试为主,哈哈。。。。

    继续跟进会发现这个方法:

    

        public Class<?> apply(ClassLoader loader, Class<?>[] interfaces) {

            Map<Class<?>, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length);
            for (Class<?> intf : interfaces) {
                /*
                 * Verify that the class loader resolves the name of this
                 * interface to the same Class object.
                 */
                Class<?> interfaceClass = null;
                try {
                    interfaceClass = Class.forName(intf.getName(), false, loader);
                } catch (ClassNotFoundException e) {
                }
                if (interfaceClass != intf) {
                    throw new IllegalArgumentException(
                        intf + " is not visible from class loader");
                }
                /*
                 * Verify that the Class object actually represents an
                 * interface.
                 */
                if (!interfaceClass.isInterface()) {
                    throw new IllegalArgumentException(
                        interfaceClass.getName() + " is not an interface");
                }
                /*
                 * Verify that this interface is not a duplicate.
                 */
                if (interfaceSet.put(interfaceClass, Boolean.TRUE) != null) {
                    throw new IllegalArgumentException(
                        "repeated interface: " + interfaceClass.getName());
                }
            }

            String proxyPkg = null;     // package to define proxy class in
            int accessFlags = Modifier.PUBLIC | Modifier.FINAL;

            /*
             * Record the package of a non-public proxy interface so that the
             * proxy class will be defined in the same package.  Verify that
             * all non-public proxy interfaces are in the same package.
             */
            for (Class<?> intf : interfaces) {
                int flags = intf.getModifiers();
                if (!Modifier.isPublic(flags)) {
                    accessFlags = Modifier.FINAL;
                    String name = intf.getName();
                    int n = name.lastIndexOf('.');
                    String pkg = ((n == -1) ? "" : name.substring(0, n + 1));
                    if (proxyPkg == null) {
                        proxyPkg = pkg;
                    } else if (!pkg.equals(proxyPkg)) {
                        throw new IllegalArgumentException(
                            "non-public interfaces from different packages");
                    }
                }
            }

            if (proxyPkg == null) {
                // if no non-public proxy interfaces, use com.sun.proxy package
                proxyPkg = ReflectUtil.PROXY_PACKAGE + ".";
            }

            /*
             * Choose a name for the proxy class to generate.
             */
            long num = nextUniqueNumber.getAndIncrement();
            String proxyName = proxyPkg + proxyClassNamePrefix + num;

            /*
             * Generate the specified proxy class.
             */
            //核心之处,如何生成类的
            byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
                proxyName, interfaces, accessFlags);
            try {
                return defineClass0(loader, proxyName,
                                    proxyClassFile, 0, proxyClassFile.length);
            } catch (ClassFormatError e) {
                /*
                 * A ClassFormatError here means that (barring bugs in the
                 * proxy class generation code) there was some other
                 * invalid aspect of the arguments supplied to the proxy
                 * class creation (such as virtual machine limitations
                 * exceeded).
                 */
                throw new IllegalArgumentException(e.toString());
            }
        }
    }

    我们发现有Class.forName(intf.getName,false,loader)方法,为什么这么做,相信大家都知道jvm中如何判断一个类是否唯一:根据类名+加载器,这就是方法为什么传classLoader;核心关心之处是ProxyGenerator.generateProxyClass方法,我们可以看到生成的是byte,其实这就是.class文件,只不过不像本文自己编译而已,可以看看如果用这个方法编译出来的.class文件是什么样子:

    Test类:

    

public class Test {
    public static void main(String[] args) throws Exception{

        //利用jdk代理的方式,生成class文件
        byte[] bytes = ProxyGenerator.generateProxyClass("$ProxyDao",new Class[]{IndexDao.class});

        FileOutputStream fileOutputStream = new FileOutputStream("/Users/Desktop/测试文件路径/proxy/$ProxyDao.class");
        fileOutputStream.write(bytes);
        fileOutputStream.flush();
    }
}

我们将生成的.class文件反编译:

public final class $ProxyDao extends Proxy implements IndexDao {
    private static Method m1;
    private static Method m3;
    private static Method m2;
    private static Method m0;

    public $ProxyDao(InvocationHandler var1) throws  {
        super(var1);
    }

    public final boolean equals(Object var1) throws  {
        try {
            return ((Boolean)super.h.invoke(this, m1, new Object[]{var1})).booleanValue();
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }

    public final String query() throws Exception {
        try {
            return (String)super.h.invoke(this, m3, (Object[])null);
        } catch (Exception | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final int hashCode() throws  {
        try {
            return ((Integer)super.h.invoke(this, m0, (Object[])null)).intValue();
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[]{Class.forName("java.lang.Object")});
            m3 = Class.forName("com.wolf.test.spring.proxy.dao.IndexDao").getMethod("query", new Class[0]);
            m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
            m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

    与我们生成的$Proxy.java文件(关注构造方法与query方法),细细对比可以发现基本相同。

结束语:

    本文从静态代理到自己实现的动态代理再与JDK动态代理做对比,由每一个代理存在的不足层层发展,最终描述JDK动态代理具体是如何实现的。

    谢谢大家阅读,请大家多多指教文章中的不足,互相学习!!

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值