knowledge base

マークアップ/フロントエンドエンジニアのWEB制作における備忘録です。平日はWEB屋、休日は社会人劇団の主宰・劇作家をしています。

特定のディレクトリを除外して、ファイル一覧を取得する

単純なオプションの指定だけで良いかと思いきや、そうでもありませんでした。 下記の「avoid_this_directory_please」に除外したいディレクトリのパスを代入します。

find . -type d -name 'avoid_this_directory_please' -prune -o -type f -print

下記で解説されていますが、スニペットとして扱うのが良さそうです。

mollifier.hatenablog.com

特定の文字列を含む、特定の拡張子を持つファイル一覧の取得と、ファイル数の取得

特定の文字列を含むファイル一覧の取得

grep -ril "文字列" ディレクトリのパス

特定の拡張子のファイルを検索対象に絞り込むには「–include」オプションを追加

grep -ril --include="*.html" "文字列" ディレクトリのパス

grep」コマンドの詳細はこちらを参照

www.ibm.com

ファイル数の取得

結果の一覧をカウントするには、パイプで「wc」コマンドに結果を連携します。

grep -ril --include="*.html" "文字列" ディレクトリのパス | wc -l

「wc」コマンドの詳細はこちらを参照

www.ibm.com

gulpでShift_JISのファイルを生成する(Gitでの文字化け対策も行う)

きっかけ

先日、とあるプロジェクトにてShift_JISでHTMLやCSSを作成しなければいけないという場面がありました。

ただそれだけではなく、そのファイルは近いうち(半年から1年後)にUTF-8にして公開しなければいけないとのこと。

エディタの文字コードShift_JISにしただけでは、のちのちUTF-8のものを公開する際に、わざわざ文字コードのコンバート作業が発生してしまいます。

VS Codeでコーディングをする際にもわざわざ毎回Shift_JISに変換してファイルを開き直す必要もあり、そこで、UTF-8で元ファイルを作成して、それをgulpを利用してShift_JISに変換して出力するようにしようと思いました。

HTML

gulp-convert-encoding というモジュールを利用します。

こちらはUTF-8からShift_JISに変換してくれるものです。

また、gulp-replaceを使用して、head内にあるcharsetの表記を置換します。

合わせて、gulp-cr-lf-replace というモジュールも併用して、改行コードも正規化しておきます。

var gulp = require('gulp');
var replace = require('gulp-replace');
var convertEncoding = require('gulp-convert-encoding');
var crLfReplace = require('gulp-cr-lf-replace');

gulp.task('html', function(){
	gulp.src('./html/**/**/*.html')
		.pipe(crLfReplace({changeCode: 'CR+LF'}))  //改行コード変換
		.pipe(replace(/meta charset="utf-8"/g, 'meta charset="shift_jis"')) //charsetの表記を置換
		.pipe(convertEncoding({to: "shift_jis"})) //sjis変換
		.pipe(gulp.dest('../'));
});    

SCSS/CSS

HTMLと同様の方法でコンパイル後にShift_JISに変換します。

var gulp = require('gulp');
var sass = require('gulp-sass');
var header = require('gulp-header');
var replace = require('gulp-replace');
var sassUnicode = require('gulp-sass-unicode');
var convertEncoding = require('gulp-convert-encoding');
var crLfReplace = require('gulp-cr-lf-replace');

gulp.task('scss', function() {
	gulp.src('./scss/**/**/*.scss', { base: './scss/'})
		.pipe(sassUnicode())
		.pipe(replace(/@charset "(Shift_JIS|UTF-8)";/g, ''))
		.pipe(header('@charset "shift_jis";\n\n'))
		.pipe(convertEncoding({to: "shift_jis"}))  //sjis変換
		.pipe(gulp.dest('../assets/css/'));
});   

以前の記事を参考に、CSSファイルの先頭にcharsetの記述を追加したり、擬似要素のcontentプロパティで用いられる数値文字参照が文字化けしない処理も追加しています。

shinimae.hatenablog.com

Gitとの連携

ところが、いざShift_JISで出力したファイルの差分を、Gitクライアントツールを通して見ると、文字化けを起こしてしまいます。

こちらを参考にして、Gitの設定を少しばかり編集します。

qiita.com

.gitattributes

*.html diff=sjis

.git/config

[diff "sjis"]
    textconv = iconv -f sjis

こうすることで、Gitクライアントツールでも文字化けすることなく、開発を行うことができました。

【PWA】シリーズPWA (10) クライアントからAPIにリクエストを送って通知を受け取る

クライアント側の実装

クライアント側は、基本的に前回までで実装したAPIを叩きにいくように変更します。

サブスクリプション の登録(と古いものがあれば削除)

前回の実装では、サブスクリプションの情報をconsoleに出力するだけでしたが、実際にサブスクリプションの情報をリクエストヘッダーに乗せてAPIを叩きます。

