7 结语

最后更新于:2022-04-01 22:38:09

# 结语 我不是一个专业的编程人员,但是很喜欢编程,还喜欢写东西。 将我自己从零开始用SF编写我的[任氏有无轩](https://rsywx.net/)应用的过程全盘写出,一是给自己的工作做个总结;二是分享出来,推广SF这个优秀的PHP框架。 限于个人水平,书中错误在所难免,恳请不吝指正。 如果有任何问题,可以与我联络。我的邮箱是taylor.ren@gmail.com。 祝编程愉快! TR@SOE 2016.5.7
';

6 用户和后台

最后更新于:2022-04-01 22:38:07

# 用户及后台 我们在之前的章节已经看到如何开发一个WEB应用。严格的说,那只是前台的应用。 一般而言,我们总还有一个后台的应用,而后台的应用一般也会要求用户登录。 所以在本章,我们详细讲述用户和后台的开发。 ## 用户 SF中的用户概念和常规应用中的没有什么不同。对用户的验证也有多种方法。详情可以参考[SF官方文档中的相关章节](http://symfony.com/doc/current/book/security.html)。 在本应用中,我们只采用最简单的所谓Plain Authorization,而且用户及密码都以明文保存在配置文件中。 ## `security.yml` SF所有的安全配置都在`/app/config/security.yml`中,我们将该文件修改为: ~~~ security: encoders: Symfony\Component\Security\Core\User\User: plaintext providers: in_memory: memory: users: admin: { password: 123456, roles: [ 'ROLE_ADMIN' ] } firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false login: pattern: ^/demo/secured/login$ security: false secured_area: pattern: ^/admin anonymous: ~ http_basic: realm: "Secured RSYWX Admin Area" access_control: - { path: ^/admin, roles: ROLE_ADMIN } ~~~ 我们略作解释。 * `encoders`一段中,说明我们的用户验证机制是plaintext,也就是明文的方式。 * `providers`一段中,说明我们的用户是`in memory`方式,也就是说用户信息存放在内存中。 * `firewalls`中,主要看`secured_area`中`pattern`的说明。`^/admin`表示类似`'/admin'`这样的路径是属于受控路径,需要验证。 * `access_control`中,我们规定类似`/admin`这样的目录只能由具有`ROLE_ADMIN`权限的用户访问。 这里我们略微讲一下验证和授权的区别。 验证是对一个用户是否合法的判定。常规情形下,一个用户用正确的密码登录系统后,就认为该用户已经获得验证。 授权是对一个验证用户的进一步判定。在应用配置中,该用户能做什么不能做什么是由授权来完成的。 ## 后台 本应用的后台只是一些统计信息的显示。具体编程不再赘述,显示效果如下: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/94dd0fa0d4b6d910e806d24075fadfe6_1024x736.png) 如果配置正确的话,在访问该页面之前,浏览器会弹出一个对话框,要求输入用户/密码(我们的配置是admin/123456)。
';

5.11 书籍搜索

最后更新于:2022-04-01 22:38:05

# 书籍搜索 基于我们API的设置,该Web应用可以提供两种类型的搜索: * 根据书名的开始部分搜索; * 根据单一TAG进行搜索; 上一节中我们提到,书籍列表页面有两个功能没有完成,其中一个就是搜索书名。 对应的控制器方法如下: ~~~ public function searchAction(Request $req) { $q = $req->request->all(); $page = 1; $key = $q['key']; $uri = $this->get('router')->generate('book_list', array('page' => $page, 'key' => $key, 'type' => 'title')); return $this->redirect($uri); } ~~~ 这里我们需要注意的是,在搜索栏里输入的文字缺省被认为是书名的开始部分,也就是说,我们缺省认为我们按照书名搜索。 另外,搜索的过程其实并不搜索!我们只是根据当前情景构造了一个URI而已!这就是之前我们提到的`book_list`这个路由灵活性带来的好处了。而且,在此情形下,显示书籍列表的模板也可以被复用,而且在书名搜索模式(和TAG搜索)模式下,关键字在分页时不会被丢失。这是因为在构造所有相关的URI的时候,搜索类型和关键字都是被传递的。 至此,所有重要的前端页面都已经基本描述完毕。 笔者在此鼓励读者自行完成其它页面的构建。 下一小节我们开始讲述用户和后台的编写。
';

5.10 书籍列表页面

最后更新于:2022-04-01 22:38:02

# 书籍列表页面 在这个页面,我们会分页显示书籍列表页面。同时我们也会看到,SF中一个模板是可以被多个控制器复用的。当然,我们还要讨论如何编写分页。 ## 分页模块 首先我们看看分页模块的编写。 在`src`目录下创建一个`lib`目录,并创建一个`Paginator.php`: ~~~ namespace lib; class Paginator { private $totalPages; private $page; private $rpp; public function __construct($page, $totalcount, $rpp) { $this->rpp=$rpp; $this->page=$page; $this->totalPages=$this->setTotalPages($totalcount, $rpp); } /* * var recCount: the total count of records * var $rpp: the record per page */ private function setTotalPages($totalcount, $rpp) { if ($rpp == 0) { $rpp = 20; //This is forced in this. Need to get parameter from configuration but seems not necessary } $this->totalPages=ceil($totalcount / $rpp); return $this->totalPages; } public function getTotalPages() { return $this->totalPages; } public function getPagesList() { $pageCount = 5; if ($this->totalPages <= $pageCount) //Less than total 5 pages { return [1, 2, 3, 4, 5]; } if($this->page <=3) { return [1,2,3,4,5]; } $i = $pageCount; $r=array(); $half = floor($pageCount / 2); if ($this->page + $half > $this->totalPages) // Close to end { while ($i >= 1) { $r[] = $this->totalPages - $i + 1; $i--; } return $r; } else { while ($i >= 1) { $r[] = $this->page - $i + $half + 1; $i--; } return $r; } } } ~~~ 这个模块只负责一件事情:根据记录总数和每页的记录数算出总页面,并根据当前页数返回一个(合理的)包含前后各2个页数及当前页数(共5个)的数组,使得调用端可以显示去往不同页面的链接。这个类的编写并不复杂,这里不再做进一步的解释。 ## 书籍列表的路由定义 书籍列表的路由定义比较长,这是因为在设计这个路由(和对应的动作)时,我们考虑要将该路由(和对应的动作)复用。它不仅只是简单地按照登录顺序的逆序分页显示若干书籍,而且还能按照搜索方式的不同只显示符合搜索条件的若干书籍[1](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.10%20list.html#fn_1)。 该路由定义如下: ~~~ book_list: path: /books/list/{type}/{key}/{page} defaults: page: 1 type: title key: all _controller: AppBundle:Book:list ~~~ 这个路由定义的路径可以解读为:按照某个类型(`type`)下的关键字(`key`)搜索并返回搜索结果中的某一页(`page`)。 ## 书籍列表动作的编写 由于我们采用API的方式获得远程数据,所以控制器中的动作编写相对简单: ~~~ public function listAction($page, $key, $type) { $uri="http://api/books/list/$type/$key/$page"; $out= json_decode(file_get_contents($uri))->out; $res=$out->books; $totalcount=$out->count->bc; $rpp=$this->container->getParameter('books_per_page'); $paginator = new \lib\Paginator($page, $totalcount, $rpp); $pagelist = $paginator->getPagesList(); return $this->render("AppBundle:book:list.html.twig", array('res' => $res, 'paginator' => $pagelist, 'cur' => $page, 'total' => $paginator->getTotalPages(), 'key' => $key, 'type' => $type)); } ~~~ 简单说来,我们通过API调用获得适当的数据(符合搜索条件的书籍和书籍总数),从SF全局配置文件中获得`books_per_page`这个参数并调用上文提到的`Paginator`类构造一个分页列表。最后将相应的参数(共6个)传递到模板中显示。 注意: `books_per_page`参数应该在`/app/config/parameters.yml`中得到定义。方法是在该文件中加入一行: ~~~ books_per_page: 20 ~~~ ## 书籍列表模板 书籍列表模板比较长,这里不再列出。我们只是重点分析一下分页导航部分的代码。 ~~~
{% if cur==1 %} {% else %} {% endif %} {% if cur==total %} {% else %} {% endif %}
~~~ 虽然说从`Paginator`获得的是一个页面导航列表,但是我们选择用“前后页”(加上首页、末页)的方式显示导航按钮。也因此,我们分离了分页本身、导航、显示这三部分。这样做能提供最大程度的灵活性。 ## 效果 至此页面效果如下: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/83176454bdc57aa8d0e989265993a3a8_1680x925.png) 当然,这个页面还有一些功能没有完成。比如搜索以及直接跳转页面。搜索会在后续章节讲述。直接页面跳转比较简单,请自行完成。 > 1. 目前只支持按照书籍标题起始字符搜索和单一TAG搜索。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.10%20list.html#reffn_1 "Jump back to footnote [1] in the text.")
';

5.9 书籍详情页面

最后更新于:2022-04-01 22:38:00

