spring boot ・ doma を使用した際の eclipse (gradle) の環境設定

spring boot で作成した プログラムで、データベースにアクセスする際の O/Rマッパーに doma を選択した際に、依存管理ツールに doma-spring-boot-stater を指定します。

いざ、eclipse で開発しようとした際、doma-n.n.n.jar を使用した環境設定は、公式(※1)を含めて多数存在します。しかし、doma-spring-boot-stater に関しての詳細な設定方法等が見つからなかったので、調査した内容をここに記載します。

■使用環境

windows 10

eclipse 4.15.0
gradle 6.3

doma-spring-boot-starter 1.4.0
doma-processor 2.35.0

■ファクトリーパスの設定

公式ページの説明では、Maven Central Repository より doma を入手し、ファクトリーパスに doma-n.n.n.jar を設定するように記載されています。(※1)

doma-spring-boot-stater の場合、gradle に記載する依存の設定は以下の通りになります。
※必要な箇所のみ抜粋

dependencies {
  implementation "org.seasar.doma.boot:doma-spring-boot-starter:1.4.0"
  annotationProcessor "org.seasar.doma:doma-processor:2.35.0"
}

上記を反映すると、doma-n.n.n.jar が取り込まれません。
代わりに、doma-core-2.35.0.jar が取り込まれます。

gradle の dependencies に設定した doma-processor と共に、doma-core-2.35.0.jar をファクトリーパスに設定する必要があります。
この時、下記画像の通りの順番も関係ありますので、ご注意ください。

※例)[外部jarの追加]より.gradle 配下のファイルを直接指定

■DOMA4019 エラー対応

gradle をデフォルトで使用した際 eclipse 上で[ DOMA4019 ]エラーが発生し、ビルドが失敗します。

javaのビルドパスで、「ソース・フォルダーごとに出力フォルダーの指定を可能にする」にチェックが入っている為です。
こちらのチェックを外すと、エラーが解消されます。

以上、eclipse での躓き箇所と 解決方法でした。

最後に、上記を含めた gradle の設定を公開します。

■gradle の設定

//  doma の gradle設定について、下記を参照してください。
//      https://doma.readthedocs.io/en/2.20.0/build/(日本語訳ページ)

// テンポラリディレクトリのパスを定義する
ext.domaResourcesDir = "${buildDir}/tmp/doma-resources"

  // ---- debug用 -------------------------------------
  //  println "processResources.destinationDir is ${processResources.destinationDir}"
  //  println "ext.domaResourcesDir            is ${ext.domaResourcesDir}"
  //  println "compileJava.destinationDir      is ${compileJava.destinationDir}"

// domaが注釈処理で参照するリソースをテンポラリディレクトリに抽出
task extractDomaResources(type: Copy, dependsOn: processResources)  {
  from processResources.destinationDir
  include 'doma.compile.config'
  include 'META-INF/**/*.sql'
  include 'META-INF/**/*.script'
  into domaResourcesDir
}

// テンポラリディレクトリ内のリソースをcompileJavaタスクの出力先ディレクトリにコピーする
task copyDomaResources(type: Copy, dependsOn: extractDomaResources)  {
  from domaResourcesDir
  into compileJava.destinationDir
}

compileJava {
  // 上述のタスクに依存させる
  dependsOn copyDomaResources
  // テンポラリディレクトリをcompileJavaタスクの入力ディレクトリに設定する
  inputs.dir domaResourcesDir
  options.encoding = 'UTF-8'
}

repositories {
  mavenCentral()
  mavenLocal()
  maven {url 'https://oss.sonatype.org/content/repositories/snapshots/'}
}

dependencies {
  implementation "org.seasar.doma.boot:doma-spring-boot-starter:1.4.0"
  annotationProcessor "org.seasar.doma:doma-processor:2.35.0"
}

eclipse {
  classpath {
    // [DOMA4019] 対応
    //   ソース毎の出力先指定を解除
    file.whenMerged {
      entries.each { entry ->
        if (entry.kind == 'src' && entry.hasProperty('output')) {
          entry.output = null
        }
      }
    }
  }
}

