SpringMVC完成初始化流程之后,就进入Servlet标准生命周期的第二个阶段,即“service”阶段。在“service”阶段中,每一次Http请求到来,容器都会启动一个请求线程,通过service()方法,委派到doGet()或者doPost()这些方法,完成Http请求的处理。

在初始化流程中,SpringMVC巧妙的运用依赖注入读取参数,并最终建立一个与容器上下文相关联的spring子上下文。这个子上下文,就像Struts2中xwork容器一样,为接下来的Http处理流程中各种编程元素提供了容身之所。如果说将Spring上下文关联到Servlet容器中,是SpringMVC框架的第一个亮点,那么在请求转发流程中,SpringMVC对各种处理环节编程元素的抽象,就是另外一个独具匠心的亮点。

Struts2采取的是一种完全和Web容器隔离和解耦的事件机制。诸如Action对象、Result对象、Interceptor对象,这些都是完全脱离Servlet容器的编程元素。Struts2将数据流和事件处理完全剥离开来,从Http请求中读取数据后,下面的事件处理流程就只依赖于这些数据,而完全不知道有Web环境的存在。

反观SpringMVC,无论HandlerMapping对象、HandlerAdapter对象还是View对象,这些核心的接口所定义的方法中,HttpServletRequest和HttpServletResponse对象都是直接作为方法的参数出现的。这也就意味着,框架的设计者,直接将SpringMVC框架和容器绑定到了一起。或者说,整个SpringMVC框架,都是依托着Servlet容器元素来设计的。下面就来看一下,源码中是如何体现这一点的。

1.请求转发的入口

就像任何一个注册在容器中的Servlet一样,DispatcherServlet也是通过自己的service()方法来接收和转发Http请求到具体的doGet()或doPost()这些方法的。以一次典型的GET请求为例,经过HttpServlet基类中service()方法的委派,请求会被转发到doGet()方法中。doGet()方法,在DispatcherServlet的父类FrameworkServlet类中被覆写。

  1. @Override
  2. protected final void doGet(HttpServletRequest request, HttpServletResponse response)
  3. throws ServletException, IOException {
  4. processRequest(request, response);
  5. }

可以看到,这里只是简单的转发到processRequest()这个方法。

  1. protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
  2. throws ServletException, IOException {
  3. long startTime = System.currentTimeMillis();
  4. Throwable failureCause = null;
  5. // Expose current LocaleResolver and request as LocaleContext.
  6. LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
  7. LocaleContextHolder.setLocaleContext(buildLocaleContext(request), this.threadContextInheritable);
  8. // Expose current RequestAttributes to current thread.
  9. RequestAttributes previousRequestAttributes = RequestContextHolder.getRequestAttributes();
  10. ServletRequestAttributes requestAttributes = null;
  11. if (previousRequestAttributes == null || previousRequestAttributes.getClass().equals(ServletRequestAttributes.class)) {
  12. requestAttributes = new ServletRequestAttributes(request);
  13. RequestContextHolder.setRequestAttributes(requestAttributes, this.threadContextInheritable);
  14. }
  15. if (logger.isTraceEnabled()) {
  16. logger.trace("Bound request context to thread: " + request);
  17. }
  18. try {
  19. doService(request, response);
  20. }
  21. catch (ServletException ex) {
  22. failureCause = ex;
  23. throw ex;
  24. }
  25. catch (IOException ex) {
  26. failureCause = ex;
  27. throw ex;
  28. }
  29. catch (Throwable ex) {
  30. failureCause = ex;
  31. throw new NestedServletException("Request processing failed", ex);
  32. }
  33. finally {
  34. // Clear request attributes and reset thread-bound context.
  35. LocaleContextHolder.setLocaleContext(previousLocaleContext, this.threadContextInheritable);
  36. if (requestAttributes != null) {
  37. RequestContextHolder.setRequestAttributes(previousRequestAttributes, this.threadContextInheritable);
  38. requestAttributes.requestCompleted();
  39. }
  40. if (logger.isTraceEnabled()) {
  41. logger.trace("Cleared thread-bound request context: " + request);
  42. }
  43. if (logger.isDebugEnabled()) {
  44. if (failureCause != null) {
  45. this.logger.debug("Could not complete request", failureCause);
  46. }
  47. else {
  48. this.logger.debug("Successfully completed request");
  49. }
  50. }
  51. if (this.publishEvents) {
  52. // Whether or not we succeeded, publish an event.
  53. long processingTime = System.currentTimeMillis() - startTime;
  54. this.webApplicationContext.publishEvent(
  55. new ServletRequestHandledEvent(this,
  56. request.getRequestURI(), request.getRemoteAddr(),
  57. request.getMethod(), getServletConfig().getServletName(),
  58. WebUtils.getSessionId(request), getUsernameForRequest(request),
  59. processingTime, failureCause));
  60. }
  61. }
  62. }

