当前位置 : 首页 » 文章分类 :  开发  »  Hibernate

Hibernate

Hibernate 使用笔记

Hibernate 3.2 中文文档
http://static.kancloud.cn/wizardforcel/java-opensource-doc/112234


Hibernate 对象状态

Chapter 11. Working with objects
https://docs.jboss.org/hibernate/core/3.6/reference/en-US/html/objectstate.html

第 11 章 与对象共事
http://itmyhome.com/hibernate/objectstate.html

Hibernate 对象三种状态

Hibernate 对象有三种状态:

  • Transient 临时态/瞬时态
  • Persistent 持久态
  • Detached 游离态/脱管态
对象状态 是否处于Session缓存中 数据库中是否有对应记录 是否有主键ID
Transient 临时态 × × ×
Persistent 持久态
Detached 游离态 ×

Transient 临时态/瞬时态

Transient 临时态/瞬时态,指从对象通过 new 语句创建到被持久化之前的状态,此时对象不在 Session 的缓存中,不与任何 Session 实例相关联,在数据库中没有与之对应的记录,主键 id 为 null。

如何获得临时态对象?
1、通过new语句创建新对象。
2、执行对象的 delete() 方法,对于游离状态的对象,delete() 方法会将其与数据库中对应的记录删除;而对于持久化状态的对象,delete() 方法会将其与数据库中对应的记录删除并将其在 Session 缓存中删除。

Persistent 持久态

Persistent 持久态,是指从 对象被持久化 到 Session对象被销毁 之前的状态,此时对象在 Session 的缓存中,与 Session 实例相关联,在数据库中有与之对应的记录,主键 id 不为 null。
注意:Session 在清理缓存的时候,会根据持久化对象的属性变化更新数据库

如何获得持久态对象?
1、临时对象调用 save() 或 update() 等持久化方法后会变为持久态对象。
2、执行 load(), findOne() 或 findAll() 等查询方法返回的都是持久态对象。

Detached 游离态/脱管态

Detached 游离态/脱管态,是指从 持久化对象的Session对象被销毁 到 该对象消失 之前的状态,此时对象不在 Session 的缓存中,不与任何 Session 实例相关联,但在数据库中有与之对应的记录,id 不为 null。

如何获得游离态对象?
1、调用 close/clear 方法清理 session 中的缓存对象,则所有的缓存对象都会变为游离态。
2、调用 session.evict() 方法,从缓存中删除一个持久态对象,则此对象变为游离态。

脱管(Detached)对象如果重新关联到某个新的 Session 上, 会再次转变为持久(Persistent)的(在Detached其间的改动将被持久化到数据库)。


如何判断一个Hibernate对象的状态?

可通过 session 中是否包含 entity session.contains(entity) 方法判断对象是否持久态,在 session 中就是持久态。
对于非持久态对象,可通过是否有主键id判断是否游离态,无id的是临时态,有id的是游离态。

另外,SessionImpl.contains() 内有 persistenceContext.getEntry(object).getStatus() 获取对象状态(MANAGED,READ_ONLY,DELETED,GONE,LOADING,SAVING),但外部无法访问。


Hibernate 对象状态转换图


Hibernate 对象状态转换图

Hibernate 对象状态与操作示例

save() 之后对象就有主键ID

例1、持久化对象上的更新操作在事务提交/flush()后会被更新到数据库

@Test
public void testExample1() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = new UserDO(); // new 对象时临时态
    userDO.setName("小明");
    session.save(userDO); // save 后变为持久态,id被填充
    userDO.setName("李华");
    session.getTransaction().commit();  // 事务结束时,持久化对象的属性变化会被自动更新到数据库
}

注意:
(1)执行完save后还未到事务提交,就会触发insert sql,实体也就有了id(由于事务还未提交,在其他Session中看不到这个数据)


save之后实体就有了ID

