AWS Lambda (Python 3.12) で cryptography の ImportError にハマった話と解決策

zappa frameworkを使用したアプリを MacOS でパッケージして、AWS Lambda にデプロイしようとしました。

デプロイ自体は成功しましたが、実行時に ImportError: PyO3 modules compiled for CPython 3.8 or older may only be initialized once per interpreter process というエラーが発生し、アプリが起動しませんでした。(はじめは、NoneType object is not callable としか出てなかったが、根本原因はこのimportErrorでした。)

この原因は、cryptography のバイナリが Lambda と互換性がなかったことでした。

具体的には、 cryptography は ネイティブ 拡張を含んでおり、ビルドを実行した環境向けのネイティブ拡張を取得していたようです。端的にいうと、M2(arm)向けのcryptographyが取得されていました。一方、AWS Lambda は、x86 Linuxでした。

さいわい、CI環境が、x86 Linuxだっため、CI環境でビルドすることで解決しました。

今後、Lambda (Python) でネイティブ拡張を含むライブラリ (cryptography, numpy, pandas 等) を使うときは、ビルド環境と実行環境を合わせることが鉄則です。

OracleのDockerコンテナ初回起動が遅すぎる問題とその対処法

OracleのDockerコンテナの初回起動が非常に遅いという問題は、すでに広く知られています。

この遅さの原因は、コンテナの初回作成時にOracleの初期セットアップ処理が実行されるためです。

一度この初期セットアップを済ませてしまえば、以降の起動はそこまで遅くはなりません。

ただし、「コンテナの削除・再作成を頻繁に行う場合」は話が変わってきます。

たとえば、初回に、自動でDB定義(スキーマ)の流し込みをしており、開発初期などでDB定義が頻繁に変わる場合や、CI環境で毎回初期状態のDBを立ち上げるようなケースでは、この初回の遅延が大きなストレスになります。

この「初回起動が非常に遅い」という問題には、すでにワークアラウンドがあり、それがpre-builtイメージの利用です。

参考:

kagamihoge.hatenablog.com

ただし、pre-builtでも課題があった

実際にpre-builtしたイメージを試してみたところ、DB定義(スキーマ)の流し込みが実行されないという問題に直面しました。

なぜ実行されないのかというと、スクリプトのフックがOracleの初期セットアップ処理(runOracle.sh)の中で呼び出されており、この処理自体がpre-builtイメージではスキップされてしまうためです。

自前でスクリプトを流し込む方法

この問題に対して、私は自前でDB定義(スキーマ)の流し込む処理を追加することにしました。

以下がその構成です。

Dockerfile

FROM ***/oracledb-pre-built:1.0.0

USER root
COPY entrypoint.sh /opt/oracle/entrypoint.sh
RUN chmod +x /opt/oracle/entrypoint.sh
USER oracle

ENTRYPOINT ["/opt/oracle/entrypoint.sh"]

entrypoint.sh

#!/bin/bash
set -e

INIT_MARKER="/opt/oracle/oradata/.init_done"
HEALTH_MARKER="/opt/oracle/oradata/.healthy"

# Oracle起動
bash /opt/oracle/runOracle.sh --nowait

if [ ! -f "$INIT_MARKER" ]; then
  echo "🚀 初回セットアップを実行します..."
  bash /opt/oracle/runUserScripts.sh /opt/oracle/scripts/setup
  touch "$INIT_MARKER"
  echo "✅ 初回セットアップ完了"
else
  echo "🔁 すでに初期化済みのため、セットアップをスキップします。"
fi

touch "$HEALTH_MARKER"

# ログをtailしてコンテナを保持
echo "以下、alert.logのtail出力:"
tail -f "$ORACLE_BASE"/diag/rdbms/*/*/trace/alert*.log &
childPID=$!
wait $childPID

docker-compose.yaml

services:
  db:
    container_name: db
    build:
      context: ./
      dockerfile: Dockerfile
    ports:
      - "${DOCKER_DB_PORT}:1521"
      - "5500:5500"
    environment:
      ORACLE_SID: ${DOCKER_DB_SID}
      ORACLE_PDB: ${DOCKER_DB_PDB}
      ORACLE_PWD: ${DOCKER_DB_PASSWORD}
      ORACLE_CHARACTERSET: JA16SJIS
      ORACLE_USER: ${DOCKER_DB_USER}
    volumes:
      - ./schema:/opt/oracle/scripts/setup
    healthcheck:
      test: [
        "CMD-SHELL",
        "test -f /opt/oracle/oradata/.healthy && /opt/oracle/checkDBStatus.sh"
      ]
      interval: 2s

MinecraftのBedrock 版サーバーをAWSに建ててみた

息子がSwitchで、Minecraftを始めたので、私もiphoneでMinecraftを、始めてみました。