代码有点长,理解的要点是以doService()方法为区隔,前一部分是将当前请求的Locale对象和属性,分别设置到LocaleContextHolder和RequestContextHolder这两个抽象类中的ThreadLocal对象中,也就是分别将这两个东西和请求线程做了绑定。在doService()处理结束后,再恢复回请求前的LocaleContextHolder和RequestContextHolder,也即解除线程绑定。每次请求处理结束后,容器上下文都发布了一个ServletRequestHandledEvent事件,你可以注册监听器来监听该事件。

可以看到,processRequest()方法只是做了一些线程安全的隔离,真正的请求处理,发生在doService()方法中。点开FrameworkServlet类中的doService()方法。

  1. protected abstract void doService(HttpServletRequest request, HttpServletResponse response)
  2. throws Exception;

又是一个抽象方法,这也是SpringMVC类设计中的惯用伎俩:父类抽象处理流程,子类给予具体的实现。真正的实现是在DispatcherServlet类中。

让我们接着看DispatcherServlet类中实现的doService()方法。

  1. @Override
  2. protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
  3. if (logger.isDebugEnabled()) {
  4. String requestUri = urlPathHelper.getRequestUri(request);
  5. logger.debug("DispatcherServlet with name '" + getServletName() + "' processing " + request.getMethod() +
  6. " request for [" + requestUri + "]");
  7. }
  8. // Keep a snapshot of the request attributes in case of an include,
  9. // to be able to restore the original attributes after the include.
  10. Map<String, Object> attributesSnapshot = null;
  11. if (WebUtils.isIncludeRequest(request)) {
  12. logger.debug("Taking snapshot of request attributes before include");
  13. attributesSnapshot = new HashMap<String, Object>();
  14. Enumeration<?> attrNames = request.getAttributeNames();
  15. while (attrNames.hasMoreElements()) {
  16. String attrName = (String) attrNames.nextElement();
  17. if (this.cleanupAfterInclude || attrName.startsWith("org.springframework.web.servlet")) {
  18. attributesSnapshot.put(attrName, request.getAttribute(attrName));
  19. }
  20. }
  21. }
  22. // Make framework objects available to handlers and view objects.
  23. request.setAttribute(WEB_APPLICATION_CONTEXT_ATTRIBUTE, getWebApplicationContext());
  24. request.setAttribute(LOCALE_RESOLVER_ATTRIBUTE, this.localeResolver);
  25. request.setAttribute(THEME_RESOLVER_ATTRIBUTE, this.themeResolver);
  26. request.setAttribute(THEME_SOURCE_ATTRIBUTE, getThemeSource());
  27. FlashMap inputFlashMap = this.flashMapManager.retrieveAndUpdate(request, response);
  28. if (inputFlashMap != null) {
  29. request.setAttribute(INPUT_FLASH_MAP_ATTRIBUTE, Collections.unmodifiableMap(inputFlashMap));
  30. }
  31. request.setAttribute(OUTPUT_FLASH_MAP_ATTRIBUTE, new FlashMap());
  32. request.setAttribute(FLASH_MAP_MANAGER_ATTRIBUTE, this.flashMapManager);
  33. try {
  34. doDispatch(request, response);
  35. }
  36. finally {
  37. // Restore the original attribute snapshot, in case of an include.
  38. if (attributesSnapshot != null) {
  39. restoreAttributesAfterInclude(request, attributesSnapshot);
  40. }
  41. }
  42. }

