Показаны сообщения с ярлыком PHP. Показать все сообщения
Показаны сообщения с ярлыком PHP. Показать все сообщения

вторник, 21 октября 2014 г.

Web Application Development with Yii 2 and PHP book (by me)

Holy cow, I wrote a book!

After the terror of 6 months writing, re-reading and re-writing, "Web Application Development with Yii 2 and PHP" have finally been published at 26th of September.

It was completely unexpected for me to receive this contract, I have never used version 2 of Yii framework, which was in early beta at the time, and had quite small Web presence to be spotted by any publisher. But it happened (thank you, God).

Yeah, URL is https://www.packtpub.com/web-development/web-application-development-yii-2-and-php, and, honestly, without any self-promotion, if you want to develop using Yii 2, go buy this book, because I took a lot of time inspecting this framework so you will not need to do the same. For example, you will not get the exact details of how the error reporting works in Yii from the official documentation. The code is always the last source of truth, and I tried hard to express in plain technical English what it actually does.

To be honest, I strongly dislike Yii. I think that Yii 2 is enormous leap forward since version 1.1.x but I still dislike this framework. Each book I read about pro-level software development tells me things which I find completely ignored or misinterpreted in Yii. Reading its code was also not a pleasurable experience.

And trust me, our team at Clevertech is maintaining a ~80KLOC application based on Yii 1.1 idioms (initially) and at second year we came to conclusion that we don't need most of it. For example the whole ActiveRecord layer. I don't even talk about basing the project structure on the conventions, "models-controllers-views" directory triad is a biggest bullshit I saw so far in my career. Our "common/models" directory has like 15 subfolders in it, and we have no other choice, because you cannot stuff everything into ActiveRecord descendant, and there's no "domain_models" directory in the bastard of MVC which Yii implements.

Err... I went away. The book. Right.

I wrote it nevertheless. I am working with Yii application (version 1.1 though) every day and actually can tell a thing or two about it. So, publisher asked and I answered.

I had two main premises when writing.

  1. Show how you can use Yii 2 when developing your application, not simply "how to use Yii 2", which is covered already by official documentation.
  2. Show how to work in ATDD manner with CI in mind, even using the framework. There is clearly not enough literature which shows how to properly develop applications right from the start, without rudimentary examples like bowling kata.
So, the whole Chapter 2 is a glorious implementation of the point 2. Or so I thought. This chapter ended being probably of the worst quality from the whole book. It contains so much testing harness and deployment machine setup that in the end I separated the whole Appendix out of it and had thrown away around 10 pages which were not related to Yii 2 at all. And it  may be still so boring to read (if you wanted to learn Yii 2 and know that stuff already) that probably 9/10 readers will drop the book right there and not go to the really interesting stuff like how Yii 2 extensions work or its event system or new Request/Response model.

The book uses a single example application, slowly adding more and more features from chapter to chapter. The application is pretty trivial, it's a CRM skeleton, and by the end of the book there are mainly two features: adding the customer and fetching his info given the phone number; and sign in to the system using login/password. There are also a whole Yii 2 extension, though.

I seriously tried to put all I learned through the professional literature like the DDD, GOOS, Clean Code and such, but I personally think I failed at this quite spectacularly. :) But apart from that, I tried my best to show the pieces of the Yii 2 picture which are not covered (and probably will never be) by the official docs and references. Hope it'll be useful for you.

Also, there's no other book on this topic yet so you have no choice anyway. ;)

There were a serious misunderstanding from my part also: I completely missed the point that this book is legally the "second edition" of the previous book, "Web Application Development with Yii and PHP" (note the absence of the number 2) by different author, Jeffrey Winesett. The outcome of this is that I become something like a co-author with both names present on the cover. It hurt a bit, as I lived 6 months in permanent crunch mode (book writing is not my fulltime job after all) and Jeff is not related to this book at all, but we both are listed as authors equally.
This does not apply to the royalties, though, so I am not bothered by this so much.

I did this for greed, not for pride, after all. :) This is not the kind of book I would be proud of. Personally, I would promote Common Lisp-based development, not the Yii 2 framework (doh) over PHP (holy cow).

суббота, 20 июля 2013 г.

How to package and use Yii framework in PHAR archive

Okay, today's the task: pushing all of 1892 files of Yii framework to the Git repository is a burden, and upgrading it to new version pollutes the git log with changes to files you don't care about. Let's package it into a PHAR!

Packaging

Rasmus Schultz made a special script to package the Yii framework into a PHAR archive. I forked it to save for a future (at least my GitHub account will live as long as this blog).

You need just to put this script to the root of Yii codebase (cloned github repo, for example), and run it as usual:

php yii-phar.php

This will create the PHAR archive in the same directory.

Hovewer, beware the catch 1: PHP can refuse to create packed archive, emitting the following error:

PHP Fatal error:  Uncaught exception 'BadMethodCallException' 
with message 'unable to create temporary file' 
in /path/to/your/yii/root/yii-phar.php:142
Stack trace:
#0 /path/to/your/yii/root/yii-phar.php(142): Phar->compressFiles(4096)
#1 {main}
  thrown in /parh/to/your/yii/root/yii-phar.php on line 142

I decided to just remove lines 140 and 142 from the script:

echo "Compressing files ...\n\n";

$phar->compressFiles($mode);

And that's all. I can bear with 20 MB file in repo, and don't really care about compression.

Using

To connect the resulting PHAR to your Yii application, replace your usual:

require_once('/path/to/your/yii/framework/yii.php');

With the following:

new Phar('/path/to/yii.phar');
require_once('phar://yii/yii.php');

Note that in new Phar() invocation you should use real path to your phar archive file, but second line should be written verbatim, as the PHAR which becomes created is being made with alias 'yii', using feature described in the documentation for Phar::__construct.

However, of course, there's a catch 2: Yii built-in asset manager (CAssetManager) has too specific `publish` method, unable to cope with custom PHP streams. So, we need the fixed version.

I decided to create a descendant of `CAssetManager` descriptively called `PharCompatibleAssetManager` with the following definition exactly:

/**
 * Class PharCompatibleAssetManager
 *
 * As we use Yii packaged into .phar archive, we need to make changes into the Asset Manager,
 * according to the https://code.google.com/p/yii/issues/detail?id=3104
 *
 * Details about packaging Yii into .phar archive can be found at
 * https://gist.github.com/mindplay-dk/1607318
 */
class PharCompatibleAssetManager extends CAssetManager
{
  protected $_published = array();

  public function publish($path,$hashByName=false,$level=-1,$forceCopy=null)
  {
    if($forceCopy===null)
      $forceCopy=$this->forceCopy;
    if($forceCopy && $this->linkAssets)
      throw new CException(Yii::t('yii','The "forceCopy" and "linkAssets" cannot be both true.'));
    if(isset($this->_published[$path]))
      return $this->_published[$path];

    $isPhar = strncmp('phar://', $path, 7) === 0;
    $src = $isPhar ? $path : realpath($path);

    if ($isPhar && $this->linkAssets)
    {
      throw new CException(
        Yii::t(
          'yii',
          'The asset "{asset}" cannot be published using symlink, because the file resides in a phar.',
          array('{asset}' => $path)
        )
      );
    }

    if ($src !== false || $isPhar)
    {
      $dir=$this->generatePath($src,$hashByName);
      $dstDir=$this->getBasePath().DIRECTORY_SEPARATOR.$dir;
      if(is_file($src))
      {
        $fileName=basename($src);
        $dstFile=$dstDir.DIRECTORY_SEPARATOR.$fileName;

        if(!is_dir($dstDir))
        {
          mkdir($dstDir,$this->newDirMode,true);
          @chmod($dstDir,$this->newDirMode);
        }

        if($this->linkAssets && !is_file($dstFile)) symlink($src,$dstFile);
        elseif(@filemtime($dstFile)<@filemtime($src))
        {
          copy($src,$dstFile);
          @chmod($dstFile,$this->newFileMode);
        }

        return $this->_published[$path]=$this->getBaseUrl()."/$dir/$fileName";
      }
      elseif(is_dir($src))
      {
        if($this->linkAssets && !is_dir($dstDir))
        {
          symlink($src,$dstDir);
        }
        elseif(!is_dir($dstDir) || $forceCopy)
        {
          CFileHelper::copyDirectory($src,$dstDir,array(
            'exclude'=>$this->excludeFiles,
            'level'=>$level,
            'newDirMode'=>$this->newDirMode,
            'newFileMode'=>$this->newFileMode,
          ));
        }

        return $this->_published[$path]=$this->getBaseUrl().'/'.$dir;
      }
    }
    throw new CException(Yii::t('yii','The asset "{asset}" to be published does not exist.',
      array('{asset}'=>$path)));
  }
}

I'm really, really sorry that you had to read this traditionally horrible Yii code, but that was inevitable... :(

Main change was starting from the $isPhar = strncmp('phar://', $path, 7) === 0; part.

Now just link this asset manager instead of built-in one:

config/main.php:
        'components' => array(
            'assetManager' => array(
                'class' => 'your.alias.path.to.PharCompatibleAssetManager',
            ),
        )

Congratulations!

Now your Yii web application uses phar archive instead of huge pile of separate files. They say that this increases performance, but my personal reasons was just to reduce the number of files inside the repository.

вторник, 28 августа 2012 г.

Context-dependent Behat tests steps

Preamble is this: we have the PHP-based website, and we are testing it with the Behat+Mink+MinkExtension combo.

Suppose we want to write the following test scenario:

When I am in the Friends section

… (something there) …

Then I should see “My Friend” in search results

Let's define this steps in our FeatureContext. First step we can define with the following regexp: /^I am in the Friends section$/ because we really don’t need the method of FeatureContext class containing long switch enumerating every possible section of the site.


/**
 * @Given /^I am in the "Friends" section$/
 */
public function iAmInTheFriendsSection() {
 return new Given('I am on "/friends"');
}

Second step we can define with the following regexp: /^I(?: should)? see "([^"]*)" in the search results$/.


/**
 * @Then /^I should see "([^"]*)" in the search results$/
 */
public function iShouldSeeInTheSearchResults($search_term) {

 // separate helper function to search the "search results" HTML element
 $search_results = $this->getSearchResultsElement();

 // separate helper function to search the $search_term text in $search_results element
 $this->trySearchTextOnDomElement($search_term, $search_results);

}

It should be obvious why we use the custom test step instead of using the predefined test steps and writing something like 'I should see "My Friend" in ".search-wrapper form input[role="search"]" element'.

Then, someday, sure thing, we will want to write the following scenario:

When I am in the Shop section

… (something there) …

Then I should see “Interesting product” in search results

And in here, we have another “search results”, which should be found by completely different selector and which is located on different page.

So, this is the context-dependent statement: term “search results” depends on what “section” we mentioned previously. This is right from the linguistics. To be able to use this natural-language feature we need to implement it somehow.

I'll use the abbrev CDTS instead of longer "context-dependent test step".

Fortunately, Behat has a feature with exactly the same purpose: subcontexts. Unfortunately, it's not working in the way we need to use the CDTS properly.

In an ideal world, we can do this:


/**
* @Given /^I am in the Friends section$/
*/
public function iAmInTheFriendsSection() {
 $this->useContext('friends_section', new FriendsSectionContext())
 return new Given('I am on "/friends"');
}

and this would load the FriendsSectionContext and all CDTS definitions in it, like the following:


// in FriendsSectionContext class
/**
 * @Then /^I should see "([^"]*)" in the search results$/
 */
public function iShouldSeeInTheSearchResults($search_term) {
 // This selector is valid only in the context of "Friends" section.
 $search_results_selector = '#PeopleFinder #pf_all .results';

 // Logic to check if the given $search_term is the present in anything called "search results" in the context of "Friends" section.
 $search_results = $this->getSession()->getPage()->find('css', $search_results_selector);
 $search_term_present = strpos($search_results->getHtml(), $search_term);
 if ($search_term_present === false) {
  throw new Exception(...);
 }
}

We useContext different context class, we get different definition for the /^I should see "([^"]*)" in the search results$/ test step.

Unfortunately, Behat cannot load the test step definitions from subcontexts at runtime. Apparently, it’s because it should parse the regexps in docblocks corresponding to definitions or something like that. So, are forced to load all our subcontexts right in our constructor.

Apart from being horribly ineffective, this prevents us from defining the test steps having same regexp across several different separate subcontexts.

Workaround for this problem is this:

  1. add the property to the FeatureContext which will hold the reference to current subcontext, name it like "location_context" or so,
  2. make the context-setting ('I am in the "..." section') test step set the "location_context" to the subcontext needed (you can get the subcontext with the call to getSubcontext('alias')),
  3. move the context-dependent logic to “normal” subcontext methods, which should have the same name across all subcontexts,
  4. register all subcontexts with useContext under meaningful aliases like "friends_section", "shop_section", etc,
  5. define the context-dependent test step like 'I should see "..." in search results' in main FeatureContext class,
  6. in the definition of this step, get the context-dependent logic needed by calling the relevant method on the subcontext the "location_context" property currently points at.

So, we need our context-setting test steps to be like this:


/**
 * @Given /^I am in the "Friends" section$/
 */
public function iAmInTheFriendsSection() {
 $this->location_context = $this->getSubcontext('friends_section');
 return new Given('I am on "/people"');
}

Assuming 'friends_section' is an alias of the FriendsSectionContext, and it was set in the constructor, after this test step, our "location_context" will be FriendsSectionContext, and, say, it's getSearchResultsElement() will do exactly what we need in the "Friends" section.

Then, the context-depentent test step will be like this, getting the location-dependent logic from the "location_context" set previously:


/**
 * @Then /^I should see "([^"]*)" in the search results$/
 */
