こんにちわ。2019年4月15日にUUUMにインターンとして入社したれとるときゃりーです。
インターンに入るまで、個人でサービスを作るなどして開発はしていましたが、実際の会社に入ってコードを書くのは初めてでした。
ちょうどUUUMに入って任された一つ目のタスクが終わったので、振り返りを込めてブログに執筆していこうと思います。
前提条件
laravel+nuxt.jsのプロダクトにアサインされました。
どのような実装を任されたか
外部のRSSを受け取って、それをサービス内で表示するようにします。
※ RSSとは
RSSは、ニュースやブログなど各種のウェブサイトの更新情報を配信するための文書フォーマットの総称である。( wikipediaより引用)
どのように実装したか
※ コードは一部省略しています
実装の方針
バックエンド側は、RSSを受け取り、タイトルやサムネイル画像のURLなど必要な部分のみを抜き出してAPIとして返します
フロント側は、バックエンド側のAPIを叩いて表示します
バックエンド側(laravel)
RSSを受け取る
ライブラリを使えば簡単でした。willvincent/feedsを使用しました。
サムネイル画像は簡単に取得できませんでした。記事中の一番最初に出てくるimgタグの中のsrcを取ってくる必要がありました。
画像のパース部分は、最初は正規表現で実装していました。
// 記事の本文の中からimgタグを抽出して画像URLを取得 $pattern = '/<img.*?src\s*=\s*[\"|\'](.*?)[\"|\'].*?>/'; if (preg_match($pattern, html_entity_decode($feedItem->get_content()), $match)) { $image = $match[1]; } else { $image = null; }
しかしこの実装だと、記事側でのタグの表記揺れがありうまくいかないときがありました。
正規表現はつらいのでやめました😇
HTMLパーサーのライブラリammadeuss/laravel-html-dom-parserを導入して解決しました。
// 記事の本文の中からimgタグを抽出して画像URLを取得 $image = HTMLDomParser::str_get_html(html_entity_decode($feedItem->get_content()))->find('img')[0]->attr['src'];
テスト
テストは書いたことがなかったので、かなり苦戦しました。 他のテストを参考に、laravelとPHPUnitのドキュメントを見ながら書きました。
上述した、willvincent/feedsをモックする必要があったので、中身を少し読む必要がありました。
個人でサービスを作っている中では、ライブラリのコードをまったく見たことなかったので新鮮でした。
「Qiitaなどで調べるより、直接コードを見たほうが早いので癖をつけたほうが良い」ということを先輩に教えてもらいました。
エディタのコードジャンプ機能も、この時教えてもらいました。(今まで知らなくて1年位やってきたので便利すぎて泣きました😭)
書いたテストはこんな感じです。
<?php namespace Tests\Unit\ControllerTest\Api\V1; use Mockery; use Tests\TestCase; use App\Http\Controllers\Api\V1\HogeController; use Feeds; class HogeControllerTest extends TestCase { private $hogeController; /** * {@inheritdoc} */ public function setUp() { parent::setUp(); $this->hogeController = new hogeController(); } /** * Test Get List Hoge * * @dataProvider listHogeDataProvider * @param array $hoges データセット * @param array $expect 期待値 * @return void */ public function testFetchListHoge(array $hoges, array $expect) { $fetchedTopics = []; foreach ($hoges as $hoge) { $simplePieItem = $this->mock('SimplePie_Item'); $simplePieItem->shouldReceive('get_content')->andReturn($creatorTopic['content']); $simplePieItem->shouldReceive('get_title')->andReturn($creatorTopic['title']); $fetchedTopics[] = $simplePieItem; } $feed = $this->mock('SimplePie'); $feed->shouldReceive('get_items')->withAnyArgs()->andReturn($fetchedTopics); $feed->shouldReceive('get_title')->andReturn('title'); Feeds::shouldReceive('make')->with(Mockery::any())->andReturn($feed); $response = $this->json('GET', 'api/v1/hoge'); $response->assertJson($expect); } /** * @return array */ public function listHogeataProvider() { return [ "正常" => [ [ [ 'content' => '<figure class="block-image">' . '<img src="https://example.com/uploads/img1.png" alt="image" width="auto" height="auto">' . '</figure>', 'title' => 'title1', ], ... ], [ 'title' => 'title', 'hoges' => [ [ 'title' => 'title1', 'image' => 'https://example.com/uploads/img1.png', ], ... ], ... ] ], ]; } /** * @param $class * @return Mockery\MockInterface */ private function mock($class) { $mock = Mockery::mock($class); $this->app->instance($class, $mock); return $mock; } }
DataProviderを利用することで綺麗にまとまりました。とても便利ですね。
フロント側(nuxt.js)
記事の表示
プロジェクト内はアトミックデザインを意識している構成でした。
アトミックデザインは、詳しくなかったのでいろいろと調べてみましたが、結局良くわかっていません。
変更前
もともと記事を表示するためのカードがあったので、これを再利用したいと考えました。
CardList.vue
<template> <div> <ul> <li v-for="post in posts" :key="post.id"> <card v-bind="{ post }" /> </li> </ul> </div> </template> <script> import Card from "~/components/molecules/Card"; export default { name: "CardList", components: { Card }, props: { posts: { type: Array, default: () => [] } } }; </script>
Card.vue
<template> <a :href="`posts/${post.id}`"> <div v-lazy:background-image="post.image" /> <div> <text-title :value="post.title" /> <div> <text-date :value="publishDate" /> </div> </div> </a> </template> <script> import moment from "moment"; import TextTitle from "~/components/atoms/TextTitle.vue"; import TextDate from "~/components/atoms/TextDate"; import { Post } from "~/utils/entities"; import timeFormat from "~/config/timeFormat"; export default { name: "Card", components: { TextTitle, TextDate }, props: { post: { type: Object, default: () => new Post({}), validator: obj => Post.keys === obj.keys } }, computed: { publishDate() { return moment(this.post.publish_date, "YYYY/MM/DD").format(timeFormat); } } }; </script>
変更後
変更前は、Post
という記事のオブジェクトの構造に依存していました。そのため、CardList
側でStringやNumberに分解してCard
に渡してあげることで、Card
を再利用可能なものにしました。
CardList.vue
<template> <div> <ul> <li v-for="content in parsedContents" :key="content.id" > <card :link="content.link ? content.link : 'posts/' + content.id" :image="content.image" :title="content.title" :publish-date="content.publishDate" /> </li> </ul> </div> </template> <script> import Card from "~/components/molecules/Card"; export default { name: "CardList", components: { Card }, props: { contents: { type: Array, default: () => [] } } }; </script>
Card.vue
<template> <nuxt-link-and-atag-wrapper :to="link"> <div v-lazy:background-image="image" /> <div> <text-title :value="title" /> <div> <text-date :value="publishDate" /> </div> </div> </nuxt-link-and-atag-wrapper> </template> <script> import TextTitle from "~/components/atoms/TextTitle"; import TextDate from "~/components/atoms/TextDate"; import Tag from "~/components/atoms/Tag"; import NuxtLinkAndAtagWrapper from "~/components/molecules/NuxtLinkAndAtagWrapper"; export default { name: "Card", components: { NuxtLinkAndAtagWrapper, TextTitle, TextDate, Tag }, props: { link: { type: String, default: "" }, image: { type: String, default: "" }, title: { type: String, default: "" }, publishDate: { type: String, default: "" }, categoryId: { type: Number, default: null } } }; </script>
クリックすると内部または外部リンクに飛んでくれるコンポーネントを作成
渡されたリンクが、内部リンク("/hoge"
など)か、外部リンク("https://example.com"
など)を判断して、aタグかnuxt-linkでラップしてくれるコンポーネントを作りました。
関数型コンポーネントを利用しています。
NuxtLinkAndAtagWrapper.vue
<script> export default { functional: true, render: function(h, { props, children, data }) { function isInternalLink(path) { return !/^https?:\/\//.test(path); } if (isInternalLink(props.to)) { return h("nuxt-link", data, children); } else { delete data.attrs.to; data.attrs.href = props.to; return h("a", data, children); } } }; </script>
テスト
このプロジェクトのnuxt.js内ではほとんどテストが書かれていませんでした。
フロントのテストを書くのも初めてだったので、いろいろ調べながら書きました。
例
テスト対象 TextDate.vue
<template> <p>{{ value | readableDate }}</p> </template> <script> import moment from "moment"; import timeFormat from "~/config/timeFormat"; export default { name: "TextDate", filters: { readableDate(str) { if (str == "") return ""; return moment(str, "YYYY/MM/DD").format(timeFormat); } }, props: { value: { type: String, default: "" } } }; </script>
テスト TextDate.spec.js
import { shallowMount } from "@vue/test-utils"; import TextDate from "@/components/atoms/TextDate.vue"; describe("TextDate.vue", () => { let textDate; test("Setup correctly", () => { textDate = shallowMount(TextDate, { propsData: { value: "2019-01-01 00:00:00" } }); expect(true).toBe(true); }); test("props", () => { textDate = shallowMount(TextDate, { propsData: { value: "2019-01-01 00:00:00" } }); expect(textDate.props().value).toBe("2019-01-01 00:00:00"); }); test("日時が人間に読みやすいように変換されて表示される", () => { textDate = shallowMount(TextDate, { propsData: { value: "2019-01-01 00:00:00" } }); expect(textDate.text()).toBe("2019/01/01"); }); test("空文字を渡すと、何も表示されない", () => { textDate = shallowMount(TextDate, { propsData: { value: "" } }); expect(textDate.text()).toBe(""); }); });
フロントエンドのテスト項目は、どのようなものが良いのか、正直イマイチわかっていません....。
感想
まず、プロジェクトにアサインされてから、実際に中身を読むと分からないことが多くて大変でした。
理解するためには、テストやアトミックデザイン、Vuex、DI、Trait、サービスコンテナなど、使ったことのない技術や概念について勉強する必要がありました。
また、他の人が書いたコードを読むと勉強になることがとても多く楽しいです。
これは個人でコードを書いていてもあまり得られない経験です。
いろいろ調べて悩みながらやっていたら、実装に2週間半くらいかかってしまいました・・・。もうちょっと早く実装できるようになりたいです。
まだまだ分からないことがたくさんあるので、勉強していきたいです。
これからも頑張っていきます〜💪