跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

ColdFusion 学习笔记③

实战实现一个 Ajax 学生搜索功能

BY 稻, 草人

前两篇分别整理了:

Application.cfc
CFM
CFC
Ajax
remote
JSON

这一篇不再单独讲概念。

直接做一个完整功能:

在学生管理页面输入学生姓名,点击搜索,在不刷新页面的情况下通过 Ajax 查询数据库,并显示结果。

最终结构:

student.cfm
    │
    │ Ajax
    ▼
StudentApi.cfc
    │
    ▼
student_master

同时加入实际业务系统中必须考虑的:

Session
权限
SQL Injection
XSS
异常处理
Loading
空数据
JSON
目次

1. 最终目录

假设项目:

/myapp
│
├─ Application.cfc
│
├─ student.cfm
│
│
└─ components/
    └─ StudentApi.cfc

数据库表:

student_master

2. 数据库设计

简单设计:

CREATE TABLE student_master (
    student_id INT PRIMARY KEY,
    student_name VARCHAR(100) NOT NULL,
    grade INT,
    class_no INT,
    student_number VARCHAR(20)
);

测试数据:

INSERT INTO student_master
(
    student_id,
    student_name,
    grade,
    class_no,
    student_number
)
VALUES
(
    1,
    '山田 太郎',
    1,
    1,
    '001'
),
(
    2,
    '山田 花子',
    2,
    3,
    '010'
),
(
    3,
    '佐藤 一郎',
    1,
    2,
    '002'
);

3. Application.cfc

首先建立:

Application.cfc

代码:

component {

    this.name =
        "ColdFusionStudyApp";

    this.sessionManagement =
        true;

    this.sessionTimeout =
        createTimeSpan(
            0,
            0,
            30,
            0
        );

    function onApplicationStart() {

        application.datasource =
            "mydb";

        return true;

    }

    function onSessionStart() {

        session.login = true;

        session.userId = 1;

        session.role = "ADMIN";

    }

}

这里为了学习方便:

session.login = true;

实际系统当然应该通过:

登录认证

之后再设置。

4. 为什么把 Datasource 放 Application?

这里:

application.datasource = "mydb";

之后 CFC:

datasource =
    application.datasource

即可。

这样避免整个项目到处写:

mydb

当然大型项目中还可以进一步使用:

Config
Environment Variable
Server Setting

管理。

5. StudentApi.cfc

建立:

components/StudentApi.cfc

完整代码:

component {

    remote struct function search(
        string keyword = ""
    )
    returnformat="json" {

        local.result = {
            success = false,
            message = "",
            data = []
        };

        try {

            if (
                !structKeyExists(
                    session,
                    "login"
                )
                ||
                !session.login
            ) {

                local.result.message =
                    "ログインしてください。";

                return local.result;

            }

            local.keyword =
                trim(arguments.keyword);

            if (
                len(local.keyword) > 100
            ) {

                local.result.message =
                    "検索条件が長すぎます。";

                return local.result;

            }

            local.qStudent =
                queryExecute(
                    "
                    SELECT
                        student_id,
                        student_name,
                        grade,
                        class_no,
                        student_number
                    FROM
                        student_master
                    WHERE
                        student_name
                        LIKE :keyword
                    ORDER BY
                        grade ASC,
                        class_no ASC,
                        student_number ASC
                    ",
                    {
                        keyword = {
                            value =
                                "%" &
                                local.keyword &
                                "%",
                            cfsqltype =
                                "cf_sql_varchar"
                        }
                    },
                    {
                        datasource =
                            application.datasource
                    }
                );

            for (
                local.student
                in
                local.qStudent
            ) {

                arrayAppend(
                    local.result.data,
                    {
                        studentId =
                            local.student.student_id,

                        studentName =
                            local.student.student_name,

                        grade =
                            local.student.grade,

                        classNo =
                            local.student.class_no,

                        studentNumber =
                            local.student.student_number
                    }
                );

            }

            local.result.success = true;

        }
        catch (any e) {

            local.result.success = false;

            local.result.message =
                "検索処理中にエラーが発生しました。";

            writeLog(
                file = "student",
                type = "error",
                text =
                    e.message &
                    " / " &
                    e.detail
            );

        }

        return local.result;

    }

}

6. 为什么不用 SELECT *

这里故意不写:

SELECT *

而写:

SELECT
    student_id,
    student_name,
    grade,
    class_no,
    student_number

原因有几个。

第一:

明确知道程序使用哪些字段

第二:

表增加字段时不会自动全部返回

第三:

减少不必要数据

第四:

代码 Review 更容易

业务系统里我越来越倾向于明确写字段。

