《Yii Rapid Application Development Hotshot》

Chapter 8. Extend Yourself – Make a Module for Reuse

In this chapter, we will package the job queue function that we created in Project 7, Let It Work While You Sleep – Reports and Job Queues, into a module so we can share it and reuse it in future work. In the process, we will cover what makes a good module, how to put a module together, and how to post a module to the Yii community.

Mission Briefing

You will find yourself reusing some entities and functions in project after project. If you wanted to include that work in multiple projects without a framework for doing so, you would have to:

  • Review your past projects to find the pieces you want
  • Copy out each piece (model, view, controller, and supporting files)
  • Integrate your old work into your current project
  • Somehow replicate any changes or improvements to the shared work

Yii provides a facility for gathering your work into reusable packages, such as modules, widgets, and components, that you can plug into projects as needed.

In this project, we will cover:

  • How to identify good candidates for reuse
  • How to package the files into a module
  • How to test your module to make sure you can use it successfully
  • How to submit your module to the Yii community to share your work with the world

Why Is It Awesome?

What isn't awesome about writing a module?

  • You save yourself from having to write the same tasks over and over again
  • You can maintain cross-project code in one place and deploy updates easily
  • You can share your work with the rest of the world

It will take a little work to create a module from some of the functions in your project, but the time you will save reusing that module over and over again will more than make up for the effort.

Your Hotshot Objectives

  • Selecting Code for Reuse
  • Preparing Your Module Framework
  • Moving Your Module Files
  • Writing a Migration Script
  • Re-incorporating Your Module
  • Testing Your Module
  • Submitting Your Module

Mission Checklist

This project assumes that you have a web development environment prepared. If you do not have one, the tasks in Project 1, Develop a Comic Book Database, will guide you through setting one up. In order to work this project, you will need to set up the project files that have been provided with the book. Refer to the Preface of the book for instructions on downloading these files. The files for this project include a Yii project directory with a database schema. To prepare for the project, follow these steps, replacing the username lomeara with your own username.

  1. Copy the project files into your working directory.
cpr ~/Downloads/project_files/Chapter\ 8/project_files ~/projects/ch8
  1. Make the directories that Yii uses web writeable.
cd ~/projects/ch8/
sudo chown -R lomeara:www-data protected/runtime assets protected/models \ 
protected/controllers protected/views
  1. Create a link in the webroot directory to the copied directory.