几个requet.setAttribute()方法的调用,将前面在初始化流程中实例化的对象设置到http请求的属性中,供下一步处理使用,其中有容器的上下文对象、本地化解析器等SpringMVC特有的编程元素。不同于Struts2中的ValueStack,SpringMVC的数据并没有从HttpServletRequest对象中抽离出来再存进另外一个编程元素,这也跟SpringMVC的设计思想有关。因为从一开始,SpringMVC的设计者就认为,不应该将请求处理过程和Web容器完全隔离。

所以,你可以看到,真正发生请求转发的方法doDispatch()中,它的参数是HttpServletRequest和HttpServletResponse对象。这给我们传递的意思也很明确,从request中能获取到一切请求的数据,从response中,我们又可以往服务器端输出任何响应,Http请求的处理,就应该围绕这两个对象来设计。我们不妨可以将SpringMVC这种设计方案,是从Struts2的过度设计中吸取教训,而向Servlet编程的一种回归和简化。

2.请求转发的抽象描述

接下来让我们看看doDispatch()这个整个请求转发流程中最核心的方法。DispatcherServlet所接收的Http请求,经过层层转发,最终都是汇总到这个方法中来进行最后的请求分发和处理。doDispatch()这个方法的内容,就是SpringMVC整个框架的精华所在。它通过高度抽象的接口,描述出了一个MVC(Model-View-Controller)设计模式的实现方案。Model、View、Controller三种层次的编程元素,在SpringMVC中都有大量的实现类,各种处理细节也是千差万别。但是,它们最后都是由,也都能由doDispatch()方法来统一描述,这就是接口和抽象的威力,万变不离其宗。

先来看一下doDispatch()方法的庐山真面目。

  1. protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
  2. HttpServletRequest processedRequest = request;
  3. HandlerExecutionChain mappedHandler = null;
  4. int interceptorIndex = -1;
  5. try {
  6. ModelAndView mv;
  7. boolean errorView = false;
  8. try {
  9. processedRequest = checkMultipart(request);
  10. // Determine handler for the current request.
  11. mappedHandler = getHandler(processedRequest, false);
  12. if (mappedHandler == null || mappedHandler.getHandler() == null) {
  13. noHandlerFound(processedRequest, response);
  14. return;
  15. }
  16. // Determine handler adapter for the current request.
  17. HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
  18. // Process last-modified header, if supported by the handler.
  19. String method = request.getMethod();
  20. boolean isGet = "GET".equals(method);
  21. if (isGet || "HEAD".equals(method)) {
  22. long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
  23. if (logger.isDebugEnabled()) {
  24. String requestUri = urlPathHelper.getRequestUri(request);
  25. logger.debug("Last-Modified value for [" + requestUri + "] is: " + lastModified);
  26. }
  27. if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
  28. return;
  29. }
  30. }
  31. // Apply preHandle methods of registered interceptors.
  32. HandlerInterceptor[] interceptors = mappedHandler.getInterceptors();
  33. if (interceptors != null) {
  34. for (int i = 0; i < interceptors.length; i++) {
  35. HandlerInterceptor interceptor = interceptors[i];
  36. if (!interceptor.preHandle(processedRequest, response, mappedHandler.getHandler())) {
  37. triggerAfterCompletion(mappedHandler, interceptorIndex, processedRequest, response, null);
  38. return;
  39. }
  40. interceptorIndex = i;
  41. }
  42. }
  43. // Actually invoke the handler.
  44. mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
  45. // Do we need view name translation?
  46. if (mv != null && !mv.hasView()) {
  47. mv.setViewName(getDefaultViewName(request));
  48. }
  49. // Apply postHandle methods of registered interceptors.
  50. if (interceptors != null) {
  51. for (int i = interceptors.length - 1; i >= 0; i--) {
  52. HandlerInterceptor interceptor = interceptors[i];
  53. interceptor.postHandle(processedRequest, response, mappedHandler.getHandler(), mv);
  54. }
  55. }
  56. }
  57. catch (ModelAndViewDefiningException ex) {
  58. logger.debug("ModelAndViewDefiningException encountered", ex);
  59. mv = ex.getModelAndView();
  60. }
  61. catch (Exception ex) {
  62. Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
  63. mv = processHandlerException(processedRequest, response, handler, ex);
  64. errorView = (mv != null);
  65. }
  66. // Did the handler return a view to render?
  67. if (mv != null && !mv.wasCleared()) {
  68. render(mv, processedRequest, response);
  69. if (errorView) {
  70. WebUtils.clearErrorRequestAttributes(request);
  71. }
  72. }
  73. else {
  74. if (logger.isDebugEnabled()) {
  75. logger.debug("Null ModelAndView returned to DispatcherServlet with name '" + getServletName() +
  76. "': assuming HandlerAdapter completed request handling");
  77. }
  78. }
  79. // Trigger after-completion for successful outcome.
  80. triggerAfterCompletion(mappedHandler, interceptorIndex, processedRequest, response, null);
  81. }
  82. catch (Exception ex) {
  83. // Trigger after-completion for thrown exception.
  84. triggerAfterCompletion(mappedHandler, interceptorIndex, processedRequest, response, ex);
  85. throw ex;
  86. }
  87. catch (Error err) {
  88. ServletException ex = new NestedServletException("Handler processing failed", err);
  89. // Trigger after-completion for thrown exception.
  90. triggerAfterCompletion(mappedHandler, interceptorIndex, processedRequest, response, ex);
  91. throw ex;
  92. }
  93. finally {
  94. // Clean up any resources used by a multipart request.
  95. if (processedRequest != request) {
  96. cleanupMultipart(processedRequest);
  97. }
  98. }
  99. }