(2)最后一次在持久化对象上 setName 更新后,虽然没有执行 save 操作,但事务结束 commit 时也会将更新保存到数据库,事务提交时会发出一条更新sql,总共发出两条sql:

Hibernate: 
    insert 
    into
        user
        (address, birthday, has_children, name) 
    values
        (?, ?, ?, ?)
Hibernate: 
    update
        user 
    set
        address=?,
        birthday=?,
        has_children=?,
        name=? 
    where
        id=?

多次更新只最后提交时发出sql

例2、持久态对象在事务提交前,无论显示执行多少次 save/update 操作,hibernate 都不会发送 sql 语句,只有当事物提交/flush()的时候 hibernate 才会拿当前这个对象与之前保存在session 中的持久化对象进行比较,如果不相同就发送一条 update 的 sql 语句,否则就不会发送 update 语句

@Test
public void testExample2() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = new UserDO(); // new 对象是临时态
    userDO.setName("小明");
    session.save(userDO); // save 后变为持久态
    userDO.setName("李华");
    session.save(userDO); // 不会立即执行 insert 语句
    userDO.setAddress("北京市");
    session.update(userDO);
    session.getTransaction().commit(); // 事务结束时发出 insert 和 update 两条语句
}

第一个 save() 执行完会发出 insert sql,然后后面的 save 和 update 都不会再发出sql,直到最后事务提交时会发出一条 update sql,总共两条sql:

Hibernate: 
    insert 
    into
        user
        (address, birthday, has_children, name, id) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    update
        user 
    set
        address=?,
        birthday=?,
        has_children=?,
        name=? 
    where
        id=?

事务提交后持久态对象的修改自动更新到数据库

例3、

@Test
public void testExample3() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = session.get(UserDO.class, 1L); // 查询出来的是持久态对象
    System.out.println(userDO);
    userDO.setName("姓名改");
    session.getTransaction().commit(); // 最后提交事务时对持久态对象的修改会被更新到数据库
}

之前一段类似下面的代码,忘了写 save() 语句,但发现还是成功更新了user的name,不知道为什么

public void updateUser(req) {
    UserDO user = jpaRepository.findOne(1L);
    user.setName(req.getName());
}

事务结束时,Hibernate 会自动保存更新了的 DO,在了解 Hibernate 对象状态前不知道为什么,查问题才查到 Hibernate 对象状态相关信息。

https://stackoverflow.com/questions/12859305/hibernate-how-to-disable-automatic-saving-of-dirty-objects
https://stackoverflow.com/questions/8190926/transactional-saves-without-calling-update-method

游离态对象

例4、游离态对象

@Test
public void testExample4() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = session.get(UserDO.class, 1L); // 查询出来的是持久态对象
    System.out.println(userDO);
    session.clear(); // 清空 session,user变为游离态
    userDO.setName("姓名改");
    System.out.println("Entity在当前Session中:" + session.contains(userDO));
    session.getTransaction().commit(); // 提交事务时无需更新
}

调用 session.clear() 方法会将 session 的缓存对象清空,那么 session 中就没有了 user 这个对象,这个时候在提交事务的时候,发现 session 中已经没有该对象了,所以就不会进行任何操作,对象的修改也不会更新到数据库。
session.contains(userDO) 返回 false,游离态对象不在 session 中。

持久态对象无法修改ID

例5、如果试图修改一个持久化对象的ID值的话,就会抛出异常
javax.persistence.PersistenceException: org.hibernate.HibernateException: identifier of an instance of com.masikkk.common.jpa.UserDO was altered from 1 to 22

@Test
public void testExample5() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = session.get(UserDO.class, 1L); // 查询出来的是持久态对象
    System.out.println(userDO);
    userDO.setId(22L); // 运行时会抛异常
    session.getTransaction().commit();
}

https://www.cnblogs.com/xiaoluo501395377/p/3380270.html
https://www.baeldung.com/hibernate-session-object-states


Hibernate 缓存

一级缓存(Session级缓存)

