目次


Java の API 設計手法

モジュール式 Java 環境でも非モジュール式 Java 環境でも有用な API にする

Comments

はじめに

Java API を設計するときに適用すべき、いくつかの API 設計手法について把握してください。これらの手法は概して、OSGi や Java Platform Module System (JPMS) などのモジュール式環境で適切に使用できる API を設計するのに役立ちます。ここで取り上げる手法の中には、規範的なものも規制的なものもあります。もちろん、ここでは取り上げていない優れた API 設計手法を適用することもできます。

OSGi 環境が提供するモジュール式ランタイムは、Java クラス・ローダーの概念を使用して、型の可視性をカプセル化します。モジュールごとに独自のクラス・ローダーを割り当て、そのクラス・ローダーを他のモジュールのクラス・ローダーに関連付けることで、エクスポートされたパッケージを共有したり、インポートされたパッケージを使用したりできるようにするという仕組みです。

Java 9 で導入された JPMS が提供するモジュール式プラットフォームでは、Java 言語仕様に含まれるアクセス制御の概念に従って、型のアクセス可能性をカプセル化します。各モジュールによって、どのパッケージをエクスポートするか、したがって、どのパッケージを他のモジュールに対してアクセス可能にするかを定義するという仕組みです。デフォルトでは、JMPS レイヤー内のモジュールは、すべて同じクラス・ローダー内に置かれます。

パッケージには API を含めることができます。パッケージに含まれる API に対するクライアントの役割には、「API コンシューマー」と「API プロバイダー」の 2 つがあります。API コンシューマーとしてのクライアントは、API プロバイダーとしてのクライアントが実装する API を使用します。

以降で説明する設計手法では、パッケージの公開部分について説明します。パッケージを構成するメンバーのうち、公開されることも保護されることもない (つまり、プライベートまたはデフォルトでアクセス可能な) メンバーおよび型には、パッケージ外部からアクセスすることはできません。したがって、これらは各パッケージの実装詳細ということになります。

Java パッケージをまとまりのある安定したユニットにすること

Java パッケージを設計するときは、まとまりのある安定したユニットになるように設計する必要があります。モジュール式 Java でのパッケージは、モジュール間で共有されるエンティティーです。あるモジュールがパッケージをエクスポートすることで、他のモジュールがそのパッケージを使用できるようにします。パッケージはモジュール間での共有単位であるため、パッケージに含まれるすべての型がそのパッケージ固有の目的に関連するように、パッケージをまとめる必要があります。java.util のように雑多な型をパッケージに詰め込むことは推奨されません。そのようなパッケージに含まれる型は、互いに関連性がないことが多いためです。パッケージにまとまりがないと、そのパッケージ内の型とは関連しない型が別の関連性のないパッケージを参照して、多数の依存関係が生まれます。そうなると、パッケージのある側面に変更を加えた場合、そのパッケージに依存する他のすべてのパッケージに影響を及ぼすことになります。影響を受けるパッケージには、変更されたパッケージの側面を実際には使用していないモジュールであっても含まれます。

パッケージは共有の単位であることから、パッケージの内容を十分に把握する必要があるとともに、将来のバージョンアップでパッケージが進化するにつれ、そのパッケージに含まれる API も両立できる形で変更できなければなりません。つまり、パッケージで API スーパークラスやサブセットをサポートしないようにする必要があるということです。例えば、javax.transaction を考えてください。パッケージとしては、その内容は不安定です。パッケージ内にどのような型が用意されているのかを、そのパッケージのユーザーが把握できるようでなければなりません。これはまた、パッケージのユーザーは、パッケージ全体が存在していることを把握しなければならないことも意味するため、パッケージを複数のエンティティーに分割するのではなく、単一のエンティティー (例えば、jar ファイル) で配布する必要もあります。

さらに、パッケージは将来のバージョンアップと互換性のある形で進化していかなければなりません。したがって、パッケージのバージョンを管理し、このリンク先のページに掲載されているセマンティック・バージョニング仕様のルールに従ってバージョンの番号を繰り上げていく必要があります。セマンティック・バージョニングについては、このリンク先の OSGi ホワイトペーパーも参照してください。

ただし、私は最近、パッケージのメジャー・バージョンの変更に関するセマンティック・バージョニングの推奨は誤っていると気づきました。パッケージの進化は機能の増大であるべきですが、セマンティック・バージョニングでは、マイナー・バージョンの番号を繰り上げるとしています。それでは、機能を削除した場合に互換性のない変更をパッケージに加えることになります。その場合はメジャー・バージョンの番号を繰り上げるのではなく、新しいパッケージ名を付けて、元のパッケージの互換性を維持する必要があります。こうすることが重要であり、必要である理由を理解するには、このリンク先の記事「Semantic Import Versioning for Go」を参照するとともに、このリンク先の動画で、Clojure/conj 2016 で Rich Hickey 氏が行った素晴らしい基調プレゼンテーションを見てください。いずれも、パッケージに互換性のない変更を加えることになる場合はメジャー・バージョンを変更するのではなく、新しいパッケージ名を使用すべきであることを論証しています。

