15
22

More than 1 year has passed since last update.

【個人開発】DMM Web サービスのAPIを使ってお気に入り女優さんが出演しているキャンペーン中の商品を検索できるサービスを作った

Posted at

DMM Web サービスのAPIを使ってお気に入り女優さんが出演しているキャンペーン中の商品を検索できるサービスを作った

DMM.comにDMM Webサービスというのがありまして、DMMアフィリエイトのサイト申請をしたらAPIを使うことが出来ます。
これを使って自分が欲しかった機能を実装した画面が作れそうだったので実際に作ってみました。
作ったサイトはこちらです。※18禁のサイトです。
このサイトを作るのに使った技術の情報を書きます。

作成者

ひろいんと申します。
ErogameScapeというサイトの管理者です。※こちらのサイトも18禁です。
2023/02/28現在42Tokyoに在籍しています。

使用した技術

対象 技術
フロントエンド Next.js, Tailwind CSS, Mantine
バックエンド NestJS, Prisma
ミドルウェア Apache, PostgreSQL
インフラ 専用サーバー
API DMM Webサービス

APIの使い方

[商品情報APIリファレンス - DMM API](商品情報APIリファレンス - DMM API)のリクエストURLに書いてある通りのURLを叩くだけです。
例えば商品のIDが「mide00988」の情報を取得する場合
https://api.dmm.com/affiliate/v3/ItemList?api_id=[APIID]&affiliate_id=[アフィリエイトID]&site=FANZA&service=digital&floor=videoa&hits=10&sort=date&cid=mide00988&output=json
にアクセスすればOKです。
AmazonのProduct Advertising APIは、署名が必要なので一手間必要ですが、DMM APIはお手軽に情報を取得できます。
APIをたくさん叩きすぎると一定時間アクセスできなくなりますが、制限はとてもゆるいと感じました。

APIで取得できないものもある

キャンペーン中の商品を「売上本数順」「お気に入り数順」で並び替えたかったのですが、APIで取得した情報の中に項目がなかったので諦めました。
まず情報を取得してみてご自身が作りたいものに必要な情報があるかの確認が必要です。

クレジット表示について

DMM Webサービスから提供されたAPIを利用して制作されたサイトやアプリケーションには、クレジットの表示が必要です。

DBを構築する

キャンペーンの情報、キャンペーン中の商品の情報、商品に出演している女優さんの情報、ユーザーさんの情報、ユーザーさんのお気に入りの女優さんの情報、が必要なので、ER図は以下のようになりました。
image.png
※自分の場合、ER図を書かずに直接DDLを書いております。上記の図はA5:SQL Mk-2を使ってリバース生成したものです。

取得したデータをDBに流し込む

キャンペーン対象の商品のcidを取得する

キャンペーン対象の商品を取得できるAPIはないのでスクレイピングで取得します。
スクレイピングが十八番なのはPythonなのですが、自分はPythonが書けないので、puppeteerを使いました。
FANZAのサイトにアクセスすると「あなたは18歳以上ですか?」という画面に飛ばされます。「はい」をクリックすると、Cookieにage_check_done=1がセットされますので、puppeteerでスクレイピングする場合も、Cookieにage_check_done=1をセットします。
具体的には

const cookies = [
  {
    name: "age_check_done",
    value: "1",
    domain: ".dmm.co.jp",
  },
];

  const browser = await puppeteer.launch();
  const browserPage = await browser.newPage();
  browserPage.setCookie(...cookies);

としました。

取得したcidを使ってAPIを叩いて商品の情報を取得する

AmazonのProduct Advertising APIと違って商品IDを複数渡す機能はない…と思うので、取得したcidごとにAPIを叩きます。
キャンペーンによっては、6000商品が対象だったりするので、取得が完了したらnodemailerで自分にメールがくるようにしています。※今どきの方々ですとSlackで通知かと思いました…

import * as dotenv from "dotenv";
import fs from "fs";
import axios from "axios";
dotenv.config();

import cids from "./cids.json";
import sendMail from "./util/sendMail";
const itemsJson: any[] = [];

const getItemFromAPI = async (cid: string) => {
  try {
    const response = await axios.get(
      `https://api.dmm.com/affiliate/v3/ItemList?api_id=${process.env.API_ID}&affiliate_id=${process.env.AFFILIATE_ID}&site=FANZA&service=digital&floor=videoa&output=json&cid=${cid}`
    );
    return response.data;
  } catch (error) {
    console.error(error);
    return null;
  }
};

(async () => {
  for (const cid of cids) {
    console.log(cid, "の取得中・・・");
    const itemDataOfJson = await getItemFromAPI(cid);
    itemsJson.push(itemDataOfJson.result.items[0]);
    await new Promise((s) => setTimeout(s, 1000));
  }
  fs.writeFileSync("items.json", JSON.stringify(itemsJson));
  sendMail("FANZA動画の取得が完了しました", "FANZA動画の取得が完了しました。");
})();

取得した商品の情報をDBに流し込む

取得した商品のJSONのデータをSQLに書き直します。
JSONで取得しているので、そのままJSONB型に流し込めばいいとも思ったのですが、JSONB型から情報を取り出すSQLの書き方の習得に時間がかかりそうでしたので途中でやめました。

JSONから必要な項目を抜き出しましてPREPAREしたものに流し込むSQLを生成します。

import items from "./items.json";

