dorapon2000’s diary

忘備録的な。セキュリティとかネットワークすきです。

Perlの文字化けとutf8とEncodeの関係

ブログの記事のサンプルコードを書いているときにPerlで文字化けに悩まされました。文字化けはせずとも、Wide character in say atという警告が表示されたりすると、記事のサンプルコードとしては締りが悪いです。もうPerlで日本語まったくわからん... そこで、いろいろ実験してPerlの文字化け・警告と仲良くなろうというのがこの記事の主旨です。use utf8;encode_utf8を使っているのに文字化けが直らない!という人がいれば、助けになるかも知れません。

目次

検証環境

文字化けに関わるコード

Perl 文字化け」で検索してヒットする記事で出てくるPerlのコード達です。

# コード中に埋め込まれた文字列リテラルが内部文字列として解釈されるようになる。
use utf8;

# それぞれ、utf8のバイナリを内部文字列に変換するメソッドと、内部文字列からutf8のバイナリに変換するメソッド
use Encode qw/ decode_utf8 encode_utf8 /;

# 標準入力からのutf8の文字列が内部文字列へ変換される
binmode STDIN,  ":utf8";

# 標準出力する際に、内部文字列がutf8のバイナリへ変換される
binmode STDOUT, ":utf8";

# 標準エラー出力する際に、内部文字列がutf8のバイナリへ変換される
binmode STDERR, ":utf8";

お、お手柔らかに...

これら以外にもファイルの読み書きに関するものもあります。今回は標準入出力とコード中の文字の埋め込みだけに絞って考えていきます。

内部文字列とutf8のバイナリ

Perlはどうやらプログラムで扱う専用の文字列と入出力用の文字列で異なるものとして扱うようです。前者を「内部文字列」、後者を「バイナリ」と呼びます。内部文字列はこちらとしてはどう管理されているか使っている側は気にしなくてもいいはずですが、utf8だそうです。

f:id:dorapon2000:20220223180737p:plain

つまり、Perlで文字列を扱う際は内部文字列にしてからバイナリに戻すという作業が必要ということになります。

ということで...

検証① decode_utf8とencode_utf8

内部文字列にしてからバイナリに戻してみる

decode_utf8encode_utf8を使います。

use 5.30.0;
use strict;
use warnings;

use Encode;

my $en = decode_utf8("English");
say encode_utf8($en), " : ", length($en), " 文字";

my $ja = decode_utf8("日本語");
say encode_utf8($ja), " : ", length($ja), " 文字";
English : 7 文字
日本語 : 3 文字

f:id:dorapon2000:20220223185100p:plain

文字列をdecode_utf8で内部文字列にしたあと、sayで標準出力する際にencode_utf8でバイナリに戻しています。length($en)は返り値が文字列ではなく数字なので(Perlでは文字列と数字を区別しませんが)、とくにencode_utf8する必要がないはずです。

では、"English""日本語"以外の" : "" 文字"の部分はencode_utf8しなくていいんでしょうか。これはしなくていいですね。" : "" 文字"は内部文字列ではなくバイナリです。"English""日本語"は文字数を数えるという処理のために、一旦内部文字列に変換する必要がありましたが、" : "" 文字"は特に処理の対象ではないです。

内部文字列にしない

内部文字列にしないと何が困るのでしょうか。試してみます。

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
English : 7 文字
日本語 : 9 文字

「日本語」が9文字としてカウントされてしまいました。utf8は日本語1文字を3バイトで表現するので、3 x 3 = 9になったようです。

しかし、「English」の方は内部文字列にしていないにも関わらず、正しくカウントできています。これは内部文字列も実体はutf8で、バイナリもutf8で、utf8は英字1文字を1バイトで表現するという偶然の結果なのだと思います。しっかりするなら「English」だけであってもencode_utf8するべきなのでしょう。

文字化けさせてみる

文字化けさせてみましょう。引数に内部文字列を渡すべきencode_utf8でバイナリを渡せば文字化けしそうです。

my $en = "English";
say encode_utf8($en), " : ", length($en), " 文字";

my $ja = "日本語";
say encode_utf8($ja), " : ", length($ja), " 文字";
English : 7 文字
æ¥æ¬èª字

f:id:dorapon2000:20220223185320p:plain

予想通り、文字化けました。

警告を表示させる

