株式会社スマレジの開発部でスマレジのサーバサイドを作っています

electronとReactを併用するときに注意すること

こんにちは!株式会社スマレジ 、開発部のmasaです。 また投稿が空いてしまい申し訳ないです。。。実は体調を崩しておりました。。。

気温がグッと下がって寒暖差も大きかったのが原因だと思います。皆様も体調管理にはお気をつけください。

さて、表題の通り、前回からReact + electronでアプリ開発をしているのですが、 作っていて気になったところをあげようと思います。

なお、作成しているアプリはgithubに置いています。

github.com

React側でWebAPIをコールするのは注意が必要

Reactに限った話ではないですが、フロントからAPIをコールして画面表示する方式をとる場合には、 一手間加える必要がありそうです。

www.kabanoki.net

この記事などで書かれているように、html上でのAPI通信を有効にすると、Electron APIが使えなくなります。 そのため、フロントで利用したいライブラリについてはCDNなどではなくダウンロードして利用する方式をとります。

API通信もしたいし、サーバで処理する必要がないものについてはElectronで処理させたい」となった場合は、 React側ではなく、メインプロセス内でAPIをコールする方式にするのが良さそうです。

github.com

詳しくは上のリンクを見て欲しいんですが、私の場合は以下のようにmainプロセス内でactionをコールし、 呼ばれたAPIに紐づくアクションクラスを別に用意しています。

ipcMain.on('dialog:redmineVersion', redmineVersionAction);
ipcMain.on('dialog:saveConfig', saveConfigAction);
ipcMain.on('dialog:saveRedmineConfig', saveRedmineConfigAction);

で、APIのコールはこんな感じ。

github.com



const axios = require('axios');

