読者です 読者をやめる 読者になる 読者になる

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

5年目のSIerのブログです

Spring Framework で同一アプリ内でのイベントを扱う(ApplicationEvent、EventPublisher)

Java Spring SpringBoot Servlet

Spring Frameworkには、イベントを扱うための機能がある。
イベントの登場人物は以下。

ロール 役割 実装方法
Publisher イベントを発行する ApplicationEventPublisher
Listener イベントを受け取る @EventListener、ApplicationListener

なぜイベントを使うのか

イベントを利用すると、コンポーネント間を疎結合に実装することができる。
あるイベントが起きたときに、そのイベントきっかけで実施しないといけない処理が多くなるようなときに使うと、そのイベントに対してどのような処理をするべきかという責務が特定のServiceクラスにふくれあがることなく、それぞれのListenerにもっていけるので見通しがよくなる。(と思う)
また、ライブラリでイベントを発行しておけば、ライブラリ本体に手を入れなくても特定タイミングで処理をはさみこめるようになる。(Springコンテナの初期化が終わったらxxxの処理をしたい、とか)

デフォルトで提供されているイベント

Spring Frameworkにはデフォルトでいくつかのイベントが定義されているので、コンテナのCRUDに関する任意のタイミングで、アプリケーション実装者が任意の処理をはさみこめるようになっている。

イベント名 イベントが発生するタイミング
ContextRefreshedEvent ConfigurableApplicationContext のrefresh()
ContextStartedEvent ConfigurableApplicationContextのstart()
ContextStoppedEvent ConfigurableApplicationContextのstop()
ContextClosedEvent ConfigurableApplicationContextのclose()
RequestHandledEvent リクエスト処理が終わったとき(WEB限定)

Listenerの実装方法

Listenerの実装方法は2通りある。

  • ApplicationListenerを実装する方法(~spring4.1)
  • @EventListenerアノテーションを使う方法(spring4.2~)

ApplicationListenerを実装する方法(~spring4.1)

ApplicationListener<T>のTにハンドリングしたいイベントを指定する。

@Slf4j
@Component
public class BeforeSpring42Listener implements ApplicationListener<ContextClosedEvent> {
  @Override
  public void onApplicationEvent(ContextClosedEvent event) {
    log.info("bood bye");
  }
}

@Eventlistenerアノテーションを使う方法(spring4.2~)

メソッド引数にハンドリングしたいイベントを指定し、@EventListenerを付与する。

@Component
@Slf4j
public class AfterSpring42listener {
  @EventListener
  public void processContextClosedEvent(ContextClosedEvent event) {
    log.info("good bye");
  }
}

カスタムイベントの実装方法

上記以外に、任意のイベントを作成することもできる。

サンプルアプリの題材説明

カスタムイベントのサンプルを書いた。
題材はスロットマシーン。

通知するApplicationEventをカスタムイベントにする。

Publisher -> (ApplicationEvent) -> Listener
クラス 役割
SlotMachine 3回スロットを回すクラス。スロットを回すときにSlotStartEventを発行する。
Rotation スロット1回を表現するクラス
SlotStartEvent スロットが始まったことを表現するイベント
BeforeSpring42Listener ApplicationListnerで実装したリスナ
AfterSpring42listener @EventListenerで実装したリスナ
FeverEvent 大当たりが発生したことを表現するイベント


github.com

ApplicationEvent

ApplicationEventを拡張して任意のイベントを作成する。

public class SlotStartEvent extends ApplicationEvent {
  private final Rotation rotation;

  public SlotStartEvent(Object source, Rotation rotation) {
    super(source);
    this.rotation = rotation;
  }

  public Rotation getRotation() {
    return rotation;
  }
}

Publisher

ApplicationEventPublisher を使ってイベントを発行する。

@Component
@Data
@Slf4j
public class SlotMachine {
  private final ApplicationEventPublisher publisher;
  public void execute() {
    LongStream.rangeClosed(1, 3).forEach(i -> {
      log.info(">>>-------------------------");
      this.publisher.publishEvent(new SlotStartEvent(this, new Rotation(i)));
      log.info("<<<-------------------------");
    });
  }
}

Listener

ApplicationEventを受け取って処理する。