Wide character in say atを表示させてみます。これは内部文字列をバイナリに変換せず直接出力しようとすると出るようです。

my $en = decode_utf8("English");
say $en, " : ", length($en), " 文字";

my $ja = decode_utf8("日本語");
say $ja, " : ", length($ja), " 文字";
English : 7 文字
Wide character in say at /Users/.../mojibake.pl line 14.
日本語 : 3 文字

f:id:dorapon2000:20220223191038p:plain

警告が出ましたが、表示としては問題ありません。これはutf8である内部文字列をutf8として画面上に表示しようとして、偶然文字化けしなかった形でしょうね。Shift-JIS環境などで表示すると文字化けると思います。

個人的には"English"の方も警告が表示されると予想していたので、警告が1つしか表示されないのは意外です。

encode_utf8とdecode_utf8を間違えて逆にする

my $en = encode_utf8("English");
say decode_utf8($en), " : ", length($en), " 文字";

my $ja = encode_utf8("日本語");
say decode_utf8($ja), " : ", length($ja), " 文字";
English : 7 文字
日本語 : 18 文字

f:id:dorapon2000:20220223194726p:plain

文字列の長さがおかしいので逆をやったことに気づけますが、そうでないと標準出力からは気づけないという罠です。エンコード→デコードの順に使いたくなってしまうのも非常にわかります。

検証② utf8プラグマ

decode_utf8を使わず内部文字列にする

use utf8;はコード中に埋め込まれている文字列リテラルを内部文字列として解釈するためのプラグマです。これを使うとdecode_utf8のひと手間が要らなくなります。

use 5.30.0;
use strict;
use warnings;

use Encode;
use utf8;

my $en = "English";
say encode_utf8($en . " : " . length($en) . " 文字");

my $ja = "日本語";
say encode_utf8($ja . " : " . length($ja) . " 文字");
English : 7 文字
日本語 : 3 文字

f:id:dorapon2000:20220223192615p:plain

utf8プラグマとencode_utf8だけで内部文字列を介したやり取りができました。ポイントは、検証①ではencode_utf8していなかった" : "" 文字"もencode_utf8の中に入れる必要があるということです。これらはプログラムの処理の対象ではないですが、utf8プラグマは彼らも内部文字列に変換してしまうのです。

警告を表示させる

確認のため、" : "" 文字"をencode_utf8せず標準出力すると、Wide character in sayが出るはずです。

use utf8;

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
Wide character in say at /Users/.../mojibake.pl line 11.
English : 7 文字
Wide character in say at /Users/.../mojibake.pl line 14.
Wide character in say at /Users/.../mojibake.pl line 14.
日本語 : 3 文字

f:id:dorapon2000:20220223193119p:plain

出ました。

文字化けさせてみる

use utf8;しているのにdecode_utf8すると文字化けするはずです。

use utf8;

my $en = decode_utf8("English");
say encode_utf8($en . " : " . length($en) . " 文字");

my $ja = decode_utf8("日本語");
say encode_utf8($ja . " : " . length($ja) . " 文字");
English : 7 文字
Wide character at /Users/.../mojibake.pl line 13.

f:id:dorapon2000:20220223193740p:plain

何も出力されませんでした笑。おかしな使い方であることは間違いないです。

検証③ binmode STDOUT

encode_utf8を使わずに標準出力する

binmode STDOUT, ":utf8";とすると、標準出力される際に、内部文字列をutf8のバイナリに変換して出力されます。

use 5.30.0;
use strict;
use warnings;

use utf8;
binmode STDOUT, ":utf8";

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
English : 7 文字
日本語 : 3 文字

f:id:dorapon2000:20220223195859p:plain

今までで一番すっきりしましたね。

文字化けさせる

ここまでの検証を踏まえれば、文字化けの方法も無数に気づけます。例えばutf8プラグマを使わなければ、標準出力の際にバイナリをバイナリに変換というおかしなことになります。

binmode STDOUT, ":utf8";

my $en = "English";
say $en, " : ", length($en), " 文字";

my $ja = "日本語";
say $ja, " : ", length($ja), " 文字";
English : 7 æå­
æ¥æ¬èªå­

"English"”日本語”にだけdecode_utf8を適応しても、" : "" 文字"が文字化けします。

use Encode;
binmode STDOUT, ":utf8";