まずmain.jsにおいてこのようになっていた箇所を、

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		return registration.pushManager.subscribe({
			userVisibleOnly: true,
			applicationServerKey: convertedVapidKey
		}).then(function (subscription) {
			 var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
			 var key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
			 var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
			 var auth = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
			 var endpoint = subscription.endpoint;
			 console.log(key);
			 console.log(auth);
			 console.log(endpoint);
		});
	})
});

このように書き換えます。

var sendSubscription = function(subscription){
	var data = {};
	var latestID = localStorage.getItem('latestID');
	var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
	data.key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
	var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
	data.auth = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
	data.endpoint = subscription.endpoint;
	data.registrationID = data.endpoint.substring(data.endpoint.lastIndexOf('/')+1, data.endpoint.length);
	localStorage.setItem('latestID', data.registrationID);

	// 新しいサブスクリプションを通知対象に登録する
	fetch('https://*******/add',{
		method: 'POST',
		headers: {
			'Content-Type': 'application/json'
		},
		mode: 'cors',
		body: JSON.stringify(data)
	})
	.then(function(response) { return response.text(); })
	.then(function(res){ console.log(res); });

	// 古いサブスクリプションがあれば通知対象から削除する
	if (latestID) {
		fetch('https://*******/delete',{
			method: 'POST',
			headers: {
				'Content-Type': 'application/json'
			},
			mode: 'cors',
			body: JSON.stringify({ registrationID: latestID})
		})
		.then(function(response) { return response.text(); })
		.then(function(res){ console.log(res); });
	}
}

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		return registration.pushManager.subscribe({
			userVisibleOnly: true,
			applicationServerKey: convertedVapidKey
		}).then(function (subscription) {
			sendSubscription(subscription);
		});
	})
});

サブスクリプションの情報をJSON形式にしてリクエストヘッダーに持たせています。

また、最新のregistrationIDを常にlocalStorageで管理し、古いものがあれば(つまりlocalStorageに格納されたregistrationIDがあれば)そのIDは削除するようAPIを叩いています。

push用ページを作成する

pushについては任意の通知内容を設定できるように専用のフォームページを作成し、そのフォームがsubmitされたタイミングで、pushするためのAPIを叩きに行くようにします。 仮にファイル名をpush.htmlとします。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form method="POST" id="form">
	Title <input type="text" name="title">
	Message <input type="text" name="message">
	<button type="submit">Push</button>
</form>
<script>
document.querySelector('#form').addEventListener('submit', function(e){
	e.preventDefault();
	var title = document.querySelector('#form').querySelector('[name="title"]').value;
	var message = document.querySelector('#form').querySelector('[name="message"]').value;
	var data = { title: title, message: message };
	fetch('https://******/push',{
		method: 'POST',
		headers: {
			'Content-Type': 'application/json'
		},
		mode: 'cors',
		body: JSON.stringify(data)
	})
	.then(function(response) {
		return response.text();
	})
	.then(function(res){
		console.log(res)
	})
}, false)
</script>
</body>
</html>

push.htmlにアクセスし、テキストフィールドに任意の値を入力してボタンを押せば、入力した内容で通知が送られてくるはずです。

【PWA】シリーズPWA (9) APIを実装する

Expressが提供するAPI自体を実装します。

基本的には第7回で紹介した内容を、Expressの文法に落とし込んでゆく感じになります。

ここで登場するsubscriptionsという変数には前回でSqualizeによって作成されたORマッパーのオブジェクト(DBにデータの追加や削除や他にもごにょごにょできるオブジェクト)が格納されています

add (レコードの追加)

app.post('/add', function(req, res) {
	let data = req.body;
	let response = res;
	subscriptions.create({
		registrationID: data.registrationID,
		publicKey: data.key,
		auth: data.auth,
		endpoint: data.endpoint
	}).then(function(){
		response.setHeader('Content-Type', 'text/plain');
		response.end('New subscriptions is successfully added.');
	});
});

delete(レコードの削除フラグをTRUEに)

クライアントからは削除するregistrationIDがわたってくる想定です。

app.post('/delete', function(req, res) {
	let data = req.body;
	subscriptions.update({
		deleted: true
	},{
	    where: {
	        registrationID: data.registrationID
	    }
	});
});

push

ここは、これまでに登場したロジックを再利用することができます。

