2016/11/30

Docker On Windows

enter image description here

前言

我的工作環境是 Windows,但很習慣使用 command line 工作,雖然 Windows 有一些工具可以模擬,像是 Cygwin,或者是 cmder,但都只能說堪用而已,所以我習慣在 Windows 上安裝 Vagrant 當作工作環境,久仰 docker 大名,最近花了點時間研究,今天這篇文章會一個一個步驟帶到怎麼在 Windows 使用 docker toolbox 安裝一個 PHP7 container by ubuntu xenail 連結另一個 mysql container

開始 Docker 之旅

首先去 docker tool 下載頁面,下載 Windows 安裝檔,之後便是無窮的下一步,這邊介紹一下這個工具的運作方式,他會使用 docker-machine 的 vm 技術透過 virtualbox build 一個虛擬環境讓你跑 docker command,所以安裝成功後會有 Docker Quickstart Terminal 可以用,但他就跟其他模擬 bash 環境的軟體一樣只是堪用而已,所以我打算使用 xshell 去走這個 demo,也可以使用 putty

安裝完成後 docker vm 預設的 ip 是 192.168.99.100,ssh 192.168.99.100 以後會問你帳密,分別是 docker 以及 tcuser,進入以後我們就可以使用所有的 docker command,我今天要使用 ubuntumysql/mysql-server 這兩個 container,當然一定也有 build 好的 lamp image,但我打算 step by step 紀錄一下,至於 docker 的一些基礎命令我不會多做解釋,可以去 Docker —— 從入門到實踐 看一下,我們先 pull 一下

$ docker pull ubuntu
$ docker pull mysql/mysql-server

$ docker images

REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
ubuntu               latest              4ca3a192ff2a        12 hours ago        128.2 MB
mysql/mysql-server   latest              6272a6121ff6        7 days ago          316.2 MB

已經順利將這兩個 container 抓下來,先來設定 database

$ docker run --name db1 -e MYSQL_ROOT_PASSWORD=123456 -d mysql/mysql-server
$ docker ps

CONTAINER ID        IMAGE                COMMAND                  CREATED             STATUS              PORTS                 NAMES
dfc880ed429d        mysql/mysql-server   "/entrypoint.sh mysql"   18 seconds ago      Up 17 seconds       3306/tcp, 33060/tcp   db1

上述指令建立了一個名叫 db1 的 mysql server,root 密碼為 123456,但預設帳號只有 root@local,所以我們必須建一個 root@% 的帳號來使用,順便建立一個資料表來測試

$ docker exec -it db1 mysql -uroot -p123456
mysql> GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456' WITH GRANT OPTION;
create database test;

mysql> use test;

