こんにちは。
キャスレーコンサルティングSI(システム・インテグレーション)部の小寺です。

今回はMastodonのソースを参考に、数独のwebアプリを作り、その要素技術を紹介していこうと思います。

今回使用する技術(レシピ紹介)

以下が、今回のレシピになります。

【OS】
CentOS 7
【DB】
PostgreSQL9.6
【言語】
Ruby 2.4.1
JavaScript
【その他】
rbenvhaml-rails
React
Redux
ECMAScript 2015
Webpacker
dotenv-ruby

環境構築~コーディング

まずは、CentOSにRuby on Railsの環境を構築します。

Nodejsは、uglifier等を動かすのにも必要になります。

Ruby2.4.1のインストールは、少し時間がかかります。
もしインストールに失敗する場合は、メモリが足りない可能性があります。
Swap領域を用意してください。

bundle exec rails new .をする際に、Gemfileが既にあると警告が出ますが「Y」を入力して上書きしてください。

# yum install yum-utils
# yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
# yum-config-manager --enablerepo=pgdg96
# yum install postgresql96-{contrib,devel,server}
# ln -s /usr/pgsql-9.6/bin/* /usr/bin/
# postgresql96-setup initdb
# systemctl start postgresql-9.6 && systemctl enable $_
# sudo -u postgres psql
postgres=# create role numpla with createdb login password 'ongr';
postgres=# \q
# sed -i.org '/shared_preload_libraries/ s/^#//' /var/lib/pgsql/9.6/data/postgresql.conf
# sed -i "/shared_preload_libraries/ s/''/'pg_stat_statements'/" /var/lib/pgsql/9.6/data/postgresql.conf
# sed -i "/shared_preload_libraries/a pg_stat_statements.track = all" /var/lib/pgsql/9.6/data/postgresql.conf
# systemctl restart postgresql-9.6
# yum install nodejs
# curl -sL https://dl.yarnpkg.com/rpm/yarn.repo -o /etc/yum.repos.d/yarn.repo
# yum install yarn
# yum install bzip2 gcc-c++ git {openssl,readline,zlib}-devel
$ git clone https://github.com/rbenv/rbenv.git ~/.rbenv
$ cd ~/.rbenv && src/configure && make -C src && cd ~
$ echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
$ echo 'eval "$(rbenv init -)"' >> ~/.bash_profile && source ~/.bash_profile
$ git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
$ rbenv install 2.4.1 && rbenv global $_ && rbenv rehash
$ gem install bundler
# yum install sqlite-devel
$ mkdir number-place
$ cd number-place
$ bundle init
$ vi Gemfile
gem "rails"
$ bundle install
$ bundle exec rails new .

Swap領域の確保(ruby2.4.1のインストール時にメモリが足りなかったときのみ)

# dd if=/dev/zero of=/swap bs=1M count=2048
# chmod 600 /swap
# mkswap /swap
# swapon /swap
# vi /etc/fstab

以下を追記
「/swap swap swap defaults 0 0」

Gemfileは、以下の構成になっています。

自動生成の時点との差異は
gem ‘haml-rails’
から
gem ‘grape’
までの部分になります。

(※シンタックスハイライターでは行頭スペースが省略されるため、インデントも正確に表現できるようにしています。)

Gemfile


source 'https://rubygems.org'
git_source(:github) do |repo_name|
repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
"https://github.com/#{repo_name}.git"
end
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 5.1.1'
# Use sqlite3 as the database for Active Record
gem 'sqlite3'
# Use Puma as the app server
gem 'puma', '~> 3.7'
# Use SCSS for stylesheets
gem 'sass-rails', '~> 5.0'
# Use Uglifier as compressor for JavaScript assets
gem 'uglifier', '>= 1.3.0'
# See https://github.com/rails/execjs#readme for more supported runtimes
# gem 'therubyracer', platforms: :ruby
# Use CoffeeScript for .coffee assets and views
gem 'coffee-rails', '~> 4.2'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.5'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 3.0'
# Use ActiveModel has_secure_password
# gem 'bcrypt', '~> 3.1.7'
# Use Capistrano for deployment
# gem 'capistrano-rails', group: :development
gem 'haml-rails'
gem 'erb2haml'
gem 'pg'
gem 'rails-settings-cached'
gem 'react-rails'
gem 'rabl'
gem 'webpacker', github: "rails/webpacker"
gem 'foreman'
gem 'dotenv-rails', require: 'dotenv/rails-now'
gem 'oj'
gem 'grape'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
# Adds support for Capybara system testing and selenium driver
gem 'capybara', '~> 2.13'
gem 'selenium-webdriver'
end
group :development do
# Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
gem 'web-console', '>= 3.3.0'
gem 'listen', '>= 3.0.5', '< 3.2'
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]


上記差異部分は、すべてMastodonを参考にした構成になります。
ただし、最新版のMastodonではhaml-railsではなく、hamlit-railsを用いていたりと微妙に変更点があります。

続いて、package.jsonです。
自動生成時点では、nameとprivateがあるだけなので、
どんどん追加していきます。

こちらは、ほぼMastodonからの流用になります。
バージョン等は、Mastodonの最新版と違っている可能性はあります。

package.json


{
"dependencies": {
"array-includes": "^3.0.3",
"autoprefixer": "^7.0.1",
"axios": "^0.16.1",
"babel-core": "^6.24.1",
"babel-loader": "7.x",
"babel-polyfill": "^6.23.0",
"babel-preset-env": "^1.4.0",
"babel-preset-stage-0": "^6.24.1",
"coffee-loader": "^0.7.3",
"coffee-script": "^1.12.5",
"compression-webpack-plugin": "^0.4.0",
"css-loader": "^0.28.1",
"es6-symbol": "^3.1.1",
"extract-text-webpack-plugin": "^2.1.0",
"file-loader": "^0.11.1",
"font-awesome": "^4.7.0",
"glob": "^7.1.1",
"immutable": "^3.8.1",
"intl": "^1.2.5",
"is-nan": "^1.2.1",
"js-yaml": "^3.8.4",
"node-sass": "^4.5.2",
"path-complete-extname": "^0.1.0",
"postcss-loader": "^2.0.5",
"postcss-smart-import": "^0.7.0",
"precss": "^1.4.0",
"prop-types": "^15.5.10",
"rails-erb-loader": "^5.0.0",
"rails-ujs": "^5.1.1",
"react": "^15.5.4",
"react-addons-perf": "^15.4.2",
"react-dom": "^15.5.4",
"react-immutable-pure-component": "0.0.4",
"react-intl": "^2.3.0",
"react-redux": "^5.0.4",
"redux": "^3.6.0",
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.2.0",
"sass-loader": "^6.0.5",
"style-loader": "^0.17.0",
"webpack": "^2.5.1",
"webpack-manifest-plugin": "^1.1.0",
"webpack-merge": "^4.1.0"
},
"devDependencies": {
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"webpack-dev-server": "^2.4.5"
}
}