app.post('/push', function(req, res) {
	let response = res;
	let data = req.body;
	let subscribers = [];

	// 通知の内容
	const params = {
	    title: data.title,
	    msg: data.message,
	    icon: '/path/to/icon'
	};

	webpush.setVapidDetails(
	    'admin@xxx.jp',
	    'Firebase Consoleから取得した公開鍵',
	    'Firebase Consoleから取得した秘密鍵'
	);

	subscriptions.findAll({
	    where: {
	        deleted : false
	    }
	}).then(function(rows){
		// 利用可能なサブスクリプションを配列に追加
		rows.forEach(function (row) {
			let subscription = {};
			subscription.endpoint = row.endpoint;
			subscription.expirationTime = null;
			subscription.keys = {
				p256dh: row.publicKey,
				auth: row.auth
			};
			subscribers.push(subscription);
		});
		// 利用可能なサブスクリプション全てに対して通知を発信
		Promise.all(subscribers.map(subscription => {
		    return webpush.sendNotification(subscription, JSON.stringify(params), {});
		}))
		.then(function(res){
			response.setHeader('Content-Type', 'text/plain');
			response.end('Request data is successfully pushed.');
		})
		.catch(function(err){
			console.log('ERROR', err);
		});
	});
});

これらを合わせると以下のようになります。

const express = require('express');
const webpush = require('web-push');
const bodyParser = require('body-parser');
const Sequelize = require('sequelize');
const app = express();

// CORSを許可する
app.use(function(req, res, next) {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
    next();
});

// urlencodedとjsonは別々に初期化する
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// DBに接続
const sequelize = new Sequelize('接続文字列', {
	logging: false,
	operatorsAliases: false
})

// テーブルを作成
const subscriptions = sequelize.define('subscriptions', {
	id: {
		type: Sequelize.INTEGER,
		autoIncrement: true,
		primaryKey: true
	},
	registrationID: {
		type: Sequelize.STRING,
		allowNull: false
	},
	publicKey: {
		type: Sequelize.STRING,
		allowNull: false
	},
	auth: {
		type: Sequelize.STRING,
		allowNull: false
	},
	endpoint: {
		type: Sequelize.STRING,
		allowNull: false
	},
	deleted: {
		type: Sequelize.BOOLEAN,
		allowNull: false,
		defaultValue: false
	}
}, {
	freezeTableName: true,
	timestamps: true,
	indexes:[{
		unique: false,
		fields:['deleted']
	}]
});

subscriptions.sync();

// ルートにアクセスされたら文字列を表示
app.get('/', function(req, res) {
	res.setHeader('Content-Type', 'text/plain');
	res.send('This is PWA Test App');
});

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
	let data = req.body;
	let response = res;
	subscriptions.create({
		registrationID: data.registrationID,
		publicKey: data.key,
		auth: data.auth,
		endpoint: data.endpoint
	}).then(function(){
		response.setHeader('Content-Type', 'text/plain');
		response.end('New subscriptions is successfully added.');
	});
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
	let data = req.body;
	subscriptions.update({
	    deleted: true
	},{
	    where: {
            registrationID: data.registrationID
	    }
	});
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
	let response = res;
	let data = req.body;
	let subscribers = [];

	// 通知の内容
	const params = {
	    title: data.title,
	    msg: data.message,
	    icon: '/path/to/icon'
	};

	webpush.setVapidDetails(
	    'admin@xxx.jp',
	    'Firebase Consoleから取得した公開鍵',
	    'Firebase Consoleから取得した秘密鍵'
	);

	subscriptions.findAll({
	    where: {
	        deleted : false
	    }
	}).then(function(rows){
		// 利用可能なサブスクリプションを配列に追加
		rows.forEach(function (row) {
			let subscription = {};
			subscription.endpoint = row.endpoint;
			subscription.expirationTime = null;
			subscription.keys = {
				p256dh: row.publicKey,
				auth: row.auth
			};
			subscribers.push(subscription);
		});
		Promise.all(subscribers.map(subscription => {
		    return webpush.sendNotification(subscription, JSON.stringify(params), {});
		}))
		.then(function(res){
			response.setHeader('Content-Type', 'text/plain');
			response.end('Request data is successfully pushed.');
		})
		.catch(function(err){
			console.log('ERROR', err);
		});
	});
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

次回予告

以上で、サブスクリプション をDBで管理し、有効なものにだけ通知を発信する仕組みができました。 続いて、このプログラムを叩く、クライアント側の実装を行います。

【PWA】シリーズPWA (8) 複数の端末に通知を送る

ここまでで、任意の内容でプッシュ通知を送ることはできました。

ところが現状では、今後複数のサブスクリプションが増えた時に対応ができません。

push.jsは配列にて複数のサブスクリプションを送れるようになっているため、サブスクリプション が増えること自体は問題ありません。

ところが現状push.jsにハードコーディングされているため、増えれば増えるほどpush.jsに追記をしていかなければいけません。

そこでここからさらに一歩踏み込み、サブスクリプションの情報を永続的に管理できる様に変えてゆきます。

基本的にはこちらで解説されているように、DBを利用してユーザー管理をする要領でサブスクリプション を管理します。

ajike.github.io