真是千呼万唤始出来,犹抱琵琶半遮面。我们在第一篇《SpringMVC源码剖析(一)- 从抽象和接口说起》中所描述的各种编程元素,依次出现在该方法中。HandlerMapping、HandlerAdapter、View这些接口的设计,我们在第一篇中已经讲过。现在我们来重点关注一下HandlerExecutionChain这个对象。

从上面的代码中,很明显可以看出一条线索,整个方法是围绕着如何获取HandlerExecutionChain对象,执行HandlerExecutionChain对象得到相应的视图对象,再对视图进行渲染这条主线来展开的。HandlerExecutionChain对象显得异常重要。

因为Http请求要进入SpringMVC的处理体系,必须由HandlerMapping接口的实现类映射Http请求,得到一个封装后的HandlerExecutionChain对象。再由HandlerAdapter接口的实现类来处理这个HandlerExecutionChain对象所包装的处理对象,来得到最后渲染的视图对象。

视图对象是用ModelAndView对象来描述的,名字已经非常直白,就是数据和视图,其中的数据,由HttpServletRequest的属性得到,视图就是由HandlerExecutionChain封装的处理对象处理后得到。当然HandlerExecutionChain中的拦截器列表HandlerInterceptor,会在处理过程的前后依次被调用,为处理过程留下充足的扩展点。

所有的SpringMVC框架元素,都是围绕着HandlerExecutionChain这个执行链来发挥效用。我们来看看,HandlerExecutionChain类的代码。

  1. package org.springframework.web.servlet;
  2. import java.util.ArrayList;
  3. import java.util.Arrays;
  4. import java.util.List;
  5. import org.springframework.util.CollectionUtils;
  6. public class HandlerExecutionChain {
  7. private final Object handler;
  8. private HandlerInterceptor[] interceptors;
  9. private List<HandlerInterceptor> interceptorList;
  10. public HandlerExecutionChain(Object handler) {
  11. this(handler, null);
  12. }
  13. public HandlerExecutionChain(Object handler, HandlerInterceptor[] interceptors) {
  14. if (handler instanceof HandlerExecutionChain) {
  15. HandlerExecutionChain originalChain = (HandlerExecutionChain) handler;
  16. this.handler = originalChain.getHandler();
  17. this.interceptorList = new ArrayList<HandlerInterceptor>();
  18. CollectionUtils.mergeArrayIntoCollection(originalChain.getInterceptors(), this.interceptorList);
  19. CollectionUtils.mergeArrayIntoCollection(interceptors, this.interceptorList);
  20. }
  21. else {
  22. this.handler = handler;
  23. this.interceptors = interceptors;
  24. }
  25. }
  26. public Object getHandler() {
  27. return this.handler;
  28. }
  29. public void addInterceptor(HandlerInterceptor interceptor) {
  30. initInterceptorList();
  31. this.interceptorList.add(interceptor);
  32. }
  33. public void addInterceptors(HandlerInterceptor[] interceptors) {
  34. if (interceptors != null) {
  35. initInterceptorList();
  36. this.interceptorList.addAll(Arrays.asList(interceptors));
  37. }
  38. }
  39. private void initInterceptorList() {
  40. if (this.interceptorList == null) {
  41. this.interceptorList = new ArrayList<HandlerInterceptor>();
  42. }
  43. if (this.interceptors != null) {
  44. this.interceptorList.addAll(Arrays.asList(this.interceptors));
  45. this.interceptors = null;
  46. }
  47. }
  48. public HandlerInterceptor[] getInterceptors() {
  49. if (this.interceptors == null && this.interceptorList != null) {
  50. this.interceptors = this.interceptorList.toArray(new HandlerInterceptor[this.interceptorList.size()]);
  51. }
  52. return this.interceptors;
  53. }
  54. @Override
  55. public String toString() {
  56. if (this.handler == null) {
  57. return "HandlerExecutionChain with no handler";
  58. }
  59. StringBuilder sb = new StringBuilder();
  60. sb.append("HandlerExecutionChain with handler [").append(this.handler).append("]");
  61. if (!CollectionUtils.isEmpty(this.interceptorList)) {
  62. sb.append(" and ").append(this.interceptorList.size()).append(" interceptor");
  63. if (this.interceptorList.size() > 1) {
  64. sb.append("s");
  65. }
  66. }
  67. return sb.toString();
  68. }
  69. }