# 书籍详情页面 第二个我们要详细解释的页面编程是书籍详情。 在该页面中我们要显示很多东西:书籍的所有登陆信息,包括TAG,一个jQuery的表单方便访客增加自己的TAG,如果这本书有相关的评论,那么还要显示评论的连接等等等等。该页面编程量非常大,请做好准备。 ## 创建`detail.html.twig`页面 这个页面很长,我这里就不贴出全部的代码了。同时我也不会写出所有的步骤:一般而言,SF给出的错误提示是非常精确的。在过程中参考错误提示,不断地修订——增加路由,控制器方法,模板等,最终一定能调试通过。 我们贴一张最终渲染的效果图: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/7a8f884c750d2f8cd53232a9e123f3c9_1680x925.png) 我们分别来看几个重要的版块。 ## 显示书籍封面图片 我们这里要显示的书籍封面图片,以JPG图片格式保存在`web/covers`目录之下,每本书都对应一个图片,其格式是`{{bookid}}.jpg`,例如途中所示的书其`bookid`为`99999`,那么它对应的封面图片应该是`99999.jpg`。如果该文件名的图片不存在,我们将显示一个`default.jpg`。 但是我们不是简单地显示图片,而是要加一个水印。同时,对于缺省图片,我们需要根据必要的书籍信息(书名、作者)在缺省图片上显示。因此,虽然只有一个缺省图片,其最终效果是动态的。 我们先看其路由定义: ~~~ cover: path: /books/cover/{id}_{title}_{author}_{width}.png defaults: {_controller: AppBundle:Default:cover, width: 300} requirements: title: .+ ~~~ 从路由定义我们可以看到,我们的图片是动态生成的。 再看对应的动作,我们将其放置在`DefaultController`中的`cover`动作中: ~~~ public function coverAction($id, $title, $author, $width) { // Construct image file name based on $path = 'covers/'; $ext = '.jpg'; $filename = $path . $id . $ext; $default = false; // Check if the file exists if (!file_exists($filename)) { $filename = $path . 'default' . $ext; $default = true; } list($w, $h) = getimagesize($filename); $nw = 300; $nh = $nw / $w * $h; // Propotionally change the width/height // Resize image $oimg = imagecreatefromjpeg($filename); $nimg = imagecreatetruecolor($nw, $nh); imagecopyresampled($nimg, $oimg, 0, 0, 0, 0, $nw, $nh, $w, $h); // Print copyright texts $copytext1 = "任氏有无轩"; $copytext2 = "版权所有"; $copytext3 = "1989-" . date("Y"); $color = imagecolorallocate($nimg, 255, 255, 255); $color2 = imagecolorallocate($nimg, 0, 0, 0); $font = $path . 'msyh.ttf'; imagettftext($nimg, 10, 0, 10, 26, $color, $font, $copytext1); imagettftext($nimg, 10, 0, 10, 40, $color, $font, $copytext2); imagettftext($nimg, 10, 0, 10, 54, $color, $font, $copytext3); if ($default) { //Print title imagettftext($nimg, 12, 0, 10, 140, $color2, $font, $title); // Print author imagettftext($nimg, 24, 0, 10, 240, $color, $font, $author); } //Resize the image to fit into reading list if ($width <> 300) //300 is the image width for book detail { $height = $width / $nw * $nh; $timg = imagecreatetruecolor($width, $height); imagecopyresampled($timg, $nimg, 0, 0, 0, 0, $width, $height, $nw, $nh); } // Output the image header('Content-type: image/png'); if ($width == 300) { imagepng($nimg, null, 9); } else { imagepng($timg, null, 9); } imagedestroy($nimg); imagedestroy($oimg); imagedestroy($timg); } ~~~ 这段代码比较长,用到了PHP的GD扩展对图片进行操作。但是流程还是比较直观,请读者自行分析。 注意:图片处理过程中要用到字体文件。由于字体文件比较大,作者没有将其放入代码仓库中,请读者自行拷贝。 ## 显示TAG并允许用户自行添加TAG 显示TAG的过程比较简单,直接贴代码: ~~~ public function tagsbyidAction($id) { $tags = json_decode(file_get_contents("http://api/book/tagsByBookId/$id"))->out; return $this->render("AppBundle:book:tags.html.twig", array('tags' => $tags)); } ~~~ 我们用的是嵌入控制器的方式,所以对应的模板中代码如下: ~~~ {{ render (controller('AppBundle:Book:tagsbyid', {"id":book.id})) }} ~~~ 显示完书籍的TAG后,我们显示一个按钮,让用户能添加自己的TAG: ~~~ 增加更多TAG »
... ...
~~~ 这是标准jQuery的模态对话框。我们不进行特别的分析。只是提请大家注意表单的编写方式。 ## 获取豆瓣信息 我们希望从第三方(豆瓣)获取书籍的一些信息(TAG、介绍、评分等)。这里我们要用到已经用了很多次的`file_get_contents`函数: ~~~ public function detailAction($id, Request $request) { $logger = $this->createLogger(); $books = json_decode(file_get_contents("http://api/book/bookByBookId/$id"))->out; if (count($books) == 0) // Book not found { return $this->render("AppBundle:book:BookNotFound.html.twig", array('bookid' => $id)); } $book = $books[0]; $isbn = $book->isbn; $douban = json_decode(file_get_contents("http://api/douban/douban/$isbn"))->out; if ($douban->summary == '(豆瓣找不到)') { $logger->addError('豆瓣找不到ISBN ' . $book->isbn . '的书:' . $book->title); } else { $logger->addInfo('豆瓣找到ISBN ' . $book->isbn . '的书:' . $book->title); } $lvt = json_decode(file_get_contents("http://api/book/lastvisit/" . $book->bookid))->out; $session = $request->getSession(); $count = $session->get('addvc', 1); $session->remove('addvc'); $vc = json_decode(file_get_contents("http://api/book/visitCount/" . $book->id . "," . $count))->out; return $this->render("AppBundle:book:detail.html.twig", array('book' => $book, 'douban' => $douban, 'vc' => $vc, 'lvt' => $lvt)); } ~~~ 注意到我们此时用的是传递变量到模板的方式。但是方法本身全部采用RESTful API调用的方式。 ## 获得相关评论 基于我们的样本数据,这本书应该有2个评论文章。所以我们还要获取相应的评论并显示。该段代码比较简单,请读者自行完成。 ## 小结 至此,我们已经基本完成书籍详情页面的编写。 请读者认真学习本段,因为本段牵涉到的代码量是非常大的。
';

5.8 开始编写首页

最后更新于:2022-04-01 22:37:58