一级缓存的生命周期和 Session 一致,也称为 Session 级缓存,或者事务级缓存。
一级缓存时默认开启的,不可关闭。

查询:当在同一个 Session 中查询同一个对象时,Hibernate 不会再次与数据库交互,直接从 Session 缓存中取。
更新:save/update 更新对象时,Hibernate 不会立即发出 SQL 将这个数据存到数据库,而是将它放在了 Session 的一级缓存中,直到调用 flush() 或提交事务时才会一并存到数据库。

例1、下面两次根据ID查询user,只会执行一次select语句,第二次会从 Session 缓存中获取

@Test
public void testSessionCache1() {
    Session session = HibernateUtils.openSession();
    System.out.println(session.get(UserDO.class, 1L));
    System.out.println(session.get(UserDO.class, 1L)); // 有缓存,只执行一次查询
}

例2、HQL 查询不使用 Session 缓存

@Test
public void testSessionCache2() {
    Session session = HibernateUtils.openSession();
    System.out.println(session.get(UserDO.class, 1L));

    // HQL 查询不走缓存,还是会执行sql
    Query idQuery = session.createQuery("from UserDO where id = :id");
    idQuery.setParameter("id", 1L);
    List<UserDO> userDOList = idQuery.list();
    userDOList.forEach(System.out::println);

    // 再执行一次 HQL 查询还是不走缓存,会再执行一次sql
    userDOList = idQuery.list();
}

例3、虽然 HQL 查询不走缓存,但是会将数据放入Session缓存,第二次根据id get查询直接从缓存取

@Test
public void testSessionCache3() {
    Session session = HibernateUtils.openSession();
    Query nameQuery = session.createQuery("from UserDO where name = :n");
    nameQuery.setParameter("n", "李华6");
    List<UserDO> userDOList = nameQuery.list();
    userDOList.forEach(System.out::println);

    // 虽然 HQL 查询不走缓存,但是会将数据放入Session缓存,第二次根据id get查询直接从缓存取
    System.out.println(session.get(UserDO.class, 6L));
}

二级缓存(应用级缓存)

二级缓存是全局性的,可以在多个 Session 中共享数据,二级缓存称为是 SessionFactory 级缓存,或应用级缓存。
二级缓存默认没有开启,需要手动配置缓存实现(EhCache等)才可以使用。开启二级缓存后,根据id查询数据时,会先在一级缓存查询,没有再去二级缓存,还没有再去数据库查。
在 Spring Data 中可通过 javax.persistence.Cacheable 等注解使用。

注意:一级缓存和二级缓存都是只在根据 ID 查询对象时起作用,并不对全部查询生效。

例1、默认二级缓存是不开启的,两个Session分别查id=1的数据,会执行两次sql

@Test
public void testSecondLevelCache() {
    Session session1 = HibernateUtils.openSession();
    System.out.println(session1.get(UserDO.class, 1L));

    Session session2 = HibernateUtils.openSession();
    System.out.println(session2.get(UserDO.class, 1L));
}

开启二级缓存

1、打开二级缓存开关,测试过程中发现 use_second_level_cache 开关默认是开启的

<property name="hibernate.cache.use_second_level_cache">true</property>
<property name="hibernate.cache.region.factory_class"> org.hibernate.cache.ehcache.internal.EhCacheRegionFactory</property>

或在 properties 文件中配置:

hibernate.cache.use_second_level_cache=true
hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory

或者在 Spring-Boot yml 配置:

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
          provider_configuration_file_resource_path: ehcache.xml

2、二级缓存需要在各个实体上单独开启,注解需要二级缓存的实体

import javax.persistence.Cacheable;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Foo {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "ID")
    private long id;
}

缓存并发策略

