Symfony2について

Symfony2ってどんなフレームワークなの?って聞かれたので答えてみる。

あ、最初に書いておきますが、これはあくまで自分の主観です。
自分もSymfony2のコアな部分のコードはそんなに読んでいないので、Symfony2を学習するうえで思ったことです。
ここは違うだろう、とかあれば突っ込んでいただけるととても嬉しいです。

Symfony2ってのはSymfony2 Componentsを利用して作られたフルスタックフレームワークです。
ここSymfony2Symfony2 Componentsは別に考えることが重要。
Silexってフレームワークもあるんだけど、これもSymfony2 Componentsが利用されて作られています。

ようは何が言いたいのかっていうと、Symfony2 Componentsってのは物凄く優秀なコンポーネント群で各々のコンポーネント疎結合度が高いので、コンポーネントの再利用がめちゃくちゃしやすいってことです。
Symfony2の開発者であるFabien氏もSymfony2を使って独自のフレームワークの作り方みたいな記事を書いていますしね。
(参照: http://fabien.potencier.org/article/50/create-your-own-framework-on-top-of-the-symfony2-components-part-1


コンポーネントが再利用しやすいと、フレームワークを使うまでもないときでも特定のコンポーネントだけ使うこともできますし、
Fabien氏の記事の内容からもコンポーネントだけ再利用してオレオレフレームワークを作ることだって出来ます。

これだけ聞くとZend Frameworkと似てる気がするけど、実際は似て非なるものだと思う。
確かに設計思想は似ているけど、フルスタックフレームワークとして考えるとSymfony2のほうが遥かに高機能。
個人的にはどちらかというと、Zend Frameworkはフレームワークというよりコンポーネント群な気がする。


OOPな考え方的にオブジェクトの独立性は高いに越したことはないと思っています。
コンポーネント疎結合度の低いフレームワークは色々な縛りが大きくプラグインなどの作成も面倒だったりすることもあります。
そういう意味ではZend Frameworkは良い設計思想だと思います。


ようは何が言いたいかというとみんなSymfony2やればいいよってことです。

Symfony2で複雑なフォームの実装について

Symfony2で少し複雑なフォームを作る際のメモ書き。

例として1つの商品は複数のカテゴリに属する。
要件としては管理画面の商品の設定画面にて、複数のカテゴリを設定することとする。
更に設定したカテゴリにはついでに何かメモ書きを設定出来ることとする。

各Entityは以下のように実装する。

Product.php (商品)

<?php
// ...
class Product
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="product_id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string $productName
     *
     * @ORM\Column(name="product_name", type="string", length="255", nullable=true)
     */
    private $productName;

    /**
     * @var string $amount
     *
     * @ORM\Column(name="amount", type="integer", nullable=false)
     */
    private $amount;

    /**
     * @var ArrayCollection $productCategories
     *
     * @ORM\OneToMany(targetEntity="ProductCategory", mappedBy="product", cascade={"persist", "remove"})
     */
    private $productCategories;


    public function __construct()
    {
        $this->productCategories = new \Doctrine\Common\Collections\ArrayCollection();
    }

    public function getId()
    {
        return $this->id;
    }

    public function setProductName($productName)
    {
        $this->productName = $productName;
    }

    public function getProductName()
    {
        return $this->productName;
    }

    public function setAmount($amount)
    {
        $this->amount = $amount;
    }

    public function getAmount()
    {
        return $this->amount;
    }

    public function addProductCategory(Category $category)
    {
        $this->productCategories[] = $category;
    }

    public function getProductCategories()
    {
        return $this->productCategories;
    }
}

Category.php (カテゴリ)

<?php
// ...
class Category
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="category_id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string $categoryName
     *
     * @ORM\Column(name="category_name", type="string", length="100", nullable=true)
     */
    private $categoryName;


    public function __toString()
    {
        return $this->getCategoryName();
    }

    public function getId()
    {
        return $this->id;
    }

    public function setCategoryName($categoryName)
    {
        $this->categoryName = $categoryName;
    }

    public function getCategoryName()
    {
        return $this->categoryName;
    }
}