my $en = decode_utf8("English");
say $en, " : ", length($en), " 文字";

my $ja = decode_utf8("日本語");
say $ja, " : ", length($ja), " 文字";
English : 7 æå­
日本語 : 4 æå­

f:id:dorapon2000:20220223200243p:plain

まとめ

この記事を書きながら、かなりPerlの文字の扱いについて理解が深まりました。

最初で紹介したようにいろいろ文字を扱う方法はあるけれど、すべてを適応しても文字化けを誘うだけということがよくわかります。今回は記事が長くなってしまったので扱いませんでしたが、ファイル入出力の文字コード指定しかりです。正直なところ、use utf8;が一番それっぽいので、これだけですべての文字化けが解決してくれると嬉しいんですけどねぇ。

参考

PythonとPerlとjsの日付操作のサンプルコード

PythonPerlJavaScriptの3つの言語で以下の日付操作をまとめます。自分用の忘備録です。

  • 現在時刻/今日の時刻オブジェクトの取得
  • 文字列 → 時刻オブジェクト
  • 時刻オブジェクト → 文字列
  • 曜日を日本語で取得
  • 足し算と引き算
  • 時刻オブジェクト同士の比較
  • (可能なら)タイムゾーン指定で現在時刻

サンプルコードはGitHubに公開しています。

github.com

目次

検証環境

Python

import locale
import datetime

# 現在時刻/今日のDateTime
dt_now = datetime.datetime.now()
dt_today = datetime.datetime.today()

# 文字列 → DateTime型
dt_nopadding = datetime.datetime.strptime('2022/2/10 1:00:00', '%Y/%m/%d %H:%M:%S')
dt_padding = datetime.datetime.strptime('2022-02-10 01:00', '%Y-%m-%d %H:%M')
dt_iso = datetime.datetime.fromisoformat('2022-02-10T01:00:00')

# DateTime型 → 文字列
print(dt_now.strftime('%Y年%m月%d日 %H時%M分%S秒'))  # 2022年02月20日 19時46分43秒
print(dt_now.isoformat())  # 2022-02-20T19:46:43.466040

# 曜日を日本語で取得
locale.setlocale(locale.LC_TIME, 'ja_JP.UTF-8')
print(dt_now.strftime('%a'))  # 日

# 足し算と引き算
td_10d10h10m10s = datetime.timedelta(days=10, seconds=10, minutes=10, hours=10)
print(dt_now + td_10d10h10m10s)  # 2022-03-03 05:56:53.466040
print(dt_now - td_10d10h10m10s)  # 2022-02-10 09:36:33.466040

# 比較
print(dt_now < dt_now + td_10d10h10m10s)  # True

# タイムゾーン
tz_jst = datetime.timezone(datetime.timedelta(hours=9))
dt_now_jst = datetime.datetime.now(tz=tz_jst)
print(dt_now_jst)  # 2022-02-20 19:46:43.470019+09:00

Perl

標準モジュールだけで

use 5.30.0;
use strict;
use warnings;
use utf8; # 埋め込みの文字列リテラルがutf8の内部文字列として解釈される
use Encode qw/encode_utf8/; # 日本語を含む内部文字列を標準出力できるutf8バイナリに変換する用

use Time::Piece;
use Time::Seconds qw/ONE_DAY ONE_HOUR ONE_MINUTE/;

# 現在時刻/今日のTime::Piece
my $now = localtime;
my $today = do {
    my $now = localtime;
    my $today = Time::Piece->strptime($now->ymd, "%Y-%m-%d");
    localtime($today);
};

# 文字列 → Time::Piece型
my $t_nopadding = Time::Piece->strptime('2022/2/10 1:00:00', '%Y/%m/%e %k:%M:%S');
my $t_padding = Time::Piece->strptime('2022-02-10 01:00', '%Y-%m-%d %H:%M');
my $t_iso = Time::Piece->strptime('2022-02-10T01:00:00', '%Y-%m-%dT%H:%M:%S');

# Time::Piece型 → 文字列
say $now->ymd('/'), ' ', $now->hms; # 2022/02/20 21:21:47
say $now->datetime; # 2022-02-20T21:21:47
say $now->strftime('%Y年%m月%d日 %H時%M分%S秒'); # 2022年02月20日 21時21分47秒