const sql = items.map((item) => {
  const delivery_4k = item.prices.deliveries.delivery.find(
    (delivery) => delivery.type === "4k"
  );
  const prices_4k = delivery_4k ? delivery_4k.list_price : null;

  const delivery_hd = item.prices.deliveries.delivery.find(
    (delivery) => delivery.type === "hd"
  );
  const prices_hd = delivery_hd ? delivery_hd.list_price : null;

中略

  const sql = `\
  EXECUTE digital_videoa_goods_plan(
      '${item.product_id.replace(/'/g, "''")}'
    , '${item.title.replace(/'/g, "''")}'
    ,  ${item.review?.count ?? 0}
    ,  ${item.review?.average ?? -1}
    ,  ${prices_hd}
    ,  ${prices_download}
    ,  '${item.date.replace(/'/g, "''")}'
    ,  ${solo}
    ,  ${vr}
    ,  '${sampleMovieURL.replace(/'/g, "''")}'
    ,  ${prices_4k}
  );`;
  return sql;
});

省略しますが、流し込むSQLの先頭にPREPARE文をつけたものをitems.sqlとして生成します。

fs.writeFileSync("./items.sql", prepare + sql.join("\n"));

これで商品情報テーブルへ挿入するSQLが完成しました。
同じように、キャペーンと商品をくくりつけるSQL、商品と女優さんをくくりつけるSQLを作成するスクリプトも作成してDBに情報を流し込みます。

NestJSでAPIを作成する

DBからデータを取り出してフロントエンドにデータを送るためのAPIを作成します。
42Tokyo最終課題で覚えたNestJSを使いました。
ORMはPrismaを使いました。

42Tokyo最終課題ではORMにTypeORMを使った…課題をはじめた当初はこれしか選択肢がなかった…ので使ったのですが、いつもSQLを生で書いている身からするとやりたいことがなかなか出来なくてとてもつらかったです。
その点、Prismaは出来ることが増えており素晴らしい!と思いました。
今回はORM縛りを自分に課して、生SQLを書かずにORMでなんとかしたのですが…SQLだったらすぐ書けるのに、Prismaだとどう書くかわからん…と思うことがあったので、次からは併用でいこうと思います。

NestJSの入門としては、2年前時点では…【NestJS入門】基礎からmongoDB、認証までを一気に解説 -Fullが素晴らしかったです。今でもたぶん大丈夫だと思います。
また、作成したサービスはUdemyのNestJS + Next.js によるフルスタックWeb開発を改造して作りました。

NestJSは公式のマニュアルの内容だけ見て実装するのは厳しいと感じました。
完全に機能を使いこなすためにはソースコードを見る(見られる)必要があるかな…と思います。

ログイン情報をセッションに保存する設定

NestJS でログイン情報をセッションに保存のドキュメントが素晴らしいです。

NestJSで認証について調べるとJWTを使う例がほとんどで実装に苦労しました。
今見返すと、なぜこれで動いているのかさっぱり分からない状態でしたが、yuuAnさんが書いてくれているNestJS でログイン情報をセッションに保存で思い出しました。素晴らしいです。
自分が書いたときはAPI with NestJS #35. Using server-side sessions instead of JSON Web Tokensを見た気がします。

main.ts
  app.use(
    session({
      // eslint-disable-next-line @typescript-eslint/no-var-requires
      store: new (require('connect-pg-simple')(session))({
        conString: configService.get('DATABASE_URL'),
        schemaName: 'video',
      }),
      secret: configService.get('SESSION_SECRET'),
      resave: false,
      saveUninitialized: false,
      cookie: {
        maxAge: 1000 * 60 * 60 * 24 * 7,
        httpOnly: true,
        sameSite: configService.get('COOKIE_SAMESITE'),
        secure: configService.get('COOKIE_SECURE') === 'true',
      },
    }),
  );
  app.use(passport.initialize());
  app.use(passport.session());

セッションのstoreにPostgreSQLを使っています。
require('connect-pg-simple')をimportで書き直したかったのですが、書き直し方が分かりませんでした…
resaveをtrueにすると、ユーザーさんがアクセスするたびに、MaxAgeが更新されます。具体的には、アクセスするたびに、アクセスした時点 + 7日後に更新されます。
saveUninitializedはtrueにすると、すごい数のセッションがDBに記録されてしまいます。最初trueにしていたのですが、ものすごい数のセッションが記録されていました。
node.js - When to use saveUninitialized and resave in express-session - Stack Overflow

One thing to note is that if you set saveUninitialized to false, the session cookie will not be set on the browser unless the session is modified. That behavior may be implied but it was not clear to me when I was first reading through the documentation.
とありまして、なるほど…と思いました。確かに、なんの情報も格納されていないセッションが多数記録されていました。

シリアライズとデシリアライズ

passport.session()を使っている場合、PassportSerializerを継承して、serializeUser関数と、deserializeUser関数を定義し、auth.module.tsのprovidersに書く(DIする…でいいのかな…)必要があります。
Username & Password Tutorial: Establish Sessionを見ると、passportを使う場合、passport.serializeUser()とpassport.deserializeUser()を定義する必要があります。
これをNestJSで定義する方法が

local.serializer.ts
import { PassportSerializer } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { userInfoInRequest } from 'src/user/types/userInfoInRequest.type';

@Injectable()
export class LocalSerializer extends PassportSerializer {
  serializeUser(user: userInfoInRequest, done: CallableFunction) {
    // console.log('== call serializeUser ==');
    done(null, { id: user.id, name: user.name });
  }

