20210308のReactに関する記事は5件です。

【Spring-boot】Nature Remoをローカル環境から操作する【React】

NatureRemo

テレビやエアコン等の赤外線操作ができる家電をスマホやスマートスピーカー等から操作できるようにするデバイス。
Nature Remo(ネイチャーリモ)

概要

たまにAWSの障害が波及してNatureRemoが使えなくなる事象が発生する。
こうなってしまうと、いつもはAlexaで操作していた家電の切り替えがリモコンじゃないと操作できなくなる。

また、NatureRemoはLocal環境からアクセスできるAPIを用意していて赤外線信号の取得と実行のみができる仕組みがある。
これを利用してローカル環境だけでNatureRemoを操作できるアプリの開発を行う。
赤外線信号を保存する仕組みは備わっていない様子。こちらも合わせて実装していく。
Nature Inc

環境整理

  • サーバ: Spring-boot
  • フロントエンド: React.js
  • DB: Cassandra

サーバサイド

akapo001/local-remo
サーバサイドはREST APIをSpringBootで実装する。
firebaseで実装しても良かったが次のアサイン予定のプロジェクトでSpringを使う予定なので思い出すのを兼ねて自前で実装を行った。
DBはRDBで実装するとNatureRemoから取得する赤外線情報のフォーマット的に大変そうだったので
NoSQLのCassandraを使用してデータの保存を行う。
CassandraはDockerのイメージを利用して構築する。

API一覧

  • 信号取得
    • NatureRemoから赤外線信号を取得する
    • NatureRemoに赤外線信号を当てたあとこのAPIを実行すると赤外線情報を取得できる
  • 信号保存
    • 信号取得で取得した赤外線情報をそのままこのAPIのBodyにわたすとサーバ側のCassandraに保存するAPI
    • パスパラメータに信号に紐付ける名前を指定する。
  • 信号一覧取得
    • Cassandraに保存されている信号の一覧を取得する。
  • 信号実行
    • 信号一覧で取得したデータから実行したい信号のIDを設定してリクエストするとNatureRemoを通して家電の操作ができる。
  • 信号削除
    • 信号一覧で取得したデータから削除したい信号のIDを設定してリクエストするとCassandraからデータを削除する。 ## SpringからCassandraへの接続設定 SpringからCassandra呼び出しをする処理のサンプルがなくて苦労したが下記のクラスをConfigrationクラスとして定義することで接続ができた。 Keyspaceやホスト、ポート等は設定ファイルから読み取る。
@Configuration
@EnableCassandraRepositories(cassandraTemplateRef = "cassandraTemplate")
public class CassandraConfig extends AbstractCassandraConfiguration {
    @Value("${spring.data.cassandra.username}")
    private String username;

    @Value("${spring.data.cassandra.password}")
    private String password;

    @Value("${spring.data.cassandra.keyspace-name}")
    private String keyspaceName;

    @Value("${spring.data.cassandra.contact-points}")
    private String contactPoints;

    @Value("${spring.data.cassandra.port}")
    private int port;


    @Override
    @Primary
    @Bean
    public CassandraAdminTemplate cassandraTemplate() {
        CqlSession session = CqlSession.builder().withKeyspace(keyspaceName).build();
        return new CassandraAdminTemplate(session);
    }

    // ポート番号の設定
    @Override
    @Bean(name = "Port")
    public int getPort() {
        return port;  // デフォルトは9042
    }

    // キースペースの設定
    @Override
    @Bean(name = "KeySpace")
    protected String getKeyspaceName() {
        return keyspaceName;
    }

}

Cassandra操作

こちらも最新のバージョンだとAPIのIFが違うらしくなかなかアクセスができなかったので以下にサンプルを記載

@Service
public class CassandraService {

    @Autowired
    CassandraAdminTemplate cassandraTemplate;

    public SignalTable insert(Signal signal, String name) {
        return cassandraTemplate.insert(new SignalTable(Uuids.timeBased(), name, signal));
    }

    public List<SignalTable> selectAll() {
        SimpleStatement select = QueryBuilder.selectFrom("signal").all().build();
        return cassandraTemplate.select(select, SignalTable.class);
    }

    public SignalTable select(String id) {
        UUID uuid = UUID.fromString(id);
        return cassandraTemplate.selectOneById(uuid, SignalTable.class);
    }

    public String delete(String id) throws NotFoundSignal {
        UUID uuid = UUID.fromString(id);
        Boolean res = cassandraTemplate.deleteById(uuid, SignalTable.class);
        if(!res) throw new NotFoundSignal("信号が見つかりませんでした");

        return id;
    }

}

CORS対策

ローカルのフロントエンドからローカルに別途立てたサーバを呼び出したところ下記のエラーが出た。

The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

controllerクラスにCrossOriginアノテーションを追加してやることで回避できた。
デフォルトだとすべてのアクセスを許可する。

@RestController
@CrossOrigin
public class SignalController {
    @Autowired
    SignalService signalService;
    @Autowired
    CassandraService cassandraService;

    @GetMapping("/signal")
    Signal getSignal() {
        return signalService.fetchSignal();
    }

    @PostMapping("/signal")
    Signal sendSignal(@RequestBody SendSignal sendSignal) {
        Signal signal = cassandraService.select(sendSignal.getId()).getSignal();
        return signalService.sendSignal(signal);
    }

    @PostMapping("/signal/{name}")
    SignalTable  postSignal(@RequestBody Signal signal, @PathVariable String name) {return cassandraService.insert(signal, name);}

    @GetMapping("/all-signal")
    List<SignalTable> getAllSignal() { return cassandraService.selectAll();}

    @DeleteMapping("/signal/{id}")
    SimpleResponse deleteSignal(@PathVariable String id) throws NotFoundSignal {
        System.out.println(id);
        cassandraService.delete(id);
        return new SimpleResponse("削除成功");
    }
}

フロントエンド

akapo001/local-remo-react
React.jsで上記のAPIを呼び出すような機能を実装
フロントエンドは簡素な作りなのでソース等の詳細は割愛。

画面一覧

Home

画面表示時に信号一覧取得APIを呼び出してリストで表示する。

  • リストには実行ボタンと削除ボタンがありそれぞれ信号実行APIと信号削除APIを呼び出す。
  • 登録ボタンをクリックすると下記のCreate画面に遷移する
  • 信号一覧のリストはStateで管理して削除ボタンがクリックされた際にはサーバにリクエストを送ったあとにStateからも削除する。 スクリーンショット 2021-03-08 11.55.03.png

Create

  • 信号取得と登録ボタンからそれぞれ信号取得APIと信号保存APIを呼び出す。
  • 登録ボタンクリック後に保存が成功すればHome画面に遷移する。 スクリーンショット 2021-03-08 11.51.17.png

iPhoneからのアクセス

Spring側の起動IPとReact側のアクセスIPをローカルIPにしてあげればiPhoneからでもアクセスが可能です。
IMG_6098.PNG
IMG_6099.PNG

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Next.js+TailwindCSS製 個人Blog構築の流れ -後編-

※本記事は、私が作成した こちらのブログ ⑤〜⑧ の内容をまとめて上げたものです。
前回投稿した ①〜④ はこちら
語弊を恐れずなるべく平易に書こうとしておりますので、細かなニュアンスの違い等はご容赦頂ければと思います。誤植や記述内容の間違い等ございましたら遠慮なくコメント頂ければ幸いです。

|ローダーのセットアップ

まずは、3つのライブラリをインストールしていきます。

ターミナルを開き、アプリのディレクトリに移動し、以下のコマンドを入力してください。

npm install react-markdown gray-matter raw-loader

続いて、row-loader でマークダウンファイルを読み込むための Next.js の設定ファイルを作成していきます。
package.json がある階層と同じ場所にnext.config.jsファイルを作成してください。
next.config.jsですよ!nextconfig.js ではないですからね... このドット(.)が無いだけで私は2時間も時間を溶かしてしまいました(T.T)

ファイル作成ができたら、以下のように記述してください。

// next.config.js
module.exports = {
  target: 'serverless',
  webpack: function (config) {
    config.module.rules.push({
      test: /\.md$/,
      use: 'raw-loader',
    })
    return config
  },
}

次に、読み込み用のマークダウンファイルを作成していきます。

public などと同じトップの階層にpostsディレクトリを作成し、その中にmypost.mdファイルを作成し、以下のように記述してください。
改行用に各行の末尾に半角スペース2個含まれたりしていますのでコピペしてしまってください。

---
title: 'ブログのタイトル'
author: 'ブログの筆者'
---
↓ここからマークダウンのボディ↓  
色んなマークダウンの書き方を試してみて、  
`どのように表示されるか`確認してみましょう!
- リスト1
- リスト2
- リスト3

最後にこのマークダウンファイルを読み取る部分の作成をしていきましょう!
[post].jsを編集しましょう!少し長いですが以下のように書き換えてください。

// [post].js
import matter from 'gray-matter'
import ReactMarkdown from 'react-markdown'

export default function BlogPost({ frontmatter, markdownBody }) {
  if (!frontmatter) return <></>

  return (
    <article>
      <h1>{frontmatter.title}</h1>
      <p>By {frontmatter.author}</p>
      <div>
        <ReactMarkdown source={markdownBody} />
      </div>
    </article>
  )
}

export async function getStaticProps({ ...ctx }) {
  const { post } = ctx.params

  const content = await import(`../../posts/${post}.md`)
  const data = matter(content.default)

  return {
    props: {
      frontmatter: data.data,
      markdownBody: data.content,
    },
  }
}

export async function getStaticPaths() {
  const blogSlugs = ((context) => {

    const keys = context.keys()
    const data = keys.map((key, index) => {
      let slug = key.replace(/^.*[\\\/]/, '').slice(0, -3)

      return slug
    })
    return data
  })(require.context('../../posts', true, /\.md$/))

  const paths = blogSlugs.map((slug) => `/post/${slug}`)

  return {
    paths,
    fallback: false,
  }
}

各種ファイルを作成したあと、開発サーバーを立ち上げている場合は再起動してください。
立ち上げていない場合はターミナルにてnpm run devをしてください。
http://localhost:3000/post/mypostにアクセスしてみると以下のような画面がみれましたでしょうか?
mypost.png
いくつかに分けて解説をしていきます!

● getStaticProps()

まずは、BlogPostファンクションの下にあるgetStaticProps()からですが、前回でもこのファンクションの説明はしてありますが、もう一度コンパクトにお伝えするとデータをビルド時にページコンポーネントに引き渡すファンクションです。
今回のものでいうとマークダウンファイルのデータを取得・解析し、[post].js のページに引き渡す役割を担っているという感じでしょうか。

今回返却しているデータはfrontmattermarkdownBodyの2つです。
frontmatter には何が入っているのかというと、mypost.md ファイルの冒頭で記述した

---
title: 'ブログのタイトル'
author: 'ブログの筆者'
---

の部分(FrontMatter)をgray-matterというライブラリを用いて解析し、以下のようなJSONデータとして返却しています。

{title: 'ブログのタイトル', author: 'ブログの筆者'}

また、markdownBody の中には、マークダウンファイルで記述した FromtMatter より下のテキストがcontentというキーでJSONデータの中にそのまま格納されています。
これについては後述しますが、変換作業が必要です。

● ReactMarkdown

BlogPost ファンクションの中に<ReactMarkdown source={markdownBody} />という記述があるかと思います。
こちらはreact-markdownというライブラリを使って、マークダウンで記述されているテキストをHTMLに変換している部分です。
source={markdownBody}という形で引き渡してあげても構いませんし、<ReactMarkdown># Hello, *world*!</ReactMarkdown>という形で挟んであげても大丈夫です!
次回以降の記事でスタイルを整えていきますが、マークダウンで書かれたテキストが、どのようなHTMLに変換されているかについては色々と試して確認してみてくださいね @@/

● getStaticPaths()

最後に getStaticPaths() について説明して、この記事を終えたいと思います。
getStaticPaths()とは、getStaticProps() と同様に Next.js が用意しているファンクションです。どのようなことができるのかというのをザックリ言ってしまうと、「ビルド時に特定のデータに基づいて動的ルートを静的に生成する」という感じかなと思います。@@;
ブログでいうと記事が複数ある場合、その記事の全てのルートは確保しておきたいですよね。
その記事のルートを確保するために、今回でいうと、マークダウンファイルの名称をルートとして作成しよう!としているわけです。
つまるところ、pages ディレクトリに post01.mdファイルとpost02.mdファイルが格納されている場合、/post/post01post/post02のアクセスに関しては記事の表示をするが、それ以外は404ページ(return 内の fallback: false が該当箇所です!)を表示するというコントロールをおこなっているという感じです!

いつものごとく、詳しくはNext公式getStaticPaths
をご確認ください。
機能面はこれで概ね完成ですね!超絶に簡易ですが、ブログシステムができました〜 ^^v

|表示コンテンツの整理

ブログシステムの構築も終盤となってまいりました!もうしばらくお付き合いください。
それではやっていきましょう!まずは、トップページに表示するコンテンツを考えていきます。
トップページでは、ブログ記事の一覧と、記事へのリンクを設置していきたいと思います!
components/PostList.jsを開き、以下のように記述してください。

// PostList.js
import Link from 'next/link'

export default function PostList({ posts }) {
  if (posts === 'undefined') return null

  return (
    <div>
      {!posts && <div>No posts!</div>}
      <ul>
        {posts &&
          posts.map((post) => {
            return (
              <div key={post.slug} className="container mx-auto">
                <Link href={{ pathname: `/post/${post.slug}` }}>
                  <div className="text-2xl mt-20 hover:underline hover:text-blue-800">
                    {post.frontmatter.title}
                  </div>
                </Link>
                <div className="flex items-center">
                  {post.frontmatter.author}
                </div>
                <div className="mt-8 mb-10 text-justify">
                  {post.frontmatter.excerpt}
                </div>
                <Link href={{ pathname: `/post/${post.slug}` }}>
                  <a className="underline hover:text-blue-800">続きを読む </a>
                </Link>
              </div>
            )
          })
        }
      </ul>
    </div>
  )
}

それから、pages/index.jsを以下のように修正してください。

// index.js
import matter from 'gray-matter'
import Layout from '../components/Layout'
import PostList from '../components/PostList'

const Index = ({ title, description, posts }) => {

  return (
    <Layout pageTitle={title}>
      <div>ここがLayoutコンポーネントのChildren部分です</div>
      <div>{description}</div>
      <PostList posts={posts} />
    </Layout>
  )
}

export default Index

export async function getStaticProps() {
  const configData = await import(`../siteconfig.json`)

  const posts = ((context) => {
    const keys = context.keys()
    const values = keys.map(context)

    const data = keys.map((key, index) => {
      let slug = key.replace(/^.*[\\\/]/, '').slice(0, -3)
      const value = values[index]
      const document = matter(value.default)

      return {
        frontmatter: document.data,
        markdownBody: document.content,
        slug,
      }
    })

    return data
  })(require.context('../posts', true, /\.md$/))

  return {
    props: {
      posts,
      title: configData.default.title,
      description: configData.default.description,
    },
  }
}

また、記事の概要についてもリスト内で表示させたいのでmypost.mdファイルの FrontMatter 部分にexcerpt項目を追加してください。

// mypost.md (※この行は含めないでください!)
---
title: 'ブログのタイトル'
author: 'ブログの筆者'
excerpt: 'ブログ記事の概要をここに記述ブログ記事の概要をここに記述ブログ記事の概要をここに記述ブログ記事の概要をここに記述'
---
↓ここからマークダウンのボディ↓  
色んなマークダウンの書き方を試してみて、  
`どのように表示されるか`確認してみましょう!
- リスト1
- リスト2
- リスト3

この状態で画面を確認してみると以下のようになっているかと思います。
少し不恰好ですが、後でレイアウト修正しますので、もうしばらく我慢してください。

post_list.png

現在、記事の件数(マークダウンファイルの枚数)は1件しかありません。この状態では複数ある場合の画面を確認できないので、postsディレクトリに別のマークダウンファイルを作成してみましょう!
another_post.mdファイルを作成し、中身を記述してください。

---
title: 'ブログのタイトル②'
author: 'ブログの筆者②'
excerpt: 'ブログ記事の概要をここに記述②ブログ記事の概要をここに記述②ブログ記事の概要をここに記述②ブログ記事の概要をここに記述②'
---
ブログ記事の中身です。ブログ記事の中身です。ブログ記事の中身です。ブログ記事の中身です。ブログ記事の中身です。ブログ記事の中身です。

保存後画面の確認をしてください!無事2つ目の記事がリストアップされましたでしょうか^^?
ちなみに、Footerコンポーネントが意図した部分にないですよね ^^;
Lyaout.jsで{children}の div にh-20が当たっているのが原因なのですが、レイアウトの修正自体は後でおこなっていくので、心配しないでください!
気になってムズムズが止まらない方は、<div className="bg-yellow-300 h-20">{children}</div>h-20を削除しておいてください!

次に、ブログ記事詳細ページも修正をしていきましょう。[post].jsファイルを開き、下記のようにしてください。

// [post].js
import Link from 'next/link'
import matter from 'gray-matter'
import ReactMarkdown from 'react-markdown'
import Layout from '../../components/Layout'

export default function BlogPost({ siteTitle, frontmatter, markdownBody }) {
  if (!frontmatter) return <></>

  return (
    <Layout pageTitle={`${siteTitle} | ${frontmatter.title}`}>
        <Link href="/">
          <a className="underline"> トップページに戻る</a>
        </Link>
        <article>
          <h1>{frontmatter.title}</h1>
          <p>By {frontmatter.author}</p>
          <div>
            <ReactMarkdown source={markdownBody} />
          </div>
        </article>
    </Layout>
  )
}