# 曜日を日本語で取得
my @week_names = qw/日 月 火 水 木 金 土/;
say encode_utf8($now->wdayname(@week_names)); # 日

# 足し算と引き算
my $future = $now + ONE_DAY * 10 + ONE_HOUR * 10 + ONE_MINUTE * 10 + 10;
say $future; # Thu Mar  3 07:31:57 2022

my $past = $now - ONE_DAY * 10 - ONE_HOUR * 10 - ONE_MINUTE * 10 - 10;
say $past; # Thu Feb 10 11:11:37 2022

# 比較
say $past < $future ? 'True' : 'False'; # True

Perlは日本語周りの扱いが複雑で、一応コメントで補足していますが、utf-8周りの細かい話は別の記事にしようと思っています。

追記:書きました。

dorapon2000.hatenablog.com

外部モジュールを使って

日付の演算をするときはDateTimeモジュールが便利です。

$ cpanm DateTime
$ cpanm DateTime::Format::Strptime
use 5.30.0;
use strict;
use warnings;
use utf8; # 埋め込みの文字列リテラルがutf8の内部文字列として解釈される
use Encode qw/encode_utf8/; # 漢字を含む内部文字列を標準出力できるutf8バイナリに変換する用

use DateTime;
use DateTime::Format::Strptime;

# 現在時刻/今日のDateTime
my $dt_now = DateTime->now(time_zone => 'local');
my $dt_today = DateTime->now(time_zone => 'local');

# 文字列 → DateTime型
my $strp_YmekMS = DateTime::Format::Strptime->new(
    pattern   => '%Y/%m/%e %k:%M:%S',
    locale    => 'ja',
    time_zone => 'local',
);
my $dt_nopadding = $strp_YmekMS->parse_datetime('2022/2/10 1:00:00');

my $strp_YmdHM = DateTime::Format::Strptime->new(
    pattern   => '%Y-%m-%d %H:%M',
    locale    => 'ja',
    time_zone => 'local',
);
my $dt_padding = $strp_YmdHM->parse_datetime('2022-02-10 01:00');

my $strp_iso = DateTime::Format::Strptime->new(
    pattern   => '%Y-%m-%dT%H:%M:%S',
    locale    => 'ja',
    time_zone => 'local',
);
my $dt_iso = $strp_iso->parse_datetime('2022-02-10T01:00:00');

# DateTime型 → 文字列
say $dt_now->ymd('/'), ' ', $dt_now->hms; # 2022/02/21 00:58:23
say $dt_now->datetime; # 2022-02-21T00:58:23
say encode_utf8($dt_now->strftime('%Y年%m月%d日 %H時%M分%S秒')); # 2022年02月21日 00時58分23秒

# 曜日を日本語で取得
my $dt = DateTime->now(locale => 'ja' , time_zone => 'local');
say encode_utf8($dt->day_abbr); # 月

# 足し算と引き算
my $future = $dt_now->clone;
$future->add(days => 10, hours => 10, minutes => 10, seconds => 10);
say $future; # 2022-03-03T11:08:33

my $past = $dt_now->clone;
$past->subtract(days => 10, hours => 10, minutes => 10, seconds => 10);
say $past; # 2022-02-10T14:48:13

# 比較
say $past < $future ? 'True' : 'False'; # True

# タイムゾーン
my $dt_now_jst = DateTime->now(time_zone => 'Asia/Tokyo');

JavaScript

標準モジュールだけで

function copyDate(future) {
  return new Date(future.getTime())
}

// 現在時刻/今日のDate
const now = new Date()
const today = (() => {
  const now = new Date()
  return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0)
})()

// 文字列 → Date型
const dtNopadding = new Date('2022/2/10 1:00:00')
const dtpadding = new Date('2022-02-10 01:00')
const dtISO = new Date('2022-02-10T01:00:00')

// Date型 → 文字列
const y = now.getFullYear()
const M = now.getMonth()
const d = now.getDay()
const H = now.getHours()
const m = now.getMinutes()
const s = now.getSeconds()
console.log(`${y}年${M}月${d}日 ${H}時${m}分${s}秒`) // 2022年1月0日 19時42分24秒