  deserializeUser(user: userInfoInRequest, done: CallableFunction) {
    // console.log('== call deserializeUser ==');
    done(null, user);
  }
}

と、PassportSerializerを継承したクラスを定義して、

auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LocalSerializer } from './local.serializer';
import { LocalStrategy } from './strategy/local.strategy';

@Module({
  imports: [PrismaModule, JwtModule.register({})],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, LocalSerializer],
})
export class AuthModule {}

LocalStrategyを使うところで、providersに書きます。
これをしていないと、

[Nest] 20752  - 2023/03/01 11:17:53   ERROR [ExceptionsHandler] Failed to serialize user into session
Error: Failed to serialize user into sessio

と怒られます。
おそらくPassportSerializerを継承したクラスを定義は必須なのだと思います。
PassportSerializerの定義は以下のようになっているので、serializeUserとdeserializeUserは自分で書く必要があります。

passport.serializer.d.ts
import * as passport from 'passport';
export declare abstract class PassportSerializer {
    abstract serializeUser(user: any, done: Function): any;
    abstract deserializeUser(payload: any, done: Function): any;
    constructor();
    getPassportInstance(): passport.PassportStatic;
}

ログインとユーザーの情報のセッションへの保存

POSTで/loginにアクセスして認証し、認証OKであればセッションにユーザーIDを保存します。

auth.controller.ts
  @UseGuards(LogInWithCredentialsGuard)
  @Post('login')
  async login(@Req() req: Request): Promise<userInfoInRequest> {
    return req.user;
  }

POSTで/loginにアクセスすると、LogInWithCredentialsGuardが動きます。

logInWithCredentialsGuard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LogInWithCredentialsGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // check the id and the password
    await super.canActivate(context);

    // initialize the session
    const request = context.switchToHttp().getRequest();
    await super.logIn(request);

    // if no exceptions were thrown, allow the access to the route
    return true;
  }
}

ただ認証するだけであれば、

auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

でよいのですが、認証した後セッションに情報を埋め込む必要があるので、認証の際に必ず呼ばれるcanActivate関数を
オーバーライドして、デフォルトのcanActivate関数の処理(passport.authenticate('local')で認証する)に加えて、

    const request = context.switchToHttp().getRequest();
    await super.logIn(request);

として、セッションに情報を埋め込みます。
logIn関数の実装

    async logIn<TRequest extends { logIn: Function } = any>(
      request: TRequest
    ): Promise<void> {
      const user = request[this.options.property || defaultOptions.property];
      await new Promise<void>((resolve, reject) =>
        request.logIn(user, (err) => (err ? reject(err) : resolve()))
      );
    }

となっていて、Passport exposesのrequest.logIn関数を呼んでいます。

AuthGuard('local')は、PassportStrategy(Strategy)を継承して作成するLocalStrategyです。
LocalStrategyという名前でクラスを作って、DIする(providersに追加する)と、AuthGuard('local')と書いたときにLocalStrategyが読み込まれます。

local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, ForbiddenException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaNobodyService, PrismaService } from 'src/prisma/prisma.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly prismaAdmin: PrismaService) {
    super({
      usernameField: 'name',
    });
  }
  async validate(
    name: string,
    password: string,
  ): Promise<{ id: number; name: string }> {
    const user = await this.prismaAdmin.users.findUnique({
      where: {
        name: name,
      },
    });
    if (!user)
      throw new ForbiddenException([
        'ユーザー名かパスワードが間違っています。',
      ]);
    const isValid = await bcrypt.compare(password, user.hashed_password);
    if (!isValid) {
      throw new ForbiddenException([
        'ユーザー名かパスワードが間違っています。',
      ]);
    }
    return { id: user.id, name: user.name };
  }
}

validate関数が自動で呼ばれるので、validate関数にパスワードがあっているかの処理を書きます。
パスワードが間違っていた等の場合、throw new ForbiddenExceptionしてあげると、クライアントにForbiddenがかえります。

認証していないとアクセスできないルート

@UseGuards()を使って認証していないとアクセスできないルートを作成できます。
()の中に認証に使うクラスを渡します。以下の例では、CookieAuthenticationGuardを渡しています。

user.controller.ts
  @Get()
  @UseGuards(CookieAuthenticationGuard)
  async getLoginUser(
    @Req() req: Request,
    @Query('detail', ParseBoolAllowUndefinedPipe) detail?: boolean,
  ): Promise<userInfoInRequest> {
    return req.user;
  }

@UseGuardsを使うと、Strategyでretrunした値がreq.userにセットされます。
passport.session()を使っている場合は、deserializeUserで実行されるdoneの中身がセットされます。

cookieAuthentication.guard.ts
import { ExecutionContext, Injectable, CanActivate } from '@nestjs/common';

@Injectable()
export class CookieAuthenticationGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    return request.isAuthenticated();
  }
}

CookieAuthenticationGuardはCanActivateを継承します。
canActivateが自動で実行されるので、ここにすでに認証済みかの確認処理を書きます。
passportは、isAuthenticated関数で認証済みかを確認できるので、request.isAuthenticated();をreturnします。
※isAuthenticated()についてドキュメントを確認しようとしたら見つからず、かわりにNo Mention of isAuthenticated() in docsが見つかりました。ドキュメントはないのですね…

誰でもアクセスできるけど、ログインしている場合はユーザーの情報を使うエンドポイントの作り方

このやり方が一番よいのか分からないのですが…

