Help us understand the problem. What is going on with this article?

なぜDeNA 20 新卒 Advent Calendar 2019を企画したのか【+専用アプリの解説】

はじめに

ついに最終日🎉
「なぜ内定者のカレンダーを企画したのか?」の理由と感想を書いていきます。
合わせて専用アプリの実装の解説も付けました🙌

また、この記事は@tocknとの合作記事です。

目次

章番号 タイトル
1章 内定者カレンダーを企画した理由
2章 全体の感想
3章 専用アプリ作成の発端
4章 専用アプリ作成のバックエンド実装
5章 専用アプリ作成のフロント実装
終章 さいごに

内定者カレンダーを企画した理由

「内定者で何かをしたい!」という思いと、「採用に貢献したい!」という思いが合わさった結果です。
この企画は来年で完成するもので、

  • 👨‍🎓内定者のカレンダー
  • 👩‍💻新卒のカレンダー
  • 👨‍💻社員のカレンダー

の3つを作成することで

  • 👨‍🎓内定者のカレンダー: どういう人が入社するのか
  • 👩‍💻新卒のカレンダー: 1年後の成長度、成長環境
  • 👨‍💻社員のカレンダー: 2年以降のキャリア

を一部ですが表現でき、DeNAという会社のことをより知ってもらえる機会になると考えました。
学生にとっては、どういう人が入社し、1年後の成長、その後のキャリアが見えるのは非常に有益な情報になると考えてます。
採用イベントで知ることが可能ですが記事という、より自然体で個人の思想がでる媒体での情報から感じ取れるものがあると私は思ってます。(就活中は各社の技術ブログはチェックしてました)

以上のような思いから企画をしました。

また、今回は「内定者vs社員!?」という企画をしたように、普通のカレンダーとは違う楽しみ方を提供できると考えています。

全体の感想

企画して本当によかったです。
内定者同士で交流が生まれたのは勿論ですが、「この子(サービス)知ってる!」となる機会も多く、同期をより知るきっかけになりました。
記事の中で研究内容や興味ある分野などを知ることもできて交流が増えました。

また、個人的にはイベントを企画する際のフローを今回で知れたのも良かったです。

申請などやり取りする中で、「弊社は頑張っている人を応援するとき、他の人を蹴落とすこと以外にNGを出すことはない」 と言っていただきましたが、本当にその通りだと感じました。

本当にいいカレンダーになったと思います。
申請など協力していただいた社員の方々、執筆者のみんな、読者の皆さん、本当に本当にありがとうございました。

専用アプリ作成の発端

vsの企画をしていた時にみんなに意見を募ったのが発端でした。
スクリーンショット 2019-12-19 14.58.00.png

そして何気なく送信したメッセージが始まりでした...。

スクリーンショット 2019-12-19 14.58.50.png

...3時間後にtocknがAPIを作りました...!?

19-12-19-14-50-51-928_deco.jpg

APIができたことでハッカソンが突如始まりましたw
(だって、APIあったら作るしかないじゃん)

19-12-19-14-51-25-135_deco.jpg

専用アプリ作成のバックエンド実装

バックエンド実装を担当した@tocknです。

naroのアイデアを見た時自分は地元のケンタッキーでお昼を食べていたのですが、すぐに実装したい衝動に駆られ、残っていたチキンフィレサンドを急いで口の中に突っ込みました。

そしてケンタッキーから家までの徒歩10分で、サーバーの構成を考えていました。

とりあえず自分はJSONを返すAPIだけ提供して、フロントは得意な人に実装してもらう形にして開発に柔軟性を持たせることにしました。

Qiitaには公式でAPIが提供されていますが、アドベントカレンダーに関するAPIは公開されていないようでした。案を見た感じ、アドベントカレンダーの内定者の総イイね数、社員の総イイね数をイイ感じに取得するAPIが必要になりそうです。そこで、カレンダーページをスクレイピングする事で総イイね数を取得する事にしました。

また、安く爆速で作るために慣れているGoを採用し、インフラはGAE, Datastoreを使う事に決めました。
(昨年度参加したDeNAのサマーインターンでGoを使ってスクレイピングを実装したのを思い出し、少しエモい気持ちになりました)

今回実装したコードはGitHubで公開しています。

