前言

在上一篇《Spring学习之——手写Spring源码(V1.0)》中,我实现了一个Mini版本的Spring框架,在这几天,博主又看了不少关于Spring源码解析的视频,受益匪浅,也对Spring的各组件有了自己的理解和认识,于是乎,在空闲时间把之前手写Spring的代码重构了一遍,遵循了单一职责的原则,使结构更清晰,并且实现了AOP,这次还是只引用一个servlet包,其他全部手写实现。

全部源码照旧放在文章末尾~

开发工具

环境:jdk8 + IDEA + maven

jar包:javax.servlet-2.5

项目结构

具体实现

配置文件

web.xml 与之前一样  并无改变

application.properties   增加了html页面路径和AOP的相关配置

#扫描路径#
scanPackage=com.wqfrw #模板引擎路径#
templateRoot=template #切面表达式#
pointCut=public .* com.wqfrw.service.impl..*ServiceImpl..*(.*)
#切面类#
aspectClass=com.wqfrw.aspect.LogAspect
#切面前置通知#
aspectBefore=before
#切面后置通知#
aspectAfter=after
#切面异常通知#
aspectAfterThrowing=afterThrowing
#切面异常类型#
aspectAfterThrowingName=java.lang.Exception

IOC与DI实现

1.在DispatcherServlet的init方法中初始化ApplicationContent;

2.ApplicationContent是Spring容器的主入口,通过创建BeanDefintionReader对象加载配置文件;

3.在BeanDefintionReader中将扫描到的类解析成BeanDefintion返回;

4.ApplicationContent中通过BeanDefintionMap这个缓存来关联BeanName与BeanDefintion对象之间的关系;