パッケージの連結を最小限にすること

パッケージに含まれる型が、他のパッケージに含まれる型を参照することがあります。例えば、メソッドのパラメーターの型と戻り値の型や、フィールドの型などです。このようなパッケージ間の連結は、「使用制約」と呼ばれるしがらみをパッケージに課すことになります。つまり、API コンシューマーと API プロバイダーの両方が参照先の型を理解するためには、API コンシューマーが API プロバイダーで参照しているのと同じパッケージを使用しなければならないということです。

通常は、このようなパッケージ連結を極力抑え、パッケージの使用制約を最小限にする必要があります。そうすることによって、OSGi 環境内での関連付けの解決が容易になり、依存性の論理出力数が最小限になってデプロイメントが単純化されます。

クラスよりもインターフェースを優先すること

API では、クラスよりもインターフェースが優先されます。このかなり一般的な API 設計手法は、モジュール式 Java でも大きな意味を持ちます。インターフェースを使用すると、実装に自由が与えられるだけでなく、複数の実装が可能になります。インターフェースは、API コンシューマーを API プロバイダーから切り離す上で重要です。インターフェースを使用すれば、API インターフェースが含まれるパッケージを、そのインターフェースを実装している API プロバイダーと、そのインターフェース上のメソッドを呼び出す API コンシューマーの両方がパッケージを使用できるようになります。この手法を取ることで、API コンシューマーは API プロバイダーに直接依存することがなくなり、どちらも API パッケージにだけ依存することになります。

インターフェースではなく、抽象クラスが設計の選択肢として有効であることもあります。けれども、最近の改善によってインターフェースにデフォルト・メソッドを追加できるようになったことを考えると、通常はインターフェースが第一の選択肢となります。

最後に付け加える点として、API では小さい具体的なクラス (イベント型や例外型など) が必要になることがよくあります。こうしたクラスを使用するのでも問題はありませんが、通常は、型を不変にして、API コンシューマーによるサブクラス化に使用されないようにすることが重要です。

静的にしないようにすること

API では静的な要素を使わないようにしてください。具体的には、型には静的メンバーを持たせてはなりません。静的ファクトリーは使用しないようにします。インタンスの作成は API から分離する必要があります。例えば、API コンシューマーが API 型のオブジェクト・インスタンスを受け取る手段としては、依存性注入、OSGi サービス・レジストリーなどのオブジェクト・レジストリー、あるいは JPMS の java.util.ServiceLoader を使用するようにしてください。

静的な要素は簡単にはモックできないため、静的にしないことは、テスト可能な API にする上でも有効な手法です。

シングルトン

API 設計にシングルトン・オブジェクトが含まれることがあります。ただし、シングルトン・オブジェクトには、静的 getInstance メソッドや静的フィールドなどの静的要素を介してアクセスするようにしてください。シングルトン・オブジェクトが必要な場合、API によってそのオブジェクトを定義し、上述のように依存性注入またはオブジェクト・レジストリーを介して API コンシューマーに提供する必要があります。

クラス・ローダーに対する思い込みをなくすこと

多くの場合、API には拡張性機能が備わっており、API プロバイダーがロードしなければならないクラスの名前を API コンシューマーが指定できるようになっています。この場合、API プロバイダーは Class.forName (場合によってはスレッド・コンテキストのクラス・ローダー) を使用して、指定された名前のクラスをロードする必要があります。このようなメカニズムでは、API プロバイダー (またはスレッド・コンテキストのクラス・ローダー) から API コンシューマーにクラスが公開されていることを前提としています。API 設計では、こうしたクラス・ローダーに対する前提を回避しなければなりません。モジュール方式の要点の 1 つは、型のカプセル化です。あるモジュール (例えば、API プロバイダー) の実装詳細が別のプロバイダー (API コンシューマー) に公開されないようにすることが、モジュール方式の目的となっています。

API 設計では、API コンシューマーと API プロバイダーの間でクラス名を受け渡さないようにすること、そしてクラス・ローダー階層と型の可視性に関する思い込みをなくすことが必要です。拡張性モデルを実現するためには、API 設計で API コンシューマーが API プロバイダーにクラス・オブジェクトを渡すようにするか、さらに有効な方法として、インスタンス・オブジェクトを渡すようにします。それには、API 内のメソッドを使用するか、OSGi サービス・レジストリーなどのオブジェクト・レジストリーを使用できます。このリンク先のホワイトペーパーで説明しているホワイトボード・パターンを参照してください。

