博客 web 框架简介

2015-07-29 23:33:28   最后更新: 2015-09-25 12:06:01   访问数量:1215




经过几周的重构,博客在结构上、编码风格上日趋完善,虽然在前端显示上还有着明显的不足,后端也有着很多需要进一步改进和完善的地方,但是作为一个简单、初步的 php web 框架来说,已经初具雏形了,所以打算在这篇日志里将这个简单、小巧的 web 框架做一个介绍,以图抛砖引玉,欢迎吐槽

总的来说,做一个网站是很简单的一件事,一个文件里写上一句 Hello World,浏览器里输入 localhost 就可以看到页面,然而,对一个网站来说,静态页面显然是无法满足需求的,而使用 cgi 程序动态加载所需要的数据,生成相应页面的动态页面才是一个网站构建的正常手段,然而,使用 php 来说,直接使用 http://xx.xx.xx/xxx.php?params1=a&params2=b 这样的形式又是非常丑陋和不安全的

综上,自己写一套简单的 web 框架的出发点正是优化上述 URL 访问所带来的丑陋与不安全性,同时,增强代码结构化

 

博客代码见:

https://github.com/zeyu203/techlog

 

整套博客的目录结构如下:

 

. ├── app │ ├── config.json │ ├── log │ └── register.php ├── controller │ ├── ArticleController.php │ ├── DebinController.php │ ├── EarningsController.php │ ├── IndexController.php │ ├── InfosController.php │ ├── MsgchkController.php │ ├── NoteController.php │ ├── PicturesController.php │ └── SearchController.php ├── draft ├── library │ ├── Controller.php │ ├── Dispatcher.php │ ├── ESRepository.php │ ├── HttpCurl.php │ ├── LogOpt.php │ ├── Repository.php │ ├── SphinxClient.php │ ├── SqlRepository.php │ ├── StringOpt.php │ └── TechlogTools.php ├── model │ ├── ArticleModel.php │ ├── ArticleTagRelationModel.php │ ├── BooknoteModel.php │ ├── CategoryModel.php │ ├── EarningsModel.php │ ├── ImagesModel.php │ ├── MoodModel.php │ ├── StatsModel.php │ └── TagsModel.php ├── README.md ├── tools │ ├── article_creater.php │ ├── build_model.php │ ├── contents_loader.php │ ├── diary_loader.php │ ├── earnings_loader.php │ ├── mood_loader.php │ ├── note_loader.php │ └── revise_article.php ├── views │ ├── article │ ├── base │ ├── debin │ ├── index │ ├── infos │ ├── note │ ├── pictures │ └── search └── web ├── app.php ├── .htaccess ├── resource -> /home/zeyu/Documents/resource/ └── stats

 

 

整个结构分为以下目录:

  • app -- 配置文件及分发器
  • web -- 网站入口地址
  • draft -- 草稿箱
  • controller -- 网页操作对象
  • model -- 数据库操作对象
  • views -- 页面代码
  • tools -- 博客创建、修改、model 生成、图片添加等工具
  • library -- 公共工具类

 

出于网站安全方面考虑,也是为了整个结构的清晰,网站入口位于 web 目录下

目录下存放了 Apache rewrite 文件 .htaccess:

<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ app.php [QSA,L] </IfModule>

 

 

将所有 URI 不存在的 URL 全部 rewrite 到了 app.php 文件中,通过 app.php 分发请求实现了整个网站的访问

 

<?php require_once(__DIR__.'/../app/register.php'); Dispatcher::getInstance()->dispatch(); ?>

 

 

这段代码很简单,首先加载了注册文件(require all files),然后创建了分发器实例并调用分发函数实现分发

 

注册器用来 require 全局引用的文件,加载全部的类:

<?php define('APP_PATH', __DIR__); define('WEB_PATH', __DIR__.'/../web'); define('LIB_PATH', __DIR__.'/../library'); define('VIEW_PATH', __DIR__.'/../views'); define('DRAFT_PATH', __DIR__.'/../draft'); define('MODEL_PATH', __DIR__.'/../model'); define('RESOURCE_PATH', __DIR__.'/../resource'); define('CONTROLLER_PATH', __DIR__.'/../controller'); require_once(LIB_PATH.'/StringOpt.php'); require_once(LIB_PATH.'/LogOpt.php'); require_once(LIB_PATH.'/Controller.php'); require_once(LIB_PATH.'/Repository.php'); require_once(LIB_PATH.'/Dispatcher.php'); require_once(LIB_PATH.'/TechlogTools.php'); require_once(LIB_PATH.'/SphinxClient.php'); require_once(LIB_PATH.'/SqlRepository.php'); ini_set('date.timezone','Asia/Shanghai'); $controller_list = array( 'ArticleController', 'IndexController', 'DebinController', 'MsgchkController', 'NoteController', 'InfosController', 'SearchController', 'EarningsController', 'PicturesController', ); $model_list = array( 'ArticleModel', 'ArticleTagRelationModel', 'BooknoteModel', 'CategoryModel', 'EarningsModel', 'ImagesModel', 'MoodModel', 'TagsModel', 'StatsModel', ); require_once (LIB_PATH.'/'.'Controller.php'); foreach ($controller_list as $controller) require_once(CONTROLLER_PATH.'/'.$controller.'.php'); foreach ($model_list as $model) require_once(MODEL_PATH.'/'.$model.'.php'); ?>

 