eclipse.jdt.file {
    // [Javaコンパイラー]->[注釈処理] の設定
    def  eclipseAptPrefsFile = '.settings/org.eclipse.jdt.apt.core.prefs'
    file(eclipseAptPrefsFile).write """\
      |eclipse.preferences.version=1
      |org.eclipse.jdt.apt.aptEnabled=true
      |org.eclipse.jdt.apt.genSrcDir=.apt_generated
      |org.eclipse.jdt.apt.genTestSrcDir=.apt_generated_tests
      |org.eclipse.jdt.apt.reconcileEnabled=true
      |""".stripMargin()


    // [Javaコンパイラー]->[注釈処理]->[ファクトリーパス] の設定
    def f = file(".factorypath")
    def w = new FileWriter(f)
    def jar = ""

    def xml = new groovy.xml.MarkupBuilder(w)
    xml.setDoubleQuotes(true)
    xml."factorypath"() {
      // doma-core.jar の読込み
      jar = configurations.annotationProcessor.find { File file -> file.name.matches('doma-core[^//]*') }
      'factorypathentry'(kind: 'EXTJAR', id: jar, enabled: true, runInBatchMode: false)

      // doma-processorjar の読込み
      jar = configurations.annotationProcessor.find { File file -> file.name.matches('doma-processor[^//]*') }
      'factorypathentry'(kind: 'EXTJAR', id: jar, enabled: true, runInBatchMode: false)
    }
    w.close()
}

上記を設定し、gradle eclipse もしくは、eclipse 上で [Gradle] -> [Gradle プロジェクトのリフレッシュ]を実行すると、本記事のファクトリーパスの設定・DOMA4019 を自動対応します。

■参考情報
※1:参考にした、doma 公式の設定ページ。
   https://doma.readthedocs.io/en/2.20.0/build/


ReactNative&ExpoによるPush通知(iOS)の実装

こんにちは。
最近ReactNative&ExpoでPush通知を実装する機会があったので、その内容を記事にさせていただきました。
私個人として、Expoを利用したPush通知機能の開発は

●難易度
  仕組みを理解することで実装自体は比較的簡単
  証明書などの手続きが基本的に不要。

●開発することによる効果
  簡易に通知を実装し、アプリの価値を高めることができる。

という点から、Expoでのアプリ開発をする上では非常にオススメの機能です。

その反面、ExpoでのPush通知の実装においては、

・日本語の記事はいくつかあるものの、サンプルが動かなかった。
・Push通知の仕組みも押さえておきたかったが、実装のコードのみの記事が多かった。
・テスト時の情報があまりなく、push通知のテスト部分で少し苦労した。


そういった経験から今回の記事を作成させていただきました。
私自身、そもそもPush通知の仕組みとは?という知識でしたので、Push通知の仕組みも含めて解説させていただきます。
また、今回はiOSでのテストのみ対象としています。
※基本はandroidでも同様ですが、androidの場合は追加で少し設定が必要です。

対象読者

①Expoを利用してより簡易的にPush通知を実装・テストしたいという方。
②そもそもPush通知とは?という方。

目次

1.環境
2.そもそもPush通知とは
3.Expoの提供するPush通知とは
4.実装①ライブラリインストール
5.実装②Push通知許可と通知用トークンの取得
6.実装③通知を開く・フォアグラウンドでの通知受信時の動作
7.ポイント Expoへのログイン
8.テスト①Expo Goのインストール
9.テスト②Push通知を送る
10.まとめ

1.環境

本記事では下記環境が構築されていることを前提としています。
・Expo(40.0.0)
・その他(上記Expoのバージョンに依存)
・テスト用iPhone (今回はiPhoneSE iOS 14.4を使用)
※本記事はReactNative&Expoの環境構築は省いています。

2.そもそもiOSのPush通知とは

上記の図のように