java.util.ServiceLoader クラスも (JPMS モジュール外で使用されるときは)、すべてのプロバイダーがスレッド・コンテキストのクラス・ローダーまたは指定されたクラス・ローダーに公開されていることを前提とする点で、クラス・ローダーの前提によって苦労します。通常、この前提はモジュール式環境に当てはまりませんが、JPMS ではモジュール宣言で ServiceLoader 管理対象サービスを指定または使用できるようになっています。

永続性を前提としないこと

多くの API 設計では、構成フェーズでのみオブジェクトがインスタンス化されて API に追加されることを前提とし、動的システムで発生する可能性のある構成解除フェーズを無視しています。API 設計では、オブジェクトが追加される可能性と削除される可能性の両方を考慮する必要があります。一例を取ると、ほとんどのリスナー API ではリスナーを追加および削除できるようになっています。けれども、多くの API 設計では、追加されたオブジェクトが削除されることはないと前提しています。例えば、多くの依存性注入システムには、注入されたオブジェクトを破棄する手段がありません。

OSGi 環境内では、モジュールを追加したり削除したりできます。したがって、こうした動態に対応できる API 設計が重要となります。OSGi Declarative Services 仕様では、OSGi を対象とした依存性注入モデルを定義しています。このモデルは、注入したオブジェクトを含め、このような動態をサポートしています。

API コンシューマーと API プロバイダーの型に対する役割を明確に文書化すること

「はじめに」で説明したように、API パッケージに対するクライアントの役割には、API コンシューマーと API プロバイダーの 2 つがあります。API コンシューマーが API を使用し、API プロバイダーが API を実装します。API に含まれるインターフェース (および抽象クラス) の型について重要な点は、それらの型のうち、API プロバイダーだけが実装するものと、API コンシューマーでも実装できるものを、API 設計で明確に文書化することです。例えば、リスナー・インターフェースは一般に API コンシューマーによって実装され、そのインスタンスが API プロバイダーに渡されます。

API プロバイダーは API コンシューマーによって実装された型についても、自身が実装した型についても、その変更による影響を受けやすいものです。API プロバイダーは自身が実装した型に加えられた新しい変更を実装する必要があるとともに、API コンシューマーによって実装された型での新しい変更を把握し、型が変更された後の関数を呼び出せるようでなければなりません。一般に、API コンシューマーは新しい関数を呼び出せるように型を変更する場合を除き、API プロバイダーが実装した型の変更を無視できます (互換性があります)。その一方で、API コンシューマーは自身が実装した型の変更による影響を受けがちです。通常は、新しい関数を実装するために変更が必要になります。例えば、javax.servlet パッケージに含まれる ServletContext 型は、サーブレット・コンテナーなどの API プロバイダーによって実装されます。ServletContext に新しいメソッドを追加するには、すべての API プロバイダーを更新して新しいメソッドを実装する必要がありますが、API コンシューマーについては、その新しいメソッドを呼び出す必要がない限り、変更する必要はありません。一方、Servlet 型は API コンシューマーによって実装されます。Servlet に新しいメソッドを追加するには、その新しいメソッドを実装するよう、すべての API コンシューマーを変更しなければならないだけでなく、すべての API プロバイダーもその新しいメソッドを使用するように変更する必要があります。したがって、ServletContext 型に対しては API プロバイダーの役割、Servlet 型に対しては API コンシューマーの役割を定義します。

API コンシューマーの数は多く、API プロバイダーの数は少ないのが通常なので、API プロバイダーが実装する型については比較的柔軟に変更できますが、API コンシューマーが実装する型に変更を加える場合は極めて慎重に検討しなればなりません。そのわけは、更新後の API をサポートするために変更が必要となる API プロバイダーの数はわずかになることになる一方で、既存の多数の API コンシューマーを変更することは望まないためです。API コンシューマーを変更する必要があるのは、API コンシューマーで新しい API を利用する必要がある場合のみです。

OSGi Alliance では、API パッケージに含まれる型の役割を記述するアノテーションProviderTypeConsumerType を定義し、osgi.annotation jar に含まれるこれらのアノテーションを API で使用できるようにしています。

まとめ

次に API を設計する際は、この記事で説明した API 設計手法を考慮してください。そうすれば、モジュール式 Java 環境でも非モジュール式 Java 環境でも利用できる API を設計できます。


ダウンロード可能なリソース


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=1064714
ArticleTitle=Java の API 設計手法
publish-date=02212019