Play! Framework学习笔记:ActionInvoker源码分析

开发 后端
上一篇介绍了Play!Framework初识的文章,这次借着将介绍Actionlnvoker源码分析,文中会接触到很多新的知识点。详细了解请看下文。

上一篇: Play!Framework学习笔记:初识Play

再往下看,我被雷到了= =# 第一遍看没反应过来,因为我见识较浅,也从来没这么写过代码,之前也没看过这样写的。就是这句:

  1. throw new RenderTemplate(template, templateBinding.data); 

这里用RenderTemplate的构造方法new了一个RenderTemplate对象,

然后.......抛出去了。

看看这里抛出去的是什么,先进去看看RenderTemplate类的实现:

  1. public class RenderTemplate extends Result { 

再看看Result

  1. public abstract class Result extends RuntimeException { 

原来RenderTemplate是个RuntimeException

@

 关于RuntimeException
http://java.sun.com/docs/books/tutorial/essential/exceptions/runtime.html
提到RuntimeException,我们最熟悉的可能就是NullPointerException,由于程序编写疏漏,造成访问空指针时,就会抛出此异常。
我们写个最简单的代码就是这样
public class TestString {
public static void main(String[] args){
testNull(null);
}
public static void testNull(String a){
a.charAt(0);
}
}
main方法里对testNull传了个null,但是charAt方法需要String obj,此时却是null,触发NullPointerException退出程序。
在看看console的信息:
Exception in thread "main" java.lang.NullPointerException
at com.langtest.TestString.testNull(TestString.java:9)
at com.langtest.TestString.main(TestString.java:6)
main 线程引发空指针异常,程序到main后也没对此异常处理的逻辑,导致程序退出,并在控制台打印出错信息。
throw之后的异常对象沿着方法栈往回扔(即调用此方法的方法),如果一直没有截获异常,则一直扔到栈底。

既然是个异常,下一步则是抛向上级调用者,往下走,我们找这个“不是异常的异常”是在何处被截获的。。。 (对比JAVA官网那篇对运行时异常小心翼翼的陈述,这种做法简直有点#_#,要么是因为我太菜,不能理解这么用的高明之处吧)。

Debug F6后,程序转至play.mvc.ActionInvoker的invoke方法中的catch语句。

  1. Result actionResult = null;  
  2. ControllerInstrumentation.initActionCall();  
  3. try {  
  4.     Java.invokeStatic(actionMethod, getActionMethodArgs(actionMethod));  
  5. catch (InvocationTargetException ex) {  
  6.     // It's a Result ? (expected)  
  7.     if (ex.getTargetException() instanceof Result) {  
  8.         actionResult = (Result) ex.getTargetException();  
  9.     } else {  
  10.         // @Catch  
  11.         Object[] args = new Object[]{ex.getTargetException()};  
  12.         List<Method> catches = Java.findAllAnnotatedMethods(Controller.getControllerClass(), Catch.class);  
  13.         Collections.sort(catches, new Comparator<Method>() {  

try... catch块中:

try块:用JAVA的反射机制invoke静态方法,这里其实就是invoke了我们在控制器中写的index方法。

@

 如果不理解反射,一定得去先弄明白,任何框架的源码及要实现一些共同的对象实例化或方法调用的通用接口,基本都得用到反射= =#
自编最土最俗的方式理解反射帮助同学理解反射(可能不一定准确):
反射说:“我是个万能的对象生成器和方法调用器,只要你给我类名,方法名以及相应的参数,我就能帮你new出对象或者调用相应的方法”
不过任何事情都是双刃剑,反射的能力过于强大,可以任意的生成和操作对象,所以就赋予程序员Do evil的可能.
而JAVA本身的设计是个尽量安全的语言,没有指针运算,虚拟机帮着整理内存,异常机制让做出健壮性的程序变得简化不少,所以我觉得反射机制对于JAVA的总体设计思想来说,还是不小的challenge。
此处的invokeStatic方法的两个参数,用debug工具的watch功能可以看到
actionMethod就是通过反射被invoke的方法。

catch块:拦截InvocationTargetException,这个exception是当通过反射的方式invoke的方法throw异常时,反射机制会触发这个异常,并将上一级throw出的异常存为这个异常的taget变量。

本例的过程是这样的,Play框架通过反射的方式invoke 控制器中的index方法(Application.index()),然后进入render(),在render方法里调用renderTemplate方法,在此方法将RenderTemplate这个异常(再次汗)抛出,反射机制发现有异常抛出,随后抛出InvocationTargetException异常,并将RenderTemplate存入InvocationTargetException的target变量..Play在使用反射invoke方法处catch了此异常,然后把target引用的RenderTemplate取出,则得到了render完成的模板。

from Java Api

InvocationTargetException is a checked exception that wraps an exception thrown by an invoked method or constructor.

@

 此处虽然搞明白了这段诡异代码的用途,其实也只是个学习的开始,我觉得学习的入门是读懂,能应用是其次,能对某个事物有批判性思维(知道好坏,知道适用何处才是更高层次)。
无论何时,我们都得有追本溯源的精神,下面是几个疑问:
①为什么框架这么设计
②如果可以,自己想一个替代的实现方式,对比此方式看看为何要用这么怪的设计

ActionInvoker源码分析

既然现在Debug走到ActionInvoker,不妨看看这个类:

由类上的注释:

Invoke an action after an HTTP request.

这个类是根据Http request Invoke相应的action。

这个类没有成员变量和函数,只有三个共有的静态方法,这三个方法分别是(用附加注释的方法解释):

  1. public class ActionInvoker {  
  2.         //响应请求的主函数,其实ActionInvoker这个类主要用途就是放置这个方法,因此这个类也同样也不具备面向对象特性的类,这个类注重的是响应HTTP请求的逻辑  
  3.         public static void invoke(Http.Request request, Http.Response response) {  
  4.         //通过传入的action(ie:Application.index),得到对应的method,以便反射时invoke使用  
  5.         public static Object[] getActionMethod(String fullAction) {  
  6.         //从method中取出方法的参数,这两个get方法都是为反射调用服务的。  
  7.         public static Object[] getActionMethodArgs(Method method) throws Exception {  

可见invoke是Play框架的运行的核心控件(说是核心是因为web框架的主要职责就是完成处理HTTP请求的过程)。

为了了解Play的核心运行机制,我们断开debug线程,在invoke方法设上断点,重新跑Debug。

进入方法,传入该方法的两个参数是由上一层调用者HttpHandler的内部类MinaInvocation的execute方法传入的。由于HttpHandler里做的工作比ActionInvoker更加基础(Mina应用服务器下的http协议处理及session管理),我们到后面再研究。

  1. public static void invoke(Http.Request request, Http.Response response) {      
  2.    Monitor monitor = null;  
  3.         try {  
  4.             if (!Play.started) {  
  5.                 return;  
  6.             }  
  7.  
  8.             Http.Request.current.set(request);  
  9.             Http.Response.current.set(response);  
  10.  
  11.             Scope.Params.current.set(new Scope.Params());  
  12.             Scope.RenderArgs.current.set(new Scope.RenderArgs());  
  13.             Scope.Session.current.set(Scope.Session.restore());  
  14.             Scope.Flash.current.set(Scope.Flash.restore());  
  15. ......  

先new一个Monitor ,用来监控。

然后判断Play是否启动。

随后的是一系列xxx.xxx.current.set方法:

这里的current变量都是ThreadLocal:

  1. public static ThreadLocal<Request> current = new ThreadLocal<Request>(); 

@

  1. //对于Java开发,ThreadLocal是必须要了解的概念。   
  2. //ThreadLocal虽然是个对象,但是ThreadLocal的set方法存的东西并不是放在ThreadLocal对象里   
  3. /**   
  4. * Sets the current thread's copy of this thread-local variable   
  5. * to the specified value. Many applications will have no need for   
  6. * this functionality, relying solely on the {@link #initialValue}   
  7. * method to set the values of thread-locals.   
  8.  
  9. * @param value the value to be stored in the current threads' copy of   
  10. * this thread-local.   
  11. */   
  12. public void set(T value) {   
  13. Thread t = Thread.currentThread();   
  14. ThreadLocalMap map = getMap(t);   
  15. if (map != null)   
  16. map.set(this, value);   
  17. else   
  18. createMap(t, value);   
  19. }   
  20. //由上可见,set方法首先取得当前的Thread对象,然后取得该线程的ThreadLocalMap ,如果map不为空,则写入map,以当前的ThreadLocal对象为key,将传入的value存入map。   
  21. //这里也只是个引子,没概念的可能很难理解清楚,毕竟ThreadLocal也不是我这么三言两语能说清的,建议同学多谷哥一下,多看多用多体会。   

将Request,Response以及Scope的引用放入当前线程后,实际上是完成了线程的初始化过程。

  1. // 1. Route and resolve format if not already done  
  2. if (request.action == null) {  
  3.     for (PlayPlugin plugin : Play.plugins) {  
  4.         plugin.routeRequest(request);  
  5.     }  
  6.     Router.route(request);  
  7. }  
  8. request.resolveFormat();  

Router.route(request); 根据请求的URL找到router中相应的action,并将action的名字赋值给request.action。

request.resolveFormat();此时request中format为html,如果request中format为null,则根据http头来取得相应的format。

往下走:

  1. // 2. Find the action method  
  2.  Method actionMethod = null;  
  3.  try {  
  4.      Object[] ca = getActionMethod(request.action);  
  5.      actionMethod = (Method) ca[1];  
  6.      request.controller = ((Class<?>) ca[0]).getName().substring(12);  
  7.      request.actionMethod = actionMethod.getName();  
  8.      request.action = request.controller + "." + request.actionMethod;  
  9.      request.invokedMethod = actionMethod;  
  10.  } catch (ActionNotFoundException e) {  
  11.      throw new NotFound(String.format("%s action not found", e.getAction()));  
  12.  } 

声明一个Method变量,供后面反射Invoke。

getActionMethod(request.action) 前面提到过了,通过request.action这个String得到存有application.index()方法相应Method对象的obj数组。

得到Method对象(ca[1],ca[0]存放的是对应controllers.Application的Class对象)后,将request对象中与Action相关的成员变量赋值。

此处:request.controller值为Application,request.actionMethod值为index,后面两个变量,一个是照前两个拼出来的action,另一个传入的是Method对象。

继续:下面的代码为合并action用到的参数:

  1. // 3. Prepare request params  
  2. Scope.Params.current().__mergeWith(request.routeArgs);  
  3. // add parameters from the URI query string   
  4. Scope.Params.current()._mergeWith(UrlEncodedParser.parseQueryString(new ByteArrayInputStream(request.querystring.getBytes("utf-8"))));  
  5. Lang.resolvefrom(request); 

routeArgs是在route中附加的http参数:

  1. /**  
  2.  * Additinal HTTP params extracted from route  
  3.  */ 
  4. public Map<String, String> routeArgs; 

除此之外还将QueryString中的参数也合并进来。

后面的Lang.resolvefrom(request)没仔细看实现,看Lang的包名中与i18n有关,这部分等以后专门看国际化的实现单独写吧(继续欠账)。

下面的代码,又看到雷人的片段了...

  1. // 4. Easy debugging ...  
  2. if (Play.mode == Play.Mode.DEV) {  
  3.     Controller.class.getDeclaredField("params").set(null, Scope.Params.current());  
  4.     Controller.class.getDeclaredField("request").set(null, Http.Request.current());  
  5.     Controller.class.getDeclaredField("response").set(null, Http.Response.current());  
  6.     Controller.class.getDeclaredField("session").set(null, Scope.Session.current());  
  7.     Controller.class.getDeclaredField("flash").set(null, Scope.Flash.current());  
  8.     Controller.class.getDeclaredField("renderArgs").set(null, Scope.RenderArgs.current());  
  9.     Controller.class.getDeclaredField("validation").set(null, Java.invokeStatic(Validation.class"current"));  

!!!!!!

Controller.class.getDeclaredField("xxx").set(null,xxx);

这里Play用反射的方式将Controller中受保护的静态变量强行赋值!!!

@

 如果之前将程序处理结果实现为运行时异常并在产生结果后直接抛出可以暂时理解为一种策略,那这种做法简直就是简单粗暴了,和我们读的各种经典书籍中的教诲大相径庭。

而且还是自己设置了变量的访问权限后又自己暴力破解赋值......

不过作者在注释里加了句// 4. Easy debugging ...,可能是对此做法无奈的解释吧。

让我不禁又想起那句"Java中的关键字只是给编译器看的"...

不过仔细想想,这可能也是开源项目的特色之一,如果在企业里写这种代码直接破坏框架,不知道老板的脸会怎么黑- -|||..

这里废话这么多,是因为我们学生在校时往往喜欢比较另类的代码,所以学弟学妹需要醒目一下:
仔细想想
我们初涉编程是不是很佩服算出i=3,k=(i++)+(++i)+(i++)的同学
我们是不是佩服过能看懂16层嵌套并且变量名没任何意义的if else
我们是不是对于能写出令人头晕的指针运算的同学无比崇敬过。

自己玩玩可以,但是以后工作,一份代码的生命期有可能是伴随着企业的生命期。
写一些另类代码有很多恶果,最明显的是难以维护。
至于类似修改私有变量的危险行为,虽然用起来比较cool,但是对于个人学习来说,不提倡,尽量不要给自己灌输用非正常途径解决问题的思想。不过这里,没说play不好的意思,只是对于咱们来说,学生阶段对语言的理解力掌控力还太差,需要不断深入学习,假以时日,能有技术方面的批判性思维,能把这些java特性用的恰到好处当然是好事。

又跑题了,回到主题....这部分是判断play的模式(play有两种运行模式DEV和实际运行模式,在config里文件配置切换),在开发模式下,直接将request,response和scope等赋值给Cotroller类相应的静态变量。

可能便于实际invoke控制器时访问这些值。

#遍历各个PlugIn看在action invoke前做些动作。

  1. ControllerInstrumentation.stopActionCall();  
  2. for (PlayPlugin plugin : Play.plugins) {  
  3.     plugin.beforeActionInvocation(actionMethod);  

ControllerInstrumentation这个类的作用是对allow这个标志位进行操作,allow是个ThreadLocal<Boolean>,对其set值则将其引用存入当前Thread内,换句话说,其实是对Thread做了标记。

  1. public static class ControllerInstrumentation {  
  2.        
  3.      public static boolean isActionCallAllowed() {  
  4.          return allow.get();  
  5.      }  
  6.        
  7.      public static void initActionCall() {  
  8.          allow.set(true);  
  9.      }  
  10.        
  11.      public static void stopActionCall() {  
  12.          allow.set(false);  
  13.      }  
  14.        
  15.      static ThreadLocal<Boolean> allow = new ThreadLocal<Boolean>();         
  16.        
  17.  } 

beforeActionInvocation方法则是在action前Plugin做的事情,这里我看了一下都是空的实现。

#打开monitor

  1. // Monitoring  
  2. monitor = MonitorFactory.start(request.action + "()"); 

#找到标记@Before Annotation的方法,并先于action invoke执行。

  1. // 5. Invoke the action  
  2. try {  
  3.     // @Before  
  4.     List<Method> befores = Java.findAllAnnotatedMethods(Controller.getControllerClass(), Before.class);  
  5.     Collections.sort(befores, new Comparator<Method>() {  
  6.  
  7.         public int compare(Method m1, Method m2) {  
  8.             Before before1 = m1.getAnnotation(Before.class);  
  9.             Before before2 = m2.getAnnotation(Before.class);  
  10.             return before1.priority() - before2.priority();  
  11.         }  
  12.     });  

Controller.getControllerClass()方法返回class controllers.Application。

Java.findAllAnnotatedMethods()找到所有带有@Before Annotation的方法。

再根据各个方法的优先级,来对befores中的Method排序。

此处实现比较器用了匿名内部类,按Before的priority进行排序。

  1. @Retention(RetentionPolicy.RUNTIME)  
  2. @Target(ElementType.METHOD)  
  3. public @interface Before {  
  4.       
  5.     /**  
  6.      * Does not intercept these actions  
  7.      */ 
  8.     String[] unless() default {};  
  9.       
  10.     /**  
  11.      * Interceptor priority (0 is high priority)  
  12.      */ 
  13.     int priority() default 0;  
  14.       

Annotation Before除了成员变量priority外,还有一个String数组变量unless,存放的是action的名字,表示不拦截这些action。

看看这部分的实现。

  1. ControllerInstrumentation.stopActionCall();  
  2.    //遍历包含Before Annotation的方法  
  3. for (Method before : befores) {  
  4.        //取出当前Before action的unless数组  
  5.     String[] unless = before.getAnnotation(Before.class).unless();  
  6.        //设置标志位  
  7.     boolean skip = false;  
  8.        //遍历unless数组  
  9.     for (String un : unless) {  
  10.         if (!un.contains(".")) {  
  11.             un = before.getDeclaringClass().getName().substring(12) + "." + un;  
  12.         }  
  13.           //如果unless与当前被调用的action名字相同,标志位skip设为true,退出循环  
  14.         if (un.equals(request.action)) {  
  15.             skip = true;  
  16.             break;  
  17.         }  
  18.     }  
  19.       //如果skip为false,调用before方法  
  20.     if (!skip) {  
  21.            //加个保护,判断被调用方法是否为静态,因为下面用到得是invokeStatic..  
  22.         if (Modifier.isStatic(before.getModifiers())) {  
  23.             before.setAccessible(true);  
  24.             Java.invokeStatic(before, getActionMethodArgs(before));  
  25.         }  
  26.     }  

通过Before拦截器后,再往下就是我们前面看到的实际执行Action的地方:

  1. //声明一个Result变量用来保存方法调用的结构  
  2. Result actionResult = null;  
  3.       //与之前stopActionCall()相反,这里调用initActionCall()将allow设为true,意思是允许此线程invoke方法  
  4.       ControllerInstrumentation.initActionCall();  
  5.       try {  
  6.           //invoke action  
  7.           Java.invokeStatic(actionMethod, getActionMethodArgs(actionMethod));  
  8.       } catch (InvocationTargetException ex) {  
  9.           // It's a Result ? (expected)    
  10.           if (ex.getTargetException() instanceof Result) {  
  11.               //得到调用action后返回的Result  
  12.               actionResult = (Result) ex.getTargetException();  
  13.               //else部分本例未涉及,先跳过不管  
  14.           } else {  
  15.           ..... 

执行完action,下面的代码部分是After拦截器,和Before基本一致,不赘述。

随后将monitor关闭。

之后...继续将返回结果往上扔。

  1. // Ok, rethrow the original action result  
  2. if (actionResult != null) {  
  3.     throw actionResult;  
  4. }  
  5.  
  6. throw new NoResult(); 
  1. catch (InvocationTargetException ex) {    
  2.                 // It's a Result ? (expected)    
  3.                 if (ex.getTargetException() instanceof Result) {    
  4.                     throw (Result) ex.getTargetException();    
  5.                 }    
  6.                 // Rethrow the enclosed exception    
  7.                 if (ex.getTargetException() instanceof PlayException) {    
  8.                     throw (PlayException) ex.getTargetException();    
  9.                 }    
  10.                 StackTraceElement element = PlayException.getInterestingStrackTraceElement(ex.getTargetException());    
  11.                 if (element != null) {    
  12.                     throw new JavaExecutionException(Play.classes.getApplicationClass(element.getClassName()), element.getLineNumber(), ex.getTargetException());    
  13.                 }    
  14.                 throw new JavaExecutionException(Http.Request.current().action, ex);    
  15.             }   

一直扔到invoke方法的第一个try..catch块。

  1. public static void invoke(Http.Request request, Http.Response response) {  
  2.      Monitor monitor = null;  
  3.      try {  
  4.          .......  
  5.      }catch (Result result) {  
  6.          //遍历执行plugin的onActionInvocationResult()方法,对结果进行处理  
  7.          for (PlayPlugin plugin : Play.plugins) {  
  8.              plugin.onActionInvocationResult(result);  
  9.          }  
  10.  
  11.          // Ok there is a result to apply  
  12.          // Save session & flash scope now  
  13.  
  14.          Scope.Session.current().save();  
  15.          Scope.Flash.current().save();  
  16.          //相应结果的apply方法,此处result实际是RenderTemplate对象,它的apply方法最终的HTML输出  
  17.          result.apply(request, response);  
  18.  
  19.          //这里可见Plugin的功能是非常灵活的,因为几乎在action生命期的每阶段都出现,其实到后面可以发现PlugIn几乎随处可见,否则怎么能叫做框架的插件呢= =#  
  20.          for (PlayPlugin plugin : Play.plugins) {  
  21.              plugin.afterActionInvocation();  
  22.          } 

最后看看RenderTemplate类

  1. public class RenderTemplate extends Result {  
  2.       
  3.     private Template template;  
  4.     private String content;  
  5.     Map<String,Object> args;  
  6.       
  7.     public RenderTemplate(Template template, Map<String,Object> args) {  
  8.         this.template = template;  
  9.         this.args = args;  
  10.         this.content = template.render(args);  
  11.     }  
  12.  
  13.     //apply方法是在invoke方法截获Result后,确认其是需要的返回结果后,调用的结果最终执行代码  
  14.     public void apply(Request request, Response response) {  
  15.         try {  
  16.             setContentTypeIfNotSet(response, MimeTypes.getContentType(template.name, "text/plain"));  
  17.             response.out.write(content.getBytes("utf-8"));  
  18.         } catch(Exception e) {  
  19.             throw new UnexpectedException(e);  
  20.         }  
  21.     }  
  22.  

执行完结果代码后,来到Invoke方法的结尾处,仍处于catch块,即找到@final的方法并执行。

  1. // @Finally  
  2.    //这个if判断不知道有什么意义,前面在get action的时候,就是找Application(Controller Class)的action方法,此处怎么会得到null呢,等以后理解加深再解释吧。  
  3.    if (Controller.getControllerClass() != null) {  
  4.        try {  
  5.            List<Method> allFinally = Java.findAllAnnotatedMethods(Controller.getControllerClass(), Finally.class);  
  6.            //后面略,与@before和@after同  
  7.            } 

Controller.getControllerClass()这个方法,涉及到Play的classloader,大概看了一下,比较复杂,等以后专门研究。

不过其中发现一些比较核心的与play热加载功能相关的代码,如下:

  1. byte[] bc = BytecodeCache.getBytecode(name, applicationClass.javaSource);  
  2.                if (bc != null) {  
  3.                    applicationClass.enhancedByteCode = bc;  
  4.                    applicationClass.javaClass = defineClass(applicationClass.name, applicationClass.enhancedByteCode, 0, applicationClass.enhancedByteCode.length, protectionDomain);  
  5.                    resolveClass(applicationClass.javaClass);  
  6.                    Logger.trace("%sms to load class %s from cache", System.currentTimeMillis() - start, name);  
  7.                    return applicationClass.javaClass;  
  8.                } 

这里大概能看出,play可以直接通过读java源代码来动态的生成java class.这应该与Play修改代码不需编译就能运行有关。

小结:到此处,从两个层次学习了Play框架的中处理和响应请求的模块。

最里面一层是Controller层,就是Application,这里放置的是Request最终invoke的action

往外一层是ActionInvoker,负责通过Http Request来判断需要调用的action,并执行调用,此外,还对action起拦截器作用分别在action的生命期的几个阶段Before,After和Finally阶段进行拦截并执行有相应Annotation的方法。除了上述两个作用,ActionInvoker还负责执行PlugIn。可以看出ActionInvoker的职责是控制action。

由此容易想到,ActionInvoker外面应该还有一层,负责实际获取客户端的HTTP Request,并转给ActionInvoker,是的,这个类就是HttpHandler,在下一篇我会详细分析。

画图表示从客户端的Request进入Play到Response返回并跳出Play的过程

呼,写了半天今天就写了这么短个小节,这么写下去不知写到猴年马月了,~~~~(>_<)~~~~

下一篇会重点分析HttpHandler源码,从而更加深刻理解此流程,这篇就到此为止。

原文链接:http://djb4ke.iteye.com/blog/662264

【编辑推荐】

  1. Play!Framework学习笔记:初识Play
  2. Play Framework总结性介绍
  3. 10分钟用Play!在GAE上实现一个Blog
  4. Play Framework 2.0 RC1发布 Java Web框架
  5. Play Framework介绍:使用Eclipse开发和调试
责任编辑:林师授 来源: djb4ke的博客
相关推荐

2012-02-22 17:23:51

JavaPlay Framew

2012-02-23 12:53:40

JavaPlay Framew

2012-02-23 13:13:00

JavaPlay Framew

2012-02-20 14:20:44

JavaPlay Framew

2012-03-14 09:29:00

Play framewJava

2012-02-20 14:26:48

JavaPlay Framew

2012-02-24 09:53:24

JavaPlay Framew

2012-02-23 13:48:16

JavaPlay Framew

2012-02-22 16:06:42

2012-03-14 12:29:55

JavaPlay Framwo

2012-02-24 10:57:43

2023-03-07 10:43:52

AndroidFramework测试

2012-02-20 14:41:30

JavaPlay Framew

2012-02-24 11:31:09

JavaPlay Framew

2012-02-29 10:54:21

JavaPlay Framew

2023-01-10 07:52:15

2011-09-09 17:01:42

框架

2009-07-22 10:34:37

ActionInvokASP.NET MVC

2016-09-20 10:26:25

LaravelPHPMiddleware

2012-02-22 15:51:22

JavaPlay Framew
点赞
收藏

51CTO技术栈公众号