@Component
@Slf4j
public class BeforeSpring42Listener implements ApplicationListener<SlotStartEvent> {
  @Override
  public void onApplicationEvent(SlotStartEvent event) {
    log.info("before ver4.2 listen : " + event.getRotation());
  }
}
@Component
@Slf4j
public class AfterSpring42listener {
  @EventListener
  public void processExecuteStartEvent(SlotStartEvent event) {
    log.info("after ver4.2 listen : " + event.getRotation());
  }

実行結果

2016-09-23 11:50:52.290  INFO 5136 --- [           main] com.example.SlotMachine                  : >>>-------------------------
2016-09-23 11:50:52.310  INFO 5136 --- [           main] com.example.AfterSpring42listener        : after ver4.2 listen : Rotation(id=1)
2016-09-23 11:50:52.310  INFO 5136 --- [           main] com.example.BeforeSpring42Listener       : before ver4.2 listen : Rotation(id=1)
2016-09-23 11:50:52.310  INFO 5136 --- [           main] com.example.SlotMachine                  : <<<-------------------------
2016-09-23 11:50:52.310  INFO 5136 --- [           main] com.example.SlotMachine                  : >>>-------------------------
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.AfterSpring42listener        : after ver4.2 listen : Rotation(id=2)
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.BeforeSpring42Listener       : before ver4.2 listen : Rotation(id=2)
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.SlotMachine                  : <<<-------------------------
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.SlotMachine                  : >>>-------------------------
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.AfterSpring42listener        : after ver4.2 listen : Rotation(id=3)
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.BeforeSpring42Listener       : before ver4.2 listen : Rotation(id=3)
2016-09-23 11:50:52.311  INFO 5136 --- [           main] com.example.SlotMachine                  : <<<-------------------------

注意点

デフォルトの動作だと、イベントを発行したスレッドと同一のスレッドでListenerが実行される。(実行結果のログをみても、すべてmainスレッドで処理されているとわかる)
同期的に処理していればスレッドに紐づいたコンテキスト(トランザクションコンテキストやセキュリティコンテキスト)を取得できるため、ある時には有効だが、非同期に実行したい場合もある。

非同期にListenerを実行する

まず、@EnableAsyncで非同期を有効にしたうえでTaskExecutorを用意する。

@SpringBootApplication
@EnableAsync
public class EventExampleApplication {
  public static void main(String[] args) {
    SpringApplication.run(EventExampleApplication.class, args);
  }

  @Autowired
  private SlotMachine machine;

  @Bean
  public CommandLineRunner execute() {
    return args -> {
      machine.execute();
    };
  }

  @Bean
  public TaskExecutor getTaskExecutor(){
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    return executor;
  }
}

@Eventlistenerを付与したメソッドに@Asyncを付与する。
ver4.2以前の場合は、SimpleApplicationEventMulticasterにThreadPoolTaskExecutorを指定してBean定義すればいけそうだけど、めんどくさいから試してない。

@Component
@Slf4j
public class AfterSpring42listener {
  @EventListener
  @Async
  public void processExecuteStartEvent(SlotStartEvent event) {
    log.info("after ver4.2 listen : " + event.getRotation());
  }

実行結果

2016-09-23 12:10:03.511  INFO 3556 --- [tTaskExecutor-1] com.example.AfterSpring42listener        : after ver4.2 listen : Rotation(id=1)
2016-09-23 12:10:03.521  INFO 3556 --- [tTaskExecutor-4] com.example.AfterSpring42listener        : after ver4.2 listen : Rotation(id=3)
2016-09-23 12:10:03.521  INFO 3556 --- [tTaskExecutor-3] com.example.AfterSpring42listener        : after ver4.2 listen : Rotation(id=2)

@EventListenerでSpEL式を使う

Spring Framework4.3からは、@EventListenerにSpEL式が記述できるようになった。
また、メソッドの戻り値にApplicationEventを取ると、イベントを発行することができる。(ver4.2~)

  @EventListener(condition = "#event.rotation.id == 2")
  public FeverEvent conditionOn2(SlotStartEvent event) {
    log.info("fever event start");
    return new FeverEvent(event, event.getRotation());
  }  

まとめ

同一ApplicationContext上でのイベントを気軽に作れる。


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

Java Spring SpringBoot

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ドキュメントを作成する

Java Spring

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

自作の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の作成

Java SpringBoot Spring

はじめに

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

SpringMVC 複数のフォームを送信する方法

Java Spring

よくやり方忘れるのでメモ。

こんな画面をSpringMVCで作りたい

familyNameとgivenNameが1つのFormで、それを複数繰り返す。

f:id:kimulla:20160619103043p:plain

実装方法(サーバ側)

Controllerクラスの引数に受け取るFormを用意する

f:id:kimulla:20160619104557p:plain

本筋と外れるけど、入力値チェックも設定する。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserForm {
  @Size(max = 10, min = 1)
  private String familyName;
  @Size(max = 10, min = 1)
  private String givenName;
}

子フォームの入力値チェックが有効になるように@Validを設定する。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ParentForm {
  @Valid
  private List<UserForm> userFormList;
}

Controllerクラスで引数として受け取る

(1)…ControllerクラスではFormを引数に取るだけ。
(2)…初期画面表示のために、初期値を設定する。

@Controller
public class HomeController {

  @RequestMapping(value = "/", method = RequestMethod.GET)
  public String home(Model model) {
    return "welcome/home";
  }

  @RequestMapping(value = "/entry", method = RequestMethod.POST)
  public String entry(@Validated ParentForm form,  … (1)
                   BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
      return "welcome/home";
    }
    return "welcome/home";
  }

  @ModelAttribute … (2)
  public ParentForm setUpParentForm() {
    ParentForm parentForm = new ParentForm();
    parentForm.setUserFormList(Arrays.asList(new UserForm("value1", "value2"), new UserForm("value3", "value4")));
    return parentForm;
  }

}


あとはサーバ側が受け取れるリクエストパラメータの形式で送るだけ。

親Formのフィールド変数名[添え字].子Formのフィールド変数名

例えば、

userFormList[0].familyName=value1
userFormList[0].givenName=value2

クライアント側(JSP)

ForEachで複数Form回すだけ。
pathの指定は『親Formのフィールド変数名[添え字].子Formのフィールド変数名』。

<h1>複数入力画面</h1>
<form:form method="post" action="${pageContext.request.contextPath}/entry" modelAttribute="parentForm">

  <c:forEach items="${parentForm.userFormList}" varStatus="rowStatus" var="item">
    <div>
      familyName
      <form:input path="userFormList[${rowStatus.index}].familyName"/>
      <form:errors path="userFormList[${rowStatus.index}].familyName"/>

      givenName
      <form:input path="userFormList[${rowStatus.index}].givenName"/>
      <form:errors path="userFormList[${rowStatus.index}].givenName"/>
    </div>
  </c:forEach>
  <input type="submit" value="send"/>
</form:form>

サンプル書いた

https://github.com/kimullamen/multi-form


JSONで送るなら、こんなめんどくさいこと考えなくていいのに。

SpringSecurity 権限に基づいて認可処理をする

Java SpringSecurity Spring Spring Security

この記事の内容

SpringSecurityを理解すればこんな感じの、権限に基づいた制御を簡単に実現できる。

f:id:kimulla:20161020212939p:plain

f:id:kimulla:20161020212122p:plain

概要

SpringSecurityのサンプルでは、ROLEに基づいて認可してることが多い。

 @Override
  protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.authorizeRequests()
      .antMatchers("/admin/**").hasRole("ADMIN")


以下ブログが詳しいが、ロールではなく権限に基づいて制御したいときがある。
つまり、ロールごとに複数の権限を持ち、ユーザにはロールを割り当てたい場合。
権限に基づいて制御しておくと、ロールにひもづく権限を画面から変更可能にもなる。

qiita.com


そのため、権限ごとに認可制御する方法を調べた。

権限に基づいて認可処理をする方法

SpringSecurityでは認証済みユーザを
UserDetailsインタフェースを実装したクラスに保持している。

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

これとは別に、Authenticationという認証情報を保持するクラスがある。
ProvoderManagerの認証後にUserDetailsの情報から認証済みのAuthenticationが作成される。(PrincipalにはUserDetailsが入る)

public interface Authentication extends Principal, Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();

    Object getCredentials();
    
    Object getDetails();
    
    Object getPrincipal();
    
    boolean isAuthenticated();
    
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}


ここらへんは前書いたブログを参照。
kimulla.hatenablog.com


このAuthenticationクラスの保持する情報をもとに、認可チェックをするためのEL式が提供されている。Spring Security Reference

  • hasRole([role])
  • hasAnyRole([role1,role2])
  • hasAuthority([authority])
  • hasAnyAuthority([authority1,authority2])

hasRoleやhasAuthorityが何をしているかというところだが、Authenticationからauthoritiesを取得して文字列変換して比較しているだけ。(authoritiesにはSimpleGrantedAuthorityが利用されることが多いが、これは本当になんもしない)
実装クラスはSecurityExpressionRoot。