READ_ONLY 只适用于只读的实体/表
NONSTRICT_READ_WRITE 事务提交后会更新缓存,非强一致性,可能读到脏数据,适用于可容忍一定非一致性的数据。
READ_WRITE 保证强一致性,缓存实体被更新时会加一个软锁,更新提交后软锁释放。访问加了软锁的缓存实体的并发请求会直接从数据库取数据。
TRANSACTIONAL 缓存与数据库数据之间加 XA 分布式事务,保证两者数据一致性。

Hibernate Second-Level Cache
https://www.baeldung.com/hibernate-second-level-cache


查询(Query)缓存

Hibernate 的一、二级缓存都是根据对象 id 来查找时才起作用,如果需要缓存任意查询条件,就需要用到查询缓存。

Hibernate 配置开启 query 缓存

<property name="hibernate.cache.use_query_cache">true</property>

使用 Spring-Data-Jpa 时可在 application.yml 中配置开启

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_query_cache: true

Session

save()/persist() 区别

persist() 把一个瞬态的实例持久化,但是并”不保证”标识符(identifier主键对应的属性)被立刻填入到持久化实例中,标识符的填入可能被推迟到 flush() 的时候。
save() 把一个瞬态的实例持久化,保证立即返回一个标识符。如果需要运行 INSERT 来获取标识符(如 “identity” 而非 “sequence” 生成器),这个 INSERT 将立即执行,即使事务还未提交。
经测试,主键生成策略是 IDENTITY 的实体,在 MySQL 上测试,发现 persist/save 都会立即执行 insert 语句(事务还未提交),然后实体都具有了主键 id。

对象id不为 null 时,persist() 方法会抛出 PersistenceException 异常
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.masikkk.common.jpa.UserDO

@Test
public void testPersistWithId() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = new UserDO();
    userDO.setId(20L);
    userDO.setName("persist持久化带id");
    Exception e = Assertions.assertThrows(PersistenceException.class, () -> session.persist(userDO));
    System.out.println(e);
    session.getTransaction().commit();
}

对象id不为 null 时,save() 方法会忽略id,当做一个新对象正常保存

@Test
public void testSaveWithId() {
    Session session = HibernateUtils.openSession();
    session.beginTransaction();
    UserDO userDO = new UserDO();
    userDO.setId(20L);
    userDO.setName("save持久化带id");
    session.save(userDO);
    session.getTransaction().commit();
}

上面的 user id 被设为 20,但 save 之后可以看到 id 是 14。


save带id的实体

get()/load() 区别

get/load 都可以根据主键 id 从数据库中加载一个持久化对象。
get/load 都会先从一级缓存中取,如果没有,get 会立即向数据库发请求,而 load 会返回一个代理对象,直到用户真的去使用数据,才会向数据库发请求,即 load 方法支持延迟加载策略,而 get 不支持

当数据库中不存在与 id 对应的记录时, load() 方法抛出 ObjectNotFoundException 异常, 而 get() 方法返回 null

@Test
public void testGetNotExistId() {
    Session session = HibernateUtils.openSession();
    UserDO userDO = session.get(UserDO.class, 1000L);
    System.out.println(userDO);
    Assertions.assertNull(userDO);
}

@Test
public void testLoadNotExistId() {
    Session session = HibernateUtils.openSession();
    UserDO userDO = session.load(UserDO.class, 2020L); // 此时不会抛异常
    System.out.println(userDO); // 使用userDO时才真正去获取,才抛异常
}