cd /opt/lampp/htdocs
sudo ln -s ~/projects/ch8 cbdb
  1. Import the project into NetBeans (remember to set the project URL to http://localhost/cbdb) and configure for Yii development with PHPUnit.
  2. Create a database named cbdb and load the database schema (~/projects/ch8/protected/data/schema.sql) into it.
  3. If you are not using the XAMPP stack or if your access to MySQL is password protected, you should review and update the Yii configuration file (in NetBeans: ch8 | Source Files | protected | config | main.php).

The admin login to the website is admin/test.

Selecting Code for Reuse

Not everything we write is a good candidate for reuse. We are going to talk about some ways you can identify and isolate code for reuse. The Engage Thrusters section in this task is more of a checklist to review than a list of steps to take, but we will apply the checklist to our job module as we go.

Engage Thrusters

  1. Does the function operate on isolated tables in the database?

For example, although many tables in your database may refer to the user table, the user information can be isolated to a user table containing username and password with some extension tables for things such as personal and contact information. It is a good candidate, because you typically know from the start if a project will need user management. In which case, you can include a user management module at the beginning of a project and build from there.

If this is not the case, and your module does require some table information, you are not necessarily halted in your tracks. You could include support for module configuration so that users can identify required table information. For an example of a module that does this, see the RBAC module that we used in Project 4, Level Up! Permission Levels.

Our proposed module uses the tables job and scheduled_job from Project 7, Let It Work While You Sleep – Reports and Job Queues. The schema for job and scheduled_job look like the following:

The scheduled_job table depends on the job table, but no other tables are required. We are in good shape here.

  1. Can you easily identify the models, controllers, and views that will be a part of your module?

Similar to the table isolation, if your core objects are isolated, they are more easily incorporated into a module. And, again, if they are not, you may be able to address the dependencies by providing a way to configure the module.

The models we use for jobs are Job.php and ScheduledJob.php. As we know there are no table dependencies, so we can be pretty sure there are no model dependencies. And, when we review the files, we find that there are none.

The controllers are JobController.php and ScheduledJobController.php. They do not depend on other controllers or classes. They both inherit from the default Yii Controller class. This class may not be included in a project that would use the module. You can address this by:

    • Including the Controller class in your module, so that your controllers can inherit from it
    • Reworking your controllers to not inherit from a base class
      • We are going to take the first option, and include the base controller class in our module.

        It takes more time to go through the view files, because there are so many. You want to make sure that only standard Yii functions are used.

        The views for our proposed job module, job and scheduledJob, do have dependencies. They use three extensions: quickdlgs, flot, and timepicker. If your module depends on someone else's work, check the license for their work. quickdlgs and timepicker are under the BSD 2 license. flot has a different, but similar license. We can redistribute the extensions in our module if we adhere to the terms of their licenses.

        Alternately, we could note these dependencies in the documentation for our module, but for the sake of making an easily-testable, stand alone module, we will include all of the required extensions.

        1. Are there any complementary classes or utilities that should be included in your module?

        Maybe you created some additional classes or scripts that your functions use. Be sure to identify these and include them.

        Our job queue relies on a utility script, ch8 | Source Files | protected | utils | job_entry.php, and a command script, ch8 | Source Files | protected | commands | JobProcessorCommand.php.

        1. Does the code represent a task that is common across your projects?

        User management, contact management, comments, ratings. All of these modules are already implemented for Yii and provide common web application functionality. If you implement a common generic function, such as these, it is probably going to be worth your while to make a module out of it. But, of course, if you are thinking about implementing a common generic function, such as user management, contact management, and so on, check first to see if a module that meets your needs is already available.

        We searched Yii extensions (http://www.yiiframework.com/extensions/) for extensions related to scheduling jobs. We found some extensions for creating cron jobs. Our job manager does things a little differently, so we decided to move forward with implementing it and packaging it for reuse.

        Objective Complete - Mini Debriefing

        In this section, we reviewed the conditions and qualifications for creating a module. We looked at the tables, models, views, and controllers that make up our job management utility. We found no dependencies that require special handling, so we are ready to begin making a module.

        Preparing Your Module Framework

        The first task in creating a module is creating a space for the module. In this task, we will set up the framework that our module will inhabit.

        Engage Thrusters

        1. In the modules directory of your project (ch8 | Source Files | protected | modules) create a directory named jobQueue.
        2. In the newly created jobQueue directory, create the following directories:
          • commands
          • components
          • controllers
          • migrations
          • models
          • views
          • extensions

        The Unix command to create these directories looks like the one shown in the following screenshot:

        1. A module must include in its root directory a class that extends from CWebModule. In the main module directory (ch8 | Source Files | protected | modules | jobQueue), create a file named JobQueueModule.php and input the following contents:
        class JobQueueModule extends CWebModule
        {
            public function init()
            {
                $this->setImport(array(
                    'jobQueue.models.*',
                    'jobQueue.extensions.quickdlgs.*',
                    'jobQueue.extensions.timepicker.*',
                    'jobQueue.extensions.EFlot.*',
                    'jobQueue.components.*',
                ));
            }
        }

        This is the file where you would access and apply the configuration values that we mentioned in the Selecting Code for Reuse task.

        Classified Intel

        If you wanted to include configurable variables in your module, here is how you would do it:

        1. In the base module class, which extends CWebModule, add class variables for any configuration fields you want to include in your module. Be sure to provide a default value for your variable. For example, if you wanted to have a configuration variable named jobQueueUser, we would add a variable to the class as follows:
        class JobQueueModule extends CWebModule
        {
            /* @var $jobQueueUser String The name of the job queue user*/
            public $jobQueueUser = "lomeara";
        1. Now, when you want to change the configuration value for the module in your project, edit the main project configuration file. For example to override the default value for the previous jobQueueUser variable, we would add the following entry to ch8 | Source Files | protected | config | main.php:
        'modules'=>array(
            'jobQueue' => array(
                'jobQueueUser'=>'www-data',

        Objective Complete-Mini Debriefing

        The result of this task is a directory framework to hold our module. We can add module, view, and controller files. We can also expand on the module class, if we need to add configuration or custom behaviors.

        Moving Your Module Files

        An unavoidable task in our module creation is corralling our files into our module directory. We decided to multi-task and remove the files from our project while we place them in the module directory. In other words, we are just going to move the files from our project into the module. This is going to break the functionality temporarily. In a later task, we will take the necessary steps to make the job queue work again.

        Engage Thrusters

        1. Move the model files, Job.php and ScheduledJob.php, from your project model directory ch8 | Source Files | protected | models into your module model directory ch8 | Source Files | protected | modules | jobQueue | models.
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/models$ 
        mv ../../../models/Job.php .
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/models$ 
        mv ../../../models/ScheduledJob.php .
        1. Move the controller files, JobController.php and ScheduledJobController.php, from your project controller directory ch8 | Source Files | protected | controllers into your module controller directory ch8 | Source Files | protected | modules | jobQueue | controllers.
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/controllers$ 
        mv ../../../controllers/JobController.php .
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/controllers$ 
        mv ../../../controllers/ScheduledJobController.php .
        1. Move the view directories, job and scheduledJob, from your project view directory ch8 | Source Files | protected | views into your module view directory ch8 | Source Files | protected | modules | jobQueue | views.
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/views$ 
        mv ../../../views/job/ .
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/views$ 
        mv ../../../views/scheduledJob/ .
        1. Move all of the directories from your project extension directory ch8 | Source Files | protected | extensions into your module extension directory(ch8 | Source Files | protected | modules | jobQueue | extensions).
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/extensions$ 
        mv ../../../extensions/* .
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/extensions$ 
        ls
        EFlot  quickdlgs  timepicker
        1. Copy the Controller class from the component directory ch8 | Source Files | protected | components into your module component directory ch8 | Source Files | protected | modules | jobQueue | components.
        lomeara@YiiBook:~/projects/ch8/protected/modules/jobQueue/components$ 
        cp ../../../components/Controller.php .
        1. Remove the configuration entries for the extensions from the project configuration file ch8 | Source Files | protected | config | main.php, The import array, with the extension entries removed, should look like the following:
        'import'=>array(
            'application.models.*',
            'application.components.*',
            'application.modules.srbac.controllers.SBaseController',
            'application.modules.auditTrail.models.AuditTrail',
        ),
        1. Move the utility directory from your project directory ch8 | Source Files | protected | utils into your module directory ch8 | Source Files | protected | modules | jobQueue.
        2. Move the JobProcessorCommand script from the commands directory in your project ch8 | Source Files | protected | commands | JobProcessorCommand.php to the commands directory in your module ch8 | Source Files | protected | modules | jobQueue | commands | JobProcessorCommand.php.
        3. Create a console configuration directory ch8 | Source Files | protected | modules | jobQueue. Create a file in the module config directory ch8 | Source Files | protected | modules | jobQueue | config | console.php with the following contents:
        <?php
        return array(
            'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
            'runtimePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'../../../runtime',
            'preload'=>array('log'),
            'components'=>array(
                'log'=>array(
                    'class'=>'CLogRouter',
                    'routes'=>array(
                        array(
                            'class'=>'CFileLogRoute',
                            'levels'=>'info, error, warning', 
                            'logFile'=>'job.log',
                            'categories'=>'jobprocessor',
                        ),
                    ),
                ),
            ),
        );
        1. The resulting module directory tree should look this:

        Objective Complete - Mini Debriefing

        In this task, we moved all of the files related to our job module from our project directory into the jobQueue directory.

        Writing a Migration Script

        The migration script will make any database changes that our module requires. Since our module depends on the existence of two tables, we will write a migration script to create them.

        Engage Thrusters

        1. To create a new migration, change to the project directory. Run Yiic with the migrate command, specifying the path alias to the module migrations directory.
        cd ~/projects/ch8/protected
        php yiic.php migrate create create_tables_job_queue \ 
            --migrationPath=application.modules.jobQueue.migrations

        The command will output a file named something like this:

        m120927_012345_create_tables_job_queue.php

        The filename is the letter m, followed by the UTC timestamp of the time the file is created, followed by the name you gave the command.

        The contents of the file will look something like the following code snippet:

        <?php
         
        class m120927_012345_create_tables_job_queue extends CDbMigration
        {
            public function up()
            {
            }
         
            public function down()
            {
                echo "m120927_012345_create_tables_job_queue does not support migration down.\n";
                return false;
            }
         
            /*
            // Use safeUp/safeDown to do migration with transaction
            public function safeUp()
            {
            }
         
            public function safeDown()
            {
            }
            */
        1. The migration file that we generated is just a skeleton. Now we need to flesh it out with the actual migration steps. The up method holds the migration steps. The down method contains the steps to revert the migration, if they can be reverted. The generated code in the down function is for the case where a migration cannot be reverted.

        Let's start by completing the up function. We will need to create both the job and scheduled_job tables. And, actually, we will implement the safeUp function, because our database supports transactions. Replace up with the following code snippet:

        public function safeUp()
        {
            $this->createTable('job', array(
                'id' => 'pk',
                'name' => 'varchar(64) NOT NULL',
                'action' => 'varchar(64) NOT NULL',
                ));
            $this->createTable('scheduled_job', array(
                'id' => 'pk',
                'params' => 'text',
                'output' => 'text',
                'job_id' => 'int(11) NOT NULL',
                'scheduled_time' => 'datetime NOT NULL',
                'started' => 'datetime NOT NULL',
                'completed' => 'datetime NOT NULL',
                'active' => 'tinyint(1) DEFAULT \'0\'',
            ));
            $this->createIndex( 'job_id', 'scheduled_job', 'job_id');
            $this->addForeignKey('scheduled_job_ibfk_1', 'scheduled_job', 'job_id', 'job', 'id');
        }

        This function will create the related tables we have already been using: job and scheduled_job, as well as the relationship from scheduled_job to job. Because we have used the transaction safe method, the contents of the function will be wrapped in a transaction. If your function included any database commands that do not carry an implicit commit, such as insert, update or delete, those steps would be rolled back if any step fails.

        1. Before we try the script, let's get ready for testing. Since our migrate up function only creates tables, we can have a migration down function that will undo the change. All it has to do is drop the tables. It will look like the following code snippet:
        public function safeDown()
        {
            $this->dropTable( 'scheduled_job' );
            $this->dropTable( 'job' );
        }
        1. In order to test the migration script we have just written, we must drop the tables that we have been using from the database. If you have some entries in those tables that you might want to use later, we recommend that you back up your database first, and then enter the following commands in the NetBeans MySQL command window:
        drop table scheduled_job;
        drop table job;
        1. Test the migrate up function, which will re-create the tables, by running the following commands in a terminal window:
        cd ~/projects/ch8/protected
        php yiic.php migrate up --migrationPath=application.modules.jobQueue.migrations
        1. If your migrate up command was successful, try the migrate down function in the same terminal window.
        php yiic.php migrate down --migrationPath=application.modules.jobQueue.migrations

        If you receive an error message, such as … create_tables_job_queue does not support migration down, check your safeDown function. Make sure that it does not end with a return false.

        Don't forget to migrate back up before you continue.

        Objective Complete - Mini Debriefing

        We have just created a migration script, which will be a part of our module, and prepare a database for use by our module code.

        Classified Intel

        Migration scripts are useful for more than just modules. They are also useful for maintaining the database in your project. You can use them in upgrades, deployments, and team development. Start your project by creating the initial migration script, as we just did. Each time you want to make a change to your database schema, create a new migration script to record and propagate the changes. Using this method to update your schema will result in consistent deployments and will help disseminate changes when you are developing in a team.

        Re-incorporating Your Module

        Now that we have our module directory initialized and populated, and we have created the necessary migration script, let's reincorporate what we have back into our project, to verify that it still works well.

        Engage Thrusters

        1. In your web browser, log back into your web app http://localhost/cbdb/.
        2. Log in and navigate to the job screen Admin | Jobs and see that we get an error. For the moment, we have broken our job queue.
        1. Yii no longer knows where to find the job queue functions. One problem is that the link is now in the module's name space, so we need to update the menu to link to the new place. Edit ch8 | Source Files | protected | views | layouts | main.php. Change the entry for jobs to include the jobQueue directory in its path.
        array('label'=>'Users', 'url'=>array('/user/index')),
        array('label'=>'Jobs',
               'url'=>array('/jobQueue/scheduledJob/index'),
               'authItemName' => 'Authority'),
        array('label'=>'Reports', 'url'=>array('/report/index')),
        1. Update Yii's information, by editing the project configuration file ch8 | Source Files | protected | config | main.php. Add an entry to the modules array:
        'jobQueue'=>array(),
        1. Reload the Jobs page and you'll see a new error Access Denied.

        Oh! That's because we are using RBAC and our permissions no longer match the path to the controller. To fix this, navigate to the srbac administrative interface, and add jobQueue@ to the beginning of all job-related operations. Here is a list so you don't forget any:

          • jobQueue@JobAdmin
          • jobQueue@JobCreate
          • jobQueue@JobDelete
          • jobQueue@JobIndex
          • jobQueue@JobList
          • jobQueue@JobUpdate
          • jobQueue@JobView
          • jobQueue@ScheduledJobAdmin
          • jobQueue@ScheduledJobCreate
          • jobQueue@ScheduledJobDelete
          • jobQueue@ScheduledJobIndex
          • jobQueue@ScheduledJobUpdate
          • jobQueue@ScheduledJobView

        By updating the values in the existing operations, instead of creating new ones, we don't have to recreate our existing authorization rules.

        1. Now, if you reload your page, you can navigate to Admin | Jobs, and you can click to the scheduled job index successfully.

        Oh, but the action menu doesn't display!

        Well, you can get to it if you enter the URL, but you will see an error page that looks like the following screenshot:

        1. Add an entry to each menu item for 'authItemName' => 'Authority' as follows:
        $this->menu=array(
            array('label'=>'Schedule Job', 'url'=>array('create'), 'authItemName' => 'Authority'),
            array('label'=>'List Registered Jobs', 'url'=>array('job/index'), 'authItemName' => 'Authority'),
            array('label'=>'Register Job', 'url'=>array('job/create'), 'authItemName' => 'Authority'),
        );

        You will need to make this change in every job view file that has an action menu. Here is another list for your reference:

          • views/scheduledJob/create.php
          • views/scheduledJob/index.php
          • views/scheduledJob/update.php
          • views/scheduledJob/view.php
          • views/job/create.php
          • views/ job /index.php
          • views/ job /update.php
          • views/ job /view.php

        Now the menus appear like they should.

        1. Well… except for the Job Index, which has a nasty error.

        We need to fix the routes in our buttons on the Job Index.

        1. To fix the create button, change the controllerRoute attribute in the iFrameButton function call in ch8 | Source Files | protected | modules | jobQueue | views | job | index.php.
        EQuickDlgs::iframeButton(
            array(
                'controllerRoute' => 'jobQueue/job/create',
                'dialogTitle' => 'Create item',
        1. Also we have to make a minor change to the quickdlgs extension, so that it will work within a module. Edit ch8 | Source Files | protected | modules | jobQueue | extensions | quickdlgs | EQuickDlgs.php and change the EXTINSTALLDIR constant to be the module extension directory alias.
        const EXTINSTALLDIR = 'jobQueue.extensions.quickdlgs';

        Now the Job Index page will display correctly.

        1. If you try to schedule a job, you will encounter another error.
        1. We need to correct the path alias for the timepicker widget. Change the reference in ch8 | Source Files | protected | modules | jobQueue | views | scheduledJob | _form.php to the following:
         <?php $this->widget('application.modules.jobQueue.extensions.timepicker.timepicker', array(

        The fixed page will look like the following screenshot:

        1. Finally, if you add an entry to the registered job list and try to view or edit from the registered job grid, those functions will not work.
        1. Update the configuration for the update and view dialogs in ch8 | Source Files | protected | modules | jobQueue | views | job | index.php as follows:
        'updateDialog'=>array(
            'controllerRoute' => 'jobQueue/job/update',
            'actionParams' => array('id'=>'$data->id'),
            'dialogWidth' => 580,
            'dialogHeight' => 250,
        ),
        'viewDialog'=>array(
            'controllerRoute' => 'jobQueue/job/view',
            'actionParams' => array('id'=>'$data->id'),
            'dialogWidth' => 580,
            'dialogHeight' => 250,
        ),

        Now the edit and update buttons will work as expected.

        Since the delete dialog does not depend on an extension, it does not require any change.

        Objective Complete - Mini Debriefing

        We have just tried out the Job Queue functions in our web application to see how well they work with the new module. We made adjustments to the module file as we encountered configuration errors. The result is a functional module that fits into our site.

        Testing Your Module

        In the last task, we walked through the Job Queue screens to make sure that they work correctly. In this task, we will test the function of the module further with data.

        Engage Thrusters

        1. When we removed the tables from the database to test the migration script, we lost the job data that we had been using. Let's begin our testing by inputting the jobs that we used in Project 7, Let It Work While You Sleep - Reports and Job Queues.

        Delete any data you may have entered to test the forms.

        1. Go to the Job Index (http://localhost/cbdb/index.php/jobQueue/job/index) and create the following entries:
          • Name: Send Bday Wishlist Email
            Action: SendWishlist
          • Name: Generate a Report
            Action: RunReport

        The list of registered jobs should look like the following screenshot:

        1. Now, go to the scheduled Job Index http://localhost/cbdb/index.php/jobQueue/scheduledJob/index and schedule these jobs (click on the link for Schedule Job in the Operations menu) to run in the past (so that when you run the job processor, the job will be sure to run). The queue should look like the following screenshot:
        1. Try running the job processor, like we did in Project 7, Let It Work While You Sleep - Reports and Job Queues, except we will be running the script from the module directory. Open a terminal window and try running the following command:
        php ~/projects/ch8/protected/modules/jobQueue/utils/job_entry.php jobprocessor

        Looks like we have another error to correct; we must correct the path to the configuration file. For our module, we want to use the new console configuration file that we created.

        $config=dirname(__FILE__).'/../config/console.php';

        Objective Complete - Mini Debriefing

        We have worked out the last few kinks in our module by inputting some example jobs and running them to verify that all the pieces work correctly together.

        Submitting Your Module

        We have now walked through all of the functions our module performs. Everything looks great. Before packing it up and sharing it, we should implement some automated unit tests. Or better yet, we should have started with the unit tests before we implemented any of the functions. Assuming that a full unit test suite has been created, applied to the module, and passed with flying colors, we are now ready to submit the module to the Yii website to share with the world.

        Engage Thrusters

        1. Create an account in the Yii forum http://www.yiiframework.com/.
        2. Post your extension to the Yii forum to gather feedback from other users. If many people become interested in your module, this step will really put your module through the ringer.
        3. Determine what license you will use for your module.
        4. Prepare the documentation for your module.
          • Prepare screenshots of your module in action
          • Include any configuration instructions
          • Gather a list of all required software
          • Compress your module directory into a tarball or ZIP file or both
          • Write installation instructions
          • Consolidate usage examples that will help your users understand how to incorporate your module
        1. Go to the Yii Extensions page http://www.yiiframework.com/extension/ and click on the Create extension button.
        2. Upload your compressed module package.
        3. Input your module description, license information, usage information, installation instructions, and so on.
        4. Submit your module for review and sharing.

        Objective Complete - Mini Debriefing

        After you have followed these steps, you will be the proud, community contributor of a Yii module that anyone in the world can download and use, depending on the terms of your license. Good work! What are you going to accomplish next?

        Mission Accomplished

        In this project, we learned how to identify pieces of our work that could benefit our projects or others by being converted into reusable modules. We demonstrated the module creation process by converting a function that we had written in a previous chapter, complete with required extensions and custom scripts, into a module structure. We tested our new module in place against our web application project and then prepared and submitted the module to the wider community.

        You Ready to go Gung HO? A Hotshot Challenge

        Create your own module for reuse! Here are some ideas of modules you could write to benefit your own projects and the Yii community:

        • Address manager
        • Contact information manager
        • Customer information manager
        • Leaderboard for game sites
        • Shopping cart
        • Wish list
        • Any API you commonly use
          • Any Google API (Google Maps, Google+, or Fusion Tables)
          • Amazon web services
          • Twitter REST API
          • Any credit card processor
          • Any shipping vendor
    评论 X

        友荐云推荐
        Copyright 2011-2014. YiiBook.com