Richie

Sometimes at night when I look up at the stars, and see the whole sky just laid out there, don't you think I ain't remembering it all. I still got dreams like anybody else, and ever so often, I am thinking about how things might of been. And then, all of a sudden, I'm forty, fifty, sixty years old, you know?

NHibernate考察系列 03 many-to-many 集合映射

   1. 创建关联
    表结构参考NHibernate考察系列 01。使用TBLPURCHASEGROUP与TBLUSER进行多对多关联,关联表为TBLPURCHASEGROUPUSER。
    PurchaseGroup和User类代码以及映射配置文件在文章最下面。在NHTest项目里面运行下面的测试代码
static void Main(string[] args)
{
    ISessionFactory sessionFactory 
= new Configuration().Configure().BuildSessionFactory();
    ISession session 
= null;
    ITransaction trans 
= null;
    
try
    {
        session 
= sessionFactory.OpenSession();
        trans 
= session.BeginTransaction();

        User user1 
= new User("user1""user1"new HashedSet<PurchaseGroup>());
        session.Save(user1);
        User user2 
= new User("user2""user2"new HashedSet<PurchaseGroup>());
        session.Save(user2);
        User user3 
= new User("user3""user3"new HashedSet<PurchaseGroup>());
        session.Save(user3);
        PurchaseGroup group1 
= new PurchaseGroup("01A""一采01A"new HashedSet<User>());
        group1.Users.Add(user1);
        group1.Users.Add(user2);
        session.Save(group1);
        PurchaseGroup group2 
= new PurchaseGroup("701""分公司采购组"new HashedSet<User>());
        group2.Users.Add(user2);
        group2.Users.Add(user3);
        session.Save(group2);

        trans.Commit();
    }
    
catch
    {
        
if (trans != null)
            trans.Rollback();
    }
    
finally
    {
        session.Close();
    }

    sessionFactory.Close();
}
    执行上面的测试代码后,不仅添加了三个User对象和两个PurchaseGroup对象,NHibernate自动根据映射文件中的配置信息,在TBLPURCHASEGROUPUSER关联表中也添加好了关联关系。

    2. 主映射端 反向端
    首先注意到配置文件中的set元素上有个inverse属性,表明关联的方向。在User.hbm.xml文件中inverse属性值为true,说明 User是这个关联的反向端,而PurchaseGroup是主映射端。主映射端与反向端的区别,我们可以用下面的代码运行结果跟上面的运行结果做个比 较:
trans = session.BeginTransaction();
User user 
= new User("user4""user4"new HashedSet<PurchaseGroup>());
PurchaseGroup group1 
= session.Get<PurchaseGroup>("701");
if (group1 != null)
    user.PurchaseGroups.Add(group1);
session.Save(user);
trans.Commit();
    这段代码的运行结果是user4这个对象被添加到数据库,但是user4和701的关联关系没有被添加到关联表中。从这个比较中可以看出,对映射关系做修 改(新增、删除)必须在主映射端完成,否则无法保存到数据库中。因此,在双向的many-to-many映射中,必须清楚哪个主映射端、哪个是反向端。

    3. 获取关联的集合对象
    现在注意,上面的测试已经在关联表中添加了如下的数据:
   
    接下来看一下怎样获取many-to-many集合对象,首先注意到在User.hbm.xml和Company.hbm.xml文件的set元素下, lasy属性被设成了false。打开SQL Server的SQL Profiler,监控执行的SQL语句。单步执行下面的测试语句,并随时观察SQL Profiler中监控到的SQL。