# 首页的编写 由于本站点采用了新的架构(即不再应用中包含数据的CRUD操作,而改为调用对应的RESTful API的方式),因此站点编写工作量大大降低。 SF基于MVC架构,所以编程部分在C部分,也就是控制器(Controller)部分。 一个常规控制器的流程如下: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/9337535478eaed985e08316b2ff26588_450x703.png) 我们之所以首先用首页来进行控制器编写的说明,主要是因为它是一个站点的入口,具有特别重要的地位,而且它往往独一无二与别的页面不同(别的页面如书籍详情等会重复)。另外,首页中也用到控制器嵌入等重要技术,值得首先加以描述。 ## 顶端项目栏和搜索框 ### 创建一个新的路由 首页的顶端我们要设计成项目名称和搜索框。修改的是`AppBundle:default:nav.html.twig`文件,并最终呈现在`AppBundle:default:index.html.twig`的渲染效果中。 既然是搜索栏,我们要为搜索这个动作设定一个路由。出于简化编程和示范的目的,我们约定搜索只搜索书籍。我们修改`src/AppBundle/Resources/config/routing.yml`并增加一个新的路由如下: ~~~ books_search: path: /books/search defaults: {_controller: AppBundle:Book:search} requirements: _method: POST ~~~ ### 创建一个新的控制器 从路由的设置我们看到,我们同时要创建一个新的控制器(`BookController`)并在其中创建一个搜索的方法(`searchAction`)来处理这个工作。 创建新的控制器很简单,我们可以简单地从`DefaultController.php`出发,将其拷贝黏贴为`BookController.php`,并作相应修改: ~~~
~~~ 简单说明如下: 1. 我们修改了项目名称为“任氏有无轩”。 2. 我们为表单提供了`action`和`method`参数。 3. 我们使用了Twig专用的辅助函数`path`来为制定表单指定动作。它指向我们在上一步定义的`books_search`路由。 4. `method`只是简单的定义为POST。 5. 我们为一个文本输入框定义了一个名字为`key`。 我们刷新一下页面,会看到如下效果: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/cda72d33c22851d2c0187e47fe76d77e_1024x736.png) 而如果我们在“书籍名称”框中输入一些文字并点击“搜索”后会出现如下界面(部分分支已展开): ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/ce87c2c2df9a428bf0d5b793afc65ee0_1024x582.png) 可见,`VarDump`包中提供的`dump`函数能比PHP内置的`var_dump`函数更有组织地、更灵活地显示一个变量的值。从图中也能看到,我们通过表单提交的变量(`key`)获得了正确的赋值。 ## Jumbotron的编写 顶端项目栏和搜索栏之下是所谓的Jumbotron部分。这是首页特有的内容。在本应用中,我们用来显示最近购买的一本书。 要显示最近购买的一本书,有两种做法。一种是在所谓的`Default:indexAction`中直接获取最新的一本书,并将代表这本书的对象直接传递给模板;另外一种是在模板中嵌入一个控制器,让控制器获取相应的最新图书并显示。在Jumbotron的编写中我们采用第二种方式。 我们首先编写控制器如下: ~~~ public function latestAction() { $b= json_decode(file_get_contents('http://api/book/latestBook')); return $this->render("AppBundle:book:latest.html.twig", ['book' => $b->out[0]]); } ~~~ 由于我们采用了API调用,这个控制器可以写得非常简单。 ### `latest.html.twig`模板 我们可以简单地将当前`index.html.twig`模板中Jumbotron的`
...
`部分提取出来,成为`latest.html.twig`模板的基础,并进行一些修改。此时,我们从控制器的编写中可以看到,该模板会使用到一个`book`变量。 ~~~

{{book.title}}

作者:{{book.author}}({{book.region}})

收录时间:{{book.purchdate|date('Y年m月d日')}}

版次:{{book.ver}}

浏览本书 »

~~~ 注意如下几点: 1. 我们这里用Bootstrap布局来安排我们的书籍信息部分和书籍封面部分,左右分列。 2. 所有的书籍信息的显示都来自从控制器传递过来的`book`变量。 3. 我们再次用到`path`函数来构造浏览书籍详情的连接。这里我们还为该路径提供了一个参数,请读者必须注意参数传递的方式:`{'id':book.bookid}`。 4. 暂时我们用一个缺省的书籍封面作为所有书籍的封面。在后续章节中,我们还会进一步编写一个方法来显示书籍封面。 我们简化了Jumbotron的编写。在我自己开发的[rsywx.net](https://rsywx.net/)站点中, ### `index.html.twig`的修改 最后,我们修改`index.html.twig`,用嵌入控制器的方式替代原来的Jumbotron的`
...
`部分: ~~~ {% block content %} {{ render (controller('AppBundle:Book:latest')) }}
... ... ~~~ 此处我们用到Twig的一个高级语法`render`,它用来嵌入一个从控制器的返回。 ### 效果 让我们保存所有修改,然后重新刷新首页页面: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/256cf0fe7d9b8c6e4b0a1ca1fcdf16c2_1024x643.png) 由于我们对“最新登录书籍”的定义是按照其ID,所以我们会看到之前在样本数据填充中最后生成的书籍记录会被选出。 ## Headline的编写 Jumbotron之下是所谓的Headline。原布局中由3个`col-md-4`构成,在我们的应用中,会改成4个,它们分别是: 1. 藏书统计信息; 2. 书评统计信息; 3. 博客最新文章[1](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.08%20index.html#fn_1); 4. 维客的链接和其它链接[2](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.08%20index.html#fn_2); 这四个小板块的渲染会采用嵌入控制器和变量直接传递的方式。 ### `index.html.twig`的进一步改写 我们进一步改写`index.html.twig`文件,将原先三个`
`的部分抽取出来成为`AppBundle:book:summary.html.twig`和`AppBundle:reading:summary.html.twig`两个模板。 ~~~

截止{{'today'|date('Y年m月d日')}},任氏有无轩藏书{{summary.summary.0.bc|number_format(0,'.',',')}}本。约{{summary.summary.0.wc|number_format(0,'.',',')}}千字,{{summary.summary.0.pc|number_format(0,'.',',')}}页。

最近({{summary.last.0.purchdate|date('Y年m月d日')}})收藏/整理的书籍是{{summary.last.0.author}}《{{summary.last.0.title}}》

~~~ ~~~

截至{{"now"|date('Y年m月d日')}},任氏有无轩主人撰写了{{rs.summary}}篇评论。

最近({{rs.last.datein|date('Y年m月d日')}})评论的书籍是《{{rs.book.title}}》,题为{{rs.last.title}}

~~~ 对`index.html.twig`改写如下: ~~~ {{ render (controller('AppBundle:Book:latest')) }}

藏书

{{ render (controller('AppBundle:Book:summary')) }}

读书

{{ render (controller('AppBundle:Reading:summary')) }}

博客

本博客自2003年开始设立。写的少不是因为我不思考。我思考的越多,写下来的就越少。

最近的文章发布于{{wp.post_date|date('Y年m月d日H时i分')}},题为“{{wp.post_title}}”。

维客

这里是我整理的一些资源,主要是电子书和我最喜爱的湖人队的赛程

还可以和我取得联系

~~~ ### 编写对应的控制器 我们需要在`BookController`和`ReadingController`中增加对应的控制器方法分别响应上述模板中对控制器的调用: ~~~ // BookController.php public function summaryAction() { $summary = json_decode(file_get_contents('http://api/book/summary'))->out; return $this->render("AppBundle:book:summary.html.twig", ['summary' => $summary]); } // ReadingController.php public function summaryAction() { $summary = json_decode(file_get_contents('http://api/reading/summary')); return $this->render("AppBundle:reading:summary.html.twig", ['rs' => $summary->out]); } ~~~ ### 效果 我们可以看效果了。再次刷新首页[3](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.08%20index.html#fn_3)得到效果如下: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/946f934759c435f821176460ca3cebf9_1024x736.png) ## 小结 至此,我们已经基本完成首页的编写(还有一些内容我们会在后续章节讲述)。 1. 我们通过Twig模板的继承和包含,对布局模板进行了重构,分成了几个互相独立又互相依赖的子模板。这样做的好处是,每个模板的HTML代码总量都不大,方便调整和调试。 2. 通过内嵌控制器,我们进一步重构了模板,并就此编写了对应的控制器动作和模板。 3. 模板的编写基本基于当今流行的BootStrap框架,大大缩短了时间,提升了效率。 我们还将看两个页面的编写。重点是分页(在“书籍列表页面”中讲述)和图片处理、jQuery的集成(在“书籍详情页面”中讲述)。 不过在此之前,我们先来熟悉一下SF调试环境下的状态栏。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/c4baaf48ae94aa06d3af9aff16062520_1024x53.png) 该状态栏只有在调试环境下出现。从左到右,该状态栏图标的含义为: 1. HTTP返回状态。200表示正常。 2. 当前调用路由名。 3. 峰值内存占用。 4. 当前身份。 5. 渲染耗时。 6. SF当前版本。 将鼠标停在各图标上还有更详细的信息。该状态栏对调试还是很有一定的用处的。 > 1. 在本教程中,为了方便起见,博客文章是“虚拟”的。在实际应用中,用到了对后台WordPress数据库的调用。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.08%20index.html#reffn_1 "Jump back to footnote [1] in the text.") > 2. 这部分代码非常简单,只有静态代码。所以本教程中不再展开描述。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.08%20index.html#reffn_2 "Jump back to footnote [2] in the text.") > 3. 该页面效果所使用的博客数据库是真实的,而不是虚拟的。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.08%20index.html#reffn_3 "Jump back to footnote [3] in the text.")
';

5.7 模板

最后更新于:2022-04-01 22:37:55

# 模板 模板属于MVC结构中的V。它的出现是为了更好地分离程序代码(对用户不可见)HTML呈现(用户可见)。 对模板开发者的要求是能掌握一些基本的模板语法,写出基本框架,加入对应的控制和输出,最终形成一个可以呈现给用户的页面。 ## SF模板引擎:Twig SF的创始人Fabien Potencier研制了一个全新的模板引擎:[Twig](http://twig.sensiolabs.org/) 。我很喜欢这个引擎。我个人认为它有这么几个优点: 1. 语法简单,上手快; 2. 支持嵌套、继承等模板高级特性; 3. 扩展性好,可以开发Twig插件扩充功能; 4. 编译速度快,效率高。 Twig是SF框架中的一个部件,也可以独立被其它框架使用。在SF应用创建后,已经支持Twig。 ## 页面结构的设计 我们的应用不会只有一个页面,但是众多页面却可能有着相同的布局。比如,都采用“顶部导航栏+中间内容+底部导航栏”的上中下布局,或者采用“左导航栏+中间内容+底部导航栏”的布局,等等等等。 我一般会采用基于[Bootstrap](http://getbootstrap.com/)的模板加以定制。我现在[任氏有无轩](https://rsywx.net/) 使用的是从[WrapBootstrap](https://wrapbootstrap.com/) 上购买的一个商业模板。在本应用开发中,我们会用一个免费的Bootstrap模板,[Jumbotron](http://getbootstrap.com/examples/jumbotron/): ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/3b6c7031e7b423fc215dafd97275fb05_1592x853.png) 这个页面布局很合适我们的应用。 1. 顶部的黑色导航区可以放应用名称,导航链接,右边的用户登录部分可以用搜索框来代替; 2. 下面的Jumbotron部分可以用在某些页面中作为强调部分; 3. 主内容区三个并排的列可以在首页中显示不同栏目的内容,也很方便变成一个列用在别的页面中; 4. 最下面的一块地方正好可以用来显示一些版权信息之类的。 为了在我们的应用中使用这个布局,我们需要做一些额外的工作。 ## 保存Bootstrap模板 我们首先要将Jumbotron这个页面保存下来。在浏览器中选择保存页面。如果有选择保存方式的话,选择“全部”。这样我们保存的就不仅是该页面的HTML文件,而且包括该HTML页面中引用到的CSS,JS和图片文件等。 在我们SF项目的`web`目录下创建几个目录,如`css`,`js`,`img`,将保存下来的页面引用到的CSS,JS和图片文件分别拷贝到对应的目录中去。 接着,我们将刚才保存的那个HTML文件移到`src/AppBundle/Resources/views/default`并改名为`layout.html.twig`。 ## 修改首页显示该模板 下一步,我们修改当前首页所渲染的模板,使其显示我们刚才创建的模板,这样方便我们进一步修改该模板并看到效果。 让我们修改`src/AppBundle/Controller/DefaultController.php`中的`indexAction`为: ~~~ public function indexAction(Request $request) { // replace this example code with whatever you need return $this->render('AppBundle:default:layout.html.twig'); } ~~~ 刷新我们的应用页面,可以看到Bootstrap的页面已经替换了原来的SF欢迎页面。但是由于该页面中引用的CSS、JS文件的相对路径已经发生变化,所以需要进行调整。具体怎样调整这里就不再赘述,原则上只是一些文件路径和文件名的调整罢了。 如果一切顺利,我们应该看到和之前在Bootstrap上一样的页面效果。 ## 模板的继承和包含 Twig模板引擎的一个强大之处在于它支持模板的继承和包含,而且还可以嵌入控制器[1](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.07%20template.html#fn_1)。这为我们定义灵活的页面布局并同时保持一致性提供了方便。 ### 包含 包含是include,它并不是Twig的重点,所以我们只是提一下。它用来在一个模板中导入另一个模板。通常情形下,那些相对固定且在多个页面中都会重复出现的内容比较适合被剥离到一个单独的模板文件中。在需要这些内容的地方加以导入即可。 考虑我们使用的Bootstrap样板,我们会很快地发现,顶部导航栏和底部的导航栏都具有这样的性质:相对固定、作为页眉和页脚也会在多个页面重复出现。所以,这两部分可以被提取出去作为一个独立的模板供其它模板使用。 ### 继承 Twig的强大之处在于模板的继承。 学习过OOP的一定知道类继承是怎样的机制。简单地说,子类继承了父类中所有公共特性和公共方法,同时也可以按照子类的特殊要求加入新的特性和方法。 Twig模板的继承与此也有类似之处。不过由于我们讨论的是页面布局,所以这里的继承也是对页面布局的继承。 根据上面对Bootstrap样板的分析,我们可以看到,以页面布局来看,我们的页面基本上有三个部分: 1. 顶部导航栏,我们不妨称之为"`nav`[2](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.07%20template.html#fn_2)"。 2. 中间的内容部分,我们不妨称之为"`content`"。 3. 底部的信息栏,我们不妨称之为"`footer`"。 这三部分中,1/3我们已经讨论过了,我们需要将它们独立出去,成为两个独立的模板供包含。 2这部分是非常动态的,各个不同页面要显示的主要内容肯定不同。所以,它也必须独立出去,由各个页面各自完成。 于是我们可以对主模板进行如下图所示的调整: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/b7efd1036477406039da2720f885e839_1199x657.png) 简要说明一下: 1. 原本的`layout`模板被拆分为四块:`header`,`nav`,`index`,`footer`。 2. `index`模板成为我们首页最终使用的模板,它将继承`layout`模板,并用属于该页面的内容覆盖`content`页面组块。 3. 其它三个模板分别在`layout`模板中被引用。 随着应用的开发,我们会创建更多的页面。但是,其创建方式和创建`index`页面的方式类似。 ## Twig模板中的`block` 可以这么说,block(块)是Twig模板构造页面的基础。 在`layout`模板中,我们会注意到这么一段: ~~~ {% include "AppBundle:default:header.html.twig" %} {% include "AppBundle:default:nav.html.twig" %} {% block content %}{% endblock %} {% include "AppBundle:default:footer.html.twig" %} ~~~ `{% block content %}{% endblock %}`在`layout`模板中定义了一个名为`content`的块。我们注意到,`layout`中这个块中没有任何内容。它的内容是由各个具体的页面填充的,比如在`index`模板中: ~~~ {% extends "AppBundle:default:layout.html.twig" %} {% block content %}
... ...
{% endblock %} ~~~ 首先,`index`模板声明它会扩展(也就是继承自)`layout.html.twig`模板。因此,所有在`layout`模板中出现的布局因素(包括`layout`需要引用的其它模板中的布局因素)也都会在`index`中出现。 但是,`index`模板重写了`content`块,替换成自己的内容。而`index`模板中未覆盖的那些块,会仍然采用`layout`中的相应内容。 这样的一个过程,正是继承的过程。 一般而言,每当我们要创建一个新的页面,我们会采用类似的方式: 1. 从主模板(本例中的`layout`)继承; 2. 重写相应的块。一般而言,也就是一个诸如名为`content`的块; 3. 根据需要,重写另外的一些块,比如`title`,`extralink`,`extrajs`等以达到高度定制的目的。 ## Twig模板的存放位置 缺省情况下,所有的Twig模板文件都应该存放在`src/AppBundle/Resources/views`之下。为了更方便的组织模板文件,我们可以在这个目录之下再创建一些子目录。 引用一个模板的时候,无论是在模板之中还是在控制器之中,方法都是类似“`AppBundle:目录:模板`”的格式。比如,`AppBundle:default:nav.html.twig`对应的模板文件是`src/AppBundle/Resources/views/default/nav.html.twig`文件。 更深层的路径也是支持的,比如`AppBundle:Theme1/default:index.html.twig`对应的模板文件是`src/AppBundle/Resources/views/Theme1/default/index.html.twig`。这里的关键是,“`目录`”部分可以被替换成更完整的路径层次,但是我们不能修改`AppBundle`和最终调用的模板文件的名字。 > 1. 在模板中嵌入控制器是高级用法,但是其语法却非常简明。我们在后续章节会看到详细讲解。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.07%20template.html#reffn_1 "Jump back to footnote [1] in the text.") > 2. 我们没有把它叫做`header`是因为我们还会有一个`header`的模板,用来统一管理HTML页面中的头信息(如`meta`,CSS/JS的引用等)。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.07%20template.html#reffn_2 "Jump back to footnote [2] in the text.")
';

5.6 路由

最后更新于:2022-04-01 22:37:53

# 路由 SF以及所有现代PHP框架都采用“单一入口”的方式。 所谓“单一入口”说的是,一个Web应用,不管要访问哪个资源和URI,都统一由一个单一的入口文件进行调派。在SF中,这个文件就是`web/app.php`(生产环境)或者`web/app_dev.php`(开发环境)。 在单一入口模式下,用户在浏览器中键入类似“`mysite/book/list`”这样的地址的时候,这样的请求会被入口文件处理,从中分离出不同的部分。在SF中,这样的部分可能包括:控制器(一个类)、动作(类方法)、参数等。 怎样来进行这个分离的动作呢?SF采用的是路由(router)的方法。 在SF中,定义路由有几种方式。比如注释方式(annotation)、YML、XML、PHP等。我个人比较喜欢的是用YML的方式。 ## 定义入口路径 不管我们如何设计WEB应用,总是需要定义一个“入口”。 修改或者创建该文件 `src/AppBundle/Resources/config/routing.yml`,使之包含如下内容: ~~~ home: path: / defaults: { _controller: AppBundle:Default:index } ~~~ 同时修改`app/config/routing.yml`,使之只有如下内容: ~~~ rsywx: resource: "@AppBundle/Resources/config/routing.yml" ~~~ 修改`app/config/routing.yml`的目的是向SF应用表明,我们的路由配置将来自`src/AppBundle/Resources/config/routing.yml`文件。这个文件是一个YML格式的文件,定义了我们应用中所要提供的所有资源的路径配置。 修改完毕后我们再次访问应用,浏览器将会显示我们之前看到的SF欢迎页面: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/72862c19b3b57276c6b47244ccee5628_768x543.png) ## 路径配置 路径配置的核心包括三个部分: 1. 路径名。如`home`这样的一个名称。该名称必须在某个路径配置文件中唯一。 2. 路径。如`path: /`。该路径定义了应用能提供的URI。在本例中,我们定义的是入口,也就是通常所说的“首页”、“主页”。所以它的路径是`/`。我们在WEB中用`http(s)://sitename/`对该资源进行访问。 3. 动作。如`defaults: { _controller: AppBundle:Default:index }`。该动作表明,该路由将调用控制器的某个动作。该控制器位于`src/AppBundle/Controller/DefaultController.php`中,而调用的具体动作是`indexAction`方法。 由此,我们得到此类路径动作的一个重要约定。SF在寻找动作的时候,会在指定的Bundle(本例中的`AppBundle`目录,即`src/AppBundle`的控制器目录(即`src/AppBundle/Controller`)下寻找一个名为“`类名+Controller.php`”的文件(即`DefaultController.php`),并在其中寻找一个名为“`类名+Controller`”的类(即`class DefaultContrller`),再在其中找到一个“`动作名+Action`”的公共方法(即`public function indexAction`)并加以调用。 我们略微看一些这个控制器文件: ~~~ render('default/index.html.twig', [ 'base_dir' => realpath($this->getParameter('kernel.root_dir').'/..'), ]); } } ~~~ 我们在以后还会详细解释控制器的编写。这里只是简单地提一句:一般情况下,一个控制器中的动作都会返回一个模板的渲染,于是浏览器就有内容加以显示。 ## 两个重要的命令 在深入讨论更多路由配置之前,我们先看两个SF提供的和路由密切相关的命令。 ## 路由匹配 总有一天,我们的路由配置会越来越复杂,于是我们会产生疑惑(应用也可能产生bug):某个URI到底匹配哪个路由?其匹配的路由到底是不是我们原先设计中想要的呢? 我们可以使用`php bin/console router:match`命令来对一个URI匹配哪个路由进行调试。比如对`/`路由的调试命令为: ~~~ php bin/console router:match / ~~~ 该命令会产生如下输出: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/5e976e3ebb52034730b753eaea4ee77f_675x425.png) 可见,如我们的设计,`/`匹配了我们定义的`home`路由,它所调用的正是我们规定的`AppBundle:Default:index`动作。 ## 路由调试 有时,我们需要知道在应用中到底定义了多少路由,这时我们可以用如下的命令: ~~~ php bin/console debug:router ~~~ 该命令将列出所有的路径名、调用方法(是`POST`、`GET`或者其它还是无所谓)、协议(比如是不是必须要求https)、主机(可以由哪些主机对此访问)和路径。 ## 更多的路由配置 我们再来看几个路由,以了解更多的路由配置。 在这个藏书管理程序中,有一个功能是书籍列表(分页)。该路由定义如下: ~~~ book_list: path: /books/list/{type}/{key}/{page} defaults: page: 1 type: title key: all _controller: AppBundle:Book:list ~~~ SF采用`{...}`来标记路径中的参数。在上例的路由中,其路径有三个参数: * `type`:确定书籍列表的类型。一种是列书名,一种是列tag(更多的说明见后续章节); * `key`:如果`type`是列书名,这里就是书名的开始部分;如果`type`是列tag,这里就是一个tag; * `page`:确定要显示第几页。 因此用这样一个单一的路径,我们可以可以显示三种不同的书籍列表: 1. 不带任何参数,或者参数为缺省值,那么列出所有藏书(按照`id`降序,亦即最新登录的书籍最先展示)的第一页。 2. 按照书名开头进行搜索,显示匹配书名开头部分的那些书籍。 3. 按照tag进行搜索,显示匹配tag的那些书籍。 在我的网站中,这些页面的效果如下所示[1](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.06%20router.html#fn_1): ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/069f260eff328e120b203fb6d4658f27_1024x736.png)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/99fd3fa441d1ef93fc31e1c484e90f60_1024x736.png)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/79580291a8a4bae1a711e15e23f27d36_1024x736.png) 我们需要注意的是浏览器地址栏显示的地址。还有就是,虽然这是三个不同的动作,但是它们使用的显示模板是一样的。 在该路由的配置中,其`defaults`段和之前的不同。除了按照常规要制定一个控制器和动作外,我们对该路由的路径中出现的三个参数设置了一个缺省值。所以我们在访问`books/list`的时候,实际上就是访问了`/books/list/title/all/1`。 ## 只能进行`POST`访问的路径 该应用中还有一些路径是用来处理表单输入的。对于这样的路径,我们不希望用户在浏览器中直接输入URI而进行误操作,所以需要对该路径可以通过怎样的方法进行访问加以限制。 比如下面这个为一本书增加tag的路径: ~~~ tags_add: path: /books/addtag defaults: {_controller: AppBundle:Book:tagsAdd} requirements: _method: POST ~~~ 这里我们设置了路由的一些额外要求。其中的`_method: POST`规定该路由只能通过`POST`方式访问。 ## 对参数的限制 我们有一个书籍详情的页面,列出书籍的详细信息。该路径定义如下: ~~~ book_detail: path: /books/{id}.html defaults: { _controller: AppBundle:Book:detail } ~~~ 于是我们就可以用类似`/books/00005.html`这样的方式来访问一本书籍。但是这么做有一个小问题。 在我们的数据库中,一本书的`bookid`有5位,按照约定,它应该都是数字并有前导0,比如`00666`,`01234`等。类似`1234`(位数不够),`abcd8`(混杂了字母)这样的参数是不合理的。如果用上述的这个路径定义,我们访问`/books/1234.html`的时候,也还会匹配到上面的那个路径。这样做不会有什么致命的后果,只是数据库中无法找到这本书,显示一个“该书籍找不到”的页面而已[2](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.06%20router.html#fn_2)。但是这样不是很好的方法,如果我们能对路径中参数加以限制,使得那些不符合要求的参数(和URI)根本不访问该路由,我们至少解决了部分问题。 于是我们要对该路由中参数`id`加以限制。我们修改上述路由为: ~~~ book_detail: path: /books/{id}.html defaults: { _controller: AppBundle:Book:detail } requirements: id: \d{5} ~~~ 通过一个简单的正则表达式,我们约定`id`这个参数必须是5位数字,因此类似`1234`,`abcd8`这样的参数将不会触发这个路径。访问这样的URI只会出现一个Apache自身的404页面。 我个人认为,我会比较喜欢这种处理方式。这样做的一个好处是减少了后台控制器中的判断。 ## 路由定义的陷阱 随着我们应用的开发,路由的定义肯定会越来越多。我们有必要强调一些在路由定义时可能会犯的错误。 用YML定义的路由,遵循“最先匹配”的原则。某个URI只要符合某个特定的路径模式就会触发相应的动作。这么一来就可能会有问题。 假定在我们的路由文件中,有这样两个路由: ~~~ display_by_tag: path: /tag/{tag} add_tag: path: /tag/add requirements: _method: POST ~~~ 如果我们在一个表单中增加了一些tag,然后提交,我们的本意当然是要让`add_tag`这个路由中指定的动作去执行为一本书增加tag的动作。但是,在这样的路由配置情形下,首先被匹配的是`display_by_tag`这个路径,因此我们试图添加的tag不会真正地保存。 当然,要解决上面提到的问题也有很多方法。我们可以重新规划路径,调整路由定义的顺序等。 一般而言,路由的设计需要考虑到两点: 1. 简单、直观 2. 越是特殊的路由就要越早定义。 路由是SF中非常核心的一个部件。它可以由其它应用独立引用。 对于路由的解说,本文只能给出最基本的讲解。SF的[官方文档中对于路由的说明](http://symfony.com/doc/current/book/routing.html) 才是最权威的指南。 本应用完整的[路由文件](https://github.com/taylorren/rsywx_tutorial/blob/master/src/AppBundle/Resources/config/routing.yml)已经上传。 路由定义完毕后,我们需要开始模板的编写。 > 1. 我们现在的应用因为只有样本数据,所以是无法显示出这样的结果的。但是我们在后面会看到,即便如此,我们还是可以显示一个示范的效果。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.06%20router.html#reffn_1 "Jump back to footnote [1] in the text.") > 2. 该页面不是Apache自己的404页面,而是我们定制的一个页面。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.06%20router.html#reffn_2 "Jump back to footnote [2] in the text.")
';