では、諸々インストールをします。

$ bundle install
$ yarn install --pure-lockfile
$ bundle exec rails webpacker:install

何もしないと、ES2015の記法に対応できないので.babelrcを編集します。

.babelrc


{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": "> 1%",
"uglify": true
},
"useBuiltIns": true
}],
"es2015", "react", "stage-0"
]
}

webpack

webpackの設定です。
gz圧縮を行う設定をします。
他はdevelopment等と同じなので、environmentにまとめます。

やはりMastodonの構成をもとにしていますが、過渡期のソースをもとにしていますため、
現在の構成とは大幅に変わっています。

config/webpack/production.js


// Note: You must restart bin/webpack-dev-server for changes to take effect
/* eslint global-require: 0 */
const webpack = require('webpack')
const merge = require('webpack-merge')
const CompressionPlugin = require('compression-webpack-plugin')
const sharedConfig = require('./environment.js')
module.exports = merge(sharedConfig, {
output: { filename: '[name]-[chunkhash].js' },
plugins: [
new CompressionPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf)$/
})
]
})

共通設定です。
細かい設定は、configuration.jsに入れます。

何度も呼ばれるjs部分を、vendor.jsに切り出す設定などをしています。
他は、configuration.jsの定義を読み取る設定です。

config/webpack/environment.js


// Note: You must restart bin/webpack-dev-server for changes to take effect
/* eslint global-require: 0 */
/* eslint import/no-dynamic-require: 0 */
const webpack = require('webpack')
const { basename, dirname, join, relative, resolve } = require('path')
const { sync } = require('glob')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')
const extname = require('path-complete-extname')
const { env, paths, publicPath, loadersDir } = require('./configuration.js')
const extensionGlob = `**/*{${paths.extensions.join(',')}}*`
const packPaths = sync(join(paths.source, paths.entry, extensionGlob))
module.exports = {
  entry: packPaths.reduce(
    (map, entry) => {
      const localMap = map
      const namespace = relative(join(paths.source, paths.entry), dirname(entry))
      localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry)
      return localMap
    }, {}
  ),
  output: {
    filename: '[name].js',
    path: resolve(paths.output, paths.entry),
    publicPath
  },
  module: {
    rules: sync(join(loadersDir, '*.js')).map(loader => require(loader))
  },
  plugins: [
    new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))),
    new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[hash].css' : '[name].css'),
    new ManifestPlugin({ fileName: paths.manifest, publicPath, writeToFileEmit: true }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: ({ resource }) => /node_modules/.test(resource)
    })
  ],
  resolve: {
    extensions: paths.extensions,
    modules: [
      resolve(paths.source),
      resolve(paths.node_modules)
    ]
  },
  resolveLoader: {
    modules: [paths.node_modules]
  }
}

設定値を、paths.ymlに記述することを設定します。

config/webpack/configuration.js


// Common configuration for webpacker loaded from config/webpack/paths.yml
const { join, resolve } = require('path')
const { env } = require('process')
const { safeLoad } = require('js-yaml')
const { readFileSync } = require('fs')
const configPath = resolve('config', 'webpack')
const loadersDir = join(__dirname, 'loaders')
const paths = safeLoad(readFileSync(join(configPath, 'paths.yml'), 'utf8'))[env.NODE_ENV]
const devServer = safeLoad(readFileSync(join(configPath, 'development.server.yml'), 'utf8'))[env.NODE_ENV]
// Compute public path based on environment and ASSET_HOST in production
const ifHasCDN = env.ASSET_HOST !== undefined && env.NODE_ENV === 'production'
const devServerUrl = `http://${devServer.host}:${devServer.port}/${paths.entry}/`
const publicUrl = ifHasCDN ? `${env.ASSET_HOST}/${paths.entry}/` : `/${paths.entry}/`
const publicPath = env.NODE_ENV !== 'production' && devServer.enabled ? devServerUrl : publicUrl
module.exports = {
devServer,
env,
paths,
loadersDir,
publicUrl,
publicPath
}

コンパイル対象のパスや拡張子を設定します。

config/webpack/paths.yml

# Note: You must restart bin/webpack-dev-server for changes to take effect
default: &default
config: config/webpack
entry: packs
output: public
manifest: manifest.json
node_modules: node_modules
source: app/javascript
extensions:
- .coffee
- .js
- .jsx
- .ts
- .vue
- .sass
- .scss
- .css
- .png
- .svg
- .gif
- .jpeg
- .jpg
development:
<<: *default
test:
<<: *default
manifest: manifest-test.json
production:
<<: *default

以下、拡張子ごとの挙動を定義する部分になります。

config/webpack/loaders/assets.js


const { env, publicPath } = require('../configuration.js')
module.exports = {
test: /\.(jpg|jpeg|png|gif|svg|eot|ttf|woff|woff2)$/i,
use: [{
loader: 'file-loader',
options: {
publicPath,
name: env.NODE_ENV === 'production' ? '[name]-[hash].[ext]' : '[name].[ext]'
}
}]
}

config/webpack/loaders/babel.js


module.exports = {
test: /\.js(\.erb)?$/,
exclude: /node_modules/,
loader: 'babel-loader'
}

config/webpack/loaders/coffee.js


module.exports = {
test: /\.coffee(\.erb)?$/,
loader: 'coffee-loader'
}

config/webpack/loaders/erb.js


module.exports = {
test: /\.erb$/,
enforce: 'pre',
exclude: /node_modules/,
loader: 'rails-erb-loader',
options: {
runner: 'bin/rails runner'
}
}

config/webpack/loaders/sass.js


const ExtractTextPlugin = require('extract-text-webpack-plugin')
const { env } = require('../configuration.js')
module.exports = {
test: /\.(scss|sass|css)$/i,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [
{ loader: 'css-loader', options: { minimize: env.NODE_ENV === 'production' } },
'postcss-loader',
'sass-loader'
]
})
}

ここまでで、webpackの準備が完了です。

CSS

webpackで生成させるファイルの準備に入ります。
CSSの管理も行います。

app/javascript/packs/application.js


import 'font-awesome/css/font-awesome.css';
import '../styles/application.scss';
import '../styles/number_place.scss';

JavaScriptは、こちらにまとめます。
polyfillsを用います。

app/javascript/packs/number_place.js


import main from '../number_place/main';
if (!window.Intl || !Object.assign || !Number.isNaN ||
!window.Symbol || !Array.prototype.includes) {
import('../number_place/polyfills').then(main);
} else {
main();
}

全体に効かせたいCSSは、こちらに記述します。
今回は空ファイルです。

app/javascript/styles/application.scss


数独のCSSは、こちらにまとめます。
clearfixのみ定義し、部品ごとにSCSSファイルを分けます。

app/javascript/styles/number_place.scss


@import 'number_place_table';
@import 'manip_buttons';
.clearfix {
display: inline-table;
>div {
float: left;
}
&:after {
content: " ";
display: block;
height: 0;
clear: both;
visibility: hidden;
}
}

数独の問題のSCSSです。
ハイライトの色などの設定も、ここに記述します。

app/javascript/styles/number_place_table.scss


table.number-place-table {
margin: 1em auto;
border-collapse: collapse;
>tr {
>td {
height: 60px;
width: 60px;
border: 1px solid;
text-align: center;
font-size: 40px;
div {
height: 100%;
width: 100%;
}
div:hover {
background-color: gray;
}
div.question-data {
color: black;
}
div.playing-data {
color: cyan;
}
div.test-data{
color: red;
}
&:first-child {
border-left: 2px solid;
}
&:nth-child(3n) {
border-right: 2px solid;
}
table.element-table {
margin: auto;
border: none;
tr td {
height: 10px;
width: 10px;
border: none;
font-size: 7px;
div.can-delete {
color: red;
}
}
}
&.help-at {
background-color: pink;
}
&.help-by {
background-color: lime;
}
}
&:first-child>td {
border-top: 2px solid;
}
&:nth-child(3n)>td {
border-bottom: 2px solid;
}
}
&.cell-hover>tr>td:hover{
background-color: gray;
}
}

操作ボタンに、CSSをつける場合はこちらに記述しますが、今回はつけません。

app/javascript/styles/manip_buttons.scss


CSSは、以上になります。

React+Redux

ここからが、React+ReduxのJavaScriptになります。

ところどころ簡略化していますが、Mastodonの構成をほぼそのまま用いています。
簡略化しているため、Mastodonのソースが複雑で追いきれない場合、こちらでソースを追う訓練をすると追いやすくなります。

まずは、polyfills

app/javascript/number_place/polyfills.js


import 'intl';
import 'intl/locale-data/jsonp/en.js';
import 'es6-symbol/implement';
import includes from 'array-includes';
import assign from 'object-assign';
import isNaN from 'is-nan';
if (!Array.prototype.includes) {
includes.shim();
}
if (!Object.assign) {
Object.assign = assign;
}
if (!Number.isNaN) {
Number.isNaN = isNaN;
}

次に、数独のJavaScriptのmainです。
onDomContentLoadedで読み込み終わり次第、表示させるようにします。
idが「number-place」の要素に表示させます。

app/javascript/number_place/main.js


function onDomContentLoaded(callback) {
if (document.readyState !== 'loading') {
callback();
} else {
document.addEventListener('DOMContentLoaded', callback);
}
}
function main() {
const NumberPlace = require('number_place/containers/number_place').default;
const React = require('react');
const ReactDOM = require('react-dom');
const Rails = require('rails-ujs');
window.Perf = require('react-addons-perf');
Rails.start();
onDomContentLoaded(() => {
const mountNode = document.getElementById('number-place');
const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(
<NumberPlace {...props}/>, mountNode
)
});
}
export default main

表示させる数独の全体です。
問題情報を読み込むために、idが「initial-state」の要素を読み込みます。

app/javascript/number_place/container/number_place.js


import React from 'react';
import { Provider } from 'react-redux';
import configureStore from '../store/configureStore';
import Game from '../features/game';
import { hydrateStore } from '../actions/store';
const store = configureStore();
const initialState = JSON.parse(document.getElementById('initial-state').textContent);
store.dispatch(hydrateStore(initialState));
class App extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
renderedPersistents: [],
unrenderedPersistents: [],
};
}
componentWillMount() {
}
render() {
const { locale } = this.props
return (
<Provider store={store}>
<Game />
</Provider>
)
}
}
export default App;

Reduxの構成要素です。
特に難しいことはしません。

app/javascript/number_place/store/configureStore.js


import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import appReducer from '../reducers';
import Immutable from 'immutable';
export default function configurestore() {
return createStore(appReducer, compose(applyMiddleware(
thunk
), window.decToolsExtension ? window.devToolsExtension() : f => f));
};

JSONで受け取ったデータを使いやすくするため、ハイドレートします。

app/javascript/number_place/actions/store.js


import Immutable from 'immutable';
export const STORE_HYDRATE = 'STORE_HYDRATE';
const convertState = rawState =>
Immutable.fromJS(rawState, (k, v) =>
Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
Number.isNaN(x * 1) ? x : x* 1));
export function hydrateStore(rawState) {
const state = convertState(rawState);
return {
type: STORE_HYDRATE,
state: state
};
};

Reduxの重要な要素である、Reducerの部品です。
諸関数も、ここに記述します。
複数のReducerで構成できるようにcombineReducersを使いますが、
今回はReducerが一つなので些か冗長です。

app/javascript/number_place/reducers/index.js


import { combineReducers } from 'redux-immutable';
import data from './data';
export default combineReducers({
data
});

app/javascript/number_place/reducers/data.js


import { STORE_HYDRATE } from '../actions/store';
import { CHANGE_MODE, SELECT_POINT, SELECT_NUMBER, MODE_INPUT, MODE_TEST, MODE_NOTE, MODE_DELETE, ANSWER_CHECK, HELP } from '../actions/number_place';
import Immutable from 'immutable';
export const initialState = Immutable.Map({
question: Array(81 + 1).join("0"),
playingData: Array(81 + 1).join("0"),
test: Array(81 + 1).join("0"),
notes: Array(80 + 1).join(","),
mode: MODE_INPUT,
inputVal: 0,
cleared: false
});
export default function data(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return state.merge(action.state.get('data'));
case CHANGE_MODE:
return state.merge(Immutable.Map({mode: action.mode, inputVal: 0}));
case SELECT_POINT:
let value = action.value;
const index = action.pointI + action.pointJ * 9;
const prevInputVal = state.get('inputVal');
const mode = state.get('mode');
let playingDataArr = state.get('playingData').split('');
let testPutsArr = state.get('test').split('');
let notesArr = state.get('notes').split(',');
if (mode == MODE_INPUT) {
if (prevInputVal != 0) {
playingDataArr[index] = prevInputVal;
value = 0;
}
} else if (mode == MODE_TEST) {
if (prevInputVal != 0) {
testPutsArr[index] = prevInputVal;
value = 0;
}
} else if (mode == MODE_NOTE) {
if (notesArr[index].indexOf(value) != -1) {
const regExp = new RegExp(value, "g");
notesArr[index] = notesArr[index].replace(regExp, "");
} else {
notesArr[index] = notesArr[index] + value;
}
} else if (mode == MODE_DELETE) {
playingDataArr[index] = 0;
testPutsArr[index] = 0;
value = 0;
}
return state.merge(Immutable.Map({inputVal: value, playingData: playingDataArr.join(''), test: testPutsArr.join(''), notes: notesArr.join(',')}));
case SELECT_NUMBER:
return state.set('inputVal', action.value);
case ANSWER_CHECK:
const checkQuestionArr = state.get('question').split('');
const checkPlayingDataArr = state.get('playingData').split('');
const checkTestPutsArr = state.get('test').split('');
let myAnswerArr = Array(81);
for (let i = 0; i < 81; i++) {
myAnswerArr[i] = checkQuestionArr[i] != 0 ? checkQuestionArr[i] :
checkPlayingDataArr[i] != 0 ? checkPlayingDataArr[i] :
checkTestPutsArr[i] != 0 ? checkTestPutsArr[i] : 0
}
const myAnswer = myAnswerArr.join('');
const cleared = action.answer == myAnswer;
return state.set('cleared', cleared);
case HELP:
let isNotesBlank = true;
let checkPlayingArr = state.get('playingData').split('');
let checkNotesArr = state.get('notes').split(',');
for (let i = 0; i < 81; i++) {
if (checkNotesArr[i] != '') {
isNotesBlank = false;
break;
}
}
if (isNotesBlank) {
for (let i = 0; i < 81; i++) {
checkNotesArr[i] = '123456789';
}
return state.set('notes', checkNotesArr.join(','));
}
let helpMarker = getHelpMarker(state);
const preMarker = state.get('helpMarker');
if (preMarker
&& preMarker.at == helpMarker.at
&& preMarker.number == helpMarker.number) {
if (helpMarker.number == 0) {
checkPlayingArr[helpMarker.at] = checkNotesArr[helpMarker.at];
helpMarker = getHelpMarker(state.set('playingData', checkPlayingArr.join('')));
} else {
const regExp = new RegExp(helpMarker.number, "g");
checkNotesArr[helpMarker.at] = checkNotesArr[helpMarker.at].replace(regExp, '');
helpMarker = getHelpMarker(state.set('notes', checkNotesArr.join(',')));
}
}
return state.merge(Immutable.Map({
helpMarker: helpMarker,
notes: checkNotesArr.join(','),
playingData: checkPlayingArr.join('')
}));
default:
return state;
}
};
function getHelpMarker(state) {
const preHelp = state.get('helpMarker');
const questionArr = state.get('question').split('');
const playingArr = state.get('playingData').split('');
const testArr = state.get('test').split('');
let notesArr = state.get('notes').split(',');
let fixedArr = Array(81);
for (let i = 0; i < 81; i++) {
fixedArr[i] = questionArr[i] != 0 ? questionArr[i] :
playingArr[i] != 0 ? playingArr[i] :
testArr[i];
if (fixedArr[i] == 0 && notesArr[i].length == 1) {
return {
at: i,
number: 0,
kind: 'singleNote',
by: []
};
}
}
const testFuncs = [xCheck, yCheck, boxCheck,
xNakedPair, yNakedPair, boxNakedPair];
for (const func of testFuncs) {
const ret = func(fixedArr, notesArr);
if (ret) {
return ret;
}
}
}
function xCheck(fixed, notes) {
return notesCheck(fixed, notes, xArr());
}
function yCheck(fixed, notes) {
return notesCheck(fixed, notes, yArr());
}
function boxCheck(fixed, notes) {
return notesCheck(fixed, notes, boxArr());
}
function notesCheck(fixed, notes, arr) {
for (const block of arr) {
let fixedBlocks = [];
for (const index of block) {
if (fixed[index] != 0) {
fixedBlocks.push({
index: index,
fixed: fixed[index]
});
}
}
for (const index of block) {
if (fixed[index] == 0 && notes[index] != '') {
for (const fb of fixedBlocks) {
if (notes[index].indexOf(fb.fixed) != -1) {
return {
at: index,
number: fb.fixed,
kind: 'simpleCheck',
by: [fb.index],
};
}
}
}
}
}
return false;
}
function xNakedPair(fixed, notes) {
return nakedPair(fixed, notes, xArr());
}
function yNakedPair(fixed, notes) {
return nakedPair(fixed, notes, yArr());
}
function boxNakedPair(fixed, notes) {
return nakedPair(fixed, notes, boxArr());
}
function nakedPair(fixed, notes, arr) {
for (const block of arr) {
let notesIndexes = [];
for (const index of block) {
if (fixed[index] == 0) {
notesIndexes.push(index);
}
}
for (let n = 2; n < notesIndexes.length; n++) {
const indexesCombis = combi(notesIndexes, n);
for (const indexesCombi of indexesCombis) {
let includeNumbers = [];
for (const index of indexesCombi) {
const note = notes[index].split('');
for (const n of note) {
if (!includeNumbers.includes(n)) {
includeNumbers.push(n);
}
}
}
if (includeNumbers.length == n) {
const elseIndexes = arrayDiff(notesIndexes, indexesCombi);
for (const index of elseIndexes) {
for (const number of includeNumbers) {
if (notes[index].indexOf(number) != -1) {
return {
at: index,
number: number,
kind: 'nakedPair',
by: indexesCombi
};
}
}
}
}
}
}
}
return false;
}
function xArr() {
let ret = [];
for (let y = 0; y < 9; y++) {
let line = [];
for (let x = 0; x < 9; x++) {
line.push(x + y * 9);
}
ret.push(line);
}
return ret;
}
function yArr() {
let ret = [];
for (let x = 0; x < 9; x++) {
let line = [];
for (let y = 0; y < 9; y++) {
line.push(x + y * 9);
}
ret.push(line);
}
return ret;
}
function boxArr() {
let ret = [];
for (let i = 0; i < 9; i++) {
let box = [];
for (let j = 0; j < 9; j++) {
const x = (i % 3) * 3 + (j % 3);
const y = Math.floor(i / 3) * 3 + Math.floor(j / 3);
box.push(x + y * 9);
}
ret.push(box);
}
return ret;
}
function combi(arr, takes) {
if (takes > arr.length || takes <= 0) {
return [];
}
if (takes == arr.length) {
return [arr];
}
let ret = [];
if (takes == 1) {
for (const i of arr) {
ret.push([i]);
}
return ret;
}
for (let i = 0; i < arr.length - takes + 1; i++) {
const head = [arr[i]];
const tailCombi = combi(arr.slice(i + 1), takes - 1);
for (const tail of tailCombi) {
ret.push(head.concat(tail));
}
}
return ret;
}
function arrayDiff(a, b) {
let ret = [];
for (const aElem of a) {
if (!b.includes(aElem)) {
ret.push(aElem);
}
}
return ret;
}

