0.说明
本博客记录的是如何显示SpringMVC框架中所有使用@RequestMapping注解标注的方法.
由于项目需要,web框架使用SpringMVC.前端\客户端\后端是分开的不同组的人,所以不可避免的要编写\更新大量的接口说明文档.这大大降低了效率,因此实现了显示SpringMVC中所有接受请求的方法的信息的功能.
总体思想:从Spring容器中找到所有加了RequestMapping注解的方法,并且集中显示.
1.用处
显示SpringMVC中所有加了@RequestMapping注解的方法信息
2.实现
2.0.环境说明
使用maven开发,别的不多说了.直接上pom
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${org.springframework.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>${org.springframework.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>${org.springframework.version}</version> <scope>test</scope> </dependency>
其中
<org.springframework.version>3.2.13.RELEASE</org.springframework.version>
底层的spring core之类的也是3.2.13.RELEASE
2.1.显示所有请求信息
细心的同学可能发现了.在springMVC项目启动的时候,会出现很多的信息.
其中,和咱们这个文章相关的就是这个类输出的信息了:
2015-05-18 12:18:47,253 INFO org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - Mapped "{[/guide/display/search],methods=[GET],params=[],headers=[],consumes=[],produces=[application/json;charset=UTF-8],custom=[]}" onto public java.lang.String com.renren.toro.waltz.web.controller.guide.DisplayController.search() 2015-05-18 12:18:47,253 INFO org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - Mapped "{[/guide/display],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public org.springframework.web.servlet.ModelAndView com.renren.toro.waltz.web.controller.guide.DisplayController.index() 2015-05-18 12:18:47,253 INFO org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - Mapped "{[/guide/display/detail],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public org.springframework.web.servlet.ModelAndView com.renren.toro.waltz.web.controller.guide.DisplayController.detail(int) 2015-05-18 12:18:47,255 INFO org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - Mapped "{[/logout],methods=[GET],params=[],headers=[],consumes=[],produces=[application/json;charset=UTF-8],custom=[]}" onto public java.lang.String com.renren.toro.waltz.web.controller.LogoutController.logout(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
还有很多,只是截取了一段.
这个RequestMappingHandlerMapping 类会输出所有的Map信息.
下面介绍信息中的数据
这里以这一条输出作为实例:
2015-05-18 12:18:47,255 INFO org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - Mapped "{[/logout],methods=[GET],params=[],headers=[],consumes=[],produces=[application/json;charset=UTF-8],custom=[]}" onto public java.lang.String com.renren.toro.waltz.web.controller.LogoutController.logout(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
序号 | 名称 | 说明 |
1 | /logout | 对应的接受请求的url |
2 | methods | 接受请求的方法 |
3 | params | 请求的参数 |
4 | headers | 请求的header信息 |
5 | consumes | 接受请求的类型 |
6 | produces | 返回的数据类型 |
后边会输出对应的类和方法信息:
onto public java.lang.String com.renren.toro.waltz.web.controller.LogoutController.logout(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
因此从这里入手.
我这里使用了annotation-driven,所以可以直接
@Autowired private RequestMappingHandlerMapping requestMappingHandlerMapping;
这样就可以在一个handler方法中获取所有的添加了@RequestMapping的方法了.
具体如下:
1 // request methods 2 Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods(); 3 Set<RequestMappingInfo> keySet = handlerMethods.keySet(); 4 5 for (RequestMappingInfo requestMappingInfo : keySet) { 6 // 请求路径 7 String path = requestMappingInfo.getPatternsCondition().toString(); 8 9 // 请求方法 10 String requestMethod = requestMappingInfo.getMethodsCondition().toString(); 11 18 // 返回header类型 19 String responseType = requestMappingInfo.getProducesCondition().toString(); 20 21 RequestMethodItem item = new RequestMethodItem(); 22 23 item.setPath(path);26 item.setMethod(handlerMethod.toString().replace(" ", "<br>")); 27 item.setResponseType(responseType); 28 29 items.add(item); 30 }
这里需要写一个Item对象来存储这些信息,方便一起"打包"传给jsp页面,方便显示,由于只是一个POJO,就不帖代码了.
2.2.显示请求所对应的handler方法信息
可以使用requestMappingInfo中的getMethodsCondition()方法获取Controller类
从Map<RequestMappingInfo, HandlerMethod>这个Map中,使用对应的RequestMappingInfo作为key到这个map中查找对应的HandlerMethod
我们来具体看一下这个类:
HandlerMethod
/** * Encapsulates information about a handler method consisting of a {@linkplain #getMethod() method} * and a {@linkplain #getBean() bean}. Provides convenient access to method parameters, * method return value, method annotations. * * <p>The class may be created with a bean instance or with a bean name (e.g. lazy-init bean, * prototype bean). Use {@link #createWithResolvedBean()} to obtain a {@link HandlerMethod} * instance with a bean instance resolved through the associated {@link BeanFactory}. * * @author Arjen Poutsma * @author Rossen Stoyanchev * @since 3.1 */
一个包含了getMethod和getBean的类.
里边有用的方法:
getMethodParameters() 获得参数列表
通过这个方法,可以获得这个方法所对应的参数信息.该方法返回的是:MethodParameter[]
2.3.显示handler方法的参数信息
遍历2.2中说到的MethodParameter[] 可以获得具体的每一个参数的信息
使用其中的方法:
getParameterName(); 参数名
getParameterType(); 参数类型
getParameterAnnotations(); 参数注解
一切似乎就这样美妙的完成了.但是,学习过java class 规范的同学应该记得,javac在编译的时候,会抹去参数名称信息;
那么,getParameterName()获取到的会是什么呢?
看注释,果然如我所料:
注意returns描述的括号中的内容.
有可能是null,如果参数名称没有在class文件中,或者在开始时没有设置ParameterNameDiscoverer.
查看javac -help
输出如下:
果断加上-g参数再进行一次编译
在maven中这个参数应该加在org.apache.maven.plugins插件配置中
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <compilerArguments> <!-- show all debug info --> <g /> </compilerArguments> </configuration> </plugin>
在complierArguments中添加一个<g/>
那么,第一个问题解决了,参数名称信息已经包含在了class文件中了.
第二个问题,怎么在开始的时候指定ParameterNameDiscoverer?
查遍google stackoverflow无果...
spring doc也翻了翻,没找到,无奈,只能debug.查调用方.
思路如下,在接受参数的时候,如果不加@RequestParam注解,spring也能实现参数注入.一顿折腾.发现spring直接在spring-core中使用了asm的代码来操作字节码.最终完成了读取class文件中对应方法的的参数名
而初始化ParameterNameDiscoverer的代码在org.springframework.web.method.support.InvocableHandlerMethod类中找到了使用的方式.
直接声明了一个本地变量,使用LocalVariableTableParameterNameDiscoverer,实现类;然后调用initParameterNameDiscovery方法完成了初始化.
仔细看过LocalVariableTableParameterNameDiscoverer里边有很多虚拟机内缓存的实践,有大量的map结构用于存储数据,进而减少字节码操作.
鉴于这里只要springmvc容器启动,就不会出现class文件变动的情况,所以在本地变量中加上了static.
至此,就完成了显示所有@RequestMapping的信息的工作.
2.4.扩展功能
但是,由于只知道了对应的handlerMethod的参数,类,请求方式等信息,还是会出现可能的歧义,不够明确的指出接口的作用和意义.
所以定义了一个注解
@Inherited @Target({ ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface WaltzDocument { /** * 注释内容,推荐使用html标签进行编辑<br> * 2015年5月7日:下午4:27:30<br> * <br> * * @return */ String info(); /** * 返回给页面的参数,推荐使用html标签进行编辑 <br> * 2015年5月16日:下午4:05:32<br> * <br> * * @return */ String[] params() default ""; /** * 接口提供者 <br> * 2015年5月7日:下午4:27:43<br> * <br> * * @return */ String author(); /** * 接口状态 <br> * 2015年5月7日:下午4:34:06<br> * <br> * * @return * <pre> * </pre> */ Status status(); public static enum Status { /** * 假接口 */ fake, /** * 开发 */ developing, /** * 开发完成 */ done, ; } }
在扫描对应的handlerMethod的同时,获取对应的注解:
handlerMethod.getMethodAnnotation(WaltzDocument.class);
之后把这个注解中对应的参数一同设置到Item中.方便显示.
同时还结合了javadoc的功能,可以直接跳转到具体方法的javadoc页面查看具体说明文档.
页面上,结合一下Bootstrap和datatables就可以完成分页\筛选\搜索\排序的功能,进一步方便使用.
最终效果:
3.思考
3.1.spring中"虚拟机内"缓存的使用
在spring中,有很多的"虚拟机内换存",所谓虚拟机内缓存,利用虚拟机内的内存空间来实现一些变量的存储,当调用查询方法的时候,先使用查询的key在对应的缓存map中进行查找,这样可以大大降低底层IO操作的次数和频度.像本文中提到的LocalVariableTableParameterNameDiscoverer类,下层的获取方法参数名的方式是使用asm处理字节码.使用ClassReader和LocalVariableTableParameterNameDiscoverer提供的内部类ParameterNameDiscoveringVisitor来实现字节码操作.
数据要求不可变:这种虚拟机内缓存,对数据也是有一定的要求的.要数据的key不可变.试想,调用put方法是的hash值是A0,而想要get时hash值却变成了A1,这样就不能正确的获取数据,反而会造成缓存map越来越大,map的命中率越来越低和不断的扩容.最终导致的是大量无用内存占用和性能下降,最差情况会到导致OOM.
缓存对象大小要求有限:这种虚拟机内缓存只是用于数量有限(放置于map中的对象小于内存大小要求)的情况,试想,如果是一个无限大小的数据集合要做缓存,那最终的结构就是OOM了.否则要考虑使用一些软连接,虚连接形式的对象声明来避免这个问题了.
3.2.java参数抹去
jvm在执行方法的时候,实际上是不需要知道参数的名称的,jvm关心的只是参数的类型.所以,javac抹去了所有的参数信息,替换成paramOfString0....
测试代码如下:
public class Test { public static void main( String[] mainArgs) { System.out.println("beenoisy"); } public static void test( int thisIsAIntArg, String thisIsAObjectArg) { System.out.println("Test.test()"); } }
javac Test.java编译出来的字节码:
而同样的代码,添加了-g参数,就会带上参数名称信息,相信这是对class文件压缩的一种体现,但是却某种程度上的削弱了jvm运行时的一些功能.
同样,java对集合类型的泛型也是采取抹去处理的.在thinking in java中也说过,java的泛型不是真正的泛型.估计也是类似的考量.
3.3.怎样提高前后端开发效率
尽量减少无用功.
代码是最好的文档,然文档跟着代码改变.
一体化的文档体系\bug追踪体系\需求分派体系对于效率提升十分重要.
当然,这个小工具还是开发开始阶段紧急弄出来的,很多地方没有很好的遵循规范,后期如果有时间,考虑做成一个通用工具来集成到spring中.
最后,附上完整代码:
Controller:
1 import java.util.Arrays; 2 import java.util.Collections; 3 import java.util.List; 4 import java.util.Map; 5 import java.util.Set; 6 7 import org.springframework.beans.factory.annotation.Autowired; 8 import org.springframework.core.LocalVariableTableParameterNameDiscoverer; 9 import org.springframework.core.MethodParameter; 10 import org.springframework.stereotype.Controller; 11 import org.springframework.web.bind.annotation.RequestMapping; 12 import org.springframework.web.method.HandlerMethod; 13 import org.springframework.web.servlet.ModelAndView; 14 import org.springframework.web.servlet.mvc.method.RequestMappingInfo; 15 import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; 16 17 import com.google.common.collect.Lists; 18 import com.renren.toro.waltz.common.annotation.WaltzDocument; 19 import com.renren.toro.waltz.common.annotation.WaltzDocument.Status; 20 import com.renren.toro.waltz.common.interceptor.performance.CountTime; 21 import com.renren.toro.waltz.dev.controller.support.RequestMethodItem; 22 import com.renren.toro.waltz.dev.controller.support.RequestMethodParameter; 23 24 /** 25 * 显示所有请求 <br> 26 * 2015年5月6日:下午5:03:51 27 * 28 * @author Keen <br> 29 */ 30 @Controller 31 @RequestMapping("dev/param") 32 public class ParamController { 33 @Autowired 34 private RequestMappingHandlerMapping requestMappingHandlerMapping; 35 36 private static LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer(); 37 38 @RequestMapping("") 39 @WaltzDocument(info = "参数显示页面", author = "ziyi.wang", status = Status.done) 40 @CountTime 41 public ModelAndView exe() { 42 // items 43 List<RequestMethodItem> items = Lists.newArrayList(); 44 45 // request methods 46 Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods(); 47 Set<RequestMappingInfo> keySet = handlerMethods.keySet(); 48 49 for (RequestMappingInfo requestMappingInfo : keySet) { 50 // 请求路径 51 String path = requestMappingInfo.getPatternsCondition().toString(); 52 53 // 请求方法 54 String requestMethod = requestMappingInfo.getMethodsCondition().toString(); 55 56 // Controller的处理方法 57 HandlerMethod handlerMethod = handlerMethods.get(requestMappingInfo); 58 59 // 参数 60 MethodParameter[] methodParameters = handlerMethod.getMethodParameters(); 61 62 // 返回header类型 63 String responseType = requestMappingInfo.getProducesCondition().toString(); 64 65 List<RequestMethodParameter> parameters = Lists.newArrayListWithExpectedSize(methodParameters.length); 66 for (MethodParameter methodParameter : methodParameters) { 67 // 参数名称 68 // 如果没有discover参数会是null.参考LocalVariableTableParameterNameDiscoverer 69 methodParameter.initParameterNameDiscovery(discoverer); 70 String parameterName = methodParameter.getParameterName(); 71 72 // 参数类型 73 Class<?> parameterType = methodParameter.getParameterType(); 74 75 // 参数注解 76 Object[] parameterAnnotations = methodParameter.getParameterAnnotations(); 77 78 // 注解 79 String annoation = Arrays.toString(parameterAnnotations); 80 81 RequestMethodParameter parameter = new RequestMethodParameter(); 82 parameter.setAnnoation(annoation); 83 parameter.setName(parameterName); 84 parameter.setType(parameterType.toString()); 85 parameters.add(parameter); 86 } 87 88 WaltzDocument documentAnnotation = handlerMethod.getMethodAnnotation(WaltzDocument.class); 89 90 RequestMethodItem item = new RequestMethodItem(); 91 92 item.setPath(path); 93 item.setRequestMethod(requestMethod); 94 item.setParameters(parameters); 95 item.setMethod(handlerMethod.toString().replace(" ", "<br>")); 96 item.setDocument(documentAnnotation); 97 item.setResponseType(responseType); 98 99 items.add(item); 100 } 101 102 Collections.sort(items); 103 104 ModelAndView mav = new ModelAndView("dev/param"); 105 mav.addObject("items", items); 106 return mav; 107 } 108 }
POJO:
1 import java.util.List; 2 3 import com.renren.toro.waltz.common.annotation.WaltzDocument; 4 5 /** 6 * 条目 <br> 7 * 2015年5月7日:下午3:26:15 8 * 9 * @author Keen <br> 10 */ 11 public class RequestMethodItem implements Comparable<RequestMethodItem> { 12 private String path; 13 private String requestMethod; 14 private String method; 15 private String responseType; 16 private List<RequestMethodParameter> parameters; 17 private WaltzDocument document; 18 19 public String getPath() { 20 return path.replace("[", "").replace("]", ""); 21 } 22 23 public void setPath( 24 String path) { 25 this.path = path; 26 } 27 28 public String getRequestMethod() { 29 return requestMethod; 30 } 31 32 public void setRequestMethod( 33 String requestMethod) { 34 this.requestMethod = requestMethod; 35 } 36 37 public String getMethod() { 38 String a = getA(); 39 return method + a; 40 } 41 42 private String getA() { 43 String methodName = method.split("<br>")[2]; 44 45 String classPath = methodName.split("\\(")[0]; 46 47 String[] packageSplit = classPath.split("\\."); 48 StringBuilder sb = new StringBuilder(); 49 for (int i = 0; i < packageSplit.length; i++) { 50 sb.append(packageSplit[i]); 51 if (i < packageSplit.length - 1) { 52 if (i == packageSplit.length - 2) { 53 sb.append(".html#"); 54 } else { 55 sb.append("/"); 56 } 57 } 58 } 59 60 sb.append("("); 61 String methodPath = methodName.split("\\(")[1]; 62 sb.append(methodPath.replace(",", ", ")); 63 64 String href = sb.toString(); 65 String a = "<br>\n<a href='http://svn.d.xiaonei.com/toro/waltz/renren-toro-waltz-web/trunk/doc/" + href 66 + "' target='_blank'>详细接口文档</a>"; 67 return a; 68 } 69 70 public void setMethod( 71 String method) { 72 this.method = method; 73 } 74 75 public List<RequestMethodParameter> getParameters() { 76 return parameters; 77 } 78 79 public void setParameters( 80 List<RequestMethodParameter> parameters) { 81 this.parameters = parameters; 82 } 83 84 public WaltzDocument getDocument() { 85 return document; 86 } 87 88 public void setDocument( 89 WaltzDocument document) { 90 this.document = document; 91 } 92 93 public String getResponseType() { 94 return responseType; 95 } 96 97 public void setResponseType( 98 String responseType) { 99 this.responseType = responseType; 100 } 101 102 public String getResponseParams() { 103 WaltzDocument document = this.getDocument(); 104 if (document == null) { 105 return ""; 106 } 107 String[] params = document.params(); 108 StringBuilder sb = new StringBuilder(); 109 for (int i = 0; i < params.length; i++) { 110 sb.append(params[i]).append("<hr>\n"); 111 } 112 113 return sb.toString(); 114 } 115 116 @Override 117 public String toString() { 118 return "Item [path=" + path + ", requestMethod=" + requestMethod + ", method=" + method + ", responseType=" 119 + responseType + ", parameters=" + parameters + ", document=" + document + "]"; 120 } 121 122 @Override 123 public int compareTo( 124 RequestMethodItem o) { 125 return this.getPath().compareTo(o.getPath()); 126 } 127 }
1 /** 2 * 参数 <br> 3 * 2015年5月7日:下午3:26:31 4 * 5 * @author Keen <br> 6 */ 7 public class RequestMethodParameter { 8 private String name; 9 private String annoation; 10 private String type; 11 12 public String getName() { 13 return name; 14 } 15 16 public void setName( 17 String name) { 18 this.name = name; 19 } 20 21 public String getAnnoation() { 22 return annoation; 23 } 24 25 public void setAnnoation( 26 String annoation) { 27 this.annoation = annoation; 28 } 29 30 public String getType() { 31 return type; 32 } 33 34 public void setType( 35 String type) { 36 this.type = type; 37 } 38 39 @Override 40 public String toString() { 41 return "Parameter [name=" + name + ", annoation=" + annoation + ", type=" + type + "]"; 42 } 43 }
jsp
1 <%@ page language="java" pageEncoding="UTF-8"%> 2 <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%> 3 <%@ page import="java.util.Arrays"%> 4 <html> 5 <head> 6 <title>Waltz 接口查看页</title> 7 8 <!-- 新 Bootstrap 核心 CSS 文件 --> 9 <link rel="stylesheet" href="http://cdn.bootcss.com/bootstrap/3.3.4/css/bootstrap.min.css"> 10 11 <!-- 可选的Bootstrap主题文件(一般不用引入) --> 12 <link rel="stylesheet" href="http://cdn.bootcss.com/bootstrap/3.3.4/css/bootstrap-theme.min.css"> 13 14 <!-- jQuery文件。务必在bootstrap.min.js 之前引入 --> 15 <script src="http://cdn.bootcss.com/jquery/1.11.2/jquery.min.js"></script> 16 17 <!-- 最新的 Bootstrap 核心 JavaScript 文件 --> 18 <script src="http://cdn.bootcss.com/bootstrap/3.3.4/js/bootstrap.min.js"></script> 19 20 21 <!-- datatables --> 22 <script type="text/javascript" charset="utf8" src="http://cdn.datatables.net/1.10.7/js/jquery.dataTables.min.js"></script> 23 <script type="text/javascript" charset="utf8" src="http://cdn.datatables.net/plug-ins/1.10.7/integration/bootstrap/3/dataTables.bootstrap.js"></script> 24 25 26 </head> 27 <body> 28 <table id="paramTable" class="table table-striped table-hover table-bordered table-condensed display"> 29 <thead> 30 <tr> 31 <th>No.</th> 32 <th>docment</th> 33 <th>author</th> 34 <th>status</th> 35 <th>path</th> 36 <th>requestMethod</th> 37 <th>responseType</th> 38 <th>responseParams</th> 39 <th>controllerMethod</th> 40 <th>parameters</th> 41 </tr> 42 43 </thead> 44 <tbody> 45 <c:forEach items="${items}" var="i" varStatus="x"> 46 <tr> 47 <td>${x.count}</td> 48 <td>${i.document.info()}</td> 49 <td>${i.document.author()}</td> 50 <td>${i.document.status()}</td> 51 <td>${i.path}<br><a href='http://xxx.com${i.path}' target='_blank'>跳转查看</a></td> 52 <td>${i.requestMethod}</td> 53 <td>${i.responseType}</td> 54 <td>${i.responseParams}</td> 55 <td>${i.method}</td> 56 <td> 57 <c:if test="${i.parameters.size() > 0 }"> 58 <table class="table table-striped table-hover table-bordered table-condensed"> 59 <thead> 60 <tr> 61 <th>parameterName</th> 62 <th>parameterType</th> 63 <th>parameterAnnotations</th> 64 </tr> 65 </thead> 66 <tbody> 67 <c:forEach items="${i.parameters}" var="p"> 68 <tr> 69 <td>${p.name}</td> 70 <td>${p.type}</td> 71 <td>${p.annoation}</td> 72 </tr> 73 </c:forEach> 74 </tbody> 75 </table> 76 </c:if> 77 </td> 78 </tr> 79 </c:forEach> 80 </tbody> 81 <tfoot> 82 <tr> 83 <th>No.</th> 84 <th>docment</th> 85 <th>author</th> 86 <th>status</th> 87 <th>path</th> 88 <th>requestMethod</th> 89 <th>responseType</th> 90 <th>responseParams</th> 91 <th>controllerMethod</th> 92 <th>parameters</th> 93 </tr> 94 </tfoot> 95 </table> 96 97 <script type="text/javascript"> 98 $(document).ready(function() { 99 // Setup - add a text input to each footer cell 100 $('#paramTable tfoot th').each( function () { 101 var title = $('#paramTable thead th').eq( $(this).index() ).text(); 102 $(this).html( '<input type="text" placeholder="Search '+title+'" />' ); 103 }); 104 105 // DataTable 106 var table = $('#paramTable').DataTable({ 107 paging: false 108 }); 109 110 // Apply the search 111 table.columns().eq(0).each(function(colIdx){ 112 $('input', table.column(colIdx).footer()).on('keyup change',function () { 113 table.column( colIdx ).search( this.value ).draw(); 114 }); 115 }); 116 }); 117 </script> 118 119 </body> 120 </html>