const yyyy = now.getFullYear().toString().padStart(4, '0')
const MM = now.getMonth().toString().padStart(2, '0')
const dd = now.getDay().toString().padStart(2, '0')
const HH = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
const ss = now.getSeconds().toString().padStart(2, '0')
console.log(`${yyyy}-${MM}-${dd}T${HH}:${mm}:${ss}`) // 2022-01-00T19:42:24

// 曜日を日本語で取得
console.log('日月火水木金土'.charAt(now.getDay())) // 日

// 足し算と引き算
const future = copyDate(now)
future.setDate(future.getDate() + 10)
future.setHours(future.getHours() + 10)
future.setMinutes(future.getMinutes() + 10)
future.setSeconds(future.getSeconds() + 10)
console.log(future) // Thu Mar 03 2022 05:52:34 GMT+0900 (日本標準時)

const past = copyDate(now)
past.setDate(past.getDate() - 10)
past.setHours(past.getHours() - 10)
past.setMinutes(past.getMinutes() - 10)
past.setSeconds(past.getSeconds() - 10)
console.log(past) //  Thu Feb 10 2022 09:32:14 GMT+0900 (日本標準時)

// 比較
console.log(`${now < future}`) // true

// タイムゾーン
const nowJst = new Date().toLocaleString({ timeZone: 'Asia/Tokyo' })

全体的にjsのDateを扱うのは厳しいです。

外部モジュールを使って

date-fnsというモジュールを使うことでDate型を簡単に扱えるようになります。標準モジュールだけのときと比べてかなりすっきりします。

$ npm install --save date-fns
import { format, formatISO, getDay, add, sub } from 'date-fns'

// 現在時刻/今日のDate型
const now = new Date()
const today = (() => {
  const now = new Date()
  return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0)
})()

// 文字列 → Date型
const dtNopadding = new Date('2022/2/10 1:00:00')
const dtpadding = new Date('2022-02-10 01:00')
const dtISO = new Date('2022-02-10T01:00:00')

// Date型 → 文字列
console.log(format(now, 'y年M月d日 H時m分s秒')) // 2022年2月20日 19時43分43秒
console.log(format(now, 'yyyy年MM月dd日 HH時mm分ss秒')) // 2022年02月20日 19時43分43秒
console.log(formatISO(now)) // 2022-02-20T19:43:43+09:00

// 曜日を日本語で取得
console.log('日月火水木金土'.charAt(getDay(now))) // 日

// 足し算と引き算
const future = add(now, { days: 10, hours: 10, minutes: 10, seconds: 10 })
console.log(future) // 2022-03-02T20:53:53.284Z

const past = sub(now, { days: 10, hours: 10, minutes: 10, seconds: 10 })
console.log(past) // 2022-02-10T00:33:33.284Z

// 比較
console.log(`${now < future}`) // true

// タイムゾーン
const nowJst = new Date().toLocaleString({ timeZone: 'Asia/Tokyo' })

参考文献

Python

Perl

JavaScript

Twitter APIのPOST lists/members/create_allの制約と対応

突然の宣伝で恐縮ですが、最近ListIDOというサービスを作りました。

listido.dorapon2000.xyz

内部でTwitter APIを使っており、主にTwitterのリストを操作しています。その中で、リストにメンバーをまとめて追加できるPOST lists/members/create_allという大変便利なAPIがあります。このAPIがなければ、ListIDOというサービスを作ろうとは思いませんでした。

しかし、便利すぎるがゆえに、その制約がどうなっているのかが気がかりでした。フォローは1日に400人までしかできません。ではリストにメンバーを追加する場合は?制約の厳しさ次第でサービスの根幹を揺るがされてしまいます。

今回はそのAPIについて調べたことをまとめ、その制約に対する自分の対応も紹介します。

3行まとめ

  • 1度の呼び出しで100人まで追加できる
  • リストに大量・無差別なメンバーの追加はTwitterルールで禁止されている
  • 具体的なレートリミットはなさそう

POST lists/members/create_all

POST lists/members/create_all | Docs | Twitter Developer Platform

Twitterのリストにメンバーを複数人追加するAPIです。リストの作成や、1人だけ追加するAPIは別であります。また、API v2には同じ役割のAPIがないため、API v1.1でしかできない操作になります。

APIドキュメントに記載されている制約

  • メンバーの追加先のリストは自分が所有している必要がある
  • 1つのリストには5000メンバーまでしか含めない
  • 1度の操作で100人までしかリストに追加できない
  • 同じリストに対してメンバーの追加と削除を急激に行うと問題が起きる可能性がある
  • レートリミットあり