只需要在数组中加入对应的 controller 和 model 即可自动加载相应的文件,想要禁止某些 controller 和 model 的访问也只需删除相应数组成员即可,实现了高度的扩展性

全局加载了哪些文件也可以一目了然,同时避免了多次 require 的问题

 

上文已经介绍,整个网站加载的第一步就是调用 Dispatcher 分发请求,那么他是如何做的呢?

<?php class Dispatcher { private $loader = null; public static $rewriteRule = array( array( 'pattern' => '/^\/html\/?(index\.php)?$/i', 'replace' => '/', ), array( 'pattern' => '/^\/favicon.ico/', 'replace' => '/resource/images/favicon.ico', ), array( 'pattern' => '/^.*\/images\/(.+)$/i', 'replace' => '/resource/images/$1', ), array( 'pattern' => '/^.*article\.php\?id=(\d+)$/i', 'replace' => '/article/list/$1', ), array( 'pattern' => '/^.*debin\.php\?category=0\&tags=icon_tag_(\d+)$/i', 'replace' => '/debin/tag/$1/1', ), array( 'pattern' => '/^.*debin\.php\?category=(\d+)$/i', 'replace' => '/debin/category/$1/1', ), array( 'pattern' => '/^.*note\.php$/i', 'replace' => '/note', ), ); private function __construct() { global $controller_list; global $model_list; define('URI', isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''); define('REDIRECT', isset($_SERVER['REDIRECT_URL']) ? $_SERVER['REDIRECT_URL'] : ''); define('HTTP_HOST', strtolower($_SERVER['HTTP_HOST'])); } public static function getInstance($debug = null) { if (!empty($debug)) { ini_set('display_errors', 1); error_reporting(E_ALL); } else { ini_set('display_errors', 0); } if (!empty($loader)) return $loader; $loader = new self; return $loader; } public function dispatch() { $this->rewrite(); list($class, $func, $params) = $this->parseUrl(); $obj_exsists = false; if (class_exists($class)) { $obj = new $class(); if (method_exists($obj, $func)) { $obj->$func($params); $obj_exsists = true; } else if (method_exists($obj, $func.'Ajax')) { $func = $func.'Ajax'; if (!$this->checkAjax()) { echo '{"code":1, "msg":"ERROR: MUST BE AN AJAX REQUEST"}'; exit; } $obj->$func($params); $obj_exsists = true; } } if (!$obj_exsists) { header("Location: /index/notfound"); exit; } } protected function checkAjax() { if(isset($_SERVER["HTTP_X_REQUESTED_WITH"]) && strtolower($_SERVER["HTTP_X_REQUESTED_WITH"]) == "xmlhttprequest" ) return true; return false; } protected function rewrite() { foreach (self::$rewriteRule as $rule) { $count = 0; $result = preg_replace( $rule['pattern'], $rule['replace'], URI, -1, $count ); if ($count) { header('HTTP/1.1 301 Moved Permanently'); header('Location: '.$result); exit; } } } protected function parseUrl() { $pattern = '/^\/'.'(?<class>[^\/?]+)?'.'\/?' .'(?<func>[^\/?]+)?'.'\/?' .'(?<params>[^\/?]+(\/[^\/?]+)*)?'.'/is'; if (preg_match($pattern, URI, $uri_infos) == false) { header('Location: /index/notfound'); exit; } $uri_infos['class'] = isset($uri_infos['class']) ? ucfirst(strtolower($uri_infos['class'])).'Controller' : 'IndexController'; $uri_infos['func'] = isset($uri_infos['func']) ? strtolower($uri_infos['func']).'Action' : 'listAction'; $uri_infos['params'] = isset($uri_infos['params']) ? explode('/', $uri_infos['params']) : array(); return array($uri_infos['class'], $uri_infos['func'], $uri_infos['params']); } } ?>

 

这里使用了设计模式中的单例模式,通过 dispatch 方法完成请求的分发和对应 controller 的加载

在这个方法中,首先调用的是 rewrite 方法,其目的在于兼容原有的所有 url,通过正则匹配与替换完成了全部旧地址到新地址的 301 跳转

其后,通过 parseUrl 方法完成了对新地址的解析,从地址中抽取 controller 类名与对应的方法名,然后调用相应类的方法,完成整个显示过程

此处,为了防止 ajax 恶意请求,对请求的 header 进行了判断,只有真正的 ajax 请求才会被相应的 Ajax 方法接收

 

controller 就是各个页面的入口,每个 controller 负责展示一个业务,controller 的每个 public 类函数则负责一个页面的展示,同时,对应了相应的 views 目录中的显示文件

所有的 controller 类均继承自 Controller 类