public function iShouldSeeInTheSearchResults($search_term) {
 // getSearchResultsElement() is defined in subcontext which was set before in $this->location_context
 $search_results = $this->location_context->getSearchResultsElement();
 $search_term_present = strpos($search_results->getHtml(), $search_term);
 if ($search_term_present === false) {
  throw new Exception(...);
 }
}

Main point is this: we want to check if something should appear in the "search results" entity in some different page → we can use the same test step in our .feature files, just explicitly name the section needed beforehand somewhere above in the text. This will make the .feature files a lot more human-readable.

This concludes the explanation about how to use this linguistic technique in Behat tests.

четверг, 7 июня 2012 г.

Паттерн «Веб-функция»

Допустим, вам нужно написать endpoint для аякс-запроса. Или обычный обработчик обычного POST-запроса от веб-формы. Тогда основные действия, которые обязательно должны быть в процессе обработки, будут такие:

  • Фильтрация входных данных (это НЕ валидация бизнес-правилами, только лишь такие действия, как укорачивание, очистка от окружающих пробелов, исключение непечатных символов, приведение к нижнему регистру и т. д.). Или мы можем даже сыграть роль адаптера и переименовать некоторые параметры запроса в вид, ожидаемый обработчиком.
  • Обработка запроса. Здесь мы собственно делаем то, зачем существуем. Мы ожидаем уже более-менее очищенный от мусора массив входных параметров, возможно, среди которых есть ошибочные (например, числовое значение вне допустимого диапазона, или передан массив вместо скаляра).
  • Форматирование результата для отклика клиенту. Например, мы можем возвращать JSON, или генерировать HTML страницу по шаблону. Или генерировать изображения. Или отдавать файлы с диска.

Если записать в функциональном стиле на PHP, то можно получить следующий шаблон хэндлера:


<?php

echo format(process(clean($_REQUEST)));

/**
 * Здесь очистка суперглобального массива $_REQUEST и генерация массива $request
 * 
 * @param array $request Данные запроса из массива $_POST или $_GET (или $_REQUEST).
 * @return array Очищенные данные из запроса, которые ожидает функция process.
 */
function clean($request)
{
  // TODO
  return $request;
}

/**
 * Здесь выполнение действий согласно запросу $request
 * 
 * @param array $request Параметры запроса, очищенные функцией clean.
 * @return array $result Результат работы со всей информацией, необходимой для форматирования ответа клиенту.
 */
function process($request)
{
  // TODO
  return $request;
}

/**
 * Здесь форматирование результата работы для выдачи клиенту.
 * 
 * @param array $result Результат работы, как он сгенерирован процессором. Нам не дозволяется использовать какие-либо другие данные.
 * @return string Форматированный ответ, готовый к отправке клиенту через echo.
 */
function format($result)
{
  // TODO
  return json_encode($result);
}
?>

Если process сталкивается с ошибкой, он пишет об этом в результат своей работы (например, в поле 'error') и сразу возвращает результат в format.

Если format должен генерировать большой HTML документ, то ничего страшного, он может и делать echo внутри себя, вместо того, чтобы возвращать строку. Тогда вызов всей цепочки будет без echo в начале, конечно же.

понедельник, 26 декабря 2011 г.

Фильтры в Битриксе

Сегодня, после года практики в Битриксе мне открылось поистине тайное знание.

Когда передаёшь в компонент имя переменной-фильтра через 'FILTER_NAME', Битрикс ожидает имя глобально доступной переменной. Там прямо в исходниках любого компонента можно увидеть вызов

global ${$arParams['FILTER_NAME']}
или, во всяком случае, аналог этого кода. А раз используется поиск переменной средствами ключевого слова global, то без шаманства можно забыть о таких интересных вещах, как динамическая генерация фильтра внутри объектов классов или вообще внутри любого lexical scope, отличающегося от глобального.

Сегодня же до меня дошёл смысл этого global. Фильтр надо просто сохранять как элемент массива $GLOBALS, вместе со всеми остальными глобальными переменными. Это, по-видимому, самый надёжный способ генерировать фильтр и передавать его имя в компонент.

Следующие две инструкции могут быть теперь вообще где угодно, как угодно глубоко внутри иерархии классов и/или стека вызовов:

$GLOBALS['similar_items'] = array(
 '=EXTERNAL_ID' => getSimilarItems($arResult)
);
$APPLICATION->IncludeComponent(
 "bitrix:catalog.section",
 "",
 Array(
...
  "FILTER_NAME" => 'similar_items',
...
 )
);

Я знаю, вы всегда мечтали хранить временные данные в глобальной области видимости, не правда ли?

Пиздец.