こんにちは、PR TIMESの開発部インターンの三宅です。PR TIMESではFAXを用いてプレスリリースの発信を行うことができます。今回、私はこれまで手動で送っていたFAXをWindows環境のC言語のCGIプログラムを作成し自動送信できる仕組みを開発しました。その内容について紹介します。
背景
PR TIMESには、「プレスリリースのFAX配信」というオプションサービスが用意されています。通常はメディアリストとして選定した各メディアにメールでプレスリリースを配信しています。そこにオプションサービスを付加することで、FAXでもリリース配信が行えるようになります。
FAXの送信はカスタマーリレーション部(CR)が手作業で送付しています。PR TIMESでのプレスリリースの配信数の増加に伴いCRの負担が増加していることが課題です。そこで、FAXの送付を自動で行えるようにするプロジェクトが立ち上がりました。
FAXを自動化することが現在社内管理画面の運用改善において重要になっています。しかし、社外のFAX APIサービスを利用することは金額面で厳しいという課題があります。また、当社は毎日大量のFAXを送信しており手動での対応に限界がありました。
弊社が利用しているFAXは、C言語でコンパイルされたバイナリと、公開されているヘッダーファイルのみを持つライブラリが付属しています。このライブラリを利用してFAXを自動化することができるのでは?と考えました。今回は、このライブラリを用いてFAXの送信を自動化するためのAPIを開発しました。
前提
前述の通り、FAXのベンダーから提供されているものはC言語のライブラリとヘッダーファイルのみです。それ以外は非公開のC言語のプログラムとなっています。そのため、実装が未知でありメモリリークの危険性もあり、そういった問題が起こっても私たちで対処できることはほとんどありません。さらに、このライブラリが使用できる環境がWindowsのみであり文字列や改行コードなどで考慮することがいくつもあります。
このような背景もあり、今回はC言語(一部C++の標準ライブラリ)を利用して、できる限りシンプルなCGIのプログラムを作成しました。そのCGIのプログラムをIISで実行することで、メンテナンスしやすく、メモリーリークなどライブラリ側に不具合があっても心配がない実装にできます。
設計
これ以降、今回実装するAPIをFAX HTTP APIと呼称します。FAX HTTP APIはIIS上のCGIとして動作させ、FAXの送信に必要なデータをリクエストで受け取り、送信を行なうFAXサーバーに要求を行なう責務を担います。なお、FAXサーバーに要求を行なってから実際にFAXが送信されるまでには一定の時間がかかります。そのため、FAXの送信ログを収集するプログラムも開発しました。
最初にシステムの概要図を示します。FAXサーバーは全部で4台あり、これらはオフィスのネットワークと接続されています。PR TIMESのアプリケーションとFAXサーバーは別のVPCで稼働しています。そのため、PrivateLinkを用いてFAX HTTP APIとFAXサーバーを接続しています。また、バッチサーバーは後で説明するログの収集プログラムを稼働させているサーバーです。

