AngularからPHPサーバにアクセス
Vue.jsと比較をするために、前回までAngularの公式のチュートリアルに則してアプリを作成しながらその手法を学んできました。チュートリアルアプリは完成しましたが、データ送受信はエミュレーターによるものでしたので、ここではApache+PHPサーバーとの連携をして、最後にVue.jsとの比較をして一旦Angularの学習を終えたいとおもいます。
app.modules.tsの修正
in-memory Web APIが稼働している場合、何も設定しないとすべてのアドレスにin-memory Web APIが応答してしまいます。そのためapp.modules.tsで、InMemoryDataServiceに対する設定にpassThruUnknownUrl: trueを加えます。
app.modules.ts
HttpClientInMemoryWebApiModule.forRoot(
InMemoryDataService, { dataEncapsulation: false, passThruUnknownUrl: true }
)
hero.service.tsの修正
hero.service.tsでは主に次のような修正をします。
- DBのアドレス修正
URL中のcollectionの値をheroesのままで使用してしまうとアドレス階層が同じ場合、先のURLのパススルー設定をしても、In-Memory Web APIのデータを取得してしまいます。PHP側ではCollectionの値を参照しないので、heroes以外の名前にしておきます。
- post時のヘッダとデータ
「Content-Type」で指定する値を「application/json」から 「application/x-www-form-urlencoded」に変更します。そうしないとPHPで$_POSTの値として取得できません。
また、POSTを受信するPHPの挙動に合わせてheroオブジェクトをJSON.stringifiで文字列にして、jsonという変数にセットして送信しています。
逆にPHPの$_POSTでの取得にこだわらなければ、Content-Typeをapplication/jsonにした状態で、PUTメソッドの際と同様にjson_decode(file_get_contents('php://input'))でJSON.stringifiや変数の設定等なしにheroオブジェクトをそのまま受け取れます。
hero.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { Hero } from './hero';
import { MessageService } from './message.service';
@Injectable({ providedIn: 'root' })
export class HeroService {
//In-Memory Web APIの場合はapi/heroesにする
//ドメイン以下の階層数がIn-Memory Web APIと同じ場合
//collection名をheroesにするとIn-Memory Web APIが応答するのでheroesxにしています。
private heroesUrl = 'http://localhost/api/heroesx';
// Content-Typeを変えます。
// PHP => application/x-www-form-urlencoded,
// In-Memory Web API => application/json
httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
};
constructor(
private http: HttpClient,
private messageService: MessageService) { }
/** サーバーからヒーローを取得する */
getHeroes(): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(heroes => this.log('fetched heroes')),
catchError(this.handleError<Hero[]>('getHeroes', []))
);
}
/** IDによりヒーローを取得する。idが見つからない場合は`undefined`を返す。 */
getHeroNo404<Data>(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/?id=${id}.get`;
return this.http.get<Hero[]>(url)
.pipe(
map(heroes => heroes[0]), // {0|1} 要素の配列を返す
tap(h => {
const outcome = h ? `fetched` : `did not find`;
this.log(`${outcome} hero id=${id}`);
}),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/** IDによりヒーローを取得する。見つからなかった場合は404を返却する。 */
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get<Hero>(url).pipe(
tap(_ => this.log(`fetched hero id=${id}`)),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
/* 検索語を含むヒーローを取得する */
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
// 検索語がない場合、空のヒーロー配列を返す
return of([]);
}
const url = `${this.heroesUrl}/?name=${term}`;
return this.http.get<Hero[]>(url).pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
//////// Save methods //////////
/** POST: サーバーに新しいヒーローを登録する */
addHero(hero: Hero): Observable<Hero> {
const param = "json="+JSON.stringify(hero);
return this.http.post<Hero>(this.heroesUrl, param, this.httpOptions).pipe(
tap((newHero: Hero) => this.log(`added hero w/ id=${newHero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
/** DELETE: サーバーからヒーローを削除 */
deleteHero(hero: Hero | number): Observable<Hero> {
const id = typeof hero === 'number' ? hero : hero.id;
const url = `${this.heroesUrl}/${id}`;
return this.http.delete<Hero>(url, this.httpOptions).pipe(
tap(_ => this.log(`deleted hero id=${id}`)),
catchError(this.handleError<Hero>('deleteHero'))
);
}
/** PUT: サーバー上でヒーローを更新 */
//PUTの時は保存されたテキストデータをJSONでparseするので、POST時のようなパラメータの変換は不要。
updateHero(hero: Hero): Observable<any> {
return this.http.put(this.heroesUrl, hero, this.httpOptions).pipe(
tap(_ => this.log(`updated hero id=${hero.id}`)),
catchError(this.handleError<any>('updateHero'))
);
}
/**
* 失敗したHttp操作を処理します。
* アプリを持続させます。
* @param operation - 失敗した操作の名前
* @param result - observableな結果として返す任意の値
*/
private handleError<T>(operation = 'operation', result?: T) {
return (error: any): Observable<T> => {
// TODO: リモート上のロギング基盤にエラーを送信する
console.error(error); // かわりにconsoleに出力
// TODO: ユーザーへの開示のためにエラーの変換処理を改善する
this.log(`${operation} failed: ${error.message}`);
// 空の結果を返して、アプリを持続可能にする
return of(result as T);
};
}
/** HeroServiceのメッセージをMessageServiceを使って記録 */
private log(message: string) {
this.messageService.add(`HeroService: ${message}`);
}
}
Apacheの設定
PHPサーバーのコードを記述する前に、Apacheのmod_rewriteの設定をします。
この機能を使ってURL中のapiより後の文字列をパラメータとして使えるようにします。
また、apacheでは.htaccessファイルを使って様々な機能を提供していますが、フォルダ毎に存在するそのファイルが親の設定をオーバーライドできるようにも設定を変更します。
httpd.conf
...#コメントアウトを解除します。
LoadModule rewrite_module modules/mod_rewrite.so
...
<Directory />
#Overrideを許可します。
AllowOverride all
Require all denied
</Directory>
<Directory "${SRVROOT}/htdocs">
...
AllowOverride all
...
mod_rewriteは、RewriteCondで条件を提示して、パターンに一致したらその後のRewriteRuleにあるようにURLを書き換えます。これは内部処理だけで行われブラウザに表示されるアドレスは変わりません。
この機能を使って、アドレス内にパラメータを持たせるのと同時にアドレスに.phpという拡張子がなくてもアクセスできるように修正します。
RewriteCondの書式は、%{変数名} パターン [フラグ]となります。
RewriteRuleの書式は、パターン 書き換え後のパターン [フラグ]となります。
変数名には%{REQUEST_URI}などのapacheの変数を指定します。パターンは正規表現を用います。
RewriteCondのフラグには条件をORにするORや、大文字小文字の区別をしないNCがあり、RewriteRuleのフラグにはそこで処理を止めるLやリダイレクトをするR(=でレスポンスコードも指定できます)などがあります。複数設定する際は,で並べます。
.htaccessファイルは次のようになります。
.htaccess
# 拡張子のないアクセスからapi.phpファイルを読ませるようにします。
RewriteRule ^api$ api.php [L]
# api以降にアドレスがついていてもapi.phpを読ませるようにします。
RewriteRule ^api/.*$ api.php [L]
# phpを付けたアクセスを禁止させます。(オプション)
RewriteCond %{THE_REQUEST} "^[^ ]* .*?¥.php[? ].*$"
RewriteRule .* - [L,R=404]
PHPのコード
URLの構造はチュートリアルと変わらず:base/:collection/:idとなっていますが、PHPサーバではbaseやcollectionの値は利用しません。
idの値は、$_SERVER['REQUEST_URI']にあるRewriteRuleで変換する前のURLを使って取得します。
HTTPリクエストメソッドは$_SERVER['REQUEST_METHOD']変数に入っています。
全体としては次のようなコードにしました。
api.php
<?php
//DBがなかったら作成
if (file_exists('api.db')===FALSE) {
makeMockDb();
}
//通常処理
main();
function main() {
$formalParam=array();//正式なパラメータ格納用
/* パラメータ取得処理
* [param] URLをパラメータとして処理する
* 0:空白
* 1:api固定
* 2:コレクション名
* 3:ID
*
* [qparam] ?以降で取得されるもの
* name: 検索時の名前
*/
//?以降を取得する
$qparamLine = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY);
//?の位置を取得して存在すればそれ以前をアドレスとする。
$intQ = strpos($_SERVER['REQUEST_URI'],'?');
if ($intQ === FALSE) {
$params =explode('/',$_SERVER['REQUEST_URI']);
} else {
$params =explode('/',substr($_SERVER['REQUEST_URI'],0,$intQ-1));
}
//ゆるいアドレスチェック
if (count($params) < 3) {
makeResultError();
}
$formalParam['collection']=$params[2];//コレクション名 この例では使っていません。
$formalParam['id']=-1;
$formalParam['search']="";
//検索用パラメータ取得
parse_str($qparamLine, $qparams);
if (isset($qparams['name'])) {
$formalParam['search'] = $qparams['name'];
}
//32bit環境でもエラーにならないように9桁までの制限をしています。
if (isset($params[3])) {
if (preg_match('/^[0-9]{1,9}$/',$params[3])===1) {
$formalParam['id']=intval($params[3]);
} else {
//idが存在してルール外だったらエラー(空白のみ許可)
if ($params[3]!='') {
makeResultError();
}
}
}
//メソッド別の処理
switch(strtolower($_SERVER['REQUEST_METHOD'])) {
case 'put':
//更新
$json = json_decode(file_get_contents('php://input'),TRUE);
if (isset($json['id']) && isset($json['name'])) {
put($json['id'],$json['name']);
}
break;
case 'delete':
//削除 アドレスからIDを取得する
if ($formalParam['id'] < 0) {
makeResultIdName(0,"error");
} else {
delete($formalParam['id']);
}
break;
case 'post':
//新規
if (isset($_POST['json']) == FALSE) {
makeResultError();
}
$json = json_decode($_POST['json'],TRUE);
if (isset($json['name'])) {
post($json['name']);
} else {
makeResultError();
}
break;
case 'get':
//取得
get($formalParam['id'],$formalParam['search']);
break;
case 'options':
//プリフライトリクエストに対してダミーデータを返す
//404を返すとプリフライトリクエストがエラーで終了するため
makeResultIdName(0,'dummy');
break;
default:
makeResultError();
}
}
function delete($id) {
//削除して削除したデータを返す
$data = readDb();
for ($i = 0; $i < count($data); $i++) {
if ($data[$i]['id']==$id) {
$dname = $data[$i]['name'];
array_splice($data,$i,1);
writeDb($data);
makeResultIdName($id,$dname);
}
}
makeResultIdName(0,"error");
}
function post($name) {
//登録して登録したデータを返す
$data = readDb();
$intMax = 0;
for ($i = 0; $i < count($data); $i++) {
if ($intMax < $data[$i]['id']) {
$intMax = $data[$i]['id'];
}
}
$add=array();
$add['id']=$intMax+1;
$add['name']=$name;
$data[]=$add;
writeDb($data);
makeResultIdName($add['id'],$add['name']);
}
function put($id,$name) {
//更新して更新したデータを返す
$data = readDb();
for ($i = 0; $i < count($data); $i++) {
if ($data[$i]['id']==$id) {
$data[$i]['name']=$name;
writeDb($data);
makeResultIdName($data[$i]['id'],$data[$i]['name']);
}
}
makeResultIdName(0,"error");
}
function get($id,$search) {
//表示
$data = readDb();
if ($search==='') {
if ($id < 0) {
//全件表示
makeResult($data);
} else {
//id指定表示
for ($i = 0; $i < count($data); $i++) {
if ($data[$i]['id']==$id) {
makeResult($data[$i]);
}
}
echo makeResultIdName(0,"error");
exit;
}
} else {
//検索モード
$ret = array();
for ($i = 0; $i < count($data); $i++) {
if (mb_strpos($data[$i]['name'],$search) !== FALSE) {
$ret[] = array('id' => $data[$i]['id'],'name' => $data[$i]['name']);
}
}
makeResult($ret);
}
}
function makeResultError() {
//エラーを返して終了
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');
header('Access-Control-Allow-Methods: GET, HEAD, POST, DELETE, PUT, OPTIONS');
header("HTTP/1.1 404 Not Found");
exit;
}
function makeResultIdName($id,$name) {
//結果を返して終了
$array = array();
$array['id']=$id;
$array['name']=$name;
makeResult($array);
}
function makeResult($array) {
//結果を返して終了
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');
header('Access-Control-Allow-Methods: GET, HEAD, POST, DELETE, PUT,OPTIONS');
echo json_encode($array);
exit;
}
function readDb() {
//DBから一覧取得
return json_decode(file_get_contents("api.db"),TRUE);
}
function writeDb($data) {
//DBに反映
file_put_contents("api.db",json_encode($data));
}
function makeMockDb() {
//テスト用DB作成
$mock= array (
array('id'=>101,'name'=>'徳川'),
array('id'=>102,'name'=>'豊臣'),
array('id'=>103,'name'=>'織田'),
array('id'=>104,'name'=>'宮本'),
array('id'=>105,'name'=>'武田'),
array('id'=>106,'name'=>'佐々木'),
array('id'=>107,'name'=>'坂田')
);
file_put_contents("api.db",json_encode($mock));
}
CORSに起因するエラー
PHPサーバのヘッダ出力の設定をせず、デバッグ環境のAngularのアプリからPHPサーバへアクセスすると次のJavaScriptのエラーが出て機能しないと思います。これはCORSに関連したブラウザのセキュリティ面での挙動によるものです。
筆者の環境では次のようなエラーが出ました。
最初に読み込んだページとスクリプトがアクセスするサイトとで、オリジン(ドメイン+ポート)が違ったり、GET、HEAD、POST以外のHTTPリクエストメソッドを利用していたりすると、CORSポリシーによるチェックが働きます。スクリプトがアクセスするサーバーが適切なヘッダを返さないとエラーとなります。
サーバーは「Access-Control-Allow-」から始まるヘッダを返すことで、該当のアクセスが正しいものだとクライアントのブラウザに伝えます。
特殊なヘッダを使っていたりする場合はプリフライトという事前チェックの為の通信が発生します。この時のHTTPリクエストメソッドはOPTIONSで、このアクセスに対して適切なレスポンスを返さないとブラウザは本来送るはずのデータを送信をしません。
これらのエラーを回避するために、ここでは、PHPサーバーで次のようなheaderを出力するような設定をしています。これらはテストサーバー用の設定ですので本番ではそのまま使わないでください。
PHP
//許可するオリジンの設定ここではすべて(*)のオリジンとしています
header('Access-Control-Allow-Origin: *');
//許可するヘッダ
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');
//許可するメソッド
header('Access-Control-Allow-Methods: GET, HEAD, POST, DELETE, PUT,OPTIONS');
AngularとVue.jsの比較
- コンポーネントの構造
Vue.jsではシングルファイルコンポーネントという概念があり、ひとつのファイル内でHTML、Script、CSSを記述しました。
一方のAngularではそれぞれ単独でファイルを持っています。
ひとつの方がわかりやすい、分かれていた方がわかりやすいというのは好みの問題でしょう。
言い換えるとコードが長くなるとわかりにくくなる、たくさんファイルがあるとわかりにくくなる、というトレードオフの関係にあると思うのですが、その欠点はどちらの場合でも開発環境によりヘルパーが用意されています。
- TypeScript
TypeScriptのインターフェースや型定義はコードの間違いを減らせることを身をもって知りました。
- インラインコンポーネント
CDNとちょっとしたコードで簡単なSPAを作ることができる点は捨てがたいVue.jsの長所です。
- Laravelとの相性
PHPのフレームワークであるLaravelとの相性はVue.jsの方が優れています。
- Google
Angularの開発にはGoogleが携わっています。