export async function getStaticProps({ ...ctx }) {
  const { post } = ctx.params

  const config = await import(`../../siteconfig.json`)
  const content = await import(`../../posts/${post}.md`)
  const data = matter(content.default)

  return {
    props: {
      siteTitle: config.title,
      frontmatter: data.data,
      markdownBody: data.content,
    },
  }
}

export async function getStaticPaths() {
  const blogSlugs = ((context) => {

    const keys = context.keys()
    const data = keys.map((key, index) => {
      let slug = key.replace(/^.*[\\\/]/, '').slice(0, -3)

      return slug
    })
    return data
  })(require.context('../../posts', true, /\.md$/))

  const paths = blogSlugs.map((slug) => `/post/${slug}`)

  return {
    paths,
    fallback: false,
  }
}

大きな変更点は、レイアウトコンポーネントの反映と、トップページに戻るためのリンクの設置です。この時点で、URLに値を入力することなく、画面上で全ページへのルーティングが通ったかと思います。一度存分にルーティングを体感してみてください〜

●デザイン修正

表示コンテンツの整理(文言等は未修正だが...)はできましたので、次に、デザインの修正に取り掛かっていきましょう!
まずは、Header から修正します。Header.jsを開き、以下のように修正してください。

// Header.js
import Link from 'next/link'
export default function Header() {
  return (
    <header className="bg-black text-white sticky top-0">
      <nav className="mb-20 flex items-center h-20">
        <Link href="/">
          <a className="pl-8 md:pl-20 lg:pl-40 xl:pl-64 2xl:pl-80">My Blog</a>
        </Link>
        <Link href="/about">
          <a className="pl-20">About</a>
        </Link>
      </nav>
    </header>
  )
}

続いて、Footer の修正をしましょう!Footer.jsを開き、以下のように修正してください。

// Footer.js
import Link from 'next/link'
export default function Footer() {
  return (
    <footer className="text-white text-xs">
      <div className="bg-gray-900 flex-col text-center cursor-pointer">
        <Link href="/">
          <div className="h-20 flex justify-center items-center">
            Top Page
          </div>
        </Link>
        <Link href="/about">
          <div className="h-20 flex justify-center items-center border-t border-gray-500">
            About Page
          </div>
        </Link>
      </div>
      <div className="bg-black h-10 flex justify-center items-center">
        &copy;Daisuke All Rights Reserved.
      </div>
    </footer>
  )
}

続いてLayout.jsの修正を行います!以下のように変更してください。

// Layout.js
import Head from 'next/head'
import Header from '../components/Header'
import Footer from '../components/Footer'

export default function Layout({ children, pageTitle }) {
  return (
    <>
      <Head>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>{pageTitle}</title>
      </Head>
      <section>
        <Header />
        <div className="m-8 md:mx-14 lg:mx-40 xl:mx-64 2xl:mx-80 mb-20">
          {children}
        </div>
      </section>
      <Footer />
    </>
  )
}

非常にシンプルなデザインとなっておりますが、Header と Footer、Layout の修正が終わり、
デザイン面は整ってきたのではないでしょうか ^^/

after_design.png

最後に、記事詳細のデザインを少しだけ整えましょう!
[post].jsの BlogPostファンクションを以下のように修正してください。

// [post].js の BlogPost ファンクションを修正
export default function BlogPost({ siteTitle, frontmatter, markdownBody }) {
  if (!frontmatter) return <></>

  return (
    <Layout pageTitle={`${siteTitle} | ${frontmatter.title}`}>
      <Link href="/">
        <a className="underline"> トップページに戻る</a>
      </Link>
      <article className="mt-10">
        <h1 className="text-2xl mb-4">{frontmatter.title}</h1>
        <p className="mb-6">By {frontmatter.author}</p>
        <div>
          <ReactMarkdown source={markdownBody} />
        </div>
      </article>
    </Layout>
  )
}

post_page.png
お気づきかと思いますが、マークダウンで書かれた部分... デザインが無いですよね^^;

|記事へのCSS反映

それでは、記事の部分へのCSSを反映させていきましょう!

まずは[post].jsファイルの修正からやっていきます。
以下のように<ReactMarkdown>を囲っている div タグにmarkdownクラスを付与してください。

// [post].js の BlogPostファンクション return 内
<div className="markdown">
  <ReactMarkdown source={markdownBody} />
</div>

続いて、globals.cssファイルを以下のように修正してください。

/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Markdown Styles */
/* Global */
.markdown {
    @apply leading-relaxed text-sm;
}
@screen sm {
  .markdown {
    @apply text-base;
  }
}
@screen lg {
  .markdown {
    @apply text-lg;
  }
}

/* Headers */
.markdown h1,
.markdown h2 {
  @apply text-xl my-6;
}
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
  @apply text-lg my-3 ;
}
@screen sm {
  .markdown h1,
  .markdown h2 {
    @apply text-2xl;
  }
  .markdown h3,
  .markdown h4,
  .markdown h5,
  .markdown h6 {
    @apply text-xl;
  }
}

/* Links */
.markdown a {
  @apply text-blue-600;
}
.markdown a:hover {
  @apply underline;
}

/* Paragraph */
.markdown p {
  @apply mb-4 leading-8 md:leading-10 text-justify;
}

/* Lists */
.markdown ul,
.markdown ol {
  @apply mb-4 ml-8;
}
.markdown li > p,
.markdown li > ul,
.markdown li > ol {
  @apply mb-0;
}
.markdown ol {
  @apply list-decimal;
}
.markdown ul {
  @apply list-disc;
}

/* Blockquotes */
.markdown blockquote {
  @apply p-2 mx-2 my-2 bg-gray-100 mb-4 border-l-4 border-gray-400  rounded-r-lg;
}
.markdown blockquote > p {
  @apply mb-0;
}

/* Images */
.markdown img {
  @apply shadow-lg;
}

/* Code */
.markdown :not(pre) > code {
  @apply bg-indigo-50 p-1 font-semibold text-gray-600 rounded-lg ;
}

/* Pre */
.markdown pre {
  @apply mx-2;
}

全てのマークダウン記法に対応できている訳では無く、最低限これくらいあればいいかな〜という範囲を記述しています!
もし、ご自身で不足しているものがある場合は、適宜追加してください。(コードブロックのシンタックスハイライトはこの後対応します。)
では、一度画面を確認してみましょう!色々なパターンのマークダウン記法で書いて確認してみてください!
markdown_design.png
反映されてそうですね ^^b

では、最後にコードブロックのシンタックスハイライトを対応していきましょう!
今回は、react-syntax-highlighterというライブラリを使いたいと思います。

まずは、ライブラリのインストールからしていきましょう!
ターミナルでアプリのトップの階層に移動して以下のコマンドを入力してください。

npm install react-syntax-highlighter --save

インストールが成功したら、次にコードブロック用のコンポーネントを作っていきます!
components ディレクトリにCodeBlock.jsファイルを作成し、以下のように記述してください。

// CodeBlock.js
import React from "react"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { base16AteliersulphurpoolLight } from "react-syntax-highlighter/dist/cjs/styles/prism"

const CodeBlock = ({ language, value }) => {
  return (
    <SyntaxHighlighter language={language} style={base16AteliersulphurpoolLight}>
      {value}
    </SyntaxHighlighter>
  )
}
export default CodeBlock

このコンポーネントを<ReactMarkdown>内で読み込んであげると、コードブロックにシンタックスハイライトが反映されます。
ということで、[post].jsを以下のように修正してください!

import Link from 'next/link'
import matter from 'gray-matter'
import ReactMarkdown from 'react-markdown'
import Layout from '../../components/Layout'
import CodeBlock from "../../components/CodeBlock" //←追記

export default function BlogPost({ siteTitle, frontmatter, markdownBody }) {
  if (!frontmatter) return <></>

  return (
    <Layout pageTitle={`${siteTitle} | ${frontmatter.title}`}>
        <Link href="/">
          <a className="underline"> トップページに戻る</a>
        </Link>
        <article className="mt-10">
          <h1 className="text-2xl mb-4">{frontmatter.title}</h1>
          <p className="mb-6">By {frontmatter.author}</p>
          <div className="markdown">
            <ReactMarkdown
              source={markdownBody}
              renderers={{ code: CodeBlock }} //←追記
            />
          </div>
        </article>
    </Layout>
  )
}
// 以下 getStaticProps(), getStaticPaths() が続きます

