xsdのURLは正しいのにEclipseのエディタ上でエラーになる

xsdのURLは正しいのにDownloading external resources is disabled.と表示されてエラーになっている。

xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"

下記の設定を変更することで解決した。

Mavenの設定の「Download Artifact Javadoc」にチェックを入れる。

XML (Wild Web Developer)の設定の「Download external resources like referenced DTD, XSD」にチェックを入れる。

Hibernate Searchを7.2にアップデートしたらNoSuchMethodErrorが発生した

Hibernate Searchを7.2.1にアップグレードして、テストを実施したら下記のエラーが発生した。

NoSuchMethodError: 'java.lang.Object org.jboss.logging.Logger.getMessageLogger

コンパイルエラーにはなっていないが、テスト時のログ出力でエラーとなった。

他のライブラリとの依存関係の影響で、jboss-logging 3.5系を使用するようになっていた。3.6.0.Finalを使用するようにpom.xmlに依存関係を追記したところ、エラーは解消した。

<dependency>
    <groupId>org.jboss.logging</groupId>
    <artifactId>jboss-logging</artifactId>
    <version>3.6.0.Final</version>
</dependency>

Gradleの場合も同様。

implementation 'org.jboss.logging:jboss-logging:3.6.0.Final'

Spring BootでSessionをRedisに保存する

前回、Raspberry piで遊んだ時にTomcatによるSession Replicationを実装した。

今回はSpring BootとRedisでSession管理を外部化してSession Replicationを実装してみる。

Redisのインストール

sudo apt install redis

Redisの設定

複数サーバーからアクセス出来るようにするため、アクセス制限を削除する。

sudo vi /etc/redis/redis.conf
下記の通り修正

bind 0.0.0.0 ::0 # 一時的に使用するだけなので全てのIPからアクセスOKにしている

Spring Bootでの実装例

build.gradleの依存関係部分
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	implementation 'org.springframework.session:spring-session-data-redis'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Sessionオブジェクトの保存にRedisを使用するようにConfigを設定する。なお、外部にデータを保存することになるので、Sessionに入れるデータはSerializableである必要がある。

import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisWebSession;

@Configuration
@EnableRedisWebSession()
public class SessionConfig {
}
Controllerの実装例
@RestController
@AllArgsConstructor
public class SessionController {

    @GetMapping("/websession")
    public Mono<SessionForm> getSession(WebSession session) {
        session.getAttributes().putIfAbsent("key", 0);
        session.getAttributes().putIfAbsent("note", "Nothing!");

        var sessionForm = new SessionForm();
        sessionForm.setKey((Integer) session.getAttributes().get("key"));
        sessionForm.setNote((String) session.getAttributes().get("note"));

        return Mono.just(sessionForm);
    }

    @GetMapping("/websession/test")
    public Mono<SessionForm> testWebSessionByParam(@RequestParam(value = "key") Integer key,
            @RequestParam(value = "note") String note, WebSession session) {
        session.getAttributes().put("key", key);
        session.getAttributes().put("note", note);

        var sessionForm = new SessionForm();
        sessionForm.setKey((Integer) session.getAttributes().get("key"));
        sessionForm.setNote((String) session.getAttributes().get("note"));

        return Mono.just(sessionForm);
    }
}

完全なコードはこちら。
https://github.com/hide6644/spring-session

動作確認

下記URLにアクセスするとパラメーターがSessionに保存される。
localhost:8080/websession/test?key=222&note=helloworld

下記のURLにアクセスすると、先ほど保存した文字が表示される。
localhost:8080/websession

{“key”:222,”note”:”helloworld”}

SessionをRedisに保存しているので、Spring Bootアプリをスケールアウトしても、それぞれのアプリでSessionが共有されていることが確認できると思う。

$ redis-cli
127.0.0.1:6379> keys *
1) "spring:session:sessions:セッションID"
127.0.0.1:6379> exit

Plugin could not be resolved. Ensure the plugin’s groupId, artifactId and version are present.が表示される

問題点

Eclipseでpom.xml開くと下記artifactIdの行にワーニングが表示されていた。

<reporting>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-javadoc-plugin</artifactId>
==省略==
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-project-info-reports-plugin</artifactId>
==省略==

Plugin could not be resolved. Ensure the plugin's groupId, artifactId and version are present.
Additional information: Unable to resolve org.apache.maven.plugins:maven-javadoc-plugin
Plugin could not be resolved. Ensure the plugin's groupId, artifactId and version are present.
Additional information: Unable to resolve org.apache.maven.plugins:maven-project-info-reports-plugin

Maven Repository上に存在はしている。
https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-javadoc-plugin
https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-project-info-reports-plugin

修正点

原因は<reporting>のみ記述していたこと。下記のように<pluginManagement>にも記述したらワーニングは表示されなくなった。

<pluginManagement>
    <plugins>
        <plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-javadoc-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-project-info-reports-plugin</artifactId>
            </plugin>

Jxls 2から3にアップグレードする

<!-- https://mvnrepository.com/artifact/org.jxls/jxls-poi -->
<dependency>
    <groupId>org.jxls</groupId>
    <artifactId>jxls-poi</artifactId>
    <version>3.0.0</version>
</dependency>

メジャーバージョンが3に上がり、Javaコードを殆ど書き直すことになったため、そのメモを残す。

参考URL:https://jxls.sourceforge.net/migration-to-v3-0.html

Jxls 2

ほぼ全てが変わったので、比較対象にならないが、変更前のコードは下記の通りとなっている。

import org.jxls.area.Area;
import org.jxls.builder.AreaBuilder;
import org.jxls.builder.xls.XlsCommentAreaBuilder;
import org.jxls.common.CellRef;
import org.jxls.common.Context;
import org.jxls.util.TransformerFactory;

// 出力部分抜粋
try (InputStream is = getTemplateSource(getUrl(), request);
        OutputStream os = response.getOutputStream()) {
    var transformer = TransformerFactory.createTransformer(is, os);
    AreaBuilder areaBuilder = new XlsCommentAreaBuilder(transformer);
    List<Area> xlsAreaList = areaBuilder.build();
    var xlsArea = xlsAreaList.get(0);
    var context = new Context();

    model.entrySet().forEach(mode -> context.putVar(mode.getKey(), mode.getValue()));

    xlsArea.applyAt(new CellRef("Sheet1!A1"), context);
    transformer.write();
}

※getTemplateSourceはテンプレート用のエクセルファイルを読み込んでいるメソッド。

Jxls 3

Java部分は下記の通りかなりシンプルになった。テンプレートファイル自体は変更の必要はなかった。

import org.jxls.builder.JxlsStreaming;
import org.jxls.transform.poi.JxlsPoiTemplateFillerBuilder;

// 出力部分抜粋
try (InputStream is = getTemplateSource(getUrl(), request);
        OutputStream os = response.getOutputStream()) {
    JxlsPoiTemplateFillerBuilder.newInstance()
        .withStreaming(JxlsStreaming.STREAMING_ON)
        .withTemplate(is)
        .buildAndFill(model, new JxlsOutputStream(os));
}

ファイル出力用のクラスはファイルをローカルに保存するJxlsOutputFile.classのみ提供されているため、必要に応じて自分で実装する。今回はHttpResponseのOutputStreamに直接乗せたかったので、下記の通り実装した。

import java.io.IOException;
import java.io.OutputStream;

import org.jxls.builder.JxlsOutput;

/**
 * Excelを書き込むためのOutputStreamを提供するクラス.
 */
public class JxlsOutputStream implements JxlsOutput {

    private OutputStream os;

    public JxlsOutputStream(OutputStream os) {
        this.os = os;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public OutputStream getOutputStream() throws IOException {
        return os;
    }
}

Switch Expressionsに書き換える

しばらく放置していたが、Java 14から正式に追加されたSwitch Expressions、Java 21から追加されたPattern Matching for switchで既存の処理を書き換えることにした。

Java 11から21までの間のSwitch文の大きな変更は2つあって、分けて考えた方がわかりやすい。

Java 14で追加されたSwitch Expressions

これに書き換えることのメリットは2つと考えている。

1.breakが不要

Switch文から抜けるためのbreakが不要になった。breakがなくても同じように機能する。

例)変更前
switch (code) {
case 401, 403:
    model.addAttribute("errorTitle", "403");
    break;
case 404:
    model.addAttribute("errorTitle", "404");
    break;
default:
    model.addAttribute("errorTitle", "error");
}
変更後
switch (code) {
case 401, 403 -> model.addAttribute("errorTitle", "403");
case 404 -> model.addAttribute("errorTitle", "404");
default -> model.addAttribute("errorTitle", "error");
};

2.無駄な記述が減る

breakが不要になったことも含めて、無駄な記述を減らせる。

例)変更前
int multiple = 0;

switch (unitSign) {
case M_BYTE:
    multiple = 1024 * 1024;
    break;
case K_BYTE:
    multiple = 1024;
    break;
case BYTE:
    multiple = 1;
}
変更後
int multiple = switch (unitSign) {
case M_BYTE -> 1024 * 1024;
case K_BYTE -> 1024;
case BYTE -> 1;
};

Java 21で追加されたPattern Matching for switch

パターンマッチが使えるようになったが、正直すぐ使いそうなのはnullのチェックくらいだろうか。

例)変更前
public static FileConverter createConverter(FileType fileType) {
    if (fileType == null) {
        throw new FileException("errors.fileType");
    }

    switch (fileType) {
    case XML:
        return createXmlConverter();
    case EXCEL:
        return createExcelConverter();
    case CSV:
        return createCsvConverter();
    default:
        throw new FileException("errors.fileType");
    }
}
変更後
public static FileConverter createConverter(FileType fileType) {
    return switch (fileType) {
    case XML -> createXmlConverter();
    case EXCEL -> createExcelConverter();
    case CSV -> createCsvConverter();
    case null, default -> throw new FileException("errors.fileType");
    };
}

Gradle 7から8にアップグレードする

Gradle 7から8にアップデートした時のメモ。

非推奨になった箇所を修正した。
参考URL
https://docs.gradle.org/current/userguide/upgrading_version_8.html#changes_8.2

sourceCompatibility = '21'

java {
    sourceCompatibility = JavaVersion.VERSION_21
}

他にもpluginsで読み込んだものは、war{}のように囲んでから、プロパティを記述する必要がありそう。

例)

plugins {
    id 'war'
}

war {
    webAppDirectory = file('src/main/webapp')
}

OAuth 2.0を使用して Office365のSMTPでメール送信したい

注意

Microsoft Exchangeのアカウント(例:@の後ろが企業名とか独自のドメインになっているもの)でないとSMTPのOAuth認証を有効に出来ないような。アカウント持っていないので詳細はわからない。

個人アカウントでも、Microsoft Entraでアプリの登録が出来るので、トークン取得部分だけ動作確認した。Microsoft Exchangeのアカウントがあれば、通しでも多分動くはず。

build.gradle
plugins {
	id 'java'
}

group = 'oauth2hotmail'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
    implementation 'com.microsoft.azure:msal4j:1.14.2'
    implementation 'com.sun.mail:jakarta.mail:2.0.1'
}

tasks.named('test') {
	useJUnitPlatform()
}
HotmailOAuth.java
public class HotmailOAuth {

    private static final String SCOPE = "https://graph.microsoft.com/.default";
    // アプリケーション(クライアント) ID
    private static final String CLIENT_ID = "";
    // クライアント シークレット
    private static final String CLIENT_SECRET = "";
    // OAuth 2.0 トークン エンドポイント (v2)
    private static final String AUTHORITY = "https://login.microsoftonline.com/ディレクトリ(テナント) ID/oauth2/v2.0/token";
    private static final String MAIL_FROM = "sender@foo.bar";
    private static final String MAIL_TO = "receiver@foo.bar";