①端末側でユーザからPush通知の許可を取得。
②APNsからPush通知先の端末を特定するトークンを発行。
③②で発行されたトークン情報をサーバに登録。
④③で登録したトークン情報をキーに通知したい情報をApple社のPush通知用サーバに送信。
⑤Apple社のPush通知用サーバから各ユーザの端末にPush通知を送信。

上記のような仕組みとなり、トークンの発行や通知の送信などはApple社のPush通知用サーバを介する必要があります。
※通常はFirebaseなどのツールを利用することが多いかと思います。
また、iOSのPush通知を利用する場合には証明書の発行などの手続きを行う必要があります。

3.Expo(expo-notifications)を利用したPush通知の仕組み

上記の図のように

①端末側でユーザからPush通知の許可を取得
②Expoを介してApple社のPush通知用サーバから端末を特定するトークンを発行。
③②で発行されたトークン情報をサーバに登録
④③で登録したトークン情報をキーに通知したい情報をExpoのサーバを介してApple社のPush通知用サーバに送信。
⑤Apple社のPush通知用サーバから各ユーザの端末にPush通知を送信

基本的にはやはりApple社のサーバを介す必要があるのですが、そのあたりのやり取り(トークンの取得や通知情報の送信)をExpoが代行して行ってくれるため、
「とりあえずPush通知を実装したい」という場合は非常に簡易的にPush通知を実装することができます。
また、何より証明書発行などの手続きが必要ないということがとても大きなメリットかと思います。

4.実装①ライブラリインストール

今回必要となるライブラリは以下の通りです。

expo-notifications
Push通知を実装するためのコアとなるライブラリ

expo install expo-notifications

expo-constants
Push通知は実機でのみ確認可能となるため、実機かどうかの判定を入れています。

expo install expo-constants

5.実装②Push通知許可と通知用トークンの取得

registerForPushNotificationsAsync
①このアプリからのPush通知の許可を取得
②初回起動時は許可ダイアログを出してユーザからPush通知の許可を取得
③通知用トークンの取得
※今回はサーバなどには送信せず、画面にトークンを表示します。

App.js

import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import React, { useState, useEffect, useRef } from 'react';
import { Text, View, Button } from 'react-native';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

export default function App() {
  const [expoPushToken,setExpoPushToken] = useState(null);

  return (
    <View
      style={{
        flex: 1,
        alignItems: 'center',
        justifyContent: 'space-around',
      }}>
      <Text>push通知のトークン: {expoPushToken}</Text>
      <Button
        title="push通知用のトークンを取得"
        onPress={async () => {
          const pushToken = await registerForPushNotificationsAsync()
          setExpoPushToken(pushToken);
        }}
      />
  </View>
  );
}


async function registerForPushNotificationsAsync() {
  let token;
  if (Constants.isDevice) {
    //①このアプリからのPush通知の許可を取得
    const { status: existingStatus } = await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;
    if (existingStatus !== 'granted') {
       //②初回起動時は許可ダイアログを出してユーザからPush通知の許可を取得
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }
    if (finalStatus !== 'granted') {
      //許可がない場合
      alert('Failed to get push token for push notification!');
      return;
    }
    //③通知用トークンの取得
    token = (await Notifications.getExpoPushTokenAsync()).data;
    console.log(token);
  } else {
    //実機以外の場合
    alert('Must use physical device for Push Notifications');
  }
  return token;
}

6.実装③通知を開く・フォアグラウンドでの通知受信時の動作

④ユーザが通知をフォアグラウンドで開いた場合のリスナー
今回は「フォアグラウンドで通知を受信しました」と表示することにします。
⑤ユーザが通知を開いた場合のリスナー
今回は「通知を開きました」と表示することにします。

App.js

import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import React, { useState, useEffect, useRef } from 'react';
import { Text, View, Button } from 'react-native';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

