おれおれWebアプリフレームワークを勢いで(しかもPHPで)書いてみた
はじめに
いったい、一日に何個のフレームワークが生まれているのだろう。そんな疑問が浮かぶほど、PHPによる (Webアプリ) フレームワークの数は尋常じゃない*1。
そして、PHPほどプログラマにフレームワークを書かせる気にさせる言語も他にはないんじゃないだろうか。元々PHP自体がフレームワークにスクリプト言語インタプリタを足したものなんだから本末転倒だ、いや、そんなことは百も承知。「俺様のフレームワークで『その駄目な』PHPをやりこめてやった!」という征服感と「俺様のフレームワークはPHPで書かれているのに、まるでRubyの○○で書いたみたいになるし、しかも△△ができるんだぜフハハ!」というとこの、ほかのフレームワークに対する優越感、これを創造的行為を通じて同時に味わえるという、オーガズムに富んだ過程、その産物、それがPHPのフレームワークといっても過言じゃない (かなり偏見)。
ま、というわけでEthnaのViewClassはいるのかいらないのかを確かめようとか思いたって、勢いでやってみた。
追記:コード読んだ人なら分かると思うけど「書いてみた」という割には未完です。
ore.php
<?php $_SCHEMA = array(); $_DATA = array(); $_ERROR = array(); $_OUTPUT = ''; $_PAGE = ''; $_CONFIG = array( 'message_file' => 'messages', 'base_dir' => dirname(__FILE__), 'script_indent' => ' ', ); class o_i18n { var $_; function o_i18n($str) { $this->_ = $str; } function __toString() { global $_MESSAGE; return isset($_MESSAGE[$this->_]) ? $_MESSAGE[$this->_]: $this->_; } } class o_error { var $_fmt; var $_params; function o_error($fmt, $params) { $this->_fmt = $fmt; $this->_params = $params; } function __toString() { return vsprintf(o::to_string($this->_fmt), $this->_params); } } class o_cli { function main($argv) { array_shift($argv); switch (array_shift($argv)) { case 'update_messages': o_cli::update_messages(); break; case 'generate_message': $lang = $argv[0]; o_cli::generate_message($lang); break; } } function generate_message($lang) { global $_CONFIG; $files = o_cli::find_script_files(array($_CONFIG['base_dir'])); $msg_files = o_cli::find_message_files(array($_CONFIG['base_dir'])); $files = array_diff($files, $msg_files); $messages = array(); foreach ($files as $file) { $tmp = o_cli::extract_messages($file); if ($tmp === false) { fwrite(STDERR, "Failed to read {$file}.\n"); return 1; } $messages += $tmp; } $msg_file = $_CONFIG['message_file']. '.'. $lang . $_CONFIG['script_suffix']; if (o_cli::generate_message_file($messages, $msg_file)) { fwrite(STDERR, "Failed to generate {$msg_file}.\n"); return 1; } return 0; } function generate_message_file($messages, $msg_file) { global $_CONFIG; $tmp_file = "{$msg_file}.tmp"; $fh = fopen($tmp_file, 'w'); if ($fh === false) return false; $err = 0; do { $_MESSAGE = array(); @include($msg_file); $indent = $_CONFIG['script_indent']; if (!fwrite($fh, "<?php\n\$_MESSAGE = array(\n")) { $err = 1; break; } $_messages = $_MESSAGE; foreach ($messages as $str => $locs) { foreach ($locs as $loc) { if (!fwrite($fh, "{$indent}// {$loc[0]}:{$loc[1]}\n")) { $err = 1; break; } } $val = isset($_MESSAGE[$str]) ? $_MESSAGE[$str]: NULL; if (!fwrite($fh, "{$indent}\"" . addcslashes($str, "\x00..\x1f\x7f"). "\"\n" . "{$indent}{$indent}=> " . ($val !== NULL ? "\"" .addcslashes($val, "\x00..\x1f\x7f"). "\"": 'NULL') . ",\n")) { $err = 1; break; } unset($_messages[$str]); } foreach ($_messages as $str => $val) { if (!fwrite($fh, "{$indent}/* \"" . addcslashes($str, "\x00..\x1f\x7f"). "\"\n" . "{$indent}{$indent}=> " . ($val !== NULL ? "\"" .addcslashes($val, "\x00..\x1f\x7f"). "\"": 'NULL') . ", */\n")) { $err = 1; break; } unset($_messages[$str]); } fwrite($fh, ");\n?>\n"); } while (0); fclose($fh); if (!$err) { copy($tmp_file, $msg_file); unlink($tmp_file); } return $err; } function update_messages() { global $_CONFIG; $files = o_cli::find_script_files(array($_CONFIG['base_dir'])); $msg_files = o_cli::find_message_files(array($_CONFIG['base_dir'])); $files = array_diff($files, $msg_files); $messages = array(); foreach ($files as $file) { $tmp = o_cli::extract_messages($file); if ($tmp === false) { fwrite(STDERR, "Failed to read {$file}.\n"); return 1; } $messages += $tmp; } foreach ($msg_files as $msg_file) { if (o_cli::generate_message_file($messages, $msg_file)) { fwrite(STDERR, "Failed to update {$msg_file}.\n"); return 1; } } return 0; } function get_include_path() { return explode(':', ini_get('include_path' )); } function find_script_files($path) { $retval = array(); $traversed_dirs = array(); foreach ($path as $dir) { o_cli::_find_files($retval, $traversed_dirs, array(__CLASS__, '_is_script_file'), $dir); } return $retval; } function find_message_files($path) { $retval = array(); $traversed_dirs = array(); foreach ($path as $dir) { o_cli::_find_files($retval, $traversed_dirs, array(__CLASS__, '_is_message_file'), $dir); } return $retval; } function _is_script_file($file) { global $_CONFIG; if (!is_readable($file)) return false; $info = pathinfo($file); return @$info['extension'] == substr($_CONFIG['script_suffix'], 1); } function _is_message_file($file) { global $_CONFIG; if (!is_readable($file)) return false; $info = pathinfo($file); return @$info['extension'] == substr($_CONFIG['script_suffix'], 1) && strncmp($info['basename'], $_CONFIG['message_file'], strlen($_CONFIG['message_file']) - 1) == 0; } function _find_files(&$retval, &$traversed_dirs, $predicate, $dir) { global $_CONFIG; $dir = realpath($dir); $dh = opendir($dir); if ($dh === false) return false; $traversed_dirs[] = $dir; while (($entry = readdir($dh)) !== false) { if ($entry == '.' || $entry == '..') continue; $_entry = $dir. '/'. $entry; if (in_array($_entry, $traversed_dirs)) continue; $st = lstat($_entry); if ($st['mode'] & 040000) { o_cli::_find_files($retval, $traversed_dirs, $predicate, $_entry); } else { if (call_user_func($predicate, $_entry)) $retval[] = $_entry; } } closedir($dh); return $retval; } function extract_messages($src) { $messages = array(); $tokens = token_get_all(file_get_contents($src)); $state = 0; $paren = 0; $line = 1; foreach ($tokens as $token) { if (is_array($token)) { switch ($token[0]) { case T_ENCAPSED_AND_WHITESPACE: case T_WHITESPACE: case T_INLINE_HTML: case T_OPEN_TAG: case T_CLOSE_TAG: case T_COMMENT: case defined('T_DOC_COMMENT') ? T_DOC_COMMENT: '': $line += substr_count($token[1], "\r") + substr_count($token[1], "\n") - substr_count($token[1], "\r\n"); break; case T_STRING: if ($state == 0) { $state = 1; } else if ($state == 2) { if ($token[1]{0} == '_') $state = 3; else $state = 0; } break; case T_PAAMAYIM_NEKUDOTAYIM: if ($state == 1) $state = 2; else $state = 0; break; case T_CONSTANT_ENCAPSED_STRING: $line += substr_count($token[1], "\r") + substr_count($token[1], "\n") - substr_count($token[1], "\r\n"); if ($state == 3 && $paren == 1) { $str = stripcslashes(substr($token[1], 1, -1)); if (!isset($messages[$str])) $messages[$str] = array(); $messages[$str][] = array($src, $line); } } } else { if ($state == 3) { switch ($token) { case '(': case '[': case '{': ++$paren; break; case ')': case ']': case '}': --$paren; } if (!$paren) $state = 0; } } } return $messages; } } class o_base { // {{{ i18n related function &_($str) { $retval = &new o_i18n($str); return $retval; } // }}} // {{{ schema builders function &_error() { $args = func_get_args(); $fmt = array_shift($args); $retval = &new o_error(o::_($fmt), $args); return $retval; } function text() { return array('repr_type' => 'text'); } function radio() { $choices = array_chunk(func_get_args(), 2); return array( 'repr_type' => 'radio', 'choices' => $choices, 'default' => $choices[0][0] ); } function password() { return array('repr_type' => 'text', 'password' => true); } function chars($max_chars = 255, $required = 0) { return array( 'data_type' => 'string', 'max_chars' => $max_chars, 'required' => $required ); } function required() { return array( 'required' => 1 ); } function email($max_chars = 255) { // XXX: tekitou return array( 'data_type' => 'string', 'regex' => '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$/', 'regex_error_message' => o::_('Enter a valid e-mail address.'), 'max_chars' => $max_chars ); } function int_num($min = false, $max = false) { return array( 'data_type' => 'integer', 'range' => array($min, $max) ); } function _name($humanized_name) { return array( 'humanized_name' => o::_($humanized_name) ); } // }}} // {{{ utility functions function to_string($_) { if (is_object($_)) $_ = $_->__toString(); return $_; } function dirname($str) { return preg_replace('#/+[^/]*/*$#', '', $str); } function path_suffix($str) { return preg_match('#[^.](\.[^/]+)$#', $str, $cap) ? $cap[1]: false; } function t($file) { global $_OUTPUT; ob_start(); include($file); $_OUTPUT = ob_get_clean(); } function h($_) { return htmlspecialchars(o::to_string($_), ENT_QUOTES); } function redirect($url) { global $_OUTPUT; $_OUTPUT = ''; header('Location: '. o::build_url(o::absolute_url(o::parse_url($url)))); } function form($schema, $fs, $verb = false) { if ($verb === false) $verb = o::_('Submit'); if ($_SERVER['REQUEST_METHOD'] == 'POST') o::fetch_data($schema, $fs); return array('form', 'name' => $fs, 'method' => 'post', 'enctype' => 'multipart/form-data', array( o::repr_fieldset($schema, $fs), array('div', 'class' => 'form-footer', array( array('input', 'type' => 'submit', 'value' => $verb ) ) ) ) ); } function parse_url($url) { return parse_url($url); } function absolute_url($url) { static $base_url = false; if ($base_url === false) { global $_CONFIG; $base_url = o::parse_url($_CONFIG['base_url']); } return o::merge_url($base_url, $url); } function merge_url($base_url, $url) { return array( 'scheme' => isset($url['scheme']) ? $url['scheme']: @$base_url['scheme'], 'host' => isset($url['host']) ? $url['host']: @$base_url['host'], 'port' => isset($url['port']) ? $url['port']: @$base_url['port'], 'user' => isset($url['user']) ? $url['user']: @$base_url['user'], 'pass' => isset($url['pass']) ? $url['pass']: @$base_url['pass'], 'path' => isset($url['path']) ? $url['path']: @$base_url['path'], 'query' => isset($url['query']) ? $url['query']: @$base_url['query'], 'fragment' => isset($url['fragment']) ? $url['fragment']: @$base_url['fragment'] ); } function build_url($url) { $retval = ''; if (isset($url['scheme'])) { $retval = $url['scheme']. ':'; if (isset($url['host'])) { if (isset($url['user'])) { $retval .= $url['user']; if (isset($url['pass'])) $retval .= ':'. $url['pass']; $retval .= '@'; } $retval .= $url['host']; if (isset($url['port'])) $retval .= ':'. $url['port']; if (isset($url['path'])) { if ($url['path']{0} != '/') return false; } else { $retval .= '/'; } } } if (isset($url['path'])) $retval .= $url['path']; if (isset($url['query'])) $retval .= '?'. $url['query']; if (isset($url['fragment'])) $retval .= '#'. $url['fragment']; } function parse_accept_language($str) { $locs = array(); foreach (preg_split('/ *, */', $str) as $v) { $var = array(); $var_reprs = preg_split('/ *; */', $v); $loc = array_shift($var_reprs); foreach ($var_reprs as $var_repr) { list($key, $val) = explode('=', $var_repr); $var[$key] = $val; } $loc = strtolower($loc); if (!preg_match('/[^a-z_-]/', $loc)) $locs[$loc] = (isset($var['q']) ? (double)$var['q']: (double)1.0); } return $locs; } // }}} // {{{ renderers function r($data) { switch (gettype($data)) { case 'array': return o::render_tag($data); case 'object': return o::r(o::repr_object($data)); default: return o::h($data); } } function render_tag($tag_repr) { $tag_name = $tag_repr[0]; $retval = '<'. $tag_name; $children = false; foreach ($tag_repr as $name => $value) { if (is_int($name)) { if (is_array($value)) { $children = $value; } continue; } $retval .= ' '. $name . '="'. o::h($value). '"'; } if (!$children) { $retval .= ' />'; } else { $retval .= '>'; $last = array_pop($children); foreach ($children as $idx => $_tag_repr) { $retval .= o::r($_tag_repr); } if (is_array($last) && count($children)) { $last['class'] = isset($last['class']) ? $last['class']. ' last': 'last'; } $retval .= o::r($last); $retval .= '</'. $tag_name. '>'; } return $retval; } // {{{ object renderers function repr_object($obj) { $disp = 'repr_object_'. get_class($obj); return is_callable(array(__CLASS__, $disp)) ? o::$disp($obj): o::to_string($obj); } function repr_object_o_error($obj) { return array('span', 'class' => 'error-message', array(o::to_string($obj)) ); } // }}} // }}} // {{{ form handlers function handle_field_param($type, $name, $aux, $params) { $disp = 'handle_field_param_'. $type; return o::$disp($name, $aux, $params); } function handle_field_param_text($name, $aux, $params) { $val = (string)@$params[$name]; $retval = array($val); $len = strlen($val); if ($len < (int)@$aux['required']) $retval[] = $aux['required'] > 1 ? o::_error("At lease %d characters are required for this field.", $aux['required']): o::_error("This field is required"); if (isset($aux['max_chars']) && $len > $aux['max_chars']) $retval[] = o::_error( "This field may not be longer than %d characters.", $aux['max_chars']); if (isset($aux['regex']) && !preg_match($aux['regex'], $val)) $retval[] = isset($aux['regex_error_message']) ? new o_error($aux['regex_error_message'], array($aux['regex'])): o::_error('Does not match the pattern %s.', $aux['regex']); return $retval; } function handle_field_param_radio($name, $aux, $params) { if (!isset($params[$name])) return array($aux['choices'][0][0]); $val = (string)@$params[$name]; $retval = array($val); if (!in_array($val, array_map('array_shift', $aux['choices']))) $retval[] = o::_error('Invalid value (%s) submitted for the choices.', $val); return $retval; } function get_params_for_fieldset($fs) { return (array)@$_REQUEST[$fs]; } function fetch_data($schema, $fs) { global $_SCHEMA, $_DATA, $_ERROR; $params = o::get_params_for_fieldset($fs); $data = array(); foreach ($_SCHEMA[$schema] as $key => $field) { $value_and_errors = o::handle_field_param( $field['repr_type'], $key, $field, $params ); $data[$key] = array_shift($value_and_errors); if ($value_and_errors) { if (!isset($_ERROR[$fs])) $_ERROR[$fs] = array(); $_ERROR[$fs][$key] = $value_and_errors; } } $_DATA[$fs] = $data; } // }}} // {{{ form builder function decorate_field_repr($ctx, $repr) { global $_ERROR; $id = o::build_field_id($ctx['fieldset'], $ctx['key']); $error_reprs = array('div', 'class' => 'errors', (array)$_ERROR[$ctx['fieldset']][$ctx['key']] ); $repr['id'] = $id; return array('div', 'class' => 'form-field'. ($error_reprs[1] ? ' error': ''), array( array('label', 'for' => $id, array($ctx['field']['humanized_name'], ':')), $repr, $error_reprs[1] ? $error_reprs: '', ) ); } function decorate_fieldset_repr($ctx, $repr) { $repr['class'] = 'form-fieldset'; return $repr; } function repr_field($type, $name, $aux, $value) { $disp = "repr_field_{$type}"; return o::$disp($name, $aux, $value); } function repr_field_text($name, $aux, $value) { $repr = array('input', 'type' => @$aux['password'] ? 'password': 'text', 'name' => $name, 'value' => $value ); if (isset($aux['max_chars'])) $repr['maxlength'] = $aux['max_chars']; return $repr; } function repr_field_radio($name, $aux, $value) { $choice_reprs = array(); foreach ($aux['choices'] as $choice) { $choice_repr = array('input', 'type' => 'radio', 'name' => $name, 'value' => $choice[0] ); if ($value == $choice[0]) $choice_repr['checked'] = 'checked'; $choice_reprs[] = $choice_repr; $choice_reprs[] = $choice[1]; } return array('div', 'class' => 'form-radio-group', $choice_reprs ); } function repr_fieldset($schema, $fs) { global $_SCHEMA, $_DATA; $ctx = array( 'schema' => $schema, 'fieldset' => $fs ); $tags = array(); if (isset($_DATA[$fs])) { $data = $_DATA[$fs]; } else { foreach ($_SCHEMA[$schema] as $key => $field) $data[$key] = (string)@$field['default']; } foreach ($_SCHEMA[$schema] as $key => $field) { $tags[] = o::decorate_field_repr( $ctx + array('key' => $key, 'field' => $field), o::repr_field( $field['repr_type'], o::build_field_name($fs, $key), $field, $data[$key] ) ); } return o::decorate_fieldset_repr( $ctx, array('div', 'id' => o::build_fieldset_id($fs), $tags ) ); } // }}} // {{{ functions to override when you need to change the form building behavior function build_field_id($fs, $key) { return "field-{$fs}-{$key}"; } function build_field_name($fs, $key) { return "{$fs}[{$key}]"; } function build_fieldset_id($fs) { return "fieldset-{$fs}"; } // }}} function initialize() { global $_CONFIG, $_MESSAGE; register_shutdown_function(array(__CLASS__, 'send_output')); $locs = o::parse_accept_language($_SERVER['HTTP_ACCEPT_LANGUAGE']); arsort($locs); foreach ($locs as $loc => $g) { if (@include_once( $_CONFIG['message_file']. '.'. strtr($loc, '-', '_'). $_CONFIG['script_suffix'])) break; } } function send_output() { global $_OUTPUT; echo $_OUTPUT; } } function bootstrap() { if (!class_exists('o')) eval('class o extends o_base {}'); o::initialize(); } if (isset($_SERVER['REQUEST_METHOD'])) { // XXX: urlencode? $_CONFIG['base_url'] = (isset($_SERVER['HTTPS']) ? 'https://': 'http://') . strtok($_SERVER['HTTP_HOST'], ':') . ($_SERVER['SERVER_PORT'] == (isset($_SERVER['HTTPS']) ? 443: 80) ? '': ':'. $_SERVER['SERVER_PORT']) . o_base::dirname($_SERVER['PHP_SELF']); $_CONFIG['script_suffix'] = o_base::path_suffix($_SERVER['PHP_SELF']); } else { $tmp = pathinfo($_SERVER['argv'][0]); $_CONFIG['script_suffix'] = '.'. $tmp['extension']; o_cli::main($_SERVER['argv']); } // vim: fdm=marker ?>
いちおう説明
こいつを適当なディレクトリに放り込んで、次にスキーマ schema.php を作成。
schema.php
<?php $_SCHEMA['user'] = array( 'name' => o::_name('Name') + o::text() + o::chars(32, 1), 'email' => o::_name('E-mail Address') + o::text() + o::email(), 'gender' => o::_name('Gender') + o::radio(1, o::_('Male'), 2, o::_('Female'), 0, o::_('Prefer not to answer')) ); ?>
ま、見れば大体何を表してるか分かると思う。で、次にアクションを実行するスクリプト form.php を同じ場所に作成。
form.php:
<?php require_once 'ore.php'; bootstrap(); require_once 'schema.php'; o::t('templates/form.php'); ?>
とりあえずのCSSもそこに置く。
default.css:
body { font-family: Lucida Grande, Verdana, Helvetica, Arial; } form .form-fieldset { } form .form-field { overflow: hidden; margin: 0 0 1px 0; padding-bottom: 8px; } form .form-field.last { padding-bottom: 0; } form .form-field label { display: block; float: left; width: 25%; font-weight: bold; } form .form-field .errors { display: block; clear: left; margin-left: 25%; color: #800000; font-weight: bold; } form .form-field.error { background-color: #ffcccc; } form .form-footer { padding-top: 8px; }
で、templates/ というディレクトリを掘ってそこに form.php (まぎらわしい) を置く。
templates/form.php:
<html> <head> <title>test</title> <link type="text/css" rel="stylesheet" href="default.css" /> </head> <body> <?php echo o::r(o::form('user', 'user')); ?> </body> </html>
ここでブラウザから form.php にアクセスすると次のような画面が出て、とりあえずバリデーションとかも適当に動くことが確認できるはず。ちなみにPHP4でも5でも動くので、まあ、あれですな。
メッセージが英語になってるのは次でメッセージカタログを説明するから。
さっきのディレクトリを作業ディレクトリとしておいて、シェルで
$ php ore.php generate_message ja
とかやると messages.ja.php ができるのでこいつを適当に訳せば OK。
messages.ja.php (生成直後):
<?php $_MESSAGE = array( // /home/moriyoshi/src/ore/schema.php:3 "Name" => NULL, // /home/moriyoshi/src/ore/schema.php:6 "E-mail Address" => NULL, // /home/moriyoshi/src/ore/schema.php:9 "Gender" => NULL, // /home/moriyoshi/src/ore/schema.php:10 "Male" => NULL, // /home/moriyoshi/src/ore/schema.php:11 "Female" => NULL, // /home/moriyoshi/src/ore/schema.php:12 "Prefer not to answer" => NULL, // /home/moriyoshi/src/ore/ore.php:366 "Enter a valid e-mail address." => NULL, // /home/moriyoshi/src/ore/ore.php:420 "Submit" => NULL, // /home/moriyoshi/src/ore/ore.php:598 "At lease %d characters are required for this field." => NULL, // /home/moriyoshi/src/ore/ore.php:599 "This field is required" => NULL, // /home/moriyoshi/src/ore/ore.php:602 "This field may not be longer than %d characters." => NULL, // /home/moriyoshi/src/ore/ore.php:606 "Does not match the pattern %s." => NULL, // /home/moriyoshi/src/ore/ore.php:616 "Invalid value (%s) submitted for the choices." => NULL, ); ?>
messages.ja.php (翻訳後):
<?php $_MESSAGE = array( // /home/moriyoshi/src/ore/schema.php:3 "Name" => "名前", // /home/moriyoshi/src/ore/schema.php:6 "E-mail Address" => "メールアドレス", // /home/moriyoshi/src/ore/schema.php:9 "Gender" => "性別", // /home/moriyoshi/src/ore/schema.php:10 "Male" => "男性", // /home/moriyoshi/src/ore/schema.php:11 "Female" => "女性", // /home/moriyoshi/src/ore/schema.php:12 "Prefer not to answer" => "回答しない", // /home/moriyoshi/src/ore/ore.php:366 "Enter a valid e-mail address." => "正しいメールアドレスを入力してください。", // /home/moriyoshi/src/ore/ore.php:420 "Submit" => "送信", // /home/moriyoshi/src/ore/ore.php:598 "At lease %d characters are required for this field." => "少なくとも%d文字入力してください。", // /home/moriyoshi/src/ore/ore.php:599 "This field is required" => "必須項目です。", // /home/moriyoshi/src/ore/ore.php:602 "This field may not be longer than %d characters." => "%d文字以内にしてください。", // /home/moriyoshi/src/ore/ore.php:606 "Does not match the pattern %s." => "パターン %s に合致しません", // /home/moriyoshi/src/ore/ore.php:616 "Invalid value (%s) submitted for the choices." => "不正な値 (%s) が選択肢に対して与えられました", ); ?>
これで、ページをリロードすると日本語になってるはず。
TODO
- エンコーディングとか考えないとダメ
結論
ViewClass云々の話に到達するのはまだまだ先の話っぽい。また暇な時に気力があれば続きを書く。というかRhacoいいよね。