設計をする上での問題点
- 文字コードの扱い
FAXサーバーがCP932前提のプログラムを使っており、改行コードもCRLFを想定しています。しかし、PR TIMESのバックエンドではほとんどWindowsを使っておらず、UTF-8+LFで扱う箇所がほとんどです。実際に開発する中で、文字化けしたデータがFAX要求リクエストに入っていたこともあり、この辺りはかなり注意をする必要があります。 そのため、リクエストは基本的にUTF-8の文字コードで受け取り、内部でCP932として扱い、FAXサーバーに要求を行なうようにしました。なお、CP932とUTF-8で変換出来ない文字列が混入する可能性がありますが、CGI実装が複雑になるためリクエスト元でCP932に変換できない文字が含まれていないことを保証する仕様にしました。これらの検証は呼び出し元で行うこととしました。
- 安定性の問題
このAPIはベンダーが提供している非公開のC言語のプログラムを実行する必要があるため、我々には手出しできない箇所が多いです。そのため、原因不明の理由で失敗することが考えられます。ただし、CGI側で何らかの状態を保つようなことをすると実装が複雑になるためなるべく避けたいです。
今回はFAX HTTP API自体を安定させることは追求せずに呼び出し側にリトライ処理などを委ねる仕様としました。また、API全体を例外ハンドラで括って何らかのエラーが発生したときも全て呼び出し側に情報が渡せるような設計にしました。
- マルチスレッド処理が走らないようにする対策
APIはリクエストを受ける毎にプロセスが立ち上がりFAXの送信処理が行われます。ただ、ベンダーのライブラリが同時実行やマルチスレッドに対応していないと明記されています。これはそもそも、FAXの送信処理を同時に実行することに問題があると考えられるため、確実にロックを取る必要があります。今回はファイルロックを利用して同時実行を防ぐための対策を講じました。処理速度の面で課題のあるやり方ですが、呼び出し側がジョブキューなどを活用して適切に実行すれば問題ないのでこのような仕様としました。
実装
ファイルロックの実装
ファイルによる排他ロックはWindows APIの機能を使って実装します。ロックファイル名はハードコードしてしまい、どのプロセスでも同じファイル名になるようにしています。あとは、ロックが取得できるまでループで待機し、ロックを取得した後に送信処理を行います。CGI側でタイムアウトを設定しているので、一定時間が経過したらタイムアウトとなりプロセスも終了します。
// ロックファイルを開く
HANDLE hFile = CreateFile(lockFileName.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
std::cerr << "Unable to open or create lock file." << std::endl;
return 1;
}
// LockFileExを使用してファイルを排他ロック
OVERLAPPED overlapped = { 0 };
// ロックが取得できるまでループ
while (true) {
if (LockFileEx(hFile, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0, MAXDWORD, MAXDWORD, &overlapped)) {
std::cerr << "Lock file is locked." << std::endl;
break; // ロック成功
}
// ロック取得に失敗した場合、少し待機してから再試行
if (GetLastError() == ERROR_LOCK_VIOLATION) {
std::cerr << "Failed to lock the file. Retrying..." << std::endl;
Sleep(1000); // 1秒待機
}
else {
std::cerr << "Failed to lock the file. Error: " << GetLastError() << std::endl;
CloseHandle(hFile);
return 1;
}
}
// ロック成功、Fax処理を実行
exec_proc();リクエストデータの処理
FAX HTTP APIはリクエストとして、FAX送信用のTIFFファイルと宛先情報を含むJSONデータを受け取ります。TIFFファイルの作成はリクエスト元で行う仕様とし、APIでは受け取ったデータをFAXサーバーに送信する役割に特化しています。CGIのPOSTリクエストではヘッダーの情報は環境変数に格納され、ボディは標準入力に格納されます。今回のAPIではPOST以外は受け付けてないので、それ以外のHTTPメソッドだった場合はエラーを返します。
// REQUEST_METHODの取得
std::string request_method = std::getenv("REQUEST_METHOD");
if (request_method != "POST"){
// 省略
]
// CONTENT_TYPEの取得
char* contentType = getenv("CONTENT_TYPE");
// 省略
// CONTENT_LENGTHの取得
char* contentLengthStr = getenv("CONTENT_LENGTH");
// CONTENT_LENGTHの値を数値に変換
int contentLength = 0;
if (contentLengthStr != nullptr) {
contentLength = std::stoi(contentLengthStr);
}
リクエストではmultipart/form-data形式で受け取ります。multipart/form-dataは以下の様なデータ形式になっています。
リクエストヘッダー
Content-type: multipart/form-data; boundary=--prtimes-fax-data
リクエストボディ
boundary: --prtimes-fax-data
--prtimes-fax-data
Content-Disposition: form-data; name="body"
<CRLF>
// bodyの中身(JSON形式のデータ)
Content-Disposition: form-data; name="file" filename="<filename.tiff>"
Content-Type: image/tiff
<CRLF>
// fileの中身
--prtimes-fax-data-- //終端
今回はbodyにリクエストデータのJSON文字列、fileにFAX送信用TIFFファイルをセットされています。このような形式のデータが標準出力に格納されているので中身を一括で取得してパーサーで処理をしていきます。ファイルがバイナリで格納されているので、最初は標準入力をバイナリモードで受け取り全てをバイナリとして処理します。そして、JSONデータのパートになったら文字列として処理をすることで、文字列として必要なデータとバイナリを両方扱うことができます。
FAXの送信データですが、ファイルロックにより、同時実行を1つに制限し、一時ファイルは同一ファイル名で出力します。この設計により、POSTされた古いTIFFファイルがディスク上に残る問題を防ぎ、ディスク容量の枯渇を回避しています。FAXの送付に失敗したときは呼び出し元がリトライ処理を制御するので、FAXサーバーにデータを残す必要はありません。
最後に他の処理で影響がでるので、バイナリモードからテキストモードに戻しておきます。
int parseMultipartFormData(const std::string& boundary, std::istream& stream, FaxRequest& jsonData, std::string fileName, int contentLength) {
// バイナリモードを設定
_setmode(_fileno(stdin), _O_BINARY);
const int readSize = contentLength;
// バイナリモードで全データを読み込む
char* buffer = new char[readSize];
std::cin.read(buffer, sizeof(char) * readSize);
// バッファに取り込んだデータをstd::vector<char>に変換する
std::vector<char> data(buffer, buffer + readSize);
// マルチパートの境界を指定
std::string endBoundary = boundary + "--";
auto endBoundaryPos = findPattern(data.begin(), data.end(), endBoundary);
auto currentPos = data.begin();
while (currentPos != data.end()) {
auto boundaryPos = findPattern(currentPos, data.end(), boundary);
if (boundaryPos == data.end()) break; // 境界が見つからない場合は終了
// 次の境界を探す
auto nextBoundaryPos = findPattern(boundaryPos + boundary.length(), data.end(), boundary);
// boundaryが終端だったらパースを終了する
if (nextBoundaryPos == data.end()) break;
// ヘッダとペイロードを分離する
auto headerEndPos = findPattern(boundaryPos, nextBoundaryPos, "\\r\\n\\r\\n");
if (headerEndPos == nextBoundaryPos) break;
std::string header(boundaryPos, headerEndPos);
if (header.find("name=\\"body\\"") != std::string::npos) {
// JSONパートの処理
// ヘッダの終わりからデータ開始
auto jsonStart = headerEndPos + 1;
// 次の境界の手前まで
std::string jsonStr(jsonStart, nextBoundaryPos - 1);
// JSON文字列をパースして構造体に変換
jsonData = nlohmann::json::parse(jsonStr).get<FaxRequest>();
}
else if (header.find("name=\\"file\\"") != std::string::npos) {
// ファイルパートの処理
// ファイルの読み取り開始位置の指定
auto fileStart = headerEndPos + 4;
// ファイルに書き出す
std::ofstream outFile(fileName, std::ios::out | std::ios::binary);
if (!outFile) {
std::cerr << "failed to open file: " << fileName << std::endl;
return 1;
}
outFile.write(&(*fileStart), std::distance(fileStart, nextBoundaryPos - 2));
}
currentPos = nextBoundaryPos;
}
_setmode(_fileno(stdin), _O_TEXT);
delete[] buffer;
return 0;
}
FAXの送信処理
FAXの送信処理部分はベンダー提供のライブラリを使うためサンプルをお見せすることはできませんが、大まかな処理の流れをご紹介します。
- 送信データの文字列をUTF-8からShift-JISに変換する
- ベンダー提供のライブラリの仕様に合わせてデータを格納する
- FAXの送信要求処理を行なう
- 処理結果を返す
ライブラリの内部エラーも考慮し、API独自のエラーコードで管理しています。さらに、送信処理要求では任意のユーザー定義のプロパティを追加することもできます。こちらにログの収集や各種サポートで必要となる情報を付与しました。これにより、障害対応時の調査やサポートがより迅速に行えるようになります。
レスポンスの形式は成功・失敗問わず、ステータスコードと文字情報を含めるのでファクトリー関数でデータを作成しています。
// FAX送信のメインの処理を行う関数
int exec_proc() {
// 省略
// 標準入力に入ったCGIに対するPOSTリクエストのボディを解析して構造体と一時ファイルを作成
int code = prtimes::parseMultipartFormData(boundary, std::cin, req, fileName, contentLength);
if (code == 1) {
std::cout << prtimes::responseFactory("Failed to save temporary file", prtimes::STATUS_INTERNAL) << std::endl;
return 0;
}
else if (code > 0) {
std::cout << prtimes::responseFactory("Failed to save temporary file", prtimes::STATUS_INTERNAL) << std::endl;
return 0;
}
// ここからFAXの送信処理
// SEHはstd::runtime_errorに変換されるため、このtry句で全てキャッチできる
try {
// FAXの送信処理
code = prtimes::SendMultiFax(req, fileName);
}
catch (const std::runtime_error& e) {
code = prtimes::FAX_UNEXPECTED_ERROR;
std::cerr << e.what() << std::endl;
}
catch (const std::exception& e) {
code = prtimes::FAX_UNEXPECTED_ERROR;
std::cerr << "std::exception: " << e.what() << std::endl;
}
catch (...) {
code = prtimes::FAX_UNEXPECTED_ERROR;
}
if (code == prtimes::FAX_INIT_ERROR) {
std::cout << prtimes::responseFactory("Failed to init MpfApi", prtimes::STATUS_INTERNAL) << std::endl;
return 0;
}
else if (code == prtimes::FAX_CONNECT_ERROR) {
std::cout << prtimes::responseFactory("Failed to establish a fax server connection", prtimes::STATUS_INTERNAL) << std::endl;
return 0;
}
else if (code == prtimes::FAX_PROCESS_ERROR) {
std::cout << prtimes::responseFactory("Failed to send fax data", prtimes::STATUS_INTERNAL) << std::endl;
return 0;
}
else if (code == prtimes::FAX_FAILED_EXIT_PROCESS) {
std::cout << prtimes::responseFactory("Unexpected error has occured. But, fax process is success.", prtimes::STATUS_OK) << std::endl;
return 0;
}
else if (code == prtimes::FAX_UNEXPECTED_ERROR) {
std::cout << prtimes::responseFactory("some error has occured.", prtimes::STATUS_INTERNAL) << std::endl;
return 1;
}
std::cout << prtimes::responseFactory("success", prtimes::STATUS_OK) << std::endl;
return 0;
}FAXの送信情報をログで取得する
次にFAX HTTP APIで送信リクエストしたものが実際に送信されたか確認するプログラムについて説明します。
FAXのライブラリを使うと、FAXの送受信に関わる多くの情報を取得できます。今回は以下の様な構造体を定義して、FAXの送信情報やステータスなどを取得するようにしました。
struct FaxAnalytics {
std::string fax_number; // FAX送信番号
std::string company_name; // 宛先会社名
std::string company_depart; // 宛先会社の部署情報
std::string name; // FAX担当者名
std::string title; // FAX送信タイトル
int company_id; // 会社ID
int release_id; // リリースID
unsigned long log_id; // FAXの送信ジョブに割り当てられたユニークなID
char send_status; // FAXサーバーのエラーコード
std::string message; // エラーコードのメッセージ{送信正常終了, 送信異常終了, 送信処理中}
std::string ip_address; // FAX送信サーバーのIPアドレス
time_t received_at; // FAX処理受付日時
time_t started_at; // FAX送信処理開始日時
time_t ended_at; // FAX送信処理完了日時
};ログの収集プログラムではYYYY-MM-DD形式の文字列を引数で渡すことで、その日付のFAXの送信ログを取得します。以下にプログラムの記載しています。FAXのログ収集については外部のライブラリを使っているため詳細なコードはお見せできませんが、大まかには以下の流れで処理を行ないます。書き出したログファイルはBigQueryに転送してデータの可視化や監視を行います。
- コマンドライン引数を解析して処理対象日時を取得
- ライブラリを使って指定日時を含む送信ログを取得
- 取得したログから指定日時のデータを抽出して保存
int main(int argc, char* argv[]) {
// stderrをファイルにリダイレクト
auto result = freopen(prtimes::getLogfileName().c_str(), "a+", stderr);
std::cerr << "start process" << std::endl;
// ロケールを日本語に合わせる。マルチバイト文字の変換に影響するので必ず入れる。
// 基本的にはUTF-8での処理を行い、必要な個所で変換を行う。
std::wcout.imbue(std::locale("ja"));
// Windows独自の例外であるSEHのハンドラを定義。これがないとセグフォなどのシステムエラーがキャッチできない。
_set_se_translator(TranslateSEHtoCplusplusException);
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " <date in YYYY-MM-DD format>" << std::endl;
return 1;
}
std::string input_date(argv[1]);
std::tm tm = {};
std::istringstream ss(input_date);
ss >> std::get_time(&tm, "%Y-%m-%d");
if (ss.fail()) {
std::cerr << "Failed to parse date." << std::endl;
return 1;
}
// 指定された日付の00:00:00にする
tm.tm_hour = 0;
tm.tm_min = 0;
tm.tm_sec = 0;
std::time_t start_time = std::mktime(&tm);
if (start_time == -1) {
std::cerr << "Failed to convert time." << std::endl;
return 1;
}
// 23:59:59を求める
tm.tm_hour = 23;
tm.tm_min = 59;
tm.tm_sec = 59;
std::time_t end_time = std::mktime(&tm);
if (end_time == -1) {
std::cerr << "Failed to convert time." << std::endl;
return 1;
}
// FAXの送信ログにアクセスしてログを収集する
exec_log_proc(start_time, end_time);
return 0;
}
このプログラムをWindowsのタスクスケジューラーに登録し、FAXの稼働が少ない時間帯に送信ログを全て収集します。収集したログはGCSを経由してBigQueryにインポートしています。なお、WIndowsで作成されるログファイルはShift-JISでエンコーディングされているため、nkfを用いてUTF-8に変換してから転送しています。
TIFFファイルの作成について
TIFFファイルの作成はこれまで原稿PDFをベンダーから提供されたプログラムを用いて行なっていました。しかし、このライブラリは無圧縮のTIFFしか作成することができずPrivateLinkの転送量コストが大幅に増大する懸念があります。また、PDFからTIFFに変換する作業は人手に頼るため、ファイルを取り違えるなどのミスが発生する可能性もあります。 そこで技術調査を行なった結果、フォントが全て埋め込まれているPDFであれば、popplerとImageMagickの組み合わせで圧縮TIFFファイルを作成できることが分かりました。TIFFの作成は以下のコマンドで実行できます。
# 圧縮したTIFFファイル出力
% pdftoppm input.pdf output -rx 300 -ry 300
% convert output-1.ppm -compress Fax output1.tiff
# いくつかのTIFFファイルがある場合は結合する
convert output1.tiff output2.tiff combined.tiff
さらに、圧縮効率が非常に良いのでネットワーク転送量コストも大きく削減できることが分かりました。これにより、社内管理画面では原稿PDFをアップロードすると、TIFFファイルが自動的に作成されてFAX HTTP APIにデータを送信するようになりました。
ハマったところ
FAX HTTP APIを開発する中で、様々な問題が発生しました。ここで、いくつかのハマった点とその対応方法について紹介します。
ログ出力
CGIの出力は標準出力であるためprintfをしてしまうとログが全てレスポンスに表示されてしまいます。そのため、標準エラー出力にログを全て書き出してログファイルにリダイレクトすることでログを出力できるようにしました。これはfreopenを使うことで実現できます。さらに、ログファイル名に日付を付けることで日にち毎にログファイルを分けて出力します。
auto result = freopen(prtimes::getLogfileName().c_str(), "a+", stderr);例外処理
Windows環境でのC++ではSTLで定義されている例外とMicrosoftの拡張機能である構造化例外処理(SEH)があります。SEHはUnix系のOSにあるシグナルに相当する機能です。
これはC++の標準ライブラリとは異なるもので、C++のtry-catchではSEHをハンドリングできません。そこで、SEH例外にコールバック関数を定義し、C++の例外に変換するハンドラを実装しました。
// SEH例外をC++例外に変換する
// C++ではtryのネストができないため、SEH例外をC++例外に変換してcatchすることで一元的に扱えるようにします
void TranslateSEHtoCplusplusException(unsigned int u, EXCEPTION_POINTERS* pExp) {
std::stringstream ss;
ss << "SEH exception has occurred, error code: ";
// 例外コードを取得
auto code = pExp->ExceptionRecord->ExceptionCode;
ss << std::hex << code;
// std::runtime_errorにエラーメッセージを渡して投げる
throw std::runtime_error(ss.str());
}
// 省略
// Windows独自の例外であるSEHのハンドラを定義。これがないとセグフォなどのシステムエラーがキャッチできない。
_set_se_translator(TranslateSEHtoCplusplusException);
このハンドラを_set_se_translatorで登録することでC++のruntime_errorに変換して同じように処理することができるようになります。
NLBの負荷分散が設定されておらず特定のサーバーに負荷が集中した
今回は事前のテストで大量のFAXを送信することが出来なかったため、実運用に入らないと顕在化しない問題が発生することがあります。FAX HTTP APIの運用が始まって発覚したことですが、10月31日頃からFAXの送信が集中する時間帯に、FAXの送信が完了するまでの時間が長くなっている問題が発生しました。
この問題はNLBのクロスロード負荷分散がオフになっていたことが原因でした。NLBにはクロスゾーン負荷分散という複数AZに跨いでトラフィックを分散する機能があります。しかし、デフォルトではオフになっています。この設定を有効化することで特定のサーバーにリクエストが集中する問題は解消しました。
参考:https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/network/load-balancer-access-logs.html
終わりに
今回はC言語とCGIを用いて大量のFAXの送信を自動化するためのAPIを実装しました。大量のFAXを自動的に送信する例はそれほど多くなく、ベンダーの仕様などさまざまな制約がありましたが何とか実装することができました。
このシステムは10月の終わりから本番稼働を開始し、現在まで安定してFAXの送信を行うことが出来ています。これまでは1日に大量のFAXを手動で送っていたので、その大部分を自動で行えるようになり社内の業務改善に貢献できたと自負しています。今後も社内の業務改善やPHPのレガシーコード改善などを頑張って行きたいと思います!