export default function App() {
  const notificationListener = useRef();
  const responseListener = useRef();
  const [pushState,setPushState] = useState(null);
  const [expoPushToken,setExpoPushToken] = useState(null);

  useEffect(() => {
    // ④ユーザが通知をフォアグラウンドで開いた場合のリスナー
    notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
      setPushState("フォアグラウンドで通知を受信しました。");
    });

    // ⑤ユーザが通知を開いた場合のリスナー
    responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
      setPushState("通知を開きました。")
    });
    // userEffectのreturnに登録する関数は、コンポーネントがunmountされるときに実行される。ここで主にcleanup処理を定義する
    return () => {
      Notifications.removeNotificationSubscription(notificationListener);
      Notifications.removeNotificationSubscription(responseListener);
    };
  }, []);
  return (
    <View
      style={{
        flex: 1,
        alignItems: 'center',
        justifyContent: 'space-around',
      }}>
      <Text>push通知のトークン: {expoPushToken}</Text>
      <View style={{ alignItems: 'center', justifyContent: 'center' }}>
        <Text>push通知受信時の動作: {pushState} </Text>
      </View>
      <Button
        title="push通知用のトークンを取得"
        onPress={async () => {
          const pushToken = await registerForPushNotificationsAsync()
          setExpoPushToken(pushToken);
        }}
      />
  </View>
  );
}


async function registerForPushNotificationsAsync() {
  let token;
  if (Constants.isDevice) {
    ////①このアプリからのPush通知の許可を取得
    const { status: existingStatus } = await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;
    if (existingStatus !== 'granted') {
      //②初回起動時は許可ダイアログを出してユーザからPush通知の許可を取得
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }
    if (finalStatus !== 'granted') {
      //許可がない場合
      alert('Failed to get push token for push notification!');
      return;
    }
    //③通知用トークンの取得
    token = (await Notifications.getExpoPushTokenAsync()).data;
    console.log(token);
  } else {
    //実機以外の場合
    alert('Must use physical device for Push Notifications');
  }
  return token;
}

7.ポイント Expoへのログイン

expo-notifiationsの機能を利用してテストをするためには、Expoにログインしておく必要があります。
以下のコマンドでログインしておくことでテストが可能になります。
Expoのユーザはこちらで作成可能です。

expo login

8.テスト①Expo Goのインストール

storeから「Expo Goアプリ」をインストールします。

アプリインストール後、[expo start]を実行した際に表示されるQRコードを実機でスキャンします。
これで画面が表示されるはずです。

9.テスト②Push通知を送る

Push通知を送信するためには、Expoの提供するAPIに必要な情報を送信する必要があります。
下記のようなJSONを「https://exp.host/–/api/v2/push/send」に対してPOST送信します。

[
  {
    "to": "取得したpush通知用のトークン",
    "title":"通知のタイトル部分",
    "body": "通知の内容部分"
  }
]

この際、ヘッダー情報は下記のものを設定します。
host: exp.host
accept: application/json
accept-encoding: gzip, deflate
content-type: application/json

例えば以下のようなPOST送信用のツールから情報を送信した場合

通知受信時(フォアグラウンド)

通知を開いた場合

10.まとめ

今回はできるだけ時間をかけずに早くリリースしたいということから「Expo」と「Expoが提供するPush通知用ライブラリ」の組み合わせでPush通知の実装・テストをより簡易的に行いました。
Push通知と聞くと「何やら難しそう」と思っていましたが、Expoを利用することでとても簡単に実装することができます。
是非皆さんも気軽にPush通知を実装してみてください。

SpringBoot、Thymeleafでのページング機能の実装(Java初心者向け)

はじめに

 現在のプロジェクトでは、プログラムはJava(Spring-Boot)、フロントはthymeleaf、Bootstrapを使って開発をしています。
 開発する中で、時間を要したのがページネーション処理です。同環境での実装例も見当たらず、数日かかっての実装となりました。
 そこで、Spring-Boot、Tymeleaf環境でのページネーションの実装方法をご紹介します。同環境でページネーション処理につまずいている方に読んでいただきたいです。

環境

実装ページング機能概要

  • テーブルでData内容を表示する(3件づつ)
  • ページ数の表示、そのページへの遷移が可能
  • 現在のページの所在が分かる。
  • 「次へ」「前へ」を表出させて遷移できる。
  • 「最初」「最後」を表出させて遷移できる。
  • 表示するページ数を制限する(3ページ)

