Bonus - Refactoring Part 1


After watching through this series, I had a query from a site member regarding a better way to handle the dependency on Guzzle.

Here is the question:

In that tutorial you mentioned that 'new'ing' the Guzzle client was bad practice, I assume that's because it's repeated code that you have to copy and paste into each function in the service. So, after that I was looking for a tutorial describing how to create it through dependency injection (which is what I think you said the correct way was)

It's a good point, and one that I hadn't actually addressed in the series.

The 'copy / paste' argument is really just a small part of the problem.

It's really painful to test classes where calls to new are made in the constructor. It's considered a really bad practice. Don't do this.

I'd also really like to only define a service once and keep re-using it, rather than instantiating new instances of it all over the shop.

So, why on Earth did I do it like this for the video?

Well, in truth, if I were to use Guzzle inside a Symfony project - unless I had a very good reason not to do so - I would rely on a third party bundle, such as Eightpoints Guzzle Bundle.

It's only when digging into 'a better way' of using Guzzle over simply new'ing up that you will run into a (likely) unanticipated problem. You can't inject Guzzle's Client without having new'ed it up somewhere to begin with.

Doing so requires a service definition, but it initially seems like chicken and the egg - we know not to do it this way, but there doesn't seem to be any other way. Well, we will cover how third party bundles do this in the next video.

But for now, we will start by refactoring our code to isolate this problem, instead relying on an interface we will define and use to hide the untestable mess laying behind it.

Once we have the interface in place, in the next video we will use a third party bundle to remove this problem altogether.

Defining an Interface to Decouple our Problematic Code

At this scale of project, what I am about to discuss is really not that big of a concern.

But it's pretty poor practice, and even with such a small project the rot has already begun to set in. We can do better.

Currently we have implementation details spilling into our code:

<?php

// src/AppBundle/Service/GitHubApi.php

namespace AppBundle\Service;

use GuzzleHttp\Client;

