doctrine                  Doctrine in the Real World                          Real world examplesFriday, March 4, 2011
My name is                        Jonathan H. WageFriday, March 4, 2011
• PHP Developer for 10+ years                        • Long time Symfony and Doctrine                          contributor...
Previously employed by                        SensioLabsFriday, March 4, 2011
http://mongodbhosting.com                         Partnered with                          ServerGrove                     ...
Today, I work full-time                            for OpenSky                             http://shopopensky.comFriday, M...
What is OpenSky?Friday, March 4, 2011
A new way to shop                        • OpenSky connects you with innovators,                          trendsetters and...
OpenSky Loves                                     OpenSource                        •   PHP 5.3                        •  ...
We don’t just use open                      source projectsFriday, March 4, 2011
We help build themFriday, March 4, 2011
OpenSky has some of                        the top committers in                         Symfony2 and other               ...
Symfony2 OpenSky                             Committers                        • 65   Kris Wallsmith                      ...
Doctrine MongoDB                               Committers                        •   39 Jonathan H. Wage                  ...
MongoDB ODM                                  Committers                        •   349 Jonathan H. Wage                   ...
OpenSky uses both the           Doctrine ORM and ODMFriday, March 4, 2011
Why?Friday, March 4, 2011
We are an eCommerce                           siteFriday, March 4, 2011
Actions involving                        commerce need                          transactionsFriday, March 4, 2011
ORM and MySQL                        • Order                        • OrderTransaction                        • OrderShipm...
ODM and MongoDB                        • Product                        • Seller                        • Supplier        ...
Blending the TwoFriday, March 4, 2011
Defining our Product                            DocumentFriday, March 4, 2011
/** @mongodb:Document(collection="products") */                        class Product                        {             ...
Defining our Order                              EntityFriday, March 4, 2011
/**                          * @orm:Entity                          * @orm:Table(name="orders")                          *...
Setting the Product                        public function setProduct(Product $product)                        {          ...
• $productId is mapped and persisted                        • but $product which stores the Product                       ...
Order has a reference                            to product?                        • How?                         • Order...
Loading Product ODM                      reference in Order                             EntityFriday, March 4, 2011
Lifecycle Events to the                                RescueFriday, March 4, 2011
EventManager   • Event system is controlled by the EventManager   • Central point of event listener system   • Listeners a...
Add EventListener                        $eventListener = new OrderPostLoadListener($dm);                        $eventMan...
In Symfony2 DI   <?xml version="1.0" encoding="utf-8" ?>   <container xmlns="http://www.symfony-project.org/schema/dic/ser...
OrderPostLoadListener    use DoctrineODMMongoDBDocumentManager;    use DoctrineORMEventLifecycleEventArgs;    class OrderP...
All Together Now                        // Create a new product and order                        $product = new Product();...
Seamless                        • Documents and Entities play together like                          best friends         ...
print_r($order)         Order Object         (             [id:EntitiesOrder:private] => 53             [productId:Entitie...
Example from Blog                        • This example was first written on my                          personal blog http...
MongoDB ODM                SoftDelete FunctionalityFriday, March 4, 2011
I like my deletes soft,                               not hardFriday, March 4, 2011
Why?Friday, March 4, 2011
Deleting data is                        dangerous businessFriday, March 4, 2011
Flickr accidentally                   deleted a pro members                      account and 5000                         ...
They were able to                        restore it later but it                          took some timeFriday, March 4, 2...
Instead of deleting, simply               set a deletedAt fieldFriday, March 4, 2011
Install SoftDelete                   Extension for Doctrine                      MongoDB ODM              http://github.co...
Autoload Extension    $loader = new UniversalClassLoader();    $loader->registerNamespaces(array(        // ...        Doc...
Raw PHP Configuration                 use DoctrineODMMongoDBSoftDeleteUnitOfWork;                 use DoctrineODMMongoDBSof...
Symfony2 Integration   http://github.com/doctrine/mongodb-odm-softdelete-bundle  $ git clone git://github.com/doctrine/mon...
Autoload the Bundle    $loader = new UniversalClassLoader();    $loader->registerNamespaces(array(        // ...        Do...
Register the Bundle                 public function registerBundles()                 {                     $bundles = arr...
Enable the Bundle                 // app/config/config.yml                 doctrine_mongodb_softdelete.config: ~Friday, Ma...
SoftDeleteManager          $sdm = $container->get(doctrine.odm.mongodb.soft_delete.manager);Friday, March 4, 2011
SoftDeleteable                ODM Documents must implement this interface                             interface SoftDelete...
User implements                              SoftDeletable                        /** @mongodb:Document */                ...
SoftDelete a User              $user = new User(jwage);              // ...              $dm->persist($user);             ...
Query Executed              db.users.update(                  {                      _id : {                          $in ...
Restore a User              // now again later we can restore that same user              $user = $dm->getRepository(User)...
Query Executed              db.users.update(                  {                      _id : {                          $in ...
Limit cursors to only                 show non deleted users            $qb = $dm->createQueryBuilder(User)               ...
Get only deleted users            $qb = $dm->createQueryBuilder(User)                ->field(deletedAt)->exists(true);    ...
Restore several deleted                           users            $qb = $dm->createQueryBuilder(User)                ->fi...
Soft Delete Events                           class TestEventSubscriber implements DoctrineCommonEventSubscriber           ...
PHP DaemonsFriday, March 4, 2011
Symfony2 and                         supervisor                         http://supervisord.org/Friday, March 4, 2011
What is supervisor?Friday, March 4, 2011
Supervisor is a client/server system         that allows its users to monitor and         control a number of processes on...
Daemonize a Symfony2                    Console Command                      with supervisorFriday, March 4, 2011
Scenario                        • You want to send an e-mail when new                          users register in your syst...
Tailable Cursor                        • Use a tailable mongodb cursor                         • Tail a NewUser document c...
Define NewUser             namespace MyCompanyBundleMyBundleDocument;             /**               * @mongodb:Document(col...
Create Collection                        • The NewUser collection must be capped in                          order to tail...
Insert NewUser upon                             Registration                        public function register()            ...
Executing Console                                   Command  $ php app/console doctrine:mongodb:tail-cursor MyBundle:NewUs...
findUnProcessed()                        • We need the findUnProcessed() method to                    class NewUserRepositor...
NewUserProcessor   We need a service id new_user.processor with a  process(OutputInterface $output, $document) method     ...
Send the e-mail        public function process(OutputInterface $output, $document)        {            $user = $document->...
Tailable Cursor Bundle          https://github.com/doctrine/doctrine-mongodb-odm-                           tailable-curso...
Daemonization                        • Now, how do we really daemonize the                          console command and ke...
Install supervisor                        http://supervisord.org/installing.html                        $ easy_install sup...
Configure a Profile              • We need to configure a profile for supervisor to                      know how to run the c...
Start supervisord                        • Start an instance of supervisord                        • It will run as a daem...
Where do I use                               supervisor?                        • http://sociallynotable.com              ...
Thanks!                    I hope this presentation was useful to you!Friday, March 4, 2011
Questions?                        - http://jwage.com                        - http://twitter.com/jwage                    ...
Upcoming SlideShare
Loading in...5
×

Doctrine In The Real World sflive2011 Paris

8,523

Published on

Published in: Technology, Business
0 Comments
19 Likes
Statistics
Notes
  • Be the first to comment

No Downloads
Views
Total Views
8,523
On Slideshare
0
From Embeds
0
Number of Embeds
11
Actions
Shares
0
Downloads
135
Comments
0
Likes
19
Embeds 0
No embeds

No notes for slide

Doctrine In The Real World sflive2011 Paris

  1. 1. doctrine Doctrine in the Real World Real world examplesFriday, March 4, 2011
  2. 2. My name is Jonathan H. WageFriday, March 4, 2011
  3. 3. • PHP Developer for 10+ years • Long time Symfony and Doctrine contributor • Published Author • Entrepreneur • Currently living in Nashville, TennesseeFriday, March 4, 2011
  4. 4. Previously employed by SensioLabsFriday, March 4, 2011
  5. 5. http://mongodbhosting.com Partnered with ServerGrove MongoDB HostingFriday, March 4, 2011
  6. 6. Today, I work full-time for OpenSky http://shopopensky.comFriday, March 4, 2011
  7. 7. What is OpenSky?Friday, March 4, 2011
  8. 8. A new way to shop • OpenSky connects you with innovators, trendsetters and tastemakers.You choose the ones you like and each week they invite you to their private online sales.Friday, March 4, 2011
  9. 9. OpenSky Loves OpenSource • PHP 5.3 • Apache2 • Symfony2 • Doctrine2 • jQuery • mule, stomp, hornetq • MongoDB • nginx • varnishFriday, March 4, 2011
  10. 10. We don’t just use open source projectsFriday, March 4, 2011
  11. 11. We help build themFriday, March 4, 2011
  12. 12. OpenSky has some of the top committers in Symfony2 and other projectsFriday, March 4, 2011
  13. 13. Symfony2 OpenSky Committers • 65 Kris Wallsmith • 52 Jonathan H. Wage • 36 Jeremy Mikola • 36 Bulat Shakirzyanov •6 Justin HilemanFriday, March 4, 2011
  14. 14. Doctrine MongoDB Committers • 39 Jonathan H. Wage • 11 Bulat Shakirzyanov • 2 Kris WallsmithFriday, March 4, 2011
  15. 15. MongoDB ODM Committers • 349 Jonathan H. Wage • 226 Bulat Shakirzyanov • 17 Kris Wallsmith • 13 Steven Surowiec • 2 Jeremy MikolaFriday, March 4, 2011
  16. 16. OpenSky uses both the Doctrine ORM and ODMFriday, March 4, 2011
  17. 17. Why?Friday, March 4, 2011
  18. 18. We are an eCommerce siteFriday, March 4, 2011
  19. 19. Actions involving commerce need transactionsFriday, March 4, 2011
  20. 20. ORM and MySQL • Order • OrderTransaction • OrderShipmentFriday, March 4, 2011
  21. 21. ODM and MongoDB • Product • Seller • Supplier • User • ... basically everything else that is not involving $$$ and transactionsFriday, March 4, 2011
  22. 22. Blending the TwoFriday, March 4, 2011
  23. 23. Defining our Product DocumentFriday, March 4, 2011
  24. 24. /** @mongodb:Document(collection="products") */ class Product { /** @mongodb:Id */ private $id; /** @mongodb:String */ private $title; public function getId() { return $this->id; } public function getTitle() { return $this->title; } public function setTitle($title) { $this->title = $title; } }Friday, March 4, 2011
  25. 25. Defining our Order EntityFriday, March 4, 2011
  26. 26. /** * @orm:Entity * @orm:Table(name="orders") * @orm:HasLifecycleCallbacks */ class Order { /** * @orm:Id @orm:Column(type="integer") * @orm:GeneratedValue(strategy="AUTO") */ private $id; /** * @orm:Column(type="string") */ private $productId; /** * @var DocumentsProduct */ private $product; // ... }Friday, March 4, 2011
  27. 27. Setting the Product public function setProduct(Product $product) { $this->productId = $product->getId(); $this->product = $product; }Friday, March 4, 2011
  28. 28. • $productId is mapped and persisted • but $product which stores the Product instance is not a persistent entity propertyFriday, March 4, 2011
  29. 29. Order has a reference to product? • How? • Order is an ORM entity stored in MySQL • and Product is an ODM document stored in MongoDBFriday, March 4, 2011
  30. 30. Loading Product ODM reference in Order EntityFriday, March 4, 2011
  31. 31. Lifecycle Events to the RescueFriday, March 4, 2011
  32. 32. EventManager • Event system is controlled by the EventManager • Central point of event listener system • Listeners are registered on the manager • Events are dispatched through the managerFriday, March 4, 2011
  33. 33. Add EventListener $eventListener = new OrderPostLoadListener($dm); $eventManager = $em->getEventManager(); $eventManager->addEventListener( array(DoctrineORMEvents::postLoad), $eventListener );Friday, March 4, 2011
  34. 34. In Symfony2 DI <?xml version="1.0" encoding="utf-8" ?> <container xmlns="http://www.symfony-project.org/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.symfony-project.org/schema/dic/services http://www.symfony-project.org/ schema/dic/services/services-1.0.xsd"> <parameters> <parameter key="order.post_load.listener.class">OrderPostLoadListener</parameter> </parameters> <services> <service id="order.post_load.listener" class="%order.post_load.listener.class%" scope="container"> <argument type="service" id="doctrine.odm.mongodb.default_document_manager" /> <tag name="doctrine.orm.default_event_listener" event="postLoad" /> </service> </services> </container>Friday, March 4, 2011
  35. 35. OrderPostLoadListener use DoctrineODMMongoDBDocumentManager; use DoctrineORMEventLifecycleEventArgs; class OrderPostLoadListener { public function __construct(DocumentManager $dm) { $this->dm = $dm; } public function postLoad(LifecycleEventArgs $eventArgs) { // get the order entity $order = $eventArgs->getEntity(); // get odm reference to order.product_id $productId = $order->getProductId(); $product = $this->dm->getReference(MyBundle:DocumentProduct, $productId); // set the product on the order $em = $eventArgs->getEntityManager(); $productReflProp = $em->getClassMetadata(MyBundle:EntityOrder) ->reflClass->getProperty(product); $productReflProp->setAccessible(true); $productReflProp->setValue($order, $product); } }Friday, March 4, 2011
  36. 36. All Together Now // Create a new product and order $product = new Product(); $product->setTitle(Test Product); $dm->persist($product); $dm->flush(); $order = new Order(); $order->setProduct($product); $em->persist($order); $em->flush(); // Find the order later $order = $em->find(Order, $order->getId()); // Instance of an uninitialized product proxy $product = $order->getProduct(); // Initializes proxy and queries the monogodb database echo "Order Title: " . $product->getTitle(); print_r($order);Friday, March 4, 2011
  37. 37. Seamless • Documents and Entities play together like best friends • Because Doctrine persistence remains transparent from your domain this is possibleFriday, March 4, 2011
  38. 38. print_r($order) Order Object ( [id:EntitiesOrder:private] => 53 [productId:EntitiesOrder:private] => 4c74a1868ead0ed7a9000000 [product:EntitiesOrder:private] => ProxiesDocumentProductProxy Object ( [__isInitialized__] => 1 [id:DocumentsProduct:private] => 4c74a1868ead0ed7a9000000 [title:DocumentsProduct:private] => Test Product ) )Friday, March 4, 2011
  39. 39. Example from Blog • This example was first written on my personal blog http://jwage.com • You can read the blog post here http:// jwage.com/2010/08/25/blending-the- doctrine-orm-and-mongodb-odm/Friday, March 4, 2011
  40. 40. MongoDB ODM SoftDelete FunctionalityFriday, March 4, 2011
  41. 41. I like my deletes soft, not hardFriday, March 4, 2011
  42. 42. Why?Friday, March 4, 2011
  43. 43. Deleting data is dangerous businessFriday, March 4, 2011
  44. 44. Flickr accidentally deleted a pro members account and 5000 picturesFriday, March 4, 2011
  45. 45. They were able to restore it later but it took some timeFriday, March 4, 2011
  46. 46. Instead of deleting, simply set a deletedAt fieldFriday, March 4, 2011
  47. 47. Install SoftDelete Extension for Doctrine MongoDB ODM http://github.com/doctrine/mongodb-odm-softdelete $ git clone git://github.com/doctrine/mongodb-odm-softdelete src/ vendor/doctrine-mongodb-odm-softdeleteFriday, March 4, 2011
  48. 48. Autoload Extension $loader = new UniversalClassLoader(); $loader->registerNamespaces(array( // ... DoctrineODMMongoDBSoftDelete => __DIR__./vendor/doctrine-mongodb-odm- softdelete/lib, )); $loader->register();Friday, March 4, 2011
  49. 49. Raw PHP Configuration use DoctrineODMMongoDBSoftDeleteUnitOfWork; use DoctrineODMMongoDBSoftDeleteSoftDeleteManager; use DoctrineCommonEventManager; // $dm is a DocumentManager instance we should already have use DoctrineODMMongoDBSoftDeleteConfiguration; $config = new Configuration(); $uow = new UnitOfWork($dm, $config); $evm = new EventManager(); $sdm = new SoftDeleteManager($dm, $config, $uow, $evm);Friday, March 4, 2011
  50. 50. Symfony2 Integration http://github.com/doctrine/mongodb-odm-softdelete-bundle $ git clone git://github.com/doctrine/mongodb-odm-softdelete-bundle.git src/vendor/doctrine-mongodb-odm-softdelete-bundleFriday, March 4, 2011
  51. 51. Autoload the Bundle $loader = new UniversalClassLoader(); $loader->registerNamespaces(array( // ... DoctrineODMMongoDBSymfonySoftDeleteBundle => __DIR__./vendor/doctrine- mongodb-odm-softdelete-bundle, )); $loader->register();Friday, March 4, 2011
  52. 52. Register the Bundle public function registerBundles() { $bundles = array( // ... // register doctrine symfony bundles new DoctrineODMMongoDBSymfonySoftDeleteBundleSoftDeleteBundle() ); // ... return $bundles; }Friday, March 4, 2011
  53. 53. Enable the Bundle // app/config/config.yml doctrine_mongodb_softdelete.config: ~Friday, March 4, 2011
  54. 54. SoftDeleteManager $sdm = $container->get(doctrine.odm.mongodb.soft_delete.manager);Friday, March 4, 2011
  55. 55. SoftDeleteable ODM Documents must implement this interface interface SoftDeleteable { function getDeletedAt(); }Friday, March 4, 2011
  56. 56. User implements SoftDeletable /** @mongodb:Document */ class User implements SoftDeleteable { /** @mongodb:Date @mongodb:Index */ private $deletedAt; public function getDeletedAt() { return $this->deletedAt; } }Friday, March 4, 2011
  57. 57. SoftDelete a User $user = new User(jwage); // ... $dm->persist($user); $dm->flush(); // later we can soft delete the user jwage $user = $dm->getRepository(User)->findOneByUsername(jwage); $sdm->delete($user); $sdm->flush();Friday, March 4, 2011
  58. 58. Query Executed db.users.update( { _id : { $in : [new ObjectId(1234567891011123456)] } }, { $set : { deletedAt: new Date() } } )Friday, March 4, 2011
  59. 59. Restore a User // now again later we can restore that same user $user = $dm->getRepository(User)->findOneByUsername(jwage); $sdm->restore($user); $sdm->flush();Friday, March 4, 2011
  60. 60. Query Executed db.users.update( { _id : { $in : [new ObjectId(1234567891011123456)] } }, { $unset : { deletedAt: true } } )Friday, March 4, 2011
  61. 61. Limit cursors to only show non deleted users $qb = $dm->createQueryBuilder(User) ->field(deletedAt)->exists(false); $query = $qb->getQuery(); $users = $query->execute();Friday, March 4, 2011
  62. 62. Get only deleted users $qb = $dm->createQueryBuilder(User) ->field(deletedAt)->exists(true); $query = $qb->getQuery(); $users = $query->execute();Friday, March 4, 2011
  63. 63. Restore several deleted users $qb = $dm->createQueryBuilder(User) ->field(deletedAt)->exists(true) ->field(createdAt)->gt(new DateTime(-24 hours)); $query = $qb->getQuery(); $users = $query->execute(); foreach ($users as $user) { $sdm->restore($user); } $sdm->flush();Friday, March 4, 2011
  64. 64. Soft Delete Events class TestEventSubscriber implements DoctrineCommonEventSubscriber { public function preSoftDelete(LifecycleEventArgs $args) { $document = $args->getDocument(); - preDelete } $sdm = $args->getSoftDeleteManager(); - postDelete public function getSubscribedEvents() { - preRestore } return array(Events::preSoftDelete); - postRestore } $eventSubscriber = new TestEventSubscriber(); $evm->addEventSubscriber($eventSubscriber);Friday, March 4, 2011
  65. 65. PHP DaemonsFriday, March 4, 2011
  66. 66. Symfony2 and supervisor http://supervisord.org/Friday, March 4, 2011
  67. 67. What is supervisor?Friday, March 4, 2011
  68. 68. Supervisor is a client/server system that allows its users to monitor and control a number of processes on UNIX-like operating systems. http://supervisord.orgFriday, March 4, 2011
  69. 69. Daemonize a Symfony2 Console Command with supervisorFriday, March 4, 2011
  70. 70. Scenario • You want to send an e-mail when new users register in your system. • But, sending an e-mail directly from your action introduces a failure point to your stack. • ....What do you do?Friday, March 4, 2011
  71. 71. Tailable Cursor • Use a tailable mongodb cursor • Tail a NewUser document collection • Insert NewUser documents from your actions • The daemon will instantly process the NewUser after it is inserted and dispatch the e-mailFriday, March 4, 2011
  72. 72. Define NewUser namespace MyCompanyBundleMyBundleDocument; /** * @mongodb:Document(collection={ * "name"="new_users", * "capped"="true", * "size"="100000", * "max"="1000" * }, repositoryClass="MyCompanyBundleMyBundleDocumentNewUserRepository") */ class NewUser { /** @mongodb:Id */ private $id; /** @mongodb:ReferenceOne(targetDocument="User") */ private $user; /** @mongodb:Boolean @mongodb:Index */ private $isProcessed = false; // ... }Friday, March 4, 2011
  73. 73. Create Collection • The NewUser collection must be capped in order to tail it so we need to create it. • Luckily, Doctrine has a console command for it. • It will read the mapping information we configured and create the collection $ php app/console doctrine:mongodb:schema:create --class="MyBundle:NewUser" --collectionFriday, March 4, 2011
  74. 74. Insert NewUser upon Registration public function register() { // ... $user = new User(); $form = new RegisterForm(register, $user, $validator); $form->bind($request, $user); if ($form->isValid()) { $newUser = new NewUser($user); $dm->persist($newUser); $dm->persist($user); $dm->flush(); // ... } // ... }Friday, March 4, 2011
  75. 75. Executing Console Command $ php app/console doctrine:mongodb:tail-cursor MyBundle:NewUser findUnProcessed new_user.processor • The command requires 3 arguments: • document - the name of the document to tail • finder - the repository finder method used to get the cursor • processor - the id of the service used to process the new usersFriday, March 4, 2011
  76. 76. findUnProcessed() • We need the findUnProcessed() method to class NewUserRepository extends DocumentRepository { return the unprocessed cursor to tail public function findUnProcessed() { return $this->createQueryBuilder() ->field(isProcessed)->equals(false) ->getQuery() ->execute(); } }Friday, March 4, 2011
  77. 77. NewUserProcessor We need a service id new_user.processor with a process(OutputInterface $output, $document) method use Swift_Message; use SymfonyComponentConsoleOutputOutputInterface; class NewUserProcessor { private $mailer; public function __construct($mailer) { $this->mailer = $mailer; } public function process(OutputInterface $output, $document) { } }Friday, March 4, 2011
  78. 78. Send the e-mail public function process(OutputInterface $output, $document) { $user = $document->getUser(); $message = Swift_Message::newInstance() ->setSubject(New Registration) ->setFrom(noreply@domain.com) ->setTo($user->getEmail()) ->setBody(New user registration) ; $this->mailer->send($message); $document->setIsProcessed(true); }Friday, March 4, 2011
  79. 79. Tailable Cursor Bundle https://github.com/doctrine/doctrine-mongodb-odm- tailable-cursor-bundleFriday, March 4, 2011
  80. 80. Daemonization • Now, how do we really daemonize the console command and keep it running 24 hours a day, 7 days a week? • The answer is supervisor, it will allow us to configure a console command for it to manage the process id of and always keep an instance of it running.Friday, March 4, 2011
  81. 81. Install supervisor http://supervisord.org/installing.html $ easy_install supervisorFriday, March 4, 2011
  82. 82. Configure a Profile • We need to configure a profile for supervisor to know how to run the console command/ $ vi /etc/supervisor/conf.d/tail-new-user.confr [program:tail-new-user] numprocs=1 startretries=100 directory=/ stdout_logfile=/path/to/symfonyproject/app/logs/tail-new-user-supervisord.log autostart=true autorestart=true user=root command=/usr/local/bin/php /path/to/symfonyproject/app/console doctrine:mongodb:tail-cursor MyBundle:NewUser findUnprocessed new_user.processor Friday, March 4, 2011
  83. 83. Start supervisord • Start an instance of supervisord • It will run as a daemon in the background • The tail-new-user.conf will always be running $ supervisordFriday, March 4, 2011
  84. 84. Where do I use supervisor? • http://sociallynotable.com • Keeps daemon running that watches twitter • Indexes tweets with links to amazon products • Maintains tweet statistics and ranks the popular productsFriday, March 4, 2011
  85. 85. Thanks! I hope this presentation was useful to you!Friday, March 4, 2011
  86. 86. Questions? - http://jwage.com - http://twitter.com/jwage - http://facebook.com/jwage - http://about.me/jwage - http://shopopensky.com - http://mongodbhosting.com - http://servergrove.com - http://sociallynotable.comFriday, March 4, 2011
  1. A particular slide catching your eye?

    Clipping is a handy way to collect important slides you want to go back to later.

×