load() 是延迟加载的,session.load() 虽然查不到但不会立即抛异常,之后在真正使用 userDO 实体时才去查询并抛异常,如果之后一直不使用 userDO 就一直不抛异常
org.hibernate.ObjectNotFoundException: No row with the given identifier exists: [com.masikkk.common.jpa.UserDO#1000]


Hibernate 配置

hibernate.hbm2ddl.auto

hibernate.hbm2ddl.auto 参数的作用主要用于:自动创建,更新,验证数据库表结构。
在 SpringBoot 中的配置项是 spring.jpa.properties.hibernate.hbm2ddl.auto
有几种配置:
update 最常用的属性值,第一次加载 Hibernate 时创建数据表(前提是需要先有数据库),以后加载 Hibernate 时不会删除上一次生成的表,会根据实体更新,只新增字段,不会删除字段(即使实体中已经删除)。
validate 每次加载 Hibernate 时都会验证数据表结构,只会和已经存在的数据表进行比较,根据 model 修改表结构,但不会创建新表。
create 每次加载 Hibernate 时都会删除上一次生成的表(包括数据),然后重新生成新表,即使两次没有任何修改也会这样执行。适用于每次执行单测前清空数据库的场景。
create-drop 每次加载 Hibernate 时都会生成表,但当 SessionFactory 关闭时,所生成的表将自动删除。
none 不执行任何操作。将不会生成架构。适用于只读库。

不配置此项,表示禁用自动建表功能


globally_quoted_identifiers 标识符加反引号

将 SQL 中的标识符(表名,列名等)全部用 反引号(MySQL) 括起来,可解决表名、列名和 MySQL 标识符冲突的问题
spring.jpa.properties.hibernate.globally_quoted_identifiers=true

其实是根据 spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect 配置的具体 SQL 方言来决定用什么符号将标识符括起来,比如 MySQL 中是反引号,达梦数据库中是双引号


Hibernate命名策略

Hibernate隐式命名策略和物理命名策略

hibernate 5.1 之前,命名策略通过 NamingStrategy 接口实现,但是 5.1 之后该接口已废弃。
hibernate 5.1 之后,命名策略通过 隐式命名策略 接口 ImplicitNameSource物理命名策略 接口 PhysicalNamingStrategy 共同作用实现。

在 Spring 中使用时通过下面两个步骤来确定:
第一步:如果我们没有使用 @Table 或 @Column 指定了表或字段的名称,则由 SpringImplicitNamingStrategy 为我们隐式处理,表名隐式处理为类名,列名隐式处理为字段名。如果指定了表名列名,SpringImplicitNamingStrategy 不起作用。
第二步:将上面处理过的逻辑名称解析成物理名称。无论在实体中是否显示指定表名列名 SpringPhysicalNamingStrategy 都会被调用。

JPA大写表名自动转换为小写提示表不存在

有个全大写的表名 MYTABLE, 实体类如下,通过 @Table 注解的 name 属性指定了大写的表名

@Data
@Entity
@Table(name = "MYTABLE")
public class MYTABLEDO {
    ...
}

运行报错,提示找不到小写的表名
com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Table ‘mydb.mytable’ doesn’t exist
貌似 jpa 的默认表命名策略是都转为小写表名。

原因:
spring data jpa 是基于 hibernate 5.0 , 而 Hibernate 5 关于数据库命名策略的配置与之前版本略有不同:
不再支持早期的 hibernate.ejb.naming_strategy,而是改为两个配置项分别控制命名策略:
hibernate.physical_naming_strategy
hibernate.implicit_naming_strategy
在 spring 中的配置项是

spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

implicit-strategy 负责模型对象层次的处理,将对象模型处理为逻辑名称
physical-strategy 负责映射成真实的数据名称的处理,将上述的逻辑名称处理为物理名称。
当没有使用 @Table 和 @Column 注解时,implicit-strategy 配置项才会被使用,当对象模型中已经指定 @Table 和 @Column 时,implicit-strategy 并不会起作用。
physical-strategy 一定会被应用,与对象模型中是否显式地指定列名或者已经被隐式决定无关。

physical-strategy 策略常用的两个实现有
org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy 这个是 Spring data jpa 的默认数据库命名策略。
org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

解决方法一:
可以在 springboot 项目中配置文件内加上配置行,设置命名为 无修改命名策略:
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

解决方法二:
1 重写命名策略中改表名为小写的方法:

import org.hibernate.boot.model.naming.Identifier;
import org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl;
import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment;

/**
 * 重写 hibernate 对于命名策略中改表名大写为小写的方法
 */
public class MySQLUpperCaseStrategy extends PhysicalNamingStrategyStandardImpl {
    @Override
    public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) {
        String tableName = name.getText().toUpperCase();
        return name.toIdentifier(tableName);
    }
}

2 在对应配置文件中 使用自己实现的策略
spring.jpa.hibernate.naming.physical-strategy=com.xxx.xxx.util.MySQLUpperCaseStrategy

解决 springboot + JPA + MySQL 表名全大写 出现 “表不存在” 问题(Table ‘XXX.xxx’ doesn’t exist)
https://blog.csdn.net/jiangyu1013/article/details/80409082

当JPA遇上MySQL表名全大写+全小写+驼峰+匈牙利四风格
https://blog.51cto.com/yerikyu/2440787


JPA实现根据SpringBoot命令行参数动态设置表名

背景:
使用 JPA 操作的一个表名经常变化(表结构不变)的表,每过一段时间就做一次数据升级,升级时表名会变,但表结构不变,使用 @Table(name = "xxx") 注解实体 bean 来做映射。
表名变化后可以改配置参数重启,但尽量不要改代码,因为改代码还得重新发包。
有如下几种方案:
1 JPA 中通过 @Query 手动拼 sql,这样不仅不需要重启,利用配置项字典表还能运行中动态改表名。但不想这样做,都使用 JPA 了还手动拼sql,太麻烦。
2 Hibernate 拦截器改表名,只是偶然搜到了能这么做,没实现过。
Hibernate 拦截器的使用–动态表名
https://my.oschina.net/cloudcross/blog/831277

3 自定义物理命名策略 SpringPhysicalNamingStrategy,在其中读取配置项,修改指定的表名,参考下面这篇文章。
我完全照着这篇文章实现的话,总是无法成功,每次运行到 toPhysicalTableName() 方法时,之前注入的 parser 就变为 null 了,后来仔细看了看,发现被 ApplicationContextAware 回调的,和运行 toPhysicalTableName() 方法的,是两个不同的 MySpringPhysicalNamingStrategy 实例,导致无法读取配置。
Spring Data JPA自定义实现动态表名映射
https://blog.csdn.net/u014229347/article/details/88892559

最后改了改实现,简化为在命令行参数中 -Dtable.name=xxx 配置表名,在自定义命名策略中直接 System.getProperty("table.name") 读取命令行参数。

完整步骤:
1 自定义物理命名策略

@Slf4j
@Component
public class ConfigurableNamingStrategy extends SpringPhysicalNamingStrategy {
    @Override
    public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment jdbcEnvironment) {
        if (StringUtils.equals(name.getText(), "table_name_placeholder")) {
            String tableName = System.getProperty("table.name");
            log.info("新表名: {}", tableName);
            return Identifier.toIdentifier(tableName);
        } else {
            // 其他表不变
            return super.toPhysicalTableName(name, jdbcEnvironment);
        }
    }
}

2 实体上直接注解为假的表名占位符

@Data
@Entity
@Table(name = "table_name_placeholder")
public class MyDO {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "JDBC")
    private Long id;
}

3 Spring 配置文件中设置自定义命名策略

spring.jpa.hibernate.naming.physical-strategy=com.masikkk.persistence.config.ConfigurableNamingStrategy

4 SpringBoot 启动参数中添加表名配置项

nohup java -jar -Dtable.name=xxx myapp.jar &

注意,这种 -D System 参数必须放在 myapp.jar 之前才能被 System.getProperty() 读取到

Hibernate 自定义表名映射
https://segmentfault.com/a/1190000015305191


上一篇 DrawIO

下一篇 Linux-CGroup

阅读
评论
5.3k
阅读预计21分钟
创建日期 2022-10-19
修改日期 2022-10-28
类别

页面信息

location:
protocol:
host:
hostname:
origin:
pathname:
href:
document:
referrer:
navigator:
platform:
userAgent:

评论