Service クラスのトランザクション

Grails は、 Artefact という概念を持っています。以下、この文章では、この概念の具体クラスを Artefact クラスと書きます。

Artefactクラスの特徴は、アプリケーションを起動した状態で Artefactクラスのソースコードの修正を行うと、アプリケーションにその修正が反映されることです。Artefact の正確な定義は見つけられていないのですが、私はリロード可能な実装要素と理解しています(新規作成したArtefactクラスも実行時にロードされます)。
もう少し具体的に書くと、Grails は、Service, Domain, Controller, TagLib, Codec, Filters, UrlMappings といった Artefactを組み込みで提供していますが、これらの概念としての Artefact の具体が Artefact クラスです。このArtefact クラスは Grails の利用者が実装します。そして、Artefact クラスは実行時に編集するとアプリケーションに反映されます。とまぁ、こういう調子で理解しています(実際はいくらかうまくリロードしないケースがあります)。


少し内部に入りますと、上で挙げた概念としての各 Artefact には、 AbstractGrailsClass を継承したサブクラス(他にも協調するクラスあり)とそれらを管理する Plugin が存在します。そして、これらの実装により個々の Artefactクラスに個性を加えています(AbstractGrailsClassは、Bean に責務を加える Wrapper クラスになります)。

少し分かり難いと思いますので、Service クラスを例にしますと、個々の Servise クラスは、利用側から見ると、単に Spring Framework の DI Container で管理された Bean に見えるのですが、実際は、AbstractGrailsClassというラッパークラス(のサブクラス)でそれらのクラスはラッピングされ、このラッパークラスから間接的に使用していることになります。
AbstractGrailsClass でラッピングする目的は、当然いくつかの責務を加わえることにあるのですが、その主要なものとして ExpandoMetaClass の提供があります。この責務により、例えばダイナミックメソッドを個々のServiceクラス(のラッパー)に登録することが実行時に可能になります。実際には、この登録処理は、Service クラス専用の Plugin である ServicesGrailsPlugin( のdoWithDynamicMethodsクロージャ)で通常行われます。余談ですが、「通常」と書いたのは、現在の ServicesGrailsPlugin は、doWithDynamicMethods クロージャを持っていないためです。
リロードに関しても Plugin が絡みますが、これはまた別の記事で書きたいと思います。



本題に入ります。
全ての Service クラスには、トランザクション制御の為の仕組みがデフォルトで加えられます。
少しそれますが、次のコードの様に`transactional=false' と記述することにより、その仕組みを加えないようにすることもできます。
Grailsのリファレンスから引用:

class CountryService {
    static transactional = false
}

さてさて、先に Plugin の doWithDynamicMethodsクロージャの記述によってダイナミックメソッドを加えることができると書きましたが、Service クラスのトランザクション制御の仕組みは doWithSpring クロージャの中の記述により加えられています。記述する内容は Spring Framework の Bean 定義そのものといって良いものです。

実際の該当コードは、次のようになっています。
ServicesGrailsPluginクラスのdoWithSpringクロージャ

	def doWithSpring = {
		application.serviceClasses.each { serviceClass ->
		    def scope = serviceClass.getPropertyValue("scope")

			"${serviceClass.fullName}ServiceClass"(MethodInvokingFactoryBean) {
				targetObject = ref("grailsApplication", true)
				targetMethod = "getArtefact"
				arguments = [ServiceArtefactHandler.TYPE, serviceClass.fullName]
			}

			def hasDataSource = (application.config?.dataSource 
			    || application.domainClasses.size() > 0)						
			if(serviceClass.transactional && hasDataSource) {
				def props = new Properties()
				props."*"="PROPAGATION_REQUIRED"
				"${serviceClass.propertyName}"(TransactionProxyFactoryBean) { bean ->
				    if(scope) bean.scope = scope
					target = { innerBean ->   
						innerBean.factoryBean =
						     "${serviceClass.fullName}ServiceClass"
						innerBean.factoryMethod = "newInstance"
						innerBean.autowire = "byName"
						if(scope) innerBean.scope = scope
					}
					proxyTargetClass = true
					transactionAttributes = props
					transactionManager = ref("transactionManager")					       
				}
			}
			else {
			    "${serviceClass.propertyName}"(serviceClass.getClazz()) { bean ->
				  bean.autowire =  true
                                      if(scope) {
                                            bean.scope = scope
                                      }

			     }
			}
		}
	}

※ 見やすいように適当に改行を加えています。が、まだ見難いですね。

TransactionProxyFactoryBean という FactoryBean を使って TransactionInterceptor で Serviceクラス をラッピングすることでトランザクション制御を加えています。

さらに、TransactionProxyFactoryBean に関係する設定コードを見て気づくのは、Hibernateトランザクションアノテーションが利用できない設定になっていることです。つまり、Service クラスでトランザクション制御を利用する場合、トランザクションロールバックするのは、RuntimeException が発生した時だけに限定されるということです。

ここで考えないといけないのは、『Groovy は RuntimeException 系以外の例外に対しても try/catch 文を要求しない為にそれを省いたコードを書ける』という点です。

例えば、意味のないものですが、DomainAService という Service クラスを作り、次のようなメソッドを書いたとします。

    def create(def params) {
        def domainA = new DomainA(params)
        domainA.save()
        if(true) throw new Exception("exception") // 例外を発生させる
        return domainA
    }

そして、Controller の save で次のように呼び出します。

   def save = {
        def domainA = domainAService.create(params)
        if(!domainA.hasErrors()) {
            flash.message = "DomainA ${domainA.id} created"
            redirect(action:show,id:domainA.id)
        }
        else {
            render(view:'create',model:[domainA:domainA])
        }
    }

すると次のような例外がブラウザ上に現れます。

[249766] errors.GrailsExceptionResolver java.lang.reflect.UndeclaredThrowableException
org.codehaus.groovy.runtime.InvokerInvocationException: java.lang.reflect.UndeclaredThrowableException
	at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:92)
	at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:226)
	at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:899)
	at groovy.lang.ExpandoMetaClass.invokeMethod(ExpandoMetaClass.java:946)
	at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:740)
	at groovy.lang.Closure.call(Closure.java:292)
	at groovy.lang.Closure.call(Closure.java:287)
	at org.codehaus.groovy.grails.web.servlet.mvc.SimpleGrailsControllerHelper.handleAction(SimpleGrailsControllerHelper.java:525)
(省略)
Caused by: java.lang.reflect.UndeclaredThrowableException
	at DomainAServiceService$$EnhancerByCGLIB$$657c2eb6.create(<generated>)
	at DomainAController$_closure8.doCall(DomainAController:76)
	at DomainAController$_closure8.doCall(DomainAController)
Caused by: java.lang.Exception: exception
	at DomainAService.create(DomainAService.groovy:8)
	at DomainAServiceService$$FastClassByCGLIB$$68b8337d.invoke(<generated>)
	at net.sf.cglib.proxy.MethodProxy.invoke(MethodProxy.java:149)
	... 3 more

ブラウザ上あるいはログ出力からは、 InvokerInvocationException という RuntimeException をキャッチしたかに見え、トランザクションロールバックが行われていそうですが、実際に実験してみるとロールバックは行われず DomainA のオブジェクトは DB に保存されてしまいます。

このことから、Serviceクラス のトランザクション制御を利用する場合には、『本来必要な try/catch を省いてはいけない』ということが言えそうです。しかし、この方針を明確にしただけでは十分ではなく、完全に問題がない状態であることを確認できなければ安心ができない方もいらっしゃるかもしれません。なぜなら、稀有かもしれませんが、Grails 本体あるいは利用している PluginのGroovy コードに問題が潜んでいるかもしれないからです。
SpaceCardプロジェクトでは、(外部で開発された)機能を拡張するような Plugin を差込むことでアプリケーション機能が拡張されることを(無くなってしまうかもしれませんが)構想として持っています。このことを含めて考えると、良いアイデアが見つからず、Service クラスでトランザクション制御を利用することを諦め、他の要件と絡めて独自の Logic という Artefact を設計し、Logic クラスレベルでトランザクション制御を可能にしました。その実装では Throwable オブジェクトを全てキャッチしロールバックするようにしています。

※断定的に書いているところにも間違いがあるやもしれません。間違いのご指摘お待ちしています。