【Java版】CI(継続的インテグレーション)ツール導入ガイド:第1回 概要と導入

はじめに

CI(継続的インテグレーション)の導入ガイドとして Java 言語によるチュートリアルを全6回で解説する。
CI(継続的インテグレーション)とは、開発、テスト、リリースのサイクルが継続的に行われていく様子の事であり、それらを円滑に行うためのツールがCIツールである。 CIについては「CI – 継続的インテグレーションとは?
本記事では CI の概要と導入を紹介すると同時に Java プログラマ向けの CI ツールを紹介する。

1. 概要

非常に簡単な Spring Boot のウェブアプリケーションを用意し、CI を導入する。以下のタスクを自動化し、gitlab 上での CI の実行を目指す。

  • ビルド
  • コードスタイルチェック
  • ユニットテスト
    • カバレッジ取得
  • 静的バグ解析
  • E2E テスト

使用したツールは以下の通りである

ツール名 概要
JDK 10.0.2 Oracle JDK
PostgreSQL 9.6 Database
Gradle 4.10 ビルドツール
Checkstyle 8.12 コードスタイル解析
Spock 1.1 テストライブラリ
Jacoco newest Java カバレッジツール
Infer v0.15.0 静的解析ツール

2. 導入

はじめに、対象となるアプリケーションである、一行掲示板を用意する。

仕様

  • メッセージを一行投稿できる。
  • 投稿したメッセージは新着順で一覧に表示される。
  • メッセージの先頭に #red, #green, #blue のコマンドを一つつけられる。
    • 投稿メッセージが該当の色で表示される。
    • コマンドとそれに続く空白はメッセージから omit される。
  • ブランク、コマンドのみの投稿は受け付けない。

実装

適当なディレクトリで mkdir sample を実行し、プロジェクトのディレクトリとする。

初期のファイル構成は以下の通り。

.
├── build.gradle
├── common.gradle
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── messageboard
        │               ├── Application.java
        │               ├── Message.java
        │               ├── MessageBoardController.java
        │               ├── MessageForm.java
        │               ├── MessageMappingService.java
        │               └── MessagesRepository.java
        └── resources
            ├── application.properties
            ├── hibernate.properties
            └── templates
                └── index.html

以下に用意した各ファイルを列挙する。 いくらか間違いや冗長な部分がある。

src/main/java/com/example/messageboard/Application.java(メインメソッド)

package com.example.messageboard;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
 

/**
 * アプリケーションの起動
 */
@SpringBootApplication
public class Application {
 
  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

src/main/java/com/example/messageboard/Message.java

package com.example.messageboard;
 
import java.time.ZonedDateTime;
import java.util.UUID;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
 

/**
 * 一行掲示板メッセージテーブルエンティティ.
 *
 */
@Entity
@Table(name = "messages")
public class Message {
 
  @Id
  @Column
  @GeneratedValue(strategy = GenerationType.AUTO)
  private UUID id;
 
  @Column(nullable = false)
  private String message;
 
  @Column(nullable = true)
  private String command;
 
  @Column(nullable = false)
  private ZonedDateTime createdAt;
 
  /**
   * 主キーを設定する.
   *
   * @param id レコードに設定する主キー.
   */
  public void setId(final UUID id) {
    this.id = id;
  }
 
  /**
   * 主キーを取得する.
   *
   * @return レコードの主キー.
   */
  public UUID getId() {
    return this.id;
  }
 
  /**
   * メッセージ本文を設定する.
   *
   * <p>メッセージ本文からはすでに有効なコマンドが取り除かれているべきである.
   *
   * @param message レコードに設定するメッセージ本文.
   */
  public void setMessage(final String message) {
    this.message = message;
  }
 
  /**
   * メッセージ本文を取得する.
   *
   * @return レコードのメッセージ本文.
   */
  public String getMessage() {
    return this.message;
  }
 