5.5 样本数据

最后更新于:2022-04-01 22:37:51

# 样本数据 应用开发,其实有很大一部分是在样本数据的创建上。我们为什么要用样本数据? 首先,有了样本数据,我们的页面显示不会那么“空白”,有了比较实在的内容。 其次,样本数据一般都是非常有规则的,因此便于我们进行单元测试和功能测试。 第三,有一些应用上的逻辑问题,需要有足够的样本数据才会体现。比如我们在测试分页数据的时候。 如果我们只能人工或者半自动地往数据库中添加数据,那么效率就太低了。SF考虑到了这个问题,因此提供了一个专门的部件帮助我们来完成这个工作。这个部件就是`DoctrineFixturesBundle`。 ## 安装`DoctrineFixturesBundle` 按照SF官方文档的[说明](http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html),该部件的分装有两步。 第一步,下载部件包。而这是通过运行如下命令完成的: `composer require --dev doctrine/doctrine-fixtures-bundle` 下载安装完毕后,项目根目录下`composer.json`文件中会增加一行: ~~~ "require-dev": { ... "doctrine/doctrine-fixtures-bundle": "^2.3" //这是增加的一行 }, ~~~ 第二步,注册并激活该部件包。 找到`app/AppKernel.php`文件,并作如下修改: ~~~ // app/AppKernel.php // ... class AppKernel extends Kernel { public function registerBundles() { // ... if (in_array($this->getEnvironment(), array('dev', 'test'))) { $bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle(); } return $bundles } // ... } ~~~ 从这段代码我们也可以看出,这个部件包只能使用在开发和测试环境中。这当然是很明显的。 ## 编写样本数据文件 首先我们要确定这些样本数据文件所在的文件夹。一般而言,如果我们用Doctrine的话,样本文件应该存放在`src/AppBundle/DataFixtures/ORM`之下。每个需要样本数据填充的表格都要有一个对应的样本文件。 我们先看一个比较简单的样本文件。该文件用来为`book_publisher`表格填充样本数据: ~~~ setName('Common'); $this->addReference('commonPub', $pub1); //Create a special publisher $pub2=new BookPublisher(); $pub2->setName('Special'); $this->addReference('specialPub', $pub2); $manager->persist($pub1); $manager->persist($pub2); $manager->flush(); } /** * * {@inheritDoc} */ public function getOrder() { return 1; } } ~~~ 我们首先约定本文件的命名空间。其次是四个`use`语句。这四个语句中,前三个是标准的,也是所有样本文件需要用到的。第四个(`use AppBundle\Entity\BookPublisher as BookPublisher;`)用到的命名空间是本样本文件需要操作的表格所对应的实体类。我们不对该实体类加以引用也是可以的,只是这样做的话,后面的代码中将要用到这个类的FQN(Fully Qualified Name)。 所有样本数据类都派生自`AbstractFixture`并实现了`OrderedFixtureInterface`接口。从这点我们可以看出,样本数据文件是有顺序的。这是因为,在数据库中,由于存在表格之间的依赖关系和引用一致性检查,有些表格的数据必须在另外一些表格的数据能得以填充之前先得到填充。 比如,`book_book`表格中的`publisher`字段是`book_publisher`的外键。基于引用一致性的要求,我们不能为`book_book.publisher`赋一个并不存在于`book_publiser.id`中的值。所以,我们必须先填充`book_publisher`表格才能再填充`book_book`表格中的数据。 类的实现中,至少必须有两个函数:一个是`load()`,一个是`getOrder()`。 `getOrder()`用来制定本样本类中的数据需要在第几位被填充。从上面的代码中可以看到,该样本数据是在第一位被填充,也就是我们首先在`book_publisher`中填充数据。 `load()`完成真正的数据填充。在上述代码中,我们创建了两个出版商的信息:一个被我们命名为"Common",一个被命名为"Special"。 另外,由于我们已经知道这两个出版社一定会被`book_book`引用,所以我们用"`addReference()`"方法添加了各自的引用,以便我们在后续的样本文件中引用这两个对象。 最后我们持续化这两个新创建的出版社对象并保存到物理数据库中。 ## 数据填充 有了这个样本文件,我们就已经可以开始数据填充了。我们输入如下的命令: `php bin/console doctrine:fixtures:load` 注意:每一次我们进行样本数据填充的工作,原来在数据库表格中的数据都会被清除。这是为了保证在开发和测试的时候所有原始的数据都是一致的。 数据填充完毕后,我们使用的数据库中就有了对应的数据。即以`book_publisher`为例,此时应该有两个数据: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/38c536bc7c38afd703cba84606315c52_734x394.png) ## 引用其它对象的样本文件 如前所述,`book_book`这个表格依赖其它表格数据,所以它不能第一个被填充,而且在填充的时候需要引用其它表格中的数据。 我们已经在上面看到,`book_publisher`表格的填充文件中创建了两个对象引用。类似的创建在`book_place`中也一样存在。 一旦我们完成了其它表格的填充和相应依赖对象的创建,我们就可以在`book_book`对应的样本文件中加以必要的引用而进一步创建`book_book`中的记录。 ~~~ setAuthor('Normal'); $p->setCategory('Normal'); $p->setCopyrighter(''); $p->setDeco('Normal'); $p->setInstock(1); $p->setTitle('Normal Book Title'); $p->setRegion('Normal'); $p->setTranslated(0); $p->setPurchdate(new \DateTime()); $p->setPubdate(new \DateTime()); $p->setPrintdate(new \DateTime()); $p->setVer('1.1'); $p->setKword($i); $p->setPage($i); $p->setIsbn("$i"); $p->setPrice("$i.99"); $p->setLocation('a1'); $p->setIntro('This is a normal book.'); $p->setPublisher($this->getReference('commonPub')); $p->setPlace($this->getReference('commonPlace')); $p->setBookid("$i"); $manager->persist($p); } //Create a special book $s = new BookBook(); $s->setAuthor('Special'); $s->setCategory('Special'); $s->setCopyrighter(''); $s->setDeco('Special'); $s->setInstock(1); $s->setTitle('Special Book Title'); $s->setRegion('Somewhere On Earth'); $s->setTranslated(0); $s->setPurchdate(new \DateTime('1970-1-1')); $s->setPubdate(new \DateTime('1970-1-1')); $s->setPrintdate(new \DateTime('1970-1-1')); $s->setVer('1.1'); $s->setKword(999); $s->setPage(999); $s->setIsbn('123456789'); $s->setPrice('9999.99'); $s->setLocation('x1'); $s->setIntro('This is a very special book.\nIt is special because it is purchased at EPOCH.'); $s->setPublisher($this->getReference('specialPub')); $s->setPlace($this->getReference('specialPlace')); $s->setBookid('99999'); $this->addReference('aBook', $s); $manager->persist($s); $manager->flush(); } /** * * {@inheritDoc} */ public function getOrder() { return 3; } } ~~~ 在该样本文件中,我们一共创建了101本书。其中有100本是常规的,有1本是特殊的。 对于常规的图书,我们设置其相应的日期都是样本数据创建当日: ~~~ $p->setPurchdate(new \DateTime()); $p->setPubdate(new \DateTime()); $p->setPrintdate(new \DateTime()); ~~~ 我们在设置其购买地点和出版商时,用到了之前样本文件中创建的对象引用: ~~~ $p->setPublisher($this->getReference('commonPub')); $p->setPlace($this->getReference('commonPlace')); ~~~ 对于那本特殊的书,我们设置其相应的日期是一个很特别的日期,1970年1月1日,也就是计算机元年。其购买地点和出版商的设置也用到了之前样本文件中创建的对象引用: ~~~ $s->setPublisher($this->getReference('specialPub')); $s->setPlace($this->getReference('specialPlace')); ~~~ 最后,我们为这本特殊的书创建了一个对象引用。因为这本书的对象在后续的样本文件中还会用到。 ## 小结 样本数据填充部件是功能很强大的一个部件。它能帮助我们系统、高效地创建大量有组织、有规律的数据,方便今后的开发和调试。 样本填充文件采用PHP写成,所以不必麻烦开发人员再去熟悉一种新的方式或者语法。 笔者个人认为,开发初期使用样本数据填充绝对是事半功倍的。
';