5.通过getBean方法,进行Bean的创建并封装为BeanWrapper对象,进行依赖注入,缓存到IoC容器中

  /**
* 功能描述: 初始化MyApplicationContext
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月03日 18:54:01
* @param configLocations
* @return:
**/
public MyApplicationContext(String... configLocations) {
this.configLocations = configLocations; try {
//1.读取配置文件并解析BeanDefinition对象
beanDefinitionReader = new MyBeanDefinitionReader(configLocations);
List<MyBeanDefinition> beanDefinitionList = beanDefinitionReader.loadBeanDefinitions(); //2.将解析后的BeanDefinition对象注册到beanDefinitionMap中
doRegisterBeanDefinition(beanDefinitionList); //3.触发创建对象的动作,调用getBean()方法(Spring默认是延时加载)
doCreateBean();
} catch (Exception e) {
e.printStackTrace();
}
}
    /**
* 功能描述: 真正触发IoC和DI的动作 1.创建Bean 2.依赖注入
*
* @param beanName
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月03日 19:48:58
* @return: java.lang.Object
**/
public Object getBean(String beanName) {
//============ 创建实例 ============ //1.获取配置信息,只要拿到beanDefinition对象即可
MyBeanDefinition beanDefinition = beanDefinitionMap.get(beanName); //用反射创建实例 这个实例有可能是代理对象 也有可能是原生对象 封装成BeanWrapper统一处理
Object instance = instantiateBean(beanName, beanDefinition);
MyBeanWrapper beanWrapper = new MyBeanWrapper(instance); factoryBeanInstanceCache.put(beanName, beanWrapper); //============ 依赖注入 ============
populateBean(beanName, beanDefinition, beanWrapper); return beanWrapper.getWrapperInstance();
}
  /**
* 功能描述: 依赖注入
*
* @param beanName
* @param beanDefinition
* @param beanWrapper
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月03日 20:09:01
* @return: void
**/
private void populateBean(String beanName, MyBeanDefinition beanDefinition, MyBeanWrapper beanWrapper) {
Object instance = beanWrapper.getWrapperInstance();
Class<?> clazz = beanWrapper.getWrapperClass(); //只有加了注解的类才需要依赖注入
if (!(clazz.isAnnotationPresent(MyController.class) || clazz.isAnnotationPresent(MyService.class))) {
return;
} //拿到bean所有的字段 包括private、public、protected、default
for (Field field : clazz.getDeclaredFields()) { //如果没加MyAutowired注解的属性则直接跳过
if (!field.isAnnotationPresent(MyAutowired.class)) {
continue;
} MyAutowired annotation = field.getAnnotation(MyAutowired.class);
String autowiredBeanName = annotation.value().trim();
if ("".equals(autowiredBeanName)) {
autowiredBeanName = field.getType().getName();
}
//强制访问
field.setAccessible(true);
try {
if (factoryBeanInstanceCache.get(autowiredBeanName) == null) { continue; }
//赋值
field.set(instance, this.factoryBeanInstanceCache.get(autowiredBeanName).getWrapperInstance());
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}

MVC实现

1.在DispatcherServlet的init方法中调用initStrategies方法初始化九大核心组件;

2.通过循环BeanDefintionMap拿到每个接口的url、实例对象、对应方法封装成一个HandlerMapping对象的集合,并建立HandlerMapping与HandlerAdapter(参数适配器)的关联;

3.初始化ViewResolver(视图解析器),解析配置文件中模板文件路径(即html文件的路径,其作用类似于BeanDefintionReader);

4.在运行阶段,调用doDispatch方法,根据请求的url找到对应的HandlerMapping;

5.在HandlerMapping对应的HandlerAdapter中,调用handle方法,进行参数动态赋值,反射调用接口方法,拿到返回值与返回页面封装成一个MyModelAndView对象返回;

6.通过ViewResolver拿到View(模板页面文件),在View中通过render方法,通过正则将返回值与页面取值符号进行适配替换,渲染成html页面返回

  /**
* 功能描述: 初始化核心组件 在Spring中有九大核心组件,这里只实现三种
*
* @param context
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月04日 11:51:55
* @return: void
**/
protected void initStrategies(MyApplicationContext context) {
//多文件上传组件
//initMultipartResolver(context);
//初始化本地语言环境
//initLocaleResolver(context);
//初始化模板处理器
//initThemeResolver(context);
//初始化请求分发处理器
initHandlerMappings(context);
//初始化参数适配器
initHandlerAdapters(context);
//初始化异常拦截器
//initHandlerExceptionResolvers(context);
//初始化视图预处理器
//initRequestToViewNameTranslator(context);
//初始化视图转换器
initViewResolvers(context);
//缓存管理器(值栈)
//initFlashMapManager(context);
}
  /**
* 功能描述: 进行参数适配
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 19:41:38
* @param req
* @param resp
* @param mappedHandler
* @return: com.framework.webmvc.servlet.MyModelAndView
**/
public MyModelAndView handle(HttpServletRequest req, HttpServletResponse resp, MyHandlerMapping mappedHandler) throws Exception { //保存参数的名称和位置
Map<String, Integer> paramIndexMapping = new HashMap<>(); //获取这个方法所有形参的注解 因一个参数可以添加多个注解 所以是一个二维数组
Annotation[][] pa = mappedHandler.getMethod().getParameterAnnotations(); /**
* 获取加了MyRequestParam注解的参数名和位置 放入到paramIndexMapping中
*/
for (int i = 0; i < pa.length; i++) {
for (Annotation annotation : pa[i]) {
if (!(annotation instanceof MyRequestParam)) {
continue;
}
String paramName = ((MyRequestParam) annotation).value();
if (!"".equals(paramName.trim())) {
paramIndexMapping.put(paramName, i);
}
}
} //方法的形参列表
Class<?>[] parameterTypes = mappedHandler.getMethod().getParameterTypes(); /**
* 获取request和response的位置(如果有的话) 放入到paramIndexMapping中
*/
for (int i = 0; i < parameterTypes.length; i++) {
Class<?> parameterType = parameterTypes[i];
if (parameterType == HttpServletRequest.class || parameterType == HttpServletResponse.class) {
paramIndexMapping.put(parameterType.getName(), i);
}
} //拿到一个请求所有传入的实际实参 因为一个url上可以多个相同的name,所以此Map的结构为一个name对应一个value[]
//例如:request中的参数t1=1&t1=2&t2=3形成的map结构:
//key=t1;value[0]=1,value[1]=2
//key=t2;value[0]=3
Map<String, String[]> paramsMap = req.getParameterMap(); //自定义初始实参列表(反射调用Controller方法时使用)
Object[] paramValues = new Object[parameterTypes.length]; /**
* 从paramIndexMapping中取出参数名与位置 动态赋值
*/
for (Map.Entry<String, String[]> entry : paramsMap.entrySet()) {
//拿到请求传入的实参
String value = entry.getValue()[0]; //如果包含url参数上的key 则动态转型赋值
if (paramIndexMapping.containsKey(entry.getKey())) {
//获取这个实参的位置
int index = paramIndexMapping.get(entry.getKey());
//动态转型并赋值
paramValues[index] = caseStringValue(value, parameterTypes[index]);
}
} /**
* request和response单独赋值
*/
if (paramIndexMapping.containsKey(HttpServletRequest.class.getName())) {
int index = paramIndexMapping.get(HttpServletRequest.class.getName());
paramValues[index] = req;
}
if (paramIndexMapping.containsKey(HttpServletResponse.class.getName())) {
int index = paramIndexMapping.get(HttpServletResponse.class.getName());
paramValues[index] = resp;
} //方法调用 拿到返回结果
Object result = mappedHandler.getMethod().invoke(mappedHandler.getController(), paramValues);
if (result == null || result instanceof Void) {
return null;
} else if (mappedHandler.getMethod().getReturnType() == MyModelAndView.class) {
return (MyModelAndView) result;
}
return null;
} /**
* 功能描述: 动态转型
*
* @param value String类型的value
* @param clazz 实际对象的class
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月04日 16:34:40
* @return: java.lang.Object 实际对象的实例
**/
private Object caseStringValue(String value, Class<?> clazz) throws Exception {
//通过class对象获取一个入参为String的构造方法 没有此方法则抛出异常
Constructor constructor = clazz.getConstructor(new Class[]{String.class});
//通过构造方法new一个实例返回
return constructor.newInstance(value);
}
    /**
* 功能描述: 对页面内容进行渲染
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月04日 17:54:40
* @param model
* @param req
* @param resp
* @return: void
**/
public void render(Map<String, ?> model, HttpServletRequest req, HttpServletResponse resp) throws Exception {
StringBuilder sb = new StringBuilder();
//只读模式 读取文件
RandomAccessFile ra = new RandomAccessFile(this.viewFile, "r"); String line = null;
while ((line = ra.readLine()) != null) {
line = new String(line.getBytes("ISO-8859-1"), "utf-8"); //%{name}
Pattern pattern = Pattern.compile("%\\{[^\\}]+\\}", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(line); while (matcher.find()) {
String paramName = matcher.group(); paramName = paramName.replaceAll("%\\{|\\}", "");
Object paramValue = model.get(paramName);
line = matcher.replaceFirst(makeStringForRegExp(paramValue.toString()));
matcher = pattern.matcher(line);
}
sb.append(line);
} resp.setCharacterEncoding("utf-8");
resp.getWriter().write(sb.toString());
}

html页面

404.html

<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<title>页面没有找到</title>
</head>
<body>
<font size="25" color="red">Exception Code : 404 Not Found</font>
<br><br><br>
@我恰芙蓉王
</body>
</html>

500.html

<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<title>服务器崩溃</title>
</head>
<body>
<font size="25" color="red">Exception Code : 500 <br/> 服务器崩溃了~</font>
<br/>
<br/>
<b>Message:%{message}</b>
<br/>
<b>StackTrace:%{stackTrace}</b>
<br/>
<br><br><br>
@我恰芙蓉王
</body>
</html>

index.html

<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<title>自定义SpringMVC模板引擎Demo</title>
</head>
<center>
<h1>大家好,我是%{name}</h1>
<h2>我爱%{food}</h2>
<font color="red">
<h2>时间:%{date}</h2>
</font>
<br><br><br>
@我恰芙蓉王
</center>
</html>

测试接口调用返回页面

404.html  接口未找到

500.html  服务器错误

index.html   正常返回页面

AOP实现

1.参照IOC与DI实现第五点,在对象实例化之后,依赖注入之前,将配置文件中AOP的配置解析至AopConfig中;

2.通过配置的pointCut参数,正则匹配此实例对象的类名与方法名,如果匹配上,将配置的三个通知方法(Advice)与此方法建立联系,生成一个  Map<Method, Map<String, MyAdvice>> methodCache  的缓存;

3.将原生对象、原生对象class、原生对象方法与通知方法的映射关系封装成AdviceSupport对象;

4.如果需要代理,则使用JdkDynamicAopProxy中getProxy方法,获得一个此原生对象的代理对象,并将原生对象覆盖;

5.JdkDynamicAopProxy实现了InvocationHandler接口(使用JDK的动态代理),重写invoke方法,在此方法中执行切面方法与原生对象方法。

  /**
* 功能描述: 反射实例化对象
*
* @param beanName
* @param beanDefinition
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月03日 20:08:50
* @return: java.lang.Object
**/
private Object instantiateBean(String beanName, MyBeanDefinition beanDefinition) {
String className = beanDefinition.getBeanClassName(); Object instance = null;
try {
Class<?> clazz = Class.forName(className);
instance = clazz.newInstance(); /**
* ===========接入AOP begin===========
*/
MyAdviceSupport support = instantiateAopConfig(beanDefinition);
support.setTargetClass(clazz);
support.setTarget(instance);
//如果需要代理 则用代理对象覆盖目标对象
if (support.pointCutMatch()) {
instance = new MyJdkDynamicAopProxy(support).getProxy();
}
/**
* ===========接入AOP end===========
*/ factoryBeanObjectCache.put(beanName, instance);
} catch (Exception e) {
e.printStackTrace();
}
return instance;
}
  /**
* 功能描述: 解析配置 pointCut
*
* @param
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 11:20:21
* @return: void
**/
private void parse() {
String pointCut = aopConfig.getPointCut()
.replaceAll("\\.", "\\\\.")
.replaceAll("\\\\.\\*", ".*")
.replaceAll("\\(", "\\\\(")
.replaceAll("\\)", "\\\\)"); //public .*.com.wqfrw.service..*impl..*(.*)
String pointCutForClassRegex = pointCut.substring(0, pointCut.lastIndexOf("\\(") - 4);
this.pointCutClassPattern = Pattern.compile(pointCutForClassRegex.substring(pointCutForClassRegex.lastIndexOf(" ") + 1)); methodCache = new HashMap<>();
//匹配方法的正则
Pattern pointCutPattern = Pattern.compile(pointCut); //1.对回调通知进行缓存
Map<String, Method> aspectMethods = new HashMap<>();
try {
//拿到切面类的class com.wqfrw.aspect.LogAspect
Class<?> aspectClass = Class.forName(this.aopConfig.getAspectClass());
//将切面类的通知方法缓存到aspectMethods
Stream.of(aspectClass.getMethods()).forEach(v -> aspectMethods.put(v.getName(), v)); //2.扫描目标类的方法,去循环匹配
for (Method method : targetClass.getMethods()) {
String methodString = method.toString();
//如果目标方法有抛出异常 则截取
if (methodString.contains("throws")) {
methodString = methodString.substring(0, methodString.lastIndexOf("throws")).trim();
}
/**
* 匹配目标类方法 如果匹配上,就将缓存好的通知与它建立联系 如果没匹配上,则忽略
*/
Matcher matcher = pointCutPattern.matcher(methodString);
if (matcher.matches()) {
Map<String, MyAdvice> adviceMap = new HashMap<>();
//前置通知
if (!(null == aopConfig.getAspectBefore() || "".equals(aopConfig.getAspectBefore()))) {
adviceMap.put("before", new MyAdvice(aspectClass.newInstance(), aspectMethods.get(aopConfig.getAspectBefore())));
} //后置通知
if (!(null == aopConfig.getAspectAfter() || "".equals(aopConfig.getAspectAfter()))) {
adviceMap.put("after", new MyAdvice(aspectClass.newInstance(), aspectMethods.get(aopConfig.getAspectAfter())));
} //异常通知
if (!(null == aopConfig.getAspectAfterThrowing() || "".equals(aopConfig.getAspectAfterThrowing()))) {
MyAdvice advice = new MyAdvice(aspectClass.newInstance(), aspectMethods.get(aopConfig.getAspectAfterThrowing()));
advice.setThrowingName(aopConfig.getAspectAfterThrowingName());
adviceMap.put("afterThrowing", advice);
}
//建立关联
methodCache.put(method, adviceMap);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
  /**
* 功能描述: 返回一个代理对象
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 14:17:22
* @param
* @return: java.lang.Object
**/
public Object getProxy() {
return Proxy.newProxyInstance(this.getClass().getClassLoader(), this.support.getTargetClass().getInterfaces(), this);
} /**
* 功能描述: 重写invoke
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 20:29:19
* @param proxy
* @param method
* @param args
* @return: java.lang.Object
**/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Map<String, MyAdvice> advices = support.getAdvices(method, support.getTargetClass()); Object result = null;
try {
//调用前置通知
invokeAdvice(advices.get("before")); //执行原生目标方法
result = method.invoke(support.getTarget(), args); //调用后置通知
invokeAdvice(advices.get("after"));
} catch (Exception e) {
//调用异常通知
invokeAdvice(advices.get("afterThrowing"));
throw e;
} return result;
} /**
* 功能描述: 执行切面方法
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 11:09:32
* @param advice
* @return: void
**/
private void invokeAdvice(MyAdvice advice) {
try {
advice.getAdviceMethod().invoke(advice.getAspect());
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
/**
* @ClassName LogAspect
* @Description TODO(切面类)
* @Author 我恰芙蓉王
* @Date 2020年08月05日 10:03
* @Version 2.0.0
**/ public class LogAspect { /**
* 功能描述: 前置通知
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 17:24:30
* @param
* @return: void
**/
public void before(){
System.err.println("=======前置通知=======");
} /**
* 功能描述: 后置通知
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 17:24:40
* @param
* @return: void
**/
public void after(){
System.err.println("=======后置通知=======\n");
} /**
* 功能描述: 异常通知
*
* @创建人: 我恰芙蓉王
* @创建时间: 2020年08月05日 17:24:47
* @param
* @return: void
**/
public void afterThrowing(){
System.err.println("=======出现异常=======");
}
}

执行结果

总结

以上只贴出了部分核心实现代码,有兴趣的童鞋可以下载源码调试,具体的注释我都在代码中写得很清楚。

代码已经提交至Git : https://github.com/wqfrw/HandWritingSpringV2.0

Spring学习之——手写Spring源码V2.0(实现IOC、D、MVC、AOP)的更多相关文章

  1. Spring学习之——手写Mini版Spring源码

    前言 Sping的生态圈已经非常大了,很多时候对Spring的理解都是在会用的阶段,想要理解其设计思想却无从下手.前些天看了某某学院的关于Spring学习的相关视频,有几篇讲到手写Spring源码,感 ...

  2. Spring源码 20 手写模拟源码

    参考源 https://www.bilibili.com/video/BV1tR4y1F75R?spm_id_from=333.337.search-card.all.click https://ww ...

  3. 手写Redux-Saga源码

    上一篇文章我们分析了Redux-Thunk的源码,可以看到他的代码非常简单,只是让dispatch可以处理函数类型的action,其作者也承认对于复杂场景,Redux-Thunk并不适用,还推荐了Re ...

  4. 手写koa-static源码,深入理解静态服务器原理

    这篇文章继续前面的Koa源码系列,这个系列已经有两篇文章了: 第一篇讲解了Koa的核心架构和源码:手写Koa.js源码 第二篇讲解了@koa/router的架构和源码:手写@koa/router源码 ...

  5. 手写Tomcat源码

    http://search.bilibili.com/all?keyword=%E6%89%8B%E5%86%99Tomcat%E6%BA%90%E7%A0%81 tomcat源码分析一:https: ...

  6. 织梦dedecms红黑配图片模板源码v2.0

    dedecms红黑配风格美女图片站是采用dedecms程序搭建的图片网站源码,网站感觉很大气,简约但是不简单,适合做图片网站.网站模板是收集其他网站的模板,感谢原网站提供者.在安装过程中出现问题,现已 ...

  7. 手写Vuex源码

    Vuex原理解析 Vuex是基于Vue的响应式原理基础,所以无法拿出来单独使用,必须在Vue的基础之上使用. 1.Vuex使用相关解析 main.js   import store form './s ...

  8. Spring系列28:@Transactional事务源码分析

    本文内容 @Transactional事务使用 @EnableTransactionManagement 详解 @Transactional事务属性的解析 TransactionInterceptor ...

  9. 从零开始手写 spring ioc 框架,深入学习 spring 源码

    IoC Ioc 是一款 spring ioc 核心功能简化实现版本,便于学习和理解原理. 创作目的 使用 spring 很长时间,对于 spring 使用非常频繁,实际上对于源码一直没有静下心来学习过 ...

随机推荐

  1. python面试题六: 剑指offer

    面试题3 二维数组中的查找 LeetCode题目:二维数组中,每行从左到右递增,每列从上到下递增,给出一个数,判断它是否在数组中思路:从左下角或者右上角开始比较 def find_integer(ma ...

  2. typeError:The value of a feed cannot be a tf.Tensor object.Acceptable feed values include Python scalars,strings,lists.numpy ndarrays,or TensorHandles.For reference.the tensor object was Tensor...

    如上贴出了:错误信息和错误代码. 这个问题困扰了自己两天,报错大概是说输入的数据和接受的格式不一样,不能作为tensor. 后来问了大神,原因出在tf.reshape(),因为网络训练时用placeh ...

  3. 关于PowerShell调用Linq的一组实验

    Windows PowerShell 版权所有 (C) Microsoft Corporation.保留所有权利. 尝试新的跨平台 PowerShell https://aka.ms/pscore6 ...

  4. easyUI时间控件

    ##=============================JSP======================================<div class="labelw l ...

  5. Static关键字的使用

    Static关键字的使用 static关键字的作用: 用来修饰成员变量和方法,被修饰的成员是属于类的,而不单单是属于某个对象的,也就是说,可以不靠对象来调用. 首先我们来介绍类变量 当static修饰 ...

  6. Ethical Hacking - NETWORK PENETRATION TESTING(19)

    MITM-DNS Spoofing DNS Spoofing allows us to redirect any request to a certain domain to another doma ...

  7. 洛谷 P5350 序列 珂朵莉树

    题目描述 分析 操作一.二.三为珂朵莉树的基本操作,操作四.五.六稍作转化即可 不会珂朵莉树请移步至这里 求和操作 把每一段区间分别取出,暴力相加 ll qh(ll l,ll r){ it2=Spli ...

  8. 脸书(Facebook)如何绑定谷歌二次验证码/谷歌身份验证/双重认证?

    1.打开Facebook,找到双重验证界面   打开Facebook,点击“设置”-“安全与登陆”-“使用双重验证”-“身份验证应用”-“在其他设备上设置应用”-“输入验证码” *****想使用Fac ...

  9. java 多线程的售票问题

    java 多线程的售票问题 对票的库存进行操作 public class Tickets implements Runnable{ private int ticket = 100; public v ...

  10. 疑难杂症1-去掉网站里的特殊编码&#65279

    编辑器:Visual Studio 2019项目框架:Net Core 3.1 + AutoFac 特殊符号&#65279,这是编码UTF-8 + BOM的特殊标记,是隐藏的,但是会被浏览器翻 ...