CookieDataUseGuard
import { ExecutionContext, Injectable, CanActivate } from '@nestjs/common';

@Injectable()
export class CookieDataUseGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    return true;
  }
}

と、必ず認証OKになるCookieDataUseGuardを作って

  @Get('campaigns')
  @UseGuards(CookieDataUseGuard)
  async listVideosAllCampaign(
    @Req() req: Request,
  ): Promise<ApiReturnVideos> {
    console.log('user :', req.user);
  }

とすると、ログインしている場合はreq.userに値がセットされ、ログインしていない場合は、req.userがundefinedになるので、その情報を使って処理を分岐できます。

バリデーション

入力された文字列を検証してnumber型に変換する

listVideosFilterCampaign.dto.ts
  @Get('actress/:id')
  async listVideosFilterActress(
    @Req() req: Request,
    @Query() query: ListVideosFilterCampaignQueryDto,
  ): Promise<void> {
  }

のようなコントローラーに
http://localhost:3105/videos/actress/10598707?offset=10
というGETを送った際に、offsetが数値であることを確認し、number型に変換するにはListVideosFilterCampaignQueryDtoを以下のように書きます。

listVideosFilterCampaign.dto.ts
import { Transform, Type } from 'class-transformer';
import {
  IsInt,
} from 'class-validator';

export class ListVideosFilterCampaignQueryDto {
  @Type(() => Number)
  @IsInt()
  offset: number;
}

@Type(() => Number)を書かないと、offset=10でも以下のエラーがでます。

{
    "statusCode": 400,
    "message": [
        "offset must be an integer number"
    ],
    "error": "Bad Request"
}

同じことをParseIntPipeを使っても書けます。

listVideosFilterCampaign.dto.ts
  @Get('actress/:id')
  async listVideosFilterActress(
    @Req() req: Request,
    @Query('offset', ParseIntPipe) offset: number,
  ): Promise<void> {
  }

入力された文字列がtrueかfalseであることを検証してboolean型に変換する

http://localhost:3105/videos/actress/10598707?in_campaign=true
というGETを送った際に、in_campaignがtrueまたはfalseであることを確認し、boolean型に変換するためListVideosFilterCampaignQueryDtoを以下のように書きました。

listVideosFilterCampaign.dto.ts
export class ListVideosFilterCampaignQueryDto {
  @IsOptional()
  @IsBoolean()
  @Transform(({ value }) => {
    console.log(typeof value);
    console.log(value);
    if (typeof value !== 'string') return value;
    if (value.toLowerCase() === 'true') {
      return true;
    }
    if (value.toLowerCase() === 'false') {
      return false;
    }
    return value;
  })
  readonly in_campaign?: boolean;
}

in_campaignはなくてもOKにしたかったので、@IsOptional()をつけています。
このdtoで、http://localhost:3105/videos/actress/10598707?in_campaign=trueアクセスすると、in_campaignは無事boolean型になるのですが…console.logの出力が以下のようになります。

string
true
boolean
true

この結果から、おそらく@Transformが2回動いているのだと思います。
ただどうして2回動くのか全く分かりませんでした…

同じことをParseBoolPipeで書くと

listVideosFilterCampaign.dto.ts
  @Get('actress/:id')
  async listVideosFilterActress(
    @Req() req: Request,
    @Query('in_campaign', ParseBoolPipe) solo: boolean,
  ): Promise<void> {
  }

となるのですが、in_campaignがセットされていないと、バリデーションエラーになってしまうので使えません。
そこで、in_campaignがセットされていない(undefiend)の場合はエラーにせずundefiendのままにするようにParseBoolPipeを改造します。

parse-bool-allow-undefined.pipe.ts
import {
  Injectable,
  Optional,
  HttpStatus,
  ArgumentMetadata,
  PipeTransform,
} from '@nestjs/common';

import {
  ErrorHttpStatusCode,
  HttpErrorByCode,
} from '@nestjs/common/utils/http-error-by-code.util';

export interface ParseBoolPipeOptions {
  errorHttpStatusCode?: ErrorHttpStatusCode;
  exceptionFactory?: (error: string) => any;
}

/**
 * Defines the built-in ParseBool Pipe
 *
 * @see [Built-in Pipes](https://docs.nestjs.com/pipes#built-in-pipes)
 *
 * @publicApi
 */
@Injectable()
export class ParseBoolAllowUndefinedPipe
  implements PipeTransform<string | boolean, Promise<boolean>>
{
  protected exceptionFactory: (error: string) => any;

  constructor(@Optional() options?: ParseBoolPipeOptions) {
    options = options || {};
    const { exceptionFactory, errorHttpStatusCode = HttpStatus.BAD_REQUEST } =
      options;
    this.exceptionFactory =
      exceptionFactory ||
      ((error) => new HttpErrorByCode[errorHttpStatusCode](error));
  }

  /**
   * Method that accesses and performs optional transformation on argument for
   * in-flight requests.
   *
   * @param value currently processed route argument
   * @param metadata contains metadata about the currently processed route argument
   */
  async transform(
    value: string | boolean,
    metadata: ArgumentMetadata,
  ): Promise<boolean> {
    if (value === undefined) {
      return undefined;
    }
    if (this.isTrue(value)) {
      return true;
    }
    if (this.isFalse(value)) {
      return false;
    }
    throw this.exceptionFactory(
      'Validation failed (boolean string is expected)',
    );
  }

  /**
   * @param value currently processed route argument
   * @returns `true` if `value` is said 'true', ie., if it is equal to the boolean
   * `true` or the string `"true"`
   */
  protected isTrue(value: string | boolean): boolean {
    return value === true || value === 'true';
  }

  /**
   * @param value currently processed route argument
   * @returns `true` if `value` is said 'false', ie., if it is equal to the boolean
   * `false` or the string `"false"`
   */
  protected isFalse(value: string | boolean): boolean {
    return value === false || value === 'false';
  }
}

確かParseBoolPipeに

    if (value === undefined) {
      return undefined;
    }

を付け加えただけだったと思います。

入力された文字列が特定の文字列であることを検証する

動画の並び順を決めるためGETリクエストでorderというパラメータ渡します。
orderは、distribution_start、review_count、review_average、prices_downloadの4種類の文字列を許容します。
これをtypeとclass-validatorを使ってどう書くのが一番いいのか分かりませんでした。

listVideosFilterCampaign.dto.ts
import { IsEnum, IsString } from 'class-validator';
import Order from '../types/order';

export class ListVideosFilterCampaignQueryDto {
  @IsString()
  @IsEnum(Order)
  public order: Order;
}
order.ts
enum Order {
  distribution_start = 'distribution_start',
  review_count = 'review_count',
  review_average = 'review_average',
  prices_download = 'prices_download',
}

export default Order;

と書いたのですが、order.tsでdistribution_start = 'distribution_start',とdistribution_startを2回書くのは何か無駄な気がしました。

type Order = 'distribution_start' | 'review_count' | 'review_average' | 'prices_download'

を、class-validatorを使ってなんとかしたかったのですが、分かりませんでした。

アクセスログの取得

Apacheと同じようなログを出力するように設定します。

main.ts
  const server = express();
  const app = await NestFactory.create(AppModule, new ExpressAdapter(server));

  const accessLogStream = fs.createWriteStream(
    path.join(__dirname, '../log/access.log'),
    { flags: 'a' },
  );
  app.use(morgan('combined', { stream: accessLogStream }));

  await app.init();
  http.createServer(server).listen(process.env.HTTP_PORT || 3105);

ログのローテーションは、logrotateで設定しました。

Prisma の発行するクエリを出力する

設定方法はTakepepeさんのPrisma の発行するクエリを出力するのドキュメントの通りです。
Prismaの公式ドキュメントはこちらです。

prisma.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient, Prisma } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient<Prisma.PrismaClientOptions, Prisma.LogLevel>
  implements OnModuleInit
{
  private readonly logger = new Logger(PrismaService.name);
  constructor(private readonly config: ConfigService) {
    super({
      datasources: {
        db: {
          url: config.get('DATABASE_URL'),
        },
      },
      log: ['query', 'info', 'warn', 'error'],
    });
  }
  async onModuleInit() {
    this.$on('query', (event) => {
      this.logger.log(
        `Query: ${event.query}`,
        `Params: ${event.params}`,
        `Duration: ${event.duration} ms`,
      );
    });
    this.$on('info', (event) => {
      this.logger.log(`message: ${event.message}`);
    });
    this.$on('error', (event) => {
      this.logger.log(`error: ${event.message}`);
    });
    this.$on('warn', (event) => {
      this.logger.log(`warn: ${event.message}`);
    });
  }
}

PrismaのSELECTのWHERE句に渡すオブジェクトを作成する

SELECT * FROM users WHERE id = 1;
のような簡単なクエリだったらいいのですが、オプションがたくさんある場合…、具体的には、

CREATE TABLE digital_videoa_goods(
  cid                TEXT NOT NULL,
  title              TEXT NOT NULL,
  solo               boolean,
  vr                 boolean,
  PRIMARY KEY(cid)
);

のようなテーブルに対して、soloがtrue/don't care、vrがtrue/false/don't careという2 * 3 = 6通りの入力に対するSQLを作りたい場合、いいやり方が思いつきませんでした。

  async getVideos(
    solo: boolean,
    onlyVR: boolean,
    excludeVR: boolean,
  ): Promise<Videos> {
  // ここでSQLを発行する
}
入力がsolo=true、onlyVR=false、excludeVR=falseの場合
  where: {
    solo: true,
  },
入力がsolo=true、onlyVR==true、excludeVR=falseの場合
  where: {
    solo: true,
    vr: true,
  },
入力がsolo=false、onlyVR=true、excludeVR=falseの場合
  where: {
    vr: true,
  },
入力がexcludeVR=trueの場合
  where: {
    vr: false,
  },

というwhere句を作りたいです。
どうしていいかわからず

  where: { ...whereOption },
const whereOption = makeWhereOption({
  solo,
  onlyVR,
  excludeVR,
});
makeWhereOption({
    solo,
    onlyVR,
    excludeVR,
  }: {
    solo: boolean;
    onlyVR: boolean;
    excludeVR: boolean;
  }): Promise<SqlWhereOptions> {
    const whereOption: any = {};
    if (solo || onlyVR || excludeVR) {
      if (solo) {
        whereOption.solo = true;
      }
      if (onlyVR) {
        whereOption.vr = true;
      }
      if (excludeVR) {
        whereOption.vr = false;
      }
    }
    return whereOption;
  }

と書きました。
今、試したところ、入力がsolo=true、onlyVR=false、excludeVR=falseの場合

入力がsolo=true、onlyVR=false、excludeVR=falseの場合
  where: {
    solo: true,
    vr: undefined,
  },

と書けば、vrに関するWHERE句は生成されないことを知りました…