7. 为什么使用 cfqueryparam / 参数绑定?

错误写法:

WHERE
    student_name LIKE
    '%#arguments.keyword#%'

如果输入:

' OR 1=1 --

可能改变 SQL 结构。

所以应该:

WHERE
    student_name LIKE :keyword

参数:

{
    keyword = {
        value =
            "%" &
            local.keyword &
            "%",
        cfsqltype =
            "cf_sql_varchar"
    }
}

这样:

SQL

和:

Data

分开处理。

8. student.cfm

接下来制作页面。

<!DOCTYPE html>

<html lang="ja">

<head>

    <meta charset="UTF-8">

    <title>
        学生検索
    </title>

</head>

<body>

    <h1>
        学生検索
    </h1>

    <div>

        <input
            type="text"
            id="keyword"
            placeholder="学生名"
        >

        <button
            type="button"
            id="searchButton"
        >
            検索
        </button>

    </div>

    <div
        id="loading"
        hidden
    >
        Loading...
    </div>

    <div
        id="message"
    >
    </div>

    <table
        border="1"
        id="resultTable"
    >

        <thead>

            <tr>

                <th>
                    学年
                </th>

                <th>
                    組
                </th>

                <th>
                    番号
                </th>

                <th>
                    氏名
                </th>

            </tr>

        </thead>

        <tbody>
        </tbody>

    </table>

<script>

const keyword =
    document.querySelector(
        "#keyword"
    );

const searchButton =
    document.querySelector(
        "#searchButton"
    );

const loading =
    document.querySelector(
        "#loading"
    );

const message =
    document.querySelector(
        "#message"
    );

const tbody =
    document.querySelector(
        "#resultTable tbody"
    );

searchButton.addEventListener(
    "click",
    searchStudents
);

async function searchStudents() {

    loading.hidden = false;

    message.textContent = "";

    tbody.innerHTML = "";

    try {

        const response =
            await fetch(
                "components/StudentApi.cfc",
                {
                    method: "POST",

                    headers: {
                        "Content-Type":
                            "application/x-www-form-urlencoded"
                    },

                    body:
                        new URLSearchParams({
                            method:
                                "search",

                            keyword:
                                keyword.value
                        })
                }
            );

        if (!response.ok) {

            throw new Error(
                "HTTP Error: "
                +
                response.status
            );

        }

        const result =
            await response.json();

        if (!result.success) {

            message.textContent =
                result.message;

            return;

        }

        if (
            result.data.length === 0
        ) {

            message.textContent =
                "該当する学生はいません。";

            return;

        }

        renderStudents(
            result.data
        );

    }
    catch (error) {

        console.error(error);

        message.textContent =
            "通信エラーが発生しました。";

    }
    finally {

        loading.hidden = true;

    }

}

function renderStudents(
    students
) {

    for (
        const student
        of
        students
    ) {

        const tr =
            document.createElement(
                "tr"
            );

        appendCell(
            tr,
            student.grade
        );

        appendCell(
            tr,
            student.classNo
        );

        appendCell(
            tr,
            student.studentNumber
        );

        appendCell(
            tr,
            student.studentName
        );

        tbody.appendChild(tr);

    }

}

function appendCell(
    tr,
    value
) {

    const td =
        document.createElement(
            "td"
        );

    td.textContent =
        value ?? "";

    tr.appendChild(td);

}

</script>

</body>

</html>

9. 点击按钮之后发生什么?

用户点击:

検索

JavaScript:

searchStudents()

执行:

① Loading 显示

② 清空旧结果

③ fetch()

④ POST StudentApi.cfc

⑤ CFC search()

⑥ SQL

⑦ JSON

⑧ response.json()

⑨ renderStudents()

⑩ Loading 隐藏

10. Request 实际长什么样?

浏览器发送:

POST /components/StudentApi.cfc

Body:

method=search&keyword=山田

ColdFusion:

method = search

于是执行:

search()

参数:

keyword = 山田

于是:

arguments.keyword

就是:

山田

11. 为什么不用 innerHTML 插入学生姓名?

很多代码会写:

tbody.innerHTML += `
    <tr>
        <td>${student.studentName}</td>
    </tr>
`;

如果数据库里恶意数据是:

<script>
alert(1)
</script>

就可能造成 XSS。

这一篇故意使用:

td.textContent = value;

浏览器会把内容当:

文字

而不是 HTML。

所以:

textContent

一般比直接拼 innerHTML 安全。

12. ColdFusion 页面输出也一样

CFM 中如果输出:

<cfoutput>
    #student_name#
</cfoutput>

数据库内容如果包含:

<script>

也可能产生 XSS。