module.exports = class AbstractApi {

    /**
     * execute post api
     * @param uri
     * @param headers
     * @param request
     * @return {Promise<null>}
     */
    static async callPostApi(uri, headers, request) {
        let response = null;
        try {
            response = await axios.post(uri, request, {headers: headers});
        } catch (e) {
            console.error(e);
            throw new Error("APIの実行に失敗しました。")
        }
        
...

なんてことはない、axios利用したAPIコールです。

逆に言えば気になったのはこれくらいで、他に関しては本当にWebアプリを作るのとほとんど変わらない感じでした。

Node.jsでWebアプリ作るときとあまり変わらない。

masa的主観で言うと、Node.jsでWebアプリを作成する際もCORSの問題などでAPIをフロントから直呼びすることってLambdaとか割と決まった時だけが多いので、結局、サーバとフロントの間の処理層としてelectronが機能しているだけでWebアプリ作るときと、大きくは変わらないのかなと思っています。

ゆくゆくはelectron前提のフレームワークとか出てきても良さそうですよね、めっちゃ使われてますし。

masa的に思うSaaSエンジニアのIT以外の資格って必要?って話

こんにちは!株式会社スマレジ 、開発部のmasaです。

9月になって、すこーし過ごしやすくなってきたかなと思いましたが、今日一日、府外の友人に付き合って大阪観光して気のせいだと思い直しました。 大阪はやっぱり蒸し暑い。湿度高い。それはどこもそうか。

ちなみにルートは心斎橋出発して、戎橋(グリコの看板)・四天王寺(友人の希望)・通天閣日本橋(友人はハードいじりが趣味)です。ほぼ歩きで回りましたので、いい運動になりましたけど、普通に直射日光で溶けそうになりました。

今回は最近開発ブログが続いたので、違うやつです。

IT以外の資格の取得について

以前、情報処理者試験がエンジニアにとって必要か、というテーマで記事を書きましたが、今日はそれの続きで、 IT以外の資格はどうなんって話です。(以前のブログは↓)

masa2019.hatenablog.com

結論から言うと、結構大事かなって思っています。(ケースにもよるけど)

これは、新卒からずっとこのリテール系(流通業界)のエンジニアをしていて思いました。 大事な理由は3つ。

理由1. その業界について自分が興味が持てるものかを知れる

エンジニアに限った話ではないですが、試験に受かるために勉強した知識や技能をシンプルに「面白い」と思えて、楽しんで吸収できるなら、 その業界へのモチベーションは維持しやすいかなと思います。

理由2. ドメインの理解に役に立つ

エンジニアに絞った話にすると、その業界・業種に併せてシステムを構築するので、ドメインを把握する上で業界理解は大事なのですが、 大抵の資格は、業界標準に則った汎用的な知識をベースとするので、モデリング向きな知識が詰まっているとも言えます。 ドメインの把握はそのまま仕様を検討する際の精度につながるので、大事な部分かなと思っています。

理由3. お客さんや営業やCSとの会話に役に立つ

masaの話で言うと、IT系以外の資格でスマレジ に関連する資格だと、

  • 簿記2級
  • 販売士3級

この辺りです。 ちなみに販売士は前の会社で取得奨励だったのもあります。 ぶっちゃけどっちもめちゃむずい資格ってわけではないです。(簿記はちょっと時間必要かもですが)

知識量としては決して多くはないですが、知っていないのと知っているのとでは、やっぱり見え方が変わってくる部分があるかなと思います。 それを一番思うのは、他部署の方やお客さんとお話する時ですね。 スマレジ に入ってからは直接の顧客とのコミュニケーションはそれほど多くはないんですが、 前の会社はSIerだったので、お客さんとの接点も多くて、要望を伺う時に会計の初歩的な知識が役に立つことは多かったです。 また、スマレジ 管理画面はPL管理など、財務諸表関連の機能があるので、それらの理解や追加の改修のイメージを、 営業やCSと共有するのにも、役に立ちます。

masa的に「こういうエンジニアはとったほうがいいかも」と思うタイプ

PMや仕様検討をする立場の人は最低限必要かなと思います。資格という形は不要かもしれないけど、問われる知識は結局一緒か、その一部分だと思いますし。

あとは、スマレジ にはあまりいないタイプのエンジニアですが、「そもそもプログラム書くのが好きじゃないエンジニア」は、 コーディングの時点で周りと差がつくので、仕様検討など、それ以外の部分での精度が高くないと、相対的に市場価値は低くなってしまうかなと。

個人的には、理由1が一番かなと。 やっぱり好きな分野でプログラム書いている方が、楽しいと思うので。

ElectronにBootstrap 5とReactを入れてみる。

こんばんは!株式会社スマレジ 、開発部のmasaです。

今回の前回に引き続き、Electron関係の記事になります。

なおスマレジ プラットフォームAPIのクライアントアプリ作成(アクセストークンの取り方など)については、別に記事にしますのでもう暫しお待ちください。

ElectronにBootstrap 5とReactを入れてみる

タイトルの通りです。ちなみにmasaはごく最近Reactを触り出したので、間違えたこと言うかもです。ご容赦ください。 簡単に書いていますが、ポイントになるところが結構多いので、細かい部分はgithubソースが上がっているので、詳しくはそれを見て見てください。

Bootstrapの導入

ファイルの設置

前提として、Electronを使う訳なので、CDNは使用せずローカルにBootstrapをダウンロードして使用します。 フォルダの構成は暫定ですが、views以下にフロントのプログラムを設置します。

フォルダ構成(Viewの部分)

このうち、buildフォルダにコンパイルされたhtmlが設置されます。本体となるReactファイルはsrcの下に配置する想定です。

Bootstrapのフォルダは直接build直下のlibフォルダ配下に配置します。

Helmetを使って、headにlinkタグとscriptタグを追加する

次にlibに配置したBootstrapを読み込むHTMLを生成します。 Headに要素を追加したいときは、HelmetというReactパッケージがあるのでそれを利用します。

npm install --save react-helmet

で取り込んで、下記のように利用します。

import { Helmet } from "react-helmet"

...

class Head extends React.Component {
    constructor(props) {
        super(props);
        this.title = props.title;
    }
    render() {
        return (
            <Helmet>
                <title>{this.title}</title>
                <link rel="stylesheet" href="../public/lib/bootstrap-5.0.2-dist/css/bootstrap.css"/>
                <script src="../public/lib/bootstrap-5.0.2-dist/js/bootstrap.js"></script>
            </Helmet>
        );
    }
}

class Top extends React.Component {
    render() {
        return (
            <div class="top container-fluid">
                <Head title="Project Manager" />
                <div class="row" id="content-field">
                ...
        );
    }
}

これでBoostrapの導入は終わりです。

Reactの導入

続いて、Reactですが、これは難しいことはなく単純にbuildしたファイルをメインプロセスで指定してあげるだけです。

const { app, BrowserWindow, ipcMain } = require('electron')
...

const createWindow = () => {
  const width = 700;
  const height = 600;
  const win = new BrowserWindow({
    width: width,
    height: height,
    minHeight: height,
    minWidth: width,
    maxHeight: height,
    maxWidth: width,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    },
    fullscreen: false,
    frame: true
  })
  // 開発ツールを有効化
  // win.webContents.openDevTools();
  win.loadFile('./views/build/index.html') // <------ここでbuildファイルを指定している
}
...

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

今回はSPAで実装予定なので、初期ファイルがロードできれば後はなんとかなるかな・・・?と思って今は作っています汗。(なったらいいな)

実行するとこんな感じ(2022/8現在)

サイドメニュー はBootstrapの公式サンプルを丸パクリです笑

getbootstrap.jp

Electronを使って、Node.jsバッチをクライアントアプリ化する。

こんばんは!株式会社スマレジ、開発部のmasaです。 また投稿が開いてしまい申し訳ないです。。。汗

今回は前回のブログで紹介したツールをデスクトップアプリ化したので、そのお話をしたいと思います。 前回の投稿は↓から。

masa2019.hatenablog.com

electronを使って、Node.jsのバッチツールをGUIアプリ化する。

前回のブログで紹介した、redmine apiと smaregi platform apiを使用して、タイムカードの日報をつける仕組みですが、 あれはコマンドライン実行のツールです。

現在、このツールを使ってメンバーの皆様に日報をつけていただいているのですが、 サンドボックス環境から本番に転記しているので完全自動化ができていないんです。

そこで本番運用を考えた時に問題になったのが、「アプリアクセストークン」です。 今回、smaregi platform api側ではアプリアクセストークンを利用していました。

アプリアクセストークンの取得の仕方は↓

developers.smaregi.dev

このアクセストークンはユーザの権限が反映されないため、他の人の日報も編集できてしまうという問題がありました。

そこで、ユーザの権限が反映される、ユーザアクセストークンを使用するように変更をする必要が出てきました。

ユーザアクセストーク

ユーザアクセストークンは「だれがAPIを使っているか」を判断する必要があるので、

  1. スマレジのログイン画面にリダイレクト
  2. 承認を行う
  3. リダイレクト後、アプリに処理が戻ってくる(この時アクセストークンを取るのに必要な認可コードなども取れる)

という流れになるので、3でコマンドラインツールに処理を戻してあげる必要が出てきました。 しかし、実行しているのはローカル環境です。リダイレクト先に指定することも、どこかから投げ直すこともできない状態。。。

そのため、クライアントアプリのディープリンク機能を使ってリクエストを渡すことを考えたのです。 ディープリンクについては↓

www.adjust.com

Electron

今回は既存資産にNode.jsのバッチがあるので、できればその資産をそのまま流用したい。 そこで、Node.jsでも動作するクライアントアプリのフレームワークがないか調べてみたところ、Electronに行きつきました。

www.electronjs.org

Node.jsで動いて、フロントにhtmlも使える・・・!ちょうどいいなと思い採用してみました。

Electronで作った日報生成ツール

出来上がったのが上の画像のアプリです。 追加したコードの一部を下に載せます。

まずはフロントのhtml(CSSは割愛します)

<html>
<head>
    <meta charset="UTF-8"/>
    <!-- https://developer.mozilla.org/ja/docs/Web/HTTP/CSP -->
    <title>日報生成ツール</title>
    <style>
        (中略)
    </style>
    <script src="https://cdn.jsdelivr.net/npm/crypto-js@4.0.0/crypto-js.js"></script>
</head>

<body>
<script type="text/javascript" src="../frontend/click.js"></script>
<div id="code_verifier"></div>
<div id="access_token"></div>
<form id="createDailyReportForm">
    <div class="inputWithIcon inputIconBg">
        <input type="text" placeholder="Redmine User ID" id="redmineUserId">
    </div>

    <div class="inputWithIcon inputIconBg">
        <input type="text" placeholder="Timecard User ID" id="timecardUserId">
    </div>

    <div class="inputWithIcon inputIconBg">
        <input type="text" placeholder="yyyymmdd" id="ymd">
    </div>

    <button type="button" class="btn btn-primary" onclick="execute()">日報を生成</button>
</form>
</body>
</html>

ここは特にいうことはないと思います。

で、ボタンに紐づいているイベントが書いてあるフロントJS。

async function execute() {
    let ymd = document.getElementById('ymd').value;
    let redmineUserId = document.getElementById('redmineUserId').value;
    let timecardUserId = document.getElementById('timecardUserId').value;
    if (ymd.length !== 8) {
        alert("yyyymmddが不正です。");
        return;
    }
    if (redmineUserId.length < 1) {
        alert("redmineUserIdは必須です。");
        return;
    }
    if (timecardUserId.length < 1) {
        alert("timecardUserIdは必須です。");
        return;
    }
    const accessToken = document.getElementById('access_token').innerText;
    window.electronAPI.execute([ymd, timecardUserId, redmineUserId, accessToken]);

}

// (ハッシュ生成のメソッドは省略)

window.electronAPI.auth((event, value) => {
    console.log(event);
    console.log(value);
    value.codeVerifier = document.getElementById('code_verifier').innerText;
    window.electronAPI.getRefreshToken(value);
})

window.electronAPI.openForm((event, value) => {
    console.log(value);
    document.getElementById('access_token').innerText = value["access_token"];
    document.getElementById('createDailyReportForm').style.display = 'inherit';
    document.getElementById('redmineUserId').value = value["redmine-user-id"];
    document.getElementById('timecardUserId').value = value["timecard-user-id"];

})

window.electronAPI.getAuthorizationCode(() => {
    const codeVerifier = generateCodeVerifier();
    document.getElementById('code_verifier').innerText = codeVerifier;
    const codeChallenge = generateCodeChallenge(codeVerifier); // 中身は割愛
    window.location.href = "https://id.smaregi.dev/authorize?response_type=code&scope=openid offline_access timecard.shifts:read timecard.daily-reports:write&state=12345&redirect_uri=http://(リダイレクト先のURL)&client_id=(クライアントID)&code_challenge_method=S256&code_challenge=" + codeChallenge;
})

window.onload = function() {
    window.electronAPI.checkAuth();
}

Electronで特徴的なのが、 window.electronAPI.checkAuth();この表記。このwindowオブジェクトにAPIを注入することができるんです。

で、それをやっているのがpreloadで動くJS。↓

const { contextBridge, ipcRenderer } = require('electron')
const axios = require("axios");
const {log} = require("electron-log");

contextBridge.exposeInMainWorld('electronAPI', {
    checkAuth: () => ipcRenderer.send('checkAuth'),
    getAuthorizationCode: (callback) => ipcRenderer.on('getAuthorizationCode', callback),
    auth: (callback) => ipcRenderer.on('auth', callback),
    getRefreshToken: (value) => ipcRenderer.send('getRefreshToken', value),
    openForm: (callback) => ipcRenderer.on('openForm', callback),
    execute: (value) => ipcRenderer.send('execute', value),
})

contextBridge.exposeInMainWorld()の第一引数で注入するAPIオブジェクト名, 2つ目にAPIの中身を書きます。 この時、ipcRenderer.sendをしているのが、アプリ側のAPIをコールしていて、ipcRenderer.onでコールしているのが、フロントJS側のAPIになります。

最後に、アプリ側に追加したAPIのJS

// アプリケーション作成用のモジュールを読み込み
const { app, BrowserWindow, ipcMain, shell, dialog, ipcRenderer} = require('electron')
const path = require("path");
const ipc = require('electron').ipcMain
const {execute} = require("./index");
const {getStringFromDate} = require("./util");
const log = require('electron-log');
const axios = require("axios");
const Store = require('electron-store');
const store = new Store();

const apiRequestHeaders = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Authorization': 'Basic 認証キー'
}