入力がsolo=true、onlyVR=false、excludeVR=falseの場合
  where: {
    solo: solo ? true : undefined,
    vr: onlyVR ? true : excludeVR ? false : undefined,
  },

と書けば良さそうです…

Prismaで生成されたSQLの実行時間を確認する

Prismaで生成されたSQLが実行におそろしく時間がかかってサーバーのCPU使用率が100%にはりついたことがありました。
そのときは「たぶん、ORMのここをこうすれば、実行時間減るかな」と試行錯誤したのですが、WEB+DB PRESS Vol. 133のDjango ORM道場に「理想のSQLをORMクエリで実現する方法を考える」と書いてあって、なるほどなあ…と思いました。
次はその考え方でやってみようと思います。
※ちなみに「実装したORMクエリがわかりやすく、保守しやすいコードか見直す」とも書いてありまして、自分のコードは分かりにくいです…どうすればいいんだ…

キャッシュを設定する

あまり更新されないデータを使って結果を返すAPIはキャッシュを返すよう設定します。
キャッシュはRedisに保存することが多いイメージでErogameScapeでは一時期Redisにセッションの情報を保持していたことがあったのですが、Redisを運用するスキルはないに等しいのでdiskに保存することにします。

main.ts
import { CacheModule, Module } from '@nestjs/common';
(中略)
import * as fsStore from 'cache-manager-fs-hash';

@Module({
  imports: [
    CacheModule.register({
      isGlobal: true,
      ttl: 60 * 1000,
      store: fsStore,
      options: {
        path: 'diskcache', //path for cached files
        ttl: 60, //time to life in seconds
        subdirs: true, //create subdirectories to reduce the
        //files in a single dir (default: false)
        zip: false, //zip files to save diskspace (default: false)
      },
    }),
(中略)

})
export class AppModule {}

とmain.tsに書くと、GlobalにCacheModuleが使えるようになりまして、

campaign.controller.ts
import {
  Controller,
  Get,
  Req,
  Param,
  ParseIntPipe,
  UseInterceptors,
  CacheInterceptor,
} from '@nestjs/common';
import { digital_videoa_campaigns } from '@prisma/client';
import { Request } from 'express';
import { CampaignService } from './campaign.service';

@UseInterceptors(CacheInterceptor)
@Controller('campaign')
export class CampaignController {
(中略)
}

と書くと、/campaign配下のリクエストはすべて60秒間キャッシュされます。
この方法ですと、リクエスト単位でしかキャッシュできないです。
部分的にキャッシュしたい場合は

video.service.ts
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
(中略)

@Injectable()
export class VideoService {
  constructor(
    private prisma: PrismaNobodyService,
    private readonly config: ConfigService,
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}

のように、@Inject(CACHE_MANAGER) private cacheManager: Cache,を書きまして、

video.service.ts
  async getVideosSummaryCached({
    order,
    offset,
    limit,
    whereOption,
  }: GetVideosParams) {
    const key = this.generateCacheKey({ order, offset, limit, whereOption });
    const ttl = 60 * 1000;
    return await this.cacheManager.wrap(
      key,
      async () => {
        return await this.getVideosSummaryFromDB({
          order,
          offset,
          limit,
          whereOption,
        });
      },
      ttl,
    );
  }

と、this.cacheManager.wrap(ユニークなkey, キャッシュしたい内容, ttl)と書くことで、キャッシュがttlを迎えていない場合はキャッシュを、ttlを迎えている場合は内容を取得しにいってキャッシュする、ということをしてくれます。
マニュアルはnode-cache-managerCaching | NestJS - A progressive Node.js frameworkです。

デプロイの構成を考える その1

18禁のコンテンツなので、デプロイできる場所を探すところからスタートです。
おそらく間違いないのはアダルトOKのVPSにデプロイすることかなと思いました。
httpsで接続したいので以下のように設定して接続できることを確認しました。

main.ts
async function bootstrap() {
  const server = express();
  const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
  const configService = app.get(ConfigService);
  (中略)
  await app.init();

  http.createServer(server).listen(process.env.HTTP_PORT || 3105);
  // httpsを動かしたい場合は以下のコメントアウトを外す
  // const httpsOptions = {
  //   key: fs.readFileSync(configService.get('PRIVATE_KEY')),
  //   cert: fs.readFileSync(configService.get('PUBLIC-CERTIFICATE')),
  // };
  // https
  //   .createServer(httpsOptions, server)
  //   .listen(process.env.HTTPS_PORT || 3106);
}
bootstrap();

しかし、このままだとroot権限でサーバーが起動してしまいます。
ググった結果、setuidでlistenした後にユーザーを変更するのがセオリーのようなのですが、自信がなかったです。
さらにググった結果、そもそもExpressを最前段に配置するのではなく、プロキシの背後にExpressを配置するのがセオリーのようでした。
そこで、もともと動いているApacheをプロキシにすることにしました。

/etc/httpd/conf.modules.d/00-proxy.conf
以下のコメントアウトを外す
LoadModule proxy_module modules/mod_proxy.so 
LoadModule proxy_connect_module modules/mod_proxy_connect.so 
LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so 
LoadModule proxy_http_module modules/mod_proxy_http.so
/etc/httpd/conf.modules.d/10-proxy_h2.conf
以下のコメントアウトを外す
LoadModule proxy_http2_module modules/mod_proxy_http2.so
/etc/httpd/conf.modules.d/00-ssl.conf
<VirtualHost *:443>
    ServerName discounted-video.dyndns.org:443
    (中略)
    SSLEngine on
    (中略)
    ProxyPass /api/ http://127.0.0.1:3105/
    ProxyPassReverse /api/ http://127.0.0.1:3105/
    (中略)
</VirtualHost>

これで、SSLはApacheが終端するので、ExpressはSSLのことを気にしなくてよくなりました。

Proxyの後段に配置する設定をする

NestJSをProxyの後段に配置するとアクセスログのアクセス元がloopbackアドレスになるので、それを回避するため以下の設定を追加します。

main.ts
async function bootstrap() {
  const server = express();
  server.set('trust proxy', 1); //https://expressjs.com/ja/guide/behind-proxies.html
  const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
  (中略)
  await app.init();

  http.createServer(server).listen(process.env.HTTP_PORT || 3105);

}
bootstrap();

server.set('trust proxy', 1);を追加します。

Next.jsでフロントエンドを作成する

Create a Next.js App | Learn Next.jsを一通りやってNext.jsの使い方を学習して作り始めました。

SG(Static Generation)にするとbuildにとても時間がかかるので、SSRにしました

APIで取得できる女優さんは5万人ほどいらっしゃるので、SGにすると女優さんのページが5万ほど作成されます。
その取得のためにAPIを叩きまくってサーバーにとても負荷がかかるし、buildに時間もかかるので、SGにしました。
ErogameScapeをリプレイスすることを想定した際に、静的な情報が多いので理論上は殆どの画面でSGできるのですが、おそらく何十万のページが存在するのでbuildするのは現実的ではない気がしました。
実運用ではどうしているのでしょうか。

最初はSGの画面を表示して、すぐにクライアントでフェッチした画面に切り替えたい場合のよい書き方が分からない

このサイトの場合、/pages/campaign/[id].tsxに書いたgetStaticPropsで取得した動画の情報を利用するために、

/pages/campaign/[id].tsx
export default function Campaign(props: Props) {
  return (
      <VideoListAndPageNation
        videos={videos}
      />
  )
}
export async function getStaticProps({ params }: { params: { id: string } }) {
  const videos = await getVideosBySpecifyingCampaign()
  return {
    props: { campaign: campaign },
  }
}
VideoListAndPageNation.tsx
        <VideoList
          videos={videos}
        />

とpropsで渡していく…でよいのでしょうか。このくらいの受渡しであればいいのですが、とても深いとvideos={videos}を何回も書かないといけないので、なんとかできないのかな…と思いました。

PWA(Progressive Web App)対応

アプリのようにインストールできるようにするためPWAを導入しました。
Next.js環境でのPWA(Progressive Web App)の導入手順の通りに実施いたしました。
公式のマニュアルはZero Config PWA Plugin for Next.jsです。
favicon.icoの生成は様々なファビコンを一括生成を利用いたしました。
ありがたいです。

コンソールにでてくるworkboxを消す

PWAを導入するとデフォルトで以下のようなログが出力されます。
image.png
この出力を抑えるには、worler/index.jsを作成し、self.__WB_DISABLE_DEV_LOGS = trueと書きます。
image.png
service workerを使ったアプリでworkboxのログを表示しないようにするを見て設定いたしました。

Mantine Components Gallery

thr3aさんのMantine Components Galleryを見て、自分が実装したい部品はあるかな?と探しました。
一覧になっているのでとても使いやすかったです。Mantine Components Galleryでお目当ての部品を探して公式を見て実装いたしました。

@tanstack/react-queryのQueryClientを使うとき、適切にremoveQueriesやinvalidateQueriesを使ってキャッシュをクリアすることを忘れない

logoutしたときや、お気に入りの女優さんの情報が変更された際、適切にキャッシュを破棄します。

  const logout = async () => {
    queryClient.removeQueries(['user'])
    queryClient.removeQueries(['favoriteActress'])
    queryClient.removeQueries(['allCampaign'])
    queryClient.removeQueries(['specifyingCampaign'])
    await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/auth/logout`)
    router.push('/')
  }
  const createFavoriteMutation = useMutation(
    async (actressId: number) => {
      const res = await axios.post(
        `${process.env.NEXT_PUBLIC_API_URL}/actress/favorite`,
        { actressId: actressId }
      )
      return res.data
    },
    {
      onSuccess: (res) => {
        queryClient.invalidateQueries(['favoriteActress'])
        queryClient.invalidateQueries(['allCampaign'])
        queryClient.invalidateQueries(['specifyingCampaign'])
      },
      onError: (err: any) => {},
    }
  )

破棄を忘れると、ログアウトしてもログインしているときと同じ情報が表示されたり、お気に入り女優さんを追加したのに追加されていない情報が表示されてしまいます。
デフォルトのcacheTimeは5 minutesなので…あれ?表示されてる情報がおかしいな…と思って切り分けているうちに、表示が直ってしまい、気のせいかな…と思ってしまいました…

デプロイの構成を考える その2

pm2の導入

Node.jsのサーバーを運用する場合、例外が発生するとNode.jsが止まるので、NestJSであればnode dist/mainで起動してはいけないことを知りました。
pm2を使うことが一般的のようなので、pm2を介してNext.jsもNestJSも立ち上げることにしました。

pm2 start --name Next yarn -- start
pm2 start --name Nest dist/main.js 

yarn startと入力したい場合、引数の渡し方は、--の後に引数、になります。
NestJSを

pm2 start --name Nest yarn -- start:prod

で立ち上げると、pm2 stop Nestとしても、Nestのプロセスを殺してくれませんでした。

pm2 start --name Nest dist/main.js

と直接main.tsを指定する必要があります。

フロントエンドのSSL化

フロントエンドにもSSLでアクセスしてもらう必要があるので、Apacheの後段にフロントエンドを配置します。

/etc/httpd/conf.modules.d/00-ssl.conf
<VirtualHost *:443>
    ServerName discounted-video.dyndns.org:443
    (中略)
    SSLEngine on
    (中略)
    ProxyPass /api/ http://127.0.0.1:3105/
    ProxyPassReverse /api/ http://127.0.0.1:3105/
    ProxyPass / http://127.0.0.1:3000/ ← 追加
    ProxyPassReverse / http://127.0.0.1:3000/ ← 追加
    (中略)
</VirtualHost>

Cookieのsecure対応

Cookieのsecure対応のため、NestJSの以下のsecueをtrueにしました。

main.ts
  app.use(
    session({
      // eslint-disable-next-line @typescript-eslint/no-var-requires
      store: new (require('connect-pg-simple')(session))({
        conString: configService.get('DATABASE_URL'),
        schemaName: 'video',
      }),
      secret: configService.get('SESSION_SECRET'),
      resave: true,
      saveUninitialized: false,
      cookie: {
        maxAge: 1000 * 60 * 60 * 24 * 30,
        httpOnly: true,
        sameSite: configService.get('COOKIE_SAMESITE'),
        secure: configService.get('COOKIE_SECURE') === 'true',
      },
    }),
  );

そうすると…
クライアント --- https --- Apache --- http --- Node.js
な構成ではApacheとNode.js間がhttpなのでNGということが分かりました。
ApacheでSet-Cookie Secureを加えればよいことがわかったので、加えました。

/etc/httpd/conf.modules.d/00-ssl.conf
<VirtualHost *:443>
    ServerName discounted-video.dyndns.org:443
(中略)
    Header edit Set-Cookie ^(.*)$ $1;Secure
</VirtualHost>

作ってみた感想

「公開できる」サイトのレベルはとてもあがったと思う

自分が運用しているErogameScapeというサイトは、2001年にPHPで書いたサイトです。
そこからちょっとづつ手を加えてはいるものの、モダンな作り方に置き換えることはもはや不可能であるがゆえにモダンな技術を学ぶモチベーションはまったくありませんでした。
42Tokyo最終課題でNestJSについて学んだことと、業務委託でNext.jsを学んだので、せっかくなのでその学んだことを活かしてサイトを作ってみました。

自分がErogameScapeを作ったときは、ものすごく牧歌的で、

<?php echo _$_GET['name'] ?>

と書いても、まあ…、あまり問題ではなかったり、CSSもそんなに文法は多くなく、むしろまだHTMLの属性にいろいろ書いてたり、JavaScriptもそんなに多くのことができるとは思われていなかったりしたので、学ぶことが少なく「公開できる」サイトが作れていました。
自分が今回作成したごくごく簡単な…DBからデータをもってきて加工して表示するだけの…「公開できる」サイトを作るだけでも、めっちゃ大変だな…と思いました。

このサイトと同じものを2001年に作ろうとしたら自分には絶対に無理で、昔よりいろいろなツール、ライブラリ等が整備されて簡単にサイトが作れるようになったのですが、「公開できる」サイトのレベルがめちゃくちゃあがって、サイトを公開できるまでに学ぶ必要がある量はとても増えたと思いました。

聞ける人がいなくてつらい

躓いたときに聞ける人がいなくてつらいです。
調べればなんとかなるので前へ進むことはできるのですが、それが最善なのか分からないのがとてももどかしいです。
ChatGPTに聞くと、なるほど!、と思う回答が得られることができるようになりましたが、いつでもいい回答が得られるわけではないので厳しいです。
だいぶ限界を感じましたのでTypeScriptとReact/Next.jsでつくる 実践Webアプリケーション開発を買ってまいりました。
これを読んで自分のソースと見比べようと思います。

宣伝

42 Tokyoについて

42 TokyoのCommonCoreと呼ばれる基礎課程をクリアで、今回作ったサイトくらいのものはマニュアルを見ながら自力で作れます。
joker1007さんソフトウェアエンジニアとしての能力を高める方法について

状況や対象に依って割合は変わるかもしれないが基本的にそのためにやることは3つしかないと思っている。
・出来る限り公式に近いドキュメント、もしくは信頼できる著者による書籍を読む。場合によっては論文を参照する。
・それを使ってみる。とりあえず動く小さなアプリなりツールなりが書けるのが一番良い。
・それを利用しているOSSのコードを読む。フレームワークやライブラリ自体だったら一旦動くものを書いてからコードを追う。

と書いています。
CommonCoreと呼ばれる基礎課程をクリアできている学生であれば、上記3つはできているかな…と思います。
例えば、Bashを再実装課題をこなすため、Bashのソースを読んで実装を学んでいます…、正直すごいな…と思いました。
プログラミングスクールの卒業生は使えないというイメージがあるように思っている…のですが、42Tokyoはちょっと違います、と宣伝させて頂きます。

おわりに

FANZAの動画を利用していて、お気入り女優さんで商品を検索できたらいいな…と思っている方がいらっしゃいましたら、ぜひ一度作ったサイトをご利用ください。※18禁のサイトです

15
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
22