ディレクトリ構成

src/
├ java/
│ ├ sampleController.java
│ ├ sampleForm.java
│ ├ sampleDao.java
│ └ member.java
└ resourse/
└ html/
└ sample.html

1-1 Controller

package jp.sbworks.standard.web.controller;

import java.util.HashMap;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

import jp.sbworks.standard.web.dao.SampleDao;
import jp.sbworks.standard.web.db.single.pojo.SSample;

@Controller
public class SampleController {

    /** 1ページの表示数 */
    private final String limit = "3";

    /** ページネーションで表示するページ数 */
    private int showPageSize = 3;

    @Autowired
    private SampleDao sampleDao;


    @RequestMapping(value = "/sample",method = RequestMethod.GET)
    @Transactional(readOnly = true)
    public String index(Model model, @RequestParam HashMap<String, String> params) throws Exception {
        // パラメータを設定し、現在のページを取得する    
        String currentPage = params.get("page");

        // 初期表示ではパラメータを取得できないので、1ページに設定
        if (currentPage == null){
            currentPage = "1";
        }
        // データ取得時の取得件数、取得情報の指定
        HashMap<String, String> search = new HashMap<String, String>();
        search.put("limit", limit);
        search.put("page", currentPage);
    
        int total = 0;
        List<SSample> list = null;
        try {
            // データ総数を取得
            total = sampleDao.getMemberListCount();
            // データ一覧を取得
            list = sampleDao.getMemberList(search);
        } catch (Exception e) {
            return "error/fatal";
        }

        // pagination処理
        // "総数/1ページの表示数"から総ページ数を割り出す
        int totalPage = (total + Integer.valueOf(limit) -1) / Integer.valueOf(limit);
        int page = Integer.valueOf(currentPage);
        // 表示する最初のページ番号を算出(今回は3ページ表示する設定)
        // (例)1,2,3ページのstartPageは1。4,5,6ページのstartPageは4
        int startPage = page - (page-1)%showPageSize;
        // 表示する最後のページ番号を算出
        int endPage = (startPage+showPageSize-1 > totalPage) ? totalPage :    startPage+showPageSize-1;
        model.addAttribute("list", list);
        model.addAttribute("total", total);
        model.addAttribute("page", page);
        model.addAttribute("totalPage", totalPage);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);
        return "html/sample/index";
        }
    }

1-2 DAO

package jp.sbworks.standard.web.dao;

import java.io.IOException;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;

import org.sql2o.Connection;
import org.sql2o.Query;
import org.sql2o.Sql2o;

import jp.sbworks.standard.web.db.single.pojo.SSample;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;


@Repository
public class SampleDao {

    @Autowired
// データベースアクセス用のライブラリ
    private Sql2o sql2o;
 
    // リスト情報を取得する 
    public List<SSample> getMemberList(HashMap<String, String> search) throws SQLException, IOException {
        // Jdbcに接続する
        Connection con = sql2o.open();
        con.getJdbcConnection().setAutoCommit(false);
        // クエリの作成
        Query query = con.createQuery("select ID as id, NAME as name, DEPARTMENT as department, DAY_OF_JOINING as dayOfJoining from SAMPLE limit :limit offset :offset;");
        int limit = Integer.valueOf(search.get("limit"));
        int page = Integer.valueOf(search.get("page")) - 1;
        // 何件情報を取得するかの指定。
        query = query.addParameter("limit", limit);
        // 何件目からの情報を取得するかの指定(※コントローラからパラメータを使って現在のページ数が分かる。それによって何件目からの情報を取得すればいいのかが分かる。)
        query = query.addParameter("offset", limit * page);

        return query.executeAndFetch(SSample.class);
    }
    
    // リスト情報件数を取得する 
    public int getMemberListCount() throws SQLException, IOException {
        Connection con = sql2o.open();
        con.getJdbcConnection().setAutoCommit(false);
        Query query =  con.createQuery("select count(1) from SAMPLE");
        return query.executeAndFetchFirst(Integer.class);
     }
}

