Spring Boot がとても楽しい

Spring Boot を勉強し始めて、いろいろ楽しいので、いったんここにまとめてみます。

ここで書いている内容は、下記のリポジトリで試している内容になります。(今後もいろいろ試すので、リポジトリの内容はどんどん変わっていくかもしれません…)

またSpring projectでも、たくさんのサンプルが公開されていてとても参考になります。

Spring Bootを使うと、さまざまなコンポーネントを組み合わせて、よい感じのアプリケーション構成に仕上げてくれます。ちょっとうまく言い表せないのですが、、いろいろなコンポーネントを組み合わせて、結果的にフルスタックのフレームワークのような機能を提供してくれている感じです。組み合わせ自体はSpring Bootが解決してくれるので、そこで迷うことはありません。

サンプルとしてRESTなアプリケーションを書いてみましたが、APIを簡単に作れるのは当然で、それに対する試験だったり、DBのマイグレーションなどといった仕組みも簡単に試すことが出来ました。

APIの作成

@RestControllerアノテーションを付与したクラスが、RESTのAPIとして公開されます。

Jersey でもそうですが、アノテーションだけで済むので、とっても簡単です。

package com.example.api;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.example.domain.Customer;
import com.example.service.CustomerService;

@RestController
@RequestMapping("api/customers")
public class CustomerRestController {

    @Autowired
    private CustomerService customerService;

    @GetMapping
    public List<Customer> getCustomers() {
        return customerService.findAll();
    }

    @GetMapping(path = "{id}")
    public Customer getCustomer(@PathVariable Integer id) {
        return customerService.findOne(id);
    }

    @GetMapping(path = "/search")
    public List<Customer> getCustomersByName(@RequestParam String name) {
        return customerService.findByName(name);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Customer craeteCustomer(@RequestBody Customer customer) {
        return customerService.create(customer);
    }

    @PostMapping(path = "{id}")
    public Customer upateCustomer(@PathVariable Integer id, @RequestBody Customer customer) {
        customer.setId(id);
        return customerService.update(customer);
    }

    @DeleteMapping(path = "{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteCustomer(@PathVariable Integer id) {
        customerService.delete(id);
    }
}

データアクセス部分

簡単なCRUDならば、JPAを使うのがよいかと思います。Spring DATA JPAは、JPAをとても使いやすくしてくれています。 基本的にはインタフェースを定義するだけです。EntityManagerを触ることはありません。

JpaRepositoryをextendsするだけで、CRUDのメソッドを提供してくれます。

package com.example.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.example.domain.Customer;

@Repository
public interface CustomerRepository extends JpaRepository<Customer, Integer> {
}

他にもメソッド名にあわせて自動的にSQLを作成してくれたり、アノテーションでクエリを指定できたりと便利です。

また、単純なデータアクセスのAPIならば、@RestControler使わずに、@Repositoryを直接APIとして公開することができます。

複雑なクエリになる場合は、JPAだと使いこなせる自信が無いので、MyBatisを使おうと考えています。MyBatisもSpring Bootと連携できます。

DBのマイグレーション

DBのマイグレーション方法としてFlywayを使った方法が提供されています。

アプリケーションの起動時に、Flywayが実行されるイメージです。クラスパス上のdb/migration配下 に配置したSQLが実行されます。

APIのドキュメント生成

SpringFoxを使うと、SpringのアノテーションからSwagger JSONを生成してくれます。

SpringFoxを依存関係に追加して、下記のようなクラスを定義しておくだけです。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.google.common.base.Predicate;
import com.google.common.base.Predicates;

import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket document() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .paths(paths())
                .build()
                .apiInfo(apiInfo());
    }

    @SuppressWarnings("unchecked")
    private Predicate<String> paths() {
        return Predicates.or(Predicates.containsPattern("/api/*"));
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Example API")
                .version("1.0")
                .build();
    }
}

さらにSwagger UIと組み合わせて、APIのドキュメントがアプリケーション上で確認できます。

f:id:onozaty:20161210005706p:plain

Swagger UIの良いところは、そこから直接APIを叩いて確認ができるところです。いちいちcurlコマンドで確認するなどといった必要が無くなります。

テスト

テスト方法もいろいろ提供されています。

RESTのAPIに対する結合テストは、下記のような感じです。TestRestTemplateクラスが、RESTのテストを行うのに便利です。

package com.example;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Arrays;
import java.util.List;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import com.example.domain.Customer;
import com.example.repository.CustomerRepository;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = {
        "spring.datasource.url=jdbc:log4jdbc:h2:mem:test;DB_CLOSE_ON_EXIT=FALSE" })
public class SpringBootRestJpaApplicationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private CustomerRepository customerRepository;

    private Customer customer1;
    private Customer customer2;
    private Customer customer3;

    @Before
    public void setup() {

        customerRepository.deleteAll();

        customer1 = new Customer();
        customer1.setName("Taro");
        customer1.setAddress("Tokyo");

        customer2 = new Customer();
        customer2.setName("花子");
        customer2.setAddress("千葉");

        customer3 = new Customer();
        customer3.setName("Taku");

        customerRepository.save(Arrays.asList(customer1, customer2, customer3));
    }

    @Test
    public void getCustomers() {

        ResponseEntity<List<Customer>> response = restTemplate.exchange(
                "/api/customers", HttpMethod.GET, null, new ParameterizedTypeReference<List<Customer>>() {
                });

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).containsOnly(customer1, customer2, customer3);
    }

    @Test
    public void getCustomer() {

        ResponseEntity<Customer> response = restTemplate.getForEntity(
                "/api/customers/{id}", Customer.class, customer1.getId());

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(customer1);
    }

    @Test
    public void getCustomersByName() {

        ResponseEntity<List<Customer>> response = restTemplate.exchange(
                "/api/customers/search?name=a", HttpMethod.GET, null, new ParameterizedTypeReference<List<Customer>>() {
                });

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).containsOnly(customer1, customer3);
    }

    @Test
    public void createCustomer() {

        Customer newCustomer = new Customer();
        newCustomer.setName("Tayler");
        newCustomer.setAddress("New York");

        ResponseEntity<Customer> response = restTemplate.postForEntity(
                "/api/customers", newCustomer, Customer.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        Customer result = response.getBody();
        assertThat(result.getId()).isEqualTo(customer3.getId() + 1); // 最後に追加したもの+1
        assertThat(result.getName()).isEqualTo("Tayler");
        assertThat(result.getAddress()).isEqualTo("New York");
    }

    @Test
    public void upateCustomer() {

        customer1.setName("New Name");

        ResponseEntity<Customer> response = restTemplate.postForEntity(
                "/api/customers/{id}", customer1, Customer.class, customer1.getId());

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(customer1);
    }

    @Test
    public void deleteCustomer() {

        {
            ResponseEntity<Void> response = restTemplate.exchange(
                    "/api/customers/{id}", HttpMethod.DELETE, null, Void.class, customer1.getId());

            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
        }

        {
            ResponseEntity<Customer> response = restTemplate.getForEntity(
                    "/api/customers/{id}", Customer.class, customer1.getId());

            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(response.getBody()).isNull();
        }
    }
}

テストのときだけDBの接続先を切り替えたり、空いているポートを探してサーバ立ち上げたりと、テストに必要な機能はいろいろ揃っています。

また、そもそもDIの仕組み自体が、テストがしやすい(オブジェクトをモックに差し替えたりとかが楽)です。

最後に

ざっくりとした説明になってしまいましたが、、ちょっとでも楽しさが伝わればと思います。

Spring Bootの勉強には、下記の本がとても役に立ちました。この本を最初に読んだおかげで、イメージがだいぶ掴めました。

また、サンプルコードもたくさんWeb上にあるので、今のところ嵌るといったこともなく勉強できています。

いろいろなコンポーネントがあるので、これからがさらに楽しみです。

Eclipseで「Class 'springfox.documentation.swagger.web.ClassOrApiAnnotationResourceGrouping' is marked deprecated」って警告が出る

下記の構成で Spring boot + Swagger のサンプルを作ってみたところ、

  • Eclipse 4.6.0
  • Spring Tool Suite(STS) 3.8.2
  • SpringFox 2.6.1

下記のような警告が出ました。

Class 'springfox.documentation.swagger.web.ClassOrApiAnnotationResourceGrouping' is marked deprecated

警告がひとつでもあると嫌(出ていることを許容してしまうと、他の重要な警告を見逃しかねない)なので、下記にしたがって、Eclipseの設定(SpringのValidationでBeans Validatorところ)を変えて警告が出ないようにしました。

そもそも出てしまっている理由がいまいち理解できていない(どういった形でこのクラスが参照されているのか)のですが、いったんこれで様子みることにします。

Redmine: チケットの説明欄を非表示にする(View customize plugin)

GoogleグループのRedmine Users (japanese)で、チケットの説明欄を非表示にするにはといった質問があったので、View customize pluginで対応してみました。

非表示にするだけならば、CSSで設定できる場合も多いですが、今回対象となる要素を指定するためには、xxを持った親要素といった指定が必要で、現状のCSSだとできない(CSS4でhasがあって、それが入って各ブラウザに実装されれば…)ので、JavaScriptで実施してみました。

