SIerだけど技術やりたいブログ

6年目のSIerのブログです

Spring管理外のクラスでSpringのBeanを使う

Spring管理外のクラスでSpringのBeanを使う

Spring管理外のクラスでSpringのBeanを使いたい場面がある。
そもそも自分でnewすればいいだけでは?という場面もあるけど、@ProfileでどんなBeanが来るのか実行時までわからない、とか複雑な初期化あるとかいう前提。

例えば、

  • ORMが生成するクラス内でSpringのBean参照したい
  • throw時に毎回インスタンスを生成する例外クラスで、Springの管理してるメッセージを取り出したい

どうすればよいのか。方法は2つある。

  • Springの@Configurableを使う方法(aspectjで実現する方法)
  • 使いたいフィールドをstaticにして強引にSpringのBeanをつっこむ方法

Springの@Configurable(aspectjで実現する方法)

外部ライブラリで勝手にnewされるクラスでSpringのBeanを使いたかったらこの方法。今回はJPAで取得してきたドメインクラス内でSpringの管理Beanを使う例。

1. DBにサイトのリンクをブックマークとして登録する
2. JPAでDBからブックマークを取得する
3. JPAで取得したクラス内でSpring管理BeanのRestTemplateをつかってリンクのサイトがまだ存在するか確認する

Springの管理Beanを使うPOJOのクラス

  • SpringのBeanを使う側に@Configurableをつける。そうするとaspectj(というかspring-aspects.jar?)でコンパイル時にSpring管理Beanをインジェクトする処理が追加される。(黒魔術感がはんぱじゃない)
  • インジェクトしたいフィールドに@Autowired追加する
@Entity
@Configurable
public class BookMark {
  @Id
  @GeneratedValue
  private Long id;
  private String link;

  BookMark() {
  }

  public BookMark(String link) {
    this.link = link;
  }

  public String getLink() {
    return link;
  }

  public void setLink(String link) {
    this.link = link;
  }

  @Transient
  @Autowired
  private RestTemplate restTemplate;

  // アクセスして200が返ってこなければfalse
  public boolean isExist() {
    ResponseEntity<String> result;
    try {
      result = restTemplate.getForEntity(link, String.class);
    } catch (ResourceAccessException e) {
      return false;
    }
    if (result.getStatusCode() == HttpStatus.OK) {
      return true;
    } else {
      return false;
    }
  }
}

Configurationクラス

  • @EnableSpringConfiguredを追加する

Springの管理BeanにRestTemplateを追加するのも忘れずに。

@EnableSpringConfigured
@SpringBootApplication
public class AspectApplication {

  public static void main(String[] args) {
    SpringApplication.run(AspectApplication.class, args);
  }

  @Bean
  public RestTemplate getRestTemplate() {
    return new RestTemplate();
  }

  @Autowired
  BookMarkRepository bookMarkRepository;

  //Spring Data JPAを使って確認
  @Bean
  public CommandLineRunner save() {
    return args -> {
      System.out.println("-----------save-----------");
      bookMarkRepository.save(new BookMark("http://google.com"));
      bookMarkRepository.save(new BookMark("http://google.come"));
      System.out.println("--------execute--------");
      bookMarkRepository.findAll().forEach(bookMark -> {
        System.out.println(bookMark.getLink() + " isExist:  " + bookMark.isExist());
      });
    };

  }
}

pom.xml

aspectJ使うのでコンパイル時に指定が必要

<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>aspectj-maven-plugin</artifactId>
  <version>1.8</version>
  <executions>
    <execution>
    <goals>
      <goal>compile</goal>
      <goal>test-compile</goal>
    </goals>
    </execution>
  </executions>
  <configuration>
    <aspectLibraries>
    <aspectLibrary>
      <groupId>org.springframework</groupId>
      <artifactId>spring-aspects</artifactId>
    </aspectLibrary>
    </aspectLibraries>
    <complianceLevel>1.8</complianceLevel>
    <source>1.8</source>
    <target>1.8</target>
  </configuration>
</plugin>

サンプル

サンプル書いた。

github.com




使いたいフィールドをstaticにして強引にSpringのBeanをつっこむ方法

自分でnewしたコードでSpringのBean使いたい、って例外クラスくらいしか思いつかないので例外を例にして説明。

SpringのBeanを使う側のクラスでstaticフィールド定義しとく。

public class BusinessException extends RuntimeException {
  private static MessageSource ms;
  private ErrorCode errorCode;
  public BusinessException(ErrorCode errorCode) {
    super(ms.getMessage(errorCode.code(), null, Locale.getDefault()));
    this.errorCode = errorCode;
  }

  public static void setMessageSource(MessageSource ms){
    BusinessException.ms = ms;
  }

}

強引にSpringのBeanをつっこむ。
Bean解決が終わったあと(@PostConstruct)にインジェクトする。

@Component
public class MessageSourceInjector {
  @Autowired
  private MessageSource ms;

  @PostConstruct
  public void inject(){
    BusinessException.setMessageSource(ms);
  }
}

注意点

  • 同一インスタンスを共有するので、スレッドセーフなクラス以外入れないこと
  • Springの管理Beanのライフサイクルから外れるので、Singletonより短いスコープを入れないこと(リクエストスコープのBeanを生成の度に全インスタンスで共有するstaticフィールドなんかにつっこむのは危ない)

サンプル

サンプル書いた。

github.com

Spring REST Docs でAPIドキュメントを作成する

Spring REST Docs とは

RESTfulなサービスのドキュメント作成を支援するプロダクト。
テストコードをもとにasciidoc形式のドキュメントを生成する。

テストをパスした内容しかドキュメント化させないので、正確なドキュメント作成ができる。

生成したasciidocはAsciidoctorでhtml,pdfなどに変換できる。
(自分で書いたasciidocとも統合できる)

テストコードはデフォルトではJUnit + Spring MVC Testをサポート。
(TestNGやREST Assuredなどの他のライブラリも対応している)


f:id:kimulla:20160710121141p:plain

サンプル

サンプル書いた。
github.com

使い方

まずは、SpringInitializrからひな形を生成する。

mavenのpom.xmlにversionを指定する

SpringBootの1.3.6.RELEASEはspring-mvc-restdocバージョンが古いので、spring-boot-starter-parent で指定されているspring-restdocsのversionを上書きする。

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.restdocs</groupId>
      <artifactId>spring-restdocs-mockmvc</artifactId>
      <version>1.1.0.RELEASE</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.restdocs</groupId>
      <artifactId>spring-restdocs-core</artifactId>
      <version>1.1.0.RELEASE</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.restdocs</groupId>
    <artifactId>spring-restdocs-mockmvc</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.restdocs</groupId>
    <artifactId>spring-restdocs-core</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Asciidoctorでasciidocからhtmlを生成する

以下を参考にpom.xmlにAsciidoctorの設定をする。
https://github.com/asciidoctor/asciidoctor-maven-plugin

<plugin>
  <groupId>org.asciidoctor</groupId>
  <artifactId>asciidoctor-maven-plugin</artifactId>
  <version>1.5.2</version>
  <configuration>
    <!-- 変換元のadocのディレクトリ -->
    <sourceDirectory>${snippetsDirectory}</sourceDirectory>     
    <!-- 変換先のhtmlのディレクトリ -->
    <outputDirectory>${docDirectory}</outputDirectory>
  </configuration>
  <executions>
    <execution>
      <id>asciidoc-to-html</id>
      <phase>prepare-package</phase>
      <goals>
        <goal>process-asciidoc</goal>
      </goals>
      <configuration>
        <backend>html5</backend>
        <doctype>book</doctype>
        <sourceHighlighter>coderay</sourceHighlighter>
        <preserveDirectories>true</preserveDirectories>
        <sourceDocumentName>index.adoc</sourceDocumentName>
        <!-- asciidocからgenerated-snippetsを参照するときに使う変数定義-->
        <attributes>
          <snippets>${snippetsDirectory}</snippets>
        </attributes>
      </configuration>
    </execution>
  </executions>