mysql> CREATE TABLE `tests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(50) NOT NULL
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;

mysql> INSERT INTO `tests` (`name`) VALUES ('a'), ('b');

mysql> exit;

到此 mysql-server 的部份就完成了,現在來進行 web 的部份

$ docker run -td --name php7 --link db1:db1 ubuntu /bin/bash
$ docker ps

CONTAINER ID        IMAGE                COMMAND                  CREATED             STATUS              PORTS                 NAMES
2c52f1c58edb        ubuntu               "/bin/bash"              12 seconds ago      Up 11 seconds                             php7
dfc880ed429d        mysql/mysql-server   "/entrypoint.sh mysql"   10 minutes ago      Up 10 minutes       3306/tcp, 33060/tcp   db1

這邊順利的建立了 php7 這個 container,並且將其 link 到 db1,接著要一連串的安裝 php7 並且寫一個 pdo 測試連線的流程

$ docker exec -it php7 bash

$ env

# 擷取重點
DB1_PORT_3306_TCP_ADDR=172.17.0.2
DB1_PORT_3306_TCP_PROTO=tcp
SHLVL=1
HOME=/root
DB1_ENV_MYSQL_ROOT_PASSWORD=123456
DB1_PORT=tcp://172.17.0.2:3306
DB1_PORT_33060_TCP_PROTO=tcp
DB1_NAME=/php7/db1

$ apt-get udpate
$ apt-get install php php-mysql vim

$ vim test.php

test.php 的內容如下

<?php

$ip = getenv('DB1_PORT_33060_TCP_ADDR');
$dsn = "mysql:host=${ip};dbname=test";
$db = new PDO($dsn, 'root', 123456);
$sql = "SELECT * FROM `tests`";

foreach ($db->query($sql) as $value) {
    var_dump($value);
}

接著我們執行看看

$ php test.php

array(4) {
  ["id"]=>
  string(1) "1"
  [0]=>
  string(1) "1"
  ["name"]=>
  string(1) "a"
  [1]=>
  string(1) "a"
}
array(4) {
  ["id"]=>
  string(1) "2"
  [0]=>
  string(1) "2"
  ["name"]=>
  string(1) "b"
  [1]=>
  string(1) "b"
}

這樣就大功告成啦

docker-machine

docker 一次只能 bind 一個對外的 80 port,如果要實現不同 domain name 不帶 port 去不同的 container 這種作法,前端要架設一個 nginx 去設定導向,這邊提供另一個偷懶的方法,docker-machine 本身用的就是 vm,所以我們可以開另一個 vm,這時候就得進到 docker terminal,執行以下指令

$ docker-machine create -d virtualbox chan

接著執行

$ docker-machine ls

我們會看到

NAME      ACTIVE   DRIVER       STATE     URL                         SWARM   DOCKER    ERRORS
chan      -        virtualbox   Running   tcp://192.168.99.101:2376           v1.12.3
default   *        virtualbox   Running   tcp://192.168.99.100:2376           v1.12.3

成功的建立了 chan 這個 vm,我們可以使用 ssh 進去 192.168.99.101,如果你在 docker terminal 要切換環境只要執行下方指令即可,然後把 lamp 的環境做在這個 ip 裡面,並且在本機設好 hosts 連過來即可

$ eval $(docker-machine env chan)

可以將自製的 web image 拷貝出來並寫成 Dockerfile,這樣開一個 vm 並且建一個新環境非常的快,如果只是測試環境不在意 port 的話就用不同的 port 去導向每個 container,或者是要用哪個 case 就 start 哪個 container 也是 ok 的,如果你跟我一樣重開機或關機會把軟體都關乾乾淨淨的話,可以在 docker toolbox terminal 下指令

$ docker-machine stop default

2016/11/20

Gulp Watch And Babel

目前我常使用 webpack 來編譯 JavaScript 檔案,包含 babel 的編譯,然而有時候我們可能只想寫小東西,不需要模組化,但又想要自動化偵測並且使用 babel 編譯的話該怎麼做,作法很多,我這邊採用的方式是使用 gulp-watch 來監聽檔案變動,偵測到改變以後便自動編譯

gulp-babel

首先是安裝必要的套件

npm i --save-dev gulp-watch gulp-babel

gulp 因為太常用本身我安裝在 global 了,今天的結構是要監聽 assets/js/src/ 下的所有目錄,偵測到變動後 compile 到 assets/js/build

gulpfile.js
const gulp = require('gulp');
const watch = require('gulp-watch');
const babel = require('gulp-babel');

gulp.task('babel', function() {
    watch('assets/js/src/**/*', function() {
        gulp.src('assets/js/src/**/*')
            .pipe(babel({
                presets: ['es2015']
            }))
            .pipe(gulp.dest('assets/js/build/'))
            .on('end', function() {
                console.log('file compiled!');
            });
    });
});

執行 gulp babel便會開啟監聽,當 compiled 完成後會 echo file compiled

babel-cli

這邊提供另一個方法,因為 babel 本身就有 cli 可以使用,所以我們可以監聽後搭配 child_process 的 exec 來執行 scripts

安裝 babel-cli
npm i -g babel-cli
增加 package.json scripts 指令
"scripts": {
    "babel": "babel assets/js/src/ -d assets/js/build/ --presets es2015"
}
gulpfile.js
const gulp = require('gulp');
const watch = require('gulp-watch');
const exec = require('child_process').exec;

gulp.task('babel', function() {
    watch('assets/js/src/**/*', function() {
        exec('npm run babel', function() {
            console.log('file complied');
        });
    });
});

2016/11/17

Query Log

sql 存取 log 可以用來查尋所有的操作軌跡,這邊使用 codeigniter 的 hooks,來達成自動記錄 query 的功能。

資料庫

CREATE TABLE `query_logs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`type` CHAR(10) NOT NULL,
`location` VARCHAR(255) NOT NULL,
`content` TEXT NOT NULL,
`admin_id` INT NOT NULL,
INDEX(`admin_id`),
`created_at` DATETIME NOT NULL
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;

type 用來記錄 sql 的處理方式,例如說是 update 還是 delete,content 的部分存整句的語法,admin_id 是我後台的管理者 id,我這邊的範例只打算記錄後台操作 insert, update, delete 三種行為,所以我用 admin_id 當作是否要記錄的依據,如果要做更細的設定可以再行配置。

設定