    public static void main(String[] args) throws MalformedURLException, AddressException, MessagingException {
        // アクセス トークンを取得
        var token = getAccessTokenFromSecret();

        // SMTP セッションを作成
        var props = new Properties();
        props.put("mail.smtp.host", "それぞれのSMTPホスト名");
        props.put("mail.smtp.port", "587");
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.auth.mechanisms", "XOAUTH2");
        props.put("mail.smtp.starttls.enable", "true");
        var session = Session.getDefaultInstance(props);

        // メールを作成
        var message = new MimeMessage(session);
        message.setFrom(new InternetAddress(MAIL_FROM));
        message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO));
        message.setSubject("OAuth 2.0認証を使って送信したメール");
        message.setText("このメールは、OAuth 2.0 認証を使って送信しました。");

        // メールを送信
        var transport = session.getTransport("smtp");
        transport.connect(MAIL_FROM, token);
        transport.sendMessage(message, message.getAllRecipients());
        transport.close();

        System.out.println("メールを送信しました。");
    }

    public static String getAccessTokenFromSecret() throws MalformedURLException {
      var credential = ClientCredentialFactory.createFromSecret(CLIENT_SECRET);

      var cca = ConfidentialClientApplication
              .builder(CLIENT_ID, credential)
              .authority(AUTHORITY)
              .build();

      var parameters = ClientCredentialParameters
              .builder(Set.of(SCOPE))
              .build();

      var result = cca.acquireToken(parameters).join();

      return result.accessToken();
    }
}

Hibernate Search 7.0 Migration

Hibernate Search 7.0がリリースされていたので、アップグレードした。今回プログラムの修正は必要なかった。

pom.xmlの変更点

  • lucene-analyzers-kuromojiをlucene-analysis-kuromojiに変更
  • hibernate-search-mapper-orm-orm6をhibernate-search-mapper-ormに変更
<!-- Hibernate Search & Lucene -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-analysis-kuromoji</artifactId>
    <version>9.8.0</version>
</dependency>
<dependency>
    <groupId>org.hibernate.search</groupId>
    <artifactId>hibernate-search-mapper-orm</artifactId>
    <version>7.0.0.Final</version>
</dependency>
<dependency>
   <groupId>org.hibernate.search</groupId>
   <artifactId>hibernate-search-backend-lucene</artifactId>
   <version>7.0.0.Final</version>
</dependency>

バージョンを変えるだけでなく、artifactIdを変更する必要がある。luceneを使用している場合はそちらも変更する必要がある。

OAuth 2.0を使用して GmailのSMTPでメール送信したい

前提条件

Gmailのアカウントを持っていること。

Google Cloudの設定

https://console.cloud.google.com/にアクセスして、プロジェクトを作成する。

わかりやすい名前を付ける。

User Typeは「外部」を選択する。

テスト用なのでアプリ情報は適当に入力する。

プロジェクトを作成したら、「認証情報」タブの「認証情報を作成」をクリックする。

「OAuthクライアントID」を選択する。

アプリケーションの種類には「デスクトップアプリ」を選択する。メールを送信するだけなので、ライブラリからGmail APIを追加する必要はない。

作成が完了したら、クライアント ID、クライアント シークレットが発行されるので、メモしておく。

プログラムの例

build.gradle
plugins {
	id 'java'
}

group = 'oauth2gmail'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
    implementation 'com.google.api-client:google-api-client:2.2.0'
    implementation 'com.sun.mail:jakarta.mail:2.0.1'
}

tasks.named('test') {
	useJUnitPlatform()
}
GmailOAuth.java

メモしておいたクライアント ID、クライアント シークレットを、CLIENT_ID、CLIENT_SECRET変数にそれぞれ設定する。

今回リダイレクト処理は必要ないので、リダイレクトURLにはurn:ietf:wg:oauth:2.0:oobを設定する。これを設定するとリダイレクトせずに直接画面に認証コードが表示される。

