`
Foxswily
  • 浏览: 77446 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

解决Spring导致iBatis缓存失效问题

阅读更多

版本:
  Spring 3.0.4(2.x版本中也存在类似问题)
  iBatis 2.3.4.726(2.3.x版本都适用)

起因:
  使用Spring管理iBatis实例,标准方式采用SqlMapClientFactoryBean创建SqlMapClient

 

	<bean id="sqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
		<property name="configLocation"
			value="classpath:/com/foo/xxx/sqlMapConfig.xml" />
		<property name="dataSource" ref="dataSource" />
		<property name="mappingLocations" value="classpath*:/**/sqlmap/*SqlMap.xml" />
	</bean>



使用mappingLocations方式定义可以让sqlmap不必添加到sqlMapConfig.xml
即避免了如下形式

 

 

<sqlMapConfig>
...
  <sqlMap url="someSqlMap" resource="com/foo/xxx/someSqlMap.xml"/>
...
</sqlMapConfig>

 

随之而来的问题是在sqlMap中的cache无法在执行预订方法时自动flush!

<cacheModel id="mycache" type ="LRU" readOnly="false" serialize="false">
  <flushInterval hours="24"/>
  <flushOnExecute statement="myobj.insert"/>
  <property name="cache-size" value="50" />
</cacheModel>

 

在myobj.insert被执行时cache不会flush,即无报错也无提醒,彻头彻尾的坑爹,不清楚Spring开发组为什么会让这问题一直存在。

产生问题的原因网上有不少分析贴(不再重复,需要的可搜索相关帖子),原因是Spring在处理mappingLocations之前,iBatis已经完成对sqlMapConfig里注册的sqlMap的cache设置(真绕口=.="),非亲生的sqlMap被无情的抛弃了。解决办法大都建议放弃mappingLocations的方式,把sqlMap写到sqlMapConfig里——还真是够简单——或者说够偷懒。

找到原因就想办法解决吧,从SqlMapClientFactoryBean下手,扩展一份

/**
* 
* @author Foxswily
*/

@SuppressWarnings("deprecation")
public class MySqlMapClientFactoryBean implements FactoryBean<SqlMapClient>,
        InitializingBean {

    private static final ThreadLocal<LobHandler> configTimeLobHandlerHolder = new ThreadLocal<LobHandler>();

    /**
     * Return the LobHandler for the currently configured iBATIS SqlMapClient,
     * to be used by TypeHandler implementations like ClobStringTypeHandler.
     * <p>
     * This instance will be set before initialization of the corresponding
     * SqlMapClient, and reset immediately afterwards. It is thus only available
     * during configuration.
     * 
     * @see #setLobHandler
     * @see org.springframework.orm.ibatis.support.ClobStringTypeHandler
     * @see org.springframework.orm.ibatis.support.BlobByteArrayTypeHandler
     * @see org.springframework.orm.ibatis.support.BlobSerializableTypeHandler
     */
    public static LobHandler getConfigTimeLobHandler() {
        return configTimeLobHandlerHolder.get();
    }

    private Resource[] configLocations;

    private Resource[] mappingLocations;

    private Properties sqlMapClientProperties;

    private DataSource dataSource;

    private boolean useTransactionAwareDataSource = true;

    @SuppressWarnings("rawtypes")
    private Class transactionConfigClass = ExternalTransactionConfig.class;

    private Properties transactionConfigProperties;

    private LobHandler lobHandler;

    private SqlMapClient sqlMapClient;

    public MySqlMapClientFactoryBean() {
        this.transactionConfigProperties = new Properties();
        this.transactionConfigProperties.setProperty("SetAutoCommitAllowed", "false");
    }

    /**
     * Set the location of the iBATIS SqlMapClient config file. A typical value
     * is "WEB-INF/sql-map-config.xml".
     * 
     * @see #setConfigLocations
     */
    public void setConfigLocation(Resource configLocation) {
        this.configLocations = (configLocation != null ? new Resource[] { configLocation }
                : null);
    }

    /**
     * Set multiple locations of iBATIS SqlMapClient config files that are going
     * to be merged into one unified configuration at runtime.
     */
    public void setConfigLocations(Resource[] configLocations) {
        this.configLocations = configLocations;
    }

    /**
     * Set locations of iBATIS sql-map mapping files that are going to be merged
     * into the SqlMapClient configuration at runtime.
     * <p>
     * This is an alternative to specifying "&lt;sqlMap&gt;" entries in a
     * sql-map-client config file. This property being based on Spring's
     * resource abstraction also allows for specifying resource patterns here:
     * e.g. "/myApp/*-map.xml".
     * <p>
     * Note that this feature requires iBATIS 2.3.2; it will not work with any
     * previous iBATIS version.
     */
    public void setMappingLocations(Resource[] mappingLocations) {
        this.mappingLocations = mappingLocations;
    }

    /**
     * Set optional properties to be passed into the SqlMapClientBuilder, as
     * alternative to a <code>&lt;properties&gt;</code> tag in the
     * sql-map-config.xml file. Will be used to resolve placeholders in the
     * config file.
     * 
     * @see #setConfigLocation
     * @see com.ibatis.sqlmap.client.SqlMapClientBuilder#buildSqlMapClient(java.io.InputStream,
     *      java.util.Properties)
     */
    public void setSqlMapClientProperties(Properties sqlMapClientProperties) {
        this.sqlMapClientProperties = sqlMapClientProperties;
    }

    /**
     * Set the DataSource to be used by iBATIS SQL Maps. This will be passed to
     * the SqlMapClient as part of a TransactionConfig instance.
     * <p>
     * If specified, this will override corresponding settings in the
     * SqlMapClient properties. Usually, you will specify DataSource and
     * transaction configuration <i>either</i> here <i>or</i> in SqlMapClient
     * properties.
     * <p>
     * Specifying a DataSource for the SqlMapClient rather than for each
     * individual DAO allows for lazy loading, for example when using
     * PaginatedList results.
     * <p>
     * With a DataSource passed in here, you don't need to specify one for each
     * DAO. Passing the SqlMapClient to the DAOs is enough, as it already
     * carries a DataSource. Thus, it's recommended to specify the DataSource at
     * this central location only.
     * <p>
     * Thanks to Brandon Goodin from the iBATIS team for the hint on how to make
     * this work with Spring's integration strategy!
     * 
     * @see #setTransactionConfigClass
     * @see #setTransactionConfigProperties
     * @see com.ibatis.sqlmap.client.SqlMapClient#getDataSource
     * @see SqlMapClientTemplate#setDataSource
     * @see SqlMapClientTemplate#queryForPaginatedList
     */
    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    /**
     * Set whether to use a transaction-aware DataSource for the SqlMapClient,
     * i.e. whether to automatically wrap the passed-in DataSource with Spring's
     * TransactionAwareDataSourceProxy.
     * <p>
     * Default is "true": When the SqlMapClient performs direct database
     * operations outside of Spring's SqlMapClientTemplate (for example, lazy
     * loading or direct SqlMapClient access), it will still participate in
     * active Spring-managed transactions.
     * <p>
     * As a further effect, using a transaction-aware DataSource will apply
     * remaining transaction timeouts to all created JDBC Statements. This means
     * that all operations performed by the SqlMapClient will automatically
     * participate in Spring-managed transaction timeouts.
     * <p>
     * Turn this flag off to get raw DataSource handling, without Spring
     * transaction checks. Operations on Spring's SqlMapClientTemplate will
     * still detect Spring-managed transactions, but lazy loading or direct
     * SqlMapClient access won't.
     * 
     * @see #setDataSource
     * @see org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy
     * @see org.springframework.jdbc.datasource.DataSourceTransactionManager
     * @see SqlMapClientTemplate
     * @see com.ibatis.sqlmap.client.SqlMapClient
     */
    public void setUseTransactionAwareDataSource(boolean useTransactionAwareDataSource) {
        this.useTransactionAwareDataSource = useTransactionAwareDataSource;
    }

    /**
     * Set the iBATIS TransactionConfig class to use. Default is
     * <code>com.ibatis.sqlmap.engine.transaction.external.ExternalTransactionConfig</code>
     * .
     * <p>
     * Will only get applied when using a Spring-managed DataSource. An instance
     * of this class will get populated with the given DataSource and
     * initialized with the given properties.
     * <p>
     * The default ExternalTransactionConfig is appropriate if there is external
     * transaction management that the SqlMapClient should participate in: be it
     * Spring transaction management, EJB CMT or plain JTA. This should be the
     * typical scenario. If there is no active transaction, SqlMapClient
     * operations will execute SQL statements non-transactionally.
     * <p>
     * JdbcTransactionConfig or JtaTransactionConfig is only necessary when
     * using the iBATIS SqlMapTransactionManager API instead of external
     * transactions. If there is no explicit transaction, SqlMapClient
     * operations will automatically start a transaction for their own scope (in
     * contrast to the external transaction mode, see above).
     * <p>
     * <b>It is strongly recommended to use iBATIS SQL Maps with Spring
     * transaction management (or EJB CMT).</b> In this case, the default
     * ExternalTransactionConfig is fine. Lazy loading and SQL Maps operations
     * without explicit transaction demarcation will execute
     * non-transactionally.
     * <p>
     * Even with Spring transaction management, it might be desirable to specify
     * JdbcTransactionConfig: This will still participate in existing
     * Spring-managed transactions, but lazy loading and operations without
     * explicit transaction demaration will execute in their own auto-started
     * transactions. However, this is usually not necessary.
     * 
     * @see #setDataSource
     * @see #setTransactionConfigProperties
     * @see com.ibatis.sqlmap.engine.transaction.TransactionConfig
     * @see com.ibatis.sqlmap.engine.transaction.external.ExternalTransactionConfig
     * @see com.ibatis.sqlmap.engine.transaction.jdbc.JdbcTransactionConfig
     * @see com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig
     * @see com.ibatis.sqlmap.client.SqlMapTransactionManager
     */
    @SuppressWarnings("rawtypes")
    public void setTransactionConfigClass(Class transactionConfigClass) {
        if (transactionConfigClass == null
                || !TransactionConfig.class.isAssignableFrom(transactionConfigClass)) {
            throw new IllegalArgumentException(
                    "Invalid transactionConfigClass: does not implement "
                            + "com.ibatis.sqlmap.engine.transaction.TransactionConfig");
        }
        this.transactionConfigClass = transactionConfigClass;
    }

    /**
     * Set properties to be passed to the TransactionConfig instance used by
     * this SqlMapClient. Supported properties depend on the concrete
     * TransactionConfig implementation used:
     * <p>
     * <ul>
     * <li><b>ExternalTransactionConfig</b> supports "DefaultAutoCommit"
     * (default: false) and "SetAutoCommitAllowed" (default: true). Note that
     * Spring uses SetAutoCommitAllowed = false as default, in contrast to the
     * iBATIS default, to always keep the original autoCommit value as provided
     * by the connection pool.
     * <li><b>JdbcTransactionConfig</b> does not supported any properties.
     * <li><b>JtaTransactionConfig</b> supports "UserTransaction" (no default),
     * specifying the JNDI location of the JTA UserTransaction (usually
     * "java:comp/UserTransaction").
     * </ul>
     * 
     * @see com.ibatis.sqlmap.engine.transaction.TransactionConfig#initialize
     * @see com.ibatis.sqlmap.engine.transaction.external.ExternalTransactionConfig
     * @see com.ibatis.sqlmap.engine.transaction.jdbc.JdbcTransactionConfig
     * @see com.ibatis.sqlmap.engine.transaction.jta.JtaTransactionConfig
     */
    public void setTransactionConfigProperties(Properties transactionConfigProperties) {
        this.transactionConfigProperties = transactionConfigProperties;
    }

    /**
     * Set the LobHandler to be used by the SqlMapClient. Will be exposed at
     * config time for TypeHandler implementations.
     * 
     * @see #getConfigTimeLobHandler
     * @see com.ibatis.sqlmap.engine.type.TypeHandler
     * @see org.springframework.orm.ibatis.support.ClobStringTypeHandler
     * @see org.springframework.orm.ibatis.support.BlobByteArrayTypeHandler
     * @see org.springframework.orm.ibatis.support.BlobSerializableTypeHandler
     */
    public void setLobHandler(LobHandler lobHandler) {
        this.lobHandler = lobHandler;
    }

    public void afterPropertiesSet() throws Exception {
        if (this.lobHandler != null) {
            // Make given LobHandler available for SqlMapClient configuration.
            // Do early because because mapping resource might refer to custom
            // types.
            configTimeLobHandlerHolder.set(this.lobHandler);
        }

        try {
            this.sqlMapClient = buildSqlMapClient(this.configLocations,
                    this.mappingLocations, this.sqlMapClientProperties);

            // Tell the SqlMapClient to use the given DataSource, if any.
            if (this.dataSource != null) {
                TransactionConfig transactionConfig = (TransactionConfig) this.transactionConfigClass
                        .newInstance();
                DataSource dataSourceToUse = this.dataSource;
                if (this.useTransactionAwareDataSource
                        && !(this.dataSource instanceof TransactionAwareDataSourceProxy)) {
                    dataSourceToUse = new TransactionAwareDataSourceProxy(this.dataSource);
                }
                transactionConfig.setDataSource(dataSourceToUse);
                transactionConfig.initialize(this.transactionConfigProperties);
                applyTransactionConfig(this.sqlMapClient, transactionConfig);
            }
        }

        finally {
            if (this.lobHandler != null) {
                // Reset LobHandler holder.
                configTimeLobHandlerHolder.set(null);
            }
        }
    }

    /**
     * Build a SqlMapClient instance based on the given standard configuration.
     * <p>
     * The default implementation uses the standard iBATIS
     * {@link SqlMapClientBuilder} API to build a SqlMapClient instance based on
     * an InputStream (if possible, on iBATIS 2.3 and higher) or on a Reader (on
     * iBATIS up to version 2.2).
     * 
     * @param configLocations
     *            the config files to load from
     * @param properties
     *            the SqlMapClient properties (if any)
     * @return the SqlMapClient instance (never <code>null</code>)
     * @throws IOException
     *             if loading the config file failed
     * @throws NoSuchFieldException
     * @throws SecurityException
     * @throws IllegalAccessException
     * @throws IllegalArgumentException
     * @throws NoSuchMethodException
     * @throws InvocationTargetException
     * @see com.ibatis.sqlmap.client.SqlMapClientBuilder#buildSqlMapClient
     */
    protected SqlMapClient buildSqlMapClient(Resource[] configLocations,
            Resource[] mappingLocations, Properties properties) throws IOException,
            SecurityException, NoSuchFieldException, IllegalArgumentException,
            IllegalAccessException, NoSuchMethodException, InvocationTargetException {

        if (ObjectUtils.isEmpty(configLocations)) {
            throw new IllegalArgumentException(
                    "At least 1 'configLocation' entry is required");
        }

        SqlMapClient client = null;
        SqlMapConfigParser configParser = new SqlMapConfigParser();
        for (Resource configLocation : configLocations) {
            InputStream is = configLocation.getInputStream();
            try {
                client = configParser.parse(is, properties);
            } catch (RuntimeException ex) {
                throw new NestedIOException("Failed to parse config resource: "
                        + configLocation, ex.getCause());
            }
        }

        if (mappingLocations != null) {
            SqlMapParser mapParser = SqlMapParserFactory.createSqlMapParser(configParser);
            for (Resource mappingLocation : mappingLocations) {
                try {
                    mapParser.parse(mappingLocation.getInputStream());
                } catch (NodeletException ex) {
                    throw new NestedIOException("Failed to parse mapping resource: "
                            + mappingLocation, ex);
                }
            }
        }
        //*************其实只改这一点而已,为了方便他人,全source贴出**************
        //为了取sqlMapConfig,反射private的field
        Field stateField = configParser.getClass().getDeclaredField("state");
        stateField.setAccessible(true);
        XmlParserState state = (XmlParserState) stateField.get(configParser);
        SqlMapConfiguration sqlMapConfig = state.getConfig();
        //反射取设置cache的方法,执行
        Method wireUpCacheModels = sqlMapConfig.getClass().getDeclaredMethod(
                "wireUpCacheModels");
        wireUpCacheModels.setAccessible(true);
        wireUpCacheModels.invoke(sqlMapConfig);
        //*************************************************************************
        return client;
    }

    /**
     * Apply the given iBATIS TransactionConfig to the SqlMapClient.
     * <p>
     * The default implementation casts to ExtendedSqlMapClient, retrieves the
     * maximum number of concurrent transactions from the
     * SqlMapExecutorDelegate, and sets an iBATIS TransactionManager with the
     * given TransactionConfig.
     * 
     * @param sqlMapClient
     *            the SqlMapClient to apply the TransactionConfig to
     * @param transactionConfig
     *            the iBATIS TransactionConfig to apply
     * @see com.ibatis.sqlmap.engine.impl.ExtendedSqlMapClient
     * @see com.ibatis.sqlmap.engine.impl.SqlMapExecutorDelegate#getMaxTransactions
     * @see com.ibatis.sqlmap.engine.impl.SqlMapExecutorDelegate#setTxManager
     */
    protected void applyTransactionConfig(SqlMapClient sqlMapClient,
            TransactionConfig transactionConfig) {
        if (!(sqlMapClient instanceof ExtendedSqlMapClient)) {
            throw new IllegalArgumentException(
                    "Cannot set TransactionConfig with DataSource for SqlMapClient if not of type "
                            + "ExtendedSqlMapClient: " + sqlMapClient);
        }
        ExtendedSqlMapClient extendedClient = (ExtendedSqlMapClient) sqlMapClient;
        transactionConfig.setMaximumConcurrentTransactions(extendedClient.getDelegate()
                .getMaxTransactions());
        extendedClient.getDelegate().setTxManager(
                new TransactionManager(transactionConfig));
    }

    public SqlMapClient getObject() {
        return this.sqlMapClient;
    }

    public Class<? extends SqlMapClient> getObjectType() {
        return (this.sqlMapClient != null ? this.sqlMapClient.getClass()
                : SqlMapClient.class);
    }

    public boolean isSingleton() {
        return true;
    }

    /**
     * Inner class to avoid hard-coded iBATIS 2.3.2 dependency (XmlParserState
     * class).
     */
    private static class SqlMapParserFactory {

        public static SqlMapParser createSqlMapParser(SqlMapConfigParser configParser) {
            // Ideally: XmlParserState state = configParser.getState();
            // Should raise an enhancement request with iBATIS...
            XmlParserState state = null;
            try {
                Field stateField = SqlMapConfigParser.class.getDeclaredField("state");
                stateField.setAccessible(true);
                state = (XmlParserState) stateField.get(configParser);
            } catch (Exception ex) {
                throw new IllegalStateException(
                        "iBATIS 2.3.2 'state' field not found in SqlMapConfigParser class - "
                                + "please upgrade to IBATIS 2.3.2 or higher in order to use the new 'mappingLocations' feature. "
                                + ex);
            }
            return new SqlMapParser(state);
        }
    }

}

 

修改Spring配置文件

<bean id="sqlMapClient" class="com.foo.xxx.MySqlMapClientFactoryBean">
  <property name="configLocation"
value="classpath:/com/foo/xxx/sqlMapConfig.xml" />
  <property name="dataSource" ref="dataSource" />
  <property name="mappingLocations" value="classpath*:/**/sqlmap/*SqlMap.xml" />
</bean>

 
至此,cache又工作如初了。
编后:
·反射会消耗,但仅仅在初始化时一次性消耗,还可以接受。
·iBatis的cache能力比较弱,但没太多要求的情况下是个省事的方案,聊胜于无。
·Mybatis3.0的cache暂时不用为好,EhCache选项本身还有bug,实在很无语。

 

分享到:
评论

相关推荐

    spring+ibatis事务的配置

    很好的spring+ibatis事务的配置文档.

    Spring与iBATIS的集成

    Spring与iBATIS的集成 iBATIS似乎已远离众说纷纭的OR框架之列,通常人们对非常流行的Hibernate情有独钟。但正如Spring A Developer's Notebook作者Bruce Tate 和Justin Gehtland所说的那样,与其他的OR框架相比...

    解决IBatis缓存动态字段问题

    ### 解决IBatis缓存动态字段问题 #### 背景与问题描述 在使用IBatis框架处理数据库操作时,可能会遇到动态数据表名、动态字段名的情况。这种情况下,由于IBatis的缓存机制,可能导致字段找不到的问题。具体表现为...

    spring+ibatis+oracle分页缓存源码

    在Spring+iBatis+Oracle体系中,缓存可以分为两种类型:一级缓存(本地缓存)和二级缓存。 一级缓存是iBatis默认提供的,它存在于SqlSession级别,同一SqlSession内的多次查询会共享结果,避免了重复的数据库访问。...

    Struts2 Spring Hibernate IBatis

    Struts2 Spring Hibernate IBatis Struts2 Spring Hibernate IBatisStruts2 Spring Hibernate IBatisStruts2 Spring Hibernate IBatis 只需要导入相应的jar包就行了 ,数据库是mysql :数据库名叫做mydatabase,表名...

    maven搭建SpringMVC+spring+ibatis

    在IT行业中,构建高效、可扩展的Web应用是至关重要的,而"Maven搭建SpringMVC+Spring+Ibatis"的组合则提供了一种强大的解决方案。本文将深入探讨这些技术及其集成,帮助你理解和掌握如何利用它们来构建现代化的Java ...

    spring ibatis整合所需jar包

    在Java Web开发中,Spring和iBatis是两个非常重要的框架。Spring是一个全面的后端开发框架,提供了依赖注入、AOP(面向切面编程)、事务管理等特性,而iBatis则是一个优秀的持久层框架,它将SQL语句与Java代码分离,...

    Struts2+Spring+Hibernate和Struts2+Spring+Ibatis

    Struts2+Spring+Hibernate和Struts2+Spring+Ibatis是两种常见的Java Web应用程序集成框架,它们分别基于ORM框架Hibernate和轻量级数据访问框架Ibatis。这两种框架结合Spring,旨在提供一个强大的、可扩展的、易于...

    Spring+ibatis 保留ibatis事务的配置

    根据提供的文件信息,本文将详细解析如何在Spring与ibatis框架整合时,通过特定配置来保留ibatis事务处理机制,并实现对事务的自定义控制。文章将围绕标题、描述及部分代码片段展开讨论。 ### Spring与ibatis整合...

    Spring+Ibatis技术

    Spring+Ibatis技术:很好的架构文档

    spring-ibatis简单集成

    在IT行业中,Spring框架与iBatis的集成是常见的数据访问解决方案,特别是在Java Web开发中。这个集成将Spring的依赖注入特性和iBatis的SQL映射功能相结合,提供了高效且灵活的数据操作方式。让我们深入探讨一下这个...

    spring+ibatis配置实例

    在IT行业中,Spring框架与iBatis(现为MyBatis)是两个广泛使用的开源库,主要用于构建企业级Java应用程序。本实例将介绍如何将它们整合以实现数据访问层的操作。"spring+ibatis配置实例"这个项目提供了一个完整的...

    Struts+Spring+Ibatis示例

    Struts、Spring 和 iBatis 是 Java Web 开发中三个非常重要的开源框架,它们共同构建了一个灵活、可扩展且易于维护的系统架构。这个"Struts+Spring+Ibatis示例"提供了一个基础的整合应用,帮助开发者理解这三者如何...

    Spring对IBatis的整合

    ### Spring对IBatis的整合 #### 一、Spring与IBatis整合概述 Spring框架与IBatis(现称为MyBatis)的整合为开发者提供了一种更简洁、更强大的数据库访问方式。Spring通过其内置的支持机制极大地简化了原有的IBatis...

    struts+spring+ibatis做的一个增删改查例子

    Struts、Spring 和 iBATIS 是Java开发领域中三大经典的开源框架,它们组合起来可以构建出高效、可维护的企业级Web应用。这个例子是利用这三个框架实现了一个基础的增删改查(CRUD)功能,涵盖了数据库操作、业务逻辑...

    Spring struts ibatis Mysql 集成

    在IT行业中,集成Spring、Struts和iBatis与MySQL是构建企业级Java Web应用程序的常见选择。这个项目集成了Spring 2.5.5、Struts 2.1.6、iBatis 2.3.4以及MySQL 5.1数据库,使用IntelliJ IDEA 9作为开发环境。下面将...

    spring-ibatis

    在IT行业中,Spring框架和iBatis持久层框架的整合是一个常见的应用场景,旨在提供更加灵活且高效的数据库操作。"spring-ibatis"项目的核心目标就是将Spring的依赖注入特性和iBatis的数据访问能力结合起来,使得开发...

Global site tag (gtag.js) - Google Analytics