一个拦截器列表,一个执行对象,这个类的内容十分的简单,它蕴含的设计思想,却十分的丰富。

1.拦截器组成的列表,在执行对象被调用的前后,会依次执行。这里可以看成是一个的AOP环绕通知,拦截器可以对处理对象随心所欲的进行处理和增强。这里明显是吸收了Struts2中拦截器的设计思想。这种AOP环绕式的扩展点设计,也几乎成为所有框架必备的内容。

2.实际的处理对象,即handler对象,是由Object对象来引用的。

  1. private final Object handler;

之所以要用一个Java世界最基础的Object对象引用来引用这个handler对象,是因为连特定的接口也不希望绑定在这个handler对象上,从而使handler对象具有最大程度的选择性和灵活性。

我们常说,一个框架最高层次的抽象是接口,但是这里SpringMVC更进了一步。在最后的处理对象上面,SpringMVC没有对它做任何的限制,只要是java世界中的对象,都可以用来作为最后的处理对象,来生成视图。极端一点来说,你甚至可以将另外一个MVC框架集成到SpringMVC中来,也就是为什么SpringMVC官方文档中,居然还有集成其他表现层框架的内容。这一点,在所有表现层框架中,是独领风骚,冠绝群雄的。

3.结语

SpringMVC的成功,源于它对开闭原则的运用和遵守。也正因此,才使得整个框架具有如此强大的描述和扩展能力。这也许和SpringMVC出现和兴起的时间有关,正是经历了Struts1到Struts2这些Web开发领域MVC框架的更新换代,它的设计者才能站在前人的肩膀上。知道了如何将事情做的糟糕之后,你或许才知道如何将事情做得好。

希望在这个系列里面分享的SpringMVC源码阅读经验,能帮助读者们从更高的层次来审视SpringMVC框架的设计,也希望这里所描述的一些基本设计思想,能在你更深入的了解SpringMVC的细节时,对你有帮助。哲学才是唯一的、最终的武器,在一个框架的设计上,尤其是如此。经常地体会一个框架设计者的设计思想,对你更好的使用它,是有莫大的益处的。