<?php class Controller { public $is_root = false; public function __construct() { $config = file_get_contents(APP_PATH.'/config.json'); $config = json_decode($config, true); if (isset($_COOKIE["LoginInfo"]) && $_COOKIE["LoginInfo"] == $config['admin']['logininfo'] ) { setcookie('LoginInfo', $config['admin']['logininfo'], time()+1800, '/'); $this->is_root = true; } else { $this->record_access(); } } public function record_access() { $remote_host = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '-'; $referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '-'; $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '-'; $stats = new StatsModel( array( 'time_str' => 'now()', 'remote_host' => $remote_host, 'request' => "http://".$_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'], 'referer' => $referer, 'user_agent' => $user_agent, ) ); Repository::persist($stats); } public function display($func, $params) { $params['background'] = $this->is_root ? 'images/183755241795a6aac850b8.jpg' : 'images/17183518883b16614c2fe8.jpg'; $pattern = '/^(?<class>.+)Controller::(?<func>.+)Action/is'; if (preg_match($pattern, $func, $arr) == false) { header('Location: /index/notfound'); exit; } $class = strtolower($arr['class']); $func = strtolower($arr['func']); $file = $class.'/'.$func.'.php'; $params['is_root'] = $this->is_root; if (empty($class) || empty($func) || !file_exists(VIEW_PATH.'/'.$file)) header("Location: /index/notfound"); else require(VIEW_PATH.'/'.$file); } } ?>

 

由于所有 controller 均继承自该类,因此所有页面展开时均会调用该类的构造函数,所以在该类构造函数中进行了向数据库中添加访问记录的操作,负责统计 UV、PV,同时进行权限认证

最终,controller 调用 display 方法,根据类名及函数名自动调用相应的 views 文件

 

数据库表抽象 -- model

博客重构解决的另一个问题是代码中裸写的 SQL 语句,在代码中裸写 SQL 语句,一来容易出错,二来可读性极差,同时,代码结构杂乱

为了增加代码可读性、复用性,于是引入了 model,原则是每个数据库表对应一个 model 类,在 register.php 中完成相应类的注册

每个 model 类具有相同的结构,由 tools/build_model.php 自动生成,类 private 成员变量与数据库表字段一一对应,同时,每个字段分别具有 get 和 set 方法操作相应成员,当然,出于安全角度考虑,PRIMARY KEY 并没有对应的 set 方法

 

那么,如何创建或获取 model 呢?

可以直接通过构造函数创建,构造函数以可选 array 为参数,指定类成员变量的值,即指定要创建的数据库元组的各个字段的值

也可以通过数据仓库类获取,数据仓库类分为:

  1. model 仓库 -- Repository
  2. SQL 仓库 -- SqlRepository

 

model 仓库 -- Repository

静态类 Repository 提供以下静态方法:

Repository 静态方法
静态方法意义
findOneFrom{Table}($params)根据 array 参数 $params 获取表 Table 的一个 model 对象,返回一个 TableModel 对象
findCountFrom{Table}($params)根据 array 参数 $params 获取表 Table 中元组个数,返回 int 类型的 count 值
findFrom{Table}($params)根据 array 参数 $params 获取表 Table 中全部 model 对象,返回一个 model 对象的数组
find{Field}From{Table}根据 array 参数 $params 获取表 Table 中 Field 字段的值

通过以上函数可以清晰、简单地从数据库中获取数据库元组所对应的 model 对象

$params 参数可以类似:

<?php $params = array( 'lt' => array('inserttime' => '2015-02-21', 'updatetime' => '2015-03-01'), 'gt' => array('inserttime' => '2015-03-01'), 'range' => array(0, 10), 'order' => array('inserttime' => 'desc') ); $ret = findFromArticle($params); ?>

 

上面的例子可以检索出指定时间范围内的前十个记录对应的 model 对象,同时以 inserttime 逆序输出

 

SQL 仓库 -- SqlRepository

SqlRepository 静态类中定义了一系列静态方法,均与具体业务相关,每个方法执行一个 sql 语句,获取数字、字符串值或 model 对象,完成复杂 sql 的封装工作,使业务逻辑清晰,同时,所有 SQL 语句集中在一起,便于统一维护或修改

框架中所有数据库操作均使用 PDO 进行,关于 PDO 的相关介绍可以参看:

PDO 及相关操作

 

library 中存放了供整个框架使用的公共工具类,包括上文已经介绍过的 Dispatcher、Controller、Repository、SqlRepository,还包括以下工具类

公共工具类
类名用途
SphinxClientsphinx 操作类,封装了 sphinx 的查找、配置等操作,用于支持博客查询功能
LogOpt日志类,用于支持博客日志的记录
StringOpt字符串封装类,包括重新封装更加强大的字符串查找工作、驼峰式与下划线式变量名的转换等字符串操作
TechlogTools定义了一套该博客框架特有的日志编写格式,类似于 markdown,可以实现日志的快速编写、格式的自动生成与统一修改,同时,该类还实现了日志目录的自动抽取、日志图片的自动入库、加载等操作

 

 

 






技术帖      symfony      web      php      controller      mvc      html      框架      mysql      sql      database      技术分享      sphinx      javascript      数据库      网站      pdo      display      dispatcher      数据仓库      web 框架      model      view      views      下划线      驼峰      markdown      检索引擎     


京ICP备15018585号