各パーツを組み立てる部分です。
ここで、connectしReduxを用います。

app/javascript/number_place/features/game/index.js


import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Question from '../../components/question';
import ManipButtons from '../../components/manip_buttons';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { setMode, selectPoint, selectNumber, answerCheck, help, MODE_INPUT, MODE_TEST, MODE_NOTE, MODE_DELETE } from '../../actions/number_place'
const mapStateToProps = (state, props) => ({
id: state.getIn(['data', 'id']),
question: state.getIn(['data', 'question']),
playingData: state.getIn(['data', 'playingData']),
testPuts: state.getIn(['data', 'test']),
notes: state.getIn(['data', 'notes']),
mode: state.getIn(['data', 'mode']),
inputVal: state.getIn(['data', 'inputVal']),
cleared: state.getIn(['data', 'cleared']),
helpMarker: state.getIn(['data', 'helpMarker']),
});
const mapDispatchToProps = (dispatch) => ({
onElementClick(i, j, v) {
dispatch(selectPoint(i, j, v));
},
onModeClick(mode) {
dispatch(setMode(mode));
},
onNumberSelect(v) {
dispatch(selectNumber(v));
},
onAnswerCheckClick(id) {
dispatch(answerCheck(id));
},
onHelpClick() {
dispatch(help());
},
});
class Game extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
id: PropTypes.number.isRequired,
question: PropTypes.string.isRequired,
playingData: PropTypes.string,
testPuts: PropTypes.string,
notes: PropTypes.string,
mode: PropTypes.string,
inputVal: PropTypes.number,
cleared: PropTypes.bool.isRequired,
helpMarker: PropTypes.object,
onElementClick: PropTypes.func.isRequired,
onModeClick: PropTypes.func.isRequired,
onNumberSelect: PropTypes.func.isRequired,
onAnswerCheckClick: PropTypes.func.isRequired,
onHelpClick: PropTypes.func.isRequired,
};
componentWillMount() {
}
componentWillReceiveProps(nextProps) {

}
render() {
const { id, question, playingData, testPuts, notes, mode, inputVal, cleared, helpMarker, onElementClick, onModeClick, onNumberSelect, onAnswerCheckClick, onHelpClick } = this.props;
let tableClass = ”;
if ((mode == MODE_INPUT && inputVal != 0)
|| (mode == MODE_TEST && inputVal != 0)
|| (mode == MODE_DELETE)) {
tableClass = ‘cell-hover’;
}
return (
<div className=”clearfix”>
<Question question={question} playingData={playingData} testPuts={testPuts} notes={notes} tableClass={tableClass} helpMarker={helpMarker} onElementClick={onElementClick} />
<ManipButtons mode={mode} inputVal={inputVal} cleared={cleared} helpMarker={helpMarker} onModeClick={onModeClick} onNumberSelect={onNumberSelect} onAnswerCheckClick={() => {onAnswerCheckClick(id)}} onHelpClick={onHelpClick}/>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Game);

数独の表示をする部品です。
一マスは、別の部品に切り分けます。

app/javascript/number_place/components/question.js


import React from 'react';
import PropTypes from 'prop-types';
import BoardElement from './board_element'
export default class Question extends React.PureComponent {
static propTypes = {
question: PropTypes.string.isRequired,
playingData: PropTypes.string,
testPuts: PropTypes.string,
notes: PropTypes.string,
tableClass: PropTypes.string,
helpMarker: PropTypes.object,
onElementClick: PropTypes.func.isRequired
};
static defaultProps = {
playingData: Array(81 + 1).join("0"),
testPuts: Array(81 + 1).join("0"),
notes: Array(80 + 1).join(","),
tableClass: '',
helpMarker: {at: -1},
};
render() {
const { question, playingData, testPuts, notes, tableClass, helpMarker, onElementClick } = this.props;
const wholeTableClass = 'number-place-table ' + tableClass;
const questionArr = question.split('');
const playingDataArr = playingData.split('');
const testPutArr = testPuts.split('');
const noteArr = notes.split(',');
let board = [];
let line = [];
for (let i = 0; i < 81; i++) {
line.push([questionArr[i], playingDataArr[i], testPutArr[i], noteArr[i]]);
if (line.length >= 9) {
board.push(line.concat());
line = [];
}
}
let table = [];
let indexJ = 0;
for (const line of board) {
let tr = [];
let indexI = 0;
for (const c of line) {
const index = indexI + indexJ * 9;
let tdClass = [];
let dataHelp = '';
let canDelete = '0';
if (helpMarker.at == index) {
tdClass.push('help-at');
dataHelp = helpMarker.kind;
canDelete = helpMarker.number;
}
if (helpMarker.by && helpMarker.by.includes(index)) {
tdClass.push('help-by');
}
tr.push(<td className={tdClass} data-help={dataHelp}><BoardElement question={c[0]} playingData={c[1]} testPut={c[2]} note={c[3]} pointI={indexI} pointJ={indexJ} canDelete={canDelete} onElementClick={onElementClick} /></td>);
indexI++;
}
table.push(<tr>{tr}</tr>);
indexJ++;
}
return (
<div>
<table className={wholeTableClass}>
{table}
</table>
</div>
);
}
}

一マスの部品になります。
確定や仮置きしていない場合は、メモを3×3で表示できるようにします。

app/javascript/number_place/components/board_element.js


import React from 'react';
import PropTypes from 'prop-types';
import { setMode, selectPoint } from '../actions/number_place'
export default class BoardElement extends React.PureComponent {
static propTypes = {
question : PropTypes.string.isRequred,
playingData : PropTypes.string,
testPut : PropTypes.string,
note : PropTypes.string,
pointI : PropTypes.number.isRequired,
pointJ : PropTypes.number.isRequired,
canDelete : PropTypes.string,
onElementClick : PropTypes.func.isrequired
};
static defaultProps ={
playingData : '0',
testPut : '0',
note : '123456789',
canDelete : '0',
};
onElementClick = (v) => {
this.props.onElementClick(this.props.pointI, this.props.pointJ, v);
}
render() {
const { question, playingData, testPut, note, canDelete } = this.props;
if (question != '0') {
return <div className="question-data">{question}</div>
}
if (playingData != '0') {
return <div className="playing-data" onClick={() => this.onElementClick(0)}>{playingData}</div>
}
if (testPut != '0') {
return <div className="test-data" onClick={() => this.onElementClick(0)}>{testPut}</div>
}
let table = [];
for (let i = 0; i < 3; i++) {
let tr = [];
for (let j = 1; j <= 3; j++) {
const v = i * 3 + j;
const n = (note.indexOf(v) != -1) ? v : '';
const divClass = (v == canDelete) ? 'can-delete' : '';
tr.push(<td><div className={divClass} onClick={() => this.onElementClick(v)}>{n}</div></td>);
}
table.push(<tr>{tr}</tr>);
}
return <table className="element-table">{table}</table>
}
};

定数とアクションを定義します。
答え合わせを行うのに、Ajax通信するのでaxiosを用います。
Mastodonでも同様に、actions等でAjax通信を組み込んでいます。

app/javascript/number_place/actions/number_place.js


import axios from 'axios';
export const CHANGE_MODE = 'CHANGE_MODE';
export const SELECT_POINT = 'SELECT_POINT';
export const SELECT_NUMBER = 'SELECT_NUMBER';
export const ANSWER_CHECK = 'ANSWER_CHECK';
export const HELP = 'HELP';
export const MODE_INPUT = 'INPUT';
export const MODE_TEST = 'TEST';
export const MODE_NOTE = 'NOTE';
export const MODE_DELETE = 'DELETE';
export const REQ_DATA = 'REQ_DATA';
export const RECV_DATA = 'RECV_DATA';
export const RECV_ERROR = 'RECV_ERROR';
export function setMode(mode) {
return {
type: CHANGE_MODE,
mode: mode
};
};
export function selectPoint(i, j, v) {
return {
type: SELECT_POINT,
pointI: i,
pointJ: j,
value: v
};
};
export function selectNumber(v) {
return {
type: SELECT_NUMBER,
value: v
};
}
export function answerCheck(id) {
return function(dispatch) {
return axios({
url: '/api/v1/game/answerCheck',
timeout: 20000,
method: 'post',
responseType: 'json',
data: {
id: id
}
})
.then(function(response) {
dispatch({
type: ANSWER_CHECK,
answer: response.data.answer
});
})
.catch(function(error) {
alert('ajax failed!');
});
}
}
export function help() {
return {
type: HELP
};
}

盤面のみでは操作しづらいので、隣に操作のボタンを表示させます。
数字の選択部分は、長くなるので分離します。

app/javascript/number_place/components/manip_buttons.js


import React from 'react';
import PropTypes from 'prop-types';
import NumberInput from './number_input';
import { MODE_INPUT, MODE_TEST, MODE_NOTE, MODE_DELETE } from '../actions/number_place';
export default class ManipButtons extends React.PureComponent {
static propTypes = {
mode: PropTypes.string,
inputVal: PropTypes.number,
cleared: PropTypes.bool.isRequired,
helpMarker: PropTypes.object,
onModeClick: PropTypes.func.isRequired,
onNumberSelect: PropTypes.func.isRequired,
onAnswerCheckClick: PropTypes.func.isRequired,
onHelpClick: PropTypes.func.isRequired,
};
static defaultProps = {
mode: MODE_INPUT,
inputVal: 0,
helpMarker: {
at: -1,
number: 0,
kind: '',
by: []
}
};
render() {
const { mode, inputVal, cleared, helpMarker, onModeClick, onNumberSelect, onAnswerCheckClick, onHelpClick } = this.props;
const clearLabel = cleared ? "cleared" : "unsolved";
return <div>
cleared:{clearLabel}<br />
mode:{mode}<br />
選択番号:{inputVal}<br />
HELP:{helpMarker.kind}
<ul>
<li><button onClick={() => onModeClick(MODE_INPUT)}>記入</button></li>
<li><button onClick={() => onModeClick(MODE_TEST)}>仮置き</button></li>
<li><button onClick={() => onModeClick(MODE_NOTE)}>候補</button></li>
<li><button onClick={() => onModeClick(MODE_DELETE)}>削除</button></li>
<li><button onClick={() => onAnswerCheckClick()}>答え合わせ</button></li>
<li><button onClick={() => onHelpClick()}>HELP</button></li>
</ul>
<NumberInput onNumberSelect={onNumberSelect} />
</div>
}
}

数字の選択部分です。
数字のボタンは、さらに別要素に分けます。

app/javascript/number_place/components/number_input.js


import React from 'react';
import PropTypes from 'prop-types';
import NumberSelectButton from './number_select_button';
export default class NumberInput extends React.PureComponent {
static propTypes = {
onNumberSelect: PropTypes.func.isRequired
};
render() {
const { onNumberSelect } = this.props;
let table = [];
for (let i = 0; i < 3; i++) {
let tr = [];
for (let j = 1; j <= 3; j++) {
const v = i * 3 + j;
tr.push(<td><NumberSelectButton num={v} onNumberSelect={onNumberSelect} /></td>);
}
table.push(<tr>{tr}</tr>);
}
return <div>
<table className="number-input-table">
{table}
</table>
</div>
}
}

app/javascript/number_place/components/number_select_button.js


import React from 'react';
import PropTypes from 'prop-types';
export default class NumberSelectButton extends React.PureComponent {
static propTypes = {
num: PropTypes.number.isRequired,
onNumberSelect: PropTypes.func.isRequired
};
onNumberSelect(num) {
this.props.onNumberSelect(this.props.num);
}
render() {
const { num, onNumberSelect } = this.props;
return <button onClick={() => this.onNumberSelect()}>{num}</button>
}
}

ここまでが、React+ReduxのJavaScriptになります。
あとは、問題のデータを表示する部分を作成すれば完成です。

Ruby on Rails

ここからは、Ruby on Railsになります。
コントローラーを作成します。

$ bundle exec rails generate controller game index

タイトルなど、全画面共通の設定を供給するヘルパーです。

app/helpers/application_helper.rb


module ApplicationHelper
def add_rtl_body_class(other_classes)
other_classes = "#{other_classes} rtl" if [:ar, :fa, :he].include?(I18n.locale)
other_classes
end
def title
Rails.env.production? ? site_title : "#{site_title}(Dev)"
end
end

数独での設定を供給するヘルパーです。

app/helpers/game_helper.rb


module GameHelper
def default_props
{
locale: I18n.locale,
}
end
end

Mastodonにならい、インスタンスの設定を持つヘルパーも作ります。

app/helpers/instance_helper.rb


# frozen_string_literal: true
module InstanceHelper
def site_title
Setting.site_title.to_s
end
def site_hostname
Rails.configuration.x.local_domain
end
end

ルートは、必要なものをどんどん追加します。
game/random:問題一覧からランダムに出題します。
game/generate/(問題情報):問題を作成します。
game/randomGenerate:プログラムに自動で問題を作成させます。
game/index:問題の一覧です。
game/show/(id):問題の画面です。
api/v1/game/answerCheck:ajaxで答え合わせをするのに使用します。

config/routes.rb


Rails.application.routes.draw do
get 'game/random' => 'game#random'
get 'game/generate/:data' => 'game#generate'
get 'game/randomGenerate' => 'game#randomGenerate'
resources :game, :only => [:index, :show]
namespace :api do
namespace :v1 do
namespace :game do
post "answerCheck", :action => "answerCheck"
end
end
end
get 'welcome/index'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
root 'welcome#index'
end

コントローラーです。
問題データを渡されて作成する前に、問題として成立しているかチェックします。

app/controllers/game_controller.rb


class GameController < ApplicationController
def show
@question = Question.find(params[:id])
end
def index
@questions = Question.all
end
def generate
gQuestion = Question.new(:question => params[:data])
gQuestion.solve()
if !gQuestion.isUnique?()
redirect_to action: 'index' if !gQuestion.isUnique?()
return
end
aQuestions = Question.where(answer: gQuestion.answer)
gq = gQuestion.question.split("")
gIsFind = false

if aQuestions.length > 0
childExists = false
aQuestions.each { |q|
qq = q.question.split(“”)
gIsParent = true
gIsChild = true
for i in 0..80
if gq[i] != qq[i]
if gq[i] == “0”
gIsChild = false
else
gIsParent = false
end
end
end
if gIsParent
if childExists
q.delete
continue
else
q.question = gQuestion.question
q.save
end
childExists = true
end
if gIsParent || gIsChild
@question = q
gIsFind = true
end
}
end
if !gIsFind
gQuestion.save
@question = gQuestion
end
redirect_to game_path(@question)
end
def randomGenerate
@question = Question.new
@question.generateQuestion()
redirect_to “/game/generate/” + @question.question
end
def random
@question = Question.where(‘id >= ?’, rand(Question.first.id..Question.last.id)).first
redirect_to game_path(@question)
end
def answerCheck
respond_to :json
@question = Question.find(params[:id])
@ret = (@question.answer == params[:answer])
end
end

Ajaxの答え合わせ用APIのコントローラーです。

app/controllers/api/v1/game_controller.rb


class Api::V1::GameController < ApplicationController
skip_before_action :verify_authenticity_token
def answerCheck
@question = Question.find(params[:id])
ret = {'answer' => @question.answer}
render :json => ret
end
end

数独の問題のモデルです。
ソルバも含みます。

app/models/question.rb


require 'benchmark'
class Question < ApplicationRecord
def solve()
result = Benchmark.realtime do
solveInit()
readQuestion()
if @mat.flatten().count { |n| n != 0 } > 16
begini, beginj = getNextCand()
searchRec(begini, beginj)
end
@isSolved = true
self.answer = @answers[0].join("") if isUnique?()
end
end

def generateQuestion()
solveInit()
questionMat = 9.times.collect { |i| Array.new(9, 0) }
while true
@mat = Marshal.load(Marshal.dump(questionMat))
@answers = []
updateCand()

insertPoint = 0.upto(8).collect { |i|
0.upto(8).collect { |j|
[isEmpty?(i, j) ? 0.upto(8).count { |v| @cand[i][j][v] } : 0, i, j]
}
}.flatten(1).select { |cands|
cands[0] > 0
}.sample[1..2]
questionMat[insertPoint[0]][insertPoint[1]] = 0.upto(8).select { |v| @cand[insertPoint[0]][insertPoint[1]][v] }.sample + 1
next if questionMat.flatten().count { |n| n != 0 } <= 16
self.question = questionMat.flatten().join(“”)
solve()
break if isUnique?()
if @answers.length == 0
questionMat[insertPoint[0]][insertPoint[1]] = 0
end
end
end
def solveInit()
@answers = []
@isSolved = false
@mat = 9.times.collect { |i| Array.new(9, 0) }
@cand = 9.times.collect { |i| 9.times.collect { |j| Array.new(9, false)} }
end
def readQuestion()
self.question.split(“”).each_with_index { |c, i|
setValue(i / 9, i % 9, c) if c != 0
}
end
def searchRec(i, j)
@answers.push(Marshal.load(Marshal.dump(@mat))) if isAllFilled?()
return if @answers.length > 1
if isEmpty?(i, j)
chal = []
for val in 0..8
chal.push(val) if @cand[i][j][val]
end
chal.each { |v|
setValue(i, j, v + 1)
nexti, nextj = getNextCand()
searchRec(nexti, nextj)
}
setValue(i, j, 0)
end
end
def getNextCand()
updateCand()
0.upto(8).collect { |i|
0.upto(8).collect { |j|
[isEmpty?(i, j) ? 0.upto(8).count { |v| @cand[i][j][v] } : 10, i, j]
}
}.flatten(1).min[1..2]
end
def updateCand()
initCand()
while 0.upto(8).collect { |i| 0.upto(8).collect { |j| isEmpty?(i, j) ? 0.upto(8).count { |v| @cand[i][j][v] } : 10 } }.flatten(1).min > 1
next if nakedPair()
break
end
end
def initCand()
for i in 0..8
for j in 0..8
for v in 0..8
@cand[i][j][v] = canSet?(i, j, v + 1)
end
end
end
end
def nakedPair()
return nakedPairSub(xSets()) || nakedPairSub(ySets()) || nakedPairSub(boxSets())
end
def nakedPairSub(sets)
for i in 0..8
empties = 0.upto(8).select { |j| isEmpty?(sets[i][j][0], sets[i][j][1])}
for c in 2..empties.length-1
empties.combination(c) { |jPair|
if 0.upto(8).count { |v| jPair.any? { |j| @cand[sets[i][j][0]][sets[i][j][1]][v] } } == c
vPair = 0.upto(8).select { |v| jPair.any? { |j| @cand[sets[i][j][0]][sets[i][j][1]][v] } }
reduceCand = false
(empties – jPair).each { |j|
vPair.each { |v|
reduceCand = true if @cand[sets[i][j][0]][sets[i][j][1]][v]
@cand[sets[i][j][0]][sets[i][j][1]][v] = false
}
}
return true if reduceCand
end
}
end
end
return false
end
def isEmpty?(i, j)
return (@mat[i][j] == 0)
end
def canSet?(i, j, val)
return true if val == 0
for t in 0..8
return false if @mat[i][t] == val
return false if @mat[t][j] == val
end
for ti in 0..2
for tj in 0..2
return false if @mat[(i / 3) * 3 + ti][(j / 3) * 3 + tj] == val
end
end
return true
end
def isAllFilled?()
for i in 0..8
for j in 0..8
return false if isEmpty?(i, j)
end
end
return true
end
def setValue(i, j, val)
@mat[i][j] = val.to_i
end
def isUnique?()
solve() if !@isSolved
return (@answers.length == 1)
end
def printAnswer()
solve if !@isSolved
@answers.each { |ans|
printMat(ans)
puts “———-”
}
end
def printMat(mat)
mat.each { |row|
row.each { |elm|
print elm.to_s
}
puts ” ”
}
end
def xSets()
0.upto(8).collect { |i|
0.upto(8).collect { |j|
[i, j]
}
}
end
def ySets()
0.upto(8).collect { |j|
0.upto(8).collect { |i|
[i, j]
}
}
end
def boxSets()
0.upto(8).collect { |i|
0.upto(8).collect { |j|
[(j % 3) + (i % 3) * 3, (j / 3) + (i / 3) * 3]
}
}
end
end


Mastodonでは、大半のビューにhamlを用いており、
JSONのビューには、RABLを用いています。
同じようにビューを作成します。

問題一覧のビューです。
hamlで記述します。

app/views/game/index.html.haml

%h1 過去問
%p 数独の過去に生成された問題です。
%p
%table
%tr
%th 問題番号
%th 生成日
- @questions.each do |question|
%tr
%td= link_to question.id, game_path(question)
%td= question.created_at

数独の問題のビューです。
ここでnumber_place.jsを読み込みます。

app/views/game/show.html.haml

- content_for :header_tags do
%script#initial-state{ type: 'application/json' }!= json_escape(render(file: 'game/initial_state', formats: :json))
= javascript_pack_tag 'number_place', integrity: true, crossorigin: 'anonymous'
.app-holder#number-place{data: { props: Oj.dump(default_props) } }

問題のデータの部分です。
RABLで記述します。

app/views/initial_stat.json.rabl


object false
node(:data) do
{
id: @question.id,
question: @question.question,
playingData: "000000000000000000000000000000000000000000000000000000000000000000000000000000000",
}
end

サーバーの情報のモデルも作ります。
今回は、そんなに重要ではありません。

app/models/setting.rb


# RailsSettings Model
class Setting < RailsSettings::Base
source Rails.root.join("config/app.yml")
def to_param
var
end
class << self
def [](key)
return super(key) unless rails_initialized?
val = Rails.cache.fetch(cache_key(key, @object)) do
db_val = object(key)
if db_val
default_value = default_settings[key]
return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
db_val.value
else
default_settings[key]
end
end
val
end
def all_as_records
vars = thing_scoped
records = vars.map { |r| [r.var, r] }.to_h
default_settings.each do |key, default_value|
next if records.key?(key) || default_value.is_a?(Hash)
records[key] = Setting.new(var: key, value: default_value)
end
records
end
private
def default_settings
return {} unless RailsSettings::Default.enabled?
RailsSettings::Default.instance
end
end
end

データベース

データベースを作成します。
データベースには、PostgreSQLを使用します。
データベース名は
development:numberplace_dev
test:number_place_test
production:number_place
になります。

config/database.yml

# SQLite version 3.x
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem 'sqlite3'
#
default: &default
adapter: postgresql
encoding: utf8
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: numpla
password: ongr
development:
<<: *default
database: number_place_dev
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: number_place_test
production:
<<: *default
database: number_place

schema.rbの用意をして、DBを作成します。
setupにデータベースのcreateも含まれます。

$ vi db/schema.rb
$ bundle exec rake db:setup

schema.rbは、以下の通りに記述します。

db/schema.rb


# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# Note that this schema.rb definition is the authoritative source for your
# database schema. If you need to create the application database on another
# system, you should be using db:schema:load, not running all the migrations
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170517114848) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
create_table "questions", force: :cascade do |t|
t.string "question"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "answer"
end
create_table "settings", id: :serial, force: :cascade do |t|
t.string "var", null: false
t.text "value"
t.integer "thing_id"
t.string "thing_type", limit: 30
t.datetime "created_at"
t.datetime "updated_at"
t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true
end
end