hasRoleとhasAuthorityの違いは、権限の文字列に"ROLE_"プレフィックスをつけてくれるか、つけてくれないか、くらい。厳密にいうとhasRoleのプレフィックスはROLE以外も指定できるので、同じふるまいにもできる。

つまり、hasRoleとかいっておもいっきりロールを意識させられてるが、実際にはロールじゃなくていいしただのpermissionのチェックくらいの意味しかない。ひでえ。

でも権限はロールじゃないのにhasRoleを使うのもどうかと思うので、今回はhasAuthorityを使う。

ということで、UserDetailsを作る際にauthoritiesに文字列の権限情報を渡してhasAuthorityでチェックすればよい。

以下はInMemoryUserDetailsManagerを利用したサンプルだが、DBからUserDetailsを取得するときにROLEに紐づく権限を取得してauthoritiesに設定すればok

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.authorizeRequests()
      .antMatchers("/admin/**").hasAuthority("AUTH_SHOW_ADMIN_PAGE")
      .antMatchers("/operations/create-user").hasAuthority("AUTH_CREATE_USER")
      .antMatchers("/operations/show-all").hasAuthority("AUTH_SHOW_ALL")
      .anyRequest().authenticated()
    .and()
    .formLogin()
      .loginPage("/login")
      .defaultSuccessUrl("/home")
      .failureUrl("/login?error")
      .permitAll().and()
    .logout()
      .logoutUrl("/logout")
      .logoutSuccessUrl("/login?logout")
      .permitAll();
  }
  
  @Autowired
  public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .withUser("user").password("password")
      .authorities(new String[]{"AUTH_SHOW_ALL", "AUTH_CREATE_USER","AUTH_SHOW_USER_PAGE"})
    .and()
      .withUser("admin").password("password")
      .authorities(new String[]{"AUTH_SHOW_ADMIN_PAGE"});
  }
}

サンプル書いた

10/19 画面からロールにひもづく権限を更新できるように改良した。
https://github.com/kimullamen/springsecurity-with-boot-authority

f:id:kimulla:20161020212939p:plain

f:id:kimulla:20161020212122p:plain


SpringのAOP @Pointcutの使いどころ

Spring Java

SpringのAOP @Pointcutの使いどころ

SpringのAOPアノテーションで、@Pointcutだけ使い方がよくわからなかったのでメモ。

SpringのAOPってなに?

具体的な処理と関係のない処理(ロギングとか)が混じっていると、煩雑になって保守性が下がる。なので、具体的な処理と関係のない処理は別の場所でやりましょうね。という技術。

煩雑なコードの例

public class HogeClass {  
  private static final Logger logger = LoggerFactory.getLogger(HogeClass.class); 
  
  public void methodOne(String arg) {
    logger.info("methodOne start" + arg);
    //本当にやりたい処理が埋もれて見づらい
    ...
    logger.info("methodOne end" + arg);
  }
}


以下のようなクラスを作るとログ処理が挟み込める。(例だから適当)

@Aspect
public class LoggingAspects {

  @Around("execution(* methodOne(..))")
  public void logging(ProceedingJoinPoint pjp) throws Throwable{
    String methodName = pjp.getSignature().getName();
    Logger logger = LoggerFactory.getLogger(pjp.getTarget().getClass());
    logger.info(methodName + " start " + pjp.getArgs()[0]);
    pjp.proceed();
    logger.info(methodName  + " end " + pjp.getArgs()[0]);
  }
}
AOPのクラスかどうか @Aspect
どんなタイミングでメソッドを実行するか @Around ※1
どんな条件でメソッドを実行するか execution(* methodOne(..)) ※2

※1 この他に@Before,@After,@AfterThrowing,@AfterReturingがある
※2 executionの他にannotation(..)でannotationがついてるかどうかでチェックできたりする。詳細はリファレンス参照。

本題(@Pointcutの使い道)

@Around(...)などで指定する条件式を使いまわしたいときに使う。

package common;

@Aspect
public class MyPointcuts {
  @Pointcut("execution(* methodOne(..))")
  public void methodOne(){}
@Aspect
public class LoggingAspects {

  @Around("common.MyPointcuts.methodOne()")
  public void logging(ProceedingJoinPoint pjp) {
    ...
  }


これからもあんまり使わなさそう。