Hello Java

 

2006年7月4日

一个用于J2EE应用程序的异常处理框架(转载)

转自:http://dev2dev.bea.com.cn/techdoc/20060601803.html
一个用于J2EE应用程序的异常处理框架


时间:2006-06-01
作者:ShriKant Vashishtha
浏览次数: 1184
本文关键字:J2EEStrutsRuntimeExceptiondesign patterns最佳实践异常处理包装设计模式外观模板方法
文章工具
推荐给朋友 推荐给朋友
打印文章 打印文章

  在大多数Java项目中,大部分代码都是样板代码。异常处理就属于此类代码。即使业务逻辑只有3到4行代码,用于异常处理的代码也要占10到 20行。本文将讨论如何让异常处理保持简单和直观,使开发人员可以专心于开发业务逻辑,而不是把时间浪费在编写异常处理的样板代码上。本文还将说明用于在 J2EE环境中创建和处理异常的基础知识和指导原则,并提出了一些可以使用异常解决的业务问题。本文将使用Struts框架作为表示实现,但该方法适用于任何表示实现。

使用checked和unchecked异常的场景

  您是否曾经想过,为什么要在编写好的代码块周围放置一个try-catch块,即便明知道无法对这些异常进行什么处理,而只满足于把它们放在 catch块中?您可能想知道,为什么不能把这项工作放在一个集中的地方完成?在大多数情况下,这个地方对于J2EE应用程序来说就是一个前端控制器。 换句话说,开发人员不会因为它们而受到干扰,因为根本不必很多地过问它们。但是,如果一个方法名称包含一个throws子句,会出现什么情况呢?开发人员 或者必须捕捉这些异常,或者把它们放在自己的方法的throws子句中。这就是痛苦的根源!幸运的是,Java API有一类叫做unchecked exception的异常,它们不必捕捉。但是,仍然存在一个问题:根据什么来决定哪些是checked异常,哪些是unchecked异常?下面给出一 些指导原则:

  • 终端用户无法采取有效操作的异常应该作为unchecked异常。例如,致命的和不可恢复的异常就应该 是unchecked。把XMLParseException(在解析XML文件时抛出)作为checked异常没有任何意义,因为惟一能够采取的措施就 是基于异常跟踪来解决根本问题。通过扩展java.lang.RuntimeException,可以创建自定义的unchecked异常。
  • 在 应用程序中,与用户操作相关的异常应该是checked异常。checked异常要求客户端来捕捉它们。您可能会问,为什么不把所有异常都当作是 unchecked。这样做的问题在于,其中一些异常无法在正确的位置被捕捉到。这会带来更大的问题,因为错误只有在运行时才能被识别。checked异 常的例子有业务确认异常、安全性异常等等。

异常抛出策略

只捕捉基本应用程序异常(假定为BaseAppException)并在throws子句中声明

  在大多数J2EE应用程序中,关于针对某个异常应该在哪一界面上显示哪条错误消息的决策只能在表示层中做出。这会带来另一个问题:为什么我们不能把这种决策放在一个公共的地方呢?在J2EE应用程序中,前端控制器就是一个进行常见处理的集中位置。

   此外,必须有一种用于传播异常的通用机制。异常也需要以一种普适的方式得到处理。为此,我们始终需要在控制器端捕捉基本应用程序异常 BaseAppException。这意味着我们需要把BaseAppException异常(只有这个异常)放入可以抛出checked异常的每个方法 的throws子句中。这里的概念是使用多态来隐藏异常的实际实现。我们在控制器中捕捉BaseAppException,但是所抛出的特定异常实例可能 是几个派生异常类中的任意一个。借助于这种方法,可以获得许多异常处理方面的灵活性:

  • 不需要在throws子句中放入大量的checked异常。throws子句中只需要有一个异常。
  • 不需要再对应用程序异常使用混乱的catch块。如果需要处理它们,一个catch块(用于BaseAppException)就足够了。
  • 开发人员不需要亲自进行异常处理(日志记录以及获取错误代码)。这种抽象是由ExceptionHandler完成的,稍后本文会就此点进行讨论。
  • 即使稍后把更多异常引入到方法实现中,方法名称也不会改变,因此也不需要修改客户端代码,否则就会引起连锁反应。然而,抛出的异常需要在方法的Javadoc中指定,以便让客户端可以看到方法约束。

  下面给出抛出checked异常的一个例子:

public void updateUser(UserDTO userDTO) 
throws BaseAppException{
UserDAO userDAO = new UserDAO();
UserDAO.updateUser(userDTO);
...
if(...)
throw new RegionNotActiveException(
"Selected region is not active");
}

Controller Method:
...
try{
User user = new User();
user.updateUser(userDTO);
}catch(BaseAppException ex){
//ExceptionHandler is used to handle
//all exceptions derived from BaseAppException
}
...

  迄今为止,我们已经说明,对于所有可能抛出checked异常并被Controller调用的方法,其throws子句中应该只包含 checked异常。然而,这实际上暗示着我们在throws子句中不能包含其他任何应用程序异常。但是,如果需要基于catch块中某种类型的异常来执 行业务逻辑,那又该怎么办呢?要处理这类情况,方法还可以抛出一个特定异常。记住,这是一种特例,开发人员绝对不能认为这是理所当然的。同样,此处讨论的 应用程序异常应该扩展BaseAppException类。下面给出一个例子:

CustomerDAO method:
//throws CustomerNotActiveException along with
//BaseAppException
public CustomerDTO getCustomer(InputDTO inputDTO)
throws BaseAppException,
CustomerNotActiveException {
. . .
//Make a DB call to fetch the customer
//details based on inputDTO
. . .
// if not details found
throw new CustomerNotActiveException(
"Customer is not active");
}

Client method:

//catch CustomerNotActiveException
//and continues its processing
public CustomerDTO getCustomerDetails(
UserDTO userDTO)
throws BaseAppException{
...
CustomerDTO custDTO = null;
try{
//Get customer details
//from local database
customerDAO.getCustomerFromLocalDB();
}catch(CustomerNotActiveException){
...
return customerDAO
.activateCustomerDetails();
}
}

在web应用程序层次上处理unchecked异常

  所有unchecked异常都应该在web应用程序层次上进行处理。可以在web.xml文件中配置web页面,以便当应用程序中出现unchecked异常时,可以把这个web页面显示给终端用户。

把第三方异常包装到特定于应用程序的异常中

  当一个异常起源于另一个外部接口(组件)时,应该把它包装到一个特定于应用程序的异常中,并进行相应处理。

  例子:

try {
BeanUtils.copyProperties(areaDTO, areaForm);
} catch (Exception se) {
throw new CopyPropertiesException(
"Exception occurred while using
copy properties", se);
}

  这里,CopyPropertiesException扩展了java.lang.RuntimeException,我们将会记录它。我们捕 捉的是Exception,而不是copyProperties方法可以抛出的特定checked异常,因为对于所有这些异常来说,我们都会抛出同一个 unchecked CopyPropertiesException异常。

过多异常

  您可能想知道,如果我们为每条错误消息创建一个异常,异常类自身是否会溢出呢?例如,如果“Order not found”是OrderNotFoundException的一条错误消息,您肯定不会让CustomerNotFoundException的错误消 息为“Customer not found”,理由很明显:这两个异常代表同样的意义,惟一的区别在于使用它们的上下文不同。所以,如果可以在处理异常时指定上下文,我们无疑可以把这些 异常合并为一个RecordNotFoundException。下面给出一个例子:

try{
...
}catch(BaseAppException ex){
IExceptionHandler eh =ExceptionHandlerFactory
.getInstance().create();
ExceptionDTO exDto = eh.handleException(
"employee.addEmployee", userId, ex);
}

  在这里,employee.addEmployee上下文将被附加给一个上下文敏感的异常的错误代码,从而产生惟一的错误代码。例如,如果 RecordNotFoundException的错误代码是errorcode.recordnotfound,那么这个上下文的最终错误代码将变为 errorcode.recordnotfound.employee.addEmployee,它对于这个上下文是惟一的错误代码。

   然而,我们要给出一个警告:如果您准备在同一个客户端方法中使用多个接口,而且这些接口都可以抛出RecordNotFoundException异常, 那么想要知道是哪个实体引发了这个异常就变得十分困难。如果业务接口是公共的,而且可以被各种外部客户端使用,那么建议只使用特定的异常,而不使用像 RecordNotFoundException这样的一般性异常。特定于上下文的异常对于基于数据库的可恢复异常来说非常有用,因为在这种情况下,异常 类始终是相同的,不同的只有它们出现的上下文。

J2EE应用程序的异常层次结构

  正如前面讨论的那样,我们需要定义一个异常基类,叫做BaseAppException,它包含了所有应用程序异常的默认行为。我们将把这个基 类放到所有可能抛出checked异常的方法的throws子句中。应用程序的所有checked异常都应该是这个基类的子类。有多种定义错误处理抽象的 方式。然而,其中的区别更多地是与业务类而不是与技术有关。对错误处理的抽象可分为以下几类。所有这些异常类都是从BaseAppException派生 而来。

checked异常

  • 业务异常:执行业务逻辑时出现的异常。BaseBusinessException是这类异常的基类。
  • 数据库异常:与持久化机制进行交互时抛出的异常。BaseDBException是这类异常的基类。
  • 安全性异常:执行安全性操作时出现的异常。这类异常的基类是BaseSecurityException。
  • 确认异常:在从终端用户处获得确认以执行某个特定任务时使用。这类异常的基类是BaseConfirmationException。

unchecked异常

  • 系统异常:有时候我们希望使用unchecked异常。例如下面的 情况:不想亲自处理来自第三方库API的异常,而是希望把它们包装在unchecked异常中,然后抛出给控制器。有时会出现配置问题,这些问题也不能由 客户端进行处理,而应该被当作unchecked异常。所有自定义的unchecked异常都应该扩展自 java.lang.RuntimeException类。

表示层上的异常处理

  表示层独自负责决定对一个异常采取什么操作。这种决策涉及到识别抛出异常的错误代码。此外,我们还需要知道在处理错误之后应该把错误消息重定向到哪一界面。

  我们需要对基于异常类型获得错误代码这个过程进行抽象。必要时还应该执行日志记录。我们把这种抽象称之为ExceptionHandler。它基于“四人帮”(Gang of Four,GOF) 外观模式(《Design Patterns》 一书中说,该模式是用于“为子系统中的一组接口提供一个统一接口。外观定义了一个更高级别的接口,使子系统变得更加易于使用。”),是用于处理所有派生自 BaseAppException的异常的整个异常处理系统的外观。下面给出一个在Struts Action方法中进行异常处理的例子:

try{ 
...
DivisionDTO storeDTO = divisionBusinessDelegate
.getDivisionByNum(fromDivisionNum);
}catch(BaseAppException ex){
IExceptionHandler eh = ExceptionHandlerFactory
.getInstance().create();
String expContext = "divisionAction.searchDivision";
ExceptionDTO exDto = eh.handleException(
expContext , userId, ex);
ActionErrors errors = new ActionErrors();
errors.add(ActionErrors.GLOBAL_ERROR
,new ActionError(
exDto.getMessageCode()));
saveErrors(request, errors);
return actionMapping.findForward(
"SearchAdjustmentPage");
}

  如果更仔细地观察我们刚刚编写的异常处理代码,您可能会意识到,为每个Struts方法编写的代码是十分相似的,这也是一个问题。我们的目标是尽可能地去掉样板代码。我们需要再次对它进行抽象。

  解决方案是使用模板方法(Template Method)设计模式(引自GOF:“它用于实现一个算法的不变部分,并把可变的算法部分留给子类来实现。”)。我们需要一个包含模板方法形式算法的基 类。该算法将包含用于BaseAppException的try-catch块和对dispatchMethod方法的调用,方法实现(委托给派生类)如 下面的基于Struts的Action中所示:

public abstract class BaseAppDispatchAction 
extends DispatchAction{
...
protected static ThreadLocal
expDisplayDetails = new ThreadLocal();

public ActionForward execute(
ActionMapping mapping,
ActionForm form,
HttpServletRequest request,
HttpServletResponse response) throws Exception{
...
try{
String actionMethod = request
.getParameter(mapping.getParameter());
finalDestination =dispatchMethod(mapping,
form, request, response,actionMethod);
}catch (BaseAppException Ex) {
ExceptionDisplayDTO expDTO =
(ExceptionDisplayDTO)expDisplayDetails
.get();
IExceptionHandler expHandler =
ExceptionHandlerFactory
.getInstance().create();
ExceptionDTO exDto = expHandler
.handleException(
expDTO.getContext(), userId, Ex);
ActionErrors errors = new ActionErrors();
errors.add(ActionErrors.GLOBAL_ERROR,
new ActionError(exDto
.getMessageCode()));
saveErrors(request, errors);
return mapping.findForward(expDTO
.getActionForwardName());
} catch(Throwable ex){
//log the throwable
//throw ex;
} finally {
expDisplayDetails.set(null);
}

  在Struts中,DispatchAction::dispatchMethod方法用于把请求转发给正确的Action方法,叫做actionMethod。

   我们假定从一个HTTP请求获得searchDivision作为actionMethod:dispatchMethod将在 BaseAppDispatchAction的派生Action类中把请求分派给searchDivision方法。在这里,您可以看到,异常处理仅在基 类中完成,而派生类则只实现Action方法。这采用了模板方法设计模式,在该模式中,异常处理部分是保持不变的,而dispatchMethod方法的 实际实现(可变部分)则交由派生类完成。

  修改后的Struts Action方法如下所示:

... 
String exceptionActionForward =
"SearchAdjustmentPage";
String exceptionContext =
"divisionAction.searchDivision";

ExceptionDisplayDTO expDTO =
new ExceptionDisplayDTO(expActionForward,
exceptionContext);
expDisplayDetails.set(expDTO);
...
DivisionDTO divisionDTO =divisionBusinessDelegate
.getDivisionByNum(fromDivisionNum);
...

  现在它看起来相当清晰。因为异常处理是在一个集中的位置上(BaseAppDispatchAction)完成的,手动错误可能造成的影响也降至最低。

  然而,我们需要设置异常上下文和ActionForward方法的名称,如果有异常出现,请求就会被转发到该方法。我们将在ThreadLocal变量expDisplayDetails中设置这些内容。

   但是,为什么要使用java.lang.ThreadLocal变量呢?expDisplayDetails是 BaseAppDispatchActiion类中的一个受保护数据成员,这也是它需要是线程安全的原因。java.lang.ThreadLocal对 象在这里便可派上用场。

异常处理程序

  在上一部分中,我们讨论了如何对异常处理进行抽象。下面给出一些应该满足的约束:

  • 识别异常类型并获得相应的错误代码,该错误代码可用于显示一条消息给终端用户。
  • 记录异常。底层的日志记录机制被隐藏,可以基于一些环境属性对其进行配置。

  您可能已经注意到了,我们在表示层中捕捉的惟一异常就是BaseAppException。由于所有checked异常都是 BaseAppException的子类,这意味着我们要捕捉BaseAppException的所有派生类。基于类名称来识别错误代码再容易不过了。

//exp is an object of BaseAppException
String className = exp.getClass().getName();

  可以基于异常类的名称在一个XML文件(exceptioninfo.xml)中对错误代码进行配置。下面给出异常配置的一个例子:

<exception name="EmployeeConfirmationException">
<messagecode>messagecode.laborconfirmation</messagecode>
<confirmationind>true</confirmationind>
<loggingtype>nologging</loggingtype>
</exception>

  正如您看到的那样,我们把这个异常变为显式,要使用的消息代码是messagecode.employeeconfirmation。然后,为了实现国际化的目的,可以从ResourceBundle提取实际的消息。我们很清楚,不需要对这类异常执行日志记录,因为它只是一条确认消息,而不是一个应用程序错误。

  让我们看一看上下文敏感异常的一个例子:

<exception name="RecordNotFoundException">
<messagecode>messagecode.recordnotfound</messagecode>
<confirmationind>false</confirmationind>
<contextind>true</contextind>
<loggingtype>error</loggingtype>
</exception>

  在这里,这个表达式的contextind为true。在handleException方法中传递的上下文可用于创建惟一的错误代码。例如, 如果我们把order.getOrder当作一个上下文进行传递,结果得到的消息代码就是异常的消息代码和所传递的上下文的串联。因此,我们获得了一个像 messagecode.recordnotfound.order.getOrder这样的惟一消息代码。

  对于每个异常来说,可以把 exceptioninfo.xml 中的数据封装到一个叫做ExceptionInfoDTO的数据传输对象(data transfer object,DTO)。现在,我们还需要一个占位符,用于缓存这些对象,因为我们不想在异常出现时反复解析XML文件和创建对象。这项工作可以委托给一 个叫做ExceptionInfoCache的类来完成,这个类将会在从exceptioninfo.xml文件读取ExceptionInfoDTO对 象信息之后缓存所有这些对象。

  现在您是否弄清楚了这整个过程?这种方法的核心部分是ExceptionHandler实现,该实现将使 用封装在ExceptionInfoDTO中的数据来获取消息代码,创建ExceptionDTO对象,然后基于在给定异常的 ExceptionInfoDTO中指定的日志记录类型来记录它。

  下面是ExceptionHandler实现的handleException方法:

public ExceptionDTO handleException(String userId,
BaseAppException exp) {
ExceptionDTO exDTO = new ExceptionDTO();
ExceptionInfoCache ecache =
ExceptionInfoCache.getInstance();
ExceptionInfo exInfo = ecache
.getExceptionInfo(
ExceptionHelper.getClassName(exp));
String loggingType = null;
if (exInfo != null) {
loggingType = exInfo.getLoggingType();
exDTO.setConfirmation(exInfo
.isConfirmation());
exDTO.setMessageCode(exInfo
.getMessageCode());
}

FileLogger logger = new FileLoggerFactory()
.create();
logger.logException(exp, loggingType);

  根据不同的业务需求,ExceptionHandler接口可以有多种实现。决定使用何种实现的任务可交由Factory来完成,特别是ExceptionHandlerFactory类。

结束语

  如果缺乏全面的异常处理策略,一些特殊的异常处理块便可能导致出现非标准的错误处理和不可维护的代码。通过使用上面的方法,便可简化J2EE应用程序中的异常处理过程。

参考资料

原文出处: http://www.onjava.com/pub/a/onjava/2006/01/11/exception-handling-framework-for-j2ee.html

 作者简介

ShriKant Vashishtha 当前是印度Tata Consultancy Services Limited (TCS)公司的解决方案架构师。

posted @ 2006-07-04 11:15 Hello Java 阅读(474) | 评论 (0)编辑 收藏

2006年6月14日

Servlet 2.4规范阅读笔记(2-3章)

第2章 The servlet Interface
所有Servlet实现同一接口:Servlet,它有两个直接的子类:GenericServlet和HttpServlet.
2.1 Request Handle method
基本的Servlet使用service()方法处理请求,因为可能有多个客户的请求在service()中工作,所以开发者必须要考虑到并发的情况。
SRV.2.1.1 HTTP Specific Request Handling Methods

HttpServlet定义了以下7个方法:

  • doGet for handling HTTP GET requests
  • doPost for handling HTTP POST requests
  • doPut for handling HTTP PUT requests
  • doDelete for handling HTTP DELETE requests
  • doHead for handling HTTP HEAD requests
  • doOptions for handling HTTP OPTIONS requests
  • doTrace for handling HTTP TRACE requests
SRV.2.1.2 Additional Methods
   
关于Http的几个附加命令HEAD,DELETE,PUT,OPTIONS,TRACE。
SRV.2.1.3 Conditional GET Support
这个”Conditional GET“指的是这样的请求:只有所请求的资源在一个特定时间之后被修改过,才被发送给客户端。
HttpServlet里的getLastModified()方法提供了这方面的支持。
SRV.2.2 Number of Instances
    在2.4之前的版本中,Servlet有两种方式:
    第一种方式是对于一个Servlet,只有一个实例,这个实例可以同时服务多个用户,这是默认的。
    另一种方式是一个Servlet的实例只能服务一个客户,这样就需要一个Servlet的Pool,当多个用户连接时,需要建立多个Servlet实例。这种方式需要Servlet实现SingleThreadModel接口。
    以上两种方式的比较:第一种方式需要考虑同步问题(因为一个Servlet的实例服务多个用户,Servlet中的数据成员需要同步),第二种方式不需要考虑同步问题。
   
SRV.2.3 Servlet Life Cycle
    1.载入:   
    应用服务器可能在启动时载入Servlet或者在第一个请求到来时延迟加载,载入的方式也是使用类装载器,和普通的Java Object没有什么区别。
    2.初始化:
    初始化时可能遇到错误,这时会抛出ServletException或者UnaviliableException异常
    3.多线程:
    需要注意的一点是,最好不要对service()方法进行synchronize修饰。这样的话就不能使用线程池,而必须对request序列化
    4.处理请求时的错误处理:
    如果在这时发生异常,可以发送UnavailableException或者ServletException,如果发送了 ServletException,服务器必须要想办法清除request。如果发送了UnavailableException,有两种选择,如果是永 久的的无效,需要调用destroy(),并摧毁Servlet,如果这时客户端访问此Servlet,它将会收到404错误。如果是临时的无效,容器需 要拒绝任何到此Servlet的请求,并且返回503错误
   5.线程安全的话题
    request和response这两个对象不是线程安全的,所以不要在service()方法外面使用它们,它们的引用不应该传给另一个线程中的对象,如果一定要访问它们,必须对使用这两个对象的代码进行同步。
    6.服务的结束
   Servlet容器并不需要一直保持一个Servlet处于运行状态,在释放一个servlet时,会调用destroy方法,当调用destroy方法之前,容器会一直等到所有的线程都完成了在service()方法中的工作。
第3章 Servlet Context
SRV.3.1 Introduction to the ServletContext Interface
Servlet Context,从Servlet的角度来看,可以理解为它所属Web应用程序,ServletContext是一个接口,Servlet容器的提供商必须要实现这个接口。
ServletContext能做什么呢?它可以把事件记录到日志中,获得资源的URL,还可以利用ServletContext存放一些所有Servlet都能共享的数据。
ServletContext有一个路径,例如http://www.mycorp.com/catalog,这里的cataqlog就是ServletContext的路径,所有的对catalog的请求都会关联到这个ServletContext.
SRV.3.2 Scope of a ServletContext Interface

在Servlet容器中,每个ServletContext通常只有一个实例。当Web容器是分布式的部署在多台机器上时,那一个ServletContext在每个JVM上都有一个实例。
在Servlet容器中,但没有部署的Servlet也是允许的,这种Servlet属于一个缺省的ServletContext,这种缺省的ServletContext不能被分布到多个JVM上。
SRV.3.3 Initialization Parameters
在ServletContext接口中,有两个方法可以用来获得初始化参数:
• getInitParameter
• getInitParameterNames
SRV.3.4 Context Attributes
在ServletContext中,有4个方法可以用来设置和管理ServletContext的属性:
• setAttribute
• getAttribute
• getAttributeNames
• removeAttribute
SRV.3.4.1 Context Attributes in a Distributed Container
ServletContext中的属性只在本地的JVM中有效,不能被分布式环境中运行于其它JVM中的Servlet访问,如果需要在分布式环境中共享数据,可以把数据存放在Session,数据库或EJB中。
SRV.3.5 Resources
资源指的是在Web应用程序中的一些静态的内容,如静态HTML页面,图片等等。ServletContext提供了两个方法来访问这些资源:
• getResource
• getResourceAsStream
这两个方法都接受一个String型参数,它指定了一个以"/"开头的相对于这个ServletContext的资源路径。资源可以放在同一服务器上,或者不同服务器上,或者在一个Web应用程序的WAR包中。
需要注意的是,这两个方法不能用来获取动态内容,如果我们用这两个方法去取一个JSP页面,返回的将是JSP页面的源代码。
getResourcePaths(String path)方法可以用来获取一个资源列表。
SRV.3.6 Multiple Hosts and Servlet Contexts
Web服务器可能支持多个域名分享一个IP地址,这种配置叫做“虚拟主机”。在这种情况下,每个虚拟主机必须要有自己的ServletContext,而不能共享一个ServletContext。
SRV.3.7 Reloading Considerations
所有的Servlet和它们引用的类必须都处于一个类装载器范围内。
SRV.3.7.1 Temporary Working Directories
每一个ServletContext都需要一个临时目录,并且通过 javax.servlet.context.tempdir属性指定。Servlet容器不需要管理这个临时路径的内容,但是要确保一个 ServletContext的临时目录对其他的ServletContext是不可见的。

posted @ 2006-06-14 23:43 Hello Java 阅读(1549) | 评论 (1)编辑 收藏

动态代理和AOP的一点学习心得

     摘要: 一、最初的设计 有一个经营一家在线商店的客户,在这个商店系统中,有一个供管理员使用的管理定单的类,代码如下: 1 、接口 Interface OrderService{             public boolean showOrders();// 察看定单 } 2、实...  阅读全文

posted @ 2006-06-14 20:38 Hello Java 阅读(507) | 评论 (1)编辑 收藏

仅列出标题  

导航

统计

常用链接

留言簿(1)

随笔分类

随笔档案

搜索

最新评论

阅读排行榜

评论排行榜