時を遡ること2004年、私たちは困難な課題に直面していました。Javaアプリケーションでデータベースとのやり取りを抽象化する方法を必要としていたのですが、当時存在していたどのフレームワークも以下の要件に対応することが出来ませんでした。
- データは非常に断片化されており、同じスキーマを持つ100以上のデータベースがあり、それぞれに異なるデータが存在していました
- データはバイテンポラルの形で保存されていました(これについては、この記事の第2部で説明します。ぜひお楽しみに!)
- データを取得更新するクエリは必ずしも静的ではなく、ユーザーの入力から動的に作成しなければならない場合がありました
- データモデルが複雑で、更にテーブルが数百個もありました
そんな中、私たちは2004年にReladomoの開発を開始しました。その年の後半に社内の本番環境に最初のリリースをして以後、定期的なリリースが行われています。数年の間にReladomoはゴールドマン・サックス内のアプリケーションで広く採用され、実際の使用ケースを元に主要な新機能を追加していきました。現在は、複数の勘定元帳システム、ミドルオフィスの取引処理システム、財務報告書作成・処理システム、その他に数十のアプリケーションで使用されています。ゴールドマン・サックスは2016年にApache 2.0ライセンスとしてReladomo(Relational Domain Objectsを省略した名称)をオープンソースプロジェクトとして公開しました。
なぜ新たなORMを開発したのか?
簡単に言うと、当社の主要な要件を既存のソリューションでは満たすことが出来ず、また従来のORMには解決するべき課題が色々あったからです。
私たちは、コードレベルの定型文と一世代前のような構文を排除することに決めました。Reladomoでは取得、切断、リーク、フラッシュのためのデータベース接続を特に意識する必要はありません。セッションも無ければ、EntityManagerやLazyInitializationExceptionもありません。APIはドメインオブジェクトそのものと強力な型指定のリスト実装の、2つの基本的な方法によって提供されています。
Reladomoのもう一つの重要なポイントは、クエリ言語です。文字列ベースのクエリ言語は、私たちのアプリケーションやオブジェクト指向のコードにはあまり適していませんでした。動的なクエリを生成するために文字列を連結しようとしても、ごく単純なクエリしかはうまく動作しないからです。文字列の連結により作成される動的クエリを管理するのは大変手間がかかります。
また、シャーディングはネイティブなサポートが必要な領域でした。Reladomoのシャーディングは非常に柔軟で、異なるシャードに同一の主キーの値が存在していても違うオブジェクトとして認識することができます。シャードのクエリ構文はクエリ言語により自然な形でサポートされています。
Richard Snodgrass(リチャード・スノードグラス)が「Developing Time-Oriented Database Applications in SQL (英語)」で データベース設計者が変更履歴を記録して分析するためのテンポラル(ユニテンポラルおよびバイテンポラル)なサポートについて書いています。これはReladomoが備えている実にユニークな機能です。 会計システムに関連する様々なデータ、参照データ、その他多くの変更履歴を再現したいという時に適用できます。プロジェクトコラボレーションツールなどのような単純なアプリケーションであっても、ユニテンポラル(一つの時間軸)の情報から恩恵を受けることができ、ユーザーインターフェイスをタイムマシンのように動作させて、物事がどのように変化したかを見せることができます。
テスト容易性は重要度が高く、これは正しく提供されなければいけないと早い段階で決めていました。そしてそれを可能にするには自分たちでその機能を使うしかないと考えました。大半のReladomoのテストは、Reladomo自身のテストユーティリティを使用して書かれています。私たちは実用的なテストの考えを持っており、テストは長期的にみて価値を提供されるべきだと考えています。Reladomoのテストはセットアップが簡単で、メモリ内のテストデータベースに対してすべての本番環境のコードを実行できるため、継続的な統合テストが可能です。これらのテストは、開発者がデータベースをわざわざインストールして開発環境を構築することなしに、データベースとのやりとりを理解するのに役立ちます。
最後に、私たちはパフォーマンスの面でも妥協したくありませんでした。Reladomoの中でも特に重要で技術的に洗練されたものの1つは、キャッシュです。 これはキーレス、複数インデックス、トランザクションオブジェクトのキャッシュです。オブジェクトはオブジェクトとしてキャッシュされ、そのデータは単一のメモリ参照を使用することが保証されます。オブジェクトキャッシュは、同じオブジェクトを参照するクエリキャッシュによって更新されます。クエリキャッシュは優秀なので、古くなってしまったデータは返しません。Reladomoを使用して複数のJVMから同じデータに書き込む時でも、キャッシュは正しく動作します。起動時にオンデマンドまたは全データのキャッシュにするかどうかを設定できます。データとアプリケーションの種類に応じて、大規模なキャッシュ用にオブジェクトをオフヒープ(ネイティブメモリ)で保存してレプリケーションもできます。なお、私たちの本番環境では200GBを超えるキャッシュがあります。
原則に基づいた開発
Reladomoはライブラリではなく、フレームワークという位置づけです。フレームワークはライブラリ等が提供する機能以外にどういったコーディングのパターンで使われるべきか、そうでないかという独自の意見を持ちます。Reladomoは更にコードを生成し、その中で生成されたAPIはコードの様々なところで使用されることが想定されています。したがって、フレームワークとアプリケーションの間で、何が上手く出来て何が出来ないかについて共通の視点を持つことが重要です。
以下にReladomoのコアバリューを定義しました。これを見てReladomoがユーザーにとって使えるかどうかを判断することができるでしょう。
- 数年または数十年後も本番環境で実行されるようなコードを書くこと
- 同じことを繰り返さないこと (Don't repeat yourself)
- コードを容易に変更できること
- ドメインに基づいたオブジェクト指向の方法でコードを書くこと
- データの正確さと一貫性で妥協しないこと
これらのコアバリューとその影響は、哲学とビジョン(英語)のドキュメントで詳しく説明されています。
ユーザビリティとプログラマビリティ
Reladomoの機能をいくつか示すために、いくつかの小さなドメインモデルをお見せします。例として、まずペットに関する非テンポラルモデルを見てみましょう。
次に、元帳のモデルです。
このモデルでは、Account(口座)で複数のProduct(有価証券)を取引し、Productには複数のSynonym(識別子)があるということが分かります。 合計残高はBalance(残高)オブジェクトに格納されます。Balanceは数量、課税所得、利子などのAccountに関する合計値を表しています。これらのモデルのコードはGitHubで確認できます。
後で例を見てみますが、これはバイテンポラルモデルの一例です。 しかしひとまずはテンポラルの事は気にせず先に進みましょう。
モデルはReladomoオブジェクト定義を概念オブジェクト毎に作成し、それらからクラスを生成することによって定義されます。定義したドメインクラスは、実際のビジネスドメインとして使用されることが期待されます。抽象クラスはモデルまたはReladomoのバージョンが変更されるたびに生成されますが、ドメイン内の具象クラスは最初の生成後は上書きされませんので、これらの具象クラスにメソッドを追加する事が出来ますし、それらをバージョン管理システムにチェックインするべきです。
Reladomoが提供するAPIの大半は生成されたクラスの中にあります。(例 :PetFinder, PetAbstract, PetListAbstract
)PetFinder
には通常のget / setメソッド以外に何個かのデータ永続化のためのメソッドがあります。APIの本当に興味深い部分は生成されたFinderとListにあります。
名前が示すように、クラスごとのFinder(例:PersonFinder)はデータを見つけるために使われます。簡単な例を次に示します。
Person john = PersonFinder.findOne(PersonFinder.personId().eq(8));
接続やセッションの取得や切断がないことに気づいたでしょうか? 取り出されたオブジェクトはあらゆるコンテクストで参照できます。例えばそれを別のスレッドに渡し、トランザクション単位で使用することなどができます。findOneを呼び複数のオブジェクトが返された場合には 例外を返します
次に以下の表現を詳しくみてみましょう。PersonFinder.firstName()
はAttribute
(属性)になります。これはStringAttribute
として型指定されていますのでfirstName
().eq("John")
のように書ける一方、firstName().eq(8)
やfirstName().eq(someDate)
のように書くことはできません。また、他のタイプの属性には見られないStringAttribute特有のメソッドも使用しています。
PersonFinder.firstName().toLowerCase().startsWith("j")
toLowerCase()
やstartsWith()
などのメソッドは、IntegerAttribute
では利用できません。IntegerAttribute
には別の特別なメソッドが備わっています。
これらは二つの重要なユーザビリティの観点で助けてくれます。まず、IDEが正しいコードを書くのを助けてくれます。次にモデルを変更した時に、コンパイラが変更の必要な部分を見つけてくれます。
Attributeには、eq()
やgreaterThan()
などのOperationを作成するメソッドがあります。ReladomoでのOperationはオブジェクトを取得するために使用されています。Finder.findOne
やFinder.findMany
です。Operationはイミュータブルに実装されているので、and()
やor()
と組み合わせることができます。
Operation op = PersonFinder.firstName().eq("John");
op = op.and(PersonFinder.lastName().endsWith("e"));
PersonList johns = PersonFinder.findMany(op);
大量のIOを実行するアプリケーションは、データをIN句を用いて一括してロードする場合があります。もし以下のようにした場合、
Set lastNames = ... // 一万件ほどの大きなセット
PersonList largeList =
PersonFinder.findMany(PersonFinder.lastName().in(lastNames));
裏側でReladomoがOperation
を分析し対応するSQLを生成します。大きなIN句に対してどのようなSQLが生成されるのでしょうか?Reladomoの場合、その答えは 「場合による」です。Reladomoは複数のIN句のSQL文を発行するか、又はデータベースに応じて一時テーブルを作り JOINするかを自動的に判別します。Reladomoは、Operationとデータベースを元に効率良く正しい結果を返します。開発者は設定が変更された際に頭を悩ませる必要もなければ、複雑なコードを書く必要もありません。これがBatteries are included (電池付属)です!
主キー
Reladomoでは、オブジェクトの属性値を組み合わせて主キーとして定義できます。主キーのためのクラスを定義したり、他の方法で属性値を扱ったりする必要はありません。モデルの中で複合キーを使うことはごく当たり前のことなので、それを使う上で問題がないようにしなければならないと 私たち考えています。例えば上記のモデルの中で、ProductSynonym
クラスは以下のような複合キーを持っています。
<Attribute name="productId"
javaType="int"
columnName="PRODUCT_ID"
primaryKey="true"/>
<Attribute name="synonymType"
javaType="String"
columnName="SYNONYM_TYPE"
primaryKey="true"/>
もちろん、合成キーが有効な場合もあります。Reladomoは、高いパフォーマンスを維持しながらテーブル毎の合成キーの生成もサポートしています。合成キーは、必要に応じて非同期にまとめて生成されます。
リレーションシップ
クラス間のリレーションシップは、モデルの中で定義することができます。
<Relationship name="pets"
relatedObject="Pet"
cardinality="one-to-many"
relatedIsDependent="true"
reverseRelationshipName="owner">
this.personId = Pet.personId
</Relationship>
リレーションシップを定義すると以下の三つの読み込み機能が提供されます。
- リレーションシップにreverseRelationshipName属性が定義されていれば、お互いのオブジェクトを双方向に行き来が出来るgetメソッドを提供 (例:
person.getPets()
) - クラス間のリレーションシップが定義されたファインダーを提供 (例:
PersonFinder.pets()
) - クエリごとにリレーションシップを通して関連オブジェクトをディープ・フェッチする機能を提供
ディープ・フェッチとは、関連するオブジェクトを効率的に取得する機能です。これはよく知られているN+1 問題
を回避するためのものです。例えばPersonオブジェクトを取得しようとした時に、それに関連したPetオブジェクトが効率的に読み込まれます。
PersonList people = ...
people.deepFetch(PersonFinder.pets());
もっと参考になるのは以下の例でしょう。
TradeList trades = ...
trades.deepFetch(TradeFinder.account()); // TradeのAccountを取得
trades.deepFetch(TradeFinder.product()
.cusipSynonym()); // TradeのProductとCUSIP synonym (識別子の一つ) を取得
trades.deepFetch(TradeFinder.product()
.synonymByType("ISN")); // そしてISN synony (CUSIPとは別の識別子)を取得
オブジェクト到達可能グラフなどの部分も指定することができます。ちなみに、モデル定義の中では指定していないのでご注意下さい。また、モデル定義の中でEagerやLazyという概念もありません。これは、コードの中で実装するべきものだからです。そのため、IO処理やパフォーマンスを上げるためにモデルを変更する必要もありません。その結果として、モデルの開発をよりアジャイルに行えます。
Operation
を定義する時にもリレーションシップの定義が使われています。
Operation op = TradeFinder
.account()
.location()
.eq("NY"); // NYのAccountに属する全てのTradeを取得
op = op.and(TradeFinder.product()
.productName()
.in(productNames)); // そして、それらのTrade使われたProduct nameを取得
TradeList trades2 = TradeFinder.findMany(op);
リレーションシップはReladomoの中で静的に定義されたリファレンスとしては実装されていません。これにより、メモリとIOの面からオーバーヘッド無しにリレーションシップを追加できます。
このようにReladomoのリレーションシップはとても柔軟です。例として、多くの異なる種類のID(例えば、CUSIP、Tickerなど)を持つProductオブジェクトを見てみましょう。この例を上記でも示したのTradeモデル中で定義してみました。 従来のProduct
からProductSynonym
への一対多リレーションシップは、あまり有用ではありません。
<Relationship name="synonyms"
relatedObject="ProductSynonym"
cardinality="one-to-many">
this.productId = ProductSynonym.productId
</Relationship>
その理由は、ProductのSynonymを全て必要とするのがとても稀だからです。しかし二種類の異なるリレーションシップがこの例を有用にしてくれます。静的な式で表現されたリレーションシップは重要なビジネスコンセプトをモデル上で表す事が出来ます。例えば、ProductのCUSIP Synonymにアクセスする場合は、次のようにリレーションシップを追加します。
<Relationship name="cusipSynonym"
relatedObject="ProductSynonym"
cardinality="one-to-one">
this.productId = ProductSynonym.productId and
ProductSynonym.synonymType = "CUS"
</Relationship>
上の例のdeepFetch
の中でこのcusipSynonym
のリレーションシップを使っていますが、どのように使っているか見てみてください。これには3つの利点があります。一つ目は、コードの中で「CUS」を繰り返し書く必要がなくなることです。二つ目は、CUSIPだけを取得したい時に全てのSynonymを取得する必要が無くなり、IOコストが下がることです。三つ目は、クエリがとても読みやすくなる上に自然に書くことができることです。
クエリの組立て容易性
文字列を元にクエリを生成しようとした時の問題の一つは、その作成が非常に難しいことです。そこで型厳密なドメインベースなオブジェクト指向のクエリ言語を持つことで、我々は高度な組立てを可能にしました。これを理解するために、以下の例を見てみましょう。
上記のTradeモデルを見てみると、TradeオブジェクトとBalanceオブジェクトはAccountとProductの両方に関係があります。 そして、AccountとProductの値を元にフィルタリングして複数のTradeを取得できるGUIがあるとします。別のウィンドウでは、AccountとProductをフィルタリングしてBalanceを取り出すことができます。この際、同じエンティティを処理しているためフィルタは同じになります。Reladomoでは、2つのコードを簡単に共有することができます。 AccountとProductのビジネスロジックをいくつかのGUIコンポーネントクラスに抽象化しました。
public BalanceList retrieveBalances()
{
Operation op = BalanceFinder.businessDate().eq(readUserDate());
op = op.and(BalanceFinder.desk().in(readUserDesks()));
Operation refDataOp = accountComponent.getUserOperation(
BalanceFinder.account());
refDataOp = refDataOp.and(
productComponent.getUserOperation(BalanceFinder.product()));
op = op.and(refDataOp);
return BalanceFinder.findMany(op);
}
これにより、次のSQLが発行されます。
select t0.ACCT_ID,t0.PRODUCT_ID,t0.BALANCE_TYPE,t0.VALUE,t0.FROM_Z,
t0.THRU_Z,t0.IN_Z,t0.OUT_Z
from BALANCE t0
inner join PRODUCT t1
on t0.PRODUCT_ID = t1.PRODUCT_ID
inner join PRODUCT_SYNONYM t2
on t1.PRODUCT_ID = t2.PRODUCT_ID
inner join ACCOUNT t3
on t0.ACCT_ID = t3.ACCT_ID
where t1.FROM_Z <= '2017-03-02 00:00:00.000'
and t1.THRU_Z > '2017-03-02 00:00:00.000'
and t1.OUT_Z = '9999-12-01 23:59:00.000'
and t2.OUT_Z = '9999-12-01 23:59:00.000'
and t2.FROM_Z <= '2017-03-02 00:00:00.000'
and t2.THRU_Z > '2017-03-02 00:00:00.000'
and t2.SYNONYM_TYPE = 'CUS'
and t2.SYNONYM_VAL in ( 'ABC', 'XYZ' )
and t1.MATURITY_DATE < '2020-01-01'
and t3.FROM_Z <= '2017-03-02 00:00:00.000'
and t3.THRU_Z > '2017-03-02 00:00:00.000'
and t3.OUT_Z = '9999-12-01 23:59:00.000'
and t3.CITY = 'NY'
and t0.FROM_Z <= '2017-03-02 00:00:00.000'
and t0.THRU_Z > '2017-03-02 00:00:00.000'
and t0.OUT_Z = '9999-12-01 23:59:00.000'
AccountComponentクラスとProductComponentクラスは、Trade用に再利用することが出来ます。(BalanceWindowとTradeWindowを参照)しかし、組立てはここで終わりではありません。例えば要件定義が変更されて、BalanceWindowでのみ、ユーザーがAccountフィルタもしくはProductフィルタをBalanceに適用したいとします。Reladomoならば、一行のコード変更だけで出来ます。
refDataOp = refDataOp.or(
productComponent.getUserOperation(BalanceFinder.product()));
これによって発行されたSQL文は、先ほどのとは大分異なります。
select t0.ACCT_ID,t0.PRODUCT_ID,t0.BALANCE_TYPE,t0.VALUE,t0.FROM_Z,
t0.THRU_Z,t0.IN_Z,t0.OUT_Z
from BALANCE t0
left join ACCOUNT t1
on t0.ACCT_ID = t1.ACCT_ID
and t1.OUT_Z = '9999-12-01 23:59:00.000'
and t1.FROM_Z <= '2017-03-02 00:00:00.000'
and t1.THRU_Z > '2017-03-02 00:00:00.000'
and t1.CITY = 'NY'
left join PRODUCT t2
on t0.PRODUCT_ID = t2.PRODUCT_ID
and t2.FROM_Z <= '2017-03-02 00:00:00.000'
and t2.THRU_Z > '2017-03-02 00:00:00.000'
and t2.OUT_Z = '9999-12-01 23:59:00.000'
and t2.MATURITY_DATE < '2020-01-01'
left join PRODUCT_SYNONYM t3
on t2.PRODUCT_ID = t3.PRODUCT_ID
and t3.OUT_Z = '9999-12-01 23:59:00.000'
and t3.FROM_Z <= '2017-03-02 00:00:00.000'
and t3.THRU_Z > '2017-03-02 00:00:00.000'
and t3.SYNONYM_TYPE = 'CUS'
and t3.SYNONYM_VAL in ( 'ABC', 'XYZ' )
where ( ( t1.ACCT_ID is not null )
or ( t2.PRODUCT_ID is not null
and t3.PRODUCT_ID is not null ) )
and t0.FROM_Z <= '2017-03-02 00:00:00.000'
and t0.THRU_Z > '2017-03-02 00:00:00.000'
and t0.OUT_Z = '9999-12-01 23:59:00.000'
このSQL文と以前のSQL文との構造上の違いに注目してください。要件定義が「and」から「or」に変更されたとき、コードを「and」から「or」に変更しただけで上手く動きます。これが我々がBatteries included(電池付属)と謳う理由です!もしこれが文字列の連結や何らかのクエリメカニズムを使って自前で結合を実装していたならば、「and」から「or」への変更はとても大変な作業になるでしょう。
CRUDと作業単位
ReladomoのCRUDのためのAPIは、オブジェクトもしくはそれらのリストに実装されています。 オブジェクトにはinsert()やdelete()のようなメソッドがあり、リストには一括メソッドがあります。 「save」や「update」といったメソッドはありません。永続オブジェクトに値を設定するとデータベースが更新されます。ほとんどの書き込み処理はコマンドパターンで実装されたトランザクション内で実行されます。
MithraManagerProvider.getMithraManager().executeTransactionalCommand(
tx ->
{
Person person = PersonFinder.findOne(PersonFinder.personId().eq(8));
person.setFirstName("David");
person.setLastName("Smith");
return person;
});
UPDATE PERSON
SET FIRST_NAME='David', LAST_NAME='Smith'
WHERE PERSON_ID=8
データベースへの書き込みは、正しさを保証するという唯一の制約下で、結合されバッチ処理されます。
PersonListオブジェクトには多くの便利なメソッドがあり、その中にはコレクションを対象にしたAPIも含まれます。たとえば、次のように書くことができます。
Operation op = PersonFinder.firstName().eq("John");
op = op.and(PersonFinder.lastName().endsWith("e"));
PersonList johns = PersonFinder.findMany(op);
johns.deleteAll();
これを見ると、最初にデータベースから複数の列を取得してそれから一つずつ削除すると思うかもしれませんが、そうではありません。代わりに、以下のようなクエリをトランザクション内で実行します。
DELETE from PERSON
WHERE LAST_NAME like '%e' AND FIRST_NAME = 'John'
しかし、本番環境で運用されているようなアプリケーションで必要とされるのはこのような一括削除の方法だけではありません。 古いデータを削除する必要がある場合を考えてみましょう。データはもう使われていないので、大きなトランザクション内でデータを削除するよりも出来る限り効率的にバックグラウンドで削除する必要があります。そのためには、以下のメソッドが使えます。
johns.deleteAllInBatches(1000);
これは、データベースのタイプに応じて異なるクエリを発行します。
MS-SQL:
delete top(1000) from PERSON
where LAST_NAME like '%e' and FIRST_NAME = 'John'
PostgreSQL:
delete from PERSON
where ctid = any (array(select ctid
from PERSON
where LAST_NAME like '%e'
and FIRST_NAME = 'John'
limit 1000))
この処理は一時的な障害の際にもすべてが完了するまで実行しようとします。これが我々が「batteries included(電池付属)」と謳う理由です。典型的なパターンの実装を簡単に使えるようにしています。
統合の容易さ
Reladomoはあなたのコードで簡単に使えるように作られています。
まず、Reladomoには依存関係がほとんどありません。 このおかげでjarの競合を心配することなく色々追加することが出来ます。実行時は、クラスパスに6つのjar(1つのメインのライブラリjarと浅い依存関係を持つ5つのjar)を追加するだけで動きます。なお、本番環境ではあなたのコードの他にデータベースドライバのクラスとslf4jログの実装が必要となります。
次に、Reladomoでは後方互換性が保証されています。コードの変更をすること無くReladomoのバージョンをアップグレードできるはずです。もし将来、Reladomoの変更で後方互換性が無くなる場合は、新しいAPIに切り替えるために少なくとも1年の猶予を設けます。
最後に
私たちはユーザビリティを重要なものとして捉えていますが(「batteries included!」 - 電池付属)、数多くのユースケースがあるので万人に合うものを作ろうとしても上手くいかないことも認識しています。
従来のORMフレームワークに蔓延している問題の1つは、抽象化の破綻(Leaky Abstractions)です。私たちのコア・バリューに沿って開発されれば、抽象化の破綻を避けられ非常に魅力的なシステムを作ることが出来ます。Reladomoで生のSQLクエリやストアドプロシージャをサポートしていませんが、それは何かの間違いというわけではありません。 「機能Yがデータベース側でサポートされていれば、機能Xが使えます」というようなドキュメントを書かないようにも努めました。
Reladomoには、まだまだここではカバーしきれていないたくさんの機能があります。是非、GitHubやドキュメント、そしてKatas(Reladomoを学ぶためのチュートリアル集)を見てみてください。次の記事では、Reladomoのパフォーマンス、テスト容易性、そしてエンタープライズ機能のいくつかを紹介いたします。
著者について
Mohammad Rezaei ゴールドマン・サックスのテックフェローであり、Reladomoのチーフアーキテクト。テクノロジー部のプラットフォームビジネスユニットに所属。分割並列トランザクション処理を要するシステムから最大スループットのためにロックフリーのアルゴリズムを必要とする大容量メモリシステムといった様々な環境下で高パフォーマンスなJavaの開発経験を有する。ペンシルベニア大学のコンピューターサイエンスの学士号ならびに、コーネル大学の物理学の博士号を所持。
翻訳者について
児玉英之 ゴールドマン・サックス テクノロジー部のコンプライアンステクノロジーチームに所属するJava/Pythonエンジニア。アジア全般を担当している。2013年ゴールドマン・サックス入社。主にJavaでのソフトウェア開発ならびにPythonを用いたデータ分析を行っている。