1-3 DB

package jp.sbworks.standard.web.db.single.pojo;

import java.io.Serializable;
import java.time.LocalDate;

public class SSample implements Serializable {

    // ID 
    public Integer id;
    // 名前 
    public String name;
    // 部署 
    public Integer department;
    // 入社日
    public LocalDate dayOfJoining;
    // 登録者 
    public String regName;

}

1-4 html

<!DOCTYPE html>
<html lang="ja">
    <head>
        <script type="text/javascript" th:src="@{/webpack-bundled/Sample/bundled.js}"></script>
    </head>
<div class="table-scroll mb-2">
    <table class="table table-bordered table-sm mt-1">
        <thead>
            <tr>
                <th>社員ID</th>
                <th>名前</th>
                <th>部署</th>
                <th>入社日</th>
                <th>登録者</th>
            </tr>
        </thead>
        <tbody>
            <th:block th:each="item, status : ${list}">
                <tr>
                    <td th:text="${item.id}"></td>
                    <td th:text="${item.name}"></td>
                    <td th:text="${item.department}"></td>
                    <td th:text="${item.dayofjoining}"></td>
                    <td th:text="${item.regname}"></td>
                </tr>
            </th:block>
        </tbody>
    </table>
</div>
<!-- ここからページング処理 -->
<nav>
    <ul class="pagination pg-blue justify-content-center">
        <li th:if="${startPage} > 1" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=1'}" tabindex="-2">最初</a>
        </li>
        <li th:if="${page} > 1" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=' + ${page-1}}" tabindex="-1">前へ</a>
        </li>
        <th:block th:if="${endPage}<=0">
            <li class="page-item " th:classappend="${i == page} ? active" th:each="i : ${startPage}">
                <a class="page-link" th:href="@{'/sample?page=' + ${i}}" th:text="${i}"></a>
            </li>
        </th:block>
        <!-- StartPageからEndPageまでのページ数を表示する -->
        <th:block th:if="${endPage}>0">
            <li class="page-item " th:classappend="${i == page} ? active" th:each="i : ${#numbers.sequence(startPage, endPage)}">
                <a class="page-link" th:href="@{'/sample?page=' + ${i}}" th:text="${i}"></a>
            </li>
        </th:block>
        <li th:if="${page} < ${totalPage}" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=' + ${page+1}}">次へ</a>
        </li>
        <li th:if="${endPage} < ${totalPage}" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=' + ${totalPage}}">最後</a>
        </li>
    </ul>
</nav>

現在いるページの所在を分かりやすくするために、Bootstrapを使って色付けをしています。下記は現在表示しているページとして青色で表示する場合の処理を簡単に書きました。

<nav>
    <ul class="pagination pg-blue">
…
<li th:classappend="${i == page} ? active" >

結果

下記のように実装できました。

まとめ

ページングの処理は煩雑なりがちですが、基本的な動きはJavaで実装し、Thymeleafで条件分岐、繰り返し処理を活用することで、より可読性が高く、シンプルなプログラムとなります。
 私がつまづいた箇所は、2ページ以降のデータの取得です。コントローラでパラメータを使って現在のpage番号を取得することで、取得を始めるデータを特定でき、ページごとに正しいデータを取得することに成功しました。
 1ページに表示するデータ数や、表示ページ数の指定をプロジェクト用に沿って替えることで、活用いただければ幸いです。

author/A.Nishihara

DBの環境差異調査(mysql編)

システム開発において、
 ・本番
 ・検品(ステージング)
 ・開発
と環境が別れている事は通常よくあることであり、それにともないDBの環境もそれぞれ存在することとなります。

開発体制が小規模であったり、管理ルールが整っていない時などに、それぞれのDB環境に差異が発生している事が多々あります。
その差異を視覚化し、容易に把握するための方法をご紹介します。