</plugin>

生成したhtmlをjarに含める

<plugin>
  <artifactId>maven-resources-plugin</artifactId>
  <version>2.7</version>
  <executions>
    <execution>
      <id>copy-resources</id>
      <phase>prepare-package</phase>
      <goals>
        <goal>copy-resources</goal>
      </goals>
      <configuration>
        <outputDirectory>
          ${project.build.outputDirectory}/static/docs
        </outputDirectory>
        <resources>
          <resource>
            <directory>
              ${docDirectory}
            </directory>
          </resource>
        </resources>
      </configuration>
    </execution>
  </executions>
</plugin>

各pluginを実行するphase

上2つのpluginはtestが通ってpackageが始まる前のフェーズ(prepare-pachage)に実行する。そのため、テストが失敗した場合はasciidocは生成されない。

mavenのライフサイクルの詳細はここ。
https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html#Lifecycle_Reference

ライフサイクルってなに?という方には下記も参考になると思います。
kimulla.hatenablog.com

自作のasciidocと統合したい場合

htmlに変換する前に、自作したasciidocをgenerated-snippetsディレクトリにコピーする。

<plugin>
  <artifactId>maven-resources-plugin</artifactId>
  <executions>
    <execution>
      <id>copy-adoc</id>
      <phase>validate</phase>
      <goals>
        <goal>copy-resources</goal>
      </goals>
      <configuration>
        <outputDirectory>${snippetsDirectory}</outputDirectory>
        <resources>
          <resource>
            <directory>src/main/asciidoc</directory>
          </resource>
        </resources>
      </configuration>
    </execution>
  </executions>
</plugin>

mavenコマンド実行のイメージ

  1. テストを実行すると
    • Spring REST Docsが、target/generated-snippetsにasciidocを生成する
  2. テストが終わると、
    • maven-resources-pluginが、src/main/adocからtarget/generated-snippetsにコピーする
    • Asciidoctorが、target/generated-docsにhtmlを生成する

テストコード

SpringMVC Testを使った単体テストを書く。
ドキュメントをどの単位で出力するのかはalwaysDoやandDoに記載されたdocument(...)で制御する。今回はテストメソッドごとに出力している。
同じ出力先を複数テストケースで指定すると、同名ファイルは上書きされる。

public class GreetingControllerTest {
  private MockMvc mockMvc;

  // asciidocの出力先ディレクトリ
  @Rule
  public final JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation("target/generated-snippets");

  @Before
  public void setUp() {
    this.mockMvc = MockMvcBuilders
          .standaloneSetup(new GreetingController())
          .apply(documentationConfiguration(restDocumentation))
          // 全てのテストケースで出力するドキュメント
          // メソッドごとにasciidocを生成する
          // リクエストの説明をドキュメントに出力する
          .alwaysDo(document("greeting/{method-name}",
             requestParameters(
               parameterWithName("hour")
               .description("時刻: 必須入力(0-24)")
             )))
          .build();
  }

  @Test
  public void doc() throws Exception {
    this.mockMvc.perform(get("/greeting").param("hour", "24"))
          .andExpect(status().isOk())
          .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
          .andExpect(jsonPath("$.greeting").value("good night"))
          // レスポンス結果をドキュメントに出力する
          .andDo(document("greeting/{method-name}",
            responseFields(
              fieldWithPath("greeting").type(JsonFieldType.STRING)
              .description("hourが0-4,16-24の場合はgood night,5-15の場合はgood morning")
            )));
  }

  @Test
  public void 入力値範囲外_下限越え() throws Exception {
    this.mockMvc.perform(get("/greeting").param("hour", "-1"))
          .andExpect(status().isBadRequest())
          .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
  }
  ..
}

自作のasciidocドキュメントを作る

今のままだと以下のようなasciidocが出力されるだけなので、
これらをまとめて1つのドキュメントにできるようにasciidocを作成する。

- target/generated-snippets/greeting/doc/curl-request.adoc
- target/generated-snippets/greeting/doc/http-request.adoc
- target/generated-snippets/greeting/doc/http-response.adoc
- target/generated-snippets/greeting/doc/httpie-request.adoc
- target/generated-snippets/greeting/doc/response-fields.adoc
- target/generated-snippets/greeting/doc/request-parameters.adoc
- target/generated-snippets/greeting/入力値範囲外_下限越え/curl-request.adoc
- target/generated-snippets/greeting/入力値範囲外_下限越え/httpie-request.adoc
- target/generated-snippets/greeting/入力値範囲外_下限越え/http-request.adoc
- target/generated-snippets/greeting/入力値範囲外_下限越え/http-response.adoc
- target/generated-snippets/greeting/入力値範囲外_下限越え/request-parameters.adoc

src/main/adoc にindex.adocを作る。
今回は正常系だけをHTMLドキュメントにするので、入力値範囲外_下限越えのドキュメントは使わない。

= API Document

[[greeting]]
== Greeting

時刻に応じたあいさつを返す

=== Request Parameter
include::{snippets}/greeting/doc/request-parameters.adoc[]

=== Response Fields
include::{snippets}/greeting/doc/response-fields.adoc[]

=== Example request
include::{snippets}/greeting/doc/curl-request.adoc[]

=== Example response
include::{snippets}/greeting/doc/http-response.adoc[]

最終的に生成されるドキュメント(html)

target/generated-docsに、いい感じのドキュメントが生成される。

f:id:kimulla:20160710121141p:plain

テストと反するドキュメントを書くと…

レスポンスのフィールドを実際の値から以下のように変えると…

fieldWithPath("greetingaa").type(JsonFieldType.STRING)

エラーになる。

Tests run: 9, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.997 sec <<< FA
ILURE! - in com.example.controllers.GreetingControllerTest
doc(com.example.controllers.GreetingControllerTest)  Time elapsed: 0.063 sec  <<
< ERROR!
org.springframework.restdocs.snippet.SnippetException: The following parts of th
e payload were not documented:
{
  "greeting" : "good night"
}
Fields with the following paths were not found in the payload: [greetingaa]
        at org.springframework.restdocs.payload.AbstractFieldsSnippet.validateFi
eldDocumentation(AbstractFieldsSnippet.java:158)
        at org.springframework.restdocs.payload.AbstractFieldsSnippet.createMode
l(AbstractFieldsSnippet.java:97)
        at org.springframework.restdocs.snippet.TemplatedSnippet.document(Templa
tedSnippet.java:64)
        at org.springframework.restdocs.generate.RestDocumentationGenerator.hand
le(RestDocumentationGenerator.java:196)
        at org.springframework.restdocs.mockmvc.RestDocumentationResultHandler.h
andle(RestDocumentationResultHandler.java:54)
        at org.springframework.test.web.servlet.MockMvc$1.andDo(MockMvc.java:177
)
        at com.example.controllers.GreetingControllerTest.doc(GreetingController
Test.java:85)
...

Results :

Tests in error:
  GreetingControllerTest.doc:85 ≫ Snippet The following parts of the payload w
r...

Tests run: 10, Failures: 0, Errors: 1, Skipped: 0

所感

良いところ

  • テストケースをパスしないとドキュメントが作れないので、ある程度コードとドキュメントとの整合性は保てそう
  • asciidocなのでカスタマイズしやすそう
  • PDFやHTMLなどの色々な種類のドキュメントを生成できる

悪いところ

  • テストケースをパスしないとドキュメントが作れないので、設計ドキュメントにはできなそう

検討しないといけないこと

  • INPUTごとに異なる返却値を返す場合、細かな内部仕様はテストケースから生成できるのか(難しそう)

結論

やっぱりテストケースから仕様を生成するよりも、まずは仕様をまとめたいので、Swaggerみたいなツールのほうがいいかな。
(Springアノテーションからドキュメント生成できるSwagger拡張のSpringFoxを試してみたい)
https://github.com/springfox/springfox

SpringBoot @Valueで任意の型にセットする CustomPropertyEditorの作成

はじめに

Springには外部定義から値を読み込んで、Javaのフィールドにセットする機能がある。

application.propertiesにメッセージを記述し、

message=hello

Javaクラスで利用すると、

@Service
public MessageServiceImpl implements MessageService {

@Value("${message}")
String message;

public void say() {
    //helloが出力される
    System.out.println(message);  
}

}

詳細はTERASOLUNAガイドラインがわかりやすい。
5.10. プロパティ管理 — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.1.0.RELEASE documentation

またSpringBootでは、外部ファイルのほかにも環境変数やJNDIなど、様々なところから値をとってこれる。24. Externalized Configuration


@Valueの詳細

外部定義の値(String)からJavaの型へのマッピングはPropertyEditorで行われる。
デフォルトで定義されているPropertyEditorは以下の通り。
詳細はリファレンスを参照。http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#beans-beans-conversion

  • ByteArrayPropertyEditor
  • ClassEditor
  • CustomBooleanEditor
  • CustomCollectionEditor
  • CustomDateEditor
  • CustomNumberEditor
  • FileEditor
  • InputStreamEditor
  • LocaleEditor
  • PatternEditor
  • PropertiesEditor
  • StringTrimmerEditor
  • URLEditor

上記で処理できない型にマッピングしようとすると、ConversionNotSupportedExceptionがスローされる。

Caused by: org.springframework.beans.ConversionNotSupportedException: Failed toconvert value of type [java.lang.String] to required type [java.nio.file.Path];
nested exception is java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [java.nio.file.Path]: no matching editorsor conversion strategy found
        at org.springframework.beans.TypeConverterSupport.doConvert(TypeConverterSupport.java:74)
        at org.springframework.beans.TypeConverterSupport.convertIfNecessary(TypeConverterSupport.java:54)
        at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1033)
        at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1014)
        at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:545)
        ... 24 more
Caused by: java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [java.nio.file.Path]: no matching editors or conversion strategy found
        at org.springframework.beans.TypeConverterDelegate.convertIfNecessary(TypeConverterDelegate.java:302)
        at org.springframework.beans.TypeConverterDelegate.convertIfNecessary(TypeConverterDelegate.java:125)
        at org.springframework.beans.TypeConverterSupport.doConvert(TypeConverte
rSupport.java:61)
        ... 28 more

マッピングする型を増やす方法

JavaのPathを例に説明する。

1. マッピングさせたい型のPropertyEditor を作る。

package com.example.path;

import java.beans.PropertyEditorSupport;
import java.nio.file.Paths;

public class PathPropertyEditor extends PropertyEditorSupport{
  @Override
  public void setAsText(String text) throws IllegalArgumentException {
    this.setValue(text == null?null: Paths.get(text));
  }
}

2. PropertyEditorを登録する。

@SpringBootApplication
public class PathPropertyEditorApplication {

  public static void main(String[] args) {
    SpringApplication.run(PathPropertyEditorApplication.class, args);
  }

  @Bean
  public CustomEditorConfigurer getCustomEditorConfigurer(){
    CustomEditorConfigurer configurer = new CustomEditorConfigurer();
    Map<Class<?>, Class<? extends PropertyEditor>> map = new HashMap<>();
    map.put(Path.class, PathPropertyEditor.class);
    configurer.setCustomEditors(map);
    return configurer;
  }
}

実行してみる。

@Component
public class Application implements CommandLineRunner {
  @Value(("${home.dir}"))
  Path path;

  @Override
  public void run(String... strings) throws Exception {
    System.out.println("----------------");
    System.out.println(path);
    System.out.println("----------------");
  }
}

実行結果

----------------
\tmp
----------------


これで外部定義した文字列を任意のJavaの型にマッピングできる。

サンプル

サンプル書いた。
https://github.com/kimullamen/path-property-editor