View customize の設定内容

Path pattern

チケット画面を対象とします。

/issues

Code

Type:JavaScriptとして下記を設定します。

$(function() {
  $('#issue_description_and_toolbar').parent().hide();
  $('div.issue div.description').hide()
    .next('hr').hide();
});

設定後のイメージ

説明欄を消すことができました。

f:id:onozaty:20161120003509p:plain:w700 f:id:onozaty:20161120003512p:plain:w650

Redmine: 一部のカスタムフィールドを説明の下に移動する(View customize plugin)

といった質問をいただいたので、View customizeで試してみました。

該当の要素を取得して、それを説明欄の下に移動するイメージです。

なお、カスタムフィールドの表示は、divがネストして1行に2項目を表示しているような形になっているので、それを再現して配置します。

View customize の設定内容

Path pattern

チケット画面を対象とします。

/issues

Code

Type:JavaScriptとして下記を設定します。 cf_2のところは、移動したいカスタムフィールドに変更してください。

$(function() {
  // 対象のカスタムフィールドの要素を取得
  var customField = $('.cf_2.attribute');

  // 説明の後に移動
  $('.description')
    .after(
      $('<div class="splitcontent">')
        .append(
          $('<div class="splitcontentleft">').append(customField)));
})

設定後のイメージ

リスト2となっているカスタムフィールドが移動しました。

変更前

f:id:onozaty:20161116004019p:plain:w600

変更後

f:id:onozaty:20161116004023p:plain:w600

なお、今回は一番末尾の項目を移動したので、表示的に違和感ない形となっていますが、途中の項目を移動すると、そこだけ歯抜けになるので、きれいに見せたい場合には、詰めるような処理も必要になってきます…

Tomcat8をインストールするAnsibleのRoleを書きました

Tomcat8をCentOS7にインストールするためのRoleを書きました。

最初はAnsible Galaxyにあがっているものを試してみようと思いましたが、やっていることが理解できない部分も多かったので、勉強もかねて自分で書いてみました。

Tomcat8をインストールして、サービスとして登録するところまでの、シンプルなものになっています。

すぐに確認できるようVagrantfileを置いています。 cloneしてvagrant upとすると、bento/centos-7.2のboxを立ち上げて、Ansibleを実行してTomcatのインストールを確認できます。

Tomcat8で、デフォルトだとローカル以外からmanagerが表示できない

Tomcat8をインストールして、managerを見ようとしたところ、403 Access Denied となったので、画面に表示されていた通りtomcat-user.xmlmanager-guiというロールでユーザを追加します。Tomcat6の時は、managerだったのが、Tomcat7以降で変わったようですね。

<role rolename="manager-gui"/>
<user username="tomcat" password="s3cret" roles="manager-gui"/>

これだけだと、ローカル以外からアクセスできませんでした。

よくよく調べたところ、デフォルトだとローカル以外からアクセスできないように制限されているようです。(ちゃんとエラーページにも書いてありました)

By default the Manager is only accessible from a browser running on the same machine as Tomcat. If you wish to modify this restriction, you'll need to edit the Manager's context.xml file.

webapps/manager/META-INF/context.xmlには、下記のようにアクセスを制限するような記載がありました。

<Context antiResourceLocking="false" privileged="true" >
  <Valve className="org.apache.catalina.valves.RemoteAddrValve"
         allow="127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1" />
</Context>

allowのところに正規表現で許可するアドレスを書いてあげると、問題なくアクセスできるようになりました。

Redmine: コンテキストメニューからステータスを変えた際に、対象バージョンも変更する (View customize plugin)

Issueとして、コンテキストメニューからステータスを却下(6)に変えたときに、対象バージョンを未設定としたいんだけど、どうやれば、、といったものが上がっていたので、スクリプトを書いてみました。

やり方としては、コンテキストメニューが生成されたタイミングで、ステータスを却下に変えるリンクのパラメータに、対象バージョンも追加します。

設定内容

Path pattern

チケット一覧を対象にします。

/issues

Code

Type:JavaScriptとして下記を設定します。

$(function() {

  // コンテキストメニューを表示したタイミングでフックするために
  // jQueryのshow関数を差し替え
  jQuery.fn._show = jQuery.fn.show;

  jQuery.fn.show = function() {
    if (this.attr('id') == 'context-menu') {
      // ステータスを6:却下に変える場合に
      // 対象バージョンを未設定に
      var a = $('#context-menu a[href*="status_id%5D=6"]');
      a.attr('href',a.attr('href') + '&issue%5Bfixed_version_id%5D=none');
    }

    return jQuery.fn._show.apply(this, arguments);
  };
});