application/config/autoload.php
$autoload['libraries'] = array('database', 'session');
$autoload['config'] = array('site');
application/config/hooks.php
$hook['post_controller'] = [
    'class' => 'QueryLogger',
    'function' => 'save',
    'filename' => 'QueryLogger.php',
    'filepath' => 'hooks',
    'params' => ''
];
application/config/site.php
$config['query_types'] = [
    'insert',
    'update',
    'delete'
];

我目前只紀錄了這三個事件,如果有其他需求可以透過 config 增減

Hook 功能

application/hooks/QueryLogger.php
<?php

if (!defined('BASEPATH')) exit('No direct script access allowed');

class QueryLogger
{
    private $ci;
    private $types;

    public function __construct()
    {
        $this->ci =& get_instance();
        $this->types = $this->ci->config->item('query_types');
    }

    public function save()
    {
        if ($this->can_saved()) {
            foreach ($this->ci->db->queries as $query) {
                $query = str_replace("\n", '', $query);
                $type = $this->get_type($query);

                if ($type !== '') {
                    $this->ci->db->insert('query_logs', [
                        'type' => $type,
                        'content' => $query,
                        'location' => $this->ci->router->fetch_class().'/'.$this->ci->router->fetch_method(),
                        'admin_id' => $this->ci->session->admin_id,
                        'created_at' => date('Y-m-d H:i:s')
                    ]);
                }
            }
        }

    }

    public function can_saved()
    {
        if (count($this->ci->db->queries) > 0 && $this->ci->session->has_userdata('admin_id')) {
            return true;
        }

        return false;
    }

    public function get_type($query)
    {
        return implode('', array_filter($this->types, function ($type) use ($query) {
            return stristr($query, $type);
        }));
    }
}

2016/11/11

First article from classeur

Blogger 是我一直以來使用的 blog 軟體,之前的撰寫模式是在本機 markdown 編輯後,匯出成 html 貼上 blogger,然後 markdown 的原文再存到 dropbox 或 evernote,因此假設我要修正錯字,必須修改雙邊,沒辦法,blogger 用習慣了,但他不支援 makdown,今天爬文找到了 stackedit 可以 sync 到常用的空間如 dropbox 或 Google drive,還可以直接 publish 到 blogger,索性就試用了一下,但他的 ux 其實很不直覺,編輯時還卡卡的,而且他有個超連結連到我現在正在使用的 classeur,我懷疑那個專案沒在 maintain 了…

轉過來這邊以後使用上真的舒服多了,除了 preview 有時候會卡卡的以外,起碼使用界面跟編輯工具還算順暢,publish 也還蠻快的,之後應該就會使用這套來撰寫了,免費方案可以放置 100 則文章,等期滿還算滿意的話再付月費。

順帶提一下,最近迷上 wiki,自己架了一個來玩,但不知道要寫什麼 XD

My Wiki

2016/11/04

Codeigniter image_helper

前言

今天介紹兩個縮圖的 helper,使用的套件是 ImageWorkshop,以下面這兩張圖片解說。

500x300

300x500

程式碼

application/contollers/Image.php
<?php

class Image extends CI_Controller
{
    public function index()
    {
        $this->load->helper('image_helper');

        $this->load->view('image');
    }
}
application/helpers/image_helper.php
<?php

use PHPImageWorkshop\ImageWorkshop;

if (!function_exists('fit_thumb')) {
    function fit_thumb($path, $width = 150, $height = 0)
    {
        if (!file_exists($path)) {
            return 'assets/imgs/file_not_exists.png';
        }

        if ($height == 0) {
            $height = $ratio = $width;
        } else {
            $ratio = ($width < $height) ? $height : $width;
        }

        $paths = pathinfo($path);
        $thumbName = "{$paths['filename']}_fit_{$width}x{$height}.{$paths['extension']}";
        $thumbPath = $paths['dirname'].'/thumb/';
        $fullThumbPath = $thumbPath.$thumbName;

        if (file_exists($fullThumbPath)) {
            return $fullThumbPath;
        }

        $layer = ImageWorkshop::initFromPath($path, true);
        $layer->resizeByLargestSideInPixel($ratio, true);

        $layer->cropInPixel($width, $height, 0, 0, 'MT');
        $layer->save($thumbPath, $thumbName);

        return $fullThumbPath;
    }
}

if (!function_exists('thumb')) {
    function thumb($path, $ratio = 150, $bySide = 'large')
    {
        if (!file_exists($path)) {
            return 'assets/imgs/file_not_exists.png';
        }

        $paths = pathinfo($path);

        switch ($bySide) {
            case 'large':
                $thumbName = "{$paths['filename']}_thumb_large_{$ratio}.{$paths['extension']}";
                break;
            case 'narrow':
                $thumbName = "{$paths['filename']}_thumb_narrow_{$ratio}.{$paths['extension']}";
                break;
        }

        $thumbPath = $paths['dirname'].'/thumb/';
        $fullThumbPath = $thumbPath.$thumbName;

        if (file_exists($fullThumbPath)) {
            return $fullThumbPath;
        }

        $layer = ImageWorkshop::initFromPath($path, true);

        switch ($bySide) {
            case 'large':
                $layer->resizeByLargestSideInPixel($ratio, true);
                break;
            case 'narrow':
                $layer->resizeByNarrowSideInPixel($ratio, true);
                break;
        }

        $layer->save($thumbPath, $thumbName);

        return $fullThumbPath;
    }
}

if (!function_exists('img_upload')) {
    function img_upload($path = '/', $image = '', $maxWidth = 3000)
    {
        $status = 'done';
        $message = '';
        $allows = ['jpeg', 'png', 'gif'];
        $file = $_FILES[$image];
        $fileName = $file['name'];
        $ext = pathinfo($fileName, PATHINFO_EXTENSION);
        $newName = date('YmdHis').rand(1000, 9999).'.'.$ext;
        $map = function ($item) {
            return "image/{$item}";
        };

        try {
            if (empty($fileName)) {
                throw new Exception('請選擇檔案');
            }

            $type = $file['type'];
            $tmpName = $file['tmp_name'];

            if (!in_array($type, array_map($map, $allows))) {
                throw new Exception('檔案格式錯誤,僅接受'.implode(', ', $allows));
            }

            $layer = ImageWorkshop::initFromPath($tmpName, true);
            list($width, $height) = getimagesize($tmpName);

            if ($width > $maxWidth) {
                $layer->resizeInPixel($maxWidth, null, true);
            }

            $layer->save($path, $newName);
        } catch (Exception $e) {
            $status = 'fail';
            $message = $e->getMessage();
        }

        return [
            'status' => $status,
            'image' => $newName,
            'message' => $message,
        ];
    }
}

if (!function_exists('image_delete')) {
    function image_delete($path, $image)
    {
        $path = rtrim($path, '/').'/';
        @unlink($path.$image);
        $fileName = pathinfo($image, PATHINFO_FILENAME);
        $thumbPath = $path.'thumb/';
        $thumbs = directory_map($thumbPath);

        foreach ($thumbs as $thumb) {
            $body = explode('_', $thumb)[0];

            if ($body === $fileName) {
                @unlink($thumbPath.$thumb);
            }
        }
    }
}
application/views/image.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Image Helper</title>
</head>
<body>
    <h3>500x300</h3>
    <img src="<?php echo $landscape; ?>">
    <h3>300x500</h3>
    <img src="<?php echo $portrait; ?>">

    <h3>fit_thumb</h3>
    <img src="<?php echo fit_thumb($landscape, 150); ?>">
    <img src="<?php echo fit_thumb($landscape, 150, 200); ?>">
    <img src="<?php echo fit_thumb($portrait, 150); ?>">
    <img src="<?php echo fit_thumb($portrait, 200, 150); ?>">

    <h3>thumb</h3>
    <img src="<?php echo thumb($landscape, 150); ?>">
    <img src="<?php echo thumb($landscape, 150, 'narrow'); ?>">
    <img src="<?php echo thumb($portrait, 150); ?>">
    <img src="<?php echo thumb($portrait, 150, 'narrow'); ?>">
</body>
</html>

Demo

fit_thumb

fit_thumb 如果沒有帶入第三個參數便會擷取該圖形為正方形,如果有帶入的話便會照比例擷取。

500x300

fit_thumb($landscape, 150)

150x150

fit_thumb($landscape, 150, 200)

150x200

300x500

fit_thumb($portrait, 150)

150x150

fit_thumb($portrait, 200, 150)

200x150

thumb

thumb 功能會依照傳入的比例參數將圖片依照參數等比例縮放,第二個參數可以決定從寬邊還是窄邊做縮放

500x300

thumb($landscape, 150)

150x90

thumb($landscape, 150, 'narrow')

250x150

300x500

thumb($portrait, 150)

90x150

thumb($portrait, 150, 'narrow')

150x250

img_upload

img_upload 這個 function 可以將你上傳的圖片縮圖後存入,一般來說網頁會顧慮的是寬不要破壞,因此程式碼會以寬度為主來做縮圖。

2016/11/01

Codeigniter file_manager helper

檔案上傳是蠻常使用的功能,ci 裡面有內建檔案上傳的功能,但我們當然不想記得繁複的呼叫方式,寫成 helper 來使用是最方便的,今天 news 當作範例示範一下寫法。

news table
CREATE TABLE `news` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(50) NOT NULL,
`file_name` VARCHAR(100) NOT NULL,
`real_name` VARCHAR(100) NOT NULL
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
application/controllers/News.php
class News extends CI_Controller
{
    public function index()
    {
        $this->load->helper('form');
        $this->load->view('news');
    }

    public function save()
    {
        $this->load->database();
        $this->load->helper('file_manager');
        $data = [];

        if (has_upload('file')) {
            $upload = file_upload('file', 'assets/uploads/news');

            if ($upload['status'] == 'fail') {
                die($upload['message']);
            } else {
                $data['file_name'] = $upload['fileName'];
                $data['real_name'] = $upload['realName'];
            }
        }

        $data['name'] = uniqid();
        $this->db->insert('news', $data);
    }

    public function download($id)
    {
        $this->load->database();
        $this->load->helper('file_manager');
        $row = $this->db->get_where('news', ['id' => $id])->row_array();

        force_download("assets/uploads/news/{$row['file_name']}", $row['real_name']);
    }
}

index method 就是簡單的建立一個表單,傳送 name 跟 file 這兩的 field 到 save 儲存,這邊做的都是最簡單的示範,實際情況還是資照需求去修改,但回傳的內容應該都可以應付其他的條件,download 的部份就是調用 file_manager helper 下載原始的檔案,若非必要,盡量不要在 helper 裡面做 db connection,因為 helper 很有可能會在迴圈裡面出現,這樣 db query 數量會隨資料量增加。

application/views/news.php
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>News</title>
    </head>
    <body>
        <?php echo form_open_multipart('news/save'); ?>
        <?php echo form_input('name', uniqid()); ?>
        <?php echo form_upload('file'); ?>
        <?php echo form_submit('go', 'go'); ?>
        <?php echo form_close(); ?>
    </body>
</html>

news 的 view,簡單的使用 form helper 生成。

application/helpers/file_manager_helper.php
if (!function_exists('file_upload')) {
    function file_upload($file, $path, array $allows = [])
    {
        $ci =& get_instance();
        $status = 'done';
        $message = '';
        $fileName = '';
        $realName = '';
        $config['upload_path'] = $path;
        $config['allowed_types'] = (count($allows) > 0) ? implode('|', $allows) : '*';
        $config['encrypt_name'] = true;
        $ci->load->library('upload', $config);

        if (!$ci->upload->do_upload($file)) {
            $message = $ci->upload->display_errors();
            $status = 'fail';
        } else {
            $fileData = $ci->upload->data();
            $fileName = $fileData['file_name'];
            $realName = $fileData['client_name'];
        }

        return compact('status', 'message', 'fileName', 'realName');
    }
}

if (!function_exists('force_download')) {
    function force_download($path, $realName)
    {
        $originFile = urlencode($realName);
        $realFile = $path;

        header("Pragma: public");
        header("Expires: 0");
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
        header("Content-Type: application/force-download");
        header("Content-Disposition: attachment; filename=".$originFile);
        header("Content-Description: File Transfer");
        @readfile($realFile);
    }
}

if (!function_exists('has_upload')) {
    function has_upload($file)
    {
        if (isset($_FILES[$file]) && is_uploaded_file($_FILES[$file]['tmp_name'])) {
            return true;
        }

        return false;
    }
}

file_upload function 調用了 upload 的 library,因為主要是傳檔案,所以預設 allows 為全部,當然也有先寫好參數可以傳遞,印象中 codeigniter 的上傳會檢查 mime,所以 allow 的內容可能要符合 mime 的檔名規範,如果要上傳的是圖片的東西,可以用這個 function 去改出一個 image_upload 的 function,再把跟圖片比較有關連的內容放進去,在上傳的時候我們會將檔名使用英文編碼代替,但原始的檔名會一起存到 database,在很多 Linux server 檔案名稱使用非英文的話會無法讀取,所以最好是用純英數去儲存檔案,因為 codeigniter 的 input helper 本身沒有支援 file 的內容,所以寫了一個 has_upload 的 function 來檢查這次的傳輸有沒有上傳檔案。

force_download 這個 function 只要將檔案位置跟原始檔名帶入,便可以下載原始的檔案,即便是中文也沒問題。