※今回使用した環境
 ・mysql : 5.6.40
      https://dev.mysql.com/downloads/windows/installer/5.6.html
 ・ローカル環境:windows 10
 ・比較ツール:WinMerge 2.14.0
        https://winmerge.org/downloads/?lang=ja

1.差分抽出SQL の用意


SELECT '### COLUMN情報 #########################' AS '';
SELECT TABLE_NAME
     , COLUMN_NAME
--   , ORDINAL_POSITION
     , COLUMN_TYPE
     , COLUMN_DEFAULT
     , IS_NULLABLE
     , COLUMN_KEY
     , IFNULL( COLLATION_NAME , '')      COLLATION_NAME
     , IFNULL( CHARACTER_SET_NAME , '')  CHARACTER_SET_NAME
     , COLUMN_COMMENT 
  FROM INFORMATION_SCHEMA.COLUMNS
 WHERE TABLE_SCHEMA = @DB_DATABASE
 ORDER BY TABLE_NAME , COLUMN_NAME
;

SELECT '### INDEX情報 ##########################' AS '';
SELECT TABLE_NAME
     , INDEX_NAME
     , SEQ_IN_INDEX
     , COLUMN_NAME
  FROM INFORMATION_SCHEMA.STATISTICS
 WHERE TABLE_SCHEMA = @DB_DATABASE
 ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX 
;

mysql では、DBの情報を INFORMATION_SCHEMA 内に保持しており、その情報を出力しています。
※上記例では、カラムの順番を考慮せず、カラム名順で評価しています。

2.差分抽出SQL の実行

上記 SQL をファイルに保存して、コマンドで実行します。
※ここでは、「db-columns.sql」と命名します。

mysql -e "set @DB_DATABASE='DB名'; source db-columns.sql ;" > 環境.txt

※接続情報等は、省略させて頂いています。

こちらの実行した結果は以下の通りとなります。
※ mysql のバージョンに関わらず、全てのバージョンで稼働すると思います。


### COLUMN情報 #########################
TABLE_NAME	COLUMN_NAME	COLUMN_TYPE	COLUMN_DEFAULT	IS_NULLABLE	COLUMN_KEY	COLLATION_NAME	CHARACTER_SET_NAME	COLUMN_COMMENT
hoge	birthday	date	NULL	YES				
hoge	key	int(11)	NULL	NO	PRI			
hoge	name	varchar(50)	NULL	YES	MUL	utf8_general_ci	utf8	
hoge	sex	char(1)	0	YES		utf8_general_ci	utf8	

### INDEX情報 ##########################
TABLE_NAME	INDEX_NAME	SEQ_IN_INDEX	COLUMN_NAME
hoge	hoge_i1	1	name
hoge	hoge_i1	2	birthday
hoge	PRIMARY	1	key

3.SQL実行結果の比較

上記、環境毎に出力し、その結果を winmerge 等で比較する事が出来ます。
例えば、ローカルの環境と比較。

別のタブで画像を開くと、大きな画像で参照できます。

上記は、以下の テーブルを比較した結果なのですが、

-- 左のDDL
CREATE TABLE `hoge` (
	`key` INT(11) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(50) NULL DEFAULT NULL,
	`sex` CHAR(1) NULL DEFAULT '0',
	`birthday` DATE NULL DEFAULT NULL,
	PRIMARY KEY (`key`),
	INDEX `hoge_i1` (`name`, `birthday`)
)

-- 右のDDL
CREATE TABLE `hoge` (
	`key` INT(11) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(50) NULL DEFAULT NULL,
	`sex` VARCHAR(1) NULL DEFAULT '0',
	`birthday` TIMESTAMP NULL DEFAULT NULL,
	PRIMARY KEY (`key`),
	INDEX `hoge_i1` (`name`)
)

WinMarge 上で、差分行が色分けされているのが判るかと思います。
・DATE型 と TIMESTAMP 型 の差異
・CHAR型 と VARCHAR型 の差異
・INDEX の差異

※Oracle ですと、「USER_TAB_COLUMNS」・「USER_IND_COLUMNS」テーブルを使用すると、同じ情報が取得できます。

author k.tomita