第七章 Caché 持久性对象介绍

第七章 Caché 持久性对象介绍

持久化类

持久类是继承自%persistent的任何类。持久对象就是这样一个类的实例。

%Persistent类是%RegisteredObject的子类,因此是一个对象类。除了提供前一章中描述的方法之外,%Persistent类还定义了持久性接口和一组方法。除其他外,这些方法能够将对象保存到数据库、从数据库加载对象、删除对象和测试是否存在。

介绍默认的SQL映射

对于任何持久性类,编译器都会生成一个SQL表定义,以便除了通过本书中描述的对象接口之外,还可以通过SQL访问存储的数据。

该表包含每个保存对象的每一条记录,可以通过 Caché SQL查询该表。下面显示了示例的查询结果。Sample.Person 表:

下表总结了默认的映射:

Object-SQL映射

From (Object Concept) …To (Relational Concept) …
PackageSchema
ClassTable
OIDIdentity field
Data type propertyField
Reference propertyReference field
Embedded objectSet of fields
List propertyList field
Array propertyChild table
Stream propertyBLOB
IndexIndex
Class methodStored procedure

保存对象标识符:ID和OID

当第一次保存一个对象时,Caché会为它创建两个永久标识符,可以使用其中一个来访问或删除保存的对象。更常用的标识符是对象ID。ID是表中惟一的值。默认情况下,Caché生成一个整数作为ID。

OID更加通用:它还包含类名,并且在数据库中是惟一的。在一般实践中,应用程序永远不需要使用OID值;ID值通常就足够了。

%Persistent类提供使用ID或OID的方法。在使用%OpenId()、%ExistsId()和%DeleteId()等方法时指定ID。将OID指定为方法的参数,如%Open()、%Exists()和%Delete()。也就是说,使用ID作为参数的方法在它们的名称中包含ID。使用OID作为参数的方法的名称中不包含Id;这些方法的使用频率要低得多。

当持久对象存储在数据库中时,其任何引用属性(即对其他持久对象的引用)的值都存储为OID值。对于没有oid的对象属性,对象的文字值与对象的其他状态一起存储。

对象ID映射到SQL

对象的ID在对应的SQL表中可用。如果,Caché 使用字段名ID。如果不确定要使用哪个字段名,Caché 还提供了访问ID的方法。系统如下:

  • 对象ID不是对象的属性,与属性不同。
  • 如果类不包含名为ID的属性,那么表也包含字段ID,而该字段包含对象ID。
  • 如果类包含一个属性,这个属性用名称ID(在任何情况下都是变体)映射到SQL,那么表也包含字段ID1,这个字段包含对象ID的值。

类似地,如果类包含映射为ID和ID1的属性,那么表也包含ID2字段,该字段包含对象ID的值。

  • 在所有情况下,表还提供了伪字段%ID,其中保存了对象ID的值。

OID在SQL表中不可用。

SQL中的对象ID

Caché 强制ID字段的唯一性(无论它的实际名称是什么)。Caché也防止更改此字段。这意味着不能在该字段上执行SQL更新或插入操作。

例如,下面显示了向表添加新记录所需的SQL:

INSERT INTO PERSON (FNAME, LNAME)VALUES (:fname, :lname)

注意,此SQL不引用ID字段。Caché 为ID字段生成一个值,并在创建请求的记录时插入该值。

特定于持久类的类成员

Caché 类可以包含几种只有在持久类中才有意义的类成员。存储过程、索引、外键和触发器。 storage definitions, indices, foreign keys, and triggers.

存储定义

在大多数情况下(如后面讨论的),每个持久类都有一个存储定义。存储定义的目的是描述Caché在为类保存数据或为类读取保存的数据时使用的全局结构。在以编辑模式查看类时,Studio将在类定义的末尾显示存储定义。以下是部分例子:

<Storage name="Default">
<Data name="PersonDefaultData">
<Value name="1">
<Value>%%CLASSNAME</Value>
</Value>
<Value name="2">
<Value>Name</Value>
</Value>
<Value name="3">
<Value>SSN</Value>
</Value>
<Value name="4">
<Value>DOB</Value>
</Value>
<Value name="5">
<Value>Home</Value>
</Value>
<Value name="6">
<Value>Office</Value>
</Value>
<Value name="7">
<Value>Spouse</Value>
</Value>
<Value name="8">
<Value>FavoriteColors</Value>
</Value>
</Data>
<DataLocation>^Sample.PersonD</DataLocation>
<DefaultData>PersonDefaultData</DefaultData>
<ExtentSize>200</ExtentSize>
<IdLocation>^Sample.PersonD</IdLocation>
<IndexLocation>^Sample.PersonI</IndexLocation>
<Property name="%%CLASSNAME">
<Selectivity>50.0000%</Selectivity>
</Property>
...

在大多数情况下,编译器也会生成和更新存储定义。

索引

与其他SQL表一样,Caché SQL表可以有索引;要定义这些,需要将索引定义添加到相应的类定义中。

索引可以添加约束,以确保给定字段或字段组合的唯一性。

索引的另一个用途是定义与类关联的常用请求数据的特定排序子集,以便查询可以更快地运行。例如,作为一般规则,如果一个查询包含使用给定字段的WHERE子句,那么如果该字段被索引,则查询运行得更快。相反,如果该字段上没有索引,则引擎必须执行一个完整的表扫描,检查每一行,以查看它是否符合给定的条件——如果表很大,这是一个耗时的操作。

外键

Caché SQL表也可以有外键。要定义这些,需要将外键定义添加到相应的类定义中。

外键在表之间建立引用完整性约束,Caché在添加新数据或更改数据时使用这些约束。如果使用的是关系,那么Caché将自动将这些关系视为外键。但是,如果不想使用关系或者有其他原因需要添加他们,则可以添加外键。

触发器

Caché SQL表也可以有触发器。要定义这些,需要将触发器定义添加到相应的类定义中。

触发器定义在特定事件发生时自动执行的代码,特别是在插入、修改或删除记录时。

其他类成员

可以定义类方法或类查询,以便将其作为存储过程调用,能够从SQL调用它。
对于本章没有讨论的类成员,SQL没有对应映射。也就是说,Caché 不提供直接的方式使用它们 从SQL或使它们从SQL可用的直接方法。

术语继承是指磁盘上给定持久类的所有记录。如下一章所示,%Persistent类提供了几个对类继承进行操作的方法。

  • 如果持久类Person拥有子类Employee,则Person继承包括Person的所有实例和Employee的所有实例。
  • 对于类Employee的任何给定实例,该实例都包含在Person继承和Employee继承中。

索引自动跨越其定义的类的整个范围。Person中定义的索引同时包含Person实例和Employee实例。在Employee继承中定义的索引只包含Employee实例。

子类可以定义父类中未定义的其他属性。这些在子类范围内可用,但在父类范围内不可用。例如,Employee继承可能包括Department字段,而人员继承不包括该字段。

前面几点意味着在Caché中编写一个查询来检索相同类型的所有记录相对容易。例如,如果希望统计所有类型的人员,可以对Person表运行查询。如果只想计算雇员数量,请对Employee 表运行相同的查询。与其他对象数据库相反,为了统计所有类型的人员,需要编写一个更复杂的组合表的查询,并且需要在添加另一个子类时更新这个查询。

类似地,使用ID的方法都具有多态性。也就是说,它们可以根据传递的ID值对不同类型的对象进行操作。

例如,Sample.Person对象包括Sample.Person实例和Sample.Employee 实例。当调用 Sample.Person类的%OpenId()时,得到的OREF是Sample.Person或Sample.Employee实例,取决于是存储在数据库的是什么:

/// d ##class(PHA.OP.MOB.Test).TestObjectID()
ClassMethod TestObjectID()
{
	 Set obj = ##class(Sample.Person).%OpenId(1)
	 Write $ClassName(obj),!  
	 Set obj = ##class(Sample.Person).%OpenId(2)
	 Write $ClassName(obj),!
}
DHC-APP>d ##class(PHA.OP.MOB.Test).TestObjectID()
Sample.Person
Sample.Employee

注意示例的%OpenId()方法。如果尝试打开ID 1, Sample.Employee 类将不会返回对象,因为ID 1不是Sample.Employee的继承:

ClassMethod TestIsObject()
{
	Set obj = ##class(Sample.Employee).%OpenId(1)
	Write $IsObject(obj),! 
	Set obj = ##class(Sample.Employee).%OpenId(2)
	Write $IsObject(obj),!
}


DHC-APP>d ##class(PHA.OP.MOB.Test).TestIsObject()
0
1

继承管理

对于使用默认存储类(%Library.CacheStorage)的类,Caché维护继承定义和那些继承注册到其继承管理器中使用的全局变量。到继承管理器的接口是通%ExtentMgr.Util实现的。这个注册过程发生在类编译期间。如果存在任何错误或名称冲突,则会导致编译失败。若要编译成功,解决冲突;这通常涉及更改索引的名称或添加数据的显式存储位置。

MANAGEDEXTENT类参数的默认值为1;此值将导致全局名称注册和冲突使用检查。值0指定既不进行注册也不进行冲突检查。

注意:如果一个应用程序有多个类有意共享一个全局引用,那么对于所有相关的类指定MANAGEDEXTENT类参数的默认值为1为0(如果它们使用默认存储)。否则,重新编译将生成以下错误

ERROR #5564: Storage reference: '^This.App.Global used in 'User.ClassA.cls' 
is already registered for use by 'User.ClassB.cls'

要删除继承元数据,有多种方法:

  • 使用 ##class(%ExtentMgr.Util).DeleteExtentDefinition(extent,extenttype)
  1. extent 通常是类名
  2. extenttype 是继承类型
  3. 对于类,这是cls,它也是这个参数的默认值
  • 使用以下调用之一:
  1. $SYSTEM.OBJ.Delete(classname,flags) classname是要删除的类,flags 包含e
  2. $SYSTEM.OBJ.DeletePackage(packagename,flags) packagename 是要删除的包 ,flags 包含e
  3. $SYSTEM.OBJ.DeleteAll(flags) flags 包含e

继承查询

每个持久化类都会自动包含一个类查询。称为“范围”,它提供范围中的所有id的集合。称为“继承”,它提供继承中所有id的集合。

有关使用类查询的一般信息,下面的示例使用一个类查询来显示示例的所有id。Sample.Person :

/// d ##class(PHA.OP.MOB.Test).TestExtentQueries()
ClassMethod TestExtentQueries()
{
	set query = ##class(%SQL.Statement).%New()
	set status= query.%PrepareClassQuery("Sample.Person","Extent")
	if 'status {
		do $system.OBJ.DisplayError(status)
	}
	set rset=query.%Execute()

	While (rset.%Next()) {
		Write rset.%Get("ID"),!
	}
}

DHC-APP> d ##class(PHA.OP.MOB.Test).TestExtentQueries()
1
2
 

Sample.Person 拓展包含 Sample.Person 的实例和它的子类

“extent”查询相当于以下SQL查询:

SELECT %ID FROM Sample.Person

注意,不能依赖的顺序,其中的ID值返回使用这些方法之一:Caché 可以确定使用其他属性值排序的索引来满足此请求的效率更高。如果需要,可以将ORDER BY %ID子句添加到SQL查询中。


INSERT INTO Sample.Person (Age,SSN,Name) VALUES (1,"3N1","yaoxin")
INSERT INTO Sample.Employee (Age,SSN,Name,Title,Salary) VALUES (30,"111-11-1111","xiaoli","test",2000)

附录

Sample.Person

/// This sample persistent class represents a person.
/// <p>Maintenance note: This class is used by some of the bindings samples.
Class Sample.Person Extends (%Persistent, %Populate, %XML.Adaptor)
{

Parameter EXTENTQUERYSPEC = "Name,SSN,Home.City,Home.State";

// define indices for this class

/// Define a unique index for <property>SSN</property>.
Index SSNKey On SSN [ Type = index, Unique ];

/// Define an index for <property>Name</property>.
Index NameIDX On Name [ Data = Name ];

/// Define an index for embedded object property <b>ZipCode</b>.
Index ZipCode On Home.Zip [ Type = bitmap ];

// define properties for this class

/// Person's name.
Property Name As %String(POPSPEC = "Name()") [ Required ];

/// Person's Social Security number. This is validated using pattern match.
Property SSN As %String(PATTERN = "3N1""-""2N1""-""4N") [ Required ];

/// Person's Date of Birth.
Property DOB As %Date(POPSPEC = "Date()");

/// Person's home address. This uses an embedded object.
Property Home As Address;

/// Person's office address. This uses an embedded object.
Property Office As Address;

/// Person's spouse. This is a reference to another persistent object.
Property Spouse As Person;

/// A collection of strings representing the person's favorite colors.
Property FavoriteColors As list Of %String(JAVATYPE = "java.util.List", POPSPEC = "ValueList("",Red,Orange,Yellow,Green,Blue,Purple,Black,White""):2");

/// Person's age.<br>
/// This is a calculated field whose value is derived from <property>DOB</property>.
Property Age As %Integer [ Calculated, SqlComputeCode = { Set {Age}=##class(Sample.Person).CurrentAge({DOB})
}, SqlComputed, SqlComputeOnChange = DOB ];

/// This class method calculates a current age given a date of birth <var>date</var>.
ClassMethod CurrentAge(date As %Date = "") As %Integer [ CodeMode = expression ]
{
$Select(date="":"",1:($ZD($H,8)-$ZD(date,8)\10000))
}

/// Prints the property <property>Name</property> to the console.
Method PrintPerson()
{
	Write !, "Name: ", ..Name
	Quit
}

/// A simple, sample method: add two numbers (<var>x</var> and <var>y</var>) 
/// and return the result.
Method Addition(x As %Integer = 1, y As %Integer = 1) As %Integer
{
	Quit x + y // comment
}

/// A simple, sample expression method: returns the value 99.
Method NinetyNine() As %Integer [ CodeMode = expression ]
{
99
}

/// Invoke the <method>PrintPerson</method> on all <class>Person</class> objects 
/// within the database.
ClassMethod PrintPersons()
{
	// use the extent result set to find all person
	Set extent = ##class(%ResultSet).%New("Sample.Person:Extent")
	Do extent.Execute()
	
	While (extent.Next()) {
		Set person = ..%OpenId(extent.GetData(1))
		Do person.PrintPerson()
	}
	
	Quit
}

/// Prints out data on all persons within the database using SQL to 
/// iterate over all the person data.
ClassMethod PrintPersonsSQL()
{
	// use dynamic SQL result set to find person data
	Set query = ##class(%ResultSet).%New("%DynamicQuery:SQL")
	Do query.Prepare("SELECT ID, Name, SSN FROM Sample.Person ORDER BY Name")
	Do query.Execute()
	
	While (query.Next()) {
		Write !,"Name: ", query.Get("Name"), ?30, query.Get("SSN")
	}
	
	Quit
}

/// This is a sample of how to define an SQL stored procedure using a 
/// class method. This method can be called as a stored procedure via 
/// ODBC or JDBC.<br>
/// In this case this method returns the concatenation of a string value.
ClassMethod StoredProcTest(name As %String, ByRef response As %String) As %Integer [ SqlName = Stored_Procedure_Test, SqlProc ]
{
	// Set response to the concatenation of name.
	Set response = name _ "||" _ name
	QUIT 29
}

/// This is a sample of how to define an SQL stored procedure using a 
/// class method. This method can be called as a stored procedure via 
/// ODBC or JDBC.<br>
/// This method performs an SQL update operation on the database 
/// using embedded SQL. The update modifies the embedded properties 
/// <var>Home.City</var> and <var>Home.State</var> for all rows whose 
/// <var>Home.Zip</var> is equal to <var>zip</var>.
ClassMethod UpdateProcTest(zip As %String, city As %String, state As %String) As %Integer [ SqlProc ]
{
	New %ROWCOUNT,%ROWID
	
	&sql(UPDATE Sample.Person 
	SET Home_City = :city, Home_State = :state 
	WHERE Home_Zip = :zip)
	
	// Return context information to client via %SQLProcContext object
	If ($g(%sqlcontext)'=$$$NULLOREF) { 
		Set %sqlcontext.SQLCode = SQLCODE
		Set %sqlcontext.RowCount = %ROWCOUNT
	}
	QUIT 1
}

/// A sample class query that defines a result set that returns Person data 
/// ordered by <property>Name</property>.<br>
/// This query can be used within another Cach&eacute; method (using the
/// <class>%ResultSet</class> class), from Java, or from ActiveX.<br>
/// This query is also accessible from ODBC and/or JDBC as the SQL stored procedure 
/// <b>SP_Sample_By_Name</b>.
Query ByName(name As %String = "") As %SQLQuery(CONTAINID = 1, SELECTMODE = "RUNTIME") [ SqlName = SP_Sample_By_Name, SqlProc ]
{
SELECT ID, Name, DOB, SSN
FROM Sample.Person
WHERE (Name %STARTSWITH :name)
ORDER BY Name
}

Storage Default
{
<Data name="PersonDefaultData">
<Value name="1">
<Value>%%CLASSNAME</Value>
</Value>
<Value name="2">
<Value>Name</Value>
</Value>
<Value name="3">
<Value>SSN</Value>
</Value>
<Value name="4">
<Value>DOB</Value>
</Value>
<Value name="5">
<Value>Home</Value>
</Value>
<Value name="6">
<Value>Office</Value>
</Value>
<Value name="7">
<Value>Spouse</Value>
</Value>
<Value name="8">
<Value>FavoriteColors</Value>
</Value>
</Data>
<DataLocation>^Sample.PersonD</DataLocation>
<DefaultData>PersonDefaultData</DefaultData>
<ExtentSize>200</ExtentSize>
<IdLocation>^Sample.PersonD</IdLocation>
<IndexLocation>^Sample.PersonI</IndexLocation>
<Property name="%%CLASSNAME">
<AverageFieldSize>8.5</AverageFieldSize>
<Selectivity>50.0000%</Selectivity>
</Property>
<Property name="%%ID">
<AverageFieldSize>2.46</AverageFieldSize>
<Selectivity>1</Selectivity>
</Property>
<Property name="Age">
<AverageFieldSize>1.88</AverageFieldSize>
<Selectivity>1.1765%</Selectivity>
</Property>
<Property name="DOB">
<AverageFieldSize>5</AverageFieldSize>
<Selectivity>0.5000%</Selectivity>
</Property>
<Property name="FavoriteColors">
<AverageFieldSize>6.71</AverageFieldSize>
<OutlierSelectivity>.34:</OutlierSelectivity>
<Selectivity>1.4043%</Selectivity>
</Property>
<Property name="Home">
<AverageFieldSize>36.23,City:7.27,State:2,Street:16.58,Zip:5</AverageFieldSize>
<Selectivity>0.5000%,City:3.8462%,State:2.0408%,Street:0.5000%,Zip:0.5000%</Selectivity>
</Property>
<Property name="Name">
<AverageFieldSize>15.83</AverageFieldSize>
<Selectivity>0.5000%</Selectivity>
</Property>
<Property name="Office">
<AverageFieldSize>36.43,City:7.15,State:2,Street:16.91,Zip:5</AverageFieldSize>
<Selectivity>0.5000%,City:3.8462%,State:2.0408%,Street:0.5000%,Zip:0.5000%</Selectivity>
</Property>
<Property name="SSN">
<AverageFieldSize>11</AverageFieldSize>
<Selectivity>1</Selectivity>
</Property>
<Property name="Spouse">
<AverageFieldSize>.95</AverageFieldSize>
<OutlierSelectivity>.5:</OutlierSelectivity>
<Selectivity>0.7937%</Selectivity>
</Property>
<SQLMap name="$Person">
<BlockCount>-4</BlockCount>
</SQLMap>
<SQLMap name="IDKEY">
<BlockCount>-20</BlockCount>
</SQLMap>
<SQLMap name="NameIDX">
<BlockCount>-8</BlockCount>
</SQLMap>
<SQLMap name="SSNKey">
<BlockCount>-8</BlockCount>
</SQLMap>
<SQLMap name="ZipCode">
<BlockCount>-8</BlockCount>
</SQLMap>
<StreamLocation>^Sample.PersonS</StreamLocation>
<Type>%Library.CacheStorage</Type>
}

}

Sample.Employee

/// This sample persistent class represents an employee.<br>
Class Sample.Employee Extends Person
{

/// The employee's job title.
Property Title As %String(MAXLEN = 50, POPSPEC = "Title()");

/// The employee's current salary.
Property Salary As %Integer(MAXVAL = 100000, MINVAL = 0);

/// A character stream containing notes about this employee.
Property Notes As %Stream.GlobalCharacter;

/// A picture of the employee
Property Picture As %Stream.GlobalBinary;

/// The company this employee works for.
Relationship Company As Company [ Cardinality = one, Inverse = Employees ];

/// This method overrides the method in <class>Person</class>.<br>
/// Prints the properties <property>Name</property> and <property>Title</property> 
/// to the console.
Method PrintPerson()
{
	Write !,"Name: ", ..Name, ?30, "Title: ", ..Title
	Quit
}

/// writes a .png file containing the picture, if any, of this employee
/// the purpose of this method is to prove that Picture really contains an image
Method WritePicture()
{
	if (..Picture="") {quit}
	set name=$TR(..Name,".") ; strip off trailing period
	set name=$TR(name,", ","__") ; replace commas and spaces
	set filename=name_".png"
	
	set file=##class(%Stream.FileBinary).%New()
	set file.Filename=filename
	do file.CopyFrom(..Picture)
	do file.%Save()
	write !, "Generated file: "_filename
}

Storage Default
{
<Data name="EmployeeDefaultData">
<Subscript>"Employee"</Subscript>
<Value name="1">
<Value>Company</Value>
</Value>
<Value name="2">
<Value>Notes</Value>
</Value>
<Value name="3">
<Value>Salary</Value>
</Value>
<Value name="4">
<Value>Title</Value>
</Value>
<Value name="5">
<Value>Picture</Value>
</Value>
</Data>
<DefaultData>EmployeeDefaultData</DefaultData>
<ExtentSize>100</ExtentSize>
<Property name="%%CLASSNAME">
<AverageFieldSize>17</AverageFieldSize>
<Selectivity>100.0000%</Selectivity>
</Property>
<Property name="%%ID">
<AverageFieldSize>3</AverageFieldSize>
<Selectivity>1</Selectivity>
</Property>
<Property name="Age">
<AverageFieldSize>1.85</AverageFieldSize>
<Selectivity>1.5873%</Selectivity>
</Property>
<Property name="Company">
<AverageFieldSize>1.45</AverageFieldSize>
<Selectivity>5.0000%</Selectivity>
</Property>
<Property name="DOB">
<AverageFieldSize>5</AverageFieldSize>
<Selectivity>1.0000%</Selectivity>
</Property>
<Property name="FavoriteColors">
<AverageFieldSize>5.81</AverageFieldSize>
<OutlierSelectivity>.39:</OutlierSelectivity>
<Selectivity>2.2593%</Selectivity>
</Property>
<Property name="Home">
<AverageFieldSize>36.56,City:7.66,State:2,Street:16.5,Zip:5</AverageFieldSize>
<Selectivity>1.0000%,City:3.8462%,State:2.4390%,Street:1.0000%,Zip:1.0000%</Selectivity>
</Property>
<Property name="Name">
<AverageFieldSize>15.92</AverageFieldSize>
<Selectivity>1.0000%</Selectivity>
</Property>
<Property name="Notes">
<Selectivity>100.0000%</Selectivity>
</Property>
<Property name="Office">
<AverageFieldSize>36.83,City:7.22,State:2,Street:17.19,Zip:5</AverageFieldSize>
<Selectivity>1.0000%,City:4.0000%,State:2.1739%,Street:1.0000%,Zip:1.0000%</Selectivity>
</Property>
<Property name="Picture">
<Selectivity>100.0000%</Selectivity>
</Property>
<Property name="SSN">
<AverageFieldSize>11</AverageFieldSize>
<Selectivity>1</Selectivity>
</Property>
<Property name="Salary">
<AverageFieldSize>4.91</AverageFieldSize>
<Selectivity>1.0000%</Selectivity>
</Property>
<Property name="Spouse">
<AverageFieldSize>1.89</AverageFieldSize>
<Selectivity>1.5873%</Selectivity>
</Property>
<Property name="Title">
<AverageFieldSize>21.36</AverageFieldSize>
<Selectivity>1.5385%</Selectivity>
</Property>
<SQLMap name="$Employee">
<BlockCount>-4</BlockCount>
</SQLMap>
<Type>%Library.CacheStorage</Type>
}

}

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yaoxin521123

谢谢您的支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值