MAIL_FROMにGmailのアドレスを設定する。MAIL_TOにはテストメールを送っても大丈夫なメールアドレスを設定する。

public class GmailOAuth {

    private static final String SCOPE = "https://mail.google.com/";
    private static final String CLIENT_ID = "CLIENT_ID";
    private static final String CLIENT_SECRET = "CLIENT_SECRET";
    private static final String REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
    private static final String MAIL_FROM = "sender@foo.bar";
    private static final String MAIL_TO = "receiver@foo.bar";

    public static void main(String[] args) throws IOException, MessagingException {
        // 取得した認証コードを保存するフォルダを指定
        var fileDataStoreFactory = new FileDataStoreFactory(new File(System.getProperty("java.io.tmpdir")));

        // OAuth 2.0 の認証フローを作成
        var flow = new GoogleAuthorizationCodeFlow.Builder(new NetHttpTransport(),
                GsonFactory.getDefaultInstance(), CLIENT_ID, CLIENT_SECRET, Set.of(SCOPE))
                        .setCredentialDataStore(StoredCredential.getDefaultDataStore(fileDataStoreFactory)).build();

        // アクセス トークンを取得
        var credential = flow.loadCredential("user");

        // 取得済みの認証情報があるか、またそれは有効か確認
        if (credential == null || (credential.getExpiresInSeconds() < 100 && !credential.refreshToken())) {
            // 有効なコードがなかった場合、新たに認可コードを取得
            var url = flow.newAuthorizationUrl().setRedirectUri(REDIRECT_URI).build();
            System.out.println("Please open the following URL in your browser then type the authorization code:");
            System.out.println("  " + url);
            System.out.println("Please enter your authentication code:");

            try (var s = new Scanner(System.in)) {
                var code = s.nextLine();
                var tokenResponse = flow.newTokenRequest(code).setRedirectUri(REDIRECT_URI).execute();
                credential = flow.createAndStoreCredential(tokenResponse, "user");
            }
        }

        // SMTP セッションを作成
        var props = new Properties();
        props.put("mail.smtp.host", "smtp.gmail.com");
        props.put("mail.smtp.port", "587");
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.auth.mechanisms", "XOAUTH2");
        props.put("mail.smtp.starttls.enable", "true");
        var session = Session.getDefaultInstance(props);

        // メールを作成
        var message = new MimeMessage(session);
        message.setFrom(new InternetAddress(MAIL_FROM));
        message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO));
        message.setSubject("Gmail から送信したメール");
        message.setText("このメールは、Gmail の SMTP で OAuth 2.0 認証を使って送信しました。");

        // メールを送信
        var transport = session.getTransport("smtp");
        transport.connect(MAIL_FROM, credential.getAccessToken());
        transport.sendMessage(message, message.getAllRecipients());
        transport.close();

        System.out.println("メールを送信しました。");
    }
}

実行

初回実行時、下記のメッセージが表示されるようになっている。表示されているURLにブラウザでアクセスし、アクセス権を付与する。

Please open the following URL in your browser then type the authorization code:
  ここに表示されるURLにブラウザでアクセスする
Please enter your authentication code:

ChromeブラウザでGmailにログイン済みの状態であれば、下記の通りアカウントの選択に表示される。もちろん別のアカウントを選択しても良い。

テスト用のプロジェクトのため、注意事項が表示されるがそのまま続行する。

アクセス権の付与が間違いないことを確認して、続行する。

表示された認証コードをコピーして、「Please enter your authentication code:」の下に貼り付けEnterを押下する。

「メールを送信しました。」と表示されたら、送信先のメールアドレスにメールが送信されたことを確認する。

2回目以降は、認証コードの有効期限内であれば、上記の手順無しでメール送信できる。逆にもう一度最初から実行したい場合は、「System.getProperty(“java.io.tmpdir”)」フォルダ内の「StoredCredential」というファイルを削除する。