blog.takurinton.dev

自サイトのアクセス可視化

2021-09-23

こんにちは

どうも、僕です。
1ヶ月ほど前からポートフォリオとブログのトラッキングを始めて、だいぶデータが溜まってきたのでどうしようかなと思っていました。
ちょうど、昨日(9月23日)は祝日で、数日前に研究の中間発表も終わり(学部の研究発表なんてたかが知れてるだろという声はさておき)、たまには休日っぽいことでもするかと思っていたので、ポートフォリオとブログのログを可視化するというコードを書いてみました。
昨日は1日通してちょこちょこ予定があったので、それも相まってとても良い休日でした。
まだデプロイはしてませんが、サーバサイドは EC2 に環境を準備しました。あとは pull してくるだけ。フロントエンドは vercel の予定です。

構成

簡単な構成についてです。
実際のコードはここにあります。

また、今回は主な目的は可視化ですが、もう1つ目的があって、Prisma を触ってみたかったというのがあります。ドキュメントに目を通していましたが、実際に触ったことがなかったのでこの機会に触ってみました。

API のやりとり

  • GraphQL

サーバサイド

  • express
  • Prisma
  • express-graphql

フロントエンド

  • React
  • chakra-ui
  • Vite
  • urql

この記事を書いた時点での画面

記事を書いた時点での画面はこんな感じになっています。
左側に form が、右側に一覧が出ています。この form をぽちぽちすることで、右の画面を動的に変更することができます。form の中身を変更するたびに GraphQL の query が動的に生成され、サーバサイドにリクエストが投げられます。レスポンスを見て、右側が動的に更新されます。

class="content-img

サーバサイド

まずは、サーバサイドから実装しました。
サーバサイドは、Express を使用してサーバを立ち上げ、GraphQL のエンドポイントを作成してそこでリクエストを受け取ります。Prisma という ORM を使用していて、型安全にしていますが、途中無視してしまっている箇所があるのでそこは後から修正します。

Prisma の定義

最初に Prisma の定義から始めました。
今回は、既存のテーブルを使用したため、マイグレーションは行わずにその手前の generate までを Prisma でやっています。 定義ファイルはこんな感じになっています。
domain と analytics は1対多のリレーションになっていますが、Prisma でリレーションを張ろうとするとリレーション用の scaler フィールドが必要になり、それを既存のテーブルに適用しようとすると column does not exist のエラーを吐いてしまうので今回は外部キーを Int として扱ってとりあえず対処しました。
参考( https://www.prisma.io/docs/concepts/components/prisma-schema/relations )

// schema.prisma
datasource db {
  provider = "mysql"
  url      = env("PRISMA_DB")
}

generator client {
  provider = "prisma-client-js"
}

model analytics {
  id         Int      @id @default(autoincrement())
  created_at DateTime @default(now())
  path       String
  domain     Int // とりあえずこれで行く、1が takurinton.com、2が blog.takurinton.com
  //   domain     domain   @relation(fields: [domain_id], references: [id])
  //   domain_id  Int
}

// model domain {
//   id        Int         @id @default(autoincrement())
//   name      String
//   analytics analytics[]
// }

Prisma の生成

Prisma は、定義ファイルを元に関数を生成してくれます。
そのため、型安全になるってことですね。

以下のコマンドを叩きます。

npx prisma generate

これで Prisma が使えるようになりました。
Prisma についての記事は mizchi さんがよく書いてる印象。
この記事 ではマイグレーション周りについても簡単に触れてるので参考にすると良さそうです。
ちなみに、自分も Prisma 単体の記事を書きたいと思ってるので時間を見つけて書きます。

server の実装

Express でサーバサイドを実装します。GraphQL を扱うために、 express-graphql というライブラリを使用しました。
まずは Schema を定義します。
今回使用したい Schema は、Analytics 全部と、その詳細を取得するもので、query は2種類になります。
query analytics には、domain, path, start, end が渡されます。domain は先ほど言ったように、Int 型で扱います。他は String です。全て必須ではなく、来たやつだけ処理するようにします。
query analytics_by_path_for_blog は、domain と path から、その詳細を返し、日毎のアクセス回数などもまとめてレスポンスに混ぜ込みます。

// schema.ts
import { buildSchema } from "graphql";

export const schema = buildSchema(`
type Query {
  analytics (
    domain: Int, 
    path: String, 
    start: String,
    end: String
  ): Analytics!, 
  analytics_by_path_for_blog (
    domain: Int!
    path: String!
  ): AnalyticsByPathForBlog!
}

type AnalyticsType {
  id: ID!
  created_at: String!
  path: String!
  domain: String!
}

type PathList {
  domain: String!
  path: String!
}

type Analytics {
  count: Int!
  analytics: [AnalyticsType!]
  path_list: [PathList!]
}

type DateCount {
  date: String!
  count: Int!
}


type AnalyticsByPathForBlog {
  count: Int!
  analytics: [AnalyticsType!]
  date_count: [DateCount!]
}
`);

また、それぞれのデータを取得する関数を定義します。
Prisma の syntax については Prisma Client Reference を見てください。

import { prisma } from "./prisma";
import { PORTFOLIO, BLOG } from '.';

// 全てのデータ
export const getAnalytics = async ({
  domain, 
  path,
  start,
  end, 
}: {
  domain?: number | undefined;
  path?: string;
  start?: string;
  end?: string
}) => {
  domain = domain === 0 ? undefined: domain;
  path = path === '' ? undefined: path;

  const analytics = await prisma.analytics.findMany({
    where: {
      // 何も渡さなかったら undefined なので条件分岐とかなくてもいけそう
      domain, 
      path,
    },
    orderBy: {
      created_at: 'desc',
    }
  });

  return analytics.map(analytics => {
    if (analytics.domain === 1) {
      // @ts-ignore
      analytics.domain = PORTFOLIO;
    } else if (analytics.domain === 2) {
      // @ts-ignore
      analytics.domain = BLOG;
    } else {
      // @ts-ignore
      analytics.domain = 'other';
    }
    // @ts-ignore
    analytics.created_at = `${analytics.created_at.getFullYear()}-${analytics.created_at.getMonth()+1}-${analytics.created_at.getDate()}`;
    return analytics;
  });
}

// path 一覧
export const getPathList = async () => {
  return await prisma.analytics.findMany({
    distinct: ['path'], 
    select: {
      domain: true, 
      path: true, 
    }
  });
}

// 詳細用
export const getAnalyticsByPathForBlog = async (path: string, domain: number) => {
  const analytics = await prisma.analytics.findMany({
    where: {
      domain,
      path
    }
  });

  return analytics.map(analytics => {
    if (analytics.domain === 1) {
      // @ts-ignore
      analytics.domain = PORTFOLIO;
    } else if (analytics.domain === 2) {
      // @ts-ignore
      analytics.domain = BLOG;
    } else {
      // @ts-ignore
      analytics.domain = 'other';
    }
    console.log(analytics.created_at.getDate())
    // @ts-ignore
    analytics.created_at = `${analytics.created_at.getFullYear()}-${analytics.created_at.getMonth()+1}-${analytics.created_at.getDate()}`;
    return analytics;
  });
}

// 日毎のカウント
export const getDateCount = async (path: string, domain: number) => {
  return await prisma.$queryRaw`
  SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS date, COUNT(*) AS count FROM analytics WHERE domain = ${domain} AND path = ${path} GROUP BY DATE_FORMAT(created_at, '%Y%m%d');
  `;
} 

関数定義ができたので、次はエンドポイントの定義をします。
エンドポイントは以下のようになっています。/prismaserver/graphql というエンドポイントが GraphQL エンドポイントになっていて、そこにリクエストが来るとデータを返してくれます。

// server.ts
import express from 'express';
import { graphqlHTTP } from 'express-graphql';
import bodyParser from 'body-parser';
import { getAnalytics, getDateCount } from './utils';
import { schema } from './graphql/schema';
import { getAnalyticsByPathForBlog, getPathList } from './utils/getAnalytics';

const app = express();

// json
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// cors
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'POST');
  res.header(
    'Access-Control-Allow-Headers',
    'Content-Type, Authorization, access_token'
  );

  if ('OPTIONS' === req.method) {
    res.send(200);
  } else {
    next();
  };
});

// ここで受けとる
app.use('/prismaserver/graphql', graphqlHTTP({
  schema,
  rootValue: {
    analytics: async ({ domain, path, start, end }) => {
      const analytics = await getAnalytics({ domain, path, start, end });
      const path_list = await getPathList();
      const count = (await analytics).length;
      return await {
        count,
        analytics,
        path_list
      }
    },
    analytics_by_path_for_blog: async ({ path, domain }) => {
      const analytics = await getAnalyticsByPathForBlog(path, domain);
      const date_count = await getDateCount(path, domain);
      const count = (await analytics).length;
      return await {
        count,
        analytics,
        date_count
      }
    },
  },
  graphiql: false
}));

app.listen('3001', () => console.log('listening at http://localhost:3001/prismaserver/graphql'));

express-graphql の関数の中身を見ます。
schema には、上で定義した schema を渡します。graphiql を使用したい場合は、true にすることで使うことができます。
rootValue についてですが、ここでは query 名に対して、CallBack を指定することができます。引数は、GraphQL の argments を指定することができ、それらを取得して関数に値を渡します。ここで知らない値が来たら undefined になります。また、return した値がそのままレスポンスになります。ここで形式が違うとエラーを吐きます。

app.use('/prismaserver/graphql', graphqlHTTP({
  schema,
  rootValue: {
    analytics: async ({ domain, path, start, end }) => {
      const analytics = await getAnalytics({ domain, path, start, end });
      const path_list = await getPathList();
      const count = (await analytics).length;
      return await {
        count,
        analytics,
        path_list
      }
    },
    analytics_by_path_for_blog: async ({ path, domain }) => {
      const analytics = await getAnalyticsByPathForBlog(path, domain);
      const date_count = await getDateCount(path, domain);
      const count = (await analytics).length;
      return await {
        count,
        analytics,
        date_count
      }
    },
  },
  graphiql: false
}));

こんな感じでサーバサイドはうまく動きます。
例えば、こんな感じの query を投げてあげると

analytics(domain: 2, path: "/post/77") {
    count
    analytics {
      id 
      domain 
      path
      created_at
    }
  }
}

こんな感じのレスポンスが返ってきます。

{
  "data": {
    "analytics": {
      "count": 38,
      "analytics": [
        {
          "id": "949",
          "domain": "blog.takurinton.com",
          "path": "/post/77",
          "created_at": "1632106750000"
        },
        {
          "id": "951",
          "domain": "blog.takurinton.com",
          "path": "/post/77",
          "created_at": "1632106853000"
        },
        {
          "id": "952",
          "domain": "blog.takurinton.com",
          "path": "/post/77",
          "created_at": "1632106855000"
        },
        ...
      ]
    }
  }
}

フロントエンド

次にフロントエンドの実装です。
フロントエンドでは、入力 form と、取得結果のテーブルを作成し、入力 form の変更があったらそれを Context として保持している AST を書き換えて、その AST から query を生成し、query に変更があったらサーバサイドにリクエストを投げます。

App.tsx

まずは、エントリポイントとなる App.tsx から見ていきます。
chakra-ui と urql を使用しているので、その定義と、中身では AnalyticsForm というコンポーネントを呼び出しています。react router を使用していますが、Detail というコンポーネントは未実装なので、実質 AnalyticsForm のみとなります。urql は、先ほど作成した server に対してエンドポイントを設定します。

import { ChakraProvider, Flex } from "@chakra-ui/react";
import { Provider, createClient } from 'urql';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { AnalyticsForm } from './pages/AnalyticsForm';
import { Detail } from './pages/Detail';
import { H1 } from './components/text';

const client = createClient({
  url: 'http://localhost:3001/prismaserver/graphql',
});

export const App = () => {
  return (
    <ChakraProvider>
      <Provider value={client}>
        <H1 text={'takurinton analytics'}></H1>
        <Router>
          <Route exact path='/'>
            <Flex margin={'0 auto'} width={'90vw'}>
            <AnalyticsForm />
          </Flex>
          </Route>
          <Route exact path='/detail'>
            <Detail />
          </Route>
        </Router>
      </Provider>
    </ChakraProvider>
  );
}

context

次に、状態を管理するための context についてです。
ここでは、全体の AST の管理をしていて、updateNode では AST の更新をしています。更新には graphql の visit 関数を使用しています。

import { DocumentNode, visit } from 'graphql';
import React, { useContext } from 'react';

type TransformerContextType = {
  updateNode(name: string, value: string): void;
  updatePage(page: number): void;
};

const RendererContext = React.createContext<TransformerContextType>(null as any);

export const  useTransformerContext = () => {
  return useContext(RendererContext);
}

export const TransformerContextProvider = ({
  children,
  root,
  onChangeNode,
}: {
  children: React.ReactNode;
  root: DocumentNode;
  onChangeNode: (root: DocumentNode) => void;
}) => {
  const api: TransformerContextType = {
    updateNode(name, value) {
      const newNode = visit(root, {
        Argument: arg => {
          if (arg.name.value === name) {
            return {
              ...arg, 
              value: {
                ...arg.value, 
                value,
              }
            }
          } 
        }
      })
      onChangeNode(newNode);
    },
    updatePage(page) {
      const newNode = visit(root, {
        Argument: arg => {
          if (arg.name.value === 'page') {
            return {
              ...arg, 
              value: {
                ...arg.value, 
                value: page
              }
            }
          }
        }
      });
      onChangeNode(newNode);
    }
  };
  return <RendererContext.Provider value={api}>{children}</RendererContext.Provider>;
}

AnalyticsForm

ここでは、urql を使用してリクエストを投げ、レスポンスを取得します。
また、AST と query を state として持ち、Context にセットします。変更がされたら都度反映します。

import React, { useState } from "react";
import { useQuery } from 'urql';
import { DocumentNode, parse, print } from "graphql";
import { H1 } from '../components/text';
import { Result } from "../components/Result";
import { Form } from '../components/Form';
import { TransformerContextProvider } from '../context/context';

const initialQuery = `
query getAnalytics {
  analytics(domain: 0, path: "") {
    count
    analytics {
      id 
      domain 
      path
      created_at
    }
    path_list {
      domain 
      path
    }
  }
}`;

export const AnalyticsForm = () => {
  const [query, setQuery] = useState(initialQuery)
  const [ast, setAst] = useState<DocumentNode>(parse(initialQuery));

  const [result] = useQuery({
    query: query,
  });

  // 最初だけローディング表示する、2回目以降は form がリセットされてしまうのでやらない
  if (query === initialQuery) {
    return result.fetching ? <H1 text={'loading...'}></H1>: 
    <TransformerContextProvider 
      root={ast}
      onChangeNode={ast => {
        setAst(ast);
        setQuery(print(ast));
      }}
    >
      <Form result={result} node={ast} />
      <Result result={result} ast={ast} />
    </TransformerContextProvider>
  }
 
  // Context で囲う
  return (
    <>
      <TransformerContextProvider 
        root={ast}
        onChangeNode={ast => {
          setAst(ast);
          setQuery(print(ast));
          console.log(print(ast))
        }}
      >
        {/* ここで form と table を定義する */}
        <Form result={result} node={ast} />
        <Result result={result} ast={ast} />
      </TransformerContextProvider>
    </>
  )
};

onChangeNode では、AST の変更を受け取り、GraphQL の print 関数を使用して AST から query を生成し、反映しています。
print 関数については この記事 でまとめています。

onChangeNode={ast => {
  setAst(ast);
  setQuery(print(ast));
}}

Form

ここは、入力 form を定義しています。
引数として、レスポンスと、AST を持ち、AST を展開していきます。
ここらへんの展開については、 この記事 で触れています。
このコンポーネントは再帰で呼ばれ、AST を探索していきます。AST の kind が Field まで来たら、引数をいじるために form を定義します。

import { ASTNode } from "graphql";
import { Box, FormControl, Select, FormLabel } from "@chakra-ui/react"
import { useForm } from '../hooks/useForm';
import { H2 } from '../components/text';
import { useTransformerContext } from '../context/context';

export const Form = ({ result, node }: { result?: any, node: ASTNode }) => {
  const pathList = result.data.analytics.path_list;
    
  if (node.kind === 'Document') {
    return (
      <Box>
        {node.definitions.map((def, index) => {
          return <Form key={index} node={def} result={result} />;
        })}
      </Box>
    );
  }
  
  if (node.kind === 'OperationDefinition') {
    return (
      <Box border="1px solid white" boxSizing="border-box">
        <Form node={node.selectionSet} result={result} />
      </Box>
    );
  }
  
  if (node.kind === 'SelectionSet') {
    return (
      <Box>
        {node.selections.map((def, index) => {
          return <Form key={index} node={def} result={result} />;
        })}
      </Box>
    )
  }
  
  if (node.kind === 'Field') {
    const api = useTransformerContext();
    const {
      handleChange, 
      state
    } = useForm();
  
    const onChange = (e: React.ChangeEvent<HTMLSelectElement>): void => {
      handleChange(e);
      onUpdateAST(e);
    };
  
    const onUpdateAST = (event: React.ChangeEvent<HTMLSelectElement>) => {
      api.updateNode(event.target.name, event.target.value);
    };

    return (
      <Box width={'40vw'} padding={'10px'}>
        <H2 text={'data'}></H2>
        <FormControl>
          <FormLabel>domain</FormLabel>
          <Select name={'domain'} onChange={onChange}>
            <option value={'undefined'}>all</option>
            <option value={1}>takurinton.com</option>
            <option value={2}>blog.takurinton.com</option>
          </Select>
    
          <FormLabel>path</FormLabel>
          <Select name={'path'} onChange={onChange} placeholder={'pathを入力してください'}>
            {
              state.domain === '1' ? <option value={'/'} >{'/'}</option> : 
              pathList.map((path: { path: string }) => {
                return <option key={path.path} value={path.path} >{path.path}</option>
              })
            }
          </Select>
    
          <FormLabel>start</FormLabel>
          <Select name={'start'} onChange={onChange}>
            <option value={undefined}>all</option>
          </Select>
    
          <FormLabel>end</FormLabel>
          <Select name={'start'} onChange={onChange}>
            <option value={undefined}>all</option>
          </Select>
        </FormControl>
      </Box>
    );
  };
  
  return <Box>error</Box>
}

更新する処理はここで行っています。

    const onChange = (e: React.ChangeEvent<HTMLSelectElement>): void => {
      handleChange(e);
      onUpdateAST(e);
    };
  
    const onUpdateAST = (event: React.ChangeEvent<HTMLSelectElement>) => {
      api.updateNode(event.target.name, event.target.value);
    };

ここでは、先ほど作成した context に処理を投げます。context は値を更新して全体の状態として持ちます。

Result

こちらは結果を表示するための table です。
こちらはそこまで難しいことはしていなくて、GraphQL のレスポンスを引数として受け取り、並べます。
また、form の方で query が更新されたら、その都度呼びなおされ、反映されます。

import { Link } from "react-router-dom";
import { DocumentNode } from "graphql";
import {
  Box,
  Table,
  Thead,
  Tbody,
  Tr,
  Th,
  Td,
  TableCaption,
} from "@chakra-ui/react"
import { H2 } from './text';

export const Result = ({ result, ast }: { result: any, ast: DocumentNode }) => {
  return (
    <Box width={'100%'} padding={'10px 10px 10px 30px'}>
      <H2 text={'result'}></H2>
      <Box textAlign={'right'} marginRight={'30px'} marginBottom={'10px'}>
        count: {result.data.analytics.count}
      </Box>
      <Table variant="simple">
        <TableCaption>takurinton analytics</TableCaption>
          <Thead>
            <Tr>
              <Th>domain</Th>
              <Th>path</Th>
              <Th>created_at</Th>
              <Th>detail</Th>
            </Tr>
          </Thead>
          <Tbody>
            {
              result.data.analytics.analytics.map((a: { id: number, domain: string, path: string, created_at: string}) => (
                <Tr key={a.id}>
                  <Td>{a.domain}</Td>
                  <Td>{a.path}</Td>
                  <Td>{a.created_at}</Td>
                  <Td><Link to={`/detail/?domain=${a.domain}&path=${a.path}`}>detail</Link></Td>
                </Tr>
              ))
            }
            <Tr>
              <Td>...</Td>
              <Td>...</Td>
              <Td>...</Td>
            </Tr>
          </Tbody>
      </Table>
    </Box>
  );
}

これからやりたいこと

まだまだ未完成です。これからやりたいことはたくさんあります。

期間指定をする

期間指定ができないのはだいぶ致命的な気がします。デフォルトだと1000件以上取得してしまうのでちょっと負担が大きいかなと思います。
また、特定の期間でどれくらいアクセスがあったかを見たい時も多いと思います。

詳細画面の実装

今は一覧画面しかありませんが、詳細画面を表示して、Chartjs あたりでグラフにして見やすいようにしたいです。ちなみに日毎のアクセス数とかを取得する query は既に作ってあるのであとは見た目を実装するだけです。

パフォーマンスの改善

シンプルに重いです。
ただ、typename ごとのキャッシュを効かせたりはできると思うのでそこらへんをうまく使っていきたいです。

まとめ

ずっと作ろうと思って作っていなかったので、作ってみました。
chakra-ui 使うと開発が早くなっていいなって思いますが、これインラインで色々書けすぎてもっと複雑なことしようとしたらどうなるんだろうみたいな単純な疑問があります。
また、GraphQL はいいなと思いました。
いい休日でした。