2つ目の5000メンバーという上限はリスト機能にあるそもそもの制約1です。3つ目の100人までしか一度に追加できないのはAPIを呼び出すときに注意が必要です。つまり、300人追加したい場合は3回に分けてAPIを呼ぶ必要があります。4つ目は成約というより注意喚起で、おそらくDBの整合性の問題なのだと思います。

問題は5つ目で、原文は

f:id:dorapon2000:20211115234433p:plain

YES!!!(具体的にいくつやねん!!!!!)これはPOSTのリスト操作APIすべてに言えます。

GETのAPIには何分間に何回までしか呼び出せない等の具体的なレートリミットが書かれているところに、POSTではただ「Yes」としか書かれていません。

Standard API v1.1の制約

Rate limits: Standard v1.1 | Docs | Twitter Developer Platform

こちらにおそらくすべてのGETのAPIと一部のPOSTのAPIのレートリミットが列挙されています。なぜPOSTは一部しか列挙されていないのか.... POST lists/members/create_allはもちろんありません。

Twitterの自動化ルール

Twitterの自動化開発ルール | Twitterヘルプ

少しずれますが、開発者向けの自動化ルールのページにも、リストに関する言及があります。

リストまたはコレクションへの追加の自動化: Twitterアカウントを大量または無差別にリストに追加したり、ツイートを大量または無差別にコレクションに追加したりしないでください。多数の無関係の利用者をリストに追加することは、Twitterルールで禁止されています。

リストに大量にアカウントを追加する行為はTwitterルールで禁止されているようです。この「大量」という言葉が曖昧ですが、Yesよりは具体的になりました。

API呼び出しのHTTPヘッダを見る

Twitter APIを呼び出すと、HTTPヘッダ内にレートリミットに応じた残りリクエスト数などが記載されます。

以下のサイトでは、実際にAPIを呼んだときのレスポンスヘッダまで載っているので、どうなるか見てみます。

POST lists/members/create_all - リストに複数のメンバーを追加する

f:id:dorapon2000:20211116001914p:plain

対照としてGETも

GET lists/show - リストを取得する

f:id:dorapon2000:20211116002118p:plain

GET lists/showにはx-rate-limit-limit/x-rate-limit-remaining/x-rate-limit-resetがありますが、POST lists/members/create_allにはありません。

困りました。

GET application/rate_limit_statusを使う

Retrieve rate limit details programatically | Docs | Twitter Developer Platform

Twitter APIの中にはレートリミットの残量を確認できるAPIがあります。これで調べられるのでは、と思ったところ

for read-only (GET) operations

GETしかわかりませんでした汗。

他の実装を見てみる

そもそも、なぜレートリミットが知りたいかというと、それによって実装が変わってくるからです。緩いなら連続でAPIを呼んでも問題にならないですし、厳しければ間隔を明けてAPIを呼ぶ必要があります。

https://github.com/search?l=Python&q=lists%2Fmembers%2Fcreate_all&type=Code

GitHub全体で検索して、他の実装がどうなっているのか調べてみました。流石に個人のコードをここに貼ったりするのははばかられるので、見た感じの感想を述べると、連続で呼び出す実装ばかりで、そこのところはあまり気にしていないようです。

これで1つ、実装の目安が立ちました。

レートリミットの答えらしきもの

blog.ryotak.me

ネットの海を探して、このような記事を見つけました。記事単体で面白かったのですが、注目したいのは以下の記述です。

Twitter APIの一部のレートリミットが、ドキュメント上には存在すると書いてあるにも関わらず、実際には存在しないことがわかった。 それらのエンドポイントの一つにPOST /1.1/lists/destroy.jsonが存在した。

薄々そう思っていましたが、おそらくPOST lists/members/create_allについてもそういうことなのでしょう。

ListIDOではどう実装したか

具体的な制約があれば、その制約を守っていれば怒られる(垢バン)ことはないです。しかし今回、その制約がなさそうということで、守るべきなのは制約ではなく節度に変わりました。

その節度も「大量の追加」という言葉で具体性がありません。結局、ListIDOでは1つのリストに対して15分に50人までしか追加できないようにしています。具体的には、ジョブサーバを立てて、リストへの追加作業を15分間隔でメインのアプリとは非同期に定期実行させます。300人のメンバーをリストに追加する必要があるならば、6つのジョブを75分かけて完遂させます。

単にfor文を回して3回APIを呼ぶ実装と比べて、格段に実装コストがあがりましたが、勉強になるからと自分をなだめました。

最後に

多くはいないが同じような疑問を持つ人は必ずいると思い、ニッチな内容ですが記事にしました。それで本記事を呼んで疑問を解消できたのであれば書いた甲斐があります。読んでいただきありがとうございました。

参考

pythonで例外クラスを引数にとる例外処理デコレータ

pythonの例外処理のプラクティスとして、例外処理デコレータを作るというやり方を最近知りました。更に発展させて、デコレータがどの例外の例外処理をするか選びたいとも思ってググりましたが、見つからなかったので、記事に残します。

一般的な例外処理

try-exceptを使った一般的な例外処理です。

class Exception1(BaseException):
    pass


def may_raise_exception():
    raise Exception1


def myfunc():
    try:
        may_raise_exception()
    except Exception1 as e:
        print('Exception1 occured!')

if __name__ == '__main__':
    myfunc()
$ python basic_exception.py
Exception1 occured!

デコレータを使った例外処理

自分が最近学んだやり方です。

import functools

class Exception0(BaseException):
    pass

class Exception1(BaseException):
    pass

def exception1(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except Exception1 as e:
            print('Exception1 occured!')
    return wrapper

def may_raise_exception():
    # raise Exception0
    raise Exception1

@exception1
def myfunc():
    may_raise_exception()

if __name__ == '__main__':
    myfunc()

myfuncでException1を発生したとき、デコレーター内のtry-exceptで処理して例外を握りつぶします。myfuncでException0が発生したときは、普通に例外が表示されます。

デコレータを使うことでtry-exceptをmyfuncの中に書かずに済み、見た目がスッキリしました。複数のメソッドで同じ例外処理をする必要があるときもデコレータをつけるだけなので便利です。

例外クラスを引数に取る例外処理デコレータ

上記の例外処理デコレータだけでも便利ですが、もう少し汎用性をもたせて、デコレータの引数にどの例外を受け取れるか指定したいと思い、実装しました。

import functools

# Exception0 〜 Exception3までの定義

def reraise(*catch_exceptions, reraise_exception):
    def decorated(f):
        @functools.wraps(f)
        def wrapped(*args, **kwargs):
            try:
                return f(*args, **kwargs)
            except catch_exceptions as e:
                print('may do something')
                raise raise_exception
        return wrapped
    return decorated


def may_raise_exception():
    raise Exception0
    # raise Exception1
    # raise Exception2


@reraise(Exception1, Exception2, reraise_exception=Exception3)
def myfunc():
    may_raise_exception()


if __name__ == '__main__':
    myfunc()

reraiseデコレータ(正確にはデコレータを返すreraise関数)はキャッチする複数個の例外クラスとリレイズする1つの例外クラスを引数に取ります。引数catch_exceptionsは可変長引数(*)なので、その後ろの引数reraise_exceptionはキーワード引数として渡す必要があります。

myfuncの動作としては、Exception0が発生したとき、reraiseの引数では指定していないためキャッチせず、そのままException0が発生します。myfuncでException1か2が発生したとき、reraiseデコレータの中でキャッチし代わりにException3が投げられます。

今回の例では引数で受け取った例外をリレイズしますが、except句の中身を変更することでそこはよしなに変更できます。

ここまでのコードはgithubにもあげています。

github.com

感想

ググって出なかったので自分が記事にしましたが、例外クラスを引数にとる例外処理デコレータはさすがにやりすぎなのかもしれないです。 普通、try句に入れるのは例外が発生する可能性のある必要最低限のコードです。例外処理デコレータはその関数すべてをtryで囲っているようなものなので、気をつけて使わないと範囲が広すぎます。

# OK
def myfunc() {
    # 何かしらの処理
    try:
        may_raise_exception()
    except Exception1:
        print('Exception1 occured!')
    # 何かしらの処理
}

# OK
@exception1
def myfunc() {
    may_raise_exception()
}

# tryの範囲が広い
@exception1
def myfunc() {
    # 何かしらの処理
    may_raise_exception()
    # 何かしらの処理
}

参考