static void Main(string[] args)
{
    ISessionFactory sessionFactory 
= new Configuration().Configure().BuildSessionFactory();
    ISession session 
= null;

    session 
= sessionFactory.OpenSession();
    PurchaseGroup group 
= session.Get<PurchaseGroup>("01A");
    
if (group!=null && group.Users != null)
    {
        IEnumerator
<User> enm = group.Users.GetEnumerator();
        
while (enm.MoveNext())
        {
            User user1 
= enm.Current;
            Console.WriteLine(
"id: {0}\t\tname: {1}", user1.UserID, user1.UserName);
        }
    }
    Console.WriteLine(
"");

    User user2 
= session.Get<User>("user2");
    
if (user2 != null && user2.PurchaseGroups != null)
    {
        IEnumerator
<PurchaseGroup> enm = user2.PurchaseGroups.GetEnumerator();
        
while (enm.MoveNext())
        {
            PurchaseGroup group2 
= enm.Current;
            Console.WriteLine(
"id: {0}\t\tdescription: {1}", group2.PurchaseGroupID, group2.Description);
        }
    }

    session.Close();
    sessionFactory.Close();
    Console.ReadLine();
}
    上面的测试代码运行正常,我们使用session.Get()方法获取PurchaseGroup或者User对象的时候,NHibernate会自动加 载相关的集合对象,因此我们可以直接枚举group.Users和user2.PurchaseGroups,将集合中的对象打印出来。
    SQL Profiler的结果:在执行PurchaseGroup group = session.Get<PurchaseGroup>("01A");这一行代码时,会执行6个SQL查询,其余地方的代码再没有SQL。6个SQL查询如下
SQL1: exec sp_executesql N'
    SELECT purchasegr0_.PURCHASE_GROUP_ID as PURCHASE1_1_0_, purchasegr0_.DESCRIPTION as DESCRIPT2_1_0_ 
    FROM TBLPURCHASEGROUP purchasegr0_ 
    WHERE purchasegr0_.PURCHASE_GROUP_ID=@p0
', N'@p0 nvarchar(3)'@p0 = N'01A'
SQL2: exec sp_executesql N'
    SELECT users0_.PURCHASE_GROUP_ID as PURCHASE1___1_, users0_.USER_ID as USER2_1_, 
    user1_.USER_ID as USER1_3_0_, user1_.USER_NAME as USER2_3_0_ 
    FROM TBLPURCHASEGROUPUSER users0_ 
    left outer join TBLUSER user1_ on users0_.USER_ID=user1_.USER_ID 
    WHERE users0_.PURCHASE_GROUP_ID=@p0
', N'@p0 nvarchar(3)'@p0 = N'01A'
SQL3: exec sp_executesql N'
    SELECT purchasegr0_.USER_ID as USER2___1_, purchasegr0_.PURCHASE_GROUP_ID as PURCHASE1_1_, 
    purchasegr1_.PURCHASE_GROUP_ID as PURCHASE1_1_0_, purchasegr1_.DESCRIPTION as DESCRIPT2_1_0_ 
    FROM TBLPURCHASEGROUPUSER purchasegr0_ 
    left outer join TBLPURCHASEGROUP purchasegr1_ on purchasegr0_.PURCHASE_GROUP_ID=purchasegr1_.PURCHASE_GROUP_ID 
    WHERE purchasegr0_.USER_ID=@p0
', N'@p0 nvarchar(5)'@p0 = N'user2'
SQL4: exec sp_executesql N'
    SELECT users0_.PURCHASE_GROUP_ID as PURCHASE1___1_, users0_.USER_ID as USER2_1_, 
    user1_.USER_ID as USER1_3_0_, user1_.USER_NAME as USER2_3_0_ 
    FROM TBLPURCHASEGROUPUSER users0_ 
    left outer join TBLUSER user1_ on users0_.USER_ID=user1_.USER_ID 
    WHERE users0_.PURCHASE_GROUP_ID=@p0
', N'@p0 nvarchar(3)'@p0 = N'701'
SQL5: exec sp_executesql N'
    SELECT purchasegr0_.USER_ID as USER2___1_, purchasegr0_.PURCHASE_GROUP_ID as PURCHASE1_1_, 
    purchasegr1_.PURCHASE_GROUP_ID as PURCHASE1_1_0_, purchasegr1_.DESCRIPTION as DESCRIPT2_1_0_ 
    FROM TBLPURCHASEGROUPUSER purchasegr0_ 
    left outer join TBLPURCHASEGROUP purchasegr1_ on purchasegr0_.PURCHASE_GROUP_ID=purchasegr1_.PURCHASE_GROUP_ID 
    WHERE purchasegr0_.USER_ID=@p0