ProductCategory.php (商品・カテゴリのリレーション)

<?php
// ...
class ProductCategory
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="product_category_id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var Product $product
     *
     * @ORM\OneToOne(targetEntity="Product")
     * @ORM\JoinColumn(name="product_id", referencedColumnName="id")
     */
    private $product;

    /**
     * @var Category $category;
     *
     * @ORM\OneToOne(targetEntity="Category")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     */
    private $category;

    /**
     * @var string $memo
     *
     * @ORM\Column(name="memo", type="string", nullable=true)
     */
    private $memo;


    public function getId()
    {
        return $this->id;
    }

    public function setProduct(Product $product)
    {
        $this->product = $product;
    }

    public function getProduct()
    {
        return $this->product;
    }

    public function setCategory(Category $category)
    {
        $this->category = $category;
    }

    public function getCategory()
    {
        return $this->category;
    }

    public function setMemo($memo)
    {
        $this->memo = $memo;
    }

    public function getMemo()
    {
        return $this->memo;
    }
}

で、次に商品登録用のフォームを作成する。
下記がその実装。

ProductType.php

<?php
namespace Sample\Bundle\DemoBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

use Sample\Bundle\DemoBundle\Form\Type\ProductCategoryType;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('productName')
            ->add('amount')
            ->add('productCategories', 'collection', array(
                'type'         => new ProductCategoryType(),
                'allow_add'    => true,
                'allow_delete' => true,
                'prototype'    => true,
            ))
        ;
    }

    public function getName()
    {
        return 'product';
    }
}

呼び出しているProductCategoryTypeクラスは下記のとおり。

ProductCategoryType.php

<?php
namespace Sample\Bundle\DemoBundle\Form\Type\Edit;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class ProductCategoryType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('category', 'entity', array(
                'empty_value' => '選択してください',
                'class'       => 'Sample\\Bundle\\DemoBundle\\Entity\\Category',
            ))
            ->add('memo')
        ;
    }

    public function getDefaultOptions(array $options)
    {
        return array(
            'data_class' => 'Sample\Bundle\DemoBundle\Entity\ProductCategory',
        );
    }

    public function getName()
    {
        return 'productCategory';
    }
}

ここまで実装できたら後は普通にProductTypeを呼び出してフォームを作成するだけ。
Twig内では下記のようにしてProductCategoryのフォームを呼び出すことができる。