  /**
   * コマンドを設定する.
   *
   * {@code null}可能.
   * 許容する文字列: {@code "#red"}, {@code "#green"}, {@code "#blue"}.
   *
   * @param command レコードに設定するコマンド.
   */
  public void setCommand(final String command) {
    this.command = command;
  }
 
  /**
   * コマンドを取得する.
   *
   * @return レコードのコマンド.
   */
  public String getCommand() {
    return this.command;
  }
 
  /**
   * メッセージ作成日時を設定する.
   *
   * @param createdAt レコードに設定するメッセージ作成日時.
   */
  public void setCreatedAt(final ZonedDateTime createdAt) {
    this.createdAt = createdAt;
  }
 
  /**
   * メッセージ作成日時を取得する.
   *
   * @return レコードのメッセージ作成日時.
   */
  public ZonedDateTime getCreatedAt() {
    return this.createdAt;
  }
}

src/main/java/com/example/messageboard/MessageForm.java

package com.example.messageboard;


/**
 * メッセージ投稿フォームのパラメタ用POJO.
 */
public class MessageForm {

  private String message;

  /**
   * 投稿メッセージ本文を設定する.
   *
   * <p>コマンドを含んだ送信されたメッセージである.
   *
   * @param message 設定する投稿メッセージ本文.
   */
  public void setMessage(final String message) {
    this.message = message;
  }

  /**
   * 投稿メッセージ本文を取得する.
   *
   * @return 投稿メッセージ本文.
   */
  public String getMessage() {
    return this.message;
  }
}

src/main/java/com/example/messageboard/MessagesRepository.java

package com.example.messageboard;
 
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
 
 
/**
 * messages テーブル用リポジトリ.
 */
@Repository
public interface MessagesRepository extends JpaRepository<Message, UUID> {
 
  /**
   * message テーブルから作成日付で降順ソートしたレコード全一覧を取得する.
   */
  @Query("SELECT m FROM Message m ORDER BY m.createdAt DESC")
  Iterable<Message> findAllOrderByCreatedAtDesc();
}

src/main/java/com/example/messageboard/MessageMappingService.java


ckage com.example.messageboard;
 
import java.time.ZonedDateTime;
import java.util.Optional;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
 
 
/**
 * メッセージフォームオブジェクトをDB用のエンティティに変換するサービス.
 */
@Service
public class MessageMappingService {

  protected final class ParseResult {
    private final String message;
    private final String command;

    public ParseResult(
        final String message, final String command) {
      this.message = message;
      this.command = command;
    }

    public String getMessage() {
      return this.message;
    }

    public String getCommand() {
      return this.command;
    }
  }
 
  // コマンド解析用正規表現ソース.
  private static final String commandPatternSource =
      "^(?:(#(?:red|green|brue))\\s*)?(.*)$";
 
  // コンパイル済み正規表現パターン.
  private final Pattern commandPattern;
 
  /**
   * 文字列からコマンドを分割する.
   *
   * @param sourceMessage 処理前のメッセージ本文.
   * @return コマンドが除かれたメッセージ本文とコマンド文字列のペア.
   */
  protected ParseResult parseMessage(final String sourceMessage) {
    final var match = this.commandPattern.matcher(sourceMessage);
    if (!match.matches()) {
      throw new RuntimeException("Invalid match result.");
    }
    return new ParseResult(match.group(2), match.group(1));
  }
 
  /**
   * DI 用デフォルトコンストラクタ.
   *
   * <p>正規表現パターンを予めコンパイルする.
   */
  public MessageMappingService() {
    this.commandPattern = Pattern.compile(
        commandPatternSource, Pattern.MULTILINE);
  }
 
  /**
   * 変換を実行する.
   *
   * @param messageForm 投稿されたメッセージフォームオブジェクト.
   * @param postedDateTime 投稿日時.
   * @return 変換された messages テーブルエンティティ.
   */
  public Message map(
      final MessageForm messageForm,
      final ZonedDateTime postedDateTime) {
    var message = new Message();
    final var originalMessage = messageForm.getMessage();
    final var parsedMessage = parseMessage(originalMessage);
 
    message.setMessage(parsedMessage.getMessage());
    message.setCommand(parsedMessage.getCommand());
    message.setCreatedAt(postedDateTime);
 
    return message;
  }
}

src/main/java/com/example/messageboard/MessageBoardController.java

package com.example.messageboard;
 
import java.time.ZonedDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
 
 
/**
 * 一行掲示板コントローラ.
 */
@RestController
public class MessageBoardController {
 