ディレクトリ構成

.
├── Makefile
├── api
│   ├── handler
│   │   ├── handler.go
│   │   └── utils.go
│   └── router
│       └── router.go
├── appengine
│   ├── app.yaml
│   ├── cron.yaml
│   └── main.go
├── main.go
├── model
│   ├── article.go
│   ├── likes.go
│   └── repository
│       └── likes.go
├── persistence
│   ├── datastore
│   │   ├── article.go
│   │   └── likes.go
│   └── memory
│       ├── article.go
│       └── likes.go
└── qiita
    └── qiita.go

シンプルな構成になっていると思います。層も少ないのでそれぞれ簡単に説明していきます。

model

レスポンスのモデルおよびデータベースのモデルを定義している層です。レスポンスもデータベースも同じ構造で対応しています。楽ですね。

model/likes.go
type Likes struct {
    _kind     string    `boom:"kind" json:"-"`
    ID        string    `boom:"id" json:"-"`
    Shinsotsu int64     `json:"shinsotsu"`
    General   int64     `json:"general"`
    UpdatedAt time.Time `json:"updated_at"`
}

model/repository

データを永続化するためのインターフェースをここで定義しています。これによって永続化の実装を抽象化できるので、後述するサクッとローカルデバッグが実現できたりします。

model/repository/likes.go
type Likes interface {
    GetNew() (*model.Likes, error)
    Create(likes *model.Likes) error
}

qiita

QiitaのスクレイピングおよびAPIを叩いて値を取得する層です。以下は年、カレンダー名を引数に、そのカレンダーの総イイね数を返す関数です。

qiita/qiita.go
func GetAllLikes(year int64, title string) (int64, error) {
        doc, err := getAdventDoc(year, title)
        if err != nil {
                return 0, err
        }
        selection := doc.Find("div.adventCalendarJumbotron_stats[title=Likes]")
        likesStr := selection.Text()
        likesStr = strings.TrimSpace(likesStr)
        return strconv.ParseInt(likesStr, 10, 64)
}

api/handler

HTTPハンドラーをここで定義します。このHandler構造体はメンバにmodel/repositoryで定義したrepositoryインターフェースを持っており、New関数で生成します。インターフェースなので、Handler生成時に何を持たせるかで永続化の手法を変えることができます。(モックを持たせたり自由)

api/handler/handler.go
type Handler struct {
        likesRepo   repository.Likes
        articleRepo repository.Article
}

func New(lr repository.Likes, as repository.Article) *Handler {
        return &Handler{
                likesRepo:   lr,
                articleRepo: as,
        }
}

func (h *Handler) GetLikes(w http.ResponseWriter, r *http.Request) {
        likes, err := h.likesRepo.GetNew()
        if err != nil {
                respondError(w, r, err, http.StatusInternalServerError, nil)
                return
        }
        respondSuccess(w, r, http.StatusOK, likes)
}

persistence/datastore

GCPのDatastoreを使って永続化するための層です。model/repositoryの実装になっています。

persistence/datastore/likes.go
type likesRepository struct {
        client *boom.Boom
}

func NewLikesRepository(c *boom.Boom) repository.Likes {
        return &likesRepository{
                client: c,
        }
}

func (r *likesRepository) GetNew() (*model.Likes, error) {
        q := r.client.NewQuery("Likes").
                Order("-UpdatedAt").
                Limit(1)
        var ls []*model.Likes
        if _, err := r.client.GetAll(q, &ls); err != nil {
                return nil, err
        }
        if len(ls) == 0 {
                return nil, errors.New("not found")
        }
        return ls[0], nil
}

appengine/main

GAEで動かすためのmain関数です。ここでHandlerを生成し、Datastoreの実装を注入しています。

appengine/main.go
func main() {
        ctx := context.Background()
        if err := run(ctx); err != nil {
                panic(err)
        }
}

func run(ctx context.Context) error {
        ds, err := clouddatastore.FromContext(ctx)
        if err != nil {
                return err
        }
        c := boom.FromClient(ctx, ds)
        likeRepo := datastore.NewLikesRepository(c)
        articleRepo := datastore.NewArticleRepository(c)
        s := handler.New(likeRepo, articleRepo)
        r := router.New(s)
        http.Handle("/", r)
        appengine.Main()
        return nil
}