息子とマルチプレーをしたいのですが、Minecraftが提供するRealmsという有料のサーバーを借りる必要があります。 しかも、Switch版でオンラインゲームをするには、Nintendo Switch Onlineというサブスクを購入する必要があり、Minecraft Realmsとともに、二重課金されるわけです。

さいわいなことに、Minecraftには、サーバーアプリケーションが無償で公開されています。 そこで、Minecraft Realmsの分については、AWSに自前のサーバーを立てることで節約することにしました。

とはいえ、AWSもただではありません。 Minecraft Realmsの料金より高くなっては、意味がない。どう料金を抑えるかがポイントです。

ECS上に、安いスポットインスタンスを立て、EBSにデータを保存しておいて、遊んでないときはECSタスクを停止するという構成を考えました。 そして、私が、githubを徘徊し、ついにたどり着いた解が、このCloudformationテンプレートです。

https://github.com/vatertime/minecraft-spot-pricing

しかし、これは、java版のMinecraftでした。java版は、PCでしか遊べないエディションで、SwitchやiOSは、Bedrock版というクロスプラットフォーム向けのエディションでサーバを立てる必要があります。

私は、Bedrock版サーバーのdockerイメージを見つけて、

https://hub.docker.com/r/itzg/minecraft-bedrock-server

このCloudformationテンプレートをBedrock版に書き換えることに成功しました。

それがこれです。

GitHub - ryu1/minecraft-spot-pricing: Cloudformation template to deploy a Minecraft server

ちなみに、Switchから自前のサーバに接続するには、DNSの設定を書き換えるハックが必要です。

www.radical-dreamer.com

Filter pattern in awslogs contains single quotes?

シングルクオートを含む文字列、例えば、'sample'で、CloudWatch Logsを検索したいとき、フィルターパターンとして、"'sample'"を指定すると...

awslogs get ${LOG_GROUP} ALL --start ${START} --end ${END} -f "'sample'"

Oops! エラーになります。