let mainWindow;

if (process.defaultApp) {
    if (process.argv.length >= 2) {
        app.setAsDefaultProtocolClient('electron-fiddle', process.execPath, [path.resolve(process.argv[1])])
    }
} else {
    app.setAsDefaultProtocolClient('electron-fiddle')
}

const gotTheLock = app.requestSingleInstanceLock()

if (!gotTheLock) {
    app.quit()
} else {
    app.on('second-instance', (event, commandLine, workingDirectory) => {
        // Someone tried to run a second instance, we should focus our window.
        if (mainWindow) {
            if (mainWindow.isMinimized()) mainWindow.restore()
            mainWindow.focus()
        }
    })

    // Create mainWindow, load the rest of the app, etc...
    app.whenReady().then(() => {
        createWindow()
    })
}

function createWindow () {
    // Create the browser window.
    mainWindow = new BrowserWindow({
        width: 400,
        height: 240,
        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
            nodeIntegration: true,
            contextIsolation: true
        }
    })

    // window event
    mainWindow.webContents.on("will-navigate", (event,url) => {
        if( url.match(/^http/)) {
            event.preventDefault()
            shell.openExternal(url).then(r => {})
        }
    })

    // デベロッパーツールの起動
    // mainWindow.webContents.openDevTools();
    mainWindow.webContents.send('login');
    mainWindow.loadFile('html/index.html');
}

app.on('open-url', (event, urlStr) => {
    if (typeof mainWindow === "undefined") {
        dialog.showErrorBox('エラー', `日報ツールが起動していません。`);
        return;
    }
    const urlArr = urlStr.split("?");
    if (urlArr.length < 1) {
        dialog.showErrorBox('不正な呼び出し', `不正な呼び出しが発生しました。`);
        return;
    }
    const queryParamsStr = urlArr[1];
    const queryParamsArr = queryParamsStr.split("&");
    const queryParams = {};
    let wkArr = "";
    for (let i = 0; i < queryParamsArr.length; i++) {
        wkArr = queryParamsArr[i].split("=");
        if (wkArr.length < 1) {
            continue;
        }
        queryParams[wkArr[0]] = wkArr[1];
    }
    mainWindow.webContents.send('auth', queryParams);
})

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') app.quit()
})

// リフレッシュトークンの取得
const getRefreshToken = function(queryParams) {
    log.info(queryParams);

    const requestBody = new URLSearchParams();
    requestBody.append('grant_type', 'authorization_code');
    requestBody.append('code', queryParams.code);
    requestBody.append('redirect_uri', 'http://160.16.110.61/redirect.php');
    requestBody.append('code_verifier', queryParams.codeVerifier);

    axios({
        method: "POST",
        url: 'https://id.smaregi.dev/authorize/token',
        headers: apiRequestHeaders,
        data: requestBody
    }).then(response => {
        log.info('body:', response.data);
        store.set('access-token', response.data.access_token);
        store.set('refresh-token', response.data.refresh_token);
        const today = new Date();
        store.set('last-refresh-date-time', getStringFromDate(today));
        store.set('last-authorization-date-time', getStringFromDate(today));
        let redmineUserId = store.get('redmine-user-id', "");
        let timecardUserId = store.get('timecard-user-id', "");

        response.data['redmine-user-id'] = redmineUserId;
        response.data['timecard-user-id'] = timecardUserId;
        mainWindow.webContents.send('openForm', response.data);
    });
}

/** アクセストークンのリフレッシュ処理 **/
const refreshAccessToken = async function(refreshToken) {
    const requestBody = new URLSearchParams();
    requestBody.append('grant_type', 'refresh_token');
    requestBody.append('refresh_token', refreshToken);

    await axios({
        method: "POST",
        url: 'https://id.smaregi.dev/authorize/token',
        headers: apiRequestHeaders,
        data: requestBody
    }).then(response => {
        log.info('body:', response.data);
        store.set('access-token', response.data.access_token);
        store.set('refresh-token', response.data.refresh_token);
        const today = new Date();
        store.set('last-refresh-date-time', getStringFromDate(today));
        store.set('last-authorization-date-time', getStringFromDate(today));
    });
}


ipc.on('checkAuth', async function(event) {
    let authFlag = false;
    let refreshFlag = false;
    let accessToken = store.get('access-token', "");
    if (accessToken.length < 1) {
        authFlag = true;
    }
    const refreshToken = store.get('refresh-token', "");
    if (refreshToken.length < 1) {
        authFlag = true;
    }

    const lastAuthorizationDateTimeStr = store.get('last-authorization-date-time', "");
    let lastAuthorizationDateTime = new Date('2020-01-01 00:00:00');
    if (lastAuthorizationDateTimeStr.length > 0) {
        lastAuthorizationDateTime = new Date(lastAuthorizationDateTimeStr);
    } else {
        authFlag = true;
    }
    let today = new Date();
    log.info(today);
    log.info(lastAuthorizationDateTime);
    log.info((today.getTime() - lastAuthorizationDateTime.getTime()) / 1000);
    if (today.getTime() - lastAuthorizationDateTime.getTime() > 1000 * 60 * 59) {
        refreshFlag = true;
    }

//(中略)

ipc.on('getRefreshToken', function(event, args) {
    getRefreshToken(args);
})

ipc.on('execute', async function (event, args) {
    log.info(args);
    store.set('redmine-user-id', args[2]);
    store.set('timecard-user-id', args[1]);
    await execute(args[0], args[1], args[2], args[3]);
})

長いので主要なところを掻い摘むと、

  • app.on('open-url', (event, urlStr) => {... これがディープリンクで呼び出された時に走る処理
  • ipc.on() がpreloadのJSで定義されたAPIの実体
  • mainWindow.webContents.send(); がpreloadを通じて、フロント側のAPIを呼び出す処理

という感じです。(社内ツール用でスピード重視なのでこうなっていますが、本当はAPIの処理とかは別ファイルに分離しないといけないです。)

リフレッシュトークンの話とかもあるんですが、その辺りは長くなるので、また次回に。

書いてみて

イベントハンドリングが少し独特でしたが、ほとんどWebアプリを作るのと変わらない感覚でデスクトップアプリが作れるのは、 面白いし便利!というのが感想です。今後も使っていこー。

タイムカードAPIとredmine APIを組み合わせて日報をつける

こんばんは!株式会社スマレジ、開発部のmasaです。

また、更新が空いてしまい申し訳ないです。。。

今日はうちのチームのエンジニアがつけている日報を自動化する試みにチャレンジしており、そこで作ったツールの紹介をします。

背景

結論から言えば、「日報とRedmineをリンクさせて、Redmine上のタスクについては日報を自動生成してしまう」というのがこの記事の趣旨です。ただ、それが必要となる背景について簡単にお話しします。

生産性の見える化見える化のための工数のジレンマ

タスクが入り乱れやすい性質のプロジェクトでは、得てして生産性がはっきりせず、チーム内部と外部で作業スピードで温度差が出ることがあります。 タスクの入り乱れの原因は、社内ステークホルダが多様化にあります。 例えばスマレジのようなSaaSの場合、

  • 新規機能を推し進めて売上達成をしたい営業
  • 品質改善によって問い合わせ負荷を軽減したいCS・開発メンバー
  • APIサードパーティ向け機能を拡充して、パートナー開拓、プラットフォームの活性化、連携コスト低減による導入のスケールメリット拡大を狙いたい技術営業や大手法人担当の営業

大体この辺りの思惑がプロダクトのスケジュールへ向けられます。

例えば弊社の場合だと、スマレジ・POS関係の開発チームが上記のような傾向になりがちで、タスクの種類や性質が多様化するため、開発側でのタスクも多様化します。 こんな風に、多様なタスクを抱えるチームの場合は開発作業とそれ以外の作業をするメンバーを役割わけし、定期的に役割をローテーションさせていくことになるのですが、 実際のところは簡単な話ではありません。

というのも、誰か一人のタスクのバケツが溢れたら他のメンバーでフォローするため、綺麗に役割を分けるのはマンパワーとそれを組織するための体制が安定するまでは、個人のタスクは多様化する傾向にあるためです。

こういった状況で「何にそんなに時間がかかっているか分からないから、もっと生産性を見える化しろ!」といっても、多様なタスクに振り回されている開発メンバーに10分単位のタスクについていちいち記録をつけて行ってもらうことは現実的ではありません。限界のラインが1日の終わりにタスクを振り返って日報をつけることくらいです。

ボトムアップ見積もりによる予実管理(Redmine)

以前のブログでも触れましたが、弊社はプロジェクト管理ツールとしてRedmineを使用しています。

redmine.jp

過去ブログは↓から。

masa2019.hatenablog.com

masa2019.hatenablog.com

masa2019.hatenablog.com

スマレジではRedmineボトムアップ見積もり型のプロジェクト管理ツールとして利用しています。 (チケット駆動開発はタスクの単位での開発手法に関する議論で、これをベースにプロジェクトを計画するなら、プロジェクト見積もりになるのかなとmasaは考えています)

ボトムアップ見積もりはこちら。

ssaits.jp

チケット駆動開発についてはこちら。

www.atlassian.com

どちらも、タスクレベルで課題を明らかにしてから進行するという意味で共通しています。 このタスクが前述のように多様化するので、記録するのも一手間。とはいえある程度の粒度で残す必要はある。だけど楽はしたい。。。 そこで、毎日つける日報もタスクベースでの記載になることに着目して「日報とRedmineをリンクさせて、Redmine上のタスクについては日報を自動生成してしまう」というのがこの記事の趣旨です。

プログラム

今回はNode.jsのバッチで作成しました。 今回、少しプログラムが長いので、折りたたみにします。 社内ツールとして作ったものなので、コメントがなかったり、メソッドわけが多少雑になっていますがご容赦ください汗 (暇があれば、そのうちgithubにあげておきます。)

これを運用してみて、うまく行ったかどうか、課題点などがあるかについては、また後日報告させてもらえればと思います。

masaのプログラミング遍歴

こんにちは!株式会社スマレジ 、開発部のmasaです。

また更新が空いてしまい申し訳ないです汗 ここ最近恐ろしく忙しくて、自分の開発の時間がなかなか取れず。。。

というわけではないのですが、NestJSの開発のお話から外れて、masaのプログラミング覚え始めから今に至るまでのお話とかしてみようかと思います。

プログラミングを始めたのは大学1年生

masaが初めて触れたプログラミング言語C++でした。触れるきっかけになったのは大学の講義です。 以前教育学部卒みたいなお話をしたかもしれないですが、masaは工学部と教育学部の両方を並行して履修していたんです。

動機は結構不純で、「先生だけだと、仕事が嫌になった時に辛いから、先生とは180度違うこともできるようにしておきたい」というものでした笑。 当時から、モンスターペアレントがあーだとか、教師のメンタルヘルス的な話はいっぱいあったのであまり教師という仕事に1年生の時からあまり期待していなかったんです。

じゃあなんで教育学部なんだよって突っ込まれそうですが、理由は教員免許が取りやすいからで、なんで教師に期待してないのに免許が欲しいんだっていうのは、「潰しが効くから」ってくらいのものでした。リーマンショックと震災の直後にmasaは大学生になってたんで、「何がしたい」よりも「生きる術」を身につけるために大学に入ったっていうニュアンスが強かったんですよね。(実家も食うに困るほどではないですが、裕福ではなかったので)

大学の講義でいわゆる制御構文の基礎をC++で習って、実習で課題のプログラムを作って・・・みたいな感じでした。情報系の学生あるあるじゃないかな。プログラミングは通期であって、後期になってポインタとデータ構造やアルゴリズムが入ってきて「わかんねぇぇぇぇぇ」って言いながら友達と毎夜計算機室で唸ってました笑。なので年間でも3000行も書いてなかったと思います。

ちなみに計算機室のOSはRedHatだったので、linuxのコマンドの基本とかもここで覚えています。

独学で初めて学んだのがJavaScript

1年の前期が終わって、C++以外の言語にも興味が出てきて、そこで選んだのがJavaScriptでした。 なんでJSなのかというと、無料のエディタとブラウザがあれば開発できるから。当時はLinuxを自力で自分のパソコンに入れるほどの技術力と経済力(パソコン1台しかなく、それでレポートとかもやるのでWindowsは1台必要だった)がなかったので、Windowsで気軽に開発できるのが魅力だったんです。

そこで初めて、「動的型付け」言語に触れてそのいいところと悪いところをなんとなく書きながら感じていました。 ちなみに勉強した本は↓

https://www.amazon.co.jp/dp/B01LYO6C1N/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1

この本の良い悪いは微妙なところ(当時の版はプログラミング初心者にはちょっと難しい表現が多かった)ですが、リテラルについて丁寧に説明があったので、内部的にデータ型を持っていることと、それを「いい感じに」整理してくれることはわりとスッと入ってきました。

JQueryの触りくらいまではここで勉強しました。ただ、個人でちょっと動かすだけだとjQueryのありがたみがわからない部分があって、バニラで書いていることが多かったですね。ただ根っこになるDOMの考え方とかは面白くて、div領域の色を変えたり、ドラックドロップしたりして遊んでいたのは覚えています。

3年の講義でSQLとC

データベース論の授業でpostgreSQLを触ったのが僕のSQLの始まりでした。 ここで学んだことは結構効いてきているのが最近感じます。

あと、C言語もここで初めて触れました。触れたきっかけは理論物理の授業でシミュレーション課題などがあって、 C言語運動方程式を時間発展させてグラフに描画する、みたいなことをやっていました。大学で物理やった人あるあるかな・・・? 地味にMakefileとかもここで教わりました。

4年生〜院生でPHPPerl

4年生になって、卒業研究をするようになって初めてPHPPerlに触れました。 以前のブログで取り上げたBM25を使って、大学のシラバスを相互に類似度をスコアで出して、 それを運動モデルのパラメータに落とし込んで可視化する、みたいなことをやってました。この時の計算にPerl, Webサービスとして作成したので、サーバの処理はPHP, グラフの描画はJSでやっていました。

こう書くと色々やってんなって感じですが、フレームワークも知らない、オブジェクト指向もおぼつかないときにゴリゴリ描いたものなので、まあスパゲッティコードになりました。

性質は違いますが、院生の時もPHPを使ってWebサービスを作っていました。(後Node.jsもちょっと触っていました)

就職して、Java,VB,COBOLに触れる

一者目が大きなSIerで、保守しているサービスも昭和の頃から続いているようなものも多く、そのメンテのためにCOBOLVBなどを研修で教わりました。ただ、この辺りはほぼ使わない(実装することが少ない会社だったので)のでほとんど忘れました。

スマレジ に就職してPHPガッツリ、VueとかTypeScriptとかAltJSにも触れる。

スマレジ に来てからは独習でPythonとかGoとかは触っていますが、お仕事ではPHPSQLがほとんどです。 同じプロダクトをメンテするので、使う言語も自然と一つに定まります。

あと、フロントエンドエンジニアさんの代わりにフロントをいじることもあります。そういう意味ではフロントの方が技術の勉強をすることは多かったかもです。。。(プログラムの組み方とか、バグの取り方とか、そういった実践的な部分はPHPですが)

最後に

こう振り返ってみると、色々と触ってきているんだなと思うんですが、やっぱり「一つの言語をしっかりじっくり使っていく」のが一番だなというのは感じます。 ただ、これからプログラミングを始める方は、(案件の都合でPHPとかも必要だとは思うんですが)基本静的型づけ言語にしていたほうがいいかなと思います。。。仕事で使っているので思うんですが、やっぱり動的言語はメンテが大変なことが多いかと思います。。。(チェック内容も増えるし)

NestJSで簡単なアプリを作ってみる(2)WebSocketでチャット機能を作る

こんばんは! 株式会社スマレジ 、開発部のmasaです。

皆さんはリモートワークでBGMかけていますか?masaはかけています。 かけるBGMはそれこそ気分次第なのですが、朝は無印良品の店内BGMをかけています笑

www.youtube.com

masaは夜になるにつれて集中力が増していくので、朝は頭が回っていないことが多いです。 それもあって登山とか、生活習慣を整えられそうなことをしてたりするのですが・・・(登山は朝早く出て、体を動かすので自然と早寝早起きになる)

意外とBGMひとつで気分も結構変わるのでバカにできないですよね。

WebSocketの練習の鉄板「チャットアプリ」を作る

WebSocketの入門で作った人も多いのではないでしょうか。今回は鉄板のチャットアプリをNestJS+WebSocketで作ります。 昔masaも作りました。

いきなりWebSocket触るわけですが、それ以前の基本については公式ドキュメントやyoutubeなどでざっくり勉強してサンプルを作って動かしています。

docs.nestjs.com

www.youtube.com

で、まず、WebSocketの公式ドキュメントを見てみます。

docs.nestjs.com

想像通りですが、エンドポイントに当たるgatewayを作って、ソケット監視する仕組みのようです。 しかし、公式で書いてあるコードが断片的で、全貌がよーわからんのです。

と思っていたら、一番下にサンプルのgithubを書いてくれていました。

github.com

一応チェックアウトすればすぐ動くみたいですが、このコードをベースに自分なりにいじっていきます。 決して今作っているプロジェクトと別にサンプルをチェックアウトするのがめんどくさいとか、そういう理由ではないのです。

で、サンプルを見ていて「あれ?」と思ったのがここ。

github.com

え、結局Socket.io使うのか。 いや使うにしても、NestJSの仕組みの中でラップしているとか、そういうんじゃないのか。

ちょっと拍子抜けでした。ちゃんとフレームワーク比較するときにちゃんと調べているわけではなかったのでただのmasaの勘違いなのですが。 (でもそれならそれで、公式ドキュメントにSocket.ioを使う想定だよって書いておいてもいいような。Node.jsのWebSocketのライブラリってもしかして他にも結構あるのかな・・・?)

ソースを見た感じ、SubscribeMessageデコレータでどのイベントにどのメソッドが紐づくのかを直感的に指定できるようになっていますね。 確かにこれは便利。それに他のサービスなどのデコレータの使い方と似ているから使いやすい。

作っていく

まずはnestコマンドでgatewayのテンプレを作成。

nest g gateway Events 

で、サンプルをもとにgatewayを実装。

import {
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
})
export class EventsGateway {
  @WebSocketServer()
  server: Server;
  wsClients=[];

  handleConnection(client: any) {
    this.wsClients.push(client);
  }

  @SubscribeMessage('chat')
  chat(@MessageBody() data: any) {
    console.log(data);
    this.broadcast('chat', data.message);
  }

  @SubscribeMessage('testing')
  emitLoginMessage(@MessageBody() data: any) {
    console.log(data);
    this.broadcast('login', data + 'さんがログインしました。');
  }

  private broadcast(event, message: string) {
    const broadCastMessage = message;
    for (let c of this.wsClients) {
      c.emit(event, broadCastMessage);
    }
  }

}

でクライアントhtmlも用意。

<html>
<head>
</head>

<body>
<div id="test-area">

</div>
<input type="text" id="input" />
<button onclick="sendMessage()">send</button>
<script src="https://cdn.socket.io/4.3.2/socket.io.min.js" integrity="sha384-KAZ4DtjNhLChOB/hxXuKqhMLYvx3b5MlT55xPEiNmREKRzeEm+RVPlTnAn0ajQNs" crossorigin="anonymous"></script>
<script>
    const socket = io('http://localhost:8080');
    const userId = Math.random().toString(32).substring(2);
    socket.on('connect', function() {
        console.log('Connected');
        socket.emit('testing', userId);
    });
    socket.on('login', function(data) {
        let textArea = document.getElementById("test-area");
        textArea.innerHTML += '<div style="color: blue;">' + data + '</div>';
        textArea.innerHTML += "<br />";
    });
    socket.on('chat', function(data) {
        let textArea = document.getElementById("test-area");
        textArea.innerHTML += data;
        textArea.innerHTML += "<br />";
        console.log('event', data);
    });
    socket.on('exception', function(data) {
        console.log('event', data);
    });
    socket.on('disconnect', function() {
        console.log('Disconnected');
    });

    function sendMessage() {
        let message = document.getElementById("input").value;
        console.log(message);
        socket.emit('chat', { message: userId + ": " +message });
    }
</script>
</body>
</html>

今回はブロードキャストの動作確認もするので、webstormのローカル実行機能で、ローカルでブラウザを二つ立ち上げます。

動かしてみるとこんな感じ

ブラウザにアクセスすると、ランダム文字列でuserIdが決定され、loginイベントが発火し、ログイン通知がブロードキャストされます。 その後チャット欄に文字を入れてsendボタンを押せば、そのときブラウザを開いているユーザ全員に通知がいくようになっています。

このブロードキャスト処理の実態は、接続時にwsClientsに接続情報を保存しておき、ループでクライアント全員にemitを送っているだけなので、接続数が増えると遅延なども出てきそうです。