  @Autowired
  protected final MessagesRepository messagesRepository;
                  
  @Autowired
  protected final MessageMappingService messageMappingService;
 
  /**
   * コンストラクタインジェクションを行うように設定する.
   */
  public MessageBoardController(
      final MessagesRepository messagesRepository,
      final MessageMappingService messageMappingService) {
    this.messagesRepository = messagesRepository;
    this.messageMappingService = messageMappingService;
  }
 
  /**
   * 投稿/一覧画面の処理を行う.
   *
   * <p>パス: /
   * HTTP メソッド: POST
   * テンプレート: src/main/resources/index.html
   *
   * @param messageForm メッセージフォームオブジェクト.
   *                    空のオブジェクトが渡される.
   * @param mav MVオブジェクト.
   * @return MVオブジェクト.
   */
  @RequestMapping(value = "/", method = RequestMethod.GET)
  public ModelAndView index(
      @ModelAttribute("messageForm") final MessageForm messageForm,
      final ModelAndView mav) {
    mav.setViewName("index");
    mav.addObject(
        "messages", this.messagesRepository.findAllOrderByCreatedAtDesc());
    return mav;
  }
 
  /**
   * メッセージの投稿処理を行う.
   *
   * <p>パス: /
   * HTTP メソッド: POST
   * 成功時リダイレクト: /
   *
   * @param messageForm メッセージフォームオブジェクト.
   *                    POST されたデータが渡される.
   * @param mav MVオブジェクト.
   * @return MVオブジェクト.
   */
  @RequestMapping(value = "/", method = RequestMethod.POST)
  @Transactional(readOnly = false)
  public ModelAndView create(
      @ModelAttribute("messageForm") final MessageForm messageForm,
      final ModelAndView mav) {
    var message = this.messageMappingService.map(messageForm, ZonedDateTime.now());
    this.messagesRepository.saveAndFlush(message);
    return new ModelAndView("redirect:/");
  }
}

src/main/resources/templates/index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>一行掲示板</title>
    <style>
      * {
        box-sizing: border-box;
      }
      html {
        font-size: 14px;
      }
      article {
        margin: 0 auto;
        width: 100%;
        max-width: 700px;
      }
      .message-form form input, form button {
        padding: 0.375rem 0.75rem;
      }
      .message-form form input {
        width: 80%;
      }
      .message-form .errors {
        color: red;
        font-weight: bold;
      }
      .messages-list ul {
        padding: 0;
        list-style-type: none;
      }
      .messages-list ul li {
        padding: 0.75rem;
        border-top: 1px solid #434857;
      }
      .messages-list ul li:last-child {
        border-bottom: 1px solid #434857;
      }
      .messages-list ul li .command-red {
        color: #c93a40;
      }
      .messages-list ul li .command-green {
        color: #56a764;
      }
      .messages-list ul li .command-blue {
        color: #0074bf;
      }
    </style>
  </head>
  <body>
    <article>
      <h1>一行掲示板</h1>
      <section class="message-form">
        <form action="/" method="POST" th:object="${messageForm}">
          <input type="text" name="message" placeholder="メッセージを入力" th:value="*{message}">
          <button type="submit">投稿</button>
        </form>
      </section>
      <section class="messages-list">
        <ul>
          <li th:each="message: ${messages}" th:object="${message}">
            <th:block th:switch="*{command}">
              <span th:case="'#red'" class="command-red" th:text="${message.message}" />
              <span th:case="'#green'" class="command-green" th:text="${message.message}" />
              <span th:case="'#blue'" class="command-blue" th:text="${message.message}" />
              <span th:case="*" th:text="${message.message}" />
            </th:block>
          </li>
        </ul>
    </article>
  </body>
</html>

src/main/resources/application.properties

# データベース接続設定
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/messageboard
spring.datasource.username=messageboard
spring.datasource.password=password

src/main/resources/hibernate.properties

# Pgsql Driver 例外発生の抑制
hibernate.jdbc.lob.non_contextual_creation=true

build.gradle(ビルド設定ファイル)

buildscript {
    repositories {
        mavenCentral()
    }
}

// gradle プラグイン
plugins {
    id "java"
    id "eclipse"
    id "idea"
    id "org.springframework.boot" version "2.0.4.RELEASE"
    id "io.spring.dependency-management" version "1.0.6.RELEASE"
}

// 共通設定の読み込み
apply from: './common.gradle'

bootJar {
    // アプリケーションの名前(設定から)
    baseName = project.appBaseName

    // アプリケーションのバージョン(設定から)
    version = appVersion
}

repositories {
    mavenCentral()
}

// Java のバージョン指定
sourceCompatibility = 1.10
targetCompatibility = 1.10

// 依存パッケージ
dependencies {
    compile "javax.xml.bind:jaxb-api"
    compile "org.springframework.boot:spring-boot-starter-web"
    compile "org.springframework.boot:spring-boot-starter-data-jpa"
    compile "org.springframework.boot:spring-boot-starter-thymeleaf"
    runtime "org.postgresql:postgresql"
}

common.gradle(ビルド共通設定)

ext {
  // アプリケーションの名称
  appBaseName = "message-board"

  // アプリケーションのバージョン
  appVersion = "0.1.0"
}

sql/scheme.sql(スキーマ定義)

-- この行はスーパユーザ権限が必要
CREATE EXTENSION pgcrypto;

CREATE TABLE messages (
  id UUID NOT NULL DEFAULT gen_random_uuid(),
  message TEXT NOT NULL,
  command TEXT,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
  PRIMARY KEY (id)
);

COMMENT ON TABLE messages IS '一行掲示板メッセージテーブル';
COMMENT ON COLUMN messages.id IS '主キー INSERT 時に自動的に生成される';
COMMENT ON COLUMN messages.message IS 'メッセージ本文';
COMMENT ON COLUMN messages.command IS 'コマンド 値は NULL, ''#red'', ''#green'', ''#blue'' のいずれか';
COMMENT ON COLUMN messages.created_at IS 'メッセージ作成日時';

実行

  • postgresql 上に、messageboard という名前のロールを password というパスワードで作成する。
  • messageboard というデータベースを messageboard ロールをオーナにして作成する。
  • (pgの)スーパユーザ,messageboard データベースでCREATE EXTENSION pgcrypto; を実行する。
  • messageboard ロール, messageboard データベースで sql/scheme.sql を実行する。
  • gradle wrapper を実行する。
  • ./gradlew bootRun を実行する。
  • ブラウザで http://localhost:8080/ に接続する。

git リポジトリ

.gitignore

.gradle
/build/
/e2e/build/
/tmp/
/infer-out/
gradle-app.setting
!gradle-wrapper.jar
.gradletasknamecache
Thumbs.db
.DS_Store
git init
git add .
git commit .

次回

CIツール導入ガイド 第2回 Spock によるユニットテストとjacocoによるカバレッジ計測【Java版】


社内サーバにリモートリポジトリを作るのも一つですが、「開発にまつわる面倒事」をこの際全部、tracpath(トラックパス)に任せてみませんか?
バージョン管理サービス・プロジェクト管理サービスの「tracpath(トラックパス)」では、
ユーザー5名、リポジトリ数3つまで、無料で利用可能です。

さっそく実務でも使って見ましょう。
自らも開発を行う会社が作ったからこそ、開発チームの「作る情熱」を支える、やるべきことに集中出来るサービスになっています。
エンタープライズ利用が前提のASPサービスなので、セキュリティも強固です。