サクッとローカルデバッグ

APIとしてイイ感じに値を返せるか、手元でサクッとデバッグを行えるようにしたい。そこで活きてくるのがrepositoryによる永続化の抽象化です。今回persistence/memoryにも、model/repositoryの実装があります。これを使うと非常に簡単にデバッグできます。実装は以下の通りです。datastoreの実装ではclientをメンバに持っていましたが、memoryでは普通のmapを持っています。これを簡易DBとして扱います。

persistence/memory/likes.go
type likesRepository struct {
        memory []*model.Likes
}

func NewLikesRepository() repository.Likes {
        mem := make([]*model.Likes, 1)
        mem[0] = &model.Likes{
                Shinsotsu: 100,
                General:   101,
                UpdatedAt: time.Now(),
        }
        return &likesRepository{
                memory: mem,
        }
}

func (r *likesRepository) GetNew() (*model.Likes, error) {
        return r.memory[len(r.memory)-1], nil
}

そして、ルートにあるmain.goが手元デバッグ用のmainです。

main.go
func main() {
        if err := run(); err != nil {
                panic(err)
        }
}

func run() error {
        likeRepo := memory.NewLikesRepository()
        articleRepo := memory.NewArticleRepository()
        s := handler.New(likeRepo, articleRepo)
        r := router.New(s)
        log.Println("serving...")
        return http.ListenAndServe(":8080", r)
}

このようにmemoryの方を注入しているので、手元で簡単にデバッグができます!便利!(書こうと思えばテストも綺麗に書けますね🦁)

Cronで定期実行

リクエストのたびにQiitaをクロールしてはパフォーマンス悪いしQiitaにも申し訳ないので、Qiitaのクローリングは定期実行してGCPのDatastoreに保存します。

appengine/cron.yaml
cron:
- description: "update likes"
  url: /updatelikes
  schedule: every 10 minutes
  target: default
  timezone: Asia/Tokyo

こんな感じで定義すれば、10分おきにクロールしてくれます。

以上、バックエンド実装のお話でした。

専用アプリ作成のフロント実装

フロントエンド実装を担当した@naro143です。

「毎日記事を見るきっかけになる」ということでアプリであることは必須だったのですが、いい機会なのでPWAを初めて触るNuxtで実装することにしました。
結構汚かったり無駄な実装があると思います。
是非アドバイスください:pray:

本当に基礎的なことしかしてないので、基礎的でないところに絞って解説をします。

今回実装したコードはGitHubで公開しています。

アプリ紹介

サイトはこちら↓
https://vs-dena-advent-client.naro143.com/

nuxtの設定


コードを見る
nuxt.config.js
export default {
  mode: 'spa',
  /*
   ** Headers of the page
   */
  head: {
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      {
        hid: 'description',
        name: 'description',
        content: process.env.npm_package_description || ''
      },
      {
        hid: 'og:image',
        property: 'og:image',
        content: 'https://vs-dena-advent-client.naro143.com/icon.png'
      }
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
    htmlAttrs: {
      lang: 'ja'
    },
    bodyAttrs: {
      class: 'app'
    }
  },
  /*
   ** Customize the progress-bar color
   */
  loading: { color: '#fff' },
  /*
   ** Global CSS
   */
  css: [{ src: '~/assets/styles/app.sass', lang: 'sass' }],
  /*
   ** Plugins to load before mounting the App
   */
  plugins: [{ src: '~plugins/ga.js', mode: 'client' }],
  /*
   ** Nuxt.js dev-modules
   */
  buildModules: [
    // Doc: https://github.com/nuxt-community/eslint-module
    '@nuxtjs/eslint-module',
    '@nuxtjs/google-analytics'
  ],
  /*
   ** Nuxt.js modules
   */
  modules: [
    // Doc: https://axios.nuxtjs.org/usage
    '@nuxtjs/axios',
    '@nuxtjs/pwa',
    '@nuxtjs/proxy',
    '@nuxtjs/style-resources'
  ],
  /*
   ** Axios module configuration
   ** See https://axios.nuxtjs.org/options
   */
  axios: {
    proxy: true
  },
  proxy: {
    '/api': {
      target: 'https://example.com',
      pathRewrite: { '^/api': '/' }
    },
    '/qiita': {
      target: 'https://qiita.com',
      pathRewrite: { '^/qiita': '/' }
    }
  },
  /*
   ** Build configuration
   */
  build: {
    /*
     ** You can extend webpack config here
     */
    extend(config, ctx) {}
  },
  workbox: {
    dev: true
  },
  styleResources: {
    sass: ['~/assets/styles/_variables.sass']
  },
  generate: {
    dir: 'docs'
  },
  manifest: {
    name: 'vs DeNA Advent',
    short_name: 'vs DeNA',
    start_url: '/',
    scope: '/',
    title: 'vs DeNA',
    description: 'vs DeNA Advent Calendarのアプリ',
    lang: 'ja',
    theme_color: '#5BBBB7',
    display: 'standalone',
    icons: []
  },
  googleAnalytics: {
    id: 'UA-hogehoge'
  }
}