SpringMVC源码剖析(四)- DispatcherServlet请求转发的实现的更多相关文章

  1. SpringMVC源码剖析(二)- DispatcherServlet的前世今生

    上一篇文章<SpringMVC源码剖析(一)- 从抽象和接口说起>中,我介绍了一次典型的SpringMVC请求处理过程中,相继粉墨登场的各种核心类和接口.我刻意忽略了源码中的处理细节,只列 ...

  2. SpringMVC源码剖析1——执行流程

    SpringMVC源码剖析1——执行流程 00.SpringMVC执行流程file:///C:/Users/WANGGA~1/AppData/Local/Temp/enhtmlclip/Image.p ...

  3. SpringMVC源码情操陶冶-DispatcherServlet

    本文对springmvc核心类DispatcherServlet作下简单的向导,方便博主与读者查阅 DispatcherServlet-继承关系 分析DispatcherServlet的继承关系以及主 ...

  4. SpringMVC源码情操陶冶-DispatcherServlet父类简析

    阅读源码有助于陶冶情操,本文对springmvc作个简单的向导 springmvc-web.xml配置 <servlet> <servlet-name>dispatch< ...

  5. SpringMVC源码情操陶冶-DispatcherServlet类简析(一)

    阅读源码有利于陶冶情操,此文承接前文SpringMVC源码情操陶冶-DispatcherServlet父类简析 注意:springmvc初始化其他内容,其对应的配置文件已被加载至beanFactory ...

  6. SpringMVC源码情操陶冶-DispatcherServlet简析(二)

    承接前文SpringMVC源码情操陶冶-DispatcherServlet类简析(一),主要讲述初始化的操作,本文将简单介绍springmvc如何处理请求 DispatcherServlet#doDi ...

  7. Django Rest Framework源码剖析(四)-----API版本

    一.简介 在我们给外部提供的API中,可会存在多个版本,不同的版本可能对应的功能不同,所以这时候版本使用就显得尤为重要,django rest framework也为我们提供了多种版本使用方法. 二. ...

  8. SpringMVC源码剖析(五)-消息转换器HttpMessageConverter

    原文链接:https://my.oschina.net/lichhao/blog/172562 #概述 在SpringMVC中,可以使用@RequestBody和@ResponseBody两个注解,分 ...

  9. SpringMVC源码剖析5:消息转换器HttpMessageConverter与@ResponseBody注解

    转自 SpringMVC关于json.xml自动转换的原理研究[附带源码分析] 本系列文章首发于我的个人博客:https://h2pl.github.io/ 欢迎阅览我的CSDN专栏:Spring源码 ...

随机推荐

  1. stl 迭代器(了解)

    STL 主要是由 containers(容器),iterators(迭代器)和 algorithms(算法)的 templates(模板)构成的. 对应于它们所支持的操作,共有五种 iterators ...

  2. PostgreSQL中美元符号引用的字符串常量

    虽然用于指定字符串常量的标准语法通常都很方便,但是当字符串中包含了很多单引号或反斜线时很难理解它,因为每一个都需要被双写.要在这种情形下允许可读性更好的查询,PostgreSQL提供了另一种被称为“美 ...

  3. 反射自动填充model

    public static T FillModel<T>(DataRow dr) { ) return default(T); T model = Activator.CreateInst ...

  4. iOS 关于文件的操作

    最近做东西,遇到了使用文件方面的问题,花了点时间把文件研究了一下! 一  关于文件路径的生成 我用的方法是: -(NSString*)dataFilePath { NSArray * paths = ...

  5. java.util.zip

    使用java自带的类 java.util.zip进行文件/目录的压缩的话,有一点不足,不支持中文的名件/目录命名,如果有中文名,那么打包就会失败.本人经过一段时间的摸索和实践,发现在一般的Ant.ja ...

  6. hdu1045 Fire Net---二进制枚举子集

    题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=1045 题目大意: 给你一幅n*n的图,再给你一些点,这些点的上下左右不能再放其他点,除非有墙('X') ...

  7. [UE4]Menu Anchor,菜单锚点

    一.想要弹出某个菜单的时候,Menu Anchor可以做为菜单弹出的位置. 二.Menu Anchor本身不显示任何东西 三.Menu Class:选择要弹出的UI,可以是任意的UserWidget ...

  8. [Spark][Python]Spark 访问 mysql , 生成 dataframe 的例子:

    [Spark][Python]Spark 访问 mysql , 生成 dataframe 的例子: mydf001=sqlContext.read.format("jdbc").o ...

  9. noah

    1.url:controller/method 2.在index.php中设置display_errors:1 能看到错误提示

  10. plsql 常用快捷键(自动替换)

      plsql 常用快捷键 CreateTime--2018年4月23日17:33:05 Author:Marydon 说明:这里的快捷键,不同于以往的快捷键,输入指定字符,按快捷键,可以自动替换成你 ...