今回は、このデータベースとの連携部分を実装してゆきます。 サブスクリプションの登録・更新・プッシュの機能があるので、APIとして機能を提供できるようにします。

  1. push.jsを別環境に切り離してWebアプリ化し、登録・更新・プッシュのAPIを公開。今回はフレームワークとしてExpressを利用。
  2. APIを通じて受け取ったサブスクリプションの情報をDBで管理する。今回はPostgreSQLを使用。
  3. クライアントからはサブスクリプションの情報をヘッダーに含めて、push.jsにリクエストを送るようにする。

まずは、APIを提供するところから始めます。

ExpressでAPIを提供

まずはpush.jsを以下のような骨組みを以下のようにします。

ExpressというWebアプリケーションフレームワークを使用しますので、npmにてインストールください。

expressjs.com

const express = require('express');
const app = express();

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

Expressの文法は割愛しますが、node push.jsでサーバーが立ち上がるので、その環境(ローカルだとhttp://localhost:8000になります)に対して「/add」「/delete」「/push」というURLでアクセスが会ったときにそれぞれ何かしらの処理を割り振ることができるようになりました。

アクセス時のリクエストヘッダーにサブスクリプションの情報が入っているため、reqオブジェクトのbodyを取得できるようにしなければいけません。

ところがExpressの仕様によると現状はbodyを取得するためにはいちどパーサーを通さないとundefinedになってしまうため、以下の記述を追加します。

const express = require('express');
const bodyParser = require('body-parser');
const app = express();

// CORSを許可する
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

// urlencodedとjsonは別々に初期化する
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
  console.log(req.body);
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
  console.log(req.body);
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
  console.log(req.body);
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

こうすることでリクエストヘッダーのbodyを取得できるようになります。

また、クライアントからはfetch APIを使ってリクエストを投げるようにする予定なので、今のうちにCORSエラーが起きないようにしておきます。

こちらで(1)の実装は完了しました。

テーブル定義

DBとの連携部分を実装する前に、テーブル定義を行います。

以下のようなテーブルでサブスクリプションのデータを管理します。

id registrationID publicKey auth endpoint deleted
INTEGER
AUTO INCREMENT
TEXT
NOT NULL
TEXT
NOT NULL
TEXT
NOT NULL
TEXT
NOT NULL
BOOLEAN
NOT NULL
デフォルトはFALSE
主キー 通知許可時にクライアントから送られる値を格納 同左 同左 同左 この値によって有効なサブスクリプションかどうかを判別

そして通知送信時にはdeletedカラムがFALSEのものに対して通知を送るため、このカラムに対してインデックスを生成します。

DB作成・DB接続・テーブル作成

DBはプログラムでは作成せず、あらかじめPostgreSQLをインストールし、「subscriptions」という名前でDBを作成します。

その前提で、DBに接続してテーブルを作成するところからプログラムで実装します。

生のSQLを発行しても良いのですが、ここではDBごとの方言を吸収できるよう、SequalizeというORマッパーを利用します。

www.npmjs.com

sequelize.org

const express = require('express');
const bodyParser = require('body-parser');
const Sequelize = require('sequelize');
const app = express();

// CORSを許可する
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

// urlencodedとjsonは別々に初期化する
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// DBに接続
const sequelize = new Sequelize('接続文字列', {
	logging: false,
	operatorsAliases: false
})

// テーブルを作成
const subscriptions = sequelize.define('subscriptions', {
	id: {
		type: Sequelize.INTEGER,
		autoIncrement: true,
		primaryKey: true
	},
	registrationID: {
		type: Sequelize.STRING,
		allowNull: false
	},
	publicKey: {
		type: Sequelize.STRING,
		allowNull: false
	},
	auth: {
		type: Sequelize.STRING,
		allowNull: false
	},
	endpoint: {
		type: Sequelize.STRING,
		allowNull: false
	},
	deleted: {
		type: Sequelize.BOOLEAN,
		allowNull: false,
		defaultValue: false
	}
}, {
	freezeTableName: true,
	timestamps: true,
	indexes:[{
		unique: false,
		fields:['deleted']
	}]
});

subscriptions.sync();

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

こちらでサーバー起動時にDBに接続し、テーブルが存在しなかったらテーブルを作成するようになりました。

次回予告

次回は、サブスクリプションを登録・削除、そしてpushするAPIを作成します。

特定のディレクトリ配下のデフォルトの文字コードを変更する

.htaccessに以下を記述。
今回はShift-JISにする想定です。

AddDefaultCharset shift-jis
AddType "text/html; charset=shift-jis" .html .php

php_value default_charset               Shift_JIS
php_value mbstring.language             Japanese
php_value mbstring.http_input           auto
php_value mbstring.http_output          SJIS
php_value mbstring.internal_encoding    Shift_JIS