Traceback (most recent call last):
  File "/opt/homebrew/bin/awslogs", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/opt/homebrew/Cellar/awslogs/0.15.0_1/libexec/lib/python3.12/site-packages/awslogs/bin.py", line 210, in main
    getattr(logs, options.func)()
  File "/opt/homebrew/Cellar/awslogs/0.15.0_1/libexec/lib/python3.12/site-packages/awslogs/core.py", line 219, in list_logs
    consumer()
  File "/opt/homebrew/Cellar/awslogs/0.15.0_1/libexec/lib/python3.12/site-packages/awslogs/core.py", line 172, in consumer
    for event in generator():
  File "/opt/homebrew/Cellar/awslogs/0.15.0_1/libexec/lib/python3.12/site-packages/awslogs/core.py", line 159, in generator
    response = self.client.filter_log_events(**kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/awslogs/0.15.0_1/libexec/lib/python3.12/site-packages/botocore/client.py", line 565, in _api_call
    return self._make_api_call(operation_name, kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/awslogs/0.15.0_1/libexec/lib/python3.12/site-packages/botocore/client.py", line 1021, in _make_api_call
    raise error_class(parsed_response, operation_name)
botocore.errorfactory.InvalidParameterException: An error occurred (InvalidParameterException) when calling the FilterLogEvents operation: Invalid character(s) in term '''

\で、エスケープして、"\'sample\'"としても、

Oops! エラーになります。

その場合、%で囲んで正規表現とした上で、シングルクオートを文字コード\x27で指定するとよいです。

awslogs get ${LOG_GROUP} ALL --start ${START} --end ${END} -f "%\x27sample\x27%"

EC2にセッションマネージャー経由で接続する方法 〜CLI編〜

~/.ssh/configの設定は不要です。

ssh \
  -i 秘密鍵のパス \
  -o ProxyCommand="aws ssm start-session \
    --target %h --document-name AWS-StartSSHSession \
    --parameters 'portNumber=%p' \
    --profile AWSプロファイル名" \
   ec2-user@インスタンスID

scpもいけます。

scp \
  -i 秘密鍵のパス \
  -o ProxyCommand="aws ssm start-session \
    --target %h --document-name AWS-StartSSHSession \
    --parameters 'portNumber=%p' \
    --profile AWSプロファイル名" \
   アップロードファイル ec2-user@インスタンスID:アップロードファイル

Reactによる確認ダイアログの実装サンプル

確認ダイアログのReactコンポーネント meets kintone

import React, { FC, useCallback, useState } from "react";
import { Dialog } from "@kintone/kintone-ui-component";
import { Button } from "../Button";

type EventHandler = (e: React.SyntheticEvent<EventTarget>) => void;

// https://kintone-labs.github.io/kintone-ui-component/latest/Reference/Dialog/
interface ConfirmDialogProps {
  isVisible: boolean;
  title: string;
  content: string;
  close: () => void;
  onClickOkButton: EventHandler;
}

const okButtonStyle: React.CSSProperties = {
  color: "white",
  background: "#e74c3c",
};

const createButtons = (
  onClickOkButton: EventHandler,
  onClickCancelButton: EventHandler
) => {
  return (
    <>
      <Button label="キャンセル" onClick={onClickCancelButton} />
      <Button label="OK" onClick={onClickOkButton} style={okButtonStyle} />
    </>
  );
};

export const ConfirmDialog: FC<ConfirmDialogProps> = ({
  isVisible,
  title,
  content,
  close,
  onClickOkButton,
}) => {
  const onClickOkButtonWrapper: EventHandler = (e) => {
    console.log("dialog.onClickOkButton");
    onClickOkButton(e);
    close();
  };

  const onClickCancelButton: EventHandler = () => {
    console.log("dialog.onClickCancelButton");
    close();
  };

  const buttons = createButtons(onClickOkButtonWrapper, onClickCancelButton);

  return (
    <Dialog
      showCloseButton={false}
      header={title}
      content={content}
      footer={buttons}
      isVisible={isVisible}
    />
  );
};

export const useConfirmDialogOpener = (): [boolean, () => void, () => void] => {
  const [currentVisible, setVisible] = useState(false);

  const open = useCallback(() => {
    setVisible(true);
  }, []);

  const close = useCallback(() => {
    setVisible(false);
  }, []);

  return [currentVisible, open, close];
};

使い方

export const App = () => {

  const onClickOkButton = () => {
    // implement deleting
  };

  const [isVisible, open, close] = useConfirmDialogOpener();

  return (
    <div style={{ display: "flex" }}>
      <Button label="削除" onClick={open} type="submit" />
      <ConfirmDialog
        title="削除"
        content="削除します。よろしいですか?"
        isVisible={isVisible}
        close={close}
        onClickOkButton={onClickOkButton}
      />
    </div>
  );
};

playwright on container imageをAWS Lambdaで動かす

dockerコンテナ上のアプリをAWS Lambda上で呼び出すには、awslambdaricというのが必要なようです。 dockerイメージは以下のようになります。

# Define custom function directory
ARG FUNCTION_DIR="/function"

FROM --platform=linux/amd64 python:3.10-slim-bullseye
ENV TZ="Asia/Tokyo"

# Include global arg in this stage of the build
ARG FUNCTION_DIR

# Install aws-lambda-cpp build dependencies
RUN apt-get update -y && \
  apt-get install -y \
  g++ \
  make \
  cmake \
  unzip \
  libcurl4-openssl-dev \
  oathtool

# Copy function code
RUN mkdir -p ${FUNCTION_DIR}
WORKDIR ${FUNCTION_DIR}

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

# Install the function's dependencies
RUN pip install \
    --target ${FUNCTION_DIR} \
        awslambdaric

ENV PLAYWRIGHT_BROWSERS_PATH=0

RUN python -m playwright install --with-deps chromium

ENTRYPOINT [ "python", "-m", "awslambdaric" ]
CMD [ "app.lambda_handler" ]

なぜ、わざわざ、dockerイメージを使用しているかって? それは、oathtoolコマンドを使用してOTPを生成し、多要素認証を突破したかったからです。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import asyncio
from playwright.async_api import async_playwright
import subprocess

URL = ""
MAILADDRESS = ""
PASSWORD = ""
SECRET = ""

async def main():
    async with async_playwright() as p:
        try:
            browser = await p.chromium.launch(
                args=[
                    '--autoplay-policy=user-gesture-required',
                    '--disable-background-networking',
                    '--disable-background-timer-throttling',
                    '--disable-backgrounding-occluded-windows',
                    '--disable-breakpad',
                    '--disable-client-side-phishing-detection',
                    '--disable-component-update',
                    '--disable-default-apps',
                    '--disable-dev-shm-usage',
                    '--disable-domain-reliability',
                    '--disable-extensions',
                    '--disable-features=AudioServiceOutOfProcess',
                    '--disable-hang-monitor',
                    '--disable-ipc-flooding-protection',
                    '--disable-notifications',
                    '--disable-offer-store-unmasked-wallet-cards',
                    '--disable-popup-blocking',
                    '--disable-print-preview',
                    '--disable-prompt-on-repost',
                    '--disable-renderer-backgrounding',
                    '--disable-setuid-sandbox',
                    '--disable-speech-api',
                    '--disable-sync',
                    '--disk-cache-size=33554432',
                    '--hide-scrollbars',
                    '--ignore-gpu-blacklist',
                    '--metrics-recording-only',
                    '--mute-audio',
                    '--no-default-browser-check',
                    '--no-first-run',
                    '--no-pings',
                    '--no-sandbox',
                    '--no-zygote',
                    '--password-store=basic',
                    '--use-gl=swiftshader',
                    '--use-mock-keychain',
                    '--disable-gpu',
                    '--single-process',
                    '--headless=new'
                ],
                slow_mo=1000)

            context = await browser.new_context()
            page = await context.new_page()
            await page.goto(URL)

            async with page.expect_navigation():
                # ログイン
                await page.type("input[name=loginfmt]", MAILADDRESS)
                await page.click("input[type=submit]")

                await page.type("input[name=username]", MAILADDRESS)
                await page.click("button[type=submit]")


                await page.type("input[name=password]", PASSWORD)
                await page.click("button[type=submit]")

                token = await generate_token(SECRET)

                await page.type("input[id=security-code]", token)
                await page.click("button[type=submit]")
                await page.wait_for_timeout(10000)

                # 以下は省略

        finally:
            await browser.close()


async def generate_token(secret: str) -> str:
    cmd = ['oathtool', '--totp', '--base32', secret]
    out = subprocess.run(cmd, stdout=subprocess.PIPE)
    fa_string = out.stdout.decode()
    # 取得する文字列の最後に改行があるため、6文字で切る処理
    string_2fa = fa_string[:6]
    return string_2fa



def lambda_handler(event, context):
    asyncio.run(main())


if __name__ == "__main__":
    pass

特殊な環境だったため、chromiumが、うまく動かなくて非常にはまりました。 chromiumは、複数プロセスを立ち上げるので、それがどうも相性がわるかったようです。 シングルプロセスで起動するオプションをchromium起動時に指定する必要があります。 また、--disable-gpuも付与しないとクラッシュします。

Anker PowerConf H700

去年、このヘッドセットを購入して、半年程使用しました。

Bluetooth接続できて、かつ、マイクが口元にあり、マイクにもノイズ除去機能があるものを探して、これに辿り着いた次第でした。

ところが、騒がしいところでも、周囲の音を拾わないことを期待したわけでしたが、近くの声や音は拾ってしまうということが、MT相手の反応からわかりました。

いや、求めていたレベルが大きすぎただけで、悪い製品ではないんですがね。

MTは、やはり静かなところでやるに、限りますね。

Enumの名称は単数形か複数系か

learn.microsoft.com

ほとんどの Enum 型には単数名を使用しますが、ビット フィールドのEnum 型には複数名を使用するのがよいそうです。

Javaは、Enumで、ビットフィールドを表現できない(かわりにEnumSetを用いる)ので、単数が当たり前で、 他の言語を使用すると、複数形のEnumが出てきて、長年の疑問でしたが、なるほど、腑に落ちました。

Django(DRF)のレスポンスにcodeを含める

DRFのレスポンスは、通常は、メッセージが表示されるだけ。

{
   "param1": [
     "この項目は必須です。"
   ] 
}

APIの例外クラスを見ると、エラーコード(code)が指定されることがわかります。 このcodeをレスポンスに含めたい場合、どうするか。

class APIException(Exception):
    """
    Base class for REST framework exceptions.
    Subclasses should provide `.status_code` and `.default_detail` properties.
    """
    status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
    default_detail = _('A server error occurred.')
    default_code = 'error'

    def __init__(self, detail=None, code=None):
        if detail is None:
            detail = self.default_detail
        if code is None:
            code = self.default_code

以下のように、EXCEPTION_HANDLERをカスタマイズし、exc.get_full_details()の結果をレスポンスにセットする。

/PATH/TO/xxx/utils.py

from rest_framework.exceptions import APIException
from rest_framework.response import Response
from rest_framework.views import exception_handler


def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is not None:
        if isinstance(exc, APIException):
            data = exc.get_full_details()
            response = Response(data, status=exc.status_code, headers=response.headers)
    return response

/PATH/TO/settings/base.py

REST_FRAMEWORK = {
     "EXCEPTION_HANDLER": "xxx.utils.custom_exception_handler",
 }

そうすると、こういうレスポンスが返ります。

{
   "param1": [
     {"message": "この項目は必須です。", "code": "required"}
   ] 
}

ステータスコードだけだと、多様なエラーのハンドリングが難しい場合に、試してみたいです。

キャメルケースにおける略語の表現

HTML(HyperText Markup Language)、XML(Extensible Markup Language)などの略語は、通常、全て大文字で表現しますが、クラス名などのキャメルケースでは、どうするか、いつも悩ましい、私です。

qiita.com

JAVAは、どうもHtmlというふうに、略語も一つの単語扱いするようですね。 潔くていいです。

stackoverflow.com

Pythonは、HTMLというふうに、全て大文字にするようです。

このパターンは、変数にしたときに、接頭に略語がくると、xMLParserとかになるのかっ、変なの。 ってなりますが、Pythonはスネークケースだから関係ないですね。

idも、userIDやuserIdという表現が、あって悩ましいですね。

Oracle JDK8 Install in github action

github actionで、JDKをインストールする場合は、以下のgithub actionが提供されていますが、 難点は、OracleJDKはサポートされていないことです。

github.com

そういった場合、Oracleからwgetしてインストールするgithub actionを書くわけですが、今回、私がインストールしようしたJDKのバージョンは、Oracleからコマンドラインで、ダウンロードできなくなってしまったようです。

wget to download Java 8 · GitHub

↑こちらで議論されていますが、今回インストールしようとしていた1.8.0_191については、どれもうまくいきませんでした。

苦肉の策として、JDKGoogle Driveに配置し、共有リンクからダウンロードすることにしました。

github.com

      - name: Setup java
        run: |
          # Store jdk.tar.gz in google drive and get sharing link.
          # Set <ID> contained in the link (https://drive.google.com/file/d/<ID>/view?usp=sharing) to ID variable.
          ID=""
          FILE="jdk-8u191-linux-x64.tar.gz"
          CONFIRM=$(wget --quiet --save-cookies /tmp/cookies.txt --keep-session-cookies --no-check-certificate "https://drive.google.com/uc?export=download&id=$ID" -O- | sed -En 's/.*confirm=([0-9A-Za-z_]+).*/\1/p')
          wget --load-cookies /tmp/cookies.txt "https://drive.google.com/uc?export=download&confirm=$CONFIRM&id=$ID" -O $FILE
          sudo mkdir -p /usr/lib/jvm
          sudo tar zxf $FILE -C /usr/lib/jvm
          sudo chown -R root:root /usr/lib/jvm/jdk1.8.0_191
          sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/jdk1.8.0_191/bin/java 1
          sudo update-alternatives --set java /usr/lib/jvm/jdk1.8.0_191/bin/java
          sudo update-alternatives --install /usr/bin/javac javac /usr/lib/jvm/jdk1.8.0_191/bin/javac 1
          sudo update-alternatives --set javac /usr/lib/jvm/jdk1.8.0_191/bin/javac
          sudo update-alternatives --install /usr/bin/jar jar /usr/lib/jvm/jdk1.8.0_191/bin/jar 1
          sudo update-alternatives --set jar /usr/lib/jvm/jdk1.8.0_191/bin/jar
          sudo update-alternatives --install /usr/local/java_home java_home /usr/lib/jvm/jdk1.8.0_191 1
          sudo update-alternatives --set java_home /usr/lib/jvm/jdk1.8.0_191
          echo "JAVA_HOME=/usr/local/java_home" >> $GITHUB_ENV

JDK8は、もうEOLですけどね...

.envとPycharm

JetBrain製のエディターで、.envファイルを読み込ませたいときは、 EnvFileというプラグインを使用するとよいです。

github.com

EAP版やアップグレード直後などに、ために、そのプラグインが使えないケースがあり、少し手間ではあるけど、 以下のように、pipenvを経由して、エディターを起動すると、.envファイルが読み込まれます。

$ pipenv run open ~/Applications/JetBrains\ Toolbox/PyCharm\ Professional.app

突如、WebMoneyのAPIが、SSLPeerUnverifiedExceptionを投げ始めた。

TLS1.2に移行したようです。

すでに知られていることですが、java7は、デフォルトではTLS1.2をサポートしていません。 TLS1.2限定のサーバにリクエストを送信すると、以下のようなExceptionがスローされました。

javax.net.ssl.SSLPeerUnverifiedException: peer not authenticated
        at sun.security.ssl.SSLSessionImpl.getPeerCertificates(SSLSessionImpl.java:421)
        at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:128)
        at org.apache.http.conn.ssl.SSLSocketFactory.connectSocket(SSLSocketFactory.java:397)
        at org.apache.http.impl.conn.DefaultClientConnectionOperator.openConnection(DefaultClientConnectionOperator.java:148)
        at org.apache.http.impl.conn.AbstractPoolEntry.open(AbstractPoolEntry.java:149)
        at org.apache.http.impl.conn.AbstractPooledConnAdapter.open(AbstractPooledConnAdapter.java:121)
        at org.apache.http.impl.client.DefaultRequestDirector.tryConnect(DefaultRequestDirector.java:573)
        at org.apache.http.impl.client.DefaultRequestDirector.execute(DefaultRequestDirector.java:425)
        at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:820)
        at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:754)
        at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:732)

近年のTSL1.2必須化の動きは、有名な話だけど、なぜか、本件は、アナウンスが届かなかった。 何も情報がなかったので、サーバ証明書の問題かなと思ってしまいました。

TLS1.2に、対応するには、いくつか方法があります。

  1. デフォルトで1.2をサポートしているから、java8以上にする。理想を言えばそうしよう。
  2. JVMの引数に、-Djdk.tls.client.protocols=TLSv1.1,TLSv1.2,,TLSv1.2-Dhttps.protocols=TLSv1.1,TLSv1.2,TLSv1.3をつけて実行しよう。
  3. これで駄目なケースもあるようだ。プログラムの修正が必要。

私の場合、3のパターンで。 Apache CommonsのHttpClientを使用しているので、以下のような修正をしました。

            SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
            sslContext.init(null, null, new SecureRandom());
            SSLSocketFactory sf = new SSLSocketFactory(sslContext);
            Scheme httpsScheme = new Scheme("https",  443, sf);
            SchemeRegistry schemeRegistry = new SchemeRegistry();
            schemeRegistry.register(httpsScheme);
            ClientConnectionManager cm = new SingleClientConnManager(schemeRegistry);
            DefaultHttpClient client = new DefaultHttpClient(cm);

HttpClientのバージョンよっては、こちらの修正方法になります。 というかググるとこればかり出てきます。

SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
  SSLContexts.createDefault(),
  new String[] { "TLSv1.2", "TLSv1.3" },
  null,
  SSLConnectionSocketFactory.getDefaultHostnameVerifier());

CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslsf).build();

よく見かけるこのやり方は、おそらく全体的に適用されるので、気をつける必要があると思う。

            SSLContext ctx = SSLContext.getInstance("TLSv1.2");
            ctx.init(null, null, null);
            SSLContext.setDefault(ctx);

PayPay Open Payment API(OPA)の突合ファイルをOpenCSVで読み込んでみた。

PayPay OPAでは、前日の取引データなどが、突合ファイルとして生成され、HTTP GETで取得することができます。 こちらのPayPayのディベロッパサイトに、sampleの突合ファイルがありますので、取得して読み込んでみました。

Web Cashier - PayPay Open Payment API Documentation

読み込みに使用した突合ファイル: transaction_000000000000008181_20200130000000_20200130235959.csv

決済番号,加盟店ID,屋号,店舗ID,店舗名,端末番号/PosID,取引ステータス,取引日時,取引金額,レシート番号,支払い方法,マーチャント決済ID
00000000000000000001,000000000000008181,テスト加盟店,test01,テスト01,00001,取引完了,"2020-01-30 23:58:30",150,000-0001,PayPay残高,0001-001
00000000000000000002,000000000000008181,テスト加盟店,test01,テスト01,00001,返金完了,"2020-01-30 23:55:14",-300,000-0002,PayPay残高,0001-002
00000000000000000003,000000000000008181,テスト加盟店,test01,テスト01,00001,取引完了,"2020-01-30 23:49:54",100,000-0003,PayPay残高,0001-003
00000000000000000004,000000000000008181,テスト加盟店,test01,テスト01,00001,取引完了,"2020-01-30 23:47:09",100,000-0004,PayPay残高,0001-004
00000000000000000005,000000000000008181,テスト加盟店,test01,テスト01,00001,取引完了,"2020-01-30 23:45:11",200,000-0005,PayPay残高,0001-005

突合ファイルは、CSV形式だということで、OSSライブラリで読み込みたいと思います。

opencsv –

CSVのライブラリは、いくつかありますが、多くの導入実績があり、 今後も開発が継続していく可能性が高そうな、OpenCSVをチョイスしました。

OpenCSVは、直接、CSVファイルのレコードとJava Beanクラスをバインディングする機能があります。 クラスのフィールド変数にアノテーションを付与して、カラムとの対応付けを定義します。

    /**
     * 突合ファイルのレコードクラス.
     */
    public class ReconciliationRecord implements Serializable {

        private static final long serialVersionUID = 1L;
        /**
         * 決済番号(paymentId).
         */
        @CsvBindByName(column = "決済番号")
        private String orderId;
        /**
         * 加盟店ID.
         */
        @CsvBindByName(column = "加盟店ID")
        private String merchantId;

        /**
         * 屋号.
         */
        @CsvBindByName(column = "屋号")
        private String brandName;
        /**
         * 店舗ID.
         */
        @CsvBindByName(column = "店舗ID")
        private String storeId;
        /**
         * 店舗名.
         */
        @CsvBindByName(column = "店舗名")
        private String storeName;
        /**
         * 端末番号/PosID.
         */
        @CsvBindByName(column = "端末番号/PosID")
        private String terminalId;
        /**
         * 取引ステータス.
         */
        @CsvCustomBindByName(column = "取引ステータス", converter = StatusConverter.class)
        private Status transactionStatus;
        /**
         * 取引日時.
         */
        @CsvBindByName(column = "取引日時")
        @CsvDate("yyyy-MM-dd HH:mm:ss")
        private Date acceptedAt;
        /**
         * 取引金額.
         */
        @CsvBindByName(column = "取引金額")
        private Long amount;
        /**
         * レシート番号.
         */
        @CsvBindByName(column = "レシート番号")
        private String orderReceiptNumber;
        /**
         * 支払い方法.
         */
        @CsvBindByName(column = "支払い方法")
        private String methodOfPayment;
        /**
         * マーチャント決済ID.
         */
        @CsvBindByName(column = "マーチャント決済ID")
        private String merchantPaymentId;

        public ReconciliationRecord() {
        }

        @Override
        public String toString() {
            return new StringJoiner(", ", ReconciliationRecord.class.getSimpleName() + "[", "]")
                    .add("orderId='" + orderId + "'")
                    .add("merchantId='" + merchantId + "'")
                    .add("brandName='" + brandName + "'")
                    .add("storeId='" + storeId + "'")
                    .add("storeName='" + storeName + "'")
                    .add("terminalId='" + terminalId + "'")
                    .add("transactionStatus=" + transactionStatus)
                    .add("acceptedAt=" + acceptedAt)
                    .add("amount=" + amount)
                    .add("orderReceiptNumber='" + orderReceiptNumber + "'")
                    .add("methodOfPayment='" + methodOfPayment + "'")
                    .add("merchantPaymentId='" + merchantPaymentId + "'")
                    .toString();
        }

        public String getOrderId() {
            return orderId;
        }

        public void setOrderId(String orderId) {
            this.orderId = orderId;
        }

        public String getMerchantId() {
            return merchantId;
        }

        public void setMerchantId(String merchantId) {
            this.merchantId = merchantId;
        }

        public String getBrandName() {
            return brandName;
        }

        public void setBrandName(String brandName) {
            this.brandName = brandName;
        }

        public String getStoreId() {
            return storeId;
        }

        public void setStoreId(String storeId) {
            this.storeId = storeId;
        }

        public String getStoreName() {
            return storeName;
        }

        public void setStoreName(String storeName) {
            this.storeName = storeName;
        }

        public String getTerminalId() {
            return terminalId;
        }

        public void setTerminalId(String terminalId) {
            this.terminalId = terminalId;
        }

        public Status getTransactionStatus() {
            return transactionStatus;
        }

        public void setTransactionStatus(Status transactionStatus) {
            this.transactionStatus = transactionStatus;
        }

        public Date getAcceptedAt() {
            return acceptedAt;
        }

        public void setAcceptedAt(Date acceptedAt) {
            this.acceptedAt = acceptedAt;
        }

        public Long getAmount() {
            return amount;
        }

        public void setAmount(Long amount) {
            this.amount = amount;
        }

        public String getOrderReceiptNumber() {
            return orderReceiptNumber;
        }

        public void setOrderReceiptNumber(String orderReceiptNumber) {
            this.orderReceiptNumber = orderReceiptNumber;
        }

        public String getMethodOfPayment() {
            return methodOfPayment;
        }

        public void setMethodOfPayment(String methodOfPayment) {
            this.methodOfPayment = methodOfPayment;
        }

        public String getMerchantPaymentId() {
            return merchantPaymentId;
        }

        public void setMerchantPaymentId(String merchantPaymentId) {
            this.merchantPaymentId = merchantPaymentId;
        }

        enum Status {

            COMPLETED, FAILED, REFUND_COMPLETED, REFUND_FAILED, UNKNOWN;

            private static final String STRING_COMPLETED = "取引完了";
            private static final String STRING_FAILED = "取引失敗";
            private static final String STRING_REFUND_COMPLETED = "返金完了";
            private static final String STRING_REFUND_FAILED = "返金失敗";

            static Status statusOf(final String value) {
                Status status = null;
                switch (value) {
                    case STRING_COMPLETED:
                        status = COMPLETED;
                        break;
                    case STRING_FAILED:
                        status = FAILED;
                        break;
                    case STRING_REFUND_COMPLETED:
                        status = REFUND_COMPLETED;
                        break;
                    case STRING_REFUND_FAILED:
                        status = REFUND_FAILED;
                        break;
                    default:
                        status = UNKNOWN;
                        break;

                }
                return status;
            }
        }
    }

固定値をenum型に変換したかったので、OpenCSVのAbstractBeanFieldクラスを継承して変換処理を実装しています。 そして、この変換処理を適用したいフィールド変数に対して、 @CsvCustomBindByName(column = "取引ステータス", converter = StatusConverter.class)というように指定します。

    /**
     * 取引ステータスをString<->enumに変換します.
     */
    public class StatusConverter extends AbstractBeanField {

        public StatusConverter() {
            super();
        }

        @Override
        protected ReconciliationRecord.Status convert(String value)
                throws CsvDataTypeMismatchException, CsvConstraintViolationException {
            return ReconciliationRecord.Status.statusOf(value);
        }
    }

特に、PayPayのサイトには、記載されてないが、ファイルのエンコーディングは、SHIFT_JISっぽいですね。 csvToBeanクラスを生成して、突合ファイルを読み込みしてみます。

File file = new File("/PATH/TO/transaction_000000000000008181_20200130000000_20200130235959.csv");

try (Reader reader = new InputStreamReader(new FileInputStream(file), "SHIFT_JIS")) {

            CsvToBean<ReconciliationRecord> csvToBean = new CsvToBeanBuilder(reader).withType(ReconciliationRecord.class).build();

            csvToBean.stream().parallel().forEach(new Consumer<ReconciliationRecord>() {

                @Override
                public void accept(ReconciliationRecord reconciliationRecord) {
                    log.debug(reconciliationRecord);
                }

            });
        }

結果

ReconciliationRecord[orderId='00000000000000000001', merchantId='000000000000008181', brandName='テスト加盟店', storeId='test01', storeName='テスト01', terminalId='00001', transactionStatus=COMPLETED, acceptedAt=Thu Jan 30 23:58:30 JST 2020, amount=150, orderReceiptNumber='000-0001', methodOfPayment='PayPay残高', merchantPaymentId='0001-001']
ReconciliationRecord[orderId='00000000000000000002', merchantId='000000000000008181', brandName='テスト加盟店', storeId='test01', storeName='テスト01', terminalId='00001', transactionStatus=REFUND_COMPLETED, acceptedAt=Thu Jan 30 23:55:14 JST 2020, amount=-300, orderReceiptNumber='000-0002', methodOfPayment='PayPay残高', merchantPaymentId='0001-002']
ReconciliationRecord[orderId='00000000000000000003', merchantId='000000000000008181', brandName='テスト加盟店', storeId='test01', storeName='テスト01', terminalId='00001', transactionStatus=COMPLETED, acceptedAt=Thu Jan 30 23:49:54 JST 2020, amount=100, orderReceiptNumber='000-0003', methodOfPayment='PayPay残高', merchantPaymentId='0001-003']
ReconciliationRecord[orderId='00000000000000000004', merchantId='000000000000008181', brandName='テスト加盟店', storeId='test01', storeName='テスト01', terminalId='00001', transactionStatus=COMPLETED, acceptedAt=Thu Jan 30 23:47:09 JST 2020, amount=100, orderReceiptNumber='000-0004', methodOfPayment='PayPay残高', merchantPaymentId='0001-004']
ReconciliationRecord[orderId='00000000000000000005', merchantId='000000000000008181', brandName='テスト加盟店', storeId='test01', storeName='テスト01', terminalId='00001', transactionStatus=COMPLETED, acceptedAt=Thu Jan 30 23:45:11 JST 2020, amount=200, orderReceiptNumber='000-0005', methodOfPayment='PayPay残高', merchantPaymentId='0001-005']

ふと、これを見て、acceptedAtは、タイムゾーンまで含んでいない。 JSTであってるだろうかという疑問がでてきました。

他の日時パラメタは、エポックタイムスタンプだったり、協定世界時 (UTC)なのに何故でしょうか。UTCかもしれないと思いつつ、 ファイルのエンコーディングSHIFT_JISっぽいし、カラム名が日本語だし、JSTの可能性も否めない。

そこで、別の種類の突合ファイルをPayPayのディベロッパサイトからダウンロードして見てみたところ。

preauth_transaction_000000000000008181_20200130000000_20200130235959.csv

"orderId","merchantId","brandName","storeId","storeName","terminalId","transactionStatus","acceptedAt","amount","orderReceiptNumber","methodOfPayment","merchantPaymentId"
"3456789012345678901","234567890123456789","〇〇加盟店","01234567","","0000","COMPLETED","2020-09-23T00:00:26+09:00","480","","wallet","1447142183_202009230000250894_2"
"3456789012345678902","234567890123456789","〇〇加盟店","01234567","","0000","CANCELED","2020-09-23T07:45:03+09:00","1848","","wallet","1480206255_202009230745030768_6"
"3455979365068570624","234567890123456789","〇〇加盟店","01234567","","0000","EXPIRED","2020-09-23T13:00:00+09:00","1738","","wallet","1000014012_202009302301040213_0"
"3456423834054230016","234567890123456789","〇〇加盟店","01234567","","0000","FAILED","2020-09-23T13:24:54+09:00","20","","wallet","pp_0cb694dc-5340-44ef-a173-6034de590bc5"
"3456789012345678903","234567890123456789","〇〇加盟店","01234567","","0000","REFUNDED","2020-09-23T16:53:25+09:00","1325","","wallet","1000191023_202008261653230283_4"
"3456789012345678904","234567890123456789","〇〇加盟店","01234567","","0000","AUTHORIZED","2020-09-23T23:31:43+09:00","2924","","wallet","1489917170_202009232331310185_0"

こっちは、+09:00がついています。 ファイル名からみると、1/30のデータっぽいけど、レコードは、9/23です。 レコードが、1/30なら確定的だったけど、これは参考にならないかもしれない。

しかしながら、単純に、+09:00を落としただけなんじゃないかという気が、、、確認する必要がありそうです。