class GitHubApi
{
    public function getProfile($username)
    {
        $client = new Client();
        /** @var $response \Psr\Http\Message\ResponseInterface */
        $response = $client->request('GET', 'https://api.github.com/users/' . $username);

        $data = json_decode($response->getBody()->getContents(), true);

Our GitHubApi service is directly tied to Guzzle.

Switching out from Guzzle - maybe to Buzz - would involve changing all three lines here. Of course, we duplicated this code into the other function, so that's six lines.

Also, our class is called GitHubApi, so if you were completely new to this project, you would likely be surprised to find all this code about Guzzle... aren't we interested in GitHub? What is Guzzle? Questions, questions.

Thinking about it, GitHubApi is probably a bad name for this service. We are communicating with GitHub's API, but the class itself is more of a data provider. Anyway, for the moment we will stick with the name of GitHubApi, but it's something to be thinking about as we begin the refactoring.

The fact is, how we get the data from GitHub is not the concern of this class. The responsiblity of this class is to provide a user's GitHub profile and repository data in a structured way. That's about it.

Of course, that data has to arrive from somewhere. And so in the scale and scope of this application, it made sense that inside that this class should ask GitHub for the data, and then return it in a single method call. Not amazing code, and there's no error handling either, but it's good enough at this stage.

Given that we need to ask GitHub for some information, we can deduce that we likely need a get method, much like the HTTP GET verb which we will be using to really retrieve the data. Let's start with this on our interface:

<?php

// /src/AppBundle/Service/HttpClientInterface.php

namespace AppBundle\Service;

interface HttpClientInterface
{
    public function get($url);
    public function post($url, $data);
}

I've added in the post method as we may wish to talk back to GitHub along the way, but for the purposes of this tutorial we won't be actually implementing that method. It's just for illustration.

Our interface is really simple.

We want our GitHubApi service to make calls to get($someUrl), and let a more specific HTTP client implementation worry about how that really happens.

Knowing this, we can go ahead and create a new class - GuzzleHttpClient - which will take those three lines of code from our existing getProfile method, and extract them into the get method of the new class:

<?php

// /src/AppBundle/Service/GuzzleHttpClient.php

namespace AppBundle\Service;

class GuzzleHttpClient implements HttpClientInterface
{
    public function get($url)
    {
        $client = new \GuzzleHttp\Client();

        $response = $client->get($url);

        return json_decode($response->getBody()->getContents(), true);
    }

    public function post($url, $data)
    {
        // not in use, but we must define this method
        // as it is part of the HttpClientInterface interface
    }
}

We'd likely want to share the client between the get and post methods, so we might think about moving the instantiation of \GuzzleHttp\Client to the constructor:

<?php

// /src/AppBundle/Service/GuzzleHttpClient.php

namespace AppBundle\Service;

class GuzzleHttpClient implements HttpClientInterface
{
    private $client;

    public function __construct()
    {
        // don't do this
        $this->client = new \GuzzleHttp\Client();
    }

    public function get($url)
    {
        $response = $this->client->get($url);

        return json_decode($response->getBody()->getContents(), true);
    }

    // * snip *

Again this is really bad practice!.

We will cover a better way to do this shortly. All we have done so far is to move the problem.

Injecting Our GuzzleHttpClient Into GitHubApi Service

Now that we have the standalone GuzzleHttpClient we can add an entry in Symfony's services.yml to expose this class as a service:

# /app/config/services.yml

services:
    guzzle_http_client:
        class: AppBundle\Service\GuzzleHttpClient

    github_api:
        class: AppBundle\Service\GitHubApi
        arguments:
            - "@guzzle_http_client"

We need to update the constructor in GitHubApi letting it know that it will receive something that implements HttpClientInterface:

<?php

// /src/AppBundle/Service/GitHubApi.php

namespace AppBundle\Service;

class GitHubApi
{
    /**
     * @var HttpClientInterface
     */
    private $httpClient;

    public function __construct(HttpClientInterface $httpClient)
    {
        $this->httpClient = $httpClient;
    }

    // * snip * 

And as soon as we do this, we can change both the getProfile and getRepos methods:

// /src/AppBundle/Service/GitHubApi.php

    public function getProfile($username)
    {
        $data = $this->httpClient->get('https://api.github.com/users/' . $username);

        // * snip *
    }

    public function getRepos($username)
    {
        $data = $this->httpClient->get('https://api.github.com/users/' . $username . '/repos');

        // * snip *
    }

And at this stage, our site should still work just fine.

Remember, all we have done at this stage is to move the problem. We haven't fixed it.

Still, we have isolated the rotten code, which should make our lives that little bit easier.

Giving Ourselves A Buzz

What's really cool now is that we could very easily swap out the Guzzle implementation for another - Buzz being the one that immediately jumped to my mind.

All we need to do is to implement the same interface and make sure we return data in the same format, and we are good to go.

Note: The implementation for Buzz changed significantly in 0.17.x. Please use composer require kriswallsmith/buzz:0.16.1 for compatibility with the video content.

As we are returning an array from our getProfile and getRepos methods, we would definitely want some tests around this as it's easy to make a mistake with array keys and such-like. A better way would be to use objects, but again, let's keep things as simple as possible for the moment.

<?php

// /src/AppBundle/Service/BuzzHttpClient.php

namespace AppBundle\Service;

use Buzz\Message\Request;
use Buzz\Message\Response;

class BuzzHttpClient implements HttpClientInterface
{
    private $client;

    public function __construct()
    {
        // bad practice, please don't do this
        $this->client = new \Buzz\Client\Curl();
        $this->client->setOption(CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36');
    }

    public function get($url)
    {
        $request = new Request('GET', $url);
        $response = new Response();

        $this->client->send($request, $response);

        return json_decode($response->getContent(), true);
    }

    public function post($url, $data)
    {
        // not in use, but we must define this method
        // as it is part of the HttpClientInterface interface
    }
}

Even though the implementation looks very different to the Guzzle Client, we can use this with our GitHubApi without any changes. All we need to do is tell Symfony about this new service, and change the service we are injecting into github_api:

# /app/config/services.yml

services:
    guzzle_http_client:
        class: AppBundle\Service\GuzzleHttpClient

    buzz_http_client:
        class: AppBundle\Service\BuzzHttpClient

    github_api:
        class: AppBundle\Service\GitHubApi
        arguments:
            - "@buzz_http_client"

And it should all just work fine.

Again, we've new'ed up in the constructor which really bad practice.

In the next video we will cover a better way to do this.

Code For This Course

Get the code for this course.

Episodes