构建发货模块

发货模块与支付模块一起,为我们网店的进一步销售功能提供了基础。当我们到达即将到来的销售模块的结账流程时,它将使我们能够选择发货方式。与支付类似,发货方式也可以分为静态和动态两种。静态可能意味着一个固定的价格值,甚至是通过一些简单的条件计算出来的,而动态通常意味着与外部API服务的连接。

在本章中,我们将对这两种类型进行接触,并看看如何为实现发货模块设置一个基本结构。

在本章中,我们将涉及到发货模块的以下主题:

  • 要求
  • 依赖
  • 实施
  • 单元测试
  • 功能测试

要求

应用需求在前面的模块化网店App需求规范中定义的,并没有给我们任何具体的说明,我们需要实现什么样的快递方式。因此,在本章中,我们将开发两种发货方式:动态费率发货和定额发货。动态费率装运是作为一种将发货方法与真正的快递商(如UPS、FedEx等)连接起来的方式。然而,它不会实际连接到任何外部API。

理想情况下,我们希望通过类似于下面的接口来实现。

  1. namespace Foggyline\SalesBundle\Interface;
  2. interface Shipment
  3. {
  4. function getInfo($street, $city, $country, $postcode, $amount, $qty);
  5. function process($street, $city, $country, $postcode, $amount, $qty);
  6. }

然后,getInfo方法可以用来获取给定订单信息的可用送货选项,而 process方法则会处理选定的送货选项。例如,我们可以让 API 返回 “当日送达($9.99)“和 “标准送达($4.99) “作为动态费率装运方法下的送货选项。

有了这样的发货接口,就会强加一个SalesBundle模块的要求,而我们还没有开发这个模块。因此,我们将继续我们的发货方法,使用 Symfony 控制器来处理流程方法,使用服务来处理getInfo方法。

与上一章的支付方法类似,我们将通过标记的 Symfony 服务来公开 getInfo 方法。我们将在发货方法中使用的标签是shipment_method。稍后,在结账过程中,SalesBundle模块将获取所有被标记为shipment_method的服务,并在内部使用它们作为可用的发货方法列表。

依赖

我们正在以相反的方式构建模块。也就是说,我们在对SalesBundle模块有任何了解之前就开始构建该模块,因为SalesBundle是唯一会使用该模块的模块。考虑到这一点,发货模块与其他模块没有牢固的依赖关系。然而,如果先构建SalesBundle模块,然后再暴露一些shipment模块可能使用的接口,可能会更方便。

实施

我们将从创建一个新的模块Foggyline\ShipmentBundle开始。我们将在控制台的帮助下运行以下命令:

  1. php bin/console generate:bundle --namespace=Foggyline/ShipmentBundle

该命令会触发一个交互过程,在这个过程中会问我们几个问题,如下图所示:

构建发货模块 - 图1

完成后,文件app/AppKernel.phpapp/config/routing.yml会自动修改。AppKernel类的registerBundles方法被添加到$bundles数组下的下面一行:

  1. new Foggyline\PaymentBundle\FoggylineShipmentBundle(),

routing.yml文件已经更新,加入了以下条目:

  1. foggyline_payment:
  2. resource: "@FoggylineShipmentBundle/Resources/config/routing.xml"
  3. prefix: /

为了避免与核心应用代码发生冲突,我们需要更改 prefix: /改成prefix: /shipment/

创建统一费率的运输服务

统一费率发货服务要提供固定的发货方式,我们的销售模块要在其结账过程中使用。它的作用是提供装运方式标签、代码、交付选项和处理 URL。

我们先在 src/Foggyline/ShipmentBundle/Resources/config/services.xml 文件的 services 元素下定义以下服务:

  1. <service id="foggyline_shipment.dynamicrate_shipment"class="Foggyline\ShipmentBundle\Service\DynamicRateShipment">
  2. <argument type="service" id="router"/>
  3. <tag name="shipment_method"/>
  4. </service>

