zoukankan      html  css  js  c++  java
  • Spring自定义标签解析与实现

           在Spring Bean注册解析(一)Spring Bean注册解析(二)中我们讲到,Spring在解析xml文件中的标签的时候会区分当前的标签是四种基本标签(import、alias、bean和beans)还是自定义标签,如果是自定义标签,则会按照自定义标签的逻辑解析当前的标签。另外,即使是bean标签,其也可以使用自定义的属性或者使用自定义的子标签。本文将对自定义标签和自定义属性的使用方式进行讲解,并且会从源码的角度对自定义标签和自定义属性的实现方式进行讲解。

    1. 自定义标签

    1.1 使用方式

           对于自定义标签,其主要包含两个部分:命名空间和转换逻辑的定义,而对于自定义标签的使用,我们只需要按照自定义的命名空间规则,在Spring的xml文件中定义相关的bean即可。假设我们有一个类Apple,并且我们需要在xml文件使用自定义标签声明该Apple对象,如下是Apple的定义:

    public class Apple {
      private int price;
      private String origin;
    
      public int getPrice() {
        return price;
      }
    
      public void setPrice(int price) {
        this.price = price;
      }
    
      public String getOrigin() {
        return origin;
      }
    
      public void setOrigin(String origin) {
        this.origin = origin;
      }
    }
    

           如下是我们使用自定义标签在Spring的xml文件中为其声明对象的配置:

    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:myapple="http://www.lexueba.com/schema/apple"
           xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.lexueba.com/schema/apple http://www.lexueba.com/schema/apple.xsd">
    
        <myapple:apple id="apple" price="123" origin="Asia"/>
    </beans>
    

           我们这里使用了myapple:apple标签声明名为apple的bean,这里myapple就对应了上面的xmlns:myapple,其后指定了一个链接:http://www.lexueba.com/schema/apple,Spring在解析到该链接的时候,会到META-INF文件夹下找Spring.handlers和Spring.schemas文件(这里META-INF文件夹放在maven工程的resources目录下即可),然后读取这两个文件的内容,如下是其定义:

    Spring.handlers
    http://www.lexueba.com/schema/apple=chapter4.eg3.MyNameSpaceHandler
    
    Spring.schemas
    http://www.lexueba.com/schema/apple.xsd=META-INF/custom-apple.xsd
    

           可以看到,Spring.handlers指定了当前命名空间的处理逻辑类,而Spring.schemas则指定了一个xsd文件,该文件中则声明了myapple:apple各个属性的定义。我们首先看下自定义标签各属性的定义:

    <?xml version="1.0" encoding="UTF-8"?>
    <xsd:schema xmlns="http://www.lexueba.com/schema/apple"
                xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                targetNamespace="http://www.lexueba.com/schema/apple"
                elementFormDefault="qualified">
    
        <xsd:complexType name="apple">
            <xsd:attribute name="id" type="xsd:string">
                <xsd:annotation>
                    <xsd:documentation>
                        <![CDATA[ The unique identifier for a bean. ]]>
                    </xsd:documentation>
                </xsd:annotation>
            </xsd:attribute>
            <xsd:attribute name="price" type="xsd:int">
                <xsd:annotation>
                    <xsd:documentation>
                        <![CDATA[ The price for a bean. ]]>
                    </xsd:documentation>
                </xsd:annotation>
            </xsd:attribute>
            <xsd:attribute name="origin" type="xsd:string">
                <xsd:annotation>
                    <xsd:documentation>
                        <![CDATA[ The origin of the bean. ]]>
                    </xsd:documentation>
                </xsd:annotation>
            </xsd:attribute>
        </xsd:complexType>
    
        <xsd:element name="apple" type="apple">
            <xsd:annotation>
                <xsd:documentation><![CDATA[ The service config ]]></xsd:documentation>
            </xsd:annotation>
        </xsd:element>
    
    </xsd:schema>
    

           可以看到,该xsd文件中声明了三个属性:id、price和origin。需要注意的是,这三个属性与我们的Apple对象的属性price和origin没有直接的关系,这里只是一个xsd文件的声明,以表征Spring的applicationContext.xml文件中使用当前命名空间时可以使用的标签属性。接下来我们看一下Spring.handlers中定义的MyNameSpaceHandler声明:

    public class MyNameSpaceHandler extends NamespaceHandlerSupport {
      @Override
      public void init() {
        registerBeanDefinitionParser("apple", new AppleBeanDefinitionParser());
      }
    }
    

           MyNameSpaceHandler只是注册了apple的标签的处理逻辑,真正的转换逻辑在AppleBeanDefinitionParser中。这里注册的apple必须与Spring的applicationContext.xml文件中myapple:apple标签后的apple保持一致,否则将找不到相应的处理逻辑。如下是AppleBeanDefinitionParser的处理逻辑:

    public class AppleBeanDefinitionParser extends AbstractSingleBeanDefinitionParser {
      @Override
      protected Class<?> getBeanClass(Element element) {
        return Apple.class;
      }
    
      @Override
      protected void doParse(Element element, BeanDefinitionBuilder builder) {
        String price = element.getAttribute("price");
        String origin = element.getAttribute("origin");
        if (StringUtils.hasText(price)) {
          builder.addPropertyValue("price", Integer.parseInt(price));
        }
    
        if (StringUtils.hasText(origin)) {
          builder.addPropertyValue("origin", origin);
        }
      }
    }
    

           可以看到,该处理逻辑中主要是获取当前标签中定义的price和origin属性的值,然后将其按照一定的处理逻辑注册到当前的BeanDefinition中。这里还实现了一个getBeanClass()方法,该方法用于表明当前自定义标签对应的BeanDefinition所代表的类的类型。如下是我们的入口程序,用于检查当前的自定义标签是否正常工作的:

    public class CustomSchemaApp {
      public static void main(String[] args) {
        ApplicationContext applicationContext = new ClassPathXmlApplicationContext("applicationContext.xml");
        Apple apple = applicationContext.getBean(Apple.class);
        System.out.println(apple.getPrice() + ", " + apple.getOrigin());
      }
    }
    

           运行结果如下:

    123, Asia
    

    1.2 实现方式

           我们还是从对整个applicationContext.xml文件开始读取的入口方法开始进行讲解,即DefaultBeanDefinitionDocumentReader.parseBeanDefinitions()方法,如下是该方法的源码:

    protected void parseBeanDefinitions(Element root, BeanDefinitionParserDelegate delegate) {
        // 判断根节点使用的标签所对应的命名空间是否为Spring提供的默认命名空间,
        // 这里根节点为beans节点,该节点的命名空间通过其xmlns属性进行了定义
        if (delegate.isDefaultNamespace(root)) {
            NodeList nl = root.getChildNodes();
            for (int i = 0; i < nl.getLength(); i++) {
                Node node = nl.item(i);
                if (node instanceof Element) {
                    Element ele = (Element) node;
                    if (delegate.isDefaultNamespace(ele)) {
                        // 当前标签使用的是默认的命名空间,如bean标签,
                        // 则按照默认命名空间的逻辑对其进行处理
                        parseDefaultElement(ele, delegate);
                    } else {
                        // 判断当前标签使用的命名空间是自定义的命名空间,如这里myapple:apple所
                        // 使用的就是自定义的命名空间,那么就按照定义命名空间逻辑进行处理
                        delegate.parseCustomElement(ele);
                    }
                }
            }
        }
        else {
            // 如果根节点使用的命名空间不是默认的命名空间,则按照自定义的命名空间进行处理
            delegate.parseCustomElement(root);
        }
    }
    

           可以看到,该方法首先会判断当前文件指定的xmlns命名空间是否为默认命名空间,如果是,则按照默认命名空间进行处理,如果不是则直接按照自定义命名空间进行处理。这里需要注意的是,即使在默认的命名空间中,当前标签也可以使用自定义的命名空间,我们定义的myapple:apple就是这种类型,这里myapple就关联了xmlns:myapple后的myapple。如下是自定义命名空间的处理逻辑:

    @Nullable
    public BeanDefinition parseCustomElement(Element ele, @Nullable BeanDefinition containingBd) {
        // 获取当前标签对应的命名空间指定的url
        String namespaceUri = getNamespaceURI(ele);
        if (namespaceUri == null) {
            return null;
        }
        
        // 获取当前url所对应的NameSpaceHandler处理逻辑,也即我们定义的MyNameSpaceHandler
        NamespaceHandler handler = this.readerContext
            .getNamespaceHandlerResolver()
            .resolve(namespaceUri);
        if (handler == null) {
            error("Unable to locate Spring NamespaceHandler for XML schema namespace [" + 
                  namespaceUri + "]", ele);
            return null;
        }
        
        // 调用当前命名空间处理逻辑的parse()方法,以对当前标签进行转换
        return handler.parse(ele, new ParserContext(this.readerContext, this, containingBd));
    }
    

           这里getNamespaceURI()方法的作用是获取当前标签对应的命名空间url。在获取url之后,会调用NamespaceHandlerResolver.resolve(String)方法,该方法会通过当前命名空间的url获取META-INF/Spring.handlers文件内容,并且查找当前命名空间url对应的处理逻辑类。如下是该方法的声明:

    @Nullable
    public NamespaceHandler resolve(String namespaceUri) {
        // 获取handlerMapping对象,其键为当前的命名空间url,
        // 值为当前命名空间的处理逻辑类对象,或者为处理逻辑类的包含全路径的类名
        Map<String, Object> handlerMappings = getHandlerMappings();
        // 查看是否存在当前url的处理类逻辑,没有则返回null
        Object handlerOrClassName = handlerMappings.get(namespaceUri);
        if (handlerOrClassName == null) {
            return null;
        } else if (handlerOrClassName instanceof NamespaceHandler) {
            // 如果存在当前url对应的处理类对象,则直接返回该处理对象
            return (NamespaceHandler) handlerOrClassName;
        } else {
            // 如果当前url对应的处理逻辑还是一个没初始化的全路径类名,则通过反射对其进行初始化
            String className = (String) handlerOrClassName;
            try {
                Class<?> handlerClass = ClassUtils.forName(className, this.classLoader);
                // 判断该全路径类是否为NamespaceHandler接口的实现类
                if (!NamespaceHandler.class.isAssignableFrom(handlerClass)) {
                    throw new FatalBeanException("Class [" + className + "] for namespace [" + 
                        namespaceUri + "] does not implement the [" + 
                        NamespaceHandler.class.getName() + "] interface");
                }
                NamespaceHandler namespaceHandler = 
                    (NamespaceHandler) BeanUtils.instantiateClass(handlerClass);
                namespaceHandler.init();  // 调用处理逻辑的初始化方法
                handlerMappings.put(namespaceUri, namespaceHandler);  //缓存处理逻辑类对象
                return namespaceHandler;
            }
            catch (ClassNotFoundException ex) {
                throw new FatalBeanException("Could not find NamespaceHandler class [" 
                   + className + "] for namespace [" + namespaceUri + "]", ex);
            }
            catch (LinkageError err) {
                throw new FatalBeanException("Unresolvable class definition for" 
                   + "NamespaceHandler class [" + className + "] for namespace [" 
                   +  namespaceUri + "]", err);
            }
        }
    }
    

           可以看到,在处理命名空间url的时候,首先会判断是否存在当前url的处理逻辑,不存在则直接返回。如果存在,则会判断其为一个NamespaceHandler对象,还是一个全路径的类名,是NamespaceHandler对象则强制类型转换后返回,否则通过反射初始化该类,并调用其初始化方法,然后才返回。

           我们继续查看NamespaceHandler.parse()方法,如下是该方法的源码:

    @Override
    @Nullable
    public BeanDefinition parse(Element element, ParserContext parserContext) {
        // 获取当前标签使用的parser处理类
        BeanDefinitionParser parser = findParserForElement(element, parserContext);
        // 按照定义的parser处理类对当前标签进行处理,这里的处理类即我们定义的AppleBeanDefinitionParser
        return (parser != null ? parser.parse(element, parserContext) : null);
    }
    

           这里的parse()方法首先会查找当前标签定义的处理逻辑对象,找到后则调用其parse()方法对其进行处理。这里的parser也即我们定义的AppleBeanDefinitionParser.parse()方法。这里需要注意的是,我们在前面讲过,在MyNameSpaceHandler.init()方法中注册的处理类逻辑的键(即apple)必须与xml文件中myapple:apple后的apple一致,这就是这里findParserForElement()方法查找BeanDefinitionParser处理逻辑的依据。如下是findParserForElement()方法的源码:

    @Nullable
    private BeanDefinitionParser findParserForElement(Element element, ParserContext parserContext) {
        // 获取当前标签命名空间后的局部键名,即apple
        String localName = parserContext.getDelegate().getLocalName(element);
        // 通过使用的命名空间键获取对应的BeanDefinitionParser处理逻辑
        BeanDefinitionParser parser = this.parsers.get(localName);
        if (parser == null) {
            parserContext.getReaderContext().fatal(
               "Cannot locate BeanDefinitionParser for element [" + localName + "]", element);
        }
        return parser;
    }
    

           这里首先获取当前标签的命名空间后的键名,即myapple:apple后的apple,然后在parsers中获取该键对应的BeanDefinitionParser对象。其实在MyNameSpaceHandler.init()方法中进行的注册工作就是将其注册到了parsers对象中。

    2. 自定义属性

    2.1 使用方式

           自定义属性的定义方式和自定义标签非常相似,其主要也是进行命名空间和转换逻辑的定义。假设我们有一个Car对象,我们需要使用自定义标签为其添加一个描述属性。如下是Car对象的定义:

    public class Car {
      private long id;
      private String name;
      private String desc;
    
      public long getId() {
        return id;
      }
    
      public void setId(long id) {
        this.id = id;
      }
    
      public String getDesc() {
        return desc;
      }
    
      public void setDesc(String desc) {
        this.desc = desc;
      }
    
      public String getName() {
        return name;
      }
    
      public void setName(String name) {
        this.name = name;
      }
    }
    

           如下是在applicationContext.xml中该对象的定义:

    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:car="http://www.lexueba.com/schema/car-desc"
           xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd">
        
        <bean id="car" class="chapter4.eg2.Car" car:car-desc="This is test custom attribute">
            <property name="id" value="1"/>
            <property name="name" value="baoma"/>
        </bean>
    </beans>
    

           可以看到,car对象的定义使用的就是一般的bean定义,只不过其多了一个属性car:car-desc的使用。这里的car:car-desc对应的命名空间就是上面的http://www.lexueba.com/schema/car-desc。同自定义标签一样,自定义属性也需要在META-INF下的Spring.handlers和Spring.schemas文件中指定当前的处理逻辑和xsd定义,如下是这两个文件的定义:

    Spring.handlers
    http://www.lexueba.com/schema/car-desc=chapter4.eg2.MyCustomAttributeHandler
    
    Spring.schemas
    http://www.lexueba.com/schema/car.xsd=META-INF/custom-attribute.xsd
    

           对应的xsd文件的定义如下:

    <?xml version="1.0" encoding="UTF-8"?>
    <xsd:schema xmlns="http://www.lexueba.com/schema/car-desc"
                xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                targetNamespace="http://www.lexueba.com/schema/car-desc"
                elementFormDefault="qualified">
    
        <xsd:attribute name="car-desc" type="xsd:string"/>
    
    </xsd:schema>
    

           可以看到,该xsd文件中只定义了一个属性,即car-desc。如下是MyCustomAttributeHandler的声明:

    public class MyCustomAttributeHandler extends NamespaceHandlerSupport {
      @Override
      public void init() {
        registerBeanDefinitionDecoratorForAttribute("car-desc", 
          new CarDescInitializingBeanDefinitionDecorator());
      }
    }
    

           需要注意的是,和自定义标签不同的是,自定义标签是将处理逻辑注册到parsers对象中,这里自定义属性是将处理逻辑注册到attributeDecorators中。如下CarDescInitializingBeanDefinitionDecorator的逻辑:

    public class CarDescInitializingBeanDefinitionDecorator implements BeanDefinitionDecorator {
      @Override
      public BeanDefinitionHolder decorate(Node node, BeanDefinitionHolder definition, ParserContext parserContext) {
        String desc = ((Attr) node).getValue();
        definition.getBeanDefinition().getPropertyValues().addPropertyValue("desc", desc);
        return definition;
      }
    }
    

           可以看到,对于car-desc的处理逻辑就是获取当前定义的属性的值,由于知道其是当前标签的一个属性,因而可以将其强转为一个Attr类型的对象,并获取其值,然后将其添加到指定的BeandDefinitionHolder中。这里需要注意的是,自定义标签继承的是AbstractSingleBeanDefinitionParser类,实际上是实现的BeanDefinitionParser接口,而自定义属性实现的则是BeanDefinitionDecorator接口。

    2.2 实现方式

           关于自定义属性的实现方式,需要注意的是,自定义属性只能在bean标签中使用,因而我们可以直接进入对bean标签的处理逻辑中,即DefaultBeanDefinitionDocumentReader.processBeanDefinition()方法,如下是该方法的声明:

    protected void processBeanDefinition(Element ele, BeanDefinitionParserDelegate delegate) {
        // 对bean标签的默认属性和子标签进行处理,将其封装为一个BeanDefinition对象,
        // 并放入BeanDefinitionHolder中
        BeanDefinitionHolder bdHolder = delegate.parseBeanDefinitionElement(ele);
        if (bdHolder != null) {
            // 进行自定义属性或自定义子标签的装饰
            bdHolder = delegate.decorateBeanDefinitionIfRequired(ele, bdHolder);
            try {
                // 注册当前的BeanDefinition
                BeanDefinitionReaderUtils.registerBeanDefinition(bdHolder,
                   getReaderContext().getRegistry());
            }catch (BeanDefinitionStoreException ex) {
                getReaderContext().error("Failed to register bean definition with name '" +
                                         bdHolder.getBeanName() + "'", ele, ex);
            }
            
            // 调用注册了bean标签解析完成的事件处理逻辑
            getReaderContext().fireComponentRegistered(new BeanComponentDefinition(bdHolder));
        }
    }
    

           这里我们直接进入BeanDefinitionParserDelegate.decorateBeanDefinitionIfRequired()方法中:

    public BeanDefinitionHolder decorateBeanDefinitionIfRequired(
        Element ele, BeanDefinitionHolder definitionHolder, @Nullable BeanDefinition containingBd) {
    
        BeanDefinitionHolder finalDefinition = definitionHolder;
    
        // 处理自定义属性
        NamedNodeMap attributes = ele.getAttributes();
        for (int i = 0; i < attributes.getLength(); i++) {
            Node node = attributes.item(i);
            finalDefinition = decorateIfRequired(node, finalDefinition, containingBd);
        }
    
        // 处理自定义子标签
        NodeList children = ele.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE) {
                finalDefinition = decorateIfRequired(node, finalDefinition, containingBd);
            }
        }
        return finalDefinition;
    }
    

           可以看到,自定义属性和自定义子标签的解析都是通过decorateIfRequired()方法进行的,如下是该方法的定义:

    public BeanDefinitionHolder decorateIfRequired(
        Node node, BeanDefinitionHolder originalDef, @Nullable BeanDefinition containingBd) {
    
        // 获取当前自定义属性或子标签的命名空间url
        String namespaceUri = getNamespaceURI(node);
        // 判断其如果为spring默认的命名空间则不对其进行处理
        if (namespaceUri != null && !isDefaultNamespace(namespaceUri)) {
            // 获取当前命名空间对应的NamespaceHandler对象
            NamespaceHandler handler = this.readerContext
                .getNamespaceHandlerResolver()
                .resolve(namespaceUri);
            if (handler != null) {
                // 对当前的BeanDefinitionHolder进行装饰
                BeanDefinitionHolder decorated =
                    handler.decorate(node, originalDef, 
                       new ParserContext(this.readerContext, this, containingBd));
                if (decorated != null) {
                    return decorated;
                }
            }
            else if (namespaceUri.startsWith("http://www.springframework.org/")) {
                error("Unable to locate Spring NamespaceHandler for XML schema namespace [" + 
                      namespaceUri + "]", node);
            }
            else {
                // A custom namespace, not to be handled by Spring - maybe "xml:...".
                if (logger.isDebugEnabled()) {
                    logger.debug("No Spring NamespaceHandler found for XML schema namespace [" 
                                 + namespaceUri + "]");
                }
            }
        }
        return originalDef;
    }
    

           decorateIfRequired()方法首先会获取当前自定义属性或子标签对应的命名空间url,然后根据该url获取当前命名空间对应的NamespaceHandler处理逻辑,并且调用其decorate()方法进行装饰,如下是该方法的实现:

    @Nullable
    public BeanDefinitionHolder decorate(
        Node node, BeanDefinitionHolder definition, ParserContext parserContext) {
        // 获取当前自定义属性或子标签注册的BeanDefinitionDecorator对象
        BeanDefinitionDecorator decorator = findDecoratorForNode(node, parserContext);
        // 调用自定义的BeanDefinitionDecorator.decorate()方法进行装饰,
        // 这里就是我们实现的CarDescInitializingBeanDefinitionDecorator类
        return (decorator != null ? decorator.decorate(node, definition, parserContext) : null);
    }
    

           和自定义标签不同的是,自定义属性或自定义子标签查找当前Decorator的方法是需要对属性或子标签进行分别判断的,如下是findDecoratorForNode()的实现:

    @Nullable
    private BeanDefinitionDecorator findDecoratorForNode(Node node,
            ParserContext parserContext) {
        BeanDefinitionDecorator decorator = null;
        // 获取当前标签或属性的局部键名
        String localName = parserContext.getDelegate().getLocalName(node);
        // 判断当前节点是属性还是子标签,根据情况不同获取不同的Decorator处理逻辑
        if (node instanceof Element) {
            decorator = this.decorators.get(localName);
        } else if (node instanceof Attr) {
            decorator = this.attributeDecorators.get(localName);
        } else {
            parserContext.getReaderContext().fatal(
                "Cannot decorate based on Nodes of type [" + node.getClass().getName() 
                + "]", node);
        }
        if (decorator == null) {
            parserContext.getReaderContext().fatal(
                "Cannot locate BeanDefinitionDecorator for " + (node instanceof Element 
                ? "element" : "attribute") + " [" + localName + "]", node);
        }
        return decorator;
    }
    

           对于BeanDefinitionDecorator处理逻辑的查找,可以看到,其会根据节点的类型进行判断,根据不同的情况获取不同的BeanDefinitionDecorator处理对象。

    3. 自定义子标签

           对于自定义子标签的使用,其与自定义标签的使用非常相似,不过需要注意的是,根据对自定义属性的源码解析,我们知道自定义子标签并不是自定义标签,自定义子标签只是起到对其父标签所定义的bean的一种装饰作用,因而自定义子标签的处理逻辑定义与自定义标签主要有两点不同:①在NamespaceHandler.init()方法中注册自定义子标签的处理逻辑时需要使用registerBeanDefinitionDecorator(String, BeanDefinitionDecorator)方法;②自定义子标签的处理逻辑需要实现的是BeanDefinitionDecorator接口。其余部分的使用都和自定义标签一致。

    4. 总结

           本文主要对自定义标签,自定义属性和自定义子标签的使用方式和源码实现进行了讲解,有了对自定义标签的理解,我们可以在Spring的xml文件中根据自己的需要实现自己的处理逻辑。另外需要说明的是,Spring源码中也大量使用了自定义标签,比如spring的AOP的定义,其标签为<aspectj-autoproxy />。从另一个角度来看,我们前面两篇文章对Spring的xml文件的解析进行了讲解,可以知道,Spring默认只会处理import、alias、bean和beans四种标签,对于其余的标签,如我们所熟知的事务处理标签,这些都是使用自定义标签实现的。

  • 相关阅读:
    1093 Count PAT's(25 分)
    1089 Insert or Merge(25 分)
    1088 Rational Arithmetic(20 分)
    1081 Rational Sum(20 分)
    1069 The Black Hole of Numbers(20 分)
    1059 Prime Factors(25 分)
    1050 String Subtraction (20)
    根据生日计算员工年龄
    动态获取当前日期和时间
    对计数结果进行4舍5入
  • 原文地址:https://www.cnblogs.com/zhangxufeng/p/9160880.html
Copyright © 2011-2022 走看看