{# プロトタイプを取得 #}
{{ form_widget(form.productCategories.get('prototype').getChild('category')) }}
{{ form_widget(form.productCategories.get('prototype').getChild('memo')) }}

{# bindされているものを取り出す #}
{% for productCategory in form.productCategories %}
    {{ form_widget(productCategory.category) }}
    {{ form_widget(productCategory.memo) }}
{% endfor %}

後は自分でJavascriptの実装をするなりして複雑なフォームを実装していけた。

指定したIPで並列HTTPリクエストする方法

IPアドレスが複数割り当てられているサーバから指定したIPでHTTPリクエストを送りたかったのでそれのメモ。
PHPから普通にHTTPリクエストを送信する方法はいろいろあります。

パッと思いついたのを書いていくとこんな感じ。

  1. cURL
  2. file_get_contents
  3. fsockopen
  4. ソケット関数
  5. PEAR::HTTP_Request

PEAR::HTTP_Requestについてはライブラリです、実際の中身は確かfsockopenで実装されていたはず。
で、上記の方法のうち4以外の方法だとクライアント側のIPアドレスを指定してリクエストを送信することはできない。
* 2012/01/28 - 他の方法でも実装出来ることを教えてもらったので修正

4の場合はsocket_bindを利用すればクライアント側のIPを指定してリクエストを送信することができる。

最初にテストで書いてみたのが下記のコード

<?php
$ipaddress = 'xxx.xxx.xxx.xxx';

$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($sock, $ipaddress);
socket_connect($sock, 'example.com', 80);
socket_write($sock, "GET / HTTP/1.0\r\n\r\n");
$buffer = socket_read($sock, 8192);
echo $buffer;

結果、ちゃんとIPアドレスを指定してリクエストが送れた。

で、更に並列にリクエストを何個も送信したかったので、プロセスをforkさせてリクエストを送信するようなプログラムを書いてたんだけど、
なぜかうまくいかない…

ソケットはブロッキングされないようにちゃんとsocket_set_nonblockしてる。

エラーを見てみると、Operation now in progressってのが大量に発生してる。
Operation now in progressを調べてみるが問題解決には繋がらず。
で、ふとsocket_connect関数のマニュアルを読んでる時に気づいた。

返り値
成功した場合に TRUE を、失敗した場合に FALSE を返します。 エラーコードは、 socket_last_error() により取得できます。 このコードを socket_strerror() に渡すことにより、 エラー内容を表すテキストを得ることができます。


注意:
ソケットが非ブロッキングモードの場合、この関数は FALSE を返し、エラー Operation now in progress を発生させます。

http://jp.php.net/socket_connect

なんと!注意のところを見てなかった…

だから、socket_connect関数が使われてるコードにはたまに先頭に@がついてるのかーと思った。
で、更に色々調べてるうちにプロセスをforkしなくても並列にリクエストを送信する方法がわかったのでそれも下記のコードに示す。

<?php
$requests = array(
    'xxx.xxx.xxx.xxx' => 'example.com',
    'yyy.yyy.yyy.yyy' => 'example.com',
    'zzz.zzz.zzz.zzz' => 'example.com',
);

$socks = array();

// ソケットを予め作っておく
foreach ($requests as $ipaddress => $host) {
    $sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
    socket_bind($sock, $ipaddress);
    socket_set_nonblock($sock);
    $result = @socket_connect($sock, $host, 80);
    if (true == $result || SOCKET_EINPROGRESS !== socket_last_error()) {
        exit('error!');
    }
    $socks[] = $sock;
}

$responses = array();

// リクエストの送信・受信
while (count($socks)) {
    $writes = $reads = $socks;
    $excepts = null;
    
    $num = socket_select($reads, $writes, $excepts, 120);
    if ($num) {
        foreach ($writes as $write) {
            socket_write($write, "GET / HTTP/1.0\r\n\r\n");
            socket_shutdown($write, 1);
        }

        foreach ($reads as $read) {
            $id = array_search($read, $socks);
            $responses[$id] .= $buffer = socket_read($read, 8192);
            if (0 == strlen($buffer)) {
                socket_close($read);
                unset($socks[$id]);
            }
        }
    }
}

print_r($responses);

こんな感じでクライアントのIPアドレスを指定して、並列にHTTPリクエストが送信できる。

Symfony2のバリデーションについて

アプリケーションを開発していくうえでカスタムバリデーションを作るまでもないけど、
独自のバリデーションを追加したかったり、動的にバリデーションを行いたいことがよくある。
Symfony2ではどのように行うのかメモっておく。

例えばお問い合わせフォームなどで、利用規約の同意にチェックが入っていないと送信できないようにしたい。
この例の場合、フォームクラスは下記のようになる。

<?php
namespace Sample\Bundle\DemoBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

use Symfony\Component\Form\CallbackValidator;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormError;

class ContactType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('name', 'text')
            ->add('email', 'email')
            ->add('body', 'textarea')
            ->add('terms', 'checkbox', array('property_path' => false))
        ;

        $builder->addValidator(new CallbackValidator(function(FormInterface $form) {
            if (!$form['terms']->getData()) {
                $form['terms']->addError(new FormError('利用規約に同意してないと送信できないよ'));
            }
        }));
    }

    public function getName()
    {
        return 'contact';
    }
}

termsのオプションでproperty_pathをfalseにすることによって、Entityクラスに依存しなくなる。
で、addValidatorにCallbackValidator経由でクロージャを登録することによって独自のバリデーションを行えるようになる。

コントローラに書くことも出来るけど、コントローラがあまり複雑になるのはよくないので、フォームクラスで簡潔させたほうがスマートだと思う。


【参考ページ】

  1. http://fivestar.hatenablog.com/entry/2011/12/06/013145
  2. http://www.richsage.co.uk/2011/07/20/adding-non-entity-fields-to-your-symfony2-forms/