这个服务只接受一个参数:routertagname的值被设置为shipment_method,因为我们的SalesBundle模块将根据分配给服务的shipment_method标签来寻找发货方式。

现在我们将在 src/Foggyline/ShipmentBundle/Service/FlatRateShipment.php 文件中创建实际的服务类,如下所示:

  1. namespace Foggyline\ShipmentBundle\Service;
  2. class FlatRateShipment
  3. {
  4. private $router;
  5. public function __construct(
  6. \Symfony\Bundle\FrameworkBundle\Routing\Router $router
  7. )
  8. {
  9. $this->router = $router;
  10. }
  11. public function getInfo($street, $city, $country, $postcode, $amount, $qty)
  12. {
  13. return array(
  14. 'shipment' => array(
  15. 'title' =>'Foggyline FlatRate Shipment',
  16. 'code' =>'flat_rate',
  17. 'delivery_options' => array(
  18. 'title' =>'Fixed',
  19. 'code' =>'fixed',
  20. 'price' => 9.99
  21. ),
  22. 'url_process' => $this->router->generate('foggyline_shipment_flat_rate_process'),
  23. )
  24. ;
  25. }
  26. }

getInfo方法将为我们未来的SalesBundle模块提供必要的信息,以便它能构建结账过程中的发货步骤。它接受一系列参数:$street、$city、$country、$postcode、$amount和$qtyurl_process是我们将插入所选发货方式的URL。然后,我们未来的SalesBundle模块将仅仅是对这个URL做一个AJAX POST,期望得到一个成功或错误的JSON响应,这与我们想象中对支付方式做的事情很相似。

创建统一费率装运控制器和路由

我们编辑 src/Foggyline/ShipmentBundle/Resources/config/routing.xml 文件,在其中添加以下路由定义:

  1. <route id="foggyline_shipment_flat_rate_process"path="/flat_rate/process">
  2. <default key="_controller">FoggylineShipmentBundle:FlatRate:process
  3. </default>
  4. </route>

然后我们创建一个src/Foggyline/ShipmentBundle/Controller/FlatRateController.php文件,内容如下:

  1. namespace Foggyline\ShipmentBundle\Controller;
  2. use Symfony\Component\HttpFoundation\JsonResponse;
  3. use Symfony\Component\HttpFoundation\Request;
  4. use Symfony\Bundle\FrameworkBundle\Controller\Controller;
  5. class FlatRateController extends Controller
  6. {
  7. public function processAction(Request $request)
  8. {
  9. // Simulating some transaction id, if any
  10. $transaction = md5(time() . uniqid());
  11. return new JsonResponse(array(
  12. 'success' => $transaction
  13. ));
  14. }
  15. }

现在我们应该能够访问一个URL,比如/app_dev.php/shipment/flat_rate/process,并看到processAction的输出。这里给出的实现是虚拟的。对我们来说,重要的是销售模块在结账过程中,将通过shipment_method标签服务的getInfo方法推送任何可能的交付选项。意思是,结账过程应该显示平价运费作为一个选项。结账的行为将被编码为,如果没有选择发货方式,它将阻止结账过程的进一步发展。当我们进入到SalesBundle模块时,我们将进一步触及这个问题。

创建动态费率支付服务

除了统一费率的发货方式,我们再来定义一个动态的发货方式,叫做动态费率。

我们先在 src/Foggyline/ShipmentBundle/Resources/config/services.xml 文件的 services 元素下定义以下服务:

  1. <service id="foggyline_shipment.dynamicrate_shipment"class="Foggyline\ShipmentBundle\Service\DynamicRateShipment">
  2. <argument type="service" id="router"/>
  3. <tag name="shipment_method"/>
  4. </service>

这里定义的服务只接受一个路由器参数。tag name与统一费率装运服务相同,我们将创建src/Foggyline/ShipmentBundle/Service/DynamicRateShipment.php文件。

然后我们将创建src/Foggyline/ShipmentBundle/Service/DynamicRateShipment.php文件,内容如下:

  1. namespace Foggyline\ShipmentBundle\Service;
  2. class DynamicRateShipment
  3. {
  4. private $router;
  5. public function __construct(
  6. \Symfony\Bundle\FrameworkBundle\Routing\Router $router
  7. )
  8. {
  9. $this->router = $router;
  10. }
  11. public function getInfo($street, $city, $country, $postcode, $amount, $qty)
  12. {
  13. return array(
  14. 'shipment' => array(
  15. 'title' =>'Foggyline DynamicRate Shipment',
  16. 'code' =>'dynamic_rate_shipment',
  17. 'delivery_options' => $this->getDeliveryOptions($street, $city, $country, $postcode, $amount, $qty),
  18. 'url_process' => $this->router->generate('foggyline_shipment_dynamic_rate_process'),
  19. )
  20. );
  21. }
  22. public function getDeliveryOptions($street, $city, $country, $postcode, $amount, $qty)
  23. {
  24. // Imagine we are hitting the API with: $street, $city, $country, $postcode, $amount, $qty
  25. return array(
  26. array(
  27. 'title' =>'Same day delivery',
  28. 'code' =>'dynamic_rate_sdd',
  29. 'price' => 9.99
  30. ),
  31. array(
  32. 'title' =>'Standard delivery',
  33. 'code' =>'dynamic_rate_sd',
  34. 'price' => 4.99
  35. ),
  36. );
  37. }
  38. }

与统一费率发货不同,这里getInfo方法的delivery_options键是用getDeliveryOptions方法的响应来构造的。该方法是服务的内部方法,并没有被想象成是暴露的,也没有被看成是接口的一部分。我们可以很容易地想象在它内部做一些API调用,以便为我们的动态发货方法获取计算的费率。

创建动态费率发货控制器和路由

一旦动态费率发货服务到位,我们就可以继续为它创建必要的路由。我们将首先在 src/Foggyline/ShipmentBundle/Resources/config/routing.xml 文件中添加以下路径定义:

  1. <route id="foggyline_shipment_dynamic_rate_process" path="/dynamic_rate/process">
  2. <default key="_controller">FoggylineShipmentBundle:DynamicRate:process
  3. </default>
  4. </route>

然后我们将创建src/Foggyline/ShipmentBundle/Controller/DynamicRateController.php文件,内容如下所示:

  1. namespace Foggyline\ShipmentBundle\Controller;
  2. use Foggyline\ShipmentBundle\Entity\DynamicRate;
  3. use Symfony\Component\HttpFoundation\JsonResponse;
  4. use Symfony\Component\HttpFoundation\Request;
  5. use Symfony\Bundle\FrameworkBundle\Controller\Controller;
  6. use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
  7. class DynamicRateController extends Controller
  8. {
  9. public function processAction(Request $request)
  10. {
  11. // Just a dummy string, simulating some transaction id
  12. $transaction = md5(time() . uniqid());
  13. if ($transaction) {
  14. return new JsonResponse(array(
  15. 'success' => $transaction
  16. ));
  17. }
  18. return new JsonResponse(array(
  19. 'error' =>'Error occurred while processing DynamicRate shipment.'
  20. ));
  21. }
  22. }

与统一费率发货类似,这里我们添加了一个简单的虚体实现过程和方法。传入的$request应该包含与服务getInfo方法相同的信息,也就是说,它应该有以下参数可用:$street、$city、$country、$postcode、$amount和$qty。方法的响应将在后面反馈到SalesBundle模块中。我们可以很容易地从这些方法中实现更强大的功能,但这不在本章的范围内。

单元测试

FoggylineShipmentBundle模块非常简单。通过只提供两个简单的服务和两个简单的控制器,它很容易测试。

我们首先在phpunit.xml.dist文件的testuites元素下添加以下一行:

  1. <directory>src/Foggyline/ShipmentBundle/Tests</directory>

有了这些,从商店的根目录下运行phpunit命令,就可以在src/Foggyline/ShipmentBundle/Tests/目录下找到我们定义的测试。

现在,让我们继续为FlatRateShipment服务创建一个测试。我们将创建一个 src/Foggyline/ShipmentBundle/Tests/Service/FlatRateShipmentTest.php 文件,内容如下:

  1. namespace Foggyline\ShipmentBundle\Tests\Service;
  2. use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
  3. class FlatRateShipmentTest extends KernelTestCase
  4. {
  5. private $container;
  6. private $router;
  7. private $street = 'Masonic Hill Road';
  8. private $city = 'Little Rock';
  9. private $country = 'US';
  10. private $postcode = 'AR 72201';
  11. private $amount = 199.99;
  12. private $qty = 7;
  13. public function setUp()
  14. {
  15. static::bootKernel();
  16. $this->container = static::$kernel->getContainer();
  17. $this->router = $this->container->get('router');
  18. }
  19. public function testGetInfoViaService()
  20. {
  21. $shipment = $this->container->get('foggyline_shipment.flat_rate');
  22. $info = $shipment->getInfo(
  23. $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty
  24. );
  25. $this->validateGetInfoResponse($info);
  26. }
  27. public function testGetInfoViaClass()
  28. {
  29. $shipment = new \Foggyline\ShipmentBundle\Service\FlatRateShipment($this->router);
  30. $info = $shipment->getInfo(
  31. $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty
  32. );
  33. $this->validateGetInfoResponse($info);
  34. }
  35. public function validateGetInfoResponse($info)
  36. {
  37. $this->assertNotEmpty($info);
  38. $this->assertNotEmpty($info['shipment']['title']);
  39. $this->assertNotEmpty($info['shipment']['code']);
  40. $this->assertNotEmpty($info['shipment']['delivery_options']);
  41. $this->assertNotEmpty($info['shipment']['url_process']);
  42. }
  43. }

这里正在运行两个简单的测试。一个检查我们是否可以通过容器实例化一个服务,另一个检查我们是否可以直接实例化。一旦被实例化,我们只需调用服务的getInfo方法,向它传递一个虚拟地址和订单信息。虽然我们实际上并没有在getInfo方法中使用这些数据,但我们需要传递一些东西,否则测试会失败。该方法预计将返回一个响应,其中包含shipment键下的几个键,最主要的是 title, code, delivery_optionsurl_process

现在,让我们继续为我们的DynamicRateShipment服务创建一个测试。我们将创建一个 src/Foggyline/ShipmentBundle/Tests/Service/DynamicRateShipmentTest.php 文件,内容如下:

  1. namespace Foggyline\ShipmentBundle\Tests\Service;
  2. use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
  3. class DynamicRateShipmentTest extends KernelTestCase
  4. {
  5. private $container;
  6. private $router;
  7. private $street = 'Masonic Hill Road';
  8. private $city = 'Little Rock';
  9. private $country = 'US';
  10. private $postcode = 'AR 72201';
  11. private $amount = 199.99;
  12. private $qty = 7;
  13. public function setUp()
  14. {
  15. static::bootKernel();
  16. $this->container = static::$kernel->getContainer();
  17. $this->router = $this->container->get('router');
  18. }
  19. public function testGetInfoViaService()
  20. {
  21. $shipment = $this->container->get('foggyline_shipment.dynamicrate_shipment');
  22. $info = $shipment->getInfo(
  23. $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty
  24. );
  25. $this->validateGetInfoResponse($info);
  26. }
  27. public function testGetInfoViaClass()
  28. {
  29. $shipment = new \Foggyline\ShipmentBundle\Service\DynamicRateShipment($this->router);
  30. $info = $shipment->getInfo(
  31. $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty
  32. );
  33. $this->validateGetInfoResponse($info);
  34. }
  35. public function validateGetInfoResponse($info)
  36. {
  37. $this->assertNotEmpty($info);
  38. $this->assertNotEmpty($info['shipment']['title']);
  39. $this->assertNotEmpty($info['shipment']['code']);
  40. // Could happen that dynamic rate has none?!
  41. //$this->assertNotEmpty($info['shipment']['delivery_options']);
  42. $this->assertNotEmpty($info['shipment']['url_process']);
  43. }
  44. }

这个测试与FlatRateShipment服务的测试几乎相同。在这里,我们也有两个简单的测试:一个是通过容器获取支付方法,另一个是直接通过类获取。不同的是,我们不再断言delivery_options不为空的存在。这是因为根据给定的地址和订单信息,真正的API请求可能不会返回任何交付选项。

功能测试

我们整个模块有两个控制器类,我们要测试响应。首先要确保FlatRateControllerDynamicRateController类的process方法可以访问和工作,我们将首先创建src/Foggyline/ShipmentBundle/Tests/Controller/FlatRateControllerTest.php文件。

我们先创建一个src/Foggyline/ShipmentBundle/Tests/Controller/FlatRateControllerTest.php文件,内容如下:

  1. namespace Foggyline\ShipmentBundle\Tests\Controller;
  2. use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  3. class FlatRateControllerTest extends WebTestCase
  4. {
  5. private $client;
  6. private $router;
  7. public function setUp()
  8. {
  9. $this->client = static::createClient();
  10. $this->router = $this->client->getContainer()->get('router');
  11. }
  12. public function testProcessAction()
  13. {
  14. $this->client->request('GET', $this->router->generate('foggyline_shipment_flat_rate_process'));
  15. $this->assertSame(200, $this->client->getResponse()->getStatusCode());
  16. $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type'));
  17. $this->assertContains('success', $this->client->getResponse()->getContent());
  18. $this->assertNotEmpty($this->client->getResponse()->getContent());
  19. }
  20. }

然后我们将创建一个src/Foggyline/ShipmentBundle/Tests/Controller/DynamicRateControllerTest.php文件,内容如下:

  1. namespace Foggyline\ShipmentBundle\Tests\Controller;
  2. use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  3. class DynamicRateControllerTest extends WebTestCase
  4. {
  5. private $client;
  6. private $router;
  7. public function setUp()
  8. {
  9. $this->client = static::createClient();
  10. $this->router = $this->client->getContainer()->get('router');
  11. }
  12. public function testProcessAction()
  13. {
  14. $this->client->request('GET', $this->router->generate('foggyline_shipment_dynamic_rate_process'));
  15. $this->assertSame(200, $this->client->getResponse()->getStatusCode());
  16. $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type'));
  17. $this->assertContains('success', $this->client->getResponse()->getContent());
  18. $this->assertNotEmpty($this->client->getResponse()->getContent());
  19. }
  20. }

这两个测试几乎完全相同。它们包含了一个单一的过程动作方法的测试。按照现在的编码,控制器过程动作只是返回一个固定的成功JSON响应。我们可以很容易地扩展它,使其返回的不仅仅是一个固定的响应,并且可以用一个更强大的功能测试来配合这种变化。

小结

在本章中,我们建立了一个有两种发货方式的发货模块。每个发货方法都提供了可用的交付选项。统一费率的发货方法在其交付选项下只有一个固定的值,而动态费率方法则从getDeliveryOptions方法中获取其值。我们可以很容易地嵌入一个真正的运费API作为getDeliveryOptions的一部分,以便提供真正的动态运费选项。

显然,我们在这里缺乏官方的接口,就像我们对支付方法所做的那样。然而,当我们最终确定最终模块时,我们可以随时回来重构我们的应用程序。

与支付方式类似,这里的想法是创建一个最小的结构,展示如何开发一个简单的运输模块,以便进一步定制。使用shipment_methodservice标签,我们有效地暴露了未来销售模块的发货方法。

在下一章中,我们将建立一个销售模块,它将最终利用我们的支付和发货模块。