5.4 建立数据库实体

最后更新于:2022-04-01 22:37:49

# 建立数据库实体 ORM的本质在于将一个数据库(更确切的说是其中的表格)“转换”到一个PHP对象。于是我们不用类似“`select * from ...`”或者“`insert into ...`”这样的SQL语句来操作表格中的数据,而是改用更直观、也更不容易出错的方式。比如下面这段代码的最终运行效果是在`rsywx`数据库的`book_place`中插入了一个新的纪录。 ~~~ $place = new BookPlace(); $place->setName('Common'); $manager->persist($place); $manager->flush(); ~~~ 这样的过程也许比`mysqli_query($connection, 'insert into ...')`多了几行代码的输入,但是出错机会少,而且也更安全。 ## 导入MySQL数据库 我们的应用开发里程中,数据库已经建立完成(见[建立数据库](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.02%20database.html)一节)。所以,我们需要将数据库转换到ORM中可以操作的类。 在严格的MVC框架下,这样形成的类是M(odel)层。但由于在本应用中,我们将提供数据这一任务全部放置到API中完成,所以我们导入MySQL数据库形成M层并不是必须的。我们在本教程中还是这么做是为了后面一节[样本数据](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.05%20fixture.html)的需要。 进入虚拟机中项目的根目录(`/vagrant/symfony`),输入如下命令: `php bin/console doctrine:mapping:import AppBundle yml` 其中: * `bin/console`是SF的命令行接口,对SF框架应用的操作都要通过这个接口。 * `doctrine:mapping:import`表明我们要导入一个数据库。 * `AppBundle`表明导入的数据库要为`AppBundle`这个包所用。 * `yml`表明我们要用[YAML](http://yaml.org/)格式保存导出的数据库信息。 命令顺利执行后,在`symfony\src\AppBundle\Resources\config\doctrine\`目录下会多出几个文件,这几个文件一一与数据库中的表格对应。比如`BookPlace.orm.yml`对应的就是`book_place`数据库。而它的内容也是如此: ~~~ AppBundle\Entity\BookPlace: type: entity table: book_place id: id: type: integer nullable: false options: unsigned: false id: true generator: strategy: IDENTITY fields: name: type: string nullable: false length: 255 options: fixed: false lifecycleCallbacks: { } ~~~ 如果我们回忆一下`book_place`的结构,会看到这个yml文件对表格的描述是与该表格的定义完全一致的。 我们暂时不会深入讨论各个字段定义中各个选项的意义。而且在一般情况下,我更喜欢从数据库到类的映射方式。 ## 生成实体类 导入了数据库后,我们执行如下命令来生成实体类: `php bin/console doctrine:generate:entities AppBundle` 该命令会在`AppBundle`目录下生成一个新目录`Entity`,其中会有若干个PHP文件。这些文件一一与上一步生成的YML文件对应,也因此一一与数据库中的表格对应。比如`BookPlace.php`文件对应的是`BookPlace.orm.yml`文件,并进而对应的是`book_place`这个表格。它的内容如下: ~~~ id; } /** * Set name * * @param string $name * * @return BookPlace */ public function setName($name) { $this->name = $name; return $this; } /** * Get name * * @return string */ public function getName() { return $this->name; } } ~~~ 这个文件中包含一个命名空间(`AppBundle\Entity`)的声明和一个类(`BookPlace`)的声明。而在类的声明中,包括了两部分: * 第一部分是成员声明。在`BookPlace`类中,只有两个成员:一个是`$id`,一个是`$name`。它们分别于`book_place`表格中的两个字段`id`和`name`对应。 * 第二部分是方法声明。一般情况下,对于每个成员,都有两个方法,一个是setter,一个是getter。对于只读字段或者应该由数据库引擎自动生成的字段(如本类中表明记录唯一性的`id`)就只有一个getter。 从数据库(表格)到ORM映射,再到PHP类声明,我们完成了将数据库加以对象化的步骤。 注意:上面产生的PHP文件是自动生成的。我们对这个文件和其中的类声明不应该做任何的改动。 我们只能修改YML文件然后用`doctrine:generate:entities`来生成PHP文件,用`doctrine:schema:update`命令更新数据库;或者直接操作数据库,并在此通过上面讲到的两个步骤来更新ORM和Entity。 ## ORM表述和Entity类中对表间关系的描述 在结束本小节之前,我们有必要看看ORM表述和Entity类中是怎样描述表之间的关系的。 我们的`book_book`表格与若干表格有“1对多”的关系。比如一本书的出版社和`book_publisher`,它的购买地点和`book_place`都有1对多的关系。 在`BookBook.orm.yml`中,我们可以找到这样一段: ~~~ AppBundle\Entity\BookBook: ... ... manyToOne: place: targetEntity: BookPlace cascade: { } fetch: LAZY mappedBy: null inversedBy: null joinColumns: place: referencedColumnName: id orphanRemoval: false publisher: targetEntity: BookPublisher cascade: { } fetch: LAZY mappedBy: null inversedBy: null joinColumns: publisher: referencedColumnName: id orphanRemoval: false lifecycleCallbacks: { } ~~~ 在这里我们可以清楚看到,`BookBook`是多端,它与两个1端对应。 而在`BookBook.php`中,我们可以找到这样的代码: ~~~ place = $place; return $this; } /** * Get place * * @return \AppBundle\Entity\BookPlace */ public function getPlace() { return $this->place; } /** * Set publisher * * @param \AppBundle\Entity\BookPublisher $publisher * * @return BookBook */ public function setPublisher(\AppBundle\Entity\BookPublisher $publisher = null) { $this->publisher = $publisher; return $this; } /** * Get publisher * * @return \AppBundle\Entity\BookPublisher */ public function getPublisher() { return $this->publisher; } } ~~~ 针对`place`和`publisher`这两个字段,在`book_book`中存放的只是一个ID(一个整数),但是在根据ORM生成的PHP类中,它们以各自PHP类出现(`\AppBundle\Entity\BookPlace`和`\AppBundle\Entity\BookPublisher`)。对应的,它们的setter和getter也是对这两个类的操作,而不是对`id`这个字段本身的操作。 在本教程中,我们不再对此进行进一步的展开。
';

5.3 应用结构

最后更新于:2022-04-01 22:37:46

# 应用结构 我们之前已经讲过,SF是一个非常严格的MVC框架。所以,我们的应用也严格遵循MVC分离的原则。 但是,由于本应用已经开发到了6.0版本,笔者对应用结构也有了全新的布局,所以在该版本的应用中,M模块其实已经不再存在,而改用RESTful API调用的方式。因此本应用的结构也相对比较扁平。 简单来说,我们创建了一系列的`Controller`,其中的`action`与`routing`关联,负责接收来自主入口文件(`app.php`)的调派。 在某个具体的`action`中,一般的流程是: * 获取传入的参数; * 构建要调用的API URI; * 获得返回数据并解析[1](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.03%20structure.html#fn_1); * 将构造好的数据传递给一个模板并显示; 根据应用要提供的功能,我们可以创建相应的`controller`。我们会在稍后的章节中说明各个`controller`的创建。 这个应用有前台,也有后台。 前台是各个公共页面,如首页、书籍列表、书籍详情、书评列表、其它页面等。下图是首页的效果[2](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.03%20structure.html#fn_2)。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/370e7cd5c5b7b2451b6282d82a32c429_1003x2044.png) 后台需要登陆,显示相关的统计数据,如下图所示。 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/f87338d58d4f479a7844a0aa208bf6ce_1003x1781.png) > 1. RESTful API接口返回的都是JSON格式的数据,所以必须将其转换到一个对象或者数组以便PHP进一步使用。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.03%20structure.html#reffn_1 "Jump back to footnote [1] in the text.") > 2. 这是我运行中的站点的首页,比本教程要创建的首页更复杂。[ ↩](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.03%20structure.html#reffn_2 "Jump back to footnote [2] in the text.")
';

5.2 建立数据库

最后更新于:2022-04-01 22:37:44

# 建立数据库 数据库是任何现代应用的核心。我们要开发的藏书管理程序也不例外。 虽然当今编程有向着[TDD](http://en.wikipedia.org/wiki/Test-driven_development),[DDD](http://en.wikipedia.org/wiki/Domain-driven_design)导向的趋势,但是在我们这个程序中,我们还是遵循最传统的从数据模型出发的流程。 如果我们去浏览Symfony 的官方文档,会发现SF3使用的是从实体(Entity)到数据库(Database)的流程。但是我们这个教程遵照的是一个完全不同的方向:所有数据的来源都是通过RESTful API提供的。换句话说,所有牵涉到数据库的操作都在另外一个应用中实现。关于这个RESTful API的实现,请参见我的另外一个教程[《用Silex开发一个RESTful API》](https://www.gitbook.com/book/taylorr/-silex-restful-api/welcome)中的讲解。 尽管如此,数据库还是整个应用的核心——即使在我们这个应用中不直接对其进行操作。 笔者已经将本应用使用到的数据库的结构SQL语句上传到了本书对应的Github仓库,请下载[该文件](https://github.com/taylorren/symfony/blob/master/sql/rsywx.sql),并在你的开发环境中创建该数据库。 注意:在本文中的写作中,笔者使用的开发机已经有了一个名为`rsywx`的数据库——这是我生产环境使用的数据库的一个本地备份。所以,用于在笔者的开发环境中真正使用的数据库会是`rsywx_tutorial`。不过这不影响本教程的正常使用。读者可以使用`rsywx`也可以用自己喜欢的名字来命名这个数据库。 ## 数据库结构 `rsywx`数据库包括了若干表格。从功能来看,有收录书籍信息(以及书籍出版社、购买地点、Tag)的表格,收录书籍评论的表格以及书籍访问记录的表格,还有一个记录我最喜欢的NBA球队湖人队赛程的表格。 该数据库结构以及表之间的相互关系如下图所示: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/574e0507a98a48ebe9cfcc31157ec7f3_804x705.png) 我不去一一解释各个字段、各个表之间的关联,只是简单地说几句。 这是一个符合3NF的数据库。以`book_book`为核心,其他表格(除`lakers`)之外,都直接或间接地和该表有关联。 `book_visit`用来记录书籍详情页面被访问的情况。目前我只是简单地记录了书籍、访问时间。这些数据会在后台管理中用作各类统计。 这肯定不是一个完美的数据库设计。读者可以根据自己的需求加以改进和修订。 ## 数据库用户 一般来说,用`root`来操作数据库总不是一个很好的做法。在开发过程中也许可以出于简单化的考虑,我们可以先用`root`,但是在生产环境这样做是不推荐的——除非你对`root`的密码有充分信心。 我们可以借助相应的工具来创建一个新的用户,只给他相应的CRUD权限或者其它必要的权限。有关数据库用户创建和权限分配的操作,可以参见相应的文档。 在本文中,我们使用的用户和密码是:`tr/trtrtr`。这不是一个很好的用户/密码组合,不过只是作为开发和演示而已。 ## 修改数据库配置 SF应用中数据库配置保存在`app\config\parameters.yml`中。我们在安装SF、创建项目时可以制定数据库的连接,也可以在稍后手工修改。 ~~~ # This file is auto-generated during the composer install parameters: database_host: 127.0.0.1 database_port: null database_name: rsywx_tutorial database_user: tr database_password: trtrtr mailer_transport: smtp mailer_host: 127.0.0.1 mailer_user: null mailer_password: null secret: ThisTokenIsNotSoSecretChangeIt ~~~ 我这里已经将使用到的数据库、用户、密码进行了更新。 这个文件也可以被当成“配置”文件,存放一些供整个应用使用的“全局”变量。我们会在后面的章节看到更详细的介绍。
';

5.1 建立版本管理

最后更新于:2022-04-01 22:37:42

# 建立版本管理 为了便于管理代码,我们最好将我们的应用置于版本管理之下。 我们可以选择[GitHub](https://github.com/)或者[BitBucket](https://bitbucket.org/)或者别的什么代码管理仓库,哪怕是自己搭建的都可以。 将代码置于某个仓库下进行版本管理不是很复杂。但是针对SF的话,我们需要生成自己的`.gitignore`或者`.hgignore`文件,从而避免将一大堆第三方代码和不必要的文件置于版本控制之下。 经过我的实践,我建议用如下的`.gitignore`文件——如果你使用[Hg](http://mercurial.selenic.com/),可以加以参考。该文件放置在项目根目录下。 ~~~ /web/bundles/ /app/bootstrap.php.cache /app/cache/* /app/config/parameters.yml /app/logs/* !app/cache/.gitkeep !app/logs/.gitkeep /app/phpunit.xml /build/ /vendor/ /bin/ /composer.phar /nbproject/private/ *.php~ /web/app_dev.php ~~~ 根据你的实际情况,还可以加入更多的忽视清单。 现在你可以`commit`,`push`到远程代码仓库去了! 如果你对命令行的操作感到厌烦,可以考虑使用[SourceTree](https://www.sourcetreeapp.com/)这样的GUI界面。
';

5 创建应用

最后更新于:2022-04-01 22:37:40