ポイントはgithubpagesで公開する為にbuildで吐き出すディレクトリをdocsと指定しています。

Graphの生成


コードを見る
Graph.vue
<template>
  <div class="Graph">
    <h2>vs 総合いいね数</h2>
    <bar-chart :chart-data="dataCollection" :options="options" />
  </div>
</template>

<script>
import BarChart from './BarChart.js'
export default {
  name: 'Graph',
  components: {
    BarChart
  },
  data: () => {
    return {
      shinsotsu: 0,
      general: 0
    }
  },
  computed: {
    dataCollection() {
      return {
        display: false,
        labels: ['20卒内定者', '社員オールスター'],
        datasets: [
          {
            label: '総合いいね数',
            data: [this.shinsotsu, this.general],
            backgroundColor: ['#DC0451', '#FDC82F']
          }
        ]
      }
    },
    options() {
      const maxLikes = Math.max(this.shinsotsu, this.general)
      // maxGraphValue = 100(min), 200, ...
      const maxGraphValue = maxLikes + 100 - (maxLikes % 100)
      return {
        responsive: true,
        legend: {
          display: false
        },
        scales: {
          yAxes: [
            {
              display: true,
              ticks: {
                min: 0,
                max: maxGraphValue,
                stepSize: maxGraphValue / 10,
                fontSize: 16
              }
            }
          ],
          xAxes: [
            {
              display: true,
              ticks: {
                fontSize: 16
              }
            }
          ]
        },
        layout: {
          padding: {
            left: 10,
            right: 10
          }
        }
      }
    }
  },
  mounted() {
    this.getQiita()
  },
  methods: {
    async getQiita() {
      await this.$axios
        .get('https://example.com/likes')
        .then((response) => {
          this.shinsotsu = response.data.shinsotsu
          this.general = response.data.general
        })
    }
  }
}
</script>

<style scoped lang="sass">
.Graph
  h2
    margin-left: 10px
    margin-right: 10px
    border-bottom: 4px solid $color-primary
</style>

いいね数の棒グラフの描画にはvue-chartjsを使用しました。
使用法はchart.jsと同じです。
ラベルと、データ、グラフの色を設定します。
また、グラフの最大値と間隔はいいね数の最大値から計算をして設定しています。
グラフの色はDeNAカラーを参照しました。

https://dena.com/jp/company/policy/logoguide.html

さいごに

戦略はありましたが、勢いで始めた企画でした。
前例もなく、申請のフローもわからないので質問をしまくって動きまくって、人を巻き込むだけ巻き込みました。

同期にも全体で参加を募って、個人でもメッセージを送って誘いました。
みんな快く承諾してくれて、忙しい中で最高の記事を書き上げてくれました。

多くの方が記事に反応して、盛り上がってくれました。
社員さんからも「順調だね」と言っていただけました。

本当に大変でしたが最高の1ヶ月でした。
一生の思い出になると思います。
「早く行くなら一人でいけ、遠くまで行きたいならみんなと行け」を体感しました。

本当にありがとうございました。

来年の私たちのカレンダーも楽しみにお待ちください!🙌

未来の21卒の内定者へ、前例とフローはできたのでぜひ企画を検討してみてください

質問とか-> https://twitter.com/naro143
採用サイト-> https://student.dena.com/

カレンダーまとめ

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした