Next.jsの基本まとめ

準備

npm init next-app <APPNAME>
npm run dev

ページの編集

/pagesの各jsxファイルがそのままページになる。
・ファイル名=パスネーム
index/

コンポーネントをexport defaultすればいい。

// 例
export default function () {
    return <h1>First Post</h1>
}
// このようにコンポーネント定義を省略できる。普通に関数またはクラスで定義してもいい。

ページ遷移(Linkタグ)

普通にaタグも使えるが、Linkタグで擬似ページ遷移できる

import Link from 'next/link'

<Link href="/">
    <>
         <a href="/">Back</a> to home
    </>
</Link>
// 空タグを使えば、不要な囲いタグを回避できる
// Link内はどこをクリックしてもLink.hrefに擬似遷移する。上の例のように、SEO目的などでaタグを設定することもできる。その場合でも、そのaタグではなくLinkのクリックが優先される。

Linkではなくaタグを使った場合は擬似遷移でなく普通に遷移する。

スタティック(静的)ファイル

プロジェクトディレクトリ/public/に置いたファイルは/ファイル名でホストされる。
メタデータとかどう設定すればいいのかな?は多分ここに書いてある
Basic Features: Static File Serving | Next.js

headタグ内へのアクセス

Headコンポーネントをレンダーすればいい。

// 例
import Head from 'next/head';

export default function () {
  return (
    <>
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <div>
        ...
      </div>
    </>
  )
}

CSSとコンポーネント

コンポーネントとCSSモジュールの準備

コンポーネント:プロジェクトディレクトリ/components/以下に任意の.jsファイルを置く。
CSSモジュール:プロジェクトディレクトリ/components/以下に任意の.modules.cssファイルを置く。

// xxx.modules.cssのファイルを用意すればCSSモジュールを利用できる
import styles from './layout.module.css';

export default function Layout({ children, arg1, arg2, ... }) {
	return <div className={styles.container}>{children}</div>
}
// children(引数)には、layoutコンポーネントの子要素がそのまま入る
// arg1, arg2...にはコンポーネント呼び出し時に任意の変数を代入できる 
// styles.クラス名 で、xxx.modules.cssの各クラスにアクセス

exportしたコンポーネントを他のコンポーネントやページで利用できる。

// ページ
import Layout from '../components/layout';

export default function () {
    return <Layout arg1>...</Layout>
}

CSSモジュールの挙動

// xxx.modules.cssのファイルを用意すればCSSモジュールを利用できる
import styles from './layout.module.css';

export default function Layout({ children }) {
	return <div className={styles.container}>{children}</div>
}
// children(引数)には、layoutコンポーネントの子要素がそのまま入る
// styles.クラス名 で、xxx.modules.cssの各クラスにアクセス

グローバルスタイリング(トップレベルコンポーネントとグローバルCSS)

トップレベルコンポーネント(グローバルApp)

プロジェクトディレクトリ/pages/直下に_app.jsファイルを置く。

// _app.js

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

これでトップレベルコンポーネントAppを定義できた。
開発サーバを起動している場合は、再起動が必要。

グローバルCSS

といっても、特別なことはない。トップレベルコンポーネントAppでimportしたCSSがグローバルCSSになる。
ただimportすればいい。

// _app.js

import '../styles/global.css'

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

importするCSSのファイル名や配置場所に制限はない。

便利なモジュール

classnames

インストール
npm install classnames
価値と利用方法

CSSモジュールを利用するには、className={styles.クラス名}でなければいけない。変数によってクラス名を変えたい場合に、このままでは面倒。

// 例
import styles from './alert.module.css'
import classnames from 'classnames'

export default function Alert({ type }) {
  return (
    <div
      className={classnames({
        [styles.success]: type === 'success',
        [styles.error]: type === 'error'
      })}
    >
      {children}
    </div>
  )
}

{[styles.クラス名]:真偽判定}で真のクラス名がリストに追加される(んだと思う)。

PostCSS (デフォルトのCSSコンパイラ)のカスタマイズ

postcss.config.jsというファイルをトップレベルに作成して PostCSS の設定をカスタマイズできる。

// Tailwind CSS を purgecss と合わせて使うときの例
// tailwindcss、@fullhuman/postcss-purgecss、および postcss-preset-env をインストールする必要がある
// autoprefixer はデフォルトで Next.js に含まれているため不要

module.exports = {
  plugins: [
    'tailwindcss',
    ...(process.env.NODE_ENV === 'production'
      ? [
          [
            '@fullhuman/postcss-purgecss',
            {
              content: [
                './pages/**/*.{js,jsx,ts,tsx}',
                './components/**/*.{js,jsx,ts,tsx}'
              ],
              defaultExtractor: content =>
                content.match(/[\w-/:]+(?<!:)/g) || []
            }
          ]
        ]
      : []),
    'postcss-preset-env'
  ]
}

詳しい書き方
Advanced Features: Customizing PostCSS Config | Next.js

Sass

npm install sassで、Sassを利用できる。
利用方法はcss同様。.sass (.scss).module.sass (.module.scss)を設置するだけ。

レンダリングとフェッチ

レンダリング

Next.jsではページ単位で3種類のレンダリングを選択できる。

  • 静的生成
  • サーバーサイドレンダリング(SSR)
  • クライアントサイドレンダリング
静的生成

ビルド時に外部データをフェッチ→レンダーしておく。

サーバーサイドレンダリング(SSR)

リクエストごとにサーバーサイドで外部データをフェッチ→レンダーする。

クライアントサイドレンダリング

通常のReactとは異なり、ビルド時に完結する部分(=外部データが不要)はプリレンダリングされる。
外部データはクライアントサイドでフェッチ→レンダーされる。

フェッチ

ドキュメント
Basic Features: Data fetching | Next.js

getStaticProps

getStaticProps中のコードはサーバーサイドでのみ実行されるし、クライアントに渡されることはない。ここで外部API叩くなりDBアクセスも可。

export default function (props) { ... }

export async function getStaticProps() {
    // ファイルシステムや API、DB などから外部データを取得する
    const data = ...

    // `props` キーに対応する値がデフォルトコンポーネントに渡される
    return {
        props: ...
    }
}
getServerSideProps

SSR用のフェッチ。

export async function getServerSideProps(context) {
  // contextにはリクエストパラメータが入る。
  return {
    props: {
      // コンポーネントに渡すための props
    }
  }
}

Contextについて
Basic Features: Data fetching | Next.js

SWR

クライアントで外部データを取得する場合はSWRが推奨らしい。

// 使用例
import useSWR from 'swr'

function Profile() {
  const { data, error } = useSWR('/api/user', fetch)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

ドキュメント
SWR: React Hooks for Remote Data Fetching

動的ルーティング

ドキュメント
Routing: Dynamic Routes | Next.js

ファイルの準備とパス変数の受取

[xxx].jsというファイル名で保存したとき、params.xxxがパス変数(文字列)となる。
[...xxx].jsというファイル名で保存したときは、以降のパス文字列すべてを変数として受け取る。params.xxx/でスプリットしたパス変数リストになる。(例:a/b/cであればparams.xxx = ['a', 'b', 'c']

// [id].js

import Layout from '../../components/layout'

export default function Post() {
  return <Layout>...</Layout>
}

export async function getStaticPaths() {
  // id としてとりうる値のリストを返す
  // リストは、以下のようにparamsキーとxxxキーを含む必要がある。
  return [
    {
      params: {
        id: 'ssg-ssr'
      }
    },
    {
      params: {
        id: 'pre-rendering'
      }
    }
  ]
}

export async function getStaticProps({ params }) {
  // params.id を使用して、ブログの投稿に必要なデータを取得する
}

動的ルーティングページへのリンク

<Link href="/posts/[id]" as="/posts/ssg-ssr">
  <a>...</a>
</Link>

fallback

未定義のパスリクエストに対するレスポンスについて。fallback : falseであれば、404を返す。fallback : trueのときは「fallbackページ」を返す。

  return {
    paths,
    fallback: false
  }

ドキュメント
Basic Features: Data fetching | Next.js

エラーページ

404ページであればpages/404.jsを置くだけでいい。

ドキュメント
Advanced Features: Custom Error Page | Next.js

APIルート

以下のような形でページコンポーネントを出力すればいい。

export default (req, res) => {
  res.status(200).json({ text: 'Hello' })
}

reqreshttp.IncomingMessagehttp.ServerResponseのインスタンスで、加えてミドルウェア、ヘルパー関数ももつ。

詳しくはドキュメント。
HTTP | Node.js v14.1.0 Documentation
HTTP | Node.js v14.1.0 Documentation
API Routes: API Middlewares | Next.js
API Routes: Response Helpers | Next.js

APIルートのコードはクライアント側のバンドルには含まれない。

デプロイ

Next.js純正プラットフォームのVercelがある。

coming soon...

Twitterログイン実装

申請

これはそのへんにたくさんブログあるので省略

OAuthの準備

requests_oauthlib

pip install requests-oauthlib

トークンの取得

POST api.twitter.com/oauth/request_token HTTP/
params
    oauth_callback: 'URL'

その他、好きなパラメータをつけられる(stateなど)

RESPONSE
params
    oauth_token
    oauth_token_secret
    oauth_callback_confirmed :Bool

リダイレクト

GET api.twitter.com/oauth/authenticate
params
    oauth_token
RESPONSE
params
    oauth_token
    oauth_verifier

情報の取得

GET api.twitter.com/oauth/access_token
params
    oauth_verifier
RESPONSE
params
    oauth_token
    oauth_token_secret
    user_id
    screen_name

MacにWebpackをいれて、Babel 7 を使えるようにする

Webpackインストール

npm install webpack --save-dev

ディレクトリ構成

こんなかんじに整える

/任意のディレクトリ
    -- package.json
    -- webpack.config.js
    -- /src
        -- /js
            -- entrypoint.js
            -- /modules

webpack.config.jsの中身

// output.pathに絶対パスを指定する必要があるため、pathモジュールを読み込んでおく

const path = require('path');

module.exports = {

    // モードの設定、v4系以降はmodeを指定しないと、webpack実行時に警告が出る

    mode: 'development',

    // エントリーポイントの設定

    entry: './src/js/entrypoint.js',

    // 出力の設定

    output: {

        // 出力するファイル名

        filename: 'bundle.js',

        // 出力先のパス(v2系以降は絶対パスを指定する必要がある)

        path: path.join(__dirname, 'public/js')

    }

};

準備完了

あとは、bundleしたいプロジェクト全体の実行ファイルをentrypoint.jsに保存すればOK。(今回の例だと、import元ファイルたちはmodulesにいれてる)

実行

npx webpack

出力先に指定した場所に、bundle.jsができてる。

Babelとの連携

前回babelをインストールしたが、webpackとの連携に必要なパッケージを追加でインストール

npm install --save-dev babel-loader babel-preset-env babel-polyfill

今はbabel-preset-es2015のかわりにbabel-preset-envになってるみたい。

webpack.config.jsにloadersを追加

// output.pathに絶対パスを指定する必要があるため、pathモジュールを読み込んでおく

const path = require('path');

module.exports = {

    // モードの設定、v4系以降はmodeを指定しないと、webpack実行時に警告が出る

    mode: 'development',

    // エントリーポイントの設定

    entry: './src/js/entrypoint.js',

    // 出力の設定

    output: {

        // 出力するファイル名

        filename: 'bundle.js',

        // 出力先のパス(v2系以降は絶対パスを指定する必要がある)

        path: path.join(__dirname, 'public/js')

    },

    module: {
        rules: [
            // {
            //   test: ビルド対象のファイルを指定
            //   includes: ビルド対象に含めるファイルを指定
            //   exclude: ビルド対象に除外するファイルを指定
            //   loader: loaderを指定
            //   query: loader に渡したいクエリパラメータを指定
            // },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/,
                query://loaderに渡したいクエリパラメータを指定します
                {
                    presets: ['@babel/preset-env']
                }
            }
        ]
    }
};

これで、webpackと同時にbabelがかかる。

まっさらなMacにBabelをインストール

環境

  • MacBook Pro ( macOS Mojave 10.14.5 -> Catalina 10.15.3 )

前提

以下はインストール済みの前提です。逆に、以下以外はインストールしてない。

やってく

必要なもの

  • nodebrew
  • node.js & npm (セット)

node.js & npmをインストール

nodebrewをインストール
brew install nodebrew

MacにNode.jsをインストール - Qiita

こんなエラーが出た場合

Error: /usr/local/Cellar is not writable. You should change the
ownership and permissions of /usr/local/Cellar back to your
user account:
  sudo chown -R $(whoami) /usr/local/Cellar
Error: Cannot write to /usr/local/Cellar

brewをアップデートしましょう。僕の場合、しばらくmacOSをアップデートしてなかったので、ここでOSアップデート -> Command Line Toolsのインストール(ターミナルでxcode-select --installでOK) -> brewのアップデートが必要でした。

node.jsをインストール

先にインストールディレクトリ/.nodebrew/src (ディレクトリ )を作っておく必要がある

mkdir -p .nodebrew/src

安定版をインストール

nodebrew install-binary stable

パスを通す

echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.bash_profile

ここに書いてある通りでOK。
MacにNode.jsをインストール - Qiita

これでnpmも一緒に入ってます。

参考
Node.js / npm をインストール (Mac環境) - Qiita
MacにNode.jsをインストール - Qiita
Node.jsのインストールに失敗する時の解決策(No such file or directory) - Qiita
Rails cが実行不可能、Permission denied @ rb_sysopen – /Users/…の対応策 – 私たちの未来への案内図

babelをインストール

の前に、作っておくべきファイルが3つ。

  • package.json
  • babel.config.json
  • .babelrc
package.json

npmでインストールしたパッケージ一覧が入るファイルです。内容はnpm install ~~するたびに自動で入っていきます。

{
    "devDependencies": {
    }
}
babel.config.json

babel.config.jsonは、環境ファイル(例えばクラスやアロー関数などの変換はプラグインを利用する必要があるので、その指定など。あと対応ブラウザとか。)を指定します。babelの実行時に毎回参照するイメージです。

{
    "presets": [
        [
            "@babel/env",
            {
                "targets": {
                    "edge": "17",
                    "firefox": "60",
                    "chrome": "67",
                    "safari": "11.1"
                },
                "useBuiltIns": "usage"
            }
        ]
    ],
    "plugins": [
        "@babel/plugin-proposal-class-properties",
        "@babel/plugin-transform-arrow-functions"
    ]
}
.bablelc

.bablelcは、よくわかんないけどなんかつくっとかないと動かない。2019/2/1現在の公式ドキュメントによればこれだけでOK。

{
    "presets": ["@babel/preset-env"]
}
インストール

いまつくった3つをインストールディレクトリ においたら、以下のコマンドでインストール。

npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/plugin-proposal-class-properties @babel/plugin-transform-arrow-functions

@babel/core : コア。これだけだと使えない。
@babel/cli: コマンドラインインターフェイス。
@babel/preset-env: 基本の環境プリセット
@babel/plugin-proposal-class-properties: クラス構文の変換に必要。
@babel/plugin-transform-arrow-functions: アロー関数の変換に必要。




参考
Babel · The compiler for next generation JavaScript
今更のバベる。 Babel 7を試してみたメモ - かもメモ

できた

ということで、次はWebpackをいれる。

全てのブラウザ向けにいいかんじにスムーススクロールを設定する

2019/2/23現在、スムーススクロール(このページのサイドバークリックしたときみたいなやつ)は、
・JavaScript(jQuery)
・CSS
のどっちかで実現します。

そう、CSSでもできるのです。ただし、ChromeとFirefoxだけ

CSS対応ブラウザ(特にChrome)は自分でjQueryで動きをつくるよりかっこいいので、

・ChromeかFirefoxの場合はCSSで
・それ以外はjQueryで

スムーススクロールするように設定しました。

CSS

まずはCSS。これはすげえ簡単。というかこんなプロパティ知らなかった。。現状、ChromeとFirefoxしか対応してません。

html{
    scroll-behavior:smooth;
}

jQuery

ChromeとFirefoxの場合はCSSで設定したスムーススクロールを邪魔しないように、それ以外のUser Agentの場合のみ有効にします。

if (!(navigator.userAgent.match('Chrome/') || navigator.userAgent.match('Firefox/'))) {
    jQuery(function () {
        jQuery('a[href^="#"]').click(function () {
            var speed = 500;
            var href = jQuery(this).attr("href");
            var target = jQuery(href == "#" || href == "" ? 'html' : href);
            var position = target.offset().top;
            jQuery("html, body").animate({
                scrollTop: position
            }, speed, "swing");
            return false;
        });
    });
}

HTML

あわせるとこんなかんじ。

<style>
    html{
        scroll-behavior:smooth;
    }
</style>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.slim.min.js"></script>
<script>
    if (!(navigator.userAgent.match('Chrome/') || navigator.userAgent.match('Firefox/'))) {
        jQuery(function () {
            jQuery('a[href^="#"]').click(function () {
                var speed = 500;
                var href = jQuery(this).attr("href");
                var target = jQuery(href == "#" || href == "" ? 'html' : href);
                var position = target.offset().top;
                jQuery("html, body").animate({
                    scrollTop: position
                }, speed, "swing");
                return false;
            });
        });
    }
</script>

今日の認証における課題(ぼくなりの認証論)

オンラインでパーソナルなサービスを利用する際に必ず必要になる「認証」。今回は認証論というか、そもそも認証ってなに、という話。

理論上、認証に利用できるもの

認証とは、「本当に私です」と証明するということ。なにをもって「証明」とするのか。

今日、一般的には次のように言われる。

  • 知識
  • 所有物
  • 生体

つまり、「その人でなければ持っていないもの」。

知識

その人しか知り得ないこと。

最も一般的なのはパスワード。ほかには秘密の質問など。

長所

最も手軽で、ハードや環境に依存しない。

短所

使い回せば危険だし、使いまわさなければ管理がくそめんどくさい。

あと、忘れる可能性が常にある。

所有物

その人しか持ち得ないもの。

オンラインバンキングに使うワンタイムパスワードカードなど。あれはFIDOと呼ばれる仕組みで、ざっくりいえばワンタイムパスワードカードの工場出荷時に乱数生成規則とアカウントを紐づけておいて、認証時に手元のワンタイムパスワードカードとサーバーで出す乱数が同じなら(=同じ規則=正規のワンタイムパスワードカード)認証完了というもの。

最近はYubiKeyGoogle Titanなど次々とエンドユーザー向けプロダクトが登場している。

サービスベンダーとして導入するには、APIを利用する。(キーデバイスごとにIDが割り振られているので、IDとワンタイムパスワードの組み合わせをAPIで問い合わせる)

あと、普通にメールアドレスとか電話番号、住所への郵送。所有者しか連絡内容を確認できないもの。

しかし「パスワードがわかればメールアドレスにアクセスできる」みたいな大元での認証がどうなっているかを考える必要がある。

長所

持ってさえいればいいので、管理がめっちゃ楽。

短所

失くす、盗まれるなど。あと、認証用のハードウェアをユーザーに持たせるのはなかなかハードルが高い。オンラインバンキングなどのセキュリティが重要なものか、組織内利用くらいの規模でないと厳しい。

あと、ワンタイムパスワードの場合、YubiKeyなどの外部サービスを利用すれば依存になるし、利用しなければ相当管理運用が大変。

所有物へのアクセスにパスワードなどを利用している可能性がある。本当に信頼できる所有物か考える必要がある。

生体

指紋認証、顔認証、虹彩認証、声紋認証、静脈認証など。

将来的には一番有望視されてると思う、が、現状はまだ技術が追いついてない。でもマシンラーニングによってもうすぐ実用的になるのでは?

現状、AppleのTouch IDやFace IDが最強だと思う。

長所

忘れない。なくさない。盗まれない。

短所

技術依存。いまのところ、ハード依存。(必要なハードがまだコモディティ化してない)

現実世界の「人-人」の「認証」って

さて、ところで、現実世界でぼくらは毎日「目の前にいる人は〜〜さんだ」と「認証」して、大事な情報をべらべら喋っている。

この「認証」には何を利用している?

生体

まずは当然、生体。

顔。声。

知識

それから、前回会ったときに自分がみた「彼」との整合性。

喋る内容、動きなど。

「複合的」な認証

現実世界でぼくらが人を認証するとき、単一の基準(顔だけ、など)でやっているわけではない。

たとえば「顔だけ」なら整形手術でごまかせるかもしれない。

でも、「顔」と「声」みたいに組み合わせれば、一気に認証精度が高まる。

単一の認証に頼るのは危険

認証はこれらのうち最低2つ以上を組み合わせて行うべき。そして可能なら「知識&知識」とかではなく「知識&所有物」みたいに違う種類のものを組み合わせる。

  • 知識:パスワード、秘密の質問
  • 所有物:ワンタイムパスワード、メールアドレス、電話番号、郵送
  • 生体:指紋、顔、虹彩、声紋、静脈

ユースケース

一般的に利用されている認証の組み合わせ。

メールアドレス&パスワード&電話番号

最も一般的かと思う。電話番号というのはだいたいは2段階認証の際に利用される。

手軽だが、メールアドレスには普通はパスワードでアクセスする。それに、電話番号へのアクセスは意外と簡単。電話回線キャリアに電話してうまく「本人だ」と思わせればアクセスできる。
参考:NISTが警告、SMSでの二段階認証が危険な理由 - ZDNet Japan

メールアドレス&パスワード&ワンタイムパスワード

オンラインバンキングなど。

なかなかバランスがとれているが、「ワンタイムパスワードなくしたとき問題」は解決できていない。

パスワード or 顔認証

iPhone Xシリーズのロック解除に利用されている。手軽かつセキュアで、かなり勝手はいい。

しかし、双子がロック解除できてしまう問題のほか、普通にパスコードを忘れたら詰むのがiPhoneの伝統的な仕様。

あと、ハード依存。

人かロボットか

ここまでは、認証の対象は「人」が前提だった。

それ以前に「人かロボットか」を見分けることも重要。

だが、この部分の話は本質的でないと思う。結局ロボットを動かしているのは(マトリックスの世界にならない限り)人。

「人とロボットの違いを見分ける技術」というのは結局は「人っぽく見せる技術」とのいたちごっこにしかならない。

「本人か、それ以外か」で考えていいと思う。

結局は確率論

生体とか所有物とかいろいろ工夫してはみても、結局はこれらをすべてバイナリデータにして認証可否の判断をすることになる。

ので、どうやっても「たまたまぴったり正しい数値を入力できてしまった」という可能性は0にできない。

が、めちゃくちゃ低い確率にすることはできる。1回のトライでランダムな値を渡して通る確率をめちゃくちゃ下げて、あとは複数回のトライを制限すればいい。

Google Cloud DatastoreをGoogle App Engineから利用する

前提知識はProgateでのPythonレッスン修了レベルです。Pythonで説明しますが、他の言語でも読み替えられると思います。

Google Cloud Datastoreとは

Google Cloud Platformの誇る最先端のデータベースです。
Google Cloud Datastore の概要  |  Google Cloud

これまでデータベースといえばSQL(Progateでも習えますね)でしたが、今はNoSQLといわれる、つまりSQLでないデータベースがあります。SQLと違ってAPI的に簡単に扱えるのがメリットです。

ちなみにGoogle Cloud PlatformにはNoSQL・SQLともにいろいろな種類があります。

公式ページに比較があります。想定される用途なども書いてあるので、これを見て選べばいいでしょう。
ストレージ オプションの選択  |  Google Cloud

ユーザーデータなどのシンプルで低容量のデータベースにはGoogle Cloud Datastoreがぴったりでしょう。

構造

f:id:simonsnote:20180531150337p:plain:w600

Key-Value型というらしいです。データベース全体を辞書型と思えばわかりやすいですね。

Propertyはいくつでも作れます。また、ひとつのエンティティの中で、ひとつのPropertyに対して複数のValueを持てます。しかもそれらは同じ型である必要はありません。

Propertyに設定できる型

文字列、数値、他のEntityへのリンクをはじめとしていろいろな型を利用できます。こちらの公式ドキュメントにリストがあります。
Entity Property Reference  |  App Engine standard environment for Python  |  Google Cloud

Keyだけは文字列(string)か整数(integer)である必要があります。

また、当然ながら「key」という名前のPropertyをつくることはできません。

また__(アンダースコア2つ)ではじまるProperty名はシステムに予約されているので自分でつけることはできません。

GAEからGoogle Cloud Datastoreを利用する方法

Google App EngineではデフォルトのデータベースとしてGoogle Cloud Datastoreを利用できるようになっています。

同じGCPアカウント間でのやりとりなので認証が不要です。ライブラリに定義されているメソッドを実行するだけでOK。これはかなり楽ですね。

import

ライブラリ(パッケージ)は前回の手順でセットアップしていればあとはimportするだけです。

from google.appengine.ext import ndb

なおndbの他にdbというライブラリもありますが、ndbの方が新しいライブラリになります。いまからわざわざdbを使い始めるメリットはありません。
ndbdbの対照表はこちらをご覧ください。

データベースへのアクセス

公式リファレンスはこちらです。
Creating, Retrieving, Updating, and Deleting Entities  |  App Engine standard environment for Python  |  Google Cloud

新しいデータを格納する

以下のようなステップになります。

  1. データベースを表すクラスを作成する(クラス名=Kindとなる)
  2. 作成したクラスのインスタンスを作成する(インスタンス=Entityを示す)
  3. インスタンス変数(=Property)に値(Value)を入れる
  4. 作成したインスタンスをGoogle Cloud Datastoreへ送信する

まずデータベースそのものをあらわすクラスを定義しましょう。クラス=kindとなります。
ndbライブラリに定義されているModelクラスを継承します。今回は「People」というkindを作ってみましょう。

class People(ndb.Model):

そうしたら、その際にPropertyも同時に定義します。今回は名前と生年月日をPropertyとして保存できるようにしてみましょう。
Propertyにはそれぞれ型を指定します。今回は、

  • name:文字列(str)型
  • birthday:日付(datetime.date)型

というようにします。型の指定にはライブラリに定義されたメソッドを使います。指定したい型ごとにメソッドが決まっているので、こちらのリファレンスから選んで使いましょう。

class People(ndb.Model):
    name = ndb.StringProperty()
    birthday = ndb.DateProperty()

これで、こんなイメージのクラスができあがりました。
f:id:simonsnote:20180531165923p:plain:w400

そうしたらEntityをつくりましょう。インスタンスがEntityそのものとなります。

taro = People(name='太郎', birthday=datetime.date(1956, 7, 3))
yoshiko = People(name='よし子', birthday=datetime.date(1923, 1, 1))

あとは作成したインスタンスをGoogle Cloud Datastoreに送信すれば完了です。
これはput()メソッドひとつで完了。ちょう楽。

taro.put()
yoshiko.put()
データベースからデータを取得する(Keyがわかっている場合)

Keyがわかっていればとても簡単にデータベースにアクセスできます。

なお、keyが分かっている場合というのはそんなにないはずです。上でEntityを作成したときにKeyの話がでてきませんでしたね。Entity作成時にKeyを特に指定しなかった場合、Keyは自動で生成されます。Keyを指定してEntityを作成することもできるのですが、よほど自信があるのでなければおすすめしません。なぜならKeyは完全にユニークである必要があり、また変更できないからです。

Keyがわかっていればデータの取得はとても簡単です。

michael = キー.get()

これで、変数michaelはEntityそのものを表すインスタンスになりました。

PropertyのValueを取得してみましょう。

print( michael.name )
#出力結果:マイケル

michaelの名前を変更してデータベースを更新してみましょう。

michael.name = 'マイケール'
michael.put()
データベースからデータを取得する(Keyがわからない場合)

Keyが分かっていない場合は3段階の手順が必要です。

  1. クエリを作成する
  2. フィルタでクエリの範囲を絞り込む
  3. 作成したクエリを.get()もしくは.run()する

まずはクエリを作成します。クエリとは「こんな性質のEntity」というイメージです。作成したクエリを.get()or.run()すれば、該当するEntityがGoogle Cloud Datastoreから返されます。

query_for_taro = People.query(People.name == '太郎')

これでKindPeopleのEntityのうち、name太郎である集団のイメージができました。

今回は太郎は一人しかいない自信があります。一発で目的のEntityを掴めるという自信があるなら、次のフィルタは特に必要ありません。

もし太郎が複数存在し、今回はbirthday1956-7-3太郎だけが欲しいような場合は、さらにフィルタをします。

query_for_taro = query_for_taro.filter(People.birthday == datetime.date(1956, 7, 3))

ちなみに比較演算子は==以外も利用できますし、ANDORのような表現も利用できます。詳しくはこちらのドキュメントをご覧ください。

クエリを作成できたら、それをGoogle Cloud Datastoreに送信して該当するEntityを受け取ります。
このときに.get()メソッドか.run()メソッドのどちらかを利用します。

.get()メソッドは該当するEntityのうち、最初のひとつのみを返します。

taro = query_for_taro.get()

つまり、返されるのはEntityそのものを表すインスタンスです。(対象のEntityがない場合はNone(PythonではnullではなくNoneといいます)が返ります)

.run()メソッドは該当するEntityすべてをリスト(Pythonでは配列ではなくリストと呼びます)で返します。

list_of_taros = query_for_taro.run()

つまりこういうことです。

query_for_taro.get() == query_for_taro.run()[0]
#この式は常にTrue

.run()メソッドは引数をつけなければ該当するEntityをすべて返します。返す数の上限を設定するには引数を利用します。

list_of_taros = query_for_taro.run(limit=10)
#list_of_tarosには最大で10個しか入らない

ちなみに、.run()とよく似た.fetch()というメソッドもあります。

違いはキャッシングのみです.fetch()はクエリに結果をキャッシュさせます)。Googleでは特に理由のない場合は.run()を推奨しています。

EntityのPropertyを更新する

Propertyを更新するには、取得・または作成したEntityオブジェクトのインスタンス変数を変更して.put()すればOKです。

class People(ndb.Model):
    name = ndb.StringProperty()

#taroはKindがPeopleでnameが'太郎'のEntity
taro = People.query(People.name == '太郎').get()

#taroのnameを'三郎'に変更
taro.name = '三郎'

#変更を反映
taro.put()

以下の方法も利用できます。変数でPropertyを指定したいような場合に便利です。

#taroはKindがPeopleで'name'が'太郎'のEntity
taro = People.query(People._properties['name'] == '太郎').get()

NDB Queries  |  App Engine standard environment for Python  |  Google Cloud

データベースからEntityを削除する(Keyが必要)

データベースからEntityを削除するにはKeyが必要です。Keyの取得方法はあとで説明します。

ちなみにEntity自体は削除せずいずれかのValueのみ削除するならデータベースからデータを取得するの応用でできますね。インスタンスの特定のPropertyにNoneを代入して.put()すればいいのです。

Entity自体を削除するには.delete()メソッドを使います。

taro.キー.delete()

ちなみに.delete()メソッドの返り値は常にNoneです。

その他のオペレーション

ここから下は必要な時にリファレンスがわりにみてもらえればいいです。ここまでの内容でもう十分に利用できます。

Keyの取得

実はkeyとはKeyクラスのインスタンスです。

Keyクラスは最低2つのインスタンス変数を持ちます。

Key(kind, id)

kindにはそのEntityが所属するKindが入ります。ここまでの文脈では「Key」という言葉を「Entity固有の文字列か整数」と説明してましたが、これはidのことです。

.get()などでEntityを示すインスタンス(Modelクラスのインスタンス)を取得できていれば、インスタンス変数.keyでkeyを取得できます。

キー = インスタンス.key

また、クラス名(kind)やidも取得できます。

クラス = インスタンス.key.kind()
id = インスタンス.key.id()

Keyクラスについて詳しくはこちらのリファレンスをご覧ください。
dbライブラリのリファレンスなので注意してください。このページの最後にndbdbライブラリ対照表へのリンクを張っているので、必ずそこでndbライブラリに変換して利用してください。

他のEntityへのリンク

Kindが異なる場合を含め、他のEntityへのリンクをPropertyに保有できます。Modelクラスのインスタンス(kindを示すインスタンス)を定義する際に.KeyProperty(kind)メソッドでインスタンス変数を定義しておきましょう。

class People(ndb.Model):
    film_key = ndb.KeyProperty(Films)

このときfilm_keyはKindがFilmsのEntityのKeyです。

例えば以下のコードではEntitytaroからEntitydeath_in_Veniceの特定のPropertyを取得することができています。

例:太郎の好きな映画の監督の名前を表示したい

class Films(ndb.Model):
    director_name = ndb.StringProperty()

class People(ndb.Model):
    name = ndb.StringProperty()
    favorite_film_key = ndb.KeyProperty(Films)


death_in_Venice = Films(director_name='Visconti')

taro = People(name='太郎', favorite_film_key=death_in_Venice.key)


death_in_Venice.put()
taro.put()


#↑このような状態になっているとして


taros_favorite_film = taro.favorite_film_key.get()
#taro.favorite_film_key == death_in_Venice.key なので、taro.favorite_film_key.get() == death_in_Venice

print( taros_favorite_film.director_name )
#出力結果:Visconti

実は、上のfavorite_film_keyにあたる専用の変数が最初から用意されています。Modelクラスにはparentという引数が用意されているので、以下のようにいきなり指定してしまえば大丈夫です。

class Films(ndb.Model):
    director_name = ndb.StringProperty()

class People(ndb.Model):
    name = ndb.StringProperty()


death_in_Venice = Films(director_name='Visconti')

taro = People(name='太郎', parent=death_in_Venice.key)

death_in_VeniceparentにもつEntityを選ぶには.query()メソッドにancestorという引数を入れます。

query = People.query(ancestor=key_of_death_in_Venice)

NDB Queries  |  App Engine standard environment for Python  |  Google Cloud

KindのEntityをすべて取得する

.query()メソッドに引数をいれなければKindに所属するすべてのEntityが含まれます。

all_people = People.query().fetch()
#run()は利用できないようです
Entityの順番の指定

クエリは条件にあてはまるすべてのEntityを含みます。なのでその中で順番を指定したいこともありますね。

順番の指定は.order()メソッドを使います。Propertyごとに昇順(asc)・降順(desc)で指定できます。

例えばkindがPeopleのEntityを全て取得したいが、その順番は

  1. まずnameについて昇順
  2. nameが同じ場合はbirthdayについて降順で並べる
という場合は以下のようにします。

query = People.query().order(People.name, -People.birthday)

降順の場合は-(マイナス)をつけるわけですね。

特定のPropertyのみ取得する

これまで扱ってきたクエリやフィルタでは、「あるPropertyについて特定のvalueをもつEntity」という絞り込み方をしてきました。

つまり、例えばPeopleのうちnamebirthdayについてのみ興味がある場合でも、それ以外のfavorite_filmとかageとかのPropertyも含んだ結果が返されます。

もちろんだからといって困ることはないですね。しかし不要なPropertyは最初から除外してもらうことでパフォーマンスが向上(値が返されるまでの時間の短縮、Google Cloud Datastoreの使用料の節約)します。

これはProjection Queriesという機能で実現できます。
.run().fetch()メソッドにはprojectionという引数があるので、ここに指定したいPropertyをいれればOKです。

list  = People.query().run(projection=[People.name, People.birthday])

projectionはリストです。ひとつでもprojection=[People.name]のようにします。

ちなみに以下のような書き方も可能です。

list = People.query().run(projection=["name", "birthday"])

Projection Queriesについて詳しくはこちらのリファレンスをご覧ください。

リストPropertyに特定の値が含まれるEntityをfetchする

comingsoon
NDB Queries  |  App Engine standard environment for Python  |  Google Cloud

Expandoクラス

これまでKindを示すクラスはModelクラスを継承して作成していました。実はもうひとつExpandoというクラスがあります。

ExpandoクラスはModelクラスの子クラスです。

Modelクラスを継承して作成したクラスのインスタンス変数はすべて固定Property(Fixed Property)でした。つまり、作成したPropertyに入るvalueの型は固定で、またクラス作成時に定義しなかったインスタンス変数は利用できません。

class People(ndb.Model):
    name = ndb.StringProperty() #nameにはstring型しか入れられない


taro = People(name=['Tanaka', 'Taro']) #エラー:nameにはlist型は入れられない。

yoshiko = People(age=23) #エラー:インスタンス変数ageは未定義

Expandoクラスを利用すると、上の2つとも可能になります。つまりクラス作成時にインスタンス変数を定義する必要がなく、後から自由な型でインスタンス変数を作成できます。

class People(ndb.Expando):
    pass


taro = People(name=['Tanaka', 'Taro']) #list型の変数nameを作成

yoshiko = People(age=23) #integer型の変数ageを作成

yoshiko.favorite_food = 'ラーメン' #yoshikoの新しいPropertyとしてfavorite_foodを追加

taro.favorite_food = ['カレー', 'パッタイ'] #同じ名前のPropertyでも異なる型を利用可能

このような動的なPropertyをDynamic Propertyといいます。Dynamic PropertyはExpandoクラスのインスタンスでしか利用できません。

それぞれのDynamic Propertyはvalueが代入されたタイミングで新たに生成され、それまでは存在しません。

Dynamic Propertyは削除できます。

del taro.favorite_food

ExpandoクラスでもModelクラス同様にFixed Propertyも定義できます。

class People(ndb.Expando):
    age = ndb.IntegerProperty()


taro = People(age=67, name='Taro') #Fixed Propertyであるageにvalueを代入しつつDynamic Propertyとしてnameを作成

yoshiko = People(age='23歳') #エラー:ageはFixed Propertyとしてinteger型で定義しているので、string型は入れられない

del taro.age #エラー:Fixed Propertyは削除できない

Expandoクラスについて詳しくはこちらのリファレンスをご覧ください。
dbライブラリのリファレンスなので注意してください。このページの最後にndbdbライブラリ対照表へのリンクを張っているので、必ずそこでndbライブラリに変換して利用してください。

その他・詳細

クエリの正体はQueryクラスのインスタンスです。

クエリに対して利用可能なメソッド(.get()など)の詳細はこちらのリファレンスをご覧ください。
dbライブラリのリファレンスなので注意してください。このページの最後にndbdbライブラリ対照表へのリンクを張っているので、必ずそこでndbライブラリに変換して利用してください。

デプロイ

アプリケーションのデプロイの際に、index.yamlファイルを一緒にアップロードする必要があります。これがない場合、存在しないKindに対して.query()をかけるとエラーになります。

なお、Admin Server (dev_appserver.py app.yamlで起動するローカルの開発環境) ではこの作業は必要なく、index.yamlはそれぞれのKindに対しての初回の.query()時に自動で生成・更新されます。

このAdmin Serverで自動生成されたindex.yamlをそのままアップロードすることもできますし、手動でindex.yamlを編集することもできます。

いずれにしろ作成したindex.yamlをデプロイ(実際に稼働するアプリケーションにアップロード)するには、ローカルのアプリケーションフォルダの直下(dev_appserver.py app.yamlを行うのと同じ場所)で以下のコマンドを入力します。

gcloud datastore create-indexes index.yaml

なおDatastore Indexesについてはこちらが公式リファレンスになります。手動でindex.yamlを設定する場合はこちらを参照してください。
インデックス設定  |  Cloud Datastore ドキュメント  |  Google Cloud
gcloud ツールを使用したアプリケーション テストとインデックス管理  |  Cloud Datastore ドキュメント  |  Google Cloud

補足:リファレンスについて

2018/6/1現在、Google Cloud Datastoreの公式ガイドはほぼ英語です(概要のページなど一部は日本語もあり)

Cloud Datastoreのガイドやドキュメントは正直みにくいです。ライブラリはndbが新しい方でdbは古い方です。それぞれドキュメントも別になっているのでndbをの方をみようとするのですが、実はdbから変更のない部分については書いていなかったりします。ややこしい。

なので公式ドキュメントをみる際はndbのページを中心に、書いていないことがあればdbを参照しつつ対照表でndbに変換してください。めんどいですね。

左側のグローバルメニューに全てのページが並んでいるので、根気強く探してください。基本的には下に貼ったページより下の階層に全部あるはずです。

リンクはPython用ですが、ページ上部に他の言語への切り替えボタンがあるはずです。

Google App EngineからGoogle Cloud Storageを利用する

Google App Engine(Pythonスタンダード環境)からAPIでGoogle Cloud Storageにファイルを保存・取り出しをします。

Cloud Storageは写真や動画などリッチメディア用です。文字列などの通常のデータベースはCloud Datastoreを利用しましょう。
Google Cloud DatastoreをGoogle App Engineから利用する - simon's note
Google Cloud Datastoreを利用したサーバーをFlaskでつくる [GAE] - simon's note

このページの内容はこちらの公式リファレンスを噛み砕いたものです。
Reading and Writing to Google Cloud Storage  |  App Engine standard environment for Python  |  Google Cloud

上のリファレンスはwebapp2前提でわかりにくいです。GitHubに上がっているサンプルコードをみて理解しました。

Google Cloud Storageとは

Google Cloud Platformのストレージサービスです。Google Driveみたいに物置としても使えるし、ブログの写真やスクリプトファイル置き場としても使えます。もちろんAPIを利用してWebアプリからアクセスできます(それが主用途ですよね)。

App Enigineから利用可能なのはBlobstoreというのもありますが、現在はCloud Storageの方が推奨されているようです。今から使うならCloud Storageにしましょう。

最初の5GBのみ無料で利用できます。

バケット

Cloud Storageはバケットごとに管理できます。バケットとはフォルダの上位のようなもので、基本的に用途ごとにバケットを使い分けます。(例:ブログA用のバケット、App Engine用のバケット、バックアップ用のバケットなど)

バケットごとにストレージクラスを選択できます。ストレージクラスの違いはおおまかにいえば想定しているアクセス頻度です。
ストレージ クラス  |  Cloud Storage ドキュメント  |  Google Cloud

2018/6/13現在4種類のストレージクラスがありますが、通常の利用であればMulti-RegionalRegionalになります。この2つの違いは想定するアクセス元の地域です。Multi-Regionalは世界中からのアクセスを想定しています。

NearlineColdlineはバックアップ用です。

前提知識

Google App EngineでHello Worldくらいはできるものとします。できない場合はまずこちらを読んできてください。
GAEではじめてのFlaskアプリをつくる[Progate修了レベル] - simon's note

Cloud Storageを有効化する

バケットの作成

まずはApp Engine用のバケットを作成しましょう。

GCPコンソール→App Engine→Settingsのページで、'Default Cloud Storage Bucket'の下にある'Create'をクリックしましょう。

'Create'のかわりにApp EngineアプリのURLが表示されていれば既に有効化されています。それをクリックしてください。

これでApp Engine用のCloud Storageバケットが作成されました。App Engineからのファイルの読み書きはそのバケットに行うことになります。

デフォルトではアプリからそのバケットへの読み書きは100%許可されています。変更する場合はGCPコンソールのIAMから行います。
Google Cloud Platform Console  |  Cloud Storage ドキュメント  |  Google Cloud

ライブラリをダウンロードする

Cloud Datastoreと違って、手動でライブラリをダウンロードする必要があります。

方法はpipgit、もしくはGitHubからのダウンロード(クリックするとzipファイルがダウンロードされます)のいずれかです。
Setting Up Google Cloud Storage  |  App Engine standard environment for Python  |  Google Cloud

まあ、App Engineアプリのフォルダのlibフォルダ内に置くだけなので。

pipの場合はまずはApp Engineアプリのフォルダに移動

cd AppEngineアプリのフォルダのパス

lib/にライブラリをインストール

pip install GoogleAppEngineCloudStorageClient --target lib

Pythonファイルでのコード

ここからはPythonファイルでのコーディングのみとなります。

準備

まずはCloud Storageを利用できるように準備をします。

import

先ほどダウンロードしたcloudstorageをimportします。

import cloudstorage
バケット名の自動取得

Cloud Storageへのアクセスは常に/バケット名/ファイル名のようにパスを渡す必要があります。

このバケット名は普通にGCPコンソール→Storage→Browserで見られるので、平文での指定でも全く問題ありません。

上の「Cloud Storageを有効化する」のくだりで有効化したApp Engineデフォルトのバケットを指定する場合は、自動でバケット名を取得できます。...とはいえ平文で指定した方が早い気がするし、使いどころはよくわからないですが...

この場合はosapp_identity (from google.appengine.api)をimportしておきます。

import os
from google.appengine.api import app_identity
bucket_name = os.environ.get('BUCKET_NAME', app_identity.get_default_gcs_bucket_name())
RetryParamsの設定

RetryParamsとはCloud Storageとのセッションタイムアウトとリトライ(再接続)などの設定です。

以下のように設定します。引数以外はコピペでOK。

retryparams_instance = cloudstorage.RetryParams(引数)
cloudstorage.set_default_retry_params(retryparams_instance)

引数は以下の通り。

initial_delay (default=0.1)
リトライの前に待つ秒数。 Cloud Storageのサーバーが準備できるまで待ってあげると読み込み成功率が上がります。
backoff_factor (default=2.0)
backoffレート。接続に失敗した際に定期的にリトライする間隔を設定します。詳しくはbackoffについて
max_delay (default=10.0)
リトライまでの最大秒数。
min_retries (default=3)
リトライの最小回数。
max_retries (default=6)
リトライの最大回数。リトライをしたくない場合はこれを0にします。
max_retry_period (default=10.0)
リトライまでの最大秒数。
max_delay (default=30.0)
一度のリクエストに対する全てのリトライに使う最大時間。min_retriesを満たした状態でこの秒数が経つとリトライをやめます。
urlfetch_timeout (default=None(=5秒))
UrlFetchでCloud Storageにアクセスするときに待つ最大秒数。これを過ぎるとタイムアウトエラーを返します。デフォルトはNoneですが、この場合は5秒(default UrlFetch deadline)。最大で60秒まで設定可。

どんな具合に設定したらいいかイメージが湧かないと思いますが、サンプルコードではこうなってます。

retryparams_instance = cloudstorage.RetryParams(initial_delay=0.2, max_delay=5.0, backoff_factor=2, max_retry_period=15)
cloudstorage.set_default_retry_params(retryparams_instance)

見ての通り、ここで設定したのはデフォルト値です。Cloud Storageへのアクセスの際にまた設定できます。

...つまり場面によって数値を調整してチューニングしてくれということですね。よくわかんないですがいろいろ試してるうちにコツがつかめてくるのかも?

RetryParamsクラスについて詳しくはこちらのリファレンスをどうぞ。
The RetryParams Class  |  App Engine standard environment for Python  |  Google Cloud

ファイルの読み・書き

Cloud Storageへのアクセスの手順は以下の3段階になります。

  1. cloudstorage.open()でインスタンスを作成
  2. 作成したインスタンスに.write()で書き込み・.read()で読み込み
  3. .close()で完了(ここで変更を保存)

1.インスタンスの作成

まずはcloudstorage.open()でインスタンスを作成します。ここでほとんどの操作をしてしまうし、読み込みか書き込みかもここで決めてしまいます。
引数は以下の通りです。

filename (必須)
/バケット名/beatles/revolver/taxman.mp3のようにバケット名に続けてディレクトリを含むファイルパスを指定します。ここでディレクトリ階層もファイル名も拡張子もすべて指定してしまうんですね。ちなみにこの場合実際のファイルURLはhttps://storage.googleapis.com/バケット名/beatles/revolver/taxman.mp3になります。
mode (default='r')
読み('r')か書き('w')で指定。新規ファイルの作成と既存のファイルの書き換えはどちらも'w'
content_type (default='binary/octet-stream')
mode = 'w'の時のみ有効。ファイルのMIMEタイプを指定。
options :辞書型
mode = 'w'の時のみ有効。ファイルのメタデータを指定。x-goog-acl, x-goog-meta-, cache-control, content-disposition, content-encodingを指定可。

x-goog-aclACLを設定します。これはIAMを補完する追加のアクセス制限です。指定しない場合は誰でもアクセス可能となります。

x-goog-meta-には'x-goog-meta-birthday':'1972-07-04'のように好きなメタパラメータを設定できます。

以下のように辞書型で指定します。
例:options={'x-goog-acl':'private', 'x-goog-meta-john':'lennon', 'x-goog-meta-paul':'mccartney'}
read_buffer_size (default=recommended) :integer型
mode = 'r'の時のみ有効。バッファサイズを指定。指定しないのが推奨されているので、よくわからなければ無視でOK。
retry_params (default=None)
指定しない場合は最初に設定したデフォルトのRetryParamsが適用されます。この処理の間だけ変更点がある場合は新たにRetryParamsインスタンスを作成してここに引数として渡します。
例:retry_params=cloudstorage.RetryParams(backoff_factor=1.1)
ということで、インスタンスの作成はこんな感じになります。

retryparams_w = cloudstorage.RetryParams(backoff_factor=1.1)
cloudstorage_file = cloudstorage.open(filename='/バケット名/beatles/revolver/taxman.mp3', mode='w', content_type='audio/mpeg', options={'x-goog-meta-composer': 'George Harrison', 'x-goog-meta-published': '1966'}, retry_params=retryparams_w)

mode = 'r'で指定したファイルが存在しない場合cloudstorage.NotFoundErrorエラーが返るので、mode = 'r'の時はエラーキャッチが必要です。

2-w.ファイルの書き込み

ファイルの書き込みはさっき作ったインスタンスに.write(書き込み内容)するだけです。

cloudstorage_file.write('abcdefgh')
2-r.ファイルの読み込み

ファイルの読み込みはさっき作ったインスタンスに.read()を行い、返り値を保存します。

file_content = cloudstorage_file.read()
3.close()

処理が終わったら必ず.close()を行います。ここで初めて変更が保存されます。

cloudstorage_file.close()

複数のファイルを一度に読み込む

cloudstorage.open()の戻り値は単一のファイルですが、複数のファイルを一度に読み込みたい場合はcloudstorage.listbucket()を利用します。
引数は以下のようになります。

path_prefix (必須)
呼び出すファイルたちを含む親階層。例えばpath_prefix='/バケット名/beatles/revoler'としたら、そのディレクトリ以下のファイル全てが対象になります。プレフィックスなので'/バケット名/beatles/revoler/tax'としておいて、あとで'/バケット名/beatles/revoler/taxman''/バケット名/beatles/revoler/taxwoman'と繋げることも可。
marker (default=None)
上のpath_prefixで対象に含まれるうち、除外したいプレフィックス。例えばpath_prefix='/バケット名/beatles/revoler/tax'のときにmarker='/バケット名/beatles/revoler/taxman'とすると、'/バケット名/beatles/revoler/taxwoman'は対象に含まれるが'/バケット名/beatles/revoler/taxman〜〜〜'は除外されます。
max_keys (default=None) :integer型
返されるファイルの最大数。
delimiter (default=None) :string型
ここに指定した文字列でパスを区切って階層構造にして返します。
retry_params (default=None)
.write()と同様。
返り値はGCSFileStatオブジェクトのイテレータです。

使い方はリファレンスにサンプルが載っているので参考にしてください。

Cloud Storageのファイルを削除する

cloudstorage.delete()を利用します。引数は以下の通り。

filename (必須)
.write()と同様。
retry_params (default=None)
.write()と同様。

ファイルが存在しない場合cloudstorage.NotFoundErrorエラーが返るので、エラーキャッチをします。

try:
    cloudstorage.delete('/バケット名/beatles/revoler/taxman.mp3')
except cloudstorage.NotFoundError:
    pass

エラーキャッチ

上で紹介したcloudstorage.NotFoundError以外にもタイムアウトエラーや認証エラーなどの可能性が常にあるので、基本的にエラーキャッチは万全にしておきましょう。
Google Cloud Storage Errors and Error Handling  |  App Engine standard environment for Python  |  Google Cloud



テスト

localhost:8080を開いてDevelopment serverでテストをする際は、Datastoreと違ってCloud Storageは仮想環境でのシミュレートができません。そのためテスト時も実際のバケットとのやりとりとなります。

なので、dev_appserver.py app.yamlに以下のようにオプションを付ける必要があります。

dev_appserver.py app.yaml --default_gcs_bucket_name バケット名

リファレンス

Google App Engine公式リファレンス(英語)
Setting Up Google Cloud Storage  |  App Engine standard environment for Python  |  Google Cloud
Reading and Writing to Google Cloud Storage  |  App Engine standard environment for Python  |  Google Cloud

サンプルコード(これをみないとわけわかんないです)
appengine-gcs-client/main.py at master · GoogleCloudPlatform/appengine-gcs-client · GitHub

ドキュメント
Google Cloud Storage Client Library Functions  |  App Engine standard environment for Python  |  Google Cloud

Google Cloud Storageリファレンス(日本語!)
Cloud Storage ドキュメント  |  Cloud Storage  |  Google Cloud

詐欺防止を判断力に頼ってはいけない

詐欺対策で最も重要なのは、個人の判断力に依存しない手順を徹底することです。

こちらのエントリーを読みましたが、最近僕の母も怪しげな電話にひっかかりそうになり慌てました。

さて、母と言いましたが他人事ではありません。詐欺の手口は日々アップデートされているし、20代の僕でもうっかりひっかかりそうになることがあるくらいには巧妙化しています。

しかしながら(ほぼ)確実に詐欺にひっかからない方法があります。

判断力とかは関係なく、以下の手順を徹底するだけです。自分自身や親御さんに共有して徹底させてほしいです。

向こうからの電話・メールには一切の情報を提供しない

大前提は、「向こうからの電話・メールには一切の情報を提供しない」です。

登録していない電話番号から電話があり、たとえば「警察です」と言われたところでその保証はどこにあるのでしょうか。

こちらの情報を伝える必要がある場合は必ず「折り返します」と言って一旦切ります。このとき相手方の言う連絡先を聞いても全く意味はありませんね。自分でインターネットで相手の名乗った団体の連絡先を調べます。

これは電話の内容に心当たりがあっても必ず行います。心当たりがある場合が一番ひっかかりやすいのです。相手にも手間をかけますが、仕方ありません。この時代にここで嫌な顔をするようだったら相手方のリテラシー不足です。

相手の連絡先をインターネットで調べる

なりすましでないことを確かめるために、相手が名乗った団体をインターネットで検索しましょう。

ここで以下の点に注意します。

聞いたことのある名称か

「〜〜センター」みたいな存在するかどうかよくわからない団体の場合はネットで検索する前に警察に電話して存在を確認しましょう。

「アマゾン〜〜センター」とか「fc2〜〜センター」みたいな知っている団体を含む場合は、その団体のトップページ(「アマゾン」「fc2」のトップページ)を探します。

正しいサイトであることを確認する

調べたサイトが本物であることを確認します。※スマートフォンからだと確認できない場合があります

これには「SSL証明書」を確認します。

ブラウザのアドレスバーの鍵マークもしくはhttps://と書いてある部分をクリックします。
f:id:simonsnote:20180614112138p:plain:w400

するとそのサイトのSSL証明書の情報が表示されるので、ブラウザごとに手順は違えど「詳細をみる」みたいな操作をします。
f:id:simonsnote:20180614112152p:plain:w400
f:id:simonsnote:20180614112202p:plain:w400
証明書の内容が表示されたら、Organization(組織)の部分をチェックしましょう。ここにそのウェブサイトを運営する団体の名称が表示されています。
f:id:simonsnote:20180614112230p:plain:w400
ここに表示されている内容はSSL証明書の発行機関(認証局といいます)により確認されているものなので、客観性があります。なりすましはできません。

この手順はフィッシングサイトを見破るのにも効果的です。

なお、Organizationの部分が表示されていない、またはそもそもブラウザのアドレスバーに鍵マークがなかったりhttp://から始まっているような場合は、そのサイトの運営者の保証は一切ありません。そのサイトを信頼してはなりません。大きな団体の正規のウェブサイトであればほぼ確実にそのようなことはないかと思います。

自分から連絡する

インターネットで相手のウェブサイトを見つけたら、そこで連絡先のメールアドレスなり電話番号なりを調べます。

そこに「先ほどこういう連絡をいただいたのですが」と連絡すればOKですね。

「ほぼ」確実に防げる

「ほぼ」というのは、そのサイトが仮に正規のウェブサイトであったとしても「クロスサイトスクリプティング」や「改ざん」といったサイバー攻撃を受けていた場合は悪意のある連絡先が表示される可能性が排除できないからです。または自分のブラウザがウイルスに感染している場合もだめですね。

とはいえそのような確率は非常に低いので、あまり考えなくていいかと思います。

とにかく大事なのは「もらった電話・メールに自分の情報を伝えない」「自分の情報を伝える場合はこちらから連絡する」ことです。

気になる点などあれば気軽にコメントください。

Google App EngineでGoogle Cloud DatastoreとFlaskを利用したセキュアなサーバーを作る

前回までにGoogle Cloud Datastoreを利用した簡単なFlaskサーバーFlaskのセキュリティを学びました。

今回はこれらを踏まえて、実際に運用できるFlaskサーバーを作りましょう。利用するデータベースはGoogle Cloud Datastore(以下Datastore)です。

今回のステップが終われば、あとは次回やるGoogle App Engineのセキュリティ設定だけです。

前回作ったコード

前回作ったmain.pyは以下の通りです。

# coding: UTF-8
import flask
from google.appengine.ext import ndb

app = flask.Flask(__name__)

class User(ndb.Model):
    name = ndb.StringProperty()
    mail = ndb.StringProperty()
    password = ndb.StringProperty()


@app.route('/')
def toppage():
    return flask.render_template('toppage.html')

@app.route('/showname')
def showname():
    name = flask.request.args.get('name')
    return flask.render_template('showname.html', name=name)

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.request.form['name']
    mail = flask.request.form['mail']
    password = flask.request.form['password']
    user = User(name=name, mail=mail, password=password)
    user.put()

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.request.form['name']
    password = flask.request.form['password']
    query = User.query(ndb.AND(User.name == name, User.password == password))
    result = query.get()
    if result is None:
        return 'ユーザーネームとパスワードの組み合わせが正しくありません'
    else:
        return result.mail

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.request.form['user']
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        for key, value in flask.request.form.items():
            if key != 'user':
                if hasattr(user_entity, key):
                    setattr(user_entity, key, value)
        user_entity.put()
    else:
        return '該当するユーザーがみつかりませんでした'

まだセキュリティ対策はなにもしていません。

データベースを利用しないページ

一番最初にFlaskアプリの挙動確認のために作ったページです。

  • /:トップページ(用意したHTMLを出力するだけ)
  • /showname:GETリクエストのクエリパラメータを画面に出力するページ

データベースを利用するページ

Datastoreにアクセスします。

  • /database/new:POSTリクエストでDatastoreに新しいEntityを追加
  • /database/get:POSTリクエストでDatastoreからEntityのPropertymailを取得
  • /database/reflesh:POSTリクエストでDatastoreのEntityの内容を更新

レスポンスヘッダの追加

さて、最初にセキュリティに関連するレスポンスヘッダを用意しましょう。(内容は前回をみてください)

flask.make_response(レスポンスボディ)関数を利用してレスポンスインスタンスを作成し、そこにヘッダを追加します。

def prepare_response(data):
    response = flask.make_response(data)
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['Content-Security-Policy'] = 'default-src \'self\''
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response

そうしたら、これまで直接returnしていた出力(レスポンスボディ)を今作成したprepare_response関数の引数に渡してレスポンスインスタンスを作成し、それをreturnするように変更します。

ステータスコードの追加

ついでにレスポンスにステータスコードも追加しましょう。

ステータスコードとは'200 OK'とか'404 Not Found'というやつです。404は普段のブラウジングでもたまに見るかと思います。これはレスポンスの意図を端的に伝えるもので、HTTPの仕様で番号は決まっています。

こちらのページを参考に、それぞれのレスポンスにステータスコードを設定しましょう。
HTTP 応答状態コード - HTTP | MDN

ステータスコードは.status_code属性に入ります。こんな感じで代入すればOK。

response.status_code = 200

Flaskドキュメント - Responseクラス:API — Flask 0.12.4 documentation

ステータスコードもさっきの関数で追加する

さっきの関数にステータスコードも一緒に渡してしまったほうが便利でしょう。

def prepare_response(data, statuscode):
    response = flask.make_response(data)
    response.status_code = statuscode
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['Content-Security-Policy'] = 'default-src \'self\''
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response

変更後のコード

ここまでの内容を反映したコードがこちら。

# coding: UTF-8
import flask
from google.appengine.ext import ndb

app = flask.Flask(__name__)

class User(ndb.Model):
    name = ndb.StringProperty()
    mail = ndb.StringProperty()
    password = ndb.StringProperty()

def prepare_response(data, statuscode):
    response = flask.make_response(data)
    response.status_code = statuscode
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['Content-Security-Policy'] = 'default-src \'self\''
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response


@app.route('/')
def toppage():
    response_body = flask.render_template('toppage.html')
    response = prepare_response(response_body, 200)
    return response

@app.route('/showname')
def showname():
    name = flask.request.args.get('name')
    response_body = flask.render_template('showname.html', name=name)
    response = prepare_response(response_body, 200)
    return response

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.request.form['name']
    mail = flask.request.form['mail']
    password = flask.request.form['password']
    user = User(name=name, mail=mail, password=password)
    user.put()
    response = prepare_response('', 200)
    return response

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.request.form['name']
    password = flask.request.form['password']
    query = User.query(ndb.AND(User.name == name, User.password == password))
    result = query.get()
    if result is None:
        response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません' ,401)
    else:
        response = prepare_response(result.mail, 200)
    return response

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.request.form['user']
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        for key, value in flask.request.form.items():
            if key != 'user':
                if hasattr(user_entity, key):
                    setattr(user_entity, key, value)
        user_entity.put()
        response = prepare_response('', 200)
    else:
        response = prepare_response('該当するユーザーがみつかりませんでした', 404)
    return response

基本的にreturnするのは毎回responseですね。返すデータ本体がなくてもステータスコードは必ず適切なものを返した方がいいので、基本的にreturn responseはどの場合でも行います。


今回はcookieを利用しませんが、利用する場合はリクエスト強要などへの対策としてSet-Cookieオプションの設定が必要です。

クロスサイトスクリプティング(XSS)対策

クロスサイトスクリプティングとはユーザーの入力を出力に反映する際に起こるものです。

今回その可能性があるのは、

  • /shownameでのクエリの出力
  • /database/getでのデータベースの値の出力

の2箇所です。

/shownameの対策

/shownameでは以下の点からXSSの心配はありません。(Jinja2の自動エスケープの対象)

  • 入力値を直接flask.render_template()に渡している
  • 埋め込み先はタグ内ではない

/database/getの対策

/database/getではデータベースの値をflask.render_template()を使わず直接出力しているので、もしmailにHTMLタグを渡されればXSSが可能となります。

これには以下3通りの対策が考えられます。

  1. /database/getでの出力にflask.render_template()を利用する
  2. /database/getで出力前にエスケープする
  3. /database/newおよび/database/refleshでデータベースへの格納前にエスケープする

どれが最適かはケースバイケースです。しかしながらメールアドレスの文字列に<>&;#などは不要なはずです。入力の段階でチェックするのがスマートでしょう。

エスケープする

本当は上のような記号が入っている時点でメールアドレスとしておかしいので、その時点でエラーとして再入力を求めたいところです。

が、それをやっていると話が逸れるので今回は単純にflask.escape()を使います。今回問題となりうるのはmailだけですが、すべてエスケープしておきましょう。

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.escape(flask.request.form['name'])
    mail = flask.escape(flask.request.form['mail'])
    password = flask.escape(flask.request.form['password'])
    user = User(name=name, mail=mail, password=password)
    user.put()
    response = prepare_response('', 200)
    return response

@app.route('/database/get', methods=['POST'])
def database_get():
    name =  flask.escape(flask.request.form['name'])
    password =  flask.escape(flask.request.form['password'])
    query = User.query(ndb.AND(User.name == name, User.password == password))
    result = query.get()
    if result is None:
        response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません' ,401)
    else:
        response = prepare_response(result.mail, 200)
    return response

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.escape(flask.request.form['user'])
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        for key, value in flask.request.form.items():
            if key != 'user':
                if hasattr(user_entity, key):
                    setattr(user_entity, key, flask.escape(value))
        user_entity.put()
        response = prepare_response('', 200)
    else:
        response = prepare_response('該当するユーザーがみつかりませんでした', 404)
    return response

エスケープして格納しているので、参照の際もエスケープした状態で検索しなければヒットしない点に注意が必要です。

インジェクション対策

flask.request.argsflask.request.formから値を取り出す際は.get()メソッドを利用し、引数で型を指定します。

get(キー, default=値が存在しなかった時の返り値, type=型指定)

これを踏まえて該当部分を書き換えるとこのようになります。if文でdefaultの場合の挙動を設定しておくといいでしょう。

@app.route('/showname')
def showname():
    name = flask.request.args.get('name', default='名前がありません', type=str)
    response_body = flask.render_template('showname.html', name=name)
    response = prepare_response(response_body, 200)
    return response

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str))
    mail = flask.escape(flask.request.form.get('mail', default='メールアドレスがありません', type=str))
    password = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str))

    if name == '名前がありません' or mail == 'メールアドレスがありません' or password =='パスワードがありません':
        response = prepare_response('正しいセットを入力してください', 400)
        return response

    user = User(name=name, mail=mail, password=password)
    user.put()
    response = prepare_response('', 200)
    return response

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str))
    password = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str))

    query = User.query(ndb.AND(User.name == name, User.password == password))
    result = query.get()
    if result is None:
        response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません', 401)
    else:
        response = prepare_response(result.mail, 200)
    return response

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.escape(flask.request.form.get('user', default='名前がありません', type=str))
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        #flask.request.form.get()を使うために.items()を.keys()に変更
        for key in flask.request.form.keys():
            if key != 'user':
                if hasattr(user_entity, key):
                    #if文の内部なのでdefaultは不要
                    value = flask.escape(flask.request.form.get(key, type=str))
                    setattr(user_entity, key, value)
        user_entity.put()
        response = prepare_response('', 200)
    else:
        response = prepare_response('該当するユーザーがみつかりませんでした', 404)
    return response

パスワードの扱い

さて、このアプリではまだパスワードをそのままデータベースに保存しています。Google Cloud Datastoreはデフォルトで暗号化された状態で保存されるとはいえ、それは可逆的(復元可能)なものです。(とはいえ復元には暗号化キーが必要ですが。)

そもそもこのままではデータベースの管理者がユーザーのパスワードをみることができます。パスワードは本人以外は知ることができないようになっていなければなりません。

前回やったように、SHA-256で暗号化をしましょう。

手順はこのようになります。

import hashlib

password = "123456"

#bytes型に変換
password_bytes = password.encode()

#ハッシュ化(返り値は'_hashlib.HASH'型)
hash_bytes = hashlib.sha256(password_bytes)

#str型に変換(16進数の形になります)
hashed_password = hash_bytes.hexdigest()

なお、Python2の場合はデフォルトの文字コードがasciiになっているので日本語→bytesの変換でエラーが起こります。これを防ぐためにデフォルトの文字コードをUTF-8にしておきます。(Python3の場合はデフォルトでUTF-8になっているので不要です)

# デフォルトの文字コードをutf-8に(python3ならば不要)
import sys
reload(sys)
sys.setdefaultencoding('utf-8')

パスワードを受け取る全ての場面で暗号化をします。上の手順を毎回やっているのは煩雑なので、関数を作っておきましょう。

import hashlib

# デフォルトの文字コードをutf-8に(python3ならば不要)
import sys
reload(sys)
sys.setdefaultencoding('utf-8')

def make256(raw):
    bytes = raw.encode()
    hash_bytes = hashlib.sha256(bytes)
    hash_str = hash_bytes.hexdigest()
    return hash_str

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str))
    mail = flask.escape(flask.request.form.get('mail', default='メールアドレスがありません', type=str))
    password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str))

    password_hashed = make256(password_raw)
    
    if name == '名前がありません' or mail == 'メールアドレスがありません' or password_raw =='パスワードがありません':
        response = prepare_response('正しいセットを入力してください', 400)
        return response

    user = User(name=name, mail=mail, password=password_hashed)
    user.put()
    response = prepare_response('', 200)
    return response

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str))
    password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str))

    password_hashed = make256(password_raw)

    query = User.query(ndb.AND(User.name == name, User.password == password_hashed))
    result = query.get()
    if result is None:
        response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません', 401)
    else:
        response = prepare_response(result.mail, 200)
    return response

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.escape(flask.request.form.get('user', default='名前がありません', type=str))
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        #flask.request.form.get()を使うために.items()を.keys()に変更
        for key in flask.request.form.keys():
            if key != 'user':
                if hasattr(user_entity, key):
                    #if文の内部なのでdefaultは不要
                    value = flask.escape(flask.request.form.get(key, type=str))

                    #パスワードの場合はハッシュ化
                    if key == 'password':
                        value = make256(value)

                    setattr(user_entity, key, value)
        user_entity.put()
        response = prepare_response('', 200)
    else:
        response = prepare_response('該当するユーザーがみつかりませんでした', 404)
    return response

完成

これで一通りコーディングにおけるセキュリティ対策は完了です。

# coding: UTF-8
import flask
from google.appengine.ext import ndb
import hashlib

# デフォルトの文字コードをutf-8に(python3ならば不要)
import sys
reload(sys)
sys.setdefaultencoding('utf-8')

app = flask.Flask(__name__)

class User(ndb.Model):
    name = ndb.StringProperty()
    mail = ndb.StringProperty()
    password = ndb.StringProperty()

def prepare_response(data, statuscode):
    response = flask.make_response(data)
    response.status_code = statuscode
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['Content-Security-Policy'] = 'default-src \'self\''
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response

def make256(raw):
    bytes = raw.encode()
    hash_bytes = hashlib.sha256(bytes)
    hash_str = hash_bytes.hexdigest()
    return hash_str


@app.route('/')
def toppage():
    response_body = flask.render_template('toppage.html')
    response = prepare_response(response_body, 200)
    return response

@app.route('/showname')
def showname():
    name = flask.request.args.get('name', default='名前がありません', type=str)
    response_body = flask.render_template('showname.html', name=name)
    response = prepare_response(response_body, 200)
    return response

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str))
    mail = flask.escape(flask.request.form.get('mail', default='メールアドレスがありません', type=str))
    password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str))

    password_hashed = make256(password_raw)
    
    if name == '名前がありません' or mail == 'メールアドレスがありません' or password_raw =='パスワードがありません':
        response = prepare_response('正しいセットを入力してください', 400)
        return response

    user = User(name=name, mail=mail, password=password_hashed)
    user.put()
    response = prepare_response('', 200)
    return response

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.escape(flask.request.form.get('name', default='名前がありません', type=str))
    password_raw = flask.escape(flask.request.form.get('password', default='パスワードがありません', type=str))

    password_hashed = make256(password_raw)

    query = User.query(ndb.AND(User.name == name, User.password == password_hashed))
    result = query.get()
    if result is None:
        response = prepare_response('ユーザーネームとパスワードの組み合わせが正しくありません', 401)
    else:
        response = prepare_response(result.mail, 200)
    return response

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.escape(flask.request.form.get('user', default='名前がありません', type=str))
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        #flask.request.form.get()を使うために.items()を.keys()に変更
        for key in flask.request.form.keys():
            if key != 'user':
                if hasattr(user_entity, key):
                    #if文の内部なのでdefaultは不要
                    value = flask.escape(flask.request.form.get(key, type=str))

                    #パスワードの場合はハッシュ化
                    if key == 'password':
                        value = make256(value)

                    setattr(user_entity, key, value)
        user_entity.put()
        response = prepare_response('', 200)
    else:
        response = prepare_response('該当するユーザーがみつかりませんでした', 404)
    return response

おまけ:実際の運用では

ユーザーの追加

今のままでは誰でもユーザーを追加できます。

実際の運用ではまずメールアドレスの有効性を確認→メールアドレスに送信したリンクからのみ登録可

のような形になるかと思います。

例えば可逆的(復元可能)なパラメータを含むリンクを登録をリクエストするメールアドレスに送り、そこからのアクセスの場合に該当のメールアドレスでの登録を許可するなどが考えられます。

これはアイデアというか工夫次第なのでここでは言及しません。

あと、メールアドレスは重複を許さないようにしないといけません。データベースを確認してif entity is Noneの場合のみ登録を許可しましょう。

.refleshの挙動

それから当然ながら今回作ったコードのままではユーザー名がわかればPropertyを自由に変更できてしまいます。

ここはユーザー名ではなくメールアドレスとパスワードの組み合わせで確認をすべきでしょう。そもそもユーザー名がかぶることは普通にありえますし

'名前がありません'について

どうでもいですが、今のままだと例えば「名前がありません」というユーザー名では登録できません。どうでもいいですが。

Flaskのセキュリティ対策

Flaskではデフォルトでセキュリティ対策がなされている部分もありますが、もちろんコーディングする上で気をつけなければいけない点もあります。

このページはこちらの公式リファレンスを噛み砕いた内容になります。
Security Considerations — Flask 1.0.2 documentation
Mozillaのガイドも参考になりました。
フォームデータの送信 - ウェブ開発を学ぶ | MDN

インジェクション対策

インジェクションとはユーザーの入力にコードが含まれていた時にそれをサーバー内で実行してしまうことです。

この対策にはまず受け取るデータの型を指定することが有効です。

Flaskのflask.request.argsflask.request.formなどリクエストを格納している属性はFlaskのベースであるWerkzeugのImmutableMultiDictです。ここから値を得るには.get()メソッドを利用しますが、ここで型を指定できます。

get(キー, default=値が存在しなかった時の返り値, type=型指定)

typeはほとんどの場合strintで十分ではないでしょうか。

もし型変換に失敗した場合はdefaultに指定した値が返るので、正しい値の入力をユーザーに求めましょう。

args = {'age':23, 'name':'太郎'}

args.get('age', default=-999, type=int)
#>> 23

args.get('name', default=-999, type=int)
#>> -999

.get()メソッドについてのWerkzeugのドキュメントです。
Data Structures — Werkzeug Documentation (0.14)

クロスサイトスクリプティング(XSS/CSS)対策

FlaskのテンプレートエンジンであるJinja2には自動的にエスケープする機能がついています。自分で注意しなければならない点は以下です。

  • Jinja2を利用しないでHTMLを生成する場合
  • Markupクラスを利用する場合
  • ユーザーのアップロードしたファイルを利用する場合

Jinja2のテンプレートを書く際の注意点

またJinja2でテンプレートHTMLを作成する際、タグ中に変数を入れる場合はかならずクォーテーション'"で囲います。

<!--悪い例-->
<div name={{ name }}>

<!--良い例-->
<div name="{{ name }}">
hrefとsrc属性

ただ、aタグなどのhref属性やimgタグなどのsrc属性はさらに気をつけなければなりません。

たとえば以下のような場合、

<a href="{{ url }}">

urlにこんな文字列をいれれば、任意のコードを実行できてしまいます。

<a href="javascript:〜〜〜">

これを避けるには、受け取ったurl文字列がhttp://https://で始まっていなければ無効にするのがよいでしょう。

src属性も同様です。許可するドメインや拡張子を限定しておく必要があります。(例:.jpg.pngのみ、など)

基本的にHTMLの属性値に直接ユーザーから受け取った値を埋め込むのはリスキーです。可能な限りif文などで間接的に処理をしましょう。

自分で変数を埋め込んだHTMLを用意する場合

render_template()関数の引数に変数を渡すのではなく自分で埋め込みHTMLを生成するような場合はJinja2の自動エスケープの対象とならないので注意が必要です。

安全なケース

render_template()関数の引数に変数だけを渡すような場合はJinja2が自動でエスケープするので安全です。(逆にエスケープしてから渡すとエスケープ後の形がそのまま表示されてしまいます)

name = flask.request.args.get('name')
return flask.render_template('exsample.html', name=name)
<!--example.html-->
<html>
    <body>
        {{ name }}
    </body>
</html>
安全でないケース

自分で変数を埋め込んだHTMLを生成する場合はエスケープが必要です。

危険

name = flask.request.args.get('name')
template = '<p>ようこそ' + name + 'さん!</p>'
return flask.render_template_string(template)

flask.escape()関数を使えばOKです。

安全

name = flask.request.args.get('name')
name_esc = flask.escape(name)
template = '<p>ようこそ' + name_esc + 'さん!</p>'
return flask.render_template_string(template)

こちらのページにFlaskのインジェクションのサンプルが載っています。
Injecting Flask

リクエスト強要 (CSRF)対策

リクエスト強要とは:IPA ISEC セキュア・プログラミング講座:Webアプリケーション編 第4章 セッション対策:リクエスト強要(CSRF)対策

クッキーを利用してセッション管理をする場合。Flaskではなにも対策をしていないので、上のページを参考に自分で対策を講じる必要があります。

Flaskでの設定方法はに載せています。

セキュリティヘッダ

FlaskでHTTPレスポンスヘッダを設定するにはflask.make_response()関数を使います。

引数にレスポンスボディ(ページに出力する内容)を入れれば、flask.Responseクラスのインスタンスを生成します。

response = flask.make_response(returnvalue)
response.headers[ヘッダの項目] = 内容

最後にreturn responseすればOKです。

Flaskドキュメント:Responseクラスについて
API — Flask 0.12.4 documentation
Flaskドキュメント:make_response関数について
API — Flask 0.12.4 documentation

HTTP Strict Transport Security(HSTS)

HTTPではなくHTTPSで接続するようブラウザに要求します。
Strict-Transport-Security - HTTP | MDN

以下のように設定します。

response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'

コンテンツセキュリティポリシー(CSP)

ページ上で読み込むリソースの読み込み先をホワイトリスト形式で指定します。
コンテンツセキュリティポリシー (CSP) - HTTP | MDN

上のページに例が載っていますが、たとえば自分自身のドメインとexample.comとexmaple.net(サブドメイン含む)からの読み込みを許可する場合は以下のように設定します。

response.headers['Content-Security-Policy'] = 'default-src \'self\' *.example.com *.example.net'

X-Content-Type-Options

レスポンスヘッダで指定したcontent typeに絶対に従いなさいという指示です。これがないとブラウザはsniffといって、独自で最適なcontent typeを探そうとします。それによって意図しないスクリプトが実行される恐れがあります。
X-Content-Type-Options - HTTP | MDN


以下のように設定します。

response.headers['X-Content-Type-Options'] = 'nosniff'

X-Frame-Options

HTMLのframeiframeで呼び出されることを許可する範囲を指定します。
これを指定していないと、第三者のウェブサイトが簡単にあなたのウェブサイトを偽装できてしまいます。
X-Frame-Options - HTTP | MDN

  • 一切禁止:DENY
  • 自ドメインのみ:SAMEORIGIN
  • 特定のドメインを許可:ALLOW-FROM https://example.com/

以下のように設定します。

response.headers['X-Frame-Options'] = 'SAMEORIGIN'

X-XSS-Protection

ブラウザのセキュリティ機能を利用してXSS攻撃を抑えるものです。しかしながら完全ではなく、また思わぬ誤作動の危険性もあります。とりあえず設定しておけばいいというものではありません。
ブラウザのXSSフィルタを利用した情報窃取攻撃 | MBSD Blog
X-XSS-Protection - HTTP | MDN


設定する場合は以下のように設定します。

response.headers['X-XSS-Protection'] = '1; mode=block'

Set-Cookieオプション

クッキーの設定を追加します。設定の内容はこのページをご覧ください。
HTTP Cookie - HTTP | MDN
HTTP Cookieとは (2/2):超入門HTTP Cookie - @IT

Flaskで設定するには以下の2通りの方法があります。

  • flask.Flaskクラスのインスタンス(一般的にはappとして作成済み)config属性(辞書型)にPythonの.update()メソッドなどで項目を追加する
  • flask.make_response()などで作成したResponseクラスのインスタンスに.set_cookie()メソッドで項目を追加する

#前提
app = flask.Flask(__name__)
response = flask.make_response()

#方法1
app.config.update(SESSION_COOKIE_SECURE=True,SESSION_COOKIE_HTTPONLY=True,SESSION_COOKIE_SAMESITE='Lax')

#方法2
response.set_cookie('クッキーのキー', value='クッキーの値', secure=True, httponly=True, samesite='Lax')

.set_cookie()メソッドのドキュメント
API — Flask 1.0.2 documentation
.configのドキュメント
API — Flask 1.0.2 documentation

HTTP Public Key Pinning (HPKP・公開鍵ピンニング)

SSL証明書は本来完全に信頼できることが前提です。しかし認証局も人が運営しているものなので脆弱性や問題が起こらないとは限りません。

HPKPはSSL証明書が本当に正しいのかを検証させるものです。

しかしながら設定は難しく、また失敗した場合は長期間そのサイトにアクセスできなくなる恐れがあります。しっかりとした理解の上で設定しなければなりません。ぼくには責任が負えないので、以下のサイトなどをみてください。
HTTP Public Key Pinning (HPKP) - ウェブセキュリティ | MDN
公開鍵ピンニングについて | POSTD

まとめて設定する

パスごとにいちいちレスポンスヘッダを設定する必要はありません。最初にまとめて設定しましょう。

関数を定義しておけば便利です。

def prepare_response(data):
    response = flask.make_response(data)
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['Content-Security-Policy'] = 'default-src \'self\''
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'SAMEORIGIN'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response

こうしておけばこんなかんじでレスポンスが作成できるので便利です。

@app.route('/example')
    def example():
        response_body = flask.render_template('example.html')
        response = prepare_response(response_body)
        return response

.set_cookie()は場合によって変わると思うのでその都度設定すればいいですね。

データベースを利用する際のインジェクション対策

利用するデータベースのAPIを仕様を確認しておきましょう。もしAPIにエスケープ機能が備わっていない場合は格納前にエスケープしておくべきです。

Google Cloud Datastoreの場合

Google App EngineでデフォルトのデータベースであるCloud Datastoreを利用する場合はメソッドの引数としてユーザーからの入力を渡すことになります。この構造上SQLに比べてインジェクションが起こりにくいです。

Modelクラスを利用している場合はPropertyの追加や型の変更はできないので、さらにリスクは小さくなります。

しかしながら以下の点については念を押しておいた方がいいです。

  • 引数に渡す前に型を確定しておく

また、Jinja2に直接変数を渡す以外での入力値の利用の可能性がある場合はデータの格納前にflask.escape()しておきましょう。

SQLインジェクション

SQLを利用する場合はSQLインジェクションのリスクがあります。

SQLコマンド中にユーザーからの入力を埋め込む場合は厳格な入力チェックが必要です。必要ない文字(SQLコマンドや記号など)は全てエスケープしておきましょう。

パフォーマンスの問題もありますが、事前に選択肢を読み込んだ上でfor文やif文を組み合わせて条件分岐としてしまうことでSQLコマンド中に直接ユーザーから受け取った変数を入れないこともできます。

SQLは構造上インジェクションに弱いのでセキュリティの面では可能な限りNoSQLを利用した方がいいです。

パスワードの取り扱い

まず、そもそもSSL化は必須です。SSL化していない通信でパスワードを送信するのはカフェで大声でパスワードを読み上げているのと同じことです。そのような実装ではユーザーのブラウザにも警告が出ます。

SSL化は前提として、パスワードは万一流出しても元の内容がわからないよう、すべてハッシュ化してから保存します。

確認の際はユーザーから入力された平文のパスワードを同じ方法でハッシュ化し、保存してあるものと同じになるかどうかで判断をします。

ハッシュ化には一般的にSHA-256を利用します。これは主要な言語のライブラリには用意されているはずです。

PythonでのSHA-256

Pythonでは標準ライブラリのhashlibでSHA-256をサポートしています。

使い方は、

  1. 元の文字列をbytes型に変換する
  2. それをhashlib.sha256()関数でハッシュ化する
  3. 扱いやすいように.hexdigest()str型に戻す

となります。

import hashlib

password = "123456"

#bytes型に変換
password_bytes = password.encode()

#ハッシュ化(返り値は'_hashlib.HASH'型)
hash_bytes = hashlib.sha256(password_bytes)

#str型に変換(16進数の形になります)
hashed_password = hash_bytes.hexdigest()

なお、bytes型とstr型の変換の際は文字コードに注意する必要があります。Python3の場合はデフォルトでUTF-8になっているので問題ないですが、Python2のデフォルトはasciiです。日本語を扱えないのでUnicodeDecodeErrorが発生します。

これを防ぐにはデフォルトの文字コードを変更しておく必要があります。

# デフォルトの文字コードをutf-8に(python3ならば不要)
import sys
reload(sys)
sys.setdefaultencoding('utf-8')

レインボーテーブル対策など

ハッシュ化では同じ文字列からは常に同じハッシュが得られるので、あらかじめ大量の文字列をハッシュ化しておいて、元の文字列とハッシュの対応表(レインボーテーブル)を作成できます。

これに対するアイデアはいくつかありますが、代表的なものを下に。

ソルト

ハッシュは1文字でも違うと全く異なるものになるため、「原文+なにか文字列」をハッシュ化する方法です。もちろん、このソルトを公開したら意味ないです。

HMAC

ハッシュ化の際に「ハッシュキー」を指定し、原文とハッシュキーのセットでハッシュを生成します。まあソルトの強力版みたいなかんじですね。
hmacライブラリもPython標準です。

import hmac
import hashlib

def createHMACSHA256(text, secretkey):
    signature = hmac.new(secretkey, text, hashlib.sha256).hexdigest()
    return signature

ライブラリを最新のものに保つ

ライブラリは定期的に更新を確認して、常に最新版を利用しましょう。
Flask · PyPI

その他のセキュリティ対策

今回はFlaskコーディングにおける一般的なセキュリティ対策をまとめました。

次回はGoogle App Engineでのセキュリティ設定(SSL、アクセス権限、ファイアウォール、Security scanner)をまとめます。

Google Cloud Datastoreを利用したサーバーをFlaskでつくる [GAE]

前回はじめてのFlaskアプリをGoogle App Engineで作ったわけですが、今回はデータベースを利用したアプリを作ります。

HTTPリクエストを受けて値を保存したり返したりするRESTサーバーをGoogle Cloud Datastoreで作ってみましょう。
▷HTTPリクエストの構造については知っているものとします。

前回同様、使うのはFlask。Google App Engine(以下GAE)でホストしています。

Google Cloud Datastoreとは

Google Cloud Platformの誇る最先端のデータベース(NoSQL)です。GAEからの利用方法はまとめてあるので、随時参照してください。
Google Cloud DatastoreをGoogle App Engineから利用する

基本的には上のページを読んでいなくてもわかるように書きます。詳細を知りたいときだけ上のページをリファレンスがわりに参照してもらえれば大丈夫と思います。

ちなみにGoogle Cloud Datastoreにおける各名称の意味はこのようになっています。

f:id:simonsnote:20180531150337p:plain:w500

Google Cloud Platformでの準備

Google Cloud DatastoreはGCPのデフォルトのデータベースなので、特になにか設定する必要はありません。いきなり使えます。

GCPでは「プロジェクト」という単位で各サービスを管理します。同じプロジェクトの中ではCompute EngineもApp EngineもCloud Datastoreも最初から結びついているのです。なのでたとえば同じプロジェクト内でCompute EngineとApp Engineでそれぞれ関係ないアプリを運用するというのはあまりよくないですね。(データベースを共有するなどであればいいですが)

アプリをつくっていく

さて、今回は前回のFlaskアプリをベースに、Cloud Datastoreにユーザー情報を保存・取り出すサーバーを作ります。
※なお当然ながら今回つくるサーバーはテスト用でセキュリティもなにもあったもんじゃないので、実際のユーザー情報をいれてはなりません。(それは次回やりましょう)

ここからは前回作った最小限のFlaskアプリにコードを書き足す形で作っていきます。

今回はサーバーということで、http POSTでユーザー情報を保存・更新・取り出すアプリを作ります。
取り出す際もGETでなくPOSTなのは、リクエストにパスワードを含めてもらうことで第三者がデータを取り出せないようにするためです。パスワードはGETで扱ってはなりません(GETリクエストの内容は公開情報であることが前提となっています)。

今回コードを追加するのはmain.pyのみです。

前回作ったmain.py

さて、前回作ったmain.pyの内容は以下のようになっています。

# coding: UTF-8
import flask

app = flask.Flask(__name__)


@app.route('/')
def toppage():
    return flask.render_template('toppage.html')

@app.route('/showname')
def showname():
    name = flask.request.args['name']
    return flask.render_template('showname.html', name=name)

前回までのコードを消す必要はありません。新たにデータベースと情報をやりとりするためのページを作りましょう。

ライブラリをimportする

Google App Engineでデフォルトで用意されているndbライブラリをインポートするだけです。

from google.appengine.ext import ndb

これだけでライブラリが利用可能になります。

データベースを定義する

まずはデータベースに保存するデータのクラスを作成します。

  • クラス名=kind
  • インスタンス変数=propertyの値(value)

となります。

クラスはndbライブラリに定義されているModelクラスを継承して作成します。

今回はPropertyには以下の3つを用意します。

  • name(string型)
  • mail(string型)
  • password(string型)

Propertyを用意するには専用のメソッドを使用します。今回は全てstring型ですが、型ごとにメソッドが用意されています。

class User(ndb.Model):
    name = ndb.StringProperty()
    mail = ndb.StringProperty()
    password = ndb.StringProperty()

あとは今作成したUserクラスのインスタンスを作成すればそれがEntityになります。インスタンス変数に値を代入すればそれぞれのPropertyにvalueを入れたことになります。

パスを定義する

Cloud Datastoreと情報をやりとりするには、用意したパスにPOSTリクエストを送ってもらう形になります。

前回まではルーティングは@app.route(パス)の形でしたが、実は.route()メソッドには引数でHTTPメソッドを指定することができます。

今回は

  • /database/newPOSTリクエストを送信した際に新しいデータの保存
  • /database/getPOSTリクエストを送信した際にデータの取り出し
  • /database/refleshPOSTリクエストを送信した際にデータの更新

をすることにしましょう。

.route()メソッドの引数methodsに許可するリクエストメソッドをリスト型で入れます。

新しいデータの保存

/database/newPOSTリクエストを送信した際に新しいデータの保存を行います。

イメージとしてはこんなかんじですね。(ここではエスケープやエンコード・デコードは考えません)

POSTリクエストのボディ:name=フラスク太郎&mail=abc@example.com&password=aabbcc

この形を「application/x-www-form-urlencoded」といいます。HTTPリクエストでは最もメジャーな形です。パラメータ1=値1&パラメータ2=値2...というようにパラメータ=値&で連結します。パラメータのことをid、値のことをdataといいます。

さて、flask.requestはリクエストボディのパラメータを.formに辞書型で保有しています。パラメータにアクセスするにはflask.request.form[キー]だけでいいのです。これだけでPOSTリクエストを扱えてしまうのだから便利ですね。

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.request.form['name']
    mail = flask.request.form['mail']
    password = flask.request.form['password']

Userクラスのインスタンスを作成し、インスタンス変数に今取得した値をいれましょう。

user = User(name=name, mail=mail, password=password)

そうしたらあとは作成したインスタンスuserをDatastoreに送信するだけです。

これには.put()メソッドを利用します。

user.put()

これで新しい値の保存は完了です。

データの取り出し

データの取り出しはUserクラスに対して.get()メソッドを使います。

今回はリクエストで受け取ったユーザーネーム(nameパラメータ)とパスワード(passwordパラメータ)が正しい場合にそのユーザーのメールアドレス(mailProperty)を返し、正しくない場合にはエラーメッセージを返しましょう。

まずはリクエストからユーザーネームとパスワードを取得します。

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.request.form['name']
    password = flask.request.form['password']

そうしたら、まずはそのユーザーネームとパスワードの組み合わせがデータベースに存在するか確認します。

手順は以下のようになります。

  1. クエリ(指定したnamepasswordを持つEntityを要求するリクエスト)を作成する
  2. 作成したクエリを.get()する

渡したユーザーネームとパスワードの組み合わせがデータベース(Kind=User)に存在すれば、有効なEntityが返ってきます。なければNoneが返ってきます。

ここからif文で

  1. 有効なEntityが返ってきたら(Noneでなかったら)→そのEntityのmailPropertyを出力
  2. Noneだったら→エラーメッセージを出力

すればOKです。

まずはクエリを作成しましょう。クエリの作成には.query()メソッドを使います。引数にPropertyと値の評価式を入れます。今回はANDを使います。

query = User.query(ndb.AND(User.name == name, User.password == password))

そうしたらこのクエリをDatastoreに送信し、返り値を変数に入れましょう。

result = query.get()

返ってきた値には有効なEntityかNone(該当するEntityがなかった場合)のどちらかが入っているはずです。if文で条件分岐しましょう。

resultが有効なEntityだった場合は、そのmailPropertyをreturnします。Propertyはインスタンス変数として保有されています。

if result is None:
    return 'ユーザーネームとパスワードの組み合わせが正しくありません'
else:
    return result.mail

これでOKです。

データの更新

データを更新するには.get()で取得したEntityのインスタンス変数(=Property)を新しく代入して.put()でDatastoreに戻せばOKです。

今回は以下のような仕様にしましょう。

  • /database/refleshPOSTリクエストを送信
  • リクエストボディはuser=変更したいユーザーのユーザー名&変更したいパラメータ1=新しい値&変更したいパラメータ2=新しい値...
flask.request.formは辞書型なので、Pythonの.items()メソッドを使って変更したいパラメータ全てについてfor文で処理をしましょう。インスタンス変数へのアクセスはPythonのsetattr(インスタンス, 属性名, 値)関数を使います。

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.request.form['user']
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        for key, value in flask.request.form.items():
            if key != 'user':
                setattr(user_entity, key, value)

        user_entity.put()
    else:
        return '該当するユーザーがみつかりませんでした'

これでもいいのですが、定義していない属性(Property)がリクエストに含まれていた場合はエラーになります(今回のUserクラスはModelクラスを継承しているので新しいPropertyが追加されてしまうことはありません)。

この問題を解消するにはPythonのhasattr(インスタンス, 属性名)関数を使えばいいですね。Propertyが存在する場合はTrueが返ります。

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.request.form['user']
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        for key, value in flask.request.form.items():
            if key != 'user':
                if hasattr(user_entity, key):
                    setattr(user_entity, key, value)

        user_entity.put()
    else:
        return '該当するユーザーがみつかりませんでした'

これでOKです。

出来上がったコード

これでmain.pyは完成です。

出来上がったmain.pyがこちら。前回のコードに今回のコードをくっつけただけです。前回のコード(ルーティング部分)は別に消しても構いません。

# coding: UTF-8
import flask
from google.appengine.ext import ndb

app = flask.Flask(__name__)

class User(ndb.Model):
    name = ndb.StringProperty()
    mail = ndb.StringProperty()
    password = ndb.StringProperty()


@app.route('/')
def toppage():
    return flask.render_template('toppage.html')

@app.route('/showname')
def showname():
    name = flask.request.args['name']
    return flask.render_template('showname.html', name=name)

@app.route('/database/new', methods=['POST'])
def database_new():
    name = flask.request.form['name']
    mail = flask.request.form['mail']
    password = flask.request.form['password']
    user = User(name=name, mail=mail, password=password)
    user.put()

@app.route('/database/get', methods=['POST'])
def database_get():
    name = flask.request.form['name']
    password = flask.request.form['password']
    query = User.query(ndb.AND(User.name == name, User.password == password))
    result = query.get()
    if result is None:
        return 'ユーザーネームとパスワードの組み合わせが正しくありません'
    else:
        return result.mail

@app.route('/database/reflesh', methods=['POST'])
def database_reflesh():
    username = flask.request.form['user']
    user_entity = User.query(User.name == username).get()

    #有効なEntityが返ってきた場合はリクエストに含まれるパラメータそれぞれについてPropertyを更新
    if user_entity:
        for key, value in flask.request.form.items():
            if key != 'user':
                if hasattr(user_entity, key):
                    setattr(user_entity, key, value)
        user_entity.put()
    else:
        return '該当するユーザーがみつかりませんでした'

今回は他に変更はないので、あとはデプロイするだけです。

dev_appserver.py app.yamlでテストする場合はlocalhost:8000からDatastoreへの接続をシミュレートできます(Google Cloud SDKの開発機能で、Development app serverといいます)。この環境でテストしたことは実際のDatastoreには反映されません。めちゃめちゃ便利ですね。

テストする

ちゃんと動くかテストしたいのですが、今回作ったサーバーを利用するにはPOSTリクエストを送る必要がありますね。
今回作ったようなサーバーの実際の運用ではJavaScriptなりでPOSTリクエストを送ってくれればいいのですが、ひとまずコマンドラインでPOSTリクエストを送ってみましょう。

以下はMac/Linux向けのコマンドです。Macの場合はターミナルにコードを貼ってEnterを押せばOK。

curl --include --request POST --header 'Content-Type:application/x-www-form-urlencoded' --data 'リクエストボディ' パス

まずは新しいEntityを作成してみましょう。

curl --include --request POST --header 'Content-Type:application/x-www-form-urlencoded' --data 'name=太郎&mail=taro@example.com&password=aabbcc' https://〜〜〜.appspot.com/database/new

返り値を設定していないのでエラーが返ってきますが、POST自体はできています。

GCPコンソールからデータベースをみてみる

さっきのPOSTでCloud Datastoreに新しいEntityができているはずです。GCPのコンソールから確認してみましょう。

GCPの管理画面の左上のメニューから「Datastore」→「Entities」を選びます。

f:id:simonsnote:20180603161711p:plain:w300

Kindを選択する部分があるので、今作成した「User」を選択しましょう。...どうですか?太郎さんはちゃんと追加されていますか?

追加されているのを確認したら、/database/get/database/refleshも試してみましょう。

まとめ

今回、ちゃんと動くFlaskサーバーをGAEで作ることができるようになりました。しかしながらこのままではセキュリティ上、実際の運用はできません。

次回はセキュリティ対策を施し、実際にユーザー情報を格納できるサーバーを作ります。