应该:

<cfoutput>
    #EncodeForHTML(student_name)#
</cfoutput>

HTML 属性:

value="#EncodeForHTMLAttribute(value)#"

JavaScript Context:

EncodeForJavaScript()

URL:

EncodeForURL()

不能所有地方无脑使用同一种 Encode。

13. Loading 为什么需要 finally?

代码:

finally {

    loading.hidden = true;

}

这是一个非常实用的写法。

如果只:

loading.hidden = false;

await fetch(...);

loading.hidden = true;

一旦:

fetch()

抛异常:

loading.hidden = true

可能根本执行不到。

结果页面一直:

Loading...

而:

finally

无论:

成功
失败
异常

都会执行。

14. 为什么 CFC catch 里不要直接把异常全部返回前端?

例如不要:

catch (any e) {

    return {
        message = e.detail
    };

}

因为 e.detail 可能包含:

SQL
数据库表名
文件路径
服务器路径
Stack Trace
内部结构

这些都不应该暴露给用户。

正确思路:

用户
↓
通用错误消息
Log
↓
详细异常

所以:

writeLog(
    file = "student",
    type = "error",
    text =
        e.message &
        " / " &
        e.detail
);

用户只看到:

検索処理中にエラーが発生しました。

15. 权限检查应该放在哪里?

最重要的原则:

不要只在 CFM 隐藏按钮

例如:

<cfif session.role == "ADMIN">

    <button>
        Delete
    </button>

</cfif>

这只能保证:

普通用户看不到按钮

不能阻止:

直接调用 CFC

所以 CFC 仍然必须:

if (
    session.role != "ADMIN"
) {

    return {
        success = false,
        message =
            "権限がありません。"
    };

}

16. CFM 验证和 CFC 验证的关系

可以两边都做。

前端:

if (
    keyword.value.length > 100
) {

    alert(
        "100文字以内で入力してください。"
    );

    return;

}

这是:

UX

后端:

if (
    len(local.keyword) > 100
) {

    return ...
}

这是:

Security / Data Validation

前端验证:

让正常用户更方便

后端验证:

防止恶意请求

二者不是替代关系。

17. 排序时还需要注意数据类型

例如:

student_number

数据库定义:

VARCHAR

数据:

1
2
10
11

如果直接:

ORDER BY student_number

有可能结果:

1
10
11
2

因为数据库把它当:

字符串

而不是:

数字

如果业务要求数字排序,需要根据数据库进行:

ORDER BY CAST(student_number AS UNSIGNED)

或者从数据模型层面重新考虑:

这个字段究竟应该是字符串还是数字。

例如:

001
002
010

如果前导零有业务意义,那它本身就更像字符串。

所以不能只看到:

排序不对

就马上 CAST。

还需要先确认数据定义和业务含义。

18. 如果使用 jQuery

同一个功能也可以:

$("#searchButton").on(
    "click",
    function() {

        $("#loading").show();

        $.ajax({
            url:
                "components/StudentApi.cfc",

            type:
                "POST",

            dataType:
                "json",

            data: {
                method:
                    "search",

                keyword:
                    $("#keyword").val()
            }
        })
        .done(function(result) {

            console.log(result);

        })
        .fail(function(xhr) {

            console.error(
                xhr.responseText
            );

        })
        .always(function() {

            $("#loading").hide();

        });

    }
);

对应关系:

fetch try
=
$.done
catch
=
$.fail
finally
=
$.always

19. 真正工作项目里我会进一步拆层

现在:

StudentApi.cfc
↓
SQL

学习阶段没问题。

但是项目变大以后可以继续拆:

StudentApi.cfc
        │
        ▼
StudentService.cfc
        │
        ▼
StudentDao.cfc
        │
        ▼
Database

例如:

API

负责:

HTTP
参数
Session
JSON

Service:

业务规则

DAO:

SQL

这样就会越来越接近其他现代 Web Framework 的结构。

20. 最后的完整流程

最终一个学生搜索请求:

用户输入 山田
      │
      ▼
student.cfm
      │
      ▼
JavaScript
      │
 POST Ajax
      │
      ▼
StudentApi.cfc
      │
      ├─ Session Check
      ├─ Parameter Check
      │
      ▼
queryExecute()
      │
      ▼
student_master
      │
      ▼
Query
      │
      ▼
Array + Struct
      │
      ▼
JSON
      │
      ▼
Browser
      │
      ▼
renderStudents()
      │
      ▼
Table

到这里:

CFM
CFC
Application.cfc
Session
Ajax
JSON
SQL
XSS
Error Handling

这些 ColdFusion Web 开发最基础的知识点,就全部串起来了。