# 创建应用 我们已经进行了大量的准备工作和前导阅读,目的当然就是为了我们接下来要进行的应用开发。 创建应用需要如下几个步骤。 ## 规划项目位置 我们之前看到了关于[Vagrant虚拟机的安装和如何进入虚拟机](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/01.03%20install%20ubuntu.html)。 让我们先登录到虚拟机,并转到`/vagrant`目录。我们之前讲过,这个目录实际上就是我们Windows宿主机中的某个目录。 这个目录将成为我们所有项目的存放位置。在该目录下,各个项目有各自的项目目录。我们将这个项目目录称为某个项目的根目录。以后的教程中,如果再次提到这个“根目录”,那就一定是指这个目录。 ## 获得Symfony的`installer` ~~~ sudo curl -LsS http://symfony.com/installer -o /usr/local/bin/symfony sudo chmod a+x /usr/local/bin/symfony ~~~ 注意:这么做是在`/usr/local/bin`下创建了一个可执行的`symfony`命令。我比较喜欢将这个命令放置在`/vagrant`目录下。 ## 创建应用 假定我们在`/vagrant`目录,现在用如下的命令创建我们的应用: ~~~ $ symfony new the_new_project_name ~~~ 请根据实际情况将`the_new_project_name`替换为更有意义的名字,在我们这本书中,我们用的项目名称`symfony`。 > 注意:在我们虚拟机中,由于`/vagrant`这个目录并不是实实在在的Linux系统中的目录,而是一个Windows系统下映射过去的目录,上面的命令有可能会执行出错(取决于你使用的Vagrant版本)。此时,我们可以在对应的Windows目录中进行如上操作。这样生成的应用框架是可以在Linux中使用的。 或者,可以参考SF官方文档中的[方法](http://symfony.com/doc/master/book/installation.html#creating-symfony-applications-without-the-installer),用Composer来创建项目。 这个命令将在`vagrant`目录下创建一个`symfony`目录(也就是我们的项目名字),并在该目录下进行必要的设置,创建一个全新的Symfony应用框架。 现在我们进入`symfony`目录,也就是“根目录”中,按照之前“[Composer](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/02.04%20composer.html)”一章中的介绍下载`composer.phar`,并执行一次更新。 ## 目录结构说明 一个空空如也的Symfony 3框架约莫有32M,这也是SF3被称为重量级框架的原因。它的目录结构如下: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/e87779848417cb274dcbf85a2a169901_337x409.png) ### `app`目录 这个目录是整个框架的运行核心。一些重要的核心文件,如`autoload.php`,`AppKernel`等文件都在该目录中。 它又包括几个子目录,也非常重要。 * `config`:这里存放应用所有的配置。在日后讨论中,我们会慢慢接触这些文件。 * `Resources`:这里可以存放应用级别的资源,如模板文件(在`views`子目录下)。 ### `bin`目录 * `console`:该文件是SF命令行界面,我们稍后在开发过程中会经常用到这个命令。 ### `src`目录 用户编写的所有内容都在该目录下,严格的说,是在`AppBundle`目录下。根据代码的用途,`AppBundle`目录下又可以分为: * `Controller`:控制器,即MVC中的C。 * `Entity`:实体,即MVC中的M。 * `Repository`:仓库,存放实体操作的代码。 * `Resources\config`:存放当前应用包的配置,如路由,数据库实体等。 * `Resources\views`:存放模板,即MVC中的V。 * `Tests`:存放单元测试和功能测试代码。 在项目刚创建完成时,这些目录(除了`Controller`)都不存在。我们在日后开发过程中,可以选择生成。 ### `tests`目录 此处存放所有的测试文件,包括单元测试和功能测试。 ### `var`目录 该目录中有三个子目录。 * `cache`:存放SF编译用户代码和系统代码后的缓存。根据实际使用情况,又可能会有`prod`,`dev`和`test`子目录,分别对应生产、开发、测试环境。 * `logs`:存放日志文件,如`dev.log`对应的是开发环境下的日志文件。 * `sessions`:存放PHP和SF运行时创建的对话信息。 ### `vendor`目录 所有第三方的包和代码存放在此处。一般情况下我们在此处进行操作。 ### `web`目录 这个目录是SF3应用开放给Web服务器的入口,也就是我们常规情况下访问`http://www.somewhere.com`时,Web服务器所访问的根目录。请不要和我们之前说的“项目根目录”混淆。 在这个目录中,有SF3应用的入口文件:`app.php`(生产模式)和`app_dev.php`(开发模式)。在实际应用中,我们访问的是`app.php`——当然,因为有重写规则的存在和该目录下`.htaccess`文件的配合,我们访问一个SF应用时,不需要指明`app.php`,而可以直接用类似`http://www.somewhere.com/path/to/resource`这样的方式。在开发时,我们更多的是使用`app_dev.php`,此时我们访问的URI形如:`http://www.somewhere.com/app_dev.php/path/to/resource`。 ## 远端调试 如果我们现在在Windows宿主机中访问`http://symfony/app_dev.php`,那么我们会看到一个错误信息: > You are not allowed to access this file. Check app_dev.php for more information. 这是因为`app_dev.php`是个用在开发模式下的文件,缺省时开发环境是不对远程主机开放的。 既然我们采用目前的Windows+Vagrant的开发方式,我们显然必须进行远程开发,所以需要修改一下`app.php`文件: 原来的文件中找到这一段: ~~~ // This check prevents access to debug front controllers that are deployed by accident to production servers. // Feel free to remove this, extend it, or make something more sophisticated. if (isset($_SERVER['HTTP_CLIENT_IP']) || isset($_SERVER['HTTP_X_FORWARDED_FOR']) || !(in_array(@$_SERVER['REMOTE_ADDR'], array('127.0.0.1', 'fe80::1', '::1')) || php_sapi_name() === 'cli-server') ) { header('HTTP/1.0 403 Forbidden'); exit('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.'); } ~~~ 将整个`if`段全部注释掉。然后再次访问,我们会看到如下的欢迎界面: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/72862c19b3b57276c6b47244ccee5628_768x543.png) 看到这个界面,那么我们的SF应用创建就大功告成。接下来就是真正的开发过程了。 注意:经过修改的这个`app_dev.php`程序可千万不要放到生产环境中,这会带来巨大的安全风险。因此,我一般会将这个文件放入版本控制中的被忽略文件列表中(见下一节[建立版本控制](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/05.01%20git))。 不过在进入开发之前,我们先要进行代码仓库的管理。
';

4 藏书管理程序的结构

最后更新于:2022-04-01 22:37:37

# 藏书管理程序的结构 我们要开发的是一个主要供个人使用的藏书管理程序,该程序的实际运行版本见[“任氏有无轩”主页](http://www.rsywx.net/)。 这个应用主要提供了这样一些功能: * 书籍列表:显示藏书的一个列表,提供关键字搜索、页数跳转、分页显示等功能; * 书籍详细:显示某一本书籍的详细信息,并从豆瓣处抓取信息作为补充显示,提供添加个人TAG的功能(以便访客日后搜索); * 读书心得:显示读书后写的心得(以博客文章形式呈现,牵涉到博客的整合,见以后的章节); * 博客:使用的是[WordPress](https://wordpress.org/)作为博客平台,并对WordPress数据库进行查询并获得信息。 * 维客:使用的是[DokuWiki](https://www.dokuwiki.org/dokuwiki)作为维客平台,只是整合,不做进一步编程。 * 资源:比如我喜欢的湖人队的赛程。 * 联系:列出一些和站点主人的联系方法,包括Google Map的调用等。 * 首页:提供站点的接口。 * 后台:提供一个后台入口,以比较直观的方式来管理数据。 在首页中,对书籍信息需要进行汇总并显示,有一个Dart的部件将显示“每日引言”和“今日天气”信息,列出最近的博客文章等。这是所有页面中编程量最大的页面(没有之一),而且又是站点的入口,因此将首先加以深入研究。我们先给出一张我的站点的首页截屏,作为效果展示: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/679f70f14f85cbd3e42623f0f1afa444_1004x1144.png)
';

3.8 Test/测试

最后更新于:2022-04-01 22:37:35

# 测试 只要你编程,就一定有错误。 作为程序开发的一个基本要求,我们必须对程序是否能正确运行进行测试。在PHP的世界中,我们可以使用[PHPUnit](https://phpunit.de/),它和Symfony的配合是非常好的。 测试分为两种,一种是单元测试(Unit Test),一种是功能测试(Functional Test)。PHPUnit可以配合SF3完成这两种测试。 具体的测试用例,我们会在后面编程的时候加以详细讨论。这里就不再展开。 另外,测试往往要用到很多测试数据,这就牵涉到样本数据的导入。我们也会在具体编程时加以讨论。 要在SF3的应用中使用PHPUnit,只要在根目录下运行: ~~~ phpunit ~~~ 就可以了。 注意:我们假定PHPUnit的安装方式是全局的,同时`phpunit.xml`文件保存在项目根目录下(也就是和`composer.json`同一个目录)。 如果一切正常,那么会有一个类似如下的提示: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/04ca6a97c5c841a532c189d62382ef80_661x418.png)
';

3.7 Template/模板

最后更新于:2022-04-01 22:37:33

# 模板 SF2缺省使用TWig引擎作为模板的渲染。 一般的应用,都会由不止一个页面构成。 比如我的藏书管理程序包括首页,藏书列表,书评列表和某本书的详情页面,也包括“和我联络”这样的页面。这些页面有些是动态页面,有些是静态页面。但是它们都有类似的布局。我采用的是最常见的“头+主体+尾”的三段式布局。如下两个页面分别是藏书列表和书籍详情页面: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/7f0463a6e2c5d3c12240f12f4096d1bb_1003x899.png) ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/4e342fe5d7af1e1f64a8c4ce3824f77e_1003x1545.png) 很明显,头部的内容基本保持不变(站点标识,导航条),尾部的内容页基本不变(版权申明等)。变化的是中间主体的内容。如果是藏书列表,就应该是分页显示的书籍列表信息;如果是书籍详情,就显示某本书更详细的信息。 针对这样的布局,在模板设计时我们应该遵循DRY原则,将相似的部分抽取出来成为一个独立的部分并使得这些部分可以在不同的页面中重用。 Twig提供了丰富的功能来完成这些工作。 ## `include`一个子模板 在我们刚才讨论的页面布局中,头(`header`)和尾(`footer`)是相对独立和固定的内容,所以可以作为独立的模板存在,并被包含入主模板。 因此,主模板(`layout.html.twig`)的书写大致是这样的: ~~~ {% include "AppBundle:default:header.html.twig" %} {% block content %} {% endblock %} {% include "AppBundle:default:footer.html.twig" %} ~~~ 是的,就这么简单。特别请注意`block content`到`endblock`这两行。这两行定义了一个名字为`content`的内容块,但是没有任何内容。这部分内容要由基于该主模板派生的各个模板去填充。 `include`模板还可以传递参数。比如下面的这个例子: ~~~ {{ include ("AppBundle:default:slider.html.twig", {'random':random}) }} ~~~ 这个指令在包含`slider.html.twig`模板的时候,也会同时将本模板中`random`这个变量传递到被包含的这个模板中的`random`变量,从而达到变量在两个模板中传递的目的。 `include`严格来说,是个控制结构,但是我们在上面的代码中看到了两种用法都是可行的。 ## `extends`一个模板 上文定义的`layout`模板,只是一个框架,一般不会在我们的应用中单独呈现。我们要呈现给用户的页面都是基于这个模板派生而来。 我们来看一个比较简单的书籍列表的页面模板: ~~~ {% extends 'AppBundle:default:layout.html.twig' %} {% block meta %} {% endblock %} {% block title %}任氏有无轩 | 藏书列表 | 第{{cur}}页,总{{total}}页{% endblock %} {% block content %}
......
......
......
{% endblock %} ~~~ 最关键的是要`extends`一个主模板。然后在本模板中,针对主模板中定义的不同块(`block`),如果需要加以覆盖的,就用:`{% block blockname %}...{% endblock %}`的语法加以重写。如果我们不覆盖主模板中的某个块,那么在本模板中会显示主模板中该快的内容。 ## 在模板中嵌入一个控制器 模板的第三种用法,也是比较高级的用法是在模板中嵌入一个控制器。 我们考虑我的藏书管理程序首页的一个用例。在该页面中,我需要在一个幻灯片效果中显示一本随机挑选的书,在另一处`
`中显示三本随机挑选的书。 一种常规的做法是,在显示首页的控制器(`AppBundle:Default:indexAction`)中,通过调用相关的仓库方法获得这些书,然后通过变量传递的方式传递给相应的模板,并在模板中显示。这样做是可以达到目的的。 但是这么做可能有问题。如果这样的显示要求是多个页面都要求的,我们会面临在A控制器中调用仓库方法然后获得数据传递给a模板;在B控制器中调用同一个仓库方法获得数据然后传递给b模板……的过程。这里有重复的过程:都要调用一个仓库方法。而且,假定这一方法调用后返回数据的呈现在不同模板中也是一样的,重复性会更大:我们需要在各个模板中`include`一个子模板,然后传递给这个子模板以数据。这个过程更繁琐。 因此,比较推荐的方法是,在模板中内嵌一个控制器,这个控制器负责获取数据并渲染一个模板,而该模板的渲染内容将嵌入当前调用模板中。 我们来看一个实例,是用来显示与一本书相关的tag的。 首先是当前模板(`detail.html.twig`)中对控制器的嵌入: ~~~

TAG

{% render (controller('AppBundle:Book:tagsbyid', {"id":book.id})) %} 增加更多TAG »
~~~ 然后是显示某本书的tag的控制器: ~~~ public function tagsbyidAction($id) { $em = $this->getDoctrine()->getManager(); $tags = $em->getRepository('AppBundle:BookBook')->getTagsByBookId($id); return $this->render('AppBundle:book:tags.html.twig', array('tags' => $tags)); } ~~~ 这里通过调用仓库的方法(`getTagsByBookId`),获得与这本书关联的tag数据,然后再通过`tags.html.twig`模板显示: ~~~ {% for tag in tags %} {{tag.tag}} {% endfor %} ~~~ 这样的安排是非常去耦合化的,便于程序的开发和维护。 ## 小结 由于Twig的语法相对简单,所以我们没有在之前的[Twig介绍](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/02.03%20twig.html)和本节的介绍中过多着眼其语法的介绍——这部分会在后面的开发过程中结合实践逐一介绍——而更多地介绍Twig模板的设计过程和一些重要的概念。 我们以上讨论的内容可以用如下这张的图来说明: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/f9eb01c3659dd929b0d67ec35c042526_1132x644.png)
';

3.6 Repository/仓库

最后更新于:2022-04-01 22:37:30

# 仓库 Repository(代码仓库)是SF中的另一个重要概念。 顾名思义,代码仓库就是存放(通常是通用的)代码的地方。在SF中,一般用来堆放进行数据库操作的代码。 SF为什么要增加这么一个额外的层?有两个原因: 1. 虽然一个[实体](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/03.05%20entity.html)提供了一些基本的数据库操作,如`findOneBy`之类的,但是肯定不能满足我们定制搜索的需要; 2. 如上的这个要求既不应该是Model的任务,更不应该是Controller的任务(不符合DRY和代码重用原则)。 所以,我们有必要加入一个新的层,形成这样的一个关系图: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/a289ec170b394584ac29b4ca03d74ccf_699x570.png) 1. Controller只提出我要什么数据; 2. Repository只负责选择数据; 3. Model存放真正的数据。 现在要马上透彻理解这里的关系和区别还是比较困难的。我们会在后续文章中更深入地讨论这些。 我们来看一个典型的仓库的代码: ~~~ sub($inter_s); $cte = new \DateTime(); $cte->sub($inter_e); $em = $this->getEntityManager(); $repo = $em->getRepository('AppBundle:Status'); $q = $repo->createQueryBuilder('s') ->leftJoin('AppBundle:User', 'u', 'with', 'u=s.author') ->where('s.created>=:e') ->setParameter('e', $cte) ->andWhere('s.created<=:s') ->setParameter('s', $cts) ->setMaxResults($count) ->orderBy('s.created', 'desc') ->addOrderBy('s.id', 'desc') ->getQuery() ; $status = $q->getResult(); return $status; } } ~~~ `getStatusIn`方法会提供在开始和结束期间的数据,缺省是提供4个。显然,这个数据的提供(搜索)不是简单地由`findBy`之类的实体方法可以完成的。 上面我们看到的构建SQL的方法是两种可用方法中的一种,即通过链接各个函数调用,在查询构造中中加入`join`、`where`、`order by`等限定而得到我们需要的一个`select`语句。 对其的调用在Controller中一般这样进行: ~~~ public function indexAction() { $em = $this->getDoctrine()->getManager(); $repo = $em->getRepository('AppBundle:Status'); $day = $repo->getStatusIn('P0D', 'P1D', 5); $week = $repo->getStatusIn('P1D', 'P7D'); $month = $repo->getStatusIn('P7D', 'P1M', 5); ... ... ~~~ 这几乎是一个标准的调用模式。 重要更新:如今的一种开发方式将我们从这样的重度耦合中解放了出来。因此不再需要Repository,而直接改用远程RESTful API的调用。详细的讨论可以参见[03.01 MVC](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/03.01%20mvc.html)。
';

3.5 Entity/实体

最后更新于:2022-04-01 22:37:28

# 实体 在SF的术语中,实体(Entity)是一个对象,有自己的属性和方法。我们都知道,根据面向对象的编程理念,一个所谓的对象是一个封装的实体。这么来回说似乎有点循环定义的味道。但是,确实我们只能这样来理解。 在实际应用中个,我们通常可以将一个实体理解为数据库中某个表格中记录的PHP中的类实现。 我们可以简单地说:有一个`user`表格,保存了诸如用户名,密码,主页等用户信息,那么通过某种方式将这个表格映射到一个`User`实体,这个实体有着诸如`username`, `password`, `hompage`这样的属性,也有类似`setusername`这样的方法来设置某个属性。 或者,更SF的想法是,我有一个`User`实体,其中定义了诸如`username`,`password`,`homepage`这样的属性和一些方法来操作属性,我们可以要求SF和Doctrine根据我们这个实体的结构来创建相应的数据库表格以存续(persist)这样的用户信息。 这是一个很巨大的变动。对我们如何编程,先定义什么后定义什么的顺序以及这个顺序内涵的意义有着不寻常的影响。 以往我们总是先定义一个数据库结构,定义各个表格以及之间的关系,然后才是通过ORM将数据库结构映射回一个类。而现在,SF的推荐方式是先不要管底层数据库会怎样,我们先要关心的是我们的应用需要哪些实体的支持,这些实体怎样互相操作,又各自提供怎样的一些属性和方法。这些工作做完之后,才是数据库结构的映射和将来对象得以存续。 现有数据库再有对象是传统的思路;而先有对象再有数据库是SF提倡的思路。 在本书所讲述的应用开发过程中,我们会用到这两种不同的方式。 在最开始的时候,我们用传统的方法:先定义数据库然后导出,并映射到一个个实体。这是因为我们的应用定义很明确,要处理的对象也非常明确。 在开发过程中,我们会采用SF提倡的方法,对我们的实体进行一些微调,这些微调可能是会影响到数据库表格结构的。我们将会发现,这个反向(或者更应该说是正向?)的操作室无损的,不会影响数据库中现有数据。 也许,在我们的开发中,这样两个方向的调整还是需要进行若干次的。这样的循环没有一个固定的模式,我们要根据实际的需要和自己的经验来确定此时此地用哪个方向的映射最合适。 讲述了这么多实体的理论,我们来看一个典型的实体的代码。这是一个书籍表格的映射,文件位于:`src/AppBundle/Entity/Book.php`: ~~~ id; } /** * Set bookid * * @param string $bookid * @return BookBook */ public function setBookid($bookid) { $this->bookid = $bookid; return $this; } /** * Get bookid * * @return string */ public function getBookid() { return $this->bookid; } ... ... } ~~~ 以上列出的只是该实体很小的一部分。一般而言,一个实体中将包含所有属性(全部是`private`成员)和针对该属性的R/W操作(对于某些只读或者只写参数,R/W操作会只有一个,但无论如何,都是`public`函数)。 对于实体的更多讨论,我们在后续章节会结合编程的过程加以进一步的介绍。
';

3.4 Controller/控制器

最后更新于:2022-04-01 22:37:26

# 控制器 控制器其实就是一个PHP类。 控制器的作用在于,接受来自路由的调度,进行相应的工作(获取请求的参数,进行数据库的查询或操作,对返回的数据加以进一步的处理,显示一个模板并对模板中的变量加以赋值)。 出于管理的需要,我们通常将对某个实体进行操作的工作集中归并到一个类中,这个类的名称、类中方法的命名都遵循一定的规范。 ## 类的名称和类成员的名称 从之前我们讨论的[路由](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/03.03%20route.html)中,我们看到这样一个路由: ~~~ homepage: pattern: / defaults: { _controller: AppBundle:Default:index } ~~~ 我们知道这个路由表示的路径是`/`,也就是一般意义上的站点首页的位置,而它对应的控制器是`AppBundle:Default:index`,再回想一下我们在[包](https://taylorr.gitbooks.io/building-a-web-site-with-symfony/content/03.02%20bundle.html)这一章中展示的包结构图,SF的规定是这样的: 1. 所有的控制器都位于`AppBundle\Controller`目录下。 2. 定义控制器类的文件命名规范是:*Name*+Controller.php。而这里的`name`和该路由定义的`AppBundle:Default:index`的第二部分(也就是`Default`)一样。所以,针对`homepage`这个路由,我们必然要有一个`DefaultController.php`的文件与之对应。 3. 这个文件必须定义一个*Name*Controller的类,且这个类必须派生于`Controller`类。 4. 鉴于路由定义的第三部分`index`,它规定了具体采用什么动作。与之对应的是这个类中的成员函数。这个成员函数必须是`public`,而且命名为`indexAction`。也就是说,它的名称是路由定义中的第三部分`action`加上`Action`这个后缀。 5. 函数参数的定义必须和路由中的要求一致。参数出现的顺序并不是特别重要,但是名字必须和路由中指定的参数名称有对应。我们会在以后再更详细地讨论路由参数和控制器参数的问题。 6. 控制器类必须有自己的`namespace`声明。通常它就是该文件所在目录,因此它应该总是`namespace AppBundle\Controller;`。 一个典型的控制器类的代码可能是这样的: ~~~ //File: src/AppBundle/Controller/DefaultController.php namespace AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Security; class DefaultController extends Controller { public function indexAction() { // Code for index action. } // More codes and more actions } ~~~ 上述代码中出现的一些`use`语句,我们在此不多做解释。只说明一点:它们是根据控制器中代码的需要所引入的命名空间。 关于控制器,本节就描述到这里。控制器是SF中最关键的一个概念,也是我们用SF编写应用时写代码最多、业务逻辑最集中的一个地方。
';