起動

最後に、productionでの実行をしてみます。

$ bundle exec rake secret

として、表示される文字列をコピーしておき、

$ vi .env

で編集します。

.env

RAILS_SERVE_STATIC_FILES=true
SECRET_KEY_BASE="(上でコピーした文字列)"

では、起動します。
あらかじめ3000ポートを開放しておくか、apacheに設定し80のアクセスをリダイレクトするなどして、
外部からアクセスできるようにしておいてください。

$ cd ~/number-place &amp;&amp; RAILS_ENV=production bundle exec bin/webpack &amp;&amp; RAILS_ENV=production bundle exec rails server

開発の試験にはdevelopで起動してもよいですし、productionの試験をするなら.bashrcに以下のように記述しておいてもいいです。
jsファイルなどに変更がある場合は、ptestコマンドを、
起動したいだけなら、pbrasコマンドを使います。
~/.bashrc

(下に追加)
alias be="bundle exec"
alias bra="bundle exec rails"
alias brk="bundle exec rake"
alias ptest="cd ~/number-place && RAILS_ENV=production bundle exec bin/webpack && RAILS_ENV=production bundle exec rails server"
alias pbras="cd ~/number-place && RAILS_ENV=production bundle exec rails server"

これでサーバーのip等に
/game/randomGenerate
とアクセスすれば問題が作成され、
/game
で一覧が表示されるので見てみましょう。
index
問題番号を選び、問題の画面が表示されます。
show