', N'@p0 nvarchar(5)'@p0 = N'user3'
SQL6: exec sp_executesql N'
    SELECT purchasegr0_.USER_ID as USER2___1_, purchasegr0_.PURCHASE_GROUP_ID as PURCHASE1_1_, 
    purchasegr1_.PURCHASE_GROUP_ID as PURCHASE1_1_0_, purchasegr1_.DESCRIPTION as DESCRIPT2_1_0_ 
    FROM TBLPURCHASEGROUPUSER purchasegr0_ 
    left outer join TBLPURCHASEGROUP purchasegr1_ on purchasegr0_.PURCHASE_GROUP_ID=purchasegr1_.PURCHASE_GROUP_ID 
    WHERE purchasegr0_.USER_ID=@p0
', N'@p0 nvarchar(5)'@p0 = N'user1'
    SQL1: 获取PurchaseGroup对象01A
    SQL2: 自动根据TBLPURCHASEGROUPUSER关联表获取01A对象关联到哪些User对象,以填充01A的Users集合。
    SQL3: 在SQL2中得到了两个用户user1和user2,并且这两个用户的属性在SQL2中都已经取出来了。但NHibernate还必须知道user1和 user2分别关联到哪些PurchaseGroup对象,这样才能生成完整的user1、user2两个对象,加入01A的Users集合中。这个语句 主要就是从
TBLPURCHASEGROUPUSER中查询user2关联的PurchaseGroup对象。
    接下来的SQL都是类似上面的方式,嵌套的处理,直到关联关系终止的地方。
    在执行
User user2 = session.Get<User>("user2");这一行代码时,因为在前面已经创建过user2这个对象(并且包括了完整的user2.PurchaseGroups集合对象),因此此处不再需要执行SQL查询了(参考NHibernate考察系列 01中提到的session缓存)。
    几点结论:1. 自动获取集合对象,使用了join语句。2. 自动填充集合对象,因此多对多关联中,关联的记录不要过多,否则每次需要加载过多数据。
2. 双向的many-to-many关联,会嵌套处理填充集合对象,特别注意这一点。

    4. lasy 延迟加载
    然后看一下lasy属性,我们把
User.hbm.xml 和Company.hbm.xml文件中set元素的lasy都设置成true。另外需要注意的地方,使用延迟加载的实体类,属性必须声明为 virtual的才行,否则NHibernate会丢出一个异常,这是因为NHibernate使用代理对象实现延迟加载。运行下面的测试代码,同样使用 SQL Profiler监控。
static void Main(string[] args)
{
    ISessionFactory sessionFactory 
= new Configuration().Configure().BuildSessionFactory();
    ISession session 
= null;

    session 
= sessionFactory.OpenSession();
    PurchaseGroup group 
= session.Get<PurchaseGroup>("01A");
    
int i = group.Users.Count;
    IEnumerator
<User> enm1 = group.Users.GetEnumerator();
    
while (enm1.MoveNext())
    {
        User user1 
= enm1.Current;
        Console.WriteLine(
"id: {0}\t\tname: {1}", user1.UserID, user1.UserName);
    }

    Console.WriteLine(
"");

    User user2 
= session.Get<User>("user2");
    
int j = user2.PurchaseGroups.Count;
    IEnumerator
<PurchaseGroup> enm2 = user2.PurchaseGroups.GetEnumerator();
    
while (enm2.MoveNext())
    {
        PurchaseGroup group2 
= enm2.Current;
        Console.WriteLine(
"id: {0}\t\tdescription: {1}", group2.PurchaseGroupID, group2.Description);
    }
    session.Close();
    sessionFactory.Close();

    Console.ReadLine();
}
    这个测试代码跟上一个差不多,就是多了int i = group.Users.Count;和int j = user2.PurchaseGroups.Count;这样两行代码。
    完成的内容跟
上一个测试代码一样,而执行的SQL语句要少,只有3个SQL,并且3个语句执行时机跟上面也不一样了。
SQL1: exec sp_executesql N'
    SELECT purchasegr0_.PURCHASE_GROUP_ID as PURCHASE1_1_0_, purchasegr0_.DESCRIPTION as DESCRIPT2_1_0_ 
    FROM TBLPURCHASEGROUP purchasegr0_ 
    WHERE purchasegr0_.PURCHASE_GROUP_ID=@p0