この状態で、マークダウンファイルにコードブロックを記述してみてください!
PHPや JavaScript といった言語を指定したい場合は、 ``` javascript という感じでコードブロックの開始バッククオートの横に書いてあげると読み込むことができます。反映されているか画面を確認してみましょう!

code_block.png
無事反映していることが確認できました〜 ^^v
ということで、以上で完成です!!
細かな部分のデザインの修正やアレンジの続きは読者の皆様に委ねます!

|Netlify へのデプロイ

とうとうこのシリーズも最終回となりました。
せっかくMyブログを作ったのだから、公開するまでしないと!ですよね ^^
公開する場所はNetlifyというホスティングサービスを利用したいと思います!
一緒にデプロイしようという方は、Netlifyのアカウントの作成をしておいてください。
アカウント自体は後ほどGitHub等のソース管理サービスのアカウントと連携をしますので、そちらで登録をしてください。

  • Netlify - 簡単なことであれば無料で利用することが可能です!

アカウントの登録ができたら、デプロイの準備をしましょう!
まず、next.config.jsと同階層にnetlify.tomlというファイルを作成し、以下のように記述してください。

[build]
  command = "npm run build && npm run export"
  publish = "out"

先ほど記述したコマンドをpackage.jsonファイルに追記します!以下のように修正してください。

// package.json  scripts部分
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",   //←カンマ忘れずに
    "export": "next export"  //←追記
  },

ここまでできたら、ソースコードをGitHubにプッシュしましょう!
作成した GitHub のリポジトリと紐付けて、マークダウン記事のPUSH時に自動でビルドが走るようにします!
プッシュができましたら、Netlifyの管理画面に進みNew site from Gitというボタンを押してください。
create_new.png
GitHubを選択し、先ほどソースコードをプッシュしたリポジトリを選択します。
そうすると、最終的にビルド時の設定ができる画面に移動しますので、Build command の部分をnpm run build && npm run exportに変更、Publish directory の部分をoutに変更してDeploy siteボタンを押してください!
netlify_setting.png
すると次の画面に遷移し、ビルドが走ります!
build.png
ビルド時のログも見ることができます!ログを確認し、最終的に以下のように書かれていれば完了です!
finish.png
ページのトップに戻って Preview Deploy でデプロイできているか確認しましょう!
preview.png
ブログのトップページが確認できましたら完了となります ^^v

お疲れ様でした!

まとめ

Next.js & TailwindCSS を使ってのブログ構築の解説は以上となります!
いかがでしたでしょうか^^ ?
細かく解説したい部分もいくつかあったのですが、あまり冗長になってしまうと、かえってわかりにくくなることも懸念されましたので、飛ばすところはサラっと飛ばしてあります。
記述内容に間違いがあったり、分かりにくいところなどございましたら、Twitter の方に連絡いただければと思います!

ご拝読ありがとうございました〜
それではまたお会いしましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React+springbootお試し実装参考サイトリンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る

React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る方法を紹介します。

完成するアプリのデモは以下です。
左右の画面に異なるユーザーでログインし、チャットを行っています。
ezgif.com-gif-maker.gif

本記事で作成するアプリのアーキテクチャーは下記です。
Amplify Console Static Web Site Hostingでフロンドエンドのコードをホスティングします。
AWS AppsyncでGraphQL APIを提供し、データベースはDynamoDBを使用します。
Amazon Cognitoをユーザー認証に用いています。

image.png

バージョン

使用した環境は以下の通りです。

$ npx create-react-app --version
4.0.3
$ node -v
v14.16.0
$ npm -v
6.14.11
$ amplify -v
4.44.2

Amplify CLIが未インストールの場合は、公式ドキュメントを参考にインストールします。

アプリの雛形作成

create-react-appでアプリの雛形を作ります。

$ npx create-react-app chat --template typescript

yarn startでサンプルアプリが起動すれば成功です。

$ cd chat
$ yarn start

image.png

続いて、amplify initでプロジェクトにAmplify用の設定を追加します。

$ amplify init
? Enter a name for the project chat
? Enter a name for the environment production
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default ← amplify configureで指定したプロファイル名を指定

アプリの雛形作成は以上で完了です。

認証機能の実装

認証機能を実装していきます。

バックエンド/インフラ

amplify add authで認証機能を追加します。

$ amplify add auth
? Do you want to use the default authentication and security configuration? Default configuration
? Warning: you will not be able to edit these selections.
? How do you want users to be able to sign in? Username
? Do you want to configure advanced settings? No, I am done.

続いて、amplify pushでクラウドへ変更を反映します。

$ amplify push
? Are you sure you want to continue? Yes

バックエンド/インフラの実装は以上で完了です。

フロントエンド

パッケージインストール

まずはAmplify関連のパッケージをインストール。

$ yarn add aws-amplify @aws-amplify/ui-react

続いて、Material-UIをインストール。

$ yarn add @material-ui/core @material-ui/icons

最後に、Recoilをインストール。

$ yarn add recoil

コンポーネントの実装

App.tsxを下記のように書き換えます。
ログイン画面は@aws-amplify/ui-reactのコンポーネントを用いて作成しています。
ログアウトはhandleClick内でaws-amplifyAuth.signOut()することで実現しています。
また、後述するRecoilのatomにログインユーザー名を格納しています。

App.tsx
import React, { useState } from "react";
import Amplify, { Auth } from "aws-amplify";
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react";
import {
  AuthState,
  onAuthUIStateChange,
  CognitoUserInterface,
} from "@aws-amplify/ui-components";
import awsconfig from "./aws-exports";
import { RecoilRoot } from "recoil";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";

Amplify.configure(awsconfig);

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
    },
    toolBar: {
      display: "flex",
    },
    signOut: {
      marginLeft: "auto",
      display: "flex",
    },
  })
);

const App = () => {
  const classes = useStyles();
  const [authState, setAuthState] = useState<AuthState>();
  const [user, setUser] = useState<CognitoUserInterface | undefined>();

  React.useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState as AuthState);
      setUser(authData as CognitoUserInterface);
    });
  }, []);

  const handleClick = () => {
    Auth.signOut();
  };

  return authState === AuthState.SignedIn && user ? (
    <div>
      <RecoilRoot>
        <AppBar className={classes.appBar}>
          <Toolbar className={classes.toolBar}>
            <Typography variant="h6" noWrap>
              ChatApp
            </Typography>
            <div onClick={handleClick} className={classes.signOut}>
              <IconButton
                aria-label="display more actions"
                edge="end"
                color="inherit"
              >
                <ExitToAppIcon />
              </IconButton>
            </div>
          </Toolbar>
        </AppBar>
      </RecoilRoot>
    </div>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "password" },
          { type: "email" },
        ]}
      />
    </AmplifyAuthenticator>
  );
};

export default App;

Recoilの実装

src/recoil/ChatState.tsxを作成し、下記のように書きます。
1件の投稿を意味するpostStateと投稿リストを意味するpostListStateを作成します。
また、投稿のメッセージだけをget/setするためにmessageStateを作成しています。

ChatState.tsx
import { atom, selector, DefaultValue, RecoilState } from "recoil";
import produce from "immer";

export interface PostState {
  id: string;
  message: string;
  owner: string;
  user: string;
  createdAt: string;
}

const defaultValue: PostState = {
  id: "",
  message: "",
  owner: "",
  user: "",
  createdAt: "",
};

const atomKeyName: string = "postState";

export const postState = atom({
  key: atomKeyName,
  default: defaultValue,
});

export const messageState: RecoilState<string> = (() => {
  const propName: keyof PostState = "message";
  return selector<string>({
    key: atomKeyName + "/" + propName,
    get: ({ get }) => {
      return get(postState)[propName];
    },
    set: ({ set, get }, newValue) => {
      const tempValue: string =
        newValue instanceof DefaultValue ? defaultValue[propName] : newValue;
      const imValue = produce<PostState>(get(postState), (draft) => {
        draft[propName] = tempValue;
      });
      set(postState, imValue);
    },
  });
})();

const postListDefaultValue: PostState[] = [];

export const postListState = atom({
  key: "postListState",
  default: postListDefaultValue,
});

以上で認証機能の実装は完了です。
下記の手順で動作確認してみましょう。

  1. yarn start
  2. ブラウザで http://localhost:3000 にアクセスする
  3. Create acccountをクリックする
  4. Username、Password、Emailを入力し、CREATE ACCOUNTをクリックする
  5. 入力したメールアドレスに送付されたConfirmation Codeを入力し、CONFIRMをクリックする
  6. Username、Passwordを入力しログインする
  7. ヘッダーにChatAppと表示されればログイン成功です image.png

チャットの実装

続いて、チャットの実装をしていきます。

バックエンド/インフラ

GraphQL APIの作成

amplify add apiでGraphQL APIを作成します。

$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: chat
? Choose the default authorization type for the API Amazon Cognito User Pool
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? No

GraphQLのスキーマを編集

amplify/backend/api/chat/build/schema.graphqlに生成されたスキーマを編集します。
すべての投稿を作成日順に取得するために@keyを使用しています。

type Post
  @model
  @key(
    name: "SortByCreatedAt"
    fields: ["owner", "createdAt"]
    queryField: "listPostsSortedByCreatedAt"
  ) {
  id: ID!
  message: String!
  owner: String
  user: String
  createdAt: AWSDateTime
}

@model@keyの説明は公式ドキュメントをご確認ください。
https://docs.amplify.aws/cli/graphql-transformer/model
https://docs.amplify.aws/cli/graphql-transformer/key

GraphQL APIのデプロイ

amplify pushでクラウドにGraphQL APIをデプロイします。

$ amplify push
? Are you sure you want to continue? Yes
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/API.ts
GraphQL endpoint: https://xxxxxxxxx.appsync-api.us-east-1.amazonaws.com/graphql

最後の行のURLがApp Syncが提供するGraphQL APIのURLになります。
このURLは自動生成されるsrc/aws-exports.jsというファイルに自動で書き込まれています。

フロントエンド

コンポーネントの実装

チャット機能をもつContentコンポーネントを実装します。
src/Content.tsxを作成し、下記コードのように書きます。
登録ボタン押下時にGraphQLのmutationsによりデータをDynamoDBに登録しています。
また、Contentコンポーネントの初回呼び出し時にGraphQLのqueriesにより投稿一覧を取得しています。全投稿を作成日時順に取得するため、ownerchatという固定の値を入れています。
さらに、Contentコンポーネントの初回呼び出し時にGraphQLのsubscriptionsを呼び出すことで、新規投稿をsubscribeしています。自分自身の投稿の場合もsubscribeしているので、自分自身の投稿の場合はsetPostしないようにする必要があります。

Content.tsx
import React, { useEffect } from "react";
import { useRecoilState } from "recoil";
import { postListState, messageState, PostState } from "./recoil/ChatState";
import { API, graphqlOperation } from "aws-amplify";
import { GraphQLResult } from "@aws-amplify/api";
import { listPostsSortedByCreatedAt } from "./graphql/queries";
import { createPost } from "./graphql/mutations";
import { onCreatePost } from "./graphql/subscriptions";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import Chip from "@material-ui/core/Chip";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import Container from "@material-ui/core/Container";
import { CreatePostMutation, ListPostsSortedByCreatedAtQuery } from "./API";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      paddingTop: theme.spacing(10),
      paddingBottom: theme.spacing(10),
      backgroundColor: "white",
    },
    input: {
      display: "flex",
    },
    myMessage: {
      display: "flex",
      justifyContent: "flex-start",
    },
    otherMessage: {
      display: "flex",
      justifyContent: "flex-end",
    },
  })
);

interface ContentProps {
  userName?: string;
}

const Content = (props: ContentProps) => {
  const classes = useStyles();
  const [posts, setPosts] = useRecoilState(postListState);
  const [message, setMessage] = useRecoilState(messageState);

  const handleClick = () => {
    postPost();
  };

  const postPost = async () => {
    const post = (await API.graphql(
      graphqlOperation(createPost, {
        input: { message: message, owner: "chat", user: props.userName },
      })
    )) as GraphQLResult<CreatePostMutation>;
    const postData = post.data?.createPost as PostState;
    setPosts([...posts, postData]);
    setMessage("");
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(event.target.value);
  };

  useEffect(() => {
    async function getPosts() {
      const res = (await API.graphql(
        graphqlOperation(listPostsSortedByCreatedAt, { owner: "chat" })
      )) as GraphQLResult<ListPostsSortedByCreatedAtQuery>;
      const postData = res?.data?.listPostsSortedByCreatedAt
        ?.items as PostState[];
      setPosts(postData);
    }
    getPosts();
  }, [setPosts]);

  useEffect(() => {
    // @ts-ignore
    const subscription = API.graphql(graphqlOperation(onCreatePost)).subscribe({
      next: (eventData: any) => {
        const post = eventData.value.data.onCreatePost;
        if (post !== undefined && post.user !== props.userName) {
          setPosts([...posts, post]);
        }
      },
    });
    return () => subscription.unsubscribe();
  }, [posts]);

  const postList: JSX.Element[] = [];

  for (const post of posts) {
    if (post.user === props.userName) {
      postList.push(
        <ListItem key={post.id} className={classes.myMessage}>
          <Chip label={post.message}></Chip>
        </ListItem>
      );
    } else {
      postList.push(
        <ListItem key={post.id} className={classes.otherMessage}>
          <Chip label={post.message}></Chip>
        </ListItem>
      );
    }
  }

  return (
    <Container maxWidth="lg" className={classes.container}>
      <div className={classes.input}>
        <TextField value={message} onChange={handleChange} />
        <Button variant="contained" color="secondary" onClick={handleClick}>
          登録する
        </Button>
      </div>
      <List>{postList}</List>
    </Container>
  );
};

export default Content;

作成したContentコンポーネントをAppコンポーネントから呼び出します。

App.tsx
import React, { useState } from "react";
import Amplify, { Auth } from "aws-amplify";
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react";
import {
  AuthState,
  onAuthUIStateChange,
  CognitoUserInterface,
} from "@aws-amplify/ui-components";
import awsconfig from "./aws-exports";
import Content from "./Content";
import { RecoilRoot } from "recoil";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";

Amplify.configure(awsconfig);

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
    },
    toolBar: {
      display: "flex",
    },
    signOut: {
      marginLeft: "auto",
      display: "flex",
    },
  })
);

const App = () => {
  const classes = useStyles();
  const [authState, setAuthState] = useState<AuthState>();
  const [user, setUser] = useState<CognitoUserInterface | undefined>();

  React.useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState as AuthState);
      setUser(authData as CognitoUserInterface);
    });
  }, []);

  const handleClick = () => {
    Auth.signOut();
  };

  return authState === AuthState.SignedIn && user ? (
    <div>
      <RecoilRoot>
        <AppBar className={classes.appBar}>
          <Toolbar className={classes.toolBar}>
            <Typography variant="h6" noWrap>
              ChatApp
            </Typography>
            <div onClick={handleClick} className={classes.signOut}>
              <IconButton
                aria-label="display more actions"
                edge="end"
                color="inherit"
              >
                <ExitToAppIcon />
              </IconButton>
            </div>
          </Toolbar>
        </AppBar>
        <Content userName={user.username} />
      </RecoilRoot>
    </div>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "password" },
          { type: "email" },
        ]}
      />
    </AmplifyAuthenticator>
  );
};

export default App;

以上でチャット機能の実装は完了です。
下記手順で動作確認してみましょう。

  1. yarn start
  2. ログイン
  3. 投稿内容をテキストボックスに入力
  4. 投稿する ボタンを押す
  5. 投稿内容がリスト表示されていることを確認
  6. 別のユーザーでログインする
  7. 投稿内容をテキストボックスに入力
  8. 投稿する ボタンを押す
  9. 自分の投稿内容が左側に、別のユーザーの投稿が右側に表示されていれば成功 image.png

フロントエンドをホスティング

最後に、作成したフロントエンドのコードをAmplify Console Static Web Site Hostingでホスティングしましょう。

$ amplify hosting add
? Select the plugin module to execute Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
? Choose a type Manual deployment

$ amplify publish
? Are you sure you want to continue? Yes

コンソールに出力されたURLにアクセスできれば成功です。

以上で、本記事で作成する認証機能つきチャットアプリの作成は完了です。

最後に

作成した環境を削除するには下記を実行してください。

$ amplify delete
? Are you sure you want to continue? This CANNOT be undone. (This will delete all the environments of the project from the cloud and wipe out all the local files created by Amplify CLI) Yes

本記事で作成した認証機能つきチャットアプリの全体のソースコードは下記で公開しています。
https://github.com/shimi7o/chat

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

開設後3週間で収益10万円を得た個人開発サイトに立ち向かった話の全部を公開する

こんにちは。ぬこすけです。

みなさん、Qiitaでいいね数4000越えしたジャバ・ザ・ハットリさんの名記事をご存知でしょうか?

ジャバ・ザ・ハットリさんはエンジニアのための技術書ランキングサイト「テックブックランク」を運営していますが、私もゴリゴリライバルサイトを立ち上げたというお話をしようと思います。

立ち上げたといっても実はWebサイトを公開してから1年ほど経っているのですが、その間のサイトの収益やユーザー数などの話にも触れていきます。個人開発をしている方、あるいはこれからしようとしている方にも役立つはずなので、一読いただければ嬉しいです。

パクりサイト作りました。

こんなサイト作りました。
IT技術書おすすめ入門/参考書ランキング | ぬこぷろ
ぬこぷろの画面キャプチャ.png

簡単に言うと、ITに関連する書籍をGoogleの検索結果やQiitaの記事、Twitterから独自に点数化し、ランキング形式で紹介するサイトです。

え?似たようなサイトもどこかで見たことある?
はい、冒頭でお話したジャバ・ザ・ハットリさんのテックブックランクのパクリです。

とは言ってもさすがにマルパクリというわけではないです。
ぬこぷろは主に次の点で違います。

  • ランキング付けの根拠となる情報ソースが複数であること。
  • ターゲットユーザーがIT初心者であること。

まず、テックブックランクと違う点はランキング付けの根拠となる情報ソースが複数であることです。テックブックランクはQiitaに特化して本を点数化していますが、ぬこぷろはQiitaだけでなくGoogleの検索結果やTwitterから点数化しています。

次に違う点はターゲットユーザーがIT初心者であることです。テックブックランクはエンジニア向けのサイトですが、ぬこぷろはIT初心者(「本当にIT初心者」だけでなく「すでに特定にIT分野に精通しているけど、他の分野は初心者」も含む)をターゲットユーザーにしています。

ただし、ターゲットユーザーが違うといえどニーズの競合は起きます。IT初心者であれエンジニアであれ、技術書を探す際は「python 参考書」や「python 入門書」でググるので、そのような検索キーワードに対してぬこぷろとテックブックランクはライバルということになります。
※この記事のタイトルを「開設後3週間で収益10万円を得た個人開発サイトに立ち向かった」と表現したゆえんです。

テックブックランクのすごいところ

ぬこぷろがテックブックランクをパクったわけには相応の理由があります。
ここでみなさんに質問です。テックブックランクのすごいところは何だと思いますか?もちろん、書籍とQiitaの記事を関連づけるという発想もすごいですが、私が一番にすごいと思うところは「書籍の評価を定量化している」ところです。
いらすとや_プレゼンテーションをしている男性のイラスト.png
実は世の中の書籍を紹介しているサイトで、定量的に本を評価しているサイトはほとんどないです。試しに「python 本」とかでググってみてください。上位に表示されているサイトの多くは文章で書籍を紹介するブログサイトです。本を定量的な指標で評価しているのって、Amazonのレビューくらいではないでしょうか。

そして観点は変わりますが、Googleは「独自性」や「多様性」を好みます。「独自性」に関して言えば、次のようにGoogleはオリジナリティのあるコンテンツを評価します。

オリジナルで有用なコンテンツを持つ高品質なサイトが、より上位に表示されるようになります。
引用元:Google ウェブマスター向け公式ブログ

「多様性」に関して言えば、Googleは検索結果に多様な種類のサイトを上位表示させています。例えば「python 本」とかでググった際には、ECサイトの商品一覧ページや複数の本を書評したブログ記事などが上位に表示されています。これはひとえに「python 本」とググるユーザーと言っても「価格を比較したいユーザー」もいれば「pythonに関連する本の書評を知りたい」などの多様なユーザーが想定されるからだと考えられます。
※これは俗にいう「QDD(Query Deserves Diversity)」と呼ばれるもので、SEO関係者が推測するGoogleのアルゴリズムです。Googleの公式に断言はしていないです。

再掲しますが、世の中の書籍を紹介しているサイトで、定量的に書籍を評価しているサイトは少ないです。それゆえ「書籍を定量的に評価をしている」サイトは「独自性」もあり、「多様性」を好むGoogleの検索結果において上位に食い込むのではないかという仮説を立てました。ユーザーにとっても数値としてわかりやすい指標があった方が参考書を選ぶ際に有益なはずです。

なお、ぬこぷろ開発前の企画段階でテックブックランクは一定のキーワードで上位表示されていたので「これはイケるぞ!」と確信しました。

実際、結果はどうだったのでしょうか?
ぬこぷろはすでに一定のキーワードでは成果が出ていて、例えば「typescript 参考書」「gcp 参考書」などでググると検索結果に1ページ目に出たりします(2021年2月時点)。

アイディアが思い浮かばないならとりあえずパクれ!

多くのエンジニアはかく言うなり。「個人開発したいんだけど、アイディア思い浮かばないんだよね」と。実際、私の周りのエンジニアも同じことを言います(私もそうでした)。そんな人たちにこう言いたい。「とりあえず既存あるものをパクれ」と。実際、私の尊敬するジャバ・ザ・ハットリさんも次のように名言を残しています。

前述のテックブックランクには先行している成功事例をとことんまで研究してコピーした。コピーすれば見えてくるものがたくさんある。普段はユーザーとして使っていただけのサイトも、その実装をコピーすることで「あーアレはこういう意図でそうしていたのか!」と気付く点が多々ある。

コピーを後ろめたく思う必要は無い。法に触れるようなパクリや著作権侵害は論外だが、成功事例のコピーはどんどんやるべき。フェイスブックは世界初のSNSなんかじゃないし、YouTubeもビデオシェアリングサイトとしてはかなり後発。Googleも世界初の検索エンジンじゃないのは誰もが知っている。
俺様のセンスにまかせていいのはスティーブ・ジョブズだけ。ジョブズ以外の人はとにかく成功事例を研究しまくって模倣すべし。

引用元:開設後3週間で収益10万円を得た個人開発サイトでやったことの全部を公開する

たとえ何かをパクったとしても、それがマルパクリになることはありません。「自分だったらこうする」「こうした方が良いんじゃないか」という案が必ず出てきます。私の場合は「Qiitaだけでなく他のメディアをソースに本を点数化できないか?」「このコンテンツなら他のターゲットユーザーにスイッチしても需要があるのでは?」など思い浮かびました。

あなたが普段使っているWebサイトやスマホアプリを触ってみてください。「自分だったらこういうデザインにする」「自分だったらもっとパフォーマンス上げられる」など、どんな改善点でも構いません。改善点がいくつか思いついたら、もうそれがあなたの個人開発のアイディアです!

VS モチベーション

個人開発の最大の敵はモチベーションです。みなさんも最初は意気込んで何かを作ったものの、今は放置されているプロジェクトも多いのではないでしょうか?私も数年前Ruby on Railsでブログを投稿できるCMSを作ろうと思い立ったものの、結局モチベーションが続かず、世に出ないままお蔵入りでした。

しかし、今も開発してるぬこぷろは約1年(当時自分のスキルにない技術での開発だったので、勉強時間いれるとプラス半年ほど)、平日も土日もほぼ毎日開発しています。継続的にモチベーションを続いた理由を3点紹介します。

  1. 多目的であれ
  2. さっさと世に出す
  3. 色々な技術やプログラムを構築しておく

1. 多目的であれ

1つしか目的がないよりも、たくさん目的があった方がモチベーションが高まります。私の多目的を4つ紹介します。

①技術力の向上

いらすとや_勉強しているイラスト.png
ぬこぷろの開発を決意した当時、自分の技術スタックに不安を感じていました。というのも、業務で扱っていたプロダクトは、VueやReactの台頭で少しづつ影が薄れているjQuery、テンプレートエンジンは誰も知らないApache Velocity、プログラムとして完成されたサーバーサイドはほぼ改修することなく設定ファイルのjsonをひたすらいじるのみ、みたいな状況でした。
加えて、色々なモダンな技術にチャレンジしたいという性分もあいまり、このような状況に不満を持っていました。

現状への不安と不満から、新しい技術の取得を目的の1つに設定しました。当時の私の技術スタックにはなかったPythonやDjango, React, Docker, GCPなど、色々と勉強しながらぬこぷろの開発に取り組みました。

色々な技術に触れられる楽しさもありながら、自身の技術力が向上している実感も日々得られたことがモチベーションが続いた1つの理由です。「技術力が向上している実感」に関して言えば、業務において度々感じました。例えば、所属するチームが変わりPythonを使うことになってもすでに自分の技術スタックにあったので問題なく開発できましたし、Vueを使うことになってもReactでの前提知識があったので理解しやすかったです。

②自分ブランドの向上

いらすとや_黒毛和牛のイラスト.png
個人で開発したプロダクトは様々なシーンで自分の評価に役立ちます。例えば、もし転職活動をしている人であれば面接官に評価されますし、フリーランスの人であれば顧客への宣伝の材料にもなるでしょう。

個人でプロダクトを作るのは結構難易度高めです。私もそうですが、普段業務でエンジニアとして開発していても、ゼロから自分で全てを構築する経験はあまりないと思います。インフラの構築はインフラエンジニアが社内で別でいるでしょうし、アプリケーションも何人かで分担して開発するのがほとんどでしょう。何なら企画の部署も別にあるかもしれません。

企画やインフラなど幅広い知識が求められることに加え、問題解決も全て自分でやる必要があります。後ほど「頼れる者は自分のみ」の章でお話しますが、これがまた結構苦労します。

個人でプロダクトを作るということは、アプリケーションの企画/開発はもちろん、インフラからマーケティングまで全て自分でやる必要があります。問題解決も自分でやらなきゃいけません。このゼロから自分で作り上げた実績というのは間違いなく他人にアピールできるでしょう。

③お金欲しい

これは誰もが思います。サラリーマンとして以外の収入源に憧れ、個人開発をしています。実際儲かっているかどうかに関しては後述します。

④実験台

いらすとや_化学の実験をしている人のイラスト.png
個人で作ったサイトは自分がしたいことの実験台にもなります。ぬこぷろの場合は2つの実験台としての意味がありました。

1つ目はSEO(検索エンジン最適化)施策の実験台です。唐突ですが、私は「自称SEOおじさん」なる者でした。自称SEOおじさんとは、2021年5月にGoogleモバイル検索のランキング要因に組み込まれるCore Web VitalやGoogleが検索結果の強調化スニペットとしてサポートしている構造化データなど、やたらSEOに詳しいおじさんです。このおじさんには1つ問題があります。それは「知識だけで実際に成果を出したことがない」ということです。そのおじさんがまさしく私でした。

実際にどういう施策をうてば検索順位を上げることができるのか、サイトへの流入数を上げることができるのかなどSEO施策の実験台としてPDCAを回すことを生きがいにサイトを改善しています。

2つ目はモダンな技術を取り入れる実験台です。普段の業務だと、他の技術へのスイッチングやライブラリのメジャーアップデートはプロダクトへのインパクトが大きいため、気軽にできないことが多いでしょう。個人で開発しているプロダクトであれば自由に技術のスイッチングやライブラリのメジャーアップデートをすることができます。ぬこぷろの例で言うと、初期はフレームワーク無のReactを使っていましたが、パフォーマンスの限界を理由にNext.jsに移行しました。またReactやNext.jsなど依存ライブラリのアップデートはガンガンしており、常に最新バージョンの最新機能を使っています。

このようにぬこぷろは最新技術の導入としての実験台にもしています。これも1つモチベーションになっています。

2. さっさと世に出す

いらすとや_短距離走のイラスト.png
モチベーションを維持する方法の1つ目は「多目的であること」でした。続いて2つ目は「さっさと世に出す」ことです。みなさんの中にも個人開発で「あの機能もいれなきゃ、この機能もいれなきゃ」と完璧を求めて開発を続けるうちにいつの間にかお蔵入りになったものもあるのではないでしょうか?私の場合は前述のRuby on Railsで構築したCMSのブログがまさしくそうでした。

このような経験もあり、ぬこぷろはさっさと世に出しました。最初のぬこぷろのサイトはデータベースから取ってきた生の値を、ちょっとBootstrapで味付けしてテーブルレイアウトで表示するだけのサイトでした。しょぼいサイトでも一度世に出すと「一般公開しているしもっと良いサイトにしなきゃ」というような義務感のようなものも芽生えます。また、一度公開すれば閲覧数のような数値もフィードバックとして手に入れるので、これもまたモチベーションにもなります

なお、モチベーションの観点以外にも「さっさと世に出す」メリットはあります。もし開発しているプロダクトが1日500円の収益を生み出すものであれば、公開しない日数分は機会損失です。またWebに限った話ではありますが、Googleがサイトを認識・評価するのには時間がかかるので早めにサイトを公開した方が得策です。

3. 色々な技術やプログラムを構築しておく

フロント側とバックエンド側で違う技術構成を採用することもおすすめします。これは「飽き」対策です。ぬこぷろの場合はフロントはReact/Next.js、APIはDjango、本の点数化などをするバッチプログラムはPythonという構成を取っています。そうすることでフロントの開発に飽きたらAPI, APIの開発に飽きたらバッチプログラムの開発をする、というように「飽き」対策をしています。

また、プロジェクトを複数用意するのも1つの手です。後述の副産物の章でもお話しますが、ぬこぷろ以外にもOSSを開発していたりします。ぬこぷろ自体の開発が飽きたら、OSSの方の開発を進めるということもしています。

個人開発の良いところの1つでもありますが、開発の優先度は自分で決められるので、「飽きたら違うプログラムをいじる」みたいなことができます。

甘い世界じゃない

みなさんお待ちかねの収益の話です。「開設後3週間で収益10万円いきました?」と誰もが思う質問に対しては、答えはノーです。収益立てるのはホントにムズいです。

ぬこぷろの場合は収益源としてGoogle AdSenseとAmazonアソシエイト・プログラムの2つですが、双方とも審査に合格するのに約10ヵ月かかりました。Google AdSenseは2回、Amazonアソシエイト・プログラムは5回審査に落ちています。

また、収益の元となる閲覧数やユーザー数ですが、Google検索で流入してくるようになってきたのもつい最近です。ぬこぷろのサイトを公開したのが2020年3月頃ですが、2021年1月頃にようやくデイリーで2桁のユーザーが訪問してくれるようになりました。逆に言えば、それ以前は1日数人訪問してくれるかどうかくらいのレベルでした。

▼ぬこぷろのユーザー数の推移
月単位のぬこぷろのユーザー数の推移.png

「じゃあ実際いくら稼いでるの?」と気になる人も多いと思うので差し支えない範囲でお答えしておくと、2021年2月時点でトータルで飲み会1回分くらいです。これが現実!

というわけで「収益10万円」の世界はかなり遠そうです。

自動化?効率化?んなもん後回し

「自動化」を駆使していかに運用を「効率化」することはエンジニアの習性みたいなものです。特に個人開発であれば自由に開発できるので、そうした効率化をプロダクトの開発初期に思いつき、実行したいと思うかもしれません。しかし、もしプロダクトとして成功を狙っているのであれば効率化は後回しにすべきだと思います。なぜならプロダクトを利用するユーザーはあなたの効率化なんで知ったこっちゃないからです。もしリリースを自動化したとしても、エンドユーザーにとってはそれは関係ありません。優先すべきはユーザーの目に見えるところです。

いらすとや_パソコンを見ている人のイラスト.png

ぬこぷろをリリースしてから1年ほどですが、いまだにAPIのリリースはGCEにsshしてGitコマンドやらDockerコマンドやらを打っていますし、書籍の点数化などをするPythonのバッチプログラムも私のMacBook Airで手動で実行しています。SSG(静的サイトジェネレータ)を利用しているのでバックエンドのデータを変わればフロント側もリリースする必要がありますが、「バッチプログラムの処理が完了したあとフロント側のデプロイも自動で開始」みたいな高尚なことはしていません。手動です。

もちろん、自動化はしたいですしどう自動化するかも頭の中ではすでに組み上がっているのですが、まずはある程度の規模のユーザーが利用してくれるようなプロダクトにすることが先決です。

とは言いつつも誤解のないように言っておくと、あくまで個人開発なので効率化に手をつけるのは自由です。大枠の優先度としてはユーザーの目に見えるところではありますが、先ほどのモチベーションの話でお話した通り、飽きとの戦いにもなってくるので、時には気分転換に効率化を主眼にした開発もしても良いとは思います。

圧倒的スピード感

個人開発の意思決定は早いです。「あ、こうした方が良いな」と思ったらすぐに開発できます。それが例えプロダクトへのインパクトが大きくとも、です。ぬこぷろの場合、サーバーやフレームワークの移行を1年弱でバンバンしてきました。具体的には次のようになります。

  • スタイルフレームワーク
    • Bootstrap → Material-Ui → CSS Modules → Tailwind CSS
  • APIサーバー
    • Heroku → Google Compute Engine(GCE)
  • Webフロント
    • React(クラスベース) → React(関数ベース) → Next.js
  • ホスティングサーバー
    • Netlify → Vercel

普段の業務で上記のようなスイッチングをしようとすると、関係者間の合意を得る必要ですが、個人での開発であれば「やった方が良い」と思ったらすぐに実行することができます

2021年3月現在も、APIサーバーをGKEでのKubernetesへの移行も検討しています。

頼れる者は自分のみ

チーム開発であればわからないことや実装の最適解を周りの知見のある人に聞くことができますが、個人開発だとそうはいきません。日本語でググって情報が出てくれば良いですが、時には英語でググり、さらにはGitHubのissueを読み漁ることになります。issue読みあさっても情報が出てこないのでしまいには自分でissueを起票することもありました(「リポジトリ見せなきゃ話にならん」と言われて即クローズされましたが笑)。

特に最新の技術を使うと英語のissue漁りに陥りがちです。私の場合はNext.jsがそうでした。
例えば、Next.jsではページ単位でデータを取得して静的ファイルにビルドする機能がありますが、ヘッダーやフッターなどページの雛形になる _app.js ではビルド時にデータ取得ができないという問題がありました。ぬこぷろではサイドメニューをAPIから取ってきたデータを表示したいということもあり、かなり困りました。

ぬこぷろのサイドメニュー.png

Next.jsのissueでも上がっているのですが、2021年2月時点ではまだこの問題は解決されていません。結局、当時取った回避策としてはNext.jsの設定ファイルで無理やりデータをフェッチし環境変数としてアプリケーションに注入することにしました(現在は先述のissueにもコメントがある通り、next-plugin-prevalというライブラリを使って回避しています)。

このようにわからないことは英語issueを読み漁ったり、最適解も自分で手探りで探す必要があり、個人開発は苦労します。ただ逆に言えば、「自分ブランドの向上」でもお話しましたが自分で問題を解決する能力は身につきます。

無料プランに徹したサーバー構築

テックブックランクも無料プランに徹したサーバー構築をしているそうですが、ぬこぷろも同じく無料に徹しています。ホスティングサービスで利用しているVercelは無料プランですし、バックエンドのGCEも永久無料枠です。データベースも商用利用でも無料なPostgreSQLを使っています。

個人開発において無料であることはかなり大事です。なぜなら個人開発の成果が出るのには時間がかかるからです。「甘い世界じゃない」でもお話しましたが、ぬこぷろは1年弱くらいほぼユーザーが来ない状況でした。もちろん、その間の収益はほぼゼロです。もし毎月数千円のコストがかかっていたとしたら、モチベーションも続かずにサイトも閉鎖していたでしょう。

無料でインフラを構築する方法は様々あると思いますが、せっかくなのでぬこぷろの開発で経験した無料サービスについて簡単に共有します。個人開発する方の参考になればと思います。

  • ホスティングサービス

    • Netlify
      masterブランチにプッシュするだけで自動的にデプロイできるので便利。SSL化も無料。 SPA(シングルページアプリケーション)としてぬこぷろを構築していた時は利用していましたが、Next.jsで構築し直すタイミングでVercelに移行しました。その理由は次でお話します。
    • Vercel
      Next.jsでアプリを作るならまずこれ。VercelはNext.jsをメンテナンスしている会社が運営しており、Next.jsで作られたアプリをデプロイする場合は基本設定無しでいけます。Netilifyと同様、masterブランチにプッシュすると自動的に本番環境にデプロイされることに加え、master以外のブランチでプッシュすると、本番相当の環境(いわゆるステージング環境)で自動でデプロイが走ります。このステージング環境にはブランチごとで自動でドメインが付与され、本番リリース前にサイトの状態を確認することができます。あと、SSLも無料です。
  • APIサーバー

    • Heroku
      Herokuもmasterブランチにプッシュするだけで自動デプロイできます。加えて、フレームワークによってはデータベースも無料で利用できます。ぬこぷろの場合はDjangoを使っているのですが、Django + PostgreSQL のセットで楽にデプロイできる仕組みがHerokuにありました。 このようにHerokuは便利ですが、一定の制約があるので注意が必要です。例えば、http通信で30秒で強制タイムアウトだったり、データベースも無料枠は1万レコードまで、などの制約もあります。
    • Google Compute Engine(GCE)
      Herokuは便利ですがPaaSなので一定の制約があります。もし自由にインフラを構築したいのであればIaaSであるGCEがおすすめです。永久無料枠を使ってサーバーを構築することができます。ただし、サーバーのスペックとしてはちょっと弱いので(無料なので文句は言えません!)要注意。構築についてはQiitaの「これから始めるGCP(GCE) 安全に無料枠を使い倒せ」という記事ががわかりやすかったです。

石の上にも1年

個人開発に限らずですが、バンバンアイディアを出してバンバンリリースすべし、という意見を目にします。私もこの意見に賛成ですが、少なくともWebサイトに関しては1年は運用してから次の新規開発に着手するかを判断すべきだと思います。なぜなら1年経たないと作ったWebサイトがウケるかどうかわからないからです。通常、Webサイトを公開してからユーザーがGoogle検索でサイトに定常的に流入(いわゆる自然検索流入)してくるまでに数ヶ月はかかります。ぬこぷろの場合もそうでした。

1年間のぬこぷろのGoogle検索結果での表示回数推移.png

上のグラフはぬこぷろのGoogle検索結果での表示回数の推移を表したものです。ご覧の通り、サイトを公開してから約半年はユーザーの目に触れることのない、影の存在でした。半年後に表示回数が微増したものの、10~12月はほぼ横ばいが続き、2021年1月頃にやっと増加してきました。このタイミングで「甘い世界じゃない」で少しお話しましたが、1日数十人のユーザーが訪問してきてくれるようになりました。やっとユーザーにウケるコンテンツを作れているかどうかの問えるところまで来ています。

「Webサイト公開したけど全然人来ないから次のアイディア行ってみよう!」と考えている方、ちょっと待ってください。諦めるのはまだ早いです。バズによる流入を狙っているのなら話は別ですが、世の中のサイトの流入の50%以上を占める自然検索での集客を考えているのならば1年は粘ってみてください!

副産物

個人開発をしていると思わぬ副産物が生まれることもあります。
私の場合、ぬこぷろの開発からOSSが生まれました。

autoload_module

このPythonライブラリは、指定したディレクトリ配下のPythonファイルを動的に読み込み、ファイル内で定義されたクラスや関数オブジェクトを返却するものです。これは元々、書籍の点数化などをするPythonプログラム内で実装していましたが、「この仕組みは汎用的に使えるものだし、普通に便利な機能なのでは?」と思い、ライブラリとして切り出し、Githubで公開しました。

2020年の11月ごろに公開しましたが、ありがたいことに2021年2月時点で約3,000ほどダウンロードしてもらっています。本業のぬこぷろの開発から生まれた、ちょっとしたサイドプロジェクトでしたが思わぬ副産物でした。

ぬこぷろの開発でちょっと疲れたり、飽きが到来したときはautoload_moduleの開発をしたりしています。これもまたモチベーションの話と関わってきますが、本業のぬこぷろの開発への良いアクセントになっています。

簡単に記事も書いているので良かったらご覧ください!

技術的な自慢をさせてください

エンジニアたるもの、自分が開発したプロダクトの技術的にすごいところを自慢したいもの。
私も一応エンジニアの端くれなので、ぬこぷろの技術的なポイントをいくつか共有させてください!

  • 全て静的ファイル?
    Next.jsのSSG(静的サイトジェネレータ)機能を使って、ブラウザ上で表示されるリソースは全て静的ファイルにしています。ユーザーがアクセスした時にサーバーで動的にHTMLファイル生成することもないですし、ブラウザでAPIを叩くこともありません。静的ファイル化によって、パフォーマンス最適化はもちろん、フロント側でバックエンドのAPIサーバーにアクセスすることがないので、「アクセス数に比例してGCPの料金が跳ね上がる」みたいな懸念をする必要もなくなります。

  • パフォーマンスチューニング:zap:
    ユーザーが快適にサイトを閲覧できるように、サイトのパフォーマンスも気にかけています。先述のSSGに加え、 react-windowを使った膨大なリストのレンダリング効率化、 react-lazyloadを使った遅延読み込みなど、様々な実装を施しています。
    技術的に書籍のデータを全件フロント側に返却しなくてはならない制約や、広告などのサードパーティのスクリプトの影響でLight Houseのパフォーマンススコアは芳しくはないですが、パフォーマンス向上のため可能な限りの努力をしています。
    なお、この記事を投稿してからちょっと古いですが、Reactでのパフォーマンスチューニングの記事(【2020】Reactパフォーマンスチューニング ~LightHouse Score 爆上げ物語~)も書いているので、ぜひご参考ください。

  • SEOを考慮した仮想無限スクロール?
    無限スクロールにはGoogleが推奨するお作法があります。実はこれを実装しようするとそこそこ難易度高いです。ReactでのSEOライクな無限スクロールの実装についての情報はほとんど調べても出てこないですし、ましてやreact-windowに限った話だともう皆無です(一応issueとして上がっていますがベストプラクティスはなさそう)。 react-windowの仕様上、完璧ではないですがぬこぷろはできるだけGoogleが推奨する無限スクロールで実装しています。

まだまだ未熟者

いらすとや_滝に打たれる修行僧のイラスト.png
私もぬこぷろもまだまだ未熟者です。

私自身、元々文系出身ですし、未経験エンジニアとして転職しました。情報系の大学でプログラミングをしてきた人やエンジニアから社会人スタートしている人たちと比べると、まだまだ私もエンジニアとして未熟だと思います。

そしてぬこぷろにも課題はたくさんあります。例えば、本の評価づけはまだまだチューニングが必要ですし、静的ファイルにビルドする時もサーバーへの負荷がかなり高かったり、色々課題はあります。

今後もぬこぷろの課題を一つ一つ解決していきながら、私自身も成長していきたいです。

最後に、この言葉を引用させてもらいます。

やってみなはれ

これはサントリー創業者の鳥井信治郎の言葉です。

創業者鳥井信治郎は、どんな苦境に陥ちこんでも自身とその作品についての確信を捨てず、そして、たたかれてもたたかれてもいきいきとした破天荒の才覚を発揮しつづけた人であった。 それを最も端的に伝える言葉として彼がことあるごとに口にした日本語が『やってみなはれ』である。
引用元:やってみなはれ精神が生み出したフロンティア製品

別にサントリー社員でも何でもないのですが、私はこの言葉が好きです。
失敗を恐れて行動できなかったり、何かに悩んで一歩踏み出せないことはたくさんあると思います。けれど、失敗から学ぶことも多いですし、一歩踏み出すと何か得られるものもあります。

開発でも同じことです。「このアイディアが本当にユーザーにウケるのかわからない」、「何を作れば良いかわからない」と考えて行動をストップさせているのはもったいないです。
小難しく考えず「やってみなはれ」なスタンスで、プレモル:beer:でも飲みながら気楽のコーディングしましょう!Good Luck!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む