laravel / ideas

Issues board used for Laravel internals discussions.
939 stars 28 forks source link

[Proposal] Widget Objects to help cleaning up controllers and provide easy page cache #397

Closed imanghafoori1 closed 6 years ago

imanghafoori1 commented 7 years ago

When to use it ?

This concept helps developers in situations that they want to create crowded web pages with multiple widgets (on sidebar, menu, ...) and each widget needs separate sql queries and php logic to be provided with data for its template. (If you need a small application or a json API with low traffic this concept is not much of a help.Anyway it has minimal overhead since surprisingly it is just a small abstract class.)

So what is our problem ?

Problem 1 :

Imagine An online shop like amazon which shows the list of products (in the main column) popular products, related products,etc (in the sidebar), user data and basket data (in the navbar) and a tree of product categories (in the menu) and etc... And in traditional MVC model you have a single controller method to provide all the widgets with data. You can immediately see that you are violating the SRP (Single Responsibility Priciple)!!! The trouble is worse when the client changes his mind over time and asks the deveploper to add, remove and modify those widgets on the page. And it always happens. Clients change their minds.The developer's job is to be ready to cop with that as effortlessly as possible.

Problem 2 :

Trying to cache the pages which include user specific data (for example the username on the top menu) is a often fruitless. Because each user sees slightly different page from other users. Or in cases when we have some parts of the page(recent products section) which update frequently and some other parts which change rarely... we have to expire the entire page cache to match the most most frequently updated one. :(

How this concept is going to help us ?

  1. It helps you to conforms to SRP (single responsibility principle) in your controllers (Because each widget class is only responsible for one and only one widget of the page but before you had a single controller method that was responsible for all the widgets. Effectively exploding one controller method into multiple widget classes.)
  2. It helps you to conforms to Open-closed principle. (Because if you want to add a widget on your page you do not need to touch the controller code. Instead you create a new widget class from scratch.)
  3. It optionally caches the output of each widget. (which give a very powerful, flexible and easy to use caching opportunity) You can set different cache config for each part of the page. Similar to ESI standard.
  4. It executes the widget code Lazily. Meaning the the widget's data method protected function data(){ is hit only and only after the widget object is forced to be rendered in the blade file like this: {!! $widgetObj !!}, So for example if you comment out {!! $widgetObj !!} from your blade file then all database queries will be disabled automatically. No need to comment out the controller codes anymore...
  5. It optionally minifies the output of the widget. Removing the white spaces.
  6. It support the nested widgets tree structure. (use can inject and use widgets within widgets)

Usage:

The main idea is the each widget should have it's own controller class and view partial, isolated from others.

Guideline:

  1. So we first extract each widget into its own partial. (recentProducts.blade.php)
  2. Create a class and extend the BaseWidget. (App\Widgets\RecentProductsWidget.php)
  3. Set configurations like $cacheLifeTime , $template, etc and implement the data method.
  4. Your widget is ready to be instanciated and be used in your view files. (see example below)

How to create a Widget?

Sample widget class :

namespace App\Widgets;

use Imanghafoori\Widgets\BaseWidget;

class RecentProductsWidget extends BaseWidget
{
    protected $template = 'widgets.recentProducts.blade.php'; // referes to: views/widgets/recentProducts.blade.php
    protected $cacheLifeTime = 1; // 1(min) ( 0 : disable, -1 : forever) default: 0
    protected $friendlyName = 'A Friendly Name Here'; // Showed in html Comments
    protected $context_as = '$recentProducts'; // you can access $recentProducts in recentProducts.blade.php file
    protected $minifyOutput = true; 

    // The data returned here would be available in widget view file.
    protected function data($param1=null)
    {
        // It's the perfect place to query the database...
        return Product::all();

    }
}

==============

views/widgets/recentProducts.blade.php

<ul>
  @foreach($recentProducts as $product)
    <li>
      <h3> {{ $product->title }} </h3>
      <p>$ {{ $product->price }} </p>
    </li>
  @endforeach
</ul>

Ok, Now it's done! We have a ready to use widget. let's use it...

How to use a Widget?

In your typical controller method we should instanciate our widget class and pass the object to our view:


use \App\Widgets\RecentProductsWidget;

public function index(RecentProductsWidget $recentProductsWidget)
{
    return view('home', compact('recentProductsWidget'));
}

And then you can render it in your view (home.blade.php) like this:

<div class="container">
    <h1>Hello {{ auth()->user()->username }} </h1>
    <br>
    {!! $recentProductsWidget !!}
    <p> if you need to pass parameters to data method :</p>
    {!! $recentProductsWidget('param1') !!}
</div>

=============

In order to understand what's going on here... Think of {!! $recentProductsWidget !!} as @include('widgets.recentProductsWidget') but more sophisticated. The actual result is the same piece of HTML, which is the result of rendering the partial.

Pro tip: After {!! $myWidget('param1') !!} is executed in your view file, the data method is called on your widget class with the corresponding parameters. But only if it is Not already cached or the protected $cacheLifeTime is set to 0. If the widget HTML output is already in the cache it prints out the HTML with out executing data method (hence performing database queries) or even rendering the blade file.

===============

BaseWidget class code (This class is only user tested in production.) https://github.com/imanghafoori1/laravel-widgetize https://github.com/imanghafoori1/laravel-widgetize https://github.com/imanghafoori1/laravel-widgetize

sisve commented 7 years ago

(I'll presume that the controller in your example is named DashboardController).

  1. Why should a DashboardController know which different widget that the view optionally uses?
  2. Why should any widget-related code execute at all when running the DashboardController::index()? Your example still execute the RecentProductsWidget constructor even if it isn't used by the view.
  3. How would dependency injection work with RecentProductsWidget?

How about the possibility to execute child-requests, where your view has a @childAction('WidgetController@getWidgetHtml')?. Example: https://gist.github.com/sisve/13cb9438ff74eb21f676a8bc3dcd11a0

1) SRP: The Widget is handled by WidgetController, not DashboardController. Only the dashboard view needs to be changed to add the widget, no changes at all to DashboardController. 4) Lazy execution. Nothing widget-related is executed unless that @childAction calls it. 6) Nested widgets.

It does not... 3) Cache the widget. That's something that the WidgetController may do when appropriate. 4) Minify the output. That's something that could be applied on a general level by minifying the blade compilation; see https://github.com/GrahamCampbell/Laravel-HTMLMin It's not something that needs to be done per-render.

I do not understand your reference to the open/closed principle. It's often used for class hierarchies (and inheritance), but I do not see how it works here.

imanghafoori1 commented 7 years ago

Good questions :

  1. Why should a DashboardController know which different widget that the view optionally uses?

    Because some people like to see their controllers and immediately get a bird eye view to the rest of execution flow of their app. Of course, It is absolutely possible to just use the widgets classes right within the views with a static call with minimum change on the base class.

  2. Why should any widget-related code execute at all when running the DashboardController::index()?

    We tend to put cheap code in the constructor and expensive code (like DB queries) in the data method.

  3. How would dependency injection work with RecentProductsWidget?

    I think it can be done by using the laravel App::call() in the base class. That is easily possible. No big deal.

  4. Cache the widget. That's something that the WidgetController may...

    Of course but it quickly leads to duplicate code in the widgets which is better to be in the BaseWidget class and DRYed out.

imanghafoori1 commented 7 years ago

I do not understand your reference to the open/closed principle. It's often used for class hierarchies (and inheritance), but I do not see how it works here.

Suppose that we have a lot of widgets on the page with a lot of logic and we want to:

  1. Add yet another one. If we are using the current MVC model we need to write our code in the same controller method that contains the previously written code. but in this approach we just create a brand new widget class from scratch and start to code in it. (While other members of the team are happy to work on their on isolated files and commit their changes without creating conflicts.)
  1. Remove some widget. then by removing the {!! $myWidget !!} all the database queries will be disabled without need for commenting out the queries in the controller.

  2. Modify some widget. Then any new comer to our team (when open up the widget class) has to study only with little amount of code that is related specifically to that widget. (in the current MVC model the developer has to idea which piece of controller code is related to which widget on the page so he has to study the whole controller action... )