', N'@p0 nvarchar(3)'@p0 = N'01A'
SQL2: exec sp_executesql N'
    SELECT users0_.PURCHASE_GROUP_ID as PURCHASE1___1_, users0_.USER_ID as USER2_1_, 
    user1_.USER_ID as USER1_3_0_, user1_.USER_NAME as USER2_3_0_ 
    FROM TBLPURCHASEGROUPUSER users0_ 
    left outer join TBLUSER user1_ on users0_.USER_ID=user1_.USER_ID 
    WHERE users0_.PURCHASE_GROUP_ID=@p0
', N'@p0 nvarchar(3)'@p0 = N'01A'
SQL3: exec sp_executesql N'
    SELECT purchasegr0_.USER_ID as USER2___1_, purchasegr0_.PURCHASE_GROUP_ID as PURCHASE1_1_, 
    purchasegr1_.PURCHASE_GROUP_ID as PURCHASE1_1_0_, purchasegr1_.DESCRIPTION as DESCRIPT2_1_0_ 
    FROM TBLPURCHASEGROUPUSER purchasegr0_ 
    left outer join TBLPURCHASEGROUP purchasegr1_ on purchasegr0_.PURCHASE_GROUP_ID=purchasegr1_.PURCHASE_GROUP_ID 
    WHERE purchasegr0_.USER_ID=@p0
', N'@p0 nvarchar(5)'@p0 = N'user2'
    SQL1是由
PurchaseGroup group = session.Get<PurchaseGroup>("01A");这一行代码产生的;
    SQL2是由
int i = group.Users.Count;这一行代码产生;
    SQL3是由
int j = user2.PurchaseGroups.Count;这一行代码产生。
    综合延迟加载和session缓存,很容易得出为什么会是这样一个执行状况:第一次访问集合时(包括调试过程中通过监视窗口或者是鼠标移动到集合属性上查看资料时),NHibernate才会加载数据生成这个集合对象。
    从上面可以看出lasy特性在数据加载量方面带来了改善。

    5. cascade 级联更新
    cascade属性的用法。为PurchaseGroup.hbm.xml的set元素添加cascade="delete"属性,执行下面的测试语句,然后把
cascade="delete"删除再次执行这个测试
session = sessionFactory.OpenSession();

tran 
= session.BeginTransaction();
PurchaseGroup group 
= new PurchaseGroup("01B""01B"new HashedSet<User>());
User user 
= new User("RicCC""Richie"new HashedSet<PurchaseGroup>());
session.Save(user);
group.Users.Add(user);
session.Save(group);
tran.Commit();

tran 
= session.BeginTransaction();
session.Delete(group);
tran.Commit();
    上面测试先创建一个用户,一个采购组,并把他们关联起来;然后删除采购组。在没有指定
cascade属性情况下,删除操作先删除01B的多对多关联关系,然后删除01B对象;指定cascade="delete"时,删除操作最后会将用户对象RicCC也删除掉。

    6. 集合
排序
    接下来测试一下集合排序

    第一种方式,我们用sort属性实现。
    sort属性实现排序的原理,并不是通过数据库的ORDER BY子句实现,而是当集合对象被加入到集合类中时,通过我们提供的一个IComparer对象,使得对象在加入到集合后按照
IComparer对象实现的比较大小的方法按照顺序排列的。我用set元素为PurchaseGroup类实现Users集合,当指定sort属性之后,NHibernate将使用Iesi.Collections.SortedSet来存储Users集合对象。
    在这个示例里面,我希望
PurchaseGroup的Users集合是按照UserName属性排序的,首先我们准备好测试数据。注意到现在数据库里面的关联关系如下:
   
    所以为01A对象取出的Users集合是按照user1、user2这样的顺序,在使用UserName排序之后我希望顺序跟这个不一样,以看到我指定的 排序是起作用的,所以在数据库里面直接将TBLUSER表user2的USER_NAME修改为user0,这样01A中的Users按照 UserName排序后应当就是user2、user1这个顺序了(数据库中记录的顺序仍然没有变化)。
   
    接下来是实现和测试排序结果。首先实现一个UserNameComparer类
public class UserNameComparer : System.Collections.Generic.IComparer<User>
{
    
public int Compare(User x, User y)
    {
        
//null > not null
        if (x == null && y==null)
            
return 0;
        
if (x== null && y != null)
            
return 1;
        
if (x != null && y == null)
            
return -1;
        
return x.UserName.CompareTo(y.UserName);
    }
}
    然后修改PurchaseGroup默认构造器如下,其它的地方不用修改
public PurchaseGroup()
{
    _users 
= new SortedSet<User>(new UserNameComparer());
}
    在PurchaseGroup.hbm.xml的set元素中添加这个属性配置:sort="NH12.MyExample.Domain.UserNameComparer, Domain"。
    这样就已经实现按照UserName排序了,现在用下面的代码测试一下
ISessionFactory sessionFactory = new Configuration().Configure().BuildSessionFactory();
ISession session 
= null;
session 
= sessionFactory.OpenSession();
PurchaseGroup group 
= session.Get<PurchaseGroup>("01A");
IEnumerator
<User> enm1 = group.Users.GetEnumerator();
while (enm1.MoveNext())
{
    User user1 
= enm1.Current;
    Console.WriteLine(
"id: {0}\t\tname: {1}", user1.UserID, user1.UserName);
}

Console.ReadLine();
    运行结果如下,证明按照UserName排序成功了。
   

    第二种方式,使用order-by属性实现。
    order-by属性实现排序的原理,是在SQL查询语句中追加指定的ORDER BY子句实现。首先,order-by属性是针对数据库字段,而不是实体属性;其次,order-by中指定的字段必须在关联表中存在,这给使用 order-by方式带来一定限制。而使用sort属性,可以基于整个User对象的属性来进行。
    同样为了看出测试效果,我们让PurchaseGroup 01A对象的Users集合按照USER_ID降序排列。为了进行下面的测试,先把上一步测试中的排序方法给移除掉:删除
PurchaseGroup.hbm.xml的set元素中sort属性、注释掉PurchaseGroup类默认构造器中的_users = new SortedSet<User>(new UserNameComparer());这一行代码。
    另外一点,当为set元素指定了order-by属性后,NHibernate将使用Iesi.Collections.ListSet实现集合对象,因为我在这个例子中使用了范型的ISet接口,而NHibernate在内部
没有与Iesi.Collections.ListSet相对应的范型实现,所以如果在这个示例中直接加上order-by属性,会出现异常。对于使用非范型的ISet接口,这样做是不会有问题的。为了继续测试,我把集合由set元素改成bag元素,按照下面的步骤进行就可以。
    为Domain工程添加引用using System.Collections.Generic;,把PurchaseGroup类中所有的ISet接口替换成IList,其它的不要改。把 PurchaseGroup.hbm.xml中的set元素改成bag元素,在PurchaseGroup.hbm.xml的bag元素上添加order-by="USER_ID desc"。
    运行下面的代码测试
ISessionFactory sessionFactory = new Configuration().Configure().BuildSessionFactory();
ISession session 
= null;
session 
= sessionFactory.OpenSession();
PurchaseGroup group 
= session.Get<PurchaseGroup>("01A");
IEnumerator
<User> enm1 = group.Users.GetEnumerator();
while (enm1.MoveNext())
{
    User user1 
= enm1.Current;
    Console.WriteLine(
"id: {0}\t\tname: {1}", user1.UserID, user1.UserName);
}

Console.ReadLine();
    从Console的输出可以看到跟上一个测试一样的效果,即集合中Users对象的顺序为user2、user1。从监控的SQL语句可以看到在SQL中使用了ORDER BY。

    还有其它的方式实现集合排序,例如使用list、array集合映射元素,使用
子映射元素index,但他们需要在关联表中指定一个整型或者是其它用于排序的索引、键值字段,这种方式使用起来总有种不舒服不自然的感觉,可能是本人SQL方面用惯了,比较在意数据库结构,或者是喜欢比较直接的事情的缘故。
    另外,不同的集合映射元素与接口对应关系:<list> - IList, <map> -
IDictionary, <bag> - IList, <set> - ISet, <array> - IList。当指定了order-by或者sort属性后,使用的具体类会有一些变化,例如指定order-by后:<map> - Specialized.ListDictionary, <set> - ListSet;指定sort后:<map> - SortedList, <set> - SortedSet

    关于集合、集合排序的效率问题,NHibernate文档中有一些说明,关键还得参考NHibernate对集合的内部实现,以及.Net framework中相关集合操作的效率。如果集合数据量比较大而对性能要求又比较高,使用有序集合总归不是一件好事。
   
    8. 附 实体类 映射配置文件
    User类:
#region User
public class User
{
    
private string _userID;
    
private string _userName;
    
private ISet<PurchaseGroup> _purchaseGroups; // = new HashedSet<PurchaseGroup>();

    
public User(string id, string name, ISet<PurchaseGroup> groups)
    {
        _userID 
= id;
        _userName 
= name;
        _purchaseGroups 
= groups;
    }

    
public User()
    {
    }

    
public virtual string UserID
    {
        
get { return _userID; }
        
set { _userID = value; }
    }

    
public virtual string UserName
    {
        
get { return _userName; }
        
set { _userName = value; }
    }

    
public virtual ISet<PurchaseGroup> PurchaseGroups
    {
        
get { return _purchaseGroups; }
        
set { _purchaseGroups = value; }
    }

    
#region override
    
public override bool Equals(object obj)
    {
        
if (this == obj) return true;
        
if (obj == null || obj.GetType() != this.GetType())
            
return false;
        User user 
= obj as User;
        
return user != null && user.UserID == _userID;
    }

    
public override int GetHashCode()
    {
        
return _userID.GetHashCode();
    }

    
public override string ToString()
    {
        
return _userID;
    }
    
#endregion
}
#endregion

    PurchaseGroup类:
#region PurchaseGroup
public class PurchaseGroup
{
    
private string _purchaseGroupID;
    
private string _description;
    
private ISet<User> _users; // = new HashedSet<User>();

    
public PurchaseGroup(string id, string desc, ISet<User> users)
    {
        _purchaseGroupID 
= id;
        _description 
= desc;
        _users 
= users;
    }

    
public PurchaseGroup()
    {
    }

    
public virtual string PurchaseGroupID
    {
        
get { return _purchaseGroupID; }
        
set { _purchaseGroupID = value; }
    }

    
public virtual string Description
    {
        
get { return _description; }
        
set { _description = value; }
    }

    
public virtual ISet<User> Users
    {
        
get { return _users; }
        
set { _users = value; }
    }

    
#region override
    
public override bool Equals(object obj)
    {
        
if (this == obj) return true;
        
if (obj == null || obj.GetType() != this.GetType())
            
return false;
        PurchaseGroup group 
= obj as PurchaseGroup;
        
return group != null && group._purchaseGroupID == _purchaseGroupID;
    }

    
public override int GetHashCode()
    {
        
return _purchaseGroupID.GetHashCode();
    }

    
public override string ToString()
    {
        
return _purchaseGroupID;
    }
    
#endregion
}
#endregion

    配置文件PurchaseGroup.hbm.xml和User.hbm.xml
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" namespace="NH12.MyExample.Domain" assembly="Domain">
  
<class name="PurchaseGroup" table="TBLPURCHASEGROUP">
    
<id name="PurchaseGroupID" >
      
<column name="PURCHASE_GROUP_ID" sql-type="nvarchar" length="3" not-null="true"/>
      
<generator class="assigned" />
    
</id>

    
<property name="Description">
      
<column name="DESCRIPTION" length="50" sql-type="nvarchar" not-null="true" />
    
</property>

    
<set name="Users" table="TBLPURCHASEGROUPUSER" inverse="false" lazy="false">
      
<key column="PURCHASE_GROUP_ID"/>
      
<many-to-many class="User" column="USER_ID"/>
    
</set>
  
</class>
</hibernate-mapping>

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" namespace="NH12.MyExample.Domain" assembly="Domain">
  
<class name="User" table="TBLUSER">
    
<id name="UserID" >
      
<column name="USER_ID" sql-type="nvarchar" length="18" not-null="true"/>
      
<generator class="assigned" />
    
</id>

    
<property name="UserName">
      
<column name="USER_NAME" length="40" sql-type="nvarchar" not-null="false"/>
    
</property>

    
<set name="PurchaseGroups" table="TBLPURCHASEGROUPUSER" inverse="true" lazy="false">
      
<key column="USER_ID"/>
      
<many-to-many class="PurchaseGroup" column="PURCHASE_GROUP_ID"/>
    
</set>
  
</class>
</hibernate-mapping>

posted on 2007-04-08 12:52  